hadars 0.3.1 → 0.4.0

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
@@ -2,6 +2,8 @@
2
2
 
3
3
  A minimal server-side rendering framework for React built on [rspack](https://rspack.dev). Runs on Bun, Node.js, and Deno.
4
4
 
5
+ **[hadars.xyz](https://hadars.xyz)** — docs & website
6
+
5
7
  ## Why hadars?
6
8
 
7
9
  hadars is an alternative to Next.js for apps that just need SSR.
@@ -19,30 +21,30 @@ Bring your own router (or none), keep your components as plain React, and get SS
19
21
  ## Benchmarks
20
22
 
21
23
  <!-- BENCHMARK_START -->
22
- > Last run: 2026-03-25 · 120s · 100 connections · Bun runtime
23
- > hadars is **8.9x faster** in requests/sec
24
+ > Last run: 2026-04-02 · 120s · 100 connections · Bun runtime
25
+ > hadars is **9.3x faster** in requests/sec
24
26
 
25
27
  **Throughput** (autocannon, 120s)
26
28
 
27
29
  | Metric | hadars | Next.js |
28
30
  |---|---:|---:|
29
- | Requests/sec | **151** | 17 |
30
- | Latency median | **642 ms** | 2747 ms |
31
- | Latency p99 | **959 ms** | 4019 ms |
32
- | Throughput | **43.05** MB/s | 9.5 MB/s |
33
- | Peak RSS | 950.3 MB | **478.5 MB** |
34
- | Avg RSS | 763.3 MB | **426.4 MB** |
35
- | Build time | 0.7 s | 6.0 s |
31
+ | Requests/sec | **158** | 17 |
32
+ | Latency median | **634 ms** | 2754 ms |
33
+ | Latency p99 | **1031 ms** | 4140 ms |
34
+ | Throughput | **44.97** MB/s | 9.67 MB/s |
35
+ | Peak RSS | 1001.1 MB | **477.2 MB** |
36
+ | Avg RSS | 778.8 MB | **426.8 MB** |
37
+ | Build time | 0.6 s | 6.0 s |
36
38
 
37
39
  **Page load** (Playwright · Chromium headless · median)
38
40
 
39
41
  | Metric | hadars | Next.js |
40
42
  |---|---:|---:|
41
- | TTFB | **19 ms** | 42 ms |
42
- | FCP | **96 ms** | 136 ms |
43
- | DOMContentLoaded | **39 ms** | 127 ms |
44
- | Load | **122 ms** | 173 ms |
45
- | Peak RSS | 476.8 MB | **289.5 MB** |
43
+ | TTFB | **17 ms** | 45 ms |
44
+ | FCP | **92 ms** | 140 ms |
45
+ | DOMContentLoaded | **37 ms** | 127 ms |
46
+ | Load | **118 ms** | 172 ms |
47
+ | Peak RSS | 479.5 MB | **295.5 MB** |
46
48
  <!-- BENCHMARK_END -->
47
49
 
48
50
  ## Quick start
@@ -146,16 +148,28 @@ Fetch async data inside a component during SSR. The framework's render loop awai
146
148
  import { useServerData } from 'hadars';
147
149
 
148
150
  const UserCard = ({ userId }: { userId: string }) => {
149
- const user = useServerData(['user', userId], () => db.getUser(userId));
151
+ const user = useServerData(() => db.getUser(userId));
150
152
  if (!user) return null; // undefined while pending on the first SSR pass
151
153
  return <p>{user.name}</p>;
152
154
  };
153
155
  ```
154
156
 
155
- - **`key`** - string or string array; must be stable and unique within the page
157
+ The cache key is derived automatically from the call-site's position in the component tree via `useId()` no manual key is needed.
158
+
156
159
  - **Server (SSR)** - calls `fn()`, awaits the result across render iterations, returns `undefined` until resolved
157
160
  - **Client (hydration)** - reads the pre-resolved value from the hydration cache serialised by the server; `fn()` is never called in the browser
158
- - **Client (navigation)** - when a component mounts during client-side navigation and its key is not in the cache, hadars fires a single `GET <current-url>` with `Accept: application/json`; all `useServerData` calls within the same render are batched into one request and suspended via React Suspense until the server returns the JSON data map
161
+ - **Client (navigation)** - when a component mounts during client-side navigation and its data is not in the cache, hadars fires a single `GET <current-url>` with `Accept: application/json`; all `useServerData` calls within the same render are batched into one request and suspended via React Suspense until the server returns the JSON data map
162
+
163
+ ### Options
164
+
165
+ | Option | Type | Default | Description |
166
+ |---|---|---|---|
167
+ | `cache` | `boolean` | `true` | When `false`, the cached value is evicted when the component unmounts so the next mount fetches fresh data from the server. Safe with React Strict Mode. |
168
+
169
+ ```tsx
170
+ // Uptime changes every request — evict on unmount so re-mounting always fetches fresh
171
+ const uptime = useServerData(() => process.uptime(), { cache: false });
172
+ ```
159
173
 
160
174
  ## Data lifecycle hooks
161
175
 
@@ -190,6 +204,7 @@ const UserCard = ({ userId }: { userId: string }) => {
190
204
  | `paths` | `function` | - | Returns URL list to pre-render with `hadars export static`; receives `HadarsStaticContext` |
191
205
  | `sources` | `array` | - | Gatsby-compatible source plugins; hadars infers a GraphQL schema from their nodes |
192
206
  | `graphql` | `function` | - | Custom GraphQL executor passed to `paths()` and `getInitProps()` as `ctx.graphql` |
207
+ | `onError` | `function` | - | Called on every SSR render error; use to forward to Sentry, Datadog, etc. |
193
208
 
194
209
  ### moduleRules example
195
210
 
@@ -225,6 +240,24 @@ const config: HadarsOptions = {
225
240
  export default config;
226
241
  ```
227
242
 
243
+ ### Error monitoring example
244
+
245
+ ```ts
246
+ import * as Sentry from '@sentry/node';
247
+ import type { HadarsOptions } from 'hadars';
248
+
249
+ const config: HadarsOptions = {
250
+ entry: 'src/App.tsx',
251
+ onError: (err, req) => Sentry.captureException(err, {
252
+ extra: { url: req.url, method: req.method },
253
+ }),
254
+ };
255
+
256
+ export default config;
257
+ ```
258
+
259
+ `onError` is called on every SSR render error in `dev()`, `run()`, Lambda, and Cloudflare adapters. The handler may be async — hadars fires it without awaiting so it never delays the error response to the browser.
260
+
228
261
  ## Static Export
229
262
 
230
263
  > **Experimental.** Static export and Gatsby-compatible source plugins are new features. The API — including config shape, context object, and schema inference behaviour — may change in future releases without a major version bump.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  renderPreflight,
3
3
  renderToString
4
- } from "./chunk-LY5MTHFV.js";
4
+ } from "./chunk-TV37IMRB.js";
5
5
  import {
6
6
  createElement
7
7
  } from "./chunk-OS3V4CPN.js";
