@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/relay.js CHANGED
@@ -17,6 +17,7 @@
17
17
  * `@c9up/aurora`. Node-side code that pulls it will trip on
18
18
  * `EventSource` being undefined.
19
19
  */
20
+ import { AuroraError } from "./errors.js";
20
21
  import { xsrfHeaderFor } from "./xsrf.js";
21
22
  const STATE = {
22
23
  sse: null,
@@ -250,7 +251,7 @@ async function postHandshake(url, channel) {
250
251
  credentials: "include",
251
252
  });
252
253
  if (!res.ok) {
253
- throw new Error(`HTTP ${res.status}`);
254
+ throw new AuroraError("E_AURORA_RELAY_REQUEST_FAILED", `[aurora:relay] HTTP ${res.status}`);
254
255
  }
255
256
  }
256
257
  function safeJson(raw) {
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { AsyncLocalStorage } from "node:async_hooks";
26
26
  import { setCookieStoreReader } from "../browser.js";
27
+ import { AuroraError } from "../errors.js";
27
28
  import { renderToString } from "../ssr.js";
28
29
  import { setRouteManifestReader } from "../url.js";
29
30
  const renderScope = new AsyncLocalStorage();
@@ -154,7 +155,7 @@ function isPlainRecord(value) {
154
155
  function normalizeRootTag(tag) {
155
156
  if (/^[a-z][a-z0-9-]*$/i.test(tag))
156
157
  return tag.toLowerCase();
157
- throw new Error(`[aurora] illegal root tag: ${JSON.stringify(tag)}`);
158
+ throw new AuroraError("E_AURORA_ILLEGAL_ROOT_TAG", `[aurora] illegal root tag: ${JSON.stringify(tag)}`);
158
159
  }
159
160
  function rootAttrs(id, className) {
160
161
  const attrs = [`id="${escapeAttr(id)}"`];
package/dist/server.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import "./augmentations.js";
2
2
  export { AuroraManager, type AuroraManagerConfig } from "./AuroraManager.js";
3
+ export { AuroraError, type AuroraErrorCode } from "./errors.js";
3
4
  export { type AuroraRequestRenderer, auroraContext, } from "./middleware.js";
4
- export { type PageFactory, Pages, type PagesConfig } from "./Pages.js";
5
+ export { type PageFactory, Pages, type PagesConfig,
6
+ /** @internal classification seam, asserted by the tests */
7
+ pageImportError, } from "./Pages.js";
5
8
  export { type RenderHttpContext, type RenderPageOptions, type RenderResponse, renderPage, type SharedProps, type SharedPropsResolver, } from "./server/renderPage.js";
6
9
  export { type AssetsHttpContext, type AssetsRequest, type AssetsResponse, packageAssetDir, type ServeAssetsOptions, serveAssets, } from "./server/serveAssets.js";
package/dist/server.js CHANGED
@@ -11,7 +11,10 @@
11
11
  // source into every consumer's program — including ones that never use it.
12
12
  import "./augmentations.js";
13
13
  export { AuroraManager } from "./AuroraManager.js";
14
+ export { AuroraError } from "./errors.js";
14
15
  export { auroraContext, } from "./middleware.js";
15
- export { Pages } from "./Pages.js";
16
+ export { Pages,
17
+ /** @internal classification seam, asserted by the tests */
18
+ pageImportError, } from "./Pages.js";
16
19
  export { renderPage, } from "./server/renderPage.js";
17
20
  export { packageAssetDir, serveAssets, } from "./server/serveAssets.js";
@@ -9,6 +9,7 @@
9
9
  * `() => import('@c9up/aurora/provider')`) or by the app itself via
10
10
  * `setAurora(myManager)`.
11
11
  */
12
+ import { AuroraError } from "../errors.js";
12
13
  let instance;
13
14
  /** @internal Bind the singleton (called by AuroraProvider or by the app). */
14
15
  export function setAurora(value) {
@@ -41,7 +42,7 @@ const aurora = new Proxy({}, {
41
42
  return undefined;
42
43
  }
43
44
  if (!instance) {
44
- throw new Error("[aurora] AuroraManager singleton accessed before AuroraProvider.boot() ran " +
45
+ throw new AuroraError("E_AURORA_NOT_BOOTED", "[aurora] AuroraManager singleton accessed before AuroraProvider.boot() ran " +
45
46
  "or `setAurora(myManager)` was called. Wire one of them first.");
46
47
  }
47
48
  const value = Reflect.get(instance, prop, instance);
package/dist/url.js CHANGED
@@ -16,6 +16,7 @@
16
16
  * injects the same map into the page so the hydrate bootstrap re-sets it client
17
17
  * side. Node-free — part of aurora's client runtime.
18
18
  */
19
+ import { AuroraError } from "./errors.js";
19
20
  let manifest = {};
20
21
  let routeManifestReader;
21
22
  /**
@@ -51,7 +52,7 @@ export function urlFor(name, params, query) {
51
52
  const pattern = routes[name];
52
53
  if (pattern === undefined) {
53
54
  const known = Object.keys(routes);
54
- throw new Error(`[aurora] urlFor: unknown route '${name}'. ${known.length > 0
55
+ throw new AuroraError("E_AURORA_UNKNOWN_ROUTE", `[aurora] urlFor: unknown route '${name}'. ${known.length > 0
55
56
  ? `Known: ${known.join(", ")}`
56
57
  : "No routes registered — was the manifest passed to render() / setRouteManifest() called?"}`);
57
58
  }
@@ -67,7 +68,7 @@ export function urlFor(name, params, query) {
67
68
  url = url.replace(/\/:[A-Za-z_][\w]*\?/g, "");
68
69
  const missing = url.match(/:[A-Za-z_][\w]*/g);
69
70
  if (missing && missing.length > 0) {
70
- throw new Error(`[aurora] urlFor: route '${name}' is missing params ${missing.join(", ")}`);
71
+ throw new AuroraError("E_AURORA_MISSING_ROUTE_PARAMS", `[aurora] urlFor: route '${name}' is missing params ${missing.join(", ")}`);
71
72
  }
72
73
  if (query) {
73
74
  const qs = Object.entries(query)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/aurora",
3
- "version": "0.1.39",
3
+ "version": "0.1.40",
4
4
  "description": "Aurora — reactive UI runtime for the Ream framework. Tagged-template DOM, signal-based state, isomorphic SSR + hydration, zero build step.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,6 +40,9 @@
40
40
  "import": "./dist/rpc.js"
41
41
  }
42
42
  },
43
+ "engines": {
44
+ "node": ">=22.0.0"
45
+ },
43
46
  "peerDependencies": {
44
47
  "@c9up/comet": "^0.1.0",
45
48
  "@c9up/ream": "^0.2.0"
@@ -53,17 +56,17 @@
53
56
  }
54
57
  },
55
58
  "devDependencies": {
56
- "@biomejs/biome": "^2.4.10",
59
+ "@biomejs/biome": "^2.5.12",
57
60
  "@c9up/comet": "^0.1.0",
58
- "@types/node": "^22.19.15",
61
+ "@c9up/ream": "^0.2.0",
62
+ "@types/node": "^22.20.1",
59
63
  "@vitest/browser": "4.1.11",
60
64
  "@vitest/browser-playwright": "4.1.11",
61
- "@vitest/coverage-v8": "4.1.9",
65
+ "@vitest/coverage-v8": "4.1.11",
62
66
  "jsdom": "^30.0.1",
63
- "playwright": "^1.61.1",
64
- "typescript": "^6.0.2",
65
- "vitest": "4.1.9",
66
- "@c9up/ream": "^0.2.0"
67
+ "playwright": "^1.63.0",
68
+ "typescript": "^6.0.3",
69
+ "vitest": "4.1.11"
67
70
  },
68
71
  "files": [
69
72
  "LICENSE",
package/src/Pages.ts CHANGED
@@ -14,8 +14,11 @@
14
14
  * stem (with or without the `.js` extension).
15
15
  */
16
16
 
17
+ import { existsSync } from "node:fs";
17
18
  import { resolve as resolvePath, sep } from "node:path";
18
19
  import { pathToFileURL } from "node:url";
20
+ import { newestMtime, registerDevPageHooks } from "./devPageReload.js";
21
+ import { AuroraError } from "./errors.js";
19
22
  import type { TemplateResult } from "./types.js";
20
23
 
21
24
  /** A page module's default export. Receives props, returns a template. */
@@ -101,7 +104,8 @@ export class Pages {
101
104
 
102
105
  const absolute = resolvePath(this.root, `${name}${this.extension}`);
103
106
  if (!absolute.startsWith(this.root + sep) && absolute !== this.root) {
104
- throw new Error(
107
+ throw new AuroraError(
108
+ "E_AURORA_PAGE_OUTSIDE_ROOT",
105
109
  `[aurora] page path "${name}" resolves outside the pages root`,
106
110
  );
107
111
  }
@@ -111,34 +115,37 @@ export class Pages {
111
115
  // the first-imported version of the page for the whole process
112
116
  // lifetime — pages edited on disk would NOT be picked up even
113
117
  // when the app runs under a file watcher. In dev mode we bust
114
- // the URL with the file's mtime so a real change yields a new
115
- // cache key and triggers a re-import. In production we keep
116
- // the stable URL — page sources don't change post-deploy and
117
- // busting per-request would leak memory (each unique URL stays
118
- // resident in the ESM loader for the process lifetime).
118
+ // the URL so a real change yields a new cache key and triggers
119
+ // a re-import. In production we keep the stable URL — page
120
+ // sources don't change post-deploy and busting per-request
121
+ // would leak memory (each unique URL stays resident in the ESM
122
+ // loader for the process lifetime).
123
+ //
124
+ // **The token is the newest mtime in the TREE, not this file's.**
125
+ // A page's layout, its organisms and the services it imports are
126
+ // separate modules; keyed on the page's own mtime, editing any of
127
+ // them leaves this URL unchanged, so Node serves the cached page
128
+ // and never re-resolves what it imports. That made "edit a page"
129
+ // reload and "edit a template" not, which reads from the outside
130
+ // as the server caching files. See `devPageReload.ts`, and
131
+ // `devPageHooks.ts` for the other half: the page re-imports, and
132
+ // its imports need their own fresh keys to follow.
119
133
  const isDev = process.env.NODE_ENV !== "production";
120
134
  let urlHref = pathToFileURL(absolute).href;
121
135
  if (isDev) {
122
- try {
123
- const { statSync } = await import("node:fs");
124
- urlHref = `${urlHref}?v=${statSync(absolute).mtimeMs}`;
125
- } catch {
126
- // stat failed → fall back to stable URL; the import below
127
- // will surface the underlying ENOENT.
128
- }
136
+ await registerDevPageHooks(this.root);
137
+ const stamp = newestMtime(this.root);
138
+ if (stamp !== null) urlHref = `${urlHref}?v=${stamp}`;
129
139
  }
130
140
  let mod: { default?: unknown };
131
141
  try {
132
142
  mod = (await import(urlHref)) as { default?: unknown };
133
143
  } catch (err) {
134
- throw new Error(
135
- `[aurora] page "${name}" not found at ${absolute} — ${
136
- (err as Error).message
137
- }`,
138
- );
144
+ throw pageImportError(name, absolute, err, isDev);
139
145
  }
140
146
  if (typeof mod.default !== "function") {
141
- throw new Error(
147
+ throw new AuroraError(
148
+ "E_AURORA_PAGE_INVALID_EXPORT",
142
149
  `[aurora] page "${name}" must default-export a factory function`,
143
150
  );
144
151
  }
@@ -155,6 +162,68 @@ export class Pages {
155
162
  }
156
163
  }
157
164
 
165
+ /**
166
+ * Tell "this page does not exist" apart from "this page exists and its module
167
+ * graph refused to load".
168
+ *
169
+ * `import()` fails for many reasons that say nothing about whether the page is
170
+ * there: a syntax error anywhere in the graph, a throw at module top level, an
171
+ * export missing from a transitively imported module. Reporting every one of
172
+ * them as "not found", against the page's own path, sends the reader to the one
173
+ * file that is certainly present, while the real cause arrives at the end of the
174
+ * sentence naming a module the message never said was involved.
175
+ *
176
+ * The question is answered from the filesystem, not from the error text. Node
177
+ * raises `ERR_MODULE_NOT_FOUND` for a missing specifier ANYWHERE in the graph
178
+ * and names the page in both cases — as the missing module when it IS the page,
179
+ * and as the IMPORTER when a transitive is missing:
180
+ *
181
+ * Cannot find module '<missing>' imported from '<importer>'
182
+ *
183
+ * so a substring test mis-sorts the second. Parsing the message is worse than
184
+ * fragile anyway: under a loader that is not plain Node (Vite's module runner,
185
+ * say) the text is different entirely. Whether the page is on disk is the same
186
+ * question under every loader.
187
+ *
188
+ * @internal exported for the tests that assert the classification
189
+ */
190
+ export function pageImportError(
191
+ name: string,
192
+ absolute: string,
193
+ cause: unknown,
194
+ isDev: boolean,
195
+ ): Error {
196
+ const error = cause instanceof Error ? cause : new Error(String(cause));
197
+
198
+ if (!existsSync(absolute)) {
199
+ return new AuroraError(
200
+ "E_AURORA_PAGE_NOT_FOUND",
201
+ `[aurora] page "${name}" not found at ${absolute}`,
202
+ { cause: error },
203
+ );
204
+ }
205
+
206
+ // A missing export is raised at link time as a SyntaxError, and it names the
207
+ // specifier it could not satisfy. In dev that has a second cause worth
208
+ // naming: the page URL is busted by mtime, its imports are not, so a module
209
+ // edited on disk can stay frozen in the ESM cache for the life of the
210
+ // process while the page around it is re-read on every request. The export
211
+ // is then genuinely in the file and genuinely absent from the loaded module.
212
+ const stale =
213
+ isDev && error.message.includes("does not provide an export named")
214
+ ? "\n That export may well be on disk. Only the page URL is cache-busted here," +
215
+ "\n so an edited module it imports can stay frozen in this process's ESM cache." +
216
+ "\n Restart the server, or run it under a loader hook that invalidates a page's" +
217
+ "\n dependents (hot-hook)."
218
+ : "";
219
+
220
+ return new AuroraError(
221
+ "E_AURORA_PAGE_IMPORT_FAILED",
222
+ `[aurora] page "${name}" loaded from ${absolute} but its module graph failed: ${error.message}${stale}`,
223
+ { cause: error },
224
+ );
225
+ }
226
+
158
227
  function assertSafeName(name: string): void {
159
228
  if (
160
229
  name.length === 0 ||
@@ -163,6 +232,9 @@ function assertSafeName(name: string): void {
163
232
  name.includes("..") ||
164
233
  name.includes("\0")
165
234
  ) {
166
- throw new Error(`[aurora] illegal page name: ${JSON.stringify(name)}`);
235
+ throw new AuroraError(
236
+ "E_AURORA_ILLEGAL_PAGE_NAME",
237
+ `[aurora] illegal page name: ${JSON.stringify(name)}`,
238
+ );
167
239
  }
168
240
  }
package/src/browser.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  * client barrel.
13
13
  */
14
14
 
15
+ import { AuroraError } from "./errors.js";
15
16
  import { effect, onCleanup, type Signal, signal } from "./reactive.js";
16
17
 
17
18
  /** Navigate to `url` with a full page load. No-op during SSR. */
@@ -403,7 +404,10 @@ function safeNavigationUrl(url: string): string {
403
404
  normalized.startsWith("vbscript:") ||
404
405
  normalized.startsWith("data:")
405
406
  ) {
406
- throw new Error(`[aurora] blocked unsafe navigation URL: ${url}`);
407
+ throw new AuroraError(
408
+ "E_AURORA_UNSAFE_URL",
409
+ `[aurora] blocked unsafe navigation URL: ${url}`,
410
+ );
407
411
  }
408
412
  return url;
409
413
  }
package/src/component.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  * actually moves on screen.
20
20
  */
21
21
 
22
+ import { AuroraError } from "./errors.js";
22
23
  import { setOwner } from "./reactive.js";
23
24
  import type { Disposer } from "./render.js";
24
25
  import type { EffectCallback, TemplateResult } from "./types.js";
@@ -40,7 +41,8 @@ const contextStack: ComponentContext[] = [];
40
41
  function activeContext(): ComponentContext {
41
42
  const ctx = contextStack[contextStack.length - 1];
42
43
  if (!ctx) {
43
- throw new Error(
44
+ throw new AuroraError(
45
+ "E_AURORA_OUTSIDE_COMPONENT",
44
46
  "[aurora] onMount / onUnmount called outside component() — only valid inside a component setup function.",
45
47
  );
46
48
  }
@@ -0,0 +1,86 @@
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
+
29
+ import { statSync } from "node:fs";
30
+ import { sep } from "node:path";
31
+ import { fileURLToPath } from "node:url";
32
+
33
+ interface ResolveContext {
34
+ conditions: string[];
35
+ importAttributes: Record<string, string>;
36
+ parentURL?: string;
37
+ }
38
+
39
+ interface ResolveResult {
40
+ url: string;
41
+ format?: string | null;
42
+ shortCircuit?: boolean;
43
+ importAttributes?: Record<string, string>;
44
+ }
45
+
46
+ type NextResolve = (
47
+ specifier: string,
48
+ context: ResolveContext,
49
+ ) => ResolveResult | Promise<ResolveResult>;
50
+
51
+ /** The pages directory, handed over at registration. */
52
+ let root: string | null = null;
53
+
54
+ export function initialize(data: { root?: unknown } | undefined): void {
55
+ root =
56
+ typeof data?.root === "string" && data.root.length > 0 ? data.root : null;
57
+ }
58
+
59
+ export async function resolve(
60
+ specifier: string,
61
+ context: ResolveContext,
62
+ nextResolve: NextResolve,
63
+ ): Promise<ResolveResult> {
64
+ const result = await nextResolve(specifier, context);
65
+ if (root === null || !result.url.startsWith("file:")) return result;
66
+
67
+ // Already versioned by `Pages`: leave it. See the note above — replacing it
68
+ // with this file's own mtime is precisely the bug, reintroduced one level up.
69
+ if (result.url.includes("?")) return result;
70
+
71
+ let path: string;
72
+ try {
73
+ path = fileURLToPath(result.url);
74
+ } catch {
75
+ return result;
76
+ }
77
+ if (!path.startsWith(root + sep)) return result;
78
+
79
+ try {
80
+ return { ...result, url: `${result.url}?v=${statSync(path).mtimeMs}` };
81
+ } catch {
82
+ // Gone between resolution and stat: hand back the plain URL and let the
83
+ // import raise the real error rather than inventing one here.
84
+ return result;
85
+ }
86
+ }
@@ -0,0 +1,142 @@
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
+ import { type Dirent, existsSync, readdirSync, statSync } from "node:fs";
46
+ import { join } from "node:path";
47
+ import { fileURLToPath } from "node:url";
48
+
49
+ /**
50
+ * The newest mtime under `root`, or `null` when it cannot be read.
51
+ *
52
+ * **Directories are skipped by name, not by a filter someone has to remember.**
53
+ * `node_modules` and dot-directories are the two that would turn a per-render
54
+ * walk of a page tree into a walk of a dependency tree; everything else under a
55
+ * pages root is a page, a template or something one of them imports.
56
+ *
57
+ * Synchronous on purpose. It runs once per page render in dev, on a directory
58
+ * of tens of files, and the alternative — awaiting a tree walk before every
59
+ * import — buys nothing a developer can perceive while making the caller async
60
+ * for a case production never takes.
61
+ */
62
+ export function newestMtime(root: string): number | null {
63
+ let newest: number | null = null;
64
+
65
+ const walk = (directory: string, depth: number): void => {
66
+ // A pages root nested twenty deep is a mistake, not a feature; the cap
67
+ // is what keeps a symlink loop from becoming an infinite walk.
68
+ if (depth > 20) return;
69
+ let entries: Dirent[];
70
+ try {
71
+ entries = readdirSync(directory, { withFileTypes: true });
72
+ } catch {
73
+ return;
74
+ }
75
+ for (const entry of entries) {
76
+ if (entry.name.startsWith(".") || entry.name === "node_modules") 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) newest = mtimeMs;
85
+ } catch {
86
+ // Deleted mid-walk: it cannot be the newest thing that still exists.
87
+ }
88
+ }
89
+ };
90
+
91
+ walk(root, 0);
92
+ return newest;
93
+ }
94
+
95
+ let registered = false;
96
+
97
+ /**
98
+ * Register the resolution hooks, once per process.
99
+ *
100
+ * **Either extension, because this package is run both ways.** Installed, the
101
+ * hooks are `dist/devPageHooks.js`; from a checkout under a TypeScript runner
102
+ * they are `src/devPageHooks.ts`, and `module.register` resolves the URL it is
103
+ * given literally rather than through that runner's extension mapping. Asking
104
+ * for `.js` alone therefore worked for everybody consuming the package and for
105
+ * nobody working ON it — the failure mode being that page reloading silently
106
+ * stops improving.
107
+ *
108
+ * **A failure is reported, once, rather than swallowed.** The first version of
109
+ * this caught everything and said nothing, so the registration threw
110
+ * `ERR_MODULE_NOT_FOUND` and the only symptom was that templates went on not
111
+ * reloading — which is the bug this file exists to fix, reproduced one level
112
+ * up. Degrading is fine; degrading in silence is what costs an afternoon.
113
+ */
114
+ export async function registerDevPageHooks(root: string): Promise<void> {
115
+ if (registered) 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") return;
122
+
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(
129
+ "[aurora] page hooks not found beside devPageReload — a page will still reload when edited, but a template it imports will not",
130
+ );
131
+ return;
132
+ }
133
+
134
+ register(hooks, import.meta.url, { data: { root } });
135
+ } catch (error) {
136
+ console.warn(
137
+ `[aurora] could not register the page reload hooks: ${
138
+ error instanceof Error ? error.message : String(error)
139
+ }`,
140
+ );
141
+ }
142
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,58 @@
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
+ export type AuroraErrorCode =
14
+ /** A page name escapes the pages root — `..`, an absolute path, a NUL. */
15
+ | "E_AURORA_ILLEGAL_PAGE_NAME"
16
+ /** The resolved path lands outside the configured pages root. */
17
+ | "E_AURORA_PAGE_OUTSIDE_ROOT"
18
+ /** No file for this page name. */
19
+ | "E_AURORA_PAGE_NOT_FOUND"
20
+ /** The page is on disk and its module graph refused to load. */
21
+ | "E_AURORA_PAGE_IMPORT_FAILED"
22
+ /** The page module loaded but does not default-export a factory. */
23
+ | "E_AURORA_PAGE_INVALID_EXPORT"
24
+ /** `renderPage` was given a root tag that is not a plain element name. */
25
+ | "E_AURORA_ILLEGAL_ROOT_TAG"
26
+ /** `onMount` / `onUnmount` called outside a `component()` setup function. */
27
+ | "E_AURORA_OUTSIDE_COMPONENT"
28
+ /** The manager singleton was read before a provider or `setAurora` set it. */
29
+ | "E_AURORA_NOT_BOOTED"
30
+ /** `urlFor` was given a name absent from the route manifest. */
31
+ | "E_AURORA_UNKNOWN_ROUTE"
32
+ /** `urlFor` was given a route whose required params were not all supplied. */
33
+ | "E_AURORA_MISSING_ROUTE_PARAMS"
34
+ /** A live component was mounted before `registry.define()` named it. */
35
+ | "E_AURORA_UNKNOWN_LIVE_COMPONENT"
36
+ /** A relay request came back with a non-2xx status. */
37
+ | "E_AURORA_RELAY_REQUEST_FAILED"
38
+ /** A navigation URL failed the same-origin / scheme check. */
39
+ | "E_AURORA_UNSAFE_URL"
40
+ /** An invariant inside aurora broke — always a bug in aurora itself. */
41
+ | "E_AURORA_INTERNAL";
42
+
43
+ /**
44
+ * Base class, so a caller can catch every aurora error by one name.
45
+ *
46
+ * `options` is the standard `ErrorOptions`, which is how `cause` reaches it:
47
+ * wrapping a lower-level failure must never drop the stack that points at the
48
+ * line responsible.
49
+ */
50
+ export class AuroraError extends Error {
51
+ readonly code: AuroraErrorCode;
52
+
53
+ constructor(code: AuroraErrorCode, message: string, options?: ErrorOptions) {
54
+ super(message, options);
55
+ this.name = new.target.name;
56
+ this.code = code;
57
+ }
58
+ }
package/src/html.ts CHANGED
@@ -21,6 +21,8 @@
21
21
  * placeholders.
22
22
  */
23
23
 
24
+ import { AuroraError } from "./errors.js";
25
+
24
26
  import {
25
27
  type AttrSlot,
26
28
  type BooleanAttrSlot,
@@ -223,7 +225,8 @@ function collectSlots(
223
225
  const cls = classification[slotIndex];
224
226
  if (cls === undefined) return;
225
227
  if (cls.region !== "text") {
226
- throw new Error(
228
+ throw new AuroraError(
229
+ "E_AURORA_INTERNAL",
227
230
  `[aurora] internal classification mismatch at slot ${slotIndex}`,
228
231
  );
229
232
  }
package/src/index.ts CHANGED
@@ -55,6 +55,7 @@ export { type ClassValue, clsx, cn, twMerge } from "./cn.js";
55
55
  export type { Command } from "./command.js";
56
56
  export { command } from "./command.js";
57
57
  export { component, onMount, onUnmount } from "./component.js";
58
+ export { AuroraError, type AuroraErrorCode } from "./errors.js";
58
59
  export type {
59
60
  FieldErrors,
60
61
  Form,