@jarenjs/contract 0.43.3 → 0.46.5
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/dist/types/adapters/node.d.ts +14 -3
- package/docs/CONTRACT-FORMAT.md +6 -2
- package/package.json +5 -5
- package/src/adapters/node.js +42 -4
- package/src/client/http.js +11 -0
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
*
|
|
9
9
|
* The body is collected chunk by chunk up to the matched operation's
|
|
10
10
|
* `policy.limits.maxBodyBytes`; on overflow the read stops, the 413 is
|
|
11
|
-
* answered with `connection: close
|
|
12
|
-
* the
|
|
11
|
+
* answered with `connection: close`, and once the response has flushed
|
|
12
|
+
* the socket lingers — draining and discarding the rest of the upload
|
|
13
|
+
* (bounded by a grace timer) before it is destroyed, so the close is a
|
|
14
|
+
* FIN the peer can read the 413 through, not an RST that discards it. A declared `content-length` above the limit
|
|
13
15
|
* is never read at all; an unmatched request's body is never read (the
|
|
14
16
|
* dispatcher answers 404/405 without it and the platform discards the
|
|
15
17
|
* rest). Bytes are handed to the dispatcher as received — for a JSON
|
|
@@ -26,7 +28,10 @@ export type NodeRequestLike = {
|
|
|
26
28
|
headers: Record<string, string | string[] | undefined>;
|
|
27
29
|
on: (event: string, listener: (...args: any[]) => void) => unknown;
|
|
28
30
|
pause?: () => unknown;
|
|
31
|
+
resume?: () => unknown;
|
|
29
32
|
destroy?: (error?: Error) => unknown;
|
|
33
|
+
readableEnded?: boolean;
|
|
34
|
+
destroyed?: boolean;
|
|
30
35
|
};
|
|
31
36
|
export type NodeResponseLike = {
|
|
32
37
|
writeHead: (status: number, headers?: Record<string, string>) => unknown;
|
|
@@ -40,8 +45,14 @@ export type NodeResponseLike = {
|
|
|
40
45
|
/**
|
|
41
46
|
* Put a dispatcher behind Node's `(req, res)` listener.
|
|
42
47
|
* @param {HttpDispatcher} dispatcher
|
|
48
|
+
* @param {{ lingerMs?: number }} [options] - `lingerMs` bounds how long a
|
|
49
|
+
* closing response waits, draining the peer's unfinished upload, before
|
|
50
|
+
* the socket is destroyed (default 1000 ms; see the linger comment
|
|
51
|
+
* below — it is what keeps an overflow 413 readable through the close).
|
|
43
52
|
* @returns {(req: NodeRequestLike, res: NodeResponseLike) => void}
|
|
44
53
|
* @example
|
|
45
54
|
* http.createServer(toNodeHandler(serveHttp(contract, handlers))).listen(8080);
|
|
46
55
|
*/
|
|
47
|
-
export declare function toNodeHandler(dispatcher: HttpDispatcher
|
|
56
|
+
export declare function toNodeHandler(dispatcher: HttpDispatcher, options?: {
|
|
57
|
+
lingerMs?: number;
|
|
58
|
+
}): (req: NodeRequestLike, res: NodeResponseLike) => void;
|
package/docs/CONTRACT-FORMAT.md
CHANGED
|
@@ -962,8 +962,12 @@ the platform:
|
|
|
962
962
|
- **`toNodeHandler(dispatcher)`** (`@jarenjs/contract/node`) → `(req,
|
|
963
963
|
res)` — `http.createServer`'s listener and Express middleware. It
|
|
964
964
|
collects the body chunk by chunk up to the operation's limit; on
|
|
965
|
-
overflow it stops reading, answers the 413 with `connection: close
|
|
966
|
-
|
|
965
|
+
overflow it stops reading, answers the 413 with `connection: close`,
|
|
966
|
+
then lingers — draining and discarding the rest of the upload, bounded
|
|
967
|
+
by a grace timer (`toNodeHandler(dispatcher, { lingerMs })`, default
|
|
968
|
+
1000 ms) — before destroying the request, so the close is a FIN the
|
|
969
|
+
client can read the 413 through rather than an RST that discards it
|
|
970
|
+
(winsock drops buffered receive data on RST); a declared
|
|
967
971
|
`content-length` above the limit is never read; an unmatched request's
|
|
968
972
|
body is never read. Bytes reach the dispatcher as received (its strict
|
|
969
973
|
UTF-8 decode decides `JC2005`); repeated header lines arrive as arrays
|
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.46.5",
|
|
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.46.5",
|
|
106
|
+
"@jarenjs/json": "^0.46.5",
|
|
107
|
+
"@jarenjs/validate": "^0.46.5",
|
|
108
|
+
"@jarenjs/emit": "^0.46.5"
|
|
109
109
|
}
|
|
110
110
|
}
|
package/src/adapters/node.js
CHANGED
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The body is collected chunk by chunk up to the matched operation's
|
|
11
11
|
* `policy.limits.maxBodyBytes`; on overflow the read stops, the 413 is
|
|
12
|
-
* answered with `connection: close
|
|
13
|
-
* the
|
|
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
|
|
14
16
|
* is never read at all; an unmatched request's body is never read (the
|
|
15
17
|
* dispatcher answers 404/405 without it and the platform discards the
|
|
16
18
|
* rest). Bytes are handed to the dispatcher as received — for a JSON
|
|
@@ -33,9 +35,18 @@
|
|
|
33
35
|
* @property {Record<string, string | string[] | undefined>} headers
|
|
34
36
|
* @property {(event: string, listener: (...args: any[]) => void) => unknown} on
|
|
35
37
|
* @property {() => unknown} [pause]
|
|
38
|
+
* @property {() => unknown} [resume]
|
|
36
39
|
* @property {(error?: Error) => unknown} [destroy]
|
|
40
|
+
* @property {boolean} [readableEnded]
|
|
41
|
+
* @property {boolean} [destroyed]
|
|
37
42
|
*/
|
|
38
43
|
|
|
44
|
+
/** How long a closing response waits for the peer's upload to end before
|
|
45
|
+
* destroying the socket anyway. Long enough for a client that finishes
|
|
46
|
+
* writing once it sees the response; short enough that a peer that never
|
|
47
|
+
* stops cannot hold the socket. The timer is unref'd. */
|
|
48
|
+
const LINGER_MS = 1000;
|
|
49
|
+
|
|
39
50
|
/**
|
|
40
51
|
* The response surface the adapter writes — `http.ServerResponse` fits.
|
|
41
52
|
* `write` and `flushHeaders` are read only for a streaming (SSE)
|
|
@@ -149,14 +160,20 @@ function send(res, response, close, done) {
|
|
|
149
160
|
/**
|
|
150
161
|
* Put a dispatcher behind Node's `(req, res)` listener.
|
|
151
162
|
* @param {HttpDispatcher} dispatcher
|
|
163
|
+
* @param {{ lingerMs?: number }} [options] - `lingerMs` bounds how long a
|
|
164
|
+
* closing response waits, draining the peer's unfinished upload, before
|
|
165
|
+
* the socket is destroyed (default 1000 ms; see the linger comment
|
|
166
|
+
* below — it is what keeps an overflow 413 readable through the close).
|
|
152
167
|
* @returns {(req: NodeRequestLike, res: NodeResponseLike) => void}
|
|
153
168
|
* @example
|
|
154
169
|
* http.createServer(toNodeHandler(serveHttp(contract, handlers))).listen(8080);
|
|
155
170
|
*/
|
|
156
|
-
export function toNodeHandler(dispatcher) {
|
|
171
|
+
export function toNodeHandler(dispatcher, options = {}) {
|
|
157
172
|
if (dispatcher === null || typeof dispatcher !== 'object' || typeof dispatcher.dispatch !== 'function') {
|
|
158
173
|
throw new TypeError('toNodeHandler: the argument must be a dispatcher from serveHttp');
|
|
159
174
|
}
|
|
175
|
+
const lingerMs = typeof options.lingerMs === 'number' && options.lingerMs >= 0
|
|
176
|
+
? options.lingerMs : LINGER_MS;
|
|
160
177
|
const contract = dispatcher.contract;
|
|
161
178
|
const head = dispatcher.capabilities.head;
|
|
162
179
|
|
|
@@ -172,9 +189,30 @@ export function toNodeHandler(dispatcher) {
|
|
|
172
189
|
if (res.writableFinished !== true) controller.abort();
|
|
173
190
|
});
|
|
174
191
|
|
|
192
|
+
// Closing a socket with unread data in its receive buffer sends RST,
|
|
193
|
+
// and winsock discards buffered receive data on RST — the flushed 413
|
|
194
|
+
// would never reach a Windows client. So the close lingers: resume the
|
|
195
|
+
// paused request so what is still arriving drains and discards (the
|
|
196
|
+
// settled guard below already ignores it), and destroy only after a
|
|
197
|
+
// grace window, which closes with FIN and leaves the response
|
|
198
|
+
// readable. No request event can drive this — once the response has
|
|
199
|
+
// finished, a paused, unconsumed request emits nothing further — so
|
|
200
|
+
// the window is a plain unref'd timer.
|
|
201
|
+
const lingerThenDestroy = () => {
|
|
202
|
+
if (typeof req.destroy !== 'function' || req.destroyed === true) return;
|
|
203
|
+
if (req.readableEnded === true) {
|
|
204
|
+
req.destroy();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (typeof req.resume === 'function') req.resume();
|
|
208
|
+
const timer = setTimeout(() => {
|
|
209
|
+
if (req.destroyed !== true) req.destroy();
|
|
210
|
+
}, lingerMs);
|
|
211
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
212
|
+
};
|
|
175
213
|
/** @param {import('../http/wire.js').HttpResponse} response @param {boolean} close */
|
|
176
214
|
const finish = (response, close) => {
|
|
177
|
-
send(res, response, close, close ?
|
|
215
|
+
send(res, response, close, close ? lingerThenDestroy : undefined);
|
|
178
216
|
};
|
|
179
217
|
/** @param {string | Uint8Array | null} body @param {boolean} close */
|
|
180
218
|
const answer = (body, close) => {
|
package/src/client/http.js
CHANGED
|
@@ -956,6 +956,17 @@ export function openHttpClient(contract, options = {}) {
|
|
|
956
956
|
const body = /** @type {any} */ (response).body;
|
|
957
957
|
if (typeof contentType !== 'string' || contentType.toLowerCase().indexOf(STREAM_MEDIA) === -1
|
|
958
958
|
|| body === null || body === undefined || typeof body.getReader !== 'function') {
|
|
959
|
+
// a refused stream still holds a live response body; release it,
|
|
960
|
+
// or the transport keeps the connection reserved for a read that
|
|
961
|
+
// will never come
|
|
962
|
+
try {
|
|
963
|
+
if (body !== null && body !== undefined && typeof body.cancel === 'function') {
|
|
964
|
+
Promise.resolve(body.cancel()).catch(() => {});
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
catch {
|
|
968
|
+
// cancellation is best-effort; the outcome below is the answer
|
|
969
|
+
}
|
|
959
970
|
consumer.fail(failedOutcome('contract',
|
|
960
971
|
outcomeError('JC2090', renderMessage(catalog, STREAM_ERRORS.JC2090.msgid, { op: route.id }), status, null, false), meta));
|
|
961
972
|
return;
|