@@ -230,18 +230,18 @@ var makePrecontentHtmlGetter = (htmlFilePromise) => {
230
230
  let preHead = null;
231
231
  let postHead = null;
232
232
  let postContent = null;
233
+ const primed = htmlFilePromise.then((html) => {
234
+ const headEnd = html.indexOf(HEAD_MARKER);
235
+ const contentStart = html.indexOf(BODY_MARKER);
236
+ preHead = html.slice(0, headEnd);
237
+ postHead = html.slice(headEnd + HEAD_MARKER.length, contentStart);
238
+ postContent = html.slice(contentStart + BODY_MARKER.length);
239
+ });
233
240
  return (headHtml) => {
234
241
  if (preHead !== null) {
235
242
  return [preHead + headHtml + postHead, postContent];
236
243
  }
237
- return htmlFilePromise.then((html) => {
238
- const headEnd = html.indexOf(HEAD_MARKER);
239
- const contentStart = html.indexOf(BODY_MARKER);
240
- preHead = html.slice(0, headEnd);
241
- postHead = html.slice(headEnd + HEAD_MARKER.length, contentStart);
242
- postContent = html.slice(contentStart + BODY_MARKER.length);
243
- return [preHead + headHtml + postHead, postContent];
244
- });
244
+ return primed.then(() => [preHead + headHtml + postHead, postContent]);
245
245
  };
246
246
  };
