@procedures/client 0.35.0 → 0.35.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/adapters/http/server/handler.d.ts.map +1 -1
  4. package/dist/adapters/http/server/handler.js +17 -3
  5. package/dist/adapters/http/server/handler.js.map +1 -1
  6. package/dist/adapters/port/transport.d.ts.map +1 -1
  7. package/dist/adapters/port/transport.js +2 -0
  8. package/dist/adapters/port/transport.js.map +1 -1
  9. package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
  10. package/dist/adapters/websocket/server/transport.js +2 -0
  11. package/dist/adapters/websocket/server/transport.js.map +1 -1
  12. package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
  13. package/dist/adapters/websocket/shared/rpc.js +14 -5
  14. package/dist/adapters/websocket/shared/rpc.js.map +1 -1
  15. package/dist/browser.d.ts +3 -1
  16. package/dist/browser.d.ts.map +1 -1
  17. package/dist/browser.js +2 -1
  18. package/dist/browser.js.map +1 -1
  19. package/dist/client/errors/index.d.ts +1 -1
  20. package/dist/client/errors/index.d.ts.map +1 -1
  21. package/dist/client/errors/index.js +1 -1
  22. package/dist/client/errors/index.js.map +1 -1
  23. package/dist/client/errors/registry.d.ts +10 -0
  24. package/dist/client/errors/registry.d.ts.map +1 -1
  25. package/dist/client/errors/registry.js +12 -0
  26. package/dist/client/errors/registry.js.map +1 -1
  27. package/dist/client/index.d.ts +1 -1
  28. package/dist/client/index.d.ts.map +1 -1
  29. package/dist/client/index.js +1 -1
  30. package/dist/client/index.js.map +1 -1
  31. package/dist/procedures/collection/changes.d.ts +15 -0
  32. package/dist/procedures/collection/changes.d.ts.map +1 -1
  33. package/dist/procedures/collection/changes.js.map +1 -1
  34. package/dist/server/error-policy.d.ts +96 -0
  35. package/dist/server/error-policy.d.ts.map +1 -0
  36. package/dist/server/error-policy.js +196 -0
  37. package/dist/server/error-policy.js.map +1 -0
  38. package/dist/server/error-response.d.ts +30 -0
  39. package/dist/server/error-response.d.ts.map +1 -0
  40. package/dist/server/error-response.js +90 -0
  41. package/dist/server/error-response.js.map +1 -0
  42. package/dist/server/index.d.ts +2 -0
  43. package/dist/server/index.d.ts.map +1 -1
  44. package/dist/server/index.js +1 -0
  45. package/dist/server/index.js.map +1 -1
  46. package/dist/server/last-resort.d.ts +17 -0
  47. package/dist/server/last-resort.d.ts.map +1 -0
  48. package/dist/server/last-resort.js +19 -0
  49. package/dist/server/last-resort.js.map +1 -0
  50. package/dist/server/procedure-server.d.ts +3 -11
  51. package/dist/server/procedure-server.d.ts.map +1 -1
  52. package/dist/server/procedure-server.js +7 -42
  53. package/dist/server/procedure-server.js.map +1 -1
  54. package/dist/server/server.d.ts +43 -1
  55. package/dist/server/server.d.ts.map +1 -1
  56. package/dist/server/server.js +39 -2
  57. package/dist/server/server.js.map +1 -1
  58. package/dist/server/types.d.ts +12 -0
  59. package/dist/server/types.d.ts.map +1 -1
  60. package/dist/server/types.js.map +1 -1
  61. package/package.json +1 -1
  62. package/src/adapters/http/server/handler.ts +18 -3
  63. package/src/adapters/port/transport.ts +2 -0
  64. package/src/adapters/websocket/server/transport.ts +2 -0
  65. package/src/adapters/websocket/shared/rpc.ts +14 -5
  66. package/src/browser.ts +4 -0
  67. package/src/client/errors/index.ts +1 -0
  68. package/src/client/errors/registry.ts +13 -0
  69. package/src/client/index.ts +1 -0
  70. package/src/procedures/collection/changes.ts +12 -0
  71. package/src/server/error-policy.ts +263 -0
  72. package/src/server/error-response.ts +91 -0
  73. package/src/server/index.ts +2 -0
  74. package/src/server/last-resort.ts +23 -0
  75. package/src/server/procedure-server.ts +8 -41
  76. package/src/server/server.ts +73 -3
  77. package/src/server/types.ts +13 -0
