@c9up/aurora 0.1.10 → 0.1.12

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,37 @@
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
+ import type { LiveRouter } from "./liveRouter.js";
13
+ /** The slice of the host HTTP router this needs. */
14
+ export interface LiveHttpRouter {
15
+ post(path: string, handler: (ctx: LiveHttpContext) => unknown): unknown;
16
+ }
17
+ /** The slice of the host HTTP context this needs (Ream's HttpContext satisfies it). */
18
+ export interface LiveHttpContext {
19
+ request: {
20
+ body(): unknown;
21
+ };
22
+ response: {
23
+ status(code: number): unknown;
24
+ json(data: unknown): void;
25
+ };
26
+ }
27
+ export interface WireLiveEventsOptions {
28
+ /** Route path for inbound events (must match the client transport). */
29
+ path?: string;
30
+ }
31
+ /** Default inbound-event route — keep the client transport's `path` in sync. */
32
+ export declare const DEFAULT_LIVE_EVENT_PATH = "/_live/event";
33
+ /**
34
+ * Register the inbound live-event route on the host router. Call once at boot
35
+ * (e.g. from a provider that resolved the router + relay from the container).
36
+ */
37
+ export declare function wireLiveEvents(router: LiveHttpRouter, live: LiveRouter, options?: WireLiveEventsOptions): void;
@@ -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/dist/rpc.d.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Browser JSON-RPC 2.0 client for Ream's RPC endpoint. `@c9up/ream`'s
3
+ * RpcProvider mounts `POST /rpc` and speaks JSON-RPC 2.0 (single + batch); this
4
+ * client builds on aurora's {@link HttpClient}, inheriting its base URL, auth
5
+ * headers, and timeouts.
6
+ *
7
+ * const rpc = createRpcClient() // POST /rpc, same-origin
8
+ * const result = await rpc.call('task.validate', { id }) // typed via call<T>()
9
+ * const user = await rpc.call('user.find', { id }, isUser) // validated, cast-free
10
+ *
11
+ * Pairs with aurora's `command()` for reactive calls:
12
+ * const validate = command((p) => rpc.call('task.validate', p))
13
+ */
14
+ import { HttpClient } from "./http.js";
15
+ export interface RpcClientOptions {
16
+ /** Endpoint path. Default `/rpc` (matches RpcProvider's default). */
17
+ url?: string;
18
+ /** Reuse an existing HttpClient — its baseURL / headers / auth carry over. */
19
+ http?: HttpClient;
20
+ /** Default headers — only used when no `http` client is supplied. */
21
+ headers?: Record<string, string>;
22
+ }
23
+ /** A JSON-RPC 2.0 error returned by the server (code + message + optional data). */
24
+ export declare class RpcError extends Error {
25
+ readonly code: number;
26
+ readonly data?: unknown;
27
+ constructor(code: number, message: string, data?: unknown);
28
+ }
29
+ /** Type guard for {@link RpcError}. */
30
+ export declare function isRpcError(value: unknown): value is RpcError;
31
+ /** One call in a batch. `parse` optionally validates that call's result (cast-free). */
32
+ export interface RpcCall<T = unknown> {
33
+ method: string;
34
+ params?: unknown;
35
+ parse?: (data: unknown) => T;
36
+ }
37
+ /** A settled batch entry — the result, or the JSON-RPC error for that call. */
38
+ export type RpcResult<T = unknown> = {
39
+ ok: true;
40
+ value: T;
41
+ } | {
42
+ ok: false;
43
+ error: RpcError;
44
+ };
45
+ export interface RpcClient {
46
+ /**
47
+ * Call one method. Returns the result, or throws {@link RpcError} on a
48
+ * JSON-RPC error. Pass `parse` to validate the result at runtime (and skip
49
+ * the unchecked `T` assertion).
50
+ */
51
+ call<T = unknown>(method: string, params?: unknown, parse?: (data: unknown) => T): Promise<T>;
52
+ /** Send a JSON-RPC batch. Returns one settled entry per call, in request order. */
53
+ batch(calls: RpcCall[]): Promise<RpcResult[]>;
54
+ }
55
+ export declare function createRpcClient(options?: RpcClientOptions): RpcClient;
package/dist/rpc.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Browser JSON-RPC 2.0 client for Ream's RPC endpoint. `@c9up/ream`'s
3
+ * RpcProvider mounts `POST /rpc` and speaks JSON-RPC 2.0 (single + batch); this
4
+ * client builds on aurora's {@link HttpClient}, inheriting its base URL, auth
5
+ * headers, and timeouts.
6
+ *
7
+ * const rpc = createRpcClient() // POST /rpc, same-origin
8
+ * const result = await rpc.call('task.validate', { id }) // typed via call<T>()
9
+ * const user = await rpc.call('user.find', { id }, isUser) // validated, cast-free
10
+ *
11
+ * Pairs with aurora's `command()` for reactive calls:
12
+ * const validate = command((p) => rpc.call('task.validate', p))
13
+ */
14
+ import { HttpClient } from "./http.js";
15
+ /** A JSON-RPC 2.0 error returned by the server (code + message + optional data). */
16
+ export class RpcError extends Error {
17
+ code;
18
+ data;
19
+ constructor(code, message, data) {
20
+ super(message);
21
+ this.name = "RpcError";
22
+ this.code = code;
23
+ this.data = data;
24
+ }
25
+ }
26
+ /** Type guard for {@link RpcError}. */
27
+ export function isRpcError(value) {
28
+ return value instanceof RpcError;
29
+ }
30
+ function isObject(value) {
31
+ return typeof value === "object" && value !== null;
32
+ }
33
+ /** Turn a JSON-RPC `error` member into an {@link RpcError}. */
34
+ function toRpcError(error) {
35
+ if (isObject(error) &&
36
+ typeof error.code === "number" &&
37
+ typeof error.message === "string") {
38
+ return new RpcError(error.code, error.message, error.data);
39
+ }
40
+ return new RpcError(-32603, "Malformed JSON-RPC error envelope", error);
41
+ }
42
+ export function createRpcClient(options = {}) {
43
+ const http = options.http ?? new HttpClient({ headers: options.headers });
44
+ const url = options.url ?? "/rpc";
45
+ let nextId = 0;
46
+ return {
47
+ async call(method, params, parse) {
48
+ const id = ++nextId;
49
+ const res = await http.post(url, {
50
+ jsonrpc: "2.0",
51
+ method,
52
+ params,
53
+ id,
54
+ });
55
+ if (!isObject(res)) {
56
+ throw new RpcError(-32603, `Malformed JSON-RPC response for "${method}"`);
57
+ }
58
+ if (res.error !== undefined)
59
+ throw toRpcError(res.error);
60
+ // Result boundary — the same unchecked `T` assertion HttpClient uses,
61
+ // with `parse` as the cast-free, runtime-validated escape hatch.
62
+ return parse ? parse(res.result) : res.result;
63
+ },
64
+ async batch(calls) {
65
+ if (calls.length === 0)
66
+ return [];
67
+ const requests = calls.map((c, index) => ({
68
+ jsonrpc: "2.0",
69
+ method: c.method,
70
+ params: c.params,
71
+ id: index, // index = request position; responses are matched back by id
72
+ }));
73
+ const res = await http.post(url, requests);
74
+ if (!Array.isArray(res)) {
75
+ throw new RpcError(-32603, "Malformed JSON-RPC batch response");
76
+ }
77
+ const byId = new Map();
78
+ for (const item of res)
79
+ if (isObject(item))
80
+ byId.set(item.id, item);
81
+ return calls.map((c, index) => {
82
+ const envelope = byId.get(index);
83
+ if (!envelope) {
84
+ return {
85
+ ok: false,
86
+ error: new RpcError(-32603, `No response for "${c.method}"`),
87
+ };
88
+ }
89
+ if (envelope.error !== undefined) {
90
+ return { ok: false, error: toRpcError(envelope.error) };
91
+ }
92
+ const value = c.parse ? c.parse(envelope.result) : envelope.result;
93
+ return { ok: true, value };
94
+ });
95
+ },
96
+ };
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/aurora",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
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
@@ -60,6 +60,43 @@ export {
60
60
  isHttpError,
61
61
  } from "./http.js";
62
62
  export { hydrate } from "./hydrate.js";
63
+ export {
64
+ type LiveComponentDefinition,
65
+ type LiveSession,
66
+ mountLiveSession,
67
+ type SlotPatch,
68
+ } from "./live.js";
69
+ export {
70
+ connectPatches,
71
+ type LiveStore,
72
+ liveStore,
73
+ type RelayBroadcaster,
74
+ } from "./liveBroadcast.js";
75
+ export {
76
+ buildLiveTransport,
77
+ type LiveClientOptions,
78
+ type LiveClientTransport,
79
+ type LiveHttpPoster,
80
+ liveClient,
81
+ type RelaySubscribeClient,
82
+ } from "./liveClient.js";
83
+ export {
84
+ createLiveRegistry,
85
+ type LiveRegistry,
86
+ type LiveSessionHandle,
87
+ } from "./liveRegistry.js";
88
+ export {
89
+ createLiveRouter,
90
+ type LiveMount,
91
+ type LiveRouter,
92
+ } from "./liveRouter.js";
93
+ export {
94
+ DEFAULT_LIVE_EVENT_PATH,
95
+ type LiveHttpContext,
96
+ type LiveHttpRouter,
97
+ type WireLiveEventsOptions,
98
+ wireLiveEvents,
99
+ } from "./liveServer.js";
63
100
  export {
64
101
  batch,
65
102
  effect,
@@ -78,5 +115,14 @@ export {
78
115
  type AuroraRouteConfig,
79
116
  auroraRoute,
80
117
  } from "./route.js";
118
+ export {
119
+ createRpcClient,
120
+ isRpcError,
121
+ type RpcCall,
122
+ type RpcClient,
123
+ type RpcClientOptions,
124
+ RpcError,
125
+ type RpcResult,
126
+ } from "./rpc.js";
81
127
  export { renderToString } from "./ssr.js";
82
128
  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
+ }