Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
# @suaveplan/i18n-backend-http - Universal translation backend backed by injectable fetch. Read-only; maps HTTP 404 to BACKEND_LOCALE_NOT_AVAILABLE (chain fallthrough) and HTTP 5xx / network / parse failures to BACKEND_READ_FAILED (chain stop). Pluggable parsePayload + customHeaders + retry policy + AbortSignal cancellation, OpenTelemetry instrumentation under i18n.backend.http.*. Zero runtime dependencies on @suaveplan/i18n core.
## Summary
Universal translation backend for the `@suaveplan/i18n` family,
backed by an injectable `fetch`. Read-only by design: maps HTTP 404 to
`BACKEND_LOCALE_NOT_AVAILABLE` (the chained-backend fallthrough signal)
and HTTP 5xx, network failures, and parse failures to
`BACKEND_READ_FAILED` (a chain-stopping signal that prevents silent
degradation when the transport is broken). Pluggable `parsePayload`,
`customHeaders`, `requestOptions`, `crossDomain` shorthand, retry-policy
injection (structurally compatible with `@suaveplan/retry`'s
`RetryPolicy`), `AbortSignal` cancellation, and OpenTelemetry
instrumentation under `i18n.backend.http.*`. Zero runtime dependency on
`@suaveplan/i18n` core.
<!-- begin:auto-docs -->
## Documentation tree
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ - [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_
## Why this exists
The Wave 5 modular split lifts each backend out of the
`@suaveplan/i18n` monolith into its own sibling so consumers can install
only the backends they need. The HTTP backend is the canonical
production source for browser-tier and edge consumers fetching
translations from a CDN, an internal i18n service, or a pre-signed
storage URL. Keeping it in its own package avoids dragging
`@suaveplan/storage` (storage backend) or `node:fs` (multifile-fs
backend) into browser bundles.
## Install
```bash
bun add @suaveplan/i18n-backend-http
```
## Quick Start
```ts
import { httpBackend } from "@suaveplan/i18n-backend-http";
const backend = httpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
});
const record = await backend.read("en", "auth");
```
## Modules
| Subpath | Module |
| --- | --- |
| `@suaveplan/i18n-backend-http/backend` | [`HttpBackend`](./src/backend/backend.md) |
| `@suaveplan/i18n-backend-http/factory` | [`httpBackend`](./src/factory/factory.md) |
| `@suaveplan/i18n-backend-http/observability` | [OTel instruments](./src/observability/observability.md) |
## Capabilities
```ts
{
canRead: true,
canReadMulti: false,
canSave: false,
canTrackModification: false,
canWatch: false,
}
```
## Error semantics
- HTTP 404 → `I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE)` — chained
backend SHOULD fall through.
- HTTP 5xx / non-2xx non-404 → `I18nError(I18N.BACKEND_READ_FAILED)` —
chained backend MUST propagate.
- Network failure (`TypeError` from `fetch`) →
`I18nError(I18N.BACKEND_READ_FAILED)`.
- AbortError mid-flight → `I18nError(I18N.BACKEND_TIMEOUT)`.
- Pre-aborted `signal` → `I18nError(I18N.BACKEND_TIMEOUT)` (no
network request issued).
- Parse failure → `I18nError(I18N.BACKEND_READ_FAILED)`.
- Invalid options → `I18nError(I18N.INVALID_OPTIONS)` at construction.
## Tier
Universal — runs in browsers, Bun, and Node.js >= 18. On older Node
runtimes lacking `globalThis.fetch`, `defaultFetch()` returns a stub
that throws `BACKEND_READ_FAILED` with an explicit message instructing
consumers to inject `fetchFn`.

View File

@@ -0,0 +1,82 @@
{
"name": "@suaveplan/i18n-backend-http",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal translation backend backed by injectable fetch. Read-only; maps HTTP 404 to BACKEND_LOCALE_NOT_AVAILABLE (chain fallthrough) and HTTP 5xx / network / parse failures to BACKEND_READ_FAILED (chain stop). Pluggable parsePayload + customHeaders + retry policy + AbortSignal cancellation, OpenTelemetry instrumentation under i18n.backend.http.*. Zero runtime dependencies on @suaveplan/i18n core.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./backend": {
"import": {
"types": "./dist/backend/index.d.ts",
"default": "./dist/backend/index.js"
}
},
"./factory": {
"import": {
"types": "./dist/factory/index.d.ts",
"default": "./dist/factory/index.js"
}
},
"./observability": {
"import": {
"types": "./dist/observability/index.d.ts",
"default": "./dist/observability/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/i18n-shared": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@suaveplan/typescript-config": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,119 @@
# `HttpBackend`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`HttpBackend` is the canonical browser-tier translation source for the
`@suaveplan/i18n` family. It fetches `<loadPath>` per `(locale,
namespace)` via an injectable `fetchFn` (defaulting to
`globalThis.fetch`) and parses the response body via an injectable
`parsePayload` (defaulting to a JSON-object parser that rejects arrays,
null, and primitive payloads). HTTP 404 maps to
`BACKEND_LOCALE_NOT_AVAILABLE` — the chained-backend fallthrough signal
— while HTTP 5xx, network failures, and parse failures map to
`BACKEND_READ_FAILED`, the chain-stopping signal that prevents silent
degradation when the transport itself is broken.
The backend is read-only by design (`canSave: false`,
`canTrackModification: false`, `canWatch: false`). Consumers needing
write-back wire `MultiFileFsBackend` or `StorageBackend` behind the
catalog instead. Universal — runs identically in browsers, Bun,
and Node.js >= 18; older Node runtimes get a clear
`BACKEND_READ_FAILED` from `defaultFetch()` instructing them to inject
`fetchFn`.
## Features
- 5-flag capability matrix `{ canRead: true, canReadMulti: false,
canSave: false, canTrackModification: false, canWatch: false }`.
- URL templating via `{locale}` / `{namespace}` placeholders with
`encodeURIComponent` per segment.
- Pluggable `parsePayload` accepts non-JSON bodies (YAML, MessagePack,
schema-validated JSON).
- Pluggable `customHeaders` builder per request.
- `crossDomain: true` shorthand for `credentials: "include"` +
`mode: "cors"` (preserves explicit overrides in `requestOptions`).
- Optional `retry?: HttpBackendRetry` (structurally compatible with
`@suaveplan/retry`'s `RetryPolicy`) wraps every fetch.
- Optional `signal?: AbortSignal` — pre-aborted signals throw
`BACKEND_TIMEOUT` without issuing a network request; mid-flight
AbortError surfaces as `BACKEND_TIMEOUT`; the signal is threaded into
the underlying `RequestInit` so `fetch` can cancel mid-flight.
- Zod boundary validation throws `INVALID_OPTIONS` at construction.
- Per-call OTel instrumentation under `i18n.backend.http.*`.
## Usage: Basic Example
```ts
import { HttpBackend } from "@suaveplan/i18n-backend-http";
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
});
const record = await backend.read("en", "auth");
```
## Usage: Advanced Example
```ts
import { HttpBackend } from "@suaveplan/i18n-backend-http";
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.yaml",
crossDomain: true,
customHeaders: (locale) => ({ "x-locale": locale }),
parsePayload: async (res) => parseYaml(await res.text()),
retry: {
run: async (op) => {
for (let i = 0; i < 3; i++) {
try {
return await op();
} catch (err) {
if (i === 2) throw err;
}
}
throw new Error("unreachable");
},
},
});
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 2_000);
const record = await backend.read("en", "auth", ctrl.signal);
```
## API Reference
### `class HttpBackend implements Backend`
- `new HttpBackend(options: HttpBackendOptions)` — Zod-validated;
throws `I18nError(I18N.INVALID_OPTIONS)` on malformed input.
- `read(locale, namespace, signal?): Promise<TranslationRecord>` —
fetches and parses the URL. Throws `BACKEND_LOCALE_NOT_AVAILABLE` on
404; `BACKEND_READ_FAILED` on 5xx / network / parse failures;
`BACKEND_TIMEOUT` on signal abort.
### Helpers
- `buildUrl(loadPath, locale, namespace): string` — substitutes
`{locale}` and `{namespace}` with `encodeURIComponent`-applied
values.
- `defaultParsePayload: ParsePayloadFn` — JSON object parser. Rejects
arrays, null, and primitives as `BACKEND_READ_FAILED`.
- `HttpBackendOptionsSchema: z.ZodObject` — Zod boundary schema.
## Implementation Notes
The class delegates to a private `#fetchOnce` helper that classifies
network failures: `AbortError` / `ABORT_ERR` codes surface as
`BACKEND_TIMEOUT` (so consumers can distinguish cancellation from
transport faults), and `I18nError(I18N.BACKEND_TIMEOUT)` thrown from
inside `fetchFn` (e.g. an injected fake) propagates unchanged.
Everything else from the network surface — `TypeError`, refused
connections, DNS failures, CORS preflights — maps to
`BACKEND_READ_FAILED`. Capability flags stay `false` for write paths
because the HTTP transport has no canonical `PUT` semantics across
CDNs; consumers needing write-back wire a different backend.

View File

@@ -0,0 +1,649 @@
import { I18nError } from "@suaveplan/i18n-shared";
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
buildUrl,
defaultParsePayload,
HttpBackend,
HttpBackendOptionsSchema,
} from "./backend.js";
function makeFetch(handler: (url: string, init?: RequestInit) => Response) {
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
const fn: typeof fetch = async (input, init) => {
const url = typeof input === "string" ? input : input.toString();
calls.push({ url, init });
return handler(url, init);
};
return { fn, calls };
}
// ── Conformance harness ──────────────────────────────────────────────
runBackendConformance("HttpBackend", async () => {
const { fn } = makeFetch((url) => {
if (url.includes("/missing") || url.includes("/xx/")) {
return new Response("not found", { status: 404 });
}
return new Response("{}", { status: 200 });
});
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
});
return { backend };
});
describe("HttpBackend specifics", () => {
it("capabilities matrix is read-only", { timeout: 5000 }, () => {
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: makeFetch(() => new Response("{}")).fn,
});
expect(backend.capabilities).toEqual({
canRead: true,
canReadMulti: false,
canSave: false,
canTrackModification: false,
canWatch: false,
});
});
it("default name is http", { timeout: 5000 }, () => {
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: makeFetch(() => new Response("{}")).fn,
});
expect(backend.name).toBe("http");
});
it("custom name override", { timeout: 5000 }, () => {
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: makeFetch(() => new Response("{}")).fn,
name: "cdn",
});
expect(backend.name).toBe("cdn");
});
it("substitutes {locale} and {namespace} in loadPath", {
timeout: 5000,
}, async () => {
const { fn, calls } = makeFetch(
() =>
new Response(JSON.stringify({ login: { title: "Sign in" } }), {
status: 200,
})
);
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
});
await backend.read("en", "auth");
expect(calls[0]?.url).toBe("/i18n/en/auth.json");
});
it("URL-encodes locale and namespace path segments", {
timeout: 5000,
}, async () => {
const { fn, calls } = makeFetch(
() => new Response(JSON.stringify({}), { status: 200 })
);
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
});
await backend.read("en-US", "auth/v2");
expect(calls[0]?.url).toBe("/i18n/en-US/auth%2Fv2.json");
});
it("200 OK returns parsed JSON", { timeout: 5000 }, async () => {
const { fn } = makeFetch(
() => new Response(JSON.stringify({ hello: "Hi" }), { status: 200 })
);
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
});
expect(await backend.read("en", "common")).toEqual({ hello: "Hi" });
});
it("404 maps to BACKEND_LOCALE_NOT_AVAILABLE", {
timeout: 5000,
}, async () => {
const { fn } = makeFetch(() => new Response("", { status: 404 }));
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
});
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
);
expect((err as I18nError).context).toMatchObject({ status: 404 });
}
});
it("500 maps to BACKEND_READ_FAILED", { timeout: 5000 }, async () => {
const { fn } = makeFetch(() => new Response("", { status: 500 }));
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
});
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
expect((err as I18nError).context).toMatchObject({ status: 500 });
}
});
it("network failure maps to BACKEND_READ_FAILED", {
timeout: 5000,
}, async () => {
const fn: typeof fetch = async () => {
throw new TypeError("network unreachable");
};
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
});
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
}
});
it("payload parse failure maps to BACKEND_READ_FAILED", {
timeout: 5000,
}, async () => {
const { fn } = makeFetch(
() => new Response("not json", { status: 200 })
);
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
});
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
expect((err as I18nError).cause).toBeDefined();
}
});
it("parsePayload may throw I18nError directly", {
timeout: 5000,
}, async () => {
const { fn } = makeFetch(() => new Response("{}", { status: 200 }));
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
parsePayload: async () => {
throw new I18nError(
"I18N.BACKEND_READ_FAILED",
"custom parser rejection"
);
},
});
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
expect((err as I18nError).message).toContain(
"custom parser rejection"
);
}
});
it("custom parsePayload is invoked", { timeout: 5000 }, async () => {
const { fn } = makeFetch(
() =>
new Response("login.title=Sign in\n", {
status: 200,
headers: { "content-type": "text/plain" },
})
);
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.properties",
fetchFn: fn,
parsePayload: async (response) => {
const text = await response.text();
const out: Record<string, string> = {};
for (const line of text.split("\n")) {
const idx = line.indexOf("=");
if (idx > 0)
out[line.slice(0, idx).trim()] = line
.slice(idx + 1)
.trim();
}
return out;
},
});
expect(await backend.read("en", "auth")).toEqual({
"login.title": "Sign in",
});
});
it("customHeaders is invoked", { timeout: 5000 }, async () => {
const { fn, calls } = makeFetch(
() => new Response("{}", { status: 200 })
);
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
customHeaders: (locale, namespace) => ({
"x-locale": locale,
"x-namespace": namespace,
}),
});
await backend.read("en", "auth");
expect(calls[0]?.init?.headers).toEqual({
"x-locale": "en",
"x-namespace": "auth",
});
});
it("crossDomain sets credentials and mode", { timeout: 5000 }, async () => {
const { fn, calls } = makeFetch(
() => new Response("{}", { status: 200 })
);
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
crossDomain: true,
});
await backend.read("en", "auth");
expect(calls[0]?.init?.credentials).toBe("include");
expect(calls[0]?.init?.mode).toBe("cors");
});
it("requestOptions credentials are preserved when crossDomain is set", {
timeout: 5000,
}, async () => {
const { fn, calls } = makeFetch(
() => new Response("{}", { status: 200 })
);
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
crossDomain: true,
requestOptions: { credentials: "same-origin" },
});
await backend.read("en", "auth");
expect(calls[0]?.init?.credentials).toBe("same-origin");
});
it("retry.run wraps the fetch operation", { timeout: 5000 }, async () => {
let attempts = 0;
const fn: typeof fetch = async () => {
attempts++;
if (attempts < 2) throw new TypeError("transient");
return new Response("{}", { status: 200 });
};
let runCalls = 0;
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
retry: {
run: async (op) => {
runCalls++;
try {
return await op();
} catch {
return await op();
}
},
},
});
await backend.read("en", "auth");
expect(runCalls).toBe(1);
expect(attempts).toBe(2);
});
it("rejects non-object JSON payloads as BACKEND_READ_FAILED", {
timeout: 5000,
}, async () => {
const { fn } = makeFetch(
() => new Response('"a string"', { status: 200 })
);
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
});
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
}
});
it("rejects array JSON payloads as BACKEND_READ_FAILED", {
timeout: 5000,
}, async () => {
const { fn } = makeFetch(
() => new Response("[1, 2, 3]", { status: 200 })
);
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
});
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
}
});
it("invalid options throw INVALID_OPTIONS", { timeout: 5000 }, () => {
try {
new HttpBackend({
loadPath: "",
fetchFn: makeFetch(() => new Response("")).fn,
});
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
}
});
it("invalid loadPath type throws INVALID_OPTIONS", {
timeout: 5000,
}, () => {
try {
new HttpBackend({
loadPath: 42 as unknown as string,
});
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
}
});
it("pre-aborted signal aborts read with BACKEND_TIMEOUT", {
timeout: 5000,
}, async () => {
const ctrl = new AbortController();
ctrl.abort();
const { fn } = makeFetch(() => new Response("{}", { status: 200 }));
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
});
try {
await backend.read("en", "auth", ctrl.signal);
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.BACKEND_TIMEOUT");
}
});
it("mid-flight AbortError surfaces as BACKEND_TIMEOUT", {
timeout: 5000,
}, async () => {
const fn: typeof fetch = async () => {
const err = new Error("aborted");
err.name = "AbortError";
throw err;
};
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
});
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.BACKEND_TIMEOUT");
}
});
it("mid-flight ABORT_ERR code surfaces as BACKEND_TIMEOUT", {
timeout: 5000,
}, async () => {
const fn: typeof fetch = async () => {
const err = Object.assign(new Error("aborted"), {
code: "ABORT_ERR",
});
throw err;
};
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
});
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.BACKEND_TIMEOUT");
}
});
it("fetchFn rethrowing I18nError(BACKEND_TIMEOUT) propagates unchanged", {
timeout: 5000,
}, async () => {
const fn: typeof fetch = async () => {
throw new I18nError("I18N.BACKEND_TIMEOUT", "synthetic timeout");
};
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
});
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.BACKEND_TIMEOUT");
expect((err as I18nError).message).toContain("synthetic timeout");
}
});
it("AbortSignal is threaded into RequestInit", {
timeout: 5000,
}, async () => {
const { fn, calls } = makeFetch(
() => new Response("{}", { status: 200 })
);
const ctrl = new AbortController();
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
});
await backend.read("en", "auth", ctrl.signal);
expect(calls[0]?.init?.signal).toBe(ctrl.signal);
});
it("uses default fetch fallback when globalThis.fetch is missing", {
timeout: 5000,
}, async () => {
const original = globalThis.fetch;
(globalThis as { fetch: typeof fetch | undefined }).fetch = undefined;
try {
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
});
await expect(backend.read("en", "auth")).rejects.toBeInstanceOf(
I18nError
);
} finally {
globalThis.fetch = original;
}
});
it("uses real globalThis.fetch when no fetchFn supplied (smoke)", {
timeout: 5000,
}, () => {
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
});
expect(backend.name).toBe("http");
});
it("retry policy receives signal when one is supplied", {
timeout: 5000,
}, async () => {
const { fn } = makeFetch(() => new Response("{}", { status: 200 }));
const ctrl = new AbortController();
let receivedSignal: AbortSignal | undefined;
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
retry: {
run: async (op, opts) => {
receivedSignal = opts?.signal;
return op();
},
},
});
await backend.read("en", "auth", ctrl.signal);
expect(receivedSignal).toBe(ctrl.signal);
});
it("customHeaders throwing I18nError propagates with original code", {
timeout: 5000,
}, async () => {
const { fn } = makeFetch(() => new Response("{}", { status: 200 }));
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
customHeaders: () => {
throw new I18nError(
"I18N.INVALID_OPTIONS",
"header build rejected"
);
},
});
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
}
});
it("customHeaders rejection propagates as caught error", {
timeout: 5000,
}, async () => {
const { fn } = makeFetch(() => new Response("{}", { status: 200 }));
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
customHeaders: () => {
throw new Error("header build failed");
},
});
await expect(backend.read("en", "auth")).rejects.toThrow(
"header build failed"
);
});
it("non-Error thrown from fetchFn maps to BACKEND_READ_FAILED", {
timeout: 5000,
}, async () => {
const fn: typeof fetch = async () => {
const x: unknown = "not an error instance";
throw x;
};
const backend = new HttpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: fn,
});
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
}
});
it("HttpBackendOptionsSchema parses a minimal valid input", {
timeout: 5000,
}, () => {
const result = HttpBackendOptionsSchema.safeParse({
loadPath: "/x/{locale}/{namespace}.json",
});
expect(result.success).toBe(true);
});
});
describe("buildUrl helper", () => {
it("substitutes both placeholders", { timeout: 5000 }, () => {
expect(
buildUrl("/p/{locale}/{namespace}.json", "en-GB", "common")
).toBe("/p/en-GB/common.json");
});
it("URL-encodes special characters", { timeout: 5000 }, () => {
expect(
buildUrl("/p/{locale}/{namespace}.json", "en GB", "errors/auth")
).toBe("/p/en%20GB/errors%2Fauth.json");
});
it("substitutes every placeholder occurrence", {
timeout: 5000,
}, () => {
expect(
buildUrl("/p/{locale}/{locale}/{namespace}.json", "en", "common")
).toBe("/p/en/en/common.json");
});
});
describe("defaultParsePayload helper", () => {
it("accepts a JSON object payload", { timeout: 5000 }, async () => {
const response = new Response(JSON.stringify({ hello: "Hi" }), {
status: 200,
});
expect(await defaultParsePayload(response, "en", "common")).toEqual({
hello: "Hi",
});
});
it("rejects array payload", { timeout: 5000 }, async () => {
const response = new Response("[1,2,3]", { status: 200 });
try {
await defaultParsePayload(response, "en", "common");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
}
});
it("rejects null payload", { timeout: 5000 }, async () => {
const response = new Response("null", { status: 200 });
await expect(
defaultParsePayload(response, "en", "common")
).rejects.toBeInstanceOf(I18nError);
});
it("rejects primitive payload", { timeout: 5000 }, async () => {
const response = new Response('"hi"', { status: 200 });
await expect(
defaultParsePayload(response, "en", "common")
).rejects.toBeInstanceOf(I18nError);
});
});

