@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,43 @@
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
+ /** Structural guard for the POST body — no casts (`in`-narrowing + typeof). */
13
+ function isLiveEventBody(value) {
14
+ if (typeof value !== "object" || value === null)
15
+ return false;
16
+ if (!("id" in value) || !("event" in value))
17
+ return false;
18
+ return typeof value.id === "string" && typeof value.event === "string";
19
+ }
20
+ /** Default inbound-event route — keep the client transport's `path` in sync. */
21
+ export const DEFAULT_LIVE_EVENT_PATH = "/_live/event";
22
+ /**
23
+ * Register the inbound live-event route on the host router. Call once at boot
24
+ * (e.g. from a provider that resolved the router + relay from the container).
25
+ */
26
+ export function wireLiveEvents(router, live, options = {}) {
27
+ const path = options.path ?? DEFAULT_LIVE_EVENT_PATH;
28
+ router.post(path, (ctx) => {
29
+ const body = ctx.request.body();
30
+ if (!isLiveEventBody(body)) {
31
+ ctx.response.status(400);
32
+ ctx.response.json({ error: "live event requires { id, event }" });
33
+ return;
34
+ }
35
+ const handled = live.event(body.id, body.event, body.payload);
36
+ if (!handled) {
37
+ ctx.response.status(404);
38
+ ctx.response.json({ error: "unknown live session" });
39
+ return;
40
+ }
41
+ ctx.response.json({ ok: true });
42
+ });
43
+ }
package/dist/relay.js CHANGED
@@ -21,6 +21,7 @@ const STATE = {
21
21
  sse: null,
22
22
  uid: null,
23
23
  channels: new Map(),
24
+ attached: new Set(),
24
25
  };
25
26
  let CONFIG = {
26
27
  sseUrl: "/__relay/events",
@@ -59,6 +60,11 @@ const CLIENT = {
59
60
  }
60
61
  const adapted = handler;
61
62
  handlers.add(adapted);
63
+ // Wire the SSE listener for this channel's NAMED events — the relay
64
+ // broadcasts `event: <channel>`, so a per-channel addEventListener (not
65
+ // the default `onmessage`) is what actually receives the payload.
66
+ if (STATE.sse)
67
+ attachChannel(STATE.sse, channel);
62
68
  // Subscribe over POST as soon as we have a uid. Before the first uid (or
63
69
  // during an auto-reconnect) the channel already lives in STATE.channels
64
70
  // and is (re-)subscribed by the `connected` handler — so the server,
@@ -82,13 +88,15 @@ const CLIENT = {
82
88
  }
83
89
  STATE.uid = null;
84
90
  STATE.channels.clear();
91
+ STATE.attached.clear();
85
92
  },
86
93
  };
87
94
  function open() {
88
95
  const sse = new EventSource(CONFIG.sseUrl);
89
96
  STATE.sse = sse;
97
+ STATE.attached = new Set();
90
98
  sse.addEventListener("connected", (ev) => {
91
- const data = safeJson(ev.data);
99
+ const data = safeJson(messageData(ev));
92
100
  if (data && typeof data.uid === "string") {
93
101
  STATE.uid = data.uid;
94
102
  // Re-apply EVERY active subscription on each (re)connect. The server
@@ -103,22 +111,44 @@ function open() {
103
111
  }
104
112
  }
105
113
  });
106
- sse.onmessage = (ev) => {
107
- const data = safeJson(ev.data);
108
- if (!data || typeof data.channel !== "string")
114
+ // Re-attach channel listeners — a close()+reopen builds a fresh EventSource
115
+ // that has lost the listeners wired by earlier subscribe() calls.
116
+ for (const channel of STATE.channels.keys())
117
+ attachChannel(sse, channel);
118
+ }
119
+ /**
120
+ * Wire one SSE listener for a channel's named broadcast events. The relay sends
121
+ * `event: <channel>\ndata: <JSON payload>`, so each channel is its own named
122
+ * event — `onmessage` (default/unnamed only) never sees them. The handler
123
+ * receives the broadcast payload verbatim (the value passed to
124
+ * `relay.broadcast(channel, payload)`).
125
+ */
126
+ function attachChannel(sse, channel) {
127
+ if (STATE.attached.has(channel))
128
+ return;
129
+ STATE.attached.add(channel);
130
+ sse.addEventListener(channel, (ev) => {
131
+ const payload = safeJson(messageData(ev));
132
+ if (payload === null)
109
133
  return;
110
- const handlers = STATE.channels.get(data.channel);
134
+ const handlers = STATE.channels.get(channel);
111
135
  if (!handlers)
112
136
  return;
113
137
  for (const handler of handlers) {
114
138
  try {
115
- handler(data);
139
+ handler(payload);
116
140
  }
117
141
  catch (err) {
118
- console.warn(`[aurora/relay] listener for ${data.channel} threw:`, err);
142
+ console.warn(`[aurora/relay] listener for ${channel} threw:`, err);
119
143
  }
120
144
  }
121
- };
145
+ });
146
+ }
147
+ /** Read an SSE event's string `data` without an unsafe DOM cast. */
148
+ function messageData(ev) {
149
+ if ("data" in ev && typeof ev.data === "string")
150
+ return ev.data;
151
+ return null;
122
152
  }
