moonflower 1.4.9 → 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 (41) 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 +3 -2
  4. package/dist/openapi/analyzerModule/analyzerModule.d.ts.map +1 -1
  5. package/dist/openapi/analyzerModule/analyzerModule.mjs +163 -125
  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/test/TestCase.d.ts +1 -0
  19. package/dist/openapi/analyzerModule/test/TestCase.d.ts.map +1 -1
  20. package/dist/openapi/analyzerModule/test/workerGlobalSetup.d.ts +3 -0
  21. package/dist/openapi/analyzerModule/test/workerGlobalSetup.d.ts.map +1 -0
  22. package/dist/openapi/analyzerModule/workerPaths.d.ts +2 -0
  23. package/dist/openapi/analyzerModule/workerPaths.d.ts.map +1 -0
  24. package/dist/openapi/analyzerModule/workerPool.cjs +2 -0
  25. package/dist/openapi/analyzerModule/workerPool.cjs.map +1 -0
  26. package/dist/openapi/analyzerModule/workerPool.d.ts +33 -0
  27. package/dist/openapi/analyzerModule/workerPool.d.ts.map +1 -0
  28. package/dist/openapi/analyzerModule/workerPool.mjs +46 -0
  29. package/dist/openapi/analyzerModule/workerPool.mjs.map +1 -0
  30. package/package.json +1 -1
  31. package/src/openapi/analyzerModule/analyzerModule.ts +142 -12
  32. package/src/openapi/analyzerModule/analyzerWorker.ts +73 -0
  33. package/src/openapi/analyzerModule/nodeParsers.ts +4 -104
  34. package/src/openapi/analyzerModule/test/TestCase.ts +1 -0
  35. package/src/openapi/analyzerModule/test/openApiAnalyzer.zod.spec.data.ts +6 -0
  36. package/src/openapi/analyzerModule/test/openApiAnalyzer.zod.spec.ts +8 -0
  37. package/src/openapi/analyzerModule/test/workerGlobalSetup.ts +25 -0
  38. package/src/openapi/analyzerModule/workerPaths.ts +4 -0
  39. package/src/openapi/analyzerModule/workerPool.ts +92 -0
  40. package/src/test/app.spec.ts +10 -1
  41. package/vite.config.ts +5 -0
@@ -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
  }
@@ -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',
@@ -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
+ })
@@ -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
  })
@@ -0,0 +1,25 @@
1
+ import { buildSync } from 'esbuild'
2
+ import { existsSync, unlinkSync } from 'fs'
3
+ import path from 'path'
4
+ import { fileURLToPath } from 'url'
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
7
+ const workerSrc = path.join(__dirname, '..', 'analyzerWorker.ts')
8
+ const workerOut = path.join(__dirname, '..', 'analyzerWorker.test.mjs')
9
+
10
+ export function setup() {
11
+ buildSync({
12
+ entryPoints: [workerSrc],
13
+ outfile: workerOut,
14
+ bundle: true,
15
+ format: 'esm',
16
+ platform: 'node',
17
+ external: ['ts-morph', '@ts-morph/common', 'typescript', 'worker_threads'],
18
+ })
19
+ }
20
+
21
+ export function teardown() {
22
+ if (existsSync(workerOut)) {
23
+ unlinkSync(workerOut)
24
+ }
25
+ }
@@ -0,0 +1,4 @@
1
+ import os from 'os'
2
+ import path from 'path'
3
+
4
+ export const TEST_WORKER_PATH = path.join(os.tmpdir(), 'moonflower-analyzerWorker.test.mjs')
@@ -0,0 +1,92 @@
1
+ import os from 'os'
2
+ import { Worker } from 'worker_threads'
3
+
4
+ import { EndpointData } from '../types'
5
+ import { SectionTiming } from './parseEndpoint'
6
+
7
+ export type WorkerTask = {
8
+ taskId: string
9
+ tsconfigPath: string
10
+ sourceFilePath: string
11
+ routerName: string
12
+ endpointIndex: number
13
+ }
14
+
15
+ export type WorkerResultSuccess = {
16
+ taskId: string
17
+ endpoint: EndpointData
18
+ sectionTimings: SectionTiming[]
19
+ timing: number
20
+ }
21
+
22
+ export type WorkerResultError = {
23
+ taskId: string
24
+ error: string
25
+ }
26
+
27
+ export type WorkerResult = WorkerResultSuccess | WorkerResultError
28
+
29
+ export class WorkerPool {
30
+ private workers: Worker[]
31
+ private idle: Worker[]
32
+ private queue: Array<{ task: WorkerTask; resolve: (r: WorkerResult) => void }> = []
33
+ private pending = new Map<string, (r: WorkerResult) => void>()
34
+
35
+ constructor(workerUrl: URL) {
36
+ const size = Math.max(1, Math.min(os.cpus().length - 1, 8))
37
+ this.workers = Array.from({ length: size }, () => {
38
+ const worker = new Worker(workerUrl)
39
+ worker.on('message', (result: WorkerResult) => {
40
+ const resolve = this.pending.get(result.taskId)
41
+ if (resolve) {
42
+ this.pending.delete(result.taskId)
43
+ resolve(result)
44
+ }
45
+ this.idle.push(worker)
46
+ this.flush()
47
+ })
48
+ worker.on('error', (err) => {
49
+ // Find any pending task for this worker and reject it
50
+ // (worker crashed — shouldn't happen, but handle gracefully)
51
+ for (const [taskId, resolve] of this.pending) {
52
+ resolve({ taskId, error: String(err) })
53
+ this.pending.delete(taskId)
54
+ break
55
+ }
56
+ this.idle.push(worker)
57
+ this.flush()
58
+ })
59
+ return worker
60
+ })
61
+ this.idle = [...this.workers]
62
+ }
63
+
64
+ run(task: WorkerTask): Promise<WorkerResult> {
65
+ return new Promise((resolve) => {
66
+ if (this.idle.length > 0) {
67
+ const worker = this.idle.pop()!
68
+ this.pending.set(task.taskId, resolve)
69
+ worker.postMessage(task)
70
+ } else {
71
+ this.queue.push({ task, resolve })
72
+ }
73
+ })
74
+ }
75
+
76
+ runAll(tasks: WorkerTask[]): Promise<WorkerResult[]> {
77
+ return Promise.all(tasks.map((t) => this.run(t)))
78
+ }
79
+
80
+ terminate() {
81
+ this.workers.forEach((w) => w.terminate())
82
+ }
83
+
84
+ private flush() {
85
+ while (this.queue.length > 0 && this.idle.length > 0) {
86
+ const { task, resolve } = this.queue.shift()!
87
+ const worker = this.idle.pop()!
88
+ this.pending.set(task.taskId, resolve)
89
+ worker.postMessage(task)
90
+ }
91
+ }
92
+ }
@@ -3,13 +3,22 @@ import Koa from 'koa'
3
3
  import * as os from 'os'
