hadars 0.1.40 → 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.
- package/README.md +85 -70
- package/cli-lib.ts +89 -12
- package/dist/chunk-HWOLYLPF.js +332 -0
- package/dist/{chunk-2ENP7IAW.js → chunk-LY5MTHFV.js} +360 -203
- package/dist/cli.js +506 -274
- package/dist/cloudflare.cjs +1394 -0
- package/dist/cloudflare.d.cts +64 -0
- package/dist/cloudflare.d.ts +64 -0
- package/dist/cloudflare.js +68 -0
- package/dist/{hadars-Bh-V5YXg.d.cts → hadars-DEBSYAQl.d.cts} +1 -36
- package/dist/{hadars-Bh-V5YXg.d.ts → hadars-DEBSYAQl.d.ts} +1 -36
- package/dist/index.cjs +129 -156
- package/dist/index.d.cts +5 -11
- package/dist/index.d.ts +5 -11
- package/dist/index.js +129 -155
- package/dist/lambda.cjs +391 -229
- package/dist/lambda.d.cts +1 -2
- package/dist/lambda.d.ts +1 -2
- package/dist/lambda.js +18 -307
- package/dist/slim-react/index.cjs +361 -203
- package/dist/slim-react/index.d.cts +24 -8
- package/dist/slim-react/index.d.ts +24 -8
- package/dist/slim-react/index.js +3 -1
- package/dist/ssr-render-worker.js +352 -221
- package/dist/utils/Head.tsx +132 -187
- package/package.json +7 -2
- package/src/build.ts +7 -6
- package/src/cloudflare.ts +139 -0
- package/src/index.tsx +0 -3
- package/src/lambda.ts +6 -2
- package/src/slim-react/context.ts +2 -1
- package/src/slim-react/index.ts +21 -18
- package/src/slim-react/render.ts +379 -240
- package/src/slim-react/renderContext.ts +105 -45
- package/src/ssr-render-worker.ts +14 -44
- package/src/types/hadars.ts +0 -1
- package/src/utils/Head.tsx +132 -187
- package/src/utils/cookies.ts +1 -1
- package/src/utils/response.tsx +68 -33
- package/src/utils/serve.ts +29 -27
- package/src/utils/ssrHandler.ts +54 -25
- package/src/utils/staticFile.ts +2 -7
package/dist/utils/Head.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import type {
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
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.
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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.1
|
|
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",
|
package/src/build.ts
CHANGED
|
@@ -17,9 +17,8 @@ import { spawn } from 'node:child_process';
|
|
|
17
17
|
import cluster from 'node:cluster';
|
|
18
18
|
import type { HadarsEntryModule, HadarsOptions, HadarsProps } from "./types/hadars";
|
|
19
19
|
import {
|
|
20
|
-
HEAD_MARKER, BODY_MARKER,
|
|
21
20
|
buildSsrResponse, makePrecontentHtmlGetter,
|
|
22
|
-
CacheFetchHandler, createRenderCache,
|
|
21
|
+
type CacheFetchHandler, createRenderCache,
|
|
23
22
|
} from './utils/ssrHandler';
|
|
24
23
|
|
|
25
24
|
/**
|
|
@@ -519,7 +518,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
519
518
|
getFinalProps,
|
|
520
519
|
} = (await import(importPath)) as HadarsEntryModule<any>;
|
|
521
520
|
|
|
522
|
-
const {
|
|
521
|
+
const { head, status, getAppBody, finalize } = await getReactResponse(request, {
|
|
523
522
|
document: {
|
|
524
523
|
body: Component as React.FC<HadarsProps<object>>,
|
|
525
524
|
lang: 'en',
|
|
@@ -533,6 +532,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
533
532
|
// instead of a full HTML page. The same auth context applies — cookies
|
|
534
533
|
// and headers are forwarded unchanged, so no new attack surface is created.
|
|
535
534
|
if (request.headers.get('Accept') === 'application/json') {
|
|
535
|
+
const { clientProps } = await finalize();
|
|
536
536
|
const serverData = (clientProps as any).__serverData ?? {};
|
|
537
537
|
return new Response(JSON.stringify({ serverData }), {
|
|
538
538
|
status,
|
|
@@ -540,7 +540,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
540
540
|
});
|
|
541
541
|
}
|
|
542
542
|
|
|
543
|
-
return buildSsrResponse(
|
|
543
|
+
return buildSsrResponse(head, status, getAppBody, finalize, getPrecontentHtml);
|
|
544
544
|
} catch (err: any) {
|
|
545
545
|
console.error('[hadars] SSR render error:', err);
|
|
546
546
|
const msg = (err?.stack ?? err?.message ?? String(err)).replace(/</g, '<');
|
|
@@ -723,7 +723,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
|
|
|
723
723
|
});
|
|
724
724
|
}
|
|
725
725
|
|
|
726
|
-
const {
|
|
726
|
+
const { head, status, getAppBody, finalize } = await getReactResponse(request, {
|
|
727
727
|
document: {
|
|
728
728
|
body: Component as React.FC<HadarsProps<object>>,
|
|
729
729
|
lang: 'en',
|
|
@@ -736,6 +736,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
|
|
|
736
736
|
// navigation via useServerData), return the resolved data map as JSON
|
|
737
737
|
// instead of a full HTML page.
|
|
738
738
|
if (request.headers.get('Accept') === 'application/json') {
|
|
739
|
+
const { clientProps } = await finalize();
|
|
739
740
|
const serverData = (clientProps as any).__serverData ?? {};
|
|
740
741
|
return new Response(JSON.stringify({ serverData }), {
|
|
741
742
|
status,
|
|
@@ -743,7 +744,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
|
|
|
743
744
|
});
|
|
744
745
|
}
|
|
745
746
|
|
|
746
|
-
return buildSsrResponse(
|
|
747
|
+
return buildSsrResponse(head, status, getAppBody, finalize, getPrecontentHtml);
|
|
747
748
|
} catch (err: any) {
|
|
748
749
|
console.error('[hadars] SSR render error:', err);
|
|
749
750
|
return new Response('Internal Server Error', { status: 500 });
|
|
@@ -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:
|
package/src/lambda.ts
CHANGED
|
@@ -23,7 +23,7 @@ import fs from 'node:fs/promises';
|
|
|
23
23
|
import { createProxyHandler } from './utils/proxyHandler';
|
|
24
24
|
import { parseRequest } from './utils/request';
|
|
25
25
|
import { tryServeFile } from './utils/staticFile';
|
|
26
|
-
import { getReactResponse } from './utils/response';
|
|
26
|
+
import { getReactResponse, buildHeadHtml } from './utils/response';
|
|
27
27
|
import { buildSsrResponse, buildSsrHtml, makePrecontentHtmlGetter, createRenderCache } from './utils/ssrHandler';
|
|
28
28
|
import type { HadarsOptions, HadarsEntryModule, HadarsProps } from './types/hadars';
|
|
29
29
|
|
|
@@ -247,7 +247,7 @@ export function createLambdaHandler(options: HadarsOptions, bundled?: LambdaBund
|
|
|
247
247
|
getFinalProps,
|
|
248
248
|
} = await getSsrModule();
|
|
249
249
|
|
|
250
|
-
const {
|
|
250
|
+
const { head, status, getAppBody, finalize } = await getReactResponse(request, {
|
|
251
251
|
document: {
|
|
252
252
|
body: Component as React.FC<HadarsProps<object>>,
|
|
253
253
|
lang: 'en',
|
|
@@ -257,6 +257,7 @@ export function createLambdaHandler(options: HadarsOptions, bundled?: LambdaBund
|
|
|
257
257
|
});
|
|
258
258
|
|
|
259
259
|
if (request.headers.get('Accept') === 'application/json') {
|
|
260
|
+
const { clientProps } = await finalize();
|
|
260
261
|
const serverData = (clientProps as any).__serverData ?? {};
|
|
261
262
|
return new Response(JSON.stringify({ serverData }), {
|
|
262
263
|
status,
|
|
@@ -266,6 +267,9 @@ export function createLambdaHandler(options: HadarsOptions, bundled?: LambdaBund
|
|
|
266
267
|
|
|
267
268
|
// Build the HTML string directly — avoids creating a ReadableStream
|
|
268
269
|
// that would immediately be drained by the Lambda response serialiser.
|
|
270
|
+
const bodyHtml = await getAppBody();
|
|
271
|
+
const { clientProps } = await finalize();
|
|
272
|
+
const headHtml = buildHeadHtml(head);
|
|
269
273
|
const html = await buildSsrHtml(bodyHtml, clientProps, headHtml, getPrecontentHtml);
|
|
270
274
|
return new Response(html, {
|
|
271
275
|
status,
|