From 3efd67450cf90b83db53bffa91b05e3fe9cf14b9 Mon Sep 17 00:00:00 2001 From: Andris Reinman Date: Wed, 7 Oct 2026 18:40:23 +0300 Subject: [PATCH 1/2] feat: migrate to TypeScript with ES module and CommonJS builds The source moves from lib/*.js to src/*.ts and is compiled into dist/esm and dist/cjs, each with type declarations. require('imap-handler') and require('imap-handler/lib/parser' | 'lib/compiler' | 'lib/formal') keep their CommonJS shapes. The tests also run under Bun and Deno. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yaml | 3 +- .github/workflows/test.yml | 39 ++ .gitignore | 2 + .ncurc.cjs | 9 + .ncurc.js | 8 - .prettierignore | 1 + .prettierrc.js | 4 +- CLAUDE.md | 25 +- README.md | 25 +- eslint.config.js | 43 +- index.js | 6 - lib/formal.js | 151 ----- lib/parser.js | 535 --------------- package-lock.json | 900 ++++++++++++++++++++++++- package.json | 50 +- scripts/build.js | 88 +++ lib/compiler.js => src/compiler.ts | 93 ++- src/formal.ts | 114 ++++ src/index.ts | 10 + src/parser.ts | 623 +++++++++++++++++ test/{compiler.js => compiler.test.ts} | 25 +- test/package.test.ts | 57 ++ test/{parser.js => parser.test.ts} | 15 +- tsconfig.base.json | 18 + tsconfig.cjs.json | 10 + tsconfig.esm.json | 10 + tsconfig.json | 8 + 27 files changed, 2089 insertions(+), 783 deletions(-) create mode 100644 .ncurc.cjs delete mode 100644 .ncurc.js delete mode 100644 index.js delete mode 100644 lib/formal.js delete mode 100644 lib/parser.js create mode 100644 scripts/build.js rename lib/compiler.js => src/compiler.ts (64%) create mode 100644 src/formal.ts create mode 100644 src/index.ts create mode 100644 src/parser.ts rename test/{compiler.js => compiler.test.ts} (92%) create mode 100644 test/package.test.ts rename test/{parser.js => parser.test.ts} (98%) create mode 100644 tsconfig.base.json create mode 100644 tsconfig.cjs.json create mode 100644 tsconfig.esm.json create mode 100644 tsconfig.json diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 946960d..270d542 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -67,5 +67,6 @@ jobs: with: node-version: 24 registry-url: 'https://registry.npmjs.org' - # No install step: there is no build, the package is published as-is + # installs the dev dependencies, npm publish then builds dist/ from src/ through the prepare script + - run: npm ci --ignore-scripts - run: npm publish --provenance --access public diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 239237b..18c3723 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,3 +61,42 @@ jobs: - run: npm ci - name: Run tests run: npm run test:unit + + # The package is also used from Bun and Deno: the same tests run under their latest releases + bun: + name: Tests (Bun) + if: ${{ !startsWith(github.ref_name, 'release-please--') && !startsWith(github.head_ref, 'release-please--') }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - name: Use Node.js 24 + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: npm ci + - name: Run tests + run: npm run test:bun + + deno: + name: Tests (Deno) + if: ${{ !startsWith(github.ref_name, 'release-please--') && !startsWith(github.head_ref, 'release-please--') }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - name: Use Node.js 24 + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - run: npm ci + - name: Run tests + run: npm run test:deno diff --git a/.gitignore b/.gitignore index 106073f..a3bd765 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ node_modules .DS_Store *.log .claude/* +dist/ +*.tsbuildinfo diff --git a/.ncurc.cjs b/.ncurc.cjs new file mode 100644 index 0000000..ed802ef --- /dev/null +++ b/.ncurc.cjs @@ -0,0 +1,9 @@ +'use strict'; + +module.exports = { + // Node 20 is the supported runtime floor (see "engines" in package.json). A dependency major that + // needs a newer Node has to be capped here with `target` or `reject`. + upgrade: true, + // @types/node stays on the 20.x line so the compiler rejects APIs that Node 20 does not have + target: name => (name === '@types/node' ? 'minor' : 'latest') +}; diff --git a/.ncurc.js b/.ncurc.js deleted file mode 100644 index 07d6b8b..0000000 --- a/.ncurc.js +++ /dev/null @@ -1,8 +0,0 @@ -'use strict'; - -module.exports = { - // Node 20 is the supported runtime floor (see "engines" in package.json). A dependency major that - // needs a newer Node, or that is ESM-only (this package is CommonJS), has to be capped here with - // `target` or `reject`. - upgrade: true -}; diff --git a/.prettierignore b/.prettierignore index 40edc4d..156cd2b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,3 @@ package-lock.json CHANGELOG.md +dist diff --git a/.prettierrc.js b/.prettierrc.js index 1a6faac..885b301 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,6 +1,4 @@ -'use strict'; - -module.exports = { +export default { printWidth: 160, tabWidth: 4, singleQuote: true, diff --git a/CLAUDE.md b/CLAUDE.md index 3ff7fa7..cd08ebe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,16 +4,25 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project -`imap-handler` parses complete IMAP command strings into a structured object and compiles such objects back into IMAP strings. It is not a streaming parser: the whole command, including literals, must be buffered first, and syntax errors throw. It is used by hoodiecrow-imap (the IMAP mock server), so a parser or compiler change can break that consumer. CommonJS, no runtime dependencies, supports Node.js 20 and newer (CI tests 20, 22 and 24). +`imap-handler` parses complete IMAP command strings into a structured object and compiles such objects back into IMAP strings. It is not a streaming parser: the whole command, including literals, must be buffered first, and syntax errors throw. It is used by ImapKit (`imapkit` on npm, the IMAP mock server in ../imapkit), so a parser or compiler change can break that consumer. Written in TypeScript under `src/` and published as a dual package (ES modules in `dist/esm/`, CommonJS in `dist/cjs/`, each with type declarations), no runtime dependencies, supports Node.js 20 and newer (CI tests 20, 22 and 24) and the latest Bun and Deno. ## Commands -- `npm test`: ESLint, then all tests (`npm run test:unit`, which is `node --test test/*.js`). -- Single test file: `node --test test/parser.js`. Single test case: add `--test-name-pattern=""`. +- `npm test`: ESLint and the type check (`npm run lint`), then all tests (`npm run test:unit`, which builds and runs `node --import tsx --test test/*.test.ts`). +- `npm run build`: `scripts/build.js` compiles `src/` with `tsconfig.esm.json` and `tsconfig.cjs.json` and rewrites the CommonJS modules that only have a default export so that `require()` returns the function or object itself (`require('imap-handler/lib/parser')` is the parser). `dist/` is gitignored, `prepare` builds it on install and publish. +- `npm run test:bun`, `npm run test:deno`: the same tests under Bun and Deno (CI runs both on their latest release). +- Single test file: `node --import tsx --test test/parser.test.ts`. Single test case: add `--test-name-pattern=""`. - `npm run lint`, `npm run format` / `npm run format:check` (Prettier: single quotes, 4 spaces, 160 columns). CI fails on unformatted files. `npm install` sets `core.hooksPath` to `.githooks`, whose pre-commit hook runs Prettier on staged files. - `npm run update`: refresh all dev dependencies to latest (`ncu -u`, config in `.ncurc.js`). Versions are pinned exactly. -ESLint (`eslint.config.js`) enforces `const`/`let` (no `var`), arrow callbacks, one declaration per statement, `===`, and global `'use strict'`. +ESLint (`eslint.config.js`, with typescript-eslint) enforces `const`/`let` (no `var`), arrow callbacks, one declaration per statement and `===`. + +## TypeScript and module format + +- Every file under `src/` compiles both as ES module and as CommonJS, so it must not use `import.meta`, `require`, `module`, `exports`, `__dirname`, `__filename`, top-level `await` or JSON imports. Relative imports carry the `.js` extension, builtins use the `node:` prefix. +- `erasableSyntaxOnly` is on (no enums, no parameter properties), `@types/node` stays on the 20.x line (`.ncurc.cjs`) so APIs newer than Node 20 do not type-check. +- `src/parser.ts`, `src/compiler.ts` and `src/formal.ts` have only a default export, keep it that way (the CommonJS shape depends on it, `test/package.test.ts` checks it). `src/index.ts` has the named exports `parser` and `compiler`, its default export is the exports object. +- `package.json` `exports` maps `.`, `./lib/*` and `./lib/*.js` to both builds. ## Releases @@ -21,10 +30,10 @@ Releases are automated with release-please: use Conventional Commit messages (`f ## Architecture -- `lib/parser.js`: `ParserInstance` reads the tag, the command (joining `options.multiWords` such as `UID FETCH` into one command) and hands the rest to `TokenParser`, a character-by-character state machine that builds a node tree and then walks it into the plain `attributes` array returned to the caller, with upper-cased types (`ATOM`, `STRING`, `LITERAL`, `LITERAL8`, `SEQUENCE`, `LIST`, `SECTION`, `PARTIAL`). Options: `allowUntagged`, `allowSection`, `multiWords`, `literalPlus`, `literal8` (accept `~{n}`), `utf8` (accept UTF-8 in quoted strings). The default is strict RFC 3501 grammar, extensions are opt-in. -- `lib/compiler.js`: the inverse, turns `{ tag, command, attributes }` back into an IMAP string, choosing quoting or literals per value. It also accepts `TEXT` nodes (written unquoted), which the parser never produces, and `LITERAL8` nodes. The optional second argument `{ utf8: true }` quotes valid UTF-8 values instead of writing literals. -- `lib/formal.js`: RFC 3501 character classes (`ATOM-CHAR`, `DIGIT`, ...) used by both, memoized on first call, plus a `verify` helper. +- `src/parser.ts`: `ParserInstance` reads the tag, the command (joining `options.multiWords` such as `UID FETCH` into one command) and hands the rest to `TokenParser`, a character-by-character state machine that builds a node tree and then walks it into the plain `attributes` array returned to the caller, with upper-cased types (`ATOM`, `STRING`, `LITERAL`, `LITERAL8`, `SEQUENCE`, `LIST`, `SECTION`, `PARTIAL`). Options: `allowUntagged`, `allowSection`, `multiWords`, `literalPlus`, `literal8` (accept `~{n}`), `utf8` (accept UTF-8 in quoted strings). The default is strict RFC 3501 grammar, extensions are opt-in. +- `src/compiler.ts`: the inverse, turns `{ tag, command, attributes }` back into an IMAP string, choosing quoting or literals per value. It also accepts `TEXT` nodes (written unquoted), which the parser never produces, and `LITERAL8` nodes. The optional second argument `{ utf8: true }` quotes valid UTF-8 values instead of writing literals. +- `src/formal.ts`: RFC 3501 character classes (`ATOM-CHAR`, `DIGIT`, ...) used by both, memoized on first call, plus a `verify` helper. ## Tests -Tests use `node:test` and `node:assert` and are synchronous. Parse failures are asserted with `assert.throws(() => parser(...))`; do not use the `try { ...; assert.ok(false) } catch {}` pattern, since the catch would swallow the assertion failure. +Tests (`test/*.test.ts`) use `node:test` and `node:assert` and are synchronous, and import from `../src/index.js`. `test/package.test.ts` loads the built `dist/` through the `exports` map. Parse failures are asserted with `assert.throws(() => parser(...))`; do not use the `try { ...; assert.ok(false) } catch {}` pattern, since the catch would swallow the assertion failure. diff --git a/README.md b/README.md index 751ef1d..1242879 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,20 @@ Key-value pairs are also not identified, all lists are parsed into arrays, not o npm install imap-handler ``` -IMAP Handler requires Node.js 20 or newer. +IMAP Handler requires Node.js 20 or newer. It also runs on the latest Bun and Deno releases, which CI tests as well. + +The package is written in TypeScript and ships both ES modules and CommonJS, each with type declarations: + +```javascript +// ES modules +import { parser, compiler } from 'imap-handler'; +// or: import imapHandler from 'imap-handler'; + +// CommonJS +const { parser, compiler } = require('imap-handler'); +``` + +The modules are also available on their own: `imap-handler/lib/parser` and `imap-handler/lib/compiler` export the function, and `imap-handler/lib/formal` the RFC 3501 character classes (`formal.tag()`, `formal['ATOM-CHAR']()`, ...). With `require()` these load as the function or object itself, with `import` as the default export. Types such as `ParsedCommand`, `ParserOptions` and `CompilerInput` are exported from the package root. > IMAP Handler is maintained by the team behind **[EmailEngine](https://emailengine.app/?utm_source=imap-handler-readme&utm_medium=readme&utm_campaign=oss-docs&utm_content=note)**, a self-hosted email API that turns Gmail, Microsoft 365, and IMAP accounts into REST endpoints, with managed OAuth2 and webhooks for incoming mail. For a full featured IMAP client, see [ImapFlow](https://imapflow.com/). @@ -76,7 +89,7 @@ Syntax errors throw an `Error` with `code` set to `"ParserError"` (or `"MaxNesti For example ```javascript -var imapHandler = require('imap-handler'); +const imapHandler = require('imap-handler'); imapHandler.parser('A1 FETCH *:4 (BODY[HEADER.FIELDS ({4}\r\nDate Subject)]<12.45> UID)'); ``` @@ -157,7 +170,7 @@ bodies.adjacentLists = true; For example ```javascript -var command = { +const command = { tag: '*', command: 'OK', attributes: [ @@ -175,9 +188,13 @@ imapHandler.compiler(command); ## Development +The source is in `src/`, `npm run build` compiles it into `dist/esm` and `dist/cjs`. + npm install - npm test # lint + all tests + npm test # lint, type check and all tests npm run test:unit # tests only + npm run test:bun # tests under Bun + npm run test:deno # tests under Deno npm run format # apply Prettier formatting ## License diff --git a/eslint.config.js b/eslint.config.js index 86b3e54..36c3809 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,15 +1,18 @@ -'use strict'; +import js from '@eslint/js'; +import prettier from 'eslint-config-prettier'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; -const js = require('@eslint/js'); -const globals = require('globals'); - -module.exports = [ +export default tseslint.config( + { + ignores: ['dist/**', 'node_modules/**', '.claude/**'] + }, js.configs.recommended, { - files: ['**/*.js'], + files: ['**/*.js', '**/*.cjs', '**/*.ts'], languageOptions: { ecmaVersion: 2023, - sourceType: 'commonjs', + sourceType: 'module', globals: { ...globals.node } @@ -21,8 +24,28 @@ module.exports = [ 'prefer-const': 'error', 'prefer-arrow-callback': 'error', 'one-var': ['error', 'never'], - eqeqeq: ['error', 'always', { null: 'ignore' }], + eqeqeq: ['error', 'always', { null: 'ignore' }] + } + }, + { + files: ['**/*.cjs'], + languageOptions: { + sourceType: 'commonjs' + }, + rules: { strict: ['error', 'global'] } - } -]; + }, + { + files: ['**/*.ts'], + extends: [tseslint.configs.recommended], + rules: { + // handled by the TypeScript compiler + 'no-undef': 'off', + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': ['error', { args: 'none', caughtErrors: 'none' }], + '@typescript-eslint/no-explicit-any': 'off' + } + }, + prettier +); diff --git a/index.js b/index.js deleted file mode 100644 index 9f37239..0000000 --- a/index.js +++ /dev/null @@ -1,6 +0,0 @@ -'use strict'; - -module.exports = { - parser: require('./lib/parser'), - compiler: require('./lib/compiler') -}; diff --git a/lib/formal.js b/lib/formal.js deleted file mode 100644 index 4ad31fb..0000000 --- a/lib/formal.js +++ /dev/null @@ -1,151 +0,0 @@ -'use strict'; - -module.exports = { - CHAR: function () { - const value = this._expandRange(0x01, 0x7f); - this.CHAR = function () { - return value; - }; - return value; - }, - CHAR8: function () { - const value = this._expandRange(0x01, 0xff); - this.CHAR8 = function () { - return value; - }; - return value; - }, - SP: function () { - return ' '; - }, - CTL: function () { - const value = this._expandRange(0x00, 0x1f) + '\x7F'; - this.CTL = function () { - return value; - }; - return value; - }, - DQUOTE: function () { - return '"'; - }, - ALPHA: function () { - const value = this._expandRange(0x41, 0x5a) + this._expandRange(0x61, 0x7a); - this.ALPHA = function () { - return value; - }; - return value; - }, - DIGIT: function () { - const value = this._expandRange(0x30, 0x39); - this.DIGIT = function () { - return value; - }; - return value; - }, - 'ATOM-CHAR': function () { - const value = this._excludeChars(this.CHAR(), this['atom-specials']()); - this['ATOM-CHAR'] = function () { - return value; - }; - return value; - }, - 'ASTRING-CHAR': function () { - const value = this['ATOM-CHAR']() + this['resp-specials'](); - this['ASTRING-CHAR'] = function () { - return value; - }; - return value; - }, - 'TEXT-CHAR': function () { - const value = this._excludeChars(this.CHAR(), '\r\n'); - this['TEXT-CHAR'] = function () { - return value; - }; - return value; - }, - 'atom-specials': function () { - const value = '(' + ')' + '{' + this.SP() + this.CTL() + this['list-wildcards']() + this['quoted-specials']() + this['resp-specials'](); - this['atom-specials'] = function () { - return value; - }; - return value; - }, - 'list-wildcards': function () { - return '%' + '*'; - }, - 'quoted-specials': function () { - const value = this.DQUOTE() + '\\'; - this['quoted-specials'] = function () { - return value; - }; - return value; - }, - 'resp-specials': function () { - return ']'; - }, - - tag: function () { - return this._excludeChars(this['ASTRING-CHAR'](), '+'); - }, - - // RFC 3501 9: command names are atoms, e.g. x-command = "X" atom, and the second word of - // UID and AUTHENTICATE is an atom too (auth-type = atom) - command: function () { - const value = this['ATOM-CHAR'](); - this.command = function () { - return value; - }; - return value; - }, - - _expandRange: function (start, end) { - const chars = []; - for (let i = start; i <= end; i++) { - chars.push(i); - } - return String.fromCharCode.apply(String, chars); - }, - - _excludeChars: function (source, exclude) { - const sourceArr = Array.prototype.slice.call(source); - for (let i = sourceArr.length - 1; i >= 0; i--) { - if (exclude.indexOf(sourceArr[i]) >= 0) { - sourceArr.splice(i, 1); - } - } - return sourceArr.join(''); - }, - - /** - * Checks a single character against ATOM-CHAR - * - * @param {String} chr Character to check - * @return {Boolean} true if the character may appear in an atom - */ - isAtomChar: function (chr) { - if (!this._atomChars) { - this._atomChars = new Set(this['ATOM-CHAR']()); - } - return this._atomChars.has(chr); - }, - - /** - * Checks a value against the sequence-set rule (RFC 3501 section 9). Each element is - * checked on its own, so the cost stays linear for very long sets. - * - * @param {String} value Value to check - * @return {Boolean} true for a valid sequence set - */ - isSequenceSet: function (value) { - return !!value && value.split(',').every(part => /^(?:\d+|\*)(?::(?:\d+|\*))?$/.test(part)); - }, - - verify: function (str, allowedChars) { - for (let i = 0, len = str.length; i < len; i++) { - if (allowedChars.indexOf(str.charAt(i)) < 0) { - return i; - } - } - return -1; - } -}; diff --git a/lib/parser.js b/lib/parser.js deleted file mode 100644 index 5307579..0000000 --- a/lib/parser.js +++ /dev/null @@ -1,535 +0,0 @@ -'use strict'; - -const { isUtf8 } = require('buffer'); -const formalSyntax = require('./formal'); - -// RFC 3501 9: number is an unsigned 32-bit integer -const MAX_NUMBER = 0xffffffff; - -// RFC 9051 9: number64 is a 63-bit integer for literal sizes and partial ranges. Larger values than -// Number.MAX_SAFE_INTEGER can not be represented exactly, so with the number64 option that is the limit -const MAX_NUMBER64 = Number.MAX_SAFE_INTEGER; - -// Deeper input (lists, sections and the values in them) is refused instead of risking the stack -const MAX_NODE_DEPTH = 25; - -const isAtomChar = chr => formalSyntax.isAtomChar(chr); - -const isDigit = code => code >= 0x30 && code <= 0x39; - -const isSequenceSet = value => formalSyntax.isSequenceSet(value); - -const parserError = (message, pos, code) => { - const error = new Error(message + ' at position ' + pos); - error.code = code || 'ParserError'; - error.pos = pos; - return error; -}; - -module.exports = function (command, options) { - const response = {}; - - // work on a copy, the caller's object is left as it was - options = Object.assign({}, options); - options.allowSection = options.allowSection || ['BODY', 'BODY.PEEK']; - options.multiWords = options.multiWords || ['UID', 'AUTHENTICATE']; - options.allowUntagged = !!options.allowUntagged; - - const parser = new ParserInstance(command, options); - - response.tag = parser.getTag(); - parser.getSpace(); - response.command = parser.getCommand(); - - if (options.multiWords.indexOf((response.command || '').toUpperCase()) >= 0) { - parser.getSpace(); - response.command += ' ' + parser.getElement(formalSyntax.command()); - } - - if (parser.remainder.length) { - parser.getSpace(); - response.attributes = parser.getAttributes(); - } - - return response; -}; - -function ParserInstance(input, options) { - this.input = (input || '').toString(); - this.options = options || {}; - this.remainder = this.input; - this.pos = 0; -} - -ParserInstance.prototype.getTag = function () { - if (!this.tag) { - this.tag = this.getElement(formalSyntax.tag() + (this.options.allowUntagged ? '*' : ''), true); - } - return this.tag; -}; - -ParserInstance.prototype.getCommand = function () { - if (!this.command) { - this.command = this.getElement(formalSyntax.command()); - } - return this.command; -}; - -ParserInstance.prototype.getElement = function (syntax) { - let match; - let element; - let errPos; - if (this.remainder.match(/^\s/)) { - throw parserError('Unexpected whitespace', this.pos); - } - - if ((match = this.remainder.match(/^[^\s]+(?=\s|$)/))) { - element = match[0]; - - if ((errPos = formalSyntax.verify(element, syntax)) >= 0) { - throw parserError('Unexpected char', this.pos + errPos); - } - } else { - throw parserError('Unexpected end of input', this.pos); - } - - this.pos += match[0].length; - this.remainder = this.remainder.substr(match[0].length); - - return element; -}; - -ParserInstance.prototype.getSpace = function () { - if (!this.remainder.length) { - throw parserError('Unexpected end of input', this.pos); - } - - if (formalSyntax.verify(this.remainder.charAt(0), formalSyntax.SP()) >= 0) { - throw parserError('Unexpected char', this.pos); - } - - this.pos++; - this.remainder = this.remainder.substr(1); -}; - -ParserInstance.prototype.getAttributes = function () { - if (!this.remainder.length) { - throw parserError('Unexpected end of input', this.pos); - } - - if (this.remainder.match(/^\s/)) { - throw parserError('Unexpected whitespace', this.pos); - } - - return new TokenParser(this.pos, this.remainder, this.options).getAttributes(); -}; - -function TokenParser(startPos, str, options) { - this.str = (str || '').toString(); - this.options = options || {}; - this.pos = startPos || 0; - - // the longest value that may open a section, longer atoms are not compared at all - this.maxSectionName = Math.max(0, ...this.options.allowSection.map(name => name.length)); - - // literal sizes and partial ranges are number64 values in IMAP4rev2 (RFC 9051 section 9) - this.maxNumber = this.options.number64 ? MAX_NUMBER64 : MAX_NUMBER; - - this.tree = this.currentNode = this.createNode(); - this.currentNode.type = 'TREE'; - - this.processString(); -} - -TokenParser.prototype.getAttributes = function () { - const attributes = []; - let branch = attributes; - - const walk = node => { - let elm; - const curBranch = branch; - - // If the node was never closed, throw it - if (!node.closed) { - throw parserError('Unexpected end of input', this.pos + this.str.length); - } - - switch (node.type) { - case 'LITERAL': - case 'LITERAL8': - case 'ATOM': - case 'STRING': - case 'SEQUENCE': - if (node.type === 'ATOM' && node.value.toUpperCase() === 'NIL') { - branch.push(null); - break; - } - elm = { - type: node.type, - value: node.value - }; - branch.push(elm); - break; - case 'SECTION': - branch = branch[branch.length - 1].section = []; - break; - case 'LIST': - elm = []; - branch.push(elm); - branch = elm; - break; - case 'PARTIAL': - branch[branch.length - 1].partial = node.value.split('.').map(Number); - break; - } - - node.childNodes.forEach(walk); - branch = curBranch; - }; - - walk(this.tree); - - return attributes; -}; - -TokenParser.prototype.createNode = function (parentNode, startPos) { - const node = { - childNodes: [], - type: false, - value: '', - closed: true, - depth: parentNode ? parentNode.depth + 1 : 0 - }; - - if (node.depth > MAX_NODE_DEPTH) { - throw parserError('Too much nesting', startPos, 'MaxNestingReached'); - } - - if (parentNode) { - node.parentNode = parentNode; - parentNode.childNodes.push(node); - } - - if (typeof startPos === 'number') { - node.startPos = startPos; - } - - return node; -}; - -// Adds a complete value node to the current list, section or tree -TokenParser.prototype.addValue = function (type, value, start, end) { - const node = this.createNode(this.currentNode, this.pos + start); - node.type = type; - node.value = value; - node.endPos = this.pos + end - 1; - return node; -}; - -/** - * Returns the error for an unexpected char at index i, or for the end of input - */ -TokenParser.prototype.unexpected = function (i) { - return parserError(i >= this.str.length ? 'Unexpected end of input' : 'Unexpected char', this.pos + i); -}; - -/** - * Checks what follows a value or a closed list or section and returns the index to continue from. - * A single space separates values, otherwise the next char must close the enclosing list or section. - */ -TokenParser.prototype.afterValue = function (i) { - const str = this.str; - - if (i >= str.length) { - return i; - } - - const chr = str.charAt(i); - - if (chr === ' ') { - const next = str.charAt(i + 1); - if (i + 1 >= str.length || next === ' ' || next === ')' || (next === ']' && this.currentNode.type === 'SECTION')) { - throw parserError('Unexpected whitespace', this.pos + i); - } - return i + 1; - } - - if ((chr === ')' && this.currentNode.type === 'LIST') || (chr === ']' && this.currentNode.type === 'SECTION')) { - return i; - } - - throw parserError('Unexpected char', this.pos + i); -}; - -TokenParser.prototype.processString = function () { - const str = this.str; - const len = str.length; - let i = 0; - - while (i < len) { - switch (str.charAt(i)) { - // normally a space should never occur here - case ' ': - throw parserError('Unexpected whitespace', this.pos + i); - - // DQUOTE starts a new string - case '"': - i = this.readString(i); - break; - - // ( starts a new list - case '(': - this.currentNode = this.createNode(this.currentNode, this.pos + i); - this.currentNode.type = 'LIST'; - this.currentNode.closed = false; - i++; - break; - - // ) closes a list - case ')': - if (this.currentNode.type !== 'LIST') { - throw parserError('Unexpected list terminator )', this.pos + i); - } - this.currentNode.closed = true; - this.currentNode.endPos = this.pos + i; - this.currentNode = this.currentNode.parentNode; - i = this.afterValue(i + 1); - break; - - // ] closes a section, otherwise it is a valid first char of an astring (RFC 3501 9) - case ']': - if (this.currentNode.type !== 'SECTION') { - i = this.readAtom(i); - break; - } - this.currentNode.closed = true; - this.currentNode.endPos = this.pos + i; - this.currentNode = this.currentNode.parentNode; - // a partial may follow a section directly - i = this.str.charAt(i + 1) === '<' ? this.readPartial(i + 1) : this.afterValue(i + 1); - break; - - // { starts a new literal - case '{': - i = this.readLiteral(i, false); - break; - - // ~{ starts a literal8 when enabled, otherwise ~ is an ATOM-CHAR - case '~': - i = this.options.literal8 && str.charAt(i + 1) === '{' ? this.readLiteral(i, true) : this.readAtom(i); - break; - - default: - i = this.readAtom(i); - break; - } - } -}; - -/** - * Reads an atom or a sequence set starting at i. Both are made of the same chars, so the type is - * decided once the whole token is known: a token of only digits, ":", "," and "*" is a sequence - * set (a lone number or "*" stays an ATOM) when it is a valid one, anything else is an atom. - */ -TokenParser.prototype.readAtom = function (start) { - const str = this.str; - const len = str.length; - const parentType = this.currentNode.type; - let seqOnly = true; - let digitsOnly = true; - let i; - - for (i = start; i < len; i++) { - const chr = str.charAt(i); - const code = str.charCodeAt(i); - - if (chr === ' ' || (chr === ')' && parentType === 'LIST') || (chr === ']' && parentType === 'SECTION')) { - break; - } - - // [ starts a section group for the allowed elements, otherwise it is an ATOM-CHAR - if (chr === '[' && i > start && i - start <= this.maxSectionName && this.options.allowSection.indexOf(str.slice(start, i).toUpperCase()) >= 0) { - this.addValue('ATOM', str.slice(start, i), start, i); - this.currentNode = this.createNode(this.currentNode, this.pos + i); - this.currentNode.type = 'SECTION'; - this.currentNode.closed = false; - return i + 1; - } - - // Allow \ as the first char for system flags, list-wildcards for LIST patterns and - // ] as an astring char (RFC 3501 9) - if (!isAtomChar(chr) && chr !== '%' && chr !== '*' && chr !== ']' && !(chr === '\\' && i === start)) { - throw parserError('Unexpected char', this.pos + i); - } - - if (!isDigit(code)) { - digitsOnly = false; - if (chr !== ':' && chr !== ',' && chr !== '*') { - seqOnly = false; - } - } - } - - if (i === start) { - throw parserError('Unexpected char', this.pos + i); - } - - const value = str.slice(start, i); - let type = 'ATOM'; - - // A token like "10:" or "12:30:00" is not a sequence set but still a valid atom (":" and "," - // are ATOM-CHARs, RFC 3501 9), so it stays an ATOM and the commands that take a sequence set - // refuse it - if (seqOnly && !digitsOnly && value !== '*' && isSequenceSet(value)) { - type = 'SEQUENCE'; - } - - this.addValue(type, value, start, i); - return this.afterValue(i); -}; - -// RFC 3501 9: quoted = DQUOTE *QUOTED-CHAR DQUOTE, only DQUOTE and "\" may be escaped. -// With the utf8 option QUOTED-CHAR also allows UTF8-2 / UTF8-3 / UTF8-4 (RFC 9051 9, RFC 9755 3), -// and any other octet with the high bit set is refused (RFC 9755 3). isUtf8 follows RFC 3629 4, so -// overlong forms, surrogates, values above U+10FFFF and truncated sequences fail. -TokenParser.prototype.readString = function (start) { - const str = this.str; - const len = str.length; - const allow8bit = !!this.options.utf8; - let has8bit = false; - let value = ''; - let chunkStart = start + 1; - - for (let i = start + 1; i < len; i++) { - const code = str.charCodeAt(i); - - if (code === 0x22) { - // escapes are 7-bit, so the raw octets between the quotes can be validated as a whole - if (has8bit && !isUtf8(Buffer.from(str.slice(start + 1, i), 'binary'))) { - throw parserError('Invalid UTF-8', this.pos + start + 1); - } - value += str.slice(chunkStart, i); - this.addValue('STRING', value, start, i + 1); - return this.afterValue(i + 1); - } - - if (code === 0x5c) { - if (i + 1 >= len) { - throw parserError('Unexpected end of input', this.pos + i + 1); - } - const next = str.charAt(i + 1); - if (next !== '"' && next !== '\\') { - throw parserError('Unexpected escaped char', this.pos + i + 1); - } - value += str.slice(chunkStart, i) + next; - i++; - chunkStart = i + 1; - continue; - } - - // a binary string holds one octet per char, anything above 0xFF is refused below - if (allow8bit && code > 0x7f && code <= 0xff) { - has8bit = true; - continue; - } - - // TEXT-CHAR is any 7-bit char except NUL, CR and LF - if (code === 0x00 || code === 0x0a || code === 0x0d || code > 0x7f) { - throw parserError('Unexpected char', this.pos + i); - } - } - - throw parserError('Unexpected end of input', this.pos + len); -}; - -// RFC 3501 9: literal = "{" number "}" CRLF *CHAR8, RFC 7888 adds "{" number "+}" for LITERAL+. -// RFC 4466 3: literal8 = "~{" number ["+"] "}" CRLF *OCTET, the "+" only with LITERAL+ as well. -// Both sizes are limited to 32-bit numbers, larger values could not be buffered in a string anyway. -TokenParser.prototype.readLiteral = function (start, isLiteral8) { - const str = this.str; - const len = str.length; - const numStart = start + (isLiteral8 ? 2 : 1); - let i = numStart; - - while (i < len && isDigit(str.charCodeAt(i))) { - i++; - } - - if (i === numStart) { - throw this.unexpected(i); - } - - const size = Number(str.slice(numStart, i)); - - if (str.charAt(i) === '+' && this.options.literalPlus) { - i++; - } - - if (str.charAt(i) !== '}') { - throw this.unexpected(i); - } - i++; - - if (str.charAt(i) === '\n') { - i++; - } else if (str.charAt(i) === '\r' && str.charAt(i + 1) === '\n') { - i += 2; - } else { - throw this.unexpected(i); - } - - if (size > this.maxNumber || i + size > len) { - throw parserError('Unexpected end of input', this.pos + len); - } - - const value = str.substr(i, size); - - // CHAR8 excludes NUL, OCTET in a literal8 does not - if (!isLiteral8) { - const nulPos = value.indexOf('\x00'); - if (nulPos >= 0) { - throw parserError('Unexpected \\x00', this.pos + i + nulPos); - } - } - - this.addValue(isLiteral8 ? 'LITERAL8' : 'LITERAL', value, start, i + size); - return this.afterValue(i + size); -}; - -// RFC 3501 9: partial = "<" number "." nz-number ">", a single number is also accepted. RFC 9051 9 uses -// number64 and nz-number64, allowed with the number64 option -TokenParser.prototype.readPartial = function (start) { - const str = this.str; - const len = str.length; - let i = start + 1; - - const readNumber = nonZero => { - const numStart = i; - while (i < len && isDigit(str.charCodeAt(i))) { - i++; - } - if (i === numStart) { - throw this.unexpected(i); - } - if (nonZero && str.charAt(numStart) === '0') { - throw parserError('Invalid partial', this.pos + numStart); - } - if (Number(str.slice(numStart, i)) > this.maxNumber) { - throw parserError('Invalid partial', this.pos + numStart); - } - }; - - readNumber(false); - - if (str.charAt(i) === '.') { - i++; - readNumber(true); - } - - if (str.charAt(i) !== '>') { - throw this.unexpected(i); - } - - this.addValue('PARTIAL', str.slice(start + 1, i), start, i + 1); - return this.afterValue(i + 1); -}; diff --git a/package-lock.json b/package-lock.json index 33ecd2d..82e43e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,14 @@ "license": "MIT", "devDependencies": { "@eslint/js": "10.0.1", + "@types/node": "20.19.43", "eslint": "10.12.0", + "eslint-config-prettier": "10.1.8", "globals": "17.13.0", - "prettier": "3.9.9" + "prettier": "3.9.9", + "tsx": "4.23.15", + "typescript": "6.0.3", + "typescript-eslint": "8.71.1" }, "engines": { "node": ">=20.0.0" @@ -42,6 +47,448 @@ "keyv": "^5.6.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -281,6 +728,246 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz", + "integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/type-utils": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.1.tgz", + "integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", + "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.1", + "@typescript-eslint/types": "^8.71.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.1.tgz", + "integrity": "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", + "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz", + "integrity": "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", + "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", + "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.1", + "@typescript-eslint/tsconfig-utils": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz", + "integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.1.tgz", + "integrity": "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/acorn": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", @@ -398,6 +1085,48 @@ "dev": true, "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -470,6 +1199,22 @@ } } }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-scope": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", @@ -587,6 +1332,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-entry-cache": { "version": "11.1.5", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", @@ -633,6 +1396,21 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -883,6 +1661,19 @@ "node": ">=8" } }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -939,6 +1730,19 @@ "dev": true, "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -962,6 +1766,55 @@ "node": ">=8" } }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -975,6 +1828,51 @@ "node": ">= 0.8.0" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.1.tgz", + "integrity": "sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.1", + "@typescript-eslint/parser": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 17a72ff..c8bcab4 100644 --- a/package.json +++ b/package.json @@ -2,16 +2,43 @@ "name": "imap-handler", "version": "1.3.2", "description": "Parses and compiles IMAP commands", - "main": "index.js", + "type": "module", + "main": "./dist/cjs/index.js", + "types": "./dist/cjs/index.d.ts", + "typesVersions": { + "*": { + "lib/*": [ + "dist/cjs/*.d.ts" + ] + } + }, + "exports": { + ".": { + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js" + }, + "./lib/*.js": { + "import": "./dist/esm/*.js", + "require": "./dist/cjs/*.js" + }, + "./lib/*": { + "import": "./dist/esm/*.js", + "require": "./dist/cjs/*.js" + }, + "./package.json": "./package.json" + }, "files": [ - "index.js", - "lib" + "dist" ], "scripts": { - "prepare": "git rev-parse --git-dir >/dev/null 2>&1 && git config core.hooksPath .githooks || true", - "test": "npm run lint && npm run test:unit", - "test:unit": "node --test test/*.js", - "lint": "eslint", + "build": "node scripts/build.js", + "typecheck": "tsc -p tsconfig.json", + "prepare": "npm run build && (git rev-parse --git-dir >/dev/null 2>&1 && git config core.hooksPath .githooks || true)", + "test": "npm run lint && npm run build && npm run test:unit", + "test:unit": "node --import tsx --test test/*.test.ts", + "test:bun": "bun test test/", + "test:deno": "deno test --no-check --sloppy-imports --allow-read --allow-env --allow-sys test/", + "lint": "eslint && npm run typecheck", "format": "prettier --write .", "format:check": "prettier --check .", "update": "rm -rf node_modules package-lock.json && ncu -u && npm install" @@ -27,13 +54,18 @@ "keywords": [ "IMAP" ], - "author": "Postal Systems OÜ", + "author": "Postal Systems O\u00dc", "license": "MIT", "devDependencies": { "@eslint/js": "10.0.1", + "@types/node": "20.19.43", "eslint": "10.12.0", + "eslint-config-prettier": "10.1.8", "globals": "17.13.0", - "prettier": "3.9.9" + "prettier": "3.9.9", + "tsx": "4.23.15", + "typescript": "7.0.2", + "typescript-eslint": "8.71.1" }, "engines": { "node": ">=20.0.0" diff --git a/scripts/build.js b/scripts/build.js new file mode 100644 index 0000000..4df78a0 --- /dev/null +++ b/scripts/build.js @@ -0,0 +1,88 @@ +// Builds the package. +// +// 1. Compiles src/ twice with tsc: once as ES modules into dist/esm and once as +// CommonJS into dist/cjs. Each output directory gets its own package.json +// that pins the module format. +// 2. Rewrites the CommonJS files that only have a default export so that +// `require()` keeps returning the exported function or object itself, the +// same shape the pre-TypeScript CommonJS sources had +// (`require('imap-handler/lib/parser')` is the parser function). + +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const tsc = require.resolve('typescript/bin/tsc'); + +function runTsc(project) { + const result = spawnSync(process.execPath, [tsc, '-p', project], { cwd: root, stdio: 'inherit' }); + if (result.status !== 0) { + process.exit(result.status || 1); + } +} + +function listFiles(dir, ext) { + return fs + .readdirSync(dir, { recursive: true }) + .filter(name => name.endsWith(ext)) + .map(name => path.join(dir, name)); +} + +// The entry point is the one module that combines a default export with named +// runtime exports. `require('imap-handler')` was a plain `{ parser, compiler }` +// object, so it keeps its named exports and its default export is made to be +// the exports object itself. The alias is non-enumerable so that the enumerable +// keys of the module stay `parser` and `compiler`. +const ENTRY_POINT_MODULE = 'index.js'; +const ENTRY_POINT_SHIM = "Object.defineProperty(exports, 'default', { value: exports, enumerable: false, writable: true, configurable: true });\n"; + +// tsc emits `exports.default = X` for `export default X`. For modules whose only +// runtime export is the default one, make `require()` return X directly. The +// default export stays reachable as a non-enumerable `.default` property, which +// is what TypeScript and Babel generated `import X from '...'` code reads. +function applyCjsInterop(dir) { + const namedExport = /\bexports\.(?!default\b)[A-Za-z_$][\w$]*\s*=/; + const definedExport = /Object\.defineProperty\(exports,\s*"(?!__esModule")/; + const starExport = /__exportStar\(/; + const defaultExport = /\bexports\.default\s*=/; + + for (const file of listFiles(dir, '.js')) { + const source = fs.readFileSync(file, 'utf8'); + if (!defaultExport.test(source)) { + continue; + } + const name = path.relative(dir, file); + if (name === ENTRY_POINT_MODULE) { + writePatched(file, source, ENTRY_POINT_SHIM); + continue; + } + if (namedExport.test(source) || definedExport.test(source) || starExport.test(source)) { + throw new Error(name + ' has a default export and named runtime exports. Keep default-export modules default-only'); + } + const shim = + 'module.exports = exports.default;\n' + + "Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });\n"; + writePatched(file, source, shim); + } +} + +// Appends a shim to a compiled module, ahead of the source map comment if there is one +function writePatched(file, source, shim) { + const mapComment = source.lastIndexOf('//# sourceMappingURL='); + const patched = mapComment === -1 ? source + shim : source.slice(0, mapComment) + shim + source.slice(mapComment); + fs.writeFileSync(file, patched); +} + +fs.rmSync(path.join(root, 'dist'), { recursive: true, force: true }); + +runTsc('tsconfig.esm.json'); +runTsc('tsconfig.cjs.json'); + +fs.writeFileSync(path.join(root, 'dist', 'esm', 'package.json'), JSON.stringify({ type: 'module' }, null, 4) + '\n'); +fs.writeFileSync(path.join(root, 'dist', 'cjs', 'package.json'), JSON.stringify({ type: 'commonjs' }, null, 4) + '\n'); + +applyCjsInterop(path.join(root, 'dist', 'cjs')); diff --git a/lib/compiler.js b/src/compiler.ts similarity index 64% rename from lib/compiler.js rename to src/compiler.ts index 9d835b7..d4901e5 100644 --- a/lib/compiler.js +++ b/src/compiler.ts @@ -1,7 +1,41 @@ -'use strict'; +import { isUtf8 } from 'node:buffer'; +import formalSyntax from './formal.js'; -const { isUtf8 } = require('buffer'); -const formalSyntax = require('./formal'); +/** + * A value node of a response. Strings are binary strings (one char per octet), Buffers are + * converted to the same form. + */ +export interface CompilerNode { + type: string; + value?: unknown; + section?: CompilerValue[] | undefined; + partial?: number | number[] | undefined; +} + +/** + * A list. With `adjacentLists` set, the lists at its start are written without a space between + * them (body-type-mpart and env-from of RFC 3501 section 9) + */ +export interface CompilerList extends Array { + adjacentLists?: boolean | undefined; +} + +export type CompilerValue = CompilerNode | CompilerList | string | number | Buffer | null | undefined | false; + +export interface CompilerInput { + tag?: unknown; + command?: unknown; + attributes?: CompilerValue | CompilerValue[] | undefined; +} + +export interface CompilerOptions { + /** Write valid UTF-8 values as quoted strings instead of literals */ + utf8?: boolean | undefined; +} + +export interface CompilerError extends Error { + code?: string; +} // RFC 3501 9: a quoted string holds TEXT-CHARs (7-bit, no NUL, CR or LF), anything else needs a literal // eslint-disable-next-line no-control-regex @@ -12,7 +46,7 @@ const NEEDS_LITERAL = /[\x00\r\n\x80-\uffff]/; // eslint-disable-next-line no-control-regex const UNSAFE_TEXT = /[\x00\r\n\u0100-\uffff]/; -const isAtom = value => { +const isAtom = (value: string): boolean => { for (let i = 0, len = value.length; i < len; i++) { if (!formalSyntax.isAtomChar(value.charAt(i))) { return false; @@ -22,83 +56,86 @@ const isAtom = value => { }; // RFC 3501 9: flag = "\" atom, and flag-perm also allows "\*" -const isFlagOrAtom = value => value === '\\*' || isAtom(value.charAt(0) === '\\' ? value.slice(1) : value); +const isFlagOrAtom = (value: string): boolean => value === '\\*' || isAtom(value.charAt(0) === '\\' ? value.slice(1) : value); // RFC 3501 9: sequence-set, plus "$" from RFC 5182 -const isSequenceSet = value => value === '$' || formalSyntax.isSequenceSet(value); +const isSequenceSet = (value: string): boolean => value === '$' || formalSyntax.isSequenceSet(value); // Only bounded non-negative integers are written, anything else becomes 0 -const toNumber = value => { +const toNumber = (value: unknown): number => { const num = Math.round(Number(value)); return Number.isSafeInteger(num) && num >= 0 ? num : 0; }; // Values are binary strings (one char per octet), Buffers are converted to the same form -const toBinaryString = value => { +const toBinaryString = (value: unknown): string => { if (Buffer.isBuffer(value)) { return value.toString('binary'); } - return value === null || value === undefined ? '' : value.toString(); + return value === null || value === undefined ? '' : String(value); }; -const literal = value => '{' + value.length + '}\r\n' + value; +const literal = (value: string): string => '{' + value.length + '}\r\n' + value; // RFC 3501 9: only DQUOTE and "\" are escaped in a quoted string -const quote = value => '"' + value.replace(/["\\]/g, '\\$&') + '"'; +const quote = (value: string): string => '"' + value.replace(/["\\]/g, '\\$&') + '"'; // RFC 3516 4.3 and RFC 9051 9: literal8 = "~{" number64 "}" CRLF *OCTET -const literal8 = value => '~' + literal(value); +const literal8 = (value: string): string => '~' + literal(value); -const encodeAsciiString = value => (NEEDS_LITERAL.test(value) ? literal(value) : quote(value)); +const encodeAsciiString = (value: string): string => (NEEDS_LITERAL.test(value) ? literal(value) : quote(value)); // RFC 9051 9 and RFC 9755 3: QUOTED-CHAR may also be UTF8-2 / UTF8-3 / UTF8-4, so a binary string // that is valid UTF-8 (RFC 3629 4) can be quoted as long as it has no NUL, CR, LF or chars above 0xFF -const encodeUtf8String = value => +const encodeUtf8String = (value: string): string => !NEEDS_LITERAL.test(value) || (!UNSAFE_TEXT.test(value) && isUtf8(Buffer.from(value, 'binary'))) ? quote(value) : literal(value); -const compilerError = (message, code) => { - const error = new Error(message); +const compilerError = (message: string, code: string): CompilerError => { + const error: CompilerError = new Error(message); error.code = code; return error; }; -const checkText = (value, what) => { +const checkText = (value: string, what: string): string => { if (UNSAFE_TEXT.test(value)) { throw compilerError('Line break or NUL in ' + what, 'InvalidTextValue'); } return value; }; +const toList = (value: T | T[] | undefined): T[] => ([] as T[]).concat(value || []); + /** * Compiles an input object into an IMAP string * - * @param {Object} response Object with tag, command and attributes - * @param {Object} [options] Set utf8 to write valid UTF-8 values as quoted strings instead of literals + * @param response Object with tag, command and attributes + * @param [options] Set utf8 to write valid UTF-8 values as quoted strings instead of literals */ -module.exports = function (response, options) { +export default function compiler(response: CompilerInput, options?: CompilerOptions): string { const encodeString = options && options.utf8 ? encodeUtf8String : encodeAsciiString; let resp = checkText(toBinaryString(response.tag), 'tag') + (response.command ? ' ' + checkText(toBinaryString(response.command), 'command') : ''); // set right after "(" or "[" is written, so the first element inside gets no leading space let afterOpener = false; - const walk = function (node) { + const walk = (node: CompilerValue): void => { if (!afterOpener) { resp += ' '; } afterOpener = false; if (Array.isArray(node)) { + const list: CompilerList = node; resp += '('; afterOpener = true; // some rules start with lists that have no SP between them, e.g. // body-type-mpart = 1*body SP media-subtype and env-from = "(" 1*address ")" (RFC 3501 section 9) let leadingLists = 0; - if (node.adjacentLists) { - while (leadingLists < node.length && Array.isArray(node[leadingLists])) { + if (list.adjacentLists) { + while (leadingLists < list.length && Array.isArray(list[leadingLists])) { leadingLists++; } } - node.forEach((child, i) => { + list.forEach((child, i) => { if (i && i < leadingLists) { afterOpener = true; } @@ -129,7 +166,7 @@ module.exports = function (response, options) { return; } - let val; + let val: string; switch (typeof node.type === 'string' ? node.type.toUpperCase() : '') { case 'LITERAL': @@ -177,7 +214,7 @@ module.exports = function (response, options) { afterOpener = false; } if (node.partial) { - resp += '<' + [].concat(node.partial).map(toNumber).join('.') + '>'; + resp += '<' + toList(node.partial).map(toNumber).join('.') + '>'; } break; @@ -186,7 +223,7 @@ module.exports = function (response, options) { } }; - [].concat(response.attributes || []).forEach(walk); + toList(response.attributes).forEach(walk); return resp; -}; +} diff --git a/src/formal.ts b/src/formal.ts new file mode 100644 index 0000000..56103b8 --- /dev/null +++ b/src/formal.ts @@ -0,0 +1,114 @@ +// RFC 3501 section 9 character classes. Each class is computed on first use and then reused. + +export interface FormalSyntax { + CHAR(): string; + CHAR8(): string; + SP(): string; + CTL(): string; + DQUOTE(): string; + ALPHA(): string; + DIGIT(): string; + 'ATOM-CHAR'(): string; + 'ASTRING-CHAR'(): string; + 'TEXT-CHAR'(): string; + 'atom-specials'(): string; + 'list-wildcards'(): string; + 'quoted-specials'(): string; + 'resp-specials'(): string; + tag(): string; + command(): string; + _expandRange(start: number, end: number): string; + _excludeChars(source: string, exclude: string): string; + isAtomChar(chr: string): boolean; + isSequenceSet(value: string): boolean; + verify(str: string, allowedChars: string): number; +} + +const memoize = (fn: () => T): (() => T) => { + let value: T | undefined; + return () => { + if (value === undefined) { + value = fn(); + } + return value; + }; +}; + +const expandRange = (start: number, end: number): string => { + const chars: number[] = []; + for (let i = start; i <= end; i++) { + chars.push(i); + } + return String.fromCharCode(...chars); +}; + +const excludeChars = (source: string, exclude: string): string => + Array.from(source) + .filter(chr => !exclude.includes(chr)) + .join(''); + +const atomChars = memoize(() => new Set(formal['ATOM-CHAR']())); + +const formal: FormalSyntax = { + CHAR: memoize(() => expandRange(0x01, 0x7f)), + CHAR8: memoize(() => expandRange(0x01, 0xff)), + SP: () => ' ', + CTL: memoize(() => expandRange(0x00, 0x1f) + '\x7F'), + DQUOTE: () => '"', + ALPHA: memoize(() => expandRange(0x41, 0x5a) + expandRange(0x61, 0x7a)), + DIGIT: memoize(() => expandRange(0x30, 0x39)), + 'ATOM-CHAR': memoize(() => excludeChars(formal.CHAR(), formal['atom-specials']())), + 'ASTRING-CHAR': memoize(() => formal['ATOM-CHAR']() + formal['resp-specials']()), + 'TEXT-CHAR': memoize(() => excludeChars(formal.CHAR(), '\r\n')), + 'atom-specials': memoize( + () => '(' + ')' + '{' + formal.SP() + formal.CTL() + formal['list-wildcards']() + formal['quoted-specials']() + formal['resp-specials']() + ), + 'list-wildcards': () => '%' + '*', + 'quoted-specials': () => formal.DQUOTE() + '\\', + 'resp-specials': () => ']', + + tag: memoize(() => excludeChars(formal['ASTRING-CHAR'](), '+')), + + // RFC 3501 9: command names are atoms, e.g. x-command = "X" atom, and the second word of + // UID and AUTHENTICATE is an atom too (auth-type = atom) + command: memoize(() => formal['ATOM-CHAR']()), + + _expandRange: expandRange, + + _excludeChars: excludeChars, + + /** + * Checks a single character against ATOM-CHAR + * + * @param chr Character to check + * @return true if the character may appear in an atom + */ + isAtomChar(chr: string): boolean { + return atomChars().has(chr); + }, + + /** + * Checks a value against the sequence-set rule (RFC 3501 section 9). Each element is + * checked on its own, so the cost stays linear for very long sets. + * + * @param value Value to check + * @return true for a valid sequence set + */ + isSequenceSet(value: string): boolean { + return !!value && value.split(',').every(part => /^(?:\d+|\*)(?::(?:\d+|\*))?$/.test(part)); + }, + + /** + * Returns the position of the first char of str that is not in allowedChars, or -1 + */ + verify(str: string, allowedChars: string): number { + for (let i = 0, len = str.length; i < len; i++) { + if (allowedChars.indexOf(str.charAt(i)) < 0) { + return i; + } + } + return -1; + } +}; + +export default formal; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..94960e0 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,10 @@ +import parser from './parser.js'; +import compiler from './compiler.js'; + +export { parser, compiler }; + +export type { ParserOptions, ParsedCommand, ParsedAttribute, ParsedNode, AttributeType, ParserError } from './parser.js'; +export type { CompilerOptions, CompilerInput, CompilerNode, CompilerList, CompilerValue, CompilerError } from './compiler.js'; +export type { FormalSyntax } from './formal.js'; + +export default { parser, compiler }; diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..d3cfe3a --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,623 @@ +import { isUtf8 } from 'node:buffer'; +import formalSyntax from './formal.js'; + +export interface ParserOptions { + /** Accept "*" as the tag */ + allowUntagged?: boolean | undefined; + /** Atoms that may be followed by a section, default `["BODY", "BODY.PEEK"]` */ + allowSection?: string[] | undefined; + /** Commands that are joined with the next word, default `["UID", "AUTHENTICATE"]` */ + multiWords?: string[] | undefined; + /** Accept non-synchronizing literals `{n+}` (RFC 7888) */ + literalPlus?: boolean | undefined; + /** Accept `~{n}` literals (RFC 3516, RFC 9051) */ + literal8?: boolean | undefined; + /** Accept literal sizes and partial ranges up to Number.MAX_SAFE_INTEGER (RFC 9051 number64) */ + number64?: boolean | undefined; + /** Accept valid UTF-8 in quoted strings (RFC 9051, RFC 9755) */ + utf8?: boolean | undefined; +} + +export type AttributeType = 'ATOM' | 'STRING' | 'LITERAL' | 'LITERAL8' | 'SEQUENCE'; + +/** A value of the parsed attributes, NIL is returned as null */ +export interface ParsedNode { + type: AttributeType; + value: string; + section?: ParsedAttribute[]; + partial?: number[]; +} + +export type ParsedAttribute = ParsedNode | ParsedAttribute[] | null; + +export interface ParsedCommand { + tag: string; + command: string; + attributes?: ParsedAttribute[]; +} + +export interface ParserError extends Error { + code: string; + pos: number; +} + +type NodeType = false | 'TREE' | 'LIST' | 'SECTION' | 'PARTIAL' | AttributeType; + +interface TreeNode { + childNodes: TreeNode[]; + type: NodeType; + value: string; + closed: boolean; + depth: number; + parentNode?: TreeNode; + startPos?: number; + endPos?: number; +} + +interface ResolvedOptions extends ParserOptions { + allowSection: string[]; + multiWords: string[]; + allowUntagged: boolean; +} + +// RFC 3501 9: number is an unsigned 32-bit integer +const MAX_NUMBER = 0xffffffff; + +// RFC 9051 9: number64 is a 63-bit integer for literal sizes and partial ranges. Larger values than +// Number.MAX_SAFE_INTEGER can not be represented exactly, so with the number64 option that is the limit +const MAX_NUMBER64 = Number.MAX_SAFE_INTEGER; + +// Deeper input (lists, sections and the values in them) is refused instead of risking the stack +const MAX_NODE_DEPTH = 25; + +const isAtomChar = (chr: string): boolean => formalSyntax.isAtomChar(chr); + +const isDigit = (code: number): boolean => code >= 0x30 && code <= 0x39; + +const isSequenceSet = (value: string): boolean => formalSyntax.isSequenceSet(value); + +const parserError = (message: string, pos: number, code?: string): ParserError => { + const error = new Error(message + ' at position ' + pos) as ParserError; + error.code = code || 'ParserError'; + error.pos = pos; + return error; +}; + +/** + * Parses a complete IMAP command (including all literals, without the final line break) + * + * @param command IMAP command + * @param [options] Parser options + * @return Parsed command + */ +export default function parser(command: string | Buffer, options?: ParserOptions): ParsedCommand { + // work on a copy, the caller's object is left as it was + const opts: ResolvedOptions = { + ...options, + allowSection: options?.allowSection || ['BODY', 'BODY.PEEK'], + multiWords: options?.multiWords || ['UID', 'AUTHENTICATE'], + allowUntagged: !!options?.allowUntagged + }; + + const instance = new ParserInstance(command, opts); + + const tag = instance.getTag(); + instance.getSpace(); + const response: ParsedCommand = { tag, command: instance.getCommand() }; + + if (opts.multiWords.indexOf((response.command || '').toUpperCase()) >= 0) { + instance.getSpace(); + response.command += ' ' + instance.getElement(formalSyntax.command()); + } + + if (instance.remainder.length) { + instance.getSpace(); + response.attributes = instance.getAttributes(); + } + + return response; +} + +class ParserInstance { + input: string; + options: ResolvedOptions; + remainder: string; + pos: number; + tag?: string; + command?: string; + + constructor(input: string | Buffer, options: ResolvedOptions) { + this.input = (input || '').toString(); + this.options = options; + this.remainder = this.input; + this.pos = 0; + } + + getTag(): string { + if (!this.tag) { + this.tag = this.getElement(formalSyntax.tag() + (this.options.allowUntagged ? '*' : '')); + } + return this.tag; + } + + getCommand(): string { + if (!this.command) { + this.command = this.getElement(formalSyntax.command()); + } + return this.command; + } + + getElement(syntax: string): string { + let errPos: number; + if (this.remainder.match(/^\s/)) { + throw parserError('Unexpected whitespace', this.pos); + } + + const match = this.remainder.match(/^[^\s]+(?=\s|$)/); + if (!match) { + throw parserError('Unexpected end of input', this.pos); + } + + const element = match[0]; + + if ((errPos = formalSyntax.verify(element, syntax)) >= 0) { + throw parserError('Unexpected char', this.pos + errPos); + } + + this.pos += element.length; + this.remainder = this.remainder.substring(element.length); + + return element; + } + + getSpace(): void { + if (!this.remainder.length) { + throw parserError('Unexpected end of input', this.pos); + } + + if (formalSyntax.verify(this.remainder.charAt(0), formalSyntax.SP()) >= 0) { + throw parserError('Unexpected char', this.pos); + } + + this.pos++; + this.remainder = this.remainder.substring(1); + } + + getAttributes(): ParsedAttribute[] { + if (!this.remainder.length) { + throw parserError('Unexpected end of input', this.pos); + } + + if (this.remainder.match(/^\s/)) { + throw parserError('Unexpected whitespace', this.pos); + } + + return new TokenParser(this.pos, this.remainder, this.options).getAttributes(); + } +} + +class TokenParser { + str: string; + options: ResolvedOptions; + pos: number; + maxSectionName: number; + maxNumber: number; + tree: TreeNode; + currentNode: TreeNode; + + constructor(startPos: number, str: string, options: ResolvedOptions) { + this.str = (str || '').toString(); + this.options = options; + this.pos = startPos || 0; + + // the longest value that may open a section, longer atoms are not compared at all + this.maxSectionName = Math.max(0, ...this.options.allowSection.map(name => name.length)); + + // literal sizes and partial ranges are number64 values in IMAP4rev2 (RFC 9051 section 9) + this.maxNumber = this.options.number64 ? MAX_NUMBER64 : MAX_NUMBER; + + this.tree = this.currentNode = this.createNode(); + this.currentNode.type = 'TREE'; + + this.processString(); + } + + getAttributes(): ParsedAttribute[] { + const attributes: ParsedAttribute[] = []; + let branch = attributes; + + // the last value of a branch, the one a section or partial belongs to + const lastNode = (): ParsedNode => branch[branch.length - 1] as ParsedNode; + + const walk = (node: TreeNode): void => { + let elm: ParsedAttribute; + const curBranch = branch; + + // If the node was never closed, throw it + if (!node.closed) { + throw parserError('Unexpected end of input', this.pos + this.str.length); + } + + switch (node.type) { + case 'LITERAL': + case 'LITERAL8': + case 'ATOM': + case 'STRING': + case 'SEQUENCE': + if (node.type === 'ATOM' && node.value.toUpperCase() === 'NIL') { + branch.push(null); + break; + } + elm = { + type: node.type, + value: node.value + }; + branch.push(elm); + break; + case 'SECTION': + branch = lastNode().section = []; + break; + case 'LIST': + elm = []; + branch.push(elm); + branch = elm; + break; + case 'PARTIAL': + lastNode().partial = node.value.split('.').map(Number); + break; + } + + node.childNodes.forEach(walk); + branch = curBranch; + }; + + walk(this.tree); + + return attributes; + } + + createNode(parentNode?: TreeNode, startPos?: number): TreeNode { + const node: TreeNode = { + childNodes: [], + type: false, + value: '', + closed: true, + depth: parentNode ? parentNode.depth + 1 : 0 + }; + + if (node.depth > MAX_NODE_DEPTH) { + throw parserError('Too much nesting', startPos as number, 'MaxNestingReached'); + } + + if (parentNode) { + node.parentNode = parentNode; + parentNode.childNodes.push(node); + } + + if (typeof startPos === 'number') { + node.startPos = startPos; + } + + return node; + } + + // Adds a complete value node to the current list, section or tree + addValue(type: NodeType, value: string, start: number, end: number): TreeNode { + const node = this.createNode(this.currentNode, this.pos + start); + node.type = type; + node.value = value; + node.endPos = this.pos + end - 1; + return node; + } + + // Closes the current list or section at index i + closeNode(i: number): void { + this.currentNode.closed = true; + this.currentNode.endPos = this.pos + i; + this.currentNode = this.currentNode.parentNode as TreeNode; + } + + /** + * Returns the error for an unexpected char at index i, or for the end of input + */ + unexpected(i: number): ParserError { + return parserError(i >= this.str.length ? 'Unexpected end of input' : 'Unexpected char', this.pos + i); + } + + /** + * Checks what follows a value or a closed list or section and returns the index to continue from. + * A single space separates values, otherwise the next char must close the enclosing list or section. + */ + afterValue(i: number): number { + const str = this.str; + + if (i >= str.length) { + return i; + } + + const chr = str.charAt(i); + + if (chr === ' ') { + const next = str.charAt(i + 1); + if (i + 1 >= str.length || next === ' ' || next === ')' || (next === ']' && this.currentNode.type === 'SECTION')) { + throw parserError('Unexpected whitespace', this.pos + i); + } + return i + 1; + } + + if ((chr === ')' && this.currentNode.type === 'LIST') || (chr === ']' && this.currentNode.type === 'SECTION')) { + return i; + } + + throw parserError('Unexpected char', this.pos + i); + } + + processString(): void { + const str = this.str; + const len = str.length; + let i = 0; + + while (i < len) { + switch (str.charAt(i)) { + // normally a space should never occur here + case ' ': + throw parserError('Unexpected whitespace', this.pos + i); + + // DQUOTE starts a new string + case '"': + i = this.readString(i); + break; + + // ( starts a new list + case '(': + this.currentNode = this.createNode(this.currentNode, this.pos + i); + this.currentNode.type = 'LIST'; + this.currentNode.closed = false; + i++; + break; + + // ) closes a list + case ')': + if (this.currentNode.type !== 'LIST') { + throw parserError('Unexpected list terminator )', this.pos + i); + } + this.closeNode(i); + i = this.afterValue(i + 1); + break; + + // ] closes a section, otherwise it is a valid first char of an astring (RFC 3501 9) + case ']': + if (this.currentNode.type !== 'SECTION') { + i = this.readAtom(i); + break; + } + this.closeNode(i); + // a partial may follow a section directly + i = this.str.charAt(i + 1) === '<' ? this.readPartial(i + 1) : this.afterValue(i + 1); + break; + + // { starts a new literal + case '{': + i = this.readLiteral(i, false); + break; + + // ~{ starts a literal8 when enabled, otherwise ~ is an ATOM-CHAR + case '~': + i = this.options.literal8 && str.charAt(i + 1) === '{' ? this.readLiteral(i, true) : this.readAtom(i); + break; + + default: + i = this.readAtom(i); + break; + } + } + } + + /** + * Reads an atom or a sequence set starting at i. Both are made of the same chars, so the type is + * decided once the whole token is known: a token of only digits, ":", "," and "*" is a sequence + * set (a lone number or "*" stays an ATOM) when it is a valid one, anything else is an atom. + */ + readAtom(start: number): number { + const str = this.str; + const len = str.length; + const parentType = this.currentNode.type; + let seqOnly = true; + let digitsOnly = true; + let i: number; + + for (i = start; i < len; i++) { + const chr = str.charAt(i); + const code = str.charCodeAt(i); + + if (chr === ' ' || (chr === ')' && parentType === 'LIST') || (chr === ']' && parentType === 'SECTION')) { + break; + } + + // [ starts a section group for the allowed elements, otherwise it is an ATOM-CHAR + if (chr === '[' && i > start && i - start <= this.maxSectionName && this.options.allowSection.indexOf(str.slice(start, i).toUpperCase()) >= 0) { + this.addValue('ATOM', str.slice(start, i), start, i); + this.currentNode = this.createNode(this.currentNode, this.pos + i); + this.currentNode.type = 'SECTION'; + this.currentNode.closed = false; + return i + 1; + } + + // Allow \ as the first char for system flags, list-wildcards for LIST patterns and + // ] as an astring char (RFC 3501 9) + if (!isAtomChar(chr) && chr !== '%' && chr !== '*' && chr !== ']' && !(chr === '\\' && i === start)) { + throw parserError('Unexpected char', this.pos + i); + } + + if (!isDigit(code)) { + digitsOnly = false; + if (chr !== ':' && chr !== ',' && chr !== '*') { + seqOnly = false; + } + } + } + + if (i === start) { + throw parserError('Unexpected char', this.pos + i); + } + + const value = str.slice(start, i); + let type: NodeType = 'ATOM'; + + // A token like "10:" or "12:30:00" is not a sequence set but still a valid atom (":" and "," + // are ATOM-CHARs, RFC 3501 9), so it stays an ATOM and the commands that take a sequence set + // refuse it + if (seqOnly && !digitsOnly && value !== '*' && isSequenceSet(value)) { + type = 'SEQUENCE'; + } + + this.addValue(type, value, start, i); + return this.afterValue(i); + } + + // RFC 3501 9: quoted = DQUOTE *QUOTED-CHAR DQUOTE, only DQUOTE and "\" may be escaped. + // With the utf8 option QUOTED-CHAR also allows UTF8-2 / UTF8-3 / UTF8-4 (RFC 9051 9, RFC 9755 3), + // and any other octet with the high bit set is refused (RFC 9755 3). isUtf8 follows RFC 3629 4, so + // overlong forms, surrogates, values above U+10FFFF and truncated sequences fail. + readString(start: number): number { + const str = this.str; + const len = str.length; + const allow8bit = !!this.options.utf8; + let has8bit = false; + let value = ''; + let chunkStart = start + 1; + + for (let i = start + 1; i < len; i++) { + const code = str.charCodeAt(i); + + if (code === 0x22) { + // escapes are 7-bit, so the raw octets between the quotes can be validated as a whole + if (has8bit && !isUtf8(Buffer.from(str.slice(start + 1, i), 'binary'))) { + throw parserError('Invalid UTF-8', this.pos + start + 1); + } + value += str.slice(chunkStart, i); + this.addValue('STRING', value, start, i + 1); + return this.afterValue(i + 1); + } + + if (code === 0x5c) { + if (i + 1 >= len) { + throw parserError('Unexpected end of input', this.pos + i + 1); + } + const next = str.charAt(i + 1); + if (next !== '"' && next !== '\\') { + throw parserError('Unexpected escaped char', this.pos + i + 1); + } + value += str.slice(chunkStart, i) + next; + i++; + chunkStart = i + 1; + continue; + } + + // a binary string holds one octet per char, anything above 0xFF is refused below + if (allow8bit && code > 0x7f && code <= 0xff) { + has8bit = true; + continue; + } + + // TEXT-CHAR is any 7-bit char except NUL, CR and LF + if (code === 0x00 || code === 0x0a || code === 0x0d || code > 0x7f) { + throw parserError('Unexpected char', this.pos + i); + } + } + + throw parserError('Unexpected end of input', this.pos + len); + } + + // RFC 3501 9: literal = "{" number "}" CRLF *CHAR8, RFC 7888 adds "{" number "+}" for LITERAL+. + // RFC 4466 3: literal8 = "~{" number ["+"] "}" CRLF *OCTET, the "+" only with LITERAL+ as well. + // Both sizes are limited to 32-bit numbers, larger values could not be buffered in a string anyway. + readLiteral(start: number, isLiteral8: boolean): number { + const str = this.str; + const len = str.length; + const numStart = start + (isLiteral8 ? 2 : 1); + let i = numStart; + + while (i < len && isDigit(str.charCodeAt(i))) { + i++; + } + + if (i === numStart) { + throw this.unexpected(i); + } + + const size = Number(str.slice(numStart, i)); + + if (str.charAt(i) === '+' && this.options.literalPlus) { + i++; + } + + if (str.charAt(i) !== '}') { + throw this.unexpected(i); + } + i++; + + if (str.charAt(i) === '\n') { + i++; + } else if (str.charAt(i) === '\r' && str.charAt(i + 1) === '\n') { + i += 2; + } else { + throw this.unexpected(i); + } + + if (size > this.maxNumber || i + size > len) { + throw parserError('Unexpected end of input', this.pos + len); + } + + const value = str.substring(i, i + size); + + // CHAR8 excludes NUL, OCTET in a literal8 does not + if (!isLiteral8) { + const nulPos = value.indexOf('\x00'); + if (nulPos >= 0) { + throw parserError('Unexpected \\x00', this.pos + i + nulPos); + } + } + + this.addValue(isLiteral8 ? 'LITERAL8' : 'LITERAL', value, start, i + size); + return this.afterValue(i + size); + } + + // RFC 3501 9: partial = "<" number "." nz-number ">", a single number is also accepted. RFC 9051 9 uses + // number64 and nz-number64, allowed with the number64 option + readPartial(start: number): number { + const str = this.str; + const len = str.length; + let i = start + 1; + + const readNumber = (nonZero: boolean): void => { + const numStart = i; + while (i < len && isDigit(str.charCodeAt(i))) { + i++; + } + if (i === numStart) { + throw this.unexpected(i); + } + if (nonZero && str.charAt(numStart) === '0') { + throw parserError('Invalid partial', this.pos + numStart); + } + if (Number(str.slice(numStart, i)) > this.maxNumber) { + throw parserError('Invalid partial', this.pos + numStart); + } + }; + + readNumber(false); + + if (str.charAt(i) === '.') { + i++; + readNumber(true); + } + + if (str.charAt(i) !== '>') { + throw this.unexpected(i); + } + + this.addValue('PARTIAL', str.slice(start + 1, i), start, i + 1); + return this.afterValue(i + 1); + } +} diff --git a/test/compiler.js b/test/compiler.test.ts similarity index 92% rename from test/compiler.js rename to test/compiler.test.ts index e47aa61..4edaf73 100644 --- a/test/compiler.js +++ b/test/compiler.test.ts @@ -1,9 +1,10 @@ -'use strict'; +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; -const { describe, it } = require('node:test'); -const assert = require('node:assert/strict'); +import { parser, compiler as compile_ } from '../src/index.js'; -const { parser, compiler } = require('../index'); +// the tests also pass values the types do not allow, to check how they are handled +const compiler = (response: any, options?: any): string => compile_(response, options); it('Test compiler', () => { const command = @@ -149,7 +150,7 @@ describe('Test Types', () => { }); describe('Quoting and literals', () => { - const compile = attributes => compiler({ tag: '*', command: 'CMD', attributes }); + const compile = (attributes: any) => compiler({ tag: '*', command: 'CMD', attributes }); it('escapes only DQUOTE and backslash', () => { assert.equal(compile(['a\tb', 'x"y\\z', { type: 'STRING', value: '' }]), '* CMD "a\tb" "x\\"y\\\\z" ""'); @@ -174,7 +175,7 @@ describe('Quoting and literals', () => { }); describe('Atoms', () => { - const compile = attributes => compiler({ tag: '*', command: 'CMD', attributes }); + const compile = (attributes: any) => compiler({ tag: '*', command: 'CMD', attributes }); it('writes flags as atoms', () => { assert.equal( @@ -218,7 +219,7 @@ describe('Atoms', () => { }); describe('Unchecked values', () => { - const compile = attributes => compiler({ tag: '*', command: 'CMD', attributes }); + const compile = (attributes: any) => compiler({ tag: '*', command: 'CMD', attributes }); it('validates sequence sets', () => { assert.equal( @@ -265,9 +266,9 @@ describe('Round trips', () => { }); it('writes adjacent lists without SP when the list asks for it', () => { // only the lists at the start are joined, body-fld-param SP body-fld-dsp keeps its space - const bodies = [['TEXT', 'PLAIN'], ['TEXT', 'HTML'], 'ALTERNATIVE', ['BOUNDARY', 'x'], ['INLINE', null]]; + const bodies: any = [['TEXT', 'PLAIN'], ['TEXT', 'HTML'], 'ALTERNATIVE', ['BOUNDARY', 'x'], ['INLINE', null]]; bodies.adjacentLists = true; - const addresses = [ + const addresses: any = [ [null, null, 'a', 'b'], [null, null, 'c', 'd'] ]; @@ -280,7 +281,7 @@ describe('Round trips', () => { }); describe('literal8', () => { - const compile = attributes => compiler({ tag: '*', command: 'CMD', attributes }); + const compile = (attributes: any) => compiler({ tag: '*', command: 'CMD', attributes }); it('writes LITERAL8 nodes', () => { // RFC 3516 4.3: msg-att-static =/ "BINARY" section-binary SP (nstring / literal8) @@ -307,8 +308,8 @@ describe('literal8', () => { }); describe('UTF-8 option', () => { - const compile = (attributes, options) => compiler({ tag: '*', command: 'CMD', attributes }, options); - const binary = value => Buffer.from(value).toString('binary'); + const compile = (attributes: any, options?: any) => compiler({ tag: '*', command: 'CMD', attributes }, options); + const binary = (value: string) => Buffer.from(value).toString('binary'); it('keeps literals for 8-bit values by default', () => { assert.equal(compile([binary('é')]), '* CMD {2}\r\n\xc3\xa9'); diff --git a/test/package.test.ts b/test/package.test.ts new file mode 100644 index 0000000..d576ef5 --- /dev/null +++ b/test/package.test.ts @@ -0,0 +1,57 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// These tests load the compiled output in dist/ (built by the pretest script) through the +// package.json exports map, the way an installed copy of the package is loaded. Node resolves +// the package name to the package itself when the specifier is used from inside the package. +const require = createRequire(import.meta.url); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +// a non-literal specifier keeps TypeScript from resolving the built types, dist/ may not exist yet +const packageName: string = 'imap-handler'; + +const command = 'A1 FETCH 1:* (FLAGS BODY[HEADER.FIELDS (SUBJECT)]<0.10>)'; + +describe('Built package', () => { + it('ships both module formats with type declarations', () => { + for (const format of ['esm', 'cjs']) { + for (const name of ['index', 'parser', 'compiler', 'formal']) { + assert.ok(fs.existsSync(path.join(root, 'dist', format, name + '.js')), format + ' ' + name); + assert.ok(fs.existsSync(path.join(root, 'dist', format, name + '.d.ts')), format + ' ' + name + ' declarations'); + } + } + }); + + it('keeps the CommonJS shapes', () => { + const imapHandler = require(packageName); + assert.deepEqual(Object.keys(imapHandler).sort(), ['compiler', 'parser']); + assert.equal(imapHandler.default, imapHandler); + assert.equal(imapHandler.compiler(imapHandler.parser(command)), command); + + const parser = require(packageName + '/lib/parser'); + assert.equal(typeof parser, 'function'); + assert.equal(parser, imapHandler.parser); + assert.equal(parser.default, parser); + assert.equal(require(packageName + '/lib/parser.js'), parser); + assert.equal(require(packageName + '/lib/compiler'), imapHandler.compiler); + + const formal = require(packageName + '/lib/formal'); + assert.equal(formal.tag().indexOf('+'), -1); + assert.equal(formal.isAtomChar('A'), true); + }); + + it('loads as an ES module', async () => { + const imapHandler = await import(packageName); + assert.equal(imapHandler.default.parser, imapHandler.parser); + assert.equal(imapHandler.compiler(imapHandler.parser(command)), command); + + const { default: parser } = await import(packageName + '/lib/parser'); + assert.equal(parser, imapHandler.parser); + const { default: formal } = await import(packageName + '/lib/formal'); + assert.equal(formal.isAtomChar('('), false); + }); +}); diff --git a/test/parser.js b/test/parser.test.ts similarity index 98% rename from test/parser.js rename to test/parser.test.ts index 2940466..8be78f2 100644 --- a/test/parser.js +++ b/test/parser.test.ts @@ -1,9 +1,10 @@ -'use strict'; +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; -const { describe, it } = require('node:test'); -const assert = require('node:assert/strict'); +import { parser as parse } from '../src/index.js'; -const { parser } = require('../index'); +// the assertions walk into the attributes without narrowing their types +const parser = (...args: Parameters): any => parse(...args); describe('TAG', () => { it('Get tag success', () => { @@ -777,9 +778,9 @@ describe('UTF-8 in quoted strings', () => { }); describe('Extension syntax', () => { - const atom = value => ({ type: 'ATOM', value }); - const seq = value => ({ type: 'SEQUENCE', value }); - const str = value => ({ type: 'STRING', value }); + const atom = (value: string) => ({ type: 'ATOM', value }); + const seq = (value: string) => ({ type: 'SEQUENCE', value }); + const str = (value: string) => ({ type: 'STRING', value }); it('parses ESEARCH return options and SEARCHRES $', () => { // RFC 4466 3: search-return-opts = SP "RETURN" SP "(" [search-return-opt *(SP search-return-opt)] ")" diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..99f84a4 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "useDefineForClassFields": false, + "types": ["node"], + "strict": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "isolatedModules": true, + "erasableSyntaxOnly": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true + } +} diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json new file mode 100644 index 0000000..cbf0965 --- /dev/null +++ b/tsconfig.cjs.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "bundler", + "rootDir": "src", + "outDir": "dist/cjs" + }, + "include": ["src/**/*.ts"] +} diff --git a/tsconfig.esm.json b/tsconfig.esm.json new file mode 100644 index 0000000..dc83cd3 --- /dev/null +++ b/tsconfig.esm.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "rootDir": "src", + "outDir": "dist/esm" + }, + "include": ["src/**/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d6183ed --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.esm.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} From 31c43aa4ad79fe1bd9b9feac36ea2a091fcdf8c3 Mon Sep 17 00:00:00 2001 From: Andris Reinman Date: Wed, 7 Oct 2026 18:42:08 +0300 Subject: [PATCH 2/2] chore: keep typescript on 6.x Co-Authored-By: Claude Opus 5.5 --- .ncurc.cjs | 5 +++-- package.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.ncurc.cjs b/.ncurc.cjs index ed802ef..5d88469 100644 --- a/.ncurc.cjs +++ b/.ncurc.cjs @@ -4,6 +4,7 @@ module.exports = { // Node 20 is the supported runtime floor (see "engines" in package.json). A dependency major that // needs a newer Node has to be capped here with `target` or `reject`. upgrade: true, - // @types/node stays on the 20.x line so the compiler rejects APIs that Node 20 does not have - target: name => (name === '@types/node' ? 'minor' : 'latest') + // @types/node stays on the 20.x line so the compiler rejects APIs that Node 20 does not have, and + // typescript on 6.x like nodemailer, a move to the native TypeScript 7 compiler is a separate change + target: name => (name === '@types/node' || name === 'typescript' ? 'minor' : 'latest') }; diff --git a/package.json b/package.json index c8bcab4..cb109d0 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "globals": "17.13.0", "prettier": "3.9.9", "tsx": "4.23.15", - "typescript": "7.0.2", + "typescript": "6.0.3", "typescript-eslint": "8.71.1" }, "engines": {