@c9up/aurora 0.1.10 → 0.1.11
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/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/live.d.ts +55 -0
- package/dist/live.js +96 -0
- package/dist/liveBroadcast.d.ts +51 -0
- package/dist/liveBroadcast.js +44 -0
- package/dist/liveClient.d.ts +74 -0
- package/dist/liveClient.js +77 -0
- package/dist/liveRegistry.d.ts +43 -0
- package/dist/liveRegistry.js +77 -0
- package/dist/liveRouter.d.ts +40 -0
- package/dist/liveRouter.js +46 -0
- package/dist/liveServer.d.ts +37 -0
- package/dist/liveServer.js +43 -0
- package/dist/relay.js +38 -8
- package/package.json +1 -1
- package/src/index.ts +37 -0
- package/src/live.ts +132 -0
- package/src/liveBroadcast.ts +85 -0
- package/src/liveClient.ts +115 -0
- package/src/liveRegistry.ts +112 -0
- package/src/liveRouter.ts +70 -0
- package/src/liveServer.ts +75 -0
- package/src/relay.ts +39 -10
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live router (Stage 4) — the server-side orchestration that ties the session
|
|
3
|
+
* registry to the relay transport, transport-agnostically.
|
|
4
|
+
*
|
|
5
|
+
* - `mount(name, ownerId)` → mounts a session, wires its patches to a
|
|
6
|
+
* per-session channel, returns `{ id, channel, html }` for the HTTP
|
|
7
|
+
* response (the client renders `html` and subscribes to `channel`).
|
|
8
|
+
* - `event(id, event, payload)` → routes an inbound client event (a relay
|
|
9
|
+
* POST) to the session; its patches auto-broadcast on the channel.
|
|
10
|
+
* - `disconnect(ownerId)` → disposes every session that owner opened.
|
|
11
|
+
*
|
|
12
|
+
* Pure orchestration over the duck-typed registry + relay — node-free, no
|
|
13
|
+
* `@c9up/ream` / `@c9up/relay` import. The thin HTTP/relay wiring (register the
|
|
14
|
+
* POST route, hook relay's disconnect) is the provider/app's job and feeds
|
|
15
|
+
* these three methods.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { connectPatches, type RelayBroadcaster } from "./liveBroadcast.js";
|
|
19
|
+
import type { LiveRegistry } from "./liveRegistry.js";
|
|
20
|
+
|
|
21
|
+
/** What a client needs after mounting: render this `html`, subscribe to `channel`. */
|
|
22
|
+
export interface LiveMount {
|
|
23
|
+
id: string;
|
|
24
|
+
channel: string;
|
|
25
|
+
html: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LiveRouter {
|
|
29
|
+
/** Mount a session, wire its channel, return the initial render + ids. */
|
|
30
|
+
mount(name: string, ownerId: string): LiveMount;
|
|
31
|
+
/** Route a client event to its session. Returns false if the id is unknown. */
|
|
32
|
+
event(id: string, event: string, payload?: unknown): boolean;
|
|
33
|
+
/** Tear down every session an owner opened (call on relay disconnect). */
|
|
34
|
+
disconnect(ownerId: string): void;
|
|
35
|
+
/** The relay channel a session id broadcasts on. */
|
|
36
|
+
channelFor(id: string): string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create the live router over a session registry + a relay broadcaster. One per
|
|
41
|
+
* app. The per-session channel is `live/<id>`; gate subscription with
|
|
42
|
+
* `relay.authorize("live/*", …)` if the components carry sensitive state.
|
|
43
|
+
*/
|
|
44
|
+
export function createLiveRouter(
|
|
45
|
+
registry: LiveRegistry,
|
|
46
|
+
relay: RelayBroadcaster,
|
|
47
|
+
): LiveRouter {
|
|
48
|
+
const channelFor = (id: string): string => `live/${id}`;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
mount(name, ownerId) {
|
|
52
|
+
const { id, session } = registry.mount(name, ownerId);
|
|
53
|
+
const channel = channelFor(id);
|
|
54
|
+
// Patches flow to the channel; `disconnect` → registry disposes the
|
|
55
|
+
// session, which clears its patch listener (stops broadcasting).
|
|
56
|
+
connectPatches(session, relay, channel);
|
|
57
|
+
return { id, channel, html: session.renderToString() };
|
|
58
|
+
},
|
|
59
|
+
event(id, event, payload) {
|
|
60
|
+
const session = registry.get(id);
|
|
61
|
+
if (!session) return false;
|
|
62
|
+
session.dispatch(event, payload);
|
|
63
|
+
return true;
|
|
64
|
+
},
|
|
65
|
+
disconnect(ownerId) {
|
|
66
|
+
registry.disposeOwner(ownerId);
|
|
67
|
+
},
|
|
68
|
+
channelFor,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live server wiring (Stage 6) — register the inbound-event HTTP route that
|
|
3
|
+
* feeds the {@link LiveRouter}. The client POSTs `{ id, event, payload }` here;
|
|
4
|
+
* the route dispatches it to the session, whose patches broadcast over relay.
|
|
5
|
+
*
|
|
6
|
+
* Agnostic: the host HTTP router + context are DUCK-TYPED (a `post(path,
|
|
7
|
+
* handler)` router; a `ctx.request.body()` / `ctx.response` context) — no
|
|
8
|
+
* `@c9up/ream` import. Mirrors how warden/blackhole middleware read the ctx.
|
|
9
|
+
* Mount (render + ids) is done by the page handler via `liveRouter.mount`;
|
|
10
|
+
* disconnect is wired by the app: relay's disconnect → `liveRouter.disconnect`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { LiveRouter } from "./liveRouter.js";
|
|
14
|
+
|
|
15
|
+
/** The slice of the host HTTP router this needs. */
|
|
16
|
+
export interface LiveHttpRouter {
|
|
17
|
+
post(path: string, handler: (ctx: LiveHttpContext) => unknown): unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The slice of the host HTTP context this needs (Ream's HttpContext satisfies it). */
|
|
21
|
+
export interface LiveHttpContext {
|
|
22
|
+
request: { body(): unknown };
|
|
23
|
+
response: {
|
|
24
|
+
status(code: number): unknown;
|
|
25
|
+
json(data: unknown): void;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface WireLiveEventsOptions {
|
|
30
|
+
/** Route path for inbound events (must match the client transport). */
|
|
31
|
+
path?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface LiveEventBody {
|
|
35
|
+
id: string;
|
|
36
|
+
event: string;
|
|
37
|
+
payload?: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Structural guard for the POST body — no casts (`in`-narrowing + typeof). */
|
|
41
|
+
function isLiveEventBody(value: unknown): value is LiveEventBody {
|
|
42
|
+
if (typeof value !== "object" || value === null) return false;
|
|
43
|
+
if (!("id" in value) || !("event" in value)) return false;
|
|
44
|
+
return typeof value.id === "string" && typeof value.event === "string";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Default inbound-event route — keep the client transport's `path` in sync. */
|
|
48
|
+
export const DEFAULT_LIVE_EVENT_PATH = "/_live/event";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Register the inbound live-event route on the host router. Call once at boot
|
|
52
|
+
* (e.g. from a provider that resolved the router + relay from the container).
|
|
53
|
+
*/
|
|
54
|
+
export function wireLiveEvents(
|
|
55
|
+
router: LiveHttpRouter,
|
|
56
|
+
live: LiveRouter,
|
|
57
|
+
options: WireLiveEventsOptions = {},
|
|
58
|
+
): void {
|
|
59
|
+
const path = options.path ?? DEFAULT_LIVE_EVENT_PATH;
|
|
60
|
+
router.post(path, (ctx) => {
|
|
61
|
+
const body = ctx.request.body();
|
|
62
|
+
if (!isLiveEventBody(body)) {
|
|
63
|
+
ctx.response.status(400);
|
|
64
|
+
ctx.response.json({ error: "live event requires { id, event }" });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const handled = live.event(body.id, body.event, body.payload);
|
|
68
|
+
if (!handled) {
|
|
69
|
+
ctx.response.status(404);
|
|
70
|
+
ctx.response.json({ error: "unknown live session" });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
ctx.response.json({ ok: true });
|
|
74
|
+
});
|
|
75
|
+
}
|
package/src/relay.ts
CHANGED
|
@@ -27,12 +27,15 @@ interface RelayState {
|
|
|
27
27
|
sse: EventSource | null;
|
|
28
28
|
uid: string | null;
|
|
29
29
|
channels: Map<string, Set<(event: unknown) => void>>;
|
|
30
|
+
/** Channels we've already wired an SSE listener for on the current sse. */
|
|
31
|
+
attached: Set<string>;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
const STATE: RelayState = {
|
|
33
35
|
sse: null,
|
|
34
36
|
uid: null,
|
|
35
37
|
channels: new Map(),
|
|
38
|
+
attached: new Set(),
|
|
36
39
|
};
|
|
37
40
|
|
|
38
41
|
export interface RelayOptions {
|
|
@@ -85,6 +88,11 @@ const CLIENT: RelayClient = {
|
|
|
85
88
|
const adapted = handler as (event: unknown) => void;
|
|
86
89
|
handlers.add(adapted);
|
|
87
90
|
|
|
91
|
+
// Wire the SSE listener for this channel's NAMED events — the relay
|
|
92
|
+
// broadcasts `event: <channel>`, so a per-channel addEventListener (not
|
|
93
|
+
// the default `onmessage`) is what actually receives the payload.
|
|
94
|
+
if (STATE.sse) attachChannel(STATE.sse, channel);
|
|
95
|
+
|
|
88
96
|
// Subscribe over POST as soon as we have a uid. Before the first uid (or
|
|
89
97
|
// during an auto-reconnect) the channel already lives in STATE.channels
|
|
90
98
|
// and is (re-)subscribed by the `connected` handler — so the server,
|
|
@@ -110,15 +118,17 @@ const CLIENT: RelayClient = {
|
|
|
110
118
|
}
|
|
111
119
|
STATE.uid = null;
|
|
112
120
|
STATE.channels.clear();
|
|
121
|
+
STATE.attached.clear();
|
|
113
122
|
},
|
|
114
123
|
};
|
|
115
124
|
|
|
116
125
|
function open(): void {
|
|
117
126
|
const sse = new EventSource(CONFIG.sseUrl);
|
|
118
127
|
STATE.sse = sse;
|
|
128
|
+
STATE.attached = new Set();
|
|
119
129
|
|
|
120
130
|
sse.addEventListener("connected", (ev) => {
|
|
121
|
-
const data = safeJson<{ uid?: string }>((ev
|
|
131
|
+
const data = safeJson<{ uid?: string }>(messageData(ev));
|
|
122
132
|
if (data && typeof data.uid === "string") {
|
|
123
133
|
STATE.uid = data.uid;
|
|
124
134
|
// Re-apply EVERY active subscription on each (re)connect. The server
|
|
@@ -137,21 +147,40 @@ function open(): void {
|
|
|
137
147
|
}
|
|
138
148
|
});
|
|
139
149
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
150
|
+
// Re-attach channel listeners — a close()+reopen builds a fresh EventSource
|
|
151
|
+
// that has lost the listeners wired by earlier subscribe() calls.
|
|
152
|
+
for (const channel of STATE.channels.keys()) attachChannel(sse, channel);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Wire one SSE listener for a channel's named broadcast events. The relay sends
|
|
157
|
+
* `event: <channel>\ndata: <JSON payload>`, so each channel is its own named
|
|
158
|
+
* event — `onmessage` (default/unnamed only) never sees them. The handler
|
|
159
|
+
* receives the broadcast payload verbatim (the value passed to
|
|
160
|
+
* `relay.broadcast(channel, payload)`).
|
|
161
|
+
*/
|
|
162
|
+
function attachChannel(sse: EventSource, channel: string): void {
|
|
163
|
+
if (STATE.attached.has(channel)) return;
|
|
164
|
+
STATE.attached.add(channel);
|
|
165
|
+
sse.addEventListener(channel, (ev) => {
|
|
166
|
+
const payload = safeJson<unknown>(messageData(ev));
|
|
167
|
+
if (payload === null) return;
|
|
168
|
+
const handlers = STATE.channels.get(channel);
|
|
146
169
|
if (!handlers) return;
|
|
147
170
|
for (const handler of handlers) {
|
|
148
171
|
try {
|
|
149
|
-
handler(
|
|
172
|
+
handler(payload);
|
|
150
173
|
} catch (err) {
|
|
151
|
-
console.warn(`[aurora/relay] listener for ${
|
|
174
|
+
console.warn(`[aurora/relay] listener for ${channel} threw:`, err);
|
|
152
175
|
}
|
|
153
176
|
}
|
|
154
|
-
};
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Read an SSE event's string `data` without an unsafe DOM cast. */
|
|
181
|
+
function messageData(ev: Event): string | null {
|
|
182
|
+
if ("data" in ev && typeof ev.data === "string") return ev.data;
|
|
183
|
+
return null;
|
|
155
184
|
}
|
|
156
185
|
|
|
157
186
|
async function postSubscribe(channel: string): Promise<void> {
|