@c9up/aurora 0.1.25 → 0.1.27

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/render.js CHANGED
@@ -68,10 +68,17 @@ export function mount(result, cleanups, mounted, mountHooks) {
68
68
  // the final string. Collect them in a first pass, attach effects
69
69
  // after.
70
70
  const multiGroups = new Map();
71
+ // Resolve EVERY slot's node before applying any of them. Applying a text
72
+ // slot inserts nodes into the fragment, which shifts the child indices the
73
+ // remaining paths were computed against — so a slot sitting after a nested
74
+ // template (`${Icon()}${label}`) used to resolve to the wrong node, or to
75
+ // none, and silently never bound. Fragments exist precisely so a component
76
+ // needs no wrapper element; they must not cost the slots that follow them.
77
+ const resolved = tpl.slots.map((slot) => resolvePath(fragment, slot.path));
71
78
  for (let i = 0; i < tpl.slots.length; i++) {
72
79
  const slot = tpl.slots[i];
73
- const node = resolvePath(fragment, slot.path);
74
- if (node === null) {
80
+ const node = resolved[i];
81
+ if (node === null || node === undefined) {
75
82
  // Path didn't resolve — skip this binding rather than crash (see
76
83
  // resolvePath). Degrades to a dead binding; the surrounding render
77
84
  // (and any command driving it) survives.
package/dist/route.js CHANGED
@@ -29,9 +29,17 @@ const DEFAULT_SHELL = (body, entry) => `<!doctype html>
29
29
  </head>
30
30
  <body>
31
31
  <div id="aurora-root">${body}</div>
32
- <script type="module" src="${entry}"></script>
32
+ <script type="module" src="${escapeAttr(entry)}"></script>
33
33
  </body>
34
34
  </html>`;
35
+ function escapeAttr(value) {
36
+ return value
37
+ .replace(/&/g, "&amp;")
38
+ .replace(/"/g, "&quot;")
39
+ .replace(/'/g, "&#39;")
40
+ .replace(/</g, "&lt;")
41
+ .replace(/>/g, "&gt;");
42
+ }
35
43
  /**
36
44
  * Build a Ream-compatible route handler that SSR-renders the given
37
45
  * factory and serves the full HTML document.
@@ -64,6 +64,29 @@ export interface RenderPageOptions {
64
64
  * targets.
65
65
  */
66
66
  rootId?: string;
67
+ /**
68
+ * Root element tag for the SSR + hydrated tree. Defaults to `div`.
69
+ * Mirrors Inertia's root tag customization (`@inertia({ as: ... })`) while
70
+ * keeping aurora independent from a template engine.
71
+ */
72
+ rootTag?: string;
73
+ /**
74
+ * Optional class attribute on the root element. Mirrors
75
+ * `@inertia({ class: ... })`.
76
+ */
77
+ rootClass?: string;
78
+ /**
79
+ * Shared props merged into every page render before invoking the page factory.
80
+ * Use this for global data such as user, flash and validation errors. A
81
+ * function receives the current HTTP context and may be async, matching
82
+ * Adonis/Inertia's request middleware `share()` model.
83
+ */
84
+ shared?: SharedProps | SharedPropsResolver;
85
+ /**
86
+ * Asset/version marker serialized with the page payload. Apps can use this to
87
+ * detect stale client state when their frontend build changes.
88
+ */
89
+ assetsVersion?: string;
67
90
  /**
68
91
  * Named-route manifest (`name → path-pattern`) for the isomorphic
69
92
  * `urlFor()` helper — build it with Ream's `router.namedManifest()`. It is
@@ -85,4 +108,6 @@ export interface RenderPageOptions {
85
108
  */
86
109
  cookies?: string[];
87
110
  }
111
+ export type SharedProps = Record<string, unknown>;
112
+ export type SharedPropsResolver = (ctx: RenderHttpContext) => SharedProps | Promise<SharedProps>;
88
113
  export declare function renderPage<P>(ctx: RenderHttpContext, pages: Pages, name: string, props: P, options?: RenderPageOptions): Promise<void>;
@@ -22,9 +22,13 @@
22
22
  * </script>
23
23
  * </body>
24
24
  */
25
- import { setCookieStore } from "../browser.js";
25
+ import { AsyncLocalStorage } from "node:async_hooks";
26
+ import { setCookieStoreReader } from "../browser.js";
26
27
  import { renderToString } from "../ssr.js";
27
- import { setRouteManifest } from "../url.js";
28
+ import { setRouteManifestReader } from "../url.js";
29
+ const renderScope = new AsyncLocalStorage();
30
+ setCookieStoreReader(() => renderScope.getStore()?.cookies);
31
+ setRouteManifestReader(() => renderScope.getStore()?.routes);
28
32
  function isCookieReadable(request) {
29
33
  return (typeof request === "object" &&
30
34
  request !== null &&
@@ -44,24 +48,29 @@ function readRequestCookies(request, names) {
44
48
  return seed;
45
49
  }
46
50
  export async function renderPage(ctx, pages, name, props, options = {}) {
47
- // Install the route manifest BEFORE rendering so a page calling `urlFor`
48
- // during SSR resolves against the same map the client will get.
49
- if (options.routes)
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) : {});
51
+ const scope = {
52
+ cookies: options.cookies
53
+ ? readRequestCookies(ctx.request, options.cookies)
54
+ : {},
55
+ routes: options.routes ?? {},
56
+ };
57
+ return renderScope.run(scope, () => renderPageInScope(ctx, pages, name, props, options));
58
+ }
59
+ async function renderPageInScope(ctx, pages, name, props, options) {
55
60
  const factory = await pages.resolve(name);
61
+ const shared = await resolveSharedProps(ctx, options.shared);
62
+ const pageProps = mergeProps(shared, props);
56
63
  // The factory must be invoked the SAME way client-side for hydrate
57
64
  // to find matching slots — `Page(props)` is the contract.
58
- const tree = await factory(props);
65
+ const tree = await factory(pageProps);
59
66
  const body = renderToString(tree);
60
67
  const importmap = {
61
68
  "@c9up/aurora": "/__assets/aurora/index.js",
62
69
  ...options.importmap,
63
70
  };
64
71
  const rootId = options.rootId ?? "aurora-root";
72
+ const rootTag = normalizeRootTag(options.rootTag ?? "div");
73
+ const rootClass = options.rootClass;
65
74
  const lang = options.lang ?? "en";
66
75
  const pageUrl = pages.urlFor(name);
67
76
  const doc = `<!doctype html>
@@ -73,13 +82,14 @@ export async function renderPage(ctx, pages, name, props, options = {}) {
73
82
  ${options.headExtra ?? ""}
74
83
  </head>
75
84
  <body>
76
- <div id="${escapeAttr(rootId)}">${body}</div>
85
+ <${rootTag}${rootAttrs(rootId, rootClass)}>${body}</${rootTag}>
77
86
  <script id="aurora-page-data" type="application/json">${escapeJsonForScript({
78
87
  name,
79
- props,
88
+ props: pageProps,
80
89
  url: pageUrl,
81
90
  rootId,
82
91
  routes: options.routes ?? {},
92
+ version: options.assetsVersion ?? null,
83
93
  })}</script>
84
94
  <script type="module">
85
95
  import { hydrate, setRouteManifest } from '@c9up/aurora'
@@ -93,6 +103,35 @@ hydrate(document.getElementById(data.rootId), () => Page(data.props))
93
103
  ctx.response.header("content-type", "text/html; charset=utf-8");
94
104
  ctx.response.send(doc);
95
105
  }
106
+ async function resolveSharedProps(ctx, shared) {
107
+ if (!shared)
108
+ return {};
109
+ return typeof shared === "function" ? shared(ctx) : shared;
110
+ }
111
+ function mergeProps(shared, props) {
112
+ if (Object.keys(shared).length === 0)
113
+ return props;
114
+ if (isPlainRecord(props))
115
+ return { ...shared, ...props };
116
+ return { ...shared, page: props };
117
+ }
118
+ function isPlainRecord(value) {
119
+ return (typeof value === "object" &&
120
+ value !== null &&
121
+ !Array.isArray(value) &&
122
+ Object.getPrototypeOf(value) === Object.prototype);
123
+ }
124
+ function normalizeRootTag(tag) {
125
+ if (/^[a-z][a-z0-9-]*$/i.test(tag))
126
+ return tag.toLowerCase();
127
+ throw new Error(`[aurora] illegal root tag: ${JSON.stringify(tag)}`);
128
+ }
129
+ function rootAttrs(id, className) {
130
+ const attrs = [`id="${escapeAttr(id)}"`];
131
+ if (className)
132
+ attrs.push(`class="${escapeAttr(className)}"`);
133
+ return ` ${attrs.join(" ")}`;
134
+ }
96
135
  function escapeAttr(value) {
97
136
  return value
98
137
  .replace(/&/g, "&amp;")
package/dist/server.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { AuroraManager, type AuroraManagerConfig } from "./AuroraManager.js";
2
2
  export { type AuroraRequestRenderer, auroraContext, } from "./middleware.js";
3
3
  export { type PageFactory, Pages, type PagesConfig } from "./Pages.js";
4
- export { type RenderHttpContext, type RenderPageOptions, type RenderResponse, renderPage, } from "./server/renderPage.js";
4
+ export { type RenderHttpContext, type RenderPageOptions, type RenderResponse, renderPage, type SharedProps, type SharedPropsResolver, } from "./server/renderPage.js";
5
5
  export { type AssetsHttpContext, type AssetsRequest, type AssetsResponse, packageAssetDir, type ServeAssetsOptions, serveAssets, } from "./server/serveAssets.js";
@@ -20,6 +20,15 @@ export function getAurora() {
20
20
  }
21
21
  const aurora = new Proxy({}, {
22
22
  get(_target, prop) {
23
+ // A module loader inspects what it imports before anyone uses it: it reads
24
+ // `then` to decide whether the namespace is thenable, and various symbols
25
+ // for interop and formatting. Throwing on those turns a plain
26
+ // `import { setX } from ".../services/main"` into a crash at import time,
27
+ // far from any real use. They are not members of what this stands in for,
28
+ // so answer undefined and let a genuine access be the one that reports.
29
+ if (typeof prop === "symbol" || prop === "then") {
30
+ return undefined;
31
+ }
23
32
  if (!instance) {
24
33
  throw new Error("[aurora] AuroraManager singleton accessed before AuroraProvider.boot() ran " +
25
34
  "or `setAurora(myManager)` was called. Wire one of them first.");
package/dist/url.d.ts CHANGED
@@ -16,6 +16,12 @@
16
16
  * injects the same map into the page so the hydrate bootstrap re-sets it client
17
17
  * side. Node-free — part of aurora's client runtime.
18
18
  */
19
+ type RouteManifestReader = () => Record<string, string> | undefined;
20
+ /**
21
+ * @internal Server-side hook used by `renderPage()` to provide a request-scoped
22
+ * route manifest without importing Node built-ins from this browser-safe module.
23
+ */
24
+ export declare function setRouteManifestReader(reader: RouteManifestReader | undefined): void;
19
25
  /**
20
26
  * Install the `name → path-pattern` map `urlFor` resolves against (e.g.
21
27
  * `{ 'users.show': '/users/:id' }`, from Ream's `router.namedManifest()`).
@@ -31,3 +37,4 @@ export declare function getRouteManifest(): Record<string, string>;
31
37
  * an unknown route or a missing required param. Mirrors Ream's `router.urlFor`.
32
38
  */
33
39
  export declare function urlFor(name: string, params?: Record<string, string | number>, query?: Record<string, string | number>): string;
40
+ export {};
package/dist/url.js CHANGED
@@ -17,6 +17,17 @@
17
17
  * side. Node-free — part of aurora's client runtime.
18
18
  */
19
19
  let manifest = {};
20
+ let routeManifestReader;
21
+ /**
22
+ * @internal Server-side hook used by `renderPage()` to provide a request-scoped
23
+ * route manifest without importing Node built-ins from this browser-safe module.
24
+ */
25
+ export function setRouteManifestReader(reader) {
26
+ routeManifestReader = reader;
27
+ }
28
+ function activeManifest() {
29
+ return routeManifestReader?.() ?? manifest;
30
+ }
20
31
  /**
21
32
  * Install the `name → path-pattern` map `urlFor` resolves against (e.g.
22
33
  * `{ 'users.show': '/users/:id' }`, from Ream's `router.namedManifest()`).
@@ -28,7 +39,7 @@ export function setRouteManifest(routes) {
28
39
  }
29
40
  /** The currently-installed route manifest (mainly for tests/introspection). */
30
41
  export function getRouteManifest() {
31
- return { ...manifest };
42
+ return { ...activeManifest() };
32
43
  }
33
44
  /**
34
45
  * Build a URL for a named route — fills `:param` placeholders, drops unprovided
@@ -36,9 +47,10 @@ export function getRouteManifest() {
36
47
  * an unknown route or a missing required param. Mirrors Ream's `router.urlFor`.
37
48
  */
38
49
  export function urlFor(name, params, query) {
39
- const pattern = manifest[name];
50
+ const routes = activeManifest();
51
+ const pattern = routes[name];
40
52
  if (pattern === undefined) {
41
- const known = Object.keys(manifest);
53
+ const known = Object.keys(routes);
42
54
  throw new Error(`[aurora] urlFor: unknown route '${name}'. ${known.length > 0
43
55
  ? `Known: ${known.join(", ")}`
44
56
  : "No routes registered — was the manifest passed to render() / setRouteManifest() called?"}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/aurora",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
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",
@@ -19,6 +19,8 @@ import {
19
19
  type RenderHttpContext,
20
20
  type RenderPageOptions,
21
21
  renderPage,
22
+ type SharedProps,
23
+ type SharedPropsResolver,
22
24
  } from "./server/renderPage.js";
23
25
  import {
24
26
  type AssetsHttpContext,
@@ -28,6 +30,21 @@ import {
28
30
 
29
31
  export interface AuroraManagerConfig {
30
32
  pages: PagesConfig;
33
+ /**
34
+ * Shared props injected into every `render()` call, matching Adonis/Inertia's
35
+ * request-level shared data model. Per-call `options.shared` is merged over it.
36
+ */
37
+ shared?: SharedProps | SharedPropsResolver;
38
+ /**
39
+ * Default root tag/class for rendered pages. Mirrors Inertia's root template
40
+ * customization while keeping controllers thin.
41
+ */
42
+ root?: {
43
+ tag?: string;
44
+ class?: string;
45
+ };
46
+ /** Default asset/version marker serialized into the page payload. */
47
+ assetsVersion?: string;
31
48
  /**
32
49
  * Filesystem path to aurora's pre-built `dist/`. Defaults to the
33
50
  * dist directory shipped with the installed `@c9up/aurora` package.
@@ -104,6 +121,12 @@ export class AuroraManager {
104
121
  readonly cometDistRoot: string | null;
105
122
  /** App-level importmap overrides from `config/aurora.ts`, merged on render. */
106
123
  readonly importmap: Record<string, string>;
124
+ /** App-level shared props from config/aurora.ts. */
125
+ readonly shared?: SharedProps | SharedPropsResolver;
126
+ /** App-level root element defaults from config/aurora.ts. */
127
+ readonly root?: AuroraManagerConfig["root"];
128
+ /** App-level asset/version marker. */
129
+ readonly assetsVersion?: string;
107
130
 
108
131
  constructor(config: AuroraManagerConfig) {
109
132
  this.assetsPrefix = normalizePrefix(config.assetsPrefix ?? "/__assets");
@@ -112,6 +135,9 @@ export class AuroraManager {
112
135
  this.cometAssetPath = `${this.assetsPrefix}/comet`;
113
136
  this.cometDistRoot = config.cometDistRoot ?? resolveCometDist();
114
137
  this.importmap = config.importmap ?? {};
138
+ this.shared = config.shared;
139
+ this.root = config.root;
140
+ this.assetsVersion = config.assetsVersion;
115
141
  // Pages serve their compiled JS from the same prefix unless the app
116
142
  // pins an explicit urlPrefix.
117
143
  this.pages = new Pages({
@@ -136,6 +162,10 @@ export class AuroraManager {
136
162
  ): Promise<void> {
137
163
  return renderPage(ctx, this.pages, name, props, {
138
164
  ...options,
165
+ rootTag: options?.rootTag ?? this.root?.tag,
166
+ rootClass: options?.rootClass ?? this.root?.class,
167
+ assetsVersion: options?.assetsVersion ?? this.assetsVersion,
168
+ shared: mergeSharedResolvers(this.shared, options?.shared),
139
169
  importmap: {
140
170
  "@c9up/aurora": `${this.auroraAssetPath}/index.js`,
141
171
  // The browser-facing subpath (RPC client) needs an explicit entry —
@@ -184,3 +214,22 @@ export class AuroraManager {
184
214
  : null;
185
215
  }
186
216
  }
217
+
218
+ function mergeSharedResolvers(
219
+ base: AuroraManagerConfig["shared"],
220
+ override: RenderPageOptions["shared"],
221
+ ): RenderPageOptions["shared"] {
222
+ if (!base) return override;
223
+ if (!override) return base;
224
+ return async (ctx) => ({
225
+ ...(await resolveShared(ctx, base)),
226
+ ...(await resolveShared(ctx, override)),
227
+ });
228
+ }
229
+
230
+ async function resolveShared(
231
+ ctx: RenderHttpContext,
232
+ shared: SharedProps | SharedPropsResolver,
233
+ ): Promise<SharedProps> {
234
+ return typeof shared === "function" ? shared(ctx) : shared;
235
+ }
package/src/browser.ts CHANGED
@@ -12,7 +12,7 @@ import { effect, onCleanup, type Signal, signal } from "./reactive.js";
12
12
  /** Navigate to `url` with a full page load. No-op during SSR. */
13
13
  export function redirect(url: string): void {
14
14
  if (typeof window !== "undefined") {
15
- window.location.href = url;
15
+ window.location.href = safeNavigationUrl(url);
16
16
  }
17
17
  }
18
18
 
@@ -22,7 +22,7 @@ export function redirect(url: string): void {
22
22
  */
23
23
  export function replace(url: string): void {
24
24
  if (typeof window !== "undefined") {
25
- window.location.replace(url);
25
+ window.location.replace(safeNavigationUrl(url));
26
26
  }
27
27
  }
28
28
 
@@ -358,6 +358,22 @@ export function forward(): void {
358
358
  if (typeof window !== "undefined") window.history.forward();
359
359
  }
360
360
 
361
+ /**
362
+ * Drop every C0 control character (U+0000–U+001F).
363
+ *
364
+ * Written as a scan rather than a regex: a character class over control
365
+ * characters is exactly what `noControlCharactersInRegex` flags, and the rule
366
+ * is right in general — here the stripping is the point, so the loop states it
367
+ * without needing a suppression.
368
+ */
369
+ function stripControlChars(value: string): string {
370
+ let out = "";
371
+ for (const char of value) {
372
+ if (char.charCodeAt(0) > 0x1f) out += char;
373
+ }
374
+ return out;
375
+ }
376
+
361
377
  /**
362
378
  * SPA navigation: push `url` onto history WITHOUT a full page reload (contrast
363
379
  * {@link redirect}, which reloads). Emits a `popstate` event so reactive URL
@@ -366,10 +382,27 @@ export function forward(): void {
366
382
  */
367
383
  export function navigate(url: string): void {
368
384
  if (typeof window === "undefined") return;
369
- window.history.pushState({}, "", url);
385
+ window.history.pushState({}, "", safeNavigationUrl(url));
370
386
  window.dispatchEvent(new Event("popstate"));
371
387
  }
372
388
 
389
+ function safeNavigationUrl(url: string): string {
390
+ // Browsers strip ASCII tab/newline/CR from ANYWHERE in a URL and trim leading
391
+ // control chars + whitespace before resolving the scheme, so `java\tscript:`
392
+ // (or a leading NUL) is evaluated as `javascript:`. A guard that only
393
+ // `trimStart()`s is trivially bypassed — mirror the browser and strip every
394
+ // C0 control char before comparing the scheme.
395
+ const normalized = stripControlChars(url).trimStart().toLowerCase();
396
+ if (
397
+ normalized.startsWith("javascript:") ||
398
+ normalized.startsWith("vbscript:") ||
399
+ normalized.startsWith("data:")
400
+ ) {
401
+ throw new Error(`[aurora] blocked unsafe navigation URL: ${url}`);
402
+ }
403
+ return url;
404
+ }
405
+
373
406
  /**
374
407
  * A {@link Signal} bound to a single URL query parameter. Reading reflects the
375
408
  * current value (`null` when absent); writing updates the URL via `pushState`
@@ -428,14 +461,26 @@ export interface CookieOptions {
428
461
  * no view of the request cookies and renders default UI state → a flash /
429
462
  * mismatch on hydration (the classic collapsed-sidebar flicker).
430
463
  *
431
- * Module-global by necessity (the page factory reads it ambiently). It is set
432
- * synchronously immediately before the synchronous render, so read your cookie
433
- * signals at the TOP of a page (before any `await`) to avoid a cross-request
434
- * race under concurrent async page factories. In the browser it is unused —
464
+ * Fallback module-global for manual/server tests. `renderPage()` installs a
465
+ * request-scoped reader backed by AsyncLocalStorage, so concurrent SSR renders
466
+ * do not share this mutable object. In the browser it is unused
435
467
  * {@link cookie.get} reads `document.cookie` directly there.
436
468
  */
437
469
  let cookieSeed: Record<string, string> = {};
438
470
 
471
+ type CookieStoreReader = () => Record<string, string> | undefined;
472
+ let cookieStoreReader: CookieStoreReader | undefined;
473
+
474
+ /**
475
+ * @internal Server-side hook used by `renderPage()` to provide a request-scoped
476
+ * cookie store without importing Node built-ins from this browser-safe module.
477
+ */
478
+ export function setCookieStoreReader(
479
+ reader: CookieStoreReader | undefined,
480
+ ): void {
481
+ cookieStoreReader = reader;
482
+ }
483
+
439
484
  /**
440
485
  * Install the SSR cookie seed (`name → value`). Called by `renderPage` from its
441
486
  * `cookies` allowlist; the hydrate bootstrap does NOT call it (the browser reads
@@ -458,7 +503,11 @@ export function getCookieStore(): Record<string, string> {
458
503
  */
459
504
  export const cookie = {
460
505
  get(name: string): string | null {
461
- if (typeof document === "undefined") return cookieSeed[name] ?? null;
506
+ if (typeof document === "undefined") {
507
+ const scoped = cookieStoreReader?.();
508
+ if (scoped && Object.hasOwn(scoped, name)) return scoped[name];
509
+ return cookieSeed[name] ?? null;
510
+ }
462
511
  const prefix = `${encodeURIComponent(name)}=`;
463
512
  for (const part of document.cookie.split("; ")) {
464
513
  if (part.startsWith(prefix)) {
package/src/form.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * `html\`\`` and never hand-roll field state or try/catch.
6
6
  *
7
7
  * Validation is OPTIONAL and **agnostic**: pass a `validate` function returning a
8
- * `{ field: message }` map, OR any object with a `.validate(values)` method
8
+ * `{ field: message }` map, OR any object with a `.validateResult(values)` method
9
9
  * (e.g. a `@c9up/rune` schema) — aurora never imports a validator, it only
10
10
  * duck-types `.validate`. With no `validate`, the form simply never reports field
11
11
  * errors. Node-free — part of the client barrel.
@@ -35,12 +35,23 @@ import { memo, type ReadSignal, signal } from "./reactive.js";
35
35
  /** A field-keyed error map: `{ email: "Invalid", … }`. Absent key ⇒ no error. */
36
36
  export type FieldErrors<T> = Partial<Record<keyof T, string>>;
37
37
 
38
- /** Anything `.validate()`-shaped (a `@c9up/rune` schema satisfies this). */
38
+ /** The synchronous, never-throwing outcome a form schema hands back. */
39
+ export interface FormValidationOutcome {
40
+ valid: boolean;
41
+ errors?: ReadonlyArray<{ field?: string; message: string }>;
42
+ }
43
+
44
+ /**
45
+ * Anything schema-shaped (a `@c9up/rune` schema satisfies this).
46
+ *
47
+ * Both spellings are accepted, and `validateResult` wins when present: rune
48
+ * reserves `validate()` for the VineJS contract (async, throwing), and reading
49
+ * `.valid` off a Promise yields `undefined` — the form would then report itself
50
+ * invalid with no error to show.
51
+ */
39
52
  export interface FormSchema<T> {
40
- validate(values: T): {
41
- valid: boolean;
42
- errors?: ReadonlyArray<{ field?: string; message: string }>;
43
- };
53
+ validate?(values: T): FormValidationOutcome;
54
+ validateResult?(values: T): FormValidationOutcome;
44
55
  }
45
56
 
46
57
  /** Validation source — a function, a schema-like object, or omitted. */
@@ -96,7 +107,9 @@ function computeErrors<T>(
96
107
  ): FieldErrors<T> {
97
108
  if (!validate) return {};
98
109
  if (typeof validate === "function") return validate(values);
99
- const result = validate.validate(values);
110
+ const check = validate.validateResult ?? validate.validate;
111
+ if (typeof check !== "function") return {};
112
+ const result = check.call(validate, values);
100
113
  if (result.valid) return {};
101
114
  const errors: Record<string, string> = {};
102
115
  for (const issue of result.errors ?? []) {
package/src/http.ts CHANGED
@@ -29,6 +29,13 @@ export interface HttpClientOptions {
29
29
  token?: string | null | (() => string | null | undefined);
30
30
  /** Default `credentials` mode (e.g. `"include"` to send cookies). */
31
31
  credentials?: RequestCredentials;
32
+ /**
33
+ * Allow default bearer/default Authorization headers to be sent to absolute
34
+ * cross-origin URLs. Default `false`: same-origin API clients should not leak
35
+ * credentials if an untrusted value becomes the request URL. Per-request
36
+ * `headers.Authorization` is still treated as explicit caller intent.
37
+ */
38
+ allowCrossOriginAuth?: boolean;
32
39
  /**
33
40
  * Default timeout in ms — the request is aborted (rejecting with a
34
41
  * `TimeoutError`) if it doesn't settle in time. Combined with a per-request
@@ -50,6 +57,11 @@ export interface HttpRequestOptions<T = unknown> {
50
57
  timeout?: number;
51
58
  /** `credentials` mode for this request. */
52
59
  credentials?: RequestCredentials;
60
+ /**
61
+ * Per-request override for sending managed auth headers to cross-origin
62
+ * absolute URLs. Default inherits the client option (`false` by default).
63
+ */
64
+ allowCrossOriginAuth?: boolean;
53
65
  /**
54
66
  * Runtime validator/mapper for the parsed body. When provided, the return
55
67
  * type is whatever it returns — no unchecked cast. When omitted, the parsed
@@ -131,6 +143,36 @@ function hasHeader(headers: Record<string, string>, name: string): boolean {
131
143
  return false;
132
144
  }
133
145
 
146
+ /** Delete every case variant of a header from a plain header record. */
147
+ function deleteHeader(headers: Record<string, string>, name: string): void {
148
+ const lower = name.toLowerCase();
149
+ for (const key of Object.keys(headers)) {
150
+ if (key.toLowerCase() === lower) delete headers[key];
151
+ }
152
+ }
153
+
154
+ function originOf(value: string): string | null {
155
+ try {
156
+ if (/^[a-z][a-z\d+\-.]*:\/\//i.test(value)) return new URL(value).origin;
157
+ if (typeof window !== "undefined")
158
+ return new URL(value, window.location.href).origin;
159
+ return null;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ function isCrossOriginAbsoluteUrl(url: string, baseURL: string): boolean {
166
+ if (!/^[a-z][a-z\d+\-.]*:\/\//i.test(url)) return false;
167
+ const targetOrigin = originOf(url);
168
+ if (targetOrigin === null) return true;
169
+ const baseOrigin = baseURL ? originOf(baseURL) : null;
170
+ if (baseOrigin !== null) return targetOrigin !== baseOrigin;
171
+ if (typeof window !== "undefined")
172
+ return targetOrigin !== window.location.origin;
173
+ return true;
174
+ }
175
+
134
176
  /** Merge abort signals into one (whichever fires first wins). `undefined` if none. */
135
177
  function combineSignals(
136
178
  signals: ReadonlyArray<AbortSignal | undefined>,
@@ -168,6 +210,7 @@ export class HttpClient {
168
210
  readonly #token?: string | null | (() => string | null | undefined);
169
211
  readonly #credentials?: RequestCredentials;
170
212
  readonly #timeout?: number;
213
+ readonly #allowCrossOriginAuth: boolean;
171
214
 
172
215
  constructor(options: HttpClientOptions = {}) {
173
216
  this.#baseURL = options.baseURL ?? "";
@@ -175,6 +218,7 @@ export class HttpClient {
175
218
  this.#token = options.token;
176
219
  this.#credentials = options.credentials;
177
220
  this.#timeout = options.timeout;
221
+ this.#allowCrossOriginAuth = options.allowCrossOriginAuth ?? false;
178
222
  }
179
223
 
180
224
  /** Set a default header for every subsequent request (case-insensitive replace). Chainable. */
@@ -284,6 +328,8 @@ export class HttpClient {
284
328
  token: options.token ?? this.#token,
285
329
  credentials: options.credentials ?? this.#credentials,
286
330
  timeout: options.timeout ?? this.#timeout,
331
+ allowCrossOriginAuth:
332
+ options.allowCrossOriginAuth ?? this.#allowCrossOriginAuth,
287
333
  });
288
334
  }
289
335
 
@@ -313,12 +359,31 @@ export class HttpClient {
313
359
  body: unknown,
314
360
  options: HttpRequestOptions,
315
361
  ): Promise<Response> {
362
+ const finalUrl = this.#buildUrl(url, options.query);
363
+ const crossOrigin = isCrossOriginAbsoluteUrl(finalUrl, this.#baseURL);
364
+ const allowCrossOriginAuth =
365
+ options.allowCrossOriginAuth ?? this.#allowCrossOriginAuth;
366
+ const explicitRequestAuth =
367
+ options.headers !== undefined &&
368
+ hasHeader(options.headers, "authorization");
316
369
  const headers: Record<string, string> = {
317
370
  ...this.#headers,
318
371
  ...options.headers,
319
372
  };
373
+ if (
374
+ crossOrigin &&
375
+ !allowCrossOriginAuth &&
376
+ !explicitRequestAuth &&
377
+ hasHeader(this.#headers, "authorization")
378
+ ) {
379
+ deleteHeader(headers, "authorization");
380
+ }
320
381
  const token = this.#resolveToken(options.token);
321
- if (token != null && !hasHeader(headers, "authorization")) {
382
+ if (
383
+ token != null &&
384
+ !hasHeader(headers, "authorization") &&
385
+ (!crossOrigin || allowCrossOriginAuth)
386
+ ) {
322
387
  headers.Authorization = `Bearer ${token}`;
323
388
  }
324
389
 
@@ -341,7 +406,7 @@ export class HttpClient {
341
406
  timeout !== undefined ? AbortSignal.timeout(timeout) : undefined,
342
407
  ]);
343
408
 
344
- return fetch(this.#buildUrl(url, options.query), {
409
+ return fetch(finalUrl, {
345
410
  method,
346
411
  headers,
347
412
  body: payload,