liaise 5.0.0 → 5.0.2

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,98 @@ 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.2] — 2026-10-04
9
+
10
+ ### Fixed
11
+
12
+ - **A path parameter with no usable value is refused instead of sent.** A token
13
+ was filled with `String(value)` unchecked, so `getUser({ id: undefined })` on
14
+ `/users/:id` fetched `/users/undefined`, and `null`, `''`, an object or an array
15
+ built `/users/null`, `/users/`, `/users/%5Bobject%20Object%5D` or `/users/1%2C2`.
16
+ The usual cause is a component rendering before the id has loaded. Such a call
17
+ now returns an error Result (`kind: 'network'`, a `TypeError` naming each bad
18
+ param, e.g. `Path parameter "id" is undefined in path "/users/:id", so the call
19
+ was not sent.`) and nothing reaches the server. Accepted values are non-empty
20
+ strings, finite numbers, bigints and booleans; a `Date` is refused with a hint
21
+ to convert it first (`toISOString()` or `getTime()`), as in a query string.
22
+ `NaN` and `Infinity` are refused too. See
23
+ [MIGRATION.md](./MIGRATION.md#upgrading-to-502).
24
+
25
+ ### Changed
26
+
27
+ - The README's size figures are re-measured with `npm run size`: about 5.8 kB
28
+ gzipped for a REST-only import, 6.9 kB for the core entry, 8.0 kB with all
29
+ middleware (the new check and its error messages add about 0.2 kB).
30
+
31
+ ## [5.0.1] — 2026-10-03
32
+
33
+ A bug-fix release from an audit of 5.0.0. Nothing in the API changes; a few calls
34
+ that used to send the wrong thing, or nothing, now send the right thing or say why
35
+ they cannot. See [MIGRATION.md](./MIGRATION.md#upgrading-to-501).
36
+
37
+ ### Fixed
38
+
39
+ - **Typed arrays, `DataView`, `Buffer` and `ReadableStream` are sent as real
40
+ binary bodies.** They fell through to `JSON.stringify`, so a `Uint8Array([1, 2])`
41
+ arrived as `{"0":1,"1":2}`. They now go to `fetch` as they are, with
42
+ `Content-Type: application/octet-stream`; a stream is sent with `duplex: 'half'`
43
+ set for you. A stream can be read once, so a retry (`retryMiddleware`,
44
+ `result.retry()`) now returns an error Result saying it cannot be resent, where
45
+ it used to send an empty body.
46
+ - **Params that used to send nothing now send what they hold, or are refused.**
47
+ A `Map` with string keys is the object it spells; a class with only `toJSON()`
48
+ is sent as its JSON (body only). A `Set`, a bare `Date`, a `Map` with non-string
49
+ keys and a class with no fields return an error Result (`kind: 'network'`, a
50
+ `TypeError` naming the type) instead of leaving with an empty body. A typed
51
+ array, `DataView`, stream or `toJSON`-only class on a request whose params go in
52
+ the query string (a GET) is refused for the same reason.
53
+ - **Abort listeners no longer accumulate on a long-lived caller signal.** Each
54
+ call with `dedupe`, `timeout` or `share` (and each GraphQL call) left a listener
55
+ on the caller's `AbortSignal`, so a component-scoped controller used for many
56
+ calls grew without bound. The merged signals are now released when the call
57
+ settles. One consequence: after a call settles, a later abort of the caller's
58
+ signal no longer reaches that call's `ctx.request.signal`, so fire-and-forget
59
+ middleware work still holding it is no longer cancelled by the caller.
60
+ - **`cacheMiddleware` keys on more than name and params.** The key was the request
61
+ name plus params, so a second user's call could be served the first user's
62
+ cached `/me`, and one `Request` used with two base URLs shared entries. The key
63
+ is now request name, method, URL (query string included, its pairs sorted by
64
+ name), params, and every request header except `Content-Type`. A warm cache is cold once after
65
+ upgrading, and a middleware placed before the cache that adds a per-call unique
66
+ header (a request ID) now makes every call a miss; place it after.
67
+ - **A path token can no longer be hit by an unrelated param key.** Substitution
68
+ built a regular expression from each param key, so a key like `a.b` could fill
69
+ the token `:aXb`. Tokens are now scanned from the template, using the documented
70
+ grammar `[a-zA-Z0-9_]`. A template like `/x/:a-b` with a key `a-b` used to
71
+ resolve by accident; the scan reads the token as `:a` followed by `-b`, finds no
72
+ `a` key, and the call now returns an error Result (a `TypeError`, "Unresolved
73
+ path parameter :a…"). Use only `[a-zA-Z0-9_]` in path token names and their keys.
74
+ - **A `Date` in a query string is reported as a `Date`.** The error said a nested
75
+ object was not allowed; it now names the `Date` and suggests `toISOString()` or
76
+ `getTime()`. It is still refused.
77
+ - **A header name repeated within one source is joined, not overwritten.** Two
78
+ entries for one name in an array of header pairs kept only the last; they are now
79
+ joined as `a, b`, as the platform's `Headers` does. So are case-variant
80
+ duplicates inside one record (`{ Accept: 'a', accept: 'b' }` sends `a, b`). A
81
+ later source still replaces an earlier one.
82
+ - **`timeout` works where `AbortSignal.timeout` does not exist** (React Native's
83
+ Hermes). A fallback built from `AbortController` and `setTimeout` is used. The
84
+ result is `kind: 'timeout'` where the runtime's `AbortController` carries abort
85
+ reasons (Node's does; this is what the tests cover), and possibly `'abort'`
86
+ where it ignores them. Not tested on a device. Nothing global is patched.
87
+ - **A call with no params is no longer keyed the same as a bare `[undefined]`
88
+ param.** For `share` and `cacheMiddleware` the two collided, so one could be
89
+ handed the other's response.
90
+
91
+ ### Changed
92
+
93
+ - **The README's size numbers are measured, and CI enforces them.** The old
94
+ "2.9 kB / 4.4 kB gzipped" had gone stale and understated the bundle. `npm run size`
95
+ now bundles each entry with esbuild and reports gzip and brotli: about 5.6 kB
96
+ gzipped for a REST-only import, 6.7 kB for the core entry and 7.8 kB with all
97
+ middleware. CI fails if one grows past its budget. `esbuild` is a new
98
+ devDependency; the package still has no runtime dependencies.
99
+
8
100
  ## [5.0.0] — 2026-10-03
9
101
 
10
102
  **Renamed from `@iremlopsum/apify` to `liaise`.** Same code, same API, full git
@@ -939,6 +1031,8 @@ Initial release of the rewritten client. Reconstructed from the release commit
939
1031
  `ArrayBuffer` and strings
940
1032
  - Response parsing as `json`, `text`, `blob`, `arrayBuffer` or `formData`
941
1033
 
1034
+ [5.0.2]: https://github.com/iremlopsum/liaise/compare/v5.0.1...v5.0.2
1035
+ [5.0.1]: https://github.com/iremlopsum/liaise/compare/v5.0.0...v5.0.1
942
1036
  [5.0.0]: https://github.com/iremlopsum/liaise/compare/v4.4.3...v5.0.0
943
1037
  [4.4.3]: https://github.com/iremlopsum/liaise/compare/v4.4.2...v4.4.3
944
1038
  [4.4.2]: https://github.com/iremlopsum/liaise/compare/v4.4.1...v4.4.2
package/MIGRATION.md CHANGED
@@ -7,6 +7,62 @@ 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.2
11
+
12
+ No code changes needed. A call whose path parameter is `undefined`, `null`, an
13
+ empty string, an object, an array, a `Date`, a function, a symbol, `NaN` or `Infinity` now returns an
14
+ error Result instead of being sent to a URL like `/users/undefined`. Those calls
15
+ were already hitting the wrong URL; now they say so. If you relied on a literal
16
+ `null` or `undefined` segment, pass the string (`'null'`) explicitly.
17
+
18
+ ---
19
+
20
+ ## Upgrading to 5.0.1
21
+
22
+ No code changes for most callers. Six things you may observe.
23
+
24
+ **Binary bodies arrive as binary.** A `Uint8Array`, other typed array, `DataView`
25
+ or `Buffer` used to be sent as a JSON index map (`{"0":1,"1":2}`); it is now the
26
+ raw bytes, with `Content-Type: application/octet-stream`. A `ReadableStream` is
27
+ sent as a streaming upload and can be sent once: `retryMiddleware` and
28
+ `result.retry()` return an error Result for it. If a call with a stream may be
29
+ retried, read it into a `Blob` or `ArrayBuffer` first. If your server decoded the
30
+ old index map, it needs to read bytes now.
31
+
32
+ **Calls that used to send nothing now send, or fail with a Result.** A `Map` with
33
+ string keys and a class with only `toJSON()` are now sent. A `Set`, a bare `Date`,
34
+ a `Map` with non-string keys and a class with no fields return an error Result
35
+ (`kind: 'network'`, `body` a `TypeError` naming the type). A typed array, stream
36
+ or `toJSON`-only class on a GET is refused. If you were relying on the empty body,
37
+ pass the object you meant: `{ ids: [...set] }`, `{ since: date.toISOString() }`.
38
+
39
+ **`cacheMiddleware` entries are per URL and per header set.** The key now includes
40
+ method, full URL (query string included, pairs sorted by name) and every request
41
+ header except `Content-Type`. A different `Authorization` or other header, base
42
+ URL, path or query value gets its own entry; the same query params in a different
43
+ order share one. A warm cache is cold once after
44
+ upgrading. A middleware placed before `cacheMiddleware` that adds a per-call
45
+ unique header (a request ID) makes every call a miss; put it after
46
+ `cacheMiddleware` in the `middleware` array.
47
+
48
+ **Repeated header names within one source are joined.** A header-pairs array
49
+ with two entries for one name now sends `a, b`, where the last used to win; so
50
+ does a record with case-variant duplicates (`{ Accept: 'a', accept: 'b' }`). A
51
+ later source (per-call over request over config) still replaces an earlier one.
52
+
53
+ **After a call settles, a later abort no longer reaches it.** Aborting the
54
+ caller's signal no longer reaches that call's `ctx.request.signal`, so
55
+ fire-and-forget middleware work still holding it is not cancelled by the caller;
56
+ keep your own controller if you need that.
57
+
58
+ **A path token with non-word characters now fails.** Path tokens are
59
+ `[a-zA-Z0-9_]`. A template like `/x/:a-b` with a key `a-b` used to resolve by
60
+ accident; it is now read as the token `:a` followed by `-b`, and the call returns
61
+ an error Result (a `TypeError`, "Unresolved path parameter :a…"). Use only
62
+ `[a-zA-Z0-9_]` in path token names and their keys.
63
+
64
+ ---
65
+
10
66
  ## Upgrading to 5.0.0
11
67
 
12
68
  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.8 kB gzipped** for a REST-only import, 6.9 kB for the core entry, 8.0 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
 
@@ -134,6 +135,8 @@ const getItem = new Request<{ orgId: string; id: string }, Item>({
134
135
  await api.getItem({ orgId: 'acme', id: '42' })
135
136
  ```
136
137
 
138
+ A path parameter must be a non-empty string, a finite number, a bigint or a boolean. Anything else (`undefined`, `null`, `''`, an object, an array, a `Date`, `NaN`) is refused before the request is sent, with an error Result naming the parameter. This catches the common front-end mistake of calling before an id has loaded: `getItem({ orgId: 'acme', id: undefined })` returns an error instead of fetching `/orgs/acme/items/undefined`.
139
+
137
140
  #### `responseType`
138
141
 
139
142
  Controls how the response body is parsed. Defaults to `'json'`.
@@ -442,6 +445,7 @@ never going to reach the server.
442
445
  | `{ filter: null }` | _(omitted)_ |
443
446
  | `{ filter: undefined }` | _(omitted)_ |
444
447
  | `{ meta: { nested: true } }` | **TypeError** (see below) |
448
+ | `{ since: new Date() }` | **TypeError**: convert it first (`toISOString()` or `getTime()`) |
445
449
 
446
450
  **Arrays** use repeated keys (`tags=a&tags=b`), which is the most widely supported format across server frameworks.
447
451
 
@@ -449,6 +453,8 @@ never going to reach the server.
449
453
 
450
454
  **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
455
 
456
+ **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()`.
457
+
452
458
  ### Result
453
459
 
454
460
  Every API call returns a `Result<TResponse>` instead of throwing. It's a discriminated union on `error`, not a plain interface:
@@ -839,7 +845,7 @@ const api = createApi({
839
845
 
840
846
  **`cacheMiddleware(options?)`**
841
847
 
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.
848
+ 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
849
 
844
850
  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
851
 
@@ -873,8 +879,25 @@ Request bodies are automatically serialized based on the input type. The `Conten
873
879
  | `URLSearchParams` | as-is | `application/x-www-form-urlencoded` |
874
880
  | `Blob` | as-is | `application/octet-stream` |
875
881
  | `ArrayBuffer` | as-is | `application/octet-stream` |
882
+ | Typed array, `DataView`, `Buffer` | as-is (sent as binary) | `application/octet-stream` |
883
+ | `ReadableStream` | as-is (streaming upload; `duplex: 'half'` is set for you) | `application/octet-stream` |
876
884
  | Plain object | `JSON.stringify()` | `application/json` |
877
885
 
886
+ 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.
887
+
888
+ #### What params can be
889
+
890
+ | You pass | What happens |
891
+ | -------- | ------------ |
892
+ | Plain object | Decomposed into path tokens, query string and body |
893
+ | `Map` with string keys | Same as the object it spells |
894
+ | 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) |
895
+ | Class instance with only `toJSON()` | Sent as its JSON (body only; refused on a request whose params go in the query string) |
896
+ | 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) |
897
+ | `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. |
898
+
899
+ 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.
900
+
878
901
  Header merge precedence (most specific wins):
879
902
 
880
903
  1. **Global headers** (from `createApi` config) -- lowest priority
@@ -1331,7 +1354,7 @@ Type safety comes from inference, not annotation. Define `Request<TParams, TResp
1331
1354
 
1332
1355
  ### Runtime-agnostic
1333
1356
 
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.
1357
+ 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
1358
 
1336
1359
  ### Framework-agnostic
1337
1360
 
@@ -1393,6 +1416,10 @@ The library has no opinion about your UI framework, or whether you have one. A c
1393
1416
  | `RouteValue` | type | `Response \| RouteHandler \| Array<Response \| RouteHandler>` -- anything a route key can map to |
1394
1417
  | `RecordedCall` | type | `{ method, url, headers, body }` -- shape of each entry in `mock.calls` |
1395
1418
 
1419
+ ## Contributing
1420
+
1421
+ 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.
1422
+
1396
1423
  ## License
1397
1424
 
1398
1425
  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
  }
@@ -56,10 +56,12 @@ export declare class FragmentError extends TypeError {
56
56
  * This is the main URL construction function used by the request engine.
57
57
  * It handles the full lifecycle from path template to final URL.
58
58
  *
59
- * **Path param matching** uses regex with a word-boundary lookahead to prevent
60
- * partial matches. For example, a param key `id` will match `:id` but NOT
61
- * `:idExtra`. This is achieved by requiring that the character after the param
62
- * name is either a non-alphanumeric-underscore character or end of string.
59
+ * **Path param matching** scans the template once for `:name` tokens
60
+ * (`[a-zA-Z0-9_]+`, greedy), so a param key `id` fills `:id` but never part of
61
+ * `:idExtra`. A token is filled only from a non-empty string, a finite number,
62
+ * a bigint or a boolean; `undefined`, `null`, `''`, objects, arrays and Dates
63
+ * throw a TypeError naming the param, so a call is never sent to
64
+ * '/users/undefined'.
63
65
  *
64
66
  * **Query string rules** (when `asQuery` is true):
65
67
  * - Primitives: `{ page: 1 }` → `?page=1`
@@ -25,25 +25,58 @@ export class FragmentError extends TypeError {
25
25
  this.resolvedUrl = resolvedUrl;
26
26
  }
27
27
  }
28
+ function unusableSegment(value) {
29
+ if (value === undefined)
30
+ return 'undefined';
31
+ if (value === null)
32
+ return 'null';
33
+ if (value === '')
34
+ return 'an empty string';
35
+ if (typeof value === 'number' && !Number.isFinite(value))
36
+ return String(value);
37
+ if (value instanceof Date)
38
+ return 'a Date (convert it first, e.g. date.toISOString() or date.getTime())';
39
+ if (Array.isArray(value))
40
+ return 'an array';
41
+ if (typeof value === 'object')
42
+ return 'an object';
43
+ if (typeof value === 'function' || typeof value === 'symbol')
44
+ return `a ${typeof value}`;
45
+ return null;
46
+ }
28
47
  export function buildUrl(baseUrl, path, params, asQuery = false) {
29
48
  const fragmentIn = path.includes('#') ? { where: 'path', value: path } : baseUrl.includes('#') ? { where: 'baseUrl', value: baseUrl } : null;
30
49
  let resolvedPath = path;
31
50
  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;
51
+ const lookup = new Map(Object.entries(params));
52
+ const consumed = new Set();
53
+ const unusable = [];
54
+ resolvedPath = resolvedPath.replace(/:([a-zA-Z0-9_]+)/g, (token, name) => {
55
+ if (!lookup.has(name))
56
+ return token;
57
+ consumed.add(name);
58
+ const value = lookup.get(name);
59
+ const problem = unusableSegment(value);
60
+ if (problem) {
61
+ if (!unusable.some(entry => entry.startsWith(`"${name}"`)))
62
+ unusable.push(`"${name}" is ${problem}`);
63
+ return token;
37
64
  }
38
- else {
65
+ return encodeURIComponent(String(value));
66
+ });
67
+ for (const [key, value] of lookup) {
68
+ if (!consumed.has(key))
39
69
  remaining[key] = value;
40
- }
41
70
  }
42
71
  if (fragmentIn) {
43
72
  const fragment = fragmentIn.value.slice(fragmentIn.value.indexOf('#'));
44
73
  throw new FragmentError(`A URL fragment is never sent to the server, so it cannot appear in a ${fragmentIn.where}. ` +
45
74
  `Remove "${fragment}" from "${fragmentIn.value}".`, joinUrl(baseUrl, resolvedPath));
46
75
  }
76
+ if (unusable.length > 0) {
77
+ throw new TypeError(`Path parameter ${unusable.join(', ')} in path "${path}", so the call was not sent. ` +
78
+ 'A path parameter must be a non-empty string, a finite number, a bigint or a boolean.');
79
+ }
47
80
  const unresolved = resolvedPath
48
81
  .split('/')
49
82
  .map(segment => /^:[a-zA-Z0-9_]+/.exec(segment)?.[0])
@@ -64,6 +97,9 @@ export function buildUrl(baseUrl, path, params, asQuery = false) {
64
97
  searchParams.append(key, String(item));
65
98
  }
66
99
  }
100
+ else if (value instanceof Date) {
101
+ 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().`);
102
+ }
67
103
  else if (typeof value === 'object') {
68
104
  throw new TypeError(`Nested objects are not supported in query strings. Flatten param "${key}" before passing.`);
69
105
  }
@@ -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.2",
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
  }