forked from prebid/Prebid.js
-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathgulp.cache.js
More file actions
355 lines (335 loc) · 12.8 KB
/
Copy pathgulp.cache.js
File metadata and controls
355 lines (335 loc) · 12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
/**
* How the build is keyed on disk, and the on-disk cache for gulp pipelines whose transform is a
* pure function of each file.
*
* The cache exists so that repeat builds are cheap; nothing else in the build - and nobody
* running it - needs to know it is there. There are no flags, and no hygiene rules: see
* `cachedPipeline` for the one precondition a wrapped transform has to meet.
*
* This module owns `precompilationKey`, the answer to "what, other than a source file's own
* contents, does the output depend on". Everything that caches anything derived from the sources
* keys on it - `cachedPipeline` here, and the webpack configurations, which cache on disk too.
* Define it once, here, rather than reconstructing it wherever a cache is configured.
*/
const {createHash} = require('crypto');
const fs = require('fs');
const path = require('path');
const {Transform, PassThrough} = require('node:stream');
const mergeStream = require('merge-stream');
const {argv} = require('yargs');
const helpers = require('./gulpHelpers.js');
const PluginError = require('plugin-error');
const PLUGIN = 'gulp.cache';
const CACHE_ROOT = path.resolve(__dirname, '.cache');
// Bump when the layout or contents of a cache entry change, so that entries written by an
// older version of this file are never read back by a newer one.
const ENTRY_FORMAT = 1;
const SUFFIX = '.pbcache';
// carries the cache location of an in-flight miss across the transform. A transform that
// replaces (rather than mutates) the vinyl file drops it, and `store` then fails loudly.
const PENDING = 'precompileCacheEntry';
/**
* Directory that holds the cached output for one configuration.
*
* The key is hashed rather than used verbatim: it holds URLs and an unsorted feature list,
* neither of which makes a usable path segment.
*/
function cacheDir(namespace, key) {
const digest = createHash('sha256').update(`${ENTRY_FORMAT} ${key}`).digest('hex').slice(0, 16);
return path.join(CACHE_ROOT, namespace, digest);
}
function contentHash(contents) {
return createHash('sha256').update(contents).digest('hex');
}
/**
* Everything the babel plugins read that is not the file being transformed.
*
* `plugins/pbjsGlobals.js` substitutes `package.json`'s version into the output, and
* `plugins/callerContext.js` and `plugins/gvlPurposes.js` read `metadata/modules/*.json`. A change
* to either changes what is emitted while leaving every source file's own content hash untouched,
* so both belong in the key - without them a release build on a warm cache emits the previous
* version number.
*
* Digested whole, and deliberately bluntly. Keying on just the fields in use today would
* invalidate less often, but it would also quietly stop covering a plugin that starts reading
* something else, and that failure is silent. Most changes touch neither of these, so
* over-invalidating costs little and under-invalidating costs a wrong build.
*/
const EXTERNAL_INPUTS = {
manifest: path.resolve(__dirname, 'package.json'),
metadataDir: path.resolve(__dirname, 'metadata/modules')
};
function externalInputsDigest({manifest, metadataDir} = EXTERNAL_INPUTS) {
const digest = createHash('sha256');
digest.update(fs.readFileSync(manifest));
let names;
try {
names = fs.readdirSync(metadataDir).filter(name => name.endsWith('.json')).sort();
} catch {
names = [];
}
for (const name of names) {
// the name as well as the contents, so that a rename is a change
digest.update(name);
digest.update(fs.readFileSync(path.join(metadataDir, name)));
}
return digest.digest('hex').slice(0, 16);
}
function getDefaults({distUrlBase = null, disableFeatures = null, dev = false} = {}) {
if (dev && distUrlBase == null) {
distUrlBase = argv.distUrlBase || '/build/dev/'
}
return {
disableFeatures: disableFeatures ?? helpers.getDisabledFeatures(),
distUrlBase: distUrlBase ?? argv.distUrlBase,
dev,
polyfills: argv.polyfills
}
}
/**
* Everything other than a source file's own contents that the precompiled output depends on.
*
* This is what `babelPrecomp` memoizes on, what names its cache directory, and what the webpack
* caches are versioned by, so that none of those can drift apart. It resolves the options first -
* `disableFeatures`, `distUrlBase` and `polyfills` all default from `argv`, so unresolved options
* tell you nothing about the output - and it takes whatever `getDefaults` returns rather than a
* fixed list of fields, so that a field added there is picked up here. On top of the options it
* folds in what the babel plugins read without being handed it - the environment they consult, and
* `externalInputsDigest` for the files.
*
* Feature lists arrive from `argv` in whatever order they were typed; sort so that the key is
* stable across orderings that mean the same thing.
*/
function precompilationKey(options = {}) {
const resolved = {
...getDefaults(options),
// read by plugins/pbjsGlobals.js, and so part of the output
liveConnectMode: process.env.LiveConnectMode ?? null,
externalInputs: externalInputsDigest()
};
resolved.disableFeatures = [...(resolved.disableFeatures ?? [])].sort();
return JSON.stringify(Object.fromEntries(
Object.entries(resolved).sort(([a], [b]) => a < b ? -1 : 1)
));
}
const STAMP = '.precompilation-key';
/**
* Record, in the precompiled tree, which configuration produced it.
*
* The webpack configurations that compile that tree cache on disk, and the tree itself gives them
* nothing to tell one configuration from another: the paths are the same, and `gulp.dest` carries
* each source file's mtime over, so the timestamps are the same too. Left unkeyed, webpack serves
* modules cached from a build of a different feature set.
*
* Asking the tree what it is beats reconstructing the key from `argv`, which does not see the
* options that `test-all-features-disabled` and `serve-and-test` pass directly.
*/
function writePrecompilationKey(key) {
const file = helpers.getPrecompiledPath(STAMP);
fs.mkdirSync(path.dirname(file), {recursive: true});
fs.writeFileSync(file, key);
}
/** What the precompiled tree was built with, or null if it is absent or predates the stamp. */
function readPrecompilationKey() {
try {
return fs.readFileSync(helpers.getPrecompiledPath(STAMP), 'utf8');
} catch {
return null;
}
}
const created = new Set();
function mkdirOnce(dir) {
if (!created.has(dir)) {
fs.mkdirSync(dir, {recursive: true});
created.add(dir);
}
}
/**
* Cache entries are keyed on the *source* path, which is what the source glob gives us at
* lookup time. The transform's output path is recorded in the entry instead, so that this
* file never has to replicate a transform's renaming rules.
*/
function entryFor(dir, relative) {
const file = `${path.join(dir, relative)}${SUFFIX}`;
if (path.relative(dir, file).startsWith('..')) {
// a source outside the pipeline's base cannot be given a stable location under `dir`
return null;
}
return file;
}
/**
* An entry is one file: a single line of JSON describing what was transformed, then the
* transform's output verbatim. One file rather than two so that the description and the output
* it describes cannot be written, or read, out of step with one another - and the output is
* stored as-is rather than encoded into the JSON, so that reading a hit costs no more than
* reading the file.
*/
function load(entry, hash) {
let raw;
try {
raw = fs.readFileSync(entry);
} catch {
return null;
}
const split = raw.indexOf(0x0a);
if (split < 0) {
return null;
}
let meta;
try {
meta = JSON.parse(raw.subarray(0, split).toString());
} catch {
return null;
}
if (meta == null || meta.hash !== hash || typeof meta.path !== 'string') {
return null;
}
return {contents: raw.subarray(split + 1), meta};
}
/**
* Written to a temporary name and renamed into place, so that an interrupted or concurrent
* build can leave an entry missing but never leave one half-written.
*/
function save(entry, hash, file) {
mkdirOnce(path.dirname(entry));
const header = JSON.stringify({
hash,
path: file.relative,
sourceMap: file.sourceMap ?? null
});
// JSON.stringify escapes newlines, so the header is always exactly one line
const tmp = `${entry}.${process.pid}.tmp`;
fs.writeFileSync(tmp, Buffer.concat([Buffer.from(`${header}\n`), file.contents]));
fs.renameSync(tmp, entry);
}
/**
* Split the incoming files into cache hits - which are filled in from disk and sent straight
* on to `dest` - and misses, which are passed downstream to the transform.
*/
function lookup(dir, hits) {
return new Transform({
objectMode: true,
transform(file, enc, cb) {
const entry = file.isBuffer() ? entryFor(dir, file.relative) : null;
if (entry == null) {
// nothing to look up and nothing to store; `null` marks it as intentional, so that
// `store` can tell it apart from a file it has never seen
file[PENDING] = null;
cb(null, file);
return;
}
const hash = contentHash(file.contents);
let cached;
try {
cached = load(entry, hash);
} catch (e) {
cb(new PluginError(PLUGIN, e, {fileName: file.path}));
return;
}
if (cached == null) {
file[PENDING] = {entry, hash};
cb(null, file);
return;
}
file.contents = cached.contents;
file.path = path.resolve(file.base, cached.meta.path);
if (cached.meta.sourceMap == null) {
delete file.sourceMap;
} else {
file.sourceMap = cached.meta.sourceMap;
}
if (hits.write(file)) {
cb();
} else {
hits.once('drain', cb);
}
},
flush(cb) {
hits.end();
cb();
}
});
}
/** Write what the transform produced into the cache, on the way to `dest`. */
function store() {
return new Transform({
objectMode: true,
transform(file, enc, cb) {
const pending = file[PENDING];
if (pending === undefined) {
cb(new PluginError(PLUGIN, new Error(
`'${file.relative}' did not come out of the file it went in as. A cached transform must ` +
`map each file to exactly one file, and must mutate it rather than replace it.`
), {fileName: file.path}));
return;
}
delete file[PENDING];
if (pending == null) {
cb(null, file);
return;
}
try {
save(pending.entry, pending.hash, file);
} catch (e) {
cb(new PluginError(PLUGIN, e, {fileName: file.path}));
return;
}
cb(null, file);
}
});
}
function forwardErrors(out, streams) {
let failed = false;
streams.forEach(stream => stream.on('error', err => {
if (!failed) {
failed = true;
out.emit('error', err);
}
}));
}
/**
* Run `src` through `transform` into `dest`, transpiling only the files whose output is not
* already on disk from a previous run.
*
* The transform must be a pure function of each file's contents and path - those two are all
* the cache keys on, together with `key`. No filesystem reads, no global state, one file out
* for each file in. A transform that breaks this serves stale output silently - which is why
* `babelPrecomp` does not use this helper for `--polyfills` builds, where the babel plugin
* aggregates across every file it sees.
*
* `key` identifies the configuration the transform was built with; output from different
* configurations is stored separately and never mixes.
*
* @param {string} namespace subdirectory of `.cache` to store output under
* @param {NodeJS.ReadableStream} src the source files
* @param {string} key everything other than a file's own contents that the transform's
* output depends on
* @param {function(): NodeJS.ReadWriteStream|NodeJS.ReadWriteStream[]} transform thunk
* returning the transform to run on cache misses (or the stages of it, to be piped in
* order). A thunk both because each run needs a fresh stream, and because the helper - not
* the caller - decides which files reach it
* @param {NodeJS.WritableStream} dest where both cached and freshly transformed files go
* @returns {NodeJS.ReadWriteStream} the pipeline, to be returned from a gulp task
*/
function cachedPipeline({namespace, src, key, transform, dest}) {
const dir = cacheDir(namespace, key);
const hits = new PassThrough({objectMode: true});
const stages = [lookup(dir, hits)].concat(transform(), store());
const misses = stages.reduce((from, to) => from.pipe(to));
const merged = mergeStream(misses, hits);
const out = merged.pipe(dest);
// `pipe` does not carry errors downstream, so a failure in any stage would otherwise go
// unhandled instead of failing the task
forwardErrors(out, [src, ...stages, merged]);
src.pipe(stages[0]);
return out;
}
module.exports = {
cachedPipeline,
externalInputsDigest,
getDefaults,
precompilationKey,
writePrecompilationKey,
readPrecompilationKey,
CACHE_ROOT
};