Skip to content
Merged
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
92 changes: 92 additions & 0 deletions examples/sync/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Quickdraw — real-time sync example</title>
<link rel="stylesheet" href="../../packages/core/src/quickdraw.css" />
<style>
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: #eef1f6; color: #1c2434; font-family: system-ui, sans-serif; }
body { padding: 20px; }
header { display: flex; align-items: end; justify-content: space-between; gap: 20px; max-width: 1400px; margin: 0 auto 14px; }
h1 { margin: 0 0 4px; font-size: 22px; }
p { margin: 0; color: #5d6678; font-size: 14px; }
code { font-size: 0.9em; }
.boards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; max-width: 1400px; margin: auto; }
.client { min-width: 0; overflow: hidden; border: 1px solid #d5dae3; border-radius: 12px; background: #fff; box-shadow: 0 8px 24px rgba(30, 40, 60, 0.08); }
.client-bar { display: flex; align-items: center; justify-content: space-between; height: 40px; padding: 0 12px; border-bottom: 1px solid #e2e6ed; font-size: 13px; font-weight: 650; }
.status { color: #a33; font-weight: 500; }
.status.connected { color: #18794e; }
.board { height: min(72vh, 720px); min-height: 440px; position: relative; }
@media (max-width: 800px) {
body { padding: 10px; }
header { align-items: start; flex-direction: column; }
.boards { grid-template-columns: 1fr; }
.board { height: 65vh; }
}
</style>
</head>
<body>
<header>
<div>
<h1>Real-time sync over a WebSocket relay</h1>
<p>Draw on either board. Diffs are relayed to the other client; conflicts use record-level last-writer-wins.</p>
</div>
<p><code>npm run dev:sync</code></p>
</header>
<main class="boards">
<section class="client">
<div class="client-bar"><span>Client A</span><span class="status" id="status-a">connecting</span></div>
<div class="board" id="board-a"></div>
</section>
<section class="client">
<div class="client-bar"><span>Client B</span><span class="status" id="status-b">connecting</span></div>
<div class="board" id="board-b"></div>
</section>
</main>

<script type="module">
import { createQuickdraw } from '../../packages/core/src/index.js'

const relayUrl = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`

function connect(name) {
const board = createQuickdraw({
container: document.getElementById(`board-${name}`),
theme: 'light',
grid: 'dots',
})
const status = document.getElementById(`status-${name}`)
const socket = new WebSocket(relayUrl)

socket.addEventListener('open', () => {
status.textContent = 'connected'
status.classList.add('connected')
})
socket.addEventListener('close', () => {
status.textContent = 'disconnected'
status.classList.remove('connected')
})
socket.addEventListener('error', () => {
status.textContent = 'connection error'
status.classList.remove('connected')
})

board.editor.store.listen((diff) => {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(diff))
}, { source: 'user' })
socket.addEventListener('message', (event) => {
board.editor.store.applyDiff(JSON.parse(event.data), 'remote')
})

return { board, socket }
}

const a = connect('a')
const b = connect('b')
window.boards = { a: a.board, b: b.board }
window.sockets = { a: a.socket, b: b.socket }
</script>
</body>
</html>
13 changes: 13 additions & 0 deletions examples/sync/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "quickdraw-sync-example",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "node relay.mjs",
"test": "node --test relay.test.mjs"
},
"dependencies": {
"ws": "^8.18.0"
}
}
107 changes: 107 additions & 0 deletions examples/sync/relay.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { createServer } from 'node:http'
import { dirname, extname, resolve, sep } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { WebSocket, WebSocketServer } from 'ws'

const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const mimeTypes = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
}

function serve(root, req, res) {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405, { Allow: 'GET, HEAD' }).end()
return
}

const url = new URL(req.url, 'http://localhost')
if (url.pathname === '/') {
res.writeHead(302, { Location: '/examples/sync/' }).end()
return
}

let pathname
try {
pathname = decodeURIComponent(url.pathname)
} catch {
res.writeHead(400).end('Bad request')
return
}

const relative = pathname.endsWith('/') ? `${pathname}index.html` : pathname
const file = resolve(root, `.${relative}`)
if (file !== root && !file.startsWith(`${root}${sep}`)) {
res.writeHead(403).end('Forbidden')
return
}

stat(file).then((info) => {
if (!info.isFile()) throw new Error('not a file')
res.writeHead(200, {
'Content-Length': info.size,
'Content-Type': mimeTypes[extname(file)] || 'application/octet-stream',
})
if (req.method === 'HEAD') res.end()
else createReadStream(file).pipe(res)
}).catch(() => res.writeHead(404).end('Not found'))
}

export function createSyncServer({ root = repoRoot } = {}) {
const server = createServer((req, res) => serve(root, req, res))
const relay = new WebSocketServer({ server })

relay.on('connection', (socket) => {
socket.on('message', (data, isBinary) => {
for (const peer of relay.clients) {
if (peer !== socket && peer.readyState === WebSocket.OPEN) {
peer.send(data, { binary: isBinary })
}
}
})
})

return {
server,
relay,
listen(port = 8080, host = '127.0.0.1') {
return new Promise((resolveListen, reject) => {
const onError = (error) => reject(error)
server.once('error', onError)
server.listen(port, host, () => {
server.off('error', onError)
resolveListen(server.address())
})
})
},
close() {
for (const socket of relay.clients) socket.terminate()
return new Promise((resolveClose, reject) => {
relay.close(() => server.close((error) => error ? reject(error) : resolveClose()))
})
},
}
}

const isMain = process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url

if (isMain) {
const app = createSyncServer()
const port = Number(process.env.PORT || 8080)
const address = await app.listen(port)
console.log(`Quickdraw sync example: http://localhost:${address.port}/examples/sync/`)

const stop = async () => {
await app.close()
process.exit(0)
}
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
}
130 changes: 130 additions & 0 deletions examples/sync/relay.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import assert from 'node:assert/strict'
import { afterEach, describe, it } from 'node:test'
import WebSocket from 'ws'
import { Store } from '../../packages/core/src/store.js'
import { createSyncServer } from './relay.mjs'

const apps = new Set()
const sockets = new Set()

afterEach(async () => {
for (const socket of sockets) socket.terminate()
sockets.clear()
await Promise.all([...apps].map((app) => app.close()))
apps.clear()
})

async function setup() {
const app = createSyncServer()
apps.add(app)
const { port } = await app.listen(0)
return { app, url: `ws://127.0.0.1:${port}` }
}

function connect(url) {
return new Promise((resolve, reject) => {
const socket = new WebSocket(url)
sockets.add(socket)
const timer = setTimeout(() => {
socket.terminate()
reject(new Error('timed out opening WebSocket'))
}, 1000)
socket.once('open', () => {
clearTimeout(timer)
resolve(socket)
})
socket.once('error', (error) => {
clearTimeout(timer)
reject(error)
})
})
}

function nextMessage(socket) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
socket.off('message', onMessage)
reject(new Error('timed out waiting for WebSocket message'))
}, 1000)
const onMessage = (data, isBinary) => {
clearTimeout(timer)
resolve({ data, isBinary })
}
socket.once('message', onMessage)
socket.once('error', (error) => {
clearTimeout(timer)
socket.off('message', onMessage)
reject(error)
})
})
}

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

