@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.
- package/README.md +2 -2
- package/package.json +6 -5
- package/src/capsule-projectors/{CapsuleModuleProjector.v0.ts → CapsuleModuleProjector.ts} +52 -447
- package/src/encapsulate.ts +126 -20
- package/src/spine-contracts/CapsuleSpineContract.v0/{Membrane.v0.ts → Membrane.ts} +124 -77
- package/src/spine-contracts/CapsuleSpineContract.v0/README.md +29 -3
- package/src/spine-contracts/CapsuleSpineContract.v0/{Static.v0.ts → Static.ts} +144 -22
- package/src/spine-factories/{CapsuleSpineFactory.v0.ts → CapsuleSpineFactory.ts} +117 -6
- package/src/{static-analyzer.v0.ts → static-analyzer.ts} +15 -0
- package/structs/CapsuleProjectionContext.ts +53 -0
- package/structs/StructFactory.ts +90 -0
package/src/encapsulate.ts
CHANGED
|
@@ -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
|
|
192
|
-
// Membrane
|
|
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
|
-
|
|
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
|
-
|
|
518
|
+
if (instance.onFreezeFunctions?.length) {
|
|
519
|
+
for (const fn of instance.onFreezeFunctions) {
|
|
520
|
+
await fn()
|
|
521
|
+
}
|
|
522
|
+
}
|
|
443
523
|
|
|
444
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
754
|
-
|
|
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 =
|
|
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
|
|
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
|
|
415
|
+
**Static** — direct property assignment. No interception. Minimal overhead.
|
|
390
416
|
|
|
391
|
-
**Membrane
|
|
417
|
+
**Membrane** — wraps the API in proxies that emit events for every property access:
|
|
392
418
|
|
|
393
419
|
| Event | Emitted When | Payload |
|
|
394
420
|
|---|---|---|
|