Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
# `@suaveplan/i18n-backend-http` — fetch-based translation backend
**Package**: `@suaveplan/i18n-backend-http` (NEW)
**Status**: PROPOSED
**Type**: feature (new universal-tier package; verbatim port from monolith)
**Date**: 2026-05-05
**Wave**: 5
**Design doc**: `openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md`
**Path**: `packages/i18n/i18n-backend-http/`
## Why
The Wave 5 backend split lifts each `Backend` implementation from the `@suaveplan/i18n` monolith into its own sibling package. The HTTP backend is the canonical production source for browser-tier consumers — it fetches `<loadPath>` per `(locale, namespace)`, maps HTTP 404 to `BACKEND_LOCALE_NOT_AVAILABLE` (chained-backend fallthrough signal) and HTTP 5xx / network errors to `BACKEND_READ_FAILED` (chain-stopping signal), supports retry-policy injection (structurally compatible with `@suaveplan/retry`'s `RetryPolicy`), and accepts custom payload parsers (JSON default; YAML / MessagePack / schema-validating parsers are caller-pluggable).
The `Backend` interface itself moves to `@suaveplan/types/i18n` in Wave 1 (zero-runtime types extension). `@suaveplan/i18n-backend-http` implements `Backend` from that location and does NOT depend on `@suaveplan/i18n` core. Runtime atoms (`I18nError`, `I18N_CODES`) come from `@suaveplan/i18n-shared`.
## What changes
### New package: `@suaveplan/i18n-backend-http`
- ADDED: `packages/i18n/i18n-backend-http/` package scaffold (package.json, tsconfig.json, vite.config.ts, README.md).
- ADDED: `src/backend.ts` — `HttpBackend` class implementing `Backend` from `@suaveplan/types/i18n`. Source: `packages/i18n/i18n/src/core/backend/http/http-backend.ts` L80-L240. Capabilities: `canRead: true`, `canReadMulti: false`, `canSave: false`, `canTrackModification: false`, `canWatch: false`. Read-only by design — write operations (`saveMissing` on the catalog side) are not surfaced.
- ADDED: `src/backend.types.ts` — `HttpBackendOptions`, `ParsePayloadFn`, `HttpHeadersInit`, `CustomHeadersFn`, `HttpBackendRetry`. Source: monolith L8-L70.
- ADDED: `src/backend.schemas.ts` — `HttpBackendOptionsSchema` Zod schema. Source: monolith `http-backend.schemas.ts` (40 lines).
- ADDED: `src/factory.ts` — `httpBackend(options: HttpBackendOptions): HttpBackend` factory. Source: monolith L266-L268.
- ADDED: `src/observability.ts` — emits `i18n.backend.http.read_duration_ms` (histogram), `i18n.backend.http.read_total{outcome}` (counter), `i18n.backend.http.read_errors_total{code,status?}` (counter). Histogram + counter pattern follows existing `recordRead` helper in monolith.
- ADDED: `src/url-builder.ts` — `buildUrl(loadPath, locale, namespace)` extracted as a standalone helper for tree-shaking + isolated testing. Source: monolith L220-L224 (`#buildUrl`).
- ADDED: `src/payload-parser.ts` — `defaultParsePayload` standalone helper (JSON parser with non-object rejection). Source: monolith L254-L263.
- ADDED: `src/backend.test.ts`, `src/backend.spec-conformance.test.ts`, `src/factory.test.ts`, `src/url-builder.test.ts`, `src/payload-parser.test.ts`, `src/index.test.ts` — Vitest suites verbatim-ported from monolith plus new spec-conformance suites.
- ADDED: `src/backend.md`, `src/factory.md`, `src/url-builder.md`, `src/payload-parser.md` — co-located docs.
- ADDED: `src/index.ts` — explicit named re-exports.
### Subpath exports
- `.` — root barrel
- `./backend` — `HttpBackend` class only
- `./factory` — `httpBackend` factory function only
- `./parser` — `defaultParsePayload` helper (consumers building custom parsers)
- `./url-builder` — `buildUrl` helper (consumers customising URL templating)
### Dependencies
- `@suaveplan/i18n-shared workspace:^` — for `I18N_CODES`, `I18nError`
- `@suaveplan/types workspace:^` — for `Backend`, `BackendCapabilities`, `TranslationRecord` from `@suaveplan/types/i18n`
- `@suaveplan/error workspace:^`
- `@opentelemetry/api catalog:observability`
- `tslib catalog:build`
- `zod catalog:forms` — for `HttpBackendOptionsSchema`
- **No** dep on `@suaveplan/i18n` core. **No** dep on any other backend package. **No** dep on `@suaveplan/retry` (the `HttpBackendRetry` interface is structural so consumers can pass `@suaveplan/retry`'s `RetryPolicy` directly without us taking a hard dep).
### Tier
Universal. Uses `globalThis.fetch` which is available in browsers, Bun, and Node.js >= 18. The `defaultFetch()` helper throws `BACKEND_READ_FAILED` with a clear message when `globalThis.fetch` is unavailable, prompting consumers on older runtimes to inject `fetchFn` explicitly.
## Out of scope
- **Removing the HTTP backend from `packages/i18n/i18n/src/core/backend/http/`** — happens in Wave 6.
- **Server-Sent Events / WebSocket-driven HTTP backend** — out of scope; consumers wire `EventBus` from `@suaveplan/events` against a separate transport.
- **HTTP-level caching (`Cache-Control` / `ETag` / `Last-Modified`)** — out of scope; consumers wire `ChainedBackend` with a `MemoryBackend` cache layer.
- **`save` / `lastModified` implementations** — explicit non-goals; the HTTP backend is read-only by design. Consumers who need write-back wire `MultiFileFsBackend` or `StorageBackend` behind the catalog.
## Source mapping
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
| `HttpBackend` class + capabilities matrix | `packages/i18n/i18n/src/core/backend/http/http-backend.ts` | L80-L116 | Verbatim port; rewire imports to `@suaveplan/i18n-shared` and `@suaveplan/types/i18n` |
| `HttpBackend.read` (404 → `BACKEND_LOCALE_NOT_AVAILABLE`, 5xx / network → `BACKEND_READ_FAILED`) | same file | L118-L205 | Verbatim port |
| `#fetchOnce` network-error classifier | same file | L207-L218 | Verbatim port |
| `#buildUrl` (lifted to `src/url-builder.ts`) | same file | L220-L224 | Extracted to standalone helper |
| `#buildInit` (request-init builder) | same file | L226-L239 | Inline private method on class |
| `defaultFetch()` — globalThis.fetch fallback | same file | L242-L252 | Verbatim port |
| `defaultParsePayload` (lifted to `src/payload-parser.ts`) | same file | L254-L263 | Extracted to standalone helper |
| `HttpBackendOptions`, `ParsePayloadFn`, `HttpHeadersInit`, `CustomHeadersFn`, `HttpBackendRetry` types | same file | L8-L70 | Verbatim port to `backend.types.ts` |
| `HttpBackendOptionsSchema` Zod schema | `packages/i18n/i18n/src/core/backend/http/http-backend.schemas.ts` | full file (40 lines) | Verbatim port |
| `httpBackend` factory | `packages/i18n/i18n/src/core/backend/http/http-backend.ts` | L266-L268 | Lifted to `factory.ts` |
| `recordRead` instrumentation pattern | `packages/i18n/i18n/src/observability/backend-metrics.ts` | full file | Reimplemented inline; metric names re-prefixed `i18n.backend.http.` |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Subpath exports `./backend`, `./factory`, `./parser`, `./url-builder` | Tree-shaking — consumers customising URL or payload parsing should pull only the helper they extend |
| `AbortSignal` parameter on `read` | Cancellation parity across backends; existing monolith does NOT thread `signal` into `fetch`, so this is a small surface improvement landing in the split |
## Verification approach
Each requirement carries a `**Verified by:**` line in the spec delta. Concrete test files:
- `packages/i18n/i18n-backend-http/src/backend.test.ts` — full `Backend` contract; 200 OK round-trip with stubbed `fetchFn`; 404 → `BACKEND_LOCALE_NOT_AVAILABLE`; 500 → `BACKEND_READ_FAILED`; network failure (TypeError from `fetchFn`) → `BACKEND_READ_FAILED`; custom `parsePayload` invoked; `customHeaders` callback invoked; `crossDomain: true` sets credentials + mode; injected `retry.run` wraps every fetch
- `packages/i18n/i18n-backend-http/src/backend.spec-conformance.test.ts` — every spec requirement mapped 1:1 to an `it(...)` title
- `packages/i18n/i18n-backend-http/src/factory.test.ts` — `httpBackend()` returns `HttpBackend` instance; defaults `name` to `"http"`
- `packages/i18n/i18n-backend-http/src/url-builder.test.ts` — `buildUrl` substitutes `{locale}` and `{namespace}`; URL-encodes both
- `packages/i18n/i18n-backend-http/src/payload-parser.test.ts` — `defaultParsePayload` accepts JSON object; rejects array, null, primitive
- `packages/i18n/i18n-backend-http/src/index.test.ts` — every documented public export resolves
## Impact
- New universal-tier package — joins the i18n family under `packages/i18n/`.
- No downstream consumer changes needed in Wave 5 — the slim core (Wave 6) re-exports `HttpBackend` and `httpBackend`.
- `MANIFEST.md` regenerated; `CLAUDE.md` package listing updated with a one-line entry.

View File

@@ -0,0 +1,233 @@
<!-- 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"`

View File

@@ -0,0 +1,88 @@
# Tasks — `@suaveplan/i18n-backend-http` (fetch-based translation backend)
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick.
## 1. Spec authoring
- [x] 1.1 Read `openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md` end-to-end (especially §3, §6, §6.1)
- [x] 1.2 Read `openspec/AGENTS.md` Sections 0, 0a, 6a, 7, 9, 13
- [x] 1.3 Read source: `packages/i18n/i18n/src/core/backend/http/http-backend.ts`, `http-backend.test.ts`, `http-backend.schemas.ts`, `http-backend.md`, plus `packages/i18n/i18n/src/core/backend/backend.types.ts`
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
- [x] 1.5 Fill `## Source mapping` table with verified line ranges from monolith
- [x] 1.6 Draft `specs/i18n-backend-http/spec.md` from `openspec/templates/spec-delta.md`
- [x] 1.7 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
- [x] 1.8 `openspec change validate i18n-backend-http` exits with no errors
## 2. Package scaffold
- [x] 2.1 `mkdir -p packages/i18n/i18n-backend-http/src`
- [x] 2.2 Author `package.json` — name `@suaveplan/i18n-backend-http`, version `0.1.0`, type `module`, sideEffects `false`. Deps: `@suaveplan/i18n-shared workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/error workspace:^`, `@opentelemetry/api catalog:observability`, `tslib catalog:build`, `zod catalog:forms`. devDeps: `@suaveplan/testing workspace:^`, `@suaveplan/typescript-config workspace:^`, `@vitest/coverage-istanbul catalog:testing`, `vitest catalog:testing`.
- [x] 2.3 Author `tsconfig.json` extending `@suaveplan/typescript-config/library.json`
- [x] 2.4 Author `vite.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`
- [x] 2.5 Author `README.md` (≥200 words, includes Modules table linking to each `.md`)
- [x] 2.6 Add `package.json` `exports` map with subpaths `.`, `./backend`, `./factory`, `./parser`, `./url-builder`
## 3. Implementation
- [x] 3.1 Author `src/backend.types.ts` — port `HttpBackendOptions`, `ParsePayloadFn`, `HttpHeadersInit`, `CustomHeadersFn`, `HttpBackendRetry` from monolith L8-L70 verbatim
- [x] 3.2 Author `src/backend.schemas.ts` — port `HttpBackendOptionsSchema` Zod schema verbatim (40 lines)
- [x] 3.3 Author `src/url-builder.ts` — extract `#buildUrl` body into a standalone `buildUrl(loadPath, locale, namespace): string` function with URL-encoding
- [x] 3.4 Author `src/payload-parser.ts` — extract `defaultParsePayload` ParsePayloadFn (JSON parse + non-object rejection) as a standalone export
- [x] 3.5 Author `src/observability.ts` — inline equivalents of `recordRead` from `packages/i18n/i18n/src/observability/backend-metrics.ts`; export `i18n.backend.http.read_duration_ms` histogram, `i18n.backend.http.read_total{outcome}` counter, `i18n.backend.http.read_errors_total{code,status?}` counter
- [x] 3.6 Author `src/backend.ts` — port `HttpBackend` class from monolith L80-L240; rewire imports; emit metrics via `src/observability.ts`; thread optional `signal?: AbortSignal` into the `RequestInit` passed to `fetch` AND throw `I18nError(I18N.BACKEND_TIMEOUT)` when the signal aborts during retry; preserve `defaultFetch()` fallback (L242-L252)
## 4. Factory function
- [x] 4.1 Author `src/factory.ts` — `httpBackend(options: HttpBackendOptions): HttpBackend` returning `new HttpBackend(options)`
## 5. Documentation (sibling `.md` per implementation file)
- [x] 5.1 `src/backend.md` — port from `packages/i18n/i18n/src/core/backend/http/http-backend.md`; ≥200 words; basic + advanced examples (custom parser, custom headers, retry policy)
- [x] 5.2 `src/factory.md` — ≥200 words; basic + advanced examples
- [x] 5.3 `src/url-builder.md` — placeholder substitution + URL-encoding semantics
- [x] 5.4 `src/payload-parser.md` — JSON-object rejection rules; how to plug a custom parser
- [x] 5.5 `src/observability.md` — emitted metrics + units + cardinality
- [x] 5.6 `README.md` `## Modules` section links every `.md`
## 6. Tests (Vitest, runner-neutral imports per CLAUDE.md §11)
- [x] 6.1 `src/backend.test.ts` — port every test from `packages/i18n/i18n/src/core/backend/http/http-backend.test.ts`; same `it(...)` titles for conformance traceability; cover 200 OK, 404 → `BACKEND_LOCALE_NOT_AVAILABLE`, 500 → `BACKEND_READ_FAILED`, TypeError network failure → `BACKEND_READ_FAILED`, payload-parse failure → `BACKEND_READ_FAILED`, custom `parsePayload`, `customHeaders` callback, `crossDomain: true`, injected retry policy
- [x] 6.2 `src/backend.spec-conformance.test.ts` — one `it(...)` per spec requirement; titles match `**Verified by:**` lines exactly
- [x] 6.3 `src/factory.test.ts` — `httpBackend()` returns `HttpBackend` instance; default `name === "http"`
- [x] 6.4 `src/url-builder.test.ts` — `{locale}` and `{namespace}` substitution; URL-encoding for special characters
- [x] 6.5 `src/payload-parser.test.ts` — JSON object accepted; array / null / primitive rejected with `BACKEND_READ_FAILED`
- [x] 6.6 `src/index.test.ts` — every documented public export resolves through `.`, `./backend`, `./factory`, `./parser`, `./url-builder`
- [x] 6.7 Add an AbortSignal cancellation test — pre-aborted signal causes `read` to throw `I18nError(I18N.BACKEND_TIMEOUT)`; mid-flight abort during retry also throws `BACKEND_TIMEOUT`
- [x] 6.8 Every `it(...)` carries `{ timeout: 5000 }`
- [x] 6.9 All test imports go through `@suaveplan/testing/runner` (CLAUDE.md §11) — verified by `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/i18n/i18n-backend-http/src/` returning zero hits
- [x] 6.10 No `// biome-ignore` suppressions (CLAUDE.md §9)
- [x] 6.11 No `export *` in any non-root barrel (CLAUDE.md §10)
## 7. Pre-archive verification (5-gate triple)
- [x] 7.1 `bun run check` — Biome 0/0/0 across the monorepo
- [x] 7.2 `bunx turbo lint typecheck test --filter=@suaveplan/i18n-backend-http` — green
- [x] 7.3 `bunx turbo test:coverage --filter=@suaveplan/i18n-backend-http` — 100/100/100/100
- [x] 7.4 `bun scripts/openspec-conformance.ts @suaveplan/i18n-backend-http` — exit 0
- [x] 7.5 `bunx turbo build --filter=@suaveplan/i18n-backend-http` — green
## 8. Pre-archive 7-row checklist (AGENTS.md Section 13)
- [x] 8.1 Generics threaded — `HttpBackendRetry.run<T>` parametric generic preserved; `ParsePayloadFn` correctly typed
- [x] 8.2 Zod schemas wired at the public boundary — `HttpBackendOptionsSchema` parses constructor input
- [x] 8.3 Every error code thrown by at least one production path: `INVALID_OPTIONS` (constructor), `BACKEND_LOCALE_NOT_AVAILABLE` (404), `BACKEND_READ_FAILED` (5xx / network / parse failure), `BACKEND_TIMEOUT` (signal aborted)
- [x] 8.4 Strict-mode tested — `RequestInit` shape verified via `expectTypeOf`
- [x] 8.5 OTel histograms emitted from production paths: `i18n.backend.http.read_duration_ms`; counters `read_total`, `read_errors_total{status}`
- [x] 8.6 Lifecycle dedup — N/A (HTTP backend has no async init/dispose)
- [x] 8.7 No retroactive tick — every box reflects work done in this change
## 9. Manifest + ecosystem awareness
- [x] 9.1 Add `@suaveplan/i18n-backend-http` entry to root `CLAUDE.md` package listing
- [x] 9.2 Run `bun scripts/folder-reorg/generate-manifest.ts` to refresh `MANIFEST.md`
- [x] 9.3 Append `@suaveplan/i18n-backend-http` to `STATUS.md` Completed list
## 10. Archive
- [x] 10.1 Fold spec delta into `openspec/specs/i18n-backend-http/spec.md` (NEW capability spec — first delta becomes canonical)
- [x] 10.2 Move `openspec/changes/i18n-backend-http/` → `openspec/archive/<yyyy-mm-dd>-i18n-backend-http/`