async function waitFor(check, timeout = 1000) {
const started = Date.now()
while (!check()) {
if (Date.now() - started > timeout) throw new Error('timed out waiting for condition')
await wait(10)
}
}

describe('sync relay', () => {
it('forwards bytes to peers without echoing to the sender', async () => {
const { url } = await setup()
const [a, b] = await Promise.all([connect(url), connect(url)])
let echoed = false
a.on('message', () => { echoed = true })

const received = nextMessage(b)
a.send(Buffer.from([0, 255, 1, 2]))

const message = await received
assert.deepEqual([...message.data], [0, 255, 1, 2])
assert.equal(message.isBinary, true)
await wait(50)
assert.equal(echoed, false)
})

it('forwards in both directions and keeps serving after a peer disconnects', async () => {
const { url } = await setup()
const [a, b, c] = await Promise.all([connect(url), connect(url), connect(url)])

const fromB = nextMessage(a)
b.send('from b')
assert.equal((await fromB).data.toString(), 'from b')

await new Promise((resolve) => {
b.once('close', resolve)
b.close()
})
sockets.delete(b)

const fromA = nextMessage(c)
a.send('from a')
assert.equal((await fromA).data.toString(), 'from a')
})

it('keeps remote records out of the receiving store undo history', async () => {
const { url } = await setup()
const [socketA, socketB] = await Promise.all([connect(url), connect(url)])
const storeA = new Store()
const storeB = new Store()
const localB = { id: 'shape:local-b', typeName: 'shape', type: 'rect', x: 0, y: 0, z: 1, props: {} }
const remoteA = { id: 'shape:remote-a', typeName: 'shape', type: 'rect', x: 10, y: 10, z: 2, props: {} }

storeB.put(localB)
storeA.listen((diff) => socketA.send(JSON.stringify(diff)), { source: 'user' })
storeB.listen((diff) => socketB.send(JSON.stringify(diff)), { source: 'user' })
socketA.on('message', (data) => storeA.applyDiff(JSON.parse(data.toString()), 'remote'))
socketB.on('message', (data) => storeB.applyDiff(JSON.parse(data.toString()), 'remote'))

storeA.put(remoteA)
await waitFor(() => storeB.has(remoteA.id))

assert.equal(storeB.undos.length, 1)
storeB.undo()
assert.equal(storeB.has(localB.id), false)
assert.deepEqual(storeB.get(remoteA.id), remoteA)
})
})
13 changes: 12 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading