before react rewrite 💀

This commit is contained in:
Grant 2024-01-26 19:38:41 -07:00
parent 10543b5e92
commit cc94580bbe
30 changed files with 12070 additions and 0 deletions

134
.gitignore vendored Normal file
View File

@ -0,0 +1,134 @@
packages/server/prisma/dev.db
packages/client/public/**/*.css
packages/client/public/**/*.js
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

10405
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "sc07-canvas",
"version": "1.0.0",
"description": "",
"workspaces": [
"packages/client",
"packages/server",
"packages/lib"
],
"main": "src/index.ts",
"scripts": {
"dev:server": "cd packages/server && npm run dev",
"dev:client": "cd packages/client && npm run dev"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@tsconfig/recommended": "^1.0.2",
"dotenv": "^16.3.1",
"nodemon": "^3.0.1",
"prettier": "^3.0.1",
"ts-node": "^10.9.1",
"typescript": "^5.1.6"
},
"dependencies": {
"@quixo3/prisma-session-store": "^3.1.13"
}
}

View File

@ -0,0 +1,26 @@
var gulp = require("gulp");
var ts = require("gulp-typescript");
const webpack = require("webpack-stream");
const sass = require("gulp-sass")(require("sass"));
gulp.task("css", function () {
return gulp
.src("src/**/*.scss")
.pipe(sass().on("error", sass.logError))
.pipe(gulp.dest("./public/css"));
});
gulp.task("js", function () {
return gulp
.src("src/**/*.ts")
.pipe(webpack(require("./webpack.config.js")))
.pipe(gulp.dest("public"));
});
gulp.task(
"watch",
gulp.series("js", "css", function () {
gulp.watch("src/**/*.ts", gulp.series("js"));
gulp.watch("src/**/*.scss", gulp.series("css"));
})
);

View File

@ -0,0 +1,26 @@
{
"name": "@sc07-canvas/client",
"version": "1.0.0",
"scripts": {
"dev": "gulp watch"
},
"dependencies": {
"@sc07-canvas/lib": "^1.0.0",
"gulp": "^4.0.2",
"gulp-cli": "^2.3.0",
"gulp-typescript": "^2.12.2",
"socket.io-client": "^4.7.2",
"ts-loader": "^9.4.4"
},
"devDependencies": {
"@babel/cli": "^7.22.10",
"@babel/core": "^7.22.10",
"@babel/preset-env": "^7.22.10",
"babel-loader": "^9.1.3",
"gulp-sass": "^5.1.0",
"sass": "^1.65.1",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-stream": "^7.0.0"
}
}

View File

@ -0,0 +1,64 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
/>
<title>Document</title>
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
<header>
<div></div>
<div class="spacer"></div>
<div>
<div class="user-card">
<div class="user-card--overview">
<div class="user-name"></div>
<div class="user-instance"></div>
</div>
<img src="#" alt="User Avatar" class="user-avatar" />
</div>
</div>
</header>
<main>
<div id="board-zoom">
<div id="board-move" style="width: 100px; height: 100px">
<canvas
id="board"
width="1000"
height="1000"
class="pixelate"
></canvas>
</div>
</div>
</main>
<div id="cursor" style="display: none"></div>
<div id="pallete">
<div id="canvas-meta">
<!-- canvas is in charge of the metabox -->
<span>
Pixels:
<span class="canvas-meta--pixels"></span>
</span>
<span>
Users Online:
<span class="canvas-meta--online"></span>
</span>
<button id="test">test button</button>
</div>
<div class="pallete-colors"></div>
<div class="pallete-user-overlay">
you are not logged in
<a href="/api/login" class="user-login">login</a>
</div>
</div>
<script src="bundle.js"></script>
</body>
</html>

View File

@ -0,0 +1,11 @@
import "./lib/net";
import Pallete from "./lib/pallete";
import Canvas from "./lib/canvas";
window.addEventListener("mousemove", (e) => {
const cursor = document.getElementById("cursor");
cursor!.style.transform = `translate(${e.pageX}px, ${e.pageY}px)`;
});
Canvas.setup();
Canvas.draw();

View File