@@ -0,0 +1,263 @@
1
+ /**
2
+ * The error policy of a server (roadmap CLI-80): what an error tells the caller when it leaves
3
+ * the process.
4
+ *
5
+ * `Server.handle()` applies the policy to each response, so each face of the server (HTTP,
6
+ * WebSocket, MCP, a message port, the local face of a host) sends the same error. When
7
+ * `handle()` throws, or a face fails after it (for example, a value that JSON cannot encode), the
8
+ * last-resort catch of the face applies the same policy through `lastResortError`. A nested call
9
+ * of `ctx.client` does not go through `handle()`, so it keeps the real error.
10
+ *
11
+ * - `expose: "all"` (the default): each error goes to the caller as before.
12
+ * - `expose: "coded"`: an error with a known code (`isKnownErrorCode`, or a code of `codes`) goes
13
+ * to the caller as it is. Each other error goes to the caller as `INTERNAL_ERROR` with the
14
+ * message `"internal error"`, and the server logs it one time.
15
+ * - `map` runs first. Its result goes to the caller as it is. `undefined` gives the error to
16
+ * `expose`.
17
+ *
18
+ * The policy reads the real error of a response through the links of `error-response.ts`: the
19
+ * status object, and the request object with the code and the message of the status. A copy of
20
+ * the response or of its status keeps a link.
21
+ */
22
+
23
+ import type { ProcedurePath } from "../procedures/types.js";
24
+ import { closableUnread } from "../procedures/invoke.js";
25
+ import { isKnownErrorCode } from "../client/errors/registry.js";
26
+ import { errorCodeOf } from "../protocol.js";
27
+ import { errorResponse, realErrorOf } from "./error-response.js";
28
+ import { methodToPath } from "./method.js";
29
+ import { rawErrorOf } from "./last-resort.js";
30
+ export { lastResortError } from "./last-resort.js";
31
+ import { ServerError, type ServerRequest, type ServerResponse } from "./types.js";
32
+
33
+ /** The error that the caller gets: its code, its message and its retryable flag. A `ServerError` is one. */
34
+ export interface ExposedError {
35
+ /** The error code, for example `NOT_FOUND`, or a code of the application. */
36
+ readonly code: string;
37
+ /** The message that the caller gets. */
38
+ readonly message: string;
39
+ /**
40
+ * True when a retry of the same call can succeed. Without it, the error gets the flag of its
41
+ * code before the first item of a stream, and false after it (`retryableOf`).
42
+ */
43
+ readonly retryable?: boolean | undefined;
44
+ /** The wait before a retry, in milliseconds, as on an error of a handler (`Retry-After` on HTTP). */
45
+ readonly retryAfterMs?: number | undefined;
46
+ }
47
+
48
+ /** The call of an error that leaves the server. */
49
+ export interface ErrorCall {
50
+ /** The path of the procedure. Empty when the face failed before it knew the call. */
51
+ readonly path: ProcedurePath;
52
+ /** The face that took the request (`http`, `websocket`, `port`, `mcp`), when the face tells it. */
53
+ readonly transport?: string | undefined;
54
+ }
55
+
56
+ /** The error policy of a server: the option `errors` (roadmap CLI-80). */
57
+ export interface ServerErrorPolicy {
58
+ /**
59
+ * Which errors go to the caller as they are:
60
+ * - `"all"` (the default): each error, with its code and its message, as before.
61
+ * - `"coded"`: only an error with a known code (`isKnownErrorCode`: `NOT_FOUND`, `FORBIDDEN`,
62
+ * `VALIDATION_ERROR`, `CONFLICT` and so on, or a code of `codes`), with its code, its
63
+ * message, its retryable flag and its metadata. Each other error goes to the caller as
64
+ * `INTERNAL_ERROR` with the message `"internal error"` and its real retryable flag, with
65
+ * only the metadata keys of `keepMetadata`. The codes `INTERNAL_ERROR`, `HANDLER_ERROR`, `UNKNOWN`,
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.
69
+ */
70
+ readonly expose?: "all" | "coded" | undefined;
71
+ /**
72
+ * The codes of the application that also go to the caller as they are under `"coded"`, for
73
+ * example `["QUOTA_EXCEEDED"]`.
74
+ */
75
+ readonly codes?: readonly string[] | undefined;
76
+ /**
77
+ * The metadata keys that a hidden error response or a `map` result keeps from the response: an
78
+ * allowlist. Each other key goes, also a key that the server does not know (`issues`, a key of
79
+ * the application). The default is `DEFAULT_KEPT_METADATA`: the trace keys `traceparent` and
80
+ * `tracestate`.
81
+ */
82
+ readonly keepMetadata?: readonly string[] | undefined;
83
+ /**
84
+ * This function runs first, for each error that leaves the server. Its result goes to the
85
+ * caller as it is, also with a code of the application, and with the metadata of an error of a
86
+ * handler (`retryAfterMs`, the conflicts of a `VERSION_CONFLICT` error). `undefined` gives the
87
+ * error to `expose`. A function that throws gives `INTERNAL_ERROR`.
88
+ */
89
+ readonly map?: ((error: unknown, call: ErrorCall) => ExposedError | undefined) | undefined;
90
+ }
91
+
92
+ /** The message of a hidden error. */
93
+ export const HIDDEN_ERROR_MESSAGE = "internal error";
94
+
95
+ /** The codes that tell of a failure of the server, not of a refusal: `"coded"` hides their message. */
96
+ const FAILURE_CODES: ReadonlySet<string> = new Set(["INTERNAL_ERROR", "HANDLER_ERROR", "UNKNOWN", "OUTPUT_VALIDATION_ERROR", "NO_OUTPUT"]);
97
+
98
+ /** The errors that a logger got: each path logs an error one time. */
99
+ const LOGGED = new WeakSet<object>();
100
+
101
+ /**
102
+ * This function records that the logger of the server got this error, so the error policy does
103
+ * not log it again.
104
+ *
105
+ * @internal
106
+ */
107
+ export function markLogged(error: unknown): void {
108
+ if (typeof error === "object" && error !== null) LOGGED.add(error);
109
+ }
110
+
111
+ /** The logger of the hidden errors: the hook `onError` of the server, or `console.error`. */
112
+ export type HiddenErrorLog = (error: unknown, request: ServerRequest<unknown> | undefined) => void;
113
+
114
+ /** This function gives an error to the logger, when the logger did not get it before. */
115
+ function logOnce(log: HiddenErrorLog, error: unknown, request: ServerRequest<unknown> | undefined): void {
116
+ if (typeof error === "object" && error !== null && LOGGED.has(error)) return;
117
+ markLogged(error);
118
+ log(error, request);
119
+ }
120
+
121
+ /** True when the policy can change a response: `"coded"`, or a `map` function. */
122
+ export function policyActs(policy: ServerErrorPolicy | undefined): policy is ServerErrorPolicy {
123
+ return policy !== undefined && (policy.expose === "coded" || policy.map !== undefined);
124
+ }
125
+
126
+ /**
127
+ * The call of a request, for `map`.
128
+ *
129
+ * @internal
130
+ */
131
+ export function callOf(request: ServerRequest<unknown> | undefined): ErrorCall {
132
+ if (request === undefined) return { path: [] };
133
+ let path: ProcedurePath;
134
+ try {
135
+ path = methodToPath(request.method);
136
+ } catch {
137
+ path = [];
138
+ }
139
+ return request.transport !== undefined ? { path, transport: request.transport } : { path };
140
+ }
141
+
142
+ /** The decision of the policy for one error. */
143
+ type Decision =
144
+ | { readonly kind: "keep" }
145
+ | { readonly kind: "replace"; readonly error: ExposedError }
146
+ | { readonly kind: "hide" };
147
+
148
+ /**
149
+ * The decision of the policy for one error. `code` is the code that the error has on the wire. A
150
+ * `map` result is final. `"coded"` keeps an error with a code of `codes`, or with a known code
151
+ * that is not a failure code.
152
+ */
153
+ function decide(policy: ServerErrorPolicy, error: unknown, code: string, call: ErrorCall, logMapError: (error: unknown) => void): Decision {
154
+ if (policy.map) {
155
+ let mapped: ExposedError | undefined;
156
+ try {
157
+ mapped = policy.map(error, call);
158
+ } catch (mapError) {
159
+ logMapError(mapError);
160
+ return { kind: "hide" };
161
+ }
162
+ if (mapped !== undefined) return { kind: "replace", error: mapped };
163
+ }
164
+ if (policy.expose !== "coded") return { kind: "keep" };
165
+ if (policy.codes?.includes(code) === true) return { kind: "keep" };
166
+ if (isKnownErrorCode(code) && !FAILURE_CODES.has(code)) return { kind: "keep" };
167
+ return { kind: "hide" };
168
+ }
169
+
170
+ /** A `map` result as an error: a result that is not an `Error` becomes one, with its fields. */
171
+ function errorOfResult(result: ExposedError): Error {
172
+ return result instanceof Error ? result : Object.assign(new Error(result.message), result);
173
+ }
174
+
175
+ /** The metadata keys that a hidden error response keeps by default: the trace keys (CLI-80). */
176
+ export const DEFAULT_KEPT_METADATA: readonly string[] = ["traceparent", "tracestate"];
177
+
178
+ /** The metadata of a response with only the keys of the allowlist `keepMetadata` of the policy. */
179
+ function keptMetadata(policy: ServerErrorPolicy, metadata: ServerResponse<unknown>["metadata"]): ServerResponse<unknown>["metadata"] {
180
+ const keys = policy.keepMetadata ?? DEFAULT_KEPT_METADATA;
181
+ const kept: Record<string, unknown> = {};
182
+ for (const [key, value] of Object.entries(metadata ?? {})) if (keys.includes(key)) kept[key] = value;
183
+ return kept as ServerResponse<unknown>["metadata"];
184
+ }
185
+
186
+ /**
187
+ * This function applies the error policy to a response that leaves the server: to an error
188
+ * status, and to the error that ends a stream.
189
+ *
190
+ * @internal
191
+ */
192
+ export function exposedResponse<T>(policy: ServerErrorPolicy, request: ServerRequest<unknown>, response: ServerResponse<T>, log: HiddenErrorLog): ServerResponse<T> {
193
+ const status = response.status;
194
+ if (status.type === "error") {
195
+ // A response that the server made without an error (a refusal of the token, a duplicate
196
+ // stream), or a status without the link, gives `map` a ServerError of its status
197
+ const link = realErrorOf(response, request);
198
+ const real = link.found ? link.error : new ServerError(status.message, status.code, status.retryable);
199
+ const decision = decide(policy, real, status.code, callOf(request), (mapError) => logOnce(log, mapError, request));
200
+ if (decision.kind === "keep") return response;
201
+ const { stream: _stream, payload: _payload, ...rest } = response;
202
+ // Only the keys of the allowlist stay (the trace keys): an unknown key can tell of the error
203
+ const kept = keptMetadata(policy, response.metadata);
204
+ if (decision.kind === "replace") {
205
+ // The usual error response of the result: its wait and its conflicts go out too
206
+ const built = errorResponse(response.id, errorOfResult(decision.error));
207
+ return { ...rest, status: built.status, metadata: { ...kept, ...built.metadata } };
208
+ }
209
+ logOnce(log, real, request);
210
+ return {
211
+ ...rest,
212
+ // The retryable flag tells nothing private: a transient failure stays retryable
213
+ status: { type: "error", code: "INTERNAL_ERROR", message: HIDDEN_ERROR_MESSAGE, retryable: status.retryable },
214
+ metadata: kept,
215
+ };
216
+ }
217
+ if (response.stream === undefined) return response;
218
+ return { ...response, stream: exposedStream(policy, request, response.stream, log) };
219
+ }
220
+
221
+ /**
222
+ * The items of a stream, with the error policy for the error that ends it. A kept error goes on
223
+ * unchanged. Another error goes on as an error of its exposed code and message.
224
+ */
225
+ function exposedStream<T>(policy: ServerErrorPolicy, request: ServerRequest<unknown>, items: AsyncIterable<T>, log: HiddenErrorLog): AsyncGenerator<T, void, undefined> {
226
+ const exposed = (async function* () {
227
+ try {
228
+ yield* items;
229
+ } catch (error) {
230
+ const decision = decide(policy, error, errorCodeOf(error), callOf(request), (mapError) => logOnce(log, mapError, request));
231
+ if (decision.kind === "keep") throw error;
232
+ if (decision.kind === "hide") {
233
+ logOnce(log, error, request);
234
+ // The real retryable flag of the error goes on. A last-resort catch that gets this error
235
+ // does not log it again.
236
+ const hidden = new ServerError(HIDDEN_ERROR_MESSAGE, "INTERNAL_ERROR", (error as { retryable?: unknown } | null)?.retryable === true);
237
+ markLogged(hidden);
238
+ throw hidden;
239
+ }
240
+ // Without a flag, the face gives the flag of the code (before the first item) or false
241
+ throw errorOfResult(decision.error);
242
+ }
243
+ })();
244
+ return closableUnread(exposed, items);
245
+ }
246
+
247
+
248
+ /**
249
+ * The error that a last-resort catch sends under a policy: when `handle()` throws, or when a face
250
+ * fails after it. `raw` is the error that the face sends without a policy. `map` runs first. Under
251
+ * `"coded"`, the code of `raw` decides, so `INTERNAL_ERROR` gives `"internal error"`, and the
252
+ * logger gets the real error one time.
253
+ */
254
+ export function exposeError(policy: ServerErrorPolicy | undefined, error: unknown, call: ErrorCall, log: (error: unknown) => void, raw: ExposedError = rawErrorOf(error)): ExposedError {
255
+ if (!policyActs(policy)) return raw;
256
+ const once: HiddenErrorLog = (logged) => log(logged);
257
+ const decision = decide(policy, error, raw.code, call, (mapError) => logOnce(once, mapError, undefined));
258
+ if (decision.kind === "keep") return raw;
259
+ if (decision.kind === "replace") return decision.error;
260
+ logOnce(once, error, undefined);
261
+ return { code: "INTERNAL_ERROR", message: HIDDEN_ERROR_MESSAGE, retryable: raw.retryable === true };
262
+ }
263
+
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The error response of the server, and the link from an error status to its real error (roadmap
3
+ * CLI-80).
4
+ *
5
+ * The error policy of a server (`error-policy.ts`) reads the real error of a response: the error
6
+ * object that a handler threw, of its own class. Two links lead to it, and neither is a field of
7
+ * the response, so the wire, a log of the response and a deep comparison never see them:
8
+ * - The status object of the error response. A copy of the response (`{ ...response, id }`) keeps
9
+ * the same status.
10
+ * - The request object of the call, with the code and the message of the status. A copy of the
11
+ * status (`{ ...response.status }`, for example in a middleware or a wrapper) keeps the code
12
+ * and the message, so the link holds. A status with another code or message is another error,
13
+ * and has no link.
14
+ * - Without a link (a middleware copied both the request and the status, or made a new error),
15
+ * the policy gives `map` a `ServerError` of the code, the message and the retryable flag of the
16
+ * status.
17
+ */
18
+
19
+ import { InvocationError } from "../procedures/invoke.js";
20
+ import { VERSION_CONFLICTS_METADATA_KEY, versionConflictsOf } from "../procedures/collection/precondition.js";
21
+ import { errorCodeOf, retryableOf } from "../protocol.js";
22
+ import type { ServerResponse } from "./types.js";
23
+
24
+ /** The real error of each error status that the server made from an error. */
25
+ const ERRORS_OF_STATUSES = new WeakMap<object, unknown>();
26
+
27
+ /** The last real error of each request, with the code and the message of its status. */
28
+ const ERRORS_OF_REQUESTS = new WeakMap<object, { readonly code: string; readonly message: string; readonly error: unknown }>();
29
+
30
+ /**
31
+ * This function records the real error of an error response, for the error policy: by its status
32
+ * object, and by the request object of the call. The response does not change.
33
+ *
34
+ * @internal
35
+ */
36
+ export function withRealError<T extends ServerResponse<unknown>>(response: T, error: unknown, request?: object): T {
37
+ ERRORS_OF_STATUSES.set(response.status, error);
38
+ const status = response.status;
39
+ if (request !== undefined && status.type === "error") ERRORS_OF_REQUESTS.set(request, { code: String(status.code), message: status.message, error });
40
+ return response;
41
+ }
42
+
43
+ /**
44
+ * The real error of an error status of a request, or `{ found: false }` when no link leads to it.
45
+ *
46
+ * @internal
47
+ */
48
+ export function realErrorOf(response: ServerResponse<unknown>, request?: object): { readonly found: true; readonly error: unknown } | { readonly found: false } {
49
+ const status = response.status;
50
+ if (ERRORS_OF_STATUSES.has(status)) return { found: true, error: ERRORS_OF_STATUSES.get(status) };
51
+ const recorded = request !== undefined ? ERRORS_OF_REQUESTS.get(request) : undefined;
52
+ if (recorded !== undefined && status.type === "error" && recorded.code === String(status.code) && recorded.message === status.message) return { found: true, error: recorded.error };
53
+ return { found: false };
54
+ }
55
+
56
+ /**
57
+ * The error response of a failed invocation. An invocation error keeps its code
58
+ * (VALIDATION_ERROR, NOT_EXPOSED, ABORTED and so on). An error of the handler keeps its own code
59
+ * when it is a string, as for a stream (`WIRE-PROTOCOL.md`). Without a code, it is HANDLER_ERROR.
60
+ * An error without a retryable flag gets the flag of its code in `ERROR_CODES`, or false (CLI-48).
61
+ * An in-band end before the first item of a stream gets the same default. After the first item,
62
+ * an in-band end without a flag gets false (`retryableOf`, CLI-58, CLI-68, `WIRE-PROTOCOL.md`,
63
+ * section "Error codes"). `request` is the request of the call: the link of the error policy to the
64
+ * real error (CLI-80).
65
+ */
66
+ export function errorResponse(id: string, error: unknown, request?: object): ServerResponse<unknown> {
67
+ // A refusal of the capability says why (CAPABILITIES-2026-10)
68
+ const reason = error instanceof InvocationError ? error.reason : undefined;
69
+ // An error can ask the client to wait before a retry: retryAfterMs, in milliseconds (R12)
70
+ const retryAfterMs = (error as { retryAfterMs?: unknown } | undefined)?.retryAfterMs;
71
+ const metadata: Record<string, unknown> = reason ? { reason } : {};
72
+ if (typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) && retryAfterMs >= 0) metadata["retry-after"] = retryAfterMs;
73
+ // A VERSION_CONFLICT error tells the current item of each conflict (roadmap CLI-16)
74
+ const conflicts = versionConflictsOf(error);
75
+ if (conflicts !== undefined) metadata[VERSION_CONFLICTS_METADATA_KEY] = conflicts;
76
+ const code = errorCodeOf(error);
77
+ // The error policy of the server (CLI-80) reads the real error of the response
78
+ return withRealError({
79
+ id,
80
+ status: {
81
+ type: "error",
82
+ code,
83
+ message: error instanceof Error ? error.message : String(error),
84
+ // An error that says it is retryable stays retryable: before, every error lost the flag (deep
85
+ // dive TRN-7). Without a flag, the default of the code: UNAVAILABLE stays retryable, and
86
+ // keeps HTTP 503 (CLI-48). No item went out before this response.
87
+ retryable: retryableOf(error, 0),
88
+ },
89
+ metadata,
90
+ }, error, request);
91
+ }
@@ -40,6 +40,8 @@
40
40
 
