@procedures/client 0.5.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.
- package/CHANGELOG.md +21 -0
- package/README.md +43 -4
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/http/client/transport.d.ts +5 -0
- package/dist/adapters/http/client/transport.d.ts.map +1 -1
- package/dist/adapters/http/client/transport.js +10 -4
- package/dist/adapters/http/client/transport.js.map +1 -1
- package/dist/adapters/http/server/ndjson.d.ts +73 -0
- package/dist/adapters/http/server/ndjson.d.ts.map +1 -0
- package/dist/adapters/http/server/ndjson.js +228 -0
- package/dist/adapters/http/server/ndjson.js.map +1 -0
- package/dist/adapters/http/server/transport.d.ts +9 -4
- package/dist/adapters/http/server/transport.d.ts.map +1 -1
- package/dist/adapters/http/server/transport.js +19 -68
- package/dist/adapters/http/server/transport.js.map +1 -1
- package/dist/adapters/http/server/types.d.ts +15 -0
- package/dist/adapters/http/server/types.d.ts.map +1 -1
- package/dist/adapters/sequence.d.ts +14 -0
- package/dist/adapters/sequence.d.ts.map +1 -1
- package/dist/adapters/sequence.js +16 -0
- package/dist/adapters/sequence.js.map +1 -1
- package/dist/browser.d.ts +1 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +3 -2
- package/dist/browser.js.map +1 -1
- package/dist/node.d.ts +6 -0
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +5 -0
- package/dist/node.js.map +1 -1
- package/dist/server/procedure-server.d.ts +4 -2
- package/dist/server/procedure-server.d.ts.map +1 -1
- package/dist/server/procedure-server.js +8 -5
- package/dist/server/procedure-server.js.map +1 -1
- package/dist/timers.d.ts +4 -0
- package/dist/timers.d.ts.map +1 -1
- package/dist/timers.js +3 -0
- package/dist/timers.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/http/client/transport.ts +10 -4
- package/src/adapters/http/server/ndjson.ts +259 -0
- package/src/adapters/http/server/transport.ts +20 -68
- package/src/adapters/http/server/types.ts +17 -0
- package/src/adapters/sequence.ts +18 -0
- package/src/browser.ts +3 -2
- package/src/node.ts +6 -0
- package/src/server/procedure-server.ts +8 -5
- 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,
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
410
|
-
res.
|
|
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.
|
package/src/adapters/sequence.ts
CHANGED
|
@@ -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
|
|
327
|
-
|
|
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
|
|
147
|
-
*
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
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
|
});
|