diff --git a/.changeset/silent-timers-yell.md b/.changeset/silent-timers-yell.md new file mode 100644 index 000000000..df6b7393e --- /dev/null +++ b/.changeset/silent-timers-yell.md @@ -0,0 +1,5 @@ +--- +"modular-scripts": patch +--- + +Support selective version resolutions in esm-views via `[selectiveCDNResolutions]` template token. diff --git a/docs/esm-views/esm-cdn.md b/docs/esm-views/esm-cdn.md index 9e94040ba..d3b9902af 100644 --- a/docs/esm-views/esm-cdn.md +++ b/docs/esm-views/esm-cdn.md @@ -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: @@ -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 throughout the + 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. diff --git a/docs/esm-views/known-limitations.md b/docs/esm-views/known-limitations.md index 169f232e7..a6845cecb 100644 --- a/docs/esm-views/known-limitations.md +++ b/docs/esm-views/known-limitations.md @@ -41,3 +41,85 @@ 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 uses hooks). Depending on the moment that your dependency was +first 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) + +While approach 1 is completely up to the server and needs no additional +configuration to work, it has the disadvantage of not being flexible: +essentially, a bunch of well-known stateful libraries are locked to a version +that's "good enough". This is often not enough in terms of security and +guarantees of immutability (since the version can be only updated unilaterally, +on the CDN). In the previous example, it wouldn't matter what version of `react` +we specify in our manifest - the CDN would always serve a fixed version at +runtime to our application and all its dependencies. + +Modular has a flexible approach to address rewriting, allowing users to specify +a custom CDN query template, in which query parameters can be specified +manually. This can be used to complement approach 2. 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 (i.e. any requested dependency that has a dependency on React and is built +on the CDN will be guaranteed to import `react@17.0.1` on the CDN at runtime, no +matter what its manifest file says). In the previous example, we don't care of +the `peerDependency` range of our dependencies, since we know that their `react` +will always point to `17.0.1`. + +Modular also provides a [`[selectiveCDNResolutions] token`](./esm-cdn.md) in its +template, which automatically translates +[Yarn selective version resolutions](https://classic.yarnpkg.com/lang/en/docs/selective-version-resolutions/) +to lists of locked dependencies. For example, if you had these resolutions in +your package.json: + +```json +{ + "resolutions": { + "react": "17.0.1", + "another-dependency": "2.3.7" + } +} +``` + +and your environment contained +`EXTERNAL_CDN_TEMPLATE="https://esm.sh/[name]@[resolution]?deps=[selectiveCDNResolutions]`, +all your imports would be rewritten in this form: +`import someDependency from "https://esm.sh/some-dependency@7.7.7?deps=react@17.0.1,another-dependency@2.3.7"` diff --git a/packages/modular-scripts/react-scripts/config/webpack.config.js b/packages/modular-scripts/react-scripts/config/webpack.config.js index e27fad30f..67c8acc26 100644 --- a/packages/modular-scripts/react-scripts/config/webpack.config.js +++ b/packages/modular-scripts/react-scripts/config/webpack.config.js @@ -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'; @@ -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 @@ -788,6 +797,7 @@ module.exports = function (webpackEnv) { function createExternalDependenciesMap( externalDependencies, externalResolutions, + selectiveCDNResolutions, ) { const externalCdnTemplate = process.env.EXTERNAL_CDN_TEMPLATE || @@ -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(',') + : '', + ), }; }, {}); } diff --git a/packages/modular-scripts/src/__tests__/__snapshots__/esmView.test.ts.snap b/packages/modular-scripts/src/__tests__/__snapshots__/esmView.test.ts.snap index f3531c141..1de4ba875 100644 --- a/packages/modular-scripts/src/__tests__/__snapshots__/esmView.test.ts.snap +++ b/packages/modular-scripts/src/__tests__/__snapshots__/esmView.test.ts.snap @@ -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\\"; diff --git a/packages/modular-scripts/src/__tests__/esmView.test.ts b/packages/modular-scripts/src/__tests__/esmView.test.ts index ee23b01a1..ffae4e5e2 100644 --- a/packages/modular-scripts/src/__tests__/esmView.test.ts +++ b/packages/modular-scripts/src/__tests__/esmView.test.ts @@ -400,6 +400,178 @@ describe('modular-scripts', () => { }); }); + describe('WHEN building a esm-view with a series of CDN selective dependency resolutions with the resolution field with webpack', () => { + beforeAll(async () => { + await fs.copyFile( + path.join(__dirname, 'TestViewPackages.test-tsx'), + path.join(packagesPath, targetedView, 'src', 'index.tsx'), + ); + + const packageJsonPath = path.join( + packagesPath, + targetedView, + 'package.json', + ); + const packageJson = (await fs.readJSON( + packageJsonPath, + )) as CoreProperties; + + await fs.writeJSON( + packageJsonPath, + Object.assign(packageJson, { + dependencies: { + lodash: '^4.17.21', + 'lodash.merge': '^4.6.2', + 'regular-table': '^0.5.6', + }, + resolutions: { + react: '17.0.2', + 'url-join': '5.0.0', + }, + }), + ); + + await execa('yarnpkg', [], { + cwd: modularRoot, + cleanup: true, + }); + + await modular('build sample-esm-view', { + stdio: 'inherit', + env: { + EXTERNAL_CDN_TEMPLATE: + 'https://mycustomcdn.net/[name]@[version]?selectiveDeps=[selectiveCDNResolutions]', + }, + }); + }); + + it('THEN outputs the correct directory structure', () => { + expect(tree(path.join(modularRoot, 'dist', 'sample-esm-view'))) + .toMatchInlineSnapshot(` + "sample-esm-view + ├─ asset-manifest.json #ih1jkw + ├─ index.html #17sfbiz + ├─ package.json + └─ static + └─ js + ├─ _trampoline.js #1kohre7 + ├─ main.62f8218b.js #1g7koy7 + └─ main.62f8218b.js.map #1whmojy" + `); + }); + + it('THEN rewrites the dependencies', async () => { + const baseDir = path.join( + modularRoot, + 'dist', + 'sample-esm-view', + 'static', + 'js', + ); + + const indexFile = ( + await fs.readFile(path.join(baseDir, 'main.62f8218b.js')) + ).toString(); + expect( + prettier.format(indexFile, { + filepath: 'index-F6YQ237K.js', + }), + ).toMatchSnapshot(); + expect(indexFile).toContain( + `https://mycustomcdn.net/lodash@^4.17.21?selectiveDeps=react@17.0.2,url-join@5.0.0`, + ); + expect(indexFile).toContain( + `https://mycustomcdn.net/lodash.merge@^4.6.2?selectiveDeps=react@17.0.2,url-join@5.0.0`, + ); + }); + }); + + describe('WHEN building a esm-view with a series of CDN selective dependency resolutions with the resolution field with esbuild', () => { + beforeAll(async () => { + await fs.copyFile( + path.join(__dirname, 'TestViewPackages.test-tsx'), + path.join(packagesPath, targetedView, 'src', 'index.tsx'), + ); + + const packageJsonPath = path.join( + packagesPath, + targetedView, + 'package.json', + ); + const packageJson = (await fs.readJSON( + packageJsonPath, + )) as CoreProperties; + + await fs.writeJSON( + packageJsonPath, + Object.assign(packageJson, { + dependencies: { + lodash: '^4.17.21', + 'lodash.merge': '^4.6.2', + 'regular-table': '^0.5.6', + }, + resolutions: { + react: '17.0.2', + 'url-join': '5.0.0', + }, + }), + ); + + await execa('yarnpkg', [], { + cwd: modularRoot, + cleanup: true, + }); + + await modular('build sample-esm-view', { + stdio: 'inherit', + env: { + USE_MODULAR_ESBUILD: 'true', + EXTERNAL_CDN_TEMPLATE: + 'https://mycustomcdn.net/[name]@[version]?selectiveDeps=[selectiveCDNResolutions]', + }, + }); + }); + + it('THEN outputs the correct directory structure', () => { + expect(tree(path.join(modularRoot, 'dist', 'sample-esm-view'))) + .toMatchInlineSnapshot(` + "sample-esm-view + ├─ index.html #17sfbiz + ├─ package.json + └─ static + └─ js + ├─ _trampoline.js #1wurily + ├─ index-ZHOP3VOF.js #7gwf58 + └─ index-ZHOP3VOF.js.map #10lquwp" + `); + }); + + it('THEN rewrites the dependencies', async () => { + const baseDir = path.join( + modularRoot, + 'dist', + 'sample-esm-view', + 'static', + 'js', + ); + + const indexFile = ( + await fs.readFile(path.join(baseDir, 'index-ZHOP3VOF.js')) + ).toString(); + expect( + prettier.format(indexFile, { + filepath: 'index-F6YQ237K.js', + }), + ).toMatchSnapshot(); + expect(indexFile).toContain( + `https://mycustomcdn.net/lodash@^4.17.21?selectiveDeps=react@17.0.2,url-join@5.0.0`, + ); + expect(indexFile).toContain( + `https://mycustomcdn.net/lodash.merge@^4.6.2?selectiveDeps=react@17.0.2,url-join@5.0.0`, + ); + }); + }); + describe('WHEN building a esm-view with resolutions', () => { beforeAll(async () => { await fs.copyFile( diff --git a/packages/modular-scripts/src/build/index.ts b/packages/modular-scripts/src/build/index.ts index 78d00beb7..20c371021 100644 --- a/packages/modular-scripts/src/build/index.ts +++ b/packages/modular-scripts/src/build/index.ts @@ -96,8 +96,11 @@ async function buildStandalone( let assets: Asset[]; logger.debug('Extracting dependencies from source code...'); // Retrieve dependencies for target to inform the build process - const { manifest: packageDependencies, resolutions: packageResolutions } = - await getPackageDependencies(target); + const { + manifest: packageDependencies, + resolutions: packageResolutions, + selectiveCDNResolutions, + } = await getPackageDependencies(target); // Get workspace info to automatically bundle workspace dependencies const workspaceInfo = await getWorkspaceInfo(); // Split dependencies between external and bundled @@ -145,6 +148,7 @@ async function buildStandalone( paths, externalDependencies, externalResolutions, + selectiveCDNResolutions, type, ); jsEntryPoint = getEntryPoint(paths, result, '.js'); @@ -177,6 +181,9 @@ async function buildStandalone( externalResolutions, bundledResolutions, }), + MODULAR_PACKAGE_SELECTIVE_CDN_RESOLUTIONS: JSON.stringify( + selectiveCDNResolutions, + ), }, }); @@ -242,6 +249,7 @@ async function buildStandalone( paths.appSrc, externalDependencies, externalResolutions, + selectiveCDNResolutions, browserTarget, ); const trampolinePath = `${paths.appBuild}/static/js/_trampoline.js`; diff --git a/packages/modular-scripts/src/esbuild-scripts/api.ts b/packages/modular-scripts/src/esbuild-scripts/api.ts index 8e102b756..1016a9c8b 100644 --- a/packages/modular-scripts/src/esbuild-scripts/api.ts +++ b/packages/modular-scripts/src/esbuild-scripts/api.ts @@ -16,6 +16,7 @@ export async function createViewTrampoline( srcPath: string, dependencies: Dependency, resolutions: Dependency, + selectiveCDNResolutions: Dependency, browserTarget: string[], ): Promise { const fileRelativePath = `./${fileName}`; @@ -60,6 +61,7 @@ ReactDOM.render(, DOMRoot);`; ...resolutions, 'react-dom': resolutions['react-dom'] ?? resolutions.react, }, + selectiveCDNResolutions, ), ], }); diff --git a/packages/modular-scripts/src/esbuild-scripts/build/index.ts b/packages/modular-scripts/src/esbuild-scripts/build/index.ts index af298a992..3d82e9d0f 100644 --- a/packages/modular-scripts/src/esbuild-scripts/build/index.ts +++ b/packages/modular-scripts/src/esbuild-scripts/build/index.ts @@ -22,6 +22,7 @@ export default async function build( paths: Paths, externalDependencies: Dependency, externalResolutions: Dependency, + selectiveCDNResolutions: Dependency, type: 'app' | 'esm-view', ) { const modularRoot = getModularRoot(); @@ -46,6 +47,7 @@ export default async function build( createRewriteDependenciesPlugin( externalDependencies, externalResolutions, + selectiveCDNResolutions, browserTarget, ), ], diff --git a/packages/modular-scripts/src/esbuild-scripts/plugins/rewriteDependenciesPlugin.ts b/packages/modular-scripts/src/esbuild-scripts/plugins/rewriteDependenciesPlugin.ts index f7a6ba6a5..38287588d 100644 --- a/packages/modular-scripts/src/esbuild-scripts/plugins/rewriteDependenciesPlugin.ts +++ b/packages/modular-scripts/src/esbuild-scripts/plugins/rewriteDependenciesPlugin.ts @@ -4,6 +4,7 @@ import type { Dependency } from '@schemastore/package'; export function createRewriteDependenciesPlugin( externalDependencies: Dependency, externalResolutions: Dependency, + selectiveCDNResolutions: Dependency, target?: string[], ): esbuild.Plugin { const externalCdnTemplate = @@ -22,7 +23,15 @@ export function createRewriteDependenciesPlugin( 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(',') + : '', + ), ]; }), ); diff --git a/packages/modular-scripts/src/esbuild-scripts/start/index.ts b/packages/modular-scripts/src/esbuild-scripts/start/index.ts index 846681f0e..5b9be5308 100644 --- a/packages/modular-scripts/src/esbuild-scripts/start/index.ts +++ b/packages/modular-scripts/src/esbuild-scripts/start/index.ts @@ -65,6 +65,7 @@ class DevServer { private isApp: boolean; // TODO maybe it's better to pass the type here private dependencies: Dependency; private resolutions: Dependency; + private selectiveCDNResolutions: Dependency; constructor( paths: Paths, @@ -74,6 +75,7 @@ class DevServer { isApp: boolean, dependencies: Dependency, resolutions: Dependency, + selectiveCDNResolutions: Dependency, ) { this.paths = paths; this.urls = urls; @@ -82,6 +84,7 @@ class DevServer { this.isApp = isApp; this.dependencies = dependencies; this.resolutions = resolutions; + this.selectiveCDNResolutions = selectiveCDNResolutions; this.firstCompilePromise = new Promise((resolve) => { this.firstCompilePromiseResolve = resolve; @@ -205,6 +208,7 @@ class DevServer { createRewriteDependenciesPlugin( this.dependencies, this.resolutions, + this.selectiveCDNResolutions, browserTarget, ), ], @@ -302,6 +306,7 @@ class DevServer { this.paths.appSrc, this.dependencies, this.resolutions, + this.selectiveCDNResolutions, baseConfig.target as string[], ); res.end(trampolineBuildResult.outputFiles[0].text); @@ -370,6 +375,7 @@ export default async function start( isApp: boolean, packageDependencies: Dependency, packageResolutions: Dependency, + selectiveCDNResolutions: Dependency, ): Promise { const paths = await createPaths(target); const host = getHost(); @@ -388,6 +394,7 @@ export default async function start( isApp, packageDependencies, packageResolutions, + selectiveCDNResolutions, ); const server = await devServer.start(); diff --git a/packages/modular-scripts/src/start.ts b/packages/modular-scripts/src/start.ts index 324de0cf4..062eaced8 100644 --- a/packages/modular-scripts/src/start.ts +++ b/packages/modular-scripts/src/start.ts @@ -71,8 +71,11 @@ async function start(packageName: string): Promise { process.env.USE_MODULAR_ESBUILD && process.env.USE_MODULAR_ESBUILD === 'true'; - const { manifest: packageDependencies, resolutions: packageResolutions } = - await getPackageDependencies(target); + const { + manifest: packageDependencies, + resolutions: packageResolutions, + selectiveCDNResolutions, + } = await getPackageDependencies(target); const { external: externalDependencies, bundled: bundledDependencies } = filterDependencies({ dependencies: packageDependencies, @@ -97,6 +100,7 @@ async function start(packageName: string): Promise { !isEsmView, externalDependencies, externalResolutions, + selectiveCDNResolutions, ); } else { const startScript = require.resolve( @@ -127,6 +131,9 @@ async function start(packageName: string): Promise { externalResolutions, bundledResolutions, }), + MODULAR_PACKAGE_SELECTIVE_CDN_RESOLUTIONS: JSON.stringify( + selectiveCDNResolutions, + ), }, }); } diff --git a/packages/modular-scripts/src/utils/getPackageDependencies.ts b/packages/modular-scripts/src/utils/getPackageDependencies.ts index e81eb9f52..a89bddd99 100644 --- a/packages/modular-scripts/src/utils/getPackageDependencies.ts +++ b/packages/modular-scripts/src/utils/getPackageDependencies.ts @@ -9,11 +9,9 @@ import getModularRoot from './getModularRoot'; import getLocation from './getLocation'; import getWorkspaceInfo, { WorkspaceInfo } from './getWorkspaceInfo'; import * as logger from './logger'; - -type DependencyManifest = NonNullable; interface DependencyResolution { - manifest: DependencyManifest; - resolutions: DependencyManifest; + manifest: Dependency; + resolutions: Dependency; } interface DependencyResolutionWithErrors extends DependencyResolution { manifestMiss: string[]; @@ -50,9 +48,11 @@ function getDependenciesFromSource(workspaceLocation: string) { return Array.from(dependencySet); } -export async function getPackageDependencies( - target: string, -): Promise<{ manifest: DependencyManifest; resolutions: DependencyManifest }> { +export async function getPackageDependencies(target: string): Promise<{ + manifest: Dependency; + resolutions: Dependency; + selectiveCDNResolutions: Dependency; +}> { // This function is based on the assumption that nested package are not supported, so dependencies can be either declared in the // target's package.json or hoisted up to the workspace root. const targetLocation = await getLocation(target); @@ -74,6 +74,12 @@ export async function getPackageDependencies( }, ); + // Selective CDN resolutions is a list of dependencies that we want our CDN to use to build our dependencies with. + // Some CDNs support this mechanism - https://github.com/esm-dev/esm.sh#specify-external-dependencies + // This is especially useful if we have stateful dependencies (like React) that we need to query the same version through all our CDN depenencies + // We just output them as a comma-separated parameter in the CDN template as [selectiveCDNResolutions] + const selectiveCDNResolutions = targetManifest?.resolutions ?? {}; + // Package dependencies can be either local to the package or in the root package (hoisted) const packageDeps = Object.assign( Object.create(null), @@ -108,6 +114,7 @@ export async function getPackageDependencies( return { manifest: resolvedPackageDependencies.manifest, resolutions: resolvedPackageDependencies.resolutions, + selectiveCDNResolutions, }; }