Browse Source

Initital commit

master
A.Olokhtonov 1 year ago
commit
4466640439
  1. 18
      Caddyfile
  2. 96
      client/cursor.js
  3. 40
      client/default.css
  4. 41
      client/draw.js
  5. BIN
      client/favicon.png
  6. BIN
      client/favicon2.png
  7. 21
      client/index.html
  8. 148
      client/index.js
  9. 135
      client/math.js
  10. 229
      client/recv.js
  11. 154
      client/send.js
  12. 35
      client/websocket.js
  13. 5
      server/app.mjs
  14. BIN
      server/bun.lockb
  15. 4
      server/config.js
  16. BIN
      server/data/db.sqlite
  17. 67
      server/deserializer.js
  18. 26
      server/enums.js
  19. 4
      server/http.js
  20. 14
      server/math.js
  21. 129
      server/recv.js
  22. 186
      server/send.js
  23. 63
      server/serializer.js
  24. 75
      server/server.js
  25. 201
      server/storage.js

18
Caddyfile

@ -0,0 +1,18 @@ @@ -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
}
}

96
client/cursor.js

@ -0,0 +1,96 @@ @@ -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;
}
}

40
client/default.css

@ -0,0 +1,40 @@ @@ -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;
}

41
client/draw.js

@ -0,0 +1,41 @@ @@ -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 });
}

BIN
client/favicon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 B

BIN
client/favicon2.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 B

21
client/index.html

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Desk</title>
<link rel="icon" type="image/png" href="favicon.png">
<link rel="stylesheet" type="text/css" href="default.css">
<script type="text/javascript" src="index.js"></script>
<script type="text/javascript" src="cursor.js"></script>
<script type="text/javascript" src="websocket.js"></script>
<script type="text/javascript" src="send.js"></script>
<script type="text/javascript" src="recv.js"></script>
<script type="text/javascript" src="math.js"></script>
<script type="text/javascript" src="draw.js"></script>
</head>
<body>
<div class="cursor" id="cursor"></div>
<canvas class="canvas" id="canvas0"></canvas>
<canvas class="canvas" id="canvas1"></canvas>
</body>
</html>

148
client/index.js

@ -0,0 +1,148 @@ @@ -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);
}

135
client/math.js

@ -0,0 +1,135 @@ @@ -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);
}

229
client/recv.js

@ -0,0 +1,229 @@ @@ -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;
}
}
}

154
client/send.js

@ -0,0 +1,154 @@ @@ -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);
}

35
client/websocket.js

@ -0,0 +1,35 @@ @@ -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();
}

5
server/app.mjs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
import * as storage from './storage';
import * as server from './server';
storage.startup();
server.startup();

BIN
server/bun.lockb

Binary file not shown.

4
server/config.js

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
export const HOST = '127.0.0.1';
export const PORT = 3003;
export const DATADIR = 'data';
export const SYNC_TIMEOUT = 1000;

BIN
server/data/db.sqlite

Binary file not shown.

67
server/deserializer.js

@ -0,0 +1,67 @@ @@ -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;
}

26
server/enums.js

@ -0,0 +1,26 @@ @@ -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,
});

4
server/http.js

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
export function route(req) {
console.log('HTTP:', req.url);
return new Response(req.url);
}

14
server/math.js

@ -0,0 +1,14 @@ @@ -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);
}

129
server/recv.js

@ -0,0 +1,129 @@ @@ -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);
}
}
}

186
server/send.js

@ -0,0 +1,186 @@ @@ -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);
}
}
}

63
server/serializer.js

@ -0,0 +1,63 @@ @@ -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);
}
}
}

75
server/server.js

@ -0,0 +1,75 @@ @@ -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}`)
}

201
server/storage.js

@ -0,0 +1,201 @@ @@ -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);
}
Loading…
Cancel
Save