hadars 0.4.1 → 0.4.2

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.
Files changed (58) hide show
  1. package/dist/{chunk-TV37IMRB.js → chunk-2TMQUXFL.js} +10 -10
  2. package/dist/{chunk-2J2L2H3H.js → chunk-NYLXE7T7.js} +6 -6
  3. package/dist/{chunk-OS3V4CPN.js → chunk-OZUZS2PD.js} +4 -4
  4. package/dist/cli.js +462 -496
  5. package/dist/cloudflare.cjs +11 -11
  6. package/dist/cloudflare.js +3 -3
  7. package/dist/index.d.cts +8 -4
  8. package/dist/index.d.ts +8 -4
  9. package/dist/lambda.cjs +11 -11
  10. package/dist/lambda.js +7 -7
  11. package/dist/loader.cjs +90 -54
  12. package/dist/slim-react/index.cjs +13 -13
  13. package/dist/slim-react/index.js +2 -2
  14. package/dist/slim-react/jsx-runtime.cjs +2 -4
  15. package/dist/slim-react/jsx-runtime.js +1 -1
  16. package/dist/ssr-render-worker.js +174 -161
  17. package/dist/ssr-watch.js +40 -74
  18. package/package.json +8 -10
  19. package/cli-lib.ts +0 -676
  20. package/cli.ts +0 -36
  21. package/index.ts +0 -17
  22. package/src/build.ts +0 -805
  23. package/src/cloudflare.ts +0 -140
  24. package/src/index.tsx +0 -41
  25. package/src/lambda.ts +0 -287
  26. package/src/slim-react/context.ts +0 -55
  27. package/src/slim-react/dispatcher.ts +0 -87
  28. package/src/slim-react/hooks.ts +0 -137
  29. package/src/slim-react/index.ts +0 -232
  30. package/src/slim-react/jsx-runtime.ts +0 -7
  31. package/src/slim-react/jsx.ts +0 -53
  32. package/src/slim-react/render.ts +0 -1101
  33. package/src/slim-react/renderContext.ts +0 -294
  34. package/src/slim-react/types.ts +0 -33
  35. package/src/source/context.ts +0 -113
  36. package/src/source/graphiql.ts +0 -101
  37. package/src/source/inference.ts +0 -260
  38. package/src/source/runner.ts +0 -138
  39. package/src/source/store.ts +0 -50
  40. package/src/ssr-render-worker.ts +0 -116
  41. package/src/ssr-watch.ts +0 -62
  42. package/src/static.ts +0 -109
  43. package/src/types/global.d.ts +0 -5
  44. package/src/types/hadars.ts +0 -350
  45. package/src/utils/Head.tsx +0 -462
  46. package/src/utils/clientScript.tsx +0 -71
  47. package/src/utils/cookies.ts +0 -16
  48. package/src/utils/loader.ts +0 -335
  49. package/src/utils/proxyHandler.tsx +0 -104
  50. package/src/utils/request.tsx +0 -9
  51. package/src/utils/response.tsx +0 -141
  52. package/src/utils/rspack.ts +0 -467
  53. package/src/utils/runtime.ts +0 -19
  54. package/src/utils/serve.ts +0 -155
  55. package/src/utils/ssrHandler.ts +0 -239
  56. package/src/utils/staticFile.ts +0 -43
  57. package/src/utils/template.html +0 -11
  58. package/src/utils/upgradeRequest.tsx +0 -19
