22 KiB
@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:
- 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. - Universal consumers can't compose chains. Only React consumers see the eight detectors. A Hono / Express / Bun.serve handler that wants
cookieDetector + acceptLanguageDetectorhas nothing to import. - The chain orchestrator and its detectors are split across two packages.
createDetectorChainlives in@suaveplan/i18nbut the only detectors that compose into it live in@suaveplan/react-i18n.defaultDetectorChaincannot live in a universal package without a React detour — the design doc explicitly flags this as the reasoni18n-detectorexists as a Wave 4 package. getTextDirectionis misfiled. Today it lives in@suaveplan/i18n/utils/utils.ts(L142-L146). It is a pure function overRTL_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) probeglobalThis.document/globalThis.localStorage/globalThis.navigatorand returnnullwhen the global is absent. The chain orchestrator additionally honoursLocaleDetector.environment === "browser"so server runs skip them entirely. - Server-only detectors (
acceptLanguageDetector,cloudflareIpGeoDetector) gate onctx.request !== undefinedand returnnullotherwise. Theirenvironment === "server"causes the chain to skip them on a browser run. - Universal detectors (
querystringDetector,cookieDetector,userPreferenceDetector,fallbackDetector) carryenvironment === "both"and fall back throughctx.request → globalThislookups 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— portsDetectorStorageLike,DetectorContext,DetectorEnvironment,LocaleDetector,DetectorChainResultfrom the monolith verbatim. Source:packages/i18n/i18n/src/detector/detector.types.ts(full file). - ADDED:
src/chain/chain.ts— portscreateDetectorChain,DETECTOR_CHAIN_UNRESOLVED,DetectorChainUnresolved,DetectorChainfrom the monolith. Source:packages/i18n/i18n/src/detector/chain/chain.ts(full file). Modified: replaces directLocaleResolverimport with an injectablenegotiate: (raw: string, supported: readonly string[], fallback: string) => string | nullparameter oncreateDetectorChain(detectors, options?). The package ships a default BCP-47 negotiator (bcp47Negotiate) that consumesLOCALE_BCP47_MAPfrom@suaveplan/i18n-shared. ThrowsI18nError(I18N.DETECTOR_CHAIN_EXHAUSTED)whenctx.supportedLocalesis empty AND the consumer opted intothrowOnExhausted: true(back-compat default returnsDETECTOR_CHAIN_UNRESOLVED). - ADDED:
src/chain/bcp47-negotiate.ts— defaultnegotiate()implementation that normalises raw locale viaLOCALE_BCP47_MAP, walks supported locales for an exact / language-subtag match, and returnsnullon 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— exportsgetTextDirection(locale: string): "ltr" | "rtl"andisRtl(locale: string): boolean. Source:packages/i18n/i18n/src/utils/utils.tsL138-L153(port + retype: removes the indirectimport type { TextDirection } from "../middleware/server/server.types.js"and inlinestype 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=fromctx.request.url.searchParams(server) orglobalThis.location.searchviaURLSearchParams(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 onoptions.endpoint+ best-effort POST on cache. Replaces the directConsoleLoggerdependency with theLoggerLikefrom@suaveplan/typesaccepted viaoptions.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 inboundCookieheader / writesSet-Cookieonctx.responseHeaders(server) or reads/writesdocument.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/writesglobalThis.localStoragewithtry/catchfor 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". Readsdocument.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(): LocaleDetectorplus exportedparseAcceptLanguage(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". ReadsglobalThis.navigator.languages(preferred) ornavigator.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". Mapscf-ipcountryISO 3166-1 alpha-2 to a BCP-47 locale viagetAllCountries()from@suaveplan/locale-dataplus 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. ThrowsI18nError(I18N.INVALID_LOCALE)on empty/whitespace input (replaces the upstreamthrow 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 acrosschain/,direction/,detectors/, anddefault-chain/. NOexport *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:^— forRTL_LOCALES(direction),LOCALE_BCP47_MAP(BCP-47 normalisation in default negotiator),I18N_CODES+I18nError(every error throw).@suaveplan/locale-data workspace:^— forgetAllCountries()consumed bycloudflareIpGeoDetectorto map ISO 3166-1 alpha-2 → BCP-47.@suaveplan/types workspace:^— forLoggerLikeshape consumed byuserPreferenceDetector.@suaveplan/error workspace:^— fordefineCodesconsumer-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 returnsDETECTOR_CHAIN_UNRESOLVED.i18n.detect(span) — wraps every chain run; setsresolved_localeandwinning_detectorattributes (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 asreact-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, FastlyFastly-Geo-Country, custom-header) ship in follow-ups, each as its own subpath under./detectors/<name>/. - Replacing the chain's
LocaleResolverdependency with a full-featured negotiator (CLDR fallback chain, script-aware matching, RFC 4647 lookup) — the embeddedbcp47Negotiatecovers the existing monolith'sLocaleResolver.resolve()happy path; full CLDR support is a follow-up that lands asi18n-shared-cldr-negotiate. - Persisting cookie SameSite=None requires Secure flag enforcement — the current implementation does not auto-add
; SecureforSameSite=None; that is tracked ascookie-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_UNRESOLVEDsentinel,cache()walks every persisting detector, AbortSignal threading, throw-on-exhausted opt-inpackages/i18n/i18n-detector/src/chain/bcp47-negotiate.test.ts— exact match, language-subtag fallback (zh-Hant-TW→zh),nullon miss,LOCALE_BCP47_MAPshort-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");isRtlmirrorspackages/i18n/i18n-detector/src/detectors/querystring/querystring.test.ts— server?lang=frresolves; browserglobalThis.location.searchresolves; missing param returnsnull; both contexts absent returnsnullpackages/i18n/i18n-detector/src/detectors/user-preference/user-preference.test.ts— happy GET returns body locale; non-2xx logs + returnsnull; rejected fetch returnsnull; AbortSignal threaded; cache POST round-trips; cache failure is non-fatalpackages/i18n/i18n-detector/src/detectors/cookie/cookie.test.ts— server reads inbound Cookie header; server writes Set-Cookie on responseHeaders; browser reads/writesdocument.cookie; URL-encoding round-trip; SameSite formattingpackages/i18n/i18n-detector/src/detectors/local-storage/local-storage.test.ts— explicit storage option;globalThis.localStoragefallback; missing storage returnsnull; quota error caughtpackages/i18n/i18n-detector/src/detectors/html-lang/html-lang.test.ts—ctx.documentlookup;globalThis.documentfallback; missing root returnsnull; emptylangreturnsnullpackages/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 returnsnullpackages/i18n/i18n-detector/src/detectors/navigator/navigator.test.ts—navigator.languagespreferred;navigator.languagefallback; missing navigator returnsnullpackages/i18n/i18n-detector/src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.test.ts—AE→ar-AEvia locale-data; override map wins; missing header returnsnull; unknown country returnsnullpackages/i18n/i18n-detector/src/detectors/fallback/fallback.test.ts— returns configured locale; empty locale throwsI18nError(I18N.INVALID_LOCALE)packages/i18n/i18n-detector/src/default-chain/default-chain.test.ts— order matches spec;userPreferenceonly when endpoint set; result is a fresh array; fallback always lastpackages/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-dataunderpackages/i18n/. @suaveplan/i18nslim core (Wave 6) re-exportscreateDetectorChain,getTextDirection,LocaleDetector,DetectorContextfrom this package — every existing consumer keeps working.@suaveplan/react-i18n(follow-upreact-i18n-detector-reexportchange) deletes its ownsrc/detector/folder and re-exports the eight detectors plusdefaultDetectorChainfrom this package.MANIFEST.mdregenerated viabun scripts/folder-reorg/generate-manifest.ts.CLAUDE.mdpackage listing updated with a one-line entry under the i18n cluster.- Net dependency-graph effect: removes
@suaveplan/i18n←@suaveplan/react-i18nruntime coupling for detector composition (was only resolved via the@suaveplan/i18n/detectorsubpath today).