UNPKG

@jsenv/core

Version:

Tool to develop, test and build js projects

1,652 lines (1,586 loc) • 315 kB
import { WebSocketResponse, pickContentType, ServerEvents, jsenvServiceCORS, jsenvAccessControlAllowedHeaders, composeTwoResponses, serveDirectory, jsenvServiceErrorHandler, startServer } from "@jsenv/server"; import { convertFileSystemErrorToResponseProperties } from "@jsenv/server/src/internal/convertFileSystemErrorToResponseProperties.js"; import { lookupPackageDirectory, registerDirectoryLifecycle, urlToRelativeUrl, moveUrl, urlIsOrIsInsideOf, ensureWindowsDriveLetter, createDetailedMessage, stringifyUrlSite, generateContentFrame, validateResponseIntegrity, setUrlFilename, getCallerPosition, urlToBasename, urlToExtension, asSpecifierWithoutSearch, asUrlWithoutSearch, injectQueryParamsIntoSpecifier, bufferToEtag, isFileSystemPath, urlToPathname, setUrlBasename, urlToFileSystemPath, writeFileSync, createLogger, URL_META, applyNodeEsmResolution, RUNTIME_COMPAT, normalizeUrl, ANSI, CONTENT_TYPE, errorToHTML, DATA_URL, normalizeImportMap, composeTwoImportMaps, resolveImport, JS_QUOTES, defaultLookupPackageScope, defaultReadPackageJson, readCustomConditionsFromProcessArgs, readEntryStatSync, ensurePathnameTrailingSlash, compareFileUrls, urlToFilename, applyFileSystemMagicResolution, getExtensionsToTry, setUrlExtension, isSpecifierForNodeBuiltin, memoizeByFirstArgument, assertAndNormalizeDirectoryUrl, createTaskLog, formatError, readPackageAtOrNull } from "./jsenv_core_packages.js"; import { readFileSync, existsSync, readdirSync, lstatSync, realpathSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { generateSourcemapFileUrl, createMagicSource, composeTwoSourcemaps, generateSourcemapDataUrl, SOURCEMAP } from "@jsenv/sourcemap"; import { parseHtml, injectHtmlNodeAsEarlyAsPossible, createHtmlNode, stringifyHtmlAst, applyBabelPlugins, generateUrlForInlineContent, injectJsenvScript, parseJsWithAcorn, parseCssUrls, getHtmlNodeAttribute, getHtmlNodePosition, getHtmlNodeAttributePosition, setHtmlNodeAttributes, parseSrcSet, getUrlForContentInsideHtml, removeHtmlNodeText, setHtmlNodeText, getHtmlNodeText, analyzeScriptNode, visitHtmlNodes, parseJsUrls, getUrlForContentInsideJs, analyzeLinkNode } from "@jsenv/ast"; import { performance } from "node:perf_hooks"; import { jsenvPluginSupervisor } from "@jsenv/plugin-supervisor"; import { jsenvPluginTranspilation } from "@jsenv/plugin-transpilation"; import { randomUUID } from "node:crypto"; import { createRequire } from "node:module"; import "./jsenv_core_node_modules.js"; import "node:process"; import "node:os"; import "node:tty"; import "node:util"; import "node:path"; // default runtimeCompat corresponds to // "we can keep <script type="module"> intact": // so script_type_module + dynamic_import + import_meta const defaultRuntimeCompat = { // android: "8", chrome: "64", edge: "79", firefox: "67", ios: "12", opera: "51", safari: "11.3", samsung: "9.2", }; const createEventEmitter = () => { const callbackSet = new Set(); const on = (callback) => { callbackSet.add(callback); return () => { callbackSet.delete(callback); }; }; const off = (callback) => { callbackSet.delete(callback); }; const emit = (...args) => { for (const callback of callbackSet) { callback(...args); } }; return { on, off, emit }; }; const getDirectoryWatchPatterns = ( directoryUrl, watchedDirectoryUrl, { sourceFilesConfig }, ) => { const directoryUrlRelativeToWatchedDirectory = urlToRelativeUrl( directoryUrl, watchedDirectoryUrl, ); const watchPatterns = { [`${directoryUrlRelativeToWatchedDirectory}**/*`]: true, // by default watch everything inside the source directory [`${directoryUrlRelativeToWatchedDirectory}**/.*`]: false, // file starting with a dot -> do not watch [`${directoryUrlRelativeToWatchedDirectory}**/.*/`]: false, // directory starting with a dot -> do not watch [`${directoryUrlRelativeToWatchedDirectory}**/node_modules/`]: false, // node_modules directory -> do not watch }; for (const key of Object.keys(sourceFilesConfig)) { watchPatterns[`${directoryUrlRelativeToWatchedDirectory}${key}`] = sourceFilesConfig[key]; } return watchPatterns; }; const watchSourceFiles = ( sourceDirectoryUrl, callback, { sourceFilesConfig = {}, keepProcessAlive, cooldownBetweenFileEvents }, ) => { // Project should use a dedicated directory (usually "src/") // passed to the dev server via "sourceDirectoryUrl" param // In that case all files inside the source directory should be watched // But some project might want to use their root directory as source directory // In that case source directory might contain files matching "node_modules/*" or ".git/*" // And jsenv should not consider these as source files and watch them (to not hurt performances) const watchPatterns = {}; let watchedDirectoryUrl = ""; const addDirectoryToWatch = (directoryUrl) => { Object.assign( watchPatterns, getDirectoryWatchPatterns(directoryUrl, watchedDirectoryUrl, { sourceFilesConfig, }), ); }; const watch = () => { const stopWatchingSourceFiles = registerDirectoryLifecycle( watchedDirectoryUrl, { watchPatterns, cooldownBetweenFileEvents, keepProcessAlive, recursive: true, added: ({ relativeUrl }) => { callback({ url: new URL(relativeUrl, watchedDirectoryUrl).href, event: "added", }); }, updated: ({ relativeUrl }) => { callback({ url: new URL(relativeUrl, watchedDirectoryUrl).href, event: "modified", }); }, removed: ({ relativeUrl }) => { callback({ url: new URL(relativeUrl, watchedDirectoryUrl).href, event: "removed", }); }, }, ); stopWatchingSourceFiles.watchPatterns = watchPatterns; return stopWatchingSourceFiles; }; npm_workspaces: { const packageDirectoryUrl = lookupPackageDirectory(sourceDirectoryUrl); let packageContent; try { packageContent = JSON.parse( readFileSync(new URL("package.json", packageDirectoryUrl), "utf8"), ); } catch { break npm_workspaces; } const { workspaces } = packageContent; if (!workspaces || !Array.isArray(workspaces) || workspaces.length === 0) { break npm_workspaces; } watchedDirectoryUrl = packageDirectoryUrl; for (const workspace of workspaces) { if (workspace.endsWith("*")) { const workspaceDirectoryUrl = new URL( workspace.slice(0, -1), packageDirectoryUrl, ); addDirectoryToWatch(workspaceDirectoryUrl); } else { const workspaceRelativeUrl = new URL(workspace, packageDirectoryUrl); addDirectoryToWatch(workspaceRelativeUrl); } } // we are updating the root directory // we must make the patterns relative to source directory relative to the new root directory addDirectoryToWatch(sourceDirectoryUrl); return watch(); } watchedDirectoryUrl = sourceDirectoryUrl; addDirectoryToWatch(sourceDirectoryUrl); return watch(); }; const WEB_URL_CONVERTER = { asWebUrl: (fileUrl, webServer) => { if (urlIsOrIsInsideOf(fileUrl, webServer.rootDirectoryUrl)) { return moveUrl({ url: fileUrl, from: webServer.rootDirectoryUrl, to: `${webServer.origin}/`, }); } const fsRootUrl = ensureWindowsDriveLetter("file:///", fileUrl); return `${webServer.origin}/@fs/${fileUrl.slice(fsRootUrl.length)}`; }, asFileUrl: (webUrl, webServer) => { const { pathname, search } = new URL(webUrl); if (pathname.startsWith("/@fs/")) { const fsRootRelativeUrl = pathname.slice("/@fs/".length); return `file:///${fsRootRelativeUrl}${search}`; } return moveUrl({ url: webUrl, from: `${webServer.origin}/`, to: webServer.rootDirectoryUrl, }); }, }; const jsenvCoreDirectoryUrl = new URL("../", import.meta.url); const createResolveUrlError = ({ pluginController, reference, error, }) => { const createFailedToResolveUrlError = ({ name = "RESOLVE_URL_ERROR", code = error.code || "RESOLVE_URL_ERROR", reason, ...details }) => { const resolveError = new Error( createDetailedMessage( `Failed to resolve url reference ${reference.trace.message} ${reason}`, { ...detailsFromFirstReference(reference), ...details, ...detailsFromPluginController(pluginController), }, ), ); defineNonEnumerableProperties(resolveError, { isJsenvCookingError: true, name, code, reason, asResponse: error.asResponse, trace: error.trace || reference.trace, }); return resolveError; }; if (error.message === "NO_RESOLVE") { return createFailedToResolveUrlError({ reason: `no plugin has handled the specifier during "resolveUrl" hook`, }); } if (error.code === "MODULE_NOT_FOUND") { const bareSpecifierError = createFailedToResolveUrlError({ reason: `"${reference.specifier}" is a bare specifier but cannot be remapped to a package`, }); return bareSpecifierError; } if (error.code === "DIRECTORY_REFERENCE_NOT_ALLOWED") { error.message = createDetailedMessage(error.message, { "reference trace": reference.trace.message, ...detailsFromFirstReference(reference), }); return error; } if (error.code === "PROTOCOL_NOT_SUPPORTED") { const notSupportedError = createFailedToResolveUrlError({ reason: error.message, }); return notSupportedError; } return createFailedToResolveUrlError({ reason: `An error occured during specifier resolution`, ...detailsFromValueThrown(error), }); }; const createFetchUrlContentError = ({ pluginController, urlInfo, error, }) => { const createFailedToFetchUrlContentError = ({ code = error.code || "FETCH_URL_CONTENT_ERROR", reason, parseErrorSourceType, ...details }) => { const reference = urlInfo.firstReference; const fetchError = new Error( createDetailedMessage( `Failed to fetch url content ${reference.trace.message} ${reason}`, { ...detailsFromFirstReference(reference), ...details, ...detailsFromPluginController(pluginController), }, ), ); defineNonEnumerableProperties(fetchError, { isJsenvCookingError: true, name: "FETCH_URL_CONTENT_ERROR", code, reason, parseErrorSourceType, url: urlInfo.url, trace: code === "PARSE_ERROR" ? error.trace : reference.trace, asResponse: error.asResponse, }); return fetchError; }; if (error.code === "EPERM") { return createFailedToFetchUrlContentError({ code: "NOT_ALLOWED", reason: `not allowed to read entry on filesystem`, }); } if (error.code === "DIRECTORY_REFERENCE_NOT_ALLOWED") { return createFailedToFetchUrlContentError({ code: "DIRECTORY_REFERENCE_NOT_ALLOWED", reason: `found a directory on filesystem`, }); } if (error.code === "ENOENT") { const urlTried = pathToFileURL(error.path).href; // ensure ENOENT is caused by trying to read the urlInfo.url // any ENOENT trying to read an other file should display the error.stack // because it means some side logic has failed if (urlInfo.url.startsWith(urlTried)) { return createFailedToFetchUrlContentError({ code: "NOT_FOUND", reason: "no entry on filesystem", }); } } if (error.code === "PARSE_ERROR") { return createFailedToFetchUrlContentError({ "code": "PARSE_ERROR", "reason": error.reasonCode, "parseErrorSourceType": error.parseErrorSourceType, ...(error.cause ? { "parse error message": error.cause.message } : {}), "parse error trace": error.trace?.message, }); } return createFailedToFetchUrlContentError({ reason: `An error occured during "fetchUrlContent"`, ...detailsFromValueThrown(error), }); }; const createTransformUrlContentError = ({ pluginController, urlInfo, error, }) => { if (error.code === "MODULE_NOT_FOUND") { return error; } if (error.code === "PROTOCOL_NOT_SUPPORTED") { return error; } if (error.code === "DIRECTORY_REFERENCE_NOT_ALLOWED") { return error; } if (error.code === "PARSE_ERROR") { if (error.isJsenvCookingError) { return error; } const trace = getErrorTrace(error, urlInfo.firstReference); const reference = urlInfo.firstReference; const transformError = new Error( createDetailedMessage( `parse error on "${urlInfo.type}" ${trace.message} ${error.message}`, { "first reference": reference.trace.url ? `${reference.trace.url}:${reference.trace.line}:${reference.trace.column}` : reference.trace.message, ...detailsFromFirstReference(reference), ...detailsFromPluginController(pluginController), }, ), ); defineNonEnumerableProperties(transformError, { isJsenvCookingError: true, name: "TRANSFORM_URL_CONTENT_ERROR", code: "PARSE_ERROR", reason: error.message, reasonCode: error.reasonCode, parseErrorSourceType: error.parseErrorSourceType, stack: transformError.stack, trace, asResponse: error.asResponse, }); return transformError; } const createFailedToTransformError = ({ code = error.code || "TRANSFORM_URL_CONTENT_ERROR", reason, ...details }) => { const reference = urlInfo.firstReference; let trace = reference.trace; const transformError = new Error( createDetailedMessage( `"transformUrlContent" error on "${urlInfo.type}" ${trace.message} ${reason}`, { ...detailsFromFirstReference(reference), ...details, ...detailsFromPluginController(pluginController), }, ), ); defineNonEnumerableProperties(transformError, { isJsenvCookingError: true, cause: error, name: "TRANSFORM_URL_CONTENT_ERROR", code, reason, stack: error.stack, url: urlInfo.url, trace, asResponse: error.asResponse, }); return transformError; }; return createFailedToTransformError({ reason: `"transformUrlContent" error on "${urlInfo.type}"`, ...detailsFromValueThrown(error), }); }; const createFinalizeUrlContentError = ({ pluginController, urlInfo, error, }) => { const reference = urlInfo.firstReference; const finalizeError = new Error( createDetailedMessage( `"finalizeUrlContent" error on "${urlInfo.type}" ${reference.trace.message}`, { ...detailsFromFirstReference(reference), ...detailsFromValueThrown(error), ...detailsFromPluginController(pluginController), }, ), ); defineNonEnumerableProperties(finalizeError, { isJsenvCookingError: true, ...(error && error instanceof Error ? { cause: error } : {}), name: "FINALIZE_URL_CONTENT_ERROR", reason: `"finalizeUrlContent" error on "${urlInfo.type}"`, asResponse: error.asResponse, }); return finalizeError; }; const getErrorTrace = (error, reference) => { const urlInfo = reference.urlInfo; let trace = reference.trace; let line = error.line; let column = error.column; if (urlInfo.isInline) { line = trace.line + line; line = line - 1; return { ...trace, line, column, codeFrame: generateContentFrame({ line, column, content: urlInfo.inlineUrlSite.content, }), message: stringifyUrlSite({ url: urlInfo.inlineUrlSite.url, line, column, content: urlInfo.inlineUrlSite.content, }), }; } return { url: urlInfo.url, line, column: error.column, codeFrame: generateContentFrame({ line, column: error.column, content: urlInfo.content, }), message: stringifyUrlSite({ url: urlInfo.url, line, column: error.column, content: urlInfo.content, }), }; }; const detailsFromFirstReference = (reference) => { const referenceInProject = getFirstReferenceInProject(reference); if ( referenceInProject === reference || referenceInProject.type === "http_request" ) { return {}; } return { "first reference in project": `${referenceInProject.trace.url}:${referenceInProject.trace.line}:${referenceInProject.trace.column}`, }; }; const getFirstReferenceInProject = (reference) => { const ownerUrlInfo = reference.ownerUrlInfo; if (ownerUrlInfo.isRoot) { return reference; } if ( !ownerUrlInfo.url.includes("/node_modules/") && ownerUrlInfo.packageDirectoryUrl === ownerUrlInfo.context.packageDirectory.url ) { return reference; } const { firstReference } = ownerUrlInfo; return getFirstReferenceInProject(firstReference); }; const detailsFromPluginController = (pluginController) => { const currentPlugin = pluginController.getCurrentPlugin(); if (!currentPlugin) { return null; } return { "plugin name": `"${currentPlugin.name}"` }; }; const detailsFromValueThrown = (valueThrownByPlugin) => { if (valueThrownByPlugin && valueThrownByPlugin instanceof Error) { if ( valueThrownByPlugin.code === "PARSE_ERROR" || valueThrownByPlugin.code === "MODULE_NOT_FOUND" || valueThrownByPlugin.name === "RESOLVE_URL_ERROR" || valueThrownByPlugin.name === "FETCH_URL_CONTENT_ERROR" || valueThrownByPlugin.name === "TRANSFORM_URL_CONTENT_ERROR" || valueThrownByPlugin.name === "FINALIZE_URL_CONTENT_ERROR" ) { return { "error message": valueThrownByPlugin.message, }; } return { "error stack": valueThrownByPlugin.stack, }; } if (valueThrownByPlugin === undefined) { return { error: "undefined", }; } return { error: JSON.stringify(valueThrownByPlugin), }; }; const defineNonEnumerableProperties = (object, properties) => { for (const key of Object.keys(properties)) { Object.defineProperty(object, key, { configurable: true, writable: true, value: properties[key], }); } }; const assertFetchedContentCompliance = ({ urlInfo, content }) => { if (urlInfo.status === 404) { return; } const { expectedContentType } = urlInfo.firstReference; if (expectedContentType && urlInfo.contentType !== expectedContentType) { throw new Error( `content-type must be "${expectedContentType}", got "${urlInfo.contentType} on ${urlInfo.url}`, ); } const { expectedType } = urlInfo.firstReference; if (expectedType && urlInfo.type !== expectedType) { if (urlInfo.type === "entry_build" && urlInfo.context.build) ; else { throw new Error( `type must be "${expectedType}", got "${urlInfo.type}" on ${urlInfo.url}`, ); } } const { integrity } = urlInfo.firstReference; if (integrity) { validateResponseIntegrity({ url: urlInfo.url, type: "basic", dataRepresentation: content, }); } }; const determineFileUrlForOutDirectory = (urlInfo) => { let { url, filenameHint } = urlInfo; const { rootDirectoryUrl, outDirectoryUrl } = urlInfo.context; if (!outDirectoryUrl) { return url; } if (!url.startsWith("file:")) { return url; } if (!urlIsOrIsInsideOf(url, rootDirectoryUrl)) { const fsRootUrl = ensureWindowsDriveLetter("file:///", url); url = `${rootDirectoryUrl}@fs/${url.slice(fsRootUrl.length)}`; } if (filenameHint) { url = setUrlFilename(url, filenameHint); } const outUrl = moveUrl({ url, from: rootDirectoryUrl, to: outDirectoryUrl, }); return outUrl; }; const determineSourcemapFileUrl = (urlInfo) => { // sourcemap is a special kind of reference: // It's a reference to a content generated dynamically the content itself. // when jsenv is done cooking the file // during build it's urlInfo.url to be inside the build // but otherwise it's generatedUrl to be inside .jsenv/ directory const generatedUrlObject = new URL(urlInfo.generatedUrl); generatedUrlObject.searchParams.delete("js_module_fallback"); generatedUrlObject.searchParams.delete("as_js_module"); generatedUrlObject.searchParams.delete("as_js_classic"); generatedUrlObject.searchParams.delete("as_css_module"); generatedUrlObject.searchParams.delete("as_json_module"); generatedUrlObject.searchParams.delete("as_text_module"); generatedUrlObject.searchParams.delete("dynamic_import"); generatedUrlObject.searchParams.delete("dynamic_import_id"); generatedUrlObject.searchParams.delete("cjs_as_js_module"); const urlForSourcemap = generatedUrlObject.href; return generateSourcemapFileUrl(urlForSourcemap); }; const prependContent = async ( urlInfoReceivingCode, urlInfoToPrepend, ) => { // we could also implement: // - prepend svg in html // - prepend css in html // - prepend css in css // - maybe more? // but no need for now if ( urlInfoReceivingCode.type === "html" && urlInfoToPrepend.type === "js_classic" ) { prependJsClassicInHtml(urlInfoReceivingCode, urlInfoToPrepend); return; } if ( urlInfoReceivingCode.type === "js_classic" && urlInfoToPrepend.type === "js_classic" ) { prependJsClassicInJsClassic(urlInfoReceivingCode, urlInfoToPrepend); return; } if ( urlInfoReceivingCode.type === "js_module" && urlInfoToPrepend.type === "js_classic" ) { await prependJsClassicInJsModule(urlInfoReceivingCode, urlInfoToPrepend); return; } throw new Error( `cannot prepend content from "${urlInfoToPrepend.type}" into "${urlInfoReceivingCode.type}"`, ); }; const prependJsClassicInHtml = (htmlUrlInfo, urlInfoToPrepend) => { const htmlAst = parseHtml({ html: htmlUrlInfo.content, url: htmlUrlInfo.url, }); injectHtmlNodeAsEarlyAsPossible( htmlAst, createHtmlNode({ tagName: "script", ...(urlInfoToPrepend.url ? { "inlined-from-src": urlInfoToPrepend.url } : {}), children: urlInfoToPrepend.content, }), "jsenv:core", ); const content = stringifyHtmlAst(htmlAst); htmlUrlInfo.mutateContent({ content }); }; const prependJsClassicInJsClassic = (jsUrlInfo, urlInfoToPrepend) => { const magicSource = createMagicSource(jsUrlInfo.content); magicSource.prepend(`${urlInfoToPrepend.content}\n\n`); const magicResult = magicSource.toContentAndSourcemap(); const sourcemap = composeTwoSourcemaps( jsUrlInfo.sourcemap, magicResult.sourcemap, ); jsUrlInfo.mutateContent({ content: magicResult.content, sourcemap, }); }; const prependJsClassicInJsModule = async (jsUrlInfo, urlInfoToPrepend) => { const { code, map } = await applyBabelPlugins({ babelPlugins: [ [ babelPluginPrependCodeInJsModule, { codeToPrepend: urlInfoToPrepend.content }, ], ], input: jsUrlInfo.content, inputIsJsModule: true, inputUrl: jsUrlInfo.originalUrl, }); jsUrlInfo.mutateContent({ content: code, sourcemap: map, }); }; const babelPluginPrependCodeInJsModule = (babel) => { return { name: "prepend-code-in-js-module", visitor: { Program: (programPath, state) => { const { codeToPrepend } = state.opts; const astToPrepend = babel.parse(codeToPrepend); const bodyNodePaths = programPath.get("body"); for (const bodyNodePath of bodyNodePaths) { if (bodyNodePath.node.type === "ImportDeclaration") { continue; } bodyNodePath.insertBefore(astToPrepend.program.body); return; } bodyNodePaths.unshift(astToPrepend.program.body); }, }, }; }; let referenceId = 0; const createDependencies = (ownerUrlInfo) => { const { referenceToOthersSet } = ownerUrlInfo; const startCollecting = async (callback) => { const prevReferenceToOthersSet = new Set(referenceToOthersSet); referenceToOthersSet.clear(); const stopCollecting = () => { for (const prevReferenceToOther of prevReferenceToOthersSet) { checkForDependencyRemovalEffects(prevReferenceToOther); } prevReferenceToOthersSet.clear(); }; try { await callback(); } finally { // finally to ensure reference are updated even in case of error stopCollecting(); } }; const createResolveAndFinalize = (props) => { const originalReference = createReference({ ownerUrlInfo, ...props, }); const reference = originalReference.resolve(); if (reference.urlInfo) { return reference; } const kitchen = ownerUrlInfo.kitchen; const urlInfo = kitchen.graph.reuseOrCreateUrlInfo(reference); reference.urlInfo = urlInfo; addDependency(reference); ownerUrlInfo.context.finalizeReference(reference); return reference; }; const found = ({ trace, ...rest }) => { if (trace === undefined) { trace = traceFromUrlSite( adjustUrlSite(ownerUrlInfo, { url: ownerUrlInfo.url, line: rest.specifierLine, column: rest.specifierColumn, }), ); } const reference = createResolveAndFinalize({ trace, ...rest, }); return reference; }; const foundInline = ({ isOriginalPosition, specifierLine, specifierColumn, content, ...rest }) => { const parentUrl = isOriginalPosition ? ownerUrlInfo.url : ownerUrlInfo.generatedUrl; const parentContent = isOriginalPosition ? ownerUrlInfo.originalContent : ownerUrlInfo.content; const trace = traceFromUrlSite({ url: parentUrl, content: parentContent, line: specifierLine, column: specifierColumn, }); const reference = createResolveAndFinalize({ trace, isOriginalPosition, specifierLine, specifierColumn, isInline: true, content, ...rest, }); return reference; }; // side effect file const foundSideEffectFile = async ({ sideEffectFileUrl, trace, ...rest }) => { if (trace === undefined) { const { url, line, column } = getCallerPosition(); trace = traceFromUrlSite({ url, line, column, }); } const sideEffectFileReference = ownerUrlInfo.dependencies.inject({ trace, type: "side_effect_file", specifier: sideEffectFileUrl, ...rest, }); const injectAsBannerCodeBeforeFinalize = (urlInfoReceiver) => { const basename = urlToBasename(sideEffectFileUrl); const inlineUrl = generateUrlForInlineContent({ url: urlInfoReceiver.originalUrl || urlInfoReceiver.url, basename, extension: urlToExtension(sideEffectFileUrl), }); const sideEffectFileReferenceInlined = sideEffectFileReference.inline({ ownerUrlInfo: urlInfoReceiver, trace, type: "side_effect_file", specifier: inlineUrl, }); urlInfoReceiver.addContentTransformationCallback(async () => { await sideEffectFileReferenceInlined.urlInfo.cook(); await prependContent( urlInfoReceiver, sideEffectFileReferenceInlined.urlInfo, ); }); }; // When possible we inject code inside the file in a common ancestor // -> less duplication // During dev: // during dev cooking files is incremental // so HTML/JS is already executed by the browser // we can't late inject into entry point // During build: // files are not executed so it's possible to inject reference // when discovering a side effect file const visitedMap = new Map(); let foundOrInjectedOnce = false; const visit = (urlInfo) => { urlInfo = urlInfo.findParentIfInline() || urlInfo; const value = visitedMap.get(urlInfo); if (value !== undefined) { return value; } // search if already referenced for (const referenceToOther of urlInfo.referenceToOthersSet) { if (referenceToOther === sideEffectFileReference) { continue; } if (referenceToOther.url === sideEffectFileUrl) { // consider this reference becomes the last reference // this ensure this ref is properly detected as inlined by urlInfo.isUsed() sideEffectFileReference.next = referenceToOther.next || referenceToOther; foundOrInjectedOnce = true; visitedMap.set(urlInfo, true); return true; } if ( referenceToOther.original && referenceToOther.original.url === sideEffectFileUrl ) { // consider this reference becomes the last reference // this ensure this ref is properly detected as inlined by urlInfo.isUsed() sideEffectFileReference.next = referenceToOther.next || referenceToOther; foundOrInjectedOnce = true; visitedMap.set(urlInfo, true); return true; } } // not referenced and we reach an entry point, stop there if (urlInfo.isEntryPoint) { foundOrInjectedOnce = true; visitedMap.set(urlInfo, true); injectAsBannerCodeBeforeFinalize(urlInfo); return true; } visitedMap.set(urlInfo, false); for (const referenceFromOther of urlInfo.referenceFromOthersSet) { const urlInfoReferencingThisOne = referenceFromOther.ownerUrlInfo; visit(urlInfoReferencingThisOne); // during dev the first urlInfo where we inject the side effect file is enough // during build we want to inject into every possible entry point if (foundOrInjectedOnce && urlInfo.context.dev) { break; } } return false; }; visit(ownerUrlInfo); if (ownerUrlInfo.context.dev && !foundOrInjectedOnce) { injectAsBannerCodeBeforeFinalize( ownerUrlInfo.findParentIfInline() || ownerUrlInfo, ); } }; const inject = ({ trace, ...rest }) => { if (trace === undefined) { const { url, line, column } = getCallerPosition(); trace = traceFromUrlSite({ url, line, column, }); } const reference = createResolveAndFinalize({ trace, injected: true, ...rest, }); return reference; }; return { startCollecting, createResolveAndFinalize, found, foundInline, foundSideEffectFile, inject, }; }; /* * - "http_request" * - "entry_point" * - "link_href" * - "style" * - "script" * - "a_href" * - "iframe_src * - "img_src" * - "img_srcset" * - "source_src" * - "source_srcset" * - "image_href" * - "use_href" * - "css_@import" * - "css_url" * - "js_import" * - "js_import_script" * - "js_url" * - "js_inline_content" * - "sourcemap_comment" * - "webmanifest_icon_src" * - "package_json" * - "side_effect_file" * */ const createReference = ({ ownerUrlInfo, data = {}, trace, type, subtype, expectedContentType, expectedType, expectedSubtype, filenameHint, integrity, crossorigin, specifier, specifierStart, specifierEnd, specifierLine, specifierColumn, baseUrl, isOriginalPosition, isEntryPoint = false, isDynamicEntryPoint = false, isResourceHint = false, // implicit references are not real references // they represent an abstract relationship isImplicit = false, // weak references cannot keep the corresponding url info alive // there must be an other reference to keep the url info alive // an url referenced solely by weak references is: // - not written in build directory // - can be removed from graph during dev/build // - not cooked until referenced by a strong reference isWeak = false, hasVersioningEffect = false, version = null, injected = false, isInline = false, content, contentType, fsStat = null, debug = false, original = null, prev = null, next = null, url = null, searchParams = null, generatedUrl = null, generatedSpecifier = null, urlInfo = null, escape = null, importAttributes, isSideEffectImport = false, astInfo = {}, mutation, }) => { if (typeof specifier !== "string") { if (specifier instanceof URL) { specifier = specifier.href; } else { throw new TypeError( `"specifier" must be a string, got ${specifier} in ${ownerUrlInfo.url}`, ); } } const reference = { id: ++referenceId, ownerUrlInfo, original, prev, next, data, trace, url, urlInfo, searchParams, generatedUrl, generatedSpecifier, type, subtype, expectedContentType, expectedType, expectedSubtype, filenameHint, integrity, crossorigin, specifier, get specifierPathname() { return asSpecifierWithoutSearch(reference.specifier); }, specifierStart, specifierEnd, specifierLine, specifierColumn, isOriginalPosition, baseUrl, isEntryPoint, isDynamicEntryPoint, isResourceHint, isImplicit, implicitReferenceSet: new Set(), isWeak, hasVersioningEffect, urlInfoEffectSet: new Set(), version, injected, timing: {}, fsStat, debug, // for inline resources the reference contains the content isInline, content, contentType, escape, // used mostly by worker and import assertions astInfo, importAttributes, isSideEffectImport, mutation, }; reference.resolve = () => { const resolvedReference = reference.ownerUrlInfo.context.resolveReference(reference); return resolvedReference; }; reference.redirect = (url, props = {}) => { const redirectedProps = getRedirectedReferenceProps(reference, url); const referenceRedirected = createReference({ ...redirectedProps, ...props, }); reference.next = referenceRedirected; return referenceRedirected; }; // "formatReference" can be async BUT this is an exception // for most cases it will be sync. We want to favor the sync signature to keep things simpler // The only case where it needs to be async is when // the specifier is a `data:*` url // in this case we'll wait for the promise returned by // "formatReference" reference.readGeneratedSpecifier = () => { if (reference.generatedSpecifier.then) { return reference.generatedSpecifier.then((value) => { reference.generatedSpecifier = value; return value; }); } return reference.generatedSpecifier; }; reference.inline = ({ line, column, // when urlInfo is given it means reference is moved into an other file ownerUrlInfo = reference.ownerUrlInfo, ...props }) => { const content = ownerUrlInfo === undefined ? isOriginalPosition ? reference.ownerUrlInfo.originalContent : reference.ownerUrlInfo.content : ownerUrlInfo.content; const trace = traceFromUrlSite({ url: ownerUrlInfo === undefined ? isOriginalPosition ? reference.ownerUrlInfo.url : reference.ownerUrlInfo.generatedUrl : reference.ownerUrlInfo.url, content, line, column, }); const inlineCopy = ownerUrlInfo.dependencies.createResolveAndFinalize({ isInline: true, original: reference.original || reference, prev: reference, trace, injected: reference.injected, expectedType: reference.expectedType, ...props, }); // the previous reference stays alive so that even after inlining // updating the file will invalidate the other file where it was inlined reference.next = inlineCopy; return inlineCopy; }; reference.addImplicit = (props) => { const implicitReference = ownerUrlInfo.dependencies.inject({ ...props, isImplicit: true, }); reference.implicitReferenceSet.add(implicitReference); return implicitReference; }; reference.gotInlined = () => { return !reference.isInline && reference.next && reference.next.isInline; }; reference.remove = () => removeDependency(reference); // Object.preventExtensions(reference) // useful to ensure all properties are declared here return reference; }; const addDependency = (reference) => { const { ownerUrlInfo } = reference; if (ownerUrlInfo.referenceToOthersSet.has(reference)) { return; } if (!canAddOrRemoveReference(reference)) { throw new Error( `cannot add reference for content already sent to the browser --- reference url --- ${reference.url} --- content url --- ${ownerUrlInfo.url}`, ); } ownerUrlInfo.referenceToOthersSet.add(reference); if (reference.isImplicit) { // an implicit reference is a reference that does not explicitely appear in the file // but has an impact on the file // -> package.json on import resolution for instance // in that case: // - file depends on the implicit file (it must autoreload if package.json is modified) // - cache validity for the file depends on the implicit file (it must be re-cooked if package.json is modified) ownerUrlInfo.implicitUrlSet.add(reference.url); if (ownerUrlInfo.isInline) { const parentUrlInfo = ownerUrlInfo.graph.getUrlInfo( ownerUrlInfo.inlineUrlSite.url, ); parentUrlInfo.implicitUrlSet.add(reference.url); } } const referencedUrlInfo = reference.urlInfo; referencedUrlInfo.referenceFromOthersSet.add(reference); applyReferenceEffectsOnUrlInfo(reference); for (const implicitRef of reference.implicitReferenceSet) { addDependency(implicitRef); } }; const removeDependency = (reference) => { const { ownerUrlInfo } = reference; if (!ownerUrlInfo.referenceToOthersSet.has(reference)) { return false; } if (!canAddOrRemoveReference(reference)) { throw new Error( `cannot remove reference for content already sent to the browser --- reference url --- ${reference.url} --- content url --- ${ownerUrlInfo.url}`, ); } for (const implicitRef of reference.implicitReferenceSet) { implicitRef.remove(); } ownerUrlInfo.referenceToOthersSet.delete(reference); return checkForDependencyRemovalEffects(reference); }; const canAddOrRemoveReference = (reference) => { if (reference.isWeak || reference.isImplicit) { // weak and implicit references have no restrictions // because they are not actual references with an influence on content return true; } const { ownerUrlInfo } = reference; if (ownerUrlInfo.context.build) { // during build url content is not executed // it's still possible to mutate references safely return true; } if (!ownerUrlInfo.contentFinalized) { return true; } if (ownerUrlInfo.isRoot) { // the root urlInfo is abstract, there is no real file behind it return true; } if (reference.type === "http_request") { // reference created to http requests are abstract concepts return true; } return false; }; const checkForDependencyRemovalEffects = (reference) => { const { ownerUrlInfo } = reference; const { referenceToOthersSet } = ownerUrlInfo; if (reference.isImplicit && !reference.isInline) { let hasAnOtherImplicitRef = false; for (const referenceToOther of referenceToOthersSet) { if ( referenceToOther.isImplicit && referenceToOther.url === reference.url ) { hasAnOtherImplicitRef = true; break; } } if (!hasAnOtherImplicitRef) { ownerUrlInfo.implicitUrlSet.delete(reference.url); } } const prevReference = reference.prev; const nextReference = reference.next; if (prevReference && nextReference) { nextReference.prev = prevReference; prevReference.next = nextReference; } else if (prevReference) { prevReference.next = null; } else if (nextReference) { nextReference.original = null; nextReference.prev = null; } const referencedUrlInfo = reference.urlInfo; referencedUrlInfo.referenceFromOthersSet.delete(reference); let firstReferenceFromOther; let wasInlined; for (const referenceFromOther of referencedUrlInfo.referenceFromOthersSet) { if (referenceFromOther.urlInfo !== referencedUrlInfo) { continue; } // Here we want to know if the file is referenced by an other file. // So we want to ignore reference that are created by other means: // - "http_request" // This type of reference is created when client request a file // that we don't know yet // 1. reference(s) to this file are not yet discovered // 2. there is no reference to this file if (referenceFromOther.type === "http_request") { continue; } wasInlined = referenceFromOther.gotInlined(); if (wasInlined) { // the url info was inlined, an other reference is required // to consider the non-inlined urlInfo as used continue; } firstReferenceFromOther = referenceFromOther; break; } if (firstReferenceFromOther) { // either applying new ref should override old ref // or we should first remove effects before adding new ones // for now we just set firstReference to null if (reference === referencedUrlInfo.firstReference) { referencedUrlInfo.firstReference = null; applyReferenceEffectsOnUrlInfo(firstReferenceFromOther); } return false; } if (wasInlined) { return false; } // referencedUrlInfo.firstReference = null; // referencedUrlInfo.lastReference = null; referencedUrlInfo.onDereferenced(reference); return true; }; const traceFromUrlSite = (urlSite) => { const codeFrame = urlSite.content ? generateContentFrame({ content: urlSite.content, line: urlSite.line, column: urlSite.column, }) : ""; return { codeFrame, message: stringifyUrlSite(urlSite), url: urlSite.url, line: urlSite.line, column: urlSite.column, }; }; const adjustUrlSite = (urlInfo, { url, line, column }) => { const isOriginal = url === urlInfo.url; const adjust = (urlInfo, urlSite) => { if (!urlSite.isOriginal) { return urlSite; } const inlineUrlSite = urlInfo.inlineUrlSite; if (!inlineUrlSite) { return urlSite; } const parentUrlInfo = urlInfo.graph.getUrlInfo(inlineUrlSite.url); line = inlineUrlSite.line === undefined ? urlSite.line : inlineUrlSite.line + urlSite.line; // we remove 1 to the line because imagine the following html: // <style>body { color: red; }</style> // -> content starts same line as <style> (same for <script>) if (urlInfo.content[0] === "\n") { line = line - 1; } column = inlineUrlSite.column === undefined ? urlSite.column : inlineUrlSite.column + urlSite.column; return adjust(parentUrlInfo, { isOriginal: true, url: inlineUrlSite.url, content: inlineUrlSite.content, line, column, }); }; return adjust(urlInfo, { isOriginal, url, content: isOriginal ? urlInfo.originalContent : urlInfo.content, line, column, }); }; const getRedirectedReferenceProps = (reference, url) => { const redirectedProps = { ...reference, specifier: url, url, original: reference.original || reference, prev: reference, }; return redirectedProps; }; const applyReferenceEffectsOnUrlInfo = (reference) => { const referencedUrlInfo = reference.urlInfo; referencedUrlInfo.lastReference = reference; if (reference.isInline) { referencedUrlInfo.isInline = true; referencedUrlInfo.inlineUrlSite = { url: reference.ownerUrlInfo.url, content: reference.isOriginalPosition ? reference.ownerUrlInfo.originalContent : reference.ownerUrlInfo.content, line: reference.specifierLine, column: reference.specifierColumn, }; } if ( referencedUrlInfo.firstReference && !referencedUrlInfo.firstReference.isWeak ) { return; } referencedUrlInfo.firstReference = reference; referencedUrlInfo.originalUrl = referencedUrlInfo.originalUrl || (reference.original || reference).url; if (reference.isEntryPoint) { referencedUrlInfo.isEntryPoint = true; } if (reference.isDynamicEntryPoint) { referencedUrlInfo.isDynamicEntryPoint = true; } Object.assign(referencedUrlInfo.data, reference.data); Object.assign(referencedUrlInfo.timing, reference.timing); if (reference.injected) { referencedUrlInfo.injected = true; } if (reference.filenameHint && !referencedUrlInfo.filenameHint) { referencedUrlInfo.filenameHint = reference.filenameHint; } if (reference.dirnameHint && !referencedUrlInfo.dirnameHint) { referencedUrlInfo.dirnameHint = reference.dirnameHint; } if (reference.debug) { referencedUrlInfo.debug = true; } if (reference.expectedType) { referencedUrlInfo.typeHint = reference.expectedType; } if (reference.expectedSubtype) { referencedUrlInfo.subtypeHint = reference.expectedSubtype; } referencedUrlInfo.entryUrlInfo = reference.isEntryPoint ? referencedUrlInfo : reference.ownerUrlInfo.entryUrlInfo; for (const urlInfoEffect of reference.urlInfoEffectSet) { urlInfoEffect(referencedUrlInfo); } }; const GRAPH_VISITOR = {}; GRAPH_VISITOR.map = (graph, callback) => { const array = []; graph.urlInfoMap.forEach((urlInfo) => { array.push(callback(urlInfo)); }); return array; }; GRAPH_VISITOR.forEach = (graph, callback) => { graph.urlInfoMap.forEach(callback); }; GRAPH_VISITOR.filter = (graph, callback) => { const urlInfos = []; graph.urlInfoMap.forEach((urlInfo) => { if (callback(urlInfo)) { urlInfos.push(urlInfo); } }); return urlInfos; }; GRAPH_VISITOR.find = (graph, callback) => { let found = null; for (const urlInfo of graph.urlInfoMap.values()) { if (callback(urlInfo)) { found = urlInfo; break; } } return found; }; GRAPH_VISITOR.findDependent = (urlInfo, visitor) => { const graph = urlInfo.graph; const seen = new Set(); seen.add(urlInfo.url); let found = null; const visit = (dependentUrlInfo) => { if (seen.has(dependentUrlInfo.url)) { return false; } seen.add(dependentUrlInfo.url); if (visitor(dependentUrlInfo)) { found = dependentUrlInfo; } return true; }; const iterate = (currentUrlInfo) => { // When cookin html inline content, html dependencies are not yet updated // consequently htmlUrlInfo.dependencies is empty // and inlineContentUrlInfo.referenceFromOthersSet is empty as well // in that case we resort to isInline + inlineUrlSite to establish the dependency if (currentUrlInfo.isInline) { const parentUrl = currentUrlInfo.inlineUrlSite.url; const parentUrlInfo = graph.getUrlInfo(parentUrl); visit(parentUrlInfo); if (found) { return; } } for (const referenceFromOther of currentUrlInfo.referenceFromOthersSet) { const urlInfoReferencingThisOne = referenceFromOther.ownerUrlInfo; if (visit(urlInfoReferencingThisOne)) { if (found) { break; } iterate(urlInfoReferencingThisOne); } } }; iterate(urlInfo); return found; }; GRAPH_VISITOR.findDependency = (urlInfo, visitor) => { const graph = urlInfo.graph; const seen = new Set(); seen.add(urlInfo.url); let found = null; const visit = (dependencyUrlInfo) => { if (seen.has(dependencyUrlInfo.url)) { return false; } seen.add(dependencyUrlInfo.url); if (visitor(dependencyUrlInfo)) { found = dependencyUrlInfo; } return true; }; const iterate = (currentUrlInfo) => { for (const referenceToOther of currentUrlInfo.referenceToOthersSet) { const referencedUrlInfo = graph.getUrlInfo(referenceToOther); if (visit(referencedUrlInfo)) { if (found) { break; } iterate(referencedUrlInfo); } } }; iterate(urlInfo); return found; }; // This function will be used in "build.js" // by passing rootUrlInfo as first arg // -> this ensure we visit only urls with strong references // because we start from root and ignore weak ref // The alternative would be to iterate on urlInfoMap // and call urlInfo.isUsed() but that would be more expensive GRAPH_VISITOR.forEachUrlInfoStronglyReferenced = ( initialUrlInfo, callback, { directoryUrlInfoSet } = {}, ) => { const seen = new Set(); seen.add(initialUrlInfo); const iterateOnReferences = (urlInfo) => { for (const referenceToOther of urlInfo.referenceToOthersSet) { if (referenceToOther.gotInlined()) { continue; } if (referenceToOther.url.startsWith("ignore:")) { continue; } const referencedUrlInfo = referenceToOther.urlInfo; if ( directoryUrlInfoSet && referenceToOther.expectedType === "directory" ) { directoryUrlInfoSet.add(referencedUrlInfo); } if (referenceToOther.isWeak) { continue; } if (seen.has(referencedUrlInfo)) { continue; } seen.add(referencedUrlInfo); callback(referencedUrlInfo); iterateOnReferences(referencedUrlInfo); } }; iterateOnReferences(initialUrlInfo); seen.clear(); }; const urlSpecifierEncoding = { encode: (reference) => { const { generatedSpecifier } = reference; if (generatedSpecifier.then) { return generatedSpecifier.then((value) => { reference.generatedSpecifier = value; return urlSpecifierEncoding.encode(reference); }); } // allow plugin to return a function to bypas default formatting // (which is to use JSON.stringify when url is referenced inside js) if (typeof ge