@procedures/client 0.41.1 → 0.42.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 +57 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/http/client/batch.d.ts +98 -0
- package/dist/adapters/http/client/batch.d.ts.map +1 -0
- package/dist/adapters/http/client/batch.js +414 -0
- package/dist/adapters/http/client/batch.js.map +1 -0
- package/dist/adapters/http/client/index.d.ts +1 -0
- package/dist/adapters/http/client/index.d.ts.map +1 -1
- package/dist/adapters/http/client/index.js.map +1 -1
- package/dist/adapters/http/client/transport.d.ts +7 -0
- package/dist/adapters/http/client/transport.d.ts.map +1 -1
- package/dist/adapters/http/client/transport.js +27 -1
- package/dist/adapters/http/client/transport.js.map +1 -1
- package/dist/adapters/http/client/types.d.ts +9 -0
- package/dist/adapters/http/client/types.d.ts.map +1 -1
- package/dist/adapters/http/server/addressing.d.ts +5 -0
- package/dist/adapters/http/server/addressing.d.ts.map +1 -1
- package/dist/adapters/http/server/addressing.js +1 -1
- package/dist/adapters/http/server/addressing.js.map +1 -1
- package/dist/adapters/http/server/batch.d.ts +105 -0
- package/dist/adapters/http/server/batch.d.ts.map +1 -0
- package/dist/adapters/http/server/batch.js +172 -0
- package/dist/adapters/http/server/batch.js.map +1 -0
- package/dist/adapters/http/server/handler.d.ts +8 -0
- package/dist/adapters/http/server/handler.d.ts.map +1 -1
- package/dist/adapters/http/server/handler.js +103 -16
- package/dist/adapters/http/server/handler.js.map +1 -1
- package/dist/adapters/http/server/transport.d.ts.map +1 -1
- package/dist/adapters/http/server/transport.js +13 -2
- package/dist/adapters/http/server/transport.js.map +1 -1
- package/dist/adapters/http/server/types.d.ts +7 -0
- package/dist/adapters/http/server/types.d.ts.map +1 -1
- package/dist/adapters/http/shared/errors.d.ts +6 -8
- package/dist/adapters/http/shared/errors.d.ts.map +1 -1
- package/dist/adapters/http/shared/errors.js +10 -9
- package/dist/adapters/http/shared/errors.js.map +1 -1
- package/dist/adapters/http/shared/status-codes.d.ts +13 -9
- package/dist/adapters/http/shared/status-codes.d.ts.map +1 -1
- package/dist/adapters/http/shared/status-codes.js +21 -19
- package/dist/adapters/http/shared/status-codes.js.map +1 -1
- package/dist/browser.d.ts +1 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/node.d.ts +1 -0
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js.map +1 -1
- package/package.json +3 -3
- package/src/adapters/http/client/batch.ts +463 -0
- package/src/adapters/http/client/index.ts +1 -0
- package/src/adapters/http/client/transport.ts +31 -1
- package/src/adapters/http/client/types.ts +10 -0
- package/src/adapters/http/server/addressing.ts +1 -1
- package/src/adapters/http/server/batch.ts +237 -0
- package/src/adapters/http/server/handler.ts +119 -17
- package/src/adapters/http/server/transport.ts +15 -4
- package/src/adapters/http/server/types.ts +8 -0
- package/src/adapters/http/shared/errors.ts +10 -9
- package/src/adapters/http/shared/status-codes.ts +21 -21
- package/src/browser.ts +1 -0
- package/src/client/index.ts +1 -1
- package/src/node.ts +2 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A batch of calls in one HTTP request (roadmap CLI-32).
|
|
3
|
+
*
|
|
4
|
+
* The client sends `POST {base}` with the header `Procedures-Batch: 1` and the body
|
|
5
|
+
* `{"calls": [{"id", "path", "input", "headers", "metadata"}]}`. The answer is one NDJSON stream.
|
|
6
|
+
* Each item of the stream is a frame that names its call: the value of a call, an item of its
|
|
7
|
+
* stream, the end of its stream, or its error. Each call runs through `handle()` as a call of its
|
|
8
|
+
* own, so the authentication, the limits and the error policy apply to each call. The frames of
|
|
9
|
+
* the calls come in the order of their arrival. Refer to "A batch of calls" in WIRE-PROTOCOL.md.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { IncomingMessage } from "node:http";
|
|
13
|
+
import type { Method, Metadata, Status } from "../../../client/types.js";
|
|
14
|
+
import type { RequestHandler, ServerRequest, ServerResponse } from "../../../server/types.js";
|
|
15
|
+
import { lastResortError } from "../../../server/last-resort.js";
|
|
16
|
+
import { pathToMethod } from "../../../server/method.js";
|
|
17
|
+
import { segmentsOf } from "./addressing.js";
|
|
18
|
+
import type { NdjsonError } from "./ndjson.js";
|
|
19
|
+
import { BodyError } from "./body.js";
|
|
20
|
+
|
|
21
|
+
/** The request header of a batch, and of the answer of a server that took the batch. */
|
|
22
|
+
export const BATCH_HEADER = "Procedures-Batch";
|
|
23
|
+
|
|
24
|
+
/** The options of the batches of `procedureHandler`. */
|
|
25
|
+
export interface HandlerBatchOptions {
|
|
26
|
+
/**
|
|
27
|
+
* The most calls in one batch. A batch with more calls gets the status 413 and the code
|
|
28
|
+
* `PAYLOAD_TOO_LARGE`, and no call runs.
|
|
29
|
+
* @default 64
|
|
30
|
+
*/
|
|
31
|
+
readonly maxCalls?: number | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* The most calls of one batch that run at the same time. The next call starts when a call ends.
|
|
34
|
+
* @default 8
|
|
35
|
+
*/
|
|
36
|
+
readonly concurrency?: number | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The options of the batches, with each default. */
|
|
40
|
+
export interface ResolvedBatchOptions {
|
|
41
|
+
readonly maxCalls: number;
|
|
42
|
+
readonly concurrency: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The defaults of `HandlerBatchOptions`. */
|
|
46
|
+
export const BATCH_DEFAULTS = { maxCalls: 64, concurrency: 8 } as const;
|
|
47
|
+
|
|
48
|
+
/** The longest id of a call in a batch. */
|
|
49
|
+
export const BATCH_CALL_ID_LIMIT = 128;
|
|
50
|
+
|
|
51
|
+
/** One call of a batch, as the body gives it. */
|
|
52
|
+
export interface BatchCall {
|
|
53
|
+
/** The id of the call, unique in its batch. Each frame of the call names it. */
|
|
54
|
+
readonly id: string;
|
|
55
|
+
readonly method: Method;
|
|
56
|
+
/** The input of the procedure. Without `input`, the input is `{}`. */
|
|
57
|
+
readonly input: unknown;
|
|
58
|
+
/** The headers of the call, as the headers of a request of one call. */
|
|
59
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
60
|
+
/** The custom metadata of the call, as the `X-Metadata` header of a request of one call. */
|
|
61
|
+
readonly metadata: Readonly<Record<string, unknown>> | undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A frame of the answer of a batch: the payload of one NDJSON item. */
|
|
65
|
+
export type BatchFrame =
|
|
66
|
+
| { readonly call: string; readonly type: "response"; readonly payload?: unknown }
|
|
67
|
+
| { readonly call: string; readonly type: "item"; readonly seq: number; readonly payload: unknown }
|
|
68
|
+
| { readonly call: string; readonly type: "done"; readonly seq: number }
|
|
69
|
+
| { readonly call: string; readonly type: "error"; readonly error: BatchError };
|
|
70
|
+
|
|
71
|
+
/** The error of a call of a batch: the fields of the error body of one call. */
|
|
72
|
+
export interface BatchError extends NdjsonError {
|
|
73
|
+
readonly reason?: string | undefined;
|
|
74
|
+
readonly retryAfterMs?: number | undefined;
|
|
75
|
+
readonly conflicts?: unknown[] | undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** True when the request is a batch: `POST` with the header `Procedures-Batch: 1`. */
|
|
79
|
+
export function isBatchRequest(req: IncomingMessage): boolean {
|
|
80
|
+
return (req.method ?? "").toUpperCase() === "POST" && req.headers[BATCH_HEADER.toLowerCase()] === "1";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The resolved options of the batches, or undefined when the handler takes no batch. */
|
|
84
|
+
export function batchOptionsOf(option: boolean | HandlerBatchOptions | undefined): ResolvedBatchOptions | undefined {
|
|
85
|
+
if (option === false) return undefined;
|
|
86
|
+
const given = option === true || option === undefined ? {} : option;
|
|
87
|
+
const count = (value: number | undefined, fallback: number, name: string): number => {
|
|
88
|
+
if (value === undefined) return fallback;
|
|
89
|
+
if (!Number.isInteger(value) || value < 1) throw new TypeError(`batch.${name} must be a positive integer, not ${String(value)}`);
|
|
90
|
+
return value;
|
|
91
|
+
};
|
|
92
|
+
return { maxCalls: count(given.maxCalls, BATCH_DEFAULTS.maxCalls, "maxCalls"), concurrency: count(given.concurrency, BATCH_DEFAULTS.concurrency, "concurrency") };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
96
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The calls of a batch body. A body that is not a batch throws a `BodyError` with the status 400,
|
|
101
|
+
* and a batch above `maxCalls` throws one with the status 413. Then no call runs.
|
|
102
|
+
*/
|
|
103
|
+
export function parseBatch(body: unknown, maxCalls: number): BatchCall[] {
|
|
104
|
+
const calls = isRecord(body) && Object.hasOwn(body, "calls") ? body["calls"] : undefined;
|
|
105
|
+
if (!Array.isArray(calls) || calls.length === 0) throw new BodyError('A batch body is {"calls": [...]} with one call or more', 400);
|
|
106
|
+
if (calls.length > maxCalls) throw new BodyError(`A batch has ${calls.length} calls, and the limit is ${maxCalls}`, 413);
|
|
107
|
+
const ids = new Set<string>();
|
|
108
|
+
return calls.map((value: unknown, index): BatchCall => {
|
|
109
|
+
if (!isRecord(value)) throw new BodyError(`The call ${index} of the batch is not an object`, 400);
|
|
110
|
+
const own = (key: string): unknown => (Object.hasOwn(value, key) ? value[key] : undefined);
|
|
111
|
+
const id = own("id");
|
|
112
|
+
if (typeof id !== "string" || id === "" || id.length > BATCH_CALL_ID_LIMIT) {
|
|
113
|
+
throw new BodyError(`The call ${index} of the batch needs an id: a text of 1 to ${BATCH_CALL_ID_LIMIT} characters`, 400);
|
|
114
|
+
}
|
|
115
|
+
if (ids.has(id)) throw new BodyError(`Two calls of the batch have the id "${id}"`, 400);
|
|
116
|
+
ids.add(id);
|
|
117
|
+
const path = segmentsOf(own("path"));
|
|
118
|
+
if (!path) throw new BodyError(`The call "${id}" of the batch has no valid path`, 400);
|
|
119
|
+
const headers = own("headers") ?? {};
|
|
120
|
+
if (!isRecord(headers) || !Object.values(headers).every((header) => typeof header === "string")) {
|
|
121
|
+
throw new BodyError(`The headers of the call "${id}" are not an object of texts`, 400);
|
|
122
|
+
}
|
|
123
|
+
const metadata = own("metadata");
|
|
124
|
+
if (metadata !== undefined && !isRecord(metadata)) throw new BodyError(`The metadata of the call "${id}" is not an object`, 400);
|
|
125
|
+
const input = own("input");
|
|
126
|
+
return { id, method: pathToMethod(path), input: input === undefined ? {} : input, headers: headers as Record<string, string>, metadata };
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The error fields of an error response, as the error body of one call has them. */
|
|
131
|
+
export function batchErrorOf(status: Extract<Status, { type: "error" }>, metadata: Metadata): BatchError {
|
|
132
|
+
const wait = metadata["retry-after"];
|
|
133
|
+
const reason = metadata["reason"];
|
|
134
|
+
const conflicts = metadata["conflicts"];
|
|
135
|
+
return {
|
|
136
|
+
code: status.code,
|
|
137
|
+
message: status.message,
|
|
138
|
+
retryable: status.retryable,
|
|
139
|
+
...(typeof wait === "number" && Number.isFinite(wait) && wait >= 0 ? { retryAfterMs: wait } : {}),
|
|
140
|
+
...(typeof reason === "string" ? { reason } : {}),
|
|
141
|
+
...(Array.isArray(conflicts) ? { conflicts } : {}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The frames of one call: its response, or the items and the end of its stream, or its error. */
|
|
146
|
+
async function* callFrames(
|
|
147
|
+
server: RequestHandler,
|
|
148
|
+
call: string,
|
|
149
|
+
request: ServerRequest,
|
|
150
|
+
errorOf: (error: unknown, items: number) => NdjsonError,
|
|
151
|
+
): AsyncGenerator<BatchFrame, void, undefined> {
|
|
152
|
+
let response: ServerResponse;
|
|
153
|
+
try {
|
|
154
|
+
response = await server.handle(request);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
// The last-resort error of a call, as the face of one call sends it (CLI-80)
|
|
157
|
+
const exposed = lastResortError(server, error, request);
|
|
158
|
+
const wait = exposed.retryAfterMs;
|
|
159
|
+
yield {
|
|
160
|
+
call,
|
|
161
|
+
type: "error",
|
|
162
|
+
error: {
|
|
163
|
+
code: exposed.code,
|
|
164
|
+
message: exposed.message,
|
|
165
|
+
retryable: exposed.retryable === true,
|
|
166
|
+
...(typeof wait === "number" && Number.isFinite(wait) && wait >= 0 ? { retryAfterMs: wait } : {}),
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (response.stream) {
|
|
172
|
+
// The seq of the frames of a stream counts within the call, as in a stream of one call
|
|
173
|
+
let seq = 0;
|
|
174
|
+
try {
|
|
175
|
+
for await (const item of response.stream) yield { call, type: "item", seq: ++seq, payload: item };
|
|
176
|
+
} catch (error) {
|
|
177
|
+
yield { call, type: "error", error: errorOf(error, seq) };
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
yield { call, type: "done", seq: ++seq };
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (response.status.type === "error") {
|
|
184
|
+
yield { call, type: "error", error: batchErrorOf(response.status, response.metadata) };
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
// No value: the frame has no payload, as a response of one call has no body (status 204)
|
|
188
|
+
yield response.payload === undefined ? { call, type: "response" } : { call, type: "response", payload: response.payload };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** One call of a batch with its request. */
|
|
192
|
+
export interface BatchRun {
|
|
193
|
+
readonly call: string;
|
|
194
|
+
readonly request: ServerRequest;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The frames of each call of a batch, in the order of their arrival. At most `concurrency` calls
|
|
199
|
+
* run at the same time. The function takes the next frame of a call only after the reader took the
|
|
200
|
+
* frame before it, so a slow reader slows each stream (backpressure). When the reader stops, each
|
|
201
|
+
* call that runs gets `return()`.
|
|
202
|
+
*/
|
|
203
|
+
export async function* batchFrames(
|
|
204
|
+
server: RequestHandler,
|
|
205
|
+
runs: readonly BatchRun[],
|
|
206
|
+
concurrency: number,
|
|
207
|
+
errorOf: (error: unknown, items: number) => NdjsonError,
|
|
208
|
+
): AsyncGenerator<BatchFrame, void, undefined> {
|
|
209
|
+
type Next = { readonly key: number; readonly result: IteratorResult<BatchFrame, void> };
|
|
210
|
+
const running = new Map<number, { readonly iterator: AsyncIterator<BatchFrame, void>; next: Promise<Next> }>();
|
|
211
|
+
const pull = (key: number, iterator: AsyncIterator<BatchFrame, void>): Promise<Next> => iterator.next().then((result) => ({ key, result }));
|
|
212
|
+
let started = 0;
|
|
213
|
+
const start = (): void => {
|
|
214
|
+
while (running.size < concurrency && started < runs.length) {
|
|
215
|
+
const key = started++;
|
|
216
|
+
const run = runs[key]!;
|
|
217
|
+
const iterator = callFrames(server, run.call, run.request, errorOf);
|
|
218
|
+
running.set(key, { iterator, next: pull(key, iterator) });
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
try {
|
|
222
|
+
start();
|
|
223
|
+
while (running.size > 0) {
|
|
224
|
+
const { key, result } = await Promise.race(Array.from(running.values(), (entry) => entry.next));
|
|
225
|
+
const entry = running.get(key)!;
|
|
226
|
+
if (result.done) {
|
|
227
|
+
running.delete(key);
|
|
228
|
+
start();
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
yield result.value;
|
|
232
|
+
entry.next = pull(key, entry.iterator);
|
|
233
|
+
}
|
|
234
|
+
} finally {
|
|
235
|
+
for (const entry of running.values()) void Promise.resolve(entry.iterator.return?.()).catch(() => undefined);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -32,6 +32,7 @@ import { NDJSON_MEDIA_TYPE as NDJSON, defaultNdjsonError as errorOf, keepAliveMs
|
|
|
32
32
|
import { applyCors, corsOrigins, corsPolicy, type CorsOptions, type CorsPolicy } from "./cors.js";
|
|
33
33
|
import { urlAddressing, type Addressing } from "./addressing.js";
|
|
34
34
|
import { BodyError, readJsonBody } from "./body.js";
|
|
35
|
+
import { BATCH_HEADER, batchFrames, batchOptionsOf, isBatchRequest, parseBatch, type HandlerBatchOptions, type BatchRun } from "./batch.js";
|
|
35
36
|
import { NARROWING_METADATA_KEY, addNarrowing } from "../../../procedures/narrowing.js";
|
|
36
37
|
import { retryAfterSeconds } from "@mark1russell7/service/limiter";
|
|
37
38
|
|
|
@@ -107,6 +108,13 @@ export interface ProcedureHandlerOptions extends BrowserGuardOptions {
|
|
|
107
108
|
* @default ["token"]
|
|
108
109
|
*/
|
|
109
110
|
readonly secretQuery?: readonly string[] | undefined;
|
|
111
|
+
/**
|
|
112
|
+
* The batches of calls (roadmap CLI-32): `POST {basePath}` with the header `Procedures-Batch: 1`
|
|
113
|
+
* carries several calls, and the answer is one NDJSON stream of frames that name their call.
|
|
114
|
+
* Each call runs as a call of its own. `false` takes no batch: such a request goes on as before.
|
|
115
|
+
* @default { maxCalls: 64, concurrency: 8 }
|
|
116
|
+
*/
|
|
117
|
+
readonly batch?: boolean | HandlerBatchOptions | undefined;
|
|
110
118
|
}
|
|
111
119
|
|
|
112
120
|
/**
|
|
@@ -196,7 +204,26 @@ const STANDARD_HEADERS = new Set([
|
|
|
196
204
|
]);
|
|
197
205
|
|
|
198
206
|
/** The known custom headers whose metadata key is not the header name (lower case to camel case). */
|
|
199
|
-
|
|
207
|
+
/**
|
|
208
|
+
* The headers that a call of a batch cannot give (CLI-32): the headers of the connection, of the
|
|
209
|
+
* body, of the browser and of the protocol stay the headers of the HTTP request.
|
|
210
|
+
*/
|
|
211
|
+
const REQUEST_ONLY_HEADERS = new Set([
|
|
212
|
+
"host",
|
|
213
|
+
"connection",
|
|
214
|
+
"content-length",
|
|
215
|
+
"content-type",
|
|
216
|
+
"transfer-encoding",
|
|
217
|
+
"accept",
|
|
218
|
+
"accept-encoding",
|
|
219
|
+
"origin",
|
|
220
|
+
"referer",
|
|
221
|
+
"x-metadata",
|
|
222
|
+
"procedures-protocol",
|
|
223
|
+
"procedures-batch",
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
const HEADER_MAPPINGS: Record<string, string> ={ collectionname: "collectionName" };
|
|
200
227
|
|
|
201
228
|
/** This function sets a key as an own property, also the key `__proto__` (the `put` of the query language). */
|
|
202
229
|
function put(target: Record<string, unknown>, key: string, value: unknown): void {
|
|
@@ -291,6 +318,7 @@ export function createProcedureHandler(server: RequestHandler, options: Procedur
|
|
|
291
318
|
assertNamedOrigins(compat.face?.name ?? "procedureHandler", ambientMode, guard.allowedOrigins, compat.face?.originOption ?? "cors.origin");
|
|
292
319
|
const secretHeaders = headerNames(options.secretHeaders);
|
|
293
320
|
const secretQuery = new Set(options.secretQuery ?? (legacy ? [] : ["token"]));
|
|
321
|
+
const batch = batchOptionsOf(options.batch);
|
|
294
322
|
// The addressing of a request (CLI-31): the option, or the URL strategy of the shim, or the URL
|
|
295
323
|
// format of HttpTransport
|
|
296
324
|
const compatMethodOf = compat.methodOf;
|
|
@@ -317,7 +345,14 @@ export function createProcedureHandler(server: RequestHandler, options: Procedur
|
|
|
317
345
|
const matches = (req: IncomingMessage): boolean => {
|
|
318
346
|
if (compat.matchAll === true) return true;
|
|
319
347
|
const path = pathOf(req);
|
|
320
|
-
return path.startsWith(prefix) || (addressing.takesBasePath === true
|
|
348
|
+
return path.startsWith(prefix) || (path === bareBase && (addressing.takesBasePath === true || (batch !== undefined && isBatchRequest(req))));
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/** True when the request is a batch at the base path (CLI-32). */
|
|
352
|
+
const isBatch = (req: IncomingMessage): boolean => {
|
|
353
|
+
if (batch === undefined || !isBatchRequest(req)) return false;
|
|
354
|
+
const path = pathOf(req);
|
|
355
|
+
return compat.matchAll === true || path === bareBase || path === prefix;
|
|
321
356
|
};
|
|
322
357
|
|
|
323
358
|
/** The error body of wire v1: the message, the code, the retryable flag and the more fields. */
|
|
@@ -340,9 +375,45 @@ export function createProcedureHandler(server: RequestHandler, options: Procedur
|
|
|
340
375
|
const view: UrlRequest = { method: (req.method ?? "GET").toUpperCase(), path, url: req.url ?? "/", headers: req.headers, query: queryRecord(urlQuery) };
|
|
341
376
|
const address = addressing(view, body, req);
|
|
342
377
|
if (!address) return null;
|
|
343
|
-
const method = address.method;
|
|
344
|
-
|
|
345
378
|
const id = (req.headers["x-request-id"] as string | undefined) ?? `req-${Date.now()}-${Math.random()}`;
|
|
379
|
+
const narrowing = req.headersDistinct?.[NARROWING_METADATA_KEY] ?? req.headers[NARROWING_METADATA_KEY];
|
|
380
|
+
return requestOf(req, { id, method: address.method, payload: address.payload, headers: req.headers, narrowing: [narrowing].flat(), custom: customOf(req.headers) });
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The custom metadata of the `X-Metadata` header (deep dive TRN-11): any JSON value, any key.
|
|
385
|
+
* A header above the limit or a header that is not URI-encoded JSON throws a `BodyError`.
|
|
386
|
+
*/
|
|
387
|
+
const customOf = (headers: IncomingHttpHeaders): Record<string, unknown>[] => {
|
|
388
|
+
const encoded = headers["x-metadata"];
|
|
389
|
+
if (typeof encoded !== "string") return [];
|
|
390
|
+
if (encoded.length > METADATA_HEADER_LIMIT) {
|
|
391
|
+
throw new BodyError(`The X-Metadata header is longer than ${METADATA_HEADER_LIMIT} characters`, 431);
|
|
392
|
+
}
|
|
393
|
+
const custom = decodeMetadataHeader(encoded);
|
|
394
|
+
if (!custom) throw new BodyError("The X-Metadata header is not URI-encoded JSON of an object", 400);
|
|
395
|
+
return [custom];
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The `ServerRequest` of one call. A request of one call gives the headers of the HTTP request.
|
|
400
|
+
* A call of a batch gives the headers of the HTTP request with the headers of the call over
|
|
401
|
+
* them (CLI-32). The credentials always come from the HTTP request.
|
|
402
|
+
*/
|
|
403
|
+
const requestOf = (
|
|
404
|
+
req: IncomingMessage,
|
|
405
|
+
call: {
|
|
406
|
+
readonly id: string;
|
|
407
|
+
readonly method: Method;
|
|
408
|
+
readonly payload: unknown;
|
|
409
|
+
readonly headers: IncomingHttpHeaders;
|
|
410
|
+
/** Each value of the narrowing header: each one is a source (CLI-30) */
|
|
411
|
+
readonly narrowing: readonly unknown[];
|
|
412
|
+
/** The custom metadata, in order: a later object replaces a key of an earlier one */
|
|
413
|
+
readonly custom: readonly Record<string, unknown>[];
|
|
414
|
+
}
|
|
415
|
+
): ServerRequest => {
|
|
416
|
+
const { id, method, headers } = call;
|
|
346
417
|
|
|
347
418
|
// The query without its secret parameters: they go into the credentials (CLI-2a)
|
|
348
419
|
// An own property for each key, also `__proto__`: a key of the request changes no prototype
|
|
@@ -354,7 +425,7 @@ export function createProcedureHandler(server: RequestHandler, options: Procedur
|
|
|
354
425
|
// query or params. The secret headers go into the credentials, not into the metadata, so a
|
|
355
426
|
// handler and a nested call do not see them (roadmap R18)
|
|
356
427
|
const metadata: Metadata = {
|
|
357
|
-
headers: withoutSecretHeaders(
|
|
428
|
+
headers: withoutSecretHeaders(headers as Record<string, unknown>, secretHeaders),
|
|
358
429
|
query,
|
|
359
430
|
params,
|
|
360
431
|
};
|
|
@@ -364,7 +435,7 @@ export function createProcedureHandler(server: RequestHandler, options: Procedur
|
|
|
364
435
|
|
|
365
436
|
// The custom headers at the root of the metadata, as the client's metadataToHeaders() sends
|
|
366
437
|
// them. A secret header (Cookie too) does not go there.
|
|
367
|
-
for (const [key, value] of Object.entries(
|
|
438
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
368
439
|
const lower = key.toLowerCase();
|
|
369
440
|
if (STANDARD_HEADERS.has(lower) || isSecretHeader(lower, secretHeaders) || typeof value !== "string" || lower === NARROWING_METADATA_KEY) continue;
|
|
370
441
|
const normalizedKey = HEADER_MAPPINGS[lower] ?? key;
|
|
@@ -372,17 +443,10 @@ export function createProcedureHandler(server: RequestHandler, options: Procedur
|
|
|
372
443
|
}
|
|
373
444
|
// Each value of the narrowing header is one source, also each line of a repeated header. No
|
|
374
445
|
// source replaces another: a gateway's header and the client's key of X-Metadata both apply (CLI-30)
|
|
375
|
-
const
|
|
376
|
-
for (const value of [narrowingHeader].flat()) if (typeof value === "string") setMetadata(metadata, NARROWING_METADATA_KEY, value);
|
|
446
|
+
for (const value of call.narrowing) if (typeof value === "string") setMetadata(metadata, NARROWING_METADATA_KEY, value);
|
|
377
447
|
|
|
378
448
|
// The custom metadata of the HTTP client: any JSON value, any key (deep dive TRN-11)
|
|
379
|
-
const
|
|
380
|
-
if (typeof encoded === "string") {
|
|
381
|
-
if (encoded.length > METADATA_HEADER_LIMIT) {
|
|
382
|
-
throw new BodyError(`The X-Metadata header is longer than ${METADATA_HEADER_LIMIT} characters`, 431);
|
|
383
|
-
}
|
|
384
|
-
const custom = decodeMetadataHeader(encoded);
|
|
385
|
-
if (!custom) throw new BodyError("The X-Metadata header is not URI-encoded JSON of an object", 400);
|
|
449
|
+
for (const custom of call.custom) {
|
|
386
450
|
for (const [key, value] of Object.entries(custom)) {
|
|
387
451
|
if (!RESERVED_METADATA.has(key) && key !== "auth" && !key.startsWith("__")) setMetadata(metadata, key, value);
|
|
388
452
|
}
|
|
@@ -390,7 +454,7 @@ export function createProcedureHandler(server: RequestHandler, options: Procedur
|
|
|
390
454
|
|
|
391
455
|
// The payload of the addressing (the body, or the id of a RESTful GET). Only an absent payload
|
|
392
456
|
// becomes {}: 0, false, "" and null stay (deep dive TRN-10)
|
|
393
|
-
const payload: unknown =
|
|
457
|
+
const payload: unknown = call.payload === undefined ? {} : call.payload;
|
|
394
458
|
|
|
395
459
|
return {
|
|
396
460
|
id,
|
|
@@ -405,6 +469,32 @@ export function createProcedureHandler(server: RequestHandler, options: Procedur
|
|
|
405
469
|
};
|
|
406
470
|
};
|
|
407
471
|
|
|
472
|
+
/**
|
|
473
|
+
* The runs of a batch (CLI-32). A bad body or a bad `X-Metadata` header throws a `BodyError`, and
|
|
474
|
+
* then no call runs. A header of a call cannot be a secret header, a header of the connection or
|
|
475
|
+
* a header of the body: the credentials and the body are the parts of the HTTP request.
|
|
476
|
+
*/
|
|
477
|
+
const batchRuns = (req: IncomingMessage, body: unknown, signal: AbortSignal): BatchRun[] => {
|
|
478
|
+
const calls = parseBatch(body, batch!.maxCalls);
|
|
479
|
+
const outerCustom = customOf(req.headers);
|
|
480
|
+
const outerNarrowing = [req.headersDistinct?.[NARROWING_METADATA_KEY] ?? req.headers[NARROWING_METADATA_KEY]].flat();
|
|
481
|
+
const batchId = (req.headers["x-request-id"] as string | undefined) ?? `batch-${Date.now()}-${Math.random()}`;
|
|
482
|
+
return calls.map((call) => {
|
|
483
|
+
const headers: IncomingHttpHeaders = {};
|
|
484
|
+
for (const [key, value] of Object.entries(req.headers)) put(headers, key, value);
|
|
485
|
+
const narrowing = [...outerNarrowing];
|
|
486
|
+
for (const [key, value] of Object.entries(call.headers)) {
|
|
487
|
+
const lower = key.toLowerCase();
|
|
488
|
+
if (lower === NARROWING_METADATA_KEY) narrowing.push(value);
|
|
489
|
+
else if (!REQUEST_ONLY_HEADERS.has(lower) && !lower.startsWith("sec-") && !isSecretHeader(lower, secretHeaders)) put(headers, lower, value);
|
|
490
|
+
}
|
|
491
|
+
const custom = call.metadata ? [...outerCustom, call.metadata] : outerCustom;
|
|
492
|
+
const request = requestOf(req, { id: `${batchId}:${call.id}`, method: call.method, payload: call.input, headers, narrowing, custom });
|
|
493
|
+
request.signal = signal;
|
|
494
|
+
return { call: call.id, request };
|
|
495
|
+
});
|
|
496
|
+
};
|
|
497
|
+
|
|
408
498
|
/** This function converts a `ServerResponse` to the HTTP response. */
|
|
409
499
|
const toHttp = (serverResponse: ServerResponse, res: NodeResponse): void => {
|
|
410
500
|
const { status, payload, metadata } = serverResponse;
|
|
@@ -517,6 +607,9 @@ export function createProcedureHandler(server: RequestHandler, options: Procedur
|
|
|
517
607
|
if (cors && applyCors(req, res, cors)) return;
|
|
518
608
|
// The protocol version of this server, on each response (roadmap R25)
|
|
519
609
|
res.setHeader(PROTOCOL_HEADER, String(PROTOCOL_VERSION));
|
|
610
|
+
// A server that takes batches says so on each answer to a batch, also on an error. A client
|
|
611
|
+
// sends the calls of a batch alone after an answer without it (CLI-32).
|
|
612
|
+
if (isBatch(req)) res.setHeader(BATCH_HEADER, "1");
|
|
520
613
|
const version = protocolVersionOf(req.headers[PROTOCOL_HEADER.toLowerCase()]);
|
|
521
614
|
if (version !== undefined && version > PROTOCOL_VERSION) {
|
|
522
615
|
sendJson(res, 400, {
|
|
@@ -557,14 +650,23 @@ export function createProcedureHandler(server: RequestHandler, options: Procedur
|
|
|
557
650
|
});
|
|
558
651
|
let serverRequest: ServerRequest | null = null;
|
|
559
652
|
try {
|
|
653
|
+
let runs: BatchRun[] | undefined;
|
|
560
654
|
try {
|
|
561
|
-
|
|
655
|
+
const body = await readBody(req);
|
|
656
|
+
if (isBatch(req)) runs = batchRuns(req, body, controller.signal);
|
|
657
|
+
else serverRequest = toServerRequest(req, body);
|
|
562
658
|
} catch (error) {
|
|
563
659
|
const status = error instanceof BodyError ? error.httpStatus : 400;
|
|
564
660
|
// A body above the limit is PAYLOAD_TOO_LARGE, as on the MCP face and the WebSocket face (CLI-85)
|
|
565
661
|
sendError(res, status, (error as Error).message, status === 413 ? "PAYLOAD_TOO_LARGE" : "INVALID_REQUEST");
|
|
566
662
|
return;
|
|
567
663
|
}
|
|
664
|
+
if (runs) {
|
|
665
|
+
// One NDJSON stream for the calls of the batch: the lines, the backpressure and the
|
|
666
|
+
// keep-alive of a stream of one call (CLI-32)
|
|
667
|
+
await writeNdjsonStream(res, batchFrames(server, runs, batch!.concurrency, errorOf), { signal: controller.signal, keepAlive, errorOf, clock });
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
568
670
|
if (!serverRequest) {
|
|
569
671
|
sendError(res, 400, "Invalid request: could not parse RPC method from URL", "INVALID_REQUEST");
|
|
570
672
|
return;
|
|
@@ -17,6 +17,7 @@ import type { HttpServerTransportOptions } from "./types.js";
|
|
|
17
17
|
import { createPatternServerUrlStrategy } from "./strategies.js";
|
|
18
18
|
import { applyCors, corsOrigins, corsPolicy, type CorsPolicy } from "./cors.js";
|
|
19
19
|
import { createProcedureHandler, type NodeProcedureHandler } from "./handler.js";
|
|
20
|
+
import { isBatchRequest } from "./batch.js";
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* This function gives the route of the procedure handler: each path under the base path. With
|
|
@@ -54,7 +55,7 @@ export class HttpServerTransport implements ServerTransport {
|
|
|
54
55
|
|
|
55
56
|
private app: Express;
|
|
56
57
|
private httpServer: HttpServer | null = null;
|
|
57
|
-
private options: { port: number; host: string; basePath: string; cors: boolean; httpServer: HttpServer | undefined; takesBasePath: boolean };
|
|
58
|
+
private options: { port: number; host: string; basePath: string; cors: boolean; httpServer: HttpServer | undefined; takesBasePath: boolean; batch: boolean };
|
|
58
59
|
/** The procedure handler that does the work of each request (CLI-3) */
|
|
59
60
|
private readonly handler: NodeProcedureHandler;
|
|
60
61
|
/** The CORS of the app, or undefined when it is off */
|
|
@@ -72,6 +73,8 @@ export class HttpServerTransport implements ServerTransport {
|
|
|
72
73
|
httpServer: options.httpServer,
|
|
73
74
|
// A single endpoint with the path in the body is the base path itself (CLI-31)
|
|
74
75
|
takesBasePath: options.addressing?.takesBasePath === true,
|
|
76
|
+
// A batch of calls goes to the base path itself too (CLI-32)
|
|
77
|
+
batch: options.batch !== false,
|
|
75
78
|
};
|
|
76
79
|
// Route by URL path so the raw server matches the HTTP client's default URL format instead of
|
|
77
80
|
// inferring the operation from the verb (BUGS-2026-07 C3, bug 16)
|
|
@@ -93,6 +96,7 @@ export class HttpServerTransport implements ServerTransport {
|
|
|
93
96
|
secretHeaders: options.secretHeaders,
|
|
94
97
|
secretQuery: options.secretQuery,
|
|
95
98
|
addressing: options.addressing,
|
|
99
|
+
batch: options.batch,
|
|
96
100
|
},
|
|
97
101
|
{
|
|
98
102
|
legacy: true,
|
|
@@ -133,10 +137,17 @@ export class HttpServerTransport implements ServerTransport {
|
|
|
133
137
|
}
|
|
134
138
|
|
|
135
139
|
// Catch-all route for RPC - use regex to match any path under basePath, and the base path
|
|
136
|
-
// itself for an addressing that takes it
|
|
140
|
+
// itself for an addressing that takes it. A batch also goes to the base path itself; another
|
|
141
|
+
// request there goes on to the app (CLI-32).
|
|
142
|
+
const { basePath, takesBasePath, batch } = this.options;
|
|
143
|
+
const bareBase = basePath.replace(/\/$/, "");
|
|
137
144
|
this.app.all(
|
|
138
|
-
basePathRoute(
|
|
139
|
-
async (req, res) => {
|
|
145
|
+
basePathRoute(basePath, takesBasePath || batch),
|
|
146
|
+
async (req, res, next) => {
|
|
147
|
+
if (!takesBasePath && req.path.replace(/\/$/, "") === bareBase && !isBatchRequest(req)) {
|
|
148
|
+
next();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
140
151
|
await this.handler.handle(req, res);
|
|
141
152
|
}
|
|
142
153
|
);
|
|
@@ -11,6 +11,7 @@ import type { BrowserGuardOptions } from "../../../server/browser-guard.js";
|
|
|
11
11
|
import type { Clock } from "../../../clock.js";
|
|
12
12
|
import type { Duration } from "../../../duration.js";
|
|
13
13
|
import type { Addressing } from "./addressing.js";
|
|
14
|
+
import type { HandlerBatchOptions } from "./batch.js";
|
|
14
15
|
import type { UrlStrategyRequest } from "./strategies.js";
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -62,6 +63,13 @@ export interface HttpServerTransportOptions extends BrowserGuardOptions {
|
|
|
62
63
|
*/
|
|
63
64
|
addressing?: Addressing;
|
|
64
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The batches of calls (CLI-32): `POST {base}` with the header `Procedures-Batch: 1` runs many
|
|
68
|
+
* calls and answers with one NDJSON stream. `false` turns batches off.
|
|
69
|
+
* @default true: `{ maxCalls: 64, concurrency: 8 }`
|
|
70
|
+
*/
|
|
71
|
+
batch?: boolean | HandlerBatchOptions;
|
|
72
|
+
|
|
65
73
|
/**
|
|
66
74
|
* Base path for RPC endpoints.
|
|
67
75
|
* @default "/api"
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import { HTTPStatus } from "./constants.js";
|
|
9
9
|
import { formatHTTPError, getStatusCode, isRetryableStatus } from "./status-codes.js";
|
|
10
|
+
import { ErrorCode } from "../../../client/errors/codes.js";
|
|
11
|
+
import { httpStatusToErrorCode } from "../../../client/errors/http-errors.js";
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* HTTP Error Codes (Type-safe)
|
|
@@ -75,18 +77,17 @@ export interface HTTPErrorDetails {
|
|
|
75
77
|
/**
|
|
76
78
|
* Create HTTP error details from status code
|
|
77
79
|
*
|
|
78
|
-
* `HttpTransport` uses these details when the body of an error response has no string code.
|
|
79
|
-
*
|
|
80
|
-
* `
|
|
81
|
-
* `
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* `ERROR_REGISTRY`. Then 408 gives `TIMEOUT` (retryable), 500 gives `INTERNAL_ERROR` (not
|
|
85
|
-
* retryable) and 599 gives `HTTP_NETWORK_CONNECT_TIMEOUT` (retryable). `HTTPStatusToCode` has the table.
|
|
80
|
+
* `HttpTransport` uses these details when the body of an error response has no string code. The
|
|
81
|
+
* code is the code of `ERROR_REGISTRY` that `httpStatusToErrorCode()` gives. Thus 408 gives
|
|
82
|
+
* `TIMEOUT` (retryable), 500 gives `INTERNAL_ERROR` (not retryable) and 599 gives
|
|
83
|
+
* `HTTP_NETWORK_CONNECT_TIMEOUT` (retryable). A status without a code of the registry gives its
|
|
84
|
+
* name from `HTTPStatusToCode`, for example `NOT_MODIFIED` for 304. `isRetryableStatus()` gives the
|
|
85
|
+
* retryable flag.
|
|
86
86
|
*/
|
|
87
87
|
export function createHTTPStatusError(status: HTTPStatus): HTTPErrorDetails {
|
|
88
|
+
const registryCode = httpStatusToErrorCode(status);
|
|
88
89
|
return {
|
|
89
|
-
code: getStatusCode(status),
|
|
90
|
+
code: registryCode === ErrorCode.UNKNOWN ? getStatusCode(status) : registryCode,
|
|
90
91
|
message: formatHTTPError(status),
|
|
91
92
|
retryable: isRetryableStatus(status),
|
|
92
93
|
status,
|