Initial Commit
This commit is contained in:
96
packages/localization/i18n/i18n-backend-http/README.md
Normal file
96
packages/localization/i18n/i18n-backend-http/README.md
Normal 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`.
|
||||
|
||||
82
packages/localization/i18n/i18n-backend-http/package.json
Normal file
82
packages/localization/i18n/i18n-backend-http/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { httpBackend } from "./factory.js";
|
||||
101
packages/localization/i18n/i18n-backend-http/src/index.test.ts
Normal file
101
packages/localization/i18n/i18n-backend-http/src/index.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
27
packages/localization/i18n/i18n-backend-http/src/index.ts
Normal file
27
packages/localization/i18n/i18n-backend-http/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,11 @@
|
||||
export type {
|
||||
HttpBackendMetricSample,
|
||||
HttpBackendMetricsState,
|
||||
} from "./observability.js";
|
||||
export {
|
||||
getHttpBackendMetrics,
|
||||
getHttpBackendMetricsState,
|
||||
recordRead,
|
||||
resetHttpBackendMetricsCache,
|
||||
resetHttpBackendMetricsState,
|
||||
} from "./observability.js";
|
||||
@@ -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.
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
41
packages/localization/i18n/i18n-backend-http/vite.config.ts
Normal file
41
packages/localization/i18n/i18n-backend-http/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user