@ -0,0 +1,32 @@
import { SUserPacket, AuthSession } from "@sc07-canvas/lib/src/net";
const $puseroverlay = document.querySelector(
"#pallete .pallete-user-overlay"
)! as HTMLDivElement;
const $usercard = document.querySelector(".user-card")! as HTMLDivElement;
const $usercard_name = $usercard.querySelector(".user-name")! as HTMLDivElement;
const $usercard_instance = $usercard.querySelector(
".user-instance"
)! as HTMLDivElement;
const $usercard_avatar = $usercard.querySelector(
"img.user-avatar"
)! as HTMLImageElement;
class Auth {
private user: AuthSession | undefined;
login() {
window.location.href = "/api/login";
}
handleAuth(data: SUserPacket) {
this.user = data.user;
$puseroverlay.style.display = "none";
$usercard_name.innerText = data.user.user.username;
$usercard_instance.innerHTML = data.user.service.instance.hostname;
$usercard_avatar.setAttribute("src", data.user.user.profile);
}
}
export default new Auth();

View File

@ -0,0 +1,414 @@
import pallete from "./pallete";
import Network from "./net";
import {
CPixelPacket,
SCanvasPacket,
SPixelPacket,
} from "@sc07-canvas/lib/src/net";
const $moveContainer = document.querySelector("main")!;
const $canvas = document.getElementById("board")! as HTMLCanvasElement;
const $zoom = document.getElementById("board-zoom")!;
const $move = document.getElementById("board-move")!;
const $container = document.querySelector("main")!;
const $metabox = document.getElementById("canvas-meta")! as HTMLDivElement;
const $metabox_pixels = $metabox.querySelector(
".canvas-meta--pixels"
) as HTMLSpanElement;
const $metabox_online = $metabox.querySelector(
".canvas-meta--online"
) as HTMLSpanElement;
const $testbtn = document.getElementById("test")! as HTMLButtonElement;
export type CanvasConfig = {
size: [number, number];
zoom: number;
};
const ZOOM_SPEED = 0.1;
class Canvas {
private config: CanvasConfig = { size: [0, 0], zoom: 1 };
/**
* Last pixel place date
*
* TODO: Support pixel stacking
*/
private lastPlace: Date | undefined;
private zoom = 1;
private move = { x: 0, y: 0 };
private cursor = { x: -1, y: -1 };
private ctx: CanvasRenderingContext2D | null = null;
// private pixels: {
// x: number;
// y: number;
// color: number;
// type: "full" | "pending";
// }[] = [];
private pixels: {
[x_y: string]: { color: number; type: "full" | "pending" };
} = {};
private mouseDown = false;
private dragOrigin = { x: 0, y: 0 };
private downTime: number | undefined;
load(config: CanvasConfig) {
this.config = config;
$canvas.setAttribute("width", config.size[0] + "");
$canvas.setAttribute("height", config.size[1] + "");
this.setZoom(config.zoom);
}
setZoom(newzoom: number, mouse?: { x: number; y: number }) {
if (newzoom < 0.5) newzoom = 0.5;
const oldzoom = this.zoom;
this.zoom = newzoom;
$zoom.style.transform = `scale(${newzoom})`;
if (mouse) {
const dx = mouse.x - $container.clientWidth / 2;
const dy = mouse.y - $container.clientHeight / 2;
this.move.x -= dx / oldzoom;
this.move.x += dx / newzoom;
this.move.y -= dy / oldzoom;
this.move.y += dy / newzoom;
$move.style.transform = `translate(${this.move.x}px, ${this.move.y}px)`;
}
}
setup() {
this.ctx = $canvas.getContext("2d");
let pinching = false;
let pinchInit = 0;
$testbtn.addEventListener("click", (e) => {
e.preventDefault();
$canvas.classList.toggle("pixelate");
});
window.addEventListener("wheel", (e) => {
const oldzoom = this.zoom;
let newzoom = (this.zoom += ZOOM_SPEED * (e.deltaY > 0 ? -1 : 1));
this.setZoom(newzoom, {
x: e.clientX,
y: e.clientY,
});
});
const handleMouseStart = (e: { pageX: number; pageY: number }) => {
this.mouseDown = true;
this.dragOrigin = { x: e.pageX, y: e.pageY };
this.downTime = Date.now();
};
$moveContainer.addEventListener(
"touchstart",
(e) => {
if (e.changedTouches.length === 2) {
pinching = true;
pinchInit = Math.hypot(
e.touches[0].pageX - e.touches[1].pageX,
e.touches[0].pageY - e.touches[1].pageY
);
} else {
handleMouseStart(e.touches[0]);
}
$testbtn.innerText = e.changedTouches.length + "";
},
{ passive: false }
);
$moveContainer.addEventListener("mousedown", handleMouseStart);
const update = (x: number, y: number, update: boolean) => {
const deltaX = (x - this.dragOrigin.x) / this.zoom;
const deltaY = (y - this.dragOrigin.y) / this.zoom;
const newX = this.move.x + deltaX;
const newY = this.move.y + deltaY;
if (update) {
this.move.x += deltaX;
this.move.y += deltaY;
}
$move.style.transform = `translate(${newX}px, ${newY}px)`;
};
const handleMouseEnd = (e: {
clientX: number;
clientY: number;
pageX: number;
pageY: number;
}) => {
if (!this.mouseDown) return;
this.mouseDown = false;
const downDelta = Date.now() - this.downTime!;
const delta = [
Math.abs(this.dragOrigin.x - e.clientX),
Math.abs(this.dragOrigin.y - e.clientY),
];
if (downDelta < 500) {
// mouse was down for less than 500ms
if (delta[0] < 5 && delta[1] < 5) {
const [x, y] = this.screenToPos(e.clientX, e.clientY);
this.place(x, y);
}
}
update(e.pageX, e.pageY, true);
};
window.addEventListener("touchend", (e) => {
if (pinching) {
console.log("pinching end");
pinching = false;
} else {
handleMouseEnd(e.changedTouches[0]);
}
});
window.addEventListener("mouseup", (e) => {
if (pinching) {
pinching = false;
} else {
handleMouseEnd(e);
}
});
const handleMouseMove = (e: {
pageX: number;
pageY: number;
clientX: number;
clientY: number;
}) => {
if (this.mouseDown) {
update(e.pageX, e.pageY, false);
} else {
const canvasRect = $canvas.getBoundingClientRect();
if (
canvasRect.left <= e.pageX &&
canvasRect.right >= e.pageX &&
canvasRect.top <= e.pageY &&
canvasRect.bottom >= e.pageY
) {
const [x, y] = this.screenToPos(e.clientX, e.clientY);
this.cursor.x = x;
this.cursor.y = y;
} else {
this.cursor.x = -1;
this.cursor.y = -1;
}
}
};
window.addEventListener(
"touchmove",
(e) => {
if (pinching) {
let initDiff = Math.hypot(
e.touches[0].pageX - e.touches[1].pageX,
e.touches[0].pageY - e.touches[1].pageY
);
let diff = (pinchInit - initDiff) / (10 / this.zoom);
let newzoom = (this.zoom += ZOOM_SPEED * -diff);
this.setZoom(newzoom, {
x: window.innerWidth / 2,
y: window.innerHeight / 2,
});
pinchInit = initDiff;
} else {
handleMouseMove(e.touches[0]);
}
},
{ passive: false }
);
window.addEventListener("mousemove", handleMouseMove);
$canvas.addEventListener("touchmove", (e) => {
if (!pinching) {
const [x, y] = this.screenToPos(
e.touches[0].clientX,
e.touches[0].clientY
);
this.cursor.x = x;
this.cursor.y = y;
}
});
$canvas.addEventListener("mousemove", (e) => {
const [x, y] = this.screenToPos(e.clientX, e.clientY);
this.cursor.x = x;
this.cursor.y = y;
// window.requestAnimationFrame(() => this.draw());
});
}
screenToPos(x: number, y: number) {
const rect = $canvas.getBoundingClientRect();
const scale = [$canvas.width / rect.width, $canvas.height / rect.height];
return [x - rect.left, y - rect.top]
.map((v, i) => v * scale[i])
.map((v) => v >> 0);
}
draw() {
if (!this.ctx) {
window.requestAnimationFrame(() => this.draw());
return;
}
/* @ts-ignore */
this.ctx.mozImageSmoothingEnabled =
/* @ts-ignore */
this.ctx.webkitImageSmoothingEnabled =
/* @ts-ignore */
this.ctx.msImageSmoothingEnabled =
this.ctx.imageSmoothingEnabled =
false;
this.ctx.canvas.width = window.innerWidth;
this.ctx.canvas.height = window.innerHeight;
const bezier = (n: number) => n * n * (3 - 2 * n);
this.ctx.globalAlpha = 1;
this.ctx.fillStyle = "#fff";
this.ctx.fillRect(0, 0, this.config.size[0], this.config.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
? "#" + pallete.getPalleteColor(pixel.color)!.hex
: "transparent";
this.ctx.fillRect(x, y, 1, 1);
}
if (pallete.getColor() && this.cursor.x > -1 && this.cursor.y > -1) {
let t = ((Date.now() / 100) % 10) / 10;
this.ctx.globalAlpha = t < 0.5 ? bezier(t) : -bezier(t) + 1;
this.ctx.fillStyle = "#" + pallete.getColor()!.hex;
this.ctx.fillRect(this.cursor.x, this.cursor.y, 1, 1);
}
this.renderMetabox();
window.requestAnimationFrame(() => this.draw());
}
/**
* update canvas metabox with latest information
*
* executed by #draw()
*/
renderMetabox() {
if (this.lastPlace) {
let cooldown =
this.lastPlace.getTime() +
pallete.getPixelCooldown() -
new Date().getTime();
$metabox_pixels.innerText = cooldown > 0 ? cooldown / 1000 + "s" : "none";
} else {
$metabox_pixels.innerText = "no cooldown";
}
$metabox_online.innerText = Network.getOnline() + "";
}
place(x: number, y: number) {
if (!pallete.getColor()) return;
if (this.lastPlace) {
if (
this.lastPlace.getTime() + pallete.getPixelCooldown() >
new Date().getTime()
) {
console.log("cannot place, cooldown in place");
return;
}
}
this.lastPlace = new Date();
const color = pallete.getColor()!;
// this.pixels.push({
// x,
// y,
// color: color.id,
// type: "pending",
// });
this.pixels[x + "_" + y] = {
color: color.id,
type: "pending",
};
Network.sendAck<CPixelPacket, SPixelPacket>("place", {
x,
y,
color: color.id,
}).then((ack) => {
// remove pending pixel at coord
// can remove regardless of success or failure, as success places pixel at coord from server
// this.pixels.splice(
// this.pixels.indexOf(
// this.pixels.find(
// (pxl) =>
// pxl.x === x &&
// pxl.y === y &&
// pxl.color === color.id &&
// pxl.type === "pending"
// )!
// ),
// 1
// );
if (ack.success) {
this.handlePixel(ack.data);
} else {
// TODO: handle undo pixel
alert("Error: " + ack.error);
}
});
}
handlePixel({ x, y, color, ...rest }: SPixelPacket) {
// this.pixels.push({
// x,
// y,
// color,
// type: "full",
// });
this.pixels[x + "_" + y] = {
color,
type: "full",
};
}
handleBatch(batch: SCanvasPacket) {
batch.pixels.forEach((hex, index) => {
const x = index / this.config.size[0];
const y = index % this.config.size[0];
const color = pallete.getPalleteFromHex(hex);
this.pixels[x + "_" + y] = {
color: color ? color.id : -1,
type: "full",
};
});
}
}
export default new Canvas();

