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 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
- | `streaming` | `boolean` | `false` | Set to `true` to use `renderToReadableStream` (streaming SSR) instead of the default `renderToString` |
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, type, html, error, chunk } = msg;
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
- if (p.kind === "renderString")
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
- /** Offload a full renderToString call. Returns the HTML string. */
840
- renderString(appProps, clientProps) {
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: "renderString", resolve: resolve2, reject });
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: "renderString", appProps, clientProps });
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, streaming, renderPool, renderPayload) {
890
- const renderToString = !streaming && !renderPool ? await getRenderToString() : null;
891
- const renderReadableStream = streaming && !renderPool ? await getReadableStreamRenderer() : null;
892
- const unsuspendForRender = renderPayload?.appProps?.context?._unsuspend ?? null;
893
- if (!streaming) {
894
- const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
895
- let bodyHtml;
896
- if (renderPool && renderPayload) {
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
- return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, options.streaming === true);
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
- filename: `index-${randomStr}.js`,
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
- return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, options.streaming === true, renderPool, renderPayload);
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. Only applies when `streaming` is `false` (the default).
86
+ * main event loop for I/O.
87
87
  */
88
88
  workers?: number;
89
89
  /**
90
- * Whether to use streaming SSR (`renderToReadableStream`) for rendering React
91
- * components. Defaults to `false`. Set to `true` to use streaming via
92
- * `renderToReadableStream` instead of the synchronous `renderToString`.
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
- streaming?: boolean;
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 _renderToReadableStream = null;
11
- var _Component = null;
10
+ var _ssrMod = null;
12
11
  async function init() {
13
- if (_React && _renderToStaticMarkup && _renderToString && _renderToReadableStream && _Component)
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 (!_renderToReadableStream) {
28
- const browserPath = pathToFileURL(req.resolve("react-dom/server.browser")).href;
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 buildReactPage(R, appProps, clientProps) {
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(_Component, appProps)
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
- const R = _React;
60
- const unsuspend = msg.appProps?.context?._unsuspend ?? null;
61
- if (type === "renderStream") {
62
- const { appProps: appProps2, clientProps: clientProps2 } = msg;
63
- const ReactPage2 = buildReactPage(R, appProps2, clientProps2);
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
- const stream = await _renderToReadableStream(ReactPage2);
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
- const { appProps, clientProps } = msg;
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
- const errMsg = err?.message ?? String(err);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hadars",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Minimal SSR framework for React — rspack, HMR, TypeScript, Bun/Node/Deno",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
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
- // 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
-
61
- type PendingRenderString = {
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 PendingRenderStream = {
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, type, html, error, chunk } = msg;
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
- if (p.kind === 'renderString') p.reject(err);
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
- /** Offload a full renderToString call. Returns the HTML string. */
160
- renderString(appProps: Record<string, unknown>, clientProps: Record<string, unknown>): Promise<string> {
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: 'renderString', resolve, reject });
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: 'renderString', appProps, clientProps });
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
- streaming: boolean,
219
- renderPool?: RenderWorkerPool,
220
- renderPayload?: { appProps: Record<string, unknown>; clientProps: Record<string, unknown> },
146
+ unsuspendForRender: any,
221
147
  ): Promise<Response> {
222
- // Resolve renderers before touching globalThis.__hadarsUnsuspend.
223
- // Any await after setting the global would create a window where a concurrent
224
- // request on the same thread could overwrite it before the render call runs.
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
- let bodyStream: ReadableStream<Uint8Array>;
259
- if (renderPool && renderPayload) {
260
- bodyStream = renderPool.renderStream(renderPayload.appProps, renderPayload.clientProps);
261
- } else {
262
- // React's renderToReadableStream starts rendering synchronously on call,
263
- // so hooks fire before the returned Promise settles. Set the global
264
- // immediately before the call and clear right after — no await in between.
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
- return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, options.streaming === true); // no pool in dev
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
- filename: `index-${randomStr}.js`,
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
- return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, options.streaming === true, renderPool, renderPayload);
687
+ const unsuspend = (renderPayload.appProps.context as any)?._unsuspend ?? null;
688
+ return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
776
689
  }, options.websocket);
777
690
  };
@@ -1,23 +1,15 @@
1
1
  /**
2
2
  * SSR render worker — runs in a node:worker_threads thread.
3
3
  *
4
- * Handles three message types sent by RenderWorkerPool in build.ts:
4
+ * Handles one message type sent by RenderWorkerPool in build.ts:
5
5
  *
6
- * { type: 'staticMarkup', id, props }
7
- * → renderToStaticMarkup(_Component, props)
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 component is only imported once per worker lifetime.
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
- let _renderToReadableStream: ((element: any, options?: any) => Promise<ReadableStream<Uint8Array>>) | null = null;
36
- let _Component: any = null;
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 && _renderToStaticMarkup && _renderToString && _renderToReadableStream && _Component) return;
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 (!_renderToReadableStream) {
57
- const browserPath = pathToFileURL(req.resolve('react-dom/server.browser')).href;
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
- if (!_Component) {
63
- const ssrMod = await import(pathToFileURL(ssrBundlePath).href);
64
- _Component = ssrMod.default;
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
- // Build the full ReactPage element tree — mirrors the shape in response.tsx / build.ts.
69
- // Uses React.createElement to avoid needing a JSX transform in the worker.
70
- function buildReactPage(R: any, appProps: Record<string, unknown>, clientProps: Record<string, unknown>) {
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(_Component, appProps),
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
- parentPort!.on('message', async (msg: any) => {
87
- const { id, type } = msg;
88
- try {
89
- await init();
90
- const R = _React;
91
-
92
- // Expose the resolved useServerData() cache to the SSR bundle via
93
- // globalThis.__hadarsUnsuspend same bridge used on the main thread.
94
- // Safe in a worker because rendering is sequential (no concurrent requests).
95
- const unsuspend = (msg.appProps?.context as any)?._unsuspend ?? null;
96
-
97
- // ── renderStream — streaming response via ReadableStream chunks ───────────
98
- if (type === 'renderStream') {
99
- const { appProps, clientProps } = msg as {
100
- appProps: Record<string, unknown>;
101
- clientProps: Record<string, unknown>;
102
- };
103
- const ReactPage = buildReactPage(R, appProps, clientProps);
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
- const stream = await _renderToReadableStream!(ReactPage);
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
- // ── renderString (type === 'renderString' or legacy messages) ────────────
119
- const { appProps, clientProps } = msg as {
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
- const html = _renderToString!(ReactPage);
155
+ _renderToStaticMarkup!(R.createElement(Component, { ...props, location: serialReq.location, context }));
156
+ } finally {
126
157
  (globalThis as any).__hadarsUnsuspend = null;
127
- parentPort!.postMessage({ id, html });
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
- const errMsg = err?.message ?? String(err);
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
  });
@@ -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. Only applies when `streaming` is `false` (the default).
87
+ * main event loop for I/O.
88
88
  */
89
89
  workers?: number;
90
90
  /**
91
- * Whether to use streaming SSR (`renderToReadableStream`) for rendering React
92
- * components. Defaults to `false`. Set to `true` to use streaming via
93
- * `renderToReadableStream` instead of the synchronous `renderToString`.
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
- streaming?: boolean;
95
+ optimization?: Record<string, unknown>;
96
96
  }
97
97
 
98
98
 
@@ -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 || '/',