hadars 0.1.6 → 0.1.8

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/dist/cli.js CHANGED
@@ -770,11 +770,16 @@ var RenderWorkerPool = class {
770
770
  const w = new Worker(workerPath, { workerData: { ssrBundlePath } });
771
771
  this.workerPending.set(w, /* @__PURE__ */ new Set());
772
772
  w.on("message", (msg) => {
773
- const { id, type, html, error, chunk } = msg;
773
+ const { id, type, html, headHtml, status, error, chunk } = msg;
774
774
  const p = this.pending.get(id);
775
775
  if (!p)
776
776
  return;
777
- if (p.kind === "renderStream") {
777
+ if (p.kind === "renderFullStream") {
778
+ if (type === "head") {
779
+ p.headSettled = true;
780
+ p.headResolve({ headHtml, status });
781
+ return;
782
+ }
778
783
  if (type === "chunk") {
779
784
  p.controller.enqueue(chunk);
780
785
  return;
@@ -783,8 +788,12 @@ var RenderWorkerPool = class {
783
788
  this.workerPending.get(w)?.delete(id);
784
789
  if (type === "done")
785
790
  p.controller.close();
786
- else
787
- p.controller.error(new Error(error ?? "Stream error"));
791
+ else {
792
+ const err = new Error(error ?? "Stream error");
793
+ if (!p.headSettled)
794
+ p.headReject(err);
795
+ p.controller.error(err);
796
+ }
788
797
  return;
789
798
  }
790
799
  this.pending.delete(id);
@@ -792,7 +801,7 @@ var RenderWorkerPool = class {
792
801
  if (error)
793
802
  p.reject(new Error(error));
794
803
  else
795
- p.resolve(html);
804
+ p.resolve({ html, headHtml, status });
796
805
  });
797
806
  w.on("error", (err) => {
798
807
  console.error("[hadars] Render worker error:", err);
@@ -820,10 +829,13 @@ var RenderWorkerPool = class {
820
829
  const p = this.pending.get(id);
821
830
  if (p) {
822
831
  this.pending.delete(id);
823
- if (p.kind === "renderString")
832
+ if (p.kind === "renderFull")
824
833
  p.reject(err);
825
- else
834
+ else {
835
+ if (!p.headSettled)
836
+ p.headReject(err);
826
837
  p.controller.error(err);
838
+ }
827
839
  }
828
840
  }
829
841
  this.workerPending.delete(w);
@@ -836,8 +848,8 @@ var RenderWorkerPool = class {
836
848
  this.rrIndex++;
837
849
  return w;
838
850
  }
839
- /** Offload a full renderToString call. Returns the HTML string. */
840
- renderString(appProps, clientProps) {
851
+ /** Run the full SSR lifecycle in a worker thread. Returns html, headHtml, status. */
852
+ renderFull(req) {
841
853
  return new Promise((resolve2, reject) => {
842
854
  const w = this.nextWorker();
843
855
  if (!w) {
@@ -845,10 +857,10 @@ var RenderWorkerPool = class {
845
857
  return;
846
858
  }
847
859
  const id = this.nextId++;
848
- this.pending.set(id, { kind: "renderString", resolve: resolve2, reject });
860
+ this.pending.set(id, { kind: "renderFull", resolve: resolve2, reject });
849
861
  this.workerPending.get(w)?.add(id);
850
862
  try {
851
- w.postMessage({ id, type: "renderString", appProps, clientProps });
863
+ w.postMessage({ id, type: "renderFull", streaming: false, request: req });
852
864
  } catch (err) {
853
865
  this.pending.delete(id);
854
866
  this.workerPending.get(w)?.delete(id);
@@ -856,52 +868,56 @@ var RenderWorkerPool = class {
856
868
  }
857
869
  });
858
870
  }
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
- }
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;
867
878
  });
879
+ let controller;
880
+ const stream = new ReadableStream({ start: (ctrl) => {
881
+ controller = ctrl;
882
+ } });
868
883
  const w = this.nextWorker();
869
884
  if (!w) {
870
- queueMicrotask(() => controller.error(new Error("[hadars] No render workers available")));
871
- return stream;
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 };
872
890
  }
873
891
  const id = this.nextId++;
874
- this.pending.set(id, { kind: "renderStream", controller });
892
+ this.pending.set(id, { kind: "renderFullStream", headSettled: false, headResolve, headReject, controller });
875
893
  this.workerPending.get(w)?.add(id);
876
894
  try {
877
- w.postMessage({ id, type: "renderStream", appProps, clientProps });
895
+ w.postMessage({ id, type: "renderFull", streaming: true, request: req });
878
896
  } catch (err) {
879
897
  this.pending.delete(id);
880
898
  this.workerPending.get(w)?.delete(id);
881
- queueMicrotask(() => controller.error(err));
899
+ queueMicrotask(() => {
900
+ headReject(err);
901
+ controller.error(err);
902
+ });
882
903
  }
883
- return stream;
904
+ return { head, stream };
884
905
  }
885
906
  async terminate() {
886
907
  await Promise.all(this.workers.map((w) => w.terminate()));
887
908
  }
888
909
  };
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;
910
+ async function buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, streaming, unsuspendForRender) {
911
+ const renderToString = !streaming ? await getRenderToString() : null;
912
+ const renderReadableStream = streaming ? await getReadableStreamRenderer() : null;
893
913
  if (!streaming) {
894
914
  const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
895
915
  let bodyHtml;
896
- if (renderPool && renderPayload) {
897
- bodyHtml = await renderPool.renderString(renderPayload.appProps, renderPayload.clientProps);
898
- } else {
899
- try {
900
- globalThis.__hadarsUnsuspend = unsuspendForRender;
901
- bodyHtml = renderToString(ReactPage);
902
- } finally {
903
- globalThis.__hadarsUnsuspend = null;
904
- }
916
+ try {
917
+ globalThis.__hadarsUnsuspend = unsuspendForRender;
918
+ bodyHtml = renderToString(ReactPage);
919
+ } finally {
920
+ globalThis.__hadarsUnsuspend = null;
905
921
  }
906
922
  return new Response(precontentHtml + bodyHtml + postContent, {
907
923
  headers: { "Content-Type": "text/html; charset=utf-8" },
@@ -912,19 +928,14 @@ async function buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml,
912
928
  async start(controller) {
913
929
  const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
914
930
  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;
931
+ let streamPromise;
932
+ try {
933
+ globalThis.__hadarsUnsuspend = unsuspendForRender;
934
+ streamPromise = renderReadableStream(ReactPage);
935
+ } finally {
936
+ globalThis.__hadarsUnsuspend = null;
927
937
  }
938
+ const bodyStream = await streamPromise;
928
939
  const reader = bodyStream.getReader();
929
940
  while (true) {
930
941
  const { done, value } = await reader.read();
@@ -941,6 +952,24 @@ async function buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml,
941
952
  status
942
953
  });
943
954
  }
955
+ async function serializeRequest(req) {
956
+ const isGetOrHead = ["GET", "HEAD"].includes(req.method ?? "GET");
957
+ const body = isGetOrHead ? null : new Uint8Array(await req.clone().arrayBuffer());
958
+ const headers = {};
959
+ req.headers.forEach((v, k) => {
960
+ headers[k] = v;
961
+ });
962
+ return {
963
+ url: req.url,
964
+ method: req.method ?? "GET",
965
+ headers,
966
+ body,
967
+ pathname: req.pathname,
968
+ search: req.search,
969
+ location: req.location,
970
+ cookies: req.cookies
971
+ };
972
+ }
944
973
  var makePrecontentHtmlGetter = (htmlFilePromise) => {
945
974
  let preHead = null;
946
975
  let postHead = null;
@@ -1194,7 +1223,7 @@ var dev = async (options) => {
1194
1223
  getAfterRenderProps,
1195
1224
  getFinalProps
1196
1225
  } = await import(importPath);
1197
- const { ReactPage, status, headHtml } = await getReactResponse(request, {
1226
+ const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
1198
1227
  document: {
1199
1228
  body: Component,
1200
1229
  lang: "en",
@@ -1203,7 +1232,8 @@ var dev = async (options) => {
1203
1232
  getFinalProps
1204
1233
  }
1205
1234
  });
1206
- return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, options.streaming === true);
1235
+ const unsuspend = renderPayload.appProps.context?._unsuspend ?? null;
1236
+ return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, options.streaming === true, unsuspend);
1207
1237
  }, options.websocket);
1208
1238
  };
1209
1239
  var build = async (options) => {
@@ -1332,6 +1362,39 @@ var run = async (options) => {
1332
1362
  getAfterRenderProps,
1333
1363
  getFinalProps
1334
1364
  } = await import(componentPath);
1365
+ if (renderPool) {
1366
+ 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
+ }
1397
+ }
1335
1398
  const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
1336
1399
  document: {
1337
1400
  body: Component,
@@ -1341,7 +1404,8 @@ var run = async (options) => {
1341
1404
  getFinalProps
1342
1405
  }
1343
1406
  });
1344
- return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, options.streaming === true, renderPool, renderPayload);
1407
+ const unsuspend = renderPayload.appProps.context?._unsuspend ?? null;
1408
+ return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, options.streaming === true, unsuspend);
1345
1409
  }, options.websocket);
