nukejs 0.0.27 → 0.0.29

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)
@@ -25,6 +26,7 @@ npm create nuke@latest
25
26
  - [Configuration](#configuration)
26
27
  - [Link Component & Navigation](#link-component--navigation)
27
28
  - [useRequest() — URL Params, Query & Headers](#userequest--url-params-query--headers)
29
+ - [cache() — Request-Scoped Data Caching](#cache--request-scoped-data-caching)
28
30
  - [Error Pages](#error-pages)
29
31
  - [Building & Deploying](#building--deploying)
30
32
 
@@ -293,6 +295,65 @@ Children and other React elements can be passed as props — NukeJS serializes t
293
295
 
294
296
  ---
295
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
+
296
357
  ## State Management
297
358
 
298
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.
@@ -1104,6 +1165,73 @@ Changing `?lang=fr` in the URL re-renders client components automatically.
1104
1165
 
1105
1166
  ---
1106
1167
 
1168
+ ## cache() — Request-Scoped Data Caching
1169
+
1170
+ Server components render independently, so if two components in the same page tree both need the same data, NukeJS has no way to know they're asking for the same thing — each one triggers its own database call, fetch, or computation, even though they're part of the same request:
1171
+
1172
+ ```tsx
1173
+ // app/components/Nav.tsx
1174
+ export default async function Nav() {
1175
+ const user = await db.getUser(useRequest().params.id); // DB call #1
1176
+ return <nav>{user.name}</nav>;
1177
+ }
1178
+
1179
+ // app/pages/dashboard.tsx
1180
+ export default async function Dashboard() {
1181
+ const user = await db.getUser(useRequest().params.id); // DB call #2 — same data
1182
+ return <main>{user.email}</main>;
1183
+ }
1184
+ ```
1185
+
1186
+ `cache()` wraps an async function so repeated calls with the same arguments, **within the same request**, share a single call:
1187
+
1188
+ ```ts
1189
+ // lib/data.ts
1190
+ import { cache } from 'nukejs';
1191
+
1192
+ export const getUser = cache(async (id: string) => {
1193
+ return db.getUser(id); // only runs once per request, however many
1194
+ // components call getUser(id) with that id
1195
+ });
1196
+ ```
1197
+
1198
+ ```tsx
1199
+ // app/components/Nav.tsx
1200
+ import { getUser } from '../../lib/data';
1201
+
1202
+ export default async function Nav() {
1203
+ const user = await getUser(useRequest().params.id as string);
1204
+ return <nav>{user.name}</nav>;
1205
+ }
1206
+
1207
+ // app/pages/dashboard.tsx
1208
+ import { getUser } from '../../lib/data';
1209
+
1210
+ export default async function Dashboard() {
1211
+ const user = await getUser(useRequest().params.id as string); // same call → no extra DB hit
1212
+ return <main>{user.email}</main>;
1213
+ }
1214
+ ```
1215
+
1216
+ Whichever component calls `getUser('42')` first triggers the real call; every other call with `'42'` in that same request — even ones that fire concurrently, before the first has resolved — awaits that same in-flight promise instead of starting a new one.
1217
+
1218
+ ### Why not just use React's `cache()`?
1219
+
1220
+ React ships its own `cache()`, but it relies on the React Server Components renderer to signal "this is a new request, reset the memoization table." NukeJS doesn't run the RSC protocol — it renders each request to a single HTML string (see [Pages & Routing](#pages--routing)) — so nothing would ever tell React's `cache()` to reset, risking one request's cached data leaking into another's response. NukeJS's `cache()` has the same signature and mental model as React's, but is tied to NukeJS's own per-request lifecycle instead, so it resets correctly on every request.
1221
+
1222
+ ### Scope & behaviour
1223
+
1224
+ - **One request, one cache.** The cache is created fresh before rendering starts and discarded once the response is sent. There's no cross-request caching and nothing to invalidate — the next request simply starts with an empty cache.
1225
+ - **Keyed by function + arguments.** Two different `cache()`-wrapped functions never share entries, even if called with identical arguments. Arguments are compared by value (via JSON serialization), so `getUser('42')` and `getUser('42')` hit the same entry, but `getUser('42')` and `getUser('43')` don't.
1226
+ - **Concurrent calls are deduped too.** If two components call the same cached function before the first call has settled, both await the same pending promise — only one underlying call is made.
1227
+ - **Failed calls aren't stuck.** If a cached call rejects, that entry is removed immediately, so a later call with the same arguments in the same request retries instead of replaying the same error.
1228
+ - **Non-serializable arguments skip caching.** Arguments that can't be safely compared by value (functions, Symbols, cyclic objects) just call straight through, unmemoized, rather than being cached under an incorrect key.
1229
+ - **Safe to call anywhere.** Outside of an active request — a script, a test, client-side code — a `cache()`-wrapped function simply calls straight through with no memoization. It's always safe to import and call.
1230
+
1231
+ `cache()` is a pure optimization: removing it (or calling the wrapped function directly) never changes what your app returns, only how many times the underlying work runs.
1232
+
1233
+ ---
1234
+
1107
1235
  ## Error Pages
1108
1236
 
1109
1237
  NukeJS supports custom error pages for **404 Not Found** and **500 Internal Server Error**. Place them directly in `app/pages/` — they are standard server components and support everything regular pages do: layouts, `useHtml()`, client components, and HMR in dev.
@@ -1268,6 +1396,7 @@ dist/
1268
1396
  ├── static/
1269
1397
  │ ├── __n.js # NukeJS client runtime (React + NukeJS bundled together)
1270
1398
  │ ├── __client-component/ # Bundled "use client" component files
1399
+ │ ├── __worker/ # Auto-discovered Web Worker bundles (see Web Workers)
1271
1400
  │ └── <app/public files> # Copied from app/public/ at build time
1272
1401
  ├── manifest.json # Route dispatch table
1273
1402
  └── index.mjs # HTTP server entry point
@@ -1289,6 +1418,7 @@ The build output goes to `.cloudflare/output/`:
1289
1418
  └── static/
1290
1419
  ├── __n.js # NukeJS client runtime
1291
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
1292
1422
  └── <app/public files> # Copied from app/public/ at build time
1293
1423
  ```
1294
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",
@@ -387,6 +388,46 @@ async function runWithRequestStore<T>(ctx: any, fn: () => Promise<T>): Promise<T
387
388
  try { return await fn(); } finally { __setReq(null); }
388
389
  }
389
390
 
391
+ // \u2500\u2500\u2500 cache-store (inlined) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
392
+ // See src/cache-store.ts for the full explanation. Request-scoped memoisation
393
+ // for async data loaders, keyed on the wrapped function + its (serialisable)
394
+ // arguments. Reset per request exactly like the request/html stores above.
395
+ const __CACHE_KEY__ = Symbol.for('__nukejs_cache_store__');
396
+ const __getCache = (): { entries: Map<string, Promise<unknown>> } | null =>
397
+ (globalThis as any)[__CACHE_KEY__] ?? null;
398
+ const __setCache = (v: { entries: Map<string, Promise<unknown>> } | null): void => {
399
+ (globalThis as any)[__CACHE_KEY__] = v;
400
+ };
401
+ async function runWithCacheStore<T>(fn: () => Promise<T>): Promise<T> {
402
+ __setCache({ entries: new Map() });
403
+ try { return await fn(); } finally { __setCache(null); }
404
+ }
405
+ let __cacheIdCounter = 0;
406
+ function __serialiseArgs(args: unknown[]): string | null {
407
+ try {
408
+ return JSON.stringify(args, (_k, v) => {
409
+ if (typeof v === 'function' || typeof v === 'symbol') throw new Error('non-serialisable');
410
+ return v;
411
+ });
412
+ } catch { return null; }
413
+ }
414
+ function cache<Args extends unknown[], R>(fn: (...args: Args) => Promise<R>): (...args: Args) => Promise<R> {
415
+ const id = \`c\${++__cacheIdCounter}\`;
416
+ return function cached(...args: Args): Promise<R> {
417
+ const store = __getCache();
418
+ if (!store) return fn(...args);
419
+ const argsKey = __serialiseArgs(args);
420
+ if (argsKey === null) return fn(...args);
421
+ const key = \`\${id}:\${argsKey}\`;
422
+ const existing = store.entries.get(key);
423
+ if (existing) return existing as Promise<R>;
424
+ const result = fn(...args);
425
+ store.entries.set(key, result);
426
+ result.catch(() => { store.entries.delete(key); });
427
+ return result;
428
+ };
429
+ }
430
+
390
431
  // \u2500\u2500\u2500 HTML helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
391
432
  function escapeHtml(s: string): string {
392
433
  return String(s)
@@ -688,7 +729,7 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
688
729
  let appHtml = '';
689
730
  const store = await runWithRequestStore(
690
731
  { url, pathname, params, query, headers: normHeaders },
691
- () => runWithHtmlStore(async () => { appHtml = await renderNode(wrapped, hydrated); }),
732
+ () => runWithCacheStore(() => runWithHtmlStore(async () => { appHtml = await renderNode(wrapped, hydrated); })),
692
733
  );
693
734
 
694
735
  const pageTitle = resolveTitle(store.titleOps, 'NukeJS');
@@ -831,6 +872,9 @@ async function bundleClientComponents(globalRegistry, pagesDir, staticDir) {
831
872
  if (globalRegistry.size === 0) return /* @__PURE__ */ new Map();
832
873
  const outDir = path.join(staticDir, "__client-component");
833
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);
834
878
  const entryPoints = {};
835
879
  for (const [id, filePath] of globalRegistry) {
836
880
  entryPoints[id] = filePath;
@@ -855,10 +899,14 @@ async function bundleClientComponents(globalRegistry, pagesDir, staticDir) {
855
899
  define: { "process.env.NODE_ENV": '"production"' },
856
900
  entryNames: "[name]",
857
901
  // cc_abc123.js (no hash on entries)
858
- chunkNames: "__chunks/[hash]"
902
+ chunkNames: "__chunks/[hash]",
859
903
  // __chunks/ABCDEF.js
904
+ plugins: [workerPlugin]
860
905
  });
861
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
+ }
862
910
  const prerendered = /* @__PURE__ */ new Map();
863
911
  for (const [id, filePath] of globalRegistry) {
864
912
  const ssrTmp = path.join(
@@ -875,7 +923,13 @@ async function bundleClientComponents(globalRegistry, pagesDir, staticDir) {
875
923
  jsx: "automatic",
876
924
  packages: "external",
877
925
  define: { "process.env.NODE_ENV": '"production"' },
878
- 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]
879
933
  });
880
934
  fs.writeFileSync(ssrTmp, ssrResult.outputFiles[0].text);
881
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
  };
@@ -0,0 +1,48 @@
1
+ const KEY = /* @__PURE__ */ Symbol.for("__nukejs_cache_store__");
2
+ const getGlobal = () => globalThis[KEY] ?? null;
3
+ const setGlobal = (store) => {
4
+ globalThis[KEY] = store;
5
+ };
6
+ async function runWithCacheStore(fn) {
7
+ setGlobal({ entries: /* @__PURE__ */ new Map() });
8
+ try {
9
+ return await fn();
10
+ } finally {
11
+ setGlobal(null);
12
+ }
13
+ }
14
+ let cacheIdCounter = 0;
15
+ function serialiseArgs(args) {
16
+ try {
17
+ return JSON.stringify(args, (_key, value) => {
18
+ if (typeof value === "function" || typeof value === "symbol") {
19
+ throw new Error("non-serialisable value");
20
+ }
21
+ return value;
22
+ });
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+ function cache(fn) {
28
+ const cacheId = `c${++cacheIdCounter}`;
29
+ return function cached(...args) {
30
+ const store = getGlobal();
31
+ if (!store) return fn(...args);
32
+ const argsKey = serialiseArgs(args);
33
+ if (argsKey === null) return fn(...args);
34
+ const key = `${cacheId}:${argsKey}`;
35
+ const existing = store.entries.get(key);
36
+ if (existing) return existing;
37
+ const result = fn(...args);
38
+ store.entries.set(key, result);
39
+ result.catch(() => {
40
+ store.entries.delete(key);
41
+ });
42
+ return result;
43
+ };
44
+ }
45
+ export {
46
+ cache,
47
+ runWithCacheStore
48
+ };
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export { default as useRouter } from './use-router';
7
7
  export { useRequest } from './use-request';
8
8
  export type { RequestContext } from './use-request';
9
9
  export { normaliseHeaders, sanitiseHeaders, getRequestStore } from './request-store';
10
+ export { cache } from './cache-store';
10
11
  export { default as Link } from './Link';
11
12
  export { setupLocationChangeMonitor, initRuntime } from './bundle';
12
13
  export type { RuntimeData } from './bundle';
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { useHtml } from "./use-html.js";
3
3
  import { default as default2 } from "./use-router.js";
4
4
  import { useRequest } from "./use-request.js";
5
5
  import { normaliseHeaders, sanitiseHeaders, getRequestStore } from "./request-store.js";
6
+ import { cache } from "./cache-store.js";
6
7
  import { default as default3 } from "./Link.js";
7
8
  import { setupLocationChangeMonitor, initRuntime } from "./bundle.js";
8
9
  import { escapeHtml } from "./utils.js";
@@ -11,6 +12,7 @@ export {
11
12
  default3 as Link,
12
13
  ansi,
13
14
  c,
15
+ cache,
14
16
  createPersistedStore,
15
17
  createStore,
16
18
  escapeHtml,
package/dist/ssr.js CHANGED
@@ -8,6 +8,7 @@ import { matchRoute, findLayoutsForRoute } from "./router.js";
8
8
  import { findClientComponentsInTree } from "./component-analyzer.js";
9
9
  import { renderElementToHtml } from "./renderer.js";
10
10
  import { runWithRequestStore, normaliseHeaders, sanitiseHeaders } from "./request-store.js";
11
+ import { runWithCacheStore } from "./cache-store.js";
11
12
  import {
12
13
  runWithHtmlStore,
13
14
  resolveTitle
@@ -130,9 +131,9 @@ async function renderFile(filePath, params, url, pagesDir, isDev, res, req, stat
130
131
  query: queryParams,
131
132
  headers: normHeaders
132
133
  },
133
- () => runWithHtmlStore(async () => {
134
+ () => runWithCacheStore(() => runWithHtmlStore(async () => {
134
135
  appHtml = await renderElementToHtml(wrappedElement, ctx);
135
- })
136
+ }))
136
137
  );
137
138
  const pageTitle = resolveTitle(store.titleOps, "NukeJS");
138
139
  const headLines = [
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
2
  const SSR_ROUTER = { push: () => {
3
3
  }, replace: () => {
4
4
  }, back: () => {
@@ -28,7 +28,10 @@ function useRouter() {
28
28
  const refresh = useCallback(() => {
29
29
  window.dispatchEvent(new Event("locationchange"));
30
30
  }, []);
31
- return { path, push, replace, back, refresh };
31
+ return useMemo(
32
+ () => ({ path, push, replace, back, refresh }),
33
+ [path, push, replace, back, refresh]
34
+ );
32
35
  }
33
36
  export {
34
37
  useRouter as default
@@ -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.27",
3
+ "version": "0.0.29",
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",