accelr8 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ CANVAS_H,
4
+ CANVAS_W,
5
+ PORT,
6
+ decode,
7
+ encode
8
+ } from "./chunk-BVRGM7YI.js";
9
+
10
+ // src/server.ts
11
+ import { WebSocketServer, WebSocket } from "ws";
12
+ import { randomUUID } from "node:crypto";
13
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { homedir } from "node:os";
16
+ var MAX_PROJECTS = 500;
17
+ var MAX_MEMBERS = 200;
18
+ var DATA_FILE = join(homedir(), ".accelr8", "server-data.json");
19
+ var projects = /* @__PURE__ */ new Map();
20
+ function load() {
21
+ if (!existsSync(DATA_FILE)) return;
22
+ try {
23
+ const raw = JSON.parse(readFileSync(DATA_FILE, "utf8"));
24
+ for (const p of raw) projects.set(p.id, p);
25
+ console.log(`loaded ${projects.size} project(s)`);
26
+ } catch (e) {
27
+ console.error("failed to load projects:", e);
28
+ }
29
+ }
30
+ var saveTimer = null;
31
+ function saveNow() {
32
+ if (saveTimer) {
33
+ clearTimeout(saveTimer);
34
+ saveTimer = null;
35
+ }
36
+ try {
37
+ mkdirSync(dirname(DATA_FILE), { recursive: true });
38
+ writeFileSync(DATA_FILE, JSON.stringify([...projects.values()], null, 2));
39
+ } catch (e) {
40
+ console.error("failed to save projects:", e);
41
+ }
42
+ }
43
+ function scheduleSave() {
44
+ if (saveTimer) return;
45
+ saveTimer = setTimeout(() => {
46
+ saveTimer = null;
47
+ saveNow();
48
+ }, 400);
49
+ }
50
+ var clients = /* @__PURE__ */ new Set();
51
+ function shortCode() {
52
+ const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
53
+ let code = "";
54
+ do {
55
+ code = "";
56
+ for (let i = 0; i < 6; i++) code += alphabet[Math.floor(Math.random() * alphabet.length)];
57
+ } while ([...projects.values()].some((p) => p.code === code));
58
+ return code;
59
+ }
60
+ function onlineIds(projectId) {
61
+ return [...new Set([...clients].filter((c) => c.projectId === projectId).map((c) => c.id))];
62
+ }
63
+ function summaries() {
64
+ return [...projects.values()].sort((a, b) => b.createdAt - a.createdAt).map((p) => ({
65
+ id: p.id,
66
+ name: p.name,
67
+ code: p.code,
68
+ members: p.members.length,
69
+ online: onlineIds(p.id).length
70
+ }));
71
+ }
72
+ function roomPeers(projectId, except) {
73
+ return [...clients].filter((c) => c.projectId === projectId && c !== except);
74
+ }
75
+ function send(c, msg) {
76
+ if (c.ws.readyState === WebSocket.OPEN) c.ws.send(encode(msg));
77
+ }
78
+ function broadcastProjectList() {
79
+ const list = summaries();
80
+ for (const c of clients) send(c, { t: "projects", list });
81
+ }
82
+ function joinRoom(client, project) {
83
+ leaveRoom(client);
84
+ client.projectId = project.id;
85
+ const existing = project.members.find((m) => m.id === client.id);
86
+ if (!existing) {
87
+ project.members.push({ id: client.id, name: client.name, color: client.color });
88
+ scheduleSave();
89
+ } else if (existing.name !== client.name || existing.color !== client.color) {
90
+ existing.name = client.name;
91
+ existing.color = client.color;
92
+ scheduleSave();
93
+ }
94
+ send(client, {
95
+ t: "joined",
96
+ project: { id: project.id, name: project.name, code: project.code },
97
+ grid: project.grid,
98
+ members: project.members
99
+ });
100
+ send(client, { t: "presence", online: onlineIds(project.id) });
101
+ send(client, { t: "members", members: project.members });
102
+ for (const c of roomPeers(project.id, client)) {
103
+ send(c, { t: "members", members: project.members });
104
+ send(c, { t: "presence", online: onlineIds(project.id) });
105
+ }
106
+ broadcastProjectList();
107
+ }
108
+ function leaveRoom(client) {
109
+ const pid = client.projectId;
110
+ if (!pid) return;
111
+ client.projectId = null;
112
+ const stillHere = [...clients].some((c) => c.id === client.id && c.projectId === pid);
113
+ for (const c of roomPeers(pid)) {
114
+ if (!stillHere) send(c, { t: "cursorGone", id: client.id });
115
+ send(c, { t: "presence", online: onlineIds(pid) });
116
+ }
117
+ broadcastProjectList();
118
+ }
119
+ function sanitizeChar(v) {
120
+ const ch = [...String(v ?? " ")][0] ?? " ";
121
+ const cp = ch.codePointAt(0) ?? 32;
122
+ return cp < 32 || cp === 127 ? " " : ch;
123
+ }
124
+ function handleMessage(client, msg) {
125
+ switch (msg.t) {
126
+ case "hello": {
127
+ const u = msg.user || {};
128
+ client.id = String(u.id || "").slice(0, 40) || "u_anon";
129
+ client.name = String(u.name || "guest").slice(0, 16);
130
+ client.color = Number(u.color) || 7;
131
+ send(client, { t: "welcome", you: { id: client.id, name: client.name, color: client.color } });
132
+ send(client, { t: "projects", list: summaries() });
133
+ break;
134
+ }
135
+ case "listProjects": {
136
+ send(client, { t: "projects", list: summaries() });
137
+ break;
138
+ }
139
+ case "createProject": {
140
+ if (projects.size >= MAX_PROJECTS) {
141
+ send(client, { t: "joinError", message: "server full: project limit reached" });
142
+ break;
143
+ }
144
+ const name = String(msg.name || "").trim().slice(0, 40) || "untitled";
145
+ const project = {
146
+ id: randomUUID(),
147
+ name,
148
+ code: shortCode(),
149
+ createdAt: Date.now(),
150
+ members: [],
151
+ grid: {}
152
+ };
153
+ projects.set(project.id, project);
154
+ scheduleSave();
155
+ joinRoom(client, project);
156
+ broadcastProjectList();
157
+ break;
158
+ }
159
+ case "joinProject": {
160
+ const code = String(msg.code || "").trim().toUpperCase();
161
+ const project = [...projects.values()].find((p) => p.code === code);
162
+ if (!project) {
163
+ send(client, { t: "joinError", message: `no project with code ${code}` });
164
+ break;
165
+ }
166
+ if (project.members.length >= MAX_MEMBERS && !project.members.some((m) => m.id === client.id)) {
167
+ send(client, { t: "joinError", message: "project is full" });
168
+ break;
169
+ }
170
+ joinRoom(client, project);
171
+ break;
172
+ }
173
+ case "leaveProject": {
174
+ leaveRoom(client);
175
+ send(client, { t: "projects", list: summaries() });
176
+ break;
177
+ }
178
+ case "cursor": {
179
+ if (!client.projectId) break;
180
+ const x = Number(msg.x), y = Number(msg.y);
181
+ if (!Number.isFinite(x) || !Number.isFinite(y)) break;
182
+ for (const c of roomPeers(client.projectId, client))
183
+ send(c, { t: "cursor", id: client.id, x, y });
184
+ break;
185
+ }
186
+ case "paint": {
187
+ if (!client.projectId) break;
188
+ const project = projects.get(client.projectId);
189
+ if (!project) break;
190
+ const x = Math.floor(Number(msg.x)), y = Math.floor(Number(msg.y));
191
+ if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x >= CANVAS_W || y >= CANVAS_H) break;
192
+ const char = sanitizeChar(msg.char);
193
+ const key = `${x},${y}`;
194
+ if (char === " ") delete project.grid[key];
195
+ else project.grid[key] = [char, client.color];
196
+ scheduleSave();
197
+ for (const c of roomPeers(client.projectId))
198
+ send(c, { t: "paint", x, y, char, color: client.color });
199
+ break;
200
+ }
201
+ case "clear": {
202
+ if (!client.projectId) break;
203
+ const project = projects.get(client.projectId);
204
+ if (!project) break;
205
+ project.grid = {};
206
+ scheduleSave();
207
+ for (const c of roomPeers(client.projectId)) send(c, { t: "cleared" });
208
+ break;
209
+ }
210
+ }
211
+ }
212
+ function runServer(port = PORT) {
213
+ load();
214
+ const wss = new WebSocketServer({ port });
215
+ wss.on("connection", (ws) => {
216
+ const client = { ws, id: "", name: "", color: 7, projectId: null };
217
+ clients.add(client);
218
+ ws.on("message", (raw) => {
219
+ let msg;
220
+ try {
221
+ msg = decode(raw.toString());
222
+ } catch {
223
+ return;
224
+ }
225
+ if (!msg || typeof msg !== "object" || typeof msg.t !== "string") return;
226
+ try {
227
+ handleMessage(client, msg);
228
+ } catch (e) {
229
+ console.error("bad frame from client:", e instanceof Error ? e.message : e);
230
+ }
231
+ });
232
+ ws.on("close", () => {
233
+ leaveRoom(client);
234
+ clients.delete(client);
235
+ });
236
+ ws.on("error", () => {
237
+ });
238
+ });
239
+ const alive = /* @__PURE__ */ new WeakMap();
240
+ wss.on("connection", (ws) => {
241
+ alive.set(ws, true);
242
+ ws.on("pong", () => alive.set(ws, true));
243
+ });
244
+ setInterval(() => {
245
+ for (const ws of wss.clients) {
246
+ if (alive.get(ws) === false) {
247
+ ws.terminate();
248
+ continue;
249
+ }
250
+ alive.set(ws, false);
251
+ try {
252
+ ws.ping();
253
+ } catch {
254
+ }
255
+ }
256
+ }, 3e4);
257
+ const stop = () => {
258
+ saveNow();
259
+ process.exit(0);
260
+ };
261
+ process.on("SIGINT", stop);
262
+ process.on("SIGTERM", stop);
263
+ console.log(`accelr8 sync server listening on ws://localhost:${port}`);
264
+ console.log(`data file: ${DATA_FILE}`);
265
+ console.log(`share it beyond your machine with: cloudflared tunnel --url http://localhost:${port}`);
266
+ return wss;
267
+ }
268
+ export {
269
+ runServer
270
+ };
package/dist/cli.js ADDED
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ DEFAULT_SERVER,
4
+ PORT,
5
+ VERSION
6
+ } from "./chunks/chunk-BVRGM7YI.js";
7
+
8
+ // src/cli.ts
9
+ var HELP = `
10
+ accelr8 v${VERSION} - collaborative ascii canvas in your terminal
11
+
12
+ USAGE
13
+ accelr8 open the app
14
+ accelr8 join <CODE> open straight into a project
15
+ accelr8 serve run your own sync server
16
+
17
+ OPTIONS
18
+ --name <name> display name (remembered for next time)
19
+ --server <url> sync server url
20
+ (default: ${DEFAULT_SERVER})
21
+ --port <port> port for serve (default: ${PORT})
22
+ -v, --version print version
23
+ -h, --help this help
24
+
25
+ IN THE APP
26
+ mouse click tabs, buttons, project rows, brushes
27
+ click + drag on canvas paint (right-click erases)
28
+ tab / 1 2 3 switch tabs
29
+ q quit
30
+
31
+ Everyone in a project sees everyone else's cursor move live on the canvas.
32
+ `;
33
+ function argVal(args, flag) {
34
+ const i = args.indexOf(flag);
35
+ if (i >= 0 && i + 1 < args.length) return args[i + 1];
36
+ return void 0;
37
+ }
38
+ async function main() {
39
+ const args = process.argv.slice(2);
40
+ if (args.includes("-h") || args.includes("--help")) {
41
+ console.log(HELP);
42
+ return;
43
+ }
44
+ if (args.includes("-v") || args.includes("--version")) {
45
+ console.log(VERSION);
46
+ return;
47
+ }
48
+ if (args[0] === "serve") {
49
+ const port = Number(argVal(args, "--port") || PORT);
50
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
51
+ console.error(`invalid port: ${argVal(args, "--port")}`);
52
+ process.exit(1);
53
+ }
54
+ const { runServer } = await import("./chunks/server-CG3UEKLP.js");
55
+ runServer(port);
56
+ return;
57
+ }
58
+ let joinCode;
59
+ if (args[0] === "join") {
60
+ joinCode = args[1];
61
+ if (!joinCode || joinCode.startsWith("-")) {
62
+ console.error("usage: accelr8 join <CODE>");
63
+ process.exit(1);
64
+ }
65
+ }
66
+ if (!process.stdout.isTTY) {
67
+ console.error("accelr8 is a terminal app and needs an interactive terminal (TTY).");
68
+ process.exit(1);
69
+ }
70
+ const { runClient } = await import("./chunks/client-FMFGODEU.js");
71
+ runClient({
72
+ name: argVal(args, "--name"),
73
+ server: argVal(args, "--server"),
74
+ joinCode
75
+ });
76
+ }
77
+ main();
package/package.json CHANGED
@@ -1,19 +1,54 @@
1
1
  {
2
2
  "name": "accelr8",
3
- "version": "1.0.0",
4
- "main": "index.js",
5
- "scripts": {
6
- "test": "echo \"Error: no test specified\" && exit 1"
7
- },
8
- "keywords": [],
9
- "author": "",
3
+ "version": "2.0.0",
4
+ "description": "Collaborative ascii canvas in your terminal. Real multiplayer, live cursors, mouse-driven TUI.",
5
+ "type": "module",
10
6
  "license": "ISC",
11
- "dependencies": {
12
- "reblessed": "^0.2.1"
7
+ "author": "Pat Santiago",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Patonchain/accelr8-cli.git"
13
11
  },
14
12
  "bin": {
15
- "accelr8": "bin/index.js"
13
+ "accelr8": "dist/cli.js"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "worker",
18
+ "README.md"
19
+ ],
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "keywords": [
24
+ "cli",
25
+ "tui",
26
+ "ascii",
27
+ "collaborative",
28
+ "canvas",
29
+ "live-cursors",
30
+ "terminal",
31
+ "accelr8"
32
+ ],
33
+ "scripts": {
34
+ "build": "node scripts/build.mjs",
35
+ "dev": "tsx src/cli.ts",
36
+ "serve": "tsx src/cli.ts serve",
37
+ "smoketest": "tsx src/smoketest.ts",
38
+ "rendertest": "tsx src/rendertest.ts",
39
+ "typecheck": "tsc --noEmit",
40
+ "prepublishOnly": "npm run build"
41
+ },
42
+ "dependencies": {
43
+ "terminal-kit": "3.1.2",
44
+ "ws": "8.18.0"
16
45
  },
17
- "devDependencies": {},
18
- "description": ""
46
+ "devDependencies": {
47
+ "@types/node": "22.10.2",
48
+ "@types/terminal-kit": "2.5.7",
49
+ "@types/ws": "8.5.13",
50
+ "esbuild": "0.28.1",
51
+ "tsx": "4.19.2",
52
+ "typescript": "5.7.2"
53
+ }
19
54
  }
