iso-web
Version:
Isomorphic web apis utilities for fetch, event target, signals, crypto and doh.
225 lines (200 loc) • 5.07 kB
JavaScript
import { KV } from 'iso-kv'
// eslint-disable-next-line no-unused-vars
import { JsonError, request } from '../http.js'
const symbol = Symbol.for('doh-error')
/**
* @typedef {import('../http.js').Errors | DohError | JsonError} Errors
* @typedef {import('../http.js').Errors} RequestErrors
*/
export {
AbortError,
HttpError,
JsonError,
NetworkError,
RequestError,
TimeoutError,
} from '../http.js'
/**
* Check if a value is a DohError
*
* @param {unknown} value
* @returns {value is DohError}
*/
export function isDohError(value) {
return value instanceof Error && symbol in value
}
export class DohError extends Error {
/** @type {boolean} */
[symbol] = true
name = 'DohError'
/** @type {unknown} */
cause
/** @type {import('./types.js').DoHResponse} */
data
/**
*
* @param {string} message
* @param {ErrorOptions & {data: import('./types.js').DoHResponse}} options
*/
constructor(message, options) {
super(message, options)
this.cause = options.cause
this.data = options.data
}
/**
* Check if a value is a DohError
*
* @param {unknown} value
* @returns {value is DohError}
*/
static is(value) {
return isDohError(value) && value.name === 'DohError'
}
}
/**
* DoH Status to Description
*
* @param {number} status
*/
function statusToDescription(status) {
switch (status) {
case 1: {
return 'DNS query format error'
}
case 2: {
return 'Server failed to complete the DNS request'
}
case 3: {
return 'Domain name does not exist'
}
case 4: {
return 'Not implemented'
}
case 5: {
return 'Server refused to answer for the query'
}
case 6: {
return 'Name that should not exist, does exist'
}
case 7: {
return 'RRset that should not exist, does exist'
}
case 8: {
return 'RRset that should exist, does not exist'
}
case 9: {
return 'Server not authoritative for the zone'
}
case 10: {
return 'Name not in zone'
}
case 11: {
return 'DSO-TYPE Not Implemented'
}
case 16: {
return 'Bad OPT Version / TSIG Signature Failure'
}
case 17: {
return 'Key not recognized'
}
case 18: {
return 'Signature out of time window'
}
case 19: {
return 'Bad TKEY Mode'
}
case 20: {
return 'Duplicate key name'
}
case 21: {
return 'Algorithm not supported'
}
case 22: {
return 'Bad Truncation'
}
case 23: {
return 'Bad/missing Server Cookie'
}
default: {
if (typeof status === 'number') {
return 'Unassigned or reserved error code'
}
return 'DNS Status is not defined'
}
}
}
const kv = new KV()
/**
* Resolve a DNS query using DNS over HTTPS
*
* @see https://developers.google.com/speed/public-dns/docs/doh/json
* @see https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json/
*
* @template {string[]} [T=string[]]
*
* @param {string} query
* @param {import("./types.js").RecordType} type
* @param {import("./types.js").ResolveOptions} [options]
* @returns {Promise<import("../types.js").MaybeResult<T, Errors>>}
*/
export async function resolve(query, type, options = {}) {
const { cache = kv } = options
const {
server = 'https://cloudflare-dns.com/dns-query',
signal,
retry,
timeout,
} = options
const url = `${server}?name=${query}&type=${type}`
/** @type {import('../types.js').MaybeResult<T, Errors> | undefined} */
const cached = await cache.get([url])
if (cached) {
return cached
}
const { error, result: rawResult } = await request.json(new URL(url), {
signal,
headers: { accept: 'application/dns-json' },
retry,
timeout,
})
if (error) {
return {
error,
}
}
/** @type {import('./types.js').DoHResponse} */
const result = await rawResult
if (result.Status !== 0) {
const desc = statusToDescription(result.Status)
// eslint-disable-next-line no-nested-ternary
const error = Array.isArray(result.Comment)
? `${desc} - ${result.Comment.join(' ').trim()}`
: result.Comment
? `${desc} - ${result.Comment}`
: desc
const out = {
error: new DohError(error, { data: result }),
}
await cache.set([url], out, { ttl: 3600 }) // caches errors for 1 hour
return out
}
if (result.Answer) {
const data = /** @type {T} */ (
result.Answer.map((a) => a.data.replaceAll(/["']+/g, ''))
)
const ttl = Math.min(...result.Answer.map((a) => a.TTL))
const out = { result: data }
await cache.set([url], out, { ttl })
return out
}
if (result.Authority) {
const data = /** @type {T} */ (result.Authority.map((a) => a.data))
const ttl = Math.min(...result.Authority.map((a) => a.TTL))
const out = { result: data }
await cache.set([url], out, { ttl })
return out
}
return {
error: new DohError('No answer or authority', { data: result }),
}
}