Hosting on Cloudflare

@pluv/io can be hosted on the Cloudflare Workers runtime via Durable Objects. You can define your handler and your DurableObject manually if you need more control. If you only need a basic server, you can use createPluvHandler to get started quickly.

Using with Cloudflare Workers (manual)

Let's step through how we'd put together a real-time API for Cloudflare Workers. The examples below assumes a basic understanding of Cloudflare Workers and Durable Objects.

Install dependencies

# For the server
npm install @pluv/io @pluv/platform-cloudflare

# Server peer-dependencies
npm install zod

Create PluvIO instance

Define an io (websocket client) instance on the server codebase:

// server/io.ts

import { createIO } from "@pluv/io";
import { infer, platformCloudflare } from "@pluv/platform-cloudflare";
import { eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/d1";
import { schema } from "./schema";

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({
        /**
         * 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({
    // Example of using Cloudflare's D1 database with drizzle-orm
    getInitialStorage: async ({ context: { env }, room }) => {
        const db = drizzle(env.DB, { schema });

        const existingRoom = await db
            .select({ encodedState: schema.rooms.encodedState })
            .from(schema.rooms)
            .where(eq(schema.rooms.name, room))
            .get();

        return existingRoom?.encodedState ?? null;
    },
    onRoomDeleted: async ({ encodedState, env, room }) => {
        const db = drizzle(env.DB, { schema });

        await db
            .insert(schema.rooms)
            .values({
                name: room,
                encodedState,
            })
            .onConflictDoUpdate({
                target: schema.rooms.name,
                set: { encodedState },
            })
            .run();
    },
});

// Export the ioServer type, so that this can be type-imported on the
// frontend
export type AppPluvIO = typeof ioServer

Attach to a RoomDurableObject

Next, create a RoomDurableObject and attach our new PluvServer to the RoomDurableObject:

// server/RoomDurableObject.ts

import { type InferIORoom } from "@pluv/io";
import { AppPluvIO, Env, ioServer } from "./io";

export class RoomDurableObject implements DurableObject {
    private _room: IORoom<CloudflarePlatform>;

    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 { 0: client, 1: server } = new WebSocketPair();

        // Only needed if you have configured authentication
        const token = new URL(request.url).searchParams.get("token");

        await this._room.register(
            server,
            // Only needed if you have configured authentication
            { token },
        );

        return new Response(null, { status: 101, webSocket: client });
    }
}

Forward request to RoomDurableObject

Lastly, integrate your RoomDurableObject with your Cloudflare Worker's default handler:

// server/index.ts

const parseRoomId = (url: string): string => {
    /* get room from req.url */
};

const handler = {
    async fetch(req: Request, env: Env): Promise<Response> {
        const roomId = parseRoomId(req.url);
        // In wrangler.toml:
        // [durable_objects]
        // bindings = [{ name = "rooms", class_name = "RoomDurableObject" }]
        const durableObjectId = env.rooms.idFromString(roomId);

        const room = env.rooms.get(durableObjectId);

        return room.fetch(request);
    },
};

export default handler;

createPluvHandler

If you don't need to modify your DurableObject or Cloudflare Worker handler too specifically, @pluv/platform-cloudflare also provides a function createPluvHandler to create a DurableObject and handler for you automatically.

import { createIO } from "@pluv/io";
import {
    createPluvHandler,
    platformCloudflare,
} from "@pluv/platform-cloudflare";
import { Database } from "./database";

const io = createIO(
    platformCloudflare({
        context: ({ env, state }) => ({
            // Example of using context with Cloudflare
            db: new Database(env.DATABASE_URL),

            env,
            state,
        }),
    })
);
const ioServer = io.server();

const Pluv = createPluvHandler({
    // Your PluvServer instance
    io: ioServer,
    // Your durable object binding, defined in wrangler.toml
    binding: "rooms",
    // Optional: Specify the base path from which endpoints are defined
    endpoint: "/api/pluv", // default
    // If your PluvIO instance defines authorization, add your authorization
    // logic here. Return a user if authorized, return null or throw an error
    // if not authorized.
    authorize({ env, request, room }): Promise<User> {
        return {
            id: "abc123",
            name: "pluvrt"
        };
    },
    // Optional: If you want to modify your response before it is returned
    modify: (request, response) => {
        if (request.headers.get("Upgrade") === "websocket") return response;

        // Add custom headers if you want
        response.headers.append("access-control-allow-origin", "*");

        return response;
    },
});

// Export your Cloudflare Worker DurableObject with your own custom name
// Then in wrangler.toml:
// [durable_objects]
// bindings = [{ name = "rooms", class_name = "RoomDurableObject" }]
export const RoomDurableObject = Pluv.DurableObject;

// Export your Cloudflare Worker handler
export default Pluv.handler;

// Alternatively, define your own custom handler
export default {
    async fetch(request: Request, env: Env): Promise<Response> {
        const response = await Pluv.fetch(request, env);

        // matched with the Pluv handler, return response
        if (response) return response;

        // didn't match with Pluv handler, add your own worker logic
        // ...

        return new Response("Not found", { status: 404 });
    }
};