From 56537294d29fe065ce4ece3f3a35b0bcb5e57725 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Tue, 26 May 2026 12:13:03 -0700 Subject: [PATCH] feat: add ESM support closes #99 --- .github/workflows/types.yml | 10 ++-- CHANGELOG.md | 2 + eslint.config.mjs | 33 +++++++----- example/client/esm.mjs | 5 ++ index.mjs | 18 +++++++ package.json | 18 ++++++- test/esm.mjs | 78 +++++++++++++++++++++++++++ ts/index.d.mts | 53 ++++++++++++++++++ ts/tsconfig.mjs.json | 13 +++++ ts/typings-check.mts | 105 ++++++++++++++++++++++++++++++++++++ 10 files changed, 318 insertions(+), 17 deletions(-) create mode 100644 example/client/esm.mjs create mode 100644 index.mjs create mode 100644 test/esm.mjs create mode 100644 ts/index.d.mts create mode 100644 ts/tsconfig.mjs.json create mode 100644 ts/typings-check.mts diff --git a/.github/workflows/types.yml b/.github/workflows/types.yml index a7648bc..3138a54 100644 --- a/.github/workflows/types.yml +++ b/.github/workflows/types.yml @@ -4,10 +4,10 @@ on: push: branches: [master, main] paths: - - 'ts/index.d.ts' + - 'ts/**' - 'index.js' - - 'ts/typings-check.ts' - - 'ts/tsconfig.json' + - 'index.mjs' + - 'package.json' - '.github/workflows/types.yml' pull_request: types: [opened, synchronize, reopened] @@ -25,5 +25,7 @@ jobs: node-version: 24 - name: Install type-check dependencies run: npm install --no-save typescript @types/node - - name: Check index.d.ts + - name: Check CJS types (index.d.ts via require) run: npx tsc --project ts/tsconfig.json + - name: Check ESM types (index.d.mts via import) + run: npx tsc --project ts/tsconfig.mjs.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 026ae10..cd02f0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Unreleased +- feat(ESM): dual published with ESM support - feat(packet): encode name compression pointers (RFC 1035 §4.1.4) - feat(server/udp): negotiated UDP payload size with TC=1 on oversize - feat(server/tcp): pipeline support (RFC 7766 §6.2.1.1) @@ -15,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - fix(server/doh): DoH POST requires Content-Type: application/dns-message - feat(server/doh): DoH responses include TTL-derived Cache-Control - fix(packet): Packet.Header.toBuffer writes Z=0 (RFC 1035 §4.1.1) +- style(prettier): update to prettier #129 ### [2.3.0] - 2026-05-25 diff --git a/eslint.config.mjs b/eslint.config.mjs index f3e4b70..6397ff4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,17 @@ import js from '@eslint/js'; import globals from 'globals'; +const nodeRules = { + 'no-unused-vars': [ + 'error', + { + args: 'none', + caughtErrors: 'none', + ignoreRestSiblings: true, + }, + ], +}; + export default [ js.configs.recommended, { @@ -8,19 +19,17 @@ export default [ languageOptions: { ecmaVersion: 'latest', sourceType: 'commonjs', - globals: { - ...globals.node, - }, + globals: { ...globals.node }, }, - rules: { - 'no-unused-vars': [ - 'error', - { - args: 'none', - caughtErrors: 'none', - ignoreRestSiblings: true, - }, - ], + rules: nodeRules, + }, + { + files: ['**/*.mjs'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: { ...globals.node }, }, + rules: nodeRules, }, ]; diff --git a/example/client/esm.mjs b/example/client/esm.mjs new file mode 100644 index 0000000..101471b --- /dev/null +++ b/example/client/esm.mjs @@ -0,0 +1,5 @@ +import { DOHClient } from '../../index.mjs'; + +const resolve = DOHClient({ dns: 'https://cloudflare-dns.com/dns-query' }); +const reply = await resolve('example.com', 'A'); +console.log(reply.answers); diff --git a/index.mjs b/index.mjs new file mode 100644 index 0000000..c8ec46c --- /dev/null +++ b/index.mjs @@ -0,0 +1,18 @@ +import dns from './index.js'; + +export default dns; + +export const { + TCPServer, + UDPServer, + DOHServer, + createUDPServer, + createTCPServer, + createDOHServer, + createServer, + TCPClient, + DOHClient, + UDPClient, + GoogleClient, + Packet, +} = dns; diff --git a/package.json b/package.json index 4c90163..25bfd7e 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,28 @@ "description": "A DNS Server and Client Implementation in Pure JavaScript with no dependencies.", "main": "index.js", "types": "ts/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./ts/index.d.mts", + "default": "./index.mjs" + }, + "require": { + "types": "./ts/index.d.ts", + "default": "./index.js" + } + }, + "./package.json": "./package.json" + }, "files": [ "lib", "client", "server", "packet.js", "example", - "ts" + "index.mjs", + "ts/index.d.ts", + "ts/index.d.mts" ], "scripts": { "lint": "npx eslint .", @@ -19,6 +34,7 @@ "prettier:fix": "npx prettier . --write --log-level=warn", "test": "node --test", "test:coverage": "node --test --experimental-test-coverage", + "typecheck": "npx --package=typescript --package=@types/node -- tsc -p ts/tsconfig.json && npx --package=typescript --package=@types/node -- tsc -p ts/tsconfig.mjs.json", "test:coverage:lcov": "mkdir -p coverage && node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info", "example-server-udp": "node example/server/udp.js", "example-server-tcp": "node example/server/tcp.js", diff --git a/test/esm.mjs b/test/esm.mjs new file mode 100644 index 0000000..8a2c743 --- /dev/null +++ b/test/esm.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert'; +import { test } from 'node:test'; +import dns, { + Packet, + TCPClient, + UDPClient, + DOHClient, + GoogleClient, + TCPServer, + UDPServer, + DOHServer, + createServer, + createUDPServer, + createTCPServer, + createDOHServer, +} from '../index.mjs'; + +test('esm: default export is the DNS class', () => { + assert.equal(typeof dns, 'function'); + assert.equal(dns.name, 'DNS'); +}); + +test('esm: named exports mirror DNS.* statics', () => { + for (const [name, value] of Object.entries({ + Packet, + TCPClient, + UDPClient, + DOHClient, + GoogleClient, + TCPServer, + UDPServer, + DOHServer, + createServer, + createUDPServer, + createTCPServer, + createDOHServer, + })) { + assert.strictEqual( + value, + dns[name], + `${name} should be the same reference`, + ); + } +}); + +test('esm: Packet round-trips a request', () => { + const pkt = new Packet(); + pkt.header.id = 0x2026; + pkt.questions.push({ + name: 'esm.example', + type: Packet.TYPE.A, + class: Packet.CLASS.IN, + }); + const parsed = Packet.parse(pkt.toBuffer()); + assert.equal(parsed.header.id, 0x2026); + assert.equal(parsed.questions[0].name, 'esm.example'); +}); + +test('esm: UDP server + client end-to-end', async () => { + const server = createUDPServer(); + server.on('request', (request, send) => { + const response = Packet.createResponseFromRequest(request); + response.answers.push({ + name: request.questions[0].name, + type: Packet.TYPE.A, + class: Packet.CLASS.IN, + ttl: 60, + address: '198.51.100.123', + }); + send(response); + }); + await server.listen(0, '127.0.0.1'); + const { port } = server.address(); + const query = UDPClient({ dns: '127.0.0.1', port }); + const reply = await query('esm-client.test'); + assert.equal(reply.answers[0].address, '198.51.100.123'); + await new Promise(resolve => server.close(resolve)); +}); diff --git a/ts/index.d.mts b/ts/index.d.mts new file mode 100644 index 0000000..3380a87 --- /dev/null +++ b/ts/index.d.mts @@ -0,0 +1,53 @@ +// ESM-shaped types for the index.mjs wrapper. The runtime surface and the CJS +// types both live in their own files; this re-exports those types so that +// `import dns, { Packet, createServer } from 'dns2'` is fully typed under +// `moduleResolution: node16 | nodenext | bundler`. + +import DNS from './index.js'; + +export default DNS; + +// ── Value re-exports (mirror index.mjs's destructured exports) ─────────────── + +export const Packet: typeof DNS.Packet; +export const TCPClient: typeof DNS.TCPClient; +export const UDPClient: typeof DNS.UDPClient; +export const DOHClient: typeof DNS.DOHClient; +export const GoogleClient: typeof DNS.GoogleClient; +export const TCPServer: typeof DNS.TCPServer; +export const UDPServer: typeof DNS.UDPServer; +export const DOHServer: typeof DNS.DOHServer; +export const createServer: typeof DNS.createServer; +export const createUDPServer: typeof DNS.createUDPServer; +export const createTCPServer: typeof DNS.createTCPServer; +export const createDOHServer: typeof DNS.createDOHServer; + +// ── Type re-exports (for `import type { ... } from 'dns2'`) ────────────────── + +export type Packet = DNS.Packet; +export type TCPServer = DNS.TCPServer; +export type UDPServer = DNS.UDPServer; +export type DOHServer = DNS.DOHServer; +export type DnsServer = DNS.DnsServer; + +export type DnsHandler = DNS.DnsHandler; +export type DnsResolver = DNS.DnsResolver; + +export type ClientOptions = DNS.ClientOptions; +export type ResolveOptions = DNS.ResolveOptions; +export type UdpClientOptions = DNS.UdpClientOptions; +export type TcpClientOptions = DNS.TcpClientOptions; +export type DohClientOptions = DNS.DohClientOptions; +export type UdpServerOptions = DNS.UdpServerOptions; +export type DohServerOptions = DNS.DohServerOptions; +export type CreateServerOptions = DNS.CreateServerOptions; +export type ServerAddresses = DNS.ServerAddresses; +export type DnsServerListenOptions = DNS.DnsServerListenOptions; +export type ListenOptions = DNS.ListenOptions; + +// Sub-types nested under Packet for callers that prefer the flat namespace. +export type Header = DNS.Packet.Header; +export type Question = DNS.Packet.Question; +export type Resource = DNS.Packet.Resource; +export type Reader = DNS.Packet.Reader; +export type Writer = DNS.Packet.Writer; diff --git a/ts/tsconfig.mjs.json b/ts/tsconfig.mjs.json new file mode 100644 index 0000000..af03c5b --- /dev/null +++ b/ts/tsconfig.mjs.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "typeRoots": ["../node_modules/@types"] + }, + "include": ["typings-check.mts"] +} diff --git a/ts/typings-check.mts b/ts/typings-check.mts new file mode 100644 index 0000000..b6cf3dd --- /dev/null +++ b/ts/typings-check.mts @@ -0,0 +1,105 @@ +/** + * Type-check smoke test for index.d.mts. + * + * Mirrors typings-check.ts but exercises the ESM type surface — both the + * default export and the named exports re-emitted by index.mjs. CI runs + * `tsc --project ts/tsconfig.mjs.json` and fails if any of these break. + * + * Add a line here whenever a new named export is added to index.mjs. + */ + +import type { AddressInfo } from 'node:net'; + +// Default import: the DNS class with all of its statics. +import DNS from './index.mjs'; + +// Named imports: must resolve to the same values DNS.X resolves to. +import { + Packet, + TCPClient, + UDPClient, + DOHClient, + GoogleClient, + TCPServer, + UDPServer, + DOHServer, + createServer, + createUDPServer, + createTCPServer, + createDOHServer, +} from './index.mjs'; + +// Named type-only imports: the flat namespace some ESM consumers prefer. +import type { + Header, + Question, + Resource, + DnsHandler, + DnsResolver, + ServerAddresses, + CreateServerOptions, +} from './index.mjs'; + +// ── Default-imported DNS instance ──────────────────────────────────────────── + +const dns = new DNS({ nameServers: ['8.8.8.8'], port: 53, recursive: true }); +void dns.resolveA('example.com'); +void dns.resolveMX('example.com'); + +// ── Named factories / constructors ─────────────────────────────────────────── + +const udpClient: DnsResolver = UDPClient({ dns: '8.8.8.8', port: 53 }); +const tcpClient: DnsResolver = TCPClient({ dns: '8.8.8.8' }); +const dohClient: DnsResolver = DOHClient({ dns: 'https://cloudflare-dns.com/dns-query' }); +const googleClient: DnsResolver = GoogleClient(); +void udpClient('example.com', 'A'); +void tcpClient('example.com', 'MX'); +void dohClient('example.com', 'AAAA'); +void googleClient('example.com'); + +const udpServer: UDPServer = createUDPServer({ type: 'udp4' }); +const tcpServer: TCPServer = createTCPServer(); +const dohServer: DOHServer = createDOHServer({ ssl: false, cors: true }); + +const handler: DnsHandler = (req, send) => { + void send(Packet.createResponseFromRequest(req)); +}; +udpServer.on('request', handler); +tcpServer.on('request', handler); +dohServer.on('request', handler); + +// ── Multi-server via createServer ──────────────────────────────────────────── + +const opts: CreateServerOptions = { + udp: true, + tcp: true, + doh: { ssl: false }, + maxConcurrent: 100, + handle: handler, +}; +const server = createServer(opts); +server.listen({ udp: { port: 53 }, tcp: 5353 }).then((addrs: ServerAddresses) => { + const _udp: AddressInfo | undefined = addrs.udp; +}); +void server.close(); + +// ── Packet construction via the named import ───────────────────────────────── + +const pkt = new Packet(); +pkt.header.id = 0xabcd; +pkt.questions.push(new Packet.Question('esm.test', Packet.TYPE.A, Packet.CLASS.IN)); +const buf: Buffer = pkt.toBuffer(); +const parsed: Packet = Packet.parse(buf); + +const hdr: Header = parsed.header; +const q: Question = parsed.questions[0]; +const ans: Resource | undefined = parsed.answers[0]; +void hdr.id; +void q.name; +void ans?.address; + +// ── Cross-check: named export ≡ DNS.X ──────────────────────────────────────── + +const _packetSame: typeof DNS.Packet = Packet; +const _createServerSame: typeof DNS.createServer = createServer; +const _tcpClientSame: typeof DNS.TCPClient = TCPClient;