Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Support selective version resolutions in esm-views #1872

Merged
merged 13 commits into from
Jun 28, 2022
Merged
5 changes: 5 additions & 0 deletions .changeset/silent-timers-yell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"modular-scripts": patch
---

Support selective version resolutions in esm-views via `[selectiveCDNResolutions]` template token.
18 changes: 18 additions & 0 deletions docs/esm-views/esm-cdn.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ For example:
`EXTERNAL_CDN_TEMPLATE="https://cdn.skypack.dev/[name]@[resolution]"`
- A valid template to work with the esm.sh public CDN can be specified with
`EXTERNAL_CDN_TEMPLATE="https://esm.sh/[name]@[version]"`
- A valid template to work with the esm.sh public CDN, telling the CDN to
build dependencies with selective version resolutions can be specified with
`EXTERNAL_CDN_TEMPLATE="https://esm.sh/[name]@[version]?deps=[selectiveCDNResolutions]"`

These are the substrings that are replaced in the template:

Expand All @@ -43,3 +46,18 @@ These are the substrings that are replaced in the template:
extracted from the package's or the root's (hoisted) `package.json`.
- `[resolution]` is replaced with the version of the imported dependency as
extracted from the yarn lockfile (`yarn.lock`).
- `[selectiveCDNResolutions]` is replaced with the
[selective version resolutions](https://classic.yarnpkg.com/lang/en/docs/selective-version-resolutions/)
specified in the manifest, as a comma-separated list of `package@version`
resolutions. Some CDNs implement a mechanism of building all the requested
dependencies on the fly, giving the user the option to specify how their
requested dependencies' subdependencies must be resolved. For example, esm.sh
has an option to specify a list of fixed dependencies, called
["external dependencies"](https://github.com/esm-dev/esm.sh#specify-external-dependencies).
This is similar to the concept of forcing selective resolutions throught the
cristiano-belloni marked this conversation as resolved.
Show resolved Hide resolved
dependency tree in Yarn; `[selectiveCDNResolutions]` is a mechanism to
automatically generate a comma-separated list of selective resolutions to pass
to the CDN from the `resolutions` field in the manifest. _Please note that
selective version resolutions are not filtered out in any way._ If your
`resolutions` have special characters like wildcards, and those are not
supported by your CDN, you can still specify your query options manually.
52 changes: 52 additions & 0 deletions docs/esm-views/known-limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,55 @@ allow list). But, since package `B` comes from the CDN, **its `C` dependency
will come from the CDN as well** (CDN packages are pre-built to use the CDN for
their own dependencies). The result, in this case, will be a bundle with two
copies of C, one fetched from the CDN and one bundled in the application.

## Peer dependencies are resolved at build time on the CDN

ESM CDNs essentially perform two tasks before serving a package that is
requested for the first time:

1. If the package is available as a CJS module, it is converted into an ES
Module.
2. All the external dependencies found in the module are rewritten to point to
the CDN itself. This is because
[import maps are not dynamic](https://github.com/WICG/import-maps/issues/92),
and there is no client-side way (yet) to route static imports.

This means that, if a package `A` has a `peerDependency` to package `B`, and `A`
is packaged on the CDN, it must resolve a version for package `B` and rewrite
the `import b from 'B'`(s) statements in A to an URL at _CDN build time_ (i.e.
when the package is requested for the first time). This means that the same
`peerDependency` in different CDN sub-dependencies of an ESM View can point to
different versions of the package, depending on the `peerDependency` ranges
specified in the sub-depenency manifest and the dependency versions available in
the registry in the moment when the package is built on the CDN. This is
particularly relevant in case the `peerDependency` in question is stateful:
suppose, for example, that one of your ESM Views depends on `react@17.0.1`, but
one of your dependencies on the CDN depend on `react@>16.8.0` (pretty common if
the dependency use hooks). Depending on the moment that yor dependency was first
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo: your

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
the dependency use hooks). Depending on the moment that yor dependency was first
the dependency use hooks). Depending on the moment that your dependency was first

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

