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

22 KiB
Raw Permalink Blame History

@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 <name>.ts, <name>.types.ts (when options exist), index.ts (explicit named re-exports), <name>.test.ts, and <name>.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 <Detector>OptionsSchema).

Observability

  • i18n.detector.<name>.duration_ms (histogram) — per detector, async-detector wall time.
  • i18n.detector.<name>.detected_total{locale=<resolved>} (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/<name>/.
  • 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.<name>.duration_ms and counters i18n.detector.<name>.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).