mhtml-stream 2.0.2 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -5
- package/dist/cli.js +1 -2
- package/dist/headers.d.ts +1 -1
- package/dist/headers.js +21 -17
- package/dist/index.d.ts +23 -2
- package/dist/index.js +16 -16
- package/dist/mhtml.esm.min.js +1 -1
- package/dist/utils.d.ts +20 -3
- package/dist/utils.js +55 -14
- package/package.json +8 -3
- package/dist/index.spec.d.ts +0 -1
- package/dist/index.spec.js +0 -248
- package/dist/utils.spec.d.ts +0 -1
- package/dist/utils.spec.js +0 -148
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
MHTML Stream
|
|
2
2
|
============
|
|
3
|
-
[](https://github.com/erikbrinkman/mhtml-stream/actions/workflows/build.yml)
|
|
4
4
|
[](https://erikbrinkman.github.io/mhtml-stream/)
|
|
5
5
|
[](https://www.npmjs.com/package/mhtml-stream)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
Library for parsing MHTML data as streams using modern WHATWG streams and async
|
|
9
|
+
iterators. Because it relies on modern cross javascript standards it works
|
|
10
|
+
out-of-the-box in all javascript environments, with only a little tweaking
|
|
11
|
+
necessary for module definitions.
|
|
12
12
|
|
|
13
13
|
Usage
|
|
14
14
|
-----
|
|
@@ -39,3 +39,7 @@ Notes
|
|
|
39
39
|
comes to whether whitespace should be added when unfolding. This currently
|
|
40
40
|
uses the first whitespace character to indicate folding, and preservers any
|
|
41
41
|
others.
|
|
42
|
+
- Decoded part content preserves the original MIME line endings. The parser
|
|
43
|
+
splits the stream on `\r\n`, and the quoted-printable and 7bit/8bit decoders
|
|
44
|
+
re-insert that `\r\n` between lines, so extraction is lossless. Both decoders
|
|
45
|
+
accept a `newLine` argument if you'd rather normalize to `\n`.
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
/* eslint-disable no-console */
|
|
2
1
|
import { mkdir } from "node:fs/promises";
|
|
3
2
|
import { argv } from "bun";
|
|
4
3
|
import yargs from "yargs";
|
|
5
|
-
import { parseMhtml } from ".";
|
|
4
|
+
import { parseMhtml } from "./index.js";
|
|
6
5
|
async function* readFile(name) {
|
|
7
6
|
const file = Bun.file(name);
|
|
8
7
|
yield await file.bytes();
|
package/dist/headers.d.ts
CHANGED
|
@@ -56,7 +56,7 @@ export declare class Headers implements MhtmlHeaders {
|
|
|
56
56
|
get(key: string, delim?: string): string | null;
|
|
57
57
|
getAll(key: string): string[];
|
|
58
58
|
has(key: string): boolean;
|
|
59
|
-
keys():
|
|
59
|
+
keys(): IterableIterator<string>;
|
|
60
60
|
values(delim?: string): IterableIterator<string>;
|
|
61
61
|
valuesAll(): IterableIterator<string>;
|
|
62
62
|
}
|
package/dist/headers.js
CHANGED
|
@@ -1,55 +1,59 @@
|
|
|
1
1
|
export class Headers {
|
|
2
|
+
// keyed by lowercased name; the original name casing (first seen) is
|
|
3
|
+
// preserved for iteration while lookups are case-insensitive, per RFC 5322
|
|
2
4
|
#raw = new Map();
|
|
3
5
|
[Symbol.iterator]() {
|
|
4
6
|
return this.entries();
|
|
5
7
|
}
|
|
6
8
|
append(key, value) {
|
|
7
|
-
const
|
|
8
|
-
if (
|
|
9
|
-
this.#raw.set(key, [value]);
|
|
9
|
+
const entry = this.#raw.get(key.toLowerCase());
|
|
10
|
+
if (entry === undefined) {
|
|
11
|
+
this.#raw.set(key.toLowerCase(), { key, values: [value] });
|
|
10
12
|
}
|
|
11
13
|
else {
|
|
12
|
-
|
|
14
|
+
entry.values.push(value);
|
|
13
15
|
}
|
|
14
16
|
}
|
|
15
17
|
*entries(delim = ", ") {
|
|
16
|
-
for (const
|
|
18
|
+
for (const { key, values } of this.#raw.values()) {
|
|
17
19
|
yield [key, values.join(delim)];
|
|
18
20
|
}
|
|
19
21
|
}
|
|
20
22
|
*entriesAll() {
|
|
21
|
-
for (const
|
|
23
|
+
for (const { key, values } of this.#raw.values()) {
|
|
22
24
|
for (const val of values) {
|
|
23
25
|
yield [key, val];
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
29
|
get(key, delim = ", ") {
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
30
|
+
const entry = this.#raw.get(key.toLowerCase());
|
|
31
|
+
if (entry === undefined) {
|
|
30
32
|
return null;
|
|
31
33
|
}
|
|
32
34
|
else {
|
|
33
|
-
return
|
|
35
|
+
return entry.values.join(delim);
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
38
|
getAll(key) {
|
|
37
|
-
return this.#raw.get(key) ?? [];
|
|
39
|
+
return this.#raw.get(key.toLowerCase())?.values ?? [];
|
|
38
40
|
}
|
|
39
41
|
has(key) {
|
|
40
|
-
return this.#raw.has(key);
|
|
42
|
+
return this.#raw.has(key.toLowerCase());
|
|
41
43
|
}
|
|
42
|
-
keys() {
|
|
43
|
-
|
|
44
|
+
*keys() {
|
|
45
|
+
for (const { key } of this.#raw.values()) {
|
|
46
|
+
yield key;
|
|
47
|
+
}
|
|
44
48
|
}
|
|
45
49
|
*values(delim = ", ") {
|
|
46
|
-
for (const
|
|
47
|
-
yield
|
|
50
|
+
for (const { values } of this.#raw.values()) {
|
|
51
|
+
yield values.join(delim);
|
|
48
52
|
}
|
|
49
53
|
}
|
|
50
54
|
*valuesAll() {
|
|
51
|
-
for (const
|
|
52
|
-
yield*
|
|
55
|
+
for (const { values } of this.#raw.values()) {
|
|
56
|
+
yield* values;
|
|
53
57
|
}
|
|
54
58
|
}
|
|
55
59
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type MhtmlHeaders } from "./headers";
|
|
1
|
+
import { type MhtmlHeaders } from "./headers.js";
|
|
2
2
|
export type { MhtmlHeaders };
|
|
3
3
|
/** a file that was encoded in MHTML format */
|
|
4
4
|
export interface MhtmlFile {
|
|
@@ -15,7 +15,28 @@ export interface MhtmlFile {
|
|
|
15
15
|
export type Decoder = (lines: AsyncIterable<Uint8Array>) => AsyncIterable<Uint8Array>;
|
|
16
16
|
/** options for mhtml parsing */
|
|
17
17
|
export interface ParseOptions {
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* custom decoders keyed by (lowercased) Content-Transfer-Encoding
|
|
20
|
+
*
|
|
21
|
+
* Use these to handle an encoding the defaults don't, or to override one. A
|
|
22
|
+
* {@link Decoder} turns the CRLF-split lines of a part into its decoded
|
|
23
|
+
* bytes; the parser strips the CRLFs when splitting, so a passthrough decoder
|
|
24
|
+
* re-emits them between lines:
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* const crlf = new Uint8Array([13, 10]);
|
|
28
|
+
* const decoderOverrides = new Map([
|
|
29
|
+
* ["binary", async function* (lines) {
|
|
30
|
+
* let first = true;
|
|
31
|
+
* for await (const line of lines) {
|
|
32
|
+
* if (!first) yield crlf;
|
|
33
|
+
* first = false;
|
|
34
|
+
* yield line;
|
|
35
|
+
* }
|
|
36
|
+
* }],
|
|
37
|
+
* ]);
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
19
40
|
decoderOverrides?: Map<string, Decoder>;
|
|
20
41
|
}
|
|
21
42
|
/**
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { toByteArray } from "base64-js";
|
|
2
|
-
import { Headers } from "./headers";
|
|
3
|
-
import { bytesEqual, collect, decodeBase64, decodeBinary, decodeIdentity, decodeQuotedPrintable, splitStream, } from "./utils";
|
|
2
|
+
import { Headers } from "./headers.js";
|
|
3
|
+
import { bytesEqual, collect, decodeBase64, decodeBinary, decodeIdentity, decodeQuotedPrintable, isHexDigit, splitStream, } from "./utils.js";
|
|
4
4
|
// NOTE we use this for ascii because it's faster than manual, but may cause
|
|
5
5
|
// unexpected errors if the assumption is violated
|
|
6
6
|
const encoder = new TextEncoder();
|
|
@@ -21,9 +21,12 @@ function decodeQEncoding(text) {
|
|
|
21
21
|
}
|
|
22
22
|
else if (code === 61) {
|
|
23
23
|
// encoded character
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
const high = text.charCodeAt(++ind);
|
|
25
|
+
const low = text.charCodeAt(++ind);
|
|
26
|
+
if (!isHexDigit(high) || !isHexDigit(low)) {
|
|
27
|
+
throw new Error(`got invalid hex escape when decoding q-quoted word: "${text}"`);
|
|
28
|
+
}
|
|
29
|
+
val = parseInt(String.fromCharCode(high, low), 16);
|
|
27
30
|
}
|
|
28
31
|
else {
|
|
29
32
|
// residual ascii
|
|
@@ -36,7 +39,6 @@ function decodeQEncoding(text) {
|
|
|
36
39
|
const encodeFormat = /^=\?([^?\s]+)\?([BQ])\?([^?\s]+)\?=$/;
|
|
37
40
|
/** decode a header line that may have an extra encoding in it */
|
|
38
41
|
function decodeLine(line) {
|
|
39
|
-
// TODO this might make more sense as a function as part of a regex replaceAll
|
|
40
42
|
const match = encodeFormat.exec(line);
|
|
41
43
|
if (match) {
|
|
42
44
|
const [, charset, encoding, text] = match;
|
|
@@ -66,7 +68,6 @@ async function parseHeaders(iter) {
|
|
|
66
68
|
let key = "";
|
|
67
69
|
let val = "";
|
|
68
70
|
for (;;) {
|
|
69
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
70
71
|
const { done, value } = await iter.next();
|
|
71
72
|
if (done) {
|
|
72
73
|
throw new Error("didn't find an empty line to signify the end of header parsing");
|
|
@@ -81,12 +82,12 @@ async function parseHeaders(iter) {
|
|
|
81
82
|
headers.append(key, val);
|
|
82
83
|
}
|
|
83
84
|
if (line) {
|
|
84
|
-
const delim = line.indexOf(":
|
|
85
|
+
const delim = line.indexOf(":");
|
|
85
86
|
if (delim === -1) {
|
|
86
87
|
throw new Error(`header line didn't have key-value delimiter: "${line}"`);
|
|
87
88
|
}
|
|
88
89
|
key = line.slice(0, delim);
|
|
89
|
-
val = decodeLine(line.slice(delim +
|
|
90
|
+
val = decodeLine(line.slice(delim + 1).replace(/^\s+/, ""));
|
|
90
91
|
}
|
|
91
92
|
else {
|
|
92
93
|
return headers;
|
|
@@ -102,7 +103,6 @@ const defaultDecoders = new Map([
|
|
|
102
103
|
["8bit", decodeIdentity],
|
|
103
104
|
["binary", decodeBinary],
|
|
104
105
|
]);
|
|
105
|
-
// eslint-disable-next-line spellcheck/spell-checker
|
|
106
106
|
/**
|
|
107
107
|
* extract the boundary condition from a multipart header
|
|
108
108
|
*
|
|
@@ -116,12 +116,14 @@ function getBoundary(headers) {
|
|
|
116
116
|
let bound;
|
|
117
117
|
let multipart = false;
|
|
118
118
|
for (const field of contentType.split(/;\s*/)) {
|
|
119
|
-
|
|
119
|
+
// the media type and the boundary parameter name are case-insensitive, but
|
|
120
|
+
// the boundary value itself is not, so match on a lowercased copy
|
|
121
|
+
const lower = field.toLowerCase();
|
|
122
|
+
if (lower.startsWith("multipart/")) {
|
|
120
123
|
multipart = true;
|
|
121
124
|
}
|
|
122
|
-
else if (
|
|
125
|
+
else if (lower.startsWith("boundary=")) {
|
|
123
126
|
bound = field.slice(9);
|
|
124
|
-
// TODO handling of quoted fields is not great
|
|
125
127
|
if (bound.startsWith('"') && bound.endsWith('"')) {
|
|
126
128
|
bound = bound.slice(1, -1);
|
|
127
129
|
}
|
|
@@ -150,12 +152,11 @@ export async function* parseMhtml(stream, { decoderOverrides = new Map() } = {})
|
|
|
150
152
|
const lines = splitStream(stream, crlf);
|
|
151
153
|
let bound = null;
|
|
152
154
|
let cont = true;
|
|
153
|
-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
154
155
|
while (cont) {
|
|
155
156
|
// parse out headers and get encoding for content
|
|
156
157
|
const headers = await parseHeaders(lines);
|
|
157
158
|
const [boundary, terminus] = bound ?? (bound = getBoundary(headers));
|
|
158
|
-
const encoding = headers.get("Content-Transfer-Encoding") ?? "7bit";
|
|
159
|
+
const encoding = (headers.get("Content-Transfer-Encoding") ?? "7bit").toLowerCase();
|
|
159
160
|
const decode = decoders.get(encoding);
|
|
160
161
|
if (decode === undefined) {
|
|
161
162
|
throw new Error(`unhandled encoding type: ${encoding}`);
|
|
@@ -166,7 +167,6 @@ export async function* parseMhtml(stream, { decoderOverrides = new Map() } = {})
|
|
|
166
167
|
[Symbol.asyncIterator]() {
|
|
167
168
|
return {
|
|
168
169
|
async next() {
|
|
169
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
170
170
|
const { done, value } = await lines.next();
|
|
171
171
|
if (done) {
|
|
172
172
|
throw new Error(`stream didn't end with the appropriate termination boundary: ${decoder.decode(terminus)}`);
|
package/dist/mhtml.esm.min.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var
|
|
1
|
+
var R=h;var m=[],Z=[],f=typeof Uint8Array<"u"?Uint8Array:Array,q="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";for($=0,Q=q.length;$<Q;++$)m[$]=q[$],Z[q.charCodeAt($)]=$;var $,Q;Z[45]=62;Z[95]=63;function L(G){var J=G.length;if(J%4>0)throw Error("Invalid string. Length must be a multiple of 4");var K=G.indexOf("=");if(K===-1)K=J;var M=K===J?0:4-K%4;return[K,M]}function k(G,J,K){return(J+K)*3/4-K}function h(G){var J,K=L(G),M=K[0],U=K[1],W=new f(k(G,M,U)),X=0,V=U>0?M-4:M,Y;for(Y=0;Y<V;Y+=4)J=Z[G.charCodeAt(Y)]<<18|Z[G.charCodeAt(Y+1)]<<12|Z[G.charCodeAt(Y+2)]<<6|Z[G.charCodeAt(Y+3)],W[X++]=J>>16&255,W[X++]=J>>8&255,W[X++]=J&255;if(U===2)J=Z[G.charCodeAt(Y)]<<2|Z[G.charCodeAt(Y+1)]>>4,W[X++]=J&255;if(U===1)J=Z[G.charCodeAt(Y)]<<10|Z[G.charCodeAt(Y+1)]<<4|Z[G.charCodeAt(Y+2)]>>2,W[X++]=J>>8&255,W[X++]=J&255;return W}class E{#G=new Map;[Symbol.iterator](){return this.entries()}append(G,J){let K=this.#G.get(G.toLowerCase());if(K===void 0)this.#G.set(G.toLowerCase(),{key:G,values:[J]});else K.values.push(J)}*entries(G=", "){for(let{key:J,values:K}of this.#G.values())yield[J,K.join(G)]}*entriesAll(){for(let{key:G,values:J}of this.#G.values())for(let K of J)yield[G,K]}get(G,J=", "){let K=this.#G.get(G.toLowerCase());if(K===void 0)return null;else return K.values.join(J)}getAll(G){return this.#G.get(G.toLowerCase())?.values??[]}has(G){return this.#G.has(G.toLowerCase())}*keys(){for(let{key:G}of this.#G.values())yield G}*values(G=", "){for(let{values:J}of this.#G.values())yield J.join(G)}*valuesAll(){for(let{values:G}of this.#G.values())yield*G}}function F(G,J){if(G.length!==J.length)return!1;else if(G.byteOffset%4===J.byteOffset%4){let K=(4-G.byteOffset%4)%4,M=Math.floor((G.byteLength-K)/4),U=K+M*4,W=new Uint32Array(G.buffer,G.byteOffset+K,M),X=new Uint32Array(J.buffer,J.byteOffset+K,M);for(let V=0;V<K;++V)if(G[V]!==J[V])return!1;for(let V=0;V<M;++V)if(W[V]!==X[V])return!1;for(let V=U;V<G.length;++V)if(G[V]!==J[V])return!1;return!0}else{for(let K of G.keys())if(G[K]!==J[K])return!1;return!0}}function y(G,J){return G.findIndex((K,M)=>{if(K!==J[0]||M+J.length>G.length)return!1;else{for(let U=1;U<J.length;++U)if(G[M+U]!==J[U])return!1;return!0}})}async function*T(G,J){let K=new Uint8Array(0);for await(let M of G){K=K.length?j([K,M]):M;let U;while((U=y(K,J))!==-1)yield K.subarray(0,U),K=K.subarray(U+J.length)}yield K}function j(G){let J=G.reduce((U,W)=>U+W.length,0),K=new Uint8Array(J),M=0;for(let U of G)K.set(U,M),M+=U.length;return K}async function C(G){let J=[];for await(let K of G)J.push(K);return j(J)}var D=new Uint8Array([13,10]);function S(G){return G>=48&&G<=57||G>=65&&G<=70||G>=97&&G<=102}async function*A(G,J=D){let K=!1;for await(let M of G){let U=new Uint8Array(M.length+J.length),W=0;if(K)U.set(J,W),W+=J.length,K=!1;let X=!1;for(let V=0;V<M.length;++V){let Y=M[V];if(Y>=128)throw Error(`got non-ascii character when decoding quoted printable: ${Y}`);if(Y!==61)U[W++]=Y;else{let z=M[++V];if(z===void 0)X=!0;else{let P=M[++V];if(P===void 0)throw Error("quoted printable escape (=) was not followed by two bytes");else if(!S(z)||!S(P))throw Error(`quoted printable escape (=) was not followed by two hex digits: "=${String.fromCharCode(z,P)}"`);U[W++]=parseInt(String.fromCharCode(z,P),16)}}}if(!X)K=!0;yield U.subarray(0,W)}}var b=new TextDecoder;async function*I(G){let J="";for await(let K of G){J+=b.decode(K).replace(/\s/g,"");let M=J.length-J.length%4;if(M>0)yield R(J.slice(0,M)),J=J.slice(M)}if(J.length>0)yield R(J)}async function*N(G,J=D){let K=!0;for await(let M of G){if(K)K=!1;else yield J;yield M}}function H(){throw Error("binary transfer-encoding is explicitly not supported and trying to add an implementation will likely result in unexpected results, but if you want to handle it anyway, override `binary` in `decoderOverrides`")}var B=new TextEncoder,x=new TextDecoder;function p(G){let J=new Uint8Array(G.length),K=0;for(let M=0;M<G.length;++M){let U=G.charCodeAt(M),W;if(U>=128)throw Error(`got non-ascii character when decoding q-quoted word: "${G}"`);else if(U===95)W=32;else if(U===61){let X=G.charCodeAt(++M),V=G.charCodeAt(++M);if(!S(X)||!S(V))throw Error(`got invalid hex escape when decoding q-quoted word: "${G}"`);W=parseInt(String.fromCharCode(X,V),16)}else W=U;J[K++]=W}return J.subarray(0,K)}var c=/^=\?([^?\s]+)\?([BQ])\?([^?\s]+)\?=$/;function w(G){let J=c.exec(G);if(J){let[,K,M,U]=J,W;if(M==="Q")W=p(U);else W=R(U);return new TextDecoder(K).decode(W)}else return G}async function u(G){let J=new E,K="",M="";for(;;){let{done:U,value:W}=await G.next();if(U)throw Error("didn't find an empty line to signify the end of header parsing");let X=x.decode(W);if(/^\s/.test(X))M+=w(X.substring(1));else{if(K)J.append(K,M);if(X){let V=X.indexOf(":");if(V===-1)throw Error(`header line didn't have key-value delimiter: "${X}"`);K=X.slice(0,V),M=w(X.slice(V+1).replace(/^\s+/,""))}else return J}}}var a=new Map([["7bit",N],["base64",I],["quoted-printable",A],["8bit",N],["binary",H]]);function s(G){let J=G.get("Content-Type");if(J===null)throw Error(`first headers didn't contain a content type: ${JSON.stringify(Object.fromEntries(G))}`);let K,M=!1;for(let X of J.split(/;\s*/)){let V=X.toLowerCase();if(V.startsWith("multipart/"))M=!0;else if(V.startsWith("boundary=")){if(K=X.slice(9),K.startsWith('"')&&K.endsWith('"'))K=K.slice(1,-1)}}if(!M||K===void 0)throw Error("first content type header didn't contain 'multipart/...' and a boundary string");let U=B.encode(`--${K}`),W=B.encode(`--${K}--`);return[U,W]}async function*e(G,{decoderOverrides:J=new Map}={}){let K=new Map([...a.entries(),...J.entries()]),M=new Uint8Array([13,10]),U=T(G,M),W=null,X=!0;while(X){let V=await u(U),[Y,z]=W??(W=s(V)),P=(V.get("Content-Transfer-Encoding")??"7bit").toLowerCase(),O=K.get(P);if(O===void 0)throw Error(`unhandled encoding type: ${P}`);let g=await C(O({[Symbol.asyncIterator](){return{async next(){let{done:v,value:_}=await U.next();if(v)throw Error(`stream didn't end with the appropriate termination boundary: ${x.decode(z)}`);else if(F(_,Y))return{done:!0,value:void 0};else if(F(_,z))return X=!1,{done:!0,value:void 0};else return{value:_}}}}}));yield{headers:V,content:g}}}export{e as parseMhtml};
|
package/dist/utils.d.ts
CHANGED
|
@@ -18,21 +18,38 @@ export declare function splitStream(iter: AsyncIterable<Uint8Array>, split: Uint
|
|
|
18
18
|
* collect an async iterable of buffers into one
|
|
19
19
|
*/
|
|
20
20
|
export declare function collect(stream: AsyncIterable<Uint8Array>): Promise<Uint8Array>;
|
|
21
|
+
/** whether a character code is a hex digit (0-9, A-F, a-f) */
|
|
22
|
+
export declare function isHexDigit(code: number): boolean;
|
|
21
23
|
/**
|
|
22
24
|
* decoder for quoted printable
|
|
23
25
|
*
|
|
24
26
|
* If quoted printable "lines" aren't escaped with an "=" then a new line needs
|
|
25
|
-
* to be inserted. We use `newLine
|
|
27
|
+
* to be inserted. We use `newLine`, which defaults to CRLF to match the
|
|
28
|
+
* canonical MIME form; pass a custom separator (e.g. a single "\n") to
|
|
29
|
+
* normalize instead. The separator goes between lines; the CRLF before the MIME
|
|
30
|
+
* boundary belongs to the delimiter, not the body.
|
|
26
31
|
*/
|
|
27
32
|
export declare function decodeQuotedPrintable(lines: AsyncIterable<Uint8Array>, newLine?: Uint8Array): AsyncIterableIterator<Uint8Array>;
|
|
28
33
|
/**
|
|
29
34
|
* decoder for base64
|
|
35
|
+
*
|
|
36
|
+
* RFC 2045 requires decoders to ignore line breaks and decode the concatenated
|
|
37
|
+
* stream, so producers may wrap at any column. We strip whitespace and buffer
|
|
38
|
+
* characters that don't yet form a complete four-character quantum, flushing
|
|
39
|
+
* the remainder at the end of the part.
|
|
30
40
|
*/
|
|
31
41
|
export declare function decodeBase64(lines: AsyncIterable<Uint8Array>): AsyncIterableIterator<Uint8Array>;
|
|
32
42
|
/**
|
|
33
43
|
* decoder for 7bit and 8bit
|
|
34
|
-
|
|
35
|
-
|
|
44
|
+
*
|
|
45
|
+
* 7bit/8bit apply no transfer transformation, so the content bytes are the
|
|
46
|
+
* payload as-is. parseMhtml splits the stream on CRLF to find part boundaries,
|
|
47
|
+
* so we re-insert `newLine` (defaulting to CRLF) between lines to restore the
|
|
48
|
+
* original bytes exactly. Pass `newLine` (e.g. a single "\n") to normalize line
|
|
49
|
+
* endings instead. The separator goes between lines; the CRLF before the
|
|
50
|
+
* boundary belongs to the delimiter, not the body.
|
|
51
|
+
*/
|
|
52
|
+
export declare function decodeIdentity(lines: AsyncIterable<Uint8Array>, newLine?: Uint8Array): AsyncIterableIterator<Uint8Array>;
|
|
36
53
|
/**
|
|
37
54
|
* decoder for binary
|
|
38
55
|
*
|
package/dist/utils.js
CHANGED
|
@@ -111,19 +111,33 @@ export async function collect(stream) {
|
|
|
111
111
|
}
|
|
112
112
|
return concat(chunks);
|
|
113
113
|
}
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
const crlf = new Uint8Array([13, 10]);
|
|
115
|
+
/** whether a character code is a hex digit (0-9, A-F, a-f) */
|
|
116
|
+
export function isHexDigit(code) {
|
|
117
|
+
return ((code >= 48 && code <= 57) ||
|
|
118
|
+
(code >= 65 && code <= 70) ||
|
|
119
|
+
(code >= 97 && code <= 102));
|
|
120
|
+
}
|
|
116
121
|
/**
|
|
117
122
|
* decoder for quoted printable
|
|
118
123
|
*
|
|
119
124
|
* If quoted printable "lines" aren't escaped with an "=" then a new line needs
|
|
120
|
-
* to be inserted. We use `newLine
|
|
125
|
+
* to be inserted. We use `newLine`, which defaults to CRLF to match the
|
|
126
|
+
* canonical MIME form; pass a custom separator (e.g. a single "\n") to
|
|
127
|
+
* normalize instead. The separator goes between lines; the CRLF before the MIME
|
|
128
|
+
* boundary belongs to the delimiter, not the body.
|
|
121
129
|
*/
|
|
122
|
-
export async function* decodeQuotedPrintable(lines, newLine =
|
|
130
|
+
export async function* decodeQuotedPrintable(lines, newLine = crlf) {
|
|
131
|
+
let pendingNewLine = false; // a hard line break from the previous line
|
|
123
132
|
for await (const bytes of lines) {
|
|
124
133
|
const res = new Uint8Array(bytes.length + newLine.length);
|
|
125
|
-
let softLine = false; // if newline wasn't escaped so we need to add
|
|
126
134
|
let destInd = 0;
|
|
135
|
+
if (pendingNewLine) {
|
|
136
|
+
res.set(newLine, destInd);
|
|
137
|
+
destInd += newLine.length;
|
|
138
|
+
pendingNewLine = false;
|
|
139
|
+
}
|
|
140
|
+
let softLine = false; // if newline was escaped, so we shouldn't add one
|
|
127
141
|
for (let ind = 0; ind < bytes.length; ++ind) {
|
|
128
142
|
const code = bytes[ind];
|
|
129
143
|
if (code >= 128) {
|
|
@@ -144,16 +158,15 @@ export async function* decodeQuotedPrintable(lines, newLine = defaultNewLine) {
|
|
|
144
158
|
if (second === undefined) {
|
|
145
159
|
throw new Error("quoted printable escape (=) was not followed by two bytes");
|
|
146
160
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
res[destInd++] =
|
|
161
|
+
else if (!isHexDigit(first) || !isHexDigit(second)) {
|
|
162
|
+
throw new Error(`quoted printable escape (=) was not followed by two hex digits: "=${String.fromCharCode(first, second)}"`);
|
|
163
|
+
}
|
|
164
|
+
res[destInd++] = parseInt(String.fromCharCode(first, second), 16);
|
|
151
165
|
}
|
|
152
166
|
}
|
|
153
167
|
}
|
|
154
168
|
if (!softLine) {
|
|
155
|
-
|
|
156
|
-
destInd += newLine.length;
|
|
169
|
+
pendingNewLine = true;
|
|
157
170
|
}
|
|
158
171
|
yield res.subarray(0, destInd);
|
|
159
172
|
}
|
|
@@ -161,17 +174,45 @@ export async function* decodeQuotedPrintable(lines, newLine = defaultNewLine) {
|
|
|
161
174
|
const decoder = new TextDecoder();
|
|
162
175
|
/**
|
|
163
176
|
* decoder for base64
|
|
177
|
+
*
|
|
178
|
+
* RFC 2045 requires decoders to ignore line breaks and decode the concatenated
|
|
179
|
+
* stream, so producers may wrap at any column. We strip whitespace and buffer
|
|
180
|
+
* characters that don't yet form a complete four-character quantum, flushing
|
|
181
|
+
* the remainder at the end of the part.
|
|
164
182
|
*/
|
|
165
183
|
export async function* decodeBase64(lines) {
|
|
184
|
+
let residual = "";
|
|
166
185
|
for await (const bytes of lines) {
|
|
167
|
-
|
|
186
|
+
residual += decoder.decode(bytes).replace(/\s/g, "");
|
|
187
|
+
const usable = residual.length - (residual.length % 4);
|
|
188
|
+
if (usable > 0) {
|
|
189
|
+
yield toByteArray(residual.slice(0, usable));
|
|
190
|
+
residual = residual.slice(usable);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (residual.length > 0) {
|
|
194
|
+
yield toByteArray(residual);
|
|
168
195
|
}
|
|
169
196
|
}
|
|
170
197
|
/**
|
|
171
198
|
* decoder for 7bit and 8bit
|
|
199
|
+
*
|
|
200
|
+
* 7bit/8bit apply no transfer transformation, so the content bytes are the
|
|
201
|
+
* payload as-is. parseMhtml splits the stream on CRLF to find part boundaries,
|
|
202
|
+
* so we re-insert `newLine` (defaulting to CRLF) between lines to restore the
|
|
203
|
+
* original bytes exactly. Pass `newLine` (e.g. a single "\n") to normalize line
|
|
204
|
+
* endings instead. The separator goes between lines; the CRLF before the
|
|
205
|
+
* boundary belongs to the delimiter, not the body.
|
|
172
206
|
*/
|
|
173
|
-
export async function* decodeIdentity(lines) {
|
|
207
|
+
export async function* decodeIdentity(lines, newLine = crlf) {
|
|
208
|
+
let first = true;
|
|
174
209
|
for await (const bytes of lines) {
|
|
210
|
+
if (first) {
|
|
211
|
+
first = false;
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
yield newLine;
|
|
215
|
+
}
|
|
175
216
|
yield bytes;
|
|
176
217
|
}
|
|
177
218
|
}
|
|
@@ -182,5 +223,5 @@ export async function* decodeIdentity(lines) {
|
|
|
182
223
|
* error.
|
|
183
224
|
*/
|
|
184
225
|
export function decodeBinary() {
|
|
185
|
-
throw new Error("binary transfer-encoding is explicitly not supported and trying to add an implementation will likely result in unexpected results, but if you want to
|
|
226
|
+
throw new Error("binary transfer-encoding is explicitly not supported and trying to add an implementation will likely result in unexpected results, but if you want to handle it anyway, override `binary` in `decoderOverrides`");
|
|
186
227
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mhtml-stream",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "3.0.1",
|
|
4
|
+
"description": "Streaming MHTML parser",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mhtml",
|
|
7
7
|
"stream"
|
|
@@ -10,9 +10,13 @@
|
|
|
10
10
|
"type": "git",
|
|
11
11
|
"url": "git+https://github.com/erikbrinkman/mhtml-stream.git"
|
|
12
12
|
},
|
|
13
|
+
"homepage": "https://erikbrinkman.github.io/mhtml-stream/",
|
|
13
14
|
"author": "Erik Brinkman <erik.brinkman@gmail.com>",
|
|
14
15
|
"license": "MIT",
|
|
15
16
|
"type": "module",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20.19"
|
|
19
|
+
},
|
|
16
20
|
"sideEffects": false,
|
|
17
21
|
"types": "dist/index.d.ts",
|
|
18
22
|
"module": "dist/mhtml.esm.min.js",
|
|
@@ -32,12 +36,13 @@
|
|
|
32
36
|
"lint": "tsc && biome check && typedoc --emit none",
|
|
33
37
|
"doc": "typedoc",
|
|
34
38
|
"export": "tsc -p tsconfig.build.json && bun build src/index.ts --minify --outfile dist/mhtml.esm.min.js",
|
|
35
|
-
"prepack": "bun lint && bun test --coverage && bun export && publint"
|
|
39
|
+
"prepack": "bun lint && bun test --coverage && bun export && publint --strict && npm_config_ignore_scripts=true attw --pack --profile esm-only"
|
|
36
40
|
},
|
|
37
41
|
"dependencies": {
|
|
38
42
|
"base64-js": "^1.5.1"
|
|
39
43
|
},
|
|
40
44
|
"devDependencies": {
|
|
45
|
+
"@arethetypeswrong/cli": "^0.18.4",
|
|
41
46
|
"@biomejs/biome": "^2.4.15",
|
|
42
47
|
"@types/base64-js": "^1.5.0",
|
|
43
48
|
"@types/bun": "^1.3.14",
|
package/dist/index.spec.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/index.spec.js
DELETED
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { parseMhtml } from ".";
|
|
3
|
-
import { Headers } from "./headers";
|
|
4
|
-
const encoder = new TextEncoder();
|
|
5
|
-
const decoder = new TextDecoder();
|
|
6
|
-
// from https://en.wikipedia.org/wiki/MIME#Multipart_messages
|
|
7
|
-
// eslint-disable-next-line spellcheck/spell-checker
|
|
8
|
-
const example = `MIME-Version: 1.0
|
|
9
|
-
Subject: =?iso-8859-1?Q?=A1Hola,_se=F1or!?=
|
|
10
|
-
Content-Type: multipart/mixed; boundary=frontier
|
|
11
|
-
|
|
12
|
-
This is a message with multiple parts in MIME format.
|
|
13
|
-
--frontier
|
|
14
|
-
Content-Type: text/plain
|
|
15
|
-
|
|
16
|
-
This is the body of the message.
|
|
17
|
-
--frontier
|
|
18
|
-
Content-Type: application/octet-stream
|
|
19
|
-
Content-Transfer-Encoding: base64
|
|
20
|
-
|
|
21
|
-
PGh0bWw+CiAgPGhlYWQ+CiAgPC9oZWFkPgogIDxib2R5PgogICAgPHA+VGhpcyBpcyB0aGUg
|
|
22
|
-
Ym9keSBvZiB0aGUgbWVzc2FnZS48L3A+CiAgPC9ib2R5Pgo8L2h0bWw+Cg==
|
|
23
|
-
--frontier--
|
|
24
|
-
`;
|
|
25
|
-
const newYorker = `From: <Saved by Blink>
|
|
26
|
-
Snapshot-Content-Location: https://www.newyorker.com/culture/cultural-comment/what-the-twilight-zone-reveals-about-todays-prestige-tv
|
|
27
|
-
Subject: =?utf-8?Q?What=20=E2=80=9CThe=20Twilight=20Zone=E2=80=9D=20Reveals=20Abou?=
|
|
28
|
-
=?utf-8?Q?t=20Today=E2=80=99s=20Prestige=20TV=20|=20The=20New=20Yorker?=
|
|
29
|
-
Date: Sat, 16 Apr 2022 17:48:31 -0000
|
|
30
|
-
MIME-Version: 1.0
|
|
31
|
-
Content-Type: multipart/related;
|
|
32
|
-
type="text/html";
|
|
33
|
-
boundary="----MultipartBoundary--NYswbLinUCqE8KaJecg8DEV6giqFeyGLtHeT0qLB4h----"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
------MultipartBoundary--NYswbLinUCqE8KaJecg8DEV6giqFeyGLtHeT0qLB4h------
|
|
37
|
-
`;
|
|
38
|
-
// eslint-disable-next-line @typescript-eslint/require-await
|
|
39
|
-
async function* stringToStream(file) {
|
|
40
|
-
yield encoder.encode(file.replaceAll("\n", "\r\n"));
|
|
41
|
-
}
|
|
42
|
-
async function consume(iter) {
|
|
43
|
-
for await (const _ of iter) {
|
|
44
|
-
//
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
describe("parseMhtml()", () => {
|
|
48
|
-
test("example", async () => {
|
|
49
|
-
const files = [];
|
|
50
|
-
for await (const file of parseMhtml(stringToStream(example))) {
|
|
51
|
-
files.push(file);
|
|
52
|
-
}
|
|
53
|
-
const headers = files.map(({ headers }) => Object.fromEntries(headers));
|
|
54
|
-
const expectedHeaders = [
|
|
55
|
-
{
|
|
56
|
-
"MIME-Version": "1.0",
|
|
57
|
-
// eslint-disable-next-line spellcheck/spell-checker
|
|
58
|
-
Subject: "¡Hola, señor!",
|
|
59
|
-
"Content-Type": "multipart/mixed; boundary=frontier",
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
"Content-Type": "text/plain",
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
"Content-Type": "application/octet-stream",
|
|
66
|
-
"Content-Transfer-Encoding": "base64",
|
|
67
|
-
},
|
|
68
|
-
];
|
|
69
|
-
expect(headers).toEqual(expectedHeaders);
|
|
70
|
-
const text = files
|
|
71
|
-
.slice(0, 2)
|
|
72
|
-
.map(({ content }) => decoder.decode(content));
|
|
73
|
-
const expectedText = [
|
|
74
|
-
"This is a message with multiple parts in MIME format.",
|
|
75
|
-
"This is the body of the message.",
|
|
76
|
-
];
|
|
77
|
-
expect(text).toEqual(expectedText);
|
|
78
|
-
});
|
|
79
|
-
test("other headers", async () => {
|
|
80
|
-
// eslint-disable-next-line spellcheck/spell-checker
|
|
81
|
-
const content = `MIME-Version: 1.0
|
|
82
|
-
From: this is a wrapped: header
|
|
83
|
-
with an extra delimiter in: both sections
|
|
84
|
-
Subject: =?utf-8?B?QmFzZSA2NCDigJQgTW96aWxsYSBEZXZlbG9wZXIgTmV0d29yaw==?=
|
|
85
|
-
Content-Type: multipart/mixed; boundary="quoted-frontier"
|
|
86
|
-
|
|
87
|
-
This is a message with multiple parts in MIME format.
|
|
88
|
-
--quoted-frontier--
|
|
89
|
-
`;
|
|
90
|
-
const files = [];
|
|
91
|
-
for await (const file of parseMhtml(stringToStream(content))) {
|
|
92
|
-
files.push(file);
|
|
93
|
-
}
|
|
94
|
-
const headers = files.map(({ headers }) => Object.fromEntries(headers));
|
|
95
|
-
const expectedHeaders = [
|
|
96
|
-
{
|
|
97
|
-
"MIME-Version": "1.0",
|
|
98
|
-
From: "this is a wrapped: header with an extra delimiter in: both sections",
|
|
99
|
-
Subject: "Base 64 \u2014 Mozilla Developer Network",
|
|
100
|
-
"Content-Type": 'multipart/mixed; boundary="quoted-frontier"',
|
|
101
|
-
},
|
|
102
|
-
];
|
|
103
|
-
expect(headers).toEqual(expectedHeaders);
|
|
104
|
-
const text = files.map(({ content }) => decoder.decode(content));
|
|
105
|
-
const expectedText = [
|
|
106
|
-
"This is a message with multiple parts in MIME format.",
|
|
107
|
-
];
|
|
108
|
-
expect(text).toEqual(expectedText);
|
|
109
|
-
});
|
|
110
|
-
test("escaped multi-line headers", async () => {
|
|
111
|
-
const files = [];
|
|
112
|
-
for await (const file of parseMhtml(stringToStream(newYorker))) {
|
|
113
|
-
files.push(file);
|
|
114
|
-
}
|
|
115
|
-
const headers = files.map(({ headers }) => Object.fromEntries(headers));
|
|
116
|
-
const expectedHeaders = [
|
|
117
|
-
{
|
|
118
|
-
// eslint-disable-next-line spellcheck/spell-checker
|
|
119
|
-
"Content-Type": `multipart/related;type="text/html";boundary="----MultipartBoundary--NYswbLinUCqE8KaJecg8DEV6giqFeyGLtHeT0qLB4h----"`,
|
|
120
|
-
Date: "Sat, 16 Apr 2022 17:48:31 -0000",
|
|
121
|
-
From: "<Saved by Blink>",
|
|
122
|
-
"MIME-Version": "1.0",
|
|
123
|
-
"Snapshot-Content-Location": "https://www.newyorker.com/culture/cultural-comment/what-the-twilight-zone-reveals-about-todays-prestige-tv",
|
|
124
|
-
Subject:
|
|
125
|
-
// eslint-disable-next-line spellcheck/spell-checker
|
|
126
|
-
"What “The Twilight Zone” Reveals About Today’s Prestige TV | The New Yorker",
|
|
127
|
-
},
|
|
128
|
-
];
|
|
129
|
-
expect(headers).toEqual(expectedHeaders);
|
|
130
|
-
const text = files.map(({ content }) => decoder.decode(content));
|
|
131
|
-
const expectedText = [""];
|
|
132
|
-
expect(text).toEqual(expectedText);
|
|
133
|
-
});
|
|
134
|
-
test("fails non-ascii in q-encoding", async () => {
|
|
135
|
-
// eslint-disable-next-line spellcheck/spell-checker
|
|
136
|
-
const content = `MIME-Version: 1.0
|
|
137
|
-
Subject: =?iso-8859-1?Q?=A1Hola,\xffse=F1or!?=
|
|
138
|
-
`;
|
|
139
|
-
const parser = parseMhtml(stringToStream(content));
|
|
140
|
-
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
141
|
-
await expect(consume(parser)).rejects.toThrow("got non-ascii character when decoding q-quoted word");
|
|
142
|
-
});
|
|
143
|
-
test("fails without empty header delimiter", async () => {
|
|
144
|
-
const content = `MIME-Version: 1.0`;
|
|
145
|
-
const parser = parseMhtml(stringToStream(content));
|
|
146
|
-
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
147
|
-
await expect(consume(parser)).rejects.toThrow("didn't find an empty line to signify the end of header parsing");
|
|
148
|
-
});
|
|
149
|
-
test("fails with invalid header", async () => {
|
|
150
|
-
const content = `MIME-Version: 1.0
|
|
151
|
-
invalid header
|
|
152
|
-
|
|
153
|
-
`;
|
|
154
|
-
const parser = parseMhtml(stringToStream(content));
|
|
155
|
-
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
156
|
-
await expect(consume(parser)).rejects.toThrow("header line didn't have key-value delimiter");
|
|
157
|
-
});
|
|
158
|
-
test("fails with missing Content-Type", async () => {
|
|
159
|
-
const content = `MIME-Version: 1.0
|
|
160
|
-
|
|
161
|
-
`;
|
|
162
|
-
const parser = parseMhtml(stringToStream(content));
|
|
163
|
-
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
164
|
-
await expect(consume(parser)).rejects.toThrow("first headers didn't contain a content type");
|
|
165
|
-
});
|
|
166
|
-
test("fails with missing multipart", async () => {
|
|
167
|
-
const content = `MIME-Version: 1.0
|
|
168
|
-
Content-Type: text/plain; boundary=frontier
|
|
169
|
-
|
|
170
|
-
`;
|
|
171
|
-
const parser = parseMhtml(stringToStream(content));
|
|
172
|
-
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
173
|
-
await expect(consume(parser)).rejects.toThrow("first content type header didn't contain");
|
|
174
|
-
});
|
|
175
|
-
test("fails with missing boundary", async () => {
|
|
176
|
-
const content = `MIME-Version: 1.0
|
|
177
|
-
Content-Type: multipart/mixed
|
|
178
|
-
|
|
179
|
-
`;
|
|
180
|
-
const parser = parseMhtml(stringToStream(content));
|
|
181
|
-
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
182
|
-
await expect(consume(parser)).rejects.toThrow("first content type header didn't contain");
|
|
183
|
-
});
|
|
184
|
-
test("fails with missing decoder", async () => {
|
|
185
|
-
const content = `MIME-Version: 1.0
|
|
186
|
-
Content-Transfer-Encoding: unknown
|
|
187
|
-
Content-Type: multipart/mixed; boundary=frontier
|
|
188
|
-
|
|
189
|
-
`;
|
|
190
|
-
const parser = parseMhtml(stringToStream(content));
|
|
191
|
-
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
192
|
-
await expect(consume(parser)).rejects.toThrow("unhandled encoding type: unknown");
|
|
193
|
-
});
|
|
194
|
-
test("fails without terminus", async () => {
|
|
195
|
-
const content = `MIME-Version: 1.0
|
|
196
|
-
Content-Type: multipart/mixed; boundary=frontier
|
|
197
|
-
|
|
198
|
-
This is a message with multiple parts in MIME format.
|
|
199
|
-
`;
|
|
200
|
-
const parser = parseMhtml(stringToStream(content));
|
|
201
|
-
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
202
|
-
await expect(consume(parser)).rejects.toThrow("stream didn't end with the appropriate termination boundary");
|
|
203
|
-
});
|
|
204
|
-
});
|
|
205
|
-
describe("MhtmlHeaders", () => {
|
|
206
|
-
const headers = new Headers();
|
|
207
|
-
headers.append("a", "b");
|
|
208
|
-
headers.append("a", "c");
|
|
209
|
-
headers.append("d", "e");
|
|
210
|
-
test("entries()", () => {
|
|
211
|
-
const entries = Object.fromEntries(headers.entries());
|
|
212
|
-
expect(entries).toEqual({ a: "b, c", d: "e" });
|
|
213
|
-
});
|
|
214
|
-
test("entriesAll()", () => {
|
|
215
|
-
const entriesAll = [...headers.entriesAll()].sort();
|
|
216
|
-
expect(entriesAll).toEqual([
|
|
217
|
-
["a", "b"],
|
|
218
|
-
["a", "c"],
|
|
219
|
-
["d", "e"],
|
|
220
|
-
]);
|
|
221
|
-
});
|
|
222
|
-
test("get()", () => {
|
|
223
|
-
expect(headers.get("a")).toStrictEqual("b, c");
|
|
224
|
-
expect(headers.get("d")).toStrictEqual("e");
|
|
225
|
-
expect(headers.get("f")).toStrictEqual(null);
|
|
226
|
-
});
|
|
227
|
-
test("getAll()", () => {
|
|
228
|
-
expect(headers.getAll("a")).toEqual(["b", "c"]);
|
|
229
|
-
expect(headers.getAll("d")).toEqual(["e"]);
|
|
230
|
-
expect(headers.getAll("f")).toEqual([]);
|
|
231
|
-
});
|
|
232
|
-
test("has()", () => {
|
|
233
|
-
expect(headers.has("a")).toBe(true);
|
|
234
|
-
expect(headers.has("f")).toBe(false);
|
|
235
|
-
});
|
|
236
|
-
test("keys()", () => {
|
|
237
|
-
const keys = [...headers.keys()].sort();
|
|
238
|
-
expect(keys).toEqual(["a", "d"]);
|
|
239
|
-
});
|
|
240
|
-
test("values()", () => {
|
|
241
|
-
const values = [...headers.values()].sort();
|
|
242
|
-
expect(values).toEqual(["b, c", "e"]);
|
|
243
|
-
});
|
|
244
|
-
test("valuesAll()", () => {
|
|
245
|
-
const valuesAll = [...headers.valuesAll()].sort();
|
|
246
|
-
expect(valuesAll).toEqual(["b", "c", "e"]);
|
|
247
|
-
});
|
|
248
|
-
});
|
package/dist/utils.spec.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/utils.spec.js
DELETED
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { bytesEqual, collect, decodeBinary, decodeIdentity, decodeQuotedPrintable, indexOf, splitStream, } from "./utils";
|
|
3
|
-
const encoder = new TextEncoder();
|
|
4
|
-
const decoder = new TextDecoder();
|
|
5
|
-
// eslint-disable-next-line @typescript-eslint/require-await
|
|
6
|
-
async function* toAsyncIterable(items) {
|
|
7
|
-
for (const item of items) {
|
|
8
|
-
yield item;
|
|
9
|
-
}
|
|
10
|
-
}
|
|
11
|
-
describe("bytesEqual()", () => {
|
|
12
|
-
test("length failure", () => {
|
|
13
|
-
const left = new Uint8Array([1, 2, 3]);
|
|
14
|
-
const right = new Uint8Array([1, 2, 3, 4]);
|
|
15
|
-
expect(bytesEqual(left, right)).toBe(false);
|
|
16
|
-
});
|
|
17
|
-
test("simple success", () => {
|
|
18
|
-
const left = new Uint8Array([1, 2, 3]);
|
|
19
|
-
const right = new Uint8Array([1, 2, 3]);
|
|
20
|
-
expect(bytesEqual(left, right)).toBe(true);
|
|
21
|
-
});
|
|
22
|
-
test("simple failure", () => {
|
|
23
|
-
const left = new Uint8Array([1, 2, 3]);
|
|
24
|
-
const right = new Uint8Array([1, 2, 4]);
|
|
25
|
-
expect(bytesEqual(left, right)).toBe(false);
|
|
26
|
-
});
|
|
27
|
-
test("fast path success", () => {
|
|
28
|
-
const left = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]).subarray(1);
|
|
29
|
-
const right = new Uint8Array([0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7]).subarray(5);
|
|
30
|
-
expect(bytesEqual(left, right)).toBe(true);
|
|
31
|
-
});
|
|
32
|
-
test("fast path failure start", () => {
|
|
33
|
-
const left = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]).subarray(1);
|
|
34
|
-
const right = new Uint8Array([0, 0, 0, 0, 0, 2, 2, 3, 4, 5, 6, 7]).subarray(5);
|
|
35
|
-
expect(bytesEqual(left, right)).toBe(false);
|
|
36
|
-
});
|
|
37
|
-
test("fast path failure middle", () => {
|
|
38
|
-
const left = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]).subarray(1);
|
|
39
|
-
const right = new Uint8Array([0, 0, 0, 0, 0, 1, 2, 4, 4, 5, 6, 7]).subarray(5);
|
|
40
|
-
expect(bytesEqual(left, right)).toBe(false);
|
|
41
|
-
});
|
|
42
|
-
test("fast path failure end", () => {
|
|
43
|
-
const left = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]).subarray(1);
|
|
44
|
-
const right = new Uint8Array([0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 8]).subarray(5);
|
|
45
|
-
expect(bytesEqual(left, right)).toBe(false);
|
|
46
|
-
});
|
|
47
|
-
test("slow path success", () => {
|
|
48
|
-
const left = new Uint8Array([1, 2, 3, 4, 5]);
|
|
49
|
-
const right = new Uint8Array([0, 1, 2, 3, 4, 5]).subarray(1);
|
|
50
|
-
expect(bytesEqual(left, right)).toBe(true);
|
|
51
|
-
});
|
|
52
|
-
test("slow path failure", () => {
|
|
53
|
-
const left = new Uint8Array([1, 2, 3, 4, 5]);
|
|
54
|
-
const right = new Uint8Array([0, 1, 2, 3, 4, 6]).subarray(1);
|
|
55
|
-
expect(bytesEqual(left, right)).toBe(false);
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
describe("indexOf()", () => {
|
|
59
|
-
test("simple success", () => {
|
|
60
|
-
const haystack = new Uint8Array([0, 1, 2]);
|
|
61
|
-
const needle = new Uint8Array([1]);
|
|
62
|
-
expect(indexOf(haystack, needle)).toEqual(1);
|
|
63
|
-
});
|
|
64
|
-
test("simple failure", () => {
|
|
65
|
-
const haystack = new Uint8Array([0, 1, 2]);
|
|
66
|
-
const needle = new Uint8Array([3]);
|
|
67
|
-
expect(indexOf(haystack, needle)).toEqual(-1);
|
|
68
|
-
});
|
|
69
|
-
test("complex success", () => {
|
|
70
|
-
const haystack = new Uint8Array([0, 1, 3, 1, 2, 3]);
|
|
71
|
-
const needle = new Uint8Array([1, 2]);
|
|
72
|
-
expect(indexOf(haystack, needle)).toEqual(3);
|
|
73
|
-
});
|
|
74
|
-
test("complex failure", () => {
|
|
75
|
-
const haystack = new Uint8Array([0, 1, 3, 1, 4, 3]);
|
|
76
|
-
const needle = new Uint8Array([1, 2]);
|
|
77
|
-
expect(indexOf(haystack, needle)).toEqual(-1);
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
test("splitStream()", async () => {
|
|
81
|
-
const split = new Uint8Array([1, 2]);
|
|
82
|
-
const input = [
|
|
83
|
-
new Uint8Array([255, 1, 2, 3, 4, 1, 2, 1]),
|
|
84
|
-
new Uint8Array([2, 5, 6, 7, 8]),
|
|
85
|
-
new Uint8Array([9, 10, 11, 2, 1, 12, 13]),
|
|
86
|
-
new Uint8Array([14, 15, 1, 2, 16]),
|
|
87
|
-
];
|
|
88
|
-
const parts = [];
|
|
89
|
-
for await (const part of splitStream(toAsyncIterable(input), split)) {
|
|
90
|
-
parts.push(part);
|
|
91
|
-
}
|
|
92
|
-
const expected = [
|
|
93
|
-
new Uint8Array([255]),
|
|
94
|
-
new Uint8Array([3, 4]),
|
|
95
|
-
new Uint8Array(0),
|
|
96
|
-
new Uint8Array([5, 6, 7, 8, 9, 10, 11, 2, 1, 12, 13, 14, 15]),
|
|
97
|
-
new Uint8Array([16]),
|
|
98
|
-
];
|
|
99
|
-
expect(parts.length).toEqual(expected.length);
|
|
100
|
-
for (const [i, part] of parts.entries()) {
|
|
101
|
-
expect(bytesEqual(part, expected[i])).toBe(true);
|
|
102
|
-
}
|
|
103
|
-
});
|
|
104
|
-
test("collect()", async () => {
|
|
105
|
-
const res = await collect(toAsyncIterable([
|
|
106
|
-
new Uint8Array([0, 1]),
|
|
107
|
-
new Uint8Array([2, 3, 4]),
|
|
108
|
-
new Uint8Array([5, 6]),
|
|
109
|
-
]));
|
|
110
|
-
const expected = new Uint8Array([0, 1, 2, 3, 4, 5, 6]);
|
|
111
|
-
expect(bytesEqual(res, expected)).toBe(true);
|
|
112
|
-
});
|
|
113
|
-
describe("decodeQuotedPrintable()", () => {
|
|
114
|
-
test("success", async () => {
|
|
115
|
-
// eslint-disable-next-line spellcheck/spell-checker
|
|
116
|
-
const input = ["key=3Dvalue", "this line continues =", "on the next line"];
|
|
117
|
-
const res = decoder.decode(await collect(decodeQuotedPrintable(toAsyncIterable(input.map((l) => encoder.encode(l))))));
|
|
118
|
-
expect(res).toStrictEqual("key=value\nthis line continues on the next line\n");
|
|
119
|
-
});
|
|
120
|
-
test("ascii failure", async () => {
|
|
121
|
-
const input = ["\xff"];
|
|
122
|
-
const decoded = decodeQuotedPrintable(toAsyncIterable(input.map((l) => encoder.encode(l))));
|
|
123
|
-
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
124
|
-
await expect((async () => {
|
|
125
|
-
for await (const _ of decoded) {
|
|
126
|
-
//
|
|
127
|
-
}
|
|
128
|
-
})()).rejects.toThrow("non-ascii");
|
|
129
|
-
});
|
|
130
|
-
test("encoding failure", async () => {
|
|
131
|
-
const input = ["a=0"];
|
|
132
|
-
const decoded = decodeQuotedPrintable(toAsyncIterable(input.map((l) => encoder.encode(l))));
|
|
133
|
-
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
134
|
-
await expect((async () => {
|
|
135
|
-
for await (const _ of decoded) {
|
|
136
|
-
//
|
|
137
|
-
}
|
|
138
|
-
})()).rejects.toThrow("quoted printable escape");
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
test("decodeIdentity()", async () => {
|
|
142
|
-
const input = ["line ", "not ascii \xff"];
|
|
143
|
-
const res = decoder.decode(await collect(decodeIdentity(toAsyncIterable(input.map((l) => encoder.encode(l))))));
|
|
144
|
-
expect(res).toStrictEqual("line not ascii \xff");
|
|
145
|
-
});
|
|
146
|
-
test("decodeBinary()", () => {
|
|
147
|
-
expect(decodeBinary).toThrow("explicitly");
|
|
148
|
-
});
|