@spfn/core 0.3.0-beta.3 → 0.3.0-beta.30

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 (104) hide show
  1. package/README.md +301 -10
  2. package/dist/app-config/index.d.ts +39 -1
  3. package/dist/app-config/index.js.map +1 -1
  4. package/dist/authz/index.js +47 -391
  5. package/dist/authz/index.js.map +1 -1
  6. package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
  7. package/dist/codegen/index.d.ts +157 -16
  8. package/dist/codegen/index.js +772 -166
  9. package/dist/codegen/index.js.map +1 -1
  10. package/dist/config/index.js +4 -4
  11. package/dist/config/index.js.map +1 -1
  12. package/dist/contract/index.d.ts +39 -16
  13. package/dist/contract/index.js +147 -23
  14. package/dist/contract/index.js.map +1 -1
  15. package/dist/db/index.d.ts +230 -35
  16. package/dist/db/index.js +316 -160
  17. package/dist/db/index.js.map +1 -1
  18. package/dist/env/index.d.ts +6 -442
  19. package/dist/env/index.js +13 -16
  20. package/dist/env/index.js.map +1 -1
  21. package/dist/env/loader.js +24 -1
  22. package/dist/env/loader.js.map +1 -1
  23. package/dist/error-CIZlYJlL.d.ts +157 -0
  24. package/dist/errors/index.d.ts +18 -1
  25. package/dist/errors/index.js +47 -391
  26. package/dist/errors/index.js.map +1 -1
  27. package/dist/event/index.d.ts +5 -5
  28. package/dist/event/index.js +202 -3
  29. package/dist/event/index.js.map +1 -1
  30. package/dist/event/sse/client.d.ts +18 -4
  31. package/dist/event/sse/client.js +94 -18
  32. package/dist/event/sse/client.js.map +1 -1
  33. package/dist/event/sse/index.d.ts +18 -5
  34. package/dist/event/sse/index.js +452 -114
  35. package/dist/event/sse/index.js.map +1 -1
  36. package/dist/event/ws/client.d.ts +3 -2
  37. package/dist/event/ws/index.d.ts +4 -3
  38. package/dist/job/index.d.ts +48 -3
  39. package/dist/job/index.js +131 -52
  40. package/dist/job/index.js.map +1 -1
  41. package/dist/logger/index.js +0 -12
  42. package/dist/logger/index.js.map +1 -1
  43. package/dist/middleware/index.js +52 -397
  44. package/dist/middleware/index.js.map +1 -1
  45. package/dist/nextjs/index.d.ts +75 -14
  46. package/dist/nextjs/index.js +200 -84
  47. package/dist/nextjs/index.js.map +1 -1
  48. package/dist/nextjs/server.d.ts +46 -8
  49. package/dist/nextjs/server.js +15 -1
  50. package/dist/nextjs/server.js.map +1 -1
  51. package/dist/ops/index.d.ts +61 -42
  52. package/dist/ops/index.js +648 -97
  53. package/dist/ops/index.js.map +1 -1
  54. package/dist/registry-C2KQCh0m.d.ts +473 -0
  55. package/dist/route/index.d.ts +5 -2
  56. package/dist/route/index.js +107 -28
  57. package/dist/route/index.js.map +1 -1
  58. package/dist/route/types.d.ts +7 -1
  59. package/dist/{router-Cy7rAfmj.d.ts → route-builder-DRW-MSCJ.d.ts} +51 -126
  60. package/dist/router-C57y-vz1.d.ts +184 -0
  61. package/dist/router-CX5uVjmU.d.ts +406 -0
  62. package/dist/server/index.d.ts +130 -7
  63. package/dist/server/index.js +1174 -164
  64. package/dist/server/index.js.map +1 -1
  65. package/dist/state/client.d.ts +206 -0
  66. package/dist/state/client.js +678 -0
  67. package/dist/state/client.js.map +1 -0
  68. package/dist/state/index.d.ts +443 -0
  69. package/dist/state/index.js +644 -0
  70. package/dist/state/index.js.map +1 -0
  71. package/dist/state/react.d.ts +49 -0
  72. package/dist/state/react.js +50 -0
  73. package/dist/state/react.js.map +1 -0
  74. package/dist/surface/index.d.ts +115 -0
  75. package/dist/surface/index.js +1013 -0
  76. package/dist/surface/index.js.map +1 -0
  77. package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
  78. package/dist/{types-CfYVhIQ9.d.ts → types-5Rl11_Nb.d.ts} +43 -4
  79. package/dist/types-CKH0D3Dy.d.ts +441 -0
  80. package/dist/{types-ZQODsBft.d.ts → types-CurMab2Q.d.ts} +52 -2
  81. package/dist/{types-2AbaW4Ie.d.ts → types-DncqXfWN.d.ts} +4 -3
  82. package/docs/file-upload.md +195 -333
  83. package/package.json +39 -9
  84. package/src/cache/README.md +330 -0
  85. package/src/codegen/README.md +721 -0
  86. package/src/config/README.md +333 -0
  87. package/src/contract/README.md +442 -0
  88. package/src/db/README.md +589 -0
  89. package/src/db/manager/README.md +500 -0
  90. package/src/db/schema/README.md +354 -0
  91. package/src/db/transaction/README.md +845 -0
  92. package/src/env/README.md +787 -0
  93. package/src/errors/README.md +445 -0
  94. package/src/event/README.md +994 -0
  95. package/src/job/README.md +613 -0
  96. package/src/logger/README.md +321 -0
  97. package/src/middleware/README.md +634 -0
  98. package/src/nextjs/README.md +756 -0
  99. package/src/route/README.md +763 -0
  100. package/src/security/README.md +100 -0
  101. package/src/server/README.md +704 -0
  102. package/src/state/README.md +806 -0
  103. package/dist/token-manager-BT5EnUAR.d.ts +0 -278
  104. package/dist/types-9oszaJqp.d.ts +0 -250
