@c9up/aurora 0.1.22 → 0.1.24
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/Pages.js +5 -1
- package/dist/browser.js +10 -3
- package/dist/live.d.ts +11 -51
- package/dist/live.js +11 -99
- package/dist/liveBroadcast.d.ts +1 -1
- package/dist/liveBroadcast.js +1 -1
- package/dist/liveClient.d.ts +1 -1
- package/dist/liveRegistry.d.ts +1 -1
- package/dist/liveRegistry.js +1 -1
- package/dist/liveSession.d.ts +56 -0
- package/dist/liveSession.js +97 -0
- package/dist/middleware.d.ts +1 -1
- package/dist/middleware.js +4 -4
- package/dist/relay.d.ts +26 -3
- package/dist/relay.js +108 -8
- package/dist/server/renderPage.d.ts +6 -0
- package/dist/server/serveAssets.js +4 -1
- package/package.json +5 -4
- package/src/Pages.ts +5 -1
- package/src/browser.ts +10 -3
- package/src/live.ts +16 -135
- package/src/liveBroadcast.ts +1 -1
- package/src/liveClient.ts +6 -2
- package/src/liveRegistry.ts +1 -1
- package/src/liveSession.ts +133 -0
- package/src/middleware.ts +7 -7
- package/src/relay.ts +159 -9
- package/src/server/renderPage.ts +6 -0
- package/src/server/serveAssets.ts +4 -1
package/dist/relay.js
CHANGED
|
@@ -22,22 +22,31 @@ const STATE = {
|
|
|
22
22
|
uid: null,
|
|
23
23
|
channels: new Map(),
|
|
24
24
|
attached: new Set(),
|
|
25
|
+
status: "connecting",
|
|
26
|
+
statusListeners: new Map(),
|
|
27
|
+
reconnectAttempts: 0,
|
|
25
28
|
};
|
|
26
29
|
let CONFIG = {
|
|
27
30
|
sseUrl: "/__relay/events",
|
|
28
31
|
subscribeUrl: "/__relay/subscribe",
|
|
32
|
+
unsubscribeUrl: "/__relay/unsubscribe",
|
|
29
33
|
bearer: "",
|
|
34
|
+
maxReconnectAttempts: 5,
|
|
30
35
|
};
|
|
31
36
|
/**
|
|
32
|
-
* Configure the relay endpoints + bearer. Call once
|
|
33
|
-
* need to override the defaults. Multiple calls overwrite
|
|
34
|
-
* wins.
|
|
37
|
+
* Configure the relay endpoints + bearer + reconnect policy. Call once
|
|
38
|
+
* at boot if you need to override the defaults. Multiple calls overwrite
|
|
39
|
+
* — last call wins.
|
|
35
40
|
*/
|
|
36
41
|
export function configureRelay(options) {
|
|
37
42
|
CONFIG = {
|
|
38
43
|
sseUrl: options.sseUrl ?? CONFIG.sseUrl,
|
|
39
44
|
subscribeUrl: options.subscribeUrl ?? CONFIG.subscribeUrl,
|
|
45
|
+
unsubscribeUrl: options.unsubscribeUrl ?? CONFIG.unsubscribeUrl,
|
|
40
46
|
bearer: options.bearer ?? CONFIG.bearer,
|
|
47
|
+
maxReconnectAttempts: options.maxReconnectAttempts ?? CONFIG.maxReconnectAttempts,
|
|
48
|
+
onReconnectAttempt: options.onReconnectAttempt ?? CONFIG.onReconnectAttempt,
|
|
49
|
+
onReconnectFailed: options.onReconnectFailed ?? CONFIG.onReconnectFailed,
|
|
41
50
|
};
|
|
42
51
|
}
|
|
43
52
|
/**
|
|
@@ -74,11 +83,32 @@ const CLIENT = {
|
|
|
74
83
|
console.warn(`[aurora/relay] subscribe to ${channel} failed:`, err);
|
|
75
84
|
});
|
|
76
85
|
}
|
|
77
|
-
// Detacher —
|
|
78
|
-
//
|
|
79
|
-
//
|
|
86
|
+
// Detacher — removes the local listener. When it was the LAST handler
|
|
87
|
+
// on the channel, the server-side subscription is dropped too (POST
|
|
88
|
+
// /__relay/unsubscribe), so the server stops streaming a channel
|
|
89
|
+
// nobody's listening to. Other channels / other listeners are
|
|
90
|
+
// untouched.
|
|
80
91
|
return () => {
|
|
81
92
|
handlers?.delete(adapted);
|
|
93
|
+
if (handlers && handlers.size === 0) {
|
|
94
|
+
STATE.channels.delete(channel);
|
|
95
|
+
if (STATE.uid) {
|
|
96
|
+
postUnsubscribe(channel).catch((err) => {
|
|
97
|
+
console.warn(`[aurora/relay] unsubscribe from ${channel} failed:`, err);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
on(status, callback) {
|
|
104
|
+
let set = STATE.statusListeners.get(status);
|
|
105
|
+
if (!set) {
|
|
106
|
+
set = new Set();
|
|
107
|
+
STATE.statusListeners.set(status, set);
|
|
108
|
+
}
|
|
109
|
+
set.add(callback);
|
|
110
|
+
return () => {
|
|
111
|
+
set?.delete(callback);
|
|
82
112
|
};
|
|
83
113
|
},
|
|
84
114
|
close() {
|
|
@@ -89,16 +119,22 @@ const CLIENT = {
|
|
|
89
119
|
STATE.uid = null;
|
|
90
120
|
STATE.channels.clear();
|
|
91
121
|
STATE.attached.clear();
|
|
122
|
+
STATE.reconnectAttempts = 0;
|
|
92
123
|
},
|
|
93
124
|
};
|
|
94
125
|
function open() {
|
|
95
126
|
const sse = new EventSource(CONFIG.sseUrl);
|
|
96
127
|
STATE.sse = sse;
|
|
97
128
|
STATE.attached = new Set();
|
|
129
|
+
changeStatus("connecting");
|
|
98
130
|
sse.addEventListener("connected", (ev) => {
|
|
99
131
|
const data = safeJson(messageData(ev));
|
|
100
132
|
if (data && typeof data.uid === "string") {
|
|
101
133
|
STATE.uid = data.uid;
|
|
134
|
+
// A successful (re)connect clears the failure counter and flips the
|
|
135
|
+
// status back to `connected`.
|
|
136
|
+
STATE.reconnectAttempts = 0;
|
|
137
|
+
changeStatus("connected");
|
|
102
138
|
// Re-apply EVERY active subscription on each (re)connect. The server
|
|
103
139
|
// assigns a fresh uid per connection and has no memory of prior
|
|
104
140
|
// subscriptions, so both the first connect AND browser auto-reconnects
|
|
@@ -111,11 +147,46 @@ function open() {
|
|
|
111
147
|
}
|
|
112
148
|
}
|
|
113
149
|
});
|
|
150
|
+
// The native EventSource auto-reconnects on a dropped connection, firing
|
|
151
|
+
// `error` each time. Mirror Transmit's reconnect bookkeeping: surface a
|
|
152
|
+
// `disconnected` → `reconnecting` transition, count attempts, and once the
|
|
153
|
+
// cap is reached close the stream (stopping the native retry loop) and fire
|
|
154
|
+
// `onReconnectFailed`.
|
|
155
|
+
sse.addEventListener("error", () => {
|
|
156
|
+
if (STATE.status !== "reconnecting")
|
|
157
|
+
changeStatus("disconnected");
|
|
158
|
+
changeStatus("reconnecting");
|
|
159
|
+
CONFIG.onReconnectAttempt?.(STATE.reconnectAttempts + 1);
|
|
160
|
+
if (CONFIG.maxReconnectAttempts > 0 &&
|
|
161
|
+
STATE.reconnectAttempts >= CONFIG.maxReconnectAttempts) {
|
|
162
|
+
sse.close();
|
|
163
|
+
if (STATE.sse === sse)
|
|
164
|
+
STATE.sse = null;
|
|
165
|
+
CONFIG.onReconnectFailed?.();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
STATE.reconnectAttempts++;
|
|
169
|
+
});
|
|
114
170
|
// Re-attach channel listeners — a close()+reopen builds a fresh EventSource
|
|
115
171
|
// that has lost the listeners wired by earlier subscribe() calls.
|
|
116
172
|
for (const channel of STATE.channels.keys())
|
|
117
173
|
attachChannel(sse, channel);
|
|
118
174
|
}
|
|
175
|
+
/** Update the status and notify every listener registered for it. */
|
|
176
|
+
function changeStatus(status) {
|
|
177
|
+
STATE.status = status;
|
|
178
|
+
const set = STATE.statusListeners.get(status);
|
|
179
|
+
if (!set)
|
|
180
|
+
return;
|
|
181
|
+
for (const cb of set) {
|
|
182
|
+
try {
|
|
183
|
+
cb(status);
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
console.warn(`[aurora/relay] status listener for ${status} threw:`, err);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
119
190
|
/**
|
|
120
191
|
* Wire one SSE listener for a channel's named broadcast events. The relay sends
|
|
121
192
|
* `event: <channel>\ndata: <JSON payload>`, so each channel is its own named
|
|
@@ -150,21 +221,50 @@ function messageData(ev) {
|
|
|
150
221
|
return ev.data;
|
|
151
222
|
return null;
|
|
152
223
|
}
|
|
153
|
-
|
|
224
|
+
function postSubscribe(channel) {
|
|
225
|
+
return postHandshake(CONFIG.subscribeUrl, channel);
|
|
226
|
+
}
|
|
227
|
+
function postUnsubscribe(channel) {
|
|
228
|
+
return postHandshake(CONFIG.unsubscribeUrl, channel);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* POST a `{ uid, channel }` handshake to a relay endpoint. Sends the
|
|
232
|
+
* signed-CSRF trio blackhole expects: the `XSRF-TOKEN` cookie echoed as
|
|
233
|
+
* the `X-XSRF-TOKEN` header plus `credentials: 'include'` so the cookie
|
|
234
|
+
* itself rides along. Without both, the POST is rejected by the signed
|
|
235
|
+
* double-submit guard. Mirrors `HttpClient.#retrieveXsrfToken` /
|
|
236
|
+
* `createRequest` in `@adonisjs/transmit-client`.
|
|
237
|
+
*/
|
|
238
|
+
async function postHandshake(url, channel) {
|
|
154
239
|
const headers = {
|
|
155
240
|
"content-type": "application/json",
|
|
156
241
|
};
|
|
157
242
|
if (CONFIG.bearer)
|
|
158
243
|
headers.authorization = `Bearer ${CONFIG.bearer}`;
|
|
159
|
-
const
|
|
244
|
+
const xsrf = retrieveXsrfToken();
|
|
245
|
+
if (xsrf !== null)
|
|
246
|
+
headers["x-xsrf-token"] = xsrf;
|
|
247
|
+
const res = await fetch(url, {
|
|
160
248
|
method: "POST",
|
|
161
249
|
headers,
|
|
162
250
|
body: JSON.stringify({ uid: STATE.uid, channel }),
|
|
251
|
+
credentials: "include",
|
|
163
252
|
});
|
|
164
253
|
if (!res.ok) {
|
|
165
254
|
throw new Error(`HTTP ${res.status}`);
|
|
166
255
|
}
|
|
167
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Read the `XSRF-TOKEN` cookie so it can be echoed as the `X-XSRF-TOKEN`
|
|
259
|
+
* header (signed double-submit CSRF). Browser-only — returns `null` under
|
|
260
|
+
* SSR / any environment without `document`.
|
|
261
|
+
*/
|
|
262
|
+
function retrieveXsrfToken() {
|
|
263
|
+
if (typeof document === "undefined")
|
|
264
|
+
return null;
|
|
265
|
+
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/);
|
|
266
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
267
|
+
}
|
|
168
268
|
function safeJson(raw) {
|
|
169
269
|
if (typeof raw !== "string")
|
|
170
270
|
return null;
|
|
@@ -46,6 +46,12 @@ export interface RenderPageOptions {
|
|
|
46
46
|
/**
|
|
47
47
|
* Extra markup spliced into `<head>` after the importmap. Use to
|
|
48
48
|
* inject `<title>`, meta tags, stylesheets.
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ Injected RAW / unescaped — it IS `<head>` markup, so it cannot be
|
|
51
|
+
* HTML-escaped. Pass ONLY trusted, server-authored strings; NEVER
|
|
52
|
+
* interpolate request/user input into it (that is an HTML-injection
|
|
53
|
+
* sink). Build any dynamic head content through an escaping helper
|
|
54
|
+
* upstream before handing it here.
|
|
49
55
|
*/
|
|
50
56
|
headExtra?: string;
|
|
51
57
|
/**
|
|
@@ -33,7 +33,10 @@ const CONTENT_TYPES = {
|
|
|
33
33
|
".json": "application/json; charset=utf-8",
|
|
34
34
|
};
|
|
35
35
|
export function serveAssets(options) {
|
|
36
|
-
|
|
36
|
+
// Normalize the root ONCE so the lexical containment gate below compares
|
|
37
|
+
// like-for-like: a raw root with a trailing slash or a non-normalized
|
|
38
|
+
// segment would never match the resolved request path → spurious 403s.
|
|
39
|
+
const root = resolvePath(options.root);
|
|
37
40
|
const cacheControl = options.cacheControl ?? "public, max-age=60";
|
|
38
41
|
// Canonicalize the root ONCE at handler creation. The realpath check
|
|
39
42
|
// below compares against this canonical form so a symlinked root
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@c9up/aurora",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.24",
|
|
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",
|
|
@@ -53,14 +53,15 @@
|
|
|
53
53
|
}
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
|
+
"@biomejs/biome": "^2.4.10",
|
|
56
57
|
"@c9up/comet": "^0.1.0",
|
|
57
58
|
"@types/node": "^22.19.15",
|
|
58
|
-
"@vitest/browser": "4.1.
|
|
59
|
-
"@vitest/browser-playwright": "
|
|
59
|
+
"@vitest/browser": "4.1.9",
|
|
60
|
+
"@vitest/browser-playwright": "4.1.9",
|
|
60
61
|
"happy-dom": "^15.11.7",
|
|
61
62
|
"playwright": "^1.61.1",
|
|
62
63
|
"typescript": "^6.0.2",
|
|
63
|
-
"vitest": "
|
|
64
|
+
"vitest": "4.1.9"
|
|
64
65
|
},
|
|
65
66
|
"files": [
|
|
66
67
|
"LICENSE",
|
package/src/Pages.ts
CHANGED
|
@@ -62,7 +62,11 @@ export class Pages {
|
|
|
62
62
|
private readonly registry = new Map<string, PageFactory>();
|
|
63
63
|
|
|
64
64
|
constructor(config: PagesConfig) {
|
|
65
|
-
|
|
65
|
+
// Normalize the root ONCE so the `startsWith(root + sep)` containment
|
|
66
|
+
// check below compares like-for-like against the resolved page path.
|
|
67
|
+
// A raw root with a trailing slash, a relative segment, or `..` would
|
|
68
|
+
// otherwise never match the resolved absolute path → spurious 403s.
|
|
69
|
+
this.root = resolvePath(config.root);
|
|
66
70
|
this.urlPrefix = (config.urlPrefix ?? "/__assets/pages").replace(/\/$/, "");
|
|
67
71
|
this.extension = config.extension ?? ".js";
|
|
68
72
|
}
|
package/src/browser.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* client barrel.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { effect, type Signal, signal } from "./reactive.js";
|
|
10
|
+
import { effect, onCleanup, type Signal, signal } from "./reactive.js";
|
|
11
11
|
|
|
12
12
|
/** Navigate to `url` with a full page load. No-op during SSR. */
|
|
13
13
|
export function redirect(url: string): void {
|
|
@@ -226,14 +226,21 @@ export function persistedSignal<T>(
|
|
|
226
226
|
typeof window !== "undefined"
|
|
227
227
|
) {
|
|
228
228
|
const fullKey = store.fullKey(key);
|
|
229
|
-
|
|
229
|
+
const onStorage = (event: StorageEvent) => {
|
|
230
230
|
if (event.key !== fullKey || event.newValue === null) return;
|
|
231
231
|
try {
|
|
232
232
|
sig(JSON.parse(event.newValue) as T);
|
|
233
233
|
} catch {
|
|
234
234
|
// Ignore a malformed cross-tab write.
|
|
235
235
|
}
|
|
236
|
-
}
|
|
236
|
+
};
|
|
237
|
+
window.addEventListener("storage", onStorage);
|
|
238
|
+
// Tie the listener to the owning reactive scope so a persistedSignal
|
|
239
|
+
// created in a component's setup removes it on dispose (matching the
|
|
240
|
+
// mirror effect above — the JSDoc promises disposal-with-the-component).
|
|
241
|
+
// At module scope onCleanup is a no-op, so the listener lives for the
|
|
242
|
+
// page lifetime, as intended for a shared module-level signal.
|
|
243
|
+
onCleanup(() => window.removeEventListener("storage", onStorage));
|
|
237
244
|
}
|
|
238
245
|
|
|
239
246
|
return sig;
|
package/src/live.ts
CHANGED
|
@@ -1,143 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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.
|
|
2
|
+
* `@c9up/aurora/live` barrel — the whole Live surface behind ONE subpath.
|
|
7
3
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* and the
|
|
4
|
+
* The transport-agnostic core (`mountLiveSession` + its types) lives in
|
|
5
|
+
* `./liveSession.js`; the transport stack (broadcast / client / registry /
|
|
6
|
+
* router / server) builds on it. Re-exporting everything here keeps the niche
|
|
7
|
+
* surface behind a single subpath and keeps the main `.` barrel lean (it no
|
|
8
|
+
* longer eager-pulls these into every browser graph), consistent with how
|
|
9
|
+
* `./ssr`, `./relay`, `./rpc` and `./hydrate` are already subpath-gated.
|
|
12
10
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* The core lives in a SEPARATE module (not inline here) so the transport files
|
|
12
|
+
* import it directly from `./liveSession.js` rather than through this barrel —
|
|
13
|
+
* which would otherwise form an import cycle (barrel → transport → barrel).
|
|
15
14
|
*/
|
|
16
15
|
|
|
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
|
-
}
|
|
133
|
-
|
|
134
|
-
// ─── `@c9up/aurora/live` barrel ──────────────────────────────────────
|
|
135
|
-
//
|
|
136
|
-
// The Live stack (broadcast / client / registry / router / server) is
|
|
137
|
-
// re-exported here so the whole niche surface lives behind ONE subpath,
|
|
138
|
-
// `@c9up/aurora/live` — keeping the main `.` barrel lean (it no longer
|
|
139
|
-
// eager-pulls these into every browser graph), consistent with how `./ssr`,
|
|
140
|
-
// `./relay`, `./rpc` and `./hydrate` are already subpath-gated.
|
|
141
16
|
export {
|
|
142
17
|
connectPatches,
|
|
143
18
|
type LiveStore,
|
|
@@ -169,3 +44,9 @@ export {
|
|
|
169
44
|
type WireLiveEventsOptions,
|
|
170
45
|
wireLiveEvents,
|
|
171
46
|
} from "./liveServer.js";
|
|
47
|
+
export {
|
|
48
|
+
type LiveComponentDefinition,
|
|
49
|
+
type LiveSession,
|
|
50
|
+
mountLiveSession,
|
|
51
|
+
type SlotPatch,
|
|
52
|
+
} from "./liveSession.js";
|
package/src/liveBroadcast.ts
CHANGED
package/src/liveClient.ts
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { hydrate } from "./hydrate.js";
|
|
19
|
+
import type { SlotPatch } from "./liveSession.js";
|
|
19
20
|
import { isSignal } from "./reactive.js";
|
|
20
|
-
import type { SlotPatch } from "./live.js";
|
|
21
21
|
import type { TemplateResult } from "./types.js";
|
|
22
22
|
|
|
23
23
|
/** The transport the live client needs: a patch subscription + an event POST. */
|
|
@@ -67,7 +67,11 @@ export function liveClient(opts: LiveClientOptions): () => void {
|
|
|
67
67
|
const target = event.target;
|
|
68
68
|
if (!(target instanceof Element)) return;
|
|
69
69
|
const el = target.closest("[data-live-click]");
|
|
70
|
-
if (el)
|
|
70
|
+
if (el)
|
|
71
|
+
opts.transport.post(
|
|
72
|
+
opts.mount.id,
|
|
73
|
+
el.getAttribute("data-live-click") ?? "",
|
|
74
|
+
);
|
|
71
75
|
};
|
|
72
76
|
opts.container.addEventListener("click", onClick);
|
|
73
77
|
|
package/src/liveRegistry.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
type LiveComponentDefinition,
|
|
16
16
|
type LiveSession,
|
|
17
17
|
mountLiveSession,
|
|
18
|
-
} from "./
|
|
18
|
+
} from "./liveSession.js";
|
|
19
19
|
|
|
20
20
|
/** A mounted instance: its id, the owner (e.g. relay uid), and the session. */
|
|
21
21
|
export interface LiveSessionHandle {
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live components — server-resident reactive UI CORE. The state lives on the
|
|
3
|
+
* server as ordinary aurora signals; this module turns a fine-grained signal
|
|
4
|
+
* change into a PRECISE per-slot patch (`{slot, value}`) instead of re-rendering
|
|
5
|
+
* the 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: mount a session, render its initial HTML,
|
|
9
|
+
* dispatch events that mutate signals, and drain / subscribe to the patches
|
|
10
|
+
* produced. The transport stack (broadcast / client / registry / router /
|
|
11
|
+
* server) builds ON this and re-exports through the `./live.js` barrel — kept in
|
|
12
|
+
* a separate module so those transport files import the core WITHOUT forming an
|
|
13
|
+
* import cycle back through the barrel.
|
|
14
|
+
*
|
|
15
|
+
* Node-free / isomorphic: uses only `signal`/`effect`/`renderToString`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { effect, isSignal } from "./reactive.js";
|
|
19
|
+
import { renderToString } from "./ssr.js";
|
|
20
|
+
import type { TemplateResult } from "./types.js";
|
|
21
|
+
|
|
22
|
+
/** A precise per-slot update: slot index (positional in the template) + value. */
|
|
23
|
+
export interface SlotPatch {
|
|
24
|
+
slot: number;
|
|
25
|
+
value: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A live component: its reactive view + named event handlers that mutate it. */
|
|
29
|
+
export interface LiveComponentDefinition {
|
|
30
|
+
/** The reactive view. Slots reading signals become live-patchable. */
|
|
31
|
+
view: TemplateResult;
|
|
32
|
+
/**
|
|
33
|
+
* Event handlers, by name. A client interaction (`@click="increment"`)
|
|
34
|
+
* dispatches one of these; it mutates the component's signals, which the
|
|
35
|
+
* patch tracker turns into a `{slot, value}` patch.
|
|
36
|
+
*/
|
|
37
|
+
handlers?: Record<string, (payload?: unknown) => void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A mounted live component instance — one per connected client session. */
|
|
41
|
+
export interface LiveSession {
|
|
42
|
+
/** Initial server-side render (the first full-HTML response). */
|
|
43
|
+
renderToString(): string;
|
|
44
|
+
/** Run a named handler (mutates signals); emits a patch for the batch. */
|
|
45
|
+
dispatch(event: string, payload?: unknown): void;
|
|
46
|
+
/** Collect + clear the patches accumulated since the last drain (pull model). */
|
|
47
|
+
drainPatches(): SlotPatch[];
|
|
48
|
+
/**
|
|
49
|
+
* Subscribe to patches as they are produced (push model — what the relay
|
|
50
|
+
* transport hooks into). Use `onPatch` OR `drainPatches`, not both.
|
|
51
|
+
*/
|
|
52
|
+
onPatch(listener: (patch: SlotPatch[]) => void): () => void;
|
|
53
|
+
/** Stop every effect — call on client disconnect to free the session. */
|
|
54
|
+
dispose(): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Mount a live session from a definition factory. Call once per connected
|
|
59
|
+
* client: the factory's signals become that session's private state. To SHARE
|
|
60
|
+
* state across sessions, close the factory over a signal created OUTSIDE it —
|
|
61
|
+
* every session then reads the same signal and patches on its change.
|
|
62
|
+
*/
|
|
63
|
+
export function mountLiveSession(
|
|
64
|
+
factory: () => LiveComponentDefinition,
|
|
65
|
+
): LiveSession {
|
|
66
|
+
const { view, handlers = {} } = factory();
|
|
67
|
+
const listeners = new Set<(patch: SlotPatch[]) => void>();
|
|
68
|
+
// slot → latest value. `pending` = current (un-flushed) batch; `buffer` =
|
|
69
|
+
// accumulated for pull consumers. Both keyed by slot so repeated writes in
|
|
70
|
+
// one batch collapse to the last value.
|
|
71
|
+
const pending = new Map<number, string>();
|
|
72
|
+
const buffer = new Map<number, string>();
|
|
73
|
+
let priming = true;
|
|
74
|
+
let flushScheduled = false;
|
|
75
|
+
|
|
76
|
+
const flush = (): void => {
|
|
77
|
+
flushScheduled = false;
|
|
78
|
+
if (pending.size === 0) return;
|
|
79
|
+
const patch: SlotPatch[] = [];
|
|
80
|
+
for (const [slot, value] of pending) {
|
|
81
|
+
patch.push({ slot, value });
|
|
82
|
+
buffer.set(slot, value);
|
|
83
|
+
}
|
|
84
|
+
pending.clear();
|
|
85
|
+
for (const listener of listeners) listener(patch);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const scheduleFlush = (): void => {
|
|
89
|
+
if (flushScheduled) return;
|
|
90
|
+
flushScheduled = true;
|
|
91
|
+
queueMicrotask(flush);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// One fine-grained effect per reactive slot. The priming run only
|
|
95
|
+
// subscribes; later runs (a signal changed) record the slot's new value.
|
|
96
|
+
const stops = view.values.map((value, slot) => {
|
|
97
|
+
if (!isSignal(value) && typeof value !== "function") return () => {};
|
|
98
|
+
return effect(() => {
|
|
99
|
+
const next = String((value as () => unknown)());
|
|
100
|
+
if (priming) return;
|
|
101
|
+
pending.set(slot, next);
|
|
102
|
+
scheduleFlush();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
priming = false;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
renderToString: () => renderToString(view),
|
|
109
|
+
dispatch(event, payload) {
|
|
110
|
+
const handler = handlers[event];
|
|
111
|
+
if (!handler) return;
|
|
112
|
+
handler(payload);
|
|
113
|
+
flush(); // synchronous — one patch per dispatch (batches the handler's writes)
|
|
114
|
+
},
|
|
115
|
+
drainPatches() {
|
|
116
|
+
flush();
|
|
117
|
+
const patch: SlotPatch[] = [];
|
|
118
|
+
for (const [slot, value] of buffer) patch.push({ slot, value });
|
|
119
|
+
buffer.clear();
|
|
120
|
+
return patch;
|
|
121
|
+
},
|
|
122
|
+
onPatch(listener) {
|
|
123
|
+
listeners.add(listener);
|
|
124
|
+
return () => listeners.delete(listener);
|
|
125
|
+
},
|
|
126
|
+
dispose() {
|
|
127
|
+
for (const stop of stops) stop();
|
|
128
|
+
listeners.clear();
|
|
129
|
+
pending.clear();
|
|
130
|
+
buffer.clear();
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
package/src/middleware.ts
CHANGED
|
@@ -30,7 +30,7 @@ export interface AuroraRequestRenderer {
|
|
|
30
30
|
|
|
31
31
|
/** Request context the middleware needs: render target + optional resolver/slot. */
|
|
32
32
|
interface AuroraMiddlewareContext extends RenderHttpContext {
|
|
33
|
-
containerResolver?: { make(token: unknown): unknown };
|
|
33
|
+
containerResolver?: { make(token: unknown): Promise<unknown> };
|
|
34
34
|
aurora?: AuroraRequestRenderer;
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -44,11 +44,11 @@ function isManager(value: unknown): value is AuroraManager {
|
|
|
44
44
|
);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
function resolveManager(
|
|
48
|
-
resolver: { make(token: unknown): unknown } | undefined,
|
|
49
|
-
): AuroraManager | undefined {
|
|
47
|
+
async function resolveManager(
|
|
48
|
+
resolver: { make(token: unknown): Promise<unknown> } | undefined,
|
|
49
|
+
): Promise<AuroraManager | undefined> {
|
|
50
50
|
try {
|
|
51
|
-
const resolved = resolver?.make("aurora");
|
|
51
|
+
const resolved = await resolver?.make("aurora");
|
|
52
52
|
return isManager(resolved) ? resolved : undefined;
|
|
53
53
|
} catch {
|
|
54
54
|
return undefined;
|
|
@@ -59,11 +59,11 @@ function resolveManager(
|
|
|
59
59
|
* Middleware: attach `ctx.aurora` for the request. No-op (passes through) when
|
|
60
60
|
* the AuroraManager isn't registered, so it's safe to mount unconditionally.
|
|
61
61
|
*/
|
|
62
|
-
export function auroraContext(
|
|
62
|
+
export async function auroraContext(
|
|
63
63
|
ctx: AuroraMiddlewareContext,
|
|
64
64
|
next: () => Promise<void>,
|
|
65
65
|
): Promise<void> {
|
|
66
|
-
const manager = resolveManager(ctx.containerResolver);
|
|
66
|
+
const manager = await resolveManager(ctx.containerResolver);
|
|
67
67
|
if (manager) {
|
|
68
68
|
ctx.aurora = {
|
|
69
69
|
render: (name, props, options) =>
|