Skip to content

Commit

Permalink
ref: embroider-build#609 -- start esbuild prototype replacement for w…
Browse files Browse the repository at this point in the history
…ebpack
  • Loading branch information
NullVoxPopuli committed Mar 11, 2021
1 parent 0b0c8a2 commit d39c930
Show file tree
Hide file tree
Showing 8 changed files with 470 additions and 4 deletions.
7 changes: 7 additions & 0 deletions packages/esbuild/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/node_modules
/src/**/*.js
/src/**/*.d.ts
/src/**/*.map
/*/tests/**/*.js
/*/tests/**/*.d.ts
/*/tests/**/*.map
6 changes: 6 additions & 0 deletions packages/esbuild/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
testEnvironment: 'node',
testMatch: [
'<rootDir>/tests/**/*.test.js',
],
};
44 changes: 44 additions & 0 deletions packages/esbuild/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@embroider/esbuild",
"version": "0.37.0",
"private": false,
"description": "Builds EmberJS apps with ESBuild",
"repository": {
"type": "git",
"url": "https://github.com/embroider-build/embroider.git",
"directory": "packages/esbuild"
},
"license": "MIT",
"author": "NullVoxPopuli",
"main": "src/ember-webpack.js",
"files": [
"src/**/*.js",
"src/**/*.d.ts",
"src/**/*.js.map"
],
"scripts": {
"prepare": "tsc"
},
"dependencies": {
"debug": "^3.1.0",
"esbuild": "^0.9.0",
"fs-extra": "^7.0.0",
"jsdom": "^16.4.0",
"lodash": "^4.17.10",
},
"devDependencies": {
"@types/node": "^10.5.2",
"@types/fs-extra": "^5.0.4",
"@types/lodash": "^4.14.116",
"typescript": "~4.0.0"
},
"peerDependencies": {
"@embroider/core": "0.37.0"
},
"engines": {
"node": "10.* || 12.* || >= 14"
},
"volta": {
"extends": "../../package.json"
}
}
92 changes: 92 additions & 0 deletions packages/esbuild/src/ember-esbuild.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
Most of the work this module does is putting an HTML-oriented facade around
Webpack. That is, we want both the input and output to be primarily HTML files
with proper spec semantics, and we use webpack to optimize the assets referred
to by those files.
While there are webpack plugins for handling HTML, none of them handle
multiple HTML entrypoints and apply correct HTML semantics (for example,
getting script vs module context correct).
*/

import { getOrCreate, Variant, applyVariantToBabelConfig } from '@embroider/core';
import { PackagerInstance, AppMeta, Packager } from '@embroider/core';
import { readFileSync, outputFileSync, copySync, realpathSync, Stats, statSync, readJsonSync } from 'fs-extra';
import { join, dirname, relative, sep } from 'path';
import isEqual from 'lodash/isEqual';
import mergeWith from 'lodash/mergeWith';
import flatMap from 'lodash/flatMap';
import { format } from 'util';
import makeDebug from 'debug';
import { tmpdir } from 'os';
import { HTMLEntrypoint } from './html-entrypoint';
import { StatSummary } from './stat-summary';
import crypto from 'crypto';
import type { HbsLoaderConfig } from '@embroider/hbs-loader';

const debug = makeDebug('embroider:debug');

// This is a type-only import, so it gets compiled away. At runtime, we load
// terser lazily so it's only loaded for production builds that use it. Don't
// add any non-type-only imports here.
import type { MinifyOptions } from 'terser';

interface AppInfo {
entrypoints: HTMLEntrypoint[];
otherAssets: string[];
templateCompiler: AppMeta['template-compiler'];
babel: AppMeta['babel'];
rootURL: AppMeta['root-url'];
publicAssetURL: string;
resolvableExtensions: AppMeta['resolvable-extensions'];
}

// AppInfos are equal if they result in the same webpack config.
function equalAppInfo(left: AppInfo, right: AppInfo): boolean {
return (
isEqual(left.babel, right.babel) &&
left.entrypoints.length === right.entrypoints.length &&
left.entrypoints.every((e, index) => isEqual(e.modules, right.entrypoints[index].modules))
);
}

interface Options {
// the base public URL for your assets in production. Use this when you want
// to serve all your assets from a different origin (like a CDN) than your
// actual index.html will be served on.
//
// This should be a URL ending in "/".
publicAssetURL?: string;
}

