@stream44.studio/encapsulate 0.4.0-rc.34 → 0.4.0-rc.39

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.
@@ -7,7 +7,16 @@ type TSpineOptions = {
7
7
  spineFilesystemRoot?: string,
8
8
  spineContracts: Record<string, any>,
9
9
  staticAnalyzer?: any,
10
- timing?: { record: (step: string) => void, chalk?: any }
10
+ timing?: { record: (step: string) => void, chalk?: any },
11
+ projectionContext?: {
12
+ capsuleModuleProjectionPackage?: string,
13
+ capsuleModuleProjectionRoot?: string,
14
+ projectionStore?: {
15
+ writeFile: (filepath: string, content: string) => Promise<void>,
16
+ getStats?: (filepath: string) => Promise<{ mtime: Date } | null>
17
+ } | null,
18
+ capsules?: Record<string, any>
19
+ }
11
20
  }
12
21
 
13
22
  type TSpineRunOptions = {
@@ -115,6 +124,8 @@ export const CapsulePropertyTypes = {
115
124
  StructDispose: 'StructDispose' as const,
116
125
  Init: 'Init' as const,
117
126
  Dispose: 'Dispose' as const,
127
+ OnFreeze: 'OnFreeze' as const,
128
+ ProxyFunction: 'ProxyFunction' as const,
118
129
  }
119
130
 
120
131
  // ##################################################
@@ -188,8 +199,8 @@ export async function SpineRuntime(options: TSpineRuntimeOptions): Promise<TSpin
188
199
 
189
200
  // If the value is a raw capsule instance (has spineContractCapsuleInstances
190
201
  // but is NOT a Proxy that handles API access), unwrap it
191
- // Static.v0 sets apiTarget[property.name] = mappedInstance (raw)
192
- // Membrane.v0 sets apiTarget[property.name] = new Proxy(...) which handles API access
202
+ // Static sets apiTarget[property.name] = mappedInstance (raw)
203
+ // Membrane sets apiTarget[property.name] = new Proxy(...) which handles API access
193
204
  if (value && typeof value === 'object' && value.spineContractCapsuleInstances) {
194
205
  // Check if this is a raw capsule instance by seeing if it has .api
195
206
  // and the .api doesn't have the same spineContractCapsuleInstances
@@ -430,7 +441,13 @@ export async function Spine(options: TSpineOptions): Promise<TSpine> {
430
441
 
431
442
  options.timing?.record(`Spine: Freezing ${Object.keys(capsules).length} capsules`)
432
443
 
433
- await Promise.all(Object.entries(capsules).map(async ([capsuleSourceLineRef, capsule]) => {
444
+ const processedCapsules = new Set<any>()
445
+ const freezeVisited = new Set<any>()
446
+ for (const [capsuleSourceLineRef, capsule] of Object.entries(capsules)) {
447
+
448
+ // Skip capsuleName aliases — only process each capsule once via its capsuleSourceLineRef
449
+ if (processedCapsules.has(capsule)) continue
450
+ processedCapsules.add(capsule)
434
451
 
435
452
  if (!capsule.cst.source.capsuleName) throw new Error(`'capsuleName' must be set for encapsulate options to enable freezing.`)
436
453
 
@@ -438,10 +455,95 @@ export async function Spine(options: TSpineOptions): Promise<TSpine> {
438
455
  cst: capsule.cst,
439
456
  spineContracts: {}
440
457
  }
458
+ // Also register under capsuleName so SpineRuntime can resolve by name
459
+ if (capsule.cst.source.capsuleName && capsule.cst.source.capsuleName !== capsuleSourceLineRef) {
460
+ snapshot.capsules[capsule.cst.source.capsuleName] = snapshot.capsules[capsuleSourceLineRef]
461
+ }
462
+
463
+ const capsuleInstance = await capsule.makeInstance()
464
+
465
+ // Run OnFreeze functions so capsules can perform side effects
466
+ // (e.g. file projection) at build/freeze time
467
+ async function runOnFreeze(instance: any, parentCapsuleCst?: any, parentCapsuleSourceLineRef?: string, projectionPath?: string, projectionSpineContractUri?: string) {
468
+ if (!instance) return
469
+ // Use capsuleSourceLineRef for deduplication — this is stable across different
470
+ // instance objects of the same capsule (top-level vs mapped child).
471
+ // Include projectionPath in the key so the same capsule can project to
472
+ // multiple output paths (e.g. standalone delegate used by different parents).
473
+ // Fall back to instance identity for internal capsules without a lineRef.
474
+ // Use capsuleName as the base key when available (always a unique string),
475
+ // otherwise fall back to capsuleSourceLineRef or instance identity.
476
+ // This avoids collisions from object refs that all stringify to '[object Object]'.
477
+ const baseKey = instance.capsuleName || instance.capsuleSourceLineRef || instance
478
+ const freezeKey = projectionPath ? `${baseKey}::${projectionPath}` : baseKey
479
+ if (freezeVisited.has(freezeKey)) return
480
+ freezeVisited.add(freezeKey)
481
+
482
+ // Inject projection context onto CapsuleProjectionContext instances
483
+ // Set values on both the api (encapsulatedApi) and spine contract self
484
+ // so they are visible through all proxy chains.
485
+ // Scan recursively into mapped children since CapsuleProjectionContext
486
+ // may be nested inside a property contract delegate (e.g. a projector capsule).
487
+ const projectionCtx = options.projectionContext
488
+ if (projectionCtx && instance.mappedCapsuleInstances?.length) {
489
+ const injectCtx = (children: any[]) => {
490
+ for (const mappedChild of children) {
491
+ const childApi = mappedChild.api
492
+ if (childApi && mappedChild.capsuleName === '@stream44.studio/encapsulate/structs/CapsuleProjectionContext') {
493
+ const ctx = {
494
+ parentCapsuleCst: parentCapsuleCst,
495
+ parentCapsuleSourceLineRef: parentCapsuleSourceLineRef,
496
+ capsuleModuleProjectionPackage: projectionCtx.capsuleModuleProjectionPackage,
497
+ projectionStore: projectionCtx.projectionStore,
498
+ capsuleSnapshots: projectionCtx.capsules,
499
+ projectionPath: projectionPath,
500
+ spineContractUri: projectionSpineContractUri,
501
+ }
502
+ Object.assign(childApi, ctx)
503
+ // Also update spine contract self for selfProxy access
504
+ for (const childSci of Object.values(mappedChild.spineContractCapsuleInstances || {})) {
505
+ const childSelf = (childSci as any).self
506
+ if (childSelf) Object.assign(childSelf, ctx)
507
+ }
508
+ }
509
+ // Recurse into delegate's mapped children
510
+ if (mappedChild.mappedCapsuleInstances?.length) {
511
+ injectCtx(mappedChild.mappedCapsuleInstances)
512
+ }
513
+ }
514
+ }
515
+ injectCtx(instance.mappedCapsuleInstances)
516
+ }
441
517
 
442
- const { spineContractCapsuleInstances } = await capsule.makeInstance()
518
+ if (instance.onFreezeFunctions?.length) {
519
+ for (const fn of instance.onFreezeFunctions) {
520
+ await fn()
521
+ }
522
+ }
443
523
 
444
- await Promise.all(Object.entries(spineContractCapsuleInstances).map(async ([spineContractUri, spineContractCapsuleInstance]) => {
524
+ if (instance.extendedCapsuleInstance) {
525
+ await runOnFreeze(instance.extendedCapsuleInstance, parentCapsuleCst, parentCapsuleSourceLineRef, projectionPath, projectionSpineContractUri)
526
+ }
527
+ if (instance.mappedCapsuleInstances?.length) {
528
+ for (const mappedInstance of instance.mappedCapsuleInstances) {
529
+ // Determine projection path from the property name (alias) used for this mapping.
530
+ // If the mapped child doesn't define its own path (no / prefix), inherit the parent's projectionPath
531
+ // so it flows down to property contract delegates.
532
+ const mappedProjectionPath = mappedInstance.mappedPropertyName?.startsWith('/') ? mappedInstance.mappedPropertyName : projectionPath
533
+ // For regular mapped capsules (non-struct delegates), use their own CST as
534
+ // parentCapsuleCst so nested property contract delegates see the correct parent.
535
+ // Property contract delegates (flagged by Static) pass through the current
536
+ // parentCapsuleCst so their OnFreeze sees the declaring capsule's CST.
537
+ const mappedCapsule = (!mappedInstance.isPropertyContractDelegate && mappedInstance.capsuleName) ? capsules[mappedInstance.capsuleName] : undefined
538
+ const childParentCst = mappedCapsule?.cst || parentCapsuleCst
539
+ const instanceSourceLineRef = instance.capsuleSourceLineRef || parentCapsuleSourceLineRef
540
+ await runOnFreeze(mappedInstance, childParentCst, instanceSourceLineRef, mappedProjectionPath, Object.keys(capsuleInstance.spineContractCapsuleInstances)?.[0])
541
+ }
542
+ }
543
+ }
544
+ await runOnFreeze(capsuleInstance, capsule.cst, capsule.capsuleSourceLineRef)
545
+
546
+ await Promise.all(Object.entries(capsuleInstance.spineContractCapsuleInstances).map(async ([spineContractUri, spineContractCapsuleInstance]) => {
445
547
 
446
548
  snapshot.capsules[capsuleSourceLineRef] = merge(
447
549
  snapshot.capsules[capsuleSourceLineRef],
@@ -451,7 +553,7 @@ export async function Spine(options: TSpineOptions): Promise<TSpine> {
451
553
  })
452
554
  )
453
555
  }))
454
- }))
556
+ }
455
557
 
456
558
  options.timing?.record('Spine: Freeze complete')
457
559
 
@@ -621,7 +723,7 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
621
723
  // Check if 'as' is defined to use as the property name alias
622
724
  const propDefTyped = propDef as Record<string, any>
623
725
  const aliasName = propDefTyped.as
624
- const delegateOptions = propDefTyped.options
726
+ let delegateOptions = propDefTyped.options
625
727
  const contractKey = aliasName || ('#' + propContractUri.substring(1))
626
728
 
627
729
  if (!propertyContractDefinitions[spineContractUri]['#']) {
@@ -835,8 +937,8 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
835
937
  moduleFilepath: absoluteModuleFilepath
836
938
  },
837
939
  parentCapsuleSourceUriLineRefInstanceId: parentCapsuleSourceUriLineRefInstanceId
838
- ? sha256(parentCapsuleSourceUriLineRefInstanceId + ':' + (cst?.capsuleSourceUriLineRef || encapsulateOptions.capsuleSourceLineRef))
839
- : sha256(cst?.capsuleSourceUriLineRef || encapsulateOptions.capsuleSourceLineRef),
940
+ ? await sha256(parentCapsuleSourceUriLineRefInstanceId + ':' + (cst?.capsuleSourceUriLineRef || encapsulateOptions.capsuleSourceLineRef))
941
+ : await sha256(cst?.capsuleSourceUriLineRef || encapsulateOptions.capsuleSourceLineRef),
840
942
  sit
841
943
  })
842
944
 
@@ -873,8 +975,8 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
873
975
  // child: sha256(parentCapsuleSourceUriLineRefInstanceId + ":" + capsuleSourceUriLineRef)
874
976
  const capsuleSourceUriLineRef = cst?.capsuleSourceUriLineRef || encapsulateOptions.capsuleSourceLineRef
875
977
  const capsuleSourceUriLineRefInstanceId = parentCapsuleSourceUriLineRefInstanceId
876
- ? sha256(parentCapsuleSourceUriLineRefInstanceId + ':' + capsuleSourceUriLineRef)
877
- : sha256(capsuleSourceUriLineRef)
978
+ ? await sha256(parentCapsuleSourceUriLineRefInstanceId + ':' + capsuleSourceUriLineRef)
979
+ : await sha256(capsuleSourceUriLineRef)
878
980
 
879
981
  // Register this instance in the sit structure if provided
880
982
  if (sit) {
@@ -893,6 +995,7 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
893
995
  structDisposeFunctions: [] as Array<() => any>,
894
996
  initFunctions: [] as Array<() => any>,
895
997
  disposeFunctions: [] as Array<() => any>,
998
+ onFreezeFunctions: [] as Array<() => any>,
896
999
  mappedCapsuleInstances: [] as Array<any>,
897
1000
  rootCapsule: resolvedRootCapsule,
898
1001
  capsuleSourceUriLineRefInstanceId,
@@ -987,6 +1090,9 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
987
1090
  if (sci.disposeFunctions?.length) {
988
1091
  capsuleInstance.disposeFunctions.push(...sci.disposeFunctions)
989
1092
  }
1093
+ if (sci.onFreezeFunctions?.length) {
1094
+ capsuleInstance.onFreezeFunctions.push(...sci.onFreezeFunctions)
1095
+ }
990
1096
  if (sci.mappedCapsuleInstances?.length) {
991
1097
  capsuleInstance.mappedCapsuleInstances.push(...sci.mappedCapsuleInstances)
992
1098
  }
@@ -1124,15 +1230,15 @@ function relative(from: string, to: string): string {
1124
1230
  return result || '.'
1125
1231
  }
1126
1232
 
1127
- function sha256(input: string): string {
1128
- // Use Bun's native hasher for speed; falls back to Node crypto
1129
- if (typeof globalThis.Bun !== 'undefined') {
1130
- const hasher = new globalThis.Bun.CryptoHasher('sha256')
1131
- hasher.update(input)
1132
- return hasher.digest('hex') as string
1233
+ async function sha256(input: string): Promise<string> {
1234
+ const data = new TextEncoder().encode(input)
1235
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data)
1236
+ const hashArray = new Uint8Array(hashBuffer)
1237
+ let hex = ''
1238
+ for (let i = 0; i < hashArray.length; i++) {
1239
+ hex += hashArray[i].toString(16).padStart(2, '0')
1133
1240
  }
1134
- const { createHash } = require('crypto')
1135
- return createHash('sha256').update(input).digest('hex')
1241
+ return hex
1136
1242
  }
1137
1243
 
1138
1244
  function isObject(item: any): boolean {
@@ -1,7 +1,5 @@
1
1
  import { CapsulePropertyTypes } from "../../encapsulate"
2
- import { ContractCapsuleInstanceFactory, CapsuleInstanceRegistry } from "./Static.v0"
3
- import { readFileSync, existsSync } from "node:fs"
4
- import { dirname, relative, join } from "node:path"
2
+ import { ContractCapsuleInstanceFactory, CapsuleInstanceRegistry } from "./Static"
5
3
 
6
4
  type CallerContext = {
7
5
  capsuleSourceLineRef: string
@@ -60,6 +58,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
60
58
  private setCurrentCallerContext: (ctx: CallerContext | undefined) => void
61
59
  private onMembraneEvent?: (event: any) => void
62
60
  private enableCallerStackInference: boolean
61
+ private npmUriForFilepathSync?: (filepath: string) => string | null
63
62
  private encapsulateOptions: any
64
63
  private capsuleSourceNameRef?: string
65
64
  private capsuleSourceNameRefHash?: string
@@ -78,6 +77,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
78
77
  freezeCapsule,
79
78
  onMembraneEvent,
80
79
  enableCallerStackInference,
80
+ npmUriForFilepathSync,
81
81
  encapsulateOptions,
82
82
  getEventIndex,
83
83
  incrementEventIndex,
@@ -99,6 +99,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
99
99
  freezeCapsule?: (capsule: any) => Promise<any>
100
100
  onMembraneEvent?: (event: any) => void
101
101
  enableCallerStackInference: boolean
102
+ npmUriForFilepathSync?: (filepath: string) => string | null
102
103
  encapsulateOptions: any
103
104
  getEventIndex: () => number
104
105
  incrementEventIndex: () => number
@@ -116,6 +117,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
116
117
  this.setCurrentCallerContext = setCurrentCallerContext
117
118
  this.onMembraneEvent = onMembraneEvent
118
119
  this.enableCallerStackInference = enableCallerStackInference
120
+ this.npmUriForFilepathSync = npmUriForFilepathSync
119
121
  this.encapsulateOptions = encapsulateOptions
120
122
  this.capsuleSourceNameRef = capsule?.cst?.capsuleSourceNameRef
121
123
  this.capsuleSourceNameRefHash = capsule?.cst?.capsuleSourceNameRefHash
@@ -184,7 +186,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
184
186
  if (this.enableCallerStackInference) {
185
187
  const stackStr = new Error('[MAPPED_CAPSULE]').stack
186
188
  if (stackStr) {
187
- const stackFrames = parseCallerFromStack(stackStr, this.spineFilesystemRoot)
189
+ const stackFrames = parseCallerFromStack(stackStr, this.spineFilesystemRoot, this.npmUriForFilepathSync)
188
190
  if (stackFrames.length > 0) {
189
191
  const callerInfo = extractCallerInfo(stackFrames, 3)
190
192
  callerCtx.fileUri = callerInfo.fileUri
@@ -311,7 +313,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
311
313
  if (this.enableCallerStackInference) {
312
314
  const stackStr = new Error('[MAPPED_CAPSULE]').stack
313
315
  if (stackStr) {
314
- const stackFrames = parseCallerFromStack(stackStr, this.spineFilesystemRoot)
316
+ const stackFrames = parseCallerFromStack(stackStr, this.spineFilesystemRoot, this.npmUriForFilepathSync)
315
317
  if (stackFrames.length > 0) {
316
318
  const callerInfo = extractCallerInfo(stackFrames, 3)
317
319
  callerCtx.fileUri = callerInfo.fileUri
@@ -374,7 +376,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
374
376
  if (this.enableCallerStackInference) {
375
377
  const stackStr = new Error('[PROPERTY_CONTRACT_DELEGATE]').stack
376
378
  if (stackStr) {
377
- const stackFrames = parseCallerFromStack(stackStr, this.spineFilesystemRoot)
379
+ const stackFrames = parseCallerFromStack(stackStr, this.spineFilesystemRoot, this.npmUriForFilepathSync)
378
380
  if (stackFrames.length > 0) {
379
381
  const callerInfo = extractCallerInfo(stackFrames, 3)
380
382
  callerCtx.fileUri = callerInfo.fileUri
@@ -618,6 +620,86 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
618
620
  })
619
621
  }
620
622
 
623
+ protected override mapProxyFunctionProperty({ property }: { property: any }) {
624
+ const selfProxy = this.createSelfProxy()
625
+
626
+ const childTargetFn = property.definition.value.target
627
+ const childInvokeFn = property.definition.value.invoke
628
+
629
+ // Inherit missing parts from parent's ProxyFunction (if extending)
630
+ const parentParts = this.self[`__proxyFn_${property.name}`]
631
+ const targetFn = childTargetFn ?? parentParts?.target
632
+ const invokeFn = childInvokeFn ?? parentParts?.invoke
633
+
634
+ if (!targetFn) throw new Error(`ProxyFunction '${property.name}': target() is required`)
635
+ if (!invokeFn) throw new Error(`ProxyFunction '${property.name}': invoke() is required`)
636
+
637
+ // Store parts for potential child override
638
+ this.self[`__proxyFn_${property.name}`] = { target: targetFn, invoke: invokeFn }
639
+
640
+ const boundProxyFn = (...args: any[]) => {
641
+ const transformedArgs = invokeFn.call(selfProxy, ...args)
642
+ const target = targetFn.call(selfProxy)
643
+ if (transformedArgs && typeof transformedArgs.then === 'function') {
644
+ return transformedArgs.then((resolved: any) => target(resolved))
645
+ }
646
+ return target(transformedArgs)
647
+ }
648
+
649
+ Object.defineProperty(this.encapsulatedApi, property.name, {
650
+ get: () => {
651
+ return (...args: any[]) => {
652
+ const callEvent: any = {
653
+ event: 'call',
654
+ eventIndex: this.incrementEventIndex(),
655
+ membrane: 'external',
656
+ target: {
657
+ capsuleSourceLineRef: this.encapsulateOptions.capsuleSourceLineRef,
658
+ spineContractCapsuleInstanceId: this.id,
659
+ prop: property.name,
660
+ },
661
+ args
662
+ }
663
+
664
+ if (this.capsuleSourceNameRef) {
665
+ callEvent.target.capsuleSourceNameRef = this.capsuleSourceNameRef
666
+ }
667
+ if (this.capsuleSourceNameRefHash) {
668
+ callEvent.target.capsuleSourceNameRefHash = this.capsuleSourceNameRefHash
669
+ }
670
+ if (this.capsuleInstance?.capsuleSourceUriLineRefInstanceId) {
671
+ callEvent.target.capsuleSourceUriLineRefInstanceId = this.capsuleInstance.capsuleSourceUriLineRefInstanceId
672
+ }
673
+
674
+ this.addCallerContextToEvent(callEvent)
675
+ this.onMembraneEvent?.(callEvent)
676
+
677
+ const previousCallerContext = this.getCurrentCallerContext()
678
+ this.setCurrentCallerContext(this.buildCallerContext(property.name))
679
+ const result = boundProxyFn(...args)
680
+ this.setCurrentCallerContext(previousCallerContext)
681
+
682
+ const resultEvent: any = {
683
+ event: 'call-result',
684
+ eventIndex: this.incrementEventIndex(),
685
+ membrane: 'external',
686
+ callEventIndex: callEvent.eventIndex,
687
+ target: {
688
+ spineContractCapsuleInstanceId: this.id,
689
+ },
690
+ result
691
+ }
692
+
693
+ this.onMembraneEvent?.(resultEvent)
694
+
695
+ return result
696
+ }
697
+ },
698
+ enumerable: true,
699
+ configurable: true
700
+ })
701
+ }
702
+
621
703
  protected mapGetterFunctionProperty({ property }: { property: any }) {
622
704
  const getterFn = property.definition.value
623
705
  const selfProxy = this.createSelfProxy()
@@ -747,16 +829,13 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
747
829
  }
748
830
 
749
831
  // Determine the value source and get the value
832
+ // Virtual dispatch: child overrides take precedence over both
833
+ // self and own encapsulatedApi, matching class-inheritance semantics
750
834
  let value: any
751
835
  let source: 'self' | 'encapsulatedApi' | 'childApi' | 'extendedApi' | undefined
752
836
 
753
- if (prop in target) {
754
- value = target[prop]
755
- source = 'self'
756
- } else if (prop in factory.encapsulatedApi) {
757
- value = factory.encapsulatedApi[prop]
758
- source = 'encapsulatedApi'
759
- } else if (factory.childEncapsulatedApis) {
837
+ // Check child capsule APIs first for virtual dispatch
838
+ if (factory.childEncapsulatedApis) {
760
839
  for (const childApi of factory.childEncapsulatedApis) {
761
840
  if (prop in childApi) {
762
841
  value = childApi[prop]
@@ -766,6 +845,16 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
766
845
  }
767
846
  }
768
847
 
848
+ if (source === undefined) {
849
+ if (prop in target) {
850
+ value = target[prop]
851
+ source = 'self'
852
+ } else if (prop in factory.encapsulatedApi) {
853
+ value = factory.encapsulatedApi[prop]
854
+ source = 'encapsulatedApi'
855
+ }
856
+ }
857
+
769
858
  if (source === undefined && extendedApi && prop in extendedApi) {
770
859
  value = extendedApi[prop]
771
860
  source = 'extendedApi'
@@ -892,7 +981,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
892
981
  } else if (this.enableCallerStackInference) {
893
982
  const stackStr = new Error('[MEMBRANE_EVENT]').stack
894
983
  if (stackStr) {
895
- const stackFrames = parseCallerFromStack(stackStr, this.spineFilesystemRoot)
984
+ const stackFrames = parseCallerFromStack(stackStr, this.spineFilesystemRoot, this.npmUriForFilepathSync)
896
985
  if (stackFrames.length > 0) {
897
986
  const callerInfo = extractCallerInfo(stackFrames, 3)
898
987
  event.caller = {
@@ -911,7 +1000,8 @@ export function CapsuleSpineContract({
911
1000
  enableCallerStackInference = false,
912
1001
  spineFilesystemRoot,
913
1002
  resolve,
914
- importCapsule
1003
+ importCapsule,
1004
+ npmUriForFilepath
915
1005
  }: {
916
1006
  onMembraneEvent?: (event: any) => void
917
1007
  freezeCapsule?: (capsule: any) => Promise<any>
@@ -919,12 +1009,28 @@ export function CapsuleSpineContract({
919
1009
  spineFilesystemRoot?: string
920
1010
  resolve?: (uri: string, parentFilepath: string) => Promise<string>
921
1011
  importCapsule?: (filepath: string) => Promise<any>
1012
+ npmUriForFilepath?: (filepath: string) => Promise<string | null>
922
1013
  } = {}) {
923
1014
 
924
1015
  let eventIndex = 0
925
1016
  let currentCallerContext: CallerContext | undefined = undefined
926
1017
  const instanceRegistry: CapsuleInstanceRegistry = new Map()
927
1018
 
1019
+ // Sync cache for npmUriForFilepath — async calls populate the cache,
1020
+ // sync reads return cached values (raw filepath fallback on cache miss).
1021
+ const npmUriCache = new Map<string, string | null>()
1022
+ const npmUriForFilepathSync = npmUriForFilepath
1023
+ ? (filepath: string): string | null => {
1024
+ if (npmUriCache.has(filepath)) return npmUriCache.get(filepath)!
1025
+ // Fire async resolution to populate cache for next access
1026
+ npmUriForFilepath(filepath).then(
1027
+ uri => npmUriCache.set(filepath, uri),
1028
+ () => npmUriCache.set(filepath, null)
1029
+ )
1030
+ return null
1031
+ }
1032
+ : undefined
1033
+
928
1034
  // Re-entrancy guard: suppress event emission while inside an onMembraneEvent callback.
929
1035
  // This prevents consumers (e.g. JSON.stringify on event.value) from triggering proxy getters
930
1036
  // that would cause spurious recursive membrane events with wrong caller context and ordering.
@@ -955,6 +1061,7 @@ export function CapsuleSpineContract({
955
1061
  importCapsule,
956
1062
  onMembraneEvent: guardedOnMembraneEvent,
957
1063
  enableCallerStackInference,
1064
+ npmUriForFilepathSync,
958
1065
  encapsulateOptions,
959
1066
  getEventIndex: () => eventIndex,
960
1067
  incrementEventIndex: () => eventIndex++,
@@ -975,67 +1082,7 @@ export function CapsuleSpineContract({
975
1082
  CapsuleSpineContract['#'] = '@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0'
976
1083
 
977
1084
 
978
-
979
-
980
- // Cache for synchronous npm URI lookups (directory -> package name or null)
981
- const npmUriCache = new Map<string, string | null>()
982
-
983
- function constructNpmUriSync(absoluteFilepath: string): string | null {
984
- // Only process absolute paths — skip V8 internal markers like "native", "node:*", etc.
985
- if (!absoluteFilepath.startsWith('/')) {
986
- return null
987
- }
988
-
989
- // Check for /node_modules/ in the path — use the last occurrence to handle nested node_modules
990
- const nodeModulesMarker = '/node_modules/'
991
- const lastIdx = absoluteFilepath.lastIndexOf(nodeModulesMarker)
992
- if (lastIdx !== -1) {
993
- return absoluteFilepath.substring(lastIdx + nodeModulesMarker.length)
994
- }
995
-
996
- let currentDir = dirname(absoluteFilepath)
997
- const maxDepth = 20
998
-
999
- for (let i = 0; i < maxDepth; i++) {
1000
- if (npmUriCache.has(currentDir)) {
1001
- const cachedName = npmUriCache.get(currentDir)
1002
- if (cachedName) {
1003
- const relativeFromPackage = relative(currentDir, absoluteFilepath)
1004
- return `${cachedName}/${relativeFromPackage}`
1005
- }
1006
- // null means no package.json with name found at this level, continue up
1007
- const parentDir = dirname(currentDir)
1008
- if (parentDir === currentDir) break
1009
- currentDir = parentDir
1010
- continue
1011
- }
1012
-
1013
- const packageJsonPath = join(currentDir, 'package.json')
1014
- try {
1015
- if (existsSync(packageJsonPath)) {
1016
- const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'))
1017
- const packageName = packageJson.name
1018
- npmUriCache.set(currentDir, packageName || null)
1019
- if (packageName) {
1020
- const relativeFromPackage = relative(currentDir, absoluteFilepath)
1021
- return `${packageName}/${relativeFromPackage}`
1022
- }
1023
- } else {
1024
- npmUriCache.set(currentDir, null)
1025
- }
1026
- } catch {
1027
- npmUriCache.set(currentDir, null)
1028
- }
1029
-
1030
- const parentDir = dirname(currentDir)
1031
- if (parentDir === currentDir) break
1032
- currentDir = parentDir
1033
- }
1034
-
1035
- return null
1036
- }
1037
-
1038
- function parseCallerFromStack(stack: string, spineFilesystemRoot?: string): Array<{ function?: string, fileUri?: string, line?: number, column?: number }> {
1085
+ function parseCallerFromStack(stack: string, spineFilesystemRoot?: string, npmUriForFilepathSync?: (filepath: string) => string | null): Array<{ function?: string, fileUri?: string, line?: number, column?: number }> {
1039
1086
  const lines = stack.split('\n')
1040
1087
  const result: Array<{ function?: string, fileUri?: string, line?: number, column?: number }> = []
1041
1088
 
@@ -1082,7 +1129,7 @@ function parseCallerFromStack(stack: string, spineFilesystemRoot?: string): Arra
1082
1129
 
1083
1130
  // Convert absolute filepaths to npm URIs
1084
1131
  if (rawFilepath) {
1085
- const npmUri = constructNpmUriSync(rawFilepath)
1132
+ const npmUri = npmUriForFilepathSync ? npmUriForFilepathSync(rawFilepath) : null
1086
1133
  if (npmUri) {
1087
1134
  // Strip file extension from URI for consistency
1088
1135
  frame.fileUri = npmUri.replace(/\.(ts|tsx|js|jsx)$/, '')
@@ -263,6 +263,7 @@ All value types accept a `value` in their definition. `undefined` means "no defa
263
263
  | `Function` | `api.name(...args)` | `function(this, ...args)` | Callable method. Bound to self proxy. |
264
264
  | `GetterFunction` | `api.name` (no parens) | `function(this)` | Lazy getter. Evaluated on each access (unless memoized). |
265
265
  | `SetterFunction` | `api.name = value` | `function(this, value)` | Triggered on assignment. Enables validation/transformation logic. |
266
+ | `ProxyFunction` | `api.name(...args)` | `{ target(this), invoke(this, ...args) }` | Wraps a target function with argument transformation. See below. |
266
267
 
267
268
  All function types are bound to a **self proxy** where:
268
269
  - `this.<prop>` resolves through: self → encapsulatedApi → extendedCapsuleApi
@@ -290,6 +291,31 @@ Applies to `Function` and `GetterFunction`. Added as a sibling to `type` and `va
290
291
 
291
292
  Memoize caches are scoped per spine contract capsule instance and cleared automatically when `run()` completes.
292
293
 
294
+ #### ProxyFunction
295
+
296
+ `ProxyFunction` wraps calling a target function with argument transformation. Its `value` is an object with two methods:
297
+
298
+ - **`target(this)`** — resolves the function to call (bound to self proxy)
299
+ - **`invoke(this, ...args)`** — transforms the caller's arguments before passing to target (bound to self proxy, may be async)
300
+
301
+ ```ts
302
+ {
303
+ type: CapsulePropertyTypes.ProxyFunction,
304
+ value: {
305
+ target() { return this.service.fetchData }, // resolve target fn
306
+ async invoke(pathname: string) { // transform args
307
+ const origin = this.origin
308
+ return { url: `http://${origin}${pathname}`, headers: { Host: origin } }
309
+ }
310
+ }
311
+ }
312
+ ```
313
+
314
+ When `api.name(...args)` is called:
315
+ 1. `invoke(...args)` runs with self proxy as `this`, returning transformed args
316
+ 2. `target()` runs with self proxy as `this`, returning the function to call
317
+ 3. The target function is called with the transformed args (awaited if `invoke` returns a promise)
318
+
293
319
  ### Mapping
294
320
 
295
321
  `Mapping` composes another capsule as a sub-component.
@@ -382,13 +408,13 @@ A capsule can inherit properties from a parent capsule:
382
408
  - `this.self` in a parent function returns the parent's own values (`ownSelf`), not the merged context.
383
409
  - Multiple capsules can extend the same parent — each gets a separate parent instance with its own `self`.
384
410
 
385
- ### Spine Contracts: Static.v0 vs Membrane.v0
411
+ ### Spine Contracts: Static vs Membrane
386
412
 
387
413
  Both implement the same property mapping logic. The difference is observability.
388
414
 
389
- **Static.v0** — direct property assignment. No interception. Minimal overhead.
415
+ **Static** — direct property assignment. No interception. Minimal overhead.
390
416
 
391
- **Membrane.v0** — wraps the API in proxies that emit events for every property access:
417
+ **Membrane** — wraps the API in proxies that emit events for every property access:
392
418
 
393
419
  | Event | Emitted When | Payload |
394
420
  |---|---|---|