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 +130 -0
- package/dist/app.js +3 -1
- package/dist/build-common.js +57 -3
- package/dist/bundler.js +22 -2
- package/dist/cache-store.js +48 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/ssr.js +3 -2
- package/dist/use-router.js +5 -2
- 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)
|
|
@@ -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);
|
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",
|
|
@@ -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 = [
|
package/dist/use-router.js
CHANGED
|
@@ -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
|
|
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.
|
|
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",
|