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
29 changes: 29 additions & 0 deletions apps/docs/content/docs/dev/websocket.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -367,3 +367,32 @@ export const NotificationListener = () => {
one are all built in. A user only receives notifications while signed in - a
guest connection has no one to deliver to.
</Callout>

## Follow sign-in and sign-out

A connection learns who it belongs to exactly once, during its opening
handshake, from the sign-in cookie the browser sent with it. Nothing afterwards
can change the server's mind - so when someone signs in or out, the socket has
to be re-opened to say hello again as the new person.

`WebSocketAuthSync` does that for you, and it is already mounted in the default
layout. You only need it if you build your own shell:

```tsx title="your layout"
import { WebSocketAuthSync } from "@vitnode/core/views/layouts/theme/web-socket-auth-sync";

// `userId` is the signed-in user's id, or `null` for a guest.
<WebSocketAuthSync userId={userId} />;
```

It renders nothing and holds no state. Hand it the id, and it re-opens the
shared connection whenever that id changes - guest to user, user to guest, or
one account to another.

<Callout type="info">
If your app reads the session in the browser rather than on the server, pass
`undefined` until you actually know the answer. `undefined` means "still
asking" and is left alone; `null` means "definitely nobody" and counts as a
sign-out. Collapsing the two would re-open the connection on every page load,
which the socket would find quite rude.
</Callout>
104 changes: 104 additions & 0 deletions apps/web/src/components/header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { useSuspenseQuery } from '@tanstack/react-query'
import { useLanguages } from '@vitnode/core/components/languages-provider'
import { LogoVitNode } from '@vitnode/core/components/logo-vitnode'
import { HeaderLayoutContent } from '@vitnode/core/views/layouts/theme/header/header-content'
import {
HEADER_NAV_MESSAGE_KEYS,
headerNavItems,
} from '@vitnode/core/views/layouts/theme/header/header-nav'
import { createTranslator } from 'use-intl'

import type { Locale } from '#/lib/i18n/shared'

import { LanguageSwitcher } from '#/components/language-switcher'
import { MigrationLink } from '#/components/migration-link'
import { useLocale } from '#/lib/i18n/client'
import { intlQueryOptions } from '#/lib/i18n/query'

/** What the header renders strings from: the shell's set, plus the nav labels. */
export const HEADER_NAMESPACES = ['core.global', 'core.search'] as const

/**
* The messages the header renders, as a query the shell's loader can ensure.
*
* Exported so the loader and the component cannot ask for different sets: the
* namespace list is part of the query key, so a shell that warmed
* `["core.global"]` would leave the header suspending on a key nobody fetched.
*/
export const headerIntlQueryOptions = ({ locale }: { locale: Locale }) =>
intlQueryOptions({ locale, namespaces: HEADER_NAMESPACES })

/**
* The main header, on TanStack Start.
*
* The bar, the logo, the nav and the action area are `HeaderLayoutContent` - the
* same module the Next.js pages render, so there is one copy of that markup
* rather than one per framework. What this supplies is the three things a shared
* component cannot resolve for itself: the link, the language switcher, and the
* translated nav.
*
* ## The link is `MigrationLink`
*
* Not the router's `Link` directly. `/`, `/discover` and `/search` are all
* routes this app owns today, so all three are client-side navigations with the
* locale prefix written by Stage 3's rewrite - no prefix is applied here, and
* applying one would produce `/pl/pl/discover`. `MigrationLink` asks the route
* tree that question per href, which is what makes a header link that later
* points at a route the Next.js app still serves (`/files`, `/admin`) a document
* load into that app instead of a TanStack not-found. There is no allowlist of
* migrated routes in that decision - the route tree is the list.
*
* ## The strings, and what the shell has to warm
*
* The labels are `core.search.nav.*` - the same keys the Next.js header reads,
* paired with their hrefs by the same `headerNavItems`. The header sits above
* every route, so it cannot rely on a route's own `RouteMessages`: it reads the
* messages out of the cache itself and translates them with `use-intl`'s
* framework-free `createTranslator`.
*
* That makes one demand on whoever mounts it: **the shell's loader must warm
* {@link headerIntlQueryOptions}**, which `_main`'s does. It is a
* `useSuspenseQuery` with no boundary between it and the document, so an
* unwarmed entry does not degrade - it suspends the whole response. Warming it
* is one line, and it is the same rule every migrated route already follows for
* its own namespaces.
*
* No provider is mounted here. `core.global` - which the theme switcher and the
* language switcher read - is provided by the root route, and the two extra
* words the nav needs are not worth replacing the message tree over.
*/
export const Header = ({
logo = <LogoVitNode className="w-34" />,
user,
}: {
/** The application's mark. Defaults to VitNode's, as both Next.js apps pass. */
logo?: React.ReactNode
/** The session slot - avatar and menu when signed in, sign-in button when not. */
user?: React.ReactNode
}) => {
const locale = useLocale()
const languages = useLanguages()
const { data } = useSuspenseQuery(headerIntlQueryOptions({ locale }))

const t = createTranslator({
locale,
messages: data.messages,
namespace: 'core.search',
})

return (
<HeaderLayoutContent
// One language means nothing to switch to - the same call the Next.js
// header makes, from the provider that was given the list rather than
// from the config.
languageSwitcher={languages.length > 1 ? <LanguageSwitcher /> : null}
LinkComponent={MigrationLink}
logo={logo}
navigation={headerNavItems({
discover: t(HEADER_NAV_MESSAGE_KEYS.discover),
search: t(HEADER_NAV_MESSAGE_KEYS.search),
})}
user={user}
/>
)
}
71 changes: 21 additions & 50 deletions apps/web/src/components/language-switcher.tsx
Original file line number Diff line number Diff line change
@@ -1,67 +1,38 @@
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 { LanguageSwitcherContent } from '@vitnode/core/components/switchers/langs/language-switcher-content'

import { useLocale, useSwitchLocale } from '#/lib/i18n/client'
import { isLocale } from '#/lib/i18n/shared'

/**
* 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.
* The same control as the Next.js app's - literally the same component now
* (`LanguageSwitcherContent`), so the dropdown, the icon, the check mark and the
* `core.global.language_switcher` label cannot drift between the two. What is
* forked is the two lines that navigate: core's Next half replaces the pathname
* through `next-intl`'s locale-aware router, and this one goes through Stage 3's
* `useSwitchLocale`, which pushes the public href and invalidates.
*
* What it preserves is the whole point: the route, its params, its search
* string and its hash. Only the locale prefix changes.
* What it preserves is the whole point: the route, its params, its search string
* and its hash. Only the locale prefix changes - and on a route that carries no
* prefix (`/admin`) the cookie is the whole of the switch. All of that rule lives
* in `#/lib/i18n/client`, not here.
*/
export const LanguageSwitcher = () => {
const languages = useLanguages()
const locale = useLocale()
const switchLocale = useSwitchLocale()
const t = useTranslations('core.global')

return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
aria-label={t('language_switcher')}
size="icon"
variant="ghost"
/>
}
>
<LanguagesIcon />
</DropdownMenuTrigger>

<DropdownMenuContent>
{languages.map((language) => (
<DropdownMenuItem
key={language.code}
onClick={() => {
switchLocale(language.code as Locale)
}}
>
{language.name}

{language.code === locale && (
<CheckIcon aria-hidden className="ml-auto" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<LanguageSwitcherContent
currentLocale={locale}
onSelect={(code) => {
// The list comes from configuration, so this always holds - and narrowing
// it here is what keeps a locale cast out of a click handler.
if (isLocale(code)) switchLocale(code)
}}
options={languages}
/>
)
}
24 changes: 24 additions & 0 deletions apps/web/src/components/layout/main-breadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useMatches } from '@tanstack/react-router'

import { breadcrumbOf } from '#/lib/breadcrumb'

/**
* The shell's breadcrumb area: whichever matched route declared the deepest
* crumb, and nothing at all when none did.
*
* `useMatches()` rather than a `select`: the whole match list changes on
* navigation, which is exactly when this has to re-render, and the router's
* structural sharing has nothing useful to say about a React element.
*
* The crumb owns its own markup, including the container the legacy slot uses
* (`BreadcrumbMain` renders `container mx-auto p-4`), so a route that moves here
* keeps the spacing it had.
*/
export const MainBreadcrumb = () => {
const breadcrumb = breadcrumbOf(useMatches())

// Wrapped rather than returned straight: `ReactNode` includes a promise in
// React 19's types, and a component whose inferred return type includes one
// reads as an async component to every rule that looks for one.
return <>{breadcrumb}</>
}
26 changes: 26 additions & 0 deletions apps/web/src/components/layout/main-header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Header } from '#/components/header'
import { UserHeader } from '#/components/layout/user-header'

/**
* The site header, as the main shell's slot for it.
*
* Two components and no logic, which is the point: `Header` is the bar - the
* logo, the nav, the language and theme switchers - and `UserHeader` is the
* session-dependent half that goes in its action area. Keeping them separate is
* what lets the bar render from the message cache alone while the user area
* reads a query that may still be in flight.
*
* The logo is `Header`'s default (`LogoVitNode`), as it is in both Next.js apps.
* An application with its own mark passes one here and changes nothing else.
*
* ## What the shell owes this
*
* Two warm cache entries, both ensured by `_main`'s loader:
*
* headerIntlQueryOptions -> a `useSuspenseQuery`, so this is required
* prefetchSession -> the first paint shows the visitor, not a gap
*
* See the loader in `routes/_main.tsx`, which states why one is `ensure` and the
* other `prefetch`.
*/
export const MainHeader = () => <Header user={<UserHeader />} />
102 changes: 102 additions & 0 deletions apps/web/src/components/layout/user-header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { useQuery } from '@tanstack/react-query'
import { UserHeaderContent } from '@vitnode/core/views/layouts/theme/header/user/user-header-content'
import { userHeaderState } from '@vitnode/core/views/layouts/theme/header/user/user-header-model'
import { toast } from 'sonner'
import { useTranslations } from 'use-intl'

import { MigrationLink } from '#/components/migration-link'
import { useSignOutAction } from '#/lib/auth/actions'
import { sessionQueryOptions } from '#/lib/auth/query'

/**
* The user area of the main header, wired to this app.
*
* Everything visible is `UserHeaderContent`'s - the avatar, the menu, the guest
* buttons and the placeholder are the same components the Next.js header renders.
* What is here is the three things that component refuses to decide:
*
* the session -> sessionQueryOptions() the one canonical entry
* a link -> MigrationLink the route tree decides per href
* sign-out -> useSignOutAction() Stage 6's action, unchanged
*
* ## One session, read - not fetched
*
* `useQuery` over `sessionQueryOptions()`, which is the *same definition* that
* `_authenticated`'s guard calls through `ensureAuthState`. One key, one cache
* entry, one request: the header cannot show a visitor the guard has already
* turned away, and signing in or out replaces the value both of them read. There
* is no `AuthContext`, no module-level session and no second call to
* `/users/session` - see the long note in `#/lib/auth/query` for why the
* QueryClient's per-request lifetime is what a session needs.
*
* `useQuery` rather than `useSuspenseQuery` on purpose. The header is on every
* page, and suspending it would suspend the shell: with the entry warm both read
* from the cache with no round trip, but when it is *not* warm - a client-side
* arrival at a route whose loader did not ask for it - `useQuery` renders the
* placeholder while `useSuspenseQuery` would hold back the whole page for a
* session only the header needs.
*
* ## What the shell owes it: one line
*
* `_main`'s loader calls `prefetchSession(context.queryClient)`. With
* it, the entry is filled before anything renders, the SSR pass dehydrates it,
* and the very first paint shows the visitor - no placeholder, no shift, and no
* round trip after hydration. Without it this still works, and works correctly:
* the server renders the placeholder and the browser fills it in a moment later.
* So it is a performance requirement rather than a correctness one, which is why
* this component does not try to enforce it.
*
* It is deliberately `prefetchSession` and not `ensureAuthState`. The latter
* *rejects* when the session cannot be read - correct for a guard, because an
* outage must not sign anybody out - and in a shell's loader that same rejection
* would replace every page on the site with an error screen because the header
* could not name the visitor. Both go through `sessionQueryOptions()`, so it is
* still one cache entry either way.
*
* ## The three states are `userHeaderState`'s to name
*
* Including the one that matters here: a session already in hand wins over an
* error, so a failed *refetch* does not flicker a signed-in visitor to anonymous
* and back. A read that has failed with nothing cached shows the guest controls,
* which is what the Next.js header has always done - and which is emphatically
* not what a route guard does with the same failure.
*/
export const UserHeader = () => {
const { data, isError } = useQuery(sessionQueryOptions())
const signOut = useSignOutAction()
const tErrors = useTranslations('core.global.errors')

/**
* Sign-out, and the one thing Stage 6's action leaves to its caller.
*
* The action already does all of it - `DELETE /sign_out`, the cookie cleared
* by the API's own `Set-Cookie`, the anonymous session written into the
* canonical entry, that entry invalidated, and `router.invalidate()` so a
* visitor sitting behind `_authenticated` is redirected out by the guard that
* owns that rule. Nothing is repeated here: no second `invalidate`, no
* `setQueryData` of anything else, no navigation of our own.
*
* What is left is the failure, which the action reports rather than throws. A
* session that could not be ended is a server problem the visitor cannot act
* on, so it is the internal-error toast - the same one the sign-in form raises
* for the same class of answer - and the header stays as it was, which is
* honest: they are still signed in.
*/
const onSignOut = async () => {
const result = await signOut()

if (result.ok) return

toast.error(tErrors('title'), {
description: tErrors('internal_server_error'),
})
}

return (
<UserHeaderContent
LinkComponent={MigrationLink}
onSignOut={onSignOut}
state={userHeaderState({ isError, session: data })}
/>
)
}
Loading
Loading