Add initial generation
This commit is contained in:
commit
6b10f7f21d
17 changed files with 705 additions and 0 deletions
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal 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
12
Dockerfile
Normal 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
26
Justfile
Normal 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
47
NOTES.txt
Normal 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
15
README.md
Normal 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.
|
||||||
109
adapters/vim/bridge.ts
Normal file
109
adapters/vim/bridge.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
#!/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 room: string | null = null;
|
||||||
|
let suppressLocal = false;
|
||||||
|
|
||||||
|
function send(msg: object) {
|
||||||
|
console.log(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect(roomName: string) {
|
||||||
|
room = roomName;
|
||||||
|
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 = (err) => {
|
||||||
|
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
119
adapters/vim/collab.vim
Normal 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()
|
||||||
13
biome.json
Normal file
13
biome.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
|
||||||
|
"organizeImports": { "enabled": true },
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": { "recommended": true }
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
55
bun.lock
Normal file
55
bun.lock
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "collabd",
|
||||||
|
"dependencies": {
|
||||||
|
"lib0": "*",
|
||||||
|
"yjs": "*",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "*",
|
||||||
|
"@types/bun": "*",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="],
|
||||||
|
|
||||||
|
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
|
||||||
|
|
||||||
|
"@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
6
compose.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
services:
|
||||||
|
collabd:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "4040:4040"
|
||||||
|
restart: unless-stopped
|
||||||
25
package.json
Normal file
25
package.json
Normal 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": {
|
||||||
|
"yjs": "*",
|
||||||
|
"lib0": "*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "*",
|
||||||
|
"@types/bun": "*"
|
||||||
|
},
|
||||||
|
"module": "src/index.ts",
|
||||||
|
"private": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/index.ts
Normal file
64
src/index.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { decode } from "./protocol";
|
||||||
|
import { type Client, getOrCreateSession, getSession } from "./session";
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT) || 4040;
|
||||||
|
|
||||||
|
const server = 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(ws) {
|
||||||
|
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
21
src/protocol.test.ts
Normal 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
25
src/protocol.ts
Normal 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
59
src/session.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import * as Y from "yjs";
|
||||||
|
import { Session, getOrCreateSession } 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
63
src/session.ts
Normal 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
12
tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"types": ["bun"],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue