nukejs 0.0.30 → 0.0.32
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 +57 -0
- package/dist/builder.js +1 -0
- package/dist/bundle.js +11 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -2
- package/dist/render-component.js +6 -15
- package/dist/render-document.js +165 -0
- package/dist/renderer.js +3 -5
- package/dist/server.d.ts +13 -0
- package/dist/server.js +4 -0
- package/dist/ssr.js +9 -164
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ npm create nuke@latest
|
|
|
28
28
|
- [useRequest() — URL Params, Query & Headers](#userequest--url-params-query--headers)
|
|
29
29
|
- [cache() — Request-Scoped Data Caching](#cache--request-scoped-data-caching)
|
|
30
30
|
- [Error Pages](#error-pages)
|
|
31
|
+
- [renderComponent() — SSR Outside the Page Router](#rendercomponent--ssr-outside-the-page-router)
|
|
31
32
|
- [Building & Deploying](#building--deploying)
|
|
32
33
|
|
|
33
34
|
## Overview
|
|
@@ -1378,6 +1379,62 @@ The `_500.tsx` page receives `errorMessage` and `errorStack` props from client e
|
|
|
1378
1379
|
|
|
1379
1380
|
---
|
|
1380
1381
|
|
|
1382
|
+
## renderComponent() — SSR Outside the Page Router
|
|
1383
|
+
|
|
1384
|
+
`renderComponent()` renders any React component to a full HTML document with the same SSR pipeline pages use — but without requiring a file in `app/pages/`. Use it for DB-driven routes, CMS content, webhook-rendered HTML, server-rendered emails, or any endpoint that needs real React SSR.
|
|
1385
|
+
|
|
1386
|
+
```ts
|
|
1387
|
+
import { renderComponent } from 'nukejs/server';
|
|
1388
|
+
import Widget from './components/Widget';
|
|
1389
|
+
import RootLayout from './app/pages/layout';
|
|
1390
|
+
|
|
1391
|
+
export async function GET(req, res) {
|
|
1392
|
+
const html = await renderComponent(Widget, { title: 'Hello' }, {
|
|
1393
|
+
layouts: [RootLayout],
|
|
1394
|
+
url: req.url,
|
|
1395
|
+
title: 'My Widget Page',
|
|
1396
|
+
});
|
|
1397
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1398
|
+
res.end(html);
|
|
1399
|
+
}
|
|
1400
|
+
```
|
|
1401
|
+
|
|
1402
|
+
### API
|
|
1403
|
+
|
|
1404
|
+
```ts
|
|
1405
|
+
renderComponent(
|
|
1406
|
+
Component: ComponentType<any>,
|
|
1407
|
+
props?: Record<string, unknown>,
|
|
1408
|
+
options?: RenderComponentOptions,
|
|
1409
|
+
): Promise<string>
|
|
1410
|
+
```
|
|
1411
|
+
|
|
1412
|
+
| Option | Type | Default | Description |
|
|
1413
|
+
|---|---|---|---|
|
|
1414
|
+
| `layouts` | `ComponentType<{ children }>[]` | `[]` | Layout components to wrap the element in, outermost last |
|
|
1415
|
+
| `url` | `string` | `'/'` | Exposed via `useRequest()` inside the component |
|
|
1416
|
+
| `params` | `Record<string, string \| string[]>` | — | Route params exposed via `useRequest()` |
|
|
1417
|
+
| `query` | `Record<string, string \| string[]>` | — | Query params exposed via `useRequest()` |
|
|
1418
|
+
| `headers` | `Record<string, string>` | — | Request headers exposed via `useRequest()` |
|
|
1419
|
+
| `isDev` | `boolean` | auto-detected | Whether to enable dev-mode behaviour |
|
|
1420
|
+
| `title` | `string` | `'NukeJS'` | Fallback `<title>` if the component never calls `useHtml({ title })` |
|
|
1421
|
+
|
|
1422
|
+
### Import path
|
|
1423
|
+
|
|
1424
|
+
`renderComponent` is exported from `nukejs/server`, not from `nukejs`. This separation exists because the main `nukejs` entry is also resolved by the client-component bundler (browser platform) — server-only code with Node built-in dependencies must live under a separate subpath.
|
|
1425
|
+
|
|
1426
|
+
### Hydration
|
|
1427
|
+
|
|
1428
|
+
`renderComponent()` produces pure server-rendered markup with no `"use client"` hydration wiring. This is intentional — detecting hydration boundaries requires reading source files off disk, which isn't available on Cloudflare Workers and isn't reliable in pre-bundled Vercel functions.
|
|
1429
|
+
|
|
1430
|
+
For interactive islands inside a `renderComponent()` result, mount them as normal client-side widgets the browser initialises separately, rather than relying on NukeJS's SSR hydration markers.
|
|
1431
|
+
|
|
1432
|
+
### Platform support
|
|
1433
|
+
|
|
1434
|
+
Because `renderComponent()` takes an already-imported component (a normal static ES import in your code), your own bundler resolves it at build time. It has no filesystem dependencies and works identically on Node.js, Vercel, and Cloudflare Workers.
|
|
1435
|
+
|
|
1436
|
+
---
|
|
1437
|
+
|
|
1381
1438
|
## Building & Deploying
|
|
1382
1439
|
|
|
1383
1440
|
### Node.js server
|
package/dist/builder.js
CHANGED
package/dist/bundle.js
CHANGED
|
@@ -2,15 +2,25 @@ function setupLocationChangeMonitor() {
|
|
|
2
2
|
const originalPushState = window.history.pushState.bind(window.history);
|
|
3
3
|
const originalReplaceState = window.history.replaceState.bind(window.history);
|
|
4
4
|
const dispatch = (href) => window.dispatchEvent(new CustomEvent("locationchange", { detail: { href } }));
|
|
5
|
+
let lastPath = window.location.pathname + window.location.search;
|
|
5
6
|
window.history.pushState = function(...args) {
|
|
6
7
|
originalPushState(...args);
|
|
8
|
+
lastPath = window.location.pathname + window.location.search;
|
|
7
9
|
dispatch(args[2]);
|
|
8
10
|
};
|
|
9
11
|
window.history.replaceState = function(...args) {
|
|
10
12
|
originalReplaceState(...args);
|
|
13
|
+
lastPath = window.location.pathname + window.location.search;
|
|
11
14
|
dispatch(args[2]);
|
|
12
15
|
};
|
|
13
|
-
window.addEventListener("popstate", () =>
|
|
16
|
+
window.addEventListener("popstate", () => {
|
|
17
|
+
const currentPath = window.location.pathname + window.location.search;
|
|
18
|
+
if (currentPath === lastPath) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
lastPath = currentPath;
|
|
22
|
+
dispatch(currentPath);
|
|
23
|
+
});
|
|
14
24
|
}
|
|
15
25
|
function makeLogger(level) {
|
|
16
26
|
return {
|
package/dist/index.d.ts
CHANGED
|
@@ -9,8 +9,6 @@ export type { RequestContext } from './use-request';
|
|
|
9
9
|
export { normaliseHeaders, sanitiseHeaders, getRequestStore } from './request-store';
|
|
10
10
|
export { cache } from './cache-store';
|
|
11
11
|
export { default as Link } from './Link';
|
|
12
|
-
export { renderComponent } from './render-component';
|
|
13
|
-
export type { RenderComponentOptions } from './render-component';
|
|
14
12
|
export { setupLocationChangeMonitor, initRuntime } from './bundle';
|
|
15
13
|
export type { RuntimeData } from './bundle';
|
|
16
14
|
export { escapeHtml } from './utils';
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,6 @@ import { useRequest } from "./use-request.js";
|
|
|
5
5
|
import { normaliseHeaders, sanitiseHeaders, getRequestStore } from "./request-store.js";
|
|
6
6
|
import { cache } from "./cache-store.js";
|
|
7
7
|
import { default as default3 } from "./Link.js";
|
|
8
|
-
import { renderComponent } from "./render-component.js";
|
|
9
8
|
import { setupLocationChangeMonitor, initRuntime } from "./bundle.js";
|
|
10
9
|
import { escapeHtml } from "./utils.js";
|
|
11
10
|
import { ansi, c, log, setDebugLevel, getDebugLevel } from "./logger.js";
|
|
@@ -22,7 +21,6 @@ export {
|
|
|
22
21
|
initRuntime,
|
|
23
22
|
log,
|
|
24
23
|
normaliseHeaders,
|
|
25
|
-
renderComponent,
|
|
26
24
|
sanitiseHeaders,
|
|
27
25
|
setDebugLevel,
|
|
28
26
|
setupLocationChangeMonitor,
|
package/dist/render-component.js
CHANGED
|
@@ -1,28 +1,19 @@
|
|
|
1
1
|
import { createElement } from "react";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import { renderDocument } from "./ssr.js";
|
|
6
|
-
async function renderComponent(filePath, props = {}, options = {}) {
|
|
7
|
-
const layoutPaths = options.layoutPaths ?? [];
|
|
8
|
-
const { default: Component } = await tsImport(pathToFileURL(filePath).href, { parentURL: import.meta.url });
|
|
2
|
+
import { renderDocument } from "./render-document.js";
|
|
3
|
+
async function renderComponent(Component, props = {}, options = {}) {
|
|
4
|
+
const layouts = options.layouts ?? [];
|
|
9
5
|
let element = createElement(Component, props);
|
|
10
|
-
for (let i =
|
|
11
|
-
|
|
12
|
-
element = createElement(Layout, { children: element });
|
|
6
|
+
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
7
|
+
element = createElement(layouts[i], { children: element });
|
|
13
8
|
}
|
|
14
9
|
return renderDocument({
|
|
15
|
-
registryEntryFiles: [filePath, ...layoutPaths],
|
|
16
|
-
pagesDir: options.pagesDir ?? path.resolve("./app/pages"),
|
|
17
10
|
element,
|
|
11
|
+
// No clientRegistry / resolveComponentCache — pure SSR, see module doc.
|
|
18
12
|
url: options.url ?? "/",
|
|
19
13
|
params: options.params,
|
|
20
14
|
query: options.query,
|
|
21
15
|
headers: options.headers,
|
|
22
16
|
isDev: options.isDev ?? process.env.ENVIRONMENT !== "production",
|
|
23
|
-
// Always false here: the whole point is a real server-rendered
|
|
24
|
-
// document for crawlers, not just a hydration target. skipClientSSR
|
|
25
|
-
// exists in renderDocument only for ssr.ts's dev-mode HMR fast path.
|
|
26
17
|
skipClientSSR: false,
|
|
27
18
|
defaultTitle: options.title ?? "NukeJS"
|
|
28
19
|
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { renderElementToHtml } from "./renderer.js";
|
|
2
|
+
import { runWithRequestStore, normaliseHeaders, sanitiseHeaders } from "./request-store.js";
|
|
3
|
+
import { runWithCacheStore } from "./cache-store.js";
|
|
4
|
+
import {
|
|
5
|
+
runWithHtmlStore,
|
|
6
|
+
resolveTitle
|
|
7
|
+
} from "./html-store.js";
|
|
8
|
+
import { getDebugLevel } from "./logger.js";
|
|
9
|
+
function toClientDebugLevel(level) {
|
|
10
|
+
if (level === true) return "verbose";
|
|
11
|
+
if (level === "info") return "info";
|
|
12
|
+
if (level === "error") return "error";
|
|
13
|
+
return "silent";
|
|
14
|
+
}
|
|
15
|
+
function escapeAttr(str) {
|
|
16
|
+
return str.replace(/&/g, "&").replace(/"/g, """);
|
|
17
|
+
}
|
|
18
|
+
function renderAttrs(attrs) {
|
|
19
|
+
return Object.entries(attrs).filter(([, v]) => v !== void 0 && v !== false).map(([k, v]) => v === true ? k : `${k}="${escapeAttr(String(v))}"`).join(" ");
|
|
20
|
+
}
|
|
21
|
+
function openTag(tag, attrs) {
|
|
22
|
+
const str = renderAttrs(attrs);
|
|
23
|
+
return str ? `<${tag} ${str}>` : `<${tag}>`;
|
|
24
|
+
}
|
|
25
|
+
function metaKey(k) {
|
|
26
|
+
return k === "httpEquiv" ? "http-equiv" : k;
|
|
27
|
+
}
|
|
28
|
+
function linkKey(k) {
|
|
29
|
+
if (k === "hrefLang") return "hreflang";
|
|
30
|
+
if (k === "crossOrigin") return "crossorigin";
|
|
31
|
+
return k;
|
|
32
|
+
}
|
|
33
|
+
function renderMetaTag(tag) {
|
|
34
|
+
const attrs = {};
|
|
35
|
+
for (const [k, v] of Object.entries(tag)) if (v !== void 0) attrs[metaKey(k)] = v;
|
|
36
|
+
return ` <meta ${renderAttrs(attrs)} />`;
|
|
37
|
+
}
|
|
38
|
+
function renderLinkTag(tag) {
|
|
39
|
+
const attrs = {};
|
|
40
|
+
for (const [k, v] of Object.entries(tag)) if (v !== void 0) attrs[linkKey(k)] = v;
|
|
41
|
+
return ` <link ${renderAttrs(attrs)} />`;
|
|
42
|
+
}
|
|
43
|
+
function renderScriptTag(tag) {
|
|
44
|
+
const attrs = {
|
|
45
|
+
src: tag.src,
|
|
46
|
+
type: tag.type,
|
|
47
|
+
crossorigin: tag.crossOrigin,
|
|
48
|
+
integrity: tag.integrity,
|
|
49
|
+
defer: tag.defer,
|
|
50
|
+
async: tag.async,
|
|
51
|
+
nomodule: tag.noModule
|
|
52
|
+
};
|
|
53
|
+
const attrStr = renderAttrs(attrs);
|
|
54
|
+
const open = attrStr ? `<script ${attrStr}>` : "<script>";
|
|
55
|
+
return ` ${open}${tag.src ? "" : tag.content ?? ""}</script>`;
|
|
56
|
+
}
|
|
57
|
+
function renderStyleTag(tag) {
|
|
58
|
+
const media = tag.media ? ` media="${escapeAttr(tag.media)}"` : "";
|
|
59
|
+
return ` <style${media}>${tag.content ?? ""}</style>`;
|
|
60
|
+
}
|
|
61
|
+
function renderManagedHeadTags(store) {
|
|
62
|
+
const headScripts = store.script.filter((s) => (s.position ?? "head") === "head");
|
|
63
|
+
const tags = [
|
|
64
|
+
...store.meta.map(renderMetaTag),
|
|
65
|
+
...store.link.map(renderLinkTag),
|
|
66
|
+
...store.style.map(renderStyleTag),
|
|
67
|
+
...headScripts.map(renderScriptTag)
|
|
68
|
+
];
|
|
69
|
+
if (tags.length === 0) return [];
|
|
70
|
+
return [" <!--n-head-->", ...tags, " <!--/n-head-->"];
|
|
71
|
+
}
|
|
72
|
+
function renderManagedBodyScripts(store) {
|
|
73
|
+
const bodyScripts = store.script.filter((s) => s.position === "body");
|
|
74
|
+
if (bodyScripts.length === 0) return [];
|
|
75
|
+
return [" <!--n-body-scripts-->", ...bodyScripts.map(renderScriptTag), " <!--/n-body-scripts-->"];
|
|
76
|
+
}
|
|
77
|
+
async function renderDocument(options) {
|
|
78
|
+
const {
|
|
79
|
+
element,
|
|
80
|
+
clientRegistry,
|
|
81
|
+
resolveComponentCache,
|
|
82
|
+
url,
|
|
83
|
+
params = {},
|
|
84
|
+
query = {},
|
|
85
|
+
headers = {},
|
|
86
|
+
isDev = false,
|
|
87
|
+
skipClientSSR = false,
|
|
88
|
+
defaultTitle = "NukeJS"
|
|
89
|
+
} = options;
|
|
90
|
+
const cleanUrl = url.split("?")[0];
|
|
91
|
+
const normHeaders = normaliseHeaders(headers);
|
|
92
|
+
const safeHeaders = sanitiseHeaders(headers);
|
|
93
|
+
const registry = clientRegistry ?? /* @__PURE__ */ new Map();
|
|
94
|
+
const ctx = {
|
|
95
|
+
registry,
|
|
96
|
+
hydrated: /* @__PURE__ */ new Set(),
|
|
97
|
+
skipClientSSR,
|
|
98
|
+
getComponentCache: resolveComponentCache
|
|
99
|
+
};
|
|
100
|
+
let appHtml = "";
|
|
101
|
+
const store = await runWithRequestStore(
|
|
102
|
+
{
|
|
103
|
+
url,
|
|
104
|
+
pathname: cleanUrl,
|
|
105
|
+
params,
|
|
106
|
+
query,
|
|
107
|
+
headers: normHeaders
|
|
108
|
+
},
|
|
109
|
+
() => runWithCacheStore(() => runWithHtmlStore(async () => {
|
|
110
|
+
appHtml = await renderElementToHtml(element, ctx);
|
|
111
|
+
}))
|
|
112
|
+
);
|
|
113
|
+
const pageTitle = resolveTitle(store.titleOps, defaultTitle);
|
|
114
|
+
const headLines = [
|
|
115
|
+
' <meta charset="utf-8" />',
|
|
116
|
+
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
117
|
+
` <title>${escapeAttr(pageTitle)}</title>`,
|
|
118
|
+
...renderManagedHeadTags(store)
|
|
119
|
+
];
|
|
120
|
+
const runtimeData = JSON.stringify({
|
|
121
|
+
hydrateIds: [...ctx.hydrated],
|
|
122
|
+
allIds: [...registry.keys()],
|
|
123
|
+
url,
|
|
124
|
+
params,
|
|
125
|
+
query,
|
|
126
|
+
headers: safeHeaders,
|
|
127
|
+
debug: toClientDebugLevel(getDebugLevel())
|
|
128
|
+
}).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
|
|
129
|
+
const bodyScriptLines = renderManagedBodyScripts(store);
|
|
130
|
+
const bodyScriptsHtml = bodyScriptLines.length > 0 ? "\n" + bodyScriptLines.join("\n") + "\n" : "";
|
|
131
|
+
return `<!DOCTYPE html>
|
|
132
|
+
${openTag("html", store.htmlAttrs)}
|
|
133
|
+
<head>
|
|
134
|
+
${headLines.join("\n")}
|
|
135
|
+
</head>
|
|
136
|
+
${openTag("body", store.bodyAttrs)}
|
|
137
|
+
<div id="app">${appHtml}</div>
|
|
138
|
+
|
|
139
|
+
<script id="__n_data" type="application/json">${runtimeData}</script>
|
|
140
|
+
|
|
141
|
+
<script type="importmap">
|
|
142
|
+
{
|
|
143
|
+
"imports": {
|
|
144
|
+
"react": "/__react.js",
|
|
145
|
+
"react-dom/client": "/__react.js",
|
|
146
|
+
"react/jsx-runtime": "/__react.js",
|
|
147
|
+
"nukejs": "/__n.js"
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
</script>
|
|
151
|
+
|
|
152
|
+
<script type="module">
|
|
153
|
+
await import('react');
|
|
154
|
+
const { initRuntime } = await import('nukejs');
|
|
155
|
+
const data = JSON.parse(document.getElementById('__n_data').textContent);
|
|
156
|
+
initRuntime(data);
|
|
157
|
+
</script>
|
|
158
|
+
|
|
159
|
+
${isDev ? '<script type="module" src="/__hmr.js"></script>' : ""}
|
|
160
|
+
${bodyScriptsHtml}</body>
|
|
161
|
+
</html>`;
|
|
162
|
+
}
|
|
163
|
+
export {
|
|
164
|
+
renderDocument
|
|
165
|
+
};
|
package/dist/renderer.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import path from "path";
|
|
2
1
|
import { createElement, Fragment } from "react";
|
|
3
2
|
import { renderToString } from "react-dom/server";
|
|
4
3
|
import { log } from "./logger.js";
|
|
5
|
-
import { getComponentCache } from "./component-analyzer.js";
|
|
6
4
|
import { escapeHtml } from "./utils.js";
|
|
7
5
|
function isWrapperAttr(key) {
|
|
8
6
|
return key === "className" || key === "style" || key === "id" || key.startsWith("data-") || key.startsWith("aria-");
|
|
@@ -77,7 +75,7 @@ async function renderHtmlElement(type, props, ctx) {
|
|
|
77
75
|
return `<${type}${attrStr}>${childrenHtml}</${type}>`;
|
|
78
76
|
}
|
|
79
77
|
async function renderFunctionComponent(type, props, ctx) {
|
|
80
|
-
const componentCache = getComponentCache();
|
|
78
|
+
const componentCache = ctx.getComponentCache ? ctx.getComponentCache() : /* @__PURE__ */ new Map();
|
|
81
79
|
for (const [id, filePath] of ctx.registry.entries()) {
|
|
82
80
|
const info = componentCache.get(filePath);
|
|
83
81
|
if (!info?.isClientComponent) continue;
|
|
@@ -91,7 +89,7 @@ async function renderFunctionComponent(type, props, ctx) {
|
|
|
91
89
|
const { wrapperAttrs, componentProps } = splitWrapperAttrs(props);
|
|
92
90
|
const wrapperAttrStr = buildWrapperAttrString(wrapperAttrs);
|
|
93
91
|
const { real: hydrationSafeProps, json: serializedProps } = await prepareProps(componentProps, ctx);
|
|
94
|
-
log.verbose(`Client component rendered for hydration: ${id} (${
|
|
92
|
+
log.verbose(`Client component rendered for hydration: ${id} (${filePath.split(/[\\/]/).pop()})`);
|
|
95
93
|
const html = ctx.skipClientSSR ? "" : renderToString(createElement(type, hydrationSafeProps));
|
|
96
94
|
return `<span data-hydrate-id="${id}"${wrapperAttrStr} data-hydrate-props="${escapeHtml(
|
|
97
95
|
JSON.stringify(serializedProps)
|
|
@@ -148,7 +146,7 @@ async function prepareElement(element, ctx) {
|
|
|
148
146
|
return { real: createElement(type, p.real), json: { __re: "html", tag: type, props: p.json } };
|
|
149
147
|
}
|
|
150
148
|
if (typeof type === "function") {
|
|
151
|
-
const componentCache = getComponentCache();
|
|
149
|
+
const componentCache = ctx.getComponentCache ? ctx.getComponentCache() : /* @__PURE__ */ new Map();
|
|
152
150
|
for (const [id, filePath] of ctx.registry.entries()) {
|
|
153
151
|
const info = componentCache.get(filePath);
|
|
154
152
|
if (!info?.isClientComponent) continue;
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server.ts — server-only public API.
|
|
3
|
+
*
|
|
4
|
+
* Kept separate from index.ts on purpose: index.ts is the entry point the
|
|
5
|
+
* client component bundler (bundler.ts, platform: 'browser') resolves
|
|
6
|
+
* whenever a "use client" file imports from 'nukejs'. renderComponent()
|
|
7
|
+
* pulls in tsx/esm/api and, transitively, ssr.ts's Node built-ins (fs,
|
|
8
|
+
* node:worker_threads, …) — none of which esbuild can (or should) resolve
|
|
9
|
+
* for a browser bundle. Anything server-only belongs here, under
|
|
10
|
+
* 'nukejs/server', not in the shared index.
|
|
11
|
+
*/
|
|
12
|
+
export { renderComponent } from './render-component';
|
|
13
|
+
export type { RenderComponentOptions } from './render-component';
|
package/dist/server.js
ADDED
package/dist/ssr.js
CHANGED
|
@@ -3,16 +3,10 @@ import fs from "fs";
|
|
|
3
3
|
import { createElement } from "react";
|
|
4
4
|
import { pathToFileURL } from "url";
|
|
5
5
|
import { tsImport } from "tsx/esm/api";
|
|
6
|
-
import { log
|
|
6
|
+
import { log } from "./logger.js";
|
|
7
7
|
import { matchRoute, findLayoutsForRoute } from "./router.js";
|
|
8
|
-
import { findClientComponentsInTree } from "./component-analyzer.js";
|
|
9
|
-
import {
|
|
10
|
-
import { runWithRequestStore, normaliseHeaders, sanitiseHeaders } from "./request-store.js";
|
|
11
|
-
import { runWithCacheStore } from "./cache-store.js";
|
|
12
|
-
import {
|
|
13
|
-
runWithHtmlStore,
|
|
14
|
-
resolveTitle
|
|
15
|
-
} from "./html-store.js";
|
|
8
|
+
import { findClientComponentsInTree, getComponentCache } from "./component-analyzer.js";
|
|
9
|
+
import { renderDocument } from "./render-document.js";
|
|
16
10
|
async function wrapWithLayouts(pageElement, layoutPaths) {
|
|
17
11
|
let element = pageElement;
|
|
18
12
|
for (let i = layoutPaths.length - 1; i >= 0; i--) {
|
|
@@ -24,158 +18,6 @@ async function wrapWithLayouts(pageElement, layoutPaths) {
|
|
|
24
18
|
}
|
|
25
19
|
return element;
|
|
26
20
|
}
|
|
27
|
-
function toClientDebugLevel(level) {
|
|
28
|
-
if (level === true) return "verbose";
|
|
29
|
-
if (level === "info") return "info";
|
|
30
|
-
if (level === "error") return "error";
|
|
31
|
-
return "silent";
|
|
32
|
-
}
|
|
33
|
-
function escapeAttr(str) {
|
|
34
|
-
return str.replace(/&/g, "&").replace(/"/g, """);
|
|
35
|
-
}
|
|
36
|
-
function renderAttrs(attrs) {
|
|
37
|
-
return Object.entries(attrs).filter(([, v]) => v !== void 0 && v !== false).map(([k, v]) => v === true ? k : `${k}="${escapeAttr(String(v))}"`).join(" ");
|
|
38
|
-
}
|
|
39
|
-
function openTag(tag, attrs) {
|
|
40
|
-
const str = renderAttrs(attrs);
|
|
41
|
-
return str ? `<${tag} ${str}>` : `<${tag}>`;
|
|
42
|
-
}
|
|
43
|
-
function metaKey(k) {
|
|
44
|
-
return k === "httpEquiv" ? "http-equiv" : k;
|
|
45
|
-
}
|
|
46
|
-
function linkKey(k) {
|
|
47
|
-
if (k === "hrefLang") return "hreflang";
|
|
48
|
-
if (k === "crossOrigin") return "crossorigin";
|
|
49
|
-
return k;
|
|
50
|
-
}
|
|
51
|
-
function renderMetaTag(tag) {
|
|
52
|
-
const attrs = {};
|
|
53
|
-
for (const [k, v] of Object.entries(tag)) if (v !== void 0) attrs[metaKey(k)] = v;
|
|
54
|
-
return ` <meta ${renderAttrs(attrs)} />`;
|
|
55
|
-
}
|
|
56
|
-
function renderLinkTag(tag) {
|
|
57
|
-
const attrs = {};
|
|
58
|
-
for (const [k, v] of Object.entries(tag)) if (v !== void 0) attrs[linkKey(k)] = v;
|
|
59
|
-
return ` <link ${renderAttrs(attrs)} />`;
|
|
60
|
-
}
|
|
61
|
-
function renderScriptTag(tag) {
|
|
62
|
-
const attrs = {
|
|
63
|
-
src: tag.src,
|
|
64
|
-
type: tag.type,
|
|
65
|
-
crossorigin: tag.crossOrigin,
|
|
66
|
-
integrity: tag.integrity,
|
|
67
|
-
defer: tag.defer,
|
|
68
|
-
async: tag.async,
|
|
69
|
-
nomodule: tag.noModule
|
|
70
|
-
};
|
|
71
|
-
const attrStr = renderAttrs(attrs);
|
|
72
|
-
const open = attrStr ? `<script ${attrStr}>` : "<script>";
|
|
73
|
-
return ` ${open}${tag.src ? "" : tag.content ?? ""}</script>`;
|
|
74
|
-
}
|
|
75
|
-
function renderStyleTag(tag) {
|
|
76
|
-
const media = tag.media ? ` media="${escapeAttr(tag.media)}"` : "";
|
|
77
|
-
return ` <style${media}>${tag.content ?? ""}</style>`;
|
|
78
|
-
}
|
|
79
|
-
function renderManagedHeadTags(store) {
|
|
80
|
-
const headScripts = store.script.filter((s) => (s.position ?? "head") === "head");
|
|
81
|
-
const tags = [
|
|
82
|
-
...store.meta.map(renderMetaTag),
|
|
83
|
-
...store.link.map(renderLinkTag),
|
|
84
|
-
...store.style.map(renderStyleTag),
|
|
85
|
-
...headScripts.map(renderScriptTag)
|
|
86
|
-
];
|
|
87
|
-
if (tags.length === 0) return [];
|
|
88
|
-
return [" <!--n-head-->", ...tags, " <!--/n-head-->"];
|
|
89
|
-
}
|
|
90
|
-
function renderManagedBodyScripts(store) {
|
|
91
|
-
const bodyScripts = store.script.filter((s) => s.position === "body");
|
|
92
|
-
if (bodyScripts.length === 0) return [];
|
|
93
|
-
return [" <!--n-body-scripts-->", ...bodyScripts.map(renderScriptTag), " <!--/n-body-scripts-->"];
|
|
94
|
-
}
|
|
95
|
-
async function renderDocument(options) {
|
|
96
|
-
const {
|
|
97
|
-
registryEntryFiles,
|
|
98
|
-
pagesDir,
|
|
99
|
-
element,
|
|
100
|
-
url,
|
|
101
|
-
params = {},
|
|
102
|
-
query = {},
|
|
103
|
-
headers = {},
|
|
104
|
-
isDev = false,
|
|
105
|
-
skipClientSSR = false,
|
|
106
|
-
defaultTitle = "NukeJS"
|
|
107
|
-
} = options;
|
|
108
|
-
const cleanUrl = url.split("?")[0];
|
|
109
|
-
const normHeaders = normaliseHeaders(headers);
|
|
110
|
-
const safeHeaders = sanitiseHeaders(headers);
|
|
111
|
-
const registry = /* @__PURE__ */ new Map();
|
|
112
|
-
for (const entryFile of registryEntryFiles)
|
|
113
|
-
for (const [id, p] of findClientComponentsInTree(entryFile, pagesDir))
|
|
114
|
-
registry.set(id, p);
|
|
115
|
-
const ctx = { registry, hydrated: /* @__PURE__ */ new Set(), skipClientSSR };
|
|
116
|
-
let appHtml = "";
|
|
117
|
-
const store = await runWithRequestStore(
|
|
118
|
-
{
|
|
119
|
-
url,
|
|
120
|
-
pathname: cleanUrl,
|
|
121
|
-
params,
|
|
122
|
-
query,
|
|
123
|
-
headers: normHeaders
|
|
124
|
-
},
|
|
125
|
-
() => runWithCacheStore(() => runWithHtmlStore(async () => {
|
|
126
|
-
appHtml = await renderElementToHtml(element, ctx);
|
|
127
|
-
}))
|
|
128
|
-
);
|
|
129
|
-
const pageTitle = resolveTitle(store.titleOps, defaultTitle);
|
|
130
|
-
const headLines = [
|
|
131
|
-
' <meta charset="utf-8" />',
|
|
132
|
-
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
133
|
-
` <title>${escapeAttr(pageTitle)}</title>`,
|
|
134
|
-
...renderManagedHeadTags(store)
|
|
135
|
-
];
|
|
136
|
-
const runtimeData = JSON.stringify({
|
|
137
|
-
hydrateIds: [...ctx.hydrated],
|
|
138
|
-
allIds: [...registry.keys()],
|
|
139
|
-
url,
|
|
140
|
-
params,
|
|
141
|
-
query,
|
|
142
|
-
headers: safeHeaders,
|
|
143
|
-
debug: toClientDebugLevel(getDebugLevel())
|
|
144
|
-
}).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
|
|
145
|
-
const bodyScriptLines = renderManagedBodyScripts(store);
|
|
146
|
-
const bodyScriptsHtml = bodyScriptLines.length > 0 ? "\n" + bodyScriptLines.join("\n") + "\n" : "";
|
|
147
|
-
return `<!DOCTYPE html>
|
|
148
|
-
${openTag("html", store.htmlAttrs)}
|
|
149
|
-
<head>
|
|
150
|
-
${headLines.join("\n")}
|
|
151
|
-
</head>
|
|
152
|
-
${openTag("body", store.bodyAttrs)}
|
|
153
|
-
<div id="app">${appHtml}</div>
|
|
154
|
-
|
|
155
|
-
<script id="__n_data" type="application/json">${runtimeData}</script>
|
|
156
|
-
|
|
157
|
-
<script type="importmap">
|
|
158
|
-
{
|
|
159
|
-
"imports": {
|
|
160
|
-
"react": "/__react.js",
|
|
161
|
-
"react-dom/client": "/__react.js",
|
|
162
|
-
"react/jsx-runtime": "/__react.js",
|
|
163
|
-
"nukejs": "/__n.js"
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
</script>
|
|
167
|
-
|
|
168
|
-
<script type="module">
|
|
169
|
-
await import('react');
|
|
170
|
-
const { initRuntime } = await import('nukejs');
|
|
171
|
-
const data = JSON.parse(document.getElementById('__n_data').textContent);
|
|
172
|
-
initRuntime(data);
|
|
173
|
-
</script>
|
|
174
|
-
|
|
175
|
-
${isDev ? '<script type="module" src="/__hmr.js"></script>' : ""}
|
|
176
|
-
${bodyScriptsHtml}</body>
|
|
177
|
-
</html>`;
|
|
178
|
-
}
|
|
179
21
|
async function renderFile(filePath, params, url, pagesDir, isDev, res, req, statusCode, skipClientSSR) {
|
|
180
22
|
const searchParams = new URL(url, "http://localhost").searchParams;
|
|
181
23
|
const queryParams = {};
|
|
@@ -195,10 +37,14 @@ async function renderFile(filePath, params, url, pagesDir, isDev, res, req, stat
|
|
|
195
37
|
createElement(PageComponent, mergedParams),
|
|
196
38
|
layoutPaths
|
|
197
39
|
);
|
|
40
|
+
const clientRegistry = /* @__PURE__ */ new Map();
|
|
41
|
+
for (const entryFile of [filePath, ...layoutPaths])
|
|
42
|
+
for (const [id, p] of findClientComponentsInTree(entryFile, pagesDir))
|
|
43
|
+
clientRegistry.set(id, p);
|
|
198
44
|
const html = await renderDocument({
|
|
199
|
-
registryEntryFiles: [filePath, ...layoutPaths],
|
|
200
|
-
pagesDir,
|
|
201
45
|
element: wrappedElement,
|
|
46
|
+
clientRegistry,
|
|
47
|
+
resolveComponentCache: getComponentCache,
|
|
202
48
|
url,
|
|
203
49
|
params,
|
|
204
50
|
query: queryParams,
|
|
@@ -282,6 +128,5 @@ async function serverSideRender(url, res, pagesDir, isDev = false, req) {
|
|
|
282
128
|
}
|
|
283
129
|
}
|
|
284
130
|
export {
|
|
285
|
-
renderDocument,
|
|
286
131
|
serverSideRender
|
|
287
132
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nukejs",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.32",
|
|
4
4
|
"description": "A minimal, opinionated full-stack React framework on Node.js that server-renders everything and hydrates only interactive parts.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
".": {
|
|
9
9
|
"import": "./dist/index.js",
|
|
10
10
|
"types": "./dist/index.d.ts"
|
|
11
|
+
},
|
|
12
|
+
"./server": {
|
|
13
|
+
"import": "./dist/server.js",
|
|
14
|
+
"types": "./dist/server.d.ts"
|
|
11
15
|
}
|
|
12
16
|
},
|
|
13
17
|
"bin": {
|