View File

@ -0,0 +1,74 @@
import io from "socket.io-client";
import Pallete from "./pallete";
import Canvas from "./canvas";
import {
CPixelPacket,
ClientPacket,
PacketAck,
SCanvasPacket,
SPixelPacket,
SUserPacket,
ServerPacket,
} from "@sc07-canvas/lib/src/net";
import Auth from "./auth";
class Network {
private socket;
private online_count = 0;
constructor() {
this.socket = io();
this.socket.on("config", (config) => {
Pallete.load(config.pallete);
Canvas.load(config.canvas);
});
this.socket.on("pixel", (data: SPixelPacket) => {
Canvas.handlePixel(data);
});
this.socket.on("user", (data: SUserPacket) => {
Auth.handleAuth(data);
});
this.socket.on("canvas", (data: SCanvasPacket) => {
Canvas.handleBatch(data);
});
this.socket.on("online", (data: { count: number }) => {
this.online_count = data.count;
});
}
send<T extends ClientPacket>(
type: T["type"],
data: Omit<T, "type" | "_direction">
) {
// @ts-ignore
data._direction = "client->server";
this.socket.emit(type, data);
}
sendAck<O extends ClientPacket, I extends ServerPacket>(
type: O["type"],
data: Omit<O, "type" | "_direction">
) {
return new Promise<PacketAck<I>>((res) => {
this.socket.emit(type, data, (data: PacketAck<I>) => {
res(data);
});
});
}
/**
* Get online user count
* @returns online users count
*/
getOnline() {
return this.online_count;
}
}
export default new Network();