123
153
  async function postSubscribe(channel) {
124
154
  const headers = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/aurora",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Aurora — reactive UI runtime for the Ream framework. Tagged-template DOM, signal-based state, isomorphic SSR + hydration, zero build step.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -79,4 +79,41 @@ export {
79
79
  auroraRoute,
80
80
  } from "./route.js";
81
81
  export { renderToString } from "./ssr.js";
82
+ export {
83
+ type LiveComponentDefinition,
84
+ type LiveSession,
85
+ mountLiveSession,
86
+ type SlotPatch,
87
+ } from "./live.js";
88
+ export {
89
+ createLiveRegistry,
90
+ type LiveRegistry,
91
+ type LiveSessionHandle,
92
+ } from "./liveRegistry.js";
93
+ export {
94
+ connectPatches,
95
+ type LiveStore,
96
+ liveStore,
97
+ type RelayBroadcaster,
98
+ } from "./liveBroadcast.js";
99
+ export {
100
+ createLiveRouter,
101
+ type LiveMount,
102
+ type LiveRouter,
103
+ } from "./liveRouter.js";
104
+ export {
105
+ buildLiveTransport,
106
+ liveClient,
107
+ type LiveClientOptions,
108
+ type LiveClientTransport,
109
+ type LiveHttpPoster,
110
+ type RelaySubscribeClient,
111
+ } from "./liveClient.js";
112
+ export {
113
+ DEFAULT_LIVE_EVENT_PATH,
114
+ type LiveHttpContext,
115
+ type LiveHttpRouter,
116
+ wireLiveEvents,
117
+ type WireLiveEventsOptions,
118
+ } from "./liveServer.js";
82
119
  export type { TemplateResult } from "./types.js";
