# Capability Spec: i18n-backend-http (i18n-backend-http) **Package**: `@suaveplan/i18n-backend-http` **Status**: ARCHIVED — folded from `openspec/changes/i18n-backend-http/` on 2026-05-09. > Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.) ## Requirements ### Requirement: HttpBackend implements the Backend interface (read-only) The package SHALL export an `HttpBackend` class that implements the `Backend` interface from `@suaveplan/types/i18n` (universal-tier; runs in browsers, Bun, Node.js >= 18). The class SHALL declare a `capabilities` matrix of `{ canRead: true, canReadMulti: false, canSave: false, canTrackModification: false, canWatch: false }`. The class SHALL accept a required `HttpBackendOptions` constructor argument. Default `name` SHALL equal `"http"`. #### Scenario: capabilities matrix is read-only - **WHEN** `new HttpBackend({ loadPath: "/x/{locale}/{namespace}.json" }).capabilities` is read - **THEN** the result equals `{ canRead: true, canReadMulti: false, canSave: false, canTrackModification: false, canWatch: false }` #### Scenario: default name is "http" - **WHEN** `new HttpBackend({ loadPath: "/p/{locale}/{namespace}.json" }).name` is read - **THEN** the value equals `"http"` **Verified by:** `packages/i18n/i18n-backend-http/src/backend.test.ts::"capabilities matrix is read-only"` AND `packages/i18n/i18n-backend-http/src/backend.test.ts::"default name is http"` --- ### Requirement: read fetches and parses translation payload The `read(locale, namespace, signal?)` method SHALL substitute `{locale}` and `{namespace}` into `loadPath`, fetch the URL via the configured `fetchFn` (or `globalThis.fetch`), and parse the response body via `parsePayload` (default JSON object). The method SHALL return the parsed `TranslationRecord`. #### Scenario: 200 OK returns parsed JSON - **GIVEN** an `HttpBackend` whose `fetchFn` resolves to `Response(200, '{"hello":"Hi"}')` - **WHEN** `backend.read("en", "common")` is awaited - **THEN** the result equals `{ hello: "Hi" }` **Verified by:** `packages/i18n/i18n-backend-http/src/backend.test.ts::"200 OK returns parsed JSON"` --- ### Requirement: 404 maps to BACKEND_LOCALE_NOT_AVAILABLE When the HTTP response status is 404, `read` SHALL throw `I18nError` with code `I18N.BACKEND_LOCALE_NOT_AVAILABLE`. This SHALL signal "locale/namespace not available at this source" — the trigger for `ChainedBackend` to fall through to the next backend in the chain. The error context SHALL include `{ locale, namespace, url, status: 404, backend }`. #### Scenario: 404 maps to BACKEND_LOCALE_NOT_AVAILABLE - **GIVEN** an `HttpBackend` whose `fetchFn` resolves to `Response(404)` - **WHEN** `backend.read("xx", "common")` is awaited - **THEN** the call throws `I18nError` with code `"I18N.BACKEND_LOCALE_NOT_AVAILABLE"` **Verified by:** `packages/i18n/i18n-backend-http/src/backend.test.ts::"404 maps to BACKEND_LOCALE_NOT_AVAILABLE"` --- ### Requirement: 5xx and network errors map to BACKEND_READ_FAILED Non-404 non-2xx responses (typically 5xx) SHALL throw `I18nError` with code `I18N.BACKEND_READ_FAILED`. Network failures (TypeError from `fetchFn` — DNS / CORS / connection refused) SHALL also throw `I18nError` with code `I18N.BACKEND_READ_FAILED`. Both error paths SHALL stop `ChainedBackend` fallthrough (transport failures must propagate, not silently degrade). Payload-parse failures SHALL also map to `BACKEND_READ_FAILED`. #### Scenario: 500 maps to BACKEND_READ_FAILED - **GIVEN** an `HttpBackend` whose `fetchFn` resolves to `Response(500)` - **WHEN** `backend.read("en", "common")` is awaited - **THEN** the call throws `I18nError` with code `"I18N.BACKEND_READ_FAILED"` #### Scenario: network failure maps to BACKEND_READ_FAILED - **GIVEN** an `HttpBackend` whose `fetchFn` rejects with `TypeError("network")` - **WHEN** `backend.read("en", "common")` is awaited - **THEN** the call throws `I18nError` with code `"I18N.BACKEND_READ_FAILED"` #### Scenario: payload parse failure maps to BACKEND_READ_FAILED - **GIVEN** an `HttpBackend` whose `fetchFn` resolves to `Response(200, "not-json")` - **WHEN** `backend.read("en", "common")` is awaited - **THEN** the call throws `I18nError` with code `"I18N.BACKEND_READ_FAILED"` **Verified by:** `packages/i18n/i18n-backend-http/src/backend.test.ts::"500 maps to BACKEND_READ_FAILED"` AND `packages/i18n/i18n-backend-http/src/backend.test.ts::"network failure maps to BACKEND_READ_FAILED"` AND `packages/i18n/i18n-backend-http/src/backend.test.ts::"payload parse failure maps to BACKEND_READ_FAILED"` --- ### Requirement: AbortSignal cancellation throws BACKEND_TIMEOUT The `read` method SHALL accept an optional `signal?: AbortSignal` parameter. When the signal is already aborted at call time, the method SHALL throw `I18nError` with code `I18N.BACKEND_TIMEOUT` and SHALL NOT issue a network request. The signal SHALL also be threaded into the underlying `RequestInit` so `fetchFn` can cancel mid-flight; mid-flight aborts SHALL also surface as `BACKEND_TIMEOUT`. #### Scenario: pre-aborted signal aborts read - **GIVEN** an `AbortController` whose signal is already aborted - **WHEN** `backend.read("en", "common", { signal })` is awaited - **THEN** the call throws `I18nError` with code `"I18N.BACKEND_TIMEOUT"` **Verified by:** `packages/i18n/i18n-backend-http/src/backend.test.ts::"pre-aborted signal aborts read with BACKEND_TIMEOUT"` --- ### Requirement: Custom payload parser The constructor option `parsePayload?: ParsePayloadFn` SHALL be invoked instead of the default JSON parser when supplied. The default parser SHALL accept JSON object payloads and SHALL reject arrays, null, and primitive payloads with `BACKEND_READ_FAILED`. #### Scenario: custom parsePayload is invoked - **GIVEN** an `HttpBackend` with `parsePayload: async () => ({ injected: "yes" })` - **WHEN** `backend.read("en", "common")` is awaited - **THEN** the result equals `{ injected: "yes" }` #### Scenario: default parser rejects array payload - **WHEN** `defaultParsePayload(new Response("[1,2,3]"), "en", "common")` is awaited - **THEN** the call throws `I18nError` with code `"I18N.BACKEND_READ_FAILED"` **Verified by:** `packages/i18n/i18n-backend-http/src/backend.test.ts::"custom parsePayload is invoked"` AND `packages/i18n/i18n-backend-http/src/payload-parser.test.ts::"default parser rejects array payload"` --- ### Requirement: Custom headers and cross-domain options The constructor option `customHeaders?: CustomHeadersFn` SHALL be invoked per request with `(locale, namespace)` and the returned headers SHALL be merged into the `RequestInit`. The constructor option `crossDomain?: boolean` SHALL, when `true`, set `credentials: "include"` and `mode: "cors"` on every `RequestInit` (unless already set in `requestOptions`). #### Scenario: customHeaders is invoked - **GIVEN** an `HttpBackend` with `customHeaders: async (l, n) => ({ "x-locale": l, "x-namespace": n })` - **WHEN** `backend.read("en", "common")` is awaited - **THEN** the captured `RequestInit.headers` contains `x-locale: en` and `x-namespace: common` #### Scenario: crossDomain sets credentials and mode - **GIVEN** an `HttpBackend` with `crossDomain: true` - **WHEN** `backend.read("en", "common")` is awaited - **THEN** the captured `RequestInit.credentials` equals `"include"` AND `mode` equals `"cors"` **Verified by:** `packages/i18n/i18n-backend-http/src/backend.test.ts::"customHeaders is invoked"` AND `packages/i18n/i18n-backend-http/src/backend.test.ts::"crossDomain sets credentials and mode"` --- ### Requirement: Retry policy injection The constructor option `retry?: HttpBackendRetry` SHALL be invoked once per logical `read` call to wrap the underlying `fetch` operation. The interface SHALL be structurally compatible with `@suaveplan/retry`'s `RetryPolicy` so consumers can pass that instance directly without `@suaveplan/i18n-backend-http` taking a hard dep on `@suaveplan/retry`. #### Scenario: retry.run wraps the fetch operation - **GIVEN** an `HttpBackend` with `retry: { run: vi.fn(async (op) => op()) }` - **WHEN** `backend.read("en", "common")` is awaited - **THEN** `retry.run` was called exactly once **Verified by:** `packages/i18n/i18n-backend-http/src/backend.test.ts::"retry.run wraps the fetch operation"` --- ### Requirement: URL builder substitutes and encodes placeholders The package SHALL export `buildUrl(loadPath, locale, namespace): string` from `./url-builder`. The function SHALL replace every `{locale}` occurrence with `encodeURIComponent(locale)` and every `{namespace}` occurrence with `encodeURIComponent(namespace)`. #### Scenario: builder substitutes both placeholders - **WHEN** `buildUrl("/p/{locale}/{namespace}.json", "en-GB", "common")` is called - **THEN** the result equals `"/p/en-GB/common.json"` #### Scenario: builder URL-encodes special characters - **WHEN** `buildUrl("/p/{locale}/{namespace}.json", "en GB", "errors/auth")` is called - **THEN** the result equals `"/p/en%20GB/errors%2Fauth.json"` **Verified by:** `packages/i18n/i18n-backend-http/src/url-builder.test.ts::"builder substitutes both placeholders"` AND `packages/i18n/i18n-backend-http/src/url-builder.test.ts::"builder URL-encodes special characters"` --- ### Requirement: Boundary validation via Zod The constructor SHALL validate `HttpBackendOptions` via `HttpBackendOptionsSchema` (Zod) and SHALL throw `I18nError(I18N.INVALID_OPTIONS)` on invalid input. #### Scenario: invalid options throw INVALID_OPTIONS - **WHEN** `new HttpBackend({ loadPath: 42 as unknown as string })` is called - **THEN** the call throws `I18nError` with code `"I18N.INVALID_OPTIONS"` **Verified by:** `packages/i18n/i18n-backend-http/src/backend.test.ts::"invalid options throw INVALID_OPTIONS"` --- ### Requirement: OpenTelemetry instrumentation The backend SHALL emit `i18n.backend.http.read_duration_ms` (histogram, milliseconds), `i18n.backend.http.read_total{outcome="hit"|"miss"|"error"}` (counter), and `i18n.backend.http.read_errors_total{code,status?}` (counter). The `status` attribute SHALL be present on errors carrying an HTTP status code. #### Scenario: read emits duration histogram - **WHEN** `backend.read("en", "common")` succeeds - **THEN** `i18n.backend.http.read_duration_ms` is observed with the elapsed milliseconds #### Scenario: 404 increments read_total with outcome=miss - **WHEN** `backend.read("xx", "common")` returns 404 - **THEN** `i18n.backend.http.read_total` increments with `outcome="miss"` **Verified by:** `packages/i18n/i18n-backend-http/src/backend.spec-conformance.test.ts::"read emits duration histogram"` AND `packages/i18n/i18n-backend-http/src/backend.spec-conformance.test.ts::"404 increments read_total with outcome=miss"` --- ### Requirement: Subpath exports The package SHALL declare subpath exports so consumers can import only the surfaces they need: - `@suaveplan/i18n-backend-http` — root barrel - `@suaveplan/i18n-backend-http/backend` — `HttpBackend` class only - `@suaveplan/i18n-backend-http/factory` — `httpBackend` factory function only - `@suaveplan/i18n-backend-http/parser` — `defaultParsePayload` helper only - `@suaveplan/i18n-backend-http/url-builder` — `buildUrl` helper only #### Scenario: every subpath resolves - **WHEN** every documented subpath is imported in a consumer test fixture - **THEN** every import resolves with no `Module not found` errors **Verified by:** `packages/i18n/i18n-backend-http/src/index.test.ts::"every documented subpath resolves"` --- ### Requirement: Side-effect-free package The package SHALL declare `"sideEffects": false` in `package.json` and SHALL NOT depend on `@suaveplan/i18n` core, on `@suaveplan/retry`, nor on any other backend package. #### Scenario: package.json declares sideEffects false - **WHEN** `package.json` is parsed - **THEN** the `sideEffects` field equals `false` #### Scenario: package.json does not depend on i18n core or retry - **WHEN** `package.json` `dependencies` is parsed - **THEN** the result does NOT contain `@suaveplan/i18n` AND does NOT contain `@suaveplan/retry` **Verified by:** `packages/i18n/i18n-backend-http/src/index.test.ts::"package.json declares sideEffects false"` AND `packages/i18n/i18n-backend-http/src/index.test.ts::"package.json does not depend on i18n core or retry"`