View File

@ -0,0 +1,80 @@
import { SUserPacket } from "@sc07-canvas/lib/src/net";
export type PalleteColor = {
id: number;
hex: string;
name: string;
};
const $pallete = document.getElementById("pallete")!;
const $pcolors = $pallete.querySelector(".pallete-colors")!;
const $puseroverlay = $pallete.querySelector(
".pallete-user-overlay"
)! as HTMLDivElement;
const $cursor = document.getElementById("cursor")!;
class Pallete {
private pallete: PalleteColor[] = [];
private active: PalleteColor | undefined;
private pixel_cooldown = 0;
load({
colors,
pixel_cooldown,
}: {
colors: PalleteColor[];
pixel_cooldown: number;
}) {
$pcolors.innerHTML = "";
this.pallete = colors;
this.pixel_cooldown = pixel_cooldown;
colors.forEach((color) => {
const $color = document.createElement("a");
$color.href = "#";
$color.classList.add("pallete-color");
$color.style.backgroundColor = "#" + color.hex;
$color.addEventListener("click", () => {
this.pick(color);
});
$pcolors.append($color);
});
this.active = undefined;
}
pick(color?: PalleteColor) {
this.active = color;
$cursor.style.display = color ? "block" : "none";
if (color) $cursor.style.backgroundColor = "#" + color.hex;
}
getColor() {
return this.active;
}
getPallete() {
return this.pallete;
}
getPalleteColor(id: number) {
return this.pallete.find((p) => p.id === id);
}
getPalleteFromHex(hex: string) {
return this.pallete.find((p) => p.hex === hex);
}
/**
* Get pixel cooldown
* @returns Pixel cooldown in ms
*/
getPixelCooldown() {
return this.pixel_cooldown;
}
}
export default new Pallete();

