hadars 0.2.0 → 0.2.1

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.
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import type { AppContext as HadarsAppContext, AppUnsuspend, LinkProps, MetaProps, ScriptProps, StyleProps } from '../types/hadars'
2
+ import type { AppHead, AppUnsuspend, LinkProps, MetaProps, ScriptProps, StyleProps } from '../types/hadars'
3
3
 
4
4
  interface InnerContext {
5
5
  setTitle: (title: string) => void;
@@ -41,142 +41,95 @@ function deriveKey(tag: string, props: Record<string, any>): string {
41
41
  }
42
42
  }
43
43
 
44
- const AppContext = React.createContext<InnerContext>({
45
- setTitle: () => {
46
- console.warn('AppContext: setTitle called outside of provider');
47
- },
48
- addMeta: () => {
49
- console.warn('AppContext: addMeta called outside of provider');
50
- },
51
- addLink: () => {
52
- console.warn('AppContext: addLink called outside of provider');
53
- },
54
- addStyle: () => {
55
- console.warn('AppContext: addStyle called outside of provider');
56
- },
57
- addScript: () => {
58
- console.warn('AppContext: addScript called outside of provider');
59
- },
60
- setStatus: () => { },
61
- });
62
-
63
- export const AppProviderSSR: React.FC<{
64
- children: React.ReactNode,
65
- context: HadarsAppContext,
66
- }> = React.memo( ({ children, context }) => {
67
-
68
- const { head } = context;
69
-
70
- // mutate seoData
71
- const setTitle = React.useCallback((title: string) => {
72
- head.title = title;
73
- }, [head]);
74
- const addMeta = React.useCallback((props: MetaProps) => {
75
- head.meta[deriveKey('meta', props as any)] = props;
76
- }, [head]);
77
- const addLink = React.useCallback((props: LinkProps) => {
78
- head.link[deriveKey('link', props as any)] = props;
79
- }, [head]);
80
- const addStyle = React.useCallback((props: StyleProps) => {
81
- head.style[deriveKey('style', props as any)] = props;
82
- }, [head]);
83
- const addScript = React.useCallback((props: ScriptProps) => {
84
- head.script[deriveKey('script', props as any)] = props;
85
- }, [head]);
86
-
87
- const setStatus = React.useCallback((status: number) => {
88
- head.status = status;
89
- }, [head]);
90
-
91
- const contextValue: InnerContext = React.useMemo(() => ({
92
- setTitle,
93
- addMeta,
94
- addLink,
95
- addStyle,
96
- addScript,
97
- setStatus,
98
- }), [ setTitle, addMeta, addLink, addStyle, addScript, setStatus]);
99
- return (
100
- <AppContext.Provider value={contextValue}>
101
- {children}
102
- </AppContext.Provider>
103
- );
104
- } );
44
+ // ── Head context resolution ──────────────────────────────────────────────────
45
+ //
46
+ // On the server, HadarsHead reads from globalThis.__hadarsContext.head which
47
+ // the SSR render worker populates before every renderToString call.
48
+ // On the client, HadarsHead directly manipulates the DOM.
49
+ // This approach means users do NOT need to wrap their App with HadarsContext.
50
+
51
+ const LINK_ATTR: Record<string, string> = {
52
+ crossOrigin: 'crossorigin',
53
+ referrerPolicy: 'referrerpolicy',
54
+ fetchPriority: 'fetchpriority',
55
+ hrefLang: 'hreflang',
56
+ };
57
+
58
+ function makeServerCtx(head: AppHead): InnerContext {
59
+ return {
60
+ setTitle: (t) => { head.title = t; },
61
+ addMeta: (p) => { head.meta[deriveKey('meta', p as any)] = p; },
62
+ addLink: (p) => { head.link[deriveKey('link', p as any)] = p; },
63
+ addStyle: (p) => { head.style[deriveKey('style', p as any)] = p; },
64
+ addScript: (p) => { head.script[deriveKey('script', p as any)] = p; },
65
+ setStatus: (s) => { head.status = s; },
66
+ };
67
+ }
105
68
 
