diff --git a/packages/client/src/contexts/AppContext.tsx b/packages/client/src/contexts/AppContext.tsx
index ebc03c0..dca939a 100644
--- a/packages/client/src/contexts/AppContext.tsx
+++ b/packages/client/src/contexts/AppContext.tsx
@@ -17,8 +17,8 @@ interface IAppContext {
canvasPosition?: ICanvasPosition;
setCanvasPosition: (v: ICanvasPosition) => void;
- cursorPosition?: IPosition;
- setCursorPosition: (v?: IPosition) => void;
+ cursor: ICursor;
+ setCursor: React.Dispatch
>;
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(undefined as any);
const [auth, setAuth] = useState();
const [canvasPosition, setCanvasPosition] = useState();
- const [cursorPosition, setCursorPosition] = useState();
+ const [cursor, setCursor] = useState({});
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,
diff --git a/packages/client/src/lib/canvas.ts b/packages/client/src/lib/canvas.ts
index c34a6a5..97eabb3 100644
--- a/packages/client/src/lib/canvas.ts
+++ b/packages/client/src/lib/canvas.ts
@@ -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 {
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 {
lastPlace: number | undefined;
private bypassCooldown = false;
+ private _delayedLoad: ReturnType;
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 {
([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 {
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 {
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 {
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());
- }
}
diff --git a/packages/client/src/lib/canvasRenderer.ts b/packages/client/src/lib/canvasRenderer.ts
new file mode 100644
index 0000000..a0e4fc8
--- /dev/null
+++ b/packages/client/src/lib/canvasRenderer.ts
@@ -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 {
+ 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");
+ }
+}
diff --git a/packages/client/src/lib/keybinds.ts b/packages/client/src/lib/keybinds.ts
index 538ea80..0582422 100644
--- a/packages/client/src/lib/keybinds.ts
+++ b/packages/client/src/lib/keybinds.ts
@@ -54,6 +54,11 @@ const KEYBINDS = enforceObjectType({
key: "KeyM",
},
],
+ DESELECT_COLOR: [
+ {
+ key: "Escape",
+ },
+ ],
});
class KeybindManager_ extends EventEmitter<{
diff --git a/packages/client/src/lib/network.ts b/packages/client/src/lib/network.ts
index 082f877..6bd6974 100644
--- a/packages/client/src/lib/network.ts
+++ b/packages/client/src/lib/network.ts
@@ -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 {
this.emit("pixel", pixel);
});
+ this.socket.on("square", (...square) => {
+ this.emit("square", ...square);
+ });
+
this.socket.on("undo", (undo) => {
this.emit("undo", undo);
});
diff --git a/packages/client/src/lib/renderer.ts b/packages/client/src/lib/renderer.ts
new file mode 100644
index 0000000..51ba14b
--- /dev/null
+++ b/packages/client/src/lib/renderer.ts
@@ -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
+ 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,
+ "useCanvas" | keyof ExtractMethods
+> & {
+ 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);
+ }
+}
diff --git a/packages/client/src/lib/utils.ts b/packages/client/src/lib/utils.ts
index d3e72da..e2891e4 100644
--- a/packages/client/src/lib/utils.ts
+++ b/packages/client/src/lib/utils.ts
@@ -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 (
@@ -35,6 +52,12 @@ export const api = async (
};
};
+export type PickMatching = {
+ [K in keyof T as T[K] extends V ? K : never]: T[K];
+};
+
+export type ExtractMethods = PickMatching;
+
export type EnforceObjectType = (
v: V
) => { [k in keyof V]: T };
diff --git a/packages/client/src/worker/render.worker.ts b/packages/client/src/worker/render.worker.ts
new file mode 100644
index 0000000..24cf6ad
--- /dev/null
+++ b/packages/client/src/worker/render.worker.ts
@@ -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;
+};
diff --git a/packages/lib/src/debug.ts b/packages/lib/src/debug.ts
index e615768..7702449 100644
--- a/packages/lib/src/debug.ts
+++ b/packages/lib/src/debug.ts
@@ -100,6 +100,7 @@ class FlagManager extends EventEmitter {
*/
class Debugcl extends EventEmitter {
readonly flags = new FlagManager();
+ _getRenderer: any;
constructor() {
super();
@@ -180,6 +181,15 @@ class Debugcl extends EventEmitter {
}
}
}
+
+ getRenderer() {
+ return this._getRenderer();
+ }
}
-export const Debug = new Debugcl();
+const Debug = new Debugcl();
+
+// @ts-ignore
+window.Debug = Debug;
+
+export { Debug };
diff --git a/packages/lib/src/net.ts b/packages/lib/src/net.ts
index 5782785..69b08bb 100644
--- a/packages/lib/src/net.ts
+++ b/packages/lib/src/net.ts
@@ -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;
diff --git a/packages/lib/src/renderer/lib/zoom.utils.ts b/packages/lib/src/renderer/lib/zoom.utils.ts
index 5c45f0c..8ea9919 100644
--- a/packages/lib/src/renderer/lib/zoom.utils.ts
+++ b/packages/lib/src/renderer/lib/zoom.utils.ts
@@ -1,4 +1,3 @@
-import { Debug } from "../../debug";
import { PanZoom } from "../PanZoom";
export function handleCalculateZoomPositions(
diff --git a/packages/server/prisma/dbml/schema.dbml b/packages/server/prisma/dbml/schema.dbml
index db73b60..7861b1a 100644
--- a/packages/server/prisma/dbml/schema.dbml
+++ b/packages/server/prisma/dbml/schema.dbml
@@ -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]
}
diff --git a/packages/server/prisma/migrations/20240619231750_add_pixel_mod_flag/migration.sql b/packages/server/prisma/migrations/20240619231750_add_pixel_mod_flag/migration.sql
new file mode 100644
index 0000000..a3a0e81
--- /dev/null
+++ b/packages/server/prisma/migrations/20240619231750_add_pixel_mod_flag/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Pixel" ADD COLUMN "isModAction" BOOLEAN NOT NULL DEFAULT false;
diff --git a/packages/server/prisma/migrations/20240620192400_is_top_pixel/migration.sql b/packages/server/prisma/migrations/20240620192400_is_top_pixel/migration.sql
new file mode 100644
index 0000000..bd9b6f2
--- /dev/null
+++ b/packages/server/prisma/migrations/20240620192400_is_top_pixel/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Pixel" ADD COLUMN "isTop" BOOLEAN NOT NULL DEFAULT false;
diff --git a/packages/server/prisma/migrations/20240625192346_pixel_deleted_at/migration.sql b/packages/server/prisma/migrations/20240625192346_pixel_deleted_at/migration.sql
new file mode 100644
index 0000000..aa14890
--- /dev/null
+++ b/packages/server/prisma/migrations/20240625192346_pixel_deleted_at/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Pixel" ADD COLUMN "deletedAt" TIMESTAMP(3);
diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma
index df0b78f..9a401ae 100644
--- a/packages/server/prisma/schema.prisma
+++ b/packages/server/prisma/schema.prisma
@@ -53,13 +53,16 @@ model PaletteColor {
}
model Pixel {
- id Int @id @default(autoincrement())
- userId String
- x Int
- y Int
- color String
+ id Int @id @default(autoincrement())
+ userId String
+ x Int
+ y Int
+ color String
+ isTop Boolean @default(false)
+ isModAction Boolean @default(false)
- createdAt DateTime @default(now())
+ createdAt DateTime @default(now())
+ deletedAt DateTime?
user User @relation(fields: [userId], references: [sub])
pallete PaletteColor @relation(fields: [color], references: [hex])
diff --git a/packages/server/src/api/admin.ts b/packages/server/src/api/admin.ts
index 9e3a8d8..e6a0600 100644
--- a/packages/server/src/api/admin.ts
+++ b/packages/server/src/api/admin.ts
@@ -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;
diff --git a/packages/server/src/api/client.ts b/packages/server/src/api/client.ts
index 45bbd8e..134c69e 100644
--- a/packages/server/src/api/client.ts
+++ b/packages/server/src/api/client.ts
@@ -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",
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index 68ef7c1..d04a609 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -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);
});
diff --git a/packages/server/src/lib/Jobs.ts b/packages/server/src/jobs/Jobs.ts
similarity index 64%
rename from packages/server/src/lib/Jobs.ts
rename to packages/server/src/jobs/Jobs.ts
index 77f9d2a..a21e80a 100644
--- a/packages/server/src/lib/Jobs.ts
+++ b/packages/server/src/jobs/Jobs.ts
@@ -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);
diff --git a/packages/server/src/lib/Canvas.ts b/packages/server/src/lib/Canvas.ts
index 9d9cf94..485d694 100644
--- a/packages/server/src/lib/Canvas.ts
+++ b/packages/server/src/lib/Canvas.ts
@@ -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();
- // announce the new config, which contains the canvas size
- SocketServer.instance.broadcastConfig();
+ // 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);
- });
+ // announce new pixel array that was generated previously
+ await this.getPixelsArray().then((pixels) => {
+ SocketServer.instance?.io.emit("canvas", pixels);
+ });
+ } else {
+ Logger.warn(
+ "[Canvas#setSize] No SocketServer instance, cannot broadcast config change"
+ );
+ }
- Logger.info("Canvas#setSize has finished");
+ Logger.info(
+ "[Canvas#setSize] has finished in " +
+ ((Date.now() - now) / 1000).toFixed(1) +
+ " seconds"
+ );
}
- /**
- * Latest database pixels -> Redis
- */
- async pixelsToRedis() {
- const redis = await Redis.getClient();
-
- 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({
- where: {
- x,
- y,
- },
- orderBy: {
- createdAt: "desc",
- },
- take: 1,
- })
- )?.[0];
+ return await prisma.pixel.findFirst({
+ where: {
+ x,
+ y,
+ isTop: true,
+ },
+ });
}
- 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;
}
diff --git a/packages/server/src/lib/Express.ts b/packages/server/src/lib/Express.ts
index 9b0569a..df96120 100644
--- a/packages/server/src/lib/Express.ts
+++ b/packages/server/src/lib/Express.ts
@@ -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,
diff --git a/packages/server/src/lib/Logger.ts b/packages/server/src/lib/Logger.ts
index 2c206a2..8232a69 100644
--- a/packages/server/src/lib/Logger.ts
+++ b/packages/server/src/lib/Logger.ts
@@ -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 });
diff --git a/packages/server/src/lib/Settings.ts b/packages/server/src/lib/Settings.ts
index 3b95600..4d1f595 100644
--- a/packages/server/src/lib/Settings.ts
+++ b/packages/server/src/lib/Settings.ts
@@ -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[] = [];
@@ -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");
})
);
diff --git a/packages/server/src/lib/SocketServer.ts b/packages/server/src/lib/SocketServer.ts
index c74ec15..2af8102 100644
--- a/packages/server/src/lib/SocketServer.ts
+++ b/packages/server/src/lib/SocketServer.ts
@@ -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);
+ });
}
}
diff --git a/packages/server/src/lib/redis.ts b/packages/server/src/lib/redis.ts
index 12223d8..dd56a62 100644
--- a/packages/server/src/lib/redis.ts
+++ b/packages/server/src/lib/redis.ts
@@ -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;
}
diff --git a/packages/server/src/lib/utils.ts b/packages/server/src/lib/utils.ts
new file mode 100644
index 0000000..30c31af
--- /dev/null
+++ b/packages/server/src/lib/utils.ts
@@ -0,0 +1,16 @@
+/**
+ * Create enum from array of strings
+ *
+ * @param values
+ * @returns
+ */
+export const createEnum = (values: T[]): { [k in T]: k } => {
+ // @ts-ignore
+ let ret: { [k in T]: k } = {};
+
+ for (const val of values) {
+ ret[val] = val;
+ }
+
+ return ret;
+};
diff --git a/packages/server/src/models/User.ts b/packages/server/src/models/User.ts
index 643bada..8c5a6cd 100644
--- a/packages/server/src/models/User.ts
+++ b/packages/server/src/models/User.ts
@@ -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;
diff --git a/packages/server/src/tools/canvas_cache.ts b/packages/server/src/tools/canvas_cache.ts
deleted file mode 100644
index 325b29b..0000000
--- a/packages/server/src/tools/canvas_cache.ts
+++ /dev/null
@@ -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");
-})();
diff --git a/packages/server/src/tools/start_job_worker.ts b/packages/server/src/tools/start_job_worker.ts
new file mode 100644
index 0000000..1632f34
--- /dev/null
+++ b/packages/server/src/tools/start_job_worker.ts
@@ -0,0 +1,6 @@
+import { Jobs } from "../jobs/Jobs";
+import { loadSettings } from "../lib/Settings";
+
+loadSettings(true).then(() => {
+ new Jobs();
+});