@mark1russell7/service 0.4.0 → 0.5.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/build/http.js ADDED
@@ -0,0 +1,789 @@
1
+ import { realClock } from "./clock.js";
2
+ import { convertible, valueIn } from "@mark1russell7/quantity";
3
+ import { createServer } from "node:http";
4
+ //#region src/http/transport.ts
5
+ /** This function gives the path of a request target, without its query and fragment. */
6
+ const pathOf = (url) => {
7
+ const raw = url ?? "/";
8
+ const end = raw.search(/[?#]/);
9
+ const path = end === -1 ? raw : raw.slice(0, end);
10
+ if (path.startsWith("/")) return path;
11
+ try {
12
+ return new URL(raw).pathname;
13
+ } catch {
14
+ return path;
15
+ }
16
+ };
17
+ /** This function gives a knob in milliseconds, or infinity for a time without a bound. */
18
+ const millisecondsOf = (knobs, name, owner = "httpTransport") => {
19
+ const amount = knobs[name];
20
+ if (amount === void 0) throw new Error(`${owner} needs the knob ${name}: it has no defaults`);
21
+ if (!convertible(amount.unit, "s")) throw new Error(`the knob ${name} is not a time`);
22
+ if (amount.value === "infinite") return Number.POSITIVE_INFINITY;
23
+ const value = valueIn(amount, "ms");
24
+ if (!(value >= 0)) throw new Error(`the knob ${name} is less than zero`);
25
+ return value;
26
+ };
27
+ /**
28
+ * This function gives the `Retry-After` of the knob `server_retry_after`, in whole seconds, rounded
29
+ * up. The knob is necessary, and it has a bound.
30
+ */
31
+ const retryAfterOf = (knobs, owner) => {
32
+ const retryAfter = millisecondsOf(knobs, "server_retry_after", owner);
33
+ if (!Number.isFinite(retryAfter)) throw new Error("the knob server_retry_after has no bound: a Retry-After needs a time");
34
+ return String(Math.ceil(retryAfter / 1e3));
35
+ };
36
+ /** This function tells if the headers of a call of `writeHead` name a header, an object or a flat list of names and values. */
37
+ const names = (headers, name) => {
38
+ if (Array.isArray(headers)) return headers.some((each, at) => at % 2 === 0 && String(each).toLowerCase() === name);
39
+ return typeof headers === "object" && headers !== null && Object.keys(headers).some((key) => key.toLowerCase() === name);
40
+ };
41
+ /** This function gives the headers of a call of `writeHead` without one header. */
42
+ const without = (headers, name) => {
43
+ if (Array.isArray(headers)) {
44
+ const kept = [];
45
+ for (let at = 0; at + 1 < headers.length; at += 2) if (String(headers[at]).toLowerCase() !== name) kept.push(headers[at], headers[at + 1]);
46
+ return kept;
47
+ }
48
+ if (typeof headers !== "object" || headers === null) return headers;
49
+ return Object.fromEntries(Object.entries(headers).filter(([key]) => key.toLowerCase() !== name));
50
+ };
51
+ /**
52
+ * This function applies the rule of `Retry-After` to a call of `writeHead`, and gives its arguments.
53
+ * A 503 without `Retry-After` gets the time of `server_retry_after` (RFC 9110). A 500 loses each
54
+ * `Retry-After`, from the call and from the response.
55
+ */
56
+ const retryRule = (res, args, retryAfter) => {
57
+ const status = typeof args[0] === "number" ? args[0] : res.statusCode;
58
+ const at = typeof args[1] === "string" ? 2 : 1;
59
+ if (status === 503 && !res.hasHeader("retry-after") && !names(args[at], "retry-after")) res.setHeader("retry-after", retryAfter);
60
+ if (status !== 500) return args;
61
+ res.removeHeader("retry-after");
62
+ if (!names(args[at], "retry-after")) return args;
63
+ const given = [...args];
64
+ given[at] = without(args[at], "retry-after");
65
+ return given;
66
+ };
67
+ const STATUS_TEXT = {
68
+ 404: "Not Found",
69
+ 500: "Internal Server Error",
70
+ 503: "Service Unavailable"
71
+ };
72
+ /**
73
+ * This function makes a server of `node:http` a transport of the lifecycle (S6 A8 and A13). It is not
74
+ * a framework: it has no routes, no body parser and no middleware.
75
+ * - The count of work in flight comes from events: each response until its `close`, and each upgraded
76
+ * connection until its `close`.
77
+ * - `stopIntake` closes the listener, and new connections get a refusal. It closes the idle connections
78
+ * at once and at each `server_idle_sweep_interval`. Each later response gets `Connection: close`.
79
+ * - `drain` waits until no work is in flight, or until the deadline. It gives the cooperative abort to
80
+ * the handlers through their context.
81
+ * - `close` destroys each connection. A second close gives the same promise.
82
+ * - The mounts share one port. The first request handler that gives true answers. After the stop of
83
+ * the intake, only the mounts until `close transports` answer, and each other request gets a 503.
84
+ */
85
+ const httpTransport = (server, knobs, options = {}) => {
86
+ const clock = options.clock ?? realClock;
87
+ const idleClose = millisecondsOf(knobs, "server_idle_close");
88
+ const sweepEvery = millisecondsOf(knobs, "server_idle_sweep_interval");
89
+ if (sweepEvery === 0) throw new Error("the knob server_idle_sweep_interval is zero: a sweep needs an interval above zero");
90
+ const retryAfterHeader = retryAfterOf(knobs, "httpTransport");
91
+ for (const derived of ["server_idle_close_hint", "server_idle_close_buffer"]) if (knobs[derived] !== void 0) throw new Error(`the knob ${derived} comes from the transport: give only server_idle_close`);
92
+ if (server.listenerCount("request") > 0 || server.listenerCount("upgrade") > 0) throw new Error("the server has a request or upgrade listener of its own: mount it as a handler");
93
+ const buffer = server.keepAliveTimeoutBuffer ?? 0;
94
+ if (Number.isFinite(idleClose) && idleClose <= buffer) throw new Error(`the knob server_idle_close is ${idleClose} ms, at or below the keepAliveTimeoutBuffer of the server, ${buffer} ms: the server closes an idle connection at keepAliveTimeout + keepAliveTimeoutBuffer`);
95
+ server.keepAliveTimeout = Number.isFinite(idleClose) ? idleClose - buffer : 0;
96
+ const declared = {
97
+ ...knobs,
98
+ server_idle_close_hint: Number.isFinite(idleClose) ? {
99
+ value: Math.floor(server.keepAliveTimeout / 1e3),
100
+ unit: "s"
101
+ } : {
102
+ value: "infinite",
103
+ unit: "s"
104
+ },
105
+ server_idle_close_buffer: {
106
+ value: buffer,
107
+ unit: "ms"
108
+ }
109
+ };
110
+ const requestMounts = [];
111
+ const upgradeMounts = [];
112
+ const responses = /* @__PURE__ */ new Set();
113
+ const upgraded = /* @__PURE__ */ new Set();
114
+ const sockets = /* @__PURE__ */ new Set();
115
+ const idleWaiters = /* @__PURE__ */ new Set();
116
+ const closedWaiters = /* @__PURE__ */ new Set();
117
+ const intake = new AbortController();
118
+ const cooperative = new AbortController();
119
+ let life = null;
120
+ const context = {
121
+ intake: intake.signal,
122
+ signal: cooperative.signal,
123
+ get life() {
124
+ return life;
125
+ },
126
+ retryAfter: knobs.server_retry_after
127
+ };
128
+ let intakeOpen = true;
129
+ let listenerOpen = server.listening;
130
+ let serverClosed = !server.listening;
131
+ let sweep = null;
132
+ let closing = null;
133
+ const inFlight = () => responses.size + upgraded.size;
134
+ const report = (error, req) => {
135
+ try {
136
+ options.onError?.(error, req);
137
+ } catch {}
138
+ };
139
+ const settled = () => {
140
+ if (inFlight() === 0) for (const done of [...idleWaiters]) done();
141
+ if (closing !== null && inFlight() === 0 && sockets.size === 0 && serverClosed) for (const done of [...closedWaiters]) done();
142
+ };
143
+ const keepsAlive = () => intakeOpen && (options.keepAlive?.() ?? life?.keepAlive() ?? true);
144
+ const live = (mounts) => intakeOpen ? [...mounts] : mounts.filter((mount) => mount.until === "close transports");
145
+ const throughDrain = () => requestMounts.some((mount) => mount.until === "close transports") || upgradeMounts.some((mount) => mount.until === "close transports");
146
+ const closeListener = () => {
147
+ if (!listenerOpen) return;
148
+ listenerOpen = false;
149
+ server.close();
150
+ };
151
+ const startSweep = () => {
152
+ if (!Number.isFinite(sweepEvery) || closing !== null) return;
153
+ sweep = clock.after(sweepEvery, () => {
154
+ sweep = null;
155
+ if (closing !== null) return;
156
+ server.closeIdleConnections();
157
+ startSweep();
158
+ });
159
+ };
160
+ /**
161
+ * This function watches the headers of each response when they go, whatever wrote them. Each 503
162
+ * gets `Retry-After`, and a 500 gets none. The response closes its connection while the transport
163
+ * cannot keep it.
164
+ */
165
+ const watchHeaders = (res) => {
166
+ const writeHead = res.writeHead;
167
+ res.writeHead = ((...args) => {
168
+ if (res.headersSent) return writeHead.apply(res, args);
169
+ if (!keepsAlive()) {
170
+ res.shouldKeepAlive = false;
171
+ res.setHeader("connection", "close");
172
+ }
173
+ return writeHead.apply(res, retryRule(res, args, retryAfterHeader));
174
+ });
175
+ };
176
+ const answer = (res, status, headers = {}) => {
177
+ if (res.headersSent) {
178
+ res.destroy();
179
+ return;
180
+ }
181
+ const body = STATUS_TEXT[status] ?? "";
182
+ res.writeHead(status, {
183
+ "content-type": "text/plain; charset=utf-8",
184
+ "content-length": String(Buffer.byteLength(body)),
185
+ ...headers
186
+ });
187
+ res.end(body);
188
+ };
189
+ const refuse = (res) => answer(res, 503, {
190
+ "retry-after": retryAfterHeader,
191
+ connection: "close"
192
+ });
193
+ const route = async (req, res) => {
194
+ const mounts = live(requestMounts);
195
+ try {
196
+ for (const mount of mounts) {
197
+ const given = mount.handler(req, res, context);
198
+ if ((typeof given === "boolean" ? given : await given) || res.headersSent || res.writableEnded) return;
199
+ }
200
+ } catch (error) {
201
+ report(error, req);
202
+ if (!res.writableEnded) answer(res, 500);
203
+ return;
204
+ }
205
+ if (intakeOpen) answer(res, 404);
206
+ else refuse(res);
207
+ };
208
+ const onRequest = (req, res) => {
209
+ responses.add(res);
210
+ res.once("close", () => {
211
+ responses.delete(res);
212
+ settled();
213
+ });
214
+ watchHeaders(res);
215
+ route(req, res);
216
+ };
217
+ const onUpgrade = (req, socket, head) => {
218
+ socket.on("error", () => {});
219
+ const mount = live(upgradeMounts).find((each) => each.matches(req));
220
+ if (mount === void 0) {
221
+ const status = intakeOpen ? 404 : 503;
222
+ const extra = intakeOpen ? "" : `Retry-After: ${retryAfterHeader}\r\n`;
223
+ socket.end(`HTTP/1.1 ${status} ${STATUS_TEXT[status]}\r\nConnection: close\r\nContent-Length: 0\r\n${extra}\r\n`);
224
+ return;
225
+ }
226
+ if (socket.destroyed) return;
227
+ upgraded.add(socket);
228
+ socket.once("close", () => {
229
+ upgraded.delete(socket);
230
+ settled();
231
+ });
232
+ try {
233
+ mount.handler(req, socket, head, context);
234
+ } catch (error) {
235
+ report(error, req);
236
+ socket.destroy();
237
+ }
238
+ };
239
+ const onConnection = (socket) => {
240
+ sockets.add(socket);
241
+ socket.once("close", () => {
242
+ sockets.delete(socket);
243
+ settled();
244
+ });
245
+ };
246
+ server.on("request", onRequest);
247
+ server.on("upgrade", onUpgrade);
248
+ server.on("connection", onConnection);
249
+ server.on("listening", () => {
250
+ listenerOpen = true;
251
+ serverClosed = false;
252
+ });
253
+ server.on("close", () => {
254
+ listenerOpen = false;
255
+ serverClosed = true;
256
+ settled();
257
+ });
258
+ const remover = (list, entry) => () => {
259
+ const at = list.indexOf(entry);
260
+ if (at >= 0) list.splice(at, 1);
261
+ if (!intakeOpen && closing === null && !throughDrain()) closeListener();
262
+ };
263
+ return {
264
+ server,
265
+ knobs: declared,
266
+ get connections() {
267
+ return sockets.size;
268
+ },
269
+ get intakeStopped() {
270
+ return !intakeOpen;
271
+ },
272
+ mount(handler, mountOptions = {}) {
273
+ const entry = {
274
+ handler,
275
+ until: mountOptions.until ?? handler.until ?? "stop intake"
276
+ };
277
+ requestMounts.push(entry);
278
+ return remover(requestMounts, entry);
279
+ },
280
+ upgrade(match, handler, mountOptions = {}) {
281
+ const entry = {
282
+ matches: typeof match === "string" ? (req) => pathOf(req.url) === match : match,
283
+ handler,
284
+ until: mountOptions.until ?? handler.until ?? "stop intake"
285
+ };
286
+ upgradeMounts.push(entry);
287
+ return remover(upgradeMounts, entry);
288
+ },
289
+ async start(_signal, given) {
290
+ life = given;
291
+ if (closing !== null) throw new Error("the transport closed: a closed transport does not start again");
292
+ if (server.listenerCount("request") > 1 || server.listenerCount("upgrade") > 1) throw new Error("the server has a request or upgrade listener of its own: mount it as a handler");
293
+ if (server.listening) return;
294
+ const listen = options.listen;
295
+ if (listen === void 0) throw new Error("the server does not listen, and the transport has no listen options");
296
+ if (typeof listen === "function") {
297
+ await listen(server);
298
+ return;
299
+ }
300
+ await new Promise((resolve, reject) => {
301
+ const failed = (error) => {
302
+ server.off("listening", listened);
303
+ reject(error);
304
+ };
305
+ const listened = () => {
306
+ server.off("error", failed);
307
+ resolve();
308
+ };
309
+ server.once("error", failed);
310
+ server.once("listening", listened);
311
+ server.listen(listen);
312
+ });
313
+ },
314
+ stopIntake() {
315
+ if (!intakeOpen) return;
316
+ intakeOpen = false;
317
+ intake.abort(/* @__PURE__ */ new Error("the transport stopped its intake"));
318
+ if (!throughDrain()) closeListener();
319
+ server.closeIdleConnections();
320
+ startSweep();
321
+ },
322
+ drain(deadline, signal) {
323
+ const abort = () => {
324
+ if (!cooperative.signal.aborted) cooperative.abort(signal.reason);
325
+ };
326
+ if (signal.aborted) abort();
327
+ else signal.addEventListener("abort", abort, { once: true });
328
+ if (inFlight() === 0 || deadline <= clock.now()) {
329
+ signal.removeEventListener("abort", abort);
330
+ return Promise.resolve();
331
+ }
332
+ return new Promise((resolve) => {
333
+ let cancel = () => {};
334
+ const done = () => {
335
+ idleWaiters.delete(done);
336
+ cancel();
337
+ signal.removeEventListener("abort", abort);
338
+ resolve();
339
+ };
340
+ idleWaiters.add(done);
341
+ cancel = clock.after(Math.max(0, deadline - clock.now()), done);
342
+ });
343
+ },
344
+ inFlight,
345
+ close() {
346
+ if (closing !== null) return closing;
347
+ intakeOpen = false;
348
+ if (!intake.signal.aborted) intake.abort(/* @__PURE__ */ new Error("the transport closed"));
349
+ if (!cooperative.signal.aborted) cooperative.abort(/* @__PURE__ */ new Error("the transport closed"));
350
+ sweep?.();
351
+ sweep = null;
352
+ closing = new Promise((resolve) => {
353
+ closedWaiters.add(resolve);
354
+ });
355
+ closeListener();
356
+ server.closeAllConnections();
357
+ for (const socket of [...upgraded]) socket.destroy();
358
+ for (const socket of [...sockets]) socket.destroy();
359
+ settled();
360
+ return closing;
361
+ }
362
+ };
363
+ };
364
+ //#endregion
365
+ //#region src/http/probes.ts
366
+ const KINDS = [
367
+ "startup",
368
+ "readiness",
369
+ "liveness",
370
+ "report"
371
+ ];
372
+ const prefixProblem = (prefix) => prefix === "" || prefix.startsWith("/") && !prefix.endsWith("/") ? null : `the probe prefix "${prefix}" does not start with "/", or it ends with "/"`;
373
+ const json = (status, body) => ({
374
+ status,
375
+ headers: {
376
+ "content-type": "application/health+json",
377
+ "cache-control": "no-store"
378
+ },
379
+ body: JSON.stringify(body)
380
+ });
381
+ /**
382
+ * This function gives the response of a probe path from the cache of the health runtime. For a path
383
+ * that is not a probe, it gives null. The paths are `/startup`, `/readiness`, `/liveness` and
384
+ * `/report` after the prefix. `/readiness/<controller>` gives the readiness of one controller.
385
+ */
386
+ const probeResponse = (health, method, path, prefix = "") => {
387
+ if (!path.startsWith(`${prefix}/`)) return null;
388
+ const parts = path.slice(prefix.length + 1).split("/");
389
+ const kind = KINDS.find((each) => each === parts[0]);
390
+ if (kind === void 0) return null;
391
+ const controller = parts[1];
392
+ if (parts.length > 2 || controller !== void 0 && (kind !== "readiness" || controller === "")) return null;
393
+ if (method !== "GET" && method !== "HEAD") return {
394
+ status: 405,
395
+ headers: {
396
+ allow: "GET, HEAD",
397
+ "content-length": "0"
398
+ },
399
+ body: ""
400
+ };
401
+ if (health === null || health === void 0) return json(503, {
402
+ status: "fail",
403
+ probe: kind
404
+ });
405
+ try {
406
+ const answer = health.probe(kind, controller === void 0 ? void 0 : decodeURIComponent(controller));
407
+ return {
408
+ ...json(answer.status, answer.body),
409
+ headers: {
410
+ "content-type": answer.contentType,
411
+ "cache-control": "no-store"
412
+ }
413
+ };
414
+ } catch {
415
+ return json(404, {
416
+ status: "fail",
417
+ probe: kind,
418
+ controller
419
+ });
420
+ }
421
+ };
422
+ /** This function writes a probe response. A 503 gets `Retry-After`, when the writer has the time of a retry. */
423
+ const write = (req, res, response, retryAfter) => {
424
+ const retry = response.status === 503 && retryAfter !== void 0 ? { "retry-after": retryAfter } : {};
425
+ res.writeHead(response.status, {
426
+ ...response.headers,
427
+ ...retry,
428
+ "content-length": String(Buffer.byteLength(response.body))
429
+ });
430
+ res.end(req.method === "HEAD" ? void 0 : response.body);
431
+ };
432
+ /**
433
+ * This function gives a request handler that answers the probes on the port of the application. It
434
+ * answers until `close transports`, thus its mount keeps the listener open through the drain. A path
435
+ * that is not a probe goes to the next handler. Each 503 gets the `Retry-After` of the context.
436
+ */
437
+ const probeHandler = (health, options) => {
438
+ const problem = prefixProblem(options.prefix);
439
+ if (problem !== null) throw new Error(problem);
440
+ const handler = (req, res, context) => {
441
+ const runtime = typeof health === "function" ? health() : health;
442
+ const response = probeResponse(runtime, req.method, pathOf(req.url), options.prefix);
443
+ if (response === null) return false;
444
+ const retryAfter = context?.retryAfter === void 0 ? void 0 : retryAfterOf({ server_retry_after: context.retryAfter }, "probeHandler");
445
+ write(req, res, response, retryAfter);
446
+ return true;
447
+ };
448
+ return Object.assign(handler, { until: "close transports" });
449
+ };
450
+ /**
451
+ * This function makes a health server on a port of its own (S6 decision D5). It starts first and
452
+ * closes last, thus the probes answer through the whole drain. Each probe answers from the cache, and
453
+ * each 503 gets the `Retry-After` of `server_retry_after`.
454
+ */
455
+ const probeServer = (options) => {
456
+ const { prefix = "", server = createServer(), server_retry_after, ...listen } = options;
457
+ const retryAfter = retryAfterOf({ server_retry_after }, "probeServer");
458
+ const problem = prefixProblem(prefix);
459
+ if (problem !== null) throw new Error(problem);
460
+ if (server.listenerCount("request") > 0) throw new Error("the probe server has a request listener of its own");
461
+ let health = null;
462
+ let closing = null;
463
+ const sockets = /* @__PURE__ */ new Set();
464
+ server.on("connection", (socket) => {
465
+ sockets.add(socket);
466
+ socket.once("close", () => sockets.delete(socket));
467
+ });
468
+ server.on("request", (req, res) => {
469
+ const response = probeResponse(health, req.method, pathOf(req.url), prefix);
470
+ write(req, res, response ?? {
471
+ status: 404,
472
+ headers: { "content-type": "text/plain; charset=utf-8" },
473
+ body: "Not Found"
474
+ }, retryAfter);
475
+ });
476
+ return {
477
+ server,
478
+ listen(runtime) {
479
+ health = runtime;
480
+ if (server.listening) return Promise.resolve();
481
+ return new Promise((resolve, reject) => {
482
+ const failed = (error) => {
483
+ server.off("listening", listened);
484
+ reject(error);
485
+ };
486
+ const listened = () => {
487
+ server.off("error", failed);
488
+ resolve();
489
+ };
490
+ server.once("error", failed);
491
+ server.once("listening", listened);
492
+ server.listen(listen);
493
+ });
494
+ },
495
+ close() {
496
+ if (closing !== null) return closing;
497
+ closing = new Promise((resolve) => {
498
+ if (!server.listening) {
499
+ resolve();
500
+ return;
501
+ }
502
+ server.close(() => resolve());
503
+ server.closeAllConnections();
504
+ for (const socket of [...sockets]) socket.destroy();
505
+ });
506
+ return closing;
507
+ }
508
+ };
509
+ };
510
+ //#endregion
511
+ //#region src/http/outcome.ts
512
+ const JSON_TYPE = "application/json; charset=utf-8";
513
+ /**
514
+ * This function gives an end as a record for a body: its kind and the count of its items. A cut and
515
+ * a refusal also give their cause, and if the caller can send the request again. A failure gives
516
+ * no detail.
517
+ */
518
+ const endRecord = (end) => {
519
+ if (end.kind === "refused" || end.kind === "cut") return {
520
+ end: end.kind,
521
+ delivered: end.delivered,
522
+ retryable: end.retryable,
523
+ cause: end.cause
524
+ };
525
+ return {
526
+ end: end.kind,
527
+ delivered: end.delivered
528
+ };
529
+ };
530
+ /** This format writes each item and the end as one line of JSON (NDJSON). */
531
+ const ndjson = {
532
+ contentType: "application/x-ndjson; charset=utf-8",
533
+ item: (value) => `${JSON.stringify(value)}\n`,
534
+ end: (end) => `${JSON.stringify(endRecord(end))}\n`
535
+ };
536
+ /** This format writes each item as an event of server-sent events, and the end as the event `end`. */
537
+ const sse = {
538
+ contentType: "text/event-stream; charset=utf-8",
539
+ item: (value) => `data: ${JSON.stringify(value)}\n\n`,
540
+ end: (end) => `event: end\ndata: ${JSON.stringify(endRecord(end))}\n\n`
541
+ };
542
+ const valueResponse = (value) => {
543
+ if (value === void 0) return {
544
+ status: 204,
545
+ headers: {},
546
+ body: ""
547
+ };
548
+ if (typeof value === "string") return {
549
+ status: 200,
550
+ headers: { "content-type": "text/plain; charset=utf-8" },
551
+ body: value
552
+ };
553
+ if (value instanceof Uint8Array) return {
554
+ status: 200,
555
+ headers: { "content-type": "application/octet-stream" },
556
+ body: value
557
+ };
558
+ return {
559
+ status: 200,
560
+ headers: { "content-type": JSON_TYPE },
561
+ body: JSON.stringify(value)
562
+ };
563
+ };
564
+ const merge = (base, part) => part === void 0 ? base : {
565
+ status: part.status ?? base.status,
566
+ headers: {
567
+ ...base.headers,
568
+ ...part.headers
569
+ },
570
+ body: part.body ?? base.body
571
+ };
572
+ const keeps = (keepAlive) => typeof keepAlive === "function" ? keepAlive() : keepAlive;
573
+ /**
574
+ * This function applies the rules of the headers to a response. Each 503 gets `Retry-After`, and a
575
+ * 500 gets none. The response closes its connection while the service cannot keep it.
576
+ */
577
+ const ruled = (response, retryAfter, keepAlive) => {
578
+ const headers = { ...response.headers };
579
+ if (response.status === 503 && headers["retry-after"] === void 0) headers["retry-after"] = String(Math.max(0, Math.ceil(valueIn(retryAfter, "s"))));
580
+ if (response.status === 500) delete headers["retry-after"];
581
+ if (!keeps(keepAlive)) headers["connection"] = "close";
582
+ return {
583
+ ...response,
584
+ headers
585
+ };
586
+ };
587
+ /** This function gives the status response of an end before the first byte of the body. */
588
+ const endStatus = (end, options) => {
589
+ const body = JSON.stringify(endRecord(end));
590
+ switch (end.kind) {
591
+ case "refused": return {
592
+ status: 503,
593
+ headers: { "content-type": JSON_TYPE },
594
+ body
595
+ };
596
+ case "cut": return {
597
+ status: end.status ?? (end.retryable ? 503 : 500),
598
+ headers: { "content-type": JSON_TYPE },
599
+ body
600
+ };
601
+ case "failed": return merge({
602
+ status: 500,
603
+ headers: { "content-type": JSON_TYPE },
604
+ body
605
+ }, options.failed?.(end.error));
606
+ case "done": return {
607
+ status: 204,
608
+ headers: {},
609
+ body: ""
610
+ };
611
+ }
612
+ };
613
+ /**
614
+ * This function maps a unary request to its response, from its items and its end (S6 A12 and A13).
615
+ * - One value and the end `done`: 200 with the value, or the response of the `done` hook.
616
+ * - No value and the end `done`: 204. More than one value: 500, because the route is not a stream.
617
+ * - `refused`: 503. The work did not start, thus the caller can send it again. The service did not
618
+ * start, it stopped its intake, or a controller is not ready.
619
+ * - `cut`: 503 for a route whose callers can send it again (S6 A12), and 500 for each other route.
620
+ * - `failed`: 500 with no detail, or the response of the `failed` hook. A failure of the route is not a 503.
621
+ *
622
+ * Each 503 gets `Retry-After` (RFC 9110), and a 500 gets none. Each response gets `Connection: close`
623
+ * while the service cannot keep the connection.
624
+ */
625
+ const outcomeResponse = (items, end, options) => {
626
+ let response;
627
+ if (end.kind !== "done") response = endStatus(end, options);
628
+ else if (items.length === 1) response = merge(valueResponse(items[0]), options.done?.(items[0]));
629
+ else if (items.length === 0) response = endStatus(end, options);
630
+ else response = endStatus({
631
+ kind: "failed",
632
+ error: /* @__PURE__ */ new Error(`the work gave ${items.length} values, and a unary response has one`),
633
+ delivered: items.length
634
+ }, options);
635
+ return ruled(response, options.retryAfter, options.keepAlive);
636
+ };
637
+ /** This function writes a whole response of `node:http`. A response whose headers went gets no second status: it is destroyed. */
638
+ const writeWhole = (res, response) => {
639
+ if (res.headersSent) {
640
+ res.destroy();
641
+ return;
642
+ }
643
+ if (response.headers["connection"] === "close") res.shouldKeepAlive = false;
644
+ if (response.status === 204) {
645
+ res.writeHead(204, response.headers);
646
+ res.end();
647
+ return;
648
+ }
649
+ const length = typeof response.body === "string" ? Buffer.byteLength(response.body) : response.body.byteLength;
650
+ res.writeHead(response.status, {
651
+ ...response.headers,
652
+ "content-length": String(length)
653
+ });
654
+ res.end(response.body);
655
+ };
656
+ /**
657
+ * This function writes the stream of a request to a response of `node:http`, and gives its end. It
658
+ * gives null when the client closed the connection first. Then the work stops through `return()`.
659
+ * - Unary (no format): it waits for the end, and it writes the status and the body of `outcomeResponse`.
660
+ * - Stream: the status 200 goes with the first item, then each item goes as it comes, then the end
661
+ * goes in the body (in-band). The next item waits until the client reads, thus a slow client
662
+ * slows the work. An end before the first item gets a status, as a unary end does.
663
+ */
664
+ const writeOutcome = async (res, stream, options) => {
665
+ const iterator = stream[Symbol.asyncIterator]();
666
+ let gone = () => {};
667
+ const closed = new Promise((resolve) => {
668
+ gone = () => resolve("gone");
669
+ });
670
+ const onClose = () => {
671
+ if (!res.writableFinished) gone();
672
+ };
673
+ res.once("close", onClose);
674
+ if (res.destroyed) gone();
675
+ const leave = async () => {
676
+ await iterator.return?.();
677
+ return null;
678
+ };
679
+ const next = async () => {
680
+ const step = await Promise.race([iterator.next(), closed]);
681
+ if (step === "gone") return step;
682
+ if (step.done === true) throw new Error("the stream of the request ended without its end");
683
+ return step.value;
684
+ };
685
+ /** This function writes one part of the body, and waits while the client reads slower than the work. */
686
+ const send = async (chunk) => {
687
+ if (res.write(chunk)) return true;
688
+ const drained = new Promise((resolve) => res.once("drain", () => resolve("drained")));
689
+ return await Promise.race([drained, closed]) === "drained";
690
+ };
691
+ try {
692
+ const format = options.stream;
693
+ if (format === void 0) {
694
+ const items = [];
695
+ for (;;) {
696
+ const event = await next();
697
+ if (event === "gone") return await leave();
698
+ if (event.kind === "item") {
699
+ items.push(event.value);
700
+ continue;
701
+ }
702
+ writeWhole(res, outcomeResponse(items, event, options));
703
+ return event;
704
+ }
705
+ }
706
+ const first = await next();
707
+ if (first === "gone") return await leave();
708
+ if (first.kind !== "item") {
709
+ if (first.kind === "done") writeWhole(res, ruled({
710
+ status: 200,
711
+ headers: {
712
+ "content-type": format.contentType,
713
+ "cache-control": "no-store"
714
+ },
715
+ body: format.end(first)
716
+ }, options.retryAfter, options.keepAlive));
717
+ else writeWhole(res, outcomeResponse([], first, options));
718
+ return first;
719
+ }
720
+ if (res.headersSent) {
721
+ res.destroy();
722
+ return await leave();
723
+ }
724
+ const head = ruled({
725
+ status: 200,
726
+ headers: {
727
+ "content-type": format.contentType,
728
+ "cache-control": "no-store"
729
+ },
730
+ body: ""
731
+ }, options.retryAfter, options.keepAlive);
732
+ if (head.headers["connection"] === "close") res.shouldKeepAlive = false;
733
+ res.writeHead(200, head.headers);
734
+ if (!await send(format.item(first.value))) return await leave();
735
+ for (;;) {
736
+ const event = await next();
737
+ if (event === "gone") return await leave();
738
+ if (event.kind === "item") {
739
+ if (!await send(format.item(event.value))) return await leave();
740
+ continue;
741
+ }
742
+ res.end(format.end(event));
743
+ return event;
744
+ }
745
+ } finally {
746
+ res.off("close", onClose);
747
+ }
748
+ };
749
+ /**
750
+ * This function gives a request handler that does each request of a route through `life.request`
751
+ * (S6 A12 and A13). The transport gives the lifecycle and `server_retry_after` through the context.
752
+ * - A route name takes a request whose method and path are that name, for example `POST /orders`.
753
+ * A function gives the name of a route or of a pattern, or null for a request of another handler.
754
+ * - Before the start of the transport, each request gets a retryable 503.
755
+ * - The response is unary, or a stream with the option `stream`, as `writeOutcome` writes it.
756
+ */
757
+ const handle = (controller, route, work, options = {}) => {
758
+ const nameOf = (req) => {
759
+ if (typeof route === "function") return route(req);
760
+ return `${req.method ?? "GET"} ${pathOf(req.url)}` === route ? route : null;
761
+ };
762
+ return async (req, res, context) => {
763
+ const name = nameOf(req);
764
+ if (name === null) return false;
765
+ const life = context.life;
766
+ const written = {
767
+ ...options,
768
+ keepAlive: () => life?.keepAlive() ?? false,
769
+ retryAfter: context.retryAfter
770
+ };
771
+ if (life === null) {
772
+ writeWhole(res, outcomeResponse([], {
773
+ kind: "refused",
774
+ status: 503,
775
+ retryable: true,
776
+ cause: "the transport has not started",
777
+ delivered: 0
778
+ }, written));
779
+ return true;
780
+ }
781
+ const given = options.detail === void 0 ? {} : { detail: options.detail(req) };
782
+ await writeOutcome(res, life.request(controller, name, (signal) => work(req, signal), given), written);
783
+ return true;
784
+ };
785
+ };
786
+ //#endregion
787
+ export { endRecord, handle, httpTransport, ndjson, outcomeResponse, pathOf, probeHandler, probeResponse, probeServer, sse, writeOutcome };
788
+
789
+ //# sourceMappingURL=http.js.map