@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.
@@ -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>;
@@ -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 (let i = 0; i < classification.length; i++) {
82
- out +=
83
- classification[i].region === "text"
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
- export interface HttpClientOptions {
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 (let i = 0; i < tpl.slots.length; i++) {
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
- let node = children[path[0]] ?? null;
425
- for (let i = 1; node && i < path.length; i++) {
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[path[i]] ?? null;
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";
@@ -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
- void http.post(path, { id, event, payload });
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 blackhole expects: the `XSRF-TOKEN` cookie echoed as
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. Mirrors `HttpClient.#retrieveXsrfToken` /
236
- * `createRequest` in `@adonisjs/transmit-client`.
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
- const xsrf = retrieveXsrfToken();
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 (let i = 0; i < tpl.slots.length; i++) {
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 ?? new HttpClient({ headers: options.headers });
48
- const xsrfEnabled = options.xsrf ?? true;
49
- const cookieName = options.xsrfCookieName ?? "XSRF-TOKEN";
50
- const headerName = options.xsrfHeaderName ?? "X-XSRF-TOKEN";
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
- transport: (url, body, { signal }) => {
54
- let headers;
55
- if (xsrfEnabled) {
56
- const token = readCookie(cookieName);
57
- if (token !== undefined)
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
- let pendingClosingQuote = false;
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 (let i = 0; i < strings.length; i++) {
51
- let segment = strings[i];
52
- if (pendingClosingQuote) {
53
- segment = segment.replace(/^"/, "");
54
- pendingClosingQuote = false;
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
- const directiveMatch = segment.match(/\s([@?.][\w-]+)="$/);
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
- segment = segment.slice(0, segment.length - directiveMatch[0].length);
60
- pendingClosingQuote = true;
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
- // In attribute position: directive handlers (`@click`, `?disabled`,
151
- // `.prop`) have already been stripped by `stripDirectiveBefore`.
152
- // A function reaching this point is a reactive-expression text
153
- // slot (`${() => ...}`), which we evaluate eagerly server-side.
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.33",
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.9",
60
- "@vitest/browser-playwright": "4.1.9",
61
- "happy-dom": "^15.11.7",
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",
@@ -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 (let i = 0; i < classification.length; i++) {
116
- out +=
117
- classification[i].region === "text"
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
- export interface HttpClientOptions {
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 (let i = 0; i < tpl.slots.length; i++) {
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
- let node: Node | null = children[path[0]] ?? null;
558
- for (let i = 1; node && i < path.length; i++) {
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[path[i]] ?? null;
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
- void http.post(path, { id, event, payload });
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 blackhole expects: the `XSRF-TOKEN` cookie echoed as
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. Mirrors `HttpClient.#retrieveXsrfToken` /
320
- * `createRequest` in `@adonisjs/transmit-client`.
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
- const xsrf = retrieveXsrfToken();
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 (let i = 0; i < tpl.slots.length; i++) {
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 = options.http ?? new HttpClient({ headers: options.headers });
80
- const xsrfEnabled = options.xsrf ?? true;
81
- const cookieName = options.xsrfCookieName ?? "XSRF-TOKEN";
82
- const headerName = options.xsrfHeaderName ?? "X-XSRF-TOKEN";
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
- transport: (url, body, { signal }) => {
86
- let headers: Record<string, string> | undefined;
87
- if (xsrfEnabled) {
88
- const token = readCookie(cookieName);
89
- if (token !== undefined) headers = { [headerName]: token };
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
- let pendingClosingQuote = false;
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 (let i = 0; i < strings.length; i++) {
55
- let segment = strings[i];
56
- if (pendingClosingQuote) {
57
- segment = segment.replace(/^"/, "");
58
- pendingClosingQuote = false;
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
- const directiveMatch = segment.match(/\s([@?.][\w-]+)="$/);
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
- segment = segment.slice(0, segment.length - directiveMatch[0].length);
64
- pendingClosingQuote = true;
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
- // In attribute position: directive handlers (`@click`, `?disabled`,
152
- // `.prop`) have already been stripped by `stripDirectiveBefore`.
153
- // A function reaching this point is a reactive-expression text
154
- // slot (`${() => ...}`), which we evaluate eagerly server-side.
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
+ }