Files
2026-05-27 23:29:53 +01:00

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. (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"