Skip to content

Commit

Permalink
Refactoring and add encoding function
Browse files Browse the repository at this point in the history
  • Loading branch information
corrideat committed Apr 15, 2023
1 parent 78cdd59 commit 4029d36
Show file tree
Hide file tree
Showing 9 changed files with 506 additions and 18 deletions.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,19 @@ A regular expression that can be used to validate a boundary string.
A regular expression that can be used to extract a boundary string from a
`Content-Type` header.

#### `encodeMultipartMessage = (boundary: string, msg: TDecodedMultipartMessage[]): ReadableStream<ArrayBuffer>

This function takes a boundary string and an array of messages as arguments and returns a `ReadableStream` that can be read to obtain a multipart message.

`TDecodedMultipartMessage` is defined as an object with the following fields:

* `headers`: a `Headers` object containing the headers of the current part
* `body` (optional): The body of the current part, or `null` if the part is
empty. It can be any of the following types: `ArrayBuffer`, `Blob`, `ReadableStream` or any typed array, such as `Uint8Array`.
* `parts` (optional): An array of one element or more of the same type
(`TDecodedMultipartMessage`), for nested messages. If both `body` and
`parts` are specified, `body` takes precedence.

### Example

```js
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@exact-realty/multipart-parser",
"version": "1.0.1",
"version": "1.0.2",
"description": "TypeScript streaming parser for MIME multipart messages",
"main": "dist/index.js",
"module": "./dist/index.mjs",
Expand Down
213 changes: 213 additions & 0 deletions src/encodeMultipartMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
/* Copyright © 2023 Exact Realty Limited.
*
* Permission to use, copy, modify, and distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
* REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
* INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
* LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
* OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
* PERFORMANCE OF THIS SOFTWARE.
*/

import { boundaryMatchRegex } from './lib/boundaryRegex';
import createBufferStream from './lib/createBufferStream';

export type TDecodedMultipartMessage = {
headers: Headers;
body?: TTypedArray | ArrayBuffer | Blob | ReadableStream | null;
parts?: TDecodedMultipartMessage[];
};

const textEncoder = new TextEncoder();

export const liberalBoundaryMatchRegex = /;\s*boundary=(?:"([^"]+)"|([^;",]+))/;

const multipartBoundaryAlphabet =
'ABCDEFGHIJKLMNOPQRSTUVWXYZ' +
'abcdefghijklmnopqrstuvwxyz' +
'0123456789' +
'+_-.';

const generateMultipartBoundary = (): string => {
const buffer = new Uint8Array(24);
globalThis.crypto.getRandomValues(buffer);
return Array.from(buffer)
.map(
(v) =>
multipartBoundaryAlphabet[v % multipartBoundaryAlphabet.length],
)
.join('');
};

const pipeToOptions = {
preventClose: true,
};

async function* asyncEncoderGenerator(
boundary: string,
msg: TDecodedMultipartMessage[],
ws: WritableStream,
): AsyncGenerator<void> {
const encodedBoundary = textEncoder.encode(`\r\n--${boundary}`);

if (!Array.isArray(msg) || msg.length < 1) {
await ws.abort(Error('At least one part is required'));
return;
}

for (const part of msg) {
let subBoundary: string | undefined;
let partContentType: string | null | undefined;

// First, do some validation in case a multipart message
// needs to be encoded
if (!part.body && part.parts) {
partContentType = part.headers.get('content-type');

if (!partContentType) {
subBoundary = generateMultipartBoundary();
partContentType = `multipart/mixed; boundary="${subBoundary}"`;
} else if (
!partContentType.startsWith('multipart/') ||
!liberalBoundaryMatchRegex.test(partContentType)
) {
await ws.abort(
Error('Invalid multipart content type: ' + partContentType),
);
return;
} else {
const messageBoundaryMatch =
partContentType.match(boundaryMatchRegex);

// Invalid boundary. Attempt to replace it.
if (
!messageBoundaryMatch ||
!(subBoundary =
messageBoundaryMatch[1] || messageBoundaryMatch[2])
) {
subBoundary = generateMultipartBoundary();
partContentType = partContentType.replace(
liberalBoundaryMatchRegex,
`; boundary="${subBoundary}"`,
);
}
}
}

await createBufferStream(encodedBoundary).pipeTo(ws, pipeToOptions);
yield;

// Send headers
{
const hh: string[] = [''];
if (partContentType) {
let seenContentType = false;
part.headers.forEach((v, k) => {
if (k !== 'content-type') {
hh.push(`${k}: ${v}`);
} else {
seenContentType = true;
hh.push(`${k}: ${partContentType}`);
}
});
if (!seenContentType) {
hh.push(`content-type: ${partContentType}`);
}
} else {
part.headers.forEach((v, k) => {
hh.push(`${k}: ${v}`);
});
}

if (part.parts || !part.body) {
hh.push('');
} else {
hh.push('', '');
}
const headers = textEncoder.encode(hh.join('\r\n'));
hh.length = 0;
await createBufferStream(headers.buffer).pipeTo(ws, pipeToOptions);
yield;
}

// Now, we'll either send a body, if there is one, or construct
// a multipart submessage
if (part.body) {
if (part.body instanceof ArrayBuffer) {
await createBufferStream(part.body).pipeTo(ws, pipeToOptions);
} else if (part.body instanceof Blob) {
await part.body.stream().pipeTo(ws, pipeToOptions);
} else if (part.body instanceof ReadableStream) {
await part.body.pipeTo(ws, pipeToOptions);
} else if (part.body.buffer instanceof ArrayBuffer) {
await createBufferStream(part.body.buffer).pipeTo(
ws,
pipeToOptions,
);
} else {
await ws.abort(Error('Invalid body type'));
return;
}
yield;
} else if (part.parts) {
if (!subBoundary) {
await ws.abort(
Error('Runtime exception: undefined part boundary'),
);
return;
}

yield* asyncEncoderGenerator(subBoundary, part.parts, ws);
yield;
}
}

const encodedEndBoundary = textEncoder.encode(`\r\n--${boundary}--`);
await createBufferStream(encodedEndBoundary).pipeTo(ws, pipeToOptions);
}

const encodeMultipartMessage = (
boundary: string,
msg: TDecodedMultipartMessage[],
): ReadableStream<ArrayBuffer> => {
const transformStream = new TransformStream<ArrayBuffer>();

const asyncEncoder = asyncEncoderGenerator(
boundary,
msg,
transformStream.writable,
);
let finishedEncoding: boolean | undefined = false;

const reader = transformStream.readable.getReader();

const readableStream = new ReadableStream<ArrayBuffer>({
async pull(controller) {
return Promise.all([
!finishedEncoding && asyncEncoder.next(),
reader.read(),
]).then(async ([encodingResult, readResult]) => {
if (encodingResult && encodingResult.done) {
finishedEncoding = true;
await transformStream.writable.close();
}

if (readResult.done) {
controller.enqueue(new Uint8Array([0x0d, 0x0a]));
controller.close();
return;
}

controller.enqueue(readResult.value);
});
},
});

return readableStream;
};

export default encodeMultipartMessage;
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
* PERFORMANCE OF THIS SOFTWARE.
*/

export { default as encodeMultipartMessage } from './encodeMultipartMessage';
export type { TDecodedMultipartMessage } from './encodeMultipartMessage';
export { boundaryMatchRegex, boundaryRegex } from './lib/boundaryRegex';
export { default as parseMessage } from './parseMessage';
export type { TMessage } from './parseMessage';
export * from './parseMultipartMessage';
Expand Down
28 changes: 28 additions & 0 deletions src/lib/boundaryRegex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/* Copyright © 2023 Exact Realty Limited.
*
* Permission to use, copy, modify, and distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
* REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
* INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
* LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
* OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
* PERFORMANCE OF THIS SOFTWARE.
*/

/* From RFC 2046 section 5.1.1 */
export const boundaryRegex =
/^[0-9a-zA-Z'()+_,\-./:=? ]{0,69}[0-9a-zA-Z'()+_,\-./:=?]$/;

/* From RFC 2045 section 5.1
tspecials := "(" / ")" / "<" / ">" / "@" /
"," / ";" / ":" / "\" / <">
"/" / "[" / "]" / "?" / "="
; Must be in quoted-string,
; to use within parameter values
*/
export const boundaryMatchRegex =
/;\s*boundary=(?:"([0-9a-zA-Z'()+_,\-./:=? ]{0,69}[0-9a-zA-Z'()+_,\-./:=?])"|([0-9a-zA-Z'+_\-.]{0,69}[0-9a-zA-Z'+_\-.]))/;
2 changes: 1 addition & 1 deletion src/lib/createBufferStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* PERFORMANCE OF THIS SOFTWARE.
*/

const createBufferStream = <T extends TTypedArray>(buffer: T) => {
const createBufferStream = <T extends TTypedArray | ArrayBuffer>(buffer: T) => {
const readableStream = new ReadableStream<T>({
pull(controller) {
controller.enqueue(buffer);
Expand Down
15 changes: 1 addition & 14 deletions src/parseMultipartMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* PERFORMANCE OF THIS SOFTWARE.
*/

import { boundaryMatchRegex, boundaryRegex } from './lib/boundaryRegex';
import createBufferStream from './lib/createBufferStream';
import findIndex from './lib/findIndex';
import mergeTypedArrays from './lib/mergeTypedArrays';
Expand All @@ -30,20 +31,6 @@ const textEncoder = new TextEncoder();
const newline = textEncoder.encode('\r\n');
const LWSPchar = [0x09, 0x20];

/* From RFC 2046 section 5.1.1 */
export const boundaryRegex =
/^[0-9a-zA-Z'()+_,\-./:=? ]{0,69}[0-9a-zA-Z'()+_,\-./:=?]$/;

/* From RFC 2045 section 5.1
tspecials := "(" / ")" / "<" / ">" / "@" /
"," / ";" / ":" / "\" / <">
"/" / "[" / "]" / "?" / "="
; Must be in quoted-string,
; to use within parameter values
*/
export const boundaryMatchRegex =
/;\s*boundary=(?:"([0-9a-zA-Z'()+_,\-./:=? ]{0,69}[0-9a-zA-Z'()+_,\-./:=?])"|([0-9a-zA-Z'+_\-.]{0,69}[0-9a-zA-Z'+_\-.]))/;

export type TMultipartMessage = {
headers: Headers;
body?: Uint8Array | null;
Expand Down
Loading

0 comments on commit 4029d36

Please sign in to comment.