diff --git a/packages/modular-scripts/src/__tests__/TestViewPackages.test-tsx b/packages/modular-scripts/src/__tests__/TestViewPackages.test-tsx new file mode 100644 index 000000000..5abfdd352 --- /dev/null +++ b/packages/modular-scripts/src/__tests__/TestViewPackages.test-tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import get from 'lodash/get'; +import merge from 'lodash.merge'; +import { difference } from 'lodash'; + +export default function SampleView(): JSX.Element { + return ( +
+
{JSON.stringify({ get, merge, difference })}
+
+ ); +} \ No newline at end of file diff --git a/packages/modular-scripts/src/__tests__/__snapshots__/index.test.ts.snap b/packages/modular-scripts/src/__tests__/__snapshots__/index.test.ts.snap deleted file mode 100644 index f38eb1cd3..000000000 --- a/packages/modular-scripts/src/__tests__/__snapshots__/index.test.ts.snap +++ /dev/null @@ -1,56 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`modular-scripts WHEN building a view THEN outputs the correct output cjs file 1`] = ` -"'use strict'; - -var React = require('react'); - -function _interopNamespace(e) { - if (e && e.__esModule) return e; - var n = Object.create(null); - if (e) { - Object.keys(e).forEach(function (k) { - if (k !== 'default') { - var d = Object.getOwnPropertyDescriptor(e, k); - Object.defineProperty(n, k, d.get ? d : { - enumerable: true, - get: function () { return e[k]; } - }); - } - }); - } - n[\\"default\\"] = e; - return n; -} - -var React__namespace = /*#__PURE__*/_interopNamespace(React); - -function SampleView() { - return /* @__PURE__ */ React__namespace.createElement(\\"div\\", { - \\"data-testid\\": \\"test-this\\" - }, \\"this is a modular view\\"); -} - -module.exports = SampleView; -//# sourceMappingURL=index.js.map -" -`; - -exports[`modular-scripts WHEN building a view THEN outputs the correct output cjs map file 1`] = ` -Object { - "file": "index.js", - "mappings": ";;;;;;;;;;;;;;;;;;;;;;;;AAEkD,SAAA,UAAA,GAAA;AAChD,EAAA,sDAAQ,KAAD,EAAA;AAAA,IAAK,aAAY,EAAA,WAAA;AAAA,GAAY,EAAA,wBAAA,CAAA,CAAA;AAAA;;;;", - "names": Array [], - "sources": Array [ - "../src/index.tsx", - ], - "sourcesContent": Array [ - "import * as React from 'react'; - -export default function SampleView(): JSX.Element { - return
this is a modular view
-}", - ], - "version": 3, -} -`; diff --git a/packages/modular-scripts/src/__tests__/build.test.ts b/packages/modular-scripts/src/__tests__/build.test.ts index 851e945ef..2811d3298 100644 --- a/packages/modular-scripts/src/__tests__/build.test.ts +++ b/packages/modular-scripts/src/__tests__/build.test.ts @@ -51,14 +51,14 @@ describe('WHEN building with preserve modules', () => { ├─ README.md #1jv3l2q ├─ dist-cjs │ ├─ index.js #y5z0kw - │ ├─ index.js.map #1ppp712 + │ ├─ index.js.map #16xad8o │ ├─ runAsync.js #kr3qrh - │ └─ runAsync.js.map #18daxam + │ └─ runAsync.js.map #130u3kt ├─ dist-es │ ├─ index.js #7arwpf - │ ├─ index.js.map #1in842g + │ ├─ index.js.map #n6rb69 │ ├─ runAsync.js #1tt0e7o - │ └─ runAsync.js.map #1qvfs9 + │ └─ runAsync.js.map #r9z8sx ├─ dist-types │ ├─ index.d.ts #12l2tmi │ └─ runAsync.d.ts #1iek7az @@ -149,10 +149,10 @@ describe('WHEN building packages with private cross-package dependencies', () => ├─ README.md #1jv3l2q ├─ dist-cjs │ ├─ index.js #1gj4b9h - │ └─ index.js.map #39c8bu + │ └─ index.js.map #1j96nz6 ├─ dist-es │ ├─ index.js #xezjee - │ └─ index.js.map #89b1k5 + │ └─ index.js.map #12d2mbd ├─ dist-types │ └─ index.d.ts #6hjmh9 └─ package.json" diff --git a/packages/modular-scripts/src/__tests__/index.test.ts b/packages/modular-scripts/src/__tests__/index.test.ts index a0db1f87a..18659a09b 100644 --- a/packages/modular-scripts/src/__tests__/index.test.ts +++ b/packages/modular-scripts/src/__tests__/index.test.ts @@ -15,6 +15,7 @@ import puppeteer from 'puppeteer'; import getModularRoot from '../utils/getModularRoot'; import { startApp, DevServer } from './start-app'; import { ModularPackageJson } from '../utils/isModularType'; +import type { CoreProperties } from '@schemastore/package'; const rimraf = promisify(_rimraf); @@ -53,7 +54,6 @@ const targetedView = 'sample-view'; describe('modular-scripts', () => { beforeAll(async () => { await cleanup(); - await modular( 'add sample-view --unstable-type view --unstable-name sample-view', { stdio: 'inherit' }, @@ -152,7 +152,9 @@ describe('modular-scripts', () => { browser = await puppeteer.launch(launchArgs); port = '4000'; - devServer = await startApp(targetedView, { env: { PORT: port } }); + devServer = await startApp(targetedView, { + env: { PORT: port, USE_MODULAR_ESBUILD: 'true' }, + }); }); afterAll(async () => { @@ -200,6 +202,9 @@ describe('modular-scripts', () => { beforeAll(async () => { await modular('build sample-view', { stdio: 'inherit', + env: { + USE_MODULAR_ESBUILD: 'true', + }, }); }); @@ -210,74 +215,222 @@ describe('modular-scripts', () => { ), ).toMatchInlineSnapshot(` Object { + "bundledDependencies": Array [], "dependencies": Object { "react": "17.0.2", }, - "files": Array [ - "README.md", - "dist-cjs", - "dist-es", - "dist-types", - ], "license": "UNLICENSED", - "main": "dist-cjs/index.js", "modular": Object { "type": "view", }, - "module": "dist-es/index.js", + "module": "static/js/index-IC6FL6E2.js", "name": "sample-view", - "typings": "dist-types/index.d.ts", "version": "1.0.0", } `); }); - it('THEN outputs the correct output cjs file', () => { - expect( - String( - fs.readFileSync( - path.join( - modularRoot, - 'dist', - 'sample-view', - 'dist-cjs', - 'index.js', - ), - ), - ), - ).toMatchSnapshot(); + it('THEN outputs the correct directory structure', () => { + expect(tree(path.join(modularRoot, 'dist', 'sample-view'))) + .toMatchInlineSnapshot(` + "sample-view + ├─ index.html #1o286v3 + ├─ package.json + └─ static + └─ js + ├─ _trampoline.js #1atamnv + ├─ index-IC6FL6E2.js #19sl0ps + └─ index-IC6FL6E2.js.map #1sysx0b" + `); }); + }); - it('THEN outputs the correct output cjs map file', () => { - expect( - fs.readJsonSync( - path.join( - modularRoot, - 'dist', - 'sample-view', - 'dist-cjs', - 'index.js.map', - ), - ), - ).toMatchSnapshot(); + describe('WHEN building a view with a custom ESM CDN', () => { + beforeAll(async () => { + await modular('build sample-view', { + stdio: 'inherit', + env: { + USE_MODULAR_ESBUILD: 'true', + EXTERNAL_CDN_TEMPLATE: + 'https://mycustomcdn.net/[name]?version=[version]', + }, + }); }); it('THEN outputs the correct directory structure', () => { expect(tree(path.join(modularRoot, 'dist', 'sample-view'))) .toMatchInlineSnapshot(` "sample-view - ├─ README.md #11adaka - ├─ dist-cjs - │ ├─ index.js #a7k6ic - │ └─ index.js.map #1pwjhqx - ├─ dist-es - │ ├─ index.js #1ymmv5l - │ └─ index.js.map #xpk3zp - ├─ dist-types - │ └─ index.d.ts #1vloh7q - └─ package.json" + ├─ index.html #1iozhyg + ├─ package.json + └─ static + └─ js + ├─ _trampoline.js #9paktu + ├─ index-LUQBNEET.js #7c5l8d + └─ index-LUQBNEET.js.map #1bqa5dr" + `); + }); + + it('THEN rewrites the dependencies according to the template string', async () => { + const baseDir = path.join( + modularRoot, + 'dist', + 'sample-view', + 'static', + 'js', + ); + const trampolineFile = ( + await fs.readFile(path.join(baseDir, '_trampoline.js')) + ).toString(); + + const indexFile = ( + await fs.readFile(path.join(baseDir, 'index-LUQBNEET.js')) + ).toString(); + + expect(trampolineFile).toContain( + `https://mycustomcdn.net/react?version=`, + ); + expect(trampolineFile).toContain( + `https://mycustomcdn.net/react-dom?version=`, + ); + expect(indexFile).toContain(`https://mycustomcdn.net/react?version=`); + }); + }); + + describe('WHEN building a view with various kinds of package dependencies', () => { + 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', + }, + }), + ); + + await execa('yarnpkg', [], { + cwd: modularRoot, + cleanup: true, + }); + + await modular('build sample-view', { + stdio: 'inherit', + env: { + USE_MODULAR_ESBUILD: 'true', + EXTERNAL_CDN_TEMPLATE: + 'https://mycustomcdn.net/[name]?version=[version]', + }, + }); + }); + + it('THEN outputs the correct directory structure', () => { + expect(tree(path.join(modularRoot, 'dist', 'sample-view'))) + .toMatchInlineSnapshot(` + "sample-view + ├─ index.html #1tkhgxi + ├─ package.json + └─ static + └─ js + ├─ _trampoline.js #1g4vig6 + ├─ index-F6YQ237K.js #oj2dgc + └─ index-F6YQ237K.js.map #1yijvx1" `); }); + + it('THEN rewrites the dependencies', async () => { + const baseDir = path.join( + modularRoot, + 'dist', + 'sample-view', + 'static', + 'js', + ); + + const indexFile = ( + await fs.readFile(path.join(baseDir, 'index-F6YQ237K.js')) + ).toString(); + expect(indexFile).toContain(`https://mycustomcdn.net/react?version=`); + expect(indexFile).toContain( + `https://mycustomcdn.net/lodash?version=^4.17.21`, + ); + expect(indexFile).toContain( + `https://mycustomcdn.net/lodash.merge?version=^4.6.2`, + ); + }); + }); + + describe('WHEN building a view specifying a dependency to not being rewritten', () => { + beforeAll(async () => { + await modular('build sample-view', { + stdio: 'inherit', + env: { + USE_MODULAR_ESBUILD: 'true', + EXTERNAL_CDN_TEMPLATE: + 'https://mycustomcdn.net/[name]?version=[version]', + EXTERNAL_BLOCK_LIST: 'lodash,lodash.merge', + }, + }); + }); + + it('THEN outputs the correct directory structure', () => { + expect(tree(path.join(modularRoot, 'dist', 'sample-view'))) + .toMatchInlineSnapshot(` + "sample-view + ├─ index.html #1vkdpvs + ├─ package.json + └─ static + └─ js + ├─ _trampoline.js #9qjmtx + ├─ index-P6RWJ53F.js #1emvouy + └─ index-P6RWJ53F.js.map #1y2yxmy" + `); + }); + + it('THEN rewrites only the dependencies that are not specified in the blocklist', async () => { + const baseDir = path.join( + modularRoot, + 'dist', + 'sample-view', + 'static', + 'js', + ); + + const indexFile = ( + await fs.readFile(path.join(baseDir, 'index-P6RWJ53F.js')) + ).toString(); + expect(indexFile).toContain(`https://mycustomcdn.net/react?version=`); + expect(indexFile).not.toContain( + `https://mycustomcdn.net/lodash?version=`, + ); + expect(indexFile).not.toContain( + `https://mycustomcdn.net/lodash.merge?version=`, + ); + }); + }); + + it('THEN expects the correct bundledDependencies in package.json', async () => { + expect( + ( + (await fs.readJson( + path.join(modularRoot, 'dist', 'sample-view', 'package.json'), + )) as CoreProperties + ).bundledDependencies, + ).toEqual(['lodash', 'lodash.merge']); }); it('can execute tests', async () => { diff --git a/packages/modular-scripts/src/__tests__/utils/stageView.test.ts b/packages/modular-scripts/src/__tests__/utils/stageView.test.ts deleted file mode 100644 index f35a56b67..000000000 --- a/packages/modular-scripts/src/__tests__/utils/stageView.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import rimraf from 'rimraf'; -import * as path from 'path'; -import * as fs from 'fs-extra'; -import stageView from '../../utils/stageView'; -import getModularRoot from '../../utils/getModularRoot'; -import tree from 'tree-view-for-tests'; - -describe('stageView', () => { - let testView = ''; - const modularRoot = getModularRoot(); - const tempDirPath = path.join(modularRoot, 'node_modules', '.modular'); - function cleanUpTempView(view: string) { - rimraf.sync(path.join(tempDirPath, view)); - } - afterEach(() => { - cleanUpTempView(testView); - testView = ''; - }); - afterAll(() => { - cleanUpTempView(''); - }); - it('should create a temp app using the app type template', () => { - testView = 'test-view'; - const testViewPath = path.join(tempDirPath, testView); - stageView(testView); - expect(tree(testViewPath)).toMatchInlineSnapshot(` - "test-view - ├─ package.json - ├─ public - │ ├─ index.html #rm3xgn - │ └─ manifest.json #kalmoq - ├─ src - │ ├─ index.tsx #1qbgs9s - │ └─ react-app-env.d.ts #t4ygcy - └─ tsconfig.json #1ww9d44" - `); - }); - it('should import the view as the main app in index.tsx', () => { - testView = 'test-view'; - const testViewPath = path.join(tempDirPath, testView); - stageView(testView); - const indexFile = fs - .readFileSync(path.join(testViewPath, 'src', 'index.tsx'), 'utf-8') - .toString(); - expect(indexFile).toContain(`import App from '${testView}'`); - }); -}); diff --git a/packages/modular-scripts/src/build/index.ts b/packages/modular-scripts/src/build/index.ts index 965de2076..84b931f71 100644 --- a/packages/modular-scripts/src/build/index.ts +++ b/packages/modular-scripts/src/build/index.ts @@ -2,11 +2,12 @@ import { paramCase as toParamCase } from 'change-case'; import chalk from 'chalk'; import * as fs from 'fs-extra'; import * as path from 'path'; - import * as logger from '../utils/logger'; import getModularRoot from '../utils/getModularRoot'; import actionPreflightCheck from '../utils/actionPreflightCheck'; -import isModularType from '../utils/isModularType'; +import { getModularType } from '../utils/isModularType'; +import { filterDependencies } from '../utils/filterDependencies'; +import type { ModularType } from '../utils/isModularType'; import execAsync from '../utils/execAsync'; import getLocation from '../utils/getLocation'; import { setupEnvForDirectory } from '../utils/setupEnv'; @@ -28,7 +29,10 @@ import { import { getPackageDependencies } from '../utils/getPackageDependencies'; import type { CoreProperties } from '@schemastore/package'; -async function buildApp(target: string) { +async function buildAppOrView( + target: string, + type: Extract, +) { // True if there's no preference set - or the preference is for webpack. const useWebpack = !process.env.USE_MODULAR_WEBPACK || @@ -49,6 +53,7 @@ async function buildApp(target: string) { const targetName = toParamCase(target); const paths = await createPaths(target); + const isApp = type === 'app'; await checkBrowsers(targetDirectory); @@ -64,25 +69,50 @@ async function buildApp(target: string) { } // Warn and crash if required files are missing - await checkRequiredFiles([paths.appHtml, paths.appIndexJs]); + isApp + ? await checkRequiredFiles([paths.appHtml, paths.appIndexJs]) + : await checkRequiredFiles([paths.appIndexJs]); logger.log('Creating an optimized production build...'); await fs.emptyDir(paths.appBuild); - await fs.copy(paths.appPublic, paths.appBuild, { - dereference: true, - filter: (file) => file !== paths.appHtml, - overwrite: true, - }); + if (isApp) { + await fs.copy(paths.appPublic, paths.appBuild, { + dereference: true, + filter: (file) => file !== paths.appHtml, + overwrite: true, + }); + } let assets: Asset[]; + // Retrieve dependencies for target to inform the build process + const packageDependencies = await getPackageDependencies(target); + // Split dependencies between external and bundled + const { external: externalDependencies, bundled: bundledDependencies } = + filterDependencies(packageDependencies, isApp); + + const browserTarget = createEsbuildBrowserslistTarget(targetDirectory); + + let moduleEntryPoint: string | undefined; + // Build views with esbuild + if (!isApp && !useEsbuild) { + throw new Error( + "Views can currently be built only with esbuild. Please set USE_MODULAR_ESBUILD='true' to build a view", + ); + } if (isEsbuild) { const { default: buildEsbuildApp } = await import( '../esbuild-scripts/build' ); - const result = await buildEsbuildApp(target, paths); + const result = await buildEsbuildApp( + target, + paths, + externalDependencies, + type, + ); + moduleEntryPoint = result.moduleEntryPoint; assets = createEsbuildAssets(paths, result); } else { // create-react-app doesn't support plain module outputs yet, @@ -92,8 +122,6 @@ async function buildApp(target: string) { 'modular-scripts/react-scripts/scripts/build.js', ); - const browserTarget = createEsbuildBrowserslistTarget(targetDirectory); - // TODO: this shouldn't be sync await execAsync('node', [buildScript], { cwd: targetDirectory, @@ -131,12 +159,12 @@ async function buildApp(target: string) { } // Add dependencies from source and bundled dependencies to target package.json - const packageDependencies = await getPackageDependencies(target); const targetPackageJson = (await fs.readJSON( path.join(targetDirectory, 'package.json'), )) as CoreProperties; targetPackageJson.dependencies = packageDependencies; - targetPackageJson.bundledDependencies = Object.keys(packageDependencies); + targetPackageJson.bundledDependencies = Object.keys(bundledDependencies); + // Copy selected fields of package.json over await fs.writeJSON( path.join(paths.appBuild, 'package.json'), @@ -146,6 +174,8 @@ async function buildApp(target: string) { license: targetPackageJson.license, modular: targetPackageJson.modular, dependencies: targetPackageJson.dependencies, + bundledDependencies: targetPackageJson.bundledDependencies, + module: moduleEntryPoint, }, { spaces: 2 }, ); @@ -169,8 +199,9 @@ async function build( await setupEnvForDirectory(targetDirectory); - if (isModularType(targetDirectory, 'app')) { - await buildApp(target); + const targetType = getModularType(targetDirectory); + if (targetType === 'app' || targetType === 'view') { + await buildAppOrView(target, targetType); } else { const { buildPackage } = await import('./buildPackage'); // ^ we do a dynamic import here to defer the module's initial side effects diff --git a/packages/modular-scripts/src/esbuild-scripts/api.ts b/packages/modular-scripts/src/esbuild-scripts/api.ts index d21dfbdd6..ef2d9e59c 100644 --- a/packages/modular-scripts/src/esbuild-scripts/api.ts +++ b/packages/modular-scripts/src/esbuild-scripts/api.ts @@ -9,7 +9,7 @@ import * as path from 'path'; type FileType = '.css' | '.js'; -function getEntryPoint( +export function getEntryPoint( paths: Paths, metafile: esbuild.Metafile, type: FileType, @@ -35,8 +35,10 @@ export async function createIndex( metafile: esbuild.Metafile, replacements: Record, includeRuntime: boolean, + indexContent?: string, ): Promise { - const index = await fs.readFile(paths.appHtml, { encoding: 'utf-8' }); + const index = + indexContent ?? (await fs.readFile(paths.appHtml, { encoding: 'utf-8' })); const page = parse5.parse(index); const html = page.childNodes.find( (node) => node.nodeName === 'html', diff --git a/packages/modular-scripts/src/esbuild-scripts/build/index.ts b/packages/modular-scripts/src/esbuild-scripts/build/index.ts index 4b796aceb..7872d0a9c 100644 --- a/packages/modular-scripts/src/esbuild-scripts/build/index.ts +++ b/packages/modular-scripts/src/esbuild-scripts/build/index.ts @@ -9,23 +9,49 @@ import type { Paths } from '../../utils/createPaths'; import * as logger from '../../utils/logger'; import { formatError } from '../utils/formatError'; -import { createIndex } from '../api'; +import { createIndex, getEntryPoint } from '../api'; import createEsbuildConfig from '../config/createEsbuildConfig'; import getModularRoot from '../../utils/getModularRoot'; import sanitizeMetafile from '../utils/sanitizeMetafile'; +import { createRewriteDependenciesPlugin } from '../plugins/rewriteDependenciesPlugin'; +import { indexFile, createViewTrampoline } from '../utils/createViewTrampoline'; +import type { Dependency } from '@schemastore/package'; +import createEsbuildBrowserslistTarget from '../../utils/createEsbuildBrowserslistTarget'; -export default async function build(target: string, paths: Paths) { +interface Metafile extends esbuild.Metafile { + moduleEntryPoint?: string; +} + +export default async function build( + target: string, + paths: Paths, + externalDependencies: Dependency, + type: 'app' | 'view', +) { const modularRoot = getModularRoot(); + const isApp = type === 'app'; const env = getClientEnvironment(paths.publicUrlOrPath.slice(0, -1)); - let result: esbuild.Metafile; + let result: Metafile; + + const browserTarget = createEsbuildBrowserslistTarget(paths.appPath); + try { const buildResult = await esbuild.build( createEsbuildConfig(paths, { entryNames: 'static/js/[name]-[hash]', chunkNames: 'static/js/[name]-[hash]', assetNames: 'static/media/[name]-[hash]', + target: browserTarget, + plugins: isApp + ? undefined + : [ + createRewriteDependenciesPlugin( + externalDependencies, + browserTarget, + ), + ], }), ); @@ -54,7 +80,13 @@ export default async function build(target: string, paths: Paths) { } } - const html = await createIndex(paths, result, env.raw, false); + const html = await createIndex( + paths, + result, + env.raw, + false, + isApp ? undefined : indexFile, + ); await fs.writeFile( path.join(paths.appBuild, 'index.html'), minimize.minify(html, { @@ -71,5 +103,25 @@ export default async function build(target: string, paths: Paths) { }), ); + if (!isApp) { + // Include js entry point in the result + result.moduleEntryPoint = getEntryPoint(paths, result, '.js'); + // Create and write trampoline file + if (!result.moduleEntryPoint) { + throw new Error("Can't find main entrypoint after building"); + } + const trampolineBuildResult = await createViewTrampoline( + path.basename(result.moduleEntryPoint), + paths.appSrc, + externalDependencies, + browserTarget, + ); + const trampolinePath = `${paths.appBuild}/static/js/_trampoline.js`; + await fs.writeFile( + trampolinePath, + trampolineBuildResult.outputFiles[0].contents, + ); + } + return result; } diff --git a/packages/modular-scripts/src/esbuild-scripts/config/createEsbuildConfig.ts b/packages/modular-scripts/src/esbuild-scripts/config/createEsbuildConfig.ts index 5556e4032..3bc129505 100644 --- a/packages/modular-scripts/src/esbuild-scripts/config/createEsbuildConfig.ts +++ b/packages/modular-scripts/src/esbuild-scripts/config/createEsbuildConfig.ts @@ -3,7 +3,6 @@ import * as path from 'path'; import * as esbuild from 'esbuild'; import type { Paths } from '../../utils/createPaths'; import getClientEnvironment from './getClientEnvironment'; -import createEsbuildBrowserslistTarget from '../../utils/createEsbuildBrowserslistTarget'; import * as logger from '../../utils/logger'; import moduleScopePlugin from '../plugins/moduleScopePlugin'; @@ -30,9 +29,9 @@ export default function createEsbuildConfig( }, ); - const target = createEsbuildBrowserslistTarget(paths.appPath); - - logger.debug(`Using target: ${target.join(', ')}`); + logger.debug( + `Using target: ${(partialConfig.target as string[]).join(', ')}`, + ); return { entryPoints: [paths.appIndexJs], @@ -60,7 +59,7 @@ export default function createEsbuildConfig( '.js': 'jsx', }, logLevel: 'silent', - target, + target: partialConfig.target, format: 'esm', color: !isCi, define, diff --git a/packages/modular-scripts/src/esbuild-scripts/plugins/rewriteDependenciesPlugin.ts b/packages/modular-scripts/src/esbuild-scripts/plugins/rewriteDependenciesPlugin.ts new file mode 100644 index 000000000..369d74c71 --- /dev/null +++ b/packages/modular-scripts/src/esbuild-scripts/plugins/rewriteDependenciesPlugin.ts @@ -0,0 +1,140 @@ +import * as esbuild from 'esbuild'; +import type { Dependency } from '@schemastore/package'; + +export function createRewriteDependenciesPlugin( + externalDependencies: Dependency, + target?: string[], +): esbuild.Plugin { + const externalCdnTemplate = + process.env.EXTERNAL_CDN_TEMPLATE ?? + 'https://cdn.skypack.dev/[name]@[version]'; + + const importMap: Record = Object.entries( + externalDependencies, + ).reduce( + (acc, [name, version]) => ({ + ...acc, + [name]: externalCdnTemplate + .replace('[name]', name) + .replace('[version]', version), + }), + {}, + ); + + const dependencyRewritePlugin: esbuild.Plugin = { + name: 'dependency-rewrite', + setup(build) { + // Don't want to load global css more than once + const globalCSSMap: Map = new Map(); + // Filter on external dependencies + build.onResolve( + { filter: /^[a-z0-9-~]|@/, namespace: 'file' }, + (args) => { + // Get name and eventual submodule to construct the url + const { dependencyName, submodule } = parsePackageName(args.path); + // Find dependency name (no submodule) in the pre-built import map + if (dependencyName in importMap) { + // Rewrite the path taking the submodule into account + const path = `${importMap[dependencyName]}${ + submodule ? `/${submodule}` : '' + }`; + if (submodule.endsWith('.css')) { + // This is a global CSS import from the CDN. + if (target && target.every((target) => target === 'esnext')) { + // If target is esnext we can use CSS module scripts - https://web.dev/css-module-scripts/ + // esbuild supports them only on an `esnext` target, otherwise the assertion is removed - https://github.com/evanw/esbuild/issues/1871 + // We must create a variable name to not clash with anything else though + const variableName = + `__sheet_${dependencyName}_${submodule}`.replace( + /[\W_]+/g, + '_', + ); + return { + path, + namespace: 'rewritable-css-import-css-module-scripts', + pluginData: { variableName }, + }; + } + // Fall back to link injection if we don't support CSS module scripts + // We want to ignore this import if it's been already imported before (no need to inject it twice into the HEAD) + const namespace = globalCSSMap.get(path) + ? 'rewritable-css-import-ignore' + : 'rewritable-css-import'; + // Set it in the "allready done" map + globalCSSMap.set(path, true); + return { + path, + namespace, + }; + } + // Just rewrite and mark as external. It will be ignored the next resolve cycle + return { + path, + external: true, + } as esbuild.OnResolveResult; + } else { + // Dependency has been filtered out: ignore and bundle + return {}; + } + }, + ); + build.onLoad( + { filter: /^[a-z0-9-~]|@/, namespace: 'rewritable-css-import' }, + (args) => { + return { + contents: ` + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.type = 'text/css'; + link.href = '${args.path}'; + document.getElementsByTagName('HEAD')[0].appendChild(link); + `, + }; + }, + ); + build.onLoad( + { filter: /^[a-z0-9-~]|@/, namespace: 'rewritable-css-import-ignore' }, + (args) => { + return { + contents: ` + /* Ignored CSS import at path ${args.path} */ + `, + }; + }, + ); + build.onLoad( + { + filter: /^[a-z0-9-~]|@/, + namespace: 'rewritable-css-import-css-module-scripts', + }, + (args) => { + const { variableName } = args.pluginData as { variableName: string }; + return { + contents: ` + import ${variableName} from '${args.path}' assert { type: 'css' }; + document.adoptedStyleSheets = [...document.adoptedStyleSheets, ${variableName}]; + `, + }; + }, + ); + build.onStart(() => { + // Clear the map on start, for incremental mode + globalCSSMap.clear(); + }); + }, + }; + return dependencyRewritePlugin; +} + +const packageRegex = + /^(@[a-z0-9-~][a-z0-9-._~]*)?\/?([a-z0-9-~][a-z0-9-._~]*)\/?(.*)/; +function parsePackageName(name: string) { + const parsedName = packageRegex.exec(name); + if (!parsedName) { + throw new Error(`Can't parse package name: ${name}`); + } + /* eslint-disable @typescript-eslint/no-unused-vars */ + const [_, scope, module, submodule] = parsedName; + const dependencyName = (scope ? `${scope}/` : '') + module; + return { dependencyName, scope, module, submodule }; +} diff --git a/packages/modular-scripts/src/esbuild-scripts/start/index.ts b/packages/modular-scripts/src/esbuild-scripts/start/index.ts index c1de9ef20..a37edb6fa 100644 --- a/packages/modular-scripts/src/esbuild-scripts/start/index.ts +++ b/packages/modular-scripts/src/esbuild-scripts/start/index.ts @@ -1,6 +1,7 @@ import * as esbuild from 'esbuild'; import chalk from 'chalk'; import * as express from 'express'; +import type { RequestHandler } from 'express'; import ws from 'express-ws'; import * as fs from 'fs-extra'; import * as http from 'http'; @@ -31,6 +32,10 @@ import getHost from './utils/getHost'; import getPort from './utils/getPort'; import sanitizeMetafile, { sanitizeFileName } from '../utils/sanitizeMetafile'; import getModularRoot from '../../utils/getModularRoot'; +import { createRewriteDependenciesPlugin } from '../plugins/rewriteDependenciesPlugin'; +import createEsbuildBrowserslistTarget from '../../utils/createEsbuildBrowserslistTarget'; +import { indexFile, createViewTrampoline } from '../utils/createViewTrampoline'; +import type { Dependency } from '@schemastore/package'; const RUNTIME_DIR = path.join(__dirname, 'runtime'); class DevServer { @@ -58,11 +63,23 @@ class DevServer { private urls: InstructionURLS; private port: number; - constructor(paths: Paths, urls: InstructionURLS, host: string, port: number) { + private isApp: boolean; // TODO maybe it's better to pass the type here + private dependencies: Dependency; + + constructor( + paths: Paths, + urls: InstructionURLS, + host: string, + port: number, + isApp: boolean, + dependencies: Dependency, + ) { this.paths = paths; this.urls = urls; this.host = host; this.port = port; + this.isApp = isApp; + this.dependencies = dependencies; this.firstCompilePromise = new Promise((resolve) => { this.firstCompilePromiseResolve = resolve; @@ -74,6 +91,11 @@ class DevServer { this.ws = ws(this.express); this.express.use(this.handleStaticAsset); + this.isApp || + this.express.get( + '/static/js/_trampoline.js', + this.handleTrampoline as RequestHandler, + ); this.express.use('/static/js', this.handleStaticAsset); this.express.use(this.handleRuntimeAsset); @@ -170,12 +192,17 @@ class DevServer { }); baseEsbuildConfig = memoize(() => { + const browserTarget = createEsbuildBrowserslistTarget(this.paths.appPath); return createEsbuildConfig(this.paths, { write: false, minify: false, entryNames: 'static/js/[name]', chunkNames: 'static/js/[name]', assetNames: 'static/media/[name]', + target: browserTarget, + plugins: this.isApp + ? undefined + : [createRewriteDependenciesPlugin(this.dependencies, browserTarget)], }); }); @@ -235,7 +262,35 @@ class DevServer { await this.firstCompilePromise; res.writeHead(200); - res.end(await createIndex(this.paths, this.metafile, this.env.raw, true)); + if (this.isApp) { + res.end(await createIndex(this.paths, this.metafile, this.env.raw, true)); + } else { + res.end( + await createIndex( + this.paths, + this.metafile, + this.env.raw, + true, + indexFile, + ), + ); + } + }; + + handleTrampoline = async ( + _: http.IncomingMessage, + res: http.ServerResponse, + ) => { + res.setHeader('content-type', 'application/javascript'); + res.writeHead(200); + const baseConfig = this.baseEsbuildConfig(); + const trampolineBuildResult = await createViewTrampoline( + 'index.js', + this.paths.appSrc, + this.dependencies, + baseConfig.target as string[], + ); + res.end(trampolineBuildResult.outputFiles[0].text); }; private serveEsbuild = ( @@ -296,7 +351,11 @@ class DevServer { }; } -export default async function start(target: string): Promise { +export default async function start( + target: string, + isApp: boolean, + packageDependencies: Dependency, +): Promise { const paths = await createPaths(target); const host = getHost(); const port = await getPort(host); @@ -306,7 +365,14 @@ export default async function start(target: string): Promise { port, paths.publicUrlOrPath.slice(0, -1), ); - const devServer = new DevServer(paths, urls, host, port); + const devServer = new DevServer( + paths, + urls, + host, + port, + isApp, + packageDependencies, + ); const server = await devServer.start(); diff --git a/packages/modular-scripts/src/esbuild-scripts/utils/createViewTrampoline.ts b/packages/modular-scripts/src/esbuild-scripts/utils/createViewTrampoline.ts new file mode 100644 index 000000000..a247ace27 --- /dev/null +++ b/packages/modular-scripts/src/esbuild-scripts/utils/createViewTrampoline.ts @@ -0,0 +1,66 @@ +import * as esbuild from 'esbuild'; +import { createRewriteDependenciesPlugin } from '../plugins/rewriteDependenciesPlugin'; +import type { Dependency } from '@schemastore/package'; + +export const indexFile = ` + + + +
+ + + +`; + +export async function createViewTrampoline( + fileName: string, + srcPath: string, + dependencies: Dependency, + browserTarget: string[], +): Promise { + const fileRelativePath = `./${fileName}`; + + const trampolineTemplate = ` +import ReactDOM from 'react-dom' +import React from 'react' +import Component from '${fileRelativePath}' +const DOMRoot = document.getElementById('root'); +ReactDOM.render(, DOMRoot);`; + + const fileRegexp = new RegExp(String.raw`^${escapeRegex(fileRelativePath)}$`); + + // Build the trampoline on the fly, from stdin + const buildResult = await esbuild.build({ + stdin: { + contents: trampolineTemplate, + resolveDir: srcPath, + sourcefile: '_trampoline.tsx', + loader: 'tsx', + }, + format: 'esm', + bundle: true, + write: false, + target: browserTarget, + plugins: [ + // See https://github.com/evanw/esbuild/issues/456 + { + name: 'import-path', + setup(build) { + build.onResolve({ filter: fileRegexp }, (args) => { + return { path: args.path, external: true }; + }); + }, + }, + createRewriteDependenciesPlugin({ + ...dependencies, + 'react-dom': dependencies.react, + }), + ], + }); + + return buildResult; +} + +function escapeRegex(s: string) { + return s.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); +} diff --git a/packages/modular-scripts/src/esbuild-scripts/utils/openBrowser.ts b/packages/modular-scripts/src/esbuild-scripts/utils/openBrowser.ts index 0018a8cf6..7a4dd3f2e 100644 --- a/packages/modular-scripts/src/esbuild-scripts/utils/openBrowser.ts +++ b/packages/modular-scripts/src/esbuild-scripts/utils/openBrowser.ts @@ -14,7 +14,7 @@ enum Actions { SCRIPT = 'Script', } -const DEFAULT_BROWSER = process.env.BROWSER || OSX_CHROME; +const DEFAULT_BROWSER = process.env.BROWSER; const DEFAULT_BROWSER_ARGS = process.env.BROWSER_ARGS ? process.env.BROWSER_ARGS.split(' ') : []; @@ -143,7 +143,8 @@ export default async function openBrowser(url: string): Promise { // Special case: BROWSER="none" will prevent opening completely. return false; case Actions.SCRIPT: - return executeNodeScript(value, url); + // Value will always be string if action is SCRIPT. + return executeNodeScript(value as string, url); case Actions.BROWSER: return startBrowserProcess(value, url, args); default: diff --git a/packages/modular-scripts/src/serve.ts b/packages/modular-scripts/src/serve.ts index eff748376..738d73b18 100644 --- a/packages/modular-scripts/src/serve.ts +++ b/packages/modular-scripts/src/serve.ts @@ -10,7 +10,10 @@ import isModularType from './utils/isModularType'; async function serve(target: string, port = 3000): Promise { const targetLocation = await getLocation(target); - if (isModularType(targetLocation, 'app')) { + if ( + isModularType(targetLocation, 'app') || + isModularType(targetLocation, 'view') + ) { const paths = await createPaths(target); if (fs.existsSync(paths.appBuild)) { @@ -25,7 +28,7 @@ async function serve(target: string, port = 3000): Promise { ); } } else { - throw new Error(`Modular can only serve an app.`); + throw new Error(`Modular can only serve an app or a view.`); } } diff --git a/packages/modular-scripts/src/start.ts b/packages/modular-scripts/src/start.ts index 0f0ccce33..953d2dec1 100644 --- a/packages/modular-scripts/src/start.ts +++ b/packages/modular-scripts/src/start.ts @@ -4,7 +4,6 @@ import actionPreflightCheck from './utils/actionPreflightCheck'; import isModularType from './utils/isModularType'; import execAsync from './utils/execAsync'; import getLocation from './utils/getLocation'; -import stageView from './utils/stageView'; import getModularRoot from './utils/getModularRoot'; import getWorkspaceInfo from './utils/getWorkspaceInfo'; import { setupEnvForDirectory } from './utils/setupEnv'; @@ -14,6 +13,8 @@ import createPaths from './utils/createPaths'; import * as logger from './utils/logger'; import createEsbuildBrowserslistTarget from './utils/createEsbuildBrowserslistTarget'; import prompts from 'prompts'; +import { getPackageDependencies } from './utils/getPackageDependencies'; +import { filterDependencies } from './utils/filterDependencies'; async function start(packageName: string): Promise { let target = packageName; @@ -43,23 +44,14 @@ async function start(packageName: string): Promise { ); } - /** - * If we're trying to start a view then we first need to stage out the - * view into an 'app' directory which can be built. - */ - let startPath: string; - if (isModularType(targetPath, 'view')) { - startPath = stageView(target); - } else { - startPath = targetPath; + const isView = isModularType(targetPath, 'view'); - // in the case we're an app then we need to make sure that users have no incorrectly - // setup their app folder. - const paths = await createPaths(target); - await checkRequiredFiles([paths.appHtml, paths.appIndexJs]); - } + const paths = await createPaths(target); + isView + ? await checkRequiredFiles([paths.appIndexJs]) + : await checkRequiredFiles([paths.appHtml, paths.appIndexJs]); - await checkBrowsers(startPath); + await checkBrowsers(targetPath); // True if there's no preference set - or the preference is for webpack. const useWebpack = @@ -71,13 +63,24 @@ async function start(packageName: string): Promise { process.env.USE_MODULAR_ESBUILD && process.env.USE_MODULAR_ESBUILD === 'true'; + if (isView && !useEsbuild) { + throw new Error( + "Views can currently be started only with esbuild. Please set USE_MODULAR_ESBUILD='true' to start a view", + ); + } + // If you want to use webpack then we'll always use webpack. But if you've indicated // you want esbuild - then we'll switch you to the new fancy world. if (!useWebpack || useEsbuild) { const { default: startEsbuildApp } = await import( './esbuild-scripts/start' ); - await startEsbuildApp(target); + const packageDependencies = await getPackageDependencies(target); + const { external: externalDependencies } = filterDependencies( + packageDependencies, + !isView, + ); + await startEsbuildApp(target, !isView, externalDependencies); } else { const startScript = require.resolve( 'modular-scripts/react-scripts/scripts/start.js', @@ -90,7 +93,7 @@ async function start(packageName: string): Promise { logger.debug(`Using target: ${browserTarget.join(', ')}`); await execAsync('node', [startScript], { - cwd: startPath, + cwd: targetPath, log: false, // @ts-ignore env: { diff --git a/packages/modular-scripts/src/utils/filterDependencies.ts b/packages/modular-scripts/src/utils/filterDependencies.ts new file mode 100644 index 000000000..9a022dc3d --- /dev/null +++ b/packages/modular-scripts/src/utils/filterDependencies.ts @@ -0,0 +1,37 @@ +import type { Dependency } from '@schemastore/package'; + +interface FilteredDependencies { + external: Dependency; + bundled: Dependency; +} + +// Filter out dependencies that are in blocklist +export function filterDependencies( + packageDependencies: Dependency, + isApp: boolean, +): FilteredDependencies { + if (isApp) { + return { + bundled: packageDependencies, + external: {}, + }; + } + const externalBlockList = + process.env.EXTERNAL_BLOCK_LIST && !isApp + ? process.env.EXTERNAL_BLOCK_LIST.split(',') + : []; + return Object.entries(packageDependencies).reduce( + (acc, [name, version]) => { + if (externalBlockList.includes(name)) { + acc.bundled[name] = version; + } else { + acc.external[name] = version; + } + return acc; + }, + { + external: {}, + bundled: {}, + }, + ); +} diff --git a/packages/modular-scripts/src/utils/stageView.ts b/packages/modular-scripts/src/utils/stageView.ts deleted file mode 100644 index e16fd1b64..000000000 --- a/packages/modular-scripts/src/utils/stageView.ts +++ /dev/null @@ -1,66 +0,0 @@ -import * as fs from 'fs-extra'; -import path from 'path'; -import { pascalCase as toPascalCase } from 'change-case'; -import getModularRoot from './getModularRoot'; -import getAllFiles from './getAllFiles'; - -export default function stageView(targetedView: string): string { - const modularRoot = getModularRoot(); - - const tempDir = path.join(modularRoot, 'node_modules', '.modular'); - if (!fs.existsSync(tempDir)) { - fs.mkdirSync(tempDir); - } - const stagedViewAppPath = path.join(tempDir, targetedView); - if (!fs.existsSync(`${tempDir}/${targetedView}`)) { - const appTypePath = path.join(__dirname, '../../types', 'app-view'); - fs.mkdirSync(`${tempDir}/${targetedView}`); - fs.copySync(appTypePath, stagedViewAppPath); - - const packageFilePaths = getAllFiles(stagedViewAppPath); - - for (const packageFilePath of packageFilePaths) { - fs.writeFileSync( - packageFilePath, - fs - .readFileSync(packageFilePath, 'utf8') - .replace(/PackageName__/g, toPascalCase(targetedView)) - .replace(/ComponentName__/g, toPascalCase(targetedView)), - ); - if (path.basename(packageFilePath) === 'packagejson') { - fs.moveSync( - packageFilePath, - packageFilePath.replace('packagejson', 'package.json'), - ); - } - } - } - - // This optimizes repeated modular start executions. If a tsconfig.json is present - // we assume that this view has been staged before and we do not need to write to the index.tsx - // file or write a tsconfig.json again - if (!fs.existsSync(path.join(stagedViewAppPath, 'tsconfig.json'))) { - const indexTemplate = `import * as React from 'react'; -import * as ReactDOM from 'react-dom'; - -import App from '${targetedView}'; - -ReactDOM.render( - , - document.getElementById('root'), -);`; - fs.writeFileSync( - path.join(stagedViewAppPath, 'src', 'index.tsx'), - indexTemplate, - ); - fs.writeJSONSync( - path.join(stagedViewAppPath, 'tsconfig.json'), - { - extends: - path.relative(stagedViewAppPath, modularRoot) + '/tsconfig.json', - }, - { spaces: 2 }, - ); - } - return stagedViewAppPath; -}