@jarenjs/contract 0.56.0 → 0.67.0

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.
Files changed (48) hide show
  1. package/README.md +139 -25
  2. package/dist/types/adapters/fetch.d.ts +11 -10
  3. package/dist/types/adapters/node.d.ts +24 -10
  4. package/dist/types/client/http.d.ts +77 -10
  5. package/dist/types/compat.d.ts +1 -1
  6. package/dist/types/errors.d.ts +3 -0
  7. package/dist/types/host.d.ts +179 -0
  8. package/dist/types/http/body.d.ts +147 -0
  9. package/dist/types/http/dispatch.d.ts +26 -2
  10. package/dist/types/http/serve.d.ts +46 -3
  11. package/dist/types/http/wire.d.ts +31 -17
  12. package/dist/types/ledger.d.ts +57 -12
  13. package/dist/types/local/index.d.ts +7 -1
  14. package/dist/types/messages.d.ts +2 -0
  15. package/dist/types/path.d.ts +4 -2
  16. package/dist/types/pipeline.d.ts +15 -1
  17. package/dist/types/port/client.d.ts +18 -1
  18. package/dist/types/port/serve.d.ts +38 -5
  19. package/dist/types/runtime.d.ts +25 -0
  20. package/dist/types/stream/client.d.ts +14 -3
  21. package/dist/types/stream/server.d.ts +218 -44
  22. package/dist/types/stream/sse.d.ts +10 -0
  23. package/docs/APP-INTEGRATION.md +4 -2
  24. package/docs/CONTRACT-FORMAT.md +580 -128
  25. package/package.json +5 -5
  26. package/src/adapters/fetch.js +144 -25
  27. package/src/adapters/node.js +246 -82
  28. package/src/cli.js +22 -16
  29. package/src/client/http.js +588 -189
  30. package/src/compat.js +1 -1
  31. package/src/errors.js +3 -0
  32. package/src/host.js +319 -0
  33. package/src/http/body.js +337 -0
  34. package/src/http/dispatch.js +511 -75
  35. package/src/http/serve.js +39 -5
  36. package/src/http/wire.js +33 -14
  37. package/src/ledger.js +119 -36
  38. package/src/local/index.js +91 -35
  39. package/src/messages.js +2 -0
  40. package/src/path.js +9 -3
  41. package/src/pipeline.js +18 -1
  42. package/src/port/client.js +39 -6
  43. package/src/port/serve.js +207 -69
  44. package/src/project/typescript.jtlt.json +39 -7
  45. package/src/runtime.js +36 -0
  46. package/src/stream/client.js +40 -6
  47. package/src/stream/server.js +573 -138
  48. package/src/stream/sse.js +2 -0
package/src/compat.js CHANGED
@@ -1,7 +1,7 @@
1
1
  //@ts-check
