UNPKG

@phonecheck/phone-number-validator-js

Version:

Validate, parse, and enrich international phone numbers — geocoding, carrier lookup, and timezone resolution. Sync (Node) + async (serverless) APIs, platform adapters, and a CLI.

532 lines (508 loc) 18.7 kB
#!/usr/bin/env node 'use strict'; var bson = require('bson'); var tinyLru = require('tiny-lru'); var node_fs = require('node:fs'); var node_path = require('node:path'); var node_url = require('node:url'); var libphonenumberJs = require('libphonenumber-js'); var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null; const DEFAULT_CACHE_SIZE = 100; let cache = tinyLru.lru(DEFAULT_CACHE_SIZE); function cacheGet(key) { return cache.get(key); } function cacheSet(key, value) { cache.set(key, value); } const DEFAULT_LOCALE = "en"; const TIMEZONES_PATH = "timezones.bson"; let activeLoader = null; function setResourceLoader(loader) { activeLoader = loader; } function findByPrefix(table, key) { for (let prefix = key; prefix.length > 0; prefix = prefix.slice(0, -1)) { const value = table[prefix]; if (typeof value === "string" && value.length > 0) return value; } return null; } function decodeBson(bytes) { if (!bytes || bytes.byteLength === 0) return null; const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes); return bson.deserialize(buf); } function logLoadError(path, err) { if (process.env.NODE_ENV !== "production") { console.error(`Error loading data from ${path}:`, err); } } function rememberTable(path, bytes) { const table = decodeBson(bytes); if (!table) return null; cacheSet(path, table); return table; } async function loadTableAsync(path) { if (!activeLoader) return null; const cached = cacheGet(path); if (cached) return cached; try { return rememberTable(path, await activeLoader.loadResource(path)); } catch (err) { logLoadError(path, err); return null; } } function localeResourcePath(kind, locale, countryCode) { return `${kind}/${locale}/${countryCode}.bson`; } function localePaths(kind, locale, cc) { if (locale === DEFAULT_LOCALE) return [localeResourcePath(kind, DEFAULT_LOCALE, cc)]; return [localeResourcePath(kind, locale, cc), localeResourcePath(kind, DEFAULT_LOCALE, cc)]; } function partsOf(phoneNumber) { const national = phoneNumber?.nationalNumber?.toString(); const cc = phoneNumber?.countryCallingCode?.toString(); if (!national || !cc) return null; return { national, cc }; } function timezoneKey(phoneNumber) { const raw = phoneNumber?.number?.toString(); if (!raw) return null; return raw.startsWith("+") ? raw.slice(1) : raw; } function parseTimezones(value) { if (!value) return null; const zones = value.split("&").filter((z) => z.length > 0); return zones.length > 0 ? zones : null; } async function localizedAsync(kind, phoneNumber, locale) { const parts = partsOf(phoneNumber); if (!parts) return null; for (const path of localePaths(kind, locale, parts.cc)) { const table = await loadTableAsync(path); const hit = table ? findByPrefix(table, parts.national) : null; if (hit) return hit; } return null; } async function geocoderAsync(phoneNumber, locale = DEFAULT_LOCALE) { return localizedAsync("geocodes", phoneNumber, locale); } async function carrierAsync(phoneNumber, locale = DEFAULT_LOCALE) { return localizedAsync("carrier", phoneNumber, locale); } async function timezonesAsync(phoneNumber) { const key = timezoneKey(phoneNumber); if (!key) return null; const table = await loadTableAsync(TIMEZONES_PATH); return table ? parseTimezones(findByPrefix(table, key)) : null; } function defaultResourcesDir() { let here; try { here = __dirname; } catch { here = node_path.dirname(node_url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.js', document.baseURI).href)))); } let dir = here; for (let i = 0; i < 6; i++) { const candidate = node_path.resolve(dir, "resources"); if (node_fs.existsSync(node_path.resolve(candidate, "timezones.bson"))) { return candidate; } const parent = node_path.resolve(dir, ".."); if (parent === dir) break; dir = parent; } return node_path.resolve(here, "..", "resources"); } class NodeFsResourceLoader { constructor(options = {}) { this.baseDir = options.resourcesDir ?? defaultResourcesDir(); } async loadResource(path) { return this.loadResourceSync(path); } loadResourceSync(path) { const fullPath = node_path.join(this.baseDir, path); if (!node_fs.existsSync(fullPath)) return null; return node_fs.readFileSync(fullPath); } } function resolveOptions(options) { return { defaultCountry: options.defaultCountry, locale: options.locale ?? "en", carrierLocale: options.carrierLocale ?? "en", // Each enrichment toggle defaults to ON when unset — opt-out, not opt-in. geocode: options.geocode ?? true, carrier: options.carrier ?? true, timezones: options.timezones ?? true }; } async function validateOne(input, options) { const parsed = libphonenumberJs.parsePhoneNumberFromString(input, options.defaultCountry); if (!parsed?.isValid()) { return { input, valid: false, error: "Invalid or unparseable phone number" }; } const [geocode, carrier, timezones] = await Promise.all([ options.geocode ? geocoderAsync(parsed, options.locale) : Promise.resolve(void 0), options.carrier ? carrierAsync(parsed, options.carrierLocale) : Promise.resolve(void 0), options.timezones ? timezonesAsync(parsed) : Promise.resolve(void 0) ]); const result = { input, valid: true, formatted: { e164: parsed.format("E.164"), international: parsed.formatInternational(), national: parsed.formatNational(), rfc3966: parsed.format("RFC3966") }, country: parsed.country, countryCallingCode: parsed.countryCallingCode.toString(), nationalNumber: parsed.nationalNumber.toString(), type: parsed.getType() }; if (geocode !== void 0) result.geocode = geocode; if (carrier !== void 0) result.carrier = carrier; if (timezones !== void 0) result.timezones = timezones; return result; } async function validateSingle(input, options = {}) { return validateOne(input, resolveOptions(options)); } setResourceLoader(new NodeFsResourceLoader()); const HELP_TEXT = `phone-validate <phone-number> [options] Parse and enrich an international phone number \u2014 geocoding (city / region), original carrier, timezone(s), formatted variants, and number type. By default, runs the full enrichment pipeline, prints a colored summary to stdout, and writes the JSON result to ./logs/. Options: --country <CC> Default country (e.g. US, DE) when the input lacks a leading +. Pass-through to libphonenumber. --locale <code> Geocoder locale: en, de, fr, es, ... (default: en) --carrier-locale <code> Carrier locale: en, ar, zh, ... (default: en) --geocode, --no-geocode Look up city / region (default: on) --carrier-info, --no-carrier-info Look up original carrier (default: on) --timezones, --no-timezones Look up IANA timezone(s) (default: on) --enrich, --no-enrich Master toggle for all three above (default: on) --format <text|json|pretty> Stdout format (default: pretty) --log-dir <path> Directory to write the JSON result (default: ./logs) --no-log-file Skip writing the result file --quiet Print only the final verdict to stdout --debug Verbose logging -h, --help Show this help -v, --version Print version Examples: # Parse + enrich an E.164 number phone-validate +14155552671 # Parse a national number with a country fallback phone-validate "(415) 555-2671" --country US # German locale for the geocoder phone-validate +41431234567 --locale de # Format-only validation (skips BSON loads \u2014 fastest) phone-validate +14155552671 --no-enrich # Just timezone, skip geocode + carrier phone-validate +14155552671 --no-geocode --no-carrier-info # Pipe JSON to jq for tooling phone-validate +14155552671 --format json --quiet --no-log-file | jq # Silent verdict for shell scripting (exit code 0=valid, 1=invalid) phone-validate "+14155552671" --quiet --no-log-file if phone-validate "$NUMBER" --quiet --no-log-file > /dev/null; then \u2026 `; const BOOLEAN_FLAGS = { enrich: (s, on) => { s.result.enrichGeocode = on; s.result.enrichCarrier = on; s.result.enrichTimezones = on; }, geocode: (s, on) => { s.result.enrichGeocode = on; }, "carrier-info": (s, on) => { s.result.enrichCarrier = on; }, timezones: (s, on) => { s.result.enrichTimezones = on; }, "log-file": (s, on) => { s.logFile = on; }, quiet: (s, on) => { s.result.quiet = on; }, debug: (s, on) => { s.result.debug = on; } }; const FORMAT_VALUES = /* @__PURE__ */ new Set(["text", "json", "pretty"]); const VALUE_FLAGS = { country: (s, value) => { s.result.defaultCountry = value.toUpperCase(); }, locale: (s, value) => { s.result.locale = value; }, "carrier-locale": (s, value) => { s.result.carrierLocale = value; }, format: (s, value, errors) => { if (!FORMAT_VALUES.has(value)) { errors.push(`--format must be one of text|json|pretty (got "${value}")`); return; } s.result.format = value; }, "log-dir": (s, value) => { s.result.logDir = value; } }; function tokenize(token) { if (!token.startsWith("--")) return null; const eqIdx = token.indexOf("="); const rawName = eqIdx === -1 ? token.slice(2) : token.slice(2, eqIdx); const inlineValue = eqIdx === -1 ? void 0 : token.slice(eqIdx + 1); if (rawName.startsWith("no-")) return { name: rawName.slice(3), positive: false, inlineValue }; return { name: rawName, positive: true, inlineValue }; } function defaultArgs() { return { phoneNumber: "", locale: "en", carrierLocale: "en", enrichGeocode: true, enrichCarrier: true, enrichTimezones: true, format: "pretty", quiet: false, debug: false, logDir: "./logs" }; } function parseArgs(argv) { const errors = []; const positional = []; const state = { result: defaultArgs(), logFile: true }; for (let i = 0; i < argv.length; i++) { const token = argv[i]; if (token === void 0) continue; if (!token.startsWith("-")) { positional.push(token); continue; } if (token === "-h" || token === "--help") return { kind: "help" }; if (token === "-v" || token === "--version") return { kind: "version" }; const flag = tokenize(token); if (!flag) { errors.push(`Unknown short flag: "${token}"`); continue; } const boolApply = BOOLEAN_FLAGS[flag.name]; if (boolApply) { boolApply(state, flag.positive); continue; } const valueApply = VALUE_FLAGS[flag.name]; if (valueApply && flag.positive) { const value = flag.inlineValue ?? argv[++i]; if (value === void 0) { errors.push(`Flag --${flag.name} requires a value`); continue; } valueApply(state, value, errors); continue; } errors.push(`Unknown flag: "${token}"`); } if (!state.logFile) state.result.logDir = null; if (positional.length === 0) errors.push("Missing required argument: <phone-number>"); if (positional.length > 1) { errors.push(`Expected one phone number, got ${positional.length}: ${positional.join(", ")}`); } if (errors.length > 0) return { kind: "error", messages: errors, exitCode: 2 }; const [phoneNumber] = positional; if (!phoneNumber) return { kind: "error", messages: ["Missing required argument: <phone-number>"], exitCode: 2 }; state.result.phoneNumber = phoneNumber; return { kind: "args", ...state.result }; } function helpText() { return HELP_TEXT; } function colorize() { const enabled = process.stdout.isTTY && process.env.NO_COLOR !== "1"; const wrap = (open, close) => (s) => enabled ? `\x1B[${open}m${s}\x1B[${close}m` : s; return { green: wrap("32", "39"), red: wrap("31", "39"), yellow: wrap("33", "39"), cyan: wrap("36", "39"), dim: wrap("2", "22"), bold: wrap("1", "22") }; } function verdictLine(result) { const c = colorize(); if (!result.valid) return c.red(`\u2717 INVALID ${result.input}`); return c.green(`\u2713 VALID ${result.formatted?.e164 ?? result.input}`); } function formatJson(result) { return JSON.stringify(result); } function formatText(result) { const lines = []; lines.push(verdictLine(result)); if (!result.valid) { if (result.error) lines.push(` error=${result.error}`); return lines.join("\n"); } lines.push(` country=${result.country ?? "_"} type=${result.type ?? "_"}`); lines.push(` e164=${result.formatted?.e164}`); lines.push(` national=${result.formatted?.national}`); lines.push(` international=${result.formatted?.international}`); if (result.geocode) lines.push(` geocode=${result.geocode}`); if (result.carrier) lines.push(` carrier=${result.carrier}`); if (result.timezones?.length) lines.push(` timezones=${result.timezones.join(", ")}`); return lines.join("\n"); } function formatPretty(result) { const c = colorize(); const lines = []; lines.push(verdictLine(result)); lines.push(""); if (!result.valid) { if (result.error) lines.push(` ${c.dim("error:")} ${c.red(result.error)}`); return lines.join("\n"); } lines.push(c.bold("Summary")); lines.push( ` ${c.dim("country:")} ${c.cyan(result.country ?? "\u2014")} ${c.dim(`(+${result.countryCallingCode})`)}` ); lines.push(` ${c.dim("type:")} ${result.type ?? "\u2014"}`); lines.push(""); lines.push(c.bold("Formatted")); lines.push(` ${c.dim("E.164:")} ${result.formatted?.e164}`); lines.push(` ${c.dim("national:")} ${result.formatted?.national}`); lines.push(` ${c.dim("international:")} ${result.formatted?.international}`); lines.push(` ${c.dim("RFC3966:")} ${result.formatted?.rfc3966}`); if (result.geocode || result.carrier || result.timezones?.length) { lines.push(""); lines.push(c.bold("Enrichment")); if (result.geocode) lines.push(` ${c.dim("geocode:")} ${result.geocode}`); if (result.carrier) lines.push(` ${c.dim("carrier:")} ${result.carrier}`); if (result.timezones?.length) { lines.push(` ${c.dim("timezones:")} ${result.timezones.join(", ")}`); } } return lines.join("\n"); } const FORMATTERS = { json: formatJson, text: formatText, pretty: formatPretty }; function pad(n) { return n < 10 ? `0${n}` : String(n); } function logFileNameFor(phoneNumber, when) { const safe = phoneNumber.replace(/[^a-zA-Z0-9._+-]+/g, "_"); const stamp = `${when.getUTCFullYear()}-${pad(when.getUTCMonth() + 1)}-${pad(when.getUTCDate())}T${pad(when.getUTCHours())}${pad(when.getUTCMinutes())}${pad(when.getUTCSeconds())}Z`; return `phone-validate-${stamp}-${safe}.json`; } function debugLine(args) { return `Validating "${args.phoneNumber}" (country=${args.defaultCountry ?? "auto"}, locale=${args.locale}, carrier=${args.carrierLocale}, geocode=${args.enrichGeocode}, carrier-info=${args.enrichCarrier}, timezones=${args.enrichTimezones})`; } function renderOutput(args, result) { if (args.quiet) return verdictLine(result); return FORMATTERS[args.format](result); } function persistLog(logDir, phoneNumber, result, deps) { try { const dir = node_path.resolve(logDir); deps.ensureDir(dir); const path = node_path.resolve(dir, logFileNameFor(phoneNumber, deps.now())); deps.writeFile(path, JSON.stringify(result, null, 2)); return { ok: true, path }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; } } async function run(args, deps = {}) { const validate = deps.validate ?? validateSingle; const stdout = deps.stdout ?? ((line) => process.stdout.write(`${line} `)); const stderr = deps.stderr ?? ((line) => process.stderr.write(`${line} `)); const fsDeps = { writeFile: deps.writeFile ?? ((path, contents) => node_fs.writeFileSync(path, contents, "utf8")), ensureDir: deps.ensureDir ?? ((path) => node_fs.mkdirSync(path, { recursive: true })), now: deps.now ?? (() => /* @__PURE__ */ new Date()) }; if (args.debug) stderr(debugLine(args)); const result = await validate(args.phoneNumber, { defaultCountry: args.defaultCountry, locale: args.locale, carrierLocale: args.carrierLocale, geocode: args.enrichGeocode, carrier: args.enrichCarrier, timezones: args.enrichTimezones }); stdout(renderOutput(args, result)); if (args.logDir) { const log = persistLog(args.logDir, args.phoneNumber, result, fsDeps); if (!log.ok) stderr(`Warning: failed to write log file: ${log.error}`); else if (!args.quiet) stderr(`Log written: ${log.path}`); } return exitCodeFor(result); } function exitCodeFor(result) { return result.valid ? 0 : 1; } async function main(argv = process.argv.slice(2)) { const parsed = parseArgs(argv); if (parsed.kind === "help") { process.stdout.write(`${helpText()} `); return 0; } if (parsed.kind === "version") { try { const pkg = require("../../package.json"); process.stdout.write(`${pkg.version} `); } catch { process.stdout.write("unknown\n"); } return 0; } if (parsed.kind === "error") { for (const msg of parsed.messages) process.stderr.write(`${msg} `); process.stderr.write(` Run with --help for usage. `); return parsed.exitCode; } return run(parsed); } const isDirectInvocation = typeof ({ url: (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.js', document.baseURI).href)) }) !== "undefined" && undefined === true || typeof require !== "undefined" && require.main === module; if (isDirectInvocation) { main().then((code) => process.exit(code)).catch((error) => { process.stderr.write( `Unexpected error: ${error instanceof Error ? error.stack ?? error.message : String(error)} ` ); process.exit(1); }); } exports.exitCodeFor = exitCodeFor; exports.helpText = helpText; exports.logFileNameFor = logFileNameFor; exports.main = main; exports.parseArgs = parseArgs; exports.run = run; //# sourceMappingURL=index.js.map