@web/dev-server-esbuild
Version:
Plugin for using esbuild in @web/dev-server
226 lines (195 loc) • 6.64 kB
text/typescript
import {
Context,
Plugin,
PluginSyntaxError,
Logger,
DevServerCoreConfig,
getRequestFilePath,
} from '@web/dev-server-core';
import type { TransformOptions, BuildFailure } from 'esbuild';
import { Loader, Message, transform } from 'esbuild';
import { promisify } from 'util';
import path from 'path';
import fs from 'fs';
import {
queryAll,
predicates,
getTextContent,
setTextContent,
} from '@web/dev-server-core/dist/dom5';
import { parse as parseHtml, serialize as serializeHtml } from 'parse5';
import { getEsbuildTarget } from './getEsbuildTarget.js';
const filteredWarnings = ['Unsupported source map comment'];
async function fileExists(path: string) {
try {
await promisify(fs.access)(path);
return true;
} catch {
return false;
}
}
export interface EsbuildConfig {
loaders: Record<string, Loader>;
target: string | string[];
handledExtensions: string[];
tsFileExtensions: string[];
jsxFactory?: string;
jsxFragment?: string;
define?: { [key: string]: string };
tsconfig?: string;
banner?: string;
footer?: string;
}
export class EsbuildPlugin implements Plugin {
private config?: DevServerCoreConfig;
private esbuildConfig: EsbuildConfig;
private logger?: Logger;
private transformedHtmlFiles: string[] = [];
private tsconfigRaw?: string;
name = 'esbuild';
constructor(esbuildConfig: EsbuildConfig) {
this.esbuildConfig = esbuildConfig;
}
async serverStart({ config, logger }: { config: DevServerCoreConfig; logger: Logger }) {
this.config = config;
this.logger = logger;
if (this.esbuildConfig.tsconfig) {
this.tsconfigRaw = await promisify(fs.readFile)(this.esbuildConfig.tsconfig, 'utf8');
}
}
resolveMimeType(context: Context) {
const fileExtension = path.posix.extname(context.path);
if (this.esbuildConfig.handledExtensions.includes(fileExtension)) {
return 'js';
}
}
async resolveImport({ source, context }: { source: string; context: Context }) {
const fileExtension = path.posix.extname(context.path);
if (!this.esbuildConfig.tsFileExtensions.includes(fileExtension)) {
// only handle typescript files
return;
}
if (!source.endsWith('.js') || !source.startsWith('.')) {
// only handle relative imports
return;
}
// a TS file imported a .js file relatively, but they might intend to import a .ts file instead
// check if the .ts file exists, and rewrite it in that case
const filePath = getRequestFilePath(context.url, this.config!.rootDir);
const fileDir = path.dirname(filePath);
const importAsTs = source.substring(0, source.length - 3) + '.ts';
const importedTsFilePath = path.join(fileDir, importAsTs);
if (!(await fileExists(importedTsFilePath))) {
return;
}
return importAsTs;
}
transformCacheKey(context: Context) {
// the transformed files are cached per esbuild transform target
const target = getEsbuildTarget(this.esbuildConfig.target, context.headers['user-agent']);
return Array.isArray(target) ? target.join('_') : target;
}
async transform(context: Context) {
let loader: Loader;
if (context.response.is('html')) {
// we are transforming inline scripts
loader = 'js';
} else {
const fileExtension = path.posix.extname(context.path);
loader = this.esbuildConfig.loaders[fileExtension];
}
if (!loader) {
// we are not handling this file
return;
}
const target = getEsbuildTarget(this.esbuildConfig.target, context.headers['user-agent']);
if (target === 'esnext' && loader === 'js') {
// no need run esbuild, this happens when compile target is set to auto and the user is on a modern browser
return;
}
const filePath = getRequestFilePath(context.url, this.config!.rootDir);
if (context.response.is('html')) {
this.transformedHtmlFiles.push(context.path);
return this.__transformHtml(context, filePath, loader, target);
}
return this.__transformCode(context.body as string, filePath, loader, target);
}
private async __transformHtml(
context: Context,
filePath: string,
loader: Loader,
target: string | string[],
) {
const documentAst = parseHtml(context.body as string);
const inlineScripts = queryAll(
documentAst,
predicates.AND(
predicates.hasTagName('script'),
predicates.NOT(predicates.hasAttr('src')),
predicates.OR(
predicates.NOT(predicates.hasAttr('type')),
predicates.hasAttrValue('type', 'module'),
),
),
);
if (inlineScripts.length === 0) {
return;
}
for (const node of inlineScripts) {
const code = getTextContent(node);
const transformedCode = await this.__transformCode(code, filePath, loader, target);
setTextContent(node, transformedCode);
}
return serializeHtml(documentAst);
}
private async __transformCode(
code: string,
filePath: string,
loader: Loader,
target: string | string[],
): Promise<string> {
try {
const transformOptions: TransformOptions = {
sourcefile: filePath,
sourcemap: 'inline',
loader,
target,
// don't set any format for JS-like formats, otherwise esbuild reformats the code unnecessarily
format: ['js', 'jsx', 'ts', 'tsx'].includes(loader) ? undefined : 'esm',
jsxFactory: this.esbuildConfig.jsxFactory,
jsxFragment: this.esbuildConfig.jsxFragment,
define: this.esbuildConfig.define,
tsconfigRaw: this.tsconfigRaw,
banner: this.esbuildConfig.banner,
footer: this.esbuildConfig.footer,
};
const { code: transformedCode, warnings } = await transform(code, transformOptions);
if (warnings) {
const relativePath = path.relative(process.cwd(), filePath);
for (const warning of warnings) {
if (!filteredWarnings.some(w => warning.text.includes(w))) {
this.logger!.warn(
`[/test-runner-esbuild] Warning while transforming ${relativePath}: ${warning.text}`,
);
}
}
}
return transformedCode;
} catch (e) {
if (Array.isArray((e as BuildFailure).errors)) {
const msg = (e as BuildFailure).errors[0] as Message;
if (msg.location) {
throw new PluginSyntaxError(
msg.text,
filePath,
code,
msg.location.line,
msg.location.column,
);
}
throw new Error(msg.text);
}
throw e;
}
}
}