@procedures/client 0.6.0 → 0.7.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 (47) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +43 -4
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/adapters/http/client/transport.d.ts +5 -0
  5. package/dist/adapters/http/client/transport.d.ts.map +1 -1
  6. package/dist/adapters/http/client/transport.js +10 -4
  7. package/dist/adapters/http/client/transport.js.map +1 -1
  8. package/dist/adapters/http/server/ndjson.d.ts +73 -0
  9. package/dist/adapters/http/server/ndjson.d.ts.map +1 -0
  10. package/dist/adapters/http/server/ndjson.js +228 -0
  11. package/dist/adapters/http/server/ndjson.js.map +1 -0
  12. package/dist/adapters/http/server/transport.d.ts +9 -4
  13. package/dist/adapters/http/server/transport.d.ts.map +1 -1
  14. package/dist/adapters/http/server/transport.js +19 -68
  15. package/dist/adapters/http/server/transport.js.map +1 -1
  16. package/dist/adapters/http/server/types.d.ts +15 -0
  17. package/dist/adapters/http/server/types.d.ts.map +1 -1
  18. package/dist/adapters/sequence.d.ts +14 -0
  19. package/dist/adapters/sequence.d.ts.map +1 -1
  20. package/dist/adapters/sequence.js +16 -0
  21. package/dist/adapters/sequence.js.map +1 -1
  22. package/dist/browser.d.ts +1 -1
  23. package/dist/browser.d.ts.map +1 -1
  24. package/dist/browser.js +3 -2
  25. package/dist/browser.js.map +1 -1
  26. package/dist/node.d.ts +6 -0
  27. package/dist/node.d.ts.map +1 -1
  28. package/dist/node.js +5 -0
  29. package/dist/node.js.map +1 -1
  30. package/dist/server/procedure-server.d.ts +4 -2
  31. package/dist/server/procedure-server.d.ts.map +1 -1
  32. package/dist/server/procedure-server.js +8 -5
  33. package/dist/server/procedure-server.js.map +1 -1
  34. package/dist/timers.d.ts +4 -0
  35. package/dist/timers.d.ts.map +1 -1
  36. package/dist/timers.js +3 -0
  37. package/dist/timers.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/adapters/http/client/transport.ts +10 -4
  40. package/src/adapters/http/server/ndjson.ts +259 -0
  41. package/src/adapters/http/server/transport.ts +20 -68
  42. package/src/adapters/http/server/types.ts +17 -0
  43. package/src/adapters/sequence.ts +18 -0
  44. package/src/browser.ts +3 -2
  45. package/src/node.ts +6 -0
  46. package/src/server/procedure-server.ts +8 -5
  47. package/src/timers.ts +7 -0
