A client-side language guardrail for AI output in the browser. Detects when text has drifted from the language your backend told the model to use, and rewrites it — using Chrome's built-in Translator and Language Detector APIs (on-device, Gemini Nano). No server round-trip, no manual i18n dictionary.
Your backend prompts an LLM to always respond in, say, pt-BR. Most of the
time it does. Occasionally it doesn't — a stray English phrase, a leaked
technical term in another language, a full response in the wrong language
entirely. The instruction was correct; the model still drifted.
langfence catches this on the client: it checks the language of text you're
about to render, and only pays the cost of a rewrite when the text actually
needs one. It's also useful for dynamic API responses you don't want to (or
can't) map into a static i18n JSON file — it keeps output language-consistent
without a translation dictionary.
Try it live: jhonatangalves.github.io/langfence-playground —
every GuardrailOptions field as an interactive control, runs entirely in
your own browser.
npm install langfenceimport { normalize } from 'langfence'
const text = await normalize(apiResponseText, {
expectedLanguage: 'pt-BR',
})
render(text)That's it for the common case. normalize() only calls the Translator API
when the text actually needs rewriting — if it's already confidently in
pt-BR, you get the original string back untouched, with no extra API call.
import { normalize, type GuardrailStatus } from 'langfence'
const text = await normalize(apiResponseText, {
expectedLanguage: 'pt-BR',
onStatusChange: (status: GuardrailStatus, error) => {
// 'idle' | 'detecting' | 'translating' | 'done' | 'unsupported' | 'error'
setGuardrailStatus(status)
if (status === 'error') console.warn('langfence:', error)
},
})The first time a given language configuration is used, Chrome may need to
download the underlying model, which can take a while. onDownloadProgress
reports that separately from onStatusChange, since it's continuous (0–1)
rather than a discrete state:
const text = await normalize(apiResponseText, {
expectedLanguage: 'pt-BR',
onDownloadProgress: ({ stage, loaded }) => {
// stage: 'detecting' | 'translating'
setDownloadProgress(Math.round(loaded * 100))
},
})It only fires on a cold start for that language configuration — once the
model is on disk, later calls skip straight to 'detecting'/'translating'
with no download step.
function normalize(text: string, options: GuardrailOptions): Promise<string>Always resolves to a string and never throws: on any native API failure,
or when this browser doesn't support the APIs at all, the original text is
returned unchanged (fail-open). Use onStatusChange to find out what
actually happened.
Pipeline:
- Empty
text→ returned immediately, no API calls. - Feature detection (
Translator/LanguageDetectoronglobalThis) → if missing,status: 'unsupported', original text returned. LanguageDetectorruns on the original text.- If the top detected language equals
expectedLanguageand its confidence is>= minConfidence, andforceRewriteisn't set, the original text is returned —Translatoris never called. - Otherwise,
Translatorrewrites the text intoexpectedLanguage, using the top detected language as the source. The text is translated line by line (splitting on\n, preserving blank lines and leading indentation) rather than as one blob, sinceTranslator.translate()has no obligation to keep whitespace structure intact across a multi-line string.
| Option | Type | Default | Description |
|---|---|---|---|
expectedLanguage |
string |
— (required) | BCP 47 tag the text should be in, e.g. 'pt-BR'. |
minConfidence |
number |
0.9 |
Minimum detector confidence to skip rewriting. |
forceRewrite |
boolean |
false |
Always rewrite via Translator, skipping the confidence check. |
onStatusChange |
(status, error?) => void |
undefined |
Called on every status transition; error is set only when status === 'error'. |
onDownloadProgress |
(progress) => void |
undefined |
Called with { stage, loaded } while a model is downloading. Only fires on a cold start for that language configuration. |
'idle' | 'detecting' | 'translating' | 'done' | 'unsupported' | 'error'
interface GuardrailError {
stage: 'detecting' | 'translating'
message: string
cause: unknown // the original value thrown by the native API
}Delivered exclusively through onStatusChange — normalize()'s return value
is always just the resulting string.
interface GuardrailDownloadProgress {
stage: 'detecting' | 'translating'
loaded: number // fraction between 0 and 1
}Delivered exclusively through onDownloadProgress.
langfence caches native instances — one Translator per source/target
language pair, one LanguageDetector per set of expected input languages —
so repeated calls with the same language configuration reuse the same
instance instead of paying model-creation cost again.
- Chrome/Edge only, desktop only.
Translatorrequires Chrome 138+/Edge 148+;LanguageDetectorthe same. Neither is available on Firefox, Safari, or mobile browsers. - Requires a secure context (HTTPS).
- On first use for a given language pair, the underlying model may need to download — this can take noticeable time and disk space. See developer.chrome.com/docs/ai for current hardware/storage requirements.
LanguageDetectoris restricted to top-level windows and same-origin iframes by default; cross-origin iframes need alanguage-detectorPermission Policy.- These are experimental browser APIs that have changed shape before (e.g.
moving from
window.ai.translatortowindow.Translator) and may change again.langfencetargets the current global-namespace shape; breaking browser changes will be called out in the CHANGELOG.
pnpm install
pnpm test:coverage # 100% coverage required
pnpm lint
pnpm typecheck
pnpm buildChanges are versioned with Changesets:
run pnpm changeset alongside your PR to describe the change for the
changelog.
MIT