GeezerVison is showing github.com — stream: improve Web Compression spec compliance · nodejs/node@4bdcaf2
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: lib/internal/webstreams/adapters.js +36-8Lines changed: 36 additions & 8 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -11,6 +11,8 @@ const { | 11 | 11 | SafePromiseAll, | 12 | 12 | SafePromisePrototypeFinally, | 13 | 13 | SafeSet, | | 14 | + StringPrototypeStartsWith, | | 15 | + Symbol, | 14 | 16 | TypeError, | 15 | 17 | TypedArrayPrototypeGetBuffer, | 16 | 18 | TypedArrayPrototypeGetByteLength, | @@ -94,6 +96,9 @@ const { UV_EOF } = internalBinding('uv'); | 94 | 96 |
| 95 | 97 | const encoder = new TextEncoder(); | 96 | 98 |
| | 99 | +const kValidateChunk = Symbol('kValidateChunk'); | | 100 | +const kDestroyOnSyncError = Symbol('kDestroyOnSyncError'); | | 101 | + | 97 | 102 | // Collect all negative (error) ZLIB codes and Z_NEED_DICT | 98 | 103 | const ZLIB_FAILURES = new SafeSet([ | 99 | 104 | ...ArrayPrototypeFilter( | @@ -139,9 +144,10 @@ function handleKnownInternalErrors(cause) { | 139 | 144 |
| 140 | 145 | /** | 141 | 146 | * @param {Writable} streamWritable | | 147 | + * @param {object} [options] | 142 | 148 | * @returns {WritableStream} | 143 | 149 | */ | 144 | | -function newWritableStreamFromStreamWritable(streamWritable) { | | 150 | +function newWritableStreamFromStreamWritable(streamWritable, options = kEmptyObject) { | 145 | 151 | // Not using the internal/streams/utils isWritableNodeStream utility | 146 | 152 | // here because it will return false if streamWritable is a Duplex | 147 | 153 | // whose writable option is false. For a Duplex that is not writable, | @@ -220,12 +226,26 @@ function newWritableStreamFromStreamWritable(streamWritable) { | 220 | 226 | if (!streamWritable.writableObjectMode && isArrayBuffer(chunk)) { | 221 | 227 | chunk = new Uint8Array(chunk); | 222 | 228 | } | 223 | | - if (streamWritable.writableNeedDrain || !streamWritable.write(chunk)) { | 224 | | - backpressurePromise = PromiseWithResolvers(); | 225 | | - return SafePromisePrototypeFinally( | 226 | | - backpressurePromise.promise, () => { | 227 | | - backpressurePromise = undefined; | 228 | | - }); | | 229 | + try { | | 230 | + options[kValidateChunk]?.(chunk); | | 231 | + if (streamWritable.writableNeedDrain || !streamWritable.write(chunk)) { | | 232 | + backpressurePromise = PromiseWithResolvers(); | | 233 | + return SafePromisePrototypeFinally( | | 234 | + backpressurePromise.promise, () => { | | 235 | + backpressurePromise = undefined; | | 236 | + }); | | 237 | + } | | 238 | + } catch (error) { | | 239 | + // When the kDestroyOnSyncError flag is set (e.g. for | | 240 | + // CompressionStream), a sync throw must also destroy the | | 241 | + // stream so the readable side is errored too. Without this | | 242 | + // the readable side hangs forever. This replicates the | | 243 | + // TransformStream semantics: error both sides on any throw | | 244 | + // in the transform path. | | 245 | + if (options[kDestroyOnSyncError]) { | | 246 | + destroy(streamWritable, error); | | 247 | + } | | 248 | + throw error; | 229 | 249 | } | 230 | 250 | }, | 231 | 251 |
| @@ -662,9 +682,15 @@ function newReadableWritablePairFromDuplex(duplex, options = kEmptyObject) { | 662 | 682 | return { readable, writable }; | 663 | 683 | } | 664 | 684 |
| | 685 | + const writableOptions = { | | 686 | + __proto__: null, | | 687 | + [kValidateChunk]: options[kValidateChunk], | | 688 | + [kDestroyOnSyncError]: options[kDestroyOnSyncError], | | 689 | + }; | | 690 | + | 665 | 691 | const writable = | 666 | 692 | isWritable(duplex) ? | 667 | | - newWritableStreamFromStreamWritable(duplex) : | | 693 | + newWritableStreamFromStreamWritable(duplex, writableOptions) : | 668 | 694 | new WritableStream(); | 669 | 695 |
| 670 | 696 | if (!isWritable(duplex)) | @@ -1064,4 +1090,6 @@ module.exports = { | 1064 | 1090 | newStreamDuplexFromReadableWritablePair, | 1065 | 1091 | newWritableStreamFromStreamBase, | 1066 | 1092 | newReadableStreamFromStreamBase, | | 1093 | + kValidateChunk, | | 1094 | + kDestroyOnSyncError, | 1067 | 1095 | }; |
Collapse file Copy file name to clipboardExpand all lines: lib/internal/webstreams/compression.js +38-11Lines changed: 38 additions & 11 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -7,15 +7,28 @@ const { | 7 | 7 |
| 8 | 8 | const { | 9 | 9 | newReadableWritablePairFromDuplex, | | 10 | + kValidateChunk, | | 11 | + kDestroyOnSyncError, | 10 | 12 | } = require('internal/webstreams/adapters'); | 11 | 13 |
| 12 | 14 | const { customInspect } = require('internal/webstreams/util'); | 13 | 15 |
| | 16 | +const { | | 17 | + isArrayBufferView, | | 18 | + isSharedArrayBuffer, | | 19 | +} = require('internal/util/types'); | | 20 | + | 14 | 21 | const { | 15 | 22 | customInspectSymbol: kInspect, | 16 | 23 | kEnumerableProperty, | 17 | 24 | } = require('internal/util'); | 18 | 25 |
| | 26 | +const { | | 27 | + codes: { | | 28 | + ERR_INVALID_ARG_TYPE, | | 29 | + }, | | 30 | +} = require('internal/errors'); | | 31 | + | 19 | 32 | const { createEnumConverter } = require('internal/webidl'); | 20 | 33 |
| 21 | 34 | let zlib; | @@ -24,6 +37,18 @@ function lazyZlib() { | 24 | 37 | return zlib; | 25 | 38 | } | 26 | 39 |
| | 40 | +// Per the Compression Streams spec, chunks must be BufferSource | | 41 | +// (ArrayBuffer or ArrayBufferView not backed by SharedArrayBuffer). | | 42 | +function validateBufferSourceChunk(chunk) { | | 43 | + if (isArrayBufferView(chunk) && isSharedArrayBuffer(chunk.buffer)) { | | 44 | + throw new ERR_INVALID_ARG_TYPE( | | 45 | + 'chunk', | | 46 | + ['ArrayBuffer', 'Buffer', 'TypedArray', 'DataView'], | | 47 | + chunk, | | 48 | + ); | | 49 | + } | | 50 | +} | | 51 | + | 27 | 52 | const formatConverter = createEnumConverter('CompressionFormat', [ | 28 | 53 | 'deflate', | 29 | 54 | 'deflate-raw', | @@ -62,7 +87,10 @@ class CompressionStream { | 62 | 87 | this.#handle = lazyZlib().createBrotliCompress(); | 63 | 88 | break; | 64 | 89 | } | 65 | | - this.#transform = newReadableWritablePairFromDuplex(this.#handle); | | 90 | + this.#transform = newReadableWritablePairFromDuplex(this.#handle, { | | 91 | + [kValidateChunk]: validateBufferSourceChunk, | | 92 | + [kDestroyOnSyncError]: true, | | 93 | + }); | 66 | 94 | } | 67 | 95 |
| 68 | 96 | /** | @@ -108,25 +136,24 @@ class DecompressionStream { | 108 | 136 | }); | 109 | 137 | break; | 110 | 138 | case 'deflate-raw': | 111 | | - this.#handle = lazyZlib().createInflateRaw(); | | 139 | + this.#handle = lazyZlib().createInflateRaw({ | | 140 | + rejectGarbageAfterEnd: true, | | 141 | + }); | 112 | 142 | break; | 113 | 143 | case 'gzip': | 114 | 144 | this.#handle = lazyZlib().createGunzip({ | 115 | 145 | rejectGarbageAfterEnd: true, | 116 | 146 | }); | 117 | 147 | break; | 118 | 148 | case 'brotli': | 119 | | - this.#handle = lazyZlib().createBrotliDecompress(); | | 149 | + this.#handle = lazyZlib().createBrotliDecompress({ | | 150 | + rejectGarbageAfterEnd: true, | | 151 | + }); | 120 | 152 | break; | 121 | 153 | } | 122 | | - this.#transform = newReadableWritablePairFromDuplex(this.#handle); | 123 | | - | 124 | | - this.#handle.on('error', (err) => { | 125 | | - if (this.#transform?.writable && | 126 | | - !this.#transform.writable.locked && | 127 | | - typeof this.#transform.writable.abort === 'function') { | 128 | | - this.#transform.writable.abort(err); | 129 | | - } | | 154 | + this.#transform = newReadableWritablePairFromDuplex(this.#handle, { | | 155 | + [kValidateChunk]: validateBufferSourceChunk, | | 156 | + [kDestroyOnSyncError]: true, | 130 | 157 | }); | 131 | 158 | } | 132 | 159 |
| |
Collapse file Copy file name to clipboard +79Lines changed: 79 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 1 | +'use strict'; | | 2 | +// Flags: --no-warnings --expose-internals | | 3 | +require('../common'); | | 4 | +const assert = require('assert'); | | 5 | +const test = require('node:test'); | | 6 | +const { Duplex, Writable } = require('stream'); | | 7 | +const { | | 8 | + newWritableStreamFromStreamWritable, | | 9 | + newReadableWritablePairFromDuplex, | | 10 | +} = require('internal/webstreams/adapters'); | | 11 | + | | 12 | +// Verify that when the underlying Node.js stream throws synchronously from | | 13 | +// write(), the writable web stream properly rejects but does not destroy | | 14 | +// the stream (destroy-on-sync-throw is only used internally by | | 15 | +// CompressionStream/DecompressionStream). | | 16 | + | | 17 | +test('WritableStream from Node.js stream handles sync write throw', async () => { | | 18 | + const error = new TypeError('invalid chunk'); | | 19 | + const writable = new Writable({ | | 20 | + write() { | | 21 | + throw error; | | 22 | + }, | | 23 | + }); | | 24 | + | | 25 | + const ws = newWritableStreamFromStreamWritable(writable); | | 26 | + const writer = ws.getWriter(); | | 27 | + | | 28 | + await assert.rejects(writer.write('bad'), (err) => { | | 29 | + assert.strictEqual(err, error); | | 30 | + return true; | | 31 | + }); | | 32 | + | | 33 | + // Standalone writable should not be destroyed on sync write error | | 34 | + assert.strictEqual(writable.destroyed, false); | | 35 | +}); | | 36 | + | | 37 | +test('Duplex-backed pair does NOT destroy on sync write throw', async () => { | | 38 | + const error = new TypeError('invalid chunk'); | | 39 | + const duplex = new Duplex({ | | 40 | + read() {}, | | 41 | + write() { | | 42 | + throw error; | | 43 | + }, | | 44 | + }); | | 45 | + | | 46 | + const { writable, readable } = newReadableWritablePairFromDuplex(duplex); | | 47 | + const writer = writable.getWriter(); | | 48 | + | | 49 | + await assert.rejects(writer.write('bad'), (err) => { | | 50 | + assert.strictEqual(err, error); | | 51 | + return true; | | 52 | + }); | | 53 | + | | 54 | + // A plain Duplex should NOT be destroyed on sync write error | | 55 | + assert.strictEqual(duplex.destroyed, false); | | 56 | + | | 57 | + // The readable side should still be usable | | 58 | + const reader = readable.getReader(); | | 59 | + reader.cancel(); | | 60 | +}); | | 61 | + | | 62 | +test('WritableStream from Node.js stream - valid writes still work', async () => { | | 63 | + const chunks = []; | | 64 | + const writable = new Writable({ | | 65 | + write(chunk, _encoding, cb) { | | 66 | + chunks.push(chunk); | | 67 | + cb(); | | 68 | + }, | | 69 | + }); | | 70 | + | | 71 | + const ws = newWritableStreamFromStreamWritable(writable); | | 72 | + const writer = ws.getWriter(); | | 73 | + | | 74 | + await writer.write(Buffer.from('hello')); | | 75 | + await writer.write(Buffer.from(' world')); | | 76 | + await writer.close(); | | 77 | + | | 78 | + assert.strictEqual(Buffer.concat(chunks).toString(), 'hello world'); | | 79 | +}); |
Collapse file Copy file name to clipboard +57Lines changed: 57 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 1 | +'use strict'; | | 2 | +require('../common'); | | 3 | +const assert = require('assert'); | | 4 | +const test = require('node:test'); | | 5 | +const { CompressionStream, DecompressionStream } = require('stream/web'); | | 6 | + | | 7 | +// Verify that writing invalid (non-BufferSource) chunks to | | 8 | +// CompressionStream and DecompressionStream properly rejects | | 9 | +// on both the write and the read side, instead of hanging. | | 10 | + | | 11 | +const badChunks = [ | | 12 | + { name: 'undefined', value: undefined, code: 'ERR_INVALID_ARG_TYPE' }, | | 13 | + { name: 'null', value: null, code: 'ERR_STREAM_NULL_VALUES' }, | | 14 | + { name: 'number', value: 3.14, code: 'ERR_INVALID_ARG_TYPE' }, | | 15 | + { name: 'object', value: {}, code: 'ERR_INVALID_ARG_TYPE' }, | | 16 | + { name: 'array', value: [65], code: 'ERR_INVALID_ARG_TYPE' }, | | 17 | + { | | 18 | + name: 'SharedArrayBuffer', | | 19 | + value: new SharedArrayBuffer(1), | | 20 | + code: 'ERR_INVALID_ARG_TYPE', | | 21 | + }, | | 22 | + { | | 23 | + name: 'Uint8Array backed by SharedArrayBuffer', | | 24 | + value: new Uint8Array(new SharedArrayBuffer(1)), | | 25 | + code: 'ERR_INVALID_ARG_TYPE', | | 26 | + }, | | 27 | +]; | | 28 | + | | 29 | +for (const format of ['deflate', 'deflate-raw', 'gzip', 'brotli']) { | | 30 | + for (const { name, value, code } of badChunks) { | | 31 | + const expected = { name: 'TypeError', code }; | | 32 | + | | 33 | + test(`CompressionStream rejects bad chunk (${name}) for ${format}`, async () => { | | 34 | + const cs = new CompressionStream(format); | | 35 | + const writer = cs.writable.getWriter(); | | 36 | + const reader = cs.readable.getReader(); | | 37 | + | | 38 | + const writePromise = writer.write(value); | | 39 | + const readPromise = reader.read(); | | 40 | + | | 41 | + await assert.rejects(writePromise, expected); | | 42 | + await assert.rejects(readPromise, expected); | | 43 | + }); | | 44 | + | | 45 | + test(`DecompressionStream rejects bad chunk (${name}) for ${format}`, async () => { | | 46 | + const ds = new DecompressionStream(format); | | 47 | + const writer = ds.writable.getWriter(); | | 48 | + const reader = ds.readable.getReader(); | | 49 | + | | 50 | + const writePromise = writer.write(value); | | 51 | + const readPromise = reader.read(); | | 52 | + | | 53 | + await assert.rejects(writePromise, expected); | | 54 | + await assert.rejects(readPromise, expected); | | 55 | + }); | | 56 | + } | | 57 | +} |
|