@procedures/client 0.8.0 → 0.9.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 (197) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +95 -8
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/adapters/http/client/transport.d.ts +4 -0
  5. package/dist/adapters/http/client/transport.d.ts.map +1 -1
  6. package/dist/adapters/http/client/transport.js +11 -1
  7. package/dist/adapters/http/client/transport.js.map +1 -1
  8. package/dist/adapters/http/client/types.d.ts +19 -0
  9. package/dist/adapters/http/client/types.d.ts.map +1 -1
  10. package/dist/adapters/http/server/cors.d.ts +64 -0
  11. package/dist/adapters/http/server/cors.d.ts.map +1 -0
  12. package/dist/adapters/http/server/cors.js +113 -0
  13. package/dist/adapters/http/server/cors.js.map +1 -0
  14. package/dist/adapters/http/server/handler.d.ts +136 -0
  15. package/dist/adapters/http/server/handler.d.ts.map +1 -0
  16. package/dist/adapters/http/server/handler.js +495 -0
  17. package/dist/adapters/http/server/handler.js.map +1 -0
  18. package/dist/adapters/http/server/strategies.d.ts +14 -5
  19. package/dist/adapters/http/server/strategies.d.ts.map +1 -1
  20. package/dist/adapters/http/server/strategies.js.map +1 -1
  21. package/dist/adapters/http/server/transport.d.ts +30 -46
  22. package/dist/adapters/http/server/transport.d.ts.map +1 -1
  23. package/dist/adapters/http/server/transport.js +48 -432
  24. package/dist/adapters/http/server/transport.js.map +1 -1
  25. package/dist/adapters/http/server/types.d.ts +13 -0
  26. package/dist/adapters/http/server/types.d.ts.map +1 -1
  27. package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
  28. package/dist/adapters/websocket/client/transport.js +4 -12
  29. package/dist/adapters/websocket/client/transport.js.map +1 -1
  30. package/dist/adapters/websocket/server/transport.d.ts +46 -1
  31. package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
  32. package/dist/adapters/websocket/server/transport.js +152 -15
  33. package/dist/adapters/websocket/server/transport.js.map +1 -1
  34. package/dist/adapters/websocket/server/types.d.ts +10 -3
  35. package/dist/adapters/websocket/server/types.d.ts.map +1 -1
  36. package/dist/adapters/websocket/server/upgrade-router.d.ts +28 -0
  37. package/dist/adapters/websocket/server/upgrade-router.d.ts.map +1 -0
  38. package/dist/adapters/websocket/server/upgrade-router.js +62 -0
  39. package/dist/adapters/websocket/server/upgrade-router.js.map +1 -0
  40. package/dist/adapters/websocket/server/upgrade.d.ts +68 -0
  41. package/dist/adapters/websocket/server/upgrade.d.ts.map +1 -0
  42. package/dist/adapters/websocket/server/upgrade.js +49 -0
  43. package/dist/adapters/websocket/server/upgrade.js.map +1 -0
  44. package/dist/adapters/websocket/shared/rpc.d.ts +2 -0
  45. package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
  46. package/dist/adapters/websocket/shared/rpc.js +8 -2
  47. package/dist/adapters/websocket/shared/rpc.js.map +1 -1
  48. package/dist/browser.d.ts +12 -4
  49. package/dist/browser.d.ts.map +1 -1
  50. package/dist/browser.js +13 -2
  51. package/dist/browser.js.map +1 -1
  52. package/dist/client/client.d.ts +43 -0
  53. package/dist/client/client.d.ts.map +1 -1
  54. package/dist/client/client.js +69 -1
  55. package/dist/client/client.js.map +1 -1
  56. package/dist/events/publisher.d.ts +25 -1
  57. package/dist/events/publisher.d.ts.map +1 -1
  58. package/dist/events/publisher.js +66 -9
  59. package/dist/events/publisher.js.map +1 -1
  60. package/dist/events/rows.d.ts +31 -0
  61. package/dist/events/rows.d.ts.map +1 -0
  62. package/dist/events/rows.js +39 -0
  63. package/dist/events/rows.js.map +1 -0
  64. package/dist/index.d.ts +1 -1
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +1 -1
  67. package/dist/index.js.map +1 -1
  68. package/dist/node.d.ts +14 -0
  69. package/dist/node.d.ts.map +1 -1
  70. package/dist/node.js +9 -0
  71. package/dist/node.js.map +1 -1
  72. package/dist/procedures/capability.d.ts +6 -0
  73. package/dist/procedures/capability.d.ts.map +1 -1
  74. package/dist/procedures/capability.js +25 -0
  75. package/dist/procedures/capability.js.map +1 -1
  76. package/dist/procedures/collection/changes.d.ts +94 -0
  77. package/dist/procedures/collection/changes.d.ts.map +1 -0
  78. package/dist/procedures/collection/changes.js +182 -0
  79. package/dist/procedures/collection/changes.js.map +1 -0
  80. package/dist/procedures/collection/index.d.ts +10 -3
  81. package/dist/procedures/collection/index.d.ts.map +1 -1
  82. package/dist/procedures/collection/index.js +8 -1
  83. package/dist/procedures/collection/index.js.map +1 -1
  84. package/dist/procedures/collection/partition.d.ts +67 -0
  85. package/dist/procedures/collection/partition.d.ts.map +1 -0
  86. package/dist/procedures/collection/partition.js +82 -0
  87. package/dist/procedures/collection/partition.js.map +1 -0
  88. package/dist/procedures/collection/procedures.d.ts +39 -2
  89. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  90. package/dist/procedures/collection/procedures.js +147 -44
  91. package/dist/procedures/collection/procedures.js.map +1 -1
  92. package/dist/procedures/collection/query.d.ts +246 -0
  93. package/dist/procedures/collection/query.d.ts.map +1 -0
  94. package/dist/procedures/collection/query.js +854 -0
  95. package/dist/procedures/collection/query.js.map +1 -0
  96. package/dist/procedures/collection/schemas.d.ts +50 -0
  97. package/dist/procedures/collection/schemas.d.ts.map +1 -1
  98. package/dist/procedures/collection/schemas.js +144 -6
  99. package/dist/procedures/collection/schemas.js.map +1 -1
  100. package/dist/procedures/collection/storage.d.ts +58 -6
  101. package/dist/procedures/collection/storage.d.ts.map +1 -1
  102. package/dist/procedures/collection/storage.js +23 -0
  103. package/dist/procedures/collection/storage.js.map +1 -1
  104. package/dist/procedures/elicit.d.ts +190 -0
  105. package/dist/procedures/elicit.d.ts.map +1 -0
  106. package/dist/procedures/elicit.js +127 -0
  107. package/dist/procedures/elicit.js.map +1 -0
  108. package/dist/procedures/hints.d.ts +43 -0
  109. package/dist/procedures/hints.d.ts.map +1 -0
  110. package/dist/procedures/hints.js +74 -0
  111. package/dist/procedures/hints.js.map +1 -0
  112. package/dist/procedures/index.d.ts +9 -2
  113. package/dist/procedures/index.d.ts.map +1 -1
  114. package/dist/procedures/index.js +5 -1
  115. package/dist/procedures/index.js.map +1 -1
  116. package/dist/procedures/invoke.d.ts +24 -2
  117. package/dist/procedures/invoke.d.ts.map +1 -1
  118. package/dist/procedures/invoke.js +14 -1
  119. package/dist/procedures/invoke.js.map +1 -1
  120. package/dist/procedures/progress.d.ts +24 -0
  121. package/dist/procedures/progress.d.ts.map +1 -0
  122. package/dist/procedures/progress.js +11 -0
  123. package/dist/procedures/progress.js.map +1 -0
  124. package/dist/procedures/registry.d.ts.map +1 -1
  125. package/dist/procedures/registry.js +5 -0
  126. package/dist/procedures/registry.js.map +1 -1
  127. package/dist/procedures/types.d.ts +55 -0
  128. package/dist/procedures/types.d.ts.map +1 -1
  129. package/dist/procedures/types.js.map +1 -1
  130. package/dist/procedures/untrusted.d.ts +28 -0
  131. package/dist/procedures/untrusted.d.ts.map +1 -0
  132. package/dist/procedures/untrusted.js +32 -0
  133. package/dist/procedures/untrusted.js.map +1 -0
  134. package/dist/server/browser-guard.d.ts +12 -2
  135. package/dist/server/browser-guard.d.ts.map +1 -1
  136. package/dist/server/browser-guard.js +43 -6
  137. package/dist/server/browser-guard.js.map +1 -1
  138. package/dist/server/host-policy.d.ts +10 -1
  139. package/dist/server/host-policy.d.ts.map +1 -1
  140. package/dist/server/host-policy.js +5 -0
  141. package/dist/server/host-policy.js.map +1 -1
  142. package/dist/server/index.d.ts +1 -1
  143. package/dist/server/index.d.ts.map +1 -1
  144. package/dist/server/index.js.map +1 -1
  145. package/dist/server/procedure-server.d.ts +68 -7
  146. package/dist/server/procedure-server.d.ts.map +1 -1
  147. package/dist/server/procedure-server.js +124 -24
  148. package/dist/server/procedure-server.js.map +1 -1
  149. package/dist/server/request-auth.d.ts +51 -4
  150. package/dist/server/request-auth.d.ts.map +1 -1
  151. package/dist/server/request-auth.js +116 -5
  152. package/dist/server/request-auth.js.map +1 -1
  153. package/dist/server/types.d.ts +73 -5
  154. package/dist/server/types.d.ts.map +1 -1
  155. package/dist/server/types.js.map +1 -1
  156. package/package.json +7 -3
  157. package/src/adapters/http/client/transport.ts +11 -1
  158. package/src/adapters/http/client/types.ts +22 -0
  159. package/src/adapters/http/server/cors.ts +144 -0
  160. package/src/adapters/http/server/handler.ts +624 -0
  161. package/src/adapters/http/server/strategies.ts +16 -6
  162. package/src/adapters/http/server/transport.ts +55 -478
  163. package/src/adapters/http/server/types.ts +15 -0
  164. package/src/adapters/websocket/client/transport.ts +4 -12
  165. package/src/adapters/websocket/server/transport.ts +166 -16
  166. package/src/adapters/websocket/server/types.ts +11 -3
  167. package/src/adapters/websocket/server/upgrade-router.ts +77 -0
  168. package/src/adapters/websocket/server/upgrade.ts +88 -0
  169. package/src/adapters/websocket/shared/rpc.ts +9 -2
  170. package/src/browser.ts +85 -2
  171. package/src/client/client.ts +83 -1
  172. package/src/events/publisher.ts +75 -11
  173. package/src/events/rows.ts +48 -0
  174. package/src/index.ts +1 -1
  175. package/src/node.ts +21 -0
  176. package/src/procedures/capability.ts +21 -0
  177. package/src/procedures/collection/changes.ts +245 -0
  178. package/src/procedures/collection/index.ts +57 -1
  179. package/src/procedures/collection/partition.ts +123 -0
  180. package/src/procedures/collection/procedures.ts +206 -51
  181. package/src/procedures/collection/query.ts +1009 -0
  182. package/src/procedures/collection/schemas.ts +196 -7
  183. package/src/procedures/collection/storage.ts +78 -6
  184. package/src/procedures/elicit.ts +320 -0
  185. package/src/procedures/hints.ts +91 -0
  186. package/src/procedures/index.ts +83 -2
  187. package/src/procedures/invoke.ts +33 -2
  188. package/src/procedures/progress.ts +25 -0
  189. package/src/procedures/registry.ts +4 -0
  190. package/src/procedures/types.ts +65 -0
  191. package/src/procedures/untrusted.ts +50 -0
  192. package/src/server/browser-guard.ts +48 -6
  193. package/src/server/host-policy.ts +13 -1
  194. package/src/server/index.ts +3 -0
  195. package/src/server/procedure-server.ts +147 -26
  196. package/src/server/request-auth.ts +128 -6
  197. package/src/server/types.ts +82 -5
