mhtml-stream 2.0.2 → 3.0.1

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