hadars 0.1.9 → 0.1.11

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/dist/loader.cjs CHANGED
@@ -21,14 +21,135 @@ __export(loader_exports, {
21
21
  default: () => loader
22
22
  });
23
23
  module.exports = __toCommonJS(loader_exports);
24
- const LOAD_MODULE_RE = /\bloadModule\s*(?:<.*?>\s*)?\(\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1\s*\)/gs;
25
24
  function loader(source) {
26
25
  const isServer = this.target === "node" || this.target === "async-node";
27
- return source.replace(LOAD_MODULE_RE, (_match, quote, modulePath) => {
28
- if (isServer) {
29
- return `Promise.resolve(require(${quote}${modulePath}${quote}))`;
26
+ const resourcePath = this.resourcePath ?? this.resource ?? "(unknown)";
27
+ let swc;
28
+ try {
29
+ swc = require("@swc/core");
30
+ } catch {
31
+ return regexTransform.call(this, source, isServer, resourcePath);
32
+ }
33
+ return swcTransform.call(this, swc, source, isServer, resourcePath);
34
+ }
35
+ function swcTransform(swc, source, isServer, resourcePath) {
36
+ const isTs = /\.[mc]?tsx?$/.test(resourcePath);
37
+ const isTsx = /\.(tsx|jsx)$/.test(resourcePath);
38
+ let ast;
39
+ try {
40
+ ast = swc.parseSync(source, {
41
+ syntax: isTs ? "typescript" : "ecmascript",
42
+ tsx: isTsx
43
+ });
44
+ } catch {
45
+ return regexTransform.call(this, source, isServer, resourcePath);
46
+ }
47
+ const srcBytes = Buffer.from(source, "utf8");
48
+ const fileOffset = ast.span.start - countLeadingNonCodeBytes(source);
49
+ const replacements = [];
50
+ walkAst(ast, (node) => {
51
+ if (node.type !== "CallExpression")
52
+ return;
53
+ const callee = node.callee;
54
+ if (!callee || callee.type !== "Identifier" || callee.value !== "loadModule")
55
+ return;
56
+ const args = node.arguments;
57
+ if (!args || args.length === 0)
58
+ return;
59
+ const firstArg = args[0].expression ?? args[0];
60
+ let modulePath;
61
+ let quoteChar;
62
+ if (firstArg.type === "StringLiteral") {
63
+ modulePath = firstArg.value;
64
+ const quoteByteIdx = firstArg.span.start - fileOffset;
65
+ quoteChar = String.fromCharCode(srcBytes[quoteByteIdx]);
66
+ } else if (firstArg.type === "TemplateLiteral" && firstArg.expressions.length === 0 && firstArg.quasis.length === 1) {
67
+ modulePath = firstArg.quasis[0].raw;
68
+ quoteChar = "`";
30
69
  } else {
31
- return `import(${quote}${modulePath}${quote})`;
70
+ const start0 = node.span.start - fileOffset;
71
+ const bytesBefore = srcBytes.slice(0, start0);
72
+ const line = bytesBefore.toString("utf8").split("\n").length;
73
+ this.emitWarning(
74
+ new Error(
75
+ `[hadars] loadModule() called with a dynamic (non-literal) path at ${resourcePath}:${line}. Only string-literal paths are transformed by the loader; dynamic calls fall back to runtime import().`
76
+ )
77
+ );
78
+ return;
32
79
  }
80
+ const replacement = isServer ? `Promise.resolve(require(${quoteChar}${modulePath}${quoteChar}))` : `import(${quoteChar}${modulePath}${quoteChar})`;
81
+ replacements.push({ start: node.span.start - fileOffset, end: node.span.end - fileOffset, replacement });
33
82
  });
83
+ if (replacements.length === 0)
84
+ return source;
85
+ replacements.sort((a, b) => b.start - a.start);
86
+ let result = srcBytes;
87
+ for (const { start, end, replacement } of replacements) {
88
+ result = Buffer.concat([result.slice(0, start), Buffer.from(replacement, "utf8"), result.slice(end)]);
89
+ }
90
+ return result.toString("utf8");
91
+ }
92
+ function walkAst(node, visit) {
93
+ if (!node || typeof node !== "object")
94
+ return;
95
+ visit(node);
96
+ for (const key of Object.keys(node)) {
97
+ if (key === "span" || key === "type" || key === "ctxt")
98
+ continue;
99
+ const val = node[key];
100
+ if (Array.isArray(val)) {
101
+ for (const child of val)
102
+ walkAst(child, visit);
103
+ } else if (val && typeof val === "object") {
104
+ walkAst(val, visit);
105
+ }
106
+ }
107
+ }
108
+ function countLeadingNonCodeBytes(source) {
109
+ let i = 0;
110
+ while (i < source.length) {
111
+ if (source[i] === " " || source[i] === " " || source[i] === "\r" || source[i] === "\n") {
112
+ i++;
113
+ continue;
114
+ }
115
+ if (source[i] === "/" && source[i + 1] === "/") {
116
+ while (i < source.length && source[i] !== "\n")
117
+ i++;
118
+ continue;
119
+ }
120
+ if (source[i] === "/" && source[i + 1] === "*") {
121
+ i += 2;
122
+ while (i + 1 < source.length && !(source[i] === "*" && source[i + 1] === "/"))
123
+ i++;
124
+ if (i + 1 < source.length)
125
+ i += 2;
126
+ continue;
127
+ }
128
+ if (i === 0 && source[i] === "#" && source[i + 1] === "!") {
129
+ while (i < source.length && source[i] !== "\n")
130
+ i++;
131
+ continue;
132
+ }
133
+ break;
134
+ }
135
+ return Buffer.byteLength(source.slice(0, i), "utf8");
136
+ }
137
+ const LOAD_MODULE_RE = /\bloadModule\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1\s*\)/gs;
138
+ const DYNAMIC_LOAD_MODULE_RE = /\bloadModule\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(/g;
139
+ function regexTransform(source, isServer, resourcePath) {
140
+ const transformed = source.replace(
141
+ LOAD_MODULE_RE,
142
+ (_match, quote, modulePath) => isServer ? `Promise.resolve(require(${quote}${modulePath}${quote}))` : `import(${quote}${modulePath}${quote})`
143
+ );
144
+ let match;
145
+ DYNAMIC_LOAD_MODULE_RE.lastIndex = 0;
146
+ while ((match = DYNAMIC_LOAD_MODULE_RE.exec(transformed)) !== null) {
147
+ const line = transformed.slice(0, match.index).split("\n").length;
148
+ this.emitWarning(
149
+ new Error(
150
+ `[hadars] loadModule() called with a dynamic (non-literal) path at ${resourcePath}:${line}. Only string-literal paths are transformed by the loader; dynamic calls fall back to runtime import().`
151
+ )
152
+ );
153
+ }
154
+ return transformed;
34
155
  }
@@ -108,6 +108,9 @@ async function runFullLifecycle(serialReq) {
108
108
  await Promise.all(pending);
109
109
  }
110
110
  } while (unsuspend.hasPending && ++iters < 25);
111
+ if (unsuspend.hasPending) {
112
+ console.warn("[hadars] SSR render loop hit the 25-iteration cap \u2014 some useServerData values may not be resolved. Check for data dependencies that are never fulfilled.");
113
+ }
111
114
  props = getAfterRenderProps ? await getAfterRenderProps(props, html) : props;
112
115
  try {
113
116
  globalThis.__hadarsUnsuspend = unsuspend;
@@ -175,8 +175,10 @@ export const useApp = () => React.useContext(AppContext);
175
175
  const clientServerDataCache = new Map<string, unknown>();
176
176
 
177
177
  /** Call this before hydrating to seed the client cache from the server's data.
178
- * Invoked automatically by the hadars client bootstrap. */
178
+ * Invoked automatically by the hadars client bootstrap.
179
+ * Always clears the existing cache before populating — call with `{}` to just clear. */
179
180
  export function initServerDataCache(data: Record<string, unknown>) {
181
+ clientServerDataCache.clear();
180
182
  for (const [k, v] of Object.entries(data)) {
181
183
  clientServerDataCache.set(k, v);
182
184
  }
@@ -208,7 +210,7 @@ export function initServerDataCache(data: Record<string, unknown>) {
208
210
  * if (!user) return null; // undefined while pending on the first SSR pass
209
211
  */
210
212
  export function useServerData<T>(key: string | string[], fn: () => Promise<T> | T): T | undefined {
211
- const cacheKey = Array.isArray(key) ? key.join('\x00') : key;
213
+ const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
212
214
 
213
215
  if (typeof window !== 'undefined') {
214
216
  // Client: if the server serialised a value for this key, return it directly
@@ -32,8 +32,12 @@ const main = async () => {
32
32
 
33
33
  const { location } = props;
34
34
 
35
- if ( appMod.getClientProps ) {
36
- props = await appMod.getClientProps(props);
35
+ if (appMod.getClientProps) {
36
+ try {
37
+ props = await appMod.getClientProps(props);
38
+ } catch (err) {
39
+ console.error('[hadars] getClientProps threw an error:', err);
40
+ }
37
41
  }
38
42
 
39
43
  props = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hadars",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Minimal SSR framework for React — rspack, HMR, TypeScript, Bun/Node/Deno",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -32,6 +32,7 @@
32
32
  "build:lib": "tsup src/index.tsx --format esm,cjs --dts --out-dir dist --clean --external '@rspack/*' --external '@rspack/binding'",
33
33
  "build:cli": "node build-scripts/build-cli.mjs",
34
34
  "build:all": "npm run build:lib && npm run build:cli",
35
+ "test": "bun test test/ssr.test.ts",
35
36
  "prepare": "npm run build:all",
36
37
  "prepublishOnly": "npm run build:all"
37
38
  },
@@ -51,8 +52,8 @@
51
52
  "node": ">=18.0.0"
52
53
  },
53
54
  "peerDependencies": {
54
- "react": "^19.0.0",
55
- "react-dom": "^19.0.0",
55
+ "react": "^19.1.1",
56
+ "react-dom": "^19.1.1",
56
57
  "typescript": "^5"
57
58
  },
58
59
  "peerDependenciesMeta": {
@@ -62,28 +63,28 @@
62
63
  },
63
64
  "optionalDependencies": {
64
65
  "@rspack/core": "1.4.9",
65
- "@rspack/dev-server": "^1.1.4",
66
- "@rspack/plugin-react-refresh": "^1.5.0",
66
+ "@rspack/dev-server": "^1.2.1",
67
+ "@rspack/plugin-react-refresh": "^1.6.1",
68
+ "@swc/core": "^1.15.18",
67
69
  "@types/bun": "latest",
68
- "@types/react-dom": "^19.1.9",
70
+ "@types/react-dom": "^19.2.3",
69
71
  "react-refresh": "^0.17.0",
70
- "typescript": "^5"
72
+ "typescript": "^5.9.3"
71
73
  },
72
74
  "devDependencies": {
73
- "@types/react": "^19.0.0",
75
+ "@types/react": "^19.2.14",
74
76
  "@types/react-dom": "^19.0.0",
75
- "esbuild": "^0.19.0",
76
- "tsup": "^6.6.0",
77
- "@swc/core": "^1.4.0"
77
+ "esbuild": "^0.19.12",
78
+ "tsup": "^6.7.0"
78
79
  },
79
80
  "dependencies": {
80
81
  "@mdx-js/loader": "^3.1.1",
81
82
  "@mdx-js/react": "^3.1.1",
82
83
  "@svgr/webpack": "^8.1.0",
83
- "@tailwindcss/postcss": "^4.1.12",
84
- "postcss": "^8.5.6",
85
- "postcss-loader": "^8.2.0",
86
- "tailwindcss": "^4.1.12"
84
+ "@tailwindcss/postcss": "^4.2.1",
85
+ "postcss": "^8.5.8",
86
+ "postcss-loader": "^8.2.1",
87
+ "tailwindcss": "^4.2.1"
87
88
  },
88
89
  "license": "MIT",
89
90
  "repository": {
package/src/build.ts CHANGED
@@ -10,6 +10,7 @@ import { RspackDevServer } from "@rspack/dev-server";
10
10
  import pathMod from "node:path";
11
11
  import { fileURLToPath, pathToFileURL } from 'node:url';
12
12
  import { createRequire } from 'node:module';
13
+ import crypto from 'node:crypto';
13
14
  import fs from 'node:fs/promises';
14
15
  import { existsSync } from 'node:fs';
15
16
  import os from 'node:os';
@@ -53,6 +54,9 @@ class RenderWorkerPool {
53
54
  private workerPending = new Map<any, Set<number>>();
54
55
  private nextId = 0;
55
56
  private rrIndex = 0;
57
+ private _Worker: any = null;
58
+ private _workerPath = '';
59
+ private _ssrBundlePath = '';
56
60
 
57
61
  constructor(workerPath: string, size: number, ssrBundlePath: string) {
58
62
  // Dynamically import Worker so this class can be defined at module load
@@ -61,36 +65,42 @@ class RenderWorkerPool {
61
65
  }
62
66
 
63
67
  private _init(workerPath: string, size: number, ssrBundlePath: string) {
68
+ this._workerPath = workerPath;
69
+ this._ssrBundlePath = ssrBundlePath;
64
70
  import('node:worker_threads').then(({ Worker }) => {
65
- for (let i = 0; i < size; i++) {
66
- const w = new Worker(workerPath, { workerData: { ssrBundlePath } });
67
- this.workerPending.set(w, new Set());
68
- w.on('message', (msg: any) => {
69
- const { id, html, headHtml, status, error } = msg;
70
- const p = this.pending.get(id);
71
- if (!p) return;
72
- this.pending.delete(id);
73
- this.workerPending.get(w)?.delete(id);
74
- if (error) p.reject(new Error(error));
75
- else p.resolve({ html, headHtml, status });
76
- });
77
- w.on('error', (err: Error) => {
78
- console.error('[hadars] Render worker error:', err);
79
- this._handleWorkerDeath(w, err);
80
- });
81
- w.on('exit', (code: number) => {
82
- if (code !== 0) {
83
- console.error(`[hadars] Render worker exited with code ${code}`);
84
- this._handleWorkerDeath(w, new Error(`Render worker exited with code ${code}`));
85
- }
86
- });
87
- this.workers.push(w);
88
- }
71
+ this._Worker = Worker;
72
+ for (let i = 0; i < size; i++) this._spawnWorker();
89
73
  }).catch(err => {
90
74
  console.error('[hadars] Failed to initialise render worker pool:', err);
91
75
  });
92
76
  }
93
77
 
78
+ private _spawnWorker() {
79
+ if (!this._Worker) return;
80
+ const w = new this._Worker(this._workerPath, { workerData: { ssrBundlePath: this._ssrBundlePath } });
81
+ this.workerPending.set(w, new Set());
82
+ w.on('message', (msg: any) => {
83
+ const { id, html, headHtml, status, error } = msg;
84
+ const p = this.pending.get(id);
85
+ if (!p) return;
86
+ this.pending.delete(id);
87
+ this.workerPending.get(w)?.delete(id);
88
+ if (error) p.reject(new Error(error));
89
+ else p.resolve({ html, headHtml, status });
90
+ });
91
+ w.on('error', (err: Error) => {
92
+ console.error('[hadars] Render worker error:', err);
93
+ this._handleWorkerDeath(w, err);
94
+ });
95
+ w.on('exit', (code: number) => {
96
+ if (code !== 0) {
97
+ console.error(`[hadars] Render worker exited with code ${code}`);
98
+ this._handleWorkerDeath(w, new Error(`Render worker exited with code ${code}`));
99
+ }
100
+ });
101
+ this.workers.push(w);
102
+ }
103
+
94
104
  private _handleWorkerDeath(w: any, err: Error) {
95
105
  const idx = this.workers.indexOf(w);
96
106
  if (idx !== -1) this.workers.splice(idx, 1);
@@ -106,6 +116,10 @@ class RenderWorkerPool {
106
116
  }
107
117
  this.workerPending.delete(w);
108
118
  }
119
+
120
+ // Spawn a replacement to keep the pool at full capacity.
121
+ console.log('[hadars] Spawning replacement render worker');
122
+ this._spawnWorker();
109
123
  }
110
124
 
111
125
  private nextWorker(): any | undefined {
@@ -216,6 +230,96 @@ const makePrecontentHtmlGetter = (htmlFilePromise: Promise<string>) => {
216
230
  };
217
231
  };
218
232
 
233
+ // ── SSR response cache ────────────────────────────────────────────────────────
234
+
235
+ interface CacheEntry {
236
+ /** Gzip-compressed response body — much cheaper to keep in RAM than raw HTML. */
237
+ body: Uint8Array;
238
+ status: number;
239
+ /** Headers with Content-Encoding: gzip already set. */
240
+ headers: [string, string][];
241
+ expiresAt: number | null;
242
+ }
243
+
244
+ type CacheFetchHandler = (req: Request, ctx: any) => Promise<Response | undefined>;
245
+
246
+ async function transformStream(data: Uint8Array, stream: { writable: WritableStream; readable: ReadableStream<Uint8Array> }): Promise<Uint8Array> {
247
+ const writer = stream.writable.getWriter();
248
+ writer.write(data);
249
+ writer.close();
250
+ const chunks: Uint8Array[] = [];
251
+ const reader = stream.readable.getReader();
252
+ while (true) {
253
+ const { done, value } = await reader.read();
254
+ if (done) break;
255
+ chunks.push(value);
256
+ }
257
+ const total = chunks.reduce((n, c) => n + c.length, 0);
258
+ const out = new Uint8Array(total);
259
+ let offset = 0;
260
+ for (const c of chunks) { out.set(c, offset); offset += c.length; }
261
+ return out;
262
+ }
263
+
264
+ const gzipCompress = (d: Uint8Array) => transformStream(d, new (globalThis as any).CompressionStream('gzip'));
265
+ const gzipDecompress = (d: Uint8Array) => transformStream(d, new (globalThis as any).DecompressionStream('gzip'));
266
+
267
+ function createRenderCache(
268
+ opts: NonNullable<HadarsOptions['cache']>,
269
+ handler: CacheFetchHandler,
270
+ ): CacheFetchHandler {
271
+ const store = new Map<string, CacheEntry>();
272
+
273
+ return async (req, ctx) => {
274
+ const hadarsReq = parseRequest(req);
275
+ const cacheOpts = await opts(hadarsReq);
276
+ const key = cacheOpts?.key ?? null;
277
+
278
+ if (key != null) {
279
+ const entry = store.get(key);
280
+ if (entry) {
281
+ if (entry.expiresAt == null || Date.now() < entry.expiresAt) {
282
+ const accept = req.headers.get('Accept-Encoding') ?? '';
283
+ if (accept.includes('gzip')) {
284
+ return new Response(entry.body.buffer as ArrayBuffer, { status: entry.status, headers: entry.headers });
285
+ }
286
+ // Client doesn't support gzip — decompress before serving
287
+ const plain = await gzipDecompress(entry.body);
288
+ const headers = entry.headers.filter(([k]) => k.toLowerCase() !== 'content-encoding');
289
+ return new Response(plain.buffer as ArrayBuffer, { status: entry.status, headers });
290
+ }
291
+ store.delete(key);
292
+ }
293
+ }
294
+
295
+ const res = await handler(req, ctx);
296
+
297
+ // Compress and cache in the background via clone() so the original
298
+ // stream is returned to the client immediately.
299
+ if (key != null && res) {
300
+ const ttl = cacheOpts?.ttl;
301
+ res.clone().arrayBuffer().then(async (buf) => {
302
+ const body = await gzipCompress(new Uint8Array(buf));
303
+ const headers: [string, string][] = [];
304
+ res.headers.forEach((v, k) => {
305
+ if (k.toLowerCase() !== 'content-encoding' && k.toLowerCase() !== 'content-length') {
306
+ headers.push([k, v]);
307
+ }
308
+ });
309
+ headers.push(['content-encoding', 'gzip']);
310
+ store.set(key, {
311
+ body,
312
+ status: res.status,
313
+ headers,
314
+ expiresAt: ttl != null ? Date.now() + ttl : null,
315
+ });
316
+ }).catch(() => { /* ignore read errors on clone */ });
317
+ }
318
+
319
+ return res;
320
+ };
321
+ }
322
+
219
323
  interface HadarsRuntimeOptions extends HadarsOptions {
220
324
  mode: "development" | "production";
221
325
  }
@@ -319,7 +423,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
319
423
  await fs.writeFile(tmpFilePath, clientScript);
320
424
 
321
425
  // SSR live-reload id to force re-import
322
- let ssrBuildId = Date.now();
426
+ let ssrBuildId = crypto.randomBytes(4).toString('hex');
323
427
 
324
428
  // Start rspack-dev-server for the client bundle. It provides true React
325
429
  // Fast Refresh HMR: the browser's HMR runtime connects directly to the
@@ -435,7 +539,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
435
539
  const chunk = decoder.decode(value, { stream: true });
436
540
  try { process.stdout.write(chunk); } catch (e) { }
437
541
  if (chunk.includes(rebuildMarker)) {
438
- ssrBuildId = Date.now();
542
+ ssrBuildId = crypto.randomBytes(4).toString('hex');
439
543
  console.log('[hadars] SSR bundle updated, build id:', ssrBuildId);
440
544
  }
441
545
  }
@@ -491,25 +595,34 @@ export const dev = async (options: HadarsRuntimeOptions) => {
491
595
  // (cache-busting key) rather than a literal filename character on Linux.
492
596
  const importPath = pathToFileURL(ssrComponentPath).href + `?t=${ssrBuildId}`;
493
597
 
494
- const {
495
- default: Component,
496
- getInitProps,
497
- getAfterRenderProps,
498
- getFinalProps,
499
- } = (await import(importPath)) as HadarsEntryModule<any>;
500
-
501
- const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
502
- document: {
503
- body: Component as React.FC<HadarsProps<object>>,
504
- lang: 'en',
598
+ try {
599
+ const {
600
+ default: Component,
505
601
  getInitProps,
506
602
  getAfterRenderProps,
507
603
  getFinalProps,
508
- },
509
- });
604
+ } = (await import(importPath)) as HadarsEntryModule<any>;
605
+
606
+ const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
607
+ document: {
608
+ body: Component as React.FC<HadarsProps<object>>,
609
+ lang: 'en',
610
+ getInitProps,
611
+ getAfterRenderProps,
612
+ getFinalProps,
613
+ },
614
+ });
510
615
 
511
- const unsuspend = (renderPayload.appProps.context as any)?._unsuspend ?? null;
512
- return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
616
+ const unsuspend = (renderPayload.appProps.context as any)?._unsuspend ?? null;
617
+ return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
618
+ } catch (err: any) {
619
+ console.error('[hadars] SSR render error:', err);
620
+ const msg = (err?.stack ?? err?.message ?? String(err)).replace(/</g, '&lt;');
621
+ return new Response(`<!doctype html><pre style="white-space:pre-wrap">${msg}</pre>`, {
622
+ status: 500,
623
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
624
+ });
625
+ }
513
626
  }, options.websocket);
514
627
  };
515
628
 
@@ -616,7 +729,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
616
729
  fs.readFile(pathMod.join(__dirname, StaticPath, 'out.html'), 'utf-8')
617
730
  );
618
731
 
619
- await serve(port, async (req, ctx) => {
732
+ const runHandler: CacheFetchHandler = async (req, ctx) => {
620
733
  const request = parseRequest(req);
621
734
  if (handler) {
622
735
  const res = await handler(request);
@@ -656,35 +769,47 @@ export const run = async (options: HadarsRuntimeOptions) => {
656
769
  const componentPath = pathToFileURL(
657
770
  pathMod.resolve(__dirname, HadarsFolder, SSR_FILENAME)
658
771
  ).href;
659
- const {
660
- default: Component,
661
- getInitProps,
662
- getAfterRenderProps,
663
- getFinalProps,
664
- } = (await import(componentPath)) as HadarsEntryModule<any>;
665
-
666
- if (renderPool) {
667
- // Worker runs the full lifecycle — no non-serializable objects cross the thread boundary.
668
- const serialReq = await serializeRequest(request);
669
- const { html, headHtml: wHead, status: wStatus } = await renderPool.renderFull(serialReq);
670
- const [precontentHtml, postContent] = await getPrecontentHtml(wHead);
671
- return new Response(precontentHtml + html + postContent, {
672
- headers: { 'Content-Type': 'text/html; charset=utf-8' },
673
- status: wStatus,
674
- });
675
- }
676
772
 
677
- const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
678
- document: {
679
- body: Component as React.FC<HadarsProps<object>>,
680
- lang: 'en',
773
+ try {
774
+ const {
775
+ default: Component,
681
776
  getInitProps,
682
777
  getAfterRenderProps,
683
778
  getFinalProps,
684
- },
685
- });
779
+ } = (await import(componentPath)) as HadarsEntryModule<any>;
780
+
781
+ if (renderPool) {
782
+ // Worker runs the full lifecycle — no non-serializable objects cross the thread boundary.
783
+ const serialReq = await serializeRequest(request);
784
+ const { html, headHtml: wHead, status: wStatus } = await renderPool.renderFull(serialReq);
785
+ const [precontentHtml, postContent] = await getPrecontentHtml(wHead);
786
+ return new Response(precontentHtml + html + postContent, {
787
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
788
+ status: wStatus,
789
+ });
790
+ }
686
791
 
687
- const unsuspend = (renderPayload.appProps.context as any)?._unsuspend ?? null;
688
- return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
689
- }, options.websocket);
792
+ const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
793
+ document: {
794
+ body: Component as React.FC<HadarsProps<object>>,
795
+ lang: 'en',
796
+ getInitProps,
797
+ getAfterRenderProps,
798
+ getFinalProps,
799
+ },
800
+ });
801
+
802
+ const unsuspend = (renderPayload.appProps.context as any)?._unsuspend ?? null;
803
+ return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
804
+ } catch (err: any) {
805
+ console.error('[hadars] SSR render error:', err);
806
+ return new Response('Internal Server Error', { status: 500 });
807
+ }
808
+ };
809
+
810
+ await serve(
811
+ port,
812
+ options.cache ? createRenderCache(options.cache, runHandler) : runHandler,
813
+ options.websocket,
814
+ );
690
815
  };
