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.

333 lines (325 loc) 10.8 kB
import { deserialize } from 'bson'; import { lru } from 'tiny-lru'; import { existsSync, readFileSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parsePhoneNumberFromString } from 'libphonenumber-js'; export * from 'libphonenumber-js'; const DEFAULT_CACHE_SIZE = 100; let cache = lru(DEFAULT_CACHE_SIZE); let maxSize = DEFAULT_CACHE_SIZE; function cacheGet(key) { return cache.get(key); } function cacheSet(key, value) { cache.set(key, value); } function clearCache() { cache.clear(); } function getCacheSize() { return cache.size; } function getCacheStats() { return { size: cache.size, maxSize }; } function setCacheSize(size) { if (size <= 0) { throw new Error(`Cache size must be > 0 (got ${size})`); } if (size === maxSize) return; const previous = cache; cache = lru(size); maxSize = size; const entries = [...previous.entries()].reverse(); for (const [key, value] of entries) { if (cache.size >= size) break; cache.set(key, value); } } const DEFAULT_LOCALE = "en"; const TIMEZONES_PATH = "timezones.bson"; let activeLoader = null; function setResourceLoader(loader) { activeLoader = loader; } function getResourceLoader() { return activeLoader; } 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 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 loadTableSync(path) { if (!(activeLoader === null || activeLoader === void 0 ? void 0 : activeLoader.loadResourceSync)) return null; const cached = cacheGet(path); if (cached) return cached; try { return rememberTable(path, activeLoader.loadResourceSync(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) { var _a, _b; const national = (_a = phoneNumber === null || phoneNumber === void 0 ? void 0 : phoneNumber.nationalNumber) === null || _a === void 0 ? void 0 : _a.toString(); const cc = (_b = phoneNumber === null || phoneNumber === void 0 ? void 0 : phoneNumber.countryCallingCode) === null || _b === void 0 ? void 0 : _b.toString(); if (!national || !cc) return null; return { national, cc }; } function timezoneKey(phoneNumber) { var _a; const raw = (_a = phoneNumber === null || phoneNumber === void 0 ? void 0 : phoneNumber.number) === null || _a === void 0 ? void 0 : _a.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; } function localizedSync(kind, phoneNumber, locale) { const parts = partsOf(phoneNumber); if (!parts) return null; for (const path of localePaths(kind, locale, parts.cc)) { const table = loadTableSync(path); const hit = table ? findByPrefix(table, parts.national) : null; if (hit) return hit; } return null; } function geocoder(phoneNumber, locale = DEFAULT_LOCALE) { return localizedSync("geocodes", phoneNumber, locale); } function carrier(phoneNumber, locale = DEFAULT_LOCALE) { return localizedSync("carrier", phoneNumber, locale); } function timezones(phoneNumber) { const key = timezoneKey(phoneNumber); if (!key) return null; const table = loadTableSync(TIMEZONES_PATH); return table ? parseTimezones(findByPrefix(table, key)) : 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; } async function enrichPhoneNumber(phoneNumber, options = {}) { const [geocode, car, tz] = await Promise.all([ geocoderAsync(phoneNumber, options.locale), carrierAsync(phoneNumber, options.carrierLocale), timezonesAsync(phoneNumber) ]); return { geocode, carrier: car, timezones: tz }; } function defaultResourcesDir() { let here; try { here = __dirname; } catch { here = dirname(fileURLToPath(import.meta.url)); } let dir = here; for (let i = 0; i < 6; i++) { const candidate = resolve(dir, "resources"); if (existsSync(resolve(candidate, "timezones.bson"))) { return candidate; } const parent = resolve(dir, ".."); if (parent === dir) break; dir = parent; } return resolve(here, "..", "resources"); } class NodeFsResourceLoader { constructor(options = {}) { var _a; this.baseDir = (_a = options.resourcesDir) !== null && _a !== void 0 ? _a : defaultResourcesDir(); } async loadResource(path) { return this.loadResourceSync(path); } loadResourceSync(path) { const fullPath = join(this.baseDir, path); if (!existsSync(fullPath)) return null; return readFileSync(fullPath); } } function resolveOptions(options) { var _a, _b, _c, _d, _e; return { defaultCountry: options.defaultCountry, locale: (_a = options.locale) !== null && _a !== void 0 ? _a : "en", carrierLocale: (_b = options.carrierLocale) !== null && _b !== void 0 ? _b : "en", // Each enrichment toggle defaults to ON when unset — opt-out, not opt-in. geocode: (_c = options.geocode) !== null && _c !== void 0 ? _c : true, carrier: (_d = options.carrier) !== null && _d !== void 0 ? _d : true, timezones: (_e = options.timezones) !== null && _e !== void 0 ? _e : true }; } async function validateOne(input, options) { const parsed = parsePhoneNumberFromString(input, options.defaultCountry); if (!(parsed === null || parsed === void 0 ? void 0 : 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 executeValidation(dispatch) { const opts = resolveOptions(dispatch.options); if (dispatch.kind === "single") { return validateOne(dispatch.phoneNumber, opts); } return Promise.all(dispatch.phoneNumbers.map((p) => validateOne(p, opts))); } async function validateSingle(input, options = {}) { return validateOne(input, resolveOptions(options)); } async function validateBatch(inputs, options = {}) { const opts = resolveOptions(options); return Promise.all(inputs.map((p) => validateOne(p, opts))); } const MAX_BATCH_SIZE = 100; function extractBatchOptions(body) { return { defaultCountry: body.defaultCountry, locale: body.locale, carrierLocale: body.carrierLocale, geocode: body.geocode, carrier: body.carrier, timezones: body.timezones }; } function validateBatchField(phoneNumbers) { if (!Array.isArray(phoneNumbers) || phoneNumbers.length === 0) { return { ok: false, status: 400, message: "phoneNumbers array is required" }; } if (phoneNumbers.length > MAX_BATCH_SIZE) { return { ok: false, status: 400, message: `Maximum ${MAX_BATCH_SIZE} phone numbers per batch` }; } return { ok: true, phoneNumbers }; } const MISSING_INPUT = { kind: "invalid", status: 400, message: "phoneNumber or phoneNumbers array is required" }; function classifyRequest(body) { if (!body) return MISSING_INPUT; const options = extractBatchOptions(body); if (body.phoneNumbers !== void 0) { const validated = validateBatchField(body.phoneNumbers); if (!validated.ok) return { kind: "invalid", status: validated.status, message: validated.message }; return { kind: "batch", phoneNumbers: validated.phoneNumbers, options }; } if (body.phoneNumber) { return { kind: "single", phoneNumber: body.phoneNumber, options }; } return MISSING_INPUT; } setResourceLoader(new NodeFsResourceLoader()); export { DEFAULT_CACHE_SIZE, MAX_BATCH_SIZE, NodeFsResourceLoader, carrier, carrierAsync, classifyRequest, clearCache, enrichPhoneNumber, executeValidation, extractBatchOptions, geocoder, geocoderAsync, getCacheSize, getCacheStats, getResourceLoader, setCacheSize, setResourceLoader, timezones, timezonesAsync, validateBatch, validateBatchField, validateSingle }; //# sourceMappingURL=index.esm.js.map