@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.
@@ -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
- fs.writeFileSync(path.join(generatedDir, `${baseName}.ts`), source.ts)
58
- fs.writeFileSync(path.join(generatedDir, `${baseName}.mjs`), source.compiled)
59
- const fresh = new Date(Date.now() + 60_000)
60
- fs.utimesSync(path.join(generatedDir, `${baseName}.mjs`), fresh, fresh)
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 jsExists = fs.existsSync(jsPath)
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 needsCompile = !jsExists ||
104
- fs.statSync(tsPath).mtimeMs > fs.statSync(jsPath).mtimeMs
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 fileUrl = `${pathToFileURL(jsPath).href}?mtime=${fs.statSync(jsPath).mtimeMs}`
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) {