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.
@@ -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
- return source.replace(LOAD_MODULE_RE, (_match, quote, modulePath) => {
35
- if (isServer) {
36
- return `Promise.resolve(require(${quote}${modulePath}${quote}))`;
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
- return `import(${quote}${modulePath}${quote})`;
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
  }
@@ -30,49 +30,45 @@ interface ReactResponseOptions {
30
30
  }
31
31
  }
32
32
 
33
- const getHeadHtml = (seoData: AppHead, renderToStaticMarkup: (el: any) => string): string => {
34
- const metaEntries = Object.entries(seoData.meta)
35
- const linkEntries = Object.entries(seoData.link)
36
- const styleEntries = Object.entries(seoData.style)
37
- const scriptEntries = Object.entries(seoData.script)
38
-
39
- return renderToStaticMarkup(
40
- <>
41
- <title>{seoData.title}</title>
42
- {
43
- metaEntries.map( ([id, options]) => (
44
- <meta
45
- key={id}
46
- id={ id }
47
- { ...options }
48
- />
49
- ) )
50
- }
51
- {linkEntries.map( ([id, options]) => (
52
- <link
53
- key={id}
54
- id={ id }
55
- { ...options }
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> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
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
- props = getAfterRenderProps ? await getAfterRenderProps(props, html) : props;
146
- // Re-render to capture any head changes introduced by getAfterRenderProps.
147
- try {
148
- (globalThis as any).__hadarsUnsuspend = unsuspend;
149
- renderToStaticMarkup(
150
- <App {...({
151
- ...props,
152
- location: req.location,
153
- context,
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, renderToStaticMarkup),
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>,
@@ -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({
@@ -1,4 +0,0 @@
1
- export const loadModule = <T>(path: string): T => {
2
- return import(path) as unknown as T;
3
- // throw new Error('loadModule should be transformed by loader')
4
- }