@open-mercato/shared 0.6.7-develop.6758.1.697eade236 → 0.6.7-develop.6768.1.9d2c4efc43
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/.turbo/turbo-build.log +1 -1
- package/dist/lib/bootstrap/dynamicLoader.js +174 -5
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/widgets/extension-points.js +96 -0
- package/dist/modules/widgets/extension-points.js.map +7 -0
- package/package.json +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.cacheRecovery.test.ts +38 -5
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +37 -4
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +292 -0
- package/src/lib/bootstrap/dynamicLoader.ts +239 -7
- package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
- package/src/modules/widgets/extension-points.ts +421 -0
|
@@ -33,6 +33,7 @@ import fs from 'node:fs'
|
|
|
33
33
|
import os from 'node:os'
|
|
34
34
|
import path from 'node:path'
|
|
35
35
|
import { createLogger } from '../../logger'
|
|
36
|
+
import crypto from 'node:crypto'
|
|
36
37
|
import { loadBootstrapData } from '../dynamicLoader'
|
|
37
38
|
|
|
38
39
|
const mockedLogger = createLogger('shared') as unknown as {
|
|
@@ -53,11 +54,42 @@ const GENERATED_MODULES: Record<string, { ts: string; compiled: string }> = {
|
|
|
53
54
|
},
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
const APP_TSCONFIG = JSON.stringify({
|
|
58
|
+
compilerOptions: {
|
|
59
|
+
experimentalDecorators: true,
|
|
60
|
+
emitDecoratorMetadata: true,
|
|
61
|
+
useDefineForClassFields: false,
|
|
62
|
+
target: 'ES2022',
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
function hash(content: string): string {
|
|
67
|
+
return crypto.createHash('sha256').update(content).digest('hex')
|
|
68
|
+
}
|
|
69
|
+
|
|
56
70
|
function writeGeneratedModule(generatedDir: string, baseName: string, source: { ts: string; compiled: string }) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
71
|
+
const sourcePath = path.join(generatedDir, `${baseName}.ts`)
|
|
72
|
+
const compiledPath = path.join(generatedDir, `${baseName}.mjs`)
|
|
73
|
+
const sourceRelativePath = path.relative(
|
|
74
|
+
path.dirname(path.dirname(generatedDir)),
|
|
75
|
+
sourcePath,
|
|
76
|
+
).split(path.sep).join('/')
|
|
77
|
+
fs.writeFileSync(sourcePath, source.ts)
|
|
78
|
+
fs.writeFileSync(compiledPath, source.compiled)
|
|
79
|
+
fs.writeFileSync(`${compiledPath}.cache.json`, JSON.stringify({
|
|
80
|
+
version: 4,
|
|
81
|
+
inputHash: hash(JSON.stringify({
|
|
82
|
+
version: 4,
|
|
83
|
+
sourceHash: hash(source.ts),
|
|
84
|
+
tsconfigHashes: {
|
|
85
|
+
'tsconfig.json': hash(APP_TSCONFIG),
|
|
86
|
+
},
|
|
87
|
+
})),
|
|
88
|
+
outputHash: hash(source.compiled),
|
|
89
|
+
dependencies: {
|
|
90
|
+
[sourceRelativePath]: hash(source.ts),
|
|
91
|
+
},
|
|
92
|
+
}))
|
|
61
93
|
}
|
|
62
94
|
|
|
63
95
|
const createdAppRoots: string[] = []
|
|
@@ -66,6 +98,7 @@ function createAppRoot(overrides: Record<string, { ts: string; compiled: string
|
|
|
66
98
|
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'om-bootstrap-4327-'))
|
|
67
99
|
const generatedDir = path.join(appRoot, '.mercato', 'generated')
|
|
68
100
|
fs.mkdirSync(generatedDir, { recursive: true })
|
|
101
|
+
fs.writeFileSync(path.join(appRoot, 'tsconfig.json'), APP_TSCONFIG)
|
|
69
102
|
for (const [baseName, source] of Object.entries({ ...GENERATED_MODULES, ...overrides })) {
|
|
70
103
|
writeGeneratedModule(generatedDir, baseName, source)
|
|
71
104
|
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { execFileSync } from 'node:child_process'
|
|
7
|
+
import { pathToFileURL } from 'node:url'
|
|
8
|
+
|
|
9
|
+
const LEGACY_TSCONFIG = {
|
|
10
|
+
compilerOptions: {
|
|
11
|
+
experimentalDecorators: true,
|
|
12
|
+
emitDecoratorMetadata: true,
|
|
13
|
+
useDefineForClassFields: false,
|
|
14
|
+
target: 'ES2022',
|
|
15
|
+
module: 'ESNext',
|
|
16
|
+
moduleResolution: 'Bundler',
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const GENERATED_MODULES: Record<string, string> = {
|
|
21
|
+
'entities.ids.generated': 'export const E = {}',
|
|
22
|
+
'modules.cli.generated': 'export const modules = []',
|
|
23
|
+
'di.generated': 'export const diRegistrars = []',
|
|
24
|
+
'entities.generated': `
|
|
25
|
+
import { ProbeEntity } from '@/src/modules/probe/data/entities'
|
|
26
|
+
export const entities = [ProbeEntity]
|
|
27
|
+
`,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeFixture(): string {
|
|
31
|
+
const appRoot = fs.mkdtempSync(path.join(process.cwd(), '.tmp-dynamic-loader-'))
|
|
32
|
+
const generatedDir = path.join(appRoot, '.mercato', 'generated')
|
|
33
|
+
const entityDir = path.join(appRoot, 'src', 'modules', 'probe', 'data')
|
|
34
|
+
fs.mkdirSync(generatedDir, { recursive: true })
|
|
35
|
+
fs.mkdirSync(entityDir, { recursive: true })
|
|
36
|
+
fs.writeFileSync(path.join(appRoot, 'tsconfig.json'), JSON.stringify(LEGACY_TSCONFIG))
|
|
37
|
+
fs.writeFileSync(path.join(entityDir, 'entities.ts'), `
|
|
38
|
+
import { Entity, PrimaryKey } from '@mikro-orm/decorators/legacy'
|
|
39
|
+
|
|
40
|
+
@Entity()
|
|
41
|
+
export class ProbeEntity {
|
|
42
|
+
static revision = 1
|
|
43
|
+
|
|
44
|
+
@PrimaryKey()
|
|
45
|
+
id!: string
|
|
46
|
+
}
|
|
47
|
+
`)
|
|
48
|
+
for (const [baseName, source] of Object.entries(GENERATED_MODULES)) {
|
|
49
|
+
fs.writeFileSync(path.join(generatedDir, `${baseName}.ts`), source)
|
|
50
|
+
}
|
|
51
|
+
return appRoot
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readCacheMetadata(appRoot: string, baseName: string): {
|
|
55
|
+
version: number
|
|
56
|
+
inputHash: string
|
|
57
|
+
outputHash: string
|
|
58
|
+
dependencies: Record<string, string>
|
|
59
|
+
} {
|
|
60
|
+
return JSON.parse(
|
|
61
|
+
fs.readFileSync(
|
|
62
|
+
path.join(appRoot, '.mercato', 'generated', `${baseName}.generated.mjs.cache.json`),
|
|
63
|
+
'utf8',
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function loadBootstrapDataInNode(appRoot: string): {
|
|
69
|
+
entityNames: string[]
|
|
70
|
+
entityRevisions: number[]
|
|
71
|
+
} {
|
|
72
|
+
const loaderUrl = pathToFileURL(path.resolve(__dirname, '../dynamicLoader.ts')).href
|
|
73
|
+
const script = `
|
|
74
|
+
import { loadBootstrapData } from ${JSON.stringify(loaderUrl)}
|
|
75
|
+
const data = await loadBootstrapData(process.argv[1])
|
|
76
|
+
process.stdout.write(JSON.stringify({
|
|
77
|
+
entityNames: data.entities.map((entity) => entity.name),
|
|
78
|
+
entityRevisions: data.entities.map((entity) => entity.revision),
|
|
79
|
+
}))
|
|
80
|
+
`
|
|
81
|
+
return JSON.parse(execFileSync(
|
|
82
|
+
process.execPath,
|
|
83
|
+
['--import', 'tsx', '--input-type=module', '-e', script, appRoot],
|
|
84
|
+
{
|
|
85
|
+
cwd: process.cwd(),
|
|
86
|
+
encoding: 'utf8',
|
|
87
|
+
env: { ...process.env, OM_LOG_DESTINATION: 'stderr' },
|
|
88
|
+
},
|
|
89
|
+
))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe('dynamic loader app tsconfig and content-addressed cache', () => {
|
|
93
|
+
const appRoots: string[] = []
|
|
94
|
+
|
|
95
|
+
afterAll(() => {
|
|
96
|
+
for (const appRoot of appRoots) {
|
|
97
|
+
fs.rmSync(appRoot, { recursive: true, force: true })
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
function createAppRoot(): string {
|
|
102
|
+
const appRoot = writeFixture()
|
|
103
|
+
appRoots.push(appRoot)
|
|
104
|
+
return appRoot
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
it('imports a real MikroORM legacy-decorated local entity', async () => {
|
|
108
|
+
const appRoot = createAppRoot()
|
|
109
|
+
|
|
110
|
+
const data = loadBootstrapDataInNode(appRoot)
|
|
111
|
+
|
|
112
|
+
expect(data.entityNames).toEqual(['ProbeEntity'])
|
|
113
|
+
const compiled = fs.readFileSync(
|
|
114
|
+
path.join(appRoot, '.mercato', 'generated', 'entities.generated.mjs'),
|
|
115
|
+
'utf8',
|
|
116
|
+
)
|
|
117
|
+
expect(compiled).toContain('__decorateClass')
|
|
118
|
+
expect(compiled).not.toContain('__decorateElement')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('invalidates compiled output when only app tsconfig content changes', async () => {
|
|
122
|
+
const appRoot = createAppRoot()
|
|
123
|
+
loadBootstrapDataInNode(appRoot)
|
|
124
|
+
const before = readCacheMetadata(appRoot, 'entities')
|
|
125
|
+
|
|
126
|
+
fs.writeFileSync(path.join(appRoot, 'tsconfig.json'), JSON.stringify({
|
|
127
|
+
...LEGACY_TSCONFIG,
|
|
128
|
+
compilerOptions: {
|
|
129
|
+
...LEGACY_TSCONFIG.compilerOptions,
|
|
130
|
+
useDefineForClassFields: true,
|
|
131
|
+
},
|
|
132
|
+
}))
|
|
133
|
+
loadBootstrapDataInNode(appRoot)
|
|
134
|
+
const after = readCacheMetadata(appRoot, 'entities')
|
|
135
|
+
|
|
136
|
+
expect(after.version).toBe(4)
|
|
137
|
+
expect(after.inputHash).not.toBe(before.inputHash)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('invalidates compiled output when only an extended tsconfig changes', async () => {
|
|
141
|
+
const appRoot = createAppRoot()
|
|
142
|
+
const appTsconfigPath = path.join(appRoot, 'tsconfig.json')
|
|
143
|
+
const baseTsconfigPath = path.join(appRoot, 'tsconfig.base.json')
|
|
144
|
+
fs.writeFileSync(baseTsconfigPath, JSON.stringify(LEGACY_TSCONFIG))
|
|
145
|
+
fs.writeFileSync(appTsconfigPath, `{
|
|
146
|
+
// The loader must follow JSONC extends references.
|
|
147
|
+
"extends": "./tsconfig.base.json",
|
|
148
|
+
}`)
|
|
149
|
+
loadBootstrapDataInNode(appRoot)
|
|
150
|
+
const before = readCacheMetadata(appRoot, 'entities')
|
|
151
|
+
|
|
152
|
+
fs.writeFileSync(baseTsconfigPath, JSON.stringify({
|
|
153
|
+
...LEGACY_TSCONFIG,
|
|
154
|
+
compilerOptions: {
|
|
155
|
+
...LEGACY_TSCONFIG.compilerOptions,
|
|
156
|
+
useDefineForClassFields: true,
|
|
157
|
+
},
|
|
158
|
+
}))
|
|
159
|
+
loadBootstrapDataInNode(appRoot)
|
|
160
|
+
const after = readCacheMetadata(appRoot, 'entities')
|
|
161
|
+
|
|
162
|
+
expect(after.inputHash).not.toBe(before.inputHash)
|
|
163
|
+
expect(after.dependencies['tsconfig.base.json']).not.toBe(
|
|
164
|
+
before.dependencies['tsconfig.base.json'],
|
|
165
|
+
)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('invalidates compiled output when a package-resolved tsconfig changes', async () => {
|
|
169
|
+
const appRoot = createAppRoot()
|
|
170
|
+
const configPackageRoot = path.join(appRoot, 'node_modules', 'probe-tsconfig')
|
|
171
|
+
const packageTsconfigPath = path.join(configPackageRoot, 'tsconfig.json')
|
|
172
|
+
fs.mkdirSync(configPackageRoot, { recursive: true })
|
|
173
|
+
fs.writeFileSync(path.join(configPackageRoot, 'package.json'), JSON.stringify({
|
|
174
|
+
name: 'probe-tsconfig',
|
|
175
|
+
version: '1.0.0',
|
|
176
|
+
main: 'tsconfig.json',
|
|
177
|
+
}))
|
|
178
|
+
fs.writeFileSync(packageTsconfigPath, JSON.stringify(LEGACY_TSCONFIG))
|
|
179
|
+
fs.writeFileSync(path.join(appRoot, 'tsconfig.json'), JSON.stringify({
|
|
180
|
+
extends: 'probe-tsconfig',
|
|
181
|
+
}))
|
|
182
|
+
loadBootstrapDataInNode(appRoot)
|
|
183
|
+
const before = readCacheMetadata(appRoot, 'entities')
|
|
184
|
+
|
|
185
|
+
fs.writeFileSync(packageTsconfigPath, JSON.stringify({
|
|
186
|
+
...LEGACY_TSCONFIG,
|
|
187
|
+
compilerOptions: {
|
|
188
|
+
...LEGACY_TSCONFIG.compilerOptions,
|
|
189
|
+
useDefineForClassFields: true,
|
|
190
|
+
},
|
|
191
|
+
}))
|
|
192
|
+
loadBootstrapDataInNode(appRoot)
|
|
193
|
+
const after = readCacheMetadata(appRoot, 'entities')
|
|
194
|
+
|
|
195
|
+
expect(after.inputHash).not.toBe(before.inputHash)
|
|
196
|
+
expect(after.dependencies['node_modules/probe-tsconfig/tsconfig.json']).not.toBe(
|
|
197
|
+
before.dependencies['node_modules/probe-tsconfig/tsconfig.json'],
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('invalidates compiled output when generated source content changes', async () => {
|
|
202
|
+
const appRoot = createAppRoot()
|
|
203
|
+
loadBootstrapDataInNode(appRoot)
|
|
204
|
+
const before = readCacheMetadata(appRoot, 'entities')
|
|
205
|
+
const sourcePath = path.join(
|
|
206
|
+
appRoot,
|
|
207
|
+
'.mercato',
|
|
208
|
+
'generated',
|
|
209
|
+
'entities.generated.ts',
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
fs.appendFileSync(sourcePath, '\nexport const sourceRevision = 2\n')
|
|
213
|
+
loadBootstrapDataInNode(appRoot)
|
|
214
|
+
const after = readCacheMetadata(appRoot, 'entities')
|
|
215
|
+
|
|
216
|
+
expect(after.inputHash).not.toBe(before.inputHash)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('excludes client-only virtual inputs from cache dependencies', async () => {
|
|
220
|
+
const appRoot = createAppRoot()
|
|
221
|
+
const generatedDir = path.join(appRoot, '.mercato', 'generated')
|
|
222
|
+
fs.writeFileSync(path.join(generatedDir, 'modules.cli.generated.ts'), `
|
|
223
|
+
export const modules = [{ widget: () => import('./widget.client') }]
|
|
224
|
+
`)
|
|
225
|
+
|
|
226
|
+
loadBootstrapDataInNode(appRoot)
|
|
227
|
+
|
|
228
|
+
const metadata = readCacheMetadata(appRoot, 'modules.cli')
|
|
229
|
+
expect(Object.keys(metadata.dependencies)).not.toContain('om-client-only-stub:./widget.client')
|
|
230
|
+
expect(
|
|
231
|
+
fs.readFileSync(path.join(generatedDir, 'modules.cli.generated.mjs'), 'utf8'),
|
|
232
|
+
).toContain('clientOnlyModuleUnavailable')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('invalidates compiled output when only a bundled local dependency changes', async () => {
|
|
236
|
+
const appRoot = createAppRoot()
|
|
237
|
+
const generatedSourcePath = path.join(
|
|
238
|
+
appRoot,
|
|
239
|
+
'.mercato',
|
|
240
|
+
'generated',
|
|
241
|
+
'entities.generated.ts',
|
|
242
|
+
)
|
|
243
|
+
const generatedSource = fs.readFileSync(generatedSourcePath, 'utf8')
|
|
244
|
+
const first = loadBootstrapDataInNode(appRoot)
|
|
245
|
+
const before = readCacheMetadata(appRoot, 'entities')
|
|
246
|
+
const entityPath = path.join(appRoot, 'src', 'modules', 'probe', 'data', 'entities.ts')
|
|
247
|
+
|
|
248
|
+
fs.writeFileSync(
|
|
249
|
+
entityPath,
|
|
250
|
+
fs.readFileSync(entityPath, 'utf8').replace('static revision = 1', 'static revision = 2'),
|
|
251
|
+
)
|
|
252
|
+
const second = loadBootstrapDataInNode(appRoot)
|
|
253
|
+
const after = readCacheMetadata(appRoot, 'entities')
|
|
254
|
+
|
|
255
|
+
expect(first.entityRevisions).toEqual([1])
|
|
256
|
+
expect(second.entityRevisions).toEqual([2])
|
|
257
|
+
expect(after.outputHash).not.toBe(before.outputHash)
|
|
258
|
+
expect(after.dependencies['src/modules/probe/data/entities.ts']).not.toBe(
|
|
259
|
+
before.dependencies['src/modules/probe/data/entities.ts'],
|
|
260
|
+
)
|
|
261
|
+
expect(fs.readFileSync(generatedSourcePath, 'utf8')).toBe(generatedSource)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('rebuilds a compiled file whose bytes do not match its sidecar', async () => {
|
|
265
|
+
const appRoot = createAppRoot()
|
|
266
|
+
loadBootstrapDataInNode(appRoot)
|
|
267
|
+
const compiledPath = path.join(appRoot, '.mercato', 'generated', 'entities.generated.mjs')
|
|
268
|
+
fs.writeFileSync(compiledPath, 'this is not valid JavaScript')
|
|
269
|
+
|
|
270
|
+
const data = loadBootstrapDataInNode(appRoot)
|
|
271
|
+
|
|
272
|
+
expect(data.entityNames).toEqual(['ProbeEntity'])
|
|
273
|
+
expect(fs.readFileSync(compiledPath, 'utf8')).toContain('__decorateClass')
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('rebuilds a cache sidecar from an older loader format version', async () => {
|
|
277
|
+
const appRoot = createAppRoot()
|
|
278
|
+
loadBootstrapDataInNode(appRoot)
|
|
279
|
+
const metadataPath = path.join(
|
|
280
|
+
appRoot,
|
|
281
|
+
'.mercato',
|
|
282
|
+
'generated',
|
|
283
|
+
'entities.generated.mjs.cache.json',
|
|
284
|
+
)
|
|
285
|
+
const stale = readCacheMetadata(appRoot, 'entities')
|
|
286
|
+
fs.writeFileSync(metadataPath, JSON.stringify({ ...stale, version: 2 }))
|
|
287
|
+
|
|
288
|
+
loadBootstrapDataInNode(appRoot)
|
|
289
|
+
|
|
290
|
+
expect(readCacheMetadata(appRoot, 'entities').version).toBe(4)
|
|
291
|
+
})
|
|
292
|
+
})
|
|
@@ -6,9 +6,11 @@ import {
|
|
|
6
6
|
ensureMikroOrmV7GeneratedCacheCompatibility,
|
|
7
7
|
recoverMikroOrmV7GeneratedCacheFromImportError,
|
|
8
8
|
} from './generatedCacheRecovery'
|
|
9
|
-
import { createClientOnlyStubPlugin } from './clientOnlyModules'
|
|
9
|
+
import { CLIENT_ONLY_STUB_NAMESPACE, createClientOnlyStubPlugin } from './clientOnlyModules'
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
import fs from 'node:fs'
|
|
12
|
+
import crypto from 'node:crypto'
|
|
13
|
+
import { createRequire } from 'node:module'
|
|
12
14
|
import { pathToFileURL } from 'node:url'
|
|
13
15
|
|
|
14
16
|
const logger = createLogger('shared').child({ component: 'bootstrap' })
|
|
@@ -83,6 +85,217 @@ export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugi
|
|
|
83
85
|
return [createClientOnlyStubPlugin(), aliasPlugin, externalNonJsonPlugin]
|
|
84
86
|
}
|
|
85
87
|
|
|
88
|
+
const DYNAMIC_LOADER_CACHE_VERSION = 4
|
|
89
|
+
|
|
90
|
+
type DynamicLoaderCacheMetadata = {
|
|
91
|
+
version: number
|
|
92
|
+
inputHash: string
|
|
93
|
+
outputHash: string
|
|
94
|
+
dependencies: Record<string, string>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function cacheMetadataPath(jsPath: string): string {
|
|
98
|
+
return `${jsPath}.cache.json`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function contentHash(content: Buffer | string): string {
|
|
102
|
+
return crypto.createHash('sha256').update(content).digest('hex')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseJsonConfig(content: string): unknown {
|
|
106
|
+
let normalized = ''
|
|
107
|
+
let inString = false
|
|
108
|
+
let escaped = false
|
|
109
|
+
|
|
110
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
111
|
+
const character = content[index]
|
|
112
|
+
const nextCharacter = content[index + 1]
|
|
113
|
+
|
|
114
|
+
if (inString) {
|
|
115
|
+
normalized += character
|
|
116
|
+
if (escaped) {
|
|
117
|
+
escaped = false
|
|
118
|
+
} else if (character === '\\') {
|
|
119
|
+
escaped = true
|
|
120
|
+
} else if (character === '"') {
|
|
121
|
+
inString = false
|
|
122
|
+
}
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (character === '"') {
|
|
127
|
+
inString = true
|
|
128
|
+
normalized += character
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (character === '/' && nextCharacter === '/') {
|
|
133
|
+
while (index < content.length && content[index] !== '\n') index += 1
|
|
134
|
+
normalized += '\n'
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (character === '/' && nextCharacter === '*') {
|
|
139
|
+
index += 2
|
|
140
|
+
while (index < content.length && !(content[index] === '*' && content[index + 1] === '/')) {
|
|
141
|
+
index += 1
|
|
142
|
+
}
|
|
143
|
+
index += 1
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (character === ',') {
|
|
148
|
+
let lookahead = index + 1
|
|
149
|
+
while (lookahead < content.length && /\s/.test(content[lookahead])) lookahead += 1
|
|
150
|
+
if (content[lookahead] === '}' || content[lookahead] === ']') continue
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
normalized += character
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return JSON.parse(normalized)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function resolveExistingConfigPath(candidate: string): string | null {
|
|
160
|
+
for (const configPath of [candidate, `${candidate}.json`, path.join(candidate, 'tsconfig.json')]) {
|
|
161
|
+
if (fs.existsSync(configPath) && fs.statSync(configPath).isFile()) return configPath
|
|
162
|
+
}
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function resolvePackageConfig(configPath: string, reference: string): string | null {
|
|
167
|
+
try {
|
|
168
|
+
const resolved = createRequire(pathToFileURL(configPath)).resolve(reference)
|
|
169
|
+
return path.extname(resolved) === '.json' ? resolved : null
|
|
170
|
+
} catch {
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function resolveExtendedConfig(configPath: string, reference: string): string {
|
|
176
|
+
if (path.isAbsolute(reference) || reference.startsWith('.')) {
|
|
177
|
+
const resolved = resolveExistingConfigPath(path.resolve(path.dirname(configPath), reference))
|
|
178
|
+
if (resolved) return resolved
|
|
179
|
+
} else {
|
|
180
|
+
for (const packageReference of [reference, `${reference}/tsconfig.json`]) {
|
|
181
|
+
const resolved = resolvePackageConfig(configPath, packageReference)
|
|
182
|
+
if (resolved) return resolved
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
throw new Error(`[internal] TypeScript config extends target not found: ${reference}`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function collectTsconfigPaths(entryPath: string, visited: Set<string> = new Set()): string[] {
|
|
190
|
+
const configPath = path.resolve(entryPath)
|
|
191
|
+
if (visited.has(configPath)) return []
|
|
192
|
+
visited.add(configPath)
|
|
193
|
+
|
|
194
|
+
const parsed = parseJsonConfig(fs.readFileSync(configPath, 'utf8'))
|
|
195
|
+
if (typeof parsed !== 'object' || parsed === null || !('extends' in parsed)) return [configPath]
|
|
196
|
+
|
|
197
|
+
const extendsValue = parsed.extends
|
|
198
|
+
const references = typeof extendsValue === 'string'
|
|
199
|
+
? [extendsValue]
|
|
200
|
+
: Array.isArray(extendsValue) && extendsValue.every((value) => typeof value === 'string')
|
|
201
|
+
? extendsValue
|
|
202
|
+
: []
|
|
203
|
+
|
|
204
|
+
return [
|
|
205
|
+
...references.flatMap((reference) => collectTsconfigPaths(
|
|
206
|
+
resolveExtendedConfig(configPath, reference),
|
|
207
|
+
visited,
|
|
208
|
+
)),
|
|
209
|
+
configPath,
|
|
210
|
+
]
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function hashFilesRelativeTo(appRoot: string, filePaths: string[]): Record<string, string> {
|
|
214
|
+
return Object.fromEntries(filePaths.map((filePath) => [
|
|
215
|
+
path.relative(appRoot, filePath).split(path.sep).join('/'),
|
|
216
|
+
contentHash(fs.readFileSync(filePath)),
|
|
217
|
+
]))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function cacheInputHash(tsPath: string, appRoot: string, tsconfigPaths: string[]): string {
|
|
221
|
+
const hash = crypto.createHash('sha256')
|
|
222
|
+
hash.update(JSON.stringify({
|
|
223
|
+
version: DYNAMIC_LOADER_CACHE_VERSION,
|
|
224
|
+
sourceHash: contentHash(fs.readFileSync(tsPath)),
|
|
225
|
+
tsconfigHashes: hashFilesRelativeTo(appRoot, tsconfigPaths),
|
|
226
|
+
}))
|
|
227
|
+
return hash.digest('hex')
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function dependenciesAreValid(appRoot: string, dependencies: Record<string, string>): boolean {
|
|
231
|
+
return Object.entries(dependencies).every(([relativePath, expectedHash]) => {
|
|
232
|
+
const dependencyPath = path.resolve(appRoot, relativePath)
|
|
233
|
+
return fs.existsSync(dependencyPath)
|
|
234
|
+
&& contentHash(fs.readFileSync(dependencyPath)) === expectedHash
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function collectDependencyHashes(
|
|
239
|
+
appRoot: string,
|
|
240
|
+
inputs: Record<string, unknown>,
|
|
241
|
+
): Record<string, string> {
|
|
242
|
+
return Object.fromEntries(
|
|
243
|
+
Object.keys(inputs)
|
|
244
|
+
.filter((inputPath) => !inputPath.startsWith(`${CLIENT_ONLY_STUB_NAMESPACE}:`))
|
|
245
|
+
.map((inputPath) => {
|
|
246
|
+
const absolutePath = path.isAbsolute(inputPath)
|
|
247
|
+
? inputPath
|
|
248
|
+
: path.resolve(appRoot, inputPath)
|
|
249
|
+
const relativePath = path.relative(appRoot, absolutePath).split(path.sep).join('/')
|
|
250
|
+
return [relativePath, contentHash(fs.readFileSync(absolutePath))]
|
|
251
|
+
})
|
|
252
|
+
.sort(([left], [right]) => left.localeCompare(right)),
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function readCacheMetadata(metadataPath: string): DynamicLoaderCacheMetadata | null {
|
|
257
|
+
try {
|
|
258
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(metadataPath, 'utf8'))
|
|
259
|
+
if (
|
|
260
|
+
typeof parsed === 'object'
|
|
261
|
+
&& parsed !== null
|
|
262
|
+
&& 'version' in parsed
|
|
263
|
+
&& parsed.version === DYNAMIC_LOADER_CACHE_VERSION
|
|
264
|
+
&& 'inputHash' in parsed
|
|
265
|
+
&& typeof parsed.inputHash === 'string'
|
|
266
|
+
&& 'outputHash' in parsed
|
|
267
|
+
&& typeof parsed.outputHash === 'string'
|
|
268
|
+
&& 'dependencies' in parsed
|
|
269
|
+
&& typeof parsed.dependencies === 'object'
|
|
270
|
+
&& parsed.dependencies !== null
|
|
271
|
+
&& Object.values(parsed.dependencies).every((hash) => typeof hash === 'string')
|
|
272
|
+
) {
|
|
273
|
+
return {
|
|
274
|
+
version: parsed.version,
|
|
275
|
+
inputHash: parsed.inputHash,
|
|
276
|
+
outputHash: parsed.outputHash,
|
|
277
|
+
dependencies: parsed.dependencies as Record<string, string>,
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
} catch {
|
|
281
|
+
return null
|
|
282
|
+
}
|
|
283
|
+
return null
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function cacheIsValid(
|
|
287
|
+
appRoot: string,
|
|
288
|
+
jsPath: string,
|
|
289
|
+
metadataPath: string,
|
|
290
|
+
expectedInputHash: string,
|
|
291
|
+
): boolean {
|
|
292
|
+
if (!fs.existsSync(jsPath)) return false
|
|
293
|
+
const metadata = readCacheMetadata(metadataPath)
|
|
294
|
+
if (!metadata || metadata.inputHash !== expectedInputHash) return false
|
|
295
|
+
return contentHash(fs.readFileSync(jsPath)) === metadata.outputHash
|
|
296
|
+
&& dependenciesAreValid(appRoot, metadata.dependencies)
|
|
297
|
+
}
|
|
298
|
+
|
|
86
299
|
/**
|
|
87
300
|
* Compile a TypeScript file to JavaScript using esbuild bundler.
|
|
88
301
|
* This bundles the file and all its dependencies, handling JSON imports properly.
|
|
@@ -91,39 +304,58 @@ export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugi
|
|
|
91
304
|
async function compileAndImport(tsPath: string, allowRecovery: boolean = true): Promise<Record<string, unknown>> {
|
|
92
305
|
const jsPath = tsPath.replace(/\.ts$/, '.mjs')
|
|
93
306
|
const appRoot = path.dirname(path.dirname(path.dirname(tsPath)))
|
|
307
|
+
const appTsconfig = path.join(appRoot, 'tsconfig.json')
|
|
308
|
+
const metadataPath = cacheMetadataPath(jsPath)
|
|
94
309
|
|
|
95
|
-
// Check if we need to recompile (source newer than compiled)
|
|
96
310
|
const tsExists = fs.existsSync(tsPath)
|
|
97
|
-
const
|
|
311
|
+
const tsconfigExists = fs.existsSync(appTsconfig)
|
|
98
312
|
|
|
99
313
|
if (!tsExists) {
|
|
100
314
|
throw new GeneratedFileNotFoundError(tsPath)
|
|
101
315
|
}
|
|
316
|
+
if (!tsconfigExists) {
|
|
317
|
+
throw new Error(`App TypeScript config not found: ${appTsconfig}`)
|
|
318
|
+
}
|
|
102
319
|
|
|
103
|
-
const
|
|
104
|
-
|
|
320
|
+
const tsconfigPaths = collectTsconfigPaths(appTsconfig)
|
|
321
|
+
const expectedInputHash = cacheInputHash(tsPath, appRoot, tsconfigPaths)
|
|
322
|
+
const needsCompile = !cacheIsValid(appRoot, jsPath, metadataPath, expectedInputHash)
|
|
105
323
|
|
|
106
324
|
if (needsCompile) {
|
|
107
325
|
// Dynamically import esbuild only when needed
|
|
108
326
|
const esbuild = await import('esbuild')
|
|
109
327
|
|
|
110
328
|
// Use esbuild.build with bundling to handle JSON imports
|
|
111
|
-
await esbuild.build({
|
|
329
|
+
const result = await esbuild.build({
|
|
112
330
|
entryPoints: [tsPath],
|
|
113
331
|
outfile: jsPath,
|
|
332
|
+
absWorkingDir: appRoot,
|
|
114
333
|
bundle: true,
|
|
334
|
+
metafile: true,
|
|
115
335
|
format: 'esm',
|
|
116
336
|
platform: 'node',
|
|
117
337
|
target: 'node18',
|
|
338
|
+
tsconfig: appTsconfig,
|
|
118
339
|
plugins: createCliBundlePlugins(appRoot),
|
|
119
340
|
// Allow JSON imports
|
|
120
341
|
loader: { '.json': 'json' },
|
|
121
342
|
})
|
|
343
|
+
const metadata: DynamicLoaderCacheMetadata = {
|
|
344
|
+
version: DYNAMIC_LOADER_CACHE_VERSION,
|
|
345
|
+
inputHash: expectedInputHash,
|
|
346
|
+
outputHash: contentHash(fs.readFileSync(jsPath)),
|
|
347
|
+
dependencies: {
|
|
348
|
+
...collectDependencyHashes(appRoot, result.metafile.inputs),
|
|
349
|
+
...hashFilesRelativeTo(appRoot, tsconfigPaths),
|
|
350
|
+
},
|
|
351
|
+
}
|
|
352
|
+
fs.writeFileSync(metadataPath, JSON.stringify(metadata))
|
|
122
353
|
}
|
|
123
354
|
|
|
124
355
|
// Import the compiled JavaScript
|
|
125
356
|
try {
|
|
126
|
-
const
|
|
357
|
+
const outputHash = contentHash(fs.readFileSync(jsPath))
|
|
358
|
+
const fileUrl = `${pathToFileURL(jsPath).href}?cache=${outputHash}`
|
|
127
359
|
return await import(fileUrl)
|
|
128
360
|
} catch (error) {
|
|
129
361
|
if (!allowRecovery) {
|