One of the good things about Ember is that apps and addons have a powerful set of build-time capabilities that allow lots of shared code with zero-to-no manual integration steps for the typical user. We have been doing “zero config” since before it was a cool buzzword (it was just called “convention over configuration”). And we’ve been broadly successful at maintaining very wide backward- and forward-compatibility for a large body of highly-rated community-maintained addons.
But one of the challenging things about Ember is that our ecosystem’s build-time capabilities are more implementation-defined than spec-defined, and the implementation has accumulated capabilities organically while only rarely phasing out older patterns. I believe the lack of a clear, foundational, build-time public API specification is the fundamental underlying issue that efforts like the various packaging / packager RFCs have tried to work around.
The benefits to users for this RFC are:
- faster builds and faster NPM installs
- “zero-config import from NPM — both static and dynamic” as a first-class feature all apps and addons can rely on.
- immediate tree-shaking of app- and addon-provided modules that are consumed directly via ECMA imports (for example, any ember-animated transition you don’t use in your app won’t get included in the build), with a smooth improvement path for steadily increasing the level of static analysis as other efforts like templates imports land.
- a more approachable build system that enables more people to contribute and better integration with other JS toolchains.
Ember was one of the earliest adopters of ECMAScript modules, and Ember core team members were directly involved in helping move that features through TC39. Ember’s early experiences with modules influenced the spec itself. Yet we have lagged in truly embracing modules.
For example, how do Ember apps express that they depend on a third-party library? The app.import API. This should be ECMA standard import
.
Another way to state the problem is that apps and addons all push whatever code they want into the final built app. Whereas ES modules can pull each other into the build as needed.
The ECMA module spec by itself doesn’t try to define a module resolution algorithm. But the overwhelmingly most popular convention is the node_modules resolution algorithm.
Ember addons do respect node_module resolution for build-time code, but they do not respect it for runtime code. There’s no reason not to.
Ember’s strong conventions mean that many kinds of dependencies can be inferred (including statically inferred) without requiring the developer to laboriously manage them. This is a good thing and I believe the current fad in the wider Javascript ecosystem for making developers hand-write verbose static imports for everything confuses the benefits of having static analysis (which is good) with the benefits of hand-managing those static imports (which is unnecessary cognitive load when you have clear conventions and a compiler).
This design is about compiling today’s idiomatic Ember code into more “vanilla” patterns that leverage ES modules, node_modules resolution, and spec-compliant static and dynamic import
to express the structure of an Ember application in a much more “vanilla Javascript” way.
This compile step lets us separate the authoring format (which isn’t changing in any significant way in this RFC) from the packaging format (which can be more verbose and static than we would want in an authoring format).
package: every addon and app is a package. Usually synonymous with “NPM package”, but we also include in-repo packages. The most important fact about a package is that it’s often the boundary around code that comes from a particular author, team, or organization, so coordination across packages is a more sensitive design problem than coordination within apps.
app: a package used at the root of a project.
addon: a package not used at the root of a project. Will be an allowed dependency of either an app or an addon.
allowed dependency: For addons, the allowed dependencies are the dependencies
and peerDependencies
in package.json
plus any in-repo addons. For apps, the allowed dependencies are the dependencies
, peerDependencies
, and devDependencies
in package.json
plus any in-repo addons.
Ember package metadata: the ember-addon
section inside package.json
. This already exists in v1, we’re going to extend it.
v2 package: a package with package.json
like:
"keywords": [ "ember-addon" ],
"ember-addon": {
"version": 2
}
v1 package: a package with package.json
like:
"keywords": [ "ember-addon" ]
and no version
key (or version key less than 2) in Ember package metadata.
non-Ember package: a package without keywords: ["ember-addon"]
The structure we are about to describe is a publication format. Not necessarily an authoring format. By separating the two, we make it easier to evolve the authoring formats without breaking ecosystem-wide compatibility. The publication format is deliberately more explicit and less dynamic that what we would want for an authoring format.
First, here’s the list of things a v2 package can provide. More detail on each of these will follow:
- Own Javascript: javascript and templates under the package’s own namespace (the v1 equivalent is
/addon/**/*.js/
) - App Javascript: javascript and templates that must be merged with the consuming app’s namespace (the v1 equivalent is
/app/**/*.js
). This likely stops being needed when use with a template imports feature, but v2 package format is not dependent on that. - CSS: available for
@import
by other CSS files (both in the same package and across packages) and by ECMAimport
directives in Javascript modules (both in the same package and across packages). - Assets: any files that should be available in the final built application directory (typical examples are images and fonts).
- Middleware: express middleware that will mount automatically during development, unchanged from v1.
- Preprocessors: for producing JS, CSS, or HBS.
- Commands: commands that can be invoked from the command line. Unchanged from v1.
- Blueprints: blueprints for generating new files from the command line. Unchanged from v1.
- ContentFor: the ability to insert snippets into key places, like the document header.
- Active Dependencies: the subset of a given package’s allowed dependencies that are Ember packages and that the given package considers active.
The public main
(as defined in package.json
) of a v2 package points to its Own Javascript. The code is formatted as ES modules using ES latest features. Templates are in place in hbs format, and any custom AST transforms have already been applied.
(Remember, we’re describing the publication format, not the authoring format. Authors can still do what they do today, using preprocessors provided by other addons. But that will all run before publishing.)
The benefit of this design is that it makes our packages understandable by a broader set of tooling. Editors and build tools can follow import
statements across packages and end up in the right place.
In v1 packages, main
usually points to a build-time configuration file. That file is moving and will be described in the Addon Hooks section below.
Modules in Own Javascript are allowed to use ECMA static import
to resolve any allowed dependency, causing it to be included in the build whenever the importing module is included. This replaces app.import
.
Notice that a package’s allowed dependencies do not include the package itself. This is consistent with how node module resolution works. This is different from how run-time AMD module resolution has historically worked in Ember Apps, so the build step that produces the v2 publication format will need to adjust import paths appropriately. For example, if your-package/a.js
tries to import from "your-package/b"
, that needs to get rewritten to “./b
".
Modules in Own Javascript are also allowed to use the (currently stage 3) ECMA dynamic import()
, and the specifiers have the same meanings as in static import. We impose one caveat: only string-literal specifiers are supported. So import('./lang-en')
is OK but import("./lang-"+language)
is not. We retain the option to relax this restriction in the future. The restriction allows us to do better analysis of possible inter-module dependencies (see Build-time Conditionals below for an example).
Modules in Own Javascript are allowed to import template files. This is common in today’s addons (they import their own layout to set it explicitly).
Modules in Own Javascript are allowed to use hbs
tagged template strings as provided by ember-cli-htmlbars-inline-precompile
, and we promise to compile the templates at app build time.
You’re allowed to import
from both other v2 Ember packages and non-Ember packages. The only difference is that v2 Ember packages necessarily agree to provide ES modules with ES latest features, and so we will always apply the application’s browser-specific Babel transpilation to them. Non-Ember packages can be authored in lots of ways, and we will use best-effort to consume them, including conversion of ESM or CJS to whatever format we’re using in the browser (currently AMD), but we won’t apply the app’s Babel transpilation to them, because it’s usually just unnecessary expense — the most common way to ship NPM packages outside of well-known build systems like ember-cli is to transpile before publication.
A recent lesson from ember-auto-import is that we’re going to want to allow people to opt-in to babel transpilation of specific foreign packages, as the wider ecosystem’s norms evolve and more projects ship modern JS untranspiled. Unfortunately there is no simple correct universal answer here. Double transpilation is not safe in general, since choices get made about how to map between modules, AMD, UMD, etc.
To provide App Javascript, a package includes the app-js
key in Ember package metadata. For example, to duplicate the behavior of v1 packages, you could say:
"ember-addon": {
"version": 2,
"app-js": "./app"
}
Like the Own Javascript, templates are in place in hbs format with any AST transforms already applied. Javascript is in ES modules, using only ES latest features. ECMA static and dynamic imports from any allowed dependency are supported.
By making this an explicit key in Ember package metadata, our publication format is more durable (you can rearrange the conventional directory structure in the future without breaking the format) and more performant (less filesystem traversal is required to decide which features the package is using).
To provide CSS, a package can include any number of CSS files. These files can @import
each other via relative paths, which will result in build-time inclusion (as already works in v1 packages).
If any of the Own Javascript or App Javascript modules depend on the presence of a CSS file in the same package, it should say so explicitly via an ECMA relative import, like:
import '../css/some-component.css';
This is interpreted as a build-time directive that ensures that before the Javascript module is evaluated, the CSS file's contents will be present in the DOM.
Q: Does this interfere with the ability to do CSS-in-JS style for people who like that?
A: No, because that would be a preprocessing step before publication. It’s a choice of authoring format, just like TypeScript or SCSS.
It is also possible for other packages (including the consuming application) to depend on a CSS file in any of its allowed dependencies, from either Javascript or CSS. From Javascript it looks like:
// This will resolve the `your-addon` package and find
// './some-component.css' relative to the package root.
// The .css file extension is mandatory
import 'your-addon/some-component.css';
And from CSS it looks like:
@import 'your-addon/some-component';
What about SCSS et al? You’re still free to use them as your authoring format, and they should be transpiled to CSS in your publication format. If you want to offer the original SCSS to consuming packages, you’re free to include it in the publication format too. Since we’re making all packages resolvable via normal node rules, it’s now dramatically easier to implement a preprocessor that supports inter-package dependencies. (The same logic applies to TypeScript.)
To provide Assets, a package includes the public-assets
key in Ember package metadata. It's a mapping from local paths to app-relative URLs that should be available in the final app.
"name": "my-addon",
"ember-addon": {
"version": 2,
"public-assets": {
"./public/image.png": "/my-addon/image.png"
}
}
with:
my-addon
└── public
└── image.png
will result in final build output:
dist
└── my-addon
└── image.png
Notice that we’re not choosing to include assets via explicit ECMA import
. The reason is that fine-grained inclusion of asset files is not critical to runtime performance. Any assets that your app doesn’t actually need, it should never fetch.
The following targets are supported the same as in v1 packages:
- head
- head-footer
- body
- body-footer
- test-head
- test-head-footer
- test-body
- test-body-footer
- config-module
The following targets are deprecated because they tie us permanently to the idea of fixed app/test/vendor Javascript bundles, and because they are not widely used according to the EmberObserver code search:
- app-boot
- app-prefix
- app-suffix
- test-support-prefix
- test-support-suffix
- vendor-prefix
- vendor-suffix
v1 packages can provide treeForTestSupport
, treeForAddonTestSupport
, and app.import
with type="test"
. All of these features are dropped.
To provide test-support code, make a separate module within your package and tell people to import
it from their tests. As long as it is only imported from tests, it will not be present in non-test bundles.
In today’s v1 addon packages, the index.js
file is the main entrypoint that allows an addon to integrate itself with the overall ember-cli build pipeline. The same idea carries forward to v2, with some changes.
It is no longer the main
entrypoint of the package (see Own Javascript). Instead, it’s located via the build
key in Ember package metadata, which should point at a Javascript file. build
is optional — if you don’t have anything to say, you don’t need the file.
It is now an ECMA module, not a CJS file. The default export is a class that implements your addon hooks (no base class is required).
One area that is under-documented and under-designed in the existing hooks is: which ones cascade into active grandchild addons? Do they cascade via super
so you can (accidentally or on purpose) block the cascade? Section Active Dependencies makes these rules consistent and clear.
List of existing v1 public methods and properties on addons, and their disposition in v2:
- blueprintsPath: unchanged in v2
- buildError: Kept. This is an event hook that makes it possible to implement things like ember-cli-build-notifications.
- cacheKeyForTree: Dropped. This is a build-time feature, it doesn’t belong in the publication format.
- config: TODO.
- contentFor: Some of the possible destinations for content are removed. See ContentFor section.
- dependencies: Dropped. Can’t find any usages in the wild.
- description: Dropped. This is redundant with the description in package.json.
- import: Dropped. This is replaced with actual ECMA
import
for both Javascript and CSS. - importTransforms: Dropped, because this goes with
this.import()
above. All examples in the wild that I could find are handled better by other alternatives.- the CJS and AMD transforms aren’t needed because better packagers can automate the transformation of both, as demonstrated by ember-auto-import
- the fastboot transform is used to neuter whole dependencies in fastboot. This can be handled by ECMA dynamic
import()
instead. - most other occurrences in the EmberObserver code search are actually addons re-exporting the fastboot transform (because apparently
importTransforms
doesn’t cascade properly).
- included: Unchanged, but it should be needed much more rarely. Today it is mostly used to
this.import()
things, which is not a thing anymore. - includedCommands: Unchanged.
- init: Dropped in favor of
constructor
, since we’re now talking about a native class. - isDevelopingAddon: Dropped. This doesn’t belong in each addon’s code, it’s a runtime decision and control over it belongs in ember-cli proper.
- isEnabled: Dropped. Rarely used. This decision doesn’t belong inside an addon, it belongs in the addon’s parent which will decide to activate it or not. Putting it here means every addon needs to invent its own API for how to tell it to activate or not.
- lintTree: Kept. This is a legit runtime thing to do.
- moduleName: Dropped. Using a moduleName that doesn’t match your NPM package name is a megatroll, and it won’t work with build tools that know how to follow the Node package resolution algorithm.
- name: Dropped. Setting a name that doesn’t match your NPM package name is a megatroll.
- outputReady: Kept.
- postBuild: Kept.
- postprocessTree: TODO. need to confirm existing pre/postprocessTree behaviors. I think most of the trees (js, styles, templates, all) only apply to your immediate parent, meaning they can run at publication time when they’re being applied to an addon.
- preBuild: Kept
- preprocessTree: TODO. Same boat as postprocessTree.
- serverMiddleware: Kept.
- setupPreprocessorRegistry: Kept. But remember, it will have an effect whenever the consuming package is built, which for apps will be the same as today, but for addons will be publication time.
- shouldIncludeChildAddon: Dropped in favor of
activeDependencies
because we’re changing the semantics. This method receives a complete instance representing each child addon, which unintentionally exposes way too much API. And the meaning of being an active dependency has been rationalized. See section Active Dependencies. - testemMiddleware: Kept.
- treeFor, treeForAddon, treeForAddonTemplates, treeForAddonTestSupport, treeForApp, treeForPublic, treeForStyles, treeForTemplates, treeForTestSupport, treeForVendor: Dropped. Dynamically generating broccoli trees at app build time is no longer supported. Your trees are built at publication time. If you need to produce different output at build time based on dynamic configuration, see Build-time Conditionals.
New addon hooks:
activeDependencies
: defined in its own section below
Finally, your build
module may export named constants that will be made available to your runtime Javascript. See Build-time Conditionals for details.
The v2 format deliberately moves a lot of dynamic behavior to publication time. So how do we deal with remaining cases where different code needs to be included based on dynamic information?
You may export named const
values from your build
module (as defined in the Addon Hooks section). These constants will be available to your Javascript via import { someConstant } from
'@ember/build-time-config/your-package-name'
, and we guarantee that a dead-code elimination step can see any boolean constant branch predicates (this is how feature flags already work inside Ember itself). For example:
import { needsLegacySupport } from '@ember/build-time-config/my-package';
let MyComponent = Component.extend({
//....
});
if (needsLegacySupport) {
MyComponent.reopen({
// add some extra code here. It will be stripped from builds that don't need it.
});
}
export default MyComponent;
This is also a motivating example for our support of dynamic imports()
: it allows you to conditionally depend on other JS modules or CSS:
import { provideDefaultStyles } from '@ember/build-time-config/my-package';
if (provideDefaultStyles) {
import("../css/default-styles.css");
}
Your build
module is evaluated in Node, not the browser. We just promise that any JSON-serializable constants it exports will get packaged up into the special Ember-provided @ember/build-time-config
package.
Template Build-time conditionals
TODO: this section is a rough first pass. Once clarified, it should also get reflected in the other places where we talk about the template publication format.
We also need build-time conditional capability in templates, because (for example) many of the AST transforms we will be asking addons to pre-apply are supposed to behave differently depending on the Ember version.
The input data is exactly the same as used for Javascript build-time conditionals (any JSON-serializable constants exported from your build module are available via the @ember/build-config-config
package). We add a helper for accessing those values:
{{#if (ember-build-time-config "my-package" "needsOldFeature")}}
...
{{else}}
...
{{/if}}
And we implement a transform in the template compiler that does branch elimination based off the values.
Note that only Boolean predicates are handled by the dead-code elimination. You can produce Booleans from arbitrary logic in your build
module (including things like semver tests or feature probing).
The activeDependencies
hook receives the list of names of your allowed dependencies that are Ember packages as input and returns either the same list or a subset of the list:
activeDependencies(childPackageNames) {
if (someThingIsDisabled) {
return childPackageNames.filter(name => name !== 'the-one-we-dont-need');
} else {
return childPackageNames;
}
}
If you don’t implement the activeDependencies
hook, all your dependencies
are considered active.
When and only when a package is active:
- all standard Ember module types (
your-package/components/*.js
,your-package/services/*.js
, etc) from its Own Javascript that cannot be statically ruled out as unnecessary are included in the build as if some application code hasimport
ed them. (What counts as “cannot be statically ruled out” is free to change as apps adopt increasingly static practices. This doesn’t break any already published packages, it just makes builds that consume them more efficient.) - if your Ember package metadata contains
"implicit-scripts"
or"implicit-test-scripts"
, the listed scripts will be included in the consuming app or its tests, respectively. Each of these keys can contain a list of specifier strings that will be resolved relative to the package. This is a backward-compatibility feature for capturing the behavior of v1 packages. New features are encouraged to use directimport
where possible.
Example:
"ember-addon": {
"version": 2,
"implicit-scripts": ["./vendor/my-package/some-shim", "lodash/sortBy"]
}
Scripts included this way are not interpreted as ES modules. They are evaluated in script context (think <script src="./vendor/my-package/some-shim.js">
not <script type="module"
src="./vendor-my-package/some-shim.js">
.
- all App Javascript is included in the build.
- Preprocessors apply to the consuming package. In the case of an addon with an active child addon that provides a preprocessor, this is only interesting at publication time, not built time, because that is when preprocessors run for addon code.
- Middleware, Commands, and Blueprints are available.
- ContentFor is injected.
- Assets are included in the build.
- its Active Dependencies become active recursively.
Whether or not a package is active:
- directly-imported Own Javascript and CSS are available to any other package as described in those sections. The rationale for allowing
import
of non-active packages is that (1) we follow node module resolution and node module resolution doesn’t care about our notion of “active”, and (2)import
is an explicit request to use the module in question. It’s not surprising that it would work, it would be more surprising if it didn’t.
The activeDependencies
hook is the only way to disable child packages. Notice that the package hooks are now implemented as a class with no base class. There is no super
to manipulate to interfere with your children’s hooks.
TODO: this section is a rough draft. It’s ideas need to get incorporated better into the rest of the doc, too.
v1 Addon packages make up their own conventions for how to receive configuration from their parent package(s). It’s messy. Many packages expect their config under a key that doesn’t actually match their own name (like fingerprint
for broccoli-asset-rev
), which makes things unnecessarily mysterious.
Furthermore, a package can be used by multiple other packages simultaneously. For some features (like preprocessors) this is fine, because multiple instances of a given package with distinct configurations can each operate independently. But for features like Own Javascript, there’s no desirable way for each instance to operate independently. A consensus must be reached. v1 packages just smoosh together the output from all instances, with a precedence that depends on ember-cli traversal order. This often wastes work, since multiple instances are doing the same thing.
In contrast, a v2 package that is consumed by multiple other packages is only instantiated once, and it can see all the configurations simultaneously. It’s required to come up with a single answer for (for example) any build-time configuration it wants to use to manipulate its own build via Build-Time Conditionals. The hooks that do work fine independently such as preprocessor registration are called multiple times and receive each configuration as an argument. TODO: clarify each of the hooks in more detail.
An addon that is published in v2 format has already applied all of the preprocessors and AST transforms that is needs to its own code. This means that many things that are today dependencies
of addons can become devDependencies
of addons. This results in a smaller overall node_modules
tree for consuming apps, and faster NPM installs.
Examples of packages that are very frequently used as dependencies
of addons that could stop being so include ember-cli-htmlbars
, ember-cli-babel
, and ember-cli-sass
. Readers are invited to count how many copies of each of these appear in their apps today, and think about how nice it will be not to install all of those every time.
This is an example of embracing NPM conventions: best practice is to preprocess the unusual features out of your code before publishing.
In-repo addons are addons, so they have all the same semantics. The only gotcha is that we need them to be resolvable by the node modules resolution algorithm, such that their containing package can import directly from them.
Therefore, a v2 package that contains in-repo addons is responsible for linking or copying them into its own node_modules directory.
Engines don’t require any special features in the v2 format.
Non-lazy engines are just addons that have more restrictive resolvers. This makes them already closer to how v2 formatted packages should behave (only accessing things that are in their own allowed dependencies).
Lazy engines require special build code today, but all of that is expressible as dynamic import()
, so we can represent lazy engines in v2 format without introducing the “engine” concept to the v2 format itself. Remember: the point of the v2 format is that most of the ember-specific knowledge — include what an “engine” is — has already been compiled out into more verbose, spec-defined features.
Fastboot doesn’t require any special features in the v2 format.
Builds can do different things in fastboot vs browser by using Build-Time Conditionals.
Final-stage packagers already tend to have target support that allows them to optimize for browser vs node builds. In both cases they could consume the same v2-formatted packages as input.
All the work that goes into producing the full set of v2 packages is shared between browser and fastboot builds. The only differences are that we would change the build-time config for the ember-cli-fastboot package, and rerun the final stage packager with Node as the target.
Apps are packages. For an app, the v2 publication format is not something you would probably ever publish to NPM. But it’s still important that it exists!
During the build process for an app, it will first build from its authoring format to the standard v2 package format. At that point, the whole project is just a collection of standard v2 packages with well-defined semantics, and we can confidently treat that stage in the build pipeline as supported public API.
The benefit of this approach is that we can separately evolve authoring formats and last-stage packaging tools, while keeping a stable interface between them. The stable interface is designed to leverage general-purpose ECMA-spec-compliant features wherever practical, which makes it a rich target.
v2-formatted apps do differ in a few ways from v2-formatted addon, as described in the following sections.
Several features in the v2 addon format are designed to be consumed by the app. These features aren’t appropriate in an app, because that is the end of the line — a v2-formatted app should have no more Ember-specific build semantics.
These features include:
- the
implicit-*
keys in Ember package metadata. - the
app-js
key in Ember package metadata - the
build
key in Ember package metadata. (We should consider updating the authoring format so that apps can use a build file with the standard package hooks, because that makes a lot of sense. But it’s not appropriate in the v2 build output, and this change can be a separate RFC, and it will be an easier RFC after landing this one.) - automatic inclusion of resolvable types (components, services, etc) from the Own Javascript of all Active Dependencies.
All these features can appear in v2 addons, and the app ensures each one is represented by standards-compliant Javascript within the app’s own code.
One feature that is allowed in a v2 app is the externals
key in Ember package metadata. This is designed to match up with a common feature on existing Javascript packagers that allows them to leave some module references unresolved until runtime.
These are features that are only supported in apps, not addons:
-
"assets"
: in Ember package metadata, a list of relative paths to files. The intent of"assets"
is that it declares that each file in the list must result in a valid URL in the final app.The most important assets are HTML files. All
contentFor
has already been applied to them. (Remember, we’re talking about the publication format that can be handed to the final stage packager, not necessarily the authoring format.) It is the job of the final stage packager to examine each asset HTML file and decide how to package up all its included assets in a correct and optimal way, emitting a final result HTML file that is rewritten to include the packaged assets.Note that packagers must respect the HTML semantics of
<script type="module">
vs<script>
vs<script async>
. For example:- don’t go looking for
import
in<script>
, it’s only correct in<script type="module">
File types other than HTML are allowed to appear in
"assets"
. The intent is the same (it means these files must end up in the final build such that they’re addressable by HTTP). For example, a Javascript file in"assets"
implies that you want that JS file to be addressable in the final app (and we will treat it as a script, not a module, because this is for foreign JS that isn’t going through the typical build system. If you actually want a separate JS file as output of your build, useimport()
instead). This is a catch-all that allows things like your/public
folder full of arbitrary files to pass through the final stage packager.A conventional app will have an
"assets"
list that includeindex.html
,tests/index.html
, and all the files that were copied from/public
. - don’t go looking for
-
synchronous dynamic imports are allowed in the app’s Javascript. See next subsection.
-
"template-compiler"
: in Ember package metadata, the relative path to a module that is capable of compiling all the templates. The module’s exports :compile: (moduleName: string, templateContents: string) => string
that converts templates into JS modules.isParallelSafe: boolean
: true if this compiler can be used in other node processes
-
"babel-config"
: in Ember package metadata, the relative path to a module that exports a value with three properties:config
: the app’s preferred babel settingsisParallelSafe: boolean
: true if thebabel
settings can be used in a new node process.version
: the version of babel the app's settings were written for.
-
Unlike addons, an app’s Own Javascript is not limited to only ES latest features. It’s allowed to use any features that work with its exposed
"babel-config"
. This is an optimization — we could logically require apps to follow the same rule as addons and compile down to ES latest before handing off to a final packager. But the final packager is going to run babel anyway, so we allow apps to do all their transpilation in that final single pass.
Apps can use synchronous dynamic “imports”
We need to make two small extensions beyond the ECMA spec, because Ember’s resolver is synchronous and dynamic. ECMA import gives us either asynchronous dynamic or synchronous static, but not synchronous dynamic (for a pretty good reason — browsers can’t really load a new dynamic asset synchronously).
Our extensions are:
importSync(specifier: string) => Module
a special form with the same syntax and semantics asimport()
except instead of returning a Promise it returns the module object synchronously or throws if it is not available.mayImportSync StringLiteral
a special form that exists to inform a static analyzer that a given specifier might be accessed viaimportSync
. Only valid at module scope. Any module that saysmayImportSync "something"
and every module that statically depends on it may safely assume that eitherimportSync("something")
will succeed or it will fail at build time.mayImportSync
has no runtime semantics (it can compile to nothing by the time we are running in the browser).
In practice, final stage packagers already tend to offer importSync
semantics (because they compile import
to a synchronous function). mayImportSync
is less supported, but given that we are keeping runtime AMD compatibility (see Named AMD Interop below), we can express it as window.define("your-module", [], function(){ return importSync("your-module"); })
.
Q. What is the difference between mayImportSync "thing"
and import "thing"
, since both just cause "thing"
to be statically added to our build?
A. mayImportSync "thing"
doesn’t execute the dependency. import "thing"
guarantees that the dependency will be executed before any of the code in your module.
Only app packages can use this capability, not addons. It’s mostly of interest to people who want to integrate new final-stage packagers. Addons don’t need this capability because they delegate the responsibility to the consuming application.
You should think of mayImportSync
and importSync
as spec concepts, not literally code that will appear anywhere. In practice, someone integrating a packager can provide us hooks for how to express both of these concepts in terms that their packager understands.
In the future it is worth aligning Ember’s resolver with ECMA by eliminating the need for these extensions. It will require deprecating synchronous container lookup
, etc, in favor of asynchronous resolution, so we can model is as import()
.
A final stage packager receives a directory containing a v2 app package. The app package’s dependencies are resolvable (via normal node rules) to v2 addon packages and non-Ember packages. All the Ember packages have a resolvable dependency on the special @ember/build-time-config
package, which is built and ready to consume as normal ECMA modules.
One benefit of our current system not respecting node_modules resolution everywhere is that it avoids the worst thing about node_modules: widespread module duplication. Browser applications very rarely want to ship multiple copies of the same dependency simultaneously. It adds bloat, and it can lead to very confusing bugs if duplicated modules have state.
This problem can be compounded when you use npm link
to develop multiple packages simultaneously, since even if you were careful to flatten down your dependencies, you will suddenly have multiple distinct copies again.
This problem is general to NPM, and I think the solution should be equally general. So in one sense, it’s beyond the scope of this document and the v2 package spec doesn’t need to directly address it. But we should consider the usability impact. Some recommendations to assuage the impact:
- add a linting tool to the default app blueprint that will warn whenever a duplicated package would end up in the browser build. Many final-stage packagers already offer analysis features like this. For example, a webpack-based packager could integrate duplicate-package-checker-webpack-plugin with the Ember build output or test suite.
- consider whether we can use Node’s preserve symlinks option to prevent
npm link
from introducing duplication. - find or write a utility that does a more sophisticated
npm link
that also back-links all shared dependencies.
One hopeful NPM-ecosystem proposal is Yarn Plugn’n’Play. If that proposal or a similar one moves forward, everything in this spec still works, and only localized changes to our build code would be needed (no ecosystem-wide changes to all addons / packages would be needed).
My goal is to make it so we can automatically compile all v1 packages to v2 on demand. This allows us to immediately refactor the ember-cli build pipeline into a clean main path that only handles v2 packages, plus a nicely-encapsulated wrapper around v1 packages that does their precompilation.
We can also make v2 packages work with older ember-cli by taking advantage of the existing main
key support in Ember package metadata. v2 packages will have their true main
pointing at their Own Javascript as described so far, while pointing their ember-addon.main
at a v2-to-v1 shim. This allows addon authors to begin to update immediately without dropping support for older ember-cli versions.
Addons can immediately begin relying on direct import from NPM by using ember-auto-import as a polyfill. In old ember-cli versions, the ember-auto-import will run. In new ember-cli versions, ember-auto-import can become a no-op. To make this fully align with the v2 build spec, ember-auto-import should gain CSS support to match.
Named AMD Interop
Our runtime AMD-based loader does not mesh well with ES module and node_modules resolution semantics. One big difference is that Named AMD define
is allowed to create a module with whatever name you want, completely disconnected from which NPM package is actually doing the defining. Examples:
@ember/component
really gets transpiled into usage of theEmber
global, which comes fromember-source
.moment
typically comes fromember-cli-moment-shim
, which has its owndependency
onmoment
and dynamically incorporates the right files into the build.qunit
typically comes fromember-qunit
.
This makes it basically impossible to statically discover all the modules at build time. It would be nice to completely jettison the whole named AMD loader, but in practice it is public API that is widely used. This design does not propose any breaking changes to it.
To describe our interoperability strategy, we must first distinguish “run-time specifiers” and “build-time specifiers”. A run-time specifier (like @ember/component
) is a name that is able to be require
'd at runtime. A build-time specifier (like ember-cli-shims/vendor/ember-cli-shims/app-shims
) is something you can actually require.resolve
using Node resolution rules.
For many modules, the run-time and build-time specifiers happily coincide (or at least they do once we have run the v1 to v2 compiler step, which (for example) moves ember-percy/addon/index.js
to ember-percy/index.js
so that import { percySnapshot } from
"ember-percy"
follows normal node_modules resolution. This is the case for all /app
and /addon
Javascript that is authored in ES modules. It’s also already true for packages that adopt Module Unification (good job MU authors).
But for many other cases (including the examples like @ember/component
), the build- and run-time specifiers are different.
The good news is, even though the define
side is generally too dynamic to analyze, in practice Ember apps overwhelmingly use import
on the consuming side, which we can analyze. Any import
specifier that appears in a v1 package can safely be assumed to already be available at runtime (or if it isn’t, the package was already broken and we aren’t making it any worse).
So our solution works like this:
- Analyze the imports that appear in our package, partitioning them into ones that can be build-time resolved and ones that cannot. This is what ember-auto-import already does.
- For imports that resolve to files within the same package, rewrite them as relative imports if they aren’t already (node_modules resolution does not allow packages to resolve themselves).
- For imports that cannot be resolved, list them in the Ember package metadata as
externals
. - Our final-stage packager (like Webpack or Parcel) should be configured to find externals via our runtime AMD loader.
- Our final-stage packager integration shall hook itself in as a fallback to
require
(for example,require('foo')
would first find something that came from a runtimedefine('foo', …)
, but then would also try__webpack_require__('foo')
.
This allows statically-defined modules to depend on dynamically-defined modules (using externals), and it allows dynamically-defined modules to depend on statically-defined modules (because of the fallback hook).
The only failure mode for this design happens if a v1 package was doing a run-time resolution of a specifier that happens to also be statically resolvable. For example, if you’re using ember-cli-moment-shim
to import
"moment"
, but you also list moment
in your package.json dependencies, when you switch to a v2 build you will start getting the direct moment
and not the shim. In that case, you could get an unexpected version of moment. In practice, I think this is rare, and even when you get the surprising behavior you often wouldn’t get breaking behavior (because people generally don’t stomp on existing module names to provide something entirely different). Keep in mind it’s not sufficient that moment
happens to be resolvable — we strictly check that you’ve actually listed it as a dependency. So even if moment
gets hoisted out of ember-cli-moment-shim
, you’re still safe. It’s only if you actually depend directly on moment
and ember-cli-moment-shim
that you would get new behavior.
The impact on application authors is very small. As far as I can tell, there is almost nothing new to learn. The only visible API change should be that is becomes possible to import
directly from any NPM package (as already demonstrated by ember-auto-import), and this is arguably one less thing to learn about for new people, since they may very well already expect that to work.
We could consider deprecating app.import
, but it can be compiled directly into a script tag that the final stage packagers understand, so it’s not worth deprecating immediately.
The impact on addon authors is more significant. This design is fully backward compatible, and the intention is that all existing addons continue to work (some with worse compatibility hacks than others in the v1-to-v2 compiler). But there will be a demand for addons published in v2 format, since it is expected to result in faster build times. My prediction is that people who are motivated to get their own app build times down will send a lot of PRs to the addons they’re using.
In many cases, converting addons to v2 makes them simpler. For example, today many addons use custom broccoli code to wrap third-party libraries in a fastboot guard that prevents the libraries from trying to load in Node (where they presumably don’t work). In v2, they can drop all that custom build-time code in favor of:
if (!Fastboot) {
await import('third-party-library');
}
This design does not advocate loudly deprecating any v1 addon features. Doing that all at once would be unnecessarily disruptive. I would rather rely on the carrot of faster builds than the stick of deprecation warnings. We can choose to deprecate v1 features in stages at a later time.
This design effectively supersedes both the Packager RFC and the Prebuilt Addons RFC. So both of those are alternatives to this one.
Packager creates an escape hatch from the existing ember-cli build that is supposed to provide a foundation for many of the same features enabled by this design. The intention was correct, but in my opinion it tries to decompose the build along the wrong abstraction boundaries. It follows the existing pattern within ember-cli of decomposing the build by feature (all app javacript, all addon javascript, all templates, etc) rather than by package (everything from the app, everything from ember-data, everything from ember-power-select, etc), which puts it into direct conflict with the Prebuilt Addons RFC.
The API that packager provides is also incomplete compared with this design. For example, to take the packager output and build it using Webpack, Rollup, or Parcel still requires a significant amount of custom code. Whereas taking a collection of v2 formatted Ember packages and building them with any of those tools requires very little custom code. TODO: link to hopefully more than one working example.
The prebuilt addons RFC addresses build performance by doing the same kind of work-moving as this design. Addons can do much of their building up front, thus saving time when apps are building. But it only achieves a speedup when apps happen to be using the same build options that addons authors happened to publish. This design takes a different approach that preserves complete freedom for app authors to postprocess all addon Javascript, including dead-code-elimination based on the addon features their app is using. The prebuilt addons RFC also doesn’t attempt to specify the contents of the prebuilt trees — it just accepts the current implementation-defined contents. This is problematic because shared builds artifacts are long-lived, so it’s worth trying to align them with very general, spec-compliant semantics.
This spec makes some promises about app-build-time behavior that all v2-formatted addons can rely on. This behavior goes beyond "just Javascript" semantics by making some optimizations mandatory. V2-formatted addons that depend on the following packages get optimization guarantees:
@ember/build-time-config
ember-cli-htmlbars-inline-precompile
The details of each should be described elsewhere in this spec.
Allowed in: addons
Status: intent to deprecate
A path to a directory that should be merged with the app's own namespace. This is a backward-compatibility feature, avoiding using it.
Allowed in: apps and addons
Status: internal use only
Boolean. Marks a package as having been compiled on the fly from v1 to v2. It's probably not a good idea to ever publish a package to NPM with this set.
Allowed in: apps
Status: encouraged
Path to a Javascript file that exports a function for testing (absolute) file paths and returning true if that file should be transpiled by our babel config (as defined by babel.filename
).
Allowed in: apps
Status: encouraged
Path to a Javascript file that exports a babel config.
Note that this is for use in apps, which means in compiled apps that are being handed off for final stage packaging. Mostly this is relevant only to authors of final stage packagers.
Allowed in: apps
Status: encouraged
Boolean that indicates whether it's safe to load our babel config in a new node process.
Allowed in: apps
Status: encouraged
Which babel major version is our babel config expecting to run inside.
Allowed in: addons
Status: encouraged
Path to a package's build-time hooks file.
Allowed in: apps
Status: encouraged
List of paths to files (of any type) that must be present as valid URLs in the final output. HTML files are typical assets, but so is anything that we cannot otherwise rule out. For example: everything in /public
in a traditional Ember app goes into assets
, since we can't know if anybody expects them to be remain present on the web.
Note that this is for use in apps, which means in compiled apps that are being handed off for final stage packaging. Mostly this is relevant only to authors of final stage packagers.
Allowed in: addons and apps
Status: intent to deprecate
List of module names that are used within the package but not statically build-time resolvable.
This is a backward-compatibility feature that allows us to more efficiently bridge the gap between build-time and run-time resolution.
Allowed in: addons
Status: intent to deprecate
A path to a directory that should be merged with the app's own namespace, but only when running in Fastboot. This is a backward-compatibility feature, avoiding using it. New addons can use the macro system to guard fastboot-only imports.
Allowed in: addons
Status: intent to deprecate
List of paths to Javascript modules that must be resolvable at runtime whenever this package is active.
This is a backward-compatibility feature that's used by the v1-to-v2 compiler.
Allowed in: addons
Status: use sparingly
List of paths to Javascript files that should be included via <script>
whenever this package is active. This is effectively a drop-in replacement for the old app.import()
of JS files.
Instead of using this, prefer to directly import the code you need from the place that needs it. But this may still be needed if you're depending on a legacy library that doesn't work in module context.
Allowed in: addons
Status: use sparingly
List of paths to CSS files that should be included as <link rel="stylesheet">
whenever this package is active.
Prefer instead to express your dependencies on CSS via ECMA import from the places where it's needed.
Allowed in: addons
Status: intent to deprecate
Same as implicit-modules
, but only within tests/index.html
.
Allowed in: addons
Status: use sparingly
Same as implicit-scripts
, but only within tests/index.html
.
Allowed in: addons
Status: use sparingly
Same as implicit-styles
, but only within tests/index.html
Allowed in: addons
Status: encouraged
This field predates the v2 package spec. It's already used by ember-cli to provide an alternative location for the addon's build-time hooks (as opposed to using the top-level main
from package.json
). It's definition is unchanged by this spec: old EmberCLI can keep on using this and finding compatible hooks. v2-aware versions of EmberCLI will ignore this in favor of build
. Addon authors are encouraged to use this field to allow V2 packages to work in older EmberCLI versions.
Allowed in: addons
Status: encouraged
This is a mapping from local filenames (relative to the addon's root) to app-relative-URLs. When a given addon is active, any public-assets will be available at the corresponding URLs.
Allowed in: addons
Status: intent to deprecate
An object that maps old module names to new module names. Any Ember package that consumes this package will rewrite its own imports to follow these renames.
For example, ember-qunit
emits a module that is importable as qunit
, which we capture and rename:
"renamed-modules": {
"qunit/index.js": "ember-qunit/qunit/index.js"
}
And then in an app that imports qunit
, our Babel plugin will rewrite:
-import QUnit from 'qunit';
+import QUnit from 'ember-qunit/qunit';
This is a backward compatibility feature and you should stop doing this. Exposing a module under some other package's name is Not Nice.
Allowed in: addons
Status: intent to deprecate
An object that maps old package names to new package names. Any Ember package that consumes this package will rewrite its own imports to follow these renames.
For example, ember-lodash
renames itself to lodash
. When we compile it into a v2 package, we generate:
"renamed-packages": {
"lodash": "ember-lodash"
}
And then in an app that depends on ember-lodash
, our Babel plugin will rewrite:
-import capitalize from 'lodash/capitalize';
+import capitalize from 'ember-lodash/capitalize';
This is a backward compatibility feature and you should stop doing this. Exposing a module under some other package's name is Not Nice.
Allowed in: apps
Status: required
A list of file extensions that are considered resolvable as modules. In priority order. Example:
"resolvable-extensions": [".ts", ".js", ".hbs"]
Allowed in: apps
Status: encouraged
The public URL at which the root of the app will be served. Defaults to '/' when not provided.
Allowed in: apps
Status: encouraged
Path to a Javascript file that provides the preconfigured HBS template compiler. Stage3 packagers should grab the compile
function off the default export and just use that.
Note that this is for use in apps, which means in compiled apps that are being handed off for final stage packaging. Mostly this is relevant only to authors of final stage packagers.
Allowed in: apps
Status: encouraged
Boolean. Indicates whether this template compiler is safe to use in a new node process.
Allowed in: addons and apps
Status: encouraged
Identifies that a package is v2 spec compatible.