@graphorin/server 0.6.0 → 0.7.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 (182) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +6 -6
  3. package/dist/app-audit-binding.d.ts +15 -0
  4. package/dist/app-audit-binding.d.ts.map +1 -0
  5. package/dist/app-audit-binding.js +136 -0
  6. package/dist/app-audit-binding.js.map +1 -0
  7. package/dist/app-daemons.d.ts +29 -0
  8. package/dist/app-daemons.d.ts.map +1 -0
  9. package/dist/app-daemons.js +27 -0
  10. package/dist/app-daemons.js.map +1 -0
  11. package/dist/app-lifecycle.js +272 -0
  12. package/dist/app-lifecycle.js.map +1 -0
  13. package/dist/app-metrics.js +79 -0
  14. package/dist/app-metrics.js.map +1 -0
  15. package/dist/app-middleware.js +92 -0
  16. package/dist/app-middleware.js.map +1 -0
  17. package/dist/app-routes.js +131 -0
  18. package/dist/app-routes.js.map +1 -0
  19. package/dist/app-ws.js +65 -0
  20. package/dist/app-ws.js.map +1 -0
  21. package/dist/app.d.ts +16 -16
  22. package/dist/app.d.ts.map +1 -1
  23. package/dist/app.js +40 -611
  24. package/dist/app.js.map +1 -1
  25. package/dist/commentary/built-in-patterns.d.ts +1 -1
  26. package/dist/commentary/built-in-patterns.js +1 -1
  27. package/dist/commentary/built-in-patterns.js.map +1 -1
  28. package/dist/config.d.ts +100 -1
  29. package/dist/config.d.ts.map +1 -1
  30. package/dist/config.js +30 -2
  31. package/dist/config.js.map +1 -1
  32. package/dist/health/checks.d.ts +15 -1
  33. package/dist/health/checks.d.ts.map +1 -1
  34. package/dist/health/checks.js +21 -0
  35. package/dist/health/checks.js.map +1 -1
  36. package/dist/health/routes.js +1 -1
  37. package/dist/index.d.ts +9 -4
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +22 -26
  40. package/dist/index.js.map +1 -1
  41. package/dist/internal/context.d.ts +2 -2
  42. package/dist/internal/wire-error.js +20 -0
  43. package/dist/internal/wire-error.js.map +1 -0
  44. package/dist/lifecycle/pre-bind.d.ts +1 -1
  45. package/dist/metrics/catalog.d.ts.map +1 -1
  46. package/dist/metrics/catalog.js.map +1 -1
  47. package/dist/metrics/tools-bridge.d.ts +15 -0
  48. package/dist/metrics/tools-bridge.d.ts.map +1 -0
  49. package/dist/metrics/tools-bridge.js +103 -0
  50. package/dist/metrics/tools-bridge.js.map +1 -0
  51. package/dist/middleware/audit.d.ts +1 -1
  52. package/dist/middleware/auth.js +1 -1
  53. package/dist/middleware/csrf.js +1 -1
  54. package/dist/middleware/index.js +1 -1
  55. package/dist/middleware/scope.d.ts.map +1 -1
  56. package/dist/middleware/scope.js +44 -4
  57. package/dist/middleware/scope.js.map +1 -1
  58. package/dist/package.js +6 -0
  59. package/dist/package.js.map +1 -0
  60. package/dist/registry/index.d.ts +33 -0
  61. package/dist/registry/index.d.ts.map +1 -1
  62. package/dist/registry/index.js.map +1 -1
  63. package/dist/replay/routes.d.ts +1 -1
  64. package/dist/replay/routes.js +2 -2
  65. package/dist/routes/agents.d.ts.map +1 -1
  66. package/dist/routes/agents.js +64 -18
  67. package/dist/routes/agents.js.map +1 -1
  68. package/dist/routes/auth.js +2 -2
  69. package/dist/routes/auth.js.map +1 -1
  70. package/dist/routes/sessions.js +5 -5
  71. package/dist/routes/sessions.js.map +1 -1
  72. package/dist/routes/tokens.d.ts +1 -1
  73. package/dist/routes/tokens.d.ts.map +1 -1
  74. package/dist/routes/tokens.js +21 -1
  75. package/dist/routes/tokens.js.map +1 -1
  76. package/dist/routes/workflows.d.ts.map +1 -1
  77. package/dist/routes/workflows.js +165 -19
  78. package/dist/routes/workflows.js.map +1 -1
  79. package/dist/runtime/retention.d.ts +105 -0
  80. package/dist/runtime/retention.d.ts.map +1 -0
  81. package/dist/runtime/retention.js +154 -0
  82. package/dist/runtime/retention.js.map +1 -0
  83. package/dist/runtime/run-state.d.ts +2 -0
  84. package/dist/runtime/run-state.d.ts.map +1 -1
  85. package/dist/runtime/run-state.js +34 -2
  86. package/dist/runtime/run-state.js.map +1 -1
  87. package/dist/sse/events.js +3 -4
  88. package/dist/sse/events.js.map +1 -1
  89. package/dist/tools-audit-bridge.d.ts +29 -0
  90. package/dist/tools-audit-bridge.d.ts.map +1 -0
  91. package/dist/tools-audit-bridge.js +103 -0
  92. package/dist/tools-audit-bridge.js.map +1 -0
  93. package/dist/workflows/timer-daemon.d.ts +69 -0
  94. package/dist/workflows/timer-daemon.d.ts.map +1 -0
  95. package/dist/workflows/timer-daemon.js +37 -0
  96. package/dist/workflows/timer-daemon.js.map +1 -0
  97. package/dist/ws/dispatcher.d.ts +1 -1
  98. package/dist/ws/dispatcher.d.ts.map +1 -1
  99. package/dist/ws/dispatcher.js +19 -12
  100. package/dist/ws/dispatcher.js.map +1 -1
  101. package/dist/ws/index.d.ts +2 -2
  102. package/dist/ws/index.js +3 -3
  103. package/dist/ws/replay-buffer.d.ts +35 -1
  104. package/dist/ws/replay-buffer.d.ts.map +1 -1
  105. package/dist/ws/replay-buffer.js +35 -3
  106. package/dist/ws/replay-buffer.js.map +1 -1
  107. package/dist/ws/upgrade.d.ts.map +1 -1
  108. package/dist/ws/upgrade.js +26 -19
  109. package/dist/ws/upgrade.js.map +1 -1
  110. package/package.json +31 -30
  111. package/src/app-audit-binding.ts +227 -0
  112. package/src/app-daemons.ts +73 -0
  113. package/src/app-lifecycle.ts +476 -0
  114. package/src/app-metrics.ts +144 -0
  115. package/src/app-middleware.ts +149 -0
  116. package/src/app-routes.ts +305 -0
  117. package/src/app-ws.ts +111 -0
  118. package/src/app.ts +317 -0
  119. package/src/commentary/audit-bridge.ts +135 -0
  120. package/src/commentary/built-in-patterns.ts +79 -0
  121. package/src/commentary/index.ts +32 -0
  122. package/src/commentary/sanitizer.ts +238 -0
  123. package/src/commentary/types.ts +130 -0
  124. package/src/config.ts +472 -0
  125. package/src/consolidator/daemon.ts +141 -0
  126. package/src/consolidator/index.ts +14 -0
  127. package/src/errors/index.ts +247 -0
  128. package/src/health/checks.ts +322 -0
  129. package/src/health/index.ts +34 -0
  130. package/src/health/routes.ts +154 -0
  131. package/src/index.ts +243 -0
  132. package/src/internal/context.ts +77 -0
  133. package/src/internal/ids.ts +17 -0
  134. package/src/internal/json.ts +47 -0
  135. package/src/internal/wire-error.ts +44 -0
  136. package/src/lifecycle/hooks.ts +66 -0
  137. package/src/lifecycle/index.ts +16 -0
  138. package/src/lifecycle/pre-bind.ts +138 -0
  139. package/src/metrics/catalog.ts +112 -0
  140. package/src/metrics/index.ts +12 -0
  141. package/src/metrics/registry.ts +337 -0
  142. package/src/metrics/tools-bridge.ts +124 -0
  143. package/src/middleware/audit.ts +114 -0
  144. package/src/middleware/auth.ts +195 -0
  145. package/src/middleware/cors.ts +72 -0
  146. package/src/middleware/csrf.ts +98 -0
  147. package/src/middleware/idempotency.ts +336 -0
  148. package/src/middleware/index.ts +38 -0
  149. package/src/middleware/rate-limit.ts +71 -0
  150. package/src/middleware/request-state.ts +79 -0
  151. package/src/middleware/scope.ts +161 -0
  152. package/src/registry/index.ts +278 -0
  153. package/src/replay/index.ts +14 -0
  154. package/src/replay/routes.ts +255 -0
  155. package/src/routes/agents.ts +363 -0
  156. package/src/routes/audit.ts +207 -0
  157. package/src/routes/auth.ts +80 -0
  158. package/src/routes/health.ts +42 -0
  159. package/src/routes/index.ts +16 -0
  160. package/src/routes/mcp.ts +97 -0
  161. package/src/routes/memory.ts +152 -0
  162. package/src/routes/sessions.ts +250 -0
  163. package/src/routes/skills.ts +91 -0
  164. package/src/routes/tokens.ts +166 -0
  165. package/src/routes/workflows.ts +616 -0
  166. package/src/runtime/retention.ts +284 -0
  167. package/src/runtime/run-state.ts +422 -0
  168. package/src/sse/events.ts +303 -0
  169. package/src/sse/index.ts +7 -0
  170. package/src/tools-audit-bridge.ts +120 -0
  171. package/src/triggers/daemon.ts +198 -0
  172. package/src/triggers/index.ts +16 -0
  173. package/src/triggers/routes.ts +139 -0
  174. package/src/workflows/timer-daemon.ts +102 -0
  175. package/src/ws/dispatcher.ts +537 -0
  176. package/src/ws/index.ts +45 -0
  177. package/src/ws/replay-buffer.ts +209 -0
  178. package/src/ws/subjects.ts +162 -0
  179. package/src/ws/ticket.ts +174 -0
  180. package/src/ws/upgrade.ts +507 -0
  181. package/dist/lifecycle/index.js +0 -3
  182. package/dist/routes/index.js +0 -12
