Skip to content

Instantly share code, notes, and snippets.

@iso2022jp
Created May 2, 2023 10:47
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save iso2022jp/c8efeacddbfd02e19d232dfaa056788c to your computer and use it in GitHub Desktop.
Save iso2022jp/c8efeacddbfd02e19d232dfaa056788c to your computer and use it in GitHub Desktop.
Chunked "Content" Encoding transformer
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
},
}
}
}
@iso2022jp
Copy link
Author

TODO: trailer-section

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment