From 2395cbd3864747c4a161dc16eb55e22562c45d10 Mon Sep 17 00:00:00 2001 From: Sunil Pai Date: Fri, 24 Oct 2025 16:05:21 +0100 Subject: [PATCH] y-partyserver: ability to send/recieve custom messages on the same websocket --- .changeset/nasty-pans-spend.md | 5 ++ fixtures/tiptap-yjs/src/client/index.tsx | 60 ++++++++++++++++ fixtures/tiptap-yjs/src/server/index.ts | 24 +++++++ packages/y-partyserver/README.md | 73 ++++++++++++++++++++ packages/y-partyserver/src/provider/index.ts | 10 ++- packages/y-partyserver/src/server/index.ts | 72 ++++++++++++++++++- 6 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 .changeset/nasty-pans-spend.md diff --git a/.changeset/nasty-pans-spend.md b/.changeset/nasty-pans-spend.md new file mode 100644 index 00000000..5fd19c5c --- /dev/null +++ b/.changeset/nasty-pans-spend.md @@ -0,0 +1,5 @@ +--- +"y-partyserver": patch +--- + +y-partyserver: ability to send/recieve custom messages on the same websocket diff --git a/fixtures/tiptap-yjs/src/client/index.tsx b/fixtures/tiptap-yjs/src/client/index.tsx index 517d37c0..266243d0 100644 --- a/fixtures/tiptap-yjs/src/client/index.tsx +++ b/fixtures/tiptap-yjs/src/client/index.tsx @@ -1,4 +1,5 @@ import { createRoot } from "react-dom/client"; +import { useEffect, useState } from "react"; import Collaboration from "@tiptap/extension-collaboration"; import CollaborationCursor from "@tiptap/extension-collaboration-cursor"; import { EditorContent, useEditor } from "@tiptap/react"; @@ -20,6 +21,38 @@ function Tiptap() { room: "y-partyserver-text-editor-example" // replace with your own document name }); + const [messages, setMessages] = useState>( + [] + ); + + useEffect(() => { + // Listen for custom messages from the server + const handleCustomMessage = (message: string) => { + try { + const data = JSON.parse(message); + setMessages((prev) => [ + ...prev, + { + id: `${Date.now()}-${Math.random()}`, + text: `${new Date().toLocaleTimeString()}: ${JSON.stringify(data)}` + } + ]); + } catch (error) { + console.error("Failed to parse custom message:", error); + } + }; + + provider.on("custom-message", handleCustomMessage); + + return () => { + provider.off("custom-message", handleCustomMessage); + }; + }, [provider]); + + const sendPing = () => { + provider.sendMessage(JSON.stringify({ action: "ping" })); + }; + const editor = useEditor({ extensions: [ StarterKit.configure({ @@ -44,6 +77,33 @@ function Tiptap() {

A text editor

+ +
+

Custom Messages Demo

+ +
+

Messages:

+ {messages.length === 0 ? ( +

No messages yet

+ ) : ( + messages.map((msg) =>
{msg.text}
) + )} +
+
); } diff --git a/fixtures/tiptap-yjs/src/server/index.ts b/fixtures/tiptap-yjs/src/server/index.ts index 51163cca..9cdd8121 100644 --- a/fixtures/tiptap-yjs/src/server/index.ts +++ b/fixtures/tiptap-yjs/src/server/index.ts @@ -1,4 +1,5 @@ import { routePartykitRequest } from "partyserver"; +import type { Connection } from "partyserver"; import { YServer } from "y-partyserver"; import * as Y from "yjs"; @@ -51,6 +52,29 @@ export class Document extends YServer { update ); } + + // Handle custom messages - example ping/pong + onCustomMessage(connection: Connection, message: string): void { + try { + const data = JSON.parse(message); + + if (data.action === "ping") { + // Reply to the sender + this.sendCustomMessage( + connection, + JSON.stringify({ action: "pong", timestamp: Date.now() }) + ); + + // Broadcast to everyone else + this.broadcastCustomMessage( + JSON.stringify({ action: "notification", text: "Someone pinged!" }), + connection + ); + } + } catch (error) { + console.error("Failed to handle custom message:", error); + } + } } export default { diff --git a/packages/y-partyserver/README.md b/packages/y-partyserver/README.md index 573df6eb..f51c98b1 100644 --- a/packages/y-partyserver/README.md +++ b/packages/y-partyserver/README.md @@ -145,6 +145,79 @@ export class MyDocument extends YServer { `onSave` is called periodically after the document has been edited, and when the room is emptied. It should be used to save the document state to a database or some other external storage. +## Custom Messages + +In addition to Yjs synchronization, you can send custom string messages over the same WebSocket connection. This is useful for implementing custom function calling, chat features, or other real-time communication patterns. + +### Sending custom messages from the client + +```ts +// client.ts +import YProvider from "y-partyserver/provider"; +import * as Y from "yjs"; + +const yDoc = new Y.Doc(); +const provider = new YProvider("localhost:8787", "my-document-name", yDoc); + +// Send a custom message to the server +provider.sendMessage(JSON.stringify({ action: "ping", data: "hello" })); + +// Listen for custom messages from the server +provider.on("custom-message", (message: string) => { + const data = JSON.parse(message); + console.log("Received custom message:", data); +}); +``` + +### Handling custom messages on the server + +```ts +// server.ts +import { YServer } from "y-partyserver"; +import type { Connection } from "partyserver"; + +export class MyDocument extends YServer { + // Override onCustomMessage to handle incoming custom messages + onCustomMessage(connection: Connection, message: string): void { + const data = JSON.parse(message); + + if (data.action === "ping") { + // Send a response back to the specific connection + this.sendCustomMessage( + connection, + JSON.stringify({ + action: "pong", + data: "world" + }) + ); + + // Or broadcast to all connections + this.broadcastCustomMessage( + JSON.stringify({ + action: "notification", + data: "Someone pinged!" + }) + ); + } + } +} +``` + +### Custom message API + +**Client (YProvider):** + +- `provider.sendMessage(message: string)` - Send a custom message to the server +- `provider.on("custom-message", (message: string) => {})` - Listen for custom messages from the server + +**Server (YServer):** + +- `onCustomMessage(connection: Connection, message: string)` - Override to handle incoming custom messages +- `sendCustomMessage(connection: Connection, message: string)` - Send a custom message to a specific connection +- `broadcastCustomMessage(message: string, excludeConnection?: Connection)` - Broadcast a custom message to all connections + +Custom messages are sent as strings. We recommend using JSON for structured data. + ## Learn more For more information, refer to the [official Yjs documentation](https://docs.yjs.dev/ecosystem/editor-bindings). Examples provided in the Yjs documentation should work seamlessly with `y-partyserver` (ensure to replace `y-websocket` with `y-partyserver/provider`). diff --git a/packages/y-partyserver/src/provider/index.ts b/packages/y-partyserver/src/provider/index.ts index a3d871a7..d6846686 100644 --- a/packages/y-partyserver/src/provider/index.ts +++ b/packages/y-partyserver/src/provider/index.ts @@ -143,7 +143,11 @@ function setupWS(provider: WebsocketProvider) { websocket.addEventListener("message", (event) => { if (typeof event.data === "string") { - // ignore text messages + // Handle custom messages with __YPS: prefix + if (event.data.startsWith("__YPS:")) { + const customMessage = event.data.slice(6); // Remove __YPS: prefix + provider.emit("custom-message", [customMessage]); + } return; } provider.wsLastMessageReceived = time.getUnixTime(); @@ -633,4 +637,8 @@ export default class YProvider extends WebsocketProvider { throw err; } } + + sendMessage(message: string) { + this.ws?.send(`__YPS:${message}`); + } } diff --git a/packages/y-partyserver/src/server/index.ts b/packages/y-partyserver/src/server/index.ts index e7d1309a..0fa5c85d 100644 --- a/packages/y-partyserver/src/server/index.ts +++ b/packages/y-partyserver/src/server/index.ts @@ -289,10 +289,80 @@ export class YServer extends Server { return false; } + /** + * Handle custom string messages from the client. + * Override this method to implement custom message handling. + * @param connection - The connection that sent the message + * @param message - The custom message string (without the __YPS: prefix) + */ + // biome-ignore lint/correctness/noUnusedFunctionParameters: so autocomplete works + onCustomMessage(connection: Connection, message: string): void { + // to be implemented by the user + console.warn( + `Received custom message but onCustomMessage is not implemented in ${this.#ParentClass.name}:`, + message + ); + } + + /** + * Send a custom string message to a specific connection. + * @param connection - The connection to send the message to + * @param message - The custom message string to send + */ + sendCustomMessage(connection: Connection, message: string): void { + if ( + connection.readyState !== undefined && + connection.readyState !== wsReadyStateConnecting && + connection.readyState !== wsReadyStateOpen + ) { + return; + } + try { + connection.send(`__YPS:${message}`); + } catch (e) { + console.warn("Failed to send custom message", e); + } + } + + /** + * Broadcast a custom string message to all connected clients. + * @param message - The custom message string to broadcast + * @param excludeConnection - Optional connection to exclude from the broadcast + */ + broadcastCustomMessage( + message: string, + excludeConnection?: Connection + ): void { + const formattedMessage = `__YPS:${message}`; + this.document.conns.forEach((_, conn) => { + if (excludeConnection && conn === excludeConnection) { + return; + } + if ( + conn.readyState !== undefined && + conn.readyState !== wsReadyStateConnecting && + conn.readyState !== wsReadyStateOpen + ) { + return; + } + try { + conn.send(formattedMessage); + } catch (e) { + console.warn("Failed to broadcast custom message", e); + } + }); + } + handleMessage(connection: Connection, message: WSMessage) { if (typeof message === "string") { + // Handle custom messages with __YPS: prefix + if (message.startsWith("__YPS:")) { + const customMessage = message.slice(6); // Remove __YPS: prefix + this.onCustomMessage(connection, customMessage); + return; + } console.warn( - `Received non-binary message. Override onMessage on ${this.#ParentClass.name} to handle string messages if required` + `Received non-prefixed string message. Custom messages should be sent using sendMessage() on the provider.` ); return; }