hadars 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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,32 +775,10 @@ var RenderWorkerPool = class {
770
775
  const w = new Worker(workerPath, { workerData: { ssrBundlePath } });
771
776
  this.workerPending.set(w, /* @__PURE__ */ new Set());
772
777
  w.on("message", (msg) => {
773
- const { id, type, html, headHtml, status, 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 === "renderFullStream") {
778
- if (type === "head") {
779
- p.headSettled = true;
780
- p.headResolve({ headHtml, status });
781
- return;
782
- }
783
- if (type === "chunk") {
784
- p.controller.enqueue(chunk);
785
- return;
786
- }
787
- this.pending.delete(id);
788
- this.workerPending.get(w)?.delete(id);
789
- if (type === "done")
790
- p.controller.close();
791
- else {
792
- const err = new Error(error ?? "Stream error");
793
- if (!p.headSettled)
794
- p.headReject(err);
795
- p.controller.error(err);
796
- }
797
- return;
798
- }
799
782
  this.pending.delete(id);
800
783
  this.workerPending.get(w)?.delete(id);
801
784
  if (error)
@@ -829,13 +812,7 @@ var RenderWorkerPool = class {
829
812
  const p = this.pending.get(id);
830
813
  if (p) {
831
814
  this.pending.delete(id);
832
- if (p.kind === "renderFull")
833
- p.reject(err);
834
- else {
835
- if (!p.headSettled)
836
- p.headReject(err);
837
- p.controller.error(err);
838
- }
815
+ p.reject(err);
839
816
  }
840
817
  }
841
818
  this.workerPending.delete(w);
@@ -868,82 +845,25 @@ var RenderWorkerPool = class {
868
845
  }
869
846
  });
870
847
  }
871
- /** Run the full SSR lifecycle in a worker thread, streaming the response. */
872
- renderFullStream(req) {
873
- let headResolve;
874
- let headReject;
875
- const head = new Promise((res, rej) => {
876
- headResolve = res;
877
- headReject = rej;
878
- });
879
- let controller;
880
- const stream = new ReadableStream({ start: (ctrl) => {
881
- controller = ctrl;
882
- } });
883
- const w = this.nextWorker();
884
- if (!w) {
885
- queueMicrotask(() => {
886
- headReject(new Error("[hadars] No render workers available"));
887
- controller.error(new Error("[hadars] No render workers available"));
888
- });
889
- return { head, stream };
890
- }
891
- const id = this.nextId++;
892
- this.pending.set(id, { kind: "renderFullStream", headSettled: false, headResolve, headReject, controller });
893
- this.workerPending.get(w)?.add(id);
894
- try {
895
- w.postMessage({ id, type: "renderFull", streaming: true, request: req });
896
- } catch (err) {
897
- this.pending.delete(id);
898
- this.workerPending.get(w)?.delete(id);
899
- queueMicrotask(() => {
900
- headReject(err);
901
- controller.error(err);
902
- });
903
- }
904
- return { head, stream };
905
- }
906
848
  async terminate() {
907
849
  await Promise.all(this.workers.map((w) => w.terminate()));
908
850
  }
909
851
  };
