From cb73ceb2d33d19423b8c448a8f7c95c2f461a7cd Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Tue, 27 Jan 2026 13:45:07 -0500 Subject: [PATCH] Add initial generation --- .gitignore | 34 ++++++++++++ Dockerfile | 12 ++++ Justfile | 26 +++++++++ NOTES.txt | 47 ++++++++++++++++ README.md | 15 +++++ adapters/vim/bridge.ts | 107 ++++++++++++++++++++++++++++++++++++ adapters/vim/collab.vim | 119 ++++++++++++++++++++++++++++++++++++++++ biome.json | 19 +++++++ bun.lock | 55 +++++++++++++++++++ compose.yml | 6 ++ package.json | 25 +++++++++ src/index.ts | 64 +++++++++++++++++++++ src/protocol.test.ts | 21 +++++++ src/protocol.ts | 25 +++++++++ src/session.test.ts | 59 ++++++++++++++++++++ src/session.ts | 63 +++++++++++++++++++++ tsconfig.json | 12 ++++ 17 files changed, 709 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Justfile create mode 100644 NOTES.txt create mode 100644 README.md create mode 100644 adapters/vim/bridge.ts create mode 100644 adapters/vim/collab.vim create mode 100644 biome.json create mode 100644 bun.lock create mode 100644 compose.yml create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 src/protocol.test.ts create mode 100644 src/protocol.ts create mode 100644 src/session.test.ts create mode 100644 src/session.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a399eb0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM oven/bun:1-alpine AS base +WORKDIR /app + +FROM base AS deps +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile --production + +FROM base AS runner +COPY --from=deps /app/node_modules ./node_modules +COPY src ./src +EXPOSE 4040 +CMD ["bun", "run", "src/index.ts"] diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..b870731 --- /dev/null +++ b/Justfile @@ -0,0 +1,26 @@ +default: + @just --list + +dev: + bun run dev + +start: + bun run start + +check: + bun run check + +fix: + bun run fix + +build: + docker build -t collabd . + +up: + docker compose up -d + +down: + docker compose down + +logs: + docker compose logs -f diff --git a/NOTES.txt b/NOTES.txt new file mode 100644 index 0000000..07d418e --- /dev/null +++ b/NOTES.txt @@ -0,0 +1,47 @@ +collabd design notes + +two possible sync modes +----------------------- + +1. text-crdt mode (current implementation) + + uses yjs for proper text crdt operations. handles insertions, deletions, + concurrent edits with automatic conflict resolution. supports all the + stuff editors expect: select, cut, paste, undo per user. + + pros: + - real editor behavior + - proper undo/redo + - handles complex concurrent edits + + cons: + - more complex + - yjs overhead + - adapters need to translate buffer ops to crdt ops + +2. cell-grid mode (simpler alternative) + + treat document as 2d grid of cells at (row, col). each cell is one + character. last write wins (or use timestamps per cell). + + basically: shared terminal buffer. + + pros: + - dead simple + - no crdt library needed + - works great for terminal-native use cases + - less adapter complexity + + cons: + - no cut/paste (moving cells is weird) + - no semantic operations (select word, delete line) + - undo is per-cell, not per-action + - inserting char doesnt shift rest of line + + good for: shared scratch buffer, terminal notepad, mob viewing + bad for: actual code editing + +could support both modes and let adapters pick. text-crdt for real editors, +cell-grid for simpler/terminal-only tools. + +jquast suggested this approach - worth keeping in mind for mvp simplicity. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf183d6 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# collabd + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run src/index.ts +``` + +This project was created using `bun init` in bun v1.3.5. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/adapters/vim/bridge.ts b/adapters/vim/bridge.ts new file mode 100644 index 0000000..6b2a764 --- /dev/null +++ b/adapters/vim/bridge.ts @@ -0,0 +1,107 @@ +#!/usr/bin/env bun +// bridge between vim (stdin/stdout json lines) and collabd (websocket) +// vim spawns this process and communicates via channels + +import * as Y from "yjs"; + +const DAEMON_URL = process.env.COLLABD_URL || "ws://localhost:4040/ws"; + +let ws: WebSocket | null = null; +let doc: Y.Doc | null = null; +let text: Y.Text | null = null; +let suppressLocal = false; + +function send(msg: object) { + console.log(JSON.stringify(msg)); +} + +function connect(roomName: string) { + doc = new Y.Doc(); + text = doc.getText("content"); + + // when remote changes come in, notify vim + text.observe(() => { + if (!suppressLocal) { + send({ type: "content", text: text?.toString() || "" }); + } + }); + + ws = new WebSocket(DAEMON_URL); + + ws.onopen = () => { + ws?.send(JSON.stringify({ type: "join", room: roomName })); + send({ type: "connected", room: roomName }); + }; + + ws.onmessage = (ev) => { + const msg = JSON.parse(ev.data.toString()); + switch (msg.type) { + case "sync": + case "update": { + if (!doc) break; + suppressLocal = true; + Y.applyUpdate(doc, new Uint8Array(msg.data)); + suppressLocal = false; + send({ type: "content", text: text?.toString() || "" }); + break; + } + case "peers": { + send({ type: "peers", count: msg.count }); + break; + } + } + }; + + ws.onclose = () => { + send({ type: "disconnected" }); + }; + + ws.onerror = () => { + send({ type: "error", message: "websocket error" }); + }; +} + +function setContent(newContent: string) { + if (!doc || !text || !ws) return; + + const oldContent = text.toString(); + if (oldContent === newContent) return; + + // compute diff and apply + // simple approach: delete all, insert all + // TODO: proper diff for efficiency + const t = text; + doc.transact(() => { + t.delete(0, t.length); + t.insert(0, newContent); + }); + + // send update to daemon + const update = Y.encodeStateAsUpdate(doc); + ws.send(JSON.stringify({ type: "update", data: Array.from(update) })); +} + +// read json lines from stdin +const decoder = new TextDecoder(); +for await (const chunk of Bun.stdin.stream()) { + const lines = decoder.decode(chunk).trim().split("\n"); + for (const line of lines) { + if (!line) continue; + try { + const msg = JSON.parse(line); + switch (msg.type) { + case "connect": + connect(msg.room); + break; + case "content": + setContent(msg.text); + break; + case "disconnect": + ws?.close(); + break; + } + } catch { + send({ type: "error", message: "invalid json" }); + } + } +} diff --git a/adapters/vim/collab.vim b/adapters/vim/collab.vim new file mode 100644 index 0000000..0a3c8be --- /dev/null +++ b/adapters/vim/collab.vim @@ -0,0 +1,119 @@ +vim9script + +# collab.vim - collaborative editing adapter for collabd +# requires: bun, collabd daemon running + +var bridge_job: job +var bridge_channel: channel +var connected = false +var room = "" +var suppressing = false + +# path to bridge script (adjust as needed) +const bridge_script = expand(':p:h') .. '/bridge.ts' + +def Send(msg: dict) + if bridge_channel != null + ch_sendraw(bridge_channel, json_encode(msg) .. "\n") + endif +enddef + +def OnOutput(ch: channel, msg: string) + if empty(msg) + return + endif + var data: dict + try + data = json_decode(msg) + catch + return + endtry + + if data.type == 'connected' + connected = true + echom '[collab] connected to room: ' .. data.room + elseif data.type == 'disconnected' + connected = false + echom '[collab] disconnected' + elseif data.type == 'content' + ApplyRemoteContent(data.text) + elseif data.type == 'peers' + echom '[collab] peers: ' .. data.count + elseif data.type == 'error' + echoerr '[collab] ' .. data.message + endif +enddef + +def ApplyRemoteContent(content: string) + if suppressing + return + endif + suppressing = true + var lines = split(content, "\n", true) + var view = winsaveview() + silent! :%delete _ + setline(1, lines) + winrestview(view) + suppressing = false +enddef + +def SendBuffer() + if !connected || suppressing + return + endif + var lines = getline(1, '$') + var content = join(lines, "\n") + Send({type: 'content', text: content}) +enddef + +export def Connect(room_name: string) + if bridge_job != null + Disconnect() + endif + + room = room_name + bridge_job = job_start(['bun', 'run', bridge_script], { + mode: 'nl', + out_cb: OnOutput, + err_io: 'null' + }) + bridge_channel = job_getchannel(bridge_job) + + # give it a moment to start + sleep 100m + Send({type: 'connect', room: room_name}) + + # set up autocmds to send changes + augroup CollabVim + autocmd! + autocmd TextChanged,TextChangedI * call SendBuffer() + augroup END +enddef + +export def Disconnect() + if bridge_job != null + Send({type: 'disconnect'}) + job_stop(bridge_job) + bridge_job = null + bridge_channel = null + endif + connected = false + room = "" + augroup CollabVim + autocmd! + augroup END + echom '[collab] disconnected' +enddef + +export def Status() + if connected + echom '[collab] connected to room: ' .. room + else + echom '[collab] not connected' + endif +enddef + +# commands +command! -nargs=1 CollabJoin call Connect() +command! CollabLeave call Disconnect() +command! CollabStatus call Status() diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..9ad6705 --- /dev/null +++ b/biome.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://biomejs.dev/schemas/latest/schema.json", + "linter": { + "enabled": true, + "rules": { "recommended": true } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "assist": { + "actions": { + "source": { + "organizeImports": { "level": "on" } + } + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..b5b6147 --- /dev/null +++ b/bun.lock @@ -0,0 +1,55 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "collabd", + "dependencies": { + "lib0": "^0.2.117", + "yjs": "^13.6.29", + }, + "devDependencies": { + "@biomejs/biome": "^2.3.13", + "@types/bun": "^1.3.6", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.3.13", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.13", "@biomejs/cli-darwin-x64": "2.3.13", "@biomejs/cli-linux-arm64": "2.3.13", "@biomejs/cli-linux-arm64-musl": "2.3.13", "@biomejs/cli-linux-x64": "2.3.13", "@biomejs/cli-linux-x64-musl": "2.3.13", "@biomejs/cli-win32-arm64": "2.3.13", "@biomejs/cli-win32-x64": "2.3.13" }, "bin": { "biome": "bin/biome" } }, "sha512-Fw7UsV0UAtWIBIm0M7g5CRerpu1eKyKAXIazzxhbXYUyMkwNrkX/KLkGI7b+uVDQ5cLUMfOC9vR60q9IDYDstA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-AGr8OoemT/ejynbIu56qeil2+F2WLkIjn2d8jGK1JkchxnMUhYOfnqc9sVzcRxpG9Ycvw4weQ5sprRvtb7Yhcw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-TUdDCSY+Eo/EHjhJz7P2GnWwfqet+lFxBZzGHldrvULr59AgahamLs/N85SC4+bdF86EhqDuuw9rYLvLFWWlXA=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.13", "", { "os": "win32", "cpu": "x64" }, "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ=="], + + "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + + "@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], + + "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + + "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], + + "lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "yjs": ["yjs@13.6.29", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ=="], + } +} diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..a69f6ff --- /dev/null +++ b/compose.yml @@ -0,0 +1,6 @@ +services: + collabd: + build: . + ports: + - "4040:4040" + restart: unless-stopped diff --git a/package.json b/package.json new file mode 100644 index 0000000..31dcac1 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "collabd", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "test": "bun test", + "check": "biome check .", + "fix": "biome check --write ." + }, + "dependencies": { + "lib0": "^0.2.117", + "yjs": "^13.6.29" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.13", + "@types/bun": "^1.3.6" + }, + "module": "src/index.ts", + "private": true, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..dfb4ab7 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,64 @@ +import { decode } from "./protocol"; +import { type Client, getOrCreateSession, getSession } from "./session"; + +const PORT = Number(process.env.PORT) || 4040; + +Bun.serve({ + port: PORT, + fetch(req, server) { + const url = new URL(req.url); + if (url.pathname === "/ws") { + const upgraded = server.upgrade(req, { data: { room: null } }); + if (!upgraded) { + return new Response("websocket upgrade failed", { status: 400 }); + } + return; + } + return new Response("collabd running"); + }, + websocket: { + open() { + console.debug("client connected"); + }, + message(ws, raw) { + const msg = decode(raw.toString()); + if (!msg) return; + + const client: Client = { ws }; + + switch (msg.type) { + case "join": { + const session = getOrCreateSession(msg.room); + ws.data.room = msg.room; + session.join(client); + console.debug(`client joined room: ${msg.room}`); + break; + } + case "leave": { + if (ws.data.room) { + const session = getSession(ws.data.room); + session?.leave(client); + ws.data.room = null; + } + break; + } + case "update": { + if (ws.data.room) { + const session = getSession(ws.data.room); + session?.applyUpdate(new Uint8Array(msg.data), client); + } + break; + } + } + }, + close(ws) { + if (ws.data.room) { + const session = getSession(ws.data.room); + session?.leave({ ws }); + } + console.debug("client disconnected"); + }, + }, +}); + +console.log(`collabd listening on :${PORT}`); diff --git a/src/protocol.test.ts b/src/protocol.test.ts new file mode 100644 index 0000000..72af3e0 --- /dev/null +++ b/src/protocol.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { decode, encode } from "./protocol"; + +describe("protocol", () => { + test("encode produces valid json", () => { + const msg = { type: "sync" as const, data: [1, 2, 3] }; + const encoded = encode(msg); + expect(JSON.parse(encoded)).toEqual(msg); + }); + + test("decode parses valid json", () => { + const msg = { type: "join", room: "test" }; + const decoded = decode(JSON.stringify(msg)); + expect(decoded).toEqual(msg); + }); + + test("decode returns null for invalid json", () => { + expect(decode("not json")).toBeNull(); + expect(decode("{broken")).toBeNull(); + }); +}); diff --git a/src/protocol.ts b/src/protocol.ts new file mode 100644 index 0000000..16bf9d5 --- /dev/null +++ b/src/protocol.ts @@ -0,0 +1,25 @@ +// message types between daemon and adapters + +export type ClientMessage = + | { type: "join"; room: string } + | { type: "leave" } + | { type: "update"; data: number[] } // yjs update as byte array + | { type: "awareness"; data: number[] }; + +export type ServerMessage = + | { type: "sync"; data: number[] } // full yjs state + | { type: "update"; data: number[] } + | { type: "awareness"; data: number[] } + | { type: "peers"; count: number }; + +export function encode(msg: ServerMessage): string { + return JSON.stringify(msg); +} + +export function decode(raw: string): ClientMessage | null { + try { + return JSON.parse(raw) as ClientMessage; + } catch { + return null; + } +} diff --git a/src/session.test.ts b/src/session.test.ts new file mode 100644 index 0000000..39e66d5 --- /dev/null +++ b/src/session.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import * as Y from "yjs"; +import { getOrCreateSession, Session } from "./session"; + +describe("Session", () => { + test("creates yjs doc on init", () => { + const session = new Session("test-room"); + expect(session.doc).toBeInstanceOf(Y.Doc); + expect(session.name).toBe("test-room"); + }); + + test("tracks clients", () => { + const session = new Session("test-room"); + const mockWs = { + send: () => {}, + data: { room: null as string | null }, + } as unknown as import("bun").ServerWebSocket<{ room: string | null }>; + const client = { ws: mockWs }; + + expect(session.clients.size).toBe(0); + session.join(client); + expect(session.clients.size).toBe(1); + session.leave(client); + expect(session.clients.size).toBe(0); + }); + + test("applies yjs updates", () => { + const session = new Session("test-room"); + const text = session.doc.getText("content"); + + // simulate a remote update + const remoteDoc = new Y.Doc(); + const remoteText = remoteDoc.getText("content"); + remoteText.insert(0, "hello"); + const update = Y.encodeStateAsUpdate(remoteDoc); + + const mockWs = { + send: () => {}, + data: { room: null as string | null }, + } as unknown as import("bun").ServerWebSocket<{ room: string | null }>; + session.applyUpdate(update, { ws: mockWs }); + + expect(text.toString()).toBe("hello"); + }); +}); + +describe("getOrCreateSession", () => { + test("returns same session for same room", () => { + const s1 = getOrCreateSession("room-a"); + const s2 = getOrCreateSession("room-a"); + expect(s1).toBe(s2); + }); + + test("returns different sessions for different rooms", () => { + const s1 = getOrCreateSession("room-x"); + const s2 = getOrCreateSession("room-y"); + expect(s1).not.toBe(s2); + }); +}); diff --git a/src/session.ts b/src/session.ts new file mode 100644 index 0000000..01763c8 --- /dev/null +++ b/src/session.ts @@ -0,0 +1,63 @@ +import type { ServerWebSocket } from "bun"; +import * as Y from "yjs"; +import { encode } from "./protocol"; + +export interface Client { + ws: ServerWebSocket<{ room: string | null }>; +} + +export class Session { + doc: Y.Doc; + clients: Set = new Set(); + + constructor(public name: string) { + this.doc = new Y.Doc(); + } + + join(client: Client) { + this.clients.add(client); + // send full state to new client + const state = Y.encodeStateAsUpdate(this.doc); + client.ws.send(encode({ type: "sync", data: Array.from(state) })); + this.broadcastPeerCount(); + } + + leave(client: Client) { + this.clients.delete(client); + this.broadcastPeerCount(); + } + + applyUpdate(update: Uint8Array, from: Client) { + Y.applyUpdate(this.doc, update); + // broadcast to others + for (const client of this.clients) { + if (client !== from) { + client.ws.send(encode({ type: "update", data: Array.from(update) })); + } + } + } + + broadcastPeerCount() { + const msg = encode({ type: "peers", count: this.clients.size }); + for (const client of this.clients) { + client.ws.send(msg); + } + } +} + +// room name -> session +const sessions = new Map(); + +export function getOrCreateSession(name: string): Session { + let session = sessions.get(name); + if (!session) { + session = new Session(name); + sessions.set(name, session); + console.debug(`session created: ${name}`); + } + return session; +} + +export function getSession(name: string): Session | undefined { + return sessions.get(name); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2ba5c8f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["bun"], + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*"] +}