@graphorin/server 0.6.1 → 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 (181) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +3 -3
  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 +35 -607
  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 +8 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +19 -24
  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 +1 -1
  59. package/dist/package.js.map +1 -1
  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.js +19 -12
  99. package/dist/ws/dispatcher.js.map +1 -1
  100. package/dist/ws/index.d.ts +2 -2
  101. package/dist/ws/index.js +3 -3
  102. package/dist/ws/replay-buffer.d.ts +35 -1
  103. package/dist/ws/replay-buffer.d.ts.map +1 -1
  104. package/dist/ws/replay-buffer.js +35 -3
  105. package/dist/ws/replay-buffer.js.map +1 -1
  106. package/dist/ws/upgrade.d.ts.map +1 -1
  107. package/dist/ws/upgrade.js +24 -18
  108. package/dist/ws/upgrade.js.map +1 -1
  109. package/package.json +30 -29
  110. package/src/app-audit-binding.ts +227 -0
  111. package/src/app-daemons.ts +73 -0
  112. package/src/app-lifecycle.ts +476 -0
  113. package/src/app-metrics.ts +144 -0
  114. package/src/app-middleware.ts +149 -0
  115. package/src/app-routes.ts +305 -0
  116. package/src/app-ws.ts +111 -0
  117. package/src/app.ts +317 -0
  118. package/src/commentary/audit-bridge.ts +135 -0
  119. package/src/commentary/built-in-patterns.ts +79 -0
  120. package/src/commentary/index.ts +32 -0
  121. package/src/commentary/sanitizer.ts +238 -0
  122. package/src/commentary/types.ts +130 -0
  123. package/src/config.ts +472 -0
  124. package/src/consolidator/daemon.ts +141 -0
  125. package/src/consolidator/index.ts +14 -0
  126. package/src/errors/index.ts +247 -0
  127. package/src/health/checks.ts +322 -0
  128. package/src/health/index.ts +34 -0
  129. package/src/health/routes.ts +154 -0
  130. package/src/index.ts +243 -0
  131. package/src/internal/context.ts +77 -0
  132. package/src/internal/ids.ts +17 -0
  133. package/src/internal/json.ts +47 -0
  134. package/src/internal/wire-error.ts +44 -0
  135. package/src/lifecycle/hooks.ts +66 -0
  136. package/src/lifecycle/index.ts +16 -0
  137. package/src/lifecycle/pre-bind.ts +138 -0
  138. package/src/metrics/catalog.ts +112 -0
  139. package/src/metrics/index.ts +12 -0
  140. package/src/metrics/registry.ts +337 -0
  141. package/src/metrics/tools-bridge.ts +124 -0
  142. package/src/middleware/audit.ts +114 -0
  143. package/src/middleware/auth.ts +195 -0
  144. package/src/middleware/cors.ts +72 -0
  145. package/src/middleware/csrf.ts +98 -0
  146. package/src/middleware/idempotency.ts +336 -0
  147. package/src/middleware/index.ts +38 -0
  148. package/src/middleware/rate-limit.ts +71 -0
  149. package/src/middleware/request-state.ts +79 -0
  150. package/src/middleware/scope.ts +161 -0
  151. package/src/registry/index.ts +278 -0
  152. package/src/replay/index.ts +14 -0
  153. package/src/replay/routes.ts +255 -0
  154. package/src/routes/agents.ts +363 -0
  155. package/src/routes/audit.ts +207 -0
  156. package/src/routes/auth.ts +80 -0
  157. package/src/routes/health.ts +42 -0
  158. package/src/routes/index.ts +16 -0
  159. package/src/routes/mcp.ts +97 -0
  160. package/src/routes/memory.ts +152 -0
  161. package/src/routes/sessions.ts +250 -0
  162. package/src/routes/skills.ts +91 -0
  163. package/src/routes/tokens.ts +166 -0
  164. package/src/routes/workflows.ts +616 -0
  165. package/src/runtime/retention.ts +284 -0
  166. package/src/runtime/run-state.ts +422 -0
  167. package/src/sse/events.ts +303 -0
  168. package/src/sse/index.ts +7 -0
  169. package/src/tools-audit-bridge.ts +120 -0
  170. package/src/triggers/daemon.ts +198 -0
  171. package/src/triggers/index.ts +16 -0
  172. package/src/triggers/routes.ts +139 -0
  173. package/src/workflows/timer-daemon.ts +102 -0
  174. package/src/ws/dispatcher.ts +537 -0
  175. package/src/ws/index.ts +45 -0
  176. package/src/ws/replay-buffer.ts +209 -0
  177. package/src/ws/subjects.ts +162 -0
  178. package/src/ws/ticket.ts +174 -0
  179. package/src/ws/upgrade.ts +507 -0
  180. package/dist/lifecycle/index.js +0 -3
  181. package/dist/routes/index.js +0 -12
