hadars 0.1.4 → 0.1.6

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
@@ -49,12 +49,11 @@ export default App;
49
49
 
50
50
  ## CLI
51
51
 
52
- After installing hadars the `hadars` (Node.js) and `hadars-bun` (Bun) binaries are available:
52
+ After installing hadars the `hadars` binary is available. It works on Node.js, Bun, and Deno the runtime is auto-detected:
53
53
 
54
54
  ```bash
55
55
  # Development server with React Fast Refresh HMR
56
56
  hadars dev
57
- hadars-bun dev # Bun — runs TypeScript directly, no build step
58
57
 
59
58
  # Production build (client + SSR bundles compiled in parallel)
60
59
  hadars build
package/cli.ts CHANGED
@@ -1,13 +1,36 @@
1
1
  #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
2
3
  import { runCli } from './cli-lib'
3
4
 
4
- runCli(process.argv).catch((err) => {
5
- console.error(err)
6
- // When Bun runs a script, allow non-zero exit codes to propagate
7
- try {
8
- process.exit(1)
9
- } catch (_) {
10
- // Some Bun environments may not allow process.exit; just rethrow
11
- throw err
12
- }
13
- })
5
+ // When the #!/usr/bin/env node shebang forces Node.js as the interpreter,
6
+ // try to re-exec with Bun so the server runs under Bun's runtime
7
+ // (native Bun.serve, WebSocket support, etc.).
8
+ // Falls back to Node.js silently if bun is not in PATH.
9
+ if (typeof (globalThis as any).Bun === 'undefined' && typeof (globalThis as any).Deno === 'undefined') {
10
+ const child = spawn('bun', [process.argv[1], ...process.argv.slice(2)], {
11
+ stdio: 'inherit',
12
+ env: process.env,
13
+ });
14
+ const sigs = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const;
15
+ const fwd = (sig: string) => () => { try { child.kill(sig); } catch {} };
16
+ for (const sig of sigs) process.on(sig, fwd(sig));
17
+ child.on('error', (err: any) => {
18
+ for (const sig of sigs) process.removeAllListeners(sig);
19
+ if (err.code !== 'ENOENT') { console.error(err); process.exit(1); }
20
+ // bun not found — fall through and run with Node.js
21
+ runCli(process.argv).catch((e) => {
22
+ console.error(e);
23
+ try { process.exit(1); } catch (_) { throw e; }
24
+ });
25
+ });
26
+ child.on('exit', (code: number | null) => process.exit(code ?? 0));
27
+ } else {
28
+ runCli(process.argv).catch((err) => {
29
+ console.error(err)
30
+ try {
31
+ process.exit(1)
32
+ } catch (_) {
33
+ throw err
34
+ }
35
+ })
36
+ }
package/dist/cli.js CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // cli.ts
4
+ import { spawn as spawn2 } from "node:child_process";
5
+
3
6
  // cli-lib.ts
4
7
  import { existsSync as existsSync3 } from "node:fs";
5
8
  import { mkdir, writeFile } from "node:fs/promises";
@@ -48,35 +51,38 @@ var createProxyHandler = (options) => {
48
51
  }
49
52
  const proxyRules = Object.entries(proxy).sort((a, b) => b[0].length - a[0].length);
50
53
  return async (req) => {
51
- if (req.method === "OPTIONS" && options.proxyCORS) {
52
- return new Response(null, {
53
- status: 204,
54
- headers: getCORSHeaders(req)
55
- });
56
- }
57
54
  for (const [path2, target] of proxyRules) {
58
55
  if (req.pathname.startsWith(path2)) {
56
+ if (req.method === "OPTIONS" && proxyCORS) {
57
+ return new Response(null, {
58
+ status: 204,
59
+ headers: getCORSHeaders(req)
60
+ });
61
+ }
59
62
  const targetURL = new URL(target);
60
63
  targetURL.pathname = targetURL.pathname.replace(/\/$/, "") + req.pathname.slice(path2.length);
61
64
  targetURL.search = req.search;
62
65
  const sendHeaders = cloneHeaders(req.headers);
63
66
  sendHeaders.set("Host", targetURL.host);
67
+ const hasBody = !["GET", "HEAD"].includes(req.method);
64
68
  const proxyReq = new Request(targetURL.toString(), {
65
69
  method: req.method,
66
70
  headers: sendHeaders,
67
- body: ["GET", "HEAD"].includes(req.method) ? void 0 : req.body,
68
- redirect: "follow"
71
+ body: hasBody ? req.body : void 0,
72
+ redirect: "follow",
73
+ // Node.js (undici) requires duplex:'half' when body is a ReadableStream
74
+ ...hasBody ? { duplex: "half" } : {}
69
75
  });
70
76
  const res = await fetch(proxyReq);
71
- if (proxyCORS) {
72
- Object.entries(getCORSHeaders(req)).forEach(([key, value]) => {
73
- res.headers.set(key, value);
74
- });
75
- }
76
77
  const body = await res.arrayBuffer();
77
78
  const clonedRes = new Headers(res.headers);
78
79
  clonedRes.delete("content-length");
79
80
  clonedRes.delete("content-encoding");
81
+ if (proxyCORS) {
82
+ Object.entries(getCORSHeaders(req)).forEach(([key, value]) => {
83
+ clonedRes.set(key, value);
84
+ });
85
+ }
80
86
  return new Response(body, {
81
87
  status: res.status,
82
88
  statusText: res.statusText,
@@ -257,7 +263,7 @@ var getReactResponse = async (req, opts) => {
257
263
  location: req.location,
258
264
  context
259
265
  } }) }),
260
- /* @__PURE__ */ jsx("script", { id: "hadars", type: "application/json", dangerouslySetInnerHTML: { __html: JSON.stringify({ hadars: { props: clientProps } }) } })
266
+ /* @__PURE__ */ jsx("script", { id: "hadars", type: "application/json", dangerouslySetInnerHTML: { __html: JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, "\\u003c") } })
261
267
  ] });
262
268
  return {
263
269
  ReactPage,
@@ -482,7 +488,6 @@ var buildCompilerConfig = (entry, opts, includeHotPlugin) => {
482
488
  };
483
489
  return {
484
490
  entry,
485
- resolve: resolveConfig,
486
491
  output: {
487
492
  ...opts.output,
488
493
  clean: false
@@ -502,6 +507,13 @@ var buildCompilerConfig = (entry, opts, includeHotPlugin) => {
502
507
  ...extraPlugins
503
508
  ],
504
509
  ...localConfig,
510
+ // Merge base resolve (modules, tsConfig, extensions) with per-build resolve
511
+ // (alias, mainFields). The spread order matters: resolveConfig wins for keys
512
+ // it defines, localConfig.resolve wins for keys it defines exclusively.
513
+ resolve: {
514
+ ...localConfig.resolve,
515
+ ...resolveConfig
516
+ },
505
517
  // HMR is not implemented for module chunk format, so disable outputModule
506
518
  // for client builds. SSR builds still need it for dynamic import() of exports.
507
519
  experiments: {
@@ -744,6 +756,9 @@ async function getRenderToString() {
744
756
  var RenderWorkerPool = class {
745
757
  workers = [];
746
758
  pending = /* @__PURE__ */ new Map();
759
+ // Track which pending IDs were dispatched to each worker so we can reject
760
+ // them when that worker crashes.
761
+ workerPending = /* @__PURE__ */ new Map();
747
762
  nextId = 0;
748
763
  rrIndex = 0;
749
764
  constructor(workerPath, size, ssrBundlePath) {
@@ -753,6 +768,7 @@ var RenderWorkerPool = class {
753
768
  import("node:worker_threads").then(({ Worker }) => {
754
769
  for (let i = 0; i < size; i++) {
755
770
  const w = new Worker(workerPath, { workerData: { ssrBundlePath } });
771
+ this.workerPending.set(w, /* @__PURE__ */ new Set());
756
772
  w.on("message", (msg) => {
757
773
  const { id, type, html, error, chunk } = msg;
758
774
  const p = this.pending.get(id);
@@ -764,6 +780,7 @@ var RenderWorkerPool = class {
764
780
  return;
765
781
  }
766
782
  this.pending.delete(id);
783
+ this.workerPending.get(w)?.delete(id);
767
784
  if (type === "done")
768
785
  p.controller.close();
769
786
  else
@@ -771,6 +788,7 @@ var RenderWorkerPool = class {
771
788
  return;
772
789
  }
773
790
  this.pending.delete(id);
791
+ this.workerPending.get(w)?.delete(id);
774
792
  if (error)
775
793
  p.reject(new Error(error));
776
794
  else
@@ -778,6 +796,13 @@ var RenderWorkerPool = class {
778
796
  });
779
797
  w.on("error", (err) => {
780
798
  console.error("[hadars] Render worker error:", err);
799
+ this._handleWorkerDeath(w, err);
800
+ });
801
+ w.on("exit", (code) => {
802
+ if (code !== 0) {
803
+ console.error(`[hadars] Render worker exited with code ${code}`);
804
+ this._handleWorkerDeath(w, new Error(`Render worker exited with code ${code}`));
805
+ }
781
806
  });
782
807
  this.workers.push(w);
783
808
  }
@@ -785,7 +810,28 @@ var RenderWorkerPool = class {
785
810
  console.error("[hadars] Failed to initialise render worker pool:", err);
786
811
  });
787
812
  }
813
+ _handleWorkerDeath(w, err) {
814
+ const idx = this.workers.indexOf(w);
815
+ if (idx !== -1)
816
+ this.workers.splice(idx, 1);
817
+ const ids = this.workerPending.get(w);
818
+ if (ids) {
819
+ for (const id of ids) {
820
+ const p = this.pending.get(id);
821
+ if (p) {
822
+ this.pending.delete(id);
823
+ if (p.kind === "renderString")
824
+ p.reject(err);
825
+ else
826
+ p.controller.error(err);
827
+ }
828
+ }
829
+ this.workerPending.delete(w);
830
+ }
831
+ }
788
832
  nextWorker() {
833
+ if (this.workers.length === 0)
834
+ return void 0;
789
835
  const w = this.workers[this.rrIndex % this.workers.length];
790
836
  this.rrIndex++;
791
837
  return w;
@@ -793,9 +839,21 @@ var RenderWorkerPool = class {
793
839
  /** Offload a full renderToString call. Returns the HTML string. */
794
840
  renderString(appProps, clientProps) {
795
841
  return new Promise((resolve2, reject) => {
842
+ const w = this.nextWorker();
843
+ if (!w) {
844
+ reject(new Error("[hadars] No render workers available"));
845
+ return;
846
+ }
796
847
  const id = this.nextId++;
797
848
  this.pending.set(id, { kind: "renderString", resolve: resolve2, reject });
798
- this.nextWorker().postMessage({ id, type: "renderString", appProps, clientProps });
849
+ this.workerPending.get(w)?.add(id);
850
+ try {
851
+ w.postMessage({ id, type: "renderString", appProps, clientProps });
852
+ } catch (err) {
853
+ this.pending.delete(id);
854
+ this.workerPending.get(w)?.delete(id);
855
+ reject(err);
856
+ }
799
857
  });
800
858
  }
801
859
  /** Offload a renderToReadableStream call. Returns a ReadableStream fed by
@@ -807,9 +865,21 @@ var RenderWorkerPool = class {
807
865
  controller = ctrl;
808
866
  }
809
867
  });
868
+ const w = this.nextWorker();
869
+ if (!w) {
870
+ queueMicrotask(() => controller.error(new Error("[hadars] No render workers available")));
871
+ return stream;
872
+ }
810
873
  const id = this.nextId++;
811
874
  this.pending.set(id, { kind: "renderStream", controller });
812
- this.nextWorker().postMessage({ id, type: "renderStream", appProps, clientProps });
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
+ }
813
883
  return stream;
814
884
  }
815
885
  async terminate() {
@@ -940,10 +1010,9 @@ var dev = async (options) => {
940
1010
  const hmrPort = options.hmrPort ?? port + 1;
941
1011
  const packageDir2 = pathMod3.dirname(fileURLToPath2(import.meta.url));
942
1012
  const clientScriptPath2 = pathMod3.resolve(packageDir2, "utils", "clientScript.tsx");
943
- const headPath = pathMod3.resolve(packageDir2, "utils", "Head");
944
1013
  let clientScript = "";
945
1014
  try {
946
- clientScript = (await fs.readFile(clientScriptPath2, "utf-8")).replace("$_MOD_PATH$", entry + getSuffix(options.mode)).replace("$_HEAD_PATH$", headPath);
1015
+ clientScript = (await fs.readFile(clientScriptPath2, "utf-8")).replace("$_MOD_PATH$", entry + getSuffix(options.mode));
947
1016
  } catch (err) {
948
1017
  console.error("Failed to read client script from package dist, falling back to src", err);
949
1018
  throw err;
@@ -999,6 +1068,22 @@ var dev = async (options) => {
999
1068
  ...options.define ? [`--define=${JSON.stringify(options.define)}`] : []
1000
1069
  ], { stdio: "pipe" });
1001
1070
  child.stdin?.end();
1071
+ const cleanupChild = () => {
1072
+ try {
1073
+ if (!child.killed)
1074
+ child.kill();
1075
+ } catch {
1076
+ }
1077
+ };
1078
+ process.once("exit", cleanupChild);
1079
+ process.once("SIGINT", () => {
1080
+ cleanupChild();
1081
+ process.exit(0);
1082
+ });
1083
+ process.once("SIGTERM", () => {
1084
+ cleanupChild();
1085
+ process.exit(0);
1086
+ });
1002
1087
  const stdoutWebStream = nodeReadableToWebStream(child.stdout);
1003
1088
  const stderrWebStream = nodeReadableToWebStream(child.stderr);
1004
1089
  const marker = "ssr-watch: initial-build-complete";
@@ -1126,13 +1211,12 @@ var build = async (options) => {
1126
1211
  const entry = pathMod3.resolve(__dirname2, options.entry);
1127
1212
  const packageDir2 = pathMod3.dirname(fileURLToPath2(import.meta.url));
1128
1213
  const clientScriptPath2 = pathMod3.resolve(packageDir2, "utils", "clientScript.js");
1129
- const headPath = pathMod3.resolve(packageDir2, "utils", "Head");
1130
1214
  let clientScript = "";
1131
1215
  try {
1132
- clientScript = (await fs.readFile(clientScriptPath2, "utf-8")).replace("$_MOD_PATH$", entry + getSuffix(options.mode)).replace("$_HEAD_PATH$", headPath);
1216
+ clientScript = (await fs.readFile(clientScriptPath2, "utf-8")).replace("$_MOD_PATH$", entry + getSuffix(options.mode));
1133
1217
  } catch (err) {
1134
1218
  const srcClientPath = pathMod3.resolve(packageDir2, "utils", "clientScript.tsx");
1135
- clientScript = (await fs.readFile(srcClientPath, "utf-8")).replace("$_MOD_PATH$", entry + `?v=${Date.now()}`).replace("$_HEAD_PATH$", pathMod3.resolve(packageDir2, "utils", "Head"));
1219
+ clientScript = (await fs.readFile(srcClientPath, "utf-8")).replace("$_MOD_PATH$", entry + `?v=${Date.now()}`);
1136
1220
  }
1137
1221
  const tmpFilePath = pathMod3.join(os.tmpdir(), `hadars-client-${Date.now()}.tsx`);
1138
1222
  await fs.writeFile(tmpFilePath, clientScript);
@@ -1196,7 +1280,7 @@ var run = async (options) => {
1196
1280
  if (!isNode && workers > 1) {
1197
1281
  const packageDir2 = pathMod3.dirname(fileURLToPath2(import.meta.url));
1198
1282
  const workerJs = pathMod3.resolve(packageDir2, "ssr-render-worker.js");
1199
- const workerTs = pathMod3.resolve(packageDir2, "src", "ssr-render-worker.ts");
1283
+ const workerTs = pathMod3.resolve(packageDir2, "ssr-render-worker.ts");
1200
1284
  const workerFile = existsSync2(workerJs) ? workerJs : workerTs;
1201
1285
  const ssrBundlePath = pathMod3.resolve(__dirname2, HadarsFolder, SSR_FILENAME);
1202
1286
  renderPool = new RenderWorkerPool(workerFile, workers, ssrBundlePath);
@@ -1433,11 +1517,44 @@ async function runCli(argv, cwd = process.cwd()) {
1433
1517
  }
1434
1518
 
1435
1519
  // cli.ts
1436
- runCli(process.argv).catch((err) => {
1437
- console.error(err);
1438
- try {
1439
- process.exit(1);
1440
- } catch (_) {
1441
- throw err;
1442
- }
1443
- });
1520
+ if (typeof globalThis.Bun === "undefined" && typeof globalThis.Deno === "undefined") {
1521
+ const child = spawn2("bun", [process.argv[1], ...process.argv.slice(2)], {
1522
+ stdio: "inherit",
1523
+ env: process.env
1524
+ });
1525
+ const sigs = ["SIGINT", "SIGTERM", "SIGHUP"];
1526
+ const fwd = (sig) => () => {
1527
+ try {
1528
+ child.kill(sig);
1529
+ } catch {
1530
+ }
1531
+ };
1532
+ for (const sig of sigs)
1533
+ process.on(sig, fwd(sig));
1534
+ child.on("error", (err) => {
1535
+ for (const sig of sigs)
1536
+ process.removeAllListeners(sig);
1537
+ if (err.code !== "ENOENT") {
1538
+ console.error(err);
1539
+ process.exit(1);
1540
+ }
1541
+ runCli(process.argv).catch((e) => {
1542
+ console.error(e);
1543
+ try {
1544
+ process.exit(1);
1545
+ } catch (_) {
1546
+ throw e;
1547
+ }
1548
+ });
1549
+ });
1550
+ child.on("exit", (code) => process.exit(code ?? 0));
1551
+ } else {
1552
+ runCli(process.argv).catch((err) => {
1553
+ console.error(err);
1554
+ try {
1555
+ process.exit(1);
1556
+ } catch (_) {
1557
+ throw err;
1558
+ }
1559
+ });
1560
+ }
package/dist/index.cjs CHANGED
@@ -32,6 +32,7 @@ var src_exports = {};
32
32
  __export(src_exports, {
33
33
  HadarsContext: () => HadarsContext,
34
34
  HadarsHead: () => Head,
35
+ initServerDataCache: () => initServerDataCache,
35
36
  loadModule: () => loadModule,
36
37
  useServerData: () => useServerData
37
38
  });
@@ -170,6 +171,11 @@ var AppProviderCSR = import_react.default.memo(({ children }) => {
170
171
  });
171
172
  var useApp = () => import_react.default.useContext(AppContext);
172
173
  var clientServerDataCache = /* @__PURE__ */ new Map();
174
+ function initServerDataCache(data) {
175
+ for (const [k, v] of Object.entries(data)) {
176
+ clientServerDataCache.set(k, v);
177
+ }
178
+ }
173
179
  function useServerData(key, fn) {
174
180
  const cacheKey = Array.isArray(key) ? key.join("\0") : key;
175
181
  if (typeof window !== "undefined") {
@@ -184,11 +190,16 @@ function useServerData(key, fn) {
184
190
  const existing = unsuspend.cache.get(cacheKey);
185
191
  if (existing?.status === "suspense-resolved") {
186
192
  try {
187
- return fn();
193
+ const value = fn();
194
+ unsuspend.cache.set(cacheKey, { status: "suspense-cached", value });
195
+ return value;
188
196
  } catch {
189
197
  return void 0;
190
198
  }
191
199
  }
200
+ if (existing?.status === "suspense-cached") {
201
+ return existing.value;
202
+ }
192
203
  if (!existing) {
193
204
  let result;
194
205
  try {
@@ -298,6 +309,7 @@ function loadModule(path) {
298
309
  0 && (module.exports = {
299
310
  HadarsContext,
300
311
  HadarsHead,
312
+ initServerDataCache,
301
313
  loadModule,
302
314
  useServerData
303
315
  });
package/dist/index.d.ts CHANGED
@@ -29,6 +29,9 @@ type UnsuspendEntry = {
29
29
  value: unknown;
30
30
  } | {
31
31
  status: 'suspense-resolved';
32
+ } | {
33
+ status: 'suspense-cached';
34
+ value: unknown;
32
35
  } | {
33
36
  status: 'rejected';
34
37
  reason: unknown;
@@ -102,6 +105,9 @@ interface HadarsRequest extends Request {
102
105
  cookies: Record<string, string>;
103
106
  }
104
107
 
108
+ /** Call this before hydrating to seed the client cache from the server's data.
109
+ * Invoked automatically by the hadars client bootstrap. */
110
+ declare function initServerDataCache(data: Record<string, unknown>): void;
105
111
  /**
106
112
  * Fetch async data on the server during SSR. Returns `undefined` on the first
107
113
  * render pass(es) while the promise is in flight; returns the resolved value
@@ -157,4 +163,4 @@ declare const HadarsContext: React$1.FC<{
157
163
  */
158
164
  declare function loadModule<T = any>(path: string): Promise<T>;
159
165
 
160
- export { HadarsApp, HadarsContext, HadarsEntryModule, HadarsGetAfterRenderProps, HadarsGetClientProps, HadarsGetFinalProps, HadarsGetInitialProps, Head as HadarsHead, HadarsOptions, HadarsProps, HadarsRequest, loadModule, useServerData };
166
+ export { HadarsApp, HadarsContext, HadarsEntryModule, HadarsGetAfterRenderProps, HadarsGetClientProps, HadarsGetFinalProps, HadarsGetInitialProps, Head as HadarsHead, HadarsOptions, HadarsProps, HadarsRequest, initServerDataCache, loadModule, useServerData };
package/dist/index.js CHANGED
@@ -131,6 +131,11 @@ var AppProviderCSR = React.memo(({ children }) => {
131
131
  });
132
132
  var useApp = () => React.useContext(AppContext);
133
133
  var clientServerDataCache = /* @__PURE__ */ new Map();
134
+ function initServerDataCache(data) {
135
+ for (const [k, v] of Object.entries(data)) {
136
+ clientServerDataCache.set(k, v);
137
+ }
138
+ }
134
139
  function useServerData(key, fn) {
135
140
  const cacheKey = Array.isArray(key) ? key.join("\0") : key;
136
141
  if (typeof window !== "undefined") {
@@ -145,11 +150,16 @@ function useServerData(key, fn) {
145
150
  const existing = unsuspend.cache.get(cacheKey);
146
151
  if (existing?.status === "suspense-resolved") {
147
152
  try {
148
- return fn();
153
+ const value = fn();
154
+ unsuspend.cache.set(cacheKey, { status: "suspense-cached", value });
155
+ return value;
149
156
  } catch {
150
157
  return void 0;
151
158
  }
152
159
  }
160
+ if (existing?.status === "suspense-cached") {
161
+ return existing.value;
162
+ }
153
163
  if (!existing) {
154
164
  let result;
155
165
  try {
@@ -258,6 +268,7 @@ function loadModule(path) {
258
268
  export {
259
269
  HadarsContext,
260
270
  Head as HadarsHead,
271
+ initServerDataCache,
261
272
  loadModule,
262
273
  useServerData
263
274
  };
@@ -47,7 +47,7 @@ function buildReactPage(R, appProps, clientProps) {
47
47
  id: "hadars",
48
48
  type: "application/json",
49
49
  dangerouslySetInnerHTML: {
50
- __html: JSON.stringify({ hadars: { props: clientProps } })
50
+ __html: JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, "\\u003c")
51
51
  }
52
52
  })
53
53
  );
package/dist/ssr-watch.js CHANGED
@@ -213,7 +213,6 @@ var buildCompilerConfig = (entry2, opts, includeHotPlugin) => {
213
213
  };
214
214
  return {
215
215
  entry: entry2,
216
- resolve: resolveConfig,
217
216
  output: {
218
217
  ...opts.output,
219
218
  clean: false
@@ -233,6 +232,13 @@ var buildCompilerConfig = (entry2, opts, includeHotPlugin) => {
233
232
  ...extraPlugins
234
233
  ],
235
234
  ...localConfig,
235
+ // Merge base resolve (modules, tsConfig, extensions) with per-build resolve
236
+ // (alias, mainFields). The spread order matters: resolveConfig wins for keys
237
+ // it defines, localConfig.resolve wins for keys it defines exclusively.
238
+ resolve: {
239
+ ...localConfig.resolve,
240
+ ...resolveConfig
241
+ },
236
242
  // HMR is not implemented for module chunk format, so disable outputModule
237
243
  // for client builds. SSR builds still need it for dynamic import() of exports.
238
244
  experiments: {
@@ -231,17 +231,27 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
231
231
  const existing = unsuspend.cache.get(cacheKey);
232
232
 
233
233
  // Suspense promise has resolved — re-call fn() so the hook returns its value
234
- // synchronously from its own internal cache. The result is returned directly
235
- // without being stored as 'fulfilled', so it is never included in the
236
- // serialised serverData sent to the client (the library owns its own hydration).
234
+ // synchronously from its own internal cache. Cache the result as
235
+ // 'suspense-cached' so later renders (e.g. the final renderToString in
236
+ // buildSsrResponse, which runs after getFinalProps may have cleared the
237
+ // user's QueryClient) can return the value without calling fn() again.
238
+ // NOT stored as 'fulfilled' so it is never included in serverData sent to
239
+ // the client — the Suspense library owns its own hydration.
237
240
  if (existing?.status === 'suspense-resolved') {
238
241
  try {
239
- return fn() as T | undefined;
242
+ const value = fn() as T;
243
+ unsuspend.cache.set(cacheKey, { status: 'suspense-cached', value });
244
+ return value;
240
245
  } catch {
241
246
  return undefined;
242
247
  }
243
248
  }
244
249
 
250
+ // Return the cached Suspense value on all subsequent renders.
251
+ if (existing?.status === 'suspense-cached') {
252
+ return existing.value as T;
253
+ }
254
+
245
255
  if (!existing) {
246
256
  // First encounter — call fn(), which may:
247
257
  // (a) return a Promise<T> — normal async usage (serialised for the client)
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import { hydrateRoot, createRoot } from 'react-dom/client';
3
3
  import type { HadarsEntryModule } from '../types/ninety';
4
- import { initServerDataCache } from '$_HEAD_PATH$';
4
+ import { initServerDataCache } from 'hadars';
5
5
  import * as _appMod from '$_MOD_PATH$';
6
6
 
7
7
  const appMod = _appMod as HadarsEntryModule<{}>;
package/package.json CHANGED
@@ -1,12 +1,11 @@
1
1
  {
2
2
  "name": "hadars",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Minimal SSR framework for React — rspack, HMR, TypeScript, Bun/Node/Deno",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
7
7
  "bin": {
8
- "hadars": "dist/cli.js",
9
- "hadars-bun": "cli-bun.ts"
8
+ "hadars": "dist/cli.js"
10
9
  },
11
10
  "main": "dist/index.js",
12
11
  "types": "dist/index.d.ts",
@@ -15,7 +14,6 @@
15
14
  "src",
16
15
  "cli-lib.ts",
17
16
  "cli.ts",
18
- "cli-bun.ts",
19
17
  "index.ts",
20
18
  "LICENSE"
21
19
  ],
package/src/build.ts CHANGED
@@ -72,6 +72,9 @@ type PendingEntry = PendingRenderString | PendingRenderStream;
72
72
  class RenderWorkerPool {
73
73
  private workers: any[] = [];
74
74
  private pending = new Map<number, PendingEntry>();
75
+ // Track which pending IDs were dispatched to each worker so we can reject
76
+ // them when that worker crashes.
77
+ private workerPending = new Map<any, Set<number>>();
75
78
  private nextId = 0;
76
79
  private rrIndex = 0;
77
80
 
@@ -85,6 +88,7 @@ class RenderWorkerPool {
85
88
  import('node:worker_threads').then(({ Worker }) => {
86
89
  for (let i = 0; i < size; i++) {
87
90
  const w = new Worker(workerPath, { workerData: { ssrBundlePath } });
91
+ this.workerPending.set(w, new Set());
88
92
  w.on('message', (msg: any) => {
89
93
  const { id, type, html, error, chunk } = msg;
90
94
  const p = this.pending.get(id);
@@ -96,6 +100,7 @@ class RenderWorkerPool {
96
100
  return; // keep entry until 'done'
97
101
  }
98
102
  this.pending.delete(id);
103
+ this.workerPending.get(w)?.delete(id);
99
104
  if (type === 'done') p.controller.close();
100
105
  else p.controller.error(new Error(error ?? 'Stream error'));
101
106
  return;
@@ -103,11 +108,19 @@ class RenderWorkerPool {
103
108
 
104
109
  // renderString
105
110
  this.pending.delete(id);
111
+ this.workerPending.get(w)?.delete(id);
106
112
  if (error) p.reject(new Error(error));
107
113
  else p.resolve(html);
108
114
  });
109
115
  w.on('error', (err: Error) => {
110
116
  console.error('[hadars] Render worker error:', err);
117
+ this._handleWorkerDeath(w, err);
118
+ });
119
+ w.on('exit', (code: number) => {
120
+ if (code !== 0) {
121
+ console.error(`[hadars] Render worker exited with code ${code}`);
122
+ this._handleWorkerDeath(w, new Error(`Render worker exited with code ${code}`));
123
+ }
111
124
  });
112
125
  this.workers.push(w);
113
126
  }
@@ -116,7 +129,28 @@ class RenderWorkerPool {
116
129
  });
117
130
  }
118
131
 
119
- private nextWorker() {
132
+ private _handleWorkerDeath(w: any, err: Error) {
133
+ // Remove the dead worker from the rotation so it is never selected again.
134
+ const idx = this.workers.indexOf(w);
135
+ if (idx !== -1) this.workers.splice(idx, 1);
136
+
137
+ // Reject every in-flight request that was sent to this worker.
138
+ const ids = this.workerPending.get(w);
139
+ if (ids) {
140
+ for (const id of ids) {
141
+ const p = this.pending.get(id);
142
+ if (p) {
143
+ this.pending.delete(id);
144
+ if (p.kind === 'renderString') p.reject(err);
145
+ else p.controller.error(err);
146
+ }
147
+ }
148
+ this.workerPending.delete(w);
149
+ }
150
+ }
151
+
152
+ private nextWorker(): any | undefined {
153
+ if (this.workers.length === 0) return undefined;
120
154
  const w = this.workers[this.rrIndex % this.workers.length];
121
155
  this.rrIndex++;
122
156
  return w;
@@ -125,9 +159,21 @@ class RenderWorkerPool {
125
159
  /** Offload a full renderToString call. Returns the HTML string. */
126
160
  renderString(appProps: Record<string, unknown>, clientProps: Record<string, unknown>): Promise<string> {
127
161
  return new Promise((resolve, reject) => {
162
+ const w = this.nextWorker();
163
+ if (!w) {
164
+ reject(new Error('[hadars] No render workers available'));
165
+ return;
166
+ }
128
167
  const id = this.nextId++;
129
168
  this.pending.set(id, { kind: 'renderString', resolve, reject });
130
- this.nextWorker().postMessage({ id, type: 'renderString', appProps, clientProps });
169
+ this.workerPending.get(w)?.add(id);
170
+ try {
171
+ w.postMessage({ id, type: 'renderString', appProps, clientProps });
172
+ } catch (err) {
173
+ this.pending.delete(id);
174
+ this.workerPending.get(w)?.delete(id);
175
+ reject(err);
176
+ }
131
177
  });
132
178
  }
133
179
 
@@ -138,11 +184,24 @@ class RenderWorkerPool {
138
184
  const stream = new ReadableStream<Uint8Array>({
139
185
  start: (ctrl) => { controller = ctrl; },
140
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
+ }
141
193
  const id = this.nextId++;
142
194
  // Store controller before postMessage so the handler is ready when
143
195
  // the first chunk arrives.
144
196
  this.pending.set(id, { kind: 'renderStream', controller });
145
- this.nextWorker().postMessage({ id, type: 'renderStream', appProps, clientProps });
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
+ }
146
205
  return stream;
147
206
  }
148
207
 
@@ -341,13 +400,10 @@ export const dev = async (options: HadarsRuntimeOptions) => {
341
400
  const packageDir = pathMod.dirname(fileURLToPath(import.meta.url));
342
401
  const clientScriptPath = pathMod.resolve(packageDir, 'utils', 'clientScript.tsx');
343
402
 
344
- const headPath = pathMod.resolve(packageDir, 'utils', 'Head');
345
-
346
403
  let clientScript = '';
347
404
  try {
348
405
  clientScript = (await fs.readFile(clientScriptPath, 'utf-8'))
349
- .replace('$_MOD_PATH$', entry + getSuffix(options.mode))
350
- .replace('$_HEAD_PATH$', headPath);
406
+ .replace('$_MOD_PATH$', entry + getSuffix(options.mode));
351
407
  }
352
408
  catch (err) {
353
409
  console.error("Failed to read client script from package dist, falling back to src", err);
@@ -421,6 +477,12 @@ export const dev = async (options: HadarsRuntimeOptions) => {
421
477
  ], { stdio: 'pipe' });
422
478
  child.stdin?.end();
423
479
 
480
+ // Ensure the SSR watcher child is killed when this process exits.
481
+ const cleanupChild = () => { try { if (!child.killed) child.kill(); } catch {} };
482
+ process.once('exit', cleanupChild);
483
+ process.once('SIGINT', () => { cleanupChild(); process.exit(0); });
484
+ process.once('SIGTERM', () => { cleanupChild(); process.exit(0); });
485
+
424
486
  // Convert Node.js Readable streams to Web ReadableStream so the rest of
425
487
  // the logic works identically across all runtimes.
426
488
  const stdoutWebStream = nodeReadableToWebStream(child.stdout!);
@@ -553,17 +615,14 @@ export const build = async (options: HadarsRuntimeOptions) => {
553
615
  // prepare client script
554
616
  const packageDir = pathMod.dirname(fileURLToPath(import.meta.url));
555
617
  const clientScriptPath = pathMod.resolve(packageDir, 'utils', 'clientScript.js');
556
- const headPath = pathMod.resolve(packageDir, 'utils', 'Head');
557
618
  let clientScript = '';
558
619
  try {
559
620
  clientScript = (await fs.readFile(clientScriptPath, 'utf-8'))
560
- .replace('$_MOD_PATH$', entry + getSuffix(options.mode))
561
- .replace('$_HEAD_PATH$', headPath);
621
+ .replace('$_MOD_PATH$', entry + getSuffix(options.mode));
562
622
  } catch (err) {
563
623
  const srcClientPath = pathMod.resolve(packageDir, 'utils', 'clientScript.tsx');
564
624
  clientScript = (await fs.readFile(srcClientPath, 'utf-8'))
565
- .replace('$_MOD_PATH$', entry + `?v=${Date.now()}`)
566
- .replace('$_HEAD_PATH$', pathMod.resolve(packageDir, 'utils', 'Head'));
625
+ .replace('$_MOD_PATH$', entry + `?v=${Date.now()}`);
567
626
  }
568
627
 
569
628
  const tmpFilePath = pathMod.join(os.tmpdir(), `hadars-client-${Date.now()}.tsx`);
@@ -645,7 +704,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
645
704
  if (!isNode && workers > 1) {
646
705
  const packageDir = pathMod.dirname(fileURLToPath(import.meta.url));
647
706
  const workerJs = pathMod.resolve(packageDir, 'ssr-render-worker.js');
648
- const workerTs = pathMod.resolve(packageDir, 'src', 'ssr-render-worker.ts');
707
+ const workerTs = pathMod.resolve(packageDir, 'ssr-render-worker.ts');
649
708
  const workerFile = existsSync(workerJs) ? workerJs : workerTs;
650
709
  const ssrBundlePath = pathMod.resolve(__dirname, HadarsFolder, SSR_FILENAME);
651
710
  renderPool = new RenderWorkerPool(workerFile, workers, ssrBundlePath);
package/src/index.tsx CHANGED
@@ -9,7 +9,7 @@ export type {
9
9
  HadarsEntryModule,
10
10
  HadarsApp,
11
11
  } from "./types/ninety";
12
- export { Head as HadarsHead, useServerData } from './utils/Head';
12
+ export { Head as HadarsHead, useServerData, initServerDataCache } from './utils/Head';
13
13
  import { AppProviderSSR, AppProviderCSR } from "./utils/Head";
14
14
 
15
15
  export const HadarsContext = typeof window === 'undefined' ? AppProviderSSR : AppProviderCSR;
@@ -77,7 +77,7 @@ function buildReactPage(R: any, appProps: Record<string, unknown>, clientProps:
77
77
  id: 'hadars',
78
78
  type: 'application/json',
79
79
  dangerouslySetInnerHTML: {
80
- __html: JSON.stringify({ hadars: { props: clientProps } }),
80
+ __html: JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c'),
81
81
  },
82
82
  }),
83
83
  );
@@ -27,6 +27,7 @@ export type UnsuspendEntry =
27
27
  | { status: 'pending'; promise: Promise<unknown> }
28
28
  | { status: 'fulfilled'; value: unknown }
29
29
  | { status: 'suspense-resolved' }
30
+ | { status: 'suspense-cached'; value: unknown }
30
31
  | { status: 'rejected'; reason: unknown };
31
32
 
32
33
  /** @internal Populated by the framework's render loop — use useServerData() instead. */
@@ -231,17 +231,27 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
231
231
  const existing = unsuspend.cache.get(cacheKey);
232
232
 
233
233
  // Suspense promise has resolved — re-call fn() so the hook returns its value
234
- // synchronously from its own internal cache. The result is returned directly
235
- // without being stored as 'fulfilled', so it is never included in the
236
- // serialised serverData sent to the client (the library owns its own hydration).
234
+ // synchronously from its own internal cache. Cache the result as
235
+ // 'suspense-cached' so later renders (e.g. the final renderToString in
236
+ // buildSsrResponse, which runs after getFinalProps may have cleared the
237
+ // user's QueryClient) can return the value without calling fn() again.
238
+ // NOT stored as 'fulfilled' so it is never included in serverData sent to
239
+ // the client — the Suspense library owns its own hydration.
237
240
  if (existing?.status === 'suspense-resolved') {
238
241
  try {
239
- return fn() as T | undefined;
242
+ const value = fn() as T;
243
+ unsuspend.cache.set(cacheKey, { status: 'suspense-cached', value });
244
+ return value;
240
245
  } catch {
241
246
  return undefined;
242
247
  }
243
248
  }
244
249
 
250
+ // Return the cached Suspense value on all subsequent renders.
251
+ if (existing?.status === 'suspense-cached') {
252
+ return existing.value as T;
253
+ }
254
+
245
255
  if (!existing) {
246
256
  // First encounter — call fn(), which may:
247
257
  // (a) return a Promise<T> — normal async usage (serialised for the client)
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import { hydrateRoot, createRoot } from 'react-dom/client';
3
3
  import type { HadarsEntryModule } from '../types/ninety';
4
- import { initServerDataCache } from '$_HEAD_PATH$';
4
+ import { initServerDataCache } from 'hadars';
5
5
  import * as _appMod from '$_MOD_PATH$';
6
6
 
7
7
  const appMod = _appMod as HadarsEntryModule<{}>;
@@ -53,14 +53,14 @@ export const createProxyHandler = (options: HadarsOptions): ProxyHandler => {
53
53
  const proxyRules = Object.entries(proxy).sort((a, b) => b[0].length - a[0].length);
54
54
 
55
55
  return async (req: HadarsRequest) => {
56
- if (req.method === 'OPTIONS' && options.proxyCORS) {
57
- return new Response(null, {
58
- status: 204,
59
- headers: getCORSHeaders(req),
60
- });
61
- }
62
56
  for (const [path, target] of proxyRules) {
63
57
  if (req.pathname.startsWith(path)) {
58
+ if (req.method === 'OPTIONS' && proxyCORS) {
59
+ return new Response(null, {
60
+ status: 204,
61
+ headers: getCORSHeaders(req),
62
+ });
63
+ }
64
64
  const targetURL = new URL(target);
65
65
  targetURL.pathname = targetURL.pathname.replace(/\/$/, '') + req.pathname.slice(path.length);
66
66
  targetURL.search = req.search;
@@ -69,25 +69,28 @@ export const createProxyHandler = (options: HadarsOptions): ProxyHandler => {
69
69
  // Overwrite the Host header to match the target
70
70
  sendHeaders.set('Host', targetURL.host);
71
71
 
72
+ const hasBody = !['GET', 'HEAD'].includes(req.method);
72
73
  const proxyReq = new Request(targetURL.toString(), {
73
74
  method: req.method,
74
75
  headers: sendHeaders,
75
- body: ['GET', 'HEAD'].includes(req.method) ? undefined : req.body,
76
+ body: hasBody ? req.body : undefined,
76
77
  redirect: 'follow',
77
- });
78
+ // Node.js (undici) requires duplex:'half' when body is a ReadableStream
79
+ ...(hasBody ? { duplex: 'half' } : {}),
80
+ } as RequestInit);
78
81
 
79
82
  const res = await fetch(proxyReq);
80
- if (proxyCORS) {
81
- Object.entries(getCORSHeaders(req)).forEach(([key, value]) => {
82
- res.headers.set(key, value);
83
- });
84
- }
85
83
  // Read the response body
86
84
  const body = await res.arrayBuffer();
87
85
  // remove content-length and content-encoding headers to avoid issues with modified body
88
86
  const clonedRes = new Headers(res.headers);
89
87
  clonedRes.delete('content-length');
90
88
  clonedRes.delete('content-encoding');
89
+ if (proxyCORS) {
90
+ Object.entries(getCORSHeaders(req)).forEach(([key, value]) => {
91
+ clonedRes.set(key, value);
92
+ });
93
+ }
91
94
  // return a new Response with the modified headers and original body
92
95
  return new Response(body, {
93
96
  status: res.status,
@@ -181,7 +181,7 @@ export const getReactResponse = async (
181
181
  context,
182
182
  })} />
183
183
  </div>
184
- <script id="hadars" type="application/json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ hadars: { props: clientProps } }) }}></script>
184
+ <script id="hadars" type="application/json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c') }}></script>
185
185
  </>
186
186
  )
187
187
 
@@ -260,7 +260,6 @@ const buildCompilerConfig = (
260
260
 
261
261
  return {
262
262
  entry,
263
- resolve: resolveConfig,
264
263
  output: {
265
264
  ...opts.output,
266
265
  clean: false,
@@ -280,6 +279,13 @@ const buildCompilerConfig = (
280
279
  ...extraPlugins,
281
280
  ],
282
281
  ...localConfig,
282
+ // Merge base resolve (modules, tsConfig, extensions) with per-build resolve
283
+ // (alias, mainFields). The spread order matters: resolveConfig wins for keys
284
+ // it defines, localConfig.resolve wins for keys it defines exclusively.
285
+ resolve: {
286
+ ...localConfig.resolve,
287
+ ...resolveConfig,
288
+ },
283
289
  // HMR is not implemented for module chunk format, so disable outputModule
284
290
  // for client builds. SSR builds still need it for dynamic import() of exports.
285
291
  experiments: {
package/cli-bun.ts DELETED
@@ -1,13 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { runCli } from './cli-lib'
3
-
4
- runCli(process.argv).catch((err) => {
5
- console.error(err)
6
- // When Bun runs a script, allow non-zero exit codes to propagate
7
- try {
8
- process.exit(1)
9
- } catch (_) {
10
- // Some Bun environments may not allow process.exit; just rethrow
11
- throw err
12
- }
13
- })