hadars 0.1.17 → 0.1.18

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/ssr-watch.js CHANGED
@@ -171,25 +171,21 @@ var buildCompilerConfig = (entry2, opts, includeHotPlugin) => {
171
171
  const isServerBuild = Boolean(
172
172
  opts.output && typeof opts.output === "object" && (opts.output.library || String(opts.output.filename || "").includes("ssr"))
173
173
  );
174
+ const slimReactIndex = pathMod.resolve(packageDir, "slim-react", "index.js");
175
+ const slimReactJsx = pathMod.resolve(packageDir, "slim-react", "jsx-runtime.js");
174
176
  const resolveAliases = isServerBuild ? {
175
- // force all react imports to resolve to this project's react
176
- react: path.resolve(process.cwd(), "node_modules", "react"),
177
- "react-dom": path.resolve(process.cwd(), "node_modules", "react-dom"),
178
- // also map react/jsx-runtime to avoid duplicates when automatic runtime is used
179
- "react/jsx-runtime": path.resolve(process.cwd(), "node_modules", "react", "jsx-runtime.js"),
180
- "react/jsx-dev-runtime": path.resolve(process.cwd(), "node_modules", "react", "jsx-dev-runtime.js"),
181
- // ensure emotion packages resolve to the project's node_modules so we don't pick up a browser-specific entry
177
+ // Route all React imports to slim-react for SSR.
178
+ react: slimReactIndex,
179
+ "react/jsx-runtime": slimReactJsx,
180
+ "react/jsx-dev-runtime": slimReactJsx,
181
+ // Keep emotion on the project's node_modules (server-safe entry).
182
182
  "@emotion/react": path.resolve(process.cwd(), "node_modules", "@emotion", "react"),
183
183
  "@emotion/server": path.resolve(process.cwd(), "node_modules", "@emotion", "server"),
184
184
  "@emotion/cache": path.resolve(process.cwd(), "node_modules", "@emotion", "cache"),
185
185
  "@emotion/styled": path.resolve(process.cwd(), "node_modules", "@emotion", "styled")
186
186
  } : void 0;
187
187
  const externals = isServerBuild ? [
188
- "react",
189
- "react-dom",
190
- // keep common aliases external as well
191
- "react/jsx-runtime",
192
- "react/jsx-dev-runtime",
188
+ // react / react-dom are replaced by slim-react via alias above — not external.
193
189
  // emotion should be external on server builds to avoid client/browser code
194
190
  "@emotion/react",
195
191
  "@emotion/server",
@@ -244,8 +240,33 @@ var buildCompilerConfig = (entry2, opts, includeHotPlugin) => {
244
240
  template: opts.htmlTemplate ? pathMod.resolve(process.cwd(), opts.htmlTemplate) : clientScriptPath,
245
241
  scriptLoading: "module",
246
242
  filename: "out.html",
247
- inject: "body"
243
+ inject: "head",
244
+ minify: opts.mode === "production"
248
245
  }),
246
+ // Add `async` to the emitted module script so DOMContentLoaded fires
247
+ // as soon as HTML is parsed — without waiting for the bundle to execute.
248
+ // `<script type="module" async>` is valid: it downloads in parallel and
249
+ // executes without blocking DOMContentLoaded, while retaining module
250
+ // semantics (strict mode, ES imports, etc.).
251
+ {
252
+ apply(compiler) {
253
+ compiler.hooks.emit.tapAsync("HadarsAsyncModuleScript", (compilation, cb) => {
254
+ const asset = compilation.assets["out.html"];
255
+ if (asset) {
256
+ const html = asset.source();
257
+ const updated = html.replace(
258
+ /(<script\b[^>]*\btype="module"[^>]*)(>)/g,
259
+ (match, before, end) => before.includes("async") ? match : `${before} async${end}`
260
+ );
261
+ compilation.assets["out.html"] = {
262
+ source: () => updated,
263
+ size: () => Buffer.byteLength(updated)
264
+ };
265
+ }
266
+ cb();
267
+ });
268
+ }
269
+ },
249
270
  isDev && new ReactRefreshPlugin(),
250
271
  includeHotPlugin && isDev && new rspack.HotModuleReplacementPlugin(),
251
272
  ...extraPlugins
@@ -273,8 +273,7 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
273
273
  () => { unsuspend.cache.set(cacheKey, { status: 'suspense-resolved' }); },
274
274
  );
