nukejs 0.0.17 → 0.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Link.d.ts +12 -0
- package/dist/Link.js +15 -0
- package/dist/app.js +112 -0
- package/dist/build-common.js +886 -0
- package/dist/build-node.js +217 -0
- package/dist/build-vercel.js +359 -0
- package/dist/builder.js +95 -0
- package/dist/bundle.d.ts +85 -0
- package/dist/bundle.js +322 -0
- package/dist/bundler.js +112 -0
- package/dist/component-analyzer.js +125 -0
- package/dist/config.js +29 -0
- package/dist/hmr-bundle.js +120 -0
- package/dist/hmr.js +68 -0
- package/dist/html-store.d.ts +128 -0
- package/dist/html-store.js +41 -0
- package/dist/http-server.js +172 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +28 -0
- package/dist/logger.d.ts +59 -0
- package/dist/logger.js +53 -0
- package/dist/metadata.js +42 -0
- package/dist/middleware-loader.js +53 -0
- package/dist/middleware.example.js +57 -0
- package/dist/middleware.js +71 -0
- package/dist/renderer.js +151 -0
- package/dist/request-store.d.ts +80 -0
- package/dist/request-store.js +46 -0
- package/dist/router.js +118 -0
- package/dist/ssr.js +262 -0
- package/dist/store.d.ts +104 -0
- package/dist/store.js +45 -0
- package/dist/use-html.d.ts +64 -0
- package/dist/use-html.js +128 -0
- package/dist/use-request.d.ts +74 -0
- package/dist/use-request.js +48 -0
- package/dist/use-router.d.ts +7 -0
- package/dist/use-router.js +27 -0
- package/dist/utils.d.ts +26 -0
- package/dist/utils.js +61 -0
- package/package.json +1 -1
package/dist/metadata.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { pathToFileURL } from "url";
|
|
2
|
+
import { escapeHtml } from "./utils.js";
|
|
3
|
+
async function loadMetadata(filePath) {
|
|
4
|
+
try {
|
|
5
|
+
const mod = await import(pathToFileURL(filePath).href);
|
|
6
|
+
return mod.metadata ?? {};
|
|
7
|
+
} catch {
|
|
8
|
+
return {};
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function mergeMetadata(ordered) {
|
|
12
|
+
const result = { title: "", scripts: [], styles: [] };
|
|
13
|
+
for (const m of ordered) {
|
|
14
|
+
if (m.title) result.title = m.title;
|
|
15
|
+
if (m.scripts?.length) result.scripts.push(...m.scripts);
|
|
16
|
+
if (m.styles?.length) result.styles.push(...m.styles);
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
function renderScriptTag(s) {
|
|
21
|
+
if (s.src) {
|
|
22
|
+
const attrs = [
|
|
23
|
+
`src="${escapeHtml(s.src)}"`,
|
|
24
|
+
s.type ? `type="${escapeHtml(s.type)}"` : "",
|
|
25
|
+
s.defer ? "defer" : "",
|
|
26
|
+
s.async ? "async" : ""
|
|
27
|
+
].filter(Boolean).join(" ");
|
|
28
|
+
return `<script ${attrs}></script>`;
|
|
29
|
+
}
|
|
30
|
+
const typeAttr = s.type ? ` type="${escapeHtml(s.type)}"` : "";
|
|
31
|
+
return `<script${typeAttr}>${s.content ?? ""}</script>`;
|
|
32
|
+
}
|
|
33
|
+
function renderStyleTag(s) {
|
|
34
|
+
if (s.href) return `<link rel="stylesheet" href="${escapeHtml(s.href)}" />`;
|
|
35
|
+
return `<style>${s.content ?? ""}</style>`;
|
|
36
|
+
}
|
|
37
|
+
export {
|
|
38
|
+
loadMetadata,
|
|
39
|
+
mergeMetadata,
|
|
40
|
+
renderScriptTag,
|
|
41
|
+
renderStyleTag
|
|
42
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { pathToFileURL, fileURLToPath } from "url";
|
|
4
|
+
import { log } from "./logger.js";
|
|
5
|
+
const middlewares = [];
|
|
6
|
+
async function loadMiddlewareFromPath(middlewarePath) {
|
|
7
|
+
if (!fs.existsSync(middlewarePath)) {
|
|
8
|
+
log.verbose(`No middleware found at ${middlewarePath}, skipping`);
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
const mod = await import(pathToFileURL(middlewarePath).href);
|
|
13
|
+
if (typeof mod.default === "function") {
|
|
14
|
+
middlewares.push({ fn: mod.default, src: middlewarePath });
|
|
15
|
+
log.info(`Middleware loaded from ${middlewarePath}`);
|
|
16
|
+
} else {
|
|
17
|
+
log.warn(`${middlewarePath} does not export a default function`);
|
|
18
|
+
}
|
|
19
|
+
} catch (error) {
|
|
20
|
+
log.error(`Error loading middleware from ${middlewarePath}:`, error);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function loadMiddleware() {
|
|
24
|
+
const appDir = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const builtinPath = path.join(
|
|
26
|
+
appDir,
|
|
27
|
+
`middleware.${appDir.endsWith("dist") ? "js" : "ts"}`
|
|
28
|
+
);
|
|
29
|
+
const userPath = path.join(process.cwd(), "middleware.ts");
|
|
30
|
+
const paths = [.../* @__PURE__ */ new Set([builtinPath, userPath])];
|
|
31
|
+
for (const middlewarePath of paths) {
|
|
32
|
+
await loadMiddlewareFromPath(middlewarePath);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function runMiddleware(req, res) {
|
|
36
|
+
if (middlewares.length === 0) return false;
|
|
37
|
+
for (const { fn, src } of middlewares) {
|
|
38
|
+
await fn(req, res);
|
|
39
|
+
if (res.writableEnded || res.headersSent) {
|
|
40
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
41
|
+
const url = req.url ?? "/";
|
|
42
|
+
const status = res.statusCode;
|
|
43
|
+
const srcName = path.basename(src);
|
|
44
|
+
log.verbose(`middleware ${method} ${url} ${status} (${srcName})`);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
export {
|
|
51
|
+
loadMiddleware,
|
|
52
|
+
runMiddleware
|
|
53
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
async function middleware(req, res) {
|
|
2
|
+
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${req.method} ${req.url}`);
|
|
3
|
+
if (req.url?.startsWith("/admin") && !isAuthenticated(req)) {
|
|
4
|
+
res.statusCode = 401;
|
|
5
|
+
res.setHeader("Content-Type", "text/html");
|
|
6
|
+
res.end("<h1>401 Unauthorized</h1><p>Please log in to access this page.</p>");
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
if (req.url === "/old-page") {
|
|
10
|
+
req.url = "/new-page";
|
|
11
|
+
}
|
|
12
|
+
res.setHeader("X-Powered-By", "nukejs-framework");
|
|
13
|
+
res.setHeader("X-Request-Id", generateRequestId());
|
|
14
|
+
if (req.url?.startsWith("/api/")) {
|
|
15
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
16
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
17
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
18
|
+
if (req.method === "OPTIONS") {
|
|
19
|
+
res.statusCode = 204;
|
|
20
|
+
res.end();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const clientIp = req.socket.remoteAddress || "unknown";
|
|
25
|
+
if (isRateLimited(clientIp)) {
|
|
26
|
+
res.statusCode = 429;
|
|
27
|
+
res.setHeader("Content-Type", "application/json");
|
|
28
|
+
res.end(JSON.stringify({ error: "Too many requests" }));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (process.env.MAINTENANCE_MODE === "true" && !req.url?.startsWith("/__")) {
|
|
32
|
+
res.statusCode = 503;
|
|
33
|
+
res.setHeader("Content-Type", "text/html");
|
|
34
|
+
res.end("<h1>503 Service Unavailable</h1><p>We are currently down for maintenance.</p>");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function isAuthenticated(req) {
|
|
39
|
+
return req.headers.authorization === "Bearer valid-token";
|
|
40
|
+
}
|
|
41
|
+
function generateRequestId() {
|
|
42
|
+
return Math.random().toString(36).substring(2, 15);
|
|
43
|
+
}
|
|
44
|
+
const rateLimitMap = /* @__PURE__ */ new Map();
|
|
45
|
+
function isRateLimited(ip) {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
const limit = rateLimitMap.get(ip);
|
|
48
|
+
if (!limit || now > limit.resetAt) {
|
|
49
|
+
rateLimitMap.set(ip, { count: 1, resetAt: now + 6e4 });
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
limit.count++;
|
|
53
|
+
return limit.count > 100;
|
|
54
|
+
}
|
|
55
|
+
export {
|
|
56
|
+
middleware as default
|
|
57
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { build } from "esbuild";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { hmrClients } from "./hmr.js";
|
|
6
|
+
import { getMimeType } from "./utils.js";
|
|
7
|
+
let hmrBundlePromise = null;
|
|
8
|
+
const PUBLIC_DIR = path.resolve("./app/public");
|
|
9
|
+
async function middleware(req, res) {
|
|
10
|
+
const rawUrl = req.url ?? "/";
|
|
11
|
+
const pathname = rawUrl.split("?")[0];
|
|
12
|
+
if (fs.existsSync(PUBLIC_DIR)) {
|
|
13
|
+
const candidate = path.join(PUBLIC_DIR, pathname);
|
|
14
|
+
const publicBase = PUBLIC_DIR.endsWith(path.sep) ? PUBLIC_DIR : PUBLIC_DIR + path.sep;
|
|
15
|
+
const safe = candidate.startsWith(publicBase) || candidate === PUBLIC_DIR;
|
|
16
|
+
if (!safe) {
|
|
17
|
+
res.statusCode = 400;
|
|
18
|
+
res.end("Bad request");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
22
|
+
const ext = path.extname(candidate);
|
|
23
|
+
res.setHeader("Content-Type", getMimeType(ext));
|
|
24
|
+
res.end(fs.readFileSync(candidate));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (rawUrl === "/__hmr.js") {
|
|
29
|
+
if (!hmrBundlePromise) {
|
|
30
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const entry = path.join(dir, `hmr-bundle.${dir.endsWith("dist") ? "js" : "ts"}`);
|
|
32
|
+
hmrBundlePromise = build({
|
|
33
|
+
entryPoints: [entry],
|
|
34
|
+
write: false,
|
|
35
|
+
format: "esm",
|
|
36
|
+
minify: true,
|
|
37
|
+
bundle: true,
|
|
38
|
+
external: ["react", "react-dom/client", "react/jsx-runtime"]
|
|
39
|
+
}).then((r) => r.outputFiles[0].text);
|
|
40
|
+
}
|
|
41
|
+
res.setHeader("Content-Type", "application/javascript");
|
|
42
|
+
res.end(await hmrBundlePromise);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (rawUrl === "/__hmr") {
|
|
46
|
+
const MAX_SSE_PER_IP = 5;
|
|
47
|
+
const remoteAddr = req.socket?.remoteAddress;
|
|
48
|
+
if (remoteAddr) {
|
|
49
|
+
const fromSameIp = [...hmrClients].filter(
|
|
50
|
+
(c) => c.socket?.remoteAddress === remoteAddr
|
|
51
|
+
);
|
|
52
|
+
if (fromSameIp.length >= MAX_SSE_PER_IP) {
|
|
53
|
+
const oldest = fromSameIp[0];
|
|
54
|
+
oldest.destroy();
|
|
55
|
+
hmrClients.delete(oldest);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
59
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
60
|
+
res.setHeader("Connection", "keep-alive");
|
|
61
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
62
|
+
res.flushHeaders();
|
|
63
|
+
res.write('data: {"type":"connected"}\n\n');
|
|
64
|
+
hmrClients.add(res);
|
|
65
|
+
req.on("close", () => hmrClients.delete(res));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export {
|
|
70
|
+
middleware as default
|
|
71
|
+
};
|
package/dist/renderer.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { createElement, Fragment } from "react";
|
|
3
|
+
import { renderToString } from "react-dom/server";
|
|
4
|
+
import { log } from "./logger.js";
|
|
5
|
+
import { getComponentCache } from "./component-analyzer.js";
|
|
6
|
+
import { escapeHtml } from "./utils.js";
|
|
7
|
+
function isWrapperAttr(key) {
|
|
8
|
+
return key === "className" || key === "style" || key === "id" || key.startsWith("data-") || key.startsWith("aria-");
|
|
9
|
+
}
|
|
10
|
+
function splitWrapperAttrs(props) {
|
|
11
|
+
const wrapperAttrs = {};
|
|
12
|
+
const componentProps = {};
|
|
13
|
+
for (const [key, value] of Object.entries(props || {})) {
|
|
14
|
+
if (isWrapperAttr(key)) wrapperAttrs[key] = value;
|
|
15
|
+
else componentProps[key] = value;
|
|
16
|
+
}
|
|
17
|
+
return { wrapperAttrs, componentProps };
|
|
18
|
+
}
|
|
19
|
+
function buildWrapperAttrString(attrs) {
|
|
20
|
+
const parts = Object.entries(attrs).map(([key, value]) => {
|
|
21
|
+
if (key === "className") key = "class";
|
|
22
|
+
if (key === "style" && typeof value === "object") {
|
|
23
|
+
const css = "display:contents;" + Object.entries(value).map(([k, v]) => {
|
|
24
|
+
const prop = k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
|
|
25
|
+
const safeVal = String(v).replace(/[<>"'`\\]/g, "");
|
|
26
|
+
return `${prop}:${safeVal}`;
|
|
27
|
+
}).join(";");
|
|
28
|
+
return `style="${css}"`;
|
|
29
|
+
}
|
|
30
|
+
if (typeof value === "boolean") return value ? key : "";
|
|
31
|
+
if (value == null) return "";
|
|
32
|
+
return `${key}="${escapeHtml(String(value))}"`;
|
|
33
|
+
}).filter(Boolean);
|
|
34
|
+
if (!("style" in attrs)) parts.push('style="display:contents"');
|
|
35
|
+
return parts.length ? " " + parts.join(" ") : "";
|
|
36
|
+
}
|
|
37
|
+
async function renderElementToHtml(element, ctx) {
|
|
38
|
+
if (element === null || element === void 0 || typeof element === "boolean") return "";
|
|
39
|
+
if (typeof element === "string" || typeof element === "number")
|
|
40
|
+
return escapeHtml(String(element));
|
|
41
|
+
if (Array.isArray(element)) {
|
|
42
|
+
const parts = await Promise.all(element.map((e) => renderElementToHtml(e, ctx)));
|
|
43
|
+
return parts.join("");
|
|
44
|
+
}
|
|
45
|
+
if (!element.type) return "";
|
|
46
|
+
const { type, props } = element;
|
|
47
|
+
if (type === Fragment) return renderElementToHtml(props.children, ctx);
|
|
48
|
+
if (typeof type === "string") return renderHtmlElement(type, props, ctx);
|
|
49
|
+
if (typeof type === "function") return renderFunctionComponent(type, props, ctx);
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
async function renderHtmlElement(type, props, ctx) {
|
|
53
|
+
const { children, ...attributes } = props || {};
|
|
54
|
+
const attrs = Object.entries(attributes).map(([key, value]) => {
|
|
55
|
+
if (key === "className") key = "class";
|
|
56
|
+
if (key === "htmlFor") key = "for";
|
|
57
|
+
if (key === "dangerouslySetInnerHTML") return "";
|
|
58
|
+
if (typeof value === "boolean") return value ? key : "";
|
|
59
|
+
if (key === "style" && typeof value === "object") {
|
|
60
|
+
const styleStr = Object.entries(value).map(([k, v]) => {
|
|
61
|
+
const prop = k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
|
|
62
|
+
const safeVal = String(v).replace(/[<>"'`\\]/g, "");
|
|
63
|
+
return `${prop}:${safeVal}`;
|
|
64
|
+
}).join(";");
|
|
65
|
+
return `style="${styleStr}"`;
|
|
66
|
+
}
|
|
67
|
+
return `${key}="${escapeHtml(String(value))}"`;
|
|
68
|
+
}).filter(Boolean).join(" ");
|
|
69
|
+
const attrStr = attrs ? ` ${attrs}` : "";
|
|
70
|
+
if (props?.dangerouslySetInnerHTML) {
|
|
71
|
+
return `<${type}${attrStr}>${props.dangerouslySetInnerHTML.__html}</${type}>`;
|
|
72
|
+
}
|
|
73
|
+
if (["img", "br", "hr", "input", "meta", "link"].includes(type)) {
|
|
74
|
+
return `<${type}${attrStr} />`;
|
|
75
|
+
}
|
|
76
|
+
const childrenHtml = children ? await renderElementToHtml(children, ctx) : "";
|
|
77
|
+
return `<${type}${attrStr}>${childrenHtml}</${type}>`;
|
|
78
|
+
}
|
|
79
|
+
async function renderFunctionComponent(type, props, ctx) {
|
|
80
|
+
const componentCache = getComponentCache();
|
|
81
|
+
for (const [id, filePath] of ctx.registry.entries()) {
|
|
82
|
+
const info = componentCache.get(filePath);
|
|
83
|
+
if (!info?.isClientComponent) continue;
|
|
84
|
+
if (!info.exportedName || type.name !== info.exportedName) continue;
|
|
85
|
+
try {
|
|
86
|
+
ctx.hydrated.add(id);
|
|
87
|
+
const { wrapperAttrs, componentProps } = splitWrapperAttrs(props);
|
|
88
|
+
const wrapperAttrStr = buildWrapperAttrString(wrapperAttrs);
|
|
89
|
+
const serializedProps = serializePropsForHydration(componentProps, ctx.registry);
|
|
90
|
+
log.verbose(`Client component rendered for hydration: ${id} (${path.basename(filePath)})`);
|
|
91
|
+
const html = ctx.skipClientSSR ? "" : renderToString(createElement(type, componentProps));
|
|
92
|
+
return `<span data-hydrate-id="${id}"${wrapperAttrStr} data-hydrate-props="${escapeHtml(
|
|
93
|
+
JSON.stringify(serializedProps)
|
|
94
|
+
)}">${html}</span>`;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
log.error("Error rendering client component:", err);
|
|
97
|
+
return `<div style="color:red">Error rendering client component: ${escapeHtml(String(err))}</div>`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const result = type(props);
|
|
101
|
+
const resolved = result?.then ? await result : result;
|
|
102
|
+
return renderElementToHtml(resolved, ctx);
|
|
103
|
+
}
|
|
104
|
+
function serializePropsForHydration(props, registry) {
|
|
105
|
+
if (!props || typeof props !== "object") return props;
|
|
106
|
+
const out = {};
|
|
107
|
+
for (const [key, value] of Object.entries(props)) {
|
|
108
|
+
const s = serializeValue(value, registry);
|
|
109
|
+
if (s !== void 0) out[key] = s;
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
function serializeValue(value, registry) {
|
|
114
|
+
if (value === null || value === void 0) return value;
|
|
115
|
+
if (typeof value === "function") return void 0;
|
|
116
|
+
if (typeof value !== "object") return value;
|
|
117
|
+
if (Array.isArray(value))
|
|
118
|
+
return value.map((v) => serializeValue(v, registry)).filter((v) => v !== void 0);
|
|
119
|
+
if (value.$$typeof)
|
|
120
|
+
return serializeReactElement(value, registry);
|
|
121
|
+
const out = {};
|
|
122
|
+
for (const [k, v] of Object.entries(value)) {
|
|
123
|
+
const s = serializeValue(v, registry);
|
|
124
|
+
if (s !== void 0) out[k] = s;
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
function serializeReactElement(element, registry) {
|
|
129
|
+
const { type, props } = element;
|
|
130
|
+
if (typeof type === "string") {
|
|
131
|
+
return { __re: "html", tag: type, props: serializePropsForHydration(props, registry) };
|
|
132
|
+
}
|
|
133
|
+
if (typeof type === "function") {
|
|
134
|
+
const componentCache = getComponentCache();
|
|
135
|
+
for (const [id, filePath] of registry.entries()) {
|
|
136
|
+
const info = componentCache.get(filePath);
|
|
137
|
+
if (!info?.isClientComponent) continue;
|
|
138
|
+
if (info.exportedName && type.name === info.exportedName) {
|
|
139
|
+
return {
|
|
140
|
+
__re: "client",
|
|
141
|
+
componentId: id,
|
|
142
|
+
props: serializePropsForHydration(props, registry)
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return void 0;
|
|
148
|
+
}
|
|
149
|
+
export {
|
|
150
|
+
renderElementToHtml
|
|
151
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* request-store.ts — Per-Request Server Context Store
|
|
3
|
+
*
|
|
4
|
+
* Provides a request-scoped store that server components can read via
|
|
5
|
+
* `useRequest()` during SSR. The store is populated by the SSR pipeline
|
|
6
|
+
* before rendering and cleared in a `finally` block after — preventing any
|
|
7
|
+
* cross-request contamination.
|
|
8
|
+
*
|
|
9
|
+
* Why globalThis?
|
|
10
|
+
* Node's module system may import this file multiple times when the page
|
|
11
|
+
* module and the nukejs package resolve to different copies (common in dev
|
|
12
|
+
* with tsx/tsImport). Using a well-known Symbol on globalThis guarantees
|
|
13
|
+
* all copies share the same store instance, exactly like html-store.ts.
|
|
14
|
+
*
|
|
15
|
+
* Request isolation:
|
|
16
|
+
* runWithRequestStore() creates a fresh store before rendering and clears
|
|
17
|
+
* it in the `finally` block, so concurrent requests cannot bleed into each
|
|
18
|
+
* other even if rendering throws.
|
|
19
|
+
*
|
|
20
|
+
* Headers in __n_data:
|
|
21
|
+
* A safe subset of headers is embedded in the HTML `__n_data` blob so
|
|
22
|
+
* client components can read them after hydration. Sensitive headers
|
|
23
|
+
* (cookie, authorization, proxy-authorization) are intentionally excluded
|
|
24
|
+
* from the client payload. The server-side store always has ALL headers.
|
|
25
|
+
*/
|
|
26
|
+
export interface RequestContext {
|
|
27
|
+
/** Full URL with query string (e.g. '/blog/hello?lang=en'). */
|
|
28
|
+
url: string;
|
|
29
|
+
/** Pathname only, no query string (e.g. '/blog/hello'). */
|
|
30
|
+
pathname: string;
|
|
31
|
+
/**
|
|
32
|
+
* Dynamic route segments matched by the file-system router.
|
|
33
|
+
* e.g. for `/blog/[slug]` → `{ slug: 'hello' }`
|
|
34
|
+
*/
|
|
35
|
+
params: Record<string, string | string[]>;
|
|
36
|
+
/**
|
|
37
|
+
* Query string parameters, parsed from the URL.
|
|
38
|
+
* Multi-value params (e.g. `?tag=a&tag=b`) become arrays.
|
|
39
|
+
* e.g. `{ lang: 'en', tag: ['a', 'b'] }`
|
|
40
|
+
*/
|
|
41
|
+
query: Record<string, string | string[]>;
|
|
42
|
+
/**
|
|
43
|
+
* Incoming request headers.
|
|
44
|
+
*
|
|
45
|
+
* Server-side (SSR): all headers from IncomingMessage.headers.
|
|
46
|
+
* Client-side: safe subset embedded in __n_data (cookie, authorization,
|
|
47
|
+
* proxy-authorization are stripped before serialisation).
|
|
48
|
+
*
|
|
49
|
+
* Multi-value headers are joined with ', '.
|
|
50
|
+
*/
|
|
51
|
+
headers: Record<string, string>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Normalises raw Node `IncomingMessage.headers` into a flat `Record<string,string>`.
|
|
55
|
+
* Array values (multi-value headers) are joined with `', '`.
|
|
56
|
+
* Undefined values are dropped.
|
|
57
|
+
*
|
|
58
|
+
* Used server-side so all headers — including cookies and auth tokens — are
|
|
59
|
+
* available to server components that need them.
|
|
60
|
+
*/
|
|
61
|
+
export declare function normaliseHeaders(raw: Record<string, string | string[] | undefined>): Record<string, string>;
|
|
62
|
+
/**
|
|
63
|
+
* Same as `normaliseHeaders` but additionally strips headers that must never
|
|
64
|
+
* appear in a serialised HTML document. Used when embedding headers in the
|
|
65
|
+
* `__n_data` blob so credentials cannot leak into cached or logged HTML pages.
|
|
66
|
+
*/
|
|
67
|
+
export declare function sanitiseHeaders(raw: Record<string, string | string[] | undefined>): Record<string, string>;
|
|
68
|
+
/**
|
|
69
|
+
* Runs `fn` inside the context of the given request, then clears the store.
|
|
70
|
+
* The store is set synchronously before `fn` is called, so any code that
|
|
71
|
+
* reads getRequestStore() during the synchronous phase of a server component
|
|
72
|
+
* (before its first `await`) will always see the correct context.
|
|
73
|
+
*/
|
|
74
|
+
export declare function runWithRequestStore<T>(ctx: RequestContext, fn: () => Promise<T>): Promise<T>;
|
|
75
|
+
/**
|
|
76
|
+
* Returns the current request context, or `null` when called outside of
|
|
77
|
+
* an active `runWithRequestStore` scope (e.g. in the browser, in tests,
|
|
78
|
+
* or in a client component).
|
|
79
|
+
*/
|
|
80
|
+
export declare function getRequestStore(): RequestContext | null;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const SENSITIVE_HEADERS = /* @__PURE__ */ new Set([
|
|
2
|
+
"cookie",
|
|
3
|
+
"authorization",
|
|
4
|
+
"proxy-authorization",
|
|
5
|
+
"set-cookie",
|
|
6
|
+
"x-api-key"
|
|
7
|
+
]);
|
|
8
|
+
function normaliseHeaders(raw) {
|
|
9
|
+
const out = {};
|
|
10
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
11
|
+
if (v === void 0) continue;
|
|
12
|
+
out[k] = Array.isArray(v) ? v.join(", ") : v;
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
function sanitiseHeaders(raw) {
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
19
|
+
if (SENSITIVE_HEADERS.has(k.toLowerCase())) continue;
|
|
20
|
+
if (v === void 0) continue;
|
|
21
|
+
out[k] = Array.isArray(v) ? v.join(", ") : v;
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
const KEY = /* @__PURE__ */ Symbol.for("__nukejs_request_store__");
|
|
26
|
+
const getGlobal = () => globalThis[KEY] ?? null;
|
|
27
|
+
const setGlobal = (ctx) => {
|
|
28
|
+
globalThis[KEY] = ctx;
|
|
29
|
+
};
|
|
30
|
+
async function runWithRequestStore(ctx, fn) {
|
|
31
|
+
setGlobal(ctx);
|
|
32
|
+
try {
|
|
33
|
+
return await fn();
|
|
34
|
+
} finally {
|
|
35
|
+
setGlobal(null);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function getRequestStore() {
|
|
39
|
+
return getGlobal();
|
|
40
|
+
}
|
|
41
|
+
export {
|
|
42
|
+
getRequestStore,
|
|
43
|
+
normaliseHeaders,
|
|
44
|
+
runWithRequestStore,
|
|
45
|
+
sanitiseHeaders
|
|
46
|
+
};
|
package/dist/router.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
function findAllRoutes(dir, baseDir = dir) {
|
|
4
|
+
if (!fs.existsSync(dir)) return [];
|
|
5
|
+
const routes = [];
|
|
6
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
7
|
+
const fullPath = path.join(dir, entry.name);
|
|
8
|
+
if (entry.isDirectory()) {
|
|
9
|
+
routes.push(...findAllRoutes(fullPath, baseDir));
|
|
10
|
+
} else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) {
|
|
11
|
+
const stem = entry.name.replace(/\.(tsx|ts)$/, "");
|
|
12
|
+
if (stem === "layout") continue;
|
|
13
|
+
routes.push(path.relative(baseDir, fullPath).replace(/\.(tsx|ts)$/, ""));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return routes;
|
|
17
|
+
}
|
|
18
|
+
function matchDynamicRoute(urlSegments, routePath) {
|
|
19
|
+
const routeSegments = routePath.split(path.sep);
|
|
20
|
+
if (routeSegments.at(-1) === "index") routeSegments.pop();
|
|
21
|
+
const params = {};
|
|
22
|
+
let ri = 0;
|
|
23
|
+
let ui = 0;
|
|
24
|
+
while (ri < routeSegments.length) {
|
|
25
|
+
const seg = routeSegments[ri];
|
|
26
|
+
const optCatchAll = seg.match(/^\[\[\.\.\.(.+)\]\]$/);
|
|
27
|
+
if (optCatchAll) {
|
|
28
|
+
params[optCatchAll[1]] = urlSegments.slice(ui);
|
|
29
|
+
return { params };
|
|
30
|
+
}
|
|
31
|
+
const optDynamic = seg.match(/^\[\[([^.][^\]]*)\]\]$/);
|
|
32
|
+
if (optDynamic) {
|
|
33
|
+
if (ui < urlSegments.length) {
|
|
34
|
+
params[optDynamic[1]] = urlSegments[ui++];
|
|
35
|
+
} else {
|
|
36
|
+
params[optDynamic[1]] = "";
|
|
37
|
+
}
|
|
38
|
+
ri++;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const catchAll = seg.match(/^\[\.\.\.(.+)\]$/);
|
|
42
|
+
if (catchAll) {
|
|
43
|
+
const remaining = urlSegments.slice(ui);
|
|
44
|
+
if (!remaining.length) return null;
|
|
45
|
+
params[catchAll[1]] = remaining;
|
|
46
|
+
return { params };
|
|
47
|
+
}
|
|
48
|
+
const dynamic = seg.match(/^\[(.+)\]$/);
|
|
49
|
+
if (dynamic) {
|
|
50
|
+
if (ui >= urlSegments.length) return null;
|
|
51
|
+
params[dynamic[1]] = urlSegments[ui++];
|
|
52
|
+
ri++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (ui >= urlSegments.length || seg !== urlSegments[ui]) return null;
|
|
56
|
+
ui++;
|
|
57
|
+
ri++;
|
|
58
|
+
}
|
|
59
|
+
return ui < urlSegments.length ? null : { params };
|
|
60
|
+
}
|
|
61
|
+
function getRouteSpecificity(routePath) {
|
|
62
|
+
return routePath.split(path.sep).reduce((score, seg) => {
|
|
63
|
+
if (seg.match(/^\[\[\.\.\.(.+)\]\]$/)) return score + 1;
|
|
64
|
+
if (seg.match(/^\[\.\.\.(.+)\]$/)) return score + 2;
|
|
65
|
+
if (seg.match(/^\[\[([^.][^\]]*)\]\]$/)) return score + 3;
|
|
66
|
+
if (seg.match(/^\[(.+)\]$/)) return score + 4;
|
|
67
|
+
return score + 5;
|
|
68
|
+
}, 0);
|
|
69
|
+
}
|
|
70
|
+
function isWithinBase(baseDir, filePath) {
|
|
71
|
+
const rel = path.relative(baseDir, filePath);
|
|
72
|
+
return Boolean(rel) && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
73
|
+
}
|
|
74
|
+
function matchRoute(urlPath, baseDir, extension = ".tsx") {
|
|
75
|
+
const normPath = urlPath.length > 1 ? urlPath.replace(/\/+$/, "") : urlPath;
|
|
76
|
+
const rawSegments = normPath === "/" ? [] : normPath.slice(1).split("/");
|
|
77
|
+
if (rawSegments.some((s) => s === ".." || s === ".")) return null;
|
|
78
|
+
const segments = rawSegments.length === 0 ? ["index"] : rawSegments;
|
|
79
|
+
const exactPath = path.join(baseDir, ...segments) + extension;
|
|
80
|
+
const exactStem = path.basename(exactPath, extension);
|
|
81
|
+
if (!isWithinBase(baseDir, exactPath)) return null;
|
|
82
|
+
if (exactStem !== "layout" && fs.existsSync(exactPath)) {
|
|
83
|
+
return { filePath: exactPath, params: {}, routePattern: segments.join("/") };
|
|
84
|
+
}
|
|
85
|
+
const sortedRoutes = findAllRoutes(baseDir).sort(
|
|
86
|
+
(a, b) => getRouteSpecificity(b) - getRouteSpecificity(a)
|
|
87
|
+
);
|
|
88
|
+
for (const route of sortedRoutes) {
|
|
89
|
+
const match = matchDynamicRoute(rawSegments, route);
|
|
90
|
+
if (!match) continue;
|
|
91
|
+
const filePath = path.join(baseDir, route) + extension;
|
|
92
|
+
if (!isWithinBase(baseDir, filePath)) continue;
|
|
93
|
+
if (fs.existsSync(filePath)) {
|
|
94
|
+
return { filePath, params: match.params, routePattern: route };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
function findLayoutsForRoute(routeFilePath, pagesDir) {
|
|
100
|
+
const layouts = [];
|
|
101
|
+
const rootLayout = path.join(pagesDir, "layout.tsx");
|
|
102
|
+
if (fs.existsSync(rootLayout)) layouts.push(rootLayout);
|
|
103
|
+
const relativePath = path.relative(pagesDir, path.dirname(routeFilePath));
|
|
104
|
+
if (!relativePath || relativePath === ".") return layouts;
|
|
105
|
+
const segments = relativePath.split(path.sep).filter((s) => s !== ".");
|
|
106
|
+
for (let i = 1; i <= segments.length; i++) {
|
|
107
|
+
const layoutPath = path.join(pagesDir, ...segments.slice(0, i), "layout.tsx");
|
|
108
|
+
if (fs.existsSync(layoutPath)) layouts.push(layoutPath);
|
|
109
|
+
}
|
|
110
|
+
return layouts;
|
|
111
|
+
}
|
|
112
|
+
export {
|
|
113
|
+
findAllRoutes,
|
|
114
|
+
findLayoutsForRoute,
|
|
115
|
+
getRouteSpecificity,
|
|
116
|
+
matchDynamicRoute,
|
|
117
|
+
matchRoute
|
|
118
|
+
};
|