@procedures/client 0.41.0 → 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 +70 -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 +7 -0
- package/dist/adapters/http/shared/errors.d.ts.map +1 -1
- package/dist/adapters/http/shared/errors.js +11 -1
- package/dist/adapters/http/shared/errors.js.map +1 -1
- package/dist/adapters/http/shared/status-codes.d.ts +19 -3
- package/dist/adapters/http/shared/status-codes.d.ts.map +1 -1
- package/dist/adapters/http/shared/status-codes.js +27 -10
- 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/errors/codes.d.ts +2 -0
- package/dist/client/errors/codes.d.ts.map +1 -1
- package/dist/client/errors/codes.js +2 -0
- package/dist/client/errors/codes.js.map +1 -1
- package/dist/client/errors/registry.d.ts.map +1 -1
- package/dist/client/errors/registry.js +24 -0
- package/dist/client/errors/registry.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 +11 -1
- package/src/adapters/http/shared/status-codes.ts +27 -12
- package/src/browser.ts +1 -0
- package/src/client/errors/codes.ts +2 -0
- package/src/client/errors/registry.ts +26 -0
- package/src/client/index.ts +1 -1
- package/src/node.ts +2 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The batches of `HttpTransport` (roadmap CLI-32).
|
|
3
|
+
*
|
|
4
|
+
* With the option `batch`, the calls that start in the same turn of the event loop go to the
|
|
5
|
+
* server in one request: `POST {baseUrl}` with the header `Procedures-Batch: 1`. The answer is one
|
|
6
|
+
* NDJSON stream, and each frame of it names its call. The batcher gives each call its own items, so
|
|
7
|
+
* the middleware of a call (retry, timeout, circuit breaker) sees the items of one call, as without
|
|
8
|
+
* a batch. Refer to "A batch of calls" in WIRE-PROTOCOL.md.
|
|
9
|
+
*
|
|
10
|
+
* - The calls of one request have the same secret headers (`Authorization`, `X-API-Key` and the
|
|
11
|
+
* others). Calls with other secret headers go in another request.
|
|
12
|
+
* - A batch of one call is a request of one call.
|
|
13
|
+
* - A server without batches answers without the header `Procedures-Batch`. Then the batcher sends
|
|
14
|
+
* each call alone, now and later.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Message, Metadata, ResponseItem, Status } from "../../../client/types.js";
|
|
18
|
+
import { DELIVERY_METADATA_KEY, RETRY_AFTER_METADATA_KEY, isNotSentError } from "../../../client/middleware/items.js";
|
|
19
|
+
import { StreamSequence, streamItemMetadata } from "../../sequence.js";
|
|
20
|
+
import { foldedErrorCode, PROTOCOL_HEADER, PROTOCOL_VERSION } from "../../../protocol.js";
|
|
21
|
+
import { toMs, type Duration } from "../../../duration.js";
|
|
22
|
+
import { HTTPHeaders, createAbortError, createErrorFromException, decodeMetadataHeader } from "../shared/index.js";
|
|
23
|
+
|
|
24
|
+
/** The header of a batch request, and of the answer of a server that takes batches. */
|
|
25
|
+
export const BATCH_HEADER = "Procedures-Batch";
|
|
26
|
+
|
|
27
|
+
/** The options of the batches of `HttpTransport`. */
|
|
28
|
+
export interface HttpBatchOptions {
|
|
29
|
+
/**
|
|
30
|
+
* The most calls in one request. A call after them goes in the next request.
|
|
31
|
+
* @default 32
|
|
32
|
+
*/
|
|
33
|
+
readonly maxCalls?: number | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* The time that the first call of a batch waits for more calls. Zero takes the calls that start
|
|
36
|
+
* in the same turn of the event loop.
|
|
37
|
+
* @default 0
|
|
38
|
+
*/
|
|
39
|
+
readonly window?: Duration | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* The URL of a batch request.
|
|
42
|
+
* @default the `baseUrl` of the transport
|
|
43
|
+
*/
|
|
44
|
+
readonly url?: string | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* More headers that carry a secret, as the option `secretHeaders` of the server. Calls with other
|
|
47
|
+
* values of a secret header go in different requests. `Authorization`, `Proxy-Authorization`,
|
|
48
|
+
* `Cookie` and `X-Api-Key` are always secret.
|
|
49
|
+
*/
|
|
50
|
+
readonly secretHeaders?: readonly string[] | undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The defaults of `HttpBatchOptions`. */
|
|
54
|
+
export const HTTP_BATCH_DEFAULTS = { maxCalls: 32, windowMs: 0 } as const;
|
|
55
|
+
|
|
56
|
+
/** The secret headers of each request: they go on the batch request, not on a call. */
|
|
57
|
+
const SECRET_HEADERS = ["authorization", "proxy-authorization", "cookie", "x-api-key"];
|
|
58
|
+
|
|
59
|
+
/** The most items that wait for the reader of one call before the batch stops reading. */
|
|
60
|
+
const HIGH_WATER = 64;
|
|
61
|
+
|
|
62
|
+
/** The statuses of a server that knows no batch, when the answer has no `Procedures-Batch` header. */
|
|
63
|
+
const NO_BATCH_STATUSES = new Set([400, 404, 405, 415, 501]);
|
|
64
|
+
|
|
65
|
+
const NDJSON = "application/x-ndjson";
|
|
66
|
+
|
|
67
|
+
/** The parts of the transport that the batcher uses. */
|
|
68
|
+
export interface BatchHost {
|
|
69
|
+
readonly url: string;
|
|
70
|
+
readonly timeoutMs: number | undefined;
|
|
71
|
+
readonly fetch: (url: string, init: RequestInit) => Promise<Response>;
|
|
72
|
+
/** The headers and the init of each request of the transport. */
|
|
73
|
+
readonly init: () => { readonly headers: Record<string, string>; readonly init: Omit<RequestInit, "method" | "headers" | "body" | "signal"> };
|
|
74
|
+
/** The headers of the metadata of a call. It throws for metadata that is too large. */
|
|
75
|
+
readonly headersOf: (metadata: Metadata) => Record<string, string>;
|
|
76
|
+
readonly metadataOf: (headers: Headers) => Metadata;
|
|
77
|
+
/** The status of an HTTP answer that is not NDJSON, from its status and its body. */
|
|
78
|
+
readonly statusOf: (status: number, body: unknown) => Status;
|
|
79
|
+
readonly bodyOf: (response: Response) => Promise<unknown>;
|
|
80
|
+
/** The request of one call. */
|
|
81
|
+
readonly sendOne: <TRes>(message: Message) => AsyncIterable<ResponseItem<TRes>>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The items of one call: the reader of the batch pushes them, and the caller reads them. */
|
|
85
|
+
class Channel<T> {
|
|
86
|
+
private readonly items: T[] = [];
|
|
87
|
+
private ended = false;
|
|
88
|
+
private wake: (() => void) | undefined;
|
|
89
|
+
private room: (() => void) | undefined;
|
|
90
|
+
|
|
91
|
+
/** It resolves when the reader has room for more items. */
|
|
92
|
+
push(item: T): Promise<void> {
|
|
93
|
+
if (this.ended) return Promise.resolve();
|
|
94
|
+
this.items.push(item);
|
|
95
|
+
this.wake?.();
|
|
96
|
+
if (this.items.length < HIGH_WATER) return Promise.resolve();
|
|
97
|
+
return new Promise<void>((resolve) => (this.room = resolve));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
end(): void {
|
|
101
|
+
this.ended = true;
|
|
102
|
+
this.wake?.();
|
|
103
|
+
this.free();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private free(): void {
|
|
107
|
+
const room = this.room;
|
|
108
|
+
this.room = undefined;
|
|
109
|
+
room?.();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async *read(): AsyncGenerator<T, void, undefined> {
|
|
113
|
+
for (;;) {
|
|
114
|
+
const item = this.items.shift();
|
|
115
|
+
if (item !== undefined) {
|
|
116
|
+
if (this.items.length < HIGH_WATER) this.free();
|
|
117
|
+
yield item;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (this.ended) return;
|
|
121
|
+
await new Promise<void>((resolve) => (this.wake = resolve));
|
|
122
|
+
this.wake = undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** One call in a batch. */
|
|
128
|
+
interface Pending {
|
|
129
|
+
readonly message: Message;
|
|
130
|
+
readonly headers: Record<string, string>;
|
|
131
|
+
readonly channel: Channel<ResponseItem<unknown>>;
|
|
132
|
+
/** "queued" before the request; "alone" in a request of one call; "sent" in a batch */
|
|
133
|
+
state: "queued" | "alone" | "sent";
|
|
134
|
+
/** The call has its last item, or the caller stopped reading */
|
|
135
|
+
ended: boolean;
|
|
136
|
+
/** The caller stopped before the last item: an abort, a timeout or an early return */
|
|
137
|
+
detached: boolean;
|
|
138
|
+
/** The call got a frame: the timeout of the first item stops */
|
|
139
|
+
started: boolean;
|
|
140
|
+
timer?: ReturnType<typeof setTimeout> | undefined;
|
|
141
|
+
batch?: { readonly controller: AbortController; readonly calls: readonly Pending[] } | undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function errorItem(message: Message, status: Omit<Extract<Status, { type: "error" }>, "type">, metadata: Metadata): ResponseItem<unknown> {
|
|
145
|
+
return { id: message.id, status: { type: "error", ...status }, payload: null, metadata };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The text form of a path: the segments of the method joined with dots (CLI-75). */
|
|
149
|
+
function pathOf(message: Message): string {
|
|
150
|
+
const { service, operation } = message.method;
|
|
151
|
+
return service === "" ? operation : `${service}.${operation}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The batcher of one transport. */
|
|
155
|
+
export class HttpBatcher {
|
|
156
|
+
private readonly maxCalls: number;
|
|
157
|
+
private readonly windowMs: number;
|
|
158
|
+
private readonly secret: ReadonlySet<string>;
|
|
159
|
+
private queue: Pending[] = [];
|
|
160
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
161
|
+
/** The server answered a batch as a server without batches */
|
|
162
|
+
private unsupported = false;
|
|
163
|
+
|
|
164
|
+
constructor(
|
|
165
|
+
private readonly host: BatchHost,
|
|
166
|
+
options: HttpBatchOptions,
|
|
167
|
+
) {
|
|
168
|
+
const maxCalls = options.maxCalls ?? HTTP_BATCH_DEFAULTS.maxCalls;
|
|
169
|
+
if (!Number.isInteger(maxCalls) || maxCalls < 1) throw new TypeError(`batch.maxCalls must be a positive integer, not ${String(maxCalls)}`);
|
|
170
|
+
this.maxCalls = maxCalls;
|
|
171
|
+
this.windowMs = options.window === undefined ? HTTP_BATCH_DEFAULTS.windowMs : Math.max(0, toMs(options.window, "batch.window"));
|
|
172
|
+
this.secret = new Set([...SECRET_HEADERS, ...(options.secretHeaders ?? []).map((name) => name.toLowerCase())]);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async *send<TRes>(message: Message): AsyncGenerator<ResponseItem<TRes>, void, undefined> {
|
|
176
|
+
let headers: Record<string, string>;
|
|
177
|
+
try {
|
|
178
|
+
headers = this.host.headersOf(message.metadata);
|
|
179
|
+
} catch {
|
|
180
|
+
// The request of one call gives the error of the metadata
|
|
181
|
+
yield* this.host.sendOne<TRes>(message);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (this.unsupported || message.signal?.aborted) {
|
|
185
|
+
yield* this.host.sendOne<TRes>(message);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const pending: Pending = { message, headers, channel: new Channel(), state: "queued", ended: false, detached: false, started: false };
|
|
189
|
+
const onAbort = (): void => this.abort(pending);
|
|
190
|
+
message.signal?.addEventListener("abort", onAbort, { once: true });
|
|
191
|
+
this.enqueue(pending);
|
|
192
|
+
try {
|
|
193
|
+
yield* pending.channel.read() as AsyncGenerator<ResponseItem<TRes>, void, undefined>;
|
|
194
|
+
} finally {
|
|
195
|
+
message.signal?.removeEventListener("abort", onAbort);
|
|
196
|
+
// An early return of the caller: the call stops
|
|
197
|
+
if (!pending.ended) this.detach(pending);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private enqueue(pending: Pending): void {
|
|
202
|
+
this.queue.push(pending);
|
|
203
|
+
if (this.queue.length >= this.maxCalls) {
|
|
204
|
+
this.flush();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
this.timer ??= setTimeout(() => this.flush(), this.windowMs);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private flush(): void {
|
|
211
|
+
if (this.timer !== undefined) clearTimeout(this.timer);
|
|
212
|
+
this.timer = undefined;
|
|
213
|
+
const queue = this.queue;
|
|
214
|
+
this.queue = [];
|
|
215
|
+
// The calls of one request have the same secret headers
|
|
216
|
+
const groups = new Map<string, Pending[]>();
|
|
217
|
+
for (const pending of queue) {
|
|
218
|
+
const key = JSON.stringify(
|
|
219
|
+
Object.entries(pending.headers)
|
|
220
|
+
.filter(([name]) => this.secret.has(name.toLowerCase()))
|
|
221
|
+
.map(([name, value]) => [name.toLowerCase(), value])
|
|
222
|
+
.sort(([a], [b]) => (a! < b! ? -1 : 1)),
|
|
223
|
+
);
|
|
224
|
+
const group = groups.get(key);
|
|
225
|
+
if (group) group.push(pending);
|
|
226
|
+
else groups.set(key, [pending]);
|
|
227
|
+
}
|
|
228
|
+
for (const group of groups.values()) {
|
|
229
|
+
if (group.length === 1 || this.unsupported) for (const pending of group) void this.alone(pending);
|
|
230
|
+
else void this.sendBatch(group);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The request of one call, with the items in the channel of the call. */
|
|
235
|
+
private async alone(pending: Pending): Promise<void> {
|
|
236
|
+
pending.state = "alone";
|
|
237
|
+
try {
|
|
238
|
+
for await (const item of this.host.sendOne(pending.message)) {
|
|
239
|
+
if (pending.detached) break;
|
|
240
|
+
await pending.channel.push(item);
|
|
241
|
+
}
|
|
242
|
+
} finally {
|
|
243
|
+
this.end(pending);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The last item of a call, and the end of its channel. */
|
|
248
|
+
private finish(pending: Pending, item: ResponseItem<unknown>): void {
|
|
249
|
+
if (pending.ended) return;
|
|
250
|
+
void pending.channel.push(item);
|
|
251
|
+
this.end(pending);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private end(pending: Pending): void {
|
|
255
|
+
if (pending.ended) return;
|
|
256
|
+
pending.ended = true;
|
|
257
|
+
if (pending.timer !== undefined) clearTimeout(pending.timer);
|
|
258
|
+
pending.channel.end();
|
|
259
|
+
this.check(pending);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The caller does not read more items of the call. */
|
|
263
|
+
private detach(pending: Pending): void {
|
|
264
|
+
if (pending.ended) return;
|
|
265
|
+
pending.detached = true;
|
|
266
|
+
if (pending.state === "queued") this.queue = this.queue.filter((other) => other !== pending);
|
|
267
|
+
this.end(pending);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The signal of a call aborted. A call alone gets the abort from the request of one call. */
|
|
271
|
+
private abort(pending: Pending): void {
|
|
272
|
+
if (pending.ended || pending.state === "alone") return;
|
|
273
|
+
const delivery = pending.state === "queued" ? "not-sent" : "indeterminate";
|
|
274
|
+
pending.detached = true;
|
|
275
|
+
if (pending.state === "queued") this.queue = this.queue.filter((other) => other !== pending);
|
|
276
|
+
this.finish(pending, errorItem(pending.message, createAbortError(), { [DELIVERY_METADATA_KEY]: delivery }));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** When each call of a batch ended and a caller stopped early, the request stops. */
|
|
280
|
+
private check(pending: Pending): void {
|
|
281
|
+
const batch = pending.batch;
|
|
282
|
+
if (!batch || !batch.calls.every((call) => call.ended) || !batch.calls.some((call) => call.detached)) return;
|
|
283
|
+
batch.controller.abort();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private async sendBatch(calls: Pending[]): Promise<void> {
|
|
287
|
+
const controller = new AbortController();
|
|
288
|
+
const batch = { controller, calls };
|
|
289
|
+
const { headers: defaults, init } = this.host.init();
|
|
290
|
+
const secret: Record<string, string> = {};
|
|
291
|
+
const body = {
|
|
292
|
+
calls: calls.map((pending, index) => {
|
|
293
|
+
pending.state = "sent";
|
|
294
|
+
pending.batch = batch;
|
|
295
|
+
const own: Record<string, string> = {};
|
|
296
|
+
let metadata: Record<string, unknown> | undefined;
|
|
297
|
+
for (const [name, value] of Object.entries(pending.headers)) {
|
|
298
|
+
const lower = name.toLowerCase();
|
|
299
|
+
if (this.secret.has(lower)) secret[name] = value;
|
|
300
|
+
else if (lower === HTTPHeaders.METADATA.toLowerCase()) metadata = decodeMetadataHeader(value);
|
|
301
|
+
else own[name] = value;
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
id: String(index),
|
|
305
|
+
path: pathOf(pending.message),
|
|
306
|
+
input: pending.message.payload,
|
|
307
|
+
...(Object.keys(own).length > 0 ? { headers: own } : {}),
|
|
308
|
+
...(metadata ? { metadata } : {}),
|
|
309
|
+
};
|
|
310
|
+
}),
|
|
311
|
+
};
|
|
312
|
+
const byId = new Map(calls.map((pending, index) => [String(index), pending]));
|
|
313
|
+
// The timeout of the transport covers the time to the first frame of each call
|
|
314
|
+
const timeoutMs = this.host.timeoutMs;
|
|
315
|
+
if (timeoutMs) {
|
|
316
|
+
for (const pending of calls) {
|
|
317
|
+
pending.timer = setTimeout(() => {
|
|
318
|
+
pending.detached = true;
|
|
319
|
+
this.finish(pending, errorItem(pending.message, { code: "TIMEOUT", message: `Request timed out after ${timeoutMs}ms`, retryable: false }, { [DELIVERY_METADATA_KEY]: "indeterminate" }));
|
|
320
|
+
}, timeoutMs);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const live = (): Pending[] => calls.filter((pending) => !pending.ended);
|
|
324
|
+
try {
|
|
325
|
+
const response = await this.host.fetch(this.host.url, {
|
|
326
|
+
...init,
|
|
327
|
+
method: "POST",
|
|
328
|
+
headers: {
|
|
329
|
+
[HTTPHeaders.CONTENT_TYPE]: "application/json",
|
|
330
|
+
Accept: NDJSON,
|
|
331
|
+
[PROTOCOL_HEADER]: String(PROTOCOL_VERSION),
|
|
332
|
+
...defaults,
|
|
333
|
+
...secret,
|
|
334
|
+
[BATCH_HEADER]: "1",
|
|
335
|
+
},
|
|
336
|
+
body: JSON.stringify(body),
|
|
337
|
+
signal: controller.signal,
|
|
338
|
+
});
|
|
339
|
+
const metadata = this.host.metadataOf(response.headers);
|
|
340
|
+
const batchAnswer = response.headers.get(BATCH_HEADER) === "1";
|
|
341
|
+
if (!batchAnswer && NO_BATCH_STATUSES.has(response.status)) {
|
|
342
|
+
// A server without batches ran no call: each call goes alone, now and later
|
|
343
|
+
void response.body?.cancel().catch(() => undefined);
|
|
344
|
+
this.unsupported = true;
|
|
345
|
+
for (const pending of live()) {
|
|
346
|
+
if (pending.timer !== undefined) clearTimeout(pending.timer);
|
|
347
|
+
pending.timer = undefined;
|
|
348
|
+
void this.alone(pending);
|
|
349
|
+
}
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (!response.headers.get(HTTPHeaders.CONTENT_TYPE)?.includes(NDJSON) || !response.body) {
|
|
353
|
+
// An error of the whole batch, for example a refused origin or a stopped server
|
|
354
|
+
const answer = await this.host.bodyOf(response);
|
|
355
|
+
const status = this.host.statusOf(response.status, answer);
|
|
356
|
+
const fields = status.type === "error" ? status : { code: "INVALID_RESPONSE", message: `A batch got the status ${response.status} without a stream`, retryable: false };
|
|
357
|
+
for (const pending of live()) this.finish(pending, errorItem(pending.message, fields, { ...metadata, ...errorMetadataOf(answer) }));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
await this.read(response.body, byId, metadata);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
// The same error items as a request of one call (R1)
|
|
363
|
+
const details = error instanceof Error ? (error.name === "AbortError" ? createAbortError() : createErrorFromException(error)) : createErrorFromException(new Error("Unknown error"));
|
|
364
|
+
const delivery = isNotSentError(error) ? "not-sent" : "indeterminate";
|
|
365
|
+
for (const pending of live()) this.finish(pending, errorItem(pending.message, details, { [DELIVERY_METADATA_KEY]: delivery }));
|
|
366
|
+
} finally {
|
|
367
|
+
for (const pending of calls) if (pending.timer !== undefined) clearTimeout(pending.timer);
|
|
368
|
+
if (live().length > 0) controller.abort();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The reader of the stream of a batch: each frame goes to the channel of its call. */
|
|
373
|
+
private async read(body: ReadableStream<Uint8Array>, byId: ReadonlyMap<string, Pending>, metadata: Metadata): Promise<void> {
|
|
374
|
+
const reader = body.getReader();
|
|
375
|
+
const decoder = new TextDecoder();
|
|
376
|
+
const outer = new StreamSequence();
|
|
377
|
+
const orders = new Map<string, StreamSequence>();
|
|
378
|
+
const items = new Map<string, number>();
|
|
379
|
+
const unfinished = (): Pending[] => Array.from(byId.values()).filter((pending) => !pending.ended);
|
|
380
|
+
let buffer = "";
|
|
381
|
+
try {
|
|
382
|
+
for (;;) {
|
|
383
|
+
const { value, done } = await reader.read();
|
|
384
|
+
if (done) break;
|
|
385
|
+
buffer += decoder.decode(value, { stream: true });
|
|
386
|
+
let newline: number;
|
|
387
|
+
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
388
|
+
const line = buffer.slice(0, newline).trim();
|
|
389
|
+
buffer = buffer.slice(newline + 1);
|
|
390
|
+
// A keep-alive line of the server
|
|
391
|
+
if (!line) continue;
|
|
392
|
+
const entry = JSON.parse(line) as { type: string; seq?: unknown; payload?: unknown; error?: { code?: string; message?: string; retryable?: boolean } };
|
|
393
|
+
if (entry.type === "item" || entry.type === "done") {
|
|
394
|
+
if (outer.next(entry.seq, "batch") < 0) continue;
|
|
395
|
+
}
|
|
396
|
+
if (entry.type === "done" || entry.type === "error") {
|
|
397
|
+
// The end of the batch: a call without its last frame gets an error
|
|
398
|
+
const fields =
|
|
399
|
+
entry.type === "error"
|
|
400
|
+
? { code: foldedErrorCode(entry.error?.code ?? "HANDLER_ERROR").code, message: entry.error?.message ?? "Unknown error", retryable: entry.error?.retryable ?? false }
|
|
401
|
+
: { code: "STREAM_ENDED", message: "The batch ended without the last frame of the call", retryable: true };
|
|
402
|
+
for (const pending of unfinished()) {
|
|
403
|
+
const base = (items.get(pending.message.id) ?? 0) > 0 ? streamItemMetadata(metadata, 0) : { ...metadata };
|
|
404
|
+
this.finish(pending, errorItem(pending.message, fields, { ...base, [DELIVERY_METADATA_KEY]: "indeterminate" }));
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (entry.type !== "item") continue;
|
|
409
|
+
const frame = entry.payload as { call?: unknown; type?: unknown; seq?: unknown; payload?: unknown; error?: Record<string, unknown> } | null;
|
|
410
|
+
const pending = typeof frame?.call === "string" ? byId.get(frame.call) : undefined;
|
|
411
|
+
if (!frame || !pending || pending.ended) continue;
|
|
412
|
+
pending.started = true;
|
|
413
|
+
if (pending.timer !== undefined) clearTimeout(pending.timer);
|
|
414
|
+
pending.timer = undefined;
|
|
415
|
+
const id = pending.message.id;
|
|
416
|
+
if (frame.type === "response") {
|
|
417
|
+
this.finish(pending, { id, status: { type: "success", code: frame.payload === undefined ? 204 : 200 }, payload: frame.payload, metadata: { ...metadata } });
|
|
418
|
+
} else if (frame.type === "item" || frame.type === "done") {
|
|
419
|
+
let order = orders.get(frame.call as string);
|
|
420
|
+
if (!order) orders.set(frame.call as string, (order = new StreamSequence()));
|
|
421
|
+
const gap = order.next(frame.seq, id);
|
|
422
|
+
if (gap < 0) continue;
|
|
423
|
+
if (frame.type === "done") {
|
|
424
|
+
this.end(pending);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
items.set(id, (items.get(id) ?? 0) + 1);
|
|
428
|
+
await pending.channel.push({ id, status: { type: "success", code: 200 }, payload: frame.payload, metadata: streamItemMetadata(metadata, gap) });
|
|
429
|
+
} else if (frame.type === "error") {
|
|
430
|
+
const error = frame.error ?? {};
|
|
431
|
+
const base = (items.get(id) ?? 0) > 0 ? streamItemMetadata(metadata, 0) : { ...metadata };
|
|
432
|
+
const code = foldedErrorCode(typeof error["code"] === "string" ? error["code"] : "HANDLER_ERROR");
|
|
433
|
+
this.finish(
|
|
434
|
+
pending,
|
|
435
|
+
errorItem(
|
|
436
|
+
pending.message,
|
|
437
|
+
{ code: code.code, message: typeof error["message"] === "string" ? error["message"] : "Unknown error", retryable: error["retryable"] === true },
|
|
438
|
+
{ ...base, ...errorMetadataOf(error), ...(code.reason !== undefined && typeof error["reason"] !== "string" ? { reason: code.reason } : {}) },
|
|
439
|
+
),
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// The stream of the batch ended without its end line
|
|
445
|
+
for (const pending of unfinished()) {
|
|
446
|
+
this.finish(pending, errorItem(pending.message, { code: "STREAM_ENDED", message: "The stream ended without its end line", retryable: true }, { ...metadata, [DELIVERY_METADATA_KEY]: "indeterminate" }));
|
|
447
|
+
}
|
|
448
|
+
} finally {
|
|
449
|
+
reader.releaseLock();
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** The metadata of an error body: the wait, the reason and the conflicts (R12, R25, CLI-16). */
|
|
455
|
+
function errorMetadataOf(body: unknown): Metadata {
|
|
456
|
+
if (body === null || typeof body !== "object") return {};
|
|
457
|
+
const fields = body as { retryAfterMs?: unknown; reason?: unknown; conflicts?: unknown };
|
|
458
|
+
const out: Metadata = {};
|
|
459
|
+
if (typeof fields.retryAfterMs === "number" && Number.isFinite(fields.retryAfterMs) && fields.retryAfterMs >= 0) out[RETRY_AFTER_METADATA_KEY] = fields.retryAfterMs;
|
|
460
|
+
if (typeof fields.reason === "string") out["reason"] = fields.reason;
|
|
461
|
+
if (Array.isArray(fields.conflicts)) out["conflicts"] = fields.conflicts;
|
|
462
|
+
return out;
|
|
463
|
+
}
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
import type { HttpTransportOptions } from "./types.js";
|
|
15
15
|
import { DELIVERY_METADATA_KEY, RETRY_AFTER_METADATA_KEY, isNotSentError } from "../../../client/middleware/items.js";
|
|
16
16
|
import { StreamSequence, streamItemMetadata } from "../../sequence.js";
|
|
17
|
+
import { HttpBatcher } from "./batch.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* The wait that an error response asks for, in milliseconds: `retryAfterMs` of the body of the
|
|
@@ -102,15 +103,44 @@ export class HttpTransport implements Transport {
|
|
|
102
103
|
...(options.credentials !== undefined ? { credentials: options.credentials } : {}),
|
|
103
104
|
};
|
|
104
105
|
this.fetchFn = options.fetch;
|
|
106
|
+
const batch = options.batch === true ? {} : options.batch || undefined;
|
|
107
|
+
this.batcher = batch
|
|
108
|
+
? new HttpBatcher(
|
|
109
|
+
{
|
|
110
|
+
url: batch.url ?? this.baseUrl,
|
|
111
|
+
timeoutMs: this.timeout,
|
|
112
|
+
fetch: (url, init) => (this.fetchFn ?? fetch)(url, init),
|
|
113
|
+
init: () => ({ headers: this.defaultHeaders, init: this.requestInit }),
|
|
114
|
+
headersOf: (metadata) => this.headerConverter.metadataToHeaders(metadata),
|
|
115
|
+
metadataOf: (headers) => this.headerConverter.headersToMetadata(headers),
|
|
116
|
+
statusOf: (status, body) => this.httpStatusToUniversal(status as HTTPStatus, body),
|
|
117
|
+
bodyOf: (response) => this.parseResponseBody(response),
|
|
118
|
+
sendOne: <TRes>(message: Message) => this.sendOne<unknown, TRes>(message),
|
|
119
|
+
},
|
|
120
|
+
batch
|
|
121
|
+
)
|
|
122
|
+
: undefined;
|
|
105
123
|
}
|
|
106
124
|
|
|
125
|
+
/** The batches of the calls (CLI-32), or undefined without the option `batch` */
|
|
126
|
+
private readonly batcher: HttpBatcher | undefined;
|
|
127
|
+
|
|
107
128
|
/**
|
|
108
129
|
* Send HTTP request and yield its response: one item, or the items of an NDJSON stream.
|
|
109
130
|
*
|
|
110
131
|
* The `timeout` option covers the time to the first item. Before, it covered a whole NDJSON
|
|
111
132
|
* stream, so a long stream always failed (deep dive TRN-7).
|
|
133
|
+
*
|
|
134
|
+
* With the option `batch`, the `POST` calls that start in the same turn of the event loop go in
|
|
135
|
+
* one request (CLI-32). Each call still gets its own items.
|
|
112
136
|
*/
|
|
113
|
-
|
|
137
|
+
send<TReq, TRes>(message: Message<TReq>): AsyncIterable<ResponseItem<TRes>> {
|
|
138
|
+
if (this.batcher && this.httpMethodStrategy(message.method) === HTTPMethod.POST) return this.batcher.send<TRes>(message);
|
|
139
|
+
return this.sendOne<TReq, TRes>(message);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The request of one call. */
|
|
143
|
+
private async *sendOne<TReq, TRes>(
|
|
114
144
|
message: Message<TReq>
|
|
115
145
|
): AsyncIterable<ResponseItem<TRes>> {
|
|
116
146
|
// Convert Method → URL
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { UrlStrategy, HttpMethodStrategy, HeaderConverter } from "../shared/index.js";
|
|
9
|
+
import type { HttpBatchOptions } from "./batch.js";
|
|
9
10
|
import type { Duration } from "../../../duration.js";
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -53,4 +54,13 @@ export interface HttpTransportOptions {
|
|
|
53
54
|
* @default globalThis.fetch
|
|
54
55
|
*/
|
|
55
56
|
fetch?: typeof fetch;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The batches of the calls (roadmap CLI-32). With it, the `POST` calls that start in the same turn
|
|
60
|
+
* of the event loop go to the server in one request, and the answer is one stream of frames that
|
|
61
|
+
* name their call. Each call still gets its own items, and its middleware sees one call. A server
|
|
62
|
+
* without batches gets each call alone. `true` uses the defaults.
|
|
63
|
+
* @default false: one request for each call
|
|
64
|
+
*/
|
|
65
|
+
batch?: boolean | HttpBatchOptions;
|
|
56
66
|
}
|
|
@@ -95,7 +95,7 @@ export interface BodyPathAddressingOptions {
|
|
|
95
95
|
* The segments of a path in a body: a list of raw texts, or one text with dots. Each segment of
|
|
96
96
|
* the text decodes `%2E` to a dot and `%25` to `%` (roadmap CLI-75).
|
|
97
97
|
*/
|
|
98
|
-
function segmentsOf(value: unknown): string[] | undefined {
|
|
98
|
+
export function segmentsOf(value: unknown): string[] | undefined {
|
|
99
99
|
const segments = typeof value === "string" ? topicToSegments(value) : value;
|
|
100
100
|
if (!Array.isArray(segments) || segments.length === 0) return undefined;
|
|
101
101
|
return segments.every((segment): segment is string => typeof segment === "string" && segment !== "") ? segments : undefined;
|