View File

@@ -0,0 +1,327 @@
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
import type {
Backend,
BackendCapabilities,
TranslationRecord,
} from "@suaveplan/types";
import z from "zod";
import { recordRead } from "../observability/observability.js";
import type {
CustomHeadersFn,
HttpBackendOptions,
HttpBackendRetry,
ParsePayloadFn,
} from "./backend.types.js";
/**
* Zod boundary schema for {@link HttpBackendOptions}. Validates only the
* statically-checkable shape; injected functions (`fetchFn`,
* `parsePayload`, `customHeaders`, `retry`) are accepted as opaque
* `z.unknown()` because Zod cannot meaningfully type-check function
* arity. Caller-supplied option values failing this schema cause the
* constructor to throw `I18nError(I18N.INVALID_OPTIONS)`.
*/
export const HttpBackendOptionsSchema = z.object({
loadPath: z
.string()
.min(1, "loadPath must contain at least the {locale} placeholder"),
name: z.string().min(1).optional(),
fetchFn: z.unknown().optional(),
parsePayload: z.unknown().optional(),
customHeaders: z.unknown().optional(),
requestOptions: z
.object({
method: z.string().optional(),
credentials: z.enum(["omit", "same-origin", "include"]).optional(),
mode: z
.enum(["cors", "no-cors", "same-origin", "navigate"])
.optional(),
cache: z
.enum([
"default",
"no-store",
"reload",
"no-cache",
"force-cache",
"only-if-cached",
])
.optional(),
})
.optional(),
crossDomain: z.boolean().optional(),
retry: z.unknown().optional(),
});
export type HttpBackendOptionsInput = z.input<typeof HttpBackendOptionsSchema>;
/**
* Default JSON parser — accepts a JSON object payload, rejects arrays,
* null, and primitive values as `BACKEND_READ_FAILED` (translation
* records are always objects in the contract).
*/
export const defaultParsePayload: ParsePayloadFn = async (response) => {
const json = (await response.json()) as unknown;
if (typeof json !== "object" || json === null || Array.isArray(json)) {
throw new I18nError(
I18N_CODES.BACKEND_READ_FAILED,
"HttpBackend: payload is not a TranslationRecord object"
);
}
return json as TranslationRecord;
};
/**
* Substitute `{locale}` and `{namespace}` placeholders in `loadPath`,
* URL-encoding each segment via `encodeURIComponent`.
*/
export function buildUrl(
loadPath: string,
locale: string,
namespace: string
): string {
return loadPath
.replaceAll("{locale}", encodeURIComponent(locale))
.replaceAll("{namespace}", encodeURIComponent(namespace));
}
/**
* Resolve the default `fetch` binding. When `globalThis.fetch` is
* unavailable (older Node, non-Node environments without DOM), returns
* a stub that throws `BACKEND_READ_FAILED` with a clear message
* prompting consumers to inject `fetchFn` explicitly.
*/
function defaultFetch(): typeof fetch {
if (typeof globalThis.fetch !== "function") {
return (async () => {
throw new I18nError(
I18N_CODES.BACKEND_READ_FAILED,
"HttpBackend: globalThis.fetch is unavailable; supply fetchFn"
);
}) as typeof fetch;
}
return globalThis.fetch.bind(globalThis);
}
/**
* HTTP-backed translation source.
*
* Universal — runs in browsers, Bun, and Node.js >= 18. Read-only
* by design (`canSave: false`). Maps HTTP 404 to
* `BACKEND_LOCALE_NOT_AVAILABLE` (the chained-backend fallthrough
* signal); HTTP 5xx, network failures, and parse failures to
* `BACKEND_READ_FAILED` (a chain-stopping signal — transport failures
* must propagate, not silently degrade). Pluggable `parsePayload`
* accepts non-JSON payloads (YAML, MessagePack, schema-validating
* JSON). Optional injectable `retry` policy wraps every fetch.
* Optional `signal?: AbortSignal` parameter on `read` throws
* `BACKEND_TIMEOUT` on pre-aborted signals and is threaded into the
* underlying `RequestInit` so `fetch` can cancel mid-flight.
*/
export class HttpBackend implements Backend {
public readonly name: string;
public readonly capabilities: BackendCapabilities = {
canRead: true,
canReadMulti: false,
canSave: false,
canTrackModification: false,
canWatch: false,
};
readonly #loadPath: string;
readonly #fetchFn: typeof fetch;
readonly #parsePayload: ParsePayloadFn;
readonly #customHeaders: CustomHeadersFn | undefined;
readonly #requestOptions: RequestInit | undefined;
readonly #crossDomain: boolean;
readonly #retry: HttpBackendRetry | undefined;
public constructor(options: HttpBackendOptions) {
const parsed = HttpBackendOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new I18nError(
I18N_CODES.INVALID_OPTIONS,
"HttpBackend: invalid options",
{
cause: parsed.error,
context: { issues: parsed.error.issues },
}
);
}
this.name = options.name ?? "http";
this.#loadPath = options.loadPath;
this.#fetchFn = options.fetchFn ?? defaultFetch();
this.#parsePayload = options.parsePayload ?? defaultParsePayload;
this.#customHeaders = options.customHeaders;
this.#requestOptions = options.requestOptions;
this.#crossDomain = options.crossDomain ?? false;
this.#retry = options.retry;
}
public async read(
locale: string,
namespace: string,
signal?: AbortSignal
): Promise<TranslationRecord> {
this.#checkAborted(signal);
const t0 = performance.now();
const url = buildUrl(this.#loadPath, locale, namespace);
let outcome: "hit" | "miss" | "error" = "hit";
let errorCode: string | undefined;
let errorStatus: number | undefined;
try {
const init = await this.#buildInit(locale, namespace, signal);
const fire = async () => await this.#fetchOnce(url, init);
const response = this.#retry
? await this.#retry.run(
fire,
signal !== undefined ? { signal } : undefined
)
: await fire();
if (response.status === 404) {
outcome = "miss";
errorCode = I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE;
errorStatus = 404;
throw new I18nError(
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE,
`HttpBackend: ${url} returned 404`,
{
context: {
locale,
namespace,
url,
status: 404,
backend: this.name,
},
}
);
}
if (!response.ok) {
outcome = "error";
errorCode = I18N_CODES.BACKEND_READ_FAILED;
errorStatus = response.status;
throw new I18nError(
I18N_CODES.BACKEND_READ_FAILED,
`HttpBackend: ${url} returned ${response.status}`,
{
context: {
locale,
namespace,
url,
status: response.status,
backend: this.name,
},
}
);
}
try {
return await this.#parsePayload(response, locale, namespace);
} catch (cause) {
outcome = "error";
errorCode = I18N_CODES.BACKEND_READ_FAILED;
if (cause instanceof I18nError) throw cause;
throw new I18nError(
I18N_CODES.BACKEND_READ_FAILED,
`HttpBackend: parse failed for ${url}`,
{
cause,
context: {
locale,
namespace,
url,
backend: this.name,
},
}
);
}
} catch (err) {
if (outcome === "hit") {
outcome = "error";
if (err instanceof I18nError) {
errorCode = err.code;
}
}
throw err;
} finally {
recordRead(
locale,
namespace,
outcome,
performance.now() - t0,
errorCode,
errorStatus
);
}
}
async #fetchOnce(url: string, init: RequestInit): Promise<Response> {
try {
return await this.#fetchFn(url, init);
} catch (cause) {
if (
cause instanceof I18nError &&
cause.code === I18N_CODES.BACKEND_TIMEOUT
) {
throw cause;
}
// AbortError surfaced from fetch when its signal aborts.
const causeCode =
cause instanceof Error
? (cause as Error & { code?: string }).code
: undefined;
const isAbort =
cause instanceof Error &&
(cause.name === "AbortError" || causeCode === "ABORT_ERR");
if (isAbort) {
throw new I18nError(
I18N_CODES.BACKEND_TIMEOUT,
`HttpBackend: fetch aborted for ${url}`,
{ cause, context: { url, backend: this.name } }
);
}
// TypeError on network failure (DNS, CORS preflight, refused).
throw new I18nError(
I18N_CODES.BACKEND_READ_FAILED,
`HttpBackend: fetch failed for ${url}`,
{ cause, context: { url, backend: this.name } }
);
}
}
async #buildInit(
locale: string,
namespace: string,
signal: AbortSignal | undefined
): Promise<RequestInit> {
const headers = this.#customHeaders
? await this.#customHeaders(locale, namespace)
: undefined;
const init: RequestInit = {
...(this.#requestOptions ?? {}),
...(headers ? { headers } : {}),
};
if (this.#crossDomain) {
init.credentials = init.credentials ?? "include";
init.mode = init.mode ?? "cors";
}
if (signal !== undefined) {
init.signal = signal;
}
return init;
}
#checkAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw new I18nError(
I18N_CODES.BACKEND_TIMEOUT,
"HttpBackend: operation aborted",
{
context: { backend: this.name, reason: signal.reason },
}
);
}
}
}

