Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ REDIS_URL=redis://localhost:6379
NEXT_PUBLIC_WEB_URL=http://localhost:3001
NEXT_PUBLIC_API_URL=http://localhost:3001

# The legacy Next.js application (`apps/docs`), while the migration is in
# progress. This app owns `/` and `/discover`; `/blog/*`, `/files/*`, `/search`,
# `/admin/*` and every plugin route are still served by Next.js on 3000, so a
# search result pointing at one has to leave this origin to reach it. Without
# this, `/blog/post-1` resolves against 3001 and 404s here instead.
#
# 3001 this app (vite dev)
# 3000 the legacy app (apps/docs, next dev)
#
# Temporary: delete it, and `src/lib/legacy-app.ts`, with the last legacy route.
# Leave it unset only if something in front of both apps routes by path.
NEXT_PUBLIC_LEGACY_WEB_URL=http://localhost:3000

# === CRON Secret for Internal API Calls ===
CRON_SECRET=your-secure-cron-secret-key

Expand Down
4 changes: 3 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
},
"scripts": {
"dev": "vite dev --port 3001",
"generate-routes": "tsr generate",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
Expand Down Expand Up @@ -52,12 +51,15 @@
"@tanstack/devtools-vite": "^0.8.5",
"@tanstack/eslint-config": "^0.4.0",
"@tanstack/router-cli": "^1.132.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^22.10.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitnode/config": "workspace:*",
"eslint": "^10.7.0",
"jsdom": "^29.1.1",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.2",
"vite": "^8.0.0",
Expand Down
123 changes: 123 additions & 0 deletions apps/web/src/components/migration-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { AnyRouter } from '@tanstack/react-router'

import { Link, useRouter } from '@tanstack/react-router'

import { useLocale } from '#/lib/i18n/client'
import { localeRouting } from '#/lib/i18n/shared'
import { buildLegacyHref, legacyWebOrigin } from '#/lib/legacy-app'

/**
* Linking to a VitNode page while half of VitNode still runs on Next.js.
*
* This app owns three routes today - `/`, `/discover` and the `/api/*` mount -
* and search results point at all of the ones it does not: `/blog/post-30`,
* `/files/...`, `/admin/...`, whatever a plugin indexed. Handing every
* internal-looking path to `<Link>` routes those into *this* router, which has
* nothing to match them with, so a perfectly good blog post becomes a TanStack
* not-found page. During a strangler migration a full document load to the
* running Next.js app is the correct answer, not a fallback.
*
* So: ask the router what it owns, and let it answer.
*
* owned -> <Link>, client-side navigation, locale prefix from the rewrite
* not owned -> <a href>, document navigation, locale prefix applied here
*
* This is deliberately not a cross-framework navigation system, and there is no
* hand-maintained table of migrated routes - the route tree *is* the table. When
* `/blog` is migrated it appears in the generated tree, `isTanStackOwnedPath`
* starts answering `true` for it, and nothing here changes.
*/

/**
* The API mount is not a page.
*
* `/api/$` is a real route in the generated tree - it is how Hono is mounted -
* so it matches, and without this a search result pointing into `/api` would be
* handed to the router as a client-side navigation to a route that renders
* nothing. Matched by route id rather than by a hardcoded pathname, so it stays
* correct if the mount ever moves.
*/
const isApiRouteId = (routeId: string): boolean =>
routeId === '/api' || routeId.startsWith('/api/')

/**
* Whether this app's route tree can render `href` itself.
*
* Three things have to happen before the router is asked, and each one is a way
* this returned the wrong answer while it was being written:
*
* 1. **Strip the query and hash.** `matchRoutes` takes a *pathname*;
* `/discover?a=1` matches nothing.
* 2. **De-localize.** The route tree has no locale in it - that is the whole of
* Stage 3 - so `/pl/discover` matches nothing until the prefix comes off.
* 3. **Reject the API mount.** See {@link isApiRouteId}.
*
* An unmatched path resolves to the root route alone, so "something below the
* root matched" is the test. That also means a root-level catch-all route would
* make every path look owned; there is none today, and
* `migration-link.test.tsx` fails loudly if one appears.
*/
export const isTanStackOwnedPath = (
router: AnyRouter,
href: string,
): boolean => {
// The same rule `rewrite.input` applies, from the same Stage 3 helper - the
// rewrite is `deLocalizeUrl` and nothing else, so this is one rule, not a copy.
const { pathname } = localeRouting.deLocalizeUrl(
new URL(href, 'https://vitnode.invalid'),
)

const matched = router
.matchRoutes(pathname, undefined)
.map((match: { routeId: string }) => match.routeId)
.filter((routeId: string) => routeId !== '__root__')

return matched.length > 0 && !matched.some(isApiRouteId)
}

/**
* A link to anywhere in VitNode, migrated or not.
*
* The two branches differ in origin as well as in mechanism, which is the whole
* point: a relative `/blog/post-1` from this app resolves against *this* app,
* so it turned a client-side not-found into a full-document not-found rather
* than reaching the application that owns the route.
*
* - **Owned.** `<Link to>` takes the *internal* path and stays relative. The
* router's `rewrite.output` writes the locale prefix, so `/discover` renders
* as `/pl/discover` while reading Polish. Neither an origin nor a prefix is
* added here; either would be a duplicate.
* - **Not owned.** The router never sees the URL, so `buildLegacyHref` localizes
* it with the same Stage 3 rule and points it at the legacy origin.
*
* Search parameters and hashes survive both branches untouched.
*/
export const MigrationLink = ({
children,
className,
href,
}: {
children: React.ReactNode
className?: string
href: string
}) => {
const router = useRouter()
const locale = useLocale()

if (isTanStackOwnedPath(router, href)) {
return (
<Link className={className} to={href}>
{children}
</Link>
)
}

return (
<a
className={className}
href={buildLegacyHref({ href, legacyOrigin: legacyWebOrigin(), locale })}
>
{children}
</a>
)
}
61 changes: 61 additions & 0 deletions apps/web/src/components/route-messages.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useSuspenseQuery } from '@tanstack/react-query'
import { IntlProvider as CoreIntlProvider } from '@vitnode/core/lib/i18n/provider'
import { IntlProvider } from 'use-intl'

