@c9up/aurora 0.1.15 → 0.1.17

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/browser.d.ts CHANGED
@@ -131,16 +131,64 @@ export interface CookieOptions {
131
131
  /** Restrict to HTTPS. */
132
132
  secure?: boolean;
133
133
  }
134
+ /**
135
+ * Install the SSR cookie seed (`name → value`). Called by `renderPage` from its
136
+ * `cookies` allowlist; the hydrate bootstrap does NOT call it (the browser reads
137
+ * `document.cookie`). Replaces any previous seed.
138
+ */
139
+ export declare function setCookieStore(values: Record<string, string>): void;
140
+ /** The currently-installed SSR cookie seed (mainly for tests/introspection). */
141
+ export declare function getCookieStore(): Record<string, string>;
134
142
  /**
135
143
  * SSR-safe cookie accessor — the one store {@link WebStorage} can't cover, for
136
- * values the server also reads. Reads return `null` during SSR; writes are a
137
- * no-op. Names and values are URL-encoded.
144
+ * values the server also reads. In the browser reads/writes hit
145
+ * `document.cookie`; during SSR reads come from the {@link setCookieStore} seed
146
+ * and writes are a no-op. Names and values are URL-encoded.
138
147
  */
139
148
  export declare const cookie: {
140
149
  get(name: string): string | null;
141
150
  set(name: string, value: string, options?: CookieOptions): void;
142
151
  remove(name: string, options?: Pick<CookieOptions, "path" | "domain">): void;
143
152
  };
153
+ /**
154
+ * Maps a typed value to and from the string a cookie stores. Pass one to
155
+ * {@link cookieState} for non-string state (booleans, enums, JSON).
156
+ */
157
+ export interface CookieCodec<T> {
158
+ /** Parse the raw cookie string into `T`. */
159
+ parse(raw: string): T;
160
+ /** Serialize `T` into the raw cookie string. */
161
+ serialize(value: T): string;
162
+ }
163
+ /** Codec for a boolean cookie, stored as `"1"` / `"0"`. */
164
+ export declare const booleanCookie: CookieCodec<boolean>;
165
+ /**
166
+ * Codec for a JSON-serializable value. A parse failure (malformed cookie) is
167
+ * surfaced by returning `fallback`, so a tampered cookie never throws mid-render.
168
+ */
169
+ export declare function jsonCookie<T>(fallback: T): CookieCodec<T>;
170
+ /**
171
+ * A {@link Signal} backed by a cookie — the isomorphic counterpart to
172
+ * {@link persistedSignal}. It is seeded from the cookie (the
173
+ * {@link setCookieStore} request seed during SSR, `document.cookie` in the
174
+ * browser) and persists every change back to the cookie via a reactive
175
+ * `effect`. Because the same seed is visible on both sides, a page renders the
176
+ * SAME markup server- and client-side — no hydration mismatch, no flash of the
177
+ * default state (e.g. a sidebar that animates open→collapsed on every load).
178
+ *
179
+ * Use {@link cookieSignal} for string state; pass a {@link CookieCodec} here for
180
+ * booleans/enums/JSON (e.g. {@link booleanCookie}). Created inside a component's
181
+ * setup, the persistence effect is disposed with it.
182
+ *
183
+ * Read it at the TOP of a page (before any `await`) so the SSR seed is current —
184
+ * see {@link setCookieStore}.
185
+ */
186
+ export declare function cookieState<T>(name: string, initial: T, codec: CookieCodec<T>, options?: CookieOptions): Signal<T>;
187
+ /**
188
+ * A {@link Signal} backed by a string cookie — {@link cookieState} with an
189
+ * identity codec, for the common case where the value is already a string.
190
+ */
191
+ export declare function cookieSignal(name: string, initial: string, options?: CookieOptions): Signal<string>;
144
192
  /** Async clipboard access. Methods return `false`/`null` when unavailable. */
145
193
  export declare const clipboard: {
146
194
  /** Copy `text`. Returns whether it succeeded. */
package/dist/browser.js CHANGED
@@ -289,15 +289,43 @@ export function queryParam(key) {
289
289
  }
290
290
  return sig;
291
291
  }
292
+ /**
293
+ * SSR seed of the request's cookies — a `name → value` map installed by
294
+ * `renderPage` (server-side) before a page renders, so {@link cookie.get} and
295
+ * {@link cookieSignal} can read the SAME values during SSR that the browser
296
+ * will read from `document.cookie` after hydration. Without it the server has
297
+ * no view of the request cookies and renders default UI state → a flash /
298
+ * mismatch on hydration (the classic collapsed-sidebar flicker).
299
+ *
300
+ * Module-global by necessity (the page factory reads it ambiently). It is set
301
+ * synchronously immediately before the synchronous render, so read your cookie
302
+ * signals at the TOP of a page (before any `await`) to avoid a cross-request
303
+ * race under concurrent async page factories. In the browser it is unused —
304
+ * {@link cookie.get} reads `document.cookie` directly there.
305
+ */
306
+ let cookieSeed = {};
307
+ /**
308
+ * Install the SSR cookie seed (`name → value`). Called by `renderPage` from its
309
+ * `cookies` allowlist; the hydrate bootstrap does NOT call it (the browser reads
310
+ * `document.cookie`). Replaces any previous seed.
311
+ */
312
+ export function setCookieStore(values) {
313
+ cookieSeed = { ...values };
314
+ }
315
+ /** The currently-installed SSR cookie seed (mainly for tests/introspection). */
316
+ export function getCookieStore() {
317
+ return { ...cookieSeed };
318
+ }
292
319
  /**
293
320
  * SSR-safe cookie accessor — the one store {@link WebStorage} can't cover, for
294
- * values the server also reads. Reads return `null` during SSR; writes are a
295
- * no-op. Names and values are URL-encoded.
321
+ * values the server also reads. In the browser reads/writes hit
322
+ * `document.cookie`; during SSR reads come from the {@link setCookieStore} seed
323
+ * and writes are a no-op. Names and values are URL-encoded.
296
324
  */
