hadars 0.1.9 → 0.1.11
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/cli.js +259 -122
- package/dist/index.cjs +2 -1
- package/dist/index.d.ts +25 -1
- package/dist/index.js +2 -1
- package/dist/loader.cjs +126 -5
- package/dist/ssr-render-worker.js +3 -0
- package/dist/utils/Head.tsx +4 -2
- package/dist/utils/clientScript.tsx +6 -2
- package/package.json +16 -15
- package/src/build.ts +193 -68
- package/src/ssr-render-worker.ts +3 -0
- package/src/types/ninety.ts +18 -0
- package/src/utils/Head.tsx +4 -2
- package/src/utils/clientScript.tsx +6 -2
- package/src/utils/loader.ts +193 -11
- package/src/utils/response.tsx +51 -55
- package/src/utils/serve.ts +40 -0
- package/src/utils/loadModule.ts +0 -4
package/src/utils/loader.ts
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
* synchronously on the server, wrapped in Promise.resolve to keep the
|
|
11
11
|
* API shape identical to the client side.
|
|
12
12
|
*
|
|
13
|
+
* Transformation strategy:
|
|
14
|
+
* Primary — SWC AST parsing via @swc/core. Handles any valid TS/JS syntax
|
|
15
|
+
* including arbitrarily-nested generics, comments, and string
|
|
16
|
+
* literals that contain the text "loadModule".
|
|
17
|
+
* Fallback — Regex transform used when @swc/core is unavailable.
|
|
18
|
+
*
|
|
13
19
|
* Example usage:
|
|
14
20
|
*
|
|
15
21
|
* import { loadModule } from 'hadars';
|
|
@@ -21,21 +27,197 @@
|
|
|
21
27
|
* const { default: fn } = await loadModule('./heavyUtil');
|
|
22
28
|
*/
|
|
23
29
|
|
|
24
|
-
// Matches: loadModule('./path')
|
|
25
|
-
// loadModule<SomeType>('./path') (TypeScript generic, any complexity)
|
|
26
|
-
// Captures group 1 = quote char, group 2 = the module path.
|
|
27
|
-
// The `s` flag lets `.` span newlines so multi-line generics are handled.
|
|
28
|
-
const LOAD_MODULE_RE =
|
|
29
|
-
/\bloadModule\s*(?:<.*?>\s*)?\(\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1\s*\)/gs;
|
|
30
|
-
|
|
31
30
|
export default function loader(this: any, source: string): string {
|
|
32
31
|
const isServer = this.target === 'node' || this.target === 'async-node';
|
|
32
|
+
const resourcePath: string = this.resourcePath ?? this.resource ?? '(unknown)';
|
|
33
|
+
|
|
34
|
+
let swc: any;
|
|
35
|
+
try {
|
|
36
|
+
swc = require('@swc/core');
|
|
37
|
+
} catch {
|
|
38
|
+
return regexTransform.call(this, source, isServer, resourcePath);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return swcTransform.call(this, swc, source, isServer, resourcePath);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// SWC AST transform
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
function swcTransform(this: any, swc: any, source: string, isServer: boolean, resourcePath: string): string {
|
|
49
|
+
const isTs = /\.[mc]?tsx?$/.test(resourcePath);
|
|
50
|
+
const isTsx = /\.(tsx|jsx)$/.test(resourcePath);
|
|
51
|
+
|
|
52
|
+
let ast: any;
|
|
53
|
+
try {
|
|
54
|
+
ast = swc.parseSync(source, {
|
|
55
|
+
syntax: isTs ? 'typescript' : 'ecmascript',
|
|
56
|
+
tsx: isTsx,
|
|
57
|
+
});
|
|
58
|
+
} catch {
|
|
59
|
+
// Unparseable file (e.g., exotic syntax) — fall back to regex
|
|
60
|
+
return regexTransform.call(this, source, isServer, resourcePath);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// SWC spans use 1-based byte offsets into a GLOBAL SourceMap that
|
|
64
|
+
// accumulates across parseSync calls.
|
|
65
|
+
//
|
|
66
|
+
// `ast.span.start` = global position of the FIRST meaningful (non-comment,
|
|
67
|
+
// non-whitespace) token. Subtract the leading non-code bytes to get the
|
|
68
|
+
// true global start of byte 0 of this source file.
|
|
69
|
+
//
|
|
70
|
+
// We do NOT use `ast.span.end - srcBytes.length` because `ast.span.end`
|
|
71
|
+
// only reaches the last AST token and does not include trailing whitespace
|
|
72
|
+
// or newlines — causing a systematic off-by-one for the typical file that
|
|
73
|
+
// ends with `\n`.
|
|
74
|
+
const srcBytes = Buffer.from(source, 'utf8');
|
|
75
|
+
const fileOffset = ast.span.start - countLeadingNonCodeBytes(source);
|
|
33
76
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
77
|
+
const replacements: Array<{ start: number; end: number; replacement: string }> = [];
|
|
78
|
+
|
|
79
|
+
walkAst(ast, (node: any) => {
|
|
80
|
+
if (node.type !== 'CallExpression') return;
|
|
81
|
+
|
|
82
|
+
const callee = node.callee;
|
|
83
|
+
if (!callee || callee.type !== 'Identifier' || callee.value !== 'loadModule') return;
|
|
84
|
+
|
|
85
|
+
const args: any[] = node.arguments;
|
|
86
|
+
if (!args || args.length === 0) return;
|
|
87
|
+
|
|
88
|
+
const firstArg = args[0].expression ?? args[0];
|
|
89
|
+
|
|
90
|
+
let modulePath: string;
|
|
91
|
+
let quoteChar: string;
|
|
92
|
+
|
|
93
|
+
if (firstArg.type === 'StringLiteral') {
|
|
94
|
+
modulePath = firstArg.value;
|
|
95
|
+
// The quote char (' " `) is always ASCII so byte index == char index here.
|
|
96
|
+
const quoteByteIdx = firstArg.span.start - fileOffset;
|
|
97
|
+
quoteChar = String.fromCharCode(srcBytes[quoteByteIdx]!);
|
|
98
|
+
} else if (
|
|
99
|
+
firstArg.type === 'TemplateLiteral' &&
|
|
100
|
+
firstArg.expressions.length === 0 &&
|
|
101
|
+
firstArg.quasis.length === 1
|
|
102
|
+
) {
|
|
103
|
+
// No-interpolation template literal: `./path`
|
|
104
|
+
modulePath = firstArg.quasis[0].raw;
|
|
105
|
+
quoteChar = '`';
|
|
37
106
|
} else {
|
|
38
|
-
|
|
107
|
+
// Dynamic (non-literal) path — emit a build warning
|
|
108
|
+
const start0 = node.span.start - fileOffset;
|
|
109
|
+
const bytesBefore = srcBytes.slice(0, start0);
|
|
110
|
+
const line = bytesBefore.toString('utf8').split('\n').length;
|
|
111
|
+
this.emitWarning(
|
|
112
|
+
new Error(
|
|
113
|
+
`[hadars] loadModule() called with a dynamic (non-literal) path at ${resourcePath}:${line}. ` +
|
|
114
|
+
`Only string-literal paths are transformed by the loader; dynamic calls fall back to runtime import().`
|
|
115
|
+
)
|
|
116
|
+
);
|
|
117
|
+
return;
|
|
39
118
|
}
|
|
119
|
+
const replacement = isServer
|
|
120
|
+
? `Promise.resolve(require(${quoteChar}${modulePath}${quoteChar}))`
|
|
121
|
+
: `import(${quoteChar}${modulePath}${quoteChar})`;
|
|
122
|
+
|
|
123
|
+
// Normalise to 0-based local byte offsets for Buffer.slice
|
|
124
|
+
replacements.push({ start: node.span.start - fileOffset, end: node.span.end - fileOffset, replacement });
|
|
40
125
|
});
|
|
126
|
+
|
|
127
|
+
if (replacements.length === 0) return source;
|
|
128
|
+
|
|
129
|
+
// Apply replacements from last to first so earlier byte offsets stay valid
|
|
130
|
+
replacements.sort((a, b) => b.start - a.start);
|
|
131
|
+
|
|
132
|
+
let result = srcBytes;
|
|
133
|
+
for (const { start, end, replacement } of replacements) {
|
|
134
|
+
result = Buffer.concat([result.slice(0, start), Buffer.from(replacement, 'utf8'), result.slice(end)]);
|
|
135
|
+
}
|
|
136
|
+
return result.toString('utf8');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Minimal recursive AST walker — visits every node depth-first.
|
|
140
|
+
function walkAst(node: any, visit: (n: any) => void): void {
|
|
141
|
+
if (!node || typeof node !== 'object') return;
|
|
142
|
+
visit(node);
|
|
143
|
+
for (const key of Object.keys(node)) {
|
|
144
|
+
if (key === 'span' || key === 'type' || key === 'ctxt') continue;
|
|
145
|
+
const val = node[key];
|
|
146
|
+
if (Array.isArray(val)) {
|
|
147
|
+
for (const child of val) walkAst(child, visit);
|
|
148
|
+
} else if (val && typeof val === 'object') {
|
|
149
|
+
walkAst(val, visit);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Returns the number of leading bytes that are pure whitespace / comments /
|
|
155
|
+
// shebangs — i.e. bytes before the first actual code token. Used to anchor
|
|
156
|
+
// SWC's accumulated global span offsets back to byte-0 of this source file.
|
|
157
|
+
function countLeadingNonCodeBytes(source: string): number {
|
|
158
|
+
let i = 0;
|
|
159
|
+
while (i < source.length) {
|
|
160
|
+
// Whitespace
|
|
161
|
+
if (source[i] === ' ' || source[i] === '\t' || source[i] === '\r' || source[i] === '\n') {
|
|
162
|
+
i++;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
// Line comment //...
|
|
166
|
+
if (source[i] === '/' && source[i + 1] === '/') {
|
|
167
|
+
while (i < source.length && source[i] !== '\n') i++;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
// Block comment /* ... */
|
|
171
|
+
if (source[i] === '/' && source[i + 1] === '*') {
|
|
172
|
+
i += 2;
|
|
173
|
+
while (i + 1 < source.length && !(source[i] === '*' && source[i + 1] === '/')) i++;
|
|
174
|
+
if (i + 1 < source.length) i += 2;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
// Shebang #!... (only valid at position 0)
|
|
178
|
+
if (i === 0 && source[i] === '#' && source[i + 1] === '!') {
|
|
179
|
+
while (i < source.length && source[i] !== '\n') i++;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
// SWC spans are UTF-8 byte offsets, but `i` here is a char index.
|
|
185
|
+
// Return the byte length of the leading non-code prefix.
|
|
186
|
+
return Buffer.byteLength(source.slice(0, i), 'utf8');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Regex fallback (used when @swc/core is not available)
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
// Matches loadModule('./path') with optional TypeScript generic (up to 2 levels
|
|
194
|
+
// of nesting). Captures: group 1 = quote char, group 2 = module path.
|
|
195
|
+
const LOAD_MODULE_RE =
|
|
196
|
+
/\bloadModule\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1\s*\)/gs;
|
|
197
|
+
|
|
198
|
+
// Matches any remaining loadModule( that was NOT handled by the regex above
|
|
199
|
+
// (i.e. a dynamic / non-literal path argument).
|
|
200
|
+
const DYNAMIC_LOAD_MODULE_RE = /\bloadModule\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(/g;
|
|
201
|
+
|
|
202
|
+
function regexTransform(this: any, source: string, isServer: boolean, resourcePath: string): string {
|
|
203
|
+
const transformed = source.replace(LOAD_MODULE_RE, (_match, quote, modulePath) =>
|
|
204
|
+
isServer
|
|
205
|
+
? `Promise.resolve(require(${quote}${modulePath}${quote}))`
|
|
206
|
+
: `import(${quote}${modulePath}${quote})`
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Warn for any remaining dynamic calls
|
|
210
|
+
let match: RegExpExecArray | null;
|
|
211
|
+
DYNAMIC_LOAD_MODULE_RE.lastIndex = 0;
|
|
212
|
+
while ((match = DYNAMIC_LOAD_MODULE_RE.exec(transformed)) !== null) {
|
|
213
|
+
const line = transformed.slice(0, match.index).split('\n').length;
|
|
214
|
+
this.emitWarning(
|
|
215
|
+
new Error(
|
|
216
|
+
`[hadars] loadModule() called with a dynamic (non-literal) path at ${resourcePath}:${line}. ` +
|
|
217
|
+
`Only string-literal paths are transformed by the loader; dynamic calls fall back to runtime import().`
|
|
218
|
+
)
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return transformed;
|
|
41
223
|
}
|
package/src/utils/response.tsx
CHANGED
|
@@ -30,49 +30,45 @@ interface ReactResponseOptions {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
))}
|
|
58
|
-
{styleEntries.map( ([id, options]) => (
|
|
59
|
-
<style
|
|
60
|
-
key={id}
|
|
61
|
-
id={id}
|
|
62
|
-
{ ...options }
|
|
63
|
-
/>
|
|
64
|
-
))}
|
|
65
|
-
{scriptEntries.map( ([id, options]) => (
|
|
66
|
-
<script
|
|
67
|
-
key={id}
|
|
68
|
-
id={id}
|
|
69
|
-
{ ...options }
|
|
70
|
-
/>
|
|
71
|
-
))}
|
|
72
|
-
</>
|
|
73
|
-
)
|
|
33
|
+
// ── Head HTML serialisation (no React render needed) ─────────────────────────
|
|
34
|
+
|
|
35
|
+
const ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
|
36
|
+
const escAttr = (s: string) => s.replace(/[&<>"]/g, c => ESC[c]);
|
|
37
|
+
const escText = (s: string) => s.replace(/[&<>]/g, c => ESC[c]);
|
|
38
|
+
|
|
39
|
+
// React prop → HTML attribute name for the subset used in head tags.
|
|
40
|
+
const ATTR: Record<string, string> = {
|
|
41
|
+
className: 'class', htmlFor: 'for', httpEquiv: 'http-equiv',
|
|
42
|
+
charSet: 'charset', crossOrigin: 'crossorigin', noModule: 'nomodule',
|
|
43
|
+
referrerPolicy: 'referrerpolicy', fetchPriority: 'fetchpriority',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function renderHeadTag(tag: string, id: string, opts: Record<string, unknown>, selfClose = false): string {
|
|
47
|
+
let attrs = ` id="${escAttr(id)}"`;
|
|
48
|
+
let inner = '';
|
|
49
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
50
|
+
if (k === 'key' || k === 'children') continue;
|
|
51
|
+
if (k === 'dangerouslySetInnerHTML') { inner = (v as any).__html ?? ''; continue; }
|
|
52
|
+
const attr = ATTR[k] ?? k;
|
|
53
|
+
if (v === true) attrs += ` ${attr}`;
|
|
54
|
+
else if (v !== false && v != null) attrs += ` ${attr}="${escAttr(String(v))}"`;
|
|
55
|
+
}
|
|
56
|
+
return selfClose ? `<${tag}${attrs}>` : `<${tag}${attrs}>${inner}</${tag}>`;
|
|
74
57
|
}
|
|
75
58
|
|
|
59
|
+
const getHeadHtml = (seoData: AppHead): string => {
|
|
60
|
+
let html = `<title>${escText(seoData.title ?? '')}</title>`;
|
|
61
|
+
for (const [id, opts] of Object.entries(seoData.meta))
|
|
62
|
+
html += renderHeadTag('meta', id, opts as Record<string, unknown>, true);
|
|
63
|
+
for (const [id, opts] of Object.entries(seoData.link))
|
|
64
|
+
html += renderHeadTag('link', id, opts as Record<string, unknown>, true);
|
|
65
|
+
for (const [id, opts] of Object.entries(seoData.style))
|
|
66
|
+
html += renderHeadTag('style', id, opts as Record<string, unknown>);
|
|
67
|
+
for (const [id, opts] of Object.entries(seoData.script))
|
|
68
|
+
html += renderHeadTag('script', id, opts as Record<string, unknown>);
|
|
69
|
+
return html;
|
|
70
|
+
};
|
|
71
|
+
|
|
76
72
|
|
|
77
73
|
export const getReactResponse = async (
|
|
78
74
|
req: HadarsRequest,
|
|
@@ -141,20 +137,20 @@ export const getReactResponse = async (
|
|
|
141
137
|
}
|
|
142
138
|
if (unsuspend.hasPending) await processUnsuspend();
|
|
143
139
|
} while (unsuspend.hasPending && ++iters < 25);
|
|
140
|
+
if (unsuspend.hasPending) {
|
|
141
|
+
console.warn('[hadars] SSR render loop hit the 25-iteration cap — some useServerData values may not be resolved. Check for data dependencies that are never fulfilled.');
|
|
142
|
+
}
|
|
144
143
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
);
|
|
156
|
-
} finally {
|
|
157
|
-
(globalThis as any).__hadarsUnsuspend = null;
|
|
144
|
+
if (getAfterRenderProps) {
|
|
145
|
+
props = await getAfterRenderProps(props, html);
|
|
146
|
+
// Re-render only when getAfterRenderProps is present — it may mutate
|
|
147
|
+
// props that affect head tags, so we need another pass to capture them.
|
|
148
|
+
try {
|
|
149
|
+
(globalThis as any).__hadarsUnsuspend = unsuspend;
|
|
150
|
+
renderToStaticMarkup(<App {...({ ...props, location: req.location, context })} />);
|
|
151
|
+
} finally {
|
|
152
|
+
(globalThis as any).__hadarsUnsuspend = null;
|
|
153
|
+
}
|
|
158
154
|
}
|
|
159
155
|
|
|
160
156
|
// Serialize resolved useServerData() values for client hydration.
|
|
@@ -188,7 +184,7 @@ export const getReactResponse = async (
|
|
|
188
184
|
return {
|
|
189
185
|
ReactPage,
|
|
190
186
|
status: context.head.status,
|
|
191
|
-
headHtml: getHeadHtml(context.head
|
|
187
|
+
headHtml: getHeadHtml(context.head),
|
|
192
188
|
renderPayload: {
|
|
193
189
|
appProps: { ...props, location: req.location, context } as Record<string, unknown>,
|
|
194
190
|
clientProps: clientProps as Record<string, unknown>,
|
package/src/utils/serve.ts
CHANGED
|
@@ -46,12 +46,52 @@ const noopCtx: ServerContext = { upgrade: () => false };
|
|
|
46
46
|
* The `fetchHandler` may return `undefined` to signal that the response was
|
|
47
47
|
* handled out-of-band (e.g. a Bun WebSocket upgrade).
|
|
48
48
|
*/
|
|
49
|
+
const COMPRESSIBLE_RE = /\b(?:text\/|application\/(?:json|javascript|xml)|image\/svg\+xml)/;
|
|
50
|
+
|
|
51
|
+
function withCompression(handler: FetchHandler): FetchHandler {
|
|
52
|
+
return async (req, ctx) => {
|
|
53
|
+
const res = await handler(req, ctx);
|
|
54
|
+
if (!res?.body) return res;
|
|
55
|
+
if (!COMPRESSIBLE_RE.test(res.headers.get('Content-Type') ?? '')) return res;
|
|
56
|
+
if (res.headers.has('Content-Encoding')) return res; // already compressed
|
|
57
|
+
|
|
58
|
+
const accept = req.headers.get('Accept-Encoding') ?? '';
|
|
59
|
+
const encoding = accept.includes('br') ? 'br' : accept.includes('gzip') ? 'gzip' : null;
|
|
60
|
+
if (!encoding) return res;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const compressed = res.body.pipeThrough(new (globalThis as any).CompressionStream(encoding));
|
|
64
|
+
const headers = new Headers(res.headers);
|
|
65
|
+
headers.set('Content-Encoding', encoding);
|
|
66
|
+
headers.delete('Content-Length');
|
|
67
|
+
return new Response(compressed, { status: res.status, statusText: res.statusText, headers });
|
|
68
|
+
} catch {
|
|
69
|
+
return res;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function withRequestLogging(handler: FetchHandler): FetchHandler {
|
|
75
|
+
return async (req, ctx) => {
|
|
76
|
+
const start = performance.now();
|
|
77
|
+
const res = await handler(req, ctx);
|
|
78
|
+
const ms = Math.round(performance.now() - start);
|
|
79
|
+
const status = res?.status ?? 404;
|
|
80
|
+
const path = new URL(req.url).pathname;
|
|
81
|
+
console.log(`[hadars] ${req.method} ${path} ${status} ${ms}ms`);
|
|
82
|
+
return res;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
49
86
|
export async function serve(
|
|
50
87
|
port: number,
|
|
51
88
|
fetchHandler: FetchHandler,
|
|
52
89
|
/** Bun WebSocketHandler — ignored on Deno and Node.js. */
|
|
53
90
|
websocket?: unknown,
|
|
54
91
|
): Promise<void> {
|
|
92
|
+
fetchHandler = withCompression(fetchHandler);
|
|
93
|
+
fetchHandler = withRequestLogging(fetchHandler);
|
|
94
|
+
|
|
55
95
|
// ── Bun ────────────────────────────────────────────────────────────────
|
|
56
96
|
if (isBun) {
|
|
57
97
|
(globalThis as any).Bun.serve({
|
package/src/utils/loadModule.ts
DELETED