@c9up/aurora 0.1.33 → 0.1.36
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 -0
- package/dist/AuroraProvider.js +1 -0
- package/dist/augmentations.d.ts +23 -0
- package/dist/augmentations.js +17 -0
- package/dist/browser.js +1 -1
- package/dist/html.js +11 -10
- package/dist/http.d.ts +8 -1
- package/dist/http.js +19 -0
- package/dist/hydrate.js +9 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +7 -0
- package/dist/liveClient.js +8 -1
- package/dist/relay.js +6 -28
- package/dist/render.js +1 -2
- package/dist/rpc.js +12 -30
- package/dist/ssr.js +25 -13
- package/dist/xsrf.d.ts +63 -0
- package/dist/xsrf.js +81 -0
- package/package.json +6 -5
- package/src/AuroraProvider.ts +1 -0
- package/src/augmentations.ts +28 -0
- package/src/browser.ts +1 -1
- package/src/html.ts +9 -9
- package/src/http.ts +27 -1
- package/src/hydrate.ts +7 -5
- package/src/index.ts +2 -0
- package/src/liveClient.ts +13 -1
- package/src/relay.ts +7 -25
- package/src/render.ts +1 -2
- package/src/rpc.ts +14 -28
- package/src/ssr.ts +25 -13
- package/src/xsrf.ts +97 -0
package/dist/AuroraProvider.d.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* framework with a container — non-Ream hosts get the singleton bindings
|
|
16
16
|
* and skip the route auto-registration silently.
|
|
17
17
|
*/
|
|
18
|
+
import "./augmentations.js";
|
|
18
19
|
interface AuroraContainer {
|
|
19
20
|
singleton(token: unknown, factory: () => unknown): void;
|
|
20
21
|
resolve<T = unknown>(token: unknown): Promise<T>;
|
package/dist/AuroraProvider.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* framework with a container — non-Ream hosts get the singleton bindings
|
|
16
16
|
* and skip the route auto-registration silently.
|
|
17
17
|
*/
|
|
18
|
+
import "./augmentations.js";
|
|
18
19
|
import { isAbsolute, resolve as resolvePath } from "node:path";
|
|
19
20
|
import { fileURLToPath } from "node:url";
|
|
20
21
|
import { AuroraManager } from "./AuroraManager.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teach ream's `ContainerBindings` what `container.make(...)` returns for the
|
|
3
|
+
* tokens aurora binds.
|
|
4
|
+
*
|
|
5
|
+
* ream declares that interface open on purpose: it registers its own entries
|
|
6
|
+
* and expects each package to contribute the ones it owns. Nothing filled
|
|
7
|
+
* these in, so resolving by the string token answered `unknown` and every call
|
|
8
|
+
* site had to assert a type it could not prove.
|
|
9
|
+
*
|
|
10
|
+
* Loaded from the package barrel, so importing Aurora anywhere in the
|
|
11
|
+
* application is enough — nobody writes a `declare module` of their own.
|
|
12
|
+
*
|
|
13
|
+
* Type-only, and ream stays an OPTIONAL peer: nothing here reaches a runtime
|
|
14
|
+
* import, and a `declare module` for a specifier that does not resolve is
|
|
15
|
+
* simply inert.
|
|
16
|
+
*/
|
|
17
|
+
import type { AuroraManager } from "./AuroraManager.js";
|
|
18
|
+
declare module "@c9up/ream/types" {
|
|
19
|
+
interface ContainerBindings {
|
|
20
|
+
/** The Aurora manager, bound by `AuroraProvider`. */
|
|
21
|
+
aurora: AuroraManager;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teach ream's `ContainerBindings` what `container.make(...)` returns for the
|
|
3
|
+
* tokens aurora binds.
|
|
4
|
+
*
|
|
5
|
+
* ream declares that interface open on purpose: it registers its own entries
|
|
6
|
+
* and expects each package to contribute the ones it owns. Nothing filled
|
|
7
|
+
* these in, so resolving by the string token answered `unknown` and every call
|
|
8
|
+
* site had to assert a type it could not prove.
|
|
9
|
+
*
|
|
10
|
+
* Loaded from the package barrel, so importing Aurora anywhere in the
|
|
11
|
+
* application is enough — nobody writes a `declare module` of their own.
|
|
12
|
+
*
|
|
13
|
+
* Type-only, and ream stays an OPTIONAL peer: nothing here reaches a runtime
|
|
14
|
+
* import, and a `declare module` for a specifier that does not resolve is
|
|
15
|
+
* simply inert.
|
|
16
|
+
*/
|
|
17
|
+
export {};
|
package/dist/browser.js
CHANGED
|
@@ -403,7 +403,7 @@ export const cookie = {
|
|
|
403
403
|
if (typeof document === "undefined") {
|
|
404
404
|
const scoped = cookieStoreReader?.();
|
|
405
405
|
if (scoped && Object.hasOwn(scoped, name))
|
|
406
|
-
return scoped[name];
|
|
406
|
+
return scoped[name] ?? null;
|
|
407
407
|
return cookieSeed[name] ?? null;
|
|
408
408
|
}
|
|
409
409
|
const prefix = `${encodeURIComponent(name)}=`;
|
package/dist/html.js
CHANGED
|
@@ -36,6 +36,10 @@ function classifySlots(strings) {
|
|
|
36
36
|
let insideComment = false;
|
|
37
37
|
for (let i = 0; i < strings.length - 1; i++) {
|
|
38
38
|
const segment = strings[i];
|
|
39
|
+
// `i` is bounded by the loop; naming the miss is what carries that
|
|
40
|
+
// bound into the character scan below.
|
|
41
|
+
if (segment === undefined)
|
|
42
|
+
continue;
|
|
39
43
|
for (let j = 0; j < segment.length; j++) {
|
|
40
44
|
if (insideComment) {
|
|
41
45
|
// Comments swallow everything (including stray `<` / `>`) up
|
|
@@ -77,13 +81,10 @@ function classifySlots(strings) {
|
|
|
77
81
|
* detects when scanning attribute values.
|
|
78
82
|
*/
|
|
79
83
|
function buildMarkup(strings, classification) {
|
|
80
|
-
let out = strings[0];
|
|
81
|
-
for (
|
|
82
|
-
out +=
|
|
83
|
-
|
|
84
|
-
? TEXT_NODE_MARKER
|
|
85
|
-
: attrPlaceholder(i);
|
|
86
|
-
out += strings[i + 1];
|
|
84
|
+
let out = strings[0] ?? "";
|
|
85
|
+
for (const [i, slot] of classification.entries()) {
|
|
86
|
+
out += slot.region === "text" ? TEXT_NODE_MARKER : attrPlaceholder(i);
|
|
87
|
+
out += strings[i + 1] ?? "";
|
|
87
88
|
}
|
|
88
89
|
return out;
|
|
89
90
|
}
|
|
@@ -154,7 +155,7 @@ function collectSlots(root, classification) {
|
|
|
154
155
|
const staticParts = [];
|
|
155
156
|
const slotCountInThisAttr = (parts.length - 1) / 2;
|
|
156
157
|
for (let i = 0; i < parts.length; i += 2) {
|
|
157
|
-
staticParts.push(parts[i]);
|
|
158
|
+
staticParts.push(parts[i] ?? "");
|
|
158
159
|
}
|
|
159
160
|
for (let i = 0; i < slotCountInThisAttr; i++) {
|
|
160
161
|
const slot = {
|
|
@@ -178,9 +179,9 @@ function collectSlots(root, classification) {
|
|
|
178
179
|
if (node.nodeType === 8 /* Comment */) {
|
|
179
180
|
const data = node.data;
|
|
180
181
|
if (data === MARKER) {
|
|
181
|
-
if (slotIndex >= classification.length)
|
|
182
|
-
return;
|
|
183
182
|
const cls = classification[slotIndex];
|
|
183
|
+
if (cls === undefined)
|
|
184
|
+
return;
|
|
184
185
|
if (cls.region !== "text") {
|
|
185
186
|
throw new Error(`[aurora] internal classification mismatch at slot ${slotIndex}`);
|
|
186
187
|
}
|
package/dist/http.d.ts
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* Node-free and isomorphic — uses the global `fetch` (browsers, Node 18+,
|
|
16
16
|
* Workers, Bun, Deno). Part of the client barrel.
|
|
17
17
|
*/
|
|
18
|
-
|
|
18
|
+
import { type XsrfOptions } from "./xsrf.js";
|
|
19
|
+
export interface HttpClientOptions extends XsrfOptions {
|
|
19
20
|
/** Prepended to every request URL, unless the URL is already absolute. */
|
|
20
21
|
baseURL?: string;
|
|
21
22
|
/** Headers merged into every request. */
|
|
@@ -49,6 +50,12 @@ export interface HttpRequestOptions<T = unknown> {
|
|
|
49
50
|
headers?: Record<string, string>;
|
|
50
51
|
/** Per-request bearer token override (`null` to force-omit). */
|
|
51
52
|
token?: string | null;
|
|
53
|
+
/**
|
|
54
|
+
* Turn the automatic `X-XSRF-TOKEN` header off for this request. Rarely
|
|
55
|
+
* needed: it is already a no-op cross-origin, outside a browser, and when
|
|
56
|
+
* the cookie is absent.
|
|
57
|
+
*/
|
|
58
|
+
xsrf?: boolean;
|
|
52
59
|
/** Abort signal — abort it to cancel the request (e.g. on unmount / new keystroke). */
|
|
53
60
|
signal?: AbortSignal;
|
|
54
61
|
/** Per-request timeout in ms (overrides the client default). Aborts with a `TimeoutError`. */
|
package/dist/http.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* Node-free and isomorphic — uses the global `fetch` (browsers, Node 18+,
|
|
21
21
|
* Workers, Bun, Deno). Part of the client barrel.
|
|
22
22
|
*/
|
|
23
|
+
import { xsrfHeaderFor } from "./xsrf.js";
|
|
23
24
|
/** Thrown on a non-2xx response. Carries the status, the `Response`, and the parsed body. */
|
|
24
25
|
export class HttpError extends Error {
|
|
25
26
|
status;
|
|
@@ -148,6 +149,7 @@ export class HttpClient {
|
|
|
148
149
|
#credentials;
|
|
149
150
|
#timeout;
|
|
150
151
|
#allowCrossOriginAuth;
|
|
152
|
+
#xsrf;
|
|
151
153
|
constructor(options = {}) {
|
|
152
154
|
this.#baseURL = options.baseURL ?? "";
|
|
153
155
|
this.#headers = { ...options.headers };
|
|
@@ -155,6 +157,11 @@ export class HttpClient {
|
|
|
155
157
|
this.#credentials = options.credentials;
|
|
156
158
|
this.#timeout = options.timeout;
|
|
157
159
|
this.#allowCrossOriginAuth = options.allowCrossOriginAuth ?? false;
|
|
160
|
+
this.#xsrf = {
|
|
161
|
+
xsrf: options.xsrf,
|
|
162
|
+
xsrfCookieName: options.xsrfCookieName,
|
|
163
|
+
xsrfHeaderName: options.xsrfHeaderName,
|
|
164
|
+
};
|
|
158
165
|
}
|
|
159
166
|
/** Set a default header for every subsequent request (case-insensitive replace). Chainable. */
|
|
160
167
|
setHeader(name, value) {
|
|
@@ -282,6 +289,18 @@ export class HttpClient {
|
|
|
282
289
|
(!crossOrigin || allowCrossOriginAuth)) {
|
|
283
290
|
headers.Authorization = `Bearer ${token}`;
|
|
284
291
|
}
|
|
292
|
+
// The client's half of the signed double-submit check. Same-origin only,
|
|
293
|
+
// and never over a header the caller set: an explicit value is intent.
|
|
294
|
+
const xsrf = xsrfHeaderFor(finalUrl, {
|
|
295
|
+
...this.#xsrf,
|
|
296
|
+
xsrf: options.xsrf ?? this.#xsrf.xsrf,
|
|
297
|
+
});
|
|
298
|
+
if (xsrf !== undefined) {
|
|
299
|
+
for (const [name, value] of Object.entries(xsrf)) {
|
|
300
|
+
if (!hasHeader(headers, name))
|
|
301
|
+
headers[name] = value;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
285
304
|
let payload;
|
|
286
305
|
if (body !== undefined && body !== null) {
|
|
287
306
|
if (shouldJsonEncode(body)) {
|
package/dist/hydrate.js
CHANGED
|
@@ -319,8 +319,7 @@ function hydrateTemplateResult(result, liveNodes, cleanups, mountHooks, markerCu
|
|
|
319
319
|
// between. Binding each slot on its own would have the last writer win and
|
|
320
320
|
// wipe the statics, which is what render.ts already avoids server-side.
|
|
321
321
|
const multiGroups = new Map();
|
|
322
|
-
for (
|
|
323
|
-
const slot = tpl.slots[i];
|
|
322
|
+
for (const [i, slot] of tpl.slots.entries()) {
|
|
324
323
|
const liveNode = resolvePathLive(slot.path, liveNodes);
|
|
325
324
|
if (!liveNode) {
|
|
326
325
|
// Path missed in the live DOM — SSR markup diverges from the
|
|
@@ -421,10 +420,15 @@ function resolvePathLive(path, rootNodes) {
|
|
|
421
420
|
// Collapse marker ranges at EVERY level so the live child list matches the
|
|
422
421
|
// parsed template's one-node-per-slot shape (see collapseMarkerRanges).
|
|
423
422
|
let children = collapseMarkerRanges(rootNodes);
|
|
424
|
-
|
|
425
|
-
|
|
423
|
+
const [head, ...rest] = path;
|
|
424
|
+
if (head === undefined)
|
|
425
|
+
return null;
|
|
426
|
+
let node = children[head] ?? null;
|
|
427
|
+
for (const step of rest) {
|
|
428
|
+
if (!node)
|
|
429
|
+
break;
|
|
426
430
|
children = collapseMarkerRanges(Array.from(node.childNodes));
|
|
427
|
-
node = children[
|
|
431
|
+
node = children[step] ?? null;
|
|
428
432
|
}
|
|
429
433
|
return node;
|
|
430
434
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import "./augmentations.js";
|
|
1
2
|
export type { CookieCodec, CookieOptions, PersistedSignalOptions, ShareData, StorageArea, WebStorageOptions, WindowSize, } from "./browser.js";
|
|
2
3
|
export { back, booleanCookie, clipboard, cookie, cookieSignal, cookieState, forward, getCookieStore, hash, jsonCookie, mediaQuery, navigate, online, persistedSignal, prefersDark, queryParam, redirect, reload, replace, session, setCookieStore, share, storage, visibility, WebStorage, windowSize, } from "./browser.js";
|
|
3
4
|
export { type ClassValue, clsx, cn, twMerge } from "./cn.js";
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
// ─── Client surface (node-free — safe to bundle for the browser) ──────
|
|
2
|
+
//
|
|
3
|
+
// Server-only exports (AuroraManager, Pages, renderPage, serveAssets) that pull
|
|
4
|
+
// node:fs / node:path / node:url live in `@c9up/aurora/server`. Keeping them off
|
|
5
|
+
// this barrel is what lets a browser bundle import the client primitives without
|
|
6
|
+
// the bundler dragging Node built-ins through the import graph.
|
|
7
|
+
import "./augmentations.js";
|
|
1
8
|
export { back, booleanCookie, clipboard, cookie, cookieSignal, cookieState, forward, getCookieStore, hash, jsonCookie, mediaQuery, navigate, online, persistedSignal, prefersDark, queryParam, redirect, reload, replace, session, setCookieStore, share, storage, visibility, WebStorage, windowSize, } from "./browser.js";
|
|
2
9
|
export { clsx, cn, twMerge } from "./cn.js";
|
|
3
10
|
export { command } from "./command.js";
|
package/dist/liveClient.js
CHANGED
|
@@ -76,7 +76,14 @@ export function buildLiveTransport(relayClient, http, options = {}) {
|
|
|
76
76
|
return {
|
|
77
77
|
subscribe: (channel, handler) => relayClient.subscribe(channel, handler),
|
|
78
78
|
post: (id, event, payload) => {
|
|
79
|
-
|
|
79
|
+
// A post that fails means the server never saw this interaction, so
|
|
80
|
+
// the component's server-side state and what the user is looking at
|
|
81
|
+
// have diverged — silence is the worst possible answer. Unhandled,
|
|
82
|
+
// it was also a bare `Uncaught (in promise)` with no clue which
|
|
83
|
+
// event was lost.
|
|
84
|
+
void (async () => http.post(path, { id, event, payload }))().catch((err) => {
|
|
85
|
+
console.warn(`[aurora/live] '${event}' on ${id} did not reach the server; this component is now out of sync:`, err);
|
|
86
|
+
});
|
|
80
87
|
},
|
|
81
88
|
};
|
|
82
89
|
}
|
package/dist/relay.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* `@c9up/aurora`. Node-side code that pulls it will trip on
|
|
18
18
|
* `EventSource` being undefined.
|
|
19
19
|
*/
|
|
20
|
+
import { xsrfHeaderFor } from "./xsrf.js";
|
|
20
21
|
const STATE = {
|
|
21
22
|
sse: null,
|
|
22
23
|
uid: null,
|
|
@@ -229,11 +230,11 @@ function postUnsubscribe(channel) {
|
|
|
229
230
|
}
|
|
230
231
|
/**
|
|
231
232
|
* POST a `{ uid, channel }` handshake to a relay endpoint. Sends the
|
|
232
|
-
* signed-CSRF trio
|
|
233
|
-
* the `X-XSRF-TOKEN` header plus `credentials: 'include'` so the cookie
|
|
233
|
+
* signed-CSRF trio the security layer expects: the `XSRF-TOKEN` cookie echoed
|
|
234
|
+
* as the `X-XSRF-TOKEN` header plus `credentials: 'include'` so the cookie
|
|
234
235
|
* itself rides along. Without both, the POST is rejected by the signed
|
|
235
|
-
* double-submit guard.
|
|
236
|
-
* `
|
|
236
|
+
* double-submit guard. The header comes from the one reader in `xsrf.ts`,
|
|
237
|
+
* which `HttpClient` uses too.
|
|
237
238
|
*/
|
|
238
239
|
async function postHandshake(url, channel) {
|
|
239
240
|
const headers = {
|
|
@@ -241,9 +242,7 @@ async function postHandshake(url, channel) {
|
|
|
241
242
|
};
|
|
242
243
|
if (CONFIG.bearer)
|
|
243
244
|
headers.authorization = `Bearer ${CONFIG.bearer}`;
|
|
244
|
-
|
|
245
|
-
if (xsrf !== null)
|
|
246
|
-
headers["x-xsrf-token"] = xsrf;
|
|
245
|
+
Object.assign(headers, xsrfHeaderFor(url) ?? {});
|
|
247
246
|
const res = await fetch(url, {
|
|
248
247
|
method: "POST",
|
|
249
248
|
headers,
|
|
@@ -254,27 +253,6 @@ async function postHandshake(url, channel) {
|
|
|
254
253
|
throw new Error(`HTTP ${res.status}`);
|
|
255
254
|
}
|
|
256
255
|
}
|
|
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
|
-
if (!match)
|
|
267
|
-
return null;
|
|
268
|
-
try {
|
|
269
|
-
return decodeURIComponent(match[1]);
|
|
270
|
-
}
|
|
271
|
-
catch {
|
|
272
|
-
// A malformed cookie must not break subscribe/unsubscribe handshakes. The
|
|
273
|
-
// server will reject an invalid token normally; the client should not throw
|
|
274
|
-
// before it even sends the request.
|
|
275
|
-
return match[1];
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
256
|
function safeJson(raw) {
|
|
279
257
|
if (typeof raw !== "string")
|
|
280
258
|
return null;
|
package/dist/render.js
CHANGED
|
@@ -80,8 +80,7 @@ export function mount(result, cleanups, mounted, mountHooks) {
|
|
|
80
80
|
// none, and silently never bound. Fragments exist precisely so a component
|
|
81
81
|
// needs no wrapper element; they must not cost the slots that follow them.
|
|
82
82
|
const resolved = tpl.slots.map((slot) => resolvePath(fragment, slot.path));
|
|
83
|
-
for (
|
|
84
|
-
const slot = tpl.slots[i];
|
|
83
|
+
for (const [i, slot] of tpl.slots.entries()) {
|
|
85
84
|
const node = resolved[i];
|
|
86
85
|
if (node === null || node === undefined) {
|
|
87
86
|
// Path didn't resolve — skip this binding rather than crash (see
|
package/dist/rpc.js
CHANGED
|
@@ -21,43 +21,25 @@
|
|
|
21
21
|
import { createRpcClient as createCometRpcClient } from "@c9up/comet";
|
|
22
22
|
import { HttpClient } from "./http.js";
|
|
23
23
|
export { isRpcError, RpcError, } from "@c9up/comet";
|
|
24
|
-
/**
|
|
25
|
-
* Read a cookie's raw value from `document.cookie`. Returns `undefined`
|
|
26
|
-
* server-side (no `document`) or when the cookie is absent. The value is sent
|
|
27
|
-
* verbatim — double-submit compares it byte-for-byte against the cookie, so it
|
|
28
|
-
* must not be decoded.
|
|
29
|
-
*/
|
|
30
|
-
function readCookie(name) {
|
|
31
|
-
if (typeof document === "undefined")
|
|
32
|
-
return undefined;
|
|
33
|
-
const prefix = `${name}=`;
|
|
34
|
-
for (const part of document.cookie.split(";")) {
|
|
35
|
-
const trimmed = part.trimStart();
|
|
36
|
-
if (trimmed.startsWith(prefix))
|
|
37
|
-
return trimmed.slice(prefix.length);
|
|
38
|
-
}
|
|
39
|
-
return undefined;
|
|
40
|
-
}
|
|
41
24
|
/**
|
|
42
25
|
* Create a JSON-RPC client bound to aurora's HttpClient transport. Inherits the
|
|
43
26
|
* supplied (or a fresh) HttpClient's base URL, auth headers, and timeouts, and
|
|
44
27
|
* (by default) auto-attaches the `X-XSRF-TOKEN` CSRF header from the cookie.
|
|
45
28
|
*/
|
|
46
29
|
export function createRpcClient(options = {}) {
|
|
47
|
-
const http = options.http ??
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
30
|
+
const http = options.http ??
|
|
31
|
+
new HttpClient({
|
|
32
|
+
headers: options.headers,
|
|
33
|
+
xsrf: options.xsrf,
|
|
34
|
+
xsrfCookieName: options.xsrfCookieName,
|
|
35
|
+
xsrfHeaderName: options.xsrfHeaderName,
|
|
36
|
+
});
|
|
51
37
|
return createCometRpcClient({
|
|
52
38
|
url: options.url,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
headers = { [headerName]: token };
|
|
59
|
-
}
|
|
60
|
-
return http.post(url, body, { signal, headers });
|
|
61
|
-
},
|
|
39
|
+
// The header comes from the transport, which attaches it for every
|
|
40
|
+
// request it sends. Adding it here as well meant a caller who passed
|
|
41
|
+
// their own `http` got a client that read the cookie and one that did
|
|
42
|
+
// not, depending on which constructor argument they used.
|
|
43
|
+
transport: (url, body, { signal }) => http.post(url, body, { signal, xsrf: options.xsrf }),
|
|
62
44
|
});
|
|
63
45
|
}
|
package/dist/ssr.js
CHANGED
|
@@ -45,19 +45,31 @@ function stringifyTemplateResult(result) {
|
|
|
45
45
|
// the matching value, and consume the closing `"` from the next
|
|
46
46
|
// segment. This three-step coordination is why the loop holds a
|
|
47
47
|
// `pendingClosingQuote` flag.
|
|
48
|
-
|
|
48
|
+
// Which quote closes the directive currently being skipped, so the closing
|
|
49
|
+
// one consumed is the one that was opened. Undefined when none is pending.
|
|
50
|
+
let pendingClosingQuote;
|
|
49
51
|
const scanner = new TagScanner();
|
|
50
|
-
for (
|
|
51
|
-
let segment =
|
|
52
|
-
if (pendingClosingQuote) {
|
|
53
|
-
segment =
|
|
54
|
-
|
|
52
|
+
for (const [i, raw] of strings.entries()) {
|
|
53
|
+
let segment = raw;
|
|
54
|
+
if (pendingClosingQuote !== undefined) {
|
|
55
|
+
segment =
|
|
56
|
+
pendingClosingQuote === '"'
|
|
57
|
+
? segment.replace(/^"/, "")
|
|
58
|
+
: segment.replace(/^'/, "");
|
|
59
|
+
pendingClosingQuote = undefined;
|
|
55
60
|
}
|
|
56
|
-
|
|
61
|
+
// Both quote styles. Matching only `="` left `@click='${handler}'`
|
|
62
|
+
// unrecognised, so the handler fell through to `stringifyValue`, which
|
|
63
|
+
// CALLED it — a client event handler running on the server, its return
|
|
64
|
+
// value written into the HTML, and any exception swallowed.
|
|
65
|
+
const directiveMatch = segment.match(/\s([@?.][\w-]+)=("|'|)$/);
|
|
57
66
|
const skipValue = directiveMatch !== null;
|
|
58
67
|
if (directiveMatch) {
|
|
59
|
-
|
|
60
|
-
|
|
68
|
+
const [whole = "", , quote] = directiveMatch;
|
|
69
|
+
segment = segment.slice(0, segment.length - whole.length);
|
|
70
|
+
// Only a quoted directive leaves a closing quote to swallow.
|
|
71
|
+
pendingClosingQuote =
|
|
72
|
+
quote === '"' ? '"' : quote === "'" ? "'" : undefined;
|
|
61
73
|
}
|
|
62
74
|
out += segment;
|
|
63
75
|
scanner.consume(segment);
|
|
@@ -147,10 +159,10 @@ function stringifyValue(value, inAttribute) {
|
|
|
147
159
|
if (isSignal(value))
|
|
148
160
|
return stringifyValue(value(), inAttribute);
|
|
149
161
|
if (typeof value === "function") {
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
162
|
+
// A function here is a reactive expression — `class="${() => …}"` in an
|
|
163
|
+
// attribute, `${() => …}` in text — and is evaluated eagerly
|
|
164
|
+
// server-side. Directive values (`@click`, `?disabled`, `.prop`) never
|
|
165
|
+
// reach this point: the scanner skips them, whatever quoting they use.
|
|
154
166
|
try {
|
|
155
167
|
return stringifyValue(value(), inAttribute);
|
|
156
168
|
}
|
package/dist/xsrf.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Echoing the CSRF cookie back as a header, in one place.
|
|
3
|
+
*
|
|
4
|
+
* The signed double-submit guard on the server reads `XSRF-TOKEN` from the
|
|
5
|
+
* cookie jar and compares it to `X-XSRF-TOKEN` on the request. A browser sends
|
|
6
|
+
* the cookie on its own; the header is the client's half, and it is the half
|
|
7
|
+
* that says "this request came from our own page", because a cross-site caller
|
|
8
|
+
* can cause the cookie to ride along but cannot read it.
|
|
9
|
+
*
|
|
10
|
+
* This lived three times over. `rpc.ts` and `relay.ts` each had a copy, and
|
|
11
|
+
* both described theirs as mirroring `HttpClient.#retrieveXsrfToken` — a method
|
|
12
|
+
* `HttpClient` never had. So the client the docs tell you to submit a form with
|
|
13
|
+
* sent no header at all, and every POST through it was refused the moment an
|
|
14
|
+
* application turned CSRF on. One reader now, used by all three.
|
|
15
|
+
*/
|
|
16
|
+
/** The cookie the server seeds. */
|
|
17
|
+
export declare const XSRF_COOKIE_NAME = "XSRF-TOKEN";
|
|
18
|
+
/** The header it is echoed in (the Axios/Angular convention the server reads). */
|
|
19
|
+
export declare const XSRF_HEADER_NAME = "X-XSRF-TOKEN";
|
|
20
|
+
/** Per-client switches for the automatic header. */
|
|
21
|
+
export interface XsrfOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Echo the CSRF cookie as a header on same-origin requests. Default `true`.
|
|
24
|
+
* A no-op outside a browser and when the cookie is absent — a bearer-authed
|
|
25
|
+
* API is CSRF-exempt and seeds no cookie, so nothing is sent there either.
|
|
26
|
+
*/
|
|
27
|
+
xsrf?: boolean;
|
|
28
|
+
/** Cookie to read the token from. Default `XSRF-TOKEN`. */
|
|
29
|
+
xsrfCookieName?: string;
|
|
30
|
+
/** Header to echo it in. Default `X-XSRF-TOKEN`. */
|
|
31
|
+
xsrfHeaderName?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Read a cookie's raw value from `document.cookie`.
|
|
35
|
+
*
|
|
36
|
+
* Verbatim, never decoded: the server compares the header to the cookie
|
|
37
|
+
* byte-for-byte. The token is hex + `.` + base64url, so there is nothing a
|
|
38
|
+
* decode could change — but a decode that ever did change something would turn
|
|
39
|
+
* a valid request into a rejected one, silently.
|
|
40
|
+
*
|
|
41
|
+
* Returns `undefined` server-side (no `document`) or when the cookie is absent.
|
|
42
|
+
*/
|
|
43
|
+
export declare function readXsrfCookie(name?: string): string | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Is this URL served by the page's own origin?
|
|
46
|
+
*
|
|
47
|
+
* The question is not "does it match the client's baseURL" — a client whose
|
|
48
|
+
* baseURL IS a third-party API would pass that one. A CSRF token authenticates
|
|
49
|
+
* the page's session; sending it anywhere else hands a working token to whoever
|
|
50
|
+
* runs that host.
|
|
51
|
+
*
|
|
52
|
+
* A relative URL is same-origin by construction. Outside a browser there is no
|
|
53
|
+
* page and no cookie, so the answer is no.
|
|
54
|
+
*/
|
|
55
|
+
export declare function isSameOriginAsPage(url: string): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* The header to add for `url`, or `undefined` when there is nothing to send.
|
|
58
|
+
*
|
|
59
|
+
* Absent cookie, disabled, cross-origin target, or no browser: nothing. The
|
|
60
|
+
* caller merges the result rather than being handed an empty object, so a call
|
|
61
|
+
* site cannot accidentally overwrite a header it set itself.
|
|
62
|
+
*/
|
|
63
|
+
export declare function xsrfHeaderFor(url: string, options?: XsrfOptions): Record<string, string> | undefined;
|
package/dist/xsrf.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Echoing the CSRF cookie back as a header, in one place.
|
|
4
|
+
*
|
|
5
|
+
* The signed double-submit guard on the server reads `XSRF-TOKEN` from the
|
|
6
|
+
* cookie jar and compares it to `X-XSRF-TOKEN` on the request. A browser sends
|
|
7
|
+
* the cookie on its own; the header is the client's half, and it is the half
|
|
8
|
+
* that says "this request came from our own page", because a cross-site caller
|
|
9
|
+
* can cause the cookie to ride along but cannot read it.
|
|
10
|
+
*
|
|
11
|
+
* This lived three times over. `rpc.ts` and `relay.ts` each had a copy, and
|
|
12
|
+
* both described theirs as mirroring `HttpClient.#retrieveXsrfToken` — a method
|
|
13
|
+
* `HttpClient` never had. So the client the docs tell you to submit a form with
|
|
14
|
+
* sent no header at all, and every POST through it was refused the moment an
|
|
15
|
+
* application turned CSRF on. One reader now, used by all three.
|
|
16
|
+
*/
|
|
17
|
+
/** The cookie the server seeds. */
|
|
18
|
+
export const XSRF_COOKIE_NAME = "XSRF-TOKEN";
|
|
19
|
+
/** The header it is echoed in (the Axios/Angular convention the server reads). */
|
|
20
|
+
export const XSRF_HEADER_NAME = "X-XSRF-TOKEN";
|
|
21
|
+
/**
|
|
22
|
+
* Read a cookie's raw value from `document.cookie`.
|
|
23
|
+
*
|
|
24
|
+
* Verbatim, never decoded: the server compares the header to the cookie
|
|
25
|
+
* byte-for-byte. The token is hex + `.` + base64url, so there is nothing a
|
|
26
|
+
* decode could change — but a decode that ever did change something would turn
|
|
27
|
+
* a valid request into a rejected one, silently.
|
|
28
|
+
*
|
|
29
|
+
* Returns `undefined` server-side (no `document`) or when the cookie is absent.
|
|
30
|
+
*/
|
|
31
|
+
export function readXsrfCookie(name = XSRF_COOKIE_NAME) {
|
|
32
|
+
if (typeof document === "undefined")
|
|
33
|
+
return undefined;
|
|
34
|
+
const prefix = `${name}=`;
|
|
35
|
+
for (const part of document.cookie.split(";")) {
|
|
36
|
+
const trimmed = part.trimStart();
|
|
37
|
+
if (trimmed.startsWith(prefix))
|
|
38
|
+
return trimmed.slice(prefix.length);
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Is this URL served by the page's own origin?
|
|
44
|
+
*
|
|
45
|
+
* The question is not "does it match the client's baseURL" — a client whose
|
|
46
|
+
* baseURL IS a third-party API would pass that one. A CSRF token authenticates
|
|
47
|
+
* the page's session; sending it anywhere else hands a working token to whoever
|
|
48
|
+
* runs that host.
|
|
49
|
+
*
|
|
50
|
+
* A relative URL is same-origin by construction. Outside a browser there is no
|
|
51
|
+
* page and no cookie, so the answer is no.
|
|
52
|
+
*/
|
|
53
|
+
export function isSameOriginAsPage(url) {
|
|
54
|
+
if (typeof window === "undefined")
|
|
55
|
+
return false;
|
|
56
|
+
if (!/^[a-z][a-z\d+\-.]*:\/\//i.test(url))
|
|
57
|
+
return true;
|
|
58
|
+
try {
|
|
59
|
+
return new URL(url).origin === window.location.origin;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The header to add for `url`, or `undefined` when there is nothing to send.
|
|
67
|
+
*
|
|
68
|
+
* Absent cookie, disabled, cross-origin target, or no browser: nothing. The
|
|
69
|
+
* caller merges the result rather than being handed an empty object, so a call
|
|
70
|
+
* site cannot accidentally overwrite a header it set itself.
|
|
71
|
+
*/
|
|
72
|
+
export function xsrfHeaderFor(url, options = {}) {
|
|
73
|
+
if (options.xsrf === false)
|
|
74
|
+
return undefined;
|
|
75
|
+
if (!isSameOriginAsPage(url))
|
|
76
|
+
return undefined;
|
|
77
|
+
const token = readXsrfCookie(options.xsrfCookieName ?? XSRF_COOKIE_NAME);
|
|
78
|
+
if (token === undefined)
|
|
79
|
+
return undefined;
|
|
80
|
+
return { [options.xsrfHeaderName ?? XSRF_HEADER_NAME]: token };
|
|
81
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@c9up/aurora",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.36",
|
|
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",
|
|
@@ -56,12 +56,13 @@
|
|
|
56
56
|
"@biomejs/biome": "^2.4.10",
|
|
57
57
|
"@c9up/comet": "^0.1.0",
|
|
58
58
|
"@types/node": "^22.19.15",
|
|
59
|
-
"@vitest/browser": "4.1.
|
|
60
|
-
"@vitest/browser-playwright": "4.1.
|
|
61
|
-
"
|
|
59
|
+
"@vitest/browser": "4.1.11",
|
|
60
|
+
"@vitest/browser-playwright": "4.1.11",
|
|
61
|
+
"jsdom": "^30.0.1",
|
|
62
62
|
"playwright": "^1.61.1",
|
|
63
63
|
"typescript": "^6.0.2",
|
|
64
|
-
"vitest": "4.1.9"
|
|
64
|
+
"vitest": "4.1.9",
|
|
65
|
+
"@c9up/ream": "^0.2.0"
|
|
65
66
|
},
|
|
66
67
|
"files": [
|
|
67
68
|
"LICENSE",
|
package/src/AuroraProvider.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* and skip the route auto-registration silently.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import "./augmentations.js";
|
|
19
20
|
import { isAbsolute, resolve as resolvePath } from "node:path";
|
|
20
21
|
import { fileURLToPath } from "node:url";
|
|
21
22
|
import { AuroraManager, type AuroraManagerConfig } from "./AuroraManager.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teach ream's `ContainerBindings` what `container.make(...)` returns for the
|
|
3
|
+
* tokens aurora binds.
|
|
4
|
+
*
|
|
5
|
+
* ream declares that interface open on purpose: it registers its own entries
|
|
6
|
+
* and expects each package to contribute the ones it owns. Nothing filled
|
|
7
|
+
* these in, so resolving by the string token answered `unknown` and every call
|
|
8
|
+
* site had to assert a type it could not prove.
|
|
9
|
+
*
|
|
10
|
+
* Loaded from the package barrel, so importing Aurora anywhere in the
|
|
11
|
+
* application is enough — nobody writes a `declare module` of their own.
|
|
12
|
+
*
|
|
13
|
+
* Type-only, and ream stays an OPTIONAL peer: nothing here reaches a runtime
|
|
14
|
+
* import, and a `declare module` for a specifier that does not resolve is
|
|
15
|
+
* simply inert.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// Referenced so the augmentation below resolves the module it augments.
|
|
19
|
+
import type {} from "@c9up/ream/types";
|
|
20
|
+
|
|
21
|
+
import type { AuroraManager } from "./AuroraManager.js";
|
|
22
|
+
|
|
23
|
+
declare module "@c9up/ream/types" {
|
|
24
|
+
interface ContainerBindings {
|
|
25
|
+
/** The Aurora manager, bound by `AuroraProvider`. */
|
|
26
|
+
aurora: AuroraManager;
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/browser.ts
CHANGED
|
@@ -510,7 +510,7 @@ export const cookie = {
|
|
|
510
510
|
get(name: string): string | null {
|
|
511
511
|
if (typeof document === "undefined") {
|
|
512
512
|
const scoped = cookieStoreReader?.();
|
|
513
|
-
if (scoped && Object.hasOwn(scoped, name)) return scoped[name];
|
|
513
|
+
if (scoped && Object.hasOwn(scoped, name)) return scoped[name] ?? null;
|
|
514
514
|
return cookieSeed[name] ?? null;
|
|
515
515
|
}
|
|
516
516
|
const prefix = `${encodeURIComponent(name)}=`;
|
package/src/html.ts
CHANGED
|
@@ -64,6 +64,9 @@ function classifySlots(strings: readonly string[]): RawSlot[] {
|
|
|
64
64
|
let insideComment = false;
|
|
65
65
|
for (let i = 0; i < strings.length - 1; i++) {
|
|
66
66
|
const segment = strings[i];
|
|
67
|
+
// `i` is bounded by the loop; naming the miss is what carries that
|
|
68
|
+
// bound into the character scan below.
|
|
69
|
+
if (segment === undefined) continue;
|
|
67
70
|
for (let j = 0; j < segment.length; j++) {
|
|
68
71
|
if (insideComment) {
|
|
69
72
|
// Comments swallow everything (including stray `<` / `>`) up
|
|
@@ -111,13 +114,10 @@ function buildMarkup(
|
|
|
111
114
|
strings: readonly string[],
|
|
112
115
|
classification: readonly RawSlot[],
|
|
113
116
|
): string {
|
|
114
|
-
let out = strings[0];
|
|
115
|
-
for (
|
|
116
|
-
out +=
|
|
117
|
-
|
|
118
|
-
? TEXT_NODE_MARKER
|
|
119
|
-
: attrPlaceholder(i);
|
|
120
|
-
out += strings[i + 1];
|
|
117
|
+
let out = strings[0] ?? "";
|
|
118
|
+
for (const [i, slot] of classification.entries()) {
|
|
119
|
+
out += slot.region === "text" ? TEXT_NODE_MARKER : attrPlaceholder(i);
|
|
120
|
+
out += strings[i + 1] ?? "";
|
|
121
121
|
}
|
|
122
122
|
return out;
|
|
123
123
|
}
|
|
@@ -195,7 +195,7 @@ function collectSlots(
|
|
|
195
195
|
const staticParts: string[] = [];
|
|
196
196
|
const slotCountInThisAttr = (parts.length - 1) / 2;
|
|
197
197
|
for (let i = 0; i < parts.length; i += 2) {
|
|
198
|
-
staticParts.push(parts[i]);
|
|
198
|
+
staticParts.push(parts[i] ?? "");
|
|
199
199
|
}
|
|
200
200
|
for (let i = 0; i < slotCountInThisAttr; i++) {
|
|
201
201
|
const slot: AttrSlot = {
|
|
@@ -220,8 +220,8 @@ function collectSlots(
|
|
|
220
220
|
if (node.nodeType === 8 /* Comment */) {
|
|
221
221
|
const data = (node as Comment).data;
|
|
222
222
|
if (data === MARKER) {
|
|
223
|
-
if (slotIndex >= classification.length) return;
|
|
224
223
|
const cls = classification[slotIndex];
|
|
224
|
+
if (cls === undefined) return;
|
|
225
225
|
if (cls.region !== "text") {
|
|
226
226
|
throw new Error(
|
|
227
227
|
`[aurora] internal classification mismatch at slot ${slotIndex}`,
|
package/src/http.ts
CHANGED
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
* Workers, Bun, Deno). Part of the client barrel.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
import { type XsrfOptions, xsrfHeaderFor } from "./xsrf.js";
|
|
25
|
+
|
|
26
|
+
export interface HttpClientOptions extends XsrfOptions {
|
|
25
27
|
/** Prepended to every request URL, unless the URL is already absolute. */
|
|
26
28
|
baseURL?: string;
|
|
27
29
|
/** Headers merged into every request. */
|
|
@@ -56,6 +58,12 @@ export interface HttpRequestOptions<T = unknown> {
|
|
|
56
58
|
headers?: Record<string, string>;
|
|
57
59
|
/** Per-request bearer token override (`null` to force-omit). */
|
|
58
60
|
token?: string | null;
|
|
61
|
+
/**
|
|
62
|
+
* Turn the automatic `X-XSRF-TOKEN` header off for this request. Rarely
|
|
63
|
+
* needed: it is already a no-op cross-origin, outside a browser, and when
|
|
64
|
+
* the cookie is absent.
|
|
65
|
+
*/
|
|
66
|
+
xsrf?: boolean;
|
|
59
67
|
/** Abort signal — abort it to cancel the request (e.g. on unmount / new keystroke). */
|
|
60
68
|
signal?: AbortSignal;
|
|
61
69
|
/** Per-request timeout in ms (overrides the client default). Aborts with a `TimeoutError`. */
|
|
@@ -216,6 +224,7 @@ export class HttpClient {
|
|
|
216
224
|
readonly #credentials?: RequestCredentials;
|
|
217
225
|
readonly #timeout?: number;
|
|
218
226
|
readonly #allowCrossOriginAuth: boolean;
|
|
227
|
+
readonly #xsrf: XsrfOptions;
|
|
219
228
|
|
|
220
229
|
constructor(options: HttpClientOptions = {}) {
|
|
221
230
|
this.#baseURL = options.baseURL ?? "";
|
|
@@ -224,6 +233,11 @@ export class HttpClient {
|
|
|
224
233
|
this.#credentials = options.credentials;
|
|
225
234
|
this.#timeout = options.timeout;
|
|
226
235
|
this.#allowCrossOriginAuth = options.allowCrossOriginAuth ?? false;
|
|
236
|
+
this.#xsrf = {
|
|
237
|
+
xsrf: options.xsrf,
|
|
238
|
+
xsrfCookieName: options.xsrfCookieName,
|
|
239
|
+
xsrfHeaderName: options.xsrfHeaderName,
|
|
240
|
+
};
|
|
227
241
|
}
|
|
228
242
|
|
|
229
243
|
/** Set a default header for every subsequent request (case-insensitive replace). Chainable. */
|
|
@@ -392,6 +406,18 @@ export class HttpClient {
|
|
|
392
406
|
headers.Authorization = `Bearer ${token}`;
|
|
393
407
|
}
|
|
394
408
|
|
|
409
|
+
// The client's half of the signed double-submit check. Same-origin only,
|
|
410
|
+
// and never over a header the caller set: an explicit value is intent.
|
|
411
|
+
const xsrf = xsrfHeaderFor(finalUrl, {
|
|
412
|
+
...this.#xsrf,
|
|
413
|
+
xsrf: options.xsrf ?? this.#xsrf.xsrf,
|
|
414
|
+
});
|
|
415
|
+
if (xsrf !== undefined) {
|
|
416
|
+
for (const [name, value] of Object.entries(xsrf)) {
|
|
417
|
+
if (!hasHeader(headers, name)) headers[name] = value;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
395
421
|
let payload: BodyInit | undefined;
|
|
396
422
|
if (body !== undefined && body !== null) {
|
|
397
423
|
if (shouldJsonEncode(body)) {
|
package/src/hydrate.ts
CHANGED
|
@@ -416,8 +416,7 @@ function hydrateTemplateResult(
|
|
|
416
416
|
// wipe the statics, which is what render.ts already avoids server-side.
|
|
417
417
|
const multiGroups = new Map<string, MultiAttrGroup>();
|
|
418
418
|
|
|
419
|
-
for (
|
|
420
|
-
const slot = tpl.slots[i];
|
|
419
|
+
for (const [i, slot] of tpl.slots.entries()) {
|
|
421
420
|
const liveNode = resolvePathLive(slot.path, liveNodes);
|
|
422
421
|
if (!liveNode) {
|
|
423
422
|
// Path missed in the live DOM — SSR markup diverges from the
|
|
@@ -554,10 +553,13 @@ function resolvePathLive(path: NodePath, rootNodes: ChildNode[]): Node | null {
|
|
|
554
553
|
// Collapse marker ranges at EVERY level so the live child list matches the
|
|
555
554
|
// parsed template's one-node-per-slot shape (see collapseMarkerRanges).
|
|
556
555
|
let children = collapseMarkerRanges(rootNodes);
|
|
557
|
-
|
|
558
|
-
|
|
556
|
+
const [head, ...rest] = path;
|
|
557
|
+
if (head === undefined) return null;
|
|
558
|
+
let node: Node | null = children[head] ?? null;
|
|
559
|
+
for (const step of rest) {
|
|
560
|
+
if (!node) break;
|
|
559
561
|
children = collapseMarkerRanges(Array.from(node.childNodes));
|
|
560
|
-
node = children[
|
|
562
|
+
node = children[step] ?? null;
|
|
561
563
|
}
|
|
562
564
|
return node;
|
|
563
565
|
}
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// node:fs / node:path / node:url live in `@c9up/aurora/server`. Keeping them off
|
|
5
5
|
// this barrel is what lets a browser bundle import the client primitives without
|
|
6
6
|
// the bundler dragging Node built-ins through the import graph.
|
|
7
|
+
import "./augmentations.js";
|
|
8
|
+
|
|
7
9
|
export type {
|
|
8
10
|
CookieCodec,
|
|
9
11
|
CookieOptions,
|
package/src/liveClient.ts
CHANGED
|
@@ -118,7 +118,19 @@ export function buildLiveTransport(
|
|
|
118
118
|
subscribe: (channel, handler) =>
|
|
119
119
|
relayClient.subscribe<SlotPatch[]>(channel, handler),
|
|
120
120
|
post: (id, event, payload) => {
|
|
121
|
-
|
|
121
|
+
// A post that fails means the server never saw this interaction, so
|
|
122
|
+
// the component's server-side state and what the user is looking at
|
|
123
|
+
// have diverged — silence is the worst possible answer. Unhandled,
|
|
124
|
+
// it was also a bare `Uncaught (in promise)` with no clue which
|
|
125
|
+
// event was lost.
|
|
126
|
+
void (async () => http.post(path, { id, event, payload }))().catch(
|
|
127
|
+
(err: unknown) => {
|
|
128
|
+
console.warn(
|
|
129
|
+
`[aurora/live] '${event}' on ${id} did not reach the server; this component is now out of sync:`,
|
|
130
|
+
err,
|
|
131
|
+
);
|
|
132
|
+
},
|
|
133
|
+
);
|
|
122
134
|
},
|
|
123
135
|
};
|
|
124
136
|
}
|
package/src/relay.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* `EventSource` being undefined.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { xsrfHeaderFor } from "./xsrf.js";
|
|
22
|
+
|
|
21
23
|
/**
|
|
22
24
|
* Connection lifecycle status. Mirrors `@adonisjs/transmit-client`'s
|
|
23
25
|
* `TransmitStatus` (minus `initializing`, which the singleton never
|
|
@@ -313,19 +315,18 @@ function postUnsubscribe(channel: string): Promise<void> {
|
|
|
313
315
|
|
|
314
316
|
/**
|
|
315
317
|
* POST a `{ uid, channel }` handshake to a relay endpoint. Sends the
|
|
316
|
-
* signed-CSRF trio
|
|
317
|
-
* the `X-XSRF-TOKEN` header plus `credentials: 'include'` so the cookie
|
|
318
|
+
* signed-CSRF trio the security layer expects: the `XSRF-TOKEN` cookie echoed
|
|
319
|
+
* as the `X-XSRF-TOKEN` header plus `credentials: 'include'` so the cookie
|
|
318
320
|
* itself rides along. Without both, the POST is rejected by the signed
|
|
319
|
-
* double-submit guard.
|
|
320
|
-
* `
|
|
321
|
+
* double-submit guard. The header comes from the one reader in `xsrf.ts`,
|
|
322
|
+
* which `HttpClient` uses too.
|
|
321
323
|
*/
|
|
322
324
|
async function postHandshake(url: string, channel: string): Promise<void> {
|
|
323
325
|
const headers: Record<string, string> = {
|
|
324
326
|
"content-type": "application/json",
|
|
325
327
|
};
|
|
326
328
|
if (CONFIG.bearer) headers.authorization = `Bearer ${CONFIG.bearer}`;
|
|
327
|
-
|
|
328
|
-
if (xsrf !== null) headers["x-xsrf-token"] = xsrf;
|
|
329
|
+
Object.assign(headers, xsrfHeaderFor(url) ?? {});
|
|
329
330
|
const res = await fetch(url, {
|
|
330
331
|
method: "POST",
|
|
331
332
|
headers,
|
|
@@ -337,25 +338,6 @@ async function postHandshake(url: string, channel: string): Promise<void> {
|
|
|
337
338
|
}
|
|
338
339
|
}
|
|
339
340
|
|
|
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
|
-
if (!match) return null;
|
|
349
|
-
try {
|
|
350
|
-
return decodeURIComponent(match[1]);
|
|
351
|
-
} catch {
|
|
352
|
-
// A malformed cookie must not break subscribe/unsubscribe handshakes. The
|
|
353
|
-
// server will reject an invalid token normally; the client should not throw
|
|
354
|
-
// before it even sends the request.
|
|
355
|
-
return match[1];
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
341
|
function safeJson<T>(raw: unknown): T | null {
|
|
360
342
|
if (typeof raw !== "string") return null;
|
|
361
343
|
try {
|
package/src/render.ts
CHANGED
|
@@ -119,8 +119,7 @@ export function mount(
|
|
|
119
119
|
resolvePath(fragment, slot.path),
|
|
120
120
|
);
|
|
121
121
|
|
|
122
|
-
for (
|
|
123
|
-
const slot = tpl.slots[i];
|
|
122
|
+
for (const [i, slot] of tpl.slots.entries()) {
|
|
124
123
|
const node = resolved[i];
|
|
125
124
|
if (node === null || node === undefined) {
|
|
126
125
|
// Path didn't resolve — skip this binding rather than crash (see
|
package/src/rpc.ts
CHANGED
|
@@ -54,41 +54,27 @@ export interface RpcClientOptions {
|
|
|
54
54
|
xsrfHeaderName?: string;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
/**
|
|
58
|
-
* Read a cookie's raw value from `document.cookie`. Returns `undefined`
|
|
59
|
-
* server-side (no `document`) or when the cookie is absent. The value is sent
|
|
60
|
-
* verbatim — double-submit compares it byte-for-byte against the cookie, so it
|
|
61
|
-
* must not be decoded.
|
|
62
|
-
*/
|
|
63
|
-
function readCookie(name: string): string | undefined {
|
|
64
|
-
if (typeof document === "undefined") return undefined;
|
|
65
|
-
const prefix = `${name}=`;
|
|
66
|
-
for (const part of document.cookie.split(";")) {
|
|
67
|
-
const trimmed = part.trimStart();
|
|
68
|
-
if (trimmed.startsWith(prefix)) return trimmed.slice(prefix.length);
|
|
69
|
-
}
|
|
70
|
-
return undefined;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
57
|
/**
|
|
74
58
|
* Create a JSON-RPC client bound to aurora's HttpClient transport. Inherits the
|
|
75
59
|
* supplied (or a fresh) HttpClient's base URL, auth headers, and timeouts, and
|
|
76
60
|
* (by default) auto-attaches the `X-XSRF-TOKEN` CSRF header from the cookie.
|
|
77
61
|
*/
|
|
78
62
|
export function createRpcClient(options: RpcClientOptions = {}): RpcClient {
|
|
79
|
-
const http =
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
63
|
+
const http =
|
|
64
|
+
options.http ??
|
|
65
|
+
new HttpClient({
|
|
66
|
+
headers: options.headers,
|
|
67
|
+
xsrf: options.xsrf,
|
|
68
|
+
xsrfCookieName: options.xsrfCookieName,
|
|
69
|
+
xsrfHeaderName: options.xsrfHeaderName,
|
|
70
|
+
});
|
|
83
71
|
return createCometRpcClient({
|
|
84
72
|
url: options.url,
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
return http.post<unknown>(url, body, { signal, headers });
|
|
92
|
-
},
|
|
73
|
+
// The header comes from the transport, which attaches it for every
|
|
74
|
+
// request it sends. Adding it here as well meant a caller who passed
|
|
75
|
+
// their own `http` got a client that read the cookie and one that did
|
|
76
|
+
// not, depending on which constructor argument they used.
|
|
77
|
+
transport: (url, body, { signal }) =>
|
|
78
|
+
http.post<unknown>(url, body, { signal, xsrf: options.xsrf }),
|
|
93
79
|
});
|
|
94
80
|
}
|
package/src/ssr.ts
CHANGED
|
@@ -49,19 +49,31 @@ function stringifyTemplateResult(result: TemplateResult): string {
|
|
|
49
49
|
// the matching value, and consume the closing `"` from the next
|
|
50
50
|
// segment. This three-step coordination is why the loop holds a
|
|
51
51
|
// `pendingClosingQuote` flag.
|
|
52
|
-
|
|
52
|
+
// Which quote closes the directive currently being skipped, so the closing
|
|
53
|
+
// one consumed is the one that was opened. Undefined when none is pending.
|
|
54
|
+
let pendingClosingQuote: '"' | "'" | undefined;
|
|
53
55
|
const scanner = new TagScanner();
|
|
54
|
-
for (
|
|
55
|
-
let segment =
|
|
56
|
-
if (pendingClosingQuote) {
|
|
57
|
-
segment =
|
|
58
|
-
|
|
56
|
+
for (const [i, raw] of strings.entries()) {
|
|
57
|
+
let segment = raw;
|
|
58
|
+
if (pendingClosingQuote !== undefined) {
|
|
59
|
+
segment =
|
|
60
|
+
pendingClosingQuote === '"'
|
|
61
|
+
? segment.replace(/^"/, "")
|
|
62
|
+
: segment.replace(/^'/, "");
|
|
63
|
+
pendingClosingQuote = undefined;
|
|
59
64
|
}
|
|
60
|
-
|
|
65
|
+
// Both quote styles. Matching only `="` left `@click='${handler}'`
|
|
66
|
+
// unrecognised, so the handler fell through to `stringifyValue`, which
|
|
67
|
+
// CALLED it — a client event handler running on the server, its return
|
|
68
|
+
// value written into the HTML, and any exception swallowed.
|
|
69
|
+
const directiveMatch = segment.match(/\s([@?.][\w-]+)=("|'|)$/);
|
|
61
70
|
const skipValue = directiveMatch !== null;
|
|
62
71
|
if (directiveMatch) {
|
|
63
|
-
|
|
64
|
-
|
|
72
|
+
const [whole = "", , quote] = directiveMatch;
|
|
73
|
+
segment = segment.slice(0, segment.length - whole.length);
|
|
74
|
+
// Only a quoted directive leaves a closing quote to swallow.
|
|
75
|
+
pendingClosingQuote =
|
|
76
|
+
quote === '"' ? '"' : quote === "'" ? "'" : undefined;
|
|
65
77
|
}
|
|
66
78
|
out += segment;
|
|
67
79
|
scanner.consume(segment);
|
|
@@ -148,10 +160,10 @@ function stringifyValue(value: unknown, inAttribute: boolean): string {
|
|
|
148
160
|
if (value === true) return inAttribute ? "" : "true";
|
|
149
161
|
if (isSignal(value)) return stringifyValue(value(), inAttribute);
|
|
150
162
|
if (typeof value === "function") {
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
163
|
+
// A function here is a reactive expression — `class="${() => …}"` in an
|
|
164
|
+
// attribute, `${() => …}` in text — and is evaluated eagerly
|
|
165
|
+
// server-side. Directive values (`@click`, `?disabled`, `.prop`) never
|
|
166
|
+
// reach this point: the scanner skips them, whatever quoting they use.
|
|
155
167
|
try {
|
|
156
168
|
return stringifyValue((value as () => unknown)(), inAttribute);
|
|
157
169
|
} catch {
|
package/src/xsrf.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Echoing the CSRF cookie back as a header, in one place.
|
|
4
|
+
*
|
|
5
|
+
* The signed double-submit guard on the server reads `XSRF-TOKEN` from the
|
|
6
|
+
* cookie jar and compares it to `X-XSRF-TOKEN` on the request. A browser sends
|
|
7
|
+
* the cookie on its own; the header is the client's half, and it is the half
|
|
8
|
+
* that says "this request came from our own page", because a cross-site caller
|
|
9
|
+
* can cause the cookie to ride along but cannot read it.
|
|
10
|
+
*
|
|
11
|
+
* This lived three times over. `rpc.ts` and `relay.ts` each had a copy, and
|
|
12
|
+
* both described theirs as mirroring `HttpClient.#retrieveXsrfToken` — a method
|
|
13
|
+
* `HttpClient` never had. So the client the docs tell you to submit a form with
|
|
14
|
+
* sent no header at all, and every POST through it was refused the moment an
|
|
15
|
+
* application turned CSRF on. One reader now, used by all three.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The cookie the server seeds. */
|
|
19
|
+
export const XSRF_COOKIE_NAME = "XSRF-TOKEN";
|
|
20
|
+
|
|
21
|
+
/** The header it is echoed in (the Axios/Angular convention the server reads). */
|
|
22
|
+
export const XSRF_HEADER_NAME = "X-XSRF-TOKEN";
|
|
23
|
+
|
|
24
|
+
/** Per-client switches for the automatic header. */
|
|
25
|
+
export interface XsrfOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Echo the CSRF cookie as a header on same-origin requests. Default `true`.
|
|
28
|
+
* A no-op outside a browser and when the cookie is absent — a bearer-authed
|
|
29
|
+
* API is CSRF-exempt and seeds no cookie, so nothing is sent there either.
|
|
30
|
+
*/
|
|
31
|
+
xsrf?: boolean;
|
|
32
|
+
/** Cookie to read the token from. Default `XSRF-TOKEN`. */
|
|
33
|
+
xsrfCookieName?: string;
|
|
34
|
+
/** Header to echo it in. Default `X-XSRF-TOKEN`. */
|
|
35
|
+
xsrfHeaderName?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read a cookie's raw value from `document.cookie`.
|
|
40
|
+
*
|
|
41
|
+
* Verbatim, never decoded: the server compares the header to the cookie
|
|
42
|
+
* byte-for-byte. The token is hex + `.` + base64url, so there is nothing a
|
|
43
|
+
* decode could change — but a decode that ever did change something would turn
|
|
44
|
+
* a valid request into a rejected one, silently.
|
|
45
|
+
*
|
|
46
|
+
* Returns `undefined` server-side (no `document`) or when the cookie is absent.
|
|
47
|
+
*/
|
|
48
|
+
export function readXsrfCookie(
|
|
49
|
+
name: string = XSRF_COOKIE_NAME,
|
|
50
|
+
): string | undefined {
|
|
51
|
+
if (typeof document === "undefined") return undefined;
|
|
52
|
+
const prefix = `${name}=`;
|
|
53
|
+
for (const part of document.cookie.split(";")) {
|
|
54
|
+
const trimmed = part.trimStart();
|
|
55
|
+
if (trimmed.startsWith(prefix)) return trimmed.slice(prefix.length);
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Is this URL served by the page's own origin?
|
|
62
|
+
*
|
|
63
|
+
* The question is not "does it match the client's baseURL" — a client whose
|
|
64
|
+
* baseURL IS a third-party API would pass that one. A CSRF token authenticates
|
|
65
|
+
* the page's session; sending it anywhere else hands a working token to whoever
|
|
66
|
+
* runs that host.
|
|
67
|
+
*
|
|
68
|
+
* A relative URL is same-origin by construction. Outside a browser there is no
|
|
69
|
+
* page and no cookie, so the answer is no.
|
|
70
|
+
*/
|
|
71
|
+
export function isSameOriginAsPage(url: string): boolean {
|
|
72
|
+
if (typeof window === "undefined") return false;
|
|
73
|
+
if (!/^[a-z][a-z\d+\-.]*:\/\//i.test(url)) return true;
|
|
74
|
+
try {
|
|
75
|
+
return new URL(url).origin === window.location.origin;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The header to add for `url`, or `undefined` when there is nothing to send.
|
|
83
|
+
*
|
|
84
|
+
* Absent cookie, disabled, cross-origin target, or no browser: nothing. The
|
|
85
|
+
* caller merges the result rather than being handed an empty object, so a call
|
|
86
|
+
* site cannot accidentally overwrite a header it set itself.
|
|
87
|
+
*/
|
|
88
|
+
export function xsrfHeaderFor(
|
|
89
|
+
url: string,
|
|
90
|
+
options: XsrfOptions = {},
|
|
91
|
+
): Record<string, string> | undefined {
|
|
92
|
+
if (options.xsrf === false) return undefined;
|
|
93
|
+
if (!isSameOriginAsPage(url)) return undefined;
|
|
94
|
+
const token = readXsrfCookie(options.xsrfCookieName ?? XSRF_COOKIE_NAME);
|
|
95
|
+
if (token === undefined) return undefined;
|
|
96
|
+
return { [options.xsrfHeaderName ?? XSRF_HEADER_NAME]: token };
|
|
97
|
+
}
|