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 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);
@@ -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 renderFile(filePath, params, url, pagesDir, isDev, res, req, statusCode, skipClientSSR) {
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 searchParams = new URL(url, "http://localhost").searchParams;
98
- const queryParams = {};
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 [id, p] of findClientComponentsInTree(filePath, pagesDir))
120
- registry.set(id, p);
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: queryParams,
122
+ query,
132
123
  headers: normHeaders
133
124
  },
134
125
  () => runWithCacheStore(() => runWithHtmlStore(async () => {
135
- appHtml = await renderElementToHtml(wrappedElement, ctx);
126
+ appHtml = await renderElementToHtml(element, ctx);
136
127
  }))
137
128
  );
138
- const pageTitle = resolveTitle(store.titleOps, "NukeJS");
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: queryParams,
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
- const html = `<!DOCTYPE html>
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.28",
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",