Skip to content

Releases: iremlopsum/liaise

v5.3.1

Choose a tag to compare

@github-actions github-actions released this 08 Oct 20:26
cde5115

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, but api.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"}], with Content-Type: application/json, and [] is sent as [].
  • An array as params on a GET or DELETE built ?0=a&1=b. It is now refused with a 'network' error whose TypeError says 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 PATCH whose 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 changes ctx.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

Choose a tag to compare

@github-actions github-actions released this 07 Oct 20:50
a800628

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 send headers. 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, and getHeaders() 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 because share and the cache compare the headers a call sends (withHeaders()).
  • dedupe works 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 } turns dedupe off 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

Choose a tag to compare

@github-actions github-actions released this 07 Oct 18:23
d5c48de

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 :name in the middle of a path segment was filled. The types and the check for unfilled path params only count a :name that starts a segment, but the URL was built by filling one anywhere: path: '/v1/documents:batchGet' called with { batchGet: 'yes' } sent /v1/documentsyes. A :name is now filled only where it starts a segment, so /v1/documents:batchGet and /time/12:30 are sent as written. A call that passes a param named after a :name in the middle of a segment, such as /items/v:version with { version: '2' }, is refused with a message that says to move the :name to 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 :name in a path's query string built a broken query. path: '/v2/simple/price?:qs' typed its params as {}, sent ?:qs as text when called without qs, and with qs encoded the whole value into one parameter (price?ids%3Dbitcoin%26…). A :name right after ?, & or = is now refused on every call, and defineRequest refuses 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

Choose a tag to compare

