hadars 0.1.33 → 0.1.34

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 CHANGED
@@ -1181,7 +1181,8 @@ var getConfigBase = (mode, isServerBuild = false) => {
1181
1181
  // Transforms loadModule('./path') based on build target.
1182
1182
  // Runs before swc-loader (loaders execute right-to-left).
1183
1183
  {
1184
- loader: loaderPath
1184
+ loader: loaderPath,
1185
+ options: { server: isServerBuild }
1185
1186
  },
1186
1187
  {
1187
1188
  loader: "builtin:swc-loader",
@@ -1212,7 +1213,8 @@ var getConfigBase = (mode, isServerBuild = false) => {
1212
1213
  exclude: [loaderPath],
1213
1214
  use: [
1214
1215
  {
1215
- loader: loaderPath
1216
+ loader: loaderPath,
1217
+ options: { server: isServerBuild }
1216
1218
  },
1217
1219
  {
1218
1220
  loader: "builtin:swc-loader",
package/dist/loader.cjs CHANGED
@@ -22,7 +22,8 @@ __export(loader_exports, {
22
22
  });
23
23
  module.exports = __toCommonJS(loader_exports);
24
24
  function loader(source) {
25
- const isServer = this.target === "node" || this.target === "async-node";
25
+ const opts = this.getOptions?.() ?? {};
26
+ const isServer = typeof opts.server === "boolean" ? opts.server : this.target === "node" || this.target === "async-node";
26
27
  const resourcePath = this.resourcePath ?? this.resource ?? "(unknown)";
27
28
  let swc;
28
29
  try {
@@ -51,7 +52,22 @@ function swcTransform(swc, source, isServer, resourcePath) {
51
52
  if (node.type !== "CallExpression")
52
53
  return;
53
54
  const callee = node.callee;
54
- if (!callee || callee.type !== "Identifier" || callee.value !== "loadModule")
55
+ if (!callee || callee.type !== "Identifier")
56
+ return;
57
+ const name = callee.value;
58
+ if (!isServer && name === "useServerData") {
59
+ const args2 = node.arguments;
60
+ if (!args2 || args2.length < 2)
61
+ return;
62
+ const fnArg = args2[1].expression ?? args2[1];
63
+ replacements.push({
64
+ start: fnArg.span.start - fileOffset,
65
+ end: fnArg.span.end - fileOffset,
66
+ replacement: "()=>undefined"
67
+ });
68
+ return;
69
+ }
70
+ if (name !== "loadModule")
55
71
  return;
56
72
  const args = node.arguments;
57
73
  if (!args || args.length === 0)
@@ -136,11 +152,87 @@ function countLeadingNonCodeBytes(source) {
136
152
  }
137
153
  const LOAD_MODULE_RE = /\bloadModule\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1\s*\)/gs;
138
154
  const DYNAMIC_LOAD_MODULE_RE = /\bloadModule\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(/g;
155
+ function scanExpressionEnd(source, pos) {
156
+ let depth = 0;
157
+ let i = pos;
158
+ while (i < source.length) {
159
+ const ch = source[i];
160
+ if (ch === "(" || ch === "[" || ch === "{") {
161
+ depth++;
162
+ i++;
163
+ continue;
164
+ }
165
+ if (ch === ")" || ch === "]" || ch === "}") {
166
+ if (depth === 0)
167
+ break;
168
+ depth--;
169
+ i++;
170
+ continue;
171
+ }
172
+ if (ch === "," && depth === 0)
173
+ break;
174
+ if (ch === '"' || ch === "'" || ch === "`") {
175
+ const q = ch;
176
+ i++;
177
+ while (i < source.length && source[i] !== q) {
178
+ if (source[i] === "\\")
179
+ i++;
180
+ i++;
181
+ }
182
+ i++;
183
+ continue;
184
+ }
185
+ if (ch === "/" && source[i + 1] === "/") {
186
+ while (i < source.length && source[i] !== "\n")
187
+ i++;
188
+ continue;
189
+ }
190
+ if (ch === "/" && source[i + 1] === "*") {
191
+ i += 2;
192
+ while (i + 1 < source.length && !(source[i] === "*" && source[i + 1] === "/"))
193
+ i++;
194
+ i += 2;
195
+ continue;
196
+ }
197
+ i++;
198
+ }
199
+ return i;
200
+ }
201
+ function stripUseServerDataFns(source) {
202
+ const CALL_RE = /\buseServerData\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(/g;
203
+ let result = "";
204
+ let lastIndex = 0;
205
+ let match;
206
+ CALL_RE.lastIndex = 0;
207
+ while ((match = CALL_RE.exec(source)) !== null) {
208
+ const callStart = match.index;
209
+ let i = match.index + match[0].length;
210
+ while (i < source.length && /\s/.test(source[i]))
211
+ i++;
212
+ i = scanExpressionEnd(source, i);
213
+ if (i >= source.length || source[i] !== ",")
214
+ continue;
215
+ i++;
216
+ while (i < source.length && /\s/.test(source[i]))
217
+ i++;
218
+ const fnStart = i;
219
+ const fnEnd = scanExpressionEnd(source, i);
220
+ if (fnEnd <= fnStart)
221
+ continue;
222
+ result += source.slice(lastIndex, fnStart) + "()=>undefined";
223
+ lastIndex = fnEnd;
224
+ CALL_RE.lastIndex = fnEnd;
225
+ }
226
+ return lastIndex === 0 ? source : result + source.slice(lastIndex);
227
+ }
139
228
  function regexTransform(source, isServer, resourcePath) {
140
- const transformed = source.replace(
229
+ let transformed = source.replace(
141
230
  LOAD_MODULE_RE,
142
231
  (_match, quote, modulePath) => isServer ? `Promise.resolve(require(${quote}${modulePath}${quote}))` : `import(${quote}${modulePath}${quote})`
143
232
  );
233
+ if (!isServer) {
234
+ transformed = stripUseServerDataFns(transformed);
235
+ }
144
236
  let match;
145
237
  DYNAMIC_LOAD_MODULE_RE.lastIndex = 0;
146
238
  while ((match = DYNAMIC_LOAD_MODULE_RE.exec(transformed)) !== null) {
package/dist/ssr-watch.js CHANGED
@@ -52,7 +52,8 @@ var getConfigBase = (mode, isServerBuild = false) => {
52
52
  // Transforms loadModule('./path') based on build target.
53
53
  // Runs before swc-loader (loaders execute right-to-left).
54
54
  {
55
- loader: loaderPath
55
+ loader: loaderPath,
56
+ options: { server: isServerBuild }
56
57
  },
57
58
  {
58
59
  loader: "builtin:swc-loader",
@@ -83,7 +84,8 @@ var getConfigBase = (mode, isServerBuild = false) => {
83
84
  exclude: [loaderPath],
84
85
  use: [
85
86
  {
86
- loader: loaderPath
87
+ loader: loaderPath,
88
+ options: { server: isServerBuild }
87
89
  },
88
90
  {
89
91
  loader: "builtin:swc-loader",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hadars",
3
- "version": "0.1.33",
3
+ "version": "0.1.34",
4
4
  "description": "Minimal SSR framework for React — rspack, HMR, TypeScript, Bun/Node/Deno",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -1,34 +1,49 @@
1
1
  /**
2
- * Rspack/webpack loader that transforms `loadModule('path')` calls based on
3
- * the compilation target:
2
+ * Rspack/webpack loader that applies two source-level transforms based on the
3
+ * compilation target (web vs node):
4
4
  *
5
+ * ── loadModule('path') ────────────────────────────────────────────────────────
5
6
  * - web (browser): replaced with `import('./path')` — rspack treats this as
6
7
  * a true dynamic import and splits the module into a separate chunk.
7
- *
8
8
  * - node (SSR): replaced with `Promise.resolve(require('./path'))` —
9
- * rspack bundles the module statically so it is always available
10
- * synchronously on the server, wrapped in Promise.resolve to keep the
11
- * API shape identical to the client side.
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.
12
20
  *
13
21
  * Transformation strategy:
14
22
  * Primary — SWC AST parsing via @swc/core. Handles any valid TS/JS syntax
15
23
  * including arbitrarily-nested generics, comments, and string
16
- * literals that contain the text "loadModule".
17
- * Fallback — Regex transform used when @swc/core is unavailable.
24
+ * literals that contain the function names.
25
+ * Fallback — Scanner-based transform used when @swc/core is unavailable.
18
26
  *
19
- * Example usage:
27
+ * Example:
20
28
  *
21
- * import { loadModule } from 'hadars';
29
+ * // Source (shared component):
30
+ * const user = useServerData('user', () => db.getUser(req.userId));
22
31
  *
23
- * // Code-split React component (wrap with React.lazy + Suspense):
24
- * const MyComp = React.lazy(() => loadModule('./MyComp'));
32
+ * // Client bundle after transform:
33
+ * const user = useServerData('user', ()=>undefined);
25
34
  *
26
- * // Dynamic module load:
27
- * const { default: fn } = await loadModule('./heavyUtil');
35
+ * // Server bundle (unchanged):
36
+ * const user = useServerData('user', () => db.getUser(req.userId));
28
37
  */
29
38
 
30
39
  export default function loader(this: any, source: string): string {
31
- const isServer = this.target === 'node' || this.target === 'async-node';
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');
32
47
  const resourcePath: string = this.resourcePath ?? this.resource ?? '(unknown)';
33
48
 
34
49
  let swc: any;
@@ -80,7 +95,26 @@ function swcTransform(this: any, swc: any, source: string, isServer: boolean, re
80
95
  if (node.type !== 'CallExpression') return;
81
96
 
82
97
  const callee = node.callee;
83
- if (!callee || callee.type !== 'Identifier' || callee.value !== 'loadModule') return;
98
+ if (!callee || callee.type !== 'Identifier') return;
99
+
100
+ const name: string = callee.value;
101
+
102
+ // ── useServerData(key, fn) — strip fn on client builds ────────────────
103
+ if (!isServer && name === 'useServerData') {
104
+ const args: any[] = node.arguments;
105
+ if (!args || args.length < 2) return;
106
+ const fnArg = args[1].expression ?? args[1];
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;
84
118
 
85
119
  const args: any[] = node.arguments;
86
120
  if (!args || args.length === 0) return;
@@ -199,13 +233,99 @@ const LOAD_MODULE_RE =
199
233
  // (i.e. a dynamic / non-literal path argument).
200
234
  const DYNAMIC_LOAD_MODULE_RE = /\bloadModule\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(/g;
201
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(key, 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
+ const callStart = match.index;
294
+ let i = match.index + match[0].length;
295
+ // Skip whitespace before first arg
296
+ while (i < source.length && /\s/.test(source[i]!)) i++;
297
+ // Skip first argument (key: string or array)
298
+ i = scanExpressionEnd(source, i);
299
+ // Expect comma separator
300
+ if (i >= source.length || source[i] !== ',') continue;
301
+ i++; // skip comma
302
+ // Skip whitespace before fn
303
+ while (i < source.length && /\s/.test(source[i]!)) i++;
304
+ const fnStart = i;
305
+ // Scan to end of fn argument
306
+ const fnEnd = scanExpressionEnd(source, i);
307
+ if (fnEnd <= fnStart) continue;
308
+ // Emit everything up to fn, then the stub, skip the original fn
309
+ result += source.slice(lastIndex, fnStart) + '()=>undefined';
310
+ lastIndex = fnEnd;
311
+ // Advance regex past this call to avoid re-matching
312
+ CALL_RE.lastIndex = fnEnd;
313
+ }
314
+ return lastIndex === 0 ? source : result + source.slice(lastIndex);
315
+ }
316
+
202
317
  function regexTransform(this: any, source: string, isServer: boolean, resourcePath: string): string {
203
- const transformed = source.replace(LOAD_MODULE_RE, (_match, quote, modulePath) =>
318
+ let transformed = source.replace(LOAD_MODULE_RE, (_match, quote, modulePath) =>
204
319
  isServer
205
320
  ? `Promise.resolve(require(${quote}${modulePath}${quote}))`
206
321
  : `import(${quote}${modulePath}${quote})`
207
322
  );
208
323
 
324
+ // Strip server-only fn arguments from useServerData on client builds.
325
+ if (!isServer) {
326
+ transformed = stripUseServerDataFns(transformed);
327
+ }
328
+
209
329
  // Warn for any remaining dynamic calls
210
330
  let match: RegExpExecArray | null;
211
331
  DYNAMIC_LOAD_MODULE_RE.lastIndex = 0;
@@ -59,6 +59,7 @@ const getConfigBase = (mode: "development" | "production", isServerBuild = false
59
59
  // Runs before swc-loader (loaders execute right-to-left).
60
60
  {
61
61
  loader: loaderPath,
62
+ options: { server: isServerBuild },
62
63
  },
63
64
  {
64
65
  loader: 'builtin:swc-loader',
@@ -90,6 +91,7 @@ const getConfigBase = (mode: "development" | "production", isServerBuild = false
90
91
  use: [
91
92
  {
92
93
  loader: loaderPath,
94
+ options: { server: isServerBuild },
93
95
  },
94
96
  {
95
97
  loader: 'builtin:swc-loader',