UNPKG

@web/dev-server-core

Version:
319 lines (282 loc) 9.52 kB
/* eslint-disable @typescript-eslint/ban-ts-comment */ import path from 'path'; import { Context } from 'koa'; // @ts-ignore import { parse, ParsedImport } from 'es-module-lexer'; import { queryAll, predicates, getTextContent, setTextContent } from '../dom5/index.js'; import { parse as parseHtml, serialize as serializeHtml } from 'parse5'; import { Plugin } from './Plugin.js'; import { PluginSyntaxError } from '../logger/PluginSyntaxError.js'; import { toFilePath } from '../utils.js'; import { Logger } from '../logger/Logger.js'; import { parseDynamicImport } from './parseDynamicImport.js'; export type ResolveImport = ( source: string, code: string, line: number, column: number, ) => string | undefined | Promise<string | undefined>; interface ParsedImport { s: number; e: number; ss: number; se: number; d: number; n?: string; } const CONCAT_NO_PACKAGE_ERROR = 'Dynamic import with a concatenated string should start with a valid full package name.'; /** * Resolves an import which is a concatenated string (for ex. import('my-package/files/${filename}')) * * Resolving is done by taking the package name and resolving that, then prefixing the resolves package * to the import. This requires the full package name to be present in the string. */ async function resolveConcatenatedImport( importSpecifier: string, resolveImport: ResolveImport, code: string, line: number, column: number, ): Promise<string> { let pathToResolve = importSpecifier; let pathToAppend = ''; if (['/', '../', './'].some(p => pathToResolve.startsWith(p))) { // don't handle non-bare imports return pathToResolve; } const parts = importSpecifier.split('/'); if (importSpecifier.startsWith('@')) { if (parts.length < 2) { throw new Error(CONCAT_NO_PACKAGE_ERROR); } pathToResolve = `${parts[0]}/${parts[1]}`; pathToAppend = parts.slice(2, parts.length).join('/'); } else { if (parts.length < 1) { throw new Error(CONCAT_NO_PACKAGE_ERROR); } [pathToResolve] = parts; pathToAppend = parts.slice(1, parts.length).join('/'); } // TODO: instead of package, we could resolve the bare import and take the first one or two segments // this will make it less hardcoded to node resolution const packagePath = `${pathToResolve}/package.json`; const resolvedPackage = await resolveImport(packagePath, code, line, column); if (!resolvedPackage) { throw new Error(`Could not resolve conatenated dynamic import, could not find ${packagePath}`); } const packageDir = resolvedPackage.substring(0, resolvedPackage.length - 'package.json'.length); return `${packageDir}${pathToAppend}`; } async function maybeResolveImport( importSpecifier: string, concatenatedString: boolean, resolveImport: ResolveImport, code: string, line: number, column: number, ) { let resolvedImportFilePath; if (concatenatedString) { // if this dynamic import is a concatenated string, try our best to resolve. Otherwise leave it untouched and resolve it at runtime. try { resolvedImportFilePath = (await resolveConcatenatedImport(importSpecifier, resolveImport, code, line, column)) ?? importSpecifier; } catch (error) { return importSpecifier; } } else { resolvedImportFilePath = (await resolveImport(importSpecifier, code, line, column)) ?? importSpecifier; } return resolvedImportFilePath; } export async function transformImports( code: string, filePath: string, resolveImport: ResolveImport, ) { let imports: ParsedImport[]; try { const parseResult = await parse(code, filePath); imports = parseResult[0] as any as ParsedImport[]; } catch (error) { if (typeof (error as Error & { idx: number }).idx === 'number') { const lexerError = error as Error & { idx: number }; throw new PluginSyntaxError( 'Syntax error', filePath, code, code.slice(0, lexerError.idx).split('\n').length, lexerError.idx - code.lastIndexOf('\n', lexerError.idx - 1), ); } throw error; } let resolvedSource = ''; let lastIndex = 0; for (const imp of imports) { const { s: start, e: end, d: dynamicImportIndex, n: unescaped } = imp; if (dynamicImportIndex === -1) { // static import const importSpecifier = unescaped || code.substring(start, end); const lines = code.slice(0, end).split('\n'); const line = lines.length; const column = lines[lines.length - 1].indexOf(importSpecifier); const resolvedImport = await maybeResolveImport( importSpecifier, false, resolveImport, code, line, column, ); resolvedSource += `${code.substring(lastIndex, start)}${resolvedImport}`; lastIndex = end; } else if (dynamicImportIndex >= 0) { // dynamic import const { importString, importSpecifier, stringLiteral, concatenatedString, dynamicStart, dynamicEnd, } = parseDynamicImport(code, start, end); const lines = code.slice(0, dynamicStart).split('\n'); const line = lines.length; const column = lines[lines.length - 1].indexOf('import(') || 0; let rewrittenImport; if (stringLiteral) { const resolvedImport = await maybeResolveImport( importSpecifier, concatenatedString, resolveImport, code, line, column, ); rewrittenImport = `${importString[0]}${resolvedImport}${ importString[importString.length - 1] }`; } else { rewrittenImport = importString; } resolvedSource += `${code.substring(lastIndex, dynamicStart)}${rewrittenImport}`; lastIndex = dynamicEnd; } } if (lastIndex < code.length - 1) { resolvedSource += `${code.substring(lastIndex, code.length)}`; } return resolvedSource; } async function transformModuleImportsWithPlugins( logger: Logger, context: Context, jsCode: string, rootDir: string, resolvePlugins: Plugin[], ) { const filePath = path.join(rootDir, toFilePath(context.path)); async function resolveImport(source: string, code: string, column: number, line: number) { for (const plugin of resolvePlugins) { const resolved = await plugin.resolveImport?.({ source, context, code, column, line }); if (typeof resolved === 'string') { logger.debug( `Plugin ${plugin.name} resolved import ${source} in ${context.path} to ${resolved}.`, ); return resolved; } if (typeof resolved === 'object') { logger.debug( `Plugin ${plugin.name} resolved import ${source} in ${context.path} to ${resolved.id}.`, ); return resolved.id; } } } async function transformImport(source: string, code: string, column: number, line: number) { let resolvedImport = (await resolveImport(source, code, column, line)) ?? source; for (const plugin of resolvePlugins) { const resolved = await plugin.transformImport?.({ source: resolvedImport, context, column, line, }); if (typeof resolved === 'string') { logger.debug( `Plugin ${plugin.name} transformed import ${resolvedImport} in ${context.path} to ${resolved}.`, ); resolvedImport = resolved; } if (typeof resolved === 'object' && typeof resolved.id === 'string') { logger.debug( `Plugin ${plugin.name} transformed import ${resolvedImport} in ${context.path} to ${resolved.id}.`, ); resolvedImport = resolved.id; } } return resolvedImport; } return transformImports(jsCode, filePath, transformImport); } export function transformModuleImportsPlugin( logger: Logger, plugins: Plugin[], rootDir: string, ): Plugin { const importPlugins = plugins.filter(pl => !!pl.resolveImport || !!pl.transformImport); return { name: 'resolve-module-imports', async transform(context) { if (importPlugins.length === 0) { return; } // resolve served js code if (context.response.is('js')) { const bodyWithResolvedImports = await transformModuleImportsWithPlugins( logger, context, context.body as string, rootDir, importPlugins, ); return { body: bodyWithResolvedImports }; } // resolve inline scripts if (context.response.is('html') && typeof context.body === 'string') { const documentAst = parseHtml(context.body); const inlineModuleNodes = queryAll( documentAst, predicates.AND( predicates.hasTagName('script'), predicates.hasAttrValue('type', 'module'), predicates.NOT(predicates.hasAttr('src')), ), ); let transformed = false; for (const node of inlineModuleNodes) { const code = getTextContent(node); const resolvedCode = await transformModuleImportsWithPlugins( logger, context, code, rootDir, importPlugins, ); if (code !== resolvedCode) { setTextContent(node, resolvedCode); transformed = true; } } if (transformed) { return { body: serializeHtml(documentAst) }; } } }, }; }