@c9up/aurora 0.1.9 → 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,115 @@
1
+ /**
2
+ * Live client runtime (Stage 5) — the thin browser side of live components.
3
+ *
4
+ * It HYDRATES the server-rendered HTML with mirror signals (aurora's real
5
+ * `hydrate`), then:
6
+ * - applies inbound patches by SETTING the mirror signal at the patched slot
7
+ * → aurora's fine-grained binding updates the exact DOM node (no bespoke
8
+ * DOM patcher; the isomorphic renderer IS the applier);
9
+ * - forwards interactions declared with `data-live-click="<event>"` to the
10
+ * server via the injected transport.
11
+ *
12
+ * Transport-agnostic: `subscribe` (relay SSE) and `post` (HTTP up) are injected,
13
+ * so aurora never imports `@c9up/relay` or an HTTP client here. In an app, wire
14
+ * `subscribe` to `@c9up/aurora/relay`'s `relay().subscribe` and `post` to an
15
+ * `HttpClient`. Browser-only (uses the DOM) — part of the client barrel.
16
+ */
17
+
18
+ import { hydrate } from "./hydrate.js";
19
+ import { isSignal } from "./reactive.js";
20
+ import type { SlotPatch } from "./live.js";
21
+ import type { TemplateResult } from "./types.js";
22
+
23
+ /** The transport the live client needs: a patch subscription + an event POST. */
24
+ export interface LiveClientTransport {
25
+ /** Subscribe to a channel's patches (relay SSE). Returns an unsubscribe. */
26
+ subscribe(channel: string, handler: (patch: SlotPatch[]) => void): () => void;
27
+ /** Send a client event to the server (HTTP POST up). */
28
+ post(id: string, event: string, payload?: unknown): void;
29
+ }
30
+
31
+ export interface LiveClientOptions {
32
+ /** The element holding the server-rendered HTML to adopt. */
33
+ container: Element;
34
+ /** The client view (same template as the server; its signals are mirrors). */
35
+ factory: () => TemplateResult;
36
+ /** Ids from the server's mount response. */
37
+ mount: { id: string; channel: string };
38
+ transport: LiveClientTransport;
39
+ }
40
+
41
+ /**
42
+ * Start the live client for one mounted component. Returns a disposer that
43
+ * unsubscribes, removes the event listener, and tears down the hydration.
44
+ *
45
+ * Patches set writable signal slots; derived slots recompute locally from the
46
+ * base signals they read (aurora re-evaluates them) — so the server's
47
+ * base-signal patch is enough. (Derived-only slots with no mirrored base are an
48
+ * étape-6 refinement.)
49
+ *
50
+ * Authoring rule (aurora hydration): a reactive text slot must be the SOLE
51
+ * content of its element — write `Count: <span>${count}</span>`, not
52
+ * `Count: ${count}`. SSR merges adjacent static+dynamic text into one node,
53
+ * which hydration cannot re-split; isolating the slot keeps adopt + patch exact.
54
+ */
55
+ export function liveClient(opts: LiveClientOptions): () => void {
56
+ const view = opts.factory();
57
+ const disposeHydrate = hydrate(opts.container, () => view);
58
+
59
+ const off = opts.transport.subscribe(opts.mount.channel, (patch) => {
60
+ for (const { slot, value } of patch) {
61
+ const sig = view.values[slot];
62
+ if (isSignal(sig)) (sig as (v: string) => void)(value);
63
+ }
64
+ });
65
+
66
+ const onClick = (event: Event): void => {
67
+ const target = event.target;
68
+ if (!(target instanceof Element)) return;
69
+ const el = target.closest("[data-live-click]");
70
+ if (el) opts.transport.post(opts.mount.id, el.getAttribute("data-live-click") ?? "");
71
+ };
72
+ opts.container.addEventListener("click", onClick);
73
+
74
+ return () => {
75
+ off();
76
+ opts.container.removeEventListener("click", onClick);
77
+ disposeHydrate();
78
+ };
79
+ }
80
+
81
+ /** The relay client slice the transport needs (`@c9up/aurora/relay` satisfies it). */
82
+ export interface RelaySubscribeClient {
83
+ subscribe<E>(channel: string, handler: (event: E) => void): () => void;
84
+ }
85
+
86
+ /** The HTTP client slice the transport needs (aurora's `HttpClient` satisfies it). */
87
+ export interface LiveHttpPoster {
88
+ post(url: string, body: unknown): unknown;
89
+ }
90
+
91
+ /**
92
+ * Build a {@link LiveClientTransport} from a relay client (SSE down) + an HTTP
93
+ * client (events up). `path` must match the server's `wireLiveEvents` route
94
+ * (default `/_live/event`). Keeps `liveClient` itself transport-agnostic.
95
+ *
96
+ * @example
97
+ * import { relay } from '@c9up/aurora/relay'
98
+ * import { HttpClient, buildLiveTransport, liveClient } from '@c9up/aurora'
99
+ * const transport = buildLiveTransport(relay(), new HttpClient())
100
+ * liveClient({ container, factory, mount, transport })
101
+ */
102
+ export function buildLiveTransport(
103
+ relayClient: RelaySubscribeClient,
104
+ http: LiveHttpPoster,
105
+ options: { path?: string } = {},
106
+ ): LiveClientTransport {
107
+ const path = options.path ?? "/_live/event";
108
+ return {
109
+ subscribe: (channel, handler) =>
110
+ relayClient.subscribe<SlotPatch[]>(channel, handler),
111
+ post: (id, event, payload) => {
112
+ void http.post(path, { id, event, payload });
113
+ },
114
+ };
115
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Live session registry (Stage 2) — the lifecycle layer for live components.
3
+ *
4
+ * Holds component DEFINITIONS by name (like the Pages registry), mounts a fresh
5
+ * {@link LiveSession} per connected client, tracks ownership so every session a
6
+ * client opened can be torn down at once on disconnect, and exposes lookup so
7
+ * an inbound event reaches the right session.
8
+ *
9
+ * The transport stage drives it: connect → `mount(name, uid)`; event →
10
+ * `get(id)?.dispatch(...)`; disconnect → `disposeOwner(uid)`. Transport-agnostic
11
+ * and node-free (only `mountLiveSession` + `crypto.randomUUID`, both isomorphic).
12
+ */
13
+
14
+ import {
15
+ type LiveComponentDefinition,
16
+ type LiveSession,
17
+ mountLiveSession,
18
+ } from "./live.js";
19
+
20
+ /** A mounted instance: its id, the owner (e.g. relay uid), and the session. */
21
+ export interface LiveSessionHandle {
22
+ id: string;
23
+ ownerId: string;
24
+ session: LiveSession;
25
+ }
26
+
27
+ export interface LiveRegistry {
28
+ /** Register a live component definition under `name` (the "live class"). */
29
+ define(name: string, factory: () => LiveComponentDefinition): void;
30
+ /** True if `name` is registered. */
31
+ has(name: string): boolean;
32
+ /**
33
+ * Mount a fresh session of `name` owned by `ownerId`. Each call gets its own
34
+ * per-session signals. Throws if `name` is unknown — an unmountable component
35
+ * must fail loudly, never silently serve nothing.
36
+ */
37
+ mount(name: string, ownerId: string): LiveSessionHandle;
38
+ /** Look up a live session by instance id. */
39
+ get(id: string): LiveSession | undefined;
40
+ /** Dispose one session instance (frees its effects). */
41
+ dispose(id: string): void;
42
+ /** Dispose EVERY session a given owner opened — call on disconnect. */
43
+ disposeOwner(ownerId: string): void;
44
+ /** Dispose all sessions (shutdown). */
45
+ disposeAll(): void;
46
+ /** Number of live sessions currently mounted (diagnostics / tests). */
47
+ size(): number;
48
+ }
49
+
50
+ /** Create an isolated live-session registry (one per app / per relay instance). */
51
+ export function createLiveRegistry(): LiveRegistry {
52
+ const defs = new Map<string, () => LiveComponentDefinition>();
53
+ const sessions = new Map<string, LiveSessionHandle>();
54
+ const byOwner = new Map<string, Set<string>>();
55
+
56
+ const dispose = (id: string): void => {
57
+ const handle = sessions.get(id);
58
+ if (!handle) return;
59
+ handle.session.dispose();
60
+ sessions.delete(id);
61
+ const owned = byOwner.get(handle.ownerId);
62
+ if (owned) {
63
+ owned.delete(id);
64
+ if (owned.size === 0) byOwner.delete(handle.ownerId);
65
+ }
66
+ };
67
+
68
+ return {
69
+ define(name, factory) {
70
+ defs.set(name, factory);
71
+ },
72
+ has(name) {
73
+ return defs.has(name);
74
+ },
75
+ mount(name, ownerId) {
76
+ const factory = defs.get(name);
77
+ if (!factory) {
78
+ throw new Error(
79
+ `[aurora:live] unknown live component "${name}" — register it with registry.define("${name}", …) before mounting.`,
80
+ );
81
+ }
82
+ const id = crypto.randomUUID();
83
+ const handle: LiveSessionHandle = {
84
+ id,
85
+ ownerId,
86
+ session: mountLiveSession(factory),
87
+ };
88
+ sessions.set(id, handle);
89
+ const owned = byOwner.get(ownerId) ?? new Set<string>();
90
+ owned.add(id);
91
+ byOwner.set(ownerId, owned);
92
+ return handle;
93
+ },
94
+ get(id) {
95
+ return sessions.get(id)?.session;
96
+ },
97
+ dispose,
98
+ disposeOwner(ownerId) {
99
+ const owned = byOwner.get(ownerId);
100
+ if (!owned) return;
101
+ // Copy ids first — `dispose` mutates the same set as it goes.
102
+ for (const id of [...owned]) dispose(id);
103
+ byOwner.delete(ownerId);
104
+ },
105
+ disposeAll() {
106
+ for (const id of [...sessions.keys()]) dispose(id);
107
+ },
108
+ size() {
109
+ return sessions.size;
110
+ },
111
+ };
112
+ }
@@ -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> {