@jarenjs/contract 0.49.2 → 0.66.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.
Files changed (52) hide show
  1. package/README.md +191 -30
  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/project/tools.d.ts +1 -1
  20. package/dist/types/project/typescript.d.ts +11 -0
  21. package/dist/types/runtime.d.ts +25 -0
  22. package/dist/types/stream/client.d.ts +14 -3
  23. package/dist/types/stream/server.d.ts +218 -44
  24. package/dist/types/stream/sse.d.ts +10 -0
  25. package/docs/APP-INTEGRATION.md +4 -2
  26. package/docs/CONTRACT-FORMAT.md +617 -145
  27. package/package.json +5 -5
  28. package/src/adapters/fetch.js +144 -25
  29. package/src/adapters/node.js +246 -82
  30. package/src/cli.js +22 -16
  31. package/src/client/http.js +588 -189
  32. package/src/compat.js +1 -1
  33. package/src/errors.js +3 -0
  34. package/src/host.js +319 -0
  35. package/src/http/body.js +337 -0
  36. package/src/http/dispatch.js +511 -75
  37. package/src/http/serve.js +39 -5
  38. package/src/http/wire.js +33 -14
  39. package/src/ledger.js +119 -36
  40. package/src/local/index.js +91 -35
  41. package/src/messages.js +2 -0
  42. package/src/path.js +9 -3
  43. package/src/pipeline.js +18 -1
  44. package/src/port/client.js +39 -6
  45. package/src/port/serve.js +207 -69
  46. package/src/project/tools.js +9 -2
  47. package/src/project/typescript.js +91 -1
  48. package/src/project/typescript.jtlt.json +39 -7
  49. package/src/runtime.js +36 -0
  50. package/src/stream/client.js +40 -6
  51. package/src/stream/server.js +573 -138
  52. package/src/stream/sse.js +2 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/contract",
3
3
  "private": false,
4
- "version": "0.49.2",
4
+ "version": "0.66.1",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -102,9 +102,9 @@
102
102
  "prepack": "npm run build:types"
103
103
  },
104
104
  "dependencies": {
105
- "@jarenjs/core": "^0.49.2",
106
- "@jarenjs/json": "^0.49.2",
107
- "@jarenjs/validate": "^0.49.2",
108
- "@jarenjs/emit": "^0.49.2"
105
+ "@jarenjs/core": "^0.66.1",
106
+ "@jarenjs/json": "^0.66.1",
107
+ "@jarenjs/validate": "^0.66.1",
108
+ "@jarenjs/emit": "^0.66.1"
109
109
  }
110
110
  }
@@ -6,22 +6,64 @@
6
6
  * Dependency-free and structurally typed: it needs only the platform's
7
7
  * `Request`, `Response` and `Headers`.
8
8
  *
9
- * The adapter reads the body only when the matched operation can carry
10
- * one (`text()` for a JSON operation, `arrayBuffer()` for an opaque one),
11
- * refuses a declared `content-length` above the operation's limit
12
- * BEFORE reading (the dispatcher answers the 413 from the header), never
13
- * reads an unmatched request's body, and hands everything else to
14
- * `dispatch`. `Headers` combines repeated field lines with `, `, so a
15
- * repeated scalar header member is invisible here (the node adapter sees
16
- * distinct lines); `text()` decodes with replacement, so invalid UTF-8
17
- * reaches the JSON parser as U+FFFD (the node adapter hands bytes over
18
- * and the dispatcher's strict decode answers `JC2005`).
9
+ * The adapter hands the request's body stream to the dispatcher as a
10
+ * pull source only when the matched operation can carry one a JSON
11
+ * operation drains it under its limit there (the strict UTF-8 decode
12
+ * decides `JC2005`, as through the node adapter), an opaque handler
13
+ * pulls it chunk by chunk refuses a declared `content-length` above
14
+ * the operation's limit BEFORE reading (the dispatcher answers the 413
15
+ * from the header), never reads an unmatched request's body, and hands
16
+ * everything else to `dispatch`. A streamed response body becomes a
17
+ * `ReadableStream` that pulls one chunk per demand. `Headers` combines
18
+ * repeated field lines with `, `, so a repeated scalar header member is
19
+ * invisible here (the node adapter sees distinct lines).
19
20
  */