package/src/live.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Live components — server-resident reactive UI. The state lives on the server
3
+ * as ordinary aurora signals; this module turns a fine-grained signal change
4
+ * into a PRECISE per-slot patch (`{slot, value}`) instead of re-rendering the
5
+ * whole component. That precision — each signal already knows which template
6
+ * slot it feeds — is the angle that beats HTML-diffing live-view libraries.
7
+ *
8
+ * This is the transport-agnostic CORE (Stage 1): mount a session, render its
9
+ * initial HTML, dispatch events that mutate signals, and drain / subscribe to
10
+ * the patches produced. Wiring patches over `@c9up/relay` (SSE down, POST up)
11
+ * and the thin client applier live in later stages.
12
+ *
13
+ * Node-free / isomorphic: uses only `signal`/`effect`/`renderToString`, so it
14
+ * sits in the main barrel alongside the rest of aurora's runtime.
15
+ */
16
+
17
+ import { effect, isSignal } from "./reactive.js";
18
+ import { renderToString } from "./ssr.js";
19
+ import type { TemplateResult } from "./types.js";
20
+
21
+ /** A precise per-slot update: slot index (positional in the template) + value. */
22
+ export interface SlotPatch {
23
+ slot: number;
24
+ value: string;
25
+ }
26
+
27
+ /** A live component: its reactive view + named event handlers that mutate it. */
28
+ export interface LiveComponentDefinition {
29
+ /** The reactive view. Slots reading signals become live-patchable. */
30
+ view: TemplateResult;
31
+ /**
32
+ * Event handlers, by name. A client interaction (`@click="increment"`)
33
+ * dispatches one of these; it mutates the component's signals, which the
34
+ * patch tracker turns into a `{slot, value}` patch.
35
+ */
36
+ handlers?: Record<string, (payload?: unknown) => void>;
37
+ }
38
+
39
+ /** A mounted live component instance — one per connected client session. */
40
+ export interface LiveSession {
41
+ /** Initial server-side render (the first full-HTML response). */
42
+ renderToString(): string;
43
+ /** Run a named handler (mutates signals); emits a patch for the batch. */
44
+ dispatch(event: string, payload?: unknown): void;
45
+ /** Collect + clear the patches accumulated since the last drain (pull model). */
46
+ drainPatches(): SlotPatch[];
47
+ /**
48
+ * Subscribe to patches as they are produced (push model — what the relay
49
+ * transport hooks into). Use `onPatch` OR `drainPatches`, not both.
50
+ */
51
+ onPatch(listener: (patch: SlotPatch[]) => void): () => void;
52
+ /** Stop every effect — call on client disconnect to free the session. */
53
+ dispose(): void;
54
+ }
55
+
56
+ /**
57
+ * Mount a live session from a definition factory. Call once per connected
58
+ * client: the factory's signals become that session's private state. To SHARE
59
+ * state across sessions, close the factory over a signal created OUTSIDE it —
60
+ * every session then reads the same signal and patches on its change.
61
+ */
62
+ export function mountLiveSession(
63
+ factory: () => LiveComponentDefinition,
64
+ ): LiveSession {
65
+ const { view, handlers = {} } = factory();
66
+ const listeners = new Set<(patch: SlotPatch[]) => void>();
67
+ // slot → latest value. `pending` = current (un-flushed) batch; `buffer` =
68
+ // accumulated for pull consumers. Both keyed by slot so repeated writes in
69
+ // one batch collapse to the last value.
70
+ const pending = new Map<number, string>();
71
+ const buffer = new Map<number, string>();
72
+ let priming = true;
73
+ let flushScheduled = false;
74
+
75
+ const flush = (): void => {
76
+ flushScheduled = false;
77
+ if (pending.size === 0) return;
78
+ const patch: SlotPatch[] = [];
79
+ for (const [slot, value] of pending) {
80
+ patch.push({ slot, value });
81
+ buffer.set(slot, value);
82
+ }
83
+ pending.clear();
84
+ for (const listener of listeners) listener(patch);
85
+ };
86
+
87
+ const scheduleFlush = (): void => {
88
+ if (flushScheduled) return;
89
+ flushScheduled = true;
90
+ queueMicrotask(flush);
91
+ };
92
+
93
+ // One fine-grained effect per reactive slot. The priming run only
94
+ // subscribes; later runs (a signal changed) record the slot's new value.
95
+ const stops = view.values.map((value, slot) => {
96
+ if (!isSignal(value) && typeof value !== "function") return () => {};
97
+ return effect(() => {
98
+ const next = String((value as () => unknown)());
99
+ if (priming) return;
100
+ pending.set(slot, next);
101
+ scheduleFlush();
102
+ });
103
+ });
104
+ priming = false;
105
+
106
+ return {
107
+ renderToString: () => renderToString(view),
108
+ dispatch(event, payload) {
109
+ const handler = handlers[event];
110
+ if (!handler) return;
111
+ handler(payload);
112
+ flush(); // synchronous — one patch per dispatch (batches the handler's writes)
113
+ },
114
+ drainPatches() {
115
+ flush();
116
+ const patch: SlotPatch[] = [];
117
+ for (const [slot, value] of buffer) patch.push({ slot, value });
118
+ buffer.clear();
119
+ return patch;
120
+ },
121
+ onPatch(listener) {
122
+ listeners.add(listener);
123
+ return () => listeners.delete(listener);
124
+ },
125
+ dispose() {
126
+ for (const stop of stops) stop();
127
+ listeners.clear();
128
+ pending.clear();
129
+ buffer.clear();
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Live broadcast (Stage 3) — pipe live-component patches onto a relay channel,
3
+ * and the SHARED-store primitive that makes live components multiplayer.
4
+ *
5
+ * aurora stays framework-agnostic: it DUCK-TYPES a {@link RelayBroadcaster}
6
+ * (just `broadcast(channel, data)`) and never imports `@c9up/relay`. The app
7
+ * passes its relay instance. Channel authorization (who may subscribe) is the
8
+ * relay's job — configure it with `relay.authorize(channel, …)`; aurora only
9
+ * pushes patches.
10
+ *
11
+ * Per-session use: `connectPatches(session, relay, "live/<id>")`.
12
+ * Shared/multiplayer use: `liveStore(factory, relay, "room/<id>")` — ONE
13
+ * server-side instance whose state is shared by every client on the channel;
14
+ * one mutation → one patch computed once → relay fans it out to all subscribers
15
+ * (O(1) compute, O(N) network).
16
+ */
17
+
18
+ import {
19
+ type LiveComponentDefinition,
20
+ type LiveSession,
21
+ mountLiveSession,
22
+ type SlotPatch,
23
+ } from "./live.js";
24
+
25
+ /**
26
+ * Minimal relay surface aurora needs. The real `@c9up/relay` `Relay` satisfies
27
+ * it (`broadcast(channel, data) → recipient count`); aurora never imports it.
28
+ */
29
+ export interface RelayBroadcaster {
30
+ broadcast(channel: string, data: unknown): number;
31
+ }
32
+
33
+ /**
34
+ * Pipe a session's patches onto a relay channel as they are produced. Returns
35
+ * an unsubscribe. Each patch becomes one `broadcast(channel, patch)`.
36
+ */
37
+ export function connectPatches(
38
+ session: LiveSession,
39
+ relay: RelayBroadcaster,
40
+ channel: string,
41
+ ): () => void {
42
+ return session.onPatch((patch: SlotPatch[]) => {
43
+ relay.broadcast(channel, patch);
44
+ });
45
+ }
46
+
47
+ /**
48
+ * A shared, broadcast-backed live store — the multiplayer primitive. One
49
+ * server-side instance; every client on `channel` renders its initial HTML and
50
+ * subscribes for patches. A `dispatch` mutates the shared signals ONCE; the
51
+ * resulting patch is broadcast to the whole channel.
52
+ */
53
+ export interface LiveStore {
54
+ /** The relay channel this store broadcasts on. */
55
+ readonly channel: string;
56
+ /** Current shared-state HTML — served to each client that joins. */
57
+ renderToString(): string;
58
+ /** Run a handler that mutates the shared state → one broadcast patch. */
59
+ dispatch(event: string, payload?: unknown): void;
60
+ /** Stop broadcasting + free the underlying session. */
61
+ dispose(): void;
62
+ }
63
+
64
+ /**
65
+ * Create a shared live store. Signals declared inside `factory` are the SHARED
66
+ * state (one instance, not per-client). Pair with `relay.authorize(channel, …)`
67
+ * to gate who may subscribe.
68
+ */
69
+ export function liveStore(
70
+ factory: () => LiveComponentDefinition,
71
+ relay: RelayBroadcaster,
72
+ channel: string,
73
+ ): LiveStore {
74
+ const session = mountLiveSession(factory);
75
+ const off = connectPatches(session, relay, channel);
76
+ return {
77
+ channel,
78
+ renderToString: () => session.renderToString(),
79
+ dispatch: (event, payload) => session.dispatch(event, payload),
80
+ dispose: () => {
81
+ off();
82
+ session.dispose();
83
+ },
84
+ };
85
+ }
@@ -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
+ }