hadars 0.1.9 → 0.1.10

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/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 {
@@ -319,7 +333,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
319
333
  await fs.writeFile(tmpFilePath, clientScript);
320
334
 
321
335
  // SSR live-reload id to force re-import
322
- let ssrBuildId = Date.now();
336
+ let ssrBuildId = crypto.randomBytes(4).toString('hex');
323
337
 
324
338
  // Start rspack-dev-server for the client bundle. It provides true React
325
339
  // Fast Refresh HMR: the browser's HMR runtime connects directly to the
@@ -435,7 +449,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
435
449
  const chunk = decoder.decode(value, { stream: true });
436
450
  try { process.stdout.write(chunk); } catch (e) { }
437
451
  if (chunk.includes(rebuildMarker)) {
438
- ssrBuildId = Date.now();
452
+ ssrBuildId = crypto.randomBytes(4).toString('hex');
439
453
  console.log('[hadars] SSR bundle updated, build id:', ssrBuildId);
440
454
  }
441
455
  }
@@ -491,25 +505,34 @@ export const dev = async (options: HadarsRuntimeOptions) => {
491
505
  // (cache-busting key) rather than a literal filename character on Linux.
492
506
  const importPath = pathToFileURL(ssrComponentPath).href + `?t=${ssrBuildId}`;
493
507
 
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',
508
+ try {
509
+ const {
510
+ default: Component,
505
511
  getInitProps,
506
512
  getAfterRenderProps,
507
513
  getFinalProps,
508
- },
509
- });
514
+ } = (await import(importPath)) as HadarsEntryModule<any>;
515
+
516
+ const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
517
+ document: {
518
+ body: Component as React.FC<HadarsProps<object>>,
519
+ lang: 'en',
520
+ getInitProps,
521
+ getAfterRenderProps,
522
+ getFinalProps,
523
+ },
524
+ });
510
525
 
511
- const unsuspend = (renderPayload.appProps.context as any)?._unsuspend ?? null;
512
- return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
526
+ const unsuspend = (renderPayload.appProps.context as any)?._unsuspend ?? null;
527
+ return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
528
+ } catch (err: any) {
529
+ console.error('[hadars] SSR render error:', err);
530
+ const msg = (err?.stack ?? err?.message ?? String(err)).replace(/</g, '&lt;');
531
+ return new Response(`<!doctype html><pre style="white-space:pre-wrap">${msg}</pre>`, {
532
+ status: 500,
533
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
534
+ });
535
+ }
513
536
  }, options.websocket);
514
537
  };
515
538
 
@@ -656,35 +679,41 @@ export const run = async (options: HadarsRuntimeOptions) => {
656
679
  const componentPath = pathToFileURL(
657
680
  pathMod.resolve(__dirname, HadarsFolder, SSR_FILENAME)
658
681
  ).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
682
 
677
- const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
678
- document: {
679
- body: Component as React.FC<HadarsProps<object>>,
680
- lang: 'en',
683
+ try {
684
+ const {
685
+ default: Component,
681
686
  getInitProps,
682
687
  getAfterRenderProps,
683
688
  getFinalProps,
684
- },
685
- });
689
+ } = (await import(componentPath)) as HadarsEntryModule<any>;
690
+
691
+ if (renderPool) {
692
+ // Worker runs the full lifecycle — no non-serializable objects cross the thread boundary.
693
+ const serialReq = await serializeRequest(request);
694
+ const { html, headHtml: wHead, status: wStatus } = await renderPool.renderFull(serialReq);
695
+ const [precontentHtml, postContent] = await getPrecontentHtml(wHead);
696
+ return new Response(precontentHtml + html + postContent, {
697
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
698
+ status: wStatus,
699
+ });
700
+ }
686
701
 
687
- const unsuspend = (renderPayload.appProps.context as any)?._unsuspend ?? null;
688
- return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
702
+ const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
703
+ document: {
704
+ body: Component as React.FC<HadarsProps<object>>,
705
+ lang: 'en',
706
+ getInitProps,
707
+ getAfterRenderProps,
708
+ getFinalProps,
709
+ },
710
+ });
711
+
712
+ const unsuspend = (renderPayload.appProps.context as any)?._unsuspend ?? null;
713
+ return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
714
+ } catch (err: any) {
715
+ console.error('[hadars] SSR render error:', err);
716
+ return new Response('Internal Server Error', { status: 500 });
717
+ }
689
718
  }, options.websocket);
