Skip to content
Draft
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
1 change: 1 addition & 0 deletions .bazelignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ app/lezer-markdown/node_modules
app/project-manager-shim/node_modules
app/table-expression/node_modules
app/rust-ffi/node_modules
app/ydoc-channel/node_modules
app/ydoc-server/node_modules
app/ydoc-server-nodejs/node_modules
app/ydoc-server-polyglot/node_modules
Expand Down
11 changes: 5 additions & 6 deletions app/gui/src/project-view/util/net.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { onScopeDispose } from 'vue'
import { AbortScope } from 'ydoc-shared/util/net'
import {
ReconnectingWebSocket,
ReconnectingWebSocketTransport,
} from 'ydoc-shared/util/net/ReconnectingWSTransport'
import { ReconnectingWebSocket } from 'ydoc-shared/util/net/ReconnectingWSTransport'
import { YjsTransport } from 'ydoc-shared/util/net/YjsTransport'
import * as Y from 'yjs'

export { AbortScope }

Expand All @@ -13,8 +12,8 @@ const WS_OPTIONS = {
}

/** TODO: Add docs */
export function createRpcTransport(url: string): ReconnectingWebSocketTransport {
return new ReconnectingWebSocketTransport(url, WS_OPTIONS)
export function createRpcTransport(indexDoc: Y.Doc, url: string): YjsTransport {
return new YjsTransport(indexDoc, url)
}

/** TODO: Add docs */
Expand Down
43 changes: 24 additions & 19 deletions app/gui/src/providers/openedProjects/project/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,25 @@ export function createProjectStore(

const doc = new Y.Doc()
const awareness = new Awareness(doc)
const ydocUrl = resolveYDocUrl(props.engine.rpcUrl, props.engine.ydocUrl)
const guiId = `gui-${crypto.randomUUID()}`
let yDocsProvider: ReturnType<typeof attachProvider> | undefined
watchEffect((onCleanup) => {
yDocsProvider = attachProvider(
ydocUrl.href,
'index',
{ ls: guiId },
doc,
awareness.internal,
)
onCleanup(() => {
yDocsProvider?.dispose()
yDocsProvider = undefined
})
})

const clientId = crypto.randomUUID() as Uuid
const lsRpcConnection = createLsRpcConnection(clientId, props.engine.rpcUrl, abort)
const lsRpcConnection = createLsRpcConnection(clientId, doc, guiId, abort)
const projectRootId = lsRpcConnection.contentRoots.then(
(roots) => roots.find((root) => root.type === 'Project')?.id,
)
Expand All @@ -106,22 +122,6 @@ export function createProjectStore(
return Ok(ProjectPath.create(undefined, qn.value))
})

const ydocUrl = resolveYDocUrl(props.engine.rpcUrl, props.engine.ydocUrl)
let yDocsProvider: ReturnType<typeof attachProvider> | undefined
watchEffect((onCleanup) => {
yDocsProvider = attachProvider(
ydocUrl.href,
'index',
{ ls: props.engine.rpcUrl },
doc,
awareness.internal,
)
onCleanup(() => {
yDocsProvider?.dispose()
yDocsProvider = undefined
})
})

const projectModel = new DistributedProject(doc)
const moduleDocGuid = ref<string>()

Expand Down Expand Up @@ -483,8 +483,13 @@ function resolveYDocUrl(rpcUrl: string, url: string): URL {
return resolved
}

function createLsRpcConnection(clientId: Uuid, url: string, abort: AbortScope): LanguageServer {
const transport = createRpcTransport(url)
function createLsRpcConnection(
clientId: Uuid,
doc: Y.Doc,
url: string,
abort: AbortScope,
): LanguageServer {
const transport = createRpcTransport(doc, url)
const connection = new LanguageServer(clientId, transport)
abort.onAbort(() => {
connection.stopReconnecting()
Expand Down
34 changes: 34 additions & 0 deletions app/ydoc-channel/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
load("@aspect_rules_js//npm:defs.bzl", "npm_package")
load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project")
load("@npm//:defs.bzl", "npm_link_all_packages", "npm_link_targets")

npm_link_all_packages(name = "node_modules")

ts_config(
name = "tsconfig",
src = "tsconfig.json",
deps = ["//:tsconfig"],
)

ts_project(
name = "tsc",
srcs = glob(["src/**/*.ts"]),
composite = True,
out_dir = "dist",
root_dir = "src",
tsconfig = ":tsconfig",
validate = select({
"@platforms//os:windows": False,
"//conditions:default": True,
}),
deps = npm_link_targets(),
)

npm_package(
name = "pkg",
srcs = [
"package.json",
":tsc",
],
visibility = ["//visibility:public"],
)
27 changes: 27 additions & 0 deletions app/ydoc-channel/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "ydoc-channel",
"version": "0.1.0",
"description": "Y.js-based bidirectional communication channel",
"type": "module",
"exports": {
".": {
"source": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"main": "src/index.ts",
"scripts": {
"test:unit": "vitest run",
"compile": "tsc",
"lint": "eslint . --cache --max-warnings=0"
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"dependencies": {
"yjs": "^13.6.19"
}
}
156 changes: 156 additions & 0 deletions app/ydoc-channel/src/YjsChannel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { describe, expect, it } from 'vitest'
import * as Y from 'yjs'
import { YjsChannel } from './YjsChannel.js'

describe('YjsChannel', () => {
it('should send and receive messages between two channels', () => {
const doc = new Y.Doc()
const channel1 = new YjsChannel<string>(doc, 'test-channel')
const channel2 = new YjsChannel<string>(doc, 'test-channel')

const receivedMessages: string[] = []

// Subscribe channel2 to receive messages
channel2.subscribe((message) => {
receivedMessages.push(message)
})

// Send message from channel1
channel1.send('Hello from channel1')

// Channel2 should receive the message
expect(receivedMessages).toEqual(['Hello from channel1'])
})

it('should not receive its own messages', () => {
const doc = new Y.Doc()
const channel = new YjsChannel<string>(doc, 'test-channel')

const receivedMessages: string[] = []

// Subscribe to own channel
channel.subscribe((message) => {
receivedMessages.push(message)
})

// Send message from the same channel
channel.send('Hello from myself')

// Should not receive own message
expect(receivedMessages).toEqual([])
})

it('should allow multiple subscribers', () => {
const doc = new Y.Doc()
const channel1 = new YjsChannel<string>(doc, 'test-channel')
const channel2 = new YjsChannel<string>(doc, 'test-channel')

const received1: string[] = []
const received2: string[] = []

// Multiple subscribers on channel2
channel2.subscribe((message) => received1.push(message))
channel2.subscribe((message) => received2.push(message))

// Send message from channel1
channel1.send('Broadcast message')

// Both subscribers should receive the message
expect(received1).toEqual(['Broadcast message'])
expect(received2).toEqual(['Broadcast message'])

// Send message from channel1
channel1.send('Broadcast message 1')

// Both subscribers should receive the message
expect(received1).toEqual(['Broadcast message', 'Broadcast message 1'])
expect(received2).toEqual(['Broadcast message', 'Broadcast message 1'])
})

it('should support unsubscribing', () => {
const doc = new Y.Doc()
const channel1 = new YjsChannel<string>(doc, 'test-channel')
const channel2 = new YjsChannel<string>(doc, 'test-channel')

const receivedMessages: string[] = []

// Subscribe and then unsubscribe
const unsubscribe = channel2.subscribe((message) => {
receivedMessages.push(message)
})

channel1.send('First message')
unsubscribe()
channel1.send('Second message')

// Should only receive the first message
expect(receivedMessages).toEqual(['First message'])
})

it('should handle complex message types', () => {
interface ComplexMessage {
id: number
data: string
nested: { value: boolean }
}

const doc = new Y.Doc()
const channel1 = new YjsChannel<ComplexMessage>(doc, 'test-channel')
const channel2 = new YjsChannel<ComplexMessage>(doc, 'test-channel')

let receivedMessage: ComplexMessage | undefined

channel2.subscribe((message) => {
receivedMessage = message
})

const testMessage: ComplexMessage = {
id: 42,
data: 'test data',
nested: { value: true },
}

channel1.send(testMessage)

expect(receivedMessage).toEqual(testMessage)
})

it('should clean up properly when disposed', () => {
const doc = new Y.Doc()
const channel = new YjsChannel<string>(doc, 'test-channel')

const receivedMessages: string[] = []
channel.subscribe((message) => {
receivedMessages.push(message)
})

channel.dispose()

// After dispose, the channel should no longer receive messages
const channel2 = new YjsChannel<string>(doc, 'test-channel')
channel2.send('Message after dispose')

expect(receivedMessages).toEqual([])
})

it('should cleanup internal storage after receiving', () => {
const doc = new Y.Doc()
const channel1 = new YjsChannel<string>(doc, 'test-channel')
const channel2 = new YjsChannel<string>(doc, 'test-channel')

const receivedMessages: string[] = []

// Subscribe channel2 to receive messages
channel2.subscribe((message) => {
receivedMessages.push(message)
})

// Send message from channel1
channel1.send('Hello from channel1')

// Channel2 should receive the message
expect(receivedMessages).toEqual(['Hello from channel1'])

expect(doc.getArray('test-channel').length).toEqual(0)
})
})
Loading