From b83a9b67dcafc28588b43701a590bd0dbb00f8cc Mon Sep 17 00:00:00 2001 From: Evan Richards Date: Fri, 11 Sep 2026 19:39:12 -0700 Subject: [PATCH] [M] Accept any BufferSource in TextDecoder.decode The polyfill for `TextDecoder` read `input.length`. An `ArrayBuffer` and a `DataView` have no `length` property. The loop never ran, and the method returned an empty string. The method did not throw, so the caller lost the data without a signal. The WHATWG encoding standard defines the input as a `BufferSource`. A `BufferSource` is an `ArrayBuffer` or a view on an `ArrayBuffer`. This change converts the input to a `Uint8Array` first. The conversion keeps the byte offset and the byte length of a view, so a subarray still decodes the correct bytes. An input that is not a `BufferSource` now throws a `TypeError`. The method also builds the intermediate string in chunks of 8192 bytes with `String.fromCharCode.apply`. The previous code called `String.fromCharCode` one time for each byte. A benchmark in the sandbox decoded 600 KB of JSON. The time was 131 ms before this change and 20 ms after this change. This change also increments the package version to 3.2.1. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 2 +- src/modules/util.js | 40 ++++++++++++++-- src/test/async/util.test.ts | 91 +++++++++++++++++++++++++++++++++++++ src/test/sync/util.test.ts | 91 +++++++++++++++++++++++++++++++++++++ 4 files changed, 218 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 2934606..f5a176c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@loop-payments/quickjs", - "version": "3.2.0", + "version": "3.2.1", "description": "A typescript package to execute JavaScript and TypeScript code in a WebAssembly QuickJS sandbox", "engines": { "node": ">=18.0.0" diff --git a/src/modules/util.js b/src/modules/util.js index 7e9261e..dc3b563 100644 --- a/src/modules/util.js +++ b/src/modules/util.js @@ -13,6 +13,32 @@ class TextEncoder { } } +// The engine limits the number of arguments for one function call. The +// decoder sends the bytes in chunks of this size to stay under the limit. +const DECODE_CHUNK_SIZE = 8192; + +// The WHATWG encoding standard defines the decoder input as a BufferSource. A +// BufferSource is an ArrayBuffer or a view on an ArrayBuffer. The decoder +// reads the bytes through a Uint8Array. An ArrayBuffer has no index access, +// and a signed view returns negative numbers. +function toUint8Array(input) { + if (input === undefined) { + return new Uint8Array(); + } + if (input instanceof Uint8Array) { + return input; + } + if (ArrayBuffer.isView(input)) { + // The new array must keep the offset and the length of the input + // view. An array over the full buffer decodes the wrong bytes. + return new Uint8Array(input.buffer, input.byteOffset, input.byteLength); + } + if (input instanceof ArrayBuffer) { + return new Uint8Array(input); + } + throw new TypeError('TextDecoder.decode accepts an ArrayBuffer or a view on an ArrayBuffer'); +} + class TextDecoder { constructor(encoding = 'utf-8') { if (encoding !== 'utf-8') { @@ -20,12 +46,16 @@ class TextDecoder { } } - decode(input = new Uint8Array()) { - let str = ''; - for (let i = 0; i < input.length; i++) { - str += String.fromCharCode(input[i]); + decode(input) { + const bytes = toUint8Array(input); + // One call to String.fromCharCode per chunk is much faster than one + // call per byte. A chunk that splits a multibyte sequence is safe, + // because decodeURIComponent reads the full string. + let latin1 = ''; + for (let i = 0; i < bytes.length; i += DECODE_CHUNK_SIZE) { + latin1 += String.fromCharCode.apply(null, bytes.subarray(i, i + DECODE_CHUNK_SIZE)); } - return decodeURIComponent(escape(str)); + return decodeURIComponent(escape(latin1)); } } diff --git a/src/test/async/util.test.ts b/src/test/async/util.test.ts index a3e6fe6..526a94f 100644 --- a/src/test/async/util.test.ts +++ b/src/test/async/util.test.ts @@ -92,4 +92,95 @@ describe('async - node:util - base', () => { expect(result.ok).toBeTrue() expect((result as OkResponse).data).toBe('Test passed') }) + + describe('TextDecoder', () => { + const decode = async (setup: string, argument: string) => { + const result = await runtime.runSandboxed(async ({ evalCode }) => { + return await evalCode(` + ${setup} + export default new TextDecoder().decode(${argument}) + `) + }) + expect(result.ok).toBeTrue() + return (result as OkResponse).data + } + + const decodeError = async (argument: string) => { + const result = await runtime.runSandboxed(async ({ evalCode }) => { + return await evalCode(` + let outcome = 'the decode call did not throw' + try { + new TextDecoder().decode(${argument}) + } catch (error) { + outcome = error.constructor.name + ': ' + error.message + } + export default outcome + `) + }) + expect(result.ok).toBeTrue() + return (result as OkResponse).data + } + + const helloWorld = "const bytes = new TextEncoder().encode('hello world')" + + it('decodes an ArrayBuffer', async () => { + expect(await decode(helloWorld, 'bytes.buffer')).toBe('hello world') + }) + + it('decodes a DataView', async () => { + expect(await decode(helloWorld, 'new DataView(bytes.buffer)')).toBe('hello world') + }) + + it('decodes a Uint8Array', async () => { + expect(await decode(helloWorld, 'bytes')).toBe('hello world') + }) + + it('decodes a view that starts at a byte offset', async () => { + expect(await decode(helloWorld, 'new Uint8Array(bytes.buffer, 6)')).toBe('world') + }) + + it('decodes a view that has a shorter byte length', async () => { + expect(await decode(helloWorld, 'new Uint8Array(bytes.buffer, 0, 5)')).toBe('hello') + }) + + it('decodes a view that has signed elements', async () => { + const setup = "const bytes = new TextEncoder().encode('héllo')" + expect(await decode(setup, 'new Int8Array(bytes.buffer)')).toBe('héllo') + }) + + it('decodes multibyte characters from an ArrayBuffer', async () => { + const setup = "const bytes = new TextEncoder().encode('héllo 🌍')" + expect(await decode(setup, 'bytes.buffer')).toBe('héllo 🌍') + }) + + it('returns an empty string for no argument', async () => { + expect(await decode('', '')).toBe('') + }) + + it('returns an empty string for an empty ArrayBuffer', async () => { + expect(await decode('', 'new ArrayBuffer(0)')).toBe('') + }) + + it('decodes a payload that is larger than one internal chunk', async () => { + const result = await runtime.runSandboxed(async ({ evalCode }) => { + return await evalCode(` + let source = '' + while (source.length < 100000) source += 'héllo 🌍 world ' + const bytes = new TextEncoder().encode(source) + const decoded = new TextDecoder().decode(bytes.buffer) + export default decoded === source ? 'match' : 'mismatch at length ' + decoded.length + `) + }) + expect(result.ok).toBeTrue() + expect((result as OkResponse).data).toBe('match') + }) + + it('throws a TypeError for a string', async () => { + expect(await decodeError("'hello world'")).toStartWith('TypeError') + }) + + it('throws a TypeError for null', async () => { + expect(await decodeError('null')).toStartWith('TypeError') + }) + }) }) diff --git a/src/test/sync/util.test.ts b/src/test/sync/util.test.ts index 8695711..829ed00 100644 --- a/src/test/sync/util.test.ts +++ b/src/test/sync/util.test.ts @@ -92,4 +92,95 @@ describe('sync - node:util - base', () => { expect(result.ok).toBeTrue() expect((result as OkResponse).data).toBe('Test passed') }) + + describe('TextDecoder', () => { + const decode = async (setup: string, argument: string) => { + const result = await runtime.runSandboxed(async ({ evalCode }) => { + return await evalCode(` + ${setup} + export default new TextDecoder().decode(${argument}) + `) + }) + expect(result.ok).toBeTrue() + return (result as OkResponse).data + } + + const decodeError = async (argument: string) => { + const result = await runtime.runSandboxed(async ({ evalCode }) => { + return await evalCode(` + let outcome = 'the decode call did not throw' + try { + new TextDecoder().decode(${argument}) + } catch (error) { + outcome = error.constructor.name + ': ' + error.message + } + export default outcome + `) + }) + expect(result.ok).toBeTrue() + return (result as OkResponse).data + } + + const helloWorld = "const bytes = new TextEncoder().encode('hello world')" + + it('decodes an ArrayBuffer', async () => { + expect(await decode(helloWorld, 'bytes.buffer')).toBe('hello world') + }) + + it('decodes a DataView', async () => { + expect(await decode(helloWorld, 'new DataView(bytes.buffer)')).toBe('hello world') + }) + + it('decodes a Uint8Array', async () => { + expect(await decode(helloWorld, 'bytes')).toBe('hello world') + }) + + it('decodes a view that starts at a byte offset', async () => { + expect(await decode(helloWorld, 'new Uint8Array(bytes.buffer, 6)')).toBe('world') + }) + + it('decodes a view that has a shorter byte length', async () => { + expect(await decode(helloWorld, 'new Uint8Array(bytes.buffer, 0, 5)')).toBe('hello') + }) + + it('decodes a view that has signed elements', async () => { + const setup = "const bytes = new TextEncoder().encode('héllo')" + expect(await decode(setup, 'new Int8Array(bytes.buffer)')).toBe('héllo') + }) + + it('decodes multibyte characters from an ArrayBuffer', async () => { + const setup = "const bytes = new TextEncoder().encode('héllo 🌍')" + expect(await decode(setup, 'bytes.buffer')).toBe('héllo 🌍') + }) + + it('returns an empty string for no argument', async () => { + expect(await decode('', '')).toBe('') + }) + + it('returns an empty string for an empty ArrayBuffer', async () => { + expect(await decode('', 'new ArrayBuffer(0)')).toBe('') + }) + + it('decodes a payload that is larger than one internal chunk', async () => { + const result = await runtime.runSandboxed(async ({ evalCode }) => { + return await evalCode(` + let source = '' + while (source.length < 100000) source += 'héllo 🌍 world ' + const bytes = new TextEncoder().encode(source) + const decoded = new TextDecoder().decode(bytes.buffer) + export default decoded === source ? 'match' : 'mismatch at length ' + decoded.length + `) + }) + expect(result.ok).toBeTrue() + expect((result as OkResponse).data).toBe('match') + }) + + it('throws a TypeError for a string', async () => { + expect(await decodeError("'hello world'")).toStartWith('TypeError') + }) + + it('throws a TypeError for null', async () => { + expect(await decodeError('null')).toStartWith('TypeError') + }) + }) })