This Ember addon implements the functionality described in the Ember Engines RFC. Engines allow multiple logical applications to be composed together into a single application from the user's perspective.
This addon must be installed in any ember-cli projects that function as either consumers or providers of engines. The following functionality is supported:
- Routable engines which can be mounted at specific routes in a routing map, and which can contain routes of their own.
- Route-less engines, which can be rendered in a template using the
{{mount}}
keyword. - Sharing of dependencies from parents (applications or other engines) to contained engines. Shared dependencies are currently limited to services and route paths.
- Lazy loading of engines.
The following functionality will soon be supported:
- Route serializer modules that isolate serialization logic from the rest of the route definition.
Support for the following concepts is under consideration:
- Namespaced access to engine resources from applications.
- Sharing of dependencies other than services and route paths.
- Passing configuration attributes from an engine's parent.
This addon should be considered experimental. But engines are production ready and many of the APIs are fully stable.
The master branch of this addon is being developed against the master branch of Ember and Ember-CLI, and should be considered unstable. If you're planning to deploy to production, please use one of the stable releases.
v0.5.15 or higher is compatible with FastBoot 1.0+.
v0.5 of this addon is compatible with v2.12.x of both Ember and Ember-CLI.
v0.4 of this addon is compatible with v2.10.x of both Ember and Ember-CLI.
v0.3 of this addon is compatible with v2.8.x of Ember. This is the first version of Ember in which the required hooks for engines are available without a feature flag.
From your Ember CLI project's root directory, run the following:
ember install ember-engines
Install the appropriate version of Ember as noted above.
Engines can be created as separate addon projects or in-repo addons.
Separate addon projects can be created with the addon
command:
ember addon <engine-name>
Note: As described in the RFC, ember-cli will hopefully support an engine
command to get started more easily with engine projects.
In order to create an engine within an existing application's project, run the
in-repo-engine
generator:
ember g in-repo-engine <engine-name>
Don't forget to install ember-engines
and the appropriate version of Ember in
your project, as described above.
You must also declare in your Engine's index.js
file whether or not the engine should be lazy loaded. Until lazy loading is supported, this should be set to false
:
const EngineAddon = require('ember-engines/lib/engine-addon');
module.exports = EngineAddon.extend({
name: 'ember-blog',
lazyLoading: {
enabled: false
}
});
Routable engines should declare their route map in a routes.js
file within your engine's addon
directory.
For example:
import buildRoutes from 'ember-engines/routes';
export default buildRoutes(function() {
this.route('new');
this.route('post', { path: 'post/:id' }, function() {
this.route('comments', function() {
this.route('comment', { path: ':id' });
});
});
});
Routable engines interact with the parent application's router as if they are an extension of the parent application. A routable engine's application route will be mounted wherever specified by the parent's route map (its "mountpoint").
Route-less engines should define an engine.js
as described above. Neither
router.js
nor routes.js
should be defined. Route-less engines will be rendered as their application template
(templates/application.hbs
).
Your engine should declare any dependencies that it expects from its parent. Dependencies must be declared in the engine definition.
For example, the following engine requires a store
service from its parent:
import Engine from 'ember-engines/engine';
import Resolver from 'ember-resolver';
import loadInitializers from 'ember-load-initializers';
import config from './config/environment';
const { modulePrefix } = config;
const Eng = Engine.extend({
modulePrefix,
Resolver,
dependencies: {
services: [
'store'
]
}
});
loadInitializers(Eng, modulePrefix);
export default Eng;
Currently, only services and route paths (see below) can be shared across the parent/engine boundary.
Linking to routes outside of an Engine's isolated context is currently supported by defining "external routes" as dependencies of your Engine.
You specify what external things your Engine wants to link to by providing an array of names like so:
// ember-blog/addon/engine.js
export default Engine.extend({
// ...
dependencies: {
externalRoutes: [
'home',
'settings'
]
}
});
The Engine's consumer is then responsible for defining where those things are located via a route path:
// dummy/app/app.js
import Application from '@ember/application';
import Resolver from './resolver';
import loadInitializers from 'ember-load-initializers';
import config from './config/environment';
const App = Application.extend({
modulePrefix: config.modulePrefix,
podModulePrefix: config.podModulePrefix,
Resolver,
engines: {
emberBlog: {
dependencies: {
externalRoutes: {
home: 'home.index',
settings: 'settings.blog.index'
}
}
}
}
});
You can then use those external routes either programmatically or within a template like so:
// ember-blog/addon/some-route.js
this.transitionToExternal('settings');
For further documentation on this subject, view the Engine Linking RFC.
When routing into an Engine that is lazily loaded there are some special considerations and subtle differences from how routing works in a normal Ember application.
Since the links to your Engine are constructed before the Engine itself is loaded, you need to make sure the application has the necessary code to serialize data into the URLs. To that end, you need to replace any Route#serialize
functions with route serializers, as defined in the Route Serializers RFC.
For example, if you had a Post
route defined like so:
import Route from '@ember/routing/route';
export default Route.extend({
serialize(model) {
return { post_id: model.id };
}
});
You would need to remove that function and inline it into your routes.js
map, which is loaded pre-emptively with the application:
function serializePost(model) {
return { post_id: model.id };
}
export default buildRoutes(function() {
this.route('post', { serialize: serializePost });
});
Note that route serializers are unique to Engines and won't work in normal applications. In a normal Ember application you should continue to use Route#serialize
.
The loading and error substates work in a similar fashion to substates in a normal Ember app. The only difference is that lazily loaded Engines will enter a loading state while the assets for the Engine are loaded and can enter an error state when an asset fails to load.
As in an application, you can provide configuration settings for your
engine in config/environment.js
. You can access these settings in a
couple different ways.
The simplest method is to import these settings:
// addon/engine.js
import config from './config/environment';
console.log(config.modulePrefix);
Configuration settings are also registered with the key config:environment
and
can be looked up given an engine instance. For example:
// addon/instance-initializers/hello-instance.js
export function initialize(engineInstance) {
let config = engineInstance.resolveRegistration('config:environment');
console.log('modulePrefix', config.modulePrefix);
}
export default {
name: 'hello-instance',
initialize: initialize
};
Eager engines are built approximately the same as existing addons. Differences
are limited to consolidating the namespace of app
code inside of an engine
into the engine's namespace instead of the host application.
Beyond that it adds in a configuration module for the engine, and nothing else. It is a remarkably straightforward process.
Lazy engines are built in the same way as eager engines, but their assets are
not combined back into the host application's vendor.js
file. This means that
they are run through a separate and unique build process from what a default
addon will go through, though it reaches out to the upstream implementation in
Ember CLI where possible.
A lazy engine's output (lazy-engine
) looks like this:
dist
├── assets
│ ├── host-application.css
│ ├── host-application.js
│ ├── vendor.css
│ └── vendor.js
├── crossdomain.xml
├── engines-dist
│ └── lazy-engine
│ ├── assets
│ │ ├── engine-vendor.css
│ │ ├── engine-vendor.js
│ │ ├── engine.css
│ │ └── engine.js
│ └── public-asset.jpg
├── index.html
└── robots.txt
The routes.js
file and anything it import
s must be present at boot time of
the host application. It will be bundled into the host application's vendor.js
file. This location should be considered undefined
behavior and should not be
relied upon as it may change in the future.
Its module name inside of the host application will be lazy-engine/routes
. Any
import
s will also be in the lazy-engine
module path.
Assets in this folder don't make sense and will be ignored as they break the isolation guarantees of engines.
JavaScript assets in this folder will be processed as per normal addon behavior
except that they will end up inside of the engine.js
file. Their module
definition will be rooted to the engine name.
For example, /addon/routes/application.js
will result in a JavaScript module
named lazy-engine/routes/application
inside of the
/dist/engines-dist/lazy-engine/engine.js
file.
Templates will be compiled by your engine but they must include
ember-cli-htmlbars
inside of dependencies
in the engine's package.json
.
As an example, /addon/templates/application.hbs
will result in a JavaScript
module named lazy-engine/templates/application
inside of the
/dist/engines-dist/lazy-engine/engine.js
file.
CSS files will be built similarly to how they are processed inside of typical adddons. Typical addon behavior is as follows:
- All nested addons are processed. Each of them may return a
style
tree. By default these style trees only contain the contents ofaddon/styles/addon.css
. The contents of theaddon/styles/addon.css
file is moved inside of the Broccoli tree to${addon-name}.css
. This can be modified if the addon specifies a customtreeForStyle
hook. - All top-level addons (those directly depended upon by the host) have all of
addon/styles/**/*.css
included into the host'svendor.css
file. For exampleaddon/styles/foo.css
will appear in the output Broccoli tree atfoo.css
. - If you name a CSS file in one of the top-level addons the same as an addon
name (e.g. addon name is
alpha
), any top-level addon which has a CSS file of the same name as that addon (alpha.css
) and is provided by an addon lexicographically after it (zeta
) will clobber the contents ofalpha/addon/styles/addon.css
(from anywhere in the dependency graph) withzeta/addon/styles/alpha.css
. (This is also a possible consequence of DAG topsorting.)
Lazy engines will use a variation of this approach:
- The engine itself will be treated as if it is a top-level dependency. This
means that
addon/styles/**/*.css
will end up inside ofengine.css
. - Child addons of a lazy engine will be treated as if they are top-level
addons. This means that they will have their
treeForStyle
hook executed and the result of that hook will be merged intoengine-vendor.css
in DAG/lexicographic order. - Nested lazy engine boundaries will not be crossed when calculating the child
treeForStyle
hook.
Assets appearing in the public folder will appear at the root of the engine
output with no transformation. For example /public/public-asset.jpg
appears at
the root level of the /dist/engines-dist/lazy-engine/
output folder. Assets in
this folder have no default behavior and you are responsible for any custom
behavior.
Further, the engine must enumerate its primary assets (JS and CSS) in order to
be loaded by the asset loading service. That will be generated at
/dist/asset-manifest.json
at build time. It will also by default be inserted
into a meta tag config inside of the host application's index.html
.
Nested eager engines will be built into their host engine or application. Modules will be deduplicated within the engine boundary and with the host application.
Nested lazy engines will be promoted to /dist/engines-dist/
folder in the
build output. Module deduplication will only be done with the host application.
Engines that are published as separate addons should be installed like any other addon:
ember install <engine-name>
As mentioned above, engines can also exist as in-repo addons, in which case
you just need to ensure that this addon (ember-engines
) has been installed
in your main project.
Route-less engines can be rendered in a template using the {{mount}}
keyword.
Route-less engines can be mounted in templates using the {{mount}}
keyword.
For example, the following template renders the ember-chat
engine:
Currently, the engine name is the only argument that can be passed to
{{mount}}
.
Routable engines should be mounted in your router's route map using the
mount()
method. For example:
import Route from '@ember/routing/route'
import config from './config/environment';
const Router = Router.extend({
location: config.locationType
});
Router.map(function() {
this.route('blogs', function() {
// Mount the main blog at /blogs/ember-blog
this.mount('ember-blog');
// Mount the hr blog at /blogs/hr-blog
this.mount('ember-blog', { as: 'hr-blog' });
// Mount the admin blog at /blogs/special-admin-blog-here
this.mount('ember-blog', { as: 'admin-blog', path: '/special-admin-blog-here' });
});
});
export default Router;
The above example mounts three different instances of the ember-blog
engine
within the blogs
route.
The engine mounted with this.mount('ember-blog')
will have a root path of
/blogs/ember-blog
and its root route can be referenced as ember-blog
.
The engine mounted with this.mount('ember-blog', { as: 'hr-blog' })
will have
a root path of /blogs/hr-blog
and its root route can be referenced as
hr-blog
.
The engine mounted with this.mount('ember-blog', { as: 'admin-blog', path: '/special-admin-blog-here' })
will have a root path of
/blogs/special-admin-blog-here
and its root route can be referenced as
admin-blog
.
Note: The above example is not very practical currently without a method to
configure individual instances of ember-blog
.
Applications or engines that contain an engine must provide mappings that fulfill the dependencies required by that engine.
For example, the following engine expects its parent to provide store
and
session
services:
import Engine from 'ember-engines/engine';
import Resolver from 'ember-resolver';
export default Engine.extend({
modulePrefix: 'ember-blog',
Resolver,
dependencies: {
services: [
'store',
'session'
]
}
});
An application that contains this engine must explicitly fulfill these dependencies. For example:
import Application from '@ember/application';
import Resolver from './resolver';
import loadInitializers from 'ember-load-initializers';
import config from './config/environment';
const App = Application.extend({
modulePrefix: config.modulePrefix,
podModulePrefix: config.podModulePrefix,
Resolver,
engines: {
emberBlog: {
dependencies: {
services: [
'store',
{'session': 'user-session'}
]
}
}
}
});
loadInitializers(App, config.modulePrefix);
export default App;
Note that the app's store
service is directly mapped to the engine's store
service, while the app's user-session
service is mapped to the engine's
session
service.
Also note that multiple engines can be configured per parent application/engine,
and that each engine name should be camelCased (emberBlog
instead of
ember-blog
).
To test components declared inside an in-repo engine, you need to set a custom resolver with the engine's prefix.
Assuming you have an in-repo engine called appointments-manager
and it has a component date-picker
. The
following would be the setup to test such component from the host app:
// host-app/tests/integration/components/date-picker-test.js
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
import engineResolverFor from 'ember-engines/test-support/engine-resolver-for';
const resolver = engineResolverFor('appointments-manager');
moduleForComponent('date-picker', 'Integration | Component | Date picker', {
integration: true,
resolver
});
test('renders text', function(assert) {
this.render(hbs`{{date-picker}}`);
assert.equal(this.$().text().trim(), 'una fecha');
});
Note: you could create a helper and then use it like Resolver from ../helpers/appointments-manager/resolver
If you have a lazy engine, you'll need to edit your tests/test-helper.js
like this:
import Application from '../app';
import config from '../config/environment';
import { setApplication } from '@ember/test-helpers';
import { start } from 'ember-qunit';
import preloadAssets from 'ember-asset-loader/test-support/preload-assets';
import manifest from '<app-name>/config/asset-manifest';
setApplication(Application.create(config.APP));
preloadAssets(manifest).then(start); // This ensures all engine resources are loaded before the tests
This should be enough to make integration & acceptance tests work. For unit tests, you'll need to use a custom resolver, as described in Unit/Integration testing for standalone engines.
- ember-engines-demo - an example of a parent application (consumer).
- ember-chat-engine - an example of a route-less engine that is an in-repo addon.
- ember-blog-engine - an example of a routable engine that is a separate addon project.
git clone
this repositorynpm install
ember server
- Visit your app at http://localhost:4200.
npm test
(Runsember try:testall
to test your addon against multiple Ember versions)ember test
ember test --server
ember build
For more information on using ember-cli, visit http://www.ember-cli.com/.
Copyright 2015-2018 Dan Gebhardt and Robert Jackson. MIT License (see LICENSE.md for details).