@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.
Files changed (234) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +115 -11
  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 +36 -46
  22. package/dist/adapters/http/server/transport.d.ts.map +1 -1
  23. package/dist/adapters/http/server/transport.js +58 -433
  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 +7 -3
  28. package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
  29. package/dist/adapters/websocket/client/transport.js +9 -3
  30. package/dist/adapters/websocket/client/transport.js.map +1 -1
  31. package/dist/adapters/websocket/server/transport.d.ts +56 -1
  32. package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
  33. package/dist/adapters/websocket/server/transport.js +174 -17
  34. package/dist/adapters/websocket/server/transport.js.map +1 -1
  35. package/dist/adapters/websocket/server/types.d.ts +10 -3
  36. package/dist/adapters/websocket/server/types.d.ts.map +1 -1
  37. package/dist/adapters/websocket/server/upgrade-router.d.ts +28 -0
  38. package/dist/adapters/websocket/server/upgrade-router.d.ts.map +1 -0
  39. package/dist/adapters/websocket/server/upgrade-router.js +62 -0
  40. package/dist/adapters/websocket/server/upgrade-router.js.map +1 -0
  41. package/dist/adapters/websocket/server/upgrade.d.ts +68 -0
  42. package/dist/adapters/websocket/server/upgrade.d.ts.map +1 -0
  43. package/dist/adapters/websocket/server/upgrade.js +49 -0
  44. package/dist/adapters/websocket/server/upgrade.js.map +1 -0
  45. package/dist/adapters/websocket/shared/rpc.d.ts +2 -0
  46. package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
  47. package/dist/adapters/websocket/shared/rpc.js +8 -2
  48. package/dist/adapters/websocket/shared/rpc.js.map +1 -1
  49. package/dist/browser.d.ts +14 -6
  50. package/dist/browser.d.ts.map +1 -1
  51. package/dist/browser.js +12 -3
  52. package/dist/browser.js.map +1 -1
  53. package/dist/client/client.d.ts +43 -0
  54. package/dist/client/client.d.ts.map +1 -1
  55. package/dist/client/client.js +69 -1
  56. package/dist/client/client.js.map +1 -1
  57. package/dist/client/errors/registry.d.ts.map +1 -1
  58. package/dist/client/errors/registry.js +1 -0
  59. package/dist/client/errors/registry.js.map +1 -1
  60. package/dist/client/middleware/retry.d.ts +4 -0
  61. package/dist/client/middleware/retry.d.ts.map +1 -1
  62. package/dist/client/middleware/retry.js +86 -78
  63. package/dist/client/middleware/retry.js.map +1 -1
  64. package/dist/events/bus.d.ts +32 -14
  65. package/dist/events/bus.d.ts.map +1 -1
  66. package/dist/events/bus.js +119 -65
  67. package/dist/events/bus.js.map +1 -1
  68. package/dist/events/index.d.ts +3 -2
  69. package/dist/events/index.d.ts.map +1 -1
  70. package/dist/events/index.js.map +1 -1
  71. package/dist/events/publisher.d.ts +14 -0
  72. package/dist/events/publisher.d.ts.map +1 -1
  73. package/dist/events/publisher.js +62 -10
  74. package/dist/events/publisher.js.map +1 -1
  75. package/dist/events/remote.d.ts +80 -18
  76. package/dist/events/remote.d.ts.map +1 -1
  77. package/dist/events/remote.js +448 -58
  78. package/dist/events/remote.js.map +1 -1
  79. package/dist/events/rows.d.ts +31 -0
  80. package/dist/events/rows.d.ts.map +1 -0
  81. package/dist/events/rows.js +39 -0
  82. package/dist/events/rows.js.map +1 -0
  83. package/dist/events/types.d.ts +13 -2
  84. package/dist/events/types.d.ts.map +1 -1
  85. package/dist/index.d.ts +1 -1
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +1 -1
  88. package/dist/index.js.map +1 -1
  89. package/dist/node.d.ts +14 -0
  90. package/dist/node.d.ts.map +1 -1
  91. package/dist/node.js +9 -0
  92. package/dist/node.js.map +1 -1
  93. package/dist/procedures/caller.d.ts +33 -0
  94. package/dist/procedures/caller.d.ts.map +1 -0
  95. package/dist/procedures/caller.js +27 -0
  96. package/dist/procedures/caller.js.map +1 -0
  97. package/dist/procedures/capability.d.ts +6 -0
  98. package/dist/procedures/capability.d.ts.map +1 -1
  99. package/dist/procedures/capability.js +25 -0
  100. package/dist/procedures/capability.js.map +1 -1
  101. package/dist/procedures/collection/changes.d.ts +94 -0
  102. package/dist/procedures/collection/changes.d.ts.map +1 -0
  103. package/dist/procedures/collection/changes.js +182 -0
  104. package/dist/procedures/collection/changes.js.map +1 -0
  105. package/dist/procedures/collection/index.d.ts +8 -3
  106. package/dist/procedures/collection/index.d.ts.map +1 -1
  107. package/dist/procedures/collection/index.js +6 -1
  108. package/dist/procedures/collection/index.js.map +1 -1
  109. package/dist/procedures/collection/procedures.d.ts +39 -2
  110. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  111. package/dist/procedures/collection/procedures.js +131 -8
  112. package/dist/procedures/collection/procedures.js.map +1 -1
  113. package/dist/procedures/collection/query.d.ts +246 -0
  114. package/dist/procedures/collection/query.d.ts.map +1 -0
  115. package/dist/procedures/collection/query.js +854 -0
  116. package/dist/procedures/collection/query.js.map +1 -0
  117. package/dist/procedures/collection/schemas.d.ts +59 -0
  118. package/dist/procedures/collection/schemas.d.ts.map +1 -1
  119. package/dist/procedures/collection/schemas.js +172 -6
  120. package/dist/procedures/collection/schemas.js.map +1 -1
  121. package/dist/procedures/collection/storage.d.ts +64 -6
  122. package/dist/procedures/collection/storage.d.ts.map +1 -1
  123. package/dist/procedures/collection/storage.js +23 -0
  124. package/dist/procedures/collection/storage.js.map +1 -1
  125. package/dist/procedures/elicit.d.ts +190 -0
  126. package/dist/procedures/elicit.d.ts.map +1 -0
  127. package/dist/procedures/elicit.js +127 -0
  128. package/dist/procedures/elicit.js.map +1 -0
  129. package/dist/procedures/hints.d.ts +43 -0
  130. package/dist/procedures/hints.d.ts.map +1 -0
  131. package/dist/procedures/hints.js +74 -0
  132. package/dist/procedures/hints.js.map +1 -0
  133. package/dist/procedures/index.d.ts +12 -3
  134. package/dist/procedures/index.d.ts.map +1 -1
  135. package/dist/procedures/index.js +7 -1
  136. package/dist/procedures/index.js.map +1 -1
  137. package/dist/procedures/invoke.d.ts +20 -0
  138. package/dist/procedures/invoke.d.ts.map +1 -1
  139. package/dist/procedures/invoke.js +16 -0
  140. package/dist/procedures/invoke.js.map +1 -1
  141. package/dist/procedures/progress.d.ts +24 -0
  142. package/dist/procedures/progress.d.ts.map +1 -0
  143. package/dist/procedures/progress.js +11 -0
  144. package/dist/procedures/progress.js.map +1 -0
  145. package/dist/procedures/registry.d.ts.map +1 -1
  146. package/dist/procedures/registry.js +5 -0
  147. package/dist/procedures/registry.js.map +1 -1
  148. package/dist/procedures/types.d.ts +92 -0
  149. package/dist/procedures/types.d.ts.map +1 -1
  150. package/dist/procedures/types.js.map +1 -1
  151. package/dist/procedures/untrusted.d.ts +28 -0
  152. package/dist/procedures/untrusted.d.ts.map +1 -0
  153. package/dist/procedures/untrusted.js +32 -0
  154. package/dist/procedures/untrusted.js.map +1 -0
  155. package/dist/protocol.d.ts +1 -1
  156. package/dist/protocol.d.ts.map +1 -1
  157. package/dist/protocol.js +1 -0
  158. package/dist/protocol.js.map +1 -1
  159. package/dist/server/browser-guard.d.ts +12 -2
  160. package/dist/server/browser-guard.d.ts.map +1 -1
  161. package/dist/server/browser-guard.js +43 -6
  162. package/dist/server/browser-guard.js.map +1 -1
  163. package/dist/server/host-policy.d.ts +10 -1
  164. package/dist/server/host-policy.d.ts.map +1 -1
  165. package/dist/server/host-policy.js +5 -0
  166. package/dist/server/host-policy.js.map +1 -1
  167. package/dist/server/index.d.ts +1 -1
  168. package/dist/server/index.d.ts.map +1 -1
  169. package/dist/server/index.js.map +1 -1
  170. package/dist/server/procedure-server.d.ts +75 -9
  171. package/dist/server/procedure-server.d.ts.map +1 -1
  172. package/dist/server/procedure-server.js +151 -17
  173. package/dist/server/procedure-server.js.map +1 -1
  174. package/dist/server/request-auth.d.ts +51 -4
  175. package/dist/server/request-auth.d.ts.map +1 -1
  176. package/dist/server/request-auth.js +116 -5
  177. package/dist/server/request-auth.js.map +1 -1
  178. package/dist/server/types.d.ts +73 -5
  179. package/dist/server/types.d.ts.map +1 -1
  180. package/dist/server/types.js.map +1 -1
  181. package/dist/timers.d.ts +5 -0
  182. package/dist/timers.d.ts.map +1 -1
  183. package/dist/timers.js +3 -1
  184. package/dist/timers.js.map +1 -1
  185. package/package.json +5 -1
  186. package/src/adapters/http/client/transport.ts +11 -1
  187. package/src/adapters/http/client/types.ts +22 -0
  188. package/src/adapters/http/server/cors.ts +144 -0
  189. package/src/adapters/http/server/handler.ts +624 -0
  190. package/src/adapters/http/server/strategies.ts +16 -6
  191. package/src/adapters/http/server/transport.ts +66 -479
  192. package/src/adapters/http/server/types.ts +15 -0
  193. package/src/adapters/websocket/client/transport.ts +9 -3
  194. package/src/adapters/websocket/server/transport.ts +189 -18
  195. package/src/adapters/websocket/server/types.ts +11 -3
  196. package/src/adapters/websocket/server/upgrade-router.ts +77 -0
  197. package/src/adapters/websocket/server/upgrade.ts +88 -0
  198. package/src/adapters/websocket/shared/rpc.ts +9 -2
  199. package/src/browser.ts +85 -2
  200. package/src/client/client.ts +83 -1
  201. package/src/client/errors/registry.ts +1 -0
  202. package/src/client/middleware/retry.ts +89 -78
  203. package/src/events/bus.ts +141 -73
  204. package/src/events/index.ts +3 -0
  205. package/src/events/publisher.ts +71 -10
  206. package/src/events/remote.ts +575 -73
  207. package/src/events/rows.ts +48 -0
  208. package/src/events/types.ts +15 -2
  209. package/src/index.ts +1 -1
  210. package/src/node.ts +21 -0
  211. package/src/procedures/caller.ts +45 -0
  212. package/src/procedures/capability.ts +21 -0
  213. package/src/procedures/collection/changes.ts +245 -0
  214. package/src/procedures/collection/index.ts +56 -1
  215. package/src/procedures/collection/procedures.ts +185 -7
  216. package/src/procedures/collection/query.ts +1009 -0
  217. package/src/procedures/collection/schemas.ts +233 -7
  218. package/src/procedures/collection/storage.ts +84 -6
  219. package/src/procedures/elicit.ts +320 -0
  220. package/src/procedures/hints.ts +91 -0
  221. package/src/procedures/index.ts +82 -2
  222. package/src/procedures/invoke.ts +31 -0
  223. package/src/procedures/progress.ts +25 -0
  224. package/src/procedures/registry.ts +4 -0
  225. package/src/procedures/types.ts +102 -0
  226. package/src/procedures/untrusted.ts +50 -0
  227. package/src/protocol.ts +3 -1
  228. package/src/server/browser-guard.ts +48 -6
  229. package/src/server/host-policy.ts +13 -1
  230. package/src/server/index.ts +3 -0
  231. package/src/server/procedure-server.ts +185 -20
  232. package/src/server/request-auth.ts +128 -6
  233. package/src/server/types.ts +82 -5
  234. package/src/timers.ts +8 -1