@@ -1,44 +1,22 @@
1
1
  /**
2
- * HTTP Server Transport Implementation
2
+ * The HTTP server transport of Express: a shim over `procedureHandler` (CLI-3).
3
3
  *
4
- * Adapts Express HTTP server to unified RPC format.
5
- * Converts HTTP requests to ServerRequest and ServerResponse back to HTTP.
4
+ * The transport mounts the procedure handler of `handler.ts` on an Express app, with the CORS of
5
+ * the app, and it owns its own `node:http` server unless the caller gives one. The handler does
6
+ * the work: the request, the body, the metadata, the credentials, the response and the NDJSON
7
+ * streams. The transport keeps its former error codes (`SERVER_STOPPED` and the codes of the
8
+ * browser checks), so its behavior stays the same.
6
9
  */
7
10
 
8
11
  import type { Request, Response, Express } from "express";
9
12
  import { createServer } from "http";
10
13
  import type { Server as HttpServer } from "http";
11
- import type { ServerTransport, ServerRequest, ServerResponse } from "../../../server/types.js";
12
- import type { Metadata } from "../../../client/types.js";
14
+ import type { ServerTransport } from "../../../server/types.js";
13
15
  import type { RequestHandler } from "../../../server/types.js";
14
- import { HTTPMethod, HTTPHeaders, decodeMetadataHeader, METADATA_HEADER_LIMIT } from "../shared/index.js";
15
- import { ERROR_REGISTRY } from "../../../client/errors/index.js";
16
16
  import type { HttpServerTransportOptions } from "./types.js";
