nukejs 0.0.28 → 0.0.30
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 +62 -0
- package/dist/app.js +3 -1
- package/dist/build-common.js +16 -2
- package/dist/bundler.js +22 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/render-component.js +32 -0
- package/dist/ssr.js +55 -31
- package/dist/worker-assets.js +151 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ npm create nuke@latest
|
|
|
17
17
|
- [Pages & Routing](#pages--routing)
|
|
18
18
|
- [Layouts](#layouts)
|
|
19
19
|
- [Client Components](#client-components)
|
|
20
|
+
- [Web Workers](#web-workers)
|
|
20
21
|
- [State Management](#state-management)
|
|
21
22
|
- [API Routes](#api-routes)
|
|
22
23
|
- [Middleware](#middleware)
|
|
@@ -294,6 +295,65 @@ Children and other React elements can be passed as props — NukeJS serializes t
|
|
|
294
295
|
|
|
295
296
|
---
|
|
296
297
|
|
|
298
|
+
## Web Workers
|
|
299
|
+
|
|
300
|
+
`"use client"` components can create Web Workers using the standard modern bundler pattern — no manual copying of worker files into `app/public/`, and no extra config:
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
"use client";
|
|
304
|
+
|
|
305
|
+
function makeParserWorker() {
|
|
306
|
+
return new Worker(new URL("./parser.worker.ts", import.meta.url), {
|
|
307
|
+
type: "module",
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Any `new URL("./relative/path", import.meta.url)` reachable from a client component is discovered automatically while NukeJS bundles your app. The referenced file is bundled with esbuild as its own entry point, written out with a content hash in its filename, and the `new URL(...)` call is rewritten in place to point at the emitted file — in both `nuke dev` and `nuke build`, under the same `/__worker/<name>-<hash>.js` URL, so app code never has to branch on mode.
|
|
313
|
+
|
|
314
|
+
### Workers from installed packages
|
|
315
|
+
|
|
316
|
+
This also works when the worker lives inside a package you installed, either because the package references its own co-located worker internally:
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
// resolved correctly even though voiceClient.js ships inside node_modules
|
|
320
|
+
new URL("./workers/stt-worker.js", import.meta.url)
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
or because your own code points at a worker a package exports by subpath:
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
new Worker(
|
|
327
|
+
new URL("monaco-editor/esm/vs/language/json/json.worker.js", import.meta.url),
|
|
328
|
+
{ type: "module" }
|
|
329
|
+
)
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Bare specifiers are resolved through Node's own module resolution (honoring that package's `exports`/`main`), so the correct installed copy is found the same way a real `import` of that specifier would find it.
|
|
333
|
+
|
|
334
|
+
### Overriding the URL
|
|
335
|
+
|
|
336
|
+
Nothing here gets in the way of supplying your own worker URL at runtime — that's just a plain string, which is never something this rewrite touches:
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
function makeParserWorker(options: { workerUrls?: { parser?: string } } = {}) {
|
|
340
|
+
if (options.workerUrls?.parser) {
|
|
341
|
+
return new Worker(options.workerUrls.parser, { type: "module" });
|
|
342
|
+
}
|
|
343
|
+
return new Worker(new URL("./parser.worker.ts", import.meta.url), {
|
|
344
|
+
type: "module",
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
If `options.workerUrls.parser` is set, it's always used. Otherwise the bundled worker is resolved automatically.
|
|
350
|
+
|
|
351
|
+
### What's still out of reach
|
|
352
|
+
|
|
353
|
+
Only string literals are resolved — a dynamic path (`new URL(someVariable, import.meta.url)`) can't be, since there's nothing to inspect until the code actually runs. And a worker a package never spells out as a specifier anywhere — no relative path, no subpath export, located via some entirely runtime-only scheme — can't be resolved by any static, build-time plugin, for this or any other bundler.
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
297
357
|
## State Management
|
|
298
358
|
|
|
299
359
|
NukeJS ships a lightweight built-in store for sharing state across client components. Because each `"use client"` component is hydrated into its own independent React root, React Context cannot cross component boundaries — the store solves this.
|
|
@@ -1336,6 +1396,7 @@ dist/
|
|
|
1336
1396
|
├── static/
|
|
1337
1397
|
│ ├── __n.js # NukeJS client runtime (React + NukeJS bundled together)
|
|
1338
1398
|
│ ├── __client-component/ # Bundled "use client" component files
|
|
1399
|
+
│ ├── __worker/ # Auto-discovered Web Worker bundles (see Web Workers)
|
|
1339
1400
|
│ └── <app/public files> # Copied from app/public/ at build time
|
|
1340
1401
|
├── manifest.json # Route dispatch table
|
|
1341
1402
|
└── index.mjs # HTTP server entry point
|
|
@@ -1357,6 +1418,7 @@ The build output goes to `.cloudflare/output/`:
|
|
|
1357
1418
|
└── static/
|
|
1358
1419
|
├── __n.js # NukeJS client runtime
|
|
1359
1420
|
├── __client-component/ # Bundled "use client" component files
|
|
1421
|
+
├── __worker/ # Auto-discovered Web Worker bundles (see Web Workers) — unrelated to _worker.mjs above, which is Cloudflare's own deployment unit
|
|
1360
1422
|
└── <app/public files> # Copied from app/public/ at build time
|
|
1361
1423
|
```
|
|
1362
1424
|
|
package/dist/app.js
CHANGED
|
@@ -5,7 +5,7 @@ import { c, log, setDebugLevel, getDebugLevel } from "./logger.js";
|
|
|
5
5
|
import { loadConfig } from "./config.js";
|
|
6
6
|
import { discoverApiPrefixes, matchApiPrefix, createApiHandler } from "./http-server.js";
|
|
7
7
|
import { loadMiddleware, runMiddleware } from "./middleware-loader.js";
|
|
8
|
-
import { serveReactBundle, serveNukeBundle, serveClientComponentBundle } from "./bundler.js";
|
|
8
|
+
import { serveReactBundle, serveNukeBundle, serveClientComponentBundle, serveWorkerAsset } from "./bundler.js";
|
|
9
9
|
import { serverSideRender } from "./ssr.js";
|
|
10
10
|
import { watchDir, broadcastRestart } from "./hmr.js";
|
|
11
11
|
const isDev = process.env.ENVIRONMENT !== "production";
|
|
@@ -67,6 +67,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
67
67
|
url.slice(20).split("?")[0].replace(".js", ""),
|
|
68
68
|
res
|
|
69
69
|
);
|
|
70
|
+
if (url.startsWith("/__worker/"))
|
|
71
|
+
return await serveWorkerAsset(url.slice("/__worker/".length).split("?")[0], res);
|
|
70
72
|
if (matchApiPrefix(url, apiPrefixes))
|
|
71
73
|
return await handleApiRoute(url, req, res);
|
|
72
74
|
return await serverSideRender(url, res, PAGES_DIR, isDev, req);
|
package/dist/build-common.js
CHANGED
|
@@ -4,6 +4,7 @@ import { randomBytes } from "node:crypto";
|
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from "url";
|
|
5
5
|
import { build } from "esbuild";
|
|
6
6
|
import { findClientComponentsInTree } from "./component-analyzer.js";
|
|
7
|
+
import { WorkerAssetRegistry, createWorkerUrlPlugin } from "./worker-assets.js";
|
|
7
8
|
const NODE_BUILTINS = [
|
|
8
9
|
"node:*",
|
|
9
10
|
"http",
|
|
@@ -871,6 +872,9 @@ async function bundleClientComponents(globalRegistry, pagesDir, staticDir) {
|
|
|
871
872
|
if (globalRegistry.size === 0) return /* @__PURE__ */ new Map();
|
|
872
873
|
const outDir = path.join(staticDir, "__client-component");
|
|
873
874
|
fs.mkdirSync(outDir, { recursive: true });
|
|
875
|
+
const workerOutDir = path.join(staticDir, "__worker");
|
|
876
|
+
const workerRegistry = new WorkerAssetRegistry({ outDir: workerOutDir, minify: true });
|
|
877
|
+
const workerPlugin = createWorkerUrlPlugin(workerRegistry);
|
|
874
878
|
const entryPoints = {};
|
|
875
879
|
for (const [id, filePath] of globalRegistry) {
|
|
876
880
|
entryPoints[id] = filePath;
|
|
@@ -895,10 +899,14 @@ async function bundleClientComponents(globalRegistry, pagesDir, staticDir) {
|
|
|
895
899
|
define: { "process.env.NODE_ENV": '"production"' },
|
|
896
900
|
entryNames: "[name]",
|
|
897
901
|
// cc_abc123.js (no hash on entries)
|
|
898
|
-
chunkNames: "__chunks/[hash]"
|
|
902
|
+
chunkNames: "__chunks/[hash]",
|
|
899
903
|
// __chunks/ABCDEF.js
|
|
904
|
+
plugins: [workerPlugin]
|
|
900
905
|
});
|
|
901
906
|
console.log(` bundled ${globalRegistry.size} client component(s) \u2192 ${path.relative(process.cwd(), outDir)}/`);
|
|
907
|
+
if (workerRegistry.size > 0) {
|
|
908
|
+
console.log(` bundled ${workerRegistry.size} worker asset(s) \u2192 ${path.relative(process.cwd(), workerOutDir)}/`);
|
|
909
|
+
}
|
|
902
910
|
const prerendered = /* @__PURE__ */ new Map();
|
|
903
911
|
for (const [id, filePath] of globalRegistry) {
|
|
904
912
|
const ssrTmp = path.join(
|
|
@@ -915,7 +923,13 @@ async function bundleClientComponents(globalRegistry, pagesDir, staticDir) {
|
|
|
915
923
|
jsx: "automatic",
|
|
916
924
|
packages: "external",
|
|
917
925
|
define: { "process.env.NODE_ENV": '"production"' },
|
|
918
|
-
write: false
|
|
926
|
+
write: false,
|
|
927
|
+
// Same registry as Pass 1 — any worker already bundled there is
|
|
928
|
+
// reused from cache. Keeps this SSR-only bundle's source consistent
|
|
929
|
+
// with the browser bundle in case any worker-referencing code path
|
|
930
|
+
// is reachable during prerendering (it also means, if it ever is,
|
|
931
|
+
// `new URL(...)` won't throw for pointing at a moved temp file).
|
|
932
|
+
plugins: [workerPlugin]
|
|
919
933
|
});
|
|
920
934
|
fs.writeFileSync(ssrTmp, ssrResult.outputFiles[0].text);
|
|
921
935
|
const { default: Component } = await import(pathToFileURL(ssrTmp).href);
|
package/dist/bundler.js
CHANGED
|
@@ -5,9 +5,11 @@ import { fileURLToPath } from "url";
|
|
|
5
5
|
import { build } from "esbuild";
|
|
6
6
|
import { log } from "./logger.js";
|
|
7
7
|
import { getComponentCache } from "./component-analyzer.js";
|
|
8
|
+
import { WorkerAssetRegistry, createWorkerUrlPlugin } from "./worker-assets.js";
|
|
8
9
|
let reactBundlePromise = null;
|
|
9
10
|
let nukeBundlePromise = null;
|
|
10
11
|
const SPLIT_OUT_DIR = path.join(os.tmpdir(), "nukejs-dev-components");
|
|
12
|
+
const WORKER_OUT_DIR = path.join(os.tmpdir(), "nukejs-dev-workers");
|
|
11
13
|
let splitBuildValid = false;
|
|
12
14
|
let splitBuildPromise = null;
|
|
13
15
|
async function buildAllComponentsSplit() {
|
|
@@ -28,6 +30,7 @@ async function buildAllComponentsSplit() {
|
|
|
28
30
|
}
|
|
29
31
|
fs.mkdirSync(SPLIT_OUT_DIR, { recursive: true });
|
|
30
32
|
log.verbose(`[bundler] Split build: ${clientComponents.size} component(s) \u2192 ${SPLIT_OUT_DIR}`);
|
|
33
|
+
const workerRegistry = new WorkerAssetRegistry({ outDir: WORKER_OUT_DIR, minify: false });
|
|
31
34
|
await build({
|
|
32
35
|
entryPoints,
|
|
33
36
|
bundle: true,
|
|
@@ -49,9 +52,13 @@ async function buildAllComponentsSplit() {
|
|
|
49
52
|
define: { "process.env.NODE_ENV": '"development"' },
|
|
50
53
|
entryNames: "[name]",
|
|
51
54
|
// cc_abc123.js (stable, no hash)
|
|
52
|
-
chunkNames: "__chunks/[hash]"
|
|
55
|
+
chunkNames: "__chunks/[hash]",
|
|
53
56
|
// __chunks/ABCDEF.js
|
|
57
|
+
plugins: [createWorkerUrlPlugin(workerRegistry)]
|
|
54
58
|
});
|
|
59
|
+
if (workerRegistry.size > 0) {
|
|
60
|
+
log.verbose(`[bundler] Bundled ${workerRegistry.size} worker asset(s) \u2192 ${WORKER_OUT_DIR}`);
|
|
61
|
+
}
|
|
55
62
|
splitBuildValid = true;
|
|
56
63
|
log.verbose("[bundler] Split build complete");
|
|
57
64
|
}
|
|
@@ -85,6 +92,18 @@ async function serveClientComponentBundle(componentId, res) {
|
|
|
85
92
|
res.setHeader("Content-Type", "application/javascript");
|
|
86
93
|
res.end(fs.readFileSync(outPath));
|
|
87
94
|
}
|
|
95
|
+
async function serveWorkerAsset(fileName, res) {
|
|
96
|
+
const outPath = path.join(WORKER_OUT_DIR, fileName);
|
|
97
|
+
const base = WORKER_OUT_DIR.endsWith(path.sep) ? WORKER_OUT_DIR : WORKER_OUT_DIR + path.sep;
|
|
98
|
+
if (!outPath.startsWith(base) || !fs.existsSync(outPath)) {
|
|
99
|
+
log.error(`Worker asset not found: ${fileName}`);
|
|
100
|
+
res.statusCode = 404;
|
|
101
|
+
res.end("Worker asset not found");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
res.setHeader("Content-Type", "application/javascript");
|
|
105
|
+
res.end(fs.readFileSync(outPath));
|
|
106
|
+
}
|
|
88
107
|
async function serveReactBundle(res) {
|
|
89
108
|
log.verbose("Bundling React runtime");
|
|
90
109
|
if (!reactBundlePromise) {
|
|
@@ -157,5 +176,6 @@ export {
|
|
|
157
176
|
invalidateSplitBundle,
|
|
158
177
|
serveClientComponentBundle,
|
|
159
178
|
serveNukeBundle,
|
|
160
|
-
serveReactBundle
|
|
179
|
+
serveReactBundle,
|
|
180
|
+
serveWorkerAsset
|
|
161
181
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export type { RequestContext } from './use-request';
|
|
|
9
9
|
export { normaliseHeaders, sanitiseHeaders, getRequestStore } from './request-store';
|
|
10
10
|
export { cache } from './cache-store';
|
|
11
11
|
export { default as Link } from './Link';
|
|
12
|
+
export { renderComponent } from './render-component';
|
|
13
|
+
export type { RenderComponentOptions } from './render-component';
|
|
12
14
|
export { setupLocationChangeMonitor, initRuntime } from './bundle';
|
|
13
15
|
export type { RuntimeData } from './bundle';
|
|
14
16
|
export { escapeHtml } from './utils';
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { useRequest } from "./use-request.js";
|
|
|
5
5
|
import { normaliseHeaders, sanitiseHeaders, getRequestStore } from "./request-store.js";
|
|
6
6
|
import { cache } from "./cache-store.js";
|
|
7
7
|
import { default as default3 } from "./Link.js";
|
|
8
|
+
import { renderComponent } from "./render-component.js";
|
|
8
9
|
import { setupLocationChangeMonitor, initRuntime } from "./bundle.js";
|
|
9
10
|
import { escapeHtml } from "./utils.js";
|
|
10
11
|
import { ansi, c, log, setDebugLevel, getDebugLevel } from "./logger.js";
|
|
@@ -21,6 +22,7 @@ export {
|
|
|
21
22
|
initRuntime,
|
|
22
23
|
log,
|
|
23
24
|
normaliseHeaders,
|
|
25
|
+
renderComponent,
|
|
24
26
|
sanitiseHeaders,
|
|
25
27
|
setDebugLevel,
|
|
26
28
|
setupLocationChangeMonitor,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createElement } from "react";
|
|
2
|
+
import { pathToFileURL } from "url";
|
|
3
|
+
import { tsImport } from "tsx/esm/api";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { renderDocument } from "./ssr.js";
|
|
6
|
+
async function renderComponent(filePath, props = {}, options = {}) {
|
|
7
|
+
const layoutPaths = options.layoutPaths ?? [];
|
|
8
|
+
const { default: Component } = await tsImport(pathToFileURL(filePath).href, { parentURL: import.meta.url });
|
|
9
|
+
let element = createElement(Component, props);
|
|
10
|
+
for (let i = layoutPaths.length - 1; i >= 0; i--) {
|
|
11
|
+
const { default: Layout } = await tsImport(pathToFileURL(layoutPaths[i]).href, { parentURL: import.meta.url });
|
|
12
|
+
element = createElement(Layout, { children: element });
|
|
13
|
+
}
|
|
14
|
+
return renderDocument({
|
|
15
|
+
registryEntryFiles: [filePath, ...layoutPaths],
|
|
16
|
+
pagesDir: options.pagesDir ?? path.resolve("./app/pages"),
|
|
17
|
+
element,
|
|
18
|
+
url: options.url ?? "/",
|
|
19
|
+
params: options.params,
|
|
20
|
+
query: options.query,
|
|
21
|
+
headers: options.headers,
|
|
22
|
+
isDev: options.isDev ?? process.env.ENVIRONMENT !== "production",
|
|
23
|
+
// Always false here: the whole point is a real server-rendered
|
|
24
|
+
// document for crawlers, not just a hydration target. skipClientSSR
|
|
25
|
+
// exists in renderDocument only for ssr.ts's dev-mode HMR fast path.
|
|
26
|
+
skipClientSSR: false,
|
|
27
|
+
defaultTitle: options.title ?? "NukeJS"
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export {
|
|
31
|
+
renderComponent
|
|
32
|
+
};
|
package/dist/ssr.js
CHANGED
|
@@ -92,34 +92,25 @@ function renderManagedBodyScripts(store) {
|
|
|
92
92
|
if (bodyScripts.length === 0) return [];
|
|
93
93
|
return [" <!--n-body-scripts-->", ...bodyScripts.map(renderScriptTag), " <!--/n-body-scripts-->"];
|
|
94
94
|
}
|
|
95
|
-
async function
|
|
95
|
+
async function renderDocument(options) {
|
|
96
|
+
const {
|
|
97
|
+
registryEntryFiles,
|
|
98
|
+
pagesDir,
|
|
99
|
+
element,
|
|
100
|
+
url,
|
|
101
|
+
params = {},
|
|
102
|
+
query = {},
|
|
103
|
+
headers = {},
|
|
104
|
+
isDev = false,
|
|
105
|
+
skipClientSSR = false,
|
|
106
|
+
defaultTitle = "NukeJS"
|
|
107
|
+
} = options;
|
|
96
108
|
const cleanUrl = url.split("?")[0];
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
searchParams.forEach((_, k) => {
|
|
100
|
-
if (!(k in params)) {
|
|
101
|
-
const all = searchParams.getAll(k);
|
|
102
|
-
queryParams[k] = all.length > 1 ? all : all[0];
|
|
103
|
-
}
|
|
104
|
-
});
|
|
105
|
-
const mergedParams = { ...queryParams, ...params };
|
|
106
|
-
const rawHeaders = req?.headers ?? {};
|
|
107
|
-
const normHeaders = normaliseHeaders(rawHeaders);
|
|
108
|
-
const safeHeaders = sanitiseHeaders(rawHeaders);
|
|
109
|
-
const layoutPaths = findLayoutsForRoute(filePath, pagesDir);
|
|
110
|
-
const { default: PageComponent } = await tsImport(
|
|
111
|
-
pathToFileURL(filePath).href,
|
|
112
|
-
{ parentURL: import.meta.url }
|
|
113
|
-
);
|
|
114
|
-
const wrappedElement = await wrapWithLayouts(
|
|
115
|
-
createElement(PageComponent, mergedParams),
|
|
116
|
-
layoutPaths
|
|
117
|
-
);
|
|
109
|
+
const normHeaders = normaliseHeaders(headers);
|
|
110
|
+
const safeHeaders = sanitiseHeaders(headers);
|
|
118
111
|
const registry = /* @__PURE__ */ new Map();
|
|
119
|
-
for (const
|
|
120
|
-
|
|
121
|
-
for (const layoutPath of layoutPaths)
|
|
122
|
-
for (const [id, p] of findClientComponentsInTree(layoutPath, pagesDir))
|
|
112
|
+
for (const entryFile of registryEntryFiles)
|
|
113
|
+
for (const [id, p] of findClientComponentsInTree(entryFile, pagesDir))
|
|
123
114
|
registry.set(id, p);
|
|
124
115
|
const ctx = { registry, hydrated: /* @__PURE__ */ new Set(), skipClientSSR };
|
|
125
116
|
let appHtml = "";
|
|
@@ -128,14 +119,14 @@ async function renderFile(filePath, params, url, pagesDir, isDev, res, req, stat
|
|
|
128
119
|
url,
|
|
129
120
|
pathname: cleanUrl,
|
|
130
121
|
params,
|
|
131
|
-
query
|
|
122
|
+
query,
|
|
132
123
|
headers: normHeaders
|
|
133
124
|
},
|
|
134
125
|
() => runWithCacheStore(() => runWithHtmlStore(async () => {
|
|
135
|
-
appHtml = await renderElementToHtml(
|
|
126
|
+
appHtml = await renderElementToHtml(element, ctx);
|
|
136
127
|
}))
|
|
137
128
|
);
|
|
138
|
-
const pageTitle = resolveTitle(store.titleOps,
|
|
129
|
+
const pageTitle = resolveTitle(store.titleOps, defaultTitle);
|
|
139
130
|
const headLines = [
|
|
140
131
|
' <meta charset="utf-8" />',
|
|
141
132
|
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
@@ -147,13 +138,13 @@ async function renderFile(filePath, params, url, pagesDir, isDev, res, req, stat
|
|
|
147
138
|
allIds: [...registry.keys()],
|
|
148
139
|
url,
|
|
149
140
|
params,
|
|
150
|
-
query
|
|
141
|
+
query,
|
|
151
142
|
headers: safeHeaders,
|
|
152
143
|
debug: toClientDebugLevel(getDebugLevel())
|
|
153
144
|
}).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
|
|
154
145
|
const bodyScriptLines = renderManagedBodyScripts(store);
|
|
155
146
|
const bodyScriptsHtml = bodyScriptLines.length > 0 ? "\n" + bodyScriptLines.join("\n") + "\n" : "";
|
|
156
|
-
|
|
147
|
+
return `<!DOCTYPE html>
|
|
157
148
|
${openTag("html", store.htmlAttrs)}
|
|
158
149
|
<head>
|
|
159
150
|
${headLines.join("\n")}
|
|
@@ -184,6 +175,38 @@ ${openTag("body", store.bodyAttrs)}
|
|
|
184
175
|
${isDev ? '<script type="module" src="/__hmr.js"></script>' : ""}
|
|
185
176
|
${bodyScriptsHtml}</body>
|
|
186
177
|
</html>`;
|
|
178
|
+
}
|
|
179
|
+
async function renderFile(filePath, params, url, pagesDir, isDev, res, req, statusCode, skipClientSSR) {
|
|
180
|
+
const searchParams = new URL(url, "http://localhost").searchParams;
|
|
181
|
+
const queryParams = {};
|
|
182
|
+
searchParams.forEach((_, k) => {
|
|
183
|
+
if (!(k in params)) {
|
|
184
|
+
const all = searchParams.getAll(k);
|
|
185
|
+
queryParams[k] = all.length > 1 ? all : all[0];
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
const mergedParams = { ...queryParams, ...params };
|
|
189
|
+
const layoutPaths = findLayoutsForRoute(filePath, pagesDir);
|
|
190
|
+
const { default: PageComponent } = await tsImport(
|
|
191
|
+
pathToFileURL(filePath).href,
|
|
192
|
+
{ parentURL: import.meta.url }
|
|
193
|
+
);
|
|
194
|
+
const wrappedElement = await wrapWithLayouts(
|
|
195
|
+
createElement(PageComponent, mergedParams),
|
|
196
|
+
layoutPaths
|
|
197
|
+
);
|
|
198
|
+
const html = await renderDocument({
|
|
199
|
+
registryEntryFiles: [filePath, ...layoutPaths],
|
|
200
|
+
pagesDir,
|
|
201
|
+
element: wrappedElement,
|
|
202
|
+
url,
|
|
203
|
+
params,
|
|
204
|
+
query: queryParams,
|
|
205
|
+
headers: req?.headers ?? {},
|
|
206
|
+
isDev,
|
|
207
|
+
skipClientSSR,
|
|
208
|
+
defaultTitle: "NukeJS"
|
|
209
|
+
});
|
|
187
210
|
res.statusCode = statusCode;
|
|
188
211
|
res.setHeader("Content-Type", "text/html");
|
|
189
212
|
res.end(html);
|
|
@@ -259,5 +282,6 @@ async function serverSideRender(url, res, pagesDir, isDev = false, req) {
|
|
|
259
282
|
}
|
|
260
283
|
}
|
|
261
284
|
export {
|
|
285
|
+
renderDocument,
|
|
262
286
|
serverSideRender
|
|
263
287
|
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { createRequire } from "module";
|
|
4
|
+
import { build } from "esbuild";
|
|
5
|
+
import { log } from "./logger.js";
|
|
6
|
+
const WORKER_URL_PREFIX = "/__worker/";
|
|
7
|
+
const NEW_URL_RE = /new\s+URL\s*\(\s*(['"`])([^'"`]+)\1\s*,\s*import\.meta\.url\s*\)/g;
|
|
8
|
+
const RESOLVABLE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs"];
|
|
9
|
+
const URL_SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
|
|
10
|
+
function isCandidateSpecifier(spec) {
|
|
11
|
+
if (spec.startsWith("/")) return false;
|
|
12
|
+
if (URL_SCHEME_RE.test(spec)) return false;
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
class WorkerAssetRegistry {
|
|
16
|
+
constructor(opts) {
|
|
17
|
+
this.opts = opts;
|
|
18
|
+
}
|
|
19
|
+
built = /* @__PURE__ */ new Map();
|
|
20
|
+
pending = /* @__PURE__ */ new Map();
|
|
21
|
+
/** Number of distinct worker assets bundled so far in this registry. */
|
|
22
|
+
get size() {
|
|
23
|
+
return this.built.size;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Resolves `rawSpecifier` (e.g. './parser.worker.ts', or a bare package
|
|
27
|
+
* specifier like 'monaco-editor/esm/.../json.worker.js') against the file
|
|
28
|
+
* that referenced it, bundling it on first use, and returns the public URL
|
|
29
|
+
* to substitute into the rewritten `new URL(...)` call.
|
|
30
|
+
*
|
|
31
|
+
* Returns null when the specifier isn't a candidate at all (see
|
|
32
|
+
* isCandidateSpecifier) or doesn't resolve to a real file on disk. The
|
|
33
|
+
* caller leaves the original expression untouched in that case — this is
|
|
34
|
+
* the mechanism that keeps non-local / dynamic `new URL(..., import.meta.url)`
|
|
35
|
+
* usages (including anything not meant to be a worker) working exactly as
|
|
36
|
+
* before.
|
|
37
|
+
*/
|
|
38
|
+
async resolve(rawSpecifier, importerPath) {
|
|
39
|
+
if (!isCandidateSpecifier(rawSpecifier)) return null;
|
|
40
|
+
const absPath = resolveWorkerSpecifier(importerPath, rawSpecifier);
|
|
41
|
+
if (!absPath) return null;
|
|
42
|
+
const cached = this.built.get(absPath);
|
|
43
|
+
if (cached) return cached.publicUrl;
|
|
44
|
+
let inflight = this.pending.get(absPath);
|
|
45
|
+
if (!inflight) {
|
|
46
|
+
inflight = this.buildOne(absPath);
|
|
47
|
+
this.pending.set(absPath, inflight);
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const result = await inflight;
|
|
51
|
+
return result.publicUrl;
|
|
52
|
+
} finally {
|
|
53
|
+
this.pending.delete(absPath);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async buildOne(absPath) {
|
|
57
|
+
fs.mkdirSync(this.opts.outDir, { recursive: true });
|
|
58
|
+
log.verbose(`[worker-assets] bundling ${path.relative(process.cwd(), absPath)}`);
|
|
59
|
+
const result = await build({
|
|
60
|
+
entryPoints: [absPath],
|
|
61
|
+
bundle: true,
|
|
62
|
+
format: "esm",
|
|
63
|
+
platform: "browser",
|
|
64
|
+
target: "es2020",
|
|
65
|
+
jsx: "automatic",
|
|
66
|
+
minify: this.opts.minify,
|
|
67
|
+
write: true,
|
|
68
|
+
outdir: this.opts.outDir,
|
|
69
|
+
// esbuild computes [hash] from the output contents, so the filename
|
|
70
|
+
// — and therefore the emitted URL — changes automatically whenever the
|
|
71
|
+
// worker's own source (or anything it imports) changes. That's the
|
|
72
|
+
// "hashing/versioning" the rest of the framework's asset pipeline
|
|
73
|
+
// already relies on (see chunkNames in bundler.ts / build-common.ts).
|
|
74
|
+
entryNames: "[name]-[hash]",
|
|
75
|
+
metafile: true,
|
|
76
|
+
define: { "process.env.NODE_ENV": this.opts.minify ? '"production"' : '"development"' },
|
|
77
|
+
conditions: ["module", "browser", "import"]
|
|
78
|
+
});
|
|
79
|
+
const outputEntry = Object.entries(result.metafile.outputs).find(([, o]) => o.entryPoint);
|
|
80
|
+
if (!outputEntry) {
|
|
81
|
+
throw new Error(`[worker-assets] esbuild produced no output for ${absPath}`);
|
|
82
|
+
}
|
|
83
|
+
const outputPath = path.resolve(outputEntry[0]);
|
|
84
|
+
const publicUrl = WORKER_URL_PREFIX + path.basename(outputPath);
|
|
85
|
+
const info = { sourcePath: absPath, outputPath, publicUrl };
|
|
86
|
+
this.built.set(absPath, info);
|
|
87
|
+
log.verbose(`[worker-assets] ${path.relative(process.cwd(), absPath)} -> ${publicUrl}`);
|
|
88
|
+
return info;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function resolveWorkerSpecifier(importerPath, rawSpecifier) {
|
|
92
|
+
if (rawSpecifier.startsWith("./") || rawSpecifier.startsWith("../")) {
|
|
93
|
+
return resolveRelativeWorkerFile(path.dirname(importerPath), rawSpecifier);
|
|
94
|
+
}
|
|
95
|
+
return resolvePackageWorkerFile(importerPath, rawSpecifier);
|
|
96
|
+
}
|
|
97
|
+
function resolveRelativeWorkerFile(importerDir, rawSpecifier) {
|
|
98
|
+
const ext = path.extname(rawSpecifier);
|
|
99
|
+
const withoutExt = ext ? rawSpecifier.slice(0, -ext.length) : rawSpecifier;
|
|
100
|
+
const candidates = [rawSpecifier, ...RESOLVABLE_EXTENSIONS.map((e) => withoutExt + e)];
|
|
101
|
+
const tried = /* @__PURE__ */ new Set();
|
|
102
|
+
for (const candidate of candidates) {
|
|
103
|
+
if (tried.has(candidate)) continue;
|
|
104
|
+
tried.add(candidate);
|
|
105
|
+
const abs = path.resolve(importerDir, candidate);
|
|
106
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return abs;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
function resolvePackageWorkerFile(importerPath, rawSpecifier) {
|
|
111
|
+
try {
|
|
112
|
+
const resolve = createRequire(importerPath).resolve;
|
|
113
|
+
const resolved = resolve(rawSpecifier);
|
|
114
|
+
return fs.existsSync(resolved) ? resolved : null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function createWorkerUrlPlugin(registry) {
|
|
120
|
+
return {
|
|
121
|
+
name: "nukejs-worker-url",
|
|
122
|
+
setup(pluginBuild) {
|
|
123
|
+
pluginBuild.onLoad({ filter: /\.[jt]sx?$/ }, async (args) => {
|
|
124
|
+
const source = await fs.promises.readFile(args.path, "utf8");
|
|
125
|
+
if (!source.includes("import.meta.url")) return null;
|
|
126
|
+
const matches = [...source.matchAll(NEW_URL_RE)];
|
|
127
|
+
if (matches.length === 0) return null;
|
|
128
|
+
let contents = source;
|
|
129
|
+
for (let i = matches.length - 1; i >= 0; i--) {
|
|
130
|
+
const match = matches[i];
|
|
131
|
+
const [full, , rawSpecifier] = match;
|
|
132
|
+
const publicUrl = await registry.resolve(rawSpecifier, args.path);
|
|
133
|
+
if (!publicUrl) continue;
|
|
134
|
+
const start = match.index;
|
|
135
|
+
const end = start + full.length;
|
|
136
|
+
const replacement = `new URL(${JSON.stringify(publicUrl)}, import.meta.url)`;
|
|
137
|
+
contents = contents.slice(0, start) + replacement + contents.slice(end);
|
|
138
|
+
}
|
|
139
|
+
if (contents === source) return null;
|
|
140
|
+
const ext = path.extname(args.path).slice(1);
|
|
141
|
+
const loader = ext === "ts" || ext === "tsx" || ext === "jsx" ? ext : "js";
|
|
142
|
+
return { contents, loader, resolveDir: path.dirname(args.path) };
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
export {
|
|
148
|
+
WORKER_URL_PREFIX,
|
|
149
|
+
WorkerAssetRegistry,
|
|
150
|
+
createWorkerUrlPlugin
|
|
151
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nukejs",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.30",
|
|
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",
|