|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const decoder = new TextDecoder(); |
|
|
const encoder = new TextEncoder(); |
|
|
const blankLine = encoder.encode('\r\n'); |
|
|
|
|
|
const STATE_BOUNDARY = 0; |
|
|
const STATE_HEADERS = 1; |
|
|
const STATE_BODY = 2; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function compareArrays(a: Uint8Array, b: Uint8Array): boolean { |
|
|
if (a.length != b.length) { |
|
|
return false; |
|
|
} |
|
|
for (let i = 0; i < a.length; i++) { |
|
|
if (a[i] != b[i]) { |
|
|
return false; |
|
|
} |
|
|
} |
|
|
return true; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function getBoundary(contentType: string): Uint8Array | null { |
|
|
|
|
|
|
|
|
const MULTIPART_TYPE = 'multipart/'; |
|
|
const BOUNDARY_PARAM = '; boundary='; |
|
|
if (!contentType.startsWith(MULTIPART_TYPE)) { |
|
|
return null; |
|
|
} |
|
|
const i = contentType.indexOf(BOUNDARY_PARAM, MULTIPART_TYPE.length); |
|
|
if (i == -1) { |
|
|
return null; |
|
|
} |
|
|
const suffix = contentType.substring(i + BOUNDARY_PARAM.length); |
|
|
return encoder.encode('--' + suffix + '\r\n'); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export default function multipartStream( |
|
|
contentType: string, |
|
|
body: ReadableStream, |
|
|
): ReadableStream { |
|
|
const reader = body.getReader(); |
|
|
return new ReadableStream({ |
|
|
async start(controller) { |
|
|
|
|
|
const boundary = getBoundary(contentType); |
|
|
if (boundary === null) { |
|
|
controller.error( |
|
|
new Error( |
|
|
'Invalid content type for multipart stream: ' + contentType, |
|
|
), |
|
|
); |
|
|
return; |
|
|
} |
|
|
let pos = 0; |
|
|
let buf = new Uint8Array(); |
|
|
let state = STATE_BOUNDARY; |
|
|
let headers: Headers | null = null; |
|
|
let contentLength: number | null = null; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function processBuf() { |
|
|
|
|
|
|
|
|
while (true) { |
|
|
if (boundary === null) { |
|
|
controller.error( |
|
|
new Error( |
|
|
'Invalid content type for multipart stream: ' + contentType, |
|
|
), |
|
|
); |
|
|
return; |
|
|
} |
|
|
switch (state) { |
|
|
case STATE_BOUNDARY: |
|
|
|
|
|
while ( |
|
|
buf.length >= pos + blankLine.length && |
|
|
compareArrays(buf.slice(pos, pos + blankLine.length), blankLine) |
|
|
) { |
|
|
pos += blankLine.length; |
|
|
} |
|
|
|
|
|
|
|
|
if (buf.length < pos + boundary.length) { |
|
|
return; |
|
|
} |
|
|
|
|
|
if ( |
|
|
!compareArrays(buf.slice(pos, pos + boundary.length), boundary) |
|
|
) { |
|
|
throw new Error('bad part boundary'); |
|
|
} |
|
|
pos += boundary.length; |
|
|
state = STATE_HEADERS; |
|
|
headers = new Headers(); |
|
|
break; |
|
|
|
|
|
case STATE_HEADERS: { |
|
|
const cr = buf.indexOf('\r'.charCodeAt(0), pos); |
|
|
if (cr == -1 || buf.length == cr + 1) { |
|
|
return; |
|
|
} |
|
|
if (buf[cr + 1] != '\n'.charCodeAt(0)) { |
|
|
throw new Error('bad part header line (CR without NL)'); |
|
|
} |
|
|
const line = decoder.decode(buf.slice(pos, cr)); |
|
|
pos = cr + 2; |
|
|
if (line == '') { |
|
|
const rawContentLength = headers?.get('Content-Length'); |
|
|
if (rawContentLength == null) { |
|
|
throw new Error('missing/invalid part Content-Length'); |
|
|
} |
|
|
contentLength = parseInt(rawContentLength, 10); |
|
|
if (isNaN(contentLength)) { |
|
|
throw new Error('missing/invalid part Content-Length'); |
|
|
} |
|
|
state = STATE_BODY; |
|
|
break; |
|
|
} |
|
|
const colon = line.indexOf(':'); |
|
|
const name = line.substring(0, colon); |
|
|
if (colon == line.length || line[colon + 1] != ' ') { |
|
|
throw new Error('bad part header line (no ": ")'); |
|
|
} |
|
|
const value = line.substring(colon + 2); |
|
|
headers?.append(name, value); |
|
|
break; |
|
|
} |
|
|
case STATE_BODY: { |
|
|
if (contentLength === null) { |
|
|
throw new Error('content length not set'); |
|
|
} |
|
|
if (buf.length < pos + contentLength) { |
|
|
return; |
|
|
} |
|
|
const body = buf.slice(pos, pos + contentLength); |
|
|
pos += contentLength; |
|
|
controller.enqueue({ |
|
|
headers: headers, |
|
|
body: body, |
|
|
}); |
|
|
headers = null; |
|
|
contentLength = null; |
|
|
state = STATE_BOUNDARY; |
|
|
break; |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
while (true) { |
|
|
const {done, value} = await reader.read(); |
|
|
const buffered = buf.length - pos; |
|
|
if (done) { |
|
|
if (state != STATE_BOUNDARY || buffered > 0) { |
|
|
throw Error('multipart stream ended mid-part'); |
|
|
} |
|
|
controller.close(); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
if (buffered == 0) { |
|
|
buf = value; |
|
|
} else { |
|
|
const newLen = buffered + value.length; |
|
|
const newBuf = new Uint8Array(newLen); |
|
|
newBuf.set(buf.slice(pos), 0); |
|
|
newBuf.set(value, buffered); |
|
|
buf = newBuf; |
|
|
} |
|
|
pos = 0; |
|
|
|
|
|
processBuf(); |
|
|
} |
|
|
}, |
|
|
cancel(reason) { |
|
|
return body.cancel(reason); |
|
|
}, |
|
|
}); |
|
|
} |
|
|
|