summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-27 20:37:47 -0300
committerMurilo Ijanc <[email protected]>2026-07-27 20:37:47 -0300
commitc4032605e0833a4c74d9bf58e679a83349ce9fb1 (patch)
tree224fb5c07276e0be9890b04a2207b6916ac62cf8 /src
parent36486007ccada8c31d30e8f528d21233cabce32c (diff)
downloadautofill-main.tar.gz
Name the municipality behind a CEP that does not existHEADv0.2.0main
A CEP lookup that fails is rarely a random number: it is usually someone who knows the city and guessed the last digits, most often the -000 that 957 municipalities never received. The API now answers those 404s with a `faixa` naming the municipality the number would have belonged to and the first and last CEP that really exist there, so the shopper can be told "esse número seria de Areal/RJ" instead of a bare "não encontrado". It stays an error. The address does not exist, so no field is filled from `faixa` — quietly writing the city of a CEP that does not exist would produce a wrong address, which is worse than an empty form. Only the wording of the message changes. The range is sparse (Areal has 118 CEPs over some 4,900 numbers), so it is shown as a hint of where to look, never as a list to pick from. Parsing is defensive throughout: a body that is not JSON, a `faixa` that is not an object, a missing localidade or uf, a malformed range — each falls back to the message shipped before, and none of it can throw.
Diffstat (limited to 'src')
-rw-r--r--src/index.js146
1 files changed, 137 insertions, 9 deletions
diff --git a/src/index.js b/src/index.js
index d2f531e..77f0dcc 100644
--- a/src/index.js
+++ b/src/index.js
@@ -10,7 +10,7 @@
*/
/** Version of this widget. */
-export const VERSION = "0.1.0";
+export const VERSION = "0.2.0";
const DEFAULT_BASE_URL = "https://cep.is";
const DEFAULT_DEBOUNCE_MS = 250;
@@ -18,6 +18,8 @@ const DEFAULT_TIMEOUT_MS = 8000;
const NOT_DIGIT = /\D+/g;
const DIACRITIC = /[\u0300-\u036f]/g;
+const EIGHT_DIGITS = /^\d{8}$/;
+const PLACEHOLDER = /\{(localidade|uf|cep_ini|cep_fim)\}/g;
const ADDRESS_FIELDS = [
"cep",
@@ -38,12 +40,21 @@ const ADDRESS_FIELDS = [
/**
* The message shown for each state, in Portuguese. Override any of them
* through the `messages` option of {@link attach}.
+ *
+ * `not_found_city` and `not_found_city_range` are not states of their own:
+ * they are the wording used for the `not_found` state when the API tells us
+ * which municipality the CEP would have belonged to. `{localidade}`, `{uf}`,
+ * `{cep_ini}` and `{cep_fim}` are filled in; the two CEPs are masked. Whenever
+ * that information is missing, plain `not_found` is used instead.
*/
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.",
+ not_found_city: "CEP não encontrado. Esse número seria de {localidade}/{uf}.",
+ not_found_city_range:
+ "CEP não encontrado. Esse número seria de {localidade}/{uf}, que tem CEPs de {cep_ini} a {cep_fim}.",
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.",
@@ -67,12 +78,15 @@ export const MESSAGES = {
* | `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 |
+ *
+ * A `not_found` may carry `faixa`: the municipality the CEP would have
+ * belonged to. See {@link CepisAutofillError#faixa}.
*/
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]
+ * @param {{status?: number, retryAfterMs?: number, cep?: string, faixa?: Record<string, any>, cause?: unknown}} [details]
*/
constructor(code, message, details = {}) {
super(message, details.cause === undefined ? undefined : { cause: details.cause });
@@ -81,6 +95,24 @@ export class CepisAutofillError extends Error {
this.status = details.status;
this.retryAfterMs = details.retryAfterMs;
this.cep = details.cep;
+ /**
+ * On a `not_found`, the municipality whose CEP range brackets the number
+ * asked for — `{localidade, uf}` plus whichever of `estado`, `regiao`,
+ * `ibge`, `cep_ini`, `cep_fim` and `ceps` the API sent. Absent whenever
+ * the CEP falls outside every municipality, or the API said nothing
+ * usable.
+ *
+ * **This is context for a failure, not an address.** The CEP asked for
+ * does not exist, so neither does the address it would have had: the
+ * widget never writes any of this into a form field, and neither should
+ * you. `cep_ini` and `cep_fim` are the lowest and highest CEP that really
+ * exist in that municipality, not a contiguous run — Areal/RJ has 118 of
+ * them spread over some 4,900 numbers — so they are worth showing as a
+ * hint of where the right CEP lies, never as a list to pick from.
+ *
+ * @type {Record<string, any>|undefined}
+ */
+ this.faixa = details.faixa;
}
}
@@ -118,6 +150,87 @@ function retryAfterMs(header) {
return Number.isNaN(target) ? undefined : Math.max(0, target - Date.now());
}
+function trimmed(value) {
+ return typeof value === "string" ? value.trim() : "";
+}
+
+function maskCep(digits) {
+ return `${digits.slice(0, 5)}-${digits.slice(5)}`;
+}
+
+/**
+ * Read the `faixa` a 404 may carry: the municipality the CEP would have
+ * belonged to, had it existed.
+ *
+ * Every field is optional and none is trusted. A body that is not JSON, a
+ * `faixa` that is not an object, a missing `localidade` or `uf` — all of it
+ * yields `undefined`, and the caller falls back to the plain "não encontrado".
+ * This never throws.
+ *
+ * @param {string} body The raw 404 body.
+ * @returns {Record<string, any>|undefined}
+ */
+function parseFaixa(body) {
+ let data;
+ try {
+ data = JSON.parse(body);
+ } catch {
+ return undefined;
+ }
+ const raw = data && typeof data === "object" ? data.faixa : undefined;
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
+ return undefined;
+ }
+
+ const localidade = trimmed(raw.localidade);
+ const uf = trimmed(raw.uf);
+ if (localidade === "" || uf === "") {
+ return undefined;
+ }
+
+ const faixa = { localidade, uf };
+ for (const field of ["estado", "regiao", "ibge"]) {
+ const value = trimmed(raw[field]);
+ if (value !== "") {
+ faixa[field] = value;
+ }
+ }
+ const ini = trimmed(raw.cep_ini);
+ const fim = trimmed(raw.cep_fim);
+ if (EIGHT_DIGITS.test(ini) && EIGHT_DIGITS.test(fim)) {
+ faixa.cep_ini = ini;
+ faixa.cep_fim = fim;
+ }
+ if (typeof raw.ceps === "number" && Number.isFinite(raw.ceps)) {
+ faixa.ceps = raw.ceps;
+ }
+ return faixa;
+}
+
+/**
+ * Word the `not_found` state, naming the municipality when we know it.
+ *
+ * @param {Record<string, any>|undefined} faixa From {@link parseFaixa}.
+ * @param {Record<string, string>} messages The message table in force.
+ * @returns {string}
+ */
+function notFoundMessage(faixa, messages) {
+ if (!faixa) {
+ return messages.not_found;
+ }
+ const template = faixa.cep_ini ? messages.not_found_city_range : messages.not_found_city;
+ if (typeof template !== "string" || template === "") {
+ return messages.not_found;
+ }
+ return template.replace(PLACEHOLDER, (_, key) => {
+ const value = faixa[key];
+ if (typeof value !== "string") {
+ return "";
+ }
+ return key === "cep_ini" || key === "cep_fim" ? maskCep(value) : value;
+ });
+}
+
function apiMessage(body, fallback) {
try {
const data = JSON.parse(body);
@@ -139,6 +252,9 @@ function apiMessage(body, fallback) {
* empty strings rather than nulls, and `geo` present only for the CEPs that
* have a coordinate.
*
+ * A `not_found` may carry a `faixa` naming the municipality the CEP would have
+ * belonged to — context for the failure, never an address to fill in.
+ *
* @param {string} cep Masked or unmasked.
* @param {{baseUrl?: string, timeoutMs?: number, signal?: AbortSignal}} [options]
* @returns {Promise<Record<string, any>>} The address.
@@ -154,7 +270,7 @@ export async function lookup(cep, options = {}) {
if (hit.address) {
return hit.address;
}
- throw new CepisAutofillError(hit.code, hit.message, { cep: digits });
+ throw new CepisAutofillError(hit.code, hit.message, { cep: digits, faixa: hit.faixa });
}
const controller = new AbortController();
@@ -198,8 +314,12 @@ export async function lookup(cep, options = {}) {
}
if (response.status === 404) {
- cache.set(key, { code: "not_found", message: MESSAGES.not_found });
- throw new CepisAutofillError("not_found", MESSAGES.not_found, { cep: digits });
+ // A 404 may name the municipality the CEP would have belonged to. It stays
+ // an error: the address does not exist, so nothing here is ever filled in.
+ const faixa = parseFaixa(body);
+ const message = notFoundMessage(faixa, MESSAGES);
+ cache.set(key, { code: "not_found", message, faixa });
+ throw new CepisAutofillError("not_found", message, { cep: digits, faixa });
}
if (response.status === 400) {
throw new CepisAutofillError("invalid", apiMessage(body, MESSAGES.invalid), { cep: digits });
@@ -353,7 +473,9 @@ let regionCount = 0;
* @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`.
+ * Called for every failure the shopper sees. Never called for `aborted`. A
+ * `not_found` may carry `error.faixa`; see {@link CepisAutofillError#faixa}
+ * for why it must not be filled into the form.
* @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}}
@@ -425,8 +547,8 @@ export function attach(options) {
return node;
}
- function announce(kind) {
- const message = messages[kind] ?? "";
+ function announce(kind, text) {
+ const message = text ?? messages[kind] ?? "";
const node = region();
if (node) {
node.setAttribute(
@@ -452,6 +574,10 @@ export function attach(options) {
}
}
+ // Only ever called with an address the API confirmed exists. A `faixa` from a
+ // 404 must never reach it: naming the city of a CEP that does not exist is a
+ // hint for the shopper, and silently writing it into the form would be a
+ // wrong address.
function fill(address) {
for (const field of ADDRESS_FIELDS) {
if (field === "cep" || !options[field]) {
@@ -514,7 +640,9 @@ export function attach(options) {
return null;
}
const code = error instanceof CepisAutofillError ? error.code : "network";
- announce(code);
+ // No field is touched on this path — a `not_found` only changes the
+ // wording of the message, never the form.
+ announce(code, code === "not_found" ? notFoundMessage(error.faixa, messages) : undefined);
if (options.onError) {
options.onError(error, { cep: digits });
}