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.
- package/dist/{chunk-TV37IMRB.js → chunk-2TMQUXFL.js} +10 -10
- package/dist/{chunk-2J2L2H3H.js → chunk-NYLXE7T7.js} +6 -6
- package/dist/{chunk-OS3V4CPN.js → chunk-OZUZS2PD.js} +4 -4
- package/dist/cli.js +462 -496
- package/dist/cloudflare.cjs +11 -11
- package/dist/cloudflare.js +3 -3
- package/dist/index.d.cts +8 -4
- package/dist/index.d.ts +8 -4
- package/dist/lambda.cjs +11 -11
- package/dist/lambda.js +7 -7
- package/dist/loader.cjs +90 -54
- package/dist/slim-react/index.cjs +13 -13
- package/dist/slim-react/index.js +2 -2
- package/dist/slim-react/jsx-runtime.cjs +2 -4
- package/dist/slim-react/jsx-runtime.js +1 -1
- package/dist/ssr-render-worker.js +174 -161
- package/dist/ssr-watch.js +40 -74
- package/package.json +8 -10
- package/cli-lib.ts +0 -676
- package/cli.ts +0 -36
- package/index.ts +0 -17
- package/src/build.ts +0 -805
- package/src/cloudflare.ts +0 -140
- package/src/index.tsx +0 -41
- package/src/lambda.ts +0 -287
- package/src/slim-react/context.ts +0 -55
- package/src/slim-react/dispatcher.ts +0 -87
- package/src/slim-react/hooks.ts +0 -137
- package/src/slim-react/index.ts +0 -232
- package/src/slim-react/jsx-runtime.ts +0 -7
- package/src/slim-react/jsx.ts +0 -53
- package/src/slim-react/render.ts +0 -1101
- package/src/slim-react/renderContext.ts +0 -294
- package/src/slim-react/types.ts +0 -33
- package/src/source/context.ts +0 -113
- package/src/source/graphiql.ts +0 -101
- package/src/source/inference.ts +0 -260
- package/src/source/runner.ts +0 -138
- package/src/source/store.ts +0 -50
- package/src/ssr-render-worker.ts +0 -116
- package/src/ssr-watch.ts +0 -62
- package/src/static.ts +0 -109
- package/src/types/global.d.ts +0 -5
- package/src/types/hadars.ts +0 -350
- package/src/utils/Head.tsx +0 -462
- package/src/utils/clientScript.tsx +0 -71
- package/src/utils/cookies.ts +0 -16
- package/src/utils/loader.ts +0 -335
- package/src/utils/proxyHandler.tsx +0 -104
- package/src/utils/request.tsx +0 -9
- package/src/utils/response.tsx +0 -141
- package/src/utils/rspack.ts +0 -467
- package/src/utils/runtime.ts +0 -19
- package/src/utils/serve.ts +0 -155
- package/src/utils/ssrHandler.ts +0 -239
- package/src/utils/staticFile.ts +0 -43
- package/src/utils/template.html +0 -11
- package/src/utils/upgradeRequest.tsx +0 -19
package/src/utils/loader.ts
DELETED
|
@@ -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
|
-
}
|
package/src/utils/request.tsx
DELETED
|
@@ -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
|
-
};
|
package/src/utils/response.tsx
DELETED
|
@@ -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> = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
|
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
|
-
};
|