nukejs 0.0.11 → 0.0.13

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.
Files changed (77) hide show
  1. package/README.md +147 -0
  2. package/dist/Link.js +0 -1
  3. package/dist/app.js +0 -1
  4. package/dist/build-common.js +64 -14
  5. package/dist/build-node.js +63 -5
  6. package/dist/build-vercel.js +76 -9
  7. package/dist/builder.js +32 -4
  8. package/dist/bundle.js +47 -4
  9. package/dist/bundler.js +0 -1
  10. package/dist/component-analyzer.js +0 -1
  11. package/dist/config.js +0 -1
  12. package/dist/hmr-bundle.js +10 -1
  13. package/dist/hmr.js +7 -1
  14. package/dist/html-store.js +0 -1
  15. package/dist/http-server.js +0 -1
  16. package/dist/index.d.ts +2 -1
  17. package/dist/index.js +0 -1
  18. package/dist/logger.js +0 -1
  19. package/dist/metadata.js +0 -1
  20. package/dist/middleware-loader.js +0 -1
  21. package/dist/middleware.example.js +0 -1
  22. package/dist/middleware.js +0 -1
  23. package/dist/renderer.js +3 -9
  24. package/dist/request-store.js +0 -1
  25. package/dist/router.js +0 -1
  26. package/dist/ssr.js +73 -16
  27. package/dist/use-html.js +0 -1
  28. package/dist/use-request.js +0 -1
  29. package/dist/use-router.js +0 -1
  30. package/dist/utils.js +0 -1
  31. package/package.json +1 -1
  32. package/dist/Link.js.map +0 -7
  33. package/dist/app.d.ts +0 -19
  34. package/dist/app.js.map +0 -7
  35. package/dist/build-common.d.ts +0 -178
  36. package/dist/build-common.js.map +0 -7
  37. package/dist/build-node.d.ts +0 -15
  38. package/dist/build-node.js.map +0 -7
  39. package/dist/build-vercel.d.ts +0 -19
  40. package/dist/build-vercel.js.map +0 -7
  41. package/dist/builder.d.ts +0 -11
  42. package/dist/builder.js.map +0 -7
  43. package/dist/bundle.js.map +0 -7
  44. package/dist/bundler.d.ts +0 -58
  45. package/dist/bundler.js.map +0 -7
  46. package/dist/component-analyzer.d.ts +0 -75
  47. package/dist/component-analyzer.js.map +0 -7
  48. package/dist/config.d.ts +0 -35
  49. package/dist/config.js.map +0 -7
  50. package/dist/hmr-bundle.d.ts +0 -25
  51. package/dist/hmr-bundle.js.map +0 -7
  52. package/dist/hmr.d.ts +0 -55
  53. package/dist/hmr.js.map +0 -7
  54. package/dist/html-store.js.map +0 -7
  55. package/dist/http-server.d.ts +0 -92
  56. package/dist/http-server.js.map +0 -7
  57. package/dist/index.js.map +0 -7
  58. package/dist/logger.js.map +0 -7
  59. package/dist/metadata.d.ts +0 -51
  60. package/dist/metadata.js.map +0 -7
  61. package/dist/middleware-loader.d.ts +0 -50
  62. package/dist/middleware-loader.js.map +0 -7
  63. package/dist/middleware.d.ts +0 -22
  64. package/dist/middleware.example.d.ts +0 -8
  65. package/dist/middleware.example.js.map +0 -7
  66. package/dist/middleware.js.map +0 -7
  67. package/dist/renderer.d.ts +0 -44
  68. package/dist/renderer.js.map +0 -7
  69. package/dist/request-store.js.map +0 -7
  70. package/dist/router.d.ts +0 -92
  71. package/dist/router.js.map +0 -7
  72. package/dist/ssr.d.ts +0 -46
  73. package/dist/ssr.js.map +0 -7
  74. package/dist/use-html.js.map +0 -7
  75. package/dist/use-request.js.map +0 -7
  76. package/dist/use-router.js.map +0 -7
  77. package/dist/utils.js.map +0 -7
