Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nasty-pans-spend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"y-partyserver": patch
---

y-partyserver: ability to send/recieve custom messages on the same websocket
60 changes: 60 additions & 0 deletions fixtures/tiptap-yjs/src/client/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -20,6 +21,38 @@ function Tiptap() {
room: "y-partyserver-text-editor-example" // replace with your own document name
});

const [messages, setMessages] = useState<Array<{ id: string; text: string }>>(
[]
);

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({
Expand All @@ -44,6 +77,33 @@ function Tiptap() {
<div>
<h1 style={{ marginBottom: 20 }}> A text editor </h1>
<EditorContent style={{ border: "solid" }} editor={editor} />

<div style={{ marginTop: 20 }}>
<h2>Custom Messages Demo</h2>
<button
type="button"
onClick={sendPing}
style={{ padding: "10px 20px" }}
>
Send Ping
</button>
<div
style={{
marginTop: 10,
padding: 10,
border: "1px solid #ccc",
maxHeight: 200,
overflowY: "auto"
}}
>
<h3>Messages:</h3>
{messages.length === 0 ? (
<p>No messages yet</p>
) : (
messages.map((msg) => <div key={msg.id}>{msg.text}</div>)
)}
</div>
</div>
</div>
);
}
Expand Down
24 changes: 24 additions & 0 deletions fixtures/tiptap-yjs/src/server/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { routePartykitRequest } from "partyserver";
import type { Connection } from "partyserver";
import { YServer } from "y-partyserver";
import * as Y from "yjs";

Expand Down Expand Up @@ -51,6 +52,29 @@ export class Document extends YServer<Env> {
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 {
Expand Down
73 changes: 73 additions & 0 deletions packages/y-partyserver/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
10 changes: 9 additions & 1 deletion packages/y-partyserver/src/provider/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -633,4 +637,8 @@ export default class YProvider extends WebsocketProvider {
throw err;
}
}

sendMessage(message: string) {
this.ws?.send(`__YPS:${message}`);
}
}
72 changes: 71 additions & 1 deletion packages/y-partyserver/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,10 +289,80 @@ export class YServer<Env = unknown> extends Server<Env> {
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;
}
Expand Down