@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.
@@ -23,6 +23,7 @@ export class ContractCapsuleInstanceFactory {
23
23
  public structDisposeFunctions: Array<() => any> = []
24
24
  public initFunctions: Array<() => any> = []
25
25
  public disposeFunctions: Array<() => any> = []
26
+ public onFreezeFunctions: Array<() => any> = []
26
27
  public mappedCapsuleInstances: Array<any> = []
27
28
  protected memoizeCache: Map<string, any> = new Map()
28
29
  protected memoizeTimeouts: Map<string, ReturnType<typeof setTimeout>> = new Map()
@@ -104,6 +105,10 @@ export class ContractCapsuleInstanceFactory {
104
105
  this.mapInitProperty({ property })
105
106
  } else if (property.definition.type === CapsulePropertyTypes.Dispose) {
106
107
  this.mapDisposeProperty({ property })
108
+ } else if (property.definition.type === CapsulePropertyTypes.OnFreeze) {
109
+ this.mapOnFreezeProperty({ property })
110
+ } else if (property.definition.type === CapsulePropertyTypes.ProxyFunction) {
111
+ this.mapProxyFunctionProperty({ property })
107
112
  }
108
113
  }
109
114
 
@@ -131,11 +136,76 @@ export class ContractCapsuleInstanceFactory {
131
136
 
132
137
  // Use cst.source.moduleFilepath (always filesystem-relative) for path resolution.
133
138
  // encapsulateOptions.moduleFilepath may be an npm URI when loaded from projected files.
134
- 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
135
143
  if (!moduleFilepath) throw new Error(`'moduleFilepath' not available on capsule!`)
136
144
 
137
- const parentPath = join(this.spineFilesystemRoot, moduleFilepath)
138
- 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
+ }
139
209
  mappedCapsule = await this.importCapsule(filepath)