View File

@@ -0,0 +1,61 @@
import type { TranslationRecord } from "@suaveplan/types";
/**
* Parses the HTTP `Response` body into a `TranslationRecord`. Default
* implementation does `await res.json()` and rejects non-object payloads
* (arrays, null, primitives) as `BACKEND_READ_FAILED`. Override to parse
* YAML, MessagePack, or to apply schema validation.
*/
export type ParsePayloadFn = (
response: Response,
locale: string,
namespace: string
) => Promise<TranslationRecord>;
/** Local alias for HeadersInit so we don't depend on DOM lib types. */
export type HttpHeadersInit = Headers | Record<string, string>;
/** Per-request header builder. Receives `(locale, namespace)`. */
export type CustomHeadersFn = (
locale: string,
namespace: string
) => HttpHeadersInit | Promise<HttpHeadersInit>;
/**
* Optional retry policy. Structural so callers can plug
* `@suaveplan/retry`'s `RetryPolicy` directly without us taking a hard
* dep.
*/
export interface HttpBackendRetry {
readonly run: <T>(
operation: () => Promise<T>,
options?: { signal?: AbortSignal }
) => Promise<T>;
}
/**
* Constructor options for {@link HttpBackend}.
*
* - `loadPath` — required URL template with `{locale}` and `{namespace}`
* placeholders. Example: `/locales/{locale}/{namespace}.json`.
* - `name` — backend label override; defaults to `"http"`.
* - `fetchFn` — injectable fetch; defaults to `globalThis.fetch`.
* - `parsePayload` — custom payload parser; defaults to
* `defaultParsePayload` (JSON object).
* - `customHeaders` — per-request header builder.
* - `requestOptions` — static request options merged into every fetch
* call.
* - `crossDomain` — when `true`, sets `credentials: "include"` and
* `mode: "cors"` on every request unless already set.
* - `retry` — optional retry policy wrapping every fetch.
*/
export interface HttpBackendOptions {
readonly loadPath: string;
readonly name?: string;
readonly fetchFn?: typeof fetch;
readonly parsePayload?: ParsePayloadFn;
readonly customHeaders?: CustomHeadersFn;
readonly requestOptions?: RequestInit;
readonly crossDomain?: boolean;
readonly retry?: HttpBackendRetry;
}

