@stream44.studio/encapsulate 0.4.0-rc.5

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.
@@ -0,0 +1,582 @@
1
+ import { join, dirname, resolve as pathResolve } from 'path'
2
+ import { writeFile, mkdir, readFile, stat } from 'fs/promises'
3
+ import { Spine, SpineRuntime, CapsulePropertyTypes, makeImportStack, merge } from "../encapsulate"
4
+ import { StaticAnalyzer } from "../../src/static-analyzer.v0"
5
+ import { CapsuleModuleProjector } from "../../src/capsule-projectors/CapsuleModuleProjector.v0"
6
+
7
+
8
+ export { merge }
9
+
10
+ // Pure filesystem resolve — never calls Bun.resolve or createRequire to avoid
11
+ // Bun's internal module resolver which can OOM on large capsule trees.
12
+ //
13
+ // Resolution strategy:
14
+ // 1. Scoped packages: @scope/package/path → spineRoot/scope/packages/package/path.ts
15
+ // (matches tsconfig.paths.json pattern used across the workspace)
16
+ // 2. Scoped package root: @scope/package → resolve via package.json exports
17
+ // 3. Relative paths are resolved by the caller before reaching this function.
18
+ async function resolve(uri: string, fromPath: string, spineRoot?: string): Promise<string> {
19
+
20
+ // Scoped package: @scope/package/subpath
21
+ if (uri.startsWith('@') && spineRoot) {
22
+ const match = uri.match(/^@([^/]+)\/([^/]+)(?:\/(.+))?$/)
23
+ if (match) {
24
+ const [, scope, pkg, subpath] = match
25
+
26
+ if (subpath) {
27
+ // @scope/package/path → spineRoot/scope/packages/package/path.ts
28
+ const fsPath = join(spineRoot, scope, 'packages', pkg, subpath + '.ts')
29
+ try {
30
+ await stat(fsPath)
31
+ return fsPath
32
+ } catch {
33
+ // Try without .ts extension (already has extension)
34
+ try {
35
+ await stat(join(spineRoot, scope, 'packages', pkg, subpath))
36
+ return join(spineRoot, scope, 'packages', pkg, subpath)
37
+ } catch {
38
+ // Fall through to package.json exports resolution
39
+ }
40
+ }
41
+ }
42
+
43
+ // Try resolving via package.json exports
44
+ const packageDir = join(spineRoot, scope, 'packages', pkg)
45
+ try {
46
+ const packageJsonPath = join(packageDir, 'package.json')
47
+ const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf-8'))
48
+
49
+ if (subpath && packageJson.exports) {
50
+ // Look for matching export: "./" + subpath or "./" + subpath + ".ts"
51
+ const exportKey = './' + subpath
52
+ const exportValue = packageJson.exports[exportKey]
53
+ if (typeof exportValue === 'string') {
54
+ return pathResolve(packageDir, exportValue)
55
+ }
56
+ } else if (!subpath && packageJson.exports?.['.']) {
57
+ const mainExport = packageJson.exports['.']
58
+ if (typeof mainExport === 'string') {
59
+ return pathResolve(packageDir, mainExport)
60
+ }
61
+ } else if (!subpath && packageJson.main) {
62
+ return pathResolve(packageDir, packageJson.main)
63
+ }
64
+ } catch {
65
+ // package.json doesn't exist or is invalid
66
+ }
67
+
68
+ // Traverse up from spineRoot looking for the package:
69
+ // 1. Check if current dir IS the package (self-package)
70
+ // 2. Check node_modules/@scope/pkg at each level
71
+ let dir = spineRoot
72
+ while (true) {
73
+ // Check if this directory's package.json matches the requested package
74
+ try {
75
+ const pjPath = join(dir, 'package.json')
76
+ const pj = JSON.parse(await readFile(pjPath, 'utf-8'))
77
+ if (pj.name === `@${scope}/${pkg}`) {
78
+ if (subpath) {
79
+ if (pj.exports) {
80
+ const exportKey = './' + subpath
81
+ const exportValue = pj.exports[exportKey]
82
+ if (typeof exportValue === 'string') {
83
+ return pathResolve(dir, exportValue)
84
+ }
85
+ }
86
+ const fsPath = join(dir, subpath + '.ts')
87
+ try { await stat(fsPath); return fsPath } catch { }
88
+ try { await stat(join(dir, subpath)); return join(dir, subpath) } catch { }
89
+ } else if (pj.exports?.['.']) {
90
+ const mainExport = pj.exports['.']
91
+ if (typeof mainExport === 'string') {
92
+ return pathResolve(dir, mainExport)
93
+ }
94
+ } else if (pj.main) {
95
+ return pathResolve(dir, pj.main)
96
+ }
97
+ }
98
+ } catch { }
99
+
100
+ // Check node_modules/@scope/pkg
101
+ const nmCandidate = join(dir, 'node_modules', `@${scope}`, pkg)
102
+ try {
103
+ const nmPjPath = join(nmCandidate, 'package.json')
104
+ const nmPj = JSON.parse(await readFile(nmPjPath, 'utf-8'))
105
+ if (subpath) {
106
+ if (nmPj.exports) {
107
+ const exportKey = './' + subpath
108
+ const exportValue = nmPj.exports[exportKey]
109
+ if (typeof exportValue === 'string') {
110
+ return pathResolve(nmCandidate, exportValue)
111
+ }
112
+ }
113
+ const fsPath = join(nmCandidate, subpath + '.ts')
114
+ try { await stat(fsPath); return fsPath } catch { }
115
+ try { await stat(join(nmCandidate, subpath)); return join(nmCandidate, subpath) } catch { }
116
+ } else if (nmPj.exports?.['.']) {
117
+ const mainExport = nmPj.exports['.']
118
+ if (typeof mainExport === 'string') {
119
+ return pathResolve(nmCandidate, mainExport)
120
+ }
121
+ } else if (nmPj.main) {
122
+ return pathResolve(nmCandidate, nmPj.main)
123
+ }
124
+ } catch { }
125
+
126
+ const parent = dirname(dir)
127
+ if (parent === dir) break
128
+ dir = parent
129
+ }
130
+
131
+ // Also traverse up from fromPath (the importing file) checking:
132
+ // 1. If current dir IS the package (self-package resolution)
133
+ // 2. node_modules/@scope/pkg at each level
134
+ let fromDir = dirname(fromPath)
135
+ while (true) {
136
+ // Check if this directory's package.json matches the requested package
137
+ try {
138
+ const pjPath = join(fromDir, 'package.json')
139
+ const pj = JSON.parse(await readFile(pjPath, 'utf-8'))
140
+ if (pj.name === `@${scope}/${pkg}`) {
141
+ if (subpath) {
142
+ if (pj.exports) {
143
+ const exportKey = './' + subpath
144
+ const exportValue = pj.exports[exportKey]
145
+ if (typeof exportValue === 'string') {
146
+ return pathResolve(fromDir, exportValue)
147
+ }
148
+ }
149
+ const fsPath = join(fromDir, subpath + '.ts')
150
+ try { await stat(fsPath); return fsPath } catch { }
151
+ try { await stat(join(fromDir, subpath)); return join(fromDir, subpath) } catch { }
152
+ } else if (pj.exports?.['.']) {
153
+ const mainExport = pj.exports['.']
154
+ if (typeof mainExport === 'string') {
155
+ return pathResolve(fromDir, mainExport)
156
+ }
157
+ } else if (pj.main) {
158
+ return pathResolve(fromDir, pj.main)
159
+ }
160
+ }
161
+ } catch { }
162
+
163
+ const nmCandidate = join(fromDir, 'node_modules', `@${scope}`, pkg)
164
+ try {
165
+ const nmPjPath = join(nmCandidate, 'package.json')
166
+ const nmPj = JSON.parse(await readFile(nmPjPath, 'utf-8'))
167
+ if (subpath) {
168
+ if (nmPj.exports) {
169
+ const exportKey = './' + subpath
170
+ const exportValue = nmPj.exports[exportKey]
171
+ if (typeof exportValue === 'string') {
172
+ return pathResolve(nmCandidate, exportValue)
173
+ }
174
+ }
175
+ const fsPath = join(nmCandidate, subpath + '.ts')
176
+ try { await stat(fsPath); return fsPath } catch { }
177
+ try { await stat(join(nmCandidate, subpath)); return join(nmCandidate, subpath) } catch { }
178
+ } else if (nmPj.exports?.['.']) {
179
+ const mainExport = nmPj.exports['.']
180
+ if (typeof mainExport === 'string') {
181
+ return pathResolve(nmCandidate, mainExport)
182
+ }
183
+ } else if (nmPj.main) {
184
+ return pathResolve(nmCandidate, nmPj.main)
185
+ }
186
+ } catch { }
187
+
188
+ const parent = dirname(fromDir)
189
+ if (parent === fromDir) break
190
+ fromDir = parent
191
+ }
192
+ }
193
+ }
194
+
195
+ // Absolute path — probe extensions
196
+ if (uri.startsWith('/')) {
197
+ // Try exact path first
198
+ try {
199
+ const s = await stat(uri)
200
+ if (s.isFile()) return uri
201
+ if (s.isDirectory()) {
202
+ for (const idx of ['index.ts', 'index.js']) {
203
+ try { await stat(join(uri, idx)); return join(uri, idx) } catch { }
204
+ }
205
+ }
206
+ } catch { }
207
+ // Try with extensions
208
+ for (const ext of ['.ts', '.js', '.mjs']) {
209
+ try { await stat(uri + ext); return uri + ext } catch { }
210
+ }
211
+ }
212
+
213
+ // Non-scoped bare specifier — try node_modules resolution from fromPath
214
+ if (!uri.startsWith('.') && !uri.startsWith('/')) {
215
+ // Walk up from fromPath looking for node_modules
216
+ let dir = dirname(fromPath)
217
+ while (true) {
218
+ const candidate = join(dir, 'node_modules', uri)
219
+ try {
220
+ const s = await stat(candidate)
221
+ if (s.isDirectory()) {
222
+ // Try package.json main/exports
223
+ try {
224
+ const pj = JSON.parse(await readFile(join(candidate, 'package.json'), 'utf-8'))
225
+ if (pj.main) return pathResolve(candidate, pj.main)
226
+ } catch { }
227
+ return join(candidate, 'index.js')
228
+ }
229
+ return candidate
230
+ } catch { }
231
+ // Try with extensions
232
+ for (const ext of ['.ts', '.js', '.mjs']) {
233
+ try {
234
+ await stat(candidate + ext)
235
+ return candidate + ext
236
+ } catch { }
237
+ }
238
+ const parent = dirname(dir)
239
+ if (parent === dir) break
240
+ dir = parent
241
+ }
242
+ }
243
+
244
+ throw new Error(`[encapsulate resolve] Cannot resolve '${uri}' from '${fromPath}' (spineRoot: ${spineRoot})`)
245
+ }
246
+
247
+
248
+ export async function CapsuleSpineFactory({
249
+ spineFilesystemRoot,
250
+ capsuleModuleProjectionRoot,
251
+ capsuleModuleProjectionPackage,
252
+ staticAnalysisEnabled = true,
253
+ onMembraneEvent,
254
+ enableCallerStackInference = false,
255
+ spineContracts,
256
+ timing: timingParam
257
+ }: {
258
+ spineFilesystemRoot: string,
259
+ capsuleModuleProjectionRoot?: string,
260
+ capsuleModuleProjectionPackage?: string,
261
+ staticAnalysisEnabled?: boolean,
262
+ onMembraneEvent?: (event: any) => void,
263
+ enableCallerStackInference?: boolean,
264
+ spineContracts: Record<string, any>,
265
+ timing?: { record: (step: string) => void, recordMajor: (step: string) => void, chalk?: any }
266
+ }) {
267
+
268
+ if (capsuleModuleProjectionRoot) capsuleModuleProjectionRoot = capsuleModuleProjectionRoot.replace(/^file:\/\//, '')
269
+ if (spineFilesystemRoot) spineFilesystemRoot = spineFilesystemRoot.replace(/^file:\/\//, '')
270
+
271
+ const timing = timingParam
272
+
273
+ timing?.recordMajor('CAPSULE SPINE FACTORY: INITIALIZATION')
274
+
275
+ const SingletonRegistry = () => {
276
+ const registry = new Map<string, Promise<any>>()
277
+
278
+ return {
279
+ async ensure(id: string, createHandler: () => Promise<any>) {
280
+ if (!registry.has(id)) {
281
+ registry.set(id, createHandler())
282
+ }
283
+ return registry.get(id)!
284
+ }
285
+ }
286
+ }
287
+
288
+ const registry = SingletonRegistry()
289
+
290
+ const spineContractInstances: {
291
+ encapsulation: Record<string, any>,
292
+ runtime: Record<string, any>
293
+ } = {
294
+ encapsulation: {},
295
+ runtime: {}
296
+ }
297
+
298
+ const sourceSpine: { encapsulate?: any } = {}
299
+ const commonSpineContractOpts = {
300
+ spineFilesystemRoot,
301
+ resolve: async (uri: string, parentFilepath: string) => {
302
+ // For relative paths, join with parent directory first
303
+ if (/^\.\.?\//.test(uri)) {
304
+ return await resolve(join(parentFilepath, '..', uri), spineFilesystemRoot, spineFilesystemRoot)
305
+ }
306
+ // For absolute/package paths, use custom resolve with spine root
307
+ return await resolve(uri, parentFilepath, spineFilesystemRoot)
308
+ },
309
+ importCapsule: (() => {
310
+ const callSequence: string[] = []
311
+ const MAX_SEQUENCE = 300
312
+ const REPEAT_THRESHOLD = 3
313
+
314
+ function detectRepeatingSequence(seq: string[]): string[] | null {
315
+ const len = seq.length
316
+ // Check subsequences of length 2..len/3
317
+ for (let subLen = 2; subLen <= Math.floor(len / REPEAT_THRESHOLD); subLen++) {
318
+ const tail = seq.slice(len - subLen)
319
+ let repeats = 1
320
+ for (let offset = subLen; offset <= len - subLen; offset += subLen) {
321
+ const chunk = seq.slice(len - subLen - offset, len - offset)
322
+ if (chunk.length === tail.length && chunk.every((v, i) => v === tail[i])) {
323
+ repeats++
324
+ } else {
325
+ break
326
+ }
327
+ }
328
+ if (repeats >= REPEAT_THRESHOLD) {
329
+ return tail
330
+ }
331
+ }
332
+ return null
333
+ }
334
+
335
+ let callCount = 0
336
+
337
+ return async (filepath: string) => {
338
+ const shortPath = filepath.replace(/^.*\/genesis\//, '')
339
+
340
+ callCount++
341
+ callSequence.push(shortPath)
342
+ if (callSequence.length > MAX_SEQUENCE) {
343
+ callSequence.splice(0, callSequence.length - MAX_SEQUENCE)
344
+ }
345
+
346
+ const repeating = detectRepeatingSequence(callSequence)
347
+ if (repeating) {
348
+ throw new Error(
349
+ `Circular capsule loading detected! The following sequence repeated ${REPEAT_THRESHOLD} times:\n` +
350
+ repeating.map((p, i) => ` ${i + 1}. ${p}`).join('\n')
351
+ )
352
+ }
353
+
354
+ timing?.record(`importCapsule: Called for ${shortPath}`)
355
+ const result = await registry.ensure(filepath, async () => {
356
+ timing?.recordMajor(`importCapsule: Starting import for ${shortPath}`)
357
+ const importStart = Date.now()
358
+ const exports = await import(filepath)
359
+ const importDuration = Date.now() - importStart
360
+ timing?.recordMajor(`importCapsule: import() took ${importDuration}ms for ${shortPath}`)
361
+
362
+ if (importDuration > 10) {
363
+ if (timing) {
364
+ console.log(timing.chalk.red(`\n⚠️ WARNING: Slow module load detected!`))
365
+ console.log(timing.chalk.red(` Module: ${filepath}`))
366
+ console.log(timing.chalk.red(` Load time: ${importDuration}ms`))
367
+ console.log(timing.chalk.red(` Consider using dynamic imports to load heavy dependencies only when needed.\n`))
368
+ }
369
+ }
370
+
371
+ if (typeof exports.capsule !== 'function') throw new Error(`Module at '${filepath}' does not export 'capsule'!`)
372
+
373
+ const capsuleStart = Date.now()
374
+ const capsule = await exports.capsule({
375
+ encapsulate: sourceSpine.encapsulate,
376
+ CapsulePropertyTypes,
377
+ makeImportStack
378
+ })
379
+ const capsuleDuration = Date.now() - capsuleStart
380
+ timing?.recordMajor(`importCapsule: exports.capsule() took ${capsuleDuration}ms for ${shortPath}`)
381
+
382
+ timing?.record(`importCapsule: Returning result for ${shortPath}`)
383
+
384
+ return capsule
385
+ })
386
+ return result
387
+ }
388
+ })(),
389
+ encapsulateOpts: {
390
+ CapsulePropertyTypes
391
+ }
392
+ }
393
+
394
+ timing?.recordMajor('SPINE CONTRACTS: INITIALIZATION')
395
+
396
+ for (const spineContractUri in spineContracts) {
397
+ spineContractInstances.encapsulation[spineContractUri] = spineContracts[spineContractUri]({
398
+ ...commonSpineContractOpts,
399
+ freezeCapsule: async ({ spineContractUri, capsule }: { spineContractUri: string, capsule: any }): Promise<any> => {
400
+
401
+ if (!projector) {
402
+ throw new Error('capsuleModuleProjectionRoot must be provided to enable freezing')
403
+ }
404
+
405
+ let snapshotValues = {}
406
+
407
+ // Create a new set per freezeCapsule call to track circular dependencies within this projection tree
408
+ const projectingCapsules = new Set<string>()
409
+
410
+ const projected = await projector.projectCapsule({
411
+ capsule,
412
+ capsules,
413
+ snapshotValues,
414
+ spineContractUri,
415
+ projectingCapsules
416
+ })
417
+
418
+ return snapshotValues
419
+ }
420
+ })
421
+ spineContractInstances.runtime[spineContractUri] = spineContracts[spineContractUri]({
422
+ ...commonSpineContractOpts,
423
+ enableCallerStackInference,
424
+ onMembraneEvent,
425
+ })
426
+ }
427
+
428
+ timing?.recordMajor('CAPSULE MODULE PROJECTOR: INITIALIZATION')
429
+
430
+ const projector = capsuleModuleProjectionRoot ? CapsuleModuleProjector({
431
+ spineStore: {
432
+ writeFile: async (filepath: string, content: string) => {
433
+ filepath = join(spineFilesystemRoot, filepath)
434
+ await mkdir(dirname(filepath), { recursive: true })
435
+ await writeFile(filepath, content, 'utf-8')
436
+ },
437
+ getStats: async (filepath: string) => {
438
+ filepath = join(spineFilesystemRoot, filepath)
439
+ try {
440
+ const stats = await stat(filepath)
441
+ return { mtime: stats.mtime }
442
+ } catch (error) {
443
+ return null
444
+ }
445
+ },
446
+ },
447
+ projectionStore: {
448
+ writeFile: async (filepath: string, content: string) => {
449
+ filepath = join(capsuleModuleProjectionRoot, filepath)
450
+ await mkdir(dirname(filepath), { recursive: true })
451
+ await writeFile(filepath, content, 'utf-8')
452
+ },
453
+ getStats: async (filepath: string) => {
454
+ filepath = join(capsuleModuleProjectionRoot, filepath)
455
+ try {
456
+ const stats = await stat(filepath)
457
+ return { mtime: stats.mtime }
458
+ } catch (error) {
459
+ return null
460
+ }
461
+ },
462
+ },
463
+ projectionCacheStore: {
464
+ writeFile: async (filepath: string, content: string) => {
465
+ filepath = join(spineFilesystemRoot, '.~o/encapsulate.dev/projection-cache', filepath)
466
+ await mkdir(dirname(filepath), { recursive: true })
467
+ await writeFile(filepath, content, 'utf-8')
468
+ },
469
+ readFile: async (filepath: string) => {
470
+ filepath = join(spineFilesystemRoot, '.~o/encapsulate.dev/projection-cache', filepath)
471
+ return readFile(filepath, 'utf-8')
472
+ },
473
+ getStats: async (filepath: string) => {
474
+ filepath = join(spineFilesystemRoot, '.~o/encapsulate.dev/projection-cache', filepath)
475
+ try {
476
+ const stats = await stat(filepath)
477
+ return { mtime: stats.mtime }
478
+ } catch (error) {
479
+ return null
480
+ }
481
+ },
482
+ },
483
+ spineFilesystemRoot,
484
+ capsuleModuleProjectionPackage,
485
+ timing
486
+ }) : undefined
487
+
488
+ timing?.recordMajor('SPINE: INITIALIZATION')
489
+
490
+ let { encapsulate, freeze, capsules } = await Spine({
491
+ spineFilesystemRoot,
492
+ timing,
493
+ staticAnalyzer: staticAnalysisEnabled ? StaticAnalyzer({
494
+ timing,
495
+ cacheStore: {
496
+ writeFile: async (filepath: string, content: string) => {
497
+ filepath = join(spineFilesystemRoot, '.~o/encapsulate.dev/static-analysis', filepath)
498
+ await mkdir(dirname(filepath), { recursive: true })
499
+ await writeFile(filepath, content, 'utf-8')
500
+ },
501
+ readFile: async (filepath: string) => {
502
+ filepath = join(spineFilesystemRoot, '.~o/encapsulate.dev/static-analysis', filepath)
503
+ return readFile(filepath, 'utf-8')
504
+ },
505
+ getStats: async (filepath: string) => {
506
+ filepath = join(spineFilesystemRoot, '.~o/encapsulate.dev/static-analysis', filepath)
507
+ try {
508
+ const stats = await stat(filepath)
509
+ return { mtime: stats.mtime }
510
+ } catch (error) {
511
+ // File doesn't exist
512
+ return null
513
+ }
514
+ },
515
+ },
516
+ spineStore: {
517
+ getStats: async (filepath: string) => {
518
+ filepath = join(spineFilesystemRoot, filepath)
519
+ try {
520
+ const stats = await stat(filepath)
521
+ return { mtime: stats.mtime }
522
+ } catch (error) {
523
+ // File doesn't exist
524
+ return null
525
+ }
526
+ },
527
+ },
528
+ }) : undefined,
529
+ spineContracts: spineContractInstances.encapsulation
530
+ })
531
+ sourceSpine.encapsulate = encapsulate
532
+
533
+ timing?.recordMajor('SPINE RUNTIME: INITIALIZATION')
534
+
535
+ let { run } = await SpineRuntime({
536
+ spineFilesystemRoot,
537
+ spineContracts: spineContractInstances.runtime,
538
+ capsules
539
+ })
540
+
541
+ timing?.recordMajor('CAPSULE SPINE FACTORY: READY')
542
+
543
+ const loadCapsule = async ({ capsuleSnapshot }: { capsuleSourceLineRef: string, capsuleSnapshot: any }) => {
544
+
545
+ if (!capsuleModuleProjectionRoot) {
546
+ throw new Error('capsuleModuleProjectionRoot must be provided to enable dynamic loading of capsules')
547
+ }
548
+
549
+ let filepath = capsuleSnapshot.spineContracts?.['#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0']?.['#@stream44.studio/encapsulate/structs/Capsule']?.projectedCapsuleFilepath
550
+
551
+ if (!filepath) throw new Error(`Cannot load capsule. No 'filepath' found at 'spineContracts["#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0"]["#@stream44.studio/encapsulate/structs/Capsule"].projectedCapsuleFilepath'!`)
552
+
553
+ const { capsule } = await import(join(capsuleModuleProjectionRoot, filepath))
554
+
555
+ return capsule
556
+ }
557
+
558
+ return {
559
+ commonSpineContractOpts,
560
+ CapsulePropertyTypes,
561
+ makeImportStack,
562
+ encapsulate,
563
+ run,
564
+ freeze,
565
+ loadCapsule,
566
+ spineContractInstances, // Expose for testing
567
+ hoistSnapshot: async ({ snapshot }: { snapshot: any }) => {
568
+
569
+ timing?.recordMajor('HOIST SNAPSHOT: START')
570
+
571
+ const result = await SpineRuntime({
572
+ snapshot,
573
+ spineContracts: spineContractInstances.runtime,
574
+ loadCapsule
575
+ })
576
+
577
+ timing?.recordMajor('HOIST SNAPSHOT: COMPLETE')
578
+
579
+ return result
580
+ }
581
+ }
582
+ }
@@ -0,0 +1,26 @@
1
+
2
+ import chalk from 'chalk';
3
+
4
+ export function TimingObserver({ startTime }: { startTime: number }) {
5
+ let lastTime = startTime
6
+
7
+ return {
8
+ chalk,
9
+ record: (step: string) => {
10
+ const now = Date.now()
11
+ const diff = now - lastTime
12
+ lastTime = now
13
+
14
+ const line = `[+${diff}ms] ${step}`
15
+ console.log(diff > 10 ? chalk.red(line) : line)
16
+ },
17
+ recordMajor: (step: string) => {
18
+ const now = Date.now()
19
+ const diff = now - lastTime
20
+ lastTime = now
21
+
22
+ const line = `[+${diff}ms] ${step}`
23
+ console.log(diff > 10 ? chalk.red(line) : chalk.cyan(line))
24
+ }
25
+ }
26
+ }