106
- export const AppProviderCSR: React.FC<{
107
- children: React.ReactNode
108
- }> = React.memo( ({ children }) => {
109
-
110
- const setTitle = React.useCallback((title: string) => {
111
- document.title = title;
112
- }, []);
113
-
114
- const addMeta = React.useCallback((props: MetaProps) => {
115
- const p = props as Record<string, any>;
116
- let meta: HTMLMetaElement | null = null;
117
- if (p.name) meta = document.querySelector(`meta[name="${CSS.escape(p.name)}"]`);
118
- else if (p.property) meta = document.querySelector(`meta[property="${CSS.escape(p.property)}"]`);
119
- else if (p.httpEquiv ?? p['http-equiv']) meta = document.querySelector(`meta[http-equiv="${CSS.escape(p.httpEquiv ?? p['http-equiv'])}"]`);
120
- else if ('charSet' in p || 'charset' in p) meta = document.querySelector('meta[charset]');
121
- if (!meta) { meta = document.createElement('meta'); document.head.appendChild(meta); }
122
- for (const [k, v] of Object.entries(p)) {
123
- if (v != null && v !== false) meta.setAttribute(k === 'charSet' ? 'charset' : k === 'httpEquiv' ? 'http-equiv' : k, String(v));
124
- }
125
- }, []);
126
-
127
- const addLink = React.useCallback((props: LinkProps) => {
128
- const p = props as Record<string, any>;
129
- let link: HTMLLinkElement | null = null;
130
- const asSel = p.as ? `[as="${CSS.escape(p.as)}"]` : '';
131
- if (p.rel && p.href) link = document.querySelector(`link[rel="${CSS.escape(p.rel)}"][href="${CSS.escape(p.href)}"]${asSel}`);
132
- else if (p.rel) link = document.querySelector(`link[rel="${CSS.escape(p.rel)}"]${asSel}`);
133
- if (!link) { link = document.createElement('link'); document.head.appendChild(link); }
134
- const LINK_ATTR: Record<string, string> = { crossOrigin: 'crossorigin', referrerPolicy: 'referrerpolicy', fetchPriority: 'fetchpriority', hrefLang: 'hreflang' };
135
- for (const [k, v] of Object.entries(p)) {
136
- if (v != null && v !== false) link.setAttribute(LINK_ATTR[k] ?? k, String(v));
137
- }
138
- }, []);
139
-
140
- const addStyle = React.useCallback((props: StyleProps) => {
141
- const p = props as Record<string, any>;
142
- let style: HTMLStyleElement | null = null;
143
- if (p['data-id']) style = document.querySelector(`style[data-id="${CSS.escape(p['data-id'])}"]`);
144
- if (!style) { style = document.createElement('style'); document.head.appendChild(style); }
145
- for (const [k, v] of Object.entries(p)) {
146
- if (k === 'dangerouslySetInnerHTML') { style.innerHTML = (v as any).__html ?? ''; continue; }
147
- if (v != null && v !== false) style.setAttribute(k, String(v));
148
- }
149
- }, []);
150
-
151
- const addScript = React.useCallback((props: ScriptProps) => {
152
- const p = props as Record<string, any>;
153
- let script: HTMLScriptElement | null = null;
154
- if (p.src) script = document.querySelector(`script[src="${CSS.escape(p.src)}"]`);
155
- else if (p['data-id']) script = document.querySelector(`script[data-id="${CSS.escape(p['data-id'])}"]`);
156
- if (!script) { script = document.createElement('script'); document.body.appendChild(script); }
157
- for (const [k, v] of Object.entries(p)) {
158
- if (k === 'dangerouslySetInnerHTML') { script.innerHTML = (v as any).__html ?? ''; continue; }
159
- if (v != null && v !== false) script.setAttribute(k, String(v));
160
- }
161
- }, []);
162
-
163
- const contextValue: InnerContext = React.useMemo(() => ({
164
- setTitle,
165
- addMeta,
166
- addLink,
167
- addStyle,
168
- addScript,
169
- setStatus: () => { },
170
- }), [setTitle, addMeta, addLink, addStyle, addScript]);
171
-
172
- return (
173
- <AppContext.Provider value={contextValue}>
174
- {children}
175
- </AppContext.Provider>
176
- );
177
- } );
69
+ // Lazy singleton for the client-side DOM context.
70
+ let _cliCtx: InnerContext | null = null;
71
+
72
+ function makeClientCtx(): InnerContext {
73
+ if (_cliCtx) return _cliCtx;
74
+ _cliCtx = {
75
+ setTitle: (title) => { document.title = title; },
76
+ setStatus: () => { /* no-op on client */ },
77
+ addMeta: (props) => {
78
+ const p = props as Record<string, any>;
79
+ let meta: HTMLMetaElement | null = null;
80
+ if (p.name) meta = document.querySelector(`meta[name="${CSS.escape(p.name)}"]`);
81
+ else if (p.property) meta = document.querySelector(`meta[property="${CSS.escape(p.property)}"]`);
82
+ else if (p.httpEquiv ?? p['http-equiv']) meta = document.querySelector(`meta[http-equiv="${CSS.escape(p.httpEquiv ?? p['http-equiv'])}"]`);
83
+ else if ('charSet' in p || 'charset' in p) meta = document.querySelector('meta[charset]');
84
+ if (!meta) { meta = document.createElement('meta'); document.head.appendChild(meta); }
85
+ for (const [k, v] of Object.entries(p)) {
86
+ if (v != null && v !== false) meta.setAttribute(k === 'charSet' ? 'charset' : k === 'httpEquiv' ? 'http-equiv' : k, String(v));
87
+ }
88
+ },
89
+ addLink: (props) => {
90
+ const p = props as Record<string, any>;
91
+ let link: HTMLLinkElement | null = null;
92
+ const asSel = p.as ? `[as="${CSS.escape(p.as)}"]` : '';
93
+ if (p.rel && p.href) link = document.querySelector(`link[rel="${CSS.escape(p.rel)}"][href="${CSS.escape(p.href)}"]${asSel}`);
94
+ else if (p.rel) link = document.querySelector(`link[rel="${CSS.escape(p.rel)}"]${asSel}`);
95
+ if (!link) { link = document.createElement('link'); document.head.appendChild(link); }
96
+ for (const [k, v] of Object.entries(p)) {
97
+ if (v != null && v !== false) link.setAttribute(LINK_ATTR[k] ?? k, String(v));
98
+ }
99
+ },
100
+ addStyle: (props) => {
101
+ const p = props as Record<string, any>;
102
+ let style: HTMLStyleElement | null = null;
103
+ if (p['data-id']) style = document.querySelector(`style[data-id="${CSS.escape(p['data-id'])}"]`);
104
+ if (!style) { style = document.createElement('style'); document.head.appendChild(style); }
105
+ for (const [k, v] of Object.entries(p)) {
106
+ if (k === 'dangerouslySetInnerHTML') { style.innerHTML = (v as any).__html ?? ''; continue; }
107
+ if (v != null && v !== false) style.setAttribute(k, String(v));
108
+ }
109
+ },
110
+ addScript: (props) => {
111
+ const p = props as Record<string, any>;
112
+ let script: HTMLScriptElement | null = null;
113
+ if (p.src) script = document.querySelector(`script[src="${CSS.escape(p.src)}"]`);
114
+ else if (p['data-id']) script = document.querySelector(`script[data-id="${CSS.escape(p['data-id'])}"]`);
115
+ if (!script) { script = document.createElement('script'); document.body.appendChild(script); }
116
+ for (const [k, v] of Object.entries(p)) {
117
+ if (k === 'dangerouslySetInnerHTML') { script.innerHTML = (v as any).__html ?? ''; continue; }
118
+ if (v != null && v !== false) script.setAttribute(k, String(v));
119
+ }
120
+ },
121
+ };
122
+ return _cliCtx;
123
+ }
178
124
 