910
- async function buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, streaming, unsuspendForRender) {
911
- const renderToString = !streaming ? await getRenderToString() : null;
912
- const renderReadableStream = streaming ? await getReadableStreamRenderer() : null;
913
- if (!streaming) {
914
- const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
915
- let bodyHtml;
916
- try {
917
- globalThis.__hadarsUnsuspend = unsuspendForRender;
918
- bodyHtml = renderToString(ReactPage);
919
- } finally {
920
- globalThis.__hadarsUnsuspend = null;
921
- }
922
- return new Response(precontentHtml + bodyHtml + postContent, {
923
- headers: { "Content-Type": "text/html; charset=utf-8" },
924
- status
925
- });
926
- }
852
+ async function buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspendForRender) {
853
+ const renderToString = await getRenderToString();
927
854
  const responseStream = new ReadableStream({
928
855
  async start(controller) {
929
856
  const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
930
857
  controller.enqueue(encoder.encode(precontentHtml));
931
- let streamPromise;
858
+ await Promise.resolve();
859
+ let bodyHtml;
932
860
  try {
933
861
  globalThis.__hadarsUnsuspend = unsuspendForRender;
934
- streamPromise = renderReadableStream(ReactPage);
862
+ bodyHtml = renderToString(ReactPage);
935
863
  } finally {
936
864
  globalThis.__hadarsUnsuspend = null;
937
865
  }
938
- const bodyStream = await streamPromise;
939
- const reader = bodyStream.getReader();
940
- while (true) {
941
- const { done, value } = await reader.read();
942
- if (done)
943
- break;
944
- controller.enqueue(value);
945
- }
946
- controller.enqueue(encoder.encode(postContent));
866
+ controller.enqueue(encoder.encode(bodyHtml + postContent));
947
867
  controller.close();
948
868
  }
949
869
  });
@@ -1233,7 +1153,7 @@ var dev = async (options) => {
1233
1153
  }
1234
1154
  });
1235
1155
  const unsuspend = renderPayload.appProps.context?._unsuspend ?? null;
1236
- return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, options.streaming === true, unsuspend);
1156
+ return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
1237
1157
  }, options.websocket);
1238
1158
  };
1239
1159
  var build = async (options) => {
@@ -1250,19 +1170,20 @@ var build = async (options) => {
1250
1170
  }
1251
1171
  const tmpFilePath = pathMod3.join(os.tmpdir(), `hadars-client-${Date.now()}.tsx`);
1252
1172
  await fs.writeFile(tmpFilePath, clientScript);
1253
- const randomStr = crypto.randomBytes(6).toString("hex");
1254
1173
  console.log("Building client and server bundles in parallel...");
1255
1174
  await Promise.all([
1256
1175
  compileEntry(tmpFilePath, {
1257
1176
  target: "web",
1258
1177
  output: {
1259
- filename: `index-${randomStr}.js`,
1178
+ // Content hash: filename is stable when code is unchanged → better browser/CDN cache.
1179
+ filename: "index.[contenthash:8].js",
1260
1180
  path: pathMod3.resolve(__dirname2, StaticPath)
1261
1181
  },
1262
1182
  base: options.baseURL,
1263
1183
  mode: "production",
1264
1184
  swcPlugins: options.swcPlugins,
1265
- define: options.define
1185
+ define: options.define,
1186
+ optimization: options.optimization
1266
1187
  }),
1267
1188
  compileEntry(pathMod3.resolve(__dirname2, options.entry), {
1268
1189
  output: {
@@ -1280,10 +1201,6 @@ var build = async (options) => {
1280
1201
  })
1281
1202
  ]);
1282
1203
  await fs.rm(tmpFilePath);
1283
- await fs.writeFile(
1284
- pathMod3.join(__dirname2, HadarsFolder, "hadars.json"),
1285
- JSON.stringify({ buildId: randomStr })
1286
- );
1287
1204
  console.log("Build complete.");
1288
1205
  };
1289
1206
  var run = async (options) => {
@@ -1364,36 +1281,12 @@ var run = async (options) => {
1364
1281
  } = await import(componentPath);
1365
1282
  if (renderPool) {
1366
1283
  const serialReq = await serializeRequest(request);
1367
- if (options.streaming) {
1368
- const { head, stream } = renderPool.renderFullStream(serialReq);
1369
- const { headHtml: wHead, status: wStatus } = await head;
1370
- const [precontentHtml, postContent] = await getPrecontentHtml(wHead);
1371
- const responseStream = new ReadableStream({
1372
- async start(controller) {
1373
- controller.enqueue(encoder.encode(precontentHtml));
1374
- const reader = stream.getReader();
1375
- while (true) {
1376
- const { done, value } = await reader.read();
1377
- if (done)
1378
- break;
1379
- controller.enqueue(value);
1380
- }
1381
- controller.enqueue(encoder.encode(postContent));
1382
- controller.close();
1383
- }
1384
- });
1385
- return new Response(responseStream, {
1386
- headers: { "Content-Type": "text/html; charset=utf-8" },
1387
- status: wStatus
1388
- });
1389
- } else {
1390
- const { html, headHtml: wHead, status: wStatus } = await renderPool.renderFull(serialReq);
1391
- const [precontentHtml, postContent] = await getPrecontentHtml(wHead);
1392
- return new Response(precontentHtml + html + postContent, {
1393
- headers: { "Content-Type": "text/html; charset=utf-8" },
1394
- status: wStatus
1395
- });
1396
- }
1284
+ const { html, headHtml: wHead, status: wStatus } = await renderPool.renderFull(serialReq);
1285
+ const [precontentHtml, postContent] = await getPrecontentHtml(wHead);
1286
+ return new Response(precontentHtml + html + postContent, {
1287
+ headers: { "Content-Type": "text/html; charset=utf-8" },
1288
+ status: wStatus
1289
+ });
1397
1290
  }
1398
1291
  const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
1399
1292
  document: {
@@ -1405,7 +1298,7 @@ var run = async (options) => {
1405
1298
  }
1406
1299
  });
1407
1300
  const unsuspend = renderPayload.appProps.context?._unsuspend ?? null;
1408
- return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, options.streaming === true, unsuspend);
1301
+ return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
1409
1302
  }, options.websocket);
