hadars 0.1.9 → 0.1.10

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
@@ -149,48 +149,48 @@ async function getStaticMarkupRenderer() {
149
149
  }
150
150
  return _renderToStaticMarkup;
151
151
  }
152
- var getHeadHtml = (seoData, renderToStaticMarkup) => {
153
- const metaEntries = Object.entries(seoData.meta);
154
- const linkEntries = Object.entries(seoData.link);
155
- const styleEntries = Object.entries(seoData.style);
156
- const scriptEntries = Object.entries(seoData.script);
157
- return renderToStaticMarkup(
158
- /* @__PURE__ */ jsxs(Fragment, { children: [
159
- /* @__PURE__ */ jsx("title", { children: seoData.title }),
160
- metaEntries.map(([id, options]) => /* @__PURE__ */ jsx(
161
- "meta",
162
- {
163
- id,
164
- ...options
165
- },
166
- id
167
- )),
168
- linkEntries.map(([id, options]) => /* @__PURE__ */ jsx(
169
- "link",
170
- {
171
- id,
172
- ...options
173
- },
174
- id
175
- )),
176
- styleEntries.map(([id, options]) => /* @__PURE__ */ jsx(
177
- "style",
178
- {
179
- id,
180
- ...options
181
- },
182
- id
183
- )),
184
- scriptEntries.map(([id, options]) => /* @__PURE__ */ jsx(
185
- "script",
186
- {
187
- id,
188
- ...options
189
- },
190
- id
191
- ))
192
- ] })
193
- );
152
+ var ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
153
+ var escAttr = (s) => s.replace(/[&<>"]/g, (c) => ESC[c]);
154
+ var escText = (s) => s.replace(/[&<>]/g, (c) => ESC[c]);
155
+ var ATTR = {
156
+ className: "class",
157
+ htmlFor: "for",
158
+ httpEquiv: "http-equiv",
159
+ charSet: "charset",
160
+ crossOrigin: "crossorigin",
161
+ noModule: "nomodule",
162
+ referrerPolicy: "referrerpolicy",
163
+ fetchPriority: "fetchpriority"
164
+ };
165
+ function renderHeadTag(tag, id, opts, selfClose = false) {
166
+ let attrs = ` id="${escAttr(id)}"`;
167
+ let inner = "";
168
+ for (const [k, v] of Object.entries(opts)) {
169
+ if (k === "key" || k === "children")
170
+ continue;
171
+ if (k === "dangerouslySetInnerHTML") {
172
+ inner = v.__html ?? "";
173
+ continue;
174
+ }
175
+ const attr = ATTR[k] ?? k;
176
+ if (v === true)
177
+ attrs += ` ${attr}`;
178
+ else if (v !== false && v != null)
179
+ attrs += ` ${attr}="${escAttr(String(v))}"`;
180
+ }
181
+ return selfClose ? `<${tag}${attrs}>` : `<${tag}${attrs}>${inner}</${tag}>`;
182
+ }
183
+ var getHeadHtml = (seoData) => {
184
+ let html = `<title>${escText(seoData.title ?? "")}</title>`;
185
+ for (const [id, opts] of Object.entries(seoData.meta))
186
+ html += renderHeadTag("meta", id, opts, true);
187
+ for (const [id, opts] of Object.entries(seoData.link))
188
+ html += renderHeadTag("link", id, opts, true);
189
+ for (const [id, opts] of Object.entries(seoData.style))
190
+ html += renderHeadTag("style", id, opts);
191
+ for (const [id, opts] of Object.entries(seoData.script))
192
+ html += renderHeadTag("script", id, opts);
193
+ return html;
194
194
  };
195
195
  var getReactResponse = async (req, opts) => {
196
196
  const App = opts.document.body;
@@ -233,18 +233,17 @@ var getReactResponse = async (req, opts) => {
233
233
  if (unsuspend.hasPending)
234
234
  await processUnsuspend();
235
235
  } while (unsuspend.hasPending && ++iters < 25);
236
- props = getAfterRenderProps ? await getAfterRenderProps(props, html) : props;
237
- try {
238
- globalThis.__hadarsUnsuspend = unsuspend;
239
- renderToStaticMarkup(
240
- /* @__PURE__ */ jsx(App, { ...{
241
- ...props,
242
- location: req.location,
243
- context
244
- } })
245
- );
246
- } finally {
247
- globalThis.__hadarsUnsuspend = null;
236
+ if (unsuspend.hasPending) {
237
+ console.warn("[hadars] SSR render loop hit the 25-iteration cap \u2014 some useServerData values may not be resolved. Check for data dependencies that are never fulfilled.");
238
+ }
239
+ if (getAfterRenderProps) {
240
+ props = await getAfterRenderProps(props, html);
241
+ try {
242
+ globalThis.__hadarsUnsuspend = unsuspend;
243
+ renderToStaticMarkup(/* @__PURE__ */ jsx(App, { ...{ ...props, location: req.location, context } }));
244
+ } finally {
245
+ globalThis.__hadarsUnsuspend = null;
246
+ }
248
247
  }
249
248
  const serverData = {};
250
249
  for (const [k, v] of unsuspend.cache) {
@@ -268,7 +267,7 @@ var getReactResponse = async (req, opts) => {
268
267
  return {
269
268
  ReactPage,
270
269
  status: context.head.status,
271
- headHtml: getHeadHtml(context.head, renderToStaticMarkup),
270
+ headHtml: getHeadHtml(context.head),
272
271
  renderPayload: {
273
272
  appProps: { ...props, location: req.location, context },
274
273
  clientProps
@@ -619,7 +618,45 @@ function nodeReadableToWebStream(readable) {
619
618
  });
620
619
  }
621
620
  var noopCtx = { upgrade: () => false };
621
+ var COMPRESSIBLE_RE = /\b(?:text\/|application\/(?:json|javascript|xml)|image\/svg\+xml)/;
622
+ function withCompression(handler) {
623
+ return async (req, ctx) => {
624
+ const res = await handler(req, ctx);
625
+ if (!res?.body)
626
+ return res;
627
+ if (!COMPRESSIBLE_RE.test(res.headers.get("Content-Type") ?? ""))
628
+ return res;
629
+ if (res.headers.has("Content-Encoding"))
630
+ return res;
631
+ const accept = req.headers.get("Accept-Encoding") ?? "";
632
+ const encoding = accept.includes("br") ? "br" : accept.includes("gzip") ? "gzip" : null;
633
+ if (!encoding)
634
+ return res;
635
+ try {
636
+ const compressed = res.body.pipeThrough(new globalThis.CompressionStream(encoding));
637
+ const headers = new Headers(res.headers);
638
+ headers.set("Content-Encoding", encoding);
639
+ headers.delete("Content-Length");
640
+ return new Response(compressed, { status: res.status, statusText: res.statusText, headers });
641
+ } catch {
642
+ return res;
643
+ }
644
+ };
645
+ }
646
+ function withRequestLogging(handler) {
647
+ return async (req, ctx) => {
648
+ const start = performance.now();
649
+ const res = await handler(req, ctx);
650
+ const ms = Math.round(performance.now() - start);
651
+ const status = res?.status ?? 404;
652
+ const path2 = new URL(req.url).pathname;
653
+ console.log(`[hadars] ${req.method} ${path2} ${status} ${ms}ms`);
654
+ return res;
655
+ };
656
+ }
622
657
  async function serve(port, fetchHandler, websocket) {
658
+ fetchHandler = withCompression(fetchHandler);
659
+ fetchHandler = withRequestLogging(fetchHandler);
623
660
  if (isBun) {
624
661
  globalThis.Bun.serve({
625
662
  port,
@@ -740,6 +777,7 @@ import { RspackDevServer } from "@rspack/dev-server";
740
777
  import pathMod3 from "node:path";
741
778
  import { fileURLToPath as fileURLToPath2, pathToFileURL as pathToFileURL2 } from "node:url";
742
779
  import { createRequire as createRequire2 } from "node:module";
780
+ import crypto from "node:crypto";
743
781
  import fs from "node:fs/promises";
744
782
  import { existsSync as existsSync2 } from "node:fs";
745
783
  import os from "node:os";
@@ -766,42 +804,52 @@ var RenderWorkerPool = class {
766
804
  workerPending = /* @__PURE__ */ new Map();
767
805
  nextId = 0;
768
806
  rrIndex = 0;
807
+ _Worker = null;
808
+ _workerPath = "";
809
+ _ssrBundlePath = "";
769
810
  constructor(workerPath, size, ssrBundlePath) {
770
811
  this._init(workerPath, size, ssrBundlePath);
771
812
  }
772
813
  _init(workerPath, size, ssrBundlePath) {
814
+ this._workerPath = workerPath;
815
+ this._ssrBundlePath = ssrBundlePath;
773
816
  import("node:worker_threads").then(({ Worker }) => {
774
- for (let i = 0; i < size; i++) {
775
- const w = new Worker(workerPath, { workerData: { ssrBundlePath } });
776
- this.workerPending.set(w, /* @__PURE__ */ new Set());
777
- w.on("message", (msg) => {
778
- const { id, html, headHtml, status, error } = msg;
779
- const p = this.pending.get(id);
780
- if (!p)
781
- return;
782
- this.pending.delete(id);
783
- this.workerPending.get(w)?.delete(id);
784
- if (error)
785
- p.reject(new Error(error));
786
- else
787
- p.resolve({ html, headHtml, status });
788
- });
789
- w.on("error", (err) => {
790
- console.error("[hadars] Render worker error:", err);
791
- this._handleWorkerDeath(w, err);
792
- });
793
- w.on("exit", (code) => {
794
- if (code !== 0) {
795
- console.error(`[hadars] Render worker exited with code ${code}`);
796
- this._handleWorkerDeath(w, new Error(`Render worker exited with code ${code}`));
797
- }
798
- });
799
- this.workers.push(w);
800
- }
817
+ this._Worker = Worker;
818
+ for (let i = 0; i < size; i++)
819
+ this._spawnWorker();
801
820
  }).catch((err) => {
802
821
  console.error("[hadars] Failed to initialise render worker pool:", err);
803
822
  });
804
823
  }
824
+ _spawnWorker() {
825
+ if (!this._Worker)
826
+ return;
827
+ const w = new this._Worker(this._workerPath, { workerData: { ssrBundlePath: this._ssrBundlePath } });
828
+ this.workerPending.set(w, /* @__PURE__ */ new Set());
829
+ w.on("message", (msg) => {
830
+ const { id, html, headHtml, status, error } = msg;
831
+ const p = this.pending.get(id);
832
+ if (!p)
833
+ return;
834
+ this.pending.delete(id);
835
+ this.workerPending.get(w)?.delete(id);
836
+ if (error)
837
+ p.reject(new Error(error));
838
+ else
839
+ p.resolve({ html, headHtml, status });
840
+ });
841
+ w.on("error", (err) => {
842
+ console.error("[hadars] Render worker error:", err);
843
+ this._handleWorkerDeath(w, err);
844
+ });
845
+ w.on("exit", (code) => {
846
+ if (code !== 0) {
847
+ console.error(`[hadars] Render worker exited with code ${code}`);
848
+ this._handleWorkerDeath(w, new Error(`Render worker exited with code ${code}`));
849
+ }
850
+ });
851
+ this.workers.push(w);
852
+ }
805
853
  _handleWorkerDeath(w, err) {
806
854
  const idx = this.workers.indexOf(w);
807
855
  if (idx !== -1)
@@ -817,6 +865,8 @@ var RenderWorkerPool = class {
817
865
  }
818
866
  this.workerPending.delete(w);
819
867
  }
868
+ console.log("[hadars] Spawning replacement render worker");
869
+ this._spawnWorker();
820
870
  }
821
871
  nextWorker() {
822
872
  if (this.workers.length === 0)
@@ -968,7 +1018,7 @@ var dev = async (options) => {
968
1018
  }
969
1019
  const tmpFilePath = pathMod3.join(os.tmpdir(), `hadars-client-${Date.now()}.tsx`);
970
1020
  await fs.writeFile(tmpFilePath, clientScript);
971
- let ssrBuildId = Date.now();
1021
+ let ssrBuildId = crypto.randomBytes(4).toString("hex");
972
1022
  const clientCompiler = createClientCompiler(tmpFilePath, {
973
1023
  target: "web",
974
1024
  output: {
@@ -1083,7 +1133,7 @@ var dev = async (options) => {
1083
1133
  } catch (e) {
1084
1134
  }
1085
1135
  if (chunk.includes(rebuildMarker)) {
1086
- ssrBuildId = Date.now();
1136
+ ssrBuildId = crypto.randomBytes(4).toString("hex");
1087
1137
  console.log("[hadars] SSR bundle updated, build id:", ssrBuildId);
1088
1138
  }
1089
1139
  }
@@ -1137,23 +1187,32 @@ var dev = async (options) => {
1137
1187
  return projectRes;
1138
1188
  const ssrComponentPath = pathMod3.join(__dirname2, HadarsFolder, SSR_FILENAME);
1139
1189
  const importPath = pathToFileURL2(ssrComponentPath).href + `?t=${ssrBuildId}`;
1140
- const {
1141
- default: Component,
1142
- getInitProps,
1143
- getAfterRenderProps,
1144
- getFinalProps
1145
- } = await import(importPath);
1146
- const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
1147
- document: {
1148
- body: Component,
1149
- lang: "en",
1190
+ try {
1191
+ const {
1192
+ default: Component,
1150
1193
  getInitProps,
1151
1194
  getAfterRenderProps,
1152
1195
  getFinalProps
1153
- }
1154
- });
1155
- const unsuspend = renderPayload.appProps.context?._unsuspend ?? null;
1156
- return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
1196
+ } = await import(importPath);
1197
+ const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
1198
+ document: {
1199
+ body: Component,
1200
+ lang: "en",
1201
+ getInitProps,
1202
+ getAfterRenderProps,
1203
+ getFinalProps
1204
+ }
1205
+ });
1206
+ const unsuspend = renderPayload.appProps.context?._unsuspend ?? null;
1207
+ return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
1208
+ } catch (err) {
1209
+ console.error("[hadars] SSR render error:", err);
1210
+ const msg = (err?.stack ?? err?.message ?? String(err)).replace(/</g, "&lt;");
1211
+ return new Response(`<!doctype html><pre style="white-space:pre-wrap">${msg}</pre>`, {
1212
+ status: 500,
1213
+ headers: { "Content-Type": "text/html; charset=utf-8" }
1214
+ });
1215
+ }
1157
1216
  }, options.websocket);
1158
1217
  };
1159
1218
  var build = async (options) => {
@@ -1273,32 +1332,37 @@ var run = async (options) => {
1273
1332
  const componentPath = pathToFileURL2(
1274
1333
  pathMod3.resolve(__dirname2, HadarsFolder, SSR_FILENAME)
1275
1334
  ).href;
1276
- const {
1277
- default: Component,
1278
- getInitProps,
1279
- getAfterRenderProps,
1280
- getFinalProps
1281
- } = await import(componentPath);
1282
- if (renderPool) {
1283
- const serialReq = await serializeRequest(request);
1284
- const { html, headHtml: wHead, status: wStatus } = await renderPool.renderFull(serialReq);
1285
- const [precontentHtml, postContent] = await getPrecontentHtml(wHead);
1286
- return new Response(precontentHtml + html + postContent, {
1287
- headers: { "Content-Type": "text/html; charset=utf-8" },
1288
- status: wStatus
1289
- });
1290
- }
1291
- const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
1292
- document: {
1293
- body: Component,
1294
- lang: "en",
1335
+ try {
1336
+ const {
1337
+ default: Component,
1295
1338
  getInitProps,
1296
1339
  getAfterRenderProps,
1297
1340
  getFinalProps
1341
+ } = await import(componentPath);
1342
+ if (renderPool) {
1343
+ const serialReq = await serializeRequest(request);
1344
+ const { html, headHtml: wHead, status: wStatus } = await renderPool.renderFull(serialReq);
1345
+ const [precontentHtml, postContent] = await getPrecontentHtml(wHead);
1346
+ return new Response(precontentHtml + html + postContent, {
1347
+ headers: { "Content-Type": "text/html; charset=utf-8" },
1348
+ status: wStatus
1349
+ });
1298
1350
  }
1299
- });
1300
- const unsuspend = renderPayload.appProps.context?._unsuspend ?? null;
1301
- return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
1351
+ const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
1352
+ document: {
1353
+ body: Component,
1354
+ lang: "en",
1355
+ getInitProps,
1356
+ getAfterRenderProps,
1357
+ getFinalProps
1358
+ }
1359
+ });
1360
+ const unsuspend = renderPayload.appProps.context?._unsuspend ?? null;
1361
+ return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
1362
+ } catch (err) {
1363
+ console.error("[hadars] SSR render error:", err);
1364
+ return new Response("Internal Server Error", { status: 500 });
1365
+ }
1302
1366
  }, options.websocket);
1303
1367
  };
1304
1368
 
package/dist/index.cjs CHANGED
@@ -172,12 +172,13 @@ var AppProviderCSR = import_react.default.memo(({ children }) => {
172
172
  var useApp = () => import_react.default.useContext(AppContext);
173
173
  var clientServerDataCache = /* @__PURE__ */ new Map();
174
174
  function initServerDataCache(data) {
175
+ clientServerDataCache.clear();
175
176
  for (const [k, v] of Object.entries(data)) {
176
177
  clientServerDataCache.set(k, v);
177
178
  }
178
179
  }
179
180
  function useServerData(key, fn) {
180
- const cacheKey = Array.isArray(key) ? key.join("\0") : key;
181
+ const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
181
182
  if (typeof window !== "undefined") {
182
183
  if (clientServerDataCache.has(cacheKey)) {
183
184
  return clientServerDataCache.get(cacheKey);
package/dist/index.d.ts CHANGED
@@ -106,7 +106,8 @@ interface HadarsRequest extends Request {
106
106
  }
107
107
 
108
108
  /** Call this before hydrating to seed the client cache from the server's data.
109
- * Invoked automatically by the hadars client bootstrap. */
109
+ * Invoked automatically by the hadars client bootstrap.
110
+ * Always clears the existing cache before populating — call with `{}` to just clear. */
110
111
  declare function initServerDataCache(data: Record<string, unknown>): void;
111
112
  /**
112
113
  * Fetch async data on the server during SSR. Returns `undefined` on the first
package/dist/index.js CHANGED
@@ -132,12 +132,13 @@ var AppProviderCSR = React.memo(({ children }) => {
132
132
  var useApp = () => React.useContext(AppContext);
133
133
  var clientServerDataCache = /* @__PURE__ */ new Map();
134
134
  function initServerDataCache(data) {
135
+ clientServerDataCache.clear();
135
136
  for (const [k, v] of Object.entries(data)) {
136
137
  clientServerDataCache.set(k, v);
137
138
  }
138
139
  }
139
140
  function useServerData(key, fn) {
140
- const cacheKey = Array.isArray(key) ? key.join("\0") : key;
141
+ const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
141
142
  if (typeof window !== "undefined") {
142
143
  if (clientServerDataCache.has(cacheKey)) {
143
144
  return clientServerDataCache.get(cacheKey);
package/dist/loader.cjs CHANGED
@@ -21,14 +21,135 @@ __export(loader_exports, {
21
21
  default: () => loader
22
22
  });
23
23
  module.exports = __toCommonJS(loader_exports);
24
- const LOAD_MODULE_RE = /\bloadModule\s*(?:<.*?>\s*)?\(\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1\s*\)/gs;
25
24
  function loader(source) {
26
25
  const isServer = this.target === "node" || this.target === "async-node";
27
- return source.replace(LOAD_MODULE_RE, (_match, quote, modulePath) => {
28
- if (isServer) {
29
- return `Promise.resolve(require(${quote}${modulePath}${quote}))`;
26
+ const resourcePath = this.resourcePath ?? this.resource ?? "(unknown)";
27
+ let swc;
28
+ try {
29
+ swc = require("@swc/core");
30
+ } catch {
31
+ return regexTransform.call(this, source, isServer, resourcePath);
32
+ }
33
+ return swcTransform.call(this, swc, source, isServer, resourcePath);
34
+ }
35
+ function swcTransform(swc, source, isServer, resourcePath) {
36
+ const isTs = /\.[mc]?tsx?$/.test(resourcePath);
37
+ const isTsx = /\.(tsx|jsx)$/.test(resourcePath);
38
+ let ast;
39
+ try {
40
+ ast = swc.parseSync(source, {
41
+ syntax: isTs ? "typescript" : "ecmascript",
42
+ tsx: isTsx
43
+ });
44
+ } catch {
45
+ return regexTransform.call(this, source, isServer, resourcePath);
46
+ }
47
+ const srcBytes = Buffer.from(source, "utf8");
48
+ const fileOffset = ast.span.start - countLeadingNonCodeBytes(source);
49
+ const replacements = [];
50
+ walkAst(ast, (node) => {
51
+ if (node.type !== "CallExpression")
52
+ return;
53
+ const callee = node.callee;
54
+ if (!callee || callee.type !== "Identifier" || callee.value !== "loadModule")
55
+ return;
56
+ const args = node.arguments;
57
+ if (!args || args.length === 0)
58
+ return;
59
+ const firstArg = args[0].expression ?? args[0];
60
+ let modulePath;
61
+ let quoteChar;
62
+ if (firstArg.type === "StringLiteral") {
63
+ modulePath = firstArg.value;
64
+ const quoteByteIdx = firstArg.span.start - fileOffset;
65
+ quoteChar = String.fromCharCode(srcBytes[quoteByteIdx]);
66
+ } else if (firstArg.type === "TemplateLiteral" && firstArg.expressions.length === 0 && firstArg.quasis.length === 1) {
67
+ modulePath = firstArg.quasis[0].raw;
68
+ quoteChar = "`";
30
69
  } else {
31
- return `import(${quote}${modulePath}${quote})`;
70
+ const start0 = node.span.start - fileOffset;
71
+ const bytesBefore = srcBytes.slice(0, start0);
72
+ const line = bytesBefore.toString("utf8").split("\n").length;
73
+ this.emitWarning(
74
+ new Error(
75
+ `[hadars] loadModule() called with a dynamic (non-literal) path at ${resourcePath}:${line}. Only string-literal paths are transformed by the loader; dynamic calls fall back to runtime import().`
76
+ )
77
+ );
78
+ return;
32
79
  }
80
+ const replacement = isServer ? `Promise.resolve(require(${quoteChar}${modulePath}${quoteChar}))` : `import(${quoteChar}${modulePath}${quoteChar})`;
81
+ replacements.push({ start: node.span.start - fileOffset, end: node.span.end - fileOffset, replacement });
33
82
  });
83
+ if (replacements.length === 0)
84
+ return source;
85
+ replacements.sort((a, b) => b.start - a.start);
86
+ let result = srcBytes;
87
+ for (const { start, end, replacement } of replacements) {
88
+ result = Buffer.concat([result.slice(0, start), Buffer.from(replacement, "utf8"), result.slice(end)]);
89
+ }
90
+ return result.toString("utf8");
91
+ }
92
+ function walkAst(node, visit) {
93
+ if (!node || typeof node !== "object")
94
+ return;
95
+ visit(node);
96
+ for (const key of Object.keys(node)) {
97
+ if (key === "span" || key === "type" || key === "ctxt")
98
+ continue;
99
+ const val = node[key];
100
+ if (Array.isArray(val)) {
101
+ for (const child of val)
102
+ walkAst(child, visit);
103
+ } else if (val && typeof val === "object") {
104
+ walkAst(val, visit);
105
+ }
106
+ }
107
+ }
108
+ function countLeadingNonCodeBytes(source) {
109
+ let i = 0;
110
+ while (i < source.length) {
111
+ if (source[i] === " " || source[i] === " " || source[i] === "\r" || source[i] === "\n") {
112
+ i++;
113
+ continue;
114
+ }
115
+ if (source[i] === "/" && source[i + 1] === "/") {
116
+ while (i < source.length && source[i] !== "\n")
117
+ i++;
118
+ continue;
119
+ }
120
+ if (source[i] === "/" && source[i + 1] === "*") {
121
+ i += 2;
122
+ while (i + 1 < source.length && !(source[i] === "*" && source[i + 1] === "/"))
123
+ i++;
124
+ if (i + 1 < source.length)
125
+ i += 2;
126
+ continue;
127
+ }
128
+ if (i === 0 && source[i] === "#" && source[i + 1] === "!") {
129
+ while (i < source.length && source[i] !== "\n")
130
+ i++;
131
+ continue;
132
+ }
133
+ break;
134
+ }
135
+ return Buffer.byteLength(source.slice(0, i), "utf8");
136
+ }
137
+ const LOAD_MODULE_RE = /\bloadModule\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1\s*\)/gs;
138
+ const DYNAMIC_LOAD_MODULE_RE = /\bloadModule\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(/g;
139
+ function regexTransform(source, isServer, resourcePath) {
140
+ const transformed = source.replace(
141
+ LOAD_MODULE_RE,
142
+ (_match, quote, modulePath) => isServer ? `Promise.resolve(require(${quote}${modulePath}${quote}))` : `import(${quote}${modulePath}${quote})`
143
+ );
144
+ let match;
145
+ DYNAMIC_LOAD_MODULE_RE.lastIndex = 0;
146
+ while ((match = DYNAMIC_LOAD_MODULE_RE.exec(transformed)) !== null) {
147
+ const line = transformed.slice(0, match.index).split("\n").length;
148
+ this.emitWarning(
149
+ new Error(
150
+ `[hadars] loadModule() called with a dynamic (non-literal) path at ${resourcePath}:${line}. Only string-literal paths are transformed by the loader; dynamic calls fall back to runtime import().`
151
+ )
152
+ );
153
+ }
154
+ return transformed;
34
155
  }
@@ -108,6 +108,9 @@ async function runFullLifecycle(serialReq) {
108
108
  await Promise.all(pending);
109
109
  }
110
110
  } while (unsuspend.hasPending && ++iters < 25);
111
+ if (unsuspend.hasPending) {
112
+ console.warn("[hadars] SSR render loop hit the 25-iteration cap \u2014 some useServerData values may not be resolved. Check for data dependencies that are never fulfilled.");
113
+ }
111
114
  props = getAfterRenderProps ? await getAfterRenderProps(props, html) : props;
112
115
  try {
113
116
  globalThis.__hadarsUnsuspend = unsuspend;
@@ -175,8 +175,10 @@ export const useApp = () => React.useContext(AppContext);
175
175
  const clientServerDataCache = new Map<string, unknown>();
176
176
 
177
177
  /** Call this before hydrating to seed the client cache from the server's data.
178
- * Invoked automatically by the hadars client bootstrap. */
178
+ * Invoked automatically by the hadars client bootstrap.
179
+ * Always clears the existing cache before populating — call with `{}` to just clear. */
179
180
  export function initServerDataCache(data: Record<string, unknown>) {
181
+ clientServerDataCache.clear();
180
182
  for (const [k, v] of Object.entries(data)) {
181
183
  clientServerDataCache.set(k, v);
182
184
  }
@@ -208,7 +210,7 @@ export function initServerDataCache(data: Record<string, unknown>) {
208
210
  * if (!user) return null; // undefined while pending on the first SSR pass
209
211
  */
210
212
  export function useServerData<T>(key: string | string[], fn: () => Promise<T> | T): T | undefined {
211
- const cacheKey = Array.isArray(key) ? key.join('\x00') : key;
213
+ const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
212
214
 
213
215
  if (typeof window !== 'undefined') {
214
216
  // Client: if the server serialised a value for this key, return it directly
@@ -32,8 +32,12 @@ const main = async () => {
32
32
 
33
33
  const { location } = props;
34
34
 
35
- if ( appMod.getClientProps ) {
36
- props = await appMod.getClientProps(props);
35
+ if (appMod.getClientProps) {
36
+ try {
37
+ props = await appMod.getClientProps(props);
38
+ } catch (err) {
39
+ console.error('[hadars] getClientProps threw an error:', err);
40
+ }
37
41
  }
38
42
 
39
43
  props = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hadars",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Minimal SSR framework for React — rspack, HMR, TypeScript, Bun/Node/Deno",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -32,6 +32,7 @@
32
32
  "build:lib": "tsup src/index.tsx --format esm,cjs --dts --out-dir dist --clean --external '@rspack/*' --external '@rspack/binding'",
33
33
  "build:cli": "node build-scripts/build-cli.mjs",
34
34
  "build:all": "npm run build:lib && npm run build:cli",
35
+ "test": "bun test test/ssr.test.ts",
35
36
  "prepare": "npm run build:all",
36
37
  "prepublishOnly": "npm run build:all"
37
38
  },
@@ -64,6 +65,7 @@
64
65
  "@rspack/core": "1.4.9",
65
66
  "@rspack/dev-server": "^1.1.4",
66
67
  "@rspack/plugin-react-refresh": "^1.5.0",
68
+ "@swc/core": "^1.4.0",
67
69
  "@types/bun": "latest",
68
70
  "@types/react-dom": "^19.1.9",
69
71
  "react-refresh": "^0.17.0",
@@ -73,8 +75,7 @@
73
75
  "@types/react": "^19.0.0",
74
76
  "@types/react-dom": "^19.0.0",
75
77
  "esbuild": "^0.19.0",
76
- "tsup": "^6.6.0",
77
- "@swc/core": "^1.4.0"
78
+ "tsup": "^6.6.0"
78
79
  },
79
80
  "dependencies": {
80
81
  "@mdx-js/loader": "^3.1.1",