Skip to content

Commit 0d74493

Browse files
authored
feat: collab support in vscode-workbench extension (#14)
* chore: write-mount again * chore: absolute paths * feat: basic collab setup * feat: include better-sqlite in collab server * chore: paths * feat: collab FS * chore: persist container cache * chore: restore file: URIs * feat: syncable dir control * doc: unclear comment * chore: use node:sqlite in collab-server
1 parent 7b722e9 commit 0d74493

22 files changed

Lines changed: 8028 additions & 68776 deletions

.dockerignore

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@
1212
!tsconfig.json
1313

1414
# Collaboration server
15-
!collab-server/package.json
16-
!collab-server/server.ts
15+
!collab-server/dist
1716

1817
# Pre-built workbench extension
1918
!vscode-workbench.vsix

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ node_modules/
1414
# next.js
1515
/.next/
1616
/out/
17+
.docker-cache
1718

1819
# prisma
19-
*/prisma/generated
20+
**/prisma/generated
2021

2122
# production
2223
/build

DEVELOPMENT.md

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,6 @@ Open `http://localhost:3000`. You'll see the setup page.
2525
takes 5--30 min on first run).
2626
3. When seeding finishes, you're redirected to the landing page.
2727

28-
## Updating NPM packages
29-
30-
Make sure to pass `--install-strategy=nested` to `npm install`.
31-
This ensures that `package-lock.json` places `node_modules` in package folders
32-
as opposed to hoisting them out to the root directory;
33-
we rely on this in the dev container.
34-
3528
## Makefile targets
3629

3730
| Target | What it does |
@@ -52,6 +45,7 @@ the first VSCode server to start up will have its [extension host](https://code.
5245
start a debugger on port 9229.
5346
Use the "Attach to vscode-workbench" VSCode launch target to attach.
5447
You can set breakpoints in the `vscode-workbench/` extension.
48+
Note that `console.log` in the extension goes to the _renderer_, i.e., the web client.
5549

5650
## Resetting the data volume
5751

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ dev: container-dev
4646
'npm run watch' \
4747
'$(DOCKER_RUN) -p 127.0.0.1:3000:3000 \
4848
-p 127.0.0.1:9229:9229 \
49+
-v $(CURDIR)/.docker-cache:/root/.cache \
4950
-v $(CURDIR):/app/workbench:ro \
5051
-v $(CURDIR)/vscode-workbench:/app/openvscode-server/extensions/leanprover.workbench-universal:ro \
5152
$(IMAGE_NAME):$(IMAGE_DEV_TAG)'

collab-server/esbuild.mjs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import esbuild from 'esbuild'
2+
3+
const isProd = process.argv.includes('--production')
4+
const watch = process.argv.includes('--watch')
5+
6+
const ctx = await esbuild.context({
7+
entryPoints: ['src/server.ts'],
8+
bundle: true,
9+
format: 'esm',
10+
outfile: 'dist/server.js',
11+
minify: isProd,
12+
sourcemap: !isProd,
13+
platform: 'node',
14+
target: 'node24',
15+
})
16+
17+
if (watch) {
18+
await ctx.watch()
19+
} else {
20+
await ctx.rebuild()
21+
await ctx.dispose()
22+
}

collab-server/package.json

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@
44
"private": true,
55
"type": "module",
66
"scripts": {
7-
"watch": "tsc --watch --preserveWatchOutput",
8-
"tsc": "tsc"
7+
"watch": "concurrently --names esbuild,tsc 'npm run watch:esbuild' 'npm run watch:tsc'",
8+
"watch:esbuild": "node esbuild.mjs --watch",
9+
"watch:tsc": "tsc --watch --preserveWatchOutput",
10+
"tsc": "tsc",
11+
"build": "tsc --noEmit && node esbuild.mjs --production"
912
},
1013
"dependencies": {
11-
"@hocuspocus/server": "^4.0.0"
14+
"@hocuspocus/extension-database": "^4.0.0",
15+
"@hocuspocus/server": "^4.0.0",
16+
"esbuild": "^0.28"
1217
}
1318
}

collab-server/server.ts

Lines changed: 0 additions & 74 deletions
This file was deleted.

