@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.
@@ -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,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 (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
  }
@@ -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
- const tpl = document.createElement("template");
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
- 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";
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
@@ -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 (let i = 0; i < strings.length; i++) {
53
- let segment = strings[i];
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
- segment = segment.slice(0, segment.length - directiveMatch[0].length);
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
- directiveMatch[2] === '"'
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;