@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.
- package/dist/AuroraProvider.d.ts +1 -0
- package/dist/AuroraProvider.js +16 -43
- 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/AuroraProvider.ts +16 -48
- 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
|
@@ -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;
|
|
@@ -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
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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(
|
|
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(
|
|
139
|
+
handler(payload);
|
|
116
140
|
}
|
|
117
141
|
catch (err) {
|
|
118
|
-
console.warn(`[aurora/relay] listener for ${
|
|
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.
|
|
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/AuroraProvider.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { renderToString } from "./ssr.js";
|
|
|
31
31
|
interface AuroraContainer {
|
|
32
32
|
singleton(token: unknown, factory: () => unknown): void;
|
|
33
33
|
resolve<T = unknown>(token: unknown): T;
|
|
34
|
+
has(token: unknown): boolean;
|
|
34
35
|
}
|
|
35
36
|
interface AuroraConfigStore {
|
|
36
37
|
get<T = unknown>(key: string): T | undefined;
|
|
@@ -76,40 +77,22 @@ export default class AuroraProvider {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
async start(): Promise<void> {
|
|
79
|
-
// Asset routes are registered in `start()` — after preloads —
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
let routerMod: { default: ReamRouter };
|
|
93
|
-
try {
|
|
94
|
-
routerMod = await import(routerSpecifier);
|
|
95
|
-
} catch (err) {
|
|
96
|
-
if (isModuleNotFound(err)) return;
|
|
97
|
-
throw err;
|
|
98
|
-
}
|
|
80
|
+
// Asset routes are registered in `start()` — after preloads — so apps can
|
|
81
|
+
// swap aurora's pages root in a preload if they wanted to.
|
|
82
|
+
//
|
|
83
|
+
// Resolve the host router from the container, where Ream registers it as
|
|
84
|
+
// `'router'` (Ignitor). Reading it from the container — instead of
|
|
85
|
+
// importing `@c9up/ream/services/router` — keeps aurora runtime-agnostic:
|
|
86
|
+
// a non-Ream host simply never registers `'router'`, so aurora silently
|
|
87
|
+
// skips its asset routes. The container yields the real Router instance
|
|
88
|
+
// (registered before any provider's `start()`), so route-registration
|
|
89
|
+
// failures (slug collision, AuroraManager crash) propagate with a stack
|
|
90
|
+
// instead of being misread as "the asset routes just stopped mounting".
|
|
91
|
+
if (!this.app.container.has("router")) return;
|
|
92
|
+
const router = this.app.container.resolve<ReamRouter>("router");
|
|
99
93
|
const manager = this.app.container.resolve<AuroraManager>(AuroraManager);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
"/_assets/aurora/*",
|
|
103
|
-
adaptHandler(manager.auroraAssetsHandler()),
|
|
104
|
-
);
|
|
105
|
-
routerMod.default.get(
|
|
106
|
-
"/_assets/pages/*",
|
|
107
|
-
adaptHandler(manager.pageAssetsHandler()),
|
|
108
|
-
);
|
|
109
|
-
} catch (err) {
|
|
110
|
-
if (isRouterProxyUninit(err)) return;
|
|
111
|
-
throw err;
|
|
112
|
-
}
|
|
94
|
+
router.get("/_assets/aurora/*", adaptHandler(manager.auroraAssetsHandler()));
|
|
95
|
+
router.get("/_assets/pages/*", adaptHandler(manager.pageAssetsHandler()));
|
|
113
96
|
}
|
|
114
97
|
|
|
115
98
|
async ready(): Promise<void> {}
|
|
@@ -170,18 +153,3 @@ function adaptHandler(
|
|
|
170
153
|
}) => Promise<void> {
|
|
171
154
|
return (ctx) => handler(ctx);
|
|
172
155
|
}
|
|
173
|
-
|
|
174
|
-
/** Node's ERR_MODULE_NOT_FOUND surfaces on an Error subclass with `code`. */
|
|
175
|
-
function isModuleNotFound(err: unknown): boolean {
|
|
176
|
-
if (err === null || typeof err !== "object" || !("code" in err)) return false;
|
|
177
|
-
const { code } = err;
|
|
178
|
-
return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** Ream's router proxy throws this exact string before Ignitor wires it. */
|
|
182
|
-
function isRouterProxyUninit(err: unknown): boolean {
|
|
183
|
-
return (
|
|
184
|
-
err instanceof Error &&
|
|
185
|
-
err.message.includes("Router accessed before initialization")
|
|
186
|
-
);
|
|
187
|
-
}
|
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
|
+
}
|