@mionjs/devtools 0.8.7 → 0.8.8

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 (131) hide show
  1. package/build/eslint/cjs/index.cjs.map +1 -1
  2. package/build/eslint/cjs/src/eslint/index.d.ts +1 -0
  3. package/build/eslint/cjs/src/eslint/index.d.ts.map +1 -0
  4. package/build/eslint/cjs/src/eslint/rules/enforce-type-imports.d.ts +1 -0
  5. package/build/eslint/cjs/src/eslint/rules/enforce-type-imports.d.ts.map +1 -0
  6. package/build/eslint/cjs/src/eslint/rules/formatTypeNames.d.ts +1 -0
  7. package/build/eslint/cjs/src/eslint/rules/formatTypeNames.d.ts.map +1 -0
  8. package/build/eslint/cjs/src/eslint/rules/no-mixed-union-properties.d.ts +1 -0
  9. package/build/eslint/cjs/src/eslint/rules/no-mixed-union-properties.d.ts.map +1 -0
  10. package/build/eslint/cjs/src/eslint/rules/no-type-imports.d.ts +1 -0
  11. package/build/eslint/cjs/src/eslint/rules/no-type-imports.d.ts.map +1 -0
  12. package/build/eslint/cjs/src/eslint/rules/no-typeof-runtype.d.ts +1 -0
  13. package/build/eslint/cjs/src/eslint/rules/no-typeof-runtype.d.ts.map +1 -0
  14. package/build/eslint/cjs/src/eslint/rules/no-unreachable-union-types.d.ts +1 -0
  15. package/build/eslint/cjs/src/eslint/rules/no-unreachable-union-types.d.ts.map +1 -0
  16. package/build/eslint/cjs/src/eslint/rules/no-vite-client.d.ts +1 -0
  17. package/build/eslint/cjs/src/eslint/rules/no-vite-client.d.ts.map +1 -0
  18. package/build/eslint/cjs/src/eslint/rules/pure-functions.d.ts +1 -0
  19. package/build/eslint/cjs/src/eslint/rules/pure-functions.d.ts.map +1 -0
  20. package/build/eslint/cjs/src/eslint/rules/strong-typed-routes.d.ts +1 -0
  21. package/build/eslint/cjs/src/eslint/rules/strong-typed-routes.d.ts.map +1 -0
  22. package/build/eslint/cjs/src/eslint/rules/type-formats-imports.d.ts +1 -0
  23. package/build/eslint/cjs/src/eslint/rules/type-formats-imports.d.ts.map +1 -0
  24. package/build/eslint/cjs/src/pureFns/purityRules.d.ts +1 -0
  25. package/build/eslint/cjs/src/pureFns/purityRules.d.ts.map +1 -0
  26. package/build/eslint/esm/index.js.map +1 -1
  27. package/build/eslint/esm/src/eslint/index.d.ts +1 -0
  28. package/build/eslint/esm/src/eslint/index.d.ts.map +1 -0
  29. package/build/eslint/esm/src/eslint/rules/enforce-type-imports.d.ts +1 -0
  30. package/build/eslint/esm/src/eslint/rules/enforce-type-imports.d.ts.map +1 -0
  31. package/build/eslint/esm/src/eslint/rules/formatTypeNames.d.ts +1 -0
  32. package/build/eslint/esm/src/eslint/rules/formatTypeNames.d.ts.map +1 -0
  33. package/build/eslint/esm/src/eslint/rules/no-mixed-union-properties.d.ts +1 -0
  34. package/build/eslint/esm/src/eslint/rules/no-mixed-union-properties.d.ts.map +1 -0
  35. package/build/eslint/esm/src/eslint/rules/no-type-imports.d.ts +1 -0
  36. package/build/eslint/esm/src/eslint/rules/no-type-imports.d.ts.map +1 -0
  37. package/build/eslint/esm/src/eslint/rules/no-typeof-runtype.d.ts +1 -0
  38. package/build/eslint/esm/src/eslint/rules/no-typeof-runtype.d.ts.map +1 -0
  39. package/build/eslint/esm/src/eslint/rules/no-unreachable-union-types.d.ts +1 -0
  40. package/build/eslint/esm/src/eslint/rules/no-unreachable-union-types.d.ts.map +1 -0
  41. package/build/eslint/esm/src/eslint/rules/no-vite-client.d.ts +1 -0
  42. package/build/eslint/esm/src/eslint/rules/no-vite-client.d.ts.map +1 -0
  43. package/build/eslint/esm/src/eslint/rules/pure-functions.d.ts +1 -0
  44. package/build/eslint/esm/src/eslint/rules/pure-functions.d.ts.map +1 -0
  45. package/build/eslint/esm/src/eslint/rules/strong-typed-routes.d.ts +1 -0
  46. package/build/eslint/esm/src/eslint/rules/strong-typed-routes.d.ts.map +1 -0
  47. package/build/eslint/esm/src/eslint/rules/type-formats-imports.d.ts +1 -0
  48. package/build/eslint/esm/src/eslint/rules/type-formats-imports.d.ts.map +1 -0
  49. package/build/eslint/esm/src/pureFns/purityRules.d.ts +1 -0
  50. package/build/eslint/esm/src/pureFns/purityRules.d.ts.map +1 -0
  51. package/build/vite-plugin/cjs/index.cjs +0 -1
  52. package/build/vite-plugin/cjs/index.cjs.map +1 -1
  53. package/build/vite-plugin/cjs/src/pureFns/purityRules.d.ts +1 -0
  54. package/build/vite-plugin/cjs/src/pureFns/purityRules.d.ts.map +1 -0
  55. package/build/vite-plugin/cjs/src/vite-plugin/aotCacheGenerator.d.ts +1 -0
  56. package/build/vite-plugin/cjs/src/vite-plugin/aotCacheGenerator.d.ts.map +1 -0
  57. package/build/vite-plugin/cjs/src/vite-plugin/aotDiskCache.d.ts +1 -0
  58. package/build/vite-plugin/cjs/src/vite-plugin/aotDiskCache.d.ts.map +1 -0
  59. package/build/vite-plugin/cjs/src/vite-plugin/cjsPackageJsonPlugin.d.ts +1 -0
  60. package/build/vite-plugin/cjs/src/vite-plugin/cjsPackageJsonPlugin.d.ts.map +1 -0
  61. package/build/vite-plugin/cjs/src/vite-plugin/constants.d.ts +1 -0
  62. package/build/vite-plugin/cjs/src/vite-plugin/constants.d.ts.map +1 -0
  63. package/build/vite-plugin/cjs/src/vite-plugin/extractPureFn.d.ts +1 -0
  64. package/build/vite-plugin/cjs/src/vite-plugin/extractPureFn.d.ts.map +1 -0
  65. package/build/vite-plugin/cjs/src/vite-plugin/index.cjs +0 -1
  66. package/build/vite-plugin/cjs/src/vite-plugin/index.cjs.map +1 -1
  67. package/build/vite-plugin/cjs/src/vite-plugin/index.d.ts +2 -1
  68. package/build/vite-plugin/cjs/src/vite-plugin/index.d.ts.map +1 -0
  69. package/build/vite-plugin/cjs/src/vite-plugin/mionVitePlugin.cjs.map +1 -1
  70. package/build/vite-plugin/cjs/src/vite-plugin/mionVitePlugin.d.ts +1 -0
  71. package/build/vite-plugin/cjs/src/vite-plugin/mionVitePlugin.d.ts.map +1 -0
  72. package/build/vite-plugin/cjs/src/vite-plugin/resolveModule.d.ts +1 -0
  73. package/build/vite-plugin/cjs/src/vite-plugin/resolveModule.d.ts.map +1 -0
  74. package/build/vite-plugin/cjs/src/vite-plugin/transformers.d.ts +1 -0
  75. package/build/vite-plugin/cjs/src/vite-plugin/transformers.d.ts.map +1 -0
  76. package/build/vite-plugin/cjs/src/vite-plugin/types.d.ts +1 -0
  77. package/build/vite-plugin/cjs/src/vite-plugin/types.d.ts.map +1 -0
  78. package/build/vite-plugin/cjs/src/vite-plugin/virtualModule.d.ts +1 -0
  79. package/build/vite-plugin/cjs/src/vite-plugin/virtualModule.d.ts.map +1 -0
  80. package/build/vite-plugin/esm/index.js +2 -3
  81. package/build/vite-plugin/esm/src/pureFns/purityRules.d.ts +1 -0
  82. package/build/vite-plugin/esm/src/pureFns/purityRules.d.ts.map +1 -0
  83. package/build/vite-plugin/esm/src/vite-plugin/aotCacheGenerator.d.ts +1 -0
  84. package/build/vite-plugin/esm/src/vite-plugin/aotCacheGenerator.d.ts.map +1 -0
  85. package/build/vite-plugin/esm/src/vite-plugin/aotDiskCache.d.ts +1 -0
  86. package/build/vite-plugin/esm/src/vite-plugin/aotDiskCache.d.ts.map +1 -0
  87. package/build/vite-plugin/esm/src/vite-plugin/cjsPackageJsonPlugin.d.ts +1 -0
  88. package/build/vite-plugin/esm/src/vite-plugin/cjsPackageJsonPlugin.d.ts.map +1 -0
  89. package/build/vite-plugin/esm/src/vite-plugin/constants.d.ts +1 -0
  90. package/build/vite-plugin/esm/src/vite-plugin/constants.d.ts.map +1 -0
  91. package/build/vite-plugin/esm/src/vite-plugin/extractPureFn.d.ts +1 -0
  92. package/build/vite-plugin/esm/src/vite-plugin/extractPureFn.d.ts.map +1 -0
  93. package/build/vite-plugin/esm/src/vite-plugin/index.d.ts +2 -1
  94. package/build/vite-plugin/esm/src/vite-plugin/index.d.ts.map +1 -0
  95. package/build/vite-plugin/esm/src/vite-plugin/index.js +2 -3
  96. package/build/vite-plugin/esm/src/vite-plugin/mionVitePlugin.d.ts +1 -0
  97. package/build/vite-plugin/esm/src/vite-plugin/mionVitePlugin.d.ts.map +1 -0
  98. package/build/vite-plugin/esm/src/vite-plugin/mionVitePlugin.js.map +1 -1
  99. package/build/vite-plugin/esm/src/vite-plugin/resolveModule.d.ts +1 -0
  100. package/build/vite-plugin/esm/src/vite-plugin/resolveModule.d.ts.map +1 -0
  101. package/build/vite-plugin/esm/src/vite-plugin/transformers.d.ts +1 -0
  102. package/build/vite-plugin/esm/src/vite-plugin/transformers.d.ts.map +1 -0
  103. package/build/vite-plugin/esm/src/vite-plugin/types.d.ts +1 -0
  104. package/build/vite-plugin/esm/src/vite-plugin/types.d.ts.map +1 -0
  105. package/build/vite-plugin/esm/src/vite-plugin/virtualModule.d.ts +1 -0
  106. package/build/vite-plugin/esm/src/vite-plugin/virtualModule.d.ts.map +1 -0
  107. package/package.json +5 -3
  108. package/src/eslint/index.ts +53 -0
  109. package/src/eslint/rules/enforce-type-imports.ts +157 -0
  110. package/src/eslint/rules/formatTypeNames.ts +92 -0
  111. package/src/eslint/rules/no-mixed-union-properties.ts +276 -0
  112. package/src/eslint/rules/no-type-imports.ts +386 -0
  113. package/src/eslint/rules/no-typeof-runtype.ts +112 -0
  114. package/src/eslint/rules/no-unreachable-union-types.ts +357 -0
  115. package/src/eslint/rules/no-vite-client.ts +101 -0
  116. package/src/eslint/rules/pure-functions.ts +523 -0
  117. package/src/eslint/rules/strong-typed-routes.ts +537 -0
  118. package/src/eslint/rules/type-formats-imports.ts +73 -0
  119. package/src/pureFns/purityRules.ts +76 -0
  120. package/src/vite-plugin/README.md +399 -0
  121. package/src/vite-plugin/aotCacheGenerator.ts +397 -0
  122. package/src/vite-plugin/aotDiskCache.ts +193 -0
  123. package/src/vite-plugin/cjsPackageJsonPlugin.ts +21 -0
  124. package/src/vite-plugin/constants.ts +50 -0
  125. package/src/vite-plugin/extractPureFn.ts +845 -0
  126. package/src/vite-plugin/index.ts +27 -0
  127. package/src/vite-plugin/mionVitePlugin.ts +738 -0
  128. package/src/vite-plugin/resolveModule.ts +50 -0
  129. package/src/vite-plugin/transformers.ts +209 -0
  130. package/src/vite-plugin/types.ts +182 -0
  131. package/src/vite-plugin/virtualModule.ts +94 -0