41
41
  export { Server } from "./server.js";
42
42
  export type { ServerOptions } from "./server.js";
43
+ export type { ServerErrorPolicy, ExposedError, ErrorCall } from "./error-policy.js";
44
+ export { exposeError, lastResortError, HIDDEN_ERROR_MESSAGE, DEFAULT_KEPT_METADATA } from "./error-policy.js";
43
45
 
44
46
  export { ProcedureServer, createProcedureServer, STREAM_METADATA_KEY, isUnboundedStream } from "./procedure-server.js";
45
47
  export type { ProcedureServerOptions, RegisterProcedureOptions, StorageConfig, StopOptions, StopParts, StorageHandle, UseStorageOptions } from "./procedure-server.js";
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The last-resort catch of a face (roadmap CLI-80): when `handle()` throws, or the face fails after
3
+ * it. This module has no runtime import, so a face in a browser bundle (for example
4
+ * `PortTransport`) does not load the error policy and its imports. The policy itself runs in the
5
+ * server, through `RequestHandler.exposeError`.
6
+ */
7
+
8
+ import type { ExposedError } from "./error-policy.js";
9
+ import type { RequestHandler, ServerRequest } from "./types.js";
10
+
11
+ /** The error that a last-resort catch sends without a policy: `INTERNAL_ERROR` with the message of the error. */
12
+ export function rawErrorOf(error: unknown): ExposedError {
13
+ return { code: "INTERNAL_ERROR", message: error instanceof Error ? error.message || "Internal server error" : String(error), retryable: false };
14
+ }
15
+
16
+ /**
17
+ * The error that the last-resort catch of a face sends: the error policy of the handler
18
+ * (`RequestHandler.exposeError`, which `Server` has), or `raw` for a handler without one. `raw`
19
+ * is the error that the face sends without a policy.
20
+ */
21
+ export function lastResortError(handler: RequestHandler, error: unknown, request: ServerRequest<unknown> | undefined, raw: ExposedError = rawErrorOf(error)): ExposedError {
22
+ return handler.exposeError ? handler.exposeError(error, request, raw) : raw;
23
+ }
@@ -6,6 +6,9 @@
6
6
  */