View File

@@ -0,0 +1,17 @@
// Feature barrel for `@suaveplan/i18n-backend-http/backend`. Explicit
// named re-exports only — no `export *` per CLAUDE.md §10.
export {
buildUrl,
defaultParsePayload,
HttpBackend,
type HttpBackendOptionsInput,
HttpBackendOptionsSchema,
} from "./backend.js";
export type {
CustomHeadersFn,
HttpBackendOptions,
HttpBackendRetry,
HttpHeadersInit,
ParsePayloadFn,
} from "./backend.types.js";

View File

@@ -0,0 +1,76 @@
# `httpBackend(options)`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`httpBackend(options)` is the builder-style factory for
[`HttpBackend`](../backend/backend.md). It constructs a fresh
`HttpBackend` instance per call — no singletons, no shared state, no
caching of options or fetch bindings — and surfaces the same Zod
boundary validation and capability matrix as the constructor.
Consumers who prefer composition over `new` reach for this factory; the
two forms are interchangeable. Both exist because i18n-family packages
historically preferred named factories for ergonomic test fixtures and
dependency-injection setups.
## Features
- Zero-argument-overhead alias for `new HttpBackend(options)`.
- Returns a fresh `HttpBackend` per call (no implicit caching).
- Throws `I18nError(I18N.INVALID_OPTIONS)` on malformed input (Zod
boundary). Throws nothing else at construction time.
- Threads every option (`loadPath`, `fetchFn`, `parsePayload`,
`customHeaders`, `requestOptions`, `crossDomain`, `retry`, `name`)
unchanged into the constructor.
## Usage: Basic Example
```ts
import { httpBackend } from "@suaveplan/i18n-backend-http";
const backend = httpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
});
```
## Usage: Advanced Example
```ts
import { httpBackend } from "@suaveplan/i18n-backend-http";
const backend = httpBackend({
name: "cdn",
loadPath: "https://cdn.example/i18n/{locale}/{namespace}.json",
crossDomain: true,
customHeaders: () => ({ "x-tenant": "acme" }),
retry: {
run: async (op, _opts) => op(),
},
});
const ctrl = new AbortController();
queueMicrotask(() => ctrl.abort());
try {
await backend.read("en", "auth", ctrl.signal);
} catch (err) {
// I18nError(I18N.BACKEND_TIMEOUT)
}
```
## API Reference
### `httpBackend(options: HttpBackendOptions): HttpBackend`
Returns a new `HttpBackend`. Every option is passed verbatim. Throws
`I18nError(I18N.INVALID_OPTIONS)` when `options` fails Zod validation.
## Implementation Notes
The factory holds no module-level state. Two calls produce two
distinct `HttpBackend` instances even when the option objects are
referentially identical — this is deliberate so test fixtures can
build many cheaply-isolated backends without aliasing issues. The
factory is defined in a separate file so it can be tree-shaken away by
consumers that import the class directly via the `./backend` subpath.

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { HttpBackend } from "../backend/backend.js";
import { httpBackend } from "./factory.js";
const NOOP_FETCH: typeof fetch = async () => new Response("{}");
describe("httpBackend factory", () => {
it("returns an HttpBackend instance", { timeout: 5000 }, () => {
const result = httpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: NOOP_FETCH,
});
expect(result).toBeInstanceOf(HttpBackend);
});
it("default name is http", { timeout: 5000 }, () => {
expect(
httpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: NOOP_FETCH,
}).name
).toBe("http");
});
it("passes name through to the constructor", { timeout: 5000 }, () => {
expect(
httpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: NOOP_FETCH,
name: "cdn",
}).name
).toBe("cdn");
});
it("returns a fresh instance per call (no singleton)", {
timeout: 5000,
}, () => {
const a = httpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: NOOP_FETCH,
});
const b = httpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn: NOOP_FETCH,
});
expect(a).not.toBe(b);
});
it("passes loadPath through and the resulting backend reads it", {
timeout: 5000,
}, async () => {
let captured = "";
const fetchFn: typeof fetch = async (input) => {
captured = typeof input === "string" ? input : input.toString();
return new Response("{}");
};
const backend = httpBackend({
loadPath: "/i18n/{locale}/{namespace}.json",
fetchFn,
});
await backend.read("en", "auth");
expect(captured).toBe("/i18n/en/auth.json");
});
});

