@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
package/dist/index.d.ts
CHANGED
|
@@ -13,4 +13,10 @@ export { batch, effect, isSignal, memo, onCleanup, type ReadSignal, type Signal,
|
|
|
13
13
|
export { type Disposer, render } from "./render.js";
|
|
14
14
|
export { type AuroraHttpContext, type AuroraResponse, type AuroraRouteConfig, auroraRoute, } from "./route.js";
|
|
15
15
|
export { renderToString } from "./ssr.js";
|
|
16
|
+
export { type LiveComponentDefinition, type LiveSession, mountLiveSession, type SlotPatch, } from "./live.js";
|
|
17
|
+
export { createLiveRegistry, type LiveRegistry, type LiveSessionHandle, } from "./liveRegistry.js";
|
|
18
|
+
export { connectPatches, type LiveStore, liveStore, type RelayBroadcaster, } from "./liveBroadcast.js";
|
|
19
|
+
export { createLiveRouter, type LiveMount, type LiveRouter, } from "./liveRouter.js";
|
|
20
|
+
export { buildLiveTransport, liveClient, type LiveClientOptions, type LiveClientTransport, type LiveHttpPoster, type RelaySubscribeClient, } from "./liveClient.js";
|
|
21
|
+
export { DEFAULT_LIVE_EVENT_PATH, type LiveHttpContext, type LiveHttpRouter, wireLiveEvents, type WireLiveEventsOptions, } from "./liveServer.js";
|
|
16
22
|
export type { TemplateResult } from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -9,3 +9,9 @@ export { batch, effect, isSignal, memo, onCleanup, signal, untrack, } from "./re
|
|
|
9
9
|
export { render } from "./render.js";
|
|
10
10
|
export { auroraRoute, } from "./route.js";
|
|
11
11
|
export { renderToString } from "./ssr.js";
|
|
12
|
+
export { mountLiveSession, } from "./live.js";
|
|
13
|
+
export { createLiveRegistry, } from "./liveRegistry.js";
|
|
14
|
+
export { connectPatches, liveStore, } from "./liveBroadcast.js";
|
|
15
|
+
export { createLiveRouter, } from "./liveRouter.js";
|
|
16
|
+
export { buildLiveTransport, liveClient, } from "./liveClient.js";
|
|
17
|
+
export { DEFAULT_LIVE_EVENT_PATH, wireLiveEvents, } from "./liveServer.js";
|
package/dist/live.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
import type { TemplateResult } from "./types.js";
|
|
17
|
+
/** A precise per-slot update: slot index (positional in the template) + value. */
|
|
18
|
+
export interface SlotPatch {
|
|
19
|
+
slot: number;
|
|
20
|
+
value: string;
|
|
21
|
+
}
|
|
22
|
+
/** A live component: its reactive view + named event handlers that mutate it. */
|
|
23
|
+
export interface LiveComponentDefinition {
|
|
24
|
+
/** The reactive view. Slots reading signals become live-patchable. */
|
|
25
|
+
view: TemplateResult;
|
|
26
|
+
/**
|
|
27
|
+
* Event handlers, by name. A client interaction (`@click="increment"`)
|
|
28
|
+
* dispatches one of these; it mutates the component's signals, which the
|
|
29
|
+
* patch tracker turns into a `{slot, value}` patch.
|
|
30
|
+
*/
|
|
31
|
+
handlers?: Record<string, (payload?: unknown) => void>;
|
|
32
|
+
}
|
|
33
|
+
/** A mounted live component instance — one per connected client session. */
|
|
34
|
+
export interface LiveSession {
|
|
35
|
+
/** Initial server-side render (the first full-HTML response). */
|
|
36
|
+
renderToString(): string;
|
|
37
|
+
/** Run a named handler (mutates signals); emits a patch for the batch. */
|
|
38
|
+
dispatch(event: string, payload?: unknown): void;
|
|
39
|
+
/** Collect + clear the patches accumulated since the last drain (pull model). */
|
|
40
|
+
drainPatches(): SlotPatch[];
|
|
41
|
+
/**
|
|
42
|
+
* Subscribe to patches as they are produced (push model — what the relay
|
|
43
|
+
* transport hooks into). Use `onPatch` OR `drainPatches`, not both.
|
|
44
|
+
*/
|
|
45
|
+
onPatch(listener: (patch: SlotPatch[]) => void): () => void;
|
|
46
|
+
/** Stop every effect — call on client disconnect to free the session. */
|
|
47
|
+
dispose(): void;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Mount a live session from a definition factory. Call once per connected
|
|
51
|
+
* client: the factory's signals become that session's private state. To SHARE
|
|
52
|
+
* state across sessions, close the factory over a signal created OUTSIDE it —
|
|
53
|
+
* every session then reads the same signal and patches on its change.
|
|
54
|
+
*/
|
|
55
|
+
export declare function mountLiveSession(factory: () => LiveComponentDefinition): LiveSession;
|
package/dist/live.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
import { effect, isSignal } from "./reactive.js";
|
|
17
|
+
import { renderToString } from "./ssr.js";
|
|
18
|
+
/**
|
|
19
|
+
* Mount a live session from a definition factory. Call once per connected
|
|
20
|
+
* client: the factory's signals become that session's private state. To SHARE
|
|
21
|
+
* state across sessions, close the factory over a signal created OUTSIDE it —
|
|
22
|
+
* every session then reads the same signal and patches on its change.
|
|
23
|
+
*/
|
|
24
|
+
export function mountLiveSession(factory) {
|
|
25
|
+
const { view, handlers = {} } = factory();
|
|
26
|
+
const listeners = new Set();
|
|
27
|
+
// slot → latest value. `pending` = current (un-flushed) batch; `buffer` =
|
|
28
|
+
// accumulated for pull consumers. Both keyed by slot so repeated writes in
|
|
29
|
+
// one batch collapse to the last value.
|
|
30
|
+
const pending = new Map();
|
|
31
|
+
const buffer = new Map();
|
|
32
|
+
let priming = true;
|
|
33
|
+
let flushScheduled = false;
|
|
34
|
+
const flush = () => {
|
|
35
|
+
flushScheduled = false;
|
|
36
|
+
if (pending.size === 0)
|
|
37
|
+
return;
|
|
38
|
+
const patch = [];
|
|
39
|
+
for (const [slot, value] of pending) {
|
|
40
|
+
patch.push({ slot, value });
|
|
41
|
+
buffer.set(slot, value);
|
|
42
|
+
}
|
|
43
|
+
pending.clear();
|
|
44
|
+
for (const listener of listeners)
|
|
45
|
+
listener(patch);
|
|
46
|
+
};
|
|
47
|
+
const scheduleFlush = () => {
|
|
48
|
+
if (flushScheduled)
|
|
49
|
+
return;
|
|
50
|
+
flushScheduled = true;
|
|
51
|
+
queueMicrotask(flush);
|
|
52
|
+
};
|
|
53
|
+
// One fine-grained effect per reactive slot. The priming run only
|
|
54
|
+
// subscribes; later runs (a signal changed) record the slot's new value.
|
|
55
|
+
const stops = view.values.map((value, slot) => {
|
|
56
|
+
if (!isSignal(value) && typeof value !== "function")
|
|
57
|
+
return () => { };
|
|
58
|
+
return effect(() => {
|
|
59
|
+
const next = String(value());
|
|
60
|
+
if (priming)
|
|
61
|
+
return;
|
|
62
|
+
pending.set(slot, next);
|
|
63
|
+
scheduleFlush();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
priming = false;
|
|
67
|
+
return {
|
|
68
|
+
renderToString: () => renderToString(view),
|
|
69
|
+
dispatch(event, payload) {
|
|
70
|
+
const handler = handlers[event];
|
|
71
|
+
if (!handler)
|
|
72
|
+
return;
|
|
73
|
+
handler(payload);
|
|
74
|
+
flush(); // synchronous — one patch per dispatch (batches the handler's writes)
|
|
75
|
+
},
|
|
76
|
+
drainPatches() {
|
|
77
|
+
flush();
|
|
78
|
+
const patch = [];
|
|
79
|
+
for (const [slot, value] of buffer)
|
|
80
|
+
patch.push({ slot, value });
|
|
81
|
+
buffer.clear();
|
|
82
|
+
return patch;
|
|
83
|
+
},
|
|
84
|
+
onPatch(listener) {
|
|
85
|
+
listeners.add(listener);
|
|
86
|
+
return () => listeners.delete(listener);
|
|
87
|
+
},
|
|
88
|
+
dispose() {
|
|
89
|
+
for (const stop of stops)
|
|
90
|
+
stop();
|
|
91
|
+
listeners.clear();
|
|
92
|
+
pending.clear();
|
|
93
|
+
buffer.clear();
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
import { type LiveComponentDefinition, type LiveSession } from "./live.js";
|
|
18
|
+
/**
|
|
19
|
+
* Minimal relay surface aurora needs. The real `@c9up/relay` `Relay` satisfies
|
|
20
|
+
* it (`broadcast(channel, data) → recipient count`); aurora never imports it.
|
|
21
|
+
*/
|
|
22
|
+
export interface RelayBroadcaster {
|
|
23
|
+
broadcast(channel: string, data: unknown): number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Pipe a session's patches onto a relay channel as they are produced. Returns
|
|
27
|
+
* an unsubscribe. Each patch becomes one `broadcast(channel, patch)`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function connectPatches(session: LiveSession, relay: RelayBroadcaster, channel: string): () => void;
|
|
30
|
+
/**
|
|
31
|
+
* A shared, broadcast-backed live store — the multiplayer primitive. One
|
|
32
|
+
* server-side instance; every client on `channel` renders its initial HTML and
|
|
33
|
+
* subscribes for patches. A `dispatch` mutates the shared signals ONCE; the
|
|
34
|
+
* resulting patch is broadcast to the whole channel.
|
|
35
|
+
*/
|
|
36
|
+
export interface LiveStore {
|
|
37
|
+
/** The relay channel this store broadcasts on. */
|
|
38
|
+
readonly channel: string;
|
|
39
|
+
/** Current shared-state HTML — served to each client that joins. */
|
|
40
|
+
renderToString(): string;
|
|
41
|
+
/** Run a handler that mutates the shared state → one broadcast patch. */
|
|
42
|
+
dispatch(event: string, payload?: unknown): void;
|
|
43
|
+
/** Stop broadcasting + free the underlying session. */
|
|
44
|
+
dispose(): void;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Create a shared live store. Signals declared inside `factory` are the SHARED
|
|
48
|
+
* state (one instance, not per-client). Pair with `relay.authorize(channel, …)`
|
|
49
|
+
* to gate who may subscribe.
|
|
50
|
+
*/
|
|
51
|
+
export declare function liveStore(factory: () => LiveComponentDefinition, relay: RelayBroadcaster, channel: string): LiveStore;
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
import { mountLiveSession, } from "./live.js";
|
|
18
|
+
/**
|
|
19
|
+
* Pipe a session's patches onto a relay channel as they are produced. Returns
|
|
20
|
+
* an unsubscribe. Each patch becomes one `broadcast(channel, patch)`.
|
|
21
|
+
*/
|
|
22
|
+
export function connectPatches(session, relay, channel) {
|
|
23
|
+
return session.onPatch((patch) => {
|
|
24
|
+
relay.broadcast(channel, patch);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Create a shared live store. Signals declared inside `factory` are the SHARED
|
|
29
|
+
* state (one instance, not per-client). Pair with `relay.authorize(channel, …)`
|
|
30
|
+
* to gate who may subscribe.
|
|
31
|
+
*/
|
|
32
|
+
export function liveStore(factory, relay, channel) {
|
|
33
|
+
const session = mountLiveSession(factory);
|
|
34
|
+
const off = connectPatches(session, relay, channel);
|
|
35
|
+
return {
|
|
36
|
+
channel,
|
|
37
|
+
renderToString: () => session.renderToString(),
|
|
38
|
+
dispatch: (event, payload) => session.dispatch(event, payload),
|
|
39
|
+
dispose: () => {
|
|
40
|
+
off();
|
|
41
|
+
session.dispose();
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
import type { SlotPatch } from "./live.js";
|
|
18
|
+
import type { TemplateResult } from "./types.js";
|
|
19
|
+
/** The transport the live client needs: a patch subscription + an event POST. */
|
|
20
|
+
export interface LiveClientTransport {
|
|
21
|
+
/** Subscribe to a channel's patches (relay SSE). Returns an unsubscribe. */
|
|
22
|
+
subscribe(channel: string, handler: (patch: SlotPatch[]) => void): () => void;
|
|
23
|
+
/** Send a client event to the server (HTTP POST up). */
|
|
24
|
+
post(id: string, event: string, payload?: unknown): void;
|
|
25
|
+
}
|
|
26
|
+
export interface LiveClientOptions {
|
|
27
|
+
/** The element holding the server-rendered HTML to adopt. */
|
|
28
|
+
container: Element;
|
|
29
|
+
/** The client view (same template as the server; its signals are mirrors). */
|
|
30
|
+
factory: () => TemplateResult;
|
|
31
|
+
/** Ids from the server's mount response. */
|
|
32
|
+
mount: {
|
|
33
|
+
id: string;
|
|
34
|
+
channel: string;
|
|
35
|
+
};
|
|
36
|
+
transport: LiveClientTransport;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Start the live client for one mounted component. Returns a disposer that
|
|
40
|
+
* unsubscribes, removes the event listener, and tears down the hydration.
|
|
41
|
+
*
|
|
42
|
+
* Patches set writable signal slots; derived slots recompute locally from the
|
|
43
|
+
* base signals they read (aurora re-evaluates them) — so the server's
|
|
44
|
+
* base-signal patch is enough. (Derived-only slots with no mirrored base are an
|
|
45
|
+
* étape-6 refinement.)
|
|
46
|
+
*
|
|
47
|
+
* Authoring rule (aurora hydration): a reactive text slot must be the SOLE
|
|
48
|
+
* content of its element — write `Count: <span>${count}</span>`, not
|
|
49
|
+
* `Count: ${count}`. SSR merges adjacent static+dynamic text into one node,
|
|
50
|
+
* which hydration cannot re-split; isolating the slot keeps adopt + patch exact.
|
|
51
|
+
*/
|
|
52
|
+
export declare function liveClient(opts: LiveClientOptions): () => void;
|
|
53
|
+
/** The relay client slice the transport needs (`@c9up/aurora/relay` satisfies it). */
|
|
54
|
+
export interface RelaySubscribeClient {
|
|
55
|
+
subscribe<E>(channel: string, handler: (event: E) => void): () => void;
|
|
56
|
+
}
|
|
57
|
+
/** The HTTP client slice the transport needs (aurora's `HttpClient` satisfies it). */
|
|
58
|
+
export interface LiveHttpPoster {
|
|
59
|
+
post(url: string, body: unknown): unknown;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Build a {@link LiveClientTransport} from a relay client (SSE down) + an HTTP
|
|
63
|
+
* client (events up). `path` must match the server's `wireLiveEvents` route
|
|
64
|
+
* (default `/_live/event`). Keeps `liveClient` itself transport-agnostic.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* import { relay } from '@c9up/aurora/relay'
|
|
68
|
+
* import { HttpClient, buildLiveTransport, liveClient } from '@c9up/aurora'
|
|
69
|
+
* const transport = buildLiveTransport(relay(), new HttpClient())
|
|
70
|
+
* liveClient({ container, factory, mount, transport })
|
|
71
|
+
*/
|
|
72
|
+
export declare function buildLiveTransport(relayClient: RelaySubscribeClient, http: LiveHttpPoster, options?: {
|
|
73
|
+
path?: string;
|
|
74
|
+
}): LiveClientTransport;
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
import { hydrate } from "./hydrate.js";
|
|
18
|
+
import { isSignal } from "./reactive.js";
|
|
19
|
+
/**
|
|
20
|
+
* Start the live client for one mounted component. Returns a disposer that
|
|
21
|
+
* unsubscribes, removes the event listener, and tears down the hydration.
|
|
22
|
+
*
|
|
23
|
+
* Patches set writable signal slots; derived slots recompute locally from the
|
|
24
|
+
* base signals they read (aurora re-evaluates them) — so the server's
|
|
25
|
+
* base-signal patch is enough. (Derived-only slots with no mirrored base are an
|
|
26
|
+
* étape-6 refinement.)
|
|
27
|
+
*
|
|
28
|
+
* Authoring rule (aurora hydration): a reactive text slot must be the SOLE
|
|
29
|
+
* content of its element — write `Count: <span>${count}</span>`, not
|
|
30
|
+
* `Count: ${count}`. SSR merges adjacent static+dynamic text into one node,
|
|
31
|
+
* which hydration cannot re-split; isolating the slot keeps adopt + patch exact.
|
|
32
|
+
*/
|
|
33
|
+
export function liveClient(opts) {
|
|
34
|
+
const view = opts.factory();
|
|
35
|
+
const disposeHydrate = hydrate(opts.container, () => view);
|
|
36
|
+
const off = opts.transport.subscribe(opts.mount.channel, (patch) => {
|
|
37
|
+
for (const { slot, value } of patch) {
|
|
38
|
+
const sig = view.values[slot];
|
|
39
|
+
if (isSignal(sig))
|
|
40
|
+
sig(value);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
const onClick = (event) => {
|
|
44
|
+
const target = event.target;
|
|
45
|
+
if (!(target instanceof Element))
|
|
46
|
+
return;
|
|
47
|
+
const el = target.closest("[data-live-click]");
|
|
48
|
+
if (el)
|
|
49
|
+
opts.transport.post(opts.mount.id, el.getAttribute("data-live-click") ?? "");
|
|
50
|
+
};
|
|
51
|
+
opts.container.addEventListener("click", onClick);
|
|
52
|
+
return () => {
|
|
53
|
+
off();
|
|
54
|
+
opts.container.removeEventListener("click", onClick);
|
|
55
|
+
disposeHydrate();
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Build a {@link LiveClientTransport} from a relay client (SSE down) + an HTTP
|
|
60
|
+
* client (events up). `path` must match the server's `wireLiveEvents` route
|
|
61
|
+
* (default `/_live/event`). Keeps `liveClient` itself transport-agnostic.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* import { relay } from '@c9up/aurora/relay'
|
|
65
|
+
* import { HttpClient, buildLiveTransport, liveClient } from '@c9up/aurora'
|
|
66
|
+
* const transport = buildLiveTransport(relay(), new HttpClient())
|
|
67
|
+
* liveClient({ container, factory, mount, transport })
|
|
68
|
+
*/
|
|
69
|
+
export function buildLiveTransport(relayClient, http, options = {}) {
|
|
70
|
+
const path = options.path ?? "/_live/event";
|
|
71
|
+
return {
|
|
72
|
+
subscribe: (channel, handler) => relayClient.subscribe(channel, handler),
|
|
73
|
+
post: (id, event, payload) => {
|
|
74
|
+
void http.post(path, { id, event, payload });
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
import { type LiveComponentDefinition, type LiveSession } from "./live.js";
|
|
14
|
+
/** A mounted instance: its id, the owner (e.g. relay uid), and the session. */
|
|
15
|
+
export interface LiveSessionHandle {
|
|
16
|
+
id: string;
|
|
17
|
+
ownerId: string;
|
|
18
|
+
session: LiveSession;
|
|
19
|
+
}
|
|
20
|
+
export interface LiveRegistry {
|
|
21
|
+
/** Register a live component definition under `name` (the "live class"). */
|
|
22
|
+
define(name: string, factory: () => LiveComponentDefinition): void;
|
|
23
|
+
/** True if `name` is registered. */
|
|
24
|
+
has(name: string): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Mount a fresh session of `name` owned by `ownerId`. Each call gets its own
|
|
27
|
+
* per-session signals. Throws if `name` is unknown — an unmountable component
|
|
28
|
+
* must fail loudly, never silently serve nothing.
|
|
29
|
+
*/
|
|
30
|
+
mount(name: string, ownerId: string): LiveSessionHandle;
|
|
31
|
+
/** Look up a live session by instance id. */
|
|
32
|
+
get(id: string): LiveSession | undefined;
|
|
33
|
+
/** Dispose one session instance (frees its effects). */
|
|
34
|
+
dispose(id: string): void;
|
|
35
|
+
/** Dispose EVERY session a given owner opened — call on disconnect. */
|
|
36
|
+
disposeOwner(ownerId: string): void;
|
|
37
|
+
/** Dispose all sessions (shutdown). */
|
|
38
|
+
disposeAll(): void;
|
|
39
|
+
/** Number of live sessions currently mounted (diagnostics / tests). */
|
|
40
|
+
size(): number;
|
|
41
|
+
}
|
|
42
|
+
/** Create an isolated live-session registry (one per app / per relay instance). */
|
|
43
|
+
export declare function createLiveRegistry(): LiveRegistry;
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
import { mountLiveSession, } from "./live.js";
|
|
14
|
+
/** Create an isolated live-session registry (one per app / per relay instance). */
|
|
15
|
+
export function createLiveRegistry() {
|
|
16
|
+
const defs = new Map();
|
|
17
|
+
const sessions = new Map();
|
|
18
|
+
const byOwner = new Map();
|
|
19
|
+
const dispose = (id) => {
|
|
20
|
+
const handle = sessions.get(id);
|
|
21
|
+
if (!handle)
|
|
22
|
+
return;
|
|
23
|
+
handle.session.dispose();
|
|
24
|
+
sessions.delete(id);
|
|
25
|
+
const owned = byOwner.get(handle.ownerId);
|
|
26
|
+
if (owned) {
|
|
27
|
+
owned.delete(id);
|
|
28
|
+
if (owned.size === 0)
|
|
29
|
+
byOwner.delete(handle.ownerId);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
define(name, factory) {
|
|
34
|
+
defs.set(name, factory);
|
|
35
|
+
},
|
|
36
|
+
has(name) {
|
|
37
|
+
return defs.has(name);
|
|
38
|
+
},
|
|
39
|
+
mount(name, ownerId) {
|
|
40
|
+
const factory = defs.get(name);
|
|
41
|
+
if (!factory) {
|
|
42
|
+
throw new Error(`[aurora:live] unknown live component "${name}" — register it with registry.define("${name}", …) before mounting.`);
|
|
43
|
+
}
|
|
44
|
+
const id = crypto.randomUUID();
|
|
45
|
+
const handle = {
|
|
46
|
+
id,
|
|
47
|
+
ownerId,
|
|
48
|
+
session: mountLiveSession(factory),
|
|
49
|
+
};
|
|
50
|
+
sessions.set(id, handle);
|
|
51
|
+
const owned = byOwner.get(ownerId) ?? new Set();
|
|
52
|
+
owned.add(id);
|
|
53
|
+
byOwner.set(ownerId, owned);
|
|
54
|
+
return handle;
|
|
55
|
+
},
|
|
56
|
+
get(id) {
|
|
57
|
+
return sessions.get(id)?.session;
|
|
58
|
+
},
|
|
59
|
+
dispose,
|
|
60
|
+
disposeOwner(ownerId) {
|
|
61
|
+
const owned = byOwner.get(ownerId);
|
|
62
|
+
if (!owned)
|
|
63
|
+
return;
|
|
64
|
+
// Copy ids first — `dispose` mutates the same set as it goes.
|
|
65
|
+
for (const id of [...owned])
|
|
66
|
+
dispose(id);
|
|
67
|
+
byOwner.delete(ownerId);
|
|
68
|
+
},
|
|
69
|
+
disposeAll() {
|
|
70
|
+
for (const id of [...sessions.keys()])
|
|
71
|
+
dispose(id);
|
|
72
|
+
},
|
|
73
|
+
size() {
|
|
74
|
+
return sessions.size;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
import { type RelayBroadcaster } from "./liveBroadcast.js";
|
|
18
|
+
import type { LiveRegistry } from "./liveRegistry.js";
|
|
19
|
+
/** What a client needs after mounting: render this `html`, subscribe to `channel`. */
|
|
20
|
+
export interface LiveMount {
|
|
21
|
+
id: string;
|
|
22
|
+
channel: string;
|
|
23
|
+
html: string;
|
|
24
|
+
}
|
|
25
|
+
export interface LiveRouter {
|
|
26
|
+
/** Mount a session, wire its channel, return the initial render + ids. */
|
|
27
|
+
mount(name: string, ownerId: string): LiveMount;
|
|
28
|
+
/** Route a client event to its session. Returns false if the id is unknown. */
|
|
29
|
+
event(id: string, event: string, payload?: unknown): boolean;
|
|
30
|
+
/** Tear down every session an owner opened (call on relay disconnect). */
|
|
31
|
+
disconnect(ownerId: string): void;
|
|
32
|
+
/** The relay channel a session id broadcasts on. */
|
|
33
|
+
channelFor(id: string): string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Create the live router over a session registry + a relay broadcaster. One per
|
|
37
|
+
* app. The per-session channel is `live/<id>`; gate subscription with
|
|
38
|
+
* `relay.authorize("live/*", …)` if the components carry sensitive state.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createLiveRouter(registry: LiveRegistry, relay: RelayBroadcaster): LiveRouter;
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
import { connectPatches } from "./liveBroadcast.js";
|
|
18
|
+
/**
|
|
19
|
+
* Create the live router over a session registry + a relay broadcaster. One per
|
|
20
|
+
* app. The per-session channel is `live/<id>`; gate subscription with
|
|
21
|
+
* `relay.authorize("live/*", …)` if the components carry sensitive state.
|
|
22
|
+
*/
|
|
23
|
+
export function createLiveRouter(registry, relay) {
|
|
24
|
+
const channelFor = (id) => `live/${id}`;
|
|
25
|
+
return {
|
|
26
|
+
mount(name, ownerId) {
|
|
27
|
+
const { id, session } = registry.mount(name, ownerId);
|
|
28
|
+
const channel = channelFor(id);
|
|
29
|
+
// Patches flow to the channel; `disconnect` → registry disposes the
|
|
30
|
+
// session, which clears its patch listener (stops broadcasting).
|
|
31
|
+
connectPatches(session, relay, channel);
|
|
32
|
+
return { id, channel, html: session.renderToString() };
|
|
33
|
+
},
|
|
34
|
+
event(id, event, payload) {
|
|
35
|
+
const session = registry.get(id);
|
|
36
|
+
if (!session)
|
|
37
|
+
return false;
|
|
38
|
+
session.dispatch(event, payload);
|
|
39
|
+
return true;
|
|
40
|
+
},
|
|
41
|
+
disconnect(ownerId) {
|
|
42
|
+
registry.disposeOwner(ownerId);
|
|
43
|
+
},
|
|
44
|
+
channelFor,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -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;
|