4
4
  import * as path from 'path'
5
5
  import request from 'supertest'
6
- import { describe, expect, it, vi } from 'vitest'
6
+ import { beforeAll, describe, expect, it, vi } from 'vitest'
7
7
 
8
8
  import { generateOpenApiSpec } from '../openapi/generatorModule/generatorModule'
9
9
  import { initOpenApiEngine } from '../openapi/initOpenApiEngine'
10
10
  import { OpenApiManager } from '../openapi/manager/OpenApiManager'
11
11
  import { app } from './app'
12
12
 
13
+ // The OpenAPI engine runs the TypeScript analyzer lazily on first request, and any request that
14
+ // reaches the initOpenApiEngine middleware awaits its readiness. Requests that match a route never
15
+ // reach it, but unmatched ones (e.g. the 405 test) do, so the first such request pays the full
16
+ // analyzer cold-start cost. That can exceed a single test's default 5s timeout in CI. Warm the
17
+ // engine up once here so no individual test bears that cost.
18
+ beforeAll(async () => {
19
+ await request(app.callback()).get('/api-json')
20
+ }, 60_000)
21
+
13
22
  describe('TestAppRouter', () => {
14
23
  it('includes content type header', async () => {
15
24
  const response = await request(app.callback()).get('/test/hello')
package/vite.config.ts CHANGED
@@ -24,6 +24,7 @@ const entries = {
24
24
  'validators/BuiltInValidators': resolve(__dirname, 'src/validators/BuiltInValidators.ts'),
25
25
  'validators/ParamWrappers': resolve(__dirname, 'src/validators/ParamWrappers.ts'),
26
26
  'cli/cli': resolve(__dirname, 'src/cli/cli.ts'),
27
+ 'openapi/analyzerModule/analyzerWorker': resolve(__dirname, 'src/openapi/analyzerModule/analyzerWorker.ts'),
27
28
  }
28
29
 
29
30
  export const baseViteConfig: ViteUserConfig = {
@@ -50,7 +51,10 @@ export const baseViteConfig: ViteUserConfig = {
50
51
  'fs/promises',
51
52
  'assert',
52
53
  'crypto',
54
+ 'os',
53
55
  'perf_hooks',
56
+ 'url',
57
+ 'worker_threads',
54
58
  '@ts-morph/common',
55
59
  'typescript',
56
60
  'ts-morph',
@@ -90,6 +94,7 @@ export const baseViteConfig: ViteUserConfig = {
90
94
  },
91
95
  test: {
92
96
  globals: true,
97
+ globalSetup: 'src/openapi/analyzerModule/test/workerGlobalSetup.ts',
93
98
  setupFiles: 'src/setupTests.ts',
94
99
  include: ['src/**/*.spec.ts'],
95
100
  coverage: {