@c9up/aurora 0.1.16 → 0.1.18

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.
@@ -22,6 +22,13 @@ export interface AuroraManagerConfig {
22
22
  * Override only if you want to serve a custom build.
23
23
  */
24
24
  auroraDistRoot?: string;
25
+ /**
26
+ * Filesystem path to `@c9up/comet`'s `dist/`. Defaults to the dist of the
27
+ * installed (optional-peer) `@c9up/comet` — resolved automatically so the
28
+ * RPC client's `import '@c9up/comet'` works in the no-bundler browser with
29
+ * zero app wiring. Left unserved when comet isn't installed (no RPC).
30
+ */
31
+ cometDistRoot?: string;
25
32
  /**
26
33
  * URL prefix the asset routes mount under. The aurora runtime is served
27
34
  * from `<assetsPrefix>/aurora/*` and the app's pages from
@@ -41,6 +48,10 @@ export declare class AuroraManager {
41
48
  readonly auroraAssetPath: string;
42
49
  /** Mount path for the app's pages — `<assetsPrefix>/pages`. */
43
50
  readonly pageAssetPath: string;
51
+ /** Mount path for the RPC client's `@c9up/comet` runtime — `<assetsPrefix>/comet`. */
52
+ readonly cometAssetPath: string;
53
+ /** Resolved `@c9up/comet` dist dir, or `null` when comet isn't installed. */
54
+ readonly cometDistRoot: string | null;
44
55
  constructor(config: AuroraManagerConfig);
45
56
  /**
46
57
  * SSR + hydrate + ship the document. The importmap default points
@@ -59,4 +70,10 @@ export declare class AuroraManager {
59
70
  * `GET /_assets/pages/*`.
60
71
  */
61
72
  pageAssetsHandler(): (ctx: AssetsHttpContext) => Promise<void>;
73
+ /**
74
+ * Handler for `@c9up/comet`'s runtime (the RPC client). Mount on
75
+ * `GET <cometAssetPath>/*`. Returns `null` when comet isn't installed —
76
+ * the provider then skips the route (no RPC, nothing to serve).
77
+ */
78
+ cometAssetsHandler(): ((ctx: AssetsHttpContext) => Promise<void>) | null;
62
79
  }
@@ -15,8 +15,21 @@ import { dirname, resolve as resolvePath } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { Pages } from "./Pages.js";
17
17
  import { renderPage, } from "./server/renderPage.js";
18
- import { serveAssets } from "./server/serveAssets.js";
18
+ import { packageAssetDir, serveAssets, } from "./server/serveAssets.js";
19
19
  const DEFAULT_AURORA_DIST = resolvePath(dirname(fileURLToPath(import.meta.url)), "../dist");
