Skip to content

Commit 9c63469

Browse files
committed
feat: add buffers() method returning Buffer[]
Adds a new Source.prototype.buffers() method that returns the represented source code as an array of Buffers, avoiding the intermediate Buffer.concat allocation that buffer() performs on composite sources such as ConcatSource. Consumers that can accept Buffer[] (e.g. fs.createWriteStream backed by writev) can now keep data as Buffer[] across nested CachedSource/ConcatSource layers and write it out in one shot, which avoids repeatedly copying the same bytes. - Source: default buffers() returns [this.buffer()]. - ConcatSource: flattens child buffers() into a single Buffer[] without concatenating, and buffer() is now implemented via Buffer.concat(buffers()). - CachedSource: caches the Buffer[] separately from the Buffer; buffer() concatenates lazily when requested, so repeated buffers() calls do not trigger copies. - CompatSource: forwards to sourceLike.buffers() when available, otherwise falls back to the default. - SizeOnlySource: throws like buffer(). Closes #157
1 parent 2081a31 commit 9c63469

13 files changed

Lines changed: 206 additions & 3 deletions

.changeset/buffers-method.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack-sources": minor
3+
---
4+
5+
Add `Source.prototype.buffers()` that returns the source as `Buffer[]`. `ConcatSource`, `CachedSource`, and `CompatSource` implement it without allocating an intermediate concatenated buffer, allowing consumers that can write multiple buffers at once (e.g. via `writev`) to avoid the overhead of `Buffer.concat` in deeply nested sources.

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ Source.prototype.buffer() -> Buffer
2828

2929
Returns the represented source code as Buffer. Strings are converted to utf-8.
3030

31+
#### `buffers`
32+
33+
<!-- eslint-skip -->
34+
```typescript
35+
Source.prototype.buffers() -> Buffer[]
36+
```
37+
38+
Returns the represented source code as an array of Buffers. This avoids the
39+
intermediate `Buffer.concat` allocation performed by `buffer()` when the source
40+
is composed of multiple children (for example `ConcatSource`). Consumers that
41+
can accept an array of buffers (e.g. writing via `fs.createWriteStream` or
42+
`writev`) should prefer this method for better performance.
43+
44+
The default implementation returns `[this.buffer()]`.
45+
3146
#### `size`
3247

3348
<!-- eslint-skip -->

