detached 84b7c36

Merge pull request #71 from kitpdr/refacto/convex-backend-migration

refactor: migrate from Turso/Drizzle to Convex backend
commit
84b7c3611fe5475276c7afa3df160ca50add4335
author
KiT · · 2026-01-18 21:13 UTC
committer
GitHub · · 2026-01-18 21:13 UTC
merge of
ececade + 0021d51 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
68 files · +2915 -4512
M .github/workflows/ci.yml +2 -0
@@ -23,6 +23,8 @@ env:
2323 TURNSTILE_SECRET_KEY: '1x0000000000000000000000000000000AA'
2424 PUBLIC_TURNSTILE_SITE_KEY: '1x00000000000000000000AA'
2525 PUBLIC_DEFAULT_CHAT_ROOM_ID: '00000000-0000-0000-0000-000000000001'
26+ CONVEX_API_SECRET: 'dummy-convex-secret'
27+ PUBLIC_CONVEX_URL: 'https://dummy.convex.cloud'
2628
2729 jobs:
2830 check:
M .gitignore +4 -1
@@ -24,4 +24,7 @@ vite.config.ts.timestamp-*
2424
2525 docs/
2626
27-.serena/⏎̸
27+.serena/
28+
29+# Migration files (Turso to Convex)
30+migration/⏎̸
M .prettierignore +4 -0
@@ -2,3 +2,7 @@
22 package-lock.json
33 pnpm-lock.yaml
44 yarn.lock
5+
6+# Auto-generated files
7+src/convex/_generated/
8+.claude/
M README.md +20 -14
@@ -7,6 +7,7 @@ Ce projet _sandbox_, à but non lucratif, recrée l'ambiance des salons de discu
77 - SvelteKit (version 5)
88 - Node.js
99 - TypeScript
10+- Convex (base de données temps réel)
1011 - XP.css
1112 - Tailwind CSS
1213
@@ -48,33 +49,38 @@ Pour contribuer :
4849
4950 ## Base de données
5051
51-Si vous souhaitez pouvoir tester le chat en local, vous devez configurer une base de données. Le projet utilise Drizzle ORM et Turso https://turso.tech/ comme base de données. Je vous invite à en créer une pour vos tests en local et à regarder le fichier `.env.example`.
52+Le projet utilise [Convex](https://convex.dev/) comme base de données temps réel. Convex gère automatiquement les mises à jour en temps réel des messages et de la liste des utilisateurs.
5253
53-Pour générer les schémas de la base de données, exécutez la commande suivante :
54+### Configuration locale
5455
55-```bash
56-npm run db:generate
57-# ou
58-pnpm db:generate
59-```
56+1. Créez un compte sur [Convex](https://convex.dev/) et un nouveau projet
57+2. Copiez `.env.example` vers `.env.local` et remplissez les variables :
58+ - `PUBLIC_CONVEX_URL` : URL de votre déploiement Convex
59+ - `CONVEX_API_SECRET` : Clé secrète pour les appels serveur
60+
61+### Lancer Convex en développement
6062
61-Pour pousser les schémas à la base de données, exécutez la commande suivante :
63+Dans un terminal séparé, lancez le serveur Convex :
6264
6365 ```bash
64-npm run db:push
65-# ou
66-pnpm db:push
66+npx convex dev
6767 ```
6868
69-Vous pouvez avoir un aperçu du contenu de la base en utilisant :
69+Cela synchronise automatiquement vos fonctions backend avec Convex et affiche les logs en temps réel.
70+
71+### Dashboard Convex
72+
73+Vous pouvez visualiser et gérer vos données via le dashboard Convex :
7074
7175 ```bash
72-npx drizzle-kit studio
76+npx convex dashboard
7377 ```
7478
7579 ## Documentation et Ressources
7680
77-Si vous n'avez jamais utilisé Svelte/Kit, référez-vous aux documentations officielles :
81+Si vous n'avez jamais utilisé Svelte/Kit ou Convex, référez-vous aux documentations officielles :
7882
7983 - [Documentation de Svelte](https://svelte.dev/docs/)
8084 - [Documentation de SvelteKit](https://svelte.dev/docs/kit/@sveltejs-kit)
85+- [Documentation de Convex](https://docs.convex.dev/)
86+- [Convex + Svelte](https://docs.convex.dev/client/svelte)
A convex.json +3 -0
@@ -0,0 +1,3 @@
1+{
2+ "functions": "src/convex/"
3+}
D drizzle.config.ts +0 -12
@@ -1,12 +0,0 @@
1-import 'dotenv/config';
2-import { defineConfig } from 'drizzle-kit';
3-
4-export default defineConfig({
5- out: './drizzle',
6- schema: './src/lib/db/schema.ts',
7- dialect: 'turso',
8- dbCredentials: {
9- url: process.env.TURSO_DATABASE_URL as string,
10- authToken: process.env.TURSO_AUTH_TOKEN as string
11- }
12-});
D drizzle/0000_shallow_ghost_rider.sql +0 -33
@@ -1,33 +0,0 @@
1-CREATE TABLE `chat_rooms` (
2- `id` text PRIMARY KEY NOT NULL,
3- `name` text,
4- `type` text DEFAULT 'direct' NOT NULL,
5- `created_at` integer NOT NULL
6-);
7---> statement-breakpoint
8-CREATE TABLE `messages` (
9- `id` text PRIMARY KEY NOT NULL,
10- `chat_room_id` text NOT NULL,
11- `sender_id` text NOT NULL,
12- `content` text NOT NULL,
13- `type` text DEFAULT 'chat' NOT NULL,
14- `timestamp` integer NOT NULL,
15- FOREIGN KEY (`chat_room_id`) REFERENCES `chat_rooms`(`id`) ON UPDATE no action ON DELETE no action,
16- FOREIGN KEY (`sender_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
17-);
18---> statement-breakpoint
19-CREATE TABLE `users` (
20- `id` text PRIMARY KEY NOT NULL,
21- `email` text NOT NULL,
22- `password` text NOT NULL,
23- `nickname` text NOT NULL,
24- `status` text DEFAULT 'offline',
25- `avatar_url` text,
26- `created_at` integer NOT NULL,
27- `last_seen` integer
28-);
29---> statement-breakpoint
30-CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint
31-CREATE VIEW `chat_room_view` AS select "id", "name", "type", "created_at" from "chat_rooms";--> statement-breakpoint
32-CREATE VIEW `message_view` AS select "messages"."id", "messages"."content", "messages"."type", "messages"."timestamp", "users"."nickname", "chat_rooms"."name" from "messages" left join "users" on "messages"."sender_id" = "users"."id" left join "chat_rooms" on "messages"."chat_room_id" = "chat_rooms"."id";--> statement-breakpoint
33-CREATE VIEW `user_view` AS select "id", "nickname", "status", "avatar_url", "last_seen" from "users";⏎̸
D drizzle/0001_blushing_jean_grey.sql +0 -11
@@ -1,11 +0,0 @@
1-CREATE TABLE `sessions` (
2- `id` text PRIMARY KEY NOT NULL,
3- `user_id` text NOT NULL,
4- `expires_at` integer NOT NULL,
5- `created_at` integer NOT NULL,
6- FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
7-);
8---> statement-breakpoint
9-DROP INDEX `users_email_unique`;--> statement-breakpoint
10-ALTER TABLE `users` ALTER COLUMN "status" TO "status" text NOT NULL DEFAULT 'offline';--> statement-breakpoint
11-ALTER TABLE `users` DROP COLUMN `email`;⏎̸
D drizzle/meta/0000_snapshot.json +0 -332
@@ -1,332 +0,0 @@
1-{
2- "version": "6",
3- "dialect": "sqlite",
4- "id": "16b13426-2b9d-4084-8e17-71f9de0f72d0",
5- "prevId": "00000000-0000-0000-0000-000000000000",
6- "tables": {
7- "chat_rooms": {
8- "name": "chat_rooms",
9- "columns": {
10- "id": {
11- "name": "id",
12- "type": "text",
13- "primaryKey": true,
14- "notNull": true,
15- "autoincrement": false
16- },
17- "name": {
18- "name": "name",
19- "type": "text",
20- "primaryKey": false,
21- "notNull": false,
22- "autoincrement": false
23- },
24- "type": {
25- "name": "type",
26- "type": "text",
27- "primaryKey": false,
28- "notNull": true,
29- "autoincrement": false,
30- "default": "'direct'"
31- },
32- "created_at": {
33- "name": "created_at",
34- "type": "integer",
35- "primaryKey": false,
36- "notNull": true,
37- "autoincrement": false
38- }
39- },
40- "indexes": {},
41- "foreignKeys": {},
42- "compositePrimaryKeys": {},
43- "uniqueConstraints": {},
44- "checkConstraints": {}
45- },
46- "messages": {
47- "name": "messages",
48- "columns": {
49- "id": {
50- "name": "id",
51- "type": "text",
52- "primaryKey": true,
53- "notNull": true,
54- "autoincrement": false
55- },
56- "chat_room_id": {
57- "name": "chat_room_id",
58- "type": "text",
59- "primaryKey": false,
60- "notNull": true,
61- "autoincrement": false
62- },
63- "sender_id": {
64- "name": "sender_id",
65- "type": "text",
66- "primaryKey": false,
67- "notNull": true,
68- "autoincrement": false
69- },
70- "content": {
71- "name": "content",
72- "type": "text",
73- "primaryKey": false,
74- "notNull": true,
75- "autoincrement": false
76- },
77- "type": {
78- "name": "type",
79- "type": "text",
80- "primaryKey": false,
81- "notNull": true,
82- "autoincrement": false,
83- "default": "'chat'"
84- },
85- "timestamp": {
86- "name": "timestamp",
87- "type": "integer",
88- "primaryKey": false,
89- "notNull": true,
90- "autoincrement": false
91- }
92- },
93- "indexes": {},
94- "foreignKeys": {
95- "messages_chat_room_id_chat_rooms_id_fk": {
96- "name": "messages_chat_room_id_chat_rooms_id_fk",
97- "tableFrom": "messages",
98- "tableTo": "chat_rooms",
99- "columnsFrom": ["chat_room_id"],
100- "columnsTo": ["id"],
101- "onDelete": "no action",
102- "onUpdate": "no action"
103- },
104- "messages_sender_id_users_id_fk": {
105- "name": "messages_sender_id_users_id_fk",
106- "tableFrom": "messages",
107- "tableTo": "users",
108- "columnsFrom": ["sender_id"],
109- "columnsTo": ["id"],
110- "onDelete": "no action",
111- "onUpdate": "no action"
112- }
113- },
114- "compositePrimaryKeys": {},
115- "uniqueConstraints": {},
116- "checkConstraints": {}
117- },
118- "users": {
119- "name": "users",
120- "columns": {
121- "id": {
122- "name": "id",
123- "type": "text",
124- "primaryKey": true,
125- "notNull": true,
126- "autoincrement": false
127- },
128- "email": {
129- "name": "email",
130- "type": "text",
131- "primaryKey": false,
132- "notNull": true,
133- "autoincrement": false
134- },
135- "password": {
136- "name": "password",
137- "type": "text",
138- "primaryKey": false,
139- "notNull": true,
140- "autoincrement": false
141- },
142- "nickname": {
143- "name": "nickname",
144- "type": "text",
145- "primaryKey": false,
146- "notNull": true,
147- "autoincrement": false
148- },
149- "status": {
150- "name": "status",
151- "type": "text",
152- "primaryKey": false,
153- "notNull": false,
154- "autoincrement": false,
155- "default": "'offline'"
156- },
157- "avatar_url": {
158- "name": "avatar_url",
159- "type": "text",
160- "primaryKey": false,
161- "notNull": false,
162- "autoincrement": false
163- },
164- "created_at": {
165- "name": "created_at",
166- "type": "integer",
167- "primaryKey": false,
168- "notNull": true,
169- "autoincrement": false
170- },
171- "last_seen": {
172- "name": "last_seen",
173- "type": "integer",
174- "primaryKey": false,
175- "notNull": false,
176- "autoincrement": false
177- }
178- },
179- "indexes": {
180- "users_email_unique": {
181- "name": "users_email_unique",
182- "columns": ["email"],
183- "isUnique": true
184- }
185- },
186- "foreignKeys": {},
187- "compositePrimaryKeys": {},
188- "uniqueConstraints": {},
189- "checkConstraints": {}
190- }
191- },
192- "views": {
193- "chat_room_view": {
194- "columns": {
195- "id": {
196- "name": "id",
197- "type": "text",
198- "primaryKey": true,
199- "notNull": true,
200- "autoincrement": false
201- },
202- "name": {
203- "name": "name",
204- "type": "text",
205- "primaryKey": false,
206- "notNull": false,
207- "autoincrement": false
208- },
209- "type": {
210- "name": "type",
211- "type": "text",
212- "primaryKey": false,
213- "notNull": true,
214- "autoincrement": false,
215- "default": "'direct'"
216- },
217- "created_at": {
218- "name": "created_at",
219- "type": "integer",
220- "primaryKey": false,
221- "notNull": true,
222- "autoincrement": false
223- }
224- },
225- "name": "chat_room_view",
226- "isExisting": false,
227- "definition": "select \"id\", \"name\", \"type\", \"created_at\" from \"chat_rooms\""
228- },
229- "message_view": {
230- "columns": {
231- "id": {
232- "name": "id",
233- "type": "text",
234- "primaryKey": true,
235- "notNull": true,
236- "autoincrement": false
237- },
238- "content": {
239- "name": "content",
240- "type": "text",
241- "primaryKey": false,
242- "notNull": true,
243- "autoincrement": false
244- },
245- "type": {
246- "name": "type",
247- "type": "text",
248- "primaryKey": false,
249- "notNull": true,
250- "autoincrement": false,
251- "default": "'chat'"
252- },
253- "timestamp": {
254- "name": "timestamp",
255- "type": "integer",
256- "primaryKey": false,
257- "notNull": true,
258- "autoincrement": false
259- },
260- "nickname": {
261- "name": "nickname",
262- "type": "text",
263- "primaryKey": false,
264- "notNull": true,
265- "autoincrement": false
266- },
267- "name": {
268- "name": "name",
269- "type": "text",
270- "primaryKey": false,
271- "notNull": false,
272- "autoincrement": false
273- }
274- },
275- "name": "message_view",
276- "isExisting": false,
277- "definition": "select \"messages\".\"id\", \"messages\".\"content\", \"messages\".\"type\", \"messages\".\"timestamp\", \"users\".\"nickname\", \"chat_rooms\".\"name\" from \"messages\" left join \"users\" on \"messages\".\"sender_id\" = \"users\".\"id\" left join \"chat_rooms\" on \"messages\".\"chat_room_id\" = \"chat_rooms\".\"id\""
278- },
279- "user_view": {
280- "columns": {
281- "id": {
282- "name": "id",
283- "type": "text",
284- "primaryKey": true,
285- "notNull": true,
286- "autoincrement": false
287- },
288- "nickname": {
289- "name": "nickname",
290- "type": "text",
291- "primaryKey": false,
292- "notNull": true,
293- "autoincrement": false
294- },
295- "status": {
296- "name": "status",
297- "type": "text",
298- "primaryKey": false,
299- "notNull": false,
300- "autoincrement": false,
301- "default": "'offline'"
302- },
303- "avatar_url": {
304- "name": "avatar_url",
305- "type": "text",
306- "primaryKey": false,
307- "notNull": false,
308- "autoincrement": false
309- },
310- "last_seen": {
311- "name": "last_seen",
312- "type": "integer",
313- "primaryKey": false,
314- "notNull": false,
315- "autoincrement": false
316- }
317- },
318- "name": "user_view",
319- "isExisting": false,
320- "definition": "select \"id\", \"nickname\", \"status\", \"avatar_url\", \"last_seen\" from \"users\""
321- }
322- },
323- "enums": {},
324- "_meta": {
325- "schemas": {},
326- "tables": {},
327- "columns": {}
328- },
329- "internal": {
330- "indexes": {}
331- }
332-}
D drizzle/meta/0001_snapshot.json +0 -367
@@ -1,367 +0,0 @@
1-{
2- "version": "6",
3- "dialect": "sqlite",
4- "id": "9090026e-dae4-4b69-8539-471ebf6ec7e6",
5- "prevId": "16b13426-2b9d-4084-8e17-71f9de0f72d0",
6- "tables": {
7- "chat_rooms": {
8- "name": "chat_rooms",
9- "columns": {
10- "id": {
11- "name": "id",
12- "type": "text",
13- "primaryKey": true,
14- "notNull": true,
15- "autoincrement": false
16- },
17- "name": {
18- "name": "name",
19- "type": "text",
20- "primaryKey": false,
21- "notNull": false,
22- "autoincrement": false
23- },
24- "type": {
25- "name": "type",
26- "type": "text",
27- "primaryKey": false,
28- "notNull": true,
29- "autoincrement": false,
30- "default": "'direct'"
31- },
32- "created_at": {
33- "name": "created_at",
34- "type": "integer",
35- "primaryKey": false,
36- "notNull": true,
37- "autoincrement": false
38- }
39- },
40- "indexes": {},
41- "foreignKeys": {},
42- "compositePrimaryKeys": {},
43- "uniqueConstraints": {},
44- "checkConstraints": {}
45- },
46- "messages": {
47- "name": "messages",
48- "columns": {
49- "id": {
50- "name": "id",
51- "type": "text",
52- "primaryKey": true,
53- "notNull": true,
54- "autoincrement": false
55- },
56- "chat_room_id": {
57- "name": "chat_room_id",
58- "type": "text",
59- "primaryKey": false,
60- "notNull": true,
61- "autoincrement": false
62- },
63- "sender_id": {
64- "name": "sender_id",
65- "type": "text",
66- "primaryKey": false,
67- "notNull": true,
68- "autoincrement": false
69- },
70- "content": {
71- "name": "content",
72- "type": "text",
73- "primaryKey": false,
74- "notNull": true,
75- "autoincrement": false
76- },
77- "type": {
78- "name": "type",
79- "type": "text",
80- "primaryKey": false,
81- "notNull": true,
82- "autoincrement": false,
83- "default": "'chat'"
84- },
85- "timestamp": {
86- "name": "timestamp",
87- "type": "integer",
88- "primaryKey": false,
89- "notNull": true,
90- "autoincrement": false
91- }
92- },
93- "indexes": {},
94- "foreignKeys": {
95- "messages_chat_room_id_chat_rooms_id_fk": {
96- "name": "messages_chat_room_id_chat_rooms_id_fk",
97- "tableFrom": "messages",
98- "tableTo": "chat_rooms",
99- "columnsFrom": ["chat_room_id"],
100- "columnsTo": ["id"],
101- "onDelete": "no action",
102- "onUpdate": "no action"
103- },
104- "messages_sender_id_users_id_fk": {
105- "name": "messages_sender_id_users_id_fk",
106- "tableFrom": "messages",
107- "tableTo": "users",
108- "columnsFrom": ["sender_id"],
109- "columnsTo": ["id"],
110- "onDelete": "no action",
111- "onUpdate": "no action"
112- }
113- },
114- "compositePrimaryKeys": {},
115- "uniqueConstraints": {},
116- "checkConstraints": {}
117- },
118- "sessions": {
119- "name": "sessions",
120- "columns": {
121- "id": {
122- "name": "id",
123- "type": "text",
124- "primaryKey": true,
125- "notNull": true,
126- "autoincrement": false
127- },
128- "user_id": {
129- "name": "user_id",
130- "type": "text",
131- "primaryKey": false,
132- "notNull": true,
133- "autoincrement": false
134- },
135- "expires_at": {
136- "name": "expires_at",
137- "type": "integer",
138- "primaryKey": false,
139- "notNull": true,
140- "autoincrement": false
141- },
142- "created_at": {
143- "name": "created_at",
144- "type": "integer",
145- "primaryKey": false,
146- "notNull": true,
147- "autoincrement": false
148- }
149- },
150- "indexes": {},
151- "foreignKeys": {
152- "sessions_user_id_users_id_fk": {
153- "name": "sessions_user_id_users_id_fk",
154- "tableFrom": "sessions",
155- "tableTo": "users",
156- "columnsFrom": ["user_id"],
157- "columnsTo": ["id"],
158- "onDelete": "no action",
159- "onUpdate": "no action"
160- }
161- },
162- "compositePrimaryKeys": {},
163- "uniqueConstraints": {},
164- "checkConstraints": {}
165- },
166- "users": {
167- "name": "users",
168- "columns": {
169- "id": {
170- "name": "id",
171- "type": "text",
172- "primaryKey": true,
173- "notNull": true,
174- "autoincrement": false
175- },
176- "password": {
177- "name": "password",
178- "type": "text",
179- "primaryKey": false,
180- "notNull": true,
181- "autoincrement": false
182- },
183- "nickname": {
184- "name": "nickname",
185- "type": "text",
186- "primaryKey": false,
187- "notNull": true,
188- "autoincrement": false
189- },
190- "status": {
191- "name": "status",
192- "type": "text",
193- "primaryKey": false,
194- "notNull": true,
195- "autoincrement": false,
196- "default": "'offline'"
197- },
198- "avatar_url": {
199- "name": "avatar_url",
200- "type": "text",
201- "primaryKey": false,
202- "notNull": false,
203- "autoincrement": false
204- },
205- "created_at": {
206- "name": "created_at",
207- "type": "integer",
208- "primaryKey": false,
209- "notNull": true,
210- "autoincrement": false
211- },
212- "last_seen": {
213- "name": "last_seen",
214- "type": "integer",
215- "primaryKey": false,
216- "notNull": false,
217- "autoincrement": false
218- }
219- },
220- "indexes": {},
221- "foreignKeys": {},
222- "compositePrimaryKeys": {},
223- "uniqueConstraints": {},
224- "checkConstraints": {}
225- }
226- },
227- "views": {
228- "chat_room_view": {
229- "columns": {
230- "id": {
231- "name": "id",
232- "type": "text",
233- "primaryKey": true,
234- "notNull": true,
235- "autoincrement": false
236- },
237- "name": {
238- "name": "name",
239- "type": "text",
240- "primaryKey": false,
241- "notNull": false,
242- "autoincrement": false
243- },
244- "type": {
245- "name": "type",
246- "type": "text",
247- "primaryKey": false,
248- "notNull": true,
249- "autoincrement": false,
250- "default": "'direct'"
251- },
252- "created_at": {
253- "name": "created_at",
254- "type": "integer",
255- "primaryKey": false,
256- "notNull": true,
257- "autoincrement": false
258- }
259- },
260- "name": "chat_room_view",
261- "isExisting": false,
262- "definition": "select \"id\", \"name\", \"type\", \"created_at\" from \"chat_rooms\""
263- },
264- "message_view": {
265- "columns": {
266- "id": {
267- "name": "id",
268- "type": "text",
269- "primaryKey": true,
270- "notNull": true,
271- "autoincrement": false
272- },
273- "content": {
274- "name": "content",
275- "type": "text",
276- "primaryKey": false,
277- "notNull": true,
278- "autoincrement": false
279- },
280- "type": {
281- "name": "type",
282- "type": "text",
283- "primaryKey": false,
284- "notNull": true,
285- "autoincrement": false,
286- "default": "'chat'"
287- },
288- "timestamp": {
289- "name": "timestamp",
290- "type": "integer",
291- "primaryKey": false,
292- "notNull": true,
293- "autoincrement": false
294- },
295- "nickname": {
296- "name": "nickname",
297- "type": "text",
298- "primaryKey": false,
299- "notNull": true,
300- "autoincrement": false
301- },
302- "name": {
303- "name": "name",
304- "type": "text",
305- "primaryKey": false,
306- "notNull": false,
307- "autoincrement": false
308- }
309- },
310- "name": "message_view",
311- "isExisting": false,
312- "definition": "select \"messages\".\"id\", \"messages\".\"content\", \"messages\".\"type\", \"messages\".\"timestamp\", \"users\".\"nickname\", \"chat_rooms\".\"name\" from \"messages\" left join \"users\" on \"messages\".\"sender_id\" = \"users\".\"id\" left join \"chat_rooms\" on \"messages\".\"chat_room_id\" = \"chat_rooms\".\"id\""
313- },
314- "user_view": {
315- "columns": {
316- "id": {
317- "name": "id",
318- "type": "text",
319- "primaryKey": true,
320- "notNull": true,
321- "autoincrement": false
322- },
323- "nickname": {
324- "name": "nickname",
325- "type": "text",
326- "primaryKey": false,
327- "notNull": true,
328- "autoincrement": false
329- },
330- "status": {
331- "name": "status",
332- "type": "text",
333- "primaryKey": false,
334- "notNull": true,
335- "autoincrement": false,
336- "default": "'offline'"
337- },
338- "avatar_url": {
339- "name": "avatar_url",
340- "type": "text",
341- "primaryKey": false,
342- "notNull": false,
343- "autoincrement": false
344- },
345- "last_seen": {
346- "name": "last_seen",
347- "type": "integer",
348- "primaryKey": false,
349- "notNull": false,
350- "autoincrement": false
351- }
352- },
353- "name": "user_view",
354- "isExisting": false,
355- "definition": "select \"id\", \"nickname\", \"status\", \"avatar_url\", \"last_seen\" from \"users\""
356- }
357- },
358- "enums": {},
359- "_meta": {
360- "schemas": {},
361- "tables": {},
362- "columns": {}
363- },
364- "internal": {
365- "indexes": {}
366- }
367-}
D drizzle/meta/_journal.json +0 -20
@@ -1,20 +0,0 @@
1-{
2- "version": "7",
3- "dialect": "sqlite",
4- "entries": [
5- {
6- "idx": 0,
7- "version": "6",
8- "when": 1739208055141,
9- "tag": "0000_shallow_ghost_rider",
10- "breakpoints": true
11- },
12- {
13- "idx": 1,
14- "version": "6",
15- "when": 1739432549447,
16- "tag": "0001_blushing_jean_grey",
17- "breakpoints": true
18- }
19- ]
20-}
M eslint.config.js +3 -0
@@ -9,6 +9,9 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
99
1010 export default ts.config(
1111 includeIgnoreFile(gitignorePath),
12+ {
13+ ignores: ['src/convex/_generated/**']
14+ },
1215 js.configs.recommended,
1316 ...ts.configs.recommended,
1417 ...svelte.configs['flat/recommended'],
M package.json +3 -0
@@ -58,6 +58,9 @@
5858 "dependencies": {
5959 "@libsql/client": "^0.17.0",
6060 "@lucide/svelte": "^0.562.0",
61+ "convex": "^1.31.5",
62+ "convex-helpers": "^0.1.111",
63+ "convex-svelte": "^0.0.12",
6164 "dotenv": "^17.2.3",
6265 "drizzle-orm": "^0.45.1",
6366 "pino": "^10.2.0",
M pnpm-lock.yaml +83 -0 generated file: collapsed
M src/app.d.ts +2 -2
@@ -1,12 +1,12 @@
11 // See https://svelte.dev/docs/kit/types#app.d.ts
22 // for information about these interfaces
3-import type { User, Session } from '$lib/types/chat';
3+import type { SafeUser, Session } from '$lib/types/chat';
44
55 declare global {
66 namespace App {
77 // interface Error {}
88 interface Locals {
9- user: User | null;
9+ user: SafeUser | null;
1010 session: Session | null;
1111 }
1212 // interface PageData {}
A src/convex/README.md +88 -0
@@ -0,0 +1,88 @@
1+# Welcome to your Convex functions directory!
2+
3+Write your Convex functions here.
4+See https://docs.convex.dev/functions for more.
5+
6+A query function that takes two arguments looks like:
7+
8+```ts
9+// convex/myFunctions.ts
10+import { query } from './_generated/server';
11+import { v } from 'convex/values';
12+
13+export const myQueryFunction = query({
14+ // Validators for arguments.
15+ args: {
16+ first: v.number(),
17+ second: v.string()
18+ },
19+
20+ // Function implementation.
21+ handler: async (ctx, args) => {
22+ // Read the database as many times as you need here.
23+ // See https://docs.convex.dev/database/reading-data.
24+ const documents = await ctx.db.query('tablename').collect();
25+
26+ // Arguments passed from the client are properties of the args object.
27+ console.log(args.first, args.second);
28+
29+ // Write arbitrary JavaScript here: filter, aggregate, build derived data,
30+ // remove non-public properties, or create new objects.
31+ return documents;
32+ }
33+});
34+```
35+
36+Using this query function in a React component looks like:
37+
38+```ts
39+const data = useQuery(api.myFunctions.myQueryFunction, {
40+ first: 10,
41+ second: 'hello'
42+});
43+```
44+
45+A mutation function looks like:
46+
47+```ts
48+// convex/myFunctions.ts
49+import { mutation } from './_generated/server';
50+import { v } from 'convex/values';
51+
52+export const myMutationFunction = mutation({
53+ // Validators for arguments.
54+ args: {
55+ first: v.string(),
56+ second: v.string()
57+ },
58+
59+ // Function implementation.
60+ handler: async (ctx, args) => {
61+ // Insert or modify documents in the database here.
62+ // Mutations can also read from the database like queries.
63+ // See https://docs.convex.dev/database/writing-data.
64+ const message = { body: args.first, author: args.second };
65+ const id = await ctx.db.insert('messages', message);
66+
67+ // Optionally, return a value from your mutation.
68+ return await ctx.db.get('messages', id);
69+ }
70+});
71+```
72+
73+Using this mutation function in a React component looks like:
74+
75+```ts
76+const mutation = useMutation(api.myFunctions.myMutationFunction);
77+function handleButtonPress() {
78+ // fire and forget, the most common way to use mutations
79+ mutation({ first: 'Hello!', second: 'me' });
80+ // OR
81+ // use the result once the mutation has completed
82+ mutation({ first: 'Hello!', second: 'me' }).then((result) => console.log(result));
83+}
84+```
85+
86+Use the Convex CLI to push your functions to a deployment. See everything
87+the Convex CLI can do by running `npx convex -h` in your project root
88+directory. To learn more, launch the docs with `npx convex docs`.
A src/convex/_generated/api.d.ts +65 -0
@@ -0,0 +1,65 @@
1+/* eslint-disable */
2+/**
3+ * Generated `api` utility.
4+ *
5+ * THIS CODE IS AUTOMATICALLY GENERATED.
6+ *
7+ * To regenerate, run `npx convex dev`.
8+ * @module
9+ */
10+
11+import type * as auth from "../auth.js";
12+import type * as chatRoomsInternal from "../chatRoomsInternal.js";
13+import type * as http from "../http.js";
14+import type * as messagesInternal from "../messagesInternal.js";
15+import type * as mutations from "../mutations.js";
16+import type * as queries from "../queries.js";
17+import type * as sessionsInternal from "../sessionsInternal.js";
18+import type * as textPreferencesInternal from "../textPreferencesInternal.js";
19+import type * as usersInternal from "../usersInternal.js";
20+
21+import type {
22+ ApiFromModules,
23+ FilterApi,
24+ FunctionReference,
25+} from "convex/server";
26+
27+declare const fullApi: ApiFromModules<{
28+ auth: typeof auth;
29+ chatRoomsInternal: typeof chatRoomsInternal;
30+ http: typeof http;
31+ messagesInternal: typeof messagesInternal;
32+ mutations: typeof mutations;
33+ queries: typeof queries;
34+ sessionsInternal: typeof sessionsInternal;
35+ textPreferencesInternal: typeof textPreferencesInternal;
36+ usersInternal: typeof usersInternal;
37+}>;
38+
39+/**
40+ * A utility for referencing Convex functions in your app's public API.
41+ *
42+ * Usage:
43+ * ```js
44+ * const myFunctionReference = api.myModule.myFunction;
45+ * ```
46+ */
47+export declare const api: FilterApi<
48+ typeof fullApi,
49+ FunctionReference<any, "public">
50+>;
51+
52+/**
53+ * A utility for referencing Convex functions in your app's internal API.
54+ *
55+ * Usage:
56+ * ```js
57+ * const myFunctionReference = internal.myModule.myFunction;
58+ * ```
59+ */
60+export declare const internal: FilterApi<
61+ typeof fullApi,
62+ FunctionReference<any, "internal">
63+>;
64+
65+export declare const components: {};
A src/convex/_generated/api.js +23 -0
@@ -0,0 +1,23 @@
1+/* eslint-disable */
2+/**
3+ * Generated `api` utility.
4+ *
5+ * THIS CODE IS AUTOMATICALLY GENERATED.
6+ *
7+ * To regenerate, run `npx convex dev`.
8+ * @module
9+ */
10+
11+import { anyApi, componentsGeneric } from "convex/server";
12+
13+/**
14+ * A utility for referencing Convex functions in your app's API.
15+ *
16+ * Usage:
17+ * ```js
18+ * const myFunctionReference = api.myModule.myFunction;
19+ * ```
20+ */
21+export const api = anyApi;
22+export const internal = anyApi;
23+export const components = componentsGeneric();
A src/convex/_generated/dataModel.d.ts +60 -0
@@ -0,0 +1,60 @@
1+/* eslint-disable */
2+/**
3+ * Generated data model types.
4+ *
5+ * THIS CODE IS AUTOMATICALLY GENERATED.
6+ *
7+ * To regenerate, run `npx convex dev`.
8+ * @module
9+ */
10+
11+import type {
12+ DataModelFromSchemaDefinition,
13+ DocumentByName,
14+ TableNamesInDataModel,
15+ SystemTableNames,
16+} from "convex/server";
17+import type { GenericId } from "convex/values";
18+import schema from "../schema.js";
19+
20+/**
21+ * The names of all of your Convex tables.
22+ */
23+export type TableNames = TableNamesInDataModel<DataModel>;
24+
25+/**
26+ * The type of a document stored in Convex.
27+ *
28+ * @typeParam TableName - A string literal type of the table name (like "users").
29+ */
30+export type Doc<TableName extends TableNames> = DocumentByName<
31+ DataModel,
32+ TableName
33+>;
34+
35+/**
36+ * An identifier for a document in Convex.
37+ *
38+ * Convex documents are uniquely identified by their `Id`, which is accessible
39+ * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
40+ *
41+ * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
42+ *
43+ * IDs are just strings at runtime, but this type can be used to distinguish them from other
44+ * strings when type checking.
45+ *
46+ * @typeParam TableName - A string literal type of the table name (like "users").
47+ */
48+export type Id<TableName extends TableNames | SystemTableNames> =
49+ GenericId<TableName>;
50+
51+/**
52+ * A type describing your Convex data model.
53+ *
54+ * This type includes information about what tables you have, the type of
55+ * documents stored in those tables, and the indexes defined on them.
56+ *
57+ * This type is used to parameterize methods like `queryGeneric` and
58+ * `mutationGeneric` to make them type-safe.
59+ */
60+export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
A src/convex/_generated/server.d.ts +143 -0
@@ -0,0 +1,143 @@
1+/* eslint-disable */
2+/**
3+ * Generated utilities for implementing server-side Convex query and mutation functions.
4+ *
5+ * THIS CODE IS AUTOMATICALLY GENERATED.
6+ *
7+ * To regenerate, run `npx convex dev`.
8+ * @module
9+ */
10+
11+import {
12+ ActionBuilder,
13+ HttpActionBuilder,
14+ MutationBuilder,
15+ QueryBuilder,
16+ GenericActionCtx,
17+ GenericMutationCtx,
18+ GenericQueryCtx,
19+ GenericDatabaseReader,
20+ GenericDatabaseWriter,
21+} from "convex/server";
22+import type { DataModel } from "./dataModel.js";
23+
24+/**
25+ * Define a query in this Convex app's public API.
26+ *
27+ * This function will be allowed to read your Convex database and will be accessible from the client.
28+ *
29+ * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
30+ * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
31+ */
32+export declare const query: QueryBuilder<DataModel, "public">;
33+
34+/**
35+ * Define a query that is only accessible from other Convex functions (but not from the client).
36+ *
37+ * This function will be allowed to read from your Convex database. It will not be accessible from the client.
38+ *
39+ * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
40+ * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
41+ */
42+export declare const internalQuery: QueryBuilder<DataModel, "internal">;
43+
44+/**
45+ * Define a mutation in this Convex app's public API.
46+ *
47+ * This function will be allowed to modify your Convex database and will be accessible from the client.
48+ *
49+ * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
50+ * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
51+ */
52+export declare const mutation: MutationBuilder<DataModel, "public">;
53+
54+/**
55+ * Define a mutation that is only accessible from other Convex functions (but not from the client).
56+ *
57+ * This function will be allowed to modify your Convex database. It will not be accessible from the client.
58+ *
59+ * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
60+ * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
61+ */
62+export declare const internalMutation: MutationBuilder<DataModel, "internal">;
63+
64+/**
65+ * Define an action in this Convex app's public API.
66+ *
67+ * An action is a function which can execute any JavaScript code, including non-deterministic
68+ * code and code with side-effects, like calling third-party services.
69+ * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
70+ * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
71+ *
72+ * @param func - The action. It receives an {@link ActionCtx} as its first argument.
73+ * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
74+ */
75+export declare const action: ActionBuilder<DataModel, "public">;
76+
77+/**
78+ * Define an action that is only accessible from other Convex functions (but not from the client).
79+ *
80+ * @param func - The function. It receives an {@link ActionCtx} as its first argument.
81+ * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
82+ */
83+export declare const internalAction: ActionBuilder<DataModel, "internal">;
84+
85+/**
86+ * Define an HTTP action.
87+ *
88+ * The wrapped function will be used to respond to HTTP requests received
89+ * by a Convex deployment if the requests matches the path and method where
90+ * this action is routed. Be sure to route your httpAction in `convex/http.js`.
91+ *
92+ * @param func - The function. It receives an {@link ActionCtx} as its first argument
93+ * and a Fetch API `Request` object as its second.
94+ * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
95+ */
96+export declare const httpAction: HttpActionBuilder;
97+
98+/**
99+ * A set of services for use within Convex query functions.
100+ *
101+ * The query context is passed as the first argument to any Convex query
102+ * function run on the server.
103+ *
104+ * This differs from the {@link MutationCtx} because all of the services are
105+ * read-only.
106+ */
107+export type QueryCtx = GenericQueryCtx<DataModel>;
108+
109+/**
110+ * A set of services for use within Convex mutation functions.
111+ *
112+ * The mutation context is passed as the first argument to any Convex mutation
113+ * function run on the server.
114+ */
115+export type MutationCtx = GenericMutationCtx<DataModel>;
116+
117+/**
118+ * A set of services for use within Convex action functions.
119+ *
120+ * The action context is passed as the first argument to any Convex action
121+ * function run on the server.
122+ */
123+export type ActionCtx = GenericActionCtx<DataModel>;
124+
125+/**
126+ * An interface to read from the database within Convex query functions.
127+ *
128+ * The two entry points are {@link DatabaseReader.get}, which fetches a single
129+ * document by its {@link Id}, or {@link DatabaseReader.query}, which starts
130+ * building a query.
131+ */
132+export type DatabaseReader = GenericDatabaseReader<DataModel>;
133+
134+/**
135+ * An interface to read from and write to the database within Convex mutation
136+ * functions.
137+ *
138+ * Convex guarantees that all writes within a single mutation are
139+ * executed atomically, so you never have to worry about partial writes leaving
140+ * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
141+ * for the guarantees Convex provides your functions.
142+ */
143+export type DatabaseWriter = GenericDatabaseWriter<DataModel>;
A src/convex/_generated/server.js +93 -0
@@ -0,0 +1,93 @@
1+/* eslint-disable */
2+/**
3+ * Generated utilities for implementing server-side Convex query and mutation functions.
4+ *
5+ * THIS CODE IS AUTOMATICALLY GENERATED.
6+ *
7+ * To regenerate, run `npx convex dev`.
8+ * @module
9+ */
10+
11+import {
12+ actionGeneric,
13+ httpActionGeneric,
14+ queryGeneric,
15+ mutationGeneric,
16+ internalActionGeneric,
17+ internalMutationGeneric,
18+ internalQueryGeneric,
19+} from "convex/server";
20+
21+/**
22+ * Define a query in this Convex app's public API.
23+ *
24+ * This function will be allowed to read your Convex database and will be accessible from the client.
25+ *
26+ * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
27+ * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
28+ */
29+export const query = queryGeneric;
30+
31+/**
32+ * Define a query that is only accessible from other Convex functions (but not from the client).
33+ *
34+ * This function will be allowed to read from your Convex database. It will not be accessible from the client.
35+ *
36+ * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
37+ * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
38+ */
39+export const internalQuery = internalQueryGeneric;
40+
41+/**
42+ * Define a mutation in this Convex app's public API.
43+ *
44+ * This function will be allowed to modify your Convex database and will be accessible from the client.
45+ *
46+ * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
47+ * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
48+ */
49+export const mutation = mutationGeneric;
50+
51+/**
52+ * Define a mutation that is only accessible from other Convex functions (but not from the client).
53+ *
54+ * This function will be allowed to modify your Convex database. It will not be accessible from the client.
55+ *
56+ * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
57+ * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
58+ */
59+export const internalMutation = internalMutationGeneric;
60+
61+/**
62+ * Define an action in this Convex app's public API.
63+ *
64+ * An action is a function which can execute any JavaScript code, including non-deterministic
65+ * code and code with side-effects, like calling third-party services.
66+ * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
67+ * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
68+ *
69+ * @param func - The action. It receives an {@link ActionCtx} as its first argument.
70+ * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
71+ */
72+export const action = actionGeneric;
73+
74+/**
75+ * Define an action that is only accessible from other Convex functions (but not from the client).
76+ *
77+ * @param func - The function. It receives an {@link ActionCtx} as its first argument.
78+ * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
79+ */
80+export const internalAction = internalActionGeneric;
81+
82+/**
83+ * Define an HTTP action.
84+ *
85+ * The wrapped function will be used to respond to HTTP requests received
86+ * by a Convex deployment if the requests matches the path and method where
87+ * this action is routed. Be sure to route your httpAction in `convex/http.js`.
88+ *
89+ * @param func - The function. It receives an {@link ActionCtx} as its first argument
90+ * and a Fetch API `Request` object as its second.
91+ * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
92+ */
93+export const httpAction = httpActionGeneric;
A src/convex/auth.ts +53 -0
@@ -0,0 +1,53 @@
1+import { query, mutation } from './_generated/server';
2+import { customQuery, customMutation } from 'convex-helpers/server/customFunctions';
3+import { v } from 'convex/values';
4+import type { Doc } from './_generated/dataModel';
5+import type { QueryCtx, MutationCtx } from './_generated/server';
6+
7+// Helper to validate session and get user
8+async function validateSession(
9+ ctx: QueryCtx | MutationCtx,
10+ tokenHash: string
11+): Promise<{ session: Doc<'sessions'>; user: Doc<'users'> }> {
12+ const session = await ctx.db
13+ .query('sessions')
14+ .withIndex('by_tokenHash', (q) => q.eq('tokenHash', tokenHash))
15+ .first();
16+
17+ if (!session) {
18+ throw new Error('Invalid session');
19+ }
20+
21+ if (session.expiresAt < Date.now()) {
22+ throw new Error('Session expired');
23+ }
24+
25+ const user = await ctx.db.get(session.userId);
26+ if (!user) {
27+ throw new Error('User not found');
28+ }
29+
30+ return { session, user };
31+}
32+
33+// Authenticated query - requires tokenHash, provides user in context
34+export const authQuery = customQuery(query, {
35+ args: { tokenHash: v.string() },
36+ input: async (ctx, { tokenHash }) => {
37+ const { session, user } = await validateSession(ctx, tokenHash);
38+ return { ctx: { session, user }, args: {} };
39+ }
40+});
41+
42+// Authenticated mutation - requires tokenHash, provides user in context
43+export const authMutation = customMutation(mutation, {
44+ args: { tokenHash: v.string() },
45+ input: async (ctx, { tokenHash }) => {
46+ const { session, user } = await validateSession(ctx, tokenHash);
47+ return { ctx: { session, user }, args: {} };
48+ }
49+});
50+
51+// Type helpers for use in handlers
52+export type AuthQueryCtx = QueryCtx & { session: Doc<'sessions'>; user: Doc<'users'> };
53+export type AuthMutationCtx = MutationCtx & { session: Doc<'sessions'>; user: Doc<'users'> };
A src/convex/chatRoomsInternal.ts +58 -0
@@ -0,0 +1,58 @@
1+import { internalQuery, internalMutation } from './_generated/server';
2+import { v } from 'convex/values';
3+
4+// Get chat room by ID (internal)
5+export const getById = internalQuery({
6+ args: { id: v.id('chatRooms') },
7+ handler: async (ctx, args) => {
8+ return await ctx.db.get(args.id);
9+ }
10+});
11+
12+// List all chat rooms (internal)
13+export const list = internalQuery({
14+ args: {},
15+ handler: async (ctx) => {
16+ return await ctx.db.query('chatRooms').collect();
17+ }
18+});
19+
20+// Create a chat room (internal)
21+export const create = internalMutation({
22+ args: {
23+ name: v.optional(v.string()),
24+ type: v.string()
25+ },
26+ handler: async (ctx, args) => {
27+ const roomId = await ctx.db.insert('chatRooms', {
28+ name: args.name,
29+ type: args.type,
30+ createdAt: Date.now()
31+ });
32+
33+ return await ctx.db.get(roomId);
34+ }
35+});
36+
37+// Get or create the default "General" room (internal)
38+export const getOrCreateDefault = internalMutation({
39+ args: {},
40+ handler: async (ctx) => {
41+ // Look for existing General room
42+ const rooms = await ctx.db.query('chatRooms').collect();
43+ const generalRoom = rooms.find((r) => r.name === 'General' && r.type === 'group');
44+
45+ if (generalRoom) {
46+ return generalRoom;
47+ }
48+
49+ // Create default room
50+ const roomId = await ctx.db.insert('chatRooms', {
51+ name: 'General',
52+ type: 'group',
53+ createdAt: Date.now()
54+ });
55+
56+ return await ctx.db.get(roomId);
57+ }
58+});
A src/convex/http.ts +392 -0
@@ -0,0 +1,392 @@
1+import { httpRouter } from 'convex/server';
2+import { httpAction } from './_generated/server';
3+import { internal } from './_generated/api';
4+import type { Id } from './_generated/dataModel';
5+
6+const http = httpRouter();
7+
8+// Validate the secret header for server-to-server calls
9+function validateSecret(request: Request): boolean {
10+ const secret = request.headers.get('X-Convex-Secret');
11+ const expectedSecret = process.env.CONVEX_API_SECRET;
12+
13+ if (!expectedSecret) {
14+ console.error('CONVEX_API_SECRET environment variable not set');
15+ return false;
16+ }
17+
18+ return secret === expectedSecret;
19+}
20+
21+// Generic error response
22+function unauthorizedResponse() {
23+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
24+ status: 401,
25+ headers: { 'Content-Type': 'application/json' }
26+ });
27+}
28+
29+function errorResponse(message: string, status = 400) {
30+ return new Response(JSON.stringify({ error: message }), {
31+ status,
32+ headers: { 'Content-Type': 'application/json' }
33+ });
34+}
35+
36+// Helper function to safely parse JSON body
37+async function parseJsonBody<T>(request: Request): Promise<T | null> {
38+ try {
39+ return (await request.json()) as T;
40+ } catch {
41+ return null;
42+ }
43+}
44+
45+// ============ USERS (server-side only) ============
46+
47+// Get user by nickname - for login
48+http.route({
49+ path: '/api/users/getByNickname',
50+ method: 'POST',
51+ handler: httpAction(async (ctx, request) => {
52+ if (!validateSecret(request)) return unauthorizedResponse();
53+
54+ const body = await parseJsonBody<{ nickname: string }>(request);
55+ if (!body) return errorResponse('Invalid JSON', 400);
56+
57+ const user = await ctx.runQuery(internal.usersInternal.getByNickname, {
58+ nickname: body.nickname
59+ });
60+
61+ return new Response(JSON.stringify(user), {
62+ headers: { 'Content-Type': 'application/json' }
63+ });
64+ })
65+});
66+
67+// Create user - for registration
68+http.route({
69+ path: '/api/users/create',
70+ method: 'POST',
71+ handler: httpAction(async (ctx, request) => {
72+ if (!validateSecret(request)) return unauthorizedResponse();
73+
74+ const body = await parseJsonBody<{ nickname: string; password: string }>(request);
75+ if (!body) return errorResponse('Invalid JSON', 400);
76+
77+ try {
78+ const userId = await ctx.runMutation(internal.usersInternal.create, {
79+ nickname: body.nickname,
80+ password: body.password
81+ });
82+ return new Response(JSON.stringify({ userId }), {
83+ headers: { 'Content-Type': 'application/json' }
84+ });
85+ } catch (e) {
86+ return errorResponse(e instanceof Error ? e.message : 'Failed to create user');
87+ }
88+ })
89+});
90+
91+// Get user by ID
92+http.route({
93+ path: '/api/users/getById',
94+ method: 'POST',
95+ handler: httpAction(async (ctx, request) => {
96+ if (!validateSecret(request)) return unauthorizedResponse();
97+
98+ const body = await parseJsonBody<{ id: string }>(request);
99+ if (!body) return errorResponse('Invalid JSON', 400);
100+
101+ const user = await ctx.runQuery(internal.usersInternal.getById, { id: body.id as Id<'users'> });
102+
103+ return new Response(JSON.stringify(user), {
104+ headers: { 'Content-Type': 'application/json' }
105+ });
106+ })
107+});
108+
109+// List all users
110+http.route({
111+ path: '/api/users/list',
112+ method: 'POST',
113+ handler: httpAction(async (ctx, request) => {
114+ if (!validateSecret(request)) return unauthorizedResponse();
115+
116+ const users = await ctx.runQuery(internal.usersInternal.list);
117+
118+ return new Response(JSON.stringify(users), {
119+ headers: { 'Content-Type': 'application/json' }
120+ });
121+ })
122+});
123+
124+// Update user status
125+http.route({
126+ path: '/api/users/updateStatus',
127+ method: 'POST',
128+ handler: httpAction(async (ctx, request) => {
129+ if (!validateSecret(request)) return unauthorizedResponse();
130+
131+ const body = await parseJsonBody<{ userId: string; status: string }>(request);
132+ if (!body) return errorResponse('Invalid JSON', 400);
133+
134+ try {
135+ const user = await ctx.runMutation(internal.usersInternal.updateStatus, {
136+ id: body.userId as Id<'users'>,
137+ status: body.status
138+ });
139+ return new Response(JSON.stringify(user), {
140+ headers: { 'Content-Type': 'application/json' }
141+ });
142+ } catch (e) {
143+ return errorResponse(e instanceof Error ? e.message : 'Failed to update status');
144+ }
145+ })
146+});
147+
148+// Set all users offline (server startup)
149+http.route({
150+ path: '/api/users/setAllOffline',
151+ method: 'POST',
152+ handler: httpAction(async (ctx, request) => {
153+ if (!validateSecret(request)) return unauthorizedResponse();
154+
155+ const result = await ctx.runMutation(internal.usersInternal.setAllOffline);
156+
157+ return new Response(JSON.stringify(result), {
158+ headers: { 'Content-Type': 'application/json' }
159+ });
160+ })
161+});
162+
163+// ============ SESSIONS (server-side only) ============
164+
165+// Create session - for login
166+http.route({
167+ path: '/api/sessions/create',
168+ method: 'POST',
169+ handler: httpAction(async (ctx, request) => {
170+ if (!validateSecret(request)) return unauthorizedResponse();
171+
172+ const body = await parseJsonBody<{ tokenHash: string; userId: string; expiresAt: number }>(
173+ request
174+ );
175+ if (!body) return errorResponse('Invalid JSON', 400);
176+
177+ try {
178+ const result = await ctx.runMutation(internal.sessionsInternal.create, {
179+ tokenHash: body.tokenHash,
180+ userId: body.userId as Id<'users'>,
181+ expiresAt: body.expiresAt
182+ });
183+ return new Response(JSON.stringify(result), {
184+ headers: { 'Content-Type': 'application/json' }
185+ });
186+ } catch (e) {
187+ return errorResponse(e instanceof Error ? e.message : 'Failed to create session');
188+ }
189+ })
190+});
191+
192+// Validate session
193+http.route({
194+ path: '/api/sessions/validate',
195+ method: 'POST',
196+ handler: httpAction(async (ctx, request) => {
197+ if (!validateSecret(request)) return unauthorizedResponse();
198+
199+ const body = await parseJsonBody<{ tokenHash: string }>(request);
200+ if (!body) return errorResponse('Invalid JSON', 400);
201+
202+ const result = await ctx.runQuery(internal.sessionsInternal.validateAndGetUser, {
203+ tokenHash: body.tokenHash
204+ });
205+
206+ return new Response(JSON.stringify(result), {
207+ headers: { 'Content-Type': 'application/json' }
208+ });
209+ })
210+});
211+
212+// Remove session - for logout
213+http.route({
214+ path: '/api/sessions/remove',
215+ method: 'POST',
216+ handler: httpAction(async (ctx, request) => {
217+ if (!validateSecret(request)) return unauthorizedResponse();
218+
219+ const body = await parseJsonBody<{ tokenHash: string }>(request);
220+ if (!body) return errorResponse('Invalid JSON', 400);
221+
222+ await ctx.runMutation(internal.sessionsInternal.remove, { tokenHash: body.tokenHash });
223+
224+ return new Response(JSON.stringify({ success: true }), {
225+ headers: { 'Content-Type': 'application/json' }
226+ });
227+ })
228+});
229+
230+// ============ CHAT ROOMS (server-side only) ============
231+
232+// Get or create default room
233+http.route({
234+ path: '/api/chatRooms/getOrCreateDefault',
235+ method: 'POST',
236+ handler: httpAction(async (ctx, request) => {
237+ if (!validateSecret(request)) return unauthorizedResponse();
238+
239+ const room = await ctx.runMutation(internal.chatRoomsInternal.getOrCreateDefault);
240+
241+ return new Response(JSON.stringify(room), {
242+ headers: { 'Content-Type': 'application/json' }
243+ });
244+ })
245+});
246+
247+// Get room by ID
248+http.route({
249+ path: '/api/chatRooms/getById',
250+ method: 'POST',
251+ handler: httpAction(async (ctx, request) => {
252+ if (!validateSecret(request)) return unauthorizedResponse();
253+
254+ const body = await parseJsonBody<{ id: string }>(request);
255+ if (!body) return errorResponse('Invalid JSON', 400);
256+
257+ const room = await ctx.runQuery(internal.chatRoomsInternal.getById, {
258+ id: body.id as Id<'chatRooms'>
259+ });
260+
261+ return new Response(JSON.stringify(room), {
262+ headers: { 'Content-Type': 'application/json' }
263+ });
264+ })
265+});
266+
267+// ============ MESSAGES (server-side only) ============
268+
269+// Get messages by room - for initial load
270+http.route({
271+ path: '/api/messages/getByRoom',
272+ method: 'POST',
273+ handler: httpAction(async (ctx, request) => {
274+ if (!validateSecret(request)) return unauthorizedResponse();
275+
276+ const body = await parseJsonBody<{ roomId: string; limit?: number; beforeTimestamp?: number }>(
277+ request
278+ );
279+ if (!body) return errorResponse('Invalid JSON', 400);
280+
281+ const result = await ctx.runQuery(internal.messagesInternal.getByRoom, {
282+ roomId: body.roomId as Id<'chatRooms'>,
283+ limit: body.limit,
284+ beforeTimestamp: body.beforeTimestamp
285+ });
286+
287+ return new Response(JSON.stringify(result), {
288+ headers: { 'Content-Type': 'application/json' }
289+ });
290+ })
291+});
292+
293+// Send message
294+http.route({
295+ path: '/api/messages/send',
296+ method: 'POST',
297+ handler: httpAction(async (ctx, request) => {
298+ if (!validateSecret(request)) return unauthorizedResponse();
299+
300+ const body = await parseJsonBody<{
301+ chatRoomId: string;
302+ senderId: string;
303+ content: string;
304+ type: string;
305+ styleData?: string;
306+ hasFormatting?: boolean;
307+ timestamp?: number;
308+ }>(request);
309+ if (!body) return errorResponse('Invalid JSON', 400);
310+
311+ try {
312+ const message = await ctx.runMutation(internal.messagesInternal.send, {
313+ chatRoomId: body.chatRoomId as Id<'chatRooms'>,
314+ senderId: body.senderId as Id<'users'>,
315+ content: body.content,
316+ type: body.type,
317+ styleData: body.styleData,
318+ hasFormatting: body.hasFormatting,
319+ timestamp: body.timestamp
320+ });
321+ return new Response(JSON.stringify(message), {
322+ headers: { 'Content-Type': 'application/json' }
323+ });
324+ } catch (e) {
325+ return errorResponse(e instanceof Error ? e.message : 'Failed to send message');
326+ }
327+ })
328+});
329+
330+// ============ TEXT PREFERENCES (server-side only) ============
331+
332+// Get user text preferences
333+http.route({
334+ path: '/api/textPreferences/get',
335+ method: 'POST',
336+ handler: httpAction(async (ctx, request) => {
337+ if (!validateSecret(request)) return unauthorizedResponse();
338+
339+ const body = await parseJsonBody<{ userId: string }>(request);
340+ if (!body) return errorResponse('Invalid JSON', 400);
341+
342+ const preferences = await ctx.runQuery(internal.textPreferencesInternal.getByUserId, {
343+ userId: body.userId as Id<'users'>
344+ });
345+
346+ return new Response(JSON.stringify(preferences), {
347+ headers: { 'Content-Type': 'application/json' }
348+ });
349+ })
350+});
351+
352+// Save user text preferences
353+http.route({
354+ path: '/api/textPreferences/save',
355+ method: 'POST',
356+ handler: httpAction(async (ctx, request) => {
357+ if (!validateSecret(request)) return unauthorizedResponse();
358+
359+ const body = await parseJsonBody<{
360+ userId: string;
361+ defaultStyle: {
362+ fontFamily: string;
363+ fontSize: number;
364+ color?: string;
365+ gradient?: string[];
366+ bold: boolean;
367+ italic: boolean;
368+ underline: boolean;
369+ strikethrough: boolean;
370+ };
371+ allowFormatting: boolean;
372+ maxMessageLength: number;
373+ }>(request);
374+ if (!body) return errorResponse('Invalid JSON', 400);
375+
376+ try {
377+ const result = await ctx.runMutation(internal.textPreferencesInternal.upsert, {
378+ userId: body.userId as Id<'users'>,
379+ defaultStyle: body.defaultStyle,
380+ allowFormatting: body.allowFormatting,
381+ maxMessageLength: body.maxMessageLength
382+ });
383+ return new Response(JSON.stringify({ success: true, id: result }), {
384+ headers: { 'Content-Type': 'application/json' }
385+ });
386+ } catch (e) {
387+ return errorResponse(e instanceof Error ? e.message : 'Failed to save preferences');
388+ }
389+ })
390+});
391+
392+export default http;
A src/convex/messagesInternal.ts +91 -0
@@ -0,0 +1,91 @@
1+import { internalQuery, internalMutation } from './_generated/server';
2+import { v } from 'convex/values';
3+
4+// Get messages by chat room ID (internal)
5+export const getByRoom = internalQuery({
6+ args: {
7+ roomId: v.id('chatRooms'),
8+ limit: v.optional(v.number()),
9+ beforeTimestamp: v.optional(v.number())
10+ },
11+ handler: async (ctx, args) => {
12+ const limit = args.limit ?? 100;
13+
14+ const allMessages = await ctx.db
15+ .query('messages')
16+ .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId))
17+ .collect();
18+
19+ // Filter by timestamp if provided
20+ let filtered = allMessages;
21+ if (args.beforeTimestamp) {
22+ filtered = allMessages.filter((m) => m.timestamp < args.beforeTimestamp!);
23+ }
24+
25+ // Sort by timestamp descending and limit
26+ filtered.sort((a, b) => b.timestamp - a.timestamp);
27+ const messages = filtered.slice(0, limit);
28+
29+ return {
30+ messages,
31+ hasMore: filtered.length > limit
32+ };
33+ }
34+});
35+
36+// Send message (internal)
37+export const send = internalMutation({
38+ args: {
39+ chatRoomId: v.id('chatRooms'),
40+ senderId: v.id('users'),
41+ content: v.string(),
42+ type: v.optional(v.string()),
43+ styleData: v.optional(v.string()),
44+ hasFormatting: v.optional(v.boolean()),
45+ timestamp: v.optional(v.number())
46+ },
47+ handler: async (ctx, args) => {
48+ // Verify room exists
49+ const room = await ctx.db.get(args.chatRoomId);
50+ if (!room) {
51+ throw new Error('Chat room not found');
52+ }
53+
54+ // Verify user exists
55+ const user = await ctx.db.get(args.senderId);
56+ if (!user) {
57+ throw new Error('User not found');
58+ }
59+
60+ const messageId = await ctx.db.insert('messages', {
61+ chatRoomId: args.chatRoomId,
62+ senderId: args.senderId,
63+ content: args.content,
64+ type: args.type ?? 'chat',
65+ timestamp: args.timestamp ?? Date.now(),
66+ styleData: args.styleData,
67+ hasFormatting: args.hasFormatting ?? false
68+ });
69+
70+ return await ctx.db.get(messageId);
71+ }
72+});
73+
74+// Get recent messages across all rooms (internal) - for SSE polling
75+export const getRecent = internalQuery({
76+ args: {
77+ sinceTimestamp: v.number(),
78+ limit: v.optional(v.number())
79+ },
80+ handler: async (ctx, args) => {
81+ const limit = args.limit ?? 50;
82+
83+ const messages = await ctx.db
84+ .query('messages')
85+ .withIndex('by_timestamp')
86+ .filter((q) => q.gt(q.field('timestamp'), args.sinceTimestamp))
87+ .take(limit);
88+
89+ return messages;
90+ }
91+});
A src/convex/mutations.ts +115 -0
@@ -0,0 +1,115 @@
1+import { mutation } from './_generated/server';
2+import { authMutation } from './auth';
3+import { v } from 'convex/values';
4+
5+// Authenticated mutation: Send a message
6+export const sendMessage = authMutation({
7+ args: {
8+ chatRoomId: v.id('chatRooms'),
9+ content: v.string(),
10+ type: v.optional(v.string()),
11+ styleData: v.optional(v.string()),
12+ hasFormatting: v.optional(v.boolean())
13+ },
14+ handler: async (ctx, args) => {
15+ // ctx.user is provided by authMutation
16+ const user = ctx.user;
17+
18+ // Verify room exists
19+ const room = await ctx.db.get(args.chatRoomId);
20+ if (!room) {
21+ throw new Error('Chat room not found');
22+ }
23+
24+ const messageId = await ctx.db.insert('messages', {
25+ chatRoomId: args.chatRoomId,
26+ senderId: user._id,
27+ content: args.content,
28+ type: args.type ?? 'chat',
29+ timestamp: Date.now(),
30+ styleData: args.styleData,
31+ hasFormatting: args.hasFormatting ?? false
32+ });
33+
34+ const message = await ctx.db.get(messageId);
35+
36+ // Return enriched message
37+ return {
38+ id: message!._id,
39+ chatRoomId: message!.chatRoomId,
40+ senderId: message!.senderId,
41+ content: message!.content,
42+ type: message!.type,
43+ timestamp: message!.timestamp,
44+ styleData: message!.styleData,
45+ hasFormatting: message!.hasFormatting,
46+ sender: {
47+ id: user._id,
48+ nickname: user.nickname,
49+ status: user.status,
50+ avatarUrl: user.avatarUrl
51+ }
52+ };
53+ }
54+});
55+
56+// Authenticated mutation: Update current user's status
57+export const updateMyStatus = authMutation({
58+ args: {
59+ status: v.string()
60+ },
61+ handler: async (ctx, args) => {
62+ const validStatuses = ['online', 'away', 'busy', 'offline', 'idle'];
63+ if (!validStatuses.includes(args.status)) {
64+ throw new Error('Invalid status');
65+ }
66+
67+ await ctx.db.patch(ctx.user._id, {
68+ status: args.status,
69+ lastSeen: Date.now()
70+ });
71+
72+ const updated = await ctx.db.get(ctx.user._id);
73+ return {
74+ id: updated!._id,
75+ nickname: updated!.nickname,
76+ status: updated!.status,
77+ avatarUrl: updated!.avatarUrl,
78+ lastSeen: updated!.lastSeen
79+ };
80+ }
81+});
82+
83+// Public mutation: Create or get default room (can be called during init)
84+export const getOrCreateDefaultRoom = mutation({
85+ args: {},
86+ handler: async (ctx) => {
87+ // Look for existing General room
88+ const rooms = await ctx.db.query('chatRooms').collect();
89+ const generalRoom = rooms.find((r) => r.name === 'General' && r.type === 'group');
90+
91+ if (generalRoom) {
92+ return {
93+ id: generalRoom._id,
94+ name: generalRoom.name,
95+ type: generalRoom.type,
96+ createdAt: generalRoom.createdAt
97+ };
98+ }
99+
100+ // Create default room
101+ const roomId = await ctx.db.insert('chatRooms', {
102+ name: 'General',
103+ type: 'group',
104+ createdAt: Date.now()
105+ });
106+
107+ const room = await ctx.db.get(roomId);
108+ return {
109+ id: room!._id,
110+ name: room!.name,
111+ type: room!.type,
112+ createdAt: room!.createdAt
113+ };
114+ }
115+});
A src/convex/queries.ts +222 -0
@@ -0,0 +1,222 @@
1+import { query } from './_generated/server';
2+import { authQuery } from './auth';
3+import { v } from 'convex/values';
4+
5+// ============ PUBLIC QUERIES (no auth required) ============
6+
7+// Public query: Get messages for a chat room (real-time subscription)
8+export const getMessagesPublic = query({
9+ args: {
10+ roomId: v.id('chatRooms'),
11+ limit: v.optional(v.number())
12+ },
13+ handler: async (ctx, args) => {
14+ const limit = args.limit ?? 50;
15+
16+ const messages = await ctx.db
17+ .query('messages')
18+ .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId))
19+ .order('desc')
20+ .take(limit);
21+
22+ // Enrich messages with sender info
23+ const enrichedMessages = await Promise.all(
24+ messages.map(async (msg) => {
25+ const sender = await ctx.db.get(msg.senderId);
26+ return {
27+ id: msg._id,
28+ chatRoomId: msg.chatRoomId,
29+ senderId: msg.senderId,
30+ content: msg.content,
31+ type: msg.type,
32+ timestamp: msg.timestamp,
33+ styleData: msg.styleData,
34+ hasFormatting: msg.hasFormatting,
35+ sender: sender
36+ ? {
37+ id: sender._id,
38+ nickname: sender.nickname,
39+ status: sender.status,
40+ avatarUrl: sender.avatarUrl
41+ }
42+ : null
43+ };
44+ })
45+ );
46+
47+ // Return in chronological order (oldest first)
48+ return enrichedMessages.reverse();
49+ }
50+});
51+
52+// Public query: Get all users (real-time subscription for buddy list)
53+export const getUsersPublic = query({
54+ args: {},
55+ handler: async (ctx) => {
56+ const users = await ctx.db.query('users').collect();
57+
58+ // Return safe user data (no passwords)
59+ return users.map((user) => ({
60+ id: user._id,
61+ nickname: user.nickname,
62+ status: user.status,
63+ avatarUrl: user.avatarUrl,
64+ lastSeen: user.lastSeen
65+ }));
66+ }
67+});
68+
69+// Public query: Get default room
70+export const getDefaultRoomPublic = query({
71+ args: {},
72+ handler: async (ctx) => {
73+ const rooms = await ctx.db.query('chatRooms').collect();
74+ const defaultRoom = rooms.find((r) => r.name === 'General' && r.type === 'group');
75+
76+ if (defaultRoom) {
77+ return {
78+ id: defaultRoom._id,
79+ name: defaultRoom.name,
80+ type: defaultRoom.type,
81+ createdAt: defaultRoom.createdAt
82+ };
83+ }
84+
85+ // Return first group room if General doesn't exist
86+ const firstGroup = rooms.find((r) => r.type === 'group');
87+ if (firstGroup) {
88+ return {
89+ id: firstGroup._id,
90+ name: firstGroup.name,
91+ type: firstGroup.type,
92+ createdAt: firstGroup.createdAt
93+ };
94+ }
95+
96+ return null;
97+ }
98+});
99+
100+// ============ AUTHENTICATED QUERIES ============
101+
102+// Authenticated query: Get messages for a chat room (real-time subscription)
103+export const getMessages = authQuery({
104+ args: {
105+ roomId: v.id('chatRooms'),
106+ limit: v.optional(v.number())
107+ },
108+ handler: async (ctx, args) => {
109+ const limit = args.limit ?? 100;
110+
111+ const messages = await ctx.db
112+ .query('messages')
113+ .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId))
114+ .order('desc')
115+ .take(limit);
116+
117+ // Enrich messages with sender info
118+ const enrichedMessages = await Promise.all(
119+ messages.map(async (msg) => {
120+ const sender = await ctx.db.get(msg.senderId);
121+ return {
122+ id: msg._id,
123+ chatRoomId: msg.chatRoomId,
124+ senderId: msg.senderId,
125+ content: msg.content,
126+ type: msg.type,
127+ timestamp: msg.timestamp,
128+ styleData: msg.styleData,
129+ hasFormatting: msg.hasFormatting,
130+ sender: sender
131+ ? {
132+ id: sender._id,
133+ nickname: sender.nickname,
134+ status: sender.status,
135+ avatarUrl: sender.avatarUrl
136+ }
137+ : null
138+ };
139+ })
140+ );
141+
142+ // Return in chronological order (oldest first)
143+ return enrichedMessages.reverse();
144+ }
145+});
146+
147+// Authenticated query: Get all users (real-time subscription for buddy list)
148+export const getUsers = authQuery({
149+ args: {},
150+ handler: async (ctx) => {
151+ const users = await ctx.db.query('users').collect();
152+
153+ // Return safe user data (no passwords)
154+ return users.map((user) => ({
155+ id: user._id,
156+ nickname: user.nickname,
157+ status: user.status,
158+ avatarUrl: user.avatarUrl,
159+ lastSeen: user.lastSeen
160+ }));
161+ }
162+});
163+
164+// Authenticated query: Get chat rooms
165+export const getChatRooms = authQuery({
166+ args: {},
167+ handler: async (ctx) => {
168+ const rooms = await ctx.db.query('chatRooms').collect();
169+ return rooms.map((room) => ({
170+ id: room._id,
171+ name: room.name,
172+ type: room.type,
173+ createdAt: room.createdAt
174+ }));
175+ }
176+});
177+
178+// Authenticated query: Get default room (or first group room)
179+export const getDefaultRoom = authQuery({
180+ args: {},
181+ handler: async (ctx) => {
182+ const rooms = await ctx.db.query('chatRooms').collect();
183+ const defaultRoom = rooms.find((r) => r.name === 'General' && r.type === 'group');
184+
185+ if (defaultRoom) {
186+ return {
187+ id: defaultRoom._id,
188+ name: defaultRoom.name,
189+ type: defaultRoom.type,
190+ createdAt: defaultRoom.createdAt
191+ };
192+ }
193+
194+ // Return first group room if General doesn't exist
195+ const firstGroup = rooms.find((r) => r.type === 'group');
196+ if (firstGroup) {
197+ return {
198+ id: firstGroup._id,
199+ name: firstGroup.name,
200+ type: firstGroup.type,
201+ createdAt: firstGroup.createdAt
202+ };
203+ }
204+
205+ return null;
206+ }
207+});
208+
209+// Authenticated query: Get current user info
210+export const getCurrentUser = authQuery({
211+ args: {},
212+ handler: async (ctx) => {
213+ // ctx.user is provided by authQuery
214+ return {
215+ id: ctx.user._id,
216+ nickname: ctx.user.nickname,
217+ status: ctx.user.status,
218+ avatarUrl: ctx.user.avatarUrl,
219+ lastSeen: ctx.user.lastSeen
220+ };
221+ }
222+});
A src/convex/schema.ts +58 -0
@@ -0,0 +1,58 @@
1+import { defineSchema, defineTable } from 'convex/server';
2+import { v } from 'convex/values';
3+
4+export default defineSchema({
5+ users: defineTable({
6+ password: v.string(),
7+ nickname: v.string(),
8+ status: v.string(), // 'offline' | 'online' | 'away' | 'busy'
9+ avatarUrl: v.optional(v.string()),
10+ createdAt: v.number(),
11+ lastSeen: v.optional(v.number())
12+ }).index('by_nickname', ['nickname']),
13+
14+ sessions: defineTable({
15+ tokenHash: v.string(), // SHA-256 hash of the session token
16+ userId: v.id('users'),
17+ expiresAt: v.number(),
18+ createdAt: v.number()
19+ })
20+ .index('by_tokenHash', ['tokenHash'])
21+ .index('by_userId', ['userId']),
22+
23+ chatRooms: defineTable({
24+ name: v.optional(v.string()),
25+ type: v.string(), // 'direct' | 'group'
26+ createdAt: v.number()
27+ }),
28+
29+ messages: defineTable({
30+ chatRoomId: v.id('chatRooms'),
31+ senderId: v.id('users'),
32+ content: v.string(),
33+ type: v.string(), // 'chat' | 'emote' | 'system'
34+ timestamp: v.number(),
35+ styleData: v.optional(v.string()),
36+ hasFormatting: v.optional(v.boolean())
37+ })
38+ .index('by_chatRoom', ['chatRoomId'])
39+ .index('by_timestamp', ['timestamp']),
40+
41+ userTextPreferences: defineTable({
42+ userId: v.id('users'),
43+ defaultStyle: v.object({
44+ fontFamily: v.string(),
45+ fontSize: v.number(),
46+ color: v.optional(v.string()),
47+ gradient: v.optional(v.array(v.string())),
48+ bold: v.boolean(),
49+ italic: v.boolean(),
50+ underline: v.boolean(),
51+ strikethrough: v.boolean()
52+ }),
53+ allowFormatting: v.boolean(),
54+ maxMessageLength: v.number(),
55+ createdAt: v.number(),
56+ updatedAt: v.number()
57+ }).index('by_userId', ['userId'])
58+});
A src/convex/sessionsInternal.ts +87 -0
@@ -0,0 +1,87 @@
1+import { internalQuery, internalMutation } from './_generated/server';
2+import { v } from 'convex/values';
3+
4+// Create a new session (internal)
5+export const create = internalMutation({
6+ args: {
7+ tokenHash: v.string(), // SHA-256 hash of the token
8+ userId: v.id('users'),
9+ expiresAt: v.number()
10+ },
11+ handler: async (ctx, args) => {
12+ const user = await ctx.db.get(args.userId);
13+ if (!user) {
14+ throw new Error('User not found');
15+ }
16+
17+ const id = await ctx.db.insert('sessions', {
18+ tokenHash: args.tokenHash,
19+ userId: args.userId,
20+ expiresAt: args.expiresAt,
21+ createdAt: Date.now()
22+ });
23+
24+ return { id, tokenHash: args.tokenHash };
25+ }
26+});
27+
28+// Validate session and get user (internal)
29+export const validateAndGetUser = internalQuery({
30+ args: { tokenHash: v.string() },
31+ handler: async (ctx, args) => {
32+ // Find session by token hash
33+ const session = await ctx.db
34+ .query('sessions')
35+ .withIndex('by_tokenHash', (q) => q.eq('tokenHash', args.tokenHash))
36+ .first();
37+
38+ if (!session) {
39+ return { session: null, user: null };
40+ }
41+
42+ // Check if expired
43+ if (session.expiresAt < Date.now()) {
44+ return { session: null, user: null };
45+ }
46+
47+ // Get the user
48+ const user = await ctx.db.get(session.userId);
49+ if (!user) {
50+ return { session: null, user: null };
51+ }
52+
53+ return { session, user };
54+ }
55+});
56+
57+// Delete a session by token hash (internal)
58+export const remove = internalMutation({
59+ args: { tokenHash: v.string() },
60+ handler: async (ctx, args) => {
61+ const session = await ctx.db
62+ .query('sessions')
63+ .withIndex('by_tokenHash', (q) => q.eq('tokenHash', args.tokenHash))
64+ .first();
65+
66+ if (session) {
67+ await ctx.db.delete(session._id);
68+ }
69+ }
70+});
71+
72+// Delete all sessions for a user (internal)
73+export const removeAllForUser = internalMutation({
74+ args: { userId: v.id('users') },
75+ handler: async (ctx, args) => {
76+ const sessions = await ctx.db
77+ .query('sessions')
78+ .withIndex('by_userId', (q) => q.eq('userId', args.userId))
79+ .collect();
80+
81+ for (const session of sessions) {
82+ await ctx.db.delete(session._id);
83+ }
84+
85+ return { deleted: sessions.length };
86+ }
87+});
A src/convex/textPreferencesInternal.ts +63 -0
@@ -0,0 +1,63 @@
1+import { internalQuery, internalMutation } from './_generated/server';
2+import { v } from 'convex/values';
3+
4+// Get user text preferences by userId
5+export const getByUserId = internalQuery({
6+ args: { userId: v.id('users') },
7+ handler: async (ctx, { userId }) => {
8+ const preferences = await ctx.db
9+ .query('userTextPreferences')
10+ .withIndex('by_userId', (q) => q.eq('userId', userId))
11+ .first();
12+
13+ return preferences;
14+ }
15+});
16+
17+// Upsert user text preferences (create or update)
18+export const upsert = internalMutation({
19+ args: {
20+ userId: v.id('users'),
21+ defaultStyle: v.object({
22+ fontFamily: v.string(),
23+ fontSize: v.number(),
24+ color: v.optional(v.string()),
25+ gradient: v.optional(v.array(v.string())),
26+ bold: v.boolean(),
27+ italic: v.boolean(),
28+ underline: v.boolean(),
29+ strikethrough: v.boolean()
30+ }),
31+ allowFormatting: v.boolean(),
32+ maxMessageLength: v.number()
33+ },
34+ handler: async (ctx, args) => {
35+ const now = Date.now();
36+
37+ // Check if preferences already exist
38+ const existing = await ctx.db
39+ .query('userTextPreferences')
40+ .withIndex('by_userId', (q) => q.eq('userId', args.userId))
41+ .first();
42+
43+ if (existing) {
44+ // Update existing preferences
45+ return await ctx.db.patch(existing._id, {
46+ defaultStyle: args.defaultStyle,
47+ allowFormatting: args.allowFormatting,
48+ maxMessageLength: args.maxMessageLength,
49+ updatedAt: now
50+ });
51+ } else {
52+ // Create new preferences
53+ return await ctx.db.insert('userTextPreferences', {
54+ userId: args.userId,
55+ defaultStyle: args.defaultStyle,
56+ allowFormatting: args.allowFormatting,
57+ maxMessageLength: args.maxMessageLength,
58+ createdAt: now,
59+ updatedAt: now
60+ });
61+ }
62+ }
63+});
A src/convex/tsconfig.json +25 -0
@@ -0,0 +1,25 @@
1+{
2+ /* This TypeScript project config describes the environment that
3+ * Convex functions run in and is used to typecheck them.
4+ * You can modify it, but some settings are required to use Convex.
5+ */
6+ "compilerOptions": {
7+ /* These settings are not required by Convex and can be modified. */
8+ "allowJs": true,
9+ "strict": true,
10+ "moduleResolution": "Bundler",
11+ "jsx": "react-jsx",
12+ "skipLibCheck": true,
13+ "allowSyntheticDefaultImports": true,
14+
15+ /* These compiler options are required by Convex */
16+ "target": "ESNext",
17+ "lib": ["ES2021", "dom"],
18+ "forceConsistentCasingInFileNames": true,
19+ "module": "ESNext",
20+ "isolatedModules": true,
21+ "noEmit": true
22+ },
23+ "include": ["./**/*"],
24+ "exclude": ["./_generated"]
25+}
A src/convex/usersInternal.ts +95 -0
@@ -0,0 +1,95 @@
1+import { internalQuery, internalMutation } from './_generated/server';
2+import { v } from 'convex/values';
3+
4+// Get user by nickname (internal)
5+export const getByNickname = internalQuery({
6+ args: { nickname: v.string() },
7+ handler: async (ctx, args) => {
8+ return await ctx.db
9+ .query('users')
10+ .withIndex('by_nickname', (q) => q.eq('nickname', args.nickname))
11+ .first();
12+ }
13+});
14+
15+// Get user by ID (internal)
16+export const getById = internalQuery({
17+ args: { id: v.id('users') },
18+ handler: async (ctx, args) => {
19+ return await ctx.db.get(args.id);
20+ }
21+});
22+
23+// Get all users (internal)
24+export const list = internalQuery({
25+ args: {},
26+ handler: async (ctx) => {
27+ return await ctx.db.query('users').collect();
28+ }
29+});
30+
31+// Create a new user (internal)
32+export const create = internalMutation({
33+ args: {
34+ nickname: v.string(),
35+ password: v.string()
36+ },
37+ handler: async (ctx, args) => {
38+ // Check if nickname already exists
39+ const existing = await ctx.db
40+ .query('users')
41+ .withIndex('by_nickname', (q) => q.eq('nickname', args.nickname))
42+ .first();
43+
44+ if (existing) {
45+ throw new Error('Username already taken');
46+ }
47+
48+ const userId = await ctx.db.insert('users', {
49+ nickname: args.nickname,
50+ password: args.password,
51+ status: 'offline',
52+ createdAt: Date.now()
53+ });
54+
55+ return userId;
56+ }
57+});
58+
59+// Update user status by ID (internal)
60+export const updateStatus = internalMutation({
61+ args: {
62+ id: v.id('users'),
63+ status: v.string()
64+ },
65+ handler: async (ctx, args) => {
66+ const validStatuses = ['online', 'away', 'busy', 'offline', 'idle'];
67+ if (!validStatuses.includes(args.status)) {
68+ throw new Error('Invalid status');
69+ }
70+
71+ const user = await ctx.db.get(args.id);
72+ if (!user) {
73+ throw new Error('User not found');
74+ }
75+
76+ await ctx.db.patch(args.id, {
77+ status: args.status,
78+ lastSeen: Date.now()
79+ });
80+
81+ return await ctx.db.get(args.id);
82+ }
83+});
84+
85+// Set all users offline (internal) - used on server startup
86+export const setAllOffline = internalMutation({
87+ args: {},
88+ handler: async (ctx) => {
89+ const users = await ctx.db.query('users').collect();
90+ for (const user of users) {
91+ await ctx.db.patch(user._id, { status: 'offline' });
92+ }
93+ return { updated: users.length };
94+ }
95+});
M src/hooks.server.ts +28 -6
@@ -1,7 +1,6 @@
11 import type { Handle } from '@sveltejs/kit';
2-import db from '$lib/db/db.server';
2+import { users } from '$lib/db/convex.server';
33 import { handleRateLimit } from '$lib/api/rate-limiter';
4-import { users } from '$lib/db/schema';
54 import { createLogger } from '$lib/utils/logger.server';
65 import { ensureDefaultChatRoom } from '$lib/utils/chat.server';
76 import { validateSessionToken } from '$lib/api/session.server';
@@ -24,11 +23,11 @@ function addSecurityHeaders(response: Response): Response {
2423 });
2524 }
2625
27-// Set all users to offline on server start
26+// Set all users to offline on server start (using Convex)
2827 async function setAllUsersOffline() {
2928 try {
30- await db.update(users).set({ status: 'offline' });
31- log.info('All users set to offline on server start');
29+ const result = await users.setAllOffline();
30+ log.info('All users set to offline on server start', { updated: result.updated });
3231 } catch (error: unknown) {
3332 log.error('Failed to set users offline on server start:', { error });
3433 }
@@ -38,7 +37,7 @@ async function setAllUsersOffline() {
3837 Promise.resolve()
3938 .then(async () => {
4039 log.info('Initializing server...');
41- await ensureDefaultChatRoom(db);
40+ await ensureDefaultChatRoom();
4241 await setAllUsersOffline();
4342 log.info('Server initialized successfully');
4443 })
@@ -50,6 +49,29 @@ Promise.resolve()
5049 export const handle: Handle = async ({ event, resolve }) => {
5150 log.debug('Handling request', { path: event.url.pathname });
5251
52+ // CSRF protection: validate Origin header for state-changing requests
53+ if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
54+ const origin = event.request.headers.get('origin');
55+ const host = event.request.headers.get('host');
56+
57+ // Block cross-origin requests (origin header is present but doesn't match host)
58+ if (origin) {
59+ try {
60+ const originUrl = new URL(origin);
61+ if (originUrl.host !== host) {
62+ log.warn('CSRF check failed - origin mismatch', {
63+ origin: originUrl.host,
64+ host
65+ });
66+ return new Response('Forbidden', { status: 403 });
67+ }
68+ } catch {
69+ log.warn('CSRF check failed - invalid origin', { origin });
70+ return new Response('Forbidden', { status: 403 });
71+ }
72+ }
73+ }
74+
5375 // Get session token from cookies
5476 const token = event.cookies.get('session') ?? null;
5577
M src/lib/api/session.server.ts +39 -48
@@ -1,7 +1,5 @@
1-import db from '../db/db.server';
2-import { sessions, users } from '../db/schema';
3-import { eq } from 'drizzle-orm';
4-import type { Session, User } from '../types/chat';
1+import { sessions } from '../db/convex.server';
2+import type { Session, SafeUser } from '../types/chat';
53 import { createLogger } from '../utils/logger.server';
64
75 const log = createLogger('session-server');
@@ -17,7 +15,6 @@ export async function sha256(message: string): Promise<string> {
1715 }
1816
1917 // Helper: Custom Base32 encoder using the standard alphabet.
20-// We use this instead of @oslojs/encoding given Cloudflare Pages compatibility.
2118 const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
2219 function base32Encode(data: Uint8Array): string {
2320 let bits = 0;
@@ -44,7 +41,7 @@ export function generateSessionToken(): string {
4441 const randomBytesArray = new Uint8Array(20);
4542 crypto.getRandomValues(randomBytesArray);
4643 const token = base32Encode(randomBytesArray);
47- log.debug('Generated session token', { token });
44+ log.debug('Generated session token', { tokenPrefix: token.slice(0, 4) + '***' });
4845 return token;
4946 }
5047
@@ -52,65 +49,59 @@ export function generateSessionToken(): string {
5249 // The session ID is the SHA-256 hash of the token.
5350 export async function createSession(token: string, userId: string): Promise<Session> {
5451 log.debug('Creating session', { userId });
55- const sessionId = await sha256(token);
52+ const tokenHash = await sha256(token);
5653 const createdAt = Date.now();
5754 const expiresAt = createdAt + 7 * 24 * 60 * 60 * 1000; // 7 days expiry
58- await db.insert(sessions).values({
59- id: sessionId,
60- userId,
61- createdAt,
62- expiresAt
63- });
64- log.debug('Session created', { sessionId });
65- return { id: sessionId, userId, createdAt, expiresAt };
55+
56+ await sessions.create(tokenHash, userId, expiresAt);
57+
58+ log.debug('Session created', { tokenHash });
59+ return { id: tokenHash, userId, createdAt, expiresAt };
6660 }
6761
6862 /**
6963 * Type distributed through our API.
7064 * Returns an object with both session and user if valid, or nulls if not.
71- * Note: This returns the full User type since it's used server-side in hooks.
7265 */
7366 export type SessionValidationResult =
74- | { session: Session; user: User }
67+ | { session: Session; user: SafeUser }
7568 | { session: null; user: null };
7669
77-// Validate a session token by converting it to its SHA‑256 hash, checking expiration, and fetching the user.
70+// Validate a session token by converting it to its SHA-256 hash, checking expiration, and fetching the user.
7871 export async function validateSessionToken(token: string): Promise<SessionValidationResult> {
7972 log.debug('Validating session token');
80- const sessionId = await sha256(token);
81- const session = await db.query.sessions.findFirst({
82- where: eq(sessions.id, sessionId)
83- });
84- if (!session) {
85- log.debug('Session not found', { sessionId });
86- return { session: null, user: null };
87- }
88- const now = Date.now();
89- if (session.expiresAt < now) {
90- log.debug('Session expired', { sessionId });
91- return { session: null, user: null };
92- }
93- const user = await db.query.users.findFirst({
94- where: eq(users.id, session.userId)
95- });
96- if (!user) {
97- log.debug('User not found for session', { sessionId });
73+ const tokenHash = await sha256(token);
74+
75+ const result = await sessions.validate(tokenHash);
76+
77+ if (!result.session || !result.user) {
78+ log.debug('Session not found or invalid', { tokenHash });
9879 return { session: null, user: null };
9980 }
100- log.debug('Session validated', { sessionId, userId: user.id });
81+
82+ // Map Convex types to our local types - using native Convex IDs
83+ const session: Session = {
84+ id: result.session.tokenHash,
85+ userId: result.user._id,
86+ createdAt: result.session.createdAt,
87+ expiresAt: result.session.expiresAt
88+ };
89+
90+ const user: SafeUser = {
91+ id: result.user._id,
92+ nickname: result.user.nickname,
93+ status: result.user.status as SafeUser['status'],
94+ avatarUrl: result.user.avatarUrl,
95+ lastSeen: result.user.lastSeen
96+ };
97+
98+ log.debug('Session validated', { tokenHash, userId: user.id });
10199 return { session, user };
102100 }
103101
104102 // Invalidate a specific session by deleting it from the database.
105-export async function invalidateSession(sessionId: string): Promise<void> {
106- log.debug('Invalidating session', { sessionId });
107- await db.delete(sessions).where(eq(sessions.id, sessionId));
108- log.debug('Session invalidated', { sessionId });
109-}
110-
111-// Invalidate all sessions for a given user.
112-export async function invalidateAllSessions(userId: string): Promise<void> {
113- log.debug('Invalidating all sessions for user', { userId });
114- await db.delete(sessions).where(eq(sessions.userId, userId));
115- log.debug('All sessions invalidated', { userId });
103+export async function invalidateSession(tokenHash: string): Promise<void> {
104+ log.debug('Invalidating session', { tokenHash });
105+ await sessions.remove(tokenHash);
106+ log.debug('Session invalidated', { tokenHash });
116107 }
D src/lib/buddyListCache.ts +0 -55
@@ -1,55 +0,0 @@
1-import { createLogger } from './utils/logger.server';
2-import type { SafeUser } from './types/chat';
3-
4-const log = createLogger('buddy-list-cache');
5-
6-class BuddyListCache {
7- private cache: SafeUser[] | null = null;
8- private lastHash: string = '';
9- private lastFetch: number = 0;
10- private isDirty: boolean = true; // Force initial fetch
11-
12- // Mark cache as needing refresh
13- invalidate(): void {
14- this.isDirty = true;
15- log.debug('Buddy list cache invalidated');
16- }
17-
18- // Check if we need to fetch new data
19- needsRefresh(): boolean {
20- return this.isDirty || this.cache === null;
21- }
22-
23- // Update cache with new data
24- update(users: SafeUser[]): boolean {
25- const newHash = JSON.stringify(users);
26- const hasChanged = newHash !== this.lastHash;
27-
28- if (hasChanged) {
29- this.cache = users;
30- this.lastHash = newHash;
31- this.lastFetch = Date.now();
32- this.isDirty = false;
33-
34- log.debug('Buddy list cache updated', {
35- userCount: users.length,
36- changed: hasChanged
37- });
38- }
39-
40- return hasChanged;
41- }
42-
43- // Get cached data
44- get(): SafeUser[] | null {
45- return this.cache;
46- }
47-
48- // Get cache age in milliseconds
49- getAge(): number {
50- return this.cache ? Date.now() - this.lastFetch : Infinity;
51- }
52-}
53-
54-// Export singleton instance
55-export const buddyListCache = new BuddyListCache();
M src/lib/components/chat-room.svelte +351 -366
@@ -1,5 +1,8 @@
11 <script lang="ts">
22 import { chatState } from '../states/chat.svelte';
3+ import { useQuery } from 'convex-svelte';
4+ import { api } from '../../convex/_generated/api';
5+ import type { Id } from '../../convex/_generated/dataModel';
36 import type { Message, EnrichedMessage, SafeUser } from '../types/chat';
47 import { onMount } from 'svelte';
58 import { browser } from '$app/environment';
@@ -22,117 +25,167 @@
2225 import { loadWindowState, saveWindowState, debounce } from '$lib/utils/chat-window-state';
2326 import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';
2427
25- // Props destructuring must come first
28+ // Props
2629 let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props();
2730
28- // Initialize with default values for SSR (800x600 - 16:10 aspect ratio)
31+ // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============
32+
33+ // Subscribe to default room (to get room ID)
34+ const defaultRoomQuery = useQuery(api.queries.getDefaultRoomPublic, {});
35+
36+ // Subscribe to users (buddy list) - real-time updates
37+ const usersQuery = useQuery(api.queries.getUsersPublic, {});
38+
39+ // Room ID from Convex query
40+ const roomId = $derived(defaultRoomQuery.data?.id as Id<'chatRooms'> | undefined);
41+
42+ // Subscribe to messages - only when room ID is available
43+ const messagesQuery = $derived(
44+ roomId ? useQuery(api.queries.getMessagesPublic, () => ({ roomId: roomId!, limit: 100 })) : null
45+ );
46+
47+ // Transform Convex users to SafeUser format
48+ const onlineUsers = $derived.by<SafeUser[]>(() => {
49+ if (!usersQuery.data) return [];
50+ return usersQuery.data
51+ .map((u) => ({
52+ id: u.id,
53+ nickname: u.nickname,
54+ status: u.status as SafeUser['status'],
55+ avatarUrl: u.avatarUrl ?? null,
56+ lastSeen: u.lastSeen ?? null
57+ }))
58+ .sort((a, b) => {
59+ const statusOrder: Record<string, number> = { online: 0, away: 1, busy: 2, offline: 3 };
60+ return (statusOrder[a.status] ?? 3) - (statusOrder[b.status] ?? 3);
61+ });
62+ });
63+
64+ // Transform Convex messages to EnrichedMessage format
65+ const messages = $derived.by<EnrichedMessage[]>(() => {
66+ if (!messagesQuery?.data) return [];
67+ return messagesQuery.data.map((msg) => ({
68+ id: msg.id,
69+ chatRoomId: msg.chatRoomId,
70+ senderId: msg.senderId,
71+ content: msg.content,
72+ type: msg.type as Message['type'],
73+ timestamp: msg.timestamp,
74+ styleData: msg.styleData,
75+ hasFormatting: msg.hasFormatting ?? false,
76+ user: msg.sender
77+ ? {
78+ id: msg.sender.id,
79+ nickname: msg.sender.nickname,
80+ status: msg.sender.status as SafeUser['status'],
81+ avatarUrl: msg.sender.avatarUrl ?? null,
82+ lastSeen: null
83+ }
84+ : {
85+ id: msg.senderId,
86+ nickname: 'Unknown User',
87+ status: 'offline' as const,
88+ avatarUrl: null,
89+ lastSeen: null
90+ }
91+ }));
92+ });
93+
94+ // Messages loaded via pagination (older history)
95+ let pagedMessages = $state<Message[]>([]);
96+
97+ // Merge paginated messages with live subscription data
98+ const allMessages = $derived.by<EnrichedMessage[]>(() => {
99+ // Using object for deduplication (avoiding Map for svelte reactivity compliance)
100+ const merged: Record<string, EnrichedMessage> = {};
101+ for (const msg of pagedMessages) {
102+ merged[msg.id] = enrichMessage(msg);
103+ }
104+ for (const msg of messages) {
105+ merged[msg.id] = msg;
106+ }
107+ return Object.values(merged).sort((a, b) => a.timestamp - b.timestamp);
108+ });
109+
110+ // Loading and error states from Convex
111+ const isInitialLoading = $derived(
112+ defaultRoomQuery.isLoading || usersQuery.isLoading || messagesQuery?.isLoading
113+ );
114+ const connectionError = $derived.by<string | null>(() => {
115+ if (defaultRoomQuery.error) return 'Erreur de connexion au salon';
116+ if (usersQuery.error) return 'Erreur de chargement des utilisateurs';
117+ if (messagesQuery?.error) return 'Erreur de chargement des messages';
118+ return null;
119+ });
120+
121+ // Update chatState with room ID when available
122+ $effect(() => {
123+ if (roomId) {
124+ chatState.setCurrentRoomId(roomId);
125+ chatState.setConnectionStatus('connected');
126+ }
127+ });
128+
129+ // ============ USER STATE ============
130+
131+ const currentUser = $derived(chatState.getCurrentUser());
132+
133+ // ============ WINDOW STATE ============
134+
29135 let windowWidth = $state(800);
30136 let windowHeight = $state(600);
31137 let windowX = $state(0);
32138 let windowY = $state(0);
33139 let isMobile = $state(false);
34- let currentMessage = $state('');
35140 let showUserList = $state(false);
36141 let isMaximized = $state(false);
37- let isInitialLoading = $state(true);
38- let showAuth = $state(false);
39142 let isMinimized = $state(false);
40- let isCentered = $state(true); // true = window stays centered on resize, false = user has manually positioned
41- let inputScrollLeft = $state(0); // Track input scroll position for gradient overlay sync
143+ let isCentered = $state(true);
42144
43- // Handle input changes - truncate and sync scroll
44- function handleInputChange(e: Event) {
45- const input = e.target as HTMLInputElement;
46- // Truncate if over limit (handles paste)
47- if (input.value.length > MAX_MESSAGE_LENGTH) {
48- input.value = input.value.slice(0, MAX_MESSAGE_LENGTH);
49- currentMessage = input.value;
50- }
51- // Sync scroll position for gradient overlay
52- inputScrollLeft = input.scrollLeft;
53- }
54-
55- function handleInputScroll(e: Event) {
56- const input = e.target as HTMLInputElement;
57- inputScrollLeft = input.scrollLeft;
58- }
145+ // ============ INPUT STATE ============
59146
60- // Text formatting state - initialize directly with merged initial style
147+ let currentMessage = $state('');
148+ let inputScrollLeft = $state(0);
61149 let currentTextStyle = $state<TextStyle>({
62150 ...DEFAULT_TEXT_STYLE,
63151 ...initialTextStyle,
64152 color: initialTextStyle.color || '#000000'
65153 });
66154
67- // Rate limiting state
155+ // ============ RATE LIMITING STATE ============
156+
68157 let cooldownEndTime = $state<number | null>(null);
69158 let cooldownProgress = $state(0);
70159 let cooldownInterval: ReturnType<typeof setInterval> | null = null;
71160 let rateLimitWarning = $state<string | null>(null);
161+ let isSendingMessage = $state(false);
72162
73- // At the top along with other variable declarations, add:
74- let roomPollInterval: ReturnType<typeof setInterval> | null = null;
75-
76- // Debounced save function for window state persistence
77- const debouncedSaveWindowState = debounce(saveWindowState, 300);
78-
79- function updateCooldownProgress() {
80- if (!cooldownEndTime) return;
81-
82- const now = Date.now();
83- if (now >= cooldownEndTime) {
84- cooldownEndTime = null;
85- cooldownProgress = 0;
86- rateLimitWarning = null;
87- if (cooldownInterval) {
88- clearInterval(cooldownInterval);
89- cooldownInterval = null;
90- }
91- return;
92- }
93-
94- cooldownProgress = (cooldownEndTime - now) / 1000;
95- }
163+ // ============ AUTH STATE ============
96164
97- function startCooldownTimer(retryAfter: number) {
98- cooldownEndTime = Date.now() + retryAfter;
99- if (cooldownInterval) clearInterval(cooldownInterval);
100- cooldownInterval = setInterval(updateCooldownProgress, 100);
101- updateCooldownProgress();
102- }
165+ let showAuth = $state(false);
103166
104- function handleClose() {
105- showChatRoom = false;
106- }
167+ // ============ SCROLL STATE ============
107168
108- // Reactive state using derived values
109- let messages = $state<EnrichedMessage[]>([]);
110- let onlineUsers = $state<SafeUser[]>([]);
111- let currentUser = $state<SafeUser | null>(null);
112169 let isLoadingMore = $state(false);
113170 let hasMoreMessages = $state(true);
114171 let oldestMessageTimestamp = $state<number | null>(null);
115- let currentRoomId = $state('');
116-
117- // Compute visible messages based on login status
118- let visibleMessages = $derived(
119- (() => {
120- const isLoggedIn = Boolean(currentUser);
121- const messageCount = messages.length;
122- const result: EnrichedMessage[] = isLoggedIn
123- ? messages
124- : messages.slice(Math.max(0, messageCount - 50));
125- return result;
126- })()
127- );
172+ let lastMessageCount = $state(0);
173+
174+ // ============ DERIVED STATE ============
175+
176+ // Visible messages (limit for non-logged-in users)
177+ const visibleMessages = $derived.by<EnrichedMessage[]>(() => {
178+ const isLoggedIn = Boolean(currentUser);
179+ return isLoggedIn ? allMessages : allMessages.slice(Math.max(0, allMessages.length - 50));
180+ });
128181
129- // Add a derived state to check if we're showing the registration prompt
130- let showRegistrationPrompt = $derived(!currentUser && messages.length > 50);
182+ // Show registration prompt for non-logged-in users
183+ const showRegistrationPrompt = $derived(!currentUser && allMessages.length > 50);
131184
132- // Character counter - show when approaching limit (80%+)
133- const CHAR_WARNING_THRESHOLD = Math.floor(MAX_MESSAGE_LENGTH * 0.8); // 400 chars
134- let showCharCounter = $derived(currentMessage.length >= CHAR_WARNING_THRESHOLD);
135- let charCounterClass = $derived(
185+ // Character counter
186+ const CHAR_WARNING_THRESHOLD = Math.floor(MAX_MESSAGE_LENGTH * 0.8);
187+ const showCharCounter = $derived(currentMessage.length >= CHAR_WARNING_THRESHOLD);
188+ const charCounterClass = $derived(
136189 currentMessage.length >= MAX_MESSAGE_LENGTH
137190 ? 'at-limit'
138191 : currentMessage.length >= MAX_MESSAGE_LENGTH * 0.95
@@ -140,244 +193,133 @@
140193 : 'warning'
141194 );
142195
143- // Add SSE error state
144- let sseError = $state<string | null>(null);
145- let sseRetryAfter = $state<number | null>(null);
196+ // User counts
197+ const totalUsers = $derived(onlineUsers.length);
198+ const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline'));
199+ const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline'));
146200
147- // Update the effect to check for SSE errors
148- $effect(() => {
149- const user = chatState.getCurrentUser();
150- if (user?.id !== currentUser?.id) {
151- currentUser = user;
152- hasMoreMessages = true; // Reset when user changes
153- }
154-
155- // Check for SSE errors
156- const { error, retryAfter } = chatState.getSSEError();
157- sseError = error;
158- sseRetryAfter = retryAfter;
201+ // ============ EFFECTS ============
159202
160- // Update messages if there's any difference
161- const stateMessages = chatState.getMessages();
162- if (JSON.stringify(stateMessages) !== JSON.stringify(messages)) {
163- messages = stateMessages;
164-
165- // Update oldest message timestamp
166- if (messages.length > 0) {
167- oldestMessageTimestamp = Math.min(...messages.map((m) => m.timestamp));
168- }
169-
170- // Scroll to bottom when new messages arrive
171- const chatArea = document.querySelector('.chat-area');
172- if (chatArea) {
173- setTimeout(() => {
174- chatArea.scrollTop = chatArea.scrollHeight;
175- }, 0);
203+ // Auto-scroll to bottom when new messages arrive
204+ $effect(() => {
205+ if (messages.length > lastMessageCount && messages.length > 0) {
206+ lastMessageCount = messages.length;
207+ if (browser) {
208+ const chatArea = document.querySelector('.chat-area');
209+ if (chatArea) {
210+ setTimeout(() => {
211+ chatArea.scrollTop = chatArea.scrollHeight;
212+ }, 0);
213+ }
176214 }
177215 }
178-
179- // Update online users
180- const stateOnlineUsers = chatState.getOnlineUsers();
181- if (JSON.stringify(stateOnlineUsers) !== JSON.stringify(onlineUsers)) {
182- onlineUsers = stateOnlineUsers;
183- }
184216 });
185217
186- // Fetch public messages only once when needed
187- let fetchedPublicMessages = false;
188- let fetchedPublicRoom = false;
189-
218+ // Update oldest timestamp for pagination
190219 $effect(() => {
191- if (!currentUser) {
192- // Fetch both public room data and messages in parallel but handle them in sequence
193- if (!fetchedPublicRoom && !fetchedPublicMessages) {
194- fetchedPublicRoom = true;
195- fetchedPublicMessages = true;
196-
197- // Get the default chat room id
198- const defaultRoomId = chatState.getDefaultChatRoomId();
199-
200- // Fetch both in parallel
201- Promise.all([
202- fetch(`/api/rooms/${defaultRoomId}?public=true`).then((r) => r.json()),
203- fetch('/api/chat/messages?public=true').then((r) => r.json())
204- ])
205- .then(([roomData, messagesData]) => {
206- if (roomData.success) {
207- // Update both the chat state and local state with the buddy list
208- chatState.updateOnlineUsers(roomData.buddyList);
209- onlineUsers = roomData.buddyList;
210- console.debug('Updated public buddy list:', roomData.buddyList);
211- }
212-
213- // Then update messages after buddy list is cached
214- if (messagesData.success && messagesData.messages) {
215- chatState.updateMessages(messagesData.messages);
216- messages = chatState.enrichMessages(messagesData.messages);
217- console.debug('Updated public messages:', messages);
218- }
219- })
220- .catch((error) => {
221- console.error('Error fetching public data:', error);
222- onlineUsers = [];
223- messages = [];
224- });
225- }
220+ if (allMessages.length > 0) {
221+ oldestMessageTimestamp = Math.min(...allMessages.map((m) => m.timestamp));
226222 }
227223 });
228224
229- onMount(() => {
230- if (!browser) return;
225+ // ============ HELPERS ============
226+
227+ function resolveUser(senderId: string): SafeUser {
228+ const knownUser = onlineUsers.find((user) => user.id === senderId);
229+ if (knownUser) return knownUser;
230+ return {
231+ id: senderId,
232+ nickname: 'Unknown User',
233+ status: 'offline',
234+ avatarUrl: null,
235+ lastSeen: null
236+ };
237+ }
231238
232- // Detect mobile first
233- isMobile = window.innerWidth <= 768;
239+ function enrichMessage(message: Message): EnrichedMessage {
240+ return {
241+ ...message,
242+ user: resolveUser(message.senderId)
243+ };
244+ }
234245
235- // Load saved state from localStorage (only for desktop)
236- if (!isMobile) {
237- const savedState = loadWindowState();
238- // Only apply saved position/size if they're within current viewport bounds
239- const maxWidth = window.innerWidth - 40;
240- const maxHeight = window.innerHeight - 40;
246+ function mergePagedMessages(incoming: Message[]) {
247+ if (incoming.length === 0) return;
248+ const existingIds = new Set(pagedMessages.map((message) => message.id));
249+ const newMessages = incoming.filter((message) => !existingIds.has(message.id));
250+ if (newMessages.length === 0) return;
251+ pagedMessages = [...pagedMessages, ...newMessages];
252+ }
241253
242- windowWidth = Math.min(savedState.width, maxWidth);
243- windowHeight = Math.min(savedState.height, maxHeight);
254+ const debouncedSaveWindowState = debounce(saveWindowState, 300);
244255
245- // Handle centering: if x or y is -1, center the window
246- if (savedState.x === -1) {
247- windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
248- } else {
249- windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth));
250- }
251- if (savedState.y === -1) {
252- windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
253- } else {
254- windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight));
256+ function updateCooldownProgress() {
257+ if (!cooldownEndTime) return;
258+ const now = Date.now();
259+ if (now >= cooldownEndTime) {
260+ cooldownEndTime = null;
261+ cooldownProgress = 0;
262+ rateLimitWarning = null;
263+ if (cooldownInterval) {
264+ clearInterval(cooldownInterval);
265+ cooldownInterval = null;
255266 }
256-
257- isMaximized = savedState.isMaximized;
258- isMinimized = savedState.isMinimized;
259- showUserList = savedState.showUserList;
260- isCentered = savedState.isCentered;
267+ return;
261268 }
269+ cooldownProgress = (cooldownEndTime - now) / 1000;
270+ }
262271
263- const handleResize = () => {
264- const wasMobile = isMobile;
265- isMobile = window.innerWidth <= 768;
266- if (isMobile) {
267- windowWidth = window.innerWidth;
268- windowHeight = window.innerHeight;
269- windowX = 0;
270- windowY = 0;
271- } else if (wasMobile && !isMobile) {
272- // Switching from mobile to desktop - reload saved state
273- const savedState = loadWindowState();
274- const maxWidth = window.innerWidth - 40;
275- const maxHeight = window.innerHeight - 40;
276- windowWidth = Math.min(savedState.width, maxWidth);
277- windowHeight = Math.min(savedState.height, maxHeight);
278-
279- // Handle centering
280- if (savedState.x === -1) {
281- windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
282- } else {
283- windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth));
284- }
285- if (savedState.y === -1) {
286- windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
287- } else {
288- windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight));
289- }
290- } else if (!isMobile) {
291- // Desktop viewport changed (zoom in/out) - re-center if isCentered, otherwise keep in bounds
292- const maxWidth = window.innerWidth - 40;
293- const maxHeight = window.innerHeight - 40;
294- windowWidth = Math.min(windowWidth, maxWidth);
295- windowHeight = Math.min(windowHeight, maxHeight);
296-
297- if (isCentered) {
298- // Keep window centered
299- windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
300- windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
301- } else {
302- // Just keep window within bounds
303- windowX = Math.max(0, Math.min(windowX, window.innerWidth - windowWidth));
304- windowY = Math.max(0, Math.min(windowY, window.innerHeight - windowHeight));
305- }
306- }
307- };
308-
309- // Only run handleResize if on mobile (desktop already loaded from localStorage)
310- if (isMobile) {
311- handleResize();
312- }
272+ function startCooldownTimer(retryAfter: number) {
273+ cooldownEndTime = Date.now() + retryAfter;
274+ if (cooldownInterval) clearInterval(cooldownInterval);
275+ cooldownInterval = setInterval(updateCooldownProgress, 100);
276+ updateCooldownProgress();
277+ }
313278
314- // Add resize listener
315- window.addEventListener('resize', handleResize);
279+ // ============ HANDLERS ============
316280
317- // ---- New: Polling for public buddy list updates ----
318- if (!currentUser) {
319- const fetchPublicRoom = async () => {
320- const defaultRoomId = chatState.getDefaultChatRoomId();
321- try {
322- const response = await fetch(`/api/rooms/${defaultRoomId}?public=true`);
323- if (response.ok) {
324- const roomData = await response.json();
325- if (roomData.success && roomData.buddyList) {
326- // Update both chat state and local onlineUsers list
327- chatState.updateOnlineUsers(roomData.buddyList);
328- onlineUsers = roomData.buddyList;
329- console.debug('Public polling updated buddy list:', roomData.buddyList);
330- }
331- }
332- } catch (error) {
333- console.error('Error fetching public buddy list:', error);
334- }
335- };
281+ function handleClose() {
282+ showChatRoom = false;
283+ }
336284
337- // Initial fetch then poll every 30 seconds
338- fetchPublicRoom();
339- roomPollInterval = setInterval(fetchPublicRoom, 30000);
285+ function handleInputChange(e: Event) {
286+ const input = e.target as HTMLInputElement;
287+ if (input.value.length > MAX_MESSAGE_LENGTH) {
288+ input.value = input.value.slice(0, MAX_MESSAGE_LENGTH);
289+ currentMessage = input.value;
340290 }
341- // -------------------------------------------------------
342-
343- return () => {
344- window.removeEventListener('resize', handleResize);
345- if (cooldownInterval) clearInterval(cooldownInterval);
346- if (roomPollInterval) clearInterval(roomPollInterval);
347- };
348- });
349-
350- // Save window state to localStorage when it changes (desktop only)
351- $effect(() => {
352- if (!browser || isMobile) return;
353-
354- // Save current state (debounced)
355- debouncedSaveWindowState({
356- width: windowWidth,
357- height: windowHeight,
358- x: windowX,
359- y: windowY,
360- isMaximized,
361- isMinimized,
362- showUserList,
363- isCentered
364- });
365- });
291+ inputScrollLeft = input.scrollLeft;
292+ }
366293
367- // New reactive state to track when a message is being sent
368- let isSendingMessage = $state(false);
294+ function handleInputScroll(e: Event) {
295+ inputScrollLeft = (e.target as HTMLInputElement).scrollLeft;
296+ }
369297
370298 async function handleSubmit() {
371299 if (!currentMessage.trim() || !currentUser || cooldownEndTime) return;
372300
373301 isSendingMessage = true;
374302 try {
375- const response = await chatState.sendMessage(currentMessage, 'chat', currentTextStyle);
376- if (!response.success && response.isRateLimited && response.retryAfter) {
377- startCooldownTimer(response.retryAfter);
303+ const response = await fetch('/api/chat/messages', {
304+ method: 'POST',
305+ headers: { 'Content-Type': 'application/json' },
306+ credentials: 'include',
307+ body: JSON.stringify({
308+ content: currentMessage,
309+ type: 'chat',
310+ userId: currentUser.id,
311+ chatRoomId: roomId,
312+ styleData: currentTextStyle ? JSON.stringify(currentTextStyle) : undefined
313+ })
314+ });
315+
316+ const data = await response.json();
317+
318+ if (!data.success && data.isRateLimited && data.retryAfter) {
319+ startCooldownTimer(data.retryAfter);
378320 rateLimitWarning = "Whoa there! You're sending messages too quickly. Take a breather...";
379- } else if (!response.success) {
380- rateLimitWarning = response.error || 'Failed to send message. Please try again.';
321+ } else if (!data.success) {
322+ rateLimitWarning = data.error || 'Failed to send message. Please try again.';
381323 setTimeout(() => (rateLimitWarning = null), 3000);
382324 } else {
383325 currentMessage = '';
@@ -395,7 +337,6 @@
395337 function handleDragMove(event: CustomEvent<{ x: number; y: number }>) {
396338 windowX = event.detail.x;
397339 windowY = event.detail.y;
398- // User manually moved the window, no longer keep it centered on resize
399340 isCentered = false;
400341 }
401342
@@ -406,16 +347,12 @@
406347 function handleMaximize(event: MouseEvent) {
407348 const node = (event.currentTarget as HTMLElement).closest('.window') as MaximizableNode;
408349 if (node) {
409- // If minimized, restore first
410350 if (isMinimized) {
411351 const minimizableNode = node as unknown as MinimizableNode;
412352 if (minimizableNode?.toggleMinimize) {
413353 minimizableNode.toggleMinimize();
414354 }
415- // Wait for minimize animation to complete
416- setTimeout(() => {
417- node.toggleMaximize();
418- }, 300);
355+ setTimeout(() => node.toggleMaximize(), 300);
419356 } else {
420357 node.toggleMaximize();
421358 }
@@ -446,28 +383,21 @@
446383 const chatArea = event.target as HTMLElement;
447384 const { scrollTop } = chatArea;
448385
449- // Check if we've scrolled near the top (within 100px) and not already loading
450- if (scrollTop < 100 && !isLoadingMore && hasMoreMessages) {
386+ if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId) {
451387 isLoadingMore = true;
452-
453- // Store the scroll height and position before loading
454388 const scrollHeight = chatArea.scrollHeight;
455389 const currentScrollTop = chatArea.scrollTop;
456390
457- console.debug('Loading more messages...', {
458- oldestMessageTimestamp,
459- scrollHeight,
460- currentScrollTop
461- });
462-
463391 try {
464- const response = await fetch(
465- `/api/chat/messages?${new URLSearchParams({
466- before: oldestMessageTimestamp?.toString() || '',
467- roomId: currentRoomId,
468- ...(currentUser ? {} : { public: 'true' })
469- })}`
470- );
392+ // Build query string manually to avoid URLSearchParams (svelte reactivity compliance)
393+ const queryParts = [
394+ `before=${encodeURIComponent(oldestMessageTimestamp?.toString() || '')}`,
395+ `roomId=${encodeURIComponent(roomId)}`
396+ ];
397+ if (!currentUser) queryParts.push('public=true');
398+ const queryString = queryParts.join('&');
399+
400+ const response = await fetch(`/api/chat/messages?${queryString}`);
471401
472402 if (response.ok) {
473403 const data = await response.json();
@@ -475,30 +405,19 @@
475405 if (data.messages.length === 0) {
476406 hasMoreMessages = false;
477407 } else {
478- // Update messages through chat state
479- chatState.prependMessages(data.messages, data.hasMore);
408+ const fetchedMessages = data.messages as Message[];
409+ mergePagedMessages(fetchedMessages);
480410
481- // Use requestAnimationFrame to ensure DOM has updated
482411 requestAnimationFrame(() => {
483- // Calculate how much the content height has increased
484412 const newScrollHeight = chatArea.scrollHeight;
485413 const heightDifference = newScrollHeight - scrollHeight;
486-
487- // Adjust scroll position to maintain relative position
488414 chatArea.scrollTop = currentScrollTop + heightDifference;
489-
490- console.debug('Scroll position adjusted:', {
491- heightDifference,
492- newScrollTop: chatArea.scrollTop,
493- newScrollHeight
494- });
495415 });
496416
497- // Update oldest timestamp
498- const newOldest = Math.min(...data.messages.map((m: Message) => m.timestamp));
499- oldestMessageTimestamp = newOldest;
500-
501- // Update hasMoreMessages from response
417+ const newOldest = Math.min(...fetchedMessages.map((m) => m.timestamp));
418+ oldestMessageTimestamp = oldestMessageTimestamp
419+ ? Math.min(oldestMessageTimestamp, newOldest)
420+ : newOldest;
502421 hasMoreMessages = data.hasMore;
503422 }
504423 }
@@ -512,21 +431,6 @@
512431 }
513432 }
514433
515- // Add effect to track initial loading state
516- $effect(() => {
517- const stateMessages = chatState.getMessages();
518- if (stateMessages.length > 0 && isInitialLoading) {
519- isInitialLoading = false;
520- }
521- });
522-
523- // Add derived state for total users count
524- let totalUsers = $derived(onlineUsers.length);
525-
526- // Split users into online and offline groups
527- let usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline'));
528- let usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline'));
529-
530434 function openSignup() {
531435 showAuth = true;
532436 }
@@ -534,6 +438,105 @@
534438 function handleLoginSuccess() {
535439 showAuth = false;
536440 }
441+
442+ // ============ LIFECYCLE ============
443+
444+ onMount(() => {
445+ if (!browser) return;
446+
447+ isMobile = window.innerWidth <= 768;
448+
449+ if (!isMobile) {
450+ const savedState = loadWindowState();
451+ const maxWidth = window.innerWidth - 40;
452+ const maxHeight = window.innerHeight - 40;
453+
454+ windowWidth = Math.min(savedState.width, maxWidth);
455+ windowHeight = Math.min(savedState.height, maxHeight);
456+
457+ if (savedState.x === -1) {
458+ windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
459+ } else {
460+ windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth));
461+ }
462+ if (savedState.y === -1) {
463+ windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
464+ } else {
465+ windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight));
466+ }
467+
468+ isMaximized = savedState.isMaximized;
469+ isMinimized = savedState.isMinimized;
470+ showUserList = savedState.showUserList;
471+ isCentered = savedState.isCentered;
472+ }
473+
474+ const handleResize = () => {
475+ const wasMobile = isMobile;
476+ isMobile = window.innerWidth <= 768;
477+
478+ if (isMobile) {
479+ windowWidth = window.innerWidth;
480+ windowHeight = window.innerHeight;
481+ windowX = 0;
482+ windowY = 0;
483+ } else if (wasMobile && !isMobile) {
484+ const savedState = loadWindowState();
485+ const maxWidth = window.innerWidth - 40;
486+ const maxHeight = window.innerHeight - 40;
487+ windowWidth = Math.min(savedState.width, maxWidth);
488+ windowHeight = Math.min(savedState.height, maxHeight);
489+
490+ if (savedState.x === -1) {
491+ windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
492+ } else {
493+ windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth));
494+ }
495+ if (savedState.y === -1) {
496+ windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
497+ } else {
498+ windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight));
499+ }
500+ } else if (!isMobile) {
501+ const maxWidth = window.innerWidth - 40;
502+ const maxHeight = window.innerHeight - 40;
503+ windowWidth = Math.min(windowWidth, maxWidth);
504+ windowHeight = Math.min(windowHeight, maxHeight);
505+
506+ if (isCentered) {
507+ windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
508+ windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
509+ } else {
510+ windowX = Math.max(0, Math.min(windowX, window.innerWidth - windowWidth));
511+ windowY = Math.max(0, Math.min(windowY, window.innerHeight - windowHeight));
512+ }
513+ }
514+ };
515+
516+ if (isMobile) handleResize();
517+
518+ window.addEventListener('resize', handleResize);
519+
520+ return () => {
521+ window.removeEventListener('resize', handleResize);
522+ if (cooldownInterval) clearInterval(cooldownInterval);
523+ };
524+ });
525+
526+ // Save window state when it changes
527+ $effect(() => {
528+ if (!browser || isMobile) return;
529+ debouncedSaveWindowState({
530+ width: windowWidth,
531+ height: windowHeight,
532+ x: windowX,
533+ y: windowY,
534+ isMaximized,
535+ isMinimized,
536+ showUserList,
537+ isCentered
538+ });
539+ });
537540 </script>
538541
539542 {#if showChatRoom}
@@ -567,8 +570,8 @@
567570 Pdr Aim {#if currentUser}
568571 - {currentUser.nickname}{:else}
569572 - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if}
570- {#if sseError && !isMinimized}
571- <span class="connection-error">⚠️ Erreur de connexion</span>
573+ {#if connectionError && !isMinimized}
574+ <span class="connection-error">⚠️ {connectionError}</span>
572575 {/if}
573576 </div>
574577 <div class="title-bar-controls">
@@ -612,13 +615,9 @@
612615 class="window-body"
613616 style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;"
614617 >
615- {#if sseError}
618+ {#if connectionError}
616619 <div class="error-banner">
617- {sseError}
618- {#if sseRetryAfter}
619- <br />
620- <small>Nouvelle tentative dans {Math.ceil(sseRetryAfter)}s...</small>
621- {/if}
620+ {connectionError}
622621 </div>
623622 {/if}
624623
@@ -896,7 +895,6 @@
896895 line-height: 1.4;
897896 }
898897
899- /* Fix gradient text alignment in messages */
900898 .message :global(.gradient-text-static) {
901899 display: inline;
902900 vertical-align: baseline;
@@ -923,7 +921,6 @@
923921 vertical-align: baseline;
924922 }
925923
926- /* Ensure formatted message content aligns properly with nickname */
927924 .message-content :global(.formatted-message),
928925 .message-content :global(.gradient-text-static) {
929926 display: inline;
@@ -975,15 +972,6 @@
975972 cursor: help;
976973 }
977974
978- /* .user .status-message {
979- font-size: 0.875rem;
980- color: #666;
981- font-style: italic;
982- overflow: hidden;
983- text-overflow: ellipsis;
984- white-space: nowrap;
985- } */
986-
987975 .sunken-panel {
988976 background: white;
989977 border: 0.125rem inset #dfdfdf;
@@ -994,9 +982,7 @@
994982 margin-right: 0.25rem;
995983 }
996984
997- /* Styled input - font is controlled by retro-font-* classes */
998985 .styled-input {
999- /* Font family is set by retro-font-* classes, don't override here */
1000986 font-style: inherit;
1001987 }
1002988
@@ -1132,7 +1118,6 @@
11321118 position: relative;
11331119 }
11341120
1135- /* Character counter styles */
11361121 .char-counter {
11371122 font-size: 0.75rem;
11381123 padding: 0 0.25rem;
M src/lib/components/user-preferences-modal.svelte +11 -3
@@ -45,11 +45,19 @@
4545
4646 // Save preferences
4747 async function savePreferences() {
48- if (!onSave) return;
49-
5048 isSaving = true;
5149 try {
52- await onSave(preferences);
50+ const response = await fetch('/api/user/text-preferences', {
51+ method: 'POST',
52+ headers: { 'Content-Type': 'application/json' },
53+ body: JSON.stringify(preferences)
54+ });
55+
56+ if (!response.ok) {
57+ throw new Error('Failed to save preferences');
58+ }
59+
60+ await onSave?.(preferences);
5361 showModal = false;
5462 } catch (error) {
5563 console.error('Failed to save preferences:', error);
A src/lib/db/convex.server.ts +196 -0
@@ -0,0 +1,196 @@
1+import { env } from '$env/dynamic/private';
2+import { PUBLIC_CONVEX_URL } from '$env/static/public';
3+
4+// Convert .cloud URL to .site URL for HTTP actions
5+function getHttpUrl(): string {
6+ return PUBLIC_CONVEX_URL.replace('.convex.cloud', '.convex.site');
7+}
8+
9+// Generic fetch helper for Convex HTTP endpoints (server-side only)
10+async function convexFetch<T>(path: string, body: Record<string, unknown> = {}): Promise<T> {
11+ const url = `${getHttpUrl()}${path}`;
12+
13+ const response = await fetch(url, {
14+ method: 'POST',
15+ headers: {
16+ 'Content-Type': 'application/json',
17+ 'X-Convex-Secret': env.CONVEX_API_SECRET || ''
18+ },
19+ body: JSON.stringify(body)
20+ });
21+
22+ if (!response.ok) {
23+ const error = await response.text();
24+ throw new Error(`Convex HTTP error: ${response.status} - ${error}`);
25+ }
26+
27+ return response.json();
28+}
29+
30+// ============ USERS ============
31+
32+export const users = {
33+ getByNickname: (nickname: string) =>
34+ convexFetch<ConvexUser | null>('/api/users/getByNickname', { nickname }),
35+
36+ getById: (id: string) => convexFetch<ConvexUser | null>('/api/users/getById', { id }),
37+
38+ create: (nickname: string, password: string) =>
39+ convexFetch<{ userId: string }>('/api/users/create', { nickname, password }),
40+
41+ updateStatus: (userId: string, status: string) =>
42+ convexFetch<ConvexUser>('/api/users/updateStatus', { userId, status }),
43+
44+ list: () => convexFetch<ConvexUser[]>('/api/users/list', {}),
45+
46+ setAllOffline: () => convexFetch<{ updated: number }>('/api/users/setAllOffline', {})
47+};
48+
49+// ============ SESSIONS ============
50+
51+export const sessions = {
52+ create: (tokenHash: string, userId: string, expiresAt: number) =>
53+ convexFetch<{ id: string; tokenHash: string }>('/api/sessions/create', {
54+ tokenHash,
55+ userId,
56+ expiresAt
57+ }),
58+
59+ validate: (tokenHash: string) =>
60+ convexFetch<{ session: ConvexSession | null; user: ConvexUser | null }>(
61+ '/api/sessions/validate',
62+ { tokenHash }
63+ ),
64+
65+ remove: (tokenHash: string) =>
66+ convexFetch<{ success: boolean }>('/api/sessions/remove', { tokenHash })
67+};
68+
69+// ============ CHAT ROOMS ============
70+
71+export const chatRooms = {
72+ getOrCreateDefault: () => convexFetch<ConvexChatRoom>('/api/chatRooms/getOrCreateDefault', {}),
73+
74+ getById: (id: string) => convexFetch<ConvexChatRoom | null>('/api/chatRooms/getById', { id })
75+};
76+
77+// ============ MESSAGES ============
78+
79+export const messages = {
80+ getByRoom: (roomId: string, limit?: number, beforeTimestamp?: number) =>
81+ convexFetch<{ messages: ConvexMessage[]; hasMore: boolean }>('/api/messages/getByRoom', {
82+ roomId,
83+ limit,
84+ beforeTimestamp
85+ }),
86+
87+ send: (
88+ chatRoomId: string,
89+ senderId: string,
90+ content: string,
91+ type?: string,
92+ styleData?: string,
93+ hasFormatting?: boolean
94+ ) =>
95+ convexFetch<ConvexMessage>('/api/messages/send', {
96+ chatRoomId,
97+ senderId,
98+ content,
99+ type,
100+ styleData,
101+ hasFormatting
102+ })
103+};
104+
105+// ============ TYPES ============
106+
107+export interface ConvexUser {
108+ _id: string;
109+ _creationTime: number;
110+ password: string;
111+ nickname: string;
112+ status: string;
113+ avatarUrl?: string;
114+ createdAt: number;
115+ lastSeen?: number;
116+}
117+
118+export interface ConvexSession {
119+ _id: string;
120+ _creationTime: number;
121+ tokenHash: string;
122+ userId: string;
123+ expiresAt: number;
124+ createdAt: number;
125+}
126+
127+export interface ConvexMessage {
128+ _id: string;
129+ _creationTime: number;
130+ chatRoomId: string;
131+ senderId: string;
132+ content: string;
133+ type: string;
134+ timestamp: number;
135+ styleData?: string;
136+ hasFormatting?: boolean;
137+}
138+
139+export interface ConvexChatRoom {
140+ _id: string;
141+ _creationTime: number;
142+ name?: string;
143+ type: string;
144+ createdAt: number;
145+}
146+
147+export interface ConvexTextPreferences {
148+ _id: string;
149+ _creationTime: number;
150+ userId: string;
151+ defaultStyle: {
152+ fontFamily: string;
153+ fontSize: number;
154+ color?: string;
155+ gradient?: string[];
156+ bold: boolean;
157+ italic: boolean;
158+ underline: boolean;
159+ strikethrough: boolean;
160+ };
161+ allowFormatting: boolean;
162+ maxMessageLength: number;
163+ createdAt: number;
164+ updatedAt: number;
165+}
166+
167+// ============ TEXT PREFERENCES ============
168+
169+export const textPreferences = {
170+ get: (userId: string) =>
171+ convexFetch<ConvexTextPreferences | null>('/api/textPreferences/get', { userId }),
172+
173+ save: (
174+ userId: string,
175+ defaultStyle: {
176+ fontFamily: string;
177+ fontSize: number;
178+ color?: string;
179+ gradient?: string[];
180+ bold: boolean;
181+ italic: boolean;
182+ underline: boolean;
183+ strikethrough: boolean;
184+ },
185+ allowFormatting: boolean,
186+ maxMessageLength: number
187+ ) =>
188+ convexFetch<{ success: boolean; id: string }>('/api/textPreferences/save', {
189+ userId,
190+ defaultStyle,
191+ allowFormatting,
192+ maxMessageLength
193+ })
194+};
195+
196+export default { users, sessions, messages, chatRooms, textPreferences };
D src/lib/db/db.server.ts +0 -39
@@ -1,39 +0,0 @@
1-import { drizzle } from 'drizzle-orm/libsql';
2-import { createClient } from '@libsql/client';
3-import { TURSO_AUTH_TOKEN, TURSO_DATABASE_URL } from '$env/static/private';
4-import schema from './schema';
5-import { ensureDefaultChatRoom } from '$lib/utils/chat.server';
6-import { createLogger } from '$lib/utils/logger.server';
7-
8-const log = createLogger('db-server');
9-
10-const client = createClient({ url: TURSO_DATABASE_URL, authToken: TURSO_AUTH_TOKEN });
11-const db = drizzle(client, { schema });
12-
13-// Initialize database with required data
14-export const initializeDatabase = async () => {
15- log.debug('Initializing database...');
16- try {
17- await ensureDefaultChatRoom(db);
18- log.debug('Database initialization complete');
19- } catch (error) {
20- log.error('Error initializing database:', {
21- error: error instanceof Error ? error.message : 'Unknown error'
22- });
23- throw error;
24- }
25-};
26-
27-// Call initialization when the module is loaded
28-initializeDatabase().catch((error) => {
29- log.error('Failed to initialize database:', {
30- error: error instanceof Error ? error.message : 'Unknown error'
31- });
32-});
33-
34-export const testConnection = async () => {
35- const result = await db.query.users.findMany();
36- log.debug('Test connection result:', result);
37-};
38-
39-export default db;
D src/lib/db/flush.ts +0 -54
@@ -1,54 +0,0 @@
1-import { createClient } from '@libsql/client';
2-import * as dotenv from 'dotenv';
3-
4-// Load environment variables from .env file
5-dotenv.config();
6-
7-const { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } = process.env;
8-
9-if (!TURSO_DATABASE_URL || !TURSO_AUTH_TOKEN) {
10- throw new Error(
11- 'Missing required environment variables TURSO_DATABASE_URL and/or TURSO_AUTH_TOKEN'
12- );
13-}
14-
15-async function flushDatabase() {
16- console.debug('Starting database content flush...');
17-
18- const client = createClient({
19- url: TURSO_DATABASE_URL as string,
20- authToken: TURSO_AUTH_TOKEN as string
21- });
22-
23- try {
24- // Clear tables in correct order to respect foreign key constraints
25- console.debug('Clearing messages table...');
26- await client.execute('DELETE FROM messages');
27-
28- console.debug('Clearing sessions table...');
29- await client.execute('DELETE FROM sessions');
30-
31- console.debug('Clearing chat rooms table...');
32- await client.execute('DELETE FROM chat_rooms');
33-
34- console.debug('Clearing users table...');
35- await client.execute('DELETE FROM users');
36-
37- console.debug('Database content flush completed successfully');
38- } catch (error) {
39- console.error('Error flushing database content:', error);
40- throw error;
41- } finally {
42- await client.close();
43- }
44-}
45-
46-// Execute if this file is run directly
47-if (process.argv[1] === import.meta.url) {
48- flushDatabase()
49- .then(() => process.exit(0))
50- .catch((error) => {
51- console.error('Failed to flush database content:', error);
52- process.exit(1);
53- });
54-}
D src/lib/db/schema.ts +0 -138
@@ -1,138 +0,0 @@
1-import { sqliteTable as table } from 'drizzle-orm/sqlite-core';
2-import * as t from 'drizzle-orm/sqlite-core';
3-import type { User, ChatRoom, Message, UserStatus, MessageType, Session } from '../types/chat';
4-import { v4 as uuidv4 } from 'uuid';
5-import { eq } from 'drizzle-orm';
6-
7-export const users = table('users', {
8- id: t
9- .text('id')
10- .primaryKey()
11- .$default(() => uuidv4()),
12- password: t.text('password').notNull(),
13- nickname: t.text('nickname').notNull(),
14- status: t.text('status').notNull().$type<UserStatus>().default('offline'),
15- avatarUrl: t.text('avatar_url'),
16- createdAt: t.integer('created_at').notNull(),
17- lastSeen: t.integer('last_seen')
18-});
19-
20-export const sessions = table('sessions', {
21- id: t.text('id').primaryKey(),
22- userId: t
23- .text('user_id')
24- .references(() => users.id)
25- .notNull(),
26- expiresAt: t.integer('expires_at').notNull(),
27- createdAt: t.integer('created_at').notNull()
28-});
29-
30-export const chatRooms = table('chat_rooms', {
31- id: t
32- .text('id')
33- .primaryKey()
34- .$default(() => uuidv4()),
35- name: t.text('name'),
36- type: t.text('type').$type<'direct' | 'group'>().notNull().default('direct'),
37- createdAt: t.integer('created_at').notNull()
38-});
39-
40-export const messages = table('messages', {
41- id: t
42- .text('id')
43- .primaryKey()
44- .$default(() => uuidv4()),
45- chatRoomId: t
46- .text('chat_room_id')
47- .references(() => chatRooms.id)
48- .notNull(),
49- senderId: t
50- .text('sender_id')
51- .references(() => users.id)
52- .notNull(),
53- content: t.text('content').notNull(),
54- type: t.text('type').$type<MessageType>().notNull().default('chat'),
55- timestamp: t.integer('timestamp').notNull(),
56- styleData: t.text('style_data'), // JSON string of TextStyle object
57- hasFormatting: t.integer('has_formatting', { mode: 'boolean' }).default(false) // Boolean flag
58-});
59-
60-export const userTextPreferences = table('user_text_preferences', {
61- userId: t
62- .text('user_id')
63- .references(() => users.id)
64- .primaryKey(),
65- defaultFontFamily: t.text('default_font_family').default('tahoma'),
66- defaultFontSize: t.integer('default_font_size').default(14),
67- defaultColor: t.text('default_color').default('black'),
68- allowFormatting: t.integer('allow_formatting', { mode: 'boolean' }).default(true),
69- maxMessageLength: t.integer('max_message_length').default(500),
70- stylePresets: t.text('style_presets'), // JSON array of saved styles
71- createdAt: t.integer('created_at').notNull(),
72- updatedAt: t.integer('updated_at').notNull()
73-});
74-
75-// Validate schema types against interfaces
76-export type Users = typeof users.$inferSelect;
77-export type ChatRooms = typeof chatRooms.$inferSelect;
78-export type Messages = typeof messages.$inferSelect;
79-export type Sessions = typeof sessions.$inferSelect;
80-export type UserTextPreferences = typeof userTextPreferences.$inferSelect;
81-// These type assertions will fail if the schema doesn't match the interfaces
82-export type _UsersValidation = Omit<User, keyof Users> & Omit<Users, keyof User>;
83-export type _ChatRoomsValidation = Omit<ChatRoom, keyof ChatRooms> &
84- Omit<ChatRooms, keyof ChatRoom>;
85-export type _MessagesValidation = Omit<Message, keyof Messages> & Omit<Messages, keyof Message>;
86-export type _SessionsValidation = Omit<Session, keyof Sessions> & Omit<Sessions, keyof Session>;
87-
88-export const userView = t.sqliteView('user_view').as((qb) =>
89- qb
90- .select({
91- id: users.id,
92- nickname: users.nickname,
93- status: users.status,
94- avatarUrl: users.avatarUrl,
95- lastSeen: users.lastSeen
96- })
97- .from(users)
98-);
99-
100-export const chatRoomView = t.sqliteView('chat_room_view').as((qb) =>
101- qb
102- .select({
103- id: chatRooms.id,
104- name: chatRooms.name,
105- type: chatRooms.type,
106- createdAt: chatRooms.createdAt
107- })
108- .from(chatRooms)
109-);
110-
111-export const messageView = t.sqliteView('message_view').as((qb) =>
112- qb
113- .select({
114- id: messages.id,
115- content: messages.content,
116- type: messages.type,
117- timestamp: messages.timestamp,
118- sender: users.nickname,
119- chatRoom: chatRooms.name
120- })
121- .from(messages)
122- .leftJoin(users, eq(messages.senderId, users.id))
123- .leftJoin(chatRooms, eq(messages.chatRoomId, chatRooms.id))
124-);
125-
126-// Create a schema object with all our tables and views
127-const schema = {
128- users,
129- sessions,
130- chatRooms,
131- messages,
132- userTextPreferences,
133- userView,
134- chatRoomView,
135- messageView
136-};
137-
138-export default schema;
D src/lib/sse/MessageQueue.ts +0 -227
@@ -1,227 +0,0 @@
1-/**
2- * Server-side Message Queue for SSE Catch-up
3- *
4- * Stores recent messages to allow clients to catch up after reconnection
5- * using the Last-Event-ID mechanism.
6- */
7-
8-import { SSE_CONFIG } from './config';
9-
10-/**
11- * Queued message structure
12- */
13-export interface QueuedMessage {
14- /** Unique event ID for Last-Event-ID support */
15- id: string;
16- /** Event type (chatMessage, userStatusUpdate, etc.) */
17- event: string;
18- /** Event payload */
19- data: unknown;
20- /** Timestamp for TTL expiration */
21- timestamp: number;
22- /** Chat room ID for filtering */
23- roomId: string;
24-}
25-
26-/**
27- * Message Queue for SSE catch-up support
28- *
29- * Maintains a queue of recent messages per room to allow clients
30- * to catch up after reconnection using Last-Event-ID.
31- */
32-class MessageQueue {
33- private queues: Map<string, QueuedMessage[]> = new Map();
34- private readonly ttl: number;
35- private readonly maxSize: number;
36- private cleanupInterval: ReturnType<typeof setInterval> | null = null;
37-
38- constructor(
39- ttl: number = SSE_CONFIG.MESSAGE_QUEUE_TTL,
40- maxSize: number = SSE_CONFIG.MESSAGE_QUEUE_MAX_SIZE
41- ) {
42- this.ttl = ttl;
43- this.maxSize = maxSize;
44-
45- // Start periodic cleanup
46- this.startCleanup();
47- console.log('[MessageQueue] Initialized:', { ttl, maxSize });
48- }
49-
50- /**
51- * Add a message to the queue
52- * @returns The generated event ID
53- */
54- enqueue(roomId: string, event: string, data: unknown): string {
55- const id = crypto.randomUUID();
56- const message: QueuedMessage = {
57- id,
58- event,
59- data,
60- timestamp: Date.now(),
61- roomId
62- };
63-
64- let queue = this.queues.get(roomId);
65- if (!queue) {
66- queue = [];
67- this.queues.set(roomId, queue);
68- }
69-
70- queue.push(message);
71-
72- // Trim if over max size (keep newest)
73- if (queue.length > this.maxSize) {
74- const trimmed = queue.length - this.maxSize;
75- this.queues.set(roomId, queue.slice(-this.maxSize));
76- console.log('[MessageQueue] Queue trimmed:', { roomId, trimmed });
77- }
78-
79- return id;
80- }
81-
82- /**
83- * Get messages since a given event ID
84- * If the ID is not found, returns the most recent messages
85- */
86- getMessagesSince(roomId: string, lastEventId: string): QueuedMessage[] {
87- const queue = this.queues.get(roomId);
88- if (!queue || queue.length === 0) {
89- return [];
90- }
91-
92- const lastIndex = queue.findIndex((m) => m.id === lastEventId);
93-
94- if (lastIndex === -1) {
95- // ID not found - return recent messages as fallback
96- console.log('[MessageQueue] Last-Event-ID not found, returning recent:', {
97- roomId,
98- lastEventId,
99- returning: Math.min(50, queue.length)
100- });
101- return queue.slice(-50);
102- }
103-
104- // Return messages after the last seen ID
105- const missed = queue.slice(lastIndex + 1);
106- if (missed.length > 0) {
107- console.log('[MessageQueue] Catch-up messages:', {
108- roomId,
109- lastEventId,
110- missedCount: missed.length
111- });
112- }
113- return missed;
114- }
115-
116- /**
117- * Get all messages for a room (for initial load)
118- */
119- getMessages(roomId: string, limit: number = 50): QueuedMessage[] {
120- const queue = this.queues.get(roomId);
121- if (!queue) {
122- return [];
123- }
124- return queue.slice(-limit);
125- }
126-
127- /**
128- * Get the latest event ID for a room
129- */
130- getLatestEventId(roomId: string): string | null {
131- const queue = this.queues.get(roomId);
132- if (!queue || queue.length === 0) {
133- return null;
134- }
135- return queue[queue.length - 1].id;
136- }
137-
138- /**
139- * Check if an event ID exists in the queue
140- */
141- hasEventId(roomId: string, eventId: string): boolean {
142- const queue = this.queues.get(roomId);
143- if (!queue) {
144- return false;
145- }
146- return queue.some((m) => m.id === eventId);
147- }
148-
149- /**
150- * Remove expired messages from all queues
151- */
152- cleanup(): number {
153- const now = Date.now();
154- let totalRemoved = 0;
155-
156- for (const [roomId, queue] of this.queues.entries()) {
157- const originalLength = queue.length;
158- const filtered = queue.filter((m) => now - m.timestamp < this.ttl);
159-
160- if (filtered.length !== originalLength) {
161- totalRemoved += originalLength - filtered.length;
162- if (filtered.length === 0) {
163- this.queues.delete(roomId);
164- } else {
165- this.queues.set(roomId, filtered);
166- }
167- }
168- }
169-
170- if (totalRemoved > 0) {
171- console.log('[MessageQueue] Cleanup completed:', {
172- removed: totalRemoved,
173- activeRooms: this.queues.size
174- });
175- }
176-
177- return totalRemoved;
178- }
179-
180- /**
181- * Start periodic cleanup interval
182- */
183- private startCleanup(): void {
184- // Cleanup every minute
185- this.cleanupInterval = setInterval(() => {
186- this.cleanup();
187- }, 60_000);
188- }
189-
190- /**
191- * Stop cleanup interval (for shutdown)
192- */
193- stopCleanup(): void {
194- if (this.cleanupInterval) {
195- clearInterval(this.cleanupInterval);
196- this.cleanupInterval = null;
197- }
198- }
199-
200- /**
201- * Clear all queues (for testing or reset)
202- */
203- clear(): void {
204- this.queues.clear();
205- console.log('[MessageQueue] All queues cleared');
206- }
207-
208- /**
209- * Get statistics about the queue
210- */
211- getStats(): { rooms: number; totalMessages: number } {
212- let totalMessages = 0;
213- for (const queue of this.queues.values()) {
214- totalMessages += queue.length;
215- }
216- return {
217- rooms: this.queues.size,
218- totalMessages
219- };
220- }
221-}
222-
223-// Singleton instance
224-export const messageQueue = new MessageQueue();
225-
226-// Export class for testing
227-export { MessageQueue };
D src/lib/sse/SSEClient.svelte.ts +0 -322
@@ -1,322 +0,0 @@
1-/**
2- * SSE Client - Client-side SSE connection manager
3- *
4- * Handles EventSource connections with:
5- * - Unlimited reconnection with exponential backoff + jitter
6- * - Heartbeat monitoring and acknowledgment
7- * - Last-Event-ID tracking for catch-up
8- * - Reactive state using Svelte 5 runes
9- */
10-
11-import { SSE_CONFIG, type ConnectionStatus } from './config';
12-import type { SSEEventMap } from './SSEEventEmitter';
13-
14-/**
15- * Configuration for SSE Client
16- */
17-export interface SSEClientConfig {
18- /** SSE endpoint URL */
19- url: string;
20- /** Callback for incoming messages */
21- onMessage: <T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T]) => void;
22- /** Callback for connection status changes */
23- onStatusChange?: (status: ConnectionStatus) => void;
24- /** Callback for errors */
25- onError?: (error: string) => void;
26-}
27-
28-/**
29- * SSE Client with Svelte 5 reactive state
30- */
31-export class SSEClient {
32- // Configuration
33- private config: SSEClientConfig;
34-
35- // Connection state
36- private eventSource: EventSource | null = null;
37- private _connectionId: string | null = null;
38-
39- // Reactive state using Svelte 5 runes
40- private _status = $state<ConnectionStatus>('disconnected');
41- private _lastEventId = $state<string | null>(null);
42- private _error = $state<string | null>(null);
43- private _reconnectAttempts = $state(0);
44-
45- // Timers
46- private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
47- private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
48-
49- // Derived state
50- public status = $derived(this._status);
51- public lastEventId = $derived(this._lastEventId);
52- public error = $derived(this._error);
53- public reconnectAttempts = $derived(this._reconnectAttempts);
54- public isConnected = $derived(this._status === 'connected');
55- public isReconnecting = $derived(this._status === 'reconnecting');
56-
57- constructor(config: SSEClientConfig) {
58- this.config = config;
59- console.debug('[SSEClient] Initialized');
60- }
61-
62- /**
63- * Connect to the SSE endpoint
64- */
65- connect(): void {
66- if (this.eventSource && this.eventSource.readyState !== EventSource.CLOSED) {
67- console.debug('[SSEClient] Already connected or connecting');
68- return;
69- }
70-
71- this.setStatus('connecting');
72- this._error = null;
73-
74- try {
75- // EventSource automatically sends Last-Event-ID header on reconnection
76- this.eventSource = new EventSource(this.config.url, { withCredentials: true });
77-
78- this.eventSource.onopen = () => {
79- console.debug('[SSEClient] Connection established');
80- this.setStatus('connected');
81- this._reconnectAttempts = 0;
82- this._error = null;
83- this.startHeartbeatMonitor();
84- };
85-
86- this.eventSource.onerror = (event) => {
87- console.debug('[SSEClient] Connection error:', event);
88-
89- if (this.eventSource?.readyState === EventSource.CLOSED) {
90- this.handleDisconnection();
91- } else if (this.eventSource?.readyState === EventSource.CONNECTING) {
92- this.setStatus('reconnecting');
93- }
94- };
95-
96- // Set up event listeners
97- this.setupEventListeners();
98- } catch (error) {
99- console.error('[SSEClient] Failed to create EventSource:', error);
100- this._error = 'Failed to connect';
101- this.handleDisconnection();
102- }
103- }
104-
105- /**
106- * Disconnect from SSE
107- */
108- disconnect(): void {
109- console.debug('[SSEClient] Disconnecting');
110- this.cleanup();
111- this.setStatus('disconnected');
112- this._connectionId = null;
113- }
114-
115- /**
116- * Get the current connection ID
117- */
118- getConnectionId(): string | null {
119- return this._connectionId;
120- }
121-
122- /**
123- * Set up event listeners for SSE events
124- */
125- private setupEventListeners(): void {
126- if (!this.eventSource) return;
127-
128- // Track last event ID from all events
129- const trackEventId = (event: MessageEvent) => {
130- if (event.lastEventId) {
131- this._lastEventId = event.lastEventId;
132- }
133- };
134-
135- // Chat message events
136- this.eventSource.addEventListener('chatMessage', (event: MessageEvent) => {
137- trackEventId(event);
138- try {
139- const data = JSON.parse(event.data);
140- this.config.onMessage('chatMessage', data);
141- } catch (error) {
142- console.error('[SSEClient] Error parsing chatMessage:', error);
143- }
144- });
145-
146- // User status update events
147- this.eventSource.addEventListener('userStatusUpdate', (event: MessageEvent) => {
148- trackEventId(event);
149- try {
150- const data = JSON.parse(event.data);
151- this.config.onMessage('userStatusUpdate', data);
152- } catch (error) {
153- console.error('[SSEClient] Error parsing userStatusUpdate:', error);
154- }
155- });
156-
157- // Buddy list update events
158- this.eventSource.addEventListener('buddyListUpdate', (event: MessageEvent) => {
159- trackEventId(event);
160- try {
161- const data = JSON.parse(event.data);
162- this.config.onMessage('buddyListUpdate', data);
163- } catch (error) {
164- console.error('[SSEClient] Error parsing buddyListUpdate:', error);
165- }
166- });
167-
168- // Heartbeat events
169- this.eventSource.addEventListener('heartbeat', (event: MessageEvent) => {
170- trackEventId(event);
171- try {
172- const data = JSON.parse(event.data);
173- this._connectionId = data.connectionId;
174- this.acknowledgeHeartbeat(data.connectionId);
175- this.resetHeartbeatMonitor();
176- } catch (error) {
177- console.error('[SSEClient] Error parsing heartbeat:', error);
178- }
179- });
180- }
181-
182- /**
183- * Start monitoring for heartbeat timeout
184- */
185- private startHeartbeatMonitor(): void {
186- this.clearHeartbeatTimer();
187-
188- this.heartbeatTimer = setTimeout(() => {
189- console.warn('[SSEClient] Heartbeat timeout - connection may be stale');
190- // Force reconnection if no heartbeat received
191- if (this.eventSource) {
192- this.eventSource.close();
193- }
194- this.handleDisconnection();
195- }, SSE_CONFIG.HEARTBEAT_TIMEOUT);
196- }
197-
198- /**
199- * Reset the heartbeat monitor (called when heartbeat received)
200- */
201- private resetHeartbeatMonitor(): void {
202- this.startHeartbeatMonitor();
203- }
204-
205- /**
206- * Clear heartbeat timer
207- */
208- private clearHeartbeatTimer(): void {
209- if (this.heartbeatTimer) {
210- clearTimeout(this.heartbeatTimer);
211- this.heartbeatTimer = null;
212- }
213- }
214-
215- /**
216- * Send heartbeat acknowledgment to server
217- */
218- private async acknowledgeHeartbeat(connectionId: string): Promise<void> {
219- try {
220- const response = await fetch('/api/sse/heartbeat', {
221- method: 'POST',
222- headers: { 'Content-Type': 'application/json' },
223- credentials: 'include',
224- body: JSON.stringify({ connectionId })
225- });
226-
227- if (!response.ok) {
228- console.warn('[SSEClient] Heartbeat acknowledgment failed:', response.status);
229- }
230- } catch (error) {
231- console.warn('[SSEClient] Failed to acknowledge heartbeat:', error);
232- }
233- }
234-
235- /**
236- * Handle disconnection with reconnection logic
237- */
238- private handleDisconnection(): void {
239- this.cleanup();
240- this.setStatus('reconnecting');
241- this._reconnectAttempts++;
242-
243- const delay = this.calculateBackoff();
244- console.debug(`[SSEClient] Reconnecting in ${delay}ms (attempt ${this._reconnectAttempts})`);
245-
246- this.reconnectTimer = setTimeout(() => {
247- this.connect();
248- }, delay);
249- }
250-
251- /**
252- * Calculate reconnection delay with exponential backoff and jitter
253- * Never gives up - always tries to reconnect
254- */
255- private calculateBackoff(): number {
256- const base = SSE_CONFIG.INITIAL_RECONNECT_DELAY;
257- const max = SSE_CONFIG.MAX_RECONNECT_DELAY;
258-
259- // Exponential backoff: 2^attempts * base, capped at max
260- const exponentialDelay = Math.min(base * Math.pow(2, this._reconnectAttempts), max);
261-
262- // Add random jitter (0-25% of delay) to prevent thundering herd
263- const jitter = exponentialDelay * Math.random() * 0.25;
264-
265- return Math.floor(exponentialDelay + jitter);
266- }
267-
268- /**
269- * Get the estimated next reconnect delay in seconds
270- * Returns null if not reconnecting
271- */
272- getReconnectDelay(): number | null {
273- if (this._status !== 'reconnecting') {
274- return null;
275- }
276- // Calculate the expected delay for the current attempt
277- const base = SSE_CONFIG.INITIAL_RECONNECT_DELAY;
278- const max = SSE_CONFIG.MAX_RECONNECT_DELAY;
279- const exponentialDelay = Math.min(base * Math.pow(2, this._reconnectAttempts - 1), max);
280- return Math.ceil(exponentialDelay / 1000); // Convert to seconds
281- }
282-
283- /**
284- * Clean up resources
285- */
286- private cleanup(): void {
287- this.clearHeartbeatTimer();
288-
289- if (this.reconnectTimer) {
290- clearTimeout(this.reconnectTimer);
291- this.reconnectTimer = null;
292- }
293-
294- if (this.eventSource) {
295- this.eventSource.close();
296- this.eventSource = null;
297- }
298- }
299-
300- /**
301- * Set connection status and notify listener
302- */
303- private setStatus(status: ConnectionStatus): void {
304- this._status = status;
305- this.config.onStatusChange?.(status);
306- }
307-
308- /**
309- * Destroy the client completely
310- */
311- destroy(): void {
312- console.debug('[SSEClient] Destroying');
313- this.disconnect();
314- }
315-}
316-
317-/**
318- * Factory function to create an SSE client
319- */
320-export function createSSEClient(config: SSEClientConfig): SSEClient {
321- return new SSEClient(config);
322-}
D src/lib/sse/SSEEventEmitter.ts +0 -188
@@ -1,188 +0,0 @@
1-/**
2- * Type-safe SSE Event Emitter
3- *
4- * Provides typed event emission for SSE broadcasts with automatic
5- * listener cleanup and memory leak prevention.
6- */
7-
8-import type { Message, SafeUser, UserStatus } from '../types/chat';
9-
10-/**
11- * SSE Event Type Map - defines the payload for each event type
12- */
13-export interface SSEEventMap {
14- chatMessage: Message;
15- userStatusUpdate: {
16- userId: string;
17- status: UserStatus;
18- lastSeen: number;
19- };
20- buddyListUpdate: SafeUser[];
21- heartbeat: {
22- connectionId: string;
23- timestamp: number;
24- serverTime: string;
25- };
26-}
27-
28-/**
29- * Generic SSE event structure sent over the wire
30- */
31-export interface SSEEvent<T extends keyof SSEEventMap = keyof SSEEventMap> {
32- type: T;
33- data: SSEEventMap[T];
34- id?: string; // Event ID for Last-Event-ID support
35-}
36-
37-/**
38- * Listener function type for SSE events
39- */
40-type SSEListener = (event: SSEEvent) => void;
41-
42-/**
43- * Type-safe SSE Event Emitter with automatic cleanup
44- */
45-class SSEEventEmitter {
46- private listeners: Map<string, Set<SSEListener>> = new Map();
47- private maxListeners: number = 100;
48- private listenerCount: number = 0;
49-
50- constructor() {
51- console.log('[SSE-Emitter] Initialized');
52- }
53-
54- /**
55- * Emit an SSE event to all listeners
56- */
57- emit<T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T], id?: string): void {
58- const event: SSEEvent<T> = { type, data, id };
59-
60- // Get listeners for the 'sse' channel (main broadcast channel)
61- const eventListeners = this.listeners.get('sse');
62- if (eventListeners && eventListeners.size > 0) {
63- // Only log non-frequent event types
64- if (!['userStatusUpdate', 'chatMessage', 'heartbeat'].includes(type)) {
65- console.log('[SSE-Emitter] Broadcasting event:', {
66- type,
67- listenersCount: eventListeners.size
68- });
69- }
70-
71- // Iterate over a copy to allow removal during iteration
72- for (const listener of [...eventListeners]) {
73- try {
74- listener(event as SSEEvent);
75- } catch (error) {
76- console.error('[SSE-Emitter] Error in listener:', error);
77- // Remove broken listener
78- this.removeListener('sse', listener);
79- }
80- }
81- }
82- }
83-
84- /**
85- * Add a listener for SSE events
86- * Returns a cleanup function for easy removal
87- */
88- addListener(channel: string, listener: SSEListener): () => void {
89- if (!this.listeners.has(channel)) {
90- this.listeners.set(channel, new Set());
91- }
92-
93- const channelListeners = this.listeners.get(channel)!;
94-
95- if (channelListeners.size >= this.maxListeners) {
96- console.warn(
97- `[SSE-Emitter] Warning: Channel '${channel}' has reached max listeners (${this.maxListeners})`
98- );
99- }
100-
101- channelListeners.add(listener);
102- this.listenerCount++;
103-
104- console.log('[SSE-Emitter] Listener added:', {
105- channel,
106- totalListeners: channelListeners.size,
107- globalTotal: this.listenerCount
108- });
109-
110- // Return cleanup function
111- return () => this.removeListener(channel, listener);
112- }
113-
114- /**
115- * Remove a specific listener
116- */
117- removeListener(channel: string, listener: SSEListener): boolean {
118- const channelListeners = this.listeners.get(channel);
119- if (channelListeners) {
120- const removed = channelListeners.delete(listener);
121- if (removed) {
122- this.listenerCount--;
123- console.log('[SSE-Emitter] Listener removed:', {
124- channel,
125- remainingListeners: channelListeners.size,
126- globalTotal: this.listenerCount
127- });
128-
129- // Clean up empty channels
130- if (channelListeners.size === 0) {
131- this.listeners.delete(channel);
132- }
133- return true;
134- }
135- }
136- return false;
137- }
138-
139- /**
140- * Remove all listeners for a channel
141- */
142- removeAllListeners(channel?: string): void {
143- if (channel) {
144- const channelListeners = this.listeners.get(channel);
145- if (channelListeners) {
146- this.listenerCount -= channelListeners.size;
147- this.listeners.delete(channel);
148- console.log('[SSE-Emitter] All listeners removed for channel:', channel);
149- }
150- } else {
151- this.listeners.clear();
152- this.listenerCount = 0;
153- console.log('[SSE-Emitter] All listeners removed');
154- }
155- }
156-
157- /**
158- * Get the count of listeners for a channel
159- */
160- getListenerCount(channel?: string): number {
161- if (channel) {
162- return this.listeners.get(channel)?.size ?? 0;
163- }
164- return this.listenerCount;
165- }
166-
167- /**
168- * Set the maximum number of listeners per channel
169- */
170- setMaxListeners(n: number): void {
171- this.maxListeners = n;
172- console.log('[SSE-Emitter] Max listeners updated:', { newLimit: n });
173- }
174-
175- /**
176- * Check if a channel has any listeners
177- */
178- hasListeners(channel: string): boolean {
179- const channelListeners = this.listeners.get(channel);
180- return channelListeners !== undefined && channelListeners.size > 0;
181- }
182-}
183-
184-// Singleton instance
185-export const sseEmitter = new SSEEventEmitter();
186-
187-// Export type for use in other modules
188-export type { SSEListener };
D src/lib/sse/SSEManager.ts +0 -381
@@ -1,381 +0,0 @@
1-/**
2- * SSE Manager - Central server-side manager for SSE connections
3- *
4- * Coordinates connection tracking, message queuing, event broadcasting,
5- * and heartbeat management.
6- */
7-
8-import { SSE_CONFIG } from './config';
9-import { sseEmitter, type SSEEventMap, type SSEEvent } from './SSEEventEmitter';
10-import { messageQueue } from './MessageQueue';
11-import { createLogger } from '../utils/logger.server';
12-
13-const log = createLogger('sse-manager');
14-
15-/**
16- * Enhanced connection info with heartbeat tracking
17- */
18-export interface SSEConnection {
19- /** Unique connection ID */
20- connectionId: string;
21- /** User ID */
22- userId: string;
23- /** When the connection was established */
24- connectedAt: number;
25- /** Last activity timestamp */
26- lastActivity: number;
27- /** Last heartbeat acknowledgment */
28- lastHeartbeat: number;
29- /** Current room ID */
30- roomId: string;
31- /** Stream controller for sending events */
32- controller: ReadableStreamDefaultController<Uint8Array> | null;
33-}
34-
35-/**
36- * SSE Manager singleton
37- */
38-class SSEManager {
39- /** Map of connectionId -> SSEConnection */
40- private connections: Map<string, SSEConnection> = new Map();
41- /** Map of userId -> Set of connectionIds (for multi-tab support) */
42- private userConnections: Map<string, Set<string>> = new Map();
43- /** Text encoder for SSE output */
44- private encoder = new TextEncoder();
45- /** Intervals for periodic tasks */
46- private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
47- private cleanupInterval: ReturnType<typeof setInterval> | null = null;
48-
49- constructor() {
50- log.info('SSE Manager initialized');
51- }
52-
53- /**
54- * Start background tasks (heartbeat, cleanup)
55- */
56- startBackgroundTasks(): void {
57- if (this.heartbeatInterval || this.cleanupInterval) {
58- log.debug('Background tasks already running');
59- return;
60- }
61-
62- // Send heartbeats to all connections
63- this.heartbeatInterval = setInterval(() => {
64- this.sendHeartbeats();
65- }, SSE_CONFIG.HEARTBEAT_INTERVAL);
66-
67- // Cleanup stale connections
68- this.cleanupInterval = setInterval(() => {
69- this.cleanupStaleConnections();
70- }, SSE_CONFIG.STALE_CHECK_INTERVAL);
71-
72- log.info('Background tasks started');
73- }
74-
75- /**
76- * Stop background tasks
77- */
78- stopBackgroundTasks(): void {
79- if (this.heartbeatInterval) {
80- clearInterval(this.heartbeatInterval);
81- this.heartbeatInterval = null;
82- }
83- if (this.cleanupInterval) {
84- clearInterval(this.cleanupInterval);
85- this.cleanupInterval = null;
86- }
87- log.info('Background tasks stopped');
88- }
89-
90- /**
91- * Add a new SSE connection
92- * @returns Connection ID and whether this is a new user connection
93- */
94- addConnection(
95- userId: string,
96- roomId: string,
97- controller: ReadableStreamDefaultController<Uint8Array>
98- ): { connectionId: string; isNewUser: boolean } {
99- const connectionId = crypto.randomUUID();
100- const now = Date.now();
101-
102- const connection: SSEConnection = {
103- connectionId,
104- userId,
105- connectedAt: now,
106- lastActivity: now,
107- lastHeartbeat: now,
108- roomId,
109- controller
110- };
111-
112- this.connections.set(connectionId, connection);
113-
114- // Track user -> connections mapping
115- let userConns = this.userConnections.get(userId);
116- const isNewUser = !userConns || userConns.size === 0;
117-
118- if (!userConns) {
119- userConns = new Set();
120- this.userConnections.set(userId, userConns);
121- }
122- userConns.add(connectionId);
123-
124- log.info('Connection added', {
125- connectionId: connectionId.slice(0, 8),
126- userId: userId.slice(0, 8),
127- isNewUser,
128- userConnectionCount: userConns.size,
129- totalConnections: this.connections.size
130- });
131-
132- return { connectionId, isNewUser };
133- }
134-
135- /**
136- * Remove a connection
137- * @returns Whether this was the last connection for the user
138- */
139- removeConnection(connectionId: string): {
140- removed: boolean;
141- isLastConnection: boolean;
142- userId: string | null;
143- } {
144- const connection = this.connections.get(connectionId);
145- if (!connection) {
146- return { removed: false, isLastConnection: false, userId: null };
147- }
148-
149- const { userId } = connection;
150- this.connections.delete(connectionId);
151-
152- // Update user connections map
153- const userConns = this.userConnections.get(userId);
154- if (userConns) {
155- userConns.delete(connectionId);
156- if (userConns.size === 0) {
157- this.userConnections.delete(userId);
158- }
159- }
160-
161- const isLastConnection = !userConns || userConns.size === 0;
162-
163- log.info('Connection removed', {
164- connectionId: connectionId.slice(0, 8),
165- userId: userId.slice(0, 8),
166- isLastConnection,
167- remainingConnections: this.connections.size
168- });
169-
170- return { removed: true, isLastConnection, userId };
171- }
172-
173- /**
174- * Get a connection by ID
175- */
176- getConnection(connectionId: string): SSEConnection | undefined {
177- return this.connections.get(connectionId);
178- }
179-
180- /**
181- * Check if a user has any active connections
182- */
183- hasUserConnection(userId: string): boolean {
184- const userConns = this.userConnections.get(userId);
185- return userConns !== undefined && userConns.size > 0;
186- }
187-
188- /**
189- * Get all connection IDs for a user
190- */
191- getUserConnectionIds(userId: string): string[] {
192- const userConns = this.userConnections.get(userId);
193- return userConns ? Array.from(userConns) : [];
194- }
195-
196- /**
197- * Update activity timestamp for a connection
198- */
199- updateActivity(connectionId: string): void {
200- const connection = this.connections.get(connectionId);
201- if (connection) {
202- connection.lastActivity = Date.now();
203- }
204- }
205-
206- /**
207- * Acknowledge heartbeat for a connection
208- */
209- acknowledgeHeartbeat(connectionId: string): boolean {
210- const connection = this.connections.get(connectionId);
211- if (connection) {
212- connection.lastHeartbeat = Date.now();
213- connection.lastActivity = Date.now();
214- log.debug('Heartbeat acknowledged', { connectionId: connectionId.slice(0, 8) });
215- return true;
216- }
217- return false;
218- }
219-
220- /**
221- * Send an event to a specific connection
222- */
223- sendToConnection(connectionId: string, event: SSEEvent, eventId?: string): boolean {
224- const connection = this.connections.get(connectionId);
225- if (!connection || !connection.controller) {
226- return false;
227- }
228-
229- try {
230- const id = eventId ?? crypto.randomUUID();
231- const payload = this.formatSSEEvent(event, id);
232- connection.controller.enqueue(this.encoder.encode(payload));
233-
234- // Queue for catch-up
235- messageQueue.enqueue(connection.roomId, event.type, event.data);
236-
237- return true;
238- } catch (error) {
239- log.error('Error sending to connection', { connectionId: connectionId.slice(0, 8), error });
240- return false;
241- }
242- }
243-
244- /**
245- * Send an event to a specific user (all their connections)
246- */
247- sendToUser(userId: string, event: SSEEvent): number {
248- const connectionIds = this.getUserConnectionIds(userId);
249- let sent = 0;
250- for (const connId of connectionIds) {
251- if (this.sendToConnection(connId, event)) {
252- sent++;
253- }
254- }
255- return sent;
256- }
257-
258- /**
259- * Broadcast an event to all connections
260- * Uses the event emitter for distribution
261- */
262- broadcast<T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T], roomId?: string): void {
263- const eventId = crypto.randomUUID();
264-
265- // Queue the message first
266- if (roomId) {
267- messageQueue.enqueue(roomId, type, data);
268- }
269-
270- // Emit through the event emitter (listeners on SSE endpoint will pick this up)
271- sseEmitter.emit(type, data, eventId);
272- }
273-
274- /**
275- * Get messages since a specific event ID (for catch-up)
276- */
277- getMessagesSince(roomId: string, lastEventId: string) {
278- return messageQueue.getMessagesSince(roomId, lastEventId);
279- }
280-
281- /**
282- * Format an SSE event for transmission
283- */
284- formatSSEEvent(event: SSEEvent, eventId: string): string {
285- const lines = [
286- `id: ${eventId}`,
287- `event: ${event.type}`,
288- `data: ${JSON.stringify(event.data)}`,
289- `retry: ${SSE_CONFIG.RETRY_INTERVAL}`,
290- '',
291- ''
292- ];
293- return lines.join('\n');
294- }
295-
296- /**
297- * Format a keep-alive comment
298- */
299- formatKeepAlive(): string {
300- return ':\n\n';
301- }
302-
303- /**
304- * Send heartbeats to all connections
305- */
306- private sendHeartbeats(): void {
307- const now = Date.now();
308- let sent = 0;
309-
310- for (const [connectionId, connection] of this.connections) {
311- if (connection.controller) {
312- try {
313- const heartbeatEvent: SSEEvent<'heartbeat'> = {
314- type: 'heartbeat',
315- data: {
316- connectionId,
317- timestamp: now,
318- serverTime: new Date(now).toISOString()
319- }
320- };
321- const payload = this.formatSSEEvent(heartbeatEvent, crypto.randomUUID());
322- connection.controller.enqueue(this.encoder.encode(payload));
323- sent++;
324- } catch {
325- // Connection might be closed, will be cleaned up later
326- }
327- }
328- }
329-
330- if (sent > 0) {
331- log.debug('Heartbeats sent', { count: sent });
332- }
333- }
334-
335- /**
336- * Clean up stale connections
337- */
338- private cleanupStaleConnections(): void {
339- const now = Date.now();
340- const staleConnections: string[] = [];
341-
342- for (const [connectionId, connection] of this.connections) {
343- const timeSinceHeartbeat = now - connection.lastHeartbeat;
344- if (timeSinceHeartbeat > SSE_CONFIG.HEARTBEAT_TIMEOUT) {
345- staleConnections.push(connectionId);
346- }
347- }
348-
349- if (staleConnections.length > 0) {
350- log.info('Cleaning up stale connections', { count: staleConnections.length });
351- for (const connId of staleConnections) {
352- this.removeConnection(connId);
353- }
354- }
355- }
356-
357- /**
358- * Get all active user IDs
359- */
360- getActiveUserIds(): string[] {
361- return Array.from(this.userConnections.keys());
362- }
363-
364- /**
365- * Get connection statistics
366- */
367- getStats(): {
368- totalConnections: number;
369- uniqueUsers: number;
370- queueStats: { rooms: number; totalMessages: number };
371- } {
372- return {
373- totalConnections: this.connections.size,
374- uniqueUsers: this.userConnections.size,
375- queueStats: messageQueue.getStats()
376- };
377- }
378-}
379-
380-// Singleton instance
381-export const sseManager = new SSEManager();
D src/lib/sse/__tests__/MessageQueue.test.ts +0 -156
@@ -1,156 +0,0 @@
1-/**
2- * Unit tests for MessageQueue
3- */
4-
5-import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
6-import { MessageQueue } from '../MessageQueue';
7-
8-describe('MessageQueue', () => {
9- let queue: MessageQueue;
10-
11- beforeEach(() => {
12- // Create a queue with short TTL for testing
13- queue = new MessageQueue(1000, 10); // 1 second TTL, max 10 messages
14- });
15-
16- afterEach(() => {
17- queue.stopCleanup();
18- queue.clear();
19- });
20-
21- describe('enqueue', () => {
22- it('should add a message to the queue and return an ID', () => {
23- const id = queue.enqueue('room1', 'chatMessage', { text: 'hello' });
24-
25- expect(id).toBeDefined();
26- expect(typeof id).toBe('string');
27- expect(id.length).toBeGreaterThan(0);
28- });
29-
30- it('should store messages by room ID', () => {
31- queue.enqueue('room1', 'chatMessage', { text: 'hello' });
32- queue.enqueue('room2', 'chatMessage', { text: 'world' });
33-
34- const room1Messages = queue.getMessages('room1');
35- const room2Messages = queue.getMessages('room2');
36-
37- expect(room1Messages).toHaveLength(1);
38- expect(room2Messages).toHaveLength(1);
39- expect(room1Messages[0].data).toEqual({ text: 'hello' });
40- expect(room2Messages[0].data).toEqual({ text: 'world' });
41- });
42-
43- it('should enforce max queue size', () => {
44- // Add more than max (10) messages
45- for (let i = 0; i < 15; i++) {
46- queue.enqueue('room1', 'chatMessage', { index: i });
47- }
48-
49- const messages = queue.getMessages('room1');
50- expect(messages).toHaveLength(10);
51-
52- // Should keep the newest messages
53- expect(messages[0].data).toEqual({ index: 5 });
54- expect(messages[9].data).toEqual({ index: 14 });
55- });
56- });
57-
58- describe('getMessagesSince', () => {
59- it('should return messages after a given ID', () => {
60- const id1 = queue.enqueue('room1', 'chatMessage', { index: 1 });
61- const id2 = queue.enqueue('room1', 'chatMessage', { index: 2 });
62- queue.enqueue('room1', 'chatMessage', { index: 3 });
63-
64- const messagesSinceId1 = queue.getMessagesSince('room1', id1);
65- expect(messagesSinceId1).toHaveLength(2);
66- expect(messagesSinceId1[0].data).toEqual({ index: 2 });
67- expect(messagesSinceId1[1].data).toEqual({ index: 3 });
68-
69- const messagesSinceId2 = queue.getMessagesSince('room1', id2);
70- expect(messagesSinceId2).toHaveLength(1);
71- expect(messagesSinceId2[0].data).toEqual({ index: 3 });
72- });
73-
74- it('should return recent messages if ID not found', () => {
75- for (let i = 0; i < 5; i++) {
76- queue.enqueue('room1', 'chatMessage', { index: i });
77- }
78-
79- const messages = queue.getMessagesSince('room1', 'unknown-id');
80- expect(messages).toHaveLength(5);
81- });
82-
83- it('should return empty array for non-existent room', () => {
84- const messages = queue.getMessagesSince('nonexistent', 'some-id');
85- expect(messages).toEqual([]);
86- });
87- });
88-
89- describe('getLatestEventId', () => {
90- it('should return the ID of the last message', () => {
91- queue.enqueue('room1', 'chatMessage', { index: 1 });
92- const id2 = queue.enqueue('room1', 'chatMessage', { index: 2 });
93-
94- expect(queue.getLatestEventId('room1')).toBe(id2);
95- });
96-
97- it('should return null for empty room', () => {
98- expect(queue.getLatestEventId('nonexistent')).toBeNull();
99- });
100- });
101-
102- describe('hasEventId', () => {
103- it('should return true if event ID exists', () => {
104- const id = queue.enqueue('room1', 'chatMessage', { text: 'hello' });
105- expect(queue.hasEventId('room1', id)).toBe(true);
106- });
107-
108- it('should return false if event ID does not exist', () => {
109- expect(queue.hasEventId('room1', 'unknown')).toBe(false);
110- });
111- });
112-
113- describe('cleanup', () => {
114- it('should remove expired messages', async () => {
115- vi.useFakeTimers();
116-
117- queue.enqueue('room1', 'chatMessage', { text: 'old' });
118-
119- // Fast-forward past TTL
120- vi.advanceTimersByTime(2000);
121-
122- const removed = queue.cleanup();
123- expect(removed).toBe(1);
124- expect(queue.getMessages('room1')).toHaveLength(0);
125-
126- vi.useRealTimers();
127- });
128-
129- it('should keep non-expired messages', async () => {
130- vi.useFakeTimers();
131-
132- queue.enqueue('room1', 'chatMessage', { text: 'message' });
133-
134- // Fast-forward but not past TTL
135- vi.advanceTimersByTime(500);
136-
137- const removed = queue.cleanup();
138- expect(removed).toBe(0);
139- expect(queue.getMessages('room1')).toHaveLength(1);
140-
141- vi.useRealTimers();
142- });
143- });
144-
145- describe('getStats', () => {
146- it('should return correct statistics', () => {
147- queue.enqueue('room1', 'chatMessage', { text: '1' });
148- queue.enqueue('room1', 'chatMessage', { text: '2' });
149- queue.enqueue('room2', 'chatMessage', { text: '3' });
150-
151- const stats = queue.getStats();
152- expect(stats.rooms).toBe(2);
153- expect(stats.totalMessages).toBe(3);
154- });
155- });
156-});
D src/lib/sse/__tests__/SSEEventEmitter.test.ts +0 -187
@@ -1,187 +0,0 @@
1-/**
2- * Unit tests for SSEEventEmitter
3- */
4-
5-import { describe, it, expect, beforeEach, vi } from 'vitest';
6-
7-// We need to import the class, not the singleton for testing
8-// First, let's create a test-friendly version
9-
10-describe('SSEEventEmitter', () => {
11- // Import the module fresh for each test
12- let SSEEventEmitter: typeof import('../SSEEventEmitter');
13-
14- beforeEach(async () => {
15- // Clear module cache and re-import
16- vi.resetModules();
17- SSEEventEmitter = await import('../SSEEventEmitter');
18- });
19-
20- describe('emit', () => {
21- it('should broadcast events to all listeners on the sse channel', () => {
22- const { sseEmitter } = SSEEventEmitter;
23- const listener = vi.fn();
24-
25- sseEmitter.addListener('sse', listener);
26-
27- sseEmitter.emit('chatMessage', {
28- id: '123',
29- chatRoomId: 'room1',
30- senderId: 'user1',
31- content: 'Hello',
32- type: 'chat',
33- timestamp: Date.now()
34- });
35-
36- expect(listener).toHaveBeenCalledTimes(1);
37- expect(listener).toHaveBeenCalledWith(
38- expect.objectContaining({
39- type: 'chatMessage',
40- data: expect.objectContaining({
41- id: '123',
42- content: 'Hello'
43- })
44- })
45- );
46- });
47-
48- it('should include event ID when provided', () => {
49- const { sseEmitter } = SSEEventEmitter;
50- const listener = vi.fn();
51-
52- sseEmitter.addListener('sse', listener);
53-
54- sseEmitter.emit(
55- 'chatMessage',
56- {
57- id: '123',
58- chatRoomId: 'room1',
59- senderId: 'user1',
60- content: 'Hello',
61- type: 'chat',
62- timestamp: Date.now()
63- },
64- 'event-id-456'
65- );
66-
67- expect(listener).toHaveBeenCalledWith(
68- expect.objectContaining({
69- id: 'event-id-456'
70- })
71- );
72- });
73- });
74-
75- describe('addListener', () => {
76- it('should return a cleanup function', () => {
77- const { sseEmitter } = SSEEventEmitter;
78- const listener = vi.fn();
79-
80- const cleanup = sseEmitter.addListener('sse', listener);
81-
82- expect(typeof cleanup).toBe('function');
83- expect(sseEmitter.getListenerCount('sse')).toBe(1);
84-
85- cleanup();
86- expect(sseEmitter.getListenerCount('sse')).toBe(0);
87- });
88-
89- it('should support multiple listeners', () => {
90- const { sseEmitter } = SSEEventEmitter;
91- const listener1 = vi.fn();
92- const listener2 = vi.fn();
93-
94- sseEmitter.addListener('sse', listener1);
95- sseEmitter.addListener('sse', listener2);
96-
97- sseEmitter.emit('buddyListUpdate', []);
98-
99- expect(listener1).toHaveBeenCalledTimes(1);
100- expect(listener2).toHaveBeenCalledTimes(1);
101- });
102- });
103-
104- describe('removeListener', () => {
105- it('should remove a specific listener', () => {
106- const { sseEmitter } = SSEEventEmitter;
107- const listener = vi.fn();
108-
109- sseEmitter.addListener('sse', listener);
110- expect(sseEmitter.getListenerCount('sse')).toBe(1);
111-
112- const removed = sseEmitter.removeListener('sse', listener);
113- expect(removed).toBe(true);
114- expect(sseEmitter.getListenerCount('sse')).toBe(0);
115- });
116-
117- it('should return false for non-existent listener', () => {
118- const { sseEmitter } = SSEEventEmitter;
119- const listener = vi.fn();
120-
121- const removed = sseEmitter.removeListener('sse', listener);
122- expect(removed).toBe(false);
123- });
124- });
125-
126- describe('removeAllListeners', () => {
127- it('should remove all listeners for a channel', () => {
128- const { sseEmitter } = SSEEventEmitter;
129- const listener1 = vi.fn();
130- const listener2 = vi.fn();
131-
132- sseEmitter.addListener('sse', listener1);
133- sseEmitter.addListener('sse', listener2);
134- expect(sseEmitter.getListenerCount('sse')).toBe(2);
135-
136- sseEmitter.removeAllListeners('sse');
137- expect(sseEmitter.getListenerCount('sse')).toBe(0);
138- });
139-
140- it('should remove all listeners when no channel specified', () => {
141- const { sseEmitter } = SSEEventEmitter;
142- const listener = vi.fn();
143-
144- sseEmitter.addListener('sse', listener);
145- sseEmitter.addListener('other', listener);
146-
147- sseEmitter.removeAllListeners();
148- expect(sseEmitter.getListenerCount()).toBe(0);
149- });
150- });
151-
152- describe('hasListeners', () => {
153- it('should return true when channel has listeners', () => {
154- const { sseEmitter } = SSEEventEmitter;
155- const listener = vi.fn();
156-
157- sseEmitter.addListener('sse', listener);
158- expect(sseEmitter.hasListeners('sse')).toBe(true);
159- });
160-
161- it('should return false when channel has no listeners', () => {
162- const { sseEmitter } = SSEEventEmitter;
163- expect(sseEmitter.hasListeners('sse')).toBe(false);
164- });
165- });
166-
167- describe('error handling', () => {
168- it('should continue broadcasting even if a listener throws', () => {
169- const { sseEmitter } = SSEEventEmitter;
170- const badListener = vi.fn(() => {
171- throw new Error('Listener error');
172- });
173- const goodListener = vi.fn();
174-
175- sseEmitter.addListener('sse', badListener);
176- sseEmitter.addListener('sse', goodListener);
177-
178- // Should not throw
179- expect(() => {
180- sseEmitter.emit('buddyListUpdate', []);
181- }).not.toThrow();
182-
183- // Good listener should still be called
184- expect(goodListener).toHaveBeenCalledTimes(1);
185- });
186- });
187-});
D src/lib/sse/config.ts +0 -53
@@ -1,53 +0,0 @@
1-/**
2- * Centralized SSE Configuration Constants
3- *
4- * All timing constants for SSE connection management are defined here
5- * to ensure consistency between server and client implementations.
6- */
7-
8-export const SSE_CONFIG = {
9- // Server-side timing
10- /** Heartbeat interval - server sends ping every 30 seconds */
11- HEARTBEAT_INTERVAL: 30_000,
12-
13- /** Connection timeout - mark user offline after 5 minutes of inactivity */
14- CONNECTION_TIMEOUT: 5 * 60_000,
15-
16- /** Stale connection check interval - cleanup every 60 seconds */
17- STALE_CHECK_INTERVAL: 60_000,
18-
19- /** Message queue TTL - keep messages for 5 minutes for catch-up */
20- MESSAGE_QUEUE_TTL: 5 * 60_000,
21-
22- /** Maximum messages to queue per room */
23- MESSAGE_QUEUE_MAX_SIZE: 1000,
24-
25- /** Buddy list broadcast interval */
26- BUDDY_LIST_BROADCAST_INTERVAL: 10_000,
27-
28- // Client-side timing
29- /** Initial reconnect delay - 1 second */
30- INITIAL_RECONNECT_DELAY: 1_000,
31-
32- /** Maximum reconnect delay - 60 seconds cap */
33- MAX_RECONNECT_DELAY: 60_000,
34-
35- /** Heartbeat timeout - 45 seconds (1.5x server interval) */
36- HEARTBEAT_TIMEOUT: 45_000,
37-
38- // Shared timing
39- /** Idle timeout - mark as idle after 2 minutes */
40- IDLE_TIMEOUT: 2 * 60_000,
41-
42- /** Status update interval */
43- STATUS_UPDATE_INTERVAL: 30_000,
44-
45- /** EventSource retry hint (ms) - sent to client */
46- RETRY_INTERVAL: 3_000
47-} as const;
48-
49-/** SSE Event Types */
50-export type SSEEventType = 'chatMessage' | 'userStatusUpdate' | 'buddyListUpdate' | 'heartbeat';
51-
52-/** Connection status */
53-export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected';
M src/lib/states/chat.svelte.ts +25 -689
@@ -1,730 +1,66 @@
11 /**
22 * Chat State Management
33 *
4- * Central state manager for chat functionality using Svelte 5 runes.
5- * Handles SSE connections, messages, and user state.
4+ * Minimal state manager for user session.
5+ * Real-time data (messages, users) comes from Convex subscriptions in components.
66 */
77
8-import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat';
8+import type { User, SafeUser } from '../types/chat';
99 import { createSafeUser } from '../types/chat';
10-import type {
11- SendMessageRequest,
12- SendMessageResponse,
13- GetMessagesResponse
14-} from '../types/payloads';
15-import type { TextStyle } from '../types/text-formatting';
16-import { invalidate } from '$app/navigation';
17-import { env } from '$env/dynamic/public';
18-import { SSEClient, type SSEClientConfig } from '../sse/SSEClient.svelte';
19-import type { SSEEventMap } from '../sse/SSEEventEmitter';
20-import type { ConnectionStatus } from '../sse/config';
21-
22-// Use the public environment variable with a fallback
23-const DEFAULT_CHAT_ROOM_ID =
24- env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001';
2510
2611 class ChatState {
27- // User and message state
28- private users = $state<SafeUser[]>([]);
29- private messages = $state<Message[]>([]);
30- private currentUser = $state<SafeUser | null>(null);
31- private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID);
32- private userCache = $state<Record<string, SafeUser>>({});
33- private hasMoreMessages = $state(false);
34-
35- // SSE Client
36- private sseClient: SSEClient | null = null;
37-
38- // Connection state
39- private _connectionStatus = $state<ConnectionStatus>('disconnected');
40- private _sseError = $state<string | null>(null);
41-
42- // Internal state flags
43- private isInitializing = $state(false);
44- private isSettingUser = $state(false);
45-
46- // Memoization for enriched messages
47- private _memoizedEnrichedMessages: EnrichedMessage[] | null = null;
48- private _lastEnrichmentKey: string = '';
49-
50- // Buddy list tracking
51- private lastBuddyListHash = '';
12+ // Current user state (from session)
13+ private _currentUser = $state<SafeUser | null>(null);
14+ private _currentRoomId = $state<string | null>(null);
5215
53- // Public polling for unauthenticated users
54- private publicPollingInterval: ReturnType<typeof setInterval> | null = null;
16+ // Connection state for UI feedback
17+ private _connectionStatus = $state<'connected' | 'disconnected' | 'reconnecting'>('disconnected');
5518
56- // Derived state
19+ // Derived public state
5720 public connectionStatus = $derived(this._connectionStatus);
58- public sseError = $derived(this._sseError);
5921 public isConnected = $derived(this._connectionStatus === 'connected');
60- public isReconnecting = $derived(this._connectionStatus === 'reconnecting');
61-
62- /**
63- * Reinitialize the chat state (e.g., after login)
64- */
65- public async reinitialize() {
66- if (this.isInitializing) {
67- console.debug('Reinitialize already in progress, skipping.');
68- return;
69- }
70- this.isInitializing = true;
71- console.debug('Reinitializing chat state...');
72-
73- // Disconnect existing SSE client
74- if (this.sseClient) {
75- this.sseClient.disconnect();
76- this.sseClient = null;
77- }
78-
79- this.messages = [];
80- this.currentRoomId = DEFAULT_CHAT_ROOM_ID;
81-
82- if (this.currentUser) {
83- console.debug('Waiting for session cookie update before setting up SSE...');
84- await new Promise((resolve) => setTimeout(resolve, 1000));
85-
86- // Set up SSE connection
87- this.setupSSE();
88-
89- // Initialize messages and room users
90- await Promise.all([this.initializeMessages(), this.initializeRoomUsers()]);
91- }
92-
93- this.isInitializing = false;
94- }
95-
96- /**
97- * Get SSE error information
98- */
99- getSSEError() {
100- return {
101- error: this._sseError,
102- status: this._connectionStatus,
103- retryAfter: this.sseClient?.getReconnectDelay() ?? null
104- };
105- }
10622
10723 /**
10824 * Get the current user
10925 */
11026 getCurrentUser() {
111- return this.currentUser;
112- }
113-
114- /**
115- * Set the current user
116- */
117- async setCurrentUser(user: User | SafeUser | null) {
118- console.debug('setCurrentUser called with', user ? { ...user, password: '[REDACTED]' } : null);
119-
120- if (this.isSettingUser) {
121- console.debug('Already setting user, skipping');
122- return;
123- }
124-
125- this.isSettingUser = true;
126-
127- try {
128- if (!user && !this.currentUser) {
129- console.debug('No user to set and no current user, skipping');
130- return;
131- }
132-
133- const safeUser = user ? ('password' in user ? createSafeUser(user) : user) : null;
134-
135- // Check if same user with active connection
136- if (safeUser?.id === this.currentUser?.id) {
137- if (!this.sseClient || !this.sseClient.isConnected) {
138- console.debug('Same user being set but no active SSE connection, reinitializing.');
139- } else {
140- console.debug('Same user being set with active connection, skipping.');
141- return;
142- }
143- }
144-
145- this.messages = [];
146- this.currentUser = safeUser;
147-
148- if (safeUser) {
149- this.userCache[safeUser.id] = safeUser;
150- await this.reinitialize();
151- } else {
152- // Cleanup when user is removed
153- if (this.sseClient) {
154- this.sseClient.disconnect();
155- this.sseClient = null;
156- }
157- this.messages = [];
158- this.users = [];
159- this.userCache = {};
160- this._connectionStatus = 'disconnected';
161- }
162- } finally {
163- this.isSettingUser = false;
164- }
165- }
166-
167- /**
168- * Initialize room users
169- */
170- private async initializeRoomUsers() {
171- console.debug('Initializing room users for room:', this.currentRoomId);
172- try {
173- const isPublic = !this.currentUser;
174- const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`;
175- console.debug('Fetching room users from:', url);
176-
177- const response = await fetch(url, { credentials: 'include' });
178-
179- if (!response.ok) {
180- throw new Error('Failed to fetch room users');
181- }
182-
183- const data = await response.json();
184- if (!data.success) {
185- throw new Error(data.error);
186- }
187-
188- console.debug('Fetched room buddy list:', data.buddyList);
189-
190- // Merge fetched buddy list with existing cached users
191- // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for merging
192- const mergedUsers = new Map<string, SafeUser>();
193- Object.values(this.userCache).forEach((user) => mergedUsers.set(user.id, user));
194- data.buddyList.forEach((user: SafeUser) => mergedUsers.set(user.id, user));
195-
196- this.users = Array.from(mergedUsers.values());
197- this.users.forEach((user) => {
198- this.userCache[user.id] = user;
199- });
200- } catch (error) {
201- console.debug('Error fetching room users:', error);
202- this.users = [];
203- }
27+ return this._currentUser;
20428 }
20529
20630 /**
207- * Get online users sorted by status
31+ * Set the current user (called from layout when session changes)
20832 */
209- getOnlineUsers() {
210- return [...this.users].sort((a: SafeUser, b: SafeUser) => {
211- const statusOrder: Record<SafeUser['status'], number> = {
212- online: 0,
213- away: 1,
214- busy: 2,
215- offline: 3
216- };
217- return statusOrder[a.status] - statusOrder[b.status];
218- });
219- }
220-
221- /**
222- * Get user by ID
223- */
224- getUserById(userId: string) {
225- if (this.userCache[userId]) {
226- return this.userCache[userId];
227- }
228-
229- const fallback: SafeUser = {
230- id: userId,
231- nickname: 'Unknown User',
232- status: 'offline',
233- lastSeen: null,
234- avatarUrl: null
235- };
236- this.userCache[userId] = fallback;
237- console.debug('getUserById: returning fallback for missing user', { userId, fallback });
238- return fallback;
239- }
240-
241- /**
242- * Update user status
243- */
244- updateUserStatus(userId: string, status: User['status'], lastSeen?: number) {
245- console.debug('Updating user status with:', {
246- userId,
247- status,
248- lastSeen,
249- currentCache: this.userCache[userId],
250- currentUsers: this.users.find((u) => u.id === userId)
251- });
252-
253- const existingUser = this.userCache[userId];
254- if (
255- existingUser &&
256- existingUser.status === status &&
257- lastSeen &&
258- Math.abs((existingUser.lastSeen ?? 0) - lastSeen) < 5000
259- ) {
260- console.debug('Skipping redundant status update for user:', userId);
261- return;
262- }
263-
264- let user: SafeUser | undefined = this.userCache[userId];
265-
33+ setCurrentUser(user: User | SafeUser | null) {
26634 if (!user) {
267- const foundUser = this.users.find((u: SafeUser) => u.id === userId);
268- if (foundUser) {
269- user = foundUser;
270- }
271- }
272-
273- const now = Date.now();
274- if (user) {
275- const updatedUser: SafeUser = {
276- ...user,
277- status,
278- lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen)
279- };
280-
281- this.userCache[userId] = updatedUser;
282-
283- const index = this.users.findIndex((u: SafeUser) => u.id === userId);
284- if (index !== -1) {
285- this.users = [...this.users.slice(0, index), updatedUser, ...this.users.slice(index + 1)];
286- } else {
287- this.users = [...this.users, updatedUser];
288- }
289-
290- console.debug('User status updated:', {
291- userId,
292- newStatus: status,
293- newLastSeen: new Date(updatedUser.lastSeen || now).toISOString()
294- });
295- } else {
296- console.debug('User not found in cache or list, fetching from API:', userId);
297- fetch(`/api/users/${userId}`, { credentials: 'include' })
298- .then((response) => response.json())
299- .then((data) => {
300- if (data.success && data.user) {
301- const newUser: SafeUser = {
302- ...data.user,
303- status,
304- lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen)
305- };
306- this.userCache[userId] = newUser;
307- this.users = [...this.users, newUser];
308-
309- console.debug('User fetched and status updated:', {
310- userId,
311- status: newUser.status
312- });
313- }
314- })
315- .catch((error) => console.error('Error fetching user data:', error));
316- }
317- }
318-
319- /**
320- * Get enriched messages with user data
321- */
322- getMessages() {
323- const key =
324- this.messages.map((m) => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(',');
325- if (key === this._lastEnrichmentKey && this._memoizedEnrichedMessages !== null) {
326- return this._memoizedEnrichedMessages;
327- }
328- this._lastEnrichmentKey = key;
329- console.debug('Enriching messages with user data', {
330- messagesCount: this.messages.length,
331- userCacheSize: Object.keys(this.userCache).length
332- });
333- this._memoizedEnrichedMessages = this.enrichMessages(this.messages);
334- return this._memoizedEnrichedMessages;
335- }
336-
337- /**
338- * Prepend messages (for loading older messages)
339- */
340- public prependMessages(newMessages: Message[], hasMore?: boolean) {
341- console.debug('Prepending messages:', { count: newMessages.length });
342-
343- const uniqueUserIds = new Set(newMessages.map((msg) => msg.senderId));
344- const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]);
345-
346- if (missingUserIds.length > 0) {
347- console.debug('Fetching missing user data for IDs:', missingUserIds);
348- Promise.all(
349- missingUserIds.map(async (userId) => {
350- try {
351- const userResponse = await fetch(`/api/users/${userId}`, {
352- credentials: 'include'
353- });
354- if (userResponse.ok) {
355- const userData = await userResponse.json();
356- if (userData.success && userData.user) {
357- this.userCache[userId] = userData.user;
358- if (!this.users.some((u) => u.id === userId)) {
359- this.users = [...this.users, userData.user];
360- }
361- }
362- }
363- } catch (error) {
364- console.debug('Error fetching user data:', { userId, error });
365- }
366- })
367- );
368- }
369-
370- const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp);
371- const uniqueNewMessages = sortedNewMessages.filter(
372- (msg) => !this.messages.some((m) => m.id === msg.id)
373- );
374- this.messages = [...uniqueNewMessages, ...this.messages];
375-
376- if (hasMore !== undefined) {
377- this.hasMoreMessages = hasMore;
378- }
379- }
380-
381- /**
382- * Update user cache
383- */
384- updateUserCache(users: (User | SafeUser)[]) {
385- console.debug(
386- 'Updating user cache with users:',
387- users.map((u) => ({ ...u, password: '[REDACTED]' }))
388- );
389- users.forEach((user) => {
390- const safeUser = 'password' in user ? createSafeUser(user) : user;
391- if (!this.userCache[user.id] || safeUser.status !== this.userCache[user.id].status) {
392- this.userCache[user.id] = safeUser;
393- const existingIndex = this.users.findIndex((u) => u.id === user.id);
394- if (existingIndex === -1) {
395- this.users = [...this.users, safeUser];
396- } else if (safeUser.status !== this.users[existingIndex].status) {
397- this.users = [
398- ...this.users.slice(0, existingIndex),
399- safeUser,
400- ...this.users.slice(existingIndex + 1)
401- ];
402- }
403- }
404- });
405- }
406-
407- /**
408- * Update online users (from buddy list updates)
409- */
410- updateOnlineUsers(users: (User | SafeUser)[]) {
411- const now = Date.now();
412- const safeUsers = users.map((user) => ('password' in user ? createSafeUser(user) : user));
413- const newHash = JSON.stringify(safeUsers);
414-
415- if (newHash !== this.lastBuddyListHash) {
416- const statusCounts = safeUsers.reduce(
417- (acc, user) => {
418- acc[user.status] = (acc[user.status] || 0) + 1;
419- return acc;
420- },
421- {} as Record<string, number>
422- );
423-
424- console.debug('Updating online users (data changed):', {
425- total: safeUsers.length,
426- byStatus: statusCounts,
427- timestamp: new Date(now).toISOString()
428- });
429-
430- this.users = safeUsers;
431- safeUsers.forEach((user) => {
432- this.userCache[user.id] = user;
433- });
434- this.lastBuddyListHash = newHash;
435- }
436- }
437-
438- /**
439- * Update messages (for public/unauthenticated access)
440- */
441- updateMessages(messages: Message[]) {
442- const uniqueMessages = Array.from(new Map(messages.map((msg) => [msg.id, msg])).values());
443- this.messages = uniqueMessages.sort((a, b) => a.timestamp - b.timestamp);
444- }
445-
446- /**
447- * Initialize messages from the server
448- */
449- async initializeMessages() {
450- try {
451- // eslint-disable-next-line svelte/prefer-svelte-reactivity -- URLSearchParams for URL building
452- const params = new URLSearchParams();
453- if (!this.currentUser) {
454- params.append('public', 'true');
455- }
456- params.append('roomId', this.currentRoomId);
457-
458- const response = await fetch(`/api/chat/messages?${params}`, {
459- credentials: 'include'
460- });
461- if (!response.ok) throw new Error('Failed to fetch messages');
462-
463- const data = (await response.json()) as GetMessagesResponse;
464- if (!data.success) {
465- throw new Error(data.error);
466- }
467-
468- const uniqueUserIds = new Set(data.messages.map((msg) => msg.senderId));
469- const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]);
470-
471- if (missingUserIds.length > 0) {
472- console.debug('Fetching missing user data for IDs:', missingUserIds);
473- await Promise.all(
474- missingUserIds.map(async (userId) => {
475- try {
476- const userResponse = await fetch(`/api/users/${userId}`, {
477- credentials: 'include'
478- });
479- if (userResponse.ok) {
480- const userData = await userResponse.json();
481- if (userData.success && userData.user) {
482- this.userCache[userId] = userData.user;
483- if (!this.users.some((u) => u.id === userId)) {
484- this.users = [...this.users, userData.user];
485- }
486- }
487- }
488- } catch (error) {
489- console.debug('Error fetching user data:', { userId, error });
490- }
491- })
492- );
493- }
494-
495- this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp);
496-
497- if ('hasMore' in data) {
498- this.hasMoreMessages = data.hasMore;
499- }
500-
501- console.debug('Messages initialized:', {
502- count: data.messages.length,
503- isAuthenticated: !!this.currentUser,
504- hasMore: this.hasMoreMessages
505- });
506- } catch (error) {
507- console.debug('Error fetching initial messages:', error);
508- this.messages = [];
509- this.hasMoreMessages = false;
510- }
511- }
512-
513- /**
514- * Send a message
515- */
516- async sendMessage(
517- content: string,
518- type: Message['type'] = 'chat',
519- textStyle?: TextStyle
520- ): Promise<SendMessageResponse> {
521- const user = this.getCurrentUser();
522- if (!user) {
523- console.debug('Cannot send message: No current user');
524- return {
525- success: false,
526- error: 'Not logged in'
527- };
528- }
529-
530- try {
531- const payload: SendMessageRequest = {
532- content,
533- type,
534- userId: user.id,
535- chatRoomId: DEFAULT_CHAT_ROOM_ID,
536- styleData: textStyle ? JSON.stringify(textStyle) : undefined
537- };
538-
539- console.debug('Sending message with payload:', payload);
540-
541- const response = await fetch('/api/chat/messages', {
542- method: 'POST',
543- headers: {
544- 'Content-Type': 'application/json'
545- },
546- credentials: 'include',
547- body: JSON.stringify(payload)
548- });
549-
550- if (!response.ok) {
551- const errorData = await response.json();
552- throw new Error(errorData.error || 'Failed to save message');
553- }
554-
555- const data = (await response.json()) as SendMessageResponse;
556- if (!data.success) {
557- throw new Error(data.error);
558- }
559-
560- console.debug('Message sent successfully:', data.message);
561-
562- // Add message to state immediately for instant display (optimistic update)
563- // SSE deduplication will handle any duplicate when the broadcast arrives
564- if (data.message) {
565- this.messages = Array.from(
566- new Map([...this.messages, data.message].map((m) => [m.id, m])).values()
567- );
568- this.messages.sort((a, b) => a.timestamp - b.timestamp);
569- }
570-
571- await invalidate('chat:messages');
572- return data;
573- } catch (error) {
574- console.debug('Error sending message:', error);
575- return {
576- success: false,
577- error: 'Failed to send message'
578- };
579- }
580- }
581-
582- /**
583- * Set up SSE connection using the new SSEClient
584- */
585- private setupSSE() {
586- if (this.sseClient && this.sseClient.isConnected) {
587- console.debug('setupSSE: Active SSE connection exists, skipping setup.');
35+ this._currentUser = null;
36+ this._connectionStatus = 'disconnected';
58837 return;
58938 }
59039
591- if (!this.currentUser) {
592- console.debug('No current user, skipping SSE connection');
593- return;
594- }
595-
596- console.debug('Setting up SSE connection...');
597-
598- const config: SSEClientConfig = {
599- url: '/api/sse',
600- onMessage: this.handleSSEMessage.bind(this),
601- onStatusChange: this.handleConnectionStatusChange.bind(this),
602- onError: (error) => {
603- this._sseError = error;
604- console.debug('SSE error:', error);
605- }
606- };
607-
608- this.sseClient = new SSEClient(config);
609- this.sseClient.connect();
40+ const safeUser = 'password' in user ? createSafeUser(user) : user;
41+ this._currentUser = safeUser;
42+ this._connectionStatus = 'connected';
61043 }
61144
61245 /**
613- * Handle incoming SSE messages
46+ * Get the current room ID
61447 */
615- private handleSSEMessage<T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T]) {
616- switch (type) {
617- case 'chatMessage':
618- this.handleChatMessage(data as SSEEventMap['chatMessage']);
619- break;
620- case 'buddyListUpdate':
621- this.updateOnlineUsers(data as SSEEventMap['buddyListUpdate']);
622- break;
623- case 'userStatusUpdate': {
624- const statusData = data as SSEEventMap['userStatusUpdate'];
625- this.updateUserStatus(statusData.userId, statusData.status, statusData.lastSeen);
626- break;
627- }
628- case 'heartbeat':
629- // Heartbeat is handled by SSEClient, no action needed here
630- break;
631- default:
632- console.debug('Unknown SSE event type:', type);
633- }
48+ getCurrentRoomId() {
49+ return this._currentRoomId;
63450 }
63551
63652 /**
637- * Handle chat message from SSE
53+ * Set the current room ID
63854 */
639- private async handleChatMessage(messageData: Message) {
640- console.debug('Received chat message via SSE:', messageData);
641-
642- // Deduplicate and update messages array
643- this.messages = Array.from(
644- new Map([...this.messages, messageData].map((m) => [m.id, m])).values()
645- );
646- this.messages.sort((a, b) => a.timestamp - b.timestamp);
647-
648- // Ensure the sender's data is available
649- await this.ensureUserData(messageData.senderId);
55+ setCurrentRoomId(roomId: string | null) {
56+ this._currentRoomId = roomId;
65057 }
65158
65259 /**
653- * Handle connection status changes
60+ * Set connection status
65461 */
655- private handleConnectionStatusChange(status: ConnectionStatus) {
62+ setConnectionStatus(status: 'connected' | 'disconnected' | 'reconnecting') {
65663 this._connectionStatus = status;
657- console.debug('SSE connection status changed:', status);
658-
659- if (status === 'connected') {
660- this._sseError = null;
661- }
662- }
663-
664- /**
665- * Ensure user data is loaded
666- */
667- private async ensureUserData(userId: string) {
668- if (this.userCache[userId]) return;
669-
670- console.debug('Fetching user data for:', userId);
671- try {
672- const response = await fetch(`/api/users/${userId}`, {
673- credentials: 'include'
674- });
675- if (response.ok) {
676- const userData = await response.json();
677- if (userData.success && userData.user) {
678- this.userCache[userId] = userData.user;
679- if (!this.users.some((u) => u.id === userId)) {
680- this.users = [...this.users, userData.user];
681- }
682- }
683- }
684- } catch (error) {
685- console.debug('Error fetching user data:', error);
686- }
687- }
688-
689- /**
690- * Enrich messages with user data
691- */
692- enrichMessages(messages: Message[]): EnrichedMessage[] {
693- console.debug('Enriching messages with user data', {
694- messagesCount: messages.length,
695- userCacheSize: Object.keys(this.userCache).length
696- });
697- return messages.map((message) => ({
698- ...message,
699- user: this.userCache[message.senderId] || {
700- id: message.senderId,
701- nickname: 'Unknown User',
702- status: 'offline',
703- avatarUrl: null,
704- lastSeen: null
705- }
706- }));
707- }
708-
709- /**
710- * Get the default chat room ID
711- */
712- getDefaultChatRoomId() {
713- return DEFAULT_CHAT_ROOM_ID;
714- }
715-
716- /**
717- * Disconnect SSE (for cleanup)
718- */
719- disconnect() {
720- if (this.sseClient) {
721- this.sseClient.disconnect();
722- this.sseClient = null;
723- }
724- if (this.publicPollingInterval) {
725- clearInterval(this.publicPollingInterval);
726- this.publicPollingInterval = null;
727- }
72864 }
72965 }
73066
M src/lib/utils/chat.server.ts +12 -43
@@ -1,56 +1,25 @@
1-import type { LibSQLDatabase } from 'drizzle-orm/libsql';
21 import { createLogger } from '$lib/utils/logger.server';
3-import schema, { chatRooms } from '$lib/db/schema';
4-import { eq } from 'drizzle-orm';
5-import { env } from '$env/dynamic/public';
2+import { chatRooms } from '$lib/db/convex.server';
63
74 const log = createLogger('chat-utils');
85
9-// Default chat room ID from environment variable
10-export const DEFAULT_CHAT_ROOM_ID =
11- env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001';
6+// Default chat room ID - will be fetched/created on startup
7+let defaultChatRoomId: string | null = null;
128
13-// Type guard for LibSQL errors
14-interface LibSQLError {
15- code: string;
16- message: string;
9+// Function to get the default chat room ID
10+export function getDefaultChatRoomId(): string | null {
11+ return defaultChatRoomId;
1712 }
1813
19-function isLibSQLError(error: unknown): error is LibSQLError {
20- return typeof error === 'object' && error !== null && 'code' in error;
21-}
22-
23-// Function to ensure default chat room exists
24-export async function ensureDefaultChatRoom(db: LibSQLDatabase<typeof schema>) {
14+// Function to ensure default chat room exists (using Convex)
15+export async function ensureDefaultChatRoom(): Promise<string> {
2516 log.debug('Ensuring default chat room exists...');
2617 try {
27- // Use a transaction to handle concurrent creation attempts
28- await db.transaction(async (tx) => {
29- const defaultRoom = await tx
30- .select()
31- .from(chatRooms)
32- .where(eq(chatRooms.id, DEFAULT_CHAT_ROOM_ID))
33- .get();
34-
35- if (!defaultRoom) {
36- log.debug('Creating default chat room...');
37- await tx.insert(chatRooms).values({
38- id: DEFAULT_CHAT_ROOM_ID,
39- name: 'General',
40- type: 'group',
41- createdAt: Date.now()
42- });
43- log.debug('Default chat room created successfully');
44- } else {
45- log.debug('Default chat room already exists');
46- }
47- });
18+ const room = await chatRooms.getOrCreateDefault();
19+ defaultChatRoomId = room._id;
20+ log.debug('Default chat room ready', { id: defaultChatRoomId });
21+ return defaultChatRoomId;
4822 } catch (error: unknown) {
49- // If the error is a unique constraint violation, another process probably created the room
50- if (isLibSQLError(error) && error.code === 'SQLITE_CONSTRAINT') {
51- log.debug('Default chat room was created by another process');
52- return;
53- }
5423 log.error('Error ensuring default chat room:', {
5524 error: error instanceof Error ? error.message : 'Unknown error'
5625 });
M src/lib/utils/password.ts +16 -1
@@ -23,6 +23,21 @@ function hexToBuffer(hex: string): Uint8Array {
2323 return arr;
2424 }
2525
26+/**
27+ * Performs a constant-time string comparison to prevent timing attacks.
28+ * Returns true if strings are equal, false otherwise.
29+ */
30+function constantTimeCompare(a: string, b: string): boolean {
31+ if (a.length !== b.length) {
32+ return false;
33+ }
34+ let result = 0;
35+ for (let i = 0; i < a.length; i++) {
36+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
37+ }
38+ return result === 0;
39+}
40+
2641 /**
2742 * Derives a password hash using PBKDF2 with the provided salt.
2843 * @param password The plain text password.
@@ -70,5 +85,5 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
7085 const [saltHex, hashValue] = hash.split(':');
7186 const salt = hexToBuffer(saltHex);
7287 const derivedHash = await derivePasswordHash(password, salt);
73- return derivedHash === hashValue;
88+ return constantTimeCompare(derivedHash, hashValue);
7489 }
M src/routes/+layout.svelte +9 -5
@@ -1,9 +1,13 @@
11 <script lang="ts">
22 import '../app.css';
3+ import { setupConvex } from 'convex-svelte';
4+ import { PUBLIC_CONVEX_URL } from '$env/static/public';
35 import { chatState } from '$lib/states/chat.svelte';
46 import { onMount } from 'svelte';
57 import { browser } from '$app/environment';
6- import { SSE_CONFIG } from '$lib/sse/config';
8+
9+ // Initialize Convex client for real-time subscriptions
10+ setupConvex(PUBLIC_CONVEX_URL);
711
812 let { children, data } = $props();
913 let isVisible = $state(true);
@@ -12,14 +16,14 @@
1216 let updateInProgress = $state(false);
1317 let idleTimeout = $state<number | undefined>(undefined);
1418
15- // Timing constants from SSE config for consistency
19+ // Status update timing constants
1620 let lastSentStatus = $state<string | null>(null);
1721 let lastStatusSentAt = $state<number>(0);
1822 let lastUserActivity = $state<number>(Date.now());
19- const STATUS_UPDATE_INTERVAL = SSE_CONFIG.STATUS_UPDATE_INTERVAL; // 30 seconds
23+ const STATUS_UPDATE_INTERVAL = 30000; // 30 seconds
2024 const THROTTLE_TIME_MS = 10000; // 10 seconds
21- const FORCE_UPDATE_INTERVAL = SSE_CONFIG.CONNECTION_TIMEOUT; // 5 minutes
22- const IDLE_TIMEOUT = SSE_CONFIG.IDLE_TIMEOUT; // 2 minutes of inactivity = idle (aligned with server)
25+ const FORCE_UPDATE_INTERVAL = 5 * 60 * 1000; // 5 minutes
26+ const IDLE_TIMEOUT = 2 * 60 * 1000; // 2 minutes of inactivity = idle
2327
2428 type UserStatus = 'online' | 'offline' | 'busy' | 'idle';
2529
M src/routes/+page.server.ts +22 -31
@@ -1,9 +1,11 @@
11 import type { PageServerLoad } from './$types';
22 import { validateSessionToken } from '$lib/api/session.server';
3-import db from '$lib/db/db.server';
4-import { messages } from '$lib/db/schema';
5-import { eq, desc } from 'drizzle-orm';
6-import { DEFAULT_TEXT_STYLE, type TextStyle } from '$lib/types/text-formatting';
3+import {
4+ DEFAULT_TEXT_STYLE,
5+ DEFAULT_USER_PREFERENCES,
6+ type TextStyle
7+} from '$lib/types/text-formatting';
8+import convex from '$lib/db/convex.server';
79
810 export const load: PageServerLoad = async ({ cookies }) => {
911 const token = cookies.get('session');
@@ -11,7 +13,8 @@ export const load: PageServerLoad = async ({ cookies }) => {
1113 if (!token) {
1214 return {
1315 user: null,
14- lastTextStyle: DEFAULT_TEXT_STYLE
16+ textStyle: DEFAULT_TEXT_STYLE,
17+ userPreferences: DEFAULT_USER_PREFERENCES
1518 };
1619 }
1720
@@ -21,43 +24,31 @@ export const load: PageServerLoad = async ({ cookies }) => {
2124 if (!user) {
2225 return {
2326 user: null,
24- lastTextStyle: DEFAULT_TEXT_STYLE
27+ textStyle: DEFAULT_TEXT_STYLE,
28+ userPreferences: DEFAULT_USER_PREFERENCES
2529 };
2630 }
2731
28- // Get user's last used text styling from their most recent message
29- const lastMessage = await db.query.messages.findFirst({
30- where: eq(messages.senderId, user.id),
31- orderBy: [desc(messages.timestamp)],
32- columns: {
33- styleData: true
34- }
35- });
36-
37- let lastTextStyle: TextStyle = DEFAULT_TEXT_STYLE;
38-
39- if (lastMessage?.styleData) {
40- try {
41- const parsedStyle = JSON.parse(lastMessage.styleData);
42- // Merge with default style to ensure all required properties
43- lastTextStyle = {
44- ...DEFAULT_TEXT_STYLE,
45- ...parsedStyle
46- };
47- } catch (error) {
48- console.warn('Failed to parse last text style:', error);
49- }
50- }
32+ // Fetch user's text preferences from Convex
33+ const prefs = await convex.textPreferences.get(user.id);
5134
5235 return {
5336 user,
54- lastTextStyle
37+ textStyle: (prefs?.defaultStyle as TextStyle) ?? DEFAULT_TEXT_STYLE,
38+ userPreferences: prefs
39+ ? {
40+ defaultStyle: prefs.defaultStyle as TextStyle,
41+ allowFormatting: prefs.allowFormatting,
42+ maxMessageLength: prefs.maxMessageLength
43+ }
44+ : DEFAULT_USER_PREFERENCES
5545 };
5646 } catch (error) {
5747 console.error('Error loading page data:', error);
5848 return {
5949 user: null,
60- lastTextStyle: DEFAULT_TEXT_STYLE
50+ textStyle: DEFAULT_TEXT_STYLE,
51+ userPreferences: DEFAULT_USER_PREFERENCES
6152 };
6253 }
6354 };
M src/routes/+page.svelte +1 -1
@@ -18,7 +18,7 @@
1818 <DesktopIcons bind:showChatRoom bind:showAuth />
1919
2020 {#if showChatRoom}
21- <ChatRoom bind:showChatRoom initialTextStyle={data.lastTextStyle} />
21+ <ChatRoom bind:showChatRoom initialTextStyle={data.textStyle} />
2222 {/if}
2323
2424 {#if showAuth}
M src/routes/api/chat/messages/+server.ts +65 -96
@@ -1,18 +1,12 @@
1-import { v4 as uuidv4 } from 'uuid';
2-import db from '$lib/db/db.server';
3-import { messages } from '$lib/db/schema';
1+import { messages } from '$lib/db/convex.server';
2+import { getDefaultChatRoomId } from '$lib/utils/chat.server';
43 import type { Message } from '$lib/types/chat';
54 import type { SendMessageResponse, GetMessagesResponse } from '$lib/types/payloads';
6-import { and, desc, eq, lt } from 'drizzle-orm/sql';
7-import type { SQL } from 'drizzle-orm/sql';
85 import { error } from '@sveltejs/kit';
9-import { sseEmitter } from '$lib/sse/SSEEventEmitter';
10-import { chatRooms } from '$lib/db/schema';
11-import { users } from '$lib/db/schema';
12-import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server';
136 import { createLogger } from '$lib/utils/logger.server';
147 import { sanitizeStyleData } from '$lib/validation/text-formatting';
158 import { sendMessageSchema } from '$lib/validation/message';
9+import type { RequestHandler } from './$types';
1610
1711 const log = createLogger('chat-server');
1812
@@ -31,7 +25,6 @@ function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: nu
3125 currentCooldown: INITIAL_COOLDOWN
3226 };
3327
34- // Check if enough time has passed since the last message
3528 const timeSinceLastMessage = now - userState.lastMessageTime;
3629 if (timeSinceLastMessage < userState.currentCooldown) {
3730 return {
@@ -40,11 +33,9 @@ function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: nu
4033 };
4134 }
4235
43- // Reset cooldown if it's been long enough
4436 if (timeSinceLastMessage > userState.currentCooldown * 2) {
4537 userState.currentCooldown = INITIAL_COOLDOWN;
4638 } else {
47- // Exponential backoff
4839 userState.currentCooldown = Math.min(userState.currentCooldown * 2, MAX_COOLDOWN);
4940 }
5041
@@ -54,68 +45,65 @@ function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: nu
5445 return { canSend: true };
5546 }
5647
57-// GET endpoint: fetch messages directly from DB
58-export async function GET({ request, locals }) {
59- const url = new URL(request.url);
48+// GET endpoint: fetch messages from Convex
49+export const GET: RequestHandler = async ({ url, locals }) => {
6050 const beforeTimestamp = url.searchParams.get('before');
61- const roomId = url.searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID;
51+ const roomIdParam = url.searchParams.get('roomId');
6252 const isPublic = url.searchParams.get('public') === 'true';
6353
54+ // Use default room if not specified
55+ const roomId = roomIdParam || getDefaultChatRoomId();
56+
57+ if (!roomId) {
58+ log.error('No room ID available');
59+ throw error(500, 'Chat room not configured');
60+ }
61+
6462 try {
65- log.debug('Fetching messages from database', {
63+ log.debug('Fetching messages from Convex', {
6664 beforeTimestamp,
6765 roomId,
6866 isAuthenticated: !!locals.session,
6967 isPublic
7068 });
7169
72- let conditions = eq(messages.chatRoomId, roomId) as SQL<unknown>;
73-
74- if (beforeTimestamp) {
75- conditions = and(
76- conditions,
77- lt(messages.timestamp, parseInt(beforeTimestamp))
78- ) as SQL<unknown>;
79- }
80-
81- // Fetch messages with a limit based on authentication status
82- // Public requests are limited to 50 messages
83- // Authenticated users get 100 messages per request
8470 const fetchLimit = isPublic || !locals.session ? 50 : 100;
8571
86- const fetchedMessages = await db
87- .select()
88- .from(messages)
89- .where(conditions)
90- .orderBy(desc(messages.timestamp))
91- .limit(fetchLimit)
92- .then((rows) =>
93- rows.map((row) => ({
94- ...row,
95- styleData: row.styleData ?? undefined,
96- hasFormatting: row.hasFormatting ?? false
97- }))
98- );
72+ const result = await messages.getByRoom(
73+ roomId,
74+ fetchLimit,
75+ beforeTimestamp ? parseInt(beforeTimestamp) : undefined
76+ );
77+
78+ // Map Convex messages to our Message type
79+ const fetchedMessages: Message[] = result.messages.map((msg) => ({
80+ id: msg._id,
81+ chatRoomId: msg.chatRoomId,
82+ senderId: msg.senderId,
83+ content: msg.content,
84+ type: msg.type as Message['type'],
85+ timestamp: msg.timestamp,
86+ styleData: msg.styleData,
87+ hasFormatting: msg.hasFormatting ?? false
88+ }));
9989
10090 const response: GetMessagesResponse = {
10191 success: true,
10292 messages: fetchedMessages,
103- hasMore: fetchedMessages.length === fetchLimit // Indicate if there might be more messages
93+ hasMore: result.hasMore
10494 };
10595
10696 return new Response(JSON.stringify(response), {
107- headers: {
108- 'Content-Type': 'application/json'
109- }
97+ headers: { 'Content-Type': 'application/json' }
11098 });
11199 } catch (err) {
112100 log.error('Error fetching messages:', { error: err });
113101 throw error(500, 'Failed to fetch messages');
114102 }
115-}
103+};
116104
117-// POST endpoint: receive a new message, save it in the DB, and broadcast via SSE
118-export async function POST({ request, locals }: { request: Request; locals: App.Locals }) {
105+// POST endpoint: send a new message via Convex
106+export const POST: RequestHandler = async ({ request, locals }) => {
119107 if (!locals.user) {
120108 log.warn('Authentication required');
121109 throw error(401, 'Authentication required');
@@ -170,61 +158,42 @@ export async function POST({ request, locals }: { request: Request; locals: App.
170158 throw error(403, 'Cannot post messages as another user');
171159 }
172160
173- const chatRoomId = data.chatRoomId || DEFAULT_CHAT_ROOM_ID;
174-
175- // Check chat room existence
176- const chatRoom = await db.select().from(chatRooms).where(eq(chatRooms.id, chatRoomId)).get();
161+ // Use default room if not specified
162+ const chatRoomId = data.chatRoomId || getDefaultChatRoomId();
177163
178- if (!chatRoom) {
179- log.warn('Chat room not found', { chatRoomId });
164+ if (!chatRoomId) {
165+ log.error('No chat room ID available');
180166 const errorResponse: SendMessageResponse = {
181167 success: false,
182- error: 'Chat room not found'
168+ error: 'Chat room not configured'
183169 };
184- return new Response(JSON.stringify(errorResponse), { status: 404 });
185- }
186-
187- // Check user existence
188- const user = await db.select().from(users).where(eq(users.id, data.userId)).get();
189-
190- if (!user) {
191- const maskedUserId = `${data.userId.slice(0, 4)}...${data.userId.slice(-4)}`;
192- log.warn('User not found', { maskedUserId });
193- const errorResponse: SendMessageResponse = {
194- success: false,
195- error: 'User not found'
196- };
197- return new Response(JSON.stringify(errorResponse), { status: 404 });
170+ return new Response(JSON.stringify(errorResponse), { status: 500 });
198171 }
199172
200173 const validatedStyleData = sanitizeStyleData(data.styleData);
201174
202- const newMessage: Message = {
203- id: uuidv4(),
175+ // Send message via Convex
176+ const convexMessage = await messages.send(
204177 chatRoomId,
205- senderId: data.userId,
206- content: data.content,
207- type: data.type || 'chat',
208- timestamp: Date.now(),
209- styleData: validatedStyleData ? JSON.stringify(validatedStyleData) : undefined,
210- hasFormatting: Boolean(validatedStyleData)
178+ data.userId,
179+ data.content,
180+ data.type || 'chat',
181+ validatedStyleData ? JSON.stringify(validatedStyleData) : undefined,
182+ Boolean(validatedStyleData)
183+ );
184+
185+ // Map to our Message type
186+ const newMessage: Message = {
187+ id: convexMessage._id,
188+ chatRoomId: convexMessage.chatRoomId,
189+ senderId: convexMessage.senderId,
190+ content: convexMessage.content,
191+ type: convexMessage.type as Message['type'],
192+ timestamp: convexMessage.timestamp,
193+ styleData: convexMessage.styleData,
194+ hasFormatting: convexMessage.hasFormatting ?? false
211195 };
212196
213- // debug style data content at this point
214- log.debug('Style data content', { styleData: validatedStyleData });
215-
216- // Save to DB
217- await db.insert(messages).values(newMessage);
218- log.debug('Message saved in DB', {
219- messageId: newMessage.id,
220- chatRoomId: newMessage.chatRoomId,
221- type: newMessage.type,
222- timestamp: newMessage.timestamp
223- });
224-
225- // Broadcast the new message via SSE
226- sseEmitter.emit('chatMessage', newMessage);
227-
228197 log.debug('Message processed successfully', {
229198 messageId: newMessage.id,
230199 userId: `${newMessage.senderId.slice(0, 4)}...${newMessage.senderId.slice(-4)}`,
@@ -240,9 +209,9 @@ export async function POST({ request, locals }: { request: Request; locals: App.
240209 status: 201,
241210 headers: { 'Content-Type': 'application/json' }
242211 });
243- } catch (error) {
212+ } catch (err) {
244213 log.error('Error processing message', {
245- error: error instanceof Error ? error.message : 'Unknown error'
214+ error: err instanceof Error ? err.message : 'Unknown error'
246215 });
247216 const errorResponse: SendMessageResponse = {
248217 success: false,
@@ -253,4 +222,4 @@ export async function POST({ request, locals }: { request: Request; locals: App.
253222 headers: { 'Content-Type': 'application/json' }
254223 });
255224 }
256-}
225+};
M src/routes/api/register/+server.ts +7 -13
@@ -1,11 +1,9 @@
11 import type { RequestHandler } from '@sveltejs/kit';
22 import type { RegisterResponseSuccess, RegisterResponseError } from '$lib/types/payloads';
3-import db from '$lib/db/db.server';
4-import { users } from '$lib/db/schema';
3+import { users } from '$lib/db/convex.server';
54 import { hashPassword } from '$lib/utils/password';
65 import { createRegistrationSchema, DEFAULT_PASSWORD_CONSTRAINTS } from '$lib/validation/password';
76 import { createLogger } from '$lib/utils/logger.server';
8-import { eq } from 'drizzle-orm/sql';
97
108 const log = createLogger('register-server');
119
@@ -102,9 +100,9 @@ export const POST: RequestHandler = async ({ request }) => {
102100 const username = suUsername.trim();
103101 const password = suPassword.trim();
104102
105- // Check if nickname already exists
106- const existingUser = await db.select().from(users).where(eq(users.nickname, username));
107- if (existingUser.length > 0) {
103+ // Check if nickname already exists using Convex
104+ const existingUser = await users.getByNickname(username);
105+ if (existingUser) {
108106 log.warn('Username already exists', { username });
109107 return new Response(
110108 JSON.stringify({ error: 'This username is already taken' } as RegisterResponseError),
@@ -123,17 +121,13 @@ export const POST: RequestHandler = async ({ request }) => {
123121 { status: 500 }
124122 );
125123 }
126- // Insert the new user into the database.
124+
125+ // Insert the new user into Convex
127126 try {
128- await db.insert(users).values({
129- nickname: username,
130- password: hashedPassword,
131- createdAt: Date.now()
132- });
127+ await users.create(username, hashedPassword);
133128 log.info(`User ${username} registered successfully`);
134129 } catch (err: unknown) {
135130 log.error('Database insertion error', { error: err as object });
136- // Assume a duplicate user error if the email (or derived unique field) already exists.
137131 return new Response(
138132 JSON.stringify({
139133 error: 'User registration failed. Possibly user already exists.'
M src/routes/api/rooms/[roomId]/+server.ts +31 -37
@@ -1,16 +1,15 @@
1-import { eq } from 'drizzle-orm/sql';
2-import db from '$lib/db/db.server';
3-import { users } from '$lib/db/schema';
1+import { users } from '$lib/db/convex.server';
42 import type { SafeUser } from '$lib/types/chat';
53 import type { PublicRoomResponse } from '$lib/types/payloads';
64 import { createSafeUser } from '$lib/types/chat';
75 import { createLogger } from '$lib/utils/logger.server';
6+import type { RequestHandler } from './$types';
87
98 const log = createLogger('rooms-server');
109
1110 const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
1211
13-export async function GET({ params, url, locals }): Promise<Response> {
12+export const GET: RequestHandler = async ({ params, url, locals }) => {
1413 const { roomId } = params;
1514 const isPublic = !locals.user || url.searchParams.get('public') === 'true';
1615
@@ -26,52 +25,47 @@ export async function GET({ params, url, locals }): Promise<Response> {
2625 // Calculate timeout threshold
2726 const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS;
2827
29- // Fetch buddy list: get all users
30- const fetchedUsers = await db.select().from(users).execute();
31-
32- // Normalize users to ensure that lastSeen is always a number (using 0 if null)
33- const normalizedUsers = fetchedUsers.map((user: SafeUser & { lastSeen: number | null }) => ({
34- ...user,
35- lastSeen: user.lastSeen ?? 0
36- }));
28+ // Fetch all users from Convex
29+ const fetchedUsers = await users.list();
3730
3831 // Process users to mark them as offline if they've timed out
39- const processedUsers = normalizedUsers.map((user: SafeUser & { lastSeen: number }) => {
40- const shouldBeOffline = user.status !== 'offline' && user.lastSeen < timeoutThreshold;
32+ const processedUsers = fetchedUsers.map((user) => {
33+ const lastSeen = user.lastSeen ?? 0;
34+ const shouldBeOffline = user.status !== 'offline' && lastSeen < timeoutThreshold;
4135 return {
42- ...user,
43- status: shouldBeOffline ? 'offline' : user.status
36+ id: user._id,
37+ nickname: user.nickname,
38+ status: shouldBeOffline ? 'offline' : user.status,
39+ avatarUrl: user.avatarUrl,
40+ lastSeen
4441 };
4542 });
4643
47- // Find users who were originally non-offline but now marked offline
48- const usersToUpdate = processedUsers.filter(
49- (user: SafeUser & { lastSeen: number }) =>
50- user.status === 'offline' &&
51- normalizedUsers.find((u: SafeUser & { lastSeen: number }) => u.id === user.id)?.status !==
52- 'offline'
53- );
44+ // Find users who should be marked offline and update them
45+ const usersToUpdate = processedUsers.filter((user, index) => {
46+ const original = fetchedUsers[index];
47+ return user.status === 'offline' && original.status !== 'offline';
48+ });
5449
50+ // Update timed-out users in Convex
5551 if (usersToUpdate.length > 0) {
56- await Promise.all(
57- usersToUpdate.map((user: SafeUser & { lastSeen: number }) =>
58- db
59- .update(users)
60- .set({ status: 'offline', lastSeen: Date.now() })
61- .where(eq(users.id, user.id))
62- .execute()
63- )
64- );
52+ await Promise.all(usersToUpdate.map((user) => users.updateStatus(user.id, 'offline')));
6553
6654 log.debug('Updated offline status for users', {
6755 count: usersToUpdate.length,
68- userIds: usersToUpdate.map((u: SafeUser & { lastSeen: number }) => u.id)
56+ userIds: usersToUpdate.map((u) => u.id)
6957 });
7058 }
7159
72- // Sanitize user data using createSafeUser
73- const sanitizedUsers = processedUsers.map((user: SafeUser & { lastSeen: number }) =>
74- createSafeUser(user)
60+ // Sanitize user data
61+ const sanitizedUsers: SafeUser[] = processedUsers.map((user) =>
62+ createSafeUser({
63+ id: user.id,
64+ nickname: user.nickname,
65+ status: user.status,
66+ avatarUrl: user.avatarUrl,
67+ lastSeen: user.lastSeen
68+ })
7569 );
7670
7771 const responseData: PublicRoomResponse = {
@@ -104,4 +98,4 @@ export async function GET({ params, url, locals }): Promise<Response> {
10498 headers: { 'Content-Type': 'application/json' }
10599 });
106100 }
107-}
101+};
A src/routes/api/rooms/default/+server.ts +42 -0
@@ -0,0 +1,42 @@
1+import { chatRooms } from '$lib/db/convex.server';
2+import { createLogger } from '$lib/utils/logger.server';
3+import type { RequestHandler } from './$types';
4+
5+const log = createLogger('rooms-default-server');
6+
7+export const GET: RequestHandler = async () => {
8+ try {
9+ log.debug('Fetching default room');
10+
11+ const room = await chatRooms.getOrCreateDefault();
12+
13+ log.debug('Successfully fetched default room', {
14+ roomId: room._id
15+ });
16+
17+ return new Response(
18+ JSON.stringify({
19+ success: true,
20+ roomId: room._id
21+ }),
22+ {
23+ status: 200,
24+ headers: { 'Content-Type': 'application/json' }
25+ }
26+ );
27+ } catch (error) {
28+ log.error('Error fetching default room', {
29+ error: error instanceof Error ? error.message : 'Unknown error'
30+ });
31+ return new Response(
32+ JSON.stringify({
33+ success: false,
34+ error: 'Failed to fetch default room'
35+ }),
36+ {
37+ status: 500,
38+ headers: { 'Content-Type': 'application/json' }
39+ }
40+ );
41+ }
42+};
M src/routes/api/session/login/+server.ts +23 -20
@@ -1,8 +1,6 @@
11 import type { RequestHandler } from '@sveltejs/kit';
22 import type { LoginResponseSuccess, LoginResponseError } from '$lib/types/payloads';
3-import db from '$lib/db/db.server';
4-import { users } from '$lib/db/schema';
5-import { eq } from 'drizzle-orm/sql';
3+import { users } from '$lib/db/convex.server';
64 import { verifyPassword } from '$lib/utils/password';
75 import { generateSessionToken, createSession } from '$lib/api/session.server';
86 import { setSessionTokenCookie } from '$lib/api/session.cookie';
@@ -43,7 +41,10 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
4341 }
4442
4543 // Get the IP address for rate limiting and Turnstile validation
46- const ip = request.headers.get('x-forwarded-for') || 'unknown';
44+ // Prefer Cloudflare's CF-Connecting-IP header, then x-forwarded-for (first IP), then fallback
45+ const cfConnectingIp = request.headers.get('cf-connecting-ip');
46+ const forwardedFor = request.headers.get('x-forwarded-for') || '';
47+ const ip = cfConnectingIp || forwardedFor.split(',')[0]?.trim() || 'unknown';
4748 const maskedIp = ip
4849 .split('.')
4950 .map((octet, idx) => (idx < 3 ? 'xxx' : octet))
@@ -84,10 +85,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
8485 });
8586 }
8687
87- // Find user by username
88- const user = await db.query.users.findFirst({
89- where: eq(users.nickname, username.trim())
90- });
88+ // Find user by username using Convex
89+ const user = await users.getByNickname(username.trim());
9190
9291 if (!user) {
9392 attemptData.count++;
@@ -107,6 +106,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
107106 );
108107 }
109108
109+ // Use native Convex _id
110+ const userId = user._id;
111+
110112 // Verify password
111113 try {
112114 const isValid = await verifyPassword(password.trim(), user.password);
@@ -117,7 +119,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
117119 log.warn('Login failed - invalid password', {
118120 maskedIp,
119121 attemptCount: attemptData.count,
120- userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}`
122+ userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`
121123 });
122124 const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count);
123125 return new Response(
@@ -139,29 +141,30 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
139141
140142 // Generate session token and create session
141143 const token = generateSessionToken();
142- const session = await createSession(token, user.id);
144+ const session = await createSession(token, userId);
143145
144- // Update user status to online
145- await db
146- .update(users)
147- .set({
148- status: 'online',
149- lastSeen: Date.now()
150- })
151- .where(eq(users.id, user.id));
146+ // Update user status to online using Convex
147+ await users.updateStatus(userId, 'online');
152148
153149 // Set session cookie
154150 setSessionTokenCookie({ cookies }, token, session.expiresAt);
155151
156152 log.info('Login successful', {
157- userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}`,
153+ userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`,
158154 expiresAt: new Date(session.expiresAt).toISOString()
159155 });
160156
157+ // Return user with native Convex ID
161158 return new Response(
162159 JSON.stringify({
163160 success: true,
164- user: createSafeUser(user)
161+ user: createSafeUser({
162+ id: userId,
163+ nickname: user.nickname,
164+ status: 'online',
165+ avatarUrl: user.avatarUrl,
166+ lastSeen: user.lastSeen
167+ })
165168 } as LoginResponseSuccess),
166169 {
167170 status: 200
M src/routes/api/session/logout/+server.ts +17 -7
@@ -1,23 +1,33 @@
11 import { json } from '@sveltejs/kit';
2+import type { RequestHandler } from './$types';
23 import { invalidateSession } from '$lib/api/session.server';
34 import { sha256 } from '$lib/api/session.server'; // re-use sha256 helper to compute session id
45 import { createLogger } from '$lib/utils/logger.server';
56
67 const log = createLogger('logout-server');
78
8-export async function POST({ cookies }) {
9+export const POST: RequestHandler = async ({ cookies }) => {
910 const token = cookies.get('session');
1011
11- if (token) {
12+ if (!token) {
13+ log.debug('No session token found in cookies');
14+ return json({ success: true, invalidated: false });
15+ }
16+
17+ try {
1218 const sessionId = await sha256(token);
1319 await invalidateSession(sessionId);
1420 cookies.delete('session', { path: '/' });
1521 log.info('Session invalidated', {
1622 sessionId: `${sessionId.slice(0, 4)}...${sessionId.slice(-4)}`
1723 });
18- } else {
19- log.debug('No session token found in cookies');
24+ return json({ success: true, invalidated: true });
25+ } catch (error) {
26+ log.error('Failed to invalidate session', {
27+ error: error instanceof Error ? error.message : 'Unknown error'
28+ });
29+ // Still delete cookie to ensure client is logged out
30+ cookies.delete('session', { path: '/' });
31+ return json({ success: false, error: 'Failed to invalidate session' }, { status: 500 });
2032 }
21-
22- return json({ success: true });
23-}
33+};
M src/routes/api/session/validate/+server.ts +6 -5
@@ -1,10 +1,11 @@
11 import { json } from '@sveltejs/kit';
2+import type { RequestHandler } from './$types';
23 import { validateSessionToken } from '$lib/api/session.server';
34 import { createLogger } from '$lib/utils/logger.server';
45
56 const log = createLogger('validate-server');
67
7-export async function GET({ cookies }) {
8+export const GET: RequestHandler = async ({ cookies }) => {
89 const token = cookies.get('session');
910
1011 if (!token) {
@@ -19,9 +20,9 @@ export async function GET({ cookies }) {
1920 userId: `${result.user.id.slice(0, 4)}...${result.user.id.slice(-4)}`,
2021 sessionId: `${result.session.id.slice(0, 4)}...${result.session.id.slice(-4)}`
2122 });
22- } else {
23- log.debug('Invalid session token');
23+ return json(result, { status: 200 });
2424 }
2525
26- return json(result);
27-}
26+ log.debug('Invalid session token');
27+ return json({ session: null, user: null }, { status: 401 });
28+};
D src/routes/api/sse/+server.ts +0 -385
@@ -1,385 +0,0 @@
1-/**
2- * SSE Server Endpoint
3- *
4- * Handles Server-Sent Events connections with:
5- * - Event IDs for Last-Event-ID catch-up support
6- * - Heartbeat mechanism for connection health
7- * - Proper connection lifecycle management
8- */
9-
10-declare global {
11- var __sseIntervalsStarted: boolean;
12-}
13-
14-import type { RequestHandler } from './$types';
15-import { SSE_CONFIG } from '$lib/sse/config';
16-import { sseEmitter, type SSEEvent } from '$lib/sse/SSEEventEmitter';
17-import { sseManager } from '$lib/sse/SSEManager';
18-import { messageQueue } from '$lib/sse/MessageQueue';
19-import db from '$lib/db/db.server';
20-import { users } from '$lib/db/schema';
21-import { eq, and, lt, ne } from 'drizzle-orm';
22-import { createSafeUser } from '$lib/types/chat';
23-import { createLogger } from '$lib/utils/logger.server';
24-import { buddyListCache } from '$lib/buddyListCache';
25-import { env } from '$env/dynamic/public';
26-
27-const log = createLogger('sse-server');
28-
29-console.log('[SSE] Server initialized with new SSE modules');
30-
31-// Default chat room ID
32-const DEFAULT_CHAT_ROOM_ID =
33- env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001';
34-
35-// Intervals for background tasks
36-let timeoutInterval: ReturnType<typeof setInterval>;
37-let buddyListInterval: ReturnType<typeof setInterval>;
38-let connectionCleanupInterval: ReturnType<typeof setInterval>;
39-
40-// Timestamp tracker for buddy list updates
41-let lastBuddyListUpdate = 0;
42-let lastBuddyListHash = '';
43-
44-/**
45- * Start periodic check for timed out users
46- */
47-function startTimeoutCheck() {
48- if (timeoutInterval) {
49- clearInterval(timeoutInterval);
50- }
51-
52- timeoutInterval = setInterval(async () => {
53- const timeoutThreshold = Date.now() - SSE_CONFIG.CONNECTION_TIMEOUT;
54- log.debug('Checking for timed out users', {
55- checkTime: new Date().toISOString(),
56- timeoutThreshold: new Date(timeoutThreshold).toISOString()
57- });
58-
59- try {
60- const now = Date.now();
61- const result = await db
62- .update(users)
63- .set({
64- status: 'offline',
65- lastSeen: now
66- })
67- .where(and(ne(users.status, 'offline'), lt(users.lastSeen, timeoutThreshold)))
68- .returning({
69- id: users.id,
70- nickname: users.nickname,
71- lastSeen: users.lastSeen
72- });
73-
74- if (result.length > 0) {
75- log.info('User status updated to offline due to timeout', {
76- usersUpdated: result.length,
77- timeoutThreshold: new Date(timeoutThreshold).toISOString()
78- });
79-
80- buddyListCache.invalidate();
81-
82- // Broadcast status updates for each timed out user
83- for (const user of result) {
84- sseEmitter.emit('userStatusUpdate', {
85- userId: user.id,
86- status: 'offline',
87- lastSeen: now
88- });
89- }
90- }
91- } catch (error) {
92- log.error('Error during timeout check', { error });
93- }
94- }, SSE_CONFIG.STALE_CHECK_INTERVAL);
95-}
96-
97-/**
98- * Start buddy list broadcast interval
99- */
100-function startBuddyListUpdateInterval() {
101- if (buddyListInterval) {
102- clearInterval(buddyListInterval);
103- }
104-
105- buddyListInterval = setInterval(async () => {
106- try {
107- const now = Date.now();
108- if (now - lastBuddyListUpdate >= SSE_CONFIG.BUDDY_LIST_BROADCAST_INTERVAL) {
109- if (buddyListCache.needsRefresh()) {
110- log.debug('Fetching buddy list from database (cache miss)...');
111- const buddyList = await db.select().from(users);
112- const safeBuddyList = buddyList.map((user) => createSafeUser(user));
113-
114- const hasChanged = buddyListCache.update(safeBuddyList);
115-
116- if (hasChanged) {
117- log.debug('Broadcasting buddy list update (data changed)...');
118- sseEmitter.emit('buddyListUpdate', safeBuddyList);
119- }
120- } else {
121- const cachedData = buddyListCache.get();
122- if (cachedData) {
123- const currentHash = JSON.stringify(cachedData);
124- if (currentHash !== lastBuddyListHash) {
125- log.debug('Broadcasting buddy list update from cache...');
126- lastBuddyListHash = currentHash;
127- sseEmitter.emit('buddyListUpdate', cachedData);
128- }
129- }
130- }
131- lastBuddyListUpdate = now;
132- }
133- } catch (error) {
134- log.error('Error fetching buddy list for update', { error });
135- }
136- }, 1000);
137-}
138-
139-/**
140- * Start periodic cleanup for stale connections
141- */
142-function startConnectionCleanup() {
143- if (connectionCleanupInterval) {
144- clearInterval(connectionCleanupInterval);
145- }
146-
147- connectionCleanupInterval = setInterval(async () => {
148- // Get stats and log them periodically
149- const stats = sseManager.getStats();
150- if (stats.totalConnections > 0) {
151- log.debug('SSE connection stats', stats);
152- }
153- }, SSE_CONFIG.STALE_CHECK_INTERVAL);
154-}
155-
156-// Start background tasks only once
157-if (!globalThis.__sseIntervalsStarted) {
158- startTimeoutCheck();
159- startBuddyListUpdateInterval();
160- startConnectionCleanup();
161- sseManager.startBackgroundTasks();
162- globalThis.__sseIntervalsStarted = true;
163- log.info('Started global intervals for SSE management');
164-} else {
165- log.debug('Global intervals already started, skipping initialization');
166-}
167-
168-// Cleanup on process exit
169-if (typeof process !== 'undefined') {
170- const cleanup = () => {
171- if (timeoutInterval) clearInterval(timeoutInterval);
172- if (buddyListInterval) clearInterval(buddyListInterval);
173- if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
174- sseManager.stopBackgroundTasks();
175- };
176-
177- process.on('beforeExit', cleanup);
178- process.on('SIGTERM', () => {
179- cleanup();
180- process.exit(0);
181- });
182- process.on('SIGINT', () => {
183- cleanup();
184- process.exit(0);
185- });
186-}
187-
188-/**
189- * Format an SSE event with ID and retry field
190- */
191-function formatSSEEvent(event: SSEEvent, eventId: string): string {
192- const lines = [
193- `id: ${eventId}`,
194- `event: ${event.type}`,
195- `data: ${JSON.stringify(event.data)}`,
196- `retry: ${SSE_CONFIG.RETRY_INTERVAL}`,
197- '',
198- ''
199- ];
200- return lines.join('\n');
201-}
202-
203-/**
204- * GET /api/sse
205- *
206- * Establishes an SSE connection for real-time updates.
207- */
208-export const GET: RequestHandler = async ({ request, locals }) => {
209- log.debug('Processing SSE request', {
210- url: request.url,
211- userId: locals.user?.id
212- });
213-
214- if (!locals.user) {
215- log.warn('Authentication failed - no user in locals');
216- return new Response('Unauthorized', {
217- status: 401,
218- headers: { 'Content-Type': 'application/json' }
219- });
220- }
221-
222- const userId = locals.user.id;
223- const roomId = DEFAULT_CHAT_ROOM_ID;
224- const encoder = new TextEncoder();
225-
226- // Check for Last-Event-ID header for catch-up
227- const lastEventId = request.headers.get('Last-Event-ID');
228- if (lastEventId) {
229- log.debug('Client reconnecting with Last-Event-ID', {
230- userId: userId.slice(0, 8),
231- lastEventId: lastEventId.slice(0, 8)
232- });
233- }
234-
235- let connectionId: string;
236- let keepAliveInterval: ReturnType<typeof setInterval>;
237- let cleanupListener: (() => void) | undefined;
238-
239- const stream = new ReadableStream({
240- async start(controller) {
241- // Add connection to manager
242- const { connectionId: connId, isNewUser } = sseManager.addConnection(
243- userId,
244- roomId,
245- controller
246- );
247- connectionId = connId;
248-
249- // Update user status if new connection
250- if (isNewUser) {
251- const now = Date.now();
252- try {
253- await db
254- .update(users)
255- .set({ status: 'online', lastSeen: now })
256- .where(eq(users.id, userId));
257-
258- log.info('User status updated to online', {
259- userId: userId.slice(0, 8),
260- connectionId: connectionId.slice(0, 8)
261- });
262-
263- buddyListCache.invalidate();
264-
265- sseEmitter.emit('userStatusUpdate', {
266- userId,
267- status: 'online',
268- lastSeen: now
269- });
270- } catch (error) {
271- log.error('Error updating user status', { error });
272- }
273- }
274-
275- // Send initial connection event
276- const connectEvent = formatSSEEvent(
277- { type: 'userStatusUpdate', data: { userId, status: 'connected', lastSeen: Date.now() } },
278- crypto.randomUUID()
279- );
280- controller.enqueue(encoder.encode(connectEvent));
281-
282- // Send catch-up messages if reconnecting with Last-Event-ID
283- if (lastEventId) {
284- const missedMessages = messageQueue.getMessagesSince(roomId, lastEventId);
285- if (missedMessages.length > 0) {
286- log.info('Sending catch-up messages', {
287- userId: userId.slice(0, 8),
288- count: missedMessages.length
289- });
290- for (const msg of missedMessages) {
291- // Cast to SSEEvent - the data was already validated when queued
292- const event = { type: msg.event, data: msg.data } as SSEEvent;
293- const payload = formatSSEEvent(event, msg.id);
294- controller.enqueue(encoder.encode(payload));
295- }
296- }
297- }
298-
299- // Listen for SSE events
300- const onSSE = (event: SSEEvent) => {
301- try {
302- const eventId = crypto.randomUUID();
303- const payload = formatSSEEvent(event, eventId);
304- controller.enqueue(encoder.encode(payload));
305-
306- // Queue message for catch-up (if not heartbeat)
307- if (event.type !== 'heartbeat') {
308- messageQueue.enqueue(roomId, event.type, event.data);
309- }
310- } catch (error) {
311- log.error('Error sending event', { error });
312- }
313- };
314-
315- cleanupListener = sseEmitter.addListener('sse', onSSE);
316-
317- // Handle abort signal
318- request.signal.addEventListener('abort', () => {
319- if (cleanupListener) {
320- cleanupListener();
321- log.info('Removed listener due to abort signal');
322- }
323- });
324-
325- log.info('SSE connection established', {
326- connectionId: connectionId.slice(0, 8),
327- userId: userId.slice(0, 8)
328- });
329-
330- // Keep-alive pings (in addition to heartbeats from manager)
331- keepAliveInterval = setInterval(() => {
332- try {
333- controller.enqueue(encoder.encode(':\n\n'));
334- } catch {
335- // Connection might be closed
336- }
337- }, SSE_CONFIG.HEARTBEAT_INTERVAL);
338- },
339-
340- async cancel(reason) {
341- if (keepAliveInterval) clearInterval(keepAliveInterval);
342- if (cleanupListener) cleanupListener();
343-
344- const maskedUserId = userId.slice(0, 8);
345- log.info('Connection closed', {
346- userId: maskedUserId,
347- connectionId: connectionId?.slice(0, 8),
348- reason
349- });
350-
351- // Remove connection from manager
352- const { isLastConnection } = sseManager.removeConnection(connectionId);
353-
354- // Update database if last connection for this user
355- if (isLastConnection) {
356- const now = Date.now();
357- try {
358- await db
359- .update(users)
360- .set({ status: 'offline', lastSeen: now })
361- .where(eq(users.id, userId));
362-
363- buddyListCache.invalidate();
364-
365- sseEmitter.emit('userStatusUpdate', {
366- userId,
367- status: 'offline',
368- lastSeen: now
369- });
370- } catch (error) {
371- log.error('Error updating user status on disconnect', { error });
372- }
373- }
374- }
375- });
376-
377- return new Response(stream, {
378- headers: {
379- 'Content-Type': 'text/event-stream',
380- 'Cache-Control': 'no-cache',
381- Connection: 'keep-alive',
382- 'X-Accel-Buffering': 'no' // Disable nginx buffering
383- }
384- });
385-};
D src/routes/api/sse/heartbeat/+server.ts +0 -66
@@ -1,66 +0,0 @@
1-/**
2- * Heartbeat Acknowledgment Endpoint
3- *
4- * Receives heartbeat acknowledgments from clients to track
5- * active connections and detect stale ones.
6- */
7-
8-import type { RequestHandler } from './$types';
9-import { sseManager } from '$lib/sse/SSEManager';
10-import { createLogger } from '$lib/utils/logger.server';
11-import { json } from '@sveltejs/kit';
12-
13-const log = createLogger('sse-heartbeat');
14-
15-/**
16- * POST /api/sse/heartbeat
17- *
18- * Acknowledges a heartbeat from a client, updating the connection's
19- * last heartbeat timestamp.
20- */
21-export const POST: RequestHandler = async ({ request, locals }) => {
22- // Require authentication
23- if (!locals.user) {
24- return json({ success: false, error: 'Unauthorized' }, { status: 401 });
25- }
26-
27- try {
28- const body = await request.json();
29- const { connectionId } = body;
30-
31- if (!connectionId || typeof connectionId !== 'string') {
32- return json({ success: false, error: 'Invalid connectionId' }, { status: 400 });
33- }
34-
35- // Verify the connection belongs to this user
36- const connection = sseManager.getConnection(connectionId);
37- if (!connection) {
38- log.debug('Heartbeat for unknown connection', {
39- connectionId: connectionId.slice(0, 8),
40- userId: locals.user.id.slice(0, 8)
41- });
42- return json({ success: false, error: 'Connection not found' }, { status: 404 });
43- }
44-
45- if (connection.userId !== locals.user.id) {
46- log.warn('Heartbeat userId mismatch', {
47- connectionId: connectionId.slice(0, 8),
48- expectedUserId: connection.userId.slice(0, 8),
49- actualUserId: locals.user.id.slice(0, 8)
50- });
51- return json({ success: false, error: 'Unauthorized' }, { status: 403 });
52- }
53-
54- // Acknowledge the heartbeat
55- const acknowledged = sseManager.acknowledgeHeartbeat(connectionId);
56-
57- if (acknowledged) {
58- return json({ success: true });
59- } else {
60- return json({ success: false, error: 'Failed to acknowledge' }, { status: 500 });
61- }
62- } catch (error) {
63- log.error('Heartbeat error', { error });
64- return json({ success: false, error: 'Internal server error' }, { status: 500 });
65- }
66-};
M src/routes/api/status/+server.ts +38 -35
@@ -1,11 +1,8 @@
1-import db from '$lib/db/db.server';
2-import { users } from '$lib/db/schema';
3-import { eq } from 'drizzle-orm/sql';
1+import { users } from '$lib/db/convex.server';
42 import { validateSessionToken, generateSessionToken, createSession } from '$lib/api/session.server';
53 import { setSessionTokenCookie } from '$lib/api/session.cookie';
64 import { createSafeUser } from '$lib/types/chat';
75 import { createLogger } from '$lib/utils/logger.server';
8-import { buddyListCache } from '$lib/buddyListCache';
96 import type { RequestHandler } from './$types';
107
118 const log = createLogger('status-server');
@@ -36,47 +33,49 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
3633
3734 const now = Date.now();
3835
39- // Update the user status in the database
40- // Always update lastSeen to support timeout detection
41- const updatedUser = await db
42- .update(users)
43- .set({
44- status,
45- lastSeen: now // Always update lastSeen for proper timeout detection
46- })
47- .where(eq(users.id, userId))
48- .returning()
49- .get();
50- log.debug('Database updated successfully', { userId: maskedUserId, status });
51-
52- // Invalidate buddy list cache when status changes
53- buddyListCache.invalidate();
36+ // Update the user status in Convex
37+ const updatedUser = await users.updateStatus(userId, status);
38+ log.debug('Convex updated successfully', { userId: maskedUserId, status });
5439
5540 // Renew session if status is online and session expires in less than 1 day
5641 if (status === 'online') {
57- const token = cookies.get('session');
58- if (token) {
59- const result = await validateSessionToken(token);
60- if (result.session) {
61- const remaining = result.session.expiresAt - now;
62- const threshold = 24 * 60 * 60 * 1000; // 1 day in milliseconds
63- if (remaining < threshold) {
64- const newToken = generateSessionToken();
65- const newSession = await createSession(newToken, userId);
66- setSessionTokenCookie({ cookies }, newToken, newSession.expiresAt);
67- log.info('Session renewed', {
68- userId: maskedUserId,
69- expiresAt: new Date(newSession.expiresAt).toISOString()
70- });
42+ try {
43+ const token = cookies.get('session');
44+ if (token) {
45+ const result = await validateSessionToken(token);
46+ if (result.session) {
47+ const remaining = result.session.expiresAt - now;
48+ const threshold = 24 * 60 * 60 * 1000; // 1 day in milliseconds
49+ if (remaining < threshold) {
50+ const newToken = generateSessionToken();
51+ const newSession = await createSession(newToken, userId);
52+ setSessionTokenCookie({ cookies }, newToken, newSession.expiresAt);
53+ log.info('Session renewed', {
54+ userId: maskedUserId,
55+ expiresAt: new Date(newSession.expiresAt).toISOString()
56+ });
57+ }
7158 }
7259 }
60+ } catch (renewalError) {
61+ log.error('Session renewal failed', {
62+ error: renewalError instanceof Error ? renewalError.message : 'Unknown error',
63+ userId: maskedUserId
64+ });
65+ // Continue with status update even if renewal fails
7366 }
7467 }
7568
7669 return new Response(
7770 JSON.stringify({
7871 success: true,
79- user: createSafeUser(updatedUser)
72+ user: createSafeUser({
73+ id: updatedUser._id,
74+ nickname: updatedUser.nickname,
75+ status: updatedUser.status,
76+ avatarUrl: updatedUser.avatarUrl,
77+ lastSeen: updatedUser.lastSeen
78+ })
8079 }),
8180 {
8281 status: 200,
@@ -84,10 +83,14 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
8483 }
8584 );
8685 } catch (error) {
86+ log.error('Error updating status', {
87+ error: error instanceof Error ? error.message : 'Unknown error',
88+ userId: locals.user?.id
89+ });
8790 return new Response(
8891 JSON.stringify({
8992 success: false,
90- error: error instanceof Error ? error.message : 'Unknown error'
93+ error: 'Failed to update status'
9194 }),
9295 {
9396 status: 500,
A src/routes/api/user/text-preferences/+server.ts +106 -0
@@ -0,0 +1,106 @@
1+import { json, error } from '@sveltejs/kit';
2+import type { RequestHandler } from './$types';
3+import convex from '$lib/db/convex.server';
4+import { DEFAULT_USER_PREFERENCES, type UserTextPreferences } from '$lib/types/text-formatting';
5+import { validateSessionToken } from '$lib/api/session.server';
6+
7+const isKitHttpError = (err: unknown): err is { status: number; body: unknown } => {
8+ return typeof err === 'object' && err !== null && 'status' in err && 'body' in err;
9+};
10+
11+// GET - Fetch current user's text preferences
12+export const GET: RequestHandler = async ({ cookies }) => {
13+ try {
14+ // Get session token from cookie
15+ const sessionToken = cookies.get('session');
16+ if (!sessionToken) {
17+ throw error(401, 'No session found');
18+ }
19+
20+ // Validate session token
21+ const { user } = await validateSessionToken(sessionToken);
22+
23+ if (!user) {
24+ throw error(401, 'Invalid session');
25+ }
26+
27+ // Get user's text preferences
28+ const preferences = await convex.textPreferences.get(user.id);
29+
30+ if (!preferences) {
31+ // Return defaults if no preferences exist
32+ return json({
33+ success: true,
34+ preferences: DEFAULT_USER_PREFERENCES
35+ });
36+ }
37+
38+ // Convert Convex format to frontend format
39+ const frontendPreferences: UserTextPreferences = {
40+ defaultStyle: preferences.defaultStyle as UserTextPreferences['defaultStyle'],
41+ allowFormatting: preferences.allowFormatting,
42+ maxMessageLength: preferences.maxMessageLength
43+ };
44+
45+ return json({
46+ success: true,
47+ preferences: frontendPreferences
48+ });
49+ } catch (err) {
50+ console.error('Failed to fetch text preferences:', err);
51+ if (err instanceof Response || isKitHttpError(err)) {
52+ throw err;
53+ }
54+ throw error(500, 'Failed to fetch preferences');
55+ }
56+};
57+
58+// POST - Save current user's text preferences
59+export const POST: RequestHandler = async ({ cookies, request }) => {
60+ try {
61+ // Get session token from cookie
62+ const sessionToken = cookies.get('session');
63+ if (!sessionToken) {
64+ throw error(401, 'No session found');
65+ }
66+
67+ // Validate session token
68+ const { user } = await validateSessionToken(sessionToken);
69+
70+ if (!user) {
71+ throw error(401, 'Invalid session');
72+ }
73+
74+ // Parse request body
75+ const body = await request.json();
76+ const { defaultStyle, allowFormatting, maxMessageLength } = body as UserTextPreferences;
77+
78+ // Validate required fields
79+ if (
80+ !defaultStyle ||
81+ typeof allowFormatting !== 'boolean' ||
82+ typeof maxMessageLength !== 'number'
83+ ) {
84+ throw error(400, 'Invalid preferences data');
85+ }
86+
87+ // Save preferences via Convex
88+ const result = await convex.textPreferences.save(
89+ user.id,
90+ defaultStyle,
91+ allowFormatting,
92+ maxMessageLength
93+ );
94+
95+ return json({
96+ success: true,
97+ id: result.id
98+ });
99+ } catch (err) {
100+ console.error('Failed to save text preferences:', err);
101+ if (err instanceof Response || isKitHttpError(err)) {
102+ throw err;
103+ }
104+ throw error(500, 'Failed to save preferences');
105+ }
106+};
M src/routes/api/users/[userId]/+server.ts +9 -5
@@ -1,7 +1,5 @@
11 import { error, json } from '@sveltejs/kit';
2-import db from '$lib/db/db.server';
3-import { users } from '$lib/db/schema';
4-import { eq } from 'drizzle-orm';
2+import { users } from '$lib/db/convex.server';
53 import type { RequestHandler } from './$types';
64 import { createSafeUser } from '$lib/types/chat';
75 import { createLogger } from '$lib/utils/logger.server';
@@ -13,14 +11,20 @@ export const GET: RequestHandler = async ({ params }) => {
1311 log.debug('Fetching user data', { userId: maskedUserId });
1412
1513 try {
16- const user = await db.select().from(users).where(eq(users.id, params.userId)).get();
14+ const user = await users.getById(params.userId);
1715
1816 if (!user) {
1917 log.warn('User not found', { userId: maskedUserId });
2018 throw error(404, 'User not found');
2119 }
2220
23- const safeUser = createSafeUser(user);
21+ const safeUser = createSafeUser({
22+ id: user._id,
23+ nickname: user.nickname,
24+ status: user.status,
25+ avatarUrl: user.avatarUrl,
26+ lastSeen: user.lastSeen
27+ });
2428
2529 log.debug('User data retrieved', {
2630 userId: maskedUserId,
M svelte.config.js +6 -1
@@ -19,7 +19,12 @@ const config = {
1919 'script-src': ['self', 'https://challenges.cloudflare.com'],
2020 'style-src': ['self', 'unsafe-inline'],
2121 'img-src': ['self', 'data:', 'blob:'],
22- 'connect-src': ['self', 'https://challenges.cloudflare.com'],
22+ 'connect-src': [
23+ 'self',
24+ 'https://challenges.cloudflare.com',
25+ 'https://*.convex.cloud',
26+ 'wss://*.convex.cloud'
27+ ],
2328 'font-src': ['self'],
2429 'frame-src': ['https://challenges.cloudflare.com'],
2530 'frame-ancestors': ['none'],
D utils/db-flush.ts +0 -57
@@ -1,57 +0,0 @@
1-import { createClient } from '@libsql/client';
2-import * as dotenv from 'dotenv';
3-
4-// Load environment variables from .env file
5-dotenv.config();
6-
7-const { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } = process.env;
8-
9-if (!TURSO_DATABASE_URL || !TURSO_AUTH_TOKEN) {
10- throw new Error(
11- 'Missing required environment variables TURSO_DATABASE_URL and/or TURSO_AUTH_TOKEN'
12- );
13-}
14-
15-async function flushDatabase() {
16- console.log('Starting database content flush...');
17-
18- const client = createClient({
19- url: TURSO_DATABASE_URL as string,
20- authToken: TURSO_AUTH_TOKEN as string
21- });
22-
23- try {
24- // Clear tables in correct order to respect foreign key constraints
25- console.log('Clearing messages table...');
26- await client.execute('DELETE FROM messages');
27-
28- console.log('Clearing sessions table...');
29- await client.execute('DELETE FROM sessions');
30-
31- console.log('Clearing chat rooms table...');
32- await client.execute('DELETE FROM chat_rooms');
33-
34- console.log('Clearing users table...');
35- await client.execute('DELETE FROM users');
36-
37- console.log('Database content flush completed successfully');
38- } catch (error) {
39- console.error('Error flushing database content:', error);
40- throw error;
41- } finally {
42- await client.close();
43- }
44-}
45-
46-// Execute if this file is run directly
47-if (new URL(import.meta.url).pathname === process.argv[1]) {
48- flushDatabase()
49- .then(() => {
50- console.debug('[db-flush] Exiting process with code 0');
51- process.exit(0);
52- })
53- .catch((error) => {
54- console.error('[db-flush] Failed to flush database content:', error);
55- process.exit(1);
56- });
57-}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close