Cloudflare Workers
@pluv/io
is designed to be self-hosted on the Cloudflare Workers runtime via Durable Objects.
While @pluv/io
does support key-value storage-backed Durable Objects and the standard WebSocket API, this documentation will walkthrough the recommended way to setup @pluv/io
- using WebSocket hibernation on SQLite-backed Durable Objects.
Installation
To install pluv.io for Cloudflare Workers (self-hosted), we will install the following packages from pluv.io:
Purpose | Location | Install command |
---|---|---|
Register websockets and custom events | Server | npm install @pluv/io |
Call and listen to events. Interact with shared storage | Client | npm install @pluv/client |
React-bindings for @pluv/client | Client | npm install @pluv/react |
Adapter for Cloudflare runtime | Server | npm install @pluv/platform-cloudflare |
yjs CRDT | Both | npm install @pluv/crdt-yjs |
Installation Example
Here is an example installation for npm for you to copy:
# For the server
npm install @pluv/io @pluv/platform-cloudflare
# Server peer dependencies
npm install zod
# Optional if you wish to use CRDT storage
# For storage capabilities
npm install @pluv/crdt-yjs
# Storage peer dependencies
npm install yjs
Create PluvIO instance
Define an io (websocket client) instance on the server codebase:
// server/io.ts
import { yjs } from "@pluv/crdt-yjs";
import { createIO } from "@pluv/io";
import { infer, platformCloudflare } from "@pluv/platform-cloudflare";
import { Database } from "./db";
export type Env = {
DB: D1Database;
};
/**
* Infer type of Cloudflare Env
* Note that it is defined outside of the `createIO` function. This is to
* work around TypeScript type-inference limitations.
*/
const types = infer((i) => ({ env: i<Env> }));
export const io = createIO(
platformCloudflare({
// Provide the `env` type
types,
// Optional: Authorization is optional for `@pluv/platform-cloudflare`
// If excluded, your `user` will be `null` on the frontend.
authorize: ({ env }) => ({
// Your own secret for generating JWTs
secret: env.PLUV_AUTH_SECRET,
// The shape of your user object. `id` must be provided
user: z.object({
id: z.string(),
// Here is an additional field we can add
name: z.string(),
}),
}),
// Optional: Context that will be made available in event
// handlers and procedures
context: ({ env }) => ({
db: new Database(env.DATABASE_URL),
}),
// Optional: Only if you want to use CRDT storage features
crdt: yjs,
// Optional: If you want to enable development logging
debug: true,
/**
* Optional: Specify whether WebSocket event listeners should be
* "attached" to or "detached" from the WebSocket upon registration.
*
* Event listeners should be "detached" to use Cloudflare's WebSocket
* hibernation.
*/
mode: "detached", // @default = "detached"
types,
})
);
export const ioServer = io.server({
// Optional: Only if you're using storage, and persisting
// to a database
getInitialStorage: async ({ room, context }) => {
const { db } = context;
// Stubbed example DB query
const rows = await db.sql(
"SELECT storage FROM room WHERE name = ?;",
[room]
);
const storage = rows[0]?.storage ?? null;
return storage;
},
// Optional: Only if you want to run code when a room
// is deleted, such as saving the last storage state
onRoomDeleted: async ({ room, encodedState, context }) => {
// Upsert the db room with last storage state
},
});
Attach to a RoomDurableObject
Next, create a RoomDurableObject
and attach our new PluvServer
to the RoomDurableObject
:
// server/RoomDurableObject.ts
import { InferIORoom } from "@pluv/io";
import { DurableObject } from "cloudflare:workers";
import { Env, ioServer } from "./io";
export class RoomDurableObject extends DurableObject<Env> {
private _room: InferIORoom<typeof ioServer>;
constructor(state: DurableObjectState, env: Env) {
this._room = ioServer.createRoom(state.id.toString(), { env, state });
}
// Only needed if using "detached" mode (i.e. hibernation)
public async webSocketClose(
ws: WebSocket,
code: number,
reason: string
): Promise<void> {
const handler = this._room.onClose(ws);
await handler({ code, reason });
}
// Only needed if using "detached" mode (i.e. hibernation)
public async webSocketError(
ws: WebSocket,
error: unknown
): Promise<void> {
const handler = this._room.onError(ws);
const eventError = error instanceof Error ? error : new Error("Internal Error");
await handler({ error: eventError, message: eventError.message });
}
// Only needed if using "detached" mode (i.e. hibernation)
public async webSocketMessage(
ws: WebSocket,
message: string | ArrayBuffer
): Promise<void> {
const handler = this._room.onMessage(ws);
await handler({ data: message });
}
async fetch(request: Request) {
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("Expected WebSocket", { status: 400 });
}
const alarm = await this.ctx.storage.getAlarm();
if (alarm !== null) await this.ctx.storage.setAlarm(Date.now() + 60_000);
const { 0: client, 1: server } = new WebSocketPair();
// Only needed if you have specified authorize
const token = new URL(request.url).searchParams.get("token");
await this._room.register(server, { request, token });
return new Response(null, { status: 101, webSocket: client });
}
// Recommended to run garbage collection periodically due to edge cases around
// WebSocket hibernation
public async alarm(alarmInfo?: AlarmInvocationInfo): Promise<void> {
await this._room.garbageCollect();
await this.ctx.storage.setAlarm(Date.now() + 60_000);
}
}
Forward request to RoomDurableObject
Lastly, integrate your RoomDurableObject
with your Cloudflare Worker's default handler:
// server/index.ts
import { Hono } from "hono";
import { ioServer } from "./io";
// Bind this in your wrangler.jsonc file
export { RoomDurableObject } from "./RoomDurableObject";
// Stub example to get roomId from url
const parseRoomId = (url: string): string => {
/* get room from req.url */
};
const app = new Hono<{ Bindings: Env }>()
// Only if you specified `authorize` on @pluv/io
.get("/api/pluv/auth", async (c) => {
const room = c.req.query("room") as string;
const request = c.req.raw;
// Example stub. However you get the authed user here
const user = await getUser(request);
const token = await ioServer.createToken({
user,
env: c.env,
request,
room
});
return c.text(token, 200);
})
// Setup your websocket request handler
.get("/api/pluv/room". async (c) => {
const env = c.env;
const roomId = c.req.query("room") as string;
// Assuming wrangler.toml:
// [durable_objects]
// bindings = [{ name = "rooms", class_name = "RoomDurableObject" }]
const durableObjectId = c.env.rooms.idFromName(roomId);
const room = c.env.rooms.get(durableObjectId);
return room.fetch(request);
});
export default { fetch: app.fetch };
Create the client bundle
// frontend/pluv.ts
"use client";
import { createClient } from "@pluv/client";
import { yjs } from "@pluv/crdt-yjs";
import { createBundle, infer } from "@pluv/react";
// Use a type-import, since this will be used on the frontend
import type { ioServer } from "./server/pluv";
// Create `types` outside of `createClient` due to TypeScript inference
// limitations
const types = infer((i) => ({ io: i<typeof ioServer> }));
// Note that the `wsEndpoint` param is omitted when using
// `@pluv/platform-pluv`
export const client = createClient({
types,
// Wherever your auth endpoint was
authEndpoint: ({ room }) => `/api/pluv/auth?room=${room}`,
wsEndpoint: ({ room }) => {
const host = document.location.host;
const protocol = document.location.protocol;
const wsProtocol = protocol.includes("https") ? "wss:" : "ws:";
return `${wsProtocol}//${host}/api/pluv/room?room=${room}`;
},
});
// Destructure the bundle, as the app router will not allow accessing
// properties of objects within server components (e.g. you are unable to
// write pluv.PluvRoomProvider in a layout that is a server component)
export const {
PluvRoomProvider,
useStorage,
useMyself,
useOthers,
useRoom,
useTransact,
// ... Other values as needed
// Alternatively for things that will only be used in client components
// (i.e. not server components)
...pluv,
} = createBundle(client);
Wrap the PluvRoomProvider
At the route the room's layout will be defined, wrap the page with the PluvRoomProvider
that was destructured from the previous step. This is necessary to enable all other functions from createBundle
.
import type { FC, ReactNode } from "react";
interface PageProps {
children?: ReactNode;
params: Promise<{ room: string }>;
}
const Page: FC<PageProps> = async (props) => {
const { room } = await params;
return (
<PluvRoomProvider room={room}>
{children}
</PluvRoomProvider>
);
};
export default Page;
And that's it! With this, functions (e.g. hooks) from createBundle
can be used for components within your provider.