@@ -1,335 +0,0 @@
1
- /**
2
- * Rspack/webpack loader that applies two source-level transforms based on the
3
- * compilation target (web vs node):
4
- *
5
- * ── loadModule('path') ────────────────────────────────────────────────────────
6
- * - web (browser): replaced with `import('./path')` — rspack treats this as
7
- * a true dynamic import and splits the module into a separate chunk.
8
- * - node (SSR): replaced with `Promise.resolve(require('./path'))` —
9
- * bundled statically, wrapped in Promise.resolve to keep the API shape.
10
- *
11
- * ── useServerData(key, fn) ───────────────────────────────────────────────────
12
- * - web (browser): the second argument `fn` is replaced with `()=>undefined`.
13
- * `fn` is a server-only callback that may reference internal endpoints,
14
- * credentials, or other sensitive information. It is never called in the
15
- * browser (the hook returns the SSR-cached value immediately), but without
16
- * this transform it would still be compiled into the client bundle — exposing
17
- * those details to anyone who inspects the JS. Stripping it at bundle time
18
- * prevents the leak entirely.
19
- * - node (SSR): kept as-is — the real fn is needed to fetch data.
20
- *
21
- * Transformation strategy:
22
- * Primary — SWC AST parsing via @swc/core. Handles any valid TS/JS syntax
23
- * including arbitrarily-nested generics, comments, and string
24
- * literals that contain the function names.
25
- * Fallback — Scanner-based transform used when @swc/core is unavailable.
26
- *
27
- * Example:
28
- *
29
- * // Source (shared component):
30
- * const user = useServerData('user', () => db.getUser(req.userId));
31
- *
32
- * // Client bundle after transform:
33
- * const user = useServerData('user', ()=>undefined);
34
- *
35
- * // Server bundle (unchanged):
36
- * const user = useServerData('user', () => db.getUser(req.userId));
37
- */
38
-
39
- export default function loader(this: any, source: string): string {
40
- // Prefer the explicit `server` option injected by rspack.ts over the legacy
41
- // `this.target` heuristic (which is unreliable when `target` is not set in
42
- // the rspack config — rspack then reports 'web' for every build).
43
- const opts = this.getOptions?.() ?? {};
44
- const isServer: boolean = (typeof opts.server === 'boolean')
45
- ? opts.server
46
- : (this.target === 'node' || this.target === 'async-node');
47
- const resourcePath: string = this.resourcePath ?? this.resource ?? '(unknown)';
48
-
49
- let swc: any;
50
- try {
51
- swc = require('@swc/core');
52
- } catch {
53
- return regexTransform.call(this, source, isServer, resourcePath);
54
- }
55
-
56
- return swcTransform.call(this, swc, source, isServer, resourcePath);
57
- }
58
-
59
- // ---------------------------------------------------------------------------
60
- // SWC AST transform
61
- // ---------------------------------------------------------------------------
62
-
63
- function swcTransform(this: any, swc: any, source: string, isServer: boolean, resourcePath: string): string {
64
- const isTs = /\.[mc]?tsx?$/.test(resourcePath);
65
- const isTsx = /\.(tsx|jsx)$/.test(resourcePath);
66
-
67
- let ast: any;
68
- try {
69
- ast = swc.parseSync(source, {
70
- syntax: isTs ? 'typescript' : 'ecmascript',
71
- tsx: isTsx,
72
- });
73
- } catch {
74
- // Unparseable file (e.g., exotic syntax) — fall back to regex
75
- return regexTransform.call(this, source, isServer, resourcePath);
76
- }
77
-
78
- // SWC spans use 1-based byte offsets into a GLOBAL SourceMap that
79
- // accumulates across parseSync calls.
80
- //
81
- // `ast.span.start` = global position of the FIRST meaningful (non-comment,
82
- // non-whitespace) token. Subtract the leading non-code bytes to get the
83
- // true global start of byte 0 of this source file.
84
- //
85
- // We do NOT use `ast.span.end - srcBytes.length` because `ast.span.end`
86
- // only reaches the last AST token and does not include trailing whitespace
87
- // or newlines — causing a systematic off-by-one for the typical file that
88
- // ends with `\n`.
89
- const srcBytes = Buffer.from(source, 'utf8');
90
- const fileOffset = ast.span.start - countLeadingNonCodeBytes(source);
91
-
92
- const replacements: Array<{ start: number; end: number; replacement: string }> = [];
93
-
94
- walkAst(ast, (node: any) => {
95
- if (node.type !== 'CallExpression') return;
96
-
97
- const callee = node.callee;
98
- if (!callee || callee.type !== 'Identifier') return;
99
-
100
- const name: string = callee.value;
101
-
102
- // ── useServerData(fn) — strip fn on client builds ────────────────────
103
- if (!isServer && name === 'useServerData') {
104
- const args: any[] = node.arguments;
105
- if (!args || args.length < 1) return;
106
- const fnArg = args[0].expression ?? args[0];
107
- // Normalise to 0-based local byte offsets and replace with stub.
108
- replacements.push({
109
- start: fnArg.span.start - fileOffset,
110
- end: fnArg.span.end - fileOffset,
111
- replacement: '()=>undefined',
112
- });
113
- return;
114
- }
115
-
116
- // ── loadModule(path) ─────────────────────────────────────────────────
117
- if (name !== 'loadModule') return;
118
-
119
- const args: any[] = node.arguments;
120
- if (!args || args.length === 0) return;
121
-
122
- const firstArg = args[0].expression ?? args[0];
123
-
124
- let modulePath: string;
125
- let quoteChar: string;
126
-
127
- if (firstArg.type === 'StringLiteral') {
128
- modulePath = firstArg.value;
129
- // The quote char (' " `) is always ASCII so byte index == char index here.
130
- const quoteByteIdx = firstArg.span.start - fileOffset;
131
- quoteChar = String.fromCharCode(srcBytes[quoteByteIdx]!);
132
- } else if (
133
- firstArg.type === 'TemplateLiteral' &&
134
- firstArg.expressions.length === 0 &&
135
- firstArg.quasis.length === 1
136
- ) {
137
- // No-interpolation template literal: `./path`
138
- modulePath = firstArg.quasis[0].raw;
139
- quoteChar = '`';
140
- } else {
141
- // Dynamic (non-literal) path — emit a build warning
142
- const start0 = node.span.start - fileOffset;
143
- const bytesBefore = srcBytes.slice(0, start0);
144
- const line = bytesBefore.toString('utf8').split('\n').length;
145
- this.emitWarning(
146
- new Error(
147
- `[hadars] loadModule() called with a dynamic (non-literal) path at ${resourcePath}:${line}. ` +
148
- `Only string-literal paths are transformed by the loader; dynamic calls fall back to runtime import().`
149
- )
150
- );
151
- return;
152
- }
153
- const replacement = isServer
154
- ? `Promise.resolve(require(${quoteChar}${modulePath}${quoteChar}))`
155
- : `import(${quoteChar}${modulePath}${quoteChar})`;
156
-
157
- // Normalise to 0-based local byte offsets for Buffer.slice
158
- replacements.push({ start: node.span.start - fileOffset, end: node.span.end - fileOffset, replacement });
159
- });
160
-
161
- if (replacements.length === 0) return source;
162
-
163
- // Apply replacements from last to first so earlier byte offsets stay valid
164
- replacements.sort((a, b) => b.start - a.start);
165
-
166
- let result = srcBytes;
167
- for (const { start, end, replacement } of replacements) {
168
- result = Buffer.concat([result.slice(0, start), Buffer.from(replacement, 'utf8'), result.slice(end)]);
169
- }
170
- return result.toString('utf8');
171
- }
172
-
173
- // Minimal recursive AST walker — visits every node depth-first.
174
- function walkAst(node: any, visit: (n: any) => void): void {
175
- if (!node || typeof node !== 'object') return;
176
- visit(node);
177
- for (const key of Object.keys(node)) {
178
- if (key === 'span' || key === 'type' || key === 'ctxt') continue;
179
- const val = node[key];
180
- if (Array.isArray(val)) {
181
- for (const child of val) walkAst(child, visit);
182
- } else if (val && typeof val === 'object') {
183
- walkAst(val, visit);
184
- }
185
- }
186
- }
187
-
188
- // Returns the number of leading bytes that are pure whitespace / comments /
189
- // shebangs — i.e. bytes before the first actual code token. Used to anchor
190
- // SWC's accumulated global span offsets back to byte-0 of this source file.
191
- function countLeadingNonCodeBytes(source: string): number {
192
- let i = 0;
193
- while (i < source.length) {
194
- // Whitespace
195
- if (source[i] === ' ' || source[i] === '\t' || source[i] === '\r' || source[i] === '\n') {
196
- i++;
197
- continue;
198
- }
199
- // Line comment //...
200
- if (source[i] === '/' && source[i + 1] === '/') {
201
- while (i < source.length && source[i] !== '\n') i++;
202
- continue;
203
- }
204
- // Block comment /* ... */
205
- if (source[i] === '/' && source[i + 1] === '*') {
206
- i += 2;
207
- while (i + 1 < source.length && !(source[i] === '*' && source[i + 1] === '/')) i++;
208
- if (i + 1 < source.length) i += 2;
209
- continue;
210
- }
211
- // Shebang #!... (only valid at position 0)
212
- if (i === 0 && source[i] === '#' && source[i + 1] === '!') {
213
- while (i < source.length && source[i] !== '\n') i++;
214
- continue;
215
- }
216
- break;
217
- }
218
- // SWC spans are UTF-8 byte offsets, but `i` here is a char index.
219
- // Return the byte length of the leading non-code prefix.
220
- return Buffer.byteLength(source.slice(0, i), 'utf8');
221
- }
222
-
223
- // ---------------------------------------------------------------------------
224
- // Regex fallback (used when @swc/core is not available)
225
- // ---------------------------------------------------------------------------
226
-
227
- // Matches loadModule('./path') with optional TypeScript generic (up to 2 levels
228
- // of nesting). Captures: group 1 = quote char, group 2 = module path.
229
- const LOAD_MODULE_RE =
230
- /\bloadModule\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1\s*\)/gs;
231
-
232
- // Matches any remaining loadModule( that was NOT handled by the regex above
233
- // (i.e. a dynamic / non-literal path argument).
234
- const DYNAMIC_LOAD_MODULE_RE = /\bloadModule\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(/g;
235
-
236
- /**
237
- * Scan forward from `pos` in `source`, skipping over a balanced JS expression
238
- * (handles nested parens/brackets/braces and string literals).
239
- * Returns the index of the first character AFTER the expression
240
- * (i.e. the position of the trailing `,` or `)` at depth 0).
241
- */
242
- function scanExpressionEnd(source: string, pos: number): number {
243
- let depth = 0;
244
- let i = pos;
245
- while (i < source.length) {
246
- const ch = source[i]!;
247
- if (ch === '(' || ch === '[' || ch === '{') { depth++; i++; continue; }
248
- if (ch === ')' || ch === ']' || ch === '}') {
249
- if (depth === 0) break; // end of expression — closing delimiter of outer call
250
- depth--; i++; continue;
251
- }
252
- if (ch === ',' && depth === 0) break; // end of expression — next argument
253
- // String / template literals
254
- if (ch === '"' || ch === "'" || ch === '`') {
255
- const q = ch; i++;
256
- while (i < source.length && source[i] !== q) {
257
- if (source[i] === '\\') i++; // escape sequence
258
- i++;
259
- }
260
- i++; // closing quote
261
- continue;
262
- }
263
- // Line comment
264
- if (ch === '/' && source[i + 1] === '/') {
265
- while (i < source.length && source[i] !== '\n') i++;
266
- continue;
267
- }
268
- // Block comment
269
- if (ch === '/' && source[i + 1] === '*') {
270
- i += 2;
271
- while (i + 1 < source.length && !(source[i] === '*' && source[i + 1] === '/')) i++;
272
- i += 2;
273
- continue;
274
- }
275
- i++;
276
- }
277
- return i;
278
- }
279
-
280
- /**
281
- * Strip the `fn` argument from `useServerData(fn)` calls in client builds.
282
- * Uses a character-level scanner to handle arbitrary fn expressions (arrow
283
- * functions with nested calls, async functions, object literals, etc.).
284
- */
285
- function stripUseServerDataFns(source: string): string {
286
- // Match `useServerData` + optional generic + opening paren
287
- const CALL_RE = /\buseServerData\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(/g;
288
- let result = '';
289
- let lastIndex = 0;
290
- let match: RegExpExecArray | null;
291
- CALL_RE.lastIndex = 0;
292
- while ((match = CALL_RE.exec(source)) !== null) {
293
- let i = match.index + match[0].length;
294
- // Skip whitespace before fn arg
295
- while (i < source.length && /\s/.test(source[i]!)) i++;
296
- const fnStart = i;
297
- // Scan to end of fn argument
298
- const fnEnd = scanExpressionEnd(source, i);
299
- if (fnEnd <= fnStart) continue;
300
- // Emit everything up to fn, then the stub, skip the original fn
301
- result += source.slice(lastIndex, fnStart) + '()=>undefined';
302
- lastIndex = fnEnd;
303
- // Advance regex past this call to avoid re-matching
304
- CALL_RE.lastIndex = fnEnd;
305
- }
306
- return lastIndex === 0 ? source : result + source.slice(lastIndex);
307
- }
308
-
309
- function regexTransform(this: any, source: string, isServer: boolean, resourcePath: string): string {
310
- let transformed = source.replace(LOAD_MODULE_RE, (_match, quote, modulePath) =>
311
- isServer
312
- ? `Promise.resolve(require(${quote}${modulePath}${quote}))`
313
- : `import(${quote}${modulePath}${quote})`
314
- );
315
-
316
- // Strip server-only fn arguments from useServerData on client builds.
317
- if (!isServer) {
318
- transformed = stripUseServerDataFns(transformed);
319
- }
320
-
321
- // Warn for any remaining dynamic calls
322
- let match: RegExpExecArray | null;
323
- DYNAMIC_LOAD_MODULE_RE.lastIndex = 0;
324
- while ((match = DYNAMIC_LOAD_MODULE_RE.exec(transformed)) !== null) {
325
- const line = transformed.slice(0, match.index).split('\n').length;
326
- this.emitWarning(
327
- new Error(
328
- `[hadars] loadModule() called with a dynamic (non-literal) path at ${resourcePath}:${line}. ` +
329
- `Only string-literal paths are transformed by the loader; dynamic calls fall back to runtime import().`
330
- )
331
- );
332
- }
333
-
334
- return transformed;
335
- }
@@ -1,104 +0,0 @@
1
- import type { HadarsOptions, HadarsRequest } from "../types/hadars";
2
-
3
- type ProxyHandler = (req: HadarsRequest) => ( Promise<Response | undefined> | undefined );
4
-
5
- const cloneHeaders = (headers: Headers) => {
6
- return new Headers(headers);
7
- };
8
-
9
- const getCORSHeaders = (req: HadarsRequest) => {
10
- const origin = req.headers.get('Origin') || '*';
11
- return {
12
- 'Access-Control-Allow-Origin': origin,
13
- 'Access-Control-Allow-Methods': 'GET,HEAD,PUT,PATCH,POST,DELETE',
14
- 'Access-Control-Allow-Headers': req.headers.get('Access-Control-Request-Headers') || '*',
15
- 'Access-Control-Allow-Credentials': 'true',
16
- };
17
- };
18
-
19
- export const createProxyHandler = (options: HadarsOptions): ProxyHandler => {
20
-
21
- const { proxy, proxyCORS } = options;
22
-
23
- if (!proxy) {
24
- return () => undefined;
25
- }
26
-
27
- if (typeof proxy === 'function') {
28
- return async (req: HadarsRequest) => {
29
- if (req.method === 'OPTIONS' && options.proxyCORS) {
30
- return new Response(null, {
31
- status: 204,
32
- headers: getCORSHeaders(req),
33
- });
34
- }
35
- const res = await proxy(req);
36
- if (res && proxyCORS) {
37
- // Clone the response to modify headers
38
- const modifiedHeaders = new Headers(res.headers);
39
- Object.entries(getCORSHeaders(req)).forEach(([key, value]) => {
40
- modifiedHeaders.set(key, value);
41
- });
42
- return new Response(res.body, {
43
- status: res.status,
44
- statusText: res.statusText,
45
- headers: modifiedHeaders,
46
- });
47
- }
48
- return res || undefined;
49
- };
50
- }
51
-
52
- // sort proxy rules by length of path (longest first)
53
- const proxyRules = Object.entries(proxy).sort((a, b) => b[0].length - a[0].length);
54
-
55
- return async (req: HadarsRequest) => {
56
- for (const [path, target] of proxyRules) {
57
- if (req.pathname.startsWith(path)) {
58
- if (req.method === 'OPTIONS' && proxyCORS) {
59
- return new Response(null, {
60
- status: 204,
61
- headers: getCORSHeaders(req),
62
- });
63
- }
64
- const targetURL = new URL(target);
65
- targetURL.pathname = targetURL.pathname.replace(/\/$/, '') + req.pathname.slice(path.length);
66
- targetURL.search = req.search;
67
-
68
- const sendHeaders = cloneHeaders(req.headers);
69
- // Overwrite the Host header to match the target
70
- sendHeaders.set('Host', targetURL.host);
71
-
72
- const hasBody = !['GET', 'HEAD'].includes(req.method);
73
- const proxyReq = new Request(targetURL.toString(), {
74
- method: req.method,
75
- headers: sendHeaders,
76
- body: hasBody ? req.body : undefined,
77
- redirect: 'follow',
78
- // Node.js (undici) requires duplex:'half' when body is a ReadableStream
79
- ...(hasBody ? { duplex: 'half' } : {}),
80
- } as RequestInit);
81
-
82
- const res = await fetch(proxyReq);
83
- // Read the response body
84
- const body = await res.arrayBuffer();
85
- // remove content-length and content-encoding headers to avoid issues with modified body
86
- const clonedRes = new Headers(res.headers);
87
- clonedRes.delete('content-length');
88
- clonedRes.delete('content-encoding');
89
- if (proxyCORS) {
90
- Object.entries(getCORSHeaders(req)).forEach(([key, value]) => {
91
- clonedRes.set(key, value);
92
- });
93
- }
94
- // return a new Response with the modified headers and original body
95
- return new Response(body, {
96
- status: res.status,
97
- statusText: res.statusText,
98
- headers: clonedRes,
99
- });
100
- }
101
- }
102
- return undefined;
103
- };
104
- }
@@ -1,9 +0,0 @@
1
- import type { HadarsRequest } from "../types/hadars";
2
- import { parseCookies } from "./cookies";
3
-
4
- export const parseRequest = (request: Request): HadarsRequest => {
5
- const url = new URL(request.url);
6
- const cookies = request.headers.get('Cookie') || '';
7
- const cookieRecord: Record<string, string> = parseCookies(cookies);
8
- return Object.assign(request, { pathname: url.pathname, search: url.search, location: url.pathname + url.search, cookies: cookieRecord });
9
- };
@@ -1,141 +0,0 @@
1
- import type React from "react";
2
- import type { AppHead, HadarsRequest, HadarsEntryBase, HadarsEntryModule, HadarsProps, AppContext, HadarsStaticContext } from "../types/hadars";
3
- import { renderToString, renderPreflight, createElement } from '../slim-react/index';
4
-
5
- interface ReactResponseOptions {
6
- document: {
7
- body: React.FC<HadarsProps<object>>;
8
- head?: () => Promise<React.ReactNode>;
9
- lang?: string;
10
- getInitProps: HadarsEntryModule<HadarsEntryBase>['getInitProps'];
11
- getFinalProps: HadarsEntryModule<HadarsEntryBase>['getFinalProps'];
12
- }
13
- staticCtx?: HadarsStaticContext;
14
- }
15
-
16
- // ── Head HTML serialisation ────────────────────────────────────────────────
17
-
18
- const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
19
- const escAttr = (s: string) => s.replace(/[&<>"]/g, c => ESC[c] ?? c);
20
- const escText = (s: string) => s.replace(/[&<>]/g, c => ESC[c] ?? c);
21
-
22
- const ATTR: Record<string, string> = {
23
- className: 'class', htmlFor: 'for', httpEquiv: 'http-equiv',
24
- charSet: 'charset', crossOrigin: 'crossorigin', noModule: 'nomodule',
25
- referrerPolicy: 'referrerpolicy', fetchPriority: 'fetchpriority',
26
- hrefLang: 'hreflang',
27
- };
28
-
29
- function renderHeadTag(tag: string, id: string, opts: Record<string, unknown>, selfClose = false): string {
30
- let attrs = ` id="${escAttr(id)}"`;
31
- let inner = '';
32
- for (const [k, v] of Object.entries(opts)) {
33
- if (k === 'key' || k === 'children') continue;
34
- if (k === 'dangerouslySetInnerHTML') { inner = (v as any).__html ?? ''; continue; }
35
- const attr = ATTR[k] ?? k;
36
- if (v === true) attrs += ` ${attr}`;
37
- else if (v !== false && v != null) attrs += ` ${attr}="${escAttr(String(v))}"`;
38
- }
39
- return selfClose ? `<${tag}${attrs}>` : `<${tag}${attrs}>${inner}</${tag}>`;
40
- }
41
-
42
- export function buildHeadHtml(seoData: AppHead): string {
43
- let html = `<title>${escText(seoData.title ?? '')}</title>`;
44
- for (const [id, opts] of Object.entries(seoData.meta))
45
- html += renderHeadTag('meta', id, opts as Record<string, unknown>, true);
46
- for (const [id, opts] of Object.entries(seoData.link))
47
- html += renderHeadTag('link', id, opts as Record<string, unknown>, true);
48
- for (const [id, opts] of Object.entries(seoData.style))
49
- html += renderHeadTag('style', id, opts as Record<string, unknown>);
50
- for (const [id, opts] of Object.entries(seoData.script))
51
- html += renderHeadTag('script', id, opts as Record<string, unknown>);
52
- return html;
53
- };
54
-
55
- export const getReactResponse = async (
56
- req: HadarsRequest,
57
- opts: ReactResponseOptions,
58
- ): Promise<{
59
- /** Head object — populated by the preflight walk, ready for buildHeadHtml(). */
60
- head: AppHead,
61
- status: number,
62
- /** Renders the App to an HTML string. Call AFTER flushing head. */
63
- getAppBody: () => Promise<string>,
64
- /** Call after streaming the body to assemble the final client props. */
65
- finalize: () => Promise<{ clientProps: Record<string, unknown> }>,
66
- }> => {
67
- const App = opts.document.body;
68
- const { getInitProps, getFinalProps } = opts.document;
69
-
70
- const context: AppContext = {
71
- head: { title: 'Hadars App', meta: {}, link: {}, style: {}, script: {}, status: 200 },
72
- };
73
-
74
- let props: HadarsEntryBase = {
75
- ...(getInitProps ? await getInitProps(req, opts.staticCtx) : {}),
76
- location: req.location,
77
- context,
78
- } as HadarsEntryBase;
79
-
80
- // Per-request cache for useServerData — set before rendering so every
81
- // component in the tree that calls useServerData finds the same cache.
82
- // captureUnsuspend / restoreUnsuspend in the renderer ensure it survives
83
- // await continuations even when concurrent requests are in flight.
84
- const unsuspend = { cache: new Map<string, any>() };
85
- (globalThis as any).__hadarsUnsuspend = unsuspend;
86
- // Expose the head context so HadarsHead can write into it without needing
87
- // the user to manually wrap their App with HadarsContext.
88
- (globalThis as any).__hadarsContext = context;
89
-
90
- const element = createElement(App as any, props as any);
91
-
92
- // Phase 1 — preflight: walk the tree with a null writer (no HTML output).
93
- // This resolves all useServerData promises into the cache and populates
94
- // context.head so head can be flushed to the client immediately.
95
- try {
96
- await renderPreflight(element);
97
- } finally {
98
- // Clear the global immediately — the closure-captured `unsuspend`
99
- // keeps the cache alive. Re-set inside getAppBody() for the second pass.
100
- (globalThis as any).__hadarsUnsuspend = null;
101
- (globalThis as any).__hadarsContext = null;
102
- }
103
-
104
- // Head is fully populated — status is known.
105
- const status = context.head.status;
106
-
107
- // Phase 2 is deferred: getAppBody() triggers the actual HTML render.
108
- // All data is cached from the preflight, so the second pass is fast
109
- // (no async waits). The caller flushes head BEFORE calling this.
110
- const getAppBody = async (): Promise<string> => {
111
- (globalThis as any).__hadarsUnsuspend = unsuspend;
112
- (globalThis as any).__hadarsContext = context;
113
- try {
114
- return await renderToString(element);
115
- } finally {
116
- (globalThis as any).__hadarsUnsuspend = null;
117
- (globalThis as any).__hadarsContext = null;
118
- }
119
- };
120
-
121
- const finalize = async (): Promise<{ clientProps: Record<string, unknown> }> => {
122
- const restProps = getFinalProps ? await getFinalProps(props) : props;
123
- const serverData: Record<string, unknown> = {};
124
- let hasServerData = false;
125
- for (const [key, entry] of unsuspend.cache) {
126
- if ((entry as any).status === 'fulfilled') {
127
- serverData[key] = (entry as any).value;
128
- hasServerData = true;
129
- }
130
- }
131
- return {
132
- clientProps: {
133
- ...restProps,
134
- location: req.location,
135
- ...(hasServerData ? { __serverData: serverData } : {}),
136
- } as Record<string, unknown>,
137
- };
138
- };
139
-
140
- return { head: context.head, status, getAppBody, finalize };
141
- };