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;
-}