@stream44.studio/encapsulate 0.2.0-rc.1

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,662 @@
1
+
2
+ type TSpineOptions = {
3
+ spineFilesystemRoot?: string,
4
+ spineContracts: Record<string, any>,
5
+ staticAnalyzer?: any,
6
+ timing?: { record: (step: string) => void, chalk?: any }
7
+ }
8
+
9
+ type TSpineRunOptions = {
10
+ overrides?: Record<string, any>,
11
+ options?: Record<string, any>
12
+ }
13
+
14
+ type TSpineRootsInvocationHandler = (context: { apis: Record<string, any>, capsules?: Record<string, any> }) => Promise<any>
15
+
16
+ type TSpine = {
17
+ freeze: () => Promise<TSpineSnapshot>,
18
+ encapsulate: (definition: TCapsuleDefinition, options: TCapsuleOptions) => Promise<TCapsule>,
19
+ capsules: Record<string, any>
20
+ }
21
+
22
+ type TSpineSnapshot = {
23
+ capsules: Record<string, Record<string, any>>
24
+ }
25
+
26
+ type TSpineRuntime = {
27
+ run: (options: TSpineRunOptions, handler: TSpineRootsInvocationHandler) => Promise<any>
28
+ }
29
+
30
+ type TSpineRuntimeOptions = {
31
+ spineFilesystemRoot?: string,
32
+ spineContracts?: Record<string, any>,
33
+ snapshot?: TSpineSnapshot,
34
+ capsules?: Record<string, any>,
35
+ loadCapsule?: (options: { capsuleSourceLineRef: string, capsuleSnapshot: any, capsuleName?: string }) => Promise<any>
36
+ }
37
+
38
+ type TCapsuleSnapshot = {
39
+ cst: any,
40
+ spineContracts: Record<string, any>
41
+ }
42
+
43
+ type TCapsuleMakeInstanceOptions = {
44
+ overrides?: Record<string, any>,
45
+ options?: Record<string, any>,
46
+ runtimeSpineContracts?: Record<string, any>
47
+ }
48
+
49
+ type TCapsule = {
50
+ capsuleSourceLineRef: string,
51
+ definition: TCapsuleDefinition,
52
+ encapsulateOptions: TEncapsulateOptions,
53
+ cst?: any,
54
+ crt?: any,
55
+ makeInstance: (options?: TCapsuleMakeInstanceOptions) => any,
56
+ toCapsuleReference: () => { capsuleSourceLineRef: string, capsuleSourceNameRefHash: any }
57
+ }
58
+
59
+ // Spine contract URI -> Property contract name -> Property definitions
60
+ // Property contracts can be empty {} (for struct markers) or contain property definitions
61
+ type TCapsuleDefinition = Record<string, Record<string, {} | Record<string, { type: keyof typeof CapsulePropertyTypes, [key: string]: any }>>>
62
+
63
+ type TCapsuleOptions = {
64
+ importMeta?: {
65
+ url: string
66
+ },
67
+ importStack?: string,
68
+ importStackLine?: number,
69
+ moduleFilepath?: string,
70
+ capsuleName?: string,
71
+ ambientReferences?: Record<string, any>,
72
+ cst?: any,
73
+ crt?: any
74
+ }
75
+
76
+ type TEncapsulateOptions = {
77
+ moduleFilepath: string,
78
+ importStackLine: number,
79
+ capsuleName?: string,
80
+ ambientReferences?: Record<string, any>,
81
+ capsuleSourceLineRef: string
82
+ }
83
+
84
+ type TSpineContext = {
85
+ spineOptions: TSpineOptions,
86
+ spineContracts: Record<string, any>,
87
+ capsules: Record<string, any>
88
+ }
89
+
90
+
91
+ export const CapsulePropertyTypes = {
92
+ Function: 'Function' as const,
93
+ GetterFunction: 'GetterFunction' as const,
94
+ String: 'String' as const,
95
+ Mapping: 'Mapping' as const,
96
+ Literal: 'Literal' as const,
97
+ }
98
+
99
+ // ##################################################
100
+ // # Spine
101
+ // ##################################################
102
+
103
+ export async function SpineRuntime(options: TSpineRuntimeOptions): Promise<TSpineRuntime> {
104
+
105
+ const spineContracts = options.spineContracts || {}
106
+ const capsules: Record<string, any> = {}
107
+
108
+ const loadedCapsules: Record<string, any> = options.capsules || {}
109
+
110
+ const spine = {
111
+ run: async function (
112
+ runOptions: TSpineRunOptions,
113
+ handler: TSpineRootsInvocationHandler
114
+ ): Promise<any> {
115
+
116
+ const capsules: Record<string, any> = {}
117
+
118
+ const hydratedSnapshots: Record<string, any> = {}
119
+
120
+ // Ensure all capsules are hydrated.
121
+ await Promise.all(Object.entries(loadedCapsules).map(async ([capsuleSourceLineRef, capsule]) => {
122
+
123
+ const hydratedSnapshot = options.snapshot?.capsules?.[capsuleSourceLineRef]
124
+ if (!hydratedSnapshot) return
125
+
126
+ await Promise.all(Object.entries(hydratedSnapshot.spineContracts).map(async ([spineContractUri, capsuleContractSnapshot]) => {
127
+ hydratedSnapshot.spineContracts[spineContractUri] = spineContracts[spineContractUri].hydrate({
128
+ capsuleSnapshot: capsuleContractSnapshot
129
+ })
130
+ }))
131
+
132
+ hydratedSnapshots[capsuleSourceLineRef] = hydratedSnapshot
133
+ if (capsule.encapsulateOptions.capsuleName) {
134
+ hydratedSnapshots[capsule.encapsulateOptions.capsuleName] = hydratedSnapshot
135
+ }
136
+ }))
137
+
138
+ // Extract only the spine contract properties from hydrated snapshots
139
+ const hydratedOverrides: Record<string, any> = {}
140
+ for (const [capsuleRef, snapshot] of Object.entries(hydratedSnapshots)) {
141
+ if (snapshot.spineContracts) {
142
+ // Merge all spine contract properties into a single object for this capsule
143
+ const capsuleOverrides: Record<string, any> = {}
144
+ for (const [spineContractUri, spineContractData] of Object.entries(snapshot.spineContracts)) {
145
+ Object.assign(capsuleOverrides, spineContractData)
146
+ }
147
+ hydratedOverrides[capsuleRef] = capsuleOverrides
148
+ }
149
+ }
150
+
151
+ const overrides = merge(
152
+ hydratedOverrides,
153
+ runOptions.overrides || {}
154
+ )
155
+
156
+ // Helper function to create a proxy that dynamically unwraps .api layers
157
+ function createUnwrappingProxy(obj: any): any {
158
+ if (!obj || typeof obj !== 'object') return obj
159
+
160
+ // If this object has an .api property, create a proxy for it
161
+ if (obj.api && typeof obj.api === 'object') {
162
+ return new Proxy(obj.api, {
163
+ get: (target: any, prop: string | symbol) => {
164
+ if (typeof prop === 'symbol') return target[prop]
165
+
166
+ let value = target[prop]
167
+
168
+ // If the value is a Proxy (from Membrane), get the actual value through it
169
+ // The Proxy will return the correct value from its get trap
170
+ if (value && typeof value === 'object') {
171
+ // Check if this value has .api - if so, unwrap it
172
+ if (value.api && typeof value.api === 'object') {
173
+ return createUnwrappingProxy(value)
174
+ }
175
+ }
176
+
177
+ return value
178
+ }
179
+ })
180
+ }
181
+
182
+ return obj
183
+ }
184
+
185
+ const apis: Record<string, any> = {}
186
+
187
+ // Group keys by capsule object to avoid duplicate processing
188
+ const capsuleToKeys = new Map<any, string[]>()
189
+ for (const [key, capsule] of Object.entries(loadedCapsules)) {
190
+ if (!capsuleToKeys.has(capsule)) {
191
+ capsuleToKeys.set(capsule, [])
192
+ }
193
+ capsuleToKeys.get(capsule)!.push(key)
194
+ }
195
+
196
+ // Instantiate each unique capsule once
197
+ for (const [capsule, keys] of capsuleToKeys) {
198
+ const instance = await capsule.makeInstance({
199
+ overrides,
200
+ options: runOptions.options?.[keys[0]],
201
+ runtimeSpineContracts: spineContracts
202
+ })
203
+
204
+ // Register instance under all keys that reference this capsule
205
+ for (const key of keys) {
206
+ capsules[key] = {
207
+ capsule,
208
+ instance,
209
+ makeInstance: undefined
210
+ }
211
+
212
+ // Create proxy that dynamically unwraps .api layers
213
+ apis[key] = createUnwrappingProxy(instance)
214
+ }
215
+ }
216
+
217
+ const result = await handler({ apis, capsules })
218
+
219
+ return result
220
+ },
221
+
222
+ encapsulate: async function (definition: TCapsuleDefinition, encapsulateOptions: TCapsuleOptions): Promise<TCapsule> {
223
+
224
+ return encapsulate(definition, encapsulateOptions, {
225
+ spineOptions: {
226
+ spineFilesystemRoot: options.spineFilesystemRoot,
227
+ spineContracts,
228
+ staticAnalyzer: (options as any).staticAnalyzer
229
+ },
230
+ spineContracts,
231
+ capsules
232
+ })
233
+ }
234
+ }
235
+
236
+ if (options.snapshot) {
237
+
238
+ await Promise.all(Object.entries(options.snapshot.capsules).map(async ([capsuleSourceLineRef, capsuleSnapshot]) => {
239
+
240
+ if (typeof loadedCapsules[capsuleSourceLineRef] !== 'undefined') return
241
+
242
+ // Extract capsuleName from snapshot if available
243
+ const capsuleName = capsuleSnapshot?.spineContracts?.['#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0']?.['#@stream44.studio/encapsulate/structs/Capsule.v0']?.capsuleName
244
+
245
+ const capsule = await options.loadCapsule!({
246
+ capsuleSourceLineRef,
247
+ capsuleSnapshot,
248
+ capsuleName
249
+ })
250
+
251
+ loadedCapsules[capsuleSourceLineRef] = await capsule({
252
+ encapsulate: spine.encapsulate,
253
+ CapsulePropertyTypes,
254
+ makeImportStack,
255
+ loadCapsule: options.loadCapsule
256
+ })
257
+ }))
258
+ }
259
+
260
+ return spine
261
+ }
262
+
263
+ export async function Spine(options: TSpineOptions): Promise<TSpine> {
264
+
265
+ const spineOptions = options
266
+
267
+ options.timing?.record('Spine: Initialized')
268
+
269
+ if (typeof spineOptions.spineFilesystemRoot === 'undefined') throw new Error(`'spineFilesystemRoot' not defined!`)
270
+ if (typeof spineOptions.spineContracts === 'undefined') throw new Error(`'spineContracts' not defined!`)
271
+
272
+ const spineContracts = spineOptions.spineContracts
273
+ const capsules: Record<string, any> = {}
274
+
275
+ options.timing?.record('Spine: Ready to encapsulate')
276
+
277
+ return {
278
+ capsules,
279
+ freeze: async function (): Promise<TSpineSnapshot> {
280
+
281
+ options.timing?.record('Spine: Starting freeze')
282
+
283
+ const snapshot: TSpineSnapshot = {
284
+ capsules: {}
285
+ }
286
+
287
+ options.timing?.record(`Spine: Freezing ${Object.keys(capsules).length} capsules`)
288
+
289
+ await Promise.all(Object.entries(capsules).map(async ([capsuleSourceLineRef, capsule]) => {
290
+
291
+ if (!capsule.cst.source.capsuleName) throw new Error(`'capsuleName' must be set for encapsulate options to enable freezing.`)
292
+
293
+ snapshot.capsules[capsuleSourceLineRef] = {
294
+ cst: capsule.cst,
295
+ spineContracts: {}
296
+ }
297
+
298
+ const { spineContractCapsuleInstances } = await capsule.makeInstance()
299
+
300
+ await Promise.all(Object.entries(spineContractCapsuleInstances).map(async ([spineContractUri, spineContractCapsuleInstance]) => {
301
+
302
+ snapshot.capsules[capsuleSourceLineRef] = merge(
303
+ snapshot.capsules[capsuleSourceLineRef],
304
+ await (spineContractCapsuleInstance as any).freeze({
305
+ spineContractUri,
306
+ capsule
307
+ })
308
+ )
309
+ }))
310
+ }))
311
+
312
+ options.timing?.record('Spine: Freeze complete')
313
+
314
+ // console.log('snapshot:', JSON.stringify(snapshot, null, 4))
315
+ return snapshot
316
+ },
317
+
318
+ encapsulate: async function (definition: TCapsuleDefinition, options: TCapsuleOptions): Promise<TCapsule> {
319
+
320
+ return encapsulate(definition, options, {
321
+ spineOptions,
322
+ spineContracts,
323
+ capsules
324
+ })
325
+ }
326
+ }
327
+ }
328
+
329
+
330
+
331
+
332
+ // ##################################################
333
+ // # Encapsulate
334
+ // ##################################################
335
+
336
+ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOptions, spine: TSpineContext): Promise<TCapsule> {
337
+
338
+ if (!options.importMeta && !options.moduleFilepath) throw new Error(`'options.importMeta' nor 'options.moduleFilepath' not specified!`)
339
+ if (!options.importStack && !options.importStackLine) throw new Error(`'options.importStack' nor 'options.importStackLine' specified!`)
340
+
341
+ const moduleFilepath = options.moduleFilepath || relative(spine.spineOptions.spineFilesystemRoot || '', options.importMeta!.url.replace(/^file:\/\//, ''))
342
+ const importStackLine = options.importStackLine || formatImportStackFrame(options.importStack!)
343
+
344
+ if (typeof importStackLine !== 'number') throw new Error(`Could not determine importStackLine from options`)
345
+
346
+ const encapsulateOptions: TEncapsulateOptions = {
347
+ moduleFilepath,
348
+ importStackLine,
349
+ capsuleName: options.capsuleName,
350
+ ambientReferences: options.ambientReferences,
351
+ capsuleSourceLineRef: `${moduleFilepath}:${importStackLine}`
352
+ }
353
+
354
+ spine.spineOptions.timing?.record(`Encapsulate: Start for ${moduleFilepath}`)
355
+
356
+ const { csts, crts } = await spine.spineOptions.staticAnalyzer?.parseModule({
357
+ spineOptions: spine.spineOptions,
358
+ encapsulateOptions
359
+ }) || {
360
+ csts: options.cst ? { [encapsulateOptions.capsuleSourceLineRef]: options.cst } : undefined,
361
+ crts: options.crt ? { [encapsulateOptions.capsuleSourceLineRef]: options.crt } : undefined
362
+ }
363
+
364
+ const defaultInstance: Record<string, any> = {}
365
+
366
+ // Cache for instances to prevent duplicate makeInstance calls
367
+ const instanceCache = new Map<string, Promise<any>>()
368
+
369
+ const capsule: TCapsule = {
370
+ toCapsuleReference: () => {
371
+ return {
372
+ capsuleSourceLineRef: encapsulateOptions.capsuleSourceLineRef,
373
+ capsuleSourceNameRefHash: capsule.cst.capsuleSourceNameRefHash,
374
+ }
375
+ },
376
+ capsuleSourceLineRef: encapsulateOptions.capsuleSourceLineRef,
377
+ definition,
378
+ encapsulateOptions,
379
+ cst: csts?.[encapsulateOptions.capsuleSourceLineRef],
380
+ crt: crts?.[encapsulateOptions.capsuleSourceLineRef],
381
+ makeInstance: async ({ overrides = {}, options = {}, runtimeSpineContracts }: TCapsuleMakeInstanceOptions = {}) => {
382
+
383
+ // Create cache key based on parameters
384
+ const cacheKey = JSON.stringify({
385
+ overrides,
386
+ options,
387
+ hasRuntimeContracts: !!runtimeSpineContracts
388
+ })
389
+
390
+ // Check if we already have a pending or completed instance creation
391
+ if (instanceCache.has(cacheKey)) {
392
+ return instanceCache.get(cacheKey)!
393
+ }
394
+
395
+ // Create the instance promise and cache it immediately
396
+ const instancePromise = (async () => {
397
+ const encapsulatedApi: Record<string, any> = {}
398
+ const spineContractCapsuleInstances: Record<string, any> = {}
399
+
400
+ const capsuleInstance = {
401
+ api: encapsulatedApi,
402
+ spineContractCapsuleInstances
403
+ }
404
+
405
+ // Property contracts are keys starting with '#' that contain nested properties
406
+ // Structure: spineContractUri -> propertyContractUri -> propertyName -> propertyDef
407
+ const propertyContractDefinitions: Record<string, Record<string, Record<string, any>>> = {}
408
+ // Track which property contracts are defined for validation
409
+ const definedPropertyContracts = new Set<string>()
410
+
411
+ for (const [spineContractUri, propertyDefinitions] of Object.entries(definition)) {
412
+ // Validate that at least one property contract key exists
413
+ const hasPropertyContract = Object.keys(propertyDefinitions).some(key => key.startsWith('#'))
414
+ if (!hasPropertyContract) {
415
+ throw new Error(`Spine contract '${spineContractUri}' for capsule '${capsule.capsuleSourceLineRef}' must specify at least one property contract layer using a key starting with '#'. For example: '#': { ...properties }`)
416
+ }
417
+
418
+ propertyContractDefinitions[spineContractUri] = {}
419
+
420
+ for (const [propContractUri, propDef] of Object.entries(propertyDefinitions)) {
421
+ if (propContractUri.startsWith('#')) {
422
+
423
+ // This is a property contract - store its properties (merge if it already exists)
424
+ if (!propertyContractDefinitions[spineContractUri][propContractUri]) {
425
+ propertyContractDefinitions[spineContractUri][propContractUri] = {}
426
+ }
427
+ Object.assign(propertyContractDefinitions[spineContractUri][propContractUri], propDef as Record<string, any>)
428
+ definedPropertyContracts.add(propContractUri)
429
+
430
+ // If this is a non-default property contract, add a dynamic mapping to the '#' contract
431
+ if (propContractUri !== '#') {
432
+ // We have a property contract URI we need to resolve and load
433
+ // Add a dynamic property mapping for this contract to the '#' group
434
+ const contractKey = '#' + propContractUri.substring(1)
435
+
436
+ if (!propertyContractDefinitions[spineContractUri]['#']) {
437
+ propertyContractDefinitions[spineContractUri]['#'] = {}
438
+ }
439
+
440
+ propertyContractDefinitions[spineContractUri]['#'][contractKey] = {
441
+ type: CapsulePropertyTypes.Mapping,
442
+ value: propContractUri.substring(1),
443
+ propertyContractDelegate: propContractUri
444
+ }
445
+ }
446
+
447
+ } else {
448
+ throw new Error(`Property '${propContractUri}' in spine contract '${spineContractUri}' for capsule '${capsule.capsuleSourceLineRef}' must be nested under a property contract uri starting with '#'. For example: '#': { ${propContractUri}: {...} }`)
449
+ }
450
+ }
451
+ }
452
+
453
+ // Merge overrides and options by property contract
454
+ // Structure: propertyContractUri -> propertyName -> value
455
+ const mergedValuesByContract: Record<string, Record<string, any>> = {}
456
+
457
+ // Helper to validate and merge values from a source (overrides or options)
458
+ const mergeByContract = (source: any, sourceName: string) => {
459
+ if (!source) return
460
+
461
+ for (const [propertyContractUri, properties] of Object.entries(source)) {
462
+ if (!propertyContractUri.startsWith('#')) {
463
+ throw new Error(`${sourceName} for capsule '${capsule.capsuleSourceLineRef}' must use property contract keys starting with '#'. Found key: '${propertyContractUri}'`)
464
+ }
465
+
466
+ if (!definedPropertyContracts.has(propertyContractUri)) {
467
+ throw new Error(`${sourceName} for capsule '${capsule.capsuleSourceLineRef}' references property contract '${propertyContractUri}' which is not defined on the capsule`)
468
+ }
469
+
470
+ if (!mergedValuesByContract[propertyContractUri]) {
471
+ mergedValuesByContract[propertyContractUri] = {}
472
+ }
473
+
474
+ Object.assign(mergedValuesByContract[propertyContractUri], properties)
475
+ }
476
+ }
477
+
478
+ // Merge in order: overrides by lineRef, overrides by name, options
479
+ mergeByContract(overrides?.[encapsulateOptions.capsuleSourceLineRef], 'Overrides')
480
+ if (encapsulateOptions.capsuleName) {
481
+ mergeByContract(overrides?.[encapsulateOptions.capsuleName], 'Overrides')
482
+ }
483
+ mergeByContract(options, 'Options')
484
+
485
+ // Create a single shared self for all spine contracts by flattening merged values
486
+ const self = merge(
487
+ {},
488
+ defaultInstance,
489
+ ...Object.values(mergedValuesByContract)
490
+ )
491
+
492
+ // Use runtime spine contracts if provided, otherwise fall back to encapsulation spine contracts
493
+ const activeSpineContracts = runtimeSpineContracts || spine.spineContracts
494
+
495
+ for (const [spineContractUri, propertyContracts] of Object.entries(propertyContractDefinitions)) {
496
+
497
+ const spineContract = activeSpineContracts[spineContractUri] as any
498
+
499
+ if (!spineContract) throw new Error(`Contract uri '${spineContractUri}' used by capsule not available in Spine!`)
500
+
501
+ const spineContractCapsuleInstance = spineContract.makeContractCapsuleInstance({
502
+ spineContractUri,
503
+ encapsulateOptions,
504
+ capsuleInstance,
505
+ self,
506
+ capsule,
507
+ encapsulatedApi,
508
+ runtimeSpineContracts
509
+ })
510
+
511
+ spineContractCapsuleInstances[spineContractUri] = spineContractCapsuleInstance
512
+
513
+ // Iterate through each property contract within this spine contract
514
+ for (const [propertyContractUri, properties] of Object.entries(propertyContracts)) {
515
+ // Skip non-'#' property contracts as they're already accessible via dynamic mappings in '#'
516
+ if (propertyContractUri !== '#') {
517
+ continue
518
+ }
519
+ for (const [propertyName, propertyDefinition] of Object.entries(properties)) {
520
+
521
+ if (!propertyDefinition.type || !(propertyDefinition.type in CapsulePropertyTypes)) throw new Error(`Type '${propertyDefinition.type}' for property '${propertyName}' on spineContract '${spineContractUri}' not set or supported!`)
522
+
523
+ await spineContractCapsuleInstance.mapProperty({
524
+ overrides,
525
+ options,
526
+ property: {
527
+ name: propertyName,
528
+ definition: propertyDefinition,
529
+ propertyContractUri
530
+ }
531
+ })
532
+ }
533
+ }
534
+ }
535
+
536
+ return capsuleInstance
537
+ })()
538
+
539
+ // Cache the promise
540
+ instanceCache.set(cacheKey, instancePromise)
541
+
542
+ return instancePromise
543
+ }
544
+ }
545
+
546
+ spine.capsules[encapsulateOptions.capsuleSourceLineRef] = capsule
547
+ if (encapsulateOptions.capsuleName) {
548
+ spine.capsules[encapsulateOptions.capsuleName] = capsule
549
+ }
550
+
551
+ spine.spineOptions.timing?.record(`Encapsulate: Complete for ${moduleFilepath}`)
552
+
553
+ return capsule
554
+ }
555
+
556
+
557
+
558
+ function formatImportStackFrame(importStack: string): number | undefined {
559
+ const stackLines = importStack.split('\n')
560
+
561
+ const hasMakeImportStackMarker = importStack.includes('encapsulate:makeImportStack')
562
+ const targetMatchCount = hasMakeImportStackMarker ? 2 : 1
563
+
564
+ let matchCount = 0
565
+ for (let i = 0; i < stackLines.length; i++) {
566
+ const line = stackLines[i]
567
+
568
+ if (line.includes('encapsulate:makeImportStack')) {
569
+ continue
570
+ }
571
+
572
+ const match = line.match(/\(([^)]+):([0-9]+):[0-9]+\)|at ([^(]+):([0-9]+):[0-9]+/)
573
+ if (match) {
574
+ matchCount++
575
+ if (matchCount === targetMatchCount) {
576
+ const lineNumber = parseInt(match[2] || match[4])
577
+ return lineNumber
578
+ }
579
+ }
580
+ }
581
+
582
+ return undefined
583
+ }
584
+
585
+ // ##################################################
586
+ // # Utilities
587
+ // ##################################################
588
+
589
+ export function makeImportStack() {
590
+ return new Error('encapsulate:makeImportStack').stack
591
+ }
592
+
593
+ export function join(...paths: string[]): string {
594
+ if (paths.length === 0) return '.'
595
+
596
+ let joined = paths.join('/')
597
+
598
+ const isAbsolute = joined.startsWith('/')
599
+ const parts: string[] = []
600
+
601
+ for (const part of joined.split('/')) {
602
+ if (part === '' || part === '.') continue
603
+ if (part === '..') {
604
+ if (parts.length > 0 && parts[parts.length - 1] !== '..') {
605
+ parts.pop()
606
+ } else if (!isAbsolute) {
607
+ parts.push('..')
608
+ }
609
+ } else {
610
+ parts.push(part)
611
+ }
612
+ }
613
+
614
+ let result = parts.join('/')
615
+ if (isAbsolute) result = '/' + result
616
+
617
+ return result || (isAbsolute ? '/' : '.')
618
+ }
619
+
620
+ function relative(from: string, to: string): string {
621
+ const fromParts = from.split('/').filter(p => p && p !== '.')
622
+ const toParts = to.split('/').filter(p => p && p !== '.')
623
+
624
+ let commonLength = 0
625
+ const minLength = Math.min(fromParts.length, toParts.length)
626
+
627
+ for (let i = 0; i < minLength; i++) {
628
+ if (fromParts[i] === toParts[i]) {
629
+ commonLength++
630
+ } else {
631
+ break
632
+ }
633
+ }
634
+
635
+ const upCount = fromParts.length - commonLength
636
+ const remainingTo = toParts.slice(commonLength)
637
+
638
+ const result = [...Array(upCount).fill('..'), ...remainingTo].join('/')
639
+ return result || '.'
640
+ }
641
+
642
+ function isObject(item: any): boolean {
643
+ return item && typeof item === 'object' && !Array.isArray(item)
644
+ }
645
+
646
+ export function merge<T = any>(target: T, ...sources: any[]): T {
647
+ if (!sources.length) return target
648
+ const source = sources.shift()
649
+
650
+ if (isObject(target) && isObject(source)) {
651
+ for (const key in source) {
652
+ if (isObject(source[key])) {
653
+ if (!target[key as keyof T]) Object.assign(target, { [key]: {} })
654
+ merge(target[key as keyof T], source[key])
655
+ } else {
656
+ Object.assign(target, { [key]: source[key] })
657
+ }
658
+ }
659
+ }
660
+
661
+ return merge(target, ...sources)
662
+ }