@spfn/core 0.3.0-beta.22 → 0.3.0-beta.24
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.
- package/dist/authz/index.js +46 -10
- package/dist/authz/index.js.map +1 -1
- package/dist/contract/index.d.ts +1 -1
- package/dist/contract/index.js +2 -2
- package/dist/contract/index.js.map +1 -1
- package/dist/errors/index.d.ts +18 -1
- package/dist/errors/index.js +46 -10
- package/dist/errors/index.js.map +1 -1
- package/dist/event/index.d.ts +3 -3
- package/dist/event/index.js +138 -0
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +2 -2
- package/dist/event/sse/client.js +20 -1
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +16 -4
- package/dist/event/sse/index.js +356 -109
- package/dist/event/sse/index.js.map +1 -1
- package/dist/event/ws/client.d.ts +2 -2
- package/dist/event/ws/index.d.ts +3 -3
- package/dist/middleware/index.js +46 -10
- package/dist/middleware/index.js.map +1 -1
- package/dist/{router-CysBmgv3.d.ts → router-ZDV1ISTo.d.ts} +118 -1
- package/dist/server/index.d.ts +3 -3
- package/dist/server/index.js +356 -109
- package/dist/server/index.js.map +1 -1
- package/dist/{types-B8zAxFsE.d.ts → types-C_g6gD-w.d.ts} +27 -2
- package/dist/{types-W4u_S0lF.d.ts → types-cwMX0cyJ.d.ts} +1 -1
- package/package.json +1 -1
- package/src/contract/README.md +5 -3
- package/src/errors/README.md +16 -0
- package/src/event/README.md +128 -8
|
@@ -40,6 +40,63 @@ interface EventContract {
|
|
|
40
40
|
/** Contract version the event was removed in. */
|
|
41
41
|
removedIn?: string;
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* What the stream request asked for, copied once at connect.
|
|
45
|
+
*
|
|
46
|
+
* A plain snapshot, never the live Hono context, so a frame filter can read it
|
|
47
|
+
* on every frame long after the request object is gone. Header names are
|
|
48
|
+
* lowercased. It may hold `cookie` and `authorization` values — never log it.
|
|
49
|
+
*/
|
|
50
|
+
interface SubscribeRequest {
|
|
51
|
+
/** Query parameters of the stream URL (`events`, `token` and any the app adds). */
|
|
52
|
+
query: Record<string, string>;
|
|
53
|
+
/** Request headers, names lowercased. */
|
|
54
|
+
headers: Record<string, string>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* What `.canSubscribe()` decides on, once per connection per requested event.
|
|
58
|
+
*
|
|
59
|
+
* `P` is whatever the SSE auth config's `resolvePrincipal` returns — core never
|
|
60
|
+
* knows what a principal is.
|
|
61
|
+
*/
|
|
62
|
+
interface SubscribeContext<P = unknown> {
|
|
63
|
+
/** The subject the one-time token was issued to. */
|
|
64
|
+
subject: string;
|
|
65
|
+
/** The value `resolvePrincipal` returned for this connection, `undefined` without one. */
|
|
66
|
+
principal: P | undefined;
|
|
67
|
+
/** The connect request snapshot. */
|
|
68
|
+
request: SubscribeRequest;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Decides at connect whether a subject may receive an event. `false` or a
|
|
72
|
+
* throw denies the event on that connection; the connection stays open.
|
|
73
|
+
*/
|
|
74
|
+
type SubscribePredicate<P = unknown> = (ctx: SubscribeContext<P>) => boolean | Promise<boolean>;
|
|
75
|
+
/**
|
|
76
|
+
* What a per-event `.filter()` sees on every frame.
|
|
77
|
+
*/
|
|
78
|
+
interface FrameContext {
|
|
79
|
+
/** The connection's subject, `undefined` when the stream runs without auth. */
|
|
80
|
+
subject: string | undefined;
|
|
81
|
+
/** The connect request snapshot. */
|
|
82
|
+
request: SubscribeRequest;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Decides per frame whether one connection receives one payload. Sync: it runs
|
|
86
|
+
* for every connection on every emit, so it matches the payload against what
|
|
87
|
+
* the connection asked for and never reads storage.
|
|
88
|
+
*/
|
|
89
|
+
type FramePredicate<TPayload> = (payload: TPayload, ctx: FrameContext) => boolean;
|
|
90
|
+
/**
|
|
91
|
+
* How an SSE handler lets `grant` / `revoke` reach its open connections.
|
|
92
|
+
* Each handler registers one per router key of the event.
|
|
93
|
+
*/
|
|
94
|
+
interface SubscriptionControl {
|
|
95
|
+
/** Activate the event on every open connection of `subject` where it is denied. */
|
|
96
|
+
grant: (subject: string) => void;
|
|
97
|
+
/** Deactivate the event on every open connection of `subject` where it is allowed. */
|
|
98
|
+
revoke: (subject: string) => void;
|
|
99
|
+
}
|
|
43
100
|
/**
|
|
44
101
|
* Event definition interface
|
|
45
102
|
*/
|
|
@@ -68,6 +125,43 @@ interface EventDef<TPayload = void> {
|
|
|
68
125
|
* declarations silently winning.
|
|
69
126
|
*/
|
|
70
127
|
contract: (contract: EventContract) => EventDef<TPayload>;
|
|
128
|
+
/**
|
|
129
|
+
* The predicate declared with `.canSubscribe()`, absent when any subject
|
|
130
|
+
* that passes the global `authorize` may subscribe.
|
|
131
|
+
*/
|
|
132
|
+
readonly _canSubscribe?: SubscribePredicate<any>;
|
|
133
|
+
/**
|
|
134
|
+
* Declare who may subscribe to this event over SSE.
|
|
135
|
+
*
|
|
136
|
+
* Runs once per connection, after the global `authorize`, only when the
|
|
137
|
+
* connection has a subject. `P` is the type of the principal the SSE auth
|
|
138
|
+
* config's `resolvePrincipal` returns. Set once: a second call throws.
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* ```typescript
|
|
142
|
+
* export const auditLogged = defineEvent('audit.logged', payload)
|
|
143
|
+
* .canSubscribe<AuthPrincipal>(({ principal }) => principal?.role === 'admin');
|
|
144
|
+
* ```
|
|
145
|
+
*/
|
|
146
|
+
canSubscribe: <P = unknown>(predicate: SubscribePredicate<P>) => EventDef<TPayload>;
|
|
147
|
+
/**
|
|
148
|
+
* The per-frame predicate declared with `.filter()`.
|
|
149
|
+
*/
|
|
150
|
+
readonly _filter?: FramePredicate<TPayload>;
|
|
151
|
+
/**
|
|
152
|
+
* Declare which frames of this event a connection receives.
|
|
153
|
+
*
|
|
154
|
+
* Runs per frame on every connection subscribed to the event, with or
|
|
155
|
+
* without a subject, beside the global `filter` map (both must pass). Sync.
|
|
156
|
+
* Set once: a second call throws.
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* ```typescript
|
|
160
|
+
* export const runtimeChanged = defineEvent('runtime.changed', payload)
|
|
161
|
+
* .filter((payload, { request }) => payload.workspaceId === request.query.workspaceId);
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
filter: (predicate: FramePredicate<TPayload>) => EventDef<TPayload>;
|
|
71
165
|
/**
|
|
72
166
|
* Subscribe to this event (in-memory handler)
|
|
73
167
|
*/
|
|
@@ -80,6 +174,23 @@ interface EventDef<TPayload = void> {
|
|
|
80
174
|
* Emit the event (triggers all subscribers and queued jobs)
|
|
81
175
|
*/
|
|
82
176
|
emit: TPayload extends void ? () => Promise<void> : (payload: TPayload) => Promise<void>;
|
|
177
|
+
/**
|
|
178
|
+
* Activate this event on every open SSE connection of `subject` where it is
|
|
179
|
+
* denied — by `canSubscribe` or `authorize` at connect, or by a `revoke`.
|
|
180
|
+
*
|
|
181
|
+
* Reaches every pod when the cache transport is attached. Never re-runs
|
|
182
|
+
* `canSubscribe`: it is an operator's statement about an open connection.
|
|
183
|
+
* An event the connection never requested cannot be granted.
|
|
184
|
+
*/
|
|
185
|
+
grant: (subject: string) => Promise<void>;
|
|
186
|
+
/**
|
|
187
|
+
* Deactivate this event on every open SSE connection of `subject` where it
|
|
188
|
+
* is allowed; the client receives a `subscription.revoked` frame.
|
|
189
|
+
*
|
|
190
|
+
* Reaches every pod when the cache transport is attached. Affects open
|
|
191
|
+
* connections only — the next connect asks `canSubscribe` afresh.
|
|
192
|
+
*/
|
|
193
|
+
revoke: (subject: string) => Promise<void>;
|
|
83
194
|
/**
|
|
84
195
|
* Enable cache-based pub/sub for multi-instance support
|
|
85
196
|
* Must await before emitting events to ensure subscription is ready
|
|
@@ -90,6 +201,12 @@ interface EventDef<TPayload = void> {
|
|
|
90
201
|
* Called by job registration system
|
|
91
202
|
*/
|
|
92
203
|
_registerJobQueue: (queueName: string, sender: JobQueueSender) => void;
|
|
204
|
+
/**
|
|
205
|
+
* Internal: Let an SSE handler's connection registry receive `grant` /
|
|
206
|
+
* `revoke`. Called by `createSSEHandler` once per router key; every
|
|
207
|
+
* registered control receives every call. Returns the unregister function.
|
|
208
|
+
*/
|
|
209
|
+
_registerSubscriptionControl: (control: SubscriptionControl) => () => void;
|
|
93
210
|
/**
|
|
94
211
|
* Internal: Drop the cache binding (cache + subscribed flag).
|
|
95
212
|
* Called by the event cache transport on shutdown so a later useCache can
|
|
@@ -229,4 +346,4 @@ type InferEventPayloads<T extends EventRouterBase<any>> = T['_types'];
|
|
|
229
346
|
*/
|
|
230
347
|
declare function defineEventRouter<TEvents extends Record<string, EventDef<any>>>(events: TEvents): EventRouterDef<TEvents>;
|
|
231
348
|
|
|
232
|
-
export { type EventRouterDef as E, type InferEventNames as I, type JobQueueSender as J, type PubSubCache as P, type EventDef as a, type EventContract as b, type EventHandler as c, type EventRouterContract as d, type InferEventPayload$1 as
|
|
349
|
+
export { type EventRouterDef as E, type FrameContext as F, type InferEventNames as I, type JobQueueSender as J, type PubSubCache as P, type SubscribeContext as S, type EventDef as a, type EventContract as b, type EventHandler as c, type EventRouterContract as d, type FramePredicate as e, type InferEventPayload$1 as f, type InferEventPayloads as g, type InferEventPayload as h, type SubscribePredicate as i, type SubscribeRequest as j, defineEventRouter as k, type EventRouterBase as l };
|
package/dist/server/index.d.ts
CHANGED
|
@@ -6,9 +6,9 @@ import { NamedMiddleware, Router } from '@spfn/core/route';
|
|
|
6
6
|
import { OnErrorContext, ProxyGuardConfig, RateLimitOptions } from '@spfn/core/middleware';
|
|
7
7
|
import { SafeFetchPolicy } from '@spfn/core/security';
|
|
8
8
|
import { c as JobRouter, f as BossOptions } from '../boss-BiDCbbAQ.js';
|
|
9
|
-
import { E as EventRouterDef, a as EventDef } from '../router-
|
|
10
|
-
import { d as SSEHandlerConfig, e as SSEAuthConfig } from '../types-
|
|
11
|
-
import { W as WSRouterDef, f as WSHandlerConfig, e as WSMessageHandlers, g as WSAuthConfig } from '../types-
|
|
9
|
+
import { E as EventRouterDef, a as EventDef } from '../router-ZDV1ISTo.js';
|
|
10
|
+
import { d as SSEHandlerConfig, e as SSEAuthConfig } from '../types-C_g6gD-w.js';
|
|
11
|
+
import { W as WSRouterDef, f as WSHandlerConfig, e as WSMessageHandlers, g as WSAuthConfig } from '../types-cwMX0cyJ.js';
|
|
12
12
|
import { DatabaseProvider, MigrationStatus, MigrationStatusDb } from '@spfn/core/db';
|
|
13
13
|
import * as _sinclair_typebox from '@sinclair/typebox';
|
|
14
14
|
import { Static } from '@sinclair/typebox';
|
package/dist/server/index.js
CHANGED
|
@@ -468,6 +468,114 @@ function createBoundedWriter(stream, maxQueue, onClose) {
|
|
|
468
468
|
}
|
|
469
469
|
};
|
|
470
470
|
}
|
|
471
|
+
var registryLogger = logger.child("@spfn/core:sse");
|
|
472
|
+
var SUBSCRIPTION_GRANTED = "subscription.granted";
|
|
473
|
+
var SUBSCRIPTION_REVOKED = "subscription.revoked";
|
|
474
|
+
function createConnectionRegistry() {
|
|
475
|
+
const bySubject = /* @__PURE__ */ new Map();
|
|
476
|
+
const pendingBySubject = /* @__PURE__ */ new Map();
|
|
477
|
+
const beginAdmission = (subject, signal) => {
|
|
478
|
+
const changes = /* @__PURE__ */ new Map();
|
|
479
|
+
addToSet(pendingBySubject, subject, changes);
|
|
480
|
+
const end = endOnce(signal, () => removeFromSet(pendingBySubject, subject, changes));
|
|
481
|
+
return {
|
|
482
|
+
cancel: end,
|
|
483
|
+
open: (connection) => {
|
|
484
|
+
if (!end()) {
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
applyRecorded(connection, changes);
|
|
488
|
+
addToSet(bySubject, subject, connection);
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
};
|
|
493
|
+
const change = (kind, subject, eventName) => {
|
|
494
|
+
const pending = pendingBySubject.get(subject);
|
|
495
|
+
const connections = bySubject.get(subject);
|
|
496
|
+
pending?.forEach((changes) => changes.set(eventName, kind));
|
|
497
|
+
connections?.forEach((connection) => (kind === "grant" ? grantOn : revokeOn)(connection, eventName));
|
|
498
|
+
if (!pending && !connections) {
|
|
499
|
+
registryLogger.debug("SSE subscription change for a subject with no open connection ignored", {
|
|
500
|
+
event: eventName,
|
|
501
|
+
subject
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
return {
|
|
506
|
+
beginAdmission,
|
|
507
|
+
remove: (connection) => {
|
|
508
|
+
if (connection.subject) {
|
|
509
|
+
removeFromSet(bySubject, connection.subject, connection);
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
/** Open connections only — a pending admission is not counted. */
|
|
513
|
+
count: (subject) => bySubject.get(subject)?.size ?? 0,
|
|
514
|
+
pendingCount: (subject) => pendingBySubject.get(subject)?.size ?? 0,
|
|
515
|
+
grant: (subject, eventName) => change("grant", subject, eventName),
|
|
516
|
+
revoke: (subject, eventName) => change("revoke", subject, eventName)
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
function endOnce(signal, remove) {
|
|
520
|
+
let ended = false;
|
|
521
|
+
const end = () => {
|
|
522
|
+
if (ended) {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
ended = true;
|
|
526
|
+
signal.removeEventListener("abort", end);
|
|
527
|
+
remove();
|
|
528
|
+
return true;
|
|
529
|
+
};
|
|
530
|
+
signal.addEventListener("abort", end, { once: true });
|
|
531
|
+
if (signal.aborted) {
|
|
532
|
+
end();
|
|
533
|
+
}
|
|
534
|
+
return end;
|
|
535
|
+
}
|
|
536
|
+
function addToSet(map, key, value) {
|
|
537
|
+
const set = map.get(key) ?? /* @__PURE__ */ new Set();
|
|
538
|
+
set.add(value);
|
|
539
|
+
map.set(key, set);
|
|
540
|
+
}
|
|
541
|
+
function removeFromSet(map, key, value) {
|
|
542
|
+
const set = map.get(key);
|
|
543
|
+
set?.delete(value);
|
|
544
|
+
if (set?.size === 0) {
|
|
545
|
+
map.delete(key);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
function applyRecorded(connection, changes) {
|
|
549
|
+
changes.forEach((kind, eventName) => {
|
|
550
|
+
const [from, to] = kind === "grant" ? [connection.denied, connection.allowed] : [connection.allowed, connection.denied];
|
|
551
|
+
if (from.delete(eventName)) {
|
|
552
|
+
to.add(eventName);
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
function grantOn(connection, eventName) {
|
|
557
|
+
if (!connection.denied.has(eventName)) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
connection.unsubscribes.set(eventName, connection.subscribe(eventName));
|
|
561
|
+
connection.denied.delete(eventName);
|
|
562
|
+
connection.allowed.add(eventName);
|
|
563
|
+
connection.notify(SUBSCRIPTION_GRANTED, eventName);
|
|
564
|
+
}
|
|
565
|
+
function revokeOn(connection, eventName) {
|
|
566
|
+
if (!connection.allowed.has(eventName)) {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
connection.unsubscribes.get(eventName)?.();
|
|
570
|
+
connection.unsubscribes.delete(eventName);
|
|
571
|
+
connection.allowed.delete(eventName);
|
|
572
|
+
connection.denied.add(eventName);
|
|
573
|
+
connection.notify(SUBSCRIPTION_REVOKED, eventName);
|
|
574
|
+
}
|
|
575
|
+
function unsubscribeAll(connection) {
|
|
576
|
+
connection.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
|
577
|
+
connection.unsubscribes.clear();
|
|
578
|
+
}
|
|
471
579
|
|
|
472
580
|
// src/event/sse/handler.ts
|
|
473
581
|
var sseLogger = logger.child("@spfn/core:sse");
|
|
@@ -498,125 +606,264 @@ function createSSEHandler(router, config = {}, tokenManager) {
|
|
|
498
606
|
auth: authConfig,
|
|
499
607
|
maxQueue = DEFAULT_MAX_QUEUE
|
|
500
608
|
} = config;
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
609
|
+
const registry = createConnectionRegistry();
|
|
610
|
+
registerSubscriptionControls(router, registry);
|
|
611
|
+
const streamConfig = { router, authConfig, registry, pingInterval, maxQueue };
|
|
612
|
+
const handler = async (c) => {
|
|
613
|
+
const admission = await admitConnection(c, streamConfig, tokenManager);
|
|
614
|
+
if (admission instanceof Response) {
|
|
615
|
+
return admission;
|
|
505
616
|
}
|
|
506
|
-
if (
|
|
507
|
-
|
|
617
|
+
if (c.req.raw.signal.aborted) {
|
|
618
|
+
admission.pending?.cancel();
|
|
619
|
+
return new Response(null, { status: 499 });
|
|
508
620
|
}
|
|
509
|
-
|
|
510
|
-
|
|
621
|
+
c.header("X-Accel-Buffering", "no");
|
|
622
|
+
return streamSSE(c, (stream) => runStream(c, stream, admission, streamConfig), async (err) => {
|
|
623
|
+
sseLogger.error("SSE stream error", {
|
|
624
|
+
error: err.message
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
};
|
|
628
|
+
return Object.assign(handler, {
|
|
629
|
+
/** @internal Open registered connections of a subject (tests). */
|
|
630
|
+
_openConnections: (subject) => registry.count(subject),
|
|
631
|
+
/** @internal Connects of a subject still in admission (tests). */
|
|
632
|
+
_pendingAdmissions: (subject) => registry.pendingCount(subject)
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
function registerSubscriptionControls(router, registry) {
|
|
636
|
+
for (const eventName of router.eventNames) {
|
|
637
|
+
router.events[eventName]._registerSubscriptionControl({
|
|
638
|
+
grant: (subject) => registry.grant(subject, eventName),
|
|
639
|
+
revoke: (subject) => registry.revoke(subject, eventName)
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
async function admitConnection(c, config, tokenManager) {
|
|
644
|
+
const { router, authConfig, registry } = config;
|
|
645
|
+
const subject = await authenticateToken(c, tokenManager);
|
|
646
|
+
if (subject === false) {
|
|
647
|
+
return c.json({ error: "Missing token parameter" }, 401);
|
|
648
|
+
}
|
|
649
|
+
if (subject === null) {
|
|
650
|
+
return c.json({ error: "Invalid or expired token" }, 401);
|
|
651
|
+
}
|
|
652
|
+
if (subject) {
|
|
653
|
+
c.set("sseSubject", subject);
|
|
654
|
+
}
|
|
655
|
+
const requested = parseRequestedEvents(c);
|
|
656
|
+
if (!requested) {
|
|
657
|
+
return c.json({ error: "Missing events parameter" }, 400);
|
|
658
|
+
}
|
|
659
|
+
const validEventNames = router.eventNames;
|
|
660
|
+
const invalidEvents = requested.filter((e) => !validEventNames.includes(e));
|
|
661
|
+
if (invalidEvents.length > 0) {
|
|
662
|
+
return c.json({
|
|
663
|
+
error: "Invalid event names",
|
|
664
|
+
invalidEvents,
|
|
665
|
+
validEvents: validEventNames
|
|
666
|
+
}, 400);
|
|
667
|
+
}
|
|
668
|
+
return whilePending(
|
|
669
|
+
subject ? registry.beginAdmission(subject, c.req.raw.signal) : void 0,
|
|
670
|
+
() => authorizeConnection(c, router, authConfig, subject, requested)
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
async function whilePending(pending, check) {
|
|
674
|
+
let outcome;
|
|
675
|
+
try {
|
|
676
|
+
outcome = await check();
|
|
677
|
+
return outcome instanceof Response ? outcome : { ...outcome, pending };
|
|
678
|
+
} finally {
|
|
679
|
+
if (!outcome || outcome instanceof Response) {
|
|
680
|
+
pending?.cancel();
|
|
511
681
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
async function authorizeConnection(c, router, authConfig, subject, requested) {
|
|
685
|
+
const authorized = await authorizeEvents(subject, requested, authConfig);
|
|
686
|
+
if (authorized === null) {
|
|
687
|
+
return c.json({ error: "Not authorized for any requested events" }, 403);
|
|
688
|
+
}
|
|
689
|
+
const request = { query: c.req.query(), headers: c.req.header() };
|
|
690
|
+
const resolved = await resolveConnectionPrincipal(subject, request, authConfig);
|
|
691
|
+
if (!resolved) {
|
|
692
|
+
return c.json({ error: "Principal could not be resolved" }, 403);
|
|
693
|
+
}
|
|
694
|
+
const allowed = subject ? await checkCanSubscribe(router, authorized, { subject, principal: resolved.principal, request }) : authorized;
|
|
695
|
+
const denied = requested.filter((e) => !allowed.includes(e));
|
|
696
|
+
sseLogger.debug("SSE connection requested", {
|
|
697
|
+
events: allowed,
|
|
698
|
+
deniedEvents: denied,
|
|
699
|
+
subject: subject || void 0,
|
|
700
|
+
clientIp: c.req.header("x-forwarded-for") || c.req.header("x-real-ip")
|
|
701
|
+
});
|
|
702
|
+
return { subject, requested, allowed, denied, request };
|
|
703
|
+
}
|
|
704
|
+
async function resolveConnectionPrincipal(subject, request, authConfig) {
|
|
705
|
+
if (!subject || !authConfig?.resolvePrincipal) {
|
|
706
|
+
return { principal: void 0 };
|
|
707
|
+
}
|
|
708
|
+
try {
|
|
709
|
+
return { principal: await authConfig.resolvePrincipal(subject, request) };
|
|
710
|
+
} catch {
|
|
711
|
+
sseLogger.warn("SSE principal could not be resolved \u2014 connection refused", { subject });
|
|
712
|
+
return null;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
async function checkCanSubscribe(router, events, ctx) {
|
|
716
|
+
const verdicts = await Promise.all(events.map((eventName) => askCanSubscribe(eventName, router.events[eventName], ctx)));
|
|
717
|
+
return events.filter((_, i) => verdicts[i]);
|
|
718
|
+
}
|
|
719
|
+
async function askCanSubscribe(eventName, eventDef, ctx) {
|
|
720
|
+
if (!eventDef) {
|
|
721
|
+
return false;
|
|
722
|
+
}
|
|
723
|
+
if (!eventDef._canSubscribe) {
|
|
724
|
+
return true;
|
|
725
|
+
}
|
|
726
|
+
try {
|
|
727
|
+
return await eventDef._canSubscribe(ctx) === true;
|
|
728
|
+
} catch {
|
|
729
|
+
sseLogger.warn("SSE canSubscribe threw \u2014 event denied on this connection", {
|
|
730
|
+
event: eventName,
|
|
731
|
+
subject: ctx.subject
|
|
732
|
+
});
|
|
733
|
+
return false;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
async function runStream(c, stream, admission, config) {
|
|
737
|
+
const { allowed, denied, subject, request } = admission;
|
|
738
|
+
let connectionDead = false;
|
|
739
|
+
let pingTimer;
|
|
740
|
+
let onClosed;
|
|
741
|
+
const cleanup = (reason) => {
|
|
742
|
+
if (connectionDead) return;
|
|
743
|
+
connectionDead = true;
|
|
744
|
+
clearInterval(pingTimer);
|
|
745
|
+
unsubscribeAll(connection);
|
|
746
|
+
config.registry.remove(connection);
|
|
747
|
+
writer.close();
|
|
748
|
+
onClosed?.();
|
|
749
|
+
sseLogger.info("SSE dead connection cleaned up", {
|
|
750
|
+
events: [...connection.allowed],
|
|
751
|
+
reason
|
|
752
|
+
});
|
|
753
|
+
};
|
|
754
|
+
const writer = createBoundedWriter(stream, config.maxQueue, (reason) => {
|
|
755
|
+
sseLogger.warn("SSE connection closed", { events: [...connection.allowed], reason });
|
|
756
|
+
cleanup(reason);
|
|
757
|
+
});
|
|
758
|
+
const connection = {
|
|
759
|
+
subject,
|
|
760
|
+
requested: admission.requested,
|
|
761
|
+
allowed: new Set(allowed),
|
|
762
|
+
denied: new Set(denied),
|
|
763
|
+
unsubscribes: /* @__PURE__ */ new Map(),
|
|
764
|
+
request,
|
|
765
|
+
subscribe: createEventSubscriber({
|
|
766
|
+
router: config.router,
|
|
767
|
+
authConfig: config.authConfig,
|
|
768
|
+
subject,
|
|
769
|
+
request,
|
|
770
|
+
writer,
|
|
771
|
+
isDead: () => connectionDead
|
|
772
|
+
}),
|
|
773
|
+
notify: (frame, eventName) => {
|
|
774
|
+
if (connectionDead) return;
|
|
775
|
+
writer.enqueue({ event: frame, data: JSON.stringify({ event: eventName }) });
|
|
515
776
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
return c.json({ error: "Not authorized for any requested events" }, 403);
|
|
528
|
-
}
|
|
529
|
-
sseLogger.debug("SSE connection requested", {
|
|
530
|
-
events: allowedEvents,
|
|
531
|
-
subject: subject || void 0,
|
|
532
|
-
clientIp: c.req.header("x-forwarded-for") || c.req.header("x-real-ip")
|
|
777
|
+
};
|
|
778
|
+
if (admission.pending?.open(connection) === false) {
|
|
779
|
+
cleanup("aborted during admission");
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
openSubscriptions(connection, writer);
|
|
783
|
+
pingTimer = setInterval(() => {
|
|
784
|
+
if (connectionDead) return;
|
|
785
|
+
writer.enqueue({
|
|
786
|
+
event: "ping",
|
|
787
|
+
data: JSON.stringify({ timestamp: Date.now() })
|
|
533
788
|
});
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
event: eventName,
|
|
580
|
-
messageId
|
|
581
|
-
});
|
|
582
|
-
writer.enqueue({
|
|
583
|
-
id: String(messageId),
|
|
584
|
-
event: eventName,
|
|
585
|
-
data: serializeFrame(eventName, payload)
|
|
586
|
-
});
|
|
587
|
-
});
|
|
588
|
-
unsubscribes.push(unsubscribe);
|
|
789
|
+
}, config.pingInterval);
|
|
790
|
+
await waitForDisconnect(c.req.raw.signal, () => connectionDead, (resolve2) => {
|
|
791
|
+
onClosed = resolve2;
|
|
792
|
+
});
|
|
793
|
+
cleanup();
|
|
794
|
+
}
|
|
795
|
+
function openSubscriptions(connection, writer) {
|
|
796
|
+
writer.enqueue({
|
|
797
|
+
event: "connected",
|
|
798
|
+
data: JSON.stringify({
|
|
799
|
+
subscribedEvents: [...connection.allowed],
|
|
800
|
+
deniedEvents: [...connection.denied],
|
|
801
|
+
timestamp: Date.now()
|
|
802
|
+
})
|
|
803
|
+
});
|
|
804
|
+
for (const eventName of connection.allowed) {
|
|
805
|
+
connection.unsubscribes.set(eventName, connection.subscribe(eventName));
|
|
806
|
+
}
|
|
807
|
+
sseLogger.info("SSE connection established", {
|
|
808
|
+
events: [...connection.allowed],
|
|
809
|
+
deniedEvents: [...connection.denied],
|
|
810
|
+
subscriptionCount: connection.unsubscribes.size
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
async function waitForDisconnect(abortSignal, isDead, onServerClose) {
|
|
814
|
+
if (abortSignal.aborted || isDead()) {
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
await new Promise((resolve2) => {
|
|
818
|
+
const onAbort = () => resolve2();
|
|
819
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
820
|
+
onServerClose(() => {
|
|
821
|
+
abortSignal.removeEventListener("abort", onAbort);
|
|
822
|
+
resolve2();
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
function createEventSubscriber(ctx) {
|
|
827
|
+
let messageId = 0;
|
|
828
|
+
return (eventName) => {
|
|
829
|
+
const eventDef = ctx.router.events[eventName];
|
|
830
|
+
let active = true;
|
|
831
|
+
const unsubscribe = eventDef.subscribe((payload) => {
|
|
832
|
+
if (!active || ctx.isDead() || !passesFrameFilters(eventName, eventDef, payload, ctx)) {
|
|
833
|
+
return;
|
|
589
834
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
835
|
+
messageId++;
|
|
836
|
+
sseLogger.debug("SSE sending event", {
|
|
837
|
+
event: eventName,
|
|
838
|
+
messageId
|
|
593
839
|
});
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
data: JSON.stringify({ timestamp: Date.now() })
|
|
599
|
-
});
|
|
600
|
-
}, pingInterval);
|
|
601
|
-
const abortSignal = c.req.raw.signal;
|
|
602
|
-
if (!abortSignal.aborted && !connectionDead) {
|
|
603
|
-
await new Promise((resolve2) => {
|
|
604
|
-
const onAbort = () => resolve2();
|
|
605
|
-
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
606
|
-
onClosed = () => {
|
|
607
|
-
abortSignal.removeEventListener("abort", onAbort);
|
|
608
|
-
resolve2();
|
|
609
|
-
};
|
|
610
|
-
});
|
|
611
|
-
}
|
|
612
|
-
cleanup();
|
|
613
|
-
}, async (err) => {
|
|
614
|
-
sseLogger.error("SSE stream error", {
|
|
615
|
-
error: err.message
|
|
840
|
+
ctx.writer.enqueue({
|
|
841
|
+
id: String(messageId),
|
|
842
|
+
event: eventName,
|
|
843
|
+
data: serializeFrame(eventName, payload)
|
|
616
844
|
});
|
|
617
845
|
});
|
|
846
|
+
return () => {
|
|
847
|
+
active = false;
|
|
848
|
+
unsubscribe();
|
|
849
|
+
};
|
|
618
850
|
};
|
|
619
851
|
}
|
|
852
|
+
function passesFrameFilters(eventName, eventDef, payload, ctx) {
|
|
853
|
+
const globalFilter = ctx.authConfig?.filter?.[eventName];
|
|
854
|
+
if (ctx.subject && globalFilter && !globalFilter(ctx.subject, payload)) {
|
|
855
|
+
return false;
|
|
856
|
+
}
|
|
857
|
+
if (!eventDef._filter) {
|
|
858
|
+
return true;
|
|
859
|
+
}
|
|
860
|
+
try {
|
|
861
|
+
return eventDef._filter(payload, { subject: ctx.subject, request: ctx.request }) === true;
|
|
862
|
+
} catch {
|
|
863
|
+
sseLogger.warn("SSE event filter threw \u2014 frame dropped", { event: eventName, subject: ctx.subject });
|
|
864
|
+
return false;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
620
867
|
async function authenticateToken(c, tokenManager) {
|
|
621
868
|
if (!tokenManager) {
|
|
622
869
|
return void 0;
|
|
@@ -632,7 +879,7 @@ function parseRequestedEvents(c) {
|
|
|
632
879
|
if (!eventsParam) {
|
|
633
880
|
return null;
|
|
634
881
|
}
|
|
635
|
-
return eventsParam.split(",").map((e) => e.trim());
|
|
882
|
+
return [...new Set(eventsParam.split(",").map((e) => e.trim()))];
|
|
636
883
|
}
|
|
637
884
|
async function authorizeEvents(subject, requestedEvents, authConfig) {
|
|
638
885
|
if (!subject || !authConfig?.authorize) {
|