690
719
  };
@@ -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
 
@@ -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 = {
@@ -10,6 +10,12 @@
10
10
  * synchronously on the server, wrapped in Promise.resolve to keep the
11
11
  * API shape identical to the client side.
12
12
  *
13
+ * Transformation strategy:
14
+ * Primary — SWC AST parsing via @swc/core. Handles any valid TS/JS syntax
15
+ * including arbitrarily-nested generics, comments, and string
16
+ * literals that contain the text "loadModule".
17
+ * Fallback — Regex transform used when @swc/core is unavailable.
18
+ *
13
19
  * Example usage:
14
20
  *
15
21
  * import { loadModule } from 'hadars';
@@ -21,21 +27,197 @@
21
27
  * const { default: fn } = await loadModule('./heavyUtil');
22
28
  */
23
29
 
24
- // Matches: loadModule('./path')
25
- // loadModule<SomeType>('./path') (TypeScript generic, any complexity)
26
- // Captures group 1 = quote char, group 2 = the module path.
27
- // The `s` flag lets `.` span newlines so multi-line generics are handled.
28
- const LOAD_MODULE_RE =
29
- /\bloadModule\s*(?:<.*?>\s*)?\(\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1\s*\)/gs;
30
-
31
30
  export default function loader(this: any, source: string): string {
32
31
  const isServer = this.target === 'node' || this.target === 'async-node';
32
+ const resourcePath: string = this.resourcePath ?? this.resource ?? '(unknown)';
33
+
34
+ let swc: any;
35
+ try {
36
+ swc = require('@swc/core');
37
+ } catch {
38
+ return regexTransform.call(this, source, isServer, resourcePath);
39
+ }
40
+
41
+ return swcTransform.call(this, swc, source, isServer, resourcePath);
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // SWC AST transform
46
+ // ---------------------------------------------------------------------------
47
+
48
+ function swcTransform(this: any, swc: any, source: string, isServer: boolean, resourcePath: string): string {
49
+ const isTs = /\.[mc]?tsx?$/.test(resourcePath);
50
+ const isTsx = /\.(tsx|jsx)$/.test(resourcePath);
51
+
52
+ let ast: any;
53
+ try {
54
+ ast = swc.parseSync(source, {
55
+ syntax: isTs ? 'typescript' : 'ecmascript',
56
+ tsx: isTsx,
57
+ });
58
+ } catch {
59
+ // Unparseable file (e.g., exotic syntax) — fall back to regex
60
+ return regexTransform.call(this, source, isServer, resourcePath);
61
+ }
62
+
63
+ // SWC spans use 1-based byte offsets into a GLOBAL SourceMap that
64
+ // accumulates across parseSync calls.
65
+ //
66
+ // `ast.span.start` = global position of the FIRST meaningful (non-comment,
67
+ // non-whitespace) token. Subtract the leading non-code bytes to get the
68
+ // true global start of byte 0 of this source file.
69
+ //
70
+ // We do NOT use `ast.span.end - srcBytes.length` because `ast.span.end`
71
+ // only reaches the last AST token and does not include trailing whitespace
72
+ // or newlines — causing a systematic off-by-one for the typical file that
73
+ // ends with `\n`.
74
+ const srcBytes = Buffer.from(source, 'utf8');
75
+ const fileOffset = ast.span.start - countLeadingNonCodeBytes(source);
33
76
 
34
- return source.replace(LOAD_MODULE_RE, (_match, quote, modulePath) => {
35
- if (isServer) {
36
- return `Promise.resolve(require(${quote}${modulePath}${quote}))`;
77
+ const replacements: Array<{ start: number; end: number; replacement: string }> = [];
78
+
79
+ walkAst(ast, (node: any) => {
80
+ if (node.type !== 'CallExpression') return;
81
+
82
+ const callee = node.callee;
83
+ if (!callee || callee.type !== 'Identifier' || callee.value !== 'loadModule') return;
84
+
85
+ const args: any[] = node.arguments;
86
+ if (!args || args.length === 0) return;
87
+
88
+ const firstArg = args[0].expression ?? args[0];
89
+
90
+ let modulePath: string;
91
+ let quoteChar: string;
92
+
93
+ if (firstArg.type === 'StringLiteral') {
94
+ modulePath = firstArg.value;
95
+ // The quote char (' " `) is always ASCII so byte index == char index here.
96
+ const quoteByteIdx = firstArg.span.start - fileOffset;
97
+ quoteChar = String.fromCharCode(srcBytes[quoteByteIdx]!);
98
+ } else if (
99
+ firstArg.type === 'TemplateLiteral' &&
100
+ firstArg.expressions.length === 0 &&
101
+ firstArg.quasis.length === 1
102
+ ) {
103
+ // No-interpolation template literal: `./path`
104
+ modulePath = firstArg.quasis[0].raw;
105
+ quoteChar = '`';
37
106
  } else {
38
- return `import(${quote}${modulePath}${quote})`;
107
+ // Dynamic (non-literal) path — emit a build warning
108
+ const start0 = node.span.start - fileOffset;
109
+ const bytesBefore = srcBytes.slice(0, start0);
110
+ const line = bytesBefore.toString('utf8').split('\n').length;
111
+ this.emitWarning(
112
+ new Error(
113
+ `[hadars] loadModule() called with a dynamic (non-literal) path at ${resourcePath}:${line}. ` +
114
+ `Only string-literal paths are transformed by the loader; dynamic calls fall back to runtime import().`
115
+ )
116
+ );
117
+ return;
39
118
  }
119
+ const replacement = isServer
120
+ ? `Promise.resolve(require(${quoteChar}${modulePath}${quoteChar}))`
121
+ : `import(${quoteChar}${modulePath}${quoteChar})`;
122
+
123
+ // Normalise to 0-based local byte offsets for Buffer.slice
124
+ replacements.push({ start: node.span.start - fileOffset, end: node.span.end - fileOffset, replacement });
40
125
  });
126
+
127
+ if (replacements.length === 0) return source;
128
+
129
+ // Apply replacements from last to first so earlier byte offsets stay valid
130
+ replacements.sort((a, b) => b.start - a.start);
131
+
132
+ let result = srcBytes;
133
+ for (const { start, end, replacement } of replacements) {
134
+ result = Buffer.concat([result.slice(0, start), Buffer.from(replacement, 'utf8'), result.slice(end)]);
135
+ }
136
+ return result.toString('utf8');
137
+ }
138
+
139
+ // Minimal recursive AST walker — visits every node depth-first.
140
+ function walkAst(node: any, visit: (n: any) => void): void {
141
+ if (!node || typeof node !== 'object') return;
142
+ visit(node);
143
+ for (const key of Object.keys(node)) {
144
+ if (key === 'span' || key === 'type' || key === 'ctxt') continue;
145
+ const val = node[key];
146
+ if (Array.isArray(val)) {
147
+ for (const child of val) walkAst(child, visit);
148
+ } else if (val && typeof val === 'object') {
149
+ walkAst(val, visit);
150
+ }
151
+ }
152
+ }
153
+
154
+ // Returns the number of leading bytes that are pure whitespace / comments /
155
+ // shebangs — i.e. bytes before the first actual code token. Used to anchor
156
+ // SWC's accumulated global span offsets back to byte-0 of this source file.
157
+ function countLeadingNonCodeBytes(source: string): number {
158
+ let i = 0;
159
+ while (i < source.length) {
160
+ // Whitespace
161
+ if (source[i] === ' ' || source[i] === '\t' || source[i] === '\r' || source[i] === '\n') {
162
+ i++;
163
+ continue;
164
+ }
165
+ // Line comment //...
166
+ if (source[i] === '/' && source[i + 1] === '/') {
167
+ while (i < source.length && source[i] !== '\n') i++;
168
+ continue;
169
+ }
170
+ // Block comment /* ... */
171
+ if (source[i] === '/' && source[i + 1] === '*') {
172
+ i += 2;
173
+ while (i + 1 < source.length && !(source[i] === '*' && source[i + 1] === '/')) i++;
174
+ if (i + 1 < source.length) i += 2;
175
+ continue;
176
+ }
177
+ // Shebang #!... (only valid at position 0)
178
+ if (i === 0 && source[i] === '#' && source[i + 1] === '!') {
179
+ while (i < source.length && source[i] !== '\n') i++;
180
+ continue;
181
+ }
182
+ break;
183
+ }
184
+ // SWC spans are UTF-8 byte offsets, but `i` here is a char index.
185
+ // Return the byte length of the leading non-code prefix.
186
+ return Buffer.byteLength(source.slice(0, i), 'utf8');
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Regex fallback (used when @swc/core is not available)
191
+ // ---------------------------------------------------------------------------
192
+
193
+ // Matches loadModule('./path') with optional TypeScript generic (up to 2 levels
194
+ // of nesting). Captures: group 1 = quote char, group 2 = module path.
195
+ const LOAD_MODULE_RE =
196
+ /\bloadModule\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1\s*\)/gs;
197
+
198
+ // Matches any remaining loadModule( that was NOT handled by the regex above
199
+ // (i.e. a dynamic / non-literal path argument).
200
+ const DYNAMIC_LOAD_MODULE_RE = /\bloadModule\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(/g;
201
+
202
+ function regexTransform(this: any, source: string, isServer: boolean, resourcePath: string): string {
203
+ const transformed = source.replace(LOAD_MODULE_RE, (_match, quote, modulePath) =>
204
+ isServer
205
+ ? `Promise.resolve(require(${quote}${modulePath}${quote}))`
206
+ : `import(${quote}${modulePath}${quote})`
207
+ );
208
+
209
+ // Warn for any remaining dynamic calls
210
+ let match: RegExpExecArray | null;
211
+ DYNAMIC_LOAD_MODULE_RE.lastIndex = 0;
212
+ while ((match = DYNAMIC_LOAD_MODULE_RE.exec(transformed)) !== null) {
213
+ const line = transformed.slice(0, match.index).split('\n').length;
214
+ this.emitWarning(
215
+ new Error(
216
+ `[hadars] loadModule() called with a dynamic (non-literal) path at ${resourcePath}:${line}. ` +
217
+ `Only string-literal paths are transformed by the loader; dynamic calls fall back to runtime import().`
218
+ )
219
+ );
220
+ }
221
+
222
+ return transformed;
41
223
  }
@@ -30,49 +30,45 @@ interface ReactResponseOptions {
30
30
  }
31
31
  }
32
32
 
33
- const getHeadHtml = (seoData: AppHead, renderToStaticMarkup: (el: any) => string): string => {
34
- const metaEntries = Object.entries(seoData.meta)
35
- const linkEntries = Object.entries(seoData.link)
36
- const styleEntries = Object.entries(seoData.style)
37
- const scriptEntries = Object.entries(seoData.script)
38
-
39
- return renderToStaticMarkup(
40
- <>
41
- <title>{seoData.title}</title>
42
- {
43
- metaEntries.map( ([id, options]) => (
44
- <meta
45
- key={id}
46
- id={ id }
47
- { ...options }
48
- />
49
- ) )
50
- }
51
- {linkEntries.map( ([id, options]) => (
52
- <link
53
- key={id}
54
- id={ id }
55
- { ...options }
56
- />
57
- ))}
58
- {styleEntries.map( ([id, options]) => (
59
- <style
60
- key={id}
61
- id={id}
62
- { ...options }
63
- />
64
- ))}
65
- {scriptEntries.map( ([id, options]) => (
66
- <script
67
- key={id}
68
- id={id}
69
- { ...options }
70
- />
71
- ))}
72
- </>
73
- )
33
+ // ── Head HTML serialisation (no React render needed) ─────────────────────────
34
+
35
+ const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
36
+ const escAttr = (s: string) => s.replace(/[&<>"]/g, c => ESC[c]);
37
+ const escText = (s: string) => s.replace(/[&<>]/g, c => ESC[c]);
38
+
39
+ // React prop → HTML attribute name for the subset used in head tags.
40
+ const ATTR: Record<string, string> = {
41
+ className: 'class', htmlFor: 'for', httpEquiv: 'http-equiv',
42
+ charSet: 'charset', crossOrigin: 'crossorigin', noModule: 'nomodule',
43
+ referrerPolicy: 'referrerpolicy', fetchPriority: 'fetchpriority',
44
+ };
45
+
46
+ function renderHeadTag(tag: string, id: string, opts: Record<string, unknown>, selfClose = false): string {
47
+ let attrs = ` id="${escAttr(id)}"`;
48
+ let inner = '';
49
+ for (const [k, v] of Object.entries(opts)) {
50
+ if (k === 'key' || k === 'children') continue;
51
+ if (k === 'dangerouslySetInnerHTML') { inner = (v as any).__html ?? ''; continue; }
52
+ const attr = ATTR[k] ?? k;
53
+ if (v === true) attrs += ` ${attr}`;
54
+ else if (v !== false && v != null) attrs += ` ${attr}="${escAttr(String(v))}"`;
55
+ }
56
+ return selfClose ? `<${tag}${attrs}>` : `<${tag}${attrs}>${inner}</${tag}>`;
74
57
  }
75
58
 
59
+ const getHeadHtml = (seoData: AppHead): string => {
60
+ let html = `<title>${escText(seoData.title ?? '')}</title>`;
61
+ for (const [id, opts] of Object.entries(seoData.meta))
62
+ html += renderHeadTag('meta', id, opts as Record<string, unknown>, true);
63
+ for (const [id, opts] of Object.entries(seoData.link))
64
+ html += renderHeadTag('link', id, opts as Record<string, unknown>, true);
65
+ for (const [id, opts] of Object.entries(seoData.style))
66
+ html += renderHeadTag('style', id, opts as Record<string, unknown>);
67
+ for (const [id, opts] of Object.entries(seoData.script))
68
+ html += renderHeadTag('script', id, opts as Record<string, unknown>);
69
+ return html;
70
+ };
71
+
76
72
 
77
73
  export const getReactResponse = async (
78
74
  req: HadarsRequest,
@@ -141,20 +137,20 @@ export const getReactResponse = async (
141
137
  }
142
138
  if (unsuspend.hasPending) await processUnsuspend();
143
139
  } while (unsuspend.hasPending && ++iters < 25);
140
+ if (unsuspend.hasPending) {
141
+ 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.');
142
+ }
144
143
 
145
- props = getAfterRenderProps ? await getAfterRenderProps(props, html) : props;
146
- // Re-render to capture any head changes introduced by getAfterRenderProps.
147
- try {
148
- (globalThis as any).__hadarsUnsuspend = unsuspend;
149
- renderToStaticMarkup(
150
- <App {...({
151
- ...props,
152
- location: req.location,
153
- context,
154
- })} />
155
- );
156
- } finally {
157
- (globalThis as any).__hadarsUnsuspend = null;
144
+ if (getAfterRenderProps) {
145
+ props = await getAfterRenderProps(props, html);
146
+ // Re-render only when getAfterRenderProps is present — it may mutate
147
+ // props that affect head tags, so we need another pass to capture them.
148
+ try {
149
+ (globalThis as any).__hadarsUnsuspend = unsuspend;
150
+ renderToStaticMarkup(<App {...({ ...props, location: req.location, context })} />);
151
+ } finally {
152
+ (globalThis as any).__hadarsUnsuspend = null;
153
+ }
158
154
  }
159
155
 
160
156
  // Serialize resolved useServerData() values for client hydration.
@@ -188,7 +184,7 @@ export const getReactResponse = async (
188
184
  return {
189
185
  ReactPage,
190
186
  status: context.head.status,
191
- headHtml: getHeadHtml(context.head, renderToStaticMarkup),
187
+ headHtml: getHeadHtml(context.head),
192
188
  renderPayload: {
193
189
  appProps: { ...props, location: req.location, context } as Record<string, unknown>,
194
190
  clientProps: clientProps as Record<string, unknown>,