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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MHTML Stream
2
2
  ============
3
- [![build](https://github.com/erikbrinkman/mhtml-stream/actions/workflows/node.js.yml/badge.svg)](https://github.com/erikbrinkman/mhtml-stream/actions/workflows/node.js.yml)
3
+ [![build](https://github.com/erikbrinkman/mhtml-stream/actions/workflows/build.yml/badge.svg)](https://github.com/erikbrinkman/mhtml-stream/actions/workflows/build.yml)
4
4
  [![docs](https://img.shields.io/badge/docs-docs-blue)](https://erikbrinkman.github.io/mhtml-stream/)
5
5
  [![npm](https://img.shields.io/npm/v/mhtml-stream)](https://www.npmjs.com/package/mhtml-stream)
6
6
  [![license](https://img.shields.io/github/license/erikbrinkman/mhtml-stream)](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
@@ -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 {
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)}`);
@@ -1 +1 @@
1
- var R=k;var m=[],Z=[],f=typeof Uint8Array<"u"?Uint8Array:Array,j="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";for($=0,q=j.length;$<q;++$)m[$]=j[$],Z[j.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 h(G,J,K){return(J+K)*3/4-K}function k(G){var J,K=L(G),M=K[0],U=K[1],W=new f(h(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 Q{#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 E(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 b(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*O(G,J){let K=new Uint8Array(0);for await(let M of G){K=K.length?T([K,M]):M;let U;while((U=b(K,J))!==-1)yield K.subarray(0,U),K=K.subarray(U+J.length)}yield K}function T(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 T(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 y=new TextDecoder;async function*I(G){let J="";for await(let K of G){J+=y.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*F(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 Q,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",F],["base64",I],["quoted-printable",A],["8bit",F],["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=O(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(),N=K.get(P);if(N===void 0)throw Error(`unhandled encoding type: ${P}`);let g=await C(N({[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(E(_,Y))return{done:!0,value:void 0};else if(E(_,z))return X=!1,{done:!0,value:void 0};else return{value:_}}}}}));yield{headers:V,content:g}}}export{e as parseMhtml};
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.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",
@@ -1 +0,0 @@
1
- export {};
@@ -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
- });
@@ -1 +0,0 @@
1
- export {};
@@ -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
- });