Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
40 changes: 35 additions & 5 deletions src/modules/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,49 @@ 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') {
throw new Error('Only utf-8 encoding is supported');
}
}

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));
}
}

Expand Down
91 changes: 91 additions & 0 deletions src/test/async/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
})
91 changes: 91 additions & 0 deletions src/test/sync/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
})
Loading