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/dist/cli.js +184 -120
- package/dist/index.cjs +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/loader.cjs +126 -5
- package/dist/ssr-render-worker.js +3 -0
- package/dist/utils/Head.tsx +4 -2
- package/dist/utils/clientScript.tsx +6 -2
- package/package.json +4 -3
- package/src/build.ts +95 -66
- package/src/ssr-render-worker.ts +3 -0
- package/src/utils/Head.tsx +4 -2
- package/src/utils/clientScript.tsx +6 -2
- package/src/utils/loader.ts +193 -11
- package/src/utils/response.tsx +51 -55
- package/src/utils/serve.ts +40 -0
- package/src/utils/loadModule.ts +0 -4
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
|
-
|
|
66
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
512
|
-
|
|
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, '<');
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
|
|
688
|
-
|
|
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
|
};
|
package/src/ssr-render-worker.ts
CHANGED
|
@@ -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
|
|
package/src/utils/Head.tsx
CHANGED
|
@@ -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) ?
|
|
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 (
|
|
36
|
-
|
|
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/src/utils/loader.ts
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/utils/response.tsx
CHANGED
|
@@ -30,49 +30,45 @@ interface ReactResponseOptions {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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> = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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>,
|