From 7132be4ca2250e12796e72dca6a5c0f884b59395 Mon Sep 17 00:00:00 2001 From: GENTILHOMME Thomas Date: Thu, 23 Jul 2026 18:14:17 +0200 Subject: [PATCH] refactor(network): improve client API surface to reduce front-end boilerplate --- .changeset/goofy-birds-take.md | 6 ++ packages/network/docs/NetworkChannel.md | 9 ++ packages/network/docs/NetworkClient.md | 10 +++ packages/network/src/NetworkChannel.ts | 1 + packages/network/src/NetworkClient.ts | 7 ++ .../network/test/transport/websocket.spec.ts | 6 ++ .../docs/network/PixelSyncServer.md | 2 +- .../docs/network/PixelSyncSession.md | 4 +- .../docs/network/PixelTransport.md | 52 +++++++---- .../pixel-draw-renderer/docs/network/index.md | 10 +-- .../pixel-draw-renderer/docs/network/types.md | 10 ++- .../scripts/WebSocketPixelTransport.ts | 86 ------------------- .../examples/scripts/main.ts | 25 +++--- .../src/network/PixelSyncServer.ts | 74 ++++++---------- .../src/network/PixelSyncSession.ts | 54 ++++++------ .../src/network/PixelTransport.ts | 24 +----- .../pixel-draw-renderer/src/network/index.ts | 3 +- .../pixel-draw-renderer/src/network/types.ts | 8 ++ .../test/PixelArtCanvas.history.spec.ts | 28 ++---- .../test/network/PixelSyncSession.spec.ts | 12 ++- packages/pixel-draw-renderer/vite.config.ts | 13 +-- 21 files changed, 193 insertions(+), 251 deletions(-) create mode 100644 .changeset/goofy-birds-take.md delete mode 100644 packages/pixel-draw-renderer/examples/scripts/WebSocketPixelTransport.ts diff --git a/.changeset/goofy-birds-take.md b/.changeset/goofy-birds-take.md new file mode 100644 index 0000000..082e43e --- /dev/null +++ b/.changeset/goofy-birds-take.md @@ -0,0 +1,6 @@ +--- +"@jolly-pixel/pixel-draw.renderer": minor +"@jolly-pixel/network": minor +--- + +Improve network client API surface (reducing boilerplate required to setup a new client/connection). diff --git a/packages/network/docs/NetworkChannel.md b/packages/network/docs/NetworkChannel.md index 35add7f..d55bf83 100644 --- a/packages/network/docs/NetworkChannel.md +++ b/packages/network/docs/NetworkChannel.md @@ -15,6 +15,7 @@ type NetworkChannelPeerListener = ( interface NetworkChannel { readonly namespace: string; + readonly localClientId: string; send(payload: ClientPayload): void; leave(): void; @@ -35,6 +36,14 @@ readonly namespace: string The namespace this channel is joined to. +### `localClientId` + +```ts +readonly localClientId: string +``` + +Identifies the local peer, copied from the owning [`NetworkClient.clientId`](./NetworkClient.md#clientid). The same value across every channel a given client opens. + ## Methods ### `send` diff --git a/packages/network/docs/NetworkClient.md b/packages/network/docs/NetworkClient.md index fc17d20..5a11863 100644 --- a/packages/network/docs/NetworkClient.md +++ b/packages/network/docs/NetworkClient.md @@ -12,6 +12,16 @@ interface NetworkClientOptions { } ``` +## Properties + +### `clientId` + +```ts +readonly clientId: string +``` + +Identifies this client across every channel it joins. Generated once per connection (`crypto.randomUUID()`), so consumers don't each need to invent their own peer id. Mirrored onto every `NetworkChannel` this client creates as `channel.localClientId`. + ## Methods ### `channel` diff --git a/packages/network/src/NetworkChannel.ts b/packages/network/src/NetworkChannel.ts index d238344..c53aa69 100644 --- a/packages/network/src/NetworkChannel.ts +++ b/packages/network/src/NetworkChannel.ts @@ -11,6 +11,7 @@ export interface NetworkChannel< ServerPayload = unknown > { readonly namespace: string; + readonly localClientId: string; send( payload: ClientPayload diff --git a/packages/network/src/NetworkClient.ts b/packages/network/src/NetworkClient.ts index 95d504b..227e5ef 100644 --- a/packages/network/src/NetworkClient.ts +++ b/packages/network/src/NetworkClient.ts @@ -12,6 +12,12 @@ export interface NetworkClientOptions { * Relies on the global `WebSocket` (available in both environments) */ export class NetworkClient { + /** + * Identifies this client across every channel it joins. Generated once per + * connection so consumers don't each need to invent their own peer id. + */ + readonly clientId: string = crypto.randomUUID(); + #socket: WebSocket; #ready = false; // Messages sent before the socket finishes opening are queued and flushed on `open`. @@ -45,6 +51,7 @@ export class NetworkClient { const channel: NetworkChannel = { namespace, + localClientId: this.clientId, onMessage: null, onPeerJoined: null, onPeerLeft: null, diff --git a/packages/network/test/transport/websocket.spec.ts b/packages/network/test/transport/websocket.spec.ts index f201338..f0bd263 100644 --- a/packages/network/test/transport/websocket.spec.ts +++ b/packages/network/test/transport/websocket.spec.ts @@ -90,6 +90,10 @@ describe("WebsocketTransport + NetworkClient (integration)", () => { const client = new NetworkClient({ url: `ws://127.0.0.1:${port}/ws-sync` }); const channel = client.channel("test-ns"); + assert.equal(typeof client.clientId, "string"); + assert.ok(client.clientId.length > 0); + assert.equal(channel.localClientId, client.clientId); + await waitFor(() => plugin.connected.length === 1); let received: unknown; @@ -128,6 +132,8 @@ describe("WebsocketTransport + NetworkClient (integration)", () => { const clientB = new NetworkClient({ url: `ws://127.0.0.1:${port}/ws-sync-peers` }); clientB.channel("test-ns"); + assert.notEqual(clientA.clientId, clientB.clientId); + await waitFor(() => joined.length === 1); assert.deepEqual(joined, [plugin.connected[1].id]); diff --git a/packages/pixel-draw-renderer/docs/network/PixelSyncServer.md b/packages/pixel-draw-renderer/docs/network/PixelSyncServer.md index 4b31a91..9e4b9e2 100644 --- a/packages/pixel-draw-renderer/docs/network/PixelSyncServer.md +++ b/packages/pixel-draw-renderer/docs/network/PixelSyncServer.md @@ -13,7 +13,7 @@ Workflow, driven by `NetworkServer` once a client joins this server's namespace: 4. `receive(cmd)`: validates, resolves conflicts, applies the command to the buffer, and broadcasts it via the function from `attach()`. 5. `onClientDisconnect(clientId)`: no-op — there's no per-client state to clean up. -Peer presence (`peer-joined`/`peer-left` notifications for other clients on the same namespace) is handled by `NetworkServer` itself, before/after these hooks run — `PixelSyncServer` never sees or sends that traffic. Consumers hook it via `NetworkChannel.onPeerJoined`/`onPeerLeft` client-side (`WebSocketPixelTransport.onPeerJoined`/`onPeerLeft` forward them). +Peer presence (`peer-joined`/`peer-left` notifications for other clients on the same namespace) is handled by `NetworkServer` itself, before/after these hooks run — `PixelSyncServer` never sees or sends that traffic. Consumers hook it via `NetworkChannel.onPeerJoined`/`onPeerLeft` client-side (a `NetworkChannel` already satisfies `PixelTransport.onPeerJoined`/`onPeerLeft`, see [PixelTransport](./PixelTransport.md)). ## Types diff --git a/packages/pixel-draw-renderer/docs/network/PixelSyncSession.md b/packages/pixel-draw-renderer/docs/network/PixelSyncSession.md index 2951d57..0bd978f 100644 --- a/packages/pixel-draw-renderer/docs/network/PixelSyncSession.md +++ b/packages/pixel-draw-renderer/docs/network/PixelSyncSession.md @@ -48,7 +48,7 @@ Stops syncing the attached canvas without announcing anything to peers, restorin destroy(): void ``` -Detaches the canvas and clears the transport's `onCommand`/`onSnapshot` callbacks. Call when the session ends. +Detaches the canvas and clears the transport's `onMessage` callback. Call when the session ends. ## Example @@ -58,7 +58,7 @@ import { PixelSyncSession } from "@jolly-pixel/pixel-draw.renderer"; const session = new PixelSyncSession({ transport: myTransport }); // Attaches the canvas; the buffer's snapshot arrives asynchronously via -// transport.onSnapshot once the underlying connection is up. +// transport.onMessage once the underlying connection is up. session.attach(canvasManager); // Stop syncing (e.g. the user closed this tab/panel). diff --git a/packages/pixel-draw-renderer/docs/network/PixelTransport.md b/packages/pixel-draw-renderer/docs/network/PixelTransport.md index 1c23d71..4d17b58 100644 --- a/packages/pixel-draw-renderer/docs/network/PixelTransport.md +++ b/packages/pixel-draw-renderer/docs/network/PixelTransport.md @@ -10,38 +10,56 @@ interface PixelTransport { readonly localClientId: string; /** Sends a local mutation command to the server / peers. */ - sendCommand(command: PixelNetworkCommand): void; + send(command: PixelNetworkCommand): void; /** - * Called by the transport when a command arrives from a remote peer. - * Set this before connecting. + * Called by the transport when the buffer's snapshot arrives (once, right + * after connecting) or when a command arrives from a remote peer. Set this + * before connecting. */ - onCommand: ((command: PixelNetworkCommand) => void) | null; - - /** - * Called by the transport when the server sends the buffer's snapshot, - * once right after connecting. Set this before connecting. - */ - onSnapshot: ((snapshot: PixelBufferSnapshot) => void) | null; + onMessage: ((message: PixelServerMessage) => void) | null; onPeerJoined: ((peerId: string) => void) | null; onPeerLeft: ((peerId: string) => void) | null; } + +type PixelServerMessage = + | { type: "snapshot"; data: PixelBufferSnapshot; } + | { type: "command"; data: PixelNetworkCommand; }; +``` + +## `@jolly-pixel/network` example + +A [`NetworkChannel`](https://github.com/JollyPixel/editor/blob/main/packages/network/docs/NetworkChannel.md) obtained from `NetworkClient.channel()` already satisfies this interface structurally — `send`/`onMessage`/`onPeerJoined`/`onPeerLeft`/`localClientId` all line up — so no adapter class is needed: + +```ts +import { NetworkClient } from "@jolly-pixel/network"; +import type { + PixelNetworkCommand, + PixelServerMessage +} from "@jolly-pixel/pixel-draw.renderer"; + +const client = new NetworkClient({ url: "ws://localhost:5173/ws-sync" }); +const transport = client.channel( + "pixel-draw:my-canvas" +); + +const session = new PixelSyncSession({ transport }); +session.attach(canvasManager); ``` -## WebSocket example stub +## WebSocket example stub (without `@jolly-pixel/network`) ```ts import type { PixelTransport, PixelNetworkCommand, - PixelBufferSnapshot + PixelServerMessage } from "@jolly-pixel/pixel-draw.renderer"; class WebSocketTransport implements PixelTransport { readonly localClientId = crypto.randomUUID(); - onCommand: ((cmd: PixelNetworkCommand) => void) | null = null; - onSnapshot: ((snapshot: PixelBufferSnapshot) => void) | null = null; + onMessage: ((message: PixelServerMessage) => void) | null = null; onPeerJoined: ((peerId: string) => void) | null = null; onPeerLeft: ((peerId: string) => void) | null = null; @@ -49,15 +67,15 @@ class WebSocketTransport implements PixelTransport { ws.addEventListener("message", (ev) => { const msg = JSON.parse(ev.data as string); switch (msg.type) { - case "snapshot": this.onSnapshot?.(msg.data); break; - case "command": this.onCommand?.(msg.data); break; + case "snapshot": + case "command": this.onMessage?.(msg); break; case "peer-joined": this.onPeerJoined?.(msg.peerId); break; case "peer-left": this.onPeerLeft?.(msg.peerId); break; } }); } - sendCommand(cmd: PixelNetworkCommand): void { + send(cmd: PixelNetworkCommand): void { this.ws.send(JSON.stringify({ type: "command", data: cmd })); } } diff --git a/packages/pixel-draw-renderer/docs/network/index.md b/packages/pixel-draw-renderer/docs/network/index.md index 5b0d0bc..96abfd9 100644 --- a/packages/pixel-draw-renderer/docs/network/index.md +++ b/packages/pixel-draw-renderer/docs/network/index.md @@ -8,10 +8,10 @@ implementation: this package has no dependency on voxel-renderer. ## Architecture ``` -┌───────────────┐ onBufferUpdated ┌──────────────────┐ sendCommand ┌─────────────┐ +┌───────────────┐ onBufferUpdated ┌──────────────────┐ send ┌─────────────┐ │ PixelArtCanvas │───────────────────▶│ PixelSyncSession │────────────────▶│ Transport │ │ │ │ (one buffer) │◀────────────────│ (WebSocket, │ -│ │◀──applyRemote──────│ │ onCommand │ WebRTC, ...) │ +│ │◀──applyRemote──────│ │ onMessage │ WebRTC, ...) │ └───────────────┘ └──────────────────┘ └──────┬──────┘ │ wire ▼ @@ -40,12 +40,12 @@ buffer creation/discovery is a future extension. whatever handler was already set on the canvas rather than replacing it, so a consumer's own local reaction keeps firing once sync is layered on. 2. `PixelSyncSession` stamps the event with `clientId` / `seq` / `timestamp` - and calls `transport.sendCommand(cmd)`. + and calls `transport.send(cmd)`. 3. The transport delivers the command to [`PixelSyncServer.receive()`](./PixelSyncServer.md). 4. The server resolves conflicts (see [ConflictResolver](./ConflictResolver.md)), applies the command to its authoritative `PixelBuffer`, and broadcasts it to every client connected to that namespace. -5. Each connected client's transport calls `onCommand(cmd)`, which - `PixelSyncSession` routes to `PixelArtCanvas.applyRemoteCommand()`. +5. Each connected client's transport calls `onMessage({ type: "command", data: cmd })`, + which `PixelSyncSession` routes to `PixelArtCanvas.applyRemoteCommand()`. 6. `applyRemoteCommand` suppresses `onBufferUpdated` while applying, so the result is never re-broadcast: no echo loop. diff --git a/packages/pixel-draw-renderer/docs/network/types.md b/packages/pixel-draw-renderer/docs/network/types.md index 5c43a62..8e6382f 100644 --- a/packages/pixel-draw-renderer/docs/network/types.md +++ b/packages/pixel-draw-renderer/docs/network/types.md @@ -29,6 +29,14 @@ interface PixelBufferSnapshot { pixels: string; uvRegions: UVRegion[]; } + +/** + * Wire envelope a transport delivers to `PixelTransport.onMessage`: either + * the buffer's initial snapshot, or a live command from a peer. + */ +type PixelServerMessage = + | { type: "snapshot"; data: PixelBufferSnapshot; } + | { type: "command"; data: PixelNetworkCommand; }; ``` -`PixelBufferHookEvent` (the `"stroke"` / `"resized"` / `"texture-replaced"` / `"global-fill"` / `"uv-region-created"` / `"uv-region-deleted"` / `"uv-region-moved"` local-mutation events) is defined in [buffer/PixelBuffer.md](../buffer/PixelBuffer.md); a `PixelNetworkCommand` is that same event shape enriched with the header fields. Eight actions total. All pixel payloads (`stroke` positions and `global-fill`'s colors excepted) are raw RGBA bytes, base64-encoded via `js-base64`: no image codec dependency, so `PixelSyncServer` stays headless. Commands are plain JSON-serializable objects. `PixelBufferSnapshot.uvRegions` carries the buffer's full current UV region set, so a client connecting mid-session learns about every region that already exists (see [uv/UVMap.md](../uv/UVMap.md)). +`PixelBufferHookEvent` (the `"stroke"` / `"resized"` / `"texture-replaced"` / `"global-fill"` / `"uv-region-created"` / `"uv-region-deleted"` / `"uv-region-moved"` local-mutation events) is defined in [buffer/PixelBuffer.md](../buffer/PixelBuffer.md); a `PixelNetworkCommand` is that same event shape enriched with the header fields. Eight actions total. All pixel payloads (`stroke` positions and `global-fill`'s colors excepted) are raw RGBA bytes, base64-encoded via `js-base64`: no image codec dependency, so `PixelSyncServer` stays headless. Commands are plain JSON-serializable objects. `PixelBufferSnapshot.uvRegions` carries the buffer's full current UV region set, so a client connecting mid-session learns about every region that already exists (see [uv/UVMap.md](../uv/UVMap.md)). `PixelServerMessage` is the discriminated union `PixelTransport.onMessage` receives — see [PixelTransport](./PixelTransport.md). diff --git a/packages/pixel-draw-renderer/examples/scripts/WebSocketPixelTransport.ts b/packages/pixel-draw-renderer/examples/scripts/WebSocketPixelTransport.ts deleted file mode 100644 index bf15886..0000000 --- a/packages/pixel-draw-renderer/examples/scripts/WebSocketPixelTransport.ts +++ /dev/null @@ -1,86 +0,0 @@ -// Import Third-party Dependencies -import { - NetworkClient, - type NetworkChannel -} from "@jolly-pixel/network"; - -// Import Internal Dependencies -import type { - PixelBufferSnapshot, - PixelNetworkCommand, - PixelTransport -} from "../../src/network/index.ts"; - -export interface WebSocketPixelTransportOptions { - url: string; - /** - * Namespace of the `PixelSyncServer` instance backing this buffer. - * @default "pixel-draw" - */ - namespace?: string; -} - -type ServerMessage = - | { type: "snapshot"; data: PixelBufferSnapshot; } - | { type: "command"; data: PixelNetworkCommand; }; - -/** - * Browser-side `PixelTransport`, backed by a `NetworkClient` channel joined - * under a single buffer's `PixelSyncServer` namespace. - */ -export class WebSocketPixelTransport implements PixelTransport { - readonly localClientId = crypto.randomUUID(); - - onCommand: ((command: PixelNetworkCommand) => void) | null = null; - onSnapshot: ((snapshot: PixelBufferSnapshot) => void) | null = null; - onPeerJoined: ((peerId: string) => void) | null = null; - onPeerLeft: ((peerId: string) => void) | null = null; - - #client: NetworkClient; - #channel: NetworkChannel; - - constructor( - options: WebSocketPixelTransportOptions - ) { - const { url, namespace = "pixel-draw" } = options; - - this.#client = new NetworkClient({ url }); - - this.#channel = this.#client.channel( - namespace - ); - this.#channel.onMessage = (payload) => { - this.#handleMessage(payload); - }; - this.#channel.onPeerJoined = (peerId) => { - this.onPeerJoined?.(peerId); - }; - this.#channel.onPeerLeft = (peerId) => { - this.onPeerLeft?.(peerId); - }; - } - - sendCommand( - command: PixelNetworkCommand - ): void { - this.#channel.send(command); - } - - destroy(): void { - this.#channel.leave(); - this.#client.destroy(); - } - - #handleMessage( - message: ServerMessage - ): void { - switch (message.type) { - case "snapshot": - this.onSnapshot?.(message.data); - break; - case "command": - this.onCommand?.(message.data); - break; - } - } -} diff --git a/packages/pixel-draw-renderer/examples/scripts/main.ts b/packages/pixel-draw-renderer/examples/scripts/main.ts index a653cbc..fba4ab7 100644 --- a/packages/pixel-draw-renderer/examples/scripts/main.ts +++ b/packages/pixel-draw-renderer/examples/scripts/main.ts @@ -2,17 +2,21 @@ import * as THREE from "three"; import { Runtime, loadRuntime } from "@jolly-pixel/runtime"; import { ResizeHandle } from "@jolly-pixel/resize-handle"; +import { NetworkClient } from "@jolly-pixel/network"; // Import Internal Dependencies import type { PixelArtCanvas } from "../../src/index.ts"; -import { PixelSyncSession } from "../../src/network/index.ts"; +import { + PixelSyncSession, + type PixelNetworkCommand, + type PixelServerMessage +} from "../../src/network/index.ts"; import { CameraBehavior } from "./components/Camera.ts"; import { CubeFactory } from "./components/CubeFactory.ts"; import { OrbitControlsBehavior } from "./components/OrbitControlsBehavior.ts"; import { type PixelDrawPanel } from "./ui/PixelDrawPanel.ts"; import { CubeGallery } from "./CubeGallery.ts"; import { CubePicker } from "./CubePicker.ts"; -import { WebSocketPixelTransport } from "./WebSocketPixelTransport.ts"; // Every tab that opens this demo joins the same namespace, so pointing a // collaborator at the same URL joins them onto the same canvas. Must match @@ -141,21 +145,20 @@ async function initRuntime(): Promise { return runtime; } -// PixelSyncSession.attach() chains onto whatever local `onBufferUpdated` -// handler the canvas already has +// PixelSyncSession.attach() chains onto whatever local `onBufferUpdated` handler the canvas already has. function initializeWebsocketTransport( canvasManager: PixelArtCanvas ) { const wsProtocol = location.protocol === "https:" ? "wss:" : "ws:"; - const transport = new WebSocketPixelTransport({ - url: `${wsProtocol}//${location.host}/ws-sync`, - namespace: DEMO_NAMESPACE + const client = new NetworkClient({ + url: `${wsProtocol}//${location.host}/ws-sync` }); + const transport = client.channel( + DEMO_NAMESPACE + ); transport.onPeerJoined = (peerId) => console.log(`[pixel-sync] peer joined: ${peerId}`); transport.onPeerLeft = (peerId) => console.log(`[pixel-sync] peer left: ${peerId}`); - const syncSession = new PixelSyncSession({ - transport - }); - syncSession.attach(canvasManager); + const session = new PixelSyncSession({ transport }); + session.attach(canvasManager); } diff --git a/packages/pixel-draw-renderer/src/network/PixelSyncServer.ts b/packages/pixel-draw-renderer/src/network/PixelSyncServer.ts index 3706c99..c34cd43 100644 --- a/packages/pixel-draw-renderer/src/network/PixelSyncServer.ts +++ b/packages/pixel-draw-renderer/src/network/PixelSyncServer.ts @@ -56,11 +56,7 @@ export interface PixelSyncServerOptions { } /** - * Manages authoritative state for a single pixel buffer and its client - * synchronization. Injected into a `NetworkServer` under its own namespace, - * so it only ever sees clients that explicitly joined it. Peer presence - * (join/leave notifications for other clients) is handled by `NetworkServer` - * itself, not here. + * Manages authoritative state for a single pixel buffer and its client synchronization. */ export class PixelSyncServer extends NetworkPlugin { readonly namespace: string; @@ -82,14 +78,10 @@ export class PixelSyncServer extends NetworkPlugin { this.#resolver = options.conflictResolver ?? new LastWriteWinsResolver(); } - /** - * Sends the buffer's current snapshot to the newly connected peer. - * Delivery to other clients is handled by the broadcast function `attach()` - * provides — this server doesn't track its own client list. - */ onClientConnect( client: ClientHandle ): void { + // Sends the buffer's current snapshot to the newly connected peer. client.send({ type: "snapshot", data: this.snapshot() @@ -102,10 +94,6 @@ export class PixelSyncServer extends NetworkPlugin { // No client-list bookkeeping to clean up — NetworkServer owns that. } - /** - * Receives the function `NetworkServer` uses to fan a payload out to every - * client currently joined to this server's namespace. - */ attach( broadcast: (payload: unknown) => void ): void { @@ -123,35 +111,24 @@ export class PixelSyncServer extends NetworkPlugin { this.receive(payload); } - /** - * Applies and broadcasts an incoming command. - */ receive( cmd: PixelNetworkCommand ): void { - if (cmd.action === "stroke") { - this.#receiveStroke(cmd); - - return; + switch (cmd.action) { + case "stroke": + this.#receiveStroke(cmd); + break; + case "select-edit": + this.#receiveSelectEdit(cmd); + break; + case "uv-region-moved": + case "uv-region-deleted": + this.#receiveUvRegionCommand(cmd); + break; + default: + applyCommandToBuffer(this.buffer, cmd); + this.#broadcast(cmd); } - - if (cmd.action === "select-edit") { - this.#receiveSelectEdit(cmd); - - return; - } - - if ( - cmd.action === "uv-region-moved" || - cmd.action === "uv-region-deleted" - ) { - this.#receiveUvRegionCommand(cmd); - - return; - } - - applyCommandToBuffer(this.buffer, cmd); - this.#broadcast(cmd); } #receiveStroke( @@ -190,11 +167,7 @@ export class PixelSyncServer extends NetworkPlugin { } /** - * Resolves per-pixel like `#receiveStroke`, sharing the same - * `#lastHeaderByPixel` history — a select-edit and a concurrent stroke - * touching the same pixel compete for it just like two strokes would. - * Unlike a stroke's single uniform color, accepted positions and their - * per-pixel colors must be filtered in lockstep. + * Resolves per-pixel like `#receiveStroke`, sharing the same `#lastHeaderByPixel` history */ #receiveSelectEdit( cmd: PixelSelectEditCommand @@ -229,14 +202,16 @@ export class PixelSyncServer extends NetworkPlugin { } }; - applyCommandToBuffer(this.buffer, acceptedCmd); + applyCommandToBuffer( + this.buffer, + acceptedCmd + ); this.#broadcast(acceptedCmd); } /** * Resolves move/delete conflicts per region id (parallel to the - * per-pixel resolution strokes use). Create is idempotent by unique id - * and applies unconditionally via the generic path in `receive()`. + * per-pixel resolution strokes use). */ #receiveUvRegionCommand( cmd: PixelUvRegionCommand @@ -253,7 +228,10 @@ export class PixelSyncServer extends NetworkPlugin { } this.#lastHeaderByRegion.set(key, cmd); - applyCommandToBuffer(this.buffer, cmd); + applyCommandToBuffer( + this.buffer, + cmd + ); this.#broadcast(cmd); } diff --git a/packages/pixel-draw-renderer/src/network/PixelSyncSession.ts b/packages/pixel-draw-renderer/src/network/PixelSyncSession.ts index 5239ab2..ba6a2ab 100644 --- a/packages/pixel-draw-renderer/src/network/PixelSyncSession.ts +++ b/packages/pixel-draw-renderer/src/network/PixelSyncSession.ts @@ -10,7 +10,8 @@ import type { import type { PixelTransport } from "./PixelTransport.ts"; import type { PixelBufferSnapshot, - PixelNetworkCommand + PixelNetworkCommand, + PixelServerMessage } from "./types.ts"; export interface PixelSyncSessionOptions { @@ -18,9 +19,8 @@ export interface PixelSyncSessionOptions { } /** - * Synchronizes a single canvas over one transport connection. The transport - * is already scoped to one buffer (one namespace, one `PixelSyncServer`), so - * a session attaches at most one `PixelArtCanvas` at a time. + * Synchronizes a single canvas over one transport connection. + * The transport is scoped to one buffer */ export class PixelSyncSession { #transport: PixelTransport; @@ -32,17 +32,11 @@ export class PixelSyncSession { options: PixelSyncSessionOptions ) { this.#transport = options.transport; - - this.#transport.onCommand = (cmd) => this.#handleRemote(cmd); - this.#transport.onSnapshot = (snapshot) => this.#handleSnapshot(snapshot); + this.#transport.onMessage = (message) => this.#handleMessage(message); } /** * Attaches a canvas to sync over the transport. - * - * Chains onto whatever local listener the canvas already had (e.g. a - * consumer reacting to its own edits) rather than replacing it, so sync - * can be layered onto a canvas that's already wired for local use. */ attach( canvasManager: PixelArtCanvas @@ -55,13 +49,14 @@ export class PixelSyncSession { this.#previousHandler = canvasManager.onBufferUpdated; canvasManager.onBufferUpdated = (event) => { this.#previousHandler?.(event); - this.#handleLocal(event); + this.#transport.send( + this.#stamp(event) + ); }; } /** - * Detaches the canvas, restoring whatever local listener was present - * before `attach()`. + * Detaches the canvas, restoring whatever local listener was present before `attach()`. */ detach(): void { if (!this.#manager) { @@ -73,14 +68,6 @@ export class PixelSyncSession { this.#manager = undefined; } - #handleLocal( - event: PixelBufferHookEvent - ): void { - this.#transport.sendCommand( - this.#stamp(event) - ); - } - /** * Uses an event's origin timestamp when available. */ @@ -97,6 +84,19 @@ export class PixelSyncSession { }; } + #handleMessage( + message: PixelServerMessage + ): void { + switch (message.type) { + case "snapshot": + this.#handleSnapshot(message.data); + break; + case "command": + this.#handleRemote(message.data); + break; + } + } + #handleRemote( cmd: PixelNetworkCommand ): void { @@ -112,17 +112,15 @@ export class PixelSyncSession { ): void { this.#manager?.loadSnapshot( snapshot.size, - new Uint8ClampedArray(toUint8Array(snapshot.pixels)), + new Uint8ClampedArray( + toUint8Array(snapshot.pixels) + ), snapshot.uvRegions ); } - /** - * Detaches the canvas and clears transport callbacks. - */ destroy(): void { this.detach(); - this.#transport.onCommand = null; - this.#transport.onSnapshot = null; + this.#transport.onMessage = null; } } diff --git a/packages/pixel-draw-renderer/src/network/PixelTransport.ts b/packages/pixel-draw-renderer/src/network/PixelTransport.ts index 253e711..f560aa9 100644 --- a/packages/pixel-draw-renderer/src/network/PixelTransport.ts +++ b/packages/pixel-draw-renderer/src/network/PixelTransport.ts @@ -1,36 +1,20 @@ // Import Internal Dependencies import type { - PixelBufferSnapshot, - PixelNetworkCommand + PixelNetworkCommand, + PixelServerMessage } from "./types.ts"; /** * Sends and receives pixel network commands for a single buffer. */ export interface PixelTransport { - /** - * Identifies the local peer. - */ readonly localClientId: string; - /** - * Sends a local command. - */ - sendCommand( + send( command: PixelNetworkCommand ): void; - /** - * Receives a command from a remote peer. - */ - onCommand: ((command: PixelNetworkCommand) => void) | null; - - /** - * Receives the buffer's current snapshot, sent once by the server right - * after connecting. - */ - onSnapshot: ((snapshot: PixelBufferSnapshot) => void) | null; - + onMessage: ((message: PixelServerMessage) => void) | null; onPeerJoined: ((peerId: string) => void) | null; onPeerLeft: ((peerId: string) => void) | null; } diff --git a/packages/pixel-draw-renderer/src/network/index.ts b/packages/pixel-draw-renderer/src/network/index.ts index b938d48..f22b10a 100644 --- a/packages/pixel-draw-renderer/src/network/index.ts +++ b/packages/pixel-draw-renderer/src/network/index.ts @@ -2,7 +2,8 @@ export type { PixelBufferSnapshot, PixelNetworkCommand, PixelNetworkCommandHeader, - PixelNetworkEvent + PixelNetworkEvent, + PixelServerMessage } from "./types.ts"; export type { PixelTransport } from "./PixelTransport.ts"; export type { diff --git a/packages/pixel-draw-renderer/src/network/types.ts b/packages/pixel-draw-renderer/src/network/types.ts index 9a74f0c..e98962b 100644 --- a/packages/pixel-draw-renderer/src/network/types.ts +++ b/packages/pixel-draw-renderer/src/network/types.ts @@ -32,3 +32,11 @@ export interface PixelBufferSnapshot { pixels: string; uvRegions: UVRegion[]; } + +/** + * Wire envelope a transport delivers to `PixelTransport.onMessage`: either + * the buffer's initial snapshot, or a live command from a peer. + */ +export type PixelServerMessage = + | { type: "snapshot"; data: PixelBufferSnapshot; } + | { type: "command"; data: PixelNetworkCommand; }; diff --git a/packages/pixel-draw-renderer/test/PixelArtCanvas.history.spec.ts b/packages/pixel-draw-renderer/test/PixelArtCanvas.history.spec.ts index 62fa331..8614b7a 100644 --- a/packages/pixel-draw-renderer/test/PixelArtCanvas.history.spec.ts +++ b/packages/pixel-draw-renderer/test/PixelArtCanvas.history.spec.ts @@ -17,8 +17,8 @@ import { PixelSyncServer } from "#src/network/PixelSyncServer.ts"; import { PixelSyncSession } from "#src/network/PixelSyncSession.ts"; import type { PixelTransport } from "#src/network/PixelTransport.ts"; import type { - PixelBufferSnapshot, - PixelNetworkCommand + PixelNetworkCommand, + PixelServerMessage } from "#src/network/types.ts"; import { makeContainer } from "./helpers/dom.ts"; import { createPixelArtCanvas } from "./helpers/canvas.ts"; @@ -478,19 +478,15 @@ interface RecordingTransport extends PixelTransport { sentCommands: PixelNetworkCommand[]; } -type ServerMessage = - | { type: "command"; data: PixelNetworkCommand; } - | { type: "snapshot"; data: PixelBufferSnapshot; }; - -function isServerMessage(value: unknown): value is ServerMessage { +function isServerMessage(value: unknown): value is PixelServerMessage { return typeof value === "object" && value !== null && "type" in value; } /** * Wires a PixelTransport straight into a real PixelSyncServer in-process — - * sendCommand feeds server.receive() directly, and the server's broadcast - * back to this same client is routed back into transport.onCommand, exactly - * as a real relay would. + * send feeds server.receive() directly, and the server's broadcast back to + * this same client is routed back into transport.onMessage, exactly as a + * real relay would. */ function makeServerBackedTransport( server: PixelSyncServer, @@ -501,11 +497,10 @@ function makeServerBackedTransport( const transport: RecordingTransport = { localClientId: clientId, sentCommands, - onCommand: null, - onSnapshot: null, + onMessage: null, onPeerJoined: null, onPeerLeft: null, - sendCommand(cmd) { + send(cmd) { sentCommands.push(cmd); server.receive(cmd); } @@ -516,12 +511,7 @@ function makeServerBackedTransport( return; } - if (data.type === "command") { - transport.onCommand?.(data.data); - } - else if (data.type === "snapshot") { - transport.onSnapshot?.(data.data); - } + transport.onMessage?.(data); } server.onClientConnect({ diff --git a/packages/pixel-draw-renderer/test/network/PixelSyncSession.spec.ts b/packages/pixel-draw-renderer/test/network/PixelSyncSession.spec.ts index 1b29d62..d6e4e45 100644 --- a/packages/pixel-draw-renderer/test/network/PixelSyncSession.spec.ts +++ b/packages/pixel-draw-renderer/test/network/PixelSyncSession.spec.ts @@ -89,18 +89,17 @@ function createMockTransport( return { localClientId: clientId, sentCommands, - onCommand: null, - onSnapshot: null, + onMessage: null, onPeerJoined: null, onPeerLeft: null, - sendCommand(cmd) { + send(cmd) { sentCommands.push(cmd); }, simulateCommand(cmd) { - this.onCommand?.(cmd); + this.onMessage?.({ type: "command", data: cmd }); }, simulateSnapshot(snapshot) { - this.onSnapshot?.(snapshot); + this.onMessage?.({ type: "snapshot", data: snapshot }); } }; } @@ -351,8 +350,7 @@ describe("PixelSyncSession — destroy", () => { session.destroy(); assert.strictEqual(manager.onBufferUpdated, undefined); - assert.strictEqual(transport.onCommand, null); - assert.strictEqual(transport.onSnapshot, null); + assert.strictEqual(transport.onMessage, null); }); test("stops forwarding local mutations after destroy", () => { diff --git a/packages/pixel-draw-renderer/vite.config.ts b/packages/pixel-draw-renderer/vite.config.ts index 01b5731..9768b36 100644 --- a/packages/pixel-draw-renderer/vite.config.ts +++ b/packages/pixel-draw-renderer/vite.config.ts @@ -10,9 +10,6 @@ import checker from "vite-plugin-checker"; import { PixelSyncServer } from "./src/network/index.ts"; import { PixelBuffer } from "./src/buffer/PixelBuffer.ts"; -// Must match the client's namespace in examples/scripts/main.ts. -const DEMO_NAMESPACE = "pixel-draw:demo-canvas"; - // https://vitejs.dev/config/ export default defineConfig({ root: "examples", @@ -29,8 +26,14 @@ export default defineConfig({ // e.g. `new PixelSyncServer({ namespace: "pixel-draw:tileset-2", ... })`. plugins: [ new PixelSyncServer({ - namespace: DEMO_NAMESPACE, - buffer: new PixelBuffer({ size: { x: 80, y: 80 } }) + // Must match the client's namespace in examples/scripts/main.ts. + namespace: "pixel-draw:demo-canvas", + buffer: new PixelBuffer({ + size: { + x: 80, + y: 80 + } + }) }) ] })