297
325
  export const cookie = {
298
326
  get(name) {
299
327
  if (typeof document === "undefined")
300
- return null;
328
+ return cookieSeed[name] ?? null;
301
329
  const prefix = `${encodeURIComponent(name)}=`;
302
330
  for (const part of document.cookie.split("; ")) {
303
331
  if (part.startsWith(prefix)) {
@@ -330,6 +358,65 @@ export const cookie = {
330
358
  this.set(name, "", { ...options, maxAge: 0, expires: new Date(0) });
331
359
  },
332
360
  };
361
+ /** Codec for a boolean cookie, stored as `"1"` / `"0"`. */
362
+ export const booleanCookie = {
363
+ parse: (raw) => raw === "1" || raw === "true",
364
+ serialize: (value) => (value ? "1" : "0"),
365
+ };
366
+ /**
367
+ * Codec for a JSON-serializable value. A parse failure (malformed cookie) is
368
+ * surfaced by returning `fallback`, so a tampered cookie never throws mid-render.
369
+ */
370
+ export function jsonCookie(fallback) {
371
+ return {
372
+ parse: (raw) => {
373
+ try {
374
+ return JSON.parse(raw);
375
+ }
376
+ catch {
377
+ return fallback;
378
+ }
379
+ },
380
+ serialize: (value) => JSON.stringify(value),
381
+ };
382
+ }
383
+ /**
384
+ * A {@link Signal} backed by a cookie — the isomorphic counterpart to
385
+ * {@link persistedSignal}. It is seeded from the cookie (the
386
+ * {@link setCookieStore} request seed during SSR, `document.cookie` in the
387
+ * browser) and persists every change back to the cookie via a reactive
388
+ * `effect`. Because the same seed is visible on both sides, a page renders the
389
+ * SAME markup server- and client-side — no hydration mismatch, no flash of the
390
+ * default state (e.g. a sidebar that animates open→collapsed on every load).
391
+ *
392
+ * Use {@link cookieSignal} for string state; pass a {@link CookieCodec} here for
393
+ * booleans/enums/JSON (e.g. {@link booleanCookie}). Created inside a component's
394
+ * setup, the persistence effect is disposed with it.
395
+ *
396
+ * Read it at the TOP of a page (before any `await`) so the SSR seed is current —
397
+ * see {@link setCookieStore}.
398
+ */
399
+ export function cookieState(name, initial, codec, options = {}) {
400
+ const raw = cookie.get(name);
401
+ const sig = signal(raw === null ? initial : codec.parse(raw));
402
+ // Mirror every change back to the cookie. Runs once immediately (a no-op
403
+ // write during SSR, where `cookie.set` bails) then on each change.
404
+ effect(() => {
405
+ cookie.set(name, codec.serialize(sig()), options);
406
+ });
407
+ return sig;
408
+ }
409
+ const stringCookie = {
410
+ parse: (raw) => raw,
411
+ serialize: (value) => value,
412
+ };
413
+ /**
414
+ * A {@link Signal} backed by a string cookie — {@link cookieState} with an
415
+ * identity codec, for the common case where the value is already a string.
416
+ */
417
+ export function cookieSignal(name, initial, options = {}) {
418
+ return cookieState(name, initial, stringCookie, options);
419
+ }
333
420
  // ─── Clipboard & Web Share ───────────────────────────────────────────
334
421
  /** Async clipboard access. Methods return `false`/`null` when unavailable. */
335
422
  export const clipboard = {
package/dist/hydrate.js CHANGED
@@ -358,29 +358,11 @@ function hydrateSlot(slot, node, value, cleanups, mountHooks, markerCursor) {
358
358
  }
359
359
  }
360
360
  /**
361
- * Hydrate a text slot. SSR inlined the value as a text node (or skipped
362
- * it for null/false/undefined). We locate the **first text node sibling
363
- * preceding the path's terminal index** that's where SSR wrote the
364
- * value and wire an effect that overwrites its `data` on changes.
365
- *
366
- * For reactive values (signals/functions), the effect updates the
367
- * existing text node in place. For nested TemplateResults, we
368
- * recursively hydrate against the captured sibling range.
369
- */
370
- /**
371
- * First text node inside a marker pair's range, or a fresh empty one inserted
372
- * before the end marker (when the SSR value was empty → no text node yet).
361
+ * Hydrate a text slot against its SSR boundary-marker pair. A reactive slot
362
+ * (signal/function) always goes through the swap-capable structured path so a
363
+ * value that changes type (scalar template array) re-renders correctly; a
364
+ * direct template/array adopts its SSR range once; a static scalar is left as-is.
373
365
  */
374
- function reactiveTextNode(pair) {
375
- for (let n = pair.start.nextSibling; n !== null && n !== pair.end; n = n.nextSibling) {
376
- if (n.nodeType === 3 /* TEXT */)
377
- return n;
378
- }
379
- const doc = pair.end.ownerDocument ?? document;
380
- const fresh = doc.createTextNode("");
381
- pair.end.parentNode?.insertBefore(fresh, pair.end);
382
- return fresh;
383
- }
384
366
  function hydrateTextSlot(commentMarker, value, cleanups, mountHooks, markerCursor) {
385
367
  // Every text slot is SSR-wrapped in a <!--$-->…<!--/$--> pair, and its path
386
368
  // resolves (via collapseMarkerRanges) to the start marker. Consume the
@@ -395,39 +377,32 @@ function hydrateTextSlot(commentMarker, value, cleanups, mountHooks, markerCurso
395
377
  const reactiveFn = isSignal(value) || typeof value === "function"
396
378
  ? value
397
379
  : null;
398
- const current = reactiveFn ? reactiveFn() : value;
399
- // Structured value (nested template / array): reactive swap on change;
400
- // direct → adopt the SSR range once (inner bindings wired against it).
401
- if (isTemplateResult(current) || Array.isArray(current)) {
402
- if (reactiveFn) {
403
- hydrateReactiveStructured(reactiveFn, pair, cleanups, mountHooks, markerCursor);
404
- return;
405
- }
380
+ // Reactive slot its value TYPE can change across renders (scalar template
381
+ // array), e.g. `${() => collapsed() ? '' : html`<span>…</span>`}`. Always
382
+ // use the swap-capable structured path: its effect re-renders the value
383
+ // (whatever type) into the marker range via renderValueToNodes. Locking a
384
+ // reactive slot to a scalar text-node effect (based on its FIRST value) would
385
+ // String() a later template/array into "[object Object]".
386
+ if (reactiveFn) {
387
+ hydrateReactiveStructured(reactiveFn, pair, cleanups, mountHooks, markerCursor);
388
+ return;
389
+ }
390
+ // Non-reactive (direct) value — adopt the SSR range once.
391
+ if (isTemplateResult(value) || Array.isArray(value)) {
406
392
  const range = [];
407
393
  for (let n = pair.start.nextSibling; n !== null && n !== pair.end; n = n.nextSibling) {
408
394
  range.push(n);
409
395
  }
410
- if (isTemplateResult(current)) {
411
- hydrateTemplateResult(current, range, cleanups, mountHooks, markerCursor);
396
+ if (isTemplateResult(value)) {
397
+ hydrateTemplateResult(value, range, cleanups, mountHooks, markerCursor);
412
398
  }
413
- else if (Array.isArray(current)) {
414
- // Direct (non-reactive) array — hydrate each item so its inner marker
415
- // pairs are consumed and the cursor stays aligned (same as a reactive
416
- // array's first run).
417
- hydrateArrayItems(current, range, cleanups, mountHooks, markerCursor);
399
+ else if (Array.isArray(value)) {
400
+ // Direct array — hydrate each item so its inner marker pairs are
401
+ // consumed and the cursor stays aligned.
402
+ hydrateArrayItems(value, range, cleanups, mountHooks, markerCursor);
418
403
  }
419
- return;
420
- }
421
- // Scalar: a reactive scalar updates the range's text node on change; a static
422
- // scalar is already rendered between the markers (nothing to wire).
423
- if (reactiveFn) {
424
- const textNode = reactiveTextNode(pair);
425
- const dispose = effect(() => {
426
- const v = reactiveFn();
427
- textNode.data = v == null || v === false ? "" : String(v);
428
- });
429
- cleanups.push(dispose);
430
404
  }
405
+ // else: static scalar — already rendered between the markers.
431
406
  }
432
407
  /**
433
408
  * Pre-marker fallback — best-effort hydration when the SSR markup carries no
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export type { CookieOptions, PersistedSignalOptions, ShareData, StorageArea, WebStorageOptions, WindowSize, } from "./browser.js";
2
- export { back, clipboard, cookie, forward, hash, mediaQuery, navigate, online, persistedSignal, prefersDark, queryParam, redirect, reload, replace, session, share, storage, visibility, WebStorage, windowSize, } from "./browser.js";
1
+ export type { CookieCodec, CookieOptions, PersistedSignalOptions, ShareData, StorageArea, WebStorageOptions, WindowSize, } from "./browser.js";
2
+ 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
3
  export { type ClassValue, clsx, cn, twMerge } from "./cn.js";
4
4
  export type { Command } from "./command.js";
5
5
  export { command } from "./command.js";
@@ -19,7 +19,6 @@ export { DEFAULT_LIVE_EVENT_PATH, type LiveHttpContext, type LiveHttpRouter, typ
19
19
  export { batch, effect, isSignal, memo, onCleanup, type ReadSignal, type Signal, signal, untrack, } from "./reactive.js";
20
20
  export { type Disposer, render } from "./render.js";
21
21
  export { type AuroraHttpContext, type AuroraResponse, type AuroraRouteConfig, auroraRoute, } from "./route.js";
22
- export { createRpcClient, isRpcError, type RpcCall, type RpcClient, type RpcClientOptions, RpcError, type RpcResult, } from "./rpc.js";
23
22
  export { renderToString } from "./ssr.js";
24
23
  export type { TemplateResult } from "./types.js";
25
24
  export { getRouteManifest, setRouteManifest, urlFor } from "./url.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { back, clipboard, cookie, forward, hash, mediaQuery, navigate, online, persistedSignal, prefersDark, queryParam, redirect, reload, replace, session, share, storage, visibility, WebStorage, windowSize, } from "./browser.js";
1
+ 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
2
  export { clsx, cn, twMerge } from "./cn.js";
3
3
  export { command } from "./command.js";
4
4
  export { component, onMount, onUnmount } from "./component.js";
@@ -15,6 +15,5 @@ export { DEFAULT_LIVE_EVENT_PATH, wireLiveEvents, } from "./liveServer.js";
15
15
  export { batch, effect, isSignal, memo, onCleanup, signal, untrack, } from "./reactive.js";
16
16
  export { render } from "./render.js";
17
17
  export { auroraRoute, } from "./route.js";
18
- export { createRpcClient, isRpcError, RpcError, } from "./rpc.js";
19
18
  export { renderToString } from "./ssr.js";
20
19
  export { getRouteManifest, setRouteManifest, urlFor } from "./url.js";
package/dist/rpc.d.ts CHANGED
@@ -1,17 +1,6 @@
1
- /**
2
- * Browser JSON-RPC 2.0 client for Ream's RPC endpoint. `@c9up/ream`'s
3
- * RpcProvider mounts `POST /rpc` and speaks JSON-RPC 2.0 (single + batch); this
4
- * client builds on aurora's {@link HttpClient}, inheriting its base URL, auth
5
- * headers, and timeouts.
6
- *
7
- * const rpc = createRpcClient() // POST /rpc, same-origin
8
- * const result = await rpc.call('task.validate', { id }) // typed via call<T>()
9
- * const user = await rpc.call('user.find', { id }, isUser) // validated, cast-free
10
- *
11
- * Pairs with aurora's `command()` for reactive calls:
12
- * const validate = command((p) => rpc.call('task.validate', p))
13
- */
14
1
  import { HttpClient } from "./http.js";
2
+ export { isRpcError, type RpcCall, type RpcCallOptions, type RpcClient, RpcError, type RpcResult, } from "@c9up/comet";
3
+ import type { RpcClient } from "@c9up/comet";
15
4
  export interface RpcClientOptions {
16
5
  /** Endpoint path. Default `/rpc` (matches RpcProvider's default). */
17
6
  url?: string;
@@ -20,36 +9,8 @@ export interface RpcClientOptions {
20
9
  /** Default headers — only used when no `http` client is supplied. */
21
10
  headers?: Record<string, string>;
22
11
  }
23
- /** A JSON-RPC 2.0 error returned by the server (code + message + optional data). */
24
- export declare class RpcError extends Error {
25
- readonly code: number;
26
- readonly data?: unknown;
27
- constructor(code: number, message: string, data?: unknown);
28
- }
29
- /** Type guard for {@link RpcError}. */
30
- export declare function isRpcError(value: unknown): value is RpcError;
31
- /** One call in a batch. `parse` optionally validates that call's result (cast-free). */
32
- export interface RpcCall<T = unknown> {
33
- method: string;
34
- params?: unknown;
35
- parse?: (data: unknown) => T;
36
- }
37
- /** A settled batch entry — the result, or the JSON-RPC error for that call. */
38
- export type RpcResult<T = unknown> = {
39
- ok: true;
40
- value: T;
41
- } | {
42
- ok: false;
43
- error: RpcError;
44
- };
45
- export interface RpcClient {
46
- /**
47
- * Call one method. Returns the result, or throws {@link RpcError} on a
48
- * JSON-RPC error. Pass `parse` to validate the result at runtime (and skip
49
- * the unchecked `T` assertion).
50
- */
51
- call<T = unknown>(method: string, params?: unknown, parse?: (data: unknown) => T): Promise<T>;
52
- /** Send a JSON-RPC batch. Returns one settled entry per call, in request order. */
53
- batch(calls: RpcCall[]): Promise<RpcResult[]>;
54
- }
12
+ /**
13
+ * Create a JSON-RPC client bound to aurora's HttpClient transport. Inherits the
14
+ * supplied (or a fresh) HttpClient's base URL, auth headers, and timeouts.
15
+ */
55
16
  export declare function createRpcClient(options?: RpcClientOptions): RpcClient;
package/dist/rpc.js CHANGED
@@ -1,97 +1,29 @@
1
1
  /**
2
- * Browser JSON-RPC 2.0 client for Ream's RPC endpoint. `@c9up/ream`'s
3
- * RpcProvider mounts `POST /rpc` and speaks JSON-RPC 2.0 (single + batch); this
4
- * client builds on aurora's {@link HttpClient}, inheriting its base URL, auth
5
- * headers, and timeouts.
2
+ * Browser JSON-RPC 2.0 client for Ream's RPC endpoint — aurora's thin binding
3
+ * over the agnostic {@link https://github.com/C9up/comet | @c9up/comet} client.
4
+ * It wires aurora's {@link HttpClient} (base URL, auth headers, timeouts) as
5
+ * comet's transport, and re-exports the protocol surface so call sites keep
6
+ * importing everything from `@c9up/aurora`.
6
7
  *
7
- * const rpc = createRpcClient() // POST /rpc, same-origin
8
- * const result = await rpc.call('task.validate', { id }) // typed via call<T>()
9
- * const user = await rpc.call('user.find', { id }, isUser) // validated, cast-free
8
+ * const rpc = createRpcClient() // POST /rpc, same-origin
9
+ * const result = await rpc.call('task.validate', { id }) // typed via call<T>()
10
+ * const user = await rpc.call('user.find', { id }, { parse: isUser }) // validated, cast-free
11
+ * await rpc.call('slow.op', p, { signal: ac.signal }) // abortable
10
12
  *
11
13
  * Pairs with aurora's `command()` for reactive calls:
12
14
  * const validate = command((p) => rpc.call('task.validate', p))
13
15
  */
16
+ import { createRpcClient as createCometRpcClient } from "@c9up/comet";
14
17
  import { HttpClient } from "./http.js";
15
- /** A JSON-RPC 2.0 error returned by the server (code + message + optional data). */
16
- export class RpcError extends Error {
17
- code;
18
- data;
19
- constructor(code, message, data) {
20
- super(message);
21
- this.name = "RpcError";
22
- this.code = code;
23
- this.data = data;
24
- }
25
- }
26
- /** Type guard for {@link RpcError}. */
27
- export function isRpcError(value) {
28
- return value instanceof RpcError;
29
- }
30
- function isObject(value) {
31
- return typeof value === "object" && value !== null;
32
- }
33
- /** Turn a JSON-RPC `error` member into an {@link RpcError}. */
34
- function toRpcError(error) {
35
- if (isObject(error) &&
36
- typeof error.code === "number" &&
37
- typeof error.message === "string") {
38
- return new RpcError(error.code, error.message, error.data);
39
- }
40
- return new RpcError(-32603, "Malformed JSON-RPC error envelope", error);
41
- }
18
+ export { isRpcError, RpcError, } from "@c9up/comet";
19
+ /**
20
+ * Create a JSON-RPC client bound to aurora's HttpClient transport. Inherits the
21
+ * supplied (or a fresh) HttpClient's base URL, auth headers, and timeouts.
22
+ */
42
23
  export function createRpcClient(options = {}) {
43
24
  const http = options.http ?? new HttpClient({ headers: options.headers });
44
- const url = options.url ?? "/rpc";
45
- let nextId = 0;
46
- return {
47
- async call(method, params, parse) {
48
- const id = ++nextId;
49
- const res = await http.post(url, {
50
- jsonrpc: "2.0",
51
- method,
52
- params,
53
- id,
54
- });
55
- if (!isObject(res)) {
56
- throw new RpcError(-32603, `Malformed JSON-RPC response for "${method}"`);
57
- }
58
- if (res.error !== undefined)
59
- throw toRpcError(res.error);
60
- // Result boundary — the same unchecked `T` assertion HttpClient uses,
61
- // with `parse` as the cast-free, runtime-validated escape hatch.
62
- return parse ? parse(res.result) : res.result;
63
- },
64
- async batch(calls) {
65
- if (calls.length === 0)
66
- return [];
67
- const requests = calls.map((c, index) => ({
68
- jsonrpc: "2.0",
69
- method: c.method,
70
- params: c.params,
71
- id: index, // index = request position; responses are matched back by id
72
- }));
73
- const res = await http.post(url, requests);
74
- if (!Array.isArray(res)) {
75
- throw new RpcError(-32603, "Malformed JSON-RPC batch response");
76
- }
77
- const byId = new Map();
78
- for (const item of res)
79
- if (isObject(item))
80
- byId.set(item.id, item);
81
- return calls.map((c, index) => {
82
- const envelope = byId.get(index);
83
- if (!envelope) {
84
- return {
85
- ok: false,
86
- error: new RpcError(-32603, `No response for "${c.method}"`),
87
- };
88
- }
89
- if (envelope.error !== undefined) {
90
- return { ok: false, error: toRpcError(envelope.error) };
91
- }
92
- const value = c.parse ? c.parse(envelope.result) : envelope.result;
93
- return { ok: true, value };
94
- });
95
- },
96
- };
25
+ return createCometRpcClient({
26
+ url: options.url,
27
+ transport: (url, body, { signal }) => http.post(url, body, { signal }),
28
+ });
97
29
  }
@@ -66,5 +66,17 @@ export interface RenderPageOptions {
66
66
  * in SSR and the browser. Omit if the app doesn't use `urlFor`.
67
67
  */
68
68
  routes?: Record<string, string>;
69
+ /**
70
+ * Allowlist of cookie names to seed into the SSR cookie store so a page can
71
+ * read them during render via aurora's `cookieSignal` / `cookie.get` — the
72
+ * server then renders the SAME UI state the browser will (no hydration flash
73
+ * of the default, e.g. a sidebar animating open→collapsed on every load).
74
+ *
75
+ * Only the NAMED cookies are read (from `ctx.request`); they are NOT
76
+ * serialized into the page — the browser reads them from `document.cookie`.
77
+ * NEVER list a session / signed / encrypted / `httpOnly` cookie here: those
78
+ * must stay server-only. Use plain, JS-readable cookies for UI state.
79
+ */
80
+ cookies?: string[];
69
81
  }
70
82
  export declare function renderPage<P>(ctx: RenderHttpContext, pages: Pages, name: string, props: P, options?: RenderPageOptions): Promise<void>;
@@ -22,13 +22,36 @@
22
22
  * </script>
23
23
  * </body>
24
24
  */
25
+ import { setCookieStore } from "../browser.js";
25
26
  import { renderToString } from "../ssr.js";
26
27
  import { setRouteManifest } from "../url.js";
28
+ function isCookieReadable(request) {
29
+ return (typeof request === "object" &&
30
+ request !== null &&
31
+ "cookie" in request &&
32
+ typeof request.cookie === "function");
33
+ }
34
+ /** Read the allowlisted cookies off the request into a `name → value` seed. */
35
+ function readRequestCookies(request, names) {
36
+ if (!isCookieReadable(request))
37
+ return {};
38
+ const seed = {};
39
+ for (const name of names) {
40
+ const value = request.cookie(name);
41
+ if (value !== null)
42
+ seed[name] = value;
43
+ }
44
+ return seed;
45
+ }
27
46
  export async function renderPage(ctx, pages, name, props, options = {}) {
28
47
  // Install the route manifest BEFORE rendering so a page calling `urlFor`
29
48
  // during SSR resolves against the same map the client will get.
30
49
  if (options.routes)
31
50
  setRouteManifest(options.routes);
51
+ // Seed the request's UI cookies so the page reads the SAME state server-side
52
+ // that the browser will after hydration. Set synchronously right before the
53
+ // (synchronous) render — read cookie signals at the top of the page.
54
+ setCookieStore(options.cookies ? readRequestCookies(ctx.request, options.cookies) : {});
32
55
  const factory = await pages.resolve(name);
33
56
  // The factory must be invoked the SAME way client-side for hydrate
34
57
  // to find matching slots — `Page(props)` is the contract.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/aurora",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
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",
@@ -34,17 +34,26 @@
34
34
  "./server": {
35
35
  "types": "./dist/server.d.ts",
36
36
  "import": "./dist/server.js"
37
+ },
38
+ "./rpc": {
39
+ "types": "./dist/rpc.d.ts",
40
+ "import": "./dist/rpc.js"
37
41
  }
38
42
  },
39
43
  "peerDependencies": {
44
+ "@c9up/comet": "^0.1.0",
40
45
  "@c9up/ream": "^0.1.0"
41
46
  },
42
47
  "peerDependenciesMeta": {
48
+ "@c9up/comet": {
49
+ "optional": true
50
+ },
43
51
  "@c9up/ream": {
44
52
  "optional": true
45
53
  }
46
54
  },
47
55
  "devDependencies": {
56
+ "@c9up/comet": "^0.1.0",
48
57
  "@types/node": "^22.19.15",
49
58
  "@vitest/browser": "4.1.6",
50
59
  "@vitest/browser-playwright": "^4.1.9",
package/src/browser.ts CHANGED
@@ -384,14 +384,45 @@ export interface CookieOptions {
384
384
  secure?: boolean;
385
385
  }
386
386
 
387
+ /**
388
+ * SSR seed of the request's cookies — a `name → value` map installed by
389
+ * `renderPage` (server-side) before a page renders, so {@link cookie.get} and
390
+ * {@link cookieSignal} can read the SAME values during SSR that the browser
391
+ * will read from `document.cookie` after hydration. Without it the server has
392
+ * no view of the request cookies and renders default UI state → a flash /
393
+ * mismatch on hydration (the classic collapsed-sidebar flicker).
394
+ *
395
+ * Module-global by necessity (the page factory reads it ambiently). It is set
396
+ * synchronously immediately before the synchronous render, so read your cookie
397
+ * signals at the TOP of a page (before any `await`) to avoid a cross-request
398
+ * race under concurrent async page factories. In the browser it is unused —
399
+ * {@link cookie.get} reads `document.cookie` directly there.
400
+ */
401
+ let cookieSeed: Record<string, string> = {};
402
+
403
+ /**
404
+ * Install the SSR cookie seed (`name → value`). Called by `renderPage` from its
405
+ * `cookies` allowlist; the hydrate bootstrap does NOT call it (the browser reads
406
+ * `document.cookie`). Replaces any previous seed.
407
+ */
408
+ export function setCookieStore(values: Record<string, string>): void {
409
+ cookieSeed = { ...values };
410
+ }
411
+
412
+ /** The currently-installed SSR cookie seed (mainly for tests/introspection). */
413
+ export function getCookieStore(): Record<string, string> {
414
+ return { ...cookieSeed };
415
+ }
416
+
387
417
  /**
388
418
  * SSR-safe cookie accessor — the one store {@link WebStorage} can't cover, for
389
- * values the server also reads. Reads return `null` during SSR; writes are a
390
- * no-op. Names and values are URL-encoded.
419
+ * values the server also reads. In the browser reads/writes hit
420
+ * `document.cookie`; during SSR reads come from the {@link setCookieStore} seed
421
+ * and writes are a no-op. Names and values are URL-encoded.
391
422
  */
392
423
  export const cookie = {
393
424
  get(name: string): string | null {
394
- if (typeof document === "undefined") return null;
425
+ if (typeof document === "undefined") return cookieSeed[name] ?? null;
395
426
  const prefix = `${encodeURIComponent(name)}=`;
396
427
  for (const part of document.cookie.split("; ")) {
397
428
  if (part.startsWith(prefix)) {
@@ -422,6 +453,91 @@ export const cookie = {
422
453
  },
423
454
  };
424
455
 
456
+ /**
457
+ * Maps a typed value to and from the string a cookie stores. Pass one to
458
+ * {@link cookieState} for non-string state (booleans, enums, JSON).
459
+ */
460
+ export interface CookieCodec<T> {
461
+ /** Parse the raw cookie string into `T`. */
462
+ parse(raw: string): T;
463
+ /** Serialize `T` into the raw cookie string. */
464
+ serialize(value: T): string;
465
+ }
466
+
467
+ /** Codec for a boolean cookie, stored as `"1"` / `"0"`. */
468
+ export const booleanCookie: CookieCodec<boolean> = {
469
+ parse: (raw) => raw === "1" || raw === "true",
470
+ serialize: (value) => (value ? "1" : "0"),
471
+ };
472
+
473
+ /**
474
+ * Codec for a JSON-serializable value. A parse failure (malformed cookie) is
475
+ * surfaced by returning `fallback`, so a tampered cookie never throws mid-render.
476
+ */
477
+ export function jsonCookie<T>(fallback: T): CookieCodec<T> {
478
+ return {
479
+ parse: (raw) => {
480
+ try {
481
+ return JSON.parse(raw);
482
+ } catch {
483
+ return fallback;
484
+ }
485
+ },
486
+ serialize: (value) => JSON.stringify(value),
487
+ };
488
+ }
489
+
490
+ /**
491
+ * A {@link Signal} backed by a cookie — the isomorphic counterpart to
492
+ * {@link persistedSignal}. It is seeded from the cookie (the
493
+ * {@link setCookieStore} request seed during SSR, `document.cookie` in the
494
+ * browser) and persists every change back to the cookie via a reactive
495
+ * `effect`. Because the same seed is visible on both sides, a page renders the
496
+ * SAME markup server- and client-side — no hydration mismatch, no flash of the
497
+ * default state (e.g. a sidebar that animates open→collapsed on every load).
498
+ *
499
+ * Use {@link cookieSignal} for string state; pass a {@link CookieCodec} here for
500
+ * booleans/enums/JSON (e.g. {@link booleanCookie}). Created inside a component's
501
+ * setup, the persistence effect is disposed with it.
502
+ *
503
+ * Read it at the TOP of a page (before any `await`) so the SSR seed is current —
504
+ * see {@link setCookieStore}.
505
+ */
506
+ export function cookieState<T>(
507
+ name: string,
508
+ initial: T,
509
+ codec: CookieCodec<T>,
510
+ options: CookieOptions = {},
511
+ ): Signal<T> {
512
+ const raw = cookie.get(name);
513
+ const sig = signal<T>(raw === null ? initial : codec.parse(raw));
514
+
515
+ // Mirror every change back to the cookie. Runs once immediately (a no-op
516
+ // write during SSR, where `cookie.set` bails) then on each change.
517
+ effect(() => {
518
+ cookie.set(name, codec.serialize(sig()), options);
519
+ });
520
+
521
+ return sig;
522
+ }
523
+
524
+ const stringCookie: CookieCodec<string> = {
525
+ parse: (raw) => raw,
526
+ serialize: (value) => value,
527
+ };
528
+
529
+ /**
530
+ * A {@link Signal} backed by a string cookie — {@link cookieState} with an
531
+ * identity codec, for the common case where the value is already a string.
532
+ */
533
+ export function cookieSignal(
534
+ name: string,
535
+ initial: string,
536
+ options: CookieOptions = {},
537
+ ): Signal<string> {
538
+ return cookieState(name, initial, stringCookie, options);
539
+ }
540
+
425
541
  // ─── Clipboard & Web Share ───────────────────────────────────────────
426
542
 
427
543
  /** Async clipboard access. Methods return `false`/`null` when unavailable. */
package/src/hydrate.ts CHANGED
@@ -481,33 +481,11 @@ function hydrateSlot(
481
481
  }
482
482
 
483
483
  /**
484
- * Hydrate a text slot. SSR inlined the value as a text node (or skipped
485
- * it for null/false/undefined). We locate the **first text node sibling
486
- * preceding the path's terminal index** that's where SSR wrote the
487
- * value and wire an effect that overwrites its `data` on changes.
488
- *
489
- * For reactive values (signals/functions), the effect updates the
490
- * existing text node in place. For nested TemplateResults, we
491
- * recursively hydrate against the captured sibling range.
492
- */
493
- /**
494
- * First text node inside a marker pair's range, or a fresh empty one inserted
495
- * before the end marker (when the SSR value was empty → no text node yet).
484
+ * Hydrate a text slot against its SSR boundary-marker pair. A reactive slot
485
+ * (signal/function) always goes through the swap-capable structured path so a
486
+ * value that changes type (scalar template array) re-renders correctly; a
487
+ * direct template/array adopts its SSR range once; a static scalar is left as-is.
496
488
  */
497
- function reactiveTextNode(pair: MarkerPair): Text {
498
- for (
499
- let n = pair.start.nextSibling;
500
- n !== null && n !== pair.end;
501
- n = n.nextSibling
502
- ) {
503
- if (n.nodeType === 3 /* TEXT */) return n as Text;
504
- }
505
- const doc = pair.end.ownerDocument ?? document;
506
- const fresh = doc.createTextNode("");
507
- pair.end.parentNode?.insertBefore(fresh, pair.end);
508
- return fresh;
509
- }
510
-
511
489
  function hydrateTextSlot(
512
490
  commentMarker: Node,
513
491
  value: unknown,
@@ -536,21 +514,26 @@ function hydrateTextSlot(
536
514
  isSignal(value) || typeof value === "function"
537
515
  ? (value as () => unknown)
538
516
  : null;
539
- const current = reactiveFn ? reactiveFn() : value;
540
517
 
541
- // Structured value (nested template / array): reactive swap on change;
542
- // direct adopt the SSR range once (inner bindings wired against it).
543
- if (isTemplateResult(current) || Array.isArray(current)) {
544
- if (reactiveFn) {
545
- hydrateReactiveStructured(
546
- reactiveFn,
547
- pair,
548
- cleanups,
549
- mountHooks,
550
- markerCursor,
551
- );
552
- return;
553
- }
518
+ // Reactive slot — its value TYPE can change across renders (scalar template
519
+ // array), e.g. `${() => collapsed() ? '' : html`<span>…</span>`}`. Always
520
+ // use the swap-capable structured path: its effect re-renders the value
521
+ // (whatever type) into the marker range via renderValueToNodes. Locking a
522
+ // reactive slot to a scalar text-node effect (based on its FIRST value) would
523
+ // String() a later template/array into "[object Object]".
524
+ if (reactiveFn) {
525
+ hydrateReactiveStructured(
526
+ reactiveFn,
527
+ pair,
528
+ cleanups,
529
+ mountHooks,
530
+ markerCursor,
531
+ );
532
+ return;
533
+ }
534
+
535
+ // Non-reactive (direct) value — adopt the SSR range once.
536
+ if (isTemplateResult(value) || Array.isArray(value)) {
554
537
  const range: ChildNode[] = [];
555
538
  for (
556
539
  let n = pair.start.nextSibling;
@@ -559,27 +542,15 @@ function hydrateTextSlot(
559
542
  ) {
560
543
  range.push(n as ChildNode);
561
544
  }
562
- if (isTemplateResult(current)) {
563
- hydrateTemplateResult(current, range, cleanups, mountHooks, markerCursor);
564
- } else if (Array.isArray(current)) {
565
- // Direct (non-reactive) array — hydrate each item so its inner marker
566
- // pairs are consumed and the cursor stays aligned (same as a reactive
567
- // array's first run).
568
- hydrateArrayItems(current, range, cleanups, mountHooks, markerCursor);
545
+ if (isTemplateResult(value)) {
546
+ hydrateTemplateResult(value, range, cleanups, mountHooks, markerCursor);
547
+ } else if (Array.isArray(value)) {
548
+ // Direct array — hydrate each item so its inner marker pairs are
549
+ // consumed and the cursor stays aligned.
550
+ hydrateArrayItems(value, range, cleanups, mountHooks, markerCursor);
569
551
  }
570
- return;
571
- }
572
-
573
- // Scalar: a reactive scalar updates the range's text node on change; a static
574
- // scalar is already rendered between the markers (nothing to wire).
575
- if (reactiveFn) {
576
- const textNode = reactiveTextNode(pair);
577
- const dispose = effect(() => {
578
- const v = reactiveFn();
579
- textNode.data = v == null || v === false ? "" : String(v);
580
- });
581
- cleanups.push(dispose);
582
552
  }
553
+ // else: static scalar — already rendered between the markers.
583
554
  }
584
555
 
585
556
  /**
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@
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
7
  export type {
8
+ CookieCodec,
8
9
  CookieOptions,
9
10
  PersistedSignalOptions,
10
11
  ShareData,
@@ -14,10 +15,15 @@ export type {
14
15
  } from "./browser.js";
15
16
  export {
16
17
  back,
18
+ booleanCookie,
17
19
  clipboard,
18
20
  cookie,
21
+ cookieSignal,
22
+ cookieState,
19
23
  forward,
24
+ getCookieStore,
20
25
  hash,
26
+ jsonCookie,
21
27
  mediaQuery,
22
28
  navigate,
23
29
  online,
@@ -28,6 +34,7 @@ export {
28
34
  reload,
29
35
  replace,
30
36
  session,
37
+ setCookieStore,
31
38
  share,
32
39
  storage,
33
40
  visibility,
@@ -116,15 +123,6 @@ export {
116
123
  type AuroraRouteConfig,
117
124
  auroraRoute,
118
125
  } from "./route.js";
119
- export {
120
- createRpcClient,
121
- isRpcError,
122
- type RpcCall,
123
- type RpcClient,
124
- type RpcClientOptions,
125
- RpcError,
126
- type RpcResult,
127
- } from "./rpc.js";
128
126
  export { renderToString } from "./ssr.js";
129
127
  export type { TemplateResult } from "./types.js";
130
128
  export { getRouteManifest, setRouteManifest, urlFor } from "./url.js";
package/src/rpc.ts CHANGED
@@ -1,18 +1,32 @@
1
1
  /**
2
- * Browser JSON-RPC 2.0 client for Ream's RPC endpoint. `@c9up/ream`'s
3
- * RpcProvider mounts `POST /rpc` and speaks JSON-RPC 2.0 (single + batch); this
4
- * client builds on aurora's {@link HttpClient}, inheriting its base URL, auth
5
- * headers, and timeouts.
2
+ * Browser JSON-RPC 2.0 client for Ream's RPC endpoint — aurora's thin binding
3
+ * over the agnostic {@link https://github.com/C9up/comet | @c9up/comet} client.
4
+ * It wires aurora's {@link HttpClient} (base URL, auth headers, timeouts) as
5
+ * comet's transport, and re-exports the protocol surface so call sites keep
6
+ * importing everything from `@c9up/aurora`.
6
7
  *
7
- * const rpc = createRpcClient() // POST /rpc, same-origin
8
- * const result = await rpc.call('task.validate', { id }) // typed via call<T>()
9
- * const user = await rpc.call('user.find', { id }, isUser) // validated, cast-free
8
+ * const rpc = createRpcClient() // POST /rpc, same-origin
9
+ * const result = await rpc.call('task.validate', { id }) // typed via call<T>()
10
+ * const user = await rpc.call('user.find', { id }, { parse: isUser }) // validated, cast-free
11
+ * await rpc.call('slow.op', p, { signal: ac.signal }) // abortable
10
12
  *
11
13
  * Pairs with aurora's `command()` for reactive calls:
12
14
  * const validate = command((p) => rpc.call('task.validate', p))
13
15
  */
16
+ import { createRpcClient as createCometRpcClient } from "@c9up/comet";
14
17
  import { HttpClient } from "./http.js";
15
18
 
19
+ export {
20
+ isRpcError,
21
+ type RpcCall,
22
+ type RpcCallOptions,
23
+ type RpcClient,
24
+ RpcError,
25
+ type RpcResult,
26
+ } from "@c9up/comet";
27
+
28
+ import type { RpcClient } from "@c9up/comet";
29
+
16
30
  export interface RpcClientOptions {
17
31
  /** Endpoint path. Default `/rpc` (matches RpcProvider's default). */
18
32
  url?: string;
@@ -22,124 +36,15 @@ export interface RpcClientOptions {
22
36
  headers?: Record<string, string>;
23
37
  }
24
38
 
25
- /** A JSON-RPC 2.0 error returned by the server (code + message + optional data). */
26
- export class RpcError extends Error {
27
- readonly code: number;
28
- readonly data?: unknown;
29
- constructor(code: number, message: string, data?: unknown) {
30
- super(message);
31
- this.name = "RpcError";
32
- this.code = code;
33
- this.data = data;
34
- }
35
- }
36
-
37
- /** Type guard for {@link RpcError}. */
38
- export function isRpcError(value: unknown): value is RpcError {
39
- return value instanceof RpcError;
40
- }
41
-
42
- /** One call in a batch. `parse` optionally validates that call's result (cast-free). */
43
- export interface RpcCall<T = unknown> {
44
- method: string;
45
- params?: unknown;
46
- parse?: (data: unknown) => T;
47
- }
48
-
49
- /** A settled batch entry — the result, or the JSON-RPC error for that call. */
50
- export type RpcResult<T = unknown> =
51
- | { ok: true; value: T }
52
- | { ok: false; error: RpcError };
53
-
54
- export interface RpcClient {
55
- /**
56
- * Call one method. Returns the result, or throws {@link RpcError} on a
57
- * JSON-RPC error. Pass `parse` to validate the result at runtime (and skip
58
- * the unchecked `T` assertion).
59
- */
60
- call<T = unknown>(
61
- method: string,
62
- params?: unknown,
63
- parse?: (data: unknown) => T,
64
- ): Promise<T>;
65
- /** Send a JSON-RPC batch. Returns one settled entry per call, in request order. */
66
- batch(calls: RpcCall[]): Promise<RpcResult[]>;
67
- }
68
-
69
- function isObject(value: unknown): value is Record<string, unknown> {
70
- return typeof value === "object" && value !== null;
71
- }
72
-
73
- /** Turn a JSON-RPC `error` member into an {@link RpcError}. */
74
- function toRpcError(error: unknown): RpcError {
75
- if (
76
- isObject(error) &&
77
- typeof error.code === "number" &&
78
- typeof error.message === "string"
79
- ) {
80
- return new RpcError(error.code, error.message, error.data);
81
- }
82
- return new RpcError(-32603, "Malformed JSON-RPC error envelope", error);
83
- }
84
-
39
+ /**
40
+ * Create a JSON-RPC client bound to aurora's HttpClient transport. Inherits the
41
+ * supplied (or a fresh) HttpClient's base URL, auth headers, and timeouts.
42
+ */
85
43
  export function createRpcClient(options: RpcClientOptions = {}): RpcClient {
86
44
  const http = options.http ?? new HttpClient({ headers: options.headers });
87
- const url = options.url ?? "/rpc";
88
- let nextId = 0;
89
-
90
- return {
91
- async call<T>(
92
- method: string,
93
- params?: unknown,
94
- parse?: (data: unknown) => T,
95
- ): Promise<T> {
96
- const id = ++nextId;
97
- const res = await http.post<unknown>(url, {
98
- jsonrpc: "2.0",
99
- method,
100
- params,
101
- id,
102
- });
103
- if (!isObject(res)) {
104
- throw new RpcError(
105
- -32603,
106
- `Malformed JSON-RPC response for "${method}"`,
107
- );
108
- }
109
- if (res.error !== undefined) throw toRpcError(res.error);
110
- // Result boundary — the same unchecked `T` assertion HttpClient uses,
111
- // with `parse` as the cast-free, runtime-validated escape hatch.
112
- return parse ? parse(res.result) : (res.result as T);
113
- },
114
-
115
- async batch(calls: RpcCall[]): Promise<RpcResult[]> {
116
- if (calls.length === 0) return [];
117
- const requests = calls.map((c, index) => ({
118
- jsonrpc: "2.0",
119
- method: c.method,
120
- params: c.params,
121
- id: index, // index = request position; responses are matched back by id
122
- }));
123
- const res = await http.post<unknown>(url, requests);
124
- if (!Array.isArray(res)) {
125
- throw new RpcError(-32603, "Malformed JSON-RPC batch response");
126
- }
127
- const byId = new Map<unknown, Record<string, unknown>>();
128
- for (const item of res) if (isObject(item)) byId.set(item.id, item);
129
- return calls.map((c, index) => {
130
- const envelope = byId.get(index);
131
- if (!envelope) {
132
- return {
133
- ok: false,
134
- error: new RpcError(-32603, `No response for "${c.method}"`),
135
- };
136
- }
137
- if (envelope.error !== undefined) {
138
- return { ok: false, error: toRpcError(envelope.error) };
139
- }
140
- const value = c.parse ? c.parse(envelope.result) : envelope.result;
141
- return { ok: true, value };
142
- });
143
- },
144
- };
45
+ return createCometRpcClient({
46
+ url: options.url,
47
+ transport: (url, body, { signal }) =>
48
+ http.post<unknown>(url, body, { signal }),
49
+ });
145
50
  }
@@ -23,6 +23,7 @@
23
23
  * </body>
24
24
  */
25
25
 
26
+ import { setCookieStore } from "../browser.js";
26
27
  import type { Pages } from "../Pages.js";
27
28
  import { renderToString } from "../ssr.js";
28
29
  import { setRouteManifest } from "../url.js";
@@ -71,6 +72,46 @@ export interface RenderPageOptions {
71
72
  * in SSR and the browser. Omit if the app doesn't use `urlFor`.
72
73
  */
73
74
  routes?: Record<string, string>;
75
+ /**
76
+ * Allowlist of cookie names to seed into the SSR cookie store so a page can
77
+ * read them during render via aurora's `cookieSignal` / `cookie.get` — the
78
+ * server then renders the SAME UI state the browser will (no hydration flash
79
+ * of the default, e.g. a sidebar animating open→collapsed on every load).
80
+ *
81
+ * Only the NAMED cookies are read (from `ctx.request`); they are NOT
82
+ * serialized into the page — the browser reads them from `document.cookie`.
83
+ * NEVER list a session / signed / encrypted / `httpOnly` cookie here: those
84
+ * must stay server-only. Use plain, JS-readable cookies for UI state.
85
+ */
86
+ cookies?: string[];
87
+ }
88
+
89
+ /** A request that can read a cookie by name — the structural slice we need. */
90
+ interface CookieReadableRequest {
91
+ cookie(name: string): string | null;
92
+ }
93
+
94
+ function isCookieReadable(request: unknown): request is CookieReadableRequest {
95
+ return (
96
+ typeof request === "object" &&
97
+ request !== null &&
98
+ "cookie" in request &&
99
+ typeof request.cookie === "function"
100
+ );
101
+ }
102
+
103
+ /** Read the allowlisted cookies off the request into a `name → value` seed. */
104
+ function readRequestCookies(
105
+ request: unknown,
106
+ names: string[],
107
+ ): Record<string, string> {
108
+ if (!isCookieReadable(request)) return {};
109
+ const seed: Record<string, string> = {};
110
+ for (const name of names) {
111
+ const value = request.cookie(name);
112
+ if (value !== null) seed[name] = value;
113
+ }
114
+ return seed;
74
115
  }
75
116
 
76
117
  export async function renderPage<P>(
@@ -84,6 +125,13 @@ export async function renderPage<P>(
84
125
  // during SSR resolves against the same map the client will get.
85
126
  if (options.routes) setRouteManifest(options.routes);
86
127
 
128
+ // Seed the request's UI cookies so the page reads the SAME state server-side
129
+ // that the browser will after hydration. Set synchronously right before the
130
+ // (synchronous) render — read cookie signals at the top of the page.
131
+ setCookieStore(
132
+ options.cookies ? readRequestCookies(ctx.request, options.cookies) : {},
133
+ );
134
+
87
135
  const factory = await pages.resolve(name);
88
136
  // The factory must be invoked the SAME way client-side for hydrate
89
137
  // to find matching slots — `Page(props)` is the contract.