@@ -0,0 +1,259 @@
1
+ /**
2
+ * The writer of an NDJSON stream response (wire protocol v1, `documentation/WIRE-PROTOCOL.md`).
3
+ *
4
+ * `HttpServerTransport` writes each NDJSON stream with `writeNdjsonStream`. A server without
5
+ * Express can use the function too: it takes a `ServerResponse` of `node:http`. This module
6
+ * imports only the types of `node:http`, so it loads no Node code at run time.
7
+ */
8
+
9
+ import type { ServerResponse } from "node:http";
10
+ import { realClock, type Cancel, type Clock } from "../../../clock.js";
11
+ import { toMs, type Duration } from "../../../duration.js";
12
+ import { PROTOCOL_HEADER, PROTOCOL_VERSION, errorCodeOf } from "../../../protocol.js";
13
+ import { TIMER_DEFAULTS } from "../../../timers.js";
14
+
15
+ /** The media type of a stream response: one JSON value on each line. */
16
+ export const NDJSON_MEDIA_TYPE = "application/x-ndjson";
17
+
18
+ /** The keep-alive line of an NDJSON stream: a blank line. A reader skips it. It has no `seq`. */
19
+ export const KEEP_ALIVE_LINE = "\n";
20
+
21
+ /** The longest delay of a Node timer. A longer delay starts the timer at once. */
22
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
23
+
24
+ /** The error of an `error` line: its code, its message and its retryable flag. */
25
+ export interface NdjsonError {
26
+ code: string;
27
+ message: string;
28
+ retryable: boolean;
29
+ }
30
+
31
+ /** The options of `writeNdjsonStream`. */
32
+ export interface WriteNdjsonStreamOptions {
33
+ /**
34
+ * The signal of the request. Its abort stops the stream. The iterator of the items gets
35
+ * `return()`, and a response that is still open gets the error line `ABORTED`.
36
+ */
37
+ readonly signal?: AbortSignal | undefined;
38
+ /**
39
+ * The quiet time after which the writer sends a keep-alive line. Each line starts the time again.
40
+ * `false`, or a duration of zero or less, sends no keep-alive line.
41
+ * @default seconds(15) (`TIMER_DEFAULTS.httpServer.keepAliveMs`)
42
+ */
43
+ readonly keepAlive?: Duration | false | undefined;
44
+ /**
45
+ * The function that gives the `error` line of an error of the stream. The default gives the code
46
+ * of the error (`errorCodeOf`), its message and its own retryable flag, or `false`.
47
+ */
48
+ readonly errorOf?: ((error: unknown) => NdjsonError) | undefined;
49
+ /**
50
+ * The clock of the keep-alive timer (roadmap R35). The default is the clock of the platform. A
51
+ * test gives a fake clock.
52
+ */
53
+ readonly clock?: Clock | undefined;
54
+ }
55
+
56
+ /**
57
+ * This function gives the `error` line of a thrown error: its code, its message and its retryable
58
+ * flag (deep dive TRN-7). A code that is not a string gives `HANDLER_ERROR`.
59
+ */
60
+ export function defaultNdjsonError(error: unknown): NdjsonError {
61
+ const retryable = (error as { retryable?: unknown } | null | undefined)?.retryable;
62
+ return {
63
+ code: errorCodeOf(error),
64
+ message: error instanceof Error ? error.message : String(error),
65
+ retryable: typeof retryable === "boolean" ? retryable : false,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * This function gives the keep-alive time in milliseconds, or 0 for no keep-alive. A value that is
71
+ * not a duration throws a `TypeError`.
72
+ */
73
+ export function keepAliveMsOf(keepAlive: Duration | false | undefined): number {
74
+ if (keepAlive === false) return 0;
75
+ if (keepAlive === undefined) return TIMER_DEFAULTS.httpServer.keepAliveMs;
76
+ const value = toMs(keepAlive, "keepAlive");
77
+ return value > 0 ? Math.min(value, MAX_TIMER_DELAY_MS) : 0;
78
+ }
79
+
80
+ /** One JSON line of the stream. */
81
+ function lineOf(frame: unknown): string {
82
+ return `${JSON.stringify(frame)}\n`;
83
+ }
84
+
85
+ /**
86
+ * This function gives the next result of the iterator, or `undefined` when the stop signal
87
+ * aborts first. Thus a quiet iterator does not keep a stopped stream open. The listener goes
88
+ * away after each step, so a long stream adds no listener.
89
+ */
90
+ function nextOrStop<T>(iterator: AsyncIterator<T>, stop: AbortSignal): Promise<IteratorResult<T> | undefined> {
91
+ if (stop.aborted) return Promise.resolve(undefined);
92
+ return new Promise<IteratorResult<T> | undefined>((resolve, reject) => {
93
+ const onStop = (): void => resolve(undefined);
94
+ stop.addEventListener("abort", onStop, { once: true });
95
+ Promise.resolve(iterator.next()).then(
96
+ (result) => {
97
+ stop.removeEventListener("abort", onStop);
98
+ resolve(result);
99
+ },
100
+ (error: unknown) => {
101
+ stop.removeEventListener("abort", onStop);
102
+ reject(error);
103
+ },
104
+ );
105
+ });
106
+ }
107
+
108
+ /**
109
+ * This function waits until the response can take more data: the `drain` event. A closed
110
+ * connection or the stop signal also ends the wait (deep dive TRN-3).
111
+ */
112
+ function drained(res: ServerResponse, stop: AbortSignal): Promise<void> {
113
+ if (stop.aborted) return Promise.resolve();
114
+ return new Promise<void>((resolve) => {
115
+ const done = (): void => {
116
+ res.off("drain", done);
117
+ res.off("close", done);
118
+ stop.removeEventListener("abort", done);
119
+ resolve();
120
+ };
121
+ res.on("drain", done);
122
+ res.on("close", done);
123
+ stop.addEventListener("abort", done, { once: true });
124
+ });
125
+ }
126
+
127
+ /**
128
+ * This function writes a stream of items as an NDJSON response of wire protocol v1.
129
+ *
130
+ * - It writes the status 200, the headers `Content-Type: application/x-ndjson`,
131
+ * `Cache-Control: no-cache` and `Procedures-Protocol: 1`, and it sends them at once.
132
+ * - Each item gives the line `{"type":"item","seq":n,"payload":...}`. The `seq` counts from 1.
133
+ * - The end of the items gives `{"type":"done","seq":n}`. An error gives
134
+ * `{"type":"error","error":{"code":...,"message":...,"retryable":...}}`, without `seq`.
135
+ * - After each quiet time of `keepAlive`, it writes a keep-alive line: a blank line. Each line
136
+ * starts the time again. No keep-alive line comes after the last line.
137
+ * - When `write()` gives false, it waits for `drain` before it reads the next item.
138
+ * - A closed connection or an abort of `signal` stops it. The iterator gets `return()`. After an
139
+ * abort, a response that is still open gets the error line `ABORTED`.
140
+ *
141
+ * The promise resolves when the response ends. It does not reject: an error of the items becomes
142
+ * the error line.
143
+ */
144
+ export async function writeNdjsonStream(res: ServerResponse, items: AsyncIterable<unknown>, options: WriteNdjsonStreamOptions = {}): Promise<void> {
145
+ const interval = keepAliveMsOf(options.keepAlive);
146
+ const clock = options.clock ?? realClock;
147
+ const errorOf = options.errorOf ?? defaultNdjsonError;
148
+ const { signal } = options;
149
+
150
+ // One stop for the abort of the caller and for a closed connection
151
+ const stop = new AbortController();
152
+ const onAbort = (): void => stop.abort();
153
+ const onClose = (): void => {
154
+ if (!res.writableFinished) stop.abort();
155
+ };
156
+ if (signal?.aborted || res.destroyed) stop.abort();
157
+ signal?.addEventListener("abort", onAbort, { once: true });
158
+ res.on("close", onClose);
159
+
160
+ // The keep-alive timer. Each line starts the quiet time again, but a line does not start a new
161
+ // timer: when the timer comes due, it examines the time of the last line. Thus a fast stream
162
+ // has one timer for each quiet time, not one for each item.
163
+ let timer: Cancel | undefined;
164
+ let lastLine = 0;
165
+ const stopTimer = (): void => {
166
+ timer?.();
167
+ timer = undefined;
168
+ };
169
+ const arm = (delay: number): void => {
170
+ timer = clock.after(delay, keepAlive);
171
+ timer.unref?.();
172
+ };
173
+ /** This function records a line: the quiet time starts again. */
174
+ const startTimer = (): void => {
175
+ lastLine = clock.now();
176
+ if (timer === undefined && interval > 0 && !stop.signal.aborted && !res.writableEnded) arm(interval);
177
+ };
178
+ function keepAlive(): void {
179
+ timer = undefined;
180
+ if (stop.signal.aborted || res.writableEnded || res.destroyed) return;
181
+ const quiet = clock.now() - lastLine;
182
+ if (quiet < interval) {
183
+ // A line went out after the start of this timer: wait for the rest of the quiet time
184
+ arm(interval - quiet);
185
+ return;
186
+ }
187
+ // A full buffer waits for the reader. One more byte there does not reach the reader sooner.
188
+ if (!res.writableNeedDrain) res.write(KEEP_ALIVE_LINE);
189
+ startTimer();
190
+ }
191
+
192
+ /** This function ends an open response after an abort: the server stopped the request. */
193
+ const endAborted = (): void => {
194
+ if (!res.writableEnded && !res.destroyed) {
195
+ res.end(lineOf({ type: "error", error: { code: "ABORTED", message: "The server stopped the request", retryable: true } }));
196
+ }
197
+ };
198
+
199
+ const iterator = items[Symbol.asyncIterator]();
200
+ /** True when the iterator ended by itself: at its end, or with an error */
201
+ let ended = false;
202
+ let seq = 0;
203
+ try {
204
+ if (!res.headersSent && !res.destroyed) {
205
+ res.statusCode = 200;
206
+ res.setHeader(PROTOCOL_HEADER, String(PROTOCOL_VERSION));
207
+ res.setHeader("Content-Type", NDJSON_MEDIA_TYPE);
208
+ res.setHeader("Cache-Control", "no-cache");
209
+ res.flushHeaders();
210
+ }
211
+ startTimer();
212
+ for (;;) {
213
+ let next: IteratorResult<unknown> | undefined;
214
+ try {
215
+ next = await nextOrStop(iterator, stop.signal);
216
+ } catch (error) {
217
+ ended = true;
218
+ throw error;
219
+ }
220
+ if (next === undefined) break;
221
+ if (next.done) {
222
+ ended = true;
223
+ break;
224
+ }
225
+ if (stop.signal.aborted) break;
226
+ const room = res.write(lineOf({ type: "item", seq: ++seq, payload: next.value }));
227
+ startTimer();
228
+ if (!room) await drained(res, stop.signal);
229
+ if (stop.signal.aborted) break;
230
+ }
231
+ stopTimer();
232
+ if (stop.signal.aborted) {
233
+ endAborted();
234
+ return;
235
+ }
236
+ res.end(lineOf({ type: "done", seq: ++seq }));
237
+ } catch (error) {
238
+ stopTimer();
239
+ if (stop.signal.aborted) {
240
+ endAborted();
241
+ return;
242
+ }
243
+ let info: NdjsonError;
244
+ try {
245
+ info = errorOf(error);
246
+ } catch {
247
+ // A failed errorOf does not leave the response open
248
+ info = defaultNdjsonError(error);
249
+ }
250
+ res.end(lineOf({ type: "error", error: info }));
251
+ } finally {
252
+ stopTimer();
253
+ signal?.removeEventListener("abort", onAbort);
254
+ res.off("close", onClose);
255
+ // A stream that stopped early: its producer stops too. A pending next() keeps the return()
256
+ // waiting, so the writer does not wait for it.
257
+ if (!ended) void Promise.resolve().then(() => iterator.return?.()).catch(() => undefined);
258
+ }
259
+ }
@@ -17,10 +17,10 @@ import type { HttpServerTransportOptions } from "./types.js";
17
17
  import { createPatternServerUrlStrategy } from "./strategies.js";
18
18
  import { checkBrowserRequest, type BrowserGuardOptions } from "../../../server/browser-guard.js";
19
19
  import { bearerOf, withoutSecretHeaders } from "../../../server/request-auth.js";
20
- import { PROTOCOL_HEADER, PROTOCOL_VERSION, errorCodeOf, httpStatusOf, protocolVersionOf } from "../../../protocol.js";
21
-
22
- /** The media type of a stream response: one JSON value on each line. */
23
- const NDJSON = "application/x-ndjson";
20
+ import { PROTOCOL_HEADER, PROTOCOL_VERSION, httpStatusOf, protocolVersionOf } from "../../../protocol.js";
21
+ import { realClock, type Clock } from "../../../clock.js";
22
+ import { ms, type Duration } from "../../../duration.js";
23
+ import { NDJSON_MEDIA_TYPE as NDJSON, defaultNdjsonError as errorOf, keepAliveMsOf, writeNdjsonStream } from "./ndjson.js";
24
24
 
25
25
  /** True when a stream response can run without an end (`STREAM_METADATA_KEY` of procedure-server.ts). */
26
26
  function isUnboundedStream(response: { metadata?: Record<string, unknown> | undefined }): boolean {
@@ -40,36 +40,6 @@ class BodyError extends Error {
40
40
  }
41
41
  }
42
42
 
43
- /** The code and message of a thrown error, with its retryable flag (deep dive TRN-7). */
44
- function errorOf(error: unknown): { code: string; message: string; retryable: boolean } {
45
- const retryable = (error as { retryable?: unknown } | undefined)?.retryable;
46
- return {
47
- code: errorCodeOf(error),
48
- message: error instanceof Error ? error.message : String(error),
49
- retryable: typeof retryable === "boolean" ? retryable : false,
50
- };
51
- }
52
-
53
- /**
54
- * Write one chunk. When the response's buffer is full, wait for "drain", or for the end of the
55
- * request (deep dive TRN-3: before, the server ignored the result of write() and ran a stream
56
- * to its end whatever the reader did).
57
- */
58
- function write(res: Response, chunk: string, signal: AbortSignal): Promise<void> {
59
- if (res.write(chunk) || signal.aborted) return Promise.resolve();
60
- return new Promise<void>((resolve) => {
61
- const done = (): void => {
62
- res.off("drain", done);
63
- res.off("close", done);
64
- signal.removeEventListener("abort", done);
65
- resolve();
66
- };
67
- res.on("drain", done);
68
- res.on("close", done);
69
- signal.addEventListener("abort", done, { once: true });
70
- });
71
- }
72
-
73
43
  /**
74
44
  * HTTP server transport adapter for Express.
75
45
  *
@@ -108,7 +78,7 @@ export class HttpServerTransport implements ServerTransport {
108
78
  private options: Required<
109
79
  Omit<
110
80
  HttpServerTransportOptions,
111
- "app" | "corsOptions" | "httpServer" | "bodyLimit" | "allowedOrigins" | "allowedHosts" | "allowGet"
81
+ "app" | "corsOptions" | "httpServer" | "bodyLimit" | "allowedOrigins" | "allowedHosts" | "allowGet" | "keepAlive" | "clock"
112
82
  >
113
83
  > & {
114
84
  corsOptions: HttpServerTransportOptions["corsOptions"] | undefined;
@@ -121,6 +91,10 @@ export class HttpServerTransport implements ServerTransport {
121
91
  private readonly bodyLimit: number;
122
92
  /** The browser checks of each procedure call (see `server/browser-guard.ts`) */
123
93
  private readonly guard: BrowserGuardOptions;
94
+ /** The quiet time of an NDJSON stream before a keep-alive line, or false for none */
95
+ private readonly keepAlive: Duration | false;
96
+ /** The clock of the keep-alive timers (roadmap R35) */
97
+ private readonly clock: Clock;
124
98
 
125
99
  constructor(server: RequestHandler, options: HttpServerTransportOptions = {}) {
126
100
  this.server = server;
@@ -138,6 +112,10 @@ export class HttpServerTransport implements ServerTransport {
138
112
  httpServer: options.httpServer,
139
113
  };
140
114
  this.bodyLimit = options.bodyLimit ?? 1024 * 1024;
115
+ // A value that is not a duration fails here, not at the first stream
116
+ const keepAliveMs = keepAliveMsOf(options.keepAlive);
117
+ this.keepAlive = keepAliveMs > 0 ? ms(keepAliveMs) : false;
118
+ this.clock = options.clock ?? realClock;
141
119
  this.guard = {
142
120
  allowedOrigins: options.allowedOrigins ?? corsOrigins(options),
143
121
  allowedHosts: options.allowedHosts,
@@ -341,16 +319,14 @@ export class HttpServerTransport implements ServerTransport {
341
319
  }
342
320
  }
343
321
 
344
- /**
345
- * Convert HTTP request to ServerRequest.
346
- */
347
322
  /**
348
323
  * Send a stream response.
349
324
  *
350
325
  * A client that accepts `application/x-ndjson` gets one JSON line for each item
351
326
  * (`{"type":"item","seq":1,"payload":...}`), then `{"type":"done","seq":...}`, or
352
327
  * `{"type":"error","error":...}`. The `seq` of the lines counts from 1, as in the `stream`
353
- * frames of WebSocket.
328
+ * frames of WebSocket. A quiet stream gets a keep-alive line, a blank line, after each quiet
329
+ * time of `keepAlive`. `writeNdjsonStream` writes these lines.
354
330
  * Another client (curl, a browser fetch without the header) gets the last item as plain JSON,
355
331
  * the "sponge" mode, so a streaming procedure does not change its response format.
356
332
  */
@@ -406,37 +382,13 @@ export class HttpServerTransport implements ServerTransport {
406
382
  return;
407
383
  }
408
384
 
409
- res.status(200);
410
- res.setHeader("Content-Type", NDJSON);
411
- res.setHeader("Cache-Control", "no-cache");
412
- res.flushHeaders();
413
- // stop() aborted the request while the reader still listens: end the stream with an error
414
- const endAborted = (): void => {
415
- if (!res.writableEnded && !res.destroyed) {
416
- res.end(JSON.stringify({ type: "error", error: { code: "ABORTED", message: "The server stopped the request", retryable: true } }) + "\n");
417
- }
418
- };
419
- let seq = 0;
420
- try {
421
- for await (const item of stream) {
422
- if (signal.aborted) break;
423
- await write(res, JSON.stringify({ type: "item", seq: ++seq, payload: item }) + "\n", signal);
424
- if (signal.aborted) break;
425
- }
426
- if (signal.aborted) {
427
- endAborted();
428
- return;
429
- }
430
- res.end(JSON.stringify({ type: "done", seq: ++seq }) + "\n");
431
- } catch (error) {
432
- if (signal.aborted) {
433
- endAborted();
434
- return;
435
- }
436
- res.end(JSON.stringify({ type: "error", error: errorOf(error) }) + "\n");
437
- }
385
+ // The lines, the backpressure, the keep-alive and the abort of stop() (WIRE-PROTOCOL.md)
386
+ await writeNdjsonStream(res, stream, { signal, keepAlive: this.keepAlive, errorOf, clock: this.clock });
438
387
  }
439
388
 
389
+ /**
390
+ * This method converts an HTTP request to a `ServerRequest`.
391
+ */
440
392
  private httpToServerRequest(req: Request, body: unknown): ServerRequest | null {
441
393
  // Parse method from URL using strategy
442
394
  const method = this.options.urlStrategy(req);
@@ -8,6 +8,8 @@ import type { Request, Express } from "express";
8
8
  import type { Server as HttpServer } from "http";
9
9
  import type { Method } from "../../../client/types.js";
10
10
  import type { BrowserGuardOptions } from "../../../server/browser-guard.js";
11
+ import type { Clock } from "../../../clock.js";
12
+ import type { Duration } from "../../../duration.js";
11
13
 
12
14
  /**
13
15
  * URL strategy for converting HTTP requests to RPC methods.
@@ -68,6 +70,21 @@ export interface HttpServerTransportOptions extends BrowserGuardOptions {
68
70
  */
69
71
  bodyLimit?: number;
70
72
 
73
+ /**
74
+ * The quiet time of an NDJSON stream. After it, the server writes a keep-alive line: a blank
75
+ * line. Each line of the stream starts the time again. Thus a proxy or a client with an idle
76
+ * timeout does not close a quiet stream. `false`, or a duration of zero or less, sends no
77
+ * keep-alive line.
78
+ * @default seconds(15) (`TIMER_DEFAULTS.httpServer.keepAliveMs`)
79
+ */
80
+ keepAlive?: Duration | false;
81
+
82
+ /**
83
+ * The clock of the keep-alive timers (roadmap R35). The default is the clock of the platform. A
84
+ * test gives a fake clock.
85
+ */
86
+ clock?: Clock;
87
+
71
88
  /**
72
89
  * Custom CORS options. `origin` is "*", one origin or a list: the response names the
73
90
  * request's origin when it is in the list.
@@ -10,6 +10,8 @@
10
10
  * number of lost frames in its metadata key `stream-gap`, and the caller writes a warning. The
11
11
  * caller also gives back the credit of the lost frames.
12
12
  * - A frame without `seq` comes from an older peer. The caller takes it as before.
13
+ * - A blank line of an NDJSON stream is a keep-alive line. It is not a frame and it has no `seq`,
14
+ * so the reader skips it before the check.
13
15
  */
14
16
 
15
17
  /**
@@ -18,6 +20,14 @@
18
20
  */
19
21
  export const STREAM_GAP_METADATA_KEY = "stream-gap";
20
22
 
23
+ /**
24
+ * The metadata key of an item that the HTTP client transport read from an NDJSON stream. Its value
25
+ * is `true`. The receiver sets it. It does not cross the wire, and the receiver removes a key of
26
+ * this name that the peer sent. The constant `STREAM_METADATA_KEY` of the server has the same
27
+ * name, with the value `unbounded` in the metadata of a response.
28
+ */
29
+ export const STREAM_ITEM_METADATA_KEY = "stream";
30
+
21
31
  /** The order of the stream frames of one call, at the side that receives them. */
22
32
  export class StreamSequence {
23
33
  private last = 0;
@@ -48,6 +58,14 @@ export function itemMetadata(metadata: Record<string, unknown> | undefined, gap:
48
58
  return out;
49
59
  }
50
60
 
61
+ /**
62
+ * This function gives the metadata of an item of an NDJSON stream: the metadata of `itemMetadata`,
63
+ * and the key `stream` with the value `true`. The value of the peer for this key goes away.
64
+ */
65
+ export function streamItemMetadata(metadata: Record<string, unknown> | undefined, gap: number): Record<string, unknown> {
66
+ return { ...itemMetadata(metadata, gap), [STREAM_ITEM_METADATA_KEY]: true };
67
+ }
68
+
51
69
  /** The number of frames that did not arrive immediately before an item: 0 without a gap. */
52
70
  export function gapOf(metadata: Record<string, unknown> | undefined): number {
53
71
  const gap = metadata?.[STREAM_GAP_METADATA_KEY];
package/src/browser.ts CHANGED
@@ -323,8 +323,9 @@ export type { TraceContext } from "./tracing/traceparent.js";
323
323
  // Wire protocol v1: the version and the one list of error codes (roadmap R25)
324
324
  export { PROTOCOL_VERSION, PROTOCOL_HEADER, ERROR_CODES, isProtocolErrorCode, httpStatusOf, protocolVersionOf } from "./protocol.js";
325
325
  export type { ProtocolErrorCode, ErrorCodeInfo } from "./protocol.js";
326
- // The seq of the stream frames: the metadata key of an item after a gap (WIRE-PROTOCOL.md)
327
- export { STREAM_GAP_METADATA_KEY } from "./adapters/sequence.js";
326
+ // The seq of the stream frames: the metadata key of an item after a gap, and the key of an item
327
+ // that the HTTP client read from an NDJSON stream (WIRE-PROTOCOL.md)
328
+ export { STREAM_GAP_METADATA_KEY, STREAM_ITEM_METADATA_KEY } from "./adapters/sequence.js";
328
329
  // The deadline of a call: the remaining time goes as procedure-timeout (roadmap R10)
329
330
  export { TIMEOUT_METADATA_KEY, DEADLINE_METADATA_KEY } from "./client/deadline.js";
330
331
  // Typed durations: each public option that is a time takes a Duration (roadmap R11)
package/src/node.ts CHANGED
@@ -5,7 +5,13 @@
5
5
  * uses the `WebSocket` class of the `ws` package, so a drop ends a socket at once with
6
6
  * `terminate()` (finding of roadmap R36). The other entries keep the global `WebSocket`: a browser
7
7
  * bundle imports `@procedures/client/browser`, and that entry does not import `ws`.
8
+ *
9
+ * This entry also exports `writeNdjsonStream`: the writer of an NDJSON stream response of wire
10
+ * protocol v1, for a `ServerResponse` of `node:http`. `HttpServerTransport` uses it, so a server
11
+ * without Express writes the same bytes.
8
12
  */
9
13
 
10
14
  export { WebSocketTransport } from "./adapters/websocket/client/node.js";
11
15
  export type { WebSocketTransportOptions, WebSocketClass } from "./adapters/websocket/client/types.js";
16
+ export { writeNdjsonStream } from "./adapters/http/server/ndjson.js";
17
+ export type { WriteNdjsonStreamOptions, NdjsonError } from "./adapters/http/server/ndjson.js";
@@ -143,13 +143,16 @@ export function isUnboundedStream(response: { metadata?: Record<string, unknown>
143
143
  }
144
144
 
145
145
  /**
146
- * True when the presented token is the expected one. The comparison takes the same time for each
147
- * presented token of the same length, so the time does not tell the token.
146
+ * True when the presented token is the expected one. The comparison reads each character of the
147
+ * expected token for each presented token, so its time depends only on the expected token. The
148
+ * time tells neither the token nor its length. The function runs in a browser too, so it uses
149
+ * no `node:crypto`.
148
150
  */
149
151
  export function tokenMatches(expected: string, presented: string | undefined): boolean {
150
- if (presented === undefined || presented.length !== expected.length) return false;
151
- let difference = 0;
152
- for (let index = 0; index < expected.length; index++) difference |= expected.charCodeAt(index) ^ presented.charCodeAt(index);
152
+ if (presented === undefined) return false;
153
+ // A difference of the lengths makes the result false, after the same loop as an equal length
154
+ let difference = expected.length ^ presented.length;
155
+ for (let index = 0; index < expected.length; index++) difference |= expected.charCodeAt(index) ^ (presented.charCodeAt(index) | 0);
153
156
  return difference === 0;
154
157
  }
155
158
 
package/src/timers.ts CHANGED
@@ -47,6 +47,10 @@ export interface TimerDefaults {
47
47
  /** The time until the first frame of a call of the server to a client. */
48
48
  readonly reverseRequestTimeoutMs: number;
49
49
  };
50
+ readonly httpServer: {
51
+ /** The quiet time of an NDJSON stream. After it, the server writes a keep-alive line. */
52
+ readonly keepAliveMs: number;
53
+ };
50
54
  readonly idempotency: {
51
55
  /** The time that the server keeps the result of a keyed write. */
52
56
  readonly ttlMs: number;
@@ -77,6 +81,9 @@ export const TIMER_DEFAULTS: TimerDefaults = Object.freeze({
77
81
  // 15 s: with 2 misses, the server finds a dead client after 30 s, not later than a call of the
78
82
  // server to it reaches its request time of 30 s. Before, 30 s gave 60 s (the timer law, R34).
79
83
  webSocketServer: Object.freeze({ heartbeatIntervalMs: 15_000, heartbeatMisses: 2, reverseRequestTimeoutMs: 30_000 }),
84
+ // 15 s: less than the idle time of 60 s that many proxies and load balancers use. A quiet NDJSON
85
+ // stream then stays open (WIRE-PROTOCOL.md, "The keep-alive of an NDJSON stream").
86
+ httpServer: Object.freeze({ keepAliveMs: 15_000 }),
80
87
  idempotency: Object.freeze({ ttlMs: 10 * 60_000 }),
81
88
  server: Object.freeze({ stopGraceMs: 0 }),
82
89
  });