Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Module resolver: virtualize vendor.js #1801

Merged
merged 10 commits into from
Apr 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
84 changes: 18 additions & 66 deletions packages/compat/src/compat-app-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,11 +307,7 @@ export class CompatAppBuilder {
return extensionsPattern(this.resolvableExtensions());
}

private impliedAssets(
type: keyof ImplicitAssetPaths,
engine: AppFiles,
emberENV?: EmberENV
): (OnDiskAsset | InMemoryAsset)[] {
private impliedAssets(type: keyof ImplicitAssetPaths, engine: AppFiles): (OnDiskAsset | InMemoryAsset)[] {
let result: (OnDiskAsset | InMemoryAsset)[] = this.impliedAddonAssets(type, engine).map(
(sourcePath: string): OnDiskAsset => {
let stats = statSync(sourcePath);
Expand All @@ -325,26 +321,6 @@ export class CompatAppBuilder {
}
);

if (type === 'implicit-scripts') {
result.unshift({
kind: 'in-memory',
relativePath: '_testing_prefix_.js',
source: `var runningTests=false;`,
});

result.unshift({
kind: 'in-memory',
relativePath: '_ember_env_.js',
source: `window.EmberENV={ ...(window.EmberENV || {}), ...${JSON.stringify(emberENV, null, 2)} };`,
});

result.push({
kind: 'in-memory',
relativePath: '_loader_.js',
source: `loader.makeDefaultExport=false;`,
});
}

return result;
}

Expand Down Expand Up @@ -451,12 +427,7 @@ export class CompatAppBuilder {
return portable;
}

private insertEmberApp(
asset: ParsedEmberAsset,
appFiles: AppFiles[],
prepared: Map<string, InternalAsset>,
emberENV: EmberENV
) {
private insertEmberApp(asset: ParsedEmberAsset, appFiles: AppFiles[], prepared: Map<string, InternalAsset>) {
let html = asset.html;

if (this.fastbootConfig) {
Expand Down Expand Up @@ -485,11 +456,8 @@ export class CompatAppBuilder {

html.insertStyleLink(html.styles, `assets/${this.origAppPackage.name}.css`);

const parentEngine = appFiles.find(e => e.engine.isApp)!;
let vendorJS = this.implicitScriptsAsset(prepared, parentEngine, emberENV);
if (vendorJS) {
html.insertScriptTag(html.implicitScripts, vendorJS.relativePath);
}
// virtual-vendor entrypoint
html.insertScriptTag(html.implicitScripts, '@embroider/core/vendor.js');

if (this.fastbootConfig) {
// any extra fastboot vendor files get inserted into our
Expand Down Expand Up @@ -518,22 +486,6 @@ export class CompatAppBuilder {
html.insertStyleLink(html.implicitTestStyles, '@embroider/core/test-support.css');
}

private implicitScriptsAsset(
prepared: Map<string, InternalAsset>,
application: AppFiles,
emberENV: EmberENV
): InternalAsset | undefined {
let asset = prepared.get('assets/vendor.js');
if (!asset) {
let implicitScripts = this.impliedAssets('implicit-scripts', application, emberENV);
if (implicitScripts.length > 0) {
asset = new ConcatenatedAsset('assets/vendor.js', implicitScripts, this.resolvableExtensionsPattern);
prepared.set(asset.relativePath, asset);
}
}
return asset;
}

// recurse to find all active addons that don't cross an engine boundary.
// Inner engines themselves will be returned, but not those engines' children.
// The output set's insertion order is the proper ember-cli compatible
Expand Down Expand Up @@ -671,7 +623,7 @@ export class CompatAppBuilder {
);
}

private prepareAsset(asset: Asset, appFiles: AppFiles[], prepared: Map<string, InternalAsset>, emberENV: EmberENV) {
private prepareAsset(asset: Asset, appFiles: AppFiles[], prepared: Map<string, InternalAsset>) {
if (asset.kind === 'ember') {
let prior = this.assets.get(asset.relativePath);
let parsed: ParsedEmberAsset;
Expand All @@ -682,21 +634,17 @@ export class CompatAppBuilder {
} else {
parsed = new ParsedEmberAsset(asset);
}
this.insertEmberApp(parsed, appFiles, prepared, emberENV);
this.insertEmberApp(parsed, appFiles, prepared);
prepared.set(asset.relativePath, new BuiltEmberAsset(parsed));
} else {
prepared.set(asset.relativePath, asset);
}
}

private prepareAssets(
requestedAssets: Asset[],
appFiles: AppFiles[],
emberENV: EmberENV
): Map<string, InternalAsset> {
private prepareAssets(requestedAssets: Asset[], appFiles: AppFiles[]): Map<string, InternalAsset> {
let prepared: Map<string, InternalAsset> = new Map();
for (let asset of requestedAssets) {
this.prepareAsset(asset, appFiles, prepared, emberENV);
this.prepareAsset(asset, appFiles, prepared);
}
return prepared;
}
Expand Down Expand Up @@ -770,8 +718,8 @@ export class CompatAppBuilder {
await concat.end();
}

private async updateAssets(requestedAssets: Asset[], appFiles: AppFiles[], emberENV: EmberENV) {
let assets = this.prepareAssets(requestedAssets, appFiles, emberENV);
private async updateAssets(requestedAssets: Asset[], appFiles: AppFiles[]) {
let assets = this.prepareAssets(requestedAssets, appFiles);
for (let asset of assets.values()) {
if (this.assetIsValid(asset, this.assets.get(asset.relativePath))) {
continue;
Expand Down Expand Up @@ -841,10 +789,9 @@ export class CompatAppBuilder {
}

let appFiles = this.updateAppJS(inputPaths.appJS);
let emberENV = this.configTree.readConfig().EmberENV;
let assets = this.gatherAssets(inputPaths);

let finalAssets = await this.updateAssets(assets, appFiles, emberENV);
let finalAssets = await this.updateAssets(assets, appFiles);

let assetPaths = assets.map(asset => asset.relativePath);

Expand Down Expand Up @@ -884,6 +831,7 @@ export class CompatAppBuilder {
let resolverConfig = this.resolverConfig(appFiles);
this.addResolverConfig(resolverConfig);
this.addContentForConfig(this.contentForTree.readContents());
this.addEmberEnvConfig(this.configTree.readConfig().EmberENV);
let babelConfig = await this.babelConfig(resolverConfig);
this.addBabelConfig(babelConfig);
writeFileSync(
Expand Down Expand Up @@ -987,6 +935,12 @@ export class CompatAppBuilder {
});
}

private addEmberEnvConfig(emberEnvConfig: any) {
outputJSONSync(join(locateEmbroiderWorkingDir(this.compatApp.root), 'ember-env.json'), emberEnvConfig, {
spaces: 2,
});
}

private shouldSplitRoute(routeName: string) {
return (
!this.options.splitAtRoutes ||
Expand Down Expand Up @@ -1477,8 +1431,6 @@ interface TreeNames {
configTree: BroccoliNode;
}

type EmberENV = unknown;

type InternalAsset = OnDiskAsset | InMemoryAsset | BuiltEmberAsset | ConcatenatedAsset;

class ParsedEmberAsset {
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/module-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ export class Resolver {
request = this.handleVendorStyles(request);
request = this.handleTestSupportStyles(request);
request = this.handleRenaming(request);
request = this.handleVendor(request);
// we expect the specifier to be app relative at this point - must be after handleRenaming
request = this.generateFastbootSwitch(request);
request = this.preHandleExternal(request);
Expand Down Expand Up @@ -945,6 +946,24 @@ export class Resolver {
return request;
}

private handleVendor<R extends ModuleRequest>(request: R): R {
//TODO move the extra forwardslash handling out into the vite plugin
const candidates = ['@embroider/core/vendor.js', '/@embroider/core/vendor.js', './@embroider/core/vendor.js'];

if (!candidates.includes(request.specifier)) {
return request;
}

let pkg = this.packageCache.ownerOfFile(request.fromFile);
if (pkg?.root !== this.options.engines[0].root) {
throw new Error(
`bug: found an import of ${request.specifier} in ${request.fromFile}, but this is not the top-level Ember app. The top-level Ember app is the only one that has support for @embroider/core/vendor.js. If you think something should be fixed in Embroider, please open an issue on https://github.com/embroider-build/embroider/issues.`
);
}

return logTransition('vendor', request, request.virtualize(resolve(pkg.root, '-embroider-vendor.js')));
}

private resolveWithinMovedPackage<R extends ModuleRequest>(request: R, pkg: Package): R {
let levels = ['..'];
if (pkg.name.startsWith('@')) {
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/virtual-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { explicitRelative, extensionsPattern } from '.';
import { compile } from './js-handlebars';
import { decodeImplicitTestScripts, renderImplicitTestScripts } from './virtual-test-support';
import { decodeTestSupportStyles, renderTestSupportStyles } from './virtual-test-support-styles';
import { decodeVirtualVendor, renderVendor } from './virtual-vendor';
import { decodeVirtualVendorStyles, renderVendorStyles } from './virtual-vendor-styles';

const externalESPrefix = '/@embroider/ext-es/';
Expand Down Expand Up @@ -43,6 +44,11 @@ export function virtualContent(filename: string, resolver: Resolver): VirtualCon
return renderImplicitModules(im, resolver);
}

let isVendor = decodeVirtualVendor(filename);
if (isVendor) {
return renderVendor(filename, resolver);
}

let isImplicitTestScripts = decodeImplicitTestScripts(filename);
if (isImplicitTestScripts) {
return renderImplicitTestScripts(filename, resolver);
Expand Down
77 changes: 77 additions & 0 deletions packages/core/src/virtual-vendor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { type Package, locateEmbroiderWorkingDir } from '@embroider/shared-internals';
import type { V2AddonPackage } from '@embroider/shared-internals/src/package';
import { lstatSync, readFileSync, readJSONSync } from 'fs-extra';
import { sortBy } from 'lodash';
import { join } from 'path';
import resolve from 'resolve';
import type { Resolver } from './module-resolver';
import type { VirtualContentResult } from './virtual-content';

export function decodeVirtualVendor(filename: string): boolean {
return filename.endsWith('-embroider-vendor.js');
}

export function renderVendor(filename: string, resolver: Resolver): VirtualContentResult {
const owner = resolver.packageCache.ownerOfFile(filename);
if (!owner) {
throw new Error(`Failed to find a valid owner for ${filename}`);
}
return { src: getVendor(owner, resolver, filename), watches: [] };
}

function getVendor(owner: Package, resolver: Resolver, filename: string): string {
let engineConfig = resolver.owningEngine(owner);
let addons = new Map(
engineConfig.activeAddons.map(addon => [
resolver.packageCache.get(addon.root) as V2AddonPackage,
addon.canResolveFromFile,
])
);

let path = join(locateEmbroiderWorkingDir(resolver.options.appRoot), 'ember-env.json');
if (!lstatSync(path).isFile()) {
throw new Error(`Failed to read the ember-env.json when generating content for ${filename}`);
}
let emberENV = readJSONSync(path);

return generateVendor(addons, emberENV);
}

function generateVendor(addons: Map<V2AddonPackage, string>, emberENV?: unknown): string {
// Add addons implicit-scripts
let vendor: string[] = impliedAddonVendors(addons).map((sourcePath: string): string => {
let source = readFileSync(sourcePath);
return `${source}`;
});
// Add _testing_prefix_.js
vendor.unshift(`var runningTests=false;`);
// Add _ember_env_.js
vendor.unshift(`window.EmberENV={ ...(window.EmberENV || {}), ...${JSON.stringify(emberENV, null, 2)} };`);
// Add _loader_.js
vendor.push(`loader.makeDefaultExport=false;`);

return vendor.join('') as string;
}

function impliedAddonVendors(addons: Map<V2AddonPackage, string>): string[] {
let result: Array<string> = [];
for (let addon of sortBy(Array.from(addons.keys()), pkg => {
switch (pkg.name) {
case 'loader.js':
return 0;
case 'ember-source':
return 10;
default:
return 1000;
}
})) {
let implicitScripts = addon.meta['implicit-scripts'];
if (implicitScripts) {
let options = { basedir: addon.root };
for (let mod of implicitScripts) {
result.push(resolve.sync(mod, options));
}
}
}
return result;
}
8 changes: 8 additions & 0 deletions packages/vite/src/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ export function resolver(): Plugin {
}
},
buildEnd() {
this.emitFile({
type: 'asset',
fileName: '@embroider/core/vendor.js',
source: virtualContent(
resolve(resolverLoader.resolver.options.engines[0].root, '-embroider-vendor.js'),
resolverLoader.resolver
).src,
});
this.emitFile({
type: 'asset',
fileName: '@embroider/core/test-support.js',
Expand Down
1 change: 1 addition & 0 deletions test-packages/support/suite-setup-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ async function githubMatrix() {
...suites
.filter(s => s.name !== 'jest-suites') // TODO: jest tests do not work under windows yet
.filter(s => !s.name.includes('watch-mode')) // TODO: watch tests are far too slow on windows right now
.filter(s => !s.name.endsWith('compat-addon-classic-features-virtual-scripts')) // TODO: these tests are too slow on windows right now
.map(s => ({
name: `${s.name} windows`,
os: 'windows',
Expand Down
42 changes: 41 additions & 1 deletion tests/scenarios/compat-addon-classic-features-test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { throwOnWarnings } from '@embroider/core';
import { readFileSync } from 'fs';
import { lstatSync, readFileSync } from 'fs';
import { merge } from 'lodash';
import QUnit from 'qunit';
import type { PreparedApp } from 'scenario-tester';
Expand Down Expand Up @@ -131,3 +131,43 @@ appScenarios
});
});
});

appScenarios
.map('compat-addon-classic-features-virtual-scripts', () => {})
.forEachScenario(scenario => {
let app: PreparedApp;

Qmodule(`${scenario.name} - build mode`, function (hooks) {
hooks.before(async assert => {
app = await scenario.prepare();
let result = await app.execute('pnpm build');
assert.equal(result.exitCode, 0, result.output);
});

test('vendor.js script is emitted in the build', async function (assert) {
assert.true(lstatSync(`${app.dir}/dist/@embroider/core/vendor.js`).isFile());
});
});

Qmodule(`${scenario.name} - dev mode`, function (hooks) {
hooks.before(async () => {
app = await scenario.prepare();
});

test('vendor.js script is served', async function (assert) {
const server = CommandWatcher.launch('vite', ['--clearScreen', 'false'], { cwd: app.dir });
try {
const [, url] = await server.waitFor(/Local:\s+(https?:\/\/.*)\//g);
BlueCutOfficial marked this conversation as resolved.
Show resolved Hide resolved
let response = await fetch(`${url}/@embroider/core/vendor.js`);
assert.strictEqual(response.status, 200);
// checking the response status 200 is not enough to assert vendor.js is served,
// because when the URL is not recognized, the response contains the index.html
// and has a 200 status (for index.html being returned correctly)
let text = await response.text();
assert.true(!text.includes('<!DOCTYPE html>'));
} finally {
await server.shutdown();
}
});
});
});