Skip to content
Merged
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
12 changes: 7 additions & 5 deletions apps/web/content/docs/content-engine/live-editing-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ The AdminCP drives [live editing](/docs/content-engine/live-editing) for you. Th

## Deployment requirements

Live editing runs on two channels. Field locks and the shared draft are plain HTTP routes, so they work everywhere. Presence and rich text co-editing travel over the [WebSocket](/docs/dev/websocket) at `/api/ws`, which only a long-lived Node API server provides, such as `apps/api` or a [self-hosted](/docs/dev/deployments/self-hosted) install.
Live editing runs on two channels. Field locks and the shared draft are plain HTTP routes, so they work everywhere. Presence and rich text co-editing travel over the [WebSocket](/docs/dev/websocket) at `/api/ws`, which a standalone API server or a single app with the [Nitro WebSocket route](/docs/dev/websocket/setup) provides, on [Vercel](/docs/dev/deployments/cloud/vercel) too.

| Deployment | Field locks and autosave | Presence | Rich text |
| ------------------------------------------------------------------ | ---------------------------- | -------- | ----------------------------------------------- |
| Long-lived Node API server with the WebSocket | Live, pushed over the socket | Yes | Co-edited |
| No WebSocket, such as [Vercel](/docs/dev/deployments/cloud/vercel) | Polled over HTTP every 10 s | No | One editor at a time, locked like a plain field |
| Deployment | Field locks and autosave | Presence | Rich text |
| --------------------------------------------- | ---------------------------- | -------- | ----------------------------------------------- |
| WebSocket at `/api/ws` | Live, pushed over the socket | Yes | Co-edited |
| No WebSocket, or while the socket reconnects | Polled over HTTP every 10 s | No | One editor at a time, locked like a plain field |

After a reconnect each editor joins the room again, reloads the locks and the draft, and reopens its rich text documents, which fetches the edits it missed.