140
210
  } else if (
141
211
  typeof property.definition.value === 'object' &&
@@ -212,9 +282,12 @@ export class ContractCapsuleInstanceFactory {
212
282
 
213
283
  // Check for existing instance in registry - reuse if available when no options
214
284
  // Pre-registration with null allows parent capsules to "claim" a slot before child capsules process
215
- // Property contract delegates (structs) always get a fresh instance per parent capsule
285
+ // Property contract delegates (structs) always get a fresh instance per parent capsule.
286
+ // CapsuleProjectionContext also needs fresh instances so context injection can find it
287
+ // in mappedCapsuleInstances during freeze traversal.
216
288
  const capsuleName = mappedCapsule.encapsulateOptions?.capsuleName
217
289
  const isCapsuleStruct = property.definition.propertyContractDelegate === '#@stream44.studio/encapsulate/structs/Capsule'
290
+ || property.definition.propertyContractDelegate === '#@stream44.studio/encapsulate/structs/CapsuleProjectionContext'
218
291
 
219
292
  if (capsuleName && this.instanceRegistry && !isCapsuleStruct) {
220
293
  if (this.instanceRegistry.has(capsuleName)) {
@@ -335,6 +408,10 @@ export class ContractCapsuleInstanceFactory {
335
408
  }
336
409
 
337
410
  apiTarget[property.name] = mappedInstance
411
+ mappedInstance.mappedPropertyName = property.name
412
+ if (property.definition.propertyContractDelegate) {
413
+ mappedInstance.isPropertyContractDelegate = true
414
+ }
338
415
  this.mappedCapsuleInstances.push(mappedInstance)
339
416
  // Use proxy to unwrap .api for this.self so internal references work
340
417
  this.self[property.name] = mappedInstance.api ? new Proxy(mappedInstance.api, {
@@ -349,18 +426,17 @@ export class ContractCapsuleInstanceFactory {
349
426
  }) : mappedInstance
350
427
 
351
428
  // If this mapping has a propertyContractDelegate, also mount the mapped capsule's API
352
- // to the property contract namespace for direct access
429
+ // to the property contract namespace for direct access.
430
+ // Use a proxy so that later mutations to the delegate's API (e.g. CapsuleProjectionContext
431
+ // injection during freeze) are visible through the parent's encapsulatedApi.
353
432
  if (property.definition.propertyContractDelegate) {
354
- // Create the property contract namespace if it doesn't exist
355
- if (!this.encapsulatedApi[property.definition.propertyContractDelegate]) {
356
- this.encapsulatedApi[property.definition.propertyContractDelegate] = {}
357
- }
358
-
359
- // Mount all properties from the mapped capsule's API to the property contract namespace
360
- const delegateTarget = this.encapsulatedApi[property.definition.propertyContractDelegate]
361
- for (const [key, value] of Object.entries(mappedInstance.api)) {
362
- delegateTarget[key] = value
363
- }
433
+ this.encapsulatedApi[property.definition.propertyContractDelegate] = new Proxy(mappedInstance.api, {
434
+ get: (target: any, prop: string | symbol) => target[prop],
435
+ set: (target: any, prop: string | symbol, value: any) => { target[prop] = value; return true },
436
+ ownKeys: (target: any) => Reflect.ownKeys(target),
437
+ getOwnPropertyDescriptor: (target: any, prop: string | symbol) => Object.getOwnPropertyDescriptor(target, prop) || { configurable: true, enumerable: true, writable: true, value: target[prop] },
438
+ has: (target: any, prop: string | symbol) => prop in target,
439
+ })
364
440
  }
365
441
  }
366
442
 
@@ -396,21 +472,29 @@ export class ContractCapsuleInstanceFactory {
396
472
 
397
473
  // First check if the property exists in target (this.self)
398
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
+ }
399
482
  return target[prop]
400
483
  }
401
484
 
402
- // Fall back to encapsulatedApi
403
- if (prop in factory.encapsulatedApi) {
404
- return factory.encapsulatedApi[prop]
405
- }
406
-
407
- // 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)
408
487
  if (factory.childEncapsulatedApis) {
409
488
  for (const childApi of factory.childEncapsulatedApis) {
410
489
  if (prop in childApi) return childApi[prop]
411
490
  }
412
491
  }
413
492
 
493
+ // Fall back to own encapsulatedApi
494
+ if (prop in factory.encapsulatedApi) {
495
+ return factory.encapsulatedApi[prop]
496
+ }
497
+
414
498
  // Fall back to extended capsule's API
415
499
  if (extendedApi && prop in extendedApi) {
416
500
  return extendedApi[prop]
@@ -438,12 +522,12 @@ export class ContractCapsuleInstanceFactory {
438
522
  getOwnPropertyDescriptor: (target: any, prop: string | symbol) => {
439
523
  if (typeof prop === 'symbol') return Object.getOwnPropertyDescriptor(target, prop)
440
524
  if (prop in target) return Object.getOwnPropertyDescriptor(target, prop)
441
- if (prop in factory.encapsulatedApi) return { configurable: true, enumerable: true, writable: true, value: factory.encapsulatedApi[prop as string] }
442
525
  if (factory.childEncapsulatedApis) {
443
526
  for (const childApi of factory.childEncapsulatedApis) {
444
527
  if (prop in childApi) return { configurable: true, enumerable: true, writable: true, value: childApi[prop as string] }
445
528
  }
446
529
  }
530
+ if (prop in factory.encapsulatedApi) return { configurable: true, enumerable: true, writable: true, value: factory.encapsulatedApi[prop as string] }
447
531
  if (extendedApi && prop in extendedApi) return { configurable: true, enumerable: true, writable: true, value: extendedApi[prop as string] }
448
532
  return undefined
449
533
  }
@@ -556,6 +640,38 @@ export class ContractCapsuleInstanceFactory {
556
640
  }
557
641
  }
558
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
+
559
675
  protected mapSetterFunctionProperty({ property }: { property: any }) {
560
676
  const apiTarget = this.getApiTarget({ property })
561
677
  const setterFn = property.definition.value
@@ -606,6 +722,12 @@ export class ContractCapsuleInstanceFactory {
606
722
  this.disposeFunctions.push(boundFunction)
607
723
  }
608
724
 
725
+ protected mapOnFreezeProperty({ property }: { property: any }) {
726
+ const selfProxy = this.createSelfProxy()
727
+ const boundFunction = property.definition.value.bind(selfProxy)
728
+ this.onFreezeFunctions.push(boundFunction)
729
+ }
730
+
609
731
  async freeze(options: any): Promise<any> {
610
732
  return this.freezeCapsule?.(options) || {}
611
733
  }
@@ -1,8 +1,8 @@
1
- import { join, dirname, resolve as pathResolve } from 'path'
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')
@@ -278,6 +307,65 @@ async function resolve(uri: string, fromPath: string, spineRoot?: string): Promi
278
307
  }
279
308
 
280
309
 
310
+ // Build an async npmUriForFilepath resolver backed by a directory→package-name cache.
311
+ // Walks up the directory tree from a given filepath to find the nearest package.json
312
+ // and constructs an npm-style URI (e.g. "package-name/src/foo.ts").
313
+ function createNpmUriForFilepath(): (filepath: string) => Promise<string | null> {
314
+ const cache = new Map<string, string | null>()
315
+
316
+ return async (absoluteFilepath: string): Promise<string | null> => {
317
+ // Only process absolute paths — skip V8 internal markers like "native", "node:*", etc.
318
+ if (!absoluteFilepath.startsWith('/')) {
319
+ return null
320
+ }
321
+
322
+ // Check for /node_modules/ in the path — use the last occurrence to handle nested node_modules
323
+ const nodeModulesMarker = '/node_modules/'
324
+ const lastIdx = absoluteFilepath.lastIndexOf(nodeModulesMarker)
325
+ if (lastIdx !== -1) {
326
+ return absoluteFilepath.substring(lastIdx + nodeModulesMarker.length)
327
+ }
328
+
329
+ let currentDir = dirname(absoluteFilepath)
330
+ const maxDepth = 20
331
+
332
+ for (let i = 0; i < maxDepth; i++) {
333
+ if (cache.has(currentDir)) {
334
+ const cachedName = cache.get(currentDir)
335
+ if (cachedName) {
336
+ const relativeFromPackage = relative(currentDir, absoluteFilepath)
337
+ return `${cachedName}/${relativeFromPackage}`
338
+ }
339
+ // null means no package.json with name found at this level, continue up
340
+ const parentDir = dirname(currentDir)
341
+ if (parentDir === currentDir) break
342
+ currentDir = parentDir
343
+ continue
344
+ }
345
+
346
+ const packageJsonPath = join(currentDir, 'package.json')
347
+ try {
348
+ await stat(packageJsonPath)
349
+ const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf-8'))
350
+ const packageName = packageJson.name
351
+ cache.set(currentDir, packageName || null)
352
+ if (packageName) {
353
+ const relativeFromPackage = relative(currentDir, absoluteFilepath)
354
+ return `${packageName}/${relativeFromPackage}`
355
+ }
356
+ } catch {
357
+ cache.set(currentDir, null)
358
+ }
359
+
360
+ const parentDir = dirname(currentDir)
361
+ if (parentDir === currentDir) break
362
+ currentDir = parentDir
363
+ }
364
+
365
+ return null
366
+ }
367
+ }
368
+
281
369
  export async function CapsuleSpineFactory({
282
370
  spineFilesystemRoot,
283
371
  capsuleModuleProjectionRoot,
@@ -329,8 +417,10 @@ export async function CapsuleSpineFactory({
329
417
  }
330
418
 
331
419
  const sourceSpine: { encapsulate?: any } = {}
420
+ const npmUriForFilepath = createNpmUriForFilepath()
332
421
  const commonSpineContractOpts = {
333
422
  spineFilesystemRoot,
423
+ npmUriForFilepath,
334
424
  resolve: async (uri: string, parentFilepath: string) => {
335
425
  // For relative paths, join with parent directory first
336
426
  if (/^\.\.?\//.test(uri)) {
@@ -537,7 +627,28 @@ export async function CapsuleSpineFactory({
537
627
  },
538
628
  },
539
629
  }) : undefined,
540
- spineContracts: spineContractInstances.encapsulation
630
+ spineContracts: spineContractInstances.encapsulation,
631
+ projectionContext: capsuleModuleProjectionRoot ? {
632
+ capsuleModuleProjectionPackage,
633
+ capsuleModuleProjectionRoot,
634
+ projectionStore: {
635
+ writeFile: async (filepath: string, content: string) => {
636
+ filepath = join(capsuleModuleProjectionRoot, filepath)
637
+ await mkdir(dirname(filepath), { recursive: true })
638
+ await writeFile(filepath, content, 'utf-8')
639
+ },
640
+ getStats: async (filepath: string) => {
641
+ filepath = join(capsuleModuleProjectionRoot, filepath)
642
+ try {
643
+ const stats = await stat(filepath)
644
+ return { mtime: stats.mtime }
645
+ } catch (error) {
646
+ return null
647
+ }
648
+ },
649
+ },
650
+ get capsules() { return capsules }
651
+ } : undefined
541
652
  })
542
653
  sourceSpine.encapsulate = encapsulate
543
654
 
@@ -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,53 @@
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
+ // The parent capsule's full CST (source, spineContracts, ambient references, etc.)
11
+ parentCapsuleCst: {
12
+ type: CapsulePropertyTypes.Literal,
13
+ value: undefined
14
+ },
15
+ // The parent capsule's capsuleSourceLineRef
16
+ parentCapsuleSourceLineRef: {
17
+ type: CapsulePropertyTypes.Literal,
18
+ value: undefined
19
+ },
20
+ // Package prefix for projected capsule imports (e.g. '~caps')
21
+ capsuleModuleProjectionPackage: {
22
+ type: CapsulePropertyTypes.Literal,
23
+ value: undefined
24
+ },
25
+ // Store for writing projected files
26
+ projectionStore: {
27
+ type: CapsulePropertyTypes.Literal,
28
+ value: undefined
29
+ },
30
+ // All capsule snapshots for dependency resolution
31
+ capsuleSnapshots: {
32
+ type: CapsulePropertyTypes.Literal,
33
+ value: undefined
34
+ },
35
+ // The property contract delegate alias (e.g. '/apps/web/src/components/Counter1.tsx')
36
+ projectionPath: {
37
+ type: CapsulePropertyTypes.Literal,
38
+ value: undefined
39
+ },
40
+ // Spine contract URI
41
+ spineContractUri: {
42
+ type: CapsulePropertyTypes.Literal,
43
+ value: undefined
44
+ },
45
+ }
46
+ }
47
+ }, {
48
+ importMeta: import.meta,
49
+ importStack: makeImportStack(),
50
+ capsuleName: capsule['#'],
51
+ })
52
+ }
53
+ capsule['#'] = '@stream44.studio/encapsulate/structs/CapsuleProjectionContext'
@@ -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'