275
275
  unsuspend.cache.set(cacheKey, { status: 'pending', promise: suspensePromise });
276
- unsuspend.hasPending = true;
277
- return undefined;
276
+ throw suspensePromise; // slim-react will await and retry
278
277
  }
279
278
  throw thrown;
280
279
  }
@@ -294,12 +293,10 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
294
293
  reason => { unsuspend.cache.set(cacheKey, { status: 'rejected', reason }); },
295
294
  );
296
295
  unsuspend.cache.set(cacheKey, { status: 'pending', promise });
297
- unsuspend.hasPending = true;
298
- return undefined;
296
+ throw promise; // slim-react will await and retry
299
297
  }
300
298
  if (existing.status === 'pending') {
301
- unsuspend.hasPending = true;
302
- return undefined;
299
+ throw existing.promise; // slim-react will await and retry
303
300
  }
304
301
  if (existing.status === 'rejected') throw existing.reason;
305
302
  return existing.value as T;
package/index.ts CHANGED
@@ -12,4 +12,4 @@ export type {
12
12
  HadarsEntryModule,
13
13
  HadarsApp,
14
14
  } from "./src/types/hadars";
15
- export { HadarsHead, HadarsContext, loadModule } from "./src/index";
15
+ export { HadarsHead, HadarsContext, loadModule, CacheSegment, deleteSegment, clearSegments } from "./src/index";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hadars",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Minimal SSR framework for React — rspack, HMR, TypeScript, Bun/Node/Deno",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -29,7 +29,7 @@
29
29
  }
30
30
  },
31
31
  "scripts": {
32
- "build:lib": "tsup src/index.tsx --format esm,cjs --dts --out-dir dist --clean --external '@rspack/*' --external '@rspack/binding'",
32
+ "build:lib": "tsup src/index.tsx src/slim-react/index.ts src/slim-react/jsx-runtime.ts --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
35
  "test": "bun test test/ssr.test.ts",
package/src/build.ts CHANGED
@@ -9,7 +9,6 @@ import { isBun, isDeno, isNode } from "./utils/runtime";
9
9
  import { RspackDevServer } from "@rspack/dev-server";
10
10
  import pathMod from "node:path";
11
11
  import { fileURLToPath, pathToFileURL } from 'node:url';
12
- import { createRequire } from 'node:module';
13
12
  import crypto from 'node:crypto';
14
13
  import fs from 'node:fs/promises';
15
14
  import { existsSync } from 'node:fs';
@@ -17,6 +16,7 @@ import os from 'node:os';
17
16
  import { spawn } from 'node:child_process';
18
17
  import cluster from 'node:cluster';
19
18
  import type { HadarsEntryModule, HadarsOptions, HadarsProps } from "./types/hadars";
19
+ import { processSegmentCache } from "./utils/segmentCache";
20
20
  const encoder = new TextEncoder();
21
21
 
22
22
  /**
@@ -73,17 +73,7 @@ async function processHtmlTemplate(templatePath: string): Promise<string> {
73
73
  const HEAD_MARKER = '<meta name="HADARS_HEAD">';
74
74
  const BODY_MARKER = '<meta name="HADARS_BODY">';
75
75
 
76
- // Resolve renderToString from react-dom/server in the project's node_modules.
77
- let _renderToString: ((element: any) => string) | null = null;
78
- async function getRenderToString(): Promise<(element: any) => string> {
79
- if (!_renderToString) {
80
- const req = createRequire(pathMod.resolve(process.cwd(), '__hadars_fake__.js'));
81
- const resolved = req.resolve('react-dom/server');
82
- const mod = await import(pathToFileURL(resolved).href);
83
- _renderToString = mod.renderToString;
84
- }
85
- return _renderToString!;
86
- }
76
+ import { renderToString as slimRenderToString } from './slim-react/index';
87
77
 
88
78
  // Round-robin thread pool for SSR rendering — used on Bun/Deno where
89
79
  // node:cluster is not available but node:worker_threads is.
@@ -210,26 +200,21 @@ async function buildSsrResponse(
210
200
  getPrecontentHtml: (headHtml: string) => Promise<[string, string]>,
211
201
  unsuspendForRender: any,
212
202
  ): Promise<Response> {
213
- // Pre-load renderer before starting the stream so the set→call→clear
214
- // sequence around __hadarsUnsuspend is fully synchronous (no await between them).
215
- const renderToString = await getRenderToString();
216
-
217
203
  const responseStream = new ReadableStream({
218
204
  async start(controller) {
219
205
  const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
220
206
  // Flush the shell (precontentHtml) immediately so the browser can
221
207
  // start loading CSS/fonts before renderToString blocks the thread.
222
208
  controller.enqueue(encoder.encode(precontentHtml));
223
- await Promise.resolve(); // yield to let the runtime flush the shell chunk
224
209
 
225
- // set → call (synchronous) → clear: no await in between, safe under concurrency
226
210
  let bodyHtml: string;
227
211
  try {
228
212
  (globalThis as any).__hadarsUnsuspend = unsuspendForRender;
229
- bodyHtml = renderToString(ReactPage);
213
+ bodyHtml = await slimRenderToString(ReactPage);
230
214
  } finally {
231
215
  (globalThis as any).__hadarsUnsuspend = null;
232
216
  }
217
+ bodyHtml = processSegmentCache(bodyHtml);
233
218
  controller.enqueue(encoder.encode(bodyHtml + postContent));
234
219
  controller.close();
235
220
  },
@@ -697,7 +682,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
697
682
  getFinalProps,
698
683
  } = (await import(importPath)) as HadarsEntryModule<any>;
699
684
 
700
- const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
685
+ const { ReactPage, unsuspend, status, headHtml } = await getReactResponse(request, {
701
686
  document: {
702
687
  body: Component as React.FC<HadarsProps<object>>,
703
688
  lang: 'en',
@@ -707,7 +692,6 @@ export const dev = async (options: HadarsRuntimeOptions) => {
707
692
  },
708
693
  });
709
694
 
710
- const unsuspend = (renderPayload.appProps.context as any)?._unsuspend ?? null;
711
695
  return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
712
696
  } catch (err: any) {
713
697
  console.error('[hadars] SSR render error:', err);
@@ -885,7 +869,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
885
869
  });
886
870
  }
887
871
 
888
- const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
872
+ const { ReactPage, unsuspend, status, headHtml } = await getReactResponse(request, {
889
873
  document: {
890
874
  body: Component as React.FC<HadarsProps<object>>,
891
875
  lang: 'en',
@@ -895,7 +879,6 @@ export const run = async (options: HadarsRuntimeOptions) => {
895
879
  },
896
880
  });
897
881
 
898
- const unsuspend = (renderPayload.appProps.context as any)?._unsuspend ?? null;
899
882
  return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
900
883
  } catch (err: any) {
901
884
  console.error('[hadars] SSR render error:', err);
@@ -0,0 +1,67 @@
1
+ import React from 'react';
2
+ import { getSegment, CACHE_TAG } from '../utils/segmentCache';
3
+
4
+ interface CacheSegmentProps {
5
+ /**
6
+ * Unique cache key for this segment. Use a key that encodes all values
7
+ * the output depends on, e.g. `"product-" + product.id`.
8
+ */
9
+ cacheKey: string;
10
+ /**
11
+ * Time-to-live in milliseconds. Omit for entries that never expire.
12
+ */
13
+ ttl?: number;
14
+ children: React.ReactNode;
15
+ }
16
+
17
+ /**
18
+ * Caches the server-rendered HTML of its children across requests.
19
+ *
20
+ * **Server (SSR):**
21
+ * - Cache miss — children are rendered normally as part of the main
22
+ * `renderToString` call, so React context propagates correctly. The output
23
+ * is wrapped in a `<hadars-c>` marker that `processSegmentCache` uses to
24
+ * extract and store the HTML. The marker is stripped before the response
25
+ * is sent; the browser never sees it.
26
+ * - Cache hit — children are **not** rendered at all. The cached HTML is
27
+ * injected directly, saving the entire subtree render cost.
28
+ *
29
+ * **Client:** renders children normally (no caching). Because the server
30
+ * strips the marker wrapper, the client output matches the server HTML and
31
+ * React hydration succeeds without warnings for deterministic components.
32
+ *
33
+ * **Note:** components that rely on request-specific data (cookies, auth,
34
+ * personalisation) must not be wrapped in `CacheSegment` unless the cache
35
+ * key encodes that data — otherwise a cached response for one user could be
36
+ * served to another.
37
+ */
38
+ export function CacheSegment({ cacheKey, ttl, children }: CacheSegmentProps) {
39
+ // Client: render children normally — no server cache on the client.
40
+ if (typeof window !== 'undefined') {
41
+ return <>{children}</>;
42
+ }
43
+
44
+ const cached = getSegment(cacheKey);
45
+
46
+ if (cached !== null) {
47
+ // Cache hit: skip rendering children entirely.
48
+ // The <hadars-c> wrapper is stripped by processSegmentCache before
49
+ // the response is sent to the browser.
50
+ return React.createElement(CACHE_TAG as any, {
51
+ 'data-key': cacheKey,
52
+ 'data-cache': 'hit',
53
+ dangerouslySetInnerHTML: { __html: cached },
54
+ });
55
+ }
56
+
57
+ // Cache miss: render children as normal React elements so that React
58
+ // context (providers, etc.) propagates correctly into the subtree.
59
+ // processSegmentCache will extract the rendered HTML and store it.
60
+ const props: Record<string, unknown> = {
61
+ 'data-key': cacheKey,
62
+ 'data-cache': 'miss',
63
+ };
64
+ if (ttl != null) props['data-ttl'] = ttl;
65
+
66
+ return React.createElement(CACHE_TAG as any, props, children);
67
+ }
package/src/index.tsx CHANGED
@@ -10,6 +10,8 @@ export type {
10
10
  HadarsApp,
11
11
  } from "./types/hadars";