import { i18n } from '#/i18n'
import { useLocale } from '#/lib/i18n/client'
import { intlQueryOptions } from '#/lib/i18n/query'

/**
* The strings one route renders, scoped to that route.
*
* The root provides `core.global` and nothing else, deliberately: the merged
* message tree holds every plugin's AdminCP copy, and a page should ship only
* the branches it actually renders. This is the other half of that rule - the
* TanStack Start counterpart of `<I18nProvider namespaces={[...]}>`, which is
* how the Next.js pages have always done it.
*
* ## It reads, it does not fetch
*
* `useSuspenseQuery` over the same `intlQueryOptions` the route's loader
* already warmed, so on the first render the entry is there and nothing
* suspends. A route that mounts this **must** ensure the identical options in
* its loader - same locale, same namespaces - or the first paint is a suspend
* and the strings arrive a round trip late.
*
* ## Why two providers
*
* One component, two module records. `@vitnode/core` is external to Vite's SSR
* pass and therefore loaded by Node, while this app's source runs through
* Vite's module runner - so `use-intl` imported here and `use-intl` imported
* inside a core component can be two records with two React contexts. The
* outer one covers this app's own code; the inner comes from core itself
* (`@vitnode/core/lib/i18n/provider`) and so is by construction the record
* every shared component reads. See the long note in `routes/__root.tsx`, which
* has the same shape for the same reason.
*
* Both get the same props from one object: two providers that disagreed would
* render half a page in the wrong language.
*/
export const RouteMessages = ({
children,
namespaces,
}: {
children: React.ReactNode
namespaces: readonly string[]
}) => {
const locale = useLocale()
const { data } = useSuspenseQuery(intlQueryOptions({ locale, namespaces }))

const intlProps = {
locale,
messages: data.messages,
timeZone: i18n.timeZone,
}

return (
<IntlProvider {...intlProps}>
<CoreIntlProvider {...intlProps}>{children}</CoreIntlProvider>
</IntlProvider>
)
}
54 changes: 47 additions & 7 deletions apps/web/src/lib/i18n/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {

import type { Locale } from './shared'

import { intlQueryOptions } from './query'
import { intlQueryOptions, loadedIntlNamespaces } from './query'
import { localeRouting } from './shared'

/**
Expand Down Expand Up @@ -95,6 +95,29 @@ export const publicPathnameOf = ({
publicHref: string
}): string => new URL(publicHref, RELATIVE_BASE).pathname

/**
* An internal href, written in the public shape for one language.
*
* /blog/post-30 + pl -> /pl/blog/post-30
* /blog/post-30 + en -> /blog/post-30
* /admin/users + pl -> /admin/users (an ignored path takes no prefix)
*
* For links the router will never build. Anything it *does* build gets its
* prefix from `rewrite.output` instead, and applying both would produce
* `/pl/pl/...` - so this is only for the migration boundary in
* `components/migration-link.tsx`, where the destination belongs to the Next.js
* app and the router is deliberately not involved.
*
* `localizePathname` is the same Stage 3 rule the rewrite uses and is
* idempotent, so an href that already carries a prefix keeps exactly one. The
* query string and hash are preserved.
*/
export const localizeHref = (href: string, locale: Locale): string => {
const url = localeRouting.localizeUrl(new URL(href, RELATIVE_BASE), locale)

return `${url.pathname}${url.search}${url.hash}`
}

/**
* The router's half of locale routing: one route tree, two public URL shapes.
*
Expand Down Expand Up @@ -143,20 +166,37 @@ export const useLocale = (): Locale =>
/**
* Puts a language's messages in the cache before anything renders in it.
*
* Failure is deliberately not fatal: the switch still happens, and the
* Every set the page is currently showing, not just the global one. The root
* provides `core.global`; a route provides whatever it renders on top of that
* (`RouteMessages`), and both read through `useSuspenseQuery`. Warming only the
* first would leave the second suspending on a key nobody had fetched - which,
* because the suspend is caused by a store update, cannot be deferred: the page
* blanks for a round trip. `loadedIntlNamespaces` answers "which sets" by
* reading the cache, so this stays right as more routes declare their own.
*
* Failure is deliberately not fatal: the switch still happens, and each
* 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
}
if (!queryClient) return

try {
await queryClient?.ensureQueryData(intlQueryOptions({ locale }))
} catch {
/* empty */
}
const current = resolveLocale(publicPathnameOf(router.latestLocation))

