-
-
Notifications
You must be signed in to change notification settings - Fork 405
Description
This is a draft of an RFC, for which I'd like to get some early feedback. Will then finalize and submit it as a real RFC.
Summary
Due to the use of the global namespace, especially for its AMD-style module system, Ember apps have problems coexisting
with other Ember apps or any AMD-based JavaScript on the same page. This RFC proposes a way to prevent all potentially
conflicting usage of these globals.
Motivation
The most common case for running an Ember app is to run it as a pure isolated SPA (Single Page Application), i.e. to have
it own the HTML page it runs on. This is what you get out of the box when running ember build: a (mostly empty)
index.html, that contains the build assets that run the Ember app inside that empty page.
But another use case is to embed it into an existing page,
so that it runs alongside other JavaScript (let's call it "3rd party" from the perspective of the Ember app). While that
might work fine in some cases, due to Ember's still present reliance on globals, especially its AMD-style module
system that is actually incompatible with the original one of RequireJS, there is a high chance of non-trivial conflicts.
Let's look at a few examples:
Embedding an Ember app into a 3rd party HTML page
You might want to embed an Ember app on a 3rd party page (i.e. one that you don't control), for example
- a messaging/chat widget embedded on a corporate/sales website (think of Intercom)
- a product configurator embedded on an e-commerce site (that's what we do)
- a messaging board app on a customer support website (think of Discourse, but embedded)
In these cases, if the 3rd party page uses RequireJS itself, only one implementation (Ember vs. RequireJS) of the globally
defined define() and require() functions will "win", and the other's module loading will inherently be broken, in
the worst case both.
This is not uncommon, for example Magento, the most wide-spread e-commerce solution worldwide,
uses RequireJS, so effectively
you cannot use an Ember app on its pages without somehow tackling that conflict.
Micro Frontends
A rather new pattern in frontend development is the analogy of microservices for the frontend world, coined
Micro Frontends, where you develop, test and deploy parts of
your frontend independently, and compose them together for a seamless user experience.
The same issue arises when one of those micro frontends happens to use RequireJS, or two or more frontends are actual
Ember apps. While in the latter case we don't have to deal with different implementations of define() and require(),
those functions close over their module registry, so whatever functions win (as being the globally defined one), they will
only be able to see their registered modules, the modules of the other app will not exist anymore. And we are not even
speaking about conflicting versions of the same modules (say @ember/component). So in any way, that scenario is bound
to fail miserably.
Detailed design
The guiding principle suggested in this RFC is to make Ember apps practically side effect free, with respect to the
usage of the browser's global namespace (window/globalThis).
That means it should be discouraged to
- read custom variables from the global namespace
- write anything to the global namespace
While this can only be a recommended but not enforceable best practice for user-land code, for Ember and its build
tooling this should become a rule. That specifically includes:
- Ember.js (its runtime code)
- Ember-CLI
- ember-resolver
- ember-auto-import
- Embroider
Where this is practically not possible, it should be ensured that there is no realistic chance of a conflict, by
- naming global variables in a unique way, e.g. scoped to the app's name (in
package.json), so that two different Ember
apps cannot cause conflicts - ideally also adding a non-deterministic part like a random string that changes per build, that is only known to
privileged code (as listed above), so user-land code cannot rely on the existence of these globals.
While the Ember global has already been deprecated and removed from Ember 4 as part of RFC 706,
there are still other variables that Ember apps leak into the global namespace as described below, most notably Ember's
AMD-like module system, and as such pose a risk for conflicts.
Note that this RFC does not propose a new packaging format for Ember apps that does not rely on AMD-like modules.
While that could be a potential case for a future RFC, it is not in scope for this one. This only suggests to not rely
on the module system being accessed as globals, or at least as described above not in a way that poses a risk for conflicts.
Opt-in behavior
The changes proposed here should ideally only require changes in Ember's build tooling. For idiomatic Ember code, especially
code that only uses ES modules, it should be a non-breaking change. Also code that uses Ember's AMD-style module system
directly like require() or require.entries, should continue to work without any changes in most cases, as long as the
code is built by Ember-CLI (see below).
Although the AMD-like module API might not be strictly a public API of Ember, but it is often used in the wild to be at
least regarded as "intimate API". So for the scope of this RFC, we will consider it equivalent to public API.
But there might be cases where people are doing things differently, and code - even after the build tooling changes
proposed below - still effectively relies on the globally defined AMD-like module API. For this reason we may not just
remove these from the global namespace, breaking backwards compatibility.
So this RFC proposes an opt-in behavior, by introducing a new "feature" based on the already well known
@ember/optional-features, namely no-globals.
All the following proposals only apply when a user as explicitly opted into the new behavior, by enabling that optional
feature!
Note that similar to how Ember transitioned away from using jQuery, we might want to make this new behavior the default
at some point, and deprecate the previous one. But this is out of scope for this RFC.
Remove globals usage
As described above, when opting into no-globals mode, an Ember app built using Ember-CLI, including optionally
ember-auto-import and/or Embroider, should not read from and leak any globals, with the exceptions outlined above.
The following identifies current global usage, and outlines possible ways to remove their usage, or at least prevent the
chance of conflicts. Note that this RFC does not aim to prescribe a specific implementation, as besides the visible
behavior of not relying on globals there is no public API introduced here, so the actual implementation remains the task
of any implementor of this RFC. The implementation outlines are merely intended to demonstrate the feasibility of the
proposed changes.
AMD-like module system
The global functions/namespaces define and require (and its aliases requireModule, require, requirejs) are set
as globals and provided by the loader.js addon, which is part of the default
blueprint of any Ember app. Not having this addon or any equivalent replacement will break the app, as a.o. any define()
calls in vendor.js (for addon code) or app.js (for app code) would call an undefined function. So it is effectively a
mandatory dependency currently.
Despite that, Ember comes with its own (stripped down) loader library,
that has been used (pre Ember 3.27) internally only. As of Ember 3.27 it is effectively unused when loader.js is present
(which it has to be, see above), so dead code effectively.
With that, a typical build of vendor.js will look basically like this:
vendor.js
// EmberENV code, ommited here, see following chapter
var loader, define, requireModule, require, requirejs;
(function(global) {
// loader.js IIFE defing the globals above
// see https://github.com/ember-cli/loader.js/blob/master/lib/loader/loader.js
})(this);
(function() {
var define, require;
(function () {
// Ember's own internal loader code
// ...
// globalObj = globalThis w/ polyfill
if (typeof globalObj.define === 'function' && typeof globalObj.require === 'function') {
define = globalObj.define;
require = globalObj.require;
return;
}
// Following are Ember's own (internal) implementation of define and require
// However due to the early return above and the fact that loader.js always prepends its code, thus assigns
// the define and require globals, this code is never reached! (when the loader.js addon is present!)
})();
define("@ember/-internals/bootstrap/index", /*...*/);
// ...
// all of Ember's own modules
// ...
require('@ember/-internals/bootstrap')
})();
define('@ember-data/adapter/-private', /* ... */);
// ...
// all modules from Ember addons
// ...And this is what a typical compilation of app.js will look like:
app.js
define("my-app/app", /* ... */);
// ...
// all app modules
// ...As you can see it relies on a globally defined define, that has been declared in the first line of vendor.js, and set
by the code of loader.js.
As to how to prevent the global usage while retaining compatibility of any code that uses those functions as globals,
we can make the build system emit the existing code wrapped in a IIFE,
that avoids polluting the global namespace, while still any calls of define() or require() will see the same functions,
they just do not come from the global namespace anymore.
This works for vendor.js, where these functions are defined, but not for app.js which needs access to the same functions
as defined in vendor.js. That's why we still need some usage of globals, but as outlined in the previous chapter, we
must make sure they are scoped to the current app, so they pose no risk of conflicts.
So for vendor.js this is what the basic change could look like:
vendor.js
+ (function(){
var loader, define, requireModule, require, requirejs;
(function(global) {
// loader.js IIFE defing the globals above
// see https://github.com/ember-cli/loader.js/blob/master/lib/loader/loader.js
})(this);
+ globalThis.__my-app_12345_define = define;
+ globalThis.__my-app_12345_require = require;
- (function() {
- var define, require;
-
- (function () {
- // Ember's own internal loader code
-
- // ...
- // globalObj = globalThis w/ polyfill
- if (typeof globalObj.define === 'function' && typeof globalObj.require === 'function') {
- define = globalObj.define;
- require = globalObj.require;
-
- return;
- }
-
- // Following are Ember's own (internal) implementation of define and require
- // However due to the early return above and the fact that loader.js always prepends its code, thus assigns
- // the define and require globals, this code is never reached! (when the loader.js addon is present!)
-
- })();
define("@ember/-internals/bootstrap/index", /*...*/);
// ...
// all of Ember's own modules
// ...
require('@ember/-internals/bootstrap')
- })();
define('@ember-data/adapter/-private', /* ... */);
// ...
// all modules from Ember addons
// ...
+ })();Note that we were able to drop all of Ember's internal module code here!
And this for app.js:
app.js
+ (function() {
+ var define = globalThis.__my-app_12345_define;
+ var require = globalThis.__my-app_12345_require;
define("my-app/app", /* ... */);
// ...
// all app modules
// ...
+ })();By wrapping the code in the IIFE and defining define and require within it, we are basically hiding any eventually defined
globals (e.g. 3rd party RequireJS), while letting all existing code use our implementation as if it were globally defined.
Note that this does only work when code uses these functions as implicit globals. When explicitly referring to the global
namespace likewindow.require()orglobalThis.require(), then this will inevitably fail.
Besides these two bundles, which make up "classic" Ember apps, we must also take into account the additional bundles
("chunks") that ember-auto-import and Embroider would emit.
As ember-auto-import and Embroider emit similar webpack-compiled chunks, we will only highlight the required changes
in the case of ember-auto-import here.
ember-auto-import emits a chunk.app.xxx.js chunk, which includes the boundary between Ember's AMD-like modules and
whatever Webpack emits for the imported modules it handled, so this is basically the glue between these two worlds. It typically
looks something like this (cleaned up and simplified):
chunk.app.xxx.js
var __ember_auto_import__;
(() => {
var __webpack_modules__ = ({
"../../../../private/var/folders/q3/yzggf_l965zbzps_bk8xr37c0000gn/T/broccoli-4171121iPrUeBlpJ8/cache-307-webpack_bundler_ember_auto_import_webpack/app.js":
((module, __unused_webpack_exports, __webpack_require__) => {
module.exports = (function() {
var d = _eai_d;
var r = _eai_r;
window.emberAutoImportDynamic = function(specifier) {
if (arguments.length === 1) {
return r("_eai_dyn_" + specifier);
} else {
return r("_eai_dynt_" + specifier)(Array.prototype.slice.call(arguments, 1));
}
};
window.emberAutoImportSync = function(specifier) {return r("_eai_sync_" + specifier)(Array.prototype.slice.call(arguments, 1));};
d("lodash-es/find", [], function() { return __webpack_require__(/*! lodash-es/find */ "./node_modules/lodash-es/find.js");});
d("lodash-es/startsWith", [], function() {return __webpack_require__(/*! lodash-es/startsWith */ "./node_modules/lodash-es/startsWith.js");});
d("_eai_dyn_lodash-es/concat", [], function() {return __webpack_require__.e(/*! import() */ "node_modules_lodash-es_concat_js").then(__webpack_require__.bind(__webpack_require__, /*! lodash-es/concat */ "./node_modules/lodash-es/concat.js"));});
})();
}),
"../../../../private/var/folders/q3/yzggf_l965zbzps_bk8xr37c0000gn/T/broccoli-4171121iPrUeBlpJ8/cache-307-webpack_bundler_ember_auto_import_webpack/l.js":
(function(module, exports) {
window._eai_r = require;
window._eai_d = define;
})
});
// ... webpack internals
})();Note that ember-auto-import itself creates new globals _eai_* here, that we must prevent. And it takes Ember's require
and define from the global namespace. A "fixed" version could look like this:
chunk.app.xxx.js
var __ember_auto_import__;
(() => {
var __webpack_modules__ = ({
"../../../../private/var/folders/q3/yzggf_l965zbzps_bk8xr37c0000gn/T/broccoli-4171121iPrUeBlpJ8/cache-307-webpack_bundler_ember_auto_import_webpack/app.js":
((module, __unused_webpack_exports, __webpack_require__) => {
module.exports = (function() {
- var d = _eai_d;
+ var d = globalThis.__ember_12345_define;
- var r = _eai_r;
+ var r = globalThis.__ember_12345_require;
window.emberAutoImportDynamic = function(specifier) {
if (arguments.length === 1) {
return r("_eai_dyn_" + specifier);
} else {
return r("_eai_dynt_" + specifier)(Array.prototype.slice.call(arguments, 1));
}
};
window.emberAutoImportSync = function(specifier) {return r("_eai_sync_" + specifier)(Array.prototype.slice.call(arguments, 1));};
d("lodash-es/find", [], function() { return __webpack_require__(/*! lodash-es/find */ "./node_modules/lodash-es/find.js");});
d("lodash-es/startsWith", [], function() {return __webpack_require__(/*! lodash-es/startsWith */ "./node_modules/lodash-es/startsWith.js");});
d("_eai_dyn_lodash-es/concat", [], function() {return __webpack_require__.e(/*! import() */ "node_modules_lodash-es_concat_js").then(__webpack_require__.bind(__webpack_require__, /*! lodash-es/concat */ "./node_modules/lodash-es/concat.js"));});
})();
}),
- "../../../../private/var/folders/q3/yzggf_l965zbzps_bk8xr37c0000gn/T/broccoli-4171121iPrUeBlpJ8/cache-307-webpack_bundler_ember_auto_import_webpack/l.js":
- (function(module, exports) {
- window._eai_r = require;
- window._eai_d = define;
- })
});
// ... webpack internals
})();Note that ember-auto-import needs knowledge about the obfuscated names of the (still globally defined) functions (e.g.
__ember_12345_definein this example) here. This should be exposed to privileged addons like ember-auto-import or
Embroider somehow, but as this is a strictly private API, it is not detailed here.
EmberENV
Ember reads environment variables from a EmberENV hash to enable/disable certain runtime features. Usually this is set
from the app's config/environment.js and @ember/optional-features, and compiled into the top of vendor.js:
vendor.js
window.EmberENV = (function(EmberENV, extra) {
for (var key in extra) {
EmberENV[key] = extra[key];
}
return EmberENV;
})(window.EmberENV || {}, {"FEATURES":{},"EXTEND_PROTOTYPES":{"Date":false},"_APPLICATION_TEMPLATE_WRAPPER":false,"_DEFAULT_ASYNC_OBSERVERS":true,"_JQUERY_INTEGRATION":false,"_TEMPLATE_ONLY_GLIMMER_COMPONENTS":true});But as you can see there, you can also inject those by setting a globally defined window.EmberENV.
In no-globals mode though, the latter behavior will be disabled. Only the variables built into vendor.js are taken
into account. Implementation wise this can happen in the same way as described above, by defining EmberENV within the
IIFE (and not using the explicit window. namespace).
Deprecate direct usage of AMD-like module APIs
TO DO
When opting into no-globals, it seems reasonable to also deprecate direct usage of define and require (the latter is not uncommon)
in user-land code. We can still make the code emitted by the build system not cause any deprecations, but add deprecations
at least for require in user's code. I hope for any need of dynamic module usage, we can provide a transition path
using embroider/macros (using e.g. dependencySatisfies() and importSync()). This would make the AMD module system
become a private implementation detail, thus easing possible future changes of the way we package apps!?
Should this be part of this RFC? It could be a separate, on the other hand it is tied to the new no-globals mode proposed here.
How we teach this
What names and terminology work best for these concepts and why? How is this
idea best presented? As a continuation of existing Ember patterns, or as a
wholly new one?
Would the acceptance of this proposal mean the Ember guides must be
re-organized or altered? Does it change how Ember is taught to new users
at any level?
How should this feature be introduced and taught to existing Ember
users?
TO DO
- explain how to opt in
- not much to teach otherwise, this affects only build tooling
Drawbacks
Why should we not do this? Please consider the impact on teaching Ember,
on the integration of this feature with other existing and planned features,
on the impact of the API churn on existing apps, etc.
There are tradeoffs to choosing any path, please attempt to identify them here.
TO DO
Alternatives
What other designs have been considered? What is the impact of not doing this?
This section could also include prior art, that is, how other frameworks in the same domain have solved this problem.
TO DO
- loader.noConflict(). It is not straightforward, and does not seem reliable. See Store the noconflict aliases ember-cli/loader.js#204 (comment)
- iframe uh :-(
Unresolved questions
- Do we need all the APIs of current loader.js? Or can we ship our own stripped version of loader.js, e.g. without
noConflict(),.unsee(),.alias, what else? Remember that we are opting into a new mode, so we can drop things and define our new standard, that is basically a strictly private API now, right? - What about
Ember.__loader? Still needed? How is it even supposed to be used, with the Ember global gone? If you are supposed torequire('ember'), you already used the globalrequire, so what isEmber.__loader.requirefor? - relevant PR: Refactor the internal Ember loader to use the standard Ember CLI loader ember.js#19390. What does "Loader code is still included for Node support. If define and require are not already defined, then a backup shim is used instead." mean? Why would they not be defined for node?