12 KiB
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. (Seeopenspec/AGENTS.mdSection 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" }).capabilitiesis 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" }).nameis 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
HttpBackendwhosefetchFnresolves toResponse(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
HttpBackendwhosefetchFnresolves toResponse(404) - WHEN
backend.read("xx", "common")is awaited - THEN the call throws
I18nErrorwith 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
HttpBackendwhosefetchFnresolves toResponse(500) - WHEN
backend.read("en", "common")is awaited - THEN the call throws
I18nErrorwith code"I18N.BACKEND_READ_FAILED"
Scenario: network failure maps to BACKEND_READ_FAILED
- GIVEN an
HttpBackendwhosefetchFnrejects withTypeError("network") - WHEN
backend.read("en", "common")is awaited - THEN the call throws
I18nErrorwith code"I18N.BACKEND_READ_FAILED"
Scenario: payload parse failure maps to BACKEND_READ_FAILED
- GIVEN an
HttpBackendwhosefetchFnresolves toResponse(200, "not-json") - WHEN
backend.read("en", "common")is awaited - THEN the call throws
I18nErrorwith 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
AbortControllerwhose signal is already aborted - WHEN
backend.read("en", "common", { signal })is awaited - THEN the call throws
I18nErrorwith 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
HttpBackendwithparsePayload: 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
I18nErrorwith 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
HttpBackendwithcustomHeaders: async (l, n) => ({ "x-locale": l, "x-namespace": n }) - WHEN
backend.read("en", "common")is awaited - THEN the captured
RequestInit.headerscontainsx-locale: enandx-namespace: common
Scenario: crossDomain sets credentials and mode
- GIVEN an
HttpBackendwithcrossDomain: true - WHEN
backend.read("en", "common")is awaited - THEN the captured
RequestInit.credentialsequals"include"ANDmodeequals"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
HttpBackendwithretry: { run: vi.fn(async (op) => op()) } - WHEN
backend.read("en", "common")is awaited - THEN
retry.runwas 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
I18nErrorwith 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_msis 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_totalincrements withoutcome="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—HttpBackendclass only@suaveplan/i18n-backend-http/factory—httpBackendfactory function only@suaveplan/i18n-backend-http/parser—defaultParsePayloadhelper only@suaveplan/i18n-backend-http/url-builder—buildUrlhelper only
Scenario: every subpath resolves
- WHEN every documented subpath is imported in a consumer test fixture
- THEN every import resolves with no
Module not founderrors
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.jsonis parsed - THEN the
sideEffectsfield equalsfalse
Scenario: package.json does not depend on i18n core or retry
- WHEN
package.jsondependenciesis parsed - THEN the result does NOT contain
@suaveplan/i18nAND 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"