@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/README.md +92 -0
- package/dist/AuroraManager.d.ts +22 -1
- package/dist/AuroraManager.js +26 -0
- package/dist/browser.d.ts +7 -0
- package/dist/browser.js +49 -8
- package/dist/form.d.ts +19 -9
- package/dist/form.js +5 -2
- package/dist/http.d.ts +12 -0
- package/dist/http.js +51 -2
- package/dist/hydrate.js +44 -0
- package/dist/index.d.ts +1 -1
- package/dist/live.d.ts +1 -1
- package/dist/liveServer.d.ts +12 -0
- package/dist/liveServer.js +15 -1
- package/dist/relay.js +11 -1
- package/dist/render.js +9 -2
- package/dist/route.js +9 -1
- package/dist/server/renderPage.d.ts +25 -0
- package/dist/server/renderPage.js +52 -13
- package/dist/server.d.ts +1 -1
- package/dist/services/main.js +9 -0
- package/dist/url.d.ts +7 -0
- package/dist/url.js +15 -3
- package/package.json +1 -1
- package/src/AuroraManager.ts +49 -0
- package/src/browser.ts +57 -8
- package/src/form.ts +20 -7
- package/src/http.ts +67 -2
- package/src/hydrate.ts +74 -0
- package/src/index.ts +1 -0
- package/src/live.ts +1 -0
- package/src/liveServer.ts +25 -2
- package/src/relay.ts +9 -1
- package/src/render.ts +12 -2
- package/src/route.ts +10 -1
- package/src/server/renderPage.ts +100 -14
- package/src/server.ts +2 -0
- package/src/services/main.ts +9 -0
- package/src/url.ts +21 -3
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 =
|
|
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, "&")
|
|
38
|
+
.replace(/"/g, """)
|
|
39
|
+
.replace(/'/g, "'")
|
|
40
|
+
.replace(/</g, "<")
|
|
41
|
+
.replace(/>/g, ">");
|
|
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 {
|
|
25
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
26
|
+
import { setCookieStoreReader } from "../browser.js";
|
|
26
27
|
import { renderToString } from "../ssr.js";
|
|
27
|
-
import {
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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(
|
|
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
|
-
|
|
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, "&")
|
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";
|
package/dist/services/main.js
CHANGED
|
@@ -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 { ...
|
|
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
|
|
50
|
+
const routes = activeManifest();
|
|
51
|
+
const pattern = routes[name];
|
|
40
52
|
if (pattern === undefined) {
|
|
41
|
-
const known = Object.keys(
|
|
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.
|
|
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",
|
package/src/AuroraManager.ts
CHANGED
|
@@ -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
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
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")
|
|
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 `.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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(
|
|
409
|
+
return fetch(finalUrl, {
|
|
345
410
|
method,
|
|
346
411
|
headers,
|
|
347
412
|
body: payload,
|