@c9up/aurora 0.1.34 → 0.1.37
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 +43 -0
- package/dist/augmentations.js +17 -0
- package/dist/browser.js +1 -1
- package/dist/html.js +91 -11
- 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/relay.js +6 -28
- package/dist/render.js +1 -2
- package/dist/rpc.js +12 -30
- package/dist/ssr.js +47 -8
- package/dist/xsrf.d.ts +63 -0
- package/dist/xsrf.js +81 -0
- package/package.json +6 -4
- package/src/AuroraProvider.ts +1 -0
- package/src/augmentations.ts +50 -0
- package/src/browser.ts +1 -1
- package/src/html.ts +93 -10
- package/src/http.ts +27 -1
- package/src/hydrate.ts +7 -5
- package/src/index.ts +2 -0
- package/src/relay.ts +7 -25
- package/src/render.ts +1 -2
- package/src/rpc.ts +14 -28
- package/src/ssr.ts +46 -8
- package/src/xsrf.ts +97 -0
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.37",
|
|
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",
|
|
@@ -58,10 +58,12 @@
|
|
|
58
58
|
"@types/node": "^22.19.15",
|
|
59
59
|
"@vitest/browser": "4.1.11",
|
|
60
60
|
"@vitest/browser-playwright": "4.1.11",
|
|
61
|
-
"
|
|
61
|
+
"@vitest/coverage-v8": "4.1.9",
|
|
62
|
+
"jsdom": "^30.0.1",
|
|
62
63
|
"playwright": "^1.61.1",
|
|
63
64
|
"typescript": "^6.0.2",
|
|
64
|
-
"vitest": "4.1.9"
|
|
65
|
+
"vitest": "4.1.9",
|
|
66
|
+
"@c9up/ream": "^0.2.0"
|
|
65
67
|
},
|
|
66
68
|
"files": [
|
|
67
69
|
"LICENSE",
|
|
@@ -80,7 +82,7 @@
|
|
|
80
82
|
"build": "tsc -p tsconfig.build.json",
|
|
81
83
|
"typecheck": "tsc --noEmit",
|
|
82
84
|
"test": "vitest run",
|
|
83
|
-
"lint": "biome check src/",
|
|
85
|
+
"lint": "biome check src/ tests/",
|
|
84
86
|
"test:coverage": "vitest run --coverage",
|
|
85
87
|
"test:browser": "vitest run -c vitest.browser.config.ts"
|
|
86
88
|
}
|
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,50 @@
|
|
|
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 augmentations below resolve the modules they augment.
|
|
19
|
+
import type {} from "@c9up/ream";
|
|
20
|
+
import type {} from "@c9up/ream/types";
|
|
21
|
+
|
|
22
|
+
import type { AuroraManager } from "./AuroraManager.js";
|
|
23
|
+
import type { AuroraRequestRenderer } from "./middleware.js";
|
|
24
|
+
|
|
25
|
+
declare module "@c9up/ream/types" {
|
|
26
|
+
interface ContainerBindings {
|
|
27
|
+
/** The Aurora manager, bound by `AuroraProvider`. */
|
|
28
|
+
aurora: AuroraManager;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
declare module "@c9up/ream" {
|
|
33
|
+
interface HttpContext {
|
|
34
|
+
/**
|
|
35
|
+
* Render a page for THIS request — `ctx.aurora.render(name, props)`.
|
|
36
|
+
*
|
|
37
|
+
* Attached by the `auroraContext` middleware, which is what the docs tell
|
|
38
|
+
* an application to register. Without this declaration the property the
|
|
39
|
+
* middleware sets did not exist as far as the compiler was concerned, so
|
|
40
|
+
* the shorthand the documentation teaches did not typecheck, and a
|
|
41
|
+
* controller had to reach for the module-level `aurora.render(ctx, ...)`
|
|
42
|
+
* or assert its way past it.
|
|
43
|
+
*
|
|
44
|
+
* Optional, because the middleware is: an application that never
|
|
45
|
+
* registers it has no `ctx.aurora`, and saying otherwise would let a
|
|
46
|
+
* controller call something that is not there.
|
|
47
|
+
*/
|
|
48
|
+
aurora?: AuroraRequestRenderer;
|
|
49
|
+
}
|
|
50
|
+
}
|
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}`,
|
|
@@ -259,11 +259,94 @@ function collectSlots(
|
|
|
259
259
|
return slots;
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Elements that only ever exist inside `<svg>`.
|
|
264
|
+
*
|
|
265
|
+
* Names shared with HTML — `a`, `title`, `style`, `script`, `text` in some
|
|
266
|
+
* dialects — are deliberately absent: seeing one says nothing about which
|
|
267
|
+
* namespace was meant, and guessing wrong would move an ordinary anchor into
|
|
268
|
+
* SVG.
|
|
269
|
+
*/
|
|
270
|
+
const SVG_ONLY = new Set([
|
|
271
|
+
"animate",
|
|
272
|
+
"animatemotion",
|
|
273
|
+
"animatetransform",
|
|
274
|
+
"circle",
|
|
275
|
+
"clippath",
|
|
276
|
+
"defs",
|
|
277
|
+
"desc",
|
|
278
|
+
"ellipse",
|
|
279
|
+
"feblend",
|
|
280
|
+
"fecolormatrix",
|
|
281
|
+
"fegaussianblur",
|
|
282
|
+
"femerge",
|
|
283
|
+
"feoffset",
|
|
284
|
+
"filter",
|
|
285
|
+
"foreignobject",
|
|
286
|
+
"g",
|
|
287
|
+
"image",
|
|
288
|
+
"line",
|
|
289
|
+
"lineargradient",
|
|
290
|
+
"marker",
|
|
291
|
+
"mask",
|
|
292
|
+
"path",
|
|
293
|
+
"pattern",
|
|
294
|
+
"polygon",
|
|
295
|
+
"polyline",
|
|
296
|
+
"radialgradient",
|
|
297
|
+
"rect",
|
|
298
|
+
"stop",
|
|
299
|
+
"svg",
|
|
300
|
+
"symbol",
|
|
301
|
+
"tspan",
|
|
302
|
+
"use",
|
|
303
|
+
]);
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Whether this markup is SVG content that lost its `<svg>` ancestor.
|
|
307
|
+
*
|
|
308
|
+
* A template compiled on its own — `html\`<path/><path/>\``, the body of an
|
|
309
|
+
* icon helper — is parsed with no parent, and the HTML parser has no
|
|
310
|
+
* self-closing tag for an unknown element: the second `<path>` becomes a CHILD
|
|
311
|
+
* of the first, in the XHTML namespace. Nothing throws and nothing is logged;
|
|
312
|
+
* the icon is simply invisible, because `<path>` in the wrong namespace paints
|
|
313
|
+
* nothing. Parsing the same markup inside an `<svg>` makes the parser apply
|
|
314
|
+
* foreign-content rules and produce the two siblings that were written.
|
|
315
|
+
*
|
|
316
|
+
* `<svg>` itself is excluded: the parser already handles it when it is the root
|
|
317
|
+
* of the markup, and wrapping one in another would nest them.
|
|
318
|
+
*/
|
|
319
|
+
function isOrphanedSvgContent(root: HTMLTemplateElement): boolean {
|
|
320
|
+
const elements = Array.from(root.content.childNodes).filter(
|
|
321
|
+
(node): node is Element => node.nodeType === 1,
|
|
322
|
+
);
|
|
323
|
+
if (elements.length === 0) return false;
|
|
324
|
+
return elements.every((el) => {
|
|
325
|
+
const name = el.localName.toLowerCase();
|
|
326
|
+
return name !== "svg" && SVG_ONLY.has(name);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
262
330
|
function compile(strings: TemplateStringsArray): Template {
|
|
263
331
|
const classification = classifySlots(strings);
|
|
264
332
|
const markup = buildMarkup(strings, classification);
|
|
265
|
-
|
|
333
|
+
let tpl = document.createElement("template");
|
|
266
334
|
tpl.innerHTML = markup;
|
|
335
|
+
|
|
336
|
+
if (isOrphanedSvgContent(tpl)) {
|
|
337
|
+
// Re-parsed with the ancestor the markup was written for, then lifted
|
|
338
|
+
// back out: the nodes keep the SVG namespace they were given, and the
|
|
339
|
+
// slot paths below are collected against the shape that will actually
|
|
340
|
+
// be cloned.
|
|
341
|
+
const wrapper = document.createElement("template");
|
|
342
|
+
wrapper.innerHTML = `<svg>${markup}</svg>`;
|
|
343
|
+
const svg = wrapper.content.firstElementChild;
|
|
344
|
+
if (svg !== null) {
|
|
345
|
+
tpl = document.createElement("template");
|
|
346
|
+
while (svg.firstChild) tpl.content.appendChild(svg.firstChild);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
267
350
|
const slots = collectSlots(tpl, classification);
|
|
268
351
|
return { element: tpl, slots };
|
|
269
352
|
}
|
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/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
|
@@ -53,8 +53,8 @@ function stringifyTemplateResult(result: TemplateResult): string {
|
|
|
53
53
|
// one consumed is the one that was opened. Undefined when none is pending.
|
|
54
54
|
let pendingClosingQuote: '"' | "'" | undefined;
|
|
55
55
|
const scanner = new TagScanner();
|
|
56
|
-
for (
|
|
57
|
-
let segment =
|
|
56
|
+
for (const [i, raw] of strings.entries()) {
|
|
57
|
+
let segment = raw;
|
|
58
58
|
if (pendingClosingQuote !== undefined) {
|
|
59
59
|
segment =
|
|
60
60
|
pendingClosingQuote === '"'
|
|
@@ -68,18 +68,36 @@ function stringifyTemplateResult(result: TemplateResult): string {
|
|
|
68
68
|
// value written into the HTML, and any exception swallowed.
|
|
69
69
|
const directiveMatch = segment.match(/\s([@?.][\w-]+)=("|'|)$/);
|
|
70
70
|
const skipValue = directiveMatch !== null;
|
|
71
|
+
// Set when the skipped directive is a boolean attribute, which — unlike
|
|
72
|
+
// the other two — still has markup to emit. See below.
|
|
73
|
+
let booleanAttrName: string | undefined;
|
|
71
74
|
if (directiveMatch) {
|
|
72
|
-
|
|
75
|
+
const [whole = "", directive = "", quote] = directiveMatch;
|
|
76
|
+
segment = segment.slice(0, segment.length - whole.length);
|
|
77
|
+
// `?disabled=${x}` is HTML STATE, not a client-only binding.
|
|
78
|
+
// `@click` is a listener and `.value` a DOM property: neither
|
|
79
|
+
// exists until the runtime binds it, so dropping them is right.
|
|
80
|
+
// A boolean attribute is different — the browser acts on it while
|
|
81
|
+
// parsing. Skipping it too made the server contradict the very
|
|
82
|
+
// first client render: a `?hidden` panel arrived visible and
|
|
83
|
+
// blinked away once hydration caught up.
|
|
84
|
+
if (directive.startsWith("?")) booleanAttrName = directive.slice(1);
|
|
73
85
|
// Only a quoted directive leaves a closing quote to swallow.
|
|
74
86
|
pendingClosingQuote =
|
|
75
|
-
|
|
76
|
-
? '"'
|
|
77
|
-
: directiveMatch[2] === "'"
|
|
78
|
-
? "'"
|
|
79
|
-
: undefined;
|
|
87
|
+
quote === '"' ? '"' : quote === "'" ? "'" : undefined;
|
|
80
88
|
}
|
|
81
89
|
out += segment;
|
|
82
90
|
scanner.consume(segment);
|
|
91
|
+
if (booleanAttrName !== undefined && i < values.length) {
|
|
92
|
+
// Present-and-empty when truthy, absent otherwise — byte-for-byte
|
|
93
|
+
// what applyBooleanAttrSlot writes on the client, so hydration
|
|
94
|
+
// re-applying the effect is a no-op instead of a correction.
|
|
95
|
+
if (resolveBooleanValue(values[i])) {
|
|
96
|
+
const rendered = ` ${booleanAttrName}=""`;
|
|
97
|
+
out += rendered;
|
|
98
|
+
scanner.consume(rendered);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
83
101
|
if (i < values.length && !skipValue) {
|
|
84
102
|
const value = values[i];
|
|
85
103
|
const inAttr = scanner.insideTag;
|
|
@@ -158,6 +176,26 @@ class TagScanner {
|
|
|
158
176
|
}
|
|
159
177
|
}
|
|
160
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Read a boolean attribute's value the way the client reads it: a signal or a
|
|
181
|
+
* reactive expression is called ONCE, then coerced. One level is not an
|
|
182
|
+
* approximation — `applyBooleanAttrSlot` does exactly the same, so a signal
|
|
183
|
+
* that returns a signal is truthy on both sides.
|
|
184
|
+
*/
|
|
185
|
+
function resolveBooleanValue(value: unknown): boolean {
|
|
186
|
+
if (isSignal(value) || typeof value === "function") {
|
|
187
|
+
try {
|
|
188
|
+
return Boolean((value as () => unknown)());
|
|
189
|
+
} catch {
|
|
190
|
+
// Same fail-soft as stringifyValue: an expression that throws
|
|
191
|
+
// server-side leaves the attribute off and lets the client effect
|
|
192
|
+
// decide once it has a real DOM to read.
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return Boolean(value);
|
|
197
|
+
}
|
|
198
|
+
|
|
161
199
|
function stringifyValue(value: unknown, inAttribute: boolean): string {
|
|
162
200
|
if (value === null || value === undefined || value === false) return "";
|
|
163
201
|
if (value === true) return inAttribute ? "" : "true";
|