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 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 Vercel serverless |
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 user = await db.createUser(req.body);
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.body` | `any` | Parsed JSON body (or raw string), up to 10 MB |
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 compiled dist via plain node
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
- if (isVercel) {
117
- runWithNode(path.join(distDir, 'build-vercel.js'));
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
+ `);
@@ -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
- function enhance(res: ServerResponse) {
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
- async function parseBody(req: IncomingMessage): Promise<any> {
239
- return new Promise((resolve, reject) => {
240
- let body = '';
241
- req.on('data', chunk => { body += chunk.toString(); });
242
- req.on('end', () => {
243
- try {
244
- resolve(body && req.headers['content-type']?.includes('application/json')
245
- ? JSON.parse(body) : body);
246
- } catch (e) { reject(e); }
247
- });
248
- req.on('error', reject);
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 = enhance(res);
255
- const apiReq = req as any;
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
- return el.tagName + "|" + Array.from(el.attributes).sort((a, b) => a.name.localeCompare(b.name)).map((a) => `${a.name}=${a.value}`).join("&");
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);
@@ -31,9 +31,9 @@ function discoverApiPrefixes(serverDir) {
31
31
  return prefixes;
32
32
  }
33
33
  const MAX_BODY_BYTES = 10 * 1024 * 1024;
34
- async function parseBody(req) {
34
+ function collectBuffer(req) {
35
35
  return new Promise((resolve, reject) => {
36
- let body = "";
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
- 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
- }
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.19",
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",