@parcel/core
Version:
1,517 lines (1,359 loc) • 44.8 kB
JavaScript
// @flow strict-local
import invariant, {AssertionError} from 'assert';
import path from 'path';
import {ContentGraph} from '@parcel/graph';
import type {
ContentGraphOpts,
ContentKey,
NodeId,
SerializedContentGraph,
} from '@parcel/graph';
import logger from '@parcel/logger';
import {hashString} from '@parcel/rust';
import type {Async, EnvMap} from '@parcel/types';
import {
type Deferred,
isGlobMatch,
isDirectoryInside,
makeDeferredWithPromise,
} from '@parcel/utils';
import type {Options as WatcherOptions, Event} from '@parcel/watcher';
import type WorkerFarm from '@parcel/workers';
import nullthrows from 'nullthrows';
import {
PARCEL_VERSION,
VALID,
INITIAL_BUILD,
FILE_CREATE,
FILE_UPDATE,
FILE_DELETE,
ENV_CHANGE,
OPTION_CHANGE,
STARTUP,
ERROR,
} from './constants';
import {
type ProjectPath,
fromProjectPathRelative,
toProjectPathUnsafe,
toProjectPath,
} from './projectPath';
import {getConfigKeyContentHash} from './requests/ConfigRequest';
import type {AssetGraphRequestResult} from './requests/AssetGraphRequest';
import type {PackageRequestResult} from './requests/PackageRequest';
import type {ConfigRequestResult} from './requests/ConfigRequest';
import type {DevDepRequestResult} from './requests/DevDepRequest';
import type {WriteBundlesRequestResult} from './requests/WriteBundlesRequest';
import type {WriteBundleRequestResult} from './requests/WriteBundleRequest';
import type {TargetRequestResult} from './requests/TargetRequest';
import type {PathRequestResult} from './requests/PathRequest';
import type {ParcelConfigRequestResult} from './requests/ParcelConfigRequest';
import type {ParcelBuildRequestResult} from './requests/ParcelBuildRequest';
import type {EntryRequestResult} from './requests/EntryRequest';
import type {BundleGraphResult} from './requests/BundleGraphRequest';
import {deserialize, serialize} from './serializer';
import type {
AssetRequestResult,
ParcelOptions,
RequestInvalidation,
InternalFileCreateInvalidation,
InternalGlob,
} from './types';
import {BuildAbortError, assertSignalNotAborted, hashFromOption} from './utils';
export const requestGraphEdgeTypes = {
subrequest: 2,
invalidated_by_update: 3,
invalidated_by_delete: 4,
invalidated_by_create: 5,
invalidated_by_create_above: 6,
dirname: 7,
};
class FSBailoutError extends Error {
name: string = 'FSBailoutError';
}
export type RequestGraphEdgeType = $Values<typeof requestGraphEdgeTypes>;
type RequestGraphOpts = {|
...ContentGraphOpts<RequestGraphNode, RequestGraphEdgeType>,
invalidNodeIds: Set<NodeId>,
incompleteNodeIds: Set<NodeId>,
globNodeIds: Set<NodeId>,
envNodeIds: Set<NodeId>,
optionNodeIds: Set<NodeId>,
unpredicatableNodeIds: Set<NodeId>,
invalidateOnBuildNodeIds: Set<NodeId>,
configKeyNodes: Map<ProjectPath, Set<NodeId>>,
|};
type SerializedRequestGraph = {|
...SerializedContentGraph<RequestGraphNode, RequestGraphEdgeType>,
invalidNodeIds: Set<NodeId>,
incompleteNodeIds: Set<NodeId>,
globNodeIds: Set<NodeId>,
envNodeIds: Set<NodeId>,
optionNodeIds: Set<NodeId>,
unpredicatableNodeIds: Set<NodeId>,
invalidateOnBuildNodeIds: Set<NodeId>,
configKeyNodes: Map<ProjectPath, Set<NodeId>>,
|};
const FILE: 0 = 0;
const REQUEST: 1 = 1;
const FILE_NAME: 2 = 2;
const ENV: 3 = 3;
const OPTION: 4 = 4;
const GLOB: 5 = 5;
const CONFIG_KEY: 6 = 6;
type FileNode = {|id: ContentKey, +type: typeof FILE|};
type GlobNode = {|id: ContentKey, +type: typeof GLOB, value: InternalGlob|};
type FileNameNode = {|
id: ContentKey,
+type: typeof FILE_NAME,
|};
type EnvNode = {|
id: ContentKey,
+type: typeof ENV,
value: string | void,
|};
type OptionNode = {|
id: ContentKey,
+type: typeof OPTION,
hash: string,
|};
type ConfigKeyNode = {|
id: ContentKey,
+type: typeof CONFIG_KEY,
configKey: string,
contentHash: string,
|};
type Request<TInput, TResult> = {|
id: string,
+type: RequestType,
input: TInput,
run: ({|input: TInput, ...StaticRunOpts<TResult>|}) => Async<TResult>,
|};
export type RequestResult =
| AssetGraphRequestResult
| PackageRequestResult
| ConfigRequestResult
| DevDepRequestResult
| WriteBundlesRequestResult
| WriteBundleRequestResult
| TargetRequestResult
| PathRequestResult
| ParcelConfigRequestResult
| ParcelBuildRequestResult
| EntryRequestResult
| BundleGraphResult
| AssetRequestResult;
type InvalidateReason = number;
type RequestNode = {|
id: ContentKey,
+type: typeof REQUEST,
+requestType: RequestType,
invalidateReason: InvalidateReason,
result?: RequestResult,
resultCacheKey?: ?string,
hash?: string,
|};
export const requestTypes = {
parcel_build_request: 1,
bundle_graph_request: 2,
asset_graph_request: 3,
entry_request: 4,
target_request: 5,
parcel_config_request: 6,
path_request: 7,
dev_dep_request: 8,
asset_request: 9,
config_request: 10,
write_bundles_request: 11,
package_request: 12,
write_bundle_request: 13,
validation_request: 14,
};
type RequestType = $Values<typeof requestTypes>;
type RequestTypeName = $Keys<typeof requestTypes>;
type RequestGraphNode =
| RequestNode
| FileNode
| GlobNode
| FileNameNode
| EnvNode
| OptionNode
| ConfigKeyNode;
export type RunAPI<TResult: RequestResult> = {|
invalidateOnFileCreate: InternalFileCreateInvalidation => void,
invalidateOnFileDelete: ProjectPath => void,
invalidateOnFileUpdate: ProjectPath => void,
invalidateOnConfigKeyChange: (
filePath: ProjectPath,
configKey: string,
contentHash: string,
) => void,
invalidateOnStartup: () => void,
invalidateOnBuild: () => void,
invalidateOnEnvChange: string => void,
invalidateOnOptionChange: string => void,
getInvalidations(): Array<RequestInvalidation>,
storeResult(result: TResult, cacheKey?: string): void,
getRequestResult<T: RequestResult>(contentKey: ContentKey): Async<?T>,
getPreviousResult<T: RequestResult>(ifMatch?: string): Async<?T>,
getSubRequests(): Array<RequestNode>,
getInvalidSubRequests(): Array<RequestNode>,
canSkipSubrequest(ContentKey): boolean,
runRequest: <TInput, TResult: RequestResult>(
subRequest: Request<TInput, TResult>,
opts?: RunRequestOpts,
) => Promise<TResult>,
|};
type RunRequestOpts = {|
force: boolean,
|};
export type StaticRunOpts<TResult> = {|
api: RunAPI<TResult>,
farm: WorkerFarm,
invalidateReason: InvalidateReason,
options: ParcelOptions,
|};
const nodeFromFilePath = (filePath: ProjectPath): RequestGraphNode => ({
id: fromProjectPathRelative(filePath),
type: FILE,
});
const nodeFromGlob = (glob: InternalGlob): RequestGraphNode => ({
id: fromProjectPathRelative(glob),
type: GLOB,
value: glob,
});
const nodeFromFileName = (fileName: string): RequestGraphNode => ({
id: 'file_name:' + fileName,
type: FILE_NAME,
});
const nodeFromRequest = (request: RequestNode): RequestGraphNode => ({
id: request.id,
type: REQUEST,
requestType: request.requestType,
invalidateReason: INITIAL_BUILD,
});
const nodeFromEnv = (env: string, value: string | void): RequestGraphNode => ({
id: 'env:' + env,
type: ENV,
value,
});
const nodeFromOption = (option: string, value: mixed): RequestGraphNode => ({
id: 'option:' + option,
type: OPTION,
hash: hashFromOption(value),
});
const nodeFromConfigKey = (
fileName: ProjectPath,
configKey: string,
contentHash: string,
): RequestGraphNode => ({
id: `config_key:${fromProjectPathRelative(fileName)}:${configKey}`,
type: CONFIG_KEY,
configKey,
contentHash,
});
const keyFromEnvContentKey = (contentKey: ContentKey): string =>
contentKey.slice('env:'.length);
const keyFromOptionContentKey = (contentKey: ContentKey): string =>
contentKey.slice('option:'.length);
export class RequestGraph extends ContentGraph<
RequestGraphNode,
RequestGraphEdgeType,
> {
invalidNodeIds: Set<NodeId> = new Set();
incompleteNodeIds: Set<NodeId> = new Set();
incompleteNodePromises: Map<NodeId, Promise<boolean>> = new Map();
globNodeIds: Set<NodeId> = new Set();
envNodeIds: Set<NodeId> = new Set();
optionNodeIds: Set<NodeId> = new Set();
// Unpredictable nodes are requests that cannot be predicted whether they should rerun based on
// filesystem changes alone. They should rerun on each startup of Parcel.
unpredicatableNodeIds: Set<NodeId> = new Set();
invalidateOnBuildNodeIds: Set<NodeId> = new Set();
configKeyNodes: Map<ProjectPath, Set<NodeId>> = new Map();
// $FlowFixMe[prop-missing]
static deserialize(opts: RequestGraphOpts): RequestGraph {
// $FlowFixMe[prop-missing]
let deserialized = new RequestGraph(opts);
deserialized.invalidNodeIds = opts.invalidNodeIds;
deserialized.incompleteNodeIds = opts.incompleteNodeIds;
deserialized.globNodeIds = opts.globNodeIds;
deserialized.envNodeIds = opts.envNodeIds;
deserialized.optionNodeIds = opts.optionNodeIds;
deserialized.unpredicatableNodeIds = opts.unpredicatableNodeIds;
deserialized.invalidateOnBuildNodeIds = opts.invalidateOnBuildNodeIds;
deserialized.configKeyNodes = opts.configKeyNodes;
return deserialized;
}
// $FlowFixMe[prop-missing]
serialize(): SerializedRequestGraph {
return {
...super.serialize(),
invalidNodeIds: this.invalidNodeIds,
incompleteNodeIds: this.incompleteNodeIds,
globNodeIds: this.globNodeIds,
envNodeIds: this.envNodeIds,
optionNodeIds: this.optionNodeIds,
unpredicatableNodeIds: this.unpredicatableNodeIds,
invalidateOnBuildNodeIds: this.invalidateOnBuildNodeIds,
configKeyNodes: this.configKeyNodes,
};
}
// addNode for RequestGraph should not override the value if added multiple times
addNode(node: RequestGraphNode): NodeId {
let nodeId = this._contentKeyToNodeId.get(node.id);
if (nodeId != null) {
return nodeId;
}
nodeId = super.addNodeByContentKey(node.id, node);
if (node.type === GLOB) {
this.globNodeIds.add(nodeId);
} else if (node.type === ENV) {
this.envNodeIds.add(nodeId);
} else if (node.type === OPTION) {
this.optionNodeIds.add(nodeId);
}
return nodeId;
}
removeNode(nodeId: NodeId): void {
this.invalidNodeIds.delete(nodeId);
this.incompleteNodeIds.delete(nodeId);
this.incompleteNodePromises.delete(nodeId);
this.unpredicatableNodeIds.delete(nodeId);
this.invalidateOnBuildNodeIds.delete(nodeId);
let node = nullthrows(this.getNode(nodeId));
if (node.type === GLOB) {
this.globNodeIds.delete(nodeId);
} else if (node.type === ENV) {
this.envNodeIds.delete(nodeId);
} else if (node.type === OPTION) {
this.optionNodeIds.delete(nodeId);
} else if (node.type === CONFIG_KEY) {
for (let configKeyNodes of this.configKeyNodes.values()) {
configKeyNodes.delete(nodeId);
}
}
return super.removeNode(nodeId);
}
getRequestNode(nodeId: NodeId): RequestNode {
let node = nullthrows(this.getNode(nodeId));
if (node.type === REQUEST) {
return node;
}
throw new AssertionError({
message: `Expected a request node: ${
node.type
} (${typeof node.type}) does not equal ${REQUEST} (${typeof REQUEST}).`,
expected: REQUEST,
actual: node.type,
});
}
replaceSubrequests(
requestNodeId: NodeId,
subrequestContentKeys: Array<ContentKey>,
) {
let subrequestNodeIds = [];
for (let key of subrequestContentKeys) {
if (this.hasContentKey(key)) {
subrequestNodeIds.push(this.getNodeIdByContentKey(key));
}
}
this.replaceNodeIdsConnectedTo(
requestNodeId,
subrequestNodeIds,
null,
requestGraphEdgeTypes.subrequest,
);
}
invalidateNode(nodeId: NodeId, reason: InvalidateReason) {
let node = nullthrows(this.getNode(nodeId));
invariant(node.type === REQUEST);
node.invalidateReason |= reason;
this.invalidNodeIds.add(nodeId);
let parentNodes = this.getNodeIdsConnectedTo(
nodeId,
requestGraphEdgeTypes.subrequest,
);
for (let parentNode of parentNodes) {
this.invalidateNode(parentNode, reason);
}
}
invalidateUnpredictableNodes() {
for (let nodeId of this.unpredicatableNodeIds) {
let node = nullthrows(this.getNode(nodeId));
invariant(node.type !== FILE && node.type !== GLOB);
this.invalidateNode(nodeId, STARTUP);
}
}
invalidateOnBuildNodes() {
for (let nodeId of this.invalidateOnBuildNodeIds) {
let node = nullthrows(this.getNode(nodeId));
invariant(node.type !== FILE && node.type !== GLOB);
this.invalidateNode(nodeId, STARTUP);
}
}
invalidateEnvNodes(env: EnvMap) {
for (let nodeId of this.envNodeIds) {
let node = nullthrows(this.getNode(nodeId));
invariant(node.type === ENV);
if (env[keyFromEnvContentKey(node.id)] !== node.value) {
let parentNodes = this.getNodeIdsConnectedTo(
nodeId,
requestGraphEdgeTypes.invalidated_by_update,
);
for (let parentNode of parentNodes) {
this.invalidateNode(parentNode, ENV_CHANGE);
}
}
}
}
invalidateOptionNodes(options: ParcelOptions) {
for (let nodeId of this.optionNodeIds) {
let node = nullthrows(this.getNode(nodeId));
invariant(node.type === OPTION);
if (
hashFromOption(options[keyFromOptionContentKey(node.id)]) !== node.hash
) {
let parentNodes = this.getNodeIdsConnectedTo(
nodeId,
requestGraphEdgeTypes.invalidated_by_update,
);
for (let parentNode of parentNodes) {
this.invalidateNode(parentNode, OPTION_CHANGE);
}
}
}
}
invalidateOnConfigKeyChange(
requestNodeId: NodeId,
filePath: ProjectPath,
configKey: string,
contentHash: string,
) {
let configKeyNodeId = this.addNode(
nodeFromConfigKey(filePath, configKey, contentHash),
);
let nodes = this.configKeyNodes.get(filePath);
if (!nodes) {
nodes = new Set();
this.configKeyNodes.set(filePath, nodes);
}
nodes.add(configKeyNodeId);
if (
!this.hasEdge(
requestNodeId,
configKeyNodeId,
requestGraphEdgeTypes.invalidated_by_update,
)
) {
this.addEdge(
requestNodeId,
configKeyNodeId,
// Store as an update edge, but file deletes are handled too
requestGraphEdgeTypes.invalidated_by_update,
);
}
}
invalidateOnFileUpdate(requestNodeId: NodeId, filePath: ProjectPath) {
let fileNodeId = this.addNode(nodeFromFilePath(filePath));
if (
!this.hasEdge(
requestNodeId,
fileNodeId,
requestGraphEdgeTypes.invalidated_by_update,
)
) {
this.addEdge(
requestNodeId,
fileNodeId,
requestGraphEdgeTypes.invalidated_by_update,
);
}
}
invalidateOnFileDelete(requestNodeId: NodeId, filePath: ProjectPath) {
let fileNodeId = this.addNode(nodeFromFilePath(filePath));
if (
!this.hasEdge(
requestNodeId,
fileNodeId,
requestGraphEdgeTypes.invalidated_by_delete,
)
) {
this.addEdge(
requestNodeId,
fileNodeId,
requestGraphEdgeTypes.invalidated_by_delete,
);
}
}
invalidateOnFileCreate(
requestNodeId: NodeId,
input: InternalFileCreateInvalidation,
) {
let node;
if (input.glob != null) {
node = nodeFromGlob(input.glob);
} else if (input.fileName != null && input.aboveFilePath != null) {
let aboveFilePath = input.aboveFilePath;
// Create nodes and edges for each part of the filename pattern.
// For example, 'node_modules/foo' would create two nodes and one edge.
// This creates a sort of trie structure within the graph that can be
// quickly matched by following the edges. This is also memory efficient
// since common sub-paths (e.g. 'node_modules') are deduplicated.
let parts = input.fileName.split('/').reverse();
let lastNodeId;
for (let part of parts) {
let fileNameNode = nodeFromFileName(part);
let fileNameNodeId = this.addNode(fileNameNode);
if (
lastNodeId != null &&
!this.hasEdge(
lastNodeId,
fileNameNodeId,
requestGraphEdgeTypes.dirname,
)
) {
this.addEdge(
lastNodeId,
fileNameNodeId,
requestGraphEdgeTypes.dirname,
);
}
lastNodeId = fileNameNodeId;
}
// The `aboveFilePath` condition asserts that requests are only invalidated
// if the file being created is "above" it in the filesystem (e.g. the file
// is created in a parent directory). There is likely to already be a node
// for this file in the graph (e.g. the source file) that we can reuse for this.
node = nodeFromFilePath(aboveFilePath);
let nodeId = this.addNode(node);
// Now create an edge from the `aboveFilePath` node to the first file_name node
// in the chain created above, and an edge from the last node in the chain back to
// the `aboveFilePath` node. When matching, we will start from the first node in
// the chain, and continue following it to parent directories until there is an
// edge pointing an `aboveFilePath` node that also points to the start of the chain.
// This indicates a complete match, and any requests attached to the `aboveFilePath`
// node will be invalidated.
let firstId = 'file_name:' + parts[0];
let firstNodeId = this.getNodeIdByContentKey(firstId);
if (
!this.hasEdge(
nodeId,
firstNodeId,
requestGraphEdgeTypes.invalidated_by_create_above,
)
) {
this.addEdge(
nodeId,
firstNodeId,
requestGraphEdgeTypes.invalidated_by_create_above,
);
}
invariant(lastNodeId != null);
if (
!this.hasEdge(
lastNodeId,
nodeId,
requestGraphEdgeTypes.invalidated_by_create_above,
)
) {
this.addEdge(
lastNodeId,
nodeId,
requestGraphEdgeTypes.invalidated_by_create_above,
);
}
} else if (input.filePath != null) {
node = nodeFromFilePath(input.filePath);
} else {
throw new Error('Invalid invalidation');
}
let nodeId = this.addNode(node);
if (
!this.hasEdge(
requestNodeId,
nodeId,
requestGraphEdgeTypes.invalidated_by_create,
)
) {
this.addEdge(
requestNodeId,
nodeId,
requestGraphEdgeTypes.invalidated_by_create,
);
}
}
invalidateOnStartup(requestNodeId: NodeId) {
this.getRequestNode(requestNodeId);
this.unpredicatableNodeIds.add(requestNodeId);
}
invalidateOnBuild(requestNodeId: NodeId) {
this.getRequestNode(requestNodeId);
this.invalidateOnBuildNodeIds.add(requestNodeId);
}
invalidateOnEnvChange(
requestNodeId: NodeId,
env: string,
value: string | void,
) {
let envNode = nodeFromEnv(env, value);
let envNodeId = this.addNode(envNode);
if (
!this.hasEdge(
requestNodeId,
envNodeId,
requestGraphEdgeTypes.invalidated_by_update,
)
) {
this.addEdge(
requestNodeId,
envNodeId,
requestGraphEdgeTypes.invalidated_by_update,
);
}
}
invalidateOnOptionChange(
requestNodeId: NodeId,
option: string,
value: mixed,
) {
let optionNode = nodeFromOption(option, value);
let optionNodeId = this.addNode(optionNode);
if (
!this.hasEdge(
requestNodeId,
optionNodeId,
requestGraphEdgeTypes.invalidated_by_update,
)
) {
this.addEdge(
requestNodeId,
optionNodeId,
requestGraphEdgeTypes.invalidated_by_update,
);
}
}
clearInvalidations(nodeId: NodeId) {
this.unpredicatableNodeIds.delete(nodeId);
this.invalidateOnBuildNodeIds.delete(nodeId);
this.replaceNodeIdsConnectedTo(
nodeId,
[],
null,
requestGraphEdgeTypes.invalidated_by_update,
);
this.replaceNodeIdsConnectedTo(
nodeId,
[],
null,
requestGraphEdgeTypes.invalidated_by_delete,
);
this.replaceNodeIdsConnectedTo(
nodeId,
[],
null,
requestGraphEdgeTypes.invalidated_by_create,
);
}
getInvalidations(requestNodeId: NodeId): Array<RequestInvalidation> {
if (!this.hasNode(requestNodeId)) {
return [];
}
// For now just handling updates. Could add creates/deletes later if needed.
let invalidations = this.getNodeIdsConnectedFrom(
requestNodeId,
requestGraphEdgeTypes.invalidated_by_update,
);
return invalidations
.map(nodeId => {
let node = nullthrows(this.getNode(nodeId));
switch (node.type) {
case FILE:
return {type: 'file', filePath: toProjectPathUnsafe(node.id)};
case ENV:
return {type: 'env', key: keyFromEnvContentKey(node.id)};
case OPTION:
return {
type: 'option',
key: keyFromOptionContentKey(node.id),
};
}
})
.filter(Boolean);
}
getSubRequests(requestNodeId: NodeId): Array<RequestNode> {
if (!this.hasNode(requestNodeId)) {
return [];
}
let subRequests = this.getNodeIdsConnectedFrom(
requestNodeId,
requestGraphEdgeTypes.subrequest,
);
return subRequests.map(nodeId => {
let node = nullthrows(this.getNode(nodeId));
invariant(node.type === REQUEST);
return node;
});
}
getInvalidSubRequests(requestNodeId: NodeId): Array<RequestNode> {
if (!this.hasNode(requestNodeId)) {
return [];
}
let subRequests = this.getNodeIdsConnectedFrom(
requestNodeId,
requestGraphEdgeTypes.subrequest,
);
return subRequests
.filter(id => this.invalidNodeIds.has(id))
.map(nodeId => {
let node = nullthrows(this.getNode(nodeId));
invariant(node.type === REQUEST);
return node;
});
}
invalidateFileNameNode(
node: FileNameNode,
filePath: ProjectPath,
matchNodes: Array<FileNode>,
) {
// If there is an edge between this file_name node and one of the original file nodes pointed to
// by the original file_name node, and the matched node is inside the current directory, invalidate
// all connected requests pointed to by the file node.
let dirname = path.dirname(fromProjectPathRelative(filePath));
let nodeId = this.getNodeIdByContentKey(node.id);
for (let matchNode of matchNodes) {
let matchNodeId = this.getNodeIdByContentKey(matchNode.id);
if (
this.hasEdge(
nodeId,
matchNodeId,
requestGraphEdgeTypes.invalidated_by_create_above,
) &&
isDirectoryInside(
fromProjectPathRelative(toProjectPathUnsafe(matchNode.id)),
dirname,
)
) {
let connectedNodes = this.getNodeIdsConnectedTo(
matchNodeId,
requestGraphEdgeTypes.invalidated_by_create,
);
for (let connectedNode of connectedNodes) {
this.invalidateNode(connectedNode, FILE_CREATE);
}
}
}
// Find the `file_name` node for the parent directory and
// recursively invalidate connected requests as described above.
let basename = path.basename(dirname);
let contentKey = 'file_name:' + basename;
if (this.hasContentKey(contentKey)) {
if (
this.hasEdge(
nodeId,
this.getNodeIdByContentKey(contentKey),
requestGraphEdgeTypes.dirname,
)
) {
let parent = nullthrows(this.getNodeByContentKey(contentKey));
invariant(parent.type === FILE_NAME);
this.invalidateFileNameNode(
parent,
toProjectPathUnsafe(dirname),
matchNodes,
);
}
}
}
async respondToFSEvents(
events: Array<Event>,
options: ParcelOptions,
threshold: number,
): Async<boolean> {
let didInvalidate = false;
let count = 0;
let predictedTime = 0;
let startTime = Date.now();
for (let {path: _path, type} of events) {
if (++count === 256) {
let duration = Date.now() - startTime;
predictedTime = duration * (events.length >> 8);
if (predictedTime > threshold) {
logger.warn({
origin: '@parcel/core',
message:
'Building with clean cache. Cache invalidation took too long.',
meta: {
trackableEvent: 'cache_invalidation_timeout',
watcherEventCount: events.length,
predictedTime,
},
});
throw new FSBailoutError(
'Responding to file system events exceeded threshold, start with empty cache.',
);
}
}
let _filePath = toProjectPath(options.projectRoot, _path);
let filePath = fromProjectPathRelative(_filePath);
let hasFileRequest = this.hasContentKey(filePath);
// If we see a 'create' event for the project root itself,
// this means the project root was moved and we need to
// re-run all requests.
if (type === 'create' && filePath === '') {
logger.verbose({
origin: '@parcel/core',
message:
'Watcher reported project root create event. Invalidate all nodes.',
meta: {
trackableEvent: 'project_root_create',
},
});
for (let [id, node] of this.nodes.entries()) {
if (node?.type === REQUEST) {
this.invalidNodeIds.add(id);
}
}
return true;
}
// sometimes mac os reports update events as create events.
// if it was a create event, but the file already exists in the graph,
// then also invalidate nodes connected by invalidated_by_update edges.
if (hasFileRequest && (type === 'create' || type === 'update')) {
let nodeId = this.getNodeIdByContentKey(filePath);
let nodes = this.getNodeIdsConnectedTo(
nodeId,
requestGraphEdgeTypes.invalidated_by_update,
);
for (let connectedNode of nodes) {
didInvalidate = true;
this.invalidateNode(connectedNode, FILE_UPDATE);
}
if (type === 'create') {
let nodes = this.getNodeIdsConnectedTo(
nodeId,
requestGraphEdgeTypes.invalidated_by_create,
);
for (let connectedNode of nodes) {
didInvalidate = true;
this.invalidateNode(connectedNode, FILE_CREATE);
}
}
} else if (type === 'create') {
let basename = path.basename(filePath);
let fileNameNode = this.getNodeByContentKey('file_name:' + basename);
if (fileNameNode != null && fileNameNode.type === FILE_NAME) {
let fileNameNodeId = this.getNodeIdByContentKey(
'file_name:' + basename,
);
// Find potential file nodes to be invalidated if this file name pattern matches
let above: Array<FileNode> = [];
for (const nodeId of this.getNodeIdsConnectedTo(
fileNameNodeId,
requestGraphEdgeTypes.invalidated_by_create_above,
)) {
let node = nullthrows(this.getNode(nodeId));
// these might also be `glob` nodes which get handled below, we only care about files here.
if (node.type === FILE) {
above.push(node);
}
}
if (above.length > 0) {
didInvalidate = true;
this.invalidateFileNameNode(fileNameNode, _filePath, above);
}
}
for (let globeNodeId of this.globNodeIds) {
let globNode = this.getNode(globeNodeId);
invariant(globNode && globNode.type === GLOB);
if (isGlobMatch(filePath, fromProjectPathRelative(globNode.value))) {
let connectedNodes = this.getNodeIdsConnectedTo(
globeNodeId,
requestGraphEdgeTypes.invalidated_by_create,
);
for (let connectedNode of connectedNodes) {
didInvalidate = true;
this.invalidateNode(connectedNode, FILE_CREATE);
}
}
}
} else if (hasFileRequest && type === 'delete') {
let nodeId = this.getNodeIdByContentKey(filePath);
for (let connectedNode of this.getNodeIdsConnectedTo(
nodeId,
requestGraphEdgeTypes.invalidated_by_delete,
)) {
didInvalidate = true;
this.invalidateNode(connectedNode, FILE_DELETE);
}
// Delete the file node since it doesn't exist anymore.
// This ensures that files that don't exist aren't sent
// to requests as invalidations for future requests.
this.removeNode(nodeId);
}
let configKeyNodes = this.configKeyNodes.get(_filePath);
if (configKeyNodes && (type === 'delete' || type === 'update')) {
for (let nodeId of configKeyNodes) {
let isInvalid = type === 'delete';
if (type === 'update') {
let node = this.getNode(nodeId);
invariant(node && node.type === CONFIG_KEY);
let contentHash = await getConfigKeyContentHash(
_filePath,
node.configKey,
options,
);
isInvalid = node.contentHash !== contentHash;
}
if (isInvalid) {
for (let connectedNode of this.getNodeIdsConnectedTo(
nodeId,
requestGraphEdgeTypes.invalidated_by_update,
)) {
this.invalidateNode(
connectedNode,
type === 'delete' ? FILE_DELETE : FILE_UPDATE,
);
}
didInvalidate = true;
this.removeNode(nodeId);
}
}
}
}
let duration = Date.now() - startTime;
logger.verbose({
origin: '@parcel/core',
message: `RequestGraph.respondToFSEvents duration: ${duration}`,
meta: {
trackableEvent: 'fsevent_response_time',
duration,
predictedTime,
},
});
return didInvalidate && this.invalidNodeIds.size > 0;
}
}
export default class RequestTracker {
graph: RequestGraph;
farm: WorkerFarm;
options: ParcelOptions;
signal: ?AbortSignal;
stats: Map<RequestType, number> = new Map();
constructor({
graph,
farm,
options,
}: {|
graph?: RequestGraph,
farm: WorkerFarm,
options: ParcelOptions,
|}) {
this.graph = graph || new RequestGraph();
this.farm = farm;
this.options = options;
}
// TODO: refactor (abortcontroller should be created by RequestTracker)
setSignal(signal?: AbortSignal) {
this.signal = signal;
}
startRequest(request: RequestNode): {|
requestNodeId: NodeId,
deferred: Deferred<boolean>,
|} {
let didPreviouslyExist = this.graph.hasContentKey(request.id);
let requestNodeId;
if (didPreviouslyExist) {
requestNodeId = this.graph.getNodeIdByContentKey(request.id);
// Clear existing invalidations for the request so that the new
// invalidations created during the request replace the existing ones.
this.graph.clearInvalidations(requestNodeId);
} else {
requestNodeId = this.graph.addNode(nodeFromRequest(request));
}
this.graph.incompleteNodeIds.add(requestNodeId);
this.graph.invalidNodeIds.delete(requestNodeId);
let {promise, deferred} = makeDeferredWithPromise();
this.graph.incompleteNodePromises.set(requestNodeId, promise);
return {requestNodeId, deferred};
}
// If a cache key is provided, the result will be removed from the node and stored in a separate cache entry
storeResult(nodeId: NodeId, result: RequestResult, cacheKey: ?string) {
let node = this.graph.getNode(nodeId);
if (node && node.type === REQUEST) {
node.result = result;
node.resultCacheKey = cacheKey;
}
}
hasValidResult(nodeId: NodeId): boolean {
return (
this.graph.hasNode(nodeId) &&
!this.graph.invalidNodeIds.has(nodeId) &&
!this.graph.incompleteNodeIds.has(nodeId)
);
}
async getRequestResult<T: RequestResult>(
contentKey: ContentKey,
ifMatch?: string,
): Promise<?T> {
let node = nullthrows(this.graph.getNodeByContentKey(contentKey));
invariant(node.type === REQUEST);
if (ifMatch != null && node.resultCacheKey !== ifMatch) {
return null;
}
if (node.result != undefined) {
// $FlowFixMe
let result: T = (node.result: any);
return result;
} else if (node.resultCacheKey != null && ifMatch == null) {
let key = node.resultCacheKey;
invariant(this.options.cache.hasLargeBlob(key));
let cachedResult: T = deserialize(
await this.options.cache.getLargeBlob(key),
);
node.result = cachedResult;
return cachedResult;
}
}
completeRequest(nodeId: NodeId) {
this.graph.invalidNodeIds.delete(nodeId);
this.graph.incompleteNodeIds.delete(nodeId);
this.graph.incompleteNodePromises.delete(nodeId);
let node = this.graph.getNode(nodeId);
if (node && node.type === REQUEST) {
node.invalidateReason = VALID;
}
}
rejectRequest(nodeId: NodeId) {
this.graph.incompleteNodeIds.delete(nodeId);
this.graph.incompleteNodePromises.delete(nodeId);
let node = this.graph.getNode(nodeId);
if (node?.type === REQUEST) {
this.graph.invalidateNode(nodeId, ERROR);
}
}
respondToFSEvents(events: Array<Event>, threshold: number): Async<boolean> {
return this.graph.respondToFSEvents(events, this.options, threshold);
}
hasInvalidRequests(): boolean {
return this.graph.invalidNodeIds.size > 0;
}
getInvalidRequests(): Array<RequestNode> {
let invalidRequests = [];
for (let id of this.graph.invalidNodeIds) {
let node = nullthrows(this.graph.getNode(id));
invariant(node.type === REQUEST);
invalidRequests.push(node);
}
return invalidRequests;
}
replaceSubrequests(
requestNodeId: NodeId,
subrequestContextKeys: Array<ContentKey>,
) {
this.graph.replaceSubrequests(requestNodeId, subrequestContextKeys);
}
async runRequest<TInput, TResult: RequestResult>(
request: Request<TInput, TResult>,
opts?: ?RunRequestOpts,
): Promise<TResult> {
let hasKey = this.graph.hasContentKey(request.id);
let requestId = hasKey
? this.graph.getNodeIdByContentKey(request.id)
: undefined;
let hasValidResult = requestId != null && this.hasValidResult(requestId);
if (!opts?.force && hasValidResult) {
// $FlowFixMe[incompatible-type]
return this.getRequestResult<TResult>(request.id);
}
if (requestId != null) {
let incompletePromise = this.graph.incompleteNodePromises.get(requestId);
if (incompletePromise != null) {
// There is a another instance of this request already running, wait for its completion and reuse its result
try {
if (await incompletePromise) {
// $FlowFixMe[incompatible-type]
return this.getRequestResult<TResult>(request.id);
}
} catch (e) {
// Rerun this request
}
}
}
let previousInvalidations =
requestId != null ? this.graph.getInvalidations(requestId) : [];
let {requestNodeId, deferred} = this.startRequest({
id: request.id,
type: REQUEST,
requestType: request.type,
invalidateReason: INITIAL_BUILD,
});
let {api, subRequestContentKeys} = this.createAPI(
requestNodeId,
previousInvalidations,
);
try {
let node = this.graph.getRequestNode(requestNodeId);
this.stats.set(request.type, (this.stats.get(request.type) ?? 0) + 1);
let result = await request.run({
input: request.input,
api,
farm: this.farm,
invalidateReason: node.invalidateReason,
options: this.options,
});
assertSignalNotAborted(this.signal);
this.completeRequest(requestNodeId);
deferred.resolve(true);
return result;
} catch (err) {
if (
!(err instanceof BuildAbortError) &&
request.type === requestTypes.dev_dep_request
) {
logger.verbose({
origin: '@parcel/core',
message: `Failed DevDepRequest`,
meta: {
trackableEvent: 'failed_dev_dep_request',
hasKey,
hasValidResult,
},
});
}
this.rejectRequest(requestNodeId);
deferred.resolve(false);
throw err;
} finally {
this.graph.replaceSubrequests(requestNodeId, [...subRequestContentKeys]);
}
}
flushStats(): {[requestType: string]: number} {
let requestTypeEntries = {};
for (let key of (Object.keys(requestTypes): RequestTypeName[])) {
requestTypeEntries[requestTypes[key]] = key;
}
let formattedStats = {};
for (let [requestType, count] of this.stats.entries()) {
let requestTypeName = requestTypeEntries[requestType];
formattedStats[requestTypeName] = count;
}
this.stats = new Map();
return formattedStats;
}
createAPI<TResult: RequestResult>(
requestId: NodeId,
previousInvalidations: Array<RequestInvalidation>,
): {|api: RunAPI<TResult>, subRequestContentKeys: Set<ContentKey>|} {
let subRequestContentKeys = new Set<ContentKey>();
let api: RunAPI<TResult> = {
invalidateOnFileCreate: input =>
this.graph.invalidateOnFileCreate(requestId, input),
invalidateOnConfigKeyChange: (filePath, configKey, contentHash) =>
this.graph.invalidateOnConfigKeyChange(
requestId,
filePath,
configKey,
contentHash,
),
invalidateOnFileDelete: filePath =>
this.graph.invalidateOnFileDelete(requestId, filePath),
invalidateOnFileUpdate: filePath =>
this.graph.invalidateOnFileUpdate(requestId, filePath),
invalidateOnStartup: () => this.graph.invalidateOnStartup(requestId),
invalidateOnBuild: () => this.graph.invalidateOnBuild(requestId),
invalidateOnEnvChange: env =>
this.graph.invalidateOnEnvChange(requestId, env, this.options.env[env]),
invalidateOnOptionChange: option =>
this.graph.invalidateOnOptionChange(
requestId,
option,
this.options[option],
),
getInvalidations: () => previousInvalidations,
storeResult: (result, cacheKey) => {
this.storeResult(requestId, result, cacheKey);
},
getSubRequests: () => this.graph.getSubRequests(requestId),
getInvalidSubRequests: () => this.graph.getInvalidSubRequests(requestId),
getPreviousResult: <T: RequestResult>(ifMatch?: string): Async<?T> => {
let contentKey = nullthrows(this.graph.getNode(requestId)?.id);
return this.getRequestResult<T>(contentKey, ifMatch);
},
getRequestResult: <T: RequestResult>(id): Async<?T> =>
this.getRequestResult<T>(id),
canSkipSubrequest: contentKey => {
if (
this.graph.hasContentKey(contentKey) &&
this.hasValidResult(this.graph.getNodeIdByContentKey(contentKey))
) {
subRequestContentKeys.add(contentKey);
return true;
}
return false;
},
runRequest: <TInput, TResult: RequestResult>(
subRequest: Request<TInput, TResult>,
opts?: RunRequestOpts,
): Promise<TResult> => {
subRequestContentKeys.add(subRequest.id);
return this.runRequest<TInput, TResult>(subRequest, opts);
},
};
return {api, subRequestContentKeys};
}
async writeToCache(signal?: AbortSignal) {
let cacheKey = getCacheKey(this.options);
let requestGraphKey = `${cacheKey}-RequestGraph`;
let snapshotKey = `snapshot-${cacheKey}`;
if (this.options.shouldDisableCache) {
return;
}
let keys = [requestGraphKey];
let promises = [];
for (let node of this.graph.nodes) {
if (!node || node.type !== REQUEST) {
continue;
}
let resultCacheKey = node.resultCacheKey;
if (resultCacheKey != null && node.result != null) {
keys.push(resultCacheKey);
promises.push(
this.options.cache.setLargeBlob(
resultCacheKey,
serialize(node.result),
{signal},
),
);
delete node.result;
}
}
promises.push(
this.options.cache.setLargeBlob(requestGraphKey, serialize(this.graph), {
signal,
}),
);
let opts = getWatcherOptions(this.options);
let snapshotPath = path.join(this.options.cacheDir, snapshotKey + '.txt');
promises.push(
this.options.inputFS.writeSnapshot(
this.options.watchDir,
snapshotPath,
opts,
),
);
try {
await Promise.all(promises);
} catch (err) {
if (signal?.aborted) {
// If writing to the cache was aborted, delete all of the keys to avoid inconsistent states.
for (let key of keys) {
try {
await this.options.cache.deleteLargeBlob(key);
} catch (err) {
// ignore.
}
}
} else {
throw err;
}
}
}
static async init({
farm,
options,
}: {|
farm: WorkerFarm,
options: ParcelOptions,
|}): Async<RequestTracker> {
let graph = await loadRequestGraph(options);
return new RequestTracker({farm, graph, options});
}
}
export function getWatcherOptions({
watchIgnore = [],
cacheDir,
watchDir,
watchBackend,
}: ParcelOptions): WatcherOptions {
const vcsDirs = ['.git', '.hg'];
const uniqueDirs = [...new Set([...watchIgnore, ...vcsDirs, cacheDir])];
const ignore = uniqueDirs.map(dir => path.resolve(watchDir, dir));
return {ignore, backend: watchBackend};
}
function getCacheKey(options) {
return hashString(
`${PARCEL_VERSION}:${JSON.stringify(options.entries)}:${options.mode}:${
options.shouldBuildLazily ? 'lazy' : 'eager'
}:${options.watchBackend ?? ''}`,
);
}
async function loadRequestGraph(options): Async<RequestGraph> {
if (options.shouldDisableCache) {
return new RequestGraph();
}
let cacheKey = getCacheKey(options);
let requestGraphKey = `${cacheKey}-RequestGraph`;
const snapshotKey = `snapshot-${cacheKey}`;
const snapshotPath = path.join(options.cacheDir, snapshotKey + '.txt');
if (await options.cache.hasLargeBlob(requestGraphKey)) {
try {
let requestGraph: RequestGraph = deserialize(
await options.cache.getLargeBlob(requestGraphKey),
);
let opts = getWatcherOptions(options);
let events = await options.inputFS.getEventsSince(
options.watchDir,
snapshotPath,
opts,
);
requestGraph.invalidateUnpredictableNodes();
requestGraph.invalidateOnBuildNodes();
requestGraph.invalidateEnvNodes(options.env);
requestGraph.invalidateOptionNodes(options);
await requestGraph.respondToFSEvents(
options.unstableFileInvalidations || events,
options,
10000,
);
return requestGraph;
} catch (e) {
// Prevent logging fs events took too long warning
logErrorOnBailout(options, snapshotPath, e);
// This error means respondToFSEvents timed out handling the invalidation events
// In this case we'll return a fresh RequestGraph
return new RequestGraph();
}
}
return new RequestGraph();
}
function logErrorOnBailout(
options: ParcelOptions,
snapshotPath: string,
e: Error,
): void {
if (e.message && e.message.includes('invalid clockspec')) {
const snapshotContents = options.inputFS.readFileSync(
snapshotPath,
'utf-8',
);
logger.warn({
origin: '@parcel/core',
message: `Error reading clockspec from snapshot, building with clean cache.`,
meta: {
snapshotContents: snapshotContents,
trackableEvent: 'invalid_clockspec_error',
},
});
} else if (!(e instanceof FSBailoutError)) {
logger.warn({
origin: '@parcel/core',
message: `Unexpected error loading cache from disk, building with clean cache.`,
meta: {
errorMessage: e.message,
errorStack: e.stack,
trackableEvent: 'cache_load_error',
},
});
}
}