@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/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 at boot if you
33
- * need to override the defaults. Multiple calls overwrite — last call
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 — only removes the local listener. The server-side
78
- // subscription stays open; closing it would interrupt other
79
- // listeners on the same channel.
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
- async function postSubscribe(channel) {
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 res = await fetch(CONFIG.subscribeUrl, {
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
- const root = options.root;
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.22",
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.6",
59
- "@vitest/browser-playwright": "^4.1.9",
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": "^4.1.2"
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
- this.root = config.root;
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
- window.addEventListener("storage", (event) => {
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
- * Live componentsserver-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.
2
+ * `@c9up/aurora/live` barrelthe whole Live surface behind ONE subpath.
7
3
  *
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.
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
- * Node-free / isomorphic: uses only `signal`/`effect`/`renderToString`, so it
14
- * sits in the main barrel alongside the rest of aurora's runtime.
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";
@@ -20,7 +20,7 @@ import {
20
20
  type LiveSession,
21
21
  mountLiveSession,
22
22
  type SlotPatch,
23
- } from "./live.js";
23
+ } from "./liveSession.js";
24
24
 
25
25
  /**
26
26
  * Minimal relay surface aurora needs. The real `@c9up/relay` `Relay` satisfies
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) opts.transport.post(opts.mount.id, el.getAttribute("data-live-click") ?? "");
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
 
@@ -15,7 +15,7 @@ import {
15
15
  type LiveComponentDefinition,
16
16
  type LiveSession,
17
17
  mountLiveSession,
18
- } from "./live.js";
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) =>