|
| 1 | +# Hibernation Integration |
| 2 | + |
| 3 | +Hibernation integration lets oRPC leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api), so your server can sleep between events without dropping active connections. |
| 4 | + |
| 5 | +## Installation |
| 6 | + |
| 7 | +::: code-group |
| 8 | + |
| 9 | +```sh [npm] |
| 10 | +npm install @orpc/hibernation@beta |
| 11 | +``` |
| 12 | + |
| 13 | +```sh [yarn] |
| 14 | +yarn add @orpc/hibernation@beta |
| 15 | +``` |
| 16 | + |
| 17 | +```sh [pnpm] |
| 18 | +pnpm add @orpc/hibernation@beta |
| 19 | +``` |
| 20 | + |
| 21 | +```sh [bun] |
| 22 | +bun add @orpc/hibernation@beta |
| 23 | +``` |
| 24 | + |
| 25 | +```sh [deno] |
| 26 | +deno add npm:@orpc/hibernation@beta |
| 27 | +``` |
| 28 | + |
| 29 | +::: |
| 30 | + |
| 31 | +## Setup |
| 32 | + |
| 33 | +```ts |
| 34 | +import { HibernationHandlerPlugin } from '@orpc/hibernation' |
| 35 | + |
| 36 | +const handler = new RPCHandler(router, { |
| 37 | + plugins: [ |
| 38 | + new HibernationHandlerPlugin(), |
| 39 | + ], |
| 40 | +}) |
| 41 | +``` |
| 42 | + |
| 43 | +::: warning |
| 44 | +When combined with the [Batch Plugin](/docs/plugins/batch), make sure procedures that return a `HibernationAsyncIteratorClass` are excluded from batching (e.g. via the batch link plugin's `filter` option), because hibernation cannot work through batched responses. |
| 45 | +::: |
| 46 | + |
| 47 | +## Usage |
| 48 | + |
| 49 | +The plugin provides `HibernationAsyncIteratorClass` and `encodeHibernationRPCEvent` to help you return an [Async Iterator Object](/docs/async-iterator-object) that utilizes the Hibernation APIs. |
| 50 | + |
| 51 | +1. Return a `HibernationAsyncIteratorClass` from your handler |
| 52 | + |
| 53 | + ```ts |
| 54 | + import { HibernationAsyncIteratorClass } from '@orpc/hibernation' |
| 55 | + |
| 56 | + const base = os.$context<{ ws: WebSocket }>() |
| 57 | + |
| 58 | + export const onMessage = base.handler(async ({ context }) => { |
| 59 | + return new HibernationAsyncIteratorClass<{ message: string }>((id) => { |
| 60 | + // Save the ID. You'll need it to send events later. |
| 61 | + context.ws.serializeAttachment({ id }) |
| 62 | + }) |
| 63 | + }) |
| 64 | + ``` |
| 65 | + |
| 66 | +2. Send events to clients with `encodeHibernationRPCEvent` |
| 67 | + |
| 68 | + ```ts |
| 69 | + import { encodeHibernationRPCEvent } from '@orpc/hibernation' |
| 70 | + import * as z from 'zod' |
| 71 | + |
| 72 | + const base = os.$context<{ getWebSockets: () => WebSocket[] }>() |
| 73 | + |
| 74 | + export const sendMessage = base |
| 75 | + .input(z.object({ message: z.string() })) |
| 76 | + .handler(async ({ input, context }) => { |
| 77 | + const websockets = context.getWebSockets() |
| 78 | + |
| 79 | + for (const ws of websockets) { |
| 80 | + const { id } = ws.deserializeAttachment() |
| 81 | + |
| 82 | + // yield an event to all clients |
| 83 | + ws.send(await encodeHibernationRPCEvent(id, { message: input.message }, { |
| 84 | + // override the default RPC serializer if needed |
| 85 | + serializer: new RPCSerializer(), |
| 86 | + })) |
| 87 | + // return an event and stop the iterator |
| 88 | + ws.send(await encodeHibernationRPCEvent(id, { message: input.message }, { event: 'close' })) |
| 89 | + // throw an error and stop the iterator |
| 90 | + ws.send(await encodeHibernationRPCEvent(id, new ORPCError('INTERNAL_SERVER_ERROR'), { event: 'error' })) |
| 91 | + } |
| 92 | + }) |
| 93 | + ``` |
| 94 | + |
| 95 | +::: details Cloudflare Durable Object Chat Room Example? |
| 96 | + |
| 97 | +This example shows how to build a chat room with [Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/) and [WebSocket Hibernation](https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server/). Everyone connected to the same Durable Object can exchange messages. You can try a working version in the Cloudflare Playground, see [Playgrounds](/docs/playgrounds). |
| 98 | + |
| 99 | +::: code-group |
| 100 | + |
| 101 | +```ts [Durable Object] |
| 102 | +import { RPCHandler } from '@orpc/server/websocket' |
| 103 | +import { |
| 104 | + encodeHibernationRPCEvent, |
| 105 | + HibernationAsyncIteratorClass, |
| 106 | + HibernationHandlerPlugin, |
| 107 | +} from '@orpc/hibernation' |
| 108 | +import { onError, os } from '@orpc/server' |
| 109 | +import { DurableObject } from 'cloudflare:workers' |
| 110 | +import * as z from 'zod' |
| 111 | + |
| 112 | +const base = os.$context<{ |
| 113 | + handler: RPCHandler<any> |
| 114 | + ws: WebSocket |
| 115 | + getWebsockets: () => WebSocket[] |
| 116 | +}>() |
| 117 | + |
| 118 | +export const router = { |
| 119 | + send: base.input(z.object({ message: z.string() })).handler(async ({ input, context }) => { |
| 120 | + const websockets = context.getWebsockets() |
| 121 | + |
| 122 | + for (const ws of websockets) { |
| 123 | + const data = ws.deserializeAttachment() |
| 124 | + if (typeof data !== 'object' || data === null) { |
| 125 | + continue |
| 126 | + } |
| 127 | + |
| 128 | + const { id } = data |
| 129 | + |
| 130 | + ws.send(await encodeHibernationRPCEvent(id, input.message)) |
| 131 | + } |
| 132 | + }), |
| 133 | + onMessage: base.handler(async ({ context }) => { |
| 134 | + return new HibernationAsyncIteratorClass<string>((id) => { |
| 135 | + context.ws.serializeAttachment({ id }) |
| 136 | + }) |
| 137 | + }), |
| 138 | +} |
| 139 | + |
| 140 | +const handler = new RPCHandler(router, { |
| 141 | + interceptors: [ |
| 142 | + onError((error) => { |
| 143 | + console.error(error) |
| 144 | + }), |
| 145 | + ], |
| 146 | + plugins: [ |
| 147 | + new HibernationHandlerPlugin(), |
| 148 | + ], |
| 149 | +}) |
| 150 | + |
| 151 | +export class ChatRoom extends DurableObject { |
| 152 | + async fetch(): Promise<Response> { |
| 153 | + const { '0': client, '1': server } = new WebSocketPair() |
| 154 | + |
| 155 | + this.ctx.acceptWebSocket(server) |
| 156 | + |
| 157 | + return new Response(null, { |
| 158 | + status: 101, |
| 159 | + webSocket: client, |
| 160 | + }) |
| 161 | + } |
| 162 | + |
| 163 | + async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> { |
| 164 | + await handler.message(ws, message, { |
| 165 | + context: { |
| 166 | + handler, |
| 167 | + ws, |
| 168 | + getWebsockets: () => this.ctx.getWebSockets(), |
| 169 | + }, |
| 170 | + }) |
| 171 | + } |
| 172 | + |
| 173 | + async webSocketClose(ws: WebSocket): Promise<void> { |
| 174 | + await handler.close(ws) |
| 175 | + } |
| 176 | +} |
| 177 | +``` |
| 178 | + |
| 179 | +```ts [Client] |
| 180 | +import { RPCLink } from '@orpc/client/websocket' |
| 181 | +import { createORPCClient } from '@orpc/client' |
| 182 | +import type { router } from '../../worker/dos/chat-room' |
| 183 | +import type { RouterClient } from '@orpc/server' |
| 184 | + |
| 185 | +const websocket = new WebSocket(`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/chat-room`) |
| 186 | + |
| 187 | +websocket.addEventListener('error', (event) => { |
| 188 | + console.error(event) |
| 189 | +}) |
| 190 | + |
| 191 | +const link = new RPCLink({ |
| 192 | + connect: () => websocket, |
| 193 | +}) |
| 194 | + |
| 195 | +export const chatRoomClient: RouterClient<typeof router> = createORPCClient(link) |
| 196 | +``` |
| 197 | + |
| 198 | +```tsx [Component] |
| 199 | +import { useEffect, useState } from 'react' |
| 200 | +import { chatRoomClient } from '../lib/chat-room' |
| 201 | + |
| 202 | +export function ChatRoom() { |
| 203 | + const [messages, setMessages] = useState<string[]>([]) |
| 204 | + |
| 205 | + useEffect(() => { |
| 206 | + const controller = new AbortController() |
| 207 | + |
| 208 | + void (async () => { |
| 209 | + for await (const message of await chatRoomClient.onMessage(undefined, { signal: controller.signal })) { |
| 210 | + setMessages(messages => [...messages, message]) |
| 211 | + } |
| 212 | + })() |
| 213 | + |
| 214 | + return () => { |
| 215 | + controller.abort() |
| 216 | + } |
| 217 | + }, []) |
| 218 | + |
| 219 | + const sendMessage = async (e: React.FormEvent<HTMLFormElement>) => { |
| 220 | + e.preventDefault() |
| 221 | + |
| 222 | + const form = new FormData(e.target as HTMLFormElement) |
| 223 | + const message = form.get('message') as string |
| 224 | + |
| 225 | + await chatRoomClient.send({ message }) |
| 226 | + } |
| 227 | + |
| 228 | + return ( |
| 229 | + <div> |
| 230 | + <h1>Chat Room</h1> |
| 231 | + <p>Open multiple tabs to chat together</p> |
| 232 | + <ul> |
| 233 | + {messages.map((message, index) => ( |
| 234 | + <li key={index}>{message}</li> |
| 235 | + ))} |
| 236 | + </ul> |
| 237 | + <form onSubmit={sendMessage}> |
| 238 | + <input name="message" type="text" required defaultValue="hello" /> |
| 239 | + <button type="submit">Send</button> |
| 240 | + </form> |
| 241 | + </div> |
| 242 | + ) |
| 243 | +} |
| 244 | +``` |
| 245 | + |
| 246 | +::: |
0 commit comments