@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  <table>
2
2
  <tr>
3
- <td><a href="https://Stream44.Studio"><img src=".o/stream44.studio/assets/Icon-v1.svg" width="42" height="42"></a></td>
4
- <td><strong><a href="https://Stream44.Studio">Stream44 Studio</a></strong><br/>Open Development Project</td>
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.38",
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.v0": "./src/spine-contracts/CapsuleSpineContract.v0/Static.v0.ts",
14
- "./spine-contracts/CapsuleSpineContract.v0/Membrane.v0": "./src/spine-contracts/CapsuleSpineContract.v0/Membrane.v0.ts",
15
- "./spine-factories/CapsuleSpineFactory.v0": "./src/spine-factories/CapsuleSpineFactory.v0.ts",
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.v0.ts
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.v0'\n` : ''
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.v0'\n` : ''
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) {
@@ -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.v0 sets apiTarget[property.name] = mappedInstance (raw)
202
- // 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
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.v0) pass through the current
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.v0"
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
- if (prop in target) {
756
- value = target[prop]
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.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
  |---|---|---|
@@ -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
- const moduleFilepath = this.capsule.cst?.source?.moduleFilepath || this.capsule.encapsulateOptions?.moduleFilepath
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
- const parentPath = join(this.spineFilesystemRoot, moduleFilepath)
141
- const filepath = await this.resolve(property.definition.value, parentPath)
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
- // Fall back to encapsulatedApi
412
- if (prop in factory.encapsulatedApi) {
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.v0"
5
- import { CapsuleModuleProjector } from "../../src/capsule-projectors/CapsuleModuleProjector.v0"
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. If current dir IS the package (self-package resolution)
133
- // 2. node_modules/@scope/pkg at each level
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'