Realtime Hooks
Hook into WebSocket lifecycle events to control connections, subscriptions, and messages.
Overview
Realtime hooks are defined in edgebase.config.ts under realtime.handlers.hooks. They run inside the Realtime Durable Object.
| Hook | Timing | Behavior | Can Modify | Can Reject |
|---|---|---|---|---|
onConnect | After initial JWT auth | Non-blocking (waitUntil) | No | No |
onSubscribe | After channel access rules pass | Blocking | No | Yes (return false / throw) |
onMessage | Before broadcast to other clients | Blocking | Yes (return payload) | Yes (throw) |
Channel access rules always run before onSubscribe. If a rule rejects the subscription, the hook does not execute.
TypeScript Types
interface AuthContext {
id: string;
role?: string;
isAnonymous?: boolean;
email?: string;
custom?: Record<string, unknown>;
}
interface RealtimeHookCtx {
waitUntil(promise: Promise<unknown>): void;
}
interface RealtimeHooks {
onConnect?: (auth: AuthContext, connectionId: string, ctx: RealtimeHookCtx) =>
Promise<void> | void;
onSubscribe?: (auth: AuthContext, channel: string, ctx: RealtimeHookCtx) =>
Promise<boolean | void> | boolean | void;
onMessage?: (
auth: AuthContext,
channel: string,
message: Record<string, unknown>,
ctx: RealtimeHookCtx,
) => Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
}
Configuration
// edgebase.config.ts
import { defineConfig } from '@edgebase/shared';
export default defineConfig({
realtime: {
authTimeoutMs: 5000,
handlers: {
hooks: {
onConnect: async (auth, connectionId, ctx) => { /* ... */ },
onSubscribe: async (auth, channel, ctx) => { /* ... */ },
onMessage: async (auth, channel, message, ctx) => { /* ... */ },
},
},
namespaces: {
public: { access: { subscribe: () => true } },
},
},
});
onConnect
Fires once when a client first authenticates on the WebSocket connection. Does not fire on re-authentication (token refresh over an existing connection). Non-blocking via ctx.waitUntil().
onConnect: async (auth, connectionId, ctx) => {
// Log connection for analytics
console.log(`User ${auth.id} connected (${connectionId})`);
// Track active connections via external service
ctx.waitUntil(
fetch('https://analytics.example.com/ws-connect', {
method: 'POST',
body: JSON.stringify({
userId: auth.id,
connectionId,
connectedAt: Date.now(),
}),
}).catch(() => {}),
);
},
| Parameter | Type | Description |
|---|---|---|
auth | AuthContext | The authenticated user (always present — unauthenticated connections don't reach this hook) |
connectionId | string | Unique identifier for this WebSocket connection |
ctx | RealtimeHookCtx | Hook context with waitUntil() |
This hook cannot reject connections. To control who can connect, use JWT validation and auth configuration.
onSubscribe
Fires after channel access rules have passed. Use this for additional business logic beyond static rules — e.g., checking payment status, rate limiting subscriptions, or dynamic channel authorization.
onSubscribe: async (auth, channel, ctx) => {
// Premium channels require paid subscription
if (channel.startsWith('premium:') && auth.custom?.plan !== 'pro') {
return false; // Reject subscription
}
// Rate limit: max 10 channel subscriptions per user
// (implement with external counter or KV)
},
| Parameter | Type | Description |
|---|---|---|
auth | AuthContext | The authenticated user |
channel | string | The channel name being subscribed to |
ctx | RealtimeHookCtx | Hook context |
Return value:
| Return | Behavior |
|---|---|
void / undefined / true | Allow the subscription |
false | Reject the subscription silently |
| Throw | Reject the subscription with an error message sent to the client |
When onSubscribe throws, the client receives a WebSocket error frame with the error message. When it returns false, the subscription is silently rejected.
onMessage
Fires when a client sends a broadcast message, before it is forwarded to other subscribers. Can transform the message payload, reject the message, or pass it through unchanged.
onMessage: async (auth, channel, message, ctx) => {
// Filter profanity from chat messages
if (message.text && typeof message.text === 'string') {
return { ...message, text: filterProfanity(message.text) };
}
// Block messages with forbidden content
if (message.type === 'spam') {
throw new Error('Message rejected: spam detected');
}
// Return nothing → send original message unchanged
},
| Parameter | Type | Description |
|---|---|---|
auth | AuthContext | The user sending the message |
channel | string | The broadcast event name |
message | Record<string, unknown> | The message payload |
ctx | RealtimeHookCtx | Hook context |
Return value:
| Return | Behavior |
|---|---|
Record<string, unknown> | Replace the message payload with the returned object |
void / undefined | Send the original message unchanged |
| Throw | Block the message — it is not broadcast to other clients |
Hook Context
RealtimeHookCtx provides:
| Property | Type | Description |
|---|---|---|
waitUntil(promise) | (p: Promise<unknown>) => void | Keep the DO alive for background work |
onConnectis non-blocking — runs viactx.waitUntil(), does not delay the connection.onSubscribeandonMessageare blocking — the operation waits for the hook to complete before proceeding. Keep hook logic fast to avoid delaying real-time communication.