20
21
 
21
22
  /**
22
23
  * @typedef {import('../http/serve.js').HttpDispatcher} HttpDispatcher
23
24
  */
24
25
 
26
+ /**
27
+ * A Web `ReadableStream` over an async byte source: one `pull` awaits
28
+ * one `next()`, `cancel()` runs the source's `return()` once, and a
29
+ * source that throws errors the stream.
30
+ * @param {AsyncIterable<Uint8Array>} source
31
+ * @returns {ReadableStream<Uint8Array>}
32
+ */
33
+ function bodyStream(source) {
34
+ const iterator = source[Symbol.asyncIterator]();
35
+ let cancelled = false;
36
+ return new ReadableStream({
37
+ async pull(controller) {
38
+ let r;
39
+ try {
40
+ r = await iterator.next();
41
+ }
42
+ catch (err) {
43
+ controller.error(err);
44
+ return;
45
+ }
46
+ if (r.done) {
47
+ controller.close();
48
+ return;
49
+ }
50
+ controller.enqueue(r.value);
51
+ },
52
+ async cancel() {
53
+ if (cancelled) return;
54
+ cancelled = true;
55
+ if (typeof iterator.return === 'function') {
56
+ try {
57
+ await iterator.return();
58
+ }
59
+ catch {
60
+ // the source refused its cancel; it is released either way
61
+ }
62
+ }
63
+ },
64
+ }, { highWaterMark: 0 });
65
+ }
66
+
25
67
  /**
26
68
  * Whether the request could carry a body the operation reads.
27
69
  * @param {string} method
@@ -61,7 +103,10 @@ export function toFetchHandler(dispatcher) {
61
103
  if (hit !== null) {
62
104
  const declared = Number(headers['content-length']);
63
105
  if (!(Number.isFinite(declared) && declared > hit.op.policy.limits.maxBodyBytes)) {
64
- body = hit.op.http.opaque ? new Uint8Array(await request.arrayBuffer()) : await request.text();
106
+ // the platform's stream reaches the dispatcher as a pull
107
+ // source: a JSON operation drains it under its limit there,
108
+ // an opaque handler pulls it chunk by chunk
109
+ body = request.body;
65
110
  }
66
111
  }
67
112
  }
@@ -70,40 +115,114 @@ export function toFetchHandler(dispatcher) {
70
115
  method, url: url.pathname + url.search, headers, body, signal: request.signal,
71
116
  });
72
117
  if (typeof response.stream === 'function') {
73
- // an SSE response: the pump writes into a ReadableStream; a
74
- // consumer cancel stops the subscription (the request signal
118
+ // an SSE response: the pump writes into a ReadableStream that
119
+ // produces on DEMAND a write settles only when the consumer's
120
+ // pull takes the chunk, so the pump never runs ahead of the
121
+ // reader; a consumer cancel rejects the write waiting for demand
122
+ // and stops the subscription exactly once (the request signal
75
123
  // covers the disconnect path too)
76
124
  const pump = response.stream;
77
125
  const encoder = new TextEncoder();
126
+ /** @type {ReturnType<typeof pump> | null} */
127
+ let runner = null;
128
+ let stopped = false;
129
+ const stopOnce = () => {
130
+ if (stopped) return;
131
+ stopped = true;
132
+ if (runner !== null) runner.stop();
133
+ };
134
+ /** @type {ReadableStreamDefaultController<Uint8Array> | null} */
135
+ let controllerRef = null;
136
+ /** the write waiting for the consumer's demand */
137
+ /** @type {{ chunk: string, resolve: () => void, reject: (reason: unknown) => void } | null} */
138
+ let waiting = null;
139
+ /** the pull no write has answered yet */
78
140
  /** @type {(() => void) | null} */
79
- let stop = null;
141
+ let demand = null;
142
+ let cancelled = false;
143
+ /** @type {unknown} */
144
+ let cancelReason;
145
+ /** One chunk crosses when a write and a pull are both present. */
146
+ const serve = () => {
147
+ if (waiting === null || demand === null || controllerRef === null) return;
148
+ const w = waiting;
149
+ const d = demand;
150
+ waiting = null;
151
+ demand = null;
152
+ try {
153
+ controllerRef.enqueue(encoder.encode(w.chunk));
154
+ }
155
+ catch (err) {
156
+ w.reject(err);
157
+ d();
158
+ return;
159
+ }
160
+ w.resolve();
161
+ d();
162
+ };
80
163
  const streamBody = new ReadableStream({
81
164
  start(controller) {
82
- stop = pump({
83
- write: (chunk) => {
165
+ controllerRef = controller;
166
+ runner = pump({
167
+ write: (chunk) => new Promise((resolve, reject) => {
168
+ if (cancelled) {
169
+ reject(cancelReason);
170
+ return;
171
+ }
172
+ waiting = { chunk, resolve, reject };
173
+ serve();
174
+ }),
175
+ end: () => {
84
176
  try {
85
- controller.enqueue(encoder.encode(chunk));
177
+ controller.close();
86
178
  }
87
179
  catch {
88
- // a closed stream drops the write; the abort path stops the pump
180
+ // already closed or cancelled
89
181
  }
90
182
  },
91
- end: () => {
183
+ abort: (reason) => {
184
+ // the carrier is torn down: the consumer's pending read
185
+ // rejects with the reason, and the subscription is
186
+ // released as a destroyed node socket releases it
92
187
  try {
93
- controller.close();
188
+ controller.error(reason);
94
189
  }
95
190
  catch {
96
- // already closed
191
+ // already closed or cancelled
97
192
  }
193
+ stopOnce();
98
194
  },
99
195
  });
100
196
  },
101
- cancel() {
102
- if (stop !== null) stop();
197
+ pull() {
198
+ return new Promise((resolve) => {
199
+ demand = () => resolve(undefined);
200
+ serve();
201
+ });
103
202
  },
104
- });
203
+ cancel(reason) {
204
+ cancelled = true;
205
+ cancelReason = reason === undefined ? new Error('the stream was cancelled') : reason;
206
+ if (waiting !== null) {
207
+ const w = waiting;
208
+ waiting = null;
209
+ w.reject(cancelReason);
210
+ }
211
+ if (demand !== null) {
212
+ const d = demand;
213
+ demand = null;
214
+ d();
215
+ }
216
+ stopOnce();
217
+ },
218
+ }, { highWaterMark: 0 });
105
219
  return new Response(streamBody, { status: response.status, headers: response.headers });
106
220
  }
107
- return new Response(/** @type {BodyInit | null} */ (response.body), { status: response.status, headers: response.headers });
221
+ const out = response.body;
222
+ if (out !== null && typeof out === 'object' && !(out instanceof Uint8Array)) {
223
+ // a streamed body: one pull per chunk, cancelled once by the consumer
224
+ return new Response(bodyStream(out), { status: response.status, headers: response.headers });
225
+ }
226
+ return new Response(/** @type {BodyInit | null} */ (out), { status: response.status, headers: response.headers });
108
227
  };
109
228
  }
@@ -7,21 +7,31 @@
7
7
  * `headersDistinct` (or `headers`) and a readable-stream event surface,
8
8
  * the response anything with `writeHead`/`end`.
9
9
  *
10
- * The body is collected chunk by chunk up to the matched operation's
11
- * `policy.limits.maxBodyBytes`; on overflow the read stops, the 413 is
12
- * answered with `connection: close`, and once the response has flushed
13
- * the socket lingers draining and discarding the rest of the upload
14
- * (bounded by a grace timer) before it is destroyed, so the close is a
15
- * FIN the peer can read the 413 through, not an RST that discards it. A declared `content-length` above the limit
16
- * is never read at all; an unmatched request's body is never read (the
10
+ * The body reaches the dispatcher as a PULL SOURCE over the request's
11
+ * own chunks nothing is collected here: a JSON operation drains it
12
+ * under `policy.limits.maxBodyBytes` in the dispatcher (its strict
13
+ * UTF-8 decode decides `JC2005`), an opaque handler pulls it one chunk
14
+ * at a time through a source that never yields past the limit. When an
15
+ * upload was pulled and left unread (a limit crossing, a response
16
+ * before EOF) the answer carries `connection: close`, and once it has
17
+ * flushed the socket lingers — draining and discarding the rest of the
18
+ * upload (bounded by a grace timer) before it is destroyed, so the
19
+ * close is a FIN the peer can read the 413 through, not an RST that
20
+ * discards it. A declared `content-length` above the limit is never
21
+ * read at all; an unmatched request's body is never read (the
17
22
  * dispatcher answers 404/405 without it and the platform discards the