2
2
  /**
3
3
  * @file Version compatibility — the one implementation of the
4
- * negotiation rule (docs/CONTRACT-FORMAT.md §10.4, §13.1): two ends
4
+ * negotiation rule (docs/CONTRACT-FORMAT.md §10.4, §13): two ends
5
5
  * speak when they declare the same `version`, or when either end's
6
6
  * `compat` list names the other's `version`. The client's `negotiate()`
7
7
  * and any server that wants to refuse an incompatible peer both call
package/src/errors.js CHANGED
@@ -66,6 +66,7 @@ export const CONTRACT_CODES = Object.freeze({
66
66
  JC1008: 'openHttpClient, openPortClient, client.url, createContractEffect, createContractSubscription or a projection (publicProjection, toOpenApi, toTypeScript, toMarkdown, contractTools): an argument or option is malformed (not a compiled contract, fetch/keys/sleep/createTaskEffect/projectError not a function, storage without read/write, a non-object input to url, an ops entry naming no or an opaque operation, a tool name outside ^[a-zA-Z0-9_-]{1,64}$ or shared by two operations)',
67
67
  JC1009: 'encodeSseEvent (the stream wire): an event, id or data string the SSE frame cannot carry — a bare carriage return inside data, a line terminator inside event or id',
68
68
  JC1010: 'client.subscribe was asked for an operation that is not a subscribe operation (invoke carries reads and commands; subscribe carries streams)',
69
+ JC1011: 'a ledger commit or fail named a ref that settles no started record: the key expired, was reclaimed under a newer generation, or was settled already — the settlement is refused; the binding reports it to onError and the response still goes out',
69
70
  // ——— http request-time (ContractRuntimeError, mapped onto the wire) ———
70
71
  JC2001: 'no operation matches the request method and path (404)',
71
72
  JC2002: 'the path shape is served under other methods (405, Allow lists them)',
@@ -105,6 +106,8 @@ export const CONTRACT_CODES = Object.freeze({
105
106
  JC2093: 'the stream ended with a server error event whose code the operation does not declare (kind contract; a declared code is a failure outcome under its own code)',
106
107
  JC2094: 'the stream went silent for twice policy.stream.heartbeatMs (kind network, client-side)',
107
108
  JC2095: 'a requested resume was refused — informational, carried as resumed:false in the fresh snapshot\'s event data, never an outcome',
109
+ JC2096: 'the stream\'s bounded queue would overflow — the consumer reads slower than the source emits, or a replay page outran it (kind network, retryable; the stream ends with an error event carrying this code and the carrier tears the connection down)',
110
+ JC2097: 'the client\'s reconnect budget is exhausted: every attempt after a network loss failed the same way (kind network, not retryable; details carry the attempts made and the last network code, client-side)',
108
111
  });
109
112
 
110
113
  /**
package/src/host.js ADDED
@@ -0,0 +1,319 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The host lifecycle (docs/CONTRACT-FORMAT.md §7.7): one
4
+ * carrier-neutral coordinator every server binding runs a request
5
+ * through. A host names two hooks at construction — `identify(meta)`,
6
+ * before anything of the request is parsed, and `acquire(input,
7
+ * identity, enter)`, after the input validated and (on HTTP) the
8
+ * idempotency claim answered `new` — each answering a LEASE `{ host,
9
+ * release? }` the handler sees as `ctx.host`, or a declared failure the
10
+ * binding renders like the handler's own. `acquire` hands its lease to
11
+ * `enter`, the binding's continuation that runs the handler, validates
12
+ * the output, serializes the response and — when the lease carries
13
+ * `settlement: { ledger, required: true }` — settles the idempotency
14
+ * claim through that ledger BEFORE `enter` resolves, so a host that
15
+ * opened a transaction around `enter` commits the domain write and the
16
+ * receipt together or not at all. Releases run once each, acquired
17
+ * before identity, at the boundary the binding declares (the response
18
+ * exposed, the opaque body settled, the stream done).
19
+ *
20
+ * The defaults are exactly `identify → { host: null }` and `acquire →
21
+ * enter({ host: identity.host })`: a host that names only `identify`
22
+ * sees its host in `scope(ctx)` and in the handler; one that names
23
+ * `acquire` owns the handler's host. Every returned lease is validated
24
+ * by shape (an object with an own `host`); a hook that throws, answers
25
+ * a malformed lease, never calls `enter` or calls it twice is the host's
26
+ * fault — observed through the binding's observer and rendered as the
27
+ * binding's host-fault code (`JC2008` on HTTP, `JC2070` on port and
28
+ * local); a declared failure is recognized by the `ContractFailure`
29
+ * brand, never by shape, and validated against the operation like a
30
+ * handler's. No policy data of the hooks reaches a wire.
31
+ */
32
+
33
+ import { isThenable, toPromise } from '@jarenjs/core/function';
34
+
35
+ import { ContractFailure, isContractFailure } from './errors.js';
36
+
37
+ /**
38
+ * What `identify` sees: the matched operation, the trace, the request
39
+ * signal, the carrier, and the transport facts the carrier has — never
40
+ * a parsed input, never an auth vocabulary of the binding's own.
41
+ * @typedef {Object} IdentifyMeta
42
+ * @property {import('./compile.js').CompiledOperation} op
43
+ * @property {string} trace
44
+ * @property {AbortSignal | null} signal
45
+ * @property {'http' | 'port' | 'local'} carrier
46
+ * @property {string | null} method - the request line on HTTP; `null` elsewhere
47
+ * @property {string | null} path
48
+ * @property {Readonly<Record<string, string | readonly string[]>> | null} headers - the raw request headers on HTTP; `null` elsewhere
49
+ * @property {typeof ContractFailure} fail - the declared-failure factory
50
+ */
51
+
52
+ /**
53
+ * A lease: the host value the handler sees as `ctx.host`, an optional
54
+ * release, and — from `acquire` only — an optional required settlement.
55
+ * @typedef {Object} Lease
56
+ * @property {unknown} host
57
+ * @property {(() => unknown) | undefined} release
58
+ * @property {{ ledger: import('./ledger.js').Ledger, required: true } | null} settlement
59
+ */
60
+
61
+ /**
62
+ * The hooks as a binding validated them at construction.
63
+ * @typedef {Object} Lifecycle
64
+ * @property {(meta: IdentifyMeta) => unknown} identify
65
+ * @property {(input: unknown, identity: { host: unknown }, enter: (lease: unknown) => unknown) => unknown} acquire
66
+ */
67
+
68
+ /** The default identity: no host. */
69
+ const DEFAULT_IDENTIFY = () => ({ host: null });
70
+
71
+ /**
72
+ * The default acquisition: the identity's host, as is.
73
+ * @param {unknown} input
74
+ * @param {{ host: unknown }} identity
75
+ * @param {(lease: unknown) => unknown} enter
76
+ */
77
+ const DEFAULT_ACQUIRE = (input, identity, enter) => enter({ host: identity.host });
78
+
79
+ /**
80
+ * A host fault of the lifecycle: what the binding observes and renders
81
+ * as its host-fault code.
82
+ */
83
+ export class HostLifecycleError extends Error {
84
+ /**
85
+ * @param {string} message
86
+ * @param {unknown} [cause]
87
+ */
88
+ constructor(message, cause = undefined) {
89
+ super(message, cause === undefined ? undefined : { cause });
90
+ this.name = 'HostLifecycleError';
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Validate the hooks a binding was given; each defaults. Rejections are
96
+ * the binding's `JC1001` through `host`.
97
+ * @param {{ identify?: unknown, acquire?: unknown }} options
98
+ * @param {(reason: string) => Error} host - the binding's construction refusal
99
+ * @returns {Lifecycle}
100
+ */
101
+ export function resolveLifecycle(options, host) {
102
+ for (const name of /** @type {const} */ (['identify', 'acquire'])) {
103
+ const value = options[name];
104
+ if (value !== undefined && typeof value !== 'function') throw host(`options.${name} must be a function`);
105
+ }
106
+ const identify = options.identify === undefined ? DEFAULT_IDENTIFY : /** @type {Lifecycle['identify']} */ (options.identify);
107
+ const acquire = options.acquire === undefined ? DEFAULT_ACQUIRE : /** @type {Lifecycle['acquire']} */ (options.acquire);
108
+ return Object.freeze({ identify, acquire });
109
+ }
110
+
111
+ /**
112
+ * Read a hook's answer as a lease: an object with an own `host`, an
113
+ * optional function `release`, and (when `acquired`) an optional
114
+ * `settlement` whose `ledger` has `commit`/`fail` and whose `required`
115
+ * is `true`. Anything else is the host's fault.
116
+ * @param {unknown} value
117
+ * @param {'identify' | 'acquire'} hook
118
+ * @returns {Lease}
119
+ * @throws {HostLifecycleError}
120
+ */
121
+ export function leaseOf(value, hook) {
122
+ const v = /** @type {any} */ (value);
123
+ if (v === null || typeof v !== 'object' || !Object.hasOwn(v, 'host')) {
124
+ throw new HostLifecycleError(`${hook} must answer a lease { host, release? } — an object with an own host member`);
125
+ }
126
+ const release = v.release;
127
+ if (release !== undefined && typeof release !== 'function') {
128
+ throw new HostLifecycleError(`${hook}: a lease's release must be a function`);
129
+ }
130
+ let settlement = null;
131
+ if (hook === 'acquire' && v.settlement !== undefined && v.settlement !== null) {
132
+ const s = v.settlement;
133
+ if (typeof s !== 'object' || s.required !== true || s.ledger === null || typeof s.ledger !== 'object'
134
+ || typeof s.ledger.commit !== 'function' || typeof s.ledger.fail !== 'function') {
135
+ throw new HostLifecycleError('acquire: a lease\'s settlement must be { ledger, required: true } with a ledger that commits and fails');
136
+ }
137
+ settlement = { ledger: s.ledger, required: true };
138
+ }
139
+ else if (hook === 'identify' && v.settlement !== undefined) {
140
+ throw new HostLifecycleError('identify: a settlement belongs to the acquired lease, not the identity');
141
+ }
142
+ return { host: v.host, release, settlement };
143
+ }
144
+
145
+ /**
146
+ * A release that runs at most once, whatever the number of exits that
147
+ * reach it; its own failure is handed to `observed` and swallowed, and
148
+ * the answer says whether the release was clean (`true`) — a later
149
+ * call answers `true` without running anything again.
150
+ * @param {(() => unknown) | undefined} release
151
+ * @param {(error: unknown) => void} observed
152
+ * @returns {() => Promise<boolean>}
153
+ */
154
+ export function once(release, observed) {
155
+ let done = false;
156
+ return () => {
157
+ if (done || release === undefined) return Promise.resolve(true);
158
+ done = true;
159
+ let out;
160
+ try {
161
+ out = release();
162
+ }
163
+ catch (err) {
164
+ observed(err);
165
+ return Promise.resolve(false);
166
+ }
167
+ return isThenable(out) ? toPromise(out).then(() => true, (err) => { observed(err); return false; }) : Promise.resolve(true);
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Classify a hook's settled answer: a lease, a declared failure, or a
173
+ * host fault. Total for hostile values.
174
+ * @param {unknown} value
175
+ * @param {'identify' | 'acquire'} hook
176
+ * @returns {{ kind: 'lease', lease: Lease } | { kind: 'failure', failure: import('./errors.js').ContractFailureValue } | { kind: 'fault', cause: unknown }}
177
+ */
178
+ export function classifyAnswer(value, hook) {
179
+ if (isContractFailure(value)) return { kind: 'failure', failure: value };
180
+ try {
181
+ return { kind: 'lease', lease: leaseOf(value, hook) };
182
+ }
183
+ catch (err) {
184
+ return { kind: 'fault', cause: err };
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Run `identify`: the hook's answer (sync or async) classified.
190
+ * @param {Lifecycle} lifecycle
191
+ * @param {IdentifyMeta} meta
192
+ * @returns {Promise<ReturnType<typeof classifyAnswer>> | ReturnType<typeof classifyAnswer>}
193
+ */
194
+ export function identify(lifecycle, meta) {
195
+ let answer;
196
+ try {
197
+ answer = lifecycle.identify(meta);
198
+ }
199
+ catch (err) {
200
+ return { kind: 'fault', cause: err };
201
+ }
202
+ return isThenable(answer)
203
+ ? toPromise(answer).then((value) => classifyAnswer(value, 'identify'), (err) => ({ kind: 'fault', cause: err }))
204
+ : classifyAnswer(answer, 'identify');
205
+ }
206
+
207
+ /**
208
+ * The private carrier a required settlement's failure travels in: the
209
+ * wire fault the dispatcher intends is preserved while the host's
210
+ * transaction around `enter` rolls back on the rejection.
211
+ */
212
+ export class RollbackCarrier extends Error {
213
+ /**
214
+ * @param {unknown} response - the response the binding will answer
215
+ * @param {unknown} cause - what failed inside `enter`
216
+ */
217
+ constructor(response, cause) {
218
+ super('the required settlement did not commit; the host transaction rolls back', { cause });
219
+ this.name = 'RollbackCarrier';
220
+ this.response = response;
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Run `acquire` around `enter`. `enter` receives the validated lease and
226
+ * answers the binding's result (a response, an outcome) or a promise of
227
+ * it; what the hook itself answers is the value of `enter` — a hook
228
+ * that resolves before `enter` settled, or to something else, is a
229
+ * fault. The result: `{ kind: 'entered', lease, result }` when `enter`
230
+ * ran and settled (its rejection, when it is not a RollbackCarrier of
231
+ * ours, is a fault of the binding's continuation); `{ kind: 'failure' }`
232
+ * for a declared failure answered instead of entering; `{ kind:
233
+ * 'fault' }` for everything else — `enter` never called, called twice,
234
+ * a malformed lease, a throw.
235
+ * @param {Lifecycle} lifecycle
236
+ * @param {unknown} input
237
+ * @param {{ host: unknown }} identity
238
+ * @param {(lease: Lease) => unknown} enter
239
+ * @returns {Promise<{ kind: 'entered', lease: Lease, result: unknown, rolledBack: RollbackCarrier | null, afterFault?: unknown }
240
+ * | { kind: 'failure', failure: import('./errors.js').ContractFailureValue }
241
+ * | { kind: 'fault', cause: unknown }>}
242
+ */
243
+ export function acquire(lifecycle, input, identity, enter) {
244
+ /** @type {Lease | null} */
245
+ let lease = null;
246
+ /** @type {{ settled: boolean, value: unknown } | null} */
247
+ let entered = null;
248
+ let calls = 0;
249
+ /** @type {RollbackCarrier | null} */
250
+ let rolledBack = null;
251
+ const gate = (/** @type {unknown} */ candidate) => {
252
+ calls += 1;
253
+ if (calls > 1) throw new HostLifecycleError('acquire called enter more than once');
254
+ lease = leaseOf(candidate, 'acquire');
255
+ let out;
256
+ try {
257
+ out = enter(lease);
258
+ }
259
+ catch (err) {
260
+ out = Promise.reject(err);
261
+ }
262
+ const settled = toPromise(out).then(
263
+ (value) => {
264
+ entered = { settled: true, value };
265
+ return value;
266
+ },
267
+ (err) => {
268
+ if (err instanceof RollbackCarrier) {
269
+ rolledBack = err;
270
+ entered = { settled: true, value: err.response };
271
+ }
272
+ throw err;
273
+ });
274
+ return settled;
275
+ };
276
+ let answer;
277
+ try {
278
+ answer = lifecycle.acquire(input, identity, gate);
279
+ }
280
+ catch (err) {
281
+ return Promise.resolve(finish(err, true));
282
+ }
283
+ return toPromise(answer).then((value) => finish(value, false), (err) => finish(err, true));
284
+
285
+ /**
286
+ * @param {unknown} value - the hook's settled value or rejection
287
+ * @param {boolean} rejected
288
+ */
289
+ function finish(value, rejected) {
290
+ if (rejected) {
291
+ // our own refusal — enter called twice, a malformed lease — is the
292
+ // host's fault whatever else settled
293
+ if (value instanceof HostLifecycleError) return { kind: 'fault', cause: value };
294
+ if (value instanceof RollbackCarrier && entered !== null && lease !== null) {
295
+ // the host transaction rolled back on our carrier: the intended fault is the answer
296
+ return { kind: 'entered', lease, result: value.response, rolledBack: value };
297
+ }
298
+ if (rolledBack !== null && lease !== null) {
299
+ // the host wrapped our carrier in a rejection of its own (a transaction that rethrows): the intent stands
300
+ return { kind: 'entered', lease, result: rolledBack.response, rolledBack };
301
+ }
302
+ if (entered !== null && lease !== null) {
303
+ // the hook rejected after enter settled: the settled result stands, the rejection is the host's fault to observe
304
+ return { kind: 'entered', lease, result: entered.value, rolledBack: null, afterFault: value };
305
+ }
306
+ return { kind: 'fault', cause: value };
307
+ }
308
+ if (calls === 0) {
309
+ if (isContractFailure(value)) return { kind: 'failure', failure: value };
310
+ return { kind: 'fault', cause: new HostLifecycleError('acquire answered without calling enter and without a declared failure') };
311
+ }
312
+ if (entered === null || lease === null) {
313
+ return { kind: 'fault', cause: new HostLifecycleError('acquire resolved before enter settled') };
314
+ }
315
+ return { kind: 'entered', lease, result: entered.value, rolledBack };
316
+ }
317
+ }
318
+
319
+ export { ContractFailure };
@@ -0,0 +1,337 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Byte bodies of the HTTP binding, carrier-neutral: the one
4
+ * normalizer that turns whatever an adapter or a handler hands over (a
5
+ * string, bytes, an async iterable of chunks, a Web `ReadableStream`)
6
+ * into the shape the pipeline reads; the bounded collector a JSON
7
+ * operation drains its source through (a JSON body must be parsed and
8
+ * validated whole, so it materializes — under the operation's limit
9
+ * and never past it); and the counting source an opaque handler
10
+ * receives, which never yields a byte past the limit and cancels its
11
+ * upstream exactly once. Nothing here keeps a chunk it has handed on:
12
+ * the opaque path holds one chunk at a time, and the JSON path holds
13
+ * at most `maxBodyBytes`.
14
+ */
15
+
16
+ /**
17
+ * A body as the pipeline reads it: text, bytes, a pull source of byte
18
+ * chunks, or none.
19
+ * @typedef {string | Uint8Array | AsyncIterable<Uint8Array> | null} Body
20
+ */
21
+
22
+ /**
23
+ * The limit crossing a counting source raises to whoever is pulling
24
+ * it: recognized by class, so a handler that lets it propagate answers
25
+ * `JC2003` rather than a host fault, and a handler that catches it
26
+ * decides for itself.
27
+ */
28
+ export class BodyLimitError extends Error {
29
+ /** @param {number} limit */
30
+ constructor(limit) {
31
+ super(`the request body exceeds its ${limit}-byte limit`);
32
+ this.name = 'BodyLimitError';
33
+ /** @type {number} */
34
+ this.limit = limit;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Whether a value is an async iterable — the pull shape of a byte source.
40
+ * @param {unknown} value
41
+ * @returns {value is AsyncIterable<Uint8Array>}
42
+ */
43
+ export function isAsyncByteSource(value) {
44
+ return value !== null && typeof value === 'object' && typeof (/** @type {any} */ (value))[Symbol.asyncIterator] === 'function';
45
+ }
46
+
47
+ /**
48
+ * Whether a value is a Web `ReadableStream` (by its reader, the one
49
+ * member every platform's stream has).
50
+ * @param {unknown} value
51
+ * @returns {value is ReadableStream<Uint8Array>}
52
+ */
53
+ export function isReadableStream(value) {
54
+ return value !== null && typeof value === 'object' && typeof (/** @type {any} */ (value)).getReader === 'function';
55
+ }
56
+
57
+ /**
58
+ * An async iterable over a Web stream's reader. `return()` cancels the
59
+ * stream once; a completed read releases the lock.
60
+ * @param {ReadableStream<Uint8Array>} stream
61
+ * @returns {AsyncIterable<Uint8Array>}
62
+ */
63
+ function readerSource(stream) {
64
+ return {
65
+ [Symbol.asyncIterator]() {
66
+ const reader = stream.getReader();
67
+ let done = false;
68
+ return {
69
+ async next() {
70
+ if (done) return { done: true, value: undefined };
71
+ let r;
72
+ try {
73
+ r = await reader.read();
74
+ }
75
+ catch (err) {
76
+ done = true;
77
+ throw err;
78
+ }
79
+ if (r.done) {
80
+ done = true;
81
+ reader.releaseLock();
82
+ return { done: true, value: undefined };
83
+ }
84
+ return { done: false, value: r.value };
85
+ },
86
+ async return(value) {
87
+ if (!done) {
88
+ done = true;
89
+ try {
90
+ await reader.cancel();
91
+ }
92
+ catch {
93
+ // a stream that refuses the cancel is already gone
94
+ }
95
+ }
96
+ return { done: true, value };
97
+ },
98
+ };
99
+ },
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Normalize a body: `undefined`/`null` → `null`; a string or bytes
105
+ * pass; a Web stream becomes an async iterable over its reader; an
106
+ * async iterable passes. Anything else answers `undefined` — not a
107
+ * body, for the caller to refuse.
108
+ * @param {unknown} body
109
+ * @returns {Body | undefined}
110
+ */
111
+ export function normalizeBody(body) {
112
+ if (body === undefined || body === null) return null;
113
+ if (typeof body === 'string' || body instanceof Uint8Array) return body;
114
+ if (isReadableStream(body)) return readerSource(body);
115
+ if (isAsyncByteSource(body)) return body;
116
+ return undefined;
117
+ }
118
+
119
+ /**
120
+ * Concatenate collected chunks into one Uint8Array.
121
+ * @param {Uint8Array[]} chunks
122
+ * @param {number} total
123
+ * @returns {Uint8Array}
124
+ */
125
+ function concat(chunks, total) {
126
+ if (chunks.length === 1) return chunks[0];
127
+ const out = new Uint8Array(total);
128
+ let offset = 0;
129
+ for (let i = 0; i < chunks.length; i++) {
130
+ out.set(chunks[i], offset);
131
+ offset += chunks[i].byteLength;
132
+ }
133
+ return out;
134
+ }
135
+
136
+ /**
137
+ * Cancel an iterator once, swallowing what the cancel throws.
138
+ * @param {AsyncIterator<Uint8Array>} iterator
139
+ */
140
+ async function cancelIterator(iterator) {
141
+ if (typeof iterator.return !== 'function') return;
142
+ try {
143
+ await iterator.return();
144
+ }
145
+ catch {
146
+ // an upstream that refuses its cancel is already gone
147
+ }
148
+ }
149
+
150
+ /**
151
+ * What the collector answers: the whole body, or why it stopped —
152
+ * `limit` (the read crossed `limit`; the crossing chunk was never
153
+ * retained), `aborted` (the signal fired between pulls), `error` (the
154
+ * source threw, or yielded a non-byte chunk). In every failed case the
155
+ * upstream iterator's `return()` ran exactly once.
156
+ * @typedef {{ ok: true, bytes: Uint8Array }
157
+ * | { ok: false, kind: 'limit' | 'aborted' }
158
+ * | { ok: false, kind: 'error', cause: unknown }} Collected
159
+ */
160
+
161
+ /**
162
+ * Drain a byte source into one `Uint8Array` under a limit: at most
163
+ * `limit` bytes are ever held; the chunk that would cross it is not
164
+ * retained, the upstream is cancelled once, and the crossing is
165
+ * reported.
166
+ * @param {AsyncIterable<Uint8Array>} source
167
+ * @param {number} limit - inclusive
168
+ * @param {AbortSignal | null} signal - checked between pulls
169
+ * @returns {Promise<Collected>}
170
+ */
171
+ export async function collectBytes(source, limit, signal) {
172
+ const iterator = source[Symbol.asyncIterator]();
173
+ /** @type {Uint8Array[]} */
174
+ const chunks = [];
175
+ let total = 0;
176
+ for (;;) {
177
+ if (signal !== null && signal.aborted) {
178
+ await cancelIterator(iterator);
179
+ return { ok: false, kind: 'aborted' };
180
+ }
181
+ let r;
182
+ try {
183
+ r = await iterator.next();
184
+ }
185
+ catch (err) {
186
+ await cancelIterator(iterator);
187
+ return { ok: false, kind: 'error', cause: err };
188
+ }
189
+ if (r.done) break;
190
+ const chunk = r.value;
191
+ if (!(chunk instanceof Uint8Array)) {
192
+ await cancelIterator(iterator);
193
+ return { ok: false, kind: 'error', cause: new TypeError('a body source must yield Uint8Array chunks') };
194
+ }
195
+ total += chunk.byteLength;
196
+ if (total > limit) {
197
+ await cancelIterator(iterator);
198
+ return { ok: false, kind: 'limit' };
199
+ }
200
+ chunks.push(chunk);
201
+ }
202
+ return { ok: true, bytes: total === 0 ? new Uint8Array(0) : concat(chunks, total) };
203
+ }
204
+
205
+ /**
206
+ * The state of a counting source, readable by the binding that made it.
207
+ * @typedef {Object} SourceState
208
+ * @property {boolean} started - a chunk was pulled
209
+ * @property {boolean} finished - EOF was reached, or the source was cancelled
210
+ * @property {boolean} cancelled - `return()` ran (a crossing, a consumer's return, the binding's cancel)
211
+ * @property {boolean} crossed - the limit was crossed
212
+ * @property {number} bytes - the bytes yielded so far
213
+ */
214
+
215
+ /**
216
+ * A counting source over an upstream: what an opaque handler receives.
217
+ * @typedef {AsyncIterable<Uint8Array> & { cancel: () => Promise<void>, state: SourceState }} CountingSource
218
+ */
219
+
220
+ /**
221
+ * Wrap an upstream source so it never yields a byte past `limit`: the
222
+ * chunk that would cross it is not yielded — the upstream is cancelled
223
+ * once and a `BodyLimitError` is thrown to the puller. `return()` (a
224
+ * consumer that stops early) and `cancel()` (the binding, when a
225
+ * response goes out with the request unread) both cancel the upstream
226
+ * exactly once; `state` says what happened.
227
+ * @param {AsyncIterable<Uint8Array>} upstream
228
+ * @param {number} limit
229
+ * @returns {CountingSource}
230
+ */
231
+ export function countingSource(upstream, limit) {
232
+ /** @type {SourceState} */
233
+ const state = { started: false, finished: false, cancelled: false, crossed: false, bytes: 0 };
234
+ /** @type {AsyncIterator<Uint8Array> | null} */
235
+ let iterator = null;
236
+ const cancel = async () => {
237
+ if (state.finished) return;
238
+ state.finished = true;
239
+ state.cancelled = true;
240
+ if (iterator === null) iterator = upstream[Symbol.asyncIterator]();
241
+ await cancelIterator(iterator);
242
+ };
243
+ return {
244
+ [Symbol.asyncIterator]() {
245
+ return {
246
+ async next() {
247
+ if (state.finished) return { done: true, value: undefined };
248
+ state.started = true;
249
+ if (iterator === null) iterator = upstream[Symbol.asyncIterator]();
250
+ let r;
251
+ try {
252
+ r = await iterator.next();
253
+ }
254
+ catch (err) {
255
+ state.finished = true;
256
+ throw err;
257
+ }
258
+ if (state.finished) return { done: true, value: undefined };
259
+ if (r.done) {
260
+ state.finished = true;
261
+ return { done: true, value: undefined };
262
+ }
263
+ const chunk = r.value;
264
+ if (!(chunk instanceof Uint8Array)) {
265
+ await cancel();
266
+ throw new TypeError('a body source must yield Uint8Array chunks');
267
+ }
268
+ state.bytes += chunk.byteLength;
269
+ if (state.bytes > limit) {
270
+ state.crossed = true;
271
+ await cancel();
272
+ throw new BodyLimitError(limit);
273
+ }
274
+ return { done: false, value: chunk };
275
+ },
276
+ async return(value) {
277
+ await cancel();
278
+ return { done: true, value };
279
+ },
280
+ };
281
+ },
282
+ cancel,
283
+ state,
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Run `after` once when a response source completes, throws or is
289
+ * cancelled by its consumer — the hook a binding uses to release what
290
+ * the response held (an unread request source). A throw from the
291
+ * source reaches the consumer after the hook; the hook's own answer
292
+ * is awaited but never replaces the source's outcome.
293
+ * @param {AsyncIterable<Uint8Array>} source
294
+ * @param {(cause: unknown) => unknown} after - `cause` is the throw, or `undefined` on EOF/return
295
+ * @returns {AsyncIterable<Uint8Array>}
296
+ */
297
+ export function onSettled(source, after) {
298
+ let settled = false;
299
+ /** @param {unknown} cause */
300
+ const settle = async (cause) => {
301
+ if (settled) return;
302
+ settled = true;
303
+ try {
304
+ await after(cause);
305
+ }
306
+ catch {
307
+ // the hook's failure is not the stream's
308
+ }
309
+ };
310
+ return {
311
+ [Symbol.asyncIterator]() {
312
+ const iterator = source[Symbol.asyncIterator]();
313
+ return {
314
+ async next() {
315
+ if (settled) return { done: true, value: undefined };
316
+ let r;
317
+ try {
318
+ r = await iterator.next();
319
+ }
320
+ catch (err) {
321
+ await settle(err);
322
+ throw err;
323
+ }
324
+ if (r.done) await settle(undefined);
325
+ return r;
326
+ },
327
+ async return(value) {
328
+ if (!settled) {
329
+ await cancelIterator(iterator);
330
+ await settle(undefined);
331
+ }
332
+ return { done: true, value };
333
+ },
334
+ };
335
+ },
336
+ };
337
+ }