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
73 changes: 72 additions & 1 deletion docs/guide/websockets.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ export default defineRoom({
- `ctx.connection.close(code?, reason?)`
- `ctx.connection.setState(data)`

Add `actions` when other server code should reach the room. See [Server-side actions](#server-side-actions).

## `defineWebSocket()`

Use `defineWebSocket()` when each connection is handled independently instead of as a shared room.
Expand Down Expand Up @@ -156,7 +158,76 @@ When Void auth is enabled, every WebSocket hook receives the current user as `ct

Connections from another origin are anonymous unless that origin is in your auth `trustedOrigins`. If you read your own cookies from `ctx.request` in `onBeforeConnect`, check its `Origin` header yourself.

`ctx.user` is resolved when the socket connects and stays the same for that connection, even if the user later signs out or the session is revoked. For actions that must respect a ban or role change, re-check your own records in `onMessage` and close the connection with `ctx.connection.close()` in rooms or `ctx.socket.close()` in `defineWebSocket()`.
`ctx.user` is resolved when the socket connects and stays the same for that connection, even if the user later signs out or the session is revoked. For actions that must respect a ban or role change, re-check your own records in `onMessage` and close the connection with `ctx.connection.close()` in rooms or `ctx.socket.close()` in `defineWebSocket()`. To close idle sockets as soon as a session is revoked, declare a [room action](#server-side-actions) and call it from the code that revokes the session.

## Server-side actions

A room can declare named actions that other server code calls with typed arguments. An action runs inside the room's Durable Object, so it can close connections or broadcast without waiting for a client frame.

```ts
// routes/relay/[accountId].ws.ts
import * as v from 'valibot';
import { getSession } from 'void/auth';
import { defineRoom } from 'void/ws';

const ClientMessage = v.object({ type: v.literal('relay.ping') });
const ServerMessage = v.object({ type: v.literal('relay.message'), text: v.string() });

export default defineRoom({
messages: { client: ClientMessage, server: ServerMessage },
onConnect(ctx) {
// The id comes from the authenticated session, never from client input.
const session = getSession()?.session;
if (session) {
ctx.connection.setState({ sessionId: session.id });
}
},
actions: {
async revokeSession(ctx, sessionId: string, code: number) {
let closed = 0;
for (const connection of ctx.room.getConnections()) {
const state = connection.state as { sessionId?: string } | undefined;
if (state?.sessionId !== sessionId) continue;
connection.close(code, 'session revoked');
closed += 1;
}
return { closed };
},
async announce(ctx, text: string) {
await ctx.room.broadcast({ type: 'relay.message', text });
},
},
});
```

Call an action from a route handler, an auth hook, a queue consumer or a cron job with `getRoom()` from `void/ws/server`:

```ts
import { getRoom } from 'void/ws/server';

// Better Auth's databaseHooks.session.delete.after, or any other server code
const room = getRoom('/relay/[accountId]', { params: { accountId } });
const { closed } = await room.actions.revokeSession(session.id, 4001);
```

`getRoom()` addresses the same instance `connect()` reaches. Pass the route pattern and the same params, and a custom `key` is applied too.

Match connections on server-verified state. A session id read from the URL, a query string or a client message can be any value a client sends, so the revocation would close the wrong sockets. Derive the id from `getSession()` or your own records, store it in `onConnect`, and pass that same id from the code that revokes it.

- Annotate each action parameter. Arguments and results are typed from the definition, and an unannotated parameter is `never`.
- Actions receive `ctx.id`, `ctx.params`, `ctx.env`, `ctx.storage` and `ctx.room`. There is no `ctx.request` or `ctx.user`: other server code is the caller, so authorize the decision where you make it.
- `ctx.room.broadcast()` and connection `send()` accept any event inside an action. The server message schema still validates it at runtime.
- Arguments cross a Durable Object boundary, so pass structured-clone data, not callbacks or class instances.
- An unknown action name or a handler error rejects the call. An unknown route, a `defineWebSocket()` route or a missing param throws before any Durable Object is contacted.
- Do not call an action from the same room's own hooks. Use `ctx.room` there; a call from inside the room waits on the room itself.

Actions are not an endpoint. They run over Durable Object RPC from your Worker, so a browser request or a WebSocket message cannot call one.

`getRoom()` reads the current runtime env, so it works in any handler Void runs. Outside one, pass the bindings object you already hold:

```ts
getRoom('/relay/[accountId]', { params: { accountId }, env: bindings });
```

## Hooks

Expand Down
3 changes: 2 additions & 1 deletion docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ Import Void's app-facing APIs from these paths:
| `void/auth` | `defineAuth`, `getUser`, `getSession`, `requireAuth`, `AuthUser`, `AuthSession`, `AuthState` |
| `void/client` | `fetch`, `fetchStream`, `FetchError`, `auth`, `createAuthClient`, `AuthUser`, `AuthSession`, `AuthState` |
| `void/client/{framework}` | Same as `void/client`, with framework-specific Better Auth clients for `react`, `vue`, `svelte`, and `solid` |
| `void/ws` | `defineRoom`, `defineWebSocket`, `connect`, WebSocket context and connection types |
| `void/ws` | `defineRoom`, `defineWebSocket`, `connect`, WebSocket context, connection and action types |
| `void/ws/server` | `getRoom`, `RoomHandle`, `RoomOptions`, `RoomPath`. Server-only: call room actions from routes, auth hooks, queues and cron jobs. |
| `void/durable` | `defineDurableState`, `defineDurableObject`, typed state/RPC/context/migration types |
| `void/sse` | `eventStream`, `formatSse`, `formatSseText`, `getLastEventId`, `SseStreamClosedError`, SSE message and stream types |
| `void/sse/client` | `connectEventStream`, browser `EventSource` wrapper types |
Expand Down
1 change: 1 addition & 0 deletions packages/void/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ generated entries must preserve the module-boundary rules below.
- **Reserved `__VOID_*` names**: `envSchemaPlugin.config` (every mode, with or without `env.ts`) and `void env check` reject `__VOID_*` names in the root `.env` or declared in `env.ts`. Do not try to strip them from dev `vars` instead: Wrangler reads `.env` itself and writes it over `vars`, so a strip cannot be enforced. Manual trigger auth is a compile-time literal (`__VOID_SERVE` from `CompileOptions.dev`, false in builds and `vite preview`): dev accepts only `x-void-dev-trigger`, builds use the proxy token or build key. Never key it on `import.meta.env.DEV`, which is true in a `NODE_ENV=development` build.
- **Typed Durable State convention**: each default-exported `defineDurableState()` definition in `durable-objects/*.ts` becomes a filename-derived binding and worker class (`shopping-cart.ts` → `SHOPPING_CART` / `ShoppingCartDurableObject`). The generated entry calls the internal `__bind()` hook so named imports can use `State.get(name)` without touching `c.env`, exports `State.class`, and emits a stable `new_sqlite_classes` migration. `syncWranglerDurableObjects()` persists bindings and append-only class migration history in `void.lock.json` through the generated Cloudflare config; never regenerate migration order solely from the current sorted files. The generated class extends `DurableObject` from `cloudflare:workers` so namespace stubs expose real RPC methods; runtime state and helpers use JavaScript private fields and methods. Runtime operations are serialized and persisted after successful typed RPC, `fetch`, or `alarm` handlers; versioned value migrations run on load. This is native-Void + Cloudflare only and is supported by direct Cloudflare deploys. Typed `*.ws.ts` routes likewise use SQLite-backed classes; their root-config sync uses the comment-preserving writer, retains legacy `new_classes` histories, and appends a new migration tag only for new classes.
- **Persistent resource names**: optional static `name` on Durable State and WebSocket definitions uses the same identity derivation as unnamed filenames/routes. Preserve unnamed defaults exactly. `void info` reports names and verified unmatched lock identities without executing source or writing files. Scanners use the shared AST reader; runtime validation imports only the Worker-safe `shared/resource-name.ts` leaf. Carry explicit names through generated metadata and `__bind()`; runtime mismatches fail instead of selecting an inferred replacement. WebSocket `key({ params })` is shared by namespace selection and hook `ctx.id`; absent keys keep the existing ordered `param=value` format.
- **Server room actions**: `defineRoom({ actions })` keeps each action's declared parameters and return type (`RoomActions` accepts any annotated parameter list, so unannotated parameters become `never`). Codegen adds `actions` to `WebSocketRouteMap` for room routes only, and `getRoom()` from `void/ws/server` resolves the same `bindingName` plus `buildWebSocketInstanceId()` key as `forwardWebSocketRequest()`. The generated route table registers route metadata once, after attaching `key`, through `__registerWebSocketRoutes`; the map lives on `globalThis` because dev can evaluate the module twice. Dispatch is Durable Object RPC (`__voidRoomAction`), so the WebSocket DO class extends `DurableObject` from `cloudflare:workers`; its own helpers are `#` methods so they stay off that RPC surface, and actions are never reachable from `fetch`, `webSocketMessage`, or client HTTP.
- **Journal coherence invariant**: `db/migrations/*.sql` and `db/migrations/meta/_journal.json` must stay in sync — every `.sql` file has a matching journal entry (by `tag`) and vice versa. `assertJournalCoherence` in `src/migrations/validate.ts` is the gate, called from `deploy`, `db status`, `db migrate`, and `db reset`. The two CLI commands that mutate migration state — `void gen migration` and `void db rename-migrations` — are responsible for keeping the journal coherent themselves: when the journal has entries, the first delegates to `drizzle-kit generate --custom --name <name>`, which writes the empty `.sql`, appends the journal entry, and writes a snapshot of the current schema (it refuses if the last entry's snapshot is missing and byte-restores the journal if drizzle-kit fails); with no journal entries it writes a plain timestamped `.sql`. The second rewrites `tag` values alongside the file renames and also renames the matching `meta/<prefix>_snapshot.json` (with byte-level rollback on failure). Drift will fail loudly before touching any remote DB. This prevents an orphan scaffolded `.sql` file from half-applying against D1 and blocking later deploys.

- **D1 migration recovery**: Publish the lock and attempt record atomically. Stable attempts register in `_void_migration_fences`; every migration and legacy tracking-table upgrade checks ownership inside its D1 batch. Recovery revokes ownership before reading confirmed migrations, so a resumed executor cannot write after a replacement starts. Duplicate submissions only report progress. Never steal a missing-record lock or interrupt an older/raw-exec attempt without fencing: its owner may still be running. Such interrupted legacy attempts require operator verification that the executor has stopped before manually recovering the database lock.
Expand Down
5 changes: 5 additions & 0 deletions packages/void/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@
"import": "./dist/runtime/ws.mjs",
"require": "./dist/runtime/ws.mjs"
},
"./ws/server": {
"types": "./dist/runtime/ws-room.d.mts",
"import": "./dist/runtime/ws-room.mjs",
"require": "./dist/runtime/ws-room.mjs"
},
"./db": {
"types": "./dist/runtime/db.d.mts",
"import": "./dist/runtime/db.mjs",
Expand Down
8 changes: 8 additions & 0 deletions packages/void/src/codegen/route-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,14 @@ export function generateRouteTypes(
lines.push(
` server: ${imports.map((source) => `ExtractOutput<${source}["server"]>`).join(' | ')};`,
);
const roomSources = sources.filter((route) => route.mode === 'room');
if (roomSources.length > 0) {
const actions = roomSources.map((route) => {
const importPath = relative(outDir, `${routesDir}/${route.filePath}`);
return `NonNullable<(typeof import(${JSON.stringify(importPath)}))["default"]["actions"]>`;
});
lines.push(` actions: ${actions.join(' | ')};`);
}
lines.push(' };');
legacyRouteAlias(lines, 'WebSocketRouteMap', pattern);
}
Expand Down
8 changes: 7 additions & 1 deletion packages/void/src/router/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ export function compileRouteTable(
options.webSocketRuntimePath
) {
lines.push(
`import { createWebSocketDurableObject as __createWebSocketDurableObject, createWebSocketTombstoneDurableObject as __createWebSocketTombstoneDurableObject, forwardWebSocketRequest as __forwardWebSocketRequest } from ${JSON.stringify(options.webSocketRuntimePath)};`,
`import { createWebSocketDurableObject as __createWebSocketDurableObject, createWebSocketTombstoneDurableObject as __createWebSocketTombstoneDurableObject, forwardWebSocketRequest as __forwardWebSocketRequest, __registerWebSocketRoutes } from ${JSON.stringify(options.webSocketRuntimePath)};`,
);
}
if (options.hasLive && options.liveRuntimePath) {
Expand Down Expand Up @@ -409,6 +409,7 @@ export function compileRouteTable(
params: route.params,
className: route.className,
bindingName: route.bindingName,
mode: route.mode,
...(route.name !== undefined && { name: route.name }),
},
)}, ${auth ? '__voidAuthOptions' : 'undefined'}, ${auth ? '__resolveVoidAuthState' : 'undefined'});`,
Expand All @@ -422,6 +423,7 @@ export function compileRouteTable(
params: route.params,
className: route.className,
bindingName: route.bindingName,
mode: route.mode,
...(route.name !== undefined && { name: route.name }),
})),
)};`,
Expand All @@ -431,6 +433,10 @@ export function compileRouteTable(
for (let i = 0; i < webSockets.length; i++) {
lines.push(`__voidWebSocketRoutes[${i}].key = websocket${i}.key;`);
}
if (webSockets.length > 0 && options.webSocketRuntimePath) {
// Register after `key` is attached so getRoom() shares the client's addressing.
lines.push('__registerWebSocketRoutes(__voidWebSocketRoutes);');
}
for (const className of options.webSocketTombstones ?? []) {
lines.push(
`export const ${className} = __createWebSocketTombstoneDurableObject(${JSON.stringify(className)});`,
Expand Down
Loading
Loading