View File

@@ -0,0 +1,11 @@
import { HttpBackend } from "../backend/backend.js";
import type { HttpBackendOptions } from "../backend/backend.types.js";
/**
* Factory form for callers that prefer builders over `new`. Returns a
* fresh `HttpBackend` per call — no internal singleton, no shared
* state. Equivalent to `new HttpBackend(options)`.
*/
export function httpBackend(options: HttpBackendOptions): HttpBackend {
return new HttpBackend(options);
}

View File

@@ -0,0 +1 @@
export { httpBackend } from "./factory.js";

View File

@@ -0,0 +1,101 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as root from "./index.js";
const PACKAGE_JSON_PATH = fileURLToPath(
new URL("../package.json", import.meta.url)
);
describe("@suaveplan/i18n-backend-http — root barrel", () => {
it("re-exports HttpBackend", { timeout: 5000 }, () => {
expect(typeof root.HttpBackend).toBe("function");
});
it("re-exports defaultParsePayload", { timeout: 5000 }, () => {
expect(typeof root.defaultParsePayload).toBe("function");
});
it("re-exports buildUrl", { timeout: 5000 }, () => {
expect(typeof root.buildUrl).toBe("function");
});
it("re-exports httpBackend factory", { timeout: 5000 }, () => {
expect(typeof root.httpBackend).toBe("function");
});
it("re-exports HttpBackendOptionsSchema", { timeout: 5000 }, () => {
expect(root.HttpBackendOptionsSchema).toBeDefined();
});
it("re-exports observability helpers", { timeout: 5000 }, () => {
expect(typeof root.recordRead).toBe("function");
expect(typeof root.getHttpBackendMetrics).toBe("function");
expect(typeof root.getHttpBackendMetricsState).toBe("function");
expect(typeof root.resetHttpBackendMetricsState).toBe("function");
expect(typeof root.resetHttpBackendMetricsCache).toBe("function");
});
});
describe("@suaveplan/i18n-backend-http — every subpath resolves", () => {
it("subpath barrels resolve through the package", {
timeout: 5000,
}, async () => {
const backend = await import("./backend/index.js");
const factory = await import("./factory/index.js");
const observability = await import("./observability/index.js");
expect(typeof backend.HttpBackend).toBe("function");
expect(typeof backend.defaultParsePayload).toBe("function");
expect(typeof backend.buildUrl).toBe("function");
expect(backend.HttpBackendOptionsSchema).toBeDefined();
expect(typeof factory.httpBackend).toBe("function");
expect(typeof observability.getHttpBackendMetrics).toBe("function");
expect(typeof observability.recordRead).toBe("function");
});
});
describe("@suaveplan/i18n-backend-http — package.json", () => {
it("declares sideEffects false", { timeout: 5000 }, () => {
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
sideEffects?: boolean;
};
expect(pkg.sideEffects).toBe(false);
});
it("does not depend on @suaveplan/i18n core or @suaveplan/retry", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
dependencies?: Record<string, string>;
};
const deps = pkg.dependencies ?? {};
expect(deps).not.toHaveProperty("@suaveplan/i18n");
expect(deps).not.toHaveProperty("@suaveplan/retry");
});
it("@suaveplan/testing is in devDependencies only (Rule 8)", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
expect(pkg.dependencies ?? {}).not.toHaveProperty("@suaveplan/testing");
expect(pkg.peerDependencies ?? {}).not.toHaveProperty(
"@suaveplan/testing"
);
expect(pkg.devDependencies ?? {}).toHaveProperty("@suaveplan/testing");
});
it("declares every documented subpath export", { timeout: 5000 }, () => {
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
exports?: Record<string, unknown>;
};
const documented = [".", "./backend", "./factory", "./observability"];
for (const subpath of documented) {
expect(pkg.exports?.[subpath]).toBeDefined();
}
});
});

View File

@@ -0,0 +1,27 @@
// Root barrel for @suaveplan/i18n-backend-http. Explicit named
// re-exports only — no `export *` per CLAUDE.md §10.
export {
buildUrl,
defaultParsePayload,
HttpBackend,
type HttpBackendOptionsInput,
HttpBackendOptionsSchema,
} from "./backend/backend.js";
export type {
CustomHeadersFn,
HttpBackendOptions,
HttpBackendRetry,
HttpHeadersInit,
ParsePayloadFn,
} from "./backend/backend.types.js";
export { httpBackend } from "./factory/factory.js";
export {
getHttpBackendMetrics,
getHttpBackendMetricsState,
type HttpBackendMetricSample,
type HttpBackendMetricsState,
recordRead,
resetHttpBackendMetricsCache,
resetHttpBackendMetricsState,
} from "./observability/observability.js";

View File

@@ -0,0 +1,11 @@
export type {
HttpBackendMetricSample,
HttpBackendMetricsState,
} from "./observability.js";
export {
getHttpBackendMetrics,
getHttpBackendMetricsState,
recordRead,
resetHttpBackendMetricsCache,
resetHttpBackendMetricsState,
} from "./observability.js";

View File

@@ -0,0 +1,75 @@
# `@suaveplan/i18n-backend-http/observability`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Lazy OpenTelemetry instrument factory and matched in-memory state ring
for `@suaveplan/i18n-backend-http`. Production paths in
[`backend.ts`](../backend/backend.md) call `recordRead(...)` once per
`HttpBackend.read` call; the instruments are no-ops until a
`MeterProvider` is registered against `@opentelemetry/api`, so consumers
pay zero overhead unless they wire up the SDK. The matched state ring
exists so spec-conformance tests can assert exact attribute payloads
without standing up a full SDK + `InMemoryMetricExporter` pipeline.
## Features
- `i18n.backend.http.read_duration_ms` — Histogram, milliseconds.
Attributes: `locale`, `namespace`, `outcome ∈ hit | miss | error`.
- `i18n.backend.http.read_total` — Counter. Attribute: `outcome`.
- `i18n.backend.http.read_errors_total` — Counter. Attributes: `code`
(the `I18N.*` error code), optional `status` (HTTP status code as
string when the error originated from a known HTTP response).
- Zero-cost when no MeterProvider is wired — every instrument is a
cached singleton.
- Test-only state ring + reset helpers
(`getHttpBackendMetricsState`, `resetHttpBackendMetricsState`,
`resetHttpBackendMetricsCache`).
## Usage: Basic Example
```ts
import { recordRead } from "@suaveplan/i18n-backend-http/observability";
recordRead("en", "auth", "hit", 0.42);
```
## Usage: Advanced Example
```ts
import {
getHttpBackendMetricsState,
recordRead,
resetHttpBackendMetricsState,
} from "@suaveplan/i18n-backend-http/observability";
resetHttpBackendMetricsState();
recordRead("xx", "auth", "miss", 1.2, "I18N.BACKEND_LOCALE_NOT_AVAILABLE", 404);
const state = getHttpBackendMetricsState();
console.log(state.readErrorsTotal);
// → [{ attributes: { code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE", status: "404" } }]
```
## API Reference
- `getHttpBackendMetrics(): { readDurationMs, readTotal, readErrorsTotal }`
— idempotent factory.
- `recordRead(locale, namespace, outcome, durationMs, errorCode?, status?)`
— emit + ring-record.
- `getHttpBackendMetricsState(): HttpBackendMetricsState` — frozen
snapshot.
- `resetHttpBackendMetricsState()` / `resetHttpBackendMetricsCache()` —
test helpers.
## Implementation Notes
`status` is encoded as a string attribute so OTel SDK aggregators can
union it with the `code` label without numeric coercion ambiguity. The
counter cardinality is bounded by the small `I18N.*` error-code set
times a typical handful of HTTP statuses (404 / 500 / 502 / 503), which
stays well below downstream backend high-cardinality limits. The cached
instruments survive across `recordRead` calls inside one process — the
`resetHttpBackendMetricsCache` helper exists purely so tests can verify
the cache-hit path is exercised.