A form without a socket polls `GET /{id}/locks` and `GET /{id}/draft` every 10 seconds, on top of lock renewals. Every request counts against the [rate limiter](/docs/dev/advanced/rate-limiter), which is keyed by IP address: the polls against the read budget (300 a minute), lock renewals and draft saves against the write budget (60 a minute). A newsroom behind one office IP can reach the write budget. A refused lock request leaves the field without autosave, so raise `rateLimiter.write.points` if that is you.

Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/dev/advanced/redis.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const vitNodeApiConfig = buildApiConfig({
| :--- | :--- | :--- |
| **API Domain Cache** | No-op (always executes DB query) | Shared key-value store via `c.get("cache")` |
| **Rate Limiter** | In-memory counters per instance | Distributed sliding-window across cluster |
| **WebSocket Realtime** | Single instance only | Cross-instance broadcasts via Redis Pub/Sub |
| **WebSocket Realtime** | Single instance only | Cross-instance delivery via Redis Pub/Sub; required on serverless hosts ([details](/docs/dev/websocket/reconnects#delivery-across-instances)) |
| **Sessions** | Database queries on every request | Instant cache hits with DB fallback |

---
Expand Down
28 changes: 24 additions & 4 deletions apps/web/content/docs/dev/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -344,29 +344,49 @@ the redirect and once for the page.
## `vite.config.ts`

One VitNode plugin, which is the environment handling, the dev server's
dependency pre-bundling, the SSR externals, the Tailwind source list and the
plugin route generator in the order they have to run:
dependency pre-bundling, the SSR externals, the Tailwind source list, the
plugin route generator and Nitro, the server your app runs on, in the order they
have to run:

```ts title="apps/web/vite.config.ts"
import { vitnode } from '@vitnode/core/framework/vite'

export default defineConfig({
plugins: [
vitnode({ appRoot: import.meta.dirname }),
nitro(),
tailwindcss(),
tanstackStart(),
viteReact(),
],
})
```

`vitnode()` already adds `nitro()`, so don't add it again. Nitro's own options go
in `nitro` - a deployment `preset`, `routeRules`, extra `handlers`, anything from
[Nitro's config](https://nitro.build/config):

```ts title="apps/web/vite.config.ts"
vitnode({
appRoot: import.meta.dirname,
nitro: {
preset: 'vercel',
rollupConfig: { external: [/^@sentry\//] },
routeRules: { '/legacy/**': { redirect: '/' } },
},
})
```

VitNode adds its own Nitro settings on top of yours: the SSR externals in a
build, the development error page in `vite dev`, and the `/api/ws` route with
`webSocket: true`. Lists such as `handlers` are merged, so yours and VitNode's
both run.

The Node server from `pnpm start` sends static files and HTML uncompressed. Put
a reverse proxy, a CDN or a platform such as Vercel in front of it and let that
layer compress responses.

Nitro's `compressPublicAssets` can write a `.br` and a `.gz` twin next to every
built asset instead, but it stays off unless you pass it to `nitro()` yourself.
built asset instead, but it stays off unless you turn it on in `nitro` yourself.
It compresses every file in the output at once, and on a 2-core, 8 GB build
machine that is enough to run out of memory. The twins only help when Nitro
itself serves the files: behind a CDN they are never read.
Expand Down
40 changes: 32 additions & 8 deletions apps/web/content/docs/dev/deployments/cloud/vercel.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ A VitNode app builds with Nitro, and Nitro knows Vercel. Push the repository,
point a project at it, and the site plus the Hono API mounted at `/api/*` come
up as one serverless function - no adapter, no `vercel.json`.

Serverless is the catch, not the build. Three things VitNode can do need a
Serverless is the catch, not the build. Two things VitNode can do need a
process that is still alive between requests, and Vercel does not have one - and
nothing there will run your migrations either.
nothing there will run your migrations either. WebSockets are not one of them:
they run on Vercel Functions, with a time limit per connection.

## What works, and what does not

Expand All @@ -22,7 +23,7 @@ nothing there will run your migrations either.
| Postgres, [Redis](/docs/dev/advanced/redis), email, AI | ✅ Works | All reached over the network. Bring managed ones. |
| [S3 / R2](/docs/dev/storage/s3-r2) or Supabase storage | ✅ Works | Uploads go straight to the bucket. |
| [Local file storage](/docs/dev/storage/local) | ❌ Not durable | The adapter writes to `public/uploads` on a filesystem that disappears. |
| [WebSockets](/docs/dev/websocket) | ❌ Unsupported | The `/ws` upgrade needs a long-lived `serve()` with a WebSocket server. |
| [WebSockets](/docs/dev/websocket) | ✅ Works | Nitro serves `/api/ws`. See [WebSockets](#websockets) for the limits. |
| [In-process cron](/docs/dev/cron/node-cron) | ❌ Unsupported | The adapter runs a `*/1 * * * *` timer inside the process. |
| Migrating on deploy | ⚠️ Your job | Nothing on Vercel runs `db:migrate` for you. See [Migrations](#migrations). |

Expand Down Expand Up @@ -135,11 +136,35 @@ empty database becomes the **Administrator** - so do it before you tell anybody
the URL. Then sign in at `/admin` and open **Core → System → Integrations** to
see which services actually came up.

{/* Image prompt: A screenshot of the VitNode AdminCP Integrations board at /admin/core/system/integrations on a production deployment - nine service cards, Redis and Storage showing "Active" badges, WebSocket and Cron showing "Inactive". Dark theme, 1440x900. */}
{/* Image prompt: A screenshot of the VitNode AdminCP Integrations board at /admin/core/system/integrations on a production deployment - nine service cards, Redis, Storage and WebSocket showing "Active" badges, Cron showing "Inactive". Dark theme, 1440x900. */}

</Step>
</Steps>

## WebSockets

A single app serves `/api/ws` from the same deployment as the site, through
Nitro's Vercel preset - no separate server and no realtime service. Set it up as
in [Set up the endpoint](/docs/dev/websocket/setup); new single apps already
are. Vercel runs WebSocket connections on
[Fluid compute](https://vercel.com/docs/fluid-compute), the default for new
projects, so check it is enabled on older ones.

Three things behave differently from a server that stays up:

- **Connections end.** Vercel closes a socket when its function reaches the
maximum duration. The browser reconnects with backoff, rejoins its rooms and
reloads missed state - see
[Reconnects and missed messages](/docs/dev/websocket/reconnects). Limits
depend on your plan; check Vercel's current
[function limits](https://vercel.com/docs/functions/limitations).
- **Instances differ.** The request that sends a notification and the socket
that should receive it often run on different instances of the same
deployment. Set `REDIS_URL`, or messages reach only sockets on the sending
instance.
- **Nothing is replayed.** A socket that is reconnecting misses what was sent
meanwhile. VitNode's own features reload it from the database; yours should too.

## Migrations

`start` never migrates, and on Vercel there is no `start` at all - your code is
Expand Down Expand Up @@ -196,10 +221,9 @@ deployment, and there are exactly two honest ways to do it:
controls - see [Swagger](/docs/dev/swagger).
</Callout>

<Callout type="info" title="Need any of the three?">
Run the app on a server instead. Long-lived WebSocket connections, an
in-process scheduler and local disk are all ordinary there - see
[Self-hosted](/docs/dev/deployments/self-hosted).
<Callout type="info" title="Need either of the two?">
Run the app on a server instead. An in-process scheduler and local disk are
both ordinary there - see [Self-hosted](/docs/dev/deployments/self-hosted).
</Callout>

## Next
Expand Down
21 changes: 11 additions & 10 deletions apps/web/content/docs/dev/deployments/prerendering.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,26 +30,27 @@ A route with a parameter, such as `/solutions/$slug`, is never found on its own.

### Hand the list to Nitro

Pass the paths through `prerenderRoutes` from `@vitnode/core/framework/vite` and give the result to the `prerender.routes` option of `nitro()`:
Pass the paths through `prerenderRoutes` from `@vitnode/core/framework/vite` and give the result to Nitro's `prerender.routes`, through the `nitro` option of `vitnode()`:

```ts title="vite.config.ts"
import { prerenderRoutes, vitnode } from '@vitnode/core/framework/vite' // [!code highlight]
import { nitro } from 'nitro/vite'
import { defineConfig } from 'vite'

import { PRERENDERED_PATHS } from './src/prerender.ts' // [!code highlight]
import { vitNodeConfig } from './src/vitnode.config.ts' // [!code highlight]

export default defineConfig({
plugins: [
vitnode({ appRoot: import.meta.dirname }),
nitro({
// [!code highlight:6]
prerender: {
routes: prerenderRoutes({
i18n: vitNodeConfig.i18n,
paths: PRERENDERED_PATHS,
}),
vitnode({
appRoot: import.meta.dirname,
// [!code highlight:8]
nitro: {
prerender: {
routes: prerenderRoutes({
i18n: vitNodeConfig.i18n,
paths: PRERENDERED_PATHS,
}),
},
},
}),
// tailwindcss(), tanstackStart(), viteReact() as before
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ icon: Radio

import { Tab, Tabs } from "fumadocs-ui/components/tabs"

VitNode provides a unified, multiplexed WebSocket connection at `/api/ws`. Browsers share a single connection across tabs via a Web Lock leader election, authenticated by session cookies.
VitNode provides a unified, multiplexed WebSocket connection at `/api/ws`. Browsers share a single connection across tabs via a Web Lock leader election, authenticated by session cookies. A single app serves it from the same deployment as the site and the API, Vercel included - see [Set up the endpoint](/docs/dev/websocket/setup).

## Quick start

Expand Down Expand Up @@ -158,6 +158,23 @@ c.get("realtime").toRoom(`chat:${roomId}`, chatChannel, {

Each socket in the room receives the payload once, however many of its tabs joined. Put the room or record in the payload so every tab can ignore what is not its own.

### Join again after a reconnect

A new connection starts with no rooms on the server. `useVitNodeWebSocket` returns a `connectionId` that changes every time the shared connection opens, in every tab, so join from an effect that depends on it. `roomsChannel` is `createWebSocketChannel<RoomMessage>({ pluginId: "@acme/chat", module: "rooms", id: "presence" })`, the channel of `roomsSocket` above:

```tsx title="plugins/chat/src/views/chat-room.tsx"
const [clientId] = React.useState(() => crypto.randomUUID())
const { connectionId, send } = useVitNodeWebSocket(roomsChannel)

// [!code ++:4]
React.useEffect(() => {
if (!connectionId) return
send({ clientId, roomId, type: "join" })
}, [clientId, connectionId, roomId, send])
```

[Reconnects and missed messages](/docs/dev/websocket/reconnects) covers reloading what the tab missed while it was away.

### Clean up when a socket closes

A socket that closes leaves all of its rooms. Use `onConnectionClose` to update your own state, such as an "online now" list:
Expand Down Expand Up @@ -194,13 +211,23 @@ Without Redis, all of this works on a single instance with no extra setup: `toRo
- **Tab Leader Election**: When a user opens multiple tabs, a Web Lock elects one leader tab to hold the socket, distributing messages across tabs via `BroadcastChannel`.
- **Automatic Reconnect**: A dropped connection retries after about a second, then backs off (with a little jitter) up to 30 seconds, so a restarted server is not stampeded by every open tab.
- **Offline aware**: When the browser goes offline, the socket closes and stops retrying. It reconnects the moment the network comes back, or when a tab becomes visible again after sleep, without waiting for the backoff.
- **Heartbeat, both ways**: The bundled API server pings every socket every 30 seconds and closes the ones that stopped answering, so vanished clients do not linger. The browser sends its own ping every 25 seconds and reconnects when no reply arrives within 10 seconds, so a half-dead connection after a Wi-Fi drop never pretends to be online.
- **No replay**: Messages sent while a socket is down are not queued. A feature that must not miss updates sends its current state when the socket opens, like the notification count does.
- **Heartbeat, both ways**: The server pings every socket every 30 seconds and closes the ones that stopped answering, so vanished clients do not linger (the standalone API's `ws` server and Nitro's crossws both do). The browser sends its own ping every 25 seconds and reconnects when no reply arrives within 10 seconds, so a half-dead connection after a Wi-Fi drop never pretends to be online.
- **No replay**: Messages sent while a socket is down are not queued. A feature that must not miss updates reloads its state when the socket opens again - see [Reconnects and missed messages](/docs/dev/websocket/reconnects).
- **Multiple Instances**: With `REDIS_URL` set, `sendToUser`, `broadcast` and `toRoom` are relayed to every API instance over Redis pub/sub. Without Redis they only reach clients connected to the same instance.

## Learn More

<Cards>
<Card
title="Set up the endpoint"
description="Serve /api/ws from a single app, Vercel included, or a standalone API"
href="/docs/dev/websocket/setup"
/>
<Card
title="Reconnects and missed messages"
description="Restore rooms and reload state after the socket comes back"
href="/docs/dev/websocket/reconnects"
/>
<Card
title="Notifications"
description="Persistent inbox, unread count, email and digests"
Expand Down
6 changes: 6 additions & 0 deletions apps/web/content/docs/dev/websocket/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"title": "WebSocket",
"description": "Push realtime updates over one shared, cookie-authenticated connection",
"icon": "Radio",
"pages": ["index", "setup", "reconnects"]
}
Loading
Loading