nukejs 0.0.27 → 0.0.28
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 +68 -0
- package/dist/build-common.js +41 -1
- 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/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,6 +25,7 @@ npm create nuke@latest
|
|
|
25
25
|
- [Configuration](#configuration)
|
|
26
26
|
- [Link Component & Navigation](#link-component--navigation)
|
|
27
27
|
- [useRequest() — URL Params, Query & Headers](#userequest--url-params-query--headers)
|
|
28
|
+
- [cache() — Request-Scoped Data Caching](#cache--request-scoped-data-caching)
|
|
28
29
|
- [Error Pages](#error-pages)
|
|
29
30
|
- [Building & Deploying](#building--deploying)
|
|
30
31
|
|
|
@@ -1104,6 +1105,73 @@ Changing `?lang=fr` in the URL re-renders client components automatically.
|
|
|
1104
1105
|
|
|
1105
1106
|
---
|
|
1106
1107
|
|
|
1108
|
+
## cache() — Request-Scoped Data Caching
|
|
1109
|
+
|
|
1110
|
+
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:
|
|
1111
|
+
|
|
1112
|
+
```tsx
|
|
1113
|
+
// app/components/Nav.tsx
|
|
1114
|
+
export default async function Nav() {
|
|
1115
|
+
const user = await db.getUser(useRequest().params.id); // DB call #1
|
|
1116
|
+
return <nav>{user.name}</nav>;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// app/pages/dashboard.tsx
|
|
1120
|
+
export default async function Dashboard() {
|
|
1121
|
+
const user = await db.getUser(useRequest().params.id); // DB call #2 — same data
|
|
1122
|
+
return <main>{user.email}</main>;
|
|
1123
|
+
}
|
|
1124
|
+
```
|
|
1125
|
+
|
|
1126
|
+
`cache()` wraps an async function so repeated calls with the same arguments, **within the same request**, share a single call:
|
|
1127
|
+
|
|
1128
|
+
```ts
|
|
1129
|
+
// lib/data.ts
|
|
1130
|
+
import { cache } from 'nukejs';
|
|
1131
|
+
|
|
1132
|
+
export const getUser = cache(async (id: string) => {
|
|
1133
|
+
return db.getUser(id); // only runs once per request, however many
|
|
1134
|
+
// components call getUser(id) with that id
|
|
1135
|
+
});
|
|
1136
|
+
```
|
|
1137
|
+
|
|
1138
|
+
```tsx
|
|
1139
|
+
// app/components/Nav.tsx
|
|
1140
|
+
import { getUser } from '../../lib/data';
|
|
1141
|
+
|
|
1142
|
+
export default async function Nav() {
|
|
1143
|
+
const user = await getUser(useRequest().params.id as string);
|
|
1144
|
+
return <nav>{user.name}</nav>;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// app/pages/dashboard.tsx
|
|
1148
|
+
import { getUser } from '../../lib/data';
|
|
1149
|
+
|
|
1150
|
+
export default async function Dashboard() {
|
|
1151
|
+
const user = await getUser(useRequest().params.id as string); // same call → no extra DB hit
|
|
1152
|
+
return <main>{user.email}</main>;
|
|
1153
|
+
}
|
|
1154
|
+
```
|
|
1155
|
+
|
|
1156
|
+
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.
|
|
1157
|
+
|
|
1158
|
+
### Why not just use React's `cache()`?
|
|
1159
|
+
|
|
1160
|
+
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.
|
|
1161
|
+
|
|
1162
|
+
### Scope & behaviour
|
|
1163
|
+
|
|
1164
|
+
- **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.
|
|
1165
|
+
- **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.
|
|
1166
|
+
- **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.
|
|
1167
|
+
- **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.
|
|
1168
|
+
- **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.
|
|
1169
|
+
- **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.
|
|
1170
|
+
|
|
1171
|
+
`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.
|
|
1172
|
+
|
|
1173
|
+
---
|
|
1174
|
+
|
|
1107
1175
|
## Error Pages
|
|
1108
1176
|
|
|
1109
1177
|
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.
|
package/dist/build-common.js
CHANGED
|
@@ -387,6 +387,46 @@ async function runWithRequestStore<T>(ctx: any, fn: () => Promise<T>): Promise<T
|
|
|
387
387
|
try { return await fn(); } finally { __setReq(null); }
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
+
// \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
|
|
391
|
+
// See src/cache-store.ts for the full explanation. Request-scoped memoisation
|
|
392
|
+
// for async data loaders, keyed on the wrapped function + its (serialisable)
|
|
393
|
+
// arguments. Reset per request exactly like the request/html stores above.
|
|
394
|
+
const __CACHE_KEY__ = Symbol.for('__nukejs_cache_store__');
|
|
395
|
+
const __getCache = (): { entries: Map<string, Promise<unknown>> } | null =>
|
|
396
|
+
(globalThis as any)[__CACHE_KEY__] ?? null;
|
|
397
|
+
const __setCache = (v: { entries: Map<string, Promise<unknown>> } | null): void => {
|
|
398
|
+
(globalThis as any)[__CACHE_KEY__] = v;
|
|
399
|
+
};
|
|
400
|
+
async function runWithCacheStore<T>(fn: () => Promise<T>): Promise<T> {
|
|
401
|
+
__setCache({ entries: new Map() });
|
|
402
|
+
try { return await fn(); } finally { __setCache(null); }
|
|
403
|
+
}
|
|
404
|
+
let __cacheIdCounter = 0;
|
|
405
|
+
function __serialiseArgs(args: unknown[]): string | null {
|
|
406
|
+
try {
|
|
407
|
+
return JSON.stringify(args, (_k, v) => {
|
|
408
|
+
if (typeof v === 'function' || typeof v === 'symbol') throw new Error('non-serialisable');
|
|
409
|
+
return v;
|
|
410
|
+
});
|
|
411
|
+
} catch { return null; }
|
|
412
|
+
}
|
|
413
|
+
function cache<Args extends unknown[], R>(fn: (...args: Args) => Promise<R>): (...args: Args) => Promise<R> {
|
|
414
|
+
const id = \`c\${++__cacheIdCounter}\`;
|
|
415
|
+
return function cached(...args: Args): Promise<R> {
|
|
416
|
+
const store = __getCache();
|
|
417
|
+
if (!store) return fn(...args);
|
|
418
|
+
const argsKey = __serialiseArgs(args);
|
|
419
|
+
if (argsKey === null) return fn(...args);
|
|
420
|
+
const key = \`\${id}:\${argsKey}\`;
|
|
421
|
+
const existing = store.entries.get(key);
|
|
422
|
+
if (existing) return existing as Promise<R>;
|
|
423
|
+
const result = fn(...args);
|
|
424
|
+
store.entries.set(key, result);
|
|
425
|
+
result.catch(() => { store.entries.delete(key); });
|
|
426
|
+
return result;
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
390
430
|
// \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
431
|
function escapeHtml(s: string): string {
|
|
392
432
|
return String(s)
|
|
@@ -688,7 +728,7 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
|
|
|
688
728
|
let appHtml = '';
|
|
689
729
|
const store = await runWithRequestStore(
|
|
690
730
|
{ url, pathname, params, query, headers: normHeaders },
|
|
691
|
-
() => runWithHtmlStore(async () => { appHtml = await renderNode(wrapped, hydrated); }),
|
|
731
|
+
() => runWithCacheStore(() => runWithHtmlStore(async () => { appHtml = await renderNode(wrapped, hydrated); })),
|
|
692
732
|
);
|
|
693
733
|
|
|
694
734
|
const pageTitle = resolveTitle(store.titleOps, 'NukeJS');
|
|
@@ -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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nukejs",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.28",
|
|
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",
|