179
- export const useApp = () => React.useContext(AppContext);
125
+ function getCtx(): InnerContext | null {
126
+ if (typeof window === 'undefined') {
127
+ const head: AppHead | undefined = (globalThis as any).__hadarsContext?.head;
128
+ if (!head) return null;
129
+ return makeServerCtx(head);
130
+ }
131
+ return makeClientCtx();
132
+ }
180
133
 
181
134
  // ── useServerData ─────────────────────────────────────────────────────────────
182
135
  //
@@ -319,54 +272,23 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
319
272
  const unsuspend: AppUnsuspend | undefined = (globalThis as any).__hadarsUnsuspend;
320
273
  if (!unsuspend) return undefined;
321
274
 
322
- // ── per-pass key tracking ────────────────────────────────────────────────
323
- // We keep two sets: keys seen in the current pass and keys seen in the
324
- // previous pass. When a pass throws a promise, the *next* call to
325
- // useServerData marks the start of a new pass and rotates the sets.
326
- //
327
- // A key is unstable when a key that WAS seen in the previous pass is now
328
- // absent from the current pass while a new key appears instead. This means
329
- // a component produced a different key string between passes (e.g. Date.now()
330
- // in the key). We fire immediately — there is no need to wait for other
331
- // entries to settle first, because a legitimately-new component always extends
332
- // seenLastPass (all previous keys remain present in seenThisPass).
275
+ // ── unstable-key detection ───────────────────────────────────────────────
276
+ // Track the last key thrown as a pending promise and whether it was accessed
277
+ // as a cache hit in the current pass. If a new pending entry appears while
278
+ // the previous pending key resolved but was never requested, the key is
279
+ // changing between passes (e.g. Date.now() or Math.random() in the key).
333
280
  const _u = unsuspend as any;
