hadars 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -2
- package/cli.ts +33 -10
- package/dist/cli.js +148 -31
- package/dist/index.cjs +13 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +12 -1
- package/dist/ssr-render-worker.js +1 -1
- package/dist/ssr-watch.js +7 -1
- package/dist/utils/Head.tsx +14 -4
- package/dist/utils/clientScript.tsx +1 -1
- package/package.json +2 -4
- package/src/build.ts +72 -13
- package/src/index.tsx +1 -1
- package/src/ssr-render-worker.ts +1 -1
- package/src/types/ninety.ts +1 -0
- package/src/utils/Head.tsx +14 -4
- package/src/utils/clientScript.tsx +1 -1
- package/src/utils/proxyHandler.tsx +16 -13
- package/src/utils/response.tsx +1 -1
- package/src/utils/rspack.ts +7 -1
- package/cli-bun.ts +0 -13
package/README.md
CHANGED
|
@@ -49,12 +49,11 @@ export default App;
|
|
|
49
49
|
|
|
50
50
|
## CLI
|
|
51
51
|
|
|
52
|
-
After installing hadars the `hadars`
|
|
52
|
+
After installing hadars the `hadars` binary is available. It works on Node.js, Bun, and Deno — the runtime is auto-detected:
|
|
53
53
|
|
|
54
54
|
```bash
|
|
55
55
|
# Development server with React Fast Refresh HMR
|
|
56
56
|
hadars dev
|
|
57
|
-
hadars-bun dev # Bun — runs TypeScript directly, no build step
|
|
58
57
|
|
|
59
58
|
# Production build (client + SSR bundles compiled in parallel)
|
|
60
59
|
hadars build
|
package/cli.ts
CHANGED
|
@@ -1,13 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
2
3
|
import { runCli } from './cli-lib'
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
5
|
+
// When the #!/usr/bin/env node shebang forces Node.js as the interpreter,
|
|
6
|
+
// try to re-exec with Bun so the server runs under Bun's runtime
|
|
7
|
+
// (native Bun.serve, WebSocket support, etc.).
|
|
8
|
+
// Falls back to Node.js silently if bun is not in PATH.
|
|
9
|
+
if (typeof (globalThis as any).Bun === 'undefined' && typeof (globalThis as any).Deno === 'undefined') {
|
|
10
|
+
const child = spawn('bun', [process.argv[1], ...process.argv.slice(2)], {
|
|
11
|
+
stdio: 'inherit',
|
|
12
|
+
env: process.env,
|
|
13
|
+
});
|
|
14
|
+
const sigs = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const;
|
|
15
|
+
const fwd = (sig: string) => () => { try { child.kill(sig); } catch {} };
|
|
16
|
+
for (const sig of sigs) process.on(sig, fwd(sig));
|
|
17
|
+
child.on('error', (err: any) => {
|
|
18
|
+
for (const sig of sigs) process.removeAllListeners(sig);
|
|
19
|
+
if (err.code !== 'ENOENT') { console.error(err); process.exit(1); }
|
|
20
|
+
// bun not found — fall through and run with Node.js
|
|
21
|
+
runCli(process.argv).catch((e) => {
|
|
22
|
+
console.error(e);
|
|
23
|
+
try { process.exit(1); } catch (_) { throw e; }
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
child.on('exit', (code: number | null) => process.exit(code ?? 0));
|
|
27
|
+
} else {
|
|
28
|
+
runCli(process.argv).catch((err) => {
|
|
29
|
+
console.error(err)
|
|
30
|
+
try {
|
|
31
|
+
process.exit(1)
|
|
32
|
+
} catch (_) {
|
|
33
|
+
throw err
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// cli.ts
|
|
4
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
5
|
+
|
|
3
6
|
// cli-lib.ts
|
|
4
7
|
import { existsSync as existsSync3 } from "node:fs";
|
|
5
8
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
@@ -48,35 +51,38 @@ var createProxyHandler = (options) => {
|
|
|
48
51
|
}
|
|
49
52
|
const proxyRules = Object.entries(proxy).sort((a, b) => b[0].length - a[0].length);
|
|
50
53
|
return async (req) => {
|
|
51
|
-
if (req.method === "OPTIONS" && options.proxyCORS) {
|
|
52
|
-
return new Response(null, {
|
|
53
|
-
status: 204,
|
|
54
|
-
headers: getCORSHeaders(req)
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
54
|
for (const [path2, target] of proxyRules) {
|
|
58
55
|
if (req.pathname.startsWith(path2)) {
|
|
56
|
+
if (req.method === "OPTIONS" && proxyCORS) {
|
|
57
|
+
return new Response(null, {
|
|
58
|
+
status: 204,
|
|
59
|
+
headers: getCORSHeaders(req)
|
|
60
|
+
});
|
|
61
|
+
}
|
|
59
62
|
const targetURL = new URL(target);
|
|
60
63
|
targetURL.pathname = targetURL.pathname.replace(/\/$/, "") + req.pathname.slice(path2.length);
|
|
61
64
|
targetURL.search = req.search;
|
|
62
65
|
const sendHeaders = cloneHeaders(req.headers);
|
|
63
66
|
sendHeaders.set("Host", targetURL.host);
|
|
67
|
+
const hasBody = !["GET", "HEAD"].includes(req.method);
|
|
64
68
|
const proxyReq = new Request(targetURL.toString(), {
|
|
65
69
|
method: req.method,
|
|
66
70
|
headers: sendHeaders,
|
|
67
|
-
body:
|
|
68
|
-
redirect: "follow"
|
|
71
|
+
body: hasBody ? req.body : void 0,
|
|
72
|
+
redirect: "follow",
|
|
73
|
+
// Node.js (undici) requires duplex:'half' when body is a ReadableStream
|
|
74
|
+
...hasBody ? { duplex: "half" } : {}
|
|
69
75
|
});
|
|
70
76
|
const res = await fetch(proxyReq);
|
|
71
|
-
if (proxyCORS) {
|
|
72
|
-
Object.entries(getCORSHeaders(req)).forEach(([key, value]) => {
|
|
73
|
-
res.headers.set(key, value);
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
77
|
const body = await res.arrayBuffer();
|
|
77
78
|
const clonedRes = new Headers(res.headers);
|
|
78
79
|
clonedRes.delete("content-length");
|
|
79
80
|
clonedRes.delete("content-encoding");
|
|
81
|
+
if (proxyCORS) {
|
|
82
|
+
Object.entries(getCORSHeaders(req)).forEach(([key, value]) => {
|
|
83
|
+
clonedRes.set(key, value);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
80
86
|
return new Response(body, {
|
|
81
87
|
status: res.status,
|
|
82
88
|
statusText: res.statusText,
|
|
@@ -257,7 +263,7 @@ var getReactResponse = async (req, opts) => {
|
|
|
257
263
|
location: req.location,
|
|
258
264
|
context
|
|
259
265
|
} }) }),
|
|
260
|
-
/* @__PURE__ */ jsx("script", { id: "hadars", type: "application/json", dangerouslySetInnerHTML: { __html: JSON.stringify({ hadars: { props: clientProps } }) } })
|
|
266
|
+
/* @__PURE__ */ jsx("script", { id: "hadars", type: "application/json", dangerouslySetInnerHTML: { __html: JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, "\\u003c") } })
|
|
261
267
|
] });
|
|
262
268
|
return {
|
|
263
269
|
ReactPage,
|
|
@@ -482,7 +488,6 @@ var buildCompilerConfig = (entry, opts, includeHotPlugin) => {
|
|
|
482
488
|
};
|
|
483
489
|
return {
|
|
484
490
|
entry,
|
|
485
|
-
resolve: resolveConfig,
|
|
486
491
|
output: {
|
|
487
492
|
...opts.output,
|
|
488
493
|
clean: false
|
|
@@ -502,6 +507,13 @@ var buildCompilerConfig = (entry, opts, includeHotPlugin) => {
|
|
|
502
507
|
...extraPlugins
|
|
503
508
|
],
|
|
504
509
|
...localConfig,
|
|
510
|
+
// Merge base resolve (modules, tsConfig, extensions) with per-build resolve
|
|
511
|
+
// (alias, mainFields). The spread order matters: resolveConfig wins for keys
|
|
512
|
+
// it defines, localConfig.resolve wins for keys it defines exclusively.
|
|
513
|
+
resolve: {
|
|
514
|
+
...localConfig.resolve,
|
|
515
|
+
...resolveConfig
|
|
516
|
+
},
|
|
505
517
|
// HMR is not implemented for module chunk format, so disable outputModule
|
|
506
518
|
// for client builds. SSR builds still need it for dynamic import() of exports.
|
|
507
519
|
experiments: {
|
|
@@ -744,6 +756,9 @@ async function getRenderToString() {
|
|
|
744
756
|
var RenderWorkerPool = class {
|
|
745
757
|
workers = [];
|
|
746
758
|
pending = /* @__PURE__ */ new Map();
|
|
759
|
+
// Track which pending IDs were dispatched to each worker so we can reject
|
|
760
|
+
// them when that worker crashes.
|
|
761
|
+
workerPending = /* @__PURE__ */ new Map();
|
|
747
762
|
nextId = 0;
|
|
748
763
|
rrIndex = 0;
|
|
749
764
|
constructor(workerPath, size, ssrBundlePath) {
|
|
@@ -753,6 +768,7 @@ var RenderWorkerPool = class {
|
|
|
753
768
|
import("node:worker_threads").then(({ Worker }) => {
|
|
754
769
|
for (let i = 0; i < size; i++) {
|
|
755
770
|
const w = new Worker(workerPath, { workerData: { ssrBundlePath } });
|
|
771
|
+
this.workerPending.set(w, /* @__PURE__ */ new Set());
|
|
756
772
|
w.on("message", (msg) => {
|
|
757
773
|
const { id, type, html, error, chunk } = msg;
|
|
758
774
|
const p = this.pending.get(id);
|
|
@@ -764,6 +780,7 @@ var RenderWorkerPool = class {
|
|
|
764
780
|
return;
|
|
765
781
|
}
|
|
766
782
|
this.pending.delete(id);
|
|
783
|
+
this.workerPending.get(w)?.delete(id);
|
|
767
784
|
if (type === "done")
|
|
768
785
|
p.controller.close();
|
|
769
786
|
else
|
|
@@ -771,6 +788,7 @@ var RenderWorkerPool = class {
|
|
|
771
788
|
return;
|
|
772
789
|
}
|
|
773
790
|
this.pending.delete(id);
|
|
791
|
+
this.workerPending.get(w)?.delete(id);
|
|
774
792
|
if (error)
|
|
775
793
|
p.reject(new Error(error));
|
|
776
794
|
else
|
|
@@ -778,6 +796,13 @@ var RenderWorkerPool = class {
|
|
|
778
796
|
});
|
|
779
797
|
w.on("error", (err) => {
|
|
780
798
|
console.error("[hadars] Render worker error:", err);
|
|
799
|
+
this._handleWorkerDeath(w, err);
|
|
800
|
+
});
|
|
801
|
+
w.on("exit", (code) => {
|
|
802
|
+
if (code !== 0) {
|
|
803
|
+
console.error(`[hadars] Render worker exited with code ${code}`);
|
|
804
|
+
this._handleWorkerDeath(w, new Error(`Render worker exited with code ${code}`));
|
|
805
|
+
}
|
|
781
806
|
});
|
|
782
807
|
this.workers.push(w);
|
|
783
808
|
}
|
|
@@ -785,7 +810,28 @@ var RenderWorkerPool = class {
|
|
|
785
810
|
console.error("[hadars] Failed to initialise render worker pool:", err);
|
|
786
811
|
});
|
|
787
812
|
}
|
|
813
|
+
_handleWorkerDeath(w, err) {
|
|
814
|
+
const idx = this.workers.indexOf(w);
|
|
815
|
+
if (idx !== -1)
|
|
816
|
+
this.workers.splice(idx, 1);
|
|
817
|
+
const ids = this.workerPending.get(w);
|
|
818
|
+
if (ids) {
|
|
819
|
+
for (const id of ids) {
|
|
820
|
+
const p = this.pending.get(id);
|
|
821
|
+
if (p) {
|
|
822
|
+
this.pending.delete(id);
|
|
823
|
+
if (p.kind === "renderString")
|
|
824
|
+
p.reject(err);
|
|
825
|
+
else
|
|
826
|
+
p.controller.error(err);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
this.workerPending.delete(w);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
788
832
|
nextWorker() {
|
|
833
|
+
if (this.workers.length === 0)
|
|
834
|
+
return void 0;
|
|
789
835
|
const w = this.workers[this.rrIndex % this.workers.length];
|
|
790
836
|
this.rrIndex++;
|
|
791
837
|
return w;
|
|
@@ -793,9 +839,21 @@ var RenderWorkerPool = class {
|
|
|
793
839
|
/** Offload a full renderToString call. Returns the HTML string. */
|
|
794
840
|
renderString(appProps, clientProps) {
|
|
795
841
|
return new Promise((resolve2, reject) => {
|
|
842
|
+
const w = this.nextWorker();
|
|
843
|
+
if (!w) {
|
|
844
|
+
reject(new Error("[hadars] No render workers available"));
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
796
847
|
const id = this.nextId++;
|
|
797
848
|
this.pending.set(id, { kind: "renderString", resolve: resolve2, reject });
|
|
798
|
-
this.
|
|
849
|
+
this.workerPending.get(w)?.add(id);
|
|
850
|
+
try {
|
|
851
|
+
w.postMessage({ id, type: "renderString", appProps, clientProps });
|
|
852
|
+
} catch (err) {
|
|
853
|
+
this.pending.delete(id);
|
|
854
|
+
this.workerPending.get(w)?.delete(id);
|
|
855
|
+
reject(err);
|
|
856
|
+
}
|
|
799
857
|
});
|
|
800
858
|
}
|
|
801
859
|
/** Offload a renderToReadableStream call. Returns a ReadableStream fed by
|
|
@@ -807,9 +865,21 @@ var RenderWorkerPool = class {
|
|
|
807
865
|
controller = ctrl;
|
|
808
866
|
}
|
|
809
867
|
});
|
|
868
|
+
const w = this.nextWorker();
|
|
869
|
+
if (!w) {
|
|
870
|
+
queueMicrotask(() => controller.error(new Error("[hadars] No render workers available")));
|
|
871
|
+
return stream;
|
|
872
|
+
}
|
|
810
873
|
const id = this.nextId++;
|
|
811
874
|
this.pending.set(id, { kind: "renderStream", controller });
|
|
812
|
-
this.
|
|
875
|
+
this.workerPending.get(w)?.add(id);
|
|
876
|
+
try {
|
|
877
|
+
w.postMessage({ id, type: "renderStream", appProps, clientProps });
|
|
878
|
+
} catch (err) {
|
|
879
|
+
this.pending.delete(id);
|
|
880
|
+
this.workerPending.get(w)?.delete(id);
|
|
881
|
+
queueMicrotask(() => controller.error(err));
|
|
882
|
+
}
|
|
813
883
|
return stream;
|
|
814
884
|
}
|
|
815
885
|
async terminate() {
|
|
@@ -940,10 +1010,9 @@ var dev = async (options) => {
|
|
|
940
1010
|
const hmrPort = options.hmrPort ?? port + 1;
|
|
941
1011
|
const packageDir2 = pathMod3.dirname(fileURLToPath2(import.meta.url));
|
|
942
1012
|
const clientScriptPath2 = pathMod3.resolve(packageDir2, "utils", "clientScript.tsx");
|
|
943
|
-
const headPath = pathMod3.resolve(packageDir2, "utils", "Head");
|
|
944
1013
|
let clientScript = "";
|
|
945
1014
|
try {
|
|
946
|
-
clientScript = (await fs.readFile(clientScriptPath2, "utf-8")).replace("$_MOD_PATH$", entry + getSuffix(options.mode))
|
|
1015
|
+
clientScript = (await fs.readFile(clientScriptPath2, "utf-8")).replace("$_MOD_PATH$", entry + getSuffix(options.mode));
|
|
947
1016
|
} catch (err) {
|
|
948
1017
|
console.error("Failed to read client script from package dist, falling back to src", err);
|
|
949
1018
|
throw err;
|
|
@@ -999,6 +1068,22 @@ var dev = async (options) => {
|
|
|
999
1068
|
...options.define ? [`--define=${JSON.stringify(options.define)}`] : []
|
|
1000
1069
|
], { stdio: "pipe" });
|
|
1001
1070
|
child.stdin?.end();
|
|
1071
|
+
const cleanupChild = () => {
|
|
1072
|
+
try {
|
|
1073
|
+
if (!child.killed)
|
|
1074
|
+
child.kill();
|
|
1075
|
+
} catch {
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
process.once("exit", cleanupChild);
|
|
1079
|
+
process.once("SIGINT", () => {
|
|
1080
|
+
cleanupChild();
|
|
1081
|
+
process.exit(0);
|
|
1082
|
+
});
|
|
1083
|
+
process.once("SIGTERM", () => {
|
|
1084
|
+
cleanupChild();
|
|
1085
|
+
process.exit(0);
|
|
1086
|
+
});
|
|
1002
1087
|
const stdoutWebStream = nodeReadableToWebStream(child.stdout);
|
|
1003
1088
|
const stderrWebStream = nodeReadableToWebStream(child.stderr);
|
|
1004
1089
|
const marker = "ssr-watch: initial-build-complete";
|
|
@@ -1126,13 +1211,12 @@ var build = async (options) => {
|
|
|
1126
1211
|
const entry = pathMod3.resolve(__dirname2, options.entry);
|
|
1127
1212
|
const packageDir2 = pathMod3.dirname(fileURLToPath2(import.meta.url));
|
|
1128
1213
|
const clientScriptPath2 = pathMod3.resolve(packageDir2, "utils", "clientScript.js");
|
|
1129
|
-
const headPath = pathMod3.resolve(packageDir2, "utils", "Head");
|
|
1130
1214
|
let clientScript = "";
|
|
1131
1215
|
try {
|
|
1132
|
-
clientScript = (await fs.readFile(clientScriptPath2, "utf-8")).replace("$_MOD_PATH$", entry + getSuffix(options.mode))
|
|
1216
|
+
clientScript = (await fs.readFile(clientScriptPath2, "utf-8")).replace("$_MOD_PATH$", entry + getSuffix(options.mode));
|
|
1133
1217
|
} catch (err) {
|
|
1134
1218
|
const srcClientPath = pathMod3.resolve(packageDir2, "utils", "clientScript.tsx");
|
|
1135
|
-
clientScript = (await fs.readFile(srcClientPath, "utf-8")).replace("$_MOD_PATH$", entry + `?v=${Date.now()}`)
|
|
1219
|
+
clientScript = (await fs.readFile(srcClientPath, "utf-8")).replace("$_MOD_PATH$", entry + `?v=${Date.now()}`);
|
|
1136
1220
|
}
|
|
1137
1221
|
const tmpFilePath = pathMod3.join(os.tmpdir(), `hadars-client-${Date.now()}.tsx`);
|
|
1138
1222
|
await fs.writeFile(tmpFilePath, clientScript);
|
|
@@ -1196,7 +1280,7 @@ var run = async (options) => {
|
|
|
1196
1280
|
if (!isNode && workers > 1) {
|
|
1197
1281
|
const packageDir2 = pathMod3.dirname(fileURLToPath2(import.meta.url));
|
|
1198
1282
|
const workerJs = pathMod3.resolve(packageDir2, "ssr-render-worker.js");
|
|
1199
|
-
const workerTs = pathMod3.resolve(packageDir2, "
|
|
1283
|
+
const workerTs = pathMod3.resolve(packageDir2, "ssr-render-worker.ts");
|
|
1200
1284
|
const workerFile = existsSync2(workerJs) ? workerJs : workerTs;
|
|
1201
1285
|
const ssrBundlePath = pathMod3.resolve(__dirname2, HadarsFolder, SSR_FILENAME);
|
|
1202
1286
|
renderPool = new RenderWorkerPool(workerFile, workers, ssrBundlePath);
|
|
@@ -1433,11 +1517,44 @@ async function runCli(argv, cwd = process.cwd()) {
|
|
|
1433
1517
|
}
|
|
1434
1518
|
|
|
1435
1519
|
// cli.ts
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
process.
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1520
|
+
if (typeof globalThis.Bun === "undefined" && typeof globalThis.Deno === "undefined") {
|
|
1521
|
+
const child = spawn2("bun", [process.argv[1], ...process.argv.slice(2)], {
|
|
1522
|
+
stdio: "inherit",
|
|
1523
|
+
env: process.env
|
|
1524
|
+
});
|
|
1525
|
+
const sigs = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
1526
|
+
const fwd = (sig) => () => {
|
|
1527
|
+
try {
|
|
1528
|
+
child.kill(sig);
|
|
1529
|
+
} catch {
|
|
1530
|
+
}
|
|
1531
|
+
};
|
|
1532
|
+
for (const sig of sigs)
|
|
1533
|
+
process.on(sig, fwd(sig));
|
|
1534
|
+
child.on("error", (err) => {
|
|
1535
|
+
for (const sig of sigs)
|
|
1536
|
+
process.removeAllListeners(sig);
|
|
1537
|
+
if (err.code !== "ENOENT") {
|
|
1538
|
+
console.error(err);
|
|
1539
|
+
process.exit(1);
|
|
1540
|
+
}
|
|
1541
|
+
runCli(process.argv).catch((e) => {
|
|
1542
|
+
console.error(e);
|
|
1543
|
+
try {
|
|
1544
|
+
process.exit(1);
|
|
1545
|
+
} catch (_) {
|
|
1546
|
+
throw e;
|
|
1547
|
+
}
|
|
1548
|
+
});
|
|
1549
|
+
});
|
|
1550
|
+
child.on("exit", (code) => process.exit(code ?? 0));
|
|
1551
|
+
} else {
|
|
1552
|
+
runCli(process.argv).catch((err) => {
|
|
1553
|
+
console.error(err);
|
|
1554
|
+
try {
|
|
1555
|
+
process.exit(1);
|
|
1556
|
+
} catch (_) {
|
|
1557
|
+
throw err;
|
|
1558
|
+
}
|
|
1559
|
+
});
|
|
1560
|
+
}
|
package/dist/index.cjs
CHANGED
|
@@ -32,6 +32,7 @@ var src_exports = {};
|
|
|
32
32
|
__export(src_exports, {
|
|
33
33
|
HadarsContext: () => HadarsContext,
|
|
34
34
|
HadarsHead: () => Head,
|
|
35
|
+
initServerDataCache: () => initServerDataCache,
|
|
35
36
|
loadModule: () => loadModule,
|
|
36
37
|
useServerData: () => useServerData
|
|
37
38
|
});
|
|
@@ -170,6 +171,11 @@ var AppProviderCSR = import_react.default.memo(({ children }) => {
|
|
|
170
171
|
});
|
|
171
172
|
var useApp = () => import_react.default.useContext(AppContext);
|
|
172
173
|
var clientServerDataCache = /* @__PURE__ */ new Map();
|
|
174
|
+
function initServerDataCache(data) {
|
|
175
|
+
for (const [k, v] of Object.entries(data)) {
|
|
176
|
+
clientServerDataCache.set(k, v);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
173
179
|
function useServerData(key, fn) {
|
|
174
180
|
const cacheKey = Array.isArray(key) ? key.join("\0") : key;
|
|
175
181
|
if (typeof window !== "undefined") {
|
|
@@ -184,11 +190,16 @@ function useServerData(key, fn) {
|
|
|
184
190
|
const existing = unsuspend.cache.get(cacheKey);
|
|
185
191
|
if (existing?.status === "suspense-resolved") {
|
|
186
192
|
try {
|
|
187
|
-
|
|
193
|
+
const value = fn();
|
|
194
|
+
unsuspend.cache.set(cacheKey, { status: "suspense-cached", value });
|
|
195
|
+
return value;
|
|
188
196
|
} catch {
|
|
189
197
|
return void 0;
|
|
190
198
|
}
|
|
191
199
|
}
|
|
200
|
+
if (existing?.status === "suspense-cached") {
|
|
201
|
+
return existing.value;
|
|
202
|
+
}
|
|
192
203
|
if (!existing) {
|
|
193
204
|
let result;
|
|
194
205
|
try {
|
|
@@ -298,6 +309,7 @@ function loadModule(path) {
|
|
|
298
309
|
0 && (module.exports = {
|
|
299
310
|
HadarsContext,
|
|
300
311
|
HadarsHead,
|
|
312
|
+
initServerDataCache,
|
|
301
313
|
loadModule,
|
|
302
314
|
useServerData
|
|
303
315
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -29,6 +29,9 @@ type UnsuspendEntry = {
|
|
|
29
29
|
value: unknown;
|
|
30
30
|
} | {
|
|
31
31
|
status: 'suspense-resolved';
|
|
32
|
+
} | {
|
|
33
|
+
status: 'suspense-cached';
|
|
34
|
+
value: unknown;
|
|
32
35
|
} | {
|
|
33
36
|
status: 'rejected';
|
|
34
37
|
reason: unknown;
|
|
@@ -102,6 +105,9 @@ interface HadarsRequest extends Request {
|
|
|
102
105
|
cookies: Record<string, string>;
|
|
103
106
|
}
|
|
104
107
|
|
|
108
|
+
/** Call this before hydrating to seed the client cache from the server's data.
|
|
109
|
+
* Invoked automatically by the hadars client bootstrap. */
|
|
110
|
+
declare function initServerDataCache(data: Record<string, unknown>): void;
|
|
105
111
|
/**
|
|
106
112
|
* Fetch async data on the server during SSR. Returns `undefined` on the first
|
|
107
113
|
* render pass(es) while the promise is in flight; returns the resolved value
|
|
@@ -157,4 +163,4 @@ declare const HadarsContext: React$1.FC<{
|
|
|
157
163
|
*/
|
|
158
164
|
declare function loadModule<T = any>(path: string): Promise<T>;
|
|
159
165
|
|
|
160
|
-
export { HadarsApp, HadarsContext, HadarsEntryModule, HadarsGetAfterRenderProps, HadarsGetClientProps, HadarsGetFinalProps, HadarsGetInitialProps, Head as HadarsHead, HadarsOptions, HadarsProps, HadarsRequest, loadModule, useServerData };
|
|
166
|
+
export { HadarsApp, HadarsContext, HadarsEntryModule, HadarsGetAfterRenderProps, HadarsGetClientProps, HadarsGetFinalProps, HadarsGetInitialProps, Head as HadarsHead, HadarsOptions, HadarsProps, HadarsRequest, initServerDataCache, loadModule, useServerData };
|
package/dist/index.js
CHANGED
|
@@ -131,6 +131,11 @@ var AppProviderCSR = React.memo(({ children }) => {
|
|
|
131
131
|
});
|
|
132
132
|
var useApp = () => React.useContext(AppContext);
|
|
133
133
|
var clientServerDataCache = /* @__PURE__ */ new Map();
|
|
134
|
+
function initServerDataCache(data) {
|
|
135
|
+
for (const [k, v] of Object.entries(data)) {
|
|
136
|
+
clientServerDataCache.set(k, v);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
134
139
|
function useServerData(key, fn) {
|
|
135
140
|
const cacheKey = Array.isArray(key) ? key.join("\0") : key;
|
|
136
141
|
if (typeof window !== "undefined") {
|
|
@@ -145,11 +150,16 @@ function useServerData(key, fn) {
|
|
|
145
150
|
const existing = unsuspend.cache.get(cacheKey);
|
|
146
151
|
if (existing?.status === "suspense-resolved") {
|
|
147
152
|
try {
|
|
148
|
-
|
|
153
|
+
const value = fn();
|
|
154
|
+
unsuspend.cache.set(cacheKey, { status: "suspense-cached", value });
|
|
155
|
+
return value;
|
|
149
156
|
} catch {
|
|
150
157
|
return void 0;
|
|
151
158
|
}
|
|
152
159
|
}
|
|
160
|
+
if (existing?.status === "suspense-cached") {
|
|
161
|
+
return existing.value;
|
|
162
|
+
}
|
|
153
163
|
if (!existing) {
|
|
154
164
|
let result;
|
|
155
165
|
try {
|
|
@@ -258,6 +268,7 @@ function loadModule(path) {
|
|
|
258
268
|
export {
|
|
259
269
|
HadarsContext,
|
|
260
270
|
Head as HadarsHead,
|
|
271
|
+
initServerDataCache,
|
|
261
272
|
loadModule,
|
|
262
273
|
useServerData
|
|
263
274
|
};
|
|
@@ -47,7 +47,7 @@ function buildReactPage(R, appProps, clientProps) {
|
|
|
47
47
|
id: "hadars",
|
|
48
48
|
type: "application/json",
|
|
49
49
|
dangerouslySetInnerHTML: {
|
|
50
|
-
__html: JSON.stringify({ hadars: { props: clientProps } })
|
|
50
|
+
__html: JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, "\\u003c")
|
|
51
51
|
}
|
|
52
52
|
})
|
|
53
53
|
);
|
package/dist/ssr-watch.js
CHANGED
|
@@ -213,7 +213,6 @@ var buildCompilerConfig = (entry2, opts, includeHotPlugin) => {
|
|
|
213
213
|
};
|
|
214
214
|
return {
|
|
215
215
|
entry: entry2,
|
|
216
|
-
resolve: resolveConfig,
|
|
217
216
|
output: {
|
|
218
217
|
...opts.output,
|
|
219
218
|
clean: false
|
|
@@ -233,6 +232,13 @@ var buildCompilerConfig = (entry2, opts, includeHotPlugin) => {
|
|
|
233
232
|
...extraPlugins
|
|
234
233
|
],
|
|
235
234
|
...localConfig,
|
|
235
|
+
// Merge base resolve (modules, tsConfig, extensions) with per-build resolve
|
|
236
|
+
// (alias, mainFields). The spread order matters: resolveConfig wins for keys
|
|
237
|
+
// it defines, localConfig.resolve wins for keys it defines exclusively.
|
|
238
|
+
resolve: {
|
|
239
|
+
...localConfig.resolve,
|
|
240
|
+
...resolveConfig
|
|
241
|
+
},
|
|
236
242
|
// HMR is not implemented for module chunk format, so disable outputModule
|
|
237
243
|
// for client builds. SSR builds still need it for dynamic import() of exports.
|
|
238
244
|
experiments: {
|
package/dist/utils/Head.tsx
CHANGED
|
@@ -231,17 +231,27 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
|
|
|
231
231
|
const existing = unsuspend.cache.get(cacheKey);
|
|
232
232
|
|
|
233
233
|
// Suspense promise has resolved — re-call fn() so the hook returns its value
|
|
234
|
-
// synchronously from its own internal cache.
|
|
235
|
-
//
|
|
236
|
-
//
|
|
234
|
+
// synchronously from its own internal cache. Cache the result as
|
|
235
|
+
// 'suspense-cached' so later renders (e.g. the final renderToString in
|
|
236
|
+
// buildSsrResponse, which runs after getFinalProps may have cleared the
|
|
237
|
+
// user's QueryClient) can return the value without calling fn() again.
|
|
238
|
+
// NOT stored as 'fulfilled' so it is never included in serverData sent to
|
|
239
|
+
// the client — the Suspense library owns its own hydration.
|
|
237
240
|
if (existing?.status === 'suspense-resolved') {
|
|
238
241
|
try {
|
|
239
|
-
|
|
242
|
+
const value = fn() as T;
|
|
243
|
+
unsuspend.cache.set(cacheKey, { status: 'suspense-cached', value });
|
|
244
|
+
return value;
|
|
240
245
|
} catch {
|
|
241
246
|
return undefined;
|
|
242
247
|
}
|
|
243
248
|
}
|
|
244
249
|
|
|
250
|
+
// Return the cached Suspense value on all subsequent renders.
|
|
251
|
+
if (existing?.status === 'suspense-cached') {
|
|
252
|
+
return existing.value as T;
|
|
253
|
+
}
|
|
254
|
+
|
|
245
255
|
if (!existing) {
|
|
246
256
|
// First encounter — call fn(), which may:
|
|
247
257
|
// (a) return a Promise<T> — normal async usage (serialised for the client)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { hydrateRoot, createRoot } from 'react-dom/client';
|
|
3
3
|
import type { HadarsEntryModule } from '../types/ninety';
|
|
4
|
-
import { initServerDataCache } from '
|
|
4
|
+
import { initServerDataCache } from 'hadars';
|
|
5
5
|
import * as _appMod from '$_MOD_PATH$';
|
|
6
6
|
|
|
7
7
|
const appMod = _appMod as HadarsEntryModule<{}>;
|
package/package.json
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hadars",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Minimal SSR framework for React — rspack, HMR, TypeScript, Bun/Node/Deno",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
|
-
"hadars": "dist/cli.js"
|
|
9
|
-
"hadars-bun": "cli-bun.ts"
|
|
8
|
+
"hadars": "dist/cli.js"
|
|
10
9
|
},
|
|
11
10
|
"main": "dist/index.js",
|
|
12
11
|
"types": "dist/index.d.ts",
|
|
@@ -15,7 +14,6 @@
|
|
|
15
14
|
"src",
|
|
16
15
|
"cli-lib.ts",
|
|
17
16
|
"cli.ts",
|
|
18
|
-
"cli-bun.ts",
|
|
19
17
|
"index.ts",
|
|
20
18
|
"LICENSE"
|
|
21
19
|
],
|
package/src/build.ts
CHANGED
|
@@ -72,6 +72,9 @@ type PendingEntry = PendingRenderString | PendingRenderStream;
|
|
|
72
72
|
class RenderWorkerPool {
|
|
73
73
|
private workers: any[] = [];
|
|
74
74
|
private pending = new Map<number, PendingEntry>();
|
|
75
|
+
// Track which pending IDs were dispatched to each worker so we can reject
|
|
76
|
+
// them when that worker crashes.
|
|
77
|
+
private workerPending = new Map<any, Set<number>>();
|
|
75
78
|
private nextId = 0;
|
|
76
79
|
private rrIndex = 0;
|
|
77
80
|
|
|
@@ -85,6 +88,7 @@ class RenderWorkerPool {
|
|
|
85
88
|
import('node:worker_threads').then(({ Worker }) => {
|
|
86
89
|
for (let i = 0; i < size; i++) {
|
|
87
90
|
const w = new Worker(workerPath, { workerData: { ssrBundlePath } });
|
|
91
|
+
this.workerPending.set(w, new Set());
|
|
88
92
|
w.on('message', (msg: any) => {
|
|
89
93
|
const { id, type, html, error, chunk } = msg;
|
|
90
94
|
const p = this.pending.get(id);
|
|
@@ -96,6 +100,7 @@ class RenderWorkerPool {
|
|
|
96
100
|
return; // keep entry until 'done'
|
|
97
101
|
}
|
|
98
102
|
this.pending.delete(id);
|
|
103
|
+
this.workerPending.get(w)?.delete(id);
|
|
99
104
|
if (type === 'done') p.controller.close();
|
|
100
105
|
else p.controller.error(new Error(error ?? 'Stream error'));
|
|
101
106
|
return;
|
|
@@ -103,11 +108,19 @@ class RenderWorkerPool {
|
|
|
103
108
|
|
|
104
109
|
// renderString
|
|
105
110
|
this.pending.delete(id);
|
|
111
|
+
this.workerPending.get(w)?.delete(id);
|
|
106
112
|
if (error) p.reject(new Error(error));
|
|
107
113
|
else p.resolve(html);
|
|
108
114
|
});
|
|
109
115
|
w.on('error', (err: Error) => {
|
|
110
116
|
console.error('[hadars] Render worker error:', err);
|
|
117
|
+
this._handleWorkerDeath(w, err);
|
|
118
|
+
});
|
|
119
|
+
w.on('exit', (code: number) => {
|
|
120
|
+
if (code !== 0) {
|
|
121
|
+
console.error(`[hadars] Render worker exited with code ${code}`);
|
|
122
|
+
this._handleWorkerDeath(w, new Error(`Render worker exited with code ${code}`));
|
|
123
|
+
}
|
|
111
124
|
});
|
|
112
125
|
this.workers.push(w);
|
|
113
126
|
}
|
|
@@ -116,7 +129,28 @@ class RenderWorkerPool {
|
|
|
116
129
|
});
|
|
117
130
|
}
|
|
118
131
|
|
|
119
|
-
private
|
|
132
|
+
private _handleWorkerDeath(w: any, err: Error) {
|
|
133
|
+
// Remove the dead worker from the rotation so it is never selected again.
|
|
134
|
+
const idx = this.workers.indexOf(w);
|
|
135
|
+
if (idx !== -1) this.workers.splice(idx, 1);
|
|
136
|
+
|
|
137
|
+
// Reject every in-flight request that was sent to this worker.
|
|
138
|
+
const ids = this.workerPending.get(w);
|
|
139
|
+
if (ids) {
|
|
140
|
+
for (const id of ids) {
|
|
141
|
+
const p = this.pending.get(id);
|
|
142
|
+
if (p) {
|
|
143
|
+
this.pending.delete(id);
|
|
144
|
+
if (p.kind === 'renderString') p.reject(err);
|
|
145
|
+
else p.controller.error(err);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
this.workerPending.delete(w);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private nextWorker(): any | undefined {
|
|
153
|
+
if (this.workers.length === 0) return undefined;
|
|
120
154
|
const w = this.workers[this.rrIndex % this.workers.length];
|
|
121
155
|
this.rrIndex++;
|
|
122
156
|
return w;
|
|
@@ -125,9 +159,21 @@ class RenderWorkerPool {
|
|
|
125
159
|
/** Offload a full renderToString call. Returns the HTML string. */
|
|
126
160
|
renderString(appProps: Record<string, unknown>, clientProps: Record<string, unknown>): Promise<string> {
|
|
127
161
|
return new Promise((resolve, reject) => {
|
|
162
|
+
const w = this.nextWorker();
|
|
163
|
+
if (!w) {
|
|
164
|
+
reject(new Error('[hadars] No render workers available'));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
128
167
|
const id = this.nextId++;
|
|
129
168
|
this.pending.set(id, { kind: 'renderString', resolve, reject });
|
|
130
|
-
this.
|
|
169
|
+
this.workerPending.get(w)?.add(id);
|
|
170
|
+
try {
|
|
171
|
+
w.postMessage({ id, type: 'renderString', appProps, clientProps });
|
|
172
|
+
} catch (err) {
|
|
173
|
+
this.pending.delete(id);
|
|
174
|
+
this.workerPending.get(w)?.delete(id);
|
|
175
|
+
reject(err);
|
|
176
|
+
}
|
|
131
177
|
});
|
|
132
178
|
}
|
|
133
179
|
|
|
@@ -138,11 +184,24 @@ class RenderWorkerPool {
|
|
|
138
184
|
const stream = new ReadableStream<Uint8Array>({
|
|
139
185
|
start: (ctrl) => { controller = ctrl; },
|
|
140
186
|
});
|
|
187
|
+
const w = this.nextWorker();
|
|
188
|
+
if (!w) {
|
|
189
|
+
// Immediately error the stream if no workers are available.
|
|
190
|
+
queueMicrotask(() => controller.error(new Error('[hadars] No render workers available')));
|
|
191
|
+
return stream;
|
|
192
|
+
}
|
|
141
193
|
const id = this.nextId++;
|
|
142
194
|
// Store controller before postMessage so the handler is ready when
|
|
143
195
|
// the first chunk arrives.
|
|
144
196
|
this.pending.set(id, { kind: 'renderStream', controller });
|
|
145
|
-
this.
|
|
197
|
+
this.workerPending.get(w)?.add(id);
|
|
198
|
+
try {
|
|
199
|
+
w.postMessage({ id, type: 'renderStream', appProps, clientProps });
|
|
200
|
+
} catch (err) {
|
|
201
|
+
this.pending.delete(id);
|
|
202
|
+
this.workerPending.get(w)?.delete(id);
|
|
203
|
+
queueMicrotask(() => controller.error(err));
|
|
204
|
+
}
|
|
146
205
|
return stream;
|
|
147
206
|
}
|
|
148
207
|
|
|
@@ -341,13 +400,10 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
341
400
|
const packageDir = pathMod.dirname(fileURLToPath(import.meta.url));
|
|
342
401
|
const clientScriptPath = pathMod.resolve(packageDir, 'utils', 'clientScript.tsx');
|
|
343
402
|
|
|
344
|
-
const headPath = pathMod.resolve(packageDir, 'utils', 'Head');
|
|
345
|
-
|
|
346
403
|
let clientScript = '';
|
|
347
404
|
try {
|
|
348
405
|
clientScript = (await fs.readFile(clientScriptPath, 'utf-8'))
|
|
349
|
-
.replace('$_MOD_PATH$', entry + getSuffix(options.mode))
|
|
350
|
-
.replace('$_HEAD_PATH$', headPath);
|
|
406
|
+
.replace('$_MOD_PATH$', entry + getSuffix(options.mode));
|
|
351
407
|
}
|
|
352
408
|
catch (err) {
|
|
353
409
|
console.error("Failed to read client script from package dist, falling back to src", err);
|
|
@@ -421,6 +477,12 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
421
477
|
], { stdio: 'pipe' });
|
|
422
478
|
child.stdin?.end();
|
|
423
479
|
|
|
480
|
+
// Ensure the SSR watcher child is killed when this process exits.
|
|
481
|
+
const cleanupChild = () => { try { if (!child.killed) child.kill(); } catch {} };
|
|
482
|
+
process.once('exit', cleanupChild);
|
|
483
|
+
process.once('SIGINT', () => { cleanupChild(); process.exit(0); });
|
|
484
|
+
process.once('SIGTERM', () => { cleanupChild(); process.exit(0); });
|
|
485
|
+
|
|
424
486
|
// Convert Node.js Readable streams to Web ReadableStream so the rest of
|
|
425
487
|
// the logic works identically across all runtimes.
|
|
426
488
|
const stdoutWebStream = nodeReadableToWebStream(child.stdout!);
|
|
@@ -553,17 +615,14 @@ export const build = async (options: HadarsRuntimeOptions) => {
|
|
|
553
615
|
// prepare client script
|
|
554
616
|
const packageDir = pathMod.dirname(fileURLToPath(import.meta.url));
|
|
555
617
|
const clientScriptPath = pathMod.resolve(packageDir, 'utils', 'clientScript.js');
|
|
556
|
-
const headPath = pathMod.resolve(packageDir, 'utils', 'Head');
|
|
557
618
|
let clientScript = '';
|
|
558
619
|
try {
|
|
559
620
|
clientScript = (await fs.readFile(clientScriptPath, 'utf-8'))
|
|
560
|
-
.replace('$_MOD_PATH$', entry + getSuffix(options.mode))
|
|
561
|
-
.replace('$_HEAD_PATH$', headPath);
|
|
621
|
+
.replace('$_MOD_PATH$', entry + getSuffix(options.mode));
|
|
562
622
|
} catch (err) {
|
|
563
623
|
const srcClientPath = pathMod.resolve(packageDir, 'utils', 'clientScript.tsx');
|
|
564
624
|
clientScript = (await fs.readFile(srcClientPath, 'utf-8'))
|
|
565
|
-
.replace('$_MOD_PATH$', entry + `?v=${Date.now()}`)
|
|
566
|
-
.replace('$_HEAD_PATH$', pathMod.resolve(packageDir, 'utils', 'Head'));
|
|
625
|
+
.replace('$_MOD_PATH$', entry + `?v=${Date.now()}`);
|
|
567
626
|
}
|
|
568
627
|
|
|
569
628
|
const tmpFilePath = pathMod.join(os.tmpdir(), `hadars-client-${Date.now()}.tsx`);
|
|
@@ -645,7 +704,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
|
|
|
645
704
|
if (!isNode && workers > 1) {
|
|
646
705
|
const packageDir = pathMod.dirname(fileURLToPath(import.meta.url));
|
|
647
706
|
const workerJs = pathMod.resolve(packageDir, 'ssr-render-worker.js');
|
|
648
|
-
const workerTs = pathMod.resolve(packageDir, '
|
|
707
|
+
const workerTs = pathMod.resolve(packageDir, 'ssr-render-worker.ts');
|
|
649
708
|
const workerFile = existsSync(workerJs) ? workerJs : workerTs;
|
|
650
709
|
const ssrBundlePath = pathMod.resolve(__dirname, HadarsFolder, SSR_FILENAME);
|
|
651
710
|
renderPool = new RenderWorkerPool(workerFile, workers, ssrBundlePath);
|
package/src/index.tsx
CHANGED
|
@@ -9,7 +9,7 @@ export type {
|
|
|
9
9
|
HadarsEntryModule,
|
|
10
10
|
HadarsApp,
|
|
11
11
|
} from "./types/ninety";
|
|
12
|
-
export { Head as HadarsHead, useServerData } from './utils/Head';
|
|
12
|
+
export { Head as HadarsHead, useServerData, initServerDataCache } from './utils/Head';
|
|
13
13
|
import { AppProviderSSR, AppProviderCSR } from "./utils/Head";
|
|
14
14
|
|
|
15
15
|
export const HadarsContext = typeof window === 'undefined' ? AppProviderSSR : AppProviderCSR;
|
package/src/ssr-render-worker.ts
CHANGED
|
@@ -77,7 +77,7 @@ function buildReactPage(R: any, appProps: Record<string, unknown>, clientProps:
|
|
|
77
77
|
id: 'hadars',
|
|
78
78
|
type: 'application/json',
|
|
79
79
|
dangerouslySetInnerHTML: {
|
|
80
|
-
__html: JSON.stringify({ hadars: { props: clientProps } }),
|
|
80
|
+
__html: JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c'),
|
|
81
81
|
},
|
|
82
82
|
}),
|
|
83
83
|
);
|
package/src/types/ninety.ts
CHANGED
|
@@ -27,6 +27,7 @@ export type UnsuspendEntry =
|
|
|
27
27
|
| { status: 'pending'; promise: Promise<unknown> }
|
|
28
28
|
| { status: 'fulfilled'; value: unknown }
|
|
29
29
|
| { status: 'suspense-resolved' }
|
|
30
|
+
| { status: 'suspense-cached'; value: unknown }
|
|
30
31
|
| { status: 'rejected'; reason: unknown };
|
|
31
32
|
|
|
32
33
|
/** @internal Populated by the framework's render loop — use useServerData() instead. */
|
package/src/utils/Head.tsx
CHANGED
|
@@ -231,17 +231,27 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
|
|
|
231
231
|
const existing = unsuspend.cache.get(cacheKey);
|
|
232
232
|
|
|
233
233
|
// Suspense promise has resolved — re-call fn() so the hook returns its value
|
|
234
|
-
// synchronously from its own internal cache.
|
|
235
|
-
//
|
|
236
|
-
//
|
|
234
|
+
// synchronously from its own internal cache. Cache the result as
|
|
235
|
+
// 'suspense-cached' so later renders (e.g. the final renderToString in
|
|
236
|
+
// buildSsrResponse, which runs after getFinalProps may have cleared the
|
|
237
|
+
// user's QueryClient) can return the value without calling fn() again.
|
|
238
|
+
// NOT stored as 'fulfilled' so it is never included in serverData sent to
|
|
239
|
+
// the client — the Suspense library owns its own hydration.
|
|
237
240
|
if (existing?.status === 'suspense-resolved') {
|
|
238
241
|
try {
|
|
239
|
-
|
|
242
|
+
const value = fn() as T;
|
|
243
|
+
unsuspend.cache.set(cacheKey, { status: 'suspense-cached', value });
|
|
244
|
+
return value;
|
|
240
245
|
} catch {
|
|
241
246
|
return undefined;
|
|
242
247
|
}
|
|
243
248
|
}
|
|
244
249
|
|
|
250
|
+
// Return the cached Suspense value on all subsequent renders.
|
|
251
|
+
if (existing?.status === 'suspense-cached') {
|
|
252
|
+
return existing.value as T;
|
|
253
|
+
}
|
|
254
|
+
|
|
245
255
|
if (!existing) {
|
|
246
256
|
// First encounter — call fn(), which may:
|
|
247
257
|
// (a) return a Promise<T> — normal async usage (serialised for the client)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { hydrateRoot, createRoot } from 'react-dom/client';
|
|
3
3
|
import type { HadarsEntryModule } from '../types/ninety';
|
|
4
|
-
import { initServerDataCache } from '
|
|
4
|
+
import { initServerDataCache } from 'hadars';
|
|
5
5
|
import * as _appMod from '$_MOD_PATH$';
|
|
6
6
|
|
|
7
7
|
const appMod = _appMod as HadarsEntryModule<{}>;
|
|
@@ -53,14 +53,14 @@ export const createProxyHandler = (options: HadarsOptions): ProxyHandler => {
|
|
|
53
53
|
const proxyRules = Object.entries(proxy).sort((a, b) => b[0].length - a[0].length);
|
|
54
54
|
|
|
55
55
|
return async (req: HadarsRequest) => {
|
|
56
|
-
if (req.method === 'OPTIONS' && options.proxyCORS) {
|
|
57
|
-
return new Response(null, {
|
|
58
|
-
status: 204,
|
|
59
|
-
headers: getCORSHeaders(req),
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
56
|
for (const [path, target] of proxyRules) {
|
|
63
57
|
if (req.pathname.startsWith(path)) {
|
|
58
|
+
if (req.method === 'OPTIONS' && proxyCORS) {
|
|
59
|
+
return new Response(null, {
|
|
60
|
+
status: 204,
|
|
61
|
+
headers: getCORSHeaders(req),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
64
|
const targetURL = new URL(target);
|
|
65
65
|
targetURL.pathname = targetURL.pathname.replace(/\/$/, '') + req.pathname.slice(path.length);
|
|
66
66
|
targetURL.search = req.search;
|
|
@@ -69,25 +69,28 @@ export const createProxyHandler = (options: HadarsOptions): ProxyHandler => {
|
|
|
69
69
|
// Overwrite the Host header to match the target
|
|
70
70
|
sendHeaders.set('Host', targetURL.host);
|
|
71
71
|
|
|
72
|
+
const hasBody = !['GET', 'HEAD'].includes(req.method);
|
|
72
73
|
const proxyReq = new Request(targetURL.toString(), {
|
|
73
74
|
method: req.method,
|
|
74
75
|
headers: sendHeaders,
|
|
75
|
-
body:
|
|
76
|
+
body: hasBody ? req.body : undefined,
|
|
76
77
|
redirect: 'follow',
|
|
77
|
-
|
|
78
|
+
// Node.js (undici) requires duplex:'half' when body is a ReadableStream
|
|
79
|
+
...(hasBody ? { duplex: 'half' } : {}),
|
|
80
|
+
} as RequestInit);
|
|
78
81
|
|
|
79
82
|
const res = await fetch(proxyReq);
|
|
80
|
-
if (proxyCORS) {
|
|
81
|
-
Object.entries(getCORSHeaders(req)).forEach(([key, value]) => {
|
|
82
|
-
res.headers.set(key, value);
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
83
|
// Read the response body
|
|
86
84
|
const body = await res.arrayBuffer();
|
|
87
85
|
// remove content-length and content-encoding headers to avoid issues with modified body
|
|
88
86
|
const clonedRes = new Headers(res.headers);
|
|
89
87
|
clonedRes.delete('content-length');
|
|
90
88
|
clonedRes.delete('content-encoding');
|
|
89
|
+
if (proxyCORS) {
|
|
90
|
+
Object.entries(getCORSHeaders(req)).forEach(([key, value]) => {
|
|
91
|
+
clonedRes.set(key, value);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
91
94
|
// return a new Response with the modified headers and original body
|
|
92
95
|
return new Response(body, {
|
|
93
96
|
status: res.status,
|
package/src/utils/response.tsx
CHANGED
|
@@ -181,7 +181,7 @@ export const getReactResponse = async (
|
|
|
181
181
|
context,
|
|
182
182
|
})} />
|
|
183
183
|
</div>
|
|
184
|
-
<script id="hadars" type="application/json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ hadars: { props: clientProps } }) }}></script>
|
|
184
|
+
<script id="hadars" type="application/json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c') }}></script>
|
|
185
185
|
</>
|
|
186
186
|
)
|
|
187
187
|
|
package/src/utils/rspack.ts
CHANGED
|
@@ -260,7 +260,6 @@ const buildCompilerConfig = (
|
|
|
260
260
|
|
|
261
261
|
return {
|
|
262
262
|
entry,
|
|
263
|
-
resolve: resolveConfig,
|
|
264
263
|
output: {
|
|
265
264
|
...opts.output,
|
|
266
265
|
clean: false,
|
|
@@ -280,6 +279,13 @@ const buildCompilerConfig = (
|
|
|
280
279
|
...extraPlugins,
|
|
281
280
|
],
|
|
282
281
|
...localConfig,
|
|
282
|
+
// Merge base resolve (modules, tsConfig, extensions) with per-build resolve
|
|
283
|
+
// (alias, mainFields). The spread order matters: resolveConfig wins for keys
|
|
284
|
+
// it defines, localConfig.resolve wins for keys it defines exclusively.
|
|
285
|
+
resolve: {
|
|
286
|
+
...localConfig.resolve,
|
|
287
|
+
...resolveConfig,
|
|
288
|
+
},
|
|
283
289
|
// HMR is not implemented for module chunk format, so disable outputModule
|
|
284
290
|
// for client builds. SSR builds still need it for dynamic import() of exports.
|
|
285
291
|
experiments: {
|
package/cli-bun.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
import { runCli } from './cli-lib'
|
|
3
|
-
|
|
4
|
-
runCli(process.argv).catch((err) => {
|
|
5
|
-
console.error(err)
|
|
6
|
-
// When Bun runs a script, allow non-zero exit codes to propagate
|
|
7
|
-
try {
|
|
8
|
-
process.exit(1)
|
|
9
|
-
} catch (_) {
|
|
10
|
-
// Some Bun environments may not allow process.exit; just rethrow
|
|
11
|
-
throw err
|
|
12
|
-
}
|
|
13
|
-
})
|