@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/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
|
-
|
|
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
|
|
58
|
-
* need to override the defaults. Multiple calls overwrite
|
|
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 —
|
|
107
|
-
//
|
|
108
|
-
//
|
|
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
|
-
|
|
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
|
|
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 {
|
package/src/server/renderPage.ts
CHANGED
|
@@ -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
|
-
|
|
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
|