Skip to content

Commit d611d44

Browse files
committed
stream: encode whole chunks in TextEncoderStream
The encode-and-enqueue transform walked the chunk code unit by code unit, materializing a single-character string per index and building the output with string concatenation. The only state that crosses chunks is a trailing high (leading) surrogate, and TextEncoder.encode's USVString conversion already replaces every interior lone surrogate with U+FFFD, which is exactly what the spec loop produces. Join a pending high surrogate with the incoming chunk, hold back a new trailing high surrogate, and encode the rest in a single native call. The streaming decode path also reuses a single options object instead of allocating { stream: true } per chunk. An encoding-streams benchmark is added since the suite had no TextEncoderStream/TextDecoderStream row. Encoding improves by ~546% with 1KB string chunks and ~20% with 16-character chunks; decode is unchanged. Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent 52726af commit d611d44

2 files changed

Lines changed: 61 additions & 26 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
'use strict';
2+
const common = require('../common.js');
3+
const {
4+
ReadableStream,
5+
TextEncoderStream,
6+
TextDecoderStream,
7+
} = require('node:stream/web');
8+
9+
const bench = common.createBenchmark(main, {
10+
n: [1e5],
11+
kind: ['encode', 'decode'],
12+
len: [16, 1024],
13+
});
14+
15+
async function main({ n, kind, len }) {
16+
const encoded = new TextEncoder().encode('a'.repeat(len));
17+
const decoded = 'a'.repeat(len);
18+
let i = 0;
19+
const rs = new ReadableStream({
20+
pull(controller) {
21+
if (i++ < n) {
22+
controller.enqueue(kind === 'encode' ? decoded : encoded);
23+
} else {
24+
controller.close();
25+
}
26+
},
27+
});
28+
const ts = kind === 'encode' ?
29+
new TextEncoderStream() :
30+
new TextDecoderStream();
31+
32+
const reader = rs.pipeThrough(ts).getReader();
33+
bench.start();
34+
for (;;) {
35+
const { done } = await reader.read();
36+
if (done) break;
37+
}
38+
bench.end(n);
39+
}

‎lib/internal/webstreams/encoding.js‎

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const {
44
ObjectDefineProperties,
55
String,
66
StringPrototypeCharCodeAt,
7+
StringPrototypeSlice,
78
Uint8Array,
89
} = primordials;
910

@@ -31,6 +32,9 @@ const {
3132
kEnumerableProperty,
3233
} = require('internal/util');
3334

35+
// Shared per-chunk decode options; decode() only reads the flag.
36+
const kDecodeStreamingOptions = { __proto__: null, stream: true };
37+
3438
/**
3539
* @typedef {import('./readablestream').ReadableStream} ReadableStream
3640
* @typedef {import('./writablestream').WritableStream} WritableStream
@@ -46,34 +50,26 @@ class TextEncoderStream {
4650
this.#transform = new TransformStream({
4751
transform: (chunk, controller) => {
4852
// https://encoding.spec.whatwg.org/#encode-and-enqueue-a-chunk
53+
// The only cross-chunk state is a trailing high surrogate;
54+
// encode() replaces interior lone surrogates with U+FFFD exactly
55+
// like the spec's per-code-unit walk.
4956
chunk = String(chunk);
50-
let finalChunk = '';
51-
for (let i = 0; i < chunk.length; i++) {
52-
const item = chunk[i];
53-
const codeUnit = StringPrototypeCharCodeAt(item, 0);
54-
if (this.#pendingHighSurrogate !== null) {
55-
const highSurrogate = this.#pendingHighSurrogate;
56-
this.#pendingHighSurrogate = null;
57-
if (0xDC00 <= codeUnit && codeUnit <= 0xDFFF) {
58-
finalChunk += highSurrogate + item;
59-
continue;
60-
}
61-
finalChunk += '\uFFFD';
62-
}
63-
if (0xD800 <= codeUnit && codeUnit <= 0xDBFF) {
64-
this.#pendingHighSurrogate = item;
65-
continue;
66-
}
67-
if (0xDC00 <= codeUnit && codeUnit <= 0xDFFF) {
68-
finalChunk += '\uFFFD';
69-
continue;
70-
}
71-
finalChunk += item;
57+
if (chunk.length === 0)
58+
return;
59+
if (this.#pendingHighSurrogate !== null) {
60+
chunk = this.#pendingHighSurrogate + chunk;
61+
this.#pendingHighSurrogate = null;
7262
}
73-
if (finalChunk) {
74-
const value = this.#handle.encode(finalChunk);
75-
controller.enqueue(value);
63+
const lastCodeUnit =
64+
StringPrototypeCharCodeAt(chunk, chunk.length - 1);
65+
if (0xD800 <= lastCodeUnit && lastCodeUnit <= 0xDBFF) {
66+
this.#pendingHighSurrogate =
67+
StringPrototypeSlice(chunk, -1);
68+
chunk = StringPrototypeSlice(chunk, 0, -1);
69+
if (chunk.length === 0)
70+
return;
7671
}
72+
controller.enqueue(this.#handle.encode(chunk));
7773
},
7874
flush: (controller) => {
7975
// https://encoding.spec.whatwg.org/#encode-and-flush
@@ -137,7 +133,7 @@ class TextDecoderStream {
137133
if (chunk === undefined) {
138134
throw new ERR_INVALID_ARG_TYPE('chunk', 'string', chunk);
139135
}
140-
const value = this.#handle.decode(chunk, { stream: true });
136+
const value = this.#handle.decode(chunk, kDecodeStreamingOptions);
141137
if (value)
142138
controller.enqueue(value);
143139
},

0 commit comments

Comments
 (0)