commit 4466640439adb55b8c159559b8da557b688ab734 Author: A.Olokhtonov Date: Tue Mar 21 20:33:52 2023 +0300 Initital commit diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..1d84dad --- /dev/null +++ b/Caddyfile @@ -0,0 +1,18 @@ +desk.local { + redir /ws /ws/ + redir /desk /desk/ + + handle /ws/* { + reverse_proxy 127.0.0.1:3003 + } + + handle /api/* { + reverse_proxy 127.0.0.1:3003 + } + + handle_path /desk/* { + root * /code/desk2/client + try_files {path} /index.html + file_server + } +} diff --git a/client/cursor.js b/client/cursor.js new file mode 100644 index 0000000..7afc77a --- /dev/null +++ b/client/cursor.js @@ -0,0 +1,96 @@ +function on_down(e) { + if (e.button === 1) { + const event = undo_event(); + queue_event(event); + return; + } + + if (e.button != 0) { + return; + } + + const x = Math.round(e.clientX + window.scrollX); + const y = Math.round(e.clientY + window.scrollY); + + storage.state.drawing = true; + + if (storage.ctx1.lineWidth !== storage.cursor.width) { + storage.ctx1.lineWidth = storage.cursor.width; + } + + storage.cursor.x = x; + storage.cursor.y = y; + + const predraw = predraw_event(x, y); + storage.current_stroke.push(predraw); + + fire_event(predraw); +} + +function on_move(e) { + const last_x = storage.cursor.x; + const last_y = storage.cursor.y; + + const x = storage.cursor.x = Math.round(e.clientX + window.scrollX); + const y = storage.cursor.y = Math.round(e.clientY + window.scrollY); + const width = storage.cursor.width; + + elements.cursor.style.transform = `translate3D(${Math.round(x - width / 2)}px, ${Math.round(y - width / 2)}px, 0)`; + + if (storage.state.drawing) { + storage.ctx1.beginPath(); + + storage.ctx1.moveTo(last_x, last_y); + storage.ctx1.lineTo(x, y); + + storage.ctx1.stroke(); + + const predraw = predraw_event(x, y); + storage.current_stroke.push(predraw); + + fire_event(predraw); + } +} + +async function on_up(e) { + if (e.button != 0) { + return; + } + + storage.state.drawing = false; + + const event = stroke_event(); + storage.current_stroke = []; + await queue_event(event); +} + +function on_keydown(e) { + if (e.code === 'Space' && !storage.state.drawing) { + elements.cursor.classList.add('dhide'); + elements.canvas0.classList.add('moving'); + storage.state.moving = true; + } +} + +function on_keyup(e) { + if (e.code === 'Space' && storage.state.moving) { + elements.cursor.classList.remove('dhide'); + elements.canvas0.classList.remove('moving'); + storage.state.moving = false; + } +} + +function on_leave(e) { + if (storage.state.drawing) { + on_up(e); + storage.state.drawing = false; + return; + } + + if (storage.state.moving) { + elements.cursor.classList.remove('dhide'); + elements.canvas0.classList.remove('moving'); + storage.state.moving = false; + return; + } +} \ No newline at end of file diff --git a/client/default.css b/client/default.css new file mode 100644 index 0000000..39069ff --- /dev/null +++ b/client/default.css @@ -0,0 +1,40 @@ +html, body { + margin: 0; + padding: 0; +} + +.dhide { + display: none !important; +} + +.canvas { + cursor: crosshair; + position: absolute; + top: 0; + left: 0; +} + +.canvas.moving { + cursor: move; +} + +.cursor { + display: none; + position: absolute; + background: white; + border-radius: 50%; + box-sizing: border-box; + border: 1px solid black; + z-index: 10; + pointer-events: none; +} + +#canvas0 { + z-index: 0; +} + +#canvas1 { + z-index: 1; + pointer-events: none; + opacity: 0.3; +} \ No newline at end of file diff --git a/client/draw.js b/client/draw.js new file mode 100644 index 0000000..759d04a --- /dev/null +++ b/client/draw.js @@ -0,0 +1,41 @@ +function draw_stroke(stroke) { + const points = stroke.points; + + if (points.length === 0) { + return; + } + + // console.debug(points) + + storage.ctx0.beginPath(); + storage.ctx0.moveTo(points[0].x, points[0].y); + + for (let i = 1; i < points.length; ++i) { + const p = points[i]; + storage.ctx0.lineTo(p.x, p.y); + } + + storage.ctx0.stroke(); +} + +function redraw_predraw() { + storage.ctx1.clearRect(0, 0, storage.ctx1.canvas.width, storage.ctx1.canvas.height); +} + +function predraw_user(user_id, event) { + if (!(user_id in storage.predraw)) { + storage.predraw[user_id] = []; + } + + storage.ctx1.beginPath(); + if (storage.predraw[user_id].length > 0) { + const last = storage.predraw[user_id][storage.predraw[user_id].length - 1]; + storage.ctx1.moveTo(last.x, last.y); + storage.ctx1.lineTo(event.x, event.y); + } else { + storage.ctx1.moveTo(event.x, event.y); + } + storage.ctx1.stroke(); + + storage.predraw[user_id].push({ 'x': event.x, 'y': event.y }); +} \ No newline at end of file diff --git a/client/favicon.png b/client/favicon.png new file mode 100644 index 0000000..dab577e Binary files /dev/null and b/client/favicon.png differ diff --git a/client/favicon2.png b/client/favicon2.png new file mode 100644 index 0000000..6bc0054 Binary files /dev/null and b/client/favicon2.png differ diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..de6b393 --- /dev/null +++ b/client/index.html @@ -0,0 +1,21 @@ + + + + + Desk + + + + + + + + + + + +
+ + + + diff --git a/client/index.js b/client/index.js new file mode 100644 index 0000000..b9ee6e3 --- /dev/null +++ b/client/index.js @@ -0,0 +1,148 @@ +let ws = null; +let ls = window.localStorage; + +document.addEventListener('DOMContentLoaded', main); + +const EVENT = Object.freeze({ + PREDRAW: 10, + STROKE: 20, + UNDO: 30, + REDO: 31, +}); +const MESSAGE = Object.freeze({ + INIT: 100, + SYN: 101, + ACK: 102, + FULL: 103, + FIRE: 104, + JOIN: 105, +}); + +const config = { + ws_url: 'wss://desk.local/ws/', + sync_timeout: 1000, + ws_reconnect_timeout: 2000, +}; + +const storage = { + 'state': { + 'drawing': false, + 'moving': false, + }, + + 'predraw': {}, + 'timers': {}, + 'me': {}, + + 'sn': 0, // what WE think SERVER SN is (we tell this to the server, it uses to decide how much stuff to SYN to us) + 'server_lsn': 0, // what SERVER said LSN is (used to decide how much stuff to SYN) + 'lsn': 0, // what actual LSN is (can't just use length of local queue because it gets cleared) + 'queue': [], // to server + 'events': [], // from server + 'current_stroke': [], + + 'desk_id': 123, + + 'canvas': { + 'width': 2000, + 'height': 2000, + 'offset_x': 0, + 'offset_y': 0, + }, + + 'cursor': { + 'width': 8, + 'x': 0, + 'y': 0, + } +}; +const elements = { + 'cursor': null, + 'canvas0': null, + 'canvas1': null, +}; + +function event_size(event) { + let size = 1 + 1; // type + padding + + switch (event.type) { + case EVENT.PREDRAW: { + size += 2 * 2; + break; + } + + case EVENT.STROKE: { + size += 2 + event.points.length * 2 * 2; // u16 (count) + count * (u16, u16) points + break; + } + + case EVENT.UNDO: + case EVENT.REDO: { + break; + } + + default: { + console.error('fuck'); + process.exit(1); + } + } + + return size; +} + +function predraw_event(x, y) { + return { + 'type': EVENT.PREDRAW, + 'x': x, + 'y': y + }; +} + +function stroke_event() { + return { + 'type': EVENT.STROKE, + 'points': storage.current_stroke, + }; +} + +function undo_event() { + return { 'type': EVENT.UNDO }; +} + +function redo_event() { + return { 'type': EVENT.REDO }; +} + +function main() { + const url = new URL(window.location.href); + const parts = url.pathname.split('/'); + + storage.desk_id = parts.length > 0 ? parts[parts.length - 1] : 0; + + ws_connect(); + + elements.canvas0 = document.getElementById('canvas0'); + elements.canvas1 = document.getElementById('canvas1'); + elements.cursor = document.getElementById('cursor'); + elements.cursor.style.width = storage.cursor.width + 'px'; + elements.cursor.style.height = storage.cursor.width + 'px'; + + storage.canvas.rect = elements.canvas0.getBoundingClientRect(); + + storage.ctx0 = elements.canvas0.getContext('2d'); + storage.ctx1 = elements.canvas1.getContext('2d'); + storage.ctx1.canvas.width = storage.ctx0.canvas.width = storage.canvas.width; + storage.ctx1.canvas.height = storage.ctx0.canvas.height = storage.canvas.height; + + storage.ctx1.lineJoin = storage.ctx1.lineCap = storage.ctx0.lineJoin = storage.ctx0.lineCap = 'round'; + storage.ctx1.lineWidth = storage.ctx0.lineWidth = storage.cursor.width; + + window.addEventListener('pointerdown', on_down) + window.addEventListener('pointermove', on_move) + window.addEventListener('pointerup', on_up); + window.addEventListener('pointercancel', on_up); + window.addEventListener('touchstart', (e) => e.preventDefault()); + window.addEventListener('keydown', on_keydown); + window.addEventListener('keyup', on_keyup); + // window.addEventListener('pointerleave', on_leave); +} diff --git a/client/math.js b/client/math.js new file mode 100644 index 0000000..c8af2f1 --- /dev/null +++ b/client/math.js @@ -0,0 +1,135 @@ +function rdp_find_max(points, start, end) { + const EPS = 0.5; + + let result = -1; + let max_dist = 0; + + const a = points[start]; + const b = points[end]; + + const dx = b.x - a.x; + const dy = b.y - a.y; + + const dist_ab = Math.sqrt(dx * dx + dy * dy); + const sin_theta = dy / dist_ab; + const cos_theta = dx / dist_ab; + + for (let i = start; i < end; ++i) { + const p = points[i]; + + const ox = p.x - a.x; + const oy = p.y - a.y; + + const rx = cos_theta * ox + sin_theta * oy; + const ry = -sin_theta * ox + cos_theta * oy; + + const x = rx + a.x; + const y = ry + a.y; + + const dist = Math.abs(y - a.y); + + if (dist > EPS && dist > max_dist) { + result = i; + max_dist = dist; + } + } + + return result; +} + +function process_rdp_r(points, start, end) { + let result = []; + + const max = rdp_find_max(points, start, end); + + if (max !== -1) { + const before = process_rdp_r(points, start, max); + const after = process_rdp_r(points, max, end); + result = [...before, points[max], ...after]; + } + + return result; +} + +function process_rdp(points) { + const result = process_rdp_r(points, 0, points.length - 1); + result.unshift(points[0]); + result.push(points[points.length - 1]); + return result; +} + +function process_ewmv(points, round = false) { + const result = []; + const alpha = 0.4; + + result.push(points[0]); + + for (let i = 1; i < points.length; ++i) { + const p = points[i]; + const x = Math.round(alpha * p.x + (1 - alpha) * result[result.length - 1].x); + const y = Math.round(alpha * p.y + (1 - alpha) * result[result.length - 1].y); + result.push({'x': x, 'y': y}); + } + + return result; +} + +function process_stroke(points) { + const result0 = process_ewmv(points); + const result1 = process_rdp(result0, true); + return result1; +} + +function stroke_stats(points, width) { + let length = 0; + let xmin = points[0].x, ymin = points[0].y; + let xmax = xmin, ymax = ymin; + + for (let i = 0; i < points.length; ++i) { + const point = points[i]; + if (point.x < xmin) xmin = point.x; + if (point.y < ymin) ymin = point.y; + if (point.x > xmax) xmax = point.x; + if (point.y > ymax) ymax = point.y; + + if (i > 0) { + const last = points[i - 1]; + const dx = point.x - last.x; + const dy = point.y - last.y; + length += Math.sqrt(dx * dx + dy * dy); + } + } + + xmin -= width; + ymin -= width; + xmax += width * 2; + ymax += width * 2; + + const bbox = { + 'xmin': Math.floor(xmin), + 'ymin': Math.floor(ymin), + 'xmax': Math.ceil(xmax), + 'ymax': Math.ceil(ymax) + }; + + return { + 'bbox': bbox, + 'length': length, + }; +} + +function rectangles_intersect(a, b) { + const result = ( + a.xmin <= b.xmax + && a.xmax >= b.xmin + && a.ymin <= b.ymax + && a.ymax >= b.ymin + ); + + return result; +} + +function stroke_intersects_region(points, bbox) { + const stats = stroke_stats(points, storage.cursor.width); + return rectangles_intersect(stats.bbox, bbox); +} \ No newline at end of file diff --git a/client/recv.js b/client/recv.js new file mode 100644 index 0000000..94859d9 --- /dev/null +++ b/client/recv.js @@ -0,0 +1,229 @@ +function deserializer_create(buffer, dataview) { + return { + 'offset': 0, + 'size': buffer.byteLength, + 'buffer': buffer, + 'view': dataview, + 'strview': new Uint8Array(buffer), + }; +} + +function des_u8(d) { + const value = d.view.getUint8(d.offset); + d.offset += 1; + return value; +} + +function des_u16(d) { + const value = d.view.getUint16(d.offset, true); + d.offset += 2; + return value; +} + +function des_u32(d) { + const value = d.view.getUint32(d.offset, true); + d.offset += 4; + return value; +} + +function des_u16array(d, count) { + const result = []; + + for (let i = 0; i < count; ++i) { + const item = d.view.getUint16(d.offset, true); + d.offset += 2; + result.push(item); + } + + return result; +} + +function des_event(d) { + const event = {}; + + event.type = des_u8(d); + event.user_id = des_u32(d); + + switch (event.type) { + case EVENT.PREDRAW: { + event.x = des_u16(d); + event.y = des_u16(d); + break; + } + + case EVENT.STROKE: { + const point_count = des_u16(d); + const coords = des_u16array(d, point_count * 2); + + event.points = []; + + for (let i = 0; i < point_count; ++i) { + const x = coords[2 * i + 0]; + const y = coords[2 * i + 1]; + event.points.push({'x': x, 'y': y}); + } + + break; + } + + case EVENT.UNDO: + case EVENT.REDO: { + break; + } + + default: { + console.error('fuck'); + } + } + + return event; +} + +function redraw_region(bbox) { + storage.ctx0.save(); + storage.ctx0.clearRect(bbox.xmin, bbox.ymin, bbox.xmax - bbox.xmin, bbox.ymax - bbox.ymin); + + storage.ctx0.beginPath(); + storage.ctx0.rect(bbox.xmin, bbox.ymin, bbox.xmax - bbox.xmin, bbox.ymax - bbox.ymin); + storage.ctx0.clip(); + + for (const event of storage.events) { + if (event.type === EVENT.STROKE && !event.deleted) { + if (stroke_intersects_region(event.points, bbox)) { + draw_stroke(event); + } + } + } + + storage.ctx0.restore(); +} + +function handle_event(event) { + console.debug(`event type ${event.type} from user ${event.user_id}`); + + switch (event.type) { + case EVENT.STROKE: { + if (event.user_id in storage.predraw || event.user_id === storage.me.id) { + storage.predraw[event.user_id] = []; + redraw_predraw(); + } + + draw_stroke(event); + + break; + } + + case EVENT.UNDO: { + for (let i = storage.events.length - 1; i >=0; --i) { + const other_event = storage.events[i]; + if (other_event.type === EVENT.STROKE && other_event.user_id === event.user_id && !other_event.deleted) { + other_event.deleted = true; + const stats = stroke_stats(other_event.points, storage.cursor.width); + redraw_region(stats.bbox); + break; + } + } + + break; + } + + default: { + console.error('fuck'); + } + } +} + +async function handle_message(d) { + const message_type = des_u8(d); + + console.debug(message_type); + + switch (message_type) { + case MESSAGE.JOIN: + case MESSAGE.INIT: { + storage.me.id = des_u32(d); + storage.server_lsn = des_u32(d); + + if (storage.server_lsn > storage.lsn) { + // Server knows something that we don't + storage.lsn = storage.server_lsn; + } + + if (message_type === MESSAGE.JOIN) { + ls.setItem('sessionId', des_u32(d)); + console.debug('join in'); + } else { + console.debug('init in'); + } + + + const event_count = des_u32(d); + + console.debug(`${event_count} events in init`); + storage.ctx0.clearRect(0, 0, storage.ctx0.canvas.width, storage.ctx0.canvas.height); + + for (let i = 0; i < event_count; ++i) { + const event = des_event(d); + handle_event(event); + storage.events.push(event); + } + + send_ack(event_count); + + sync_queue(); + + break; + } + + case MESSAGE.FIRE: { + const user_id = des_u32(d); + const predraw_event = des_event(d); + + predraw_user(user_id, predraw_event); + + break; + } + + case MESSAGE.ACK: { + const lsn = des_u32(d); + + console.debug(`ack ${lsn} in`); + + if (lsn > storage.server_lsn) { + // ACKs may arrive out of order + storage.server_lsn = lsn; + } + + break; + } + + case MESSAGE.SYN: { + const sn = des_u32(d); + const count = des_u32(d); + + const we_expect = sn - storage.sn; + const first = count - we_expect; + + console.debug(`syn ${sn} in`); + + for (let i = 0; i < count; ++i) { + const event = des_event(d); + if (i >= first) { + handle_event(event); + storage.events.push(event); + } + } + + storage.sn = sn; + + await send_ack(sn); + + break; + } + + default: { + console.error('fuck'); + return; + } + } +} diff --git a/client/send.js b/client/send.js new file mode 100644 index 0000000..3960122 --- /dev/null +++ b/client/send.js @@ -0,0 +1,154 @@ +function serializer_create(size) { + const buffer = new ArrayBuffer(size); + return { + 'offset': 0, + 'size': size, + 'buffer': buffer, + 'view': new DataView(buffer), + 'strview': new Uint8Array(buffer), + }; +} + +function ser_u8(s, value) { + s.view.setUint8(s.offset, value); + s.offset += 1; +} + +function ser_u16(s, value) { + s.view.setUint16(s.offset, value, true); + s.offset += 2; +} + +function ser_u32(s, value) { + s.view.setUint32(s.offset, value, true); + s.offset += 4; +} + +function ser_event(s, event) { + ser_u8(s, event.type); + ser_u8(s, 0); // padding for 16bit alignment + + switch (event.type) { + case EVENT.PREDRAW: { + ser_u16(s, event.x); + ser_u16(s, event.y); + break; + } + + case EVENT.STROKE: { + ser_u16(s, event.points.length); + + console.debug('original', event.points); + + for (const point of event.points) { + ser_u16(s, point.x); + ser_u16(s, point.y); + } + + break; + } + + case EVENT.UNDO: + case EVENT.REDO: { + break; + } + + default: { + console.error('fuck'); + process.exit(1); + } + } +} + +async function send_ack(sn) { + const s = serializer_create(1 + 4); + + ser_u8(s, MESSAGE.ACK); + ser_u32(s, sn); + + console.debug(`ack ${sn} out`); + + if (ws) await ws.send(s.buffer); +} + +async function sync_queue() { + if (ws === null) { + console.debug('socket has closed, stopping SYNs'); + return; + } + + let size = 1 + 1 + 4 + 4; // opcode + lsn + event count + let count = storage.lsn - storage.server_lsn; + + if (count === 0) { + console.debug('server ACKed all events, clearing queue'); + storage.queue.length = 0; + return; + } + + for (let i = count - 1; i >= 0; --i) { + const event = storage.queue[storage.queue.length - 1 - i]; + size += event_size(event); + } + + const s = serializer_create(size); + + ser_u8(s, MESSAGE.SYN); + ser_u8(s, 0); // padding for 16bit alignment + ser_u32(s, storage.lsn); + ser_u32(s, count); + + for (let i = count - 1; i >= 0; --i) { + const event = storage.queue[storage.queue.length - 1 - i]; + ser_event(s, event); + } + + console.debug(`syn ${storage.lsn} out`); + + if (ws) await ws.send(s.buffer); + + setTimeout(sync_queue, config.sync_timeout); +} + +function push_event(event) { + storage.lsn += 1; + + switch (event.type) { + case EVENT.STROKE: { + const points = process_stroke(event.points); + storage.queue.push({ 'type': EVENT.STROKE, 'points': points }); + break; + } + + case EVENT.UNDO: + case EVENT.REDO: { + storage.queue.push(event); + break; + } + } +} + +// Queue an event and initialize repated sends until ACKed +function queue_event(event, skip = false) { + push_event(event); + + if (skip) { + return; + } + + if (storage.timers.queue_sync) { + clearTimeout(storage.timers.queue_sync); + } + + sync_queue(); +} + +// Fire and forget. Doesn't do anything if we are offline +async function fire_event(event) { + const s = serializer_create(1 + event_size(event)); + + ser_u8(s, MESSAGE.FIRE); + ser_event(s, event); + + if (ws) await ws.send(s.buffer); +} \ No newline at end of file diff --git a/client/websocket.js b/client/websocket.js new file mode 100644 index 0000000..2d8de34 --- /dev/null +++ b/client/websocket.js @@ -0,0 +1,35 @@ +function ws_connect() { + const session_id = ls.getItem('sessionId') || '0'; + const desk_id = storage.desk_id; + + ws = new WebSocket(`${config.ws_url}?deskId=${desk_id}&sessionId=${session_id}`); + + ws.addEventListener('open', on_open); + ws.addEventListener('message', on_message); + ws.addEventListener('error', on_error); + ws.addEventListener('close', on_close); +} + +function on_open() { + clearTimeout(storage.timers.ws_reconnect); + console.debug('open') +} + +async function on_message(event) { + const data = event.data; + const message_data = await data.arrayBuffer(); + const view = new DataView(message_data); + const d = deserializer_create(message_data, view); + + await handle_message(d); +} + +function on_close() { + ws = null; + console.debug('close'); + storage.timers.ws_reconnect = setTimeout(ws_connect, config.ws_reconnect_timeout); +} + +function on_error() { + ws.close(); +} \ No newline at end of file diff --git a/server/app.mjs b/server/app.mjs new file mode 100644 index 0000000..8d36a02 --- /dev/null +++ b/server/app.mjs @@ -0,0 +1,5 @@ +import * as storage from './storage'; +import * as server from './server'; + +storage.startup(); +server.startup(); diff --git a/server/bun.lockb b/server/bun.lockb new file mode 100755 index 0000000..eed46a0 Binary files /dev/null and b/server/bun.lockb differ diff --git a/server/config.js b/server/config.js new file mode 100644 index 0000000..92eda4c --- /dev/null +++ b/server/config.js @@ -0,0 +1,4 @@ +export const HOST = '127.0.0.1'; +export const PORT = 3003; +export const DATADIR = 'data'; +export const SYNC_TIMEOUT = 1000; \ No newline at end of file diff --git a/server/data/db.sqlite b/server/data/db.sqlite new file mode 100644 index 0000000..bb57799 Binary files /dev/null and b/server/data/db.sqlite differ diff --git a/server/deserializer.js b/server/deserializer.js new file mode 100644 index 0000000..606ed79 --- /dev/null +++ b/server/deserializer.js @@ -0,0 +1,67 @@ +import { EVENT } from './enums'; + +export function create(dataview) { + return { + 'offset': 0, + 'size': dataview.byteLength, + 'view': dataview, + }; +} + +export function u8(d) { + const value = d.view.getUint8(d.offset); + d.offset += 1; + return value; +} + +export function u16(d) { + const value = d.view.getUint16(d.offset, true); + d.offset += 2; + return value; +} + +export function u32(d) { + const value = d.view.getUint32(d.offset, true); + d.offset += 4; + return value; +} + +function u16array(d, count) { + const array = new Uint16Array(d.view.buffer, d.offset, count); + d.offset += count * 2; + return array; +} + +export function event(d) { + const event = {}; + + event.type = u8(d); + u8(d); // padding + + switch (event.type) { + case EVENT.PREDRAW: { + event.x = u16(d); + event.y = u16(d); + break; + } + + case EVENT.STROKE: { + const point_count = u16(d); + event.points = u16array(d, point_count * 2); + break; + } + + case EVENT.UNDO: + case EVENT.REDO: { + break; + } + + default: { + console.error('fuck'); + console.trace(); + process.exit(1); + } + } + + return event; +} \ No newline at end of file diff --git a/server/enums.js b/server/enums.js new file mode 100644 index 0000000..7265373 --- /dev/null +++ b/server/enums.js @@ -0,0 +1,26 @@ +export const SESSION = Object.freeze({ + OPENED: 0, // nothing sent/recved yet + READY: 1, // init complete + CLOSED: 2, // socket closed, but we might continute this same session on another socket +}); + +export const EVENT = Object.freeze({ + PREDRAW: 10, + STROKE: 20, + UNDO: 30, + REDO: 31, +}); + +export const MESSAGE = Object.freeze({ + INIT: 100, + SYN: 101, + ACK: 102, + FULL: 103, + FIRE: 104, + JOIN: 105, +}); + +export const SNS = Object.freeze({ + DESK: 1, + SESSION: 2, +}); \ No newline at end of file diff --git a/server/http.js b/server/http.js new file mode 100644 index 0000000..e0d641f --- /dev/null +++ b/server/http.js @@ -0,0 +1,4 @@ +export function route(req) { + console.log('HTTP:', req.url); + return new Response(req.url); +} \ No newline at end of file diff --git a/server/math.js b/server/math.js new file mode 100644 index 0000000..d9f27b5 --- /dev/null +++ b/server/math.js @@ -0,0 +1,14 @@ +import crypto from 'crypto'; + +export function crypto_random32() { + const arr = new Uint8Array(4); + const dataview = new DataView(arr.buffer); + + crypto.getRandomValues(arr); + + return dataview.getUint32(0); +} + +export function fast_random32() { + return Math.floor(Math.random() * 4294967296); +} \ No newline at end of file diff --git a/server/recv.js b/server/recv.js new file mode 100644 index 0000000..c84247c --- /dev/null +++ b/server/recv.js @@ -0,0 +1,129 @@ +import * as des from './deserializer'; +import * as send from './send'; +import * as math from './math'; +import * as storage from './storage'; + +import { SESSION, MESSAGE, EVENT } from './enums'; +import { sessions, desks } from './storage'; + +// Session ACKed events up to SN +function recv_ack(d, session) { + const sn = des.u32(d); + + session.state = SESSION.READY; + session.sn = sn; + + console.log(`ack ${sn} in`); +} + +function handle_event(session, event) { + switch (event.type) { + case EVENT.STROKE: { + event.stroke_id = math.fast_random32(); + storage.put_stroke(event.stroke_id, session.desk_id, event.points); + storage.put_event(event); + break; + } + + case EVENT.UNDO: { + storage.put_event(event); + break; + } + + default: { + console.error('fuck'); + console.trace(); + process.exit(1); + } + } +} + +async function recv_syn(d, session) { + const padding = des.u8(d); + const lsn = des.u32(d); + const count = des.u32(d); + + console.log(`syn ${lsn} in, total size = ${d.size}`); + + const we_expect = lsn - session.lsn; + const first = count - we_expect; + const events = []; + + console.log(`we expect ${we_expect}, count ${count}`); + + for (let i = 0; i < count; ++i) { + const event = des.event(d); + if (i >= first) { + event.desk_id = session.desk_id; + event.user_id = session.user_id; + event.stroke_id = null; + handle_event(session, event); + events.push(event); + } + } + + desks[session.desk_id].sn += we_expect; + desks[session.desk_id].events.push(...events); + session.lsn = lsn; + + storage.save_desk_sn(session.desk_id, desks[session.desk_id].sn); + storage.save_session_lsn(session.id, lsn); + + send.send_ack(session.ws, lsn); + send.sync_desk(session.desk_id); +} + +function recv_fire(d, session) { + const event = des.event(d); + + for (const sid in sessions) { + const other = sessions[sid]; + + if (other.id === session.id) { + continue; + } + + if (other.state !== SESSION.READY) { + continue; + } + + if (other.desk_id != session.desk_id) { + continue; + } + + send.send_fire(other.ws, session.user_id, event); + } +} + +export async function handle_message(ws, d) { + if (!(ws.data.session_id in sessions)) { + return; + } + + const session = sessions[ws.data.session_id]; + const desk_id = session.desk_id; + const message_type = des.u8(d); + + switch (message_type) { + case MESSAGE.FIRE: { + recv_fire(d, session); + break; + } + + case MESSAGE.SYN: { + recv_syn(d, session); + break; + } + + case MESSAGE.ACK: { + recv_ack(d, session); + break; + } + + default: { + console.error('fuck'); + console.trace(); + process.exit(1); + } + } +} diff --git a/server/send.js b/server/send.js new file mode 100644 index 0000000..ac825cd --- /dev/null +++ b/server/send.js @@ -0,0 +1,186 @@ +import * as math from './math'; +import * as ser from './serializer'; +import * as storage from './storage'; +import * as config from './config'; + +import { MESSAGE, SESSION, EVENT } from './enums'; +import { sessions, desks } from './storage'; + +function event_size(event) { + let size = 1 + 4; // type + user_id + + switch (event.type) { + case EVENT.PREDRAW: { + size += 2 * 2; + break; + } + + case EVENT.STROKE: { + size += 2; // point count + size += event.points.byteLength; + break; + } + + case EVENT.UNDO: + case EVENT.REDO: { + break; + } + + default: { + console.error('fuck'); + console.trace(); + process.exit(1); + } + } + + return size; +} + +function create_session(ws, desk_id) { + const user = { + id: math.crypto_random32(), + login: 'unnamed', + }; + + const session = { + id: math.crypto_random32(), + user_id: user.id, + desk_id: desk_id, + state: SESSION.OPENED, + sn: 0, + lsn: 0, + ws: ws, + }; + + storage.create_user(user); + storage.create_session(session); + + sessions[session.id] = session; + + return session; +} + +export async function send_init(ws) { + const session_id = ws.data.session_id; + const desk_id = ws.data.desk_id; + const desk = desks[desk_id]; + + let opcode = MESSAGE.INIT; + let size = 1 + 4 + 4 + 4 + 4; // opcode + user_id + lsn + event count + stroke count + let session = null; + + if (session_id in sessions && sessions[session_id].desk_id == desk_id) { + session = sessions[session_id]; + } else { + size += 4; // session id + opcode = MESSAGE.JOIN; + session = create_session(ws, desk_id); + ws.data.session_id = session.id; + } + + session.desk_id = desk_id; + session.ws = ws; + session.sn = 0; // Always re-send everything on reconnect + session.state = SESSION.OPENED; + + console.log(`session ${session.id} opened`); + + for (const event of desk.events) { + size += event_size(event); + } + + const s = ser.create(size); + + ser.u8(s, opcode); + ser.u32(s, session.user_id); + ser.u32(s, session.lsn); + + if (opcode === MESSAGE.JOIN) { + ser.u32(s, session.id); + } + + ser.u32(s, desk.events.length); + + for (const event of desk.events) { + ser.event(s, event); + } + + await ws.send(s.buffer); +} + +export function send_ack(ws, lsn) { + const size = 1 + 4; // opcode + lsn + const s = ser.create(size); + + ser.u8(s, MESSAGE.ACK); + ser.u32(s, lsn); + + console.log(`ack ${lsn} out`); + + ws.send(s.buffer); +} + +export function send_fire(ws, user_id, event) { + const s = ser.create(1 + 4 + event_size(event)); + + ser.u8(s, MESSAGE.FIRE); + ser.u32(s, user_id); + ser.event(s, event); + + ws.send(s.buffer); +} + +async function sync_session(session_id) { + if (!(session_id in sessions)) { + return; + } + + const session = sessions[session_id]; + const desk = desks[session.desk_id]; + + if (session.state !== SESSION.READY) { + return; + } + + let size = 1 + 4 + 4; // opcode + sn + event count + let count = desk.sn - session.sn; + + if (count === 0) { + console.log('client ACKed all events'); + return; + } + + for (let i = count - 1; i >= 0; --i) { + const event = desk.events[desk.events.length - 1 - i]; + size += event_size(event); + } + + const s = ser.create(size); + + ser.u8(s, MESSAGE.SYN); + ser.u32(s, desk.sn); + ser.u32(s, count); + + for (let i = count - 1; i >= 0; --i) { + const event = desk.events[desk.events.length - 1 - i]; + ser.event(s, event); + } + + console.debug(`syn ${desk.sn} out`); + + await session.ws.send(s.buffer); + + session.sync_timer = setTimeout(() => sync_session(session_id), config.SYNC_TIMEOUT); +} + +export function sync_desk(desk_id) { + for (const sid in sessions) { + const session = sessions[sid]; + if (session.state === SESSION.READY && session.desk_id == desk_id) { // NOT ===, because might be string or int IDK + if (session.sync_timer) { + clearTimeout(session.sync_timer); + } + sync_session(sid); + } + } +} \ No newline at end of file diff --git a/server/serializer.js b/server/serializer.js new file mode 100644 index 0000000..9fd289c --- /dev/null +++ b/server/serializer.js @@ -0,0 +1,63 @@ +import { EVENT } from './enums'; + +export function create(size) { + const buffer = new ArrayBuffer(size); + return { + 'offset': 0, + 'size': size, + 'buffer': buffer, + 'view': new DataView(buffer), + 'strview': new Uint8Array(buffer), + }; +} + +export function u8(s, value) { + s.view.setUint8(s.offset, value); + s.offset += 1; +} + +export function u16(s, value) { + s.view.setUint16(s.offset, value, true); + s.offset += 2; +} + +export function u32(s, value) { + s.view.setUint32(s.offset, value, true); + s.offset += 4; +} + +export function bytes(s, bytes) { + s.strview.set(new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength), s.offset); + s.offset += bytes.byteLength; +} + +export function event(s, event) { + u8(s, event.type); + u32(s, event.user_id); + + switch (event.type) { + case EVENT.PREDRAW: { + u16(s, event.x); + u16(s, event.y); + break; + } + + case EVENT.STROKE: { + const points_bytes = event.points; + u16(s, points_bytes.byteLength / 2 / 2); // each point is 2 u16s + bytes(s, points_bytes); + break; + } + + case EVENT.UNDO: + case EVENT.REDO: { + break; + } + + default: { + console.error('fuck'); + console.trace(); + process.exit(1); + } + } +} diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..54a6df2 --- /dev/null +++ b/server/server.js @@ -0,0 +1,75 @@ +import * as config from './config'; +import * as storage from './storage'; +import * as http_server from './http'; +import * as math from './math'; +import * as ser from './serializer'; +import * as des from './deserializer'; +import * as send from './send'; +import * as recv from './recv'; + +import { MESSAGE, EVENT, SESSION, SNS } from './enums'; +import { sessions, desks } from './storage'; + +export function startup() { + Bun.serve({ + hostname: config.HOST, + port: config.PORT, + + fetch(req, server) { + const url = new URL(req.url); + + if (url.pathname == '/ws/') { + const desk_id = url.searchParams.get('deskId') || '0'; + const session_id = url.searchParams.get('sessionId') || '0'; + + if (!(desk_id in desks)) { + const desk = { + id: desk_id, + sn: 0, + events: [], + }; + + storage.create_desk(desk_id); + desks[desk_id] = desk; + } + + server.upgrade(req, { + data: { + desk_id: desk_id, + session_id: session_id, + } + }); + + return; + } + + return http_server.route(req); + }, + + websocket: { + open(ws) { + send.send_init(ws); + }, + + async message(ws, u8array) { + const dataview = new DataView(u8array.buffer); + const d = des.create(dataview); + await recv.handle_message(ws, d); + }, + + close(ws, code, message) { + if (ws.data.session_id in sessions) { + console.log(`session ${ws.data.session_id} closed`); + sessions[ws.data.session_id].state = SESSION.CLOSED; + sessions[ws.data.session_id].ws = null; + } + }, + + error(ws, error) { + close(ws, 1000, error); // Treat error as normal close + } + } + }); + + console.log(`Running on ${config.HOST}:${config.PORT}`) +} \ No newline at end of file diff --git a/server/storage.js b/server/storage.js new file mode 100644 index 0000000..8fd1a5d --- /dev/null +++ b/server/storage.js @@ -0,0 +1,201 @@ +import * as config from './config'; +import * as sqlite from 'bun:sqlite'; + +import { EVENT, SESSION } from './enums'; + +export const sessions = {}; +export const desks = {}; + +let db = null; +const queries = {}; + +export function startup() { + const path = `${config.DATADIR}/db.sqlite`; + + db = new sqlite.Database(path, { create: true }); + + db.query(`CREATE TABLE IF NOT EXISTS desks ( + id INTEGER PRIMARY KEY, + sn INTEGER, + title TEXT + );`).run(); + + db.query(`CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + login TEXT + );`).run(); + + db.query(`CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY, + user_id INTEGER, + desk_id INTEGER, + lsn INTEGER, + + FOREIGN KEY (user_id) + REFERENCES users (id) + ON DELETE CASCADE + ON UPDATE NO ACTION, + + FOREIGN KEY (desk_id) + REFERENCES desks (id) + ON DELETE CASCADE + ON UPDATE NO ACTION + );`).run(); + + db.query(`CREATE TABLE IF NOT EXISTS strokes ( + id INTEGER PRIMARY KEY, + desk_id INTEGER, + points BLOB, + + FOREIGN KEY (desk_id) + REFERENCES desks (id) + ON DELETE CASCADE + ON UPDATE NO ACTION + );`).run(); + + db.query(`CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY, + type INTEGER, + desk_id INTEGER, + user_id INTEGER, + stroke_id INTEGER, + x INTEGER, + y INTEGER, + + FOREIGN KEY (desk_id) + REFERENCES desks (id) + ON DELETE CASCADE + ON UPDATE NO ACTION, + + FOREIGN KEY (user_id) + REFERENCES users (id) + ON DELETE CASCADE + ON UPDATE NO ACTION + + FOREIGN KEY (stroke_id) + REFERENCES strokes (id) + ON DELETE CASCADE + ON UPDATE NO ACTION + );`).run(); + + db.query(`CREATE INDEX IF NOT EXISTS idx_events_desk_id + ON events (desk_id); + `).run(); + + db.query(`CREATE INDEX IF NOT EXISTS idx_strokes_desk_id + ON strokes (desk_id); + `).run(); + + const res1 = db.query('SELECT COUNT(id) as count FROM desks').get(); + const res2 = db.query('SELECT COUNT(id) as count FROM events').get(); + const res3 = db.query('SELECT COUNT(id) as count FROM strokes').get(); + const res4 = db.query('SELECT COUNT(id) as count FROM users').get(); + const res5 = db.query('SELECT COUNT(id) as count FROM sessions').get(); + + queries.desks = db.query('SELECT id, sn FROM desks'); + queries.events = db.query('SELECT id, desk_id, user_id, stroke_id, type, x, y FROM events'); + queries.sessions = db.query('SELECT id, lsn, user_id, desk_id FROM sessions'); + queries.strokes = db.query('SELECT id, points FROM strokes'); + queries.empty_desk = db.query('INSERT INTO desks (id, title, sn) VALUES (?1, ?2, 0)'); + queries.desk_strokes = db.query('SELECT id, points FROM strokes WHERE desk_id = ?1'); + queries.put_desk_stroke = db.query('INSERT INTO strokes (id, desk_id, points) VALUES (?1, ?2, ?3)'); + queries.clear_desk_events = db.query('DELETE FROM events WHERE desk_id = ?1'); + queries.set_desk_sn = db.query('UPDATE desks SET sn = ?1 WHERE id = ?2'); + queries.save_session_lsn = db.query('UPDATE sessions SET lsn = ?1 WHERE id = ?2'); + queries.create_session = db.query('INSERT INTO sessions (id, lsn, user_id, desk_id) VALUES (?1, 0, ?2, ?3)'); + queries.create_user = db.query('INSERT INTO users (id, login) VALUES (?1, ?2)'); + queries.put_event = db.query('INSERT INTO events (type, desk_id, user_id, stroke_id, x, y) VALUES (?1, ?2, ?3, ?4, ?5, ?6)'); + + console.log(`Storing data in ${path}`); + console.log(`Entity count at startup: + ${res1.count} desks + ${res2.count} events + ${res3.count} strokes + ${res4.count} users + ${res5.count} sessions` + ); + + const stored_desks = get_desks(); + const stored_events = get_events(); + const stored_strokes = get_strokes(); + const stored_sessions = get_sessions(); + + const stroke_dict = {}; + + for (const stroke of stored_strokes) { + stroke.points = new Uint16Array(stroke.points.buffer); + stroke_dict[stroke.id] = stroke; + } + + for (const desk of stored_desks) { + desks[desk.id] = desk; + desks[desk.id].events = []; + } + + for (const event of stored_events) { + if (event.type === EVENT.STROKE) { + event.points = stroke_dict[event.stroke_id].points; + } + + + desks[event.desk_id].events.push(event); + } + + for (const session of stored_sessions) { + session.state = SESSION.CLOSED; + session.ws = null; + sessions[session.id] = session; + } +} + +export function get_strokes() { + return queries.strokes.all(); +} + +export function get_sessions() { + return queries.sessions.all(); +} + +export function get_desks() { + return queries.desks.all(); +} + +export function get_events() { + return queries.events.all(); +} + +export function get_desk_strokes(desk_id) { + return queries.desk_strokes.all(desk_id); +} + +export function put_event(event) { + return queries.put_event.get(event.type, event.desk_id || 0, event.user_id || 0, event.stroke_id || 0, event.x || 0, event.y || 0); +} + +export function put_stroke(stroke_id, desk_id, points) { + return queries.put_desk_stroke.get(stroke_id, desk_id, new Uint8Array(points.buffer, points.byteOffset, points.byteLength)); +} + +export function clear_events(desk_id) { + return queries.clear_desk_events.get(desk_id); +} + +export function create_desk(desk_id, title = 'untitled') { + return queries.empty_desk.get(desk_id, title); +} + +export function save_desk_sn(desk_id, sn) { + return queries.set_desk_sn.get(sn, desk_id); +} + +export function create_session(session) { + return queries.create_session.get(session.id, session.user_id, session.desk_id); +} + +export function create_user(user) { + return queries.create_user.get(user.id, user.login); +} + +export function save_session_lsn(session_id, lsn) { + return queries.save_session_lsn.get(lsn, session_id); +} \ No newline at end of file