# `@suaveplan/i18n-detector` — locale detection chain **Package**: `@suaveplan/i18n-detector` (NEW) **Status**: PROPOSED **Type**: feature (new universal-tier package; consolidates the misplaced react-i18n detector folder + the genesis monolith chain orchestrator) **Date**: 2026-05-05 **Wave**: 4 (depends on Waves 1–2 foundation; required by Wave 6 core slim) **Design doc**: `openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md` **Tier**: universal **Path**: `packages/i18n/i18n-detector/` ## Why Locale detection is the single most architecturally mis-placed piece of the i18n stack on genesis today. The chain orchestrator (`createDetectorChain`, `DETECTOR_CHAIN_UNRESOLVED`, `LocaleDetector` interface, `DetectorContext`, `DetectorEnvironment`) lives in `packages/i18n/i18n/src/detector/` inside the universal-tier monolith. The eight individual detector implementations (querystring, user-preference, cookie, localStorage, html-lang, Accept-Language, navigator, Cloudflare IP-geo) plus a `defaultDetectorChain` factory and a `fallbackDetector` were authored under `packages/react/react-i18n/src/detector/` — a browser-tier React binding package — even though every detector is framework-agnostic and most have no DOM dependency at all (Accept-Language and IP-geo are server-only; querystring works on either side; only localStorage / html-lang / navigator actually touch a browser global, and even then only via a guarded `globalThis` probe). The damage today: 1. **Tier inversion.** Server-only detectors (`acceptLanguageDetector`, `cloudflareIpGeoDetector`) live under a React binding package. A Node-only consumer that wants Accept-Language negotiation has to install React. 2. **Universal consumers can't compose chains.** Only React consumers see the eight detectors. A Hono / Express / Bun.serve handler that wants `cookieDetector + acceptLanguageDetector` has nothing to import. 3. **The chain orchestrator and its detectors are split across two packages.** `createDetectorChain` lives in `@suaveplan/i18n` but the only detectors that compose into it live in `@suaveplan/react-i18n`. `defaultDetectorChain` cannot live in a universal package without a React detour — the design doc explicitly flags this as the reason `i18n-detector` exists as a Wave 4 package. 4. **`getTextDirection` is misfiled.** Today it lives in `@suaveplan/i18n/utils/utils.ts` (`L142-L146`). It is a pure function over `RTL_LOCALES`; semantically it belongs with the detection / locale-direction surface, not with key-path utilities. This change consolidates every detector implementation, the chain orchestrator, the standalone `getTextDirection` helper, and the `defaultDetectorChain` factory into a single universal-tier package: `@suaveplan/i18n-detector`. The package depends only on `@suaveplan/i18n-shared` (for `RTL_LOCALES`, `LOCALE_BCP47_MAP`, `I18N_CODES`, `I18nError`) and `@suaveplan/locale-data` (for the ISO 3166 `getAllCountries()` lookup the Cloudflare detector uses to map country codes to locales). It depends on neither `@suaveplan/i18n` core nor any formatter / backend / parser package. ### Dual-tier strategy (universal package, browser + server detectors) The detector chain runs in both browser and server contexts. Universal-tier means every detector MUST gracefully short-circuit when the runtime it expects is absent. Concretely: - **Browser-only detectors** (`htmlLangDetector`, `localStorageDetector`, `navigatorDetector`) probe `globalThis.document` / `globalThis.localStorage` / `globalThis.navigator` and return `null` when the global is absent. The chain orchestrator additionally honours `LocaleDetector.environment === "browser"` so server runs skip them entirely. - **Server-only detectors** (`acceptLanguageDetector`, `cloudflareIpGeoDetector`) gate on `ctx.request !== undefined` and return `null` otherwise. Their `environment === "server"` causes the chain to skip them on a browser run. - **Universal detectors** (`querystringDetector`, `cookieDetector`, `userPreferenceDetector`, `fallbackDetector`) carry `environment === "both"` and fall back through `ctx.request → globalThis` lookups so they work in either tier. This is the same dual-tier pattern `@suaveplan/db-sqlite` uses (Bun on server / sqlite-wasm in browser): one universal package, runtime-checked branches per environment. No conditional `package.json` exports, no `node:` import in any source file. ## What changes ### New package: `@suaveplan/i18n-detector` #### Types and chain orchestrator - ADDED: `packages/i18n/i18n-detector/` package scaffold (package.json, tsconfig.json, vite.config.ts, README.md). - ADDED: `src/detector.types.ts` — ports `DetectorStorageLike`, `DetectorContext`, `DetectorEnvironment`, `LocaleDetector`, `DetectorChainResult` from the monolith verbatim. Source: `packages/i18n/i18n/src/detector/detector.types.ts` (full file). - ADDED: `src/chain/chain.ts` — ports `createDetectorChain`, `DETECTOR_CHAIN_UNRESOLVED`, `DetectorChainUnresolved`, `DetectorChain` from the monolith. Source: `packages/i18n/i18n/src/detector/chain/chain.ts` (full file). **Modified**: replaces direct `LocaleResolver` import with an injectable `negotiate: (raw: string, supported: readonly string[], fallback: string) => string | null` parameter on `createDetectorChain(detectors, options?)`. The package ships a default BCP-47 negotiator (`bcp47Negotiate`) that consumes `LOCALE_BCP47_MAP` from `@suaveplan/i18n-shared`. Throws `I18nError(I18N.DETECTOR_CHAIN_EXHAUSTED)` when `ctx.supportedLocales` is empty AND the consumer opted into `throwOnExhausted: true` (back-compat default returns `DETECTOR_CHAIN_UNRESOLVED`). - ADDED: `src/chain/bcp47-negotiate.ts` — default `negotiate()` implementation that normalises raw locale via `LOCALE_BCP47_MAP`, walks supported locales for an exact / language-subtag match, and returns `null` on miss (so the chain advances to the next detector instead of forcing the fallback prematurely). - ADDED: `src/chain/index.ts` — explicit named re-exports. #### Direction helper - ADDED: `src/direction/direction.ts` — exports `getTextDirection(locale: string): "ltr" | "rtl"` and `isRtl(locale: string): boolean`. Source: `packages/i18n/i18n/src/utils/utils.ts` `L138-L153` (port + retype: removes the indirect `import type { TextDirection } from "../middleware/server/server.types.js"` and inlines `type TextDirection = "ltr" | "rtl"` here). - ADDED: `src/direction/index.ts` — explicit named re-exports. #### Individual detector implementations Each detector lives in its own folder with `.ts`, `.types.ts` (when options exist), `index.ts` (explicit named re-exports), `.test.ts`, and `.md`: - ADDED: `src/detectors/querystring/querystring.ts` — `querystringDetector(options?: QuerystringDetectorOptions): LocaleDetector`. `environment: "both"`. Reads `?lang=` from `ctx.request.url.searchParams` (server) or `globalThis.location.search` via `URLSearchParams` (browser). Source: `packages/react/react-i18n/src/detector/querystring/querystring.ts` (full file, 42 lines). - ADDED: `src/detectors/user-preference/user-preference.ts` — `userPreferenceDetector(options: UserPreferenceDetectorOptions): LocaleDetector`. `environment: "both"`. Async GET on `options.endpoint` + best-effort POST on cache. Replaces the direct `ConsoleLogger` dependency with the `LoggerLike` from `@suaveplan/types` accepted via `options.logger?` (defaults to a no-op so the package keeps zero non-shared deps). Source: `packages/react/react-i18n/src/detector/user-preference/user-preference.ts` (full file, 105 lines). - ADDED: `src/detectors/cookie/cookie.ts` — `cookieDetector(options?: CookieDetectorOptions): LocaleDetector`. `environment: "both"`. Reads inbound `Cookie` header / writes `Set-Cookie` on `ctx.responseHeaders` (server) or reads/writes `document.cookie` (browser). Source: `packages/react/react-i18n/src/detector/cookie/cookie.ts` (full file, 110 lines). - ADDED: `src/detectors/local-storage/local-storage.ts` — `localStorageDetector(options?: LocalStorageDetectorOptions): LocaleDetector`. `environment: "browser"`. Reads/writes `globalThis.localStorage` with `try/catch` for private-mode / quota failures. Source: `packages/react/react-i18n/src/detector/local-storage/local-storage.ts` (full file, 69 lines). - ADDED: `src/detectors/html-lang/html-lang.ts` — `htmlLangDetector(): LocaleDetector`. `environment: "browser"`. Reads `document.documentElement.lang`. Source: `packages/react/react-i18n/src/detector/html-lang/html-lang.ts` (full file, 37 lines). - ADDED: `src/detectors/accept-language/accept-language.ts` — `acceptLanguageDetector(): LocaleDetector` plus exported `parseAcceptLanguage(header: string): AcceptLanguageEntry[]`. `environment: "server"`. RFC 9110 §5.3.5 q-value parsing with stable sort. Source: `packages/react/react-i18n/src/detector/accept-language/accept-language.ts` (full file, 82 lines). - ADDED: `src/detectors/navigator/navigator.ts` — `navigatorDetector(): LocaleDetector`. `environment: "browser"`. Reads `globalThis.navigator.languages` (preferred) or `navigator.language` (fallback). Source: `packages/react/react-i18n/src/detector/navigator/navigator.ts` (full file, 50 lines). - ADDED: `src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.ts` — `cloudflareIpGeoDetector(options?: CloudflareIpGeoDetectorOptions): LocaleDetector`. `environment: "server"`. Maps `cf-ipcountry` ISO 3166-1 alpha-2 to a BCP-47 locale via `getAllCountries()` from `@suaveplan/locale-data` plus an optional override map. Source: `packages/react/react-i18n/src/detector/cloudflare-ip-geo/cloudflare-ip-geo.ts` (full file, 50 lines). - ADDED: `src/detectors/fallback/fallback.ts` — `fallbackDetector(locale: string): LocaleDetector`. `environment: "both"`. Always returns the configured locale. Throws `I18nError(I18N.INVALID_LOCALE)` on empty/whitespace input (replaces the upstream `throw new Error(...)`). Source: `packages/react/react-i18n/src/detector/fallback/fallback.ts` (full file, 24 lines). #### Default chain factory - ADDED: `src/default-chain/default-chain.ts` — `defaultDetectorChain(options: DefaultDetectorChainOptions): LocaleDetector[]` returning the canonical 8-or-9-entry chain (querystring → userPreference? → cookie → localStorage → htmlLang → acceptLanguage → navigator → cloudflareIpGeo → fallback). Source: `packages/react/react-i18n/src/detector/default-chain/default-chain.ts` (full file, 54 lines). #### Root barrel - ADDED: `src/index.ts` — explicit named re-exports of every public symbol across `chain/`, `direction/`, `detectors/`, and `default-chain/`. NO `export *` per CLAUDE.md §10. ### Subpath exports (per `package.json` `exports` map) - `.` — root barrel (every public symbol) - `./chain` — `createDetectorChain`, `DETECTOR_CHAIN_UNRESOLVED`, `DetectorChain`, `DetectorChainUnresolved`, `bcp47Negotiate` - `./direction` — `getTextDirection`, `isRtl` - `./detectors/querystring` — `querystringDetector`, `QuerystringDetectorOptions` - `./detectors/user-preference` — `userPreferenceDetector`, `UserPreferenceDetectorOptions`, `UserPreferenceResponseBody` - `./detectors/cookie` — `cookieDetector`, `CookieDetectorOptions`, `CookieSameSite` - `./detectors/local-storage` — `localStorageDetector`, `LocalStorageDetectorOptions`, `LocalStorageLike` - `./detectors/html-lang` — `htmlLangDetector` - `./detectors/accept-language` — `acceptLanguageDetector`, `parseAcceptLanguage`, `AcceptLanguageEntry` - `./detectors/navigator` — `navigatorDetector` - `./detectors/cloudflare-ip-geo` — `cloudflareIpGeoDetector`, `CloudflareIpGeoDetectorOptions` - `./detectors/fallback` — `fallbackDetector` - `./default-chain` — `defaultDetectorChain`, `DefaultDetectorChainOptions` ### Dependencies - `@suaveplan/i18n-shared workspace:^` — for `RTL_LOCALES` (direction), `LOCALE_BCP47_MAP` (BCP-47 normalisation in default negotiator), `I18N_CODES` + `I18nError` (every error throw). - `@suaveplan/locale-data workspace:^` — for `getAllCountries()` consumed by `cloudflareIpGeoDetector` to map ISO 3166-1 alpha-2 → BCP-47. - `@suaveplan/types workspace:^` — for `LoggerLike` shape consumed by `userPreferenceDetector`. - `@suaveplan/error workspace:^` — for `defineCodes` consumer-side helpers (re-export consumers may need). - `@opentelemetry/api catalog:observability` — for chain + per-detector histograms / counters. - `tslib catalog:build` — TS helper inlining. - `zod catalog:forms` — boundary schemas (`DetectorContextSchema`, `DefaultDetectorChainOptionsSchema`, every `OptionsSchema`). ### Observability - `i18n.detector..duration_ms` (histogram) — per detector, async-detector wall time. - `i18n.detector..detected_total{locale=}` (counter) — per detector, increments on a non-null resolution. - `i18n.detector.chain.exhausted_total` (counter) — increments every time the entire chain returns `DETECTOR_CHAIN_UNRESOLVED`. - `i18n.detect` (span) — wraps every chain run; sets `resolved_locale` and `winning_detector` attributes (already present in monolith chain). ### AbortSignal contract Every async detector (`userPreferenceDetector.detectAsync`, `userPreferenceDetector.cache`, `cloudflareIpGeoDetector.detect` if extended in future) MUST honour `ctx.signal` by passing it on `RequestInit.signal`. The chain orchestrator threads `ctx.signal` through unchanged. A pre-aborted signal returns `null` from the detector immediately (caught + logged at warn via the injected `logger`). ## Out of scope - **Removing the detector folder from `packages/i18n/i18n/src/`** — happens in Wave 6 (`i18n-core-slim`). For Waves 4–5 the monolith retains its copy and the leaves consume from `@suaveplan/i18n-shared` + this package; both compile. - **Removing the detector folder from `packages/react/react-i18n/src/`** — that follow-up change re-exports every symbol from this package and is tracked separately as `react-i18n-detector-reexport`. - **Adding new detectors beyond the existing 8** — the design doc lists exactly the eight currently in the react-i18n source. New detectors (Vercel `x-vercel-ip-country`, Fastly `Fastly-Geo-Country`, custom-header) ship in follow-ups, each as its own subpath under `./detectors//`. - **Replacing the chain's `LocaleResolver` dependency with a full-featured negotiator (CLDR fallback chain, script-aware matching, RFC 4647 lookup)** — the embedded `bcp47Negotiate` covers the existing monolith's `LocaleResolver.resolve()` happy path; full CLDR support is a follow-up that lands as `i18n-shared-cldr-negotiate`. - **Persisting cookie SameSite=None requires Secure flag enforcement** — the current implementation does not auto-add `; Secure` for `SameSite=None`; that is tracked as `cookie-detector-secure-flag` (separate change). ## Source mapping | Requirement | Source file | Source lines | Notes | |---|---|---|---| | `LocaleDetector`, `DetectorContext`, `DetectorEnvironment`, `DetectorChainResult`, `DetectorStorageLike` types | `packages/i18n/i18n/src/detector/detector.types.ts` | L1-L87 | Verbatim port | | `createDetectorChain`, `DETECTOR_CHAIN_UNRESOLVED`, `DetectorChain`, `DetectorChainUnresolved` | `packages/i18n/i18n/src/detector/chain/chain.ts` | L1-L220 | Port; replace `LocaleResolver` import with injectable `negotiate` parameter | | `getTextDirection`, `isRtl` | `packages/i18n/i18n/src/utils/utils.ts` | L138-L153 | Port; inline `type TextDirection = "ltr" \| "rtl"` (drop `middleware/server/server.types` import) | | `querystringDetector`, `QuerystringDetectorOptions` | `packages/react/react-i18n/src/detector/querystring/querystring.ts` | L1-L42 | Verbatim port; rewire `LocaleDetector` import to `../../detector.types.js` | | `userPreferenceDetector`, `UserPreferenceDetectorOptions`, `UserPreferenceResponseBody` | `packages/react/react-i18n/src/detector/user-preference/user-preference.ts` | L1-L105 | Port; replace `ConsoleLogger` dep with `options.logger?: LoggerLike` | | `cookieDetector`, `CookieDetectorOptions`, `CookieSameSite` | `packages/react/react-i18n/src/detector/cookie/cookie.ts` | L1-L110 | Verbatim port | | `localStorageDetector`, `LocalStorageDetectorOptions`, `LocalStorageLike` | `packages/react/react-i18n/src/detector/local-storage/local-storage.ts` | L1-L69 | Verbatim port | | `htmlLangDetector` | `packages/react/react-i18n/src/detector/html-lang/html-lang.ts` | L1-L37 | Verbatim port | | `acceptLanguageDetector`, `parseAcceptLanguage`, `AcceptLanguageEntry` | `packages/react/react-i18n/src/detector/accept-language/accept-language.ts` | L1-L82 | Verbatim port | | `navigatorDetector` | `packages/react/react-i18n/src/detector/navigator/navigator.ts` | L1-L50 | Verbatim port | | `cloudflareIpGeoDetector`, `CloudflareIpGeoDetectorOptions` | `packages/react/react-i18n/src/detector/cloudflare-ip-geo/cloudflare-ip-geo.ts` | L1-L50 | Verbatim port; `getAllCountries` import already targets `@suaveplan/locale-data` | | `fallbackDetector` | `packages/react/react-i18n/src/detector/fallback/fallback.ts` | L1-L24 | Port; replace `throw new Error(...)` with `throw new I18nError(I18N_CODES.INVALID_LOCALE, ...)` | | `defaultDetectorChain`, `DefaultDetectorChainOptions` | `packages/react/react-i18n/src/detector/default-chain/default-chain.ts` | L1-L54 | Verbatim port; rewire detector imports to sibling folders | ### Requirements with no source line | Requirement | Justification | |---|---| | `bcp47Negotiate` default negotiator | Today the chain hard-imports `LocaleResolver` from `../../locale/resolver/resolver.js`. The detector package cannot pull the resolver without dragging the entire schemas / errors / locale subtree with it; the cleanest split is a small embedded negotiator that handles the monolith's happy-path negotiation against `LOCALE_BCP47_MAP` and accepts an injectable `negotiate` for advanced consumers (slim core wires the full `LocaleResolver` in Wave 6) | | Subpath exports per individual detector | Tree-shaking — a Hono server consumer should be able to import only `@suaveplan/i18n-detector/detectors/accept-language` without pulling browser-only detectors | | OTel histograms `i18n.detector..duration_ms` and counters `i18n.detector..detected_total` | Today the monolith chain emits aggregated `i18n.detector.match_total{detector}` and `i18n.detector.duration_ms{detector}`. Per-package split keeps the same surface but moves the metrics to per-detector helpers so the chain orchestrator stays decoupled from any specific detector implementation | | `i18n.detector.chain.exhausted_total` counter | New observability surface — the monolith silently returns the unresolved sentinel; production needs a counter to alert on chain exhaustion | | `I18nError(I18N.DETECTOR_CHAIN_EXHAUSTED)` thrown when `throwOnExhausted: true` | New error path — the monolith only returns the sentinel; production consumers (especially server-side renderers) need a typed throw they can catch and map to a 4xx | ## Verification approach Each requirement carries a `**Verified by:**` line in the spec delta. Concrete test files: - `packages/i18n/i18n-detector/src/chain/chain.test.ts` — sync chain runs in registration order, async chain races, environment gating, `DETECTOR_CHAIN_UNRESOLVED` sentinel, `cache()` walks every persisting detector, AbortSignal threading, throw-on-exhausted opt-in - `packages/i18n/i18n-detector/src/chain/bcp47-negotiate.test.ts` — exact match, language-subtag fallback (`zh-Hant-TW` → `zh`), `null` on miss, `LOCALE_BCP47_MAP` short-code expansion (`en` → `en-GB`) - `packages/i18n/i18n-detector/src/direction/direction.test.ts` — `getTextDirection("ar")` is `"rtl"`, `getTextDirection("en-GB")` is `"ltr"`, language-subtag extraction works (`ar-SA` → `"rtl"`); `isRtl` mirrors - `packages/i18n/i18n-detector/src/detectors/querystring/querystring.test.ts` — server `?lang=fr` resolves; browser `globalThis.location.search` resolves; missing param returns `null`; both contexts absent returns `null` - `packages/i18n/i18n-detector/src/detectors/user-preference/user-preference.test.ts` — happy GET returns body locale; non-2xx logs + returns `null`; rejected fetch returns `null`; AbortSignal threaded; cache POST round-trips; cache failure is non-fatal - `packages/i18n/i18n-detector/src/detectors/cookie/cookie.test.ts` — server reads inbound Cookie header; server writes Set-Cookie on responseHeaders; browser reads/writes `document.cookie`; URL-encoding round-trip; SameSite formatting - `packages/i18n/i18n-detector/src/detectors/local-storage/local-storage.test.ts` — explicit storage option; `globalThis.localStorage` fallback; missing storage returns `null`; quota error caught - `packages/i18n/i18n-detector/src/detectors/html-lang/html-lang.test.ts` — `ctx.document` lookup; `globalThis.document` fallback; missing root returns `null`; empty `lang` returns `null` - `packages/i18n/i18n-detector/src/detectors/accept-language/accept-language.test.ts` — q-value sort; stable order on ties; `*` wildcard skipped; malformed q skipped; missing header returns `null` - `packages/i18n/i18n-detector/src/detectors/navigator/navigator.test.ts` — `navigator.languages` preferred; `navigator.language` fallback; missing navigator returns `null` - `packages/i18n/i18n-detector/src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.test.ts` — `AE` → `ar-AE` via locale-data; override map wins; missing header returns `null`; unknown country returns `null` - `packages/i18n/i18n-detector/src/detectors/fallback/fallback.test.ts` — returns configured locale; empty locale throws `I18nError(I18N.INVALID_LOCALE)` - `packages/i18n/i18n-detector/src/default-chain/default-chain.test.ts` — order matches spec; `userPreference` only when endpoint set; result is a fresh array; fallback always last - `packages/i18n/i18n-detector/src/index.test.ts` — every documented subpath export resolves ## Impact - New universal-tier package — joins `@suaveplan/i18n-shared`, `@suaveplan/i18n-icu-parser`, and `@suaveplan/locale-data` under `packages/i18n/`. - `@suaveplan/i18n` slim core (Wave 6) re-exports `createDetectorChain`, `getTextDirection`, `LocaleDetector`, `DetectorContext` from this package — every existing consumer keeps working. - `@suaveplan/react-i18n` (follow-up `react-i18n-detector-reexport` change) deletes its own `src/detector/` folder and re-exports the eight detectors plus `defaultDetectorChain` from this package. - `MANIFEST.md` regenerated via `bun scripts/folder-reorg/generate-manifest.ts`. - `CLAUDE.md` package listing updated with a one-line entry under the i18n cluster. - Net dependency-graph effect: removes `@suaveplan/i18n` ← `@suaveplan/react-i18n` runtime coupling for detector composition (was only resolved via the `@suaveplan/i18n/detector` subpath today).