@parcel/core
Version:
790 lines (722 loc) • 22.6 kB
JavaScript
// @flow strict-local
import type {
Blob,
FilePath,
BundleResult,
Bundle as BundleType,
BundleGraph as BundleGraphType,
NamedBundle as NamedBundleType,
Async,
} from '@parcel/types';
import type SourceMap from '@parcel/source-map';
import type {
Bundle as InternalBundle,
Config,
DevDepRequest,
ParcelOptions,
ReportFn,
RequestInvalidation,
} from './types';
import type ParcelConfig, {LoadedPlugin} from './ParcelConfig';
import type InternalBundleGraph from './BundleGraph';
import type {ConfigRequest} from './requests/ConfigRequest';
import type {DevDepSpecifier} from './requests/DevDepRequest';
import invariant from 'assert';
import {blobToStream, TapStream} from '@parcel/utils';
import {PluginLogger} from '@parcel/logger';
import ThrowableDiagnostic, {errorToDiagnostic} from '@parcel/diagnostic';
import {Readable} from 'stream';
import nullthrows from 'nullthrows';
import path from 'path';
import url from 'url';
import {hashString, hashBuffer, Hash} from '@parcel/hash';
import {NamedBundle, bundleToInternalBundle} from './public/Bundle';
import BundleGraph, {
bundleGraphToInternalBundleGraph,
} from './public/BundleGraph';
import PluginOptions from './public/PluginOptions';
import PublicConfig from './public/Config';
import {PARCEL_VERSION, HASH_REF_PREFIX, HASH_REF_REGEX} from './constants';
import {
fromProjectPath,
toProjectPathUnsafe,
fromProjectPathRelative,
joinProjectPath,
} from './projectPath';
import {createConfig} from './InternalConfig';
import {
loadPluginConfig,
getConfigHash,
getConfigRequests,
type PluginWithBundleConfig,
} from './requests/ConfigRequest';
import {
createDevDependency,
getWorkerDevDepRequests,
} from './requests/DevDepRequest';
import {createBuildCache} from './buildCache';
import {getInvalidationId, getInvalidationHash} from './assetUtils';
import {optionsProxy} from './utils';
import {invalidateDevDeps} from './requests/DevDepRequest';
import {tracer, PluginTracer} from '@parcel/profiler';
type Opts = {|
config: ParcelConfig,
options: ParcelOptions,
report: ReportFn,
previousDevDeps: Map<string, string>,
previousInvalidations: Array<RequestInvalidation>,
|};
export type PackageRequestResult = {|
bundleInfo: BundleInfo,
configRequests: Array<ConfigRequest>,
devDepRequests: Array<DevDepRequest>,
invalidations: Array<RequestInvalidation>,
|};
export type BundleInfo = {|
+type: string,
+size: number,
+hash: string,
+hashReferences: Array<string>,
+time?: number,
+cacheKeys: CacheKeyMap,
+isLargeBlob: boolean,
|};
type CacheKeyMap = {|
content: string,
map: string,
info: string,
|};
const BOUNDARY_LENGTH = HASH_REF_PREFIX.length + 32 - 1;
// Packager/optimizer configs are not bundle-specific, so we only need to
// load them once per build.
const pluginConfigs = createBuildCache();
export default class PackagerRunner {
config: ParcelConfig;
options: ParcelOptions;
pluginOptions: PluginOptions;
distDir: FilePath;
distExists: Set<FilePath>;
report: ReportFn;
previousDevDeps: Map<string, string>;
devDepRequests: Map<string, DevDepRequest>;
invalidations: Map<string, RequestInvalidation>;
previousInvalidations: Array<RequestInvalidation>;
constructor({
config,
options,
report,
previousDevDeps,
previousInvalidations,
}: Opts) {
this.config = config;
this.options = options;
this.report = report;
this.previousDevDeps = previousDevDeps;
this.devDepRequests = new Map();
this.previousInvalidations = previousInvalidations;
this.invalidations = new Map();
this.pluginOptions = new PluginOptions(
optionsProxy(this.options, option => {
let invalidation: RequestInvalidation = {
type: 'option',
key: option,
};
this.invalidations.set(getInvalidationId(invalidation), invalidation);
}),
);
}
async run(
bundleGraph: InternalBundleGraph,
bundle: InternalBundle,
invalidDevDeps: Array<DevDepSpecifier>,
): Promise<PackageRequestResult> {
invalidateDevDeps(invalidDevDeps, this.options, this.config);
let {configs, bundleConfigs} = await this.loadConfigs(bundleGraph, bundle);
let bundleInfo =
(await this.getBundleInfoFromCache(
bundleGraph,
bundle,
configs,
bundleConfigs,
)) ??
(await this.getBundleInfo(bundle, bundleGraph, configs, bundleConfigs));
let configRequests = getConfigRequests([
...configs.values(),
...bundleConfigs.values(),
]);
let devDepRequests = getWorkerDevDepRequests([
...this.devDepRequests.values(),
]);
return {
bundleInfo,
configRequests,
devDepRequests,
invalidations: [...this.invalidations.values()],
};
}
async loadConfigs(
bundleGraph: InternalBundleGraph,
bundle: InternalBundle,
): Promise<{|
configs: Map<string, Config>,
bundleConfigs: Map<string, Config>,
|}> {
let configs = new Map();
let bundleConfigs = new Map();
await this.loadConfig(bundleGraph, bundle, configs, bundleConfigs);
for (let inlineBundle of bundleGraph.getInlineBundles(bundle)) {
await this.loadConfig(bundleGraph, inlineBundle, configs, bundleConfigs);
}
return {configs, bundleConfigs};
}
async loadConfig(
bundleGraph: InternalBundleGraph,
bundle: InternalBundle,
configs: Map<string, Config>,
bundleConfigs: Map<string, Config>,
): Promise<void> {
let name = nullthrows(bundle.name);
let plugin = await this.config.getPackager(name);
await this.loadPluginConfig(
bundleGraph,
bundle,
plugin,
configs,
bundleConfigs,
);
let optimizers = await this.config.getOptimizers(name, bundle.pipeline);
for (let optimizer of optimizers) {
await this.loadPluginConfig(
bundleGraph,
bundle,
optimizer,
configs,
bundleConfigs,
);
}
}
async loadPluginConfig<T: PluginWithBundleConfig>(
bundleGraph: InternalBundleGraph,
bundle: InternalBundle,
plugin: LoadedPlugin<T>,
configs: Map<string, Config>,
bundleConfigs: Map<string, Config>,
): Promise<void> {
if (!configs.has(plugin.name)) {
// Only load config for a plugin once per build.
let existing = pluginConfigs.get(plugin.name);
if (existing != null) {
configs.set(plugin.name, existing);
} else {
if (plugin.plugin.loadConfig != null) {
let config = createConfig({
plugin: plugin.name,
searchPath: toProjectPathUnsafe('index'),
});
await loadPluginConfig(plugin, config, this.options);
for (let devDep of config.devDeps) {
let devDepRequest = await createDevDependency(
devDep,
this.previousDevDeps,
this.options,
);
let key = `${devDep.specifier}:${fromProjectPath(
this.options.projectRoot,
devDep.resolveFrom,
)}`;
this.devDepRequests.set(key, devDepRequest);
}
pluginConfigs.set(plugin.name, config);
configs.set(plugin.name, config);
}
}
}
let loadBundleConfig = plugin.plugin.loadBundleConfig;
if (!bundleConfigs.has(plugin.name) && loadBundleConfig != null) {
let config = createConfig({
plugin: plugin.name,
searchPath: joinProjectPath(
bundle.target.distDir,
bundle.name ?? bundle.id,
),
});
config.result = await loadBundleConfig({
bundle: NamedBundle.get(bundle, bundleGraph, this.options),
bundleGraph: new BundleGraph<NamedBundleType>(
bundleGraph,
NamedBundle.get.bind(NamedBundle),
this.options,
),
config: new PublicConfig(config, this.options),
options: new PluginOptions(this.options),
logger: new PluginLogger({origin: plugin.name}),
tracer: new PluginTracer({origin: plugin.name, category: 'loadConfig'}),
});
bundleConfigs.set(plugin.name, config);
}
}
async getBundleInfoFromCache(
bundleGraph: InternalBundleGraph,
bundle: InternalBundle,
configs: Map<string, Config>,
bundleConfigs: Map<string, Config>,
): Async<?BundleInfo> {
if (this.options.shouldDisableCache) {
return;
}
let cacheKey = await this.getCacheKey(
bundle,
bundleGraph,
configs,
bundleConfigs,
this.previousInvalidations,
);
let infoKey = PackagerRunner.getInfoKey(cacheKey);
return this.options.cache.get<BundleInfo>(infoKey);
}
async getBundleInfo(
bundle: InternalBundle,
bundleGraph: InternalBundleGraph,
configs: Map<string, Config>,
bundleConfigs: Map<string, Config>,
): Promise<BundleInfo> {
let {type, contents, map} = await this.getBundleResult(
bundle,
bundleGraph,
configs,
bundleConfigs,
);
// Recompute cache keys as they may have changed due to dev dependencies.
let cacheKey = await this.getCacheKey(
bundle,
bundleGraph,
configs,
bundleConfigs,
[...this.invalidations.values()],
);
let cacheKeys = {
content: PackagerRunner.getContentKey(cacheKey),
map: PackagerRunner.getMapKey(cacheKey),
info: PackagerRunner.getInfoKey(cacheKey),
};
return this.writeToCache(cacheKeys, type, contents, map);
}
async getBundleResult(
bundle: InternalBundle,
bundleGraph: InternalBundleGraph,
configs: Map<string, Config>,
bundleConfigs: Map<string, Config>,
): Promise<{|
type: string,
contents: Blob,
map: ?string,
|}> {
let packaged = await this.package(
bundle,
bundleGraph,
configs,
bundleConfigs,
);
let type = packaged.type ?? bundle.type;
let res = await this.optimize(
bundle,
bundleGraph,
type,
packaged.contents,
packaged.map,
configs,
bundleConfigs,
);
let map =
res.map != null ? await this.generateSourceMap(bundle, res.map) : null;
return {
type: res.type ?? type,
contents: res.contents,
map,
};
}
getSourceMapReference(bundle: NamedBundle, map: ?SourceMap): Async<?string> {
if (map && bundle.env.sourceMap && bundle.bundleBehavior !== 'inline') {
if (bundle.env.sourceMap && bundle.env.sourceMap.inline) {
return this.generateSourceMap(bundleToInternalBundle(bundle), map);
} else {
return path.basename(bundle.name) + '.map';
}
} else {
return null;
}
}
async package(
internalBundle: InternalBundle,
bundleGraph: InternalBundleGraph,
configs: Map<string, Config>,
bundleConfigs: Map<string, Config>,
): Promise<BundleResult> {
let bundle = NamedBundle.get(internalBundle, bundleGraph, this.options);
this.report({
type: 'buildProgress',
phase: 'packaging',
bundle,
});
let packager = await this.config.getPackager(bundle.name);
let {name, resolveFrom, plugin} = packager;
let measurement;
try {
measurement = tracer.createMeasurement(name, 'packaging', bundle.name, {
type: bundle.type,
});
return await plugin.package({
config: configs.get(name)?.result,
bundleConfig: bundleConfigs.get(name)?.result,
bundle,
bundleGraph: new BundleGraph<NamedBundleType>(
bundleGraph,
NamedBundle.get.bind(NamedBundle),
this.options,
),
getSourceMapReference: map => {
return this.getSourceMapReference(bundle, map);
},
options: this.pluginOptions,
logger: new PluginLogger({origin: name}),
tracer: new PluginTracer({origin: name, category: 'package'}),
getInlineBundleContents: async (
bundle: BundleType,
bundleGraph: BundleGraphType<NamedBundleType>,
) => {
if (bundle.bundleBehavior !== 'inline') {
throw new Error(
'Bundle is not inline and unable to retrieve contents',
);
}
let res = await this.getBundleResult(
bundleToInternalBundle(bundle),
// $FlowFixMe
bundleGraphToInternalBundleGraph(bundleGraph),
configs,
bundleConfigs,
);
return {contents: res.contents};
},
});
} catch (e) {
throw new ThrowableDiagnostic({
diagnostic: errorToDiagnostic(e, {
origin: name,
filePath: path.join(bundle.target.distDir, bundle.name),
}),
});
} finally {
measurement && measurement.end();
// Add dev dependency for the packager. This must be done AFTER running it due to
// the potential for lazy require() that aren't executed until the request runs.
let devDepRequest = await createDevDependency(
{
specifier: name,
resolveFrom,
},
this.previousDevDeps,
this.options,
);
this.devDepRequests.set(
`${name}:${fromProjectPathRelative(resolveFrom)}`,
devDepRequest,
);
}
}
async optimize(
internalBundle: InternalBundle,
internalBundleGraph: InternalBundleGraph,
type: string,
contents: Blob,
map?: ?SourceMap,
configs: Map<string, Config>,
bundleConfigs: Map<string, Config>,
): Promise<BundleResult> {
let bundle = NamedBundle.get(
internalBundle,
internalBundleGraph,
this.options,
);
let bundleGraph = new BundleGraph<NamedBundleType>(
internalBundleGraph,
NamedBundle.get.bind(NamedBundle),
this.options,
);
let optimizers = await this.config.getOptimizers(
bundle.name,
internalBundle.pipeline,
);
if (!optimizers.length) {
return {type: bundle.type, contents, map};
}
this.report({
type: 'buildProgress',
phase: 'optimizing',
bundle,
});
let optimized = {
type,
contents,
map,
};
for (let optimizer of optimizers) {
let measurement;
try {
measurement = tracer.createMeasurement(
optimizer.name,
'optimize',
bundle.name,
);
let next = await optimizer.plugin.optimize({
config: configs.get(optimizer.name)?.result,
bundleConfig: bundleConfigs.get(optimizer.name)?.result,
bundle,
bundleGraph,
contents: optimized.contents,
map: optimized.map,
getSourceMapReference: map => {
return this.getSourceMapReference(bundle, map);
},
options: this.pluginOptions,
logger: new PluginLogger({origin: optimizer.name}),
tracer: new PluginTracer({
origin: optimizer.name,
category: 'optimize',
}),
});
optimized.type = next.type ?? optimized.type;
optimized.contents = next.contents;
optimized.map = next.map;
} catch (e) {
throw new ThrowableDiagnostic({
diagnostic: errorToDiagnostic(e, {
origin: optimizer.name,
filePath: path.join(bundle.target.distDir, bundle.name),
}),
});
} finally {
measurement && measurement.end();
// Add dev dependency for the optimizer. This must be done AFTER running it due to
// the potential for lazy require() that aren't executed until the request runs.
let devDepRequest = await createDevDependency(
{
specifier: optimizer.name,
resolveFrom: optimizer.resolveFrom,
},
this.previousDevDeps,
this.options,
);
this.devDepRequests.set(
`${optimizer.name}:${fromProjectPathRelative(optimizer.resolveFrom)}`,
devDepRequest,
);
}
}
return optimized;
}
async generateSourceMap(
bundle: InternalBundle,
map: SourceMap,
): Promise<string> {
// sourceRoot should be a relative path between outDir and rootDir for node.js targets
let filePath = joinProjectPath(
bundle.target.distDir,
nullthrows(bundle.name),
);
let fullPath = fromProjectPath(this.options.projectRoot, filePath);
let sourceRoot: string = path.relative(
path.dirname(fullPath),
this.options.projectRoot,
);
let inlineSources = false;
if (bundle.target) {
if (
bundle.env.sourceMap &&
bundle.env.sourceMap.sourceRoot !== undefined
) {
sourceRoot = bundle.env.sourceMap.sourceRoot;
} else if (
this.options.serveOptions &&
bundle.target.env.context === 'browser'
) {
sourceRoot = '/__parcel_source_root';
}
if (
bundle.env.sourceMap &&
bundle.env.sourceMap.inlineSources !== undefined
) {
inlineSources = bundle.env.sourceMap.inlineSources;
} else if (bundle.target.env.context !== 'node') {
// inlining should only happen in production for browser targets by default
inlineSources = this.options.mode === 'production';
}
}
let mapFilename = fullPath + '.map';
let isInlineMap = bundle.env.sourceMap && bundle.env.sourceMap.inline;
let stringified = await map.stringify({
file: path.basename(mapFilename),
// $FlowFixMe
fs: this.options.inputFS,
rootDir: this.options.projectRoot,
sourceRoot: !inlineSources
? url.format(url.parse(sourceRoot + '/'))
: undefined,
inlineSources,
format: isInlineMap ? 'inline' : 'string',
});
invariant(typeof stringified === 'string');
return stringified;
}
async getCacheKey(
bundle: InternalBundle,
bundleGraph: InternalBundleGraph,
configs: Map<string, Config>,
bundleConfigs: Map<string, Config>,
invalidations: Array<RequestInvalidation>,
): Promise<string> {
let configResults = {};
for (let [pluginName, config] of configs) {
if (config) {
configResults[pluginName] = await getConfigHash(
config,
pluginName,
this.options,
);
}
}
let globalInfoResults = {};
for (let [pluginName, config] of bundleConfigs) {
if (config) {
globalInfoResults[pluginName] = await getConfigHash(
config,
pluginName,
this.options,
);
}
}
let devDepHashes = await this.getDevDepHashes(bundle);
for (let inlineBundle of bundleGraph.getInlineBundles(bundle)) {
devDepHashes += await this.getDevDepHashes(inlineBundle);
}
let invalidationHash = await getInvalidationHash(
invalidations,
this.options,
);
return hashString(
PARCEL_VERSION +
devDepHashes +
invalidationHash +
bundle.target.publicUrl +
bundleGraph.getHash(bundle) +
JSON.stringify(configResults) +
JSON.stringify(globalInfoResults) +
this.options.mode,
);
}
async getDevDepHashes(bundle: InternalBundle): Promise<string> {
let name = nullthrows(bundle.name);
let packager = await this.config.getPackager(name);
let optimizers = await this.config.getOptimizers(name);
let key = `${packager.name}:${fromProjectPathRelative(
packager.resolveFrom,
)}`;
let devDepHashes =
this.devDepRequests.get(key)?.hash ?? this.previousDevDeps.get(key) ?? '';
for (let {name, resolveFrom} of optimizers) {
let key = `${name}:${fromProjectPathRelative(resolveFrom)}`;
devDepHashes +=
this.devDepRequests.get(key)?.hash ??
this.previousDevDeps.get(key) ??
'';
}
return devDepHashes;
}
async readFromCache(cacheKey: string): Promise<?{|
contents: Readable,
map: ?Readable,
|}> {
let contentKey = PackagerRunner.getContentKey(cacheKey);
let mapKey = PackagerRunner.getMapKey(cacheKey);
let isLargeBlob = await this.options.cache.hasLargeBlob(contentKey);
let contentExists =
isLargeBlob || (await this.options.cache.has(contentKey));
if (!contentExists) {
return null;
}
let mapExists = await this.options.cache.has(mapKey);
return {
contents: isLargeBlob
? this.options.cache.getStream(contentKey)
: blobToStream(await this.options.cache.getBlob(contentKey)),
map: mapExists
? blobToStream(await this.options.cache.getBlob(mapKey))
: null,
};
}
async writeToCache(
cacheKeys: CacheKeyMap,
type: string,
contents: Blob,
map: ?string,
): Promise<BundleInfo> {
let size = 0;
let hash;
let hashReferences = [];
let isLargeBlob = false;
// TODO: don't replace hash references in binary files??
if (contents instanceof Readable) {
isLargeBlob = true;
let boundaryStr = '';
let h = new Hash();
await this.options.cache.setStream(
cacheKeys.content,
blobToStream(contents).pipe(
new TapStream(buf => {
let str = boundaryStr + buf.toString();
hashReferences = hashReferences.concat(
str.match(HASH_REF_REGEX) ?? [],
);
size += buf.length;
h.writeBuffer(buf);
boundaryStr = str.slice(str.length - BOUNDARY_LENGTH);
}),
),
);
hash = h.finish();
} else if (typeof contents === 'string') {
let buffer = Buffer.from(contents);
size = buffer.byteLength;
hash = hashBuffer(buffer);
hashReferences = contents.match(HASH_REF_REGEX) ?? [];
await this.options.cache.setBlob(cacheKeys.content, buffer);
} else {
size = contents.length;
hash = hashBuffer(contents);
hashReferences = contents.toString().match(HASH_REF_REGEX) ?? [];
await this.options.cache.setBlob(cacheKeys.content, contents);
}
if (map != null) {
await this.options.cache.setBlob(cacheKeys.map, map);
}
let info = {
type,
size,
hash,
hashReferences,
cacheKeys,
isLargeBlob,
};
await this.options.cache.set(cacheKeys.info, info);
return info;
}
static getContentKey(cacheKey: string): string {
return hashString(`${cacheKey}:content`);
}
static getMapKey(cacheKey: string): string {
return hashString(`${cacheKey}:map`);
}
static getInfoKey(cacheKey: string): string {
return hashString(`${cacheKey}:info`);
}
}