mhtml-stream 2.0.1 → 3.0.0

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
@@ -5,10 +5,10 @@ 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)
7
7
 
8
- Zero-dependency library for parsing MHTML data as streams using modern WHATWG
9
- streams and async iterators. Because it relies on modern cross javascript
10
- standards it works out-of-the-box in all javascript environments, with only a
11
- little tweaking necessary for module definitions.
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,6 +1,6 @@
1
1
  /* eslint-disable no-console */
2
- import { argv } from "bun";
3
2
  import { mkdir } from "node:fs/promises";
3
+ import { argv } from "bun";
4
4
  import yargs from "yargs";
5
5
  import { parseMhtml } from ".";
6
6
  async function* readFile(name) {
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(): Iterable<string>;
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 list = this.#raw.get(key);
8
- if (list === undefined) {
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
- list.push(value);
14
+ entry.values.push(value);
13
15
  }
14
16
  }
15
17
  *entries(delim = ", ") {
16
- for (const [key, values] of this.#raw.entries()) {
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 [key, values] of this.#raw.entries()) {
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 vals = this.#raw.get(key);
29
- if (vals === undefined) {
30
+ const entry = this.#raw.get(key.toLowerCase());
31
+ if (entry === undefined) {
30
32
  return null;
31
33
  }
32
34
  else {
33
- return vals.join(delim);
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
- return this.#raw.keys();
44
+ *keys() {
45
+ for (const { key } of this.#raw.values()) {
46
+ yield key;
47
+ }
44
48
  }
45
49
  *values(delim = ", ") {
46
- for (const vals of this.#raw.values()) {
47
- yield vals.join(delim);
50
+ for (const { values } of this.#raw.values()) {
51
+ yield values.join(delim);
48
52
  }
49
53
  }
50
54
  *valuesAll() {
51
- for (const vals of this.#raw.values()) {
52
- yield* vals;
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 { MhtmlHeaders } from "./headers";
1
+ import { type MhtmlHeaders } from "./headers";
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
- /** custom decoders for other Content-Transfer-Encodings */
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
2
  import { Headers } from "./headers";
3
- import { bytesEqual, collect, decodeBase64, decodeBinary, decodeIdentity, decodeQuotedPrintable, splitStream, } from "./utils";
3
+ import { bytesEqual, collect, decodeBase64, decodeBinary, decodeIdentity, decodeQuotedPrintable, isHexDigit, splitStream, } from "./utils";
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
- val = parseInt(text[++ind], 16);
25
- val *= 16;
26
- val += parseInt(text[++ind], 16);
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
@@ -81,12 +84,12 @@ async function parseHeaders(iter) {
81
84
  headers.append(key, val);
82
85
  }
83
86
  if (line) {
84
- const delim = line.indexOf(": ");
87
+ const delim = line.indexOf(":");
85
88
  if (delim === -1) {
86
89
  throw new Error(`header line didn't have key-value delimiter: "${line}"`);
87
90
  }
88
91
  key = line.slice(0, delim);
89
- val = decodeLine(line.slice(delim + 2));
92
+ val = decodeLine(line.slice(delim + 1).replace(/^\s+/, ""));
90
93
  }
91
94
  else {
92
95
  return headers;
@@ -113,13 +116,16 @@ function getBoundary(headers) {
113
116
  if (contentType === null) {
114
117
  throw new Error(`first headers didn't contain a content type: ${JSON.stringify(Object.fromEntries(headers))}`);
115
118
  }
116
- let bound = undefined;
119
+ let bound;
117
120
  let multipart = false;
118
121
  for (const field of contentType.split(/;\s*/)) {
119
- if (field.startsWith("multipart/")) {
122
+ // the media type and the boundary parameter name are case-insensitive, but
123
+ // the boundary value itself is not, so match on a lowercased copy
124
+ const lower = field.toLowerCase();
125
+ if (lower.startsWith("multipart/")) {
120
126
  multipart = true;
121
127
  }
122
- else if (field.startsWith("boundary=")) {
128
+ else if (lower.startsWith("boundary=")) {
123
129
  bound = field.slice(9);
124
130
  // TODO handling of quoted fields is not great
125
131
  if (bound.startsWith('"') && bound.endsWith('"')) {
@@ -155,7 +161,7 @@ export async function* parseMhtml(stream, { decoderOverrides = new Map() } = {})
155
161
  // parse out headers and get encoding for content
156
162
  const headers = await parseHeaders(lines);
157
163
  const [boundary, terminus] = bound ?? (bound = getBoundary(headers));
158
- const encoding = headers.get("Content-Transfer-Encoding") ?? "7bit";
164
+ const encoding = (headers.get("Content-Transfer-Encoding") ?? "7bit").toLowerCase();
159
165
  const decode = decoders.get(encoding);
160
166
  if (decode === undefined) {
161
167
  throw new Error(`unhandled encoding type: ${encoding}`);
@@ -140,6 +140,86 @@ Subject: =?iso-8859-1?Q?=A1Hola,\xffse=F1or!?=
140
140
  // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression,@typescript-eslint/await-thenable
141
141
  await expect(consume(parser)).rejects.toThrow("got non-ascii character when decoding q-quoted word");
142
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
+ });
143
223
  test("fails without empty header delimiter", async () => {
144
224
  const content = `MIME-Version: 1.0`;
145
225
  const parser = parseMhtml(stringToStream(content));
@@ -1 +1 @@
1
- var D=L;var g=[],Y=[],f=typeof Uint8Array!=="undefined"?Uint8Array:Array,P="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";for(Z=0,F=P.length;Z<F;++Z)g[Z]=P[Z],Y[P.charCodeAt(Z)]=Z;var Z,F;Y[45]=62;Y[95]=63;function m(G){var K=G.length;if(K%4>0)throw new Error("Invalid string. Length must be a multiple of 4");var J=G.indexOf("=");if(J===-1)J=K;var M=J===K?0:4-J%4;return[J,M]}function v(G,K,J){return(K+J)*3/4-J}function L(G){var K,J=m(G),M=J[0],R=J[1],U=new f(v(G,M,R)),V=0,W=R>0?M-4:M,X;for(X=0;X<W;X+=4)K=Y[G.charCodeAt(X)]<<18|Y[G.charCodeAt(X+1)]<<12|Y[G.charCodeAt(X+2)]<<6|Y[G.charCodeAt(X+3)],U[V++]=K>>16&255,U[V++]=K>>8&255,U[V++]=K&255;if(R===2)K=Y[G.charCodeAt(X)]<<2|Y[G.charCodeAt(X+1)]>>4,U[V++]=K&255;if(R===1)K=Y[G.charCodeAt(X)]<<10|Y[G.charCodeAt(X+1)]<<4|Y[G.charCodeAt(X+2)]>>2,U[V++]=K>>8&255,U[V++]=K&255;return U}class S{#G=new Map;[Symbol.iterator](){return this.entries()}append(G,K){let J=this.#G.get(G);if(J===void 0)this.#G.set(G,[K]);else J.push(K)}*entries(G=", "){for(let[K,J]of this.#G.entries())yield[K,J.join(G)]}*entriesAll(){for(let[G,K]of this.#G.entries())for(let J of K)yield[G,J]}get(G,K=", "){let J=this.#G.get(G);if(J===void 0)return null;else return J.join(K)}getAll(G){return this.#G.get(G)??[]}has(G){return this.#G.has(G)}keys(){return this.#G.keys()}*values(G=", "){for(let K of this.#G.values())yield K.join(G)}*valuesAll(){for(let G of this.#G.values())yield*G}}function _(G,K){if(G.length!==K.length)return!1;else if(G.byteOffset%4===K.byteOffset%4){let J=(4-G.byteOffset%4)%4,M=Math.floor((G.byteLength-J)/4),R=J+M*4,U=new Uint32Array(G.buffer,G.byteOffset+J,M),V=new Uint32Array(K.buffer,K.byteOffset+J,M);for(let W=0;W<J;++W)if(G[W]!==K[W])return!1;for(let W=0;W<M;++W)if(U[W]!==V[W])return!1;for(let W=R;W<G.length;++W)if(G[W]!==K[W])return!1;return!0}else{for(let J of G.keys())if(G[J]!==K[J])return!1;return!0}}function h(G,K){return G.findIndex((J,M)=>{if(J!==K[0]||M+K.length>G.length)return!1;else{for(let R=1;R<K.length;++R)if(G[M+R]!==K[R])return!1;return!0}})}async function*Q(G,K){let J=new Uint8Array(0);for await(let M of G){J=J.length?E([J,M]):M;let R;while((R=h(J,K))!==-1)yield J.subarray(0,R),J=J.subarray(R+K.length)}yield J}function E(G){let K=G.reduce((R,U)=>R+U.length,0),J=new Uint8Array(K),M=0;for(let R of G)J.set(R,M),M+=R.length;return J}async function O(G){let K=[];for await(let J of G)K.push(J);return E(K)}var k=new Uint8Array([10]);async function*T(G,K=k){for await(let J of G){let M=new Uint8Array(J.length+K.length),R=!1,U=0;for(let V=0;V<J.length;++V){let W=J[V];if(W>=128)throw new Error(`got non-ascii character when decoding quoted printable: ${W}`);if(W!==61)M[U++]=W;else{let X=J[++V];if(X===void 0)R=!0;else{let z=J[++V];if(z===void 0)throw new Error("quoted printable escape (=) was not followed by two bytes");let $=parseInt(String.fromCharCode(X),16);$*=16,$+=parseInt(String.fromCharCode(z),16),M[U++]=$}}}if(!R)M.set(K,U),U+=K.length;yield M.subarray(0,U)}}var y=new TextDecoder;async function*C(G){for await(let K of G)yield D(y.decode(K))}async function*j(G){for await(let K of G)yield K}function A(){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 ignore anyway, set binary to `decode8bit`")}var I=new TextEncoder,B=new TextDecoder;function b(G){let K=new Uint8Array(G.length),J=0;for(let M=0;M<G.length;++M){let R=G.charCodeAt(M),U;if(R>=128)throw new Error(`got non-ascii character when decoding q-quoted word: "${G}"`);else if(R===95)U=32;else if(R===61)U=parseInt(G[++M],16),U*=16,U+=parseInt(G[++M],16);else U=R;K[J++]=U}return K.subarray(0,J)}var p=/^=\?([^?\s]+)\?([BQ])\?([^?\s]+)\?=$/;function H(G){let K=p.exec(G);if(K){let[,J,M,R]=K,U;if(M==="Q")U=b(R);else U=D(R);return new TextDecoder(J).decode(U)}else return G}async function c(G){let K=new S,J="",M="";for(;;){let{done:R,value:U}=await G.next();if(R)throw new Error("didn't find an empty line to signify the end of header parsing");let V=B.decode(U);if(/^\s/.test(V))M+=H(V.substring(1));else{if(J)K.append(J,M);if(V){let W=V.indexOf(": ");if(W===-1)throw new Error(`header line didn't have key-value delimiter: "${V}"`);J=V.slice(0,W),M=H(V.slice(W+2))}else return K}}}var u=new Map([["7bit",j],["base64",C],["quoted-printable",T],["8bit",j],["binary",A]]);function s(G){let K=G.get("Content-Type");if(K===null)throw new Error(`first headers didn't contain a content type: ${JSON.stringify(Object.fromEntries(G))}`);let J=void 0,M=!1;for(let V of K.split(/;\s*/))if(V.startsWith("multipart/"))M=!0;else if(V.startsWith("boundary=")){if(J=V.slice(9),J.startsWith('"')&&J.endsWith('"'))J=J.slice(1,-1)}if(!M||J===void 0)throw new Error("first content type header didn't contain 'multipart/...' and a boundary string");let R=I.encode(`--${J}`),U=I.encode(`--${J}--`);return[R,U]}async function*e(G,{decoderOverrides:K=new Map}={}){let J=new Map([...u.entries(),...K.entries()]),M=new Uint8Array([13,10]),R=Q(G,M),U=null,V=!0;while(V){let W=await c(R),[X,z]=U??(U=s(W)),$=W.get("Content-Transfer-Encoding")??"7bit",q=J.get($);if(q===void 0)throw new Error(`unhandled encoding type: ${$}`);let w=await O(q({[Symbol.asyncIterator](){return{async next(){let{done:x,value:N}=await R.next();if(x)throw new Error(`stream didn't end with the appropriate termination boundary: ${B.decode(z)}`);else if(_(N,X))return{done:!0,value:void 0};else if(_(N,z))return V=!1,{done:!0,value:void 0};else return{value:N}}}}}));yield{headers:W,content:w}}}export{e as parseMhtml};
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};
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` which defaults to a single "\n".
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
- export declare function decodeIdentity(lines: AsyncIterable<Uint8Array>): AsyncIterableIterator<Uint8Array>;
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
@@ -77,7 +77,9 @@ export function indexOf(haystack, needle) {
77
77
  export async function* splitStream(iter, split) {
78
78
  let current = new Uint8Array(0);
79
79
  for await (const chunk of iter) {
80
- current = current.length ? concat([current, chunk]) : chunk;
80
+ current = current.length
81
+ ? concat([current, chunk])
82
+ : chunk;
81
83
  let nextInd;
82
84
  while ((nextInd = indexOf(current, split)) !== -1) {
83
85
  yield current.subarray(0, nextInd);
@@ -109,19 +111,33 @@ export async function collect(stream) {
109
111
  }
110
112
  return concat(chunks);
111
113
  }
112
- // default new line character for quoted printable decoding
113
- const defaultNewLine = new Uint8Array([10]);
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
+ }
114
121
  /**
115
122
  * decoder for quoted printable
116
123
  *
117
124
  * If quoted printable "lines" aren't escaped with an "=" then a new line needs
118
- * to be inserted. We use `newLine` which defaults to a single "\n".
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.
119
129
  */
120
- export async function* decodeQuotedPrintable(lines, newLine = defaultNewLine) {
130
+ export async function* decodeQuotedPrintable(lines, newLine = crlf) {
131
+ let pendingNewLine = false; // a hard line break from the previous line
121
132
  for await (const bytes of lines) {
122
133
  const res = new Uint8Array(bytes.length + newLine.length);
123
- let softLine = false; // if newline wasn't escaped so we need to add
124
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
125
141
  for (let ind = 0; ind < bytes.length; ++ind) {
126
142
  const code = bytes[ind];
127
143
  if (code >= 128) {
@@ -142,16 +158,15 @@ export async function* decodeQuotedPrintable(lines, newLine = defaultNewLine) {
142
158
  if (second === undefined) {
143
159
  throw new Error("quoted printable escape (=) was not followed by two bytes");
144
160
  }
145
- let val = parseInt(String.fromCharCode(first), 16);
146
- val *= 16;
147
- val += parseInt(String.fromCharCode(second), 16);
148
- res[destInd++] = val;
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);
149
165
  }
150
166
  }
151
167
  }
152
168
  if (!softLine) {
153
- res.set(newLine, destInd);
154
- destInd += newLine.length;
169
+ pendingNewLine = true;
155
170
  }
156
171
  yield res.subarray(0, destInd);
157
172
  }
@@ -159,17 +174,45 @@ export async function* decodeQuotedPrintable(lines, newLine = defaultNewLine) {
159
174
  const decoder = new TextDecoder();
160
175
  /**
161
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.
162
182
  */
163
183
  export async function* decodeBase64(lines) {
184
+ let residual = "";
164
185
  for await (const bytes of lines) {
165
- yield toByteArray(decoder.decode(bytes));
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);
166
195
  }
167
196
  }
168
197
  /**
169
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.
170
206
  */
171
- export async function* decodeIdentity(lines) {
207
+ export async function* decodeIdentity(lines, newLine = crlf) {
208
+ let first = true;
172
209
  for await (const bytes of lines) {
210
+ if (first) {
211
+ first = false;
212
+ }
213
+ else {
214
+ yield newLine;
215
+ }
173
216
  yield bytes;
174
217
  }
175
218
  }
@@ -180,5 +223,5 @@ export async function* decodeIdentity(lines) {
180
223
  * error.
181
224
  */
182
225
  export function decodeBinary() {
183
- 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 ignore anyway, set binary to `decode8bit`");
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`");
184
227
  }
@@ -115,7 +115,13 @@ describe("decodeQuotedPrintable()", () => {
115
115
  // eslint-disable-next-line spellcheck/spell-checker
116
116
  const input = ["key=3Dvalue", "this line continues =", "on the next line"];
117
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");
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");
119
125
  });
120
126
  test("ascii failure", async () => {
121
127
  const input = ["\xff"];
@@ -137,11 +143,33 @@ describe("decodeQuotedPrintable()", () => {
137
143
  }
138
144
  })()).rejects.toThrow("quoted printable escape");
139
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
+ });
140
156
  });
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");
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
+ });
145
173
  });
146
174
  test("decodeBinary()", () => {
147
175
  expect(decodeBinary).toThrow("explicitly");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mhtml-stream",
3
- "version": "2.0.1",
4
- "description": "Zero dependency stream MHTML parser",
3
+ "version": "3.0.0",
4
+ "description": "Streaming MHTML parser",
5
5
  "keywords": [
6
6
  "mhtml",
7
7
  "stream"
@@ -10,90 +10,42 @@
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
+ "sideEffects": false,
16
18
  "types": "dist/index.d.ts",
17
19
  "module": "dist/mhtml.esm.min.js",
18
- "exports": "./dist/mhtml.esm.min.js",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "default": "./dist/mhtml.esm.min.js"
24
+ }
25
+ },
19
26
  "files": [
20
27
  "/dist/**/*.js",
21
28
  "/dist/**/*.d.ts"
22
29
  ],
23
30
  "scripts": {
24
31
  "cli": "bun src/cli.ts",
25
- "fmt": "prettier --cache --write 'src/*.ts' '*.{js,json}'",
26
- "lint": "tsc && eslint --cache 'src/*.ts' && typedoc --emit none",
32
+ "fmt": "biome format --write",
33
+ "lint": "tsc && biome check && typedoc --emit none",
27
34
  "doc": "typedoc",
28
35
  "export": "tsc -p tsconfig.build.json && bun build src/index.ts --minify --outfile dist/mhtml.esm.min.js",
29
- "prepack": "bun lint && bun test --coverage && bun export"
36
+ "prepack": "bun lint && bun test --coverage && bun export && publint"
30
37
  },
31
38
  "dependencies": {
32
39
  "base64-js": "^1.5.1"
33
40
  },
34
41
  "devDependencies": {
35
- "@types/base64-js": "^1.3.2",
36
- "@types/bun": "^1.1.14",
37
- "@types/yargs": "^17.0.33",
38
- "eslint": "^9.17.0",
39
- "eslint-config-prettier": "^9.1.0",
40
- "eslint-plugin-spellcheck": "^0.0.20",
41
- "eslint-plugin-tsdoc": "^0.4.0",
42
- "prettier": "^3.4.2",
43
- "prettier-plugin-organize-imports": "^4.1.0",
44
- "typedoc": "^0.27.5",
45
- "typescript": "^5.7.2",
46
- "typescript-eslint": "^8.18.1",
47
- "yargs": "^17.7.2"
48
- },
49
- "eslintConfig": {
50
- "rules": {
51
- "@typescript-eslint/no-inferrable-types": "off",
52
- "@typescript-eslint/no-non-null-assertion": "off",
53
- "@typescript-eslint/no-unused-vars": [
54
- "error",
55
- {
56
- "varsIgnorePattern": "^_+$"
57
- }
58
- ],
59
- "prefer-const": [
60
- "error",
61
- {
62
- "destructuring": "all"
63
- }
64
- ],
65
- "no-warning-comments": [
66
- "error",
67
- {
68
- "terms": [
69
- "fixme"
70
- ],
71
- "location": "anywhere"
72
- }
73
- ],
74
- "spellcheck/spell-checker": [
75
- "error",
76
- {
77
- "identifiers": false,
78
- "skipIfMatch": [
79
- "[^\\s]{20,}"
80
- ],
81
- "skipWords": [
82
- "0x7fffffff",
83
- "Chr",
84
- "Dvalue",
85
- "Hola",
86
- "Mhtml",
87
- "Today’s",
88
- "Uint",
89
- "Yorker",
90
- "ascii",
91
- "delim",
92
- "multipart",
93
- "versa"
94
- ]
95
- }
96
- ]
97
- }
42
+ "@biomejs/biome": "^2.4.15",
43
+ "@types/base64-js": "^1.5.0",
44
+ "@types/bun": "^1.3.14",
45
+ "@types/yargs": "^17.0.35",
46
+ "publint": "^0.3.21",
47
+ "typedoc": "^0.28.19",
48
+ "typescript": "~6.0.3",
49
+ "yargs": "^18.0.0"
98
50
  }
99
51
  }