@stream44.studio/encapsulate 0.4.0-rc.38 → 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 +4 -4
- package/src/capsule-projectors/{CapsuleModuleProjector.v0.ts → CapsuleModuleProjector.ts} +3 -3
- package/src/encapsulate.ts +4 -3
- package/src/spine-contracts/CapsuleSpineContract.v0/{Membrane.v0.ts → Membrane.ts} +95 -8
- package/src/spine-contracts/CapsuleSpineContract.v0/README.md +29 -3
- package/src/spine-contracts/CapsuleSpineContract.v0/{Static.v0.ts → Static.ts} +117 -10
- package/src/spine-factories/{CapsuleSpineFactory.v0.ts → CapsuleSpineFactory.ts} +33 -4
- package/src/{static-analyzer.v0.ts → static-analyzer.ts} +15 -0
- package/structs/StructFactory.ts +90 -0
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<table>
|
|
2
2
|
<tr>
|
|
3
|
-
<td><a href="https://Stream44.
|
|
4
|
-
<td><strong><a href="https://Stream44.
|
|
3
|
+
<td><a href="https://Stream44.Systems"><img src=".o/stream44.studio/assets/Icon-v1.svg" width="42" height="42"></a></td>
|
|
4
|
+
<td><strong><a href="https://Stream44.Systems">Stream44 Systems</a></strong><br/>Open Development Project</td>
|
|
5
5
|
<td>Preview release for community feedback.<br/>Get in touch on <a href="https://discord.gg/9eBcQXEJAN">discord</a>.</td>
|
|
6
6
|
<td>Designed by Hand<br/><b>AI assisted Code</a></td>
|
|
7
7
|
</tr>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stream44.studio/encapsulate",
|
|
3
|
-
"version": "0.4.0-rc.
|
|
3
|
+
"version": "0.4.0-rc.39",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
"type": "module",
|
|
11
11
|
"exports": {
|
|
12
12
|
"./encapsulate": "./src/encapsulate.ts",
|
|
13
|
-
"./spine-contracts/CapsuleSpineContract.v0/Static
|
|
14
|
-
"./spine-contracts/CapsuleSpineContract.v0/Membrane
|
|
15
|
-
"./spine-factories/CapsuleSpineFactory
|
|
13
|
+
"./spine-contracts/CapsuleSpineContract.v0/Static": "./src/spine-contracts/CapsuleSpineContract.v0/Static.ts",
|
|
14
|
+
"./spine-contracts/CapsuleSpineContract.v0/Membrane": "./src/spine-contracts/CapsuleSpineContract.v0/Membrane.ts",
|
|
15
|
+
"./spine-factories/CapsuleSpineFactory": "./src/spine-factories/CapsuleSpineFactory.ts",
|
|
16
16
|
"./spine-factories/TimingObserver": "./src/spine-factories/TimingObserver.ts",
|
|
17
17
|
"./structs/Capsule": "./structs/Capsule.ts",
|
|
18
18
|
"./structs/CapsuleProjectionContext": "./structs/CapsuleProjectionContext.ts"
|
|
@@ -50,7 +50,7 @@ async function constructCacheFilePath(moduleFilepath: string, importStackLine: n
|
|
|
50
50
|
* Finds the nearest package.json and constructs an npm URI for cache files.
|
|
51
51
|
* First checks if the path contains /node_modules/ and if so, extracts the portion
|
|
52
52
|
* after the last /node_modules/ occurrence for consistent paths in dev and installed mode.
|
|
53
|
-
* This matches the logic from static-analyzer.
|
|
53
|
+
* This matches the logic from static-analyzer.ts
|
|
54
54
|
*/
|
|
55
55
|
async function constructNpmUriForCache(absoluteFilepath: string, spineRoot: string): Promise<string | null> {
|
|
56
56
|
// Check for /node_modules/ in the path — use the last occurrence to handle nested node_modules
|
|
@@ -742,7 +742,7 @@ export function CapsuleModuleProjector({
|
|
|
742
742
|
const hasStandalone = hasStandaloneProperty(capsule, spineContractUri)
|
|
743
743
|
|
|
744
744
|
// Add runtime imports for standalone functions
|
|
745
|
-
const runtimeImport = hasStandalone ? `"use client"\nimport { Spine, SpineRuntime } from '@stream44.studio/encapsulate/encapsulate'\nimport { CapsuleSpineContract } from '@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0/Membrane
|
|
745
|
+
const runtimeImport = hasStandalone ? `"use client"\nimport { Spine, SpineRuntime } from '@stream44.studio/encapsulate/encapsulate'\nimport { CapsuleSpineContract } from '@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0/Membrane'\n` : ''
|
|
746
746
|
|
|
747
747
|
// Generate default export based on capsule type
|
|
748
748
|
let defaultExport = ''
|
|
@@ -1072,7 +1072,7 @@ ${defaultExport}
|
|
|
1072
1072
|
const mappedCapsuleExpression = rewriteCapsuleExpressionWithCST(mappedCapsule)
|
|
1073
1073
|
|
|
1074
1074
|
// Add runtime imports for standalone functions
|
|
1075
|
-
const mappedRuntimeImport = mappedHasStandalone ? `"use client"\nimport { Spine, SpineRuntime } from '@stream44.studio/encapsulate/encapsulate'\nimport { CapsuleSpineContract } from '@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0/Membrane
|
|
1075
|
+
const mappedRuntimeImport = mappedHasStandalone ? `"use client"\nimport { Spine, SpineRuntime } from '@stream44.studio/encapsulate/encapsulate'\nimport { CapsuleSpineContract } from '@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0/Membrane'\n` : ''
|
|
1076
1076
|
|
|
1077
1077
|
let mappedDefaultExport = ''
|
|
1078
1078
|
if (mappedHasStandalone) {
|
package/src/encapsulate.ts
CHANGED
|
@@ -125,6 +125,7 @@ export const CapsulePropertyTypes = {
|
|
|
125
125
|
Init: 'Init' as const,
|
|
126
126
|
Dispose: 'Dispose' as const,
|
|
127
127
|
OnFreeze: 'OnFreeze' as const,
|
|
128
|
+
ProxyFunction: 'ProxyFunction' as const,
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
// ##################################################
|
|
@@ -198,8 +199,8 @@ export async function SpineRuntime(options: TSpineRuntimeOptions): Promise<TSpin
|
|
|
198
199
|
|
|
199
200
|
// If the value is a raw capsule instance (has spineContractCapsuleInstances
|
|
200
201
|
// but is NOT a Proxy that handles API access), unwrap it
|
|
201
|
-
// Static
|
|
202
|
-
// Membrane
|
|
202
|
+
// Static sets apiTarget[property.name] = mappedInstance (raw)
|
|
203
|
+
// Membrane sets apiTarget[property.name] = new Proxy(...) which handles API access
|
|
203
204
|
if (value && typeof value === 'object' && value.spineContractCapsuleInstances) {
|
|
204
205
|
// Check if this is a raw capsule instance by seeing if it has .api
|
|
205
206
|
// and the .api doesn't have the same spineContractCapsuleInstances
|
|
@@ -531,7 +532,7 @@ export async function Spine(options: TSpineOptions): Promise<TSpine> {
|
|
|
531
532
|
const mappedProjectionPath = mappedInstance.mappedPropertyName?.startsWith('/') ? mappedInstance.mappedPropertyName : projectionPath
|
|
532
533
|
// For regular mapped capsules (non-struct delegates), use their own CST as
|
|
533
534
|
// parentCapsuleCst so nested property contract delegates see the correct parent.
|
|
534
|
-
// Property contract delegates (flagged by Static
|
|
535
|
+
// Property contract delegates (flagged by Static) pass through the current
|
|
535
536
|
// parentCapsuleCst so their OnFreeze sees the declaring capsule's CST.
|
|
536
537
|
const mappedCapsule = (!mappedInstance.isPropertyContractDelegate && mappedInstance.capsuleName) ? capsules[mappedInstance.capsuleName] : undefined
|
|
537
538
|
const childParentCst = mappedCapsule?.cst || parentCapsuleCst
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CapsulePropertyTypes } from "../../encapsulate"
|
|
2
|
-
import { ContractCapsuleInstanceFactory, CapsuleInstanceRegistry } from "./Static
|
|
2
|
+
import { ContractCapsuleInstanceFactory, CapsuleInstanceRegistry } from "./Static"
|
|
3
3
|
|
|
4
4
|
type CallerContext = {
|
|
5
5
|
capsuleSourceLineRef: string
|
|
@@ -620,6 +620,86 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
|
|
|
620
620
|
})
|
|
621
621
|
}
|
|
622
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
|
+
|
|
623
703
|
protected mapGetterFunctionProperty({ property }: { property: any }) {
|
|
624
704
|
const getterFn = property.definition.value
|
|
625
705
|
const selfProxy = this.createSelfProxy()
|
|
@@ -749,16 +829,13 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
|
|
|
749
829
|
}
|
|
750
830
|
|
|
751
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
|
|
752
834
|
let value: any
|
|
753
835
|
let source: 'self' | 'encapsulatedApi' | 'childApi' | 'extendedApi' | undefined
|
|
754
836
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
source = 'self'
|
|
758
|
-
} else if (prop in factory.encapsulatedApi) {
|
|
759
|
-
value = factory.encapsulatedApi[prop]
|
|
760
|
-
source = 'encapsulatedApi'
|
|
761
|
-
} else if (factory.childEncapsulatedApis) {
|
|
837
|
+
// Check child capsule APIs first for virtual dispatch
|
|
838
|
+
if (factory.childEncapsulatedApis) {
|
|
762
839
|
for (const childApi of factory.childEncapsulatedApis) {
|
|
763
840
|
if (prop in childApi) {
|
|
764
841
|
value = childApi[prop]
|
|
@@ -768,6 +845,16 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
|
|
|
768
845
|
}
|
|
769
846
|
}
|
|
770
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
|
+
|
|
771
858
|
if (source === undefined && extendedApi && prop in extendedApi) {
|
|
772
859
|
value = extendedApi[prop]
|
|
773
860
|
source = 'extendedApi'
|
|
@@ -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
|
|---|---|---|
|
|
@@ -107,6 +107,8 @@ export class ContractCapsuleInstanceFactory {
|
|
|
107
107
|
this.mapDisposeProperty({ property })
|
|
108
108
|
} else if (property.definition.type === CapsulePropertyTypes.OnFreeze) {
|
|
109
109
|
this.mapOnFreezeProperty({ property })
|
|
110
|
+
} else if (property.definition.type === CapsulePropertyTypes.ProxyFunction) {
|
|
111
|
+
this.mapProxyFunctionProperty({ property })
|
|
110
112
|
}
|
|
111
113
|
}
|
|
112
114
|
|
|
@@ -134,11 +136,76 @@ export class ContractCapsuleInstanceFactory {
|
|
|
134
136
|
|
|
135
137
|
// Use cst.source.moduleFilepath (always filesystem-relative) for path resolution.
|
|
136
138
|
// encapsulateOptions.moduleFilepath may be an npm URI when loaded from projected files.
|
|
137
|
-
|
|
139
|
+
// However, cst.source.moduleFilepath may be relative to a different package root than
|
|
140
|
+
// the current spineFilesystemRoot (e.g. cross-package capsule references). In that case,
|
|
141
|
+
// fall back to encapsulateOptions.moduleFilepath which is always relative to current spine root.
|
|
142
|
+
let moduleFilepath = this.capsule.cst?.source?.moduleFilepath || this.capsule.encapsulateOptions?.moduleFilepath
|
|
138
143
|
if (!moduleFilepath) throw new Error(`'moduleFilepath' not available on capsule!`)
|
|
139
144
|
|
|
140
|
-
|
|
141
|
-
|
|
145
|
+
// moduleFilepath may be a relative filesystem path (e.g. "../../../caps/Foo.ts")
|
|
146
|
+
// or an npm-style URI (e.g. "@stream44.studio/t44-docker.com/caps/Project")
|
|
147
|
+
// after freeze/hoist cycles. join(spineRoot, npmUri) produces a bogus path when
|
|
148
|
+
// the URI is npm-style and spineRoot is narrow. Detect this by checking if the
|
|
149
|
+
// moduleFilepath looks like an npm URI (starts with @, or doesn't start with / or .),
|
|
150
|
+
// and resolve it to get the actual filesystem path.
|
|
151
|
+
let parentPath = join(this.spineFilesystemRoot, moduleFilepath)
|
|
152
|
+
if (!moduleFilepath.startsWith('/') && !moduleFilepath.startsWith('.')) {
|
|
153
|
+
// Looks like an npm-style URI or filesystem-convention path — resolve it
|
|
154
|
+
try {
|
|
155
|
+
// Try with @ prefix first (npm URI convention)
|
|
156
|
+
if (moduleFilepath.startsWith('@')) {
|
|
157
|
+
parentPath = await this.resolve(moduleFilepath, this.spineFilesystemRoot)
|
|
158
|
+
} else {
|
|
159
|
+
// May be a filesystem-convention path like "scope/packages/pkg/path"
|
|
160
|
+
// Try resolving as @scope/pkg/path by extracting scope and package name
|
|
161
|
+
const parts = moduleFilepath.split('/')
|
|
162
|
+
if (parts.length >= 3 && parts[1] === 'packages') {
|
|
163
|
+
const scope = parts[0]
|
|
164
|
+
const pkg = parts[2]
|
|
165
|
+
const subpath = parts.slice(3).join('/')
|
|
166
|
+
const npmUri = subpath ? `@${scope}/${pkg}/${subpath}` : `@${scope}/${pkg}`
|
|
167
|
+
parentPath = await this.resolve(npmUri, this.spineFilesystemRoot)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
// Fall back to the joined path if resolution fails
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
let filepath: string
|
|
175
|
+
try {
|
|
176
|
+
filepath = await this.resolve(property.definition.value, parentPath)
|
|
177
|
+
} catch (resolveError) {
|
|
178
|
+
// cst.source.moduleFilepath may be relative to a different package root than spineFilesystemRoot
|
|
179
|
+
// (e.g. cross-package capsule references). Fall back to encapsulateOptions.moduleFilepath
|
|
180
|
+
// which is always relative to the current spine root.
|
|
181
|
+
const fallbackModuleFilepath = this.capsule.encapsulateOptions?.moduleFilepath
|
|
182
|
+
if (fallbackModuleFilepath && fallbackModuleFilepath !== moduleFilepath) {
|
|
183
|
+
// The fallback moduleFilepath may be a relative path or an npm URI.
|
|
184
|
+
// Apply the same resolution logic as the primary path.
|
|
185
|
+
let fallbackParentPath = join(this.spineFilesystemRoot, fallbackModuleFilepath)
|
|
186
|
+
if (!fallbackModuleFilepath.startsWith('/') && !fallbackModuleFilepath.startsWith('.')) {
|
|
187
|
+
try {
|
|
188
|
+
if (fallbackModuleFilepath.startsWith('@')) {
|
|
189
|
+
fallbackParentPath = await this.resolve!(fallbackModuleFilepath, this.spineFilesystemRoot)
|
|
190
|
+
} else {
|
|
191
|
+
const parts = fallbackModuleFilepath.split('/')
|
|
192
|
+
if (parts.length >= 3 && parts[1] === 'packages') {
|
|
193
|
+
const scope = parts[0]
|
|
194
|
+
const pkg = parts[2]
|
|
195
|
+
const subpath = parts.slice(3).join('/')
|
|
196
|
+
const npmUri = subpath ? `@${scope}/${pkg}/${subpath}` : `@${scope}/${pkg}`
|
|
197
|
+
fallbackParentPath = await this.resolve!(npmUri, this.spineFilesystemRoot)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
// Fall back to the joined path
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
filepath = await this.resolve(property.definition.value, fallbackParentPath)
|
|
205
|
+
} else {
|
|
206
|
+
throw resolveError
|
|
207
|
+
}
|
|
208
|
+
}
|
|
142
209
|
mappedCapsule = await this.importCapsule(filepath)
|
|
143
210
|
} else if (
|
|
144
211
|
typeof property.definition.value === 'object' &&
|
|
@@ -405,21 +472,29 @@ export class ContractCapsuleInstanceFactory {
|
|
|
405
472
|
|
|
406
473
|
// First check if the property exists in target (this.self)
|
|
407
474
|
if (prop in target) {
|
|
475
|
+
// Virtual dispatch: if child has an override for this property,
|
|
476
|
+
// prefer the child's version over self (which may hold the parent's bound fn)
|
|
477
|
+
if (factory.childEncapsulatedApis) {
|
|
478
|
+
for (const childApi of factory.childEncapsulatedApis) {
|
|
479
|
+
if (prop in childApi) return childApi[prop]
|
|
480
|
+
}
|
|
481
|
+
}
|
|
408
482
|
return target[prop]
|
|
409
483
|
}
|
|
410
484
|
|
|
411
|
-
//
|
|
412
|
-
|
|
413
|
-
return factory.encapsulatedApi[prop]
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// Fall back to child capsule APIs (for parent→child function delegation)
|
|
485
|
+
// Check child capsule APIs (virtual dispatch —
|
|
486
|
+
// child overrides take precedence over parent's own API)
|
|
417
487
|
if (factory.childEncapsulatedApis) {
|
|
418
488
|
for (const childApi of factory.childEncapsulatedApis) {
|
|
419
489
|
if (prop in childApi) return childApi[prop]
|
|
420
490
|
}
|
|
421
491
|
}
|
|
422
492
|
|
|
493
|
+
// Fall back to own encapsulatedApi
|
|
494
|
+
if (prop in factory.encapsulatedApi) {
|
|
495
|
+
return factory.encapsulatedApi[prop]
|
|
496
|
+
}
|
|
497
|
+
|
|
423
498
|
// Fall back to extended capsule's API
|
|
424
499
|
if (extendedApi && prop in extendedApi) {
|
|
425
500
|
return extendedApi[prop]
|
|
@@ -447,12 +522,12 @@ export class ContractCapsuleInstanceFactory {
|
|
|
447
522
|
getOwnPropertyDescriptor: (target: any, prop: string | symbol) => {
|
|
448
523
|
if (typeof prop === 'symbol') return Object.getOwnPropertyDescriptor(target, prop)
|
|
449
524
|
if (prop in target) return Object.getOwnPropertyDescriptor(target, prop)
|
|
450
|
-
if (prop in factory.encapsulatedApi) return { configurable: true, enumerable: true, writable: true, value: factory.encapsulatedApi[prop as string] }
|
|
451
525
|
if (factory.childEncapsulatedApis) {
|
|
452
526
|
for (const childApi of factory.childEncapsulatedApis) {
|
|
453
527
|
if (prop in childApi) return { configurable: true, enumerable: true, writable: true, value: childApi[prop as string] }
|
|
454
528
|
}
|
|
455
529
|
}
|
|
530
|
+
if (prop in factory.encapsulatedApi) return { configurable: true, enumerable: true, writable: true, value: factory.encapsulatedApi[prop as string] }
|
|
456
531
|
if (extendedApi && prop in extendedApi) return { configurable: true, enumerable: true, writable: true, value: extendedApi[prop as string] }
|
|
457
532
|
return undefined
|
|
458
533
|
}
|
|
@@ -565,6 +640,38 @@ export class ContractCapsuleInstanceFactory {
|
|
|
565
640
|
}
|
|
566
641
|
}
|
|
567
642
|
|
|
643
|
+
protected mapProxyFunctionProperty({ property }: { property: any }) {
|
|
644
|
+
const apiTarget = this.getApiTarget({ property })
|
|
645
|
+
const selfProxy = this.createSelfProxy()
|
|
646
|
+
|
|
647
|
+
const childTargetFn = property.definition.value.target
|
|
648
|
+
const childInvokeFn = property.definition.value.invoke
|
|
649
|
+
|
|
650
|
+
// Inherit missing parts from parent's ProxyFunction (if extending)
|
|
651
|
+
const parentParts = this.self[`__proxyFn_${property.name}`]
|
|
652
|
+
const targetFn = childTargetFn ?? parentParts?.target
|
|
653
|
+
const invokeFn = childInvokeFn ?? parentParts?.invoke
|
|
654
|
+
|
|
655
|
+
if (!targetFn) throw new Error(`ProxyFunction '${property.name}': target() is required`)
|
|
656
|
+
if (!invokeFn) throw new Error(`ProxyFunction '${property.name}': invoke() is required`)
|
|
657
|
+
|
|
658
|
+
// Store parts for potential child override
|
|
659
|
+
this.self[`__proxyFn_${property.name}`] = { target: targetFn, invoke: invokeFn }
|
|
660
|
+
|
|
661
|
+
apiTarget[property.name] = (...args: any[]) => {
|
|
662
|
+
// 1. Call invoke() bound to selfProxy to transform args
|
|
663
|
+
const transformedArgs = invokeFn.call(selfProxy, ...args)
|
|
664
|
+
// 2. Call target() bound to selfProxy to get the function to call
|
|
665
|
+
const target = targetFn.call(selfProxy)
|
|
666
|
+
// 3. If invoke returned a promise, await it then call target
|
|
667
|
+
if (transformedArgs && typeof transformedArgs.then === 'function') {
|
|
668
|
+
return transformedArgs.then((resolved: any) => target(resolved))
|
|
669
|
+
}
|
|
670
|
+
// 4. Call target with the transformed args
|
|
671
|
+
return target(transformedArgs)
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
568
675
|
protected mapSetterFunctionProperty({ property }: { property: any }) {
|
|
569
676
|
const apiTarget = this.getApiTarget({ property })
|
|
570
677
|
const setterFn = property.definition.value
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { join, dirname, relative, resolve as pathResolve } from 'path'
|
|
2
2
|
import { writeFile, mkdir, readFile, stat } from 'fs/promises'
|
|
3
3
|
import { Spine, SpineRuntime, CapsulePropertyTypes, makeImportStack, merge } from "../encapsulate"
|
|
4
|
-
import { StaticAnalyzer } from "../../src/static-analyzer
|
|
5
|
-
import { CapsuleModuleProjector } from "../../src/capsule-projectors/CapsuleModuleProjector
|
|
4
|
+
import { StaticAnalyzer } from "../../src/static-analyzer"
|
|
5
|
+
import { CapsuleModuleProjector } from "../../src/capsule-projectors/CapsuleModuleProjector"
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
export { merge }
|
|
@@ -129,10 +129,39 @@ async function resolve(uri: string, fromPath: string, spineRoot?: string): Promi
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
// Also traverse up from fromPath (the importing file) checking:
|
|
132
|
-
// 1.
|
|
133
|
-
// 2.
|
|
132
|
+
// 1. The scope/packages/pkg filesystem convention at each level
|
|
133
|
+
// 2. If current dir IS the package (self-package resolution)
|
|
134
|
+
// 3. node_modules/@scope/pkg at each level
|
|
134
135
|
let fromDir = dirname(fromPath)
|
|
135
136
|
while (true) {
|
|
137
|
+
// Check scope/packages/pkg filesystem convention
|
|
138
|
+
if (subpath) {
|
|
139
|
+
const fsPath = join(fromDir, scope, 'packages', pkg, subpath + '.ts')
|
|
140
|
+
try { await stat(fsPath); return fsPath } catch { }
|
|
141
|
+
try { await stat(join(fromDir, scope, 'packages', pkg, subpath)); return join(fromDir, scope, 'packages', pkg, subpath) } catch { }
|
|
142
|
+
}
|
|
143
|
+
const fsPkgDir = join(fromDir, scope, 'packages', pkg)
|
|
144
|
+
try {
|
|
145
|
+
const fsPjPath = join(fsPkgDir, 'package.json')
|
|
146
|
+
const fsPj = JSON.parse(await readFile(fsPjPath, 'utf-8'))
|
|
147
|
+
if (fsPj.name === `@${scope}/${pkg}`) {
|
|
148
|
+
if (subpath && fsPj.exports) {
|
|
149
|
+
const exportKey = './' + subpath
|
|
150
|
+
const exportValue = fsPj.exports[exportKey]
|
|
151
|
+
if (typeof exportValue === 'string') {
|
|
152
|
+
return pathResolve(fsPkgDir, exportValue)
|
|
153
|
+
}
|
|
154
|
+
} else if (!subpath && fsPj.exports?.['.']) {
|
|
155
|
+
const mainExport = fsPj.exports['.']
|
|
156
|
+
if (typeof mainExport === 'string') {
|
|
157
|
+
return pathResolve(fsPkgDir, mainExport)
|
|
158
|
+
}
|
|
159
|
+
} else if (!subpath && fsPj.main) {
|
|
160
|
+
return pathResolve(fsPkgDir, fsPj.main)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch { }
|
|
164
|
+
|
|
136
165
|
// Check if this directory's package.json matches the requested package
|
|
137
166
|
try {
|
|
138
167
|
const pjPath = join(fromDir, 'package.json')
|
|
@@ -85,6 +85,7 @@ const MODULE_GLOBAL_BUILTINS = new Set([
|
|
|
85
85
|
|
|
86
86
|
// Bun runtime
|
|
87
87
|
'Bun',
|
|
88
|
+
'HTMLRewriter',
|
|
88
89
|
|
|
89
90
|
// Node.js Buffer
|
|
90
91
|
'Buffer',
|
|
@@ -218,6 +219,7 @@ const MODULE_GLOBAL_BUILTINS = new Set([
|
|
|
218
219
|
'decodeURIComponent',
|
|
219
220
|
'escape',
|
|
220
221
|
'unescape',
|
|
222
|
+
'eval',
|
|
221
223
|
])
|
|
222
224
|
|
|
223
225
|
export function StaticAnalyzer({
|
|
@@ -1939,6 +1941,19 @@ function extractAndValidateAmbientReferences(
|
|
|
1939
1941
|
extractBindingIdentifiers(param.name)
|
|
1940
1942
|
}
|
|
1941
1943
|
|
|
1944
|
+
// Pre-collect all function declarations from the function body (hoisting)
|
|
1945
|
+
// Function declarations are hoisted in JavaScript, so they can be referenced
|
|
1946
|
+
// before their source position. We must collect them before the main visit pass.
|
|
1947
|
+
function preCollectDeclarations(node: ts.Node) {
|
|
1948
|
+
if (ts.isFunctionDeclaration(node) && node.name && ts.isIdentifier(node.name)) {
|
|
1949
|
+
localIdentifiers.add(node.name.text)
|
|
1950
|
+
}
|
|
1951
|
+
ts.forEachChild(node, preCollectDeclarations)
|
|
1952
|
+
}
|
|
1953
|
+
if (fn.body) {
|
|
1954
|
+
preCollectDeclarations(fn.body)
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1942
1957
|
// Traverse the function body to find identifiers
|
|
1943
1958
|
function visit(node: ts.Node) {
|
|
1944
1959
|
// Skip type nodes to avoid false positives from type annotations
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
|
|
2
|
+
export async function capsule({
|
|
3
|
+
encapsulate,
|
|
4
|
+
CapsulePropertyTypes,
|
|
5
|
+
makeImportStack
|
|
6
|
+
}: any) {
|
|
7
|
+
return encapsulate({
|
|
8
|
+
'#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
|
|
9
|
+
'#': {
|
|
10
|
+
// --------------------------------------------------------
|
|
11
|
+
// _instances — internal store for keyed instances
|
|
12
|
+
// --------------------------------------------------------
|
|
13
|
+
_instances: {
|
|
14
|
+
type: CapsulePropertyTypes.GetterFunction,
|
|
15
|
+
value: function () {
|
|
16
|
+
return new Map<string, any>();
|
|
17
|
+
},
|
|
18
|
+
memoize: true,
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
// --------------------------------------------------------
|
|
22
|
+
// _defaults — override in extending structs to provide
|
|
23
|
+
// default config for each new instance
|
|
24
|
+
// --------------------------------------------------------
|
|
25
|
+
_defaults: {
|
|
26
|
+
type: CapsulePropertyTypes.GetterFunction,
|
|
27
|
+
value: function () {
|
|
28
|
+
return {};
|
|
29
|
+
},
|
|
30
|
+
memoize: true,
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
// --------------------------------------------------------
|
|
34
|
+
// config — user-provided overrides applied to all instances
|
|
35
|
+
// --------------------------------------------------------
|
|
36
|
+
config: {
|
|
37
|
+
type: CapsulePropertyTypes.Literal,
|
|
38
|
+
value: {} as any,
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
// --------------------------------------------------------
|
|
42
|
+
// forInstance — get-or-create a config instance by key
|
|
43
|
+
// Pass an object whose keys together form a composite key.
|
|
44
|
+
// Returns a merged config (defaults + factory config + instance overrides).
|
|
45
|
+
// --------------------------------------------------------
|
|
46
|
+
forInstance: {
|
|
47
|
+
type: CapsulePropertyTypes.Function,
|
|
48
|
+
value: function (this: any, keyObj: Record<string, any>, instanceConfig?: Record<string, any>) {
|
|
49
|
+
const serializedKey = JSON.stringify(
|
|
50
|
+
Object.keys(keyObj).sort().reduce((acc: any, k: string) => {
|
|
51
|
+
acc[k] = keyObj[k];
|
|
52
|
+
return acc;
|
|
53
|
+
}, {} as any)
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
if (!instanceConfig && this._instances.has(serializedKey)) {
|
|
57
|
+
return this._instances.get(serializedKey);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const merged = {
|
|
61
|
+
...this._defaults,
|
|
62
|
+
...this.config,
|
|
63
|
+
...keyObj,
|
|
64
|
+
...(instanceConfig || {}),
|
|
65
|
+
...(this._instances.get(serializedKey) || {}),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
this._instances.set(serializedKey, merged);
|
|
69
|
+
return merged;
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
// --------------------------------------------------------
|
|
74
|
+
// getInstances — return all tracked instances
|
|
75
|
+
// --------------------------------------------------------
|
|
76
|
+
getInstances: {
|
|
77
|
+
type: CapsulePropertyTypes.Function,
|
|
78
|
+
value: function (this: any) {
|
|
79
|
+
return Array.from(this._instances.values());
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}, {
|
|
85
|
+
importMeta: import.meta,
|
|
86
|
+
importStack: makeImportStack(),
|
|
87
|
+
capsuleName: capsule['#'],
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
capsule['#'] = '@stream44.studio/encapsulate/structs/StructFactory'
|