247
247
  async function transformStream(data, stream) {
@@ -115,7 +115,16 @@ function componentCalledUseId() {
115
115
  function snapshotContext() {
116
116
  const st = s();
117
117
  const ctx = st.currentTreeContext;
118
- return { tree: { id: ctx.id, overflow: ctx.overflow }, localId: st.localIdCounter, treeDepth: _treeDepth };
118
+ const depth = _treeDepth;
119
+ return {
120
+ tree: { id: ctx.id, overflow: ctx.overflow },
121
+ localId: st.localIdCounter,
122
+ treeDepth: depth,
123
+ // Snapshot the live stack so that popTreeContext reads correct saved values
124
+ // even if another concurrent render's resetRenderState stomped the arrays.
125
+ idStack: _treeIdStack.slice(0, depth),
126
+ ovStack: _treeOvStack.slice(0, depth)
127
+ };
119
128
  }
120
129
  function restoreContext(snap) {
121
130
  const st = s();
@@ -124,6 +133,10 @@ function restoreContext(snap) {
124
133
  ctx.overflow = snap.tree.overflow;
125
134
  st.localIdCounter = snap.localId;
126
135
  _treeDepth = snap.treeDepth;
136
+ for (let i = 0; i < snap.treeDepth; i++) {
137
+ _treeIdStack[i] = snap.idStack[i];
138
+ _treeOvStack[i] = snap.ovStack[i];
139
+ }
127
140
  }
128
141
  function getTreeId() {
129
142
  const { id, overflow } = s().currentTreeContext;
@@ -269,6 +282,14 @@ function restoreDispatcher(prev) {
269
282
  }
270
283
 
271
284
  // src/slim-react/render.ts
285
+ function captureRenderCtx() {
286
+ return { m: captureMap(), u: captureUnsuspend(), t: snapshotContext() };
287
+ }
288
+ function restoreRenderCtx(ctx) {
289
+ swapContextMap(ctx.m);
290
+ restoreUnsuspend(ctx.u);
291
+ restoreContext(ctx.t);
292
+ }
272
293
  var VOID_ELEMENTS = /* @__PURE__ */ new Set([
273
294
  "area",
274
295
  "base",
@@ -712,11 +733,9 @@ function renderComponent(type, props, writer, isSvg, _suspenseRetries = 0) {
712
733
  if (e && typeof e.then === "function") {
713
734
  if (_suspenseRetries + 1 >= MAX_COMPONENT_SUSPENSE_RETRIES) throw SUSPENSE_RETRY_LIMIT;
714
735
  patchPromiseStatus(e);
715
- const m = captureMap();
716
- const u = captureUnsuspend();
736
+ const rctx = captureRenderCtx();
717
737
  return e.then(() => {
718
- swapContextMap(m);
719
- restoreUnsuspend(u);
738
+ restoreRenderCtx(rctx);
720
739
  return renderComponent(type, props, writer, isSvg, _suspenseRetries + 1);
721
740
  });
722
741
  }
@@ -753,17 +772,14 @@ function renderComponent(type, props, writer, isSvg, _suspenseRetries = 0) {
753
772
  };
754
773
  const r2 = renderChildren(props.children, writer, isSvg);
755
774
  if (r2 && typeof r2.then === "function") {
756
- const m = captureMap();
757
- const u = captureUnsuspend();
775
+ const rctx = captureRenderCtx();
758
776
  return r2.then(
759
777
  () => {
760
- swapContextMap(m);
761
- restoreUnsuspend(u);
778
+ restoreRenderCtx(rctx);
762
779
  finish();
763
780
  },
764
781
  (e) => {
765
- swapContextMap(m);
766
- restoreUnsuspend(u);
782
+ restoreRenderCtx(rctx);
767
783
  finish();
768
784
  throw e;
769
785
  }
@@ -792,11 +808,9 @@ function renderComponent(type, props, writer, isSvg, _suspenseRetries = 0) {
792
808
  if (e && typeof e.then === "function") {
793
809
  if (_suspenseRetries + 1 >= MAX_COMPONENT_SUSPENSE_RETRIES) throw SUSPENSE_RETRY_LIMIT;
794
810
  patchPromiseStatus(e);
795
- const m = captureMap();
796
- const u = captureUnsuspend();
811
+ const rctx = captureRenderCtx();
797
812
  return e.then(() => {
798
- swapContextMap(m);
799
- restoreUnsuspend(u);
813
+ restoreRenderCtx(rctx);
800
814
  return renderComponent(type, props, writer, isSvg, _suspenseRetries + 1);
801
815
  });
802
816
  }
@@ -808,30 +822,25 @@ function renderComponent(type, props, writer, isSvg, _suspenseRetries = 0) {
808
822
  savedIdTree = pushTreeContext(1, 0);
809
823
  }
810
824
  if (result instanceof Promise) {
811
- const m = captureMap();
812
- const u = captureUnsuspend();
825
+ const rctx = captureRenderCtx();
813
826
  return result.then((resolved) => {
814
- swapContextMap(m);
815
- restoreUnsuspend(u);
827
+ restoreRenderCtx(rctx);
816
828
  let asyncSavedIdTree;
817
829
  if (componentCalledUseId()) {
818
830
  asyncSavedIdTree = pushTreeContext(1, 0);
819
831
  }
820
832
  const r2 = renderNode(resolved, writer, isSvg);
821
833
  if (r2 && typeof r2.then === "function") {
822
- const m2 = captureMap();
823
- const u2 = captureUnsuspend();
834
+ const rctx2 = captureRenderCtx();
824
835
  return r2.then(
825
836
  () => {
826
- swapContextMap(m2);
827
- restoreUnsuspend(u2);
837
+ restoreRenderCtx(rctx2);
828
838
  if (asyncSavedIdTree !== void 0) popTreeContext(asyncSavedIdTree);
829
839
  popComponentScope(savedScope);
830
840
  if (isProvider) popContextValue(ctx, prevCtxValue);
831
841
  },
832
842
  (e) => {
833
- swapContextMap(m2);
834
- restoreUnsuspend(u2);
843
+ restoreRenderCtx(rctx2);
835
844
  if (asyncSavedIdTree !== void 0) popTreeContext(asyncSavedIdTree);
836
845
  popComponentScope(savedScope);
837
846
  if (isProvider) popContextValue(ctx, prevCtxValue);
@@ -843,8 +852,7 @@ function renderComponent(type, props, writer, isSvg, _suspenseRetries = 0) {
843
852
  popComponentScope(savedScope);
844
853
  if (isProvider) popContextValue(ctx, prevCtxValue);
845
854
  }, (e) => {
846
- swapContextMap(m);
847
- restoreUnsuspend(u);
855
+ restoreRenderCtx(rctx);
848
856
  popComponentScope(savedScope);
849
857
  if (isProvider) popContextValue(ctx, prevCtxValue);
850
858
  throw e;
@@ -852,19 +860,16 @@ function renderComponent(type, props, writer, isSvg, _suspenseRetries = 0) {
852
860
  }
853
861
  const r = renderNode(result, writer, isSvg);
854
862
  if (r && typeof r.then === "function") {
855
- const m = captureMap();
856
- const u = captureUnsuspend();
863
+ const rctx = captureRenderCtx();
857
864
  return r.then(
858
865
  () => {
859
- swapContextMap(m);
860
- restoreUnsuspend(u);
866
+ restoreRenderCtx(rctx);
861
867
  if (savedIdTree !== void 0) popTreeContext(savedIdTree);
862
868
  popComponentScope(savedScope);
863
869
  if (isProvider) popContextValue(ctx, prevCtxValue);
864
870
  },
865
871
  (e) => {
866
- swapContextMap(m);
867
- restoreUnsuspend(u);
872
+ restoreRenderCtx(rctx);
868
873
  if (savedIdTree !== void 0) popTreeContext(savedIdTree);
869
874
  popComponentScope(savedScope);
870
875
  if (isProvider) popContextValue(ctx, prevCtxValue);
@@ -889,11 +894,9 @@ function renderChildArrayFrom(children, startIndex, writer, isSvg) {
889
894
  const savedTree = pushTreeContext(totalChildren, i);
890
895
  const r = renderNode(child, writer, isSvg);
891
896
  if (r && typeof r.then === "function") {
892
- const m = captureMap();
893
- const u = captureUnsuspend();
897
+ const rctx = captureRenderCtx();
894
898
  return r.then(() => {
895
- swapContextMap(m);
896
- restoreUnsuspend(u);
899
+ restoreRenderCtx(rctx);
897
900
  popTreeContext(savedTree);
898
901
  return renderChildArrayFrom(children, i + 1, writer, isSvg);
899
902
  });
@@ -917,11 +920,9 @@ async function renderSuspense(props, writer, isSvg = false) {
917
920
  try {
918
921
  const r = renderNode(children, buffer, isSvg);
919
922
  if (r && typeof r.then === "function") {
920
- const m = captureMap();
921
- const u = captureUnsuspend();
923
+ const rctx = captureRenderCtx();
922
924
  await r;
923
- swapContextMap(m);
924
- restoreUnsuspend(u);
925
+ restoreRenderCtx(rctx);
925
926
  }
926
927
  writer.write("<!--$-->");
927
928
  buffer.flushTo(writer);
@@ -935,11 +936,9 @@ async function renderSuspense(props, writer, isSvg = false) {
935
936
  if (fallback) {
936
937
  const r = renderNode(fallback, writer, isSvg);
937
938
  if (r && typeof r.then === "function") {
938
- const m = captureMap();
939
- const u = captureUnsuspend();
939
+ const rctx = captureRenderCtx();
940
940
  await r;
941
- swapContextMap(m);
942
- restoreUnsuspend(u);
941
+ restoreRenderCtx(rctx);
943
942
  }
944
943
  }
945
944
  writer.write("<!--/$-->");
@@ -976,9 +975,9 @@ function renderToStream(element, options) {
976
975
  try {
977
976
  const r = renderNode(element, writer);
978
977
  if (r && typeof r.then === "function") {
979
- const m = captureMap();
978
+ const rctx = captureRenderCtx();
980
979
  await r;
981
- swapContextMap(m);
980
+ restoreRenderCtx(rctx);
982
981
  }
983
982
  writer.flush();
984
983
  controller.close();
@@ -1005,9 +1004,9 @@ async function renderPreflight(element, options) {
1005
1004
  NULL_WRITER.lastWasText = false;
1006
1005
  const r = renderNode(element, NULL_WRITER);
1007
1006
  if (r && typeof r.then === "function") {
1008
- const m = captureMap();
1007
+ const rctx = captureRenderCtx();
1009
1008
  await r;
1010
- swapContextMap(m);
1009
+ restoreRenderCtx(rctx);
1011
1010
  }
1012
1011
  } finally {
1013
1012
  swapContextMap(prev);
@@ -1032,9 +1031,9 @@ async function renderToString(element, options) {
1032
1031
  resetRenderState(idPrefix);
1033
1032
  const r = renderNode(element, writer);
1034
1033
  if (r && typeof r.then === "function") {
1035
- const m = captureMap();
1034
+ const rctx = captureRenderCtx();
1036
1035
  await r;
1037
- swapContextMap(m);
1036
+ restoreRenderCtx(rctx);
1038
1037
  }
1039
1038
  return output;
1040
1039
  } finally {