// we want to ensure that not only does our instance conform to
// PackagerInstance, but our constructor conforms to Packager. So instead of
// just exporting our class directly, we export a const constructor of the
// correct type.
export const ESBuild: Packager<Options> = class ESBuild implements PackagerInstance {
static annotation = '@embroider/esbuild';

pathToVanillaApp: string;
private passthroughCache: Map<string, Stats> = new Map();
private publicAssetURL: string | undefined;

constructor(
pathToVanillaApp: string,
private outputPath: string,
private variants: Variant[],
private consoleWrite: (msg: string) => void,
options?: Options
) {
this.pathToVanillaApp = realpathSync(pathToVanillaApp);
this.publicAssetURL = options?.publicAssetURL;
}

async build(): Promise<void> {
// let appInfo = this.examineApp();
// let webpack = this.getWebpack(appInfo);
// let stats = this.summarizeStats(await this.runWebpack(webpack));
// await this.writeFiles(stats, appInfo);
}

};

154 changes: 154 additions & 0 deletions packages/esbuild/src/html-entrypoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { getOrCreate } from '@embroider/core';
import { readFileSync } from 'fs-extra';
import { join } from 'path';
import { JSDOM } from 'jsdom';
import partition from 'lodash/partition';
import zip from 'lodash/zip';
import Placeholder from './html-placeholder';
import { StatSummary } from './stat-summary';

export class HTMLEntrypoint {
private dom: JSDOM;
private placeholders: Map<string, Placeholder[]> = new Map();
modules: string[] = [];
scripts: string[] = [];
styles: string[] = [];

constructor(
private pathToVanillaApp: string,
private rootURL: string,
private publicAssetURL: string,
public filename: string
) {
this.dom = new JSDOM(readFileSync(join(this.pathToVanillaApp, this.filename), 'utf8'));

for (let tag of this.handledStyles()) {
let styleTag = tag as HTMLLinkElement;
let href = styleTag.href;
if (!isAbsoluteURL(href)) {
let url = this.relativeToApp(href);
this.styles.push(url);
let placeholder = new Placeholder(styleTag);
let list = getOrCreate(this.placeholders, url, () => []);
list.push(placeholder);
}
}

for (let scriptTag of this.handledScripts()) {
// scriptTag.src include rootURL. Convert it to be relative to the app.
let src = this.relativeToApp(scriptTag.src);

if (scriptTag.type === 'module') {
this.modules.push(src);
} else {
this.scripts.push(src);
}

let placeholder = new Placeholder(scriptTag);
let list = getOrCreate(this.placeholders, src, () => []);
list.push(placeholder);
}
}

private relativeToApp(rootRelativeURL: string) {
return rootRelativeURL.replace(this.rootURL, '');
}

private handledScripts() {
let scriptTags = [...this.dom.window.document.querySelectorAll('script')] as HTMLScriptElement[];
let [ignoredScriptTags, handledScriptTags] = partition(scriptTags, scriptTag => {
return !scriptTag.src || scriptTag.hasAttribute('data-embroider-ignore') || isAbsoluteURL(scriptTag.src);
});
for (let scriptTag of ignoredScriptTags) {
scriptTag.removeAttribute('data-embroider-ignore');
}
return handledScriptTags;
}

private handledStyles() {
let styleTags = [...this.dom.window.document.querySelectorAll('link[rel="stylesheet"]')] as HTMLLinkElement[];
let [ignoredStyleTags, handledStyleTags] = partition(styleTags, styleTag => {
return !styleTag.href || styleTag.hasAttribute('data-embroider-ignore') || isAbsoluteURL(styleTag.href);
});
for (let styleTag of ignoredStyleTags) {
styleTag.removeAttribute('data-embroider-ignore');
}
return handledStyleTags;
}

// bundles maps from input asset to a per-variant map of output assets
render(stats: StatSummary): string {
let insertedLazy = false;
let fastbootVariant = stats.variants.findIndex(v => Boolean(v.runtime === 'fastboot'));
let supportsFastboot = stats.variants.some(v => v.runtime === 'fastboot' || v.runtime === 'all');

for (let [src, placeholders] of this.placeholders) {
let match = stats.entrypoints.get(src);
if (match) {
let firstVariant = stats.variants.findIndex((_, index) => Boolean(match!.get(index)));
let matchingBundles = match.get(firstVariant)!;
let matchingFastbootBundles = fastbootVariant >= 0 ? match.get(fastbootVariant) || [] : [];

for (let placeholder of placeholders) {
if (supportsFastboot) {
// if there is any fastboot involved, we will emit the lazy bundles
// right before our first script.
insertedLazy = maybeInsertLazyBundles(insertedLazy, stats.lazyBundles, placeholder, this.publicAssetURL);
}
for (let [base, fastboot] of zip(matchingBundles, matchingFastbootBundles)) {
if (!base) {
// this bundle only exists in the fastboot variant
let element = placeholder.start.ownerDocument.createElement('fastboot-script');
element.setAttribute('src', this.publicAssetURL + fastboot);
placeholder.insert(element);
placeholder.insertNewline();
} else if (!fastboot || base === fastboot) {
// no specialized fastboot variant
let src = this.publicAssetURL + base;
placeholder.insertURL(src);
} else {
// we have both and they differ
let src = this.publicAssetURL + base;
let element = placeholder.insertURL(src);
if (element) {
element.setAttribute('data-fastboot-src', this.publicAssetURL + fastboot);
}
}
}
}
} else {
// no match means keep the original HTML content for this placeholder.
// (If we really wanted it empty instead, there would be matchingBundles
// and it would be an empty list.)
for (let placeholder of placeholders) {
placeholder.reset();
}
}
}
return this.dom.serialize();
}
}

