@procedures/client 0.2.2 → 0.3.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 (299) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +61 -38
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/adapters/http/client/transport.d.ts.map +1 -1
  5. package/dist/adapters/http/client/transport.js +35 -3
  6. package/dist/adapters/http/client/transport.js.map +1 -1
  7. package/dist/adapters/http/client/types.d.ts +3 -2
  8. package/dist/adapters/http/client/types.d.ts.map +1 -1
  9. package/dist/adapters/http/server/transport.d.ts +2 -2
  10. package/dist/adapters/http/server/transport.d.ts.map +1 -1
  11. package/dist/adapters/http/server/transport.js +53 -20
  12. package/dist/adapters/http/server/transport.js.map +1 -1
  13. package/dist/adapters/http/shared/headers.d.ts +1 -1
  14. package/dist/adapters/http/shared/headers.d.ts.map +1 -1
  15. package/dist/adapters/http/shared/headers.js +7 -6
  16. package/dist/adapters/http/shared/headers.js.map +1 -1
  17. package/dist/adapters/local/client/transport.d.ts +3 -0
  18. package/dist/adapters/local/client/transport.d.ts.map +1 -1
  19. package/dist/adapters/local/client/transport.js +63 -4
  20. package/dist/adapters/local/client/transport.js.map +1 -1
  21. package/dist/adapters/local/client/types.d.ts +15 -0
  22. package/dist/adapters/local/client/types.d.ts.map +1 -1
  23. package/dist/adapters/local/client/types.js.map +1 -1
  24. package/dist/adapters/mock/client/transport.d.ts +3 -2
  25. package/dist/adapters/mock/client/transport.d.ts.map +1 -1
  26. package/dist/adapters/mock/client/transport.js +8 -5
  27. package/dist/adapters/mock/client/transport.js.map +1 -1
  28. package/dist/adapters/mock/client/types.d.ts +7 -6
  29. package/dist/adapters/mock/client/types.d.ts.map +1 -1
  30. package/dist/adapters/port/transport.d.ts +62 -0
  31. package/dist/adapters/port/transport.d.ts.map +1 -0
  32. package/dist/adapters/port/transport.js +94 -0
  33. package/dist/adapters/port/transport.js.map +1 -0
  34. package/dist/adapters/websocket/client/index.d.ts +1 -1
  35. package/dist/adapters/websocket/client/index.d.ts.map +1 -1
  36. package/dist/adapters/websocket/client/index.js.map +1 -1
  37. package/dist/adapters/websocket/client/transport.d.ts +47 -7
  38. package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
  39. package/dist/adapters/websocket/client/transport.js +213 -67
  40. package/dist/adapters/websocket/client/transport.js.map +1 -1
  41. package/dist/adapters/websocket/client/types.d.ts +70 -21
  42. package/dist/adapters/websocket/client/types.d.ts.map +1 -1
  43. package/dist/adapters/websocket/client/types.js.map +1 -1
  44. package/dist/adapters/websocket/server/transport.d.ts +65 -24
  45. package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
  46. package/dist/adapters/websocket/server/transport.js +192 -263
  47. package/dist/adapters/websocket/server/transport.js.map +1 -1
  48. package/dist/adapters/websocket/server/types.d.ts +20 -1
  49. package/dist/adapters/websocket/server/types.d.ts.map +1 -1
  50. package/dist/adapters/websocket/shared/rpc.d.ts +129 -0
  51. package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -0
  52. package/dist/adapters/websocket/shared/rpc.js +425 -0
  53. package/dist/adapters/websocket/shared/rpc.js.map +1 -0
  54. package/dist/browser.d.ts +29 -7
  55. package/dist/browser.d.ts.map +1 -1
  56. package/dist/browser.js +26 -3
  57. package/dist/browser.js.map +1 -1
  58. package/dist/client/call-types.d.ts +10 -10
  59. package/dist/client/call-types.d.ts.map +1 -1
  60. package/dist/client/call-types.js +1 -6
  61. package/dist/client/call-types.js.map +1 -1
  62. package/dist/client/client.d.ts +6 -1
  63. package/dist/client/client.d.ts.map +1 -1
  64. package/dist/client/client.js +23 -9
  65. package/dist/client/client.js.map +1 -1
  66. package/dist/client/deadline.d.ts +38 -0
  67. package/dist/client/deadline.d.ts.map +1 -0
  68. package/dist/client/deadline.js +60 -0
  69. package/dist/client/deadline.js.map +1 -0
  70. package/dist/client/errors/factory.d.ts.map +1 -1
  71. package/dist/client/errors/factory.js +11 -7
  72. package/dist/client/errors/factory.js.map +1 -1
  73. package/dist/client/index.d.ts +9 -2
  74. package/dist/client/index.d.ts.map +1 -1
  75. package/dist/client/index.js +8 -2
  76. package/dist/client/index.js.map +1 -1
  77. package/dist/client/middleware/cache.d.ts +19 -5
  78. package/dist/client/middleware/cache.d.ts.map +1 -1
  79. package/dist/client/middleware/cache.js +106 -18
  80. package/dist/client/middleware/cache.js.map +1 -1
  81. package/dist/client/middleware/circuit-breaker.d.ts +28 -10
  82. package/dist/client/middleware/circuit-breaker.d.ts.map +1 -1
  83. package/dist/client/middleware/circuit-breaker.js +35 -24
  84. package/dist/client/middleware/circuit-breaker.js.map +1 -1
  85. package/dist/client/middleware/contexts.d.ts +16 -10
  86. package/dist/client/middleware/contexts.d.ts.map +1 -1
  87. package/dist/client/middleware/contexts.js.map +1 -1
  88. package/dist/client/middleware/items.d.ts +31 -2
  89. package/dist/client/middleware/items.d.ts.map +1 -1
  90. package/dist/client/middleware/items.js +86 -6
  91. package/dist/client/middleware/items.js.map +1 -1
  92. package/dist/client/middleware/lru.d.ts +2 -0
  93. package/dist/client/middleware/lru.d.ts.map +1 -1
  94. package/dist/client/middleware/lru.js +11 -0
  95. package/dist/client/middleware/lru.js.map +1 -1
  96. package/dist/client/middleware/rate-limit.d.ts +7 -6
  97. package/dist/client/middleware/rate-limit.d.ts.map +1 -1
  98. package/dist/client/middleware/rate-limit.js +9 -6
  99. package/dist/client/middleware/rate-limit.js.map +1 -1
  100. package/dist/client/middleware/resilience.d.ts +61 -0
  101. package/dist/client/middleware/resilience.d.ts.map +1 -0
  102. package/dist/client/middleware/resilience.js +59 -0
  103. package/dist/client/middleware/resilience.js.map +1 -0
  104. package/dist/client/middleware/retry.d.ts +45 -11
  105. package/dist/client/middleware/retry.d.ts.map +1 -1
  106. package/dist/client/middleware/retry.js +102 -22
  107. package/dist/client/middleware/retry.js.map +1 -1
  108. package/dist/client/middleware/timeout.d.ts +15 -12
  109. package/dist/client/middleware/timeout.d.ts.map +1 -1
  110. package/dist/client/middleware/timeout.js +46 -37
  111. package/dist/client/middleware/timeout.js.map +1 -1
  112. package/dist/client/middleware/tracing.d.ts.map +1 -1
  113. package/dist/client/middleware/tracing.js +11 -3
  114. package/dist/client/middleware/tracing.js.map +1 -1
  115. package/dist/client/types.d.ts +9 -1
  116. package/dist/client/types.d.ts.map +1 -1
  117. package/dist/client/types.js.map +1 -1
  118. package/dist/clock.d.ts +19 -0
  119. package/dist/clock.d.ts.map +1 -0
  120. package/dist/clock.js +18 -0
  121. package/dist/clock.js.map +1 -0
  122. package/dist/duration.d.ts +37 -0
  123. package/dist/duration.d.ts.map +1 -0
  124. package/dist/duration.js +44 -0
  125. package/dist/duration.js.map +1 -0
  126. package/dist/machines.d.ts +90 -0
  127. package/dist/machines.d.ts.map +1 -0
  128. package/dist/machines.js +127 -0
  129. package/dist/machines.js.map +1 -0
  130. package/dist/procedures/capability.d.ts +3 -6
  131. package/dist/procedures/capability.d.ts.map +1 -1
  132. package/dist/procedures/capability.js +11 -9
  133. package/dist/procedures/capability.js.map +1 -1
  134. package/dist/procedures/collection/index.d.ts +3 -3
  135. package/dist/procedures/collection/index.d.ts.map +1 -1
  136. package/dist/procedures/collection/index.js +1 -1
  137. package/dist/procedures/collection/index.js.map +1 -1
  138. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  139. package/dist/procedures/collection/procedures.js +49 -1
  140. package/dist/procedures/collection/procedures.js.map +1 -1
  141. package/dist/procedures/collection/schemas.d.ts +33 -0
  142. package/dist/procedures/collection/schemas.d.ts.map +1 -1
  143. package/dist/procedures/collection/schemas.js +68 -0
  144. package/dist/procedures/collection/schemas.js.map +1 -1
  145. package/dist/procedures/collection/storage.d.ts +37 -0
  146. package/dist/procedures/collection/storage.d.ts.map +1 -1
  147. package/dist/procedures/core/array.d.ts.map +1 -1
  148. package/dist/procedures/core/array.js +26 -15
  149. package/dist/procedures/core/array.js.map +1 -1
  150. package/dist/procedures/core/index.d.ts.map +1 -1
  151. package/dist/procedures/core/index.js +8 -0
  152. package/dist/procedures/core/index.js.map +1 -1
  153. package/dist/procedures/core/meta.d.ts.map +1 -1
  154. package/dist/procedures/core/meta.js +2 -1
  155. package/dist/procedures/core/meta.js.map +1 -1
  156. package/dist/procedures/core/object.d.ts.map +1 -1
  157. package/dist/procedures/core/object.js +42 -13
  158. package/dist/procedures/core/object.js.map +1 -1
  159. package/dist/procedures/core/operands.d.ts.map +1 -1
  160. package/dist/procedures/core/operands.js +6 -1
  161. package/dist/procedures/core/operands.js.map +1 -1
  162. package/dist/procedures/core/string.d.ts.map +1 -1
  163. package/dist/procedures/core/string.js +4 -2
  164. package/dist/procedures/core/string.js.map +1 -1
  165. package/dist/procedures/define-procedure.d.ts +19 -7
  166. package/dist/procedures/define-procedure.d.ts.map +1 -1
  167. package/dist/procedures/define-procedure.js +66 -21
  168. package/dist/procedures/define-procedure.js.map +1 -1
  169. package/dist/procedures/effects.d.ts +16 -0
  170. package/dist/procedures/effects.d.ts.map +1 -1
  171. package/dist/procedures/effects.js +33 -0
  172. package/dist/procedures/effects.js.map +1 -1
  173. package/dist/procedures/idempotency.d.ts +73 -0
  174. package/dist/procedures/idempotency.d.ts.map +1 -0
  175. package/dist/procedures/idempotency.js +131 -0
  176. package/dist/procedures/idempotency.js.map +1 -0
  177. package/dist/procedures/index.d.ts +7 -3
  178. package/dist/procedures/index.d.ts.map +1 -1
  179. package/dist/procedures/index.js +6 -2
  180. package/dist/procedures/index.js.map +1 -1
  181. package/dist/procedures/interceptors.d.ts +17 -4
  182. package/dist/procedures/interceptors.d.ts.map +1 -1
  183. package/dist/procedures/interceptors.js +18 -7
  184. package/dist/procedures/interceptors.js.map +1 -1
  185. package/dist/procedures/invoke.d.ts +15 -3
  186. package/dist/procedures/invoke.d.ts.map +1 -1
  187. package/dist/procedures/invoke.js +8 -3
  188. package/dist/procedures/invoke.js.map +1 -1
  189. package/dist/procedures/manifest.d.ts +83 -0
  190. package/dist/procedures/manifest.d.ts.map +1 -0
  191. package/dist/procedures/manifest.js +90 -0
  192. package/dist/procedures/manifest.js.map +1 -0
  193. package/dist/procedures/runtime-programs.d.ts +19 -0
  194. package/dist/procedures/runtime-programs.d.ts.map +1 -0
  195. package/dist/procedures/runtime-programs.js +24 -0
  196. package/dist/procedures/runtime-programs.js.map +1 -0
  197. package/dist/procedures/types.d.ts +20 -1
  198. package/dist/procedures/types.d.ts.map +1 -1
  199. package/dist/procedures/types.js.map +1 -1
  200. package/dist/protocol.d.ts +40 -0
  201. package/dist/protocol.d.ts.map +1 -0
  202. package/dist/protocol.js +58 -0
  203. package/dist/protocol.js.map +1 -0
  204. package/dist/server/host-policy.d.ts +4 -3
  205. package/dist/server/host-policy.d.ts.map +1 -1
  206. package/dist/server/host-policy.js +6 -3
  207. package/dist/server/host-policy.js.map +1 -1
  208. package/dist/server/host-procedures.d.ts +31 -0
  209. package/dist/server/host-procedures.d.ts.map +1 -0
  210. package/dist/server/host-procedures.js +75 -0
  211. package/dist/server/host-procedures.js.map +1 -0
  212. package/dist/server/index.d.ts +3 -3
  213. package/dist/server/index.d.ts.map +1 -1
  214. package/dist/server/index.js +1 -1
  215. package/dist/server/index.js.map +1 -1
  216. package/dist/server/procedure-server.d.ts +110 -4
  217. package/dist/server/procedure-server.d.ts.map +1 -1
  218. package/dist/server/procedure-server.js +269 -17
  219. package/dist/server/procedure-server.js.map +1 -1
  220. package/dist/server/request-auth.d.ts +19 -0
  221. package/dist/server/request-auth.d.ts.map +1 -0
  222. package/dist/server/request-auth.js +32 -0
  223. package/dist/server/request-auth.js.map +1 -0
  224. package/dist/server/types.d.ts +32 -0
  225. package/dist/server/types.d.ts.map +1 -1
  226. package/dist/server/types.js.map +1 -1
  227. package/dist/timers.d.ts +60 -0
  228. package/dist/timers.d.ts.map +1 -0
  229. package/dist/timers.js +33 -0
  230. package/dist/timers.js.map +1 -0
  231. package/dist/tracing/traceparent.d.ts +26 -0
  232. package/dist/tracing/traceparent.d.ts.map +1 -0
  233. package/dist/tracing/traceparent.js +53 -0
  234. package/dist/tracing/traceparent.js.map +1 -0
  235. package/package.json +7 -1
  236. package/src/adapters/http/client/transport.ts +31 -3
  237. package/src/adapters/http/client/types.ts +3 -2
  238. package/src/adapters/http/server/transport.ts +60 -24
  239. package/src/adapters/http/shared/headers.ts +7 -6
  240. package/src/adapters/local/client/transport.ts +70 -5
  241. package/src/adapters/local/client/types.ts +18 -0
  242. package/src/adapters/mock/client/transport.ts +9 -6
  243. package/src/adapters/mock/client/types.ts +7 -6
  244. package/src/adapters/port/transport.ts +124 -0
  245. package/src/adapters/websocket/client/index.ts +1 -0
  246. package/src/adapters/websocket/client/transport.ts +232 -73
  247. package/src/adapters/websocket/client/types.ts +80 -21
  248. package/src/adapters/websocket/server/transport.ts +199 -301
  249. package/src/adapters/websocket/server/types.ts +20 -1
  250. package/src/adapters/websocket/shared/rpc.ts +453 -0
  251. package/src/browser.ts +51 -3
  252. package/src/client/call-types.ts +10 -14
  253. package/src/client/client.ts +28 -9
  254. package/src/client/deadline.ts +64 -0
  255. package/src/client/errors/factory.ts +12 -8
  256. package/src/client/index.ts +21 -3
  257. package/src/client/middleware/cache.ts +120 -23
  258. package/src/client/middleware/circuit-breaker.ts +65 -35
  259. package/src/client/middleware/contexts.ts +17 -10
  260. package/src/client/middleware/items.ts +101 -6
  261. package/src/client/middleware/lru.ts +12 -0
  262. package/src/client/middleware/rate-limit.ts +9 -7
  263. package/src/client/middleware/resilience.ts +81 -0
  264. package/src/client/middleware/retry.ts +146 -34
  265. package/src/client/middleware/timeout.ts +55 -46
  266. package/src/client/middleware/tracing.ts +13 -4
  267. package/src/client/types.ts +9 -1
  268. package/src/clock.ts +21 -0
  269. package/src/duration.ts +56 -0
  270. package/src/machines.ts +186 -0
  271. package/src/procedures/capability.ts +11 -9
  272. package/src/procedures/collection/index.ts +9 -1
  273. package/src/procedures/collection/procedures.ts +57 -0
  274. package/src/procedures/collection/schemas.ts +110 -0
  275. package/src/procedures/collection/storage.ts +38 -0
  276. package/src/procedures/core/array.ts +27 -16
  277. package/src/procedures/core/index.ts +8 -0
  278. package/src/procedures/core/meta.ts +2 -1
  279. package/src/procedures/core/object.ts +48 -14
  280. package/src/procedures/core/operands.ts +7 -0
  281. package/src/procedures/core/string.ts +4 -2
  282. package/src/procedures/define-procedure.ts +82 -24
  283. package/src/procedures/effects.ts +35 -0
  284. package/src/procedures/idempotency.ts +152 -0
  285. package/src/procedures/index.ts +18 -2
  286. package/src/procedures/interceptors.ts +28 -11
  287. package/src/procedures/invoke.ts +19 -5
  288. package/src/procedures/manifest.ts +158 -0
  289. package/src/procedures/runtime-programs.ts +27 -0
  290. package/src/procedures/types.ts +20 -1
  291. package/src/protocol.ts +97 -0
  292. package/src/server/host-policy.ts +8 -5
  293. package/src/server/host-procedures.ts +102 -0
  294. package/src/server/index.ts +5 -2
  295. package/src/server/procedure-server.ts +324 -10
  296. package/src/server/request-auth.ts +35 -0
  297. package/src/server/types.ts +37 -0
  298. package/src/timers.ts +82 -0
  299. package/src/tracing/traceparent.ts +61 -0