@@ -0,0 +1,69 @@
1
+ //#region src/workflows/timer-daemon.d.ts
2
+ /**
3
+ * W-032: durable-timer driver integration with the server lifecycle.
4
+ *
5
+ * Mirrors the triggers daemon: the operator builds a
6
+ * `createTimerDriver(...)` (from `@graphorin/workflow`) over their
7
+ * workflows + checkpoint stores and hands it to `createServer({
8
+ * workflowTimers: { driver } })`; the daemon binds `start()`/`stop()`
9
+ * to the lifecycle hooks and surfaces `status()` through `/v1/health`.
10
+ * The driver is typed STRUCTURALLY so `@graphorin/server` takes no
11
+ * dependency on `@graphorin/workflow`.
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+ /**
16
+ * Structural slice of `@graphorin/workflow`'s `TimerDriver` the daemon
17
+ * consumes.
18
+ *
19
+ * @stable
20
+ */
21
+ interface WorkflowTimerDriverLike {
22
+ start(): void;
23
+ stop(): void;
24
+ status(): {
25
+ readonly running: boolean;
26
+ readonly sweeps: number;
27
+ readonly fired: number;
28
+ readonly errors: number;
29
+ readonly lastSweepAt?: number;
30
+ readonly nextWakeAt?: number;
31
+ };
32
+ sweep(): Promise<number>;
33
+ }
34
+ /**
35
+ * Snapshot exposed via {@link WorkflowTimerDaemon.status} + the
36
+ * `/v1/health` aggregator.
37
+ *
38
+ * @stable
39
+ */
40
+ interface WorkflowTimerDaemonStatus {
41
+ readonly running: boolean;
42
+ readonly sweeps: number;
43
+ readonly fired: number;
44
+ readonly errors: number;
45
+ readonly lastSweepAt?: string;
46
+ readonly nextWakeAt?: string;
47
+ }
48
+ /** @stable */
49
+ interface CreateWorkflowTimerDaemonOptions {
50
+ readonly driver: WorkflowTimerDriverLike;
51
+ }
52
+ /**
53
+ * Stateful handle returned by {@link createWorkflowTimerDaemon}.
54
+ *
55
+ * @stable
56
+ */
57
+ interface WorkflowTimerDaemon {
58
+ start(): Promise<void>;
59
+ stop(): Promise<void>;
60
+ status(): Promise<WorkflowTimerDaemonStatus>;
61
+ readonly driver: WorkflowTimerDriverLike;
62
+ }
63
+ /**
64
+ * @stable
65
+ */
66
+ declare function createWorkflowTimerDaemon(options: CreateWorkflowTimerDaemonOptions): WorkflowTimerDaemon;
67
+ //#endregion
68
+ export { CreateWorkflowTimerDaemonOptions, WorkflowTimerDaemon, WorkflowTimerDaemonStatus, WorkflowTimerDriverLike, createWorkflowTimerDaemon };
69
+ //# sourceMappingURL=timer-daemon.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timer-daemon.d.ts","names":[],"sources":["../../src/workflows/timer-daemon.ts"],"sourcesContent":[],"mappings":";;AAoBA;AAoBA;AAUA;AASA;;;;;;;AAUA;;;;;;;;UAjDiB,uBAAA;;;;;;;;;;;WAWN;;;;;;;;UASM,yBAAA;;;;;;;;;UAUA,gCAAA;mBACE;;;;;;;UAQF,mBAAA;WACN;UACD;YACE,QAAQ;mBACD;;;;;iBAMH,yBAAA,UACL,mCACR"}
@@ -0,0 +1,37 @@
1
+ //#region src/workflows/timer-daemon.ts
2
+ /**
3
+ * @stable
4
+ */
5
+ function createWorkflowTimerDaemon(options) {
6
+ let running = false;
7
+ return {
8
+ get driver() {
9
+ return options.driver;
10
+ },
11
+ async start() {
12
+ if (running) return;
13
+ running = true;
14
+ options.driver.start();
15
+ },
16
+ async stop() {
17
+ if (!running) return;
18
+ running = false;
19
+ options.driver.stop();
20
+ },
21
+ async status() {
22
+ const s = options.driver.status();
23
+ return Object.freeze({
24
+ running: s.running,
25
+ sweeps: s.sweeps,
26
+ fired: s.fired,
27
+ errors: s.errors,
28
+ ...s.lastSweepAt !== void 0 ? { lastSweepAt: new Date(s.lastSweepAt).toISOString() } : {},
29
+ ...s.nextWakeAt !== void 0 ? { nextWakeAt: new Date(s.nextWakeAt).toISOString() } : {}
30
+ });
31
+ }
32
+ };
33
+ }
34
+
35
+ //#endregion
36
+ export { createWorkflowTimerDaemon };
37
+ //# sourceMappingURL=timer-daemon.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timer-daemon.js","names":[],"sources":["../../src/workflows/timer-daemon.ts"],"sourcesContent":["/**\n * W-032: durable-timer driver integration with the server lifecycle.\n *\n * Mirrors the triggers daemon: the operator builds a\n * `createTimerDriver(...)` (from `@graphorin/workflow`) over their\n * workflows + checkpoint stores and hands it to `createServer({\n * workflowTimers: { driver } })`; the daemon binds `start()`/`stop()`\n * to the lifecycle hooks and surfaces `status()` through `/v1/health`.\n * The driver is typed STRUCTURALLY so `@graphorin/server` takes no\n * dependency on `@graphorin/workflow`.\n *\n * @packageDocumentation\n */\n\n/**\n * Structural slice of `@graphorin/workflow`'s `TimerDriver` the daemon\n * consumes.\n *\n * @stable\n */\nexport interface WorkflowTimerDriverLike {\n start(): void;\n stop(): void;\n status(): {\n readonly running: boolean;\n readonly sweeps: number;\n readonly fired: number;\n readonly errors: number;\n readonly lastSweepAt?: number;\n readonly nextWakeAt?: number;\n };\n sweep(): Promise<number>;\n}\n\n/**\n * Snapshot exposed via {@link WorkflowTimerDaemon.status} + the\n * `/v1/health` aggregator.\n *\n * @stable\n */\nexport interface WorkflowTimerDaemonStatus {\n readonly running: boolean;\n readonly sweeps: number;\n readonly fired: number;\n readonly errors: number;\n readonly lastSweepAt?: string;\n readonly nextWakeAt?: string;\n}\n\n/** @stable */\nexport interface CreateWorkflowTimerDaemonOptions {\n readonly driver: WorkflowTimerDriverLike;\n}\n\n/**\n * Stateful handle returned by {@link createWorkflowTimerDaemon}.\n *\n * @stable\n */\nexport interface WorkflowTimerDaemon {\n start(): Promise<void>;\n stop(): Promise<void>;\n status(): Promise<WorkflowTimerDaemonStatus>;\n readonly driver: WorkflowTimerDriverLike;\n}\n\n/**\n * @stable\n */\nexport function createWorkflowTimerDaemon(\n options: CreateWorkflowTimerDaemonOptions,\n): WorkflowTimerDaemon {\n let running = false;\n return {\n get driver() {\n return options.driver;\n },\n async start() {\n if (running) return;\n running = true;\n options.driver.start();\n },\n async stop() {\n if (!running) return;\n running = false;\n options.driver.stop();\n },\n async status() {\n const s = options.driver.status();\n return Object.freeze({\n running: s.running,\n sweeps: s.sweeps,\n fired: s.fired,\n errors: s.errors,\n ...(s.lastSweepAt !== undefined\n ? { lastSweepAt: new Date(s.lastSweepAt).toISOString() }\n : {}),\n ...(s.nextWakeAt !== undefined ? { nextWakeAt: new Date(s.nextWakeAt).toISOString() } : {}),\n });\n },\n };\n}\n"],"mappings":";;;;AAqEA,SAAgB,0BACd,SACqB;CACrB,IAAI,UAAU;AACd,QAAO;EACL,IAAI,SAAS;AACX,UAAO,QAAQ;;EAEjB,MAAM,QAAQ;AACZ,OAAI,QAAS;AACb,aAAU;AACV,WAAQ,OAAO,OAAO;;EAExB,MAAM,OAAO;AACX,OAAI,CAAC,QAAS;AACd,aAAU;AACV,WAAQ,OAAO,MAAM;;EAEvB,MAAM,SAAS;GACb,MAAM,IAAI,QAAQ,OAAO,QAAQ;AACjC,UAAO,OAAO,OAAO;IACnB,SAAS,EAAE;IACX,QAAQ,EAAE;IACV,OAAO,EAAE;IACT,QAAQ,EAAE;IACV,GAAI,EAAE,gBAAgB,SAClB,EAAE,aAAa,IAAI,KAAK,EAAE,YAAY,CAAC,aAAa,EAAE,GACtD,EAAE;IACN,GAAI,EAAE,eAAe,SAAY,EAAE,YAAY,IAAI,KAAK,EAAE,WAAW,CAAC,aAAa,EAAE,GAAG,EAAE;IAC3F,CAAC;;EAEL"}
@@ -3,8 +3,8 @@ import { DeliveryCommentarySanitizer } from "../commentary/sanitizer.js";
3
3
  import "../commentary/index.js";
4
4
  import { ReplayBuffer, ReplayBufferOptions } from "./replay-buffer.js";
5
5
  import { ParsedSubject } from "./subjects.js";
6
- import { ServerLifecycleFrame, ServerMessage } from "@graphorin/protocol";
7
6
  import { ParsedScope } from "@graphorin/security/auth";
7
+ import { ServerLifecycleFrame, ServerMessage } from "@graphorin/protocol";
8
8
 
9
9
  //#region src/ws/dispatcher.d.ts
10
10
 
