@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,881 @@
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
+ sharedSelf?: Record<string, any>
48
+ }
49
+
50
+ type TCapsule = {
51
+ capsuleSourceLineRef: string,
52
+ definition: TCapsuleDefinition,
53
+ encapsulateOptions: TEncapsulateOptions,
54
+ cst?: any,
55
+ crt?: any,
56
+ makeInstance: (options?: TCapsuleMakeInstanceOptions) => any,
57
+ toCapsuleReference: () => { capsuleSourceLineRef: string, capsuleSourceNameRefHash: any }
58
+ }
59
+
60
+ // Spine contract URI -> Property contract name -> Property definitions
61
+ // Property contracts can be empty {} (for struct markers) or contain property definitions
62
+ type TCapsuleDefinition = Record<string, Record<string, {} | Record<string, { type: keyof typeof CapsulePropertyTypes, [key: string]: any }>>>
63
+
64
+ type TCapsuleOptions = {
65
+ importMeta?: {
66
+ url: string
67
+ },
68
+ importStack?: string,
69
+ importStackLine?: number,
70
+ moduleFilepath?: string,
71
+ capsuleName?: string,
72
+ ambientReferences?: Record<string, any>,
73
+ extendsCapsule?: TCapsule | string,
74
+ cst?: any,
75
+ crt?: any
76
+ }
77
+
78
+ type TEncapsulateOptions = {
79
+ moduleFilepath: string,
80
+ importStackLine: number,
81
+ capsuleName?: string,
82
+ ambientReferences?: Record<string, any>,
83
+ extendsCapsule?: TCapsule | string,
84
+ capsuleSourceLineRef: string
85
+ }
86
+
87
+ type TSpineContext = {
88
+ spineOptions: TSpineOptions,
89
+ spineContracts: Record<string, any>,
90
+ capsules: Record<string, any>
91
+ }
92
+
93
+
94
+ export const CapsulePropertyTypes = {
95
+ Function: 'Function' as const,
96
+ GetterFunction: 'GetterFunction' as const,
97
+ String: 'String' as const,
98
+ Mapping: 'Mapping' as const,
99
+ Literal: 'Literal' as const,
100
+ StructInit: 'StructInit' as const,
101
+ }
102
+
103
+ // ##################################################
104
+ // # Spine
105
+ // ##################################################
106
+
107
+ export async function SpineRuntime(options: TSpineRuntimeOptions): Promise<TSpineRuntime> {
108
+
109
+ const spineContracts = options.spineContracts || {}
110
+ const capsules: Record<string, any> = {}
111
+
112
+ const loadedCapsules: Record<string, any> = options.capsules || {}
113
+
114
+ const spine = {
115
+ run: async function (
116
+ runOptions: TSpineRunOptions,
117
+ handler: TSpineRootsInvocationHandler
118
+ ): Promise<any> {
119
+
120
+ const capsules: Record<string, any> = {}
121
+
122
+ const hydratedSnapshots: Record<string, any> = {}
123
+
124
+ // Ensure all capsules are hydrated.
125
+ await Promise.all(Object.entries(loadedCapsules).map(async ([capsuleSourceLineRef, capsule]) => {
126
+
127
+ const hydratedSnapshot = options.snapshot?.capsules?.[capsuleSourceLineRef]
128
+ if (!hydratedSnapshot) return
129
+
130
+ await Promise.all(Object.entries(hydratedSnapshot.spineContracts).map(async ([spineContractUri, capsuleContractSnapshot]) => {
131
+ hydratedSnapshot.spineContracts[spineContractUri] = spineContracts[spineContractUri].hydrate({
132
+ capsuleSnapshot: capsuleContractSnapshot
133
+ })
134
+ }))
135
+
136
+ hydratedSnapshots[capsuleSourceLineRef] = hydratedSnapshot
137
+ if (capsule.encapsulateOptions.capsuleName) {
138
+ hydratedSnapshots[capsule.encapsulateOptions.capsuleName] = hydratedSnapshot
139
+ }
140
+ }))
141
+
142
+ // Extract only the spine contract properties from hydrated snapshots
143
+ const hydratedOverrides: Record<string, any> = {}
144
+ for (const [capsuleRef, snapshot] of Object.entries(hydratedSnapshots)) {
145
+ if (snapshot.spineContracts) {
146
+ // Merge all spine contract properties into a single object for this capsule
147
+ const capsuleOverrides: Record<string, any> = {}
148
+ for (const [spineContractUri, spineContractData] of Object.entries(snapshot.spineContracts)) {
149
+ Object.assign(capsuleOverrides, spineContractData)
150
+ }
151
+ hydratedOverrides[capsuleRef] = capsuleOverrides
152
+ }
153
+ }
154
+
155
+ const overrides = merge(
156
+ hydratedOverrides,
157
+ runOptions.overrides || {}
158
+ )
159
+
160
+ // Helper function to create a proxy that dynamically unwraps .api layers
161
+ function createUnwrappingProxy(obj: any): any {
162
+ if (!obj || typeof obj !== 'object') return obj
163
+
164
+ // If this object has an .api property, create a proxy for it
165
+ if (obj.api && typeof obj.api === 'object') {
166
+ return new Proxy(obj.api, {
167
+ get: (target: any, prop: string | symbol) => {
168
+ if (typeof prop === 'symbol') return target[prop]
169
+
170
+ let value = target[prop]
171
+
172
+ // If the value is a raw capsule instance (has spineContractCapsuleInstances
173
+ // but is NOT a Proxy that handles API access), unwrap it
174
+ // Static.v0 sets apiTarget[property.name] = mappedInstance (raw)
175
+ // Membrane.v0 sets apiTarget[property.name] = new Proxy(...) which handles API access
176
+ if (value && typeof value === 'object' && value.spineContractCapsuleInstances) {
177
+ // Check if this is a raw capsule instance by seeing if it has .api
178
+ // and the .api doesn't have the same spineContractCapsuleInstances
179
+ // (Membrane Proxy would return .api properties, not the raw structure)
180
+ if (value.api && typeof value.api === 'object' && !value.api.spineContractCapsuleInstances) {
181
+ return createUnwrappingProxy(value)
182
+ }
183
+ }
184
+
185
+ return value
186
+ }
187
+ })
188
+ }
189
+
190
+ return obj
191
+ }
192
+
193
+ const apis: Record<string, any> = {}
194
+
195
+ // Group keys by capsule object to avoid duplicate processing
196
+ const capsuleToKeys = new Map<any, string[]>()
197
+ for (const [key, capsule] of Object.entries(loadedCapsules)) {
198
+ if (!capsuleToKeys.has(capsule)) {
199
+ capsuleToKeys.set(capsule, [])
200
+ }
201
+ capsuleToKeys.get(capsule)!.push(key)
202
+ }
203
+
204
+ // Instantiate each unique capsule once
205
+ for (const [capsule, keys] of capsuleToKeys) {
206
+ const instance = await capsule.makeInstance({
207
+ overrides,
208
+ options: runOptions.options?.[keys[0]],
209
+ runtimeSpineContracts: spineContracts
210
+ })
211
+
212
+ // Register instance under all keys that reference this capsule
213
+ for (const key of keys) {
214
+ capsules[key] = {
215
+ capsule,
216
+ instance,
217
+ makeInstance: undefined
218
+ }
219
+
220
+ // Create proxy that dynamically unwraps .api layers
221
+ apis[key] = createUnwrappingProxy(instance)
222
+ }
223
+ }
224
+
225
+ // Run StructInit functions for the entire capsule tree (top-down)
226
+ // Top-down means: child capsule's StructInit runs before extended parent's StructInit
227
+ const structInitVisited = new Set<any>()
228
+ async function runStructInits(instance: any) {
229
+ if (!instance || structInitVisited.has(instance)) return
230
+ structInitVisited.add(instance)
231
+
232
+ // Run this capsule's own StructInit functions first (top-down)
233
+ if (instance.structInitFunctions?.length) {
234
+ for (const fn of instance.structInitFunctions) {
235
+ await fn()
236
+ }
237
+ // Sync self values back to encapsulatedApi for spine contracts that use
238
+ // direct assignment (e.g. Static contract) rather than getters
239
+ if (instance.spineContractCapsuleInstances) {
240
+ for (const sci of Object.values(instance.spineContractCapsuleInstances) as any[]) {
241
+ if (sci.self && sci.encapsulatedApi) {
242
+ for (const key of Object.keys(sci.encapsulatedApi)) {
243
+ if (key in sci.self && sci.encapsulatedApi[key] !== sci.self[key]) {
244
+ sci.encapsulatedApi[key] = sci.self[key]
245
+ }
246
+ }
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ // Recurse into extended capsule instance
253
+ if (instance.extendedCapsuleInstance) {
254
+ await runStructInits(instance.extendedCapsuleInstance)
255
+ }
256
+
257
+ // Recurse into mapped capsule instances
258
+ if (instance.mappedCapsuleInstances?.length) {
259
+ for (const mappedInstance of instance.mappedCapsuleInstances) {
260
+ await runStructInits(mappedInstance)
261
+ }
262
+ }
263
+ }
264
+
265
+ for (const [, entry] of Object.entries(capsules)) {
266
+ await runStructInits((entry as any).instance)
267
+ }
268
+
269
+ const result = await handler({ apis, capsules })
270
+
271
+ return result
272
+ },
273
+
274
+ encapsulate: async function (definition: TCapsuleDefinition, encapsulateOptions: TCapsuleOptions): Promise<TCapsule> {
275
+
276
+ return encapsulate(definition, encapsulateOptions, {
277
+ spineOptions: {
278
+ spineFilesystemRoot: options.spineFilesystemRoot,
279
+ spineContracts,
280
+ staticAnalyzer: (options as any).staticAnalyzer
281
+ },
282
+ spineContracts,
283
+ capsules
284
+ })
285
+ }
286
+ }
287
+
288
+ if (options.snapshot) {
289
+
290
+ // NOTE: We can probably generate an optimized initialization tree for use at runtime
291
+ // that parallel loads as much as possible.
292
+ for (const [capsuleSourceLineRef, capsuleSnapshot] of Object.entries(options.snapshot.capsules)) {
293
+
294
+ if (typeof loadedCapsules[capsuleSourceLineRef] !== 'undefined') continue
295
+
296
+ // Extract capsuleName from snapshot if available
297
+ const capsuleName = (capsuleSnapshot as any)?.spineContracts?.['#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0']?.['#@stream44.studio/encapsulate/structs/Capsule']?.capsuleName
298
+
299
+ const capsule = await options.loadCapsule!({
300
+ capsuleSourceLineRef,
301
+ capsuleSnapshot,
302
+ capsuleName
303
+ })
304
+
305
+ loadedCapsules[capsuleSourceLineRef] = await capsule({
306
+ encapsulate: spine.encapsulate,
307
+ CapsulePropertyTypes,
308
+ makeImportStack,
309
+ loadCapsule: options.loadCapsule
310
+ })
311
+ }
312
+ }
313
+
314
+ return spine
315
+ }
316
+
317
+ export async function Spine(options: TSpineOptions): Promise<TSpine> {
318
+
319
+ const spineOptions = options
320
+
321
+ options.timing?.record('Spine: Initialized')
322
+
323
+ if (typeof spineOptions.spineFilesystemRoot === 'undefined') throw new Error(`'spineFilesystemRoot' not defined!`)
324
+ if (typeof spineOptions.spineContracts === 'undefined') throw new Error(`'spineContracts' not defined!`)
325
+
326
+ const spineContracts = spineOptions.spineContracts
327
+ const capsules: Record<string, any> = {}
328
+
329
+ options.timing?.record('Spine: Ready to encapsulate')
330
+
331
+ return {
332
+ capsules,
333
+ freeze: async function (): Promise<TSpineSnapshot> {
334
+
335
+ options.timing?.record('Spine: Starting freeze')
336
+
337
+ const snapshot: TSpineSnapshot = {
338
+ capsules: {}
339
+ }
340
+
341
+ options.timing?.record(`Spine: Freezing ${Object.keys(capsules).length} capsules`)
342
+
343
+ await Promise.all(Object.entries(capsules).map(async ([capsuleSourceLineRef, capsule]) => {
344
+
345
+ if (!capsule.cst.source.capsuleName) throw new Error(`'capsuleName' must be set for encapsulate options to enable freezing.`)
346
+
347
+ snapshot.capsules[capsuleSourceLineRef] = {
348
+ cst: capsule.cst,
349
+ spineContracts: {}
350
+ }
351
+
352
+ const { spineContractCapsuleInstances } = await capsule.makeInstance()
353
+
354
+ await Promise.all(Object.entries(spineContractCapsuleInstances).map(async ([spineContractUri, spineContractCapsuleInstance]) => {
355
+
356
+ snapshot.capsules[capsuleSourceLineRef] = merge(
357
+ snapshot.capsules[capsuleSourceLineRef],
358
+ await (spineContractCapsuleInstance as any).freeze({
359
+ spineContractUri,
360
+ capsule
361
+ })
362
+ )
363
+ }))
364
+ }))
365
+
366
+ options.timing?.record('Spine: Freeze complete')
367
+
368
+ // console.log('snapshot:', JSON.stringify(snapshot, null, 4))
369
+ return snapshot
370
+ },
371
+
372
+ encapsulate: async function (definition: TCapsuleDefinition, options: TCapsuleOptions): Promise<TCapsule> {
373
+
374
+ return encapsulate(definition, options, {
375
+ spineOptions,
376
+ spineContracts,
377
+ capsules
378
+ })
379
+ }
380
+ }
381
+ }
382
+
383
+
384
+
385
+
386
+ // ##################################################
387
+ // # Encapsulate
388
+ // ##################################################
389
+
390
+ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOptions, spine: TSpineContext): Promise<TCapsule> {
391
+
392
+ if (!options.importMeta && !options.moduleFilepath) throw new Error(`'options.importMeta' nor 'options.moduleFilepath' not specified!`)
393
+ if (!options.importStack && !options.importStackLine) throw new Error(`'options.importStack' nor 'options.importStackLine' specified!`)
394
+
395
+ const moduleFilepath = options.moduleFilepath || relative(spine.spineOptions.spineFilesystemRoot || '', options.importMeta!.url.replace(/^file:\/\//, ''))
396
+ const importStackLine = options.importStackLine || formatImportStackFrame(options.importStack!)
397
+
398
+ if (typeof importStackLine !== 'number') throw new Error(`Could not determine importStackLine from options`)
399
+
400
+ const encapsulateOptions: TEncapsulateOptions = {
401
+ moduleFilepath,
402
+ importStackLine,
403
+ capsuleName: options.capsuleName,
404
+ ambientReferences: options.ambientReferences,
405
+ extendsCapsule: options.extendsCapsule,
406
+ capsuleSourceLineRef: `${moduleFilepath}:${importStackLine}`
407
+ }
408
+
409
+ spine.spineOptions.timing?.record(`Encapsulate: Start for ${moduleFilepath}`)
410
+
411
+ const { csts, crts } = await spine.spineOptions.staticAnalyzer?.parseModule({
412
+ spineOptions: spine.spineOptions,
413
+ encapsulateOptions
414
+ }) || {
415
+ csts: options.cst ? { [encapsulateOptions.capsuleSourceLineRef]: options.cst } : undefined,
416
+ crts: options.crt ? { [encapsulateOptions.capsuleSourceLineRef]: options.crt } : undefined
417
+ }
418
+
419
+ const defaultInstance: Record<string, any> = {}
420
+
421
+ // Cache for instances to prevent duplicate makeInstance calls
422
+ const instanceCache = new Map<string, Promise<any>>()
423
+
424
+ const capsule: TCapsule = {
425
+ toCapsuleReference: () => {
426
+ return {
427
+ capsuleSourceLineRef: encapsulateOptions.capsuleSourceLineRef,
428
+ capsuleSourceNameRefHash: capsule.cst.capsuleSourceNameRefHash,
429
+ }
430
+ },
431
+ capsuleSourceLineRef: encapsulateOptions.capsuleSourceLineRef,
432
+ definition,
433
+ encapsulateOptions,
434
+ cst: csts?.[encapsulateOptions.capsuleSourceLineRef],
435
+ crt: crts?.[encapsulateOptions.capsuleSourceLineRef],
436
+ makeInstance: async ({ overrides = {}, options = {}, runtimeSpineContracts, sharedSelf }: TCapsuleMakeInstanceOptions = {}) => {
437
+
438
+ // Create cache key based on parameters
439
+ // When sharedSelf is provided, we must NOT cache because each extending capsule
440
+ // needs its own instance with its own 'this' context (sharedSelf).
441
+ // This is critical for the pattern where multiple structs extend the same parent.
442
+ const cacheKey = sharedSelf ? null : JSON.stringify({
443
+ overrides,
444
+ options,
445
+ hasRuntimeContracts: !!runtimeSpineContracts
446
+ })
447
+
448
+ // Check if we already have a pending or completed instance creation
449
+ // Skip cache when sharedSelf is provided (cacheKey is null)
450
+ if (cacheKey && instanceCache.has(cacheKey)) {
451
+ return instanceCache.get(cacheKey)!
452
+ }
453
+
454
+ // Create the instance promise and cache it immediately (only if cacheKey is set)
455
+ const instancePromise = (async () => {
456
+ const encapsulatedApi: Record<string, any> = {}
457
+ const spineContractCapsuleInstances: Record<string, any> = {}
458
+
459
+ // Property contracts are keys starting with '#' that contain nested properties
460
+ // Structure: spineContractUri -> propertyContractUri -> propertyName -> propertyDef
461
+ const propertyContractDefinitions: Record<string, Record<string, Record<string, any>>> = {}
462
+ // Track which property contracts are defined for validation
463
+ const definedPropertyContracts = new Set<string>()
464
+
465
+ for (const [spineContractUri, propertyDefinitions] of Object.entries(definition)) {
466
+ // Validate that at least one property contract key exists
467
+ const hasPropertyContract = Object.keys(propertyDefinitions).some(key => key.startsWith('#'))
468
+ if (!hasPropertyContract) {
469
+ 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 }`)
470
+ }
471
+
472
+ propertyContractDefinitions[spineContractUri] = {}
473
+
474
+ for (const [propContractUri, propDef] of Object.entries(propertyDefinitions)) {
475
+ if (propContractUri.startsWith('#')) {
476
+
477
+ // This is a property contract - store its properties (merge if it already exists)
478
+ if (!propertyContractDefinitions[spineContractUri][propContractUri]) {
479
+ propertyContractDefinitions[spineContractUri][propContractUri] = {}
480
+ }
481
+ Object.assign(propertyContractDefinitions[spineContractUri][propContractUri], propDef as Record<string, any>)
482
+ definedPropertyContracts.add(propContractUri)
483
+
484
+ // If this is a non-default property contract, add a dynamic mapping to the '#' contract
485
+ if (propContractUri !== '#') {
486
+ // We have a property contract URI we need to resolve and load
487
+ // Add a dynamic property mapping for this contract to the '#' group
488
+ // Check if 'as' is defined to use as the property name alias
489
+ const propDefTyped = propDef as Record<string, any>
490
+ const aliasName = propDefTyped.as
491
+ const delegateOptions = propDefTyped.options
492
+ const contractKey = aliasName || ('#' + propContractUri.substring(1))
493
+
494
+ if (!propertyContractDefinitions[spineContractUri]['#']) {
495
+ propertyContractDefinitions[spineContractUri]['#'] = {}
496
+ }
497
+
498
+ propertyContractDefinitions[spineContractUri]['#'][contractKey] = {
499
+ type: CapsulePropertyTypes.Mapping,
500
+ value: propContractUri.substring(1),
501
+ propertyContractDelegate: propContractUri,
502
+ as: aliasName,
503
+ // Pass options from the property contract delegate to the mapped capsule
504
+ delegateOptions
505
+ }
506
+ }
507
+
508
+ } else {
509
+ 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}: {...} }`)
510
+ }
511
+ }
512
+ }
513
+
514
+ // Merge overrides and options by property contract
515
+ // Structure: propertyContractUri -> propertyName -> value
516
+ const mergedValuesByContract: Record<string, Record<string, any>> = {}
517
+
518
+ // Helper to validate and merge values from a source (overrides or options)
519
+ const mergeByContract = (source: any, sourceName: string) => {
520
+ if (!source) return
521
+
522
+ for (const [propertyContractUri, properties] of Object.entries(source)) {
523
+ if (!propertyContractUri.startsWith('#')) {
524
+ throw new Error(`${sourceName} for capsule '${capsule.capsuleSourceLineRef}' must use property contract keys starting with '#'. Found key: '${propertyContractUri}'`)
525
+ }
526
+
527
+ if (!definedPropertyContracts.has(propertyContractUri)) {
528
+ throw new Error(`${sourceName} for capsule '${capsule.capsuleSourceLineRef}' references property contract '${propertyContractUri}' which is not defined on the capsule`)
529
+ }
530
+
531
+ if (!mergedValuesByContract[propertyContractUri]) {
532
+ mergedValuesByContract[propertyContractUri] = {}
533
+ }
534
+
535
+ Object.assign(mergedValuesByContract[propertyContractUri], properties)
536
+ }
537
+ }
538
+
539
+ // Merge in order: overrides by lineRef, overrides by name, options
540
+ mergeByContract(overrides?.[encapsulateOptions.capsuleSourceLineRef], 'Overrides')
541
+ if (encapsulateOptions.capsuleName) {
542
+ mergeByContract(overrides?.[encapsulateOptions.capsuleName], 'Overrides')
543
+ }
544
+ mergeByContract(options, 'Options')
545
+
546
+ // Extract default values from property definitions (Literal/String types)
547
+ // This ensures child capsule's default values are available before parent is instantiated
548
+ const defaultPropertyValues: Record<string, any> = {}
549
+ for (const [spineContractUri, propertyContracts] of Object.entries(propertyContractDefinitions)) {
550
+ for (const [propertyContractUri, properties] of Object.entries(propertyContracts)) {
551
+ if (propertyContractUri !== '#') continue
552
+ for (const [propertyName, propertyDef] of Object.entries(properties as Record<string, any>)) {
553
+ if (propertyDef.type === CapsulePropertyTypes.Literal ||
554
+ propertyDef.type === CapsulePropertyTypes.String) {
555
+ if (propertyDef.value !== undefined) {
556
+ defaultPropertyValues[propertyName] = propertyDef.value
557
+ }
558
+ }
559
+ }
560
+ }
561
+ }
562
+
563
+ // Create a single shared self for all spine contracts by flattening merged values
564
+ // ownValues contains this capsule's defaults and overrides
565
+ const ownValues = merge({}, defaultInstance, defaultPropertyValues, ...Object.values(mergedValuesByContract))
566
+
567
+ // If sharedSelf is provided (from extending capsule), we need to:
568
+ // 1. Add parent's properties that child doesn't have
569
+ // 2. Keep child's values for properties that exist in both
570
+ // We do this by assigning parent's values first, then child's values on top
571
+ let self: any
572
+ if (sharedSelf) {
573
+ // Save child's current values (only non-undefined values)
574
+ const childValues: Record<string, any> = {}
575
+ for (const [key, value] of Object.entries(sharedSelf)) {
576
+ if (value !== undefined) {
577
+ childValues[key] = value
578
+ }
579
+ }
580
+ // Assign parent's defaults to sharedSelf (for properties child doesn't have)
581
+ for (const [key, value] of Object.entries(ownValues)) {
582
+ if (!(key in childValues)) {
583
+ sharedSelf[key] = value
584
+ }
585
+ }
586
+ self = sharedSelf
587
+ } else {
588
+ self = ownValues
589
+ }
590
+
591
+ // Create ownSelf containing only this capsule's own properties (not from extends chain)
592
+ // This allows functions to access this.self for their own capsule's properties
593
+ // The selfProxy in spine contracts will expose this as 'self' property
594
+ const ownSelf = merge({}, defaultInstance, defaultPropertyValues, ...Object.values(mergedValuesByContract))
595
+
596
+ // Initialize extended capsule instance if this capsule extends another
597
+ // Pass our self so extended capsule's functions bind to the same context
598
+ let extendedCapsuleInstance: any = undefined
599
+
600
+ // Check CST first, then fall back to encapsulateOptions for direct capsule references
601
+ let extendsCapsuleValue = capsule.cst?.source?.extendsCapsule || encapsulateOptions.extendsCapsule
602
+
603
+ // If extendsCapsule is a string identifier, check if it's in ambientReferences first
604
+ if (typeof extendsCapsuleValue === 'string') {
605
+ const cstAmbientRefs = capsule.cst?.source?.ambientReferences || {}
606
+ const runtimeAmbientRefs = encapsulateOptions.ambientReferences || {}
607
+ for (const [refName, ref] of Object.entries(cstAmbientRefs)) {
608
+ const refTyped = ref as any
609
+ if (refName === extendsCapsuleValue) {
610
+ if (refTyped.type === 'capsule' && refTyped.value) {
611
+ extendsCapsuleValue = refTyped.value
612
+ } else if (refTyped.type === 'instance' && runtimeAmbientRefs[refName]) {
613
+ // CST stores '[instance]' placeholder; resolve from runtime ambient refs
614
+ const runtimeRef = runtimeAmbientRefs[refName]
615
+ if (runtimeRef && typeof runtimeRef === 'object' && typeof runtimeRef.makeInstance === 'function') {
616
+ extendsCapsuleValue = runtimeRef
617
+ }
618
+ }
619
+ break
620
+ }
621
+ }
622
+ }
623
+
624
+ if (extendsCapsuleValue) {
625
+ let extendsCapsule = extendsCapsuleValue
626
+
627
+ // If it's a string, resolve it using the same mechanism as mappings
628
+ if (typeof extendsCapsule === 'string') {
629
+ // Use the first available spine contract to resolve the URI
630
+ const activeSpineContracts = runtimeSpineContracts || spine.spineContracts
631
+ const firstSpineContractKey = Object.keys(activeSpineContracts)[0]
632
+ const firstSpineContract = activeSpineContracts[firstSpineContractKey] as any
633
+
634
+ if (!firstSpineContract) throw new Error(`No spine contracts available to resolve extendsCapsule URI!`)
635
+
636
+ // Create a contract instance to use resolveMappedCapsule
637
+ const contractInstance = firstSpineContract.makeContractCapsuleInstance({
638
+ spineContractUri: firstSpineContractKey,
639
+ encapsulateOptions,
640
+ capsuleInstance: { api: {}, spineContractCapsuleInstances: {} },
641
+ self: {},
642
+ capsule,
643
+ encapsulatedApi: {},
644
+ runtimeSpineContracts
645
+ })
646
+
647
+ // Resolve using the same mechanism as mappings
648
+ extendsCapsule = await contractInstance.resolveMappedCapsule({
649
+ property: {
650
+ name: '__extends__',
651
+ definition: { value: extendsCapsule }
652
+ }
653
+ })
654
+ }
655
+
656
+ extendedCapsuleInstance = await extendsCapsule.makeInstance({
657
+ overrides,
658
+ options,
659
+ runtimeSpineContracts,
660
+ sharedSelf: self
661
+ })
662
+ }
663
+
664
+ const capsuleInstance: any = {
665
+ api: encapsulatedApi,
666
+ spineContractCapsuleInstances,
667
+ extendedCapsuleInstance,
668
+ structInitFunctions: [] as Array<() => any>,
669
+ mappedCapsuleInstances: [] as Array<any>
670
+ }
671
+
672
+ // Use runtime spine contracts if provided, otherwise fall back to encapsulation spine contracts
673
+ const activeSpineContracts = runtimeSpineContracts || spine.spineContracts
674
+
675
+ for (const [spineContractUri, propertyContracts] of Object.entries(propertyContractDefinitions)) {
676
+
677
+ const spineContract = activeSpineContracts[spineContractUri] as any
678
+
679
+ if (!spineContract) throw new Error(`Contract uri '${spineContractUri}' used by capsule not available in Spine!`)
680
+
681
+ const spineContractCapsuleInstance = spineContract.makeContractCapsuleInstance({
682
+ spineContractUri,
683
+ encapsulateOptions,
684
+ capsuleInstance,
685
+ self,
686
+ ownSelf,
687
+ capsule,
688
+ encapsulatedApi,
689
+ runtimeSpineContracts,
690
+ extendedCapsuleInstance
691
+ })
692
+
693
+ spineContractCapsuleInstances[spineContractUri] = spineContractCapsuleInstance
694
+
695
+ // Iterate through each property contract within this spine contract
696
+ for (const [propertyContractUri, properties] of Object.entries(propertyContracts)) {
697
+ // Skip non-'#' property contracts as they're already accessible via dynamic mappings in '#'
698
+ if (propertyContractUri !== '#') {
699
+ continue
700
+ }
701
+ for (const [propertyName, propertyDefinition] of Object.entries(properties)) {
702
+
703
+ if (!propertyDefinition.type || !(propertyDefinition.type in CapsulePropertyTypes)) throw new Error(`Type '${propertyDefinition.type}' for property '${propertyName}' on spineContract '${spineContractUri}' not set or supported!`)
704
+
705
+ await spineContractCapsuleInstance.mapProperty({
706
+ overrides,
707
+ options,
708
+ property: {
709
+ name: propertyName,
710
+ definition: propertyDefinition,
711
+ propertyContractUri
712
+ }
713
+ })
714
+ }
715
+ }
716
+ }
717
+
718
+ // Collect StructInit functions and mapped capsule instances from all spine contract capsule instances
719
+ for (const spineContractCapsuleInstance of Object.values(spineContractCapsuleInstances)) {
720
+ const sci = spineContractCapsuleInstance as any
721
+ if (sci.structInitFunctions?.length) {
722
+ capsuleInstance.structInitFunctions.push(...sci.structInitFunctions)
723
+ }
724
+ if (sci.mappedCapsuleInstances?.length) {
725
+ capsuleInstance.mappedCapsuleInstances.push(...sci.mappedCapsuleInstances)
726
+ }
727
+ }
728
+
729
+ // Wrap encapsulatedApi in a proxy that delegates to extended capsule's API for missing properties
730
+ if (extendedCapsuleInstance) {
731
+ capsuleInstance.api = new Proxy(encapsulatedApi, {
732
+ get: (target: any, prop: string | symbol) => {
733
+ if (typeof prop === 'symbol') return target[prop]
734
+
735
+ // First check if the property exists in local API
736
+ if (prop in target) {
737
+ return target[prop]
738
+ }
739
+
740
+ // Fall back to extended capsule's API
741
+ if (prop in extendedCapsuleInstance.api) {
742
+ return extendedCapsuleInstance.api[prop]
743
+ }
744
+
745
+ return undefined
746
+ },
747
+ has: (target: any, prop: string | symbol) => {
748
+ return prop in target || prop in extendedCapsuleInstance.api
749
+ }
750
+ })
751
+ }
752
+
753
+ return capsuleInstance
754
+ })()
755
+
756
+ // Cache the promise only if cacheKey is set (not when sharedSelf is provided)
757
+ if (cacheKey) {
758
+ instanceCache.set(cacheKey, instancePromise)
759
+ }
760
+
761
+ return instancePromise
762
+ }
763
+ }
764
+
765
+ spine.capsules[encapsulateOptions.capsuleSourceLineRef] = capsule
766
+ if (encapsulateOptions.capsuleName) {
767
+ spine.capsules[encapsulateOptions.capsuleName] = capsule
768
+ }
769
+
770
+ spine.spineOptions.timing?.record(`Encapsulate: Complete for ${moduleFilepath}`)
771
+
772
+ return capsule
773
+ }
774
+
775
+
776
+
777
+ function formatImportStackFrame(importStack: string): number | undefined {
778
+ const stackLines = importStack.split('\n')
779
+
780
+ const hasMakeImportStackMarker = importStack.includes('encapsulate:makeImportStack')
781
+ const targetMatchCount = hasMakeImportStackMarker ? 2 : 1
782
+
783
+ let matchCount = 0
784
+ for (let i = 0; i < stackLines.length; i++) {
785
+ const line = stackLines[i]
786
+
787
+ if (line.includes('encapsulate:makeImportStack')) {
788
+ continue
789
+ }
790
+
791
+ const match = line.match(/\(([^)]+):([0-9]+):[0-9]+\)|at ([^(]+):([0-9]+):[0-9]+/)
792
+ if (match) {
793
+ matchCount++
794
+ if (matchCount === targetMatchCount) {
795
+ const lineNumber = parseInt(match[2] || match[4])
796
+ return lineNumber
797
+ }
798
+ }
799
+ }
800
+
801
+ return undefined
802
+ }
803
+
804
+ // ##################################################
805
+ // # Utilities
806
+ // ##################################################
807
+
808
+ export function makeImportStack() {
809
+ return new Error('encapsulate:makeImportStack').stack
810
+ }
811
+
812
+ export function join(...paths: string[]): string {
813
+ if (paths.length === 0) return '.'
814
+
815
+ let joined = paths.join('/')
816
+
817
+ const isAbsolute = joined.startsWith('/')
818
+ const parts: string[] = []
819
+
820
+ for (const part of joined.split('/')) {
821
+ if (part === '' || part === '.') continue
822
+ if (part === '..') {
823
+ if (parts.length > 0 && parts[parts.length - 1] !== '..') {
824
+ parts.pop()
825
+ } else if (!isAbsolute) {
826
+ parts.push('..')
827
+ }
828
+ } else {
829
+ parts.push(part)
830
+ }
831
+ }
832
+
833
+ let result = parts.join('/')
834
+ if (isAbsolute) result = '/' + result
835
+
836
+ return result || (isAbsolute ? '/' : '.')
837
+ }
838
+
839
+ function relative(from: string, to: string): string {
840
+ const fromParts = from.split('/').filter(p => p && p !== '.')
841
+ const toParts = to.split('/').filter(p => p && p !== '.')
842
+
843
+ let commonLength = 0
844
+ const minLength = Math.min(fromParts.length, toParts.length)
845
+
846
+ for (let i = 0; i < minLength; i++) {
847
+ if (fromParts[i] === toParts[i]) {
848
+ commonLength++
849
+ } else {
850
+ break
851
+ }
852
+ }
853
+
854
+ const upCount = fromParts.length - commonLength
855
+ const remainingTo = toParts.slice(commonLength)
856
+
857
+ const result = [...Array(upCount).fill('..'), ...remainingTo].join('/')
858
+ return result || '.'
859
+ }
860
+
861
+ function isObject(item: any): boolean {
862
+ return item && typeof item === 'object' && !Array.isArray(item)
863
+ }
864
+
865
+ export function merge<T = any>(target: T, ...sources: any[]): T {
866
+ if (!sources.length) return target
867
+ const source = sources.shift()
868
+
869
+ if (isObject(target) && isObject(source)) {
870
+ for (const key in source) {
871
+ if (isObject(source[key])) {
872
+ if (!target[key as keyof T]) Object.assign(target, { [key]: {} })
873
+ merge(target[key as keyof T], source[key])
874
+ } else {
875
+ Object.assign(target, { [key]: source[key] })
876
+ }
877
+ }
878
+ }
879
+
880
+ return merge(target, ...sources)
881
+ }