@@ -146,6 +146,9 @@ async function runFullLifecycle(serialReq: SerializableRequest) {
146
146
  await Promise.all(pending);
147
147
  }
148
148
  } while (unsuspend.hasPending && ++iters < 25);
149
+ if (unsuspend.hasPending) {
150
+ console.warn('[hadars] SSR render loop hit the 25-iteration cap — some useServerData values may not be resolved. Check for data dependencies that are never fulfilled.');
151
+ }
149
152
 
150
153
  props = getAfterRenderProps ? await getAfterRenderProps(props, html) : props;
151
154
 
@@ -93,6 +93,24 @@ export interface HadarsOptions {
93
93
  * Has no effect on the SSR bundle or dev mode.
94
94
  */
95
95
  optimization?: Record<string, unknown>;
96
+ /**
97
+ * SSR response cache for `run()` mode. Has no effect in `dev()` mode.
98
+ *
99
+ * Receives the incoming request and should return `{ key, ttl? }` to cache
100
+ * the response, or `null`/`undefined` to skip caching for that request.
101
+ * `ttl` is the time-to-live in milliseconds; omit for entries that never expire.
102
+ * The function may be async.
103
+ *
104
+ * @example
105
+ * // Cache every page by pathname (no per-user personalisation):
106
+ * cache: (req) => ({ key: req.pathname })
107
+ *
108
+ * @example
109
+ * // Cache with a per-route TTL, skip authenticated requests:
110
+ * cache: (req) => req.cookies.session ? null : { key: req.pathname, ttl: 60_000 }
111
+ */
112
+ cache?: (req: HadarsRequest) => { key: string; ttl?: number } | null | undefined
113
+ | Promise<{ key: string; ttl?: number } | null | undefined>;
96
114
  }