20
+ /**
21
+ * Resolve `@c9up/comet`'s dist dir, or `null` when it isn't installed. comet is
22
+ * aurora's OPTIONAL peer (only present when the app uses RPC), so a missing one
23
+ * is expected — aurora then simply doesn't serve it or add it to the importmap.
24
+ */
25
+ function resolveCometDist() {
26
+ try {
27
+ return packageAssetDir("@c9up/comet");
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
20
33
  /** Normalize an asset prefix: ensure a leading slash, drop trailing slashes. */
21
34
  function normalizePrefix(prefix) {
22
35
  const withLead = prefix.startsWith("/") ? prefix : `/${prefix}`;
@@ -31,10 +44,16 @@ export class AuroraManager {
31
44
  auroraAssetPath;
32
45
  /** Mount path for the app's pages — `<assetsPrefix>/pages`. */
33
46
  pageAssetPath;
47
+ /** Mount path for the RPC client's `@c9up/comet` runtime — `<assetsPrefix>/comet`. */
48
+ cometAssetPath;
49
+ /** Resolved `@c9up/comet` dist dir, or `null` when comet isn't installed. */
50
+ cometDistRoot;
34
51
  constructor(config) {
35
52
  this.assetsPrefix = normalizePrefix(config.assetsPrefix ?? "/_assets");
36
53
  this.auroraAssetPath = `${this.assetsPrefix}/aurora`;
37
54
  this.pageAssetPath = `${this.assetsPrefix}/pages`;
55
+ this.cometAssetPath = `${this.assetsPrefix}/comet`;
56
+ this.cometDistRoot = config.cometDistRoot ?? resolveCometDist();
38
57
  // Pages serve their compiled JS from the same prefix unless the app
39
58
  // pins an explicit urlPrefix.
40
59
  this.pages = new Pages({
@@ -54,6 +73,12 @@ export class AuroraManager {
54
73
  ...options,
55
74
  importmap: {
56
75
  "@c9up/aurora": `${this.auroraAssetPath}/index.js`,
76
+ // Auto-map @c9up/comet when installed so `@c9up/aurora/rpc`'s bare
77
+ // `import '@c9up/comet'` resolves in the no-bundler browser — no
78
+ // app-side importmap wiring. Omitted when comet isn't present.
79
+ ...(this.cometDistRoot
80
+ ? { "@c9up/comet": `${this.cometAssetPath}/index.js` }
81
+ : {}),
57
82
  ...options?.importmap,
58
83
  },
59
84
  });
@@ -72,4 +97,14 @@ export class AuroraManager {
72
97
  pageAssetsHandler() {
73
98
  return serveAssets({ root: this.pages.root });
74
99
  }
100
+ /**
101
+ * Handler for `@c9up/comet`'s runtime (the RPC client). Mount on
102
+ * `GET <cometAssetPath>/*`. Returns `null` when comet isn't installed —
103
+ * the provider then skips the route (no RPC, nothing to serve).
104
+ */
105
+ cometAssetsHandler() {
106
+ return this.cometDistRoot
107
+ ? serveAssets({ root: this.cometDistRoot })
108
+ : null;
109
+ }
75
110
  }
@@ -67,6 +67,13 @@ export default class AuroraProvider {
67
67
  // `/_assets`) — set `config.aurora.assetsPrefix` to change the scheme.
68
68
  router.get(`${manager.auroraAssetPath}/*`, adaptHandler(manager.auroraAssetsHandler()));
69
69
  router.get(`${manager.pageAssetPath}/*`, adaptHandler(manager.pageAssetsHandler()));
70
+ // Serve @c9up/comet's runtime so the RPC client's bare `import
71
+ // '@c9up/comet'` resolves in the browser. Skipped when comet isn't
72
+ // installed (optional peer — the app doesn't use RPC).
73
+ const cometHandler = manager.cometAssetsHandler();
74
+ if (cometHandler) {
75
+ router.get(`${manager.cometAssetPath}/*`, adaptHandler(cometHandler));
76
+ }
70
77
  }
71
78
  async ready() { }
72
79
  async shutdown() { }
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/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.
@@ -9,6 +9,17 @@
9
9
  * and writes to `ctx.response`. Any context that satisfies
10
10
  * `AssetsHttpContext` (Ream, AdonisJS, anything duck-typed) works.
11
11
  */
12
+ /**
13
+ * Absolute dist directory of an installed package — for mounting its pre-built
14
+ * ESM as browser assets (`serveAssets({ root: packageAssetDir('@c9up/x') })`).
15
+ *
16
+ * Uses `import.meta.resolve`, which honours the package's `exports` `import`
17
+ * condition — so it works for `@c9up/*` import-only packages where
18
+ * `createRequire().resolve()` throws `ERR_PACKAGE_PATH_NOT_EXPORTED` (their
19
+ * `exports` carry no `require` condition, and `main` is ignored once `exports`
20
+ * exists). Throws if the package isn't installed/resolvable.
21
+ */
22
+ export declare function packageAssetDir(specifier: string): string;
12
23
  export interface AssetsRequest {
13
24
  /**
14
25
  * Read the wildcard `*` segment of the matched route. Most routers
@@ -10,7 +10,21 @@
10
10
  * `AssetsHttpContext` (Ream, AdonisJS, anything duck-typed) works.
11
11
  */
12
12
  import { readFile, realpath } from "node:fs/promises";
13
- import { extname, join, resolve as resolvePath, sep } from "node:path";
13
+ import { dirname, extname, join, resolve as resolvePath, sep } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ /**
16
+ * Absolute dist directory of an installed package — for mounting its pre-built
17
+ * ESM as browser assets (`serveAssets({ root: packageAssetDir('@c9up/x') })`).
18
+ *
19
+ * Uses `import.meta.resolve`, which honours the package's `exports` `import`
20
+ * condition — so it works for `@c9up/*` import-only packages where
21
+ * `createRequire().resolve()` throws `ERR_PACKAGE_PATH_NOT_EXPORTED` (their
22
+ * `exports` carry no `require` condition, and `main` is ignored once `exports`
23
+ * exists). Throws if the package isn't installed/resolvable.
24
+ */
25
+ export function packageAssetDir(specifier) {
26
+ return dirname(fileURLToPath(import.meta.resolve(specifier)));
27
+ }
14
28
  const CONTENT_TYPES = {
15
29
  ".js": "text/javascript; charset=utf-8",
16
30
  ".mjs": "text/javascript; charset=utf-8",
package/dist/server.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { AuroraManager, type AuroraManagerConfig } from "./AuroraManager.js";
2
2
  export { type PageFactory, Pages, type PagesConfig } from "./Pages.js";
3
3
  export { type RenderHttpContext, type RenderPageOptions, type RenderResponse, renderPage, } from "./server/renderPage.js";
4
- export { type AssetsHttpContext, type AssetsRequest, type AssetsResponse, type ServeAssetsOptions, serveAssets, } from "./server/serveAssets.js";
4
+ export { type AssetsHttpContext, type AssetsRequest, type AssetsResponse, packageAssetDir, type ServeAssetsOptions, serveAssets, } from "./server/serveAssets.js";
package/dist/server.js CHANGED
@@ -7,4 +7,4 @@
7
7
  export { AuroraManager } from "./AuroraManager.js";
8
8
  export { Pages } from "./Pages.js";
9
9
  export { renderPage, } from "./server/renderPage.js";
10
- export { serveAssets, } from "./server/serveAssets.js";
10
+ export { packageAssetDir, serveAssets, } from "./server/serveAssets.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/aurora",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
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",
@@ -20,7 +20,11 @@ import {
20
20
  type RenderPageOptions,
21
21
  renderPage,
22
22
  } from "./server/renderPage.js";
23
- import { type AssetsHttpContext, serveAssets } from "./server/serveAssets.js";
23
+ import {
24
+ type AssetsHttpContext,
25
+ packageAssetDir,
26
+ serveAssets,
27
+ } from "./server/serveAssets.js";
24
28
 
25
29
  export interface AuroraManagerConfig {
26
30
  pages: PagesConfig;
@@ -30,6 +34,13 @@ export interface AuroraManagerConfig {
30
34
  * Override only if you want to serve a custom build.
31
35
  */
32
36
  auroraDistRoot?: string;
37
+ /**
38
+ * Filesystem path to `@c9up/comet`'s `dist/`. Defaults to the dist of the
39
+ * installed (optional-peer) `@c9up/comet` — resolved automatically so the
40
+ * RPC client's `import '@c9up/comet'` works in the no-bundler browser with
41
+ * zero app wiring. Left unserved when comet isn't installed (no RPC).
42
+ */
43
+ cometDistRoot?: string;
33
44
  /**
34
45
  * URL prefix the asset routes mount under. The aurora runtime is served
35
46
  * from `<assetsPrefix>/aurora/*` and the app's pages from
@@ -46,6 +57,19 @@ const DEFAULT_AURORA_DIST = resolvePath(
46
57
  "../dist",
47
58
  );
48
59
 
60
+ /**
61
+ * Resolve `@c9up/comet`'s dist dir, or `null` when it isn't installed. comet is
62
+ * aurora's OPTIONAL peer (only present when the app uses RPC), so a missing one
63
+ * is expected — aurora then simply doesn't serve it or add it to the importmap.
64
+ */
65
+ function resolveCometDist(): string | null {
66
+ try {
67
+ return packageAssetDir("@c9up/comet");
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
49
73
  /** Normalize an asset prefix: ensure a leading slash, drop trailing slashes. */
50
74
  function normalizePrefix(prefix: string): string {
51
75
  const withLead = prefix.startsWith("/") ? prefix : `/${prefix}`;
@@ -61,11 +85,17 @@ export class AuroraManager {
61
85
  readonly auroraAssetPath: string;
62
86
  /** Mount path for the app's pages — `<assetsPrefix>/pages`. */
63
87
  readonly pageAssetPath: string;
88
+ /** Mount path for the RPC client's `@c9up/comet` runtime — `<assetsPrefix>/comet`. */
89
+ readonly cometAssetPath: string;
90
+ /** Resolved `@c9up/comet` dist dir, or `null` when comet isn't installed. */
91
+ readonly cometDistRoot: string | null;
64
92
 
65
93
  constructor(config: AuroraManagerConfig) {
66
94
  this.assetsPrefix = normalizePrefix(config.assetsPrefix ?? "/_assets");
67
95
  this.auroraAssetPath = `${this.assetsPrefix}/aurora`;
68
96
  this.pageAssetPath = `${this.assetsPrefix}/pages`;
97
+ this.cometAssetPath = `${this.assetsPrefix}/comet`;
98
+ this.cometDistRoot = config.cometDistRoot ?? resolveCometDist();
69
99
  // Pages serve their compiled JS from the same prefix unless the app
70
100
  // pins an explicit urlPrefix.
71
101
  this.pages = new Pages({
@@ -91,6 +121,12 @@ export class AuroraManager {
91
121
  ...options,
92
122
  importmap: {
93
123
  "@c9up/aurora": `${this.auroraAssetPath}/index.js`,
124
+ // Auto-map @c9up/comet when installed so `@c9up/aurora/rpc`'s bare
125
+ // `import '@c9up/comet'` resolves in the no-bundler browser — no
126
+ // app-side importmap wiring. Omitted when comet isn't present.
127
+ ...(this.cometDistRoot
128
+ ? { "@c9up/comet": `${this.cometAssetPath}/index.js` }
129
+ : {}),
94
130
  ...options?.importmap,
95
131
  },
96
132
  });
@@ -111,4 +147,15 @@ export class AuroraManager {
111
147
  pageAssetsHandler(): (ctx: AssetsHttpContext) => Promise<void> {
112
148
  return serveAssets({ root: this.pages.root });
113
149
  }
150
+
151
+ /**
152
+ * Handler for `@c9up/comet`'s runtime (the RPC client). Mount on
153
+ * `GET <cometAssetPath>/*`. Returns `null` when comet isn't installed —
154
+ * the provider then skips the route (no RPC, nothing to serve).
155
+ */
156
+ cometAssetsHandler(): ((ctx: AssetsHttpContext) => Promise<void>) | null {
157
+ return this.cometDistRoot
158
+ ? serveAssets({ root: this.cometDistRoot })
159
+ : null;
160
+ }
114
161
  }
@@ -101,6 +101,13 @@ export default class AuroraProvider {
101
101
  `${manager.pageAssetPath}/*`,
102
102
  adaptHandler(manager.pageAssetsHandler()),
103
103
  );
104
+ // Serve @c9up/comet's runtime so the RPC client's bare `import
105
+ // '@c9up/comet'` resolves in the browser. Skipped when comet isn't
106
+ // installed (optional peer — the app doesn't use RPC).
107
+ const cometHandler = manager.cometAssetsHandler();
108
+ if (cometHandler) {
109
+ router.get(`${manager.cometAssetPath}/*`, adaptHandler(cometHandler));
110
+ }
104
111
  }
105
112
 
106
113
  async ready(): Promise<void> {}
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/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.
@@ -11,7 +11,22 @@
11
11
  */
12
12
 
13
13
  import { readFile, realpath } from "node:fs/promises";
14
- import { extname, join, resolve as resolvePath, sep } from "node:path";
14
+ import { dirname, extname, join, resolve as resolvePath, sep } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ /**
18
+ * Absolute dist directory of an installed package — for mounting its pre-built
19
+ * ESM as browser assets (`serveAssets({ root: packageAssetDir('@c9up/x') })`).
20
+ *
21
+ * Uses `import.meta.resolve`, which honours the package's `exports` `import`
22
+ * condition — so it works for `@c9up/*` import-only packages where
23
+ * `createRequire().resolve()` throws `ERR_PACKAGE_PATH_NOT_EXPORTED` (their
24
+ * `exports` carry no `require` condition, and `main` is ignored once `exports`
25
+ * exists). Throws if the package isn't installed/resolvable.
26
+ */
27
+ export function packageAssetDir(specifier: string): string {
28
+ return dirname(fileURLToPath(import.meta.resolve(specifier)));
29
+ }
15
30
 
16
31
  const CONTENT_TYPES: Record<string, string> = {
17
32
  ".js": "text/javascript; charset=utf-8",
package/src/server.ts CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  type AssetsHttpContext,
18
18
  type AssetsRequest,
19
19
  type AssetsResponse,
20
+ packageAssetDir,
20
21
  type ServeAssetsOptions,
21
22
  serveAssets,
22
23
  } from "./server/serveAssets.js";