@@ -1 +1 @@
1
- {"version":3,"file":"dispatcher.d.ts","names":[],"sources":["../../src/ws/dispatcher.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;AA0EA;;;;;AA0BiB,UAhEA,mBAAA,CAgEsB;EAe3B,SAAA,UAAe,CAAA,EA9EH,wBAiFK;EAkBZ,SAAA,YAAY,CAAA,EAlGH,mBAkGG;EACP;;;;EAyBS,SAAA,uBAAA,CAAA,EAAA,MAAA;EAInB,SAAA,GAAA,CAAA,EAAA,GAAA,GAAA,MAAA;EAI6C;;;;AAazD;EA6BgB,SAAA,MAAA,CAAA,EAAA,CAAA,KAAkB,EAlKN,mBAkKgB,EAAA,GAAA,IAA2B;;;;;;;KA1J3D,mBAAA;;;mBAIW;;;;;;;;;;;;;;;;UAYN,kBAAA;;;0BAGS,cAAc;;cAE1B;;;;;;;;;;;;;;;;;;;;UAqBG,sBAAA;;;wBAGO;;;;;;;;;;;KAYZ,eAAA;;yBAGiB;;;;;;;;;;;;UAkBZ,YAAA;sBACK;yBACG;;;;;6BAKI;;;;;;;;;;;;;;MAYvB;;;;;;;+BAOyB;;gDAInB;;2CAI+B,cAAc;gDACT;;;;;;;;;;;;;;UAY/B,cAAA;;;;;;;;;;iBA6BD,kBAAA,WAA4B,sBAA2B"}
1
+ {"version":3,"file":"dispatcher.d.ts","names":[],"sources":["../../src/ws/dispatcher.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;AA0EA;;;;;AA0BiB,UAhEA,mBAAA,CAgEsB;EAe3B,SAAA,UAAe,CAAA,EA9EH,wBAiFK;EAkBZ,SAAA,YAAY,CAAA,EAlGH,mBAkGG;EACP;;;;EAyBS,SAAA,uBAAA,CAAA,EAAA,MAAA;EAInB,SAAA,GAAA,CAAA,EAAA,GAAA,GAAA,MAAA;EAI6C;;;;AAazD;EA6BgB,SAAA,MAAA,CAAA,EAAA,CAAA,KAAkB,EAlKN,mBAkKgB,EAAA,GAAA,IAAA;;;;;;;KA1JhC,mBAAA;;;mBAIW;;;;;;;;;;;;;;;;UAYN,kBAAA;;;0BAGS,cAAc;;cAE1B;;;;;;;;;;;;;;;;;;;;UAqBG,sBAAA;;;wBAGO;;;;;;;;;;;KAYZ,eAAA;;yBAGiB;;;;;;;;;;;;UAkBZ,YAAA;sBACK;yBACG;;;;;6BAKI;;;;;;;;;;;;;;MAYvB;;;;;;;+BAOyB;;gDAInB;;2CAI+B,cAAc;gDACT;;;;;;;;;;;;;;UAY/B,cAAA;;;;;;;;;;iBA6BD,kBAAA,WAA4B,sBAA2B"}
@@ -1,5 +1,4 @@
1
1
  import { createDeliveryCommentarySanitizer } from "../commentary/sanitizer.js";
2
- import "../commentary/index.js";
3
2
  import { createReplayBuffer } from "./replay-buffer.js";
4
3
  import { isSubjectAllowed, tryParseSubject } from "./subjects.js";
5
4
  import { ServerMessageSchema, isEventFrame } from "@graphorin/protocol";
@@ -168,12 +167,10 @@ function createWsDispatcher(options = {}) {
168
167
  let replayed = 0;
169
168
  let snapshotEventId = replay.nextEventIdHint;
170
169
  for (const event of replay.events) {
171
- const frame = {
170
+ dispatchSanitized(record, {
172
171
  ...event,
173
172
  subscriptionId: input.subscriptionId
174
- };
175
- validateAndDispatch(record, frame);
176
- record.lastEventId = frame.eventId;
173
+ });
177
174
  replayed += 1;
178
175
  }
179
176
  if (replay.droppedCount > 0) {
@@ -202,12 +199,25 @@ function createWsDispatcher(options = {}) {
202
199
  subscriptions.delete(subscriptionId);
203
200
  return true;
204
201
  }
202
+ /**
203
+ * W-027: single choke point for delivering a frame to a
204
+ * subscription. Sanitizes, dispatches, and advances `lastEventId` -
205
+ * BOTH the live path and the replay path go through here so a future
206
+ * delivery path cannot silently bypass the commentary sanitizer
207
+ * (structural regression guard). `sanitize` no-ops on non-`event`
208
+ * frames and never changes `eventId`, so the ordering invariant is
209
+ * untouched.
210
+ */
211
+ function dispatchSanitized(record, frame) {
212
+ const sanitized = sanitizer.sanitize(frame, "ws");
213
+ validateAndDispatch(record, sanitized);
214
+ record.lastEventId = sanitized.eventId;
215
+ }
205
216
  function emit(subject, bare) {
206
- const eventId = bare.eventId ?? nextEventId();
207
217
  const bufferedFrame = {
208
218
  v: "1",
209
219
  kind: "event",
210
- eventId,
220
+ eventId: bare.eventId ?? nextEventId(),
211
221
  subscriptionId: "__pending__",
212
222
  subject,
213
223
  type: bare.type,
@@ -219,13 +229,10 @@ function createWsDispatcher(options = {}) {
219
229
  for (const subscriptionId of subjectSubscribers) {
220
230
  const record = subscriptions.get(subscriptionId);
221
231
  if (record === void 0) continue;
222
- const sanitized = sanitizer.sanitize({
232
+ dispatchSanitized(record, {
223
233
  ...bufferedFrame,
224
234
  subscriptionId
225
- }, "ws");
226
- validateAndDispatch(record, sanitized);
227
- record.lastEventId = sanitized.eventId;
228
- if (sanitized.eventId === eventId) continue;
235
+ });
229
236
  }
230
237
  }
231
238
  function emitLifecycle(subscriptionId, status, reason) {
@@ -1 +1 @@
1
- {"version":3,"file":"dispatcher.js","names":["record: SubscriptionRecord","snapshotEventId: string | undefined","frame: ServerEventFrame","bufferedFrame: ServerEventFrame"],"sources":["../../src/ws/dispatcher.ts"],"sourcesContent":["/**\n * Per-server WebSocket dispatcher. Owns the in-memory subscription\n * map, fans out events to matching connections, applies the\n * delivery-layer commentary-phase trace sanitization, validates\n * outbound frames against the `@graphorin/protocol` schema, and\n * exposes a tiny `emit(subject, event)` surface route handlers\n * (and the agent / workflow runtimes) consume.\n *\n * @packageDocumentation\n */\n\nimport {\n isEventFrame,\n type ServerEventFrame,\n type ServerLifecycleFrame,\n type ServerMessage,\n ServerMessageSchema,\n} from '@graphorin/protocol';\nimport type { ParsedScope } from '@graphorin/security/auth';\nimport {\n createDeliveryCommentarySanitizer,\n type DeliveryCommentaryConfig,\n type DeliveryCommentarySanitizer,\n} from '../commentary/index.js';\nimport {\n createReplayBuffer,\n type ReplayBuffer,\n type ReplayBufferOptions,\n} from './replay-buffer.js';\nimport { isSubjectAllowed, type ParsedSubject, tryParseSubject } from './subjects.js';\n\n/**\n * Public configuration accepted by {@link createWsDispatcher}.\n *\n * @stable\n */\nexport interface WsDispatcherOptions {\n readonly commentary?: DeliveryCommentaryConfig;\n readonly replayBuffer?: ReplayBufferOptions;\n /**\n * Cap on outstanding events queued for an offline subscriber.\n * Defaults to the same value as the replay buffer per-subject cap.\n */\n readonly perConnectionQueueLimit?: number;\n readonly now?: () => number;\n /**\n * Logger sink for protocol violations + dropped frames. When\n * omitted, the dispatcher is silent (production wiring uses the\n * server's structured logger).\n */\n readonly onWarn?: (event: WsDispatcherWarning) => void;\n}\n\n/**\n * Discriminator surfaced to the optional warn sink.\n *\n * @stable\n */\nexport type WsDispatcherWarning =\n | {\n readonly kind: 'invalid-frame';\n readonly subscriptionId: string;\n readonly issues: ReadonlyArray<string>;\n }\n | { readonly kind: 'queue-overflow'; readonly subscriptionId: string }\n | { readonly kind: 'subject-rejected'; readonly subject: string; readonly reason: string };\n\n/**\n * Subscriber surface used by the dispatcher. Each WebSocket\n * connection wraps its `WSContext.send` in this interface so the\n * dispatcher does not depend on `@hono/node-ws` types directly.\n *\n * @stable\n */\nexport interface WsSubscriberHandle {\n readonly id: string;\n readonly tokenId: string;\n readonly grantedScopes: ReadonlyArray<ParsedScope>;\n /** Send a server frame; the dispatcher already validated it. */\n send(frame: ServerMessage): void;\n /** Close the underlying WebSocket with a Graphorin close code. */\n close(code: number, reason: string): void;\n /**\n * Optional buffered-byte sample. The dispatcher reads this on every\n * emit to detect sustained backpressure and close the connection\n * with the Graphorin `flow.throttled` code (4006) before the OS-\n * level send buffer collapses. Consumers backed by `@hono/node-ws`\n * can return the underlying `ws.bufferedAmount`. When the field is\n * not implemented, the dispatcher falls back to the per-connection\n * outstanding-event counter.\n */\n bufferedAmount?(): number;\n}\n\n/**\n * Snapshot of an active subscription. Surfaced via\n * {@link WsDispatcher.snapshotSubscription}.\n *\n * @stable\n */\nexport interface WsSubscriptionSnapshot {\n readonly subscriptionId: string;\n readonly subject: string;\n readonly subjectKind: ParsedSubject['kind'];\n readonly subscriberId: string;\n readonly tokenId: string;\n readonly createdAt: number;\n readonly lastEventId: string | undefined;\n}\n\n/**\n * Result of {@link WsDispatcher.subscribe}.\n *\n * @stable\n */\nexport type SubscribeResult =\n | {\n readonly ok: true;\n readonly subscription: WsSubscriptionSnapshot;\n readonly replayedCount: number;\n readonly snapshotEventId: string | undefined;\n }\n | {\n readonly ok: false;\n readonly reason:\n | 'subject-malformed'\n | 'subject-unknown'\n | 'subject-wildcard'\n | 'scope-denied';\n };\n\n/**\n * Public surface of the dispatcher.\n *\n * @stable\n */\nexport interface WsDispatcher {\n readonly sanitizer: DeliveryCommentarySanitizer;\n readonly replayBuffer: ReplayBuffer;\n /**\n * Register a subscriber (one per WebSocket connection).\n * `unregister` is called on close.\n */\n registerSubscriber(handle: WsSubscriberHandle): { unregister(): void };\n /**\n * Open a new subscription for an active subscriber. Returns either\n * the subscription snapshot + replayed-event count or a typed\n * failure reason the caller maps to the appropriate close code /\n * RPC error.\n */\n subscribe(input: {\n readonly subscriberId: string;\n readonly subject: string;\n readonly subscriptionId: string;\n readonly sinceEventId?: string;\n }): SubscribeResult;\n unsubscribe(subscriptionId: string): boolean;\n /**\n * Push an event into the dispatcher. Goes through the sanitizer,\n * validates against the protocol schema, persists into the replay\n * buffer, and fans out to every matching active subscription.\n */\n emit(subject: string, frame: BareEventFrame): void;\n /** Push a lifecycle frame to a single subscription. */\n emitLifecycle(\n subscriptionId: string,\n status: ServerLifecycleFrame['status'],\n reason?: string,\n ): void;\n /** List active subscriptions for a given subscriber (diagnostics). */\n listForSubscriber(subscriberId: string): ReadonlyArray<WsSubscriptionSnapshot>;\n snapshotSubscription(subscriptionId: string): WsSubscriptionSnapshot | undefined;\n size(): { readonly subscribers: number; readonly subscriptions: number };\n /** Clear in-memory state (used during graceful shutdown). */\n shutdown(): void;\n}\n\n/**\n * Frame argument accepted by {@link WsDispatcher.emit}. The\n * dispatcher fills in `subscriptionId`, `subject`, and `eventId`.\n *\n * @stable\n */\nexport interface BareEventFrame {\n readonly type: string;\n readonly payload: unknown;\n readonly eventId?: string;\n}\n\ninterface SubscriberRecord {\n readonly handle: WsSubscriberHandle;\n readonly subscriptions: Set<string>;\n outstandingEvents: number;\n closed: boolean;\n}\n\ninterface SubscriptionRecord {\n readonly subscriberId: string;\n /** IP-18: the authenticating principal's token id (not the connection id). */\n readonly tokenId: string;\n readonly subscriptionId: string;\n readonly subject: string;\n readonly parsed: ParsedSubject;\n readonly createdAt: number;\n lastEventId: string | undefined;\n}\n\n/**\n * Build a fresh dispatcher.\n *\n * @stable\n */\nexport function createWsDispatcher(options: WsDispatcherOptions = {}): WsDispatcher {\n const sanitizer = createDeliveryCommentarySanitizer(options.commentary);\n const replayBuffer = createReplayBuffer(options.replayBuffer ?? {});\n const now = options.now ?? Date.now;\n const onWarn = options.onWarn;\n const perConnectionQueueLimit = options.perConnectionQueueLimit ?? 1_000;\n const subscribers = new Map<string, SubscriberRecord>();\n const subscriptions = new Map<string, SubscriptionRecord>();\n const subjectIndex = new Map<string, Set<string>>();\n let eventCounter = 0;\n\n function indexSubject(subject: string, subscriptionId: string): void {\n let set = subjectIndex.get(subject);\n if (set === undefined) {\n set = new Set();\n subjectIndex.set(subject, set);\n }\n set.add(subscriptionId);\n }\n\n function deindexSubject(subject: string, subscriptionId: string): void {\n const set = subjectIndex.get(subject);\n if (set === undefined) return;\n set.delete(subscriptionId);\n if (set.size === 0) subjectIndex.delete(subject);\n }\n\n function nextEventId(): string {\n eventCounter += 1;\n return `evt-${now().toString(36)}-${eventCounter.toString(36)}`;\n }\n\n function dispatchToSubscriber(record: SubscriptionRecord, frame: ServerMessage): void {\n const subscriber = subscribers.get(record.subscriberId);\n if (subscriber === undefined || subscriber.closed) return;\n // Per-connection backpressure: track the outstanding-event counter\n // and consult the optional `bufferedAmount()` sample. The\n // dispatcher closes the connection with the Graphorin\n // `flow.throttled` code (4006) when either signal exceeds the\n // configured limit so the client can reconnect against the\n // replay buffer instead of accumulating an unbounded send queue.\n const buffered =\n typeof subscriber.handle.bufferedAmount === 'function'\n ? subscriber.handle.bufferedAmount()\n : 0;\n if (\n subscriber.outstandingEvents >= perConnectionQueueLimit ||\n buffered >= perConnectionQueueLimit * 1024\n ) {\n onWarn?.({ kind: 'queue-overflow', subscriptionId: record.subscriptionId });\n shutdownSubscriber(subscriber, 4006, 'flow.throttled');\n return;\n }\n subscriber.outstandingEvents += 1;\n try {\n subscriber.handle.send(frame);\n } catch (err) {\n onWarn?.({ kind: 'queue-overflow', subscriptionId: record.subscriptionId });\n shutdownSubscriber(subscriber, 4006, 'flow.throttled');\n void err;\n } finally {\n subscriber.outstandingEvents = Math.max(0, subscriber.outstandingEvents - 1);\n }\n }\n\n function shutdownSubscriber(subscriber: SubscriberRecord, code: number, reason: string): void {\n if (subscriber.closed) return;\n subscriber.closed = true;\n try {\n subscriber.handle.close(code, reason);\n } catch {\n // ignore - we are tearing the subscriber down.\n }\n for (const id of subscriber.subscriptions) {\n const sub = subscriptions.get(id);\n if (sub !== undefined) {\n deindexSubject(sub.subject, id);\n subscriptions.delete(id);\n }\n }\n subscriber.subscriptions.clear();\n subscribers.delete(subscriber.handle.id);\n }\n\n function validateAndDispatch(record: SubscriptionRecord, frame: ServerMessage): void {\n const validated = ServerMessageSchema.safeParse(frame);\n if (!validated.success) {\n onWarn?.({\n kind: 'invalid-frame',\n subscriptionId: record.subscriptionId,\n issues: validated.error.issues.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`),\n });\n return;\n }\n dispatchToSubscriber(record, validated.data);\n }\n\n function registerSubscriber(handle: WsSubscriberHandle): { unregister(): void } {\n subscribers.set(handle.id, {\n handle,\n subscriptions: new Set(),\n outstandingEvents: 0,\n closed: false,\n });\n return {\n unregister: () => {\n const record = subscribers.get(handle.id);\n if (record === undefined) return;\n for (const id of record.subscriptions) {\n const sub = subscriptions.get(id);\n if (sub !== undefined) {\n deindexSubject(sub.subject, id);\n subscriptions.delete(id);\n }\n }\n subscribers.delete(handle.id);\n },\n };\n }\n\n function subscribe(input: {\n readonly subscriberId: string;\n readonly subject: string;\n readonly subscriptionId: string;\n readonly sinceEventId?: string;\n }): SubscribeResult {\n const subscriber = subscribers.get(input.subscriberId);\n if (subscriber === undefined) {\n return { ok: false, reason: 'subject-unknown' };\n }\n const parseResult = tryParseSubject(input.subject);\n if (!parseResult.ok) {\n const reason = parseResult.reason;\n onWarn?.({ kind: 'subject-rejected', subject: input.subject, reason });\n if (reason === 'wildcard-not-supported') return { ok: false, reason: 'subject-wildcard' };\n if (reason === 'unknown-subject') return { ok: false, reason: 'subject-unknown' };\n return { ok: false, reason: 'subject-malformed' };\n }\n if (!isSubjectAllowed(subscriber.handle.grantedScopes, parseResult.subject)) {\n return { ok: false, reason: 'scope-denied' };\n }\n const record: SubscriptionRecord = {\n subscriberId: input.subscriberId,\n tokenId: subscriber.handle.tokenId,\n subscriptionId: input.subscriptionId,\n subject: input.subject,\n parsed: parseResult.subject,\n createdAt: now(),\n lastEventId: undefined,\n };\n subscriptions.set(input.subscriptionId, record);\n subscriber.subscriptions.add(input.subscriptionId);\n indexSubject(input.subject, input.subscriptionId);\n const replay = replayBuffer.replay(input.subject, input.sinceEventId);\n let replayed = 0;\n let snapshotEventId: string | undefined = replay.nextEventIdHint;\n for (const event of replay.events) {\n const frame: ServerEventFrame = {\n ...event,\n subscriptionId: input.subscriptionId,\n };\n validateAndDispatch(record, frame);\n record.lastEventId = frame.eventId;\n replayed += 1;\n }\n if (replay.droppedCount > 0) {\n validateAndDispatch(record, {\n v: '1',\n kind: 'replay-marker',\n subscriptionId: input.subscriptionId,\n eventId: snapshotEventId ?? `evt-replay-${now().toString(36)}`,\n droppedCount: replay.droppedCount,\n note: `Replay buffer dropped ${replay.droppedCount} events before resume.`,\n });\n snapshotEventId = snapshotEventId ?? `evt-replay-${now().toString(36)}`;\n }\n return {\n ok: true,\n subscription: snapshotForRecord(record),\n replayedCount: replayed,\n snapshotEventId,\n };\n }\n\n function unsubscribe(subscriptionId: string): boolean {\n const record = subscriptions.get(subscriptionId);\n if (record === undefined) return false;\n const subscriber = subscribers.get(record.subscriberId);\n subscriber?.subscriptions.delete(subscriptionId);\n deindexSubject(record.subject, subscriptionId);\n subscriptions.delete(subscriptionId);\n return true;\n }\n\n function emit(subject: string, bare: BareEventFrame): void {\n const eventId = bare.eventId ?? nextEventId();\n // Buffer first so the replay layer captures every event, even\n // when there is no live subscriber. The buffered frame is\n // sanitizer-neutral (the per-subscription dispatch sanitizes\n // again on the wire so the second pass is idempotent - by\n // construction matching wraps are not re-wrapped).\n const bufferedFrame: ServerEventFrame = {\n v: '1',\n kind: 'event',\n eventId,\n subscriptionId: '__pending__',\n subject,\n type: bare.type,\n payload: bare.payload,\n };\n replayBuffer.push(subject, bufferedFrame);\n const subjectSubscribers = subjectIndex.get(subject);\n if (subjectSubscribers === undefined || subjectSubscribers.size === 0) return;\n for (const subscriptionId of subjectSubscribers) {\n const record = subscriptions.get(subscriptionId);\n if (record === undefined) continue;\n const sanitized = sanitizer.sanitize({ ...bufferedFrame, subscriptionId }, 'ws');\n validateAndDispatch(record, sanitized);\n record.lastEventId = sanitized.eventId;\n if (sanitized.eventId === eventId) continue;\n }\n void isEventFrame; // keep import warm\n }\n\n function emitLifecycle(\n subscriptionId: string,\n status: ServerLifecycleFrame['status'],\n reason?: string,\n ): void {\n const record = subscriptions.get(subscriptionId);\n if (record === undefined) return;\n const frame: ServerLifecycleFrame = {\n v: '1',\n kind: 'lifecycle',\n subscriptionId,\n status,\n ...(reason !== undefined ? { reason } : {}),\n };\n validateAndDispatch(record, frame);\n }\n\n function listForSubscriber(subscriberId: string): ReadonlyArray<WsSubscriptionSnapshot> {\n const subscriber = subscribers.get(subscriberId);\n if (subscriber === undefined) return Object.freeze([]);\n return Object.freeze(\n [...subscriber.subscriptions]\n .map((id) => subscriptions.get(id))\n .filter((entry): entry is SubscriptionRecord => entry !== undefined)\n .map(snapshotForRecord),\n );\n }\n\n function snapshotSubscription(subscriptionId: string): WsSubscriptionSnapshot | undefined {\n const record = subscriptions.get(subscriptionId);\n if (record === undefined) return undefined;\n return snapshotForRecord(record);\n }\n\n function size(): { readonly subscribers: number; readonly subscriptions: number } {\n return Object.freeze({\n subscribers: subscribers.size,\n subscriptions: subscriptions.size,\n });\n }\n\n function shutdown(): void {\n for (const subscription of subscriptions.values()) {\n const subscriber = subscribers.get(subscription.subscriberId);\n try {\n subscriber?.handle.send({\n v: '1',\n kind: 'lifecycle',\n subscriptionId: subscription.subscriptionId,\n status: 'aborted',\n reason: 'server-shutdown',\n });\n } catch {\n // swallow - we are tearing down.\n }\n }\n subscriptions.clear();\n subjectIndex.clear();\n subscribers.clear();\n }\n\n return Object.freeze({\n sanitizer,\n replayBuffer,\n registerSubscriber,\n subscribe,\n unsubscribe,\n emit,\n emitLifecycle,\n listForSubscriber,\n snapshotSubscription,\n size,\n shutdown,\n });\n}\n\nfunction snapshotForRecord(record: SubscriptionRecord): WsSubscriptionSnapshot {\n return Object.freeze({\n subscriptionId: record.subscriptionId,\n subject: record.subject,\n subjectKind: record.parsed.kind,\n subscriberId: record.subscriberId,\n tokenId: record.tokenId,\n createdAt: record.createdAt,\n lastEventId: record.lastEventId,\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"dispatcher.js","names":["record: SubscriptionRecord","snapshotEventId: string | undefined","bufferedFrame: ServerEventFrame"],"sources":["../../src/ws/dispatcher.ts"],"sourcesContent":["/**\n * Per-server WebSocket dispatcher. Owns the in-memory subscription\n * map, fans out events to matching connections, applies the\n * delivery-layer commentary-phase trace sanitization, validates\n * outbound frames against the `@graphorin/protocol` schema, and\n * exposes a tiny `emit(subject, event)` surface route handlers\n * (and the agent / workflow runtimes) consume.\n *\n * @packageDocumentation\n */\n\nimport {\n isEventFrame,\n type ServerEventFrame,\n type ServerLifecycleFrame,\n type ServerMessage,\n ServerMessageSchema,\n} from '@graphorin/protocol';\nimport type { ParsedScope } from '@graphorin/security/auth';\nimport {\n createDeliveryCommentarySanitizer,\n type DeliveryCommentaryConfig,\n type DeliveryCommentarySanitizer,\n} from '../commentary/index.js';\nimport {\n createReplayBuffer,\n type ReplayBuffer,\n type ReplayBufferOptions,\n} from './replay-buffer.js';\nimport { isSubjectAllowed, type ParsedSubject, tryParseSubject } from './subjects.js';\n\n/**\n * Public configuration accepted by {@link createWsDispatcher}.\n *\n * @stable\n */\nexport interface WsDispatcherOptions {\n readonly commentary?: DeliveryCommentaryConfig;\n readonly replayBuffer?: ReplayBufferOptions;\n /**\n * Cap on outstanding events queued for an offline subscriber.\n * Defaults to the same value as the replay buffer per-subject cap.\n */\n readonly perConnectionQueueLimit?: number;\n readonly now?: () => number;\n /**\n * Logger sink for protocol violations + dropped frames. When\n * omitted, the dispatcher is silent (production wiring uses the\n * server's structured logger).\n */\n readonly onWarn?: (event: WsDispatcherWarning) => void;\n}\n\n/**\n * Discriminator surfaced to the optional warn sink.\n *\n * @stable\n */\nexport type WsDispatcherWarning =\n | {\n readonly kind: 'invalid-frame';\n readonly subscriptionId: string;\n readonly issues: ReadonlyArray<string>;\n }\n | { readonly kind: 'queue-overflow'; readonly subscriptionId: string }\n | { readonly kind: 'subject-rejected'; readonly subject: string; readonly reason: string };\n\n/**\n * Subscriber surface used by the dispatcher. Each WebSocket\n * connection wraps its `WSContext.send` in this interface so the\n * dispatcher does not depend on `@hono/node-ws` types directly.\n *\n * @stable\n */\nexport interface WsSubscriberHandle {\n readonly id: string;\n readonly tokenId: string;\n readonly grantedScopes: ReadonlyArray<ParsedScope>;\n /** Send a server frame; the dispatcher already validated it. */\n send(frame: ServerMessage): void;\n /** Close the underlying WebSocket with a Graphorin close code. */\n close(code: number, reason: string): void;\n /**\n * Optional buffered-byte sample. The dispatcher reads this on every\n * emit to detect sustained backpressure and close the connection\n * with the Graphorin `flow.throttled` code (4006) before the OS-\n * level send buffer collapses. Consumers backed by `@hono/node-ws`\n * can return the underlying `ws.bufferedAmount`. When the field is\n * not implemented, the dispatcher falls back to the per-connection\n * outstanding-event counter.\n */\n bufferedAmount?(): number;\n}\n\n/**\n * Snapshot of an active subscription. Surfaced via\n * {@link WsDispatcher.snapshotSubscription}.\n *\n * @stable\n */\nexport interface WsSubscriptionSnapshot {\n readonly subscriptionId: string;\n readonly subject: string;\n readonly subjectKind: ParsedSubject['kind'];\n readonly subscriberId: string;\n readonly tokenId: string;\n readonly createdAt: number;\n readonly lastEventId: string | undefined;\n}\n\n/**\n * Result of {@link WsDispatcher.subscribe}.\n *\n * @stable\n */\nexport type SubscribeResult =\n | {\n readonly ok: true;\n readonly subscription: WsSubscriptionSnapshot;\n readonly replayedCount: number;\n readonly snapshotEventId: string | undefined;\n }\n | {\n readonly ok: false;\n readonly reason:\n | 'subject-malformed'\n | 'subject-unknown'\n | 'subject-wildcard'\n | 'scope-denied';\n };\n\n/**\n * Public surface of the dispatcher.\n *\n * @stable\n */\nexport interface WsDispatcher {\n readonly sanitizer: DeliveryCommentarySanitizer;\n readonly replayBuffer: ReplayBuffer;\n /**\n * Register a subscriber (one per WebSocket connection).\n * `unregister` is called on close.\n */\n registerSubscriber(handle: WsSubscriberHandle): { unregister(): void };\n /**\n * Open a new subscription for an active subscriber. Returns either\n * the subscription snapshot + replayed-event count or a typed\n * failure reason the caller maps to the appropriate close code /\n * RPC error.\n */\n subscribe(input: {\n readonly subscriberId: string;\n readonly subject: string;\n readonly subscriptionId: string;\n readonly sinceEventId?: string;\n }): SubscribeResult;\n unsubscribe(subscriptionId: string): boolean;\n /**\n * Push an event into the dispatcher. Goes through the sanitizer,\n * validates against the protocol schema, persists into the replay\n * buffer, and fans out to every matching active subscription.\n */\n emit(subject: string, frame: BareEventFrame): void;\n /** Push a lifecycle frame to a single subscription. */\n emitLifecycle(\n subscriptionId: string,\n status: ServerLifecycleFrame['status'],\n reason?: string,\n ): void;\n /** List active subscriptions for a given subscriber (diagnostics). */\n listForSubscriber(subscriberId: string): ReadonlyArray<WsSubscriptionSnapshot>;\n snapshotSubscription(subscriptionId: string): WsSubscriptionSnapshot | undefined;\n size(): { readonly subscribers: number; readonly subscriptions: number };\n /** Clear in-memory state (used during graceful shutdown). */\n shutdown(): void;\n}\n\n/**\n * Frame argument accepted by {@link WsDispatcher.emit}. The\n * dispatcher fills in `subscriptionId`, `subject`, and `eventId`.\n *\n * @stable\n */\nexport interface BareEventFrame {\n readonly type: string;\n readonly payload: unknown;\n readonly eventId?: string;\n}\n\ninterface SubscriberRecord {\n readonly handle: WsSubscriberHandle;\n readonly subscriptions: Set<string>;\n outstandingEvents: number;\n closed: boolean;\n}\n\ninterface SubscriptionRecord {\n readonly subscriberId: string;\n /** IP-18: the authenticating principal's token id (not the connection id). */\n readonly tokenId: string;\n readonly subscriptionId: string;\n readonly subject: string;\n readonly parsed: ParsedSubject;\n readonly createdAt: number;\n lastEventId: string | undefined;\n}\n\n/**\n * Build a fresh dispatcher.\n *\n * @stable\n */\nexport function createWsDispatcher(options: WsDispatcherOptions = {}): WsDispatcher {\n const sanitizer = createDeliveryCommentarySanitizer(options.commentary);\n const replayBuffer = createReplayBuffer(options.replayBuffer ?? {});\n const now = options.now ?? Date.now;\n const onWarn = options.onWarn;\n const perConnectionQueueLimit = options.perConnectionQueueLimit ?? 1_000;\n const subscribers = new Map<string, SubscriberRecord>();\n const subscriptions = new Map<string, SubscriptionRecord>();\n const subjectIndex = new Map<string, Set<string>>();\n let eventCounter = 0;\n\n function indexSubject(subject: string, subscriptionId: string): void {\n let set = subjectIndex.get(subject);\n if (set === undefined) {\n set = new Set();\n subjectIndex.set(subject, set);\n }\n set.add(subscriptionId);\n }\n\n function deindexSubject(subject: string, subscriptionId: string): void {\n const set = subjectIndex.get(subject);\n if (set === undefined) return;\n set.delete(subscriptionId);\n if (set.size === 0) subjectIndex.delete(subject);\n }\n\n function nextEventId(): string {\n eventCounter += 1;\n return `evt-${now().toString(36)}-${eventCounter.toString(36)}`;\n }\n\n function dispatchToSubscriber(record: SubscriptionRecord, frame: ServerMessage): void {\n const subscriber = subscribers.get(record.subscriberId);\n if (subscriber === undefined || subscriber.closed) return;\n // Per-connection backpressure: track the outstanding-event counter\n // and consult the optional `bufferedAmount()` sample. The\n // dispatcher closes the connection with the Graphorin\n // `flow.throttled` code (4006) when either signal exceeds the\n // configured limit so the client can reconnect against the\n // replay buffer instead of accumulating an unbounded send queue.\n const buffered =\n typeof subscriber.handle.bufferedAmount === 'function'\n ? subscriber.handle.bufferedAmount()\n : 0;\n if (\n subscriber.outstandingEvents >= perConnectionQueueLimit ||\n buffered >= perConnectionQueueLimit * 1024\n ) {\n onWarn?.({ kind: 'queue-overflow', subscriptionId: record.subscriptionId });\n shutdownSubscriber(subscriber, 4006, 'flow.throttled');\n return;\n }\n subscriber.outstandingEvents += 1;\n try {\n subscriber.handle.send(frame);\n } catch (err) {\n onWarn?.({ kind: 'queue-overflow', subscriptionId: record.subscriptionId });\n shutdownSubscriber(subscriber, 4006, 'flow.throttled');\n void err;\n } finally {\n subscriber.outstandingEvents = Math.max(0, subscriber.outstandingEvents - 1);\n }\n }\n\n function shutdownSubscriber(subscriber: SubscriberRecord, code: number, reason: string): void {\n if (subscriber.closed) return;\n subscriber.closed = true;\n try {\n subscriber.handle.close(code, reason);\n } catch {\n // ignore - we are tearing the subscriber down.\n }\n for (const id of subscriber.subscriptions) {\n const sub = subscriptions.get(id);\n if (sub !== undefined) {\n deindexSubject(sub.subject, id);\n subscriptions.delete(id);\n }\n }\n subscriber.subscriptions.clear();\n subscribers.delete(subscriber.handle.id);\n }\n\n function validateAndDispatch(record: SubscriptionRecord, frame: ServerMessage): void {\n const validated = ServerMessageSchema.safeParse(frame);\n if (!validated.success) {\n onWarn?.({\n kind: 'invalid-frame',\n subscriptionId: record.subscriptionId,\n issues: validated.error.issues.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`),\n });\n return;\n }\n dispatchToSubscriber(record, validated.data);\n }\n\n function registerSubscriber(handle: WsSubscriberHandle): { unregister(): void } {\n subscribers.set(handle.id, {\n handle,\n subscriptions: new Set(),\n outstandingEvents: 0,\n closed: false,\n });\n return {\n unregister: () => {\n const record = subscribers.get(handle.id);\n if (record === undefined) return;\n for (const id of record.subscriptions) {\n const sub = subscriptions.get(id);\n if (sub !== undefined) {\n deindexSubject(sub.subject, id);\n subscriptions.delete(id);\n }\n }\n subscribers.delete(handle.id);\n },\n };\n }\n\n function subscribe(input: {\n readonly subscriberId: string;\n readonly subject: string;\n readonly subscriptionId: string;\n readonly sinceEventId?: string;\n }): SubscribeResult {\n const subscriber = subscribers.get(input.subscriberId);\n if (subscriber === undefined) {\n return { ok: false, reason: 'subject-unknown' };\n }\n const parseResult = tryParseSubject(input.subject);\n if (!parseResult.ok) {\n const reason = parseResult.reason;\n onWarn?.({ kind: 'subject-rejected', subject: input.subject, reason });\n if (reason === 'wildcard-not-supported') return { ok: false, reason: 'subject-wildcard' };\n if (reason === 'unknown-subject') return { ok: false, reason: 'subject-unknown' };\n return { ok: false, reason: 'subject-malformed' };\n }\n if (!isSubjectAllowed(subscriber.handle.grantedScopes, parseResult.subject)) {\n return { ok: false, reason: 'scope-denied' };\n }\n const record: SubscriptionRecord = {\n subscriberId: input.subscriberId,\n tokenId: subscriber.handle.tokenId,\n subscriptionId: input.subscriptionId,\n subject: input.subject,\n parsed: parseResult.subject,\n createdAt: now(),\n lastEventId: undefined,\n };\n subscriptions.set(input.subscriptionId, record);\n subscriber.subscriptions.add(input.subscriptionId);\n indexSubject(input.subject, input.subscriptionId);\n const replay = replayBuffer.replay(input.subject, input.sinceEventId);\n let replayed = 0;\n let snapshotEventId: string | undefined = replay.nextEventIdHint;\n for (const event of replay.events) {\n // W-027: the buffer stores RAW frames, so the replay path (fresh\n // subscription or reconnect-resume) must sanitize per delivery\n // exactly like the live path and the SSE replay do - otherwise\n // the commentary policy is defeated precisely on the reconnect\n // scenario the buffer exists for.\n dispatchSanitized(record, { ...event, subscriptionId: input.subscriptionId });\n replayed += 1;\n }\n if (replay.droppedCount > 0) {\n // The replay-marker is dispatcher-authored (no payload to\n // sanitize) and deliberately does NOT advance lastEventId.\n validateAndDispatch(record, {\n v: '1',\n kind: 'replay-marker',\n subscriptionId: input.subscriptionId,\n eventId: snapshotEventId ?? `evt-replay-${now().toString(36)}`,\n droppedCount: replay.droppedCount,\n note: `Replay buffer dropped ${replay.droppedCount} events before resume.`,\n });\n snapshotEventId = snapshotEventId ?? `evt-replay-${now().toString(36)}`;\n }\n return {\n ok: true,\n subscription: snapshotForRecord(record),\n replayedCount: replayed,\n snapshotEventId,\n };\n }\n\n function unsubscribe(subscriptionId: string): boolean {\n const record = subscriptions.get(subscriptionId);\n if (record === undefined) return false;\n const subscriber = subscribers.get(record.subscriberId);\n subscriber?.subscriptions.delete(subscriptionId);\n deindexSubject(record.subject, subscriptionId);\n subscriptions.delete(subscriptionId);\n return true;\n }\n\n /**\n * W-027: single choke point for delivering a frame to a\n * subscription. Sanitizes, dispatches, and advances `lastEventId` -\n * BOTH the live path and the replay path go through here so a future\n * delivery path cannot silently bypass the commentary sanitizer\n * (structural regression guard). `sanitize` no-ops on non-`event`\n * frames and never changes `eventId`, so the ordering invariant is\n * untouched.\n */\n function dispatchSanitized(record: SubscriptionRecord, frame: ServerEventFrame): void {\n const sanitized = sanitizer.sanitize(frame, 'ws');\n validateAndDispatch(record, sanitized);\n record.lastEventId = sanitized.eventId;\n }\n\n function emit(subject: string, bare: BareEventFrame): void {\n const eventId = bare.eventId ?? nextEventId();\n // Buffer first so the replay layer captures every event, even\n // when there is no live subscriber. The buffered frame is\n // sanitizer-neutral (both the live dispatch and the replay\n // dispatch sanitize per delivery, so a buffered raw frame is\n // sanitized exactly once on every path that reaches a wire).\n const bufferedFrame: ServerEventFrame = {\n v: '1',\n kind: 'event',\n eventId,\n subscriptionId: '__pending__',\n subject,\n type: bare.type,\n payload: bare.payload,\n };\n replayBuffer.push(subject, bufferedFrame);\n const subjectSubscribers = subjectIndex.get(subject);\n if (subjectSubscribers === undefined || subjectSubscribers.size === 0) return;\n for (const subscriptionId of subjectSubscribers) {\n const record = subscriptions.get(subscriptionId);\n if (record === undefined) continue;\n dispatchSanitized(record, { ...bufferedFrame, subscriptionId });\n }\n void isEventFrame; // keep import warm\n }\n\n function emitLifecycle(\n subscriptionId: string,\n status: ServerLifecycleFrame['status'],\n reason?: string,\n ): void {\n const record = subscriptions.get(subscriptionId);\n if (record === undefined) return;\n const frame: ServerLifecycleFrame = {\n v: '1',\n kind: 'lifecycle',\n subscriptionId,\n status,\n ...(reason !== undefined ? { reason } : {}),\n };\n validateAndDispatch(record, frame);\n }\n\n function listForSubscriber(subscriberId: string): ReadonlyArray<WsSubscriptionSnapshot> {\n const subscriber = subscribers.get(subscriberId);\n if (subscriber === undefined) return Object.freeze([]);\n return Object.freeze(\n [...subscriber.subscriptions]\n .map((id) => subscriptions.get(id))\n .filter((entry): entry is SubscriptionRecord => entry !== undefined)\n .map(snapshotForRecord),\n );\n }\n\n function snapshotSubscription(subscriptionId: string): WsSubscriptionSnapshot | undefined {\n const record = subscriptions.get(subscriptionId);\n if (record === undefined) return undefined;\n return snapshotForRecord(record);\n }\n\n function size(): { readonly subscribers: number; readonly subscriptions: number } {\n return Object.freeze({\n subscribers: subscribers.size,\n subscriptions: subscriptions.size,\n });\n }\n\n function shutdown(): void {\n for (const subscription of subscriptions.values()) {\n const subscriber = subscribers.get(subscription.subscriberId);\n try {\n subscriber?.handle.send({\n v: '1',\n kind: 'lifecycle',\n subscriptionId: subscription.subscriptionId,\n status: 'aborted',\n reason: 'server-shutdown',\n });\n } catch {\n // swallow - we are tearing down.\n }\n }\n subscriptions.clear();\n subjectIndex.clear();\n subscribers.clear();\n }\n\n return Object.freeze({\n sanitizer,\n replayBuffer,\n registerSubscriber,\n subscribe,\n unsubscribe,\n emit,\n emitLifecycle,\n listForSubscriber,\n snapshotSubscription,\n size,\n shutdown,\n });\n}\n\nfunction snapshotForRecord(record: SubscriptionRecord): WsSubscriptionSnapshot {\n return Object.freeze({\n subscriptionId: record.subscriptionId,\n subject: record.subject,\n subjectKind: record.parsed.kind,\n subscriberId: record.subscriberId,\n tokenId: record.tokenId,\n createdAt: record.createdAt,\n lastEventId: record.lastEventId,\n });\n}\n"],"mappings":"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"}
@@ -1,6 +1,6 @@
1
- import { ReplayBuffer, ReplayBufferOptions, ReplayBufferSlice, createReplayBuffer } from "./replay-buffer.js";
1
+ import { ReplayBuffer, ReplayBufferOptions, ReplayBufferSlice, ReplayBufferStats, createReplayBuffer, scheduleReplayBufferPruning } from "./replay-buffer.js";
2
2
  import { ParseSubjectResult, ParsedSubject, isSubjectAllowed, requiredScopeFor, tryParseSubject } from "./subjects.js";
3
3
  import { BareEventFrame, SubscribeResult, WsDispatcher, WsDispatcherOptions, WsDispatcherWarning, WsSubscriberHandle, WsSubscriptionSnapshot, createWsDispatcher } from "./dispatcher.js";
4
4
  import { WsTicket, WsTicketConsumeResult, WsTicketStore, WsTicketStoreOptions, createWsTicketStore } from "./ticket.js";
5
5
  import { WsUpgradeOptions, createWsUpgradeEvents } from "./upgrade.js";
6
- export { type BareEventFrame, type ParseSubjectResult, type ParsedSubject, type ReplayBuffer, type ReplayBufferOptions, type ReplayBufferSlice, type SubscribeResult, type WsDispatcher, type WsDispatcherOptions, type WsDispatcherWarning, type WsSubscriberHandle, type WsSubscriptionSnapshot, type WsTicket, type WsTicketConsumeResult, type WsTicketStore, type WsTicketStoreOptions, type WsUpgradeOptions, createReplayBuffer, createWsDispatcher, createWsTicketStore, createWsUpgradeEvents, isSubjectAllowed, requiredScopeFor, tryParseSubject };
6
+ export { type BareEventFrame, type ParseSubjectResult, type ParsedSubject, type ReplayBuffer, type ReplayBufferOptions, type ReplayBufferSlice, type ReplayBufferStats, type SubscribeResult, type WsDispatcher, type WsDispatcherOptions, type WsDispatcherWarning, type WsSubscriberHandle, type WsSubscriptionSnapshot, type WsTicket, type WsTicketConsumeResult, type WsTicketStore, type WsTicketStoreOptions, type WsUpgradeOptions, createReplayBuffer, createWsDispatcher, createWsTicketStore, createWsUpgradeEvents, isSubjectAllowed, requiredScopeFor, scheduleReplayBufferPruning, tryParseSubject };
package/dist/ws/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import { createReplayBuffer } from "./replay-buffer.js";
1
+ import { createWsUpgradeEvents } from "./upgrade.js";
2
+ import { createReplayBuffer, scheduleReplayBufferPruning } from "./replay-buffer.js";
2
3
  import { isSubjectAllowed, requiredScopeFor, tryParseSubject } from "./subjects.js";
3
4
  import { createWsDispatcher } from "./dispatcher.js";
4
5
  import { createWsTicketStore } from "./ticket.js";
5
- import { createWsUpgradeEvents } from "./upgrade.js";
6
6
 
7
- export { createReplayBuffer, createWsDispatcher, createWsTicketStore, createWsUpgradeEvents, isSubjectAllowed, requiredScopeFor, tryParseSubject };
7
+ export { createReplayBuffer, createWsDispatcher, createWsTicketStore, createWsUpgradeEvents, isSubjectAllowed, requiredScopeFor, scheduleReplayBufferPruning, tryParseSubject };
@@ -22,6 +22,17 @@ interface ReplayBufferSlice {
22
22
  readonly droppedCount: number;
23
23
  readonly nextEventIdHint: string | undefined;
24
24
  }
25
+ /**
26
+ * Occupancy snapshot returned by {@link ReplayBuffer.stats}.
27
+ *
28
+ * @stable
29
+ */
30
+ interface ReplayBufferStats {
31
+ /** Number of subjects currently holding at least one buffered event. */
32
+ readonly subjects: number;
33
+ /** Total buffered events across all subjects. */
34
+ readonly events: number;
35
+ }
25
36
  /**
26
37
  * Per-subject replay buffer. Stores up to `maxEvents` per subject
27
38
  * with a TTL; thread-safe under the single-writer Node event loop
@@ -35,6 +46,13 @@ interface ReplayBuffer {
35
46
  size(subject: string): number;
36
47
  forget(subject: string): void;
37
48
  prune(): void;
49
+ /**
50
+ * Occupancy snapshot (W-028). OPTIONAL so external implementations
51
+ * of this `@stable` interface keep compiling; `createReplayBuffer`
52
+ * always provides it. When absent, the `/v1/metrics` replay-buffer
53
+ * gauge degrades to `0`.
54
+ */
55
+ stats?(): ReplayBufferStats;
38
56
  }
39
57
  /**
40
58
  * Build an in-memory replay buffer.
@@ -42,6 +60,22 @@ interface ReplayBuffer {
42
60
  * @stable
43
61
  */
44
62
  declare function createReplayBuffer(options?: ReplayBufferOptions): ReplayBuffer;
63
+ /**
64
+ * W-028: schedule a periodic {@link ReplayBuffer.prune} sweep. Without
65
+ * it TTL expiry only ran lazily inside `push`/`replay`/`size` FOR THE
66
+ * SAME SUBJECT, so every finished run-subject (a fresh runId per run)
67
+ * retained up to `maxEvents` full payloads forever on a long-living
68
+ * server. Mirrors `scheduleRunPruning` (IP-16): `unref`-ed timer,
69
+ * returns a stop function. The sweep applies only the already
70
+ * documented TTL - replay semantics inside the TTL window are
71
+ * unchanged (an immediate `forget` on run completion would break
72
+ * short-disconnect resume of terminal events).
73
+ *
74
+ * @stable
75
+ */
76
+ declare function scheduleReplayBufferPruning(buffer: ReplayBuffer, opts?: {
77
+ readonly intervalMs?: number;
78
+ }): () => void;
45
79
  //#endregion
46
- export { ReplayBuffer, ReplayBufferOptions, ReplayBufferSlice, createReplayBuffer };
80
+ export { ReplayBuffer, ReplayBufferOptions, ReplayBufferSlice, ReplayBufferStats, createReplayBuffer, scheduleReplayBufferPruning };
47
81
  //# sourceMappingURL=replay-buffer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"replay-buffer.d.ts","names":[],"sources":["../../src/ws/replay-buffer.ts"],"sourcesContent":[],"mappings":";;;;;;;;;UAoBiB,mBAAA;;;;;;;;;;UAWA,iBAAA;mBACE,cAAc;;;;;;;;;;;UAYhB,YAAA;+BACc;6DAC8B;;;;;;;;;;iBAgB7C,kBAAA,WAA4B,sBAA2B"}
1
+ {"version":3,"file":"replay-buffer.d.ts","names":[],"sources":["../../src/ws/replay-buffer.ts"],"sourcesContent":[],"mappings":";;;;;;;;;UAoBiB,mBAAA;;;;;;;;;;UAWA,iBAAA;mBACE,cAAc;;;;;;;;;UAUhB,iBAAA;;;;;;;;;;;;;UAcA,YAAA;+BACc;6DAC8B;;;;;;;;;;YAUjD;;;;;;;iBAaI,kBAAA,WAA4B,sBAA2B;;;;;;;;;;;;;;iBAmHvD,2BAAA,SACN"}
@@ -24,7 +24,10 @@ function createReplayBuffer(options = {}) {
24
24
  list.splice(0, cutoffIndex);
25
25
  dropped.set(subject, (dropped.get(subject) ?? 0) + cutoffIndex);
26
26
  }
27
- if (list.length === 0) buffers.delete(subject);
27
+ if (list.length === 0) {
28
+ buffers.delete(subject);
29
+ dropped.delete(subject);
30
+ }
28
31
  }
29
32
  function push(subject, event) {
30
33
  pruneSubject(subject);
@@ -56,7 +59,7 @@ function createReplayBuffer(options = {}) {
56
59
  const startIndex = list.findIndex((entry) => entry.event.eventId === sinceEventId);
57
60
  if (startIndex < 0) return {
58
61
  events: list.map((entry) => entry.event),
59
- droppedCount,
62
+ droppedCount: Math.max(droppedCount, 1),
60
63
  nextEventIdHint: last?.event.eventId
61
64
  };
62
65
  const replayed = list.slice(startIndex + 1).map((entry) => entry.event);
@@ -79,10 +82,39 @@ function createReplayBuffer(options = {}) {
79
82
  },
80
83
  prune() {
81
84
  for (const subject of [...buffers.keys()]) pruneSubject(subject);
85
+ },
86
+ stats() {
87
+ let events = 0;
88
+ for (const list of buffers.values()) events += list.length;
89
+ return {
90
+ subjects: buffers.size,
91
+ events
92
+ };
82
93
  }
83
94
  });
84
95
  }
96
+ /**
97
+ * W-028: schedule a periodic {@link ReplayBuffer.prune} sweep. Without
98
+ * it TTL expiry only ran lazily inside `push`/`replay`/`size` FOR THE
99
+ * SAME SUBJECT, so every finished run-subject (a fresh runId per run)
100
+ * retained up to `maxEvents` full payloads forever on a long-living
101
+ * server. Mirrors `scheduleRunPruning` (IP-16): `unref`-ed timer,
102
+ * returns a stop function. The sweep applies only the already
103
+ * documented TTL - replay semantics inside the TTL window are
104
+ * unchanged (an immediate `forget` on run completion would break
105
+ * short-disconnect resume of terminal events).
106
+ *
107
+ * @stable
108
+ */
109
+ function scheduleReplayBufferPruning(buffer, opts = {}) {
110
+ const intervalMs = opts.intervalMs ?? 6e4;
111
+ const timer = setInterval(() => {
112
+ buffer.prune();
113
+ }, intervalMs);
114
+ if (typeof timer.unref === "function") timer.unref();
115
+ return () => clearInterval(timer);
116
+ }
85
117
 
86
118
  //#endregion
87
- export { createReplayBuffer };
119
+ export { createReplayBuffer, scheduleReplayBufferPruning };
88
120
  //# sourceMappingURL=replay-buffer.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"replay-buffer.js","names":[],"sources":["../../src/ws/replay-buffer.ts"],"sourcesContent":["/**\n * Per-subscription replay buffer used by the dispatcher to support\n * short-disconnect resume. When a client reconnects with a\n * `lastEventId`, the dispatcher walks the buffer from the matching\n * cursor and re-emits every event the client missed.\n *\n * Defaults (configurable per-server):\n * - `maxEvents`: 1000 (per subscription)\n * - `ttlMs`: 5 minutes\n *\n * @packageDocumentation\n */\n\nimport type { ServerEventFrame } from '@graphorin/protocol';\n\n/**\n * Public configuration accepted by {@link createReplayBuffer}.\n *\n * @stable\n */\nexport interface ReplayBufferOptions {\n readonly maxEvents?: number;\n readonly ttlMs?: number;\n readonly now?: () => number;\n}\n\n/**\n * Snapshot returned by {@link ReplayBuffer.replay}.\n *\n * @stable\n */\nexport interface ReplayBufferSlice {\n readonly events: ReadonlyArray<ServerEventFrame>;\n readonly droppedCount: number;\n readonly nextEventIdHint: string | undefined;\n}\n\n/**\n * Per-subject replay buffer. Stores up to `maxEvents` per subject\n * with a TTL; thread-safe under the single-writer Node event loop\n * model.\n *\n * @stable\n */\nexport interface ReplayBuffer {\n push(subject: string, event: ServerEventFrame): void;\n replay(subject: string, sinceEventId: string | undefined): ReplayBufferSlice;\n size(subject: string): number;\n forget(subject: string): void;\n prune(): void;\n}\n\ninterface BufferedEvent {\n readonly event: ServerEventFrame;\n readonly bufferedAt: number;\n}\n\n/**\n * Build an in-memory replay buffer.\n *\n * @stable\n */\nexport function createReplayBuffer(options: ReplayBufferOptions = {}): ReplayBuffer {\n const maxEvents = options.maxEvents ?? 1_000;\n const ttlMs = options.ttlMs ?? 5 * 60_000;\n const now = options.now ?? Date.now;\n const buffers = new Map<string, BufferedEvent[]>();\n const dropped = new Map<string, number>();\n\n function pruneSubject(subject: string): void {\n const list = buffers.get(subject);\n if (list === undefined || list.length === 0) return;\n const cutoff = now() - ttlMs;\n let cutoffIndex = 0;\n while (cutoffIndex < list.length) {\n const entry = list[cutoffIndex];\n if (entry === undefined || entry.bufferedAt >= cutoff) break;\n cutoffIndex += 1;\n }\n if (cutoffIndex > 0) {\n list.splice(0, cutoffIndex);\n dropped.set(subject, (dropped.get(subject) ?? 0) + cutoffIndex);\n }\n if (list.length === 0) buffers.delete(subject);\n }\n\n function push(subject: string, event: ServerEventFrame): void {\n pruneSubject(subject);\n let list = buffers.get(subject);\n if (list === undefined) {\n list = [];\n buffers.set(subject, list);\n }\n list.push({ event, bufferedAt: now() });\n if (list.length > maxEvents) {\n const overflow = list.length - maxEvents;\n list.splice(0, overflow);\n dropped.set(subject, (dropped.get(subject) ?? 0) + overflow);\n }\n }\n\n function replay(subject: string, sinceEventId: string | undefined): ReplayBufferSlice {\n pruneSubject(subject);\n const list = buffers.get(subject) ?? [];\n const droppedCount = dropped.get(subject) ?? 0;\n const last = list.at(-1);\n if (sinceEventId === undefined) {\n const slice: ReplayBufferSlice = {\n events: list.map((entry) => entry.event),\n droppedCount,\n nextEventIdHint: last?.event.eventId,\n };\n return slice;\n }\n const startIndex = list.findIndex((entry) => entry.event.eventId === sinceEventId);\n if (startIndex < 0) {\n // Cursor predates the buffer; return everything we still have +\n // signal the gap via `droppedCount`.\n return {\n events: list.map((entry) => entry.event),\n droppedCount,\n nextEventIdHint: last?.event.eventId,\n };\n }\n const replayed = list.slice(startIndex + 1).map((entry) => entry.event);\n return {\n events: replayed,\n droppedCount: 0,\n nextEventIdHint: replayed.at(-1)?.eventId,\n };\n }\n\n return Object.freeze({\n push,\n replay,\n size(subject: string): number {\n pruneSubject(subject);\n return buffers.get(subject)?.length ?? 0;\n },\n forget(subject: string): void {\n buffers.delete(subject);\n dropped.delete(subject);\n },\n prune(): void {\n for (const subject of [...buffers.keys()]) pruneSubject(subject);\n },\n });\n}\n"],"mappings":";;;;;;AA8DA,SAAgB,mBAAmB,UAA+B,EAAE,EAAgB;CAClF,MAAM,YAAY,QAAQ,aAAa;CACvC,MAAM,QAAQ,QAAQ,SAAS,IAAI;CACnC,MAAM,MAAM,QAAQ,OAAO,KAAK;CAChC,MAAM,0BAAU,IAAI,KAA8B;CAClD,MAAM,0BAAU,IAAI,KAAqB;CAEzC,SAAS,aAAa,SAAuB;EAC3C,MAAM,OAAO,QAAQ,IAAI,QAAQ;AACjC,MAAI,SAAS,UAAa,KAAK,WAAW,EAAG;EAC7C,MAAM,SAAS,KAAK,GAAG;EACvB,IAAI,cAAc;AAClB,SAAO,cAAc,KAAK,QAAQ;GAChC,MAAM,QAAQ,KAAK;AACnB,OAAI,UAAU,UAAa,MAAM,cAAc,OAAQ;AACvD,kBAAe;;AAEjB,MAAI,cAAc,GAAG;AACnB,QAAK,OAAO,GAAG,YAAY;AAC3B,WAAQ,IAAI,UAAU,QAAQ,IAAI,QAAQ,IAAI,KAAK,YAAY;;AAEjE,MAAI,KAAK,WAAW,EAAG,SAAQ,OAAO,QAAQ;;CAGhD,SAAS,KAAK,SAAiB,OAA+B;AAC5D,eAAa,QAAQ;EACrB,IAAI,OAAO,QAAQ,IAAI,QAAQ;AAC/B,MAAI,SAAS,QAAW;AACtB,UAAO,EAAE;AACT,WAAQ,IAAI,SAAS,KAAK;;AAE5B,OAAK,KAAK;GAAE;GAAO,YAAY,KAAK;GAAE,CAAC;AACvC,MAAI,KAAK,SAAS,WAAW;GAC3B,MAAM,WAAW,KAAK,SAAS;AAC/B,QAAK,OAAO,GAAG,SAAS;AACxB,WAAQ,IAAI,UAAU,QAAQ,IAAI,QAAQ,IAAI,KAAK,SAAS;;;CAIhE,SAAS,OAAO,SAAiB,cAAqD;AACpF,eAAa,QAAQ;EACrB,MAAM,OAAO,QAAQ,IAAI,QAAQ,IAAI,EAAE;EACvC,MAAM,eAAe,QAAQ,IAAI,QAAQ,IAAI;EAC7C,MAAM,OAAO,KAAK,GAAG,GAAG;AACxB,MAAI,iBAAiB,OAMnB,QALiC;GAC/B,QAAQ,KAAK,KAAK,UAAU,MAAM,MAAM;GACxC;GACA,iBAAiB,MAAM,MAAM;GAC9B;EAGH,MAAM,aAAa,KAAK,WAAW,UAAU,MAAM,MAAM,YAAY,aAAa;AAClF,MAAI,aAAa,EAGf,QAAO;GACL,QAAQ,KAAK,KAAK,UAAU,MAAM,MAAM;GACxC;GACA,iBAAiB,MAAM,MAAM;GAC9B;EAEH,MAAM,WAAW,KAAK,MAAM,aAAa,EAAE,CAAC,KAAK,UAAU,MAAM,MAAM;AACvE,SAAO;GACL,QAAQ;GACR,cAAc;GACd,iBAAiB,SAAS,GAAG,GAAG,EAAE;GACnC;;AAGH,QAAO,OAAO,OAAO;EACnB;EACA;EACA,KAAK,SAAyB;AAC5B,gBAAa,QAAQ;AACrB,UAAO,QAAQ,IAAI,QAAQ,EAAE,UAAU;;EAEzC,OAAO,SAAuB;AAC5B,WAAQ,OAAO,QAAQ;AACvB,WAAQ,OAAO,QAAQ;;EAEzB,QAAc;AACZ,QAAK,MAAM,WAAW,CAAC,GAAG,QAAQ,MAAM,CAAC,CAAE,cAAa,QAAQ;;EAEnE,CAAC"}
1
+ {"version":3,"file":"replay-buffer.js","names":[],"sources":["../../src/ws/replay-buffer.ts"],"sourcesContent":["/**\n * Per-subscription replay buffer used by the dispatcher to support\n * short-disconnect resume. When a client reconnects with a\n * `lastEventId`, the dispatcher walks the buffer from the matching\n * cursor and re-emits every event the client missed.\n *\n * Defaults (configurable per-server):\n * - `maxEvents`: 1000 (per subscription)\n * - `ttlMs`: 5 minutes\n *\n * @packageDocumentation\n */\n\nimport type { ServerEventFrame } from '@graphorin/protocol';\n\n/**\n * Public configuration accepted by {@link createReplayBuffer}.\n *\n * @stable\n */\nexport interface ReplayBufferOptions {\n readonly maxEvents?: number;\n readonly ttlMs?: number;\n readonly now?: () => number;\n}\n\n/**\n * Snapshot returned by {@link ReplayBuffer.replay}.\n *\n * @stable\n */\nexport interface ReplayBufferSlice {\n readonly events: ReadonlyArray<ServerEventFrame>;\n readonly droppedCount: number;\n readonly nextEventIdHint: string | undefined;\n}\n\n/**\n * Occupancy snapshot returned by {@link ReplayBuffer.stats}.\n *\n * @stable\n */\nexport interface ReplayBufferStats {\n /** Number of subjects currently holding at least one buffered event. */\n readonly subjects: number;\n /** Total buffered events across all subjects. */\n readonly events: number;\n}\n\n/**\n * Per-subject replay buffer. Stores up to `maxEvents` per subject\n * with a TTL; thread-safe under the single-writer Node event loop\n * model.\n *\n * @stable\n */\nexport interface ReplayBuffer {\n push(subject: string, event: ServerEventFrame): void;\n replay(subject: string, sinceEventId: string | undefined): ReplayBufferSlice;\n size(subject: string): number;\n forget(subject: string): void;\n prune(): void;\n /**\n * Occupancy snapshot (W-028). OPTIONAL so external implementations\n * of this `@stable` interface keep compiling; `createReplayBuffer`\n * always provides it. When absent, the `/v1/metrics` replay-buffer\n * gauge degrades to `0`.\n */\n stats?(): ReplayBufferStats;\n}\n\ninterface BufferedEvent {\n readonly event: ServerEventFrame;\n readonly bufferedAt: number;\n}\n\n/**\n * Build an in-memory replay buffer.\n *\n * @stable\n */\nexport function createReplayBuffer(options: ReplayBufferOptions = {}): ReplayBuffer {\n const maxEvents = options.maxEvents ?? 1_000;\n const ttlMs = options.ttlMs ?? 5 * 60_000;\n const now = options.now ?? Date.now;\n const buffers = new Map<string, BufferedEvent[]>();\n const dropped = new Map<string, number>();\n\n function pruneSubject(subject: string): void {\n const list = buffers.get(subject);\n if (list === undefined || list.length === 0) return;\n const cutoff = now() - ttlMs;\n let cutoffIndex = 0;\n while (cutoffIndex < list.length) {\n const entry = list[cutoffIndex];\n if (entry === undefined || entry.bufferedAt >= cutoff) break;\n cutoffIndex += 1;\n }\n if (cutoffIndex > 0) {\n list.splice(0, cutoffIndex);\n dropped.set(subject, (dropped.get(subject) ?? 0) + cutoffIndex);\n }\n if (list.length === 0) {\n // W-028: release the whole subject, including its dropped-count\n // entry - otherwise every finished run leaves a Map entry behind\n // forever. A resume with a stale cursor still signals the gap:\n // `replay()` floors droppedCount at 1 when the cursor misses.\n buffers.delete(subject);\n dropped.delete(subject);\n }\n }\n\n function push(subject: string, event: ServerEventFrame): void {\n pruneSubject(subject);\n let list = buffers.get(subject);\n if (list === undefined) {\n list = [];\n buffers.set(subject, list);\n }\n list.push({ event, bufferedAt: now() });\n if (list.length > maxEvents) {\n const overflow = list.length - maxEvents;\n list.splice(0, overflow);\n dropped.set(subject, (dropped.get(subject) ?? 0) + overflow);\n }\n }\n\n function replay(subject: string, sinceEventId: string | undefined): ReplayBufferSlice {\n pruneSubject(subject);\n const list = buffers.get(subject) ?? [];\n const droppedCount = dropped.get(subject) ?? 0;\n const last = list.at(-1);\n if (sinceEventId === undefined) {\n const slice: ReplayBufferSlice = {\n events: list.map((entry) => entry.event),\n droppedCount,\n nextEventIdHint: last?.event.eventId,\n };\n return slice;\n }\n const startIndex = list.findIndex((entry) => entry.event.eventId === sinceEventId);\n if (startIndex < 0) {\n // Cursor predates the buffer; return everything we still have +\n // signal the gap via `droppedCount`. The miss itself PROVES at\n // least one event was dropped, so floor the count at 1 even\n // after the dropped-map entry was released by pruning (W-028) -\n // the dispatcher must still send the replay-marker.\n return {\n events: list.map((entry) => entry.event),\n droppedCount: Math.max(droppedCount, 1),\n nextEventIdHint: last?.event.eventId,\n };\n }\n const replayed = list.slice(startIndex + 1).map((entry) => entry.event);\n return {\n events: replayed,\n droppedCount: 0,\n nextEventIdHint: replayed.at(-1)?.eventId,\n };\n }\n\n return Object.freeze({\n push,\n replay,\n size(subject: string): number {\n pruneSubject(subject);\n return buffers.get(subject)?.length ?? 0;\n },\n forget(subject: string): void {\n buffers.delete(subject);\n dropped.delete(subject);\n },\n prune(): void {\n for (const subject of [...buffers.keys()]) pruneSubject(subject);\n },\n stats(): ReplayBufferStats {\n let events = 0;\n for (const list of buffers.values()) events += list.length;\n return { subjects: buffers.size, events };\n },\n });\n}\n\n/**\n * W-028: schedule a periodic {@link ReplayBuffer.prune} sweep. Without\n * it TTL expiry only ran lazily inside `push`/`replay`/`size` FOR THE\n * SAME SUBJECT, so every finished run-subject (a fresh runId per run)\n * retained up to `maxEvents` full payloads forever on a long-living\n * server. Mirrors `scheduleRunPruning` (IP-16): `unref`-ed timer,\n * returns a stop function. The sweep applies only the already\n * documented TTL - replay semantics inside the TTL window are\n * unchanged (an immediate `forget` on run completion would break\n * short-disconnect resume of terminal events).\n *\n * @stable\n */\nexport function scheduleReplayBufferPruning(\n buffer: ReplayBuffer,\n opts: { readonly intervalMs?: number } = {},\n): () => void {\n const intervalMs = opts.intervalMs ?? 60_000;\n const timer = setInterval(() => {\n buffer.prune();\n }, intervalMs);\n if (typeof (timer as { unref?: () => void }).unref === 'function') {\n (timer as { unref: () => void }).unref();\n }\n return () => clearInterval(timer);\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"upgrade.d.ts","names":[],"sources":["../../src/ws/upgrade.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;;;;;;UA0DiB,gBAAA;uBACM;oBACH;;;;;;sBAME;;;;;;;;kBAQJ;;;;;;;;;;;;;;;;;;;;;;;;;;iBA2BI,qBAAA,IACjB;aAAqB;aACf,mBACR,QAAQ"}
1
+ {"version":3,"file":"upgrade.d.ts","names":[],"sources":["../../src/ws/upgrade.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;;;AA4DA;;;AAQsB,UARL,gBAAA,CAQK;EAQJ,SAAA,UAAA,EAfK,YAeL;EAAe,SAAA,OAAA,EAdb,aAca;EA2BX;;;;;EAGnB,SAAA,QAAA,CAAA,EAtCmB,aAsCnB;EAAO;;;;;;;kBA9BQ;;;;;;;;;;;;;;;;;;;;;;;;;;iBA2BI,qBAAA,IACjB;aAAqB;aACf,mBACR,QAAQ"}
@@ -1,5 +1,8 @@
1
+ import { version } from "../package.js";
2
+ import { checkScope } from "../middleware/scope.js";
3
+ import { requiredRunScope } from "../runtime/run-state.js";
4
+ import { parseScope } from "@graphorin/security/auth";
1
5
  import { ClientMessageSchema, RPC_ERROR_CODES, SUBPROTOCOL_NAME, closeCodeFor, isCancelledNotification, isInitializeRequest, isPingRequest, isRunCancelRequest, isSubscribeRequest, isUnsubscribeRequest, negotiateSubprotocol, parseTicketSubprotocol } from "@graphorin/protocol";
2
- import { parseScope, scopeMatches } from "@graphorin/security/auth";
3
6
 
4
7
  //#region src/ws/upgrade.ts
5
8
  /**
@@ -88,7 +91,13 @@ async function createWsUpgradeEvents(c, options) {
88
91
  }
89
92
  const message = parsed.data;
90
93
  if (isCancelledNotification(message)) {
91
- if (initialized && grantsInvokeScope(auth.grantedScopes)) options.runs?.abort(message.params.requestId, "mcp-cancel");
94
+ if (initialized) {
95
+ const snapshot = options.runs?.snapshot(message.params.requestId);
96
+ if (snapshot !== void 0 && checkScope({
97
+ kind: "token",
98
+ grantedScopes: auth.grantedScopes
99
+ }, requiredRunScope(snapshot, "control"))) options.runs?.abort(message.params.requestId, "mcp-cancel");
100
+ }
92
101
  return;
93
102
  }
94
103
  if (!initialized && !isInitializeRequest(message) && !isPingRequest(message)) {
@@ -100,7 +109,7 @@ async function createWsUpgradeEvents(c, options) {
100
109
  sendRpcSuccess(ws, message.id, {
101
110
  serverInfo: {
102
111
  name: "graphorin-server",
103
- version: "0.6.0"
112
+ version
104
113
  },
105
114
  capabilities: {
106
115
  subscriptions: true,
@@ -164,11 +173,20 @@ async function createWsUpgradeEvents(c, options) {
164
173
  return;
165
174
  }
166
175
  if (isRunCancelRequest(message)) {
167
- if (!grantsInvokeScope(auth.grantedScopes)) {
168
- sendRpcError(ws, message.id, RPC_ERROR_CODES.SCOPE_DENIED, "Token lacks required scope 'agents:invoke'.");
176
+ const runId = message.params.runId;
177
+ const snapshot = options.runs?.snapshot(runId);
178
+ if (snapshot === void 0) {
179
+ sendRpcError(ws, message.id, RPC_ERROR_CODES.RUN_NOT_FOUND, `Run '${runId}' is not active.`);
180
+ return;
181
+ }
182
+ const required = requiredRunScope(snapshot, "control");
183
+ if (!checkScope({
184
+ kind: "token",
185
+ grantedScopes: auth.grantedScopes
186
+ }, required)) {
187
+ sendRpcError(ws, message.id, RPC_ERROR_CODES.SCOPE_DENIED, `Token lacks required scope '${required}'.`);
169
188
  return;
170
189
  }
171
- const runId = message.params.runId;
172
190
  if (!(options.runs?.abort(runId, message.params.reason ?? "rpc-cancel") === true)) {
173
191
  sendRpcError(ws, message.id, RPC_ERROR_CODES.RUN_NOT_FOUND, `Run '${runId}' is not active.`);
174
192
  return;
@@ -269,21 +287,10 @@ async function resolveUpgradeAuth(c, options) {
269
287
  };
270
288
  }
271
289
  /**
272
- * IP-8: the `agents:invoke` scope required to cancel a run over the WS
273
- * transport - the same requirement the REST `POST /runs/:runId/abort` route
274
- * enforces. `scopeMatches` honours wildcards (`agents:*`, `admin:*`).
275
- */
276
- const INVOKE_SCOPE = parseScope("agents:invoke");
277
- /**
278
- * IP-13: full scope grant handed to a no-auth (`auth.kind='none'`) upgrade.
279
- * `admin:*` matches every required scope, including the {@link INVOKE_SCOPE}
280
- * gate on run cancellation.
290
+ * IP-13: the anonymous trusted-loopback principal (auth disabled) holds
291
+ * `admin:*` so every per-subject and per-run check passes uniformly.
281
292
  */
282
293
  const ANONYMOUS_WS_SCOPES = [parseScope("admin:*")];
283
- function grantsInvokeScope(granted) {
284
- for (const scope of granted) if (scopeMatches(scope, INVOKE_SCOPE)) return true;
285
- return false;
286
- }
287
294
  function acceptTicket(ticket) {
288
295
  return {
289
296
  kind: "ok",