@@ -0,0 +1,293 @@
1
+ // accelr8 hosted sync server: Cloudflare Worker + a single Durable Object
2
+ // ("Lobby") that speaks the same JSON protocol as src/server.ts.
3
+ // Uses the WebSocket hibernation API; per-socket state lives in attachments
4
+ // so it survives DO eviction. Each project persists under its own storage key.
5
+
6
+ export interface Env {
7
+ LOBBY: DurableObjectNamespace;
8
+ }
9
+
10
+ type DurableObjectNamespace = any;
11
+ type DurableObjectState = any;
12
+
13
+ interface Member { id: string; name: string; color: number }
14
+ interface Project {
15
+ id: string;
16
+ name: string;
17
+ code: string;
18
+ createdAt: number;
19
+ members: Member[];
20
+ grid: Record<string, [string, number]>;
21
+ }
22
+ interface Att { id: string; name: string; color: number; projectId: string | null }
23
+
24
+ const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
25
+ const MAX_PROJECTS = 500;
26
+ const MAX_MEMBERS = 200;
27
+ const CANVAS_W = 100;
28
+ const CANVAS_H = 30;
29
+
30
+ /** First code point of the input, or ' ' if empty/control. */
31
+ function sanitizeChar(v: unknown): string {
32
+ const ch = [...String(v ?? ' ')][0] ?? ' ';
33
+ const cp = ch.codePointAt(0) ?? 32;
34
+ return cp < 0x20 || cp === 0x7f ? ' ' : ch;
35
+ }
36
+
37
+ export default {
38
+ async fetch(request: Request, env: Env): Promise<Response> {
39
+ const id = env.LOBBY.idFromName('main');
40
+ return env.LOBBY.get(id).fetch(request);
41
+ },
42
+ };
43
+
44
+ export class Lobby {
45
+ ctx: DurableObjectState;
46
+ projects: Map<string, Project> | null = null;
47
+
48
+ constructor(ctx: DurableObjectState) {
49
+ this.ctx = ctx;
50
+ }
51
+
52
+ async ensureLoaded(): Promise<Map<string, Project>> {
53
+ if (this.projects) return this.projects;
54
+ const map = new Map<string, Project>();
55
+ const stored = await this.ctx.storage.list({ prefix: 'project:' });
56
+ for (const [, v] of stored) {
57
+ const p = v as Project;
58
+ map.set(p.id, p);
59
+ }
60
+ this.projects = map;
61
+ return map;
62
+ }
63
+
64
+ saveProject(p: Project) {
65
+ this.ctx.storage.put(`project:${p.id}`, p);
66
+ }
67
+
68
+ async fetch(request: Request): Promise<Response> {
69
+ if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
70
+ return new Response(
71
+ 'accelr8 sync server. connect with the accelr8 CLI: npm install -g accelr8\n',
72
+ { status: 200, headers: { 'content-type': 'text/plain' } },
73
+ );
74
+ }
75
+ const pair = new (globalThis as any).WebSocketPair();
76
+ const client = pair[0], server = pair[1];
77
+ this.ctx.acceptWebSocket(server);
78
+ server.serializeAttachment(null); // no hello yet
79
+ return new Response(null, { status: 101, webSocket: client } as any);
80
+ }
81
+
82
+ // ---- helpers ----
83
+ sockets(): any[] {
84
+ return this.ctx.getWebSockets();
85
+ }
86
+ att(ws: any): Att | null {
87
+ try { return ws.deserializeAttachment(); } catch { return null; }
88
+ }
89
+ setAtt(ws: any, a: Att | null) {
90
+ ws.serializeAttachment(a);
91
+ }
92
+ sendTo(ws: any, msg: object) {
93
+ try { ws.send(JSON.stringify(msg)); } catch {}
94
+ }
95
+ peers(projectId: string, except?: any): any[] {
96
+ return this.sockets().filter((s) => s !== except && this.att(s)?.projectId === projectId);
97
+ }
98
+ onlineIds(projectId: string): string[] {
99
+ // dedupe: the same user id can be connected from several terminals
100
+ return [...new Set(
101
+ this.sockets()
102
+ .map((s) => this.att(s))
103
+ .filter((a): a is Att => !!a && a.projectId === projectId)
104
+ .map((a) => a.id),
105
+ )];
106
+ }
107
+ summaries(projects: Map<string, Project>) {
108
+ return [...projects.values()]
109
+ .sort((a, b) => b.createdAt - a.createdAt)
110
+ .map((p) => ({
111
+ id: p.id,
112
+ name: p.name,
113
+ code: p.code,
114
+ members: p.members.length,
115
+ online: this.onlineIds(p.id).length,
116
+ }));
117
+ }
118
+ shortCode(projects: Map<string, Project>): string {
119
+ let code = '';
120
+ do {
121
+ code = '';
122
+ for (let i = 0; i < 6; i++) code += ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
123
+ } while ([...projects.values()].some((p) => p.code === code));
124
+ return code;
125
+ }
126
+ broadcastProjectList(projects: Map<string, Project>) {
127
+ const list = this.summaries(projects);
128
+ for (const s of this.sockets()) {
129
+ if (this.att(s)) this.sendTo(s, { t: 'projects', list });
130
+ }
131
+ }
132
+
133
+ joinRoom(ws: any, a: Att, project: Project, projects: Map<string, Project>) {
134
+ this.leaveRoom(ws, a, projects);
135
+ a.projectId = project.id;
136
+ this.setAtt(ws, a);
137
+
138
+ const existing = project.members.find((m) => m.id === a.id);
139
+ if (!existing) {
140
+ project.members.push({ id: a.id, name: a.name, color: a.color });
141
+ this.saveProject(project);
142
+ } else if (existing.name !== a.name || existing.color !== a.color) {
143
+ existing.name = a.name;
144
+ existing.color = a.color;
145
+ this.saveProject(project);
146
+ }
147
+
148
+ this.sendTo(ws, {
149
+ t: 'joined',
150
+ project: { id: project.id, name: project.name, code: project.code },
151
+ grid: project.grid,
152
+ members: project.members,
153
+ });
154
+ this.sendTo(ws, { t: 'presence', online: this.onlineIds(project.id) });
155
+ this.sendTo(ws, { t: 'members', members: project.members });
156
+
157
+ for (const s of this.peers(project.id, ws)) {
158
+ this.sendTo(s, { t: 'members', members: project.members });
159
+ this.sendTo(s, { t: 'presence', online: this.onlineIds(project.id) });
160
+ }
161
+ this.broadcastProjectList(projects);
162
+ }
163
+
164
+ leaveRoom(ws: any, a: Att, projects: Map<string, Project>) {
165
+ const pid = a.projectId;
166
+ if (!pid) return;
167
+ a.projectId = null;
168
+ this.setAtt(ws, a);
169
+ // if the same user id is still in the room from another terminal, keep its cursor
170
+ const stillHere = this.sockets().some((s) => {
171
+ const sa = this.att(s);
172
+ return sa && sa.id === a.id && sa.projectId === pid;
173
+ });
174
+ for (const s of this.peers(pid)) {
175
+ if (!stillHere) this.sendTo(s, { t: 'cursorGone', id: a.id });
176
+ this.sendTo(s, { t: 'presence', online: this.onlineIds(pid) });
177
+ }
178
+ this.broadcastProjectList(projects);
179
+ }
180
+
181
+ async webSocketMessage(ws: any, raw: string | ArrayBuffer) {
182
+ let msg: any;
183
+ try {
184
+ msg = JSON.parse(typeof raw === 'string' ? raw : new TextDecoder().decode(raw));
185
+ } catch {
186
+ return;
187
+ }
188
+ const projects = await this.ensureLoaded();
189
+ let a = this.att(ws);
190
+
191
+ if (msg.t === 'hello') {
192
+ const u = msg.user || {};
193
+ a = {
194
+ id: String(u.id || '').slice(0, 40) || 'u_anon',
195
+ name: String(u.name || 'guest').slice(0, 16),
196
+ color: Number(u.color) || 7,
197
+ projectId: null,
198
+ };
199
+ this.setAtt(ws, a);
200
+ this.sendTo(ws, { t: 'welcome', you: { id: a.id, name: a.name, color: a.color } });
201
+ this.sendTo(ws, { t: 'projects', list: this.summaries(projects) });
202
+ return;
203
+ }
204
+ if (!a) return; // must hello first
205
+
206
+ switch (msg.t) {
207
+ case 'listProjects': {
208
+ this.sendTo(ws, { t: 'projects', list: this.summaries(projects) });
209
+ break;
210
+ }
211
+ case 'createProject': {
212
+ if (projects.size >= MAX_PROJECTS) {
213
+ this.sendTo(ws, { t: 'joinError', message: 'server full: project limit reached' });
214
+ break;
215
+ }
216
+ const name = String(msg.name || '').trim().slice(0, 40) || 'untitled';
217
+ const project: Project = {
218
+ id: crypto.randomUUID(),
219
+ name,
220
+ code: this.shortCode(projects),
221
+ createdAt: Date.now(),
222
+ members: [],
223
+ grid: {},
224
+ };
225
+ projects.set(project.id, project);
226
+ this.saveProject(project);
227
+ this.joinRoom(ws, a, project, projects);
228
+ break;
229
+ }
230
+ case 'joinProject': {
231
+ const code = String(msg.code || '').trim().toUpperCase();
232
+ const project = [...projects.values()].find((p) => p.code === code);
233
+ if (!project) {
234
+ this.sendTo(ws, { t: 'joinError', message: `no project with code ${code}` });
235
+ break;
236
+ }
237
+ if (project.members.length >= MAX_MEMBERS && !project.members.some((m) => m.id === a!.id)) {
238
+ this.sendTo(ws, { t: 'joinError', message: 'project is full' });
239
+ break;
240
+ }
241
+ this.joinRoom(ws, a, project, projects);
242
+ break;
243
+ }
244
+ case 'leaveProject': {
245
+ this.leaveRoom(ws, a, projects);
246
+ this.sendTo(ws, { t: 'projects', list: this.summaries(projects) });
247
+ break;
248
+ }
249
+ case 'cursor': {
250
+ if (!a.projectId) break;
251
+ const x = Number(msg.x), y = Number(msg.y);
252
+ if (!Number.isFinite(x) || !Number.isFinite(y)) break;
253
+ for (const s of this.peers(a.projectId, ws))
254
+ this.sendTo(s, { t: 'cursor', id: a.id, x, y });
255
+ break;
256
+ }
257
+ case 'paint': {
258
+ if (!a.projectId) break;
259
+ const project = projects.get(a.projectId);
260
+ if (!project) break;
261
+ const x = Math.floor(Number(msg.x)), y = Math.floor(Number(msg.y));
262
+ if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x >= CANVAS_W || y >= CANVAS_H) break;
263
+ const char = sanitizeChar(msg.char);
264
+ const key = `${x},${y}`;
265
+ if (char === ' ') delete project.grid[key];
266
+ else project.grid[key] = [char, a.color];
267
+ this.saveProject(project);
268
+ for (const s of this.peers(a.projectId))
269
+ this.sendTo(s, { t: 'paint', x, y, char, color: a.color });
270
+ break;
271
+ }
272
+ case 'clear': {
273
+ if (!a.projectId) break;
274
+ const project = projects.get(a.projectId);
275
+ if (!project) break;
276
+ project.grid = {};
277
+ this.saveProject(project);
278
+ for (const s of this.peers(a.projectId)) this.sendTo(s, { t: 'cleared' });
279
+ break;
280
+ }
281
+ }
282
+ }
283
+
284
+ async webSocketClose(ws: any) {
285
+ const projects = await this.ensureLoaded();
286
+ const a = this.att(ws);
287
+ if (a) this.leaveRoom(ws, a, projects);
288
+ }
289
+
290
+ async webSocketError(ws: any) {
291
+ return this.webSocketClose(ws);
292
+ }
293
+ }