17
17
  import { createPatternServerUrlStrategy } from "./strategies.js";
18
- import { checkBrowserRequest, type BrowserGuardOptions } from "../../../server/browser-guard.js";
19
- import { bearerOf, withoutSecretHeaders } from "../../../server/request-auth.js";
20
- import { PROTOCOL_HEADER, PROTOCOL_VERSION, httpStatusOf, protocolVersionOf } from "../../../protocol.js";
21
- import { realClock, type Clock } from "../../../clock.js";
22
- import { ms, type Duration } from "../../../duration.js";
23
- import { NDJSON_MEDIA_TYPE as NDJSON, defaultNdjsonError as errorOf, keepAliveMsOf, writeNdjsonStream } from "./ndjson.js";
24
-
25
- /** True when a stream response can run without an end (`STREAM_METADATA_KEY` of procedure-server.ts). */
26
- function isUnboundedStream(response: { metadata?: Record<string, unknown> | undefined }): boolean {
27
- return response.metadata?.["stream"] === "unbounded";
28
- }
29
-
30
- /** The metadata keys that the transport sets: a query parameter or a header cannot replace them. */
31
- const RESERVED_METADATA = new Set(["headers", "query", "params"]);
32
-
33
- /** The error of a request body that the transport cannot read. */
34
- class BodyError extends Error {
35
- constructor(
36
- message: string,
37
- readonly httpStatus: number
38
- ) {
39
- super(message);
40
- }
41
- }
18
+ import { applyCors, corsOrigins, corsPolicy, type CorsPolicy } from "./cors.js";
19
+ import { createProcedureHandler, type NodeProcedureHandler } from "./handler.js";
42
20
 
43
21
  /**
44
22
  * HTTP server transport adapter for Express.
@@ -59,68 +37,58 @@ class BodyError extends Error {
59
37
  * await httpTransport.start();
60
38
  * ```
61
39
  */
62
- /**
63
- * The origins that the CORS options allow: a page that CORS lets read the response can also
64
- * call. With CORS off, no other origin can call.
65
- */
66
- function corsOrigins(options: HttpServerTransportOptions): BrowserGuardOptions["allowedOrigins"] {
67
- if (!options.cors) return [];
68
- const origin = options.corsOptions?.origin ?? "*";
69
- if (origin === "*") return "*";
70
- return Array.isArray(origin) ? origin : [origin];
71
- }
72
-
73
40
  export class HttpServerTransport implements ServerTransport {
74
41
  readonly name = "http";
75
42
 
76
43
  private app: Express;
77
44
  private httpServer: HttpServer | null = null;
78
- private options: Required<
79
- Omit<
80
- HttpServerTransportOptions,
81
- "app" | "corsOptions" | "httpServer" | "bodyLimit" | "allowedOrigins" | "allowedHosts" | "allowGet" | "keepAlive" | "clock"
82
- >
83
- > & {
84
- corsOptions: HttpServerTransportOptions["corsOptions"] | undefined;
85
- httpServer: HttpServer | undefined;
86
- };
87
- private server: RequestHandler;
88
- /** The controllers of the requests in progress: stop() aborts them (deep dive TRN-15) */
89
- private openRequests = new Set<AbortController>();
45
+ private options: { port: number; host: string; basePath: string; cors: boolean; httpServer: HttpServer | undefined };
46
+ /** The procedure handler that does the work of each request (CLI-3) */
47
+ private readonly handler: NodeProcedureHandler;
48
+ /** The CORS of the app, or undefined when it is off */
49
+ private readonly cors: CorsPolicy | undefined;
90
50
  private stopped = false;
91
- private readonly bodyLimit: number;
92
- /** The browser checks of each procedure call (see `server/browser-guard.ts`) */
93
- private readonly guard: BrowserGuardOptions;
94
- /** The quiet time of an NDJSON stream before a keep-alive line, or false for none */
95
- private readonly keepAlive: Duration | false;
96
- /** The clock of the keep-alive timers (roadmap R35) */
97
- private readonly clock: Clock;
98
51
 
99
52
  constructor(server: RequestHandler, options: HttpServerTransportOptions = {}) {
100
- this.server = server;
101
53
  const basePath = options.basePath ?? "/api";
102
54
  this.options = {
103
55
  port: options.port ?? 3000,
104
- // Loopback by default (see BUGS-2026-07 H19), and route by URL path so the raw server matches
105
- // the HTTP client's default URL format instead of inferring the operation from the verb
106
- // (which lost the payload for get/list/find/watch). See BUGS-2026-07 (C3, bug 16).
56
+ // Loopback by default (see BUGS-2026-07 H19)
107
57
  host: options.host ?? "127.0.0.1",
108
- urlStrategy: options.urlStrategy ?? createPatternServerUrlStrategy(basePath),
109
58
  basePath,
110
59
  cors: options.cors ?? false,
111
- corsOptions: options.corsOptions,
112
60
  httpServer: options.httpServer,
113
61
  };
114
- this.bodyLimit = options.bodyLimit ?? 1024 * 1024;
115
- // A value that is not a duration fails here, not at the first stream
116
- const keepAliveMs = keepAliveMsOf(options.keepAlive);
117
- this.keepAlive = keepAliveMs > 0 ? ms(keepAliveMs) : false;
118
- this.clock = options.clock ?? realClock;
119
- this.guard = {
120
- allowedOrigins: options.allowedOrigins ?? corsOrigins(options),
121
- allowedHosts: options.allowedHosts,
122
- allowGet: options.allowGet,
123
- };
62
+ // Route by URL path so the raw server matches the HTTP client's default URL format instead of
63
+ // inferring the operation from the verb (BUGS-2026-07 C3, bug 16)
64
+ const urlStrategy = options.urlStrategy ?? createPatternServerUrlStrategy(basePath);
65
+ const corsOptions = this.options.cors ? (options.corsOptions ?? {}) : undefined;
66
+ // The given options go over the defaults, and the transport sends the credentials by default
67
+ this.cors = corsOptions ? corsPolicy(corsOptions, true) : undefined;
68
+ this.handler = createProcedureHandler(
69
+ server,
70
+ {
71
+ basePath,
72
+ bodyLimit: options.bodyLimit,
73
+ keepAlive: options.keepAlive,
74
+ clock: options.clock,
75
+ allowedOrigins: options.allowedOrigins ?? corsOrigins(corsOptions),
76
+ allowedHosts: options.allowedHosts,
77
+ allowGet: options.allowGet,
78
+ secretHeaders: options.secretHeaders,
79
+ secretQuery: options.secretQuery,
80
+ },
81
+ {
82
+ legacy: true,
83
+ matchAll: true,
84
+ face: { name: "HttpServerTransport", originOption: "corsOptions.origin" },
85
+ corsOutside: true,
86
+ methodOf: (req) => urlStrategy(req as Request),
87
+ sendJson: (res, status, body) => {
88
+ (res as Response).status(status).json(body);
89
+ },
90
+ }
91
+ );
124
92
 
125
93
  // Use provided app or create new one
126
94
  if (options.app) {
@@ -136,76 +104,15 @@ export class HttpServerTransport implements ServerTransport {
136
104
  }
137
105
 
138
106
  /**
139
- * Setup Express routes.
107
+ * Setup Express routes: the CORS of the app, then the procedure handler under the base path.
140
108
  */
141
109
  private setupRoutes(): void {
142
- // CORS
143
- if (this.options.cors) {
144
- // The given options go over the defaults. Before, options with only `origin` replaced
145
- // them all, and the server sent "Access-Control-Allow-Headers: undefined".
146
- const corsOptions = {
147
- origin: "*" as string | string[],
148
- methods: [
149
- HTTPMethod.GET,
150
- HTTPMethod.POST,
151
- HTTPMethod.PUT,
152
- HTTPMethod.DELETE,
153
- HTTPMethod.PATCH,
154
- HTTPMethod.OPTIONS,
155
- ],
156
- // The headers that HttpTransport sends from the metadata. Without X-Metadata, a page of
157
- // another origin failed its preflight for every call with custom metadata (pagination).
158
- allowedHeaders: [
159
- "Content-Type",
160
- "Accept",
161
- HTTPHeaders.AUTHORIZATION,
162
- HTTPHeaders.API_KEY,
163
- HTTPHeaders.REQUEST_ID,
164
- HTTPHeaders.TRACE_ID,
165
- HTTPHeaders.SPAN_ID,
166
- HTTPHeaders.PARENT_SPAN_ID,
167
- "traceparent",
168
- PROTOCOL_HEADER,
169
- HTTPHeaders.METADATA,
170
- "collection",
171
- "collectionName",
172
- "database",
173
- ],
174
- credentials: true,
175
- ...this.options.corsOptions,
176
- };
177
-
178
- // One allowed origin goes back: the request's origin when it is in the list (a header
179
- // with several origins is invalid), with Vary: Origin so that caches keep them apart
180
- const origins = corsOptions.origin ?? "*";
181
- const allowOrigin = (requestOrigin: string | undefined): string | undefined => {
182
- if (origins === "*") return "*";
183
- const list = Array.isArray(origins) ? origins : [origins];
184
- return requestOrigin !== undefined && list.includes(requestOrigin) ? requestOrigin : undefined;
185
- };
186
-
110
+ const cors = this.cors;
111
+ if (cors) {
112
+ // One allowed origin goes back: the request's origin when it is in the list, with
113
+ // Vary: Origin so that caches keep them apart. A preflight ends here with 204.
187
114
  this.app.use((req, res, next) => {
188
- const allowed = allowOrigin(req.headers.origin);
189
- if (origins !== "*") res.vary("Origin");
190
- if (allowed !== undefined) res.header("Access-Control-Allow-Origin", allowed);
191
- res.header(
192
- "Access-Control-Allow-Methods",
193
- corsOptions.methods?.join(", ")
194
- );
195
- res.header(
196
- "Access-Control-Allow-Headers",
197
- corsOptions.allowedHeaders?.join(", ")
198
- );
199
- if (corsOptions.credentials) {
200
- res.header("Access-Control-Allow-Credentials", "true");
201
- }
202
-
203
- if (req.method === "OPTIONS") {
204
- res.sendStatus(204);
205
- return;
206
- }
207
-
208
- next();
115
+ if (!applyCors(req, res, cors)) next();
209
116
  });
210
117
  }
211
118
 
@@ -213,344 +120,14 @@ export class HttpServerTransport implements ServerTransport {
213
120
  this.app.all(
214
121
  new RegExp(`^${this.options.basePath.replace(/\//g, "\\/")}\\/.*`),
215
122
  async (req, res) => {
216
- await this.handleHttpRequest(req, res);
123
+ await this.handler.handle(req, res);
217
124
  }
218
125
  );
219
126
  }
220
127
 
221
- /**
222
- * Handle HTTP request.
223
- * Converts to unified RPC format, processes, and sends response.
224
- */
225
- private async handleHttpRequest(req: Request, res: Response): Promise<void> {
226
- // The protocol version of this server, on each response (roadmap R25)
227
- res.setHeader(PROTOCOL_HEADER, String(PROTOCOL_VERSION));
228
- const version = protocolVersionOf(req.headers[PROTOCOL_HEADER.toLowerCase()]);
229
- if (version !== undefined && version > PROTOCOL_VERSION) {
230
- res.status(400).json({
231
- error: `This server speaks the protocol ${PROTOCOL_VERSION}, and the request uses ${version}`,
232
- code: "PROTOCOL_VERSION",
233
- retryable: false,
234
- protocol: PROTOCOL_VERSION,
235
- });
236
- return;
237
- }
238
- if (this.stopped) {
239
- res.status(503).json({ error: "Server stopped", code: "SERVER_STOPPED", retryable: true });
240
- return;
241
- }
242
- // A web page must not reach the procedures: host, origin, method and body type
243
- const guard = checkBrowserRequest(req, this.guard, true);
244
- if (!guard.ok) {
245
- res.status(guard.status).json({ error: guard.message, code: guard.code, retryable: false });
246
- return;
247
- }
248
- // A closed connection or stop() aborts the request: a stream stops, and its handler gets return()
249
- const controller = new AbortController();
250
- this.openRequests.add(controller);
251
- res.on("close", () => {
252
- this.openRequests.delete(controller);
253
- if (!res.writableFinished) controller.abort();
254
- });
255
- try {
256
- // Convert HTTP request to ServerRequest
257
- let serverRequest: ServerRequest | null;
258
- try {
259
- serverRequest = this.httpToServerRequest(req, await this.readBody(req));
260
- } catch (error) {
261
- const status = error instanceof BodyError ? error.httpStatus : 400;
262
- res.status(status).json({ error: (error as Error).message, code: "INVALID_REQUEST", retryable: false });
263
- return;
264
- }
265
-
266
- if (!serverRequest) {
267
- res.status(400).json({
268
- error: "Invalid request: could not parse RPC method from URL",
269
- });
270
- return;
271
- }
272
-
273
- serverRequest.signal = controller.signal;
274
-
275
- // Process request through universal server
276
- const serverResponse = await this.server.handle(serverRequest);
277
-
278
- if (serverResponse.stream) {
279
- await this.streamToHttp(serverResponse, serverResponse.stream, req, res, controller.signal);
280
- return;
281
- }
282
-
283
- // Convert ServerResponse to HTTP response
284
- this.serverResponseToHttp(serverResponse, res);
285
- } catch (error) {
286
- if (!res.headersSent) {
287
- res.status(500).json({
288
- error: (error as Error).message || "Internal server error",
289
- });
290
- }
291
- } finally {
292
- this.openRequests.delete(controller);
293
- }
294
- }
295
-
296
- /**
297
- * The request body. A body parser of the app (`express.json({ strict: false })`) gives
298
- * `req.body`. Without one, the transport reads the JSON body itself: before, every payload
299
- * was {} when the app had no parser, and a parser with `strict: true` rejected a number or
300
- * a string (deep dive TRN-10).
301
- */
302
- private async readBody(req: Request): Promise<unknown> {
303
- if (req.body !== undefined) return req.body;
304
- if (req.method === "GET" || req.method === "HEAD" || req.readableEnded) return undefined;
305
- const chunks: Buffer[] = [];
306
- let size = 0;
307
- for await (const chunk of req as AsyncIterable<Buffer | string>) {
308
- const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
309
- size += buffer.length;
310
- if (size > this.bodyLimit) throw new BodyError(`The request body is larger than ${this.bodyLimit} bytes`, 413);
311
- chunks.push(buffer);
312
- }
313
- const text = Buffer.concat(chunks).toString("utf8");
314
- if (text.trim() === "") return undefined;
315
- try {
316
- return JSON.parse(text) as unknown;
317
- } catch {
318
- throw new BodyError("The request body is not valid JSON", 400);
319
- }
320
- }
321
-
322
- /**
323
- * Send a stream response.
324
- *
325
- * A client that accepts `application/x-ndjson` gets one JSON line for each item
326
- * (`{"type":"item","seq":1,"payload":...}`), then `{"type":"done","seq":...}`, or
327
- * `{"type":"error","error":...}`. The `seq` of the lines counts from 1, as in the `stream`
328
- * frames of WebSocket. A quiet stream gets a keep-alive line, a blank line, after each quiet
329
- * time of `keepAlive`. `writeNdjsonStream` writes these lines.
330
- * Another client (curl, a browser fetch without the header) gets the last item as plain JSON,
331
- * the "sponge" mode, so a streaming procedure does not change its response format.
332
- */
333
- private async streamToHttp(
334
- response: ServerResponse,
335
- stream: AsyncIterable<unknown>,
336
- req: Request,
337
- res: Response,
338
- signal: AbortSignal
339
- ): Promise<void> {
340
- const ndjson = String(req.headers.accept ?? "").includes(NDJSON);
341
-
342
- if (!ndjson && isUnboundedStream(response)) {
343
- // The last item of a stream without an end never comes: refuse the call, and end the stream
344
- // (roadmap R15: before, the request waited until a timeout)
345
- void Promise.resolve(stream[Symbol.asyncIterator]().return?.()).catch(() => undefined);
346
- this.serverResponseToHttp(
347
- {
348
- id: response.id,
349
- status: {
350
- type: "error",
351
- code: "NOT_ACCEPTABLE",
352
- message: `This procedure gives a stream that can run without an end. Send "Accept: ${NDJSON}" to read its items.`,
353
- retryable: false,
354
- },
355
- metadata: {},
356
- },
357
- res
358
- );
359
- return;
360
- }
361
-
362
- if (!ndjson) {
363
- let last: { value: unknown } | undefined;
364
- try {
365
- for await (const item of stream) last = { value: item };
366
- } catch (error) {
367
- this.serverResponseToHttp({ id: response.id, status: { type: "error", ...errorOf(error) }, metadata: {} }, res);
368
- return;
369
- }
370
- if (!last) {
371
- this.serverResponseToHttp(
372
- {
373
- id: response.id,
374
- status: { type: "error", code: "NO_OUTPUT", message: "The procedure gave no output", retryable: false },
375
- metadata: {},
376
- },
377
- res
378
- );
379
- return;
380
- }
381
- this.serverResponseToHttp({ id: response.id, status: response.status, metadata: response.metadata, payload: last.value }, res);
382
- return;
383
- }
384
-
385
- // The lines, the backpressure, the keep-alive and the abort of stop() (WIRE-PROTOCOL.md)
386
- await writeNdjsonStream(res, stream, { signal, keepAlive: this.keepAlive, errorOf, clock: this.clock });
387
- }
388
-
389
- /**
390
- * This method converts an HTTP request to a `ServerRequest`.
391
- */
392
- private httpToServerRequest(req: Request, body: unknown): ServerRequest | null {
393
- // Parse method from URL using strategy
394
- const method = this.options.urlStrategy(req);
395
- if (!method) {
396
- return null;
397
- }
398
-
399
- // Generate request ID
400
- const id =
401
- (req.headers["x-request-id"] as string) ??
402
- `req-${Date.now()}-${Math.random()}`;
403
- const bearer = bearerOf(req.headers["authorization"]);
404
-
405
- // Build metadata from headers and query params. The flattened query parameters cannot
406
- // replace headers, query or params (before, "?headers=x" replaced metadata.headers).
407
- // The headers without the secrets: the credentials go in request.credentials, not in the
408
- // metadata, so a handler and a nested call do not see them (roadmap R18)
409
- const metadata: Metadata = {
410
- headers: withoutSecretHeaders(req.headers as Record<string, string>),
411
- query: req.query,
412
- params: req.params,
413
- };
414
- for (const [key, value] of Object.entries(req.query)) {
415
- if (!RESERVED_METADATA.has(key) && !key.startsWith("__")) metadata[key] = value;
416
- }
417
-
418
- // Extract custom headers to root-level metadata
419
- // This mirrors the client's metadataToHeaders() behavior
420
- const standardHeaders = new Set([
421
- "host",
422
- "connection",
423
- "content-length",
424
- "content-type",
425
- "user-agent",
426
- "accept",
427
- "accept-encoding",
428
- "accept-language",
429
- "cache-control",
430
- "origin",
431
- "referer",
432
- "x-request-id",
433
- "x-trace-id",
434
- "x-span-id",
435
- "x-parent-span-id",
436
- "x-timeout",
437
- "x-api-key",
438
- "authorization",
439
- "x-metadata",
440
- ]);
441
-
442
- // Known custom header mappings (lowercase → camelCase)
443
- const headerMappings: Record<string, string> = {
444
- 'collectionname': 'collectionName',
445
- };
446
-
447
- for (const [key, value] of Object.entries(req.headers)) {
448
- if (!standardHeaders.has(key.toLowerCase()) && typeof value === "string") {
449
- // Use mapped name if available, otherwise use original key
450
- const normalizedKey = headerMappings[key.toLowerCase()] || key;
451
- if (!RESERVED_METADATA.has(normalizedKey) && !normalizedKey.startsWith("__")) {
452
- metadata[normalizedKey] = value;
453
- }
454
- }
455
- }
456
-
457
- // The custom metadata of the HTTP client: any JSON value, any key (deep dive TRN-11)
458
- const encoded = req.headers["x-metadata"];
459
- if (typeof encoded === "string") {
460
- if (encoded.length > METADATA_HEADER_LIMIT) {
461
- throw new BodyError(`The X-Metadata header is longer than ${METADATA_HEADER_LIMIT} characters`, 431);
462
- }
463
- const custom = decodeMetadataHeader(encoded);
464
- if (!custom) throw new BodyError("The X-Metadata header is not URI-encoded JSON of an object", 400);
465
- for (const [key, value] of Object.entries(custom)) {
466
- if (!RESERVED_METADATA.has(key) && key !== "auth" && !key.startsWith("__")) metadata[key] = value;
467
- }
468
- }
469
-
470
- // Payload from body (for POST/PUT) or params (for GET)
471
- let payload: unknown = body;
472
- if (req.method === "GET" && req.params["id"]) {
473
- payload = { id: req.params["id"] };
474
- }
475
-
476
- // Default to empty object only if there is no payload (common for GET requests): 0, false,
477
- // "" and null stay (deep dive TRN-10)
478
- if (payload === undefined) {
479
- payload = {};
480
- }
481
-
482
- return {
483
- id,
484
- method,
485
- payload,
486
- metadata,
487
- // A network request: ProcedureServer examines the token (roadmap R18)
488
- credentials: bearer === undefined ? {} : { bearer },
489
- };
490
- }
491
-
492
- /**
493
- * Convert ServerResponse to HTTP response.
494
- */
495
- private serverResponseToHttp(
496
- serverResponse: ServerResponse,
497
- res: Response
498
- ): void {
499
- const { status, payload, metadata } = serverResponse;
500
-
501
- // Set status code (convert string to number if needed)
502
- let httpStatusCode: number;
503
- if (typeof status.code === "string") {
504
- // status.code is a symbolic error code (e.g. "VALIDATION_ERROR", "NOT_FOUND"). Map it to an
505
- // HTTP status via the error registry rather than parseInt (which yields NaN -> 500, so every
506
- // error became a 500). See documentation/BUGS-2026-07.md (M7).
507
- const mapped =
508
- httpStatusOf(status.code) ??
509
- (ERROR_REGISTRY as Record<string, { httpStatus?: number }>)[status.code]?.httpStatus;
510
- httpStatusCode = mapped ?? (status.type === "success" ? 200 : 500);
511
- } else if (typeof status.code === "number") {
512
- httpStatusCode = status.code;
513
- } else {
514
- // Default to 200 for success, 500 for error if code is null/undefined
515
- httpStatusCode = status.type === "success" ? 200 : 500;
516
- }
517
- res.status(httpStatusCode);
518
-
519
- // Set headers from metadata
520
- if (metadata["headers"]) {
521
- for (const [key, value] of Object.entries(metadata["headers"])) {
522
- res.setHeader(key, value as string);
523
- }
524
- }
525
-
526
- // Send response. A procedure that gave no result answers 204 with no body: before, the
527
- // body was empty with a JSON content type, and the client got "" (deep dive TRN-10).
528
- if (status.type === "success") {
529
- if (payload === undefined) {
530
- res.status(204).end();
531
- return;
532
- }
533
- res.json(payload);
534
- } else {
535
- // The wait that the error asks for: a Retry-After header in seconds, and the exact
536
- // milliseconds in the body (R12)
537
- const retryAfterMs = metadata["retry-after"];
538
- const hasRetryAfter = typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) && retryAfterMs >= 0;
539
- if (hasRetryAfter) res.setHeader("Retry-After", String(Math.ceil(retryAfterMs / 1000)));
540
- // The reason of a FORBIDDEN error travels on each face (roadmap R25)
541
- const reason = typeof metadata["reason"] === "string" ? metadata["reason"] : undefined;
542
- res.json({
543
- error: status.message,
544
- code: status.code,
545
- retryable: status.retryable,
546
- ...(hasRetryAfter ? { retryAfterMs } : {}),
547
- ...(reason !== undefined ? { reason } : {}),
548
- });
549
- }
550
- }
551
-
552
128
  async start(): Promise<void> {
553
129
  this.stopped = false;
130
+ this.handler.start();
554
131
  // If an HTTP server was provided, use it (don't create or listen)
555
132
  if (this.options.httpServer) {
556
133
  this.httpServer = this.options.httpServer;
@@ -586,8 +163,8 @@ export class HttpServerTransport implements ServerTransport {
586
163
  */
587
164
  async stop(): Promise<void> {
588
165
  this.stopped = true;
589
- for (const controller of this.openRequests) controller.abort();
590
- this.openRequests.clear();
166
+ this.handler.stopIntake();
167
+ this.handler.close();
591
168
 
592
169
  if (this.options.httpServer || !this.httpServer) {
593
170
  return;
@@ -85,6 +85,21 @@ export interface HttpServerTransportOptions extends BrowserGuardOptions {
85
85
  */
86
86
  clock?: Clock;
87
87
 
88
+ /**
89
+ * More headers that carry a secret, for example the token header of an application. They go
90
+ * into the credentials of the request, not into its metadata. `Authorization`,
91
+ * `Proxy-Authorization`, `Cookie` and `X-Api-Key` are always secret.
92
+ */
93
+ secretHeaders?: readonly string[];
94
+
95
+ /**
96
+ * The query parameters that carry a secret. They go into the credentials of the request, not
97
+ * into its metadata. The default keeps each query parameter in the metadata, as before.
98
+ * `procedureHandler` has the default `["token"]`.
99
+ * @default []
100
+ */
101
+ secretQuery?: readonly string[];
102
+
88
103
  /**
89
104
  * Custom CORS options. `origin` is "*", one origin or a list: the response names the
90
105
  * request's origin when it is in the list.
@@ -578,18 +578,10 @@ export class WebSocketTransport implements Transport {
578
578
  return;
579
579
  }
580
580
 
581
- // The frame type `event` is reserved: no side of protocol v1 sends it. A server outside this
582
- // repository can send it, so the frame still goes to onEvent (EVENTS.md).
583
- if (message.type === "event") {
584
- if (this.options.onEvent && message.topic && message.subscriptionId) {
585
- try {
586
- this.options.onEvent(message.topic, message.data, message.subscriptionId);
587
- } catch (error) {
588
- console.error(`[${this.name}] Event handler error:`, error);
589
- }
590
- }
591
- return;
592
- }
581
+ // The frame type `event` is reserved: no side of protocol v1 sends it. The client ignores it,
582
+ // as a frame of an unknown type. Before 0.9.0, it gave the frame to onEvent, for a server
583
+ // outside this repository (EVENTS.md). onEvent gets the events of the subscriptions only.
584
+ if (message.type === "event") return;
593
585
 
594
586
  // Handle response
595
587
  const pending = this.pendingRequests.get(message.id);