liaise 5.0.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,75 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [5.0.1] — 2026-10-03
9
+
10
+ A bug-fix release from an audit of 5.0.0. Nothing in the API changes; a few calls
11
+ that used to send the wrong thing, or nothing, now send the right thing or say why
12
+ they cannot. See [MIGRATION.md](./MIGRATION.md#upgrading-to-501).
13
+
14
+ ### Fixed
15
+
16
+ - **Typed arrays, `DataView`, `Buffer` and `ReadableStream` are sent as real
17
+ binary bodies.** They fell through to `JSON.stringify`, so a `Uint8Array([1, 2])`
18
+ arrived as `{"0":1,"1":2}`. They now go to `fetch` as they are, with
19
+ `Content-Type: application/octet-stream`; a stream is sent with `duplex: 'half'`
20
+ set for you. A stream can be read once, so a retry (`retryMiddleware`,
21
+ `result.retry()`) now returns an error Result saying it cannot be resent, where
22
+ it used to send an empty body.
23
+ - **Params that used to send nothing now send what they hold, or are refused.**
24
+ A `Map` with string keys is the object it spells; a class with only `toJSON()`
25
+ is sent as its JSON (body only). A `Set`, a bare `Date`, a `Map` with non-string
26
+ keys and a class with no fields return an error Result (`kind: 'network'`, a
27
+ `TypeError` naming the type) instead of leaving with an empty body. A typed
28
+ array, `DataView`, stream or `toJSON`-only class on a request whose params go in
29
+ the query string (a GET) is refused for the same reason.
30
+ - **Abort listeners no longer accumulate on a long-lived caller signal.** Each
31
+ call with `dedupe`, `timeout` or `share` (and each GraphQL call) left a listener
32
+ on the caller's `AbortSignal`, so a component-scoped controller used for many
33
+ calls grew without bound. The merged signals are now released when the call
34
+ settles. One consequence: after a call settles, a later abort of the caller's
35
+ signal no longer reaches that call's `ctx.request.signal`, so fire-and-forget
36
+ middleware work still holding it is no longer cancelled by the caller.
37
+ - **`cacheMiddleware` keys on more than name and params.** The key was the request
38
+ name plus params, so a second user's call could be served the first user's
39
+ cached `/me`, and one `Request` used with two base URLs shared entries. The key
40
+ is now request name, method, URL (query string included, its pairs sorted by
41
+ name), params, and every request header except `Content-Type`. A warm cache is cold once after
42
+ upgrading, and a middleware placed before the cache that adds a per-call unique
43
+ header (a request ID) now makes every call a miss; place it after.
44
+ - **A path token can no longer be hit by an unrelated param key.** Substitution
45
+ built a regular expression from each param key, so a key like `a.b` could fill
46
+ the token `:aXb`. Tokens are now scanned from the template, using the documented
47
+ grammar `[a-zA-Z0-9_]`. A template like `/x/:a-b` with a key `a-b` used to
48
+ resolve by accident; the scan reads the token as `:a` followed by `-b`, finds no
49
+ `a` key, and the call now returns an error Result (a `TypeError`, "Unresolved
50
+ path parameter :a…"). Use only `[a-zA-Z0-9_]` in path token names and their keys.
51
+ - **A `Date` in a query string is reported as a `Date`.** The error said a nested
52
+ object was not allowed; it now names the `Date` and suggests `toISOString()` or
53
+ `getTime()`. It is still refused.
54
+ - **A header name repeated within one source is joined, not overwritten.** Two
55
+ entries for one name in an array of header pairs kept only the last; they are now
56
+ joined as `a, b`, as the platform's `Headers` does. So are case-variant
57
+ duplicates inside one record (`{ Accept: 'a', accept: 'b' }` sends `a, b`). A
58
+ later source still replaces an earlier one.
59
+ - **`timeout` works where `AbortSignal.timeout` does not exist** (React Native's
60
+ Hermes). A fallback built from `AbortController` and `setTimeout` is used. The
61
+ result is `kind: 'timeout'` where the runtime's `AbortController` carries abort
62
+ reasons (Node's does; this is what the tests cover), and possibly `'abort'`
63
+ where it ignores them. Not tested on a device. Nothing global is patched.
64
+ - **A call with no params is no longer keyed the same as a bare `[undefined]`
65
+ param.** For `share` and `cacheMiddleware` the two collided, so one could be
66
+ handed the other's response.
67
+
68
+ ### Changed
69
+
70
+ - **The README's size numbers are measured, and CI enforces them.** The old
71
+ "2.9 kB / 4.4 kB gzipped" had gone stale and understated the bundle. `npm run size`
72
+ now bundles each entry with esbuild and reports gzip and brotli: about 5.6 kB
73
+ gzipped for a REST-only import, 6.7 kB for the core entry and 7.8 kB with all
74
+ middleware. CI fails if one grows past its budget. `esbuild` is a new
75
+ devDependency; the package still has no runtime dependencies.
76
+
8
77
  ## [5.0.0] — 2026-10-03
9
78
 
10
79
  **Renamed from `@iremlopsum/apify` to `liaise`.** Same code, same API, full git
@@ -939,6 +1008,7 @@ Initial release of the rewritten client. Reconstructed from the release commit
939
1008
  `ArrayBuffer` and strings
940
1009
  - Response parsing as `json`, `text`, `blob`, `arrayBuffer` or `formData`
941
1010
 
1011
+ [5.0.1]: https://github.com/iremlopsum/liaise/compare/v5.0.0...v5.0.1
942
1012
  [5.0.0]: https://github.com/iremlopsum/liaise/compare/v4.4.3...v5.0.0
943
1013
  [4.4.3]: https://github.com/iremlopsum/liaise/compare/v4.4.2...v4.4.3
944
1014
  [4.4.2]: https://github.com/iremlopsum/liaise/compare/v4.4.1...v4.4.2
package/MIGRATION.md CHANGED
@@ -7,6 +7,52 @@ For the full record of what changed in each release, see [CHANGELOG.md](./CHANGE
7
7
 
8
8
  ---
9
9
 
10
+ ## Upgrading to 5.0.1
11
+
12
+ No code changes for most callers. Six things you may observe.
13
+
14
+ **Binary bodies arrive as binary.** A `Uint8Array`, other typed array, `DataView`
15
+ or `Buffer` used to be sent as a JSON index map (`{"0":1,"1":2}`); it is now the
16
+ raw bytes, with `Content-Type: application/octet-stream`. A `ReadableStream` is
17
+ sent as a streaming upload and can be sent once: `retryMiddleware` and
18
+ `result.retry()` return an error Result for it. If a call with a stream may be
19
+ retried, read it into a `Blob` or `ArrayBuffer` first. If your server decoded the
20
+ old index map, it needs to read bytes now.
21
+
22
+ **Calls that used to send nothing now send, or fail with a Result.** A `Map` with
23
+ string keys and a class with only `toJSON()` are now sent. A `Set`, a bare `Date`,
24
+ a `Map` with non-string keys and a class with no fields return an error Result
25
+ (`kind: 'network'`, `body` a `TypeError` naming the type). A typed array, stream
26
+ or `toJSON`-only class on a GET is refused. If you were relying on the empty body,
27
+ pass the object you meant: `{ ids: [...set] }`, `{ since: date.toISOString() }`.
28
+
29
+ **`cacheMiddleware` entries are per URL and per header set.** The key now includes
30
+ method, full URL (query string included, pairs sorted by name) and every request
31
+ header except `Content-Type`. A different `Authorization` or other header, base
32
+ URL, path or query value gets its own entry; the same query params in a different
33
+ order share one. A warm cache is cold once after
34
+ upgrading. A middleware placed before `cacheMiddleware` that adds a per-call
35
+ unique header (a request ID) makes every call a miss; put it after
36
+ `cacheMiddleware` in the `middleware` array.
37
+
38
+ **Repeated header names within one source are joined.** A header-pairs array
39
+ with two entries for one name now sends `a, b`, where the last used to win; so
40
+ does a record with case-variant duplicates (`{ Accept: 'a', accept: 'b' }`). A
41
+ later source (per-call over request over config) still replaces an earlier one.
42
+
43
+ **After a call settles, a later abort no longer reaches it.** Aborting the
44
+ caller's signal no longer reaches that call's `ctx.request.signal`, so
45
+ fire-and-forget middleware work still holding it is not cancelled by the caller;
46
+ keep your own controller if you need that.
47
+
48
+ **A path token with non-word characters now fails.** Path tokens are
49
+ `[a-zA-Z0-9_]`. A template like `/x/:a-b` with a key `a-b` used to resolve by
50
+ accident; it is now read as the token `:a` followed by `-b`, and the call returns
51
+ an error Result (a `TypeError`, "Unresolved path parameter :a…"). Use only
52
+ `[a-zA-Z0-9_]` in path token names and their keys.
53
+
54
+ ---
55
+
10
56
  ## Upgrading to 5.0.0
11
57
 
12
58
  The package has a new name: **`@iremlopsum/apify` is now `liaise`**. Nothing
package/README.md CHANGED
@@ -10,8 +10,8 @@ Runtime-agnostic, type-safe HTTP client for REST and GraphQL. Built on standard
10
10
  - **Never throws** — every call returns `{ data, error, response, retry }`, no try/catch required
11
11
  - **Composable middleware** — retry, cache, dedupe, auth, logging — applied at global, per-endpoint, or per-call level
12
12
  - **Types by inference** — declare params and response once on the endpoint definition; types flow to every call site automatically
13
- - **Runtime-agnostic** — Node.js 20+, browsers, Bun, Deno, Cloudflare Workers, React Native — any environment with `fetch`
14
- - **Tiny** — about **2.9 kB gzipped** for a REST-only import, 4.4 kB for everything including GraphQL and all middleware; tree-shaking drops what you do not import
13
+ - **Runtime-agnostic** — Node.js 20+, browsers, Bun, Deno, Cloudflare Workers, React Native (its built-in `fetch`; not tested in CI) — any environment with `fetch`
14
+ - **Tiny** — about **5.6 kB gzipped** for a REST-only import, 6.7 kB for the core entry, 7.8 kB with all middleware (measured by `npm run size`); tree-shaking drops what you do not import
15
15
 
16
16
  ```
17
17
  npm install liaise
@@ -43,6 +43,7 @@ npm install liaise
43
43
  - [Testing](#testing)
44
44
  - [Philosophy](#philosophy)
45
45
  - [API Reference](#api-reference)
46
+ - [Contributing](#contributing)
46
47
 
47
48
  ## Getting Started
48
49
 
@@ -442,6 +443,7 @@ never going to reach the server.
442
443
  | `{ filter: null }` | _(omitted)_ |
443
444
  | `{ filter: undefined }` | _(omitted)_ |
444
445
  | `{ meta: { nested: true } }` | **TypeError** (see below) |
446
+ | `{ since: new Date() }` | **TypeError**: convert it first (`toISOString()` or `getTime()`) |
445
447
 
446
448
  **Arrays** use repeated keys (`tags=a&tags=b`), which is the most widely supported format across server frameworks.
447
449
 
@@ -449,6 +451,8 @@ never going to reach the server.
449
451
 
450
452
  **Nested objects** throw a `TypeError` with a descriptive message. Flatten the structure before passing. This is intentional -- there is no universal standard for serializing nested objects in query strings (brackets, dots, JSON), so the library refuses to guess.
451
453
 
454
+ **A `Date`** is refused too, with a message that names it. ISO 8601 and epoch milliseconds are both common on real APIs, so convert it yourself: `since: date.toISOString()` or `since: date.getTime()`.
455
+
452
456
  ### Result
453
457
 
454
458
  Every API call returns a `Result<TResponse>` instead of throwing. It's a discriminated union on `error`, not a plain interface:
@@ -839,7 +843,7 @@ const api = createApi({
839
843
 
840
844
  **`cacheMiddleware(options?)`**
841
845
 
842
- Caches successful responses in memory, keyed by request name and params. Calls with identical params within the TTL window are served from cache without hitting the network. Each `cacheMiddleware()` call creates an isolated store — different endpoints never share entries.
846
+ Caches successful responses in memory, keyed by request name, method, the full URL, params and every request header except `Content-Type` (which is derived from the params). The URL's query string is part of the key with its pairs sorted by name, so `?a=1&b=2` and `?b=2&a=1` are one entry, while `?key=A` and `?key=B` (or a `?lang=de` appended by a middleware before the cache) are not. Calls that agree on all of those within the TTL window are served from cache without hitting the network. A different `Authorization` or any other header (except `Content-Type`), a different base URL, path or query value gets its own entry, so one user is never served another's response; the same query params in a different order share one. The query is sorted by raw (undecoded) name, keeping the order of repeated names. The trade-off: a middleware that adds a per-call unique header (a request ID, say) must come *after* `cacheMiddleware` in the middleware array; placed before it, every call carries a fresh header and nothing is ever cached. Each `cacheMiddleware()` call creates an isolated store — different endpoints never share entries.
843
847
 
844
848
  Params are keyed by content, at every depth: plain data as sorted JSON with `undefined` members dropped (so `{ a: undefined }` and `{}` are one key), anything with `toJSON` by what it returns (a `Date` is its ISO string), and `Map`, `Set` and typed arrays by their entries. A call whose params cannot be keyed soundly — a BigInt, an `ArrayBuffer`, `Blob`, `FormData` or `URLSearchParams`, or an object with no enumerable state such as a class instance holding private fields — is never cached and never served from cache. The rule is the same one `share` uses; see [Sharing](#sharing).
845
849
 
@@ -873,8 +877,25 @@ Request bodies are automatically serialized based on the input type. The `Conten
873
877
  | `URLSearchParams` | as-is | `application/x-www-form-urlencoded` |
874
878
  | `Blob` | as-is | `application/octet-stream` |
875
879
  | `ArrayBuffer` | as-is | `application/octet-stream` |
880
+ | Typed array, `DataView`, `Buffer` | as-is (sent as binary) | `application/octet-stream` |
881
+ | `ReadableStream` | as-is (streaming upload; `duplex: 'half'` is set for you) | `application/octet-stream` |
876
882
  | Plain object | `JSON.stringify()` | `application/json` |
877
883
 
884
+ A `ReadableStream` body can be sent once. A retry (`retryMiddleware`, `result.retry()`) returns an error Result telling you to read the stream into a `Blob` or `ArrayBuffer` first. Under `retryMiddleware` that is the Result you end up with: after a 5xx, the final Result is the "cannot resend" `TypeError` (status 0), so the original 503 is not in it.
885
+
886
+ #### What params can be
887
+
888
+ | You pass | What happens |
889
+ | -------- | ------------ |
890
+ | Plain object | Decomposed into path tokens, query string and body |
891
+ | `Map` with string keys | Same as the object it spells |
892
+ | Class instance with fields | Same as a plain object (decomposed by those fields even if the class also defines `toJSON()`; `toJSON()` is used only when there are no own fields) |
893
+ | Class instance with only `toJSON()` | Sent as its JSON (body only; refused on a request whose params go in the query string) |
894
+ | Typed array, `DataView`, `Buffer`, `ReadableStream` | Sent as the body, as in the table above (refused on a request whose params go in the query string) |
895
+ | `Set`, a bare `Date`, a `Map` with non-string keys, a class with no fields | Refused: an error Result (`kind: 'network'`) naming the type. Nothing is sent. |
896
+
897
+ A `Map`, `Set` or class with private state nested inside a JSON body is sent as `{}`, because that is what `JSON.stringify` does. Convert it first.
898
+
878
899
  Header merge precedence (most specific wins):
879
900
 
880
901
  1. **Global headers** (from `createApi` config) -- lowest priority
@@ -1331,7 +1352,7 @@ Type safety comes from inference, not annotation. Define `Request<TParams, TResp
1331
1352
 
1332
1353
  ### Runtime-agnostic
1333
1354
 
1334
- No assumptions about Node.js, browsers, or any specific runtime. If your environment has `fetch`, the library works -- browsers, Node.js 20+, Bun, Deno, React Native, Cloudflare Workers, edge runtimes.
1355
+ No assumptions about Node.js, browsers, or any specific runtime. If your environment has `fetch`, the library works -- browsers, Node.js 20+, Bun, Deno, React Native (its built-in `fetch`; not tested in CI), Cloudflare Workers, edge runtimes. Where `AbortSignal.timeout` is missing (React Native's Hermes), `timeout` falls back to `AbortController` plus `setTimeout`; the failure is `kind: 'timeout'` where the runtime's `AbortController` carries abort reasons, and possibly `'abort'` where it does not. Not tested on a device.
1335
1356
 
1336
1357
  ### Framework-agnostic
1337
1358
 
@@ -1393,6 +1414,10 @@ The library has no opinion about your UI framework, or whether you have one. A c
1393
1414
  | `RouteValue` | type | `Response \| RouteHandler \| Array<Response \| RouteHandler>` -- anything a route key can map to |
1394
1415
  | `RecordedCall` | type | `{ method, url, headers, body }` -- shape of each entry in `mock.calls` |
1395
1416
 
1417
+ ## Contributing
1418
+
1419
+ Bug reports, fixes and ideas are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) for how to report a bug, run the tests, and the few rules a pull request is checked against.
1420
+
1396
1421
  ## License
1397
1422
 
1398
1423
  MIT
@@ -106,7 +106,27 @@ export function cacheMiddleware(options) {
106
106
  const paramsStr = stableKey(ctx.request.params);
107
107
  if (paramsStr === null)
108
108
  return next();
109
- const key = `${ctx.requestName}|${paramsStr}`;
109
+ const headerPairs = [];
110
+ ctx.request.headers.forEach((value, name) => {
111
+ if (name !== 'content-type')
112
+ headerPairs.push([name, value]);
113
+ });
114
+ const headerKey = JSON.stringify(headerPairs);
115
+ const fullUrl = ctx.request.url;
116
+ const qIndex = fullUrl.indexOf('?');
117
+ let urlKey = fullUrl;
118
+ if (qIndex !== -1) {
119
+ const hashIndex = fullUrl.indexOf('#', qIndex);
120
+ const rawQuery = fullUrl.slice(qIndex + 1, hashIndex === -1 ? undefined : hashIndex);
121
+ const segments = rawQuery.split('&').filter(seg => seg !== '');
122
+ const nameOf = (seg) => seg.split('=')[0];
123
+ segments.sort((x, y) => {
124
+ const nx = nameOf(x), ny = nameOf(y);
125
+ return nx < ny ? -1 : nx > ny ? 1 : 0;
126
+ });
127
+ urlKey = `${fullUrl.slice(0, qIndex)}?${segments.join('&')}`;
128
+ }
129
+ const key = `${ctx.requestName}|${ctx.request.method}|${urlKey}|${paramsStr}|${headerKey}`;
110
130
  const cached = store.get(key);
111
131
  if (cached !== null) {
112
132
  if (debug)
@@ -6,12 +6,14 @@ import { DedupeTracker } from './utils/dedupe.js';
6
6
  import { ShareTracker, isAbandoned } from './utils/share.js';
7
7
  import { mergeHeaders } from './utils/headers.js';
8
8
  import { abortKind, propagatesReason } from './utils/abort-kind.js';
9
- import { anySignal } from './utils/any-signal.js';
9
+ import { anySignal, releaseSignal } from './utils/any-signal.js';
10
10
  import { operationBudget, perCallerBudget } from './utils/budget.js';
11
11
  import { stableKey } from './utils/stable-key.js';
12
12
  import { createBackstop } from './utils/backstop.js';
13
- import { isSpecialBody } from './utils/special-body.js';
13
+ import { isReadableStream } from './utils/special-body.js';
14
+ import { classifyParams } from './utils/classify-params.js';
14
15
  import { runSchema } from './utils/validate.js';
16
+ const sentStreams = new WeakSet();
15
17
  const EMPTY_JSON_BODY = Symbol('liaise.emptyJsonBody');
16
18
  async function parseResponse(response, responseType = 'json') {
17
19
  switch (responseType) {
@@ -39,9 +41,9 @@ async function parseResponse(response, responseType = 'json') {
39
41
  }
40
42
  function resolveRequestUrl(baseUrl, request, params) {
41
43
  const asQuery = request.shouldSerializeAsQuery;
42
- const paramsIsSpecialBody = isSpecialBody(params);
43
- const { url, remaining } = buildUrl(baseUrl, request.config.path, paramsIsSpecialBody ? {} : params, asQuery);
44
- return { url, remaining, asQuery, paramsIsSpecialBody };
44
+ const classified = classifyParams(params, asQuery);
45
+ const { url, remaining } = buildUrl(baseUrl, request.config.path, classified.kind === 'fields' ? classified.fields : {}, asQuery);
46
+ return { url, remaining, asQuery, whole: classified.kind === 'whole' ? { value: classified.value } : null };
45
47
  }
46
48
  function urlForError(baseUrl, request, params) {
47
49
  try {
@@ -112,6 +114,13 @@ export function createApi(config) {
112
114
  return result;
113
115
  }
114
116
  const execute = (sharedSignal, onSettled) => {
117
+ const merged = [];
118
+ const own = (signal, ...inputs) => {
119
+ if (signal && !inputs.includes(signal))
120
+ merged.push(signal);
121
+ };
122
+ const releaseAll = () => { for (const s of merged)
123
+ releaseSignal(s); };
115
124
  try {
116
125
  const allMiddleware = [
117
126
  ...globalMiddleware,
@@ -119,19 +128,25 @@ export function createApi(config) {
119
128
  ...(options.middleware ?? [])
120
129
  ];
121
130
  const operation = operationBudget(options.timeout, request.config.timeout, options.signal, sharedSignal !== undefined);
131
+ own(operation, options.signal);
122
132
  const callerSignal = sharedSignal
123
133
  ? anySignal([sharedSignal, operation])
124
134
  : operation;
135
+ own(callerSignal, sharedSignal, operation);
125
136
  let dedupeController;
126
137
  const core = async (ctx) => {
127
138
  try {
128
139
  if (sharedSignal && ctx.request.signal !== sharedSignal) {
129
- ctx.request.signal = anySignal([ctx.request.signal, sharedSignal]);
140
+ const installed = ctx.request.signal;
141
+ ctx.request.signal = anySignal([installed, sharedSignal]);
142
+ own(ctx.request.signal, installed, sharedSignal);
130
143
  }
131
144
  if (request.config.dedupe && !dedupeController) {
132
- const tracked = dedupeTracker.track(name, ctx.request.signal ?? callerSignal);
145
+ const external = ctx.request.signal ?? callerSignal;
146
+ const tracked = dedupeTracker.track(name, external);
133
147
  dedupeController = tracked.controller;
134
148
  ctx.request.signal = tracked.signal;
149
+ own(tracked.signal, external);
135
150
  backstop.watch(tracked.controller.signal);
136
151
  }
137
152
  const fetchInit = {
@@ -140,7 +155,16 @@ export function createApi(config) {
140
155
  signal: ctx.request.signal
141
156
  };
142
157
  if (ctx.request.body !== null && ctx.request.body !== undefined) {
143
- fetchInit.body = ctx.request.body;
158
+ const body = ctx.request.body;
159
+ if (isReadableStream(body)) {
160
+ if (sentStreams.has(body)) {
161
+ throw new TypeError('A ReadableStream body can only be sent once, so retry() and retryMiddleware cannot resend it. ' +
162
+ 'If this call may be retried, read the stream into a Blob or ArrayBuffer first.');
163
+ }
164
+ sentStreams.add(body);
165
+ fetchInit.duplex = 'half';
166
+ }
167
+ fetchInit.body = body;
144
168
  }
145
169
  const response = await fetch(ctx.request.url, fetchInit);
146
170
  if (!response.ok) {
@@ -261,12 +285,12 @@ export function createApi(config) {
261
285
  return createNetworkErrorResult(error, retry);
262
286
  }
263
287
  };
264
- const { url, remaining, asQuery, paramsIsSpecialBody } = resolveRequestUrl(baseUrl, request, params);
288
+ const { url, remaining, asQuery, whole } = resolveRequestUrl(baseUrl, request, params);
265
289
  const headers = mergeHeaders(globalHeaders, request.config.headers, options.headers);
266
290
  let body = null;
267
291
  if (!asQuery) {
268
- const toSerialize = paramsIsSpecialBody ? params : remaining;
269
- if (paramsIsSpecialBody || Object.keys(remaining).length > 0) {
292
+ const toSerialize = whole ? whole.value : remaining;
293
+ if (whole || Object.keys(remaining).length > 0) {
270
294
  const serialized = serializeBody(toSerialize);
271
295
  body = serialized.body;
272
296
  if (serialized.contentType && !headers.has('Content-Type')) {
@@ -308,19 +332,25 @@ export function createApi(config) {
308
332
  dedupeController.abort();
309
333
  dedupeTracker.clear(name, dedupeController);
310
334
  }
335
+ releaseAll();
311
336
  const abandoned = sharedSignal?.aborted === true && isAbandoned(sharedSignal.reason);
312
337
  if (result.error && !abandoned)
313
338
  fireOnError(result.error);
314
339
  return result;
315
- }, (err) => failedResult(err, undefined, 'abort'));
340
+ }, (err) => {
341
+ releaseAll();
342
+ return failedResult(err, undefined, 'abort');
343
+ });
316
344
  }
317
345
  catch (err) {
346
+ releaseAll();
318
347
  return Promise.resolve(failedResult(err, undefined, 'network'));
319
348
  }
320
349
  };
321
350
  function retry() {
322
351
  return execute();
323
352
  }
353
+ let builtPerCaller;
324
354
  try {
325
355
  const key = request.config.share === true &&
326
356
  isEmptyHeaders(options.headers) &&
@@ -331,6 +361,7 @@ export function createApi(config) {
331
361
  return execute();
332
362
  const shareKey = `${name}|${key}`;
333
363
  const perCaller = perCallerBudget(options.timeout, options.signal);
364
+ builtPerCaller = perCaller;
334
365
  const { promise, release, hasSettled } = shareTracker.acquire(shareKey, (signal, markSettled) => execute(signal, markSettled));
335
366
  if (!perCaller)
336
367
  return promise.then(r => r, (err) => failedResult(err, undefined, 'network'));
@@ -341,6 +372,8 @@ export function createApi(config) {
341
372
  return;
342
373
  done = true;
343
374
  perCaller.removeEventListener('abort', onAbort);
375
+ if (perCaller !== options.signal)
376
+ releaseSignal(perCaller);
344
377
  resolve(r);
345
378
  };
346
379
  promise.then(r => finish(r), (err) => {
@@ -362,6 +395,8 @@ export function createApi(config) {
362
395
  });
363
396
  }
364
397
  catch (err) {
398
+ if (builtPerCaller !== options.signal)
399
+ releaseSignal(builtPerCaller);
365
400
  return Promise.resolve(failedResult(err, undefined, 'network'));
366
401
  }
367
402
  };
@@ -4,7 +4,7 @@ import type { RequestConfig, ResponseType, StandardSchemaV1, InferOutput } from
4
4
  * The characters a path token name may contain.
5
5
  *
6
6
  * This list is not arbitrary and must not be "simplified": it mirrors
7
- * `buildUrl`'s substitution pattern, `:${key}(?=[^a-zA-Z0-9_]|$)`
7
+ * the `/:([a-zA-Z0-9_]+)/g` template scan in `buildUrl`
8
8
  * (src/utils/path-params.ts). If the two ever disagree, the types describe a
9
9
  * URL the runtime does not build.
10
10
  */
package/dist/graphql.js CHANGED
@@ -4,6 +4,7 @@ import { DedupeTracker } from './utils/dedupe.js';
4
4
  import { mergeHeaders } from './utils/headers.js';
5
5
  import { abortKind, propagatesReason } from './utils/abort-kind.js';
6
6
  import { resolveBudget } from './utils/budget.js';
7
+ import { releaseSignal } from './utils/any-signal.js';
7
8
  import { createBackstop } from './utils/backstop.js';
8
9
  import { runSchema } from './utils/validate.js';
9
10
  export class Operation {
@@ -43,6 +44,13 @@ export function createGraphQL(config) {
43
44
  });
44
45
  return createNetworkErrorResult(error, execute);
45
46
  }
47
+ const merged = [];
48
+ const own = (signal, ...inputs) => {
49
+ if (signal && !inputs.includes(signal))
50
+ merged.push(signal);
51
+ };
52
+ const releaseAll = () => { for (const s of merged)
53
+ releaseSignal(s); };
46
54
  try {
47
55
  const allMiddleware = [
48
56
  ...globalMiddleware,
@@ -51,13 +59,16 @@ export function createGraphQL(config) {
51
59
  ];
52
60
  const budget = resolveBudget(options.timeout, operation.config.timeout, options.signal, false);
53
61
  const callerSignal = budget.operation;
62
+ own(callerSignal, options.signal);
54
63
  let dedupeController;
55
64
  const core = async (ctx) => {
56
65
  try {
57
66
  if (operation.config.dedupe && !dedupeController) {
58
- const tracked = dedupeTracker.track(name, ctx.request.signal ?? callerSignal);
67
+ const external = ctx.request.signal ?? callerSignal;
68
+ const tracked = dedupeTracker.track(name, external);
59
69
  dedupeController = tracked.controller;
60
70
  ctx.request.signal = tracked.signal;
71
+ own(tracked.signal, external);
61
72
  backstop.watch(tracked.controller.signal);
62
73
  }
63
74
  const response = await fetch(ctx.request.url, {
@@ -218,16 +229,19 @@ export function createGraphQL(config) {
218
229
  dedupeController.abort();
219
230
  dedupeTracker.clear(name, dedupeController);
220
231
  }
232
+ releaseAll();
221
233
  if (result.error)
222
234
  fireOnError(result.error);
223
235
  return result;
224
236
  }, (err) => {
237
+ releaseAll();
225
238
  const result = buildFailedResult(err, undefined, 'abort');
226
239
  fireOnError(result.error);
227
240
  return result;
228
241
  });
229
242
  }
230
243
  catch (err) {
244
+ releaseAll();
231
245
  const error = new ApiError({
232
246
  status: 0,
233
247
  kind: 'network',
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Remove the listeners `anySignal` registered for `signal`. Call it once the
3
+ * call that owns the signal has settled. A no-op for a signal anySignal did
4
+ * not create (a caller's own signal, the single-signal fast path, undefined),
5
+ * and idempotent. It deliberately does NOT release the merge's inputs: an
6
+ * input may be a signal another, still-running call owns, for example when a
7
+ * middleware passes ctx.request.signal into a nested api call.
8
+ */
9
+ export declare function releaseSignal(signal: AbortSignal | undefined): void;
1
10
  /**
2
11
  * Composes several abort signals into one that aborts when the first of them
3
12
  * aborts, carrying that signal's `reason` through.
@@ -1,3 +1,13 @@
1
+ const disposers = new WeakMap();
2
+ export function releaseSignal(signal) {
3
+ if (!signal)
4
+ return;
5
+ const dispose = disposers.get(signal);
6
+ if (!dispose)
7
+ return;
8
+ disposers.delete(signal);
9
+ dispose();
10
+ }
1
11
  export function anySignal(signals) {
2
12
  const defined = signals.filter((s) => s !== undefined);
3
13
  if (defined.length === 0)
@@ -25,5 +35,6 @@ export function anySignal(signals) {
25
35
  registered.push({ signal, listener });
26
36
  signal.addEventListener('abort', listener, { once: true });
27
37
  }
38
+ disposers.set(controller.signal, cleanup);
28
39
  return controller.signal;
29
40
  }
@@ -0,0 +1,13 @@
1
+ export type ClassifiedParams = {
2
+ kind: 'fields';
3
+ fields: Record<string, unknown>;
4
+ } | {
5
+ kind: 'whole';
6
+ value: unknown;
7
+ };
8
+ /**
9
+ * @param params - The call's params, as passed.
10
+ * @param asQuery - Whether this request's params go in the query string.
11
+ * @throws TypeError for a value that has no honest wire form here.
12
+ */
13
+ export declare function classifyParams(params: unknown, asQuery: boolean): ClassifiedParams;
@@ -0,0 +1,50 @@
1
+ import { isReadableStream, isSpecialBody } from './special-body.js';
2
+ function typeName(value) {
3
+ const name = value.constructor?.name;
4
+ return typeof name === 'string' && name !== '' ? name : 'object';
5
+ }
6
+ function withArticle(value) {
7
+ const name = typeName(value);
8
+ return `${/^([aeio]|u(?!int))/i.test(name) ? 'an' : 'a'} ${name}`;
9
+ }
10
+ export function classifyParams(params, asQuery) {
11
+ if (params === null || params === undefined)
12
+ return { kind: 'fields', fields: {} };
13
+ if (isSpecialBody(params)) {
14
+ if (asQuery && (ArrayBuffer.isView(params) || isReadableStream(params))) {
15
+ throw new TypeError(`Cannot send ${withArticle(params)} as params for a request whose params go in the query string. ` +
16
+ 'Send it with POST, PUT or PATCH.');
17
+ }
18
+ return { kind: 'whole', value: params };
19
+ }
20
+ if (typeof params !== 'object' || Array.isArray(params)) {
21
+ return { kind: 'fields', fields: params };
22
+ }
23
+ if (params instanceof Map) {
24
+ for (const key of params.keys()) {
25
+ if (typeof key !== 'string') {
26
+ throw new TypeError('Cannot send a Map with non-string keys as request params. Use string keys, or convert it to an object first.');
27
+ }
28
+ }
29
+ return { kind: 'fields', fields: Object.fromEntries(params) };
30
+ }
31
+ if (params instanceof Set) {
32
+ throw new TypeError('Cannot send a Set as request params. Put it in an object as an array, e.g. { ids: [...set] }.');
33
+ }
34
+ if (params instanceof Date) {
35
+ throw new TypeError('Cannot send a Date as request params on its own. Put it in an object, e.g. { since: date.toISOString() }.');
36
+ }
37
+ const proto = Object.getPrototypeOf(params);
38
+ const plain = proto === Object.prototype || proto === null ||
39
+ Object.getPrototypeOf(proto) === null;
40
+ if (plain || Object.keys(params).length > 0) {
41
+ return { kind: 'fields', fields: params };
42
+ }
43
+ if (typeof params.toJSON === 'function') {
44
+ if (asQuery) {
45
+ throw new TypeError(`Cannot send ${withArticle(params)} in a query string. Send it with POST, PUT or PATCH, or pass its fields as a plain object.`);
46
+ }
47
+ return { kind: 'whole', value: params };
48
+ }
49
+ throw new TypeError(`Cannot send ${withArticle(params)} as request params: it has no fields to send. Pass a plain object, or give the class a toJSON() method.`);
50
+ }
@@ -3,17 +3,7 @@ export function mergeHeaders(...sources) {
3
3
  for (const source of sources) {
4
4
  if (!source)
5
5
  continue;
6
- if (source instanceof Headers) {
7
- source.forEach((value, key) => merged.set(key, value));
8
- }
9
- else {
10
- const entries = Array.isArray(source)
11
- ? source
12
- : Object.entries(source);
13
- for (const [key, value] of entries) {
14
- merged.set(key, value);
15
- }
16
- }
6
+ new Headers(source).forEach((value, key) => merged.set(key, value));
17
7
  }
18
8
  return merged;
19
9
  }
@@ -29,15 +29,17 @@ export function buildUrl(baseUrl, path, params, asQuery = false) {
29
29
  const fragmentIn = path.includes('#') ? { where: 'path', value: path } : baseUrl.includes('#') ? { where: 'baseUrl', value: baseUrl } : null;
30
30
  let resolvedPath = path;
31
31
  const remaining = {};
32
- for (const [key, value] of Object.entries(params)) {
33
- const pattern = new RegExp(`:${key}(?=[^a-zA-Z0-9_]|$)`, 'g');
34
- const substituted = resolvedPath.replace(pattern, encodeURIComponent(String(value)));
35
- if (substituted !== resolvedPath) {
36
- resolvedPath = substituted;
37
- }
38
- else {
32
+ const lookup = new Map(Object.entries(params));
33
+ const consumed = new Set();
34
+ resolvedPath = resolvedPath.replace(/:([a-zA-Z0-9_]+)/g, (token, name) => {
35
+ if (!lookup.has(name))
36
+ return token;
37
+ consumed.add(name);
38
+ return encodeURIComponent(String(lookup.get(name)));
39
+ });
40
+ for (const [key, value] of lookup) {
41
+ if (!consumed.has(key))
39
42
  remaining[key] = value;
40
- }
41
43
  }
42
44
  if (fragmentIn) {
43
45
  const fragment = fragmentIn.value.slice(fragmentIn.value.indexOf('#'));
@@ -64,6 +66,9 @@ export function buildUrl(baseUrl, path, params, asQuery = false) {
64
66
  searchParams.append(key, String(item));
65
67
  }
66
68
  }
69
+ else if (value instanceof Date) {
70
+ throw new TypeError(`A Date cannot be sent in a query string as is. Convert param "${key}" first, e.g. ${key}: date.toISOString() or date.getTime().`);
71
+ }
67
72
  else if (typeof value === 'object') {
68
73
  throw new TypeError(`Nested objects are not supported in query strings. Flatten param "${key}" before passing.`);
69
74
  }
@@ -32,6 +32,8 @@ export interface SerializeResult {
32
32
  * | `URLSearchParams` | as-is | `application/x-www-form-urlencoded` |
33
33
  * | `Blob` | as-is | `application/octet-stream` |
34
34
  * | `ArrayBuffer` | as-is | `application/octet-stream` |
35
+ * | `ArrayBufferView` (typed array, `DataView`, `Buffer`) | as-is | `application/octet-stream` |
36
+ * | `ReadableStream` | as-is | `application/octet-stream` |
35
37
  * | Plain object | `JSON.stringify()` | `application/json` |
36
38
  *
37
39
  * @param input - The raw body value to serialize. Can be any type — the function
@@ -1,3 +1,4 @@
1
+ import { isReadableStream } from './special-body.js';
1
2
  export function serializeBody(input) {
2
3
  if (input === null || input === undefined) {
3
4
  return { body: null, contentType: null };
@@ -17,5 +18,11 @@ export function serializeBody(input) {
17
18
  if (input instanceof ArrayBuffer) {
18
19
  return { body: input, contentType: 'application/octet-stream' };
19
20
  }
21
+ if (ArrayBuffer.isView(input)) {
22
+ return { body: input, contentType: 'application/octet-stream' };
23
+ }
24
+ if (isReadableStream(input)) {
25
+ return { body: input, contentType: 'application/octet-stream' };
26
+ }
20
27
  return { body: JSON.stringify(input), contentType: 'application/json' };
21
28
  }
@@ -1,7 +1,13 @@
1
+ /**
2
+ * True for a WHATWG `ReadableStream`. Guarded with `typeof` because a runtime
3
+ * without streams has no global to `instanceof` against.
4
+ */
5
+ export declare function isReadableStream(value: unknown): value is ReadableStream;
1
6
  /**
2
7
  * True when `value` is a body type that cannot be decomposed into key-value
3
- * pairs — `FormData`, `Blob`, `ArrayBuffer`, `URLSearchParams`, or a raw
4
- * string.
8
+ * pairs — `FormData`, `Blob`, `ArrayBuffer`, any `ArrayBufferView` (typed
9
+ * arrays, `DataView`, Node's `Buffer`), a `ReadableStream`, `URLSearchParams`,
10
+ * or a raw string.
5
11
  *
6
12
  * Used by `create-api.ts`'s URL-building step, which skips path/query
7
13
  * decomposition for these entirely and hands them straight to
@@ -1,7 +1,12 @@
1
+ export function isReadableStream(value) {
2
+ return typeof ReadableStream !== 'undefined' && value instanceof ReadableStream;
3
+ }
1
4
  export function isSpecialBody(value) {
2
5
  return (value instanceof FormData ||
3
6
  value instanceof Blob ||
4
7
  value instanceof ArrayBuffer ||
8
+ ArrayBuffer.isView(value) ||
9
+ isReadableStream(value) ||
5
10
  value instanceof URLSearchParams ||
6
11
  typeof value === 'string');
7
12
  }
@@ -13,8 +13,9 @@
13
13
  *
14
14
  * Keys are built by content, in the order the rules are checked:
15
15
  *
16
- * - `undefined` at the top level → `[undefined]` (a call with no params is
17
- * keyable). An `undefined` *object member* is dropped, since both transports
16
+ * - `undefined` at the top level → `''` (a call with no params is keyable,
17
+ * and distinct from a bare `[undefined]` array param). An `undefined`
18
+ * *object member* is dropped, since both transports
18
19
  * omit it, so `{ a: undefined }` and `{}` key the same. As an array element,
19
20
  * Map key/value or Set element it keys as the unquoted token `undefined`
20
21
  * (a query string sends `ids=undefined`, not `ids=null`), and so does a
@@ -30,8 +31,9 @@
30
31
  * returns `undefined` the member is dropped, as JSON does.
31
32
  * - `Map` → `Map{k:v,...}` with entries sorted by key; `Set` → `Set[...]` in
32
33
  * insertion order; typed arrays and `DataView` → `Uint8Array[1,2]` etc. The
33
- * tag matters: a Map with entry `a: 1` does not send the same bytes as
34
- * `{ a: 1 }`, so it must not share its key.
34
+ * tag keeps a Map apart from an object with the same entries. A
35
+ * string-keyed Map is sent as that object, so over-separating them costs
36
+ * one extra miss and is harmless; sharing them wrongly would not be.
35
37
  * - arrays → `[...]`; plain objects → sorted, JSON-quoted keys. Any other
36
38
  * object with own enumerable keys is keyed the same way, since that is what
37
39
  * `JSON.stringify` sends for it.
@@ -1,6 +1,6 @@
1
1
  export function stableKey(value) {
2
2
  if (value === undefined)
3
- return '[undefined]';
3
+ return '';
4
4
  try {
5
5
  const out = visit(value, '', new Set());
6
6
  return out === undefined ? null : out;
@@ -1,7 +1,16 @@
1
+ function timeoutSignal(ms) {
2
+ if (typeof AbortSignal.timeout === 'function')
3
+ return AbortSignal.timeout(ms);
4
+ const controller = new AbortController();
5
+ const reason = new Error('The operation timed out.');
6
+ reason.name = 'TimeoutError';
7
+ setTimeout(() => controller.abort(reason), ms);
8
+ return controller.signal;
9
+ }
1
10
  export function timeoutSignalFor(callTimeout, requestTimeout) {
2
11
  const raw = Math.min(callTimeout ?? requestTimeout ?? 0, 2 ** 31 - 1);
3
12
  if (!(raw > 0))
4
13
  return undefined;
5
14
  const ms = Math.max(1, Math.floor(raw));
6
- return AbortSignal.timeout(ms);
15
+ return timeoutSignal(ms);
7
16
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "liaise",
3
- "version": "5.0.0",
3
+ "version": "5.0.1",
4
4
  "description": "Type-safe API client for REST and GraphQL on standard fetch. Never throws. Zero dependencies.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -29,6 +29,7 @@
29
29
  ],
30
30
  "scripts": {
31
31
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
32
+ "size": "node scripts/size.mjs",
32
33
  "build": "npm run clean && tsc -p tsconfig.build.json && tsc -p tsconfig.types.json",
33
34
  "test": "vitest",
34
35
  "test:run": "vitest run",
@@ -36,7 +37,7 @@
36
37
  "typecheck": "tsc --noEmit",
37
38
  "test:types": "vitest run --typecheck",
38
39
  "sessions": "test -f .claude/sessions.mjs && node .claude/sessions.mjs || echo \"npm run sessions is a local-only helper (gitignored .claude/); nothing to list in this clone\"",
39
- "prepublishOnly": "npm run typecheck && npm run test:types && npm run test:run && npm run test:integration && npm run build"
40
+ "prepublishOnly": "npm run typecheck && npm run test:types && npm run test:run && npm run test:integration && npm run build && npm run size"
40
41
  },
41
42
  "repository": {
42
43
  "type": "git",
@@ -69,6 +70,7 @@
69
70
  ],
70
71
  "devDependencies": {
71
72
  "@types/node": "^22.19.18",
73
+ "esbuild": "0.27.4",
72
74
  "typescript": "~5.8.0",
73
75
  "vitest": "^3.2.0"
74
76
  }