97
115
 
98
116
 
@@ -175,8 +175,10 @@ export const useApp = () => React.useContext(AppContext);
175
175
  const clientServerDataCache = new Map<string, unknown>();
176
176
 
177
177
  /** Call this before hydrating to seed the client cache from the server's data.
178
- * Invoked automatically by the hadars client bootstrap. */
178
+ * Invoked automatically by the hadars client bootstrap.
179
+ * Always clears the existing cache before populating — call with `{}` to just clear. */
179
180
  export function initServerDataCache(data: Record<string, unknown>) {
181
+ clientServerDataCache.clear();
180
182
  for (const [k, v] of Object.entries(data)) {
181
183
  clientServerDataCache.set(k, v);
182
184
  }
@@ -208,7 +210,7 @@ export function initServerDataCache(data: Record<string, unknown>) {
208
210
  * if (!user) return null; // undefined while pending on the first SSR pass
209
211
  */
210
212
  export function useServerData<T>(key: string | string[], fn: () => Promise<T> | T): T | undefined {
211
- const cacheKey = Array.isArray(key) ? key.join('\x00') : key;
213
+ const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
212
214
 
213
215
  if (typeof window !== 'undefined') {
214
216
  // Client: if the server serialised a value for this key, return it directly
@@ -32,8 +32,12 @@ const main = async () => {
32
32
 
33
33
  const { location } = props;
34
34
 
35
- if ( appMod.getClientProps ) {
36
- props = await appMod.getClientProps(props);
35
+ if (appMod.getClientProps) {
36
+ try {
37
+ props = await appMod.getClientProps(props);
38
+ } catch (err) {
39
+ console.error('[hadars] getClientProps threw an error:', err);
40
+ }
37
41
  }
38
42
 
39
43
  props = {