334
- if (!_u.seenThisPass) _u.seenThisPass = new Set<string>();
335
- if (!_u.seenLastPass) _u.seenLastPass = new Set<string>();
336
-
337
- if (_u.newPassStarting) {
338
- // This is the first useServerData call after a thrown promise — rotate.
339
- _u.seenLastPass = new Set(_u.seenThisPass);
340
- _u.seenThisPass.clear();
341
- _u.newPassStarting = false;
342
- }
343
- _u.seenThisPass.add(cacheKey);
281
+ if (!_u.pendingCreated) _u.pendingCreated = 0;
344
282
  // ────────────────────────────────────────────────────────────────────────
345
283
 
346
284
  const existing = unsuspend.cache.get(cacheKey);
347
285
 
348
- if (!existing) {
349
- // Detect an unstable key: a key that was called in the previous pass is
350
- // now absent while a new key has appeared. This means a component
351
- // generated a different key between passes — it will loop forever.
352
- //
353
- // We intentionally do NOT fire when seenLastPass is empty (first pass
354
- // ever) or when all previous keys are still present (legitimate
355
- // "new component reached for the first time" scenario).
356
- if (_u.seenLastPass.size > 0) {
357
- const hasVanishedKey = [..._u.seenLastPass as Set<string>].some(
358
- (k: string) => !(_u.seenThisPass as Set<string>).has(k),
359
- );
360
- if (hasVanishedKey) {
361
- throw new Error(
362
- `[hadars] useServerData: key ${JSON.stringify(cacheKey)} appeared in this pass ` +
363
- `but a key that was present in the previous pass is now missing. This means ` +
364
- `the key is not stable across render passes (e.g. it contains Date.now(), ` +
365
- `Math.random(), or a value that changes on every render). Keys must be deterministic.`,
366
- );
367
- }
368
- }
286
+ // Mark the previous pending key as accessed when it appears as a cache hit.
287
+ if (existing?.status === 'fulfilled' && _u.lastPendingKey === cacheKey) {
288
+ _u.lastPendingKeyAccessed = true;
289
+ }
369
290
 
291
+ if (!existing) {
370
292
  // First encounter — call fn(), which may:
371
293
  // (a) return a Promise<T> — async usage (serialised for the client)
372
294
  // (b) return T synchronously — e.g. a sync data source
@@ -382,16 +304,43 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
382
304
  }
383
305
 
384
306
  // (a) Async Promise — standard useServerData usage.
307
+
308
+ // Unstable-key detection: the previous pending key resolved but was never
309
+ // requested in the current pass — a new key replaced it, which means the
310
+ // key is not stable between render passes.
311
+ if (_u.lastPendingKey != null && !_u.lastPendingKeyAccessed) {
312
+ const prev = unsuspend.cache.get(_u.lastPendingKey);
313
+ if (prev?.status === 'fulfilled') {
314
+ throw new Error(
315
+ `[hadars] useServerData: key ${JSON.stringify(cacheKey)} is not stable between render passes. ` +
316
+ `The previous pass resolved ${JSON.stringify(_u.lastPendingKey)} but it was not ` +
317
+ `requested in this pass — the key is changing between renders. ` +
318
+ `Avoid dynamic values in keys (e.g. Date.now() or Math.random()); ` +
319
+ `use stable, deterministic identifiers instead.`,
320
+ );
321
+ }
322
+ }
323
+
324
+ _u.pendingCreated++;
325
+ if (_u.pendingCreated > 100) {
326
+ throw new Error(
327
+ `[hadars] useServerData: more than 100 async keys created in a single render. ` +
328
+ `This usually means a key is not stable between renders (e.g. it contains ` +
329
+ `Date.now() or Math.random()). Currently offending key: ${JSON.stringify(cacheKey)}.`,
330
+ );
331
+ }
332
+
333
+ _u.lastPendingKey = cacheKey;
334
+ _u.lastPendingKeyAccessed = false;
335
+
385
336
  const promise = (result as Promise<T>).then(
386
337
  value => { unsuspend.cache.set(cacheKey, { status: 'fulfilled', value }); },
387
338
  reason => { unsuspend.cache.set(cacheKey, { status: 'rejected', reason }); },
388
339
  );
389
340
  unsuspend.cache.set(cacheKey, { status: 'pending', promise });
390
- _u.newPassStarting = true; // next useServerData call opens a new pass
391
341
  throw promise; // slim-react will await and retry
392
342
  }
