Files
suaveplan/openspec/archive/2026-05-09-i18n-backend-http/specs/i18n-backend-http/spec.md
2026-05-27 23:29:53 +01:00

234 lines
12 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec: i18n-backend-http (i18n-backend-http)
**Package**: `@suaveplan/i18n-backend-http`
**Status**: PROPOSED — first delta; becomes canonical spec on archive.
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED 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"`