Add initial generation

This commit is contained in:
Jared Miller 2026-01-27 13:45:07 -05:00
commit cb73ceb2d3
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
17 changed files with 709 additions and 0 deletions

34
.gitignore vendored Normal file
View file

@ -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

12
Dockerfile Normal file
View file

@ -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"]

26
Justfile Normal file
View file

@ -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

47
NOTES.txt Normal file
View file

@ -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.

15
README.md Normal file
View file

@ -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.

107
adapters/vim/bridge.ts Normal file
View file

@ -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" });
}
}
}

119
adapters/vim/collab.vim Normal file
View file

@ -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('<sfile>:p:h') .. '/bridge.ts'
def Send(msg: dict<any>)
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<any>
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(<q-args>)
command! CollabLeave call Disconnect()
command! CollabStatus call Status()

19
biome.json Normal file
View file

@ -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" }
}
}
}
}

55
bun.lock Normal file
View file

@ -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=="],
}
}

6
compose.yml Normal file
View file

@ -0,0 +1,6 @@
services:
collabd:
build: .
ports:
- "4040:4040"
restart: unless-stopped

25
package.json Normal file
View file

@ -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"
}
}

64
src/index.ts Normal file
View file

@ -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}`);

21
src/protocol.test.ts Normal file
View file

@ -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();
});
});

25
src/protocol.ts Normal file
View file

@ -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;
}
}

59
src/session.test.ts Normal file
View file

@ -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);
});
});

63
src/session.ts Normal file
View file

@ -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<Client> = 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<string, Session>();
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);
}

12
tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["bun"],
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src/**/*"]
}