nukejs 0.0.17 → 0.0.19
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 +898 -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 +161 -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 +70 -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 +9 -0
- package/dist/use-router.js +35 -0
- package/dist/utils.d.ts +26 -0
- package/dist/utils.js +61 -0
- package/package.json +1 -1
package/dist/config.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { pathToFileURL } from "url";
|
|
4
|
+
import { log } from "./logger.js";
|
|
5
|
+
async function loadConfig() {
|
|
6
|
+
const configPath = path.join(process.cwd(), "nuke.config.ts");
|
|
7
|
+
if (!fs.existsSync(configPath)) {
|
|
8
|
+
return {
|
|
9
|
+
serverDir: "./server",
|
|
10
|
+
port: 3e3,
|
|
11
|
+
debug: false
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const mod = await import(pathToFileURL(configPath).href);
|
|
16
|
+
const config = mod.default;
|
|
17
|
+
return {
|
|
18
|
+
serverDir: config.serverDir || "./server",
|
|
19
|
+
port: config.port || 3e3,
|
|
20
|
+
debug: config.debug ?? false
|
|
21
|
+
};
|
|
22
|
+
} catch (error) {
|
|
23
|
+
log.error("Error loading config:", error);
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export {
|
|
28
|
+
loadConfig
|
|
29
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { log } from "./logger.js";
|
|
2
|
+
function hmr() {
|
|
3
|
+
const es = new EventSource("/__hmr");
|
|
4
|
+
let reconnecting = false;
|
|
5
|
+
es.onopen = () => {
|
|
6
|
+
reconnecting = false;
|
|
7
|
+
log.info("[HMR] Connected");
|
|
8
|
+
};
|
|
9
|
+
es.onerror = () => {
|
|
10
|
+
if (reconnecting) return;
|
|
11
|
+
reconnecting = true;
|
|
12
|
+
es.close();
|
|
13
|
+
waitForReconnect();
|
|
14
|
+
};
|
|
15
|
+
document.addEventListener("visibilitychange", () => {
|
|
16
|
+
if (document.visibilityState !== "visible") return;
|
|
17
|
+
if (es.readyState === EventSource.OPEN) return;
|
|
18
|
+
if (reconnecting) return;
|
|
19
|
+
reconnecting = true;
|
|
20
|
+
es.close();
|
|
21
|
+
waitForReconnect(500, 20);
|
|
22
|
+
});
|
|
23
|
+
es.onmessage = async (event) => {
|
|
24
|
+
try {
|
|
25
|
+
const msg = JSON.parse(event.data);
|
|
26
|
+
if (msg.type === "restart") {
|
|
27
|
+
log.info("[HMR] Server restarting \u2014 waiting to reconnect...");
|
|
28
|
+
es.close();
|
|
29
|
+
waitForReconnect();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (msg.type === "reload") {
|
|
33
|
+
if (msg.url === "*") {
|
|
34
|
+
reloadStylesheets();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (patternMatchesPathname(msg.url, window.location.pathname)) {
|
|
38
|
+
log.info("[HMR] Page changed:", msg.url);
|
|
39
|
+
navigate(window.location.pathname + window.location.search);
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (msg.type === "layout-reload") {
|
|
44
|
+
const base = msg.base === "/" ? "" : msg.base;
|
|
45
|
+
const pathname = window.location.pathname;
|
|
46
|
+
const isUnder = pathname === (base || "/") || pathname.startsWith(base + "/");
|
|
47
|
+
if (isUnder) {
|
|
48
|
+
log.info("[HMR] Layout changed:", msg.base);
|
|
49
|
+
navigate(pathname + window.location.search);
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (msg.type === "replace") {
|
|
54
|
+
log.info("[HMR] Component changed:", msg.component);
|
|
55
|
+
navigate(window.location.pathname + window.location.search);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
} catch (err) {
|
|
59
|
+
log.error("[HMR] Message parse error:", err);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
let _navTimer = null;
|
|
64
|
+
let _navHref = null;
|
|
65
|
+
function navigate(href) {
|
|
66
|
+
_navHref = href;
|
|
67
|
+
if (_navTimer) clearTimeout(_navTimer);
|
|
68
|
+
_navTimer = setTimeout(() => {
|
|
69
|
+
_navTimer = null;
|
|
70
|
+
if (_navHref !== null) {
|
|
71
|
+
window.dispatchEvent(new CustomEvent("locationchange", { detail: { href: _navHref, hmr: true } }));
|
|
72
|
+
_navHref = null;
|
|
73
|
+
}
|
|
74
|
+
}, 50);
|
|
75
|
+
}
|
|
76
|
+
function patternMatchesPathname(pattern, pathname) {
|
|
77
|
+
const normPattern = pattern.length > 1 ? pattern.replace(/\/+$/, "") : pattern;
|
|
78
|
+
const normPathname = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
|
|
79
|
+
const segments = normPattern.replace(/^\//, "").split("/");
|
|
80
|
+
const regexParts = segments.map((seg) => {
|
|
81
|
+
if (/^\[\[\.\.\..+\]\]$/.test(seg)) return "(?:/.*)?";
|
|
82
|
+
if (/^\[\.\.\./.test(seg)) return "(?:/.+)";
|
|
83
|
+
if (/^\[\[/.test(seg)) return "(?:/[^/]*)?";
|
|
84
|
+
if (/^\[/.test(seg)) return "/[^/]+";
|
|
85
|
+
return "/" + seg.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
86
|
+
});
|
|
87
|
+
return new RegExp("^" + regexParts.join("") + "$").test(normPathname);
|
|
88
|
+
}
|
|
89
|
+
function waitForReconnect(intervalMs = 3e3, maxAttempts = 10) {
|
|
90
|
+
let attempts = 0;
|
|
91
|
+
const id = setInterval(async () => {
|
|
92
|
+
attempts++;
|
|
93
|
+
try {
|
|
94
|
+
const res = await fetch("/__hmr_ping", { cache: "no-store" });
|
|
95
|
+
if (res.ok) {
|
|
96
|
+
clearInterval(id);
|
|
97
|
+
log.info("[HMR] Server back \u2014 reloading");
|
|
98
|
+
window.location.reload();
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
}
|
|
102
|
+
if (attempts >= maxAttempts) {
|
|
103
|
+
clearInterval(id);
|
|
104
|
+
log.error("[HMR] Server did not come back after restart");
|
|
105
|
+
}
|
|
106
|
+
}, intervalMs);
|
|
107
|
+
}
|
|
108
|
+
function reloadStylesheets() {
|
|
109
|
+
const links = document.querySelectorAll('link[rel="stylesheet"]');
|
|
110
|
+
log.info(`[HMR] CSS changed \u2014 reloading ${links.length} stylesheet(s)`);
|
|
111
|
+
links.forEach((link) => {
|
|
112
|
+
const url = new URL(link.href);
|
|
113
|
+
url.searchParams.set("t", String(Date.now()));
|
|
114
|
+
link.href = url.toString();
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
hmr();
|
|
118
|
+
export {
|
|
119
|
+
hmr as default
|
|
120
|
+
};
|
package/dist/hmr.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { existsSync, watch } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { log } from "./logger.js";
|
|
4
|
+
import { invalidateComponentCache } from "./component-analyzer.js";
|
|
5
|
+
import { invalidateSplitBundle } from "./bundler.js";
|
|
6
|
+
const hmrClients = /* @__PURE__ */ new Set();
|
|
7
|
+
function broadcastHmr(payload) {
|
|
8
|
+
const data = `data: ${JSON.stringify(payload)}
|
|
9
|
+
|
|
10
|
+
`;
|
|
11
|
+
for (const client of hmrClients) {
|
|
12
|
+
try {
|
|
13
|
+
client.write(data);
|
|
14
|
+
} catch {
|
|
15
|
+
hmrClients.delete(client);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function pageFileToUrl(filename) {
|
|
20
|
+
const withoutPages = filename.slice("pages/".length);
|
|
21
|
+
const withoutExt = withoutPages.replace(/\.(tsx|ts)$/, "");
|
|
22
|
+
const url = withoutExt === "index" || withoutExt === "layout" ? "/" : "/" + withoutExt.replace(/\/index$/, "").replace(/\/layout$/, "").replace(/\\/g, "/");
|
|
23
|
+
return url;
|
|
24
|
+
}
|
|
25
|
+
function buildPayload(filename) {
|
|
26
|
+
const normalized = filename.replace(/\\/g, "/");
|
|
27
|
+
if (normalized.startsWith("pages/")) {
|
|
28
|
+
const stem = path.basename(normalized, path.extname(normalized));
|
|
29
|
+
if (stem === "_404" || stem === "_500") {
|
|
30
|
+
return { type: "replace", component: stem };
|
|
31
|
+
}
|
|
32
|
+
const url = pageFileToUrl(normalized);
|
|
33
|
+
if (stem === "layout") {
|
|
34
|
+
return { type: "layout-reload", base: url };
|
|
35
|
+
}
|
|
36
|
+
return { type: "reload", url };
|
|
37
|
+
}
|
|
38
|
+
const ext = path.extname(filename).toLowerCase();
|
|
39
|
+
if (ext === ".css" || ext === ".scss" || ext === ".sass" || ext === ".less") {
|
|
40
|
+
return { type: "reload", url: "*" };
|
|
41
|
+
}
|
|
42
|
+
const componentName = path.basename(filename, path.extname(filename));
|
|
43
|
+
return { type: "replace", component: componentName };
|
|
44
|
+
}
|
|
45
|
+
const pending = /* @__PURE__ */ new Map();
|
|
46
|
+
function watchDir(dir, label) {
|
|
47
|
+
if (!existsSync(dir)) return;
|
|
48
|
+
watch(dir, { recursive: true }, (_event, filename) => {
|
|
49
|
+
if (!filename) return;
|
|
50
|
+
if (pending.has(filename)) clearTimeout(pending.get(filename));
|
|
51
|
+
const timeout = setTimeout(() => {
|
|
52
|
+
const payload = buildPayload(filename);
|
|
53
|
+
log.info(`[HMR] ${label} changed: ${filename}`, JSON.stringify(payload));
|
|
54
|
+
if (dir) invalidateComponentCache(path.resolve(dir, filename));
|
|
55
|
+
invalidateSplitBundle();
|
|
56
|
+
broadcastHmr(payload);
|
|
57
|
+
pending.delete(filename);
|
|
58
|
+
}, 100);
|
|
59
|
+
pending.set(filename, timeout);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function broadcastRestart() {
|
|
63
|
+
broadcastHmr({ type: "restart" });
|
|
64
|
+
return new Promise((resolve) => setTimeout(resolve, 120));
|
|
65
|
+
}
|
|
66
|
+
export {
|
|
67
|
+
broadcastRestart,
|
|
68
|
+
hmrClients,
|
|
69
|
+
watchDir
|
|
70
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* html-store.ts — Per-Request HTML Head Store
|
|
3
|
+
*
|
|
4
|
+
* Provides a request-scoped store that server components can write to via
|
|
5
|
+
* `useHtml()` during SSR. The accumulated values are flushed into the
|
|
6
|
+
* rendered HTML document after the component tree is fully rendered.
|
|
7
|
+
*
|
|
8
|
+
* Why globalThis?
|
|
9
|
+
* Node's module system may import this file multiple times if the page
|
|
10
|
+
* module and the nukejs package resolve to different copies (e.g. when
|
|
11
|
+
* running from source in dev with tsx). Using a well-known Symbol on
|
|
12
|
+
* globalThis guarantees all copies share the same store instance.
|
|
13
|
+
*
|
|
14
|
+
* Request isolation:
|
|
15
|
+
* runWithHtmlStore() creates a fresh store before rendering and clears it
|
|
16
|
+
* in the `finally` block, so concurrent requests cannot bleed into each other.
|
|
17
|
+
*
|
|
18
|
+
* Title resolution:
|
|
19
|
+
* Layouts and pages can both call useHtml({ title: … }). Layouts typically
|
|
20
|
+
* pass a template function:
|
|
21
|
+
*
|
|
22
|
+
* useHtml({ title: (prev) => `${prev} | Acme` })
|
|
23
|
+
*
|
|
24
|
+
* Operations are collected in render order (outermost layout first, page
|
|
25
|
+
* last) then resolved *in reverse* so the page's string value is the base
|
|
26
|
+
* and layout template functions wrap outward.
|
|
27
|
+
*/
|
|
28
|
+
/** A page sets a literal string; a layout wraps with a template function. */
|
|
29
|
+
export type TitleValue = string | ((prev: string) => string);
|
|
30
|
+
export interface HtmlAttrs {
|
|
31
|
+
lang?: string;
|
|
32
|
+
class?: string;
|
|
33
|
+
style?: string;
|
|
34
|
+
dir?: string;
|
|
35
|
+
[attr: string]: string | undefined;
|
|
36
|
+
}
|
|
37
|
+
export interface BodyAttrs {
|
|
38
|
+
class?: string;
|
|
39
|
+
style?: string;
|
|
40
|
+
[attr: string]: string | undefined;
|
|
41
|
+
}
|
|
42
|
+
export interface MetaTag {
|
|
43
|
+
name?: string;
|
|
44
|
+
property?: string;
|
|
45
|
+
httpEquiv?: string;
|
|
46
|
+
charset?: string;
|
|
47
|
+
content?: string;
|
|
48
|
+
[attr: string]: string | undefined;
|
|
49
|
+
}
|
|
50
|
+
export interface LinkTag {
|
|
51
|
+
rel?: string;
|
|
52
|
+
href?: string;
|
|
53
|
+
type?: string;
|
|
54
|
+
media?: string;
|
|
55
|
+
as?: string;
|
|
56
|
+
crossOrigin?: string;
|
|
57
|
+
integrity?: string;
|
|
58
|
+
hrefLang?: string;
|
|
59
|
+
sizes?: string;
|
|
60
|
+
[attr: string]: string | undefined;
|
|
61
|
+
}
|
|
62
|
+
export interface ScriptTag {
|
|
63
|
+
src?: string;
|
|
64
|
+
content?: string;
|
|
65
|
+
type?: string;
|
|
66
|
+
defer?: boolean;
|
|
67
|
+
async?: boolean;
|
|
68
|
+
crossOrigin?: string;
|
|
69
|
+
integrity?: string;
|
|
70
|
+
noModule?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Where to inject the script in the document.
|
|
73
|
+
* 'head' (default) — placed inside <head>, inside the <!--n-head--> block.
|
|
74
|
+
* 'body' — placed at the very end of <body>, inside the
|
|
75
|
+
* <!--n-body-scripts--> block, just before </body>.
|
|
76
|
+
*/
|
|
77
|
+
position?: 'head' | 'body';
|
|
78
|
+
}
|
|
79
|
+
export interface StyleTag {
|
|
80
|
+
content?: string;
|
|
81
|
+
media?: string;
|
|
82
|
+
}
|
|
83
|
+
export interface HtmlStore {
|
|
84
|
+
/** Collected in render order; resolved in reverse so the page title wins. */
|
|
85
|
+
titleOps: TitleValue[];
|
|
86
|
+
/** Attributes merged onto <html>; last write wins per attribute. */
|
|
87
|
+
htmlAttrs: HtmlAttrs;
|
|
88
|
+
/** Attributes merged onto <body>; last write wins per attribute. */
|
|
89
|
+
bodyAttrs: BodyAttrs;
|
|
90
|
+
/** Accumulated in render order: layouts first, page last. */
|
|
91
|
+
meta: MetaTag[];
|
|
92
|
+
link: LinkTag[];
|
|
93
|
+
script: ScriptTag[];
|
|
94
|
+
style: StyleTag[];
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Runs `fn` inside a fresh HTML store and returns the collected values.
|
|
98
|
+
*
|
|
99
|
+
* Usage in SSR:
|
|
100
|
+
* ```ts
|
|
101
|
+
* const store = await runWithHtmlStore(async () => {
|
|
102
|
+
* appHtml = await renderElementToHtml(element, ctx);
|
|
103
|
+
* });
|
|
104
|
+
* // store.titleOps, store.meta, etc. are now populated
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
export declare function runWithHtmlStore(fn: () => Promise<void>): Promise<HtmlStore>;
|
|
108
|
+
/**
|
|
109
|
+
* Returns the current request's store, or `undefined` if called outside of
|
|
110
|
+
* a `runWithHtmlStore` context (e.g. in the browser or in a test).
|
|
111
|
+
*/
|
|
112
|
+
export declare function getHtmlStore(): HtmlStore | undefined;
|
|
113
|
+
/**
|
|
114
|
+
* Resolves the final page title from a list of title operations.
|
|
115
|
+
*
|
|
116
|
+
* Operations are walked in *reverse* so the page's value is the starting
|
|
117
|
+
* point and layout template functions wrap it outward:
|
|
118
|
+
*
|
|
119
|
+
* ```
|
|
120
|
+
* ops = [ (p) => `${p} | Acme`, 'About' ] ← layout pushed first, page last
|
|
121
|
+
* Walk in reverse:
|
|
122
|
+
* i=1: op = 'About' → title = 'About'
|
|
123
|
+
* i=0: op = (p) => … → title = 'About | Acme'
|
|
124
|
+
* ```
|
|
125
|
+
*
|
|
126
|
+
* @param fallback Used when ops is empty (e.g. a page that didn't call useHtml).
|
|
127
|
+
*/
|
|
128
|
+
export declare function resolveTitle(ops: TitleValue[], fallback?: string): string;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const KEY = /* @__PURE__ */ Symbol.for("__nukejs_html_store__");
|
|
2
|
+
const getGlobal = () => globalThis[KEY] ?? null;
|
|
3
|
+
const setGlobal = (store) => {
|
|
4
|
+
globalThis[KEY] = store;
|
|
5
|
+
};
|
|
6
|
+
function emptyStore() {
|
|
7
|
+
return {
|
|
8
|
+
titleOps: [],
|
|
9
|
+
htmlAttrs: {},
|
|
10
|
+
bodyAttrs: {},
|
|
11
|
+
meta: [],
|
|
12
|
+
link: [],
|
|
13
|
+
script: [],
|
|
14
|
+
style: []
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
async function runWithHtmlStore(fn) {
|
|
18
|
+
setGlobal(emptyStore());
|
|
19
|
+
try {
|
|
20
|
+
await fn();
|
|
21
|
+
return { ...getGlobal() ?? emptyStore() };
|
|
22
|
+
} finally {
|
|
23
|
+
setGlobal(null);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function getHtmlStore() {
|
|
27
|
+
return getGlobal() ?? void 0;
|
|
28
|
+
}
|
|
29
|
+
function resolveTitle(ops, fallback = "") {
|
|
30
|
+
let title = fallback;
|
|
31
|
+
for (let i = ops.length - 1; i >= 0; i--) {
|
|
32
|
+
const op = ops[i];
|
|
33
|
+
title = typeof op === "string" ? op : op(title);
|
|
34
|
+
}
|
|
35
|
+
return title;
|
|
36
|
+
}
|
|
37
|
+
export {
|
|
38
|
+
getHtmlStore,
|
|
39
|
+
resolveTitle,
|
|
40
|
+
runWithHtmlStore
|
|
41
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { pathToFileURL } from "url";
|
|
4
|
+
import { log } from "./logger.js";
|
|
5
|
+
import { matchRoute } from "./router.js";
|
|
6
|
+
function discoverApiPrefixes(serverDir) {
|
|
7
|
+
if (!fs.existsSync(serverDir)) {
|
|
8
|
+
log.warn("Server directory not found:", serverDir);
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
const entries = fs.readdirSync(serverDir, { withFileTypes: true });
|
|
12
|
+
const prefixes = [];
|
|
13
|
+
for (const e of entries) {
|
|
14
|
+
if (e.isDirectory()) {
|
|
15
|
+
prefixes.push({ prefix: `/${e.name}`, directory: path.join(serverDir, e.name) });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
for (const e of entries) {
|
|
19
|
+
if (e.isFile() && (e.name.endsWith(".ts") || e.name.endsWith(".tsx")) && e.name !== "index.ts" && e.name !== "index.tsx") {
|
|
20
|
+
const stem = e.name.replace(/\.tsx?$/, "");
|
|
21
|
+
prefixes.push({
|
|
22
|
+
prefix: `/${stem}`,
|
|
23
|
+
directory: serverDir,
|
|
24
|
+
filePath: path.join(serverDir, e.name)
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (fs.existsSync(path.join(serverDir, "index.ts"))) {
|
|
29
|
+
prefixes.push({ prefix: "", directory: serverDir });
|
|
30
|
+
}
|
|
31
|
+
return prefixes;
|
|
32
|
+
}
|
|
33
|
+
const MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
34
|
+
async function parseBody(req) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
let body = "";
|
|
37
|
+
let bytes = 0;
|
|
38
|
+
req.on("data", (chunk) => {
|
|
39
|
+
bytes += chunk.length;
|
|
40
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
41
|
+
req.destroy();
|
|
42
|
+
return reject(new Error("Request body too large"));
|
|
43
|
+
}
|
|
44
|
+
body += chunk.toString();
|
|
45
|
+
});
|
|
46
|
+
req.on("end", () => {
|
|
47
|
+
try {
|
|
48
|
+
if (body && req.headers["content-type"]?.includes("application/json")) {
|
|
49
|
+
const parsed = JSON.parse(body);
|
|
50
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
51
|
+
delete parsed.__proto__;
|
|
52
|
+
delete parsed.constructor;
|
|
53
|
+
}
|
|
54
|
+
resolve(parsed);
|
|
55
|
+
} else {
|
|
56
|
+
resolve(body);
|
|
57
|
+
}
|
|
58
|
+
} catch (err) {
|
|
59
|
+
reject(err);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
req.on("error", reject);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function parseQuery(url, port) {
|
|
66
|
+
const query = {};
|
|
67
|
+
new URL(url, `http://localhost:${port}`).searchParams.forEach((v, k) => {
|
|
68
|
+
query[k] = v;
|
|
69
|
+
});
|
|
70
|
+
return query;
|
|
71
|
+
}
|
|
72
|
+
function enhanceResponse(res) {
|
|
73
|
+
const apiRes = res;
|
|
74
|
+
apiRes.json = function(data, statusCode = 200) {
|
|
75
|
+
this.statusCode = statusCode;
|
|
76
|
+
this.setHeader("Content-Type", "application/json");
|
|
77
|
+
this.end(JSON.stringify(data));
|
|
78
|
+
};
|
|
79
|
+
apiRes.status = function(code) {
|
|
80
|
+
this.statusCode = code;
|
|
81
|
+
return this;
|
|
82
|
+
};
|
|
83
|
+
return apiRes;
|
|
84
|
+
}
|
|
85
|
+
function respondOptions(res) {
|
|
86
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
87
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
|
|
88
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
89
|
+
res.statusCode = 204;
|
|
90
|
+
res.end();
|
|
91
|
+
}
|
|
92
|
+
function matchApiPrefix(url, apiPrefixes) {
|
|
93
|
+
for (const prefix of apiPrefixes) {
|
|
94
|
+
if (prefix.prefix === "") {
|
|
95
|
+
const claimedByOther = apiPrefixes.some(
|
|
96
|
+
(p) => p.prefix !== "" && url.startsWith(p.prefix)
|
|
97
|
+
);
|
|
98
|
+
if (!claimedByOther) return { prefix, apiPath: url || "/" };
|
|
99
|
+
} else if (url.startsWith(prefix.prefix)) {
|
|
100
|
+
return { prefix, apiPath: url.slice(prefix.prefix.length) || "/" };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
async function importFreshInDev(filePath) {
|
|
106
|
+
const { tsImport } = await import("tsx/esm/api");
|
|
107
|
+
return await tsImport(
|
|
108
|
+
pathToFileURL(filePath).href,
|
|
109
|
+
{ parentURL: import.meta.url }
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
function createApiHandler({ apiPrefixes, port, isDev }) {
|
|
113
|
+
return async function handleApiRoute(url, req, res) {
|
|
114
|
+
const apiRes = enhanceResponse(res);
|
|
115
|
+
const apiMatch = matchApiPrefix(url, apiPrefixes);
|
|
116
|
+
if (!apiMatch) {
|
|
117
|
+
apiRes.json({ error: "API endpoint not found" }, 404);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const { prefix, apiPath } = apiMatch;
|
|
121
|
+
let filePath = null;
|
|
122
|
+
let params = {};
|
|
123
|
+
if (prefix.filePath) {
|
|
124
|
+
filePath = prefix.filePath;
|
|
125
|
+
}
|
|
126
|
+
if (!filePath && prefix.prefix === "" && apiPath === "/") {
|
|
127
|
+
const indexPath = path.join(prefix.directory, "index.ts");
|
|
128
|
+
if (fs.existsSync(indexPath)) filePath = indexPath;
|
|
129
|
+
}
|
|
130
|
+
if (!filePath) {
|
|
131
|
+
const routeMatch = matchRoute(apiPath, prefix.directory, ".ts") ?? matchRoute(apiPath, prefix.directory, ".tsx");
|
|
132
|
+
if (routeMatch) {
|
|
133
|
+
filePath = routeMatch.filePath;
|
|
134
|
+
params = routeMatch.params;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (!filePath) {
|
|
138
|
+
apiRes.json({ error: "API endpoint not found" }, 404);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const method = (req.method || "GET").toUpperCase();
|
|
143
|
+
log.verbose(`API ${method} ${url} -> ${path.relative(process.cwd(), filePath)}`);
|
|
144
|
+
if (method === "OPTIONS") {
|
|
145
|
+
respondOptions(apiRes);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const apiReq = req;
|
|
149
|
+
apiReq.body = await parseBody(req);
|
|
150
|
+
apiReq.params = params;
|
|
151
|
+
apiReq.query = parseQuery(url, port);
|
|
152
|
+
const apiModule = isDev ? await importFreshInDev(filePath) : await import(pathToFileURL(filePath).href);
|
|
153
|
+
const handler = apiModule[method] ?? apiModule.default;
|
|
154
|
+
if (!handler) {
|
|
155
|
+
apiRes.json({ error: `Method ${method} not allowed` }, 405);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
await handler(apiReq, apiRes);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
log.error("API Error:", error);
|
|
161
|
+
apiRes.json({ error: "Internal server error" }, 500);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
export {
|
|
166
|
+
createApiHandler,
|
|
167
|
+
discoverApiPrefixes,
|
|
168
|
+
enhanceResponse,
|
|
169
|
+
matchApiPrefix,
|
|
170
|
+
parseBody,
|
|
171
|
+
parseQuery
|
|
172
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { createStore, useStore } from './store';
|
|
2
|
+
export type { Store } from './store';
|
|
3
|
+
export { useHtml } from './use-html';
|
|
4
|
+
export type { HtmlOptions } from './use-html';
|
|
5
|
+
export type { TitleValue, HtmlAttrs, BodyAttrs, MetaTag, LinkTag, ScriptTag, StyleTag, } from './html-store';
|
|
6
|
+
export { default as useRouter } from './use-router';
|
|
7
|
+
export { useRequest } from './use-request';
|
|
8
|
+
export type { RequestContext } from './use-request';
|
|
9
|
+
export { normaliseHeaders, sanitiseHeaders, getRequestStore } from './request-store';
|
|
10
|
+
export { default as Link } from './Link';
|
|
11
|
+
export { setupLocationChangeMonitor, initRuntime } from './bundle';
|
|
12
|
+
export type { RuntimeData } from './bundle';
|
|
13
|
+
export { escapeHtml } from './utils';
|
|
14
|
+
export { ansi, c, log, setDebugLevel, getDebugLevel } from './logger';
|
|
15
|
+
export type { DebugLevel } from './logger';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createStore, useStore } from "./store.js";
|
|
2
|
+
import { useHtml } from "./use-html.js";
|
|
3
|
+
import { default as default2 } from "./use-router.js";
|
|
4
|
+
import { useRequest } from "./use-request.js";
|
|
5
|
+
import { normaliseHeaders, sanitiseHeaders, getRequestStore } from "./request-store.js";
|
|
6
|
+
import { default as default3 } from "./Link.js";
|
|
7
|
+
import { setupLocationChangeMonitor, initRuntime } from "./bundle.js";
|
|
8
|
+
import { escapeHtml } from "./utils.js";
|
|
9
|
+
import { ansi, c, log, setDebugLevel, getDebugLevel } from "./logger.js";
|
|
10
|
+
export {
|
|
11
|
+
default3 as Link,
|
|
12
|
+
ansi,
|
|
13
|
+
c,
|
|
14
|
+
createStore,
|
|
15
|
+
escapeHtml,
|
|
16
|
+
getDebugLevel,
|
|
17
|
+
getRequestStore,
|
|
18
|
+
initRuntime,
|
|
19
|
+
log,
|
|
20
|
+
normaliseHeaders,
|
|
21
|
+
sanitiseHeaders,
|
|
22
|
+
setDebugLevel,
|
|
23
|
+
setupLocationChangeMonitor,
|
|
24
|
+
useHtml,
|
|
25
|
+
useRequest,
|
|
26
|
+
default2 as useRouter,
|
|
27
|
+
useStore
|
|
28
|
+
};
|
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* logger.ts — ANSI-Coloured Levelled Logger
|
|
3
|
+
*
|
|
4
|
+
* Provides a small set of server-side logging utilities used throughout NukeJS.
|
|
5
|
+
*
|
|
6
|
+
* Debug levels (set via nuke.config.ts `debug` field):
|
|
7
|
+
* false — silent, nothing printed
|
|
8
|
+
* 'error' — error() only
|
|
9
|
+
* 'info' — info(), warn(), error()
|
|
10
|
+
* true — verbose: all of the above plus verbose()
|
|
11
|
+
*
|
|
12
|
+
* The level can be read back with `getDebugLevel()` and is also forwarded to
|
|
13
|
+
* the browser client (as a string) so server and client log at the same level.
|
|
14
|
+
*/
|
|
15
|
+
/** Map of named ANSI colour/style escape codes. */
|
|
16
|
+
export declare const ansi: {
|
|
17
|
+
readonly reset: "\u001B[0m";
|
|
18
|
+
readonly bold: "\u001B[1m";
|
|
19
|
+
readonly dim: "\u001B[2m";
|
|
20
|
+
readonly black: "\u001B[30m";
|
|
21
|
+
readonly red: "\u001B[31m";
|
|
22
|
+
readonly green: "\u001B[32m";
|
|
23
|
+
readonly yellow: "\u001B[33m";
|
|
24
|
+
readonly blue: "\u001B[34m";
|
|
25
|
+
readonly magenta: "\u001B[35m";
|
|
26
|
+
readonly cyan: "\u001B[36m";
|
|
27
|
+
readonly white: "\u001B[37m";
|
|
28
|
+
readonly gray: "\u001B[90m";
|
|
29
|
+
readonly orange: "\u001B[38;5;208m";
|
|
30
|
+
readonly bgBlue: "\u001B[44m";
|
|
31
|
+
readonly bgGreen: "\u001B[42m";
|
|
32
|
+
readonly bgMagenta: "\u001B[45m";
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Wraps `text` in the ANSI sequence for `color`, optionally bold.
|
|
36
|
+
* Always appends the reset sequence so colour does not bleed into surrounding
|
|
37
|
+
* terminal output.
|
|
38
|
+
*/
|
|
39
|
+
export declare function c(color: keyof typeof ansi, text: string, bold?: boolean): string;
|
|
40
|
+
/** false = silent | 'error' = errors only | 'info' = startup + errors | true = verbose */
|
|
41
|
+
export type DebugLevel = false | 'error' | 'info' | true;
|
|
42
|
+
/** Sets the active log level. Called once after the config is loaded. */
|
|
43
|
+
export declare function setDebugLevel(level: DebugLevel): void;
|
|
44
|
+
/** Returns the currently active log level. */
|
|
45
|
+
export declare function getDebugLevel(): DebugLevel;
|
|
46
|
+
/**
|
|
47
|
+
* Structured log object with four severity methods.
|
|
48
|
+
* Each method is a no-op unless the current `_level` allows it.
|
|
49
|
+
*/
|
|
50
|
+
export declare const log: {
|
|
51
|
+
/** Trace-level detail: component IDs, route matching, bundle paths. */
|
|
52
|
+
verbose(...args: any[]): void;
|
|
53
|
+
/** Startup messages, route tables, config summary. */
|
|
54
|
+
info(...args: any[]): void;
|
|
55
|
+
/** Non-fatal issues: missing middleware, unrecognised config keys. */
|
|
56
|
+
warn(...args: any[]): void;
|
|
57
|
+
/** Errors that produce a 500 response or crash the build. */
|
|
58
|
+
error(...args: any[]): void;
|
|
59
|
+
};
|