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