@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
JavaScript
;
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