@@ -0,0 +1,537 @@
1
+ /**
2
+ * Per-server WebSocket dispatcher. Owns the in-memory subscription
3
+ * map, fans out events to matching connections, applies the
4
+ * delivery-layer commentary-phase trace sanitization, validates
5
+ * outbound frames against the `@graphorin/protocol` schema, and
6
+ * exposes a tiny `emit(subject, event)` surface route handlers
7
+ * (and the agent / workflow runtimes) consume.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+
12
+ import {
13
+ isEventFrame,
14
+ type ServerEventFrame,
15
+ type ServerLifecycleFrame,
16
+ type ServerMessage,
17
+ ServerMessageSchema,
18
+ } from '@graphorin/protocol';
19
+ import type { ParsedScope } from '@graphorin/security/auth';
20
+ import {
21
+ createDeliveryCommentarySanitizer,
22
+ type DeliveryCommentaryConfig,
23
+ type DeliveryCommentarySanitizer,
24
+ } from '../commentary/index.js';
25
+ import {
26
+ createReplayBuffer,
27
+ type ReplayBuffer,
28
+ type ReplayBufferOptions,
29
+ } from './replay-buffer.js';
30
+ import { isSubjectAllowed, type ParsedSubject, tryParseSubject } from './subjects.js';
31
+
32
+ /**
33
+ * Public configuration accepted by {@link createWsDispatcher}.
34
+ *
35
+ * @stable
36
+ */
37
+ export interface WsDispatcherOptions {
38
+ readonly commentary?: DeliveryCommentaryConfig;
39
+ readonly replayBuffer?: ReplayBufferOptions;
40
+ /**
41
+ * Cap on outstanding events queued for an offline subscriber.
42
+ * Defaults to the same value as the replay buffer per-subject cap.
43
+ */
44
+ readonly perConnectionQueueLimit?: number;
45
+ readonly now?: () => number;
46
+ /**
47
+ * Logger sink for protocol violations + dropped frames. When
48
+ * omitted, the dispatcher is silent (production wiring uses the
49
+ * server's structured logger).
50
+ */
51
+ readonly onWarn?: (event: WsDispatcherWarning) => void;
52
+ }
53
+
54
+ /**
55
+ * Discriminator surfaced to the optional warn sink.
56
+ *
57
+ * @stable
58
+ */
59
+ export type WsDispatcherWarning =
60
+ | {
61
+ readonly kind: 'invalid-frame';
62
+ readonly subscriptionId: string;
63
+ readonly issues: ReadonlyArray<string>;
64
+ }
65
+ | { readonly kind: 'queue-overflow'; readonly subscriptionId: string }
66
+ | { readonly kind: 'subject-rejected'; readonly subject: string; readonly reason: string };
67
+
68
+ /**
69
+ * Subscriber surface used by the dispatcher. Each WebSocket
70
+ * connection wraps its `WSContext.send` in this interface so the
71
+ * dispatcher does not depend on `@hono/node-ws` types directly.
72
+ *
73
+ * @stable
74
+ */
75
+ export interface WsSubscriberHandle {
76
+ readonly id: string;
77
+ readonly tokenId: string;
78
+ readonly grantedScopes: ReadonlyArray<ParsedScope>;
79
+ /** Send a server frame; the dispatcher already validated it. */
80
+ send(frame: ServerMessage): void;
81
+ /** Close the underlying WebSocket with a Graphorin close code. */
82
+ close(code: number, reason: string): void;
83
+ /**
84
+ * Optional buffered-byte sample. The dispatcher reads this on every
85
+ * emit to detect sustained backpressure and close the connection
86
+ * with the Graphorin `flow.throttled` code (4006) before the OS-
87
+ * level send buffer collapses. Consumers backed by `@hono/node-ws`
88
+ * can return the underlying `ws.bufferedAmount`. When the field is
89
+ * not implemented, the dispatcher falls back to the per-connection
90
+ * outstanding-event counter.
91
+ */
92
+ bufferedAmount?(): number;
93
+ }
94
+
95
+ /**
96
+ * Snapshot of an active subscription. Surfaced via
97
+ * {@link WsDispatcher.snapshotSubscription}.
98
+ *
99
+ * @stable
100
+ */
101
+ export interface WsSubscriptionSnapshot {
102
+ readonly subscriptionId: string;
103
+ readonly subject: string;
104
+ readonly subjectKind: ParsedSubject['kind'];
105
+ readonly subscriberId: string;
106
+ readonly tokenId: string;
107
+ readonly createdAt: number;
108
+ readonly lastEventId: string | undefined;
109
+ }
110
+
111
+ /**
112
+ * Result of {@link WsDispatcher.subscribe}.
113
+ *
114
+ * @stable
115
+ */
116
+ export type SubscribeResult =
117
+ | {
118
+ readonly ok: true;
119
+ readonly subscription: WsSubscriptionSnapshot;
120
+ readonly replayedCount: number;
121
+ readonly snapshotEventId: string | undefined;
122
+ }
123
+ | {
124
+ readonly ok: false;
125
+ readonly reason:
126
+ | 'subject-malformed'
127
+ | 'subject-unknown'
128
+ | 'subject-wildcard'
129
+ | 'scope-denied';
130
+ };
131
+
132
+ /**
133
+ * Public surface of the dispatcher.
134
+ *
135
+ * @stable
136
+ */
137
+ export interface WsDispatcher {
138
+ readonly sanitizer: DeliveryCommentarySanitizer;
139
+ readonly replayBuffer: ReplayBuffer;
140
+ /**
141
+ * Register a subscriber (one per WebSocket connection).
142
+ * `unregister` is called on close.
143
+ */
144
+ registerSubscriber(handle: WsSubscriberHandle): { unregister(): void };
145
+ /**
146
+ * Open a new subscription for an active subscriber. Returns either
147
+ * the subscription snapshot + replayed-event count or a typed
148
+ * failure reason the caller maps to the appropriate close code /
149
+ * RPC error.
150
+ */
151
+ subscribe(input: {
152
+ readonly subscriberId: string;
153
+ readonly subject: string;
154
+ readonly subscriptionId: string;
155
+ readonly sinceEventId?: string;
156
+ }): SubscribeResult;
157
+ unsubscribe(subscriptionId: string): boolean;
158
+ /**
159
+ * Push an event into the dispatcher. Goes through the sanitizer,
160
+ * validates against the protocol schema, persists into the replay
161
+ * buffer, and fans out to every matching active subscription.
162
+ */
163
+ emit(subject: string, frame: BareEventFrame): void;
164
+ /** Push a lifecycle frame to a single subscription. */
165
+ emitLifecycle(
166
+ subscriptionId: string,
167
+ status: ServerLifecycleFrame['status'],
168
+ reason?: string,
169
+ ): void;
170
+ /** List active subscriptions for a given subscriber (diagnostics). */
171
+ listForSubscriber(subscriberId: string): ReadonlyArray<WsSubscriptionSnapshot>;
172
+ snapshotSubscription(subscriptionId: string): WsSubscriptionSnapshot | undefined;
173
+ size(): { readonly subscribers: number; readonly subscriptions: number };
174
+ /** Clear in-memory state (used during graceful shutdown). */
175
+ shutdown(): void;
176
+ }
177
+
178
+ /**
179
+ * Frame argument accepted by {@link WsDispatcher.emit}. The
180
+ * dispatcher fills in `subscriptionId`, `subject`, and `eventId`.
181
+ *
182
+ * @stable
183
+ */
184
+ export interface BareEventFrame {
185
+ readonly type: string;
186
+ readonly payload: unknown;
187
+ readonly eventId?: string;
188
+ }
189
+
190
+ interface SubscriberRecord {
191
+ readonly handle: WsSubscriberHandle;
192
+ readonly subscriptions: Set<string>;
193
+ outstandingEvents: number;
194
+ closed: boolean;
195
+ }
196
+
197
+ interface SubscriptionRecord {
198
+ readonly subscriberId: string;
199
+ /** IP-18: the authenticating principal's token id (not the connection id). */
200
+ readonly tokenId: string;
201
+ readonly subscriptionId: string;
202
+ readonly subject: string;
203
+ readonly parsed: ParsedSubject;
204
+ readonly createdAt: number;
205
+ lastEventId: string | undefined;
206
+ }
207
+
208
+ /**
209
+ * Build a fresh dispatcher.
210
+ *
211
+ * @stable
212
+ */
213
+ export function createWsDispatcher(options: WsDispatcherOptions = {}): WsDispatcher {
214
+ const sanitizer = createDeliveryCommentarySanitizer(options.commentary);
215
+ const replayBuffer = createReplayBuffer(options.replayBuffer ?? {});
216
+ const now = options.now ?? Date.now;
217
+ const onWarn = options.onWarn;
218
+ const perConnectionQueueLimit = options.perConnectionQueueLimit ?? 1_000;
219
+ const subscribers = new Map<string, SubscriberRecord>();
220
+ const subscriptions = new Map<string, SubscriptionRecord>();
221
+ const subjectIndex = new Map<string, Set<string>>();
222
+ let eventCounter = 0;
223
+
224
+ function indexSubject(subject: string, subscriptionId: string): void {
225
+ let set = subjectIndex.get(subject);
226
+ if (set === undefined) {
227
+ set = new Set();
228
+ subjectIndex.set(subject, set);
229
+ }
230
+ set.add(subscriptionId);
231
+ }
232
+
233
+ function deindexSubject(subject: string, subscriptionId: string): void {
234
+ const set = subjectIndex.get(subject);
235
+ if (set === undefined) return;
236
+ set.delete(subscriptionId);
237
+ if (set.size === 0) subjectIndex.delete(subject);
238
+ }
239
+
240
+ function nextEventId(): string {
241
+ eventCounter += 1;
242
+ return `evt-${now().toString(36)}-${eventCounter.toString(36)}`;
243
+ }
244
+
245
+ function dispatchToSubscriber(record: SubscriptionRecord, frame: ServerMessage): void {
246
+ const subscriber = subscribers.get(record.subscriberId);
247
+ if (subscriber === undefined || subscriber.closed) return;
248
+ // Per-connection backpressure: track the outstanding-event counter
249
+ // and consult the optional `bufferedAmount()` sample. The
250
+ // dispatcher closes the connection with the Graphorin
251
+ // `flow.throttled` code (4006) when either signal exceeds the
252
+ // configured limit so the client can reconnect against the
253
+ // replay buffer instead of accumulating an unbounded send queue.
254
+ const buffered =
255
+ typeof subscriber.handle.bufferedAmount === 'function'
256
+ ? subscriber.handle.bufferedAmount()
257
+ : 0;
258
+ if (
259
+ subscriber.outstandingEvents >= perConnectionQueueLimit ||
260
+ buffered >= perConnectionQueueLimit * 1024
261
+ ) {
262
+ onWarn?.({ kind: 'queue-overflow', subscriptionId: record.subscriptionId });
263
+ shutdownSubscriber(subscriber, 4006, 'flow.throttled');
264
+ return;
265
+ }
266
+ subscriber.outstandingEvents += 1;
267
+ try {
268
+ subscriber.handle.send(frame);
269
+ } catch (err) {
270
+ onWarn?.({ kind: 'queue-overflow', subscriptionId: record.subscriptionId });
271
+ shutdownSubscriber(subscriber, 4006, 'flow.throttled');
272
+ void err;
273
+ } finally {
274
+ subscriber.outstandingEvents = Math.max(0, subscriber.outstandingEvents - 1);
275
+ }
276
+ }
277
+
278
+ function shutdownSubscriber(subscriber: SubscriberRecord, code: number, reason: string): void {
279
+ if (subscriber.closed) return;
280
+ subscriber.closed = true;
281
+ try {
282
+ subscriber.handle.close(code, reason);
283
+ } catch {
284
+ // ignore - we are tearing the subscriber down.
285
+ }
286
+ for (const id of subscriber.subscriptions) {
287
+ const sub = subscriptions.get(id);
288
+ if (sub !== undefined) {
289
+ deindexSubject(sub.subject, id);
290
+ subscriptions.delete(id);
291
+ }
292
+ }
293
+ subscriber.subscriptions.clear();
294
+ subscribers.delete(subscriber.handle.id);
295
+ }
296
+
297
+ function validateAndDispatch(record: SubscriptionRecord, frame: ServerMessage): void {
298
+ const validated = ServerMessageSchema.safeParse(frame);
299
+ if (!validated.success) {
300
+ onWarn?.({
301
+ kind: 'invalid-frame',
302
+ subscriptionId: record.subscriptionId,
303
+ issues: validated.error.issues.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`),
304
+ });
305
+ return;
306
+ }
307
+ dispatchToSubscriber(record, validated.data);
308
+ }
309
+
310
+ function registerSubscriber(handle: WsSubscriberHandle): { unregister(): void } {
311
+ subscribers.set(handle.id, {
312
+ handle,
313
+ subscriptions: new Set(),
314
+ outstandingEvents: 0,
315
+ closed: false,
316
+ });
317
+ return {
318
+ unregister: () => {
319
+ const record = subscribers.get(handle.id);
320
+ if (record === undefined) return;
321
+ for (const id of record.subscriptions) {
322
+ const sub = subscriptions.get(id);
323
+ if (sub !== undefined) {
324
+ deindexSubject(sub.subject, id);
325
+ subscriptions.delete(id);
326
+ }
327
+ }
328
+ subscribers.delete(handle.id);
329
+ },
330
+ };
331
+ }
332
+
333
+ function subscribe(input: {
334
+ readonly subscriberId: string;
335
+ readonly subject: string;
336
+ readonly subscriptionId: string;
337
+ readonly sinceEventId?: string;
338
+ }): SubscribeResult {
339
+ const subscriber = subscribers.get(input.subscriberId);
340
+ if (subscriber === undefined) {
341
+ return { ok: false, reason: 'subject-unknown' };
342
+ }
343
+ const parseResult = tryParseSubject(input.subject);
344
+ if (!parseResult.ok) {
345
+ const reason = parseResult.reason;
346
+ onWarn?.({ kind: 'subject-rejected', subject: input.subject, reason });
347
+ if (reason === 'wildcard-not-supported') return { ok: false, reason: 'subject-wildcard' };
348
+ if (reason === 'unknown-subject') return { ok: false, reason: 'subject-unknown' };
349
+ return { ok: false, reason: 'subject-malformed' };
350
+ }
351
+ if (!isSubjectAllowed(subscriber.handle.grantedScopes, parseResult.subject)) {
352
+ return { ok: false, reason: 'scope-denied' };
353
+ }
354
+ const record: SubscriptionRecord = {
355
+ subscriberId: input.subscriberId,
356
+ tokenId: subscriber.handle.tokenId,
357
+ subscriptionId: input.subscriptionId,
358
+ subject: input.subject,
359
+ parsed: parseResult.subject,
360
+ createdAt: now(),
361
+ lastEventId: undefined,
362
+ };
363
+ subscriptions.set(input.subscriptionId, record);
364
+ subscriber.subscriptions.add(input.subscriptionId);
365
+ indexSubject(input.subject, input.subscriptionId);
366
+ const replay = replayBuffer.replay(input.subject, input.sinceEventId);
367
+ let replayed = 0;
368
+ let snapshotEventId: string | undefined = replay.nextEventIdHint;
369
+ for (const event of replay.events) {
370
+ // W-027: the buffer stores RAW frames, so the replay path (fresh
371
+ // subscription or reconnect-resume) must sanitize per delivery
372
+ // exactly like the live path and the SSE replay do - otherwise
373
+ // the commentary policy is defeated precisely on the reconnect
374
+ // scenario the buffer exists for.
375
+ dispatchSanitized(record, { ...event, subscriptionId: input.subscriptionId });
376
+ replayed += 1;
377
+ }
378
+ if (replay.droppedCount > 0) {
379
+ // The replay-marker is dispatcher-authored (no payload to
380
+ // sanitize) and deliberately does NOT advance lastEventId.
381
+ validateAndDispatch(record, {
382
+ v: '1',
383
+ kind: 'replay-marker',
384
+ subscriptionId: input.subscriptionId,
385
+ eventId: snapshotEventId ?? `evt-replay-${now().toString(36)}`,
386
+ droppedCount: replay.droppedCount,
387
+ note: `Replay buffer dropped ${replay.droppedCount} events before resume.`,
388
+ });
389
+ snapshotEventId = snapshotEventId ?? `evt-replay-${now().toString(36)}`;
390
+ }
391
+ return {
392
+ ok: true,
393
+ subscription: snapshotForRecord(record),
394
+ replayedCount: replayed,
395
+ snapshotEventId,
396
+ };
397
+ }
398
+
399
+ function unsubscribe(subscriptionId: string): boolean {
400
+ const record = subscriptions.get(subscriptionId);
401
+ if (record === undefined) return false;
402
+ const subscriber = subscribers.get(record.subscriberId);
403
+ subscriber?.subscriptions.delete(subscriptionId);
404
+ deindexSubject(record.subject, subscriptionId);
405
+ subscriptions.delete(subscriptionId);
406
+ return true;
407
+ }
408
+
409
+ /**
410
+ * W-027: single choke point for delivering a frame to a
411
+ * subscription. Sanitizes, dispatches, and advances `lastEventId` -
412
+ * BOTH the live path and the replay path go through here so a future
413
+ * delivery path cannot silently bypass the commentary sanitizer
414
+ * (structural regression guard). `sanitize` no-ops on non-`event`
415
+ * frames and never changes `eventId`, so the ordering invariant is
416
+ * untouched.
417
+ */
418
+ function dispatchSanitized(record: SubscriptionRecord, frame: ServerEventFrame): void {
419
+ const sanitized = sanitizer.sanitize(frame, 'ws');
420
+ validateAndDispatch(record, sanitized);
421
+ record.lastEventId = sanitized.eventId;
422
+ }
423
+
424
+ function emit(subject: string, bare: BareEventFrame): void {
425
+ const eventId = bare.eventId ?? nextEventId();
426
+ // Buffer first so the replay layer captures every event, even
427
+ // when there is no live subscriber. The buffered frame is
428
+ // sanitizer-neutral (both the live dispatch and the replay
429
+ // dispatch sanitize per delivery, so a buffered raw frame is
430
+ // sanitized exactly once on every path that reaches a wire).
431
+ const bufferedFrame: ServerEventFrame = {
432
+ v: '1',
433
+ kind: 'event',
434
+ eventId,
435
+ subscriptionId: '__pending__',
436
+ subject,
437
+ type: bare.type,
438
+ payload: bare.payload,
439
+ };
440
+ replayBuffer.push(subject, bufferedFrame);
441
+ const subjectSubscribers = subjectIndex.get(subject);
442
+ if (subjectSubscribers === undefined || subjectSubscribers.size === 0) return;
443
+ for (const subscriptionId of subjectSubscribers) {
444
+ const record = subscriptions.get(subscriptionId);
445
+ if (record === undefined) continue;
446
+ dispatchSanitized(record, { ...bufferedFrame, subscriptionId });
447
+ }
448
+ void isEventFrame; // keep import warm
449
+ }
450
+
451
+ function emitLifecycle(
452
+ subscriptionId: string,
453
+ status: ServerLifecycleFrame['status'],
454
+ reason?: string,
455
+ ): void {
456
+ const record = subscriptions.get(subscriptionId);
457
+ if (record === undefined) return;
458
+ const frame: ServerLifecycleFrame = {
459
+ v: '1',
460
+ kind: 'lifecycle',
461
+ subscriptionId,
462
+ status,
463
+ ...(reason !== undefined ? { reason } : {}),
464
+ };
465
+ validateAndDispatch(record, frame);
466
+ }
467
+
468
+ function listForSubscriber(subscriberId: string): ReadonlyArray<WsSubscriptionSnapshot> {
469
+ const subscriber = subscribers.get(subscriberId);
470
+ if (subscriber === undefined) return Object.freeze([]);
471
+ return Object.freeze(
472
+ [...subscriber.subscriptions]
473
+ .map((id) => subscriptions.get(id))
474
+ .filter((entry): entry is SubscriptionRecord => entry !== undefined)
475
+ .map(snapshotForRecord),
476
+ );
477
+ }
478
+
479
+ function snapshotSubscription(subscriptionId: string): WsSubscriptionSnapshot | undefined {
480
+ const record = subscriptions.get(subscriptionId);
481
+ if (record === undefined) return undefined;
482
+ return snapshotForRecord(record);
483
+ }
484
+
485
+ function size(): { readonly subscribers: number; readonly subscriptions: number } {
486
+ return Object.freeze({
487
+ subscribers: subscribers.size,
488
+ subscriptions: subscriptions.size,
489
+ });
490
+ }
491
+
492
+ function shutdown(): void {
493
+ for (const subscription of subscriptions.values()) {
494
+ const subscriber = subscribers.get(subscription.subscriberId);
495
+ try {
496
+ subscriber?.handle.send({
497
+ v: '1',
498
+ kind: 'lifecycle',
499
+ subscriptionId: subscription.subscriptionId,
500
+ status: 'aborted',
501
+ reason: 'server-shutdown',
502
+ });
503
+ } catch {
504
+ // swallow - we are tearing down.
505
+ }
506
+ }
507
+ subscriptions.clear();
508
+ subjectIndex.clear();
509
+ subscribers.clear();
510
+ }
511
+
512
+ return Object.freeze({
513
+ sanitizer,
514
+ replayBuffer,
515
+ registerSubscriber,
516
+ subscribe,
517
+ unsubscribe,
518
+ emit,
519
+ emitLifecycle,
520
+ listForSubscriber,
521
+ snapshotSubscription,
522
+ size,
523
+ shutdown,
524
+ });
525
+ }
526
+
527
+ function snapshotForRecord(record: SubscriptionRecord): WsSubscriptionSnapshot {
528
+ return Object.freeze({
529
+ subscriptionId: record.subscriptionId,
530
+ subject: record.subject,
531
+ subjectKind: record.parsed.kind,
532
+ subscriberId: record.subscriberId,
533
+ tokenId: record.tokenId,
534
+ createdAt: record.createdAt,
535
+ lastEventId: record.lastEventId,
536
+ });
537
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `@graphorin/server/ws` - WebSocket protocol implementation for
3
+ * the Graphorin standalone server. Combines the dispatcher (which
4
+ * fans events out to subscribers + applies the delivery-layer
5
+ * commentary sanitization), the in-memory ticket store (browser
6
+ * single-use ticket flow), the per-subject replay buffer, the strict
7
+ * subject grammar parser + scope check, and the `@hono/node-ws`
8
+ * upgrade handler.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+
13
+ export {
14
+ type BareEventFrame,
15
+ createWsDispatcher,
16
+ type SubscribeResult,
17
+ type WsDispatcher,
18
+ type WsDispatcherOptions,
19
+ type WsDispatcherWarning,
20
+ type WsSubscriberHandle,
21
+ type WsSubscriptionSnapshot,
22
+ } from './dispatcher.js';
23
+ export {
24
+ createReplayBuffer,
25
+ type ReplayBuffer,
26
+ type ReplayBufferOptions,
27
+ type ReplayBufferSlice,
28
+ type ReplayBufferStats,
29
+ scheduleReplayBufferPruning,
30
+ } from './replay-buffer.js';
31
+ export {
32
+ isSubjectAllowed,
33
+ type ParsedSubject,
34
+ type ParseSubjectResult,
35
+ requiredScopeFor,
36
+ tryParseSubject,
37
+ } from './subjects.js';
38
+ export {
39
+ createWsTicketStore,
40
+ type WsTicket,
41
+ type WsTicketConsumeResult,
42
+ type WsTicketStore,
43
+ type WsTicketStoreOptions,
44
+ } from './ticket.js';
45
+ export { createWsUpgradeEvents, type WsUpgradeOptions } from './upgrade.js';