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 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.
@@ -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 = [
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nukejs",
3
- "version": "0.0.27",
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",