@procedures/client 0.43.0 → 0.45.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +2 -0
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/adapters/http/server/transport.d.ts +2 -0
  5. package/dist/adapters/http/server/transport.d.ts.map +1 -1
  6. package/dist/adapters/http/server/transport.js +8 -3
  7. package/dist/adapters/http/server/transport.js.map +1 -1
  8. package/dist/adapters/http/server/types.d.ts +6 -0
  9. package/dist/adapters/http/server/types.d.ts.map +1 -1
  10. package/dist/adapters/sequence.d.ts +4 -0
  11. package/dist/adapters/sequence.d.ts.map +1 -1
  12. package/dist/adapters/sequence.js +7 -1
  13. package/dist/adapters/sequence.js.map +1 -1
  14. package/dist/adapters/websocket/server/transport.d.ts +2 -0
  15. package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
  16. package/dist/adapters/websocket/server/transport.js +13 -8
  17. package/dist/adapters/websocket/server/transport.js.map +1 -1
  18. package/dist/adapters/websocket/server/types.d.ts +6 -0
  19. package/dist/adapters/websocket/server/types.d.ts.map +1 -1
  20. package/dist/adapters/websocket/shared/rpc.d.ts +3 -0
  21. package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
  22. package/dist/adapters/websocket/shared/rpc.js +1 -1
  23. package/dist/adapters/websocket/shared/rpc.js.map +1 -1
  24. package/dist/browser.d.ts +7 -3
  25. package/dist/browser.d.ts.map +1 -1
  26. package/dist/browser.js +5 -1
  27. package/dist/browser.js.map +1 -1
  28. package/dist/logger.d.ts +36 -0
  29. package/dist/logger.d.ts.map +1 -0
  30. package/dist/logger.js +40 -0
  31. package/dist/logger.js.map +1 -0
  32. package/dist/procedures/core/index.d.ts +50 -2
  33. package/dist/procedures/core/index.d.ts.map +1 -1
  34. package/dist/procedures/core/index.js +148 -3
  35. package/dist/procedures/core/index.js.map +1 -1
  36. package/dist/procedures/core/operands.d.ts +4 -2
  37. package/dist/procedures/core/operands.d.ts.map +1 -1
  38. package/dist/procedures/core/operands.js +4 -3
  39. package/dist/procedures/core/operands.js.map +1 -1
  40. package/dist/procedures/index.d.ts +2 -2
  41. package/dist/procedures/index.d.ts.map +1 -1
  42. package/dist/procedures/index.js +1 -1
  43. package/dist/procedures/index.js.map +1 -1
  44. package/dist/procedures/program.d.ts +14 -1
  45. package/dist/procedures/program.d.ts.map +1 -1
  46. package/dist/procedures/program.js +8 -1
  47. package/dist/procedures/program.js.map +1 -1
  48. package/dist/server/error-policy.d.ts +3 -3
  49. package/dist/server/error-policy.d.ts.map +1 -1
  50. package/dist/server/face-rules.d.ts +62 -0
  51. package/dist/server/face-rules.d.ts.map +1 -0
  52. package/dist/server/face-rules.js +79 -0
  53. package/dist/server/face-rules.js.map +1 -0
  54. package/dist/server/procedure-server.d.ts +9 -4
  55. package/dist/server/procedure-server.d.ts.map +1 -1
  56. package/dist/server/procedure-server.js +18 -4
  57. package/dist/server/procedure-server.js.map +1 -1
  58. package/dist/server/server.d.ts +27 -6
  59. package/dist/server/server.d.ts.map +1 -1
  60. package/dist/server/server.js +43 -12
  61. package/dist/server/server.js.map +1 -1
  62. package/dist/server/types.d.ts +14 -1
  63. package/dist/server/types.d.ts.map +1 -1
  64. package/dist/server/types.js.map +1 -1
  65. package/package.json +2 -2
  66. package/src/adapters/http/server/transport.ts +8 -5
  67. package/src/adapters/http/server/types.ts +7 -0
  68. package/src/adapters/sequence.ts +6 -1
  69. package/src/adapters/websocket/server/transport.ts +14 -9
  70. package/src/adapters/websocket/server/types.ts +6 -0
  71. package/src/adapters/websocket/shared/rpc.ts +4 -1
  72. package/src/browser.ts +13 -1
  73. package/src/logger.ts +52 -0
  74. package/src/procedures/core/index.ts +211 -2
  75. package/src/procedures/core/operands.ts +5 -4
  76. package/src/procedures/index.ts +5 -0
  77. package/src/procedures/program.ts +24 -2
  78. package/src/server/error-policy.ts +3 -3
  79. package/src/server/face-rules.ts +115 -0
  80. package/src/server/procedure-server.ts +27 -7
  81. package/src/server/server.ts +58 -15
  82. package/src/server/types.ts +15 -1