@@ -3,9 +3,16 @@
3
3
  *
4
4
  * Adapts WebSocket connections to unified RPC format.
5
5
  * Supports bidirectional streaming and persistent connections.
6
+ *
7
+ * The transport runs the `WebSocketServer` of `ws` in its "no server" mode (CLI-3). `upgrade()`
8
+ * takes the upgrade requests of its path. With the option `server`, the transport attaches
9
+ * `upgrade()` to that server through one router for each server, so two endpoints of different
10
+ * paths can share a server. Without it, the owner of the listener gives the upgrades to
11
+ * `upgrade()`, or mounts `upgradeHandler()` of `@procedures/client/node`.
6
12
  */
7
13
 
8
14
  import type { IncomingMessage } from "http";
15
+ import type { Duplex } from "stream";
9
16
  import { WebSocketServer, WebSocket } from "ws";
10
17
  import type { ServerTransport, ServerRequest } from "../../../server/types.js";
11
18
  import type { Message, ResponseItem, Transport } from "../../../client/types.js";
@@ -19,9 +26,11 @@ import type {
19
26
  ConnectionEventHandler,
20
27
  } from "./types.js";
21
28
  import { withoutInternalKeys } from "../../metadata.js";
22
- import { checkBrowserRequest, type BrowserGuardOptions } from "../../../server/browser-guard.js";
29
+ import { assertNamedOrigins, checkBrowserRequest, needsNamedOrigin, type BrowserGuardOptions } from "../../../server/browser-guard.js";
23
30
  import { optionalMs } from "../../../duration.js";
