@jsenv/core
Version:
Tool to develop, test and build js projects
480 lines (459 loc) • 15.8 kB
JavaScript
import {
asUrlWithoutSearch,
injectQueryParamsIntoSpecifier,
urlToRelativeUrl,
} from "@jsenv/urls";
import { createEventEmitter } from "../../helpers/event_emitter.js";
import { createDependencies } from "./references.js";
import { GRAPH_VISITOR } from "./url_graph_visitor.js";
import { urlSpecifierEncoding } from "./url_specifier_encoding.js";
export const createUrlGraph = ({
rootDirectoryUrl,
kitchen,
name = "anonymous",
}) => {
const urlGraph = {};
const urlInfoCreatedEventEmitter = createEventEmitter();
const urlInfoDereferencedEventEmitter = createEventEmitter();
const urlInfoMap = new Map();
const hasUrlInfo = (key) => {
if (typeof key === "string") {
return urlInfoMap.has(key);
}
if (typeof key === "object" && key && key.url) {
return urlInfoMap.has(key.url);
}
return null;
};
const getUrlInfo = (key) => {
if (typeof key === "string") {
return urlInfoMap.get(key);
}
if (typeof key === "object" && key && key.url) {
return urlInfoMap.get(key.url);
}
return null;
};
const addUrlInfo = (urlInfo) => {
urlInfo.graph = urlGraph;
urlInfo.kitchen = kitchen;
urlInfoMap.set(urlInfo.url, urlInfo);
};
const reuseOrCreateUrlInfo = (reference, useGeneratedUrl) => {
const referencedUrl = useGeneratedUrl
? reference.generatedUrl
: reference.url;
let referencedUrlInfo = getUrlInfo(referencedUrl);
if (!referencedUrlInfo) {
const ownerUrlInfo = reference.ownerUrlInfo;
const ownerContext = ownerUrlInfo.context;
const context = Object.create(ownerContext);
referencedUrlInfo = createUrlInfo(referencedUrl, context);
addUrlInfo(referencedUrlInfo);
urlInfoCreatedEventEmitter.emit(referencedUrlInfo);
}
if (
referencedUrlInfo.searchParams.size > 0 &&
kitchen.context.buildStep !== "shape"
) {
// A resource is represented by a url.
// Variations of a resource are represented by url search params
// Each representation of the resource is given a dedicated url info
// object (one url -> one url info)
// It's because search params often influence the final content returned for that url
// When a reference contains url search params it must create 2 url infos:
// 1. The url info corresponding to the url with search params
// 2. The url info corresponding to url without search params
// Because the underlying content without search params is used to generate
// the content modified according to search params
// This way when a file like "style.css" is considered as modified
// references like "style.css?as_css_module" are also affected
const urlWithoutSearch = asUrlWithoutSearch(reference.url);
// a reference with a search param creates an implicit reference
// to the file without search param
const referenceWithoutSearch = reference.addImplicit({
specifier: urlWithoutSearch,
url: urlWithoutSearch,
searchParams: new URLSearchParams(),
isWeak: true,
});
const urlInfoWithoutSearch = referenceWithoutSearch.urlInfo;
urlInfoWithoutSearch.searchParamVariantSet.add(referencedUrlInfo);
}
return referencedUrlInfo;
};
const inferReference = (specifier, parentUrl) => {
const parentUrlInfo = getUrlInfo(parentUrl);
if (!parentUrlInfo) {
return null;
}
const seen = [];
const search = (urlInfo) => {
for (const referenceToOther of urlInfo.referenceToOthersSet) {
if (urlSpecifierEncoding.decode(referenceToOther) === specifier) {
return referenceToOther;
}
}
for (const referenceToOther of parentUrlInfo.referenceToOthersSet) {
if (seen.includes(referenceToOther.url)) {
continue;
}
seen.push(referenceToOther.url);
const referencedUrlInfo = referenceToOther.urlInfo;
if (referencedUrlInfo.isInline) {
const firstRef = search(referencedUrlInfo);
if (firstRef) {
return firstRef;
}
}
}
return null;
};
return search(parentUrlInfo);
};
const getEntryPoints = () => {
const entryPoints = [];
urlInfoMap.forEach((urlInfo) => {
if (urlInfo.isEntryPoint && urlInfo.isUsed()) {
entryPoints.push(urlInfo);
}
});
return entryPoints;
};
const rootUrlInfo = createUrlInfo(rootDirectoryUrl, kitchen.context);
rootUrlInfo.isRoot = true;
rootUrlInfo.entryUrlInfo = rootUrlInfo;
addUrlInfo(rootUrlInfo);
Object.assign(urlGraph, {
name,
rootUrlInfo,
urlInfoMap,
reuseOrCreateUrlInfo,
hasUrlInfo,
getUrlInfo,
getEntryPoints,
inferReference,
urlInfoCreatedEventEmitter,
urlInfoDereferencedEventEmitter,
toObject: () => {
const data = {};
urlInfoMap.forEach((urlInfo) => {
data[urlInfo.url] = urlInfo;
});
return data;
},
toJSON: (rootDirectoryUrl) => {
const data = {};
urlInfoMap.forEach((urlInfo) => {
if (urlInfo.referenceToOthersSet.size) {
const relativeUrl = urlToRelativeUrl(urlInfo.url, rootDirectoryUrl);
const referencedUrlSet = new Set();
for (const referenceToOther of urlInfo.referenceToOthersSet) {
data[relativeUrl] = referencedUrlSet.add(referenceToOther.url);
}
data[relativeUrl] = Array.from(referencedUrlSet).map(
(referencedUrl) =>
urlToRelativeUrl(referencedUrl, rootDirectoryUrl),
);
}
});
return data;
},
});
return urlGraph;
};
const createUrlInfo = (url, context) => {
const urlInfo = {
isRoot: false,
graph: null,
kitchen: null,
context,
error: null,
modifiedTimestamp: 0,
descendantModifiedTimestamp: 0,
dereferencedTimestamp: 0,
originalContentEtag: null,
contentEtag: null,
isWatched: false,
isValid: () => false,
data: {}, // plugins can put whatever they want here
referenceToOthersSet: new Set(),
referenceFromOthersSet: new Set(),
firstReference: null, // first reference from an other url to this one
lastReference: null,
remapReference: null, // used solely during build for rollup
implicitUrlSet: new Set(),
searchParamVariantSet: new Set(),
type: undefined, // "html", "css", "js_classic", "js_module", "importmap", "sourcemap", "json", "webmanifest", ...
subtype: undefined, // "worker", "service_worker", "shared_worker" for js, otherwise undefined
typeHint: undefined,
subtypeHint: undefined,
contentType: "", // "text/html", "text/css", "text/javascript", "application/json", ...
url: null,
originalUrl: undefined,
isEntryPoint: false,
isDynamicEntryPoint: false,
entryUrlInfo: null,
originalContent: undefined,
originalContentAst: undefined,
content: undefined,
contentAst: undefined,
contentLength: undefined,
contentFinalized: false,
contentSideEffects: [],
contentInjections: {},
sourcemap: null,
sourcemapIsWrong: false,
sourcemapReference: null,
generatedUrl: null,
sourcemapGeneratedUrl: null,
filenameHint: "",
dirnameHint: "",
injected: false,
isInline: false,
inlineUrlSite: null,
jsQuote: null, // maybe move to inlineUrlSite?
timing: {},
status: 200,
headers: {},
debug: false,
};
Object.defineProperty(urlInfo, "url", {
enumerable: true,
configurable: false,
writable: false,
value: url,
});
urlInfo.pathname = new URL(url).pathname;
urlInfo.searchParams = new URL(url).searchParams;
Object.defineProperty(urlInfo, "packageDirectoryUrl", {
enumerable: true,
configurable: true,
get: () => context.packageDirectory.find(url),
});
Object.defineProperty(urlInfo, "packageJSON", {
enumerable: true,
configurable: true,
get: () => {
const packageDirectoryUrl = context.packageDirectory.find(url);
return packageDirectoryUrl
? context.packageDirectory.read(packageDirectoryUrl)
: null;
},
});
Object.defineProperty(urlInfo, "packageName", {
enumerable: true,
configurable: true,
get: () => urlInfo.packageJSON?.name,
});
urlInfo.dependencies = createDependencies(urlInfo);
urlInfo.isUsed = () => {
if (urlInfo.isRoot) {
return true;
}
for (const referenceFromOther of urlInfo.referenceFromOthersSet) {
if (referenceFromOther.urlInfo !== urlInfo) {
continue;
}
if (referenceFromOther.ownerUrlInfo.isRoot) {
return true;
}
const ref = referenceFromOther.original || referenceFromOther;
if (ref.isWeak) {
// weak reference don't count as using the url
continue;
}
if (ref.gotInlined()) {
if (ref.ownerUrlInfo.isUsed()) {
return true;
}
// the url info was inlined, an other reference is required
// to consider the non-inlined urlInfo as used
continue;
}
return ref.ownerUrlInfo.isUsed();
}
// nothing uses this url anymore
// - versioning update inline content
// - file converted for import assertion or js_classic conversion
// - urlInfo for a file that is now inlined
return false;
};
urlInfo.findParentIfInline = () => {
let currentUrlInfo = urlInfo;
const graph = urlInfo.graph;
while (currentUrlInfo.isInline) {
const parentUrlInfo = graph.getUrlInfo(currentUrlInfo.inlineUrlSite.url);
if (!parentUrlInfo.isInline) {
return parentUrlInfo;
}
currentUrlInfo = parentUrlInfo;
}
return null;
};
urlInfo.findDependent = (callback) => {
return GRAPH_VISITOR.findDependent(urlInfo, callback);
};
urlInfo.isSearchParamVariantOf = (otherUrlInfo) => {
if (urlInfo.searchParams.size === 0) {
return false;
}
if (otherUrlInfo.searchParams.size > 0) {
return false;
}
const withoutSearch = asUrlWithoutSearch(urlInfo.url);
if (withoutSearch === otherUrlInfo.url) {
return true;
}
return false;
};
urlInfo.getWithoutSearchParam = (searchParam, { expectedType } = {}) => {
// The search param can be
// 1. injected by a plugin during "redirectReference"
// - import assertions
// - js module fallback to systemjs
// 2. already inside source files
// - turn js module into js classic for convenience ?as_js_classic
// - turn js classic to js module for to make it importable
if (!urlInfo.searchParams.has(searchParam)) {
return null;
}
const reference = urlInfo.firstReference;
const newSpecifier = injectQueryParamsIntoSpecifier(reference.specifier, {
[searchParam]: undefined,
});
const referenceWithoutSearchParam = reference.addImplicit({
type: reference.type,
subtype: reference.subtype,
expectedContentType: reference.expectedContentType,
expectedType: expectedType || reference.expectedType,
expectedSubtype: reference.expectedSubtype,
integrity: reference.integrity,
crossorigin: reference.crossorigin,
specifierStart: reference.specifierStart,
specifierEnd: reference.specifierEnd,
specifierLine: reference.specifierLine,
specifierColumn: reference.specifierColumn,
baseUrl: reference.baseUrl,
isOriginalPosition: reference.isOriginalPosition,
// ok mais cet ref est implicite + weak
// donc ne devrait pas etre retournée par getEntryPoints()
isEntryPoint: reference.isEntryPoint,
isResourceHint: reference.isResourceHint,
hasVersioningEffect: reference.hasVersioningEffect,
version: reference.version,
content: reference.content,
contentType: reference.contentType,
fsStat: reference.fsStat,
debug: reference.debug,
importAttributes: reference.importAttributes,
astInfo: reference.astInfo,
mutation: reference.mutation,
data: { ...reference.data },
specifier: newSpecifier,
isWeak: true,
isInline: reference.isInline,
original: reference.original || reference,
prev: reference,
// urlInfo: null,
// url: null,
// generatedUrl: null,
// generatedSpecifier: null,
// filename: null,
});
reference.next = referenceWithoutSearchParam;
return referenceWithoutSearchParam.urlInfo;
};
urlInfo.onRemoved = () => {
urlInfo.kitchen.urlInfoTransformer.resetContent(urlInfo);
urlInfo.referenceToOthersSet.forEach((referenceToOther) => {
referenceToOther.remove();
});
if (urlInfo.searchParams.size > 0) {
const urlWithoutSearch = asUrlWithoutSearch(urlInfo.url);
const urlInfoWithoutSearch = urlInfo.graph.getUrlInfo(urlWithoutSearch);
if (urlInfoWithoutSearch) {
urlInfoWithoutSearch.searchParamVariantSet.delete(urlInfo);
}
}
};
urlInfo.onModified = ({ modifiedTimestamp = Date.now() } = {}) => {
const visitedSet = new Set();
const considerModified = (urlInfo) => {
if (visitedSet.has(urlInfo)) {
return;
}
visitedSet.add(urlInfo);
urlInfo.modifiedTimestamp = modifiedTimestamp;
urlInfo.kitchen.urlInfoTransformer.resetContent(urlInfo);
for (const referenceToOther of urlInfo.referenceToOthersSet) {
const referencedUrlInfo = referenceToOther.urlInfo;
if (referencedUrlInfo.isInline) {
considerModified(referencedUrlInfo);
}
}
for (const referenceFromOther of urlInfo.referenceFromOthersSet) {
if (referenceFromOther.gotInlined()) {
const urlInfoReferencingThisOne = referenceFromOther.ownerUrlInfo;
considerModified(urlInfoReferencingThisOne);
}
}
for (const searchParamVariant of urlInfo.searchParamVariantSet) {
considerModified(searchParamVariant);
}
};
considerModified(urlInfo);
visitedSet.clear();
};
urlInfo.onDereferenced = (lastReferenceFromOther) => {
urlInfo.dereferencedTimestamp = Date.now();
urlInfo.graph.urlInfoDereferencedEventEmitter.emit(
urlInfo,
lastReferenceFromOther,
);
};
urlInfo.cook = (customContext) => {
return urlInfo.context.cook(urlInfo, customContext);
};
urlInfo.cookDependencies = (options) => {
return urlInfo.context.cookDependencies(urlInfo, options);
};
urlInfo.fetchContent = () => {
return urlInfo.context.fetchUrlContent(urlInfo);
};
urlInfo.transformContent = () => {
return urlInfo.context.transformUrlContent(urlInfo);
};
urlInfo.finalizeContent = () => {
return urlInfo.context.finalizeUrlContent(urlInfo);
};
urlInfo.mutateContent = (transformations) => {
return urlInfo.kitchen.urlInfoTransformer.applyTransformations(
urlInfo,
transformations,
);
};
const contentTransformationCallbackSet = new Set();
urlInfo.addContentTransformationCallback = (callback) => {
if (urlInfo.contentFinalized) {
if (urlInfo.context.dev) {
throw new Error(
`cannot add a transform callback on content already sent to the browser.
--- content url ---
${urlInfo.url}`,
);
}
urlInfo.context.addLastTransformationCallback(callback);
} else {
contentTransformationCallbackSet.add(callback);
}
};
urlInfo.applyContentTransformationCallbacks = async () => {
for (const contentTransformationCallback of contentTransformationCallbackSet) {
await contentTransformationCallback();
}
contentTransformationCallbackSet.clear();
};
// Object.preventExtensions(urlInfo) // useful to ensure all properties are declared here
return urlInfo;
};