7
7
 
8
8
  import { Server, type ServerOptions } from "./server.js";
9
+ import { errorResponse } from "./error-response.js";
10
+
11
+ export { errorResponse };
9
12
  import type { Admission, AmbientCredentials, Credentials, Method, Principal, PrincipalOf, RequestIdentity, ServerHandler, ServerRequest, ServerResponse } from "./types.js";
10
13
  import { methodToPath, pathToMethod } from "./method.js";
11
14
  import { isLocalCredentials } from "./request-auth.js";
@@ -21,14 +24,13 @@ import { createCollectionProcedures } from "../procedures/collection/procedures.
21
24
  import { InvocationError, closableUnread, invokeProcedure, type AdmitItem, type Interceptor, type Invocation } from "../procedures/invoke.js";
22
25
  import type { Capability, ClearanceOrder, ProcedureContext, ResourcesOf } from "../procedures/types.js";
23
26
  import type { CollectionPartitions } from "../procedures/collection/partition.js";
24
- import { VERSION_CONFLICTS_METADATA_KEY, versionConflictsOf } from "../procedures/collection/precondition.js";
25
27
  import { TIMEOUT_METADATA_KEY, effectsOf, joinNarrowing, narrowCapability } from "../procedures/capability.js";
26
28
  import { IdempotencyCache, idempotencyKeyOf, isRepeatable, type IdempotencyOptions } from "../procedures/idempotency.js";