24
- import { credentialsOf, withoutSecretHeaders } from "../../../server/request-auth.js";
31
+ import { headerNames, requestCredentials, urlParts, withoutSecretHeaders } from "../../../server/request-auth.js";
32
+ import { attachUpgradeTarget, refuseUpgrade } from "./upgrade-router.js";
33
+ import { realClock, type Cancel } from "../../../clock.js";
25
34
  import { PROTOCOL_VERSION, protocolVersionOf } from "../../../protocol.js";
26
35
  import { TIMER_DEFAULTS } from "../../../timers.js";
27
36
  import {
@@ -64,11 +73,25 @@ export class WebSocketServerTransport implements ServerTransport {
64
73
 
65
74
  private wss: WebSocketServer | null = null;
66
75
  private options: Required<
67
- Omit<WebSocketServerTransportOptions, "authenticate" | "onConnection" | "allowedOrigins" | "allowedHosts" | "heartbeat" | "reverseRequestTimeout">
76
+ Omit<
77
+ WebSocketServerTransportOptions,
78
+ "server" | "authenticate" | "onConnection" | "allowedOrigins" | "allowedHosts" | "heartbeat" | "reverseRequestTimeout" | "secretHeaders"
79
+ >
68
80
  > & {
81
+ server: WebSocketServerTransportOptions["server"];
69
82
  authenticate?: WebSocketServerTransportOptions["authenticate"];
70
83
  onConnection?: WebSocketServerTransportOptions["onConnection"];
71
84
  };
85
+ /** The more headers that carry a secret (CLI-2a) */
86
+ private readonly secretHeaders: ReadonlySet<string>;
87
+ /** The function that detaches `upgrade()` from the `server` of the options */
88
+ private detach: (() => void) | undefined;
89
+ /** True after `stopIntake()`: a new upgrade gets 503, and a new call gets `UNAVAILABLE` */
90
+ private intakeStopped = false;
91
+ /** The functions that `drain()` waits on: each one runs when no call is in flight */
92
+ private idleWaiters: Array<() => void> = [];
93
+ /** True: a cookie of an upgrade is trusted only from a named origin, with or without an auth path */
94
+ private cookiesNamed = false;
72
95
  private server: RequestHandler;
73
96
  private connections: Set<WebSocket> = new Set();
74
97
 
@@ -90,6 +113,12 @@ export class WebSocketServerTransport implements ServerTransport {
90
113
  */
91
114
  private readonly channels = new Map<string, { incoming: IncomingCalls; outgoing: OutgoingCalls }>();
92
115
 
116
+ /**
117
+ * The transport of the remote events of each connection: one object for each connection, so the
118
+ * subscriptions of one connection to the same topics share one call (EVENTS.md).
119
+ */
120
+ private readonly eventTransports = new Map<string, Transport>();
121
+
93
122
  /** The browser checks of each connection (see `server/browser-guard.ts`) */
94
123
  private readonly guard: BrowserGuardOptions;
95
124
 
@@ -97,7 +126,7 @@ export class WebSocketServerTransport implements ServerTransport {
97
126
  private connectHandlers: ConnectionEventHandler[] = [];
98
127
  private disconnectHandlers: ConnectionEventHandler[] = [];
99
128
 
100
- constructor(server: RequestHandler, options: WebSocketServerTransportOptions) {
129
+ constructor(server: RequestHandler, options: WebSocketServerTransportOptions = {}) {
101
130
  this.server = server;
102
131
  this.options = {
103
132
  server: options.server,
@@ -110,6 +139,9 @@ export class WebSocketServerTransport implements ServerTransport {
110
139
  highWaterMark: options.highWaterMark ?? 1024 * 1024,
111
140
  };
112
141
  this.guard = { allowedOrigins: options.allowedOrigins, allowedHosts: options.allowedHosts };
142
+ // CSRF: a server that reads the cookies of each request needs named origins (CLI-2a)
143
+ assertNamedOrigins("WebSocketServerTransport", server.ambientCredentials, options.allowedOrigins, "allowedOrigins");
144
+ this.secretHeaders = headerNames(options.secretHeaders);
113
145
  this.reverseRequestTimeoutMs = optionalMs(options.reverseRequestTimeout, "reverseRequestTimeout") ?? TIMER_DEFAULTS.webSocketServer.reverseRequestTimeoutMs;
114
146
  this.heartbeat =
115
147
  options.heartbeat === false
@@ -143,9 +175,21 @@ export class WebSocketServerTransport implements ServerTransport {
143
175
  }
144
176
 
145
177
  async start(): Promise<void> {
146
- // Create WebSocket server
178
+ this.open();
179
+ }
180
+
181
+ /**
182
+ * This method creates the WebSocket server at once, and attaches it to the `server` of the
183
+ * options. `start()` calls it, and `upgradeHandler()` too. A second call does nothing.
184
+ *
185
+ * @internal
186
+ */
187
+ open(): void {
188
+ this.intakeStopped = false;
189
+ if (this.wss) return;
190
+ // The "no server" mode: upgrade() takes the upgrades of the path, and leaves the others alone
147
191
  this.wss = new WebSocketServer({
148
- server: this.options.server,
192
+ noServer: true,
149
193
  path: this.options.path,
150
194
  perMessageDeflate: this.options.perMessageDeflate,
151
195
  maxPayload: this.options.maxPayload,
@@ -157,7 +201,11 @@ export class WebSocketServerTransport implements ServerTransport {
157
201
  { req }: { req: IncomingMessage },
158
202
  callback: (result: boolean, code?: number, message?: string) => void
159
203
  ) => {
160
- const guard = checkBrowserRequest(req, this.guard);
204
+ // The cookies of an upgrade are trusted only from a named origin, when the server or the
205
+ // authenticate handler can read them (cross-site WebSocket hijacking with cookies)
206
+ const readsCookies = this.cookiesNamed || this.options.authenticate !== undefined;
207
+ const ambient = needsNamedOrigin(this.server.ambientCredentials, req.headers, readsCookies);
208
+ const guard = checkBrowserRequest(req, this.guard, false, ambient);
161
209
  if (!guard.ok) {
162
210
  callback(false, guard.status, guard.message);
163
211
  return;
@@ -179,6 +227,7 @@ export class WebSocketServerTransport implements ServerTransport {
179
227
  this.wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
180
228
  this.handleConnection(ws, req);
181
229
  });
230
+ if (this.options.server) this.detach = attachUpgradeTarget(this.options.server, this);
182
231
 
183
232
  if (this.heartbeat) {
184
233
  this.heartbeatTimer = setInterval(() => this.beat(), this.heartbeat.interval);
@@ -194,6 +243,8 @@ export class WebSocketServerTransport implements ServerTransport {
194
243
  clearInterval(this.heartbeatTimer);
195
244
  this.heartbeatTimer = undefined;
196
245
  }
246
+ this.detach?.();
247
+ this.detach = undefined;
197
248
  if (this.wss) {
198
249
  // Stop the requests in progress, so their handlers end (deep dive TRN-15), and fail the
199
250
  // calls that the server sent to its clients
@@ -202,6 +253,7 @@ export class WebSocketServerTransport implements ServerTransport {
202
253
  channel.outgoing.failAll("Server stopped");
203
254
  }
204
255
  this.channels.clear();
256
+ this.callSettled();
205
257
 
206
258
  // Close all connections
207
259
  for (const ws of this.connections) {
@@ -228,6 +280,93 @@ export class WebSocketServerTransport implements ServerTransport {
228
280
  return this.wss !== null;
229
281
  }
230
282
 
283
+ /**
284
+ * This method makes the transport trust a cookie of an upgrade only from a named origin, also
285
+ * without an auth path that reads it. `upgradeHandler()` calls it.
286
+ *
287
+ * @internal
288
+ */
289
+ nameOriginsForCookies(): void {
290
+ this.cookiesNamed = true;
291
+ }
292
+
293
+ /** The path of the WebSocket endpoint. */
294
+ get path(): string {
295
+ return this.options.path;
296
+ }
297
+
298
+ /** This method gives true when an upgrade request is for the path of this endpoint. */
299
+ matches(req: IncomingMessage): boolean {
300
+ return urlParts(req.url).path === this.options.path;
301
+ }
302
+
303
+ /**
304
+ * This method takes an upgrade request of its path and gives true. The browser checks, the
305
+ * `authenticate` handler and the handshake of `ws` follow. It gives false for another path, or
306
+ * before `start()`, and does not touch the socket. After `stopIntake()`, it refuses the upgrade
307
+ * with 503.
308
+ */
309
+ upgrade(req: IncomingMessage, socket: Duplex, head: Buffer): boolean {
310
+ const wss = this.wss;
311
+ if (!wss || !this.matches(req)) return false;
312
+ if (this.intakeStopped) {
313
+ refuseUpgrade(socket, 503, "The server is stopping");
314
+ return true;
315
+ }
316
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
317
+ return true;
318
+ }
319
+
320
+ /**
321
+ * This method stops the intake: a new upgrade gets 503, and a new call on an open connection gets
322
+ * the retryable error `UNAVAILABLE`. The calls in flight go on. `start()` accepts again.
323
+ */
324
+ stopIntake(): void {
325
+ this.intakeStopped = true;
326
+ }
327
+
328
+ /** The number of the calls in flight on all connections, open streams too. */
329
+ inFlight(): number {
330
+ let count = 0;
331
+ for (const channel of this.channels.values()) count += channel.incoming.size;
332
+ return count;
333
+ }
334
+
335
+ /**
336
+ * This method waits until no call is in flight, until the `deadline` (milliseconds on the
337
+ * monotonic clock of `performance.now()`), or until the signal aborts.
338
+ */
339
+ drain(deadline: number, signal?: AbortSignal): Promise<void> {
340
+ if (this.inFlight() === 0 || signal?.aborted) return Promise.resolve();
341
+ return new Promise<void>((resolve) => {
342
+ let timer: Cancel | undefined;
343
+ const done = (): void => {
344
+ timer?.();
345
+ signal?.removeEventListener("abort", done);
346
+ const index = this.idleWaiters.indexOf(done);
347
+ if (index >= 0) this.idleWaiters.splice(index, 1);
348
+ resolve();
349
+ };
350
+ this.idleWaiters.push(done);
351
+ signal?.addEventListener("abort", done, { once: true });
352
+ timer = realClock.after(Math.max(0, deadline - realClock.now()), done);
353
+ timer.unref?.();
354
+ });
355
+ }
356
+
357
+ /** This method stops the transport: the same as `stop()`. */
358
+ close(): Promise<void> {
359
+ return this.stop();
360
+ }
361
+
362
+ /** A call ended: a drain can end when no call is in flight. */
363
+ private callSettled(): void {
364
+ if (this.idleWaiters.length === 0 || this.inFlight() > 0) return;
365
+ const waiters = this.idleWaiters;
366
+ this.idleWaiters = [];
367
+ for (const waiter of waiters) waiter();
368
+ }
369
+
231
370
  /**
232
371
  * Handle new WebSocket connection.
233
372
  */
@@ -243,10 +382,13 @@ export class WebSocketServerTransport implements ServerTransport {
243
382
  connectedAt: new Date(),
244
383
  metadata: {
245
384
  remoteAddress: req.socket.remoteAddress,
246
- headers: withoutSecretHeaders(req.headers as Record<string, unknown>),
385
+ headers: withoutSecretHeaders(req.headers as Record<string, unknown>, this.secretHeaders),
247
386
  },
248
387
  };
249
388
  this.trackedConnections.set(connectionId, trackedConnection);
389
+ // The credentials of the upgrade, for each call of the connection: the bearer token of its
390
+ // Authorization header or of its token query, its cookies, its headers and its query (CLI-2a)
391
+ const credentials = requestCredentials({ headers: req.headers as Record<string, unknown>, url: req.url }, { tokenQuery: "token" });
250
392
 
251
393
  // The calls of this connection in the two directions (roadmap R24)
252
394
  const isOpen = (): boolean => ws.readyState === WebSocket.OPEN;
@@ -257,7 +399,8 @@ export class WebSocketServerTransport implements ServerTransport {
257
399
  send,
258
400
  sendWithBackpressure: (frame, signal) => this.sendWithBackpressure(ws, frame, signal),
259
401
  isOpen,
260
- requestOf: (frame, signal) => this.requestOf(frame, req, connectionId, signal),
402
+ requestOf: (frame, signal) => this.requestOf(frame, req, connectionId, signal, credentials),
403
+ onSettled: () => this.callSettled(),
261
404
  }),
262
405
  outgoing: new OutgoingCalls({
263
406
  send,
@@ -303,6 +446,8 @@ export class WebSocketServerTransport implements ServerTransport {
303
446
  channel?.incoming.abortAll();
304
447
  channel?.outgoing.failAll("WebSocket connection closed");
305
448
  this.channels.delete(connectionId);
449
+ this.callSettled();
450
+ this.eventTransports.delete(connectionId);
306
451
  const conn = this.trackedConnections.get(connectionId);
307
452
  if (conn) {
308
453
  this.trackedConnections.delete(connectionId);
@@ -329,7 +474,13 @@ export class WebSocketServerTransport implements ServerTransport {
329
474
  * The server request of a `request` frame of a connection: the metadata of the frame and of the
330
475
  * connection, the signal of the call, and the credentials of the upgrade (roadmap R18).
331
476
  */
332
- private requestOf(frame: WebSocketMessage, req: IncomingMessage, connectionId: string, signal: AbortSignal): ServerRequest {
477
+ private requestOf(
478
+ frame: WebSocketMessage,
479
+ req: IncomingMessage,
480
+ connectionId: string,
481
+ signal: AbortSignal,
482
+ credentials: ServerRequest["credentials"]
483
+ ): ServerRequest {
333
484
  return {
334
485
  id: frame.id,
335
486
  method: frame.method!,
@@ -341,11 +492,12 @@ export class WebSocketServerTransport implements ServerTransport {
341
492
  connectionId,
342
493
  remoteAddress: req.socket.remoteAddress,
343
494
  // The upgrade headers without their secrets (roadmap R18)
344
- headers: withoutSecretHeaders(req.headers as Record<string, unknown>),
495
+ headers: withoutSecretHeaders(req.headers as Record<string, unknown>, this.secretHeaders),
345
496
  },
346
497
  signal,
347
- // The credentials of the connection: the bearer token of its upgrade (R18)
348
- credentials: credentialsOf(req),
498
+ // The credentials of the connection: the bearer token of its upgrade (R18), its cookies,
499
+ // its headers and its query (CLI-2a)
500
+ credentials,
349
501
  };
350
502
  }
351
503
 
@@ -394,6 +546,11 @@ export class WebSocketServerTransport implements ServerTransport {
394
546
  if (!channel) return;
395
547
  // A frame of a call that the server sent to this client (roadmap R24)
396
548
  if (channel.outgoing.receive(message)) return;
549
+ // After stopIntake(), a new call goes to another server or comes again later
550
+ if (this.intakeStopped && message.type === "request") {
551
+ this.sendError(ws, message.id || "unknown", "The server is stopping", "UNAVAILABLE", true);
552
+ return;
553
+ }
397
554
  // A frame of a call of the client: a request, a cancel or a credit
398
555
  if (channel.incoming.receive(message)) return;
399
556
  // A server-response frame of an older client, for a call that no longer exists
@@ -429,7 +586,7 @@ export class WebSocketServerTransport implements ServerTransport {
429
586
  /**
430
587
  * Send error to client.
431
588
  */
432
- private sendError(ws: WebSocket, id: string, message: string, code = "INTERNAL_ERROR"): void {
589
+ private sendError(ws: WebSocket, id: string, message: string, code = "INTERNAL_ERROR", retryable = false): void {
433
590
  const errorMessage: WebSocketMessage = {
434
591
  id,
435
592
  type: "error",
@@ -437,12 +594,12 @@ export class WebSocketServerTransport implements ServerTransport {
437
594
  type: "error",
438
595
  code,
439
596
  message,
440
- retryable: false,
597
+ retryable,
441
598
  },
442
599
  error: {
443
600
  code,
444
601
  message,
445
- retryable: false,
602
+ retryable,
446
603
  },
447
604
  };
448
605
 
@@ -552,7 +709,7 @@ export class WebSocketServerTransport implements ServerTransport {
552
709
  handler: RemoteEventHandler<T>,
553
710
  options: SubscribeOptions = {},
554
711
  ): Subscription {
555
- return subscribeRemote<T>(this.transportFor(connectionId), topics, handler, options, { resubscribe: false });
712
+ return subscribeRemote<T>(this.eventTransportFor(connectionId), topics, handler, options, { resubscribe: false });
556
713
  }
557
714
 
558
715
  /**
@@ -564,7 +721,21 @@ export class WebSocketServerTransport implements ServerTransport {
564
721
  * @param options - A signal, `onSubscribed`, the metadata of the call
565
722
  */
566
723
  events<T = unknown>(connectionId: string, topics: string | readonly string[], options: RemoteEventsOptions = {}): AsyncIterable<RemoteEvent<T>> {
567
- return remoteEvents<T>(this.transportFor(connectionId), topics, options, { resubscribe: false });
724
+ return remoteEvents<T>(this.eventTransportFor(connectionId), topics, options, { resubscribe: false });
725
+ }
726
+
727
+ /**
728
+ * The transport of the remote events of a connection: the same object for each subscription of
729
+ * a tracked connection. The close of the connection removes it.
730
+ */
731
+ private eventTransportFor(connectionId: string): Transport {
732
+ if (!this.trackedConnections.has(connectionId)) return this.transportFor(connectionId);
733
+ let transport = this.eventTransports.get(connectionId);
734
+ if (!transport) {
735
+ transport = this.transportFor(connectionId);
736
+ this.eventTransports.set(connectionId, transport);
737
+ }
738
+ return transport;
568
739
  }
569
740
 
570
741
  /**
@@ -29,10 +29,11 @@ export type WebSocketConnectionHandler = (ws: WebSocket, req: IncomingMessage) =
29
29
  */
30
30
  export interface WebSocketServerTransportOptions extends Omit<BrowserGuardOptions, "allowGet"> {
31
31
  /**
32
- * HTTP server to attach WebSocket server to.
33
- * Required - WebSocket upgrades HTTP connections.
32
+ * The HTTP server whose upgrade requests the transport takes. The transport takes only the
33
+ * upgrades of its path, so several endpoints can share one server. Without it, the owner of the
34
+ * listener gives each upgrade to `upgrade()`, or mounts `upgradeHandler()`.
34
35
  */
35
- server: HttpServer;
36
+ server?: HttpServer | undefined;
36
37
 
37
38
  /**
38
39
  * Path for WebSocket connections.
@@ -91,6 +92,13 @@ export interface WebSocketServerTransportOptions extends Omit<BrowserGuardOption
91
92
  * @default seconds(30)
92
93
  */
93
94
  reverseRequestTimeout?: Duration;
95
+
96
+ /**
97
+ * More headers that carry a secret, for example the token header of an application. They go
98
+ * into the credentials of each call, not into its metadata. `Authorization`,
99
+ * `Proxy-Authorization`, `Cookie` and `X-Api-Key` are always secret.
100
+ */
101
+ secretHeaders?: readonly string[] | undefined;
94
102
  }
95
103
 
96
104
  /**
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The router of the upgrade requests of one `node:http` server (CLI-3).
3
+ *
4
+ * Before, each `WebSocketServerTransport` attached its own `WebSocketServer` of `ws` to the server.
5
+ * Such a server refuses each upgrade of another path with 400, so a second endpoint on the same
6
+ * server did not get its upgrades. With this router, each transport of the server is a target of
7
+ * one router. The router gives an upgrade to the first target that takes it. When no target takes it and no other
8
+ * listener of the server can take it, the router refuses it with 400, as before.
9
+ *
10
+ * This module imports only types of Node.
11
+ */
12
+
13
+ import type { IncomingMessage, Server as HttpServer } from "http";
14
+ import type { Duplex } from "stream";
15
+
16
+ /** A target of the router: it takes an upgrade (true), or leaves it alone (false). */
17
+ export interface UpgradeTarget {
18
+ upgrade(req: IncomingMessage, socket: Duplex, head: Buffer): boolean;
19
+ }
20
+
21
+ /** The reason texts of the statuses of a refused upgrade. */
22
+ const STATUS_TEXT: Record<number, string> = {
23
+ 400: "Bad Request",
24
+ 401: "Unauthorized",
25
+ 403: "Forbidden",
26
+ 503: "Service Unavailable",
27
+ };
28
+
29
+ /**
30
+ * This function refuses an upgrade: it writes an HTTP response with the status and the message,
31
+ * then it destroys the socket.
32
+ */
33
+ export function refuseUpgrade(socket: Duplex, status: number, message?: string): void {
34
+ if (socket.writable) {
35
+ const text = STATUS_TEXT[status] ?? "Error";
36
+ const body = message ?? text;
37
+ socket.write(
38
+ `HTTP/1.1 ${status} ${text}\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`
39
+ );
40
+ }
41
+ socket.destroy();
42
+ }
43
+
44
+ interface Router {
45
+ readonly targets: Set<UpgradeTarget>;
46
+ readonly listener: (req: IncomingMessage, socket: Duplex, head: Buffer) => void;
47
+ }
48
+
49
+ const routers = new WeakMap<HttpServer, Router>();
50
+
51
+ /**
52
+ * This function attaches a target to the upgrades of a server. It gives the function that
53
+ * detaches it. The router leaves the server when its last target goes.
54
+ */
55
+ export function attachUpgradeTarget(server: HttpServer, target: UpgradeTarget): () => void {
56
+ let router = routers.get(server);
57
+ if (!router) {
58
+ const targets = new Set<UpgradeTarget>();
59
+ const listener = (req: IncomingMessage, socket: Duplex, head: Buffer): void => {
60
+ for (const each of targets) if (each.upgrade(req, socket, head)) return;
61
+ // Another listener of the server (the upgrade handler of the application) can take it
62
+ if (server.listenerCount("upgrade") <= 1) refuseUpgrade(socket, 400);
63
+ };
64
+ router = { targets, listener };
65
+ routers.set(server, router);
66
+ server.on("upgrade", listener);
67
+ }
68
+ const current = router;
69
+ current.targets.add(target);
70
+ return () => {
71
+ current.targets.delete(target);
72
+ if (current.targets.size === 0 && routers.get(server) === current) {
73
+ server.off("upgrade", current.listener);
74
+ routers.delete(server);
75
+ }
76
+ };
77
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The procedure upgrade handler of WebSocket: an upgrade handler of `node:http` for the "no
3
+ * server" mode of `ws` (CLI-3).
4
+ *
5
+ * `upgradeHandler(server, options)` gives a function `(req, socket, head) => boolean`. The function
6
+ * takes each upgrade request of its path and gives `true`. It gives `false` for each other path and
7
+ * does not touch the socket. Thus two endpoints can share one listener, and so can an endpoint and
8
+ * the WebSocket server of the application. The connections are the connections of
9
+ * `WebSocketServerTransport`: calls in the two directions, streams with credit and `seq`, events,
10
+ * the heartbeat and the browser checks. `handler.transport` gives that API.
11
+ *
12
+ * The handler also has the stop parts of a service transport: `start()`, `stopIntake()`,
13
+ * `drain(deadline, signal)`, `inFlight()` and `close()`. It does not make a server and does not
14
+ * listen: the owner of the listener does that.
15
+ */
16
+
17
+ import type { IncomingMessage } from "http";
18
+ import type { Duplex } from "stream";
19
+ import type { RequestHandler } from "../../../server/types.js";
20
+ import type { WebSocketServerTransportOptions } from "./types.js";
21
+ import { WebSocketServerTransport } from "./transport.js";
22
+
23
+ /** The options of `upgradeHandler`: the options of `WebSocketServerTransport` without `server`. */
24
+ export type UpgradeHandlerOptions = Omit<WebSocketServerTransportOptions, "server">;
25
+
26
+ /**
27
+ * The upgrade handler of `upgradeHandler`. A call gives `true` when the handler takes the upgrade
28
+ * (its path is the path of the endpoint), and `false` for another path. The handler then does not
29
+ * touch the socket.
30
+ */
31
+ export interface NodeUpgradeHandler {
32
+ (req: IncomingMessage, socket: Duplex, head: Buffer): boolean;
33
+ /** The path of the endpoint. */
34
+ readonly path: string;
35
+ /**
36
+ * The transport of the connections: `broadcast`, `callConnection`, `transportFor`, `subscribe`,
37
+ * `events`, `onConnect` and the other methods of `WebSocketServerTransport`.
38
+ */
39
+ readonly transport: WebSocketServerTransport;
40
+ /** This method gives true when an upgrade request is for the path of the endpoint. */
41
+ matches(req: IncomingMessage): boolean;
42
+ /** This method accepts new upgrades and calls again, after `stopIntake()` or `close()`. */
43
+ start(): void;
44
+ /** This method refuses each new upgrade with 503, and each new call with `UNAVAILABLE`. */
45
+ stopIntake(): void;
46
+ /**
47
+ * This method waits until no call is in flight, until the `deadline` (milliseconds on the
48
+ * monotonic clock of `performance.now()`), or until the signal aborts.
49
+ */
50
+ drain(deadline: number, signal?: AbortSignal): Promise<void>;
51
+ /** The number of the calls in flight on all connections. */
52
+ inFlight(): number;
53
+ /** This method closes each connection and the WebSocket server. */
54
+ close(): Promise<void>;
55
+ }
56
+
57
+ /**
58
+ * This function makes a procedure upgrade handler: an upgrade handler of `node:http` that serves
59
+ * the procedures of `server` over WebSocket at `options.path`.
60
+ *
61
+ * @example
62
+ * ```typescript
63
+ * const events = upgradeHandler(server, { path: "/ws" });
64
+ * const live = upgradeHandler(server, { path: "/live" });
65
+ * http.on("upgrade", (req, socket, head) => {
66
+ * if (events(req, socket, head) || live(req, socket, head)) return;
67
+ * socket.destroy();
68
+ * });
69
+ * ```
70
+ */
71
+ export function upgradeHandler(server: RequestHandler, options: UpgradeHandlerOptions = {}): NodeUpgradeHandler {
72
+ const { server: _ignored, ...rest } = options as WebSocketServerTransportOptions;
73
+ const transport = new WebSocketServerTransport(server, rest);
74
+ // A new face: a cookie is trusted only from a named origin (CSRF)
75
+ transport.nameOriginsForCookies();
76
+ transport.open();
77
+ const handler = ((req: IncomingMessage, socket: Duplex, head: Buffer): boolean => transport.upgrade(req, socket, head)) as NodeUpgradeHandler;
78
+ return Object.assign(handler, {
79
+ path: transport.path,
80
+ transport,
81
+ matches: (req: IncomingMessage): boolean => transport.matches(req),
82
+ start: (): void => transport.open(),
83
+ stopIntake: (): void => transport.stopIntake(),
84
+ drain: (deadline: number, signal?: AbortSignal): Promise<void> => transport.drain(deadline, signal),
85
+ inFlight: (): number => transport.inFlight(),
86
+ close: (): Promise<void> => transport.close(),
87
+ });
88
+ }
@@ -317,6 +317,8 @@ export interface IncomingCallsOptions {
317
317
  isOpen: () => boolean;
318
318
  /** This function makes the server request of a `request` frame: the metadata, the credentials. */
319
319
  requestOf: (frame: WebSocketMessage, signal: AbortSignal) => ServerRequest;
320
+ /** This function runs when the number of calls that run becomes smaller. A drain waits on it. */
321
+ onSettled?: (() => void) | undefined;
320
322
  }
321
323
 
322
324
  /** The calls that the other side sends on one socket, served by a handler. */
@@ -340,7 +342,7 @@ export class IncomingCalls {
340
342
  receive(frame: WebSocketMessage): boolean {
341
343
  if (frame.type === "cancel") {
342
344
  this.inflight.get(frame.id)?.abort();
343
- this.inflight.delete(frame.id);
345
+ if (this.inflight.delete(frame.id)) this.options.onSettled?.();
344
346
  return true;
345
347
  }
346
348
  if (frame.type === "credit") {
@@ -365,8 +367,10 @@ export class IncomingCalls {
365
367
  /** This function aborts each call that runs: the socket closed, or the side stops. */
366
368
  abortAll(): void {
367
369
  for (const controller of this.inflight.values()) controller.abort();
370
+ const had = this.inflight.size > 0;
368
371
  this.inflight.clear();
369
372
  this.credits.clear();
373
+ if (had) this.options.onSettled?.();
370
374
  }
371
375
 
372
376
  private async serve(frame: WebSocketMessage): Promise<void> {
@@ -395,7 +399,10 @@ export class IncomingCalls {
395
399
  this.sendError(frame.id, error instanceof Error ? error.message : "Internal server error", "INTERNAL_ERROR");
396
400
  } finally {
397
401
  // Delete only this request's entries (deep dive TRN-9)
398
- if (this.inflight.get(frame.id) === controller) this.inflight.delete(frame.id);
402
+ if (this.inflight.get(frame.id) === controller) {
403
+ this.inflight.delete(frame.id);
404
+ this.options.onSettled?.();
405
+ }
399
406
  this.credits.delete(frame.id);
400
407
  }
401
408
  }