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
6 changes: 6 additions & 0 deletions .changeset/goofy-birds-take.md
Original file line number Diff line number Diff line change
@@ -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).
9 changes: 9 additions & 0 deletions packages/network/docs/NetworkChannel.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type NetworkChannelPeerListener = (

interface NetworkChannel<ClientPayload = unknown, ServerPayload = unknown> {
readonly namespace: string;
readonly localClientId: string;

send(payload: ClientPayload): void;
leave(): void;
Expand All @@ -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`
Expand Down
10 changes: 10 additions & 0 deletions packages/network/docs/NetworkClient.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions packages/network/src/NetworkChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface NetworkChannel<
ServerPayload = unknown
> {
readonly namespace: string;
readonly localClientId: string;

send(
payload: ClientPayload
Expand Down
7 changes: 7 additions & 0 deletions packages/network/src/NetworkClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -45,6 +51,7 @@ export class NetworkClient {

const channel: NetworkChannel<ClientPayload, ServerPayload> = {
namespace,
localClientId: this.clientId,
onMessage: null,
onPeerJoined: null,
onPeerLeft: null,
Expand Down
6 changes: 6 additions & 0 deletions packages/network/test/transport/websocket.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions packages/pixel-draw-renderer/docs/network/PixelSyncSession.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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).
Expand Down
52 changes: 35 additions & 17 deletions packages/pixel-draw-renderer/docs/network/PixelTransport.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,54 +10,72 @@ 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<PixelNetworkCommand, PixelServerMessage>(
"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;

constructor(private ws: WebSocket) {
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 }));
}
}
Expand Down
10 changes: 5 additions & 5 deletions packages/pixel-draw-renderer/docs/network/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
10 changes: 9 additions & 1 deletion packages/pixel-draw-renderer/docs/network/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

This file was deleted.

25 changes: 14 additions & 11 deletions packages/pixel-draw-renderer/examples/scripts/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -141,21 +145,20 @@ async function initRuntime(): Promise<Runtime> {
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<PixelNetworkCommand, PixelServerMessage>(
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);
}
Loading
Loading