@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.
- package/README.md +191 -30
- package/dist/types/adapters/fetch.d.ts +11 -10
- package/dist/types/adapters/node.d.ts +24 -10
- package/dist/types/client/http.d.ts +77 -10
- package/dist/types/compat.d.ts +1 -1
- package/dist/types/errors.d.ts +3 -0
- package/dist/types/host.d.ts +179 -0
- package/dist/types/http/body.d.ts +147 -0
- package/dist/types/http/dispatch.d.ts +26 -2
- package/dist/types/http/serve.d.ts +46 -3
- package/dist/types/http/wire.d.ts +31 -17
- package/dist/types/ledger.d.ts +57 -12
- package/dist/types/local/index.d.ts +7 -1
- package/dist/types/messages.d.ts +2 -0
- package/dist/types/path.d.ts +4 -2
- package/dist/types/pipeline.d.ts +15 -1
- package/dist/types/port/client.d.ts +18 -1
- package/dist/types/port/serve.d.ts +38 -5
- package/dist/types/project/tools.d.ts +1 -1
- package/dist/types/project/typescript.d.ts +11 -0
- package/dist/types/runtime.d.ts +25 -0
- package/dist/types/stream/client.d.ts +14 -3
- package/dist/types/stream/server.d.ts +218 -44
- package/dist/types/stream/sse.d.ts +10 -0
- package/docs/APP-INTEGRATION.md +4 -2
- package/docs/CONTRACT-FORMAT.md +617 -145
- package/package.json +5 -5
- package/src/adapters/fetch.js +144 -25
- package/src/adapters/node.js +246 -82
- package/src/cli.js +22 -16
- package/src/client/http.js +588 -189
- package/src/compat.js +1 -1
- package/src/errors.js +3 -0
- package/src/host.js +319 -0
- package/src/http/body.js +337 -0
- package/src/http/dispatch.js +511 -75
- package/src/http/serve.js +39 -5
- package/src/http/wire.js +33 -14
- package/src/ledger.js +119 -36
- package/src/local/index.js +91 -35
- package/src/messages.js +2 -0
- package/src/path.js +9 -3
- package/src/pipeline.js +18 -1
- package/src/port/client.js +39 -6
- package/src/port/serve.js +207 -69
- package/src/project/tools.js +9 -2
- package/src/project/typescript.js +91 -1
- package/src/project/typescript.jtlt.json +39 -7
- package/src/runtime.js +36 -0
- package/src/stream/client.js +40 -6
- package/src/stream/server.js +573 -138
- 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.
|
|
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.
|
|
106
|
-
"@jarenjs/json": "^0.
|
|
107
|
-
"@jarenjs/validate": "^0.
|
|
108
|
-
"@jarenjs/emit": "^0.
|
|
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
|
}
|
package/src/adapters/fetch.js
CHANGED
|
@@ -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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
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
|
|
74
|
-
//
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
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.
|
|
177
|
+
controller.close();
|
|
86
178
|
}
|
|
87
179
|
catch {
|
|
88
|
-
//
|
|
180
|
+
// already closed or cancelled
|
|
89
181
|
}
|
|
90
182
|
},
|
|
91
|
-
|
|
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.
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/adapters/node.js
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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).
|
|
19
|
-
*
|
|
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
|
|
53
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
218
|
-
|
|
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,
|
|
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
|
|
425
|
+
answer(null);
|
|
235
426
|
return;
|
|
236
427
|
}
|
|
237
|
-
const
|
|
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
|
|
432
|
+
answer(null);
|
|
243
433
|
return;
|
|
244
434
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
}
|