@stream44.studio/encapsulate 0.4.0-rc.5
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/.dco-signatures +9 -0
- package/.github/workflows/dco.yaml +12 -0
- package/.github/workflows/test.yml +26 -0
- package/.o/GordianOpenIntegrity-CurrentLifehash.svg +1026 -0
- package/.o/GordianOpenIntegrity-InceptionLifehash.svg +1026 -0
- package/.o/GordianOpenIntegrity.yaml +25 -0
- package/.o/assets/Hero-Explosion-v0.jpeg +0 -0
- package/DCO.md +34 -0
- package/LICENSE.md +8 -0
- package/README.md +46 -0
- package/package.json +33 -0
- package/src/capsule-projectors/CapsuleModuleProjector.v0.ts +1725 -0
- package/src/encapsulate.ts +881 -0
- package/src/spine-contracts/CapsuleSpineContract.v0/Membrane.v0.ts +705 -0
- package/src/spine-contracts/CapsuleSpineContract.v0/README.md +28 -0
- package/src/spine-contracts/CapsuleSpineContract.v0/Static.v0.ts +395 -0
- package/src/spine-factories/CapsuleSpineFactory.v0.ts +582 -0
- package/src/spine-factories/TimingObserver.ts +26 -0
- package/src/static-analyzer.v0.ts +1898 -0
- package/structs/Capsule.ts +22 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
|
|
2
|
+
type TSpineOptions = {
|
|
3
|
+
spineFilesystemRoot?: string,
|
|
4
|
+
spineContracts: Record<string, any>,
|
|
5
|
+
staticAnalyzer?: any,
|
|
6
|
+
timing?: { record: (step: string) => void, chalk?: any }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type TSpineRunOptions = {
|
|
10
|
+
overrides?: Record<string, any>,
|
|
11
|
+
options?: Record<string, any>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type TSpineRootsInvocationHandler = (context: { apis: Record<string, any>, capsules?: Record<string, any> }) => Promise<any>
|
|
15
|
+
|
|
16
|
+
type TSpine = {
|
|
17
|
+
freeze: () => Promise<TSpineSnapshot>,
|
|
18
|
+
encapsulate: (definition: TCapsuleDefinition, options: TCapsuleOptions) => Promise<TCapsule>,
|
|
19
|
+
capsules: Record<string, any>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type TSpineSnapshot = {
|
|
23
|
+
capsules: Record<string, Record<string, any>>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type TSpineRuntime = {
|
|
27
|
+
run: (options: TSpineRunOptions, handler: TSpineRootsInvocationHandler) => Promise<any>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type TSpineRuntimeOptions = {
|
|
31
|
+
spineFilesystemRoot?: string,
|
|
32
|
+
spineContracts?: Record<string, any>,
|
|
33
|
+
snapshot?: TSpineSnapshot,
|
|
34
|
+
capsules?: Record<string, any>,
|
|
35
|
+
loadCapsule?: (options: { capsuleSourceLineRef: string, capsuleSnapshot: any, capsuleName?: string }) => Promise<any>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type TCapsuleSnapshot = {
|
|
39
|
+
cst: any,
|
|
40
|
+
spineContracts: Record<string, any>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type TCapsuleMakeInstanceOptions = {
|
|
44
|
+
overrides?: Record<string, any>,
|
|
45
|
+
options?: Record<string, any>,
|
|
46
|
+
runtimeSpineContracts?: Record<string, any>,
|
|
47
|
+
sharedSelf?: Record<string, any>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type TCapsule = {
|
|
51
|
+
capsuleSourceLineRef: string,
|
|
52
|
+
definition: TCapsuleDefinition,
|
|
53
|
+
encapsulateOptions: TEncapsulateOptions,
|
|
54
|
+
cst?: any,
|
|
55
|
+
crt?: any,
|
|
56
|
+
makeInstance: (options?: TCapsuleMakeInstanceOptions) => any,
|
|
57
|
+
toCapsuleReference: () => { capsuleSourceLineRef: string, capsuleSourceNameRefHash: any }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Spine contract URI -> Property contract name -> Property definitions
|
|
61
|
+
// Property contracts can be empty {} (for struct markers) or contain property definitions
|
|
62
|
+
type TCapsuleDefinition = Record<string, Record<string, {} | Record<string, { type: keyof typeof CapsulePropertyTypes, [key: string]: any }>>>
|
|
63
|
+
|
|
64
|
+
type TCapsuleOptions = {
|
|
65
|
+
importMeta?: {
|
|
66
|
+
url: string
|
|
67
|
+
},
|
|
68
|
+
importStack?: string,
|
|
69
|
+
importStackLine?: number,
|
|
70
|
+
moduleFilepath?: string,
|
|
71
|
+
capsuleName?: string,
|
|
72
|
+
ambientReferences?: Record<string, any>,
|
|
73
|
+
extendsCapsule?: TCapsule | string,
|
|
74
|
+
cst?: any,
|
|
75
|
+
crt?: any
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type TEncapsulateOptions = {
|
|
79
|
+
moduleFilepath: string,
|
|
80
|
+
importStackLine: number,
|
|
81
|
+
capsuleName?: string,
|
|
82
|
+
ambientReferences?: Record<string, any>,
|
|
83
|
+
extendsCapsule?: TCapsule | string,
|
|
84
|
+
capsuleSourceLineRef: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type TSpineContext = {
|
|
88
|
+
spineOptions: TSpineOptions,
|
|
89
|
+
spineContracts: Record<string, any>,
|
|
90
|
+
capsules: Record<string, any>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
export const CapsulePropertyTypes = {
|
|
95
|
+
Function: 'Function' as const,
|
|
96
|
+
GetterFunction: 'GetterFunction' as const,
|
|
97
|
+
String: 'String' as const,
|
|
98
|
+
Mapping: 'Mapping' as const,
|
|
99
|
+
Literal: 'Literal' as const,
|
|
100
|
+
StructInit: 'StructInit' as const,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ##################################################
|
|
104
|
+
// # Spine
|
|
105
|
+
// ##################################################
|
|
106
|
+
|
|
107
|
+
export async function SpineRuntime(options: TSpineRuntimeOptions): Promise<TSpineRuntime> {
|
|
108
|
+
|
|
109
|
+
const spineContracts = options.spineContracts || {}
|
|
110
|
+
const capsules: Record<string, any> = {}
|
|
111
|
+
|
|
112
|
+
const loadedCapsules: Record<string, any> = options.capsules || {}
|
|
113
|
+
|
|
114
|
+
const spine = {
|
|
115
|
+
run: async function (
|
|
116
|
+
runOptions: TSpineRunOptions,
|
|
117
|
+
handler: TSpineRootsInvocationHandler
|
|
118
|
+
): Promise<any> {
|
|
119
|
+
|
|
120
|
+
const capsules: Record<string, any> = {}
|
|
121
|
+
|
|
122
|
+
const hydratedSnapshots: Record<string, any> = {}
|
|
123
|
+
|
|
124
|
+
// Ensure all capsules are hydrated.
|
|
125
|
+
await Promise.all(Object.entries(loadedCapsules).map(async ([capsuleSourceLineRef, capsule]) => {
|
|
126
|
+
|
|
127
|
+
const hydratedSnapshot = options.snapshot?.capsules?.[capsuleSourceLineRef]
|
|
128
|
+
if (!hydratedSnapshot) return
|
|
129
|
+
|
|
130
|
+
await Promise.all(Object.entries(hydratedSnapshot.spineContracts).map(async ([spineContractUri, capsuleContractSnapshot]) => {
|
|
131
|
+
hydratedSnapshot.spineContracts[spineContractUri] = spineContracts[spineContractUri].hydrate({
|
|
132
|
+
capsuleSnapshot: capsuleContractSnapshot
|
|
133
|
+
})
|
|
134
|
+
}))
|
|
135
|
+
|
|
136
|
+
hydratedSnapshots[capsuleSourceLineRef] = hydratedSnapshot
|
|
137
|
+
if (capsule.encapsulateOptions.capsuleName) {
|
|
138
|
+
hydratedSnapshots[capsule.encapsulateOptions.capsuleName] = hydratedSnapshot
|
|
139
|
+
}
|
|
140
|
+
}))
|
|
141
|
+
|
|
142
|
+
// Extract only the spine contract properties from hydrated snapshots
|
|
143
|
+
const hydratedOverrides: Record<string, any> = {}
|
|
144
|
+
for (const [capsuleRef, snapshot] of Object.entries(hydratedSnapshots)) {
|
|
145
|
+
if (snapshot.spineContracts) {
|
|
146
|
+
// Merge all spine contract properties into a single object for this capsule
|
|
147
|
+
const capsuleOverrides: Record<string, any> = {}
|
|
148
|
+
for (const [spineContractUri, spineContractData] of Object.entries(snapshot.spineContracts)) {
|
|
149
|
+
Object.assign(capsuleOverrides, spineContractData)
|
|
150
|
+
}
|
|
151
|
+
hydratedOverrides[capsuleRef] = capsuleOverrides
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const overrides = merge(
|
|
156
|
+
hydratedOverrides,
|
|
157
|
+
runOptions.overrides || {}
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
// Helper function to create a proxy that dynamically unwraps .api layers
|
|
161
|
+
function createUnwrappingProxy(obj: any): any {
|
|
162
|
+
if (!obj || typeof obj !== 'object') return obj
|
|
163
|
+
|
|
164
|
+
// If this object has an .api property, create a proxy for it
|
|
165
|
+
if (obj.api && typeof obj.api === 'object') {
|
|
166
|
+
return new Proxy(obj.api, {
|
|
167
|
+
get: (target: any, prop: string | symbol) => {
|
|
168
|
+
if (typeof prop === 'symbol') return target[prop]
|
|
169
|
+
|
|
170
|
+
let value = target[prop]
|
|
171
|
+
|
|
172
|
+
// If the value is a raw capsule instance (has spineContractCapsuleInstances
|
|
173
|
+
// but is NOT a Proxy that handles API access), unwrap it
|
|
174
|
+
// Static.v0 sets apiTarget[property.name] = mappedInstance (raw)
|
|
175
|
+
// Membrane.v0 sets apiTarget[property.name] = new Proxy(...) which handles API access
|
|
176
|
+
if (value && typeof value === 'object' && value.spineContractCapsuleInstances) {
|
|
177
|
+
// Check if this is a raw capsule instance by seeing if it has .api
|
|
178
|
+
// and the .api doesn't have the same spineContractCapsuleInstances
|
|
179
|
+
// (Membrane Proxy would return .api properties, not the raw structure)
|
|
180
|
+
if (value.api && typeof value.api === 'object' && !value.api.spineContractCapsuleInstances) {
|
|
181
|
+
return createUnwrappingProxy(value)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return value
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return obj
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const apis: Record<string, any> = {}
|
|
194
|
+
|
|
195
|
+
// Group keys by capsule object to avoid duplicate processing
|
|
196
|
+
const capsuleToKeys = new Map<any, string[]>()
|
|
197
|
+
for (const [key, capsule] of Object.entries(loadedCapsules)) {
|
|
198
|
+
if (!capsuleToKeys.has(capsule)) {
|
|
199
|
+
capsuleToKeys.set(capsule, [])
|
|
200
|
+
}
|
|
201
|
+
capsuleToKeys.get(capsule)!.push(key)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Instantiate each unique capsule once
|
|
205
|
+
for (const [capsule, keys] of capsuleToKeys) {
|
|
206
|
+
const instance = await capsule.makeInstance({
|
|
207
|
+
overrides,
|
|
208
|
+
options: runOptions.options?.[keys[0]],
|
|
209
|
+
runtimeSpineContracts: spineContracts
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// Register instance under all keys that reference this capsule
|
|
213
|
+
for (const key of keys) {
|
|
214
|
+
capsules[key] = {
|
|
215
|
+
capsule,
|
|
216
|
+
instance,
|
|
217
|
+
makeInstance: undefined
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Create proxy that dynamically unwraps .api layers
|
|
221
|
+
apis[key] = createUnwrappingProxy(instance)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Run StructInit functions for the entire capsule tree (top-down)
|
|
226
|
+
// Top-down means: child capsule's StructInit runs before extended parent's StructInit
|
|
227
|
+
const structInitVisited = new Set<any>()
|
|
228
|
+
async function runStructInits(instance: any) {
|
|
229
|
+
if (!instance || structInitVisited.has(instance)) return
|
|
230
|
+
structInitVisited.add(instance)
|
|
231
|
+
|
|
232
|
+
// Run this capsule's own StructInit functions first (top-down)
|
|
233
|
+
if (instance.structInitFunctions?.length) {
|
|
234
|
+
for (const fn of instance.structInitFunctions) {
|
|
235
|
+
await fn()
|
|
236
|
+
}
|
|
237
|
+
// Sync self values back to encapsulatedApi for spine contracts that use
|
|
238
|
+
// direct assignment (e.g. Static contract) rather than getters
|
|
239
|
+
if (instance.spineContractCapsuleInstances) {
|
|
240
|
+
for (const sci of Object.values(instance.spineContractCapsuleInstances) as any[]) {
|
|
241
|
+
if (sci.self && sci.encapsulatedApi) {
|
|
242
|
+
for (const key of Object.keys(sci.encapsulatedApi)) {
|
|
243
|
+
if (key in sci.self && sci.encapsulatedApi[key] !== sci.self[key]) {
|
|
244
|
+
sci.encapsulatedApi[key] = sci.self[key]
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Recurse into extended capsule instance
|
|
253
|
+
if (instance.extendedCapsuleInstance) {
|
|
254
|
+
await runStructInits(instance.extendedCapsuleInstance)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Recurse into mapped capsule instances
|
|
258
|
+
if (instance.mappedCapsuleInstances?.length) {
|
|
259
|
+
for (const mappedInstance of instance.mappedCapsuleInstances) {
|
|
260
|
+
await runStructInits(mappedInstance)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
for (const [, entry] of Object.entries(capsules)) {
|
|
266
|
+
await runStructInits((entry as any).instance)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const result = await handler({ apis, capsules })
|
|
270
|
+
|
|
271
|
+
return result
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
encapsulate: async function (definition: TCapsuleDefinition, encapsulateOptions: TCapsuleOptions): Promise<TCapsule> {
|
|
275
|
+
|
|
276
|
+
return encapsulate(definition, encapsulateOptions, {
|
|
277
|
+
spineOptions: {
|
|
278
|
+
spineFilesystemRoot: options.spineFilesystemRoot,
|
|
279
|
+
spineContracts,
|
|
280
|
+
staticAnalyzer: (options as any).staticAnalyzer
|
|
281
|
+
},
|
|
282
|
+
spineContracts,
|
|
283
|
+
capsules
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (options.snapshot) {
|
|
289
|
+
|
|
290
|
+
// NOTE: We can probably generate an optimized initialization tree for use at runtime
|
|
291
|
+
// that parallel loads as much as possible.
|
|
292
|
+
for (const [capsuleSourceLineRef, capsuleSnapshot] of Object.entries(options.snapshot.capsules)) {
|
|
293
|
+
|
|
294
|
+
if (typeof loadedCapsules[capsuleSourceLineRef] !== 'undefined') continue
|
|
295
|
+
|
|
296
|
+
// Extract capsuleName from snapshot if available
|
|
297
|
+
const capsuleName = (capsuleSnapshot as any)?.spineContracts?.['#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0']?.['#@stream44.studio/encapsulate/structs/Capsule']?.capsuleName
|
|
298
|
+
|
|
299
|
+
const capsule = await options.loadCapsule!({
|
|
300
|
+
capsuleSourceLineRef,
|
|
301
|
+
capsuleSnapshot,
|
|
302
|
+
capsuleName
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
loadedCapsules[capsuleSourceLineRef] = await capsule({
|
|
306
|
+
encapsulate: spine.encapsulate,
|
|
307
|
+
CapsulePropertyTypes,
|
|
308
|
+
makeImportStack,
|
|
309
|
+
loadCapsule: options.loadCapsule
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return spine
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function Spine(options: TSpineOptions): Promise<TSpine> {
|
|
318
|
+
|
|
319
|
+
const spineOptions = options
|
|
320
|
+
|
|
321
|
+
options.timing?.record('Spine: Initialized')
|
|
322
|
+
|
|
323
|
+
if (typeof spineOptions.spineFilesystemRoot === 'undefined') throw new Error(`'spineFilesystemRoot' not defined!`)
|
|
324
|
+
if (typeof spineOptions.spineContracts === 'undefined') throw new Error(`'spineContracts' not defined!`)
|
|
325
|
+
|
|
326
|
+
const spineContracts = spineOptions.spineContracts
|
|
327
|
+
const capsules: Record<string, any> = {}
|
|
328
|
+
|
|
329
|
+
options.timing?.record('Spine: Ready to encapsulate')
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
capsules,
|
|
333
|
+
freeze: async function (): Promise<TSpineSnapshot> {
|
|
334
|
+
|
|
335
|
+
options.timing?.record('Spine: Starting freeze')
|
|
336
|
+
|
|
337
|
+
const snapshot: TSpineSnapshot = {
|
|
338
|
+
capsules: {}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
options.timing?.record(`Spine: Freezing ${Object.keys(capsules).length} capsules`)
|
|
342
|
+
|
|
343
|
+
await Promise.all(Object.entries(capsules).map(async ([capsuleSourceLineRef, capsule]) => {
|
|
344
|
+
|
|
345
|
+
if (!capsule.cst.source.capsuleName) throw new Error(`'capsuleName' must be set for encapsulate options to enable freezing.`)
|
|
346
|
+
|
|
347
|
+
snapshot.capsules[capsuleSourceLineRef] = {
|
|
348
|
+
cst: capsule.cst,
|
|
349
|
+
spineContracts: {}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const { spineContractCapsuleInstances } = await capsule.makeInstance()
|
|
353
|
+
|
|
354
|
+
await Promise.all(Object.entries(spineContractCapsuleInstances).map(async ([spineContractUri, spineContractCapsuleInstance]) => {
|
|
355
|
+
|
|
356
|
+
snapshot.capsules[capsuleSourceLineRef] = merge(
|
|
357
|
+
snapshot.capsules[capsuleSourceLineRef],
|
|
358
|
+
await (spineContractCapsuleInstance as any).freeze({
|
|
359
|
+
spineContractUri,
|
|
360
|
+
capsule
|
|
361
|
+
})
|
|
362
|
+
)
|
|
363
|
+
}))
|
|
364
|
+
}))
|
|
365
|
+
|
|
366
|
+
options.timing?.record('Spine: Freeze complete')
|
|
367
|
+
|
|
368
|
+
// console.log('snapshot:', JSON.stringify(snapshot, null, 4))
|
|
369
|
+
return snapshot
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
encapsulate: async function (definition: TCapsuleDefinition, options: TCapsuleOptions): Promise<TCapsule> {
|
|
373
|
+
|
|
374
|
+
return encapsulate(definition, options, {
|
|
375
|
+
spineOptions,
|
|
376
|
+
spineContracts,
|
|
377
|
+
capsules
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
// ##################################################
|
|
387
|
+
// # Encapsulate
|
|
388
|
+
// ##################################################
|
|
389
|
+
|
|
390
|
+
async function encapsulate(definition: TCapsuleDefinition, options: TCapsuleOptions, spine: TSpineContext): Promise<TCapsule> {
|
|
391
|
+
|
|
392
|
+
if (!options.importMeta && !options.moduleFilepath) throw new Error(`'options.importMeta' nor 'options.moduleFilepath' not specified!`)
|
|
393
|
+
if (!options.importStack && !options.importStackLine) throw new Error(`'options.importStack' nor 'options.importStackLine' specified!`)
|
|
394
|
+
|
|
395
|
+
const moduleFilepath = options.moduleFilepath || relative(spine.spineOptions.spineFilesystemRoot || '', options.importMeta!.url.replace(/^file:\/\//, ''))
|
|
396
|
+
const importStackLine = options.importStackLine || formatImportStackFrame(options.importStack!)
|
|
397
|
+
|
|
398
|
+
if (typeof importStackLine !== 'number') throw new Error(`Could not determine importStackLine from options`)
|
|
399
|
+
|
|
400
|
+
const encapsulateOptions: TEncapsulateOptions = {
|
|
401
|
+
moduleFilepath,
|
|
402
|
+
importStackLine,
|
|
403
|
+
capsuleName: options.capsuleName,
|
|
404
|
+
ambientReferences: options.ambientReferences,
|
|
405
|
+
extendsCapsule: options.extendsCapsule,
|
|
406
|
+
capsuleSourceLineRef: `${moduleFilepath}:${importStackLine}`
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
spine.spineOptions.timing?.record(`Encapsulate: Start for ${moduleFilepath}`)
|
|
410
|
+
|
|
411
|
+
const { csts, crts } = await spine.spineOptions.staticAnalyzer?.parseModule({
|
|
412
|
+
spineOptions: spine.spineOptions,
|
|
413
|
+
encapsulateOptions
|
|
414
|
+
}) || {
|
|
415
|
+
csts: options.cst ? { [encapsulateOptions.capsuleSourceLineRef]: options.cst } : undefined,
|
|
416
|
+
crts: options.crt ? { [encapsulateOptions.capsuleSourceLineRef]: options.crt } : undefined
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const defaultInstance: Record<string, any> = {}
|
|
420
|
+
|
|
421
|
+
// Cache for instances to prevent duplicate makeInstance calls
|
|
422
|
+
const instanceCache = new Map<string, Promise<any>>()
|
|
423
|
+
|
|
424
|
+
const capsule: TCapsule = {
|
|
425
|
+
toCapsuleReference: () => {
|
|
426
|
+
return {
|
|
427
|
+
capsuleSourceLineRef: encapsulateOptions.capsuleSourceLineRef,
|
|
428
|
+
capsuleSourceNameRefHash: capsule.cst.capsuleSourceNameRefHash,
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
capsuleSourceLineRef: encapsulateOptions.capsuleSourceLineRef,
|
|
432
|
+
definition,
|
|
433
|
+
encapsulateOptions,
|
|
434
|
+
cst: csts?.[encapsulateOptions.capsuleSourceLineRef],
|
|
435
|
+
crt: crts?.[encapsulateOptions.capsuleSourceLineRef],
|
|
436
|
+
makeInstance: async ({ overrides = {}, options = {}, runtimeSpineContracts, sharedSelf }: TCapsuleMakeInstanceOptions = {}) => {
|
|
437
|
+
|
|
438
|
+
// Create cache key based on parameters
|
|
439
|
+
// When sharedSelf is provided, we must NOT cache because each extending capsule
|
|
440
|
+
// needs its own instance with its own 'this' context (sharedSelf).
|
|
441
|
+
// This is critical for the pattern where multiple structs extend the same parent.
|
|
442
|
+
const cacheKey = sharedSelf ? null : JSON.stringify({
|
|
443
|
+
overrides,
|
|
444
|
+
options,
|
|
445
|
+
hasRuntimeContracts: !!runtimeSpineContracts
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
// Check if we already have a pending or completed instance creation
|
|
449
|
+
// Skip cache when sharedSelf is provided (cacheKey is null)
|
|
450
|
+
if (cacheKey && instanceCache.has(cacheKey)) {
|
|
451
|
+
return instanceCache.get(cacheKey)!
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Create the instance promise and cache it immediately (only if cacheKey is set)
|
|
455
|
+
const instancePromise = (async () => {
|
|
456
|
+
const encapsulatedApi: Record<string, any> = {}
|
|
457
|
+
const spineContractCapsuleInstances: Record<string, any> = {}
|
|
458
|
+
|
|
459
|
+
// Property contracts are keys starting with '#' that contain nested properties
|
|
460
|
+
// Structure: spineContractUri -> propertyContractUri -> propertyName -> propertyDef
|
|
461
|
+
const propertyContractDefinitions: Record<string, Record<string, Record<string, any>>> = {}
|
|
462
|
+
// Track which property contracts are defined for validation
|
|
463
|
+
const definedPropertyContracts = new Set<string>()
|
|
464
|
+
|
|
465
|
+
for (const [spineContractUri, propertyDefinitions] of Object.entries(definition)) {
|
|
466
|
+
// Validate that at least one property contract key exists
|
|
467
|
+
const hasPropertyContract = Object.keys(propertyDefinitions).some(key => key.startsWith('#'))
|
|
468
|
+
if (!hasPropertyContract) {
|
|
469
|
+
throw new Error(`Spine contract '${spineContractUri}' for capsule '${capsule.capsuleSourceLineRef}' must specify at least one property contract layer using a key starting with '#'. For example: '#': { ...properties }`)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
propertyContractDefinitions[spineContractUri] = {}
|
|
473
|
+
|
|
474
|
+
for (const [propContractUri, propDef] of Object.entries(propertyDefinitions)) {
|
|
475
|
+
if (propContractUri.startsWith('#')) {
|
|
476
|
+
|
|
477
|
+
// This is a property contract - store its properties (merge if it already exists)
|
|
478
|
+
if (!propertyContractDefinitions[spineContractUri][propContractUri]) {
|
|
479
|
+
propertyContractDefinitions[spineContractUri][propContractUri] = {}
|
|
480
|
+
}
|
|
481
|
+
Object.assign(propertyContractDefinitions[spineContractUri][propContractUri], propDef as Record<string, any>)
|
|
482
|
+
definedPropertyContracts.add(propContractUri)
|
|
483
|
+
|
|
484
|
+
// If this is a non-default property contract, add a dynamic mapping to the '#' contract
|
|
485
|
+
if (propContractUri !== '#') {
|
|
486
|
+
// We have a property contract URI we need to resolve and load
|
|
487
|
+
// Add a dynamic property mapping for this contract to the '#' group
|
|
488
|
+
// Check if 'as' is defined to use as the property name alias
|
|
489
|
+
const propDefTyped = propDef as Record<string, any>
|
|
490
|
+
const aliasName = propDefTyped.as
|
|
491
|
+
const delegateOptions = propDefTyped.options
|
|
492
|
+
const contractKey = aliasName || ('#' + propContractUri.substring(1))
|
|
493
|
+
|
|
494
|
+
if (!propertyContractDefinitions[spineContractUri]['#']) {
|
|
495
|
+
propertyContractDefinitions[spineContractUri]['#'] = {}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
propertyContractDefinitions[spineContractUri]['#'][contractKey] = {
|
|
499
|
+
type: CapsulePropertyTypes.Mapping,
|
|
500
|
+
value: propContractUri.substring(1),
|
|
501
|
+
propertyContractDelegate: propContractUri,
|
|
502
|
+
as: aliasName,
|
|
503
|
+
// Pass options from the property contract delegate to the mapped capsule
|
|
504
|
+
delegateOptions
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
} else {
|
|
509
|
+
throw new Error(`Property '${propContractUri}' in spine contract '${spineContractUri}' for capsule '${capsule.capsuleSourceLineRef}' must be nested under a property contract uri starting with '#'. For example: '#': { ${propContractUri}: {...} }`)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Merge overrides and options by property contract
|
|
515
|
+
// Structure: propertyContractUri -> propertyName -> value
|
|
516
|
+
const mergedValuesByContract: Record<string, Record<string, any>> = {}
|
|
517
|
+
|
|
518
|
+
// Helper to validate and merge values from a source (overrides or options)
|
|
519
|
+
const mergeByContract = (source: any, sourceName: string) => {
|
|
520
|
+
if (!source) return
|
|
521
|
+
|
|
522
|
+
for (const [propertyContractUri, properties] of Object.entries(source)) {
|
|
523
|
+
if (!propertyContractUri.startsWith('#')) {
|
|
524
|
+
throw new Error(`${sourceName} for capsule '${capsule.capsuleSourceLineRef}' must use property contract keys starting with '#'. Found key: '${propertyContractUri}'`)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (!definedPropertyContracts.has(propertyContractUri)) {
|
|
528
|
+
throw new Error(`${sourceName} for capsule '${capsule.capsuleSourceLineRef}' references property contract '${propertyContractUri}' which is not defined on the capsule`)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (!mergedValuesByContract[propertyContractUri]) {
|
|
532
|
+
mergedValuesByContract[propertyContractUri] = {}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
Object.assign(mergedValuesByContract[propertyContractUri], properties)
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Merge in order: overrides by lineRef, overrides by name, options
|
|
540
|
+
mergeByContract(overrides?.[encapsulateOptions.capsuleSourceLineRef], 'Overrides')
|
|
541
|
+
if (encapsulateOptions.capsuleName) {
|
|
542
|
+
mergeByContract(overrides?.[encapsulateOptions.capsuleName], 'Overrides')
|
|
543
|
+
}
|
|
544
|
+
mergeByContract(options, 'Options')
|
|
545
|
+
|
|
546
|
+
// Extract default values from property definitions (Literal/String types)
|
|
547
|
+
// This ensures child capsule's default values are available before parent is instantiated
|
|
548
|
+
const defaultPropertyValues: Record<string, any> = {}
|
|
549
|
+
for (const [spineContractUri, propertyContracts] of Object.entries(propertyContractDefinitions)) {
|
|
550
|
+
for (const [propertyContractUri, properties] of Object.entries(propertyContracts)) {
|
|
551
|
+
if (propertyContractUri !== '#') continue
|
|
552
|
+
for (const [propertyName, propertyDef] of Object.entries(properties as Record<string, any>)) {
|
|
553
|
+
if (propertyDef.type === CapsulePropertyTypes.Literal ||
|
|
554
|
+
propertyDef.type === CapsulePropertyTypes.String) {
|
|
555
|
+
if (propertyDef.value !== undefined) {
|
|
556
|
+
defaultPropertyValues[propertyName] = propertyDef.value
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Create a single shared self for all spine contracts by flattening merged values
|
|
564
|
+
// ownValues contains this capsule's defaults and overrides
|
|
565
|
+
const ownValues = merge({}, defaultInstance, defaultPropertyValues, ...Object.values(mergedValuesByContract))
|
|
566
|
+
|
|
567
|
+
// If sharedSelf is provided (from extending capsule), we need to:
|
|
568
|
+
// 1. Add parent's properties that child doesn't have
|
|
569
|
+
// 2. Keep child's values for properties that exist in both
|
|
570
|
+
// We do this by assigning parent's values first, then child's values on top
|
|
571
|
+
let self: any
|
|
572
|
+
if (sharedSelf) {
|
|
573
|
+
// Save child's current values (only non-undefined values)
|
|
574
|
+
const childValues: Record<string, any> = {}
|
|
575
|
+
for (const [key, value] of Object.entries(sharedSelf)) {
|
|
576
|
+
if (value !== undefined) {
|
|
577
|
+
childValues[key] = value
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
// Assign parent's defaults to sharedSelf (for properties child doesn't have)
|
|
581
|
+
for (const [key, value] of Object.entries(ownValues)) {
|
|
582
|
+
if (!(key in childValues)) {
|
|
583
|
+
sharedSelf[key] = value
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
self = sharedSelf
|
|
587
|
+
} else {
|
|
588
|
+
self = ownValues
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Create ownSelf containing only this capsule's own properties (not from extends chain)
|
|
592
|
+
// This allows functions to access this.self for their own capsule's properties
|
|
593
|
+
// The selfProxy in spine contracts will expose this as 'self' property
|
|
594
|
+
const ownSelf = merge({}, defaultInstance, defaultPropertyValues, ...Object.values(mergedValuesByContract))
|
|
595
|
+
|
|
596
|
+
// Initialize extended capsule instance if this capsule extends another
|
|
597
|
+
// Pass our self so extended capsule's functions bind to the same context
|
|
598
|
+
let extendedCapsuleInstance: any = undefined
|
|
599
|
+
|
|
600
|
+
// Check CST first, then fall back to encapsulateOptions for direct capsule references
|
|
601
|
+
let extendsCapsuleValue = capsule.cst?.source?.extendsCapsule || encapsulateOptions.extendsCapsule
|
|
602
|
+
|
|
603
|
+
// If extendsCapsule is a string identifier, check if it's in ambientReferences first
|
|
604
|
+
if (typeof extendsCapsuleValue === 'string') {
|
|
605
|
+
const cstAmbientRefs = capsule.cst?.source?.ambientReferences || {}
|
|
606
|
+
const runtimeAmbientRefs = encapsulateOptions.ambientReferences || {}
|
|
607
|
+
for (const [refName, ref] of Object.entries(cstAmbientRefs)) {
|
|
608
|
+
const refTyped = ref as any
|
|
609
|
+
if (refName === extendsCapsuleValue) {
|
|
610
|
+
if (refTyped.type === 'capsule' && refTyped.value) {
|
|
611
|
+
extendsCapsuleValue = refTyped.value
|
|
612
|
+
} else if (refTyped.type === 'instance' && runtimeAmbientRefs[refName]) {
|
|
613
|
+
// CST stores '[instance]' placeholder; resolve from runtime ambient refs
|
|
614
|
+
const runtimeRef = runtimeAmbientRefs[refName]
|
|
615
|
+
if (runtimeRef && typeof runtimeRef === 'object' && typeof runtimeRef.makeInstance === 'function') {
|
|
616
|
+
extendsCapsuleValue = runtimeRef
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
break
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (extendsCapsuleValue) {
|
|
625
|
+
let extendsCapsule = extendsCapsuleValue
|
|
626
|
+
|
|
627
|
+
// If it's a string, resolve it using the same mechanism as mappings
|
|
628
|
+
if (typeof extendsCapsule === 'string') {
|
|
629
|
+
// Use the first available spine contract to resolve the URI
|
|
630
|
+
const activeSpineContracts = runtimeSpineContracts || spine.spineContracts
|
|
631
|
+
const firstSpineContractKey = Object.keys(activeSpineContracts)[0]
|
|
632
|
+
const firstSpineContract = activeSpineContracts[firstSpineContractKey] as any
|
|
633
|
+
|
|
634
|
+
if (!firstSpineContract) throw new Error(`No spine contracts available to resolve extendsCapsule URI!`)
|
|
635
|
+
|
|
636
|
+
// Create a contract instance to use resolveMappedCapsule
|
|
637
|
+
const contractInstance = firstSpineContract.makeContractCapsuleInstance({
|
|
638
|
+
spineContractUri: firstSpineContractKey,
|
|
639
|
+
encapsulateOptions,
|
|
640
|
+
capsuleInstance: { api: {}, spineContractCapsuleInstances: {} },
|
|
641
|
+
self: {},
|
|
642
|
+
capsule,
|
|
643
|
+
encapsulatedApi: {},
|
|
644
|
+
runtimeSpineContracts
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
// Resolve using the same mechanism as mappings
|
|
648
|
+
extendsCapsule = await contractInstance.resolveMappedCapsule({
|
|
649
|
+
property: {
|
|
650
|
+
name: '__extends__',
|
|
651
|
+
definition: { value: extendsCapsule }
|
|
652
|
+
}
|
|
653
|
+
})
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
extendedCapsuleInstance = await extendsCapsule.makeInstance({
|
|
657
|
+
overrides,
|
|
658
|
+
options,
|
|
659
|
+
runtimeSpineContracts,
|
|
660
|
+
sharedSelf: self
|
|
661
|
+
})
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const capsuleInstance: any = {
|
|
665
|
+
api: encapsulatedApi,
|
|
666
|
+
spineContractCapsuleInstances,
|
|
667
|
+
extendedCapsuleInstance,
|
|
668
|
+
structInitFunctions: [] as Array<() => any>,
|
|
669
|
+
mappedCapsuleInstances: [] as Array<any>
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Use runtime spine contracts if provided, otherwise fall back to encapsulation spine contracts
|
|
673
|
+
const activeSpineContracts = runtimeSpineContracts || spine.spineContracts
|
|
674
|
+
|
|
675
|
+
for (const [spineContractUri, propertyContracts] of Object.entries(propertyContractDefinitions)) {
|
|
676
|
+
|
|
677
|
+
const spineContract = activeSpineContracts[spineContractUri] as any
|
|
678
|
+
|
|
679
|
+
if (!spineContract) throw new Error(`Contract uri '${spineContractUri}' used by capsule not available in Spine!`)
|
|
680
|
+
|
|
681
|
+
const spineContractCapsuleInstance = spineContract.makeContractCapsuleInstance({
|
|
682
|
+
spineContractUri,
|
|
683
|
+
encapsulateOptions,
|
|
684
|
+
capsuleInstance,
|
|
685
|
+
self,
|
|
686
|
+
ownSelf,
|
|
687
|
+
capsule,
|
|
688
|
+
encapsulatedApi,
|
|
689
|
+
runtimeSpineContracts,
|
|
690
|
+
extendedCapsuleInstance
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
spineContractCapsuleInstances[spineContractUri] = spineContractCapsuleInstance
|
|
694
|
+
|
|
695
|
+
// Iterate through each property contract within this spine contract
|
|
696
|
+
for (const [propertyContractUri, properties] of Object.entries(propertyContracts)) {
|
|
697
|
+
// Skip non-'#' property contracts as they're already accessible via dynamic mappings in '#'
|
|
698
|
+
if (propertyContractUri !== '#') {
|
|
699
|
+
continue
|
|
700
|
+
}
|
|
701
|
+
for (const [propertyName, propertyDefinition] of Object.entries(properties)) {
|
|
702
|
+
|
|
703
|
+
if (!propertyDefinition.type || !(propertyDefinition.type in CapsulePropertyTypes)) throw new Error(`Type '${propertyDefinition.type}' for property '${propertyName}' on spineContract '${spineContractUri}' not set or supported!`)
|
|
704
|
+
|
|
705
|
+
await spineContractCapsuleInstance.mapProperty({
|
|
706
|
+
overrides,
|
|
707
|
+
options,
|
|
708
|
+
property: {
|
|
709
|
+
name: propertyName,
|
|
710
|
+
definition: propertyDefinition,
|
|
711
|
+
propertyContractUri
|
|
712
|
+
}
|
|
713
|
+
})
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Collect StructInit functions and mapped capsule instances from all spine contract capsule instances
|
|
719
|
+
for (const spineContractCapsuleInstance of Object.values(spineContractCapsuleInstances)) {
|
|
720
|
+
const sci = spineContractCapsuleInstance as any
|
|
721
|
+
if (sci.structInitFunctions?.length) {
|
|
722
|
+
capsuleInstance.structInitFunctions.push(...sci.structInitFunctions)
|
|
723
|
+
}
|
|
724
|
+
if (sci.mappedCapsuleInstances?.length) {
|
|
725
|
+
capsuleInstance.mappedCapsuleInstances.push(...sci.mappedCapsuleInstances)
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Wrap encapsulatedApi in a proxy that delegates to extended capsule's API for missing properties
|
|
730
|
+
if (extendedCapsuleInstance) {
|
|
731
|
+
capsuleInstance.api = new Proxy(encapsulatedApi, {
|
|
732
|
+
get: (target: any, prop: string | symbol) => {
|
|
733
|
+
if (typeof prop === 'symbol') return target[prop]
|
|
734
|
+
|
|
735
|
+
// First check if the property exists in local API
|
|
736
|
+
if (prop in target) {
|
|
737
|
+
return target[prop]
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Fall back to extended capsule's API
|
|
741
|
+
if (prop in extendedCapsuleInstance.api) {
|
|
742
|
+
return extendedCapsuleInstance.api[prop]
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
return undefined
|
|
746
|
+
},
|
|
747
|
+
has: (target: any, prop: string | symbol) => {
|
|
748
|
+
return prop in target || prop in extendedCapsuleInstance.api
|
|
749
|
+
}
|
|
750
|
+
})
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return capsuleInstance
|
|
754
|
+
})()
|
|
755
|
+
|
|
756
|
+
// Cache the promise only if cacheKey is set (not when sharedSelf is provided)
|
|
757
|
+
if (cacheKey) {
|
|
758
|
+
instanceCache.set(cacheKey, instancePromise)
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
return instancePromise
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
spine.capsules[encapsulateOptions.capsuleSourceLineRef] = capsule
|
|
766
|
+
if (encapsulateOptions.capsuleName) {
|
|
767
|
+
spine.capsules[encapsulateOptions.capsuleName] = capsule
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
spine.spineOptions.timing?.record(`Encapsulate: Complete for ${moduleFilepath}`)
|
|
771
|
+
|
|
772
|
+
return capsule
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
function formatImportStackFrame(importStack: string): number | undefined {
|
|
778
|
+
const stackLines = importStack.split('\n')
|
|
779
|
+
|
|
780
|
+
const hasMakeImportStackMarker = importStack.includes('encapsulate:makeImportStack')
|
|
781
|
+
const targetMatchCount = hasMakeImportStackMarker ? 2 : 1
|
|
782
|
+
|
|
783
|
+
let matchCount = 0
|
|
784
|
+
for (let i = 0; i < stackLines.length; i++) {
|
|
785
|
+
const line = stackLines[i]
|
|
786
|
+
|
|
787
|
+
if (line.includes('encapsulate:makeImportStack')) {
|
|
788
|
+
continue
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const match = line.match(/\(([^)]+):([0-9]+):[0-9]+\)|at ([^(]+):([0-9]+):[0-9]+/)
|
|
792
|
+
if (match) {
|
|
793
|
+
matchCount++
|
|
794
|
+
if (matchCount === targetMatchCount) {
|
|
795
|
+
const lineNumber = parseInt(match[2] || match[4])
|
|
796
|
+
return lineNumber
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return undefined
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ##################################################
|
|
805
|
+
// # Utilities
|
|
806
|
+
// ##################################################
|
|
807
|
+
|
|
808
|
+
export function makeImportStack() {
|
|
809
|
+
return new Error('encapsulate:makeImportStack').stack
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export function join(...paths: string[]): string {
|
|
813
|
+
if (paths.length === 0) return '.'
|
|
814
|
+
|
|
815
|
+
let joined = paths.join('/')
|
|
816
|
+
|
|
817
|
+
const isAbsolute = joined.startsWith('/')
|
|
818
|
+
const parts: string[] = []
|
|
819
|
+
|
|
820
|
+
for (const part of joined.split('/')) {
|
|
821
|
+
if (part === '' || part === '.') continue
|
|
822
|
+
if (part === '..') {
|
|
823
|
+
if (parts.length > 0 && parts[parts.length - 1] !== '..') {
|
|
824
|
+
parts.pop()
|
|
825
|
+
} else if (!isAbsolute) {
|
|
826
|
+
parts.push('..')
|
|
827
|
+
}
|
|
828
|
+
} else {
|
|
829
|
+
parts.push(part)
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
let result = parts.join('/')
|
|
834
|
+
if (isAbsolute) result = '/' + result
|
|
835
|
+
|
|
836
|
+
return result || (isAbsolute ? '/' : '.')
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function relative(from: string, to: string): string {
|
|
840
|
+
const fromParts = from.split('/').filter(p => p && p !== '.')
|
|
841
|
+
const toParts = to.split('/').filter(p => p && p !== '.')
|
|
842
|
+
|
|
843
|
+
let commonLength = 0
|
|
844
|
+
const minLength = Math.min(fromParts.length, toParts.length)
|
|
845
|
+
|
|
846
|
+
for (let i = 0; i < minLength; i++) {
|
|
847
|
+
if (fromParts[i] === toParts[i]) {
|
|
848
|
+
commonLength++
|
|
849
|
+
} else {
|
|
850
|
+
break
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const upCount = fromParts.length - commonLength
|
|
855
|
+
const remainingTo = toParts.slice(commonLength)
|
|
856
|
+
|
|
857
|
+
const result = [...Array(upCount).fill('..'), ...remainingTo].join('/')
|
|
858
|
+
return result || '.'
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function isObject(item: any): boolean {
|
|
862
|
+
return item && typeof item === 'object' && !Array.isArray(item)
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
export function merge<T = any>(target: T, ...sources: any[]): T {
|
|
866
|
+
if (!sources.length) return target
|
|
867
|
+
const source = sources.shift()
|
|
868
|
+
|
|
869
|
+
if (isObject(target) && isObject(source)) {
|
|
870
|
+
for (const key in source) {
|
|
871
|
+
if (isObject(source[key])) {
|
|
872
|
+
if (!target[key as keyof T]) Object.assign(target, { [key]: {} })
|
|
873
|
+
merge(target[key as keyof T], source[key])
|
|
874
|
+
} else {
|
|
875
|
+
Object.assign(target, { [key]: source[key] })
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
return merge(target, ...sources)
|
|
881
|
+
}
|