cristiano-belloni marked this conversation as resolved.
Show resolved Hide resolved
requested from the CDN (and the version of your CDN), it can come with _any_
version of React hardcoded, resulting in two different versions of React loaded
onto the page, hooks failing and the ESM view crashing.

This problem can be carefully solved on the CDN. There are two commonly used
approaches:

1. The CDN is aware of stateful dependencies and serves only one version of
them, no matter which version was requested, essentially "lying" to the user.
This is the approach taken by
[Skypack](https://github.com/skypackjs/skypack-cdn/issues/88)
2. The CDN is not aware of stateful dependencies, but has a mechanism that
allows requesting any dependency with a list of locked sub-dependencies. This
essentially generates hashed dependencies (that can be reused) which are
guaranteed to always use a particular version of a sub-dependency. This is
the approach taken by
[esm.sh with the external dependencies query option](https://github.com/esm-dev/esm.sh#specify-external-dependencies)

Modular has a flexible approach to this problem, allowing users to specify a
custom CDN query template, in which query parameters can be specied manually
cristiano-belloni marked this conversation as resolved.
Show resolved Hide resolved
(for example,
`EXTERNAL_CDN_TEMPLATE="https://esm.sh/[name]@[resolution]?deps=react@17.0.1`
would lock React to the same version throught the whole dependency tree on the
CDN). [It also provides `[selectiveCDNResolutions]`](./esm-cdn.md), a template
string to automatically translate
[Yarn selective version resolutions](https://classic.yarnpkg.com/lang/en/docs/selective-version-resolutions/)
to lists of locked dependencies.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it possible to have an example for each solution for the same React 16.8/17 example you referred to above? But this time showing the Modular solutions

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

23 changes: 21 additions & 2 deletions packages/modular-scripts/react-scripts/config/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ const { externalResolutions } = process.env.MODULAR_PACKAGE_RESOLUTIONS
? JSON.parse(process.env.MODULAR_PACKAGE_RESOLUTIONS)
: {};

const selectiveCDNResolutions = process.env
.MODULAR_PACKAGE_SELECTIVE_CDN_RESOLUTIONS
? JSON.parse(process.env.MODULAR_PACKAGE_SELECTIVE_CDN_RESOLUTIONS)
: {};

// Source maps are resource heavy and can cause out of memory issue for large source files.
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false';

Expand Down Expand Up @@ -87,7 +92,11 @@ module.exports = function (webpackEnv) {

// Create a map of external dependencies if we're building a ESM view
const dependencyMap = isEsmView
? createExternalDependenciesMap(externalDependencies, externalResolutions)
? createExternalDependenciesMap(
externalDependencies,
externalResolutions,
selectiveCDNResolutions,
)
: {};

// Variable used for enabling profiling in Production
Expand Down Expand Up @@ -788,6 +797,7 @@ module.exports = function (webpackEnv) {
function createExternalDependenciesMap(
externalDependencies,
externalResolutions,
selectiveCDNResolutions,
) {
const externalCdnTemplate =
process.env.EXTERNAL_CDN_TEMPLATE ||
Expand All @@ -799,12 +809,21 @@ function createExternalDependenciesMap(
`Dependency ${name} found in package.json but not in lockfile. Have you installed your dependencies?`,
);
}

return {
...acc,
[name]: externalCdnTemplate
.replace('[name]', name)
.replace('[version]', version || externalResolutions[name])
.replace('[resolution]', externalResolutions[name]),
.replace('[resolution]', externalResolutions[name])
.replace(
'[selectiveCDNResolutions]',
selectiveCDNResolutions
? Object.entries(selectiveCDNResolutions)
.map(([key, value]) => `${key}@${value}`)
.join(',')
: '',
),
};
}, {});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12886,6 +12886,110 @@ export { e as default };
"
`;

exports[`modular-scripts WHEN building a esm-view with a series of CDN selective dependency resolutions with the resolution field with esbuild THEN rewrites the dependencies 1`] = `
"import * as t from \\"https://mycustomcdn.net/react@17.0.2?selectiveDeps=react@17.0.2,url-join@5.0.0\\";
import m from \\"https://mycustomcdn.net/lodash@^4.17.21?selectiveDeps=react@17.0.2,url-join@5.0.0/get\\";
import i from \\"https://mycustomcdn.net/lodash.merge@^4.6.2?selectiveDeps=react@17.0.2,url-join@5.0.0\\";
import { difference as n } from \\"https://mycustomcdn.net/lodash@^4.17.21?selectiveDeps=react@17.0.2,url-join@5.0.0\\";
var e = document.createElement(\\"link\\");
e.rel = \\"stylesheet\\";
e.type = \\"text/css\\";
e.href =
\\"https://mycustomcdn.net/regular-table@^0.5.6?selectiveDeps=react@17.0.2,url-join@5.0.0/dist/css/material.css\\";
document.getElementsByTagName(\\"HEAD\\")[0].appendChild(e);
function s() {
return t.createElement(
\\"div\\",
null,
t.createElement(
\\"pre\\",
null,
JSON.stringify({ get: m, merge: i, difference: n })
)
);
}
export { s as default };
//# sourceMappingURL=/static/js/index-ZHOP3VOF.js.map
"
`;

exports[`modular-scripts WHEN building a esm-view with a series of CDN selective dependency resolutions with the resolution field with webpack THEN rewrites the dependencies 1`] = `
"import * as e from \\"https://mycustomcdn.net/react@17.0.2?selectiveDeps=react@17.0.2,url-join@5.0.0\\";
import * as t from \\"https://mycustomcdn.net/lodash@^4.17.21?selectiveDeps=react@17.0.2,url-join@5.0.0/get\\";
import * as r from \\"https://mycustomcdn.net/lodash.merge@^4.6.2?selectiveDeps=react@17.0.2,url-join@5.0.0\\";
import * as n from \\"https://mycustomcdn.net/lodash@^4.17.21?selectiveDeps=react@17.0.2,url-join@5.0.0\\";
var o = {
545: () => {
const e = document.createElement(\\"link\\");
if (
((e.rel = \\"stylesheet\\"),
(e.type = \\"text/css\\"),
(e.href =
\\"https://mycustomcdn.net/regular-table@^0.5.6/dist/css/material.css\\"),
!document.head)
) {
const e = document.createElement(\\"head\\");
document.documentElement.insertBefore(e, document.body || null);
}
document.head.appendChild(e);
},
},
c = {};
function s(e) {
var t = c[e];
if (void 0 !== t) return t.exports;
var r = (c[e] = { exports: {} });
return o[e](r, r.exports, s), r.exports;
}
(s.d = (e, t) => {
for (var r in t)
s.o(t, r) &&
!s.o(e, r) &&
Object.defineProperty(e, r, { enumerable: !0, get: t[r] });
}),
(s.o = (e, t) => Object.prototype.hasOwnProperty.call(e, t));
var a = {};
(() => {
s.d(a, { Z: () => u });
const o = ((e) => {
var t = {};
return s.d(t, e), t;
})({ createElement: () => e.createElement });
const c = ((e) => {
var t = {};
return s.d(t, e), t;
})({ default: () => t.default });
const d = ((e) => {
var t = {};
return s.d(t, e), t;
})({ default: () => r.default });
const l = ((e) => {
var t = {};
return s.d(t, e), t;
})({ difference: () => n.difference });
s(545);
function u() {
return o.createElement(
\\"div\\",
null,
o.createElement(
\\"pre\\",
null,
JSON.stringify({
get: c.default,
merge: d.default,
difference: l.difference,
})
)
);
}
})();
var d = a.Z;
export { d as default };
//# sourceMappingURL=main.62f8218b.js.map
"
`;

exports[`modular-scripts WHEN building a esm-view with resolutions THEN rewrites the dependencies 1`] = `
"import * as t from \\"https://mycustomcdn.net/react@17.0.2\\";
import m from \\"https://mycustomcdn.net/lodash@4.17.21/get\\";
Expand Down
Loading