nukejs 0.0.19 → 0.0.21
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 +37 -7
- package/bin/index.mjs +44 -5
- package/dist/build-cloudflare.js +674 -0
- package/dist/build-common.js +51 -16
- package/dist/bundle.js +3 -1
- package/dist/http-server.js +25 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ NukeJS gives you:
|
|
|
40
40
|
| **SPA navigation** | Client-side page transitions after first load |
|
|
41
41
|
| **Hot module replacement** | Instant page updates during development |
|
|
42
42
|
| **Zero config** | Works out of the box; `nuke.config.ts` for overrides |
|
|
43
|
-
| **Deploy anywhere** | Node.js or
|
|
43
|
+
| **Deploy anywhere** | Node.js, Vercel, or Cloudflare — zero config |
|
|
44
44
|
|
|
45
45
|
### The core idea
|
|
46
46
|
|
|
@@ -442,7 +442,8 @@ export async function GET(req: ApiRequest, res: ApiResponse) {
|
|
|
442
442
|
}
|
|
443
443
|
|
|
444
444
|
export async function POST(req: ApiRequest, res: ApiResponse) {
|
|
445
|
-
const
|
|
445
|
+
const body = await req.json();
|
|
446
|
+
const user = await db.createUser(body);
|
|
446
447
|
res.json(user, 201);
|
|
447
448
|
}
|
|
448
449
|
```
|
|
@@ -464,14 +465,23 @@ export async function DELETE(req: ApiRequest, res: ApiResponse) {
|
|
|
464
465
|
|
|
465
466
|
### Request object
|
|
466
467
|
|
|
467
|
-
| Property | Type | Description |
|
|
468
|
+
| Property / Method | Type | Description |
|
|
468
469
|
|---|---|---|
|
|
469
|
-
| `req.
|
|
470
|
+
| `req.json<T>()` | `Promise<T>` | Parse the request body as JSON (10 MB limit, prototype-pollution guard) |
|
|
471
|
+
| `req.text()` | `Promise<string>` | Read the request body as a UTF-8 string (10 MB limit) |
|
|
472
|
+
| `req.buffer()` | `Promise<Buffer>` | Read the request body as a raw `Buffer` — use this for binary or multipart data |
|
|
470
473
|
| `req.params` | `Record<string, string \| string[]>` | Dynamic route segments |
|
|
471
474
|
| `req.query` | `Record<string, string>` | URL search params |
|
|
472
475
|
| `req.method` | `string` | HTTP method |
|
|
473
476
|
| `req.headers` | `IncomingHttpHeaders` | Request headers |
|
|
474
477
|
|
|
478
|
+
> **Multipart / file uploads:** body helpers do not parse `multipart/form-data`. Pipe `req` directly into a multipart parser instead:
|
|
479
|
+
> ```ts
|
|
480
|
+
> import busboy from 'busboy';
|
|
481
|
+
> const bb = busboy({ headers: req.headers });
|
|
482
|
+
> req.pipe(bb);
|
|
483
|
+
> ```
|
|
484
|
+
|
|
475
485
|
### Response object
|
|
476
486
|
|
|
477
487
|
| Method | Description |
|
|
@@ -550,8 +560,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|
|
550
560
|
| `nuke dev` | Served by the built-in middleware before any API or SSR routing |
|
|
551
561
|
| `nuke build` (Node) | Copied to `dist/static/` and served by the production HTTP server |
|
|
552
562
|
| `nuke build` (Vercel) | Copied to `.vercel/output/static/` — served by Vercel's CDN, no function invocation |
|
|
553
|
-
|
|
554
|
-
On Vercel, public files receive the same zero-latency CDN treatment as `__n.js`.
|
|
563
|
+
| `nuke build` (Cloudflare) | Copied to `.cloudflare/output/static/` — served by Cloudflare's CDN, no Worker invocation |
|
|
555
564
|
|
|
556
565
|
---
|
|
557
566
|
|
|
@@ -1022,7 +1031,28 @@ dist/
|
|
|
1022
1031
|
|
|
1023
1032
|
### Vercel
|
|
1024
1033
|
|
|
1025
|
-
Just import the code from GitHub.
|
|
1034
|
+
Just import the code from GitHub. NukeJS detects the Vercel environment automatically and builds the right output — no configuration needed.
|
|
1035
|
+
|
|
1036
|
+
### Cloudflare Workers & Pages
|
|
1037
|
+
|
|
1038
|
+
Just import the code from GitHub. NukeJS detects the Cloudflare environment automatically and builds the right output — no configuration needed.
|
|
1039
|
+
|
|
1040
|
+
The build output goes to `.cloudflare/output/`:
|
|
1041
|
+
|
|
1042
|
+
```
|
|
1043
|
+
.cloudflare/output/
|
|
1044
|
+
├── _worker.mjs # Single ESM Cloudflare Worker (all routes bundled)
|
|
1045
|
+
└── static/
|
|
1046
|
+
├── __n.js # NukeJS client runtime
|
|
1047
|
+
├── __client-component/ # Bundled "use client" component files
|
|
1048
|
+
└── <app/public files> # Copied from app/public/ at build time
|
|
1049
|
+
```
|
|
1050
|
+
|
|
1051
|
+
Static files in `static/` are served directly by Cloudflare's CDN — the Worker is only invoked for pages and API routes.
|
|
1052
|
+
|
|
1053
|
+
> **Cloudflare Pages (recommended):** Set your build output directory to `.cloudflare/output` in the Pages dashboard. Static assets are served via CDN automatically through the `ASSETS` binding.
|
|
1054
|
+
|
|
1055
|
+
> **Cloudflare Workers:** Use `wrangler deploy` as your deploy command. Static assets are inlined into the worker bundle at build time — no separate CDN step required.
|
|
1026
1056
|
|
|
1027
1057
|
### Environment variables
|
|
1028
1058
|
|
package/bin/index.mjs
CHANGED
|
@@ -106,21 +106,60 @@ if (!arg || arg === 'dev') {
|
|
|
106
106
|
runWithTsx(devScript, { ENVIRONMENT: 'development' });
|
|
107
107
|
|
|
108
108
|
} else if (arg === 'build') {
|
|
109
|
-
// nuke build → run
|
|
109
|
+
// nuke build [--cloudflare|--vercel] → run the appropriate compiled build script.
|
|
110
|
+
//
|
|
111
|
+
// Target selection order:
|
|
112
|
+
// 1. --cloudflare / --vercel flag (explicit CLI override)
|
|
113
|
+
// 2. package.json "nuke": { "target": "cloudflare"|"vercel" } (project config)
|
|
114
|
+
// 3. CF_PAGES / CLOUDFLARE_WORKERS env vars (Cloudflare Pages CI)
|
|
115
|
+
// 4. VERCEL / VERCEL_ENV / NOW_BUILDER env vars (Vercel CI)
|
|
116
|
+
// 5. Default: Node.js build
|
|
117
|
+
|
|
118
|
+
const extraArgs = process.argv.slice(3);
|
|
119
|
+
|
|
120
|
+
let pkgTarget = null;
|
|
121
|
+
try {
|
|
122
|
+
const pkgPath = path.join(process.cwd(), 'package.json');
|
|
123
|
+
if (fs.existsSync(pkgPath)) {
|
|
124
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
125
|
+
pkgTarget = pkg?.nuke?.target ?? null;
|
|
126
|
+
}
|
|
127
|
+
} catch { /* malformed package.json — ignore, fall through */ }
|
|
128
|
+
|
|
129
|
+
const isCloudflare = !!(
|
|
130
|
+
extraArgs.includes('--cloudflare') ||
|
|
131
|
+
pkgTarget === 'cloudflare' ||
|
|
132
|
+
process.env.CLOUDFLARE_ACCOUNT_ID || // injected by all Cloudflare CI (Workers + Pages)
|
|
133
|
+
process.env.CLOUDFLARE_API_TOKEN || // injected by all Cloudflare CI
|
|
134
|
+
process.env.WORKERS_CI || // Cloudflare Workers CI
|
|
135
|
+
process.env.CF_PAGES ||
|
|
136
|
+
process.env.CF_PAGES_BRANCH ||
|
|
137
|
+
process.env.CF_PAGES_COMMIT_SHA ||
|
|
138
|
+
process.env.CF_PAGES_URL ||
|
|
139
|
+
process.env.CLOUDFLARE_WORKERS
|
|
140
|
+
);
|
|
141
|
+
|
|
110
142
|
const isVercel = !!(
|
|
143
|
+
extraArgs.includes('--vercel') ||
|
|
144
|
+
pkgTarget === 'vercel' ||
|
|
111
145
|
process.env.VERCEL ||
|
|
112
146
|
process.env.VERCEL_ENV ||
|
|
113
147
|
process.env.NOW_BUILDER
|
|
114
148
|
);
|
|
115
149
|
|
|
116
|
-
|
|
117
|
-
|
|
150
|
+
// Resolve the target once so the spawned build script can read it too.
|
|
151
|
+
const nukeTarget = isCloudflare ? 'cloudflare' : isVercel ? 'vercel' : 'node';
|
|
152
|
+
|
|
153
|
+
if (isCloudflare) {
|
|
154
|
+
runWithNode(path.join(distDir, 'build-cloudflare.js'), { NUKE_TARGET: nukeTarget });
|
|
155
|
+
} else if (isVercel) {
|
|
156
|
+
runWithNode(path.join(distDir, 'build-vercel.js'), { NUKE_TARGET: nukeTarget });
|
|
118
157
|
} else {
|
|
119
|
-
runWithNode(path.join(distDir, 'build-node.js'));
|
|
158
|
+
runWithNode(path.join(distDir, 'build-node.js'), { NUKE_TARGET: nukeTarget });
|
|
120
159
|
}
|
|
121
160
|
|
|
122
161
|
} else {
|
|
123
162
|
console.error(`\n ✖ Unknown command: "${arg}"`);
|
|
124
|
-
console.error(` Usage: nuke [dev|build]\n`);
|
|
163
|
+
console.error(` Usage: nuke [dev|build [--cloudflare|--vercel]]\n`);
|
|
125
164
|
process.exit(1);
|
|
126
165
|
}
|
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { build } from "esbuild";
|
|
5
|
+
import { loadConfig } from "./config.js";
|
|
6
|
+
import {
|
|
7
|
+
walkFiles,
|
|
8
|
+
analyzeFile,
|
|
9
|
+
collectServerPages,
|
|
10
|
+
collectGlobalClientRegistry,
|
|
11
|
+
bundleClientComponents,
|
|
12
|
+
findPageLayouts,
|
|
13
|
+
buildPerPageRegistry,
|
|
14
|
+
makePageAdapterSource,
|
|
15
|
+
buildCombinedBundle,
|
|
16
|
+
copyPublicFiles
|
|
17
|
+
} from "./build-common.js";
|
|
18
|
+
const OUTPUT_DIR = path.resolve(".cloudflare/output");
|
|
19
|
+
const STATIC_DIR = path.join(OUTPUT_DIR, "static");
|
|
20
|
+
if (fs.existsSync(OUTPUT_DIR)) {
|
|
21
|
+
fs.rmSync(OUTPUT_DIR, { recursive: true, force: true });
|
|
22
|
+
console.log("\u{1F5D1}\uFE0F Cleaned .cloudflare/output/");
|
|
23
|
+
}
|
|
24
|
+
fs.mkdirSync(STATIC_DIR, { recursive: true });
|
|
25
|
+
const config = await loadConfig();
|
|
26
|
+
const SERVER_DIR = path.resolve(config.serverDir);
|
|
27
|
+
const PAGES_DIR = path.resolve("./app/pages");
|
|
28
|
+
const PUBLIC_DIR = path.resolve("./app/public");
|
|
29
|
+
const CF_EXTERNALS = ["cloudflare:*", "__STATIC_CONTENT_MANIFEST"];
|
|
30
|
+
const CF_DEFINE = {
|
|
31
|
+
"process.env.NODE_ENV": '"production"'
|
|
32
|
+
};
|
|
33
|
+
const NODE_SHIM = (
|
|
34
|
+
/* js */
|
|
35
|
+
`
|
|
36
|
+
// \u2500\u2500\u2500 Node req/res shim \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
|
|
37
|
+
|
|
38
|
+
class __NodeRequest__ {
|
|
39
|
+
constructor(cfRequest, parsedUrl, bodyBytes) {
|
|
40
|
+
this.url = parsedUrl.pathname + parsedUrl.search;
|
|
41
|
+
this.method = cfRequest.method;
|
|
42
|
+
this.headers = Object.fromEntries(cfRequest.headers.entries());
|
|
43
|
+
this.body = null; // populated externally before handler call
|
|
44
|
+
this.query = Object.fromEntries(parsedUrl.searchParams.entries());
|
|
45
|
+
this.params = {};
|
|
46
|
+
// Pre-read body bytes for stream emulation
|
|
47
|
+
this._bodyBytes = bodyBytes; // Uint8Array | null
|
|
48
|
+
this._dataFns = [];
|
|
49
|
+
this._endFns = [];
|
|
50
|
+
this._errorFns = [];
|
|
51
|
+
this._streamQueued = false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Stream interface \u2014 'data' / 'end' / 'error' \u2014 used by body-parsing
|
|
55
|
+
// middleware and req.json() / req.text() from the API adapter template.
|
|
56
|
+
on(event, fn) {
|
|
57
|
+
if (event === 'data') this._dataFns.push(fn);
|
|
58
|
+
else if (event === 'end') this._endFns.push(fn);
|
|
59
|
+
else if (event === 'error') this._errorFns.push(fn);
|
|
60
|
+
|
|
61
|
+
// Schedule a single microtask flush the first time a listener is added.
|
|
62
|
+
// All listeners registered synchronously in the same tick will be ready
|
|
63
|
+
// by the time the microtask fires.
|
|
64
|
+
if (!this._streamQueued) {
|
|
65
|
+
this._streamQueued = true;
|
|
66
|
+
Promise.resolve().then(() => {
|
|
67
|
+
if (this._bodyBytes && this._bodyBytes.length > 0) {
|
|
68
|
+
for (const fn of this._dataFns) fn(this._bodyBytes);
|
|
69
|
+
}
|
|
70
|
+
for (const fn of this._endFns) fn();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return this;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
off(event, fn) {
|
|
77
|
+
if (event === 'data') this._dataFns = this._dataFns.filter(f => f !== fn);
|
|
78
|
+
else if (event === 'end') this._endFns = this._endFns.filter(f => f !== fn);
|
|
79
|
+
else if (event === 'error') this._errorFns = this._errorFns.filter(f => f !== fn);
|
|
80
|
+
return this;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
destroy() {}
|
|
84
|
+
resume() { return this; }
|
|
85
|
+
pause() { return this; }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
class __NodeResponse__ {
|
|
89
|
+
constructor() {
|
|
90
|
+
this.statusCode = 200;
|
|
91
|
+
this._headers = new Headers();
|
|
92
|
+
this._chunks = [];
|
|
93
|
+
this._resolve = null;
|
|
94
|
+
this._promise = new Promise(r => { this._resolve = r; });
|
|
95
|
+
// Attach NukeJS dispatcher helpers directly on construction so handlers
|
|
96
|
+
// receive them regardless of which dispatcher path is used.
|
|
97
|
+
this.json = (data, status = 200) => {
|
|
98
|
+
this.statusCode = status;
|
|
99
|
+
this.setHeader('content-type', 'application/json; charset=utf-8');
|
|
100
|
+
this.end(JSON.stringify(data));
|
|
101
|
+
};
|
|
102
|
+
this.status = (code) => { this.statusCode = code; return this; };
|
|
103
|
+
this.redirect = (location, code = 302) => {
|
|
104
|
+
this.statusCode = code;
|
|
105
|
+
this.setHeader('location', String(location));
|
|
106
|
+
this.end();
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
setHeader(name, value) { this._headers.set(String(name), String(value)); }
|
|
111
|
+
getHeader(name) { return this._headers.get(String(name)) ?? undefined; }
|
|
112
|
+
removeHeader(name) { this._headers.delete(String(name)); }
|
|
113
|
+
hasHeader(name) { return this._headers.has(String(name)); }
|
|
114
|
+
|
|
115
|
+
write(chunk) {
|
|
116
|
+
if (chunk == null) return;
|
|
117
|
+
this._chunks.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
end(chunk) {
|
|
121
|
+
if (chunk != null)
|
|
122
|
+
this._chunks.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk));
|
|
123
|
+
this._resolve(
|
|
124
|
+
new Response(this._chunks.join(''), {
|
|
125
|
+
status: this.statusCode,
|
|
126
|
+
headers: this._headers,
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Await this inside the fetch handler to get the completed Web Response.
|
|
132
|
+
toResponse() { return this._promise; }
|
|
133
|
+
}
|
|
134
|
+
`
|
|
135
|
+
);
|
|
136
|
+
function makeApiDispatcherSource(routes) {
|
|
137
|
+
const imports = routes.map((r, i) => `import * as __api_${i}__ from ${JSON.stringify(r.absPath)};`).join("\n");
|
|
138
|
+
const routeEntries = routes.map(
|
|
139
|
+
(r, i) => ` { regex: ${JSON.stringify(r.srcRegex)}, params: ${JSON.stringify(r.paramNames)}, mod: __api_${i}__ },`
|
|
140
|
+
).join("\n");
|
|
141
|
+
return (
|
|
142
|
+
/* ts */
|
|
143
|
+
`import type { IncomingMessage, ServerResponse } from 'http';
|
|
144
|
+
${imports}
|
|
145
|
+
|
|
146
|
+
const __CF_API_ROUTES__ = [
|
|
147
|
+
${routeEntries}
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Try to dispatch \`req\` to an API route.
|
|
152
|
+
* Returns true if a route matched (even if the handler threw), false otherwise.
|
|
153
|
+
*/
|
|
154
|
+
export async function __dispatchApi__(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
155
|
+
const url = new URL((req as any).url || '/', 'http://localhost');
|
|
156
|
+
const pathname = url.pathname;
|
|
157
|
+
|
|
158
|
+
for (const route of __CF_API_ROUTES__) {
|
|
159
|
+
const m = pathname.match(new RegExp(route.regex));
|
|
160
|
+
if (!m) continue;
|
|
161
|
+
|
|
162
|
+
const method = ((req.method || 'GET')).toUpperCase();
|
|
163
|
+
const apiReq = req as any;
|
|
164
|
+
const apiRes = res as any;
|
|
165
|
+
|
|
166
|
+
// Populate NukeJS API handler surface on the shim.
|
|
167
|
+
apiReq.query = Object.fromEntries(url.searchParams.entries());
|
|
168
|
+
apiReq.params = {};
|
|
169
|
+
route.params.forEach((name: string, i: number) => { apiReq.params[name] = m[i + 1]; });
|
|
170
|
+
|
|
171
|
+
// Attach .json() / .text() / .buffer() that resolve from the pre-read body.
|
|
172
|
+
const rawBytes: Uint8Array | null = apiReq._bodyBytes ?? null;
|
|
173
|
+
apiReq.text = () => Promise.resolve(rawBytes ? new TextDecoder().decode(rawBytes) : '');
|
|
174
|
+
apiReq.json = () => apiReq.text().then((t: string) => {
|
|
175
|
+
const parsed = t ? JSON.parse(t) : null;
|
|
176
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
177
|
+
delete parsed.__proto__;
|
|
178
|
+
delete parsed.constructor;
|
|
179
|
+
}
|
|
180
|
+
return parsed;
|
|
181
|
+
});
|
|
182
|
+
apiReq.buffer = () => Promise.resolve(rawBytes ?? new Uint8Array(0));
|
|
183
|
+
|
|
184
|
+
const fn = (route.mod as any)[method] ?? (route.mod as any)['default'];
|
|
185
|
+
if (typeof fn !== 'function') {
|
|
186
|
+
apiRes.json({ error: \`Method \${method} not allowed\` }, 405);
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
await fn(apiReq, apiRes);
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
function makePagesDispatcherSource(routes, errorAdapters2 = {}) {
|
|
198
|
+
const imports = routes.map((r, i) => `import __page_${i}__ from ${JSON.stringify(r.adapterPath)};`).join("\n");
|
|
199
|
+
const routeEntries = routes.map(
|
|
200
|
+
(r, i) => ` { regex: ${JSON.stringify(r.srcRegex)}, params: ${JSON.stringify(r.paramNames)}, catchAll: ${JSON.stringify(r.catchAllNames)}, handler: __page_${i}__ },`
|
|
201
|
+
).join("\n");
|
|
202
|
+
const error404Import = errorAdapters2.adapter404 ? `import __error_404__ from ${JSON.stringify(errorAdapters2.adapter404)};` : "";
|
|
203
|
+
const error500Import = errorAdapters2.adapter500 ? `import __error_500__ from ${JSON.stringify(errorAdapters2.adapter500)};` : "";
|
|
204
|
+
const notFoundFallback = errorAdapters2.adapter404 ? ` try { await __error_404__(req, res); return true; } catch(e) { console.error('[_404 error]', e); }` : ` (res as any).statusCode = 404;
|
|
205
|
+
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
206
|
+
res.end('Not Found');`;
|
|
207
|
+
const clientErrHandler = errorAdapters2.adapter500 ? (
|
|
208
|
+
/* ts */
|
|
209
|
+
` try {
|
|
210
|
+
const eq = new URLSearchParams();
|
|
211
|
+
eq.set('__errorMessage', url.searchParams.get('__clientError') || 'Client error');
|
|
212
|
+
const stack = url.searchParams.get('__clientStack');
|
|
213
|
+
if (stack) eq.set('__errorStack', stack);
|
|
214
|
+
(req as any).url = '/_500?' + eq.toString();
|
|
215
|
+
await __error_500__(req, res);
|
|
216
|
+
return true;
|
|
217
|
+
} catch(e) { console.error('[_500 client error]', e); }`
|
|
218
|
+
) : ` (res as any).statusCode = 500; res.end('Internal Server Error'); return true;`;
|
|
219
|
+
const errHandler = errorAdapters2.adapter500 ? (
|
|
220
|
+
/* ts */
|
|
221
|
+
` try {
|
|
222
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
223
|
+
const errStack = err instanceof Error ? err.stack : undefined;
|
|
224
|
+
const eq = new URLSearchParams();
|
|
225
|
+
eq.set('__errorMessage', errMsg);
|
|
226
|
+
if (errStack) eq.set('__errorStack', errStack);
|
|
227
|
+
(req as any).url = '/_500?' + eq.toString();
|
|
228
|
+
await __error_500__(req, res);
|
|
229
|
+
return true;
|
|
230
|
+
} catch(e) { console.error('[_500 error]', e); }`
|
|
231
|
+
) : ` (res as any).statusCode = 500; res.end('Internal Server Error'); return true;`;
|
|
232
|
+
return (
|
|
233
|
+
/* ts */
|
|
234
|
+
`import type { IncomingMessage, ServerResponse } from 'http';
|
|
235
|
+
${imports}
|
|
236
|
+
${error404Import}
|
|
237
|
+
${error500Import}
|
|
238
|
+
|
|
239
|
+
const __CF_PAGE_ROUTES__: Array<{
|
|
240
|
+
regex: string;
|
|
241
|
+
params: string[];
|
|
242
|
+
catchAll: string[];
|
|
243
|
+
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
244
|
+
}> = [
|
|
245
|
+
${routeEntries}
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Try to dispatch \`req\` to a page route.
|
|
250
|
+
* Returns true if a route matched (even if the handler threw), false otherwise.
|
|
251
|
+
*/
|
|
252
|
+
export async function __dispatchPages__(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
253
|
+
const url = new URL((req as any).url || '/', 'http://localhost');
|
|
254
|
+
const pathname = url.pathname;
|
|
255
|
+
|
|
256
|
+
// Client-side error \u2014 forward to _500 page if available.
|
|
257
|
+
if (url.searchParams.has('__clientError')) {
|
|
258
|
+
${clientErrHandler}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
for (const route of __CF_PAGE_ROUTES__) {
|
|
262
|
+
const m = pathname.match(new RegExp(route.regex));
|
|
263
|
+
if (!m) continue;
|
|
264
|
+
|
|
265
|
+
const catchAllSet = new Set(route.catchAll);
|
|
266
|
+
route.params.forEach((name, i) => {
|
|
267
|
+
const raw = m[i + 1] ?? '';
|
|
268
|
+
if (catchAllSet.has(name)) {
|
|
269
|
+
raw.split('/').filter(Boolean).forEach(seg => url.searchParams.append(name, seg));
|
|
270
|
+
} else {
|
|
271
|
+
url.searchParams.set(name, raw);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
(req as any).url = pathname + (url.search || '');
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
await route.handler(req, res);
|
|
278
|
+
return true;
|
|
279
|
+
} catch (err) {
|
|
280
|
+
console.error('[page handler error]', err);
|
|
281
|
+
${errHandler}
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
${notFoundFallback}
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
function makeWorkerEntrySource(hasApi2, hasPages2, inlineStaticMap2) {
|
|
293
|
+
const apiImport = hasApi2 ? `import { __dispatchApi__ } from "./cf-api-dispatcher.js";` : "";
|
|
294
|
+
const pagesImport = hasPages2 ? `import { __dispatchPages__ } from "./cf-pages-dispatcher.js";` : "";
|
|
295
|
+
const apiDispatch = hasApi2 ? `if (await __dispatchApi__(nodeReq as any, nodeRes as any)) return nodeRes.toResponse();` : "";
|
|
296
|
+
const pagesDispatch = hasPages2 ? `if (await __dispatchPages__(nodeReq as any, nodeRes as any)) return nodeRes.toResponse();` : "";
|
|
297
|
+
return (
|
|
298
|
+
/* ts */
|
|
299
|
+
`${NODE_SHIM}
|
|
300
|
+
|
|
301
|
+
${apiImport}
|
|
302
|
+
${pagesImport}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Pre-buffer the request body into a Uint8Array so we can both:
|
|
306
|
+
* a) inject it into req.body (parsed), and
|
|
307
|
+
* b) expose a fake stream interface on the shim (on('data', \u2026)).
|
|
308
|
+
* Returns null for requests without bodies (GET, HEAD, OPTIONS).
|
|
309
|
+
*/
|
|
310
|
+
const __INLINE_STATIC_MAP__ = new Map<string, { ct: string; body: string; text: boolean }>([
|
|
311
|
+
${inlineStaticMap2}
|
|
312
|
+
]);
|
|
313
|
+
|
|
314
|
+
async function readBodyBytes(request: Request): Promise<Uint8Array | null> {
|
|
315
|
+
const noBody = ['GET', 'HEAD', 'OPTIONS'].includes(request.method.toUpperCase());
|
|
316
|
+
if (noBody) return null;
|
|
317
|
+
try {
|
|
318
|
+
const buf = await request.arrayBuffer();
|
|
319
|
+
return buf.byteLength > 0 ? new Uint8Array(buf) : null;
|
|
320
|
+
} catch {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Attempt to parse the raw body bytes according to the Content-Type header.
|
|
327
|
+
* Returns the parsed body (object, string, or null).
|
|
328
|
+
*/
|
|
329
|
+
function parseBodyBytes(bodyBytes: Uint8Array | null, contentType: string): unknown {
|
|
330
|
+
if (!bodyBytes || bodyBytes.length === 0) return null;
|
|
331
|
+
const text = new TextDecoder().decode(bodyBytes);
|
|
332
|
+
try {
|
|
333
|
+
if (contentType.includes('application/json')) {
|
|
334
|
+
const parsed = JSON.parse(text);
|
|
335
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
336
|
+
delete (parsed as any).__proto__;
|
|
337
|
+
delete (parsed as any).constructor;
|
|
338
|
+
}
|
|
339
|
+
return parsed;
|
|
340
|
+
}
|
|
341
|
+
if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
342
|
+
return Object.fromEntries(new URLSearchParams(text).entries());
|
|
343
|
+
}
|
|
344
|
+
return text;
|
|
345
|
+
} catch {
|
|
346
|
+
return text;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export default {
|
|
351
|
+
async fetch(request: Request, env: Record<string, any>, ctx: ExecutionContext): Promise<Response> {
|
|
352
|
+
const parsedUrl = new URL(request.url);
|
|
353
|
+
|
|
354
|
+
// \u2500\u2500 1. Static assets via Cloudflare Pages ASSETS binding \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
355
|
+
// When deployed with Cloudflare Pages, \`env.ASSETS\` is a fetcher that
|
|
356
|
+
// serves files from the static/ output directory via the CDN. We issue a
|
|
357
|
+
// GET probe (no body) so we never accidentally consume the request body.
|
|
358
|
+
if (env && env.ASSETS) {
|
|
359
|
+
try {
|
|
360
|
+
const probe = new Request(request.url, { method: 'GET', headers: request.headers });
|
|
361
|
+
const staticResp = await (env.ASSETS as Fetcher).fetch(probe);
|
|
362
|
+
if (staticResp.status !== 404) return staticResp;
|
|
363
|
+
} catch (_) {
|
|
364
|
+
// ASSETS binding unavailable or errored \u2014 fall through to inline map.
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// \u2500\u2500 1b. Inline static asset map (standalone Workers fallback) \u2500\u2500\u2500\u2500\u2500\u2500
|
|
369
|
+
// When deployed via wrangler deploy (not Cloudflare Pages), there is
|
|
370
|
+
// no ASSETS binding. Static files are inlined at build time into this map.
|
|
371
|
+
{
|
|
372
|
+
const __inlineAsset__ = __INLINE_STATIC_MAP__.get(parsedUrl.pathname);
|
|
373
|
+
if (__inlineAsset__) {
|
|
374
|
+
const body = __inlineAsset__.text
|
|
375
|
+
? __inlineAsset__.body
|
|
376
|
+
: Uint8Array.from(atob(__inlineAsset__.body), c => c.charCodeAt(0));
|
|
377
|
+
return new Response(body, {
|
|
378
|
+
status: 200,
|
|
379
|
+
headers: { 'content-type': __inlineAsset__.ct },
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// \u2500\u2500 2. Pre-buffer the request body \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
|
|
385
|
+
const bodyBytes = await readBodyBytes(request);
|
|
386
|
+
const contentType = request.headers.get('content-type') || '';
|
|
387
|
+
const parsedBody = parseBodyBytes(bodyBytes, contentType);
|
|
388
|
+
|
|
389
|
+
// \u2500\u2500 3. Build Node-compatible req / res shims \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
|
|
390
|
+
const nodeReq = new ((__NodeRequest__ as any))(request, parsedUrl, bodyBytes);
|
|
391
|
+
nodeReq.body = parsedBody;
|
|
392
|
+
const nodeRes = new ((__NodeResponse__ as any))();
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
// \u2500\u2500 4. API routes \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
|
|
396
|
+
${apiDispatch}
|
|
397
|
+
|
|
398
|
+
// \u2500\u2500 5. Page routes \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
|
|
399
|
+
${pagesDispatch}
|
|
400
|
+
|
|
401
|
+
// \u2500\u2500 6. No route matched \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
|
|
402
|
+
return new Response('Not Found', {
|
|
403
|
+
status: 404,
|
|
404
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
} catch (err) {
|
|
408
|
+
console.error('[worker unhandled error]', err);
|
|
409
|
+
return new Response('Internal Server Error', {
|
|
410
|
+
status: 500,
|
|
411
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
`
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
async function bundleForWorker(entryPath, outPath, extraDefine = {}) {
|
|
420
|
+
const result = await build({
|
|
421
|
+
entryPoints: [entryPath],
|
|
422
|
+
bundle: true,
|
|
423
|
+
format: "esm",
|
|
424
|
+
platform: "browser",
|
|
425
|
+
target: "es2022",
|
|
426
|
+
external: CF_EXTERNALS,
|
|
427
|
+
define: { ...CF_DEFINE, ...extraDefine },
|
|
428
|
+
jsx: "automatic",
|
|
429
|
+
write: false,
|
|
430
|
+
resolveExtensions: [".js", ".mjs", ".ts", ".tsx", ".jsx", ".json"]
|
|
431
|
+
// Do NOT set `banner` — the Node shim is inline in the worker entry source.
|
|
432
|
+
});
|
|
433
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
434
|
+
fs.writeFileSync(outPath, result.outputFiles[0].text);
|
|
435
|
+
}
|
|
436
|
+
const apiFiles = walkFiles(SERVER_DIR);
|
|
437
|
+
if (apiFiles.length === 0) console.warn(`\u26A0 No server files found in ${SERVER_DIR}`);
|
|
438
|
+
const apiRoutes = apiFiles.map((relPath) => ({
|
|
439
|
+
...analyzeFile(relPath, "api"),
|
|
440
|
+
absPath: path.join(SERVER_DIR, relPath)
|
|
441
|
+
})).sort((a, b) => b.specificity - a.specificity);
|
|
442
|
+
const serverPages = collectServerPages(PAGES_DIR);
|
|
443
|
+
await buildCombinedBundle(STATIC_DIR);
|
|
444
|
+
copyPublicFiles(PUBLIC_DIR, STATIC_DIR);
|
|
445
|
+
const TEXT_EXTS = /* @__PURE__ */ new Set([
|
|
446
|
+
".js",
|
|
447
|
+
".mjs",
|
|
448
|
+
".cjs",
|
|
449
|
+
".ts",
|
|
450
|
+
".jsx",
|
|
451
|
+
".tsx",
|
|
452
|
+
".css",
|
|
453
|
+
".html",
|
|
454
|
+
".htm",
|
|
455
|
+
".json",
|
|
456
|
+
".xml",
|
|
457
|
+
".txt",
|
|
458
|
+
".csv",
|
|
459
|
+
".svg",
|
|
460
|
+
".map"
|
|
461
|
+
]);
|
|
462
|
+
const MIME_MAP = {
|
|
463
|
+
".html": "text/html; charset=utf-8",
|
|
464
|
+
".htm": "text/html; charset=utf-8",
|
|
465
|
+
".css": "text/css; charset=utf-8",
|
|
466
|
+
".js": "application/javascript; charset=utf-8",
|
|
467
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
468
|
+
".cjs": "application/javascript; charset=utf-8",
|
|
469
|
+
".map": "application/json; charset=utf-8",
|
|
470
|
+
".json": "application/json; charset=utf-8",
|
|
471
|
+
".xml": "application/xml; charset=utf-8",
|
|
472
|
+
".txt": "text/plain; charset=utf-8",
|
|
473
|
+
".csv": "text/csv; charset=utf-8",
|
|
474
|
+
".svg": "image/svg+xml",
|
|
475
|
+
".png": "image/png",
|
|
476
|
+
".jpg": "image/jpeg",
|
|
477
|
+
".jpeg": "image/jpeg",
|
|
478
|
+
".gif": "image/gif",
|
|
479
|
+
".webp": "image/webp",
|
|
480
|
+
".avif": "image/avif",
|
|
481
|
+
".ico": "image/x-icon",
|
|
482
|
+
".bmp": "image/bmp",
|
|
483
|
+
".woff": "font/woff",
|
|
484
|
+
".woff2": "font/woff2",
|
|
485
|
+
".ttf": "font/ttf",
|
|
486
|
+
".otf": "font/otf",
|
|
487
|
+
".mp4": "video/mp4",
|
|
488
|
+
".webm": "video/webm",
|
|
489
|
+
".mp3": "audio/mpeg",
|
|
490
|
+
".wav": "audio/wav",
|
|
491
|
+
".ogg": "audio/ogg",
|
|
492
|
+
".pdf": "application/pdf",
|
|
493
|
+
".wasm": "application/wasm"
|
|
494
|
+
};
|
|
495
|
+
function walkStaticDir(dir, base = dir) {
|
|
496
|
+
const results = [];
|
|
497
|
+
if (!fs.existsSync(dir)) return results;
|
|
498
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
499
|
+
const abs = path.join(dir, entry.name);
|
|
500
|
+
if (entry.isDirectory()) {
|
|
501
|
+
results.push(...walkStaticDir(abs, base));
|
|
502
|
+
} else {
|
|
503
|
+
results.push({ rel: "/" + path.relative(base, abs).replace(/\\/g, "/"), abs });
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return results;
|
|
507
|
+
}
|
|
508
|
+
let hasApi = false;
|
|
509
|
+
let hasPages = false;
|
|
510
|
+
if (apiRoutes.length > 0) {
|
|
511
|
+
hasApi = true;
|
|
512
|
+
const dispSrc = makeApiDispatcherSource(apiRoutes);
|
|
513
|
+
const dispPath = path.join(SERVER_DIR, `_cf_api_dispatcher_${randomBytes(4).toString("hex")}.ts`);
|
|
514
|
+
fs.writeFileSync(dispPath, dispSrc);
|
|
515
|
+
try {
|
|
516
|
+
const outPath = path.join(OUTPUT_DIR, "cf-api-dispatcher.js");
|
|
517
|
+
await bundleForWorker(dispPath, outPath);
|
|
518
|
+
console.log(` built API dispatcher \u2192 cf-api-dispatcher.js (${apiRoutes.length} route(s))`);
|
|
519
|
+
} finally {
|
|
520
|
+
fs.unlinkSync(dispPath);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const tempAdapterPaths = [];
|
|
524
|
+
const errorAdapterPaths = [];
|
|
525
|
+
const errorAdapters = {};
|
|
526
|
+
if (serverPages.length > 0 || ["_404.tsx", "_500.tsx"].some((f) => fs.existsSync(path.join(PAGES_DIR, f)))) {
|
|
527
|
+
hasPages = true;
|
|
528
|
+
const globalRegistry = collectGlobalClientRegistry(serverPages, PAGES_DIR);
|
|
529
|
+
const prerenderedHtml = await bundleClientComponents(globalRegistry, PAGES_DIR, STATIC_DIR);
|
|
530
|
+
const prerenderedRecord = Object.fromEntries(prerenderedHtml);
|
|
531
|
+
const dispatcherRoutes = [];
|
|
532
|
+
for (const page of serverPages) {
|
|
533
|
+
const adapterDir = path.dirname(page.absPath);
|
|
534
|
+
const adapterPath = path.join(
|
|
535
|
+
adapterDir,
|
|
536
|
+
`_cf_page_adapter_${randomBytes(4).toString("hex")}.ts`
|
|
537
|
+
);
|
|
538
|
+
const layoutPaths = findPageLayouts(page.absPath, PAGES_DIR);
|
|
539
|
+
const { registry, clientComponentNames } = buildPerPageRegistry(page.absPath, layoutPaths, PAGES_DIR);
|
|
540
|
+
const layoutImports = layoutPaths.map((lp, i) => {
|
|
541
|
+
const rel = path.relative(adapterDir, lp).replace(/\\/g, "/");
|
|
542
|
+
return `import __layout_${i}__ from ${JSON.stringify(rel.startsWith(".") ? rel : "./" + rel)};`;
|
|
543
|
+
}).join("\n");
|
|
544
|
+
fs.writeFileSync(
|
|
545
|
+
adapterPath,
|
|
546
|
+
makePageAdapterSource({
|
|
547
|
+
pageImport: JSON.stringify("./" + path.basename(page.absPath)),
|
|
548
|
+
layoutImports,
|
|
549
|
+
clientComponentNames,
|
|
550
|
+
allClientIds: [...registry.keys()],
|
|
551
|
+
layoutArrayItems: layoutPaths.map((_, i) => `__layout_${i}__`).join(", "),
|
|
552
|
+
prerenderedHtml: prerenderedRecord,
|
|
553
|
+
routeParamNames: page.paramNames,
|
|
554
|
+
catchAllNames: page.catchAllNames
|
|
555
|
+
})
|
|
556
|
+
);
|
|
557
|
+
tempAdapterPaths.push(adapterPath);
|
|
558
|
+
dispatcherRoutes.push({
|
|
559
|
+
adapterPath,
|
|
560
|
+
srcRegex: page.srcRegex,
|
|
561
|
+
paramNames: page.paramNames,
|
|
562
|
+
catchAllNames: page.catchAllNames
|
|
563
|
+
});
|
|
564
|
+
console.log(` prepared ${path.relative(PAGES_DIR, page.absPath)} \u2192 ${page.funcPath} [page]`);
|
|
565
|
+
}
|
|
566
|
+
for (const [statusCode, key] of [[404, "adapter404"], [500, "adapter500"]]) {
|
|
567
|
+
const src = path.join(PAGES_DIR, `_${statusCode}.tsx`);
|
|
568
|
+
if (!fs.existsSync(src)) continue;
|
|
569
|
+
console.log(` building _${statusCode}.tsx \u2192 pages dispatcher [error page]`);
|
|
570
|
+
const adapterDir = path.dirname(src);
|
|
571
|
+
const adapterPath = path.join(
|
|
572
|
+
adapterDir,
|
|
573
|
+
`_cf_error_adapter_${randomBytes(4).toString("hex")}.ts`
|
|
574
|
+
);
|
|
575
|
+
const layoutPaths = findPageLayouts(src, PAGES_DIR);
|
|
576
|
+
const { registry, clientComponentNames } = buildPerPageRegistry(src, layoutPaths, PAGES_DIR);
|
|
577
|
+
const layoutImports = layoutPaths.map((lp, i) => {
|
|
578
|
+
const rel = path.relative(adapterDir, lp).replace(/\\/g, "/");
|
|
579
|
+
return `import __layout_${i}__ from ${JSON.stringify(rel.startsWith(".") ? rel : "./" + rel)};`;
|
|
580
|
+
}).join("\n");
|
|
581
|
+
fs.writeFileSync(
|
|
582
|
+
adapterPath,
|
|
583
|
+
makePageAdapterSource({
|
|
584
|
+
pageImport: JSON.stringify("./" + path.basename(src)),
|
|
585
|
+
layoutImports,
|
|
586
|
+
clientComponentNames,
|
|
587
|
+
allClientIds: [...registry.keys()],
|
|
588
|
+
layoutArrayItems: layoutPaths.map((_, i) => `__layout_${i}__`).join(", "),
|
|
589
|
+
prerenderedHtml: prerenderedRecord,
|
|
590
|
+
routeParamNames: [],
|
|
591
|
+
catchAllNames: [],
|
|
592
|
+
statusCode
|
|
593
|
+
})
|
|
594
|
+
);
|
|
595
|
+
errorAdapters[key] = adapterPath;
|
|
596
|
+
errorAdapterPaths.push(adapterPath);
|
|
597
|
+
}
|
|
598
|
+
const pageDispSrc = makePagesDispatcherSource(dispatcherRoutes, errorAdapters);
|
|
599
|
+
const pageDispPath = path.join(
|
|
600
|
+
PAGES_DIR,
|
|
601
|
+
`_cf_pages_dispatcher_${randomBytes(4).toString("hex")}.ts`
|
|
602
|
+
);
|
|
603
|
+
fs.writeFileSync(pageDispPath, pageDispSrc);
|
|
604
|
+
try {
|
|
605
|
+
const outPath = path.join(OUTPUT_DIR, "cf-pages-dispatcher.js");
|
|
606
|
+
await bundleForWorker(pageDispPath, outPath);
|
|
607
|
+
console.log(` built Pages dispatcher \u2192 cf-pages-dispatcher.js (${serverPages.length} page(s))`);
|
|
608
|
+
} finally {
|
|
609
|
+
fs.unlinkSync(pageDispPath);
|
|
610
|
+
for (const p of tempAdapterPaths) if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
611
|
+
for (const p of errorAdapterPaths) if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
const staticEntries = walkStaticDir(STATIC_DIR);
|
|
615
|
+
const inlineStaticMap = staticEntries.map(({ rel, abs }) => {
|
|
616
|
+
const ext = path.extname(rel).toLowerCase();
|
|
617
|
+
const contentType = MIME_MAP[ext] ?? "application/octet-stream";
|
|
618
|
+
const isText = TEXT_EXTS.has(ext);
|
|
619
|
+
const raw = fs.readFileSync(abs);
|
|
620
|
+
if (isText) {
|
|
621
|
+
const escaped = JSON.stringify(raw.toString("utf-8"));
|
|
622
|
+
return ` [${JSON.stringify(rel)}, { ct: ${JSON.stringify(contentType)}, body: ${escaped}, text: true }],`;
|
|
623
|
+
} else {
|
|
624
|
+
const b64 = raw.toString("base64");
|
|
625
|
+
return ` [${JSON.stringify(rel)}, { ct: ${JSON.stringify(contentType)}, body: ${JSON.stringify(b64)}, text: false }],`;
|
|
626
|
+
}
|
|
627
|
+
}).join("\n");
|
|
628
|
+
const workerSrc = makeWorkerEntrySource(hasApi, hasPages, inlineStaticMap);
|
|
629
|
+
const workerSrcPath = path.join(
|
|
630
|
+
OUTPUT_DIR,
|
|
631
|
+
`_cf_worker_entry_${randomBytes(4).toString("hex")}.ts`
|
|
632
|
+
);
|
|
633
|
+
fs.writeFileSync(workerSrcPath, workerSrc);
|
|
634
|
+
try {
|
|
635
|
+
await bundleForWorker(workerSrcPath, path.join(OUTPUT_DIR, "_worker.mjs"));
|
|
636
|
+
console.log(` built Worker entry \u2192 .cloudflare/output/_worker.mjs`);
|
|
637
|
+
} finally {
|
|
638
|
+
fs.unlinkSync(workerSrcPath);
|
|
639
|
+
for (const disp of ["cf-api-dispatcher.js", "cf-pages-dispatcher.js"]) {
|
|
640
|
+
const p = path.join(OUTPUT_DIR, disp);
|
|
641
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
const projectName = path.basename(process.cwd()).replace(/[^a-z0-9-]/gi, "-").toLowerCase();
|
|
645
|
+
const wranglerToml = `# Generated by NukeJS build-cloudflare \u2014 edit as needed.
|
|
646
|
+
# This file is used by \`wrangler deploy\` for standalone Workers deployment.
|
|
647
|
+
# For Cloudflare Pages, configure the build output directory in the Pages
|
|
648
|
+
# dashboard instead (.cloudflare/output).
|
|
649
|
+
|
|
650
|
+
name = "${projectName}"
|
|
651
|
+
main = ".cloudflare/output/_worker.mjs"
|
|
652
|
+
compatibility_date = "${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}"
|
|
653
|
+
|
|
654
|
+
[build]
|
|
655
|
+
command = "nuke build --cloudflare"
|
|
656
|
+
|
|
657
|
+
# Uncomment to add KV, R2, D1, or other bindings:
|
|
658
|
+
# [[kv_namespaces]]
|
|
659
|
+
# binding = "KV"
|
|
660
|
+
# id = "YOUR_KV_NAMESPACE_ID"
|
|
661
|
+
`;
|
|
662
|
+
fs.writeFileSync(path.resolve("wrangler.toml"), wranglerToml);
|
|
663
|
+
const routeCount = apiRoutes.length + serverPages.length;
|
|
664
|
+
const assetCount = staticEntries.length;
|
|
665
|
+
console.log(`
|
|
666
|
+
\u2713 Cloudflare build complete \u2014 ${routeCount} route(s), ${assetCount} static asset(s)
|
|
667
|
+
Worker: .cloudflare/output/_worker.mjs
|
|
668
|
+
Static: .cloudflare/output/static/
|
|
669
|
+
Config: wrangler.toml
|
|
670
|
+
|
|
671
|
+
Deploy options:
|
|
672
|
+
Pages \u2192 wrangler pages deploy .cloudflare/output
|
|
673
|
+
Workers \u2192 wrangler deploy
|
|
674
|
+
`);
|
package/dist/build-common.js
CHANGED
|
@@ -225,7 +225,9 @@ function makeApiAdapterSource(handlerFilename) {
|
|
|
225
225
|
return `import type { IncomingMessage, ServerResponse } from 'http';
|
|
226
226
|
import * as mod from ${JSON.stringify("./" + handlerFilename)};
|
|
227
227
|
|
|
228
|
-
|
|
228
|
+
const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
229
|
+
|
|
230
|
+
function enhanceRes(res: ServerResponse) {
|
|
229
231
|
(res as any).json = function (data: any, status = 200) {
|
|
230
232
|
this.statusCode = status;
|
|
231
233
|
this.setHeader('Content-Type', 'application/json');
|
|
@@ -235,26 +237,46 @@ function enhance(res: ServerResponse) {
|
|
|
235
237
|
return res;
|
|
236
238
|
}
|
|
237
239
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
240
|
+
function enhanceReq(req: IncomingMessage) {
|
|
241
|
+
const apiReq = req as any;
|
|
242
|
+
let bufferPromise: Promise<Buffer> | null = null;
|
|
243
|
+
|
|
244
|
+
const getBuffer = (): Promise<Buffer> => {
|
|
245
|
+
if (!bufferPromise) {
|
|
246
|
+
bufferPromise = new Promise((resolve, reject) => {
|
|
247
|
+
const chunks: Buffer[] = [];
|
|
248
|
+
let bytes = 0;
|
|
249
|
+
req.on('data', (chunk: Buffer) => {
|
|
250
|
+
bytes += chunk.length;
|
|
251
|
+
if (bytes > MAX_BODY_BYTES) { req.destroy(); return reject(new Error('Request body too large')); }
|
|
252
|
+
chunks.push(chunk);
|
|
253
|
+
});
|
|
254
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
255
|
+
req.on('error', reject);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return bufferPromise;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
apiReq.buffer = () => getBuffer();
|
|
262
|
+
apiReq.text = () => getBuffer().then(buf => buf.toString('utf8'));
|
|
263
|
+
apiReq.json = () => getBuffer().then(buf => {
|
|
264
|
+
const parsed = JSON.parse(buf.toString('utf8'));
|
|
265
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
266
|
+
delete parsed.__proto__;
|
|
267
|
+
delete parsed.constructor;
|
|
268
|
+
}
|
|
269
|
+
return parsed;
|
|
249
270
|
});
|
|
271
|
+
|
|
272
|
+
return apiReq;
|
|
250
273
|
}
|
|
251
274
|
|
|
252
275
|
export default async function handler(req: IncomingMessage, res: ServerResponse) {
|
|
253
276
|
const method = (req.method || 'GET').toUpperCase();
|
|
254
|
-
const apiRes =
|
|
255
|
-
const apiReq = req
|
|
277
|
+
const apiRes = enhanceRes(res);
|
|
278
|
+
const apiReq = enhanceReq(req);
|
|
256
279
|
|
|
257
|
-
apiReq.body = await parseBody(req);
|
|
258
280
|
// In production, route dynamic segments are injected as query-string keys by
|
|
259
281
|
// the server entry, so params and query share the same parsed URL values.
|
|
260
282
|
const qs = Object.fromEntries(new URL(req.url || '/', 'http://localhost').searchParams);
|
|
@@ -396,6 +418,19 @@ function renderStyleTag(tag: any): string {
|
|
|
396
418
|
return \` <style\${media}>\${tag.content ?? ''}</style>\`;
|
|
397
419
|
}
|
|
398
420
|
|
|
421
|
+
// \u2500\u2500\u2500 HTML minifier \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
|
|
422
|
+
// Minifies the final HTML string before sending it to the client.
|
|
423
|
+
// Sentinel comments (<!--n-head-->, <!--/n-head-->, <!--n-body-scripts-->,
|
|
424
|
+
// <!--/n-body-scripts-->) are preserved \u2014 the client runtime needs them for
|
|
425
|
+
// head diffing during soft navigation.
|
|
426
|
+
function minifyHtml(h: string): string {
|
|
427
|
+
return h
|
|
428
|
+
.replace(/<!--(?!(n-head|\\/n-head|n-body-scripts|\\/n-body-scripts))[\\s\\S]*?-->/g, '')
|
|
429
|
+
.replace(/>\\s+</g, '> <')
|
|
430
|
+
.replace(/\\s*\\n\\s*/g, '')
|
|
431
|
+
.trim();
|
|
432
|
+
}
|
|
433
|
+
|
|
399
434
|
// \u2500\u2500\u2500 Renderer \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\u2500\u2500\u2500\u2500
|
|
400
435
|
const VOID_TAGS = new Set([
|
|
401
436
|
'area','base','br','col','embed','hr','img','input',
|
|
@@ -639,7 +674,7 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
|
|
|
639
674
|
|
|
640
675
|
res.statusCode = ${statusCode};
|
|
641
676
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
642
|
-
res.end(html);
|
|
677
|
+
res.end(minifyHtml(html));
|
|
643
678
|
} catch (err: any) {
|
|
644
679
|
// Re-throw so the server entry (build-node / build-vercel) can route to
|
|
645
680
|
// the _500 page handler. Do not swallow the error here.
|
package/dist/bundle.js
CHANGED
|
@@ -166,7 +166,9 @@ function headBlock(head) {
|
|
|
166
166
|
return { nodes, closeComment };
|
|
167
167
|
}
|
|
168
168
|
function fingerprint(el) {
|
|
169
|
-
|
|
169
|
+
const attrPart = Array.from(el.attributes).sort((a, b) => a.name.localeCompare(b.name)).map((a) => `${a.name}=${a.value}`).join("&");
|
|
170
|
+
const contentPart = el.tagName === "STYLE" ? el.textContent ?? "" : "";
|
|
171
|
+
return el.tagName + "|" + attrPart + (contentPart ? "|" + contentPart : "");
|
|
170
172
|
}
|
|
171
173
|
function syncHeadTags(doc) {
|
|
172
174
|
const live = headBlock(document.head);
|
package/dist/http-server.js
CHANGED
|
@@ -31,9 +31,9 @@ function discoverApiPrefixes(serverDir) {
|
|
|
31
31
|
return prefixes;
|
|
32
32
|
}
|
|
33
33
|
const MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
34
|
-
|
|
34
|
+
function collectBuffer(req) {
|
|
35
35
|
return new Promise((resolve, reject) => {
|
|
36
|
-
|
|
36
|
+
const chunks = [];
|
|
37
37
|
let bytes = 0;
|
|
38
38
|
req.on("data", (chunk) => {
|
|
39
39
|
bytes += chunk.length;
|
|
@@ -41,27 +41,31 @@ async function parseBody(req) {
|
|
|
41
41
|
req.destroy();
|
|
42
42
|
return reject(new Error("Request body too large"));
|
|
43
43
|
}
|
|
44
|
-
|
|
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
|
-
}
|
|
44
|
+
chunks.push(chunk);
|
|
61
45
|
});
|
|
46
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
62
47
|
req.on("error", reject);
|
|
63
48
|
});
|
|
64
49
|
}
|
|
50
|
+
function enhanceRequest(req) {
|
|
51
|
+
const apiReq = req;
|
|
52
|
+
let bufferPromise = null;
|
|
53
|
+
const getBuffer = () => {
|
|
54
|
+
if (!bufferPromise) bufferPromise = collectBuffer(req);
|
|
55
|
+
return bufferPromise;
|
|
56
|
+
};
|
|
57
|
+
apiReq.buffer = () => getBuffer();
|
|
58
|
+
apiReq.text = () => getBuffer().then((buf) => buf.toString("utf8"));
|
|
59
|
+
apiReq.json = () => getBuffer().then((buf) => {
|
|
60
|
+
const parsed = JSON.parse(buf.toString("utf8"));
|
|
61
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
62
|
+
delete parsed.__proto__;
|
|
63
|
+
delete parsed.constructor;
|
|
64
|
+
}
|
|
65
|
+
return parsed;
|
|
66
|
+
});
|
|
67
|
+
return apiReq;
|
|
68
|
+
}
|
|
65
69
|
function parseQuery(url, port) {
|
|
66
70
|
const query = {};
|
|
67
71
|
new URL(url, `http://localhost:${port}`).searchParams.forEach((v, k) => {
|
|
@@ -145,8 +149,7 @@ function createApiHandler({ apiPrefixes, port, isDev }) {
|
|
|
145
149
|
respondOptions(apiRes);
|
|
146
150
|
return;
|
|
147
151
|
}
|
|
148
|
-
const apiReq = req;
|
|
149
|
-
apiReq.body = await parseBody(req);
|
|
152
|
+
const apiReq = enhanceRequest(req);
|
|
150
153
|
apiReq.params = params;
|
|
151
154
|
apiReq.query = parseQuery(url, port);
|
|
152
155
|
const apiModule = isDev ? await importFreshInDev(filePath) : await import(pathToFileURL(filePath).href);
|
|
@@ -165,8 +168,8 @@ function createApiHandler({ apiPrefixes, port, isDev }) {
|
|
|
165
168
|
export {
|
|
166
169
|
createApiHandler,
|
|
167
170
|
discoverApiPrefixes,
|
|
171
|
+
enhanceRequest,
|
|
168
172
|
enhanceResponse,
|
|
169
173
|
matchApiPrefix,
|
|
170
|
-
parseBody,
|
|
171
174
|
parseQuery
|
|
172
175
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nukejs",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.21",
|
|
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",
|