1346
1410
  };
1347
1411
 
@@ -8,9 +8,9 @@ var _React = null;
8
8
  var _renderToStaticMarkup = null;
9
9
  var _renderToString = null;
10
10
  var _renderToReadableStream = null;
11
- var _Component = null;
11
+ var _ssrMod = null;
12
12
  async function init() {
13
- if (_React && _renderToStaticMarkup && _renderToString && _renderToReadableStream && _Component)
13
+ if (_React && _ssrMod)
14
14
  return;
15
15
  const req = createRequire(pathMod.resolve(process.cwd(), "__ninety_fake__.js"));
16
16
  if (!_React) {
@@ -29,19 +29,51 @@ async function init() {
29
29
  const browserMod = await import(browserPath);
30
30
  _renderToReadableStream = browserMod.renderToReadableStream;
31
31
  }
32
- if (!_Component) {
33
- const ssrMod = await import(pathToFileURL(ssrBundlePath).href);
34
- _Component = ssrMod.default;
32
+ if (!_ssrMod) {
33
+ _ssrMod = await import(pathToFileURL(ssrBundlePath).href);
35
34
  }
36
35
  }
37
- function buildReactPage(R, appProps, clientProps) {
36
+ function deserializeRequest(s) {
37
+ const init2 = { method: s.method, headers: new Headers(s.headers) };
38
+ if (s.body)
39
+ init2.body = s.body;
40
+ const req = new Request(s.url, init2);
41
+ Object.assign(req, {
42
+ pathname: s.pathname,
43
+ search: s.search,
44
+ location: s.location,
45
+ cookies: s.cookies
46
+ });
47
+ return req;
48
+ }
49
+ function buildHeadHtml(head) {
50
+ const R = _React;
51
+ const metaEntries = Object.entries(head.meta ?? {});
52
+ const linkEntries = Object.entries(head.link ?? {});
53
+ const styleEntries = Object.entries(head.style ?? {});
54
+ const scriptEntries = Object.entries(head.script ?? {});
55
+ return _renderToStaticMarkup(
56
+ R.createElement(
57
+ R.Fragment,
58
+ null,
59
+ R.createElement("title", null, head.title),
60
+ ...metaEntries.map(([id, opts]) => R.createElement("meta", { key: id, id, ...opts })),
61
+ ...linkEntries.map(([id, opts]) => R.createElement("link", { key: id, id, ...opts })),
62
+ ...styleEntries.map(([id, opts]) => R.createElement("style", { key: id, id, ...opts })),
63
+ ...scriptEntries.map(([id, opts]) => R.createElement("script", { key: id, id, ...opts }))
64
+ )
65
+ );
66
+ }
67
+ function buildReactPage(appProps, clientProps) {
68
+ const R = _React;
69
+ const Component = _ssrMod.default;
38
70
  return R.createElement(
39
71
  R.Fragment,
40
72
  null,
41
73
  R.createElement(
42
74
  "div",
43
75
  { id: "app" },
44
- R.createElement(_Component, appProps)
76
+ R.createElement(Component, appProps)
45
77
  ),
46
78
  R.createElement("script", {
47
79
  id: "hadars",
@@ -52,18 +84,79 @@ function buildReactPage(R, appProps, clientProps) {
52
84
  })
53
85
  );
54
86
  }
87
+ async function runFullLifecycle(serialReq) {
88
+ const R = _React;
89
+ const Component = _ssrMod.default;
90
+ const { getInitProps, getAfterRenderProps, getFinalProps } = _ssrMod;
91
+ const parsedReq = deserializeRequest(serialReq);
92
+ const unsuspend = { cache: /* @__PURE__ */ new Map(), hasPending: false };
93
+ const context = {
94
+ head: { title: "Hadars App", meta: {}, link: {}, style: {}, script: {}, status: 200 },
95
+ _unsuspend: unsuspend
96
+ };
97
+ let props = {
98
+ ...getInitProps ? await getInitProps(parsedReq) : {},
99
+ location: serialReq.location,
100
+ context
101
+ };
102
+ let html = "";
103
+ let iters = 0;
104
+ do {
105
+ unsuspend.hasPending = false;
106
+ try {
107
+ globalThis.__hadarsUnsuspend = unsuspend;
108
+ html = _renderToStaticMarkup(R.createElement(Component, props));
109
+ } finally {
110
+ globalThis.__hadarsUnsuspend = null;
111
+ }
112
+ if (unsuspend.hasPending) {
113
+ const pending = [...unsuspend.cache.values()].filter((e) => e.status === "pending").map((e) => e.promise);
114
+ await Promise.all(pending);
115
+ }
116
+ } while (unsuspend.hasPending && ++iters < 25);
117
+ props = getAfterRenderProps ? await getAfterRenderProps(props, html) : props;
118
+ try {
119
+ globalThis.__hadarsUnsuspend = unsuspend;
120
+ _renderToStaticMarkup(R.createElement(Component, { ...props, location: serialReq.location, context }));
121
+ } finally {
122
+ globalThis.__hadarsUnsuspend = null;
123
+ }
124
+ const serverData = {};
125
+ for (const [k, v] of unsuspend.cache) {
126
+ if (v.status === "fulfilled")
127
+ serverData[k] = v.value;
128
+ if (v.status === "suspense-cached")
129
+ serverData[k] = v.value;
130
+ }
131
+ const { context: _ctx, ...restProps } = getFinalProps ? await getFinalProps(props) : props;
132
+ const clientProps = {
133
+ ...restProps,
134
+ location: serialReq.location,
135
+ ...Object.keys(serverData).length > 0 ? { __serverData: serverData } : {}
136
+ };
137
+ const headHtml = buildHeadHtml(context.head);
138
+ const status = context.head.status ?? 200;
139
+ const finalAppProps = { ...props, location: serialReq.location, context };
140
+ return { finalAppProps, clientProps, headHtml, status, unsuspend };
141
+ }
55
142
  parentPort.on("message", async (msg) => {
56
- const { id, type } = msg;
143
+ const { id, type, request, streaming } = msg;
57
144
  try {
58
145
  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);
64
- globalThis.__hadarsUnsuspend = unsuspend;
65
- const stream = await _renderToReadableStream(ReactPage2);
66
- globalThis.__hadarsUnsuspend = null;
146
+ if (type !== "renderFull")
147
+ return;
148
+ const { finalAppProps, clientProps, headHtml, status, unsuspend } = await runFullLifecycle(request);
149
+ 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;
67
160
  const reader = stream.getReader();
68
161
  while (true) {
69
162
  const { done, value } = await reader.read();
@@ -72,18 +165,20 @@ parentPort.on("message", async (msg) => {
72
165
  parentPort.postMessage({ id, type: "chunk", chunk: value }, [value.buffer]);
73
166
  }
74
167
  parentPort.postMessage({ id, type: "done" });
75
- return;
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 });
76
177
  }
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 });
83
178
  } catch (err) {
84
179
  globalThis.__hadarsUnsuspend = null;
85
180
  const errMsg = err?.message ?? String(err);
86
- if (type === "renderStream") {
181
+ if (streaming) {
87
182
  parentPort.postMessage({ id, type: "error", error: errMsg });
88
183
  } else {
89
184
  parentPort.postMessage({ id, error: errMsg });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hadars",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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
@@ -58,16 +58,21 @@ async function getRenderToString(): Promise<(element: any) => string> {
58
58
  // renderString — renderToString for non-streaming responses
59
59
  // renderStream — renderToReadableStream chunks forwarded as a ReadableStream
60
60
 
61
- type PendingRenderString = {
62
- kind: 'renderString';
63
- resolve: (html: string) => void;
61
+ import type { SerializableRequest } from './ssr-render-worker';
62
+
63
+ type PendingRenderFull = {
64
+ kind: 'renderFull';
65
+ resolve: (result: { html: string; headHtml: string; status: number }) => void;
64
66
  reject: (err: Error) => void;
65
67
  };
66
- type PendingRenderStream = {
67
- kind: 'renderStream';
68
+ type PendingRenderFullStream = {
69
+ kind: 'renderFullStream';
70
+ headSettled: boolean;
71
+ headResolve: (result: { headHtml: string; status: number }) => void;
72
+ headReject: (err: Error) => void;
68
73
  controller: ReadableStreamDefaultController<Uint8Array>;
69
74
  };
70
- type PendingEntry = PendingRenderString | PendingRenderStream;
75
+ type PendingEntry = PendingRenderFull | PendingRenderFullStream;
71
76
 
72
77
  class RenderWorkerPool {
73
78
  private workers: any[] = [];
@@ -90,27 +95,36 @@ class RenderWorkerPool {
90
95
  const w = new Worker(workerPath, { workerData: { ssrBundlePath } });
91
96
  this.workerPending.set(w, new Set());
92
97
  w.on('message', (msg: any) => {
93
- const { id, type, html, error, chunk } = msg;
98
+ const { id, type, html, headHtml, status, error, chunk } = msg;
94
99
  const p = this.pending.get(id);
95
100
  if (!p) return;
96
101
 
97
- if (p.kind === 'renderStream') {
102
+ if (p.kind === 'renderFullStream') {
103
+ if (type === 'head') {
104
+ p.headSettled = true;
105
+ p.headResolve({ headHtml, status });
106
+ return;
107
+ }
98
108
  if (type === 'chunk') {
99
109
  p.controller.enqueue(chunk as Uint8Array);
100
- return; // keep entry until 'done'
110
+ return;
101
111
  }
102
112
  this.pending.delete(id);
103
113
  this.workerPending.get(w)?.delete(id);
104
114
  if (type === 'done') p.controller.close();
105
- else p.controller.error(new Error(error ?? 'Stream error'));
115
+ else {
116
+ const err = new Error(error ?? 'Stream error');
117
+ if (!p.headSettled) p.headReject(err);
118
+ p.controller.error(err);
119
+ }
106
120
  return;
107
121
  }
108
122
 
109
- // renderString
123
+ // renderFull (non-streaming)
110
124
  this.pending.delete(id);
111
125
  this.workerPending.get(w)?.delete(id);
112
126
  if (error) p.reject(new Error(error));
113
- else p.resolve(html);
127
+ else p.resolve({ html, headHtml, status });
114
128
  });
115
129
  w.on('error', (err: Error) => {
116
130
  console.error('[hadars] Render worker error:', err);
@@ -130,19 +144,20 @@ class RenderWorkerPool {
130
144
  }
131
145
 
132
146
  private _handleWorkerDeath(w: any, err: Error) {
133
- // Remove the dead worker from the rotation so it is never selected again.
134
147
  const idx = this.workers.indexOf(w);
135
148
  if (idx !== -1) this.workers.splice(idx, 1);
136
149
 
137
- // Reject every in-flight request that was sent to this worker.
138
150
  const ids = this.workerPending.get(w);
139
151
  if (ids) {
140
152
  for (const id of ids) {
141
153
  const p = this.pending.get(id);
142
154
  if (p) {
143
155
  this.pending.delete(id);
144
- if (p.kind === 'renderString') p.reject(err);
145
- else p.controller.error(err);
156
+ if (p.kind === 'renderFull') p.reject(err);
157
+ else {
158
+ if (!p.headSettled) p.headReject(err);
159
+ p.controller.error(err);
160
+ }
146
161
  }
147
162
  }
148
163
  this.workerPending.delete(w);
@@ -156,19 +171,16 @@ class RenderWorkerPool {
156
171
  return w;
157
172
  }
158
173
 
159
- /** Offload a full renderToString call. Returns the HTML string. */
160
- renderString(appProps: Record<string, unknown>, clientProps: Record<string, unknown>): Promise<string> {
174
+ /** Run the full SSR lifecycle in a worker thread. Returns html, headHtml, status. */
175
+ renderFull(req: SerializableRequest): Promise<{ html: string; headHtml: string; status: number }> {
161
176
  return new Promise((resolve, reject) => {
162
177
  const w = this.nextWorker();
163
- if (!w) {
164
- reject(new Error('[hadars] No render workers available'));
165
- return;
166
- }
178
+ if (!w) { reject(new Error('[hadars] No render workers available')); return; }
167
179
  const id = this.nextId++;
168
- this.pending.set(id, { kind: 'renderString', resolve, reject });
180
+ this.pending.set(id, { kind: 'renderFull', resolve, reject });
169
181
  this.workerPending.get(w)?.add(id);
170
182
  try {
171
- w.postMessage({ id, type: 'renderString', appProps, clientProps });
183
+ w.postMessage({ id, type: 'renderFull', streaming: false, request: req });
172
184
  } catch (err) {
173
185
  this.pending.delete(id);
174
186
  this.workerPending.get(w)?.delete(id);
@@ -177,32 +189,34 @@ class RenderWorkerPool {
177
189
  });
178
190
  }
179
191
 
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; },
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;
186
198
  });
199
+ let controller!: ReadableStreamDefaultController<Uint8Array>;
200
+ const stream = new ReadableStream<Uint8Array>({ start: (ctrl) => { controller = ctrl; } });
187
201
  const w = this.nextWorker();
188
202
  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;
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 };
192
208
  }
193
209
  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 });
210
+ this.pending.set(id, { kind: 'renderFullStream', headSettled: false, headResolve, headReject, controller });
197
211
  this.workerPending.get(w)?.add(id);
198
212
  try {
199
- w.postMessage({ id, type: 'renderStream', appProps, clientProps });
213
+ w.postMessage({ id, type: 'renderFull', streaming: true, request: req });
200
214
  } catch (err) {
201
215
  this.pending.delete(id);
202
216
  this.workerPending.get(w)?.delete(id);
203
- queueMicrotask(() => controller.error(err));
217
+ queueMicrotask(() => { headReject(err as Error); controller.error(err); });
204
218
  }
205
- return stream;
219
+ return { head, stream };
206
220
  }
207
221
 
208
222
  async terminate(): Promise<void> {
@@ -216,33 +230,24 @@ async function buildSsrResponse(
216
230
  status: number,
217
231
  getPrecontentHtml: (headHtml: string) => Promise<[string, string]>,
218
232
  streaming: boolean,
219
- renderPool?: RenderWorkerPool,
220
- renderPayload?: { appProps: Record<string, unknown>; clientProps: Record<string, unknown> },
233
+ unsuspendForRender: any,
221
234
  ): Promise<Response> {
222
235
  // Resolve renderers before touching globalThis.__hadarsUnsuspend.
223
236
  // Any await after setting the global would create a window where a concurrent
224
237
  // request on the same thread could overwrite it before the render call runs.
225
238
  // 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;
239
+ const renderToString = !streaming ? await getRenderToString() : null;
240
+ const renderReadableStream = streaming ? await getReadableStreamRenderer() : null;
232
241
 
233
242
  if (!streaming) {
234
243
  const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
235
244
  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
- }
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;
246
251
  }
247
252
  return new Response(precontentHtml + bodyHtml + postContent, {
248
253
  headers: { 'Content-Type': 'text/html; charset=utf-8' },
@@ -255,22 +260,17 @@ async function buildSsrResponse(
255
260
  const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
256
261
  controller.enqueue(encoder.encode(precontentHtml));
257
262
 
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;
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>>;
267
+ try {
268
+ (globalThis as any).__hadarsUnsuspend = unsuspendForRender;
269
+ streamPromise = renderReadableStream!(ReactPage);
270
+ } finally {
271
+ (globalThis as any).__hadarsUnsuspend = null;
273
272
  }
273
+ const bodyStream = await streamPromise;
274
274
  const reader = bodyStream.getReader();
275
275
  while (true) {
276
276
  const { done, value } = await reader.read();
@@ -289,6 +289,24 @@ async function buildSsrResponse(
289
289
  });
290
290
  }
291
291
 
292
+ /** Serialize a HadarsRequest into a structure-clonable object for postMessage. */
293
+ async function serializeRequest(req: any): Promise<SerializableRequest> {
294
+ const isGetOrHead = ['GET', 'HEAD'].includes(req.method ?? 'GET');
295
+ const body = isGetOrHead ? null : new Uint8Array(await req.clone().arrayBuffer());
296
+ const headers: Record<string, string> = {};
297
+ (req.headers as Headers).forEach((v: string, k: string) => { headers[k] = v; });
298
+ return {
299
+ url: req.url,
300
+ method: req.method ?? 'GET',
301
+ headers,
302
+ body,
303
+ pathname: req.pathname,
304
+ search: req.search,
305
+ location: req.location,
306
+ cookies: req.cookies,
307
+ };
308
+ }
309
+
292
310
  /**
293
311
  * Returns a function that parses `out.html` into pre-head, post-head, and
294
312
  * post-content segments and caches the result. Call the returned function with
@@ -593,7 +611,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
593
611
  getFinalProps,
594
612
  } = (await import(importPath)) as HadarsEntryModule<any>;
595
613
 
596
- const { ReactPage, status, headHtml } = await getReactResponse(request, {
614
+ const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
597
615
  document: {
598
616
  body: Component as React.FC<HadarsProps<object>>,
599
617
  lang: 'en',
@@ -603,7 +621,8 @@ export const dev = async (options: HadarsRuntimeOptions) => {
603
621
  },
604
622
  });
605
623
 
606
- return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, options.streaming === true); // no pool in dev
624
+ const unsuspend = (renderPayload.appProps.context as any)?._unsuspend ?? null;
625
+ return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, options.streaming === true, unsuspend);
607
626
  }, options.websocket);
608
627
  };
609
628
 
@@ -762,6 +781,40 @@ export const run = async (options: HadarsRuntimeOptions) => {
762
781
  getFinalProps,
763
782
  } = (await import(componentPath)) as HadarsEntryModule<any>;
764
783
 
784
+ if (renderPool) {
785
+ // Worker runs the full lifecycle — no non-serializable objects cross the thread boundary.
786
+ 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
+ }
816
+ }
817
+
765
818
  const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
766
819
  document: {
767
820
  body: Component as React.FC<HadarsProps<object>>,
@@ -772,6 +825,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
772
825
  },
773
826
  });
774
827
 
775
- return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, options.streaming === true, renderPool, renderPayload);
828
+ const unsuspend = (renderPayload.appProps.context as any)?._unsuspend ?? null;
829
+ return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, options.streaming === true, unsuspend);
776
830
  }, options.websocket);
777
831
  };
@@ -1,23 +1,22 @@
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, streaming: false, request: SerializableRequest }
7
+ * → runs full lifecycle (getInitProps → render loop → getAfterRenderProps → getFinalProps)
11
8
  * → renderToString(ReactPage)
12
- * → postMessage({ id, html })
9
+ * → postMessage({ id, html, headHtml, status })
13
10
  *
14
- * { type: 'renderStream', id, appProps, clientProps }
15
- * → renderToReadableStream(ReactPage) streams chunks back
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 })
16
15
  * → postMessage({ id, type: 'chunk', chunk }) × N
17
16
  * → postMessage({ id, type: 'done' })
18
17
  *
19
18
  * The SSR bundle path is passed once via workerData at thread creation time so
20
- * the component is only imported once per worker lifetime.
19
+ * the SSR module is only imported once per worker lifetime.
21
20
  */
22
21
 
23
22
  import { workerData, parentPort } from 'node:worker_threads';
@@ -33,10 +32,11 @@ let _React: any = null;
33
32
  let _renderToStaticMarkup: ((element: any) => string) | null = null;
34
33
  let _renderToString: ((element: any) => string) | null = null;
35
34
  let _renderToReadableStream: ((element: any, options?: any) => Promise<ReadableStream<Uint8Array>>) | null = null;
36
- let _Component: any = null;
35
+ // Full SSR module — includes default (App component) + lifecycle exports.
36
+ let _ssrMod: any = null;
37
37
 
38
38
  async function init() {
39
- if (_React && _renderToStaticMarkup && _renderToString && _renderToReadableStream && _Component) return;
39
+ if (_React && _ssrMod) return;
40
40
 
41
41
  const req = createRequire(pathMod.resolve(process.cwd(), '__ninety_fake__.js'));
42
42
 
@@ -59,19 +59,59 @@ async function init() {
59
59
  _renderToReadableStream = browserMod.renderToReadableStream;
60
60
  }
61
61
 
62
- if (!_Component) {
63
- const ssrMod = await import(pathToFileURL(ssrBundlePath).href);
64
- _Component = ssrMod.default;
62
+ if (!_ssrMod) {
63
+ _ssrMod = await import(pathToFileURL(ssrBundlePath).href);
65
64
  }
66
65
  }
67
66
 
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>) {
67
+ export type SerializableRequest = {
68
+ url: string;
69
+ method: string;
70
+ headers: Record<string, string>;
71
+ body: Uint8Array | null;
72
+ pathname: string;
73
+ search: string;
74
+ location: string;
75
+ cookies: Record<string, string>;
76
+ };
77
+
78
+ function deserializeRequest(s: SerializableRequest): any {
79
+ const init: RequestInit = { method: s.method, headers: new Headers(s.headers) };
80
+ if (s.body) init.body = s.body;
81
+ const req = new Request(s.url, init);
82
+ Object.assign(req, {
83
+ pathname: s.pathname,
84
+ search: s.search,
85
+ location: s.location,
86
+ cookies: s.cookies,
87
+ });
88
+ return req;
89
+ }
90
+
91
+ function buildHeadHtml(head: any): string {
92
+ const R = _React;
93
+ const metaEntries = Object.entries(head.meta ?? {});
94
+ const linkEntries = Object.entries(head.link ?? {});
95
+ const styleEntries = Object.entries(head.style ?? {});
96
+ const scriptEntries = Object.entries(head.script ?? {});
97
+ return _renderToStaticMarkup!(
98
+ R.createElement(R.Fragment, null,
99
+ R.createElement('title', null, head.title),
100
+ ...metaEntries.map(([id, opts]) => R.createElement('meta', { key: id, id, ...(opts as any) })),
101
+ ...linkEntries.map(([id, opts]) => R.createElement('link', { key: id, id, ...(opts as any) })),
102
+ ...styleEntries.map(([id, opts]) => R.createElement('style', { key: id, id, ...(opts as any) })),
103
+ ...scriptEntries.map(([id, opts]) => R.createElement('script', { key: id, id, ...(opts as any) })),
104
+ )
105
+ );
106
+ }
107
+
108
+ function buildReactPage(appProps: any, clientProps: any) {
109
+ const R = _React;
110
+ const Component = _ssrMod.default;
71
111
  return R.createElement(
72
112
  R.Fragment, null,
73
113
  R.createElement('div', { id: 'app' },
74
- R.createElement(_Component, appProps),
114
+ R.createElement(Component, appProps),
75
115
  ),
76
116
  R.createElement('script', {
77
117
  id: 'hadars',
@@ -83,53 +123,119 @@ function buildReactPage(R: any, appProps: Record<string, unknown>, clientProps:
83
123
  );
84
124
  }
85
125
 
126
+ async function runFullLifecycle(serialReq: SerializableRequest) {
127
+ const R = _React;
128
+ const Component = _ssrMod.default;
129
+ const { getInitProps, getAfterRenderProps, getFinalProps } = _ssrMod;
130
+
131
+ const parsedReq = deserializeRequest(serialReq);
132
+
133
+ const unsuspend: any = { cache: new Map(), hasPending: false };
134
+ const context: any = {
135
+ head: { title: 'Hadars App', meta: {}, link: {}, style: {}, script: {}, status: 200 },
136
+ _unsuspend: unsuspend,
137
+ };
138
+
139
+ let props: any = {
140
+ ...(getInitProps ? await getInitProps(parsedReq) : {}),
141
+ location: serialReq.location,
142
+ context,
143
+ };
144
+
145
+ // useServerData render loop — same logic as getReactResponse on main thread.
146
+ let html = '';
147
+ let iters = 0;
148
+ do {
149
+ unsuspend.hasPending = false;
150
+ try {
151
+ (globalThis as any).__hadarsUnsuspend = unsuspend;
152
+ html = _renderToStaticMarkup!(R.createElement(Component, props));
153
+ } finally {
154
+ (globalThis as any).__hadarsUnsuspend = null;
155
+ }
156
+ if (unsuspend.hasPending) {
157
+ const pending = [...unsuspend.cache.values()]
158
+ .filter((e: any) => e.status === 'pending')
159
+ .map((e: any) => e.promise);
160
+ await Promise.all(pending);
161
+ }
162
+ } while (unsuspend.hasPending && ++iters < 25);
163
+
164
+ props = getAfterRenderProps ? await getAfterRenderProps(props, html) : props;
165
+
166
+ // Re-render to capture head changes from getAfterRenderProps.
167
+ try {
168
+ (globalThis as any).__hadarsUnsuspend = unsuspend;
169
+ _renderToStaticMarkup!(R.createElement(Component, { ...props, location: serialReq.location, context }));
170
+ } finally {
171
+ (globalThis as any).__hadarsUnsuspend = null;
172
+ }
173
+
174
+ // Collect resolved useServerData values for client hydration.
175
+ const serverData: Record<string, unknown> = {};
176
+ for (const [k, v] of unsuspend.cache) {
177
+ if ((v as any).status === 'fulfilled') serverData[k] = (v as any).value;
178
+ if ((v as any).status === 'suspense-cached') serverData[k] = (v as any).value;
179
+ }
180
+
181
+ const { context: _ctx, ...restProps } = getFinalProps ? await getFinalProps(props) : props;
182
+ const clientProps = {
183
+ ...restProps,
184
+ location: serialReq.location,
185
+ ...(Object.keys(serverData).length > 0 ? { __serverData: serverData } : {}),
186
+ };
187
+
188
+ const headHtml = buildHeadHtml(context.head);
189
+ const status: number = context.head.status ?? 200;
190
+ const finalAppProps = { ...props, location: serialReq.location, context };
191
+
192
+ return { finalAppProps, clientProps, headHtml, status, unsuspend };
193
+ }
194
+
86
195
  parentPort!.on('message', async (msg: any) => {
87
- const { id, type } = msg;
196
+ const { id, type, request, streaming } = msg;
88
197
  try {
89
198
  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);
104
- (globalThis as any).__hadarsUnsuspend = unsuspend;
105
- const stream = await _renderToReadableStream!(ReactPage);
106
- (globalThis as any).__hadarsUnsuspend = null;
199
+
200
+ if (type !== 'renderFull') return;
201
+
202
+ const { finalAppProps, clientProps, headHtml, status, unsuspend } =
203
+ await runFullLifecycle(request as SerializableRequest);
204
+
205
+ const ReactPage = buildReactPage(finalAppProps, clientProps);
206
+
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;
107
217
  const reader = stream.getReader();
108
218
  while (true) {
109
219
  const { done, value } = await reader.read();
110
220
  if (done) break;
111
- // Transfer the underlying ArrayBuffer to avoid a copy across threads.
112
221
  parentPort!.postMessage({ id, type: 'chunk', chunk: value }, [value.buffer as ArrayBuffer]);
113
222
  }
114
223
  parentPort!.postMessage({ id, type: 'done' });
115
- return;
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 });
116
233
  }
117
234
 
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);
124
- (globalThis as any).__hadarsUnsuspend = unsuspend;
125
- const html = _renderToString!(ReactPage);
126
- (globalThis as any).__hadarsUnsuspend = null;
127
- parentPort!.postMessage({ id, html });
128
-
129
235
  } catch (err: any) {
130
236
  (globalThis as any).__hadarsUnsuspend = null;
131
237
  const errMsg = err?.message ?? String(err);
132
- if (type === 'renderStream') {
238
+ if (streaming) {
133
239
  parentPort!.postMessage({ id, type: 'error', error: errMsg });
134
240
  } else {
135
241
  parentPort!.postMessage({ id, error: errMsg });