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 +70 -0
- package/MIGRATION.md +46 -0
- package/README.md +29 -4
- package/dist/built-in-middleware.js +21 -1
- package/dist/create-api.js +47 -12
- package/dist/define-request.d.ts +1 -1
- package/dist/graphql.js +15 -1
- package/dist/utils/any-signal.d.ts +9 -0
- package/dist/utils/any-signal.js +11 -0
- package/dist/utils/classify-params.d.ts +13 -0
- package/dist/utils/classify-params.js +50 -0
- package/dist/utils/headers.js +1 -11
- package/dist/utils/path-params.js +13 -8
- package/dist/utils/serialize.d.ts +2 -0
- package/dist/utils/serialize.js +7 -0
- package/dist/utils/special-body.d.ts +8 -2
- package/dist/utils/special-body.js +5 -0
- package/dist/utils/stable-key.d.ts +6 -4
- package/dist/utils/stable-key.js +1 -1
- package/dist/utils/timeout.js +10 -1
- package/package.json +4 -2
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 **
|
|
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.
|
|
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
|
|
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)
|
package/dist/create-api.js
CHANGED
|
@@ -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 {
|
|
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
|
|
43
|
-
const { url, remaining } = buildUrl(baseUrl, request.config.path,
|
|
44
|
-
return { url, remaining, asQuery,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
269
|
-
if (
|
|
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) =>
|
|
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
|
};
|
package/dist/define-request.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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.
|
package/dist/utils/any-signal.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/utils/headers.js
CHANGED
|
@@ -3,17 +3,7 @@ export function mergeHeaders(...sources) {
|
|
|
3
3
|
for (const source of sources) {
|
|
4
4
|
if (!source)
|
|
5
5
|
continue;
|
|
6
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
package/dist/utils/serialize.js
CHANGED
|
@@ -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`, `
|
|
4
|
-
*
|
|
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 → `
|
|
17
|
-
*
|
|
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
|
|
34
|
-
*
|
|
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.
|
package/dist/utils/stable-key.js
CHANGED
package/dist/utils/timeout.js
CHANGED
|
@@ -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
|
|
15
|
+
return timeoutSignal(ms);
|
|
7
16
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "liaise",
|
|
3
|
-
"version": "5.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
|
}
|