massive performance rewrite

- main canvas & blank canvas drawing moved to separate worker thread (if possible)
- server jobs moved to separate process (fixing CPU leak on heatmap generation)
- pixels now store if they are on top reducing db queries
- remove various methods to store pixel data in redis, reducing delay for various actions

additional fixed: (came up during performance fixes)
- added square fill (fixes #15)
- redraw loop (fixes #59)
- added keybind to deselect current color (fixes #54)
- pixel undos no longer delete the pixel from the db
- server logging now indicates what module triggered the log
This commit is contained in:
Grant 2024-06-26 18:38:46 -06:00
parent 78d97b52e3
commit b09ddd13b4
37 changed files with 1244 additions and 240 deletions

View File

@ -70,7 +70,7 @@ RUN npm -w packages/server run build
FROM base as run
WORKDIR /home/node/app
COPY --from=dep /home/node/app/ ./
COPY package*.json docker-start.sh .git ./
COPY package*.json docker-start*.sh .git ./
# --- prepare lib ---

View File

@ -19,6 +19,17 @@ services:
condition: service_healthy
postgres:
condition: service_healthy
worker:
image: sc07/canvas
build: .
environment:
- REDIS_HOST=redis://redis
- DATABASE_URL=postgres://postgres@postgres/canvas
env_file:
- .env.local
depends_on:
- canvas
command: ./docker-start-worker.sh
redis:
restart: always
image: redis:7-alpine

3
docker-start-worker.sh Normal file
View File

@ -0,0 +1,3 @@
#!/bin/sh
npm -w packages/server run tool start_job_worker

View File

@ -1,4 +1,4 @@
import { useCallback, useContext, useEffect, useRef } from "react";
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import { Canvas } from "../lib/canvas";
import { useAppContext } from "../contexts/AppContext";
import { PanZoomWrapper } from "@sc07-canvas/lib/src/renderer";
@ -23,29 +23,56 @@ export const CanvasWrapper = () => {
<HeatmapOverlay />
{config && <Template />}
<CanvasInner />
<Cursor />
</PanZoomWrapper>
</main>
);
};
const Cursor = () => {
const { cursor } = useAppContext();
const [color, setColor] = useState<string>();
useEffect(() => {
console.log("color", color);
}, [color]);
useEffect(() => {
if (typeof cursor.color === "number") {
const color = Canvas.instance?.Pallete.getColor(cursor.color);
setColor(color?.hex);
} else {
setColor(undefined);
}
}, [setColor, cursor.color]);
if (!color) return <></>;
return (
<div
className="noselect"
style={{
position: "absolute",
top: cursor.y,
left: cursor.x,
backgroundColor: "#" + color,
width: "1px",
height: "1px",
opacity: 0.5,
}}
></div>
);
};
const CanvasInner = () => {
const canvasRef = useRef<HTMLCanvasElement | null>();
const canvas = useRef<Canvas>();
const { config, setCanvasPosition, setCursorPosition, setPixelWhois } =
const { config, setCanvasPosition, setCursor, setPixelWhois } =
useAppContext();
const PanZoom = useContext(RendererContext);
useEffect(() => {
if (!canvasRef.current) return;
canvas.current = new Canvas(canvasRef.current!, PanZoom);
const handlePixelWhois = ({
clientX,
clientY,
}: {
clientX: number;
clientY: number;
}) => {
const handlePixelWhois = useCallback(
({ clientX, clientY }: { clientX: number; clientY: number }) => {
if (!canvas.current) {
console.warn(
"[CanvasWrapper#handlePixelWhois] canvas instance does not exist"
@ -83,7 +110,20 @@ const CanvasInner = () => {
const surrounding = canvas.current.getSurroundingPixels(x, y, 3);
setPixelWhois({ x, y, surrounding });
};
},
[canvas.current]
);
useEffect(() => {
if (!canvasRef.current) return;
canvas.current = new Canvas(canvasRef.current!, PanZoom);
canvas.current.on("canvasReady", () => {
console.log("[CanvasWrapper] received canvasReady");
// refresh because canvas might've resized
const initialRouter = Router.get();
handleNavigate(initialRouter);
});
KeybindManager.on("PIXEL_WHOIS", handlePixelWhois);
@ -91,7 +131,7 @@ const CanvasInner = () => {
KeybindManager.off("PIXEL_WHOIS", handlePixelWhois);
canvas.current!.destroy();
};
}, [PanZoom, setCursorPosition]);
}, [PanZoom]);
useEffect(() => {
Router.PanZoom = PanZoom;
@ -115,10 +155,18 @@ const CanvasInner = () => {
pos.x > config.canvas.size[0] ||
pos.y > config.canvas.size[1]
) {
setCursorPosition();
setCursor((v) => ({
...v,
x: undefined,
y: undefined,
}));
} else {
// fixes not passing the current value
setCursorPosition({ ...pos });
setCursor((v) => ({
...v,
x: pos.x,
y: pos.y,
}));
}
}, 1);
@ -127,7 +175,7 @@ const CanvasInner = () => {
return () => {
canvas.current!.off("cursorPos", handleCursorPos);
};
}, [config, setCursorPosition]);
}, [config, setCursor]);
useEffect(() => {
if (!canvas.current) {
@ -217,7 +265,7 @@ const CanvasInner = () => {
PanZoom.removeListener("viewportMove", handleViewportMove);
Router.off("navigate", handleNavigate);
};
}, [PanZoom, setCanvasPosition, setCursorPosition]);
}, [PanZoom, setCanvasPosition]);
return (
<canvas

View File

@ -2,7 +2,6 @@ import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Switch,
} from "@nextui-org/react";
@ -32,7 +31,7 @@ export const ModModal = () => {
return () => {
KeybindManager.off("TOGGLE_MOD_MENU", handleKeybind);
};
}, []);
}, [hasAdmin]);
const setBypassCooldown = useCallback(
(value: boolean) => {

View File

@ -1,10 +1,10 @@
import { useEffect, useRef } from "react";
import { useAppContext } from "../../contexts/AppContext";
import { Canvas } from "../../lib/canvas";
import { KeybindManager } from "../../lib/keybinds";
import { getRenderer } from "../../lib/utils";
export const BlankOverlay = () => {
const { config, blankOverlay, setBlankOverlay } = useAppContext();
const { blankOverlay, setBlankOverlay } = useAppContext();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
@ -20,51 +20,21 @@ export const BlankOverlay = () => {
}, [setBlankOverlay]);
useEffect(() => {
if (!config) {
console.warn("[BlankOverlay] config is not defined");
return;
}
if (!canvasRef.current) {
console.warn("[BlankOverlay] canvasRef is not defined");
return;
}
const [width, height] = config.canvas.size;
let timeout = setTimeout(() => {
if (!canvasRef.current) return;
canvasRef.current.width = width;
canvasRef.current.height = height;
}, [config]);
useEffect(() => {
if (!canvasRef.current) {
console.warn("[BlankOverlay] canvasRef is not defined");
return;
}
const updateVirginmap = () => {
const ctx = canvasRef.current!.getContext("2d");
if (!ctx) {
console.warn("[BlankOverlay] canvas context cannot be aquired");
return;
}
ctx.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height);
const pixels = Canvas.instance!.getAllPixels();
for (const pixel of pixels) {
if (pixel.color !== -1) continue;
ctx.fillStyle = "rgba(0,140,0,0.5)";
ctx.fillRect(pixel.x, pixel.y, 1, 1);
}
};
var updateInterval = setInterval(updateVirginmap, 1000);
getRenderer().useCanvas(canvasRef.current, "blank");
}, 1000);
return () => {
clearInterval(updateInterval);
clearTimeout(timeout);
getRenderer().removeCanvas("blank");
};
}, [canvasRef]);
}, [canvasRef.current]);
return (
<canvas

View File

@ -4,17 +4,36 @@ import { Canvas } from "../../lib/canvas";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { IPaletteContext } from "@sc07-canvas/lib/src/net";
import { KeybindManager } from "../../lib/keybinds";
export const Palette = () => {
const { config, user } = useAppContext<true>();
const { config, user, setCursor } = useAppContext<true>();
const [pallete, setPallete] = useState<IPaletteContext>({});
useEffect(() => {
if (!Canvas.instance) return;
Canvas.instance?.updatePallete(pallete);
Canvas.instance.updatePallete(pallete);
setCursor((v) => ({
...v,
color: pallete.color,
}));
}, [pallete]);
useEffect(() => {
const handleDeselect = () => {
setCursor((v) => ({
...v,
color: undefined,
}));
};
KeybindManager.addListener("DESELECT_COLOR", handleDeselect);
return () => {
KeybindManager.removeListener("DESELECT_COLOR", handleDeselect);
};
}, []);
return (
<div id="pallete">
<div className="pallete-colors">

View File

@ -17,8 +17,8 @@ interface IAppContext {
canvasPosition?: ICanvasPosition;
setCanvasPosition: (v: ICanvasPosition) => void;
cursorPosition?: IPosition;
setCursorPosition: (v?: IPosition) => void;
cursor: ICursor;
setCursor: React.Dispatch<React.SetStateAction<ICursor>>;
pixels: { available: number };
undo?: { available: true; expireAt: number };
@ -53,6 +53,12 @@ interface ICanvasPosition {
zoom: number;
}
interface ICursor {
x?: number;
y?: number;
color?: number;
}
interface IMapOverlay {
enabled: boolean;
@ -88,7 +94,7 @@ export const AppContext = ({ children }: PropsWithChildren) => {
const [config, setConfig] = useState<ClientConfig>(undefined as any);
const [auth, setAuth] = useState<AuthSession>();
const [canvasPosition, setCanvasPosition] = useState<ICanvasPosition>();
const [cursorPosition, setCursorPosition] = useState<IPosition>();
const [cursor, setCursor] = useState<ICursor>({});
const [connected, setConnected] = useState(false);
// --- settings ---
@ -205,8 +211,8 @@ export const AppContext = ({ children }: PropsWithChildren) => {
user: auth,
canvasPosition,
setCanvasPosition,
cursorPosition,
setCursorPosition,
cursor,
setCursor,
pixels,
settingsSidebar,
setSettingsSidebar,

View File

@ -13,6 +13,8 @@ import {
} from "@sc07-canvas/lib/src/renderer/PanZoom";
import { toast } from "react-toastify";
import { KeybindManager } from "./keybinds";
import { getRenderer } from "./utils";
import { CanvasPixel } from "./canvasRenderer";
interface CanvasEvents {
/**
@ -22,16 +24,15 @@ interface CanvasEvents {
* @returns
*/
cursorPos: (position: IPosition) => void;
canvasReady: () => void;
}
export class Canvas extends EventEmitter<CanvasEvents> {
static instance: Canvas | undefined;
private _destroy = false;
private config: ClientConfig = {} as any;
private canvas: HTMLCanvasElement;
private PanZoom: PanZoom;
private ctx: CanvasRenderingContext2D;
private cursor = { x: -1, y: -1 };
private pixels: {
@ -40,14 +41,18 @@ export class Canvas extends EventEmitter<CanvasEvents> {
lastPlace: number | undefined;
private bypassCooldown = false;
private _delayedLoad: ReturnType<typeof setTimeout>;
constructor(canvas: HTMLCanvasElement, PanZoom: PanZoom) {
super();
Canvas.instance = this;
getRenderer().startRender();
getRenderer().on("ready", () => this.emit("canvasReady"));
this.canvas = canvas;
this.PanZoom = PanZoom;
this.ctx = canvas.getContext("2d")!;
this._delayedLoad = setTimeout(() => this.delayedLoad(), 1000);
this.PanZoom.addListener("hover", this.handleMouseMove.bind(this));
this.PanZoom.addListener("click", this.handleMouseDown.bind(this));
@ -57,23 +62,37 @@ export class Canvas extends EventEmitter<CanvasEvents> {
([time]) => (this.lastPlace = time)
);
Network.on("pixel", this.handlePixel);
Network.on("square", this.handleSquare);
}
destroy() {
this._destroy = true;
getRenderer().stopRender();
getRenderer().off("ready");
if (this._delayedLoad) clearTimeout(this._delayedLoad);
this.PanZoom.removeListener("hover", this.handleMouseMove.bind(this));
this.PanZoom.removeListener("click", this.handleMouseDown.bind(this));
this.PanZoom.removeListener("longPress", this.handleLongPress);
Network.off("pixel", this.handlePixel);
Network.off("square", this.handleSquare);
}
/**
* React.Strict remounts the main component, causing a quick remount, which then causes errors related to webworkers
*/
delayedLoad() {
getRenderer().useCanvas(this.canvas, "main");
}
setSize(width: number, height: number) {
getRenderer().setSize(width, height);
}
loadConfig(config: ClientConfig) {
this.config = config;
this.canvas.width = config.canvas.size[0];
this.canvas.height = config.canvas.size[1];
this.setSize(config.canvas.size[0], config.canvas.size[1]);
// we want the new one if possible
// (this might cause a timing issue though)
@ -83,10 +102,7 @@ export class Canvas extends EventEmitter<CanvasEvents> {
Network.waitFor("canvas").then(([pixels]) => {
console.log("loadConfig just received new canvas data");
this.handleBatch(pixels);
this.draw();
});
this.draw();
}
hasConfig() {
@ -204,29 +220,73 @@ export class Canvas extends EventEmitter<CanvasEvents> {
this.emit("cursorPos", this.cursor);
}
handleSquare = (
start: [x: number, y: number],
end: [x: number, y: number],
color: number
) => {
const palette = this.Pallete.getColor(color);
let serializeBuild: CanvasPixel[] = [];
for (let x = start[0]; x <= end[0]; x++) {
for (let y = start[1]; y <= end[1]; y++) {
// we still store a copy of the pixels in this instance for non-rendering functions
this.pixels[x + "_" + y] = {
type: "full",
color: palette?.id || -1,
};
serializeBuild.push({
x,
y,
hex:
!palette || palette?.hex === "transparent" ? "null" : palette.hex,
});
}
}
getRenderer().usePixels(serializeBuild);
};
handleBatch = (pixels: string[]) => {
if (!this.config.canvas) {
throw new Error("handleBatch called with no config");
}
let serializeBuild: CanvasPixel[] = [];
for (let x = 0; x < this.config.canvas.size[0]; x++) {
for (let y = 0; y < this.config.canvas.size[1]; y++) {
const hex = pixels[this.config.canvas.size[0] * y + x];
const color = this.Pallete.getColorFromHex(hex);
const palette = this.Pallete.getColorFromHex(hex);
// we still store a copy of the pixels in this instance for non-rendering functions
this.pixels[x + "_" + y] = {
color: color ? color.id : -1,
type: "full",
color: palette?.id || -1,
};
serializeBuild.push({
x,
y,
hex: hex === "transparent" ? "null" : hex,
});
}
}
getRenderer().usePixels(serializeBuild, true);
};
handlePixel = ({ x, y, color }: Pixel) => {
// we still store a copy of the pixels in this instance for non-rendering functions
this.pixels[x + "_" + y] = {
color,
type: "full",
color,
};
const palette = this.Pallete.getColor(color);
getRenderer().usePixel({ x, y, hex: palette?.hex || "null" });
};
palleteCtx: IPaletteContext = {};
@ -401,44 +461,4 @@ export class Canvas extends EventEmitter<CanvasEvents> {
return [output.x, output.y];
}
draw() {
this.ctx.imageSmoothingEnabled = false;
const bezier = (n: number) => n * n * (3 - 2 * n);
this.ctx.globalAlpha = 1;
this.ctx.fillStyle = "#fff";
this.ctx.fillRect(
0,
0,
this.config.canvas.size[0],
this.config.canvas.size[1]
);
for (const [x_y, pixel] of Object.entries(this.pixels)) {
const [x, y] = x_y.split("_").map((a) => parseInt(a));
this.ctx.globalAlpha = pixel.type === "full" ? 1 : 0.5;
this.ctx.fillStyle =
pixel.color > -1
? "#" + this.Pallete.getColor(pixel.color)!.hex
: "transparent";
this.ctx.fillRect(x, y, 1, 1);
}
if (this.palleteCtx.color && this.cursor.x > -1 && this.cursor.y > -1) {
const color = this.config.pallete.colors.find(
(c) => c.id === this.palleteCtx.color
);
let t = ((Date.now() / 100) % 10) / 10;
this.ctx.globalAlpha = t < 0.5 ? bezier(t) : -bezier(t) + 1;
this.ctx.fillStyle = "#" + color!.hex;
this.ctx.fillRect(this.cursor.x, this.cursor.y, 1, 1);
}
if (!this._destroy) window.requestAnimationFrame(() => this.draw());
}
}

View File

@ -0,0 +1,264 @@
import EventEmitter from "eventemitter3";
type RCanvas = HTMLCanvasElement | OffscreenCanvas;
type RContext = OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D;
export type CanvasPixel = {
x: number;
y: number;
hex: string;
};
const bezier = (n: number) => n * n * (3 - 2 * n);
const isWorker = () => {
return (
// @ts-ignore
typeof WorkerGlobalScope !== "undefined" &&
// @ts-ignore
self instanceof WorkerGlobalScope
);
};
export interface RendererEvents {
ready: () => void;
}
export type CanvasRole = "main" | "blank";
/**
* Generic renderer
*
* Can be instansiated inside worker or on the main thread
*/
export class CanvasRenderer extends EventEmitter<RendererEvents> {
private canvas: RCanvas = undefined as any;
private ctx: RContext = undefined as any;
private dimentions = {
width: 0,
height: 0,
};
private blank?: RCanvas;
private blank_ctx?: RContext;
private pixels: CanvasPixel[] = [];
private allPixels: CanvasPixel[] = [];
private isWorker = isWorker();
private _stopRender = false;
constructor() {
super();
console.log("[CanvasRenderer] Initialized", { isWorker: this.isWorker });
}
useCanvas(canvas: HTMLCanvasElement | OffscreenCanvas, role: CanvasRole) {
console.log("[CanvasRenderer] Received canvas reference for " + role);
let ctx = canvas.getContext("2d")! as any;
if (!ctx) {
throw new Error("Unable to get canvas context for " + role);
}
canvas.width = this.dimentions.width;
canvas.height = this.dimentions.height;
switch (role) {
case "main":
this.canvas = canvas;
this.ctx = ctx;
break;
case "blank":
this.blank = canvas;
this.blank_ctx = ctx;
break;
}
}
removeCanvas(role: CanvasRole) {
switch (role) {
case "main":
throw new Error("Cannot remove main canvas");
case "blank":
this.blank = undefined;
this.blank_ctx = undefined;
break;
}
}
usePixels(pixels: CanvasPixel[], replace = false) {
if (replace) {
this.pixels = pixels;
this.allPixels = pixels;
} else {
for (const pixel of pixels) {
this.usePixel(pixel);
}
}
}
usePixel(pixel: CanvasPixel) {
{
let existing = this.pixels.find(
(p) => p.x === pixel.x && p.y === pixel.y
);
if (existing) {
this.pixels.splice(this.pixels.indexOf(existing), 1);
}
}
{
let existing = this.allPixels.find(
(p) => p.x === pixel.x && p.y === pixel.y
);
if (existing) {
this.allPixels.splice(this.allPixels.indexOf(existing), 1);
}
}
this.pixels.push(pixel);
this.allPixels.push(pixel);
}
startRender() {
console.log("[CanvasRenderer] Started rendering loop");
this._stopRender = false;
this.tryDrawFull();
this.tryDrawBlank();
this.renderLoop();
}
stopRender() {
console.log("[CanvasRenderer] Stopped rendering loop");
// used when not in worker
// kills the requestAnimationFrame loop
this._stopRender = true;
}
private tryDrawFull() {
if (this._stopRender) return;
if (this.ctx) {
this.drawFull();
} else {
requestAnimationFrame(() => this.tryDrawFull());
}
}
private tryDrawBlank() {
if (this._stopRender) return;
if (this.blank_ctx) {
this.drawBlank();
setTimeout(() => requestAnimationFrame(() => this.tryDrawBlank()), 1000);
} else {
requestAnimationFrame(() => this.tryDrawBlank());
}
}
private renderLoop() {
if (this._stopRender) return;
if (this.ctx) {
this.draw();
} else {
console.warn("[CanvasRenderer#renderLoop] has no canvas context");
}
requestAnimationFrame(() => this.renderLoop());
}
private drawTimes: number[] = [];
/**
* Draw canvas
*
* This should be done using differences
*/
draw() {
const start = performance.now();
const pixels = [...this.pixels];
this.pixels = [];
if (pixels.length) {
console.log("[CanvasRenderer#draw] drawing " + pixels.length + " pixels");
}
for (const pixel of pixels) {
this.ctx.fillStyle = pixel.hex === "null" ? "#fff" : "#" + pixel.hex;
this.ctx.fillRect(pixel.x, pixel.y, 1, 1);
}
const diff = performance.now() - start;
this.drawTimes = this.drawTimes.slice(0, 300);
const drawavg =
this.drawTimes.length > 0
? this.drawTimes.reduce((a, b) => a + b) / this.drawTimes.length
: 0;
if (diff > 0) this.drawTimes.push(diff);
if (diff > drawavg) {
console.warn(
`canvas#draw took ${diff} ms (> avg: ${drawavg} ; ${this.drawTimes.length} samples)`
);
}
}
/**
* fully draw canvas
*/
private drawFull() {
// --- main canvas ---
this.ctx.imageSmoothingEnabled = false;
this.ctx.globalAlpha = 1;
// clear canvas
this.ctx.fillStyle = "#fff";
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
for (const pixel of this.allPixels) {
this.ctx.fillStyle = pixel.hex === "null" ? "#fff" : "#" + pixel.hex;
this.ctx.fillRect(pixel.x, pixel.y, 1, 1);
}
}
private drawBlank() {
if (this.blank && this.blank_ctx) {
// --- blank canvas ---
let canvas = this.blank;
let ctx = this.blank_ctx;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const pixel of this.allPixels) {
if (pixel.hex !== "null") continue;
ctx.fillStyle = "rgba(0,140,0,0.5)";
ctx.fillRect(pixel.x, pixel.y, 1, 1);
}
}
}
setSize(width: number, height: number) {
console.log("[CanvasRenderer] Received size set", { width, height });
this.dimentions = { width, height };
if (this.canvas) {
this.canvas.width = width;
this.canvas.height = height;
}
if (this.blank) {
this.blank.width = width;
this.blank.height = height;
}
this.tryDrawFull();
this.emit("ready");
}
}

View File

@ -54,6 +54,11 @@ const KEYBINDS = enforceObjectType({
key: "KeyM",
},
],
DESELECT_COLOR: [
{
key: "Escape",
},
],
});
class KeybindManager_ extends EventEmitter<{

View File

@ -21,6 +21,11 @@ export interface INetworkEvents {
pixelLastPlaced: (time: number) => void;
online: (count: number) => void;
pixel: (pixel: Pixel) => void;
square: (
start: [x: number, y: number],
end: [x: number, y: number],
color: number
) => void;
undo: (
data: { available: false } | { available: true; expireAt: number }
) => void;
@ -105,6 +110,10 @@ class Network extends EventEmitter<INetworkEvents> {
this.emit("pixel", pixel);
});
this.socket.on("square", (...square) => {
this.emit("square", ...square);
});
this.socket.on("undo", (undo) => {
this.emit("undo", undo);
});

View File

@ -0,0 +1,160 @@
import { toast } from "react-toastify";
import RenderWorker from "../worker/render.worker?worker";
import {
CanvasPixel,
CanvasRenderer,
CanvasRole,
RendererEvents,
} from "./canvasRenderer";
import { ExtractMethods } from "./utils";
import EventEmitter from "eventemitter3";
const hasWorkerSupport =
typeof Worker !== "undefined" && !localStorage.getItem("no_workers");
export abstract class Renderer
extends EventEmitter<RendererEvents>
implements ICanvasRenderer
{
hasWorker: boolean;
constructor(hasWorker: boolean) {
super();
this.hasWorker = hasWorker;
}
/**
* Get the renderer that is available to the client
* @returns
*/
static create(): Renderer {
if (hasWorkerSupport) {
return new WorkerRenderer();
} else {
return new LocalRenderer();
}
}
abstract usePixels(pixels: CanvasPixel[], replace?: boolean): void;
abstract usePixel(pixel: CanvasPixel): void;
abstract draw(): void;
abstract setSize(width: number, height: number): void;
abstract useCanvas(canvas: HTMLCanvasElement, role: CanvasRole): void;
abstract removeCanvas(role: CanvasRole): void;
abstract startRender(): void;
abstract stopRender(): void;
}
type ICanvasRenderer = Omit<
ExtractMethods<CanvasRenderer>,
"useCanvas" | keyof ExtractMethods<EventEmitter>
> & {
useCanvas: (canvas: HTMLCanvasElement, role: CanvasRole) => void;
};
class WorkerRenderer extends Renderer implements ICanvasRenderer {
private worker: Worker;
constructor() {
super(true);
this.worker = new RenderWorker();
this.worker.addEventListener("message", (req) => {
if (req.data.type === "ready") {
this.emit("ready");
}
});
}
destroy(): void {
console.warn("[WorkerRender#destroy] Destroying worker");
this.worker.terminate();
}
useCanvas(canvas: HTMLCanvasElement, role: CanvasRole): void {
const offscreen = canvas.transferControlToOffscreen();
this.worker.postMessage({ type: "canvas", role, canvas: offscreen }, [
offscreen,
]);
}
removeCanvas(role: CanvasRole): void {
this.worker.postMessage({ type: "remove-canvas", role });
}
usePixels(pixels: CanvasPixel[], replace: boolean): void {
this.worker.postMessage({
type: "pixels",
replace,
pixels: pixels
.map((pixel) => pixel.x + "," + pixel.y + "," + pixel.hex)
.join(";"),
});
}
usePixel({ x, y, hex }: CanvasPixel): void {
this.worker.postMessage({
type: "pixel",
pixel: x + "," + y + "," + (hex || "null"),
});
}
startDrawLoop(): void {
throw new Error("Method not implemented.");
}
startRender(): void {
this.worker.postMessage({ type: "startRender" });
}
stopRender(): void {
this.worker.postMessage({ type: "stopRender" });
}
draw(): void {
this.worker.postMessage({ type: "draw" });
}
setSize(width: number, height: number): void {
this.worker.postMessage({ type: "size", width, height });
}
}
class LocalRenderer extends Renderer implements ICanvasRenderer {
reference: CanvasRenderer;
constructor() {
super(false);
toast.error(
"Your browser doesn't support WebWorkers, this will cause performance issues"
);
this.reference = new CanvasRenderer();
this.reference.on("ready", () => this.emit("ready"));
}
useCanvas(canvas: HTMLCanvasElement, role: CanvasRole): void {
this.reference.useCanvas(canvas, role);
}
removeCanvas(role: CanvasRole) {
this.reference.removeCanvas(role);
}
usePixels(pixels: CanvasPixel[], replace: boolean): void {
this.reference.usePixels(pixels, replace);
}
usePixel(pixel: CanvasPixel): void {
this.reference.usePixel(pixel);
}
startRender(): void {
this.reference.startRender();
}
stopRender(): void {
this.reference.stopRender();
}
draw(): void {
this.reference.draw();
}
setSize(width: number, height: number): void {
this.reference.setSize(width, height);
}
}

View File

@ -1,4 +1,21 @@
import { toast } from "react-toastify";
import { Renderer } from "./renderer";
import { Debug } from "@sc07-canvas/lib/src/debug";
let _renderer: Renderer;
/**
* Get the renderer instance or create one
* @returns
*/
export const getRenderer = (): Renderer => {
if (_renderer) return _renderer;
_renderer = Renderer.create();
return _renderer;
};
Debug._getRenderer = getRenderer;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const api = async <T = unknown, Error = string>(
@ -35,6 +52,12 @@ export const api = async <T = unknown, Error = string>(
};
};
export type PickMatching<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K];
};
export type ExtractMethods<T> = PickMatching<T, Function>;
export type EnforceObjectType<T> = <V extends { [k: string]: T }>(
v: V
) => { [k in keyof V]: T };

View File

@ -0,0 +1,81 @@
/**
* Worker to handle canvas draws to free the main thread
*/
import { CanvasPixel, CanvasRenderer, CanvasRole } from "../lib/canvasRenderer";
console.log("[Render Worker] Initialize");
const renderer = new CanvasRenderer();
renderer.on("ready", () => {
postMessage({ type: "ready" });
});
addEventListener("message", (req) => {
switch (req.data.type) {
case "canvas": {
const canvas: OffscreenCanvas = req.data.canvas;
const role: CanvasRole = req.data.role;
renderer.useCanvas(canvas, role);
renderer.renderLoop();
break;
}
case "remove-canvas": {
const role: CanvasRole = req.data.role;
renderer.removeCanvas(role);
break;
}
case "size": {
const width: number = req.data.width;
const height: number = req.data.height;
renderer.setSize(width, height);
break;
}
case "pixels": {
const pixelsIn: string = req.data.pixels;
const replace: boolean = req.data.replace;
const pixels = deserializePixels(pixelsIn);
renderer.usePixels(pixels, replace);
break;
}
case "pixel": {
const pixel = deserializePixel(req.data.pixel);
renderer.usePixel(pixel);
break;
}
case "startRender": {
renderer.startRender();
break;
}
case "stopRender": {
renderer.stopRender();
break;
}
default:
console.warn(
"[Render Worker] Received unknown message type",
req.data.type
);
}
});
const deserializePixel = (str: string): CanvasPixel => {
let [x, y, hex] = str.split(",");
return {
x: parseInt(x),
y: parseInt(y),
hex,
};
};
const deserializePixels = (str: string): CanvasPixel[] => {
let pixels: CanvasPixel[] = [];
const pixelsIn = str.split(";");
for (const pixel of pixelsIn) {
pixels.push(deserializePixel(pixel));
}
return pixels;
};

View File

@ -100,6 +100,7 @@ class FlagManager extends EventEmitter<FlagEvents> {
*/
class Debugcl extends EventEmitter<DebugEvents> {
readonly flags = new FlagManager();
_getRenderer: any;
constructor() {
super();
@ -180,6 +181,15 @@ class Debugcl extends EventEmitter<DebugEvents> {
}
}
}
getRenderer() {
return this._getRenderer();
}
}
export const Debug = new Debugcl();
const Debug = new Debugcl();
// @ts-ignore
window.Debug = Debug;
export { Debug };

View File

@ -13,6 +13,11 @@ export interface ServerToClientEvents {
undo: (
data: { available: false } | { available: true; expireAt: number }
) => void;
square: (
start: [x: number, y: number],
end: [x: number, y: number],
color: number
) => void;
/* --- subscribe events --- */
@ -39,7 +44,9 @@ export interface ClientToServerEvents {
>
) => void
) => void;
undo: (ack: (_: PacketAck<{}, "no_user" | "unavailable">) => void) => void;
undo: (
ack: (_: PacketAck<{}, "no_user" | "unavailable" | "pixel_covered">) => void
) => void;
subscribe: (topic: Subscription) => void;
unsubscribe: (topic: Subscription) => void;

View File

@ -1,4 +1,3 @@
import { Debug } from "../../debug";
import { PanZoom } from "../PanZoom";
export function handleCalculateZoomPositions(

View File

@ -9,8 +9,8 @@ Table Setting {
Table User {
sub String [pk]
picture_url String
display_name String
picture_url String
profile_url String
lastPixelTime DateTime [default: `now()`, not null]
pixelStack Int [not null, default: 0]
@ -42,7 +42,10 @@ Table Pixel {
x Int [not null]
y Int [not null]
color String [not null]
isTop Boolean [not null, default: false]
isModAction Boolean [not null, default: false]
createdAt DateTime [default: `now()`, not null]
deletedAt DateTime
user User [not null]
pallete PaletteColor [not null]
}

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Pixel" ADD COLUMN "isModAction" BOOLEAN NOT NULL DEFAULT false;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Pixel" ADD COLUMN "isTop" BOOLEAN NOT NULL DEFAULT false;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Pixel" ADD COLUMN "deletedAt" TIMESTAMP(3);

View File

@ -58,8 +58,11 @@ model Pixel {
x Int
y Int
color String
isTop Boolean @default(false)
isModAction Boolean @default(false)
createdAt DateTime @default(now())
deletedAt DateTime?
user User @relation(fields: [userId], references: [sub])
pallete PaletteColor @relation(fields: [color], references: [hex])

View File

@ -1,10 +1,13 @@
import { Router } from "express";
import { User } from "../models/User";
import Canvas from "../lib/Canvas";
import { Logger } from "../lib/Logger";
import { getLogger } from "../lib/Logger";
import { RateLimiter } from "../lib/RateLimiter";
import { prisma } from "../lib/prisma";
import { SocketServer } from "../lib/SocketServer";
const app = Router();
const Logger = getLogger("HTTP/ADMIN");
app.use(RateLimiter.ADMIN);
@ -85,4 +88,117 @@ app.put("/canvas/heatmap", async (req, res) => {
}
});
app.post("/canvas/forceUpdateTop", async (req, res) => {
Logger.info("Starting force updating isTop");
await Canvas.forceUpdatePixelIsTop();
Logger.info("Finished force updating isTop");
res.send({ success: true });
});
app.get("/canvas/:x/:y", async (req, res) => {
const x = parseInt(req.params.x);
const y = parseInt(req.params.y);
res.json(await Canvas.getPixel(x, y));
});
app.post("/canvas/stress", async (req, res) => {
if (
typeof req.body?.width !== "number" ||
typeof req.body?.height !== "number"
) {
res.status(400).json({ success: false, error: "width/height is invalid" });
return;
}
const width: number = req.body.width;
const height: number = req.body.height;
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
let color = Math.floor(Math.random() * 30) + 1;
SocketServer.instance.io.emit("pixel", {
x,
y,
color,
});
}
}
res.send("ok");
});
/**
* Fill an area
*/
app.put("/canvas/fill", async (req, res) => {
if (
typeof req.body?.start?.x !== "number" ||
typeof req.body?.start?.y !== "number"
) {
res
.status(400)
.json({ success: false, error: "start position is invalid" });
return;
}
if (
typeof req.body?.end?.x !== "number" ||
typeof req.body?.end?.y !== "number"
) {
res.status(400).json({ success: false, error: "end position is invalid" });
return;
}
if (typeof req.body.color !== "number") {
res.status(400).json({ success: false, error: "color is invalid" });
return;
}
const user_sub =
req.session.user!.user.username +
"@" +
req.session.user!.service.instance.hostname;
const start_position: [x: number, y: number] = [
req.body.start.x,
req.body.start.y,
];
const end_position: [x: number, y: number] = [req.body.end.x, req.body.end.y];
const palette = await prisma.paletteColor.findFirst({
where: { id: req.body.color },
});
if (!palette) {
res.status(400).json({ success: false, error: "invalid color" });
return;
}
const width = end_position[0] - start_position[0];
const height = end_position[1] - start_position[1];
const area = width * height;
// if (area > 50 * 50) {
// res.status(400).json({ success: false, error: "Area too big" });
// return;
// }
await Canvas.fillArea(
{ sub: user_sub },
start_position,
end_position,
palette.hex
);
SocketServer.instance.io.emit(
"square",
start_position,
end_position,
palette.id
);
res.json({ success: true });
});
export default app;

View File

@ -2,10 +2,12 @@ import { Router } from "express";
import { prisma } from "../lib/prisma";
import { OpenID } from "../lib/oidc";
import { TokenSet, errors as OIDC_Errors } from "openid-client";
import { Logger } from "../lib/Logger";
import { getLogger } from "../lib/Logger";
import Canvas from "../lib/Canvas";
import { RateLimiter } from "../lib/RateLimiter";
const Logger = getLogger("HTTP/CLIENT");
const ClientParams = {
TYPE: "auth_type",
ERROR: "auth_error",

View File

@ -1,12 +1,13 @@
// load declare module
import "./types";
import { Redis } from "./lib/redis";
import { Logger } from "./lib/Logger";
import { getLogger } from "./lib/Logger";
import { ExpressServer } from "./lib/Express";
import { SocketServer } from "./lib/SocketServer";
import { OpenID } from "./lib/oidc";
import { loadSettings } from "./lib/Settings";
import { Jobs } from "./lib/Jobs";
const Logger = getLogger("MAIN");
// Validate environment variables
@ -86,8 +87,8 @@ Promise.all([
loadSettings(),
]).then(() => {
Logger.info("Startup tasks have completed, starting server");
Logger.warn("Make sure the jobs process is running");
new Jobs();
const express = new ExpressServer();
new SocketServer(express.httpServer);
});

View File

@ -1,8 +1,17 @@
import Canvas from "./Canvas";
import { Logger } from "./Logger";
import Canvas from "../lib/Canvas";
import { getLogger } from "../lib/Logger";
const Logger = getLogger("JOB_WORKER");
/**
* Job scheduler
*
* This should run in a different process
*/
export class Jobs {
constructor() {
Logger.info("Starting job worker...");
// every 5 minutes
setInterval(this.generateHeatmap, 1000 * 60 * 5);

View File

@ -2,7 +2,10 @@ import { CanvasConfig } from "@sc07-canvas/lib/src/net";
import { prisma } from "./prisma";
import { Redis } from "./redis";
import { SocketServer } from "./SocketServer";
import { Logger } from "./Logger";
import { getLogger } from "./Logger";
import { Pixel } from "@prisma/client";
const Logger = getLogger("CANVAS");
class Canvas {
/**
@ -37,8 +40,14 @@ class Canvas {
* @param width
* @param height
*/
async setSize(width: number, height: number) {
Logger.info("Canvas#setSize has started", {
async setSize(width: number, height: number, useStatic = false) {
if (useStatic) {
this.canvasSize = [width, height];
return;
}
const now = Date.now();
Logger.info("[Canvas#setSize] has started", {
old: this.canvasSize,
new: [width, height],
});
@ -56,46 +65,123 @@ class Canvas {
},
});
// we're about to use the redis keys, make sure they are all updated
await this.pixelsToRedis();
// the redis key is 1D, since the dimentions changed we need to update it
await this.canvasToRedis();
// this gets called on startup, before the SocketServer is initialized
// so only call if it's available
if (SocketServer.instance) {
// announce the new config, which contains the canvas size
SocketServer.instance.broadcastConfig();
// announce new pixel array that was generated previously
await this.getPixelsArray().then((pixels) => {
SocketServer.instance.io.emit("canvas", pixels);
SocketServer.instance?.io.emit("canvas", pixels);
});
Logger.info("Canvas#setSize has finished");
} else {
Logger.warn(
"[Canvas#setSize] No SocketServer instance, cannot broadcast config change"
);
}
/**
* Latest database pixels -> Redis
*/
async pixelsToRedis() {
const redis = await Redis.getClient();
Logger.info(
"[Canvas#setSize] has finished in " +
((Date.now() - now) / 1000).toFixed(1) +
" seconds"
);
}
const key = Redis.keyRef("pixelColor");
async forceUpdatePixelIsTop() {
const now = Date.now();
Logger.info("[Canvas#forceUpdatePixelIsTop] is starting...");
for (let x = 0; x < this.canvasSize[0]; x++) {
for (let y = 0; y < this.canvasSize[1]; y++) {
const pixel = await this.getPixel(x, y);
const pixel = (
await prisma.pixel.findMany({
where: { x, y },
orderBy: {
createdAt: "desc",
},
take: 1,
})
)?.[0];
await redis.set(key(x, y), pixel?.color || "transparent");
if (pixel) {
await prisma.pixel.update({
where: {
id: pixel.id,
},
data: {
isTop: true,
},
});
}
}
}
Logger.info(
"[Canvas#forceUpdatePixelIsTop] has finished in " +
((Date.now() - now) / 1000).toFixed(1) +
" seconds"
);
}
/**
* Undo a pixel
* @throws Error "Pixel is not on top"
* @param pixel
*/
async undoPixel(pixel: Pixel) {
if (!pixel.isTop) throw new Error("Pixel is not on top");
await prisma.pixel.update({
where: { id: pixel.id },
data: {
deletedAt: new Date(),
isTop: false,
},
});
const coveringPixel = (
await prisma.pixel.findMany({
where: { x: pixel.x, y: pixel.y, createdAt: { lt: pixel.createdAt } },
orderBy: { createdAt: "desc" },
take: 1,
})
)?.[0];
if (coveringPixel) {
await prisma.pixel.update({
where: { id: coveringPixel.id },
data: {
isTop: true,
},
});
}
}
/**
* Redis pixels -> single Redis comma separated list of hex
* Database pixels -> single Redis comma separated list of hex
* @returns 1D array of pixel values
*/
async canvasToRedis() {
const redis = await Redis.getClient();
const dbpixels = await prisma.pixel.findMany({
where: {
x: {
gte: 0,
lt: this.getCanvasConfig().size[0],
},
y: {
gte: 0,
lt: this.getCanvasConfig().size[1],
},
isTop: true,
},
});
const pixels: string[] = [];
// (y -> x) because of how the conversion needs to be done later
@ -104,7 +190,8 @@ class Canvas {
for (let y = 0; y < this.canvasSize[1]; y++) {
for (let x = 0; x < this.canvasSize[0]; x++) {
pixels.push(
(await redis.get(Redis.key("pixelColor", x, y))) || "transparent"
dbpixels.find((px) => px.x === x && px.y === y)?.color ||
"transparent"
);
}
}
@ -124,8 +211,25 @@ class Canvas {
(await redis.get(Redis.key("canvas"))) || ""
).split(",");
pixels[this.canvasSize[0] * y + x] =
(await redis.get(Redis.key("pixelColor", x, y))) || "transparent";
const dbpixel = await this.getPixel(x, y);
pixels[this.canvasSize[0] * y + x] = dbpixel?.color || "transparent";
await redis.set(Redis.key("canvas"), pixels.join(","), { EX: 60 * 5 });
}
async updateCanvasRedisWithBatch(
pixelBatch: { x: number; y: number; hex: string }[]
) {
const redis = await Redis.getClient();
const pixels: string[] = (
(await redis.get(Redis.key("canvas"))) || ""
).split(",");
for (const pixel of pixelBatch) {
pixels[this.canvasSize[0] * pixel.y + pixel.x] = pixel.hex;
}
await redis.set(Redis.key("canvas"), pixels.join(","), { EX: 60 * 5 });
}
@ -148,33 +252,89 @@ class Canvas {
* @returns
*/
async isPixelEmpty(x: number, y: number) {
const redis = await Redis.getClient();
const pixelColor = await redis.get(Redis.key("pixelColor", x, y));
if (pixelColor === null) {
return true;
}
return pixelColor === "transparent";
const pixel = await this.getPixel(x, y);
return pixel === null;
}
async getPixel(x: number, y: number) {
return (
await prisma.pixel.findMany({
return await prisma.pixel.findFirst({
where: {
x,
y,
isTop: true,
},
orderBy: {
createdAt: "desc",
},
take: 1,
})
)?.[0];
});
}
async setPixel(user: { sub: string }, x: number, y: number, hex: string) {
const redis = await Redis.getClient();
async fillArea(
user: { sub: string },
start: [x: number, y: number],
end: [x: number, y: number],
hex: string
) {
await prisma.pixel.updateMany({
where: {
x: {
gte: start[0],
lt: end[0],
},
y: {
gte: start[1],
lt: end[1],
},
isTop: true,
},
data: {
isTop: false,
},
});
let pixels: {
x: number;
y: number;
}[] = [];
for (let x = start[0]; x <= end[0]; x++) {
for (let y = start[1]; y <= end[1]; y++) {
pixels.push({
x,
y,
});
}
}
await prisma.pixel.createMany({
data: pixels.map((px) => ({
userId: user.sub,
color: hex,
isTop: true,
isModAction: true,
...px,
})),
});
await this.updateCanvasRedisWithBatch(
pixels.map((px) => ({
...px,
hex,
}))
);
}
async setPixel(
user: { sub: string },
x: number,
y: number,
hex: string,
isModAction: boolean
) {
// only one pixel can be on top at (x,y)
await prisma.pixel.updateMany({
where: { x, y, isTop: true },
data: {
isTop: false,
},
});
await prisma.pixel.create({
data: {
@ -182,6 +342,8 @@ class Canvas {
color: hex,
x,
y,
isTop: true,
isModAction,
},
});
@ -190,8 +352,6 @@ class Canvas {
data: { lastPixelTime: new Date() },
});
await redis.set(Redis.key("pixelColor", x, y), hex);
// maybe only update specific element?
// i don't think it needs to be awaited
await this.updateCanvasRedisAtPos(x, y);
@ -203,21 +363,15 @@ class Canvas {
* @param y
*/
async refreshPixel(x: number, y: number) {
const redis = await Redis.getClient();
const key = Redis.key("pixelColor", x, y);
// find if any pixels exist at this spot, and pick the most recent one
const pixel = await this.getPixel(x, y);
let paletteColorID = -1;
// if pixel exists in redis
if (pixel) {
redis.set(key, pixel.color);
paletteColorID = (await prisma.paletteColor.findFirst({
where: { hex: pixel.color },
}))!.id;
} else {
redis.del(key);
}
await this.updateCanvasRedisAtPos(x, y);
@ -238,7 +392,9 @@ class Canvas {
* @returns 2 character strings with 0-100 in radix 36 (depends on canvas size)
*/
async generateHeatmap() {
const redis = await Redis.getClient();
const redis_set = await Redis.getClient("MAIN");
const redis_sub = await Redis.getClient("SUB");
const now = Date.now();
const minimumDate = new Date();
minimumDate.setHours(minimumDate.getHours() - 3); // 3 hours ago
@ -247,23 +403,15 @@ class Canvas {
const heatmap: string[] = [];
const topPixels = await prisma.pixel.findMany({
where: { isTop: true, createdAt: { gte: minimumDate } },
});
for (let y = 0; y < this.canvasSize[1]; y++) {
const arr: number[] = [];
for (let x = 0; x < this.canvasSize[0]; x++) {
const pixel = (
await prisma.pixel.findMany({
where: {
x,
y,
createdAt: { gt: minimumDate },
},
orderBy: {
createdAt: "desc",
},
take: 1,
})
)?.[0];
const pixel = topPixels.find((px) => px.x === x && px.y === y);
if (pixel) {
arr.push(
@ -284,10 +432,11 @@ class Canvas {
const heatmapStr = heatmap.join("");
// cache for 5 minutes
await redis.setEx(Redis.key("heatmap"), 60 * 5, heatmapStr);
await redis_set.setEx(Redis.key("heatmap"), 60 * 5, heatmapStr);
// notify anyone interested about the new heatmap
SocketServer.instance.io.to("sub:heatmap").emit("heatmap", heatmapStr);
await redis_sub.publish(Redis.key("channel_heatmap"), heatmapStr);
// SocketServer.instance.io.to("sub:heatmap").emit("heatmap", heatmapStr);
return heatmapStr;
}

View File

@ -7,10 +7,12 @@ import cors from "cors";
import { Redis } from "./redis";
import APIRoutes_client from "../api/client";
import APIRoutes_admin from "../api/admin";
import { Logger } from "./Logger";
import { getLogger } from "./Logger";
import bodyParser from "body-parser";
import { handleMetricsEndpoint } from "./Prometheus";
const Logger = getLogger("HTTP");
export const session = expressSession({
secret: process.env.SESSION_SECRET,
resave: false,

View File

@ -1,7 +1,42 @@
import winston, { format } from "winston";
import { createEnum } from "./utils";
export const Logger = winston.createLogger({
const formatter = format.printf((options) => {
let maxModuleWidth = 0;
for (const module of Object.values(LoggerType)) {
maxModuleWidth = Math.max(maxModuleWidth, `[${module}]`.length);
}
let modulePadding = " ".repeat(
Math.max(0, maxModuleWidth - `[${options.moduleName}]`.length)
);
let parts: string[] = [
options.timestamp + ` [${options.moduleName || "---"}]` + modulePadding,
options.level + ":",
options.message,
];
return parts.join("\t");
});
const Winston = winston.createLogger({
level: process.env.LOG_LEVEL || "info",
format: format.combine(format.splat(), format.cli()),
format: format.combine(format.timestamp(), formatter),
transports: [new winston.transports.Console()],
});
export const LoggerType = createEnum([
"MAIN",
"SETTINGS",
"CANVAS",
"HTTP",
"HTTP/ADMIN",
"HTTP/CLIENT",
"REDIS",
"SOCKET",
"JOB_WORKER",
]);
export const getLogger = (module?: keyof typeof LoggerType) =>
Winston.child({ moduleName: module });

View File

@ -1,8 +1,10 @@
import Canvas from "./Canvas";
import { Logger } from "./Logger";
import { getLogger } from "./Logger";
import { prisma } from "./prisma";
export const loadSettings = async () => {
const Logger = getLogger("SETTINGS");
export const loadSettings = async (frozen = false) => {
Logger.info("Loading settings...");
const sideEffects: Promise<unknown>[] = [];
@ -14,8 +16,9 @@ export const loadSettings = async () => {
if (canvasSize) {
const data = JSON.parse(canvasSize.value);
Logger.info("Canvas size loaded as " + JSON.stringify(data));
sideEffects.push(
Canvas.setSize(data.width, data.height).then(() => {
Canvas.setSize(data.width, data.height, frozen).then(() => {
Logger.info("Canvas size successfully updated");
})
);

View File

@ -12,10 +12,12 @@ import { session } from "./Express";
import Canvas from "./Canvas";
import { PaletteColor } from "@prisma/client";
import { prisma } from "./prisma";
import { Logger } from "./Logger";
import { getLogger } from "./Logger";
import { Redis } from "./redis";
import { User } from "../models/User";
const Logger = getLogger("SOCKET");
// maybe move to a constants file?
const commitHash = child
.execSync("git rev-parse --short HEAD")
@ -248,7 +250,13 @@ export class SocketServer {
}
await user.modifyStack(-1);
await Canvas.setPixel(user, pixel.x, pixel.y, paletteColor.hex);
await Canvas.setPixel(
user,
pixel.x,
pixel.y,
paletteColor.hex,
bypassCooldown
);
// give undo capabilities
await user.setUndo(
new Date(Date.now() + Canvas.getCanvasConfig().undo.grace_period)
@ -300,12 +308,17 @@ export class SocketServer {
return;
}
// delete most recent pixel
try {
await Canvas.undoPixel(pixel);
} catch (e) {
ack({ success: false, error: "pixel_covered" });
return;
}
// mark the undo as used
await user.setUndo();
// delete most recent pixel
await prisma.pixel.delete({ where: { id: pixel.id } });
// trigger re-cache on redis
await Canvas.refreshPixel(pixel.x, pixel.y);
@ -330,7 +343,7 @@ export class SocketServer {
*
* this does work with multiple socket.io instances, so this needs to only be executed by one shard
*/
setupMasterShard() {
async setupMasterShard() {
// online announcement event
setInterval(async () => {
// possible issue: this includes every connected socket, not user count
@ -339,5 +352,10 @@ export class SocketServer {
socket.emit("online", { count: sockets.length });
}
}, 5000);
const redis = await Redis.getClient("SUB");
redis.subscribe(Redis.key("channel_heatmap"), (message, channel) => {
this.io.to("sub:heatmap").emit("heatmap", message);
});
}
}

View File

@ -1,34 +1,39 @@
import { RedisClientType } from "@redis/client";
import { createClient } from "redis";
import { Logger } from "./Logger";
import { getLogger } from "./Logger";
const Logger = getLogger("REDIS");
/**
* Typedef for RedisKeys
*/
interface IRedisKeys {
// canvas
pixelColor(x: number, y: number): string;
canvas(): string;
heatmap(): string;
// users
socketToSub(socketId: string): string;
// pub/sub channels
channel_heatmap(): string;
}
/**
* Defined as a variable due to boottime augmentation
*/
const RedisKeys: IRedisKeys = {
pixelColor: (x: number, y: number) => `CANVAS:PIXELS[${x},${y}]:COLOR`,
canvas: () => `CANVAS:PIXELS`,
heatmap: () => `CANVAS:HEATMAP`,
socketToSub: (socketId: string) => `CANVAS:SOCKET:${socketId}`,
channel_heatmap: () => `CANVAS:HEATMAP`,
};
class _Redis {
isConnecting = false;
isConnected = false;
client: RedisClientType;
sub_client: RedisClientType; // the client used for pubsub
waitingForConnect: ((...args: any) => any)[] = [];
@ -43,6 +48,9 @@ class _Redis {
this.client = createClient({
url: process.env.REDIS_HOST,
});
this.sub_client = createClient({
url: process.env.REDIS_HOST,
});
this.keys = keys;
}
@ -53,6 +61,7 @@ class _Redis {
this.isConnecting = true;
await this.client.connect();
await this.sub_client.connect();
Logger.info(
`Connected to Redis, there's ${this.waitingForConnect.length} function(s) waiting for Redis`
);
@ -75,7 +84,7 @@ class _Redis {
this.isConnected = false;
}
async getClient() {
async getClient(intent: "MAIN" | "SUB" = "MAIN") {
if (this.isConnecting) {
await (() =>
new Promise((res) => {
@ -89,6 +98,10 @@ class _Redis {
this.isConnected = true;
}
if (intent === "SUB") {
return this.sub_client;
}
return this.client;
}

View File

@ -0,0 +1,16 @@
/**
* Create enum from array of strings
*
* @param values
* @returns
*/
export const createEnum = <T extends string>(values: T[]): { [k in T]: k } => {
// @ts-ignore
let ret: { [k in T]: k } = {};
for (const val of values) {
ret[val] = val;
}
return ret;
};

View File

@ -1,5 +1,5 @@
import { Socket } from "socket.io";
import { Logger } from "../lib/Logger";
import { getLogger } from "../lib/Logger";
import { prisma } from "../lib/prisma";
import {
AuthSession,
@ -7,6 +7,8 @@ import {
ServerToClientEvents,
} from "@sc07-canvas/lib/src/net";
const Logger = getLogger();
interface IUserData {
sub: string;
lastPixelTime: Date;

View File

@ -1,14 +0,0 @@
import Canvas from "../lib/Canvas";
import { Redis } from "../lib/redis";
const log = (...data: any) => {
// eslint-disable-next-line no-console
console.log(...data);
};
(async () => {
log("Caching pixels from database to Redis...");
await Canvas.pixelsToRedis();
await Redis.disconnect();
log("Cached");
})();

View File

@ -0,0 +1,6 @@
import { Jobs } from "../jobs/Jobs";
import { loadSettings } from "../lib/Settings";
loadSettings(true).then(() => {
new Jobs();
});