@@ -0,0 +1,845 @@
1
+ /* ########
2
+ * 2026 mion
3
+ * Author: Ma-jerez
4
+ * License: MIT
5
+ * The software is provided "as is", without warranty of any kind.
6
+ * ######## */
7
+
8
+ import * as ts from 'typescript';
9
+ import {createHash} from 'crypto';
10
+ import {transformSync} from 'esbuild';
11
+ import {ExtractedPureFn, ServerPureFunctionsOptions} from './types.ts';
12
+ import {BODY_HASH_LENGTH, PURE_SERVER_FN_NAMESPACE} from './constants.ts';
13
+ import {ALLOWED_GLOBALS, FORBIDDEN_IDENTIFIERS} from '../pureFns/purityRules.ts';
14
+ import {readdirSync, statSync, readFileSync} from 'fs';
15
+ import {resolve, join} from 'path/posix';
16
+ import {isIncluded} from './mionVitePlugin.ts';
17
+
18
+ /**
19
+ * Extracts all <script> block contents and lang attributes from a Vue SFC source.
20
+ * Uses a regex + state machine approach instead of a full XML/HTML AST parser. Vue SFCs have
21
+ * a simple, well-defined structure (top-level <script> blocks only) so this is sufficient.
22
+ * This avoids adding @vue/compiler-sfc as a dependency just for keyword detection in the
23
+ * scan pipeline. The Vite transform pipeline relies on @vitejs/plugin-vue for proper parsing.
24
+ * A small string-literal tracker skips </script> inside quotes so embedded script tags
25
+ * in string literals (e.g. injecting a header script as pure text) don't break extraction.
26
+ */
27
+ export function extractVueScriptContent(source: string): {content: string; lang: string} | null {
28
+ // Matches <script ...> allowing > inside quoted attribute values (e.g. generic="T extends Foo<Bar>")
29
+ const openRegex = /<script\b((?:[^>"']|"[^"]*"|'[^']*')*)>/gi;
30
+ const closeTag = '</script>';
31
+ let combined = '';
32
+ let lang = 'js';
33
+ let found = false;
34
+
35
+ let openMatch: RegExpExecArray | null;
36
+ while ((openMatch = openRegex.exec(source)) !== null) {
37
+ const attrs = openMatch[1];
38
+ const contentStart = openMatch.index + openMatch[0].length;
39
+ const closeIdx = findClosingScriptTag(source, contentStart, closeTag);
40
+ if (closeIdx === -1) break;
41
+
42
+ found = true;
43
+ combined += source.slice(contentStart, closeIdx) + '\n';
44
+ // Advance the regex past the closing tag so the next iteration finds the next block
45
+ openRegex.lastIndex = closeIdx + closeTag.length;
46
+
47
+ const langMatch = attrs.match(/lang=["'](\w+)["']/);
48
+ if (langMatch) lang = langMatch[1];
49
+ }
50
+
51
+ return found ? {content: combined.trim(), lang} : null;
52
+ }
53
+
54
+ /** Finds the position of </script> while skipping occurrences inside string literals */
55
+ function findClosingScriptTag(source: string, start: number, closeTag: string): number {
56
+ let i = start;
57
+ while (i < source.length) {
58
+ const ch = source[i];
59
+
60
+ // Skip string literals (single, double, template)
61
+ if (ch === "'" || ch === '"' || ch === '`') {
62
+ i = skipStringLiteral(source, i, ch);
63
+ continue;
64
+ }
65
+
66
+ // Check for closing tag (case-insensitive)
67
+ if (ch === '<' && source.slice(i, i + closeTag.length).toLowerCase() === closeTag) {
68
+ return i;
69
+ }
70
+
71
+ i++;
72
+ }
73
+ return -1;
74
+ }
75
+
76
+ /** Advances past a string literal, handling escape sequences and template literal nesting */
77
+ function skipStringLiteral(source: string, start: number, quote: string): number {
78
+ let i = start + 1;
79
+ while (i < source.length) {
80
+ const ch = source[i];
81
+ if (ch === '\\') {
82
+ i += 2; // skip escaped character
83
+ continue;
84
+ }
85
+ if (ch === quote) return i + 1;
86
+ i++;
87
+ }
88
+ return i;
89
+ }
90
+
91
+ /** Scans the client source directory and extracts all pure functions */
92
+ export function scanClientSource(options: ServerPureFunctionsOptions): ExtractedPureFn[] {
93
+ const include = options.include || ['**/*.ts', '**/*.tsx', '**/*.vue'];
94
+ const exclude = options.exclude || ['../node_modules/**', '**/.dist/**', '**/dist/**'];
95
+ const clientSrcPath = resolve(options.clientSrcPath);
96
+ const fns: ExtractedPureFn[] = [];
97
+
98
+ function scanDir(dir: string) {
99
+ const entries = readdirSync(dir);
100
+ for (const entry of entries) {
101
+ const fullPath = join(dir, entry);
102
+ const stat = statSync(fullPath);
103
+
104
+ if (stat.isDirectory()) {
105
+ // Skip excluded directories
106
+ if (!isIncluded(fullPath + '/', include, exclude)) continue;
107
+ scanDir(fullPath);
108
+ } else if (stat.isFile()) {
109
+ // Only process included files
110
+ if (!isIncluded(fullPath, include, exclude)) continue;
111
+
112
+ try {
113
+ let code = readFileSync(fullPath, 'utf-8');
114
+ let effectivePath = fullPath;
115
+
116
+ // For .vue files, extract script content
117
+ if (fullPath.endsWith('.vue')) {
118
+ const scriptBlock = extractVueScriptContent(code);
119
+ if (!scriptBlock) continue;
120
+ code = scriptBlock.content;
121
+ effectivePath = `${fullPath}.${scriptBlock.lang}`;
122
+ }
123
+
124
+ // Quick check: does this file contain pureServerFn or mapFrom?
125
+ const hasPureFn = code.includes('pureServerFn');
126
+ const hasMapFrom = code.includes('mapFrom');
127
+ if (!hasPureFn && !hasMapFrom) continue;
128
+
129
+ if (hasPureFn) {
130
+ const extracted = extractPureFnsFromSource(code, effectivePath, 'pureServerFn', options.noViteClient);
131
+ fns.push(...extracted);
132
+ }
133
+ if (hasMapFrom) {
134
+ const extracted = extractPureFnsFromSource(code, effectivePath, 'mapFrom', options.noViteClient);
135
+ fns.push(...extracted);
136
+ }
137
+ } catch (err: any) {
138
+ // Log but don't fail - some files might not be parseable
139
+ console.warn(`[mion-pure-functions] Warning: Could not parse ${fullPath}: ${err.message}`);
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ scanDir(clientSrcPath);
146
+ return fns;
147
+ }
148
+
149
+ /** Extracts all calls to the given function name from a source file using AST */
150
+ export function extractPureFnsFromSource(
151
+ source: string,
152
+ filePath: string,
153
+ fnName = 'pureServerFn',
154
+ noViteClient = false
155
+ ): ExtractedPureFn[] {
156
+ const results: ExtractedPureFn[] = [];
157
+
158
+ // Quick check: does this file even contain the target function?
159
+ if (!source.includes(fnName)) return results;
160
+
161
+ // First, strip TypeScript types to get clean JavaScript
162
+ const jsSource = stripTypes(source, filePath);
163
+
164
+ // Parse the JavaScript into an AST
165
+ const sourceFile = ts.createSourceFile(filePath, jsSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
166
+
167
+ // Visit all nodes and extract pure functions
168
+ function visit(node: ts.Node): void {
169
+ if (ts.isCallExpression(node)) {
170
+ const callee = node.expression;
171
+ if (ts.isIdentifier(callee) && callee.text === fnName) {
172
+ if (fnName === 'registerPureFnFactory') {
173
+ const extracted = extractDataFromRegisterPureFnFactoryAST(node, sourceFile, filePath);
174
+ results.push(extracted);
175
+ } else if (fnName === 'mapFrom') {
176
+ const extracted = extractDataFromMapFromCallAST(node, sourceFile, filePath, noViteClient);
177
+ results.push(extracted);
178
+ } else {
179
+ const extracted = extractDataFromPureFnDefAST(node, sourceFile, filePath, noViteClient);
180
+ results.push(extracted);
181
+ }
182
+ }
183
+ }
184
+ ts.forEachChild(node, visit);
185
+ }
186
+
187
+ visit(sourceFile);
188
+
189
+ return results;
190
+ }
191
+
192
+ /**
193
+ * Strips TypeScript type annotations from code, returning pure JavaScript.
194
+ * Uses esbuild instead of ts.transpileModule to avoid deepkit type compiler patches
195
+ * that would inject __assignType artifacts into the output.
196
+ */
197
+ export function stripTypes(code: string, filePath?: string): string {
198
+ try {
199
+ let loader: 'ts' | 'tsx' | 'js' | 'jsx' = 'ts';
200
+ if (filePath) {
201
+ if (filePath.endsWith('.tsx')) loader = 'tsx';
202
+ else if (filePath.endsWith('.jsx')) loader = 'jsx';
203
+ else if (filePath.endsWith('.js')) loader = 'js';
204
+ }
205
+ const result = transformSync(code, {
206
+ loader,
207
+ target: 'esnext',
208
+ minify: false,
209
+ });
210
+ return result.code.trim();
211
+ } catch (err: any) {
212
+ // esbuild errors (e.g. await in non-async) are transformed to PurityError
213
+ // so the extraction pipeline has a consistent error type
214
+ throw new PurityError(err.message || String(err), '<esbuild>', 0);
215
+ }
216
+ }
217
+
218
+ /** Extracts a single PureFnDef from a pureServerFn() call expression */
219
+ function extractDataFromPureFnDefAST(
220
+ call: ts.CallExpression,
221
+ sourceFile: ts.SourceFile,
222
+ filePath: string,
223
+ noViteClient = false
224
+ ): ExtractedPureFn {
225
+ // Accept 1 or 2 arguments: pureServerFn(fnOrDef) or pureServerFn(fnOrDef, 'name') (user-provided name or already transformed)
226
+ if (call.arguments.length < 1 || call.arguments.length > 2) {
227
+ throw new PurityError(
228
+ 'pureServerFn() requires 1 or 2 arguments: a function/PureFnDef and an optional name/bodyHash string',
229
+ filePath,
230
+ call.getStart(sourceFile)
231
+ );
232
+ }
233
+
234
+ // Extract user-provided name from 2nd argument if present
235
+ let userProvidedName: string | undefined;
236
+ if (call.arguments.length === 2) {
237
+ const nameArg = call.arguments[1];
238
+ if (!ts.isStringLiteral(nameArg)) {
239
+ throw new PurityError(
240
+ 'pureServerFn() second argument (name/bodyHash) must be a string literal',
241
+ filePath,
242
+ nameArg.getStart(sourceFile)
243
+ );
244
+ }
245
+ if (nameArg.text.length === 0) {
246
+ throw new PurityError(
247
+ 'pureServerFn() second argument (name/bodyHash) must not be an empty string',
248
+ filePath,
249
+ nameArg.getStart(sourceFile)
250
+ );
251
+ }
252
+ userProvidedName = nameArg.text;
253
+ } else if (noViteClient) {
254
+ throw new PurityError(
255
+ 'pureServerFn() requires a name as the second argument (string literal) when noViteClient is enabled',
256
+ filePath,
257
+ call.getStart(sourceFile)
258
+ );
259
+ }
260
+
261
+ let arg = call.arguments[0];
262
+
263
+ // If the argument is a variable reference, resolve it to its initializer
264
+ if (ts.isIdentifier(arg)) {
265
+ const resolved = resolveVariableInitializer(arg.text, sourceFile);
266
+ if (!resolved) {
267
+ if (isImportedIdentifier(arg.text, sourceFile)) {
268
+ throw new PurityError(
269
+ `pureServerFn() argument "${arg.text}" is imported from another module. Pure functions must be defined inline or as a variable in the same file`,
270
+ filePath,
271
+ arg.getStart(sourceFile)
272
+ );
273
+ }
274
+ throw new PurityError(
275
+ `pureServerFn() argument "${arg.text}" could not be resolved to a variable declaration in this file. Pure functions must be defined inline or as a variable in the same file`,
276
+ filePath,
277
+ arg.getStart(sourceFile)
278
+ );
279
+ }
280
+ arg = resolved;
281
+ }
282
+
283
+ // Plain function shorthand: pureServerFn((x) => x + 1) or pureServerFn(function myFn(x) { ... })
284
+ if (ts.isFunctionExpression(arg) || ts.isArrowFunction(arg)) {
285
+ return buildExtractedPureFn(arg, PURE_SERVER_FN_NAMESPACE, undefined, false, sourceFile, filePath, userProvidedName);
286
+ }
287
+
288
+ // Full PureFnDef object: pureServerFn({ pureFn: ..., namespace: ..., fnName: ... })
289
+ if (ts.isObjectLiteralExpression(arg)) {
290
+ return extractPureFnDefFromObjectLiteral(arg, sourceFile, filePath, userProvidedName);
291
+ }
292
+
293
+ throw new PurityError(
294
+ 'pureServerFn() first argument must be a function, an object literal (PureFnDef), or a variable referencing one',
295
+ filePath,
296
+ call.arguments[0].getStart(sourceFile)
297
+ );
298
+ }
299
+
300
+ /** Extracts the mapper function from a mapFrom(source, mapper) call expression */
301
+ function extractDataFromMapFromCallAST(
302
+ call: ts.CallExpression,
303
+ sourceFile: ts.SourceFile,
304
+ filePath: string,
305
+ noViteClient = false
306
+ ): ExtractedPureFn {
307
+ // Accept 2 or 3 arguments: mapFrom(source, mapper) or mapFrom(source, mapper, 'name') (user-provided name or already transformed)
308
+ if (call.arguments.length < 2 || call.arguments.length > 3) {
309
+ throw new PurityError(
310
+ 'mapFrom() requires 2 or 3 arguments: a SubRequest source, a mapper function, and an optional name/bodyHash string',
311
+ filePath,
312
+ call.getStart(sourceFile)
313
+ );
314
+ }
315
+
316
+ // Extract user-provided name from 3rd argument if present
317
+ let userProvidedName: string | undefined;
318
+ if (call.arguments.length === 3) {
319
+ const nameArg = call.arguments[2];
320
+ if (!ts.isStringLiteral(nameArg)) {
321
+ throw new PurityError(
322
+ 'mapFrom() third argument (name/bodyHash) must be a string literal',
323
+ filePath,
324
+ nameArg.getStart(sourceFile)
325
+ );
326
+ }
327
+ if (nameArg.text.length === 0) {
328
+ throw new PurityError(
329
+ 'mapFrom() third argument (name/bodyHash) must not be an empty string',
330
+ filePath,
331
+ nameArg.getStart(sourceFile)
332
+ );
333
+ }
334
+ userProvidedName = nameArg.text;
335
+ } else if (noViteClient) {
336
+ throw new PurityError(
337
+ 'mapFrom() requires a name as the third argument (string literal) when noViteClient is enabled',
338
+ filePath,
339
+ call.getStart(sourceFile)
340
+ );
341
+ }
342
+
343
+ let arg = call.arguments[1]; // mapper is the 2nd argument
344
+
345
+ // If the argument is a variable reference, resolve it to its initializer
346
+ if (ts.isIdentifier(arg)) {
347
+ const resolved = resolveVariableInitializer(arg.text, sourceFile);
348
+ if (!resolved) {
349
+ if (isImportedIdentifier(arg.text, sourceFile)) {
350
+ throw new PurityError(
351
+ `mapFrom() mapper argument "${arg.text}" is imported from another module. Pure functions must be defined inline or as a variable in the same file`,
352
+ filePath,
353
+ arg.getStart(sourceFile)
354
+ );
355
+ }
356
+ throw new PurityError(
357
+ `mapFrom() mapper argument "${arg.text}" could not be resolved to a variable declaration in this file. Pure functions must be defined inline or as a variable in the same file`,
358
+ filePath,
359
+ arg.getStart(sourceFile)
360
+ );
361
+ }
362
+ arg = resolved;
363
+ }
364
+
365
+ // Mapper must be a function expression or arrow function
366
+ if (ts.isFunctionExpression(arg) || ts.isArrowFunction(arg)) {
367
+ return buildExtractedPureFn(arg, PURE_SERVER_FN_NAMESPACE, undefined, false, sourceFile, filePath, userProvidedName);
368
+ }
369
+
370
+ throw new PurityError(
371
+ 'mapFrom() second argument (mapper) must be a function expression or arrow function',
372
+ filePath,
373
+ call.arguments[1].getStart(sourceFile)
374
+ );
375
+ }
376
+
377
+ /** Extracts data from a registerPureFnFactory(namespace, functionID, factoryFn) call expression */
378
+ function extractDataFromRegisterPureFnFactoryAST(
379
+ call: ts.CallExpression,
380
+ sourceFile: ts.SourceFile,
381
+ filePath: string
382
+ ): ExtractedPureFn {
383
+ // Accept 3 or 4 arguments: registerPureFnFactory(ns, id, fn) or registerPureFnFactory(ns, id, fn, parsedFn)
384
+ if (call.arguments.length < 3 || call.arguments.length > 4) {
385
+ throw new PurityError(
386
+ 'registerPureFnFactory() requires 3 or 4 arguments: namespace, functionID, factoryFn, and optional parsedFn',
387
+ filePath,
388
+ call.getStart(sourceFile)
389
+ );
390
+ }
391
+
392
+ // 1st arg: namespace (string literal)
393
+ const nsArg = call.arguments[0];
394
+ if (!ts.isStringLiteral(nsArg)) {
395
+ throw new PurityError(
396
+ 'registerPureFnFactory() first argument (namespace) must be a string literal',
397
+ filePath,
398
+ nsArg.getStart(sourceFile)
399
+ );
400
+ }
401
+ const namespace = nsArg.text;
402
+
403
+ // 2nd arg: functionID (string literal)
404
+ const idArg = call.arguments[1];
405
+ if (!ts.isStringLiteral(idArg)) {
406
+ throw new PurityError(
407
+ 'registerPureFnFactory() second argument (functionID) must be a string literal',
408
+ filePath,
409
+ idArg.getStart(sourceFile)
410
+ );
411
+ }
412
+ const fnName = idArg.text;
413
+
414
+ // 3rd arg: factoryFn (function expression or arrow function)
415
+ const fnArg = call.arguments[2];
416
+ if (ts.isIdentifier(fnArg)) {
417
+ if (isImportedIdentifier(fnArg.text, sourceFile)) {
418
+ throw new PurityError(
419
+ `registerPureFnFactory() third argument "${fnArg.text}" is imported from another module. The factory function must be defined inline`,
420
+ filePath,
421
+ fnArg.getStart(sourceFile)
422
+ );
423
+ }
424
+ throw new PurityError(
425
+ `registerPureFnFactory() third argument "${fnArg.text}" could not be resolved. The factory function must be defined inline as a function expression or arrow function`,
426
+ filePath,
427
+ fnArg.getStart(sourceFile)
428
+ );
429
+ }
430
+ if (!ts.isFunctionExpression(fnArg) && !ts.isArrowFunction(fnArg)) {
431
+ throw new PurityError(
432
+ 'registerPureFnFactory() third argument (factoryFn) must be a function expression or arrow function',
433
+ filePath,
434
+ fnArg.getStart(sourceFile)
435
+ );
436
+ }
437
+
438
+ // Extract parameter names
439
+ const paramNames = fnArg.parameters.map((param) => {
440
+ if (!ts.isIdentifier(param.name)) {
441
+ throw new PurityError(
442
+ 'Factory function parameters must be simple identifiers (no destructuring)',
443
+ filePath,
444
+ param.getStart(sourceFile)
445
+ );
446
+ }
447
+ return param.name.text;
448
+ });
449
+
450
+ // Get the function body
451
+ const bodyNode = fnArg.body;
452
+ const bodyText = getBodyText(bodyNode, sourceFile);
453
+
454
+ // Normalize body for hashing (collapse whitespace)
455
+ const normalizedBody = bodyText.replace(/[ \t]+/g, ' ').trim();
456
+ const bodyHash = createHash('sha256')
457
+ .update(namespace + fnName + normalizedBody)
458
+ .digest('base64url')
459
+ .slice(0, BODY_HASH_LENGTH);
460
+
461
+ return {
462
+ namespace,
463
+ fnName,
464
+ paramNames,
465
+ fnBody: bodyText,
466
+ bodyHash,
467
+ dependencies: new Set(),
468
+ sourceFile: filePath,
469
+ isFactory: true, // registerPureFnFactory always registers factory functions
470
+ };
471
+ }
472
+
473
+ /** Checks if an identifier name is imported from another module */
474
+ function isImportedIdentifier(name: string, sourceFile: ts.SourceFile): boolean {
475
+ for (const statement of sourceFile.statements) {
476
+ if (!ts.isImportDeclaration(statement) || !statement.importClause) continue;
477
+ const clause = statement.importClause;
478
+
479
+ // Default import: import foo from '...'
480
+ if (clause.name && clause.name.text === name) return true;
481
+
482
+ if (clause.namedBindings) {
483
+ // Namespace import: import * as foo from '...'
484
+ if (ts.isNamespaceImport(clause.namedBindings) && clause.namedBindings.name.text === name) return true;
485
+
486
+ // Named import: import { foo } from '...' or import { bar as foo } from '...'
487
+ if (ts.isNamedImports(clause.namedBindings)) {
488
+ for (const element of clause.namedBindings.elements) {
489
+ if (element.name.text === name) return true;
490
+ }
491
+ }
492
+ }
493
+ }
494
+ return false;
495
+ }
496
+
497
+ /** Resolves a variable name to its initializer expression within the source file */
498
+ function resolveVariableInitializer(name: string, sourceFile: ts.SourceFile): ts.Expression | undefined {
499
+ let result: ts.Expression | undefined;
500
+
501
+ function visit(node: ts.Node): void {
502
+ if (result) return;
503
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name && node.initializer) {
504
+ result = node.initializer;
505
+ return;
506
+ }
507
+ ts.forEachChild(node, visit);
508
+ }
509
+
510
+ visit(sourceFile);
511
+ return result;
512
+ }
513
+
514
+ /** Extracts PureFnDef data from an object literal AST node */
515
+ function extractPureFnDefFromObjectLiteral(
516
+ objLiteral: ts.ObjectLiteralExpression,
517
+ sourceFile: ts.SourceFile,
518
+ filePath: string,
519
+ userProvidedName?: string
520
+ ): ExtractedPureFn {
521
+ let pureFn: ts.FunctionExpression | ts.ArrowFunction | undefined;
522
+ let namespace: string = PURE_SERVER_FN_NAMESPACE;
523
+ let fnName: string | undefined;
524
+ let isFactory = false;
525
+
526
+ // Extract properties from the object literal
527
+ for (const prop of objLiteral.properties) {
528
+ if (!ts.isPropertyAssignment(prop)) continue;
529
+
530
+ const propName = ts.isIdentifier(prop.name) ? prop.name.text : undefined;
531
+ if (!propName) continue;
532
+
533
+ switch (propName) {
534
+ case 'pureFn': {
535
+ const initializer = prop.initializer;
536
+ if (ts.isFunctionExpression(initializer) || ts.isArrowFunction(initializer)) {
537
+ pureFn = initializer;
538
+ } else if (ts.isIdentifier(initializer)) {
539
+ if (isImportedIdentifier(initializer.text, sourceFile)) {
540
+ throw new PurityError(
541
+ `pureFn property "${initializer.text}" is imported from another module. Pure functions must be defined inline or as a variable in the same file`,
542
+ filePath,
543
+ prop.initializer.getStart(sourceFile)
544
+ );
545
+ }
546
+ throw new PurityError(
547
+ `pureFn property "${initializer.text}" could not be resolved. Pure functions must be defined inline or as a variable in the same file`,
548
+ filePath,
549
+ prop.initializer.getStart(sourceFile)
550
+ );
551
+ } else {
552
+ throw new PurityError(
553
+ 'pureFn property must be a function expression or arrow function',
554
+ filePath,
555
+ prop.initializer.getStart(sourceFile)
556
+ );
557
+ }
558
+ break;
559
+ }
560
+ case 'namespace':
561
+ if (ts.isStringLiteral(prop.initializer)) {
562
+ namespace = prop.initializer.text;
563
+ } else {
564
+ throw new PurityError(
565
+ 'namespace property must be a string literal',
566
+ filePath,
567
+ prop.initializer.getStart(sourceFile)
568
+ );
569
+ }
570
+ break;
571
+ case 'fnName':
572
+ if (ts.isStringLiteral(prop.initializer)) {
573
+ fnName = prop.initializer.text;
574
+ } else {
575
+ throw new PurityError(
576
+ 'fnName property must be a string literal',
577
+ filePath,
578
+ prop.initializer.getStart(sourceFile)
579
+ );
580
+ }
581
+ break;
582
+ case 'isFactory':
583
+ if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword) {
584
+ isFactory = true;
585
+ } else if (prop.initializer.kind === ts.SyntaxKind.FalseKeyword) {
586
+ isFactory = false;
587
+ } else {
588
+ throw new PurityError(
589
+ 'isFactory property must be a boolean literal',
590
+ filePath,
591
+ prop.initializer.getStart(sourceFile)
592
+ );
593
+ }
594
+ break;
595
+ }
596
+ }
597
+
598
+ if (!pureFn) {
599
+ throw new PurityError('PureFnDef must have a pureFn property', filePath, objLiteral.getStart(sourceFile));
600
+ }
601
+
602
+ // For PureFnDef path: if no explicit fnName, try function.name fallback (set to 'useFunction' sentinel)
603
+ const explicitFnName = fnName ?? (ts.isFunctionExpression(pureFn) && pureFn.name ? pureFn.name.text : undefined);
604
+ return buildExtractedPureFn(pureFn, namespace, explicitFnName, isFactory, sourceFile, filePath, userProvidedName);
605
+ }
606
+
607
+ /** Common extraction logic: validates purity, computes hash, builds ExtractedPureFn */
608
+ function buildExtractedPureFn(
609
+ fnNode: ts.FunctionExpression | ts.ArrowFunction,
610
+ namespace: string,
611
+ explicitFnName: string | undefined,
612
+ isFactory: boolean,
613
+ sourceFile: ts.SourceFile,
614
+ filePath: string,
615
+ userProvidedName?: string
616
+ ): ExtractedPureFn {
617
+ // Extract parameter names
618
+ const paramNames = fnNode.parameters.map((param) => {
619
+ if (!ts.isIdentifier(param.name)) {
620
+ throw new PurityError(
621
+ 'Pure function parameters must be simple identifiers (no destructuring)',
622
+ filePath,
623
+ param.getStart(sourceFile)
624
+ );
625
+ }
626
+ return param.name.text;
627
+ });
628
+
629
+ // Get the function body
630
+ const bodyNode = fnNode.body;
631
+
632
+ // Validate purity
633
+ const fnTypeLabel = isFactory ? 'factory functions' : 'pure functions';
634
+ validatePurity(bodyNode, new Set(paramNames), explicitFnName, sourceFile, filePath, fnTypeLabel);
635
+
636
+ // Get the body text
637
+ const bodyText = getBodyText(bodyNode, sourceFile);
638
+
639
+ // When user provides a name, use it as both bodyHash and fnName (skip hash computation)
640
+ if (userProvidedName) {
641
+ return {
642
+ namespace,
643
+ fnName: userProvidedName,
644
+ paramNames,
645
+ fnBody: bodyText,
646
+ bodyHash: userProvidedName,
647
+ dependencies: new Set(),
648
+ sourceFile: filePath,
649
+ isFactory,
650
+ };
651
+ }
652
+
653
+ // Normalize body for hashing (collapse whitespace)
654
+ const normalizedBody = bodyText.replace(/[ \t]+/g, ' ').trim();
655
+ const bodyHash = createHash('sha256')
656
+ .update(namespace + normalizedBody)
657
+ .digest('base64url')
658
+ .slice(0, BODY_HASH_LENGTH);
659
+
660
+ // Use explicit fnName if provided, otherwise default to bodyHash
661
+ const fnName = explicitFnName || bodyHash;
662
+
663
+ return {
664
+ namespace,
665
+ fnName,
666
+ paramNames,
667
+ fnBody: bodyText,
668
+ bodyHash,
669
+ dependencies: new Set(),
670
+ sourceFile: filePath,
671
+ isFactory,
672
+ };
673
+ }
674
+
675
+ /** Gets the body text from a function body node */
676
+ function getBodyText(body: ts.Node, sourceFile: ts.SourceFile): string {
677
+ if (ts.isBlock(body)) {
678
+ // Block body: { statements }
679
+ const fullText = body.getText(sourceFile);
680
+ return fullText.slice(1, -1).trim();
681
+ } else {
682
+ // Expression body (arrow function): return the expression wrapped in return
683
+ return `return ${body.getText(sourceFile)}`;
684
+ }
685
+ }
686
+
687
+ /** Validates that a function body is pure by traversing its AST */
688
+ function validatePurity(
689
+ body: ts.Node,
690
+ localScope: Set<string>,
691
+ fnName: string | undefined,
692
+ sourceFile: ts.SourceFile,
693
+ filePath: string,
694
+ fnTypeLabel = 'pure functions'
695
+ ): void {
696
+ // Collect all locally declared variables
697
+ collectLocalDeclarations(body, localScope);
698
+
699
+ // Add the function name to local scope (for recursion) if it exists
700
+ if (fnName) localScope.add(fnName);
701
+
702
+ // Traverse the AST and check for purity violations
703
+ function checkNode(node: ts.Node): void {
704
+ // Check for 'this' keyword
705
+ if (node.kind === ts.SyntaxKind.ThisKeyword) {
706
+ throw new PurityError(`'this' is not allowed in ${fnTypeLabel}`, filePath, node.getStart(sourceFile));
707
+ }
708
+
709
+ // Check for 'await' expression
710
+ if (ts.isAwaitExpression(node)) {
711
+ throw new PurityError(`async/await is not allowed in ${fnTypeLabel}`, filePath, node.getStart(sourceFile));
712
+ }
713
+
714
+ // Check for 'yield' keyword
715
+ if (node.kind === ts.SyntaxKind.YieldKeyword) {
716
+ throw new PurityError(`generators are not allowed in ${fnTypeLabel}`, filePath, node.getStart(sourceFile));
717
+ }
718
+
719
+ // Check for dynamic import
720
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
721
+ throw new PurityError(`Dynamic import() is not allowed in ${fnTypeLabel}`, filePath, node.getStart(sourceFile));
722
+ }
723
+
724
+ // Check for identifier references
725
+ if (ts.isIdentifier(node)) {
726
+ const name = node.text;
727
+
728
+ // Skip if it's a property access (e.g., obj.prop - we only care about 'obj')
729
+ if (ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) {
730
+ ts.forEachChild(node, checkNode);
731
+ return;
732
+ }
733
+
734
+ // Skip if it's a property name in object literal
735
+ if (ts.isPropertyAssignment(node.parent) && node.parent.name === node) {
736
+ ts.forEachChild(node, checkNode);
737
+ return;
738
+ }
739
+
740
+ // Skip if it's a shorthand property assignment name
741
+ if (ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node) {
742
+ // But we still need to check the value reference
743
+ if (!localScope.has(name) && !ALLOWED_GLOBALS.has(name)) {
744
+ throw new PurityError(
745
+ `Closure variable "${name}" is not allowed in ${fnTypeLabel}. ${fnTypeLabel[0].toUpperCase() + fnTypeLabel.slice(1)} cannot access outer scope variables.`,
746
+ filePath,
747
+ node.getStart(sourceFile)
748
+ );
749
+ }
750
+ ts.forEachChild(node, checkNode);
751
+ return;
752
+ }
753
+
754
+ // Check for forbidden identifiers
755
+ if (FORBIDDEN_IDENTIFIERS.has(name)) {
756
+ throw new PurityError(`${name} is not allowed in ${fnTypeLabel}`, filePath, node.getStart(sourceFile));
757
+ }
758
+
759
+ // Check for closure variables (not local, not allowed global)
760
+ if (!localScope.has(name) && !ALLOWED_GLOBALS.has(name)) {
761
+ throw new PurityError(
762
+ `Closure variable "${name}" is not allowed in ${fnTypeLabel}. ${fnTypeLabel[0].toUpperCase() + fnTypeLabel.slice(1)} cannot access outer scope variables.`,
763
+ filePath,
764
+ node.getStart(sourceFile)
765
+ );
766
+ }
767
+ }
768
+
769
+ ts.forEachChild(node, checkNode);
770
+ }
771
+
772
+ checkNode(body);
773
+ }
774
+
775
+ /** Collects all locally declared variable names from a node */
776
+ function collectLocalDeclarations(node: ts.Node, scope: Set<string>): void {
777
+ function visit(n: ts.Node): void {
778
+ // Variable declarations: const x = ..., let y = ..., var z = ...
779
+ if (ts.isVariableDeclaration(n)) {
780
+ collectBindingNames(n.name, scope);
781
+ }
782
+
783
+ // Function declarations: function foo() {}
784
+ if (ts.isFunctionDeclaration(n) && n.name) {
785
+ scope.add(n.name.text);
786
+ return;
787
+ }
788
+
789
+ // Function expressions: const foo = function bar() {}
790
+ if (ts.isFunctionExpression(n) && n.name) {
791
+ scope.add(n.name.text);
792
+ n.parameters.forEach((p) => collectBindingNames(p.name, scope));
793
+ return;
794
+ }
795
+
796
+ // Arrow functions: (x) => ...
797
+ if (ts.isArrowFunction(n)) {
798
+ n.parameters.forEach((p) => collectBindingNames(p.name, scope));
799
+ return;
800
+ }
801
+
802
+ // For-of/for-in loop variables
803
+ if (ts.isForOfStatement(n) || ts.isForInStatement(n)) {
804
+ if (ts.isVariableDeclarationList(n.initializer)) {
805
+ n.initializer.declarations.forEach((d) => collectBindingNames(d.name, scope));
806
+ }
807
+ }
808
+
809
+ // Catch clause variable
810
+ if (ts.isCatchClause(n) && n.variableDeclaration) {
811
+ collectBindingNames(n.variableDeclaration.name, scope);
812
+ }
813
+
814
+ ts.forEachChild(n, visit);
815
+ }
816
+
817
+ visit(node);
818
+ }
819
+
820
+ /** Collects binding names from a binding pattern (handles destructuring) */
821
+ function collectBindingNames(name: ts.BindingName, scope: Set<string>): void {
822
+ if (ts.isIdentifier(name)) {
823
+ scope.add(name.text);
824
+ } else if (ts.isObjectBindingPattern(name)) {
825
+ name.elements.forEach((el) => collectBindingNames(el.name, scope));
826
+ } else if (ts.isArrayBindingPattern(name)) {
827
+ name.elements.forEach((el) => {
828
+ if (ts.isBindingElement(el)) {
829
+ collectBindingNames(el.name, scope);
830
+ }
831
+ });
832
+ }
833
+ }
834
+
835
+ /** Error thrown when a purity violation is detected */
836
+ export class PurityError extends Error {
837
+ constructor(
838
+ message: string,
839
+ public readonly filePath: string,
840
+ public readonly position: number
841
+ ) {
842
+ super(`${message} (in ${filePath} at position ${position})`);
843
+ this.name = 'PurityError';
844
+ }
845
+ }