@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/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