View File

@@ -0,0 +1,106 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
getHttpBackendMetrics,
getHttpBackendMetricsState,
recordRead,
resetHttpBackendMetricsCache,
resetHttpBackendMetricsState,
} from "./observability.js";
describe("HttpBackend metrics — instrument lifecycle", () => {
it("getHttpBackendMetrics is idempotent (cache hit path)", {
timeout: 5000,
}, () => {
resetHttpBackendMetricsCache();
const a = getHttpBackendMetrics();
const b = getHttpBackendMetrics();
expect(a).toBe(b);
});
it("resetHttpBackendMetricsCache rebuilds instruments", {
timeout: 5000,
}, () => {
const a = getHttpBackendMetrics();
resetHttpBackendMetricsCache();
const b = getHttpBackendMetrics();
expect(a).not.toBe(b);
});
});
describe("HttpBackend metrics — recordRead", () => {
it("records duration + total on hit (no error code)", {
timeout: 5000,
}, () => {
resetHttpBackendMetricsState();
recordRead("en", "auth", "hit", 0.42);
const state = getHttpBackendMetricsState();
expect(state.readDurationMs).toEqual([
{
attributes: {
locale: "en",
namespace: "auth",
outcome: "hit",
},
},
]);
expect(state.readTotal).toEqual([{ attributes: { outcome: "hit" } }]);
expect(state.readErrorsTotal).toEqual([]);
});
it("records error counter when errorCode supplied without status", {
timeout: 5000,
}, () => {
resetHttpBackendMetricsState();
recordRead("en", "auth", "error", 0.42, "I18N.BACKEND_READ_FAILED");
const state = getHttpBackendMetricsState();
expect(state.readTotal).toEqual([{ attributes: { outcome: "error" } }]);
expect(state.readErrorsTotal).toEqual([
{ attributes: { code: "I18N.BACKEND_READ_FAILED" } },
]);
});
it("records error counter with status when supplied", {
timeout: 5000,
}, () => {
resetHttpBackendMetricsState();
recordRead(
"xx",
"auth",
"miss",
0.5,
"I18N.BACKEND_LOCALE_NOT_AVAILABLE",
404
);
const state = getHttpBackendMetricsState();
expect(state.readTotal).toEqual([{ attributes: { outcome: "miss" } }]);
expect(state.readErrorsTotal).toEqual([
{
attributes: {
code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE",
status: "404",
},
},
]);
});
});
describe("HttpBackend metrics — state ring helpers", () => {
it("resetHttpBackendMetricsState clears every ring", {
timeout: 5000,
}, () => {
recordRead("en", "auth", "hit", 0.1);
recordRead(
"en",
"auth",
"miss",
0.1,
"I18N.BACKEND_LOCALE_NOT_AVAILABLE",
404
);
resetHttpBackendMetricsState();
const state = getHttpBackendMetricsState();
expect(state.readDurationMs).toEqual([]);
expect(state.readTotal).toEqual([]);
expect(state.readErrorsTotal).toEqual([]);
});
});

