hadars 0.1.8 → 0.1.9
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 +21 -14
- package/dist/cli.js +36 -143
- package/dist/index.d.ts +5 -5
- package/dist/ssr-render-worker.js +9 -40
- package/dist/ssr-watch.js +16 -0
- package/package.json +1 -1
- package/src/build.ts +24 -165
- package/src/ssr-render-worker.ts +10 -48
- package/src/types/ninety.ts +5 -5
- package/src/utils/rspack.ts +22 -0
package/README.md
CHANGED
|
@@ -66,12 +66,32 @@ hadars run # multi-core when workers > 1
|
|
|
66
66
|
|
|
67
67
|
- **React Fast Refresh** — full HMR via rspack-dev-server, module-level patches
|
|
68
68
|
- **True SSR** — components render on the server with your data, then hydrate on the client
|
|
69
|
+
- **Shell streaming** — HTML shell is flushed immediately so browsers can start loading assets before the body arrives
|
|
69
70
|
- **Code splitting** — `loadModule('./Comp')` splits on the browser, bundles statically on the server
|
|
70
71
|
- **Head management** — `HadarsHead` controls `<title>`, `<meta>`, `<link>` on server and client
|
|
71
72
|
- **Cross-runtime** — Bun, Node.js, Deno; uses the standard Fetch API throughout
|
|
72
73
|
- **Multi-core** — `workers: os.cpus().length` forks a process per CPU core via `node:cluster`
|
|
73
74
|
- **TypeScript-first** — full types for props, lifecycle hooks, config, and the request object
|
|
74
75
|
|
|
76
|
+
## useServerData
|
|
77
|
+
|
|
78
|
+
Fetch async data inside a component during SSR. The framework's render loop awaits the promise and re-renders until all values are resolved, then serialises them into the page for zero-cost client hydration.
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
import { useServerData } from 'hadars';
|
|
82
|
+
|
|
83
|
+
const UserCard = ({ userId }: { userId: string }) => {
|
|
84
|
+
const user = useServerData(['user', userId], () => db.getUser(userId));
|
|
85
|
+
if (!user) return null; // undefined while pending on the first SSR pass
|
|
86
|
+
return <p>{user.name}</p>;
|
|
87
|
+
};
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- **`key`** — string or string array; must be stable and unique within the page
|
|
91
|
+
- **Server** — calls `fn()`, awaits the result across render iterations, returns `undefined` until resolved
|
|
92
|
+
- **Client** — reads the pre-resolved value from the hydration cache serialised by the server; `fn()` is never called in the browser
|
|
93
|
+
- **Suspense libraries** — also works when `fn()` throws a thenable (e.g. Relay `useLazyLoadQuery` with `suspense: true`); the thrown promise is awaited and the next render re-calls `fn()` synchronously
|
|
94
|
+
|
|
75
95
|
## Data lifecycle hooks
|
|
76
96
|
|
|
77
97
|
| Hook | Runs on | Purpose |
|
|
@@ -97,20 +117,7 @@ hadars run # multi-core when workers > 1
|
|
|
97
117
|
| `fetch` | `function` | — | Custom fetch handler; return a `Response` to short-circuit SSR |
|
|
98
118
|
| `websocket` | `object` | — | WebSocket handler (Bun only) |
|
|
99
119
|
| `wsPath` | `string` | `"/ws"` | Path that triggers WebSocket upgrade |
|
|
100
|
-
| `
|
|
101
|
-
|
|
102
|
-
## Local build
|
|
103
|
-
|
|
104
|
-
```bash
|
|
105
|
-
npm install
|
|
106
|
-
npm run build:all
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
## Publishing
|
|
110
|
-
|
|
111
|
-
1. Update `version`, `repository`, `license` in `package.json`
|
|
112
|
-
2. `npm login`
|
|
113
|
-
3. `npm publish`
|
|
120
|
+
| `optimization` | `object` | — | Override rspack `optimization` for production client builds (merged on top of defaults) |
|
|
114
121
|
|
|
115
122
|
## License
|
|
116
123
|
|
package/dist/cli.js
CHANGED
|
@@ -486,6 +486,21 @@ var buildCompilerConfig = (entry, opts, includeHotPlugin) => {
|
|
|
486
486
|
// for server builds prefer the package "main"/"module" fields and avoid "browser" so we don't pick browser-specific entrypoints
|
|
487
487
|
mainFields: isServerBuild ? ["main", "module"] : ["browser", "module", "main"]
|
|
488
488
|
};
|
|
489
|
+
const optimization = !isServerBuild && !isDev ? {
|
|
490
|
+
moduleIds: "deterministic",
|
|
491
|
+
splitChunks: {
|
|
492
|
+
chunks: "all",
|
|
493
|
+
cacheGroups: {
|
|
494
|
+
react: {
|
|
495
|
+
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
|
|
496
|
+
name: "vendor-react",
|
|
497
|
+
chunks: "all",
|
|
498
|
+
priority: 20
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
},
|
|
502
|
+
...opts.optimization ?? {}
|
|
503
|
+
} : opts.optimization ? { ...opts.optimization } : void 0;
|
|
489
504
|
return {
|
|
490
505
|
entry,
|
|
491
506
|
output: {
|
|
@@ -494,6 +509,7 @@ var buildCompilerConfig = (entry, opts, includeHotPlugin) => {
|
|
|
494
509
|
},
|
|
495
510
|
mode: opts.mode,
|
|
496
511
|
externals,
|
|
512
|
+
...optimization !== void 0 ? { optimization } : {},
|
|
497
513
|
plugins: [
|
|
498
514
|
new rspack.HtmlRspackPlugin({
|
|
499
515
|
publicPath: base || "/",
|
|
@@ -724,7 +740,6 @@ import { RspackDevServer } from "@rspack/dev-server";
|
|
|
724
740
|
import pathMod3 from "node:path";
|
|
725
741
|
import { fileURLToPath as fileURLToPath2, pathToFileURL as pathToFileURL2 } from "node:url";
|
|
726
742
|
import { createRequire as createRequire2 } from "node:module";
|
|
727
|
-
import crypto from "node:crypto";
|
|
728
743
|
import fs from "node:fs/promises";
|
|
729
744
|
import { existsSync as existsSync2 } from "node:fs";
|
|
730
745
|
import os from "node:os";
|
|
@@ -733,16 +748,6 @@ import cluster from "node:cluster";
|
|
|
733
748
|
var encoder = new TextEncoder();
|
|
734
749
|
var HEAD_MARKER = '<meta name="NINETY_HEAD">';
|
|
735
750
|
var BODY_MARKER = '<meta name="NINETY_BODY">';
|
|
736
|
-
var _renderToReadableStream = null;
|
|
737
|
-
async function getReadableStreamRenderer() {
|
|
738
|
-
if (!_renderToReadableStream) {
|
|
739
|
-
const req = createRequire2(pathMod3.resolve(process.cwd(), "__hadars_fake__.js"));
|
|
740
|
-
const resolved = req.resolve("react-dom/server.browser");
|
|
741
|
-
const mod = await import(pathToFileURL2(resolved).href);
|
|
742
|
-
_renderToReadableStream = mod.renderToReadableStream;
|
|
743
|
-
}
|
|
744
|
-
return _renderToReadableStream;
|
|
745
|
-
}
|
|
746
751
|
var _renderToString = null;
|
|
747
752
|
async function getRenderToString() {
|
|
748
753
|
if (!_renderToString) {
|
|
@@ -770,32 +775,10 @@ var RenderWorkerPool = class {
|
|
|
770
775
|
const w = new Worker(workerPath, { workerData: { ssrBundlePath } });
|
|
771
776
|
this.workerPending.set(w, /* @__PURE__ */ new Set());
|
|
772
777
|
w.on("message", (msg) => {
|
|
773
|
-
const { id,
|
|
778
|
+
const { id, html, headHtml, status, error } = msg;
|
|
774
779
|
const p = this.pending.get(id);
|
|
775
780
|
if (!p)
|
|
776
781
|
return;
|
|
777
|
-
if (p.kind === "renderFullStream") {
|
|
778
|
-
if (type === "head") {
|
|
779
|
-
p.headSettled = true;
|
|
780
|
-
p.headResolve({ headHtml, status });
|
|
781
|
-
return;
|
|
782
|
-
}
|
|
783
|
-
if (type === "chunk") {
|
|
784
|
-
p.controller.enqueue(chunk);
|
|
785
|
-
return;
|
|
786
|
-
}
|
|
787
|
-
this.pending.delete(id);
|
|
788
|
-
this.workerPending.get(w)?.delete(id);
|
|
789
|
-
if (type === "done")
|
|
790
|
-
p.controller.close();
|
|
791
|
-
else {
|
|
792
|
-
const err = new Error(error ?? "Stream error");
|
|
793
|
-
if (!p.headSettled)
|
|
794
|
-
p.headReject(err);
|
|
795
|
-
p.controller.error(err);
|
|
796
|
-
}
|
|
797
|
-
return;
|
|
798
|
-
}
|
|
799
782
|
this.pending.delete(id);
|
|
800
783
|
this.workerPending.get(w)?.delete(id);
|
|
801
784
|
if (error)
|
|
@@ -829,13 +812,7 @@ var RenderWorkerPool = class {
|
|
|
829
812
|
const p = this.pending.get(id);
|
|
830
813
|
if (p) {
|
|
831
814
|
this.pending.delete(id);
|
|
832
|
-
|
|
833
|
-
p.reject(err);
|
|
834
|
-
else {
|
|
835
|
-
if (!p.headSettled)
|
|
836
|
-
p.headReject(err);
|
|
837
|
-
p.controller.error(err);
|
|
838
|
-
}
|
|
815
|
+
p.reject(err);
|
|
839
816
|
}
|
|
840
817
|
}
|
|
841
818
|
this.workerPending.delete(w);
|
|
@@ -868,82 +845,25 @@ var RenderWorkerPool = class {
|
|
|
868
845
|
}
|
|
869
846
|
});
|
|
870
847
|
}
|
|
871
|
-
/** Run the full SSR lifecycle in a worker thread, streaming the response. */
|
|
872
|
-
renderFullStream(req) {
|
|
873
|
-
let headResolve;
|
|
874
|
-
let headReject;
|
|
875
|
-
const head = new Promise((res, rej) => {
|
|
876
|
-
headResolve = res;
|
|
877
|
-
headReject = rej;
|
|
878
|
-
});
|
|
879
|
-
let controller;
|
|
880
|
-
const stream = new ReadableStream({ start: (ctrl) => {
|
|
881
|
-
controller = ctrl;
|
|
882
|
-
} });
|
|
883
|
-
const w = this.nextWorker();
|
|
884
|
-
if (!w) {
|
|
885
|
-
queueMicrotask(() => {
|
|
886
|
-
headReject(new Error("[hadars] No render workers available"));
|
|
887
|
-
controller.error(new Error("[hadars] No render workers available"));
|
|
888
|
-
});
|
|
889
|
-
return { head, stream };
|
|
890
|
-
}
|
|
891
|
-
const id = this.nextId++;
|
|
892
|
-
this.pending.set(id, { kind: "renderFullStream", headSettled: false, headResolve, headReject, controller });
|
|
893
|
-
this.workerPending.get(w)?.add(id);
|
|
894
|
-
try {
|
|
895
|
-
w.postMessage({ id, type: "renderFull", streaming: true, request: req });
|
|
896
|
-
} catch (err) {
|
|
897
|
-
this.pending.delete(id);
|
|
898
|
-
this.workerPending.get(w)?.delete(id);
|
|
899
|
-
queueMicrotask(() => {
|
|
900
|
-
headReject(err);
|
|
901
|
-
controller.error(err);
|
|
902
|
-
});
|
|
903
|
-
}
|
|
904
|
-
return { head, stream };
|
|
905
|
-
}
|
|
906
848
|
async terminate() {
|
|
907
849
|
await Promise.all(this.workers.map((w) => w.terminate()));
|
|
908
850
|
}
|
|
909
851
|
};
|
|
910
|
-
async function buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml,
|
|
911
|
-
const renderToString =
|
|
912
|
-
const renderReadableStream = streaming ? await getReadableStreamRenderer() : null;
|
|
913
|
-
if (!streaming) {
|
|
914
|
-
const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
|
|
915
|
-
let bodyHtml;
|
|
916
|
-
try {
|
|
917
|
-
globalThis.__hadarsUnsuspend = unsuspendForRender;
|
|
918
|
-
bodyHtml = renderToString(ReactPage);
|
|
919
|
-
} finally {
|
|
920
|
-
globalThis.__hadarsUnsuspend = null;
|
|
921
|
-
}
|
|
922
|
-
return new Response(precontentHtml + bodyHtml + postContent, {
|
|
923
|
-
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
924
|
-
status
|
|
925
|
-
});
|
|
926
|
-
}
|
|
852
|
+
async function buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspendForRender) {
|
|
853
|
+
const renderToString = await getRenderToString();
|
|
927
854
|
const responseStream = new ReadableStream({
|
|
928
855
|
async start(controller) {
|
|
929
856
|
const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
|
|
930
857
|
controller.enqueue(encoder.encode(precontentHtml));
|
|
931
|
-
|
|
858
|
+
await Promise.resolve();
|
|
859
|
+
let bodyHtml;
|
|
932
860
|
try {
|
|
933
861
|
globalThis.__hadarsUnsuspend = unsuspendForRender;
|
|
934
|
-
|
|
862
|
+
bodyHtml = renderToString(ReactPage);
|
|
935
863
|
} finally {
|
|
936
864
|
globalThis.__hadarsUnsuspend = null;
|
|
937
865
|
}
|
|
938
|
-
|
|
939
|
-
const reader = bodyStream.getReader();
|
|
940
|
-
while (true) {
|
|
941
|
-
const { done, value } = await reader.read();
|
|
942
|
-
if (done)
|
|
943
|
-
break;
|
|
944
|
-
controller.enqueue(value);
|
|
945
|
-
}
|
|
946
|
-
controller.enqueue(encoder.encode(postContent));
|
|
866
|
+
controller.enqueue(encoder.encode(bodyHtml + postContent));
|
|
947
867
|
controller.close();
|
|
948
868
|
}
|
|
949
869
|
});
|
|
@@ -1233,7 +1153,7 @@ var dev = async (options) => {
|
|
|
1233
1153
|
}
|
|
1234
1154
|
});
|
|
1235
1155
|
const unsuspend = renderPayload.appProps.context?._unsuspend ?? null;
|
|
1236
|
-
return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml,
|
|
1156
|
+
return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
|
|
1237
1157
|
}, options.websocket);
|
|
1238
1158
|
};
|
|
1239
1159
|
var build = async (options) => {
|
|
@@ -1250,19 +1170,20 @@ var build = async (options) => {
|
|
|
1250
1170
|
}
|
|
1251
1171
|
const tmpFilePath = pathMod3.join(os.tmpdir(), `hadars-client-${Date.now()}.tsx`);
|
|
1252
1172
|
await fs.writeFile(tmpFilePath, clientScript);
|
|
1253
|
-
const randomStr = crypto.randomBytes(6).toString("hex");
|
|
1254
1173
|
console.log("Building client and server bundles in parallel...");
|
|
1255
1174
|
await Promise.all([
|
|
1256
1175
|
compileEntry(tmpFilePath, {
|
|
1257
1176
|
target: "web",
|
|
1258
1177
|
output: {
|
|
1259
|
-
|
|
1178
|
+
// Content hash: filename is stable when code is unchanged → better browser/CDN cache.
|
|
1179
|
+
filename: "index.[contenthash:8].js",
|
|
1260
1180
|
path: pathMod3.resolve(__dirname2, StaticPath)
|
|
1261
1181
|
},
|
|
1262
1182
|
base: options.baseURL,
|
|
1263
1183
|
mode: "production",
|
|
1264
1184
|
swcPlugins: options.swcPlugins,
|
|
1265
|
-
define: options.define
|
|
1185
|
+
define: options.define,
|
|
1186
|
+
optimization: options.optimization
|
|
1266
1187
|
}),
|
|
1267
1188
|
compileEntry(pathMod3.resolve(__dirname2, options.entry), {
|
|
1268
1189
|
output: {
|
|
@@ -1280,10 +1201,6 @@ var build = async (options) => {
|
|
|
1280
1201
|
})
|
|
1281
1202
|
]);
|
|
1282
1203
|
await fs.rm(tmpFilePath);
|
|
1283
|
-
await fs.writeFile(
|
|
1284
|
-
pathMod3.join(__dirname2, HadarsFolder, "hadars.json"),
|
|
1285
|
-
JSON.stringify({ buildId: randomStr })
|
|
1286
|
-
);
|
|
1287
1204
|
console.log("Build complete.");
|
|
1288
1205
|
};
|
|
1289
1206
|
var run = async (options) => {
|
|
@@ -1364,36 +1281,12 @@ var run = async (options) => {
|
|
|
1364
1281
|
} = await import(componentPath);
|
|
1365
1282
|
if (renderPool) {
|
|
1366
1283
|
const serialReq = await serializeRequest(request);
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
controller.enqueue(encoder.encode(precontentHtml));
|
|
1374
|
-
const reader = stream.getReader();
|
|
1375
|
-
while (true) {
|
|
1376
|
-
const { done, value } = await reader.read();
|
|
1377
|
-
if (done)
|
|
1378
|
-
break;
|
|
1379
|
-
controller.enqueue(value);
|
|
1380
|
-
}
|
|
1381
|
-
controller.enqueue(encoder.encode(postContent));
|
|
1382
|
-
controller.close();
|
|
1383
|
-
}
|
|
1384
|
-
});
|
|
1385
|
-
return new Response(responseStream, {
|
|
1386
|
-
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
1387
|
-
status: wStatus
|
|
1388
|
-
});
|
|
1389
|
-
} else {
|
|
1390
|
-
const { html, headHtml: wHead, status: wStatus } = await renderPool.renderFull(serialReq);
|
|
1391
|
-
const [precontentHtml, postContent] = await getPrecontentHtml(wHead);
|
|
1392
|
-
return new Response(precontentHtml + html + postContent, {
|
|
1393
|
-
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
1394
|
-
status: wStatus
|
|
1395
|
-
});
|
|
1396
|
-
}
|
|
1284
|
+
const { html, headHtml: wHead, status: wStatus } = await renderPool.renderFull(serialReq);
|
|
1285
|
+
const [precontentHtml, postContent] = await getPrecontentHtml(wHead);
|
|
1286
|
+
return new Response(precontentHtml + html + postContent, {
|
|
1287
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
1288
|
+
status: wStatus
|
|
1289
|
+
});
|
|
1397
1290
|
}
|
|
1398
1291
|
const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
|
|
1399
1292
|
document: {
|
|
@@ -1405,7 +1298,7 @@ var run = async (options) => {
|
|
|
1405
1298
|
}
|
|
1406
1299
|
});
|
|
1407
1300
|
const unsuspend = renderPayload.appProps.context?._unsuspend ?? null;
|
|
1408
|
-
return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml,
|
|
1301
|
+
return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
|
|
1409
1302
|
}, options.websocket);
|
|
1410
1303
|
};
|
|
1411
1304
|
|
package/dist/index.d.ts
CHANGED
|
@@ -83,15 +83,15 @@ interface HadarsOptions {
|
|
|
83
83
|
*
|
|
84
84
|
* **Bun / Deno** — creates a `node:worker_threads` render pool of this size.
|
|
85
85
|
* Each thread handles the synchronous `renderToString` step, freeing the
|
|
86
|
-
* main event loop for I/O.
|
|
86
|
+
* main event loop for I/O.
|
|
87
87
|
*/
|
|
88
88
|
workers?: number;
|
|
89
89
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
90
|
+
* Override or extend rspack's `optimization` config for production client builds.
|
|
91
|
+
* Merged on top of hadars defaults (splitChunks vendor splitting, deterministic moduleIds).
|
|
92
|
+
* Has no effect on the SSR bundle or dev mode.
|
|
93
93
|
*/
|
|
94
|
-
|
|
94
|
+
optimization?: Record<string, unknown>;
|
|
95
95
|
}
|
|
96
96
|
type SwcPluginItem = string | [string, Record<string, unknown>] | {
|
|
97
97
|
path: string;
|
|
@@ -7,7 +7,6 @@ var { ssrBundlePath } = workerData;
|
|
|
7
7
|
var _React = null;
|
|
8
8
|
var _renderToStaticMarkup = null;
|
|
9
9
|
var _renderToString = null;
|
|
10
|
-
var _renderToReadableStream = null;
|
|
11
10
|
var _ssrMod = null;
|
|
12
11
|
async function init() {
|
|
13
12
|
if (_React && _ssrMod)
|
|
@@ -24,11 +23,6 @@ async function init() {
|
|
|
24
23
|
_renderToString = serverMod.renderToString;
|
|
25
24
|
_renderToStaticMarkup = serverMod.renderToStaticMarkup;
|
|
26
25
|
}
|
|
27
|
-
if (!_renderToReadableStream) {
|
|
28
|
-
const browserPath = pathToFileURL(req.resolve("react-dom/server.browser")).href;
|
|
29
|
-
const browserMod = await import(browserPath);
|
|
30
|
-
_renderToReadableStream = browserMod.renderToReadableStream;
|
|
31
|
-
}
|
|
32
26
|
if (!_ssrMod) {
|
|
33
27
|
_ssrMod = await import(pathToFileURL(ssrBundlePath).href);
|
|
34
28
|
}
|
|
@@ -140,48 +134,23 @@ async function runFullLifecycle(serialReq) {
|
|
|
140
134
|
return { finalAppProps, clientProps, headHtml, status, unsuspend };
|
|
141
135
|
}
|
|
142
136
|
parentPort.on("message", async (msg) => {
|
|
143
|
-
const { id, type, request
|
|
137
|
+
const { id, type, request } = msg;
|
|
144
138
|
try {
|
|
145
139
|
await init();
|
|
146
140
|
if (type !== "renderFull")
|
|
147
141
|
return;
|
|
148
142
|
const { finalAppProps, clientProps, headHtml, status, unsuspend } = await runFullLifecycle(request);
|
|
149
143
|
const ReactPage = buildReactPage(finalAppProps, clientProps);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
} finally {
|
|
157
|
-
globalThis.__hadarsUnsuspend = null;
|
|
158
|
-
}
|
|
159
|
-
const stream = await streamPromise;
|
|
160
|
-
const reader = stream.getReader();
|
|
161
|
-
while (true) {
|
|
162
|
-
const { done, value } = await reader.read();
|
|
163
|
-
if (done)
|
|
164
|
-
break;
|
|
165
|
-
parentPort.postMessage({ id, type: "chunk", chunk: value }, [value.buffer]);
|
|
166
|
-
}
|
|
167
|
-
parentPort.postMessage({ id, type: "done" });
|
|
168
|
-
} else {
|
|
169
|
-
let html;
|
|
170
|
-
try {
|
|
171
|
-
globalThis.__hadarsUnsuspend = unsuspend;
|
|
172
|
-
html = _renderToString(ReactPage);
|
|
173
|
-
} finally {
|
|
174
|
-
globalThis.__hadarsUnsuspend = null;
|
|
175
|
-
}
|
|
176
|
-
parentPort.postMessage({ id, html, headHtml, status });
|
|
144
|
+
let html;
|
|
145
|
+
try {
|
|
146
|
+
globalThis.__hadarsUnsuspend = unsuspend;
|
|
147
|
+
html = _renderToString(ReactPage);
|
|
148
|
+
} finally {
|
|
149
|
+
globalThis.__hadarsUnsuspend = null;
|
|
177
150
|
}
|
|
151
|
+
parentPort.postMessage({ id, html, headHtml, status });
|
|
178
152
|
} catch (err) {
|
|
179
153
|
globalThis.__hadarsUnsuspend = null;
|
|
180
|
-
|
|
181
|
-
if (streaming) {
|
|
182
|
-
parentPort.postMessage({ id, type: "error", error: errMsg });
|
|
183
|
-
} else {
|
|
184
|
-
parentPort.postMessage({ id, error: errMsg });
|
|
185
|
-
}
|
|
154
|
+
parentPort.postMessage({ id, error: err?.message ?? String(err) });
|
|
186
155
|
}
|
|
187
156
|
});
|
package/dist/ssr-watch.js
CHANGED
|
@@ -211,6 +211,21 @@ var buildCompilerConfig = (entry2, opts, includeHotPlugin) => {
|
|
|
211
211
|
// for server builds prefer the package "main"/"module" fields and avoid "browser" so we don't pick browser-specific entrypoints
|
|
212
212
|
mainFields: isServerBuild ? ["main", "module"] : ["browser", "module", "main"]
|
|
213
213
|
};
|
|
214
|
+
const optimization = !isServerBuild && !isDev ? {
|
|
215
|
+
moduleIds: "deterministic",
|
|
216
|
+
splitChunks: {
|
|
217
|
+
chunks: "all",
|
|
218
|
+
cacheGroups: {
|
|
219
|
+
react: {
|
|
220
|
+
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
|
|
221
|
+
name: "vendor-react",
|
|
222
|
+
chunks: "all",
|
|
223
|
+
priority: 20
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
...opts.optimization ?? {}
|
|
228
|
+
} : opts.optimization ? { ...opts.optimization } : void 0;
|
|
214
229
|
return {
|
|
215
230
|
entry: entry2,
|
|
216
231
|
output: {
|
|
@@ -219,6 +234,7 @@ var buildCompilerConfig = (entry2, opts, includeHotPlugin) => {
|
|
|
219
234
|
},
|
|
220
235
|
mode: opts.mode,
|
|
221
236
|
externals,
|
|
237
|
+
...optimization !== void 0 ? { optimization } : {},
|
|
222
238
|
plugins: [
|
|
223
239
|
new rspack.HtmlRspackPlugin({
|
|
224
240
|
publicPath: base2 || "/",
|
package/package.json
CHANGED
package/src/build.ts
CHANGED
|
@@ -10,36 +10,18 @@ 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';
|
|
14
13
|
import fs from 'node:fs/promises';
|
|
15
14
|
import { existsSync } from 'node:fs';
|
|
16
15
|
import os from 'node:os';
|
|
17
16
|
import { spawn } from 'node:child_process';
|
|
18
17
|
import cluster from 'node:cluster';
|
|
19
18
|
import type { HadarsEntryModule, HadarsOptions, HadarsProps } from "./types/ninety";
|
|
20
|
-
|
|
21
19
|
const encoder = new TextEncoder();
|
|
22
20
|
|
|
23
21
|
const HEAD_MARKER = '<meta name="NINETY_HEAD">';
|
|
24
22
|
const BODY_MARKER = '<meta name="NINETY_BODY">';
|
|
25
23
|
|
|
26
|
-
// Resolve react-dom/server.browser from the *project's* node_modules (process.cwd())
|
|
27
|
-
// rather than from hadars's own install location. This guarantees the same React
|
|
28
|
-
// instance is used here and in the SSR bundle (which is also built relative to cwd),
|
|
29
|
-
// preventing "Invalid hook call" errors when hadars is installed as a file: symlink.
|
|
30
|
-
let _renderToReadableStream: ((element: any, options?: any) => Promise<ReadableStream<Uint8Array>>) | null = null;
|
|
31
|
-
async function getReadableStreamRenderer(): Promise<(element: any, options?: any) => Promise<ReadableStream<Uint8Array>>> {
|
|
32
|
-
if (!_renderToReadableStream) {
|
|
33
|
-
const req = createRequire(pathMod.resolve(process.cwd(), '__hadars_fake__.js'));
|
|
34
|
-
const resolved = req.resolve('react-dom/server.browser');
|
|
35
|
-
const mod = await import(pathToFileURL(resolved).href);
|
|
36
|
-
_renderToReadableStream = mod.renderToReadableStream;
|
|
37
|
-
}
|
|
38
|
-
return _renderToReadableStream!;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
24
|
// Resolve renderToString from react-dom/server in the project's node_modules.
|
|
42
|
-
// Used when streaming is disabled via `streaming: false` in hadars config.
|
|
43
25
|
let _renderToString: ((element: any) => string) | null = null;
|
|
44
26
|
async function getRenderToString(): Promise<(element: any) => string> {
|
|
45
27
|
if (!_renderToString) {
|
|
@@ -53,10 +35,6 @@ async function getRenderToString(): Promise<(element: any) => string> {
|
|
|
53
35
|
|
|
54
36
|
// Round-robin thread pool for SSR rendering — used on Bun/Deno where
|
|
55
37
|
// node:cluster is not available but node:worker_threads is.
|
|
56
|
-
// Supports three render modes matching the worker's message protocol:
|
|
57
|
-
// staticMarkup — renderToStaticMarkup for lifecycle passes in getReactResponse
|
|
58
|
-
// renderString — renderToString for non-streaming responses
|
|
59
|
-
// renderStream — renderToReadableStream chunks forwarded as a ReadableStream
|
|
60
38
|
|
|
61
39
|
import type { SerializableRequest } from './ssr-render-worker';
|
|
62
40
|
|
|
@@ -65,14 +43,7 @@ type PendingRenderFull = {
|
|
|
65
43
|
resolve: (result: { html: string; headHtml: string; status: number }) => void;
|
|
66
44
|
reject: (err: Error) => void;
|
|
67
45
|
};
|
|
68
|
-
type
|
|
69
|
-
kind: 'renderFullStream';
|
|
70
|
-
headSettled: boolean;
|
|
71
|
-
headResolve: (result: { headHtml: string; status: number }) => void;
|
|
72
|
-
headReject: (err: Error) => void;
|
|
73
|
-
controller: ReadableStreamDefaultController<Uint8Array>;
|
|
74
|
-
};
|
|
75
|
-
type PendingEntry = PendingRenderFull | PendingRenderFullStream;
|
|
46
|
+
type PendingEntry = PendingRenderFull;
|
|
76
47
|
|
|
77
48
|
class RenderWorkerPool {
|
|
78
49
|
private workers: any[] = [];
|
|
@@ -95,32 +66,9 @@ class RenderWorkerPool {
|
|
|
95
66
|
const w = new Worker(workerPath, { workerData: { ssrBundlePath } });
|
|
96
67
|
this.workerPending.set(w, new Set());
|
|
97
68
|
w.on('message', (msg: any) => {
|
|
98
|
-
const { id,
|
|
69
|
+
const { id, html, headHtml, status, error } = msg;
|
|
99
70
|
const p = this.pending.get(id);
|
|
100
71
|
if (!p) return;
|
|
101
|
-
|
|
102
|
-
if (p.kind === 'renderFullStream') {
|
|
103
|
-
if (type === 'head') {
|
|
104
|
-
p.headSettled = true;
|
|
105
|
-
p.headResolve({ headHtml, status });
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
if (type === 'chunk') {
|
|
109
|
-
p.controller.enqueue(chunk as Uint8Array);
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
this.pending.delete(id);
|
|
113
|
-
this.workerPending.get(w)?.delete(id);
|
|
114
|
-
if (type === 'done') p.controller.close();
|
|
115
|
-
else {
|
|
116
|
-
const err = new Error(error ?? 'Stream error');
|
|
117
|
-
if (!p.headSettled) p.headReject(err);
|
|
118
|
-
p.controller.error(err);
|
|
119
|
-
}
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// renderFull (non-streaming)
|
|
124
72
|
this.pending.delete(id);
|
|
125
73
|
this.workerPending.get(w)?.delete(id);
|
|
126
74
|
if (error) p.reject(new Error(error));
|
|
@@ -153,11 +101,7 @@ class RenderWorkerPool {
|
|
|
153
101
|
const p = this.pending.get(id);
|
|
154
102
|
if (p) {
|
|
155
103
|
this.pending.delete(id);
|
|
156
|
-
|
|
157
|
-
else {
|
|
158
|
-
if (!p.headSettled) p.headReject(err);
|
|
159
|
-
p.controller.error(err);
|
|
160
|
-
}
|
|
104
|
+
p.reject(err);
|
|
161
105
|
}
|
|
162
106
|
}
|
|
163
107
|
this.workerPending.delete(w);
|
|
@@ -189,36 +133,6 @@ class RenderWorkerPool {
|
|
|
189
133
|
});
|
|
190
134
|
}
|
|
191
135
|
|
|
192
|
-
/** Run the full SSR lifecycle in a worker thread, streaming the response. */
|
|
193
|
-
renderFullStream(req: SerializableRequest): { head: Promise<{ headHtml: string; status: number }>; stream: ReadableStream<Uint8Array> } {
|
|
194
|
-
let headResolve!: (r: { headHtml: string; status: number }) => void;
|
|
195
|
-
let headReject!: (err: Error) => void;
|
|
196
|
-
const head = new Promise<{ headHtml: string; status: number }>((res, rej) => {
|
|
197
|
-
headResolve = res; headReject = rej;
|
|
198
|
-
});
|
|
199
|
-
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
200
|
-
const stream = new ReadableStream<Uint8Array>({ start: (ctrl) => { controller = ctrl; } });
|
|
201
|
-
const w = this.nextWorker();
|
|
202
|
-
if (!w) {
|
|
203
|
-
queueMicrotask(() => {
|
|
204
|
-
headReject(new Error('[hadars] No render workers available'));
|
|
205
|
-
controller.error(new Error('[hadars] No render workers available'));
|
|
206
|
-
});
|
|
207
|
-
return { head, stream };
|
|
208
|
-
}
|
|
209
|
-
const id = this.nextId++;
|
|
210
|
-
this.pending.set(id, { kind: 'renderFullStream', headSettled: false, headResolve, headReject, controller });
|
|
211
|
-
this.workerPending.get(w)?.add(id);
|
|
212
|
-
try {
|
|
213
|
-
w.postMessage({ id, type: 'renderFull', streaming: true, request: req });
|
|
214
|
-
} catch (err) {
|
|
215
|
-
this.pending.delete(id);
|
|
216
|
-
this.workerPending.get(w)?.delete(id);
|
|
217
|
-
queueMicrotask(() => { headReject(err as Error); controller.error(err); });
|
|
218
|
-
}
|
|
219
|
-
return { head, stream };
|
|
220
|
-
}
|
|
221
|
-
|
|
222
136
|
async terminate(): Promise<void> {
|
|
223
137
|
await Promise.all(this.workers.map((w: any) => w.terminate()));
|
|
224
138
|
}
|
|
@@ -229,56 +143,29 @@ async function buildSsrResponse(
|
|
|
229
143
|
headHtml: string,
|
|
230
144
|
status: number,
|
|
231
145
|
getPrecontentHtml: (headHtml: string) => Promise<[string, string]>,
|
|
232
|
-
streaming: boolean,
|
|
233
146
|
unsuspendForRender: any,
|
|
234
147
|
): Promise<Response> {
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
// Loading the renderer first ensures the set→call→clear sequence is synchronous.
|
|
239
|
-
const renderToString = !streaming ? await getRenderToString() : null;
|
|
240
|
-
const renderReadableStream = streaming ? await getReadableStreamRenderer() : null;
|
|
241
|
-
|
|
242
|
-
if (!streaming) {
|
|
243
|
-
const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
|
|
244
|
-
let bodyHtml: string;
|
|
245
|
-
// set → call (synchronous) → clear: no await in between, safe under concurrency
|
|
246
|
-
try {
|
|
247
|
-
(globalThis as any).__hadarsUnsuspend = unsuspendForRender;
|
|
248
|
-
bodyHtml = renderToString!(ReactPage);
|
|
249
|
-
} finally {
|
|
250
|
-
(globalThis as any).__hadarsUnsuspend = null;
|
|
251
|
-
}
|
|
252
|
-
return new Response(precontentHtml + bodyHtml + postContent, {
|
|
253
|
-
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
254
|
-
status,
|
|
255
|
-
});
|
|
256
|
-
}
|
|
148
|
+
// Pre-load renderer before starting the stream so the set→call→clear
|
|
149
|
+
// sequence around __hadarsUnsuspend is fully synchronous (no await between them).
|
|
150
|
+
const renderToString = await getRenderToString();
|
|
257
151
|
|
|
258
152
|
const responseStream = new ReadableStream({
|
|
259
153
|
async start(controller) {
|
|
260
154
|
const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
|
|
155
|
+
// Flush the shell (precontentHtml) immediately so the browser can
|
|
156
|
+
// start loading CSS/fonts before renderToString blocks the thread.
|
|
261
157
|
controller.enqueue(encoder.encode(precontentHtml));
|
|
158
|
+
await Promise.resolve(); // yield to let the runtime flush the shell chunk
|
|
262
159
|
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
// immediately before the call and clear right after — no await in between.
|
|
266
|
-
let streamPromise: Promise<ReadableStream<Uint8Array>>;
|
|
160
|
+
// set → call (synchronous) → clear: no await in between, safe under concurrency
|
|
161
|
+
let bodyHtml: string;
|
|
267
162
|
try {
|
|
268
163
|
(globalThis as any).__hadarsUnsuspend = unsuspendForRender;
|
|
269
|
-
|
|
164
|
+
bodyHtml = renderToString(ReactPage);
|
|
270
165
|
} finally {
|
|
271
166
|
(globalThis as any).__hadarsUnsuspend = null;
|
|
272
167
|
}
|
|
273
|
-
|
|
274
|
-
const reader = bodyStream.getReader();
|
|
275
|
-
while (true) {
|
|
276
|
-
const { done, value } = await reader.read();
|
|
277
|
-
if (done) break;
|
|
278
|
-
controller.enqueue(value);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
controller.enqueue(encoder.encode(postContent));
|
|
168
|
+
controller.enqueue(encoder.encode(bodyHtml + postContent));
|
|
282
169
|
controller.close();
|
|
283
170
|
},
|
|
284
171
|
});
|
|
@@ -622,7 +509,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
622
509
|
});
|
|
623
510
|
|
|
624
511
|
const unsuspend = (renderPayload.appProps.context as any)?._unsuspend ?? null;
|
|
625
|
-
return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml,
|
|
512
|
+
return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
|
|
626
513
|
}, options.websocket);
|
|
627
514
|
};
|
|
628
515
|
|
|
@@ -647,8 +534,6 @@ export const build = async (options: HadarsRuntimeOptions) => {
|
|
|
647
534
|
const tmpFilePath = pathMod.join(os.tmpdir(), `hadars-client-${Date.now()}.tsx`);
|
|
648
535
|
await fs.writeFile(tmpFilePath, clientScript);
|
|
649
536
|
|
|
650
|
-
const randomStr = crypto.randomBytes(6).toString('hex');
|
|
651
|
-
|
|
652
537
|
// Compile client and SSR bundles in parallel — they write to different
|
|
653
538
|
// output directories and use different entry files, so they are fully
|
|
654
539
|
// independent and safe to run concurrently.
|
|
@@ -657,13 +542,15 @@ export const build = async (options: HadarsRuntimeOptions) => {
|
|
|
657
542
|
compileEntry(tmpFilePath, {
|
|
658
543
|
target: 'web',
|
|
659
544
|
output: {
|
|
660
|
-
|
|
545
|
+
// Content hash: filename is stable when code is unchanged → better browser/CDN cache.
|
|
546
|
+
filename: 'index.[contenthash:8].js',
|
|
661
547
|
path: pathMod.resolve(__dirname, StaticPath),
|
|
662
548
|
},
|
|
663
549
|
base: options.baseURL,
|
|
664
550
|
mode: 'production',
|
|
665
551
|
swcPlugins: options.swcPlugins,
|
|
666
552
|
define: options.define,
|
|
553
|
+
optimization: options.optimization,
|
|
667
554
|
}),
|
|
668
555
|
compileEntry(pathMod.resolve(__dirname, options.entry), {
|
|
669
556
|
output: {
|
|
@@ -681,11 +568,6 @@ export const build = async (options: HadarsRuntimeOptions) => {
|
|
|
681
568
|
}),
|
|
682
569
|
]);
|
|
683
570
|
await fs.rm(tmpFilePath);
|
|
684
|
-
|
|
685
|
-
await fs.writeFile(
|
|
686
|
-
pathMod.join(__dirname, HadarsFolder, 'hadars.json'),
|
|
687
|
-
JSON.stringify({ buildId: randomStr }),
|
|
688
|
-
);
|
|
689
571
|
console.log("Build complete.");
|
|
690
572
|
};
|
|
691
573
|
|
|
@@ -784,35 +666,12 @@ export const run = async (options: HadarsRuntimeOptions) => {
|
|
|
784
666
|
if (renderPool) {
|
|
785
667
|
// Worker runs the full lifecycle — no non-serializable objects cross the thread boundary.
|
|
786
668
|
const serialReq = await serializeRequest(request);
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
controller.enqueue(encoder.encode(precontentHtml));
|
|
794
|
-
const reader = stream.getReader();
|
|
795
|
-
while (true) {
|
|
796
|
-
const { done, value } = await reader.read();
|
|
797
|
-
if (done) break;
|
|
798
|
-
controller.enqueue(value);
|
|
799
|
-
}
|
|
800
|
-
controller.enqueue(encoder.encode(postContent));
|
|
801
|
-
controller.close();
|
|
802
|
-
},
|
|
803
|
-
});
|
|
804
|
-
return new Response(responseStream, {
|
|
805
|
-
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
806
|
-
status: wStatus,
|
|
807
|
-
});
|
|
808
|
-
} else {
|
|
809
|
-
const { html, headHtml: wHead, status: wStatus } = await renderPool.renderFull(serialReq);
|
|
810
|
-
const [precontentHtml, postContent] = await getPrecontentHtml(wHead);
|
|
811
|
-
return new Response(precontentHtml + html + postContent, {
|
|
812
|
-
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
813
|
-
status: wStatus,
|
|
814
|
-
});
|
|
815
|
-
}
|
|
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
|
+
});
|
|
816
675
|
}
|
|
817
676
|
|
|
818
677
|
const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
|
|
@@ -826,6 +685,6 @@ export const run = async (options: HadarsRuntimeOptions) => {
|
|
|
826
685
|
});
|
|
827
686
|
|
|
828
687
|
const unsuspend = (renderPayload.appProps.context as any)?._unsuspend ?? null;
|
|
829
|
-
return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml,
|
|
688
|
+
return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
|
|
830
689
|
}, options.websocket);
|
|
831
690
|
};
|
package/src/ssr-render-worker.ts
CHANGED
|
@@ -3,18 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Handles one message type sent by RenderWorkerPool in build.ts:
|
|
5
5
|
*
|
|
6
|
-
* { type: 'renderFull', id,
|
|
6
|
+
* { type: 'renderFull', id, request: SerializableRequest }
|
|
7
7
|
* → runs full lifecycle (getInitProps → render loop → getAfterRenderProps → getFinalProps)
|
|
8
8
|
* → renderToString(ReactPage)
|
|
9
9
|
* → postMessage({ id, html, headHtml, status })
|
|
10
10
|
*
|
|
11
|
-
* { type: 'renderFull', id, streaming: true, request: SerializableRequest }
|
|
12
|
-
* → runs full lifecycle
|
|
13
|
-
* → renderToReadableStream(ReactPage), streams chunks back
|
|
14
|
-
* → postMessage({ id, type: 'head', headHtml, status })
|
|
15
|
-
* → postMessage({ id, type: 'chunk', chunk }) × N
|
|
16
|
-
* → postMessage({ id, type: 'done' })
|
|
17
|
-
*
|
|
18
11
|
* The SSR bundle path is passed once via workerData at thread creation time so
|
|
19
12
|
* the SSR module is only imported once per worker lifetime.
|
|
20
13
|
*/
|
|
@@ -31,7 +24,6 @@ const { ssrBundlePath } = workerData as { ssrBundlePath: string };
|
|
|
31
24
|
let _React: any = null;
|
|
32
25
|
let _renderToStaticMarkup: ((element: any) => string) | null = null;
|
|
33
26
|
let _renderToString: ((element: any) => string) | null = null;
|
|
34
|
-
let _renderToReadableStream: ((element: any, options?: any) => Promise<ReadableStream<Uint8Array>>) | null = null;
|
|
35
27
|
// Full SSR module — includes default (App component) + lifecycle exports.
|
|
36
28
|
let _ssrMod: any = null;
|
|
37
29
|
|
|
@@ -53,12 +45,6 @@ async function init() {
|
|
|
53
45
|
_renderToStaticMarkup = serverMod.renderToStaticMarkup;
|
|
54
46
|
}
|
|
55
47
|
|
|
56
|
-
if (!_renderToReadableStream) {
|
|
57
|
-
const browserPath = pathToFileURL(req.resolve('react-dom/server.browser')).href;
|
|
58
|
-
const browserMod = await import(browserPath);
|
|
59
|
-
_renderToReadableStream = browserMod.renderToReadableStream;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
48
|
if (!_ssrMod) {
|
|
63
49
|
_ssrMod = await import(pathToFileURL(ssrBundlePath).href);
|
|
64
50
|
}
|
|
@@ -193,7 +179,7 @@ async function runFullLifecycle(serialReq: SerializableRequest) {
|
|
|
193
179
|
}
|
|
194
180
|
|
|
195
181
|
parentPort!.on('message', async (msg: any) => {
|
|
196
|
-
const { id, type, request
|
|
182
|
+
const { id, type, request } = msg;
|
|
197
183
|
try {
|
|
198
184
|
await init();
|
|
199
185
|
|
|
@@ -204,41 +190,17 @@ parentPort!.on('message', async (msg: any) => {
|
|
|
204
190
|
|
|
205
191
|
const ReactPage = buildReactPage(finalAppProps, clientProps);
|
|
206
192
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
} finally {
|
|
214
|
-
(globalThis as any).__hadarsUnsuspend = null;
|
|
215
|
-
}
|
|
216
|
-
const stream = await streamPromise;
|
|
217
|
-
const reader = stream.getReader();
|
|
218
|
-
while (true) {
|
|
219
|
-
const { done, value } = await reader.read();
|
|
220
|
-
if (done) break;
|
|
221
|
-
parentPort!.postMessage({ id, type: 'chunk', chunk: value }, [value.buffer as ArrayBuffer]);
|
|
222
|
-
}
|
|
223
|
-
parentPort!.postMessage({ id, type: 'done' });
|
|
224
|
-
} else {
|
|
225
|
-
let html: string;
|
|
226
|
-
try {
|
|
227
|
-
(globalThis as any).__hadarsUnsuspend = unsuspend;
|
|
228
|
-
html = _renderToString!(ReactPage);
|
|
229
|
-
} finally {
|
|
230
|
-
(globalThis as any).__hadarsUnsuspend = null;
|
|
231
|
-
}
|
|
232
|
-
parentPort!.postMessage({ id, html, headHtml, status });
|
|
193
|
+
let html: string;
|
|
194
|
+
try {
|
|
195
|
+
(globalThis as any).__hadarsUnsuspend = unsuspend;
|
|
196
|
+
html = _renderToString!(ReactPage);
|
|
197
|
+
} finally {
|
|
198
|
+
(globalThis as any).__hadarsUnsuspend = null;
|
|
233
199
|
}
|
|
200
|
+
parentPort!.postMessage({ id, html, headHtml, status });
|
|
234
201
|
|
|
235
202
|
} catch (err: any) {
|
|
236
203
|
(globalThis as any).__hadarsUnsuspend = null;
|
|
237
|
-
|
|
238
|
-
if (streaming) {
|
|
239
|
-
parentPort!.postMessage({ id, type: 'error', error: errMsg });
|
|
240
|
-
} else {
|
|
241
|
-
parentPort!.postMessage({ id, error: errMsg });
|
|
242
|
-
}
|
|
204
|
+
parentPort!.postMessage({ id, error: err?.message ?? String(err) });
|
|
243
205
|
}
|
|
244
206
|
});
|
package/src/types/ninety.ts
CHANGED
|
@@ -84,15 +84,15 @@ export interface HadarsOptions {
|
|
|
84
84
|
*
|
|
85
85
|
* **Bun / Deno** — creates a `node:worker_threads` render pool of this size.
|
|
86
86
|
* Each thread handles the synchronous `renderToString` step, freeing the
|
|
87
|
-
* main event loop for I/O.
|
|
87
|
+
* main event loop for I/O.
|
|
88
88
|
*/
|
|
89
89
|
workers?: number;
|
|
90
90
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
91
|
+
* Override or extend rspack's `optimization` config for production client builds.
|
|
92
|
+
* Merged on top of hadars defaults (splitChunks vendor splitting, deterministic moduleIds).
|
|
93
|
+
* Has no effect on the SSR bundle or dev mode.
|
|
94
94
|
*/
|
|
95
|
-
|
|
95
|
+
optimization?: Record<string, unknown>;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
|
package/src/utils/rspack.ts
CHANGED
|
@@ -138,6 +138,8 @@ interface EntryOptions {
|
|
|
138
138
|
// optional compile-time defines (e.g. { 'process.env.NODE_ENV': '"development"' })
|
|
139
139
|
define?: Record<string, string>;
|
|
140
140
|
base?: string;
|
|
141
|
+
// optional rspack optimization overrides (production client builds only)
|
|
142
|
+
optimization?: Record<string, unknown>;
|
|
141
143
|
}
|
|
142
144
|
|
|
143
145
|
const buildCompilerConfig = (
|
|
@@ -258,6 +260,25 @@ const buildCompilerConfig = (
|
|
|
258
260
|
mainFields: isServerBuild ? ['main', 'module'] : ['browser', 'module', 'main'],
|
|
259
261
|
};
|
|
260
262
|
|
|
263
|
+
// Production client builds get vendor splitting and deterministic module IDs.
|
|
264
|
+
// User-supplied optimization is merged on top so it can extend or override defaults.
|
|
265
|
+
// Dev and SSR builds skip this — splitChunks slows HMR, SSR uses externals instead.
|
|
266
|
+
const optimization: any = (!isServerBuild && !isDev) ? {
|
|
267
|
+
moduleIds: 'deterministic',
|
|
268
|
+
splitChunks: {
|
|
269
|
+
chunks: 'all',
|
|
270
|
+
cacheGroups: {
|
|
271
|
+
react: {
|
|
272
|
+
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
|
|
273
|
+
name: 'vendor-react',
|
|
274
|
+
chunks: 'all' as const,
|
|
275
|
+
priority: 20,
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
...(opts.optimization ?? {}),
|
|
280
|
+
} : (opts.optimization ? { ...opts.optimization } : undefined);
|
|
281
|
+
|
|
261
282
|
return {
|
|
262
283
|
entry,
|
|
263
284
|
output: {
|
|
@@ -266,6 +287,7 @@ const buildCompilerConfig = (
|
|
|
266
287
|
},
|
|
267
288
|
mode: opts.mode,
|
|
268
289
|
externals,
|
|
290
|
+
...(optimization !== undefined ? { optimization } : {}),
|
|
269
291
|
plugins: [
|
|
270
292
|
new rspack.HtmlRspackPlugin({
|
|
271
293
|
publicPath: base || '/',
|