@jsenv/core
Version:
Tool to develop, test and build js projects
1,652 lines (1,586 loc) • 315 kB
JavaScript
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