moonflower 1.4.8 → 1.5.0

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 (49) hide show
  1. package/dist/openapi/analyzerModule/analyzerModule.cjs +1 -1
  2. package/dist/openapi/analyzerModule/analyzerModule.cjs.map +1 -1
  3. package/dist/openapi/analyzerModule/analyzerModule.d.ts +18 -3
  4. package/dist/openapi/analyzerModule/analyzerModule.d.ts.map +1 -1
  5. package/dist/openapi/analyzerModule/analyzerModule.mjs +168 -112
  6. package/dist/openapi/analyzerModule/analyzerModule.mjs.map +1 -1
  7. package/dist/openapi/analyzerModule/analyzerWorker.cjs +2 -0
  8. package/dist/openapi/analyzerModule/analyzerWorker.cjs.map +1 -0
  9. package/dist/openapi/analyzerModule/analyzerWorker.d.ts +2 -0
  10. package/dist/openapi/analyzerModule/analyzerWorker.d.ts.map +1 -0
  11. package/dist/openapi/analyzerModule/analyzerWorker.mjs +44 -0
  12. package/dist/openapi/analyzerModule/analyzerWorker.mjs.map +1 -0
  13. package/dist/openapi/analyzerModule/nodeParsers.cjs +1 -1
  14. package/dist/openapi/analyzerModule/nodeParsers.cjs.map +1 -1
  15. package/dist/openapi/analyzerModule/nodeParsers.d.ts.map +1 -1
  16. package/dist/openapi/analyzerModule/nodeParsers.mjs +199 -264
  17. package/dist/openapi/analyzerModule/nodeParsers.mjs.map +1 -1
  18. package/dist/openapi/analyzerModule/parseEndpoint.cjs +1 -1
  19. package/dist/openapi/analyzerModule/parseEndpoint.cjs.map +1 -1
  20. package/dist/openapi/analyzerModule/parseEndpoint.d.ts +8 -1
  21. package/dist/openapi/analyzerModule/parseEndpoint.d.ts.map +1 -1
  22. package/dist/openapi/analyzerModule/parseEndpoint.mjs +121 -106
  23. package/dist/openapi/analyzerModule/parseEndpoint.mjs.map +1 -1
  24. package/dist/openapi/analyzerModule/test/TestCase.d.ts +1 -0
  25. package/dist/openapi/analyzerModule/test/TestCase.d.ts.map +1 -1
  26. package/dist/openapi/analyzerModule/test/workerGlobalSetup.d.ts +3 -0
  27. package/dist/openapi/analyzerModule/test/workerGlobalSetup.d.ts.map +1 -0
  28. package/dist/openapi/analyzerModule/workerPaths.d.ts +2 -0
  29. package/dist/openapi/analyzerModule/workerPaths.d.ts.map +1 -0
  30. package/dist/openapi/analyzerModule/workerPool.cjs +2 -0
  31. package/dist/openapi/analyzerModule/workerPool.cjs.map +1 -0
  32. package/dist/openapi/analyzerModule/workerPool.d.ts +33 -0
  33. package/dist/openapi/analyzerModule/workerPool.d.ts.map +1 -0
  34. package/dist/openapi/analyzerModule/workerPool.mjs +46 -0
  35. package/dist/openapi/analyzerModule/workerPool.mjs.map +1 -0
  36. package/package.json +1 -1
  37. package/src/openapi/analyzerModule/analyzerModule.ts +198 -25
  38. package/src/openapi/analyzerModule/analyzerWorker.ts +73 -0
  39. package/src/openapi/analyzerModule/nodeParsers.ts +4 -104
  40. package/src/openapi/analyzerModule/parseEndpoint.ts +31 -9
  41. package/src/openapi/analyzerModule/test/TestCase.ts +1 -0
  42. package/src/openapi/analyzerModule/test/openApiAnalyzer.spec.ts +2 -2
  43. package/src/openapi/analyzerModule/test/openApiAnalyzer.zod.spec.data.ts +6 -0
  44. package/src/openapi/analyzerModule/test/openApiAnalyzer.zod.spec.ts +9 -1
  45. package/src/openapi/analyzerModule/test/workerGlobalSetup.ts +25 -0
  46. package/src/openapi/analyzerModule/workerPaths.ts +4 -0
  47. package/src/openapi/analyzerModule/workerPool.ts +92 -0
  48. package/src/test/app.spec.ts +10 -1
  49. package/vite.config.ts +5 -0
@@ -1,4 +1,20 @@
1
+ import crypto from 'crypto'
2
+ import { existsSync } from 'fs'
1
3
  import * as path from 'path'
