mhtml-stream 3.0.0 → 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 +1 -1
- package/dist/cli.js +1 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -8
- package/dist/mhtml.esm.min.js +1 -1
- package/package.json +6 -2
- package/dist/index.spec.d.ts +0 -1
- package/dist/index.spec.js +0 -328
- package/dist/utils.spec.d.ts +0 -1
- package/dist/utils.spec.js +0 -176
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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)
|
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/index.d.ts
CHANGED
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, isHexDigit, 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();
|
|
@@ -39,7 +39,6 @@ function decodeQEncoding(text) {
|
|
|
39
39
|
const encodeFormat = /^=\?([^?\s]+)\?([BQ])\?([^?\s]+)\?=$/;
|
|
40
40
|
/** decode a header line that may have an extra encoding in it */
|
|
41
41
|
function decodeLine(line) {
|
|
42
|
-
// TODO this might make more sense as a function as part of a regex replaceAll
|
|
43
42
|
const match = encodeFormat.exec(line);
|
|
44
43
|
if (match) {
|
|
45
44
|
const [, charset, encoding, text] = match;
|
|
@@ -69,7 +68,6 @@ async function parseHeaders(iter) {
|
|
|
69
68
|
let key = "";
|
|
70
69
|
let val = "";
|
|
71
70
|
for (;;) {
|
|
72
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
73
71
|
const { done, value } = await iter.next();
|
|
74
72
|
if (done) {
|
|
75
73
|
throw new Error("didn't find an empty line to signify the end of header parsing");
|
|
@@ -105,7 +103,6 @@ const defaultDecoders = new Map([
|
|
|
105
103
|
["8bit", decodeIdentity],
|
|
106
104
|
["binary", decodeBinary],
|
|
107
105
|
]);
|
|
108
|
-
// eslint-disable-next-line spellcheck/spell-checker
|
|
109
106
|
/**
|
|
110
107
|
* extract the boundary condition from a multipart header
|
|
111
108
|
*
|
|
@@ -127,7 +124,6 @@ function getBoundary(headers) {
|
|
|
127
124
|
}
|
|
128
125
|
else if (lower.startsWith("boundary=")) {
|
|
129
126
|
bound = field.slice(9);
|
|
130
|
-
// TODO handling of quoted fields is not great
|
|
131
127
|
if (bound.startsWith('"') && bound.endsWith('"')) {
|
|
132
128
|
bound = bound.slice(1, -1);
|
|
133
129
|
}
|
|
@@ -156,7 +152,6 @@ export async function* parseMhtml(stream, { decoderOverrides = new Map() } = {})
|
|
|
156
152
|
const lines = splitStream(stream, crlf);
|
|
157
153
|
let bound = null;
|
|
158
154
|
let cont = true;
|
|
159
|
-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
160
155
|
while (cont) {
|
|
161
156
|
// parse out headers and get encoding for content
|
|
162
157
|
const headers = await parseHeaders(lines);
|
|
@@ -172,7 +167,6 @@ export async function* parseMhtml(stream, { decoderOverrides = new Map() } = {})
|
|
|
172
167
|
[Symbol.asyncIterator]() {
|
|
173
168
|
return {
|
|
174
169
|
async next() {
|
|
175
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
176
170
|
const { done, value } = await lines.next();
|
|
177
171
|
if (done) {
|
|
178
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 R=
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mhtml-stream",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "Streaming MHTML parser",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mhtml",
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
"author": "Erik Brinkman <erik.brinkman@gmail.com>",
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"type": "module",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20.19"
|
|
19
|
+
},
|
|
17
20
|
"sideEffects": false,
|
|
18
21
|
"types": "dist/index.d.ts",
|
|
19
22
|
"module": "dist/mhtml.esm.min.js",
|
|
@@ -33,12 +36,13 @@
|
|
|
33
36
|
"lint": "tsc && biome check && typedoc --emit none",
|
|
34
37
|
"doc": "typedoc",
|
|
35
38
|
"export": "tsc -p tsconfig.build.json && bun build src/index.ts --minify --outfile dist/mhtml.esm.min.js",
|
|
36
|
-
"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"
|
|
37
40
|
},
|
|
38
41
|
"dependencies": {
|
|
39
42
|
"base64-js": "^1.5.1"
|
|
40
43
|
},
|
|
41
44
|
"devDependencies": {
|
|
45
|
+
"@arethetypeswrong/cli": "^0.18.4",
|
|
42
46
|
"@biomejs/biome": "^2.4.15",
|
|
43
47
|
"@types/base64-js": "^1.5.0",
|
|
44
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,328 +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("decodes base64 wrapped at a non-multiple-of-4 column", async () => {
|
|
144
|
-
// "hello world hello world" wrapped at column 23 (not a multiple of 4)
|
|
145
|
-
const content = `MIME-Version: 1.0
|
|
146
|
-
Content-Type: multipart/mixed; boundary=frontier
|
|
147
|
-
|
|
148
|
-
--frontier
|
|
149
|
-
Content-Type: application/octet-stream
|
|
150
|
-
Content-Transfer-Encoding: base64
|
|
151
|
-
|
|
152
|
-
aGVsbG8gd29ybGQgaGVsbG8
|
|
153
|
-
gd29ybGQ=
|
|
154
|
-
--frontier--
|
|
155
|
-
`;
|
|
156
|
-
const files = [];
|
|
157
|
-
for await (const file of parseMhtml(stringToStream(content))) {
|
|
158
|
-
files.push(file);
|
|
159
|
-
}
|
|
160
|
-
expect(decoder.decode(files[1].content)).toStrictEqual("hello world hello world");
|
|
161
|
-
});
|
|
162
|
-
test("matches header names and encodings case-insensitively", async () => {
|
|
163
|
-
const content = `mime-version: 1.0
|
|
164
|
-
content-type: MULTIPART/mixed; BOUNDARY=frontier
|
|
165
|
-
|
|
166
|
-
--frontier
|
|
167
|
-
content-type: application/octet-stream
|
|
168
|
-
content-transfer-encoding: Base64
|
|
169
|
-
|
|
170
|
-
aGVsbG8gd29ybGQ=
|
|
171
|
-
--frontier--
|
|
172
|
-
`;
|
|
173
|
-
const files = [];
|
|
174
|
-
for await (const file of parseMhtml(stringToStream(content))) {
|
|
175
|
-
files.push(file);
|
|
176
|
-
}
|
|
177
|
-
const headers = files.map(({ headers }) => Object.fromEntries(headers));
|
|
178
|
-
expect(headers).toEqual([
|
|
179
|
-
{
|
|
180
|
-
"mime-version": "1.0",
|
|
181
|
-
"content-type": "MULTIPART/mixed; BOUNDARY=frontier",
|
|
182
|
-
},
|
|
183
|
-
{
|
|
184
|
-
"content-type": "application/octet-stream",
|
|
185
|
-
"content-transfer-encoding": "Base64",
|
|
186
|
-
},
|
|
187
|
-
]);
|
|
188
|
-
expect(decoder.decode(files[1].content)).toStrictEqual("hello world");
|
|
189
|
-
});
|
|
190
|
-
test("accepts headers without a space after the colon", async () => {
|
|
191
|
-
const content = `MIME-Version:1.0
|
|
192
|
-
Content-Type:multipart/mixed; boundary=frontier
|
|
193
|
-
X-Empty:
|
|
194
|
-
|
|
195
|
-
This is a message with multiple parts in MIME format.
|
|
196
|
-
--frontier--
|
|
197
|
-
`;
|
|
198
|
-
const files = [];
|
|
199
|
-
for await (const file of parseMhtml(stringToStream(content))) {
|
|
200
|
-
files.push(file);
|
|
201
|
-
}
|
|
202
|
-
const headers = files.map(({ headers }) => Object.fromEntries(headers));
|
|
203
|
-
expect(headers).toEqual([
|
|
204
|
-
{
|
|
205
|
-
"MIME-Version": "1.0",
|
|
206
|
-
"Content-Type": "multipart/mixed; boundary=frontier",
|
|
207
|
-
"X-Empty": "",
|
|
208
|
-
},
|
|
209
|
-
]);
|
|
210
|
-
const text = files.map(({ content }) => decoder.decode(content));
|
|
211
|
-
expect(text).toEqual([
|
|
212
|
-
"This is a message with multiple parts in MIME format.",
|
|
213
|
-
]);
|
|
214
|
-
});
|
|
215
|
-
test("fails on invalid hex in q-encoding", async () => {
|
|
216
|
-
const content = `MIME-Version: 1.0
|
|
217
|
-
Subject: =?utf-8?Q?a=ZZb?=
|
|
218
|
-
`;
|
|
219
|
-
const parser = parseMhtml(stringToStream(content));
|
|
220
|
-
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
221
|
-
await expect(consume(parser)).rejects.toThrow("got invalid hex escape when decoding q-quoted word");
|
|
222
|
-
});
|
|
223
|
-
test("fails without empty header delimiter", async () => {
|
|
224
|
-
const content = `MIME-Version: 1.0`;
|
|
225
|
-
const parser = parseMhtml(stringToStream(content));
|
|
226
|
-
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
227
|
-
await expect(consume(parser)).rejects.toThrow("didn't find an empty line to signify the end of header parsing");
|
|
228
|
-
});
|
|
229
|
-
test("fails with invalid header", async () => {
|
|
230
|
-
const content = `MIME-Version: 1.0
|
|
231
|
-
invalid header
|
|
232
|
-
|
|
233
|
-
`;
|
|
234
|
-
const parser = parseMhtml(stringToStream(content));
|
|
235
|
-
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
236
|
-
await expect(consume(parser)).rejects.toThrow("header line didn't have key-value delimiter");
|
|
237
|
-
});
|
|
238
|
-
test("fails with missing Content-Type", async () => {
|
|
239
|
-
const content = `MIME-Version: 1.0
|
|
240
|
-
|
|
241
|
-
`;
|
|
242
|
-
const parser = parseMhtml(stringToStream(content));
|
|
243
|
-
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
244
|
-
await expect(consume(parser)).rejects.toThrow("first headers didn't contain a content type");
|
|
245
|
-
});
|
|
246
|
-
test("fails with missing multipart", async () => {
|
|
247
|
-
const content = `MIME-Version: 1.0
|
|
248
|
-
Content-Type: text/plain; boundary=frontier
|
|
249
|
-
|
|
250
|
-
`;
|
|
251
|
-
const parser = parseMhtml(stringToStream(content));
|
|
252
|
-
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
253
|
-
await expect(consume(parser)).rejects.toThrow("first content type header didn't contain");
|
|
254
|
-
});
|
|
255
|
-
test("fails with missing boundary", async () => {
|
|
256
|
-
const content = `MIME-Version: 1.0
|
|
257
|
-
Content-Type: multipart/mixed
|
|
258
|
-
|
|
259
|
-
`;
|
|
260
|
-
const parser = parseMhtml(stringToStream(content));
|
|
261
|
-
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
262
|
-
await expect(consume(parser)).rejects.toThrow("first content type header didn't contain");
|
|
263
|
-
});
|
|
264
|
-
test("fails with missing decoder", async () => {
|
|
265
|
-
const content = `MIME-Version: 1.0
|
|
266
|
-
Content-Transfer-Encoding: unknown
|
|
267
|
-
Content-Type: multipart/mixed; boundary=frontier
|
|
268
|
-
|
|
269
|
-
`;
|
|
270
|
-
const parser = parseMhtml(stringToStream(content));
|
|
271
|
-
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
272
|
-
await expect(consume(parser)).rejects.toThrow("unhandled encoding type: unknown");
|
|
273
|
-
});
|
|
274
|
-
test("fails without terminus", async () => {
|
|
275
|
-
const content = `MIME-Version: 1.0
|
|
276
|
-
Content-Type: multipart/mixed; boundary=frontier
|
|
277
|
-
|
|
278
|
-
This is a message with multiple parts in MIME format.
|
|
279
|
-
`;
|
|
280
|
-
const parser = parseMhtml(stringToStream(content));
|
|
281
|
-
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
282
|
-
await expect(consume(parser)).rejects.toThrow("stream didn't end with the appropriate termination boundary");
|
|
283
|
-
});
|
|
284
|
-
});
|
|
285
|
-
describe("MhtmlHeaders", () => {
|
|
286
|
-
const headers = new Headers();
|
|
287
|
-
headers.append("a", "b");
|
|
288
|
-
headers.append("a", "c");
|
|
289
|
-
headers.append("d", "e");
|
|
290
|
-
test("entries()", () => {
|
|
291
|
-
const entries = Object.fromEntries(headers.entries());
|
|
292
|
-
expect(entries).toEqual({ a: "b, c", d: "e" });
|
|
293
|
-
});
|
|
294
|
-
test("entriesAll()", () => {
|
|
295
|
-
const entriesAll = [...headers.entriesAll()].sort();
|
|
296
|
-
expect(entriesAll).toEqual([
|
|
297
|
-
["a", "b"],
|
|
298
|
-
["a", "c"],
|
|
299
|
-
["d", "e"],
|
|
300
|
-
]);
|
|
301
|
-
});
|
|
302
|
-
test("get()", () => {
|
|
303
|
-
expect(headers.get("a")).toStrictEqual("b, c");
|
|
304
|
-
expect(headers.get("d")).toStrictEqual("e");
|
|
305
|
-
expect(headers.get("f")).toStrictEqual(null);
|
|
306
|
-
});
|
|
307
|
-
test("getAll()", () => {
|
|
308
|
-
expect(headers.getAll("a")).toEqual(["b", "c"]);
|
|
309
|
-
expect(headers.getAll("d")).toEqual(["e"]);
|
|
310
|
-
expect(headers.getAll("f")).toEqual([]);
|
|
311
|
-
});
|
|
312
|
-
test("has()", () => {
|
|
313
|
-
expect(headers.has("a")).toBe(true);
|
|
314
|
-
expect(headers.has("f")).toBe(false);
|
|
315
|
-
});
|
|
316
|
-
test("keys()", () => {
|
|
317
|
-
const keys = [...headers.keys()].sort();
|
|
318
|
-
expect(keys).toEqual(["a", "d"]);
|
|
319
|
-
});
|
|
320
|
-
test("values()", () => {
|
|
321
|
-
const values = [...headers.values()].sort();
|
|
322
|
-
expect(values).toEqual(["b, c", "e"]);
|
|
323
|
-
});
|
|
324
|
-
test("valuesAll()", () => {
|
|
325
|
-
const valuesAll = [...headers.valuesAll()].sort();
|
|
326
|
-
expect(valuesAll).toEqual(["b", "c", "e"]);
|
|
327
|
-
});
|
|
328
|
-
});
|
package/dist/utils.spec.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/utils.spec.js
DELETED
|
@@ -1,176 +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\r\nthis line continues on the next line");
|
|
119
|
-
});
|
|
120
|
-
test("normalizes to a custom separator when given one", async () => {
|
|
121
|
-
// eslint-disable-next-line spellcheck/spell-checker
|
|
122
|
-
const input = ["key=3Dvalue", "this line continues =", "on the next line"];
|
|
123
|
-
const res = decoder.decode(await collect(decodeQuotedPrintable(toAsyncIterable(input.map((l) => encoder.encode(l))), new Uint8Array([10]))));
|
|
124
|
-
expect(res).toStrictEqual("key=value\nthis line continues on the next line");
|
|
125
|
-
});
|
|
126
|
-
test("ascii failure", async () => {
|
|
127
|
-
const input = ["\xff"];
|
|
128
|
-
const decoded = decodeQuotedPrintable(toAsyncIterable(input.map((l) => encoder.encode(l))));
|
|
129
|
-
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
130
|
-
await expect((async () => {
|
|
131
|
-
for await (const _ of decoded) {
|
|
132
|
-
//
|
|
133
|
-
}
|
|
134
|
-
})()).rejects.toThrow("non-ascii");
|
|
135
|
-
});
|
|
136
|
-
test("encoding failure", async () => {
|
|
137
|
-
const input = ["a=0"];
|
|
138
|
-
const decoded = decodeQuotedPrintable(toAsyncIterable(input.map((l) => encoder.encode(l))));
|
|
139
|
-
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
140
|
-
await expect((async () => {
|
|
141
|
-
for await (const _ of decoded) {
|
|
142
|
-
//
|
|
143
|
-
}
|
|
144
|
-
})()).rejects.toThrow("quoted printable escape");
|
|
145
|
-
});
|
|
146
|
-
test("non-hex escape failure", async () => {
|
|
147
|
-
const input = ["a=ZZb"];
|
|
148
|
-
const decoded = decodeQuotedPrintable(toAsyncIterable(input.map((l) => encoder.encode(l))));
|
|
149
|
-
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
|
|
150
|
-
await expect((async () => {
|
|
151
|
-
for await (const _ of decoded) {
|
|
152
|
-
//
|
|
153
|
-
}
|
|
154
|
-
})()).rejects.toThrow("two hex digits");
|
|
155
|
-
});
|
|
156
|
-
});
|
|
157
|
-
describe("decodeIdentity()", () => {
|
|
158
|
-
test("passes bytes through", async () => {
|
|
159
|
-
const input = ["line ", "not ascii \xff"];
|
|
160
|
-
const res = decoder.decode(await collect(decodeIdentity(toAsyncIterable(input.map((l) => encoder.encode(l))))));
|
|
161
|
-
expect(res).toStrictEqual("line \r\nnot ascii \xff");
|
|
162
|
-
});
|
|
163
|
-
test("reinserts CRLF between lines", async () => {
|
|
164
|
-
const input = ["line one", "line two", "line three"];
|
|
165
|
-
const res = decoder.decode(await collect(decodeIdentity(toAsyncIterable(input.map((l) => encoder.encode(l))))));
|
|
166
|
-
expect(res).toStrictEqual("line one\r\nline two\r\nline three");
|
|
167
|
-
});
|
|
168
|
-
test("normalizes to a custom separator when given one", async () => {
|
|
169
|
-
const input = ["line one", "line two"];
|
|
170
|
-
const res = decoder.decode(await collect(decodeIdentity(toAsyncIterable(input.map((l) => encoder.encode(l))), new Uint8Array([10]))));
|
|
171
|
-
expect(res).toStrictEqual("line one\nline two");
|
|
172
|
-
});
|
|
173
|
-
});
|
|
174
|
-
test("decodeBinary()", () => {
|
|
175
|
-
expect(decodeBinary).toThrow("explicitly");
|
|
176
|
-
});
|