393
343
  if (existing.status === 'pending') {
394
- _u.newPassStarting = true;
395
344
  throw existing.promise; // slim-react will await and retry
396
345
  }
397
346
  if (existing.status === 'rejected') throw existing.reason;
@@ -404,14 +353,10 @@ export const Head: React.FC<{
404
353
  status?: number;
405
354
  }> = React.memo( ({ children, status }) => {
406
355
 
407
- const {
408
- setStatus,
409
- setTitle,
410
- addMeta,
411
- addLink,
412
- addStyle,
413
- addScript,
414
- } = useApp();
356
+ const ctx = getCtx();
357
+ if (!ctx) return null;
358
+
359
+ const { setStatus, setTitle, addMeta, addLink, addStyle, addScript } = ctx;
415
360
 
416
361
  if ( status ) {
417
362
  setStatus(status);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hadars",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Minimal SSR framework for React — rspack, HMR, TypeScript, Bun/Node/Deno",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -31,10 +31,15 @@
31
31
  "types": "./dist/lambda.d.ts",
32
32
  "import": "./dist/lambda.js",
33
33
  "require": "./dist/lambda.cjs"
34
+ },
35
+ "./cloudflare": {
36
+ "types": "./dist/cloudflare.d.ts",
37
+ "import": "./dist/cloudflare.js",
38
+ "require": "./dist/cloudflare.cjs"
34
39
  }
35
40
  },
36
41
  "scripts": {
37
- "build:lib": "tsup src/index.tsx src/lambda.ts src/slim-react/index.ts src/slim-react/jsx-runtime.ts --format esm,cjs --dts --out-dir dist --clean --external '@rspack/*' --external '@rspack/binding'",
42
+ "build:lib": "tsup src/index.tsx src/lambda.ts src/cloudflare.ts src/slim-react/index.ts src/slim-react/jsx-runtime.ts --format esm,cjs --dts --out-dir dist --clean --external '@rspack/*' --external '@rspack/binding'",
38
43
  "build:cli": "node build-scripts/build-cli.mjs",
39
44
  "build:all": "npm run build:lib && npm run build:cli",
40
45
  "test": "bun test test/render-compare.test.tsx && bun test test/ssr.test.ts",
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Cloudflare Workers adapter for hadars.
3
+ *
4
+ * After running `hadars build`, bundle your app with:
5
+ *
6
+ * hadars export cloudflare
7
+ *
8
+ * This produces a self-contained `cloudflare.mjs` that you deploy with:
9
+ *
10
+ * wrangler deploy
11
+ *
12
+ * Static assets (JS, CSS, fonts) under `.hadars/static/` must be served from
13
+ * R2 or another CDN — the Worker only handles HTML rendering. Route requests
14
+ * for static file extensions to R2 and everything else to the Worker.
15
+ *
16
+ * @example wrangler.toml
17
+ * name = "my-app"
18
+ * main = "cloudflare.mjs"
19
+ * compatibility_date = "2024-09-23"
20
+ * compatibility_flags = ["nodejs_compat"]
21
+ */
22
+
23
+ import React from 'react';
24
+ import { parseRequest } from './utils/request';
25
+ import { createProxyHandler } from './utils/proxyHandler';
26
+ import { getReactResponse, buildHeadHtml } from './utils/response';
27
+ import { buildSsrHtml, makePrecontentHtmlGetter, createRenderCache } from './utils/ssrHandler';
28
+ import type { HadarsOptions, HadarsEntryModule, HadarsProps } from './types/hadars';
29
+
30
+ // ── Public types ──────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Pre-loaded SSR module and HTML template for single-file Cloudflare bundles
34
+ * produced by `hadars export cloudflare`. All I/O is eliminated at runtime —
35
+ * the Worker is fully self-contained.
36
+ */
37
+ export interface CloudflareBundled {
38
+ /** The compiled SSR module — import it statically in your entry shim. */
39
+ ssrModule: HadarsEntryModule<any>;
40
+ /**
41
+ * The contents of `.hadars/static/out.html` — esbuild inlines this as a
42
+ * string when `hadars export cloudflare` runs.
43
+ */
44
+ outHtml: string;
45
+ }
46
+
47
+ /**
48
+ * The shape of a Cloudflare Workers export object.
49
+ * Return this as the default export of your Worker entry file.
50
+ */
51
+ export interface CloudflareHandler {
52
+ fetch(request: Request, env: unknown, ctx: unknown): Promise<Response>;
53
+ }
54
+
55
+ // ── Handler factory ───────────────────────────────────────────────────────────
56
+
57
+ /**
58
+ * Creates a Cloudflare Workers handler from a hadars config and a pre-bundled
59
+ * SSR module. Use this as the default export of your Worker entry.
60
+ *
61
+ * Unlike the Lambda adapter, Cloudflare Workers receive a standard Web
62
+ * `Request` and return a standard `Response` — no event format conversion is
63
+ * required. Static assets must be routed to R2/CDN via wrangler rules; this
64
+ * Worker handles only HTML rendering and API routes.
65
+ *
66
+ * @example — generated entry shim (created by `hadars export cloudflare`)
67
+ * import * as ssrModule from './.hadars/index.ssr.js';
68
+ * import outHtml from './.hadars/static/out.html';
69
+ * import { createCloudflareHandler } from 'hadars/cloudflare';
70
+ * import config from './hadars.config';
71
+ * export default createCloudflareHandler(config, { ssrModule, outHtml });
72
+ */
73
+ export function createCloudflareHandler(
74
+ options: HadarsOptions,
75
+ bundled: CloudflareBundled,
76
+ ): CloudflareHandler {
77
+ const fetchHandler = options.fetch;
78
+ const handleProxy = createProxyHandler(options);
79
+ const getPrecontentHtml = makePrecontentHtmlGetter(Promise.resolve(bundled.outHtml));
80
+ const { ssrModule } = bundled;
81
+
82
+ const runHandler = async (req: Request): Promise<Response> => {
83
+ const request = parseRequest(req);
84
+
85
+ if (fetchHandler) {
86
+ const res = await fetchHandler(request);
87
+ if (res) return res;
88
+ }
89
+
90
+ const proxied = await handleProxy(request);
91
+ if (proxied) return proxied;
92
+
93
+ try {
94
+ const { default: Component, getInitProps, getFinalProps } = ssrModule;
95
+
96
+ const { head, status, getAppBody, finalize } = await getReactResponse(request, {
97
+ document: {
98
+ body: Component as React.FC<HadarsProps<object>>,
99
+ lang: 'en',
100
+ getInitProps,
101
+ getFinalProps,
102
+ },
103
+ });
104
+
105
+ // Data-only requests from client-side navigation.
106
+ if (request.headers.get('Accept') === 'application/json') {
107
+ const { clientProps } = await finalize();
108
+ const serverData = (clientProps as any).__serverData ?? {};
109
+ return new Response(JSON.stringify({ serverData }), {
110
+ status,
111
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
112
+ });
113
+ }
114
+
115
+ const bodyHtml = await getAppBody();
116
+ const { clientProps } = await finalize();
117
+ const headHtml = buildHeadHtml(head);
118
+ const html = await buildSsrHtml(bodyHtml, clientProps, headHtml, getPrecontentHtml);
119
+
120
+ return new Response(html, {
121
+ status,
122
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
123
+ });
124
+ } catch (err: any) {
125
+ console.error('[hadars] SSR render error:', err);
126
+ return new Response('Internal Server Error', { status: 500 });
127
+ }
128
+ };
129
+
130
+ const finalFetch = options.cache
131
+ ? createRenderCache(options.cache, (req) => runHandler(req))
132
+ : (req: Request) => runHandler(req);
133
+
134
+ return {
135
+ fetch: async (request: Request, _env: unknown, ctx: unknown): Promise<Response> => {
136
+ return (await finalFetch(request, ctx)) ?? new Response('Not Found', { status: 404 });
137
+ },
138
+ };
139
+ }
package/src/index.tsx CHANGED
@@ -9,9 +9,6 @@ export type {
9
9
  HadarsApp,
10
10
  } from "./types/hadars";
11
11
  export { Head as HadarsHead, useServerData, initServerDataCache } from './utils/Head';
12
- import { AppProviderSSR, AppProviderCSR } from "./utils/Head";
13
-
14
- export const HadarsContext = typeof window === 'undefined' ? AppProviderSSR : AppProviderCSR;
15
12
 
16
13
  /**
17
14
  * Dynamically loads a module with target-aware behaviour:
@@ -58,12 +58,14 @@ async function runFullLifecycle(serialReq: SerializableRequest) {
58
58
  let props: any = {
59
59
  ...(getInitProps ? await getInitProps(parsedReq) : {}),
60
60
  location: serialReq.location,
61
- context,
62
61
  };
63
62
 
64
63
  // Create per-request cache for useServerData, active for all renders.
65
64
  const unsuspend = { cache: new Map<string, any>() };
66
65
  (globalThis as any).__hadarsUnsuspend = unsuspend;
66
+ // Expose the head context so HadarsHead can write into it without needing
67
+ // the user to manually wrap their App with HadarsContext.
68
+ (globalThis as any).__hadarsContext = context;
67
69
 
68
70
  // Single pass — component-level self-retry resolves all useServerData inline.
69
71
  // context.head is fully populated by the time renderToString returns.
@@ -72,6 +74,7 @@ async function runFullLifecycle(serialReq: SerializableRequest) {
72
74
  appHtml = await renderToString(createElement(Component, props));
73
75
  } finally {
74
76
  (globalThis as any).__hadarsUnsuspend = null;
77
+ (globalThis as any).__hadarsContext = null;
75
78
  }
76
79
  // Head is captured after the render — all components have run.
77
80
  const headHtml = buildHeadHtml(context.head);
@@ -40,7 +40,6 @@ export interface AppContext {
40
40
 
41
41
  export type HadarsEntryBase = {
42
42
  location: string;
43
- context: AppContext;
44
43
  }
45
44
 
46
45
  export type HadarsProps<T extends {}> = T & HadarsEntryBase;