27
29
  import { optionalMs, type Duration } from "../duration.js";
28
30
  import type { CollectionStorage } from "../procedures/collection/storage.js";
29
31
  import { TIMER_DEFAULTS } from "../timers.js";
30
32
  import { realClock, type Clock } from "../clock.js";
31
- import { errorCodeOf, retryableOf } from "../protocol.js";
33
+ import { retryableOf } from "../protocol.js";
32
34
  import { EventPublisher, type EventPublisherOptions } from "../events/publisher.js";
33
35
  import { getGlobalEventBus } from "../events/bus.js";
34
36
  import type { CollectionProceduresOptions } from "../procedures/collection/procedures.js";
@@ -382,41 +384,6 @@ function withRequestTimeout(
382
384
  return narrowCapability(capability, { timeoutMs }, order);
383
385
  }
384
386
 
385
- /**
386
- * The error response of a failed invocation. An invocation error keeps its code
387
- * (VALIDATION_ERROR, NOT_EXPOSED, ABORTED and so on). An error of the handler keeps its own code
388
- * when it is a string, as for a stream (`WIRE-PROTOCOL.md`). Without a code, it is HANDLER_ERROR.
389
- * An error without a retryable flag gets the flag of its code in `ERROR_CODES`, or false (CLI-48).
390
- * An in-band end before the first item of a stream gets the same default. After the first item,
391
- * an in-band end without a flag gets false (`retryableOf`, CLI-58, CLI-68, `WIRE-PROTOCOL.md`,
392
- * section "Error codes").
393
- */
394
- export function errorResponse(id: string, error: unknown): ServerResponse<unknown> {
395
- // A refusal of the capability says why (CAPABILITIES-2026-10)
396
- const reason = error instanceof InvocationError ? error.reason : undefined;
397
- // An error can ask the client to wait before a retry: retryAfterMs, in milliseconds (R12)
398
- const retryAfterMs = (error as { retryAfterMs?: unknown } | undefined)?.retryAfterMs;
399
- const metadata: Record<string, unknown> = reason ? { reason } : {};
400
- if (typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) && retryAfterMs >= 0) metadata["retry-after"] = retryAfterMs;
401
- // A VERSION_CONFLICT error tells the current item of each conflict (roadmap CLI-16)
402
- const conflicts = versionConflictsOf(error);
403
- if (conflicts !== undefined) metadata[VERSION_CONFLICTS_METADATA_KEY] = conflicts;
404
- const code = errorCodeOf(error);
405
- return {
406
- id,
407
- status: {
408
- type: "error",
409
- code,
410
- message: error instanceof Error ? error.message : String(error),
411
- // An error that says it is retryable stays retryable: before, every error lost the flag (deep
412
- // dive TRN-7). Without a flag, the default of the code: UNAVAILABLE stays retryable, and
413
- // keeps HTTP 503 (CLI-48). No item went out before this response.
414
- retryable: retryableOf(error, 0),
415
- },
416
- metadata,
417
- };
418
- }
419
-
420
387
  // =============================================================================
