diff --git a/.eslintignore b/.eslintignore index a981add70..1172e0271 100644 --- a/.eslintignore +++ b/.eslintignore @@ -6,6 +6,10 @@ /packages/compat/**/*.d.ts /packages/macros/**/*.js /packages/macros/**/*.d.ts +/packages/util/src/**/*.js +/packages/util/src/**/*.d.ts +/packages/util/shim.js +/packages/util/shim.d.ts /packages/webpack/**/*.js /packages/webpack/**/*.d.ts /packages/hbs-loader/**/*.js diff --git a/packages/compat/package.json b/packages/compat/package.json index 4e069231a..acbd33ad0 100644 --- a/packages/compat/package.json +++ b/packages/compat/package.json @@ -40,7 +40,7 @@ "broccoli": "^3.4.2", "broccoli-concat": "^3.7.3", "broccoli-file-creator": "^2.1.1", - "broccoli-funnel": "^2.0.1", + "broccoli-funnel": "ef4/broccoli-funnel#c70d060076e14793e8495571f304a976afc754ac", "broccoli-merge-trees": "^3.0.0", "broccoli-persistent-filter": "^3.1.2", "broccoli-plugin": "^4.0.0", diff --git a/packages/compat/src/compat-addons.ts b/packages/compat/src/compat-addons.ts index dba557c1f..99ea6358d 100644 --- a/packages/compat/src/compat-addons.ts +++ b/packages/compat/src/compat-addons.ts @@ -1,5 +1,5 @@ import { Node } from 'broccoli-node-api'; -import { join, relative, dirname } from 'path'; +import { join, relative, dirname, isAbsolute } from 'path'; import { emptyDirSync, ensureSymlinkSync, ensureDirSync, realpathSync, copySync, writeJSONSync } from 'fs-extra'; import { Stage, Package, PackageCache, WaitForTrees, mangledEngineRoot } from '@embroider/core'; import V1InstanceCache from './v1-instance-cache'; @@ -111,14 +111,21 @@ export default class CompatAddons implements Stage { if (!treeInstance) { let ignore = ['**/node_modules']; - if (join(destination, 'tests', 'dummy') === this.appDestDir) { - // special case: we're building the dummy app of this addon. Because - // the dummy app is nested underneath the addon, we need to tell our - // TreeSync to ignore it. Not because it's ever present at our input, - // but because stage2 will make it appear inside our output and we - // should leave that alone. - ignore.push('tests'); + + let rel = relative(destination, this.appDestDir); + if (!rel.startsWith('..') && !isAbsolute(rel)) { + // the app is inside our addon. We must not copy the app as part of + // the addon, because that would overwrite the real app build. + ignore.push(rel); + + if (rel === 'tests/dummy') { + // special case: classic dummy apps are weird because they put the + // tests (which are truly part of the app, not the addon) inside the + // addon instead of inside the app. + ignore.push('tests'); + } } + treeInstance = new TreeSync(movedAddons[index], destination, { ignore, }); diff --git a/packages/core/src/babel-plugin-adjust-imports.ts b/packages/core/src/babel-plugin-adjust-imports.ts index 71bcec0bf..e3f487c9f 100644 --- a/packages/core/src/babel-plugin-adjust-imports.ts +++ b/packages/core/src/babel-plugin-adjust-imports.ts @@ -1,4 +1,4 @@ -import getPackageName from './package-name'; +import { emberVirtualPackages, emberVirtualPeerDeps, packageName as getPackageName } from '@embroider/shared-internals'; import { join, dirname, resolve } from 'path'; import { NodePath } from '@babel/traverse'; import type * as t from '@babel/types'; @@ -227,6 +227,12 @@ function handleExternal(specifier: string, sourceFile: AdjustFile, opts: Options let relocatedPkg = sourceFile.relocatedIntoPackage(); if (relocatedPkg) { // this file has been moved into another package (presumably the app). + + // self-imports are legal in the app tree, even for v2 packages + if (packageName === pkg.name) { + return specifier; + } + // first try to resolve from the destination package if (isResolvable(packageName, relocatedPkg)) { if (!pkg.meta['auto-upgraded']) { @@ -273,6 +279,27 @@ function handleExternal(specifier: string, sourceFile: AdjustFile, opts: Options return makeExternal(specifier, sourceFile, opts); } + if (pkg.isV2Ember()) { + // native v2 packages don't automatically externalize *everything* the way + // auto-upgraded packages do, but they still externalize known and approved + // ember virtual packages (like @ember/component) + if (emberVirtualPackages.has(packageName)) { + return makeExternal(specifier, sourceFile, opts); + } + + // native v2 packages don't automatically get to use every other addon as a + // peerDep, but they do get the known and approved ember virtual peer deps, + // like @glimmer/component + if (emberVirtualPeerDeps.has(packageName)) { + if (!opts.activeAddons[packageName]) { + throw new Error( + `${pkg.name} is trying to import from ${packageName}, which is supposed to be present in all ember apps but seems to be missing` + ); + } + return explicitRelative(dirname(sourceFile.name), specifier.replace(packageName, opts.activeAddons[packageName])); + } + } + // non-resolvable imports in dynamic positions become runtime errors, not // build-time errors, so we emit the runtime error module here before the // stage3 packager has a chance to see the missing module. (Maybe some stage3 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 24dac9e09..f9069d61f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,7 +14,6 @@ export { Plugins as TemplateCompilerPlugins } from './ember-template-compiler-ty export { Asset, EmberAsset, ImplicitAssetPaths } from './asset'; export { default as Options, optionsWithDefaults } from './options'; export { default as toBroccoliPlugin } from './to-broccoli-plugin'; -export { default as packageName } from './package-name'; export { default as WaitForTrees, OutputPaths } from './wait-for-trees'; export { default as BuildStage } from './build-stage'; export { compile as jsHandlebarsCompile } from './js-handlebars'; @@ -22,16 +21,6 @@ export { AppAdapter, AppBuilder, EmberENV } from './app'; export { todo, unsupported, warn, debug, expectWarning, throwOnWarnings } from './messages'; export { mangledEngineRoot } from './engine-mangler'; -export { - AppMeta, - AddonMeta, - explicitRelative, - extensionsPattern, - getOrCreate, - Package, - AddonPackage, - AppPackage, - V2Package, - PackageCache, - babelFilter, -} from '@embroider/shared-internals'; +// this is reexported because we already make users manage a peerDep from some +// other packages (like embroider/webpack and @embroider/ +export * from '@embroider/shared-internals'; diff --git a/packages/shared-internals/package.json b/packages/shared-internals/package.json index ee679679e..db4013785 100644 --- a/packages/shared-internals/package.json +++ b/packages/shared-internals/package.json @@ -27,6 +27,7 @@ "test": "jest" }, "dependencies": { + "ember-rfc176-data": "^0.3.17", "resolve-package-path": "^1.2.2", "pkg-up": "^3.1.0", "typescript-memoize": "^1.0.0-alpha.3", diff --git a/packages/shared-internals/src/ember-cli-models.ts b/packages/shared-internals/src/ember-cli-models.ts new file mode 100644 index 000000000..f50e25456 --- /dev/null +++ b/packages/shared-internals/src/ember-cli-models.ts @@ -0,0 +1,55 @@ +import type { Node } from 'broccoli-node-api'; +export interface Project { + targets: unknown; + ui: { + write(...args: any[]): void; + }; + pkg: { name: string; version: string }; + root: string; + addons: AddonInstance[]; + name(): string; +} + +export interface AppInstance { + env: 'development' | 'test' | 'production'; + project: Project; + options: any; + addonPostprocessTree: (which: string, tree: Node) => Node; +} + +interface BaseAddonInstance { + project: Project; + pkg: { name: string; version: string }; + root: string; + options: any; + addons: AddonInstance[]; + name: string; + _super: any; + treeGenerator(path: string): Node; + _findHost(): AppInstance; +} + +export interface DeepAddonInstance extends BaseAddonInstance { + // this is how it looks when an addon is beneath another addon + parent: AddonInstance; +} + +export interface ShallowAddonInstance extends BaseAddonInstance { + // this is how it looks when an addon is directly beneath the app + parent: Project; + app: AppInstance; +} + +export type AddonInstance = DeepAddonInstance | ShallowAddonInstance; + +export function isDeepAddonInstance(addon: AddonInstance): addon is DeepAddonInstance { + return addon.parent !== addon.project; +} + +export function findTopmostAddon(addon: AddonInstance): ShallowAddonInstance { + if (isDeepAddonInstance(addon)) { + return findTopmostAddon(addon.parent); + } else { + return addon; + } +} diff --git a/packages/shared-internals/src/ember-standard-modules.ts b/packages/shared-internals/src/ember-standard-modules.ts new file mode 100644 index 000000000..ea1e9de1e --- /dev/null +++ b/packages/shared-internals/src/ember-standard-modules.ts @@ -0,0 +1,27 @@ +// I'm doing this as a json import because even though that's not standard JS, +// it's relaively easy to consume into builds for the web. As opposed to doing +// something like fs.readFile, which is harder. +// +// @ts-ignore +import mappings from 'ember-rfc176-data/mappings.json'; + +// these are packages that available to import in standard ember code that don't +// exist as real packages. If a build system encounters them in stage 3, it +// should convert them to runtime AMD require. +// +// Some of them (like @embroider/macros) won't ever be seen in stage 3, because +// earlier plugins should take care of them. +// +// In embroider builds using ember-source >= 3.28, you won't see *any* of these +// in stage3 because ember-source uses the standard rename-modules feature to +// map them into real modules within ember-source. +export const emberVirtualPackages = new Set(mappings.map((m: any) => m.module)); + +// these are *real* packages that every ember addon is allow to resolve *as if +// they were peerDepenedencies, because the host application promises to have +// these packages. In principle, we could force every addon to declare these as +// real peerDeps all the way down the dependency graph, but in practice that +// makes the migration from v1 to v2 addons more painful than necessary, because +// a v1 addon in between the app and a v2 addon might not declare the peerDep, +// breaking the deeper v2 addon. +export const emberVirtualPeerDeps = new Set(['@glimmer/component']); diff --git a/packages/shared-internals/src/index.ts b/packages/shared-internals/src/index.ts index 3cca3b161..f64dadec3 100644 --- a/packages/shared-internals/src/index.ts +++ b/packages/shared-internals/src/index.ts @@ -4,3 +4,6 @@ export { getOrCreate } from './get-or-create'; export { default as Package, V2AddonPackage as AddonPackage, V2AppPackage as AppPackage, V2Package } from './package'; export { default as PackageCache } from './package-cache'; export { default as babelFilter } from './babel-filter'; +export { default as packageName } from './package-name'; +export * from './ember-cli-models'; +export * from './ember-standard-modules'; diff --git a/packages/core/src/package-name.ts b/packages/shared-internals/src/package-name.ts similarity index 100% rename from packages/core/src/package-name.ts rename to packages/shared-internals/src/package-name.ts diff --git a/packages/util/.eslintignore b/packages/util/.eslintignore index 922165552..c0287da2c 100644 --- a/packages/util/.eslintignore +++ b/packages/util/.eslintignore @@ -5,6 +5,10 @@ # compiled output /dist/ /tmp/ +/src/**/*.js +/src/**/*.d.ts +/shim.js +/shim.d.ts # dependencies /bower_components/ diff --git a/packages/util/.eslintrc.js b/packages/util/.eslintrc.js index 50b6a0865..0566bd419 100644 --- a/packages/util/.eslintrc.js +++ b/packages/util/.eslintrc.js @@ -27,7 +27,7 @@ module.exports = { '.prettierrc.js', '.template-lintrc.js', 'ember-cli-build.js', - 'index.js', + 'addon-main.js', 'testem.js', 'blueprints/*/index.js', 'config/**/*.js', @@ -49,5 +49,33 @@ module.exports = { plugins: ['node'], extends: ['plugin:node/recommended'], }, + // node typescript files + { + files: ['src/**/*.ts', 'shim.ts'], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 2017, + sourceType: 'module', + }, + plugins: ['@typescript-eslint'], + extends: ['prettier/@typescript-eslint'], + rules: { + '@typescript-eslint/naming-convention': [ + 'error', + { + selector: 'typeLike', + format: ['PascalCase'], + }, + ], + '@typescript-eslint/consistent-type-assertions': 'error', + '@typescript-eslint/no-inferrable-types': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_' }, + ], + 'no-unused-vars': 'off', + '@typescript-eslint/no-require-imports': 'error', + }, + }, ], }; diff --git a/packages/util/.gitignore b/packages/util/.gitignore index 7e0f7ddce..3658f9182 100644 --- a/packages/util/.gitignore +++ b/packages/util/.gitignore @@ -3,6 +3,12 @@ # compiled output /dist/ /tmp/ +/src/**/*.js +/src/**/*.d.ts +/src/**/*.map +/shim.js +/shim.d.ts +/shim.js.map # dependencies /bower_components/ diff --git a/packages/util/index.js b/packages/util/addon-main.js similarity index 100% rename from packages/util/index.js rename to packages/util/addon-main.js diff --git a/packages/util/index.d.ts b/packages/util/index.d.ts deleted file mode 100644 index d3ea75f5a..000000000 --- a/packages/util/index.d.ts +++ /dev/null @@ -1 +0,0 @@ -export function ensureSafeComponent(component: unknown, thingWithOwner: unknown): unknown; diff --git a/packages/util/package.json b/packages/util/package.json index 8254fcfda..ced4ee66c 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -13,6 +13,7 @@ "test": "tests" }, "scripts": { + "prepare": "tsc", "build": "ember build --environment=production", "lint": "npm-run-all --aggregate-output --continue-on-error --parallel 'lint:!(fix)'", "lint:fix": "npm-run-all --aggregate-output --continue-on-error --parallel lint:*:fix", @@ -28,6 +29,7 @@ }, "dependencies": { "@embroider/macros": "0.37.0", + "broccoli-funnel": "ef4/broccoli-funnel#c70d060076e14793e8495571f304a976afc754ac", "ember-cli-babel": "^7.23.1" }, "devDependencies": { @@ -40,6 +42,8 @@ "@embroider/webpack": "0.37.0", "@glimmer/component": "^1.0.3", "@glimmer/tracking": "^1.0.3", + "@typescript-eslint/eslint-plugin": "^4.1.1", + "@typescript-eslint/parser": "^4.1.1", "babel-eslint": "^10.1.0", "broccoli-asset-rev": "^3.0.0", "ember-auto-import": "^1.10.1", @@ -78,7 +82,8 @@ "edition": "octane" }, "ember-addon": { - "configPath": "tests/dummy/config" + "configPath": "tests/dummy/config", + "main": "addon-main.js" }, "volta": { "extends": "../../package.json" diff --git a/packages/util/shim.ts b/packages/util/shim.ts new file mode 100644 index 000000000..26ae52c75 --- /dev/null +++ b/packages/util/shim.ts @@ -0,0 +1,169 @@ +import { resolve, relative, isAbsolute } from 'path'; +import { readFileSync } from 'fs'; +import { + AddonMeta, + AddonInstance, + isDeepAddonInstance, +} from '@embroider/shared-internals'; +import Funnel from 'broccoli-funnel'; +import type { Node } from 'broccoli-node-api'; + +const MIN_SUPPORT_LEVEL = 1; + +export interface ShimOptions { + disabled?: (options: any) => boolean; +} + +function addonMeta(pkgJSON: any): AddonMeta { + let meta = pkgJSON['ember-addon']; + if (meta?.version !== 2 || meta?.type !== 'addon') { + throw new Error(`did not find valid v2 addon metadata in ${pkgJSON.name}`); + } + return meta as AddonMeta; +} + +export function addonV1Shim(directory: string, options: ShimOptions = {}) { + let pkg = JSON.parse( + readFileSync(resolve(directory, './package.json'), 'utf8') + ); + + let meta = addonMeta(pkg); + let disabled = false; + const rootTrees = new WeakMap(); + + function rootTree(addonInstance: AddonInstance): Node { + let tree = rootTrees.get(addonInstance); + if (!tree) { + tree = addonInstance.treeGenerator(directory); + rootTrees.set(addonInstance, tree); + } + return tree; + } + + return { + name: pkg.name, + included(this: AddonInstance, ...args: unknown[]) { + this._super.included.apply(this, args); + + ensureAutoImport(this); + + let parentOptions: any; + if (isDeepAddonInstance(this)) { + // our parent is an addon + parentOptions = this.parent.options; + } else { + // our parent is the app + parentOptions = this.app.options; + } + if (options.disabled) { + disabled = options.disabled(parentOptions); + } + }, + + treeForApp(this: AddonInstance) { + if (disabled) { + return undefined; + } + let maybeAppJS = meta['app-js']; + if (maybeAppJS) { + const appJS = maybeAppJS; + return new Funnel(rootTree(this), { + files: Object.values(appJS), + getDestinationPath(relativePath: string): string { + for (let [exteriorName, interiorName] of Object.entries(appJS)) { + if (relativePath === interiorName) { + return exteriorName; + } + } + throw new Error( + `bug in addonV1Shim, no match for ${relativePath} in ${JSON.stringify( + appJS + )}` + ); + }, + }); + } + }, + + treeForAddon() { + // this never goes through broccoli -- it's always pulled into the app via + // ember-auto-import, as needed. This means it always benefits from + // tree-shaking. + return undefined; + }, + + treeForPublic(this: AddonInstance) { + if (disabled) { + return undefined; + } + let maybeAssets = meta['public-assets']; + if (maybeAssets) { + const assets = maybeAssets; + return new Funnel(rootTree(this), { + files: Object.keys(assets), + getDestinationPath(relativePath: string): string { + for (let [interiorName, exteriorName] of Object.entries(assets)) { + if (relativePath === interiorName) { + return exteriorName; + } + } + throw new Error( + `bug in addonV1Shim, no match for ${relativePath} in ${JSON.stringify( + assets + )}` + ); + }, + }); + } + }, + + isDevelopingAddon(this: AddonInstance) { + // if the app is inside our own directory, we must be under development. + // This setting controls whether ember-cli will watch for changes in the + // broccoli trees we expose, but it doesn't have any control over our + // files that get auto-imported into the app. For that, you should use + // ember-auto-import's watchDependencies option (and this should become + // part of the blueprint for test apps). + let appInstance = this._findHost(); + return isInside(directory, appInstance.project.root); + }, + }; +} + +function isInside(parentDir: string, otherDir: string): boolean { + let rel = relative(parentDir, otherDir); + return Boolean(rel) && !rel.startsWith('..') && !isAbsolute(rel); +} + +function ensureAutoImport(instance: AddonInstance) { + let autoImport = instance.parent.addons.find( + (a) => a.name === 'ember-auto-import' + ); + if (!autoImport) { + throw new Error( + `${ + instance.name + } is a v2-formatted addon. To use it without Embroider, the package that depends on it (${parentName( + instance + )}) must have ember-auto-import.` + ); + } + let level = (autoImport as any).v2AddonSupportLevel ?? 0; + if (level < MIN_SUPPORT_LEVEL) { + throw new Error( + `${ + instance.name + } is using v2 addon features that require a newer ember-auto-import than the one that is present in ${parentName( + instance + )}` + ); + } +} + +function parentName(instance: AddonInstance): string { + if (isDeepAddonInstance(instance)) { + return instance.parent.name; + } else { + return instance.parent.name(); + } +} diff --git a/test-packages/funky-sample-addon/package.json b/test-packages/funky-sample-addon/package.json index a45b7a296..58d5aa699 100644 --- a/test-packages/funky-sample-addon/package.json +++ b/test-packages/funky-sample-addon/package.json @@ -21,7 +21,7 @@ "dependencies": { "@embroider/macros": "0.37.0", "broccoli-babel-transpiler": "^5.5.0", - "broccoli-funnel": "^2.0.2", + "broccoli-funnel": "ef4/broccoli-funnel#c70d060076e14793e8495571f304a976afc754ac", "broccoli-merge-trees": "^3.0.2", "ember-cli-babel": "^7.20.5", "ember-cli-htmlbars": "^4.3.1" diff --git a/tsconfig.json b/tsconfig.json index 998f3fef0..51b21e351 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,10 @@ { - "include": ["./packages/*/src/**/*.ts", "./packages/*/tests/**/*.ts", "./test-packages/support/**/*.ts"], + "include": [ + "./packages/*/src/**/*.ts", + "./packages/*/tests/**/*.ts", + "./test-packages/support/**/*.ts", + "./packages/util/shim.ts" + ], "compilerOptions": { "target": "es2017", "module": "commonjs", diff --git a/yarn.lock b/yarn.lock index c2388e9f9..537f4ef64 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4521,6 +4521,24 @@ broccoli-funnel@^3.0.3: path-posix "^1.0.0" walk-sync "^2.0.2" +broccoli-funnel@ef4/broccoli-funnel#c70d060076e14793e8495571f304a976afc754ac: + version "2.0.2-ef4.0" + resolved "https://codeload.github.com/ef4/broccoli-funnel/tar.gz/c70d060076e14793e8495571f304a976afc754ac" + dependencies: + array-equal "^1.0.0" + blank-object "^1.0.1" + broccoli-plugin "^1.3.0" + debug "^2.2.0" + fast-ordered-set "^1.0.0" + fs-tree-diff "^0.5.3" + heimdalljs "^0.2.0" + minimatch "^3.0.0" + mkdirp "^0.5.0" + path-posix "^1.0.0" + rimraf "^2.4.3" + symlink-or-copy "^1.0.0" + walk-sync "^2.2.0" + broccoli-kitchen-sink-helpers@^0.2.5: version "0.2.9" resolved "https://registry.yarnpkg.com/broccoli-kitchen-sink-helpers/-/broccoli-kitchen-sink-helpers-0.2.9.tgz#a5e0986ed8d76fb5984b68c3f0450d3a96e36ecc" @@ -17222,12 +17240,7 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript-memoize@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/typescript-memoize/-/typescript-memoize-1.0.0.tgz#ad3b0e7e5a411ca234be123f913a2a31302b7eb6" - integrity sha512-B1eufjs/mGzHqoGeI1VT/dnSBoZr2v3i3/Wm8NmdxlZflyVdleE8wO0QwUuj4NfundD7T5nU3I7HSKp/5BD9og== - -typescript-memoize@^1.0.0-alpha.3: +typescript-memoize@^1.0.0, typescript-memoize@^1.0.0-alpha.3: version "1.0.0" resolved "https://registry.yarnpkg.com/typescript-memoize/-/typescript-memoize-1.0.0.tgz#ad3b0e7e5a411ca234be123f913a2a31302b7eb6" integrity sha512-B1eufjs/mGzHqoGeI1VT/dnSBoZr2v3i3/Wm8NmdxlZflyVdleE8wO0QwUuj4NfundD7T5nU3I7HSKp/5BD9og==