package/README.md CHANGED
@@ -24,6 +24,7 @@ npm create nuke@latest
24
24
  - [Configuration](#configuration)
25
25
  - [Link Component & Navigation](#link-component--navigation)
26
26
  - [useRequest() — URL Params, Query & Headers](#userequest--url-params-query--headers)
27
+ - [Error Pages](#error-pages)
27
28
  - [Building & Deploying](#building--deploying)
28
29
 
29
30
  ## Overview
@@ -714,6 +715,152 @@ Changing `?lang=fr` in the URL re-renders client components automatically.
714
715
 
715
716
  ---
716
717
 
718
+ ## Error Pages
719
+
720
+ NukeJS supports custom error pages for **404 Not Found** and **500 Internal Server Error**. Place them directly in `app/pages/` — they are standard server components and support everything regular pages do: layouts, `useHtml()`, client components, and HMR in dev.
721
+
722
+ ### `_404.tsx` — Page Not Found
723
+
724
+ Rendered when no route matches the requested URL.
725
+
726
+ ```tsx
727
+ // app/pages/_404.tsx
728
+ import { useHtml } from 'nukejs';
729
+ import { Link } from 'nukejs';
730
+
731
+ export default function NotFound() {
732
+ useHtml({ title: 'Page Not Found' });
733
+
734
+ return (
735
+ <main>
736
+ <h1>404 — Page Not Found</h1>
737
+ <p>The page you're looking for doesn't exist.</p>
738
+ <Link href="/">Go home</Link>
739
+ </main>
740
+ );
741
+ }
742
+ ```
743
+
744
+ ### `_500.tsx` — Internal Server Error
745
+
746
+ Rendered when a page handler throws an unhandled error. The error is passed as optional props so you can display details in development.
747
+
748
+ ```tsx
749
+ // app/pages/_500.tsx
750
+ import { useHtml } from 'nukejs';
751
+ import { Link } from 'nukejs';
752
+
753
+ interface ErrorProps {
754
+ errorMessage?: string; // human-readable error description
755
+ errorStatus?: string; // HTTP status code if present on the thrown error
756
+ errorStack?: string; // stack trace — only populated in development
757
+ }
758
+
759
+ export default function ServerError({ errorMessage, errorStack }: ErrorProps) {
760
+ useHtml({ title: 'Something went wrong' });
761
+
762
+ return (
763
+ <main>
764
+ <h1>500 — Server Error</h1>
765
+ <p>Something went wrong on our end. Please try again.</p>
766
+ {errorMessage && <p><strong>{errorMessage}</strong></p>}
767
+ {errorStack && <pre>{errorStack}</pre>}
768
+ <Link href="/">Go home</Link>
769
+ </main>
770
+ );
771
+ }
772
+ ```
773
+
774
+ ### Server errors
775
+
776
+ Any unhandled throw inside a server page component (including async data fetching) routes to `_500.tsx`. The error message and stack trace are forwarded as props automatically.
777
+
778
+ ```tsx
779
+ // app/pages/dashboard.tsx
780
+ export default async function Dashboard() {
781
+ const data = await fetchData(); // throws → _500.tsx is rendered
782
+ return <main>{data.name}</main>;
783
+ }
784
+ ```
785
+
786
+ You can also attach a `status` property to a thrown error to control the HTTP status code sent with the response:
787
+
788
+ ```tsx
789
+ export default async function Post({ id }: { id: string }) {
790
+ const post = await db.getPost(id);
791
+
792
+ if (!post) {
793
+ const err = new Error('Post not found');
794
+ (err as any).status = 404;
795
+ throw err; // _500.tsx receives errorMessage="Post not found", errorStatus="404"
796
+ }
797
+
798
+ return <article>{post.title}</article>;
799
+ }
800
+ ```
801
+
802
+ ### Client errors
803
+
804
+ Unhandled errors in client components and async code are automatically caught and routed to `_500.tsx` via an in-place SPA navigation — no full page reload. Three mechanisms cover all cases:
805
+
806
+ - **React Error Boundary** — wraps every hydrated `"use client"` component; catches render and lifecycle errors
807
+ - **`window.onerror`** — catches synchronous throws in event handlers and other non-React code
808
+ - **`window.onunhandledrejection`** — catches unhandled `Promise` rejections from `async` functions
809
+
810
+ ```tsx
811
+ // app/components/FaultyButton.tsx
812
+ "use client";
813
+
814
+ export default function FaultyButton() {
815
+ const handleClick = () => {
816
+ throw new Error('Something broke!'); // caught by window.onerror → _500.tsx
817
+ };
818
+
819
+ return <button onClick={handleClick}>Click me</button>;
820
+ }
821
+ ```
822
+
823
+ ```tsx
824
+ // app/components/FaultyFetch.tsx
825
+ "use client";
826
+ import { useEffect } from 'react';
827
+
828
+ export default function FaultyFetch() {
829
+ useEffect(() => {
830
+ // Unhandled rejection caught by window.onunhandledrejection → _500.tsx
831
+ fetch('/api/broken').then(res => {
832
+ if (!res.ok) throw new Error(`API error ${res.status}`);
833
+ });
834
+ }, []);
835
+
836
+ return <div>Loading...</div>;
837
+ }
838
+ ```
839
+
840
+ The `_500.tsx` page receives `errorMessage` and `errorStack` props from client errors just like server errors, so a single error page handles both origins consistently.
841
+
842
+ ### Behaviour
843
+
844
+ | Scenario | Without `_500.tsx` | With `_500.tsx` |
845
+ |---|---|---|
846
+ | Server page throws | Plain-text `Internal Server Error` (500) | `_500.tsx` rendered with error props |
847
+ | Client component render error | React crashes the component subtree | `_500.tsx` rendered in-place, no reload |
848
+ | Unhandled event handler throw | Browser console error only | `_500.tsx` rendered in-place, no reload |
849
+ | Unhandled promise rejection | Browser console error only | `_500.tsx` rendered in-place, no reload |
850
+ | Unknown URL | Plain-text `Page not found` (404) | `_404.tsx` rendered with 404 status |
851
+ | `<Link>` to unknown URL | Full page reload | In-place SPA navigation, no reload |
852
+ | HMR save of `_404.tsx` / `_500.tsx` | — | Current page re-fetches immediately |
853
+
854
+ ### Notes
855
+
856
+ - Error pages are **excluded from routing** — `/_404` and `/_500` are not reachable as URLs.
857
+ - They participate in the root `layout.tsx` like any other page.
858
+ - Both are fully bundled into the production output (Node.js and Vercel) — no runtime file-system access required.
859
+ - The correct HTTP status code (404 or 500) is always sent in the response.
860
+ - `errorStack` is only populated in development (`NODE_ENV !== 'production'`).
861
+
862
+ ---
863
+
717
864
  ## Building & Deploying
718
865
 
719
866
  ### Node.js server
package/dist/Link.js CHANGED
@@ -13,4 +13,3 @@ var Link_default = Link;
13
13
  export {
14
14
  Link_default as default
15
15
  };
16
- //# sourceMappingURL=Link.js.map
package/dist/app.js CHANGED
@@ -111,4 +111,3 @@ function printStartupBanner(port, isDev2) {
111
111
  }
112
112
  const actualPort = await tryListen(PORT);
113
113
  printStartupBanner(actualPort, isDev);
114
- //# sourceMappingURL=app.js.map
@@ -147,7 +147,8 @@ function extractDefaultExportName(filePath) {
147
147
  function collectServerPages(pagesDir) {
148
148
  if (!fs.existsSync(pagesDir)) return [];
149
149
  return walkFiles(pagesDir).filter((relPath) => {
150
- if (path.basename(relPath, path.extname(relPath)) === "layout") return false;
150
+ const stem = path.basename(relPath, path.extname(relPath));
151
+ if (stem === "layout" || stem === "_404" || stem === "_500") return false;
151
152
  return isServerComponent(path.join(pagesDir, relPath));
152
153
  }).map((relPath) => ({
153
154
  ...analyzeFile(relPath, "page"),
@@ -163,6 +164,15 @@ function collectGlobalClientRegistry(serverPages, pagesDir) {
163
164
  for (const [id, p] of findClientComponentsInTree(layoutPath, pagesDir))
164
165
  registry.set(id, p);
165
166
  }
167
+ for (const stem of ["_404", "_500"]) {
168
+ const errorFile = path.join(pagesDir, `${stem}.tsx`);
169
+ if (!fs.existsSync(errorFile)) continue;
170
+ for (const [id, p] of findClientComponentsInTree(errorFile, pagesDir))
171
+ registry.set(id, p);
172
+ for (const layoutPath of findPageLayouts(errorFile, pagesDir))
173
+ for (const [id, p] of findClientComponentsInTree(layoutPath, pagesDir))
174
+ registry.set(id, p);
175
+ }
166
176
  return registry;
167
177
  }
168
178
  function buildPerPageRegistry(absPath, layoutPaths, pagesDir) {
@@ -179,12 +189,15 @@ function buildPerPageRegistry(absPath, layoutPaths, pagesDir) {
179
189
  }
180
190
  return { registry, clientComponentNames };
181
191
  }
182
- async function buildPages(pagesDir, staticDir) {
192
+ async function buildPages(pagesDir, staticDir, outPagesDir) {
183
193
  const serverPages = collectServerPages(pagesDir);
184
194
  if (fs.existsSync(pagesDir) && walkFiles(pagesDir).length > 0 && serverPages.length === 0) {
185
195
  console.warn(`\u26A0 Pages found in ${pagesDir} but none are server components`);
186
196
  }
187
- if (serverPages.length === 0) return [];
197
+ if (serverPages.length === 0) {
198
+ const errorResult2 = outPagesDir ? await buildErrorPages(pagesDir, outPagesDir, {}) : { has404: false, has500: false };
199
+ return { pages: [], ...errorResult2 };
200
+ }
188
201
  const globalRegistry = collectGlobalClientRegistry(serverPages, pagesDir);
189
202
  const prerenderedHtml = await bundleClientComponents(globalRegistry, pagesDir, staticDir);
190
203
  const prerenderedRecord = Object.fromEntries(prerenderedHtml);
@@ -205,7 +218,8 @@ async function buildPages(pagesDir, staticDir) {
205
218
  });
206
219
  builtPages.push({ ...page, bundleText });
207
220
  }
208
- return builtPages;
221
+ const errorResult = outPagesDir ? await buildErrorPages(pagesDir, outPagesDir, prerenderedRecord) : { has404: false, has500: false };
222
+ return { pages: builtPages, ...errorResult };
209
223
  }
210
224
  function makeApiAdapterSource(handlerFilename) {
211
225
  return `import type { IncomingMessage, ServerResponse } from 'http';
@@ -265,7 +279,8 @@ function makePageAdapterSource(opts) {
265
279
  layoutArrayItems,
266
280
  prerenderedHtml,
267
281
  routeParamNames,
268
- catchAllNames
282
+ catchAllNames,
283
+ statusCode = 200
269
284
  } = opts;
270
285
  return `import type { IncomingMessage, ServerResponse } from 'http';
271
286
  import { createElement as __createElement__ } from 'react';
@@ -547,7 +562,15 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
547
562
 
548
563
  const hydrated = new Set<string>();
549
564
  // Merge query params into page props to match dev behaviour (ssr.ts mergedParams).
550
- const merged = { ...query, ...params } as any;
565
+ // Error props (__errorMessage, __errorStack, __errorStatus) are injected by the
566
+ // server entry when routing to _500.mjs after a handler failure.
567
+ const errorProps: Record<string, string | undefined> = {};
568
+ const ep = parsed.searchParams;
569
+ if (ep.has('__errorMessage')) errorProps.errorMessage = ep.get('__errorMessage') ?? undefined;
570
+ if (ep.has('__errorStack')) errorProps.errorStack = ep.get('__errorStack') ?? undefined;
571
+ if (ep.has('__errorStatus')) errorProps.errorStatus = ep.get('__errorStatus') ?? undefined;
572
+
573
+ const merged = { ...query, ...params, ...errorProps } as any;
551
574
  const wrapped = wrapWithLayouts({ type: __page__.default, props: merged, key: null, ref: null });
552
575
 
553
576
  let appHtml = '';
@@ -611,14 +634,13 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
611
634
  \${bodyScriptsHtml}</body>
612
635
  </html>\`;
613
636
 
614
- res.statusCode = 200;
637
+ res.statusCode = ${statusCode};
615
638
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
616
639
  res.end(html);
617
640
  } catch (err: any) {
618
- console.error('[page render error]', err);
619
- res.statusCode = 500;
620
- res.setHeader('Content-Type', 'text/plain; charset=utf-8');
621
- res.end('Internal Server Error');
641
+ // Re-throw so the server entry (build-node / build-vercel) can route to
642
+ // the _500 page handler. Do not swallow the error here.
643
+ throw err;
622
644
  }
623
645
  }
624
646
  `;
@@ -652,7 +674,8 @@ async function bundlePageHandler(opts) {
652
674
  layoutPaths,
653
675
  prerenderedHtml,
654
676
  routeParamNames,
655
- catchAllNames
677
+ catchAllNames,
678
+ statusCode
656
679
  } = opts;
657
680
  const adapterDir = path.dirname(absPath);
658
681
  const adapterPath = path.join(adapterDir, `_page_adapter_${randomBytes(4).toString("hex")}.ts`);
@@ -668,7 +691,8 @@ async function bundlePageHandler(opts) {
668
691
  layoutArrayItems: layoutPaths.map((_, i) => `__layout_${i}__`).join(", "),
669
692
  prerenderedHtml,
670
693
  routeParamNames,
671
- catchAllNames
694
+ catchAllNames,
695
+ statusCode
672
696
  }));
673
697
  let text;
674
698
  try {
@@ -740,6 +764,32 @@ async function bundleClientComponents(globalRegistry, pagesDir, staticDir) {
740
764
  console.log(` bundled ${globalRegistry.size} client component(s) \u2192 ${path.relative(process.cwd(), outDir)}/`);
741
765
  return prerendered;
742
766
  }
767
+ async function buildErrorPages(pagesDir, outPagesDir, prerenderedHtml) {
768
+ const result = { has404: false, has500: false };
769
+ for (const statusCode of [404, 500]) {
770
+ const src = path.join(pagesDir, `_${statusCode}.tsx`);
771
+ if (!fs.existsSync(src)) continue;
772
+ console.log(` building _${statusCode}.tsx \u2192 pages/_${statusCode}.mjs`);
773
+ const layoutPaths = findPageLayouts(src, pagesDir);
774
+ const { registry, clientComponentNames } = buildPerPageRegistry(src, layoutPaths, pagesDir);
775
+ const bundleText = await bundlePageHandler({
776
+ absPath: src,
777
+ pagesDir,
778
+ clientComponentNames,
779
+ allClientIds: [...registry.keys()],
780
+ layoutPaths,
781
+ prerenderedHtml,
782
+ routeParamNames: [],
783
+ catchAllNames: [],
784
+ statusCode
785
+ });
786
+ fs.mkdirSync(outPagesDir, { recursive: true });
787
+ fs.writeFileSync(path.join(outPagesDir, `_${statusCode}.mjs`), bundleText);
788
+ if (statusCode === 404) result.has404 = true;
789
+ if (statusCode === 500) result.has500 = true;
790
+ }
791
+ return result;
792
+ }
743
793
  async function buildCombinedBundle(staticDir) {
744
794
  const nukeDir = path.dirname(fileURLToPath(import.meta.url));
745
795
  const bundleFile = nukeDir.endsWith("dist") ? "bundle" : "bundle.ts";
@@ -806,6 +856,7 @@ function copyPublicFiles(publicDir, destDir) {
806
856
  export {
807
857
  analyzeFile,
808
858
  buildCombinedBundle,
859
+ buildErrorPages,
809
860
  buildPages,
810
861
  buildPerPageRegistry,
811
862
  bundleApiHandler,
@@ -821,4 +872,3 @@ export {
821
872
  makePageAdapterSource,
822
873
  walkFiles
823
874
  };
824
- //# sourceMappingURL=build-common.js.map
@@ -38,7 +38,7 @@ for (const { srcRegex, paramNames, catchAllNames, funcPath, absPath } of apiRout
38
38
  fs.writeFileSync(outPath, await bundleApiHandler(absPath));
39
39
  manifest.push({ srcRegex, paramNames, catchAllNames, handler: path.join("api", filename), type: "api" });
40
40
  }
41
- const builtPages = await buildPages(PAGES_DIR, STATIC_DIR);
41
+ const { pages: builtPages, has404, has500 } = await buildPages(PAGES_DIR, STATIC_DIR, PAGES_DIR_);
42
42
  for (const { srcRegex, paramNames, catchAllNames, funcPath, bundleText } of builtPages) {
43
43
  const filename = funcPathToFilename(funcPath, "page");
44
44
  const outPath = path.join(PAGES_DIR_, filename);
@@ -46,6 +46,8 @@ for (const { srcRegex, paramNames, catchAllNames, funcPath, bundleText } of buil
46
46
  fs.writeFileSync(outPath, bundleText);
47
47
  manifest.push({ srcRegex, paramNames, catchAllNames, handler: path.join("pages", filename), type: "page" });
48
48
  }
49
+ if (has404) console.log(" built _404.tsx \u2192 pages/_404.mjs");
50
+ if (has500) console.log(" built _500.tsx \u2192 pages/_500.mjs");
49
51
  fs.writeFileSync(
50
52
  path.join(OUT_DIR, "manifest.json"),
51
53
  JSON.stringify({ routes: manifest }, null, 2)
@@ -121,7 +123,31 @@ const server = http.createServer(async (req, res) => {
121
123
  }
122
124
  }
123
125
 
124
- // 2. Route dispatch \u2014 API routes appear before page routes in the manifest
126
+ // 2. Client-side error \u2014 navigate directly to _500 page.
127
+ {
128
+ const qs = new URL(url, 'http://localhost').searchParams;
129
+ if (qs.has('__clientError')) {
130
+ const e500 = path.join(__dirname, 'pages', '_500.mjs');
131
+ if (fs.existsSync(e500)) {
132
+ try {
133
+ const m500 = await import(pathToFileURL(e500).href);
134
+ const eq = new URLSearchParams();
135
+ eq.set('__errorMessage', qs.get('__clientError') || 'Client error');
136
+ const stack = qs.get('__clientStack');
137
+ if (stack) eq.set('__errorStack', stack);
138
+ req.url = '/_500?' + eq.toString();
139
+ await m500.default(req, res);
140
+ return;
141
+ } catch (e) { console.error('[_500 render error]', e); }
142
+ }
143
+ res.statusCode = 500;
144
+ res.setHeader('Content-Type', 'text/plain');
145
+ res.end('Internal Server Error');
146
+ return;
147
+ }
148
+ }
149
+
150
+ // 3. Route dispatch \u2014 API routes appear before page routes in the manifest
125
151
  // (built in build-node.ts), so they are matched first.
126
152
  for (const { regex, paramNames, catchAllNames, handler } of compiled) {
127
153
  const m = clean.match(regex);
@@ -139,11 +165,44 @@ const server = http.createServer(async (req, res) => {
139
165
  });
140
166
  req.url = clean + (qs.toString() ? '?' + qs.toString() : '');
141
167
 
142
- const mod = await import(pathToFileURL(path.join(__dirname, handler)).href);
143
- await mod.default(req, res);
168
+ try {
169
+ const mod = await import(pathToFileURL(path.join(__dirname, handler)).href);
170
+ await mod.default(req, res);
171
+ } catch (err) {
172
+ console.error('[handler error]', err);
173
+ const e500 = path.join(__dirname, 'pages', '_500.mjs');
174
+ if (fs.existsSync(e500)) {
175
+ try {
176
+ const m500 = await import(pathToFileURL(e500).href);
177
+ // Inject error info as query params so _500 page receives them as props.
178
+ const errMsg = err instanceof Error ? err.message : String(err);
179
+ const errStack = err instanceof Error ? err.stack : undefined;
180
+ const errStatus = err?.status ?? err?.statusCode;
181
+ const eq = new URLSearchParams();
182
+ eq.set('__errorMessage', errMsg);
183
+ if (errStack) eq.set('__errorStack', errStack);
184
+ if (errStatus) eq.set('__errorStatus', String(errStatus));
185
+ req.url = '/_500?' + eq.toString();
186
+ await m500.default(req, res);
187
+ return;
188
+ } catch (e) { console.error('[_500 render error]', e); }
189
+ }
190
+ res.statusCode = 500;
191
+ res.setHeader('Content-Type', 'text/plain');
192
+ res.end('Internal Server Error');
193
+ }
144
194
  return;
145
195
  }
146
196
 
197
+ // 3. 404 \u2014 serve _404.mjs if built, otherwise plain text.
198
+ const e404 = path.join(__dirname, 'pages', '_404.mjs');
199
+ if (fs.existsSync(e404)) {
200
+ try {
201
+ const m404 = await import(pathToFileURL(e404).href);
202
+ await m404.default(req, res);
203
+ return;
204
+ } catch (err) { console.error('[_404 render error]', err); }
205
+ }
147
206
  res.statusCode = 404;
148
207
  res.setHeader('Content-Type', 'text/plain');
149
208
  res.end('Not found');
@@ -156,4 +215,3 @@ fs.writeFileSync(path.join(OUT_DIR, "index.mjs"), serverEntry);
156
215
  console.log(`
157
216
  \u2713 Node build complete \u2014 ${manifest.length} route(s) \u2192 dist/`);
158
217
  console.log(" run with: node dist/index.mjs");
159
- //# sourceMappingURL=build-node.js.map
@@ -143,13 +143,43 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
143
143
  }
144
144
  `;
145
145
  }
146
- function makePagesDispatcherSource(routes) {
146
+ function makePagesDispatcherSource(routes, errorAdapters = {}) {
147
147
  const imports = routes.map((r, i) => `import __page_${i}__ from ${JSON.stringify(r.adapterPath)};`).join("\n");
148
148
  const routeEntries = routes.map(
149
149
  (r, i) => ` { regex: ${JSON.stringify(r.srcRegex)}, params: ${JSON.stringify(r.paramNames)}, catchAll: ${JSON.stringify(r.catchAllNames)}, handler: __page_${i}__ },`
150
150
  ).join("\n");
151
+ const error404Import = errorAdapters.adapter404 ? `import __error_404__ from ${JSON.stringify(errorAdapters.adapter404)};` : "";
152
+ const error500Import = errorAdapters.adapter500 ? `import __error_500__ from ${JSON.stringify(errorAdapters.adapter500)};` : "";
153
+ const notFoundHandler = errorAdapters.adapter404 ? ` try { return await __error_404__(req, res); } catch(e) { console.error('[_404 error]', e); }` : ` res.statusCode = 404;
154
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
155
+ res.end('Not Found');`;
156
+ const clientErrorHandler = errorAdapters.adapter500 ? ` try {
157
+ const eq = new URLSearchParams();
158
+ eq.set('__errorMessage', url.searchParams.get('__clientError') || 'Client error');
159
+ const stack = url.searchParams.get('__clientStack');
160
+ if (stack) eq.set('__errorStack', stack);
161
+ req.url = '/_500?' + eq.toString();
162
+ return await __error_500__(req, res);
163
+ } catch(e) { console.error('[_500 client error]', e); }` : ` res.statusCode = 500;
164
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
165
+ res.end('Internal Server Error');`;
166
+ const errorHandler = errorAdapters.adapter500 ? ` try {
167
+ const errMsg = err instanceof Error ? err.message : String(err);
168
+ const errStack = err instanceof Error ? err.stack : undefined;
169
+ const errStatus = err?.status ?? err?.statusCode;
170
+ const eq = new URLSearchParams();
171
+ eq.set('__errorMessage', errMsg);
172
+ if (errStack) eq.set('__errorStack', errStack);
173
+ if (errStatus) eq.set('__errorStatus', String(errStatus));
174
+ req.url = '/_500?' + eq.toString();
175
+ return await __error_500__(req, res);
176
+ } catch(e) { console.error('[_500 error]', e); }` : ` res.statusCode = 500;
177
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
178
+ res.end('Internal Server Error');`;
151
179
  return `import type { IncomingMessage, ServerResponse } from 'http';
152
180
  ${imports}
181
+ ${error404Import}
182
+ ${error500Import}
153
183
 
154
184
  const ROUTES: Array<{
155
185
  regex: string;
@@ -164,6 +194,11 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
164
194
  const url = new URL(req.url || '/', 'http://localhost');
165
195
  const pathname = url.pathname;
166
196
 
197
+ // Client-side error \u2014 navigate directly to _500 handler.
198
+ if (url.searchParams.has('__clientError')) {
199
+ ${clientErrorHandler}
200
+ }
201
+
167
202
  for (const route of ROUTES) {
168
203
  const m = pathname.match(new RegExp(route.regex));
169
204
  if (!m) continue;
@@ -172,7 +207,6 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
172
207
  route.params.forEach((name, i) => {
173
208
  const raw = m[i + 1] ?? '';
174
209
  if (catchAllSet.has(name)) {
175
- // Encode catch-all as repeated keys so the handler can getAll() \u2192 string[]
176
210
  raw.split('/').filter(Boolean).forEach(seg => url.searchParams.append(name, seg));
177
211
  } else {
178
212
  url.searchParams.set(name, raw);
@@ -180,12 +214,16 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
180
214
  });
181
215
  req.url = pathname + (url.search || '');
182
216
 
183
- return route.handler(req, res);
217
+ try {
218
+ return await route.handler(req, res);
219
+ } catch (err) {
220
+ console.error('[handler error]', err);
221
+ ${errorHandler}
222
+ return;
223
+ }
184
224
  }
185
225
 
186
- res.statusCode = 404;
187
- res.setHeader('Content-Type', 'text/plain; charset=utf-8');
188
- res.end('Not Found');
226
+ ${notFoundHandler}
189
227
  }
190
228
  `;
191
229
  }
@@ -216,7 +254,8 @@ if (apiRoutes.length > 0) {
216
254
  vercelRoutes.push({ src: srcRegex, dest: "/api" });
217
255
  }
218
256
  const serverPages = collectServerPages(PAGES_DIR);
219
- if (serverPages.length > 0) {
257
+ const hasErrorPages = ["_404.tsx", "_500.tsx"].some((f) => fs.existsSync(path.join(PAGES_DIR, f)));
258
+ if (serverPages.length > 0 || hasErrorPages) {
220
259
  const globalRegistry = collectGlobalClientRegistry(serverPages, PAGES_DIR);
221
260
  const prerenderedHtml = await bundleClientComponents(globalRegistry, PAGES_DIR, STATIC_DIR);
222
261
  const prerenderedRecord = Object.fromEntries(prerenderedHtml);
@@ -252,8 +291,36 @@ if (serverPages.length > 0) {
252
291
  paramNames: page.paramNames,
253
292
  catchAllNames: page.catchAllNames
254
293
  }));
294
+ const errorAdapters = {};
295
+ const errorAdapterPaths = [];
296
+ for (const [statusCode, key] of [[404, "adapter404"], [500, "adapter500"]]) {
297
+ const src = path.join(PAGES_DIR, `_${statusCode}.tsx`);
298
+ if (!fs.existsSync(src)) continue;
299
+ console.log(` building _${statusCode}.tsx \u2192 pages.func [error page]`);
300
+ const adapterDir = path.dirname(src);
301
+ const adapterPath = path.join(adapterDir, `_error_adapter_${randomBytes(4).toString("hex")}.ts`);
302
+ const layoutPaths = findPageLayouts(src, PAGES_DIR);
303
+ const { registry, clientComponentNames } = buildPerPageRegistry(src, layoutPaths, PAGES_DIR);
304
+ const layoutImports = layoutPaths.map((lp, i) => {
305
+ const rel = path.relative(adapterDir, lp).replace(/\\/g, "/");
306
+ return `import __layout_${i}__ from ${JSON.stringify(rel.startsWith(".") ? rel : "./" + rel)};`;
307
+ }).join("\n");
308
+ fs.writeFileSync(adapterPath, makePageAdapterSource({
309
+ pageImport: JSON.stringify("./" + path.basename(src)),
310
+ layoutImports,
311
+ clientComponentNames,
312
+ allClientIds: [...registry.keys()],
313
+ layoutArrayItems: layoutPaths.map((_, i) => `__layout_${i}__`).join(", "),
314
+ prerenderedHtml: prerenderedRecord,
315
+ routeParamNames: [],
316
+ catchAllNames: [],
317
+ statusCode
318
+ }));
319
+ errorAdapters[key] = adapterPath;
320
+ errorAdapterPaths.push(adapterPath);
321
+ }
255
322
  const dispatcherPath = path.join(PAGES_DIR, `_pages_dispatcher_${randomBytes(4).toString("hex")}.ts`);
256
- fs.writeFileSync(dispatcherPath, makePagesDispatcherSource(dispatcherRoutes));
323
+ fs.writeFileSync(dispatcherPath, makePagesDispatcherSource(dispatcherRoutes, errorAdapters));
257
324
  try {
258
325
  const result = await build({
259
326
  entryPoints: [dispatcherPath],
@@ -272,6 +339,7 @@ if (serverPages.length > 0) {
272
339
  } finally {
273
340
  fs.unlinkSync(dispatcherPath);
274
341
  for (const p of tempAdapterPaths) if (fs.existsSync(p)) fs.unlinkSync(p);
342
+ for (const p of errorAdapterPaths) if (fs.existsSync(p)) fs.unlinkSync(p);
275
343
  }
276
344
  for (const { srcRegex } of serverPages)
277
345
  vercelRoutes.push({ src: srcRegex, dest: "/pages" });
@@ -289,4 +357,3 @@ copyPublicFiles(PUBLIC_DIR, STATIC_DIR);
289
357
  const fnCount = (apiRoutes.length > 0 ? 1 : 0) + (serverPages.length > 0 ? 1 : 0);
290
358
  console.log(`
291
359
  \u2713 Vercel build complete \u2014 ${fnCount} function(s) \u2192 .vercel/output`);
292
- //# sourceMappingURL=build-vercel.js.map
package/dist/builder.js CHANGED
@@ -39,6 +39,35 @@ function processDist(dir) {
39
39
  })(dir);
40
40
  console.log("\u{1F527} Post-processing done: relative imports \u2192 .js extensions.");
41
41
  }
42
+ const PUBLIC_STEMS = /* @__PURE__ */ new Set([
43
+ "index",
44
+ "html-store",
45
+ // exported types (TitleValue, LinkTag, MetaTag, …) live here
46
+ "use-html",
47
+ "use-router",
48
+ "use-request",
49
+ "request-store",
50
+ "Link",
51
+ "bundle",
52
+ "utils",
53
+ "logger"
54
+ ]);
55
+ function prunePrivateDeclarations(dir) {
56
+ (function walk(currentDir) {
57
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
58
+ const fullPath = path.join(currentDir, entry.name);
59
+ if (entry.isDirectory()) {
60
+ walk(fullPath);
61
+ } else if (entry.name.endsWith(".d.ts") || entry.name.endsWith(".d.ts.map")) {
62
+ const stem = entry.name.replace(/\.d\.ts(\.map)?$/, "");
63
+ if (!PUBLIC_STEMS.has(stem)) {
64
+ fs.rmSync(fullPath);
65
+ }
66
+ }
67
+ }
68
+ })(dir);
69
+ console.log("\u2702\uFE0F Pruned private .d.ts files (kept public API only).");
70
+ }
42
71
  async function runBuild() {
43
72
  try {
44
73
  cleanDist(outDir);
@@ -49,13 +78,13 @@ async function runBuild() {
49
78
  platform: "node",
50
79
  format: "esm",
51
80
  target: ["node20"],
52
- packages: "external",
53
- sourcemap: true
81
+ packages: "external"
54
82
  });
55
83
  console.log("\u2705 Build done.");
56
84
  processDist(outDir);
57
85
  console.log("\u{1F4C4} Generating TypeScript declarations\u2026");
58
- execSync("tsc --emitDeclarationOnly --declaration --outDir dist", { stdio: "inherit" });
86
+ execSync("tsc --emitDeclarationOnly --declaration --declarationMap false --outDir dist", { stdio: "inherit" });
87
+ prunePrivateDeclarations(outDir);
59
88
  console.log("\n\u{1F389} Build complete \u2192 dist/");
60
89
  } catch (err) {
61
90
  console.error("\u274C Build failed:", err);
@@ -63,4 +92,3 @@ async function runBuild() {
63
92
  }
64
93
  }
65
94
  runBuild();
66
- //# sourceMappingURL=builder.js.map