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
43 changes: 43 additions & 0 deletions apps/docs/content/docs/dev/advanced/auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,48 @@ import { TypeTable } from 'fumadocs-ui/components/type-table';
type: 'boolean',
default: 'true',
},
cookieDomain: {
description:
'The Domain to stamp on the auth cookies. Leave it unset unless you share one session across subdomains - see below.',
type: 'string',
default: 'undefined (host-only)',
},
}}
/>

### Sharing a session across subdomains

By default VitNode sends no `Domain` attribute at all, which makes every auth
cookie **host-only**: valid on exactly the host that issued it, and nowhere
else. That is what you want almost always, because the web app serves `/api/*`
on its own origin - there is no second host to share with.

It is also the only setting that survives a hostname nobody configured. A
preview deployment gets a fresh URL per branch, and a cookie stamped with a
`Domain` the response did not come from is thrown away by the browser - so
nobody can sign in, and nothing says why.

If you genuinely run VitNode across several subdomains - say `app.example.com`
and `admin.example.com` - opt in explicitly:

```ts title="src/vitnode.api.config.ts"
VitNodeAPI({
app,
plugins: [],
authorization: {
// [!code ++]
cookieDomain: '.example.com',
},
});
```

<Callout type="warn" title="Pick a domain your app actually serves from">
The value has to be one the responding host falls under. `.example.com` works
for `app.example.com`; `example.com` does not work for `example.org`, and
nothing works for a preview URL on a hosting provider's domain. Get it wrong
and every browser silently drops the cookie.
</Callout>

Changing this later logs everyone out: the browser treats a host-only cookie and
a `Domain` cookie of the same name as two different cookies, so the old one is
no longer the one VitNode reads or removes.
20 changes: 20 additions & 0 deletions apps/web/.cta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"projectName": "web",
"mode": "file-router",
"typescript": true,
"packageManager": "npm",
"includeExamples": false,
"tailwind": true,
"projectPreset": "default",
"addOnOptions": {},
"git": false,
"install": true,
"intent": true,
"routerOnly": false,
"version": 1,
"framework": "react",
"chosenAddOns": [
"eslint",
"nitro"
]
}
27 changes: 27 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
POSTGRES_URL=postgresql://root:root@localhost:5432/vitnode
REDIS_URL=redis://localhost:6379

# This app serves its own API at `/api/*`, so both name the same origin - and it
# has to be the port `pnpm dev` actually serves (`vite dev --port 3001`). Point
# either of them at 3000 and the browser talks to whatever else is on 3000,
# usually `apps/docs`, instead of the API mounted in this process.
#
# Neither side strictly needs them: `resolveApiOrigin()` in
# `src/server/fetcher.server.ts` takes the origin off the request being
# rendered, and in the browser `CONFIG.api` falls back to the origin the page
# was served from - so a preview deployment on a generated hostname needs no
# config at all. Set `NEXT_PUBLIC_API_URL` only to point at a separate API
# server; `NEXT_PUBLIC_WEB_URL` is still what the API stamps cookies with.
NEXT_PUBLIC_WEB_URL=http://localhost:3001
NEXT_PUBLIC_API_URL=http://localhost:3001

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

# === Docker Database Postgres ===
POSTGRES_USER=root
POSTGRES_PASSWORD=root
POSTGRES_NAME=vitnode

# === Docker Redis ===
REDIS_PASSWORD=root
13 changes: 13 additions & 0 deletions apps/web/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
.env
.nitro
.tanstack
.wrangler
.output
.vinxi
__unconfig*
todos.json
11 changes: 11 additions & 0 deletions apps/web/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"files.watcherExclude": {
"**/routeTree.gen.ts": true
},
"search.exclude": {
"**/routeTree.gen.ts": true
},
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
}
}
32 changes: 32 additions & 0 deletions apps/web/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import eslintVitNode from "@vitnode/config/eslint";
import eslintVitNodeReact from "@vitnode/config/eslint.react";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";

const __dirname = dirname(fileURLToPath(import.meta.url));

