@procedures/client 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +107 -0
- package/README.md +115 -11
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/http/client/transport.d.ts +4 -0
- package/dist/adapters/http/client/transport.d.ts.map +1 -1
- package/dist/adapters/http/client/transport.js +11 -1
- package/dist/adapters/http/client/transport.js.map +1 -1
- package/dist/adapters/http/client/types.d.ts +19 -0
- package/dist/adapters/http/client/types.d.ts.map +1 -1
- package/dist/adapters/http/server/cors.d.ts +64 -0
- package/dist/adapters/http/server/cors.d.ts.map +1 -0
- package/dist/adapters/http/server/cors.js +113 -0
- package/dist/adapters/http/server/cors.js.map +1 -0
- package/dist/adapters/http/server/handler.d.ts +136 -0
- package/dist/adapters/http/server/handler.d.ts.map +1 -0
- package/dist/adapters/http/server/handler.js +495 -0
- package/dist/adapters/http/server/handler.js.map +1 -0
- package/dist/adapters/http/server/strategies.d.ts +14 -5
- package/dist/adapters/http/server/strategies.d.ts.map +1 -1
- package/dist/adapters/http/server/strategies.js.map +1 -1
- package/dist/adapters/http/server/transport.d.ts +36 -46
- package/dist/adapters/http/server/transport.d.ts.map +1 -1
- package/dist/adapters/http/server/transport.js +58 -433
- package/dist/adapters/http/server/transport.js.map +1 -1
- package/dist/adapters/http/server/types.d.ts +13 -0
- package/dist/adapters/http/server/types.d.ts.map +1 -1
- package/dist/adapters/websocket/client/transport.d.ts +7 -3
- package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
- package/dist/adapters/websocket/client/transport.js +9 -3
- package/dist/adapters/websocket/client/transport.js.map +1 -1
- package/dist/adapters/websocket/server/transport.d.ts +56 -1
- package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
- package/dist/adapters/websocket/server/transport.js +174 -17
- package/dist/adapters/websocket/server/transport.js.map +1 -1
- package/dist/adapters/websocket/server/types.d.ts +10 -3
- package/dist/adapters/websocket/server/types.d.ts.map +1 -1
- package/dist/adapters/websocket/server/upgrade-router.d.ts +28 -0
- package/dist/adapters/websocket/server/upgrade-router.d.ts.map +1 -0
- package/dist/adapters/websocket/server/upgrade-router.js +62 -0
- package/dist/adapters/websocket/server/upgrade-router.js.map +1 -0
- package/dist/adapters/websocket/server/upgrade.d.ts +68 -0
- package/dist/adapters/websocket/server/upgrade.d.ts.map +1 -0
- package/dist/adapters/websocket/server/upgrade.js +49 -0
- package/dist/adapters/websocket/server/upgrade.js.map +1 -0
- package/dist/adapters/websocket/shared/rpc.d.ts +2 -0
- package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
- package/dist/adapters/websocket/shared/rpc.js +8 -2
- package/dist/adapters/websocket/shared/rpc.js.map +1 -1
- package/dist/browser.d.ts +14 -6
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +12 -3
- package/dist/browser.js.map +1 -1
- package/dist/client/client.d.ts +43 -0
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +69 -1
- package/dist/client/client.js.map +1 -1
- package/dist/client/errors/registry.d.ts.map +1 -1
- package/dist/client/errors/registry.js +1 -0
- package/dist/client/errors/registry.js.map +1 -1
- package/dist/client/middleware/retry.d.ts +4 -0
- package/dist/client/middleware/retry.d.ts.map +1 -1
- package/dist/client/middleware/retry.js +86 -78
- package/dist/client/middleware/retry.js.map +1 -1
- package/dist/events/bus.d.ts +32 -14
- package/dist/events/bus.d.ts.map +1 -1
- package/dist/events/bus.js +119 -65
- package/dist/events/bus.js.map +1 -1
- package/dist/events/index.d.ts +3 -2
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js.map +1 -1
- package/dist/events/publisher.d.ts +14 -0
- package/dist/events/publisher.d.ts.map +1 -1
- package/dist/events/publisher.js +62 -10
- package/dist/events/publisher.js.map +1 -1
- package/dist/events/remote.d.ts +80 -18
- package/dist/events/remote.d.ts.map +1 -1
- package/dist/events/remote.js +448 -58
- package/dist/events/remote.js.map +1 -1
- package/dist/events/rows.d.ts +31 -0
- package/dist/events/rows.d.ts.map +1 -0
- package/dist/events/rows.js +39 -0
- package/dist/events/rows.js.map +1 -0
- package/dist/events/types.d.ts +13 -2
- package/dist/events/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/node.d.ts +14 -0
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +9 -0
- package/dist/node.js.map +1 -1
- package/dist/procedures/caller.d.ts +33 -0
- package/dist/procedures/caller.d.ts.map +1 -0
- package/dist/procedures/caller.js +27 -0
- package/dist/procedures/caller.js.map +1 -0
- package/dist/procedures/capability.d.ts +6 -0
- package/dist/procedures/capability.d.ts.map +1 -1
- package/dist/procedures/capability.js +25 -0
- package/dist/procedures/capability.js.map +1 -1
- package/dist/procedures/collection/changes.d.ts +94 -0
- package/dist/procedures/collection/changes.d.ts.map +1 -0
- package/dist/procedures/collection/changes.js +182 -0
- package/dist/procedures/collection/changes.js.map +1 -0
- package/dist/procedures/collection/index.d.ts +8 -3
- package/dist/procedures/collection/index.d.ts.map +1 -1
- package/dist/procedures/collection/index.js +6 -1
- package/dist/procedures/collection/index.js.map +1 -1
- package/dist/procedures/collection/procedures.d.ts +39 -2
- package/dist/procedures/collection/procedures.d.ts.map +1 -1
- package/dist/procedures/collection/procedures.js +131 -8
- package/dist/procedures/collection/procedures.js.map +1 -1
- package/dist/procedures/collection/query.d.ts +246 -0
- package/dist/procedures/collection/query.d.ts.map +1 -0
- package/dist/procedures/collection/query.js +854 -0
- package/dist/procedures/collection/query.js.map +1 -0
- package/dist/procedures/collection/schemas.d.ts +59 -0
- package/dist/procedures/collection/schemas.d.ts.map +1 -1
- package/dist/procedures/collection/schemas.js +172 -6
- package/dist/procedures/collection/schemas.js.map +1 -1
- package/dist/procedures/collection/storage.d.ts +64 -6
- package/dist/procedures/collection/storage.d.ts.map +1 -1
- package/dist/procedures/collection/storage.js +23 -0
- package/dist/procedures/collection/storage.js.map +1 -1
- package/dist/procedures/elicit.d.ts +190 -0
- package/dist/procedures/elicit.d.ts.map +1 -0
- package/dist/procedures/elicit.js +127 -0
- package/dist/procedures/elicit.js.map +1 -0
- package/dist/procedures/hints.d.ts +43 -0
- package/dist/procedures/hints.d.ts.map +1 -0
- package/dist/procedures/hints.js +74 -0
- package/dist/procedures/hints.js.map +1 -0
- package/dist/procedures/index.d.ts +12 -3
- package/dist/procedures/index.d.ts.map +1 -1
- package/dist/procedures/index.js +7 -1
- package/dist/procedures/index.js.map +1 -1
- package/dist/procedures/invoke.d.ts +20 -0
- package/dist/procedures/invoke.d.ts.map +1 -1
- package/dist/procedures/invoke.js +16 -0
- package/dist/procedures/invoke.js.map +1 -1
- package/dist/procedures/progress.d.ts +24 -0
- package/dist/procedures/progress.d.ts.map +1 -0
- package/dist/procedures/progress.js +11 -0
- package/dist/procedures/progress.js.map +1 -0
- package/dist/procedures/registry.d.ts.map +1 -1
- package/dist/procedures/registry.js +5 -0
- package/dist/procedures/registry.js.map +1 -1
- package/dist/procedures/types.d.ts +92 -0
- package/dist/procedures/types.d.ts.map +1 -1
- package/dist/procedures/types.js.map +1 -1
- package/dist/procedures/untrusted.d.ts +28 -0
- package/dist/procedures/untrusted.d.ts.map +1 -0
- package/dist/procedures/untrusted.js +32 -0
- package/dist/procedures/untrusted.js.map +1 -0
- package/dist/protocol.d.ts +1 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +1 -0
- package/dist/protocol.js.map +1 -1
- package/dist/server/browser-guard.d.ts +12 -2
- package/dist/server/browser-guard.d.ts.map +1 -1
- package/dist/server/browser-guard.js +43 -6
- package/dist/server/browser-guard.js.map +1 -1
- package/dist/server/host-policy.d.ts +10 -1
- package/dist/server/host-policy.d.ts.map +1 -1
- package/dist/server/host-policy.js +5 -0
- package/dist/server/host-policy.js.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/procedure-server.d.ts +75 -9
- package/dist/server/procedure-server.d.ts.map +1 -1
- package/dist/server/procedure-server.js +151 -17
- package/dist/server/procedure-server.js.map +1 -1
- package/dist/server/request-auth.d.ts +51 -4
- package/dist/server/request-auth.d.ts.map +1 -1
- package/dist/server/request-auth.js +116 -5
- package/dist/server/request-auth.js.map +1 -1
- package/dist/server/types.d.ts +73 -5
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/types.js.map +1 -1
- package/dist/timers.d.ts +5 -0
- package/dist/timers.d.ts.map +1 -1
- package/dist/timers.js +3 -1
- package/dist/timers.js.map +1 -1
- package/package.json +5 -1
- package/src/adapters/http/client/transport.ts +11 -1
- package/src/adapters/http/client/types.ts +22 -0
- package/src/adapters/http/server/cors.ts +144 -0
- package/src/adapters/http/server/handler.ts +624 -0
- package/src/adapters/http/server/strategies.ts +16 -6
- package/src/adapters/http/server/transport.ts +66 -479
- package/src/adapters/http/server/types.ts +15 -0
- package/src/adapters/websocket/client/transport.ts +9 -3
- package/src/adapters/websocket/server/transport.ts +189 -18
- package/src/adapters/websocket/server/types.ts +11 -3
- package/src/adapters/websocket/server/upgrade-router.ts +77 -0
- package/src/adapters/websocket/server/upgrade.ts +88 -0
- package/src/adapters/websocket/shared/rpc.ts +9 -2
- package/src/browser.ts +85 -2
- package/src/client/client.ts +83 -1
- package/src/client/errors/registry.ts +1 -0
- package/src/client/middleware/retry.ts +89 -78
- package/src/events/bus.ts +141 -73
- package/src/events/index.ts +3 -0
- package/src/events/publisher.ts +71 -10
- package/src/events/remote.ts +575 -73
- package/src/events/rows.ts +48 -0
- package/src/events/types.ts +15 -2
- package/src/index.ts +1 -1
- package/src/node.ts +21 -0
- package/src/procedures/caller.ts +45 -0
- package/src/procedures/capability.ts +21 -0
- package/src/procedures/collection/changes.ts +245 -0
- package/src/procedures/collection/index.ts +56 -1
- package/src/procedures/collection/procedures.ts +185 -7
- package/src/procedures/collection/query.ts +1009 -0
- package/src/procedures/collection/schemas.ts +233 -7
- package/src/procedures/collection/storage.ts +84 -6
- package/src/procedures/elicit.ts +320 -0
- package/src/procedures/hints.ts +91 -0
- package/src/procedures/index.ts +82 -2
- package/src/procedures/invoke.ts +31 -0
- package/src/procedures/progress.ts +25 -0
- package/src/procedures/registry.ts +4 -0
- package/src/procedures/types.ts +102 -0
- package/src/procedures/untrusted.ts +50 -0
- package/src/protocol.ts +3 -1
- package/src/server/browser-guard.ts +48 -6
- package/src/server/host-policy.ts +13 -1
- package/src/server/index.ts +3 -0
- package/src/server/procedure-server.ts +185 -20
- package/src/server/request-auth.ts +128 -6
- package/src/server/types.ts +82 -5
- package/src/timers.ts +8 -1
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The procedure handler of HTTP: a request handler of `node:http`, without a framework (CLI-3).
|
|
3
|
+
*
|
|
4
|
+
* `procedureHandler(server, options)` gives a function `(req, res) => boolean`. The function takes
|
|
5
|
+
* each request whose path is under `basePath` and gives `true`. It gives `false` for each other
|
|
6
|
+
* request and does not touch it, so the routes of the application get it. A mount list of a
|
|
7
|
+
* listener ("first match wins") can hold it next to other handlers. The wire is the wire of
|
|
8
|
+
* `HttpServerTransport` (`documentation/WIRE-PROTOCOL.md`). It has the URL of the method, the JSON
|
|
9
|
+
* body and the NDJSON streams of `writeNdjsonStream` with `seq` and keep-alive lines. It also has
|
|
10
|
+
* the error codes, the idempotency keys, the deadlines, the events and the browser checks.
|
|
11
|
+
*
|
|
12
|
+
* The handler also has the stop parts of a service transport: `start()`, `stopIntake()`,
|
|
13
|
+
* `drain(deadline, signal)`, `inFlight()` and `close()`. The handler does not make a server and
|
|
14
|
+
* does not listen: the owner of the listener does that.
|
|
15
|
+
*
|
|
16
|
+
* This module imports only the types of `node:http`, so it loads no Node code at run time.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { IncomingHttpHeaders, IncomingMessage, ServerResponse as NodeResponse } from "node:http";
|
|
20
|
+
import type { Metadata, Method } from "../../../client/types.js";
|
|
21
|
+
import type { RequestHandler, ServerRequest, ServerResponse } from "../../../server/types.js";
|
|
22
|
+
import { decodeMetadataHeader, METADATA_HEADER_LIMIT } from "../shared/index.js";
|
|
23
|
+
import { ERROR_REGISTRY } from "../../../client/errors/index.js";
|
|
24
|
+
import { assertNamedOrigins, checkBrowserRequest, needsNamedOrigin, type BrowserGuardOptions, type GuardResult } from "../../../server/browser-guard.js";
|
|
25
|
+
import { headerNames, isSecretHeader, queryRecord, requestCredentials, urlParts, withoutSecretHeaders } from "../../../server/request-auth.js";
|
|
26
|
+
import { PROTOCOL_HEADER, PROTOCOL_VERSION, httpStatusOf, protocolVersionOf } from "../../../protocol.js";
|
|
27
|
+
import { realClock, type Cancel, type Clock } from "../../../clock.js";
|
|
28
|
+
import { ms, type Duration } from "../../../duration.js";
|
|
29
|
+
import { NDJSON_MEDIA_TYPE as NDJSON, defaultNdjsonError as errorOf, keepAliveMsOf, writeNdjsonStream } from "./ndjson.js";
|
|
30
|
+
import { createPatternServerUrlStrategy } from "./strategies.js";
|
|
31
|
+
import { applyCors, corsOrigins, corsPolicy, type CorsOptions, type CorsPolicy } from "./cors.js";
|
|
32
|
+
|
|
33
|
+
/** The parts of a request that a URL strategy of `procedureHandler` reads. */
|
|
34
|
+
export interface UrlRequest {
|
|
35
|
+
/** The HTTP method, in upper case. */
|
|
36
|
+
readonly method: string;
|
|
37
|
+
/** The path of the URL, without the query. */
|
|
38
|
+
readonly path: string;
|
|
39
|
+
/** The URL of the request line: the path and the query. */
|
|
40
|
+
readonly url: string;
|
|
41
|
+
readonly headers: IncomingHttpHeaders;
|
|
42
|
+
/** The first value of each query parameter. */
|
|
43
|
+
readonly query: Readonly<Record<string, string>>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The URL strategy of `procedureHandler`: it gives the method of a request, or null when the URL
|
|
48
|
+
* names no method. The second argument is the request of `node:http`.
|
|
49
|
+
*/
|
|
50
|
+
export type NodeUrlStrategy = (request: UrlRequest, req: IncomingMessage) => Method | null;
|
|
51
|
+
|
|
52
|
+
/** The options of `procedureHandler`. */
|
|
53
|
+
export interface ProcedureHandlerOptions extends BrowserGuardOptions {
|
|
54
|
+
/**
|
|
55
|
+
* The path under which the handler takes the requests. A request outside it goes on.
|
|
56
|
+
* @default "/api"
|
|
57
|
+
*/
|
|
58
|
+
readonly basePath?: string | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* The method of a request. The default reads the URL format of `HttpTransport`:
|
|
61
|
+
* `{basePath}/{service}/{operation}`.
|
|
62
|
+
*/
|
|
63
|
+
readonly urlStrategy?: NodeUrlStrategy | undefined;
|
|
64
|
+
/**
|
|
65
|
+
* The CORS headers of the requests on the path. `true` uses the defaults: each origin, without
|
|
66
|
+
* credentials. The permitted origins of the browser checks follow the CORS origins.
|
|
67
|
+
* @default false
|
|
68
|
+
*/
|
|
69
|
+
readonly cors?: boolean | CorsOptions | undefined;
|
|
70
|
+
/**
|
|
71
|
+
* The largest request body, in bytes, when the handler reads the body itself (no body parser
|
|
72
|
+
* of a framework before it).
|
|
73
|
+
* @default 1 MiB
|
|
74
|
+
*/
|
|
75
|
+
readonly bodyLimit?: number | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* The quiet time of an NDJSON stream. After it, the handler writes a keep-alive line. `false`,
|
|
78
|
+
* or a duration of zero or less, sends no keep-alive line.
|
|
79
|
+
* @default seconds(15) (`TIMER_DEFAULTS.httpServer.keepAliveMs`)
|
|
80
|
+
*/
|
|
81
|
+
readonly keepAlive?: Duration | false | undefined;
|
|
82
|
+
/** The clock of the keep-alive timers and of `drain()`. A test gives a fake clock. */
|
|
83
|
+
readonly clock?: Clock | undefined;
|
|
84
|
+
/**
|
|
85
|
+
* More headers that carry a secret, for example the token header of an application. They go
|
|
86
|
+
* into the credentials of the request, not into its metadata. `Authorization`,
|
|
87
|
+
* `Proxy-Authorization`, `Cookie` and `X-Api-Key` are always secret.
|
|
88
|
+
*/
|
|
89
|
+
readonly secretHeaders?: readonly string[] | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* The query parameters that carry a secret. They go into the credentials of the request, not
|
|
92
|
+
* into its metadata.
|
|
93
|
+
* @default ["token"]
|
|
94
|
+
*/
|
|
95
|
+
readonly secretQuery?: readonly string[] | undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The request handler of `procedureHandler`. A call gives `true` when the handler takes the request
|
|
100
|
+
* (its path is under `basePath`), and `false` for another request. The handler then does not touch
|
|
101
|
+
* the request.
|
|
102
|
+
*/
|
|
103
|
+
export interface NodeProcedureHandler {
|
|
104
|
+
(req: IncomingMessage, res: NodeResponse): boolean;
|
|
105
|
+
/**
|
|
106
|
+
* This method handles a request as the call does. Its promise resolves when the response ends:
|
|
107
|
+
* `true` when the handler took the request, `false` for another request.
|
|
108
|
+
*/
|
|
109
|
+
handle(req: IncomingMessage, res: NodeResponse): Promise<boolean>;
|
|
110
|
+
/** This method gives true when the handler takes the request: its path is under `basePath`. */
|
|
111
|
+
matches(req: IncomingMessage): boolean;
|
|
112
|
+
/**
|
|
113
|
+
* The handler as a middleware of Connect or Express: a request outside `basePath` goes to `next`.
|
|
114
|
+
*/
|
|
115
|
+
middleware(req: IncomingMessage, res: NodeResponse, next: (error?: unknown) => void): void;
|
|
116
|
+
/** The path under which the handler takes the requests. */
|
|
117
|
+
readonly basePath: string;
|
|
118
|
+
/** This method accepts new requests again, after `stopIntake()`. */
|
|
119
|
+
start(): void;
|
|
120
|
+
/** This method refuses each new request with the retryable error `UNAVAILABLE` (503). */
|
|
121
|
+
stopIntake(): void;
|
|
122
|
+
/**
|
|
123
|
+
* This method waits until no request is in progress, until the `deadline` (milliseconds on the
|
|
124
|
+
* clock of the handler), or until the signal aborts.
|
|
125
|
+
*/
|
|
126
|
+
drain(deadline: number, signal?: AbortSignal): Promise<void>;
|
|
127
|
+
/** The number of requests in progress, open streams too. */
|
|
128
|
+
inFlight(): number;
|
|
129
|
+
/** This method aborts each request in progress: an open stream ends, and its handler gets `return()`. */
|
|
130
|
+
close(): void;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The parts of `HttpServerTransport` that are not of the wire v1 handler. The Express shim gives
|
|
135
|
+
* them, so its behavior stays the same. A new mount uses the defaults.
|
|
136
|
+
*
|
|
137
|
+
* @internal
|
|
138
|
+
*/
|
|
139
|
+
export interface HandlerCompat {
|
|
140
|
+
/**
|
|
141
|
+
* True: the error codes and the behavior of `HttpServerTransport` before the handler. A stopped
|
|
142
|
+
* transport answers `SERVER_STOPPED`, a refused browser request has the code of the check, and
|
|
143
|
+
* an `OPTIONS` request goes to the procedures.
|
|
144
|
+
*/
|
|
145
|
+
readonly legacy?: boolean | undefined;
|
|
146
|
+
/** The method of a request: the URL strategy of Express. */
|
|
147
|
+
readonly methodOf?: ((req: IncomingMessage) => Method | null) | undefined;
|
|
148
|
+
/** The JSON response of the framework (`res.status(status).json(body)` of Express). */
|
|
149
|
+
readonly sendJson?: ((res: NodeResponse, status: number, body: unknown) => void) | undefined;
|
|
150
|
+
/** True: the shim applies CORS to each request of its app, so the handler does not. */
|
|
151
|
+
readonly corsOutside?: boolean | undefined;
|
|
152
|
+
/** True: the framework matched the path already, so the handler takes each request that it gets. */
|
|
153
|
+
readonly matchAll?: boolean | undefined;
|
|
154
|
+
/** The name of the face and of its CORS origin option, for the error of `assertNamedOrigins`. */
|
|
155
|
+
readonly face?: { readonly name: string; readonly originOption: string } | undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The error of a request body or of a header that the handler cannot read. */
|
|
159
|
+
class BodyError extends Error {
|
|
160
|
+
constructor(
|
|
161
|
+
message: string,
|
|
162
|
+
readonly httpStatus: number
|
|
163
|
+
) {
|
|
164
|
+
super(message);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** True when a stream response has no end (`STREAM_METADATA_KEY` of procedure-server.ts). */
|
|
169
|
+
function isUnboundedStream(response: { metadata?: Record<string, unknown> | undefined }): boolean {
|
|
170
|
+
return response.metadata?.["stream"] === "unbounded";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The metadata keys that the transport sets: a query parameter or a header cannot replace them. */
|
|
174
|
+
const RESERVED_METADATA = new Set(["headers", "query", "params"]);
|
|
175
|
+
|
|
176
|
+
/** The headers that do not go to the root of the metadata. The client sends them as such. */
|
|
177
|
+
const STANDARD_HEADERS = new Set([
|
|
178
|
+
"host",
|
|
179
|
+
"connection",
|
|
180
|
+
"content-length",
|
|
181
|
+
"content-type",
|
|
182
|
+
"user-agent",
|
|
183
|
+
"accept",
|
|
184
|
+
"accept-encoding",
|
|
185
|
+
"accept-language",
|
|
186
|
+
"cache-control",
|
|
187
|
+
"origin",
|
|
188
|
+
"referer",
|
|
189
|
+
"x-request-id",
|
|
190
|
+
"x-trace-id",
|
|
191
|
+
"x-span-id",
|
|
192
|
+
"x-parent-span-id",
|
|
193
|
+
"x-timeout",
|
|
194
|
+
"x-api-key",
|
|
195
|
+
"authorization",
|
|
196
|
+
"x-metadata",
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
/** The known custom headers whose metadata key is not the header name (lower case to camel case). */
|
|
200
|
+
const HEADER_MAPPINGS: Record<string, string> = { collectionname: "collectionName" };
|
|
201
|
+
|
|
202
|
+
/** The reasons of the `FORBIDDEN` errors of the browser checks (wire v1). */
|
|
203
|
+
const GUARD_REASONS: Record<string, string> = {
|
|
204
|
+
FORBIDDEN_HOST: "host-header-not-allowed",
|
|
205
|
+
FORBIDDEN_ORIGIN: "origin-not-allowed",
|
|
206
|
+
METHOD_NOT_ALLOWED: "method-not-allowed",
|
|
207
|
+
UNSUPPORTED_MEDIA_TYPE: "media-type-not-allowed",
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** This function writes a JSON response without a framework. */
|
|
211
|
+
function writeJson(res: NodeResponse, status: number, body: unknown): void {
|
|
212
|
+
const text = JSON.stringify(body);
|
|
213
|
+
res.statusCode = status;
|
|
214
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
215
|
+
res.setHeader("Content-Length", Buffer.byteLength(text));
|
|
216
|
+
res.end(text);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The path of a request without a framework: the path of `req.url`, without the query. */
|
|
220
|
+
function pathOf(req: IncomingMessage): string {
|
|
221
|
+
return urlParts(req.url).path;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The query that a framework parsed (`req.query` of Express), or the query of the URL. */
|
|
225
|
+
function queryOf(req: IncomingMessage): Record<string, unknown> {
|
|
226
|
+
const parsed = (req as { query?: unknown }).query;
|
|
227
|
+
if (parsed !== null && typeof parsed === "object") return parsed as Record<string, unknown>;
|
|
228
|
+
const { query } = urlParts(req.url);
|
|
229
|
+
const out = new Map<string, string | string[]>();
|
|
230
|
+
for (const key of new Set(query.keys())) {
|
|
231
|
+
const values = query.getAll(key);
|
|
232
|
+
out.set(key, values.length === 1 ? values[0]! : values);
|
|
233
|
+
}
|
|
234
|
+
return Object.fromEntries(out);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The route parameters that a framework parsed (`req.params` of Express), or none. */
|
|
238
|
+
function paramsOf(req: IncomingMessage): Record<string, string> {
|
|
239
|
+
const parsed = (req as { params?: unknown }).params;
|
|
240
|
+
return parsed !== null && typeof parsed === "object" ? (parsed as Record<string, string>) : {};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* This function makes a procedure handler: a request handler of `node:http` that serves the
|
|
245
|
+
* procedures of `server` under `options.basePath`, with wire protocol v1.
|
|
246
|
+
*
|
|
247
|
+
* @example
|
|
248
|
+
* ```typescript
|
|
249
|
+
* const server = new ProcedureServer({ registry, token });
|
|
250
|
+
* const procedures = procedureHandler(server, { basePath: "/api/proc" });
|
|
251
|
+
* createServer((req, res) => {
|
|
252
|
+
* if (procedures(req, res)) return;
|
|
253
|
+
* appRoutes(req, res); // for example /api/chat
|
|
254
|
+
* }).listen(4820);
|
|
255
|
+
* ```
|
|
256
|
+
*/
|
|
257
|
+
export function procedureHandler(server: RequestHandler, options: ProcedureHandlerOptions = {}): NodeProcedureHandler {
|
|
258
|
+
return createProcedureHandler(server, options);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* This function makes the procedure handler with the parts of a compatibility shim.
|
|
263
|
+
*
|
|
264
|
+
* @internal
|
|
265
|
+
*/
|
|
266
|
+
export function createProcedureHandler(server: RequestHandler, options: ProcedureHandlerOptions, compat: HandlerCompat = {}): NodeProcedureHandler {
|
|
267
|
+
const legacy = compat.legacy === true;
|
|
268
|
+
const basePath = options.basePath ?? "/api";
|
|
269
|
+
const prefix = basePath.endsWith("/") ? basePath : `${basePath}/`;
|
|
270
|
+
const bodyLimit = options.bodyLimit ?? 1024 * 1024;
|
|
271
|
+
// A value that is not a duration fails here, not at the first stream
|
|
272
|
+
const keepAliveMs = keepAliveMsOf(options.keepAlive);
|
|
273
|
+
const keepAlive: Duration | false = keepAliveMs > 0 ? ms(keepAliveMs) : false;
|
|
274
|
+
const clock = options.clock ?? realClock;
|
|
275
|
+
const corsOptions: CorsOptions | undefined = options.cors === true ? {} : options.cors === false ? undefined : options.cors;
|
|
276
|
+
// A list of origins or a function: the default sends the credentials. "*" with credentials is invalid.
|
|
277
|
+
const cors: CorsPolicy | undefined =
|
|
278
|
+
corsOptions === undefined || compat.corsOutside ? undefined : corsPolicy(corsOptions, (corsOptions.origin ?? "*") !== "*");
|
|
279
|
+
const guard: BrowserGuardOptions = {
|
|
280
|
+
allowedOrigins: options.allowedOrigins ?? corsOrigins(corsOptions),
|
|
281
|
+
allowedHosts: options.allowedHosts,
|
|
282
|
+
allowGet: options.allowGet,
|
|
283
|
+
};
|
|
284
|
+
// CSRF: a server that reads the cookies of each request needs named origins (CLI-2a)
|
|
285
|
+
const ambientMode = server.ambientCredentials;
|
|
286
|
+
assertNamedOrigins(compat.face?.name ?? "procedureHandler", ambientMode, guard.allowedOrigins, compat.face?.originOption ?? "cors.origin");
|
|
287
|
+
const secretHeaders = headerNames(options.secretHeaders);
|
|
288
|
+
const secretQuery = new Set(options.secretQuery ?? (legacy ? [] : ["token"]));
|
|
289
|
+
const strategy = options.urlStrategy ?? createPatternServerUrlStrategy(basePath);
|
|
290
|
+
const methodOf =
|
|
291
|
+
compat.methodOf ??
|
|
292
|
+
((req: IncomingMessage): Method | null => {
|
|
293
|
+
const { path, query } = urlParts(req.url);
|
|
294
|
+
const view: UrlRequest = { method: (req.method ?? "GET").toUpperCase(), path, url: req.url ?? "/", headers: req.headers, query: queryRecord(query) };
|
|
295
|
+
return strategy(view, req);
|
|
296
|
+
});
|
|
297
|
+
const sendJson = compat.sendJson ?? writeJson;
|
|
298
|
+
|
|
299
|
+
/** The controllers of the requests in progress: close() aborts them (deep dive TRN-15) */
|
|
300
|
+
const openRequests = new Set<AbortController>();
|
|
301
|
+
/** The functions that drain() waits on: each one runs when no request is in progress */
|
|
302
|
+
let idleWaiters: Array<() => void> = [];
|
|
303
|
+
let stopped = false;
|
|
304
|
+
|
|
305
|
+
const requestEnded = (controller: AbortController): void => {
|
|
306
|
+
if (!openRequests.delete(controller) || openRequests.size > 0) return;
|
|
307
|
+
const waiters = idleWaiters;
|
|
308
|
+
idleWaiters = [];
|
|
309
|
+
for (const waiter of waiters) waiter();
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const matches = (req: IncomingMessage): boolean => compat.matchAll === true || pathOf(req).startsWith(prefix);
|
|
313
|
+
|
|
314
|
+
/** The error body of wire v1: the message, the code, the retryable flag and the more fields. */
|
|
315
|
+
const sendError = (res: NodeResponse, status: number, message: string, code: string, retryable = false, more: Record<string, unknown> = {}): void => {
|
|
316
|
+
sendJson(res, status, { error: message, code, retryable, ...more });
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const refuseGuard = (res: NodeResponse, refusal: Extract<GuardResult, { ok: false }>): void => {
|
|
320
|
+
if (legacy) {
|
|
321
|
+
sendError(res, refusal.status, refusal.message, refusal.code);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
sendError(res, 403, refusal.message, "FORBIDDEN", false, { reason: GUARD_REASONS[refusal.code] ?? refusal.code.toLowerCase() });
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The request body. A body parser of a framework (`express.json({ strict: false })`) gives
|
|
329
|
+
* `req.body`. Without one, the handler reads the JSON body itself (deep dive TRN-10).
|
|
330
|
+
*/
|
|
331
|
+
const readBody = async (req: IncomingMessage): Promise<unknown> => {
|
|
332
|
+
const parsed = (req as { body?: unknown }).body;
|
|
333
|
+
if (parsed !== undefined) return parsed;
|
|
334
|
+
if (req.method === "GET" || req.method === "HEAD" || req.readableEnded) return undefined;
|
|
335
|
+
const chunks: Buffer[] = [];
|
|
336
|
+
let size = 0;
|
|
337
|
+
for await (const chunk of req as AsyncIterable<Buffer | string>) {
|
|
338
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
339
|
+
size += buffer.length;
|
|
340
|
+
if (size > bodyLimit) throw new BodyError(`The request body is larger than ${bodyLimit} bytes`, 413);
|
|
341
|
+
chunks.push(buffer);
|
|
342
|
+
}
|
|
343
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
344
|
+
if (text.trim() === "") return undefined;
|
|
345
|
+
try {
|
|
346
|
+
return JSON.parse(text) as unknown;
|
|
347
|
+
} catch {
|
|
348
|
+
throw new BodyError("The request body is not valid JSON", 400);
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
/** This function converts an HTTP request to a `ServerRequest`, or null when the URL names no method. */
|
|
353
|
+
const toServerRequest = (req: IncomingMessage, body: unknown): ServerRequest | null => {
|
|
354
|
+
const method = methodOf(req);
|
|
355
|
+
if (!method) return null;
|
|
356
|
+
|
|
357
|
+
const id = (req.headers["x-request-id"] as string | undefined) ?? `req-${Date.now()}-${Math.random()}`;
|
|
358
|
+
|
|
359
|
+
// The query without its secret parameters: they go into the credentials (CLI-2a)
|
|
360
|
+
const query: Record<string, unknown> = {};
|
|
361
|
+
for (const [key, value] of Object.entries(queryOf(req))) if (!secretQuery.has(key)) query[key] = value;
|
|
362
|
+
const params = paramsOf(req);
|
|
363
|
+
|
|
364
|
+
// The metadata of the headers and of the query. The query parameters cannot replace headers,
|
|
365
|
+
// query or params. The secret headers go into the credentials, not into the metadata, so a
|
|
366
|
+
// handler and a nested call do not see them (roadmap R18)
|
|
367
|
+
const metadata: Metadata = {
|
|
368
|
+
headers: withoutSecretHeaders(req.headers as Record<string, unknown>, secretHeaders),
|
|
369
|
+
query,
|
|
370
|
+
params,
|
|
371
|
+
};
|
|
372
|
+
for (const [key, value] of Object.entries(query)) {
|
|
373
|
+
if (!RESERVED_METADATA.has(key) && !key.startsWith("__")) metadata[key] = value;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// The custom headers at the root of the metadata, as the client's metadataToHeaders() sends
|
|
377
|
+
// them. A secret header (Cookie too) does not go there.
|
|
378
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
379
|
+
const lower = key.toLowerCase();
|
|
380
|
+
if (STANDARD_HEADERS.has(lower) || isSecretHeader(lower, secretHeaders) || typeof value !== "string") continue;
|
|
381
|
+
const normalizedKey = HEADER_MAPPINGS[lower] ?? key;
|
|
382
|
+
if (!RESERVED_METADATA.has(normalizedKey) && !normalizedKey.startsWith("__")) metadata[normalizedKey] = value;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// The custom metadata of the HTTP client: any JSON value, any key (deep dive TRN-11)
|
|
386
|
+
const encoded = req.headers["x-metadata"];
|
|
387
|
+
if (typeof encoded === "string") {
|
|
388
|
+
if (encoded.length > METADATA_HEADER_LIMIT) {
|
|
389
|
+
throw new BodyError(`The X-Metadata header is longer than ${METADATA_HEADER_LIMIT} characters`, 431);
|
|
390
|
+
}
|
|
391
|
+
const custom = decodeMetadataHeader(encoded);
|
|
392
|
+
if (!custom) throw new BodyError("The X-Metadata header is not URI-encoded JSON of an object", 400);
|
|
393
|
+
for (const [key, value] of Object.entries(custom)) {
|
|
394
|
+
if (!RESERVED_METADATA.has(key) && key !== "auth" && !key.startsWith("__")) metadata[key] = value;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// The payload: the body, or the id of a RESTful GET. Only an absent payload becomes {}: 0,
|
|
399
|
+
// false, "" and null stay (deep dive TRN-10)
|
|
400
|
+
let payload: unknown = body;
|
|
401
|
+
if (req.method === "GET" && params["id"]) payload = { id: params["id"] };
|
|
402
|
+
if (payload === undefined) payload = {};
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
id,
|
|
406
|
+
method,
|
|
407
|
+
payload,
|
|
408
|
+
metadata,
|
|
409
|
+
// A network request: ProcedureServer examines the token and the request hook (R18, CLI-2a)
|
|
410
|
+
credentials: requestCredentials({ headers: req.headers as Record<string, unknown>, url: req.url }),
|
|
411
|
+
};
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
/** This function converts a `ServerResponse` to the HTTP response. */
|
|
415
|
+
const toHttp = (serverResponse: ServerResponse, res: NodeResponse): void => {
|
|
416
|
+
const { status, payload, metadata } = serverResponse;
|
|
417
|
+
|
|
418
|
+
// A symbolic code maps to its HTTP status: the protocol list, then the error registry
|
|
419
|
+
// (BUGS-2026-07 M7). A number is the status.
|
|
420
|
+
let httpStatusCode: number;
|
|
421
|
+
if (typeof status.code === "string") {
|
|
422
|
+
const mapped = httpStatusOf(status.code) ?? (ERROR_REGISTRY as Record<string, { httpStatus?: number }>)[status.code]?.httpStatus;
|
|
423
|
+
httpStatusCode = mapped ?? (status.type === "success" ? 200 : 500);
|
|
424
|
+
} else if (typeof status.code === "number") {
|
|
425
|
+
httpStatusCode = status.code;
|
|
426
|
+
} else {
|
|
427
|
+
httpStatusCode = status.type === "success" ? 200 : 500;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (metadata["headers"]) {
|
|
431
|
+
for (const [key, value] of Object.entries(metadata["headers"])) res.setHeader(key, value as string);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (status.type === "success") {
|
|
435
|
+
// No result: 204 with no body (deep dive TRN-10)
|
|
436
|
+
if (payload === undefined) {
|
|
437
|
+
res.statusCode = 204;
|
|
438
|
+
res.end();
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
sendJson(res, httpStatusCode, payload);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
// The wait that the error asks for: a Retry-After header in seconds, and the exact
|
|
445
|
+
// milliseconds in the body (R12)
|
|
446
|
+
const retryAfterMs = metadata["retry-after"];
|
|
447
|
+
const hasRetryAfter = typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) && retryAfterMs >= 0;
|
|
448
|
+
if (hasRetryAfter) res.setHeader("Retry-After", String(Math.ceil(retryAfterMs / 1000)));
|
|
449
|
+
// The reason of a FORBIDDEN error travels on each face (roadmap R25)
|
|
450
|
+
const reason = typeof metadata["reason"] === "string" ? metadata["reason"] : undefined;
|
|
451
|
+
sendJson(res, httpStatusCode, {
|
|
452
|
+
error: status.message,
|
|
453
|
+
code: status.code,
|
|
454
|
+
retryable: status.retryable,
|
|
455
|
+
...(hasRetryAfter ? { retryAfterMs } : {}),
|
|
456
|
+
...(reason !== undefined ? { reason } : {}),
|
|
457
|
+
});
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* This function sends a stream response. A client that accepts NDJSON gets the lines of
|
|
462
|
+
* `writeNdjsonStream`. Another client gets the last item as plain JSON, the "sponge" mode, and a
|
|
463
|
+
* stream without an end gets `NOT_ACCEPTABLE` (roadmap R15).
|
|
464
|
+
*/
|
|
465
|
+
const streamToHttp = async (response: ServerResponse, stream: AsyncIterable<unknown>, req: IncomingMessage, res: NodeResponse, signal: AbortSignal): Promise<void> => {
|
|
466
|
+
const ndjson = String(req.headers.accept ?? "").includes(NDJSON);
|
|
467
|
+
|
|
468
|
+
if (!ndjson && isUnboundedStream(response)) {
|
|
469
|
+
void Promise.resolve(stream[Symbol.asyncIterator]().return?.()).catch(() => undefined);
|
|
470
|
+
const message = `This procedure gives a stream that can run without an end. Send "Accept: ${NDJSON}" to read its items.`;
|
|
471
|
+
toHttp({ id: response.id, status: { type: "error", code: "NOT_ACCEPTABLE", message, retryable: false }, metadata: {} }, res);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (!ndjson) {
|
|
476
|
+
let last: { value: unknown } | undefined;
|
|
477
|
+
try {
|
|
478
|
+
for await (const item of stream) last = { value: item };
|
|
479
|
+
} catch (error) {
|
|
480
|
+
toHttp({ id: response.id, status: { type: "error", ...errorOf(error) }, metadata: {} }, res);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (!last) {
|
|
484
|
+
toHttp({ id: response.id, status: { type: "error", code: "NO_OUTPUT", message: "The procedure gave no output", retryable: false }, metadata: {} }, res);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
toHttp({ id: response.id, status: response.status, metadata: response.metadata, payload: last.value }, res);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// The lines, the backpressure, the keep-alive and the abort of close() (WIRE-PROTOCOL.md)
|
|
492
|
+
await writeNdjsonStream(res, stream, { signal, keepAlive, errorOf, clock });
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
/** This function serves a request that the handler took. */
|
|
496
|
+
const serve = async (req: IncomingMessage, res: NodeResponse): Promise<void> => {
|
|
497
|
+
if (cors && applyCors(req, res, cors)) return;
|
|
498
|
+
// The protocol version of this server, on each response (roadmap R25)
|
|
499
|
+
res.setHeader(PROTOCOL_HEADER, String(PROTOCOL_VERSION));
|
|
500
|
+
const version = protocolVersionOf(req.headers[PROTOCOL_HEADER.toLowerCase()]);
|
|
501
|
+
if (version !== undefined && version > PROTOCOL_VERSION) {
|
|
502
|
+
sendJson(res, 400, {
|
|
503
|
+
error: `This server speaks the protocol ${PROTOCOL_VERSION}, and the request uses ${version}`,
|
|
504
|
+
code: "PROTOCOL_VERSION",
|
|
505
|
+
retryable: false,
|
|
506
|
+
protocol: PROTOCOL_VERSION,
|
|
507
|
+
});
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (stopped) {
|
|
511
|
+
if (legacy) sendError(res, 503, "Server stopped", "SERVER_STOPPED", true);
|
|
512
|
+
else sendError(res, 503, "The server is stopping", "UNAVAILABLE", true);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
// A preflight without CORS: no procedure runs for it, and the browser sees no CORS header
|
|
516
|
+
if (!legacy && req.method === "OPTIONS") {
|
|
517
|
+
res.statusCode = 204;
|
|
518
|
+
res.end();
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
// A web page must not reach the procedures: host, origin, method and body type
|
|
522
|
+
// Ambient credentials (cookies) are trusted only from a named origin. The shim keeps its former
|
|
523
|
+
// checks for a server that reads no cookies.
|
|
524
|
+
const ambient = needsNamedOrigin(ambientMode, req.headers, !legacy);
|
|
525
|
+
const check = checkBrowserRequest(req, guard, true, ambient);
|
|
526
|
+
if (!check.ok) {
|
|
527
|
+
refuseGuard(res, check);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
// A closed connection or close() aborts the request: a stream stops, and its handler gets return()
|
|
531
|
+
const controller = new AbortController();
|
|
532
|
+
openRequests.add(controller);
|
|
533
|
+
res.on("close", () => {
|
|
534
|
+
if (!res.writableFinished) controller.abort();
|
|
535
|
+
requestEnded(controller);
|
|
536
|
+
});
|
|
537
|
+
try {
|
|
538
|
+
let serverRequest: ServerRequest | null;
|
|
539
|
+
try {
|
|
540
|
+
serverRequest = toServerRequest(req, await readBody(req));
|
|
541
|
+
} catch (error) {
|
|
542
|
+
const status = error instanceof BodyError ? error.httpStatus : 400;
|
|
543
|
+
sendError(res, status, (error as Error).message, "INVALID_REQUEST");
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (!serverRequest) {
|
|
547
|
+
sendError(res, 400, "Invalid request: could not parse RPC method from URL", "INVALID_REQUEST");
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
serverRequest.signal = controller.signal;
|
|
551
|
+
|
|
552
|
+
const serverResponse = await server.handle(serverRequest);
|
|
553
|
+
if (serverResponse.stream) {
|
|
554
|
+
await streamToHttp(serverResponse, serverResponse.stream, req, res, controller.signal);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
toHttp(serverResponse, res);
|
|
558
|
+
} catch (error) {
|
|
559
|
+
if (!res.headersSent) sendError(res, 500, (error as Error).message || "Internal server error", "INTERNAL_ERROR");
|
|
560
|
+
} finally {
|
|
561
|
+
// An open response (an error before its end) stays in progress until its close event
|
|
562
|
+
if (res.writableEnded || res.destroyed) requestEnded(controller);
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
/** This function serves a request, and does not reject: an error ends the response with 500. */
|
|
567
|
+
const settle = (req: IncomingMessage, res: NodeResponse): Promise<void> =>
|
|
568
|
+
serve(req, res).catch((error: unknown) => {
|
|
569
|
+
if (!res.headersSent) sendError(res, 500, error instanceof Error ? error.message : String(error), "INTERNAL_ERROR");
|
|
570
|
+
else if (!res.writableEnded) res.destroy();
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
const handler = ((req: IncomingMessage, res: NodeResponse): boolean => {
|
|
574
|
+
if (!matches(req)) return false;
|
|
575
|
+
void settle(req, res);
|
|
576
|
+
return true;
|
|
577
|
+
}) as NodeProcedureHandler;
|
|
578
|
+
|
|
579
|
+
return Object.assign(handler, {
|
|
580
|
+
basePath,
|
|
581
|
+
async handle(req: IncomingMessage, res: NodeResponse): Promise<boolean> {
|
|
582
|
+
if (!matches(req)) return false;
|
|
583
|
+
await settle(req, res);
|
|
584
|
+
return true;
|
|
585
|
+
},
|
|
586
|
+
matches,
|
|
587
|
+
middleware(req: IncomingMessage, res: NodeResponse, next: (error?: unknown) => void): void {
|
|
588
|
+
if (!handler(req, res)) next();
|
|
589
|
+
},
|
|
590
|
+
start(): void {
|
|
591
|
+
stopped = false;
|
|
592
|
+
},
|
|
593
|
+
stopIntake(): void {
|
|
594
|
+
stopped = true;
|
|
595
|
+
},
|
|
596
|
+
drain(deadline: number, signal?: AbortSignal): Promise<void> {
|
|
597
|
+
if (openRequests.size === 0 || signal?.aborted) return Promise.resolve();
|
|
598
|
+
return new Promise<void>((resolve) => {
|
|
599
|
+
let timer: Cancel | undefined;
|
|
600
|
+
const done = (): void => {
|
|
601
|
+
timer?.();
|
|
602
|
+
signal?.removeEventListener("abort", done);
|
|
603
|
+
const index = idleWaiters.indexOf(done);
|
|
604
|
+
if (index >= 0) idleWaiters.splice(index, 1);
|
|
605
|
+
resolve();
|
|
606
|
+
};
|
|
607
|
+
idleWaiters.push(done);
|
|
608
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
609
|
+
timer = clock.after(Math.max(0, deadline - clock.now()), done);
|
|
610
|
+
timer.unref?.();
|
|
611
|
+
});
|
|
612
|
+
},
|
|
613
|
+
inFlight(): number {
|
|
614
|
+
return openRequests.size;
|
|
615
|
+
},
|
|
616
|
+
close(): void {
|
|
617
|
+
for (const controller of [...openRequests]) controller.abort();
|
|
618
|
+
openRequests.clear();
|
|
619
|
+
const waiters = idleWaiters;
|
|
620
|
+
idleWaiters = [];
|
|
621
|
+
for (const waiter of waiters) waiter();
|
|
622
|
+
},
|
|
623
|
+
});
|
|
624
|
+
}
|
|
@@ -4,10 +4,20 @@
|
|
|
4
4
|
* Functions for mapping HTTP requests to RPC methods.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { Request } from "express";
|
|
8
7
|
import type { Method } from "../../../client/types.js";
|
|
9
8
|
import { defaultUrlPattern } from "../shared/strategies.js";
|
|
10
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The parts of a request that the URL strategies read: the HTTP method and the path. A `Request`
|
|
12
|
+
* of Express has them, and so does the `UrlRequest` of `procedureHandler`.
|
|
13
|
+
*/
|
|
14
|
+
export interface RoutedRequest {
|
|
15
|
+
/** The HTTP method. */
|
|
16
|
+
readonly method: string;
|
|
17
|
+
/** The path of the URL, without the query. */
|
|
18
|
+
readonly path: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
11
21
|
/**
|
|
12
22
|
* Default URL strategy: RESTful routes.
|
|
13
23
|
*
|
|
@@ -18,7 +28,7 @@ import { defaultUrlPattern } from "../shared/strategies.js";
|
|
|
18
28
|
* - PUT /api/users/:id → { service: "users", operation: "update" }
|
|
19
29
|
* - DELETE /api/users/:id → { service: "users", operation: "delete" }
|
|
20
30
|
*/
|
|
21
|
-
export function defaultServerUrlStrategy(req:
|
|
31
|
+
export function defaultServerUrlStrategy(req: RoutedRequest): Method | null {
|
|
22
32
|
const path = req.path;
|
|
23
33
|
const method = req.method.toUpperCase();
|
|
24
34
|
|
|
@@ -62,7 +72,7 @@ export function defaultServerUrlStrategy(req: Request): Method | null {
|
|
|
62
72
|
* - POST /rpc/users/get → { service: "users", operation: "get" }
|
|
63
73
|
* - POST /rpc/users/list → { service: "users", operation: "list" }
|
|
64
74
|
*/
|
|
65
|
-
export function rpcServerUrlStrategy(req:
|
|
75
|
+
export function rpcServerUrlStrategy(req: RoutedRequest): Method | null {
|
|
66
76
|
const path = req.path;
|
|
67
77
|
|
|
68
78
|
// Match pattern: /rpc/:service/:operation
|
|
@@ -90,8 +100,8 @@ export function rpcServerUrlStrategy(req: Request): Method | null {
|
|
|
90
100
|
*
|
|
91
101
|
* @param basePath - Base path prefix to strip before parsing (default "/api").
|
|
92
102
|
*/
|
|
93
|
-
export function createPatternServerUrlStrategy(basePath = "/api"): (req:
|
|
94
|
-
return (req:
|
|
103
|
+
export function createPatternServerUrlStrategy(basePath = "/api"): (req: RoutedRequest) => Method | null {
|
|
104
|
+
return (req: RoutedRequest): Method | null => {
|
|
95
105
|
let path = req.path;
|
|
96
106
|
if (basePath && path.startsWith(basePath)) {
|
|
97
107
|
path = path.slice(basePath.length);
|
|
@@ -102,4 +112,4 @@ export function createPatternServerUrlStrategy(basePath = "/api"): (req: Request
|
|
|
102
112
|
}
|
|
103
113
|
|
|
104
114
|
/** Pattern strategy with the standard "/api" base path (the server transport default). */
|
|
105
|
-
export const patternServerUrlStrategy: (req:
|
|
115
|
+
export const patternServerUrlStrategy: (req: RoutedRequest) => Method | null = createPatternServerUrlStrategy();
|