View File

@ -0,0 +1,155 @@
html,
body {
overscroll-behavior: contain;
touch-action: none;
}
body {
background-color: #ddd;
}
header {
position: fixed;
top: 0;
left: 0;
width: 100%;
display: flex;
flex-direction: row;
padding: 10px;
box-sizing: border-box;
z-index: 9999;
.spacer {
flex-grow: 1;
}
}
.user-card {
background-color: #444;
color: #fff;
border-radius: 10px;
padding: 5px 10px;
display: flex;
flex-direction: row;
gap: 10px;
&--overview {
display: flex;
flex-direction: column;
justify-content: center;
line-height: 1;
span:first-of-type {
font-size: 130%;
margin-bottom: 5px;
}
}
img {
width: 64px;
height: 64px;
background-color: #aaa;
border-radius: 50%;
}
}
#cursor {
position: fixed;
top: 20px;
left: 10px;
width: 36px;
height: 36px;
border: 2px solid #000;
border-radius: 3px;
pointer-events: none;
will-change: transform;
z-index: 2;
}
#pallete {
position: fixed;
left: 0;
bottom: 0;
width: 100%;
display: flex;
gap: 10px;
padding: 10px;
background-color: #fff;
.pallete-colors {
display: flex;
width: 100%;
justify-content: center;
gap: 10px;
padding: 10px;
}
.pallete-color {
width: 36px;
height: 36px;
border: 2px solid #000;
border-radius: 3px;
}
.pallete-user-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
display: flex;
justify-content: center;
align-items: center;
}
#canvas-meta {
position: absolute;
top: -10px;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
border-radius: 5px;
padding: 5px;
transform: translateY(-100%);
display: flex;
flex-direction: column;
}
}
main {
z-index: 0;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
canvas {
display: block;
box-sizing: border-box;
}
}
.pixelate {
image-rendering: optimizeSpeed;
-ms-interpolation-mode: nearest-neighbor;
image-rendering: -webkit-optimize-contrast;
image-rendering: -webkit-crisp-edges;
image-rendering: -moz-crisp-edges;
image-rendering: -o-crisp-edges;
image-rendering: pixelated;
image-rendering: crisp-edges;
}

View File

