@c9up/aurora 0.1.23 → 0.1.25

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.
@@ -17,7 +17,7 @@
17
17
  */
18
18
  interface AuroraContainer {
19
19
  singleton(token: unknown, factory: () => unknown): void;
20
- resolve<T = unknown>(token: unknown): T;
20
+ resolve<T = unknown>(token: unknown): Promise<T>;
21
21
  has(token: unknown): boolean;
22
22
  }
23
23
  interface AuroraConfigStore {
@@ -27,9 +27,9 @@ export default class AuroraProvider {
27
27
  this.app = app;
28
28
  }
29
29
  register() {
30
- this.app.container.singleton(AuroraManager, () => {
30
+ this.app.container.singleton(AuroraManager, async () => {
31
31
  const raw = this.app.config.get("aurora");
32
- const config = this.resolveConfig(raw);
32
+ const config = await this.resolveConfig(raw);
33
33
  const manager = new AuroraManager(config);
34
34
  setAurora(manager);
35
35
  return manager;
@@ -44,7 +44,7 @@ export default class AuroraProvider {
44
44
  async boot() {
45
45
  // Force-resolve so `setAurora` runs even if the app never
46
46
  // touches the singleton from a preload.
47
- const manager = this.app.container.resolve(AuroraManager);
47
+ const manager = await this.app.container.resolve(AuroraManager);
48
48
  setAurora(manager);
49
49
  }
50
50
  async start() {
@@ -61,8 +61,8 @@ export default class AuroraProvider {
61
61
  // instead of being misread as "the asset routes just stopped mounting".
62
62
  if (!this.app.container.has("router"))
63
63
  return;
64
- const router = this.app.container.resolve("router");
65
- const manager = this.app.container.resolve(AuroraManager);
64
+ const router = await this.app.container.resolve("router");
65
+ const manager = await this.app.container.resolve(AuroraManager);
66
66
  // Mount paths derive from the configured `assetsPrefix` (default
67
67
  // `/__assets`) — set `config.aurora.assetsPrefix` to change the scheme.
68
68
  router.get(`${manager.auroraAssetPath}/*`, adaptHandler(manager.auroraAssetsHandler()));
@@ -89,8 +89,8 @@ export default class AuroraProvider {
89
89
  * one (Ream does, since v0.x — see Ignitor); other hosts get the
90
90
  * `process.cwd()` fallback.
91
91
  */
92
- resolveConfig(raw) {
93
- const appRoot = this.readAppRoot();
92
+ async resolveConfig(raw) {
93
+ const appRoot = await this.readAppRoot();
94
94
  const userRoot = raw?.pages?.root;
95
95
  const root = typeof userRoot === "string" && userRoot.length > 0
96
96
  ? isAbsolute(userRoot)
@@ -102,9 +102,9 @@ export default class AuroraProvider {
102
102
  pages: { ...(raw?.pages ?? {}), root },
103
103
  };
104
104
  }
105
- readAppRoot() {
105
+ async readAppRoot() {
106
106
  try {
107
- const raw = this.app.container.resolve("appRoot");
107
+ const raw = await this.app.container.resolve("appRoot");
108
108
  if (raw instanceof URL)
109
109
  return fileURLToPath(raw);
110
110
  if (typeof raw === "string")
package/dist/Pages.js CHANGED
@@ -28,7 +28,11 @@ export class Pages {
28
28
  extension;
29
29
  registry = new Map();
30
30
  constructor(config) {
31
- this.root = config.root;
31
+ // Normalize the root ONCE so the `startsWith(root + sep)` containment
32
+ // check below compares like-for-like against the resolved page path.
33
+ // A raw root with a trailing slash, a relative segment, or `..` would
34
+ // otherwise never match the resolved absolute path → spurious 403s.
35
+ this.root = resolvePath(config.root);
32
36
  this.urlPrefix = (config.urlPrefix ?? "/__assets/pages").replace(/\/$/, "");
33
37
  this.extension = config.extension ?? ".js";
34
38
  }
package/dist/browser.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * without `typeof window` guards at every call site. Node-free — part of the
7
7
  * client barrel.
8
8
  */
9
- import { effect, signal } from "./reactive.js";
9
+ import { effect, onCleanup, signal } from "./reactive.js";
10
10
  /** Navigate to `url` with a full page load. No-op during SSR. */
11
11
  export function redirect(url) {
12
12
  if (typeof window !== "undefined") {
@@ -185,7 +185,7 @@ export function persistedSignal(key, initial, options = {}) {
185
185
  (options.area ?? "local") === "local" &&
186
186
  typeof window !== "undefined") {
187
187
  const fullKey = store.fullKey(key);
188
- window.addEventListener("storage", (event) => {
188
+ const onStorage = (event) => {
189
189
  if (event.key !== fullKey || event.newValue === null)
190
190
  return;
191
191
  try {
@@ -194,7 +194,14 @@ export function persistedSignal(key, initial, options = {}) {
194
194
  catch {
195
195
  // Ignore a malformed cross-tab write.
196
196
  }
197
- });
197
+ };
198
+ window.addEventListener("storage", onStorage);
199
+ // Tie the listener to the owning reactive scope so a persistedSignal
200
+ // created in a component's setup removes it on dispose (matching the
201
+ // mirror effect above — the JSDoc promises disposal-with-the-component).
202
+ // At module scope onCleanup is a no-op, so the listener lives for the
203
+ // page lifetime, as intended for a shared module-level signal.
204
+ onCleanup(() => window.removeEventListener("storage", onStorage));
198
205
  }
199
206
  return sig;
200
207
  }
@@ -20,7 +20,7 @@ export interface AuroraRequestRenderer {
20
20
  /** Request context the middleware needs: render target + optional resolver/slot. */
21
21
  interface AuroraMiddlewareContext extends RenderHttpContext {
22
22
  containerResolver?: {
23
- make(token: unknown): unknown;
23
+ make(token: unknown): Promise<unknown>;
24
24
  };
25
25
  aurora?: AuroraRequestRenderer;
26
26
  }
@@ -19,9 +19,9 @@ function isManager(value) {
19
19
  "render" in value &&
20
20
  typeof value.render === "function");
21
21
  }
22
- function resolveManager(resolver) {
22
+ async function resolveManager(resolver) {
23
23
  try {
24
- const resolved = resolver?.make("aurora");
24
+ const resolved = await resolver?.make("aurora");
25
25
  return isManager(resolved) ? resolved : undefined;
26
26
  }
27
27
  catch {
@@ -32,8 +32,8 @@ function resolveManager(resolver) {
32
32
  * Middleware: attach `ctx.aurora` for the request. No-op (passes through) when
33
33
  * the AuroraManager isn't registered, so it's safe to mount unconditionally.
34
34
  */
35
- export function auroraContext(ctx, next) {
36
- const manager = resolveManager(ctx.containerResolver);
35
+ export async function auroraContext(ctx, next) {
36
+ const manager = await resolveManager(ctx.containerResolver);
37
37
  if (manager) {
38
38
  ctx.aurora = {
39
39
  render: (name, props, options) => manager.render(ctx, name, props, options),
package/dist/relay.d.ts CHANGED
@@ -17,8 +17,19 @@
17
17
  * `@c9up/aurora`. Node-side code that pulls it will trip on
18
18
  * `EventSource` being undefined.
19
19
  */
20
+ /**
21
+ * Connection lifecycle status. Mirrors `@adonisjs/transmit-client`'s
22
+ * `TransmitStatus` (minus `initializing`, which the singleton never
23
+ * exposes — the first `relay()` call opens straight into `connecting`).
24
+ */
25
+ export type RelayStatus = "connecting" | "connected" | "disconnected" | "reconnecting";
20
26
  export interface RelayClient {
21
27
  subscribe<E>(channel: string, handler: (event: E) => void): () => void;
28
+ /**
29
+ * Register a connection-status listener. Returns a detacher. Mirrors
30
+ * `transmit.on('connected' | 'disconnected' | ...)`.
31
+ */
32
+ on(status: RelayStatus, callback: (status: RelayStatus) => void): () => void;
22
33
  close(): void;
23
34
  }
24
35
  export interface RelayOptions {
@@ -26,13 +37,25 @@ export interface RelayOptions {
26
37
  sseUrl?: string;
27
38
  /** Subscribe POST endpoint. Defaults to `/__relay/subscribe`. */
28
39
  subscribeUrl?: string;
40
+ /** Unsubscribe POST endpoint. Defaults to `/__relay/unsubscribe`. */
41
+ unsubscribeUrl?: string;
29
42
  /** Optional bearer token (for guarded relay routes). */
30
43
  bearer?: string;
44
+ /**
45
+ * Give up after this many consecutive reconnect attempts. Default 5
46
+ * (Transmit parity). `0` disables the cap — the browser's native
47
+ * EventSource keeps retrying forever.
48
+ */
49
+ maxReconnectAttempts?: number;
50
+ /** Fired before each reconnect attempt with the 1-based attempt count. */
51
+ onReconnectAttempt?: (attempt: number) => void;
52
+ /** Fired once when `maxReconnectAttempts` is exhausted and we give up. */
53
+ onReconnectFailed?: () => void;
31
54
  }
32
55
  /**
33
- * Configure the relay endpoints + bearer. Call once at boot if you
34
- * need to override the defaults. Multiple calls overwrite — last call
35
- * wins.
56
+ * Configure the relay endpoints + bearer + reconnect policy. Call once
57
+ * at boot if you need to override the defaults. Multiple calls overwrite
58
+ * — last call wins.
36
59
  */
37
60
  export declare function configureRelay(options: RelayOptions): void;
38
61
  /**
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.23",
3
+ "version": "0.1.25",
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",
@@ -30,7 +30,7 @@ import { renderToString } from "./ssr.js";
30
30
 
31
31
  interface AuroraContainer {
32
32
  singleton(token: unknown, factory: () => unknown): void;
33
- resolve<T = unknown>(token: unknown): T;
33
+ resolve<T = unknown>(token: unknown): Promise<T>;
34
34
  has(token: unknown): boolean;
35
35
  }
36
36
  interface AuroraConfigStore {
@@ -52,9 +52,9 @@ export default class AuroraProvider {
52
52
  constructor(protected app: AuroraAppContext) {}
53
53
 
54
54
  register(): void {
55
- this.app.container.singleton(AuroraManager, () => {
55
+ this.app.container.singleton(AuroraManager, async () => {
56
56
  const raw = this.app.config.get<AuroraManagerConfig>("aurora");
57
- const config = this.resolveConfig(raw);
57
+ const config = await this.resolveConfig(raw);
58
58
  const manager = new AuroraManager(config);
59
59
  setAurora(manager);
60
60
  return manager;
@@ -72,7 +72,8 @@ export default class AuroraProvider {
72
72
  async boot(): Promise<void> {
73
73
  // Force-resolve so `setAurora` runs even if the app never
74
74
  // touches the singleton from a preload.
75
- const manager = this.app.container.resolve<AuroraManager>(AuroraManager);
75
+ const manager =
76
+ await this.app.container.resolve<AuroraManager>(AuroraManager);
76
77
  setAurora(manager);
77
78
  }
78
79
 
@@ -89,8 +90,9 @@ export default class AuroraProvider {
89
90
  // failures (slug collision, AuroraManager crash) propagate with a stack
90
91
  // instead of being misread as "the asset routes just stopped mounting".
91
92
  if (!this.app.container.has("router")) return;
92
- const router = this.app.container.resolve<ReamRouter>("router");
93
- const manager = this.app.container.resolve<AuroraManager>(AuroraManager);
93
+ const router = await this.app.container.resolve<ReamRouter>("router");
94
+ const manager =
95
+ await this.app.container.resolve<AuroraManager>(AuroraManager);
94
96
  // Mount paths derive from the configured `assetsPrefix` (default
95
97
  // `/__assets`) — set `config.aurora.assetsPrefix` to change the scheme.
96
98
  router.get(
@@ -125,10 +127,10 @@ export default class AuroraProvider {
125
127
  * one (Ream does, since v0.x — see Ignitor); other hosts get the
126
128
  * `process.cwd()` fallback.
127
129
  */
128
- private resolveConfig(
130
+ private async resolveConfig(
129
131
  raw: AuroraManagerConfig | undefined,
130
- ): AuroraManagerConfig {
131
- const appRoot = this.readAppRoot();
132
+ ): Promise<AuroraManagerConfig> {
133
+ const appRoot = await this.readAppRoot();
132
134
  const userRoot = raw?.pages?.root;
133
135
  const root =
134
136
  typeof userRoot === "string" && userRoot.length > 0
@@ -142,9 +144,9 @@ export default class AuroraProvider {
142
144
  };
143
145
  }
144
146
 
145
- private readAppRoot(): string {
147
+ private async readAppRoot(): Promise<string> {
146
148
  try {
147
- const raw = this.app.container.resolve<unknown>("appRoot");
149
+ const raw = await this.app.container.resolve<unknown>("appRoot");
148
150
  if (raw instanceof URL) return fileURLToPath(raw);
149
151
  if (typeof raw === "string") return raw;
150
152
  } catch {
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/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) =>
package/src/relay.ts CHANGED
@@ -18,8 +18,24 @@
18
18
  * `EventSource` being undefined.
19
19
  */
20
20
 
21
+ /**
22
+ * Connection lifecycle status. Mirrors `@adonisjs/transmit-client`'s
23
+ * `TransmitStatus` (minus `initializing`, which the singleton never
24
+ * exposes — the first `relay()` call opens straight into `connecting`).
25
+ */
26
+ export type RelayStatus =
27
+ | "connecting"
28
+ | "connected"
29
+ | "disconnected"
30
+ | "reconnecting";
31
+
21
32
  export interface RelayClient {
22
33
  subscribe<E>(channel: string, handler: (event: E) => void): () => void;
34
+ /**
35
+ * Register a connection-status listener. Returns a detacher. Mirrors
36
+ * `transmit.on('connected' | 'disconnected' | ...)`.
37
+ */
38
+ on(status: RelayStatus, callback: (status: RelayStatus) => void): () => void;
23
39
  close(): void;
24
40
  }
25
41
 
@@ -29,6 +45,12 @@ interface RelayState {
29
45
  channels: Map<string, Set<(event: unknown) => void>>;
30
46
  /** Channels we've already wired an SSE listener for on the current sse. */
31
47
  attached: Set<string>;
48
+ /** Current connection status. */
49
+ status: RelayStatus;
50
+ /** Status listeners, keyed by the status they fire on. */
51
+ statusListeners: Map<RelayStatus, Set<(status: RelayStatus) => void>>;
52
+ /** Consecutive failed-connection count, reset on every `connected` frame. */
53
+ reconnectAttempts: number;
32
54
  }
33
55
 
34
56
  const STATE: RelayState = {
@@ -36,6 +58,9 @@ const STATE: RelayState = {
36
58
  uid: null,
37
59
  channels: new Map(),
38
60
  attached: new Set(),
61
+ status: "connecting",
62
+ statusListeners: new Map(),
63
+ reconnectAttempts: 0,
39
64
  };
40
65
 
41
66
  export interface RelayOptions {
@@ -43,26 +68,55 @@ export interface RelayOptions {
43
68
  sseUrl?: string;
44
69
  /** Subscribe POST endpoint. Defaults to `/__relay/subscribe`. */
45
70
  subscribeUrl?: string;
71
+ /** Unsubscribe POST endpoint. Defaults to `/__relay/unsubscribe`. */
72
+ unsubscribeUrl?: string;
46
73
  /** Optional bearer token (for guarded relay routes). */
47
74
  bearer?: string;
75
+ /**
76
+ * Give up after this many consecutive reconnect attempts. Default 5
77
+ * (Transmit parity). `0` disables the cap — the browser's native
78
+ * EventSource keeps retrying forever.
79
+ */
80
+ maxReconnectAttempts?: number;
81
+ /** Fired before each reconnect attempt with the 1-based attempt count. */
82
+ onReconnectAttempt?: (attempt: number) => void;
83
+ /** Fired once when `maxReconnectAttempts` is exhausted and we give up. */
84
+ onReconnectFailed?: () => void;
48
85
  }
49
86
 
50
- let CONFIG: Required<RelayOptions> = {
87
+ interface RelayConfigResolved {
88
+ sseUrl: string;
89
+ subscribeUrl: string;
90
+ unsubscribeUrl: string;
91
+ bearer: string;
92
+ maxReconnectAttempts: number;
93
+ onReconnectAttempt?: (attempt: number) => void;
94
+ onReconnectFailed?: () => void;
95
+ }
96
+
97
+ let CONFIG: RelayConfigResolved = {
51
98
  sseUrl: "/__relay/events",
52
99
  subscribeUrl: "/__relay/subscribe",
100
+ unsubscribeUrl: "/__relay/unsubscribe",
53
101
  bearer: "",
102
+ maxReconnectAttempts: 5,
54
103
  };
55
104
 
56
105
  /**
57
- * Configure the relay endpoints + bearer. Call once at boot if you
58
- * need to override the defaults. Multiple calls overwrite — last call
59
- * wins.
106
+ * Configure the relay endpoints + bearer + reconnect policy. Call once
107
+ * at boot if you need to override the defaults. Multiple calls overwrite
108
+ * — last call wins.
60
109
  */
61
110
  export function configureRelay(options: RelayOptions): void {
62
111
  CONFIG = {
63
112
  sseUrl: options.sseUrl ?? CONFIG.sseUrl,
64
113
  subscribeUrl: options.subscribeUrl ?? CONFIG.subscribeUrl,
114
+ unsubscribeUrl: options.unsubscribeUrl ?? CONFIG.unsubscribeUrl,
65
115
  bearer: options.bearer ?? CONFIG.bearer,
116
+ maxReconnectAttempts:
117
+ options.maxReconnectAttempts ?? CONFIG.maxReconnectAttempts,
118
+ onReconnectAttempt: options.onReconnectAttempt ?? CONFIG.onReconnectAttempt,
119
+ onReconnectFailed: options.onReconnectFailed ?? CONFIG.onReconnectFailed,
66
120
  };
67
121
  }
68
122
 
@@ -103,11 +157,36 @@ const CLIENT: RelayClient = {
103
157
  });
104
158
  }
105
159
 
106
- // Detacher — only removes the local listener. The server-side
107
- // subscription stays open; closing it would interrupt other
108
- // listeners on the same channel.
160
+ // Detacher — removes the local listener. When it was the LAST handler
161
+ // on the channel, the server-side subscription is dropped too (POST
162
+ // /__relay/unsubscribe), so the server stops streaming a channel
163
+ // nobody's listening to. Other channels / other listeners are
164
+ // untouched.
109
165
  return () => {
110
166
  handlers?.delete(adapted);
167
+ if (handlers && handlers.size === 0) {
168
+ STATE.channels.delete(channel);
169
+ if (STATE.uid) {
170
+ postUnsubscribe(channel).catch((err: unknown) => {
171
+ console.warn(
172
+ `[aurora/relay] unsubscribe from ${channel} failed:`,
173
+ err,
174
+ );
175
+ });
176
+ }
177
+ }
178
+ };
179
+ },
180
+
181
+ on(status, callback) {
182
+ let set = STATE.statusListeners.get(status);
183
+ if (!set) {
184
+ set = new Set();
185
+ STATE.statusListeners.set(status, set);
186
+ }
187
+ set.add(callback);
188
+ return () => {
189
+ set?.delete(callback);
111
190
  };
112
191
  },
113
192
 
@@ -119,6 +198,7 @@ const CLIENT: RelayClient = {
119
198
  STATE.uid = null;
120
199
  STATE.channels.clear();
121
200
  STATE.attached.clear();
201
+ STATE.reconnectAttempts = 0;
122
202
  },
123
203
  };
124
204
 
@@ -126,11 +206,16 @@ function open(): void {
126
206
  const sse = new EventSource(CONFIG.sseUrl);
127
207
  STATE.sse = sse;
128
208
  STATE.attached = new Set();
209
+ changeStatus("connecting");
129
210
 
130
211
  sse.addEventListener("connected", (ev) => {
131
212
  const data = safeJson<{ uid?: string }>(messageData(ev));
132
213
  if (data && typeof data.uid === "string") {
133
214
  STATE.uid = data.uid;
215
+ // A successful (re)connect clears the failure counter and flips the
216
+ // status back to `connected`.
217
+ STATE.reconnectAttempts = 0;
218
+ changeStatus("connected");
134
219
  // Re-apply EVERY active subscription on each (re)connect. The server
135
220
  // assigns a fresh uid per connection and has no memory of prior
136
221
  // subscriptions, so both the first connect AND browser auto-reconnects
@@ -147,11 +232,46 @@ function open(): void {
147
232
  }
148
233
  });
149
234
 
235
+ // The native EventSource auto-reconnects on a dropped connection, firing
236
+ // `error` each time. Mirror Transmit's reconnect bookkeeping: surface a
237
+ // `disconnected` → `reconnecting` transition, count attempts, and once the
238
+ // cap is reached close the stream (stopping the native retry loop) and fire
239
+ // `onReconnectFailed`.
240
+ sse.addEventListener("error", () => {
241
+ if (STATE.status !== "reconnecting") changeStatus("disconnected");
242
+ changeStatus("reconnecting");
243
+ CONFIG.onReconnectAttempt?.(STATE.reconnectAttempts + 1);
244
+ if (
245
+ CONFIG.maxReconnectAttempts > 0 &&
246
+ STATE.reconnectAttempts >= CONFIG.maxReconnectAttempts
247
+ ) {
248
+ sse.close();
249
+ if (STATE.sse === sse) STATE.sse = null;
250
+ CONFIG.onReconnectFailed?.();
251
+ return;
252
+ }
253
+ STATE.reconnectAttempts++;
254
+ });
255
+
150
256
  // Re-attach channel listeners — a close()+reopen builds a fresh EventSource
151
257
  // that has lost the listeners wired by earlier subscribe() calls.
152
258
  for (const channel of STATE.channels.keys()) attachChannel(sse, channel);
153
259
  }
154
260
 
261
+ /** Update the status and notify every listener registered for it. */
262
+ function changeStatus(status: RelayStatus): void {
263
+ STATE.status = status;
264
+ const set = STATE.statusListeners.get(status);
265
+ if (!set) return;
266
+ for (const cb of set) {
267
+ try {
268
+ cb(status);
269
+ } catch (err) {
270
+ console.warn(`[aurora/relay] status listener for ${status} threw:`, err);
271
+ }
272
+ }
273
+ }
274
+
155
275
  /**
156
276
  * Wire one SSE listener for a channel's named broadcast events. The relay sends
157
277
  * `event: <channel>\ndata: <JSON payload>`, so each channel is its own named
@@ -183,21 +303,51 @@ function messageData(ev: Event): string | null {
183
303
  return null;
184
304
  }
185
305
 
186
- async function postSubscribe(channel: string): Promise<void> {
306
+ function postSubscribe(channel: string): Promise<void> {
307
+ return postHandshake(CONFIG.subscribeUrl, channel);
308
+ }
309
+
310
+ function postUnsubscribe(channel: string): Promise<void> {
311
+ return postHandshake(CONFIG.unsubscribeUrl, channel);
312
+ }
313
+
314
+ /**
315
+ * POST a `{ uid, channel }` handshake to a relay endpoint. Sends the
316
+ * signed-CSRF trio blackhole expects: the `XSRF-TOKEN` cookie echoed as
317
+ * the `X-XSRF-TOKEN` header plus `credentials: 'include'` so the cookie
318
+ * itself rides along. Without both, the POST is rejected by the signed
319
+ * double-submit guard. Mirrors `HttpClient.#retrieveXsrfToken` /
320
+ * `createRequest` in `@adonisjs/transmit-client`.
321
+ */
322
+ async function postHandshake(url: string, channel: string): Promise<void> {
187
323
  const headers: Record<string, string> = {
188
324
  "content-type": "application/json",
189
325
  };
190
326
  if (CONFIG.bearer) headers.authorization = `Bearer ${CONFIG.bearer}`;
191
- const res = await fetch(CONFIG.subscribeUrl, {
327
+ const xsrf = retrieveXsrfToken();
328
+ if (xsrf !== null) headers["x-xsrf-token"] = xsrf;
329
+ const res = await fetch(url, {
192
330
  method: "POST",
193
331
  headers,
194
332
  body: JSON.stringify({ uid: STATE.uid, channel }),
333
+ credentials: "include",
195
334
  });
196
335
  if (!res.ok) {
197
336
  throw new Error(`HTTP ${res.status}`);
198
337
  }
199
338
  }
200
339
 
340
+ /**
341
+ * Read the `XSRF-TOKEN` cookie so it can be echoed as the `X-XSRF-TOKEN`
342
+ * header (signed double-submit CSRF). Browser-only — returns `null` under
343
+ * SSR / any environment without `document`.
344
+ */
345
+ function retrieveXsrfToken(): string | null {
346
+ if (typeof document === "undefined") return null;
347
+ const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/);
348
+ return match ? decodeURIComponent(match[1]) : null;
349
+ }
350
+
201
351
  function safeJson<T>(raw: unknown): T | null {
202
352
  if (typeof raw !== "string") return null;
203
353
  try {
@@ -52,6 +52,12 @@ export interface RenderPageOptions {
52
52
  /**
53
53
  * Extra markup spliced into `<head>` after the importmap. Use to
54
54
  * inject `<title>`, meta tags, stylesheets.
55
+ *
56
+ * ⚠️ Injected RAW / unescaped — it IS `<head>` markup, so it cannot be
57
+ * HTML-escaped. Pass ONLY trusted, server-authored strings; NEVER
58
+ * interpolate request/user input into it (that is an HTML-injection
59
+ * sink). Build any dynamic head content through an escaping helper
60
+ * upstream before handing it here.
55
61
  */
56
62
  headExtra?: string;
57
63
  /**
@@ -72,7 +72,10 @@ export interface ServeAssetsOptions {
72
72
  export function serveAssets(
73
73
  options: ServeAssetsOptions,
74
74
  ): (ctx: AssetsHttpContext) => Promise<void> {
75
- const root = options.root;
75
+ // Normalize the root ONCE so the lexical containment gate below compares
76
+ // like-for-like: a raw root with a trailing slash or a non-normalized
77
+ // segment would never match the resolved request path → spurious 403s.
78
+ const root = resolvePath(options.root);
76
79
  const cacheControl = options.cacheControl ?? "public, max-age=60";
77
80
  // Canonicalize the root ONCE at handler creation. The realpath check
78
81
  // below compares against this canonical form so a symlinked root