@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.
@@ -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` and the request is destroyed once
12
- * the response has flushed. A declared `content-length` above the limit
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): (req: NodeRequestLike, res: NodeResponseLike) => void;
56
+ export declare function toNodeHandler(dispatcher: HttpDispatcher, options?: {
57
+ lingerMs?: number;
58
+ }): (req: NodeRequestLike, res: NodeResponseLike) => void;
@@ -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
- and destroys the request once the response has flushed; a declared
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.43.3",
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.43.3",
106
- "@jarenjs/json": "^0.43.3",
107
- "@jarenjs/validate": "^0.43.3",
108
- "@jarenjs/emit": "^0.43.3"
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
  }
@@ -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` and the request is destroyed once
13
- * the response has flushed. A declared `content-length` above the limit
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 ? () => { if (typeof req.destroy === 'function') req.destroy(); } : undefined);
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) => {
@@ -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;