hadars 0.1.17 → 0.1.19

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.
@@ -216,26 +216,27 @@ const buildCompilerConfig = (
216
216
  (opts.output && typeof opts.output === 'object' && (opts.output.library || String(opts.output.filename || '').includes('ssr')))
217
217
  );
218
218
 
219
+ // slim-react: the SSR-only React-compatible renderer bundled with hadars.
220
+ // On server builds we replace the real React with slim-react so that hooks
221
+ // get safe SSR stubs, context works, and renderToStream / Suspense are
222
+ // natively supported. The client build is untouched and uses real React.
223
+ const slimReactIndex = pathMod.resolve(packageDir, 'slim-react', 'index.js');
224
+ const slimReactJsx = pathMod.resolve(packageDir, 'slim-react', 'jsx-runtime.js');
225
+
219
226
  const resolveAliases: Record<string, string> | undefined = isServerBuild ? {
220
- // force all react imports to resolve to this project's react
221
- react: path.resolve(process.cwd(), 'node_modules', 'react'),
222
- 'react-dom': path.resolve(process.cwd(), 'node_modules', 'react-dom'),
223
- // also map react/jsx-runtime to avoid duplicates when automatic runtime is used
224
- 'react/jsx-runtime': path.resolve(process.cwd(), 'node_modules', 'react', 'jsx-runtime.js'),
225
- 'react/jsx-dev-runtime': path.resolve(process.cwd(), 'node_modules', 'react', 'jsx-dev-runtime.js'),
226
- // ensure emotion packages resolve to the project's node_modules so we don't pick up a browser-specific entry
227
- '@emotion/react': path.resolve(process.cwd(), 'node_modules', '@emotion', 'react'),
227
+ // Route all React imports to slim-react for SSR.
228
+ react: slimReactIndex,
229
+ 'react/jsx-runtime': slimReactJsx,
230
+ 'react/jsx-dev-runtime': slimReactJsx,
231
+ // Keep emotion on the project's node_modules (server-safe entry).
232
+ '@emotion/react': path.resolve(process.cwd(), 'node_modules', '@emotion', 'react'),
228
233
  '@emotion/server': path.resolve(process.cwd(), 'node_modules', '@emotion', 'server'),
229
- '@emotion/cache': path.resolve(process.cwd(), 'node_modules', '@emotion', 'cache'),
234
+ '@emotion/cache': path.resolve(process.cwd(), 'node_modules', '@emotion', 'cache'),
230
235
  '@emotion/styled': path.resolve(process.cwd(), 'node_modules', '@emotion', 'styled'),
231
236
  } : undefined;
232
237
 
233
238
  const externals = isServerBuild ? [
234
- 'react',
235
- 'react-dom',
236
- // keep common aliases external as well
237
- 'react/jsx-runtime',
238
- 'react/jsx-dev-runtime',
239
+ // react / react-dom are replaced by slim-react via alias above — not external.
239
240
  // emotion should be external on server builds to avoid client/browser code
240
241
  '@emotion/react',
241
242
  '@emotion/server',
@@ -301,8 +302,34 @@ const buildCompilerConfig = (
301
302
  : clientScriptPath,
302
303
  scriptLoading: 'module',
303
304
  filename: 'out.html',
304
- inject: 'body',
305
+ inject: 'head',
306
+ minify: opts.mode === 'production',
305
307
  }),
308
+ // Add `async` to the emitted module script so DOMContentLoaded fires
309
+ // as soon as HTML is parsed — without waiting for the bundle to execute.
310
+ // `<script type="module" async>` is valid: it downloads in parallel and
311
+ // executes without blocking DOMContentLoaded, while retaining module
312
+ // semantics (strict mode, ES imports, etc.).
313
+ {
314
+ apply(compiler: any) {
315
+ compiler.hooks.emit.tapAsync('HadarsAsyncModuleScript', (compilation: any, cb: () => void) => {
316
+ const asset = compilation.assets['out.html'];
317
+ if (asset) {
318
+ const html: string = asset.source();
319
+ const updated = html.replace(
320
+ /(<script\b[^>]*\btype="module"[^>]*)(>)/g,
321
+ (match, before: string, end: string) =>
322
+ before.includes('async') ? match : `${before} async${end}`,
323
+ );
324
+ compilation.assets['out.html'] = {
325
+ source: () => updated,
326
+ size: () => Buffer.byteLength(updated),
327
+ };
328
+ }
329
+ cb();
330
+ });
331
+ },
332
+ },
306
333
  isDev && new ReactRefreshPlugin(),
