nukejs 0.0.10 → 0.0.12

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 (80) hide show
  1. package/README.md +283 -1
  2. package/dist/Link.d.ts +8 -2
  3. package/dist/Link.js +2 -3
  4. package/dist/app.js +1 -2
  5. package/dist/build-common.js +141 -24
  6. package/dist/build-node.js +67 -5
  7. package/dist/build-vercel.js +81 -9
  8. package/dist/builder.js +30 -4
  9. package/dist/bundle.d.ts +7 -0
  10. package/dist/bundle.js +47 -4
  11. package/dist/bundler.js +0 -1
  12. package/dist/component-analyzer.js +0 -1
  13. package/dist/config.js +0 -1
  14. package/dist/hmr-bundle.js +0 -1
  15. package/dist/hmr.js +4 -1
  16. package/dist/html-store.js +0 -1
  17. package/dist/http-server.js +0 -1
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.js +5 -1
  20. package/dist/logger.js +0 -1
  21. package/dist/metadata.js +0 -1
  22. package/dist/middleware-loader.js +0 -1
  23. package/dist/middleware.example.js +0 -1
  24. package/dist/middleware.js +0 -1
  25. package/dist/renderer.js +3 -9
  26. package/dist/request-store.d.ts +84 -0
  27. package/dist/request-store.js +46 -0
  28. package/dist/router.js +0 -1
  29. package/dist/ssr.js +91 -19
  30. package/dist/use-html.js +0 -1
  31. package/dist/use-request.d.ts +74 -0
  32. package/dist/use-request.js +48 -0
  33. package/dist/use-router.js +0 -1
  34. package/dist/utils.js +0 -1
  35. package/package.json +1 -1
  36. package/dist/Link.js.map +0 -7
  37. package/dist/app.d.ts +0 -19
  38. package/dist/app.js.map +0 -7
  39. package/dist/build-common.d.ts +0 -172
  40. package/dist/build-common.js.map +0 -7
  41. package/dist/build-node.d.ts +0 -15
  42. package/dist/build-node.js.map +0 -7
  43. package/dist/build-vercel.d.ts +0 -19
  44. package/dist/build-vercel.js.map +0 -7
  45. package/dist/builder.d.ts +0 -11
  46. package/dist/builder.js.map +0 -7
  47. package/dist/bundle.js.map +0 -7
  48. package/dist/bundler.d.ts +0 -58
  49. package/dist/bundler.js.map +0 -7
  50. package/dist/component-analyzer.d.ts +0 -75
  51. package/dist/component-analyzer.js.map +0 -7
  52. package/dist/config.d.ts +0 -35
  53. package/dist/config.js.map +0 -7
  54. package/dist/hmr-bundle.d.ts +0 -25
  55. package/dist/hmr-bundle.js.map +0 -7
  56. package/dist/hmr.d.ts +0 -55
  57. package/dist/hmr.js.map +0 -7
  58. package/dist/html-store.d.ts +0 -128
  59. package/dist/html-store.js.map +0 -7
  60. package/dist/http-server.d.ts +0 -92
  61. package/dist/http-server.js.map +0 -7
  62. package/dist/index.js.map +0 -7
  63. package/dist/logger.js.map +0 -7
  64. package/dist/metadata.d.ts +0 -50
  65. package/dist/metadata.js.map +0 -7
  66. package/dist/middleware-loader.d.ts +0 -50
  67. package/dist/middleware-loader.js.map +0 -7
  68. package/dist/middleware.d.ts +0 -22
  69. package/dist/middleware.example.d.ts +0 -8
  70. package/dist/middleware.example.js.map +0 -7
  71. package/dist/middleware.js.map +0 -7
  72. package/dist/renderer.d.ts +0 -44
  73. package/dist/renderer.js.map +0 -7
  74. package/dist/router.d.ts +0 -89
  75. package/dist/router.js.map +0 -7
  76. package/dist/ssr.d.ts +0 -45
  77. package/dist/ssr.js.map +0 -7
  78. package/dist/use-html.js.map +0 -7
  79. package/dist/use-router.js.map +0 -7
  80. package/dist/utils.js.map +0 -7
package/README.md CHANGED
@@ -23,6 +23,8 @@ npm create nuke@latest
23
23
  - [useHtml() — Head Management](#usehtml--head-management)
24
24
  - [Configuration](#configuration)
25
25
  - [Link Component & Navigation](#link-component--navigation)
26
+ - [useRequest() — URL Params, Query & Headers](#userequest--url-params-query--headers)
27
+ - [Error Pages](#error-pages)
26
28
  - [Building & Deploying](#building--deploying)
27
29
 
28
30
  ## Overview
@@ -81,7 +83,7 @@ export default function LikeButton({ postId }: { postId: string }) {
81
83
  ### Installation
82
84
 
83
85
  ```bash
84
- npm create nuke
86
+ npm create nuke@latest
85
87
  ```
86
88
 
87
89
  ### Running the dev server
@@ -579,6 +581,286 @@ export default function SearchForm() {
579
581
 
580
582
  ---
581
583
 
584
+ ## useRequest() — URL Params, Query & Headers
585
+
586
+ `useRequest()` is a universal hook that exposes the current request's URL parameters, query string, and headers to any component — **server or client, dev or production**.
587
+
588
+ ```tsx
589
+ import { useRequest } from 'nukejs';
590
+
591
+ const { params, query, headers, pathname, url } = useRequest();
592
+ ```
593
+
594
+ | Field | Type | Description |
595
+ |---|---|---|
596
+ | `url` | `string` | Full URL with query string, e.g. `/blog/hello?lang=en` |
597
+ | `pathname` | `string` | Path only, e.g. `/blog/hello` |
598
+ | `params` | `Record<string, string \| string[]>` | Dynamic route segments |
599
+ | `query` | `Record<string, string \| string[]>` | Query-string params (multi-value keys become arrays) |
600
+ | `headers` | `Record<string, string>` | Request headers |
601
+
602
+ ### Where data comes from
603
+
604
+ | Environment | Source |
605
+ |---|---|
606
+ | Server (SSR) | Live `IncomingMessage` — all headers including `cookie` |
607
+ | Client (browser) | `__n_data` blob embedded in the page + `window.location` (reactive) |
608
+
609
+ On the client the hook is **reactive**: it re-reads on every SPA navigation so `query`, `pathname`, and `params` stay current without a page reload.
610
+
611
+ > **Security:** `headers` on the client never contains `cookie`, `authorization`, `proxy-authorization`, `set-cookie`, or `x-api-key`. These are stripped before embedding in the HTML document so credentials cannot leak into cached or logged pages.
612
+
613
+ ### Reading route params and query string
614
+
615
+ ```tsx
616
+ // app/pages/blog/[slug].tsx
617
+ // URL: /blog/hello-world?tab=comments
618
+ import { useRequest } from 'nukejs';
619
+
620
+ export default function BlogPost() {
621
+ const { params, query } = useRequest();
622
+ const slug = params.slug as string;
623
+ const tab = (query.tab as string) ?? 'overview';
624
+
625
+ return (
626
+ <article>
627
+ <h1>{slug}</h1>
628
+ <p>Active tab: {tab}</p>
629
+ </article>
630
+ );
631
+ }
632
+ ```
633
+
634
+ ### Catch-all routes
635
+
636
+ ```tsx
637
+ // app/pages/docs/[...path].tsx
638
+ // URL: /docs/api/hooks → path = ['api', 'hooks']
639
+ import { useRequest } from 'nukejs';
640
+
641
+ export default function Docs() {
642
+ const { params } = useRequest();
643
+ const segments = params.path as string[];
644
+
645
+ return <nav>{segments.join(' › ')}</nav>;
646
+ }
647
+ ```
648
+
649
+ ### Reading headers in a server component
650
+
651
+ ```tsx
652
+ // app/pages/dashboard.tsx
653
+ import { useRequest } from 'nukejs';
654
+
655
+ export default async function Dashboard() {
656
+ const { headers } = useRequest();
657
+
658
+ // Forward the session cookie to an internal API call
659
+ const data = await fetch('http://localhost:3000/api/me', {
660
+ headers: { cookie: headers['cookie'] ?? '' },
661
+ }).then(r => r.json());
662
+
663
+ return <main>{data.name}</main>;
664
+ }
665
+ ```
666
+
667
+ ### Building `useI18n` on top
668
+
669
+ `useRequest` is designed as a primitive for higher-level hooks. Here is a complete `useI18n` implementation that works in both server and client components:
670
+
671
+ ```tsx
672
+ // app/hooks/useI18n.ts
673
+ import { useRequest } from 'nukejs';
674
+
675
+ const translations = {
676
+ en: { welcome: 'Welcome', signIn: 'Sign in' },
677
+ fr: { welcome: 'Bienvenue', signIn: 'Se connecter' },
678
+ de: { welcome: 'Willkommen', signIn: 'Anmelden' },
679
+ } as const;
680
+ type Locale = keyof typeof translations;
681
+
682
+ function detectLocale(
683
+ query: Record<string, string | string[]>,
684
+ acceptLanguage = '',
685
+ ): Locale {
686
+ // ?lang=fr in the URL takes priority over the browser header
687
+ const fromQuery = query.lang as string | undefined;
688
+ if (fromQuery && fromQuery in translations) return fromQuery as Locale;
689
+
690
+ const fromHeader = acceptLanguage
691
+ .split(',')[0]?.split('-')[0]?.trim().toLowerCase();
692
+ if (fromHeader && fromHeader in translations) return fromHeader as Locale;
693
+
694
+ return 'en';
695
+ }
696
+
697
+ export function useI18n() {
698
+ const { query, headers } = useRequest();
699
+ const locale = detectLocale(query, headers['accept-language']);
700
+ return { t: translations[locale], locale };
701
+ }
702
+ ```
703
+
704
+ ```tsx
705
+ // app/pages/index.tsx
706
+ import { useI18n } from '../hooks/useI18n';
707
+
708
+ export default function Home() {
709
+ const { t } = useI18n();
710
+ return <h1>{t.welcome}</h1>;
711
+ }
712
+ ```
713
+
714
+ Changing `?lang=fr` in the URL re-renders client components automatically.
715
+
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
+
582
864
  ## Building & Deploying
583
865
 
584
866
  ### Node.js server
package/dist/Link.d.ts CHANGED
@@ -1,6 +1,12 @@
1
- declare const Link: ({ href, children, className }: {
1
+ interface LinkProps {
2
2
  href: string;
3
3
  children: React.ReactNode;
4
4
  className?: string;
5
- }) => import("react/jsx-runtime").JSX.Element;
5
+ }
6
+ /**
7
+ * Client-side navigation link.
8
+ * Intercepts clicks and delegates to useRouter().push() so the SPA router
9
+ * handles the transition without a full page reload.
10
+ */
11
+ declare const Link: ({ href, children, className }: LinkProps) => import("react/jsx-runtime").JSX.Element;
6
12
  export default Link;
package/dist/Link.js CHANGED
@@ -2,10 +2,10 @@
2
2
  import { jsx } from "react/jsx-runtime";
3
3
  import useRouter from "./use-router.js";
4
4
  const Link = ({ href, children, className }) => {
5
- const r = useRouter();
5
+ const { push } = useRouter();
6
6
  const handleClick = (e) => {
7
7
  e.preventDefault();
8
- r.push(href);
8
+ push(href);
9
9
  };
10
10
  return /* @__PURE__ */ jsx("a", { href, onClick: handleClick, className, children });
11
11
  };
@@ -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
@@ -68,7 +68,7 @@ const server = http.createServer(async (req, res) => {
68
68
  );
69
69
  if (matchApiPrefix(url, apiPrefixes))
70
70
  return await handleApiRoute(url, req, res);
71
- return await serverSideRender(url, res, PAGES_DIR, isDev);
71
+ return await serverSideRender(url, res, PAGES_DIR, isDev, req);
72
72
  } catch (error) {
73
73
  log.error("Server error:", error);
74
74
  res.statusCode = 500;
@@ -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);
@@ -200,11 +213,13 @@ async function buildPages(pagesDir, staticDir) {
200
213
  allClientIds: [...registry.keys()],
201
214
  layoutPaths,
202
215
  prerenderedHtml: prerenderedRecord,
216
+ routeParamNames: page.paramNames,
203
217
  catchAllNames: page.catchAllNames
204
218
  });
205
219
  builtPages.push({ ...page, bundleText });
206
220
  }
207
- return builtPages;
221
+ const errorResult = outPagesDir ? await buildErrorPages(pagesDir, outPagesDir, prerenderedRecord) : { has404: false, has500: false };
222
+ return { pages: builtPages, ...errorResult };
208
223
  }
209
224
  function makeApiAdapterSource(handlerFilename) {
210
225
  return `import type { IncomingMessage, ServerResponse } from 'http';
@@ -240,8 +255,11 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
240
255
  const apiReq = req as any;
241
256
 
242
257
  apiReq.body = await parseBody(req);
243
- apiReq.query = Object.fromEntries(new URL(req.url || '/', 'http://localhost').searchParams);
244
- apiReq.params = apiReq.query;
258
+ // In production, route dynamic segments are injected as query-string keys by
259
+ // the server entry, so params and query share the same parsed URL values.
260
+ const qs = Object.fromEntries(new URL(req.url || '/', 'http://localhost').searchParams);
261
+ apiReq.query = qs;
262
+ apiReq.params = qs;
245
263
 
246
264
  const fn = (mod as any)[method] ?? (mod as any).default;
247
265
  if (typeof fn !== 'function') {
@@ -260,7 +278,9 @@ function makePageAdapterSource(opts) {
260
278
  allClientIds,
261
279
  layoutArrayItems,
262
280
  prerenderedHtml,
263
- catchAllNames
281
+ routeParamNames,
282
+ catchAllNames,
283
+ statusCode = 200
264
284
  } = opts;
265
285
  return `import type { IncomingMessage, ServerResponse } from 'http';
266
286
  import { createElement as __createElement__ } from 'react';
@@ -271,7 +291,10 @@ ${layoutImports}
271
291
  const CLIENT_COMPONENTS: Record<string, string> = ${JSON.stringify(clientComponentNames)};
272
292
  const ALL_CLIENT_IDS: string[] = ${JSON.stringify(allClientIds)};
273
293
  const PRERENDERED_HTML: Record<string, string> = ${JSON.stringify(prerenderedHtml)};
274
- const CATCH_ALL_NAMES = new Set(${JSON.stringify(catchAllNames)});
294
+ // ROUTE_PARAM_NAMES: the dynamic segments baked into this page's URL pattern.
295
+ // Used to separate them from real user-supplied query params at runtime.
296
+ const ROUTE_PARAM_NAMES = new Set<string>(${JSON.stringify(routeParamNames)});
297
+ const CATCH_ALL_NAMES = new Set<string>(${JSON.stringify(catchAllNames)});
275
298
 
276
299
  // \u2500\u2500\u2500 html-store (inlined) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
277
300
  type TitleValue = string | ((prev: string) => string);
@@ -302,6 +325,36 @@ function resolveTitle(ops: TitleValue[], fallback = ''): string {
302
325
  return t;
303
326
  }
304
327
 
328
+ // \u2500\u2500\u2500 request-store (inlined) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
329
+ const SENSITIVE_HEADERS = new Set([
330
+ 'cookie','authorization','proxy-authorization','set-cookie','x-api-key',
331
+ ]);
332
+ // Flattens multi-value headers to strings; keeps all headers including credentials.
333
+ function normaliseHeaders(raw: Record<string, string | string[] | undefined>): Record<string, string> {
334
+ const out: Record<string, string> = {};
335
+ for (const [k, v] of Object.entries(raw)) {
336
+ if (v === undefined) continue;
337
+ out[k] = Array.isArray(v) ? v.join(', ') : v;
338
+ }
339
+ return out;
340
+ }
341
+ // Same as normaliseHeaders but strips credentials before embedding in HTML.
342
+ function sanitiseHeaders(raw: Record<string, string | string[] | undefined>): Record<string, string> {
343
+ const out: Record<string, string> = {};
344
+ for (const [k, v] of Object.entries(raw)) {
345
+ if (SENSITIVE_HEADERS.has(k.toLowerCase()) || v === undefined) continue;
346
+ out[k] = Array.isArray(v) ? v.join(', ') : v;
347
+ }
348
+ return out;
349
+ }
350
+ const __REQ_KEY__ = Symbol.for('__nukejs_request_store__');
351
+ const __getReq = () => (globalThis as any)[__REQ_KEY__] ?? null;
352
+ const __setReq = (v: any) => { (globalThis as any)[__REQ_KEY__] = v; };
353
+ async function runWithRequestStore<T>(ctx: any, fn: () => Promise<T>): Promise<T> {
354
+ __setReq(ctx);
355
+ try { return await fn(); } finally { __setReq(null); }
356
+ }
357
+
305
358
  // \u2500\u2500\u2500 HTML helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
306
359
  function escapeHtml(s: string): string {
307
360
  return String(s)
@@ -387,8 +440,8 @@ function buildWrapperAttrString(attrs: Record<string, any>): string {
387
440
  }
388
441
 
389
442
  function serializeProps(value: any): any {
443
+ if (typeof value === 'function') return undefined; // must come before the object check
390
444
  if (value == null || typeof value !== 'object') return value;
391
- if (typeof value === 'function') return undefined;
392
445
  if (Array.isArray(value)) return value.map(serializeProps).filter((v: any) => v !== undefined);
393
446
  if ((value as any).$$typeof) {
394
447
  const { type, props: p } = value as any;
@@ -478,19 +531,53 @@ function wrapWithLayouts(element: any): any {
478
531
  export default async function handler(req: IncomingMessage, res: ServerResponse): Promise<void> {
479
532
  try {
480
533
  const parsed = new URL(req.url || '/', 'http://localhost');
534
+ const url = req.url || '/';
535
+ const pathname = parsed.pathname;
536
+
537
+ // Route params are injected as query-string keys by the server entry.
538
+ // Build 'params' only from known route segments, and 'query' from the rest.
481
539
  const params: Record<string, string | string[]> = {};
540
+ ROUTE_PARAM_NAMES.forEach(k => {
541
+ if (CATCH_ALL_NAMES.has(k)) {
542
+ params[k] = parsed.searchParams.getAll(k);
543
+ } else {
544
+ const v = parsed.searchParams.get(k);
545
+ if (v !== null) params[k] = v;
546
+ }
547
+ });
548
+
549
+ const query: Record<string, string | string[]> = {};
482
550
  parsed.searchParams.forEach((_, k) => {
483
- params[k] = CATCH_ALL_NAMES.has(k)
484
- ? parsed.searchParams.getAll(k)
485
- : parsed.searchParams.get(k) as string;
551
+ if (!ROUTE_PARAM_NAMES.has(k)) {
552
+ const all = parsed.searchParams.getAll(k);
553
+ query[k] = all.length > 1 ? all : all[0];
554
+ }
486
555
  });
487
- const url = req.url || '/';
556
+
557
+ const rawHeaders = req.headers as Record<string, string | string[] | undefined>;
558
+ // Full headers (including credentials) for server components via the request store.
559
+ const normHeaders = normaliseHeaders(rawHeaders);
560
+ // Stripped headers safe for embedding in the HTML document.
561
+ const safeHeaders = sanitiseHeaders(rawHeaders);
488
562
 
489
563
  const hydrated = new Set<string>();
490
- const wrapped = wrapWithLayouts({ type: __page__.default, props: params as any, key: null, ref: null });
564
+ // Merge query params into page props to match dev behaviour (ssr.ts mergedParams).
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;
574
+ const wrapped = wrapWithLayouts({ type: __page__.default, props: merged, key: null, ref: null });
491
575
 
492
576
  let appHtml = '';
493
- const store = await runWithHtmlStore(async () => { appHtml = await renderNode(wrapped, hydrated); });
577
+ const store = await runWithRequestStore(
578
+ { url, pathname, params, query, headers: normHeaders },
579
+ () => runWithHtmlStore(async () => { appHtml = await renderNode(wrapped, hydrated); }),
580
+ );
494
581
 
495
582
  const pageTitle = resolveTitle(store.titleOps, 'NukeJS');
496
583
  const headScripts = store.script.filter((s: any) => (s.position ?? 'head') === 'head');
@@ -514,7 +601,8 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
514
601
  const bodyScriptsHtml = bodyScriptLines.length ? '\\n' + bodyScriptLines.join('\\n') + '\\n' : '';
515
602
 
516
603
  const runtimeData = JSON.stringify({
517
- hydrateIds: [...hydrated], allIds: ALL_CLIENT_IDS, url, params, debug: 'silent',
604
+ hydrateIds: [...hydrated], allIds: ALL_CLIENT_IDS, url, params,
605
+ query, headers: safeHeaders, debug: 'silent',
518
606
  }).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
519
607
 
520
608
  const html = \`<!DOCTYPE html>
@@ -546,14 +634,13 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
546
634
  \${bodyScriptsHtml}</body>
547
635
  </html>\`;
548
636
 
549
- res.statusCode = 200;
637
+ res.statusCode = ${statusCode};
550
638
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
551
639
  res.end(html);
552
640
  } catch (err: any) {
553
- console.error('[page render error]', err);
554
- res.statusCode = 500;
555
- res.setHeader('Content-Type', 'text/plain; charset=utf-8');
556
- 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;
557
644
  }
558
645
  }
559
646
  `;
@@ -586,7 +673,9 @@ async function bundlePageHandler(opts) {
586
673
  allClientIds,
587
674
  layoutPaths,
588
675
  prerenderedHtml,
589
- catchAllNames
676
+ routeParamNames,
677
+ catchAllNames,
678
+ statusCode
590
679
  } = opts;
591
680
  const adapterDir = path.dirname(absPath);
592
681
  const adapterPath = path.join(adapterDir, `_page_adapter_${randomBytes(4).toString("hex")}.ts`);
@@ -601,7 +690,9 @@ async function bundlePageHandler(opts) {
601
690
  allClientIds,
602
691
  layoutArrayItems: layoutPaths.map((_, i) => `__layout_${i}__`).join(", "),
603
692
  prerenderedHtml,
604
- catchAllNames
693
+ routeParamNames,
694
+ catchAllNames,
695
+ statusCode
605
696
  }));
606
697
  let text;
607
698
  try {
@@ -673,6 +764,32 @@ async function bundleClientComponents(globalRegistry, pagesDir, staticDir) {
673
764
  console.log(` bundled ${globalRegistry.size} client component(s) \u2192 ${path.relative(process.cwd(), outDir)}/`);
674
765
  return prerendered;
675
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
+ }
676
793
  async function buildCombinedBundle(staticDir) {
677
794
  const nukeDir = path.dirname(fileURLToPath(import.meta.url));
678
795
  const bundleFile = nukeDir.endsWith("dist") ? "bundle" : "bundle.ts";
@@ -739,6 +856,7 @@ function copyPublicFiles(publicDir, destDir) {
739
856
  export {
740
857
  analyzeFile,
741
858
  buildCombinedBundle,
859
+ buildErrorPages,
742
860
  buildPages,
743
861
  buildPerPageRegistry,
744
862
  bundleApiHandler,
@@ -754,4 +872,3 @@ export {
754
872
  makePageAdapterSource,
755
873
  walkFiles
756
874
  };
757
- //# sourceMappingURL=build-common.js.map