@@ -15,7 +15,7 @@ import {
15
15
  type AnyProcedureRef,
16
16
  type RefScope,
17
17
  } from "../ref.js";
18
- import { hydrateInput, runControl, runOperand } from "../program.js";
18
+ import { hydrateInput, runControl, runOperand, type RunControlOptions } from "../program.js";
19
19
 
20
20
  /**
21
21
  * The operands of a control-flow procedure arrive raw: the procedure runs them itself. Its logic
@@ -37,10 +37,11 @@ function executorOf(ctx: ProcedureContext): <TIn, TOut>(path: ProcedurePath, inp
37
37
 
38
38
  /**
39
39
  * This function runs a control-flow procedure on the kernel: the runner of its kind runs the
40
- * operands of the input, in the scope of the caller.
40
+ * operands of the input, in the scope of the caller. The observer in `options` gets the result of
41
+ * each step of a chain (`client.trace`).
41
42
  */
42
- export function runControlFlow(name: string, input: unknown, ctx: ProcedureContext): Promise<unknown> {
43
- return runControl(name, input, { executor: executorOf(ctx), rawInput: rawRule(ctx), onRef: ctx.onRef, scope: scopeOf(input) });
43
+ export function runControlFlow(name: string, input: unknown, ctx: ProcedureContext, options: Pick<RunControlOptions, "onStep"> = {}): Promise<unknown> {
44
+ return runControl(name, input, { executor: executorOf(ctx), rawInput: rawRule(ctx), onRef: ctx.onRef, scope: scopeOf(input), onStep: options.onStep });
44
45
  }
45
46
 
46
47
  /**
@@ -323,6 +323,8 @@ export type {
323
323
  RunDocumentOptions,
324
324
  RunProgramOptions,
325
325
  RunOperandOptions,
326
+ RunControlOptions,
327
+ StepObserver,
326
328
  } from "./program.js";
327
329
 
328
330
  // The envelope form of a program (coeval D6, D9 and D10, roadmap item M7)
@@ -337,6 +339,9 @@ export {
337
339
  allCoreProcedures,
338
340
  allCoreModules,
339
341
  chainProcedure,
342
+ traceProcedure,
343
+ type TraceRow,
344
+ type TraceReason,
340
345
  parallelProcedure,
341
346
  conditionalProcedure,
342
347
  andProcedure,
@@ -995,6 +995,8 @@ export function programMachine(host: ProgramHost): Machine {
995
995
  setOwn(names, LAST, result.value);
996
996
  passed = result.value;
997
997
  results.push(result.value);
998
+ // The observer of this run (`RunControlOptions.onStep`) gets the result of each step
999
+ STEP_OBSERVERS.get(input.doc)?.(index, result.value);
998
1000
  }
999
1001
  return ok({ results, final: results[results.length - 1] });
1000
1002
  }
@@ -1377,15 +1379,35 @@ export async function runOperand(value: unknown, options: RunOperandOptions): Pr
1377
1379
  return runDocument(doc, { ...options, self: isAnyProcedureRef(value) ? frame : null });
1378
1380
  }
1379
1381
 
1382
+ /**
1383
+ * The observer of the steps of one `client.chain` run. It gets the index and the result of each
1384
+ * step that ends without an error, in the order of the steps.
1385
+ */
1386
+ export type StepObserver = (index: number, value: unknown) => void;
1387
+
1388
+ /** The options of `runControl()`. */
1389
+ export interface RunControlOptions extends RunDocumentOptions {
1390
+ /**
1391
+ * The observer of the steps of `client.chain` (`client.trace` uses it). Only the steps of this
1392
+ * chain go to it: a nested chain does not tell it. The other procedures ignore it.
1393
+ */
1394
+ readonly onStep?: StepObserver | undefined;
1395
+ }
1396
+
1397
+ /** The step observers, by the document of their `client.chain` run (`runControl()`). */
1398
+ const STEP_OBSERVERS = new WeakMap<object, StepObserver>();
1399
+
1380
1400
  /**
1381
1401
  * This function runs a control-flow procedure on the kernel: its input becomes a document of its
1382
1402
  * kind, and the runner of the kind runs the operands. The input is raw: its scope (`scopeOf()`)
1383
1403
  * gives the names of the caller.
1384
1404
  */
1385
- export async function runControl(name: string, input: unknown, options: RunDocumentOptions): Promise<unknown> {
1405
+ export async function runControl(name: string, input: unknown, options: RunControlOptions): Promise<unknown> {
1386
1406
  const value = desugarControlValue(name, input, { rawInput: options.rawInput, scoped: options.scope !== undefined });
1387
1407
  if (value === undefined) throw new TypeError(`client.${name}: the input does not have the shape of the procedure`);
1388
- return runDocument(controlDocument(name, value, input, options.scope !== undefined), options);
1408
+ const doc = controlDocument(name, value, input, options.scope !== undefined);
1409
+ if (options.onStep) STEP_OBSERVERS.set(doc, options.onStep);
1410
+ return runDocument(doc, options);
1389
1411
  }
1390
1412
 
1391
1413
  /** The document of the run of a control-flow procedure: its own entry names the runner of the procedure. */
@@ -64,8 +64,8 @@ export interface ServerErrorPolicy {
64
64
  * `INTERNAL_ERROR` with the message `"internal error"` and its real retryable flag, with
65
65
  * only the metadata keys of `keepMetadata`. The codes `INTERNAL_ERROR`, `HANDLER_ERROR`, `UNKNOWN`,
66
66
  * `OUTPUT_VALIDATION_ERROR` and `NO_OUTPUT` tell of a failure of the server, not of a
67
- * refusal, so their message is hidden too. The server gives the real error to its `onError`
68
- * logger one time, or to `console.error` without a logger.
67
+ * refusal, so their message is hidden too. The server gives the real error one time to its `onError`
68
+ * and its `logger`, or to `console.error` without them.
69
69
  */
70
70
  readonly expose?: "all" | "coded" | undefined;
71
71
  /**
@@ -108,7 +108,7 @@ export function markLogged(error: unknown): void {
108
108
  if (typeof error === "object" && error !== null) LOGGED.add(error);
109
109
  }
110
110
 
111
- /** The logger of the hidden errors: the hook `onError` of the server, or `console.error`. */
111
+ /** The logger of the hidden errors: the hook `onError` and the `logger` of the server, or `console.error`. */
112
112
  export type HiddenErrorLog = (error: unknown, request: ServerRequest<unknown> | undefined) => void;
113
113
 
114
114
  /** This function gives an error to the logger, when the logger did not get it before. */
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The rules of one face of a host (CLI-43, AG1-B): a narrowing and an expose rule for each call of
3
+ * the face.
4
+ *
5
+ * A host can serve one server through more than one face, for example a full face and a read-only
6
+ * face for a browser. `faceRules()` reads the rules of one face one time, and `apply()` adds them to
7
+ * each request of that face:
8
+ *
9
+ * - The narrowing becomes one more source of the narrowing key (`procedure-narrowing`, CLI-30). Thus
10
+ * the server applies the meet of all the sources: the capability of the policy, the request hook
11
+ * and the key of the caller. A narrowing can only narrow.
12
+ * - The expose rule goes to `ServerRequest.expose`. The server meets it with its own rule, for the
13
+ * call of the face and for each nested call. An expose rule can only narrow.
14
+ *
15
+ * The MCP face of `@procedures/mcp-server` and the HTTP and WebSocket faces of `@procedures/peer`
16
+ * use this module. It imports no Node code.
17
+ */
18
+
19
+ import { NARROWING_METADATA_KEY, addNarrowing, narrowWithin, narrowingOf } from "../procedures/narrowing.js";
20
+ import type { Capability, CapabilityRequest, ProcedurePath } from "../procedures/types.js";
21
+ import { exposePatterns, surfaceRule, type HostPolicy, type ToolSurface } from "./host-policy.js";
22
+ import type { ServerRequest } from "./types.js";
23
+
24
+ /** The rules of one face, as options of the face. */
25
+ export interface FaceRulesOptions {
26
+ /**
27
+ * A narrower capability for each call of the face, for example the read-only mode
28
+ * `{ effects: { writes: [] } }`. A narrowing that is not valid stops `faceRules()` with an error.
29
+ */
30
+ readonly narrowing?: CapabilityRequest<unknown> | readonly CapabilityRequest<unknown>[] | undefined;
31
+ /**
32
+ * The procedures that the callers of the face reach: the patterns of `hostPolicy({ expose })`, a
33
+ * tool surface or a function. The server meets this rule with the rule of the host.
34
+ */
35
+ readonly expose?: readonly string[] | ToolSurface | ((path: ProcedurePath) => boolean) | undefined;
36
+ }
37
+
38
+ /** The rules of one face, read one time. */
39
+ export interface FaceRules {
40
+ /** The sources of the narrowing as the decoder read them, frozen. The list is empty without a narrowing. */
41
+ readonly narrowings: readonly CapabilityRequest<unknown>[];
42
+ /** The expose rule of the face, or undefined without one. */
43
+ readonly expose: ((path: ProcedurePath) => boolean) | undefined;
44
+ /**
45
+ * The capability of the policy, narrowed by the narrowing of the face, without the time. A face
46
+ * lists its procedures with it: the time limits each call, not the list.
47
+ */
48
+ readonly capability: Capability | undefined;
49
+ /** This function gives the request with the rules of the face. It does not change the request that it gets. */
50
+ apply<T>(request: ServerRequest<T>): ServerRequest<T>;
51
+ }
52
+
53
+ /** This function gives the meet of two expose rules: a path passes when it passes each rule. */
54
+ export function meetExpose(
55
+ first: ((path: ProcedurePath) => boolean) | undefined,
56
+ second: ((path: ProcedurePath) => boolean) | undefined,
57
+ ): ((path: ProcedurePath) => boolean) | undefined {
58
+ if (!first) return second;
59
+ if (!second) return first;
60
+ return (path) => first(path) && second(path);
61
+ }
62
+
63
+ /** This function gives the rule of the expose option of a face. */
64
+ function exposeRule(expose: NonNullable<FaceRulesOptions["expose"]>): (path: ProcedurePath) => boolean {
65
+ if (typeof expose === "function") return expose;
66
+ return Array.isArray(expose) ? exposePatterns(expose as readonly string[]) : surfaceRule(expose as ToolSurface);
67
+ }
68
+
69
+ /**
70
+ * This function reads the rules of one face with the decoder and the meet of the wire (CLI-30).
71
+ * It throws for a part of the narrowing that is not valid, because each call of the face then gets
72
+ * a refusal. The argument `face` names the face in the message of the error, for example `MCP`.
73
+ *
74
+ * @example
75
+ * ```typescript
76
+ * const rules = faceRules({ narrowing: { effects: { writes: [] } }, expose: ["graphapi.*"] }, policy, "read");
77
+ * const handler = { ...inner, handle: (request) => inner.handle(rules.apply(request)) };
78
+ * ```
79
+ */
80
+ export function faceRules(
81
+ options: FaceRulesOptions,
82
+ policy: Pick<HostPolicy, "capabilityOf" | "clearanceOrder">,
83
+ face?: string,
84
+ ): FaceRules {
85
+ let narrowings: readonly CapabilityRequest<unknown>[] = [];
86
+ let capability = policy.capabilityOf?.();
87
+ if (options.narrowing !== undefined) {
88
+ const wire = narrowingOf(options.narrowing);
89
+ // The sources as the decoder read them, frozen: a change of the options or of the metadata of a call does not change them
90
+ narrowings = Object.freeze(wire.narrowings.map((source) => Object.freeze({ ...source })));
91
+ const untimed = narrowings.map((source): CapabilityRequest<unknown> => ({
92
+ ...(source.clearance !== undefined ? { clearance: source.clearance } : {}),
93
+ ...(source.effects !== undefined ? { effects: source.effects } : {}),
94
+ }));
95
+ const listed = narrowWithin(capability, untimed, policy.clearanceOrder);
96
+ const refusal = [...wire.notes, ...listed.notes].find((note) => note.kind === "refused");
97
+ if (refusal) throw new Error(`The narrowing of ${face === undefined ? "the face" : `the ${face} face`} is not valid: ${refusal.message}`);
98
+ capability = listed.capability;
99
+ }
100
+ const expose = options.expose !== undefined ? exposeRule(options.expose) : undefined;
101
+ const sources = narrowings;
102
+ return {
103
+ narrowings,
104
+ expose,
105
+ capability,
106
+ apply: (request) => {
107
+ if (sources.length === 0 && !expose) return request;
108
+ return {
109
+ ...request,
110
+ ...(sources.length > 0 ? { metadata: { ...request.metadata, [NARROWING_METADATA_KEY]: addNarrowing(request.metadata[NARROWING_METADATA_KEY], sources) } } : {}),
111
+ ...(expose ? { expose: meetExpose(request.expose, expose) } : {}),
112
+ };
113
+ },
114
+ };
115
+ }
@@ -10,7 +10,7 @@ import { errorResponse } from "./error-response.js";
10
10
 
11
11
  export { errorResponse };
12
12
  import type { Admission, AmbientCredentials, Credentials, Method, Principal, PrincipalOf, RequestIdentity, ServerHandler, ServerRequest, ServerResponse } from "./types.js";
13
- import { methodToPath, pathToMethod } from "./method.js";
13
+ import { methodName, methodToPath, pathToMethod } from "./method.js";
14
14
  import { isLocalCredentials } from "./request-auth.js";
15
15
  import { STREAM_METADATA_KEY } from "./stream-metadata.js";
16
16
  import type {
@@ -39,6 +39,8 @@ import type { NarrowingNote } from "../procedures/narrowing.js";
39
39
  import type { ServerDebugReport } from "./types.js";
40
40
  import type { KeyedLimiter, LimiterStore } from "@mark1russell7/service/limiter";
41
41
  import { callLimiter, limitedCall, rateLimitedResponse, type CallLimit, type CallLimiter } from "./limits.js";
42
+ import { writeLog } from "../logger.js";
43
+ import { meetExpose } from "./face-rules.js";
42
44
 
43
45
  // =============================================================================
44
46
  // Types
@@ -195,9 +197,10 @@ export interface ProcedureServerOptions extends ServerOptions {
195
197
  events?: EventPublisherOptions | EventPublisher | undefined;
196
198
 
197
199
  /**
198
- * The debug reports of the server. `true` writes each report with `console.debug`, and a
199
- * function gets each report. The server reports each part of the narrowing of a call that has
200
- * no effect (CLI-30). Such a part asks for more than the call holds, or it is not valid. The
200
+ * The debug reports of the server. `true` writes each report to the `logger` at the level `debug`,
201
+ * or with `console.debug` without a logger (CLI-87). A function gets each report.
202
+ *
203
+ * The server reports each part of the narrowing of a call that has no effect (CLI-30). Such a part asks for more than the call holds, or it is not valid. The
201
204
  * call goes on without that part. The server also reports a source that refuses the call.
202
205
  * @default false
203
206
  */
@@ -492,7 +495,15 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
492
495
  typeof options.debug === "function"
493
496
  ? options.debug
494
497
  : options.debug === true
495
- ? (report) => console.debug(`[ProcedureServer] ${report.topic} of ${report.requestId}: ${report.message}`)
498
+ ? (report) =>
499
+ // The logger of the server gets the report at the level debug (CLI-87)
500
+ writeLog(
501
+ options.logger,
502
+ "debug",
503
+ report.message,
504
+ { topic: report.topic, requestId: report.requestId, procedure: methodName(report.method), ...(report.detail ? { detail: report.detail } : {}) },
505
+ `[ProcedureServer] ${report.topic} of ${report.requestId}: ${report.message}`,
506
+ )
496
507
  : undefined;
497
508
  // The limits of the calls: one keyed limiter for each limit, on the clock of the server (CLI-40)
498
509
  this.callLimits = options.limits && options.limits.length > 0 ? callLimiter(options.limits, { clock: this.clock, stores: options.limitStores }) : undefined;
@@ -808,8 +819,16 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
808
819
  /**
809
820
  * The handlers that `register()` added come first (exact or pattern matches). Then the server
810
821
  * looks up the procedure of the method.
822
+ *
823
+ * The expose rule of the face of the request (`request.expose`, AG1-B) comes before both. A path
824
+ * outside it has no handler, also a handler of `register()` and an `alwaysOn` pin. A rule that is
825
+ * not a function admits no path, so a face that sets a wrong value does not open the server.
811
826
  */
812
- protected override findHandler(method: Method): ServerHandler | null {
827
+ protected override findHandler(method: Method, request?: ServerRequest<unknown>): ServerHandler | null {
828
+ if (request?.expose !== undefined) {
829
+ const rule: unknown = request.expose;
830
+ if (typeof rule !== "function" || rule(methodToPath(method)) !== true) return null;
831
+ }
813
832
  const handler = super.findHandler(method);
814
833
  if (handler) return handler;
815
834
  const procedure = this.resolveProcedure(method);
@@ -1105,7 +1124,8 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
1105
1124
  ...(request.elicit ? { elicit: request.elicit } : {}),
1106
1125
  ...(request.progress ? { progress: request.progress } : {}),
1107
1126
  repository,
1108
- expose: this.expose,
1127
+ // The rule of the face of the request meets the rule of the server, also for each nested call (AG1-B)
1128
+ expose: request.expose !== undefined ? meetExpose(this.expose, request.expose) : this.expose,
1109
1129
  strictCalls: this.strictCalls,
1110
1130
  interceptors: this.interceptors,
1111
1131
  capability,
@@ -19,6 +19,8 @@ import { HandlerNotFoundError, ServerError } from "./types.js";
19
19
  import { compose } from "../middleware/index.js";
20
20
  import { callOf, exposeError, exposedResponse, markLogged, policyActs, type ExposedError, type HiddenErrorLog, type ServerErrorPolicy } from "./error-policy.js";
21
21
  import { withRealError } from "./error-response.js";
22
+ import { CONSOLE_LOGGER, type LogFields, type Logger } from "../logger.js";
23
+ import { methodName } from "./method.js";
22
24
 
23
25
  /**
24
26
  * Server configuration options.
@@ -34,9 +36,17 @@ export interface ServerOptions {
34
36
  * Global error handler.
35
37
  * Called when a handler throws an error. The error policy (`errors`) also gives it each hidden
36
38
  * error one time. A last-resort error of a face that failed before it knew the call comes with
37
- * `UNKNOWN_REQUEST`: a request with the id `"unknown"`.
39
+ * `UNKNOWN_REQUEST`: a request with the id `"unknown"`. The third argument gives the logger of
40
+ * the server: the option `logger`, or `CONSOLE_LOGGER` without it (CLI-87).
38
41
  */
39
- onError?: (error: Error, request: ServerRequest) => void;
42
+ onError?: (error: Error, request: ServerRequest, context: ServerErrorContext) => void;
43
+
44
+ /**
45
+ * The logger of the server and of its faces (CLI-87). The server writes its log lines and its
46
+ * errors to it, also when `onError` gets them. A face that has no logger of its own uses this
47
+ * logger. Without it, each line goes to the console as before.
48
+ */
49
+ logger?: Logger | undefined;
40
50
 
41
51
  /**
42
52
  * Request logger.
@@ -57,7 +67,7 @@ export interface ServerOptions {
57
67
  * - `expose: "coded"`: an error with a known code (`isKnownErrorCode`) goes to the caller as it
58
68
  * is, and so does an error with a code of `codes`. Each other error goes as `INTERNAL_ERROR`
59
69
  * with the message `"internal error"` and its real retryable flag. The server gives the real
60
- * error to `onError` one time, or to `console.error` without `onError`.
70
+ * error one time to `onError` and to the `logger`, or to `console.error` without them.
61
71
  * - `map` runs first, with the real error of its own class. Its result goes to the caller as it
62
72
  * is, with its `retryAfterMs`. `undefined` gives the error to `expose`.
63
73
  * - The last-resort catch of each face applies the same policy (`exposeError`).
@@ -76,6 +86,12 @@ export interface ServerOptions {
76
86
  errors?: ServerErrorPolicy | undefined;
77
87
  }
78
88
 
89
+ /** The third argument of the hook `onError` of a server (CLI-87). */
90
+ export interface ServerErrorContext {
91
+ /** The logger of the server: the option `logger`, or `CONSOLE_LOGGER` without it. */
92
+ readonly logger: Logger;
93
+ }
94
+
79
95
  /**
80
96
  * Method matcher for handler registration.
81
97
  * Can match exact methods or patterns.
@@ -97,6 +113,17 @@ interface HandlerEntry {
97
113
  /** The request that `onError` gets for an error of a face that failed before it knew the call (CLI-80). */
98
114
  const UNKNOWN_REQUEST: ServerRequest = { id: "unknown", method: { service: "", operation: "" }, payload: undefined, metadata: {} };
99
115
 
116
+ /** The fields of the log line of an error: the request id, the procedure, the transport and the error (CLI-87). */
117
+ function errorFields(request: ServerRequest | undefined, error: unknown): LogFields {
118
+ if (!request) return { error };
119
+ return {
120
+ requestId: request.id,
121
+ procedure: methodName(request.method),
122
+ ...(request.transport !== undefined ? { transport: request.transport } : {}),
123
+ error,
124
+ };
125
+ }
126
+
100
127
  /**
101
128
  * Universal RPC server.
102
129
  *
@@ -247,13 +274,28 @@ export class Server {
247
274
  return exposeError(this.options.errors, error, callOf(request), (real) => this.hiddenErrorLog(real, request), raw);
248
275
  }
249
276
 
250
- /** The logger of the errors that the error policy hides: `onError`, or `console.error`. */
277
+ /**
278
+ * The logger of the server and of its faces: the option `logger`, or undefined without it. A face
279
+ * writes to the console when it is undefined (CLI-87).
280
+ */
281
+ get logger(): Logger | undefined {
282
+ return this.options.logger;
283
+ }
284
+
285
+ /** The third argument of `onError`: the logger of the server, or the console adapter without it. */
286
+ private errorContext(): ServerErrorContext {
287
+ return { logger: this.options.logger ?? CONSOLE_LOGGER };
288
+ }
289
+
290
+ /** The logger of the errors that the error policy hides: `onError` and the logger, or `console.error` without them. */
251
291
  private readonly hiddenErrorLog: HiddenErrorLog = (error, request) => {
252
292
  const real = error instanceof Error ? error : new Error(String(error));
293
+ const { onError, logger } = this.options;
253
294
  // A face that failed before it knew the call gives no request: onError gets a request of no call
254
- if (this.options.onError) this.options.onError(real, request ?? UNKNOWN_REQUEST);
295
+ if (onError) onError(real, request ?? UNKNOWN_REQUEST, this.errorContext());
296
+ if (logger) logger.log("error", "The error policy hid an error", errorFields(request, real));
255
297
  // A constant format: the request id comes from the caller (CodeQL js/tainted-format-string)
256
- else console.error("[Server] hidden error of %s: %o", request?.id ?? "a request", real);
298
+ else if (!onError) console.error("[Server] hidden error of %s: %o", request?.id ?? "a request", real);
257
299
  };
258
300
 
259
301
  /** The response of a request, before the error policy. */
@@ -267,7 +309,7 @@ export class Server {
267
309
 
268
310
  try {
269
311
  // Find handler
270
- const handler = this.findHandler(request.method);
312
+ const handler = this.findHandler(request.method, request);
271
313
  if (!handler) {
272
314
  throw new HandlerNotFoundError(request.method);
273
315
  }
@@ -295,12 +337,12 @@ export class Server {
295
337
 
296
338
  return response;
297
339
  } catch (error) {
298
- // Log error
299
- if (this.options.onError) {
300
- this.options.onError(error as Error, request);
301
- // The error policy does not log this error again (CLI-80)
302
- markLogged(error);
303
- }
340
+ // Log error: to onError and to the logger (CLI-87)
341
+ const { onError, logger } = this.options;
342
+ if (onError) onError(error as Error, request, this.errorContext());
343
+ if (logger) logger.log("error", "A handler failed", errorFields(request, error));
344
+ // The error policy does not log this error again (CLI-80)
345
+ if (onError || logger) markLogged(error);
304
346
 
305
347
  // Convert error to response
306
348
  return this.errorToResponse<TRes>(request, error as Error);
@@ -309,9 +351,10 @@ export class Server {
309
351
 
310
352
  /**
311
353
  * Find handler for a method. A subclass can add handlers that it finds at request time
312
- * (`ProcedureServer` looks up its registry).
354
+ * (`ProcedureServer` looks up its registry). The request is absent for a lookup outside a call.
355
+ * `ProcedureServer` reads the expose rule of its face from it (AG1-B).
313
356
  */
314
- protected findHandler(method: Method): ServerHandler | null {
357
+ protected findHandler(method: Method, _request?: ServerRequest<unknown>): ServerHandler | null {
315
358
  for (const entry of this.handlers) {
316
359
  if (this.matchesMethod(entry.matcher, method)) {
317
360
  return entry.handler;
@@ -8,8 +8,9 @@
8
8
  import type { Elicit } from "../procedures/elicit.js";
9
9
  import type { ReportProgress } from "../procedures/progress.js";
10
10
  import type { Metadata, Status, Method } from "../client/types.js";
11
- import type { CapabilityRequest } from "../procedures/types.js";
11
+ import type { CapabilityRequest, ProcedurePath } from "../procedures/types.js";
12
12
  import type { ExposedError } from "./error-policy.js";
13
+ import type { Logger } from "../logger.js";
13
14
  import { methodName } from "./method.js";
14
15
 
15
16
  // Re-export Method from client types
@@ -75,6 +76,14 @@ export interface ServerRequest<TReq = unknown> {
75
76
  * capability does not become wider.
76
77
  */
77
78
  narrowing?: CapabilityRequest | undefined;
79
+
80
+ /**
81
+ * The expose rule of the face that took the request (AG1-B), for example a read-only face of a
82
+ * browser. The face sets it, not the caller: it is a function, so JSON cannot carry it.
83
+ * `ProcedureServer` meets it with its own expose rule, for the call and for each nested call.
84
+ * Thus it can only narrow. A path outside it gets `NOT_FOUND`, as a path outside the rule of the server.
85
+ */
86
+ expose?: ((path: ProcedurePath) => boolean) | undefined;
78
87
  }
79
88
 
80
89
  /**
@@ -247,6 +256,11 @@ export interface RequestHandler {
247
256
  * it. Absent: the handler examines each call, and the face refuses no request before its calls.
248
257
  */
249
258
  admission?(credentials: Credentials): Promise<Admission>;
259
+ /**
260
+ * The logger of the handler (CLI-87). A face writes its log lines to it, unless the options of the
261
+ * face give a logger. `Server` has it. Absent: the face writes to the console.
262
+ */
263
+ readonly logger?: Logger | undefined;
250
264
  }
251
265
 
252
266
  /**