summaryrefslogtreecommitdiffstats
path: root/src/index.js
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 15:01:02 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 15:01:02 -0300
commit36486007ccada8c31d30e8f528d21233cabce32c (patch)
treed14a108845e4c984ec922ba98d153c8d68da6ebb /src/index.js
downloadautofill-ceafecfbd5171c08d15dc0ca123d6d9a2582000d.tar.gz
Initial importv0.1.0
Diffstat (limited to 'src/index.js')
-rw-r--r--src/index.js614
1 files changed, 614 insertions, 0 deletions
diff --git a/src/index.js b/src/index.js
new file mode 100644
index 0000000..d2f531e
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,614 @@
+/**
+ * Drop-in address autofill for Brazilian checkouts, on top of the public CEP
+ * API at cep.is.
+ *
+ * The shopper types a CEP; the widget fills the street, neighbourhood, city and
+ * state fields you point it at. Field mapping is configuration: nothing about
+ * any particular e-commerce platform is baked in.
+ *
+ * @module
+ */
+
+/** Version of this widget. */
+export const VERSION = "0.1.0";
+
+const DEFAULT_BASE_URL = "https://cep.is";
+const DEFAULT_DEBOUNCE_MS = 250;
+const DEFAULT_TIMEOUT_MS = 8000;
+
+const NOT_DIGIT = /\D+/g;
+const DIACRITIC = /[\u0300-\u036f]/g;
+
+const ADDRESS_FIELDS = [
+ "cep",
+ "logradouro",
+ "complemento",
+ "unidade",
+ "bairro",
+ "localidade",
+ "uf",
+ "estado",
+ "regiao",
+ "ibge",
+ "gia",
+ "ddd",
+ "siafi",
+];
+
+/**
+ * The message shown for each state, in Portuguese. Override any of them
+ * through the `messages` option of {@link attach}.
+ */
+export const MESSAGES = {
+ idle: "",
+ loading: "Buscando endereço…",
+ found: "Endereço encontrado.",
+ not_found: "CEP não encontrado. Confira o número ou preencha o endereço manualmente.",
+ invalid: "CEP inválido. Digite os 8 dígitos.",
+ rate_limited: "Muitas consultas em pouco tempo. Aguarde alguns segundos e tente de novo.",
+ server: "O serviço de CEP está indisponível. Preencha o endereço manualmente.",
+ network: "Não foi possível consultar o CEP. Verifique sua conexão e tente de novo.",
+ timeout: "A consulta do CEP demorou demais. Tente de novo ou preencha manualmente.",
+ invalid_response: "Resposta inesperada do serviço de CEP. Preencha o endereço manualmente.",
+};
+
+/**
+ * Everything this widget throws or hands to `onError`.
+ *
+ * `code` is what you branch on. It is the decision, not the HTTP status:
+ *
+ * | code | when | worth retrying? |
+ * | --- | --- | --- |
+ * | `invalid` | not 8 digits, or the API rejected the format | no |
+ * | `not_found` | well-formed, absent from the DNE database | no |
+ * | `rate_limited` | 429; `retryAfterMs` carries what the server asked for | later |
+ * | `server` | 5xx; `status` carries the code | later |
+ * | `network` | DNS, refused connection, TLS, offline | later |
+ * | `timeout` | the request outlived `timeoutMs` | later |
+ * | `invalid_response` | a success status with an unreadable body | probably not |
+ * | `aborted` | superseded by a newer keystroke; never surfaced to the shopper | no |
+ */
+export class CepisAutofillError extends Error {
+ /**
+ * @param {string} code One of the codes in the table above.
+ * @param {string} message Human message, in Portuguese.
+ * @param {{status?: number, retryAfterMs?: number, cep?: string, cause?: unknown}} [details]
+ */
+ constructor(code, message, details = {}) {
+ super(message, details.cause === undefined ? undefined : { cause: details.cause });
+ this.name = "CepisAutofillError";
+ this.code = code;
+ this.status = details.status;
+ this.retryAfterMs = details.retryAfterMs;
+ this.cep = details.cep;
+ }
+}
+
+/**
+ * Return the CEP's 8 digits, accepting the `01001-000` mask.
+ *
+ * @param {string} value Anything the shopper typed.
+ * @returns {string} The 8 digits.
+ * @throws {CepisAutofillError} code `invalid`, when 8 digits do not remain.
+ */
+export function normalizeCep(value) {
+ const digits = String(value == null ? "" : value).replace(NOT_DIGIT, "");
+ if (digits.length !== 8) {
+ throw new CepisAutofillError("invalid", MESSAGES.invalid, { cep: digits });
+ }
+ return digits;
+}
+
+const cache = new Map();
+
+/** Drop every cached lookup. Rarely needed: the cache dies with the page. */
+export function clearCache() {
+ cache.clear();
+}
+
+function retryAfterMs(header) {
+ if (!header) {
+ return undefined;
+ }
+ const value = header.trim();
+ if (/^\d+$/.test(value)) {
+ return Number(value) * 1000;
+ }
+ const target = Date.parse(value);
+ return Number.isNaN(target) ? undefined : Math.max(0, target - Date.now());
+}
+
+function apiMessage(body, fallback) {
+ try {
+ const data = JSON.parse(body);
+ if (data && typeof data.mensagem === "string" && data.mensagem !== "") {
+ return data.mensagem;
+ }
+ } catch {
+ return fallback;
+ }
+ return fallback;
+}
+
+/**
+ * Look up one CEP on the API.
+ *
+ * Successful answers and "does not exist" are cached in memory for the life of
+ * the page, keyed by CEP and base URL; transient failures are not. Callers get
+ * the address object exactly as the API returns it — Portuguese field names,
+ * empty strings rather than nulls, and `geo` present only for the CEPs that
+ * have a coordinate.
+ *
+ * @param {string} cep Masked or unmasked.
+ * @param {{baseUrl?: string, timeoutMs?: number, signal?: AbortSignal}} [options]
+ * @returns {Promise<Record<string, any>>} The address.
+ * @throws {CepisAutofillError} See the table on {@link CepisAutofillError}.
+ */
+export async function lookup(cep, options = {}) {
+ const digits = normalizeCep(cep);
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
+ const key = `${baseUrl}|${digits}`;
+
+ const hit = cache.get(key);
+ if (hit) {
+ if (hit.address) {
+ return hit.address;
+ }
+ throw new CepisAutofillError(hit.code, hit.message, { cep: digits });
+ }
+
+ const controller = new AbortController();
+ const forward = () => controller.abort();
+ if (options.signal) {
+ if (options.signal.aborted) {
+ throw new CepisAutofillError("aborted", "consulta cancelada", { cep: digits });
+ }
+ options.signal.addEventListener("abort", forward);
+ }
+
+ let timedOut = false;
+ const timer = setTimeout(() => {
+ timedOut = true;
+ controller.abort();
+ }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
+
+ let response;
+ let body;
+ try {
+ response = await fetch(`${baseUrl}/${digits}`, {
+ method: "GET",
+ credentials: "omit",
+ headers: { Accept: "application/json" },
+ signal: controller.signal,
+ });
+ body = await response.text();
+ } catch (error) {
+ if (timedOut) {
+ throw new CepisAutofillError("timeout", MESSAGES.timeout, { cep: digits, cause: error });
+ }
+ if (controller.signal.aborted) {
+ throw new CepisAutofillError("aborted", "consulta cancelada", { cep: digits });
+ }
+ throw new CepisAutofillError("network", MESSAGES.network, { cep: digits, cause: error });
+ } finally {
+ clearTimeout(timer);
+ if (options.signal) {
+ options.signal.removeEventListener("abort", forward);
+ }
+ }
+
+ if (response.status === 404) {
+ cache.set(key, { code: "not_found", message: MESSAGES.not_found });
+ throw new CepisAutofillError("not_found", MESSAGES.not_found, { cep: digits });
+ }
+ if (response.status === 400) {
+ throw new CepisAutofillError("invalid", apiMessage(body, MESSAGES.invalid), { cep: digits });
+ }
+ if (response.status === 429) {
+ throw new CepisAutofillError("rate_limited", MESSAGES.rate_limited, {
+ cep: digits,
+ retryAfterMs: retryAfterMs(response.headers.get("Retry-After")),
+ });
+ }
+ if (response.status >= 500) {
+ throw new CepisAutofillError("server", MESSAGES.server, {
+ cep: digits,
+ status: response.status,
+ });
+ }
+ if (!response.ok) {
+ throw new CepisAutofillError("invalid_response", MESSAGES.invalid_response, {
+ cep: digits,
+ status: response.status,
+ });
+ }
+
+ let address;
+ try {
+ address = JSON.parse(body);
+ } catch (error) {
+ throw new CepisAutofillError("invalid_response", MESSAGES.invalid_response, {
+ cep: digits,
+ cause: error,
+ });
+ }
+ if (!address || typeof address !== "object" || Array.isArray(address)) {
+ throw new CepisAutofillError("invalid_response", MESSAGES.invalid_response, { cep: digits });
+ }
+
+ cache.set(key, { address });
+ return address;
+}
+
+function resolve(target) {
+ if (!target) {
+ return null;
+ }
+ if (typeof target === "string") {
+ return document.querySelector(target);
+ }
+ return target.nodeType === 1 ? target : null;
+}
+
+function fold(value) {
+ return String(value ?? "")
+ .normalize("NFD")
+ .replace(DIACRITIC, "")
+ .trim()
+ .toLowerCase();
+}
+
+function assign(element, value) {
+ const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), "value");
+ if (setter && setter.set) {
+ setter.set.call(element, value);
+ } else {
+ element.value = value;
+ }
+ element.dispatchEvent(new Event("input", { bubbles: true }));
+ element.dispatchEvent(new Event("change", { bubbles: true }));
+}
+
+function selectOption(element, candidates) {
+ const wanted = candidates.filter(Boolean).map(fold);
+ for (const option of Array.from(element.options)) {
+ if (wanted.includes(fold(option.value)) || wanted.includes(fold(option.textContent))) {
+ assign(element, option.value);
+ return option.value;
+ }
+ }
+ return null;
+}
+
+function writable(element, mode, written) {
+ const current = String(element.value ?? "");
+ if (mode === true) {
+ return true;
+ }
+ if (current.trim() === "") {
+ return true;
+ }
+ if (mode === false) {
+ return false;
+ }
+ return written.get(element) === current;
+}
+
+let regionCount = 0;
+
+/**
+ * Attach the widget to a form.
+ *
+ * Every selector accepts a CSS string or an element. Strings are resolved on
+ * every use and the CEP listener is delegated from `document`, so a checkout
+ * that re-renders its form keeps working; elements are held directly and stop
+ * working if that node is replaced.
+ *
+ * ```js
+ * const handle = CepisAutofill.attach({
+ * cep: "#billing_postcode",
+ * logradouro: "#billing_address_1",
+ * bairro: "#billing_neighborhood",
+ * localidade: "#billing_city",
+ * uf: "#billing_state",
+ * numero: "#billing_number",
+ * });
+ * ```
+ *
+ * @param {object} options
+ * @param {string|Element} options.cep The field the shopper types into. Required.
+ * @param {string|Element} [options.logradouro] Street field.
+ * @param {string|Element} [options.complemento] Complement field.
+ * @param {string|Element} [options.unidade] Unit field.
+ * @param {string|Element} [options.bairro] Neighbourhood field.
+ * @param {string|Element} [options.localidade] City field.
+ * @param {string|Element} [options.uf] State field. A `<select>` is matched by
+ * option value or label, against both the two-letter UF and the state name.
+ * @param {string|Element} [options.estado] State-name field.
+ * @param {string|Element} [options.regiao] Region field.
+ * @param {string|Element} [options.ibge] IBGE code field.
+ * @param {string|Element} [options.gia] GIA code field.
+ * @param {string|Element} [options.ddd] Area code field.
+ * @param {string|Element} [options.siafi] SIAFI code field.
+ * @param {string|Element} [options.numero] House-number field. Never written
+ * to — only used by `focusNumero`.
+ * @param {string} [options.baseUrl] API root. Default `https://cep.is`; point
+ * it at a local instance to test.
+ * @param {number} [options.debounceMs] Quiet period after the last keystroke
+ * before a request goes out. Default `250`.
+ * @param {number} [options.timeoutMs] Deadline for one request. Default `8000`.
+ * @param {boolean|"auto"} [options.overwrite] What to do with a field that
+ * already has a value. `"auto"` (default) replaces empty fields and values
+ * this widget wrote itself, and leaves anything the shopper typed alone;
+ * `false` only ever fills empty fields; `true` always overwrites.
+ * @param {boolean} [options.focusNumero] Move focus to the `numero` field
+ * after a successful lookup, and only while the CEP field still has focus.
+ * Default `false`.
+ * @param {string|Element|false} [options.status] Where to announce progress.
+ * Omit to have an `aria-live` region created right after the CEP field, pass
+ * a selector or element to use your own, or `false` to render nothing and
+ * drive your own UI from `onStatus`.
+ * @param {Record<string, string>} [options.messages] Overrides for
+ * {@link MESSAGES}.
+ * @param {(address: Record<string, any>, context: {cep: string}) => void} [options.onResult]
+ * Called after a successful lookup, once the fields are filled.
+ * @param {(error: CepisAutofillError, context: {cep: string}) => void} [options.onError]
+ * Called for every failure the shopper sees. Never called for `aborted`.
+ * @param {(state: string, message: string) => void} [options.onStatus] Called
+ * on every state change: `idle`, `loading`, `found`, or an error code.
+ * @returns {{detach: () => void, lookup: (cep?: string) => Promise<Record<string, any>|null>, element: () => Element|null}}
+ * `detach()` removes every listener, aborts anything in flight and removes a
+ * status region it created. `lookup()` runs the whole pipeline immediately,
+ * skipping the debounce, for checkouts that fill the CEP field without
+ * firing events.
+ */
+export function attach(options) {
+ if (!options || !options.cep) {
+ throw new TypeError("attach() needs a `cep` selector or element");
+ }
+
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
+ const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
+ const overwrite = options.overwrite ?? "auto";
+ const messages = { ...MESSAGES, ...(options.messages ?? {}) };
+ const written = new WeakMap();
+
+ const state = {
+ seq: 0,
+ timer: 0,
+ controller: null,
+ region: null,
+ describedBy: false,
+ detached: false,
+ };
+
+ const source = () => resolve(options.cep);
+
+ function region() {
+ if (options.status === false) {
+ return null;
+ }
+ if (state.region && state.region.isConnected) {
+ return state.region;
+ }
+ if (options.status) {
+ state.region = resolve(options.status);
+ } else {
+ const anchor = source();
+ if (!anchor) {
+ return null;
+ }
+ const node = document.createElement("div");
+ node.className = "cepis-autofill-status";
+ node.style.minHeight = "1.25em";
+ anchor.insertAdjacentElement("afterend", node);
+ state.region = node;
+ }
+ const node = state.region;
+ if (!node) {
+ return null;
+ }
+ node.setAttribute("role", "status");
+ node.setAttribute("aria-live", "polite");
+ node.setAttribute("aria-atomic", "true");
+ if (!node.id) {
+ regionCount += 1;
+ node.id = `cepis-autofill-status-${regionCount}`;
+ }
+ const anchor = source();
+ if (anchor && !(anchor.getAttribute("aria-describedby") ?? "").includes(node.id)) {
+ const previous = anchor.getAttribute("aria-describedby");
+ anchor.setAttribute("aria-describedby", previous ? `${previous} ${node.id}` : node.id);
+ state.describedBy = true;
+ }
+ return node;
+ }
+
+ function announce(kind) {
+ const message = messages[kind] ?? "";
+ const node = region();
+ if (node) {
+ node.setAttribute(
+ "data-cepis-state",
+ kind === "found" || kind === "loading" ? kind : kind === "idle" ? "idle" : "error",
+ );
+ node.textContent = message;
+ }
+ const anchor = source();
+ if (anchor) {
+ if (kind === "loading") {
+ anchor.setAttribute("aria-busy", "true");
+ } else {
+ anchor.removeAttribute("aria-busy");
+ }
+ anchor.setAttribute(
+ "aria-invalid",
+ kind === "invalid" || kind === "not_found" ? "true" : "false",
+ );
+ }
+ if (options.onStatus) {
+ options.onStatus(kind, message);
+ }
+ }
+
+ function fill(address) {
+ for (const field of ADDRESS_FIELDS) {
+ if (field === "cep" || !options[field]) {
+ continue;
+ }
+ const element = resolve(options[field]);
+ const value = address[field];
+ if (!element || typeof value !== "string" || value === "") {
+ continue;
+ }
+ if (!writable(element, overwrite, written)) {
+ continue;
+ }
+ if (element.tagName === "SELECT") {
+ const chosen = selectOption(
+ element,
+ field === "uf"
+ ? [value, address.estado]
+ : field === "estado"
+ ? [value, address.uf]
+ : [value],
+ );
+ if (chosen !== null) {
+ written.set(element, element.value);
+ }
+ continue;
+ }
+ assign(element, value);
+ written.set(element, value);
+ }
+ }
+
+ async function run(digits) {
+ state.seq += 1;
+ const seq = state.seq;
+ if (state.controller) {
+ state.controller.abort();
+ }
+ const controller = new AbortController();
+ state.controller = controller;
+ announce("loading");
+
+ try {
+ const address = await lookup(digits, { baseUrl, timeoutMs, signal: controller.signal });
+ if (seq !== state.seq || state.detached) {
+ return null;
+ }
+ fill(address);
+ announce("found");
+ if (options.onResult) {
+ options.onResult(address, { cep: digits });
+ }
+ const numero = resolve(options.numero);
+ if (options.focusNumero && numero && document.activeElement === source()) {
+ numero.focus();
+ }
+ return address;
+ } catch (error) {
+ if (seq !== state.seq || state.detached || error.code === "aborted") {
+ return null;
+ }
+ const code = error instanceof CepisAutofillError ? error.code : "network";
+ announce(code);
+ if (options.onError) {
+ options.onError(error, { cep: digits });
+ }
+ return null;
+ } finally {
+ if (state.controller === controller) {
+ state.controller = null;
+ }
+ }
+ }
+
+ function handle(event) {
+ const target = event.target;
+ const matches =
+ typeof options.cep === "string"
+ ? target && target.nodeType === 1 && target.matches(options.cep)
+ : target === options.cep;
+ if (!matches) {
+ return;
+ }
+ schedule(String(target.value ?? ""));
+ }
+
+ function schedule(value) {
+ clearTimeout(state.timer);
+ const digits = value.replace(NOT_DIGIT, "");
+ if (digits.length !== 8) {
+ state.seq += 1;
+ if (state.controller) {
+ state.controller.abort();
+ state.controller = null;
+ }
+ announce(digits.length > 8 ? "invalid" : "idle");
+ return;
+ }
+ state.timer = setTimeout(() => run(digits), debounceMs);
+ }
+
+ announce("idle");
+ document.addEventListener("input", handle, true);
+ document.addEventListener("change", handle, true);
+
+ return {
+ detach() {
+ state.detached = true;
+ state.seq += 1;
+ clearTimeout(state.timer);
+ if (state.controller) {
+ state.controller.abort();
+ state.controller = null;
+ }
+ document.removeEventListener("input", handle, true);
+ document.removeEventListener("change", handle, true);
+ const anchor = source();
+ if (anchor) {
+ anchor.removeAttribute("aria-busy");
+ anchor.removeAttribute("aria-invalid");
+ }
+ if (state.region && state.describedBy && anchor) {
+ const remaining = (anchor.getAttribute("aria-describedby") ?? "")
+ .split(/\s+/)
+ .filter((id) => id && id !== state.region.id)
+ .join(" ");
+ if (remaining) {
+ anchor.setAttribute("aria-describedby", remaining);
+ } else {
+ anchor.removeAttribute("aria-describedby");
+ }
+ }
+ if (state.region && !options.status) {
+ state.region.remove();
+ }
+ state.region = null;
+ },
+
+ lookup(cep) {
+ clearTimeout(state.timer);
+ const anchor = source();
+ const value = cep ?? (anchor ? anchor.value : "");
+ let digits;
+ try {
+ digits = normalizeCep(value);
+ } catch (error) {
+ announce("invalid");
+ if (options.onError) {
+ options.onError(error, { cep: String(value) });
+ }
+ return Promise.resolve(null);
+ }
+ return run(digits);
+ },
+
+ element: source,
+ };
+}
+
+export default { attach, lookup, normalizeCep, clearCache, CepisAutofillError, MESSAGES, VERSION };