collab-server/src/server.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { Database } from '@hocuspocus/extension-database'
2+
import { Server } from '@hocuspocus/server'
3+
import { once } from 'node:events'
4+
import fs from 'node:fs/promises'
5+
import path from 'node:path'
6+
import { DatabaseSync } from 'node:sqlite'
7+
import * as Y from 'yjs'
8+
9+
// -- CLI --
10+
if (process.argv.length !== 3) {
11+
console.error('Usage: node server.js <projectDir>')
12+
process.exit(1)
13+
}
14+
15+
const projectDir = process.argv[2]
16+
const socketPath = path.join(process.cwd(), 'collab.sock')
17+
const dbPath = path.join(process.cwd(), 'collab.db')
18+
19+
// -- DB --
20+
const db = new DatabaseSync(dbPath)
21+
db.exec('CREATE TABLE IF NOT EXISTS document (path TEXT PRIMARY KEY NOT NULL, data BLOB NOT NULL)')
22+
23+
const selectDocumentStatement = db.prepare('SELECT data FROM document WHERE path = ?')
24+
const selectDocument = (path: string): Uint8Array | undefined =>
25+
(selectDocumentStatement.get(path) as { data: Uint8Array } | undefined)?.data
26+
const upsertDocumentStatement = db.prepare(
27+
'INSERT INTO document (path, data) VALUES (?, ?) ON CONFLICT(path) DO UPDATE SET data = excluded.data',
28+
)
29+
const upsertDocument = (path: string, data: Uint8Array): void => {
30+
upsertDocumentStatement.run(path, data)
31+
}
32+
33+
// -- YJS FILE MANAGEMENT --
34+
// TODO: use const imported from single-source-of-truth module
35+
const YTEXT_KEY = 'content'
36+
37+
function checkedToDiskPath(documentName: string): string {
38+
const file = path.normalize(documentName)
39+
if (!file.startsWith(projectDir)) {
40+
throw new Error(`Path traversal in document name: '${documentName}' escapes '${projectDir}'`)
41+
}
42+
return file
43+
}
44+
45+
// -- HTTPS/WS SERVER --
46+
const server = new Server({
47+
extensions: [
48+
// Note: we can't use the SQLite extension.
49+
// Its onLoadDocument would be called after ours,
50+
// but we want to try it *before* trying the filesystem.
51+
new Database({
52+
async fetch({ documentName }) {
53+
const data = selectDocument(documentName)
54+
if (data) return data
55+
let content: string
56+
try {
57+
content = await fs.readFile(checkedToDiskPath(documentName), 'utf-8')
58+
} catch {
59+
return null
60+
}
61+
const doc = new Y.Doc()
62+
doc.getText(YTEXT_KEY).insert(0, content)
63+
return Y.encodeStateAsUpdate(doc)
64+
},
65+
async store({ documentName, state }) {
66+
upsertDocument(documentName, state)
67+
},
68+
}),
69+
],
70+
})
71+
72+
// TODO: listen for fs events to avoid lost writes.
73+
// VSCs could inform the server about which saves came from them,
74+
// as opposed to other processes (e.g. CLI tools).
75+
// Non-VSC edits could be applied to the Y.Doc as whole-file replacements.
76+
77+
// `server.listen` exposes a port. We use a socket which needs direct `httpServer` access.
78+
server.httpServer.listen(socketPath, () => {
79+
// Cosmetic monkey-patches to display the correct start screen. Server works regardless of these.
80+
Object.defineProperty(server, 'webSocketURL', {
81+
get: () => `ws+unix:${socketPath}`,
82+
})
83+
Object.defineProperty(server, 'httpURL', {
84+
get: () => `http+unix:${socketPath}`,
85+
})
86+
server['showStartScreen']()
87+
88+
// No need to call `onListen` hooks here since we don't register any.
89+
})
90+
91+
await Promise.race([once(process, 'SIGINT'), once(process, 'SIGQUIT'), once(process, 'SIGTERM')])
92+
console.log('Hocuspocus shutting down..')
93+
await server.destroy()
94+
db.close()
95+
96+
// TODO: ensure writes are flushed to disk.

collab-server/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"extends": "../tsconfig.json",
3-
"include": ["server.ts"]
3+
"include": ["src/**/*.ts"]
44
}

0 commit comments

Comments
 (0)