@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 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 Error(`[aurora] page path "${name}" resolves outside the pages root`);
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 with the file's mtime so a real change yields a new
75
- // cache key and triggers a re-import. In production we keep
76
- // the stable URL — page sources don't change post-deploy and
77
- // busting per-request would leak memory (each unique URL stays
78
- // resident in the ESM loader for the process lifetime).
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
- try {
83
- const { statSync } = await import("node:fs");
84
- urlHref = `${urlHref}?v=${statSync(absolute).mtimeMs}`;
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 new Error(`[aurora] page "${name}" not found at ${absolute} ${err.message}`);
105
+ throw pageImportError(name, absolute, err, isDev);
97
106
  }
98
107
  if (typeof mod.default !== "function") {
99
- throw new Error(`[aurora] page "${name}" must default-export a factory function`);
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 Error(`[aurora] illegal page name: ${JSON.stringify(name)}`);
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 Error(`[aurora] blocked unsafe navigation URL: ${url}`);
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 Error("[aurora] onMount / onUnmount called outside component() — only valid inside a component setup function.");
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
+ }
@@ -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 Error(`[aurora] internal classification mismatch at slot ${slotIndex}`);
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";
@@ -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 Error(`[aurora:live] unknown live component "${name}" — register it with registry.define("${name}", …) before mounting.`);
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 = {