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/cli.js +259 -122
- package/dist/index.cjs +2 -1
- package/dist/index.d.ts +25 -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 +16 -15
- package/src/build.ts +193 -68
- package/src/ssr-render-worker.ts +3 -0
- package/src/types/ninety.ts +18 -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/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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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;
|
package/dist/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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hadars",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
55
|
-
"react-dom": "^19.
|
|
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
|
|
66
|
-
"@rspack/plugin-react-refresh": "^1.
|
|
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.
|
|
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.
|
|
75
|
+
"@types/react": "^19.2.14",
|
|
74
76
|
"@types/react-dom": "^19.0.0",
|
|
75
|
-
"esbuild": "^0.19.
|
|
76
|
-
"tsup": "^6.
|
|
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
|
|
84
|
-
"postcss": "^8.5.
|
|
85
|
-
"postcss-loader": "^8.2.
|
|
86
|
-
"tailwindcss": "^4.1
|
|
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
|
-
|
|
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 {
|
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
512
|
-
|
|
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, '<');
|
|
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
|
-
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
};
|
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/types/ninety.ts
CHANGED
|
@@ -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
|
|
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 = {
|