18
- * rest). Bytes are handed to the dispatcher as received for a JSON
19
- * operation too, so its strict UTF-8 decode decides `JC2005`. Repeated
23
+ * rest). A streamed response body is written chunk by chunk behind the
24
+ * socket's `drain`. Repeated
20
25
  * header lines reach the dispatcher as arrays (`headersDistinct`), which
21
26
  * is how a repeated scalar header member becomes `JC2015`. `ctx.signal`
22
- * aborts when the client goes away before the response finished.
27
+ * aborts when the client goes away before the response finished. A
28
+ * streaming (SSE) response writes each event only after the previous
29
+ * one drained: a `res.write()` that answers `false` parks the pump
30
+ * until `drain`, so a slow reader never grows the process's buffers.
23
31
  */
24
32
 
33
+ import { createAwaitedSink } from '@jarenjs/core/async';
34
+
25
35
  /**
26
36
  * @typedef {import('../http/serve.js').HttpDispatcher} HttpDispatcher
27
37
  */
@@ -49,18 +59,212 @@ const LINGER_MS = 1000;
49
59
 
50
60
  /**
51
61
  * The response surface the adapter writes — `http.ServerResponse` fits.
52
- * `write` and `flushHeaders` are read only for a streaming (SSE)
53
- * response.
62
+ * `write`, `flushHeaders`, `once`/`removeListener` (or `off`),
63
+ * `destroy`, `destroyed` and `writableEnded` are read only for a
64
+ * streaming (SSE) response: `write`'s `false` is waited out on `drain`
65
+ * (settled on `close`/`error` too, with the listeners removed), and a
66
+ * response already ended or destroyed refuses the write instead of
67
+ * emitting `write after end`.
54
68
  * @typedef {Object} NodeResponseLike
55
69
  * @property {(status: number, headers?: Record<string, string>) => unknown} writeHead
56
70
  * @property {(body?: string | Uint8Array, callback?: () => void) => unknown} end
57
71
  * @property {(event: string, listener: (...args: any[]) => void) => unknown} on
72
+ * @property {(event: string, listener: (...args: any[]) => void) => unknown} [once]
73
+ * @property {(event: string, listener: (...args: any[]) => void) => unknown} [removeListener]
74
+ * @property {(event: string, listener: (...args: any[]) => void) => unknown} [off]
58
75
  * @property {(chunk: string | Uint8Array) => unknown} [write]
59
76
  * @property {() => unknown} [flushHeaders]
77
+ * @property {(error?: Error) => unknown} [destroy]
60
78
  * @property {boolean} [writableFinished]
79
+ * @property {boolean} [writableEnded]
80
+ * @property {boolean} [destroyed]
61
81
  * @property {boolean} [headersSent]
62
82
  */
63
83
 