421
388
  // Procedure Server
422
389
  // =============================================================================
@@ -977,7 +944,7 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
977
944
  return refused ? rateLimitedResponse(request.id, refused) : undefined;
978
945
  } catch (error) {
979
946
  const message = `The limits of the server cannot admit the call: ${error instanceof Error ? error.message : String(error)}`;
980
- return errorResponse(request.id, Object.assign(new Error(message), { code: "INTERNAL_ERROR" }));
947
+ return errorResponse(request.id, Object.assign(new Error(message), { code: "INTERNAL_ERROR" }), request);
981
948
  }
982
949
  }
983
950
 
@@ -1083,7 +1050,7 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
1083
1050
  );
1084
1051
  }
1085
1052
  const response = await untilAborted(result, request.signal);
1086
- if (response === undefined) return errorResponse(request.id, new InvocationError("ABORTED", "The request was aborted", procedure.path));
1053
+ if (response === undefined) return errorResponse(request.id, new InvocationError("ABORTED", "The request was aborted", procedure.path), request);
1087
1054
  if (response.stream) {
1088
1055
  return {
1089
1056
  id: request.id,
@@ -1138,7 +1105,7 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
1138
1105
  capability = this.capabilityOf(request);
1139
1106
  } catch (error) {
1140
1107
  const message = `The server cannot narrow the capability of the request: ${error instanceof Error ? error.message : String(error)}`;
1141
- return errorResponse(request.id, Object.assign(new Error(message), { code: "INTERNAL_ERROR" }));
1108
+ return errorResponse(request.id, Object.assign(new Error(message), { code: "INTERNAL_ERROR" }), request);
1142
1109
  }
1143
1110
  const output = await invokeProcedure(procedure, request.payload, {
1144
1111
  registry: this.procedureRegistry,
@@ -1175,7 +1142,7 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
1175
1142
  }
1176
1143
  return { id: request.id, status: { type: "success", code: 200 }, payload: output.value, metadata: {} };
1177
1144
  } catch (error) {
1178
- return errorResponse(request.id, error);
1145
+ return errorResponse(request.id, error, request);
1179
1146
  } finally {
1180
1147
  if (!streams) release();
1181
1148
  }
@@ -17,6 +17,8 @@ import type {
17
17
  } from "./types.js";
18
18
  import { HandlerNotFoundError, ServerError } from "./types.js";
19
19
  import { compose } from "../middleware/index.js";
20
+ import { callOf, exposeError, exposedResponse, markLogged, policyActs, type ExposedError, type HiddenErrorLog, type ServerErrorPolicy } from "./error-policy.js";
21
+ import { withRealError } from "./error-response.js";
20
22
 
21
23
  /**
22
24
  * Server configuration options.
@@ -30,7 +32,9 @@ export interface ServerOptions {
30
32
 
31
33
  /**
32
34
  * Global error handler.
33
- * Called when a handler throws an error.
35
+ * Called when a handler throws an error. The error policy (`errors`) also gives it each hidden
36
+ * 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"`.
34
38
  */
35
39
  onError?: (error: Error, request: ServerRequest) => void;
36
40
 
@@ -43,6 +47,33 @@ export interface ServerOptions {
43
47
  * Response logger.
44
48
  */
45
49
  onResponse?: (response: ServerResponse) => void;
50
+
51
+ /**
52
+ * The error policy (roadmap CLI-80): what an error tells the caller when it leaves the server.
53
+ * `handle()` applies it, so each face (HTTP, WebSocket, MCP, a message port) sends the same
54
+ * error. A nested call of `ctx.client` keeps the real error.
55
+ *
56
+ * - `expose: "all"` (the default): each error goes to the caller as before.
57
+ * - `expose: "coded"`: an error with a known code (`isKnownErrorCode`) goes to the caller as it
58
+ * is, and so does an error with a code of `codes`. Each other error goes as `INTERNAL_ERROR`
59
+ * 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`.
61
+ * - `map` runs first, with the real error of its own class. Its result goes to the caller as it
62
+ * is, with its `retryAfterMs`. `undefined` gives the error to `expose`.
63
+ * - The last-resort catch of each face applies the same policy (`exposeError`).
64
+ *
65
+ * @example
66
+ * ```typescript
67
+ * new ProcedureServer({
68
+ * errors: {
69
+ * expose: "coded",
70
+ * map: (error) => (isDuplicateKey(error) ? new ServerError("The item exists", "CONFLICT") : undefined),
71
+ * },
72
+ * onError: (error, request) => logger.error({ err: error, id: request.id }),
73
+ * });
74
+ * ```
75
+ */
76
+ errors?: ServerErrorPolicy | undefined;
46
77
  }
47
78
 
48
79
  /**
@@ -63,6 +94,9 @@ interface HandlerEntry {
63
94
  handler: ServerHandler;
64
95
  }
65
96
 
97
+ /** The request that `onError` gets for an error of a face that failed before it knew the call (CLI-80). */
98
+ const UNKNOWN_REQUEST: ServerRequest = { id: "unknown", method: { service: "", operation: "" }, payload: undefined, metadata: {} };
99
+
66
100
  /**
67
101
  * Universal RPC server.
68
102
  *
@@ -196,6 +230,35 @@ export class Server {
196
230
  */
197
231
  async handle<TReq, TRes>(
198
232
  request: ServerRequest<TReq>
233
+ ): Promise<ServerResponse<TRes>> {
234
+ const policy = this.options.errors;
235
+ if (!policyActs(policy)) return this.handleRaw(request);
236
+ const response = await this.handleRaw<TReq, TRes>(request);
237
+ return exposedResponse(policy, request, response, this.hiddenErrorLog);
238
+ }
239
+
240
+ /**
241
+ * The error that the last-resort catch of a face sends (roadmap CLI-80): when `handle()` throws,
242
+ * or the face fails after it. `raw` is the error that the face sends without a policy. Under the
243
+ * option `errors`, `map` runs first, and `"coded"` hides the message of `INTERNAL_ERROR`. The
244
+ * logger gets each hidden error one time. A face calls it through `lastResortError`.
245
+ */
246
+ exposeError(error: unknown, request: ServerRequest | undefined, raw: ExposedError): ExposedError {
247
+ return exposeError(this.options.errors, error, callOf(request), (real) => this.hiddenErrorLog(real, request), raw);
248
+ }
249
+
250
+ /** The logger of the errors that the error policy hides: `onError`, or `console.error`. */
251
+ private readonly hiddenErrorLog: HiddenErrorLog = (error, request) => {
252
+ const real = error instanceof Error ? error : new Error(String(error));
253
+ // 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);
255
+ // 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);
257
+ };
258
+
259
+ /** The response of a request, before the error policy. */
260
+ private async handleRaw<TReq, TRes>(
261
+ request: ServerRequest<TReq>
199
262
  ): Promise<ServerResponse<TRes>> {
200
263
  // Log request
201
264
  if (this.options.onRequest) {
@@ -235,10 +298,12 @@ export class Server {
235
298
  // Log error
236
299
  if (this.options.onError) {
237
300
  this.options.onError(error as Error, request);
301
+ // The error policy does not log this error again (CLI-80)
302
+ markLogged(error);
238
303
  }
239
304
 
240
305
  // Convert error to response
241
- return this.errorToResponse<TRes>(request.id, error as Error);
306
+ return this.errorToResponse<TRes>(request, error as Error);
242
307
  }
243
308
  }
244
309
 
@@ -320,7 +385,12 @@ export class Server {
320
385
  /**
321
386
  * Convert error to response.
322
387
  */
323
- private errorToResponse<TRes>(id: string, error: Error): ServerResponse<TRes> {
388
+ private errorToResponse<TRes>(request: ServerRequest<unknown>, error: Error): ServerResponse<TRes> {
389
+ return withRealError(this.errorToStatus<TRes>(request.id, error), error, request);
390
+ }
391
+
392
+ /** The response of an error, without its record for the error policy. */
393
+ private errorToStatus<TRes>(id: string, error: Error): ServerResponse<TRes> {
324
394
  if (error instanceof HandlerNotFoundError) {
325
395
  return {
326
396
  id,
@@ -9,6 +9,7 @@ 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
11
  import type { CapabilityRequest } from "../procedures/types.js";
12
+ import type { ExposedError } from "./error-policy.js";
12
13
  import { methodName } from "./method.js";
13
14
 
14
15
  // Re-export Method from client types
@@ -47,6 +48,12 @@ export interface ServerRequest<TReq = unknown> {
47
48
  /** Abort signal for cancellation */
48
49
  signal?: AbortSignal;
49
50
 
51
+ /**
52
+ * The face that took the request: `"http"`, `"websocket"`, `"port"` or `"mcp"` (roadmap CLI-80). The face
53
+ * sets it, not the caller. The `map` function of the error policy (`errors`) gets it.
54
+ */
55
+ transport?: string | undefined;
56
+
50
57
  /**
51
58
  * The credentials of the request (roadmap R18). A network face reads them from the request: the
52
59
  * bearer token of an `Authorization` header or of the `token` query of a WebSocket connection,
@@ -213,6 +220,12 @@ export interface ServerDebugReport {
213
220
  */
214
221
  export interface RequestHandler {
215
222
  handle(request: ServerRequest): Promise<ServerResponse>;
223
+ /**
224
+ * The error that the last-resort catch of a face sends, when `handle()` throws or the face fails
225
+ * after it (roadmap CLI-80): `raw` under the error policy of the handler. `Server` has it, and
226
+ * a face calls it through `lastResortError`. Absent: the face sends `raw`.
227
+ */
228
+ exposeError?(error: unknown, request: ServerRequest | undefined, raw: ExposedError): ExposedError;
216
229
  /**
217
230
  * How the handler can authenticate a network request with ambient credentials: the cookies that
218
231
  * a browser sends by itself, also for a request of another site (CSRF). A network face then