@c9up/aurora 0.1.25 → 0.1.26
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/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/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/index.ts +1 -0
- package/src/live.ts +1 -0
- package/src/liveServer.ts +25 -2
- package/src/relay.ts +9 -1
- 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/README.md
CHANGED
|
@@ -22,6 +22,56 @@ providers: [
|
|
|
22
22
|
]
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
```ts
|
|
26
|
+
// config/aurora.ts
|
|
27
|
+
export default {
|
|
28
|
+
pages: {
|
|
29
|
+
root: new URL('../resources/pages', import.meta.url).pathname,
|
|
30
|
+
},
|
|
31
|
+
root: {
|
|
32
|
+
tag: 'main',
|
|
33
|
+
class: 'min-h-screen',
|
|
34
|
+
},
|
|
35
|
+
shared: async (ctx) => ({
|
|
36
|
+
auth: { user: ctx.auth?.user ?? null },
|
|
37
|
+
flash: ctx.session?.flashMessages?.all?.() ?? {},
|
|
38
|
+
errors: ctx.session?.flashMessages?.get?.('errors') ?? {},
|
|
39
|
+
}),
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Controllers stay thin, like Adonis/Inertia controllers:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
export default class DashboardController {
|
|
47
|
+
async show({ aurora }) {
|
|
48
|
+
return aurora.render('dashboard/show', {
|
|
49
|
+
stats: await loadStats(),
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Dev live-reload (HMR)
|
|
56
|
+
|
|
57
|
+
Pages are dynamic-imported per request; aurora dev-busts the page module by mtime, but a page's **transitive** imports (components/layouts/services) stay cached until you restart. Enable graph-aware SSR HMR — the AdonisJS way — with [`hot-hook`](https://github.com/Julien-R44/hot-hook):
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pnpm add -D hot-hook @hot-hook/runner
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```jsonc
|
|
64
|
+
// package.json
|
|
65
|
+
{
|
|
66
|
+
"scripts": {
|
|
67
|
+
"dev": "hot-runner --node-args=--import=tsx --node-args=--import=hot-hook/register bin/server.ts"
|
|
68
|
+
},
|
|
69
|
+
"hotHook": { "boundaries": ["./resources/pages/*.js"] }
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Editing a page or any component it imports now hot-reloads the SSR with no restart. **Point `boundaries` at page entries only** (`./resources/pages/*.js`, not `**/*.js`) — hot-hook requires boundary files to be dynamically imported, so a statically-imported component matched by the glob forces a full reload. aurora itself needs no change.
|
|
74
|
+
|
|
25
75
|
## Entry points
|
|
26
76
|
|
|
27
77
|
- `@c9up/aurora` — main API: reactive primitives (`signal`/`effect`/`html`/`component`/`hydrate`) plus the client toolkit — `WebStorage`/`persistedSignal`, reactive browser signals (`prefersDark`/`online`/`windowSize`/…), SPA navigation (`navigate`/`queryParam`), `cookie`/`clipboard`/`share`, the `HttpClient` fetch wrapper, `createRpcClient()` (JSON-RPC 2.0), `command()` (async action + reactive loading/data/error), `form()` (reactive form controller; optional rune validation + rosetta i18n), `urlFor()` (isomorphic named-route URLs, paired with Ream's `router.namedManifest()`), and `cn()` (zero-dependency Tailwind v4 class merge — `clsx` + `tailwind-merge` reimplemented)
|
|
@@ -30,6 +80,48 @@ providers: [
|
|
|
30
80
|
- `@c9up/aurora/relay` — realtime adapter
|
|
31
81
|
- `@c9up/aurora/ssr` — server-side rendering
|
|
32
82
|
- `@c9up/aurora/hydrate` — client hydration
|
|
83
|
+
- `@c9up/aurora/server` — server-only helpers and types (`AuroraManager`,
|
|
84
|
+
`Pages`, `renderPage`, `serveAssets`, `SharedProps`, `SharedPropsResolver`)
|
|
85
|
+
|
|
86
|
+
## Adonis / Inertia parity
|
|
87
|
+
|
|
88
|
+
Implemented in Aurora:
|
|
89
|
+
|
|
90
|
+
- provider + IoC service;
|
|
91
|
+
- `ctx.aurora.render(name, props, options)`;
|
|
92
|
+
- named page rendering from routes/controllers;
|
|
93
|
+
- shared props per request via `shared`;
|
|
94
|
+
- root tag/class customization;
|
|
95
|
+
- named-route manifest for `urlFor()`;
|
|
96
|
+
- asset/version marker in page data;
|
|
97
|
+
- SSR + hydration payload.
|
|
98
|
+
|
|
99
|
+
Still intentionally tracked as remaining work:
|
|
100
|
+
|
|
101
|
+
- `@adonisjs/vite`-equivalent frontend integration: asset bundling, dev manifest and browser-asset HMR (SSR page-module HMR is available today via hot-hook — see [Dev live-reload](#dev-live-reload-hmr));
|
|
102
|
+
- full Inertia navigation semantics: preserve state, history encryption, version
|
|
103
|
+
mismatch handling and client-side visit lifecycle;
|
|
104
|
+
- generated page-name types.
|
|
105
|
+
|
|
106
|
+
## Security notes
|
|
107
|
+
|
|
108
|
+
- `renderPage()` isolates SSR cookies and route manifests per request. Pages can
|
|
109
|
+
read `cookieState()` and `urlFor()` across async boundaries without leaking
|
|
110
|
+
another concurrent request's state.
|
|
111
|
+
- `renderPage()` and `AuroraManager.render()` support Adonis/Inertia-style
|
|
112
|
+
shared props via `shared`, plus root element customization via `rootTag`,
|
|
113
|
+
`rootClass` or `config.aurora.root`.
|
|
114
|
+
- `HttpClient` does not send managed bearer/default `Authorization` headers to
|
|
115
|
+
cross-origin absolute URLs by default. Set `allowCrossOriginAuth: true` only
|
|
116
|
+
when the external origin is intentional and trusted, or pass an explicit
|
|
117
|
+
per-request `Authorization` header.
|
|
118
|
+
- `wireLiveEvents()` accepts an `authorize(ctx, body)` hook. Use it to bind live
|
|
119
|
+
event POSTs to the same auth/CSRF/owner policy as the page that mounted the
|
|
120
|
+
live session.
|
|
121
|
+
- `redirect()`, `replace()` and `navigate()` reject `javascript:`, `vbscript:`
|
|
122
|
+
and `data:` URLs.
|
|
123
|
+
- `auroraRoute()` is the legacy low-level route helper. Prefer the provider /
|
|
124
|
+
`ctx.aurora.render()` pipeline for Adonis-style applications.
|
|
33
125
|
|
|
34
126
|
## License
|
|
35
127
|
|
package/dist/AuroraManager.d.ts
CHANGED
|
@@ -12,10 +12,25 @@
|
|
|
12
12
|
* the app can also instantiate one manually for tests.
|
|
13
13
|
*/
|
|
14
14
|
import { Pages, type PagesConfig } from "./Pages.js";
|
|
15
|
-
import { type RenderHttpContext, type RenderPageOptions } from "./server/renderPage.js";
|
|
15
|
+
import { type RenderHttpContext, type RenderPageOptions, type SharedProps, type SharedPropsResolver } from "./server/renderPage.js";
|
|
16
16
|
import { type AssetsHttpContext } from "./server/serveAssets.js";
|
|
17
17
|
export interface AuroraManagerConfig {
|
|
18
18
|
pages: PagesConfig;
|
|
19
|
+
/**
|
|
20
|
+
* Shared props injected into every `render()` call, matching Adonis/Inertia's
|
|
21
|
+
* request-level shared data model. Per-call `options.shared` is merged over it.
|
|
22
|
+
*/
|
|
23
|
+
shared?: SharedProps | SharedPropsResolver;
|
|
24
|
+
/**
|
|
25
|
+
* Default root tag/class for rendered pages. Mirrors Inertia's root template
|
|
26
|
+
* customization while keeping controllers thin.
|
|
27
|
+
*/
|
|
28
|
+
root?: {
|
|
29
|
+
tag?: string;
|
|
30
|
+
class?: string;
|
|
31
|
+
};
|
|
32
|
+
/** Default asset/version marker serialized into the page payload. */
|
|
33
|
+
assetsVersion?: string;
|
|
19
34
|
/**
|
|
20
35
|
* Filesystem path to aurora's pre-built `dist/`. Defaults to the
|
|
21
36
|
* dist directory shipped with the installed `@c9up/aurora` package.
|
|
@@ -67,6 +82,12 @@ export declare class AuroraManager {
|
|
|
67
82
|
readonly cometDistRoot: string | null;
|
|
68
83
|
/** App-level importmap overrides from `config/aurora.ts`, merged on render. */
|
|
69
84
|
readonly importmap: Record<string, string>;
|
|
85
|
+
/** App-level shared props from config/aurora.ts. */
|
|
86
|
+
readonly shared?: SharedProps | SharedPropsResolver;
|
|
87
|
+
/** App-level root element defaults from config/aurora.ts. */
|
|
88
|
+
readonly root?: AuroraManagerConfig["root"];
|
|
89
|
+
/** App-level asset/version marker. */
|
|
90
|
+
readonly assetsVersion?: string;
|
|
70
91
|
constructor(config: AuroraManagerConfig);
|
|
71
92
|
/**
|
|
72
93
|
* SSR + hydrate + ship the document. The importmap layers, last wins:
|
package/dist/AuroraManager.js
CHANGED
|
@@ -50,6 +50,12 @@ export class AuroraManager {
|
|
|
50
50
|
cometDistRoot;
|
|
51
51
|
/** App-level importmap overrides from `config/aurora.ts`, merged on render. */
|
|
52
52
|
importmap;
|
|
53
|
+
/** App-level shared props from config/aurora.ts. */
|
|
54
|
+
shared;
|
|
55
|
+
/** App-level root element defaults from config/aurora.ts. */
|
|
56
|
+
root;
|
|
57
|
+
/** App-level asset/version marker. */
|
|
58
|
+
assetsVersion;
|
|
53
59
|
constructor(config) {
|
|
54
60
|
this.assetsPrefix = normalizePrefix(config.assetsPrefix ?? "/__assets");
|
|
55
61
|
this.auroraAssetPath = `${this.assetsPrefix}/aurora`;
|
|
@@ -57,6 +63,9 @@ export class AuroraManager {
|
|
|
57
63
|
this.cometAssetPath = `${this.assetsPrefix}/comet`;
|
|
58
64
|
this.cometDistRoot = config.cometDistRoot ?? resolveCometDist();
|
|
59
65
|
this.importmap = config.importmap ?? {};
|
|
66
|
+
this.shared = config.shared;
|
|
67
|
+
this.root = config.root;
|
|
68
|
+
this.assetsVersion = config.assetsVersion;
|
|
60
69
|
// Pages serve their compiled JS from the same prefix unless the app
|
|
61
70
|
// pins an explicit urlPrefix.
|
|
62
71
|
this.pages = new Pages({
|
|
@@ -75,6 +84,10 @@ export class AuroraManager {
|
|
|
75
84
|
render(ctx, name, props, options) {
|
|
76
85
|
return renderPage(ctx, this.pages, name, props, {
|
|
77
86
|
...options,
|
|
87
|
+
rootTag: options?.rootTag ?? this.root?.tag,
|
|
88
|
+
rootClass: options?.rootClass ?? this.root?.class,
|
|
89
|
+
assetsVersion: options?.assetsVersion ?? this.assetsVersion,
|
|
90
|
+
shared: mergeSharedResolvers(this.shared, options?.shared),
|
|
78
91
|
importmap: {
|
|
79
92
|
"@c9up/aurora": `${this.auroraAssetPath}/index.js`,
|
|
80
93
|
// The browser-facing subpath (RPC client) needs an explicit entry —
|
|
@@ -120,3 +133,16 @@ export class AuroraManager {
|
|
|
120
133
|
: null;
|
|
121
134
|
}
|
|
122
135
|
}
|
|
136
|
+
function mergeSharedResolvers(base, override) {
|
|
137
|
+
if (!base)
|
|
138
|
+
return override;
|
|
139
|
+
if (!override)
|
|
140
|
+
return base;
|
|
141
|
+
return async (ctx) => ({
|
|
142
|
+
...(await resolveShared(ctx, base)),
|
|
143
|
+
...(await resolveShared(ctx, override)),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
async function resolveShared(ctx, shared) {
|
|
147
|
+
return typeof shared === "function" ? shared(ctx) : shared;
|
|
148
|
+
}
|
package/dist/browser.d.ts
CHANGED
|
@@ -153,6 +153,12 @@ export interface CookieOptions {
|
|
|
153
153
|
/** Restrict to HTTPS. */
|
|
154
154
|
secure?: boolean;
|
|
155
155
|
}
|
|
156
|
+
type CookieStoreReader = () => Record<string, string> | undefined;
|
|
157
|
+
/**
|
|
158
|
+
* @internal Server-side hook used by `renderPage()` to provide a request-scoped
|
|
159
|
+
* cookie store without importing Node built-ins from this browser-safe module.
|
|
160
|
+
*/
|
|
161
|
+
export declare function setCookieStoreReader(reader: CookieStoreReader | undefined): void;
|
|
156
162
|
/**
|
|
157
163
|
* Install the SSR cookie seed (`name → value`). Called by `renderPage` from its
|
|
158
164
|
* `cookies` allowlist; the hydrate bootstrap does NOT call it (the browser reads
|
|
@@ -228,3 +234,4 @@ export interface ShareData {
|
|
|
228
234
|
* user cancels — never throws.
|
|
229
235
|
*/
|
|
230
236
|
export declare function share(data: ShareData): Promise<boolean>;
|
|
237
|
+
export {};
|
package/dist/browser.js
CHANGED
|
@@ -10,7 +10,7 @@ import { effect, onCleanup, signal } from "./reactive.js";
|
|
|
10
10
|
/** Navigate to `url` with a full page load. No-op during SSR. */
|
|
11
11
|
export function redirect(url) {
|
|
12
12
|
if (typeof window !== "undefined") {
|
|
13
|
-
window.location.href = url;
|
|
13
|
+
window.location.href = safeNavigationUrl(url);
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
/**
|
|
@@ -19,7 +19,7 @@ export function redirect(url) {
|
|
|
19
19
|
*/
|
|
20
20
|
export function replace(url) {
|
|
21
21
|
if (typeof window !== "undefined") {
|
|
22
|
-
window.location.replace(url);
|
|
22
|
+
window.location.replace(safeNavigationUrl(url));
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
/** Reload the current page. No-op during SSR. */
|
|
@@ -278,6 +278,22 @@ export function forward() {
|
|
|
278
278
|
if (typeof window !== "undefined")
|
|
279
279
|
window.history.forward();
|
|
280
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* Drop every C0 control character (U+0000–U+001F).
|
|
283
|
+
*
|
|
284
|
+
* Written as a scan rather than a regex: a character class over control
|
|
285
|
+
* characters is exactly what `noControlCharactersInRegex` flags, and the rule
|
|
286
|
+
* is right in general — here the stripping is the point, so the loop states it
|
|
287
|
+
* without needing a suppression.
|
|
288
|
+
*/
|
|
289
|
+
function stripControlChars(value) {
|
|
290
|
+
let out = "";
|
|
291
|
+
for (const char of value) {
|
|
292
|
+
if (char.charCodeAt(0) > 0x1f)
|
|
293
|
+
out += char;
|
|
294
|
+
}
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
281
297
|
/**
|
|
282
298
|
* SPA navigation: push `url` onto history WITHOUT a full page reload (contrast
|
|
283
299
|
* {@link redirect}, which reloads). Emits a `popstate` event so reactive URL
|
|
@@ -287,9 +303,23 @@ export function forward() {
|
|
|
287
303
|
export function navigate(url) {
|
|
288
304
|
if (typeof window === "undefined")
|
|
289
305
|
return;
|
|
290
|
-
window.history.pushState({}, "", url);
|
|
306
|
+
window.history.pushState({}, "", safeNavigationUrl(url));
|
|
291
307
|
window.dispatchEvent(new Event("popstate"));
|
|
292
308
|
}
|
|
309
|
+
function safeNavigationUrl(url) {
|
|
310
|
+
// Browsers strip ASCII tab/newline/CR from ANYWHERE in a URL and trim leading
|
|
311
|
+
// control chars + whitespace before resolving the scheme, so `java\tscript:`
|
|
312
|
+
// (or a leading NUL) is evaluated as `javascript:`. A guard that only
|
|
313
|
+
// `trimStart()`s is trivially bypassed — mirror the browser and strip every
|
|
314
|
+
// C0 control char before comparing the scheme.
|
|
315
|
+
const normalized = stripControlChars(url).trimStart().toLowerCase();
|
|
316
|
+
if (normalized.startsWith("javascript:") ||
|
|
317
|
+
normalized.startsWith("vbscript:") ||
|
|
318
|
+
normalized.startsWith("data:")) {
|
|
319
|
+
throw new Error(`[aurora] blocked unsafe navigation URL: ${url}`);
|
|
320
|
+
}
|
|
321
|
+
return url;
|
|
322
|
+
}
|
|
293
323
|
/**
|
|
294
324
|
* A {@link Signal} bound to a single URL query parameter. Reading reflects the
|
|
295
325
|
* current value (`null` when absent); writing updates the URL via `pushState`
|
|
@@ -331,13 +361,20 @@ export function queryParam(key) {
|
|
|
331
361
|
* no view of the request cookies and renders default UI state → a flash /
|
|
332
362
|
* mismatch on hydration (the classic collapsed-sidebar flicker).
|
|
333
363
|
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
* race under concurrent async page factories. In the browser it is unused —
|
|
364
|
+
* Fallback module-global for manual/server tests. `renderPage()` installs a
|
|
365
|
+
* request-scoped reader backed by AsyncLocalStorage, so concurrent SSR renders
|
|
366
|
+
* do not share this mutable object. In the browser it is unused —
|
|
338
367
|
* {@link cookie.get} reads `document.cookie` directly there.
|
|
339
368
|
*/
|
|
340
369
|
let cookieSeed = {};
|
|
370
|
+
let cookieStoreReader;
|
|
371
|
+
/**
|
|
372
|
+
* @internal Server-side hook used by `renderPage()` to provide a request-scoped
|
|
373
|
+
* cookie store without importing Node built-ins from this browser-safe module.
|
|
374
|
+
*/
|
|
375
|
+
export function setCookieStoreReader(reader) {
|
|
376
|
+
cookieStoreReader = reader;
|
|
377
|
+
}
|
|
341
378
|
/**
|
|
342
379
|
* Install the SSR cookie seed (`name → value`). Called by `renderPage` from its
|
|
343
380
|
* `cookies` allowlist; the hydrate bootstrap does NOT call it (the browser reads
|
|
@@ -358,8 +395,12 @@ export function getCookieStore() {
|
|
|
358
395
|
*/
|
|
359
396
|
export const cookie = {
|
|
360
397
|
get(name) {
|
|
361
|
-
if (typeof document === "undefined")
|
|
398
|
+
if (typeof document === "undefined") {
|
|
399
|
+
const scoped = cookieStoreReader?.();
|
|
400
|
+
if (scoped && Object.hasOwn(scoped, name))
|
|
401
|
+
return scoped[name];
|
|
362
402
|
return cookieSeed[name] ?? null;
|
|
403
|
+
}
|
|
363
404
|
const prefix = `${encodeURIComponent(name)}=`;
|
|
364
405
|
for (const part of document.cookie.split("; ")) {
|
|
365
406
|
if (part.startsWith(prefix)) {
|
package/dist/form.d.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.
|
|
@@ -31,15 +31,25 @@
|
|
|
31
31
|
import { type ReadSignal } from "./reactive.js";
|
|
32
32
|
/** A field-keyed error map: `{ email: "Invalid", … }`. Absent key ⇒ no error. */
|
|
33
33
|
export type FieldErrors<T> = Partial<Record<keyof T, string>>;
|
|
34
|
-
/**
|
|
34
|
+
/** The synchronous, never-throwing outcome a form schema hands back. */
|
|
35
|
+
export interface FormValidationOutcome {
|
|
36
|
+
valid: boolean;
|
|
37
|
+
errors?: ReadonlyArray<{
|
|
38
|
+
field?: string;
|
|
39
|
+
message: string;
|
|
40
|
+
}>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Anything schema-shaped (a `@c9up/rune` schema satisfies this).
|
|
44
|
+
*
|
|
45
|
+
* Both spellings are accepted, and `validateResult` wins when present: rune
|
|
46
|
+
* reserves `validate()` for the VineJS contract (async, throwing), and reading
|
|
47
|
+
* `.valid` off a Promise yields `undefined` — the form would then report itself
|
|
48
|
+
* invalid with no error to show.
|
|
49
|
+
*/
|
|
35
50
|
export interface FormSchema<T> {
|
|
36
|
-
validate(values: T):
|
|
37
|
-
|
|
38
|
-
errors?: ReadonlyArray<{
|
|
39
|
-
field?: string;
|
|
40
|
-
message: string;
|
|
41
|
-
}>;
|
|
42
|
-
};
|
|
51
|
+
validate?(values: T): FormValidationOutcome;
|
|
52
|
+
validateResult?(values: T): FormValidationOutcome;
|
|
43
53
|
}
|
|
44
54
|
/** Validation source — a function, a schema-like object, or omitted. */
|
|
45
55
|
export type FormValidate<T> = ((values: T) => FieldErrors<T>) | FormSchema<T>;
|
package/dist/form.js
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.
|
|
@@ -36,7 +36,10 @@ function computeErrors(validate, values) {
|
|
|
36
36
|
return {};
|
|
37
37
|
if (typeof validate === "function")
|
|
38
38
|
return validate(values);
|
|
39
|
-
const
|
|
39
|
+
const check = validate.validateResult ?? validate.validate;
|
|
40
|
+
if (typeof check !== "function")
|
|
41
|
+
return {};
|
|
42
|
+
const result = check.call(validate, values);
|
|
40
43
|
if (result.valid)
|
|
41
44
|
return {};
|
|
42
45
|
const errors = {};
|
package/dist/http.d.ts
CHANGED
|
@@ -28,6 +28,13 @@ export interface HttpClientOptions {
|
|
|
28
28
|
token?: string | null | (() => string | null | undefined);
|
|
29
29
|
/** Default `credentials` mode (e.g. `"include"` to send cookies). */
|
|
30
30
|
credentials?: RequestCredentials;
|
|
31
|
+
/**
|
|
32
|
+
* Allow default bearer/default Authorization headers to be sent to absolute
|
|
33
|
+
* cross-origin URLs. Default `false`: same-origin API clients should not leak
|
|
34
|
+
* credentials if an untrusted value becomes the request URL. Per-request
|
|
35
|
+
* `headers.Authorization` is still treated as explicit caller intent.
|
|
36
|
+
*/
|
|
37
|
+
allowCrossOriginAuth?: boolean;
|
|
31
38
|
/**
|
|
32
39
|
* Default timeout in ms — the request is aborted (rejecting with a
|
|
33
40
|
* `TimeoutError`) if it doesn't settle in time. Combined with a per-request
|
|
@@ -48,6 +55,11 @@ export interface HttpRequestOptions<T = unknown> {
|
|
|
48
55
|
timeout?: number;
|
|
49
56
|
/** `credentials` mode for this request. */
|
|
50
57
|
credentials?: RequestCredentials;
|
|
58
|
+
/**
|
|
59
|
+
* Per-request override for sending managed auth headers to cross-origin
|
|
60
|
+
* absolute URLs. Default inherits the client option (`false` by default).
|
|
61
|
+
*/
|
|
62
|
+
allowCrossOriginAuth?: boolean;
|
|
51
63
|
/**
|
|
52
64
|
* Runtime validator/mapper for the parsed body. When provided, the return
|
|
53
65
|
* type is whatever it returns — no unchecked cast. When omitted, the parsed
|
package/dist/http.js
CHANGED
|
@@ -71,6 +71,39 @@ function hasHeader(headers, name) {
|
|
|
71
71
|
}
|
|
72
72
|
return false;
|
|
73
73
|
}
|
|
74
|
+
/** Delete every case variant of a header from a plain header record. */
|
|
75
|
+
function deleteHeader(headers, name) {
|
|
76
|
+
const lower = name.toLowerCase();
|
|
77
|
+
for (const key of Object.keys(headers)) {
|
|
78
|
+
if (key.toLowerCase() === lower)
|
|
79
|
+
delete headers[key];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function originOf(value) {
|
|
83
|
+
try {
|
|
84
|
+
if (/^[a-z][a-z\d+\-.]*:\/\//i.test(value))
|
|
85
|
+
return new URL(value).origin;
|
|
86
|
+
if (typeof window !== "undefined")
|
|
87
|
+
return new URL(value, window.location.href).origin;
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function isCrossOriginAbsoluteUrl(url, baseURL) {
|
|
95
|
+
if (!/^[a-z][a-z\d+\-.]*:\/\//i.test(url))
|
|
96
|
+
return false;
|
|
97
|
+
const targetOrigin = originOf(url);
|
|
98
|
+
if (targetOrigin === null)
|
|
99
|
+
return true;
|
|
100
|
+
const baseOrigin = baseURL ? originOf(baseURL) : null;
|
|
101
|
+
if (baseOrigin !== null)
|
|
102
|
+
return targetOrigin !== baseOrigin;
|
|
103
|
+
if (typeof window !== "undefined")
|
|
104
|
+
return targetOrigin !== window.location.origin;
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
74
107
|
/** Merge abort signals into one (whichever fires first wins). `undefined` if none. */
|
|
75
108
|
function combineSignals(signals) {
|
|
76
109
|
const present = signals.filter((s) => s !== undefined);
|
|
@@ -109,12 +142,14 @@ export class HttpClient {
|
|
|
109
142
|
#token;
|
|
110
143
|
#credentials;
|
|
111
144
|
#timeout;
|
|
145
|
+
#allowCrossOriginAuth;
|
|
112
146
|
constructor(options = {}) {
|
|
113
147
|
this.#baseURL = options.baseURL ?? "";
|
|
114
148
|
this.#headers = { ...options.headers };
|
|
115
149
|
this.#token = options.token;
|
|
116
150
|
this.#credentials = options.credentials;
|
|
117
151
|
this.#timeout = options.timeout;
|
|
152
|
+
this.#allowCrossOriginAuth = options.allowCrossOriginAuth ?? false;
|
|
118
153
|
}
|
|
119
154
|
/** Set a default header for every subsequent request (case-insensitive replace). Chainable. */
|
|
120
155
|
setHeader(name, value) {
|
|
@@ -196,6 +231,7 @@ export class HttpClient {
|
|
|
196
231
|
token: options.token ?? this.#token,
|
|
197
232
|
credentials: options.credentials ?? this.#credentials,
|
|
198
233
|
timeout: options.timeout ?? this.#timeout,
|
|
234
|
+
allowCrossOriginAuth: options.allowCrossOriginAuth ?? this.#allowCrossOriginAuth,
|
|
199
235
|
});
|
|
200
236
|
}
|
|
201
237
|
#resolveToken(override) {
|
|
@@ -220,12 +256,25 @@ export class HttpClient {
|
|
|
220
256
|
return `${base}${base.includes("?") ? "&" : "?"}${qs}`;
|
|
221
257
|
}
|
|
222
258
|
#send(method, url, body, options) {
|
|
259
|
+
const finalUrl = this.#buildUrl(url, options.query);
|
|
260
|
+
const crossOrigin = isCrossOriginAbsoluteUrl(finalUrl, this.#baseURL);
|
|
261
|
+
const allowCrossOriginAuth = options.allowCrossOriginAuth ?? this.#allowCrossOriginAuth;
|
|
262
|
+
const explicitRequestAuth = options.headers !== undefined &&
|
|
263
|
+
hasHeader(options.headers, "authorization");
|
|
223
264
|
const headers = {
|
|
224
265
|
...this.#headers,
|
|
225
266
|
...options.headers,
|
|
226
267
|
};
|
|
268
|
+
if (crossOrigin &&
|
|
269
|
+
!allowCrossOriginAuth &&
|
|
270
|
+
!explicitRequestAuth &&
|
|
271
|
+
hasHeader(this.#headers, "authorization")) {
|
|
272
|
+
deleteHeader(headers, "authorization");
|
|
273
|
+
}
|
|
227
274
|
const token = this.#resolveToken(options.token);
|
|
228
|
-
if (token != null &&
|
|
275
|
+
if (token != null &&
|
|
276
|
+
!hasHeader(headers, "authorization") &&
|
|
277
|
+
(!crossOrigin || allowCrossOriginAuth)) {
|
|
229
278
|
headers.Authorization = `Bearer ${token}`;
|
|
230
279
|
}
|
|
231
280
|
let payload;
|
|
@@ -246,7 +295,7 @@ export class HttpClient {
|
|
|
246
295
|
options.signal,
|
|
247
296
|
timeout !== undefined ? AbortSignal.timeout(timeout) : undefined,
|
|
248
297
|
]);
|
|
249
|
-
return fetch(
|
|
298
|
+
return fetch(finalUrl, {
|
|
250
299
|
method,
|
|
251
300
|
headers,
|
|
252
301
|
body: payload,
|
package/dist/index.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export { connectPatches, type LiveStore, liveStore, type RelayBroadcaster, } fro
|
|
|
15
15
|
export { buildLiveTransport, type LiveClientOptions, type LiveClientTransport, type LiveHttpPoster, liveClient, type RelaySubscribeClient, } from "./liveClient.js";
|
|
16
16
|
export { createLiveRegistry, type LiveRegistry, type LiveSessionHandle, } from "./liveRegistry.js";
|
|
17
17
|
export { createLiveRouter, type LiveMount, type LiveRouter, } from "./liveRouter.js";
|
|
18
|
-
export { DEFAULT_LIVE_EVENT_PATH, type LiveHttpContext, type LiveHttpRouter, type WireLiveEventsOptions, wireLiveEvents, } from "./liveServer.js";
|
|
18
|
+
export { DEFAULT_LIVE_EVENT_PATH, type LiveEventBody, type LiveHttpContext, type LiveHttpRouter, type WireLiveEventsOptions, wireLiveEvents, } from "./liveServer.js";
|
|
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";
|
package/dist/live.d.ts
CHANGED
|
@@ -16,5 +16,5 @@ export { connectPatches, type LiveStore, liveStore, type RelayBroadcaster, } fro
|
|
|
16
16
|
export { buildLiveTransport, type LiveClientOptions, type LiveClientTransport, type LiveHttpPoster, liveClient, type RelaySubscribeClient, } from "./liveClient.js";
|
|
17
17
|
export { createLiveRegistry, type LiveRegistry, type LiveSessionHandle, } from "./liveRegistry.js";
|
|
18
18
|
export { createLiveRouter, type LiveMount, type LiveRouter, } from "./liveRouter.js";
|
|
19
|
-
export { DEFAULT_LIVE_EVENT_PATH, type LiveHttpContext, type LiveHttpRouter, type WireLiveEventsOptions, wireLiveEvents, } from "./liveServer.js";
|
|
19
|
+
export { DEFAULT_LIVE_EVENT_PATH, type LiveEventBody, type LiveHttpContext, type LiveHttpRouter, type WireLiveEventsOptions, wireLiveEvents, } from "./liveServer.js";
|
|
20
20
|
export { type LiveComponentDefinition, type LiveSession, mountLiveSession, type SlotPatch, } from "./liveSession.js";
|
package/dist/liveServer.d.ts
CHANGED
|
@@ -27,6 +27,18 @@ export interface LiveHttpContext {
|
|
|
27
27
|
export interface WireLiveEventsOptions {
|
|
28
28
|
/** Route path for inbound events (must match the client transport). */
|
|
29
29
|
path?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Optional per-request guard. Return `false` to reject the event with 403.
|
|
32
|
+
* Use this to enforce the same auth/CSRF/owner policy as the page that mounted
|
|
33
|
+
* the live session. When omitted, aurora preserves the framework-agnostic
|
|
34
|
+
* legacy behavior and expects the host route/middleware to guard the endpoint.
|
|
35
|
+
*/
|
|
36
|
+
authorize?: (ctx: LiveHttpContext, body: LiveEventBody) => boolean | Promise<boolean>;
|
|
37
|
+
}
|
|
38
|
+
export interface LiveEventBody {
|
|
39
|
+
id: string;
|
|
40
|
+
event: string;
|
|
41
|
+
payload?: unknown;
|
|
30
42
|
}
|
|
31
43
|
/** Default inbound-event route — keep the client transport's `path` in sync. */
|
|
32
44
|
export declare const DEFAULT_LIVE_EVENT_PATH = "/__live/event";
|
package/dist/liveServer.js
CHANGED
|
@@ -25,13 +25,27 @@ export const DEFAULT_LIVE_EVENT_PATH = "/__live/event";
|
|
|
25
25
|
*/
|
|
26
26
|
export function wireLiveEvents(router, live, options = {}) {
|
|
27
27
|
const path = options.path ?? DEFAULT_LIVE_EVENT_PATH;
|
|
28
|
-
router.post(path, (ctx) => {
|
|
28
|
+
router.post(path, async (ctx) => {
|
|
29
29
|
const body = ctx.request.body();
|
|
30
30
|
if (!isLiveEventBody(body)) {
|
|
31
31
|
ctx.response.status(400);
|
|
32
32
|
ctx.response.json({ error: "live event requires { id, event }" });
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
35
|
+
let authorized = true;
|
|
36
|
+
if (options.authorize) {
|
|
37
|
+
try {
|
|
38
|
+
authorized = await options.authorize(ctx, body);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
authorized = false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (!authorized) {
|
|
45
|
+
ctx.response.status(403);
|
|
46
|
+
ctx.response.json({ error: "forbidden live event" });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
35
49
|
const handled = live.event(body.id, body.event, body.payload);
|
|
36
50
|
if (!handled) {
|
|
37
51
|
ctx.response.status(404);
|
package/dist/relay.js
CHANGED
|
@@ -263,7 +263,17 @@ function retrieveXsrfToken() {
|
|
|
263
263
|
if (typeof document === "undefined")
|
|
264
264
|
return null;
|
|
265
265
|
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/);
|
|
266
|
-
|
|
266
|
+
if (!match)
|
|
267
|
+
return null;
|
|
268
|
+
try {
|
|
269
|
+
return decodeURIComponent(match[1]);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// A malformed cookie must not break subscribe/unsubscribe handshakes. The
|
|
273
|
+
// server will reject an invalid token normally; the client should not throw
|
|
274
|
+
// before it even sends the request.
|
|
275
|
+
return match[1];
|
|
276
|
+
}
|
|
267
277
|
}
|
|
268
278
|
function safeJson(raw) {
|
|
269
279
|
if (typeof raw !== "string")
|
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>;
|