-
-
Save iso2022jp/c8efeacddbfd02e19d232dfaa056788c to your computer and use it in GitHub Desktop.
Chunked "Content" Encoding transformer
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class ChunkedTransformer { | |
#trasformLastChunk | |
#chunks | |
#header | |
// BWS: [ \t]* | |
// token: [-!#-'*+.^`|~\w]+ | |
// quoted-string: "(?:[\t \x21\x23-\x5B\x5d-\x7E\x80-\xFF]|\\[\t \x21-\x7E\x80-\xFF])*" | |
// chunk-size: (?<sizeInHex>[0-9A-Fa-f]+) | |
// chunk-ext-name: [-!#-'*+.^`|~\w]+ | |
// chunk-ext-val: [-!#-'*+.^`|~\w]+|"(?:[\t \x21\x23-\x5B\x5d-\x7E\x80-\xFF]|\\[\t \x21-\x7E\x80-\xFF])*" | |
// chunk-ext: ([ \t]*;[ \t]*(?<name>[-!#-'*+.^`|~\w]+)(?:[ \t]*=[ \t]*(?<value>[-!#-'*+.^`|~\w]+|"(?:[\t \x21\x23-\x5B\x5d-\x7E\x80-\xFF]|\\[\t \x21-\x7E\x80-\xFF])*"))?)* | |
#sizePattern | |
#extensionPattern | |
#quotedPairPattern | |
#state | |
static #STATE_READY = 0 | |
static #STATE_WAIT_BODY = 1 | |
static #STATE_WAIT_TRAILER = 2 | |
static #STATE_COMPLETED = 3 | |
constructor(trasformLastChunk = false) { | |
this.#trasformLastChunk = trasformLastChunk | |
this.#sizePattern = /(?<size>[0-9A-Fa-f]+)/y; | |
this.#extensionPattern = /[ \t]*;[ \t]*(?<name>[-!#-'*+.^`|~\w]+)(?:[ \t]*=[ \t]*(?<value>[-!#-'*+.^`|~\w]+|"(?:[\t \x21\x23-\x5B\x5d-\x7E\x80-\xFF]|\\[\t \x21-\x7E\x80-\xFF])*"))?/gy; | |
this.#quotedPairPattern = /\\(?<character>[\t \x21-\x7E\x80-\xFF])/g | |
} | |
start(controller) { | |
this.#chunks = [] | |
this.#header = null | |
this.#state = ChunkedTransformer.#STATE_READY | |
} | |
async transform(chunk, controller) { | |
if (chunk === null) { | |
controller.terminate() | |
return | |
} | |
if (!ArrayBuffer.isView(chunk)) { | |
controller.error("Chunk is not ArrayBuffer view.") | |
} | |
if (!(chunk instanceof Uint8Array)) { | |
chunk = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength) | |
} | |
if (this.#state === ChunkedTransformer.#STATE_COMPLETED) { | |
throw new Error('Trailer section is not terminating properly.') | |
} | |
this.#chunks.push(chunk) | |
while (this.#state !== ChunkedTransformer.#STATE_COMPLETED) { | |
if (this.#state === ChunkedTransformer.#STATE_READY) { | |
if (!await this.#handleChunkHeader()) { | |
return | |
} | |
} | |
if (this.#state === ChunkedTransformer.#STATE_WAIT_BODY) { | |
if (!await this.#handleChunkData(controller)) { | |
return | |
} | |
} | |
if (this.#state === ChunkedTransformer.#STATE_WAIT_TRAILER) { | |
if (!await this.#handleTrailer()) { | |
return | |
} | |
} | |
} | |
} | |
async flush(controller) { | |
if (this.#state !== ChunkedTransformer.#STATE_COMPLETED) { | |
throw new Error('Chunked stream terminated unexpectedly.') | |
} | |
const blob = new Blob(this.#chunks) | |
if (blob.size > 0) { | |
throw new Error('Trailer section is not terminating properly.') | |
} | |
} | |
async #handleChunkHeader() { | |
// chunk-size [ chunk-ext ] CRLF | |
[this.#chunks, this.#header] = await this.#tryReadChunkSizeLine(this.#chunks) | |
if (!this.#header) { | |
return false // wait more | |
} | |
this.#state = ChunkedTransformer.#STATE_WAIT_BODY | |
return true | |
} | |
async #handleChunkData(controller) { | |
// chunk-data CRLF | |
const {size} = this.#header | |
const blob = new Blob(this.#chunks) | |
let total = blob.size // XXX: total of byteLength | |
if (total < size + 2) { | |
return false // wait more | |
} | |
if (await blob.slice(size, size + 2).text() !== "\r\n") { | |
throw new Error('Invalid chunk data termination.') | |
} | |
const content = await blob.slice(0, size).text() | |
// merge into one view | |
this.#chunks = [new Uint8Array(await blob.slice(size + 2).arrayBuffer())] | |
if (size === 0) { | |
this.#state = ChunkedTransformer.#STATE_WAIT_TRAILER | |
if (this.#trasformLastChunk) { | |
controller.enqueue({ | |
header: this.#header, | |
content, | |
}) | |
} | |
} else { | |
this.#state = ChunkedTransformer.#STATE_READY | |
controller.enqueue({ | |
header: this.#header, | |
content, | |
}) | |
} | |
return true | |
} | |
async #handleTrailer() { | |
// trailer-section CRLF | |
const blob = new Blob(this.#chunks) | |
if (blob < 2) { | |
return false // wait more | |
} | |
// consume 2 bytes | |
this.#chunks = [new Uint8Array(await blob.slice(2).arrayBuffer())] | |
this.#state = ChunkedTransformer.#STATE_COMPLETED | |
return true | |
} | |
async #tryReadChunkSizeLine(chunks) { | |
let position = 0 | |
let found = false | |
// find CR | |
for (const chunk of chunks) { | |
const p = chunk.indexOf(13) // CR | |
if (p >= 0) { | |
position += p | |
found = true | |
break | |
} else { | |
position += chunk.length | |
} | |
} | |
if (!found) { | |
return [chunks, undefined] | |
} | |
const blob = new Blob(chunks) | |
if (blob.size < position + 2) { | |
// ...CR | |
return [chunks, undefined] | |
} | |
if (await blob.slice(position, position + 2).text() !== "\r\n") { | |
throw new Error('Invalid header termination.') | |
} | |
const line = await blob.slice(0, position).text() | |
const remainder = [new Uint8Array(await blob.slice(position + 2).arrayBuffer())] | |
return [remainder, this.#parseChunkSizeLine(line)] | |
} | |
#parseChunkSizeLine(line) { | |
this.#sizePattern.lastIndex = 0 | |
const m = this.#sizePattern.exec(line) | |
if (!m) { | |
throw new Error('Invalid chunk size field.') | |
} | |
const size = parseInt(m.groups.size, 16) | |
this.#extensionPattern.lastIndex = this.#sizePattern.lastIndex | |
const extensions = [...line.matchAll(this.#extensionPattern)] | |
// check EOL | |
if (extensions.length > 0) { | |
const lastMatch = extensions.at(-1) | |
if (line.length !== lastMatch.index + lastMatch[0].length) { | |
throw new Error('Chunk extension is not terminating properly.') | |
} | |
} else { | |
if (line.length !== this.#sizePattern.lastIndex) { | |
throw new Error('Chunk size indicator is not terminating properly.') | |
} | |
} | |
const dequote = value => { | |
if (typeof value === 'string' && value.length >= 2 && value.at(0) === '"' && value.at(-1) === '"') { | |
value.slice(1, -1).replaceAll(this.#quotedPairPattern, '$<character>') | |
} | |
return value | |
} | |
return { | |
size, | |
extensions: extensions.map(m => ({name: m.groups.name, value: dequote(m.groups.value)})) | |
} | |
} | |
} | |
class ChunkedTransformStream extends TransformStream { | |
constructor(writableStrategy, readableStrategy) { | |
super(new ChunkedTransformer(), writableStrategy, readableStrategy) | |
} | |
} | |
if (typeof ReadableStream.prototype[Symbol.asyncIterator] === 'undefined') { | |
// Polyfill | |
ReadableStream.prototype[Symbol.asyncIterator] = function () { | |
let reader = this.getReader() | |
return { | |
async next() { | |
if (!reader) { | |
return {done: true, value: undefined} | |
} | |
const item = await reader.read() | |
if (item.done) { | |
await reader.cancel() | |
reader.releaseLock() | |
reader = null | |
} | |
return item | |
}, | |
async return(value) { | |
if (reader) { | |
await reader.cancel() | |
reader.releaseLock() | |
reader = null | |
} | |
return {done: true, value} | |
}, | |
[Symbol.toStringTag]: 'AsyncIterator Polyfill for ReadableStream', | |
[Symbol.asyncIterator]() { | |
return this | |
}, | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
TODO: trailer-section