hadars 0.1.7 → 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 +68 -111
- package/dist/index.d.ts +5 -5
- package/dist/ssr-render-worker.js +105 -41
- package/dist/ssr-watch.js +16 -0
- package/package.json +1 -1
- package/src/build.ts +67 -154
- package/src/ssr-render-worker.ts +138 -70
- 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,29 +775,16 @@ 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 === "renderStream") {
|
|
778
|
-
if (type === "chunk") {
|
|
779
|
-
p.controller.enqueue(chunk);
|
|
780
|
-
return;
|
|
781
|
-
}
|
|
782
|
-
this.pending.delete(id);
|
|
783
|
-
this.workerPending.get(w)?.delete(id);
|
|
784
|
-
if (type === "done")
|
|
785
|
-
p.controller.close();
|
|
786
|
-
else
|
|
787
|
-
p.controller.error(new Error(error ?? "Stream error"));
|
|
788
|
-
return;
|
|
789
|
-
}
|
|
790
782
|
this.pending.delete(id);
|
|
791
783
|
this.workerPending.get(w)?.delete(id);
|
|
792
784
|
if (error)
|
|
793
785
|
p.reject(new Error(error));
|
|
794
786
|
else
|
|
795
|
-
p.resolve(html);
|
|
787
|
+
p.resolve({ html, headHtml, status });
|
|
796
788
|
});
|
|
797
789
|
w.on("error", (err) => {
|
|
798
790
|
console.error("[hadars] Render worker error:", err);
|
|
@@ -820,10 +812,7 @@ var RenderWorkerPool = class {
|
|
|
820
812
|
const p = this.pending.get(id);
|
|
821
813
|
if (p) {
|
|
822
814
|
this.pending.delete(id);
|
|
823
|
-
|
|
824
|
-
p.reject(err);
|
|
825
|
-
else
|
|
826
|
-
p.controller.error(err);
|
|
815
|
+
p.reject(err);
|
|
827
816
|
}
|
|
828
817
|
}
|
|
829
818
|
this.workerPending.delete(w);
|
|
@@ -836,8 +825,8 @@ var RenderWorkerPool = class {
|
|
|
836
825
|
this.rrIndex++;
|
|
837
826
|
return w;
|
|
838
827
|
}
|
|
839
|
-
/**
|
|
840
|
-
|
|
828
|
+
/** Run the full SSR lifecycle in a worker thread. Returns html, headHtml, status. */
|
|
829
|
+
renderFull(req) {
|
|
841
830
|
return new Promise((resolve2, reject) => {
|
|
842
831
|
const w = this.nextWorker();
|
|
843
832
|
if (!w) {
|
|
@@ -845,10 +834,10 @@ var RenderWorkerPool = class {
|
|
|
845
834
|
return;
|
|
846
835
|
}
|
|
847
836
|
const id = this.nextId++;
|
|
848
|
-
this.pending.set(id, { kind: "
|
|
837
|
+
this.pending.set(id, { kind: "renderFull", resolve: resolve2, reject });
|
|
849
838
|
this.workerPending.get(w)?.add(id);
|
|
850
839
|
try {
|
|
851
|
-
w.postMessage({ id, type: "
|
|
840
|
+
w.postMessage({ id, type: "renderFull", streaming: false, request: req });
|
|
852
841
|
} catch (err) {
|
|
853
842
|
this.pending.delete(id);
|
|
854
843
|
this.workerPending.get(w)?.delete(id);
|
|
@@ -856,83 +845,25 @@ var RenderWorkerPool = class {
|
|
|
856
845
|
}
|
|
857
846
|
});
|
|
858
847
|
}
|
|
859
|
-
/** Offload a renderToReadableStream call. Returns a ReadableStream fed by
|
|
860
|
-
* worker chunk messages. */
|
|
861
|
-
renderStream(appProps, clientProps) {
|
|
862
|
-
let controller;
|
|
863
|
-
const stream = new ReadableStream({
|
|
864
|
-
start: (ctrl) => {
|
|
865
|
-
controller = ctrl;
|
|
866
|
-
}
|
|
867
|
-
});
|
|
868
|
-
const w = this.nextWorker();
|
|
869
|
-
if (!w) {
|
|
870
|
-
queueMicrotask(() => controller.error(new Error("[hadars] No render workers available")));
|
|
871
|
-
return stream;
|
|
872
|
-
}
|
|
873
|
-
const id = this.nextId++;
|
|
874
|
-
this.pending.set(id, { kind: "renderStream", controller });
|
|
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
|
-
}
|
|
883
|
-
return stream;
|
|
884
|
-
}
|
|
885
848
|
async terminate() {
|
|
886
849
|
await Promise.all(this.workers.map((w) => w.terminate()));
|
|
887
850
|
}
|
|
888
851
|
};
|
|
889
|
-
async function buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml,
|
|
890
|
-
const renderToString =
|
|
891
|
-
const
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
bodyHtml = await renderPool.renderString(renderPayload.appProps, renderPayload.clientProps);
|
|
898
|
-
} else {
|
|
852
|
+
async function buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspendForRender) {
|
|
853
|
+
const renderToString = await getRenderToString();
|
|
854
|
+
const responseStream = new ReadableStream({
|
|
855
|
+
async start(controller) {
|
|
856
|
+
const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
|
|
857
|
+
controller.enqueue(encoder.encode(precontentHtml));
|
|
858
|
+
await Promise.resolve();
|
|
859
|
+
let bodyHtml;
|
|
899
860
|
try {
|
|
900
861
|
globalThis.__hadarsUnsuspend = unsuspendForRender;
|
|
901
862
|
bodyHtml = renderToString(ReactPage);
|
|
902
863
|
} finally {
|
|
903
864
|
globalThis.__hadarsUnsuspend = null;
|
|
904
865
|
}
|
|
905
|
-
|
|
906
|
-
return new Response(precontentHtml + bodyHtml + postContent, {
|
|
907
|
-
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
908
|
-
status
|
|
909
|
-
});
|
|
910
|
-
}
|
|
911
|
-
const responseStream = new ReadableStream({
|
|
912
|
-
async start(controller) {
|
|
913
|
-
const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
|
|
914
|
-
controller.enqueue(encoder.encode(precontentHtml));
|
|
915
|
-
let bodyStream;
|
|
916
|
-
if (renderPool && renderPayload) {
|
|
917
|
-
bodyStream = renderPool.renderStream(renderPayload.appProps, renderPayload.clientProps);
|
|
918
|
-
} else {
|
|
919
|
-
let streamPromise;
|
|
920
|
-
try {
|
|
921
|
-
globalThis.__hadarsUnsuspend = unsuspendForRender;
|
|
922
|
-
streamPromise = renderReadableStream(ReactPage);
|
|
923
|
-
} finally {
|
|
924
|
-
globalThis.__hadarsUnsuspend = null;
|
|
925
|
-
}
|
|
926
|
-
bodyStream = await streamPromise;
|
|
927
|
-
}
|
|
928
|
-
const reader = bodyStream.getReader();
|
|
929
|
-
while (true) {
|
|
930
|
-
const { done, value } = await reader.read();
|
|
931
|
-
if (done)
|
|
932
|
-
break;
|
|
933
|
-
controller.enqueue(value);
|
|
934
|
-
}
|
|
935
|
-
controller.enqueue(encoder.encode(postContent));
|
|
866
|
+
controller.enqueue(encoder.encode(bodyHtml + postContent));
|
|
936
867
|
controller.close();
|
|
937
868
|
}
|
|
938
869
|
});
|
|
@@ -941,6 +872,24 @@ async function buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml,
|
|
|
941
872
|
status
|
|
942
873
|
});
|
|
943
874
|
}
|
|
875
|
+
async function serializeRequest(req) {
|
|
876
|
+
const isGetOrHead = ["GET", "HEAD"].includes(req.method ?? "GET");
|
|
877
|
+
const body = isGetOrHead ? null : new Uint8Array(await req.clone().arrayBuffer());
|
|
878
|
+
const headers = {};
|
|
879
|
+
req.headers.forEach((v, k) => {
|
|
880
|
+
headers[k] = v;
|
|
881
|
+
});
|
|
882
|
+
return {
|
|
883
|
+
url: req.url,
|
|
884
|
+
method: req.method ?? "GET",
|
|
885
|
+
headers,
|
|
886
|
+
body,
|
|
887
|
+
pathname: req.pathname,
|
|
888
|
+
search: req.search,
|
|
889
|
+
location: req.location,
|
|
890
|
+
cookies: req.cookies
|
|
891
|
+
};
|
|
892
|
+
}
|
|
944
893
|
var makePrecontentHtmlGetter = (htmlFilePromise) => {
|
|
945
894
|
let preHead = null;
|
|
946
895
|
let postHead = null;
|
|
@@ -1194,7 +1143,7 @@ var dev = async (options) => {
|
|
|
1194
1143
|
getAfterRenderProps,
|
|
1195
1144
|
getFinalProps
|
|
1196
1145
|
} = await import(importPath);
|
|
1197
|
-
const { ReactPage, status, headHtml } = await getReactResponse(request, {
|
|
1146
|
+
const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
|
|
1198
1147
|
document: {
|
|
1199
1148
|
body: Component,
|
|
1200
1149
|
lang: "en",
|
|
@@ -1203,7 +1152,8 @@ var dev = async (options) => {
|
|
|
1203
1152
|
getFinalProps
|
|
1204
1153
|
}
|
|
1205
1154
|
});
|
|
1206
|
-
|
|
1155
|
+
const unsuspend = renderPayload.appProps.context?._unsuspend ?? null;
|
|
1156
|
+
return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
|
|
1207
1157
|
}, options.websocket);
|
|
1208
1158
|
};
|
|
1209
1159
|
var build = async (options) => {
|
|
@@ -1220,19 +1170,20 @@ var build = async (options) => {
|
|
|
1220
1170
|
}
|
|
1221
1171
|
const tmpFilePath = pathMod3.join(os.tmpdir(), `hadars-client-${Date.now()}.tsx`);
|
|
1222
1172
|
await fs.writeFile(tmpFilePath, clientScript);
|
|
1223
|
-
const randomStr = crypto.randomBytes(6).toString("hex");
|
|
1224
1173
|
console.log("Building client and server bundles in parallel...");
|
|
1225
1174
|
await Promise.all([
|
|
1226
1175
|
compileEntry(tmpFilePath, {
|
|
1227
1176
|
target: "web",
|
|
1228
1177
|
output: {
|
|
1229
|
-
|
|
1178
|
+
// Content hash: filename is stable when code is unchanged → better browser/CDN cache.
|
|
1179
|
+
filename: "index.[contenthash:8].js",
|
|
1230
1180
|
path: pathMod3.resolve(__dirname2, StaticPath)
|
|
1231
1181
|
},
|
|
1232
1182
|
base: options.baseURL,
|
|
1233
1183
|
mode: "production",
|
|
1234
1184
|
swcPlugins: options.swcPlugins,
|
|
1235
|
-
define: options.define
|
|
1185
|
+
define: options.define,
|
|
1186
|
+
optimization: options.optimization
|
|
1236
1187
|
}),
|
|
1237
1188
|
compileEntry(pathMod3.resolve(__dirname2, options.entry), {
|
|
1238
1189
|
output: {
|
|
@@ -1250,10 +1201,6 @@ var build = async (options) => {
|
|
|
1250
1201
|
})
|
|
1251
1202
|
]);
|
|
1252
1203
|
await fs.rm(tmpFilePath);
|
|
1253
|
-
await fs.writeFile(
|
|
1254
|
-
pathMod3.join(__dirname2, HadarsFolder, "hadars.json"),
|
|
1255
|
-
JSON.stringify({ buildId: randomStr })
|
|
1256
|
-
);
|
|
1257
1204
|
console.log("Build complete.");
|
|
1258
1205
|
};
|
|
1259
1206
|
var run = async (options) => {
|
|
@@ -1332,6 +1279,15 @@ var run = async (options) => {
|
|
|
1332
1279
|
getAfterRenderProps,
|
|
1333
1280
|
getFinalProps
|
|
1334
1281
|
} = await import(componentPath);
|
|
1282
|
+
if (renderPool) {
|
|
1283
|
+
const serialReq = await serializeRequest(request);
|
|
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
|
+
});
|
|
1290
|
+
}
|
|
1335
1291
|
const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
|
|
1336
1292
|
document: {
|
|
1337
1293
|
body: Component,
|
|
@@ -1341,7 +1297,8 @@ var run = async (options) => {
|
|
|
1341
1297
|
getFinalProps
|
|
1342
1298
|
}
|
|
1343
1299
|
});
|
|
1344
|
-
|
|
1300
|
+
const unsuspend = renderPayload.appProps.context?._unsuspend ?? null;
|
|
1301
|
+
return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
|
|
1345
1302
|
}, options.websocket);
|
|
1346
1303
|
};
|
|
1347
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,10 +7,9 @@ var { ssrBundlePath } = workerData;
|
|
|
7
7
|
var _React = null;
|
|
8
8
|
var _renderToStaticMarkup = null;
|
|
9
9
|
var _renderToString = null;
|
|
10
|
-
var
|
|
11
|
-
var _Component = null;
|
|
10
|
+
var _ssrMod = null;
|
|
12
11
|
async function init() {
|
|
13
|
-
if (_React &&
|
|
12
|
+
if (_React && _ssrMod)
|
|
14
13
|
return;
|
|
15
14
|
const req = createRequire(pathMod.resolve(process.cwd(), "__ninety_fake__.js"));
|
|
16
15
|
if (!_React) {
|
|
@@ -24,24 +23,51 @@ async function init() {
|
|
|
24
23
|
_renderToString = serverMod.renderToString;
|
|
25
24
|
_renderToStaticMarkup = serverMod.renderToStaticMarkup;
|
|
26
25
|
}
|
|
27
|
-
if (!
|
|
28
|
-
|
|
29
|
-
const browserMod = await import(browserPath);
|
|
30
|
-
_renderToReadableStream = browserMod.renderToReadableStream;
|
|
31
|
-
}
|
|
32
|
-
if (!_Component) {
|
|
33
|
-
const ssrMod = await import(pathToFileURL(ssrBundlePath).href);
|
|
34
|
-
_Component = ssrMod.default;
|
|
26
|
+
if (!_ssrMod) {
|
|
27
|
+
_ssrMod = await import(pathToFileURL(ssrBundlePath).href);
|
|
35
28
|
}
|
|
36
29
|
}
|
|
37
|
-
function
|
|
30
|
+
function deserializeRequest(s) {
|
|
31
|
+
const init2 = { method: s.method, headers: new Headers(s.headers) };
|
|
32
|
+
if (s.body)
|
|
33
|
+
init2.body = s.body;
|
|
34
|
+
const req = new Request(s.url, init2);
|
|
35
|
+
Object.assign(req, {
|
|
36
|
+
pathname: s.pathname,
|
|
37
|
+
search: s.search,
|
|
38
|
+
location: s.location,
|
|
39
|
+
cookies: s.cookies
|
|
40
|
+
});
|
|
41
|
+
return req;
|
|
42
|
+
}
|
|
43
|
+
function buildHeadHtml(head) {
|
|
44
|
+
const R = _React;
|
|
45
|
+
const metaEntries = Object.entries(head.meta ?? {});
|
|
46
|
+
const linkEntries = Object.entries(head.link ?? {});
|
|
47
|
+
const styleEntries = Object.entries(head.style ?? {});
|
|
48
|
+
const scriptEntries = Object.entries(head.script ?? {});
|
|
49
|
+
return _renderToStaticMarkup(
|
|
50
|
+
R.createElement(
|
|
51
|
+
R.Fragment,
|
|
52
|
+
null,
|
|
53
|
+
R.createElement("title", null, head.title),
|
|
54
|
+
...metaEntries.map(([id, opts]) => R.createElement("meta", { key: id, id, ...opts })),
|
|
55
|
+
...linkEntries.map(([id, opts]) => R.createElement("link", { key: id, id, ...opts })),
|
|
56
|
+
...styleEntries.map(([id, opts]) => R.createElement("style", { key: id, id, ...opts })),
|
|
57
|
+
...scriptEntries.map(([id, opts]) => R.createElement("script", { key: id, id, ...opts }))
|
|
58
|
+
)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
function buildReactPage(appProps, clientProps) {
|
|
62
|
+
const R = _React;
|
|
63
|
+
const Component = _ssrMod.default;
|
|
38
64
|
return R.createElement(
|
|
39
65
|
R.Fragment,
|
|
40
66
|
null,
|
|
41
67
|
R.createElement(
|
|
42
68
|
"div",
|
|
43
69
|
{ id: "app" },
|
|
44
|
-
R.createElement(
|
|
70
|
+
R.createElement(Component, appProps)
|
|
45
71
|
),
|
|
46
72
|
R.createElement("script", {
|
|
47
73
|
id: "hadars",
|
|
@@ -52,41 +78,79 @@ function buildReactPage(R, appProps, clientProps) {
|
|
|
52
78
|
})
|
|
53
79
|
);
|
|
54
80
|
}
|
|
81
|
+
async function runFullLifecycle(serialReq) {
|
|
82
|
+
const R = _React;
|
|
83
|
+
const Component = _ssrMod.default;
|
|
84
|
+
const { getInitProps, getAfterRenderProps, getFinalProps } = _ssrMod;
|
|
85
|
+
const parsedReq = deserializeRequest(serialReq);
|
|
86
|
+
const unsuspend = { cache: /* @__PURE__ */ new Map(), hasPending: false };
|
|
87
|
+
const context = {
|
|
88
|
+
head: { title: "Hadars App", meta: {}, link: {}, style: {}, script: {}, status: 200 },
|
|
89
|
+
_unsuspend: unsuspend
|
|
90
|
+
};
|
|
91
|
+
let props = {
|
|
92
|
+
...getInitProps ? await getInitProps(parsedReq) : {},
|
|
93
|
+
location: serialReq.location,
|
|
94
|
+
context
|
|
95
|
+
};
|
|
96
|
+
let html = "";
|
|
97
|
+
let iters = 0;
|
|
98
|
+
do {
|
|
99
|
+
unsuspend.hasPending = false;
|
|
100
|
+
try {
|
|
101
|
+
globalThis.__hadarsUnsuspend = unsuspend;
|
|
102
|
+
html = _renderToStaticMarkup(R.createElement(Component, props));
|
|
103
|
+
} finally {
|
|
104
|
+
globalThis.__hadarsUnsuspend = null;
|
|
105
|
+
}
|
|
106
|
+
if (unsuspend.hasPending) {
|
|
107
|
+
const pending = [...unsuspend.cache.values()].filter((e) => e.status === "pending").map((e) => e.promise);
|
|
108
|
+
await Promise.all(pending);
|
|
109
|
+
}
|
|
110
|
+
} while (unsuspend.hasPending && ++iters < 25);
|
|
111
|
+
props = getAfterRenderProps ? await getAfterRenderProps(props, html) : props;
|
|
112
|
+
try {
|
|
113
|
+
globalThis.__hadarsUnsuspend = unsuspend;
|
|
114
|
+
_renderToStaticMarkup(R.createElement(Component, { ...props, location: serialReq.location, context }));
|
|
115
|
+
} finally {
|
|
116
|
+
globalThis.__hadarsUnsuspend = null;
|
|
117
|
+
}
|
|
118
|
+
const serverData = {};
|
|
119
|
+
for (const [k, v] of unsuspend.cache) {
|
|
120
|
+
if (v.status === "fulfilled")
|
|
121
|
+
serverData[k] = v.value;
|
|
122
|
+
if (v.status === "suspense-cached")
|
|
123
|
+
serverData[k] = v.value;
|
|
124
|
+
}
|
|
125
|
+
const { context: _ctx, ...restProps } = getFinalProps ? await getFinalProps(props) : props;
|
|
126
|
+
const clientProps = {
|
|
127
|
+
...restProps,
|
|
128
|
+
location: serialReq.location,
|
|
129
|
+
...Object.keys(serverData).length > 0 ? { __serverData: serverData } : {}
|
|
130
|
+
};
|
|
131
|
+
const headHtml = buildHeadHtml(context.head);
|
|
132
|
+
const status = context.head.status ?? 200;
|
|
133
|
+
const finalAppProps = { ...props, location: serialReq.location, context };
|
|
134
|
+
return { finalAppProps, clientProps, headHtml, status, unsuspend };
|
|
135
|
+
}
|
|
55
136
|
parentPort.on("message", async (msg) => {
|
|
56
|
-
const { id, type } = msg;
|
|
137
|
+
const { id, type, request } = msg;
|
|
57
138
|
try {
|
|
58
139
|
await init();
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
140
|
+
if (type !== "renderFull")
|
|
141
|
+
return;
|
|
142
|
+
const { finalAppProps, clientProps, headHtml, status, unsuspend } = await runFullLifecycle(request);
|
|
143
|
+
const ReactPage = buildReactPage(finalAppProps, clientProps);
|
|
144
|
+
let html;
|
|
145
|
+
try {
|
|
64
146
|
globalThis.__hadarsUnsuspend = unsuspend;
|
|
65
|
-
|
|
147
|
+
html = _renderToString(ReactPage);
|
|
148
|
+
} finally {
|
|
66
149
|
globalThis.__hadarsUnsuspend = null;
|
|
67
|
-
const reader = stream.getReader();
|
|
68
|
-
while (true) {
|
|
69
|
-
const { done, value } = await reader.read();
|
|
70
|
-
if (done)
|
|
71
|
-
break;
|
|
72
|
-
parentPort.postMessage({ id, type: "chunk", chunk: value }, [value.buffer]);
|
|
73
|
-
}
|
|
74
|
-
parentPort.postMessage({ id, type: "done" });
|
|
75
|
-
return;
|
|
76
150
|
}
|
|
77
|
-
|
|
78
|
-
const ReactPage = buildReactPage(R, appProps, clientProps);
|
|
79
|
-
globalThis.__hadarsUnsuspend = unsuspend;
|
|
80
|
-
const html = _renderToString(ReactPage);
|
|
81
|
-
globalThis.__hadarsUnsuspend = null;
|
|
82
|
-
parentPort.postMessage({ id, html });
|
|
151
|
+
parentPort.postMessage({ id, html, headHtml, status });
|
|
83
152
|
} catch (err) {
|
|
84
153
|
globalThis.__hadarsUnsuspend = null;
|
|
85
|
-
|
|
86
|
-
if (type === "renderStream") {
|
|
87
|
-
parentPort.postMessage({ id, type: "error", error: errMsg });
|
|
88
|
-
} else {
|
|
89
|
-
parentPort.postMessage({ id, error: errMsg });
|
|
90
|
-
}
|
|
154
|
+
parentPort.postMessage({ id, error: err?.message ?? String(err) });
|
|
91
155
|
}
|
|
92
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,21 +35,15 @@ 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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
kind: 'renderString';
|
|
63
|
-
resolve: (html: string) => void;
|
|
38
|
+
|
|
39
|
+
import type { SerializableRequest } from './ssr-render-worker';
|
|
40
|
+
|
|
41
|
+
type PendingRenderFull = {
|
|
42
|
+
kind: 'renderFull';
|
|
43
|
+
resolve: (result: { html: string; headHtml: string; status: number }) => void;
|
|
64
44
|
reject: (err: Error) => void;
|
|
65
45
|
};
|
|
66
|
-
type
|
|
67
|
-
kind: 'renderStream';
|
|
68
|
-
controller: ReadableStreamDefaultController<Uint8Array>;
|
|
69
|
-
};
|
|
70
|
-
type PendingEntry = PendingRenderString | PendingRenderStream;
|
|
46
|
+
type PendingEntry = PendingRenderFull;
|
|
71
47
|
|
|
72
48
|
class RenderWorkerPool {
|
|
73
49
|
private workers: any[] = [];
|
|
@@ -90,27 +66,13 @@ class RenderWorkerPool {
|
|
|
90
66
|
const w = new Worker(workerPath, { workerData: { ssrBundlePath } });
|
|
91
67
|
this.workerPending.set(w, new Set());
|
|
92
68
|
w.on('message', (msg: any) => {
|
|
93
|
-
const { id,
|
|
69
|
+
const { id, html, headHtml, status, error } = msg;
|
|
94
70
|
const p = this.pending.get(id);
|
|
95
71
|
if (!p) return;
|
|
96
|
-
|
|
97
|
-
if (p.kind === 'renderStream') {
|
|
98
|
-
if (type === 'chunk') {
|
|
99
|
-
p.controller.enqueue(chunk as Uint8Array);
|
|
100
|
-
return; // keep entry until 'done'
|
|
101
|
-
}
|
|
102
|
-
this.pending.delete(id);
|
|
103
|
-
this.workerPending.get(w)?.delete(id);
|
|
104
|
-
if (type === 'done') p.controller.close();
|
|
105
|
-
else p.controller.error(new Error(error ?? 'Stream error'));
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// renderString
|
|
110
72
|
this.pending.delete(id);
|
|
111
73
|
this.workerPending.get(w)?.delete(id);
|
|
112
74
|
if (error) p.reject(new Error(error));
|
|
113
|
-
else p.resolve(html);
|
|
75
|
+
else p.resolve({ html, headHtml, status });
|
|
114
76
|
});
|
|
115
77
|
w.on('error', (err: Error) => {
|
|
116
78
|
console.error('[hadars] Render worker error:', err);
|
|
@@ -130,19 +92,16 @@ class RenderWorkerPool {
|
|
|
130
92
|
}
|
|
131
93
|
|
|
132
94
|
private _handleWorkerDeath(w: any, err: Error) {
|
|
133
|
-
// Remove the dead worker from the rotation so it is never selected again.
|
|
134
95
|
const idx = this.workers.indexOf(w);
|
|
135
96
|
if (idx !== -1) this.workers.splice(idx, 1);
|
|
136
97
|
|
|
137
|
-
// Reject every in-flight request that was sent to this worker.
|
|
138
98
|
const ids = this.workerPending.get(w);
|
|
139
99
|
if (ids) {
|
|
140
100
|
for (const id of ids) {
|
|
141
101
|
const p = this.pending.get(id);
|
|
142
102
|
if (p) {
|
|
143
103
|
this.pending.delete(id);
|
|
144
|
-
|
|
145
|
-
else p.controller.error(err);
|
|
104
|
+
p.reject(err);
|
|
146
105
|
}
|
|
147
106
|
}
|
|
148
107
|
this.workerPending.delete(w);
|
|
@@ -156,19 +115,16 @@ class RenderWorkerPool {
|
|
|
156
115
|
return w;
|
|
157
116
|
}
|
|
158
117
|
|
|
159
|
-
/**
|
|
160
|
-
|
|
118
|
+
/** Run the full SSR lifecycle in a worker thread. Returns html, headHtml, status. */
|
|
119
|
+
renderFull(req: SerializableRequest): Promise<{ html: string; headHtml: string; status: number }> {
|
|
161
120
|
return new Promise((resolve, reject) => {
|
|
162
121
|
const w = this.nextWorker();
|
|
163
|
-
if (!w) {
|
|
164
|
-
reject(new Error('[hadars] No render workers available'));
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
122
|
+
if (!w) { reject(new Error('[hadars] No render workers available')); return; }
|
|
167
123
|
const id = this.nextId++;
|
|
168
|
-
this.pending.set(id, { kind: '
|
|
124
|
+
this.pending.set(id, { kind: 'renderFull', resolve, reject });
|
|
169
125
|
this.workerPending.get(w)?.add(id);
|
|
170
126
|
try {
|
|
171
|
-
w.postMessage({ id, type: '
|
|
127
|
+
w.postMessage({ id, type: 'renderFull', streaming: false, request: req });
|
|
172
128
|
} catch (err) {
|
|
173
129
|
this.pending.delete(id);
|
|
174
130
|
this.workerPending.get(w)?.delete(id);
|
|
@@ -177,34 +133,6 @@ class RenderWorkerPool {
|
|
|
177
133
|
});
|
|
178
134
|
}
|
|
179
135
|
|
|
180
|
-
/** Offload a renderToReadableStream call. Returns a ReadableStream fed by
|
|
181
|
-
* worker chunk messages. */
|
|
182
|
-
renderStream(appProps: Record<string, unknown>, clientProps: Record<string, unknown>): ReadableStream<Uint8Array> {
|
|
183
|
-
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
184
|
-
const stream = new ReadableStream<Uint8Array>({
|
|
185
|
-
start: (ctrl) => { controller = ctrl; },
|
|
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
|
-
}
|
|
193
|
-
const id = this.nextId++;
|
|
194
|
-
// Store controller before postMessage so the handler is ready when
|
|
195
|
-
// the first chunk arrives.
|
|
196
|
-
this.pending.set(id, { kind: 'renderStream', controller });
|
|
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
|
-
}
|
|
205
|
-
return stream;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
136
|
async terminate(): Promise<void> {
|
|
209
137
|
await Promise.all(this.workers.map((w: any) => w.terminate()));
|
|
210
138
|
}
|
|
@@ -215,70 +143,29 @@ async function buildSsrResponse(
|
|
|
215
143
|
headHtml: string,
|
|
216
144
|
status: number,
|
|
217
145
|
getPrecontentHtml: (headHtml: string) => Promise<[string, string]>,
|
|
218
|
-
|
|
219
|
-
renderPool?: RenderWorkerPool,
|
|
220
|
-
renderPayload?: { appProps: Record<string, unknown>; clientProps: Record<string, unknown> },
|
|
146
|
+
unsuspendForRender: any,
|
|
221
147
|
): Promise<Response> {
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
// Loading the renderer first ensures the set→call→clear sequence is synchronous.
|
|
226
|
-
const renderToString = (!streaming && !renderPool) ? await getRenderToString() : null;
|
|
227
|
-
const renderReadableStream = (streaming && !renderPool) ? await getReadableStreamRenderer() : null;
|
|
228
|
-
|
|
229
|
-
// Extract the unsuspend cache from renderPayload so non-pool renders can expose
|
|
230
|
-
// resolved useServerData() values to the SSR bundle via globalThis.__hadarsUnsuspend.
|
|
231
|
-
const unsuspendForRender = (renderPayload?.appProps?.context as any)?._unsuspend ?? null;
|
|
232
|
-
|
|
233
|
-
if (!streaming) {
|
|
234
|
-
const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
|
|
235
|
-
let bodyHtml: string;
|
|
236
|
-
if (renderPool && renderPayload) {
|
|
237
|
-
bodyHtml = await renderPool.renderString(renderPayload.appProps, renderPayload.clientProps);
|
|
238
|
-
} else {
|
|
239
|
-
// set → call (synchronous) → clear: no await in between, safe under concurrency
|
|
240
|
-
try {
|
|
241
|
-
(globalThis as any).__hadarsUnsuspend = unsuspendForRender;
|
|
242
|
-
bodyHtml = renderToString!(ReactPage);
|
|
243
|
-
} finally {
|
|
244
|
-
(globalThis as any).__hadarsUnsuspend = null;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
return new Response(precontentHtml + bodyHtml + postContent, {
|
|
248
|
-
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
249
|
-
status,
|
|
250
|
-
});
|
|
251
|
-
}
|
|
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();
|
|
252
151
|
|
|
253
152
|
const responseStream = new ReadableStream({
|
|
254
153
|
async start(controller) {
|
|
255
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.
|
|
256
157
|
controller.enqueue(encoder.encode(precontentHtml));
|
|
158
|
+
await Promise.resolve(); // yield to let the runtime flush the shell chunk
|
|
257
159
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
let streamPromise: Promise<ReadableStream<Uint8Array>>;
|
|
266
|
-
try {
|
|
267
|
-
(globalThis as any).__hadarsUnsuspend = unsuspendForRender;
|
|
268
|
-
streamPromise = renderReadableStream!(ReactPage);
|
|
269
|
-
} finally {
|
|
270
|
-
(globalThis as any).__hadarsUnsuspend = null;
|
|
271
|
-
}
|
|
272
|
-
bodyStream = await streamPromise;
|
|
273
|
-
}
|
|
274
|
-
const reader = bodyStream.getReader();
|
|
275
|
-
while (true) {
|
|
276
|
-
const { done, value } = await reader.read();
|
|
277
|
-
if (done) break;
|
|
278
|
-
controller.enqueue(value);
|
|
160
|
+
// set → call (synchronous) → clear: no await in between, safe under concurrency
|
|
161
|
+
let bodyHtml: string;
|
|
162
|
+
try {
|
|
163
|
+
(globalThis as any).__hadarsUnsuspend = unsuspendForRender;
|
|
164
|
+
bodyHtml = renderToString(ReactPage);
|
|
165
|
+
} finally {
|
|
166
|
+
(globalThis as any).__hadarsUnsuspend = null;
|
|
279
167
|
}
|
|
280
|
-
|
|
281
|
-
controller.enqueue(encoder.encode(postContent));
|
|
168
|
+
controller.enqueue(encoder.encode(bodyHtml + postContent));
|
|
282
169
|
controller.close();
|
|
283
170
|
},
|
|
284
171
|
});
|
|
@@ -289,6 +176,24 @@ async function buildSsrResponse(
|
|
|
289
176
|
});
|
|
290
177
|
}
|
|
291
178
|
|
|
179
|
+
/** Serialize a HadarsRequest into a structure-clonable object for postMessage. */
|
|
180
|
+
async function serializeRequest(req: any): Promise<SerializableRequest> {
|
|
181
|
+
const isGetOrHead = ['GET', 'HEAD'].includes(req.method ?? 'GET');
|
|
182
|
+
const body = isGetOrHead ? null : new Uint8Array(await req.clone().arrayBuffer());
|
|
183
|
+
const headers: Record<string, string> = {};
|
|
184
|
+
(req.headers as Headers).forEach((v: string, k: string) => { headers[k] = v; });
|
|
185
|
+
return {
|
|
186
|
+
url: req.url,
|
|
187
|
+
method: req.method ?? 'GET',
|
|
188
|
+
headers,
|
|
189
|
+
body,
|
|
190
|
+
pathname: req.pathname,
|
|
191
|
+
search: req.search,
|
|
192
|
+
location: req.location,
|
|
193
|
+
cookies: req.cookies,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
292
197
|
/**
|
|
293
198
|
* Returns a function that parses `out.html` into pre-head, post-head, and
|
|
294
199
|
* post-content segments and caches the result. Call the returned function with
|
|
@@ -593,7 +498,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
593
498
|
getFinalProps,
|
|
594
499
|
} = (await import(importPath)) as HadarsEntryModule<any>;
|
|
595
500
|
|
|
596
|
-
const { ReactPage, status, headHtml } = await getReactResponse(request, {
|
|
501
|
+
const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
|
|
597
502
|
document: {
|
|
598
503
|
body: Component as React.FC<HadarsProps<object>>,
|
|
599
504
|
lang: 'en',
|
|
@@ -603,7 +508,8 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
603
508
|
},
|
|
604
509
|
});
|
|
605
510
|
|
|
606
|
-
|
|
511
|
+
const unsuspend = (renderPayload.appProps.context as any)?._unsuspend ?? null;
|
|
512
|
+
return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
|
|
607
513
|
}, options.websocket);
|
|
608
514
|
};
|
|
609
515
|
|
|
@@ -628,8 +534,6 @@ export const build = async (options: HadarsRuntimeOptions) => {
|
|
|
628
534
|
const tmpFilePath = pathMod.join(os.tmpdir(), `hadars-client-${Date.now()}.tsx`);
|
|
629
535
|
await fs.writeFile(tmpFilePath, clientScript);
|
|
630
536
|
|
|
631
|
-
const randomStr = crypto.randomBytes(6).toString('hex');
|
|
632
|
-
|
|
633
537
|
// Compile client and SSR bundles in parallel — they write to different
|
|
634
538
|
// output directories and use different entry files, so they are fully
|
|
635
539
|
// independent and safe to run concurrently.
|
|
@@ -638,13 +542,15 @@ export const build = async (options: HadarsRuntimeOptions) => {
|
|
|
638
542
|
compileEntry(tmpFilePath, {
|
|
639
543
|
target: 'web',
|
|
640
544
|
output: {
|
|
641
|
-
|
|
545
|
+
// Content hash: filename is stable when code is unchanged → better browser/CDN cache.
|
|
546
|
+
filename: 'index.[contenthash:8].js',
|
|
642
547
|
path: pathMod.resolve(__dirname, StaticPath),
|
|
643
548
|
},
|
|
644
549
|
base: options.baseURL,
|
|
645
550
|
mode: 'production',
|
|
646
551
|
swcPlugins: options.swcPlugins,
|
|
647
552
|
define: options.define,
|
|
553
|
+
optimization: options.optimization,
|
|
648
554
|
}),
|
|
649
555
|
compileEntry(pathMod.resolve(__dirname, options.entry), {
|
|
650
556
|
output: {
|
|
@@ -662,11 +568,6 @@ export const build = async (options: HadarsRuntimeOptions) => {
|
|
|
662
568
|
}),
|
|
663
569
|
]);
|
|
664
570
|
await fs.rm(tmpFilePath);
|
|
665
|
-
|
|
666
|
-
await fs.writeFile(
|
|
667
|
-
pathMod.join(__dirname, HadarsFolder, 'hadars.json'),
|
|
668
|
-
JSON.stringify({ buildId: randomStr }),
|
|
669
|
-
);
|
|
670
571
|
console.log("Build complete.");
|
|
671
572
|
};
|
|
672
573
|
|
|
@@ -762,6 +663,17 @@ export const run = async (options: HadarsRuntimeOptions) => {
|
|
|
762
663
|
getFinalProps,
|
|
763
664
|
} = (await import(componentPath)) as HadarsEntryModule<any>;
|
|
764
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
|
+
|
|
765
677
|
const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
|
|
766
678
|
document: {
|
|
767
679
|
body: Component as React.FC<HadarsProps<object>>,
|
|
@@ -772,6 +684,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
|
|
|
772
684
|
},
|
|
773
685
|
});
|
|
774
686
|
|
|
775
|
-
|
|
687
|
+
const unsuspend = (renderPayload.appProps.context as any)?._unsuspend ?? null;
|
|
688
|
+
return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
|
|
776
689
|
}, options.websocket);
|
|
777
690
|
};
|
package/src/ssr-render-worker.ts
CHANGED
|
@@ -1,23 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SSR render worker — runs in a node:worker_threads thread.
|
|
3
3
|
*
|
|
4
|
-
* Handles
|
|
4
|
+
* Handles one message type sent by RenderWorkerPool in build.ts:
|
|
5
5
|
*
|
|
6
|
-
* { type: '
|
|
7
|
-
* →
|
|
8
|
-
* → postMessage({ id, type: 'staticMarkup', html, context: props.context })
|
|
9
|
-
*
|
|
10
|
-
* { type: 'renderString', id, appProps, clientProps }
|
|
6
|
+
* { type: 'renderFull', id, request: SerializableRequest }
|
|
7
|
+
* → runs full lifecycle (getInitProps → render loop → getAfterRenderProps → getFinalProps)
|
|
11
8
|
* → renderToString(ReactPage)
|
|
12
|
-
* → postMessage({ id, html })
|
|
13
|
-
*
|
|
14
|
-
* { type: 'renderStream', id, appProps, clientProps }
|
|
15
|
-
* → renderToReadableStream(ReactPage) — streams chunks back
|
|
16
|
-
* → postMessage({ id, type: 'chunk', chunk }) × N
|
|
17
|
-
* → postMessage({ id, type: 'done' })
|
|
9
|
+
* → postMessage({ id, html, headHtml, status })
|
|
18
10
|
*
|
|
19
11
|
* The SSR bundle path is passed once via workerData at thread creation time so
|
|
20
|
-
* the
|
|
12
|
+
* the SSR module is only imported once per worker lifetime.
|
|
21
13
|
*/
|
|
22
14
|
|
|
23
15
|
import { workerData, parentPort } from 'node:worker_threads';
|
|
@@ -32,11 +24,11 @@ const { ssrBundlePath } = workerData as { ssrBundlePath: string };
|
|
|
32
24
|
let _React: any = null;
|
|
33
25
|
let _renderToStaticMarkup: ((element: any) => string) | null = null;
|
|
34
26
|
let _renderToString: ((element: any) => string) | null = null;
|
|
35
|
-
|
|
36
|
-
let
|
|
27
|
+
// Full SSR module — includes default (App component) + lifecycle exports.
|
|
28
|
+
let _ssrMod: any = null;
|
|
37
29
|
|
|
38
30
|
async function init() {
|
|
39
|
-
if (_React &&
|
|
31
|
+
if (_React && _ssrMod) return;
|
|
40
32
|
|
|
41
33
|
const req = createRequire(pathMod.resolve(process.cwd(), '__ninety_fake__.js'));
|
|
42
34
|
|
|
@@ -53,25 +45,59 @@ async function init() {
|
|
|
53
45
|
_renderToStaticMarkup = serverMod.renderToStaticMarkup;
|
|
54
46
|
}
|
|
55
47
|
|
|
56
|
-
if (!
|
|
57
|
-
|
|
58
|
-
const browserMod = await import(browserPath);
|
|
59
|
-
_renderToReadableStream = browserMod.renderToReadableStream;
|
|
48
|
+
if (!_ssrMod) {
|
|
49
|
+
_ssrMod = await import(pathToFileURL(ssrBundlePath).href);
|
|
60
50
|
}
|
|
51
|
+
}
|
|
61
52
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
53
|
+
export type SerializableRequest = {
|
|
54
|
+
url: string;
|
|
55
|
+
method: string;
|
|
56
|
+
headers: Record<string, string>;
|
|
57
|
+
body: Uint8Array | null;
|
|
58
|
+
pathname: string;
|
|
59
|
+
search: string;
|
|
60
|
+
location: string;
|
|
61
|
+
cookies: Record<string, string>;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function deserializeRequest(s: SerializableRequest): any {
|
|
65
|
+
const init: RequestInit = { method: s.method, headers: new Headers(s.headers) };
|
|
66
|
+
if (s.body) init.body = s.body;
|
|
67
|
+
const req = new Request(s.url, init);
|
|
68
|
+
Object.assign(req, {
|
|
69
|
+
pathname: s.pathname,
|
|
70
|
+
search: s.search,
|
|
71
|
+
location: s.location,
|
|
72
|
+
cookies: s.cookies,
|
|
73
|
+
});
|
|
74
|
+
return req;
|
|
66
75
|
}
|
|
67
76
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
77
|
+
function buildHeadHtml(head: any): string {
|
|
78
|
+
const R = _React;
|
|
79
|
+
const metaEntries = Object.entries(head.meta ?? {});
|
|
80
|
+
const linkEntries = Object.entries(head.link ?? {});
|
|
81
|
+
const styleEntries = Object.entries(head.style ?? {});
|
|
82
|
+
const scriptEntries = Object.entries(head.script ?? {});
|
|
83
|
+
return _renderToStaticMarkup!(
|
|
84
|
+
R.createElement(R.Fragment, null,
|
|
85
|
+
R.createElement('title', null, head.title),
|
|
86
|
+
...metaEntries.map(([id, opts]) => R.createElement('meta', { key: id, id, ...(opts as any) })),
|
|
87
|
+
...linkEntries.map(([id, opts]) => R.createElement('link', { key: id, id, ...(opts as any) })),
|
|
88
|
+
...styleEntries.map(([id, opts]) => R.createElement('style', { key: id, id, ...(opts as any) })),
|
|
89
|
+
...scriptEntries.map(([id, opts]) => R.createElement('script', { key: id, id, ...(opts as any) })),
|
|
90
|
+
)
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildReactPage(appProps: any, clientProps: any) {
|
|
95
|
+
const R = _React;
|
|
96
|
+
const Component = _ssrMod.default;
|
|
71
97
|
return R.createElement(
|
|
72
98
|
R.Fragment, null,
|
|
73
99
|
R.createElement('div', { id: 'app' },
|
|
74
|
-
R.createElement(
|
|
100
|
+
R.createElement(Component, appProps),
|
|
75
101
|
),
|
|
76
102
|
R.createElement('script', {
|
|
77
103
|
id: 'hadars',
|
|
@@ -83,56 +109,98 @@ function buildReactPage(R: any, appProps: Record<string, unknown>, clientProps:
|
|
|
83
109
|
);
|
|
84
110
|
}
|
|
85
111
|
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
112
|
+
async function runFullLifecycle(serialReq: SerializableRequest) {
|
|
113
|
+
const R = _React;
|
|
114
|
+
const Component = _ssrMod.default;
|
|
115
|
+
const { getInitProps, getAfterRenderProps, getFinalProps } = _ssrMod;
|
|
116
|
+
|
|
117
|
+
const parsedReq = deserializeRequest(serialReq);
|
|
118
|
+
|
|
119
|
+
const unsuspend: any = { cache: new Map(), hasPending: false };
|
|
120
|
+
const context: any = {
|
|
121
|
+
head: { title: 'Hadars App', meta: {}, link: {}, style: {}, script: {}, status: 200 },
|
|
122
|
+
_unsuspend: unsuspend,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
let props: any = {
|
|
126
|
+
...(getInitProps ? await getInitProps(parsedReq) : {}),
|
|
127
|
+
location: serialReq.location,
|
|
128
|
+
context,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// useServerData render loop — same logic as getReactResponse on main thread.
|
|
132
|
+
let html = '';
|
|
133
|
+
let iters = 0;
|
|
134
|
+
do {
|
|
135
|
+
unsuspend.hasPending = false;
|
|
136
|
+
try {
|
|
104
137
|
(globalThis as any).__hadarsUnsuspend = unsuspend;
|
|
105
|
-
|
|
138
|
+
html = _renderToStaticMarkup!(R.createElement(Component, props));
|
|
139
|
+
} finally {
|
|
106
140
|
(globalThis as any).__hadarsUnsuspend = null;
|
|
107
|
-
const reader = stream.getReader();
|
|
108
|
-
while (true) {
|
|
109
|
-
const { done, value } = await reader.read();
|
|
110
|
-
if (done) break;
|
|
111
|
-
// Transfer the underlying ArrayBuffer to avoid a copy across threads.
|
|
112
|
-
parentPort!.postMessage({ id, type: 'chunk', chunk: value }, [value.buffer as ArrayBuffer]);
|
|
113
|
-
}
|
|
114
|
-
parentPort!.postMessage({ id, type: 'done' });
|
|
115
|
-
return;
|
|
116
141
|
}
|
|
142
|
+
if (unsuspend.hasPending) {
|
|
143
|
+
const pending = [...unsuspend.cache.values()]
|
|
144
|
+
.filter((e: any) => e.status === 'pending')
|
|
145
|
+
.map((e: any) => e.promise);
|
|
146
|
+
await Promise.all(pending);
|
|
147
|
+
}
|
|
148
|
+
} while (unsuspend.hasPending && ++iters < 25);
|
|
149
|
+
|
|
150
|
+
props = getAfterRenderProps ? await getAfterRenderProps(props, html) : props;
|
|
117
151
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
appProps: Record<string, unknown>;
|
|
121
|
-
clientProps: Record<string, unknown>;
|
|
122
|
-
};
|
|
123
|
-
const ReactPage = buildReactPage(R, appProps, clientProps);
|
|
152
|
+
// Re-render to capture head changes from getAfterRenderProps.
|
|
153
|
+
try {
|
|
124
154
|
(globalThis as any).__hadarsUnsuspend = unsuspend;
|
|
125
|
-
|
|
155
|
+
_renderToStaticMarkup!(R.createElement(Component, { ...props, location: serialReq.location, context }));
|
|
156
|
+
} finally {
|
|
126
157
|
(globalThis as any).__hadarsUnsuspend = null;
|
|
127
|
-
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Collect resolved useServerData values for client hydration.
|
|
161
|
+
const serverData: Record<string, unknown> = {};
|
|
162
|
+
for (const [k, v] of unsuspend.cache) {
|
|
163
|
+
if ((v as any).status === 'fulfilled') serverData[k] = (v as any).value;
|
|
164
|
+
if ((v as any).status === 'suspense-cached') serverData[k] = (v as any).value;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const { context: _ctx, ...restProps } = getFinalProps ? await getFinalProps(props) : props;
|
|
168
|
+
const clientProps = {
|
|
169
|
+
...restProps,
|
|
170
|
+
location: serialReq.location,
|
|
171
|
+
...(Object.keys(serverData).length > 0 ? { __serverData: serverData } : {}),
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const headHtml = buildHeadHtml(context.head);
|
|
175
|
+
const status: number = context.head.status ?? 200;
|
|
176
|
+
const finalAppProps = { ...props, location: serialReq.location, context };
|
|
177
|
+
|
|
178
|
+
return { finalAppProps, clientProps, headHtml, status, unsuspend };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
parentPort!.on('message', async (msg: any) => {
|
|
182
|
+
const { id, type, request } = msg;
|
|
183
|
+
try {
|
|
184
|
+
await init();
|
|
185
|
+
|
|
186
|
+
if (type !== 'renderFull') return;
|
|
187
|
+
|
|
188
|
+
const { finalAppProps, clientProps, headHtml, status, unsuspend } =
|
|
189
|
+
await runFullLifecycle(request as SerializableRequest);
|
|
190
|
+
|
|
191
|
+
const ReactPage = buildReactPage(finalAppProps, clientProps);
|
|
192
|
+
|
|
193
|
+
let html: string;
|
|
194
|
+
try {
|
|
195
|
+
(globalThis as any).__hadarsUnsuspend = unsuspend;
|
|
196
|
+
html = _renderToString!(ReactPage);
|
|
197
|
+
} finally {
|
|
198
|
+
(globalThis as any).__hadarsUnsuspend = null;
|
|
199
|
+
}
|
|
200
|
+
parentPort!.postMessage({ id, html, headHtml, status });
|
|
128
201
|
|
|
129
202
|
} catch (err: any) {
|
|
130
203
|
(globalThis as any).__hadarsUnsuspend = null;
|
|
131
|
-
|
|
132
|
-
if (type === 'renderStream') {
|
|
133
|
-
parentPort!.postMessage({ id, type: 'error', error: errMsg });
|
|
134
|
-
} else {
|
|
135
|
-
parentPort!.postMessage({ id, error: errMsg });
|
|
136
|
-
}
|
|
204
|
+
parentPort!.postMessage({ id, error: err?.message ?? String(err) });
|
|
137
205
|
}
|
|
138
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 || '/',
|