@procedures/peer 0.13.2 → 0.14.1
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 +53 -0
- package/README.md +1 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/host/faces.d.ts +12 -2
- package/dist/host/faces.d.ts.map +1 -1
- package/dist/host/faces.js +19 -3
- package/dist/host/faces.js.map +1 -1
- package/dist/host/index.d.ts +4 -3
- package/dist/host/index.d.ts.map +1 -1
- package/dist/host/index.js +81 -8
- package/dist/host/index.js.map +1 -1
- package/dist/host/tracking.d.ts +30 -1
- package/dist/host/tracking.d.ts.map +1 -1
- package/dist/host/tracking.js +67 -5
- package/dist/host/tracking.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +6 -3
- package/src/host/faces.ts +27 -4
- package/src/host/index.ts +96 -9
- package/src/host/tracking.ts +99 -6
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package",
|
|
3
3
|
"name": "@procedures/peer",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.14.1",
|
|
5
5
|
"description": "Serve procedures: createProcedureHost() gives the parts of a service, with HTTP, WebSocket and MCP faces on one listener. Also peers, server.create, server.call and server.connections, and manifests.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"server",
|
|
@@ -67,7 +67,10 @@
|
|
|
67
67
|
"provenance": true
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
|
-
"@procedures/
|
|
70
|
+
"@procedures/better-auth": "0.14.1",
|
|
71
|
+
"@procedures/fs": "0.14.1",
|
|
72
|
+
"@procedures/mcp-server": "0.14.1",
|
|
73
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
71
74
|
"typescript": "^7.0.2",
|
|
72
75
|
"@types/node": "^22.0.0",
|
|
73
76
|
"@mark1russell7/cue": "^1.0.0",
|
|
@@ -78,7 +81,7 @@
|
|
|
78
81
|
},
|
|
79
82
|
"dependencies": {
|
|
80
83
|
"@mark1russell7/service": "^0.5.2",
|
|
81
|
-
"@procedures/client": "0.
|
|
84
|
+
"@procedures/client": "0.14.1",
|
|
82
85
|
"zod": "^4.6.5"
|
|
83
86
|
},
|
|
84
87
|
"scripts": {
|
package/src/host/faces.ts
CHANGED
|
@@ -12,15 +12,17 @@
|
|
|
12
12
|
*
|
|
13
13
|
* `httpFace()` and `wsFace()` are the faces of this package, on `procedureHandler` and
|
|
14
14
|
* `upgradeHandler` of `@procedures/client/node`. `mcpFace()` of `@procedures/mcp-server` is the
|
|
15
|
-
* MCP face
|
|
15
|
+
* MCP face, and `betterAuthFace()` of `@procedures/better-auth` is the face of the auth routes. A
|
|
16
|
+
* face of another package has the same shape, so the host needs no change for it. A face of
|
|
17
|
+
* requests that are not procedure calls tracks them with `track` (CLI-54).
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
import type { IncomingMessage, ServerResponse as NodeResponse } from "node:http";
|
|
19
21
|
import type { Duplex } from "node:stream";
|
|
20
22
|
import type { TransportImpl } from "@mark1russell7/service";
|
|
21
|
-
import { Client, requestCredentials, type BrowserGuardOptions, type Clock, type HostPolicy, type ProcedureRegistry, type ProcedureServer } from "@procedures/client";
|
|
23
|
+
import { Client, realClock, requestCredentials, type BrowserGuardOptions, type Clock, type HostPolicy, type ProcedureRegistry, type ProcedureServer } from "@procedures/client";
|
|
22
24
|
import { procedureHandler, upgradeHandler, type ProcedureHandlerOptions, type UpgradeHandlerOptions } from "@procedures/client/node";
|
|
23
|
-
import type { FaceHandler } from "./tracking.js";
|
|
25
|
+
import type { FaceHandler, HttpTracker } from "./tracking.js";
|
|
24
26
|
|
|
25
27
|
/** What the host gives a face. */
|
|
26
28
|
export interface HostFaceContext {
|
|
@@ -33,6 +35,12 @@ export interface HostFaceContext {
|
|
|
33
35
|
readonly policy: HostPolicy;
|
|
34
36
|
/** The clock of the host: the clock of the lifecycle. */
|
|
35
37
|
readonly clock: Clock;
|
|
38
|
+
/**
|
|
39
|
+
* The request tracking of the HTTP requests of the face that are not procedure calls, for example
|
|
40
|
+
* the auth routes (CLI-54). Each request goes through `life.request` on the route `<face> <key>`
|
|
41
|
+
* of the controller of the host. The host always gives it.
|
|
42
|
+
*/
|
|
43
|
+
readonly track?: HttpTracker | undefined;
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
/** The upgrade handler of a face, and the path or the function that tells which upgrades it takes. */
|
|
@@ -66,6 +74,9 @@ export interface HostFace {
|
|
|
66
74
|
/** A face factory: the host calls it one time, with the context of the face. */
|
|
67
75
|
export type HostFaceFactory = (context: HostFaceContext) => HostFace;
|
|
68
76
|
|
|
77
|
+
/** The longest delay of a Node timer. A wait with this delay ends at its event, or at the close of its face. */
|
|
78
|
+
const UNTIL_CLOSE_MS = 2_147_483_647;
|
|
79
|
+
|
|
69
80
|
/** The browser checks of the policy, as options of a face. The options of the face win over them. */
|
|
70
81
|
function browserOptions(browser: BrowserGuardOptions, http: boolean): BrowserGuardOptions {
|
|
71
82
|
return {
|
|
@@ -98,6 +109,8 @@ export function httpFace(options: ProcedureHandlerOptions = {}): HostFaceFactory
|
|
|
98
109
|
* upgrade (`ProcedureServer.admits`). Thus no idle connection without credentials stays open.
|
|
99
110
|
* - The stop parts refuse new calls at the stop of the intake. In the drain, each connection
|
|
100
111
|
* closes when its calls end, so the drain of the listener does not wait for an idle connection.
|
|
112
|
+
* - After the cooperative abort, the connections stay open until each cut call ends in-band with
|
|
113
|
+
* `UNAVAILABLE` (CLI-48). Before, the connections closed at the abort, and a cut stream lost its end.
|
|
101
114
|
*/
|
|
102
115
|
export function wsFace(options: UpgradeHandlerOptions = {}): HostFaceFactory {
|
|
103
116
|
return (context) => {
|
|
@@ -113,7 +126,17 @@ export function wsFace(options: UpgradeHandlerOptions = {}): HostFaceFactory {
|
|
|
113
126
|
stopIntake: () => upgrade.stopIntake(),
|
|
114
127
|
drain: async (deadline, signal) => {
|
|
115
128
|
await upgrade.drain(deadline, signal);
|
|
116
|
-
|
|
129
|
+
if (signal?.aborted) {
|
|
130
|
+
// After the cooperative abort, the service cuts each call, at the latest at the deadline,
|
|
131
|
+
// and each cut call ends in-band. The connections stay open until those calls end
|
|
132
|
+
// (CLI-48). This wait has no timer of its own: the cap of the phase limits it.
|
|
133
|
+
await upgrade.drain(realClock.now() + UNTIL_CLOSE_MS);
|
|
134
|
+
// The close starts here. The stage "close transports" waits for the close handshakes,
|
|
135
|
+
// so a cut at the deadline does not make the drain late.
|
|
136
|
+
upgrade.close().catch(() => undefined);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// The calls ended: the connections end in their protocol
|
|
117
140
|
await upgrade.close();
|
|
118
141
|
},
|
|
119
142
|
inFlight: () => upgrade.inFlight(),
|
package/src/host/index.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* faces mount on its mount list, so they share one port. Without a listener, the app mounts
|
|
12
12
|
* `host.handler` and `host.upgrade` on its own listener.
|
|
13
13
|
* - The request tracking: one route pattern for each face, and a function of the idempotency of
|
|
14
|
-
* each call (decision D3). Each call is a stream (decision D4).
|
|
14
|
+
* each call (decision D3). Each call is a stream (decision D4). A face of requests that are not
|
|
15
|
+
* procedure calls, for example the auth routes, tracks them with `track` (CLI-54).
|
|
15
16
|
* - The collections: the option `collections` gives the storages, and the host serves
|
|
16
17
|
* `collections.<name>.*`. Each storage is a dependency of the service. The expose rule applies
|
|
17
18
|
* to the collection procedures. Only `host.*` and `events.subscribe` are always on (CLI-47).
|
|
@@ -44,7 +45,7 @@ import {
|
|
|
44
45
|
} from "@procedures/client";
|
|
45
46
|
import type { ProcedureHandlerOptions, UpgradeHandlerOptions } from "@procedures/client/node";
|
|
46
47
|
import { httpFace, wsFace, type HostFace, type HostFaceFactory } from "./faces.js";
|
|
47
|
-
import { callOperations, faceHandler, operationOfCall } from "./tracking.js";
|
|
48
|
+
import { callOperations, faceHandler, httpTracker, operationOfCall } from "./tracking.js";
|
|
48
49
|
|
|
49
50
|
/** The knobs of a transport of the service, by the names of the ordering law. */
|
|
50
51
|
export type HostKnobs = Parameters<ServiceBuilder["server"]>[1];
|
|
@@ -307,10 +308,15 @@ export function createProcedureHost(options: ProcedureHostOptions = {}): Procedu
|
|
|
307
308
|
const factory = factoryOf(faceName, value);
|
|
308
309
|
if (!factory) continue;
|
|
309
310
|
const handler = faceHandler({ server, controller: name, face: faceName, life: () => life, keyed: keyedWindowMs !== undefined });
|
|
310
|
-
|
|
311
|
+
// The tracking of the requests of the face that are not procedure calls, on the same route pattern (CLI-54)
|
|
312
|
+
const track = httpTracker({ controller: name, face: faceName, life: () => life });
|
|
313
|
+
faces.set(faceName, factory({ name: faceName, handler, server, registry, policy, clock, track }));
|
|
311
314
|
}
|
|
312
315
|
|
|
313
|
-
// The listeners that the host owns, each one with the faces of its mount list
|
|
316
|
+
// The listeners that the host owns, each one with the faces of its mount list. The responses
|
|
317
|
+
// and the upgraded sockets of each listener go into one record, so the close of a face or of a
|
|
318
|
+
// listener first lets each answer go out (CLI-48).
|
|
319
|
+
const outgoing = outgoingAnswers();
|
|
314
320
|
const listeners = options.listen ?? [];
|
|
315
321
|
const onListener = new Set<string>();
|
|
316
322
|
const endpoints: Array<() => HostEndpoint[]> = [];
|
|
@@ -324,11 +330,16 @@ export function createProcedureHost(options: ProcedureHostOptions = {}): Procedu
|
|
|
324
330
|
onListener.add(faceName);
|
|
325
331
|
if (face.handler) {
|
|
326
332
|
const handle = face.handler;
|
|
327
|
-
transport.mount((req, res) =>
|
|
333
|
+
transport.mount((req, res) => {
|
|
334
|
+
const taken = handle(req, res);
|
|
335
|
+
if (taken) outgoing.response(res);
|
|
336
|
+
return taken;
|
|
337
|
+
});
|
|
328
338
|
}
|
|
329
339
|
if (face.upgrade) {
|
|
330
340
|
const upgrade = face.upgrade;
|
|
331
341
|
transport.upgrade(upgrade.match, (req, socket, head) => {
|
|
342
|
+
outgoing.socket(socket as Duplex);
|
|
332
343
|
upgrade.handler(req, socket as Duplex, head as Buffer);
|
|
333
344
|
});
|
|
334
345
|
}
|
|
@@ -353,7 +364,11 @@ export function createProcedureHost(options: ProcedureHostOptions = {}): Procedu
|
|
|
353
364
|
stopIntake: () => transport.stopIntake(),
|
|
354
365
|
drain: (deadline, signal) => transport.drain(deadline, signal),
|
|
355
366
|
inFlight: () => transport.inFlight(),
|
|
356
|
-
close:
|
|
367
|
+
// The close destroys each connection: first each answer that a face ended goes out (CLI-48)
|
|
368
|
+
close: async () => {
|
|
369
|
+
await outgoing.flushed();
|
|
370
|
+
await transport.close();
|
|
371
|
+
},
|
|
357
372
|
};
|
|
358
373
|
return { name: `${name}.listen.${listenerName}`, knobs: transport.knobs, impl };
|
|
359
374
|
});
|
|
@@ -391,7 +406,22 @@ export function createProcedureHost(options: ProcedureHostOptions = {}): Procedu
|
|
|
391
406
|
close: () => server.close(),
|
|
392
407
|
},
|
|
393
408
|
};
|
|
394
|
-
|
|
409
|
+
// The close of a face (the sessions of MCP, the connections of WebSocket) also lets each answer go out first (CLI-48)
|
|
410
|
+
const faceParts: HostPart[] = [...faces.entries()].flatMap(([faceName, face]) => {
|
|
411
|
+
const impl = face.parts;
|
|
412
|
+
if (!impl) return [];
|
|
413
|
+
const part: TransportImpl = {
|
|
414
|
+
start: (signal, lifecycle) => impl.start(signal, lifecycle),
|
|
415
|
+
stopIntake: () => impl.stopIntake(),
|
|
416
|
+
drain: (deadline, signal) => impl.drain(deadline, signal),
|
|
417
|
+
inFlight: () => impl.inFlight(),
|
|
418
|
+
close: async () => {
|
|
419
|
+
await outgoing.flushed();
|
|
420
|
+
await impl.close();
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
return [{ name: `${name}.${faceName}`, knobs: {}, impl: part }];
|
|
424
|
+
});
|
|
395
425
|
const parts: HostPart[] = [serverPart, ...faceParts, ...listenerParts];
|
|
396
426
|
|
|
397
427
|
const dependencies: HostDependency[] = server.storageHandles().map((handle) => ({
|
|
@@ -453,7 +483,64 @@ export function createProcedureHost(options: ProcedureHostOptions = {}): Procedu
|
|
|
453
483
|
};
|
|
454
484
|
}
|
|
455
485
|
|
|
486
|
+
/** The answers that go out on the listeners of a host: each response of a face, and each upgraded socket. */
|
|
487
|
+
interface OutgoingAnswers {
|
|
488
|
+
/** This method records a response of a face until its end. */
|
|
489
|
+
response(res: NodeResponse): void;
|
|
490
|
+
/** This method records an upgraded socket until its close. */
|
|
491
|
+
socket(socket: Duplex): void;
|
|
492
|
+
/**
|
|
493
|
+
* This method waits until each answer that a face wrote has gone out to the socket. It first gives
|
|
494
|
+
* one turn of the event loop, so each chain of promises that writes an answer ends (the MCP SDK).
|
|
495
|
+
* Then it waits for the event `finish` of each response that is ended, and for the write callback
|
|
496
|
+
* of each socket that has bytes to write. An open stream that is not ended does not hold it.
|
|
497
|
+
*/
|
|
498
|
+
flushed(): Promise<void>;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** An empty write: its callback comes after each write before it. */
|
|
502
|
+
const FLUSH = Buffer.alloc(0);
|
|
503
|
+
|
|
504
|
+
/** This function makes the record of the answers that go out on the listeners of a host (CLI-48). */
|
|
505
|
+
function outgoingAnswers(): OutgoingAnswers {
|
|
506
|
+
const responses = new Set<NodeResponse>();
|
|
507
|
+
const sockets = new Set<Duplex>();
|
|
508
|
+
return {
|
|
509
|
+
response(res) {
|
|
510
|
+
if (responses.has(res)) return;
|
|
511
|
+
responses.add(res);
|
|
512
|
+
const done = (): void => {
|
|
513
|
+
responses.delete(res);
|
|
514
|
+
};
|
|
515
|
+
res.once("finish", done);
|
|
516
|
+
res.once("close", done);
|
|
517
|
+
},
|
|
518
|
+
socket(socket) {
|
|
519
|
+
sockets.add(socket);
|
|
520
|
+
socket.once("close", () => sockets.delete(socket));
|
|
521
|
+
},
|
|
522
|
+
async flushed() {
|
|
523
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
524
|
+
const waits: Array<Promise<void>> = [];
|
|
525
|
+
for (const res of responses) {
|
|
526
|
+
if (!res.writableEnded || res.writableFinished || res.destroyed) continue;
|
|
527
|
+
waits.push(
|
|
528
|
+
new Promise<void>((resolve) => {
|
|
529
|
+
res.once("finish", () => resolve());
|
|
530
|
+
res.once("close", () => resolve());
|
|
531
|
+
}),
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
for (const socket of sockets) {
|
|
535
|
+
if (socket.destroyed || !socket.writable || socket.writableLength === 0) continue;
|
|
536
|
+
waits.push(new Promise<void>((resolve) => socket.write(FLUSH, () => resolve())));
|
|
537
|
+
}
|
|
538
|
+
await Promise.all(waits);
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
456
543
|
export { httpFace, wsFace } from "./faces.js";
|
|
457
544
|
export type { HostFace, HostFaceContext, HostFaceEndpoint, HostFaceFactory, HostFaceUpgrade } from "./faces.js";
|
|
458
|
-
export { callOperations, faceHandler, operationOfCall } from "./tracking.js";
|
|
459
|
-
export type { CallDetail, CallOperation, FaceHandler, FaceHandlerOptions } from "./tracking.js";
|
|
545
|
+
export { callOperations, faceHandler, httpTracker, operationOfCall } from "./tracking.js";
|
|
546
|
+
export type { CallDetail, CallOperation, FaceHandler, FaceHandlerOptions, HttpTracker, HttpTrackerOptions } from "./tracking.js";
|
package/src/host/tracking.ts
CHANGED
|
@@ -11,8 +11,11 @@
|
|
|
11
11
|
* repeatable procedure, a call with an idempotency key, or a call that runs one time.
|
|
12
12
|
* - Each call is a stream (D4). A unary call is a stream of one. A stream call stays in flight
|
|
13
13
|
* until its last item, and a cut after the first item ends the stream in-band, with an error.
|
|
14
|
+
* Each cut has the code `UNAVAILABLE`, before and after the first item. Its flag `retryable` tells
|
|
15
|
+
* if a retry is safe (CLI-48).
|
|
14
16
|
*/
|
|
15
17
|
|
|
18
|
+
import type { IncomingMessage, ServerResponse as NodeResponse } from "node:http";
|
|
16
19
|
import type { Lifecycle, Operation, OperationOf, RequestEnd, RequestEvent, RouteRequest } from "@mark1russell7/service";
|
|
17
20
|
import {
|
|
18
21
|
IDEMPOTENCY_KEY_METADATA,
|
|
@@ -33,7 +36,8 @@ import {
|
|
|
33
36
|
* The operation of a call, which gives its idempotency (D3):
|
|
34
37
|
* - `repeatable`: the effects of the procedure permit a second run (no writes, or idempotent writes).
|
|
35
38
|
* - `keyed`: the call has an idempotency key, and the server keeps its result for the window.
|
|
36
|
-
* - `once`: each other call. A cut of it
|
|
39
|
+
* - `once`: each other call. A cut of it is `UNAVAILABLE` and not retryable, because it can have had its effect.
|
|
40
|
+
* Before its first item, the HTTP face answers 500 for it.
|
|
37
41
|
*/
|
|
38
42
|
export type CallOperation = "repeatable" | "keyed" | "once";
|
|
39
43
|
|
|
@@ -93,17 +97,20 @@ function messageOf(error: unknown): string {
|
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
/**
|
|
96
|
-
* The response of an end before the first item. A refusal and
|
|
97
|
-
*
|
|
100
|
+
* The response of an end before the first item. A refusal and each cut are `UNAVAILABLE`, because
|
|
101
|
+
* the server stopped (CLI-48). The flag `retryable` tells if a retry is safe:
|
|
102
|
+
* - A refusal and a retryable cut are retryable. The HTTP face answers 503, with `Retry-After` from the transport.
|
|
103
|
+
* - A cut of a call that can have had its effect is not retryable. The HTTP face answers 500, as the runner does.
|
|
104
|
+
* A failure is `INTERNAL_ERROR` (500).
|
|
98
105
|
*/
|
|
99
106
|
function endResponse(id: string, end: RequestEnd): ServerResponse {
|
|
100
107
|
switch (end.kind) {
|
|
101
108
|
case "refused":
|
|
102
109
|
return failure(id, "UNAVAILABLE", `The host does not take the call now: ${end.cause}`, true);
|
|
103
110
|
case "cut":
|
|
104
|
-
return end.
|
|
111
|
+
return end.retryable
|
|
105
112
|
? failure(id, "UNAVAILABLE", `The host stopped the call: ${end.cause}`, true)
|
|
106
|
-
: failure(id, "
|
|
113
|
+
: failure(id, "UNAVAILABLE", `The host stopped the call, and the call can have had its effect: ${end.cause}`, false);
|
|
107
114
|
case "failed":
|
|
108
115
|
return failure(id, "INTERNAL_ERROR", messageOf(end.error), false);
|
|
109
116
|
case "done":
|
|
@@ -114,11 +121,15 @@ function endResponse(id: string, end: RequestEnd): ServerResponse {
|
|
|
114
121
|
/**
|
|
115
122
|
* The error of an end after the first item: the stream ends in-band, with an error line. The count
|
|
116
123
|
* of the items of the stream goes in the message. The head of the response is not an item.
|
|
124
|
+
*
|
|
125
|
+
* The code is always `UNAVAILABLE`, because the server stopped the stream. A cut is not a failure
|
|
126
|
+
* of the server. The flag `retryable` tells if a retry is safe (CLI-48). Before, a cut that is not
|
|
127
|
+
* retryable had the code `INTERNAL_ERROR`, before and after the first item.
|
|
117
128
|
*/
|
|
118
129
|
function inBandEnd(end: Extract<RequestEnd, { kind: "refused" | "cut" }>): Error {
|
|
119
130
|
const delivered = Math.max(0, end.delivered - 1);
|
|
120
131
|
return Object.assign(new Error(`The host stopped the stream after ${delivered} items: ${end.cause}`), {
|
|
121
|
-
code:
|
|
132
|
+
code: "UNAVAILABLE",
|
|
122
133
|
retryable: end.retryable,
|
|
123
134
|
delivered,
|
|
124
135
|
});
|
|
@@ -215,3 +226,85 @@ export function faceHandler(options: FaceHandlerOptions): FaceHandler {
|
|
|
215
226
|
},
|
|
216
227
|
};
|
|
217
228
|
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The request tracking of a face whose requests are not procedure calls, for example the auth
|
|
232
|
+
* routes of an app (CLI-54). The function does the work of one HTTP request through `life.request`,
|
|
233
|
+
* on the route `<face> <key>` of the controller of the host. Thus the request gets the readiness
|
|
234
|
+
* of the controller, the refusal after the stop of the intake, and the drain.
|
|
235
|
+
*
|
|
236
|
+
* - `serve` writes the response. The request stays in flight until the response closes.
|
|
237
|
+
* - The operation of the request gives its idempotency. The default: `repeatable` for `GET` and
|
|
238
|
+
* `HEAD`, `once` for another method.
|
|
239
|
+
* - A refusal, a cut before the response starts and an error get a JSON error of wire v1:
|
|
240
|
+
* `UNAVAILABLE` (503, retryable) or `INTERNAL_ERROR` (500).
|
|
241
|
+
* - Without a service, `serve` starts at once.
|
|
242
|
+
*/
|
|
243
|
+
export type HttpTracker = (
|
|
244
|
+
req: IncomingMessage,
|
|
245
|
+
res: NodeResponse,
|
|
246
|
+
key: string,
|
|
247
|
+
serve: (signal: AbortSignal) => Promise<void> | void,
|
|
248
|
+
operation?: CallOperation,
|
|
249
|
+
) => Promise<void>;
|
|
250
|
+
|
|
251
|
+
/** The options of `httpTracker()`. */
|
|
252
|
+
export interface HttpTrackerOptions {
|
|
253
|
+
/** The controller of the calls in the declaration of the service. */
|
|
254
|
+
readonly controller: string;
|
|
255
|
+
/** The name of the face: the first part of the route of each request. */
|
|
256
|
+
readonly face: string;
|
|
257
|
+
/** This function gives the lifecycle of the service, or null. Without a lifecycle, each request starts directly. */
|
|
258
|
+
readonly life: () => Lifecycle | null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** This function writes the JSON error of an end of a request, when its response did not start. */
|
|
262
|
+
function writeEnd(res: NodeResponse, end: RequestEnd): void {
|
|
263
|
+
if (end.kind === "done") return;
|
|
264
|
+
if (res.headersSent || res.writableEnded) {
|
|
265
|
+
// The response started: only a close of the connection tells the caller
|
|
266
|
+
if (!res.writableEnded) res.destroy();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const { status } = endResponse("", end);
|
|
270
|
+
if (status.type !== "error") return;
|
|
271
|
+
res.statusCode = status.code === "UNAVAILABLE" ? 503 : 500;
|
|
272
|
+
res.setHeader("Content-Type", "application/json");
|
|
273
|
+
res.end(JSON.stringify({ error: status.message, code: status.code, retryable: status.retryable }));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** This function gives the request tracking of one face (CLI-54). */
|
|
277
|
+
export function httpTracker(options: HttpTrackerOptions): HttpTracker {
|
|
278
|
+
return async (req, res, key, serve, operation) => {
|
|
279
|
+
// The work ends when the response closes: a handler can end before its body is out
|
|
280
|
+
const work = (signal: AbortSignal): Promise<true> =>
|
|
281
|
+
new Promise<true>((resolve, reject) => {
|
|
282
|
+
if (res.writableFinished || res.destroyed) {
|
|
283
|
+
resolve(true);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
res.once("close", () => resolve(true));
|
|
287
|
+
Promise.resolve()
|
|
288
|
+
.then(() => serve(signal))
|
|
289
|
+
.catch(reject);
|
|
290
|
+
});
|
|
291
|
+
const life = options.life();
|
|
292
|
+
if (!life) {
|
|
293
|
+
try {
|
|
294
|
+
await work(new AbortController().signal);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
writeEnd(res, { kind: "failed", error, delivered: 0 });
|
|
297
|
+
}
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
301
|
+
const detail: CallDetail = { face: options.face, key, operation: operation ?? (method === "GET" || method === "HEAD" ? "repeatable" : "once") };
|
|
302
|
+
let end: RequestEnd;
|
|
303
|
+
try {
|
|
304
|
+
end = (await life.request<true>(options.controller, `${options.face} ${key}`, work, { detail }).collect()).end;
|
|
305
|
+
} catch (error) {
|
|
306
|
+
end = { kind: "failed", error: new Error(`The service of the host has no route for this request: ${messageOf(error)}`), delivered: 0 };
|
|
307
|
+
}
|
|
308
|
+
writeEnd(res, end);
|
|
309
|
+
};
|
|
310
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ export {
|
|
|
34
34
|
LOCAL_LISTENER_KNOBS,
|
|
35
35
|
callOperations,
|
|
36
36
|
faceHandler,
|
|
37
|
+
httpTracker,
|
|
37
38
|
operationOfCall,
|
|
38
39
|
} from "./host/index.js";
|
|
39
40
|
export type {
|
|
@@ -61,6 +62,8 @@ export type {
|
|
|
61
62
|
CallOperation,
|
|
62
63
|
FaceHandler,
|
|
63
64
|
FaceHandlerOptions,
|
|
65
|
+
HttpTracker,
|
|
66
|
+
HttpTrackerOptions,
|
|
64
67
|
} from "./host/index.js";
|
|
65
68
|
|
|
66
69
|
// Peer: a procedures host with a service of its own, for a process that has no service
|