zynor
v0.1.9
Published
Multi-provider DNS resolution and email intelligence across JavaScript runtimes
Downloads
388
Maintainers
Readme
Zynor
One promise-based DNS API that behaves the same way on every JavaScript runtime your team deploys to — Node.js, Bun, Deno, Cloudflare Workers, and the browser.
Zynor resolves DNS through platform DNS or three encrypted public resolvers, with explicit routing, bounded concurrency, caching, and automatic failover. It also classifies email domains — provider identity, disposable and role addresses, MX-backed deliverability signals, and bulk or streaming validation — on servers, at the edge, and in the browser.
Email validation is classification, not proof. A valid result does not mean a mailbox exists, accepts mail, or belongs to a person. Zynor does not perform SMTP recipient verification.
Install
npm install zynorRequires Node.js 22.15 or later. Bun and Deno are supported directly; browser and Worker builds are selected automatically through conditional exports.
Quick start
Resolve DNS with an explicit egress policy:
import { Zynor } from "zynor";
const dns = new Zynor({
native: { enabled: true },
google: { enabled: true },
cloudflare: { enabled: false },
quad9: { enabled: false },
});
const mx = await dns.resolveMx("example.com", { timeout: 3_000 }, "google");Classify an email domain:
import { EmailValidator } from "zynor";
const validator = new EmailValidator({
dns: { native: { enabled: true }, google: { enabled: true } },
});
const result = await validator.validate("[email protected]");
if (result.success) {
console.log(result.data.provider); // "Gmail"
console.log(result.data.loginUrl); // "https://mail.google.com"
}Standalone helpers and CommonJS work too:
import { resolve4 } from "zynor";
const addresses = await resolve4("example.com", { timeout: 3_000 }, "native");const { resolve4, Zynor } = require("zynor");Runtime support
| Runtime | DNS providers | Email | zynor/native |
| --- | --- | --- | --- |
| Node.js, Bun, Deno | All four | Full API | Available with a matching addon and runtime permissions |
| Cloudflare Workers | All four | Full API | Unavailable |
| Browser page or Worker | google, cloudflare, quad9 | Core and MX-backed, memory-only | Unavailable |
Cloudflare Workers get the complete API through the ordinary zynor import,
including the provider named native; only the separately packaged
zynor/native entry is unavailable there.
Browsers get DNS over the three DoH providers and the same email API, with two
limits: the provider named native is unavailable, and caches are memory-only.
Deep HTML and IP enrichment is also constrained there — see
Browser limits.
Provider names — native, google, cloudflare, quad9 — and public method
signatures are compatibility invariants. New capabilities may be added; existing
ones are never renamed or reshaped.
Distribution is ESM everywhere, with CommonJS additionally on Node.js and Bun. Type declarations ship for both module systems.
DNS
Standalone functions
The record-specific helpers take a hostname, optional per-call options, and an
optional provider name. When you only need to pin a provider, pass its name as
the second argument. Generic resolve takes (hostname, rrtype?, provider?)
with no options object, and reverse takes an IP address.
import { resolve4, resolveMx, resolveTxt, reverse } from "zynor";
await resolve4("example.com");
await resolveMx("example.com", { timeout: 5_000 });
await resolveTxt("example.com", "cloudflare");
await reverse("8.8.8.8");Available: resolve, resolve4, resolve6, resolveAny, resolveCaa,
resolveCname, resolveMx, resolveNaptr, resolveNs, resolvePtr,
resolveSoa, resolveSrv, resolveTlsa, resolveTxt, and reverse.
Standalone helpers share the loaded entry module's default resolver. Use
setDefaultResolver to supply your own instance, or resetDefaultResolver to
restore the default. The ESM and CommonJS builds are separate modules and keep
separate defaults, as do zynor and zynor/native.
Zynor class
const dns = new Zynor({
native: { enabled: true },
google: { enabled: true },
cloudflare: { enabled: true },
quad9: { enabled: false },
cache: { enabled: true, maxSize: 100_000, dnsTtl: 1_440_000 },
});
await dns.resolve("example.com", "MX");
await dns.resolve4("example.com", { ttl: true });cache.maxSize: 0 disables the resolver cache. The exported LruCache class
requires a positive capacity when constructed directly.
Record types
A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SOA, SRV, TLSA,
TXT, and ANY, plus reverse lookup.
TLSA is unavailable on the platform resolver in Bun and Deno; use a DoH provider for that record type there.
Provider selection
Pin a request to one provider, either positionally or through the options object. Without a provider, Zynor selects an enabled one and fails over on provider-health failures.
await dns.resolve4("example.com", {}, "quad9"); // positional
await dns.resolve4("example.com", { provider: "quad9" }); // options key
await dns.resolve4("example.com"); // automaticThe two forms are mutually exclusive — supplying both throws a TypeError
before any queue, cache, or network work. An own provider property counts as
a selection even when its value is undefined.
Pinning a disabled provider throws ProviderNotEnabledError; an unknown name
throws InvalidProviderError. Both forms produce the same errors.
Timeouts and cancellation
const controller = new AbortController();
await dns.resolve4("example.com", {
timeout: 2_000,
signal: controller.signal,
});timeout is one absolute deadline for the whole call, including time spent
queued and any failover attempt — a request that waits and then fails over does
not receive a fresh timeout.
Aborting rejects the caller promptly. A request still waiting in the queue is removed immediately; one whose transport has already started keeps its provider slot until that upstream operation settles, so replacement work cannot exceed your configured concurrency.
Caching and deduplication
Identical in-flight requests for the same hostname, record type, and provider
share a single upstream lookup. Calls that pass a timeout or signal are
deliberately excluded: they own their upstream operation, so one caller's
cancellation can never decide another caller's result. Results are cached per
provider under TTL and LRU bounds.
const dns = new Zynor({
cache: { enabled: true, maxSize: 50_000, dnsTtl: 300_000 },
});LruCache is exported for direct use:
import { LruCache } from "zynor";
const cache = new LruCache<string>(1_000); // max 1,000 entries
cache.set("key", "value", 60_000); // expires after 60 seconds
cache.get("key"); // => "value"Provider configuration
Concurrency and rate limits
const dns = new Zynor({
google: {
enabled: true,
limit: { concurrency: 10, interval: 1_000, intervalCap: 20 },
},
});Every path — public DNS calls, the validator's internal lookups, and native bulk MX warming — shares each provider's configured admission. These limits are an operational contract, not advisory hints.
Runtime updates
dns.setConfigs({ quad9: { enabled: true } });Configuration changes are validated as a whole and applied atomically. Callers already queued under a previous configuration settle exactly once.
Email validation
const validator = new EmailValidator({
dns: { native: { enabled: true } },
options: { enableDeepValidation: true },
});
const result = await validator.validate("[email protected]");A successful result carries provider, isFree, role, websiteTitle, and —
for mailbox providers — a loginUrl:
{ provider: "Gmail", isFree: true, email: "[email protected]",
role: true, websiteTitle: "Gmail", loginUrl: "https://mail.google.com" }Rejections return success: false with a type explaining why —
"Syntax" for malformed input, "Disposable" for throwaway domains, and
"Invalid" for domains that cannot receive mail.
Provider logos
Request a logo per call. It arrives as a data URL you can put straight into an
<img> tag — no asset hosting, no filesystem access, identical on every
runtime:
const result = await validator.validate("[email protected]", { logo: true });
result.data.logo; // "data:image/png;base64,iVBORw0KGgo..."Deep validation
Deep validation is opt-in twice: enableDeepValidation: true permits it, and
each call must request it — validate(email, true), or { deep: true } for
validateBulk and each.
When active it adds MX-backed checks and HTTPS probes of the domain, so it
makes outbound HTTP requests beyond DNS. Budget for that latency and egress.
detectMailProvider uses the same probe path independently of the constructor
flag.
It never performs SMTP recipient verification and never contacts a mail server over SMTP.
Bulk and streaming
const emails = ["[email protected]", "[email protected]"];
const results = await validator.validateBulk(emails);
await validator.each(
emails,
{ concurrency: 50, perPage: 100 },
async (batch) => { await persist(batch); },
);each streams results in pages and applies backpressure: when the in-memory
buffer fills, workers pause until it drains. Choose concurrency deliberately —
public resolvers are shared infrastructure, and bulk workloads should stay
within their acceptable-use expectations.
Detection helpers
validator.isEmail("[email protected]"); // true
validator.isDisposable("[email protected]"); // true
validator.isFreeDomain("gmail.com"); // true
validator.getProviderWebmailUrl("gmail.com"); // "https://mail.google.com"
validator.extractEmails("contact [email protected] or [email protected]");
validator.extractEmailsFromArray(["[email protected]", "[email protected]"]);
// Provider detection performs DNS lookups and returns a promise.
await validator.detectMailProvider("[email protected]");
await validator.detectHostingProvider("example.com");Detection distinguishes mailbox providers from inbound mail infrastructure —
security gateways, filtering services, and transactional senders. Infrastructure
results carry no webmail or loginUrl, because there is no human mailbox to
sign in to. Only mailbox providers return a login destination.
Persistent caching
Server runtimes persist validation results to a private temporary directory by default. Disable or relocate it:
new EmailValidator({
options: {
cache: {
persistence: false,
directory: "/var/lib/zynor",
maxEntries: 100_000,
maxFileBytes: 67_108_864,
},
},
});With persistence: false no files are created. Cloudflare Workers are always
memory-only.
Operational behavior
Network egress
Zynor makes outbound requests on exactly three paths:
- DNS — to the providers you enable, and nowhere else;
- Deep domain probes — HTTPS requests to the domain being validated, when
deep validation is active or
detectMailProvideris called; - IP intelligence — third-party services, only when you enable
ipResolver.
The activation fields accept booleans only — a string such as "false" is
rejected rather than treated as enabled.
In a browser, that egress originates from your visitor's browser and carries
their IP, not your server's. Enabling ipResolver on a page is a decision about
the people using it. Browser and edge email-enrichment requests are sent with
credentials: "omit", referrerPolicy: "no-referrer", and cache: "no-store".
Provider transports
| Provider | Transport |
| --- | --- |
| native | Platform DNS (node:dns on Node, Bun, and Deno; Cloudflare's node:dns compatibility layer in Workers) |
| google, cloudflare | DNS over HTTPS |
| quad9 | DNS over HTTPS, or DNS over TLS in Cloudflare Workers |
Quad9 does not support HTTP/1.1.
Node uses HTTP/2; Cloudflare Workers use DNS over TLS over
TCP sockets,
because Worker fetch cannot require HTTP/2 to an origin.
Failure handling
On automatic selection, provider-health failures fail over to another enabled provider within the caller's deadline. A pinned request does not fail over — you chose that provider, so its failure is returned to you.
Authoritative negative answers, caller cancellation, and HTTP 4xx responses are treated as query-local and returned without retrying elsewhere.
State and ownership
Each Zynor instance owns its queues and caches. Standalone helpers use the
default instance of whichever entry module is loaded — ESM and CommonJS keep
separate defaults, and so do zynor and zynor/native. Importing one never
affects the other, and an explicit zynor/native import never silently falls
back to the platform resolver.
Cloudflare Workers
Requires the nodejs_compat flag and a compatibility date of 2024-09-23 or
later, so Cloudflare's node:dns
implementation is available. Without the flag the module fails loudly rather
than silently dropping a provider. Bundlers must honour
conditional exports
so the workerd build is selected.
Worker platform limits apply: keep concurrency and cache sizes within your plan's memory and subrequest budgets.
Browser limits
The browser build carries the full email corpus so that isDisposable and
isFreeDomain stay exact and synchronous. That has a real transfer cost:
about 489 KB gzipped, against roughly 25 KB for DNS alone. Budget for it,
and prefer a server or Worker when only DNS is needed.
Caches are memory-only — no filesystem persistence exists in a browser. The
provider named native is unavailable.
Core classification and MX-backed validation work directly. Deep HTML and IP enrichment is not universally dependable from a page, because CORS, opaque redirects, and mixed-content rules govern those requests and a browser cannot pin a lookup to a socket. Treat deep enrichment as best-effort on the web, or route it through your own service.
Deno and native addons
zynor/native requires
native addon support
and the relevant runtime permissions. The ordinary zynor entry has no such
requirement.
Errors
| Error | Cause |
| --- | --- |
| InvalidHostnameError | Hostname is not a valid DNS owner name |
| InvalidProviderError | Unknown provider name |
| ProviderNotEnabledError | Pinned provider is disabled |
| NoEnabledProvidersError | No provider is enabled for the request |
import { InvalidHostnameError, ProviderNotEnabledError } from "zynor";
try {
await dns.resolve4(hostname);
} catch (error) {
if (error instanceof InvalidHostnameError) { /* reject the input */ }
if (error instanceof ProviderNotEnabledError) { /* adjust configuration */ }
}Diagnostics are bounded and redacted: upstream error text is truncated, and credential-shaped values and email local parts are removed before anything reaches logs, responses, or caches.
TypeScript
Zynor ships declarations for both ESM and CommonJS. Record shapes, provider names, record types, and configuration are fully typed.
import type { ProviderName, RecordType, MxRecord, ZynorConfig } from "zynor";Development
npm run verify # typecheck, lint, tests
npm run build # canonical package build
npm run pack:dry-run # packaging rehearsal, no publishnpm run build reads the published version from the npm registry and advances
the local version past it, so it needs network access. Set
ZYNOR_SKIP_VERSION_SYNC=1 to build offline; do not publish such a build.
Dataset and logo provenance, including unresolved redistribution questions, is documented in Data and Asset Provenance.
Support and security
Report issues through the repository tracker where available, or contact
[email protected]. Include runtime and version, entry point, provider
selection, platform, and a minimal reproduction — never API keys or user data.
Security reports follow SECURITY.md rather than a public issue.
License
Use, modification, and redistribution are governed by LICENSE. Confirm those terms before redistributing; package and source license labels are currently being reconciled with the governing text, and the asset rights gaps noted above remain open.
