Skip to content

Commit aaf85db

Browse files
committed
add output.futureEmitAssets
add a new version of emitting assets which allows to free memory of Sources with the trade-off of disallowing reading asset content after emitting It also uses Source.buffer when available.
1 parent 03ffa48 commit aaf85db

File tree

5 files changed

+144
-13
lines changed

5 files changed

+144
-13
lines changed

declarations/WebpackOptions.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,6 +1051,10 @@ export interface OutputOptions {
10511051
* Specifies the name of each output file on disk. You must **not** specify an absolute path here! The `output.path` option determines the location on disk the files are written to, filename is used solely for naming the individual files.
10521052
*/
10531053
filename?: string | Function;
1054+
/**
1055+
* Use the future version of asset emitting logic, which is allows freeing memory of assets after emitting. It could break plugins which assume that assets are still readable after emitting. Will be the new default in the next major version.
1056+
*/
1057+
futureEmitAssets?: boolean;
10541058
/**
10551059
* An expression which is used to address the global object/scope in runtime code
10561060
*/

lib/Compilation.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,8 @@ class Compilation extends Tapable {
491491
this._buildingModules = new Map();
492492
/** @private @type {Map<Module, Callback[]>} */
493493
this._rebuildingModules = new Map();
494+
/** @type {Set<string>} */
495+
this.emittedAssets = new Set();
494496
}
495497

496498
getStats() {

lib/Compiler.js

Lines changed: 130 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
const parseJson = require("json-parse-better-errors");
88
const asyncLib = require("neo-async");
99
const path = require("path");
10+
const { Source } = require("webpack-sources");
1011
const util = require("util");
1112
const {
1213
Tapable,
@@ -188,6 +189,11 @@ class Compiler extends Tapable {
188189

189190
/** @type {boolean} */
190191
this.watchMode = false;
192+
193+
/** @private @type {WeakMap<Source, { sizeOnlySource: SizeOnlySource, writtenTo: Map<string, number> }>} */
194+
this._assetEmittingSourceCache = new WeakMap();
195+
/** @private @type {Map<string, number>} */
196+
this._assetEmittingWrittenFiles = new Map();
191197
}
192198

193199
watch(watchOptions, handler) {
@@ -328,19 +334,86 @@ class Compiler extends Tapable {
328334
outputPath,
329335
targetFile
330336
);
331-
if (source.existsAt === targetPath) {
332-
source.emitted = false;
333-
return callback();
334-
}
335-
let content = source.source();
336-
337-
if (!Buffer.isBuffer(content)) {
338-
content = Buffer.from(content, "utf8");
337+
// TODO webpack 5 remove futureEmitAssets option and make it on by default
338+
if (this.options.output.futureEmitAssets) {
339+
// check if the target file has already been written by this Compiler
340+
const targetFileGeneration = this._assetEmittingWrittenFiles.get(
341+
targetPath
342+
);
343+
344+
// create an cache entry for this Source if not already existing
345+
let cacheEntry = this._assetEmittingSourceCache.get(source);
346+
if (cacheEntry === undefined) {
347+
cacheEntry = {
348+
sizeOnlySource: undefined,
349+
writtenTo: new Map()
350+
};
351+
this._assetEmittingSourceCache.set(source, cacheEntry);
352+
}
353+
354+
// if the target file has already been written
355+
if (targetFileGeneration !== undefined) {
356+
// check if the Source has been written to this target file
357+
const writtenGeneration = cacheEntry.writtenTo.get(targetPath);
358+
if (writtenGeneration === targetFileGeneration) {
359+
// if yes, we skip writing the file
360+
// as it's already there
361+
// (we assume one doesn't remove files while the Compiler is running)
362+
return callback();
363+
}
364+
}
365+
366+
// get the binary (Buffer) content from the Source
367+
/** @type {Buffer} */
368+
let content;
369+
if (source.buffer) {
370+
content = source.buffer();
371+
} else {
372+
const bufferOrString = source.source();
373+
if (Buffer.isBuffer(bufferOrString)) {
374+
content = bufferOrString;
375+
} else {
376+
content = Buffer.from(bufferOrString, "utf8");
377+
}
378+
}
379+
380+
// Create a replacement resource which only allows to ask for size
381+
// This allows to GC all memory allocated by the Source
382+
// (expect when the Source is stored in any other cache)
383+
cacheEntry.sizeOnlySource = new SizeOnlySource(content.length);
384+
compilation.assets[file] = cacheEntry.sizeOnlySource;
385+
386+
// Write the file to output file system
387+
this.outputFileSystem.writeFile(targetPath, content, err => {
388+
if (err) return callback(err);
389+
390+
// information marker that the asset has been emitted
391+
compilation.emittedAssets.add(file);
392+
393+
// cache the information that the Source has been written to that location
394+
const newGeneration =
395+
targetFileGeneration === undefined
396+
? 1
397+
: targetFileGeneration + 1;
398+
cacheEntry.writtenTo.set(targetPath, newGeneration);
399+
this._assetEmittingWrittenFiles.set(targetPath, newGeneration);
400+
callback();
401+
});
402+
} else {
403+
if (source.existsAt === targetPath) {
404+
source.emitted = false;
405+
return callback();
406+
}
407+
let content = source.source();
408+
409+
if (!Buffer.isBuffer(content)) {
410+
content = Buffer.from(content, "utf8");
411+
}
412+
413+
source.existsAt = targetPath;
414+
source.emitted = true;
415+
this.outputFileSystem.writeFile(targetPath, content, callback);
339416
}
340-
341-
source.existsAt = targetPath;
342-
source.emitted = true;
343-
this.outputFileSystem.writeFile(targetPath, content, callback);
344417
};
345418

346419
if (targetFile.match(/\/|\\/)) {
@@ -563,3 +636,48 @@ class Compiler extends Tapable {
563636
}
564637

565638
module.exports = Compiler;
639+
640+
class SizeOnlySource extends Source {
641+
constructor(size) {
642+
super();
643+
this._size = size;
644+
}
645+
646+
_error() {
647+
return new Error(
648+
"Content and Map of this Source is no longer available (only size() is supported)"
649+
);
650+
}
651+
652+
size() {
653+
return this._size;
654+
}
655+
656+
/**
657+
* @param {any} options options
658+
* @returns {string} the source
659+
*/
660+
source(options) {
661+
throw this._error();
662+
}
663+
664+
node() {
665+
throw this._error();
666+
}
667+
668+
listMap() {
669+
throw this._error();
670+
}
671+
672+
map() {
673+
throw this._error();
674+
}
675+
676+
listNode() {
677+
throw this._error();
678+
}
679+
680+
updateHash() {
681+
throw this._error();
682+
}
683+
}

lib/Stats.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,10 @@ class Stats {
400400
size: compilation.assets[asset].size(),
401401
chunks: [],
402402
chunkNames: [],
403-
emitted: compilation.assets[asset].emitted
403+
// TODO webpack 5: remove .emitted
404+
emitted:
405+
compilation.assets[asset].emitted ||
406+
compilation.emittedAssets.has(asset)
404407
};
405408

406409
if (showPerformance) {

schemas/WebpackOptions.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -867,6 +867,10 @@
867867
}
868868
]
869869
},
870+
"futureEmitAssets": {
871+
"description": "Use the future version of asset emitting logic, which is allows freeing memory of assets after emitting. It could break plugins which assume that assets are still readable after emitting. Will be the new default in the next major version.",
872+
"type": "boolean"
873+
},
870874
"globalObject": {
871875
"description": "An expression which is used to address the global object/scope in runtime code",
872876
"type": "string",

0 commit comments

Comments
 (0)