84
+ /**
85
+ * The streaming sink over a platform response: `write` hands the chunk
86
+ * to `res.write` and answers nothing when the platform took it (`true`),
87
+ * or a promise that resolves on the next `drain` when it answered
88
+ * `false` — so the pump behind it writes the next event only once the
89
+ * socket has room — and rejects when the response closes or errors
90
+ * first. Every listener the wait registered is removed at settlement.
91
+ * `end` ends the response; `abort` destroys it.
92
+ * @param {NodeResponseLike} res
93
+ * @returns {import('@jarenjs/core/async').SinkLike<string>}
94
+ */
95
+ function responseSink(res) {
96
+ const listen = typeof res.once === 'function' ? res.once.bind(res) : res.on.bind(res);
97
+ const unlisten = typeof res.removeListener === 'function'
98
+ ? res.removeListener.bind(res)
99
+ : typeof res.off === 'function' ? res.off.bind(res) : null;
100
+ return {
101
+ write: (chunk) => {
102
+ if (typeof res.write !== 'function') return undefined;
103
+ if (res.writableEnded === true || res.destroyed === true) {
104
+ return Promise.reject(new Error('the response is closed'));
105
+ }
106
+ let taken;
107
+ try {
108
+ taken = res.write(chunk);
109
+ }
110
+ catch (err) {
111
+ return Promise.reject(err);
112
+ }
113
+ if (taken !== false) return undefined;
114
+ return new Promise((resolve, reject) => {
115
+ let settled = false;
116
+ /** @type {() => void} */
117
+ const off = () => {
118
+ settled = true;
119
+ if (unlisten === null) return;
120
+ unlisten('drain', onDrain);
121
+ unlisten('close', onClose);
122
+ unlisten('error', onError);
123
+ };
124
+ const onDrain = () => {
125
+ if (settled) return;
126
+ off();
127
+ resolve();
128
+ };
129
+ const onClose = () => {
130
+ if (settled) return;
131
+ off();
132
+ reject(new Error('the response closed before it drained'));
133
+ };
134
+ /** @param {unknown} err */
135
+ const onError = (err) => {
136
+ if (settled) return;
137
+ off();
138
+ reject(err);
139
+ };
140
+ listen('drain', onDrain);
141
+ listen('close', onClose);
142
+ listen('error', onError);
143
+ });
144
+ },
145
+ end: () => {
146
+ try {
147
+ res.end();
148
+ }
149
+ catch {
150
+ // the socket may already be gone
151
+ }
152
+ },
153
+ abort: () => {
154
+ if (typeof res.destroy === 'function' && res.destroyed !== true) res.destroy();
155
+ },
156
+ };
157
+ }
158
+
159
+ /**
160
+ * The request as a pull source of its body chunks — what a body-carrying
161
+ * matched request hands the dispatcher. Chunks come from the platform's
162
+ * own async iterator (paused between pulls, so an unpulled upload never
163
+ * fills memory); `return()` does NOT destroy the request — a mid-upload
164
+ * cancel (a limit crossing, a response before EOF) must answer through
165
+ * a readable close, so the source only records that it was cancelled
166
+ * and `send` lingers and destroys after the response flushed. `state`
167
+ * says whether the request was pulled at all and whether it reached
168
+ * EOF, which decides `connection: close`.
169
+ * @param {NodeRequestLike} req
170
+ * @returns {AsyncIterable<Uint8Array> & { state: { started: boolean, ended: boolean, cancelled: boolean } }}
171
+ */
172
+ function requestSource(req) {
173
+ const state = { started: false, ended: false, cancelled: false };
174
+ /** @type {AsyncIterator<Uint8Array> | null} */
175
+ let inner = null;
176
+ return {
177
+ state,
178
+ [Symbol.asyncIterator]() {
179
+ return {
180
+ async next() {
181
+ if (state.ended || state.cancelled) return { done: true, value: undefined };
182
+ state.started = true;
183
+ if (inner === null) {
184
+ const iterable = /** @type {any} */ (req);
185
+ if (typeof iterable[Symbol.asyncIterator] !== 'function') {
186
+ state.ended = true;
187
+ return { done: true, value: undefined };
188
+ }
189
+ inner = iterable[Symbol.asyncIterator]();
190
+ }
191
+ let r;
192
+ try {
193
+ r = await inner.next();
194
+ }
195
+ catch (err) {
196
+ state.cancelled = true;
197
+ throw err;
198
+ }
199
+ if (r.done) {
200
+ state.ended = true;
201
+ return { done: true, value: undefined };
202
+ }
203
+ return { done: false, value: r.value };
204
+ },
205
+ async return(value) {
206
+ // no destroy here: the response decides how the socket closes
207
+ state.cancelled = true;
208
+ if (typeof req.pause === 'function') req.pause();
209
+ return { done: true, value };
210
+ },
211
+ };
212
+ },
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Write a streamed response body: chunk by chunk through the
218
+ * drain-aware sink, then end; the peer going away — or a sink failure —
219
+ * cancels the source exactly once, and a source that throws destroys
220
+ * the response (its status is already on the wire; the body is cut).
221
+ * @param {NodeResponseLike} res
222
+ * @param {AsyncIterable<Uint8Array>} body
223
+ * @param {(() => void) | undefined} done
224
+ */
225
+ function pumpBody(res, body, done) {
226
+ const sink = createAwaitedSink(responseSink(res));
227
+ const iterator = body[Symbol.asyncIterator]();
228
+ let cancelled = false;
229
+ const cancel = async () => {
230
+ if (cancelled) return;
231
+ cancelled = true;
232
+ if (typeof iterator.return === 'function') {
233
+ try {
234
+ await iterator.return();
235
+ }
236
+ catch {
237
+ // the source refused its cancel; it is released either way
238
+ }
239
+ }
240
+ };
241
+ const onClose = () => {
242
+ if (res.writableFinished !== true) void cancel();
243
+ };
244
+ res.on('close', onClose);
245
+ (async () => {
246
+ try {
247
+ for (;;) {
248
+ const r = await iterator.next();
249
+ if (r.done) break;
250
+ if (cancelled) break;
251
+ await sink.write(r.value);
252
+ }
253
+ if (cancelled) {
254
+ await sink.abort(new Error('the response was cancelled'));
255
+ return;
256
+ }
257
+ await sink.end();
258
+ if (done !== undefined) done();
259
+ }
260
+ catch (err) {
261
+ // a source that threw, or a sink that failed: the body is cut
262
+ await cancel();
263
+ await sink.abort(err).catch(() => undefined);
264
+ }
265
+ })();
266
+ }
267
+
64
268
  /**
65
269
  * Whether the request could carry a body the operation reads.
66
270
  * @param {string} method
@@ -99,23 +303,6 @@ function headersOf(req) {
99
303
  return out;
100
304
  }
101
305
 
102
- /**
103
- * Concatenate collected chunks into one Uint8Array.
104
- * @param {Uint8Array[]} chunks
105
- * @param {number} total
106
- * @returns {Uint8Array}
107
- */
108
- function concat(chunks, total) {
109
- if (chunks.length === 1) return chunks[0];
110
- const out = new Uint8Array(total);
111
- let offset = 0;
112
- for (let i = 0; i < chunks.length; i++) {
113
- out.set(chunks[i], offset);
114
- offset += chunks[i].byteLength;
115
- }
116
- return out;
117
- }
118
-
119
306
  /**
120
307
  * Write a dispatcher response to the platform response.
121
308
  * @param {NodeResponseLike} res
@@ -128,30 +315,26 @@ function send(res, response, close, done) {
128
315
  const headers = { ...response.headers };
129
316
  if (typeof response.stream === 'function') {
130
317
  // an SSE response: headers out immediately, then the pump writes
131
- // events until the stream ends (the pump ends the response itself);
132
- // the peer-gone path runs through the request's abort signal
318
+ // events until the stream ends (the pump ends the response itself),
319
+ // each event only once the previous one drained; the peer-gone path
320
+ // runs through the request's abort signal
133
321
  res.writeHead(response.status, headers);
134
322
  if (typeof res.flushHeaders === 'function') res.flushHeaders();
135
- response.stream({
136
- write: (chunk) => {
137
- if (typeof res.write === 'function') res.write(chunk);
138
- },
139
- end: () => {
140
- try {
141
- res.end();
142
- }
143
- catch {
144
- // the socket may already be gone
145
- }
146
- },
147
- });
323
+ response.stream(responseSink(res));
148
324
  return;
149
325
  }
150
326
  const body = response.body;
327
+ if (close) headers.connection = 'close';
328
+ if (body !== null && typeof body === 'object' && !(body instanceof Uint8Array)) {
329
+ // a streamed body: chunked, each chunk behind the previous one's
330
+ // drain; the peer going away cancels the source once
331
+ res.writeHead(response.status, headers);
332
+ pumpBody(res, body, done);
333
+ return;
334
+ }
151
335
  if (body !== null && headers['content-length'] === undefined) {
152
336
  headers['content-length'] = String(typeof body === 'string' ? new TextEncoder().encode(body).byteLength : body.byteLength);
153
337
  }
154
- if (close) headers.connection = 'close';
155
338
  res.writeHead(response.status, headers);
156
339
  if (body === null) res.end(undefined, done);
157
340
  else res.end(body, done);
@@ -214,10 +397,18 @@ export function toNodeHandler(dispatcher, options = {}) {
214
397
  const finish = (response, close) => {
215
398
  send(res, response, close, close ? lingerThenDestroy : undefined);
216
399
  };
217
- /** @param {string | Uint8Array | null} body @param {boolean} close */
218
- const answer = (body, close) => {
400
+ /**
401
+ * Dispatch and answer. A request whose upload was pulled and left
402
+ * unread (a limit crossing, a response before EOF) cannot keep its
403
+ * connection: the answer carries `connection: close` and the socket
404
+ * lingers, draining and discarding the rest, before it is destroyed
405
+ * — so the 413 is readable through a FIN, never lost to an RST. An
406
+ * upload that was never pulled is the platform's to discard.
407
+ * @param {(AsyncIterable<Uint8Array> & { state: { started: boolean, ended: boolean, cancelled: boolean } }) | null} body
408
+ */
409
+ const answer = (body) => {
219
410
  dispatcher.dispatch({ method, url, headers, body, signal: controller.signal })
220
- .then((response) => finish(response, close), (err) => {
411
+ .then((response) => finish(response, body !== null && body.state.started && !body.state.ended), (err) => {
221
412
  // only JC1004 can arrive here, and this adapter builds a
222
413
  // well-formed request; still, a rejection must not hang the socket
223
414
  const message = err instanceof Error ? err.message : String(err);
@@ -231,46 +422,19 @@ export function toNodeHandler(dispatcher, options = {}) {
231
422
  if (hit === null && method === 'HEAD' && head) hit = contract.match('GET', path);
232
423
  }
233
424
  if (hit === null) {
234
- answer(null, false);
425
+ answer(null);
235
426
  return;
236
427
  }
237
- const op = hit.op;
238
- const limit = op.policy.limits.maxBodyBytes;
428
+ const limit = hit.op.policy.limits.maxBodyBytes;
239
429
  const declared = Number(headers['content-length']);
240
430
  if (Number.isFinite(declared) && declared > limit) {
241
431
  // the dispatcher answers the 413 from the header; the body is never read
242
- answer(null, false);
432
+ answer(null);
243
433
  return;
244
434
  }
245
-
246
- /** @type {Uint8Array[]} */
247
- const chunks = [];
248
- let total = 0;
249
- let settled = false;
250
- req.on('data', (chunk) => {
251
- if (settled) return;
252
- const bytes = /** @type {Uint8Array} */ (chunk);
253
- total += bytes.byteLength;
254
- if (total > limit) {
255
- settled = true;
256
- if (typeof req.pause === 'function') req.pause();
257
- // an oversize stream: the bytes read so far already exceed the
258
- // limit, so the dispatcher answers the 413 from that length
259
- // without a body; the request is destroyed after the response
260
- // has flushed
261
- headers['content-length'] = String(total);
262
- answer(null, true);
263
- return;
264
- }
265
- chunks.push(bytes);
266
- });
267
- req.on('end', () => {
268
- if (settled) return;
269
- settled = true;
270
- answer(total === 0 ? null : concat(chunks, total), false);
271
- });
272
- req.on('error', () => {
273
- settled = true;
274
- });
435
+ // the body reaches the dispatcher as a pull source: a JSON operation
436
+ // drains it under its limit there, an opaque handler pulls it chunk
437
+ // by chunk, and nothing is collected here
438
+ answer(requestSource(req));
275
439
  };
276
440
  }