@github-actions github-actions released this 07 Oct 16:16
b2c29b5

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

  • poll and pollUntil (Polling). Both take any endpoint from createApi or createGraphQL.
    • poll(endpoint, params, callback, { every }) asks at once, then again every ms after each answer, and hands every Result to the callback until stop() or its signal. Requests never overlap.
    • pollUntil(endpoint, params, { every, until, giveUpAfter }) resolves once and never rejects. It resolves with the first success until accepts, an error that waiting can't fix (a 4xx other than 408 and 429, a GraphQL error, 'parse', 'middleware'), a 'timeout' after giveUpAfter, or an 'abort' from its signal.
    • A failure keeps polling, with longer waits. A Retry-After on a 429 or a 503 is honoured.
    • In a browser, polling pauses while the tab is hidden (inBackground: true keeps 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.
  • fetchOptions on createApi, createGraphQL, an endpoint or Operation, and a call (Cookies and other fetch options). It takes credentials, mode, cache, redirect, keepalive, priority and anything else RequestInit has, except method, headers, body and signal, which liaise sets itself. Levels merge field by field, the most specific winning, and middleware reads and changes them as ctx.request.fetchOptions. Calls whose options differ never share a request. New type: FetchOptions.
  • A client's own fetch: createApi({ fetch }) and createGraphQL({ fetch }), for undici, a Cloudflare service binding (in a wrapper) or mockFetch().fetch. Without it, liaise uses the global fetch, looked up on every call, as before.
  • cacheMiddleware({ methods }): the methods it caches. The default is ['GET', 'HEAD'].
  • RecordedCall.init in liaise/testing: a copy of the init fetch received, so a test can check credentials or keepalive.

Changed

  • cacheMiddleware caches only GET and HEAD by default. A call with any other method goes to the network and is never stored. Before, it cached any method, so a cached POST answered the next identical create with the first one's response. To keep caching a read sent as a POST, such as a search or a GraphQL query, pass methods: ['POST'].
  • The cache key includes the fetchOptions and the client's own fetch. An object in the options, such as an HTTP agent, is compared by identity, and two clients with different fetch functions never share an entry. Clients on the global fetch share 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 undefined path 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 example switch is 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 calls createUser. It also says liaise is ESM only, which moduleResolution settings work, and which TypeScript versions it is tested with.
  • Two recipes were wrong. The React hook showed the previous user after the id changed, and the per-attempt timeout replaced the caller's signal, so a cancel left the request running.
  • New notes: Request hides the Fetch API's Request, 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

Choose a tag to compare

@github-actions github-actions released this 07 Oct 10:59
58bdc27

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 cloned Response the body is one branch of a tee, and its cancel doesn't settle while the other branch is unread, so the call never returned. mockFetch serves 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 data as the selected object (Category). It is keyed by the root field ({ category: Category }), and an Operation's schema validates that whole data object, so the schema example now wraps the inner schema.
    • When the URL couldn't be built, error.request.url is baseUrl plus 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.

No action needed to upgrade.

v5.1.1

Choose a tag to compare

@github-actions github-actions released this 07 Oct 03:43
4d3a56a

Fixes a GraphQL client split into queries and mutations when a query and a mutation share a name.

Fixed

  • gql.query.x ran the mutation x. createGraphQL merged the queries and mutations records by name, so a mutation replaced a query of the same name under both gql.query.x and gql.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 dedupe lane. A call to one could cancel an in-flight call to the other. They are now separate endpoints for dedupe and share; 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. createGraphQL now 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

Choose a tag to compare

@iremlopsum iremlopsum released this 04 Oct 17:46
d2dde83

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

  • share now 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
    Authorization header 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 own Result; 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 returned kind: 'abort'. It now returns kind: 'parse' for a 2xx
    response, and kind: 'http' with body: null for a non-2xx one.

Added

  • GraphQL share on an Operation, with the same semantics as REST. share
    and dedupe on one operation throw at createGraphQL, as they do at createApi.
  • timeout on createApi and createGraphQL. There is no default. The first
    defined level wins: call, then endpoint (or operation), then client. 0 opts out
    and stops the fallback.
  • log option on both clients. Off by default. log: true prints a line around
    each call; { enabled, data } turns it off with enabled: false and prints the
    data with data: 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
    derived Content-Type are not included. An invalid configured header gives {}.
  • New type exports: LogOptions (core) and LogMiddleware (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.
  • logMiddleware prints 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 sees false. A Blob,
    ArrayBuffer or FormData is 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 are traceparent, tracestate,
    baggage, sentry-trace, x-request-id and x-correlation-id.
  • Params that used to decline sharing are compared as sent. A BigInt, an object
    with hidden state, a nested Map or Set and FormData params share when the
    bytes sent are identical. A FormData, Blob, ArrayBuffer, typed array,
    DataView or 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.
  • onError runs 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 share reports 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, under share: 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

Choose a tag to compare

@iremlopsum iremlopsum released this 04 Oct 09:15
7eebf71

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:check fails if the README copy drifts from that test.
  • src/types.ts comments 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 share option'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 replaces ctx.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' with status: 0; a caller's cancel still ends the call as
    'abort' when a middleware replaces ctx.request.signal, without aborting the
    request itself; a header added by middleware is not part of the share key;
    and the defaults of retryMiddleware (max 3, baseDelay 250,
    maxDelay 30000) and cacheMiddleware (ttl 5 minutes, maxSize 50).

v5.0.2

Choose a tag to compare

@iremlopsum iremlopsum released this 04 Oct 04:21
b7b893b

Fixed

  • A path parameter with no usable value is refused instead of sent. A token
    was filled with String(value) unchecked, so getUser({ id: undefined }) on
    /users/:id fetched /users/undefined, and null, '', an object or an array
    built /users/null, /users/, /users/%5Bobject%20Object%5D or /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', a TypeError naming 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; a Date is refused with a hint
    to convert it first (toISOString() or getTime()), as in a query string.
    NaN and Infinity are 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

Choose a tag to compare

@iremlopsum iremlopsum released this 03 Oct 18:00
cf20e3a

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, Buffer and ReadableStream are sent as real
    binary bodies.
    They fell through to JSON.stringify, so a Uint8Array([1, 2])
    arrived as {"0":1,"1":2}. They now go to fetch as they are, with
    Content-Type: application/octet-stream; a stream is sent with duplex: '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.
    A Map with string keys is the object it spells; a class with only toJSON()
    is sent as its JSON (body only). A Set, a bare Date, a Map with non-string
    keys and a class with no fields return an error Result (kind: 'network', a
    TypeError naming the type) instead of leaving with an empty body. A typed
    array, DataView, stream or toJSON-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 with dedupe, timeout or share (and each GraphQL call) left a listener
    on the caller's AbortSignal, 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's ctx.request.signal, so fire-and-forget
    middleware work still holding it is no longer cancelled by the caller.
  • cacheMiddleware keys 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 one Request used 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 except Content-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 like a.b could fill
    the token :aXb. Tokens are now scanned from the template, using the documented
    grammar [a-zA-Z0-9_]. A template like /x/:a-b with a key a-b used to
    resolve by accident; the scan reads the token as :a followed by -b, finds no
    a key, and the call now returns an error Result (a TypeError, "Unresolved
    path parameter :a…"). Use only [a-zA-Z0-9_] in path token names and their keys.
  • A Date in a query string is reported as a Date. The error said a nested
    object was not allowed; it now names the Date and suggests toISOString() 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 as a, b, as the platform's Headers does. So are case-variant
    duplicates inside one record ({ Accept: 'a', accept: 'b' } sends a, b). A
    later source still replaces an earlier one.
  • timeout works where AbortSignal.timeout does not exist (React Native's
    Hermes). A fallback built from AbortController and setTimeout is used. The
    result is kind: 'timeout' where the runtime's AbortController carries 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.
    For share and cacheMiddleware the 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. esbuild is a new
    devDependency; the package still has no runtime dependencies.