diff --git a/apps/web/package.json b/apps/web/package.json index 158b6c3c5..437a29c31 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -45,6 +45,7 @@ "shadcn": "^4.14.0", "sonner": "^2.0.7", "tailwindcss": "^4.1.18", + "use-intl": "^4.13.7", "zod": "^4.4.3" }, "devDependencies": { diff --git a/apps/web/src/components/language-switcher.tsx b/apps/web/src/components/language-switcher.tsx new file mode 100644 index 000000000..675118b42 --- /dev/null +++ b/apps/web/src/components/language-switcher.tsx @@ -0,0 +1,67 @@ +import { useLanguages } from '@vitnode/core/components/languages-provider' +import { Button } from '@vitnode/core/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@vitnode/core/components/ui/dropdown-menu' +import { CheckIcon, LanguagesIcon } from 'lucide-react' +import { useTranslations } from 'use-intl' + +import type { Locale } from '#/lib/i18n/shared' + +import { useLocale, useSwitchLocale } from '#/lib/i18n/client' + +/** + * VitNode's language switcher, for TanStack Router. + * + * The same control as `@vitnode/core`'s - the same dropdown, the same icons, the + * same `core.global.language_switcher` label - over a different navigation + * layer. Core's version is built on `next-intl/navigation`'s `useRouter`, which + * is Next.js all the way down; this one is built on the router that is actually + * mounted here. Sharing the markup and forking the two lines that navigate is + * cheaper than a navigation abstraction that has to satisfy both. + * + * What it preserves is the whole point: the route, its params, its search + * string and its hash. Only the locale prefix changes. + */ +export const LanguageSwitcher = () => { + const languages = useLanguages() + const locale = useLocale() + const switchLocale = useSwitchLocale() + const t = useTranslations('core.global') + + return ( + + + } + > + + + + + {languages.map((language) => ( + { + switchLocale(language.code as Locale) + }} + > + {language.name} + + {language.code === locale && ( + + )} + + ))} + + + ) +} diff --git a/apps/web/src/i18n.ts b/apps/web/src/i18n.ts index 3c14e3749..eb65c5a97 100644 --- a/apps/web/src/i18n.ts +++ b/apps/web/src/i18n.ts @@ -13,7 +13,7 @@ import type { VitNodeI18nConfig } from '@vitnode/core/lib/i18n/types' * reword something without forking the package that owns it. */ export const i18n = { - defaultLocale: 'en', + defaultLocale: 'en' as const, /** * Explicit, because the app renders on a server: without one, `use-intl` * formats dates in whatever zone the server happens to run in and warns that @@ -21,13 +21,18 @@ export const i18n = { * per-visitor zone would come from. */ timeZone: 'UTC', + /** + * `as const` on each code, and nothing else: it keeps `"en" | "pl"` out of + * the widening `satisfies` would otherwise do, which is what makes `Locale` + * in `lib/i18n/shared.ts` a real union rather than an alias for `string`. + */ locales: [ { - code: 'en', + code: 'en' as const, name: 'English', }, { - code: 'pl', + code: 'pl' as const, name: 'Polski', }, ], diff --git a/apps/web/src/lib/i18n.ts b/apps/web/src/lib/i18n.ts deleted file mode 100644 index d80016006..000000000 --- a/apps/web/src/lib/i18n.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { queryOptions } from '@tanstack/react-query' -import { createServerFn } from '@tanstack/react-start' - -import { loadShellIntl } from '#/server/messages.server' - -/** - * The shell's locale and its `core.global` strings, fetched on the server. - * - * A server function rather than a plain loader: the messages are read from JSON - * inside each package's `dist`, which only exists on the server, and the plugin - * registry they are merged from must never reach the browser bundle. Start - * strips the handler - and everything only it imports - out of the client build. - */ -export const getShellIntl = createServerFn().handler( - async () => await loadShellIntl(), -) - -/** - * The same request, as a query. - * - * Going through the QueryClient rather than returning it from the loader is what - * makes the shell's copy of it *the* copy: the root loader warms it on the - * server, the SSR integration dehydrates it into the HTML, and the component - * reads it out of the hydrated cache instead of asking the server again. It is - * also the first real exercise of the Stage 2 pipeline - router context, loader, - * `ensureQueryData`, dehydrate, hydrate - which is worth having under something - * the page visibly needs rather than a synthetic query. - * - * `staleTime: Infinity`: a locale's messages change when the app is redeployed. - */ -export const shellIntlQueryOptions = () => - queryOptions({ - queryFn: async () => await getShellIntl(), - queryKey: ['vitnode', 'shell-intl'] as const, - staleTime: Infinity, - }) diff --git a/apps/web/src/lib/i18n/client.ts b/apps/web/src/lib/i18n/client.ts new file mode 100644 index 000000000..09ba752a1 --- /dev/null +++ b/apps/web/src/lib/i18n/client.ts @@ -0,0 +1,226 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { AnyRouter, LocationRewrite } from '@tanstack/react-router' + +import { useRouter, useRouterState } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' +import { getRequestHeader } from '@tanstack/react-start/server' +import { + readLocaleCookie, + serializeLocaleCookie, +} from '@vitnode/core/lib/i18n/locale-cookie' + +import type { Locale } from './shared' + +import { intlQueryOptions } from './query' +import { localeRouting } from './shared' + +/** + * A base for parsing a router href that carries no origin. Never requested, and + * never rendered - only `pathname`, `search` and `hash` are ever read back off + * it. + */ +const RELATIVE_BASE = 'https://vitnode.invalid' + +/** + * The remembered language, wherever this happens to be running. + * + * Only routes outside the localized URL space ever ask - `/admin`, and anything + * else in `DEFAULT_IGNORED_LOCALE_PATHS`. A public URL says which language it is + * in, and this must never get a vote there. + * + * `createIsomorphicFn` is what keeps that one question from becoming two + * functions that drift: the Start compiler keeps only the branch for the bundle + * it is building, so the browser never sees `getRequestHeader` and the server + * never touches `document`. Un-compiled - in tests, under plain Node - the stub + * falls back to the server branch, which is the right default for a test run. + */ +const readCookieLocale = createIsomorphicFn() + .server(() => { + try { + return readLocaleCookie(getRequestHeader('cookie')) + } catch { + // `getRequestHeader` throws outside a request scope, which is where a + // prerender pass and a test both build links from. No request means no + // stored preference, which is exactly what the default locale is for. + return undefined + } + }) + // The locale cookie is deliberately not `HttpOnly`, so this read works: the + // switcher writes it in the browser and the server reads the same one back. + .client(() => readLocaleCookie(globalThis.document?.cookie)) + +/** + * The language a URL is served in - the one authoritative answer. + * + * Everything that needs a locale comes through here: the router rewrite that + * writes prefixes into links, ``, the message query, the switcher. + * There is deliberately no second source to disagree with it. + * + * `publicPathname` is the URL in the address bar, *before* the router rewrote + * the prefix away. Handing it the internal path would resolve every request to + * the default locale. + * + * The cookie is the only source handed to the shared helper, and that is the + * whole contract for a route with no locale in its URL: **cookie, then the + * default.** The helper can also negotiate an `Accept-Language` header and + * deliberately is not asked to - the browser cannot read request headers, so a + * server that answered `pl` from one would hydrate to `en` on the client: a + * flash of the wrong language and a React hydration mismatch on every first + * visit. First-visit negotiation is a product decision that needs its own + * hydration-safe design, not a source quietly added here. + * + * The cast is safe by construction: the answer is either a code this app was + * configured with or the default, never anything from the URL. + */ +export const resolveLocale = (publicPathname: string): Locale => + localeRouting.resolveLocale(publicPathname, { + // A getter, so a public path - which is every request this app serves + // today - never reads a cookie at all. + get cookieLocale() { + return readCookieLocale() + }, + }) as Locale + +/** + * The path shown in the address bar, from a location the router parsed. + * + * Takes the one field it reads rather than a `ParsedLocation`, so it is equally + * callable with `router.latestLocation`, with router state, and with a + * `beforeLoad`'s `location` - three types that differ only in their search + * schema. + */ +export const publicPathnameOf = ({ + publicHref, +}: { + publicHref: string +}): string => new URL(publicHref, RELATIVE_BASE).pathname + +/** + * The router's half of locale routing: one route tree, two public URL shapes. + * + * browser /pl/discover --input--> /discover route tree + * --output-> /pl/discover rendered href + * + * `input` is why no route file ever mentions a locale. It is also why an unknown + * first segment still 404s: only a prefix this app actually writes gets + * stripped, so `/xx/discover` reaches the route tree intact and matches nothing. + * + * `output` reads the locale from the router's own current location rather than + * from `window` or a module variable. That is the same value on the server (a + * memory history seeded with the request) as in the browser (the address bar), + * which is what keeps the `href` React renders during SSR identical to the one + * it renders after hydration. + */ +export const createLocaleRewrite = ( + getRouter: () => AnyRouter | undefined, +): LocationRewrite => ({ + input: ({ url }) => localeRouting.deLocalizeUrl(url), + output: ({ url }) => { + const location = getRouter()?.latestLocation + // Before the router has parsed a location there is nothing to read a locale + // from - and no link has been built yet either. + if (!location) return url + + return localeRouting.localizeUrl( + url, + resolveLocale(publicPathnameOf(location)), + ) + }, +}) + +/** + * The language the page is currently in, as reactive state. + * + * Subscribed to the router's location rather than read off `window`, so a + * language switch re-renders everything downstream of it - the provider, the + * message query, `` - with no reload and no second source of truth. + */ +export const useLocale = (): Locale => + useRouterState({ + select: (state) => resolveLocale(publicPathnameOf(state.location)), + }) + +/** + * Puts a language's messages in the cache before anything renders in it. + * + * Failure is deliberately not fatal: the switch still happens, and the + * provider's own query retries it. A language that cannot be fetched should + * degrade to a moment of loading, not to a switcher that appears to do nothing. + */ +const warmMessages = async (router: AnyRouter, locale: Locale) => { + const { queryClient } = router.options.context as { + queryClient?: QueryClient + } + + try { + await queryClient?.ensureQueryData(intlQueryOptions({ locale })) + } catch { + /* empty */ + } +} + +/** + * Switches the page's language, keeping the visitor exactly where they are. + * + * /discover -> pl -> /pl/discover + * /pl/discover?q=hello -> en -> /discover?q=hello + * /admin/users -> pl -> /admin/users (only the language changes) + * + * Why `history.push` rather than `navigate()`: the locale lives *only* in the + * public URL. Internally `/discover` and `/pl/discover` are the same location, + * and `commitLocation` compares internal hrefs to decide whether anything moved + * - so a `navigate()` to the same route is a no-op and the address bar never + * changes. Pushing the public href is what the router itself does at the end of + * every navigation (`this.history.push(nextHistory.publicHref)`), so this is the + * same client-side transition, not a document reload. + * + * `invalidate()` then re-runs the matched routes. Two reasons: the internal URL + * did not change, so nothing looks stale to the router even though every loader + * that read `context.locale` now holds the previous answer - and on an ignored + * route such as `/admin`, where the URL does not change at all, it is the whole + * of the switch. + * + * Written as a plain function over a router rather than only as a hook, so the + * behaviour above is testable without mounting React. + */ +export const switchLocaleOn = async ( + router: AnyRouter, + locale: Locale, +): Promise => { + if (!localeRouting.isSupportedLocale(locale)) return + + // Fetched before the URL moves, not after. The location store updates the + // moment history does, so the provider re-renders under the new locale - and + // therefore the new query key - while the root loader is still resolving it. + // That is a suspend, and a suspend caused by a store update cannot be + // deferred: the page would blank for a round trip. Warmed first, the switch + // is a re-render with the messages already in hand. + await warmMessages(router, locale) + + const current = new URL(router.latestLocation.publicHref, RELATIVE_BASE) + const next = localeRouting.localizeUrl(current, locale) + + if (next.href !== current.href) { + router.history.push(`${next.pathname}${next.search}${next.hash}`) + } + + await router.invalidate() +} + +/** {@link switchLocaleOn}, bound to the mounted router, plus the cookie write. */ +export const useSwitchLocale = () => { + const router = useRouter() + + return (locale: Locale) => { + // Remembered for the routes whose URL carries no locale - `/admin` - and for + // the next visit. `Secure` only over HTTPS: set on plain `http://localhost` + // the browser drops it without a word, and the choice never sticks. + if (localeRouting.isSupportedLocale(locale)) { + globalThis.document.cookie = serializeLocaleCookie(locale, { + secure: globalThis.location.protocol === 'https:', + }) + } + + void switchLocaleOn(router, locale) + } +} diff --git a/apps/web/src/lib/i18n/query.ts b/apps/web/src/lib/i18n/query.ts new file mode 100644 index 000000000..83bc8d983 --- /dev/null +++ b/apps/web/src/lib/i18n/query.ts @@ -0,0 +1,178 @@ +import { queryOptions } from '@tanstack/react-query' +import { createServerFn } from '@tanstack/react-start' + +import { loadIntlMessages } from '#/server/messages.server' + +import type { Locale } from './shared' + +import { localeRouting } from './shared' + +/** The strings every page needs, whatever else it renders. */ +export const GLOBAL_NAMESPACE = 'core.global' + +/** + * Namespaces in a form two callers cannot spell differently. + * + * Sorted and de-duplicated, because the list is part of the query key: without + * this, `["core.global", "core.discover"]` and `["core.discover", "core.global"]` + * are two cache entries holding the same bytes, fetched twice and invalidated + * separately. + * + * Normalisation only - it assumes strings, and says nothing about whether they + * are acceptable. That is {@link assertNamespace}'s job, and it runs on the + * server where the input is untrusted. + */ +const normalizeNamespaces = (namespaces: readonly string[]): string[] => + [...new Set(namespaces)].sort((a, b) => a.localeCompare(b)) + +/** + * More than any page has ever needed, and few enough that a caller asking for + * thousands is refused rather than served. + */ +export const MAX_NAMESPACES = 16 + +/** `@vitnode/some-plugin.a.b.c` is four; nothing real goes deeper. */ +export const MAX_NAMESPACE_DEPTH = 8 + +/** Comfortably longer than the longest plugin id plus a namespace path. */ +export const MAX_NAMESPACE_LENGTH = 128 + +/** + * Segments that must never reach {@link pickMessages}. + * + * `__proto__`, `constructor` and `prototype` are the three steps of prototype + * pollution. `pickMessages` refuses them too - it is a shared utility and does + * not get to assume its caller checked - but they are rejected here rather than + * quietly dropped, because a request asking for one is not a request with a + * typo in it. + */ +const UNSAFE_NAMESPACE_SEGMENTS: ReadonlySet = new Set([ + '__proto__', + 'constructor', + 'prototype', +]) + +/** + * One namespace, or an error. + * + * Deliberately says *what* was wrong and not *what was sent*: the value is + * attacker-controlled and this message ends up in a server log. + */ +const assertNamespace = (value: unknown, index: number): string => { + const at = `namespaces[${index}]` + + if (typeof value !== 'string') throw new Error(`${at} must be a string.`) + if (value.length === 0) throw new Error(`${at} must not be empty.`) + if (value.length > MAX_NAMESPACE_LENGTH) { + throw new Error(`${at} must be at most ${MAX_NAMESPACE_LENGTH} characters.`) + } + + const segments = value.split('.') + + if (segments.length > MAX_NAMESPACE_DEPTH) { + throw new Error(`${at} must be at most ${MAX_NAMESPACE_DEPTH} segments.`) + } + + for (const segment of segments) { + // `core..global`, a leading dot, a trailing dot - all malformed, and all + // of them paths that would walk somewhere nobody meant. + if (segment.length === 0) { + throw new Error(`${at} must not contain an empty segment.`) + } + if (UNSAFE_NAMESPACE_SEGMENTS.has(segment)) { + throw new Error(`${at} contains a forbidden segment.`) + } + } + + return value +} + +/** + * What the server function will accept. + * + * Everything below treats the argument as arriving from the network, because + * once this app is built that is exactly what it does: a server function is a + * public `POST` endpoint, and nothing about the client that normally calls it is + * enforceable. + * + * The locale is the one field that degrades rather than fails. A stale link to a + * language that has since been removed should still render the page in the + * default language; being strict there would turn a config change into a 500 on + * every old URL. A namespace, by contrast, is only ever sent by this app's own + * code, so anything unexpected is rejected outright - filtering it away silently + * would hide the fact that something is sending it. + * + * Exported for the tests: a server function cannot be invoked outside a request + * scope, so the only way to exercise the boundary directly is to call the + * function that guards it. + */ +export const validateIntlInput = (input: unknown) => { + if (typeof input !== 'object' || input === null) { + throw new Error('Expected an object.') + } + + const { locale, namespaces } = input as { + locale?: unknown + namespaces?: unknown + } + + if (typeof locale !== 'string') throw new Error('locale must be a string.') + if (!Array.isArray(namespaces)) { + throw new Error('namespaces must be an array.') + } + // Checked before validating each entry, so a caller cannot make the server + // walk an arbitrarily long list just to be told the list was too long. + if (namespaces.length > MAX_NAMESPACES) { + throw new Error(`At most ${MAX_NAMESPACES} namespaces may be requested.`) + } + + return { + locale: localeRouting.isSupportedLocale(locale) + ? locale + : localeRouting.defaultLocale, + // `Array.from` rather than `map`: `map` skips holes in a sparse array, so + // an entry could reach normalisation without ever being validated. This + // visits them as `undefined`, which `assertNamespace` rejects. + namespaces: normalizeNamespaces(Array.from(namespaces, assertNamespace)), + } +} + +/** + * One language's messages for one set of namespaces, fetched on the server. + * + * A server function rather than a plain loader: the messages are read from JSON + * inside each package's `dist`, which only exists on the server, and the plugin + * registry they are merged from must never reach the browser bundle. Start + * strips the handler - and everything only it imports - out of the client build. + */ +export const getIntlMessages = createServerFn() + .validator(validateIntlInput) + .handler(async ({ data }) => await loadIntlMessages(data)) + +/** + * The same request, as a query - and the only way the app should ask for it. + * + * The locale is a required argument and part of the key. That is the whole + * point: two languages coexist in one QueryClient, a language switch changes the + * key rather than the value under it, and nothing ever resolves "the current + * locale" from inside a query function, where it would be whatever the last + * render happened to leave behind. + * + * `staleTime: Infinity` - a locale's messages change when the app is redeployed. + */ +export const intlQueryOptions = ({ + locale, + namespaces = [GLOBAL_NAMESPACE], +}: { + locale: Locale + namespaces?: readonly string[] +}) => { + const normalized = normalizeNamespaces(namespaces) + + return queryOptions({ + queryFn: async () => + await getIntlMessages({ data: { locale, namespaces: normalized } }), + queryKey: ['vitnode', 'intl', locale, ...normalized] as const, + staleTime: Infinity, + }) +} diff --git a/apps/web/src/lib/i18n/shared.ts b/apps/web/src/lib/i18n/shared.ts new file mode 100644 index 000000000..e9f9351b6 --- /dev/null +++ b/apps/web/src/lib/i18n/shared.ts @@ -0,0 +1,31 @@ +import type { LocaleRouting } from '@vitnode/core/lib/i18n/locale-routing' + +import { localeRoutingFromConfig } from '@vitnode/core/lib/i18n/locale-routing' + +import { i18n } from '#/i18n' + +/** + * A language this app serves, as a type. `"en" | "pl"`, derived from the config + * rather than written twice. + */ +export type Locale = (typeof i18n.locales)[number]['code'] + +/** + * How this app's URLs carry a language, and the only place that decides it. + * + * Pure string transforms, built from `src/i18n.ts` - no `Request`, no cookies, + * no router, no `window`. That is what lets the same rules run in four places + * that cannot import each other's runtimes: the server middleware that + * canonicalises incoming URLs, the router rewrite that hides the prefix from the + * route tree, the language switcher in the browser, and the tests. + * + * `/admin` and `/api` are outside all of it - see `DEFAULT_IGNORED_LOCALE_PATHS` + * in core for why - and this app takes that default as-is. + */ +export const localeRouting: LocaleRouting = localeRoutingFromConfig(i18n) + +export const { defaultLocale } = localeRouting + +/** Narrows a string - a URL segment, a cookie, a `