hadars 0.1.5 → 0.1.7

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";
@@ -260,7 +263,7 @@ var getReactResponse = async (req, opts) => {
260
263
  location: req.location,
261
264
  context
262
265
  } }) }),
263
- /* @__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") } })
264
267
  ] });
265
268
  return {
266
269
  ReactPage,
@@ -485,7 +488,6 @@ var buildCompilerConfig = (entry, opts, includeHotPlugin) => {
485
488
  };
486
489
  return {
487
490
  entry,
488
- resolve: resolveConfig,
489
491
  output: {
490
492
  ...opts.output,
491
493
  clean: false
@@ -505,6 +507,13 @@ var buildCompilerConfig = (entry, opts, includeHotPlugin) => {
505
507
  ...extraPlugins
506
508
  ],
507
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
+ },
508
517
  // HMR is not implemented for module chunk format, so disable outputModule
509
518
  // for client builds. SSR builds still need it for dynamic import() of exports.
510
519
  experiments: {
@@ -747,6 +756,9 @@ async function getRenderToString() {
747
756
  var RenderWorkerPool = class {
748
757
  workers = [];
749
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();
750
762
  nextId = 0;
751
763
  rrIndex = 0;
752
764
  constructor(workerPath, size, ssrBundlePath) {
@@ -756,6 +768,7 @@ var RenderWorkerPool = class {
756
768
  import("node:worker_threads").then(({ Worker }) => {
757
769
  for (let i = 0; i < size; i++) {
758
770
  const w = new Worker(workerPath, { workerData: { ssrBundlePath } });
771
+ this.workerPending.set(w, /* @__PURE__ */ new Set());
759
772
  w.on("message", (msg) => {
760
773
  const { id, type, html, error, chunk } = msg;
761
774
  const p = this.pending.get(id);
@@ -767,6 +780,7 @@ var RenderWorkerPool = class {
767
780
  return;
768
781
  }
769
782
  this.pending.delete(id);
783
+ this.workerPending.get(w)?.delete(id);
770
784
  if (type === "done")
771
785
  p.controller.close();
772
786
  else
@@ -774,6 +788,7 @@ var RenderWorkerPool = class {
774
788
  return;
775
789
  }
776
790
  this.pending.delete(id);
791
+ this.workerPending.get(w)?.delete(id);
777
792
  if (error)
778
793
  p.reject(new Error(error));
779
794
  else
@@ -781,6 +796,13 @@ var RenderWorkerPool = class {
781
796
  });
782
797
  w.on("error", (err) => {
783
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
+ }
784
806
  });
785
807
  this.workers.push(w);
786
808
  }
@@ -788,7 +810,28 @@ var RenderWorkerPool = class {
788
810
  console.error("[hadars] Failed to initialise render worker pool:", err);
789
811
  });
790
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
+ }
791
832
  nextWorker() {
833
+ if (this.workers.length === 0)
834
+ return void 0;
792
835
  const w = this.workers[this.rrIndex % this.workers.length];
793
836
  this.rrIndex++;
794
837
  return w;
@@ -796,9 +839,21 @@ var RenderWorkerPool = class {
796
839
  /** Offload a full renderToString call. Returns the HTML string. */
797
840
  renderString(appProps, clientProps) {
798
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
+ }
799
847
  const id = this.nextId++;
800
848
  this.pending.set(id, { kind: "renderString", resolve: resolve2, reject });
801
- 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
+ }
802
857
  });
803
858
  }
804
859
  /** Offload a renderToReadableStream call. Returns a ReadableStream fed by
@@ -810,9 +865,21 @@ var RenderWorkerPool = class {
810
865
  controller = ctrl;
811
866
  }
812
867
  });
868
+ const w = this.nextWorker();
869
+ if (!w) {
870
+ queueMicrotask(() => controller.error(new Error("[hadars] No render workers available")));
871
+ return stream;
872
+ }
813
873
  const id = this.nextId++;
814
874
  this.pending.set(id, { kind: "renderStream", controller });
815
- 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
+ }
816
883
  return stream;
817
884
  }
818
885
  async terminate() {
@@ -943,10 +1010,9 @@ var dev = async (options) => {
943
1010
  const hmrPort = options.hmrPort ?? port + 1;
944
1011
  const packageDir2 = pathMod3.dirname(fileURLToPath2(import.meta.url));
945
1012
  const clientScriptPath2 = pathMod3.resolve(packageDir2, "utils", "clientScript.tsx");
946
- const headPath = pathMod3.resolve(packageDir2, "utils", "Head");
947
1013
  let clientScript = "";
948
1014
  try {
949
- 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));
950
1016
  } catch (err) {
951
1017
  console.error("Failed to read client script from package dist, falling back to src", err);
952
1018
  throw err;
@@ -1002,6 +1068,22 @@ var dev = async (options) => {
1002
1068
  ...options.define ? [`--define=${JSON.stringify(options.define)}`] : []
1003
1069
  ], { stdio: "pipe" });
1004
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
+ });
1005
1087
  const stdoutWebStream = nodeReadableToWebStream(child.stdout);
1006
1088
  const stderrWebStream = nodeReadableToWebStream(child.stderr);
1007
1089
  const marker = "ssr-watch: initial-build-complete";
@@ -1129,13 +1211,12 @@ var build = async (options) => {
1129
1211
  const entry = pathMod3.resolve(__dirname2, options.entry);
1130
1212
  const packageDir2 = pathMod3.dirname(fileURLToPath2(import.meta.url));
1131
1213
  const clientScriptPath2 = pathMod3.resolve(packageDir2, "utils", "clientScript.js");
1132
- const headPath = pathMod3.resolve(packageDir2, "utils", "Head");
1133
1214
  let clientScript = "";
1134
1215
  try {
1135
- 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));
1136
1217
  } catch (err) {
1137
1218
  const srcClientPath = pathMod3.resolve(packageDir2, "utils", "clientScript.tsx");
1138
- 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()}`);
1139
1220
  }
1140
1221
  const tmpFilePath = pathMod3.join(os.tmpdir(), `hadars-client-${Date.now()}.tsx`);
1141
1222
  await fs.writeFile(tmpFilePath, clientScript);
@@ -1199,7 +1280,7 @@ var run = async (options) => {
1199
1280
  if (!isNode && workers > 1) {
1200
1281
  const packageDir2 = pathMod3.dirname(fileURLToPath2(import.meta.url));
1201
1282
  const workerJs = pathMod3.resolve(packageDir2, "ssr-render-worker.js");
1202
- const workerTs = pathMod3.resolve(packageDir2, "src", "ssr-render-worker.ts");
1283
+ const workerTs = pathMod3.resolve(packageDir2, "ssr-render-worker.ts");
1203
1284
  const workerFile = existsSync2(workerJs) ? workerJs : workerTs;
1204
1285
  const ssrBundlePath = pathMod3.resolve(__dirname2, HadarsFolder, SSR_FILENAME);
1205
1286
  renderPool = new RenderWorkerPool(workerFile, workers, ssrBundlePath);
@@ -1436,11 +1517,44 @@ async function runCli(argv, cwd = process.cwd()) {
1436
1517
  }
1437
1518
 
1438
1519
  // cli.ts
1439
- runCli(process.argv).catch((err) => {
1440
- console.error(err);
1441
- try {
1442
- process.exit(1);
1443
- } catch (_) {
1444
- throw err;
1445
- }
1446
- });
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.5",
3
+ "version": "0.1.7",
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<{}>;
@@ -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
- })