moonflower 1.5.0 → 1.5.2

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.
@@ -53,6 +53,13 @@ type FileDiscoveryConfig = {
53
53
 
54
54
  type EndpointTiming = { method: string; path: string; timing: number; sectionTimings: SectionTiming[] }
55
55
 
56
+ /**
57
+ * Number of uncached files at or below which analysis runs inline on the (already-warm) main-thread
58
+ * Project instead of fanning out to worker threads. Each worker pays a multi-second cold-start to
59
+ * build its own Project, so parallelism only wins once there are several files to share that cost.
60
+ */
61
+ const INLINE_ANALYSIS_FILE_THRESHOLD = 2
62
+
56
63
  /**
57
64
  * @param tsconfigPath Path to tsconfig file relative to project root
58
65
  * @param sourceFilePaths Array of router source files relative to project root
@@ -213,45 +220,6 @@ export const analyzeMultipleSourceFiles = async (
213
220
  return cached.flatMap((f) => f.endpoints)
214
221
  }
215
222
 
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
223
  type FileResult = {
256
224
  endpoints: EndpointData[]
257
225
  fileName: string
@@ -259,37 +227,63 @@ export const analyzeMultipleSourceFiles = async (
259
227
  endpointTimings: EndpointTiming[]
260
228
  }
261
229
 
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
230
  const byFile = new Map<string, FileResult>()
271
231
  for (const { file } of uncached) {
272
232
  byFile.set(file.fileName, { endpoints: [], fileName: file.fileName, timing: 0, endpointTimings: [] })
273
233
  }
274
234
 
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)!
235
+ // The caller (prepareOpenApiSpec) already built and warmed a ts-morph Project on this thread, so
236
+ // inline analysis runs against a hot type-checker. A worker, by contrast, must spawn a thread and
237
+ // build its own Project from scratch — a multi-second cold-start. That cold-start only pays off
238
+ // when there are enough uncached files that spreading the parse work across workers beats it; below
239
+ // the threshold (e.g. the common single-file incremental rebuild), inline is strictly faster.
240
+ if (uncached.length <= INLINE_ANALYSIS_FILE_THRESHOLD) {
241
+ for (const { file } of uncached) {
242
+ const { endpoints, endpointTimings } = analyzeSourceFileEndpoints(file, filterEndpointPaths)
243
+ const fileResult = byFile.get(file.fileName)!
244
+ fileResult.endpoints = endpoints
245
+ fileResult.endpointTimings = endpointTimings
246
+ fileResult.timing = endpointTimings.reduce((sum, t) => sum + t.timing, 0)
247
+ }
248
+ } else {
249
+ // One task per file: each worker analyzes a whole file in a single pass, paying its Project
250
+ // cold-start once and reusing the warmed-up checker for every endpoint in that file. Cap the
251
+ // pool at one worker per file so we never spin up a worker with nothing to do.
252
+ type FileTask = { task: WorkerTask; fileName: string }
253
+ const allTasks: FileTask[] = uncached.map(({ file }) => ({
254
+ fileName: file.fileName,
255
+ task: {
256
+ taskId: crypto.randomUUID(),
257
+ tsconfigPath: config.tsconfigPath,
258
+ sourceFilePath: file.sourceFile.getFilePath(),
259
+ routerNames: file.routers.named,
260
+ filterEndpointPaths,
261
+ },
262
+ }))
279
263
 
280
- if ('error' in result) {
281
- Logger.error(`[${fileName}] Worker error: ${result.error}`)
282
- continue
264
+ const pool = new WorkerPool(resolveWorkerUrl(), allTasks.length)
265
+ let results: WorkerResult[]
266
+ try {
267
+ results = await pool.runAll(allTasks.map((ft) => ft.task))
268
+ } finally {
269
+ pool.terminate()
283
270
  }
284
271
 
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,
292
- })
272
+ // Each result maps 1:1 to a file task.
273
+ for (let i = 0; i < results.length; i++) {
274
+ const result = results[i]
275
+ const fileName = allTasks[i].fileName
276
+ const fileResult = byFile.get(fileName)!
277
+
278
+ if ('error' in result) {
279
+ Logger.error(`[${fileName}] Worker error: ${result.error}`)
280
+ continue
281
+ }
282
+
283
+ fileResult.endpoints = result.endpoints
284
+ fileResult.endpointTimings = result.endpointTimings
285
+ fileResult.timing = result.endpointTimings.reduce((sum, t) => sum + t.timing, 0)
286
+ }
293
287
  }
294
288
 
295
289
  // Write cache for each uncached file
@@ -1,8 +1,10 @@
1
- import { Node, Project, ts } from 'ts-morph'
1
+ import { Project } from 'ts-morph'
2
2
  import { parentPort } from 'worker_threads'
3
3
 
4
+ import { EndpointData } from '../types'
5
+ import { resolveEndpointPath } from './nodeParsers'
4
6
  import { parseEndpoint } from './parseEndpoint'
5
- import { WorkerResult, WorkerTask } from './workerPool'
7
+ import { EndpointTiming, WorkerResult, WorkerTask } from './workerPool'
6
8
 
7
9
  const OPERATIONS = ['get', 'post', 'put', 'delete', 'del', 'patch']
8
10
  const OPERATIONS_PATTERN = OPERATIONS.join('|')
@@ -30,37 +32,39 @@ parentPort!.on('message', (task: WorkerTask) => {
30
32
  sourceFile = proj.addSourceFileAtPath(task.sourceFilePath)
31
33
  }
32
34
 
33
- const routerPattern = new RegExp(`${task.routerName}\\.(?:${OPERATIONS_PATTERN})`)
34
- let index = 0
35
- let targetNode: Node<ts.Node> | undefined
35
+ const endpoints: EndpointData[] = []
36
+ const endpointTimings: EndpointTiming[] = []
36
37
 
37
- sourceFile.forEachChild((node) => {
38
- if (targetNode) return
39
- if (routerPattern.test(node.getText())) {
40
- if (index === task.endpointIndex) {
41
- targetNode = node
38
+ task.routerNames.forEach((routerName) => {
39
+ const routerPattern = new RegExp(`${routerName}\\.(?:${OPERATIONS_PATTERN})`)
40
+ sourceFile!.forEachChild((node) => {
41
+ if (!routerPattern.test(node.getText())) {
42
+ return
42
43
  }
43
- index++
44
- }
45
- })
46
44
 
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
- }
45
+ if (task.filterEndpointPaths) {
46
+ const endpointPath = resolveEndpointPath(node) ?? ''
47
+ if (!task.filterEndpointPaths.some((p) => endpointPath.includes(p))) {
48
+ return
49
+ }
50
+ }
55
51
 
56
- const t1 = performance.now()
57
- const { endpoint, sectionTimings } = parseEndpoint(targetNode, task.sourceFilePath)
52
+ const t1 = performance.now()
53
+ const { endpoint, sectionTimings } = parseEndpoint(node, task.sourceFilePath)
54
+ endpointTimings.push({
55
+ method: endpoint.method,
56
+ path: endpoint.path,
57
+ timing: performance.now() - t1,
58
+ sectionTimings,
59
+ })
60
+ endpoints.push(endpoint)
61
+ })
62
+ })
58
63
 
59
64
  const result: WorkerResult = {
60
65
  taskId: task.taskId,
61
- endpoint,
62
- sectionTimings,
63
- timing: performance.now() - t1,
66
+ endpoints,
67
+ endpointTimings,
64
68
  }
65
69
  parentPort!.postMessage(result)
66
70
  } catch (err) {
@@ -8,15 +8,21 @@ export type WorkerTask = {
8
8
  taskId: string
9
9
  tsconfigPath: string
10
10
  sourceFilePath: string
11
- routerName: string
12
- endpointIndex: number
11
+ routerNames: string[]
12
+ filterEndpointPaths?: string[]
13
+ }
14
+
15
+ export type EndpointTiming = {
16
+ method: string
17
+ path: string
18
+ timing: number
19
+ sectionTimings: SectionTiming[]
13
20
  }
14
21
 
15
22
  export type WorkerResultSuccess = {
16
23
  taskId: string
17
- endpoint: EndpointData
18
- sectionTimings: SectionTiming[]
19
- timing: number
24
+ endpoints: EndpointData[]
25
+ endpointTimings: EndpointTiming[]
20
26
  }
21
27
 
22
28
  export type WorkerResultError = {
@@ -32,8 +38,12 @@ export class WorkerPool {
32
38
  private queue: Array<{ task: WorkerTask; resolve: (r: WorkerResult) => void }> = []
33
39
  private pending = new Map<string, (r: WorkerResult) => void>()
34
40
 
35
- constructor(workerUrl: URL) {
36
- const size = Math.max(1, Math.min(os.cpus().length - 1, 8))
41
+ constructor(workerUrl: URL, maxWorkers?: number) {
42
+ const cpuBound = Math.max(1, Math.min(os.cpus().length - 1, 8))
43
+ // Never spin up more workers than there are files to analyze: each extra worker would
44
+ // just pay the ts-morph Project cold-start (full TS program load + type-checker warmup)
45
+ // for nothing while contending for CPU with the workers that actually have work.
46
+ const size = maxWorkers === undefined ? cpuBound : Math.max(1, Math.min(cpuBound, maxWorkers))
37
47
  this.workers = Array.from({ length: size }, () => {
38
48
  const worker = new Worker(workerUrl)
39
49
  worker.on('message', (result: WorkerResult) => {