@@ -8,7 +8,15 @@ import { randomBytes, createHash } from 'crypto';
8
8
  function createBoundedWriter(stream, maxQueue, onClose) {
9
9
  const pending = [];
10
10
  let flushing = false;
11
+ let reserved = 0;
11
12
  let closed = false;
13
+ const overflows = () => {
14
+ if (pending.length + reserved <= maxQueue) {
15
+ return false;
16
+ }
17
+ onClose("outbound queue overflow (slow consumer)");
18
+ return true;
19
+ };
12
20
  const flush = async () => {
13
21
  if (flushing || closed) {
14
22
  return;
@@ -32,11 +40,19 @@ function createBoundedWriter(stream, maxQueue, onClose) {
32
40
  return;
33
41
  }
34
42
  pending.push(frame);
35
- if (pending.length > maxQueue) {
36
- onClose("outbound queue overflow (slow consumer)");
37
- return;
43
+ if (!overflows()) {
44
+ void flush();
45
+ }
46
+ },
47
+ reserve() {
48
+ if (closed) {
49
+ return false;
38
50
  }
39
- void flush();
51
+ reserved++;
52
+ return !overflows();
53
+ },
54
+ clearReserved() {
55
+ reserved = 0;
40
56
  },
41
57
  close() {
42
58
  closed = true;
@@ -47,6 +63,124 @@ function createBoundedWriter(stream, maxQueue, onClose) {
47
63
  }
48
64
  };
49
65
  }
66
+ var registryLogger = logger.child("@spfn/core:sse");
67
+ var SUBSCRIPTION_GRANTED = "subscription.granted";
68
+ var SUBSCRIPTION_REVOKED = "subscription.revoked";
69
+ function createConnectionRegistry() {
70
+ const bySubject = /* @__PURE__ */ new Map();
71
+ const pendingBySubject = /* @__PURE__ */ new Map();
72
+ const beginAdmission = (subject, signal) => {
73
+ const changes = [];
74
+ addToSet(pendingBySubject, subject, changes);
75
+ const end = endOnce(signal, () => removeFromSet(pendingBySubject, subject, changes));
76
+ return {
77
+ cancel: end,
78
+ open: (connection) => {
79
+ if (!end()) {
80
+ return false;
81
+ }
82
+ applyRecorded(connection, changes);
83
+ addToSet(bySubject, subject, connection);
84
+ return true;
85
+ }
86
+ };
87
+ };
88
+ const change = (kind, subject, eventName, match) => {
89
+ const pending = pendingBySubject.get(subject);
90
+ const connections = bySubject.get(subject);
91
+ pending?.forEach((changes) => changes.push({ kind, eventName, match }));
92
+ connections?.forEach((connection) => {
93
+ if (matchesRequest(match, connection.request)) {
94
+ (kind === "grant" ? grantOn : revokeOn)(connection, eventName);
95
+ }
96
+ });
97
+ if (!pending && !connections) {
98
+ registryLogger.debug("SSE subscription change for a subject with no open connection ignored", {
99
+ event: eventName,
100
+ subject
101
+ });
102
+ }
103
+ };
104
+ return {
105
+ beginAdmission,
106
+ remove: (connection) => {
107
+ if (connection.subject) {
108
+ removeFromSet(bySubject, connection.subject, connection);
109
+ }
110
+ },
111
+ /** Open connections only — a pending admission is not counted. */
112
+ count: (subject) => bySubject.get(subject)?.size ?? 0,
113
+ pendingCount: (subject) => pendingBySubject.get(subject)?.size ?? 0,
114
+ grant: (subject, eventName, match) => change("grant", subject, eventName, match),
115
+ revoke: (subject, eventName, match) => change("revoke", subject, eventName, match)
116
+ };
117
+ }
118
+ function endOnce(signal, remove) {
119
+ let ended = false;
120
+ const end = () => {
121
+ if (ended) {
122
+ return false;
123
+ }
124
+ ended = true;
125
+ signal.removeEventListener("abort", end);
126
+ remove();
127
+ return true;
128
+ };
129
+ signal.addEventListener("abort", end, { once: true });
130
+ if (signal.aborted) {
131
+ end();
132
+ }
133
+ return end;
134
+ }
135
+ function addToSet(map, key, value) {
136
+ const set = map.get(key) ?? /* @__PURE__ */ new Set();
137
+ set.add(value);
138
+ map.set(key, set);
139
+ }
140
+ function removeFromSet(map, key, value) {
141
+ const set = map.get(key);
142
+ set?.delete(value);
143
+ if (set?.size === 0) {
144
+ map.delete(key);
145
+ }
146
+ }
147
+ function matchesRequest(match, request) {
148
+ return match === void 0 || Object.entries(match.query).every(([field, value]) => Object.hasOwn(request.query, field) && request.query[field] === value);
149
+ }
150
+ function applyRecorded(connection, changes) {
151
+ changes.forEach(({ kind, eventName, match }) => {
152
+ if (!matchesRequest(match, connection.request)) {
153
+ return;
154
+ }
155
+ const [from, to] = kind === "grant" ? [connection.denied, connection.allowed] : [connection.allowed, connection.denied];
156
+ if (from.delete(eventName)) {
157
+ to.add(eventName);
158
+ }
159
+ });
160
+ }
161
+ function grantOn(connection, eventName) {
162
+ if (!connection.denied.has(eventName)) {
163
+ return;
164
+ }
165
+ connection.unsubscribes.set(eventName, connection.subscribe(eventName));
166
+ connection.denied.delete(eventName);
167
+ connection.allowed.add(eventName);
168
+ connection.notify(SUBSCRIPTION_GRANTED, eventName);
169
+ }
170
+ function revokeOn(connection, eventName) {
171
+ if (!connection.allowed.has(eventName)) {
172
+ return;
173
+ }
174
+ connection.unsubscribes.get(eventName)?.();
175
+ connection.unsubscribes.delete(eventName);
176
+ connection.allowed.delete(eventName);
177
+ connection.denied.add(eventName);
178
+ connection.notify(SUBSCRIPTION_REVOKED, eventName);
179
+ }
180
+ function unsubscribeAll(connection) {
181
+ connection.unsubscribes.forEach((unsubscribe) => unsubscribe());
182
+ connection.unsubscribes.clear();
183
+ }
50
184
 
51
185
  // src/event/sse/handler.ts
52
186
  var sseLogger = logger.child("@spfn/core:sse");
@@ -77,125 +211,329 @@ function createSSEHandler(router, config = {}, tokenManager) {
77
211
  auth: authConfig,
78
212
  maxQueue = DEFAULT_MAX_QUEUE
79
213
  } = config;
80
- return async (c) => {
81
- const subject = await authenticateToken(c, tokenManager);
82
- if (subject === false) {
83
- return c.json({ error: "Missing token parameter" }, 401);
214
+ const registry = createConnectionRegistry();
215
+ registerSubscriptionControls(router, registry);
216
+ const streamConfig = { router, authConfig, registry, pingInterval, maxQueue };
217
+ const handler = async (c) => {
218
+ const admission = await admitConnection(c, streamConfig, tokenManager);
219
+ if (admission instanceof Response) {
220
+ return admission;
84
221
  }
85
- if (subject === null) {
86
- return c.json({ error: "Invalid or expired token" }, 401);
222
+ if (c.req.raw.signal.aborted) {
223
+ admission.pending?.cancel();
224
+ return new Response(null, { status: 499 });
87
225
  }
88
- if (subject) {
89
- c.set("sseSubject", subject);
90
- }
91
- const requestedEvents = parseRequestedEvents(c);
92
- if (!requestedEvents) {
93
- return c.json({ error: "Missing events parameter" }, 400);
94
- }
95
- const validEventNames = router.eventNames;
96
- const invalidEvents = requestedEvents.filter((e) => !validEventNames.includes(e));
97
- if (invalidEvents.length > 0) {
98
- return c.json({
99
- error: "Invalid event names",
100
- invalidEvents,
101
- validEvents: validEventNames
102
- }, 400);
226
+ c.header("X-Accel-Buffering", "no");
227
+ return streamSSE(c, (stream) => runStream(c, stream, admission, streamConfig), async (err) => {
228
+ sseLogger.error("SSE stream error", {
229
+ error: err.message
230
+ });
231
+ });
232
+ };
233
+ return Object.assign(handler, {
234
+ /** @internal Open registered connections of a subject (tests). */
235
+ _openConnections: (subject) => registry.count(subject),
236
+ /** @internal Connects of a subject still in admission (tests). */
237
+ _pendingAdmissions: (subject) => registry.pendingCount(subject)
238
+ });
239
+ }
240
+ function registerSubscriptionControls(router, registry) {
241
+ for (const eventName of router.eventNames) {
242
+ router.events[eventName]._registerSubscriptionControl({
243
+ grant: (subject, match) => registry.grant(subject, eventName, match),
244
+ revoke: (subject, match) => registry.revoke(subject, eventName, match)
245
+ });
246
+ }
247
+ }
248
+ async function admitConnection(c, config, tokenManager) {
249
+ const { router, authConfig, registry } = config;
250
+ const subject = await authenticateToken(c, tokenManager);
251
+ if (subject === false) {
252
+ return c.json({ error: "Missing token parameter" }, 401);
253
+ }
254
+ if (subject === null) {
255
+ return c.json({ error: "Invalid or expired token" }, 401);
256
+ }
257
+ if (subject) {
258
+ c.set("sseSubject", subject);
259
+ }
260
+ const requested = parseRequestedEvents(c);
261
+ if (!requested) {
262
+ return c.json({ error: "Missing events parameter" }, 400);
263
+ }
264
+ const validEventNames = router.eventNames;
265
+ const invalidEvents = requested.filter((e) => !validEventNames.includes(e));
266
+ if (invalidEvents.length > 0) {
267
+ return c.json({
268
+ error: "Invalid event names",
269
+ invalidEvents,
270
+ validEvents: validEventNames
271
+ }, 400);
272
+ }
273
+ return whilePending(
274
+ subject ? registry.beginAdmission(subject, c.req.raw.signal) : void 0,
275
+ () => authorizeConnection(c, router, authConfig, subject, requested)
276
+ );
277
+ }
278
+ async function whilePending(pending, check) {
279
+ let outcome;
280
+ try {
281
+ outcome = await check();
282
+ return outcome instanceof Response ? outcome : { ...outcome, pending };
283
+ } finally {
284
+ if (!outcome || outcome instanceof Response) {
285
+ pending?.cancel();
103
286
  }
104
- const allowedEvents = await authorizeEvents(subject, requestedEvents, authConfig);
105
- if (allowedEvents === null) {
106
- return c.json({ error: "Not authorized for any requested events" }, 403);
287
+ }
288
+ }
289
+ async function authorizeConnection(c, router, authConfig, subject, requested) {
290
+ const authorized = await authorizeEvents(subject, requested, authConfig);
291
+ if (authorized === null) {
292
+ return c.json({ error: "Not authorized for any requested events" }, 403);
293
+ }
294
+ const request = { query: c.req.query(), headers: c.req.header() };
295
+ const resolved = await resolveConnectionPrincipal(subject, request, authConfig);
296
+ if (!resolved) {
297
+ return c.json({ error: "Principal could not be resolved" }, 403);
298
+ }
299
+ const allowed = subject ? await checkCanSubscribe(router, authorized, { subject, principal: resolved.principal, request }) : authorized;
300
+ const denied = requested.filter((e) => !allowed.includes(e));
301
+ sseLogger.debug("SSE connection requested", {
302
+ events: allowed,
303
+ deniedEvents: denied,
304
+ subject: subject || void 0,
305
+ clientIp: c.req.header("x-forwarded-for") || c.req.header("x-real-ip")
306
+ });
307
+ return { subject, requested, allowed, denied, request, principal: resolved.principal };
308
+ }
309
+ async function resolveConnectionPrincipal(subject, request, authConfig) {
310
+ if (!subject || !authConfig?.resolvePrincipal) {
311
+ return { principal: void 0 };
312
+ }
313
+ try {
314
+ return { principal: await authConfig.resolvePrincipal(subject, request) };
315
+ } catch {
316
+ sseLogger.warn("SSE principal could not be resolved \u2014 connection refused", { subject });
317
+ return null;
318
+ }
319
+ }
320
+ async function checkCanSubscribe(router, events, ctx) {
321
+ const verdicts = await Promise.all(events.map((eventName) => askCanSubscribe(eventName, router.events[eventName], ctx)));
322
+ return events.filter((_, i) => verdicts[i]);
323
+ }
324
+ async function askCanSubscribe(eventName, eventDef, ctx) {
325
+ if (!eventDef) {
326
+ return false;
327
+ }
328
+ if (!eventDef._canSubscribe) {
329
+ return true;
330
+ }
331
+ try {
332
+ return await eventDef._canSubscribe(ctx) === true;
333
+ } catch {
334
+ sseLogger.warn("SSE canSubscribe threw \u2014 event denied on this connection", {
335
+ event: eventName,
336
+ subject: ctx.subject
337
+ });
338
+ return false;
339
+ }
340
+ }
341
+ async function runStream(c, stream, admission, config) {
342
+ const { allowed, denied, subject, request } = admission;
343
+ let connectionDead = false;
344
+ let pingTimer;
345
+ let onClosed;
346
+ const cleanup = (reason) => {
347
+ if (connectionDead) return;
348
+ connectionDead = true;
349
+ clearInterval(pingTimer);
350
+ unsubscribeAll(connection);
351
+ config.registry.remove(connection);
352
+ writer.close();
353
+ onClosed?.();
354
+ sseLogger.info("SSE dead connection cleaned up", {
355
+ events: [...connection.allowed],
356
+ reason
357
+ });
358
+ };
359
+ const writer = createBoundedWriter(stream, config.maxQueue, (reason) => {
360
+ sseLogger.warn("SSE connection closed", { events: [...connection.allowed], reason });
361
+ cleanup(reason);
362
+ });
363
+ const hold = createFrameHold(writer);
364
+ const subscriberContext = {
365
+ router: config.router,
366
+ authConfig: config.authConfig,
367
+ subject,
368
+ request,
369
+ writeFrame: createFrameWriter(writer),
370
+ send: hold.send,
371
+ isDead: () => connectionDead
372
+ };
373
+ const connection = {
374
+ subject,
375
+ requested: admission.requested,
376
+ allowed: new Set(allowed),
377
+ denied: new Set(denied),
378
+ unsubscribes: /* @__PURE__ */ new Map(),
379
+ request,
380
+ subscribe: createEventSubscriber(subscriberContext),
381
+ notify: (frame, eventName) => {
382
+ if (connectionDead) return;
383
+ hold.send(() => writer.enqueue({ event: frame, data: JSON.stringify({ event: eventName }) }));
107
384
  }
108
- sseLogger.debug("SSE connection requested", {
109
- events: allowedEvents,
110
- subject: subject || void 0,
111
- clientIp: c.req.header("x-forwarded-for") || c.req.header("x-real-ip")
385
+ };
386
+ if (admission.pending?.open(connection) === false) {
387
+ cleanup("aborted during admission");
388
+ return;
389
+ }
390
+ void openSubscriptions(connection, writer, hold, {
391
+ ...subscriberContext,
392
+ principal: admission.principal
393
+ });
394
+ pingTimer = setInterval(() => {
395
+ if (connectionDead) return;
396
+ hold.send(() => writer.enqueue({
397
+ event: "ping",
398
+ data: JSON.stringify({ timestamp: Date.now() })
399
+ }));
400
+ }, config.pingInterval);
401
+ await waitForDisconnect(c.req.raw.signal, () => connectionDead, (resolve) => {
402
+ onClosed = resolve;
403
+ });
404
+ cleanup();
405
+ }
406
+ async function openSubscriptions(connection, writer, hold, ctx) {
407
+ const connected = { subscribedEvents: [...connection.allowed], deniedEvents: [...connection.denied] };
408
+ const producing = connected.subscribedEvents.filter((eventName) => ctx.router.events[eventName]._initial);
409
+ if (!ctx.subject || producing.length === 0) {
410
+ enqueueConnected(writer, { ...connected, initial: [] });
411
+ subscribeAllowed(connection);
412
+ return;
413
+ }
414
+ hold.hold();
415
+ subscribeAllowed(connection);
416
+ const served = await produceInitialFrames(producing, ctx.router, {
417
+ subject: ctx.subject,
418
+ principal: ctx.principal,
419
+ request: ctx.request
420
+ });
421
+ if (ctx.isDead()) {
422
+ return;
423
+ }
424
+ enqueueConnected(writer, { ...connected, initial: served.map(({ eventName }) => eventName) });
425
+ served.forEach(({ eventName, frames }) => frames.forEach((data) => ctx.writeFrame(eventName, data)));
426
+ hold.release();
427
+ }
428
+ function enqueueConnected(writer, connected) {
429
+ writer.enqueue({
430
+ event: "connected",
431
+ data: JSON.stringify({ ...connected, timestamp: Date.now() })
432
+ });
433
+ }
434
+ function subscribeAllowed(connection) {
435
+ for (const eventName of connection.allowed) {
436
+ connection.unsubscribes.set(eventName, connection.subscribe(eventName));
437
+ }
438
+ sseLogger.info("SSE connection established", {
439
+ events: [...connection.allowed],
440
+ deniedEvents: [...connection.denied],
441
+ subscriptionCount: connection.unsubscribes.size
442
+ });
443
+ }
444
+ async function produceInitialFrames(events, router, ctx) {
445
+ const results = await Promise.all(events.map((eventName) => askInitial(eventName, router.events[eventName], ctx)));
446
+ return results.filter((frames) => frames !== null);
447
+ }
448
+ async function askInitial(eventName, eventDef, ctx) {
449
+ try {
450
+ const payloads = await eventDef._initial(ctx);
451
+ return { eventName, frames: payloads.map((payload) => serializeFrame(eventName, payload)) };
452
+ } catch {
453
+ sseLogger.warn("SSE initial frames could not be produced \u2014 none sent for this event", {
454
+ event: eventName,
455
+ subject: ctx.subject
112
456
  });
113
- c.header("X-Accel-Buffering", "no");
114
- return streamSSE(c, async (stream) => {
115
- const unsubscribes = [];
116
- let messageId = 0;
117
- let connectionDead = false;
118
- let pingTimer;
119
- let writer;
120
- let onClosed;
121
- const cleanup = (reason) => {
122
- if (connectionDead) return;
123
- connectionDead = true;
124
- clearInterval(pingTimer);
125
- unsubscribes.forEach((fn) => fn());
126
- writer?.close();
127
- onClosed?.();
128
- sseLogger.info("SSE dead connection cleaned up", {
129
- events: allowedEvents,
130
- reason
131
- });
132
- };
133
- writer = createBoundedWriter(stream, maxQueue, (reason) => {
134
- sseLogger.warn("SSE connection closed", { events: allowedEvents, reason });
135
- cleanup(reason);
136
- });
137
- writer.enqueue({
138
- event: "connected",
139
- data: JSON.stringify({
140
- subscribedEvents: allowedEvents,
141
- timestamp: Date.now()
142
- })
143
- });
144
- for (const eventName of allowedEvents) {
145
- const eventDef = router.events[eventName];
146
- if (!eventDef) {
147
- continue;
148
- }
149
- const unsubscribe = eventDef.subscribe((payload) => {
150
- if (connectionDead) return;
151
- if (subject && authConfig?.filter?.[eventName]) {
152
- if (!authConfig.filter[eventName](subject, payload)) {
153
- return;
154
- }
155
- }
156
- messageId++;
157
- sseLogger.debug("SSE sending event", {
158
- event: eventName,
159
- messageId
160
- });
161
- writer.enqueue({
162
- id: String(messageId),
163
- event: eventName,
164
- data: serializeFrame(eventName, payload)
165
- });
166
- });
167
- unsubscribes.push(unsubscribe);
457
+ return null;
458
+ }
459
+ }
460
+ function createFrameHold(writer) {
461
+ let pending = null;
462
+ return {
463
+ send: (write) => {
464
+ if (!pending) {
465
+ write();
466
+ } else if (writer.reserve()) {
467
+ pending.push(write);
168
468
  }
169
- sseLogger.info("SSE connection established", {
170
- events: allowedEvents,
171
- subscriptionCount: unsubscribes.length
172
- });
173
- pingTimer = setInterval(() => {
174
- if (connectionDead) return;
175
- writer.enqueue({
176
- event: "ping",
177
- data: JSON.stringify({ timestamp: Date.now() })
178
- });
179
- }, pingInterval);
180
- const abortSignal = c.req.raw.signal;
181
- if (!abortSignal.aborted && !connectionDead) {
182
- await new Promise((resolve) => {
183
- const onAbort = () => resolve();
184
- abortSignal.addEventListener("abort", onAbort, { once: true });
185
- onClosed = () => {
186
- abortSignal.removeEventListener("abort", onAbort);
187
- resolve();
188
- };
189
- });
469
+ },
470
+ hold: () => {
471
+ pending = [];
472
+ },
473
+ release: () => {
474
+ const held = pending ?? [];
475
+ pending = null;
476
+ writer.clearReserved();
477
+ held.forEach((write) => write());
478
+ }
479
+ };
480
+ }
481
+ function createFrameWriter(writer) {
482
+ let messageId = 0;
483
+ return (eventName, data) => {
484
+ messageId++;
485
+ sseLogger.debug("SSE sending event", {
486
+ event: eventName,
487
+ messageId
488
+ });
489
+ writer.enqueue({ id: String(messageId), event: eventName, data });
490
+ };
491
+ }
492
+ async function waitForDisconnect(abortSignal, isDead, onServerClose) {
493
+ if (abortSignal.aborted || isDead()) {
494
+ return;
495
+ }
496
+ await new Promise((resolve) => {
497
+ const onAbort = () => resolve();
498
+ abortSignal.addEventListener("abort", onAbort, { once: true });
499
+ onServerClose(() => {
500
+ abortSignal.removeEventListener("abort", onAbort);
501
+ resolve();
502
+ });
503
+ });
504
+ }
505
+ function createEventSubscriber(ctx) {
506
+ return (eventName) => {
507
+ const eventDef = ctx.router.events[eventName];
508
+ let active = true;
509
+ const unsubscribe = eventDef.subscribe((payload) => {
510
+ if (!active || ctx.isDead() || !passesFrameFilters(eventName, eventDef, payload, ctx)) {
511
+ return;
190
512
  }
191
- cleanup();
192
- }, async (err) => {
193
- sseLogger.error("SSE stream error", {
194
- error: err.message
195
- });
513
+ const data = serializeFrame(eventName, payload);
514
+ ctx.send(() => ctx.writeFrame(eventName, data));
196
515
  });
516
+ return () => {
517
+ active = false;
518
+ unsubscribe();
519
+ };
197
520
  };
198
521
  }
522
+ function passesFrameFilters(eventName, eventDef, payload, ctx) {
523
+ const globalFilter = ctx.authConfig?.filter?.[eventName];
524
+ if (ctx.subject && globalFilter && !globalFilter(ctx.subject, payload)) {
525
+ return false;
526
+ }
527
+ if (!eventDef._filter) {
528
+ return true;
529
+ }
530
+ try {
531
+ return eventDef._filter(payload, { subject: ctx.subject, request: ctx.request }) === true;
532
+ } catch {
533
+ sseLogger.warn("SSE event filter threw \u2014 frame dropped", { event: eventName, subject: ctx.subject });
534
+ return false;
535
+ }
536
+ }
199
537
  async function authenticateToken(c, tokenManager) {
200
538
  if (!tokenManager) {
201
539
  return void 0;
@@ -211,7 +549,7 @@ function parseRequestedEvents(c) {
211
549
  if (!eventsParam) {
212
550
  return null;
213
551
  }
214
- return eventsParam.split(",").map((e) => e.trim());
552
+ return [...new Set(eventsParam.split(",").map((e) => e.trim()))];
215
553
  }
216
554
  async function authorizeEvents(subject, requestedEvents, authConfig) {
217
555
  if (!subject || !authConfig?.authorize) {