12
12
  export { Head as HadarsHead, useServerData, initServerDataCache } from './utils/Head';
13
+ export { CacheSegment } from './components/CacheSegment';
14
+ export { deleteSegment, clearSegments } from './utils/segmentCache';
13
15
  import { AppProviderSSR, AppProviderCSR } from "./utils/Head";
14
16
 
15
17
  export const HadarsContext = typeof window === 'undefined' ? AppProviderSSR : AppProviderCSR;
@@ -0,0 +1,52 @@
1
+ import type { SlimNode } from "./types";
2
+
3
+ /**
4
+ * Minimal Context implementation for SSR.
5
+ *
6
+ * Because SSR is single-pass and synchronous within each component,
7
+ * we just track the "current" value on the context object and
8
+ * save / restore around Provider renders (handled by the renderer).
9
+ */
10
+
11
+ export interface Context<T> {
12
+ _currentValue: T;
13
+ Provider: ContextProvider<T>;
14
+ Consumer: (props: { children: (value: T) => SlimNode }) => SlimNode;
15
+ }
16
+
17
+ export type ContextProvider<T> = ((props: {
18
+ value: T;
19
+ children?: SlimNode;
20
+ }) => SlimNode) & {
21
+ _context: Context<T>;
22
+ };
23
+
24
+ export function createContext<T>(defaultValue: T): Context<T> {
25
+ const context: Context<T> = {
26
+ _currentValue: defaultValue,
27
+ Provider: null!,
28
+ Consumer: null!,
29
+ };
30
+
31
+ // Provider is a function component recognised by the renderer.
32
+ // The `_context` tag tells the renderer to push / pop the value.
33
+ const Provider = function ContextProvider({
34
+ children,
35
+ }: {
36
+ value: T;
37
+ children?: SlimNode;
38
+ }): SlimNode {
39
+ return children ?? null;
40
+ } as unknown as ContextProvider<T>;
41
+
42
+ Provider._context = context;
43
+ context.Provider = Provider;
44
+
45
+ context.Consumer = ({ children }) => {
46
+ return (children as unknown as (value: T) => SlimNode)(
47
+ context._currentValue,
48
+ );
49
+ };
50
+
51
+ return context;
52
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * SSR hook implementations.
3
+ *
4
+ * On the server every hook is either a no-op or returns the initial /
5
+ * snapshot value. This is enough for the vast majority of React-
6
+ * compatible libraries to work during server-side rendering.
7
+ */
8
+
9
+ import { makeId } from "./renderContext";
10
+
11
+ // ---- useState ----
12
+ export function useState<T>(
13
+ initialState: T | (() => T),
14
+ ): [T, (value: T | ((prev: T) => T)) => void] {
15
+ const value =
16
+ typeof initialState === "function"
17
+ ? (initialState as () => T)()
18
+ : initialState;
19
+ return [value, () => {}];
20
+ }
21
+
22
+ // ---- useReducer ----
23
+ export function useReducer<S, A>(
24
+ _reducer: (state: S, action: A) => S,
25
+ initialState: S,
26
+ ): [S, (action: A) => void] {
27
+ return [initialState, () => {}];
28
+ }
29
+
30
+ // ---- useEffect / useLayoutEffect / useInsertionEffect ----
31
+ export function useEffect(
32
+ _effect: () => void | (() => void),
33
+ _deps?: any[],
34
+ ) {}
35
+ export function useLayoutEffect(
36
+ _effect: () => void | (() => void),
37
+ _deps?: any[],
38
+ ) {}
39
+ export function useInsertionEffect(
40
+ _effect: () => void | (() => void),
41
+ _deps?: any[],
42
+ ) {}
43
+
44
+ // ---- useRef ----
45
+ export function useRef<T>(initialValue: T): { current: T } {
46
+ return { current: initialValue };
47
+ }
48
+
49
+ // ---- useMemo / useCallback ----
50
+ export function useMemo<T>(factory: () => T, _deps?: any[]): T {
51
+ return factory();
52
+ }
53
+ export function useCallback<T extends Function>(callback: T, _deps?: any[]): T {
54
+ return callback;
55
+ }
56
+
57
+ // ---- useId ----
58
+ export function useId(): string {
59
+ return makeId();
60
+ }
61
+
62
+ // ---- useDebugValue ----
63
+ export function useDebugValue(_value: any, _format?: (v: any) => any) {}
64
+
65
+ // ---- useImperativeHandle ----
66
+ export function useImperativeHandle(
67
+ _ref: any,
68
+ _createHandle: () => any,
69
+ _deps?: any[],
70
+ ) {}
71
+
72
+ // ---- useSyncExternalStore ----
73
+ export function useSyncExternalStore<T>(
74
+ _subscribe: (onStoreChange: () => void) => () => void,
75
+ getSnapshot: () => T,
76
+ getServerSnapshot?: () => T,
77
+ ): T {
78
+ return (getServerSnapshot || getSnapshot)();
79
+ }
80
+
81
+ // ---- useTransition ----
82
+ export function useTransition(): [boolean, (callback: () => void) => void] {
83
+ return [false, (cb) => cb()];
84
+ }
85
+
86
+ // ---- useDeferredValue ----
87
+ export function useDeferredValue<T>(value: T): T {
88
+ return value;
89
+ }
90
+
91
+ // ---- useOptimistic (React 19) ----
92
+ export function useOptimistic<T>(passthrough: T): [T, () => void] {
93
+ return [passthrough, () => {}];
94
+ }
95
+
96
+ // ---- useFormStatus (React 19) ----
97
+ export function useFormStatus() {
98
+ return { pending: false, data: null, method: null, action: null };
99
+ }
100
+
101
+ // ---- useActionState (React 19) ----
102
+ export function useActionState<S>(
103
+ _action: (state: S, payload: any) => S | Promise<S>,
104
+ initialState: S,
105
+ _permalink?: string,
106
+ ): [S, (payload: any) => void, boolean] {
107
+ return [initialState, () => {}, false];
108
+ }
109
+
110
+ // ---- use (React 19 – Suspense integration) ----
111
+ export function use<T>(
112
+ usable: (Promise<T> & { status?: string; value?: T; reason?: any }) | { _currentValue: T },
113
+ ): T {
114
+ // Context object
115
+ if (
116
+ typeof usable === "object" &&
117
+ usable !== null &&
118
+ "_currentValue" in usable
119
+ ) {
120
+ return (usable as { _currentValue: T })._currentValue;
121
+ }
122
+
123
+ // Promise – Suspense protocol
124
+ const promise = usable as Promise<T> & {
125
+ status?: string;
126
+ value?: T;
127
+ reason?: any;
128
+ };
129
+ if (promise.status === "fulfilled") return promise.value!;
130
+ if (promise.status === "rejected") throw promise.reason;
131
+ throw promise; // caught by the nearest Suspense boundary
132
+ }
133
+
134
+ // ---- startTransition ----
135
+ export function startTransition(callback: () => void) {
136
+ callback();
137
+ }