4
+ import { fileURLToPath } from 'url'
5
+
6
+ function resolveWorkerUrl(): URL {
7
+ const candidates = ['./analyzerWorker.mjs', './analyzerWorker.test.mjs']
8
+ for (const candidate of candidates) {
9
+ const url = new URL(candidate, import.meta.url)
10
+ if (existsSync(fileURLToPath(url))) {
11
+ return url
12
+ }
13
+ }
14
+ throw new Error(
15
+ 'analyzerWorker.mjs not found. Run yarn build, or run tests via yarn test (which compiles the worker first).',
16
+ )
17
+ }
2
18
  import { SourceFile, SyntaxKind } from 'ts-morph'
3
19
  import { Project } from 'ts-morph'
4
20
 
@@ -13,9 +29,10 @@ import { ApiDocsHeader, OpenApiManager } from '../manager/OpenApiManager'
13
29
  import { EndpointData, ExposedModelData } from '../types'
14
30
  import { getSourceFileTimestamp, TimestampCache } from './getSourceFileTimestamp'
15
31
  import { getValuesOfObjectLiteral, resolveEndpointPath } from './nodeParsers'
16
- import { parseEndpoint } from './parseEndpoint'
32
+ import { parseEndpoint, SectionTiming } from './parseEndpoint'
17
33
  import { parseExposedModel, parseNamedExposedModels } from './parseExposedModels'
18
34
  import { SourceFileCache } from './sourceFileCache'
35
+ import { WorkerPool, WorkerResult, WorkerTask } from './workerPool'
19
36
 
