@c9up/aurora 0.1.39 → 0.1.40
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/Pages.d.ts +26 -0
- package/dist/Pages.js +70 -17
- package/dist/browser.js +2 -1
- package/dist/component.js +2 -1
- package/dist/devPageHooks.d.ts +44 -0
- package/dist/devPageHooks.js +62 -0
- package/dist/devPageReload.d.ts +75 -0
- package/dist/devPageReload.js +136 -0
- package/dist/errors.d.ts +51 -0
- package/dist/errors.js +26 -0
- package/dist/html.js +2 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/liveRegistry.js +2 -1
- package/dist/relay.js +2 -1
- package/dist/server/renderPage.js +2 -1
- package/dist/server.d.ts +4 -1
- package/dist/server.js +4 -1
- package/dist/services/main.js +2 -1
- package/dist/url.js +3 -2
- package/package.json +11 -8
- package/src/Pages.ts +92 -20
- package/src/browser.ts +5 -1
- package/src/component.ts +3 -1
- package/src/devPageHooks.ts +86 -0
- package/src/devPageReload.ts +142 -0
- package/src/errors.ts +58 -0
- package/src/html.ts +4 -1
- package/src/index.ts +1 -0
- package/src/liveRegistry.ts +4 -1
- package/src/relay.ts +5 -1
- package/src/server/renderPage.ts +5 -1
- package/src/server.ts +8 -1
- package/src/services/main.ts +3 -1
- package/src/url.ts +6 -2
package/dist/Pages.d.ts
CHANGED
|
@@ -76,3 +76,29 @@ export declare class Pages {
|
|
|
76
76
|
*/
|
|
77
77
|
urlFor(name: string): string;
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Tell "this page does not exist" apart from "this page exists and its module
|
|
81
|
+
* graph refused to load".
|
|
82
|
+
*
|
|
83
|
+
* `import()` fails for many reasons that say nothing about whether the page is
|
|
84
|
+
* there: a syntax error anywhere in the graph, a throw at module top level, an
|
|
85
|
+
* export missing from a transitively imported module. Reporting every one of
|
|
86
|
+
* them as "not found", against the page's own path, sends the reader to the one
|
|
87
|
+
* file that is certainly present, while the real cause arrives at the end of the
|
|
88
|
+
* sentence naming a module the message never said was involved.
|
|
89
|
+
*
|
|
90
|
+
* The question is answered from the filesystem, not from the error text. Node
|
|
91
|
+
* raises `ERR_MODULE_NOT_FOUND` for a missing specifier ANYWHERE in the graph
|
|
92
|
+
* and names the page in both cases — as the missing module when it IS the page,
|
|
93
|
+
* and as the IMPORTER when a transitive is missing:
|
|
94
|
+
*
|
|
95
|
+
* Cannot find module '<missing>' imported from '<importer>'
|
|
96
|
+
*
|
|
97
|
+
* so a substring test mis-sorts the second. Parsing the message is worse than
|
|
98
|
+
* fragile anyway: under a loader that is not plain Node (Vite's module runner,
|
|
99
|
+
* say) the text is different entirely. Whether the page is on disk is the same
|
|
100
|
+
* question under every loader.
|
|
101
|
+
*
|
|
102
|
+
* @internal exported for the tests that assert the classification
|
|
103
|
+
*/
|
|
104
|
+
export declare function pageImportError(name: string, absolute: string, cause: unknown, isDev: boolean): Error;
|
package/dist/Pages.js
CHANGED
|
@@ -13,8 +13,11 @@
|
|
|
13
13
|
* Sub-paths are allowed; the last `/`-separated segment is the file
|
|
14
14
|
* stem (with or without the `.js` extension).
|
|
15
15
|
*/
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
16
17
|
import { resolve as resolvePath, sep } from "node:path";
|
|
17
18
|
import { pathToFileURL } from "node:url";
|
|
19
|
+
import { newestMtime, registerDevPageHooks } from "./devPageReload.js";
|
|
20
|
+
import { AuroraError } from "./errors.js";
|
|
18
21
|
/**
|
|
19
22
|
* `Pages` is a tiny resolver — no caching, no glob, no magic. The
|
|
20
23
|
* server imports the module dynamically on every render so editors +
|
|
@@ -64,39 +67,45 @@ export class Pages {
|
|
|
64
67
|
assertSafeName(name);
|
|
65
68
|
const absolute = resolvePath(this.root, `${name}${this.extension}`);
|
|
66
69
|
if (!absolute.startsWith(this.root + sep) && absolute !== this.root) {
|
|
67
|
-
throw new
|
|
70
|
+
throw new AuroraError("E_AURORA_PAGE_OUTSIDE_ROOT", `[aurora] page path "${name}" resolves outside the pages root`);
|
|
68
71
|
}
|
|
69
72
|
// `pathToFileURL` so Windows + ESM stay happy. Node's ESM
|
|
70
73
|
// loader caches modules by URL, so a stable URL would freeze
|
|
71
74
|
// the first-imported version of the page for the whole process
|
|
72
75
|
// lifetime — pages edited on disk would NOT be picked up even
|
|
73
76
|
// when the app runs under a file watcher. In dev mode we bust
|
|
74
|
-
// the URL
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
77
|
+
// the URL so a real change yields a new cache key and triggers
|
|
78
|
+
// a re-import. In production we keep the stable URL — page
|
|
79
|
+
// sources don't change post-deploy and busting per-request
|
|
80
|
+
// would leak memory (each unique URL stays resident in the ESM
|
|
81
|
+
// loader for the process lifetime).
|
|
82
|
+
//
|
|
83
|
+
// **The token is the newest mtime in the TREE, not this file's.**
|
|
84
|
+
// A page's layout, its organisms and the services it imports are
|
|
85
|
+
// separate modules; keyed on the page's own mtime, editing any of
|
|
86
|
+
// them leaves this URL unchanged, so Node serves the cached page
|
|
87
|
+
// and never re-resolves what it imports. That made "edit a page"
|
|
88
|
+
// reload and "edit a template" not, which reads from the outside
|
|
89
|
+
// as the server caching files. See `devPageReload.ts`, and
|
|
90
|
+
// `devPageHooks.ts` for the other half: the page re-imports, and
|
|
91
|
+
// its imports need their own fresh keys to follow.
|
|
79
92
|
const isDev = process.env.NODE_ENV !== "production";
|
|
80
93
|
let urlHref = pathToFileURL(absolute).href;
|
|
81
94
|
if (isDev) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
catch {
|
|
87
|
-
// stat failed → fall back to stable URL; the import below
|
|
88
|
-
// will surface the underlying ENOENT.
|
|
89
|
-
}
|
|
95
|
+
await registerDevPageHooks(this.root);
|
|
96
|
+
const stamp = newestMtime(this.root);
|
|
97
|
+
if (stamp !== null)
|
|
98
|
+
urlHref = `${urlHref}?v=${stamp}`;
|
|
90
99
|
}
|
|
91
100
|
let mod;
|
|
92
101
|
try {
|
|
93
102
|
mod = (await import(urlHref));
|
|
94
103
|
}
|
|
95
104
|
catch (err) {
|
|
96
|
-
throw
|
|
105
|
+
throw pageImportError(name, absolute, err, isDev);
|
|
97
106
|
}
|
|
98
107
|
if (typeof mod.default !== "function") {
|
|
99
|
-
throw new
|
|
108
|
+
throw new AuroraError("E_AURORA_PAGE_INVALID_EXPORT", `[aurora] page "${name}" must default-export a factory function`);
|
|
100
109
|
}
|
|
101
110
|
return mod.default;
|
|
102
111
|
}
|
|
@@ -109,12 +118,56 @@ export class Pages {
|
|
|
109
118
|
return `${this.urlPrefix}/${name}${this.extension}`;
|
|
110
119
|
}
|
|
111
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Tell "this page does not exist" apart from "this page exists and its module
|
|
123
|
+
* graph refused to load".
|
|
124
|
+
*
|
|
125
|
+
* `import()` fails for many reasons that say nothing about whether the page is
|
|
126
|
+
* there: a syntax error anywhere in the graph, a throw at module top level, an
|
|
127
|
+
* export missing from a transitively imported module. Reporting every one of
|
|
128
|
+
* them as "not found", against the page's own path, sends the reader to the one
|
|
129
|
+
* file that is certainly present, while the real cause arrives at the end of the
|
|
130
|
+
* sentence naming a module the message never said was involved.
|
|
131
|
+
*
|
|
132
|
+
* The question is answered from the filesystem, not from the error text. Node
|
|
133
|
+
* raises `ERR_MODULE_NOT_FOUND` for a missing specifier ANYWHERE in the graph
|
|
134
|
+
* and names the page in both cases — as the missing module when it IS the page,
|
|
135
|
+
* and as the IMPORTER when a transitive is missing:
|
|
136
|
+
*
|
|
137
|
+
* Cannot find module '<missing>' imported from '<importer>'
|
|
138
|
+
*
|
|
139
|
+
* so a substring test mis-sorts the second. Parsing the message is worse than
|
|
140
|
+
* fragile anyway: under a loader that is not plain Node (Vite's module runner,
|
|
141
|
+
* say) the text is different entirely. Whether the page is on disk is the same
|
|
142
|
+
* question under every loader.
|
|
143
|
+
*
|
|
144
|
+
* @internal exported for the tests that assert the classification
|
|
145
|
+
*/
|
|
146
|
+
export function pageImportError(name, absolute, cause, isDev) {
|
|
147
|
+
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
148
|
+
if (!existsSync(absolute)) {
|
|
149
|
+
return new AuroraError("E_AURORA_PAGE_NOT_FOUND", `[aurora] page "${name}" not found at ${absolute}`, { cause: error });
|
|
150
|
+
}
|
|
151
|
+
// A missing export is raised at link time as a SyntaxError, and it names the
|
|
152
|
+
// specifier it could not satisfy. In dev that has a second cause worth
|
|
153
|
+
// naming: the page URL is busted by mtime, its imports are not, so a module
|
|
154
|
+
// edited on disk can stay frozen in the ESM cache for the life of the
|
|
155
|
+
// process while the page around it is re-read on every request. The export
|
|
156
|
+
// is then genuinely in the file and genuinely absent from the loaded module.
|
|
157
|
+
const stale = isDev && error.message.includes("does not provide an export named")
|
|
158
|
+
? "\n That export may well be on disk. Only the page URL is cache-busted here," +
|
|
159
|
+
"\n so an edited module it imports can stay frozen in this process's ESM cache." +
|
|
160
|
+
"\n Restart the server, or run it under a loader hook that invalidates a page's" +
|
|
161
|
+
"\n dependents (hot-hook)."
|
|
162
|
+
: "";
|
|
163
|
+
return new AuroraError("E_AURORA_PAGE_IMPORT_FAILED", `[aurora] page "${name}" loaded from ${absolute} but its module graph failed: ${error.message}${stale}`, { cause: error });
|
|
164
|
+
}
|
|
112
165
|
function assertSafeName(name) {
|
|
113
166
|
if (name.length === 0 ||
|
|
114
167
|
name.startsWith("/") ||
|
|
115
168
|
name.startsWith("\\") ||
|
|
116
169
|
name.includes("..") ||
|
|
117
170
|
name.includes("\0")) {
|
|
118
|
-
throw new
|
|
171
|
+
throw new AuroraError("E_AURORA_ILLEGAL_PAGE_NAME", `[aurora] illegal page name: ${JSON.stringify(name)}`);
|
|
119
172
|
}
|
|
120
173
|
}
|
package/dist/browser.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* without `typeof window` guards at every call site. Node-free — part of the
|
|
12
12
|
* client barrel.
|
|
13
13
|
*/
|
|
14
|
+
import { AuroraError } from "./errors.js";
|
|
14
15
|
import { effect, onCleanup, signal } from "./reactive.js";
|
|
15
16
|
/** Navigate to `url` with a full page load. No-op during SSR. */
|
|
16
17
|
export function redirect(url) {
|
|
@@ -321,7 +322,7 @@ function safeNavigationUrl(url) {
|
|
|
321
322
|
if (normalized.startsWith("javascript:") ||
|
|
322
323
|
normalized.startsWith("vbscript:") ||
|
|
323
324
|
normalized.startsWith("data:")) {
|
|
324
|
-
throw new
|
|
325
|
+
throw new AuroraError("E_AURORA_UNSAFE_URL", `[aurora] blocked unsafe navigation URL: ${url}`);
|
|
325
326
|
}
|
|
326
327
|
return url;
|
|
327
328
|
}
|
package/dist/component.js
CHANGED
|
@@ -18,12 +18,13 @@
|
|
|
18
18
|
* signals the setup function captures. The compiled template is what
|
|
19
19
|
* actually moves on screen.
|
|
20
20
|
*/
|
|
21
|
+
import { AuroraError } from "./errors.js";
|
|
21
22
|
import { setOwner } from "./reactive.js";
|
|
22
23
|
const contextStack = [];
|
|
23
24
|
function activeContext() {
|
|
24
25
|
const ctx = contextStack[contextStack.length - 1];
|
|
25
26
|
if (!ctx) {
|
|
26
|
-
throw new
|
|
27
|
+
throw new AuroraError("E_AURORA_OUTSIDE_COMPONENT", "[aurora] onMount / onUnmount called outside component() — only valid inside a component setup function.");
|
|
27
28
|
}
|
|
28
29
|
return ctx;
|
|
29
30
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The module-resolution hooks that make a page's IMPORTS reloadable in dev.
|
|
3
|
+
*
|
|
4
|
+
* **The bug these exist for.** `Pages.resolve` busts the ESM cache for a page
|
|
5
|
+
* by appending its own mtime, and says why: Node keys modules by URL, so a
|
|
6
|
+
* stable URL freezes the first-imported version for the process lifetime. That
|
|
7
|
+
* is right, and it covers exactly one file. A page's imports — the layout, the
|
|
8
|
+
* organisms, the services it pulls in — resolve RELATIVE to that URL and come
|
|
9
|
+
* out without a query, so they land on stable URLs that are already cached.
|
|
10
|
+
* Editing a template therefore changed nothing until the process restarted,
|
|
11
|
+
* while editing the page itself worked; the difference is invisible from the
|
|
12
|
+
* outside and reads as "the server caches my files".
|
|
13
|
+
*
|
|
14
|
+
* So the query has to follow the imports, and the only place that can happen is
|
|
15
|
+
* a resolution hook: Node hands every specifier through here before it consults
|
|
16
|
+
* its cache.
|
|
17
|
+
*
|
|
18
|
+
* **An existing query is never replaced.** `Pages` stamps a page with the
|
|
19
|
+
* NEWEST mtime in the whole tree — that is what makes a page re-import when a
|
|
20
|
+
* file it imports changes — and stamping it again here with its own mtime would
|
|
21
|
+
* undo exactly that. A child with no query gets its own mtime instead, so a
|
|
22
|
+
* module nobody touched keeps its key and stays cached: one edit re-imports the
|
|
23
|
+
* page and the file that changed, not the subtree.
|
|
24
|
+
*
|
|
25
|
+
* Dev only, and registered once — see `registerDevPageHooks`. In production the
|
|
26
|
+
* registration never happens, so this file is never loaded.
|
|
27
|
+
*/
|
|
28
|
+
interface ResolveContext {
|
|
29
|
+
conditions: string[];
|
|
30
|
+
importAttributes: Record<string, string>;
|
|
31
|
+
parentURL?: string;
|
|
32
|
+
}
|
|
33
|
+
interface ResolveResult {
|
|
34
|
+
url: string;
|
|
35
|
+
format?: string | null;
|
|
36
|
+
shortCircuit?: boolean;
|
|
37
|
+
importAttributes?: Record<string, string>;
|
|
38
|
+
}
|
|
39
|
+
type NextResolve = (specifier: string, context: ResolveContext) => ResolveResult | Promise<ResolveResult>;
|
|
40
|
+
export declare function initialize(data: {
|
|
41
|
+
root?: unknown;
|
|
42
|
+
} | undefined): void;
|
|
43
|
+
export declare function resolve(specifier: string, context: ResolveContext, nextResolve: NextResolve): Promise<ResolveResult>;
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The module-resolution hooks that make a page's IMPORTS reloadable in dev.
|
|
3
|
+
*
|
|
4
|
+
* **The bug these exist for.** `Pages.resolve` busts the ESM cache for a page
|
|
5
|
+
* by appending its own mtime, and says why: Node keys modules by URL, so a
|
|
6
|
+
* stable URL freezes the first-imported version for the process lifetime. That
|
|
7
|
+
* is right, and it covers exactly one file. A page's imports — the layout, the
|
|
8
|
+
* organisms, the services it pulls in — resolve RELATIVE to that URL and come
|
|
9
|
+
* out without a query, so they land on stable URLs that are already cached.
|
|
10
|
+
* Editing a template therefore changed nothing until the process restarted,
|
|
11
|
+
* while editing the page itself worked; the difference is invisible from the
|
|
12
|
+
* outside and reads as "the server caches my files".
|
|
13
|
+
*
|
|
14
|
+
* So the query has to follow the imports, and the only place that can happen is
|
|
15
|
+
* a resolution hook: Node hands every specifier through here before it consults
|
|
16
|
+
* its cache.
|
|
17
|
+
*
|
|
18
|
+
* **An existing query is never replaced.** `Pages` stamps a page with the
|
|
19
|
+
* NEWEST mtime in the whole tree — that is what makes a page re-import when a
|
|
20
|
+
* file it imports changes — and stamping it again here with its own mtime would
|
|
21
|
+
* undo exactly that. A child with no query gets its own mtime instead, so a
|
|
22
|
+
* module nobody touched keeps its key and stays cached: one edit re-imports the
|
|
23
|
+
* page and the file that changed, not the subtree.
|
|
24
|
+
*
|
|
25
|
+
* Dev only, and registered once — see `registerDevPageHooks`. In production the
|
|
26
|
+
* registration never happens, so this file is never loaded.
|
|
27
|
+
*/
|
|
28
|
+
import { statSync } from "node:fs";
|
|
29
|
+
import { sep } from "node:path";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
/** The pages directory, handed over at registration. */
|
|
32
|
+
let root = null;
|
|
33
|
+
export function initialize(data) {
|
|
34
|
+
root =
|
|
35
|
+
typeof data?.root === "string" && data.root.length > 0 ? data.root : null;
|
|
36
|
+
}
|
|
37
|
+
export async function resolve(specifier, context, nextResolve) {
|
|
38
|
+
const result = await nextResolve(specifier, context);
|
|
39
|
+
if (root === null || !result.url.startsWith("file:"))
|
|
40
|
+
return result;
|
|
41
|
+
// Already versioned by `Pages`: leave it. See the note above — replacing it
|
|
42
|
+
// with this file's own mtime is precisely the bug, reintroduced one level up.
|
|
43
|
+
if (result.url.includes("?"))
|
|
44
|
+
return result;
|
|
45
|
+
let path;
|
|
46
|
+
try {
|
|
47
|
+
path = fileURLToPath(result.url);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
if (!path.startsWith(root + sep))
|
|
53
|
+
return result;
|
|
54
|
+
try {
|
|
55
|
+
return { ...result, url: `${result.url}?v=${statSync(path).mtimeMs}` };
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Gone between resolution and stat: hand back the plain URL and let the
|
|
59
|
+
// import raise the real error rather than inventing one here.
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What makes a page pick up a change in something it IMPORTS, in dev.
|
|
3
|
+
*
|
|
4
|
+
* Two halves, and neither works alone:
|
|
5
|
+
*
|
|
6
|
+
* 1. {@link newestMtime} — the page's cache-busting token becomes the newest
|
|
7
|
+
* mtime anywhere under the pages root, not the page file's own. Without
|
|
8
|
+
* this, editing a layout leaves the page's key unchanged, so Node never
|
|
9
|
+
* re-imports it and never re-resolves anything it pulls in.
|
|
10
|
+
*
|
|
11
|
+
* 2. {@link registerDevPageHooks} — a resolution hook that stamps each import
|
|
12
|
+
* under that root with its own mtime. Without this, the re-imported page
|
|
13
|
+
* resolves its layout to a bare URL that is still in the cache.
|
|
14
|
+
*
|
|
15
|
+
* Both are dev-only. In production a page's sources do not change, and
|
|
16
|
+
* per-request busting would leak: every distinct URL stays resident in the ESM
|
|
17
|
+
* registry for the process lifetime.
|
|
18
|
+
*
|
|
19
|
+
* **Measured 2026-09-19, and re-measured before it was written down.** The
|
|
20
|
+
* whole approach rests on Node keying modules by full URL, query included, and
|
|
21
|
+
* on a registered `resolve` hook being consulted for every specifier. Both hold
|
|
22
|
+
* under plain Node AND under `tsx` — checked at tsx 4.7.0, 4.19.2 and 4.23.13,
|
|
23
|
+
* under `tsx` and `tsx watch`, with pages written as `.js` and as `.ts`, on
|
|
24
|
+
* Node 25. Importing `Page.js?v=1` then `Page.js?v=2` yields two instances in
|
|
25
|
+
* every one of those, and an edited template is visible in the second.
|
|
26
|
+
*
|
|
27
|
+
* That is worth stating because the opposite was believed first, and the belief
|
|
28
|
+
* would have closed the question: "the runner normalises the query away, so
|
|
29
|
+
* nothing here can work" reads as a fact about tsx and sends the next reader to
|
|
30
|
+
* configure a watcher instead. **A hook registration that fails silently is
|
|
31
|
+
* indistinguishable from a runner that ignores hooks** — which is what the
|
|
32
|
+
* `.js`/`.ts` bug below produced, and why it is the first thing to check if
|
|
33
|
+
* reloading ever appears not to work. Re-measuring costs two imports:
|
|
34
|
+
*
|
|
35
|
+
* ```js
|
|
36
|
+
* const a = await import("./mod.js?v=1")
|
|
37
|
+
* const b = await import("./mod.js?v=2") // a !== b, under every runner tried
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* A watcher is still worth having for the server's own sources. It is no longer
|
|
41
|
+
* needed for pages and templates, and watching them costs a full restart where
|
|
42
|
+
* this costs one re-import.
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* The newest mtime under `root`, or `null` when it cannot be read.
|
|
46
|
+
*
|
|
47
|
+
* **Directories are skipped by name, not by a filter someone has to remember.**
|
|
48
|
+
* `node_modules` and dot-directories are the two that would turn a per-render
|
|
49
|
+
* walk of a page tree into a walk of a dependency tree; everything else under a
|
|
50
|
+
* pages root is a page, a template or something one of them imports.
|
|
51
|
+
*
|
|
52
|
+
* Synchronous on purpose. It runs once per page render in dev, on a directory
|
|
53
|
+
* of tens of files, and the alternative — awaiting a tree walk before every
|
|
54
|
+
* import — buys nothing a developer can perceive while making the caller async
|
|
55
|
+
* for a case production never takes.
|
|
56
|
+
*/
|
|
57
|
+
export declare function newestMtime(root: string): number | null;
|
|
58
|
+
/**
|
|
59
|
+
* Register the resolution hooks, once per process.
|
|
60
|
+
*
|
|
61
|
+
* **Either extension, because this package is run both ways.** Installed, the
|
|
62
|
+
* hooks are `dist/devPageHooks.js`; from a checkout under a TypeScript runner
|
|
63
|
+
* they are `src/devPageHooks.ts`, and `module.register` resolves the URL it is
|
|
64
|
+
* given literally rather than through that runner's extension mapping. Asking
|
|
65
|
+
* for `.js` alone therefore worked for everybody consuming the package and for
|
|
66
|
+
* nobody working ON it — the failure mode being that page reloading silently
|
|
67
|
+
* stops improving.
|
|
68
|
+
*
|
|
69
|
+
* **A failure is reported, once, rather than swallowed.** The first version of
|
|
70
|
+
* this caught everything and said nothing, so the registration threw
|
|
71
|
+
* `ERR_MODULE_NOT_FOUND` and the only symptom was that templates went on not
|
|
72
|
+
* reloading — which is the bug this file exists to fix, reproduced one level
|
|
73
|
+
* up. Degrading is fine; degrading in silence is what costs an afternoon.
|
|
74
|
+
*/
|
|
75
|
+
export declare function registerDevPageHooks(root: string): Promise<void>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What makes a page pick up a change in something it IMPORTS, in dev.
|
|
3
|
+
*
|
|
4
|
+
* Two halves, and neither works alone:
|
|
5
|
+
*
|
|
6
|
+
* 1. {@link newestMtime} — the page's cache-busting token becomes the newest
|
|
7
|
+
* mtime anywhere under the pages root, not the page file's own. Without
|
|
8
|
+
* this, editing a layout leaves the page's key unchanged, so Node never
|
|
9
|
+
* re-imports it and never re-resolves anything it pulls in.
|
|
10
|
+
*
|
|
11
|
+
* 2. {@link registerDevPageHooks} — a resolution hook that stamps each import
|
|
12
|
+
* under that root with its own mtime. Without this, the re-imported page
|
|
13
|
+
* resolves its layout to a bare URL that is still in the cache.
|
|
14
|
+
*
|
|
15
|
+
* Both are dev-only. In production a page's sources do not change, and
|
|
16
|
+
* per-request busting would leak: every distinct URL stays resident in the ESM
|
|
17
|
+
* registry for the process lifetime.
|
|
18
|
+
*
|
|
19
|
+
* **Measured 2026-09-19, and re-measured before it was written down.** The
|
|
20
|
+
* whole approach rests on Node keying modules by full URL, query included, and
|
|
21
|
+
* on a registered `resolve` hook being consulted for every specifier. Both hold
|
|
22
|
+
* under plain Node AND under `tsx` — checked at tsx 4.7.0, 4.19.2 and 4.23.13,
|
|
23
|
+
* under `tsx` and `tsx watch`, with pages written as `.js` and as `.ts`, on
|
|
24
|
+
* Node 25. Importing `Page.js?v=1` then `Page.js?v=2` yields two instances in
|
|
25
|
+
* every one of those, and an edited template is visible in the second.
|
|
26
|
+
*
|
|
27
|
+
* That is worth stating because the opposite was believed first, and the belief
|
|
28
|
+
* would have closed the question: "the runner normalises the query away, so
|
|
29
|
+
* nothing here can work" reads as a fact about tsx and sends the next reader to
|
|
30
|
+
* configure a watcher instead. **A hook registration that fails silently is
|
|
31
|
+
* indistinguishable from a runner that ignores hooks** — which is what the
|
|
32
|
+
* `.js`/`.ts` bug below produced, and why it is the first thing to check if
|
|
33
|
+
* reloading ever appears not to work. Re-measuring costs two imports:
|
|
34
|
+
*
|
|
35
|
+
* ```js
|
|
36
|
+
* const a = await import("./mod.js?v=1")
|
|
37
|
+
* const b = await import("./mod.js?v=2") // a !== b, under every runner tried
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* A watcher is still worth having for the server's own sources. It is no longer
|
|
41
|
+
* needed for pages and templates, and watching them costs a full restart where
|
|
42
|
+
* this costs one re-import.
|
|
43
|
+
*/
|
|
44
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
45
|
+
import { join } from "node:path";
|
|
46
|
+
import { fileURLToPath } from "node:url";
|
|
47
|
+
/**
|
|
48
|
+
* The newest mtime under `root`, or `null` when it cannot be read.
|
|
49
|
+
*
|
|
50
|
+
* **Directories are skipped by name, not by a filter someone has to remember.**
|
|
51
|
+
* `node_modules` and dot-directories are the two that would turn a per-render
|
|
52
|
+
* walk of a page tree into a walk of a dependency tree; everything else under a
|
|
53
|
+
* pages root is a page, a template or something one of them imports.
|
|
54
|
+
*
|
|
55
|
+
* Synchronous on purpose. It runs once per page render in dev, on a directory
|
|
56
|
+
* of tens of files, and the alternative — awaiting a tree walk before every
|
|
57
|
+
* import — buys nothing a developer can perceive while making the caller async
|
|
58
|
+
* for a case production never takes.
|
|
59
|
+
*/
|
|
60
|
+
export function newestMtime(root) {
|
|
61
|
+
let newest = null;
|
|
62
|
+
const walk = (directory, depth) => {
|
|
63
|
+
// A pages root nested twenty deep is a mistake, not a feature; the cap
|
|
64
|
+
// is what keeps a symlink loop from becoming an infinite walk.
|
|
65
|
+
if (depth > 20)
|
|
66
|
+
return;
|
|
67
|
+
let entries;
|
|
68
|
+
try {
|
|
69
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules")
|
|
76
|
+
continue;
|
|
77
|
+
const full = join(directory, entry.name);
|
|
78
|
+
if (entry.isDirectory()) {
|
|
79
|
+
walk(full, depth + 1);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const { mtimeMs } = statSync(full);
|
|
84
|
+
if (newest === null || mtimeMs > newest)
|
|
85
|
+
newest = mtimeMs;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Deleted mid-walk: it cannot be the newest thing that still exists.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
walk(root, 0);
|
|
93
|
+
return newest;
|
|
94
|
+
}
|
|
95
|
+
let registered = false;
|
|
96
|
+
/**
|
|
97
|
+
* Register the resolution hooks, once per process.
|
|
98
|
+
*
|
|
99
|
+
* **Either extension, because this package is run both ways.** Installed, the
|
|
100
|
+
* hooks are `dist/devPageHooks.js`; from a checkout under a TypeScript runner
|
|
101
|
+
* they are `src/devPageHooks.ts`, and `module.register` resolves the URL it is
|
|
102
|
+
* given literally rather than through that runner's extension mapping. Asking
|
|
103
|
+
* for `.js` alone therefore worked for everybody consuming the package and for
|
|
104
|
+
* nobody working ON it — the failure mode being that page reloading silently
|
|
105
|
+
* stops improving.
|
|
106
|
+
*
|
|
107
|
+
* **A failure is reported, once, rather than swallowed.** The first version of
|
|
108
|
+
* this caught everything and said nothing, so the registration threw
|
|
109
|
+
* `ERR_MODULE_NOT_FOUND` and the only symptom was that templates went on not
|
|
110
|
+
* reloading — which is the bug this file exists to fix, reproduced one level
|
|
111
|
+
* up. Degrading is fine; degrading in silence is what costs an afternoon.
|
|
112
|
+
*/
|
|
113
|
+
export async function registerDevPageHooks(root) {
|
|
114
|
+
if (registered)
|
|
115
|
+
return;
|
|
116
|
+
registered = true;
|
|
117
|
+
try {
|
|
118
|
+
// Imported here rather than at module scope so production never pays for
|
|
119
|
+
// it: `Pages` only calls this when it is not in production.
|
|
120
|
+
const { register } = await import("node:module");
|
|
121
|
+
if (typeof register !== "function")
|
|
122
|
+
return;
|
|
123
|
+
const here = new URL(".", import.meta.url);
|
|
124
|
+
const hooks = ["devPageHooks.js", "devPageHooks.ts"]
|
|
125
|
+
.map((name) => new URL(name, here))
|
|
126
|
+
.find((candidate) => existsSync(fileURLToPath(candidate)));
|
|
127
|
+
if (hooks === undefined) {
|
|
128
|
+
console.warn("[aurora] page hooks not found beside devPageReload — a page will still reload when edited, but a template it imports will not");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
register(hooks, import.meta.url, { data: { root } });
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
console.warn(`[aurora] could not register the page reload hooks: ${error instanceof Error ? error.message : String(error)}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aurora's errors.
|
|
3
|
+
*
|
|
4
|
+
* Every failure aurora raises carries a stable `code`, so a caller can branch
|
|
5
|
+
* on what went wrong without matching on a message — a message is prose and is
|
|
6
|
+
* free to improve, a code is a contract. The cohort shape is
|
|
7
|
+
* `E_<PACKAGE>_<REASON>`.
|
|
8
|
+
*
|
|
9
|
+
* This module is imported by browser-side code as well as the server, so it
|
|
10
|
+
* stays free of Node built-ins.
|
|
11
|
+
*/
|
|
12
|
+
export type AuroraErrorCode =
|
|
13
|
+
/** A page name escapes the pages root — `..`, an absolute path, a NUL. */
|
|
14
|
+
"E_AURORA_ILLEGAL_PAGE_NAME"
|
|
15
|
+
/** The resolved path lands outside the configured pages root. */
|
|
16
|
+
| "E_AURORA_PAGE_OUTSIDE_ROOT"
|
|
17
|
+
/** No file for this page name. */
|
|
18
|
+
| "E_AURORA_PAGE_NOT_FOUND"
|
|
19
|
+
/** The page is on disk and its module graph refused to load. */
|
|
20
|
+
| "E_AURORA_PAGE_IMPORT_FAILED"
|
|
21
|
+
/** The page module loaded but does not default-export a factory. */
|
|
22
|
+
| "E_AURORA_PAGE_INVALID_EXPORT"
|
|
23
|
+
/** `renderPage` was given a root tag that is not a plain element name. */
|
|
24
|
+
| "E_AURORA_ILLEGAL_ROOT_TAG"
|
|
25
|
+
/** `onMount` / `onUnmount` called outside a `component()` setup function. */
|
|
26
|
+
| "E_AURORA_OUTSIDE_COMPONENT"
|
|
27
|
+
/** The manager singleton was read before a provider or `setAurora` set it. */
|
|
28
|
+
| "E_AURORA_NOT_BOOTED"
|
|
29
|
+
/** `urlFor` was given a name absent from the route manifest. */
|
|
30
|
+
| "E_AURORA_UNKNOWN_ROUTE"
|
|
31
|
+
/** `urlFor` was given a route whose required params were not all supplied. */
|
|
32
|
+
| "E_AURORA_MISSING_ROUTE_PARAMS"
|
|
33
|
+
/** A live component was mounted before `registry.define()` named it. */
|
|
34
|
+
| "E_AURORA_UNKNOWN_LIVE_COMPONENT"
|
|
35
|
+
/** A relay request came back with a non-2xx status. */
|
|
36
|
+
| "E_AURORA_RELAY_REQUEST_FAILED"
|
|
37
|
+
/** A navigation URL failed the same-origin / scheme check. */
|
|
38
|
+
| "E_AURORA_UNSAFE_URL"
|
|
39
|
+
/** An invariant inside aurora broke — always a bug in aurora itself. */
|
|
40
|
+
| "E_AURORA_INTERNAL";
|
|
41
|
+
/**
|
|
42
|
+
* Base class, so a caller can catch every aurora error by one name.
|
|
43
|
+
*
|
|
44
|
+
* `options` is the standard `ErrorOptions`, which is how `cause` reaches it:
|
|
45
|
+
* wrapping a lower-level failure must never drop the stack that points at the
|
|
46
|
+
* line responsible.
|
|
47
|
+
*/
|
|
48
|
+
export declare class AuroraError extends Error {
|
|
49
|
+
readonly code: AuroraErrorCode;
|
|
50
|
+
constructor(code: AuroraErrorCode, message: string, options?: ErrorOptions);
|
|
51
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aurora's errors.
|
|
3
|
+
*
|
|
4
|
+
* Every failure aurora raises carries a stable `code`, so a caller can branch
|
|
5
|
+
* on what went wrong without matching on a message — a message is prose and is
|
|
6
|
+
* free to improve, a code is a contract. The cohort shape is
|
|
7
|
+
* `E_<PACKAGE>_<REASON>`.
|
|
8
|
+
*
|
|
9
|
+
* This module is imported by browser-side code as well as the server, so it
|
|
10
|
+
* stays free of Node built-ins.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Base class, so a caller can catch every aurora error by one name.
|
|
14
|
+
*
|
|
15
|
+
* `options` is the standard `ErrorOptions`, which is how `cause` reaches it:
|
|
16
|
+
* wrapping a lower-level failure must never drop the stack that points at the
|
|
17
|
+
* line responsible.
|
|
18
|
+
*/
|
|
19
|
+
export class AuroraError extends Error {
|
|
20
|
+
code;
|
|
21
|
+
constructor(code, message, options) {
|
|
22
|
+
super(message, options);
|
|
23
|
+
this.name = new.target.name;
|
|
24
|
+
this.code = code;
|
|
25
|
+
}
|
|
26
|
+
}
|
package/dist/html.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* No custom directives, no fragments-in-attribute-name, no comment-only
|
|
21
21
|
* placeholders.
|
|
22
22
|
*/
|
|
23
|
+
import { AuroraError } from "./errors.js";
|
|
23
24
|
import { isTemplateResult, TEMPLATE_RESULT_BRAND, } from "./types.js";
|
|
24
25
|
const TEMPLATE_CACHE = new WeakMap();
|
|
25
26
|
/** Sentinel inserted at every `${...}` site. Read back during the walk. */
|
|
@@ -183,7 +184,7 @@ function collectSlots(root, classification) {
|
|
|
183
184
|
if (cls === undefined)
|
|
184
185
|
return;
|
|
185
186
|
if (cls.region !== "text") {
|
|
186
|
-
throw new
|
|
187
|
+
throw new AuroraError("E_AURORA_INTERNAL", `[aurora] internal classification mismatch at slot ${slotIndex}`);
|
|
187
188
|
}
|
|
188
189
|
const slot = { kind: "text", path: [...path] };
|
|
189
190
|
slots.push(slot);
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { type ClassValue, clsx, cn, twMerge } from "./cn.js";
|
|
|
4
4
|
export type { Command } from "./command.js";
|
|
5
5
|
export { command } from "./command.js";
|
|
6
6
|
export { component, onMount, onUnmount } from "./component.js";
|
|
7
|
+
export { AuroraError, type AuroraErrorCode } from "./errors.js";
|
|
7
8
|
export type { FieldErrors, Form, FormField, FormOptions, FormSchema, FormValidate, } from "./form.js";
|
|
8
9
|
export { form } from "./form.js";
|
|
9
10
|
export { html, isTemplateResult } from "./html.js";
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ export { back, booleanCookie, clipboard, cookie, cookieSignal, cookieState, forw
|
|
|
17
17
|
export { clsx, cn, twMerge } from "./cn.js";
|
|
18
18
|
export { command } from "./command.js";
|
|
19
19
|
export { component, onMount, onUnmount } from "./component.js";
|
|
20
|
+
export { AuroraError } from "./errors.js";
|
|
20
21
|
export { form } from "./form.js";
|
|
21
22
|
export { html, isTemplateResult } from "./html.js";
|
|
22
23
|
export { HttpClient, HttpError, http, isAbortError, isHttpError, } from "./http.js";
|
package/dist/liveRegistry.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* `get(id)?.dispatch(...)`; disconnect → `disposeOwner(uid)`. Transport-agnostic
|
|
11
11
|
* and node-free (only `mountLiveSession` + `crypto.randomUUID`, both isomorphic).
|
|
12
12
|
*/
|
|
13
|
+
import { AuroraError } from "./errors.js";
|
|
13
14
|
import { mountLiveSession, } from "./liveSession.js";
|
|
14
15
|
/** Create an isolated live-session registry (one per app / per relay instance). */
|
|
15
16
|
export function createLiveRegistry() {
|
|
@@ -39,7 +40,7 @@ export function createLiveRegistry() {
|
|
|
39
40
|
mount(name, ownerId) {
|
|
40
41
|
const factory = defs.get(name);
|
|
41
42
|
if (!factory) {
|
|
42
|
-
throw new
|
|
43
|
+
throw new AuroraError("E_AURORA_UNKNOWN_LIVE_COMPONENT", `[aurora:live] unknown live component "${name}" — register it with registry.define("${name}", …) before mounting.`);
|
|
43
44
|
}
|
|
44
45
|
const id = crypto.randomUUID();
|
|
45
46
|
const handle = {
|