Repository navigation
Releases: iremlopsum/liaise
Release list
v5.3.1
Fixes an array passed as params, which was sent as an object with index keys. An array on a request whose params go in the query string is now refused; see MIGRATION.md.
Fixed
- An array as params was sent as an object with index keys.
defineRequest<Result, Item[]>()compiles, butapi.createItems([{ name: 'a' }, { name: 'b' }])sent the body{"0":{"name":"a"},"1":{"name":"b"}}, and an empty array sent no body at all. An array is now sent as a JSON array body,[{"name":"a"},{"name":"b"}], withContent-Type: application/json, and[]is sent as[]. - An array as params on a
GETorDELETEbuilt?0=a&1=b. It is now refused with a'network'error whoseTypeErrorsays to put the array in an object, such as{ ids: [...] }, and nothing is sent. The same goes for any request whose params go in the query string (bodyAs: 'query'). An array can't fill path params either, so a call to a path with params is refused, as it was before.
Documentation
- Sending data has a tested example of request bodies: a
PATCHwhose path param is left out of the body, a nested JSON body, and an array body sent to a path with a fixed query string. It says how to send a query value that changes per call alongside a body (a middleware that changesctx.request.url).
Sizes, gzipped, are unchanged: a REST-only import is 7.0 kB, and 9.4 kB with poll and pollUntil. The core entry is 11.3 kB, and 12.6 kB with all the middleware.
v5.3.0
Adds withHeaders(): a copy of a client that also sends some headers, such as a user's cookie on a server.
Added
withHeaders(client, headers, options?)returns a copy of a REST or GraphQL client whose calls also sendheaders. The original is unchanged. The headers act as client headers: an endpoint's own headers still win over them, and a call's headers win over everything. Copies chain, andgetHeaders()on a copy includes them. A copy's headers are read once, when the copy is made: changing the object you passed afterwards changes nothing. Everything else — middleware,onError,log,timeout,fetch,fetchOptions— is the client's, which is safe becauseshareand the cache compare the headers a call sends (withHeaders()).dedupeworks across copies that add the same headers, and where polls are shared (browsers, React Native) so do polls, however often a copy is rebuilt (a copy made inside a React component is rebuilt on every render), and copies that add different headers never cancel or share.{ dedupe: false }turnsdedupeoff for a copy, which suits a server.- It never throws. An invalid header value fails each call through the copy as a
'network'error. A single endpoint or a number is a type error. Something that isn't a client but looks like one, such as a plain object or a spread copy{ ...api }, gets a stand-in whose calls fail as'network'errors that say so, and so does hostile input such as a revoked Proxy or a throwing getter.
Documentation
- The recipe "One /me per page view on the server" uses a copy per page view instead of a per-call header.
Sizes, gzipped: a REST-only import is 7.0 kB (was 6.8 kB), and 9.4 kB with poll and pollUntil (was 9.2 kB). The core entry is 11.3 kB (was 10.6 kB), and 12.6 kB with all the middleware (was 12.0 kB). REST-only grew even for code that doesn't import withHeaders, because the state a copy needs is built into every client.
v5.2.1
Fixes a :name in a path being filled where the types said it couldn't be. Two path shapes that used to build a wrong URL are now refused; see MIGRATION.md.
Fixed
- A
:namein the middle of a path segment was filled. The types and the check for unfilled path params only count a:namethat starts a segment, but the URL was built by filling one anywhere:path: '/v1/documents:batchGet'called with{ batchGet: 'yes' }sent/v1/documentsyes. A:nameis now filled only where it starts a segment, so/v1/documents:batchGetand/time/12:30are sent as written. A call that passes a param named after a:namein the middle of a segment, such as/items/v:versionwith{ version: '2' }, is refused with a message that says to move the:nameto the start of a segment. Before, the value was filled in; without the refusal it would have gone to the query string or the body instead. - A
:namein a path's query string built a broken query.path: '/v2/simple/price?:qs'typed its params as{}, sent?:qsas text when called withoutqs, and withqsencoded the whole value into one parameter (price?ids%3Dbitcoin%26…). A:nameright after?,&or=is now refused on every call, anddefineRequestrefuses it at compile time. Query params go in the second type argument:defineRequest<Price, { ids: string }>()({ method: 'GET', path: '/v2/simple/price' }).
Both refusals return a 'network' error with a TypeError in error.body that says what to change, and nothing is sent (Colons in a path).
Documentation
- Handling errors said the message of every refused call ends in "so the call was not sent". Most don't; it now says the message names what liaise refused and what to change.
Sizes, gzipped: a REST-only import is 6.8 kB (was 6.5 kB), and 9.2 kB with poll and pollUntil (was 8.9 kB). The core entry is 10.6 kB (was 10.4 kB), and 12.0 kB with all the middleware (was 11.7 kB).
v5.2.0
Adds polling, fetchOptions for credentials, mode, cache and the rest of RequestInit, and a client's own fetch. cacheMiddleware now caches only GET and HEAD unless you say otherwise; see MIGRATION.md.
Added
pollandpollUntil(Polling). Both take any endpoint fromcreateApiorcreateGraphQL.poll(endpoint, params, callback, { every })asks at once, then againeveryms after each answer, and hands everyResultto the callback untilstop()or itssignal. Requests never overlap.pollUntil(endpoint, params, { every, until, giveUpAfter })resolves once and never rejects. It resolves with the first successuntilaccepts, an error that waiting can't fix (a 4xx other than 408 and 429, a GraphQL error,'parse','middleware'), a'timeout'aftergiveUpAfter, or an'abort'from itssignal.- A failure keeps polling, with longer waits. A
Retry-Afteron a 429 or a 503 is honoured. - In a browser, polling pauses while the tab is hidden (
inBackground: truekeeps it going), and callers asking one endpoint the same thing share one request loop. On a server each caller gets its own, because sharing is decided before your middleware adds a user's credentials.
fetchOptionsoncreateApi,createGraphQL, an endpoint orOperation, and a call (Cookies and other fetch options). It takescredentials,mode,cache,redirect,keepalive,priorityand anything elseRequestInithas, exceptmethod,headers,bodyandsignal, which liaise sets itself. Levels merge field by field, the most specific winning, and middleware reads and changes them asctx.request.fetchOptions. Calls whose options differ neversharea request. New type:FetchOptions.- A client's own
fetch:createApi({ fetch })andcreateGraphQL({ fetch }), for undici, a Cloudflare service binding (in a wrapper) ormockFetch().fetch. Without it, liaise uses the globalfetch, looked up on every call, as before. cacheMiddleware({ methods }): the methods it caches. The default is['GET', 'HEAD'].RecordedCall.initinliaise/testing: a copy of the initfetchreceived, so a test can checkcredentialsorkeepalive.
Changed
cacheMiddlewarecaches onlyGETandHEADby default. A call with any other method goes to the network and is never stored. Before, it cached any method, so a cachedPOSTanswered the next identical create with the first one's response. To keep caching a read sent as aPOST, such as a search or a GraphQL query, passmethods: ['POST'].- The cache key includes the
fetchOptionsand the client's ownfetch. An object in the options, such as an HTTP agent, is compared by identity, and two clients with differentfetchfunctions never share an entry. Clients on the globalfetchshare entries as before.
Documentation
From an outside review of the docs site:
- Handling errors explains that a call liaise refuses to send, such as one with an
undefinedpath param, comes back as'network'like being offline, and how to tell the two apart. GraphQL errors on a 2xx are listed under'http', and the exampleswitchis exhaustive. - Retries show how to retry only the methods that are safe to send twice.
- Quick start sets a
timeout, since there is no default, and callscreateUser. It also says liaise is ESM only, whichmoduleResolutionsettings work, and which TypeScript versions it is tested with. - Two recipes were wrong. The React hook showed the previous user after the
idchanged, and the per-attempt timeout replaced the caller's signal, so a cancel left the request running. - New notes:
Requesthides the Fetch API'sRequest, typing an error body, GraphQL types are written by hand, and what liaise doesn't do (streaming responses, server-sent events, progress, WebSockets). - The Compare page links to the site, and every size figure says which build it measures. The comparison was rerun on 5.2.0.
- Fixed: scrollbars in dark mode, tables on phones, and the playground showing a timed-out request as "cancelled".
Sizes, gzipped: a REST-only import is 6.5 kB (was 6.2 kB), and 8.9 kB with poll and pollUntil. The core entry is 10.4 kB (was 7.6 kB), and 11.7 kB with all the middleware (was 9.2 kB).
v5.1.2
Fixes responseType: 'none' calls that never settled on a cloned response, and moves the documentation to its own site.
Fixed
- A
responseType: 'none'call could hang for good. liaise cancels a body it doesn't read, and it waited for that cancel to finish. On a clonedResponsethe body is one branch of a tee, and its cancel doesn't settle while the other branch is unread, so the call never returned.mockFetchserves a static route as a clone, so a'none'endpoint against a static route hung in tests, and a fetch wrapper that clones responses would hang the same way. The cancel is now started without waiting; the body is still cancelled.
Documentation
- The docs moved to https://iremlopsum.github.io/liaise/. It has guides, recipes and a reference with search, a playground that runs liaise in the browser (REST and GraphQL), and a comparison page built from the measured results. The README is now a short front page.
- Fixed on the way (these were wrong in the 5.1.1 README):
- The GraphQL examples typed
dataas the selected object (Category). It is keyed by the root field ({ category: Category }), and anOperation'sschemavalidates that wholedataobject, so the schema example now wraps the inner schema. - When the URL couldn't be built,
error.request.urlisbaseUrlplus the path template, not the bare template. - Request overhead: liaise ties plain fetch one request at a time; with 50 requests in flight, plain fetch handles about 6% more requests per second.
- The GraphQL examples typed
No action needed to upgrade.
v5.1.1
Fixes a GraphQL client split into queries and mutations when a query and a mutation share a name.
Fixed
gql.query.xran the mutationx.createGraphQLmerged thequeriesandmutationsrecords by name, so a mutation replaced a query of the same name under bothgql.query.xandgql.mutation.x: the query sent the mutation's document and reported the mutation's headers. Each side is now built from its own record.- A query and a mutation with the same name shared one
dedupelane. A call to one could cancel an in-flight call to the other. They are now separate endpoints fordedupeandshare;requestName, as middleware and the logger see it, is still the plain name. - The share-and-dedupe check missed a query hidden by a same-named mutation.
createGraphQLnow checks each record on its own, so that configuration mistake is refused at construction as documented.
No action needed to upgrade: only clients that reused a name across queries and mutations behave differently, and they were not doing what their code said.
The core entry is 7.6 kB gzipped (was 7.5 kB); a REST-only import is unchanged at 6.2 kB.
v5.1.0
Fixes a security bug in share: on a server, one user's response could be handed to
another. Also adds GraphQL share, a timeout option, a log option and
getHeaders(). See MIGRATION.md.
Security
sharenow compares what is actually sent. It used to compare the call's
params and per-call options and ignore what middleware added. On a server, a shared
client whose middleware adds the current user's credentials (a cookie or an
Authorizationheader read from the request in flight) could therefore treat two
users' calls as the same call and hand one user's response to the other. That can
no longer happen: two calls share only when the method, the final URL, the headers
after middleware and the body are the same. Each caller runs its own middleware
and gets its ownResult; only the network request is shared.
Fixed
- A body that arrived whole is not reported as
'abort'. If the signal aborted
after the response body was fully received but before it parsed, and the parse then
failed, the call returnedkind: 'abort'. It now returnskind: 'parse'for a 2xx
response, andkind: 'http'withbody: nullfor a non-2xx one.
Added
- GraphQL
shareon anOperation, with the same semantics as REST.share
anddedupeon one operation throw atcreateGraphQL, as they do atcreateApi. timeoutoncreateApiandcreateGraphQL. There is no default. The first
defined level wins: call, then endpoint (or operation), then client.0opts out
and stops the fallback.logoption on both clients. Off by default.log: trueprints a line around
each call;{ enabled, data }turns it off withenabled: falseand prints the
data withdata: true. It wraps the whole call, including a call a deadline ends,
and a call that joined a shared request is tagged, shared. It is not a
middleware.logMiddleware({ enabled, data }).middleware: [logMiddleware]works
unchanged.getHeaders()on every generated method, REST and GraphQL, including the
query and mutation split. It returns configuration headers only: the client's merged with the endpoint's, the
endpoint winning, names lowercase. Per-call headers, headers a middleware adds and the
derivedContent-Typeare not included. An invalid configured header gives{}.- New type exports:
LogOptions(core) andLogMiddleware(liaise/middleware).
Changed
- Middleware runs for every caller that joins a shared request. A middleware
with a side effect now counts once per caller, not once per network request. logMiddlewareprints a line pair per caller on a shared endpoint, tagged
, shared.- Calls with identical per-call headers or middleware now share. Before, they
never did. Per-call headers that differ still do not share. retry()on a shared result uses the caller's own options, not the first
caller's.- Each sharer gets its own copy of JSON and text data. Code that relied on two
sharers receiving the same object (a === b) now seesfalse. ABlob,
ArrayBufferorFormDatais still one object. - Calls whose per-call headers differ only in a tracing header now share, sending
the first caller's values. The tracing headers aretraceparent,tracestate,
baggage,sentry-trace,x-request-idandx-correlation-id. - Params that used to decline sharing are compared as sent. A
BigInt, an object
with hidden state, a nestedMaporSetandFormDataparams share when the
bytes sent are identical. AFormData,Blob,ArrayBuffer, typed array,
DataViewor stream body never shares. - JSON bodies and GraphQL variables with the same keys in a different order no
longer share, because they are different bytes. onErrorruns once per failed shared request. A shared request that hangs until
the endpoint or client deadline reports once, and a later, different failure reports
too. A per-call timeout or abort stays the caller's own.- A hung middleware under
sharereports once per caller, since each caller runs
its own pipeline. - Under
share, a per-call timeout bounds that caller's own pipeline and beats the
endpoint's for that caller. The endpoint or client timeout bounds the shared request
from when it was sent. - A middleware that replaces
ctx.request.signal, undershare: the caller's own
signal and the installed one each release only that caller. The shared request
listens only to its refcount and its own deadline.
v5.0.3
A documentation release. Nothing in the package's behaviour changes. See
MIGRATION.md.
Documentation
- The README is reorganised around what you need first. It now runs from the
problem, to a quick start, a guide, tested recipes and a comparison, and ends with
a complete reference. Every recipe in it runs as a test in CI, and
npm run docs:checkfails if the README copy drifts from that test. src/types.tscomments corrected. More than ten stale or wrong comments,
including the claim that a raw string param is never shared, and a comparison of
timeouts with axios, XHR and got that the project cannot support, which is removed.
Fixed
- The
shareoption's type documentation no longer lists signal-replacing
middleware as a known limitation. It was fixed in 3.0.0 (the shared signal is
re-merged when a middleware replacesctx.request.signal), and an existing test
pins it.
Added (repository only, not in the package)
compare/, a rerunnable comparison of fetch, axios, ky, ofetch and liaise. The
README's comparison section is generated from it.npm run docs:check, which fails when a README recipe drifts from its test or an
in-page link has no heading.- Tests for behaviour the README states: a throwing middleware gives
kind: 'middleware'withstatus: 0; a caller's cancel still ends the call as
'abort'when a middleware replacesctx.request.signal, without aborting the
request itself; a header added by middleware is not part of thesharekey;
and the defaults ofretryMiddleware(max3,baseDelay250,
maxDelay30000) andcacheMiddleware(ttl5 minutes,maxSize50).
v5.0.2
Fixed
- A path parameter with no usable value is refused instead of sent. A token
was filled withString(value)unchecked, sogetUser({ id: undefined })on
/users/:idfetched/users/undefined, andnull,'', an object or an array
built/users/null,/users/,/users/%5Bobject%20Object%5Dor/users/1%2C2.
The usual cause is a component rendering before the id has loaded. Such a call
now returns an error Result (kind: 'network', aTypeErrornaming each bad
param, e.g.Path parameter "id" is undefined in path "/users/:id", so the call was not sent.) and nothing reaches the server. Accepted values are non-empty
strings, finite numbers, bigints and booleans; aDateis refused with a hint
to convert it first (toISOString()orgetTime()), as in a query string.
NaNandInfinityare refused too. See
MIGRATION.md.
Changed
- The README's size figures are re-measured with
npm run size: about 5.8 kB
gzipped for a REST-only import, 6.9 kB for the core entry, 8.0 kB with all
middleware (the new check and its error messages add about 0.2 kB).
v5.0.1
A bug-fix release from an audit of 5.0.0. Nothing in the API changes; a few calls
that used to send the wrong thing, or nothing, now send the right thing or say why
they cannot. See MIGRATION.md.
Fixed
- Typed arrays,
DataView,BufferandReadableStreamare sent as real
binary bodies. They fell through toJSON.stringify, so aUint8Array([1, 2])
arrived as{"0":1,"1":2}. They now go tofetchas they are, with
Content-Type: application/octet-stream; a stream is sent withduplex: 'half'
set for you. A stream can be read once, so a retry (retryMiddleware,
result.retry()) now returns an error Result saying it cannot be resent, where
it used to send an empty body. - Params that used to send nothing now send what they hold, or are refused.
AMapwith string keys is the object it spells; a class with onlytoJSON()
is sent as its JSON (body only). ASet, a bareDate, aMapwith non-string
keys and a class with no fields return an error Result (kind: 'network', a
TypeErrornaming the type) instead of leaving with an empty body. A typed
array,DataView, stream ortoJSON-only class on a request whose params go in
the query string (a GET) is refused for the same reason. - Abort listeners no longer accumulate on a long-lived caller signal. Each
call withdedupe,timeoutorshare(and each GraphQL call) left a listener
on the caller'sAbortSignal, so a component-scoped controller used for many
calls grew without bound. The merged signals are now released when the call
settles. One consequence: after a call settles, a later abort of the caller's
signal no longer reaches that call'sctx.request.signal, so fire-and-forget
middleware work still holding it is no longer cancelled by the caller. cacheMiddlewarekeys on more than name and params. The key was the request
name plus params, so a second user's call could be served the first user's
cached/me, and oneRequestused with two base URLs shared entries. The key
is now request name, method, URL (query string included, its pairs sorted by
name), params, and every request header exceptContent-Type. A warm cache is cold once after
upgrading, and a middleware placed before the cache that adds a per-call unique
header (a request ID) now makes every call a miss; place it after.- A path token can no longer be hit by an unrelated param key. Substitution
built a regular expression from each param key, so a key likea.bcould fill
the token:aXb. Tokens are now scanned from the template, using the documented
grammar[a-zA-Z0-9_]. A template like/x/:a-bwith a keya-bused to
resolve by accident; the scan reads the token as:afollowed by-b, finds no
akey, and the call now returns an error Result (aTypeError, "Unresolved
path parameter :a…"). Use only[a-zA-Z0-9_]in path token names and their keys. - A
Datein a query string is reported as aDate. The error said a nested
object was not allowed; it now names theDateand suggeststoISOString()or
getTime(). It is still refused. - A header name repeated within one source is joined, not overwritten. Two
entries for one name in an array of header pairs kept only the last; they are now
joined asa, b, as the platform'sHeadersdoes. So are case-variant
duplicates inside one record ({ Accept: 'a', accept: 'b' }sendsa, b). A
later source still replaces an earlier one. timeoutworks whereAbortSignal.timeoutdoes not exist (React Native's
Hermes). A fallback built fromAbortControllerandsetTimeoutis used. The
result iskind: 'timeout'where the runtime'sAbortControllercarries abort
reasons (Node's does; this is what the tests cover), and possibly'abort'
where it ignores them. Not tested on a device. Nothing global is patched.- A call with no params is no longer keyed the same as a bare
[undefined]
param. ForshareandcacheMiddlewarethe two collided, so one could be
handed the other's response.
Changed
- The README's size numbers are measured, and CI enforces them. The old
"2.9 kB / 4.4 kB gzipped" had gone stale and understated the bundle.npm run size
now bundles each entry with esbuild and reports gzip and brotli: about 5.6 kB
gzipped for a REST-only import, 6.7 kB for the core entry and 7.8 kB with all
middleware. CI fails if one grows past its budget.esbuildis a new
devDependency; the package still has no runtime dependencies.