20
37
  type Props = {
21
38
  logLevel?: Parameters<(typeof Logger)['setLevel']>[0]
@@ -27,23 +44,27 @@ type Props = {
27
44
  | {
28
45
  cachePath: string
29
46
  }
47
+ profiling?: 'stats' | 'off' | 'debug'
30
48
  }
31
49
 
32
50
  type FileDiscoveryConfig = {
33
51
  rootPath: string
34
52
  }
35
53
 
54
+ type EndpointTiming = { method: string; path: string; timing: number; sectionTimings: SectionTiming[] }
55
+
36
56
  /**
37
57
  * @param tsconfigPath Path to tsconfig file relative to project root
38
58
  * @param sourceFilePaths Array of router source files relative to project root
39
59
  */
40
- export const prepareOpenApiSpec = ({
60
+ export const prepareOpenApiSpec = async ({
41
61
  logLevel,
42
62
  tsconfigPath,
43
63
  sourceFilePaths,
44
64
  sourceFileDiscovery,
45
65
  incremental,
46
- }: Props) => {
66
+ profiling = 'stats',
67
+ }: Props): Promise<void> => {
47
68
  const openApiManager = OpenApiManager.getInstance()
48
69
 
49
70
  if (openApiManager.isReady()) {
@@ -81,7 +102,9 @@ export const prepareOpenApiSpec = ({
81
102
  targetPath: typeof sourceFileDiscovery === 'object' ? sourceFileDiscovery.rootPath : '.',
82
103
  tsConfigPath: tsconfigPath,
83
104
  })
84
- Logger.info(`File discovery took ${Math.round(performance.now() - startTime)}ms`)
105
+ if (profiling !== 'off') {
106
+ Logger.info(`File discovery took ${Math.round(performance.now() - startTime)}ms`)
107
+ }
85
108
  return files
86
109
  })()
87
110
 
@@ -116,10 +139,12 @@ export const prepareOpenApiSpec = ({
116
139
  }
117
140
  return path.resolve(process.cwd(), 'node_modules', '.cache', 'moonflower')
118
141
  })()
119
- const endpoints = analyzeMultipleSourceFiles(filesToAnalyze, {
142
+ const endpoints = await analyzeMultipleSourceFiles(filesToAnalyze, {
120
143
  incremental: incremental !== false,
121
144
  cachePath,
122
145
  timestampCache: {},
146
+ profiling,
147
+ tsconfigPath: path.resolve(tsconfigPath),
123
148
  })
124
149
 
125
150
  openApiManager.setStats({
@@ -147,29 +172,167 @@ export const prepareOpenApiSpec = ({
147
172
  openApiManager.markAsReady()
148
173
  }
149
174
 
150
- export const analyzeMultipleSourceFiles = (
175
+ export const analyzeMultipleSourceFiles = async (
151
176
  files: DiscoveredSourceFile[],
152
177
  config: {
153
178
  incremental: boolean
154
179
  cachePath: string
155
180
  timestampCache: TimestampCache
181
+ profiling?: 'stats' | 'off' | 'debug'
182
+ tsconfigPath: string
156
183
  },
157
184
  filterEndpointPaths?: string[],
158
- ): EndpointData[] => {
185
+ ): Promise<EndpointData[]> => {
186
+ const profiling = config.profiling ?? 'stats'
159
187
  const startTime = performance.now()
160
- const analyzedFiles = files.map((file) => analyzeSourceFileWithCache(file, config, filterEndpointPaths))
161
- Logger.info(`Router analysis took ${Math.round(performance.now() - startTime)}ms`)
162
188
 
163
- analyzedFiles
164
- .map((f, index) => ({
165
- fileName: files[index].fileName,
166
- timeTaken: f.timing,
167
- }))
168
- .sort((a, b) => b.timeTaken - a.timeTaken)
169
- .filter((t) => t.timeTaken > 500)
170
- .forEach((t) => {
171
- Logger.info(`- [${t.fileName}] Took ${Math.round(t.timeTaken)}ms to analyze`)
189
+ // Separate cached files from those needing analysis
190
+ type CachedFile = { endpoints: EndpointData[]; fileName: string; timing: 0; endpointTimings: [] }
191
+ type UncachedFile = { file: DiscoveredSourceFile; timestamp: number }
192
+
193
+ const cached: CachedFile[] = []
194
+ const uncached: UncachedFile[] = []
195
+
196
+ for (const file of files) {
197
+ const timestamp = getSourceFileTimestamp(file.sourceFile, config.timestampCache)
198
+ const hit = config.incremental
199
+ ? SourceFileCache.getCachedResults(file.sourceFile, timestamp, config.cachePath)
200
+ : null
201
+ if (hit) {
202
+ Logger.debug(`[${file.fileName}] Found cached results`)
203
+ cached.push({ endpoints: hit.endpoints, fileName: file.fileName, timing: 0, endpointTimings: [] })
204
+ } else {
205
+ uncached.push({ file, timestamp })
206
+ }
207
+ }
208
+
209
+ if (uncached.length === 0) {
210
+ if (profiling !== 'off') {
211
+ Logger.info(`Router analysis took ${Math.round(performance.now() - startTime)}ms`)
212
+ }
213
+ return cached.flatMap((f) => f.endpoints)
214
+ }
215
+
216
+ // Build one task per endpoint across all uncached files
217
+ const OPERATIONS = ['get', 'post', 'put', 'delete', 'del', 'patch']
218
+ const operationsPattern = OPERATIONS.join('|')
219
+
220
+ type FileTask = { task: WorkerTask; fileName: string }
221
+ const allTasks: FileTask[] = []
222
+
223
+ for (const { file } of uncached) {
224
+ for (const routerName of file.routers.named) {
225
+ const routerPattern = new RegExp(`${routerName}\\.(?:${operationsPattern})`)
226
+ let endpointIndex = 0
227
+ file.sourceFile.forEachChild((node) => {
228
+ const nodeText = node.getText()
229
+ if (routerPattern.test(nodeText)) {
230
+ if (
231
+ !filterEndpointPaths ||
232
+ filterEndpointPaths.some((p) => resolveEndpointPath(node)?.includes(p))
233
+ ) {
234
+ allTasks.push({
235
+ fileName: file.fileName,
236
+ task: {
237
+ taskId: crypto.randomUUID(),
238
+ tsconfigPath: config.tsconfigPath,
239
+ sourceFilePath: file.sourceFile.getFilePath(),
240
+ routerName,
241
+ endpointIndex,
242
+ },
243
+ })
244
+ }
245
+ endpointIndex++
246
+ }
247
+ })
248
+ }
249
+ }
250
+
251
+ // Dispatch all tasks to the worker pool
252
+ const pool = new WorkerPool(resolveWorkerUrl())
253
+
254
+ type EndpointTiming = { method: string; path: string; timing: number; sectionTimings: SectionTiming[] }
255
+ type FileResult = {
256
+ endpoints: EndpointData[]
257
+ fileName: string
258
+ timing: number
259
+ endpointTimings: EndpointTiming[]
260
+ }
261
+
262
+ let results: WorkerResult[]
263
+ try {
264
+ results = await pool.runAll(allTasks.map((ft) => ft.task))
265
+ } finally {
266
+ pool.terminate()
267
+ }
268
+
269
+ // Group results by file
270
+ const byFile = new Map<string, FileResult>()
271
+ for (const { file } of uncached) {
272
+ byFile.set(file.fileName, { endpoints: [], fileName: file.fileName, timing: 0, endpointTimings: [] })
273
+ }
274
+
275
+ for (let i = 0; i < results.length; i++) {
276
+ const result = results[i]
277
+ const fileName = allTasks[i].fileName
278
+ const fileResult = byFile.get(fileName)!
279
+
280
+ if ('error' in result) {
281
+ Logger.error(`[${fileName}] Worker error: ${result.error}`)
282
+ continue
283
+ }
284
+
285
+ fileResult.endpoints.push(result.endpoint)
286
+ fileResult.timing += result.timing
287
+ fileResult.endpointTimings.push({
288
+ method: result.endpoint.method,
289
+ path: result.endpoint.path,
290
+ timing: result.timing,
291
+ sectionTimings: result.sectionTimings,
172
292
  })
293
+ }
294
+
295
+ // Write cache for each uncached file
296
+ for (const { file, timestamp } of uncached) {
297
+ const fileResult = byFile.get(file.fileName)!
298
+ if (fileResult.endpoints.length > 0) {
299
+ SourceFileCache.cacheResults(file.sourceFile, timestamp, config.cachePath, fileResult.endpoints)
300
+ }
301
+ }
302
+
303
+ const analyzedFiles = [...cached, ...Array.from(byFile.values())]
304
+
305
+ if (profiling !== 'off') {
306
+ Logger.info(`Router analysis took ${Math.round(performance.now() - startTime)}ms`)
307
+ }
308
+
309
+ if (profiling === 'stats') {
310
+ analyzedFiles
311
+ .map((f) => ({ fileName: f.fileName, timeTaken: f.timing }))
312
+ .sort((a, b) => b.timeTaken - a.timeTaken)
313
+ .filter((t) => t.timeTaken > 500)
314
+ .forEach((t) => {
315
+ Logger.info(`- [${t.fileName}] Took ${Math.round(t.timeTaken)}ms to analyze`)
316
+ })
317
+ } else if (profiling === 'debug') {
318
+ analyzedFiles
319
+ .map((f) => ({ fileName: f.fileName, timeTaken: f.timing, endpointTimings: f.endpointTimings }))
320
+ .sort((a, b) => b.timeTaken - a.timeTaken)
321
+ .forEach((t) => {
322
+ Logger.info(`- [${t.fileName}] Took ${Math.round(t.timeTaken)}ms to analyze`)
323
+ t.endpointTimings
324
+ .sort((a, b) => b.timing - a.timing)
325
+ .forEach((ep) => {
326
+ Logger.info(` - ${ep.method} ${ep.path} (${Math.round(ep.timing)}ms)`)
327
+ ep.sectionTimings
328
+ .filter((s) => s.timing >= 1)
329
+ .sort((a, b) => b.timing - a.timing)
330
+ .forEach((s) => {
331
+ Logger.info(` - ${s.section}: ${Math.round(s.timing)}ms`)
332
+ })
333
+ })
334
+ })
335
+ }
173
336
 
174
337
  return analyzedFiles.flatMap((f) => f.endpoints)
175
338
  }
@@ -180,31 +343,33 @@ export const analyzeSourceFileWithCache = (
180
343
  incremental: boolean
181
344
  cachePath: string
182
345
  timestampCache: TimestampCache
346
+ profiling?: 'stats' | 'off' | 'debug'
183
347
  },
184
348
  filterEndpointPaths?: string[],
185
- ): { endpoints: EndpointData[]; timing: number } => {
349
+ ): { endpoints: EndpointData[]; timing: number; endpointTimings: EndpointTiming[] } => {
186
350
  const timestamp = getSourceFileTimestamp(file.sourceFile, config.timestampCache)
187
351
  const cachedResults = SourceFileCache.getCachedResults(file.sourceFile, timestamp, config.cachePath)
188
352
 
189
353
  if (cachedResults) {
190
354
  Logger.debug(`[${file.fileName}] Found cached results`)
191
- return { endpoints: cachedResults.endpoints, timing: 0 }
355
+ return { endpoints: cachedResults.endpoints, timing: 0, endpointTimings: [] }
192
356
  }
193
357
  Logger.debug(`[${file.fileName}] Analyzing...`)
194
358
 
195
359
  const t1 = performance.now()
196
- const endpoints = analyzeSourceFileEndpoints(file, filterEndpointPaths)
360
+ const { endpoints, endpointTimings } = analyzeSourceFileEndpoints(file, filterEndpointPaths)
197
361
  const t2 = performance.now()
198
362
  Logger.debug(`[${file.fileName}] Analyzed in ${t2 - t1}ms`)
199
363
  SourceFileCache.cacheResults(file.sourceFile, timestamp, config.cachePath, endpoints)
200
- return { endpoints, timing: t2 - t1 }
364
+ return { endpoints, timing: t2 - t1, endpointTimings }
201
365
  }
202
366
 
203
367
  export const analyzeSourceFileEndpoints = (
204
368
  file: DiscoveredSourceFile,
205
369
  filterEndpointPaths?: string[],
206
- ): EndpointData[] => {
370
+ ): { endpoints: EndpointData[]; endpointTimings: EndpointTiming[] } => {
207
371
  const endpoints: EndpointData[] = []
372
+ const endpointTimings: EndpointTiming[] = []
208
373
  const operations = ['get', 'post', 'put', 'delete', 'del', 'patch']
209
374
  const joinedOperations = operations.join('|')
210
375
 
@@ -220,12 +385,20 @@ export const analyzeSourceFileEndpoints = (
220
385
  return
221
386
  }
222
387
 
223
- endpoints.push(parseEndpoint(node, file.fileName))
388
+ const t1 = performance.now()
389
+ const { endpoint, sectionTimings } = parseEndpoint(node, file.fileName)
390
+ endpointTimings.push({
391
+ method: endpoint.method,
392
+ path: endpoint.path,
393
+ timing: performance.now() - t1,
394
+ sectionTimings,
395
+ })
396
+ endpoints.push(endpoint)
224
397
  }
225
398
  })
226
399
  })
227
400
 
228
- return endpoints
401
+ return { endpoints, endpointTimings }
229
402
  }
230
403
 
231
404
  export const analyzeSourceFileApiHeader = (sourceFile: SourceFile): ApiDocsHeader | null => {
@@ -0,0 +1,73 @@
1
+ import { Node, Project, ts } from 'ts-morph'
2
+ import { parentPort } from 'worker_threads'
3
+
4
+ import { parseEndpoint } from './parseEndpoint'
5
+ import { WorkerResult, WorkerTask } from './workerPool'
6
+
7
+ const OPERATIONS = ['get', 'post', 'put', 'delete', 'del', 'patch']
8
+ const OPERATIONS_PATTERN = OPERATIONS.join('|')
9
+
10
+ let project: Project | null = null
11
+ let currentTsconfigPath: string | null = null
12
+
13
+ function getProject(tsconfigPath: string): Project {
14
+ if (!project || currentTsconfigPath !== tsconfigPath) {
15
+ project = new Project({
16
+ tsConfigFilePath: tsconfigPath,
17
+ skipFileDependencyResolution: true,
18
+ })
19
+ currentTsconfigPath = tsconfigPath
20
+ }
21
+ return project
22
+ }
23
+
24
+ parentPort!.on('message', (task: WorkerTask) => {
25
+ try {
26
+ const proj = getProject(task.tsconfigPath)
27
+
28
+ let sourceFile = proj.getSourceFile(task.sourceFilePath)
29
+ if (!sourceFile) {
30
+ sourceFile = proj.addSourceFileAtPath(task.sourceFilePath)
31
+ }
32
+
33
+ const routerPattern = new RegExp(`${task.routerName}\\.(?:${OPERATIONS_PATTERN})`)
34
+ let index = 0
35
+ let targetNode: Node<ts.Node> | undefined
36
+
37
+ sourceFile.forEachChild((node) => {
38
+ if (targetNode) return
39
+ if (routerPattern.test(node.getText())) {
40
+ if (index === task.endpointIndex) {
41
+ targetNode = node
42
+ }
43
+ index++
44
+ }
45
+ })
46
+
47
+ if (!targetNode) {
48
+ const result: WorkerResult = {
49
+ taskId: task.taskId,
50
+ error: `Endpoint not found: routerName=${task.routerName} index=${task.endpointIndex} in ${task.sourceFilePath}`,
51
+ }
52
+ parentPort!.postMessage(result)
53
+ return
54
+ }
55
+
56
+ const t1 = performance.now()
57
+ const { endpoint, sectionTimings } = parseEndpoint(targetNode, task.sourceFilePath)
58
+
59
+ const result: WorkerResult = {
60
+ taskId: task.taskId,
61
+ endpoint,
62
+ sectionTimings,
63
+ timing: performance.now() - t1,
64
+ }
65
+ parentPort!.postMessage(result)
66
+ } catch (err) {
67
+ const result: WorkerResult = {
68
+ taskId: task.taskId,
69
+ error: String(err),
70
+ }
71
+ parentPort!.postMessage(result)
72
+ }
73
+ })
@@ -278,112 +278,12 @@ const isZodCallExpression = (node: Node): boolean => {
278
278
  const getZodCallShape = (node: Node): ShapeOfType['shape'] => {
279
279
  const callExpression = node.asKind(SyntaxKind.CallExpression)!
280
280
  const returnType = callExpression.getReturnType()
281
- const typeName = returnType.getSymbol()?.getName() ?? ''
282
-
283
- if (typeName === 'ZodNumber') {
284
- return 'number'
285
- }
286
- if (typeName === 'ZodString') {
287
- return 'string'
288
- }
289
- if (typeName === 'ZodBoolean') {
290
- return 'boolean'
291
- }
292
- if (typeName === 'ZodBigInt') {
293
- return 'bigint'
294
- }
295
-
296
- if (typeName === 'ZodObject') {
297
- const argNode = callExpression.getFirstChildByKind(SyntaxKind.SyntaxList)?.getFirstChild()
298
- const objectLiteral = argNode?.asKind(SyntaxKind.ObjectLiteralExpression)
299
- if (!objectLiteral) {
300
- return 'unknown_zod_object'
301
- }
302
- const syntaxList = objectLiteral.getFirstChildByKind(SyntaxKind.SyntaxList)
303
- if (!syntaxList) {
304
- return []
305
- }
306
- const properties = syntaxList.getChildrenOfKind(SyntaxKind.PropertyAssignment)
307
- return properties.map((prop) => {
308
- const identifier = prop.getFirstChildByKind(SyntaxKind.Identifier)!.getText()
309
- const valueNode = prop.getLastChild()!
310
- return {
311
- role: 'property' as const,
312
- identifier,
313
- shape: isZodCallExpression(valueNode)
314
- ? getZodCallShape(valueNode)
315
- : getValidatorPropertyShape(valueNode),
316
- optional: false,
317
- }
318
- })
319
- }
320
-
321
- if (typeName === 'ZodArray') {
322
- const argNode = callExpression.getFirstChildByKind(SyntaxKind.SyntaxList)?.getFirstChild()
323
- if (argNode) {
324
- const elementShape = isZodCallExpression(argNode)
325
- ? getZodCallShape(argNode)
326
- : getValidatorPropertyShape(argNode)
327
- return [
328
- {
329
- role: 'array' as const,
330
- shape: elementShape,
331
- optional: false,
332
- },
333
- ]
334
- }
335
- // Handle chained form: z.string().array()
336
- const propertyAccess = callExpression.getFirstChildByKind(SyntaxKind.PropertyAccessExpression)
337
- const receiverCall = propertyAccess?.getFirstChildByKind(SyntaxKind.CallExpression)
338
- if (receiverCall && isZodCallExpression(receiverCall)) {
339
- return [
340
- {
341
- role: 'array' as const,
342
- shape: getZodCallShape(receiverCall),
343
- optional: false,
344
- },
345
- ]
346
- }
347
- return 'unknown_zod_array'
281
+ const outputProp = returnType.getProperty('_output')
282
+ if (outputProp) {
283
+ return getProperTypeShape(outputProp.getTypeAtLocation(callExpression), callExpression)
348
284
  }
349
-
350
- if (typeName === 'ZodEnum') {
351
- const typeArgs = returnType.getTypeArguments()
352
- if (typeArgs.length > 0) {
353
- const enumType = typeArgs[0]
354
- const properties = enumType.getProperties()
355
- const shapes: ShapeOfUnionEntry[] = properties.map((prop) => ({
356
- role: 'union_entry' as const,
357
- shape: getProperTypeShape(prop.getTypeAtLocation(callExpression), callExpression, []),
358
- optional: false,
359
- }))
360
- if (shapes.length === 1) {
361
- return shapes[0].shape
362
- }
363
- if (shapes.length > 1) {
364
- return [
365
- {
366
- role: 'union' as const,
367
- shape: shapes,
368
- optional: false,
369
- },
370
- ]
371
- }
372
- }
373
- return 'unknown_zod_enum'
374
- }
375
-
376
- if (typeName === 'ZodOptional') {
377
- const innerCallExpression = callExpression
378
- .getFirstChildByKind(SyntaxKind.PropertyAccessExpression)
379
- ?.getFirstChildByKind(SyntaxKind.CallExpression)
380
- if (innerCallExpression && isZodCallExpression(innerCallExpression)) {
381
- return getZodCallShape(innerCallExpression)
382
- }
383
- return 'unknown_zod_optional'
384
- }
385
-
386
285
  const fileName = node.getSourceFile().getFilePath().split('/').pop()
286
+ const typeName = returnType.getSymbol()?.getName() ?? ''
387
287
  Logger.warn(`[${fileName}] Unknown zod type: ${typeName}`)
388
288
  return 'unknown_zod'
389
289
  }
@@ -14,7 +14,12 @@ import {
14
14
  resolveEndpointPath,
15
15
  } from './nodeParsers'
16
16
 
17
- export const parseEndpoint = (node: Node<ts.Node>, sourceFilePath: string) => {
17
+ export type SectionTiming = { section: string; timing: number }
18
+
19
+ export const parseEndpoint = (
20
+ node: Node<ts.Node>,
21
+ sourceFilePath: string,
22
+ ): { endpoint: EndpointData; sectionTimings: SectionTiming[] } => {
18
23
  const parsedEndpointMethod = node
19
24
  .getFirstDescendantByKind(SyntaxKind.PropertyAccessExpression)!
20
25
  .getText()
@@ -46,11 +51,20 @@ export const parseEndpoint = (node: Node<ts.Node>, sourceFilePath: string) => {
46
51
  error: Error
47
52
  }[] = []
48
53
 
54
+ const sectionTimings: SectionTiming[] = []
55
+
56
+ const time = <T>(section: string, fn: () => T): T => {
57
+ const t1 = performance.now()
58
+ const result = fn()
59
+ sectionTimings.push({ section, timing: performance.now() - t1 })
60
+ return result
61
+ }
62
+
49
63
  const hookNodes = getHookNodes(node)
50
64
 
51
65
  // API documentation
52
66
  try {
53
- const entries = parseApiDocumentation(hookNodes.useApiEndpoint)
67
+ const entries = time('useApiEndpoint', () => parseApiDocumentation(hookNodes.useApiEndpoint))
54
68
  entries.forEach((param) => {
55
69
  endpointData[param.identifier] = param.value as string & string[]
56
70
  })
@@ -64,7 +78,9 @@ export const parseEndpoint = (node: Node<ts.Node>, sourceFilePath: string) => {
64
78
 
65
79
  // Request params
66
80
  try {
67
- endpointData.requestPathParams = parseRequestParams(hookNodes.usePathParams, endpointPath)
81
+ endpointData.requestPathParams = time('usePathParams', () =>
82
+ parseRequestParams(hookNodes.usePathParams, endpointPath),
83
+ )
68
84
  } catch (err) {
69
85
  warningData.push({
70
86
  segment: 'path',
@@ -75,7 +91,9 @@ export const parseEndpoint = (node: Node<ts.Node>, sourceFilePath: string) => {
75
91
 
76
92
  // Request query
77
93
  try {
78
- endpointData.requestQuery = parseRequestObjectInput(hookNodes.useQueryParams, 'useQueryParams')
94
+ endpointData.requestQuery = time('useQueryParams', () =>
95
+ parseRequestObjectInput(hookNodes.useQueryParams, 'useQueryParams'),
96
+ )
79
97
  } catch (err) {
80
98
  warningData.push({
81
99
  segment: 'query',
@@ -86,7 +104,9 @@ export const parseEndpoint = (node: Node<ts.Node>, sourceFilePath: string) => {
86
104
 
87
105
  // Request headers
88
106
  try {
89
- endpointData.requestHeaders = parseRequestObjectInput(hookNodes.useHeaderParams, 'useHeaderParams')
107
+ endpointData.requestHeaders = time('useHeaderParams', () =>
108
+ parseRequestObjectInput(hookNodes.useHeaderParams, 'useHeaderParams'),
109
+ )
90
110
  } catch (err) {
91
111
  warningData.push({
92
112
  segment: 'headers',
@@ -97,7 +117,7 @@ export const parseEndpoint = (node: Node<ts.Node>, sourceFilePath: string) => {
97
117
 
98
118
  // Raw request body
99
119
  try {
100
- const parsedBody = parseRequestRawBody(hookNodes.useRequestRawBody)
120
+ const parsedBody = time('useRequestRawBody', () => parseRequestRawBody(hookNodes.useRequestRawBody))
101
121
  if (parsedBody) {
102
122
  endpointData.rawBody = parsedBody
103
123
  }
@@ -111,7 +131,9 @@ export const parseEndpoint = (node: Node<ts.Node>, sourceFilePath: string) => {
111
131
 
112
132
  // Object request body
113
133
  try {
114
- endpointData.objectBody = parseRequestObjectInput(hookNodes.useRequestBody, 'useRequestBody')
134
+ endpointData.objectBody = time('useRequestBody', () =>
135
+ parseRequestObjectInput(hookNodes.useRequestBody, 'useRequestBody'),
136
+ )
115
137
  } catch (err) {
116
138
  warningData.push({
117
139
  segment: 'objectBody',
@@ -122,7 +144,7 @@ export const parseEndpoint = (node: Node<ts.Node>, sourceFilePath: string) => {
122
144
 
123
145
  // Request response
124
146
  try {
125
- endpointData.responses = parseRequestResponse(node)
147
+ endpointData.responses = time('response', () => parseRequestResponse(node))
126
148
  } catch (err) {
127
149
  warningData.push({
128
150
  segment: 'response',
@@ -131,7 +153,7 @@ export const parseEndpoint = (node: Node<ts.Node>, sourceFilePath: string) => {
131
153
  Logger.error('Error', err)
132
154
  }
133
155
 
134
- return endpointData
156
+ return { endpoint: endpointData, sectionTimings }
135
157
  }
136
158
 
137
159
  type HookName =
@@ -11,6 +11,7 @@ export const TestCase = {
11
11
  parsesZodQueryNumberArray: 'parses-zod-query-number-array',
12
12
  parsesZodQueryObjectArray: 'parses-zod-query-object-array',
13
13
  parsesZodQueryOptionalArray: 'parses-zod-query-optional-array',
14
+ parsesZodPipe: 'parses-zod-pipe',
14
15
  parsesReturnRecordStringUnknown: 'parses-return-record-string-unknown',
15
16
  parsesReturnObjectWithRecordProperty: 'parses-return-object-with-record-property',
16
17
  parsesBufferReturnedFromFunction: 'parses-buffer-returned-from-function',
@@ -23,7 +23,7 @@ describe('OpenApi Analyzer', () => {
23
23
  },
24
24
  [`/test/${id}`],
25
25
  )
26
- const endpoint = analysisResult.find((endpoint) => endpoint.path.startsWith(`/test/${id}`))
26
+ const endpoint = analysisResult.endpoints.find((endpoint) => endpoint.path.startsWith(`/test/${id}`))
27
27
  if (!endpoint) {
28
28
  throw new Error(`No endpoint with id ${id} found!`)
29
29
  }
@@ -39,7 +39,7 @@ describe('OpenApi Analyzer', () => {
39
39
  },
40
40
  [`/test/${id}`],
41
41
  )
42
- const endpoints = analysisResult.filter((endpoint) => endpoint.path.startsWith(`/test/${id}`))
42
+ const endpoints = analysisResult.endpoints.filter((endpoint) => endpoint.path.startsWith(`/test/${id}`))
43
43
  if (endpoints.length === 0) {
44
44
  throw new Error(`No endpoint with id ${id} found!`)
45
45
  }
@@ -147,3 +147,9 @@ router.get(`/test/${TestCase.parsesZodQueryOptionalArray}`, (ctx) => {
147
147
  tags: z.array(z.string()).optional(),
148
148
  })
149
149
  })
150
+
151
+ router.post(`/test/${TestCase.parsesZodPipe}`, (ctx) => {
152
+ useRequestBody(ctx, {
153
+ value: z.string().pipe(z.coerce.number()),
154
+ })
155
+ })
@@ -19,7 +19,7 @@ describe('OpenApi Analyzer (Zod Validator)', () => {
19
19
  },
20
20
  [`/test/${id}`],
21
21
  )
22
- const endpoint = analysisResult.find((endpoint) => endpoint.path.startsWith(`/test/${id}`))
22
+ const endpoint = analysisResult.endpoints.find((endpoint) => endpoint.path.startsWith(`/test/${id}`))
23
23
  if (!endpoint) {
24
24
  throw new Error(`No endpoint with id ${id} found!`)
25
25
  }
@@ -315,6 +315,14 @@ describe('OpenApi Analyzer (Zod Validator)', () => {
315
315
  ])
316
316
  expect(endpoint.requestQuery[0].optional).toEqual(true)
317
317
  })
318
+
319
+ it('parses zod pipe validators', () => {
320
+ const endpoint = analyzeEndpointById(TestCase.parsesZodPipe)
321
+
322
+ expect(endpoint.objectBody[0].identifier).toEqual('value')
323
+ expect(endpoint.objectBody[0].signature).toEqual('number')
324
+ expect(endpoint.objectBody[0].optional).toEqual(false)
325
+ })
318
326
  })
319
327
  })
320
328
  })