@ -0,0 +1,6 @@
{
"extends": "@tsconfig/recommended/tsconfig.json",
"compilerOptions": {
"sourceMap": true
}
}

View File

@ -0,0 +1,22 @@
const path = require("path");
module.exports = {
entry: "./src/index.ts",
devtool: "inline-source-map",
module: {
rules: [
{
test: /\.tsx?$/,
use: "ts-loader",
exclude: /node_modules/,
},
],
},
resolve: {
extensions: [".tsx", ".ts", ".js"],
},
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "public"),
},
};

View File

@ -0,0 +1,5 @@
{
"name": "@sc07-canvas/lib",
"version": "1.0.0",
"main": "./src/index.ts"
}

View File

@ -0,0 +1,3 @@
import * as net from "./net";
export { net };

60
packages/lib/src/net.ts Normal file
View File

@ -0,0 +1,60 @@
export type CPixelPacket = ClientPacket & {
type: "place";
x: number;
y: number;
color: number;
};
export type SCanvasPacket = ServerPacket & {
type: "canvas";
pixels: string[];
};
export type SPixelPacket = ServerPacket & {
type: "pixel";
x: number;
y: number;
color: number;
};
export type SUserPacket = ServerPacket & {
type: "user";
user: AuthSession;
};
export type Packet = {
type: string;
};
// server -> client
export type ServerPacket = Packet & {
_direction: "server->client";
};
// client -> server
export type ClientPacket = Packet & {
_direction: "client->server";
};
export type PacketAck<T = ServerPacket> =
| {
success: true;
data: T;
}
| { success: false; error: string };
export type AuthSession = {
service: {
software: {
name: string;
version: string;
};
instance: {
hostname: string;
};
};
user: {
username: string;
profile: string;
};
};

3
packages/server/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
# Keep environment variables out of version control
.env

View File

@ -0,0 +1,5 @@
{
"execMap": {
"ts": "ts-node"
}
}

View File

@ -0,0 +1,31 @@
{
"name": "@sc07-canvas/server",
"version": "1.0.0",
"main": "./build/index.js",
"scripts": {
"dev": "DOTENV_CONFIG_PATH=.env.local nodemon -r dotenv/config src/index.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@tsconfig/recommended": "^1.0.2",
"@types/express": "^4.17.17",
"@types/express-session": "^1.17.7",
"dotenv": "^16.3.1",
"nodemon": "^3.0.1",
"prettier": "^3.0.1",
"prisma": "^5.3.1",
"ts-node": "^10.9.1",
"typescript": "^5.1.6"
},
"dependencies": {
"@prisma/client": "^5.3.1",
"@sc07-canvas/lib": "^1.0.0",
"connect-redis": "^7.1.1",
"express": "^4.18.2",
"express-session": "^1.17.3",
"redis": "^4.6.12",
"socket.io": "^4.7.2"
}
}

View File

@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "User" (
"sub" TEXT NOT NULL PRIMARY KEY,
"lastPixelTime" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "PalleteColor" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"hex" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "Pixel" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"userId" TEXT NOT NULL,
"x" INTEGER NOT NULL,
"y" INTEGER NOT NULL,
"color" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Pixel_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("sub") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Pixel_color_fkey" FOREIGN KEY ("color") REFERENCES "PalleteColor" ("hex") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "PalleteColor_hex_key" ON "PalleteColor"("hex");

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

View File

@ -0,0 +1,39 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
sub String @id
lastPixelTime DateTime @default(now())
pixels Pixel[]
}
model PalleteColor {
id Int @id @default(autoincrement())
name String
hex String @unique
pixels Pixel[]
}
model Pixel {
id Int @id @default(autoincrement())
userId String
x Int
y Int
color String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [sub])
pallete PalleteColor @relation(fields: [color], references: [hex])
}

View File

