summaryrefslogtreecommitdiffstats
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
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.
-rw-r--r--README.md78
-rw-r--r--package.json2
-rw-r--r--src/index.js146
-rw-r--r--test/browser.test.js117
-rw-r--r--test/fixture.html5
-rw-r--r--test/server.js27
6 files changed, 351 insertions, 24 deletions
diff --git a/README.md b/README.md
index 242859a..0489a3d 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ widget fills the street, neighbourhood, city and state fields you point it
at. Official Correios data (DNE).
One `<script>` tag, no build step, no framework, no dependencies.
-**3.3 KB minified and gzipped** (7.7 KB minified, 7859 bytes).
+**3.7 KB minified and gzipped** (8.8 KB minified, 9042 bytes).
```html
<script src="cepis-autofill.js"></script>
@@ -134,15 +134,16 @@ to render nothing and drive your own UI from `onStatus`.
Every failure has its own message, in Portuguese, and reaches both the
status region and `onError`:
-| `error.code` | message | worth retrying? |
-| ------------------ | ------------------------------------------------------------------------- | --------------- |
-| `invalid` | CEP inválido. Digite os 8 dígitos. | no |
-| `not_found` | CEP não encontrado. Confira o número ou preencha o endereço manualmente. | no |
-| `rate_limited` | Muitas consultas em pouco tempo. Aguarde alguns segundos e tente de novo. | later |
-| `server` | O serviço de CEP está indisponível. Preencha o endereço manualmente. | later |
-| `network` | Não foi possível consultar o CEP. Verifique sua conexão e tente de novo. | later |
-| `timeout` | A consulta do CEP demorou demais. Tente de novo ou preencha manualmente. | later |
-| `invalid_response` | Resposta inesperada do serviço de CEP. Preencha o endereço manualmente. | probably not |
+| `error.code` | message | worth retrying? |
+| ------------------ | ----------------------------------------------------------------------------------------- | --------------- |
+| `invalid` | CEP inválido. Digite os 8 dígitos. | no |
+| `not_found` | CEP não encontrado. Confira o número ou preencha o endereço manualmente. | no |
+| `not_found` + city | CEP não encontrado. Esse número seria de Areal/RJ, que tem CEPs de 25845-001 a 25849-899. | no |
+| `rate_limited` | Muitas consultas em pouco tempo. Aguarde alguns segundos e tente de novo. | later |
+| `server` | O serviço de CEP está indisponível. Preencha o endereço manualmente. | later |
+| `network` | Não foi possível consultar o CEP. Verifique sua conexão e tente de novo. | later |
+| `timeout` | A consulta do CEP demorou demais. Tente de novo ou preencha manualmente. | later |
+| `invalid_response` | Resposta inesperada do serviço de CEP. Preencha o endereço manualmente. | probably not |
Override any of them with `messages`:
@@ -156,6 +157,59 @@ CepisAutofill.attach({
`rate_limited` carries `retryAfterMs` and `server` carries `status`.
+### When the CEP does not exist, but the city does
+
+A CEP 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. Areal/RJ, for one, answers from `25845-001`
+up, and never at `25845-000`.
+
+So when the API can tell which municipality the number would have belonged
+to, the message says it:
+
+> CEP não encontrado. Esse número seria de Areal/RJ, que tem CEPs de
+> 25845-001 a 25849-899.
+
+**Nothing is filled in.** This is still a `not_found`: the address does not
+exist, so the widget writes no field — not the city, not the state, nothing.
+Filling the city from a CEP that does not exist would quietly produce a wrong
+address, which is worse than an empty form. Only the wording of the message
+changes.
+
+The range is not a list of valid CEPs either. `cep_ini` and `cep_fim` are the
+lowest and the highest CEP that really exist in that municipality, and what
+lies between them is sparse — Areal has 118 CEPs spread over some 4,900
+numbers. Show it as a hint of where to look; never suggest a CEP from inside
+it.
+
+Where the API says nothing about a municipality, the plain
+`CEP não encontrado…` message stands, exactly as before. The same goes for a
+reply this widget cannot make sense of.
+
+Two extra entries in `messages` carry the wording, with `{localidade}`,
+`{uf}`, `{cep_ini}` and `{cep_fim}` filled in:
+
+| key | used when |
+| ---------------------- | ------------------------------------------- |
+| `not_found_city_range` | the municipality and its first and last CEP |
+| `not_found_city` | the municipality, but no usable range |
+
+```js
+CepisAutofill.attach({
+ cep: "#cep",
+ localidade: "#cidade",
+ messages: {
+ not_found_city_range:
+ "Esse CEP não existe. Em {localidade}/{uf} eles vão de {cep_ini} a {cep_fim}.",
+ },
+});
+```
+
+`onError` gets the whole thing as `error.faixa` — `localidade`, `uf`, and
+whichever of `estado`, `regiao`, `ibge`, `cep_ini`, `cep_fim` and `ceps` the
+API sent — for a richer UI of your own. It is absent, never null, when there
+is no municipality to name.
+
### Looking a CEP up yourself
`CepisAutofill.lookup` is the request layer on its own — same cache, same
@@ -169,7 +223,9 @@ address.uf; // "SP"
It resolves to the API's own JSON: Portuguese field names, empty strings
rather than nulls, and `geo` present only for the roughly 57% of CEPs that
-have a coordinate.
+have a coordinate. It rejects with a `CepisAutofillError`, whose `faixa` —
+when the CEP does not exist but its municipality does — is
+[described above](#when-the-cep-does-not-exist-but-the-city-does).
## Mapping your platform's fields
diff --git a/package.json b/package.json
index 798ab4b..a242375 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "cepis-autofill",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Drop-in address autofill widget for Brazilian checkouts, on top of the CEP API at cep.is",
"keywords": [
"cep",
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 });
}
diff --git a/test/browser.test.js b/test/browser.test.js
index 634df0d..afbfb2c 100644
--- a/test/browser.test.js
+++ b/test/browser.test.js
@@ -68,6 +68,22 @@ function events() {
return page.evaluate(() => window.__events);
}
+function lastError() {
+ return page.evaluate(() => window.__error);
+}
+
+/** Every field the fixture maps, so a test can prove none of them was written. */
+async function filledFields() {
+ const filled = {};
+ for (const selector of ["#rua", "#bairro", "#cidade", "#uf", "#numero"]) {
+ const current = await value(selector);
+ if (current !== "") {
+ filled[selector] = current;
+ }
+ }
+ return filled;
+}
+
async function waitForRequests(count, timeout = 4000) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
@@ -209,8 +225,10 @@ describe("errors", () => {
document.querySelector(".cepis-autofill-status").textContent.includes("não encontrado"),
);
- expect(await statusText()).toContain("CEP não encontrado");
- expect(await value("#rua")).toBe("");
+ expect(await statusText()).toBe(
+ "CEP não encontrado. Confira o número ou preencha o endereço manualmente.",
+ );
+ expect(await filledFields()).toEqual({});
expect(await events()).toContain("error:not_found");
});
@@ -256,6 +274,101 @@ describe("errors", () => {
});
});
+describe("the municipality behind a CEP that does not exist", () => {
+ async function lookFor(cep) {
+ await open();
+ await typeCep(cep);
+ await page.waitForFunction(() =>
+ document.querySelector(".cepis-autofill-status").textContent.includes("não encontrado"),
+ );
+ }
+
+ test("the message names the city and where its CEPs start and end", async () => {
+ await lookFor("25845000");
+
+ expect(await statusText()).toBe(
+ "CEP não encontrado. Esse número seria de Areal/RJ, " +
+ "que tem CEPs de 25845-001 a 25849-899.",
+ );
+ expect(await events()).toContain("error:not_found");
+ });
+
+ test("naming the city fills nothing — it is a failure, not an address", async () => {
+ await lookFor("25845000");
+
+ expect(await filledFields()).toEqual({});
+ expect(await events()).not.toContain("result:25845000");
+ expect(await page.locator("#cep").getAttribute("aria-invalid")).toBe("true");
+ });
+
+ test("the faixa reaches onError, whole", async () => {
+ await lookFor("25845000");
+
+ const error = await lastError();
+ expect(error.code).toBe("not_found");
+ expect(error.faixa).toEqual({
+ localidade: "Areal",
+ uf: "RJ",
+ estado: "Rio de Janeiro",
+ regiao: "Sudeste",
+ ibge: "3300225",
+ cep_ini: "25845001",
+ cep_fim: "25849899",
+ ceps: 118,
+ });
+ });
+
+ test("a CEP in no municipality keeps the plain message", async () => {
+ await lookFor("99999999");
+
+ expect(await statusText()).toBe(
+ "CEP não encontrado. Confira o número ou preencha o endereço manualmente.",
+ );
+ expect(await filledFields()).toEqual({});
+ expect((await lastError()).faixa).toBeUndefined();
+ });
+
+ test("a malformed faixa falls back to the plain message", async () => {
+ await lookFor("25845999");
+
+ expect(await statusText()).toBe(
+ "CEP não encontrado. Confira o número ou preencha o endereço manualmente.",
+ );
+ expect(await filledFields()).toEqual({});
+ expect((await lastError()).faixa).toBeUndefined();
+ });
+
+ test("the wording is overridable like every other message", async () => {
+ await open({
+ messages: { not_found_city_range: "Nada aqui. Tente um CEP de {localidade} ({uf})." },
+ });
+ await typeCep("25845000");
+ await page.waitForFunction(() =>
+ document.querySelector(".cepis-autofill-status").textContent.includes("Nada aqui"),
+ );
+
+ expect(await statusText()).toBe("Nada aqui. Tente um CEP de Areal (RJ).");
+ expect(await filledFields()).toEqual({});
+ });
+
+ test("lookup() hands the faixa over without touching the form", async () => {
+ await open();
+ const thrown = await page.evaluate(async (baseUrl) => {
+ try {
+ await window.CepisAutofill.lookup("25845-000", { baseUrl });
+ return null;
+ } catch (error) {
+ return { code: error.code, message: error.message, localidade: error.faixa.localidade };
+ }
+ }, api.url);
+
+ expect(thrown.code).toBe("not_found");
+ expect(thrown.message).toContain("Areal/RJ");
+ expect(thrown.localidade).toBe("Areal");
+ expect(await filledFields()).toEqual({});
+ });
+});
+
describe("what the shopper typed", () => {
test("overwrite: false leaves a filled field alone", async () => {
await open({ overwrite: false });
diff --git a/test/fixture.html b/test/fixture.html
index 6d6535d..cef959f 100644
--- a/test/fixture.html
+++ b/test/fixture.html
@@ -48,6 +48,11 @@
},
onError: function (error) {
window.__events.push("error:" + error.code);
+ window.__error = {
+ code: error.code,
+ message: error.message,
+ faixa: error.faixa,
+ };
},
onStatus: function (state) {
window.__events.push("status:" + state);
diff --git a/test/server.js b/test/server.js
index 720f4a0..db31c63 100644
--- a/test/server.js
+++ b/test/server.js
@@ -40,6 +40,26 @@ const ADDRESSES = {
},
};
+/**
+ * The `faixa` a 404 carries when the CEP falls inside a municipality that
+ * simply never received that number. `25845000` is the real Areal/RJ shape —
+ * the `-000` that municipality never got. `25845999` is deliberately broken,
+ * so the tests can prove a malformed `faixa` degrades to the plain message.
+ */
+const FAIXAS = {
+ 25845000: {
+ localidade: "Areal",
+ uf: "RJ",
+ estado: "Rio de Janeiro",
+ regiao: "Sudeste",
+ ibge: "3300225",
+ cep_ini: "25845001",
+ cep_fim: "25849899",
+ ceps: 118,
+ },
+ 25845999: { uf: "RJ", cep_ini: 25845001 },
+};
+
const CORS = {
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/json; charset=utf-8",
@@ -105,8 +125,13 @@ export function startApi() {
}
const address = ADDRESSES[path];
if (!address) {
+ const faixa = FAIXAS[path];
return new Response(
- JSON.stringify({ erro: "cep_nao_encontrado", mensagem: "CEP não existe na base do DNE" }),
+ JSON.stringify({
+ erro: "cep_nao_encontrado",
+ mensagem: "CEP não existe na base do DNE",
+ ...(faixa ? { faixa } : {}),
+ }),
{ status: 404, headers: CORS },
);
}