diff --git a/packages/compat/src/compat-app-builder.ts b/packages/compat/src/compat-app-builder.ts index be6ed8f9a..6f3bcca77 100644 --- a/packages/compat/src/compat-app-builder.ts +++ b/packages/compat/src/compat-app-builder.ts @@ -277,6 +277,7 @@ export class CompatAppBuilder { engines: engines.map((engine, index) => ({ packageName: engine.package.name, root: index === 0 ? this.root : engine.package.root, // first engine is the app, which has been relocated to this.roto + fastbootFiles: {}, activeAddons: [...engine.addons] .map(a => ({ name: a.name, diff --git a/packages/compat/tests/audit.test.ts b/packages/compat/tests/audit.test.ts index c5c3030e8..ecebfe5a4 100644 --- a/packages/compat/tests/audit.test.ts +++ b/packages/compat/tests/audit.test.ts @@ -43,6 +43,7 @@ describe('audit', function () { engines: [ { packageName: 'audit-this-app', + fastbootFiles: {}, activeAddons: [], root: app.baseDir, }, diff --git a/packages/core/src/module-resolver.ts b/packages/core/src/module-resolver.ts index 73131fc97..9d62ef551 100644 --- a/packages/core/src/module-resolver.ts +++ b/packages/core/src/module-resolver.ts @@ -3,6 +3,7 @@ import { emberVirtualPeerDeps, extensionsPattern, packageName as getPackageName, + packageName, } from '@embroider/shared-internals'; import { dirname, resolve } from 'path'; import { Package, V2Package, explicitRelative, RewrittenPackageCache } from '@embroider/shared-internals'; @@ -65,6 +66,7 @@ export interface Options { interface EngineConfig { packageName: string; activeAddons: { name: string; root: string }[]; + fastbootFiles: { [appName: string]: { localFilename: string; shadowedFilename: string | undefined } }; root: string; } @@ -156,6 +158,8 @@ export class Resolver { request = this.handleFastbootCompat(request); request = this.handleGlobalsCompat(request); request = this.handleRenaming(request); + // we expect the specifier to be app relative at this point - must be after handleRenaming + request = this.handleAppFastboot(request); request = this.preHandleExternal(request); // this should probably stay the last step in beforeResolve, because it can @@ -295,6 +299,46 @@ export class Resolver { return owningPackage; } + private handleAppFastboot(request: R): R { + let pkg = this.owningPackage(request.fromFile); + + if (!pkg) { + return request; + } + + if (packageName(request.specifier)) { + // not a relative request, and we're assuming all within-engine requests + // are relative by this point due to `v1 self-import` which happens + // earlier + return request; + } + + let engineConfig = this.engineConfig(pkg.name); + if (engineConfig) { + for (let candidate of this.withResolvableExtensions(request.specifier)) { + let fastbootFile = engineConfig.fastbootFiles[candidate]; + if (fastbootFile) { + if (fastbootFile.shadowedFilename) { + let { names } = describeExports(readFileSync(resolve(pkg.root, fastbootFile.shadowedFilename), 'utf8'), {}); + return logTransition( + 'shadowed app fastboot', + request, + request.virtualize(fastbootSwitch(candidate, resolve(pkg.root, 'package.json'), names)) + ); + } else { + return logTransition( + 'unshadowed app fastboot', + request, + request.alias(fastbootFile.localFilename).rehome(resolve(pkg.root, 'package.json')) + ); + } + } + } + } + + return request; + } + private handleFastbootCompat(request: R): R { let match = decodeFastbootSwitch(request.fromFile); if (!match) { @@ -315,7 +359,22 @@ export class Resolver { let pkg = this.owningPackage(match.filename); if (pkg) { let rel = explicitRelative(pkg.root, match.filename); - let entry = this.getEntryFromMergeMap(rel, pkg.root); + + let engineConfig = this.engineConfig(pkg.name); + if (engineConfig) { + let fastbootFile = engineConfig.fastbootFiles[rel]; + if (fastbootFile && fastbootFile.shadowedFilename) { + let targetFile: string; + if (section === 'app-js') { + targetFile = fastbootFile.shadowedFilename; + } else { + targetFile = fastbootFile.localFilename; + } + return request.alias(targetFile).rehome(resolve(pkg.root, 'package.json')); + } + } + + let entry = this.getEntryFromMergeMap(rel, pkg.root)?.entry; if (entry?.type === 'both') { return request.alias(entry[section].localPath).rehome(resolve(entry[section].packageRoot, 'package.json')); } @@ -951,22 +1010,27 @@ export class Resolver { return logTransition('fallbackResolve final exit', request); } - private getEntryFromMergeMap(inEngineSpecifier: string, root: string): MergeEntry | undefined { + private getEntryFromMergeMap( + inEngineSpecifier: string, + root: string + ): { entry: MergeEntry; matched: string } | undefined { let entry: MergeEntry | undefined; + for (let candidate of this.withResolvableExtensions(inEngineSpecifier)) { + entry = this.mergeMap.get(root)?.get(candidate); + if (entry) { + return { entry, matched: candidate }; + } + } + } - if (inEngineSpecifier.match(/\.(hbs|js|hbs\.js)$/)) { - entry = this.mergeMap.get(root)?.get(inEngineSpecifier); + private *withResolvableExtensions(filename: string): Generator { + if (filename.match(/\.(hbs|js|hbs\.js)$/)) { + yield filename; } else { - // try looking up .hbs .js and .hbs.js in that order for specifiers without extenstions - ['.hbs', '.js', '.hbs.js'].forEach(ext => { - if (entry) { - return; - } - - entry = this.mergeMap.get(root)?.get(`${inEngineSpecifier}${ext}`); - }); + for (let ext of ['.hbs', '.js', '.hbs.js']) { + yield `${filename}${ext}`; + } } - return entry; } private searchAppTree( @@ -974,29 +1038,31 @@ export class Resolver { engine: EngineConfig, inEngineSpecifier: string ): R | undefined { - let entry = this.getEntryFromMergeMap(inEngineSpecifier, engine.root); + let matched = this.getEntryFromMergeMap(inEngineSpecifier, engine.root); - switch (entry?.type) { + switch (matched?.entry.type) { case undefined: return undefined; case 'app-only': - return request.alias(entry['app-js'].localPath).rehome(resolve(entry['app-js'].packageRoot, 'package.json')); + return request + .alias(matched.entry['app-js'].localPath) + .rehome(resolve(matched.entry['app-js'].packageRoot, 'package.json')); case 'fastboot-only': return request - .alias(entry['fastboot-js'].localPath) - .rehome(resolve(entry['fastboot-js'].packageRoot, 'package.json')); + .alias(matched.entry['fastboot-js'].localPath) + .rehome(resolve(matched.entry['fastboot-js'].packageRoot, 'package.json')); case 'both': let foundAppJS = this.nodeResolve( - entry['app-js'].localPath, - resolve(entry['app-js'].packageRoot, 'package.json') + matched.entry['app-js'].localPath, + resolve(matched.entry['app-js'].packageRoot, 'package.json') ); if (foundAppJS.type !== 'real') { throw new Error( - `${entry['app-js'].fromPackageName} declared ${inEngineSpecifier} in packageJSON.ember-addon.app-js, but that module does not exist` + `${matched.entry['app-js'].fromPackageName} declared ${inEngineSpecifier} in packageJSON.ember-addon.app-js, but that module does not exist` ); } let { names } = describeExports(readFileSync(foundAppJS.filename, 'utf8'), {}); - return request.virtualize(fastbootSwitch(request.specifier, request.fromFile, names)); + return request.virtualize(fastbootSwitch(matched.matched, resolve(engine.root, 'package.json'), names)); } } diff --git a/tests/scenarios/compat-resolver-test.ts b/tests/scenarios/compat-resolver-test.ts index f2390f275..5c18ef346 100644 --- a/tests/scenarios/compat-resolver-test.ts +++ b/tests/scenarios/compat-resolver-test.ts @@ -79,6 +79,7 @@ Scenarios.fromProject(() => new Project()) packageName: 'my-app', root: app.dir, activeAddons: [], + fastbootFiles: {}, }, ], modulePrefix: 'my-app', diff --git a/tests/scenarios/core-resolver-test.ts b/tests/scenarios/core-resolver-test.ts index 96bff154c..9f3646a46 100644 --- a/tests/scenarios/core-resolver-test.ts +++ b/tests/scenarios/core-resolver-test.ts @@ -52,6 +52,7 @@ Scenarios.fromProject(() => new Project()) podModulePrefix?: string; renamePackages?: Record; addonMeta?: Partial; + fastbootFiles?: { [appName: string]: { localFilename: string; shadowedFilename: string | undefined } }; } let configure: (opts?: ConfigureOpts) => Promise; @@ -92,6 +93,7 @@ Scenarios.fromProject(() => new Project()) { packageName: 'my-app', root: app.dir, + fastbootFiles: opts?.fastbootFiles ?? {}, activeAddons: [ { name: 'my-addon', @@ -569,6 +571,21 @@ Scenarios.fromProject(() => new Project()) .to('./node_modules/my-addon/_fastboot_/hello-world.js'); }); + test(`resolves app fastboot-js`, async function () { + givenFiles({ + './fastboot/hello-world.js': ``, + 'app.js': `import "my-app/hello-world"`, + }); + + await configure({ + fastbootFiles: { + './hello-world.js': { localFilename: './fastboot/hello-world.js', shadowedFilename: undefined }, + }, + }); + + expectAudit.module('./app.js').resolves('my-app/hello-world').to('./fastboot/hello-world.js'); + }); + test(`file exists in both app-js and fastboot-js`, async function () { givenFiles({ 'node_modules/my-addon/_fastboot_/hello-world.js': ` @@ -616,6 +633,56 @@ Scenarios.fromProject(() => new Project()) switcherModule.resolves('./fastboot').to('./node_modules/my-addon/_fastboot_/hello-world.js'); switcherModule.resolves('./browser').to('./node_modules/my-addon/_app_/hello-world.js'); }); + + test(`app and fastboot file exists`, async function () { + givenFiles({ + 'fastboot/hello-world.js': ` + export function hello() { return 'fastboot'; } + export class Bonjour {} + export default function() {} + const value = 1; + export { value }; + export const x = 2; + `, + 'app/hello-world.js': ` + export function hello() { return 'browser'; } + export class Bonjour {} + export default function() {} + const value = 1; + export { value }; + export const x = 2; + `, + 'app.js': `import "my-app/hello-world"`, + }); + + await configure({ + fastbootFiles: { + './hello-world.js': { + localFilename: './fastboot/hello-world.js', + shadowedFilename: './app/hello-world.js', + }, + }, + }); + + let switcherModule = expectAudit.module('./app.js').resolves('my-app/hello-world').toModule(); + switcherModule.codeEquals(` + import { macroCondition, getGlobalConfig, importSync } from '@embroider/macros'; + let mod; + if (macroCondition(getGlobalConfig().fastboot?.isRunning)) { + mod = importSync("./fastboot"); + } else { + mod = importSync("./browser"); + } + export default mod.default; + export const hello = mod.hello; + export const Bonjour = mod.Bonjour; + export const value = mod.value; + export const x = mod.x; + `); + + switcherModule.resolves('./fastboot').to('./fastboot/hello-world.js'); + switcherModule.resolves('./browser').to('./app/hello-world.js'); + }); }); Qmodule('legacy-addons', function () {