@ -0,0 +1,60 @@
import { Router } from "express";
import { prisma } from "./lib/prisma";
const app = Router();
const AUTH_ENDPOINT = "https://auth.fediverse.events";
const AUTH_CLIENT = "canvas";
const AUTH_SECRET = "secret";
app.get("/login", (req, res) => {
const params = new URLSearchParams();
params.set("service", "canvas");
res.redirect(AUTH_ENDPOINT + "/login?" + params);
});
app.get("/callback", async (req, res) => {
const { code } = req.query;
const who = await fetch(AUTH_ENDPOINT + "/api/auth/identify", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${AUTH_CLIENT}:${AUTH_SECRET}`,
},
body: JSON.stringify({
code,
}),
}).then((a) => a.json());
const [username, hostname] = who.user.sub.split("@");
await prisma.user.upsert({
where: {
sub: [username, hostname].join("@"),
},
update: {},
create: {
sub: [username, hostname].join("@"),
},
});
req.session.user = {
service: {
...who.service,
instance: {
...who.service.instance,
hostname,
},
},
user: {
profile: who.user.profile,
username,
},
};
req.session.save();
res.redirect("/");
});
export default app;

View File

@ -0,0 +1,190 @@
import express from "express";
import expressSession from "express-session";
import http from "node:http";
import { Server } from "socket.io";
import {
CPixelPacket,
PacketAck,
SCanvasPacket,
SPixelPacket,
SUserPacket,
} from "@sc07-canvas/lib/src/net";
import APIRoutes from "./api";
// load declare module
import "./types";
import { prisma } from "./lib/prisma";
import { PalleteColor, PrismaClient } from "@prisma/client";
import { getRedis, client as redisClient } from "./lib/redis";
import Canvas from "./lib/Canvas";
import RedisStore from "connect-redis";
if (!process.env.PORT || isNaN(parseInt(process.env.PORT))) {
console.log("PORT env is not a valid number");
process.exit(1);
}
getRedis().then(() => {
console.log("redis connected");
});
const session = expressSession({
secret: "jagoprhaupihuaciohruearp8349jud",
resave: false,
saveUninitialized: false,
store: new RedisStore({
client: redisClient,
prefix: "canvas_session:",
}),
});
const app = express();
const server = http.createServer(app);
const io = new Server<
{
place: (
data: CPixelPacket,
callback: (data: PacketAck<SPixelPacket>) => {}
) => void;
},
{
user: (user: SUserPacket) => void;
config: (config: any) => void;
pixel: (data: SPixelPacket) => void;
canvas: (data: SCanvasPacket) => void;
online: (data: { count: number }) => void;
}
>(server);
var PALLETE: PalleteColor[] = [];
const PIXEL_TIMEOUT_MS = 1000;
prisma.palleteColor
.findMany()
.then((palleteColors) => {
PALLETE = palleteColors;
console.log(`Loaded ${palleteColors.length} pallete colors`);
})
.catch(console.error);
// on startup, cache current top-level pixels on redis
// notify all other shards with pixel updates via redis
// cacheCanvasToRedis().then(() => {
// console.log("canvas is now in redis");
// });
setInterval(async () => {
const sockets = await io.sockets.fetchSockets();
for (const socket of sockets) {
socket.emit("online", { count: sockets.length });
}
}, 5000);
io.engine.use(session);
io.on("connection", (socket) => {
const user = socket.request.session.user
? {
sub:
socket.request.session.user.user.username +
"@" +
socket.request.session.user.service.instance.hostname,
...socket.request.session.user,
}
: undefined;
console.log("connection", socket.request.session.user);
if (socket.request.session.user)
socket.emit("user", {
type: "user",
user: socket.request.session.user,
_direction: "server->client",
});
socket.emit("config", {
pallete: {
colors: PALLETE,
pixel_cooldown: PIXEL_TIMEOUT_MS,
},
canvas: Canvas.getCanvasConfig(),
});
Canvas.getPixelsArray().then((pixels) => {
socket.emit("canvas", {
_direction: "server->client",
type: "canvas",
pixels,
});
});
socket.on(
"place",
async (
{ x, y, color }: CPixelPacket,
ack: (data: PacketAck<SPixelPacket>) => {}
) => {
if (!user) {
ack({
success: false,
error: "no_user",
});
return;
}
const puser = await prisma.user.findFirst({ where: { sub: user.sub } });
if (puser?.lastPixelTime) {
if (
puser.lastPixelTime.getTime() + PIXEL_TIMEOUT_MS >
new Date().getTime()
) {
ack({
success: false,
error: "pixel_cooldown",
});
return;
}
}
const palleteColor = await prisma.palleteColor.findFirst({
where: {
id: color,
},
});
if (!palleteColor) {
ack({
success: false,
error: "pallete_color_invalid",
});
return;
}
await Canvas.setPixel(user, x, y, palleteColor.hex);
ack({
success: true,
data: {
type: "pixel",
_direction: "server->client",
x,
y,
color,
},
});
socket.broadcast.emit("pixel", {
x,
y,
color,
type: "pixel",
_direction: "server->client",
});
}
);
});
app.use(session);
app.use(express.static("../client/public"));
app.use("/api", APIRoutes);
server.listen(parseInt(process.env.PORT!), () => {
console.log("Listening on " + process.env.PORT);
});

View File

@ -0,0 +1,124 @@
import { prisma } from "./prisma";
import { getRedis } from "./redis";
const redis_keys = {
pixelColor: (x: number, y: number) => `CANVAS:PIXELS[${x},${y}]:COLOR`,
canvas: () => `CANVAS:PIXELS`,
};
class Canvas {
private CANVAS_SIZE: [number, number];
constructor() {
this.CANVAS_SIZE = [100, 100];
}
getCanvasConfig() {
return {
size: this.CANVAS_SIZE,
zoom: 7,
};
}
/**
* Latest database pixels -> Redis
*/
async pixelsToRedis() {
const redis = await getRedis();
const key = redis_keys.pixelColor;
for (let x = 0; x < this.CANVAS_SIZE[0]; x++) {
for (let y = 0; y < this.CANVAS_SIZE[1]; y++) {
const pixel = await prisma.pixel.findFirst({
where: {
x,
y,
},
orderBy: [
{
createdAt: "asc",
},
],
});
await redis.set(key(x, y), pixel?.color || "transparent");
}
}
}
/**
* Redis pixels -> single Redis comma separated list of hex
* @returns 1D array of pixel values
*/
async canvasToRedis() {
const redis = await getRedis();
let pixels: string[] = [];
for (let x = 0; x < this.CANVAS_SIZE[0]; x++) {
for (let y = 0; y < this.CANVAS_SIZE[1]; y++) {
pixels.push(
(await redis.get(redis_keys.pixelColor(x, y))) || "transparent"
);
}
}
await redis.set(redis_keys.canvas(), pixels.join(","), { EX: 60 * 5 });
return pixels;
}
/**
* force an update at a specific position
*/
async updateCanvasRedisAtPos(x: number, y: number) {
const redis = await getRedis();
let pixels: string[] = ((await redis.get(redis_keys.canvas())) || "").split(
","
);
pixels[this.CANVAS_SIZE[0] * y + x] =
(await redis.get(redis_keys.pixelColor(x, y))) || "transparent";
await redis.set(redis_keys.canvas(), pixels.join(","), { EX: 60 * 5 });
}
async getPixelsArray() {
const redis = await getRedis();
if (await redis.exists(redis_keys.canvas())) {
const cached = await redis.get(redis_keys.canvas());
return cached!.split(",");
}
return await this.canvasToRedis();
}
async setPixel(user: { sub: string }, x: number, y: number, hex: string) {
const redis = await getRedis();
await prisma.pixel.create({
data: {
userId: user.sub,
color: hex,
x,
y,
},
});
await prisma.user.update({
where: { sub: user.sub },
data: { lastPixelTime: new Date() },
});
await redis.set(`CANVAS:PIXELS[${x},${y}]:COLOR`, hex);
// maybe only update specific element?
// i don't think it needs to be awaited
await this.updateCanvasRedisAtPos(x, y);
}
}
export default new Canvas();

View File

@ -0,0 +1,3 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();

View File

@ -0,0 +1,14 @@
import { RedisClientType } from "@redis/client";
import { createClient } from "redis";
let isConnected = false;
export const client = createClient();
export const getRedis = async () => {
if (!isConnected) {
await client.connect();
isConnected = true;
}
return client;
};

View File

@ -0,0 +1,18 @@
import type { IncomingMessage } from "http";
import type { Session, SessionData } from "express-session";
import type { Socket } from "socket.io";
import session from "express-session";
import { AuthSession } from "@sc07-canvas/lib/src/net";
declare module "express-session" {
interface SessionData {
user: AuthSession;
}
}
declare module "http" {
interface IncomingMessage {
cookieHolder?: string;
session: Session & Partial<session.SessionData>;
}
}

View File

@ -0,0 +1,7 @@
{
"extends": "@tsconfig/recommended/tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "build"
}
}