lib/CachedSource.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@ class CachedSource extends Source {
114114
* @type {Buffer | undefined}
115115
*/
116116
this._cachedBuffer = cachedData ? cachedData.buffer : undefined;
117+
/**
118+
* @private
119+
* @type {Buffer[] | undefined}
120+
*/
121+
this._cachedBuffers = undefined;
117122
/**
118123
* @private
119124
* @type {number | undefined}
@@ -228,6 +233,9 @@ class CachedSource extends Source {
228233
*/
229234
buffer() {
230235
if (this._cachedBuffer !== undefined) return this._cachedBuffer;
236+
if (this._cachedBuffers !== undefined) {
237+
return (this._cachedBuffer = Buffer.concat(this._cachedBuffers));
238+
}
231239
if (this._cachedSource !== undefined) {
232240
const value = Buffer.isBuffer(this._cachedSource)
233241
? this._cachedSource
@@ -251,6 +259,21 @@ class CachedSource extends Source {
251259
return value;
252260
}
253261

262+
/**
263+
* @returns {Buffer[]} buffers
264+
*/
265+
buffers() {
266+
if (this._cachedBuffers !== undefined) return this._cachedBuffers;
267+
if (this._cachedBuffer !== undefined) {
268+
return (this._cachedBuffers = [this._cachedBuffer]);
269+
}
270+
const original = this.original();
271+
if (typeof original.buffers === "function") {
272+
return (this._cachedBuffers = original.buffers());
273+
}
274+
return (this._cachedBuffers = [this.buffer()]);
275+
}
276+
254277
/**
255278
* @returns {number} size
256279
*/

lib/CompatSource.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const Source = require("./Source");
1717
* @typedef {object} SourceLike
1818
* @property {() => SourceValue} source source
1919
* @property {(() => Buffer)=} buffer buffer
20+
* @property {(() => Buffer[])=} buffers buffers
2021
* @property {(() => number)=} size size
2122
* @property {((options?: MapOptions) => RawSourceMap | null)=} map map
2223
* @property {((options?: MapOptions) => SourceAndMap)=} sourceAndMap source and map
@@ -60,6 +61,16 @@ class CompatSource extends Source {
6061
return super.buffer();
6162
}
6263

64+
/**
65+
* @returns {Buffer[]} buffers
66+
*/
67+
buffers() {
68+
if (typeof this._sourceLike.buffers === "function") {
69+
return this._sourceLike.buffers();
70+
}
71+
return super.buffers();
72+
}
73+
6374
size() {
6475
if (typeof this._sourceLike.size === "function") {
6576
return this._sourceLike.size();

lib/ConcatSource.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,22 @@ class ConcatSource extends Source {
8989
}
9090

9191
buffer() {
92+
return Buffer.concat(this.buffers());
93+
}
94+
95+
/**
96+
* @returns {Buffer[]} buffers
97+
*/
98+
buffers() {
9299
if (!this._isOptimized) this._optimize();
93100
/** @type {Buffer[]} */
94101
const buffers = [];
95102
for (const child of /** @type {SourceLike[]} */ (this._children)) {
96-
if (typeof child.buffer === "function") {
103+
if (typeof child.buffers === "function") {
104+
for (const buffer of child.buffers()) {
105+
buffers.push(buffer);
106+
}
107+
} else if (typeof child.buffer === "function") {
97108
buffers.push(child.buffer());
98109
} else {
99110
const bufferOrString = child.source();
@@ -105,7 +116,7 @@ class ConcatSource extends Source {
105116
}
106117
}
107118
}
108-
return Buffer.concat(buffers);
119+
return buffers;
109120
}
110121

111122
/**

lib/SizeOnlySource.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ class SizeOnlySource extends Source {
4949
throw this._error();
5050
}
5151

52+
/**
53+
* @returns {Buffer[]} buffers
54+
*/
55+
buffers() {
56+
throw this._error();
57+
}
58+
5259
/**
5360
* @param {MapOptions=} options map options
5461
* @returns {RawSourceMap | null} map

lib/Source.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ class Source {
5252
return Buffer.from(source, "utf8");
5353
}
5454

55+
/**
56+
* @returns {Buffer[]} buffers
57+
*/
58+
buffers() {
59+
return [this.buffer()];
60+
}
61+
5562
size() {
5663
return this.buffer().length;
5764
}

test/CachedSource.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ jest.mock("./__mocks__/createMappingsSerializer");
44

55
const crypto = require("crypto");
66
const { CachedSource } = require("../");
7+
const { ConcatSource } = require("../");
78
const { OriginalSource } = require("../");
89
const { RawSource } = require("../");
910
const { Source } = require("../");
@@ -548,6 +549,32 @@ describe.each([
548549
expect(chunks.length).toBeGreaterThan(0);
549550
});
550551

552+
it("should return Buffer[] from buffers() and delegate to the original source", () => {
553+
const original = new ConcatSource(
554+
new RawSource(Buffer.from("hello ")),
555+
new RawSource(Buffer.from("world")),
556+
);
557+
const cachedSource = new CachedSource(original);
558+
559+
const buffers = cachedSource.buffers();
560+
expect(Array.isArray(buffers)).toBe(true);
561+
expect(buffers).toHaveLength(2);
562+
expect(Buffer.concat(buffers).toString("utf8")).toBe("hello world");
563+
// The second call should return the cached array
564+
expect(cachedSource.buffers()).toBe(buffers);
565+
});
566+
567+
it("should return a single-entry Buffer[] from buffers() when buffer is already cached", () => {
568+
const buffer = Buffer.from("cached");
569+
const original = new RawSource(buffer);
570+
const cachedSource = new CachedSource(original);
571+
// Populate the buffer cache
572+
cachedSource.buffer();
573+
const buffers = cachedSource.buffers();
574+
expect(buffers).toHaveLength(1);
575+
expect(buffers[0]).toBe(buffer);
576+
});
577+
551578
it("should round-trip CachedSource with a Buffer-backed source", () => {
552579
const buffer = Buffer.from(Array.from({ length: 64 }, (_, i) => i));
553580
const original = new RawSource(buffer);

test/CompatSource.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,31 @@ describe("compatSource", () => {
5151
expect(source.buffer()).toBe(buffer);
5252
});
5353

54+
it("should use buffers from source-like when provided", () => {
55+
const buffers = [Buffer.from("a"), Buffer.from("b")];
56+
const source = CompatSource.from({
57+
source() {
58+
return "ab";
59+
},
60+
buffers() {
61+
return buffers;
62+
},
63+
});
64+
expect(source.buffers()).toBe(buffers);
65+
});
66+
67+
it("should fall back to super buffers() when sourceLike doesn't provide it", () => {
68+
const CONTENT = "Hello";
69+
const source = CompatSource.from({
70+
source() {
71+
return CONTENT;
72+
},
73+
});
74+
const buffers = source.buffers();
75+
expect(buffers).toHaveLength(1);
76+
expect(buffers[0]).toEqual(Buffer.from(CONTENT));
77+
});
78+
5479
it("should use size from super when sourceLike doesn't define size", () => {
5580
const CONTENT = "Hello";
5681
const source = CompatSource.from({

test/ConcatSource.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,59 @@ describe("concatSource", () => {
229229
);
230230
});
231231

232+
it("should expose individual buffers via buffers() without concatenating", () => {
233+
const a = new RawSource(Buffer.from("a"));
234+
const b = new RawSource(Buffer.from("b"));
235+
const c = new RawSource(Buffer.from("c"));
236+
const source = new ConcatSource(a, b, c);
237+
const buffers = source.buffers();
238+
expect(Array.isArray(buffers)).toBe(true);
239+
expect(buffers).toHaveLength(3);
240+
expect(buffers[0]).toEqual(Buffer.from("a"));
241+
expect(buffers[1]).toEqual(Buffer.from("b"));
242+
expect(buffers[2]).toEqual(Buffer.from("c"));
243+
expect(Buffer.concat(buffers)).toEqual(source.buffer());
244+
});
245+
246+
it("should flatten nested ConcatSource buffers() into a flat Buffer[]", () => {
247+
const inner = new ConcatSource(
248+
new RawSource(Buffer.from("x")),
249+
new RawSource(Buffer.from("y")),
250+
);
251+
const outer = new ConcatSource(new RawSource(Buffer.from("a")), inner);
252+
const buffers = outer.buffers();
253+
expect(buffers).toHaveLength(3);
254+
expect(Buffer.concat(buffers).toString("utf8")).toBe("axy");
255+
});
256+
257+
it("should fall back to buffer()/source() in buffers() for SourceLike children", () => {
258+
const customBuffer = Buffer.from("custom");
259+
const bufferOnly = {
260+
source() {
261+
return customBuffer;
262+
},
263+
buffer() {
264+
return customBuffer;
265+
},
266+
size() {
267+
return customBuffer.length;
268+
},
269+
};
270+
const sourceOnly = {
271+
source() {
272+
return Buffer.from("more");
273+
},
274+
size() {
275+
return 4;
276+
},
277+
};
278+
const source = new ConcatSource(bufferOnly, sourceOnly);
279+
const buffers = source.buffers();
280+
expect(buffers).toHaveLength(2);
281+
expect(buffers[0]).toBe(customBuffer);
282+
expect(buffers[1]).toEqual(Buffer.from("more"));
283+
});
284+
232285
it("should concat a SourceLike child where source() returns a string (no buffer())", () => {
233286
const customSource = {
234287
source() {

0 commit comments

Comments
 (0)