function isAbsoluteURL(url: string) {
return /^(?:[a-z]+:)?\/\//i.test(url);
}

// we (somewhat arbitrarily) decide to put the lazy bundles before the very
// first <script> that we have rewritten
function maybeInsertLazyBundles(
insertedLazy: boolean,
lazyBundles: Set<string>,
placeholder: Placeholder,
publicAssetURL: string
): boolean {
if (!insertedLazy && placeholder.isScript()) {
for (let bundle of lazyBundles) {
let element = placeholder.start.ownerDocument.createElement('fastboot-script');
element.setAttribute('src', publicAssetURL + bundle);
placeholder.insert(element);
placeholder.insertNewline();
}
return true;
}
return insertedLazy;
}
91 changes: 91 additions & 0 deletions packages/esbuild/src/html-placeholder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
export default class Placeholder {
end: InDOMNode;
start: StartNode;

// remove the target Element from the DOM, and track where it was so we can
// update that location later.
constructor(private target: HTMLElement) {
if (!target.ownerDocument || !target.parentElement) {
throw new Error('can only construct a placeholder for an element that is in DOM');
}
let start = target.ownerDocument.createTextNode('');
target.parentElement.insertBefore(start, target);
let endNode = target.ownerDocument.createTextNode('');
target.replaceWith(endNode);

// Type cast is justified because start always has a nextSibling (it's
// "end") and because we know we already inserted the node.
this.start = start as StartNode;

// Type cast is justified because we know we already inserted the node.
this.end = endNode as InDOMNode;
}

reset() {
this.clear();
this.insert(this.target);
}

clear() {
while (this.start.nextSibling !== this.end) {
this.start.parentElement.removeChild(this.start.nextSibling);
}
}

insert(node: Node) {
this.end.parentElement.insertBefore(node, this.end);
}

isScript(): boolean {
return this.target.tagName === 'SCRIPT';
}

insertURL(url: string) {
if (url.endsWith('.js')) {
return this.insertScriptTag(url);
}
if (url.endsWith('.css')) {
return this.insertStyleLink(url);
}
throw new Error(`don't know how to insertURL ${url}`);
}

insertScriptTag(src: string) {
let newTag = this.end.ownerDocument.createElement('script');
for (let { name, value } of [...this.target.attributes]) {
if (name === 'type' && value === 'module') {
// we always convert modules to scripts
continue;
}
// all other attributes are copied forward unchanged
newTag.setAttribute(name, value);
}
newTag.src = src;
this.insert(newTag);
this.insertNewline();
return newTag;
}

insertStyleLink(href: string) {
let newTag = this.end.ownerDocument.createElement('link');
newTag.href = href;
newTag.rel = 'stylesheet';
this.insert(newTag);
this.insertNewline();
}

insertNewline() {
this.end.parentElement.insertBefore(this.end.ownerDocument.createTextNode('\n'), this.end);
}
}

// an html node that's definitely inserted into the DOM
interface InDOMNode extends Node {
parentElement: HTMLElement;
ownerDocument: Document;
}

// an html node that definitely has a next sibling.
interface StartNode extends InDOMNode {
nextSibling: InDOMNode & ChildNode;
}
Loading

0 comments on commit d39c930

Please sign in to comment.