307
334
  includeHotPlugin && isDev && new rspack.HotModuleReplacementPlugin(),
308
335
  ...extraPlugins,
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Server-side segment cache for CacheSegment.
3
+ *
4
+ * The store lives on globalThis so all module instances (framework source,
5
+ * compiled dist, user SSR bundle) share the exact same Map regardless of
6
+ * how `hadars` was resolved by Node's module loader.
7
+ */
8
+
9
+ interface SegmentEntry {
10
+ html: string;
11
+ expiresAt: number | null;
12
+ }
13
+
14
+ function getStore(): Map<string, SegmentEntry> {
15
+ const g = globalThis as any;
16
+ if (!g.__hadarsSegmentStore) {
17
+ g.__hadarsSegmentStore = new Map<string, SegmentEntry>();
18
+ }
19
+ return g.__hadarsSegmentStore;
20
+ }
21
+
22
+ export function getSegment(key: string): string | null {
23
+ const entry = getStore().get(key);
24
+ if (!entry) return null;
25
+ if (entry.expiresAt !== null && Date.now() >= entry.expiresAt) {
26
+ getStore().delete(key);
27
+ return null;
28
+ }
29
+ return entry.html;
30
+ }
31
+
32
+ export function setSegment(key: string, html: string, ttl?: number): void {
33
+ getStore().set(key, {
34
+ html,
35
+ expiresAt: ttl != null ? Date.now() + ttl : null,
36
+ });
37
+ }
38
+
39
+ export function deleteSegment(key: string): void {
40
+ getStore().delete(key);
41
+ }
42
+
43
+ export function clearSegments(): void {
44
+ getStore().clear();
45
+ }
46
+
47
+ /**
48
+ * Custom element name used as a boundary marker in the rendered HTML.
49
+ * Must contain a hyphen (valid custom element). Stripped by processSegmentCache
50
+ * before the response is sent — the browser never sees this tag.
51
+ */
52
+ export const CACHE_TAG = 'hadars-c';
53
+
54
+ /**
55
+ * Post-processes the HTML string produced by renderToString:
56
+ *
57
+ * - **Cache miss** markers (`data-cache="miss"`): extract inner HTML, store it
58
+ * in the segment cache, strip the wrapper tag.
59
+ * - **Cache hit** markers (`data-cache="hit"`): strip the wrapper tag (the
60
+ * cached HTML is already the element content via dangerouslySetInnerHTML).
61
+ *
62
+ * Nested `CacheSegment` components are handled correctly because the
63
+ * non-greedy regex naturally matches the innermost tag first, and we iterate
64
+ * until the string stabilises.
65
+ */
66
+ export function processSegmentCache(html: string): string {
67
+ let prev: string;
68
+ do {
69
+ prev = html;
70
+ html = html.replace(
71
+ /<hadars-c([^>]*)>([\s\S]*?)<\/hadars-c>/g,
72
+ (match, attrs: string, content: string) => {
73
+ const cacheM = /data-cache="([^"]+)"/.exec(attrs);
74
+ const keyM = /data-key="([^"]+)"/.exec(attrs);
75
+ const ttlM = /data-ttl="(\d+)"/.exec(attrs);
76
+ if (!cacheM || !keyM) return match;
77
+ if (cacheM[1] === 'miss') {
78
+ setSegment(keyM[1]!, content, ttlM ? Number(ttlM[1]) : undefined);
79
+ return content;
80
+ }
81
+ if (cacheM[1] === 'hit') return content;
82
+ return match;
83
+ },
84
+ );
85
+ } while (html !== prev);
86
+ return html;
87
+ }