@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/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.13.2",
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/fs": "0.13.2",
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.13.2",
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. A face of another package has the same shape, so the host needs no change for it.
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
- // The calls ended, or the cooperative abort came: the connections end in their protocol
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
- faces.set(faceName, factory({ name: faceName, handler, server, registry, policy, clock }));
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) => handle(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: () => transport.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
- const faceParts: HostPart[] = [...faces.entries()].flatMap(([faceName, face]) => (face.parts ? [{ name: `${name}.${faceName}`, knobs: {}, impl: face.parts }] : []));
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";
@@ -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 before its first item answers 500, because it can have had its effect.
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 a retryable cut are `UNAVAILABLE`
97
- * (503, with `Retry-After` from the transport). Another cut and a failure are `INTERNAL_ERROR` (500).
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.status === 503
111
+ return end.retryable
105
112
  ? failure(id, "UNAVAILABLE", `The host stopped the call: ${end.cause}`, true)
106
- : failure(id, "INTERNAL_ERROR", `The host stopped the call, and the call can have had its effect: ${end.cause}`, false);
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: end.retryable ? "UNAVAILABLE" : "INTERNAL_ERROR",
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