@stream44.studio/encapsulate 0.4.0-rc.32 → 0.4.0-rc.38

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream44.studio/encapsulate",
3
- "version": "0.4.0-rc.32",
3
+ "version": "0.4.0-rc.38",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -14,7 +14,8 @@
14
14
  "./spine-contracts/CapsuleSpineContract.v0/Membrane.v0": "./src/spine-contracts/CapsuleSpineContract.v0/Membrane.v0.ts",
15
15
  "./spine-factories/CapsuleSpineFactory.v0": "./src/spine-factories/CapsuleSpineFactory.v0.ts",
16
16
  "./spine-factories/TimingObserver": "./src/spine-factories/TimingObserver.ts",
17
- "./structs/Capsule": "./structs/Capsule.ts"
17
+ "./structs/Capsule": "./structs/Capsule.ts",
18
+ "./structs/CapsuleProjectionContext": "./structs/CapsuleProjectionContext.ts"
18
19
  },
19
20
  "scripts": {
20
21
  "test": "bun test",
@@ -199,6 +199,9 @@ export function CapsuleModuleProjector({
199
199
  for (const nestedPropName in propContract.properties) {
200
200
  if (nestedPropName.startsWith('/')) {
201
201
  const nestedProp = propContract.properties[nestedPropName]
202
+ // Skip if this mapping has a propertyContractDelegate — the delegate
203
+ // handles its own projection via OnFreeze
204
+ if (nestedProp.propertyContractDelegate) continue
202
205
  // Check if this is a Mapping type property
203
206
  if (nestedProp.type === 'CapsulePropertyTypes.Mapping') {
204
207
  // First try to find the mapped capsule in ambient references
@@ -287,23 +290,6 @@ export function CapsuleModuleProjector({
287
290
  return uris
288
291
  }
289
292
 
290
- // Helper: Check if capsule has solidjs.com/standalone property
291
- function hasSolidJsProperty(capsule: any, spineContractUri: string): boolean {
292
- const spineContract = capsule.cst.spineContracts[spineContractUri]
293
- // Check both top-level and nested under '#' property contract
294
- const topLevelProps = spineContract?.propertyContracts || {}
295
- const nestedProps = spineContract?.propertyContracts?.['#']?.properties || {}
296
-
297
- // Check for solidjs.com/standalone specifically
298
- for (const key of Object.keys(topLevelProps)) {
299
- if (key === 'solidjs.com/standalone') return true
300
- }
301
- for (const key of Object.keys(nestedProps)) {
302
- if (key === 'solidjs.com/standalone') return true
303
- }
304
- return false
305
- }
306
-
307
293
  // Helper: Check if capsule has encapsulate.dev/standalone property (with optional suffix)
308
294
  function hasStandaloneProperty(capsule: any, spineContractUri: string): boolean {
309
295
  const spineContract = capsule.cst.spineContracts[spineContractUri]
@@ -321,66 +307,6 @@ export function CapsuleModuleProjector({
321
307
  return false
322
308
  }
323
309
 
324
- // Helper: Extract SolidJS component function from capsule definition
325
- function extractSolidJsComponent(capsule: any, spineContractUri: string): string | null {
326
- const spineContract = capsule.cst.spineContracts[spineContractUri]
327
-
328
- // Check nested under '#' property contract first, looking for solidjs.com/standalone
329
- const nestedProps = spineContract?.propertyContracts?.['#']?.properties || {}
330
- const topLevelProps = spineContract?.propertyContracts || {}
331
-
332
- let solidjsProp = null
333
- for (const key of Object.keys(nestedProps)) {
334
- if (key === 'solidjs.com/standalone') {
335
- solidjsProp = nestedProps[key]
336
- break
337
- }
338
- }
339
- if (!solidjsProp) {
340
- for (const key of Object.keys(topLevelProps)) {
341
- if (key === 'solidjs.com/standalone') {
342
- solidjsProp = topLevelProps[key]
343
- break
344
- }
345
- }
346
- }
347
-
348
- if (!solidjsProp || solidjsProp.type !== 'CapsulePropertyTypes.Function') {
349
- return null
350
- }
351
-
352
- // Extract the value expression which contains the component function
353
- const valueExpression = solidjsProp.valueExpression
354
- if (!valueExpression) return null
355
-
356
- // The value expression is: "function (this: any): Function {\n return function ComponentName() { ... }\n}"
357
- // We need to extract the inner function after "return "
358
- // Use a more flexible regex that handles multiline and varying whitespace
359
- const match = valueExpression.match(/return\s+(function\s+\w*\s*\([^)]*\)\s*\{[\s\S]*)\s*\}\s*$/m)
360
- if (match) {
361
- // Clean up the extracted function - remove extra indentation
362
- let extracted = match[1].trim()
363
- // Add back the closing brace if it was removed
364
- if (!extracted.endsWith('}')) {
365
- extracted += '\n}'
366
- }
367
- // Remove leading indentation from each line
368
- const lines = extracted.split('\n')
369
- const minIndent = lines
370
- .filter(line => line.trim().length > 0)
371
- .map(line => line.match(/^(\s*)/)?.[1].length || 0)
372
- .reduce((min, indent) => Math.min(min, indent), Infinity)
373
-
374
- if (minIndent > 0 && minIndent !== Infinity) {
375
- extracted = lines.map(line => line.substring(minIndent)).join('\n')
376
- }
377
-
378
- return extracted
379
- }
380
-
381
- return null
382
- }
383
-
384
310
  // Helper: Extract standalone function from capsule definition
385
311
  function extractStandaloneFunction(capsule: any, spineContractUri: string): string | null {
386
312
  const spineContract = capsule.cst.spineContracts[spineContractUri]
@@ -812,196 +738,15 @@ export function CapsuleModuleProjector({
812
738
 
813
739
  const allStatements = [importStatements, literalReferences, moduleLocalFunctions].filter(Boolean).join('\n')
814
740
 
815
- // Check if this capsule has a solidjs.com or standalone property
816
- const hasSolidJs = hasSolidJsProperty(capsule, spineContractUri)
741
+ // Check if this capsule has an encapsulate.dev/standalone property
817
742
  const hasStandalone = hasStandaloneProperty(capsule, spineContractUri)
818
- const needsRuntime = hasSolidJs || hasStandalone
819
-
820
- // Determine which solid-js imports are needed (avoid duplicates with ambient references)
821
- const existingSolidJsImports = new Set<string>()
822
- for (const [name, ref] of Object.entries(ambientReferences)) {
823
- const refTyped = ref as any
824
- if (refTyped.type === 'import' && refTyped.moduleUri === 'solid-js') {
825
- // Parse existing imports from solid-js
826
- const match = refTyped.importSpecifier?.match(/\{([^}]+)\}/)
827
- if (match) {
828
- match[1].split(',').forEach((imp: string) => existingSolidJsImports.add(imp.trim()))
829
- }
830
- }
831
- }
832
-
833
- const neededSolidJsImports = ['createSignal', 'onMount', 'Show'].filter(imp => !existingSolidJsImports.has(imp))
834
- const solidJsImport = hasSolidJs && neededSolidJsImports.length > 0 ? `import { ${neededSolidJsImports.join(', ')} } from 'solid-js'\n` : ''
835
743
 
836
- // Add runtime imports for SolidJS and standalone functions
837
- const runtimeImport = needsRuntime ? `"use client"\nimport { Spine, SpineRuntime } from '@stream44.studio/encapsulate/encapsulate'\nimport { CapsuleSpineContract } from '@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0/Membrane.v0'\n${solidJsImport}` : ''
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` : ''
838
746
 
839
747
  // Generate default export based on capsule type
840
748
  let defaultExport = ''
841
- if (hasSolidJs) {
842
- // Generate a wrapper that sets up runtime and exports the SolidJS component
843
- const capsuleSourceLineRef = capsule.cst.capsuleSourceLineRef
844
- const solidjsComponent = extractSolidJsComponent(capsule, spineContractUri)
845
- if (solidjsComponent) {
846
- // Collect all capsule URIs from CST (mappings and property contracts)
847
- const allCapsuleUris = collectAllCapsuleUris(capsule, spineContractUri)
848
-
849
- // Also collect from ambient references and build import paths from snapshots
850
- const capsuleDeps: Array<{ uri: string, importName: string, importPath: string }> = []
851
- for (const [name, ref] of Object.entries(ambientReferences)) {
852
- const refTyped = ref as any
853
- if (refTyped.type === 'capsule') {
854
- const snapshot = await buildCapsuleSnapshotForReference(refTyped, capsules, spineContractUri)
855
-
856
- // Use dynamic spineContractUri instead of hardcoded URI
857
- const contractData = snapshot.spineContracts?.[spineContractUri]
858
- const structKey = Object.keys(contractData || {}).find(k => k.includes('/structs/Capsule'))
859
- const capsuleName = structKey ? contractData[structKey]?.capsuleName : undefined
860
- const projectedFilepath = structKey ? contractData[structKey]?.projectedCapsuleFilepath : undefined
861
-
862
- if (capsuleName && projectedFilepath) {
863
- allCapsuleUris.add(capsuleName)
864
-
865
- // Build import path from projected filepath
866
- const importName = `_capsule_${capsuleName.replace(/[^a-zA-Z0-9]/g, '_')}`
867
- // Remove .~o/encapsulate.dev/caps/ prefix and strip extension
868
- const importPath = projectedFilepath.replace(/^\.~o\/encapsulate\.dev\/caps\//, '').replace(/\.(ts|tsx)$/, '')
869
-
870
- capsuleDeps.push({ uri: capsuleName, importName, importPath })
871
- }
872
- }
873
- }
874
-
875
- // Generate static imports for all capsule dependencies
876
- // Compute relative path from projected file to caps directory
877
- let importPrefix: string
878
- if (capsuleModuleProjectionPackage) {
879
- importPrefix = capsuleModuleProjectionPackage
880
- } else {
881
- const projectedFileDir = dirname(filepath)
882
- const capsDir = '.~o/encapsulate.dev/caps'
883
- const relativePathToCaps = relative(projectedFileDir, capsDir)
884
- importPrefix = relativePathToCaps.startsWith('.') ? relativePathToCaps : './' + relativePathToCaps
885
- }
886
- const capsuleImports = capsuleDeps.map(dep =>
887
- `import * as ${dep.importName} from '${importPrefix}/${dep.importPath}'`
888
- ).join('\n')
889
-
890
- // Generate capsules map
891
- const capsulesMapEntries = capsuleDeps.map(dep =>
892
- ` '${dep.uri}': ${dep.importName}`
893
- ).join(',\n')
894
-
895
- defaultExport = `
896
- ${capsuleImports}
897
-
898
- // Set up runtime for browser execution
899
- const sourceSpine: { encapsulate?: any } = {}
900
-
901
- // Map of statically imported capsules
902
- const capsulesMap: Record<string, any> = {
903
- ${capsulesMapEntries}
904
- }
905
-
906
- // Helper to import and instantiate a capsule from the capsules map
907
- const importCapsule = async (uri: string) => {
908
- const capsuleModule = capsulesMap[uri]
909
- if (!capsuleModule) {
910
- throw new Error(\`Capsule not found in static imports: \${uri}\`)
911
- }
912
- const capsule = await capsuleModule.capsule({
913
- encapsulate: sourceSpine.encapsulate,
914
- loadCapsule
915
- })
916
- return capsule
917
- }
918
-
919
- const loadCapsule = async ({ capsuleSourceLineRef, capsuleName }: any) => {
920
- // Return the capsule function from this projected file
921
- if (capsuleSourceLineRef === '${capsuleSourceLineRef}') {
922
- return capsule
923
- }
924
-
925
- // Use capsuleName directly if provided
926
- if (capsuleName) {
927
- return await importCapsule(capsuleName)
928
- }
929
-
930
- throw new Error(\`Cannot load capsule: \${capsuleSourceLineRef}\`)
931
- }
932
-
933
- const spineContractOpts = {
934
- spineFilesystemRoot: '.',
935
- resolve: async (uri: string) => uri,
936
- importCapsule
937
- }
938
-
939
- const runtimeSpineContracts = {
940
- ['#' + CapsuleSpineContract['#']]: CapsuleSpineContract(spineContractOpts)
941
- }
942
-
943
- const snapshot = {
944
- capsules: {
945
- ['${capsuleSourceLineRef}']: {
946
- spineContracts: {}
947
- }
948
- }
949
- }
950
-
951
- // Export wrapper function that initializes runtime and returns component
952
- export default function({ onMembraneEvent }: { onMembraneEvent?: (event: any) => void } = {}) {
953
- const [component, setComponent] = createSignal(null)
954
-
955
- onMount(async () => {
956
- // Add onMembraneEvent to spine contract opts - use provided or default logger
957
- const defaultMembraneLogger = (event: any) => {
958
- console.log('[Membrane Event]', event)
959
- }
960
- const opts = {
961
- ...spineContractOpts,
962
- onMembraneEvent: onMembraneEvent || defaultMembraneLogger
963
- }
964
- const contracts = {
965
- ['#' + CapsuleSpineContract['#']]: CapsuleSpineContract(opts)
966
- }
967
-
968
- const { encapsulate, capsules } = await Spine({
969
- spineFilesystemRoot: '.',
970
- spineContracts: contracts
971
- })
972
-
973
- sourceSpine.encapsulate = encapsulate
974
-
975
- const capsuleInstance = await capsule({ encapsulate, loadCapsule })
976
-
977
- const { run } = await SpineRuntime({
978
- spineFilesystemRoot: '.',
979
- spineContracts: contracts,
980
- snapshot,
981
- loadCapsule
982
- })
983
-
984
- const Component = await run({}, async ({ apis }) => {
985
- const capsuleApi = apis['${capsuleSourceLineRef}']
986
- const solidjsKey = Object.keys(capsuleApi).find(k => k === 'solidjs.com/standalone')
987
- if (!solidjsKey) throw new Error('solidjs.com/standalone property not found')
988
- return capsuleApi[solidjsKey]()
989
- })
990
-
991
- setComponent(() => Component)
992
- })
993
-
994
- // Return the wrapper function itself, not call it
995
- const WrapperComponent = () => {
996
- const Component = component()
997
- return Show({ when: Component, children: (Component) => Component() })
998
- }
999
-
1000
- return WrapperComponent
1001
- }
1002
- `
1003
- }
1004
- } else if (hasStandalone) {
749
+ if (hasStandalone) {
1005
750
  // Generate a wrapper function that directly invokes the standalone function
1006
751
  const capsuleSourceLineRef = capsule.cst.capsuleSourceLineRef
1007
752
 
@@ -1095,6 +840,7 @@ ${capsulesMapEntries}
1095
840
  }
1096
841
  const capsule = await capsuleModule.capsule({
1097
842
  encapsulate: sourceSpine.encapsulate,
843
+ CapsulePropertyTypes,
1098
844
  loadCapsule
1099
845
  })
1100
846
  return capsule
@@ -1255,8 +1001,12 @@ ${defaultExport}
1255
1001
  mappedCapsule.cst.source.moduleFilepath
1256
1002
  )
1257
1003
 
1258
- // Get ambient references for the mapped capsule
1259
- const mappedAmbientRefs = mappedCapsule.cst.source?.ambientReferences || {}
1004
+ // Get ambient references for the mapped capsule, excluding makeImportStack
1005
+ let mappedAmbientRefs = mappedCapsule.cst.source?.ambientReferences || {}
1006
+ if (mappedAmbientRefs['makeImportStack']) {
1007
+ mappedAmbientRefs = { ...mappedAmbientRefs }
1008
+ delete mappedAmbientRefs['makeImportStack']
1009
+ }
1260
1010
 
1261
1011
  // Generate import statements with projection CSS paths
1262
1012
  const mappedImportStatements = Object.entries(mappedAmbientRefs)
@@ -1315,191 +1065,17 @@ ${defaultExport}
1315
1065
 
1316
1066
  const mappedAllStatements = [mappedImportStatements, mappedLiteralReferences, mappedModuleLocalFunctions].filter(Boolean).join('\n')
1317
1067
 
1318
- // Check if mapped capsule has solidjs.com or encapsulate.dev/standalone property
1319
- const mappedHasSolidJs = hasSolidJsProperty(mappedCapsule, spineContractUri)
1068
+ // Check if mapped capsule has encapsulate.dev/standalone property
1320
1069
  const mappedHasStandalone = hasStandaloneProperty(mappedCapsule, spineContractUri)
1321
- const mappedNeedsRuntime = mappedHasSolidJs || mappedHasStandalone
1322
-
1323
- // Determine which solid-js imports are needed for mapped capsule
1324
- const mappedExistingSolidJsImports = new Set<string>()
1325
- for (const [name, ref] of Object.entries(mappedAmbientRefs)) {
1326
- const refTyped = ref as any
1327
- if (refTyped.type === 'import' && refTyped.moduleUri === 'solid-js') {
1328
- const match = refTyped.importSpecifier?.match(/\{([^}]+)\}/)
1329
- if (match) {
1330
- match[1].split(',').forEach((imp: string) => mappedExistingSolidJsImports.add(imp.trim()))
1331
- }
1332
- }
1333
- }
1334
-
1335
- const mappedNeededSolidJsImports = ['createSignal', 'onMount', 'Show'].filter(imp => !mappedExistingSolidJsImports.has(imp))
1336
- const mappedSolidJsImport = mappedHasSolidJs && mappedNeededSolidJsImports.length > 0 ? `import { ${mappedNeededSolidJsImports.join(', ')} } from 'solid-js'\n` : ''
1337
1070
 
1338
1071
  // Rewrite the mapped capsule expression to include CST (reuse the same function)
1339
1072
  const mappedCapsuleExpression = rewriteCapsuleExpressionWithCST(mappedCapsule)
1340
1073
 
1341
- // Add runtime imports for SolidJS and standalone functions
1342
- const mappedRuntimeImport = mappedNeedsRuntime ? `"use client"\nimport { Spine, SpineRuntime } from '@stream44.studio/encapsulate/encapsulate'\nimport { CapsuleSpineContract } from '@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0/Membrane.v0'\n${mappedSolidJsImport}` : ''
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` : ''
1343
1076
 
1344
1077
  let mappedDefaultExport = ''
1345
- if (mappedHasSolidJs) {
1346
- // Generate a wrapper that sets up runtime and exports the SolidJS component
1347
- const mappedCapsuleSourceLineRef = mappedCapsule.cst.capsuleSourceLineRef
1348
- const solidjsComponent = extractSolidJsComponent(mappedCapsule, spineContractUri)
1349
- if (solidjsComponent) {
1350
- // Collect all capsule URIs from CST (mappings and property contracts)
1351
- const allMappedCapsuleUris = collectAllCapsuleUris(mappedCapsule, spineContractUri)
1352
-
1353
- // Also collect from ambient references
1354
- for (const [name, ref] of Object.entries(mappedAmbientRefs)) {
1355
- const refTyped = ref as any
1356
- if (refTyped.type === 'capsule') {
1357
- const snapshot = await buildCapsuleSnapshotForReference(refTyped, capsules, spineContractUri)
1358
- const contractData = snapshot.spineContracts?.[spineContractUri]
1359
- const structKey = Object.keys(contractData || {}).find(k => k.includes('/structs/Capsule'))
1360
- const capsuleName = structKey ? contractData[structKey]?.capsuleName : undefined
1361
- if (capsuleName) {
1362
- allMappedCapsuleUris.add(capsuleName)
1363
- }
1364
- }
1365
- }
1366
-
1367
- // Build capsule dependencies array
1368
- const mappedCapsuleDeps: Array<{ uri: string, importName: string, importPath: string }> = []
1369
- for (const uri of allMappedCapsuleUris) {
1370
- const importName = `_capsule_${uri.replace(/[^a-zA-Z0-9]/g, '_')}`
1371
- // Strip leading @ to match caps filesystem paths
1372
- const importPath = uri.startsWith('@') ? uri.substring(1) : uri
1373
- mappedCapsuleDeps.push({ uri, importName, importPath })
1374
- }
1375
-
1376
- // Generate static imports for all capsule dependencies
1377
- // Compute relative path from projected file to caps directory
1378
- let importPrefix: string
1379
- if (capsuleModuleProjectionPackage) {
1380
- importPrefix = capsuleModuleProjectionPackage
1381
- } else {
1382
- const projectedFileDir = dirname(mapped.projectionPath)
1383
- const capsDir = '.~o/encapsulate.dev/caps'
1384
- const relativePathToCaps = relative(projectedFileDir, capsDir)
1385
- importPrefix = relativePathToCaps.startsWith('.') ? relativePathToCaps : './' + relativePathToCaps
1386
- }
1387
- const mappedCapsuleImports = mappedCapsuleDeps.map(dep =>
1388
- `import * as ${dep.importName} from '${importPrefix}/${dep.importPath}'`
1389
- ).join('\n')
1390
-
1391
- // Generate capsules map
1392
- const mappedCapsulesMapEntries = mappedCapsuleDeps.map(dep =>
1393
- ` '${dep.uri}': ${dep.importName}`
1394
- ).join(',\n')
1395
-
1396
- mappedDefaultExport = `
1397
- ${mappedCapsuleImports}
1398
-
1399
- // Set up runtime for browser execution
1400
- const sourceSpine: { encapsulate?: any } = {}
1401
-
1402
- // Map of statically imported capsules
1403
- const capsulesMap: Record<string, any> = {
1404
- ${mappedCapsulesMapEntries}
1405
- }
1406
-
1407
- // Helper to import and instantiate a capsule from the capsules map
1408
- const importCapsule = async (uri: string) => {
1409
- const capsuleModule = capsulesMap[uri]
1410
- if (!capsuleModule) {
1411
- throw new Error(\`Capsule not found in static imports: \${uri}\`)
1412
- }
1413
- const capsule = await capsuleModule.capsule({
1414
- encapsulate: sourceSpine.encapsulate,
1415
- loadCapsule
1416
- })
1417
- return capsule
1418
- }
1419
-
1420
- const loadCapsule = async ({ capsuleSourceLineRef, capsuleName }: any) => {
1421
- // Return the capsule function from this projected file
1422
- if (capsuleSourceLineRef === '${mappedCapsuleSourceLineRef}') {
1423
- return capsule
1424
- }
1425
-
1426
- // Use capsuleName directly if provided
1427
- if (capsuleName) {
1428
- return await importCapsule(capsuleName)
1429
- }
1430
-
1431
- throw new Error(\`Cannot load capsule: \${capsuleSourceLineRef}\`)
1432
- }
1433
-
1434
- const spineContractOpts = {
1435
- spineFilesystemRoot: '.',
1436
- resolve: async (uri: string) => uri,
1437
- importCapsule
1438
- }
1439
-
1440
- const runtimeSpineContracts = {
1441
- ['#' + CapsuleSpineContract['#']]: CapsuleSpineContract(spineContractOpts)
1442
- }
1443
-
1444
- const snapshot = {
1445
- capsules: {
1446
- ['${mappedCapsuleSourceLineRef}']: {
1447
- spineContracts: {}
1448
- }
1449
- }
1450
- }
1451
-
1452
- // Export wrapper function that initializes runtime and returns component
1453
- export default function({ onMembraneEvent }: { onMembraneEvent?: (event: any) => void } = {}) {
1454
- const [component, setComponent] = createSignal(null)
1455
-
1456
- onMount(async () => {
1457
- // Add onMembraneEvent to spine contract opts - use provided or default logger
1458
- const defaultMembraneLogger = (event: any) => {
1459
- console.log('[Membrane Event]', event.type, event)
1460
- }
1461
- const opts = {
1462
- ...spineContractOpts,
1463
- onMembraneEvent: onMembraneEvent || defaultMembraneLogger
1464
- }
1465
- const contracts = {
1466
- ['#' + CapsuleSpineContract['#']]: CapsuleSpineContract(opts)
1467
- }
1468
-
1469
- const { encapsulate, capsules } = await Spine({
1470
- spineFilesystemRoot: '.',
1471
- spineContracts: contracts
1472
- })
1473
-
1474
- sourceSpine.encapsulate = encapsulate
1475
-
1476
- const capsuleInstance = await capsule({ encapsulate, loadCapsule })
1477
-
1478
- const { run } = await SpineRuntime({
1479
- spineFilesystemRoot: '.',
1480
- spineContracts: contracts,
1481
- snapshot,
1482
- loadCapsule
1483
- })
1484
-
1485
- const Component = await run({}, async ({ apis }) => {
1486
- const capsuleApi = apis['${mappedCapsuleSourceLineRef}']
1487
- const solidjsKey = Object.keys(capsuleApi).find(k => k === 'solidjs.com/standalone')
1488
- if (!solidjsKey) throw new Error('solidjs.com/standalone property not found')
1489
- return capsuleApi[solidjsKey]()
1490
- })
1491
-
1492
- setComponent(() => Component)
1493
- })
1494
-
1495
- return () => {
1496
- const Component = component()
1497
- return Show({ when: Component, children: (Component) => Component() })
1498
- }
1499
- }
1500
- `
1501
- }
1502
- } else if (mappedHasStandalone) {
1078
+ if (mappedHasStandalone) {
1503
1079
  // Generate a wrapper function that directly invokes the standalone function
1504
1080
  const mappedCapsuleSourceLineRef = mappedCapsule.cst.capsuleSourceLineRef
1505
1081
 
@@ -1570,6 +1146,7 @@ ${mappedCapsulesMapEntries}
1570
1146
  }
1571
1147
  const capsule = await capsuleModule.capsule({
1572
1148
  encapsulate: sourceSpine.encapsulate,
1149
+ CapsulePropertyTypes,
1573
1150
  loadCapsule
1574
1151
  })
1575
1152
  return capsule
@@ -1726,10 +1303,38 @@ ${mappedDefaultExport}
1726
1303
  .filter(Boolean)
1727
1304
  .join('\n ')
1728
1305
 
1729
- // Add necessary imports
1730
- const imports = `import { CapsulePropertyTypes } from '@stream44.studio/encapsulate/encapsulate'
1731
- import { makeImportStack } from '@stream44.studio/encapsulate/encapsulate'
1732
- `
1306
+ // Filter out makeImportStack from ambient references (same as main capsule path)
1307
+ let filteredCapsuleAmbientRefs = capsuleAmbientRefs
1308
+ if (filteredCapsuleAmbientRefs['makeImportStack']) {
1309
+ filteredCapsuleAmbientRefs = { ...filteredCapsuleAmbientRefs }
1310
+ delete filteredCapsuleAmbientRefs['makeImportStack']
1311
+ }
1312
+
1313
+ // Generate imports from filtered ambient references
1314
+ const capsuleImportStatements = Object.entries(filteredCapsuleAmbientRefs)
1315
+ .map(([name, ref]: [string, any]) => {
1316
+ if (ref.type === 'import') {
1317
+ if (ref.moduleUri.endsWith('.css')) {
1318
+ return `import '${ref.moduleUri}'`
1319
+ }
1320
+ return `import ${ref.importSpecifier} from '${ref.moduleUri}'`
1321
+ }
1322
+ if (ref.type === 'assigned') {
1323
+ if (ref.moduleUri.includes('/spine-factories/')) {
1324
+ return `import { ${name} } from '@stream44.studio/encapsulate/encapsulate'`
1325
+ }
1326
+ }
1327
+ if (ref.type === 'invocation-argument') {
1328
+ if (ref.isEncapsulateExport) {
1329
+ return `import { ${name} } from '@stream44.studio/encapsulate/encapsulate'`
1330
+ }
1331
+ }
1332
+ return ''
1333
+ })
1334
+ .filter(Boolean)
1335
+ .join('\n')
1336
+
1337
+ const imports = capsuleImportStatements ? capsuleImportStatements + '\n' : ''
1733
1338
 
1734
1339
  // Get the capsule name for the assignment
1735
1340
  const capsuleName = registryCapsule.cst.source.capsuleName || ''
@@ -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,7 @@ 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,
118
128
  }
119
129
 
120
130
  // ##################################################
@@ -430,7 +440,13 @@ export async function Spine(options: TSpineOptions): Promise<TSpine> {
430
440
 
431
441
  options.timing?.record(`Spine: Freezing ${Object.keys(capsules).length} capsules`)
432
442
 
433
- await Promise.all(Object.entries(capsules).map(async ([capsuleSourceLineRef, capsule]) => {
443
+ const processedCapsules = new Set<any>()
444
+ const freezeVisited = new Set<any>()
445
+ for (const [capsuleSourceLineRef, capsule] of Object.entries(capsules)) {
446
+
447
+ // Skip capsuleName aliases — only process each capsule once via its capsuleSourceLineRef
448
+ if (processedCapsules.has(capsule)) continue
449
+ processedCapsules.add(capsule)
434
450
 
435
451
  if (!capsule.cst.source.capsuleName) throw new Error(`'capsuleName' must be set for encapsulate options to enable freezing.`)
436
452
 
@@ -438,10 +454,95 @@ export async function Spine(options: TSpineOptions): Promise<TSpine> {
438
454
  cst: capsule.cst,
439
455
  spineContracts: {}
440
456
  }
457
+ // Also register under capsuleName so SpineRuntime can resolve by name
458
+ if (capsule.cst.source.capsuleName && capsule.cst.source.capsuleName !== capsuleSourceLineRef) {
459
+ snapshot.capsules[capsule.cst.source.capsuleName] = snapshot.capsules[capsuleSourceLineRef]
460
+ }
461
+
462
+ const capsuleInstance = await capsule.makeInstance()
463
+
464
+ // Run OnFreeze functions so capsules can perform side effects
465
+ // (e.g. file projection) at build/freeze time
466
+ async function runOnFreeze(instance: any, parentCapsuleCst?: any, parentCapsuleSourceLineRef?: string, projectionPath?: string, projectionSpineContractUri?: string) {
467
+ if (!instance) return
468
+ // Use capsuleSourceLineRef for deduplication — this is stable across different
469
+ // instance objects of the same capsule (top-level vs mapped child).
470
+ // Include projectionPath in the key so the same capsule can project to
471
+ // multiple output paths (e.g. standalone delegate used by different parents).
472
+ // Fall back to instance identity for internal capsules without a lineRef.
473
+ // Use capsuleName as the base key when available (always a unique string),
474
+ // otherwise fall back to capsuleSourceLineRef or instance identity.
475
+ // This avoids collisions from object refs that all stringify to '[object Object]'.
476
+ const baseKey = instance.capsuleName || instance.capsuleSourceLineRef || instance
477
+ const freezeKey = projectionPath ? `${baseKey}::${projectionPath}` : baseKey
478
+ if (freezeVisited.has(freezeKey)) return
479
+ freezeVisited.add(freezeKey)
480
+
481
+ // Inject projection context onto CapsuleProjectionContext instances
482
+ // Set values on both the api (encapsulatedApi) and spine contract self
483
+ // so they are visible through all proxy chains.
484
+ // Scan recursively into mapped children since CapsuleProjectionContext
485
+ // may be nested inside a property contract delegate (e.g. a projector capsule).
486
+ const projectionCtx = options.projectionContext
487
+ if (projectionCtx && instance.mappedCapsuleInstances?.length) {
488
+ const injectCtx = (children: any[]) => {
489
+ for (const mappedChild of children) {
490
+ const childApi = mappedChild.api
491
+ if (childApi && mappedChild.capsuleName === '@stream44.studio/encapsulate/structs/CapsuleProjectionContext') {
492
+ const ctx = {
493
+ parentCapsuleCst: parentCapsuleCst,
494
+ parentCapsuleSourceLineRef: parentCapsuleSourceLineRef,
495
+ capsuleModuleProjectionPackage: projectionCtx.capsuleModuleProjectionPackage,
496
+ projectionStore: projectionCtx.projectionStore,
497
+ capsuleSnapshots: projectionCtx.capsules,
498
+ projectionPath: projectionPath,
499
+ spineContractUri: projectionSpineContractUri,
500
+ }
501
+ Object.assign(childApi, ctx)
502
+ // Also update spine contract self for selfProxy access
503
+ for (const childSci of Object.values(mappedChild.spineContractCapsuleInstances || {})) {
504
+ const childSelf = (childSci as any).self
505
+ if (childSelf) Object.assign(childSelf, ctx)
506
+ }
507
+ }
508
+ // Recurse into delegate's mapped children
509
+ if (mappedChild.mappedCapsuleInstances?.length) {
510
+ injectCtx(mappedChild.mappedCapsuleInstances)
511
+ }
512
+ }
513
+ }
514
+ injectCtx(instance.mappedCapsuleInstances)
515
+ }
441
516
 
442
- const { spineContractCapsuleInstances } = await capsule.makeInstance()
517
+ if (instance.onFreezeFunctions?.length) {
518
+ for (const fn of instance.onFreezeFunctions) {
519
+ await fn()
520
+ }
521
+ }
443
522
 
444
- await Promise.all(Object.entries(spineContractCapsuleInstances).map(async ([spineContractUri, spineContractCapsuleInstance]) => {
523
+ if (instance.extendedCapsuleInstance) {
524
+ await runOnFreeze(instance.extendedCapsuleInstance, parentCapsuleCst, parentCapsuleSourceLineRef, projectionPath, projectionSpineContractUri)
525
+ }
526
+ if (instance.mappedCapsuleInstances?.length) {
527
+ for (const mappedInstance of instance.mappedCapsuleInstances) {
528
+ // Determine projection path from the property name (alias) used for this mapping.
529
+ // If the mapped child doesn't define its own path (no / prefix), inherit the parent's projectionPath
530
+ // so it flows down to property contract delegates.
531
+ const mappedProjectionPath = mappedInstance.mappedPropertyName?.startsWith('/') ? mappedInstance.mappedPropertyName : projectionPath
532
+ // For regular mapped capsules (non-struct delegates), use their own CST as
533
+ // parentCapsuleCst so nested property contract delegates see the correct parent.
534
+ // Property contract delegates (flagged by Static.v0) pass through the current
535
+ // parentCapsuleCst so their OnFreeze sees the declaring capsule's CST.
536
+ const mappedCapsule = (!mappedInstance.isPropertyContractDelegate && mappedInstance.capsuleName) ? capsules[mappedInstance.capsuleName] : undefined
537
+ const childParentCst = mappedCapsule?.cst || parentCapsuleCst
538
+ const instanceSourceLineRef = instance.capsuleSourceLineRef || parentCapsuleSourceLineRef
539
+ await runOnFreeze(mappedInstance, childParentCst, instanceSourceLineRef, mappedProjectionPath, Object.keys(capsuleInstance.spineContractCapsuleInstances)?.[0])
540
+ }
541
+ }
542
+ }
543
+ await runOnFreeze(capsuleInstance, capsule.cst, capsule.capsuleSourceLineRef)
544
+
545
+ await Promise.all(Object.entries(capsuleInstance.spineContractCapsuleInstances).map(async ([spineContractUri, spineContractCapsuleInstance]) => {
445
546
 
446
547
  snapshot.capsules[capsuleSourceLineRef] = merge(
447
548
  snapshot.capsules[capsuleSourceLineRef],
@@ -451,7 +552,7 @@ export async function Spine(options: TSpineOptions): Promise<TSpine> {
451
552
  })
452
553
  )
453
554
  }))
454
- }))
555
+ }
455
556
 
456
557
  options.timing?.record('Spine: Freeze complete')
457
558
 
@@ -621,7 +722,7 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
621
722
  // Check if 'as' is defined to use as the property name alias
622
723
  const propDefTyped = propDef as Record<string, any>
623
724
  const aliasName = propDefTyped.as
624
- const delegateOptions = propDefTyped.options
725
+ let delegateOptions = propDefTyped.options
625
726
  const contractKey = aliasName || ('#' + propContractUri.substring(1))
626
727
 
627
728
  if (!propertyContractDefinitions[spineContractUri]['#']) {
@@ -835,8 +936,8 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
835
936
  moduleFilepath: absoluteModuleFilepath
836
937
  },
837
938
  parentCapsuleSourceUriLineRefInstanceId: parentCapsuleSourceUriLineRefInstanceId
838
- ? sha256(parentCapsuleSourceUriLineRefInstanceId + ':' + (cst?.capsuleSourceUriLineRef || encapsulateOptions.capsuleSourceLineRef))
839
- : sha256(cst?.capsuleSourceUriLineRef || encapsulateOptions.capsuleSourceLineRef),
939
+ ? await sha256(parentCapsuleSourceUriLineRefInstanceId + ':' + (cst?.capsuleSourceUriLineRef || encapsulateOptions.capsuleSourceLineRef))
940
+ : await sha256(cst?.capsuleSourceUriLineRef || encapsulateOptions.capsuleSourceLineRef),
840
941
  sit
841
942
  })
842
943
 
@@ -873,8 +974,8 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
873
974
  // child: sha256(parentCapsuleSourceUriLineRefInstanceId + ":" + capsuleSourceUriLineRef)
874
975
  const capsuleSourceUriLineRef = cst?.capsuleSourceUriLineRef || encapsulateOptions.capsuleSourceLineRef
875
976
  const capsuleSourceUriLineRefInstanceId = parentCapsuleSourceUriLineRefInstanceId
876
- ? sha256(parentCapsuleSourceUriLineRefInstanceId + ':' + capsuleSourceUriLineRef)
877
- : sha256(capsuleSourceUriLineRef)
977
+ ? await sha256(parentCapsuleSourceUriLineRefInstanceId + ':' + capsuleSourceUriLineRef)
978
+ : await sha256(capsuleSourceUriLineRef)
878
979
 
879
980
  // Register this instance in the sit structure if provided
880
981
  if (sit) {
@@ -893,6 +994,7 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
893
994
  structDisposeFunctions: [] as Array<() => any>,
894
995
  initFunctions: [] as Array<() => any>,
895
996
  disposeFunctions: [] as Array<() => any>,
997
+ onFreezeFunctions: [] as Array<() => any>,
896
998
  mappedCapsuleInstances: [] as Array<any>,
897
999
  rootCapsule: resolvedRootCapsule,
898
1000
  capsuleSourceUriLineRefInstanceId,
@@ -987,6 +1089,9 @@ async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOpti
987
1089
  if (sci.disposeFunctions?.length) {
988
1090
  capsuleInstance.disposeFunctions.push(...sci.disposeFunctions)
989
1091
  }
1092
+ if (sci.onFreezeFunctions?.length) {
1093
+ capsuleInstance.onFreezeFunctions.push(...sci.onFreezeFunctions)
1094
+ }
990
1095
  if (sci.mappedCapsuleInstances?.length) {
991
1096
  capsuleInstance.mappedCapsuleInstances.push(...sci.mappedCapsuleInstances)
992
1097
  }
@@ -1124,15 +1229,15 @@ function relative(from: string, to: string): string {
1124
1229
  return result || '.'
1125
1230
  }
1126
1231
 
1127
- function sha256(input: string): string {
1128
- // Use Bun's native hasher for speed; falls back to Node crypto
1129
- if (typeof globalThis.Bun !== 'undefined') {
1130
- const hasher = new globalThis.Bun.CryptoHasher('sha256')
1131
- hasher.update(input)
1132
- return hasher.digest('hex') as string
1232
+ async function sha256(input: string): Promise<string> {
1233
+ const data = new TextEncoder().encode(input)
1234
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data)
1235
+ const hashArray = new Uint8Array(hashBuffer)
1236
+ let hex = ''
1237
+ for (let i = 0; i < hashArray.length; i++) {
1238
+ hex += hashArray[i].toString(16).padStart(2, '0')
1133
1239
  }
1134
- const { createHash } = require('crypto')
1135
- return createHash('sha256').update(input).digest('hex')
1240
+ return hex
1136
1241
  }
1137
1242
 
1138
1243
  function isObject(item: any): boolean {
@@ -1,7 +1,5 @@
1
1
  import { CapsulePropertyTypes } from "../../encapsulate"
2
2
  import { ContractCapsuleInstanceFactory, CapsuleInstanceRegistry } from "./Static.v0"
3
- import { readFileSync, existsSync } from "node:fs"
4
- import { dirname, relative, join } from "node:path"
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
@@ -892,7 +894,7 @@ class MembraneContractCapsuleInstanceFactory extends ContractCapsuleInstanceFact
892
894
  } else if (this.enableCallerStackInference) {
893
895
  const stackStr = new Error('[MEMBRANE_EVENT]').stack
894
896
  if (stackStr) {
895
- const stackFrames = parseCallerFromStack(stackStr, this.spineFilesystemRoot)
897
+ const stackFrames = parseCallerFromStack(stackStr, this.spineFilesystemRoot, this.npmUriForFilepathSync)
896
898
  if (stackFrames.length > 0) {
897
899
  const callerInfo = extractCallerInfo(stackFrames, 3)
898
900
  event.caller = {
@@ -911,7 +913,8 @@ export function CapsuleSpineContract({
911
913
  enableCallerStackInference = false,
912
914
  spineFilesystemRoot,
913
915
  resolve,
914
- importCapsule
916
+ importCapsule,
917
+ npmUriForFilepath
915
918
  }: {
916
919
  onMembraneEvent?: (event: any) => void
917
920
  freezeCapsule?: (capsule: any) => Promise<any>
@@ -919,12 +922,28 @@ export function CapsuleSpineContract({
919
922
  spineFilesystemRoot?: string
920
923
  resolve?: (uri: string, parentFilepath: string) => Promise<string>
921
924
  importCapsule?: (filepath: string) => Promise<any>
925
+ npmUriForFilepath?: (filepath: string) => Promise<string | null>
922
926
  } = {}) {
923
927
 
924
928
  let eventIndex = 0
925
929
  let currentCallerContext: CallerContext | undefined = undefined
926
930
  const instanceRegistry: CapsuleInstanceRegistry = new Map()
927
931
 
932
+ // Sync cache for npmUriForFilepath — async calls populate the cache,
933
+ // sync reads return cached values (raw filepath fallback on cache miss).
934
+ const npmUriCache = new Map<string, string | null>()
935
+ const npmUriForFilepathSync = npmUriForFilepath
936
+ ? (filepath: string): string | null => {
937
+ if (npmUriCache.has(filepath)) return npmUriCache.get(filepath)!
938
+ // Fire async resolution to populate cache for next access
939
+ npmUriForFilepath(filepath).then(
940
+ uri => npmUriCache.set(filepath, uri),
941
+ () => npmUriCache.set(filepath, null)
942
+ )
943
+ return null
944
+ }
945
+ : undefined
946
+
928
947
  // Re-entrancy guard: suppress event emission while inside an onMembraneEvent callback.
929
948
  // This prevents consumers (e.g. JSON.stringify on event.value) from triggering proxy getters
930
949
  // that would cause spurious recursive membrane events with wrong caller context and ordering.
@@ -955,6 +974,7 @@ export function CapsuleSpineContract({
955
974
  importCapsule,
956
975
  onMembraneEvent: guardedOnMembraneEvent,
957
976
  enableCallerStackInference,
977
+ npmUriForFilepathSync,
958
978
  encapsulateOptions,
959
979
  getEventIndex: () => eventIndex,
960
980
  incrementEventIndex: () => eventIndex++,
@@ -975,67 +995,7 @@ export function CapsuleSpineContract({
975
995
  CapsuleSpineContract['#'] = '@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0'
976
996
 
977
997
 
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 }> {
998
+ function parseCallerFromStack(stack: string, spineFilesystemRoot?: string, npmUriForFilepathSync?: (filepath: string) => string | null): Array<{ function?: string, fileUri?: string, line?: number, column?: number }> {
1039
999
  const lines = stack.split('\n')
1040
1000
  const result: Array<{ function?: string, fileUri?: string, line?: number, column?: number }> = []
1041
1001
 
@@ -1082,7 +1042,7 @@ function parseCallerFromStack(stack: string, spineFilesystemRoot?: string): Arra
1082
1042
 
1083
1043
  // Convert absolute filepaths to npm URIs
1084
1044
  if (rawFilepath) {
1085
- const npmUri = constructNpmUriSync(rawFilepath)
1045
+ const npmUri = npmUriForFilepathSync ? npmUriForFilepathSync(rawFilepath) : null
1086
1046
  if (npmUri) {
1087
1047
  // Strip file extension from URI for consistency
1088
1048
  frame.fileUri = npmUri.replace(/\.(ts|tsx|js|jsx)$/, '')
@@ -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,8 @@ 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 })
107
110
  }
108
111
  }
109
112
 
@@ -212,9 +215,12 @@ export class ContractCapsuleInstanceFactory {
212
215
 
213
216
  // Check for existing instance in registry - reuse if available when no options
214
217
  // 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
218
+ // Property contract delegates (structs) always get a fresh instance per parent capsule.
219
+ // CapsuleProjectionContext also needs fresh instances so context injection can find it
220
+ // in mappedCapsuleInstances during freeze traversal.
216
221
  const capsuleName = mappedCapsule.encapsulateOptions?.capsuleName
217
222
  const isCapsuleStruct = property.definition.propertyContractDelegate === '#@stream44.studio/encapsulate/structs/Capsule'
223
+ || property.definition.propertyContractDelegate === '#@stream44.studio/encapsulate/structs/CapsuleProjectionContext'
218
224
 
219
225
  if (capsuleName && this.instanceRegistry && !isCapsuleStruct) {
220
226
  if (this.instanceRegistry.has(capsuleName)) {
@@ -335,6 +341,10 @@ export class ContractCapsuleInstanceFactory {
335
341
  }
336
342
 
337
343
  apiTarget[property.name] = mappedInstance
344
+ mappedInstance.mappedPropertyName = property.name
345
+ if (property.definition.propertyContractDelegate) {
346
+ mappedInstance.isPropertyContractDelegate = true
347
+ }
338
348
  this.mappedCapsuleInstances.push(mappedInstance)
339
349
  // Use proxy to unwrap .api for this.self so internal references work
340
350
  this.self[property.name] = mappedInstance.api ? new Proxy(mappedInstance.api, {
@@ -349,18 +359,17 @@ export class ContractCapsuleInstanceFactory {
349
359
  }) : mappedInstance
350
360
 
351
361
  // If this mapping has a propertyContractDelegate, also mount the mapped capsule's API
352
- // to the property contract namespace for direct access
362
+ // to the property contract namespace for direct access.
363
+ // Use a proxy so that later mutations to the delegate's API (e.g. CapsuleProjectionContext
364
+ // injection during freeze) are visible through the parent's encapsulatedApi.
353
365
  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
- }
366
+ this.encapsulatedApi[property.definition.propertyContractDelegate] = new Proxy(mappedInstance.api, {
367
+ get: (target: any, prop: string | symbol) => target[prop],
368
+ set: (target: any, prop: string | symbol, value: any) => { target[prop] = value; return true },
369
+ ownKeys: (target: any) => Reflect.ownKeys(target),
370
+ getOwnPropertyDescriptor: (target: any, prop: string | symbol) => Object.getOwnPropertyDescriptor(target, prop) || { configurable: true, enumerable: true, writable: true, value: target[prop] },
371
+ has: (target: any, prop: string | symbol) => prop in target,
372
+ })
364
373
  }
365
374
  }
366
375
 
@@ -606,6 +615,12 @@ export class ContractCapsuleInstanceFactory {
606
615
  this.disposeFunctions.push(boundFunction)
607
616
  }
608
617
 
618
+ protected mapOnFreezeProperty({ property }: { property: any }) {
619
+ const selfProxy = this.createSelfProxy()
620
+ const boundFunction = property.definition.value.bind(selfProxy)
621
+ this.onFreezeFunctions.push(boundFunction)
622
+ }
623
+
609
624
  async freeze(options: any): Promise<any> {
610
625
  return this.freezeCapsule?.(options) || {}
611
626
  }
@@ -1,4 +1,4 @@
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
4
  import { StaticAnalyzer } from "../../src/static-analyzer.v0"
@@ -278,6 +278,65 @@ async function resolve(uri: string, fromPath: string, spineRoot?: string): Promi
278
278
  }
279
279
 
280
280
 
281
+ // Build an async npmUriForFilepath resolver backed by a directory→package-name cache.
282
+ // Walks up the directory tree from a given filepath to find the nearest package.json
283
+ // and constructs an npm-style URI (e.g. "package-name/src/foo.ts").
284
+ function createNpmUriForFilepath(): (filepath: string) => Promise<string | null> {
285
+ const cache = new Map<string, string | null>()
286
+
287
+ return async (absoluteFilepath: string): Promise<string | null> => {
288
+ // Only process absolute paths — skip V8 internal markers like "native", "node:*", etc.
289
+ if (!absoluteFilepath.startsWith('/')) {
290
+ return null
291
+ }
292
+
293
+ // Check for /node_modules/ in the path — use the last occurrence to handle nested node_modules
294
+ const nodeModulesMarker = '/node_modules/'
295
+ const lastIdx = absoluteFilepath.lastIndexOf(nodeModulesMarker)
296
+ if (lastIdx !== -1) {
297
+ return absoluteFilepath.substring(lastIdx + nodeModulesMarker.length)
298
+ }
299
+
300
+ let currentDir = dirname(absoluteFilepath)
301
+ const maxDepth = 20
302
+
303
+ for (let i = 0; i < maxDepth; i++) {
304
+ if (cache.has(currentDir)) {
305
+ const cachedName = cache.get(currentDir)
306
+ if (cachedName) {
307
+ const relativeFromPackage = relative(currentDir, absoluteFilepath)
308
+ return `${cachedName}/${relativeFromPackage}`
309
+ }
310
+ // null means no package.json with name found at this level, continue up
311
+ const parentDir = dirname(currentDir)
312
+ if (parentDir === currentDir) break
313
+ currentDir = parentDir
314
+ continue
315
+ }
316
+
317
+ const packageJsonPath = join(currentDir, 'package.json')
318
+ try {
319
+ await stat(packageJsonPath)
320
+ const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf-8'))
321
+ const packageName = packageJson.name
322
+ cache.set(currentDir, packageName || null)
323
+ if (packageName) {
324
+ const relativeFromPackage = relative(currentDir, absoluteFilepath)
325
+ return `${packageName}/${relativeFromPackage}`
326
+ }
327
+ } catch {
328
+ cache.set(currentDir, null)
329
+ }
330
+
331
+ const parentDir = dirname(currentDir)
332
+ if (parentDir === currentDir) break
333
+ currentDir = parentDir
334
+ }
335
+
336
+ return null
337
+ }
338
+ }
339
+
281
340
  export async function CapsuleSpineFactory({
282
341
  spineFilesystemRoot,
283
342
  capsuleModuleProjectionRoot,
@@ -329,8 +388,10 @@ export async function CapsuleSpineFactory({
329
388
  }
330
389
 
331
390
  const sourceSpine: { encapsulate?: any } = {}
391
+ const npmUriForFilepath = createNpmUriForFilepath()
332
392
  const commonSpineContractOpts = {
333
393
  spineFilesystemRoot,
394
+ npmUriForFilepath,
334
395
  resolve: async (uri: string, parentFilepath: string) => {
335
396
  // For relative paths, join with parent directory first
336
397
  if (/^\.\.?\//.test(uri)) {
@@ -537,7 +598,28 @@ export async function CapsuleSpineFactory({
537
598
  },
538
599
  },
539
600
  }) : undefined,
540
- spineContracts: spineContractInstances.encapsulation
601
+ spineContracts: spineContractInstances.encapsulation,
602
+ projectionContext: capsuleModuleProjectionRoot ? {
603
+ capsuleModuleProjectionPackage,
604
+ capsuleModuleProjectionRoot,
605
+ projectionStore: {
606
+ writeFile: async (filepath: string, content: string) => {
607
+ filepath = join(capsuleModuleProjectionRoot, filepath)
608
+ await mkdir(dirname(filepath), { recursive: true })
609
+ await writeFile(filepath, content, 'utf-8')
610
+ },
611
+ getStats: async (filepath: string) => {
612
+ filepath = join(capsuleModuleProjectionRoot, filepath)
613
+ try {
614
+ const stats = await stat(filepath)
615
+ return { mtime: stats.mtime }
616
+ } catch (error) {
617
+ return null
618
+ }
619
+ },
620
+ },
621
+ get capsules() { return capsules }
622
+ } : undefined
541
623
  })
542
624
  sourceSpine.encapsulate = encapsulate
543
625
 
@@ -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'