A "multiplayer" HTML5 <canvas> to which people can connect and make changes over time.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

219 lines
6.6 KiB

// canvas-io-server.js
// Copyright (C) 2022 Rob Colbert @[email protected]
// License: Apache-2.0
'use strict';
import path, { dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line
import fs from 'fs';
import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { RateLimiterRedis } from 'rate-limiter-flexible';
import Jimp from 'jimp';
import { CronJob } from 'cron';
class CanvasImage {
get dirty ( ) { return this._dirty; }
constructor (id, width, height) {
this.id = id;
this.image = new Jimp(width, height, 0xffffffff);
this._dirty = false;
}
setPixel (pixel) {
let idx = ((pixel.y * this.image.bitmap.width) + pixel.x) * 4;
this.image.bitmap.data[idx++] = pixel.r;
this.image.bitmap.data[idx++] = pixel.g;
this.image.bitmap.data[idx++] = pixel.b;
this._dirty = true;
}
async save (filename) {
await this.image.deflateLevel(9).writeAsync(filename);
this._dirty = false;
}
}
export default class CanvasIoServer {
constructor (app) {
this.app = app;
this.limiters = {
getImageData: new RateLimiterRedis({ storeClient: this.app.redis, points: 10, duration: 60 }),
setPixel: new RateLimiterRedis({ storeClient: this.app.redis, points: 10, duration: 60 }),
};
this.images = { };
console.log('creating test image', { width: 32, height: 32 });
this.images['test-image'] = new CanvasImage('test-image', 32, 32);
this.images['test-image'].setPixel({ x: 5, y: 5, r: 255, g: 0, b: 0 });
}
async start ( ) {
const pubClient = this.app.redis.duplicate();
pubClient.on('error', this.onRedisError.bind(this));
const subClient = this.app.redis.duplicate();
subClient.on('error', this.onRedisError.bind(this));
const transports = ['websocket'/*, 'polling'*/];
const adapter = createAdapter(pubClient, subClient);
this.io = new Server(this.app.httpServer, { adapter, transports });
this.io.on('connection', this.onSocketConnect.bind(this));
console.log('starting image backup cron');
this.backupCron = new CronJob(
'*/15 * * * * *',
this.onImageBackup.bind(this),
null,
true,
'America/New_York',
);
console.log('CanvasIoServer ready');
}
async onRedisError (error) {
console.log('Redis error', error);
}
async onSocketConnect (socket) {
const CONNECT_TOKEN = socket.handshake.auth.token;
console.log('socket connection', { sid: socket.id, token: CONNECT_TOKEN });
const TOKEN_KEY = `connect-token:${CONNECT_TOKEN}`;
const tokenData = await this.app.redis.get(TOKEN_KEY);
if (!tokenData) {
console.log('rejecting invalid socket token', {
sid: socket.sid,
handshake: socket.handshake,
});
socket.close();
return;
}
const token = JSON.parse(tokenData);
const IMAGE_ID = token.imageId;
/*
* Remove the connect token to prevent multiple connects
*/
await this.app.redis.del(TOKEN_KEY);
const session = { CONNECT_TOKEN, IMAGE_ID, socket };
session.onSocketDisconnect = this.onSocketDisconnect.bind(this, session);
session.onJoinChannel = this.onJoinChannel.bind(this, session);
session.onLeaveChannel = this.onLeaveChannel.bind(this, session);
session.onGetImageData = this.onGetImageData.bind(this, session);
session.onSetPixel = this.onSetPixel.bind(this, session);
socket.on('disconnect', session.onSocketDisconnect);
socket.on('join', session.onJoinChannel);
socket.on('leave', session.onLeaveChannel);
socket.on('getimagedata', session.onGetImageData);
socket.on('setpixel', session.onSetPixel);
socket.emit('authenticated', { message: 'token verified' });
}
async onSocketDisconnect (session, reason) {
const { CONNECT_TOKEN, IMAGE_ID, socket } = session;
console.log('socket disconnect', { sid: socket.id, CONNECT_TOKEN, IMAGE_ID, reason });
session.socket.off('disconnect', session.onSocketDisconnect);
session.socket.off('join', session.onJoinChannel);
session.socket.off('leave', session.onLeaveChannel);
}
async onJoinChannel (session, message) {
const { channelId } = message;
console.log('socket joins channel', { sid: session.socket.id, token: session.CONNECT_TOKEN, channelId });
session.socket.join(channelId);
session.socket.emit('join-result', { channelId });
}
async onLeaveChannel (session, message) {
const { channelId } = message;
console.log('socket leaves channel', { sid: session.socket.id, token: session.CONNECT_TOKEN, channelId });
session.socket.leave(channelId);
}
async onGetImageData (session) {
const { CONNECT_TOKEN, IMAGE_ID, socket } = session;
console.log('onGetImageData', { sid: session.socket.id, CONNECT_TOKEN, IMAGE_ID });
try {
const limiterKey = `${IMAGE_ID}:${CONNECT_TOKEN}`;
await this.limiters.getImageData.consume(limiterKey, 1);
} catch (error) {
console.log('onGetImageData rate limit exceeded', { CONNECT_TOKEN, IMAGE_ID });
socket.emit('canvas-error', { command: 'getimagedata', message: error.message });
return;
}
const image = this.images[IMAGE_ID];
if (!image) {
socket.emit('canvas-error', { message: 'image not found' });
return;
}
session.socket.emit('canvas-image-data', {
width: image.image.bitmap.width,
height: image.image.bitmap.height,
data: Buffer.from(image.image.bitmap.data),
});
}
async onSetPixel (session, message) {
const { CONNECT_TOKEN, IMAGE_ID, socket } = session;
try {
const limiterKey = `${IMAGE_ID}:${CONNECT_TOKEN}`;
await this.limiters.setPixel.consume(limiterKey, 1);
} catch (error) {
console.log('failed to setpixel', error);
socket.emit('canvas-error', { command: 'setpixel', error: JSON.stringify(error) });
return;
}
const image = this.images[IMAGE_ID];
if (!image) {
socket.emit('canvas-error', { command: 'setpixel', message: 'Image does not exist' });
return;
}
console.log('onSetPixel', IMAGE_ID, message);
image.setPixel(message);
this.io.in(IMAGE_ID).emit('canvas-setpixel', {
x: message.x,
y: message.y,
r: message.r,
g: message.g,
b: message.b,
});
}
async onImageBackup ( ) {
for (const imageId in this.images) {
const image = this.images[imageId];
const imageIdPrefix = image.id.slice(0, 4);
const imagePath = path.join(__dirname, 'images', imageIdPrefix);
await fs.promises.mkdir(imagePath, { recursive: true });
await image.save(path.join(imagePath, `${image.id}.png`));
}
}
}