1410
1303
  };
1411
1304
 
package/dist/index.d.ts CHANGED
@@ -83,15 +83,15 @@ interface HadarsOptions {
83
83
  *
84
84
  * **Bun / Deno** — creates a `node:worker_threads` render pool of this size.
85
85
  * Each thread handles the synchronous `renderToString` step, freeing the
86
- * main event loop for I/O. 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,7 +7,6 @@ var { ssrBundlePath } = workerData;
7
7
  var _React = null;
8
8
  var _renderToStaticMarkup = null;
9
9
  var _renderToString = null;
10
- var _renderToReadableStream = null;
11
10
  var _ssrMod = null;
12
11
  async function init() {
13
12
  if (_React && _ssrMod)
@@ -24,11 +23,6 @@ async function init() {
24
23
  _renderToString = serverMod.renderToString;
25
24
  _renderToStaticMarkup = serverMod.renderToStaticMarkup;
26
25
  }
27
- if (!_renderToReadableStream) {
28
- const browserPath = pathToFileURL(req.resolve("react-dom/server.browser")).href;
29
- const browserMod = await import(browserPath);
30
- _renderToReadableStream = browserMod.renderToReadableStream;
31
- }
32
26
  if (!_ssrMod) {
33
27
  _ssrMod = await import(pathToFileURL(ssrBundlePath).href);
34
28
  }
@@ -140,48 +134,23 @@ async function runFullLifecycle(serialReq) {
140
134
  return { finalAppProps, clientProps, headHtml, status, unsuspend };
141
135
  }
142
136
  parentPort.on("message", async (msg) => {
143
- const { id, type, request, streaming } = msg;
137
+ const { id, type, request } = msg;
144
138
  try {
145
139
  await init();
146
140
  if (type !== "renderFull")
147
141
  return;
148
142
  const { finalAppProps, clientProps, headHtml, status, unsuspend } = await runFullLifecycle(request);
149
143
  const ReactPage = buildReactPage(finalAppProps, clientProps);
150
- if (streaming) {
151
- parentPort.postMessage({ id, type: "head", headHtml, status });
152
- let streamPromise;
153
- try {
154
- globalThis.__hadarsUnsuspend = unsuspend;
155
- streamPromise = _renderToReadableStream(ReactPage);
156
- } finally {
157
- globalThis.__hadarsUnsuspend = null;
158
- }
159
- const stream = await streamPromise;
160
- const reader = stream.getReader();
161
- while (true) {
162
- const { done, value } = await reader.read();
163
- if (done)
164
- break;
165
- parentPort.postMessage({ id, type: "chunk", chunk: value }, [value.buffer]);
166
- }
167
- parentPort.postMessage({ id, type: "done" });
168
- } else {
169
- let html;
170
- try {
171
- globalThis.__hadarsUnsuspend = unsuspend;
172
- html = _renderToString(ReactPage);
173
- } finally {
174
- globalThis.__hadarsUnsuspend = null;
175
- }
176
- parentPort.postMessage({ id, html, headHtml, status });
144
+ let html;
145
+ try {
146
+ globalThis.__hadarsUnsuspend = unsuspend;
147
+ html = _renderToString(ReactPage);
148
+ } finally {
149
+ globalThis.__hadarsUnsuspend = null;
177
150
  }
151
+ parentPort.postMessage({ id, html, headHtml, status });
178
152
  } catch (err) {
179
153
  globalThis.__hadarsUnsuspend = null;
180
- const errMsg = err?.message ?? String(err);
181
- if (streaming) {
182
- parentPort.postMessage({ id, type: "error", error: errMsg });
183
- } else {
184
- parentPort.postMessage({ id, error: errMsg });
185
- }
154
+ parentPort.postMessage({ id, error: err?.message ?? String(err) });
186
155
  }
187
156
  });
package/dist/ssr-watch.js CHANGED
@@ -211,6 +211,21 @@ var buildCompilerConfig = (entry2, opts, includeHotPlugin) => {
211
211
  // for server builds prefer the package "main"/"module" fields and avoid "browser" so we don't pick browser-specific entrypoints
212
212
  mainFields: isServerBuild ? ["main", "module"] : ["browser", "module", "main"]
213
213
  };
214
+ const optimization = !isServerBuild && !isDev ? {
215
+ moduleIds: "deterministic",
216
+ splitChunks: {
217
+ chunks: "all",
218
+ cacheGroups: {
219
+ react: {
220
+ test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
221
+ name: "vendor-react",
222
+ chunks: "all",
223
+ priority: 20
224
+ }
225
+ }
226
+ },
227
+ ...opts.optimization ?? {}
228
+ } : opts.optimization ? { ...opts.optimization } : void 0;
214
229
  return {
215
230
  entry: entry2,
216
231
  output: {
@@ -219,6 +234,7 @@ var buildCompilerConfig = (entry2, opts, includeHotPlugin) => {
219
234
  },
220
235
  mode: opts.mode,
221
236
  externals,
237
+ ...optimization !== void 0 ? { optimization } : {},
222
238
  plugins: [
223
239
  new rspack.HtmlRspackPlugin({
224
240
  publicPath: base2 || "/",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hadars",
3
- "version": "0.1.8",
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,10 +35,6 @@ async function getRenderToString(): Promise<(element: any) => string> {
53
35
 
54
36
  // Round-robin thread pool for SSR rendering — used on Bun/Deno where
55
37
  // node:cluster is not available but node:worker_threads is.
56
- // Supports three render modes matching the worker's message protocol:
57
- // staticMarkup — renderToStaticMarkup for lifecycle passes in getReactResponse
58
- // renderString — renderToString for non-streaming responses
59
- // renderStream — renderToReadableStream chunks forwarded as a ReadableStream
60
38
 
61
39
  import type { SerializableRequest } from './ssr-render-worker';
62
40
 
@@ -65,14 +43,7 @@ type PendingRenderFull = {
65
43
  resolve: (result: { html: string; headHtml: string; status: number }) => void;
66
44
  reject: (err: Error) => void;
67
45
  };
68
- type PendingRenderFullStream = {
69
- kind: 'renderFullStream';
70
- headSettled: boolean;
71
- headResolve: (result: { headHtml: string; status: number }) => void;
72
- headReject: (err: Error) => void;
73
- controller: ReadableStreamDefaultController<Uint8Array>;
74
- };
75
- type PendingEntry = PendingRenderFull | PendingRenderFullStream;
46
+ type PendingEntry = PendingRenderFull;
76
47
 
77
48
  class RenderWorkerPool {
78
49
  private workers: any[] = [];
@@ -95,32 +66,9 @@ class RenderWorkerPool {
95
66
  const w = new Worker(workerPath, { workerData: { ssrBundlePath } });
96
67
  this.workerPending.set(w, new Set());
97
68
  w.on('message', (msg: any) => {
98
- const { id, type, html, headHtml, status, error, chunk } = msg;
69
+ const { id, html, headHtml, status, error } = msg;
99
70
  const p = this.pending.get(id);
100
71
  if (!p) return;
101
-
102
- if (p.kind === 'renderFullStream') {
103
- if (type === 'head') {
104
- p.headSettled = true;
105
- p.headResolve({ headHtml, status });
106
- return;
107
- }
108
- if (type === 'chunk') {
109
- p.controller.enqueue(chunk as Uint8Array);
110
- return;
111
- }
112
- this.pending.delete(id);
113
- this.workerPending.get(w)?.delete(id);
114
- if (type === 'done') p.controller.close();
115
- else {
116
- const err = new Error(error ?? 'Stream error');
117
- if (!p.headSettled) p.headReject(err);
118
- p.controller.error(err);
119
- }
120
- return;
121
- }
122
-
123
- // renderFull (non-streaming)
124
72
  this.pending.delete(id);
125
73
  this.workerPending.get(w)?.delete(id);
126
74
  if (error) p.reject(new Error(error));
@@ -153,11 +101,7 @@ class RenderWorkerPool {
153
101
  const p = this.pending.get(id);
154
102
  if (p) {
155
103
  this.pending.delete(id);
156
- if (p.kind === 'renderFull') p.reject(err);
157
- else {
158
- if (!p.headSettled) p.headReject(err);
159
- p.controller.error(err);
160
- }
104
+ p.reject(err);
161
105
  }
162
106
  }
163
107
  this.workerPending.delete(w);
@@ -189,36 +133,6 @@ class RenderWorkerPool {
189
133
  });
190
134
  }
191
135
 
192
- /** Run the full SSR lifecycle in a worker thread, streaming the response. */
193
- renderFullStream(req: SerializableRequest): { head: Promise<{ headHtml: string; status: number }>; stream: ReadableStream<Uint8Array> } {
194
- let headResolve!: (r: { headHtml: string; status: number }) => void;
195
- let headReject!: (err: Error) => void;
196
- const head = new Promise<{ headHtml: string; status: number }>((res, rej) => {
197
- headResolve = res; headReject = rej;
198
- });
199
- let controller!: ReadableStreamDefaultController<Uint8Array>;
200
- const stream = new ReadableStream<Uint8Array>({ start: (ctrl) => { controller = ctrl; } });
201
- const w = this.nextWorker();
202
- if (!w) {
203
- queueMicrotask(() => {
204
- headReject(new Error('[hadars] No render workers available'));
205
- controller.error(new Error('[hadars] No render workers available'));
206
- });
207
- return { head, stream };
208
- }
209
- const id = this.nextId++;
210
- this.pending.set(id, { kind: 'renderFullStream', headSettled: false, headResolve, headReject, controller });
211
- this.workerPending.get(w)?.add(id);
212
- try {
213
- w.postMessage({ id, type: 'renderFull', streaming: true, request: req });
214
- } catch (err) {
215
- this.pending.delete(id);
216
- this.workerPending.get(w)?.delete(id);
217
- queueMicrotask(() => { headReject(err as Error); controller.error(err); });
218
- }
219
- return { head, stream };
220
- }
221
-
222
136
  async terminate(): Promise<void> {
223
137
  await Promise.all(this.workers.map((w: any) => w.terminate()));
224
138
  }
@@ -229,56 +143,29 @@ async function buildSsrResponse(
229
143
  headHtml: string,
230
144
  status: number,
231
145
  getPrecontentHtml: (headHtml: string) => Promise<[string, string]>,
232
- streaming: boolean,
233
146
  unsuspendForRender: any,
234
147
  ): Promise<Response> {
235
- // Resolve renderers before touching globalThis.__hadarsUnsuspend.
236
- // Any await after setting the global would create a window where a concurrent
237
- // request on the same thread could overwrite it before the render call runs.
238
- // Loading the renderer first ensures the set→call→clear sequence is synchronous.
239
- const renderToString = !streaming ? await getRenderToString() : null;
240
- const renderReadableStream = streaming ? await getReadableStreamRenderer() : null;
241
-
242
- if (!streaming) {
243
- const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
244
- let bodyHtml: string;
245
- // set → call (synchronous) → clear: no await in between, safe under concurrency
246
- try {
247
- (globalThis as any).__hadarsUnsuspend = unsuspendForRender;
248
- bodyHtml = renderToString!(ReactPage);
249
- } finally {
250
- (globalThis as any).__hadarsUnsuspend = null;
251
- }
252
- return new Response(precontentHtml + bodyHtml + postContent, {
253
- headers: { 'Content-Type': 'text/html; charset=utf-8' },
254
- status,
255
- });
256
- }
148
+ // Pre-load renderer before starting the stream so the set→call→clear
149
+ // sequence around __hadarsUnsuspend is fully synchronous (no await between them).
150
+ const renderToString = await getRenderToString();
257
151
 
258
152
  const responseStream = new ReadableStream({
259
153
  async start(controller) {
260
154
  const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
155
+ // Flush the shell (precontentHtml) immediately so the browser can
156
+ // start loading CSS/fonts before renderToString blocks the thread.
261
157
  controller.enqueue(encoder.encode(precontentHtml));
158
+ await Promise.resolve(); // yield to let the runtime flush the shell chunk
262
159
 
263
- // React's renderToReadableStream starts rendering synchronously on call,
264
- // so hooks fire before the returned Promise settles. Set the global
265
- // immediately before the call and clear right after — no await in between.
266
- let streamPromise: Promise<ReadableStream<Uint8Array>>;
160
+ // set call (synchronous) clear: no await in between, safe under concurrency
161
+ let bodyHtml: string;
267
162
  try {
268
163
  (globalThis as any).__hadarsUnsuspend = unsuspendForRender;
269
- streamPromise = renderReadableStream!(ReactPage);
164
+ bodyHtml = renderToString(ReactPage);
270
165
  } finally {
271
166
  (globalThis as any).__hadarsUnsuspend = null;
272
167
  }
273
- const bodyStream = await streamPromise;
274
- const reader = bodyStream.getReader();
275
- while (true) {
276
- const { done, value } = await reader.read();
277
- if (done) break;
278
- controller.enqueue(value);
279
- }
280
-
281
- controller.enqueue(encoder.encode(postContent));
168
+ controller.enqueue(encoder.encode(bodyHtml + postContent));
282
169
  controller.close();
283
170
  },
284
171
  });
@@ -622,7 +509,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
622
509
  });
623
510
 
624
511
  const unsuspend = (renderPayload.appProps.context as any)?._unsuspend ?? null;
625
- return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, options.streaming === true, unsuspend);
512
+ return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
626
513
  }, options.websocket);
627
514
  };
628
515
 
@@ -647,8 +534,6 @@ export const build = async (options: HadarsRuntimeOptions) => {
647
534
  const tmpFilePath = pathMod.join(os.tmpdir(), `hadars-client-${Date.now()}.tsx`);
648
535
  await fs.writeFile(tmpFilePath, clientScript);
649
536
 
650
- const randomStr = crypto.randomBytes(6).toString('hex');
651
-
652
537
  // Compile client and SSR bundles in parallel — they write to different
653
538
  // output directories and use different entry files, so they are fully
654
539
  // independent and safe to run concurrently.
@@ -657,13 +542,15 @@ export const build = async (options: HadarsRuntimeOptions) => {
657
542
  compileEntry(tmpFilePath, {
658
543
  target: 'web',
659
544
  output: {
660
- filename: `index-${randomStr}.js`,
545
+ // Content hash: filename is stable when code is unchanged → better browser/CDN cache.
546
+ filename: 'index.[contenthash:8].js',
661
547
  path: pathMod.resolve(__dirname, StaticPath),
662
548
  },
663
549
  base: options.baseURL,
664
550
  mode: 'production',
665
551
  swcPlugins: options.swcPlugins,
666
552
  define: options.define,
553
+ optimization: options.optimization,
667
554
  }),
668
555
  compileEntry(pathMod.resolve(__dirname, options.entry), {
669
556
  output: {
@@ -681,11 +568,6 @@ export const build = async (options: HadarsRuntimeOptions) => {
681
568
  }),
682
569
  ]);
683
570
  await fs.rm(tmpFilePath);
684
-
685
- await fs.writeFile(
686
- pathMod.join(__dirname, HadarsFolder, 'hadars.json'),
687
- JSON.stringify({ buildId: randomStr }),
688
- );
689
571
  console.log("Build complete.");
690
572
  };
691
573
 
@@ -784,35 +666,12 @@ export const run = async (options: HadarsRuntimeOptions) => {
784
666
  if (renderPool) {
785
667
  // Worker runs the full lifecycle — no non-serializable objects cross the thread boundary.
786
668
  const serialReq = await serializeRequest(request);
787
- if (options.streaming) {
788
- const { head, stream } = renderPool.renderFullStream(serialReq);
789
- const { headHtml: wHead, status: wStatus } = await head;
790
- const [precontentHtml, postContent] = await getPrecontentHtml(wHead);
791
- const responseStream = new ReadableStream({
792
- async start(controller) {
793
- controller.enqueue(encoder.encode(precontentHtml));
794
- const reader = stream.getReader();
795
- while (true) {
796
- const { done, value } = await reader.read();
797
- if (done) break;
798
- controller.enqueue(value);
799
- }
800
- controller.enqueue(encoder.encode(postContent));
801
- controller.close();
802
- },
803
- });
804
- return new Response(responseStream, {
805
- headers: { 'Content-Type': 'text/html; charset=utf-8' },
806
- status: wStatus,
807
- });
808
- } else {
809
- const { html, headHtml: wHead, status: wStatus } = await renderPool.renderFull(serialReq);
810
- const [precontentHtml, postContent] = await getPrecontentHtml(wHead);
811
- return new Response(precontentHtml + html + postContent, {
812
- headers: { 'Content-Type': 'text/html; charset=utf-8' },
813
- status: wStatus,
814
- });
815
- }
669
+ const { html, headHtml: wHead, status: wStatus } = await renderPool.renderFull(serialReq);
670
+ const [precontentHtml, postContent] = await getPrecontentHtml(wHead);
671
+ return new Response(precontentHtml + html + postContent, {
672
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
673
+ status: wStatus,
674
+ });
816
675
  }
817
676
 
818
677
  const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
@@ -826,6 +685,6 @@ export const run = async (options: HadarsRuntimeOptions) => {
826
685
  });
827
686
 
828
687
  const unsuspend = (renderPayload.appProps.context as any)?._unsuspend ?? null;
829
- return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, options.streaming === true, unsuspend);
688
+ return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
830
689
  }, options.websocket);
831
690
  };
@@ -3,18 +3,11 @@
3
3
  *
4
4
  * Handles one message type sent by RenderWorkerPool in build.ts:
5
5
  *
6
- * { type: 'renderFull', id, streaming: false, request: SerializableRequest }
6
+ * { type: 'renderFull', id, request: SerializableRequest }
7
7
  * → runs full lifecycle (getInitProps → render loop → getAfterRenderProps → getFinalProps)
8
8
  * → renderToString(ReactPage)
9
9
  * → postMessage({ id, html, headHtml, status })
10
10
  *
11
- * { type: 'renderFull', id, streaming: true, request: SerializableRequest }
12
- * → runs full lifecycle
13
- * → renderToReadableStream(ReactPage), streams chunks back
14
- * → postMessage({ id, type: 'head', headHtml, status })
15
- * → postMessage({ id, type: 'chunk', chunk }) × N
16
- * → postMessage({ id, type: 'done' })
17
- *
18
11
  * The SSR bundle path is passed once via workerData at thread creation time so
19
12
  * the SSR module is only imported once per worker lifetime.
20
13
  */
@@ -31,7 +24,6 @@ const { ssrBundlePath } = workerData as { ssrBundlePath: string };
31
24
  let _React: any = null;
32
25
  let _renderToStaticMarkup: ((element: any) => string) | null = null;
33
26
  let _renderToString: ((element: any) => string) | null = null;
34
- let _renderToReadableStream: ((element: any, options?: any) => Promise<ReadableStream<Uint8Array>>) | null = null;
35
27
  // Full SSR module — includes default (App component) + lifecycle exports.
36
28
  let _ssrMod: any = null;
37
29
 
@@ -53,12 +45,6 @@ async function init() {
53
45
  _renderToStaticMarkup = serverMod.renderToStaticMarkup;
54
46
  }
55
47
 
56
- if (!_renderToReadableStream) {
57
- const browserPath = pathToFileURL(req.resolve('react-dom/server.browser')).href;
58
- const browserMod = await import(browserPath);
59
- _renderToReadableStream = browserMod.renderToReadableStream;
60
- }
61
-
62
48
  if (!_ssrMod) {
63
49
  _ssrMod = await import(pathToFileURL(ssrBundlePath).href);
64
50
  }
@@ -193,7 +179,7 @@ async function runFullLifecycle(serialReq: SerializableRequest) {
193
179
  }
194
180
 
195
181
  parentPort!.on('message', async (msg: any) => {
196
- const { id, type, request, streaming } = msg;
182
+ const { id, type, request } = msg;
197
183
  try {
198
184
  await init();
199
185
 
@@ -204,41 +190,17 @@ parentPort!.on('message', async (msg: any) => {
204
190
 
205
191
  const ReactPage = buildReactPage(finalAppProps, clientProps);
206
192
 
207
- if (streaming) {
208
- parentPort!.postMessage({ id, type: 'head', headHtml, status });
209
- let streamPromise: Promise<ReadableStream<Uint8Array>>;
210
- try {
211
- (globalThis as any).__hadarsUnsuspend = unsuspend;
212
- streamPromise = _renderToReadableStream!(ReactPage);
213
- } finally {
214
- (globalThis as any).__hadarsUnsuspend = null;
215
- }
216
- const stream = await streamPromise;
217
- const reader = stream.getReader();
218
- while (true) {
219
- const { done, value } = await reader.read();
220
- if (done) break;
221
- parentPort!.postMessage({ id, type: 'chunk', chunk: value }, [value.buffer as ArrayBuffer]);
222
- }
223
- parentPort!.postMessage({ id, type: 'done' });
224
- } else {
225
- let html: string;
226
- try {
227
- (globalThis as any).__hadarsUnsuspend = unsuspend;
228
- html = _renderToString!(ReactPage);
229
- } finally {
230
- (globalThis as any).__hadarsUnsuspend = null;
231
- }
232
- parentPort!.postMessage({ id, html, headHtml, status });
193
+ let html: string;
194
+ try {
195
+ (globalThis as any).__hadarsUnsuspend = unsuspend;
196
+ html = _renderToString!(ReactPage);
197
+ } finally {
198
+ (globalThis as any).__hadarsUnsuspend = null;
233
199
  }
200
+ parentPort!.postMessage({ id, html, headHtml, status });
234
201
 
235
202
  } catch (err: any) {
236
203
  (globalThis as any).__hadarsUnsuspend = null;
237
- const errMsg = err?.message ?? String(err);
238
- if (streaming) {
239
- parentPort!.postMessage({ id, type: 'error', error: errMsg });
240
- } else {
241
- parentPort!.postMessage({ id, error: errMsg });
242
- }
204
+ parentPort!.postMessage({ id, error: err?.message ?? String(err) });
243
205
  }
244
206
  });
@@ -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 || '/',