@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.
- package/dist/AuroraProvider.d.ts +1 -1
- package/dist/AuroraProvider.js +9 -9
- package/dist/Pages.js +5 -1
- package/dist/browser.js +10 -3
- 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/AuroraProvider.ts +13 -11
- package/src/Pages.ts +5 -1
- package/src/browser.ts +10 -3
- 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/AuroraProvider.d.ts
CHANGED
|
@@ -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 {
|
package/dist/AuroraProvider.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/middleware.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/middleware.js
CHANGED
|
@@ -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
|
|
34
|
-
* need to override the defaults. Multiple calls overwrite
|
|
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
|
|
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.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.
|
|
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/AuroraProvider.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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/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
|
-
|
|
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
|