@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.
@@ -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 as MessageEvent).data);
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
- sse.onmessage = (ev) => {
141
- const data = safeJson<{ channel?: string; [key: string]: unknown }>(
142
- ev.data,
143
- );
144
- if (!data || typeof data.channel !== "string") return;
145
- const handlers = STATE.channels.get(data.channel);
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(data);
172
+ handler(payload);
150
173
  } catch (err) {
151
- console.warn(`[aurora/relay] listener for ${data.channel} threw:`, err);
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> {