await Promise.all(
loadedIntlNamespaces(queryClient, current).map(async (namespaces) => {
try {
await queryClient.ensureQueryData(
intlQueryOptions({ locale, namespaces }),
)
} catch {
/* empty */
}
}),
)
}

/**
Expand Down
48 changes: 47 additions & 1 deletion apps/web/src/lib/i18n/query.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { QueryClient } from '@tanstack/react-query'

import { queryOptions } from '@tanstack/react-query'
import { createServerFn } from '@tanstack/react-start'

Expand All @@ -10,6 +12,19 @@ import { localeRouting } from './shared'
/** The strings every page needs, whatever else it renders. */
export const GLOBAL_NAMESPACE = 'core.global'

/** Everything a message entry's key starts with, before the language. */
const INTL_QUERY_SCOPE = ['vitnode', 'intl'] as const

/**
* One language's slice of the message cache.
*
* Its own function because two things need it: the key each entry is stored
* under, and the prefix `loadedIntlNamespaces` searches by. Spelling the prefix
* out twice would let a search silently stop matching the keys it is looking
* for.
*/
const intlQueryPrefix = (locale: Locale) => [...INTL_QUERY_SCOPE, locale]

/**
* Namespaces in a form two callers cannot spell differently.
*
Expand Down Expand Up @@ -172,7 +187,38 @@ export const intlQueryOptions = ({
return queryOptions({
queryFn: async () =>
await getIntlMessages({ data: { locale, namespaces: normalized } }),
queryKey: ['vitnode', 'intl', locale, ...normalized] as const,
queryKey: [...intlQueryPrefix(locale), ...normalized] as const,
staleTime: Infinity,
})
}

/**
* Every namespace set a client currently holds messages for, in one language.
*
* Read off the cache rather than declared anywhere, and that is the point: the
* root asks for `core.global`, a route asks for whatever it renders, and by the
* time somebody switches language the cache is the only place that knows which
* sets are on screen. A language switch has to warm *those* - warming only the
* global set leaves the route's provider suspending on a key nobody fetched,
* which blanks the page for a round trip.
*
* Falls back to the global set, so a switch made before anything has loaded
* still warms the one set every page needs.
*/
export const loadedIntlNamespaces = (
queryClient: QueryClient,
locale: Locale,
): string[][] => {
const prefix = intlQueryPrefix(locale)
const sets = queryClient
.getQueryCache()
.findAll({ queryKey: prefix })
.map(({ queryKey }) =>
queryKey
.slice(prefix.length)
.filter((part): part is string => typeof part === 'string'),
)
.filter((namespaces) => namespaces.length > 0)

return sets.length > 0 ? sets : [[GLOBAL_NAMESPACE]]
}
Loading
Loading