diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 554bceb..eaedd64 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -3,9 +3,7 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { logger } from "hono/logger"; import { registerRoutes } from "./routes"; -// import { shoppingWsHandlers } from "./ws/shopping-ws"; -// import { db, eq } from "@haushaltsApp/db"; -// import { session as sessionTable } from "@haushaltsApp/db/schema"; +import { injectWebSocket } from "./ws/shopping-ws"; const app = new Hono(); @@ -22,45 +20,13 @@ app.use( registerRoutes(app); -// When running under Bun directly (not imported as a module for tests), -// start Bun.serve with WebSocket support. -// if (typeof Bun !== "undefined" && !process.env.BUN_TEST) { -// Bun.serve({ -// port: Number(process.env.PORT ?? 3000), -// hostname: "0.0.0.0", -// websocket: shoppingWsHandlers, -// async fetch(req: Request, server) { -// const url = new URL(req.url); -// if (url.pathname === "/api/shopping-lists/ws") { -// const token = url.searchParams.get("token") ?? ""; -// const householdId = url.searchParams.get("householdId") ?? ""; - -// if (!householdId) { -// return new Response("Missing householdId", { status: 400 }); -// } - -// const rawToken = token.includes(".") ? token.split(".")[0] : token; -// if (!rawToken) return new Response("Unauthorized", { status: 401 }); - -// const sessionRow = await db.query.session.findFirst({ -// where: eq(sessionTable.token, rawToken), -// with: { user: true }, -// }); - -// if (!sessionRow?.user || sessionRow.expiresAt < new Date()) { -// return new Response("Unauthorized", { status: 401 }); -// } - -// const upgraded = server.upgrade(req, { -// data: { householdId, userId: sessionRow.user.id }, -// }); -// if (upgraded) return undefined as unknown as Response; -// return new Response("WebSocket upgrade failed", { status: 400 }); -// } - -// return app.fetch(req); -// }, -// }); -// } +if (typeof Bun !== "undefined" && !process.env.BUN_TEST) { + const server = Bun.serve({ + port: Number(process.env.PORT ?? 3000), + hostname: "0.0.0.0", + fetch: app.fetch, + }); + injectWebSocket(server); +} export default app; diff --git a/apps/server/src/routes/shopping.routes.ts b/apps/server/src/routes/shopping.routes.ts index 6726681..10f4d02 100644 --- a/apps/server/src/routes/shopping.routes.ts +++ b/apps/server/src/routes/shopping.routes.ts @@ -1,12 +1,12 @@ import { zValidator } from "@hono/zod-validator"; import { db, eq, and, isNotNull } from "@haushaltsApp/db"; -import { shoppingItems } from "@haushaltsApp/db/schema"; +import { shoppingItems, session as sessionTable } from "@haushaltsApp/db/schema"; import { Hono } from "hono"; import type { ShoppingServerEvent } from "@haushaltsApp/shared/schemas/shopping.schema"; import { addShoppingItemSchema } from "@haushaltsApp/shared/schemas/shopping.schema"; import { authMiddleware, requireAuth, type AuthVariables } from "../middleware/auth.middleware"; import { tenantMiddleware, requireHousehold, type TenantVariables } from "../middleware/tenant.middleware"; -import { broadcast } from "../ws/shopping-ws"; +import { broadcast, upgradeWebSocket, createShoppingWsHandler } from "../ws/shopping-ws"; import { getShoppingItems, addShoppingItem, @@ -81,3 +81,28 @@ shoppingRoutes.delete("/:id", async (c) => { broadcast(householdId, { type: "item:deleted", itemId: id } satisfies ShoppingServerEvent); return c.json({ ok: true }); }); + +// GET /api/shopping/ws — WebSocket upgrade +shoppingRoutes.get( + "/ws", + upgradeWebSocket(async (c) => { + const token = c.req.query("token") ?? ""; + const householdId = c.req.query("householdId") ?? ""; + + if (!householdId || !token) { + return { onOpen(_e, ws) { ws.close(4001, "Unauthorized"); } }; + } + + const rawToken = token.includes(".") ? token.split(".")[0] : token; + const sessionRow = await db.query.session.findFirst({ + where: eq(sessionTable.token, rawToken), + with: { user: true }, + }); + + if (!sessionRow?.user || sessionRow.expiresAt < new Date()) { + return { onOpen(_e, ws) { ws.close(4001, "Unauthorized"); } }; + } + + return createShoppingWsHandler(householdId, sessionRow.user.id); + }), +); diff --git a/apps/server/src/ws/shopping-ws.ts b/apps/server/src/ws/shopping-ws.ts index 14bb3e8..1a2fd5d 100644 --- a/apps/server/src/ws/shopping-ws.ts +++ b/apps/server/src/ws/shopping-ws.ts @@ -1,6 +1,7 @@ import { db, eq, and, isNotNull } from "@haushaltsApp/db"; import { shoppingItems } from "@haushaltsApp/db/schema"; -import type { ServerWebSocket } from "bun"; +import { createBunWebSocket } from "hono/bun"; +import type { WSContext } from "hono/ws"; import type { ShoppingServerEvent, ShoppingClientCommand } from "@haushaltsApp/shared/schemas/shopping.schema"; import { getShoppingItems, @@ -12,16 +13,18 @@ import { type WsData = { householdId: string; userId: string }; +export const { upgradeWebSocket, injectWebSocket } = createBunWebSocket(); + // ── Room Management ──────────────────────────────────────────────────────────── -const rooms = new Map>>(); +const rooms = new Map>>(); -function joinRoom(householdId: string, ws: ServerWebSocket): void { +function joinRoom(householdId: string, ws: WSContext): void { if (!rooms.has(householdId)) rooms.set(householdId, new Set()); rooms.get(householdId)!.add(ws); } -function leaveRoom(householdId: string, ws: ServerWebSocket): void { +function leaveRoom(householdId: string, ws: WSContext): void { const room = rooms.get(householdId); if (!room) return; room.delete(ws); @@ -31,7 +34,7 @@ function leaveRoom(householdId: string, ws: ServerWebSocket): void { export function broadcast( householdId: string, event: ShoppingServerEvent, - exclude?: ServerWebSocket, + exclude?: WSContext, ): void { const sockets = rooms.get(householdId); if (!sockets) return; @@ -43,57 +46,57 @@ export function broadcast( } } -// ── WebSocket Handlers ───────────────────────────────────────────────────────── +// ── WebSocket Handler Factory ────────────────────────────────────────────────── -export const shoppingWsHandlers = { - async open(ws: ServerWebSocket) { - const { householdId } = ws.data; - joinRoom(householdId, ws); - const items = await getShoppingItems(householdId); - ws.send(JSON.stringify({ type: "sync", items } satisfies ShoppingServerEvent)); - }, +export function createShoppingWsHandler(householdId: string, userId: string) { + return { + async onOpen(_event: Event, ws: WSContext) { + joinRoom(householdId, ws); + const items = await getShoppingItems(householdId); + ws.send(JSON.stringify({ type: "sync", items } satisfies ShoppingServerEvent)); + }, - async message(ws: ServerWebSocket, rawMessage: string | Buffer) { - const { householdId, userId } = ws.data; - let cmd: ShoppingClientCommand; - try { - cmd = JSON.parse( - typeof rawMessage === "string" ? rawMessage : rawMessage.toString(), - ) as ShoppingClientCommand; - } catch { - return; - } - - if (cmd.type === "item:add") { - const item = await addShoppingItem(householdId, userId, cmd.label, cmd.quantity); - broadcast(householdId, { type: "item:added", item }); - } else if (cmd.type === "item:check") { - const item = await checkShoppingItem(cmd.itemId, householdId, userId); - if (item) { - broadcast(householdId, { - type: "item:checked", - itemId: item.id, - checkedBy: item.checkedBy!, - checkedAt: item.checkedAt!, - }); + async onMessage(event: MessageEvent, ws: WSContext) { + let cmd: ShoppingClientCommand; + try { + cmd = JSON.parse( + typeof event.data === "string" ? event.data : event.data.toString(), + ) as ShoppingClientCommand; + } catch { + return; } - } else if (cmd.type === "item:uncheck") { - await uncheckShoppingItem(cmd.itemId, householdId); - broadcast(householdId, { type: "item:unchecked", itemId: cmd.itemId }); - } else if (cmd.type === "item:delete") { - await deleteShoppingItem(cmd.itemId, householdId); - broadcast(householdId, { type: "item:deleted", itemId: cmd.itemId }); - } else if (cmd.type === "item:clear") { - await db - .delete(shoppingItems) - .where( - and(eq(shoppingItems.householdId, householdId), isNotNull(shoppingItems.checkedBy)), - ); - broadcast(householdId, { type: "item:cleared" }); - } - }, - close(ws: ServerWebSocket) { - leaveRoom(ws.data.householdId, ws); - }, -}; + if (cmd.type === "item:add") { + const item = await addShoppingItem(householdId, userId, cmd.label, cmd.quantity); + broadcast(householdId, { type: "item:added", item }); + } else if (cmd.type === "item:check") { + const item = await checkShoppingItem(cmd.itemId, householdId, userId); + if (item) { + broadcast(householdId, { + type: "item:checked", + itemId: item.id, + checkedBy: item.checkedBy!, + checkedAt: item.checkedAt!, + }); + } + } else if (cmd.type === "item:uncheck") { + await uncheckShoppingItem(cmd.itemId, householdId); + broadcast(householdId, { type: "item:unchecked", itemId: cmd.itemId }); + } else if (cmd.type === "item:delete") { + await deleteShoppingItem(cmd.itemId, householdId); + broadcast(householdId, { type: "item:deleted", itemId: cmd.itemId }); + } else if (cmd.type === "item:clear") { + await db + .delete(shoppingItems) + .where( + and(eq(shoppingItems.householdId, householdId), isNotNull(shoppingItems.checkedBy)), + ); + broadcast(householdId, { type: "item:cleared" }); + } + }, + + onClose(_event: Event, ws: WSContext) { + leaveRoom(householdId, ws); + }, + }; +}