View File

@@ -0,0 +1,126 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
/**
* OTel instruments for `@suaveplan/i18n-backend-http`.
*
* Built lazily via `metrics.getMeter('suaveplan.i18n-backend-http')`.
* All instruments are no-ops when no MeterProvider is registered (the
* default), so consumers pay zero overhead unless they wire up the SDK.
*
* In addition to emitting via OTel, this module retains a small
* in-memory state ring so spec-conformance tests can assert exact
* attribute payloads without standing up a full SDK +
* InMemoryMetricExporter pipeline. Tests call
* `resetHttpBackendMetricsState()` between cases and read
* `getHttpBackendMetricsState()` to inspect counters / histogram
* counts.
*/
export interface HttpBackendMetricSample {
readonly attributes: Record<string, string>;
}
export interface HttpBackendMetricsState {
readonly readDurationMs: HttpBackendMetricSample[];
readonly readTotal: HttpBackendMetricSample[];
readonly readErrorsTotal: HttpBackendMetricSample[];
}
interface CachedInstruments {
readonly readDurationMs: Histogram;
readonly readTotal: Counter;
readonly readErrorsTotal: Counter;
}
let cached: CachedInstruments | undefined;
const state: HttpBackendMetricsState = {
readDurationMs: [],
readTotal: [],
readErrorsTotal: [],
};
/**
* Build (or return cached) OTel instruments. Idempotent — safe to call
* from any production code path.
*/
export function getHttpBackendMetrics(): CachedInstruments {
if (cached) return cached;
const meter = metrics.getMeter("suaveplan.i18n-backend-http");
cached = {
readDurationMs: meter.createHistogram(
"i18n.backend.http.read_duration_ms",
{
description:
"Duration in ms of an HttpBackend.read call; labels: locale, namespace, outcome.",
unit: "ms",
}
),
readTotal: meter.createCounter("i18n.backend.http.read_total", {
description:
"HttpBackend.read call count; label: outcome (hit | miss | error).",
}),
readErrorsTotal: meter.createCounter(
"i18n.backend.http.read_errors_total",
{
description:
"HttpBackend.read error count; labels: code (the I18N error code), status (HTTP status when applicable).",
}
),
};
return cached;
}
/**
* Record a `read` call's duration + outcome. Mirrors the
* `read_total{outcome}` and per-error-code `read_errors_total{code,status?}`
* counters and pushes one sample into the in-memory state ring per counter
* touched. The optional `status` attribute is included on
* `read_errors_total` when the error originated from a known HTTP status
* code (404 / 5xx); transport / parse failures emit only `code`.
*/
export function recordRead(
locale: string,
namespace: string,
outcome: "hit" | "miss" | "error",
durationMs: number,
errorCode?: string,
status?: number
): void {
const ins = getHttpBackendMetrics();
const histogramAttrs: Record<string, string> = {
locale,
namespace,
outcome,
};
ins.readDurationMs.record(durationMs, histogramAttrs);
state.readDurationMs.push({ attributes: histogramAttrs });
const aggregate: Record<string, string> = { outcome };
ins.readTotal.add(1, aggregate);
state.readTotal.push({ attributes: aggregate });
if (errorCode !== undefined) {
const errorAttrs: Record<string, string> = { code: errorCode };
if (status !== undefined) {
errorAttrs["status"] = String(status);
}
ins.readErrorsTotal.add(1, errorAttrs);
state.readErrorsTotal.push({ attributes: errorAttrs });
}
}
/** Return a frozen snapshot of the in-memory state. */
export function getHttpBackendMetricsState(): HttpBackendMetricsState {
return state;
}
/** Reset the in-memory state ring. Tests call this between cases. */
export function resetHttpBackendMetricsState(): void {
state.readDurationMs.length = 0;
state.readTotal.length = 0;
state.readErrorsTotal.length = 0;
}
/** Test-only: drop the cached instruments so a fresh meter can be built. */
export function resetHttpBackendMetricsCache(): void {
cached = undefined;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,41 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/i18n-backend-http",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: "src/index.ts",
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/i18n-shared",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,28 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});