export default [
...eslintVitNode,
...eslintVitNodeReact,
{
// Build output, not source. `eslint .` walks these otherwise and every file
// in them fails to parse: they are outside `tsconfig.json`'s `include`.
ignores: [
".source",
".nitro/**",
".output/**",
".tanstack/**",
"dist/**",
"src/routeTree.gen.ts",
"prettier.config.js",
],
},
{
languageOptions: {
parserOptions: {
project: "./tsconfig.json",
tsconfigRootDir: __dirname,
},
},
},
];
63 changes: 63 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{
"name": "web",
"private": true,
"type": "module",
"imports": {
"#/*": "./src/*"
},
"scripts": {
"dev": "vite dev --port 3001",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Align the dev port with the same-origin API configuration

The development server listens on port 3001, while apps/web/.env.example configures both NEXT_PUBLIC_API_URL and NEXT_PUBLIC_WEB_URL as http://localhost:3000. When developers use the supplied environment and run this script, fetcherServer and browser-side core fetchers therefore call port 3000 rather than the Hono API mounted in this app; with the existing docs app running there this silently exercises the wrong API, and without it session/API calls fail. Make the configured origin and listening port agree.

Useful? React with 👍 / 👎.

"generate-routes": "tsr generate",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"lint": "eslint .",
"format": "prettier --write . && eslint --fix",
"check": "prettier --check .",
"start": "node .output/server/index.mjs",
"typecheck": "tsc --noEmit",
"test:types": "tsc --noEmit",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@hono/zod-openapi": "^1.5.1",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "^0.10.12",
"@tanstack/react-router": "^1.170.32",
"@tanstack/react-router-devtools": "^1.167.1",
"@tanstack/react-start": "^1.168.49",
"@vitnode/blog": "workspace:*",
"@vitnode/core": "workspace:*",
"@vitnode/example": "workspace:*",
"dotenv": "^17.4.2",
"drizzle-kit": "1.0.0-rc.4",
"drizzle-orm": "1.0.0-rc.4",
"hono": "^4.12.31",
"next-intl": "^4.13.7",
"nitro": "3.0.260610-beta",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwindcss": "^4.1.18",
"zod": "^4.4.3"
},
"devDependencies": {
"@tanstack/devtools-vite": "^0.8.5",
"@tanstack/eslint-config": "^0.4.0",
"@tanstack/router-cli": "^1.132.0",
"@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",
"typescript": "^6.0.2",
"vite": "^8.0.0",
"vitest": "^4.1.10"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"lightningcss"
]
}
}
10 changes: 10 additions & 0 deletions apps/web/prettier.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// @ts-check

/** @type {import('prettier').Config} */
const config = {
semi: false,
singleQuote: true,
trailingComma: "all",
};

export default config;
53 changes: 53 additions & 0 deletions apps/web/src/lib/session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { usersModule } from '@vitnode/core/api/modules/users/users.module'

import { createServerFn } from '@tanstack/react-start'
import { clientModule } from '@vitnode/core/lib/fetcher-client'

import { fetcherServer } from '#/server/fetcher.server'

/**
* The users module by type only, so nothing the API needs at runtime - Hono,
* Drizzle, the plugin tree - is reachable from a module the router imports.
* `clientModule` keeps the route paths, methods and response schemas fully
* typed while carrying just the `pluginId` the fetcher reads.
*/
const users = clientModule<typeof usersModule>('@vitnode/core')

export type SessionApi = Awaited<ReturnType<typeof getSession>>

/**
* The signed-in visitor, or `{ user: null }` - the TanStack Start counterpart of
* `@vitnode/core`'s `getSessionApi()`.
*
* A `createServerFn` rather than a route `loader`, because a loader also runs in
* the browser on client-side navigation and there is no request to read there.
* As a server function it runs on the server both times: directly during SSR,
* and over same-origin RPC afterwards - which carries the visitor's cookies to
* this server, where `fetcherServer` forwards them on.
*
* Deliberately not cached, for the same reason `getSessionApi()` is not: the
* response is per-visitor and changes the moment they sign in or edit their
* profile, so there is no shared entry to hand out. The database work behind it
* is cached in Redis by the API instead.
*
* One call per navigation as long as callers read it through the route's loader
* data. Core wraps its version in React's `cache()` because a Next layout,
* header and page each ask for the session while rendering one page; if the same
* shape appears here, that per-render memoisation has to come with it.
*/
export const getSession = createServerFn().handler(async () => {
const response = await fetcherServer(users, {
method: 'get',
module: 'users',
path: '/session',
})
Comment on lines +39 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate cookies created during the session lookup

When a cookie-less visitor calls getSession, the API's global middleware invokes SessionModel.getUser(), which creates a device record and returns a vitnode_device cookie. This response exists only inside the server function, but the handler never calls saveApiCookies(response), so the browser never receives that cookie and every later session lookup creates another device row. Relay the API cookies before consuming or returning this response.

Useful? React with 👍 / 👎.


// A non-200 (a 429 from the rate limiter, say) carries something other than a
// session, so read it as "nobody is signed in" rather than crashing the render
// while parsing it. One shape either way, so callers never have to narrow.
if (response.status !== 200) {
return { ai: { models: [] }, user: null }
}

return await response.json()
})
86 changes: 86 additions & 0 deletions apps/web/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/* eslint-disable */

// @ts-nocheck

// noinspection JSUnusedGlobalSymbols

// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.

import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ApiSplatRouteImport } from './routes/api/$'

const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSplatRoute = ApiSplatRouteImport.update({
id: '/api/$',
path: '/api/$',
getParentRoute: () => rootRouteImport,
} as any)

export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/api/$': typeof ApiSplatRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/api/$': typeof ApiSplatRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/api/$': typeof ApiSplatRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/api/$'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/api/$'
id: '__root__' | '/' | '/api/$'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ApiSplatRoute: typeof ApiSplatRoute
}

declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/$': {
id: '/api/$'
path: '/api/$'
fullPath: '/api/$'
preLoaderRoute: typeof ApiSplatRouteImport
parentRoute: typeof rootRouteImport
}
}
}

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ApiSplatRoute: ApiSplatRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
19 changes: 19 additions & 0 deletions apps/web/src/router.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'

Check warning on line 2 in apps/web/src/router.tsx

View workflow job for this annotation

GitHub Actions / build

Missed spacing between "@tanstack/react-router" and "./routeTree.gen"

export function getRouter() {
const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
})

return router
}

declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
}
Loading