@@ -7,8 +7,11 @@
7
7
 
8
8
  import type { IncomingMessage } from "http";
9
9
  import { WebSocketServer, WebSocket } from "ws";
10
- import type { ServerTransport, ServerRequest, ServerResponse } from "../../../server/types.js";
11
- import type { Server } from "../../../server/server.js";
10
+ import type { ServerTransport, ServerRequest } from "../../../server/types.js";
11
+ import type { Message, ResponseItem, Transport } from "../../../client/types.js";
12
+ import { pathToMethod } from "../../../server/method.js";
13
+ import { IncomingCalls, OutgoingCalls } from "../shared/rpc.js";
14
+ import type { RequestHandler } from "../../../server/types.js";
12
15
  import type {
13
16
  WebSocketServerTransportOptions,
14
17
  WebSocketMessage,
@@ -17,6 +20,10 @@ import type {
17
20
  } from "./types.js";
18
21
  import { withoutInternalKeys } from "../../metadata.js";
19
22
  import { checkBrowserRequest, type BrowserGuardOptions } from "../../../server/browser-guard.js";
23
+ import { optionalMs } from "../../../duration.js";
24
+ import { credentialsOf, withoutSecretHeaders } from "../../../server/request-auth.js";
25
+ import { PROTOCOL_VERSION, protocolVersionOf } from "../../../protocol.js";
26
+ import { TIMER_DEFAULTS } from "../../../timers.js";
20
27
 
21
28
  /**
22
29
  * WebSocket server transport adapter.
@@ -43,87 +50,36 @@ import { checkBrowserRequest, type BrowserGuardOptions } from "../../../server/b
43
50
  * httpServer.listen(3000);
44
51
  * ```
45
52
  */
46
- /**
47
- * The credit of one stream: the number of items the client can take now. The client sends
48
- * more credit as its reader takes items (deep dive TRN-3).
49
- */
50
- class StreamCredit {
51
- private wake: (() => void) | undefined;
52
-
53
- constructor(private credit: number) {}
54
-
55
- grant(n: number): void {
56
- this.credit += n;
57
- const wake = this.wake;
58
- this.wake = undefined;
59
- wake?.();
60
- }
61
-
62
- /** Take one item of credit. Returns false when the signal aborted the wait. */
63
- async take(signal: AbortSignal): Promise<boolean> {
64
- while (this.credit <= 0) {
65
- if (signal.aborted) return false;
66
- await new Promise<void>((resolve) => {
67
- const onAbort = (): void => resolve();
68
- signal.addEventListener("abort", onAbort, { once: true });
69
- this.wake = () => {
70
- signal.removeEventListener("abort", onAbort);
71
- resolve();
72
- };
73
- });
74
- }
75
- if (signal.aborted) return false;
76
- this.credit--;
77
- return true;
78
- }
79
- }
80
-
81
- /** The error of a failed stream or handler: its code (a string) and its retryable flag. */
82
- function errorStatus(error: unknown): { type: "error"; code: string; message: string; retryable: boolean } {
83
- const code = (error as { code?: unknown } | undefined)?.code;
84
- const retryable = (error as { retryable?: unknown } | undefined)?.retryable;
85
- return {
86
- type: "error",
87
- code: typeof code === "string" ? code : "HANDLER_ERROR",
88
- message: error instanceof Error ? error.message : String(error),
89
- // Before, every stream error lost its retryable flag (deep dive TRN-7)
90
- retryable: typeof retryable === "boolean" ? retryable : false,
91
- };
92
- }
93
-
94
- interface PendingRequest {
95
- id: string;
96
- /** The connection the request was sent to: only it may answer, and its close ends the request */
97
- connectionId: string;
98
- resolve: (value: unknown) => void;
99
- reject: (error: Error) => void;
100
- timeout: NodeJS.Timeout;
101
- }
102
-
103
53
  export class WebSocketServerTransport implements ServerTransport {
104
54
  readonly name = "websocket";
105
55
 
106
56
  private wss: WebSocketServer | null = null;
107
57
  private options: Required<
108
- Omit<WebSocketServerTransportOptions, "authenticate" | "onConnection" | "allowedOrigins" | "allowedHosts">
58
+ Omit<WebSocketServerTransportOptions, "authenticate" | "onConnection" | "allowedOrigins" | "allowedHosts" | "heartbeat" | "reverseRequestTimeout">
109
59
  > & {
110
60
  authenticate?: WebSocketServerTransportOptions["authenticate"];
111
61
  onConnection?: WebSocketServerTransportOptions["onConnection"];
112
62
  };
113
- private server: Server;
63
+ private server: RequestHandler;
114
64
  private connections: Set<WebSocket> = new Set();
115
65
 
66
+ /** The time until the first frame of a call to a client, in milliseconds (R24) */
67
+ private readonly reverseRequestTimeoutMs: number;
68
+ /** The heartbeat of the server, or undefined when it is off (roadmap R3) */
69
+ private readonly heartbeat: { interval: number; misses: number } | undefined;
70
+ private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
71
+ /** The pings without a pong, one after the other, of each connection */
72
+ private readonly missedPongs = new WeakMap<WebSocket, number>();
73
+
116
74
  // Tracked connections with IDs for bidirectional RPC
117
75
  private trackedConnections = new Map<string, TrackedConnection>();
118
- private pendingRequests = new Map<string, PendingRequest>();
119
76
  private connectionCounter = 0;
120
77
 
121
- // The requests in progress, by "connectionId:requestId": a cancel message or a closed
122
- // connection aborts their signal, so a stream stops and its handler gets return()
123
- private inflight = new Map<string, AbortController>();
124
-
125
- // The flow control of the streams in progress, by the same key
126
- private credits = new Map<string, StreamCredit>();
78
+ /**
79
+ * The calls of each connection in the two directions (roadmap R24): the calls that the client
80
+ * sends (served by the server) and the calls that the server sends to the client.
81
+ */
82
+ private readonly channels = new Map<string, { incoming: IncomingCalls; outgoing: OutgoingCalls }>();
127
83
 
128
84
  /** The browser checks of each connection (see `server/browser-guard.ts`) */
129
85
  private readonly guard: BrowserGuardOptions;
@@ -132,7 +88,7 @@ export class WebSocketServerTransport implements ServerTransport {
132
88
  private connectHandlers: ConnectionEventHandler[] = [];
133
89
  private disconnectHandlers: ConnectionEventHandler[] = [];
134
90
 
135
- constructor(server: Server, options: WebSocketServerTransportOptions) {
91
+ constructor(server: RequestHandler, options: WebSocketServerTransportOptions) {
136
92
  this.server = server;
137
93
  this.options = {
138
94
  server: options.server,
@@ -145,6 +101,36 @@ export class WebSocketServerTransport implements ServerTransport {
145
101
  highWaterMark: options.highWaterMark ?? 1024 * 1024,
146
102
  };
147
103
  this.guard = { allowedOrigins: options.allowedOrigins, allowedHosts: options.allowedHosts };
104
+ this.reverseRequestTimeoutMs = optionalMs(options.reverseRequestTimeout, "reverseRequestTimeout") ?? TIMER_DEFAULTS.webSocketServer.reverseRequestTimeoutMs;
105
+ this.heartbeat =
106
+ options.heartbeat === false
107
+ ? undefined
108
+ : {
109
+ interval: optionalMs(options.heartbeat?.interval, "heartbeat.interval") ?? TIMER_DEFAULTS.webSocketServer.heartbeatIntervalMs,
110
+ misses: Math.max(1, options.heartbeat?.misses ?? TIMER_DEFAULTS.webSocketServer.heartbeatMisses),
111
+ };
112
+ }
113
+
114
+ /**
115
+ * One beat of the heartbeat: a connection that missed too many pongs closes at once (its close
116
+ * event aborts its requests), and each other connection gets a ping.
117
+ */
118
+ private beat(): void {
119
+ if (!this.heartbeat) return;
120
+ for (const ws of this.connections) {
121
+ const missed = this.missedPongs.get(ws) ?? 0;
122
+ if (missed >= this.heartbeat.misses) {
123
+ console.error(`[${this.name}] A connection missed ${missed} pongs: closing it`);
124
+ ws.terminate();
125
+ continue;
126
+ }
127
+ this.missedPongs.set(ws, missed + 1);
128
+ try {
129
+ ws.ping();
130
+ } catch {
131
+ // The socket is closing: its close event cleans up
132
+ }
133
+ }
148
134
  }
149
135
 
150
136
  async start(): Promise<void> {
@@ -185,17 +171,28 @@ export class WebSocketServerTransport implements ServerTransport {
185
171
  this.handleConnection(ws, req);
186
172
  });
187
173
 
174
+ if (this.heartbeat) {
175
+ this.heartbeatTimer = setInterval(() => this.beat(), this.heartbeat.interval);
176
+ // The heartbeat must not keep the process alive
177
+ this.heartbeatTimer.unref?.();
178
+ }
179
+
188
180
  console.log(`[${this.name}] WebSocket server listening on ${this.options.path}`);
189
181
  }
190
182
 
191
183
  async stop(): Promise<void> {
184
+ if (this.heartbeatTimer) {
185
+ clearInterval(this.heartbeatTimer);
186
+ this.heartbeatTimer = undefined;
187
+ }
192
188
  if (this.wss) {
193
- // Stop the requests in progress, so their handlers end (deep dive TRN-15)
194
- for (const controller of this.inflight.values()) controller.abort();
195
- this.inflight.clear();
196
- for (const connectionId of this.trackedConnections.keys()) {
197
- this.rejectPendingFor(connectionId, "Server stopped");
189
+ // Stop the requests in progress, so their handlers end (deep dive TRN-15), and fail the
190
+ // calls that the server sent to its clients
191
+ for (const channel of this.channels.values()) {
192
+ channel.incoming.abortAll();
193
+ channel.outgoing.failAll("Server stopped");
198
194
  }
195
+ this.channels.clear();
199
196
 
200
197
  // Close all connections
201
198
  for (const ws of this.connections) {
@@ -237,11 +234,31 @@ export class WebSocketServerTransport implements ServerTransport {
237
234
  connectedAt: new Date(),
238
235
  metadata: {
239
236
  remoteAddress: req.socket.remoteAddress,
240
- headers: req.headers,
237
+ headers: withoutSecretHeaders(req.headers as Record<string, unknown>),
241
238
  },
242
239
  };
243
240
  this.trackedConnections.set(connectionId, trackedConnection);
244
241
 
242
+ // The calls of this connection in the two directions (roadmap R24)
243
+ const isOpen = (): boolean => ws.readyState === WebSocket.OPEN;
244
+ const send = (frame: WebSocketMessage): void => this.send(ws, frame);
245
+ this.channels.set(connectionId, {
246
+ incoming: new IncomingCalls({
247
+ handler: this.server,
248
+ send,
249
+ sendWithBackpressure: (frame, signal) => this.sendWithBackpressure(ws, frame, signal),
250
+ isOpen,
251
+ requestOf: (frame, signal) => this.requestOf(frame, req, connectionId, signal),
252
+ }),
253
+ outgoing: new OutgoingCalls({
254
+ send,
255
+ isOpen,
256
+ requestTimeoutMs: this.reverseRequestTimeoutMs,
257
+ streamWindow: 64,
258
+ idPrefix: "r:",
259
+ }),
260
+ });
261
+
245
262
  // Call user connection handler (legacy)
246
263
  if (this.options.onConnection) {
247
264
  this.options.onConnection(ws, req);
@@ -256,6 +273,9 @@ export class WebSocketServerTransport implements ServerTransport {
256
273
  }
257
274
  }
258
275
 
276
+ // A pong answers the ping of the heartbeat
277
+ ws.on("pong", () => this.missedPongs.set(ws, 0));
278
+
259
279
  // Handle messages
260
280
  ws.on("message", async (data: Buffer) => {
261
281
  try {
@@ -269,8 +289,11 @@ export class WebSocketServerTransport implements ServerTransport {
269
289
  // Handle close
270
290
  ws.on("close", () => {
271
291
  this.connections.delete(ws);
272
- this.rejectPendingFor(connectionId, "Connection closed");
273
- this.abortInflightFor(connectionId);
292
+ // The calls of the client stop, and the calls to the client fail (BUGS-2026-07 L29)
293
+ const channel = this.channels.get(connectionId);
294
+ channel?.incoming.abortAll();
295
+ channel?.outgoing.failAll("WebSocket connection closed");
296
+ this.channels.delete(connectionId);
274
297
  const conn = this.trackedConnections.get(connectionId);
275
298
  if (conn) {
276
299
  this.trackedConnections.delete(connectionId);
@@ -294,201 +317,79 @@ export class WebSocketServerTransport implements ServerTransport {
294
317
  }
295
318
 
296
319
  /**
297
- * Reject the server-to-client requests that wait for a connection that went away,
298
- * instead of letting them wait for their timeout (BUGS-2026-07 L29).
320
+ * The server request of a `request` frame of a connection: the metadata of the frame and of the
321
+ * connection, the signal of the call, and the credentials of the upgrade (roadmap R18).
299
322
  */
300
- private abortInflightFor(connectionId: string): void {
301
- const prefix = `${connectionId}:`;
302
- for (const [key, controller] of this.inflight) {
303
- if (key.startsWith(prefix)) {
304
- this.inflight.delete(key);
305
- controller.abort();
306
- }
307
- }
308
- }
309
-
310
- private rejectPendingFor(connectionId: string, reason: string): void {
311
- for (const [requestId, pending] of this.pendingRequests) {
312
- if (pending.connectionId === connectionId) {
313
- clearTimeout(pending.timeout);
314
- this.pendingRequests.delete(requestId);
315
- pending.reject(new Error(`${reason}: ${connectionId}`));
316
- }
317
- }
323
+ private requestOf(frame: WebSocketMessage, req: IncomingMessage, connectionId: string, signal: AbortSignal): ServerRequest {
324
+ return {
325
+ id: frame.id,
326
+ method: frame.method!,
327
+ // Only an absent payload becomes {}: before, 0, false and "" became {} too (deep dive TRN-10)
328
+ payload: frame.payload === undefined ? {} : frame.payload,
329
+ metadata: {
330
+ ...withoutInternalKeys(frame.metadata),
331
+ // Add connection info
332
+ connectionId,
333
+ remoteAddress: req.socket.remoteAddress,
334
+ // The upgrade headers without their secrets (roadmap R18)
335
+ headers: withoutSecretHeaders(req.headers as Record<string, unknown>),
336
+ },
337
+ signal,
338
+ // The credentials of the connection: the bearer token of its upgrade (R18)
339
+ credentials: credentialsOf(req),
340
+ };
318
341
  }
319
342
 
320
343
  /**
321
- * Handle WebSocket message.
344
+ * Handle WebSocket message: a heartbeat, a frame of a call that the server sent (a response,
345
+ * an error, a stream), or a frame of a call of the client (a request, a cancel, a credit).
322
346
  */
323
347
  private async handleMessage(
324
348
  ws: WebSocket,
325
349
  data: Buffer,
326
- req: IncomingMessage,
350
+ _req: IncomingMessage,
327
351
  connectionId: string
328
352
  ): Promise<void> {
329
353
  // Parse message
330
354
  let message: WebSocketMessage;
331
355
  try {
332
356
  message = JSON.parse(data.toString());
333
- } catch (error) {
357
+ } catch {
334
358
  this.sendError(ws, "unknown", "Invalid JSON message");
335
359
  return;
336
360
  }
337
361
 
338
362
  // Handle ping (client heartbeat)
339
363
  if (message.type === "ping") {
340
- const pong: WebSocketMessage = {
341
- id: message.id,
342
- type: "pong",
343
- };
344
- this.send(ws, pong);
345
- return;
346
- }
347
-
348
- // Handle pong (response to our ping)
349
- if (message.type === "pong") {
350
- // Just acknowledge, no action needed
364
+ this.send(ws, { id: message.id, type: "pong" });
351
365
  return;
352
366
  }
353
-
354
- // Handle server-response (response to server-initiated request)
355
- if (message.type === "server-response") {
356
- const pending = this.pendingRequests.get(message.id);
357
- // Only the connection the request was sent to may answer it
358
- if (pending && pending.connectionId === connectionId) {
359
- clearTimeout(pending.timeout);
360
- this.pendingRequests.delete(message.id);
361
- if (message.error) {
362
- pending.reject(new Error(message.error.message));
363
- } else {
364
- pending.resolve(message.result);
365
- }
367
+ // A pong answers a ping of the server: nothing to do
368
+ if (message.type === "pong") return;
369
+
370
+ // The hello of the client: the protocol version (roadmap R25)
371
+ if (message.type === "hello") {
372
+ const version = protocolVersionOf(message.protocol);
373
+ const connection = this.trackedConnections.get(connectionId);
374
+ if (connection && version !== undefined) connection.metadata = { ...connection.metadata, protocol: version };
375
+ if (version !== undefined && version > PROTOCOL_VERSION) {
376
+ this.sendError(ws, message.id || "hello", `This server speaks the protocol ${PROTOCOL_VERSION}, and the client uses ${version}`, "PROTOCOL_VERSION");
377
+ ws.close(1002, "protocol version");
378
+ return;
366
379
  }
380
+ this.send(ws, { id: message.id || "hello", type: "hello", protocol: PROTOCOL_VERSION });
367
381
  return;
368
382
  }
369
383
 
370
- // Handle cancel: the client stopped reading a response (for example, it left a stream early)
371
- if (message.type === "cancel") {
372
- const key = `${connectionId}:${message.id}`;
373
- this.inflight.get(key)?.abort();
374
- this.inflight.delete(key);
375
- return;
376
- }
377
-
378
- // Handle credit: the client's reader took items of a stream, so the stream can send more
379
- if (message.type === "credit") {
380
- if (typeof message.credit === "number" && message.credit > 0) {
381
- this.credits.get(`${connectionId}:${message.id}`)?.grant(message.credit);
382
- }
383
- return;
384
- }
385
-
386
- // Validate request message
387
- if (!message.id || message.type !== "request" || !message.method) {
388
- this.sendError(ws, message.id || "unknown", "Invalid message format");
389
- return;
390
- }
391
-
392
- const key = `${connectionId}:${message.id}`;
393
- // A request id names one request of the connection while it runs. A second request with
394
- // the id would take the first one's controller: its stream could not be cancelled, and the
395
- // two outputs would mix (deep dive TRN-9).
396
- if (this.inflight.has(key)) {
397
- this.sendError(ws, message.id, "A request with this id is still running", "DUPLICATE_REQUEST");
398
- return;
399
- }
400
- const controller = new AbortController();
401
- this.inflight.set(key, controller);
402
-
403
- // Convert to ServerRequest. Only an absent payload becomes {}: before, 0, false and ""
404
- // became {} too (deep dive TRN-10).
405
- const serverRequest: ServerRequest = {
406
- id: message.id,
407
- method: message.method,
408
- payload: message.payload === undefined ? {} : message.payload,
409
- metadata: {
410
- ...withoutInternalKeys(message.metadata),
411
- // Add connection info
412
- connectionId,
413
- remoteAddress: req.socket.remoteAddress,
414
- headers: req.headers,
415
- },
416
- signal: controller.signal,
417
- };
418
-
419
- try {
420
- // Process through universal server
421
- const serverResponse = await this.server.handle(serverRequest);
422
-
423
- if (serverResponse.stream) {
424
- const credit =
425
- typeof message.credit === "number" && message.credit > 0 ? new StreamCredit(message.credit) : undefined;
426
- if (credit) this.credits.set(key, credit);
427
- await this.sendStream(ws, serverResponse.id, serverResponse.stream, controller.signal, credit);
428
- } else {
429
- this.sendResponse(ws, serverResponse);
430
- }
431
- } catch (error) {
432
- console.error("[WebSocket] Request handling error:", error);
433
- this.sendError(
434
- ws,
435
- message.id,
436
- error instanceof Error ? error.message : "Internal server error"
437
- );
438
- } finally {
439
- // Delete only this request's entries (deep dive TRN-9)
440
- if (this.inflight.get(key) === controller) this.inflight.delete(key);
441
- this.credits.delete(key);
442
- }
443
- }
444
-
445
- /**
446
- * Send a stream response (BUGS-2026-07 H5): one "stream" frame for each item, then a frame
447
- * with `stream.done`. An error of the stream ends it with an "error" frame. When the signal
448
- * aborts (a cancel message, a closed connection), the loop stops, and the stream's handler
449
- * gets return().
450
- *
451
- * Flow control (deep dive TRN-3): the stream takes its next item only when the client has
452
- * credit for it (when the request carried credit), and waits while the connection's send
453
- * buffer holds more than `highWaterMark` bytes.
454
- */
455
- private async sendStream(
456
- ws: WebSocket,
457
- id: string,
458
- stream: AsyncIterable<unknown>,
459
- signal: AbortSignal,
460
- credit: StreamCredit | undefined
461
- ): Promise<void> {
462
- const iterator = stream[Symbol.asyncIterator]();
463
- let done = false;
464
- try {
465
- for (;;) {
466
- if (credit && !(await credit.take(signal))) return;
467
- if (signal.aborted || ws.readyState !== WebSocket.OPEN) return;
468
- const result = await iterator.next();
469
- if (result.done) {
470
- done = true;
471
- break;
472
- }
473
- if (signal.aborted || ws.readyState !== WebSocket.OPEN) return;
474
- await this.sendWithBackpressure(ws, { id, type: "stream", payload: result.value, stream: { done: false } }, signal);
475
- }
476
- this.send(ws, { id, type: "stream", stream: { done: true } });
477
- } catch (error) {
478
- done = true;
479
- if (signal.aborted) return;
480
- const status = errorStatus(error);
481
- this.send(ws, {
482
- id,
483
- type: "error",
484
- status,
485
- error: { code: status.code, message: status.message, retryable: status.retryable },
486
- stream: { done: true },
487
- });
488
- } finally {
489
- // The loop stopped before the end: the handler gets return()
490
- if (!done) await iterator.return?.();
491
- }
384
+ const channel = this.channels.get(connectionId);
385
+ if (!channel) return;
386
+ // A frame of a call that the server sent to this client (roadmap R24)
387
+ if (channel.outgoing.receive(message)) return;
388
+ // A frame of a call of the client: a request, a cancel or a credit
389
+ if (channel.incoming.receive(message)) return;
390
+ // A server-response frame of an older client, for a call that no longer exists
391
+ if (message.type === "server-response") return;
392
+ this.sendError(ws, message.id || "unknown", "Invalid message format");
492
393
  }
493
394
 
494
395
  /**
@@ -514,26 +415,6 @@ export class WebSocketServerTransport implements ServerTransport {
514
415
  });
515
416
  }
516
417
 
517
- private sendResponse(ws: WebSocket, response: ServerResponse): void {
518
- const message: WebSocketMessage = {
519
- id: response.id,
520
- type: response.status.type === "success" ? "response" : "error",
521
- payload: response.payload,
522
- metadata: response.metadata,
523
- status: response.status,
524
- };
525
-
526
- if (response.status.type === "error") {
527
- message.error = {
528
- code: response.status.code,
529
- message: response.status.message || "Unknown error",
530
- retryable: response.status.retryable,
531
- };
532
- }
533
-
534
- this.send(ws, message);
535
- }
536
-
537
418
  /**
538
419
  * Send error to client.
539
420
  */
@@ -611,48 +492,65 @@ export class WebSocketServerTransport implements ServerTransport {
611
492
  }
612
493
 
613
494
  /**
614
- * Call a procedure on a specific client (server-to-client RPC).
615
- * Returns a promise that resolves with the client's response.
495
+ * Send a call to a client (roadmap R24): the same frames as the calls of the client, with
496
+ * streaming, credit, cancel, the signal and the remaining time of the call. The client serves it
497
+ * with its own handler (the `serve` option of `WebSocketTransport`). An unknown connection gives a
498
+ * `CONNECTION_NOT_FOUND` error item.
616
499
  */
617
- callClient(
618
- connectionId: string,
619
- path: string[],
620
- input: unknown,
621
- timeout = 30000
622
- ): Promise<unknown> {
623
- const conn = this.trackedConnections.get(connectionId);
624
- if (!conn) {
625
- return Promise.reject(new Error(`Connection not found: ${connectionId}`));
500
+ callConnection<TReq, TRes>(connectionId: string, message: Message<TReq>): AsyncIterable<ResponseItem<TRes>> {
501
+ const channel = this.channels.get(connectionId);
502
+ if (!channel) {
503
+ return (async function* () {
504
+ yield {
505
+ id: message.id,
506
+ status: { type: "error", code: "CONNECTION_NOT_FOUND", message: `Connection not found: ${connectionId}`, retryable: false },
507
+ payload: null as TRes,
508
+ metadata: {},
509
+ } as ResponseItem<TRes>;
510
+ })();
626
511
  }
512
+ return channel.outgoing.call<TReq, TRes>(message);
513
+ }
627
514
 
628
- const requestId = `sreq_${Date.now()}_${Math.random().toString(36).slice(2)}`;
629
-
630
- return new Promise((resolve, reject) => {
631
- const timeoutHandle = setTimeout(() => {
632
- this.pendingRequests.delete(requestId);
633
- reject(new Error(`Request timeout after ${timeout}ms`));
634
- }, timeout);
635
-
636
- this.pendingRequests.set(requestId, {
637
- id: requestId,
638
- connectionId,
639
- resolve,
640
- reject,
641
- timeout: timeoutHandle,
642
- });
643
-
644
- const message: WebSocketMessage = {
645
- type: "server-request",
646
- id: requestId,
647
- path,
648
- input,
649
- };
515
+ /**
516
+ * A transport to one client: a `Client` over it calls the procedures that the client serves,
517
+ * with each client middleware (retry, timeouts) of the caller (roadmap R24).
518
+ */
519
+ transportFor(connectionId: string): Transport {
520
+ return {
521
+ name: `websocket-reverse:${connectionId}`,
522
+ send: <TReq, TRes>(message: Message<TReq>) => this.callConnection<TReq, TRes>(connectionId, message),
523
+ close: async () => undefined,
524
+ };
525
+ }
650
526
 
651
- this.send(conn.socket, message);
652
- });
527
+ /**
528
+ * Call a procedure on a specific client (server-to-client RPC). It gives the value of the
529
+ * call, and fails with its error. It is a wrapper of `callConnection` (roadmap R24).
530
+ */
531
+ async callClient(connectionId: string, path: string[], input: unknown, timeout = 30000): Promise<unknown> {
532
+ if (!this.channels.has(connectionId)) throw new Error(`Connection not found: ${connectionId}`);
533
+ const signal = AbortSignal.timeout(timeout);
534
+ const message: Message<unknown> = {
535
+ id: `sreq_${Date.now()}_${Math.random().toString(36).slice(2)}`,
536
+ method: pathToMethod(path),
537
+ payload: input,
538
+ metadata: { "procedure-timeout": timeout },
539
+ signal,
540
+ };
541
+ let last: ResponseItem<unknown> | undefined;
542
+ for await (const item of this.callConnection(connectionId, message)) {
543
+ if (item.status.type === "error") {
544
+ if (signal.aborted) throw new Error(`Request timeout after ${timeout}ms`);
545
+ throw Object.assign(new Error(item.status.message), { code: item.status.code, retryable: item.status.retryable });
546
+ }
547
+ last = item;
548
+ }
549
+ return last?.payload;
653
550
  }
654
551
 
655
552
  /**
553
+ * Register a handler for new connections. /**
656
554
  * Register a handler for new connections.
657
555
  * Returns a function to unregister the handler.
658
556
  */