@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/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,43 @@
|
|
|
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
|
+
import type { AuroraRequestRenderer } from "./middleware.js";
|
|
19
|
+
declare module "@c9up/ream/types" {
|
|
20
|
+
interface ContainerBindings {
|
|
21
|
+
/** The Aurora manager, bound by `AuroraProvider`. */
|
|
22
|
+
aurora: AuroraManager;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
declare module "@c9up/ream" {
|
|
26
|
+
interface HttpContext {
|
|
27
|
+
/**
|
|
28
|
+
* Render a page for THIS request — `ctx.aurora.render(name, props)`.
|
|
29
|
+
*
|
|
30
|
+
* Attached by the `auroraContext` middleware, which is what the docs tell
|
|
31
|
+
* an application to register. Without this declaration the property the
|
|
32
|
+
* middleware sets did not exist as far as the compiler was concerned, so
|
|
33
|
+
* the shorthand the documentation teaches did not typecheck, and a
|
|
34
|
+
* controller had to reach for the module-level `aurora.render(ctx, ...)`
|
|
35
|
+
* or assert its way past it.
|
|
36
|
+
*
|
|
37
|
+
* Optional, because the middleware is: an application that never
|
|
38
|
+
* registers it has no `ctx.aurora`, and saying otherwise would let a
|
|
39
|
+
* controller call something that is not there.
|
|
40
|
+
*/
|
|
41
|
+
aurora?: AuroraRequestRenderer;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -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
|
}
|
|
@@ -215,11 +216,90 @@ function collectSlots(root, classification) {
|
|
|
215
216
|
visit(root.content, []);
|
|
216
217
|
return slots;
|
|
217
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* Elements that only ever exist inside `<svg>`.
|
|
221
|
+
*
|
|
222
|
+
* Names shared with HTML — `a`, `title`, `style`, `script`, `text` in some
|
|
223
|
+
* dialects — are deliberately absent: seeing one says nothing about which
|
|
224
|
+
* namespace was meant, and guessing wrong would move an ordinary anchor into
|
|
225
|
+
* SVG.
|
|
226
|
+
*/
|
|
227
|
+
const SVG_ONLY = new Set([
|
|
228
|
+
"animate",
|
|
229
|
+
"animatemotion",
|
|
230
|
+
"animatetransform",
|
|
231
|
+
"circle",
|
|
232
|
+
"clippath",
|
|
233
|
+
"defs",
|
|
234
|
+
"desc",
|
|
235
|
+
"ellipse",
|
|
236
|
+
"feblend",
|
|
237
|
+
"fecolormatrix",
|
|
238
|
+
"fegaussianblur",
|
|
239
|
+
"femerge",
|
|
240
|
+
"feoffset",
|
|
241
|
+
"filter",
|
|
242
|
+
"foreignobject",
|
|
243
|
+
"g",
|
|
244
|
+
"image",
|
|
245
|
+
"line",
|
|
246
|
+
"lineargradient",
|
|
247
|
+
"marker",
|
|
248
|
+
"mask",
|
|
249
|
+
"path",
|
|
250
|
+
"pattern",
|
|
251
|
+
"polygon",
|
|
252
|
+
"polyline",
|
|
253
|
+
"radialgradient",
|
|
254
|
+
"rect",
|
|
255
|
+
"stop",
|
|
256
|
+
"svg",
|
|
257
|
+
"symbol",
|
|
258
|
+
"tspan",
|
|
259
|
+
"use",
|
|
260
|
+
]);
|
|
261
|
+
/**
|
|
262
|
+
* Whether this markup is SVG content that lost its `<svg>` ancestor.
|
|
263
|
+
*
|
|
264
|
+
* A template compiled on its own — `html\`<path/><path/>\``, the body of an
|
|
265
|
+
* icon helper — is parsed with no parent, and the HTML parser has no
|
|
266
|
+
* self-closing tag for an unknown element: the second `<path>` becomes a CHILD
|
|
267
|
+
* of the first, in the XHTML namespace. Nothing throws and nothing is logged;
|
|
268
|
+
* the icon is simply invisible, because `<path>` in the wrong namespace paints
|
|
269
|
+
* nothing. Parsing the same markup inside an `<svg>` makes the parser apply
|
|
270
|
+
* foreign-content rules and produce the two siblings that were written.
|
|
271
|
+
*
|
|
272
|
+
* `<svg>` itself is excluded: the parser already handles it when it is the root
|
|
273
|
+
* of the markup, and wrapping one in another would nest them.
|
|
274
|
+
*/
|
|
275
|
+
function isOrphanedSvgContent(root) {
|
|
276
|
+
const elements = Array.from(root.content.childNodes).filter((node) => node.nodeType === 1);
|
|
277
|
+
if (elements.length === 0)
|
|
278
|
+
return false;
|
|
279
|
+
return elements.every((el) => {
|
|
280
|
+
const name = el.localName.toLowerCase();
|
|
281
|
+
return name !== "svg" && SVG_ONLY.has(name);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
218
284
|
function compile(strings) {
|
|
219
285
|
const classification = classifySlots(strings);
|
|
220
286
|
const markup = buildMarkup(strings, classification);
|
|
221
|
-
|
|
287
|
+
let tpl = document.createElement("template");
|
|
222
288
|
tpl.innerHTML = markup;
|
|
289
|
+
if (isOrphanedSvgContent(tpl)) {
|
|
290
|
+
// Re-parsed with the ancestor the markup was written for, then lifted
|
|
291
|
+
// back out: the nodes keep the SVG namespace they were given, and the
|
|
292
|
+
// slot paths below are collected against the shape that will actually
|
|
293
|
+
// be cloned.
|
|
294
|
+
const wrapper = document.createElement("template");
|
|
295
|
+
wrapper.innerHTML = `<svg>${markup}</svg>`;
|
|
296
|
+
const svg = wrapper.content.firstElementChild;
|
|
297
|
+
if (svg !== null) {
|
|
298
|
+
tpl = document.createElement("template");
|
|
299
|
+
while (svg.firstChild)
|
|
300
|
+
tpl.content.appendChild(svg.firstChild);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
223
303
|
const slots = collectSlots(tpl, classification);
|
|
224
304
|
return { element: tpl, slots };
|
|
225
305
|
}
|
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/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
|
@@ -49,8 +49,8 @@ function stringifyTemplateResult(result) {
|
|
|
49
49
|
// one consumed is the one that was opened. Undefined when none is pending.
|
|
50
50
|
let pendingClosingQuote;
|
|
51
51
|
const scanner = new TagScanner();
|
|
52
|
-
for (
|
|
53
|
-
let segment =
|
|
52
|
+
for (const [i, raw] of strings.entries()) {
|
|
53
|
+
let segment = raw;
|
|
54
54
|
if (pendingClosingQuote !== undefined) {
|
|
55
55
|
segment =
|
|
56
56
|
pendingClosingQuote === '"'
|
|
@@ -64,18 +64,37 @@ function stringifyTemplateResult(result) {
|
|
|
64
64
|
// value written into the HTML, and any exception swallowed.
|
|
65
65
|
const directiveMatch = segment.match(/\s([@?.][\w-]+)=("|'|)$/);
|
|
66
66
|
const skipValue = directiveMatch !== null;
|
|
67
|
+
// Set when the skipped directive is a boolean attribute, which — unlike
|
|
68
|
+
// the other two — still has markup to emit. See below.
|
|
69
|
+
let booleanAttrName;
|
|
67
70
|
if (directiveMatch) {
|
|
68
|
-
|
|
71
|
+
const [whole = "", directive = "", quote] = directiveMatch;
|
|
72
|
+
segment = segment.slice(0, segment.length - whole.length);
|
|
73
|
+
// `?disabled=${x}` is HTML STATE, not a client-only binding.
|
|
74
|
+
// `@click` is a listener and `.value` a DOM property: neither
|
|
75
|
+
// exists until the runtime binds it, so dropping them is right.
|
|
76
|
+
// A boolean attribute is different — the browser acts on it while
|
|
77
|
+
// parsing. Skipping it too made the server contradict the very
|
|
78
|
+
// first client render: a `?hidden` panel arrived visible and
|
|
79
|
+
// blinked away once hydration caught up.
|
|
80
|
+
if (directive.startsWith("?"))
|
|
81
|
+
booleanAttrName = directive.slice(1);
|
|
69
82
|
// Only a quoted directive leaves a closing quote to swallow.
|
|
70
83
|
pendingClosingQuote =
|
|
71
|
-
|
|
72
|
-
? '"'
|
|
73
|
-
: directiveMatch[2] === "'"
|
|
74
|
-
? "'"
|
|
75
|
-
: undefined;
|
|
84
|
+
quote === '"' ? '"' : quote === "'" ? "'" : undefined;
|
|
76
85
|
}
|
|
77
86
|
out += segment;
|
|
78
87
|
scanner.consume(segment);
|
|
88
|
+
if (booleanAttrName !== undefined && i < values.length) {
|
|
89
|
+
// Present-and-empty when truthy, absent otherwise — byte-for-byte
|
|
90
|
+
// what applyBooleanAttrSlot writes on the client, so hydration
|
|
91
|
+
// re-applying the effect is a no-op instead of a correction.
|
|
92
|
+
if (resolveBooleanValue(values[i])) {
|
|
93
|
+
const rendered = ` ${booleanAttrName}=""`;
|
|
94
|
+
out += rendered;
|
|
95
|
+
scanner.consume(rendered);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
79
98
|
if (i < values.length && !skipValue) {
|
|
80
99
|
const value = values[i];
|
|
81
100
|
const inAttr = scanner.insideTag;
|
|
@@ -154,6 +173,26 @@ class TagScanner {
|
|
|
154
173
|
return this.#inTag;
|
|
155
174
|
}
|
|
156
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Read a boolean attribute's value the way the client reads it: a signal or a
|
|
178
|
+
* reactive expression is called ONCE, then coerced. One level is not an
|
|
179
|
+
* approximation — `applyBooleanAttrSlot` does exactly the same, so a signal
|
|
180
|
+
* that returns a signal is truthy on both sides.
|
|
181
|
+
*/
|
|
182
|
+
function resolveBooleanValue(value) {
|
|
183
|
+
if (isSignal(value) || typeof value === "function") {
|
|
184
|
+
try {
|
|
185
|
+
return Boolean(value());
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// Same fail-soft as stringifyValue: an expression that throws
|
|
189
|
+
// server-side leaves the attribute off and lets the client effect
|
|
190
|
+
// decide once it has a real DOM to read.
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return Boolean(value);
|
|
195
|
+
}
|
|
157
196
|
function stringifyValue(value, inAttribute) {
|
|
158
197
|
if (value === null || value === undefined || value === false)
|
|
159
198
|
return "";
|
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;
|