@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,65 +1,49 @@
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
  import type { ServerTransport } from "../../../server/types.js";
8
11
  import type { RequestHandler } from "../../../server/types.js";
9
12
  import type { HttpServerTransportOptions } from "./types.js";
13
+ /**
14
+ * HTTP server transport adapter for Express.
15
+ *
16
+ * Converts HTTP requests to unified RPC format and responses back to HTTP.
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * const server = new Server();
21
+ * const app = express();
22
+ * app.use(express.json({ strict: false })); // optional: the transport can read the body itself
23
+ * const httpTransport = new HttpServerTransport(server, {
24
+ * app,
25
+ * port: 3000,
26
+ * // The default URL strategy parses the HTTP client's format: /api/{service}/{operation}
27
+ * });
28
+ *
29
+ * await httpTransport.start();
30
+ * ```
31
+ */
10
32
  export declare class HttpServerTransport implements ServerTransport {
11
33
  readonly name = "http";
12
34
  private app;
13
35
  private httpServer;
14
36
  private options;
15
- private server;
16
- /** The controllers of the requests in progress: stop() aborts them (deep dive TRN-15) */
17
- private openRequests;
37
+ /** The procedure handler that does the work of each request (CLI-3) */
38
+ private readonly handler;
39
+ /** The CORS of the app, or undefined when it is off */
40
+ private readonly cors;
18
41
  private stopped;
19
- private readonly bodyLimit;
20
- /** The browser checks of each procedure call (see `server/browser-guard.ts`) */
21
- private readonly guard;
22
- /** The quiet time of an NDJSON stream before a keep-alive line, or false for none */
23
- private readonly keepAlive;
24
- /** The clock of the keep-alive timers (roadmap R35) */
25
- private readonly clock;
26
42
  constructor(server: RequestHandler, options?: HttpServerTransportOptions);
27
43
  /**
28
- * Setup Express routes.
44
+ * Setup Express routes: the CORS of the app, then the procedure handler under the base path.
29
45
  */
30
46
  private setupRoutes;
31
- /**
32
- * Handle HTTP request.
33
- * Converts to unified RPC format, processes, and sends response.
34
- */
35
- private handleHttpRequest;
36
- /**
37
- * The request body. A body parser of the app (`express.json({ strict: false })`) gives
38
- * `req.body`. Without one, the transport reads the JSON body itself: before, every payload
39
- * was {} when the app had no parser, and a parser with `strict: true` rejected a number or
40
- * a string (deep dive TRN-10).
41
- */
42
- private readBody;
43
- /**
44
- * Send a stream response.
45
- *
46
- * A client that accepts `application/x-ndjson` gets one JSON line for each item
47
- * (`{"type":"item","seq":1,"payload":...}`), then `{"type":"done","seq":...}`, or
48
- * `{"type":"error","error":...}`. The `seq` of the lines counts from 1, as in the `stream`
49
- * frames of WebSocket. A quiet stream gets a keep-alive line, a blank line, after each quiet
50
- * time of `keepAlive`. `writeNdjsonStream` writes these lines.
51
- * Another client (curl, a browser fetch without the header) gets the last item as plain JSON,
52
- * the "sponge" mode, so a streaming procedure does not change its response format.
53
- */
54
- private streamToHttp;
55
- /**
56
- * This method converts an HTTP request to a `ServerRequest`.
57
- */
58
- private httpToServerRequest;
59
- /**
60
- * Convert ServerResponse to HTTP response.
61
- */
62
- private serverResponseToHttp;
63
47
  start(): Promise<void>;
64
48
  /**
65
49
  * Stop the transport. The requests in progress are aborted: an open stream ends, and its
@@ -1 +1 @@
1
- {"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../../../../src/adapters/http/server/transport.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH,OAAO,KAAK,EAAE,eAAe,EAAiC,MAAM,0BAA0B,CAAC;AAE/F,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAG/D,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,YAAY,CAAC;AAyD7D,qBAAa,mBAAoB,YAAW,eAAe;IACzD,QAAQ,CAAC,IAAI,UAAU;IAEvB,OAAO,CAAC,GAAG,CAAU;IACrB,OAAO,CAAC,UAAU,CAA2B;IAC7C,OAAO,CAAC,OAAO,CAQb;IACF,OAAO,CAAC,MAAM,CAAiB;IAC/B,yFAAyF;IACzF,OAAO,CAAC,YAAY,CAA8B;IAClD,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,gFAAgF;IAChF,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAsB;IAC5C,qFAAqF;IACrF,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAmB;IAC7C,uDAAuD;IACvD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAQ;IAE9B,YAAY,MAAM,EAAE,cAAc,EAAE,OAAO,GAAE,0BAA+B,EAqC3E;IAED;;OAEG;IACH,OAAO,CAAC,WAAW;IAgFnB;;;OAGG;YACW,iBAAiB;IAuE/B;;;;;OAKG;YACW,QAAQ;IAoBtB;;;;;;;;;;OAUG;YACW,YAAY;IAwD1B;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAoG3B;;OAEG;IACH,OAAO,CAAC,oBAAoB;IAyDtB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CA2B3B;IAED;;;;;OAKG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAqB1B;IAED,SAAS,IAAI,OAAO,CAEnB;IAED;;;;OAIG;IACH,IAAI,IAAI,MAAM,GAAG,SAAS,CAGzB;CACF"}
1
+ {"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../../../../src/adapters/http/server/transport.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAChE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC/D,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,YAAY,CAAC;AAK7D;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAAa,mBAAoB,YAAW,eAAe;IACzD,QAAQ,CAAC,IAAI,UAAU;IAEvB,OAAO,CAAC,GAAG,CAAU;IACrB,OAAO,CAAC,UAAU,CAA2B;IAC7C,OAAO,CAAC,OAAO,CAAsG;IACrH,uEAAuE;IACvE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAuB;IAC/C,uDAAuD;IACvD,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAyB;IAC9C,OAAO,CAAC,OAAO,CAAS;IAExB,YAAY,MAAM,EAAE,cAAc,EAAE,OAAO,GAAE,0BAA+B,EAoD3E;IAED;;OAEG;IACH,OAAO,CAAC,WAAW;IAmBb,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CA4B3B;IAED;;;;;OAKG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAqB1B;IAED,SAAS,IAAI,OAAO,CAEnB;IAED;;;;OAIG;IACH,IAAI,IAAI,MAAM,GAAG,SAAS,CAGzB;CACF"}
@@ -1,33 +1,16 @@
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
  import { createServer } from "http";
8
- import { HTTPMethod, HTTPHeaders, decodeMetadataHeader, METADATA_HEADER_LIMIT } from "../shared/index.js";
9
- import { ERROR_REGISTRY } from "../../../client/errors/index.js";
10
11
  import { createPatternServerUrlStrategy } from "./strategies.js";
11
- import { checkBrowserRequest } from "../../../server/browser-guard.js";
12
- import { bearerOf, withoutSecretHeaders } from "../../../server/request-auth.js";
13
- import { PROTOCOL_HEADER, PROTOCOL_VERSION, httpStatusOf, protocolVersionOf } from "../../../protocol.js";
14
- import { realClock } from "../../../clock.js";
15
- import { ms } from "../../../duration.js";
16
- import { NDJSON_MEDIA_TYPE as NDJSON, defaultNdjsonError as errorOf, keepAliveMsOf, writeNdjsonStream } from "./ndjson.js";
17
- /** True when a stream response can run without an end (`STREAM_METADATA_KEY` of procedure-server.ts). */
18
- function isUnboundedStream(response) {
19
- return response.metadata?.["stream"] === "unbounded";
20
- }
21
- /** The metadata keys that the transport sets: a query parameter or a header cannot replace them. */
22
- const RESERVED_METADATA = new Set(["headers", "query", "params"]);
23
- /** The error of a request body that the transport cannot read. */
24
- class BodyError extends Error {
25
- httpStatus;
26
- constructor(message, httpStatus) {
27
- super(message);
28
- this.httpStatus = httpStatus;
29
- }
30
- }
12
+ import { applyCors, corsOrigins, corsPolicy } from "./cors.js";
13
+ import { createProcedureHandler } from "./handler.js";
31
14
  /**
32
15
  * HTTP server transport adapter for Express.
33
16
  *
@@ -47,59 +30,52 @@ class BodyError extends Error {
47
30
  * await httpTransport.start();
48
31
  * ```
49
32
  */
50
- /**
51
- * The origins that the CORS options allow: a page that CORS lets read the response can also
52
- * call. With CORS off, no other origin can call.
53
- */
54
- function corsOrigins(options) {
55
- if (!options.cors)
56
- return [];
57
- const origin = options.corsOptions?.origin ?? "*";
58
- if (origin === "*")
59
- return "*";
60
- return Array.isArray(origin) ? origin : [origin];
61
- }
62
33
  export class HttpServerTransport {
63
34
  name = "http";
64
35
  app;
65
36
  httpServer = null;
66
37
  options;
67
- server;
68
- /** The controllers of the requests in progress: stop() aborts them (deep dive TRN-15) */
69
- openRequests = new Set();
38
+ /** The procedure handler that does the work of each request (CLI-3) */
39
+ handler;
40
+ /** The CORS of the app, or undefined when it is off */
41
+ cors;
70
42
  stopped = false;
71
- bodyLimit;
72
- /** The browser checks of each procedure call (see `server/browser-guard.ts`) */
73
- guard;
74
- /** The quiet time of an NDJSON stream before a keep-alive line, or false for none */
75
- keepAlive;
76
- /** The clock of the keep-alive timers (roadmap R35) */
77
- clock;
78
43
  constructor(server, options = {}) {
79
- this.server = server;
80
44
  const basePath = options.basePath ?? "/api";
81
45
  this.options = {
82
46
  port: options.port ?? 3000,
83
- // Loopback by default (see BUGS-2026-07 H19), and route by URL path so the raw server matches
84
- // the HTTP client's default URL format instead of inferring the operation from the verb
85
- // (which lost the payload for get/list/find/watch). See BUGS-2026-07 (C3, bug 16).
47
+ // Loopback by default (see BUGS-2026-07 H19)
86
48
  host: options.host ?? "127.0.0.1",
87
- urlStrategy: options.urlStrategy ?? createPatternServerUrlStrategy(basePath),
88
49
  basePath,
89
50
  cors: options.cors ?? false,
90
- corsOptions: options.corsOptions,
91
51
  httpServer: options.httpServer,
92
52
  };
93
- this.bodyLimit = options.bodyLimit ?? 1024 * 1024;
94
- // A value that is not a duration fails here, not at the first stream
95
- const keepAliveMs = keepAliveMsOf(options.keepAlive);
96
- this.keepAlive = keepAliveMs > 0 ? ms(keepAliveMs) : false;
97
- this.clock = options.clock ?? realClock;
98
- this.guard = {
99
- allowedOrigins: options.allowedOrigins ?? corsOrigins(options),
53
+ // Route by URL path so the raw server matches the HTTP client's default URL format instead of
54
+ // inferring the operation from the verb (BUGS-2026-07 C3, bug 16)
55
+ const urlStrategy = options.urlStrategy ?? createPatternServerUrlStrategy(basePath);
56
+ const corsOptions = this.options.cors ? (options.corsOptions ?? {}) : undefined;
57
+ // The given options go over the defaults, and the transport sends the credentials by default
58
+ this.cors = corsOptions ? corsPolicy(corsOptions, true) : undefined;
59
+ this.handler = createProcedureHandler(server, {
60
+ basePath,
61
+ bodyLimit: options.bodyLimit,
62
+ keepAlive: options.keepAlive,
63
+ clock: options.clock,
64
+ allowedOrigins: options.allowedOrigins ?? corsOrigins(corsOptions),
100
65
  allowedHosts: options.allowedHosts,
101
66
  allowGet: options.allowGet,
102
- };
67
+ secretHeaders: options.secretHeaders,
68
+ secretQuery: options.secretQuery,
69
+ }, {
70
+ legacy: true,
71
+ matchAll: true,
72
+ face: { name: "HttpServerTransport", originOption: "corsOptions.origin" },
73
+ corsOutside: true,
74
+ methodOf: (req) => urlStrategy(req),
75
+ sendJson: (res, status, body) => {
76
+ res.status(status).json(body);
77
+ },
78
+ });
103
79
  // Use provided app or create new one
104
80
  if (options.app) {
105
81
  this.app = options.app;
@@ -111,385 +87,26 @@ export class HttpServerTransport {
111
87
  this.setupRoutes();
112
88
  }
113
89
  /**
114
- * Setup Express routes.
90
+ * Setup Express routes: the CORS of the app, then the procedure handler under the base path.
115
91
  */
116
92
  setupRoutes() {
117
- // CORS
118
- if (this.options.cors) {
119
- // The given options go over the defaults. Before, options with only `origin` replaced
120
- // them all, and the server sent "Access-Control-Allow-Headers: undefined".
121
- const corsOptions = {
122
- origin: "*",
123
- methods: [
124
- HTTPMethod.GET,
125
- HTTPMethod.POST,
126
- HTTPMethod.PUT,
127
- HTTPMethod.DELETE,
128
- HTTPMethod.PATCH,
129
- HTTPMethod.OPTIONS,
130
- ],
131
- // The headers that HttpTransport sends from the metadata. Without X-Metadata, a page of
132
- // another origin failed its preflight for every call with custom metadata (pagination).
133
- allowedHeaders: [
134
- "Content-Type",
135
- "Accept",
136
- HTTPHeaders.AUTHORIZATION,
137
- HTTPHeaders.API_KEY,
138
- HTTPHeaders.REQUEST_ID,
139
- HTTPHeaders.TRACE_ID,
140
- HTTPHeaders.SPAN_ID,
141
- HTTPHeaders.PARENT_SPAN_ID,
142
- "traceparent",
143
- PROTOCOL_HEADER,
144
- HTTPHeaders.METADATA,
145
- "collection",
146
- "collectionName",
147
- "database",
148
- ],
149
- credentials: true,
150
- ...this.options.corsOptions,
151
- };
152
- // One allowed origin goes back: the request's origin when it is in the list (a header
153
- // with several origins is invalid), with Vary: Origin so that caches keep them apart
154
- const origins = corsOptions.origin ?? "*";
155
- const allowOrigin = (requestOrigin) => {
156
- if (origins === "*")
157
- return "*";
158
- const list = Array.isArray(origins) ? origins : [origins];
159
- return requestOrigin !== undefined && list.includes(requestOrigin) ? requestOrigin : undefined;
160
- };
93
+ const cors = this.cors;
94
+ if (cors) {
95
+ // One allowed origin goes back: the request's origin when it is in the list, with
96
+ // Vary: Origin so that caches keep them apart. A preflight ends here with 204.
161
97
  this.app.use((req, res, next) => {
162
- const allowed = allowOrigin(req.headers.origin);
163
- if (origins !== "*")
164
- res.vary("Origin");
165
- if (allowed !== undefined)
166
- res.header("Access-Control-Allow-Origin", allowed);
167
- res.header("Access-Control-Allow-Methods", corsOptions.methods?.join(", "));
168
- res.header("Access-Control-Allow-Headers", corsOptions.allowedHeaders?.join(", "));
169
- if (corsOptions.credentials) {
170
- res.header("Access-Control-Allow-Credentials", "true");
171
- }
172
- if (req.method === "OPTIONS") {
173
- res.sendStatus(204);
174
- return;
175
- }
176
- next();
98
+ if (!applyCors(req, res, cors))
99
+ next();
177
100
  });
178
101
  }
179
102
  // Catch-all route for RPC - use regex to match any path under basePath
180
103
  this.app.all(new RegExp(`^${this.options.basePath.replace(/\//g, "\\/")}\\/.*`), async (req, res) => {
181
- await this.handleHttpRequest(req, res);
104
+ await this.handler.handle(req, res);
182
105
  });
183
106
  }
184
- /**
185
- * Handle HTTP request.
186
- * Converts to unified RPC format, processes, and sends response.
187
- */
188
- async handleHttpRequest(req, res) {
189
- // The protocol version of this server, on each response (roadmap R25)
190
- res.setHeader(PROTOCOL_HEADER, String(PROTOCOL_VERSION));
191
- const version = protocolVersionOf(req.headers[PROTOCOL_HEADER.toLowerCase()]);
192
- if (version !== undefined && version > PROTOCOL_VERSION) {
193
- res.status(400).json({
194
- error: `This server speaks the protocol ${PROTOCOL_VERSION}, and the request uses ${version}`,
195
- code: "PROTOCOL_VERSION",
196
- retryable: false,
197
- protocol: PROTOCOL_VERSION,
198
- });
199
- return;
200
- }
201
- if (this.stopped) {
202
- res.status(503).json({ error: "Server stopped", code: "SERVER_STOPPED", retryable: true });
203
- return;
204
- }
205
- // A web page must not reach the procedures: host, origin, method and body type
206
- const guard = checkBrowserRequest(req, this.guard, true);
207
- if (!guard.ok) {
208
- res.status(guard.status).json({ error: guard.message, code: guard.code, retryable: false });
209
- return;
210
- }
211
- // A closed connection or stop() aborts the request: a stream stops, and its handler gets return()
212
- const controller = new AbortController();
213
- this.openRequests.add(controller);
214
- res.on("close", () => {
215
- this.openRequests.delete(controller);
216
- if (!res.writableFinished)
217
- controller.abort();
218
- });
219
- try {
220
- // Convert HTTP request to ServerRequest
221
- let serverRequest;
222
- try {
223
- serverRequest = this.httpToServerRequest(req, await this.readBody(req));
224
- }
225
- catch (error) {
226
- const status = error instanceof BodyError ? error.httpStatus : 400;
227
- res.status(status).json({ error: error.message, code: "INVALID_REQUEST", retryable: false });
228
- return;
229
- }
230
- if (!serverRequest) {
231
- res.status(400).json({
232
- error: "Invalid request: could not parse RPC method from URL",
233
- });
234
- return;
235
- }
236
- serverRequest.signal = controller.signal;
237
- // Process request through universal server
238
- const serverResponse = await this.server.handle(serverRequest);
239
- if (serverResponse.stream) {
240
- await this.streamToHttp(serverResponse, serverResponse.stream, req, res, controller.signal);
241
- return;
242
- }
243
- // Convert ServerResponse to HTTP response
244
- this.serverResponseToHttp(serverResponse, res);
245
- }
246
- catch (error) {
247
- if (!res.headersSent) {
248
- res.status(500).json({
249
- error: error.message || "Internal server error",
250
- });
251
- }
252
- }
253
- finally {
254
- this.openRequests.delete(controller);
255
- }
256
- }
257
- /**
258
- * The request body. A body parser of the app (`express.json({ strict: false })`) gives
259
- * `req.body`. Without one, the transport reads the JSON body itself: before, every payload
260
- * was {} when the app had no parser, and a parser with `strict: true` rejected a number or
261
- * a string (deep dive TRN-10).
262
- */
263
- async readBody(req) {
264
- if (req.body !== undefined)
265
- return req.body;
266
- if (req.method === "GET" || req.method === "HEAD" || req.readableEnded)
267
- return undefined;
268
- const chunks = [];
269
- let size = 0;
270
- for await (const chunk of req) {
271
- const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
272
- size += buffer.length;
273
- if (size > this.bodyLimit)
274
- throw new BodyError(`The request body is larger than ${this.bodyLimit} bytes`, 413);
275
- chunks.push(buffer);
276
- }
277
- const text = Buffer.concat(chunks).toString("utf8");
278
- if (text.trim() === "")
279
- return undefined;
280
- try {
281
- return JSON.parse(text);
282
- }
283
- catch {
284
- throw new BodyError("The request body is not valid JSON", 400);
285
- }
286
- }
287
- /**
288
- * Send a stream response.
289
- *
290
- * A client that accepts `application/x-ndjson` gets one JSON line for each item
291
- * (`{"type":"item","seq":1,"payload":...}`), then `{"type":"done","seq":...}`, or
292
- * `{"type":"error","error":...}`. The `seq` of the lines counts from 1, as in the `stream`
293
- * frames of WebSocket. A quiet stream gets a keep-alive line, a blank line, after each quiet
294
- * time of `keepAlive`. `writeNdjsonStream` writes these lines.
295
- * Another client (curl, a browser fetch without the header) gets the last item as plain JSON,
296
- * the "sponge" mode, so a streaming procedure does not change its response format.
297
- */
298
- async streamToHttp(response, stream, req, res, signal) {
299
- const ndjson = String(req.headers.accept ?? "").includes(NDJSON);
300
- if (!ndjson && isUnboundedStream(response)) {
301
- // The last item of a stream without an end never comes: refuse the call, and end the stream
302
- // (roadmap R15: before, the request waited until a timeout)
303
- void Promise.resolve(stream[Symbol.asyncIterator]().return?.()).catch(() => undefined);
304
- this.serverResponseToHttp({
305
- id: response.id,
306
- status: {
307
- type: "error",
308
- code: "NOT_ACCEPTABLE",
309
- message: `This procedure gives a stream that can run without an end. Send "Accept: ${NDJSON}" to read its items.`,
310
- retryable: false,
311
- },
312
- metadata: {},
313
- }, res);
314
- return;
315
- }
316
- if (!ndjson) {
317
- let last;
318
- try {
319
- for await (const item of stream)
320
- last = { value: item };
321
- }
322
- catch (error) {
323
- this.serverResponseToHttp({ id: response.id, status: { type: "error", ...errorOf(error) }, metadata: {} }, res);
324
- return;
325
- }
326
- if (!last) {
327
- this.serverResponseToHttp({
328
- id: response.id,
329
- status: { type: "error", code: "NO_OUTPUT", message: "The procedure gave no output", retryable: false },
330
- metadata: {},
331
- }, res);
332
- return;
333
- }
334
- this.serverResponseToHttp({ id: response.id, status: response.status, metadata: response.metadata, payload: last.value }, res);
335
- return;
336
- }
337
- // The lines, the backpressure, the keep-alive and the abort of stop() (WIRE-PROTOCOL.md)
338
- await writeNdjsonStream(res, stream, { signal, keepAlive: this.keepAlive, errorOf, clock: this.clock });
339
- }
340
- /**
341
- * This method converts an HTTP request to a `ServerRequest`.
342
- */
343
- httpToServerRequest(req, body) {
344
- // Parse method from URL using strategy
345
- const method = this.options.urlStrategy(req);
346
- if (!method) {
347
- return null;
348
- }
349
- // Generate request ID
350
- const id = req.headers["x-request-id"] ??
351
- `req-${Date.now()}-${Math.random()}`;
352
- const bearer = bearerOf(req.headers["authorization"]);
353
- // Build metadata from headers and query params. The flattened query parameters cannot
354
- // replace headers, query or params (before, "?headers=x" replaced metadata.headers).
355
- // The headers without the secrets: the credentials go in request.credentials, not in the
356
- // metadata, so a handler and a nested call do not see them (roadmap R18)
357
- const metadata = {
358
- headers: withoutSecretHeaders(req.headers),
359
- query: req.query,
360
- params: req.params,
361
- };
362
- for (const [key, value] of Object.entries(req.query)) {
363
- if (!RESERVED_METADATA.has(key) && !key.startsWith("__"))
364
- metadata[key] = value;
365
- }
366
- // Extract custom headers to root-level metadata
367
- // This mirrors the client's metadataToHeaders() behavior
368
- const standardHeaders = new Set([
369
- "host",
370
- "connection",
371
- "content-length",
372
- "content-type",
373
- "user-agent",
374
- "accept",
375
- "accept-encoding",
376
- "accept-language",
377
- "cache-control",
378
- "origin",
379
- "referer",
380
- "x-request-id",
381
- "x-trace-id",
382
- "x-span-id",
383
- "x-parent-span-id",
384
- "x-timeout",
385
- "x-api-key",
386
- "authorization",
387
- "x-metadata",
388
- ]);
389
- // Known custom header mappings (lowercase → camelCase)
390
- const headerMappings = {
391
- 'collectionname': 'collectionName',
392
- };
393
- for (const [key, value] of Object.entries(req.headers)) {
394
- if (!standardHeaders.has(key.toLowerCase()) && typeof value === "string") {
395
- // Use mapped name if available, otherwise use original key
396
- const normalizedKey = headerMappings[key.toLowerCase()] || key;
397
- if (!RESERVED_METADATA.has(normalizedKey) && !normalizedKey.startsWith("__")) {
398
- metadata[normalizedKey] = value;
399
- }
400
- }
401
- }
402
- // The custom metadata of the HTTP client: any JSON value, any key (deep dive TRN-11)
403
- const encoded = req.headers["x-metadata"];
404
- if (typeof encoded === "string") {
405
- if (encoded.length > METADATA_HEADER_LIMIT) {
406
- throw new BodyError(`The X-Metadata header is longer than ${METADATA_HEADER_LIMIT} characters`, 431);
407
- }
408
- const custom = decodeMetadataHeader(encoded);
409
- if (!custom)
410
- throw new BodyError("The X-Metadata header is not URI-encoded JSON of an object", 400);
411
- for (const [key, value] of Object.entries(custom)) {
412
- if (!RESERVED_METADATA.has(key) && key !== "auth" && !key.startsWith("__"))
413
- metadata[key] = value;
414
- }
415
- }
416
- // Payload from body (for POST/PUT) or params (for GET)
417
- let payload = body;
418
- if (req.method === "GET" && req.params["id"]) {
419
- payload = { id: req.params["id"] };
420
- }
421
- // Default to empty object only if there is no payload (common for GET requests): 0, false,
422
- // "" and null stay (deep dive TRN-10)
423
- if (payload === undefined) {
424
- payload = {};
425
- }
426
- return {
427
- id,
428
- method,
429
- payload,
430
- metadata,
431
- // A network request: ProcedureServer examines the token (roadmap R18)
432
- credentials: bearer === undefined ? {} : { bearer },
433
- };
434
- }
435
- /**
436
- * Convert ServerResponse to HTTP response.
437
- */
438
- serverResponseToHttp(serverResponse, res) {
439
- const { status, payload, metadata } = serverResponse;
440
- // Set status code (convert string to number if needed)
441
- let httpStatusCode;
442
- if (typeof status.code === "string") {
443
- // status.code is a symbolic error code (e.g. "VALIDATION_ERROR", "NOT_FOUND"). Map it to an
444
- // HTTP status via the error registry rather than parseInt (which yields NaN -> 500, so every
445
- // error became a 500). See documentation/BUGS-2026-07.md (M7).
446
- const mapped = httpStatusOf(status.code) ??
447
- ERROR_REGISTRY[status.code]?.httpStatus;
448
- httpStatusCode = mapped ?? (status.type === "success" ? 200 : 500);
449
- }
450
- else if (typeof status.code === "number") {
451
- httpStatusCode = status.code;
452
- }
453
- else {
454
- // Default to 200 for success, 500 for error if code is null/undefined
455
- httpStatusCode = status.type === "success" ? 200 : 500;
456
- }
457
- res.status(httpStatusCode);
458
- // Set headers from metadata
459
- if (metadata["headers"]) {
460
- for (const [key, value] of Object.entries(metadata["headers"])) {
461
- res.setHeader(key, value);
462
- }
463
- }
464
- // Send response. A procedure that gave no result answers 204 with no body: before, the
465
- // body was empty with a JSON content type, and the client got "" (deep dive TRN-10).
466
- if (status.type === "success") {
467
- if (payload === undefined) {
468
- res.status(204).end();
469
- return;
470
- }
471
- res.json(payload);
472
- }
473
- else {
474
- // The wait that the error asks for: a Retry-After header in seconds, and the exact
475
- // milliseconds in the body (R12)
476
- const retryAfterMs = metadata["retry-after"];
477
- const hasRetryAfter = typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) && retryAfterMs >= 0;
478
- if (hasRetryAfter)
479
- res.setHeader("Retry-After", String(Math.ceil(retryAfterMs / 1000)));
480
- // The reason of a FORBIDDEN error travels on each face (roadmap R25)
481
- const reason = typeof metadata["reason"] === "string" ? metadata["reason"] : undefined;
482
- res.json({
483
- error: status.message,
484
- code: status.code,
485
- retryable: status.retryable,
486
- ...(hasRetryAfter ? { retryAfterMs } : {}),
487
- ...(reason !== undefined ? { reason } : {}),
488
- });
489
- }
490
- }
491
107
  async start() {
492
108
  this.stopped = false;
109
+ this.handler.start();
493
110
  // If an HTTP server was provided, use it (don't create or listen)
494
111
  if (this.options.httpServer) {
495
112
  this.httpServer = this.options.httpServer;
@@ -520,9 +137,8 @@ export class HttpServerTransport {
520
137
  */
521
138
  async stop() {
522
139
  this.stopped = true;
523
- for (const controller of this.openRequests)
524
- controller.abort();
525
- this.openRequests.clear();
140
+ this.handler.stopIntake();
141
+ this.handler.close();
526
142
  if (this.options.httpServer || !this.httpServer) {
527
143
  return;
528
144
  }