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.
- package/README.md +283 -1
- package/dist/Link.d.ts +8 -2
- package/dist/Link.js +2 -3
- package/dist/app.js +1 -2
- package/dist/build-common.js +141 -24
- package/dist/build-node.js +67 -5
- package/dist/build-vercel.js +81 -9
- package/dist/builder.js +30 -4
- package/dist/bundle.d.ts +7 -0
- package/dist/bundle.js +47 -4
- package/dist/bundler.js +0 -1
- package/dist/component-analyzer.js +0 -1
- package/dist/config.js +0 -1
- package/dist/hmr-bundle.js +0 -1
- package/dist/hmr.js +4 -1
- package/dist/html-store.js +0 -1
- package/dist/http-server.js +0 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +5 -1
- package/dist/logger.js +0 -1
- package/dist/metadata.js +0 -1
- package/dist/middleware-loader.js +0 -1
- package/dist/middleware.example.js +0 -1
- package/dist/middleware.js +0 -1
- package/dist/renderer.js +3 -9
- package/dist/request-store.d.ts +84 -0
- package/dist/request-store.js +46 -0
- package/dist/router.js +0 -1
- package/dist/ssr.js +91 -19
- package/dist/use-html.js +0 -1
- package/dist/use-request.d.ts +74 -0
- package/dist/use-request.js +48 -0
- package/dist/use-router.js +0 -1
- package/dist/utils.js +0 -1
- package/package.json +1 -1
- package/dist/Link.js.map +0 -7
- package/dist/app.d.ts +0 -19
- package/dist/app.js.map +0 -7
- package/dist/build-common.d.ts +0 -172
- package/dist/build-common.js.map +0 -7
- package/dist/build-node.d.ts +0 -15
- package/dist/build-node.js.map +0 -7
- package/dist/build-vercel.d.ts +0 -19
- package/dist/build-vercel.js.map +0 -7
- package/dist/builder.d.ts +0 -11
- package/dist/builder.js.map +0 -7
- package/dist/bundle.js.map +0 -7
- package/dist/bundler.d.ts +0 -58
- package/dist/bundler.js.map +0 -7
- package/dist/component-analyzer.d.ts +0 -75
- package/dist/component-analyzer.js.map +0 -7
- package/dist/config.d.ts +0 -35
- package/dist/config.js.map +0 -7
- package/dist/hmr-bundle.d.ts +0 -25
- package/dist/hmr-bundle.js.map +0 -7
- package/dist/hmr.d.ts +0 -55
- package/dist/hmr.js.map +0 -7
- package/dist/html-store.d.ts +0 -128
- package/dist/html-store.js.map +0 -7
- package/dist/http-server.d.ts +0 -92
- package/dist/http-server.js.map +0 -7
- package/dist/index.js.map +0 -7
- package/dist/logger.js.map +0 -7
- package/dist/metadata.d.ts +0 -50
- package/dist/metadata.js.map +0 -7
- package/dist/middleware-loader.d.ts +0 -50
- package/dist/middleware-loader.js.map +0 -7
- package/dist/middleware.d.ts +0 -22
- package/dist/middleware.example.d.ts +0 -8
- package/dist/middleware.example.js.map +0 -7
- package/dist/middleware.js.map +0 -7
- package/dist/renderer.d.ts +0 -44
- package/dist/renderer.js.map +0 -7
- package/dist/router.d.ts +0 -89
- package/dist/router.js.map +0 -7
- package/dist/ssr.d.ts +0 -45
- package/dist/ssr.js.map +0 -7
- package/dist/use-html.js.map +0 -7
- package/dist/use-router.js.map +0 -7
- 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
|
-
|
|
1
|
+
interface LinkProps {
|
|
2
2
|
href: string;
|
|
3
3
|
children: React.ReactNode;
|
|
4
4
|
className?: string;
|
|
5
|
-
}
|
|
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
|
|
5
|
+
const { push } = useRouter();
|
|
6
6
|
const handleClick = (e) => {
|
|
7
7
|
e.preventDefault();
|
|
8
|
-
|
|
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
|
package/dist/build-common.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|