diff --git a/.github/ignore.copilot-instructions.md b/.github/ignore.copilot-instructions.md new file mode 100644 index 0000000..8cbf33f --- /dev/null +++ b/.github/ignore.copilot-instructions.md @@ -0,0 +1,8 @@ +## 参考になるドキュメント + +設計.md を見て + +## ルール + +- 余計なコメントを書かないで。複雑な処理の説明とかはいいけど、コードの内容を説明するコメントは不要。 +- コメントは日本語で書いてください。 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c4b3862..94ec36c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -32,5 +32,3 @@ jobs: project: "ppap" entrypoint: "backend/server.ts" root: "." - - diff --git a/.gitignore b/.gitignore index 289e710..cbb6756 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ dist-ssr *.njsproj *.sln *.sw? + +test \ No newline at end of file diff --git a/Procfile b/Procfile deleted file mode 100644 index f6f951b..0000000 --- a/Procfile +++ /dev/null @@ -1 +0,0 @@ -web: deno run --allow-net=:${PORT} --allow-env --allow-read ./backend/server.ts --port=${PORT} \ No newline at end of file diff --git a/backend/clear_kv.ts b/backend/clear_kv.ts new file mode 100644 index 0000000..33cf713 --- /dev/null +++ b/backend/clear_kv.ts @@ -0,0 +1,6 @@ +// KVストアを完全にクリアするユーティリティ +export async function clearKvAll(kv: Deno.Kv) { + for await (const entry of kv.list({ prefix: [] })) { + await kv.delete(entry.key) + } +} diff --git a/backend/handlers/roomHandlers.ts b/backend/handlers/roomHandlers.ts new file mode 100644 index 0000000..6064dad --- /dev/null +++ b/backend/handlers/roomHandlers.ts @@ -0,0 +1,250 @@ +import { Room, UserTokenInfo } from "../type.ts" +import { CreateRoomRequest, CreateRoomResponse, RoomForClient } from "../type.ts" +import { CreateRoomRequestSchema } from "../validate.ts" +import { createRoom, createUserToken, leaveRoom, roomKey, userTokenKey } from "../kv.ts" + +export function toRoomForClient(room: Room, userToken: string): RoomForClient { + return { + id: room.id, + participants: room.participants.map((p, i) => ({ + name: p.name, + userNumber: i, + isMe: p.token === userToken, + answer: p.answer, + isAudience: p.isAudience ?? false, + })), + config: room.config, + createdAt: room.createdAt, + updatedAt: room.updatedAt, + } +} + +export async function handleCreateRoom( + req: Request, + kv: Deno.Kv, +): Promise { + let body: CreateRoomRequest + try { + body = await req.json() + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON" }), { + status: 400, + }) + } + const parse = CreateRoomRequestSchema.safeParse(body) + if (!parse.success) { + return new Response(JSON.stringify({ error: "Validation error" }), { + status: 400, + }) + } + const { userName } = parse.data + const roomId = crypto.randomUUID() + const userToken = crypto.randomUUID() + const now = Date.now() + const room: Room = { + id: roomId, + participants: [ + { token: userToken, name: userName, answer: "", isAudience: false }, + ], + config: { allowSpectators: true, maxParticipants: 50 }, + createdAt: now, + updatedAt: now, + } + const userTokenInfo: UserTokenInfo = { + token: userToken, + currentRoomId: roomId, + name: userName, + isSpectator: false, + lastAccessedAt: now, + } + try { + await createRoom(kv, room) + await createUserToken(kv, userTokenInfo) + const userNumber = 0 + const res = { + roomId, + userToken, + userNumber, + room: toRoomForClient(room, userToken), + } + return new Response(JSON.stringify(res), { + status: 201, + headers: { "content-type": "application/json" }, + }) + } catch (_e) { + return new Response(JSON.stringify({ error: "Server error" }), { + status: 500, + }) + } +} + +export async function handleJoinRoom( + req: Request, + roomId: string, + kv: Deno.Kv, +): Promise { + let body: { userName: string; userToken?: string } + try { + body = await req.json() + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON" }), { + status: 400, + }) + } + if ( + !body.userName || + typeof body.userName !== "string" || + body.userName.length > 24 + ) { + return new Response(JSON.stringify({ error: "Validation error" }), { + status: 400, + }) + } + const roomRes = await kv.get(roomKey(roomId)) + const room = roomRes.value + if (!room) { + return new Response(JSON.stringify({ error: "Room not found" }), { + status: 404, + }) + } + let userToken = body.userToken + if (!userToken) { + userToken = crypto.randomUUID() + } + const userTokenInfoRes = await kv.get(userTokenKey(userToken)) + let userTokenInfo = userTokenInfoRes.value + if (!userTokenInfo) { + userTokenInfo = { + token: userToken, + currentRoomId: roomId, + name: body.userName, + isSpectator: false, + lastAccessedAt: Date.now(), + } + await kv.atomic().set(userTokenKey(userToken), userTokenInfo).commit() + } else { + userTokenInfo = { + ...userTokenInfo, + currentRoomId: roomId, + name: body.userName, + lastAccessedAt: Date.now(), + } + await kv.atomic().set(userTokenKey(userToken), userTokenInfo).commit() + } + if (!room.participants.some((p) => p.token === userToken)) { + room.participants.push({ + token: userToken, + name: body.userName, + answer: "", + isAudience: false, + }) + room.updatedAt = Date.now() + await kv.atomic().set(roomKey(roomId), room).commit() + } + const userNumber = room.participants.findIndex((p) => p.token === userToken) + return new Response( + JSON.stringify({ + userToken, + userNumber, + room: toRoomForClient(room, userToken), + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ) +} + +export async function handleLeaveRoom( + req: Request, + roomId: string, + kv: Deno.Kv, +): Promise { + let body: { userToken: string } + try { + body = await req.json() + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON" }), { + status: 400, + }) + } + if (!body.userToken || typeof body.userToken !== "string") { + return new Response(JSON.stringify({ error: "Validation error" }), { + status: 400, + }) + } + // leaveRoomの新しい呼び出し方に対応 + const result = await leaveRoom(kv, { roomId, userToken: body.userToken }) + if (!result.ok) { + return new Response(JSON.stringify({ error: result.error }), { + status: 500, + }) + } + return new Response( + JSON.stringify({ message: "Successfully left the room" }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ) +} + +export async function handleRejoinRoom( + req: Request, + roomId: string, + kv: Deno.Kv, +): Promise { + let body: { userToken: string } + try { + body = await req.json() + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON" }), { + status: 400, + }) + } + const userToken = body.userToken + if (!userToken || typeof userToken !== "string") { + return new Response(JSON.stringify({ error: "Validation error" }), { + status: 400, + }) + } + const userTokenInfoRes = await kv.get(userTokenKey(userToken)) + const userTokenInfo = userTokenInfoRes.value + if (!userTokenInfo) { + return new Response( + JSON.stringify({ error: "User not found or invalid token" }), + { + status: 404, + }, + ) + } + const roomRes = await kv.get(roomKey(roomId)) + const room = roomRes.value + if (!room) { + return new Response(JSON.stringify({ error: "Room not found" }), { + status: 404, + }) + } + const participant = room.participants.find((p) => p.token === userToken) + if (!participant) { + return new Response( + JSON.stringify({ error: "User not joined in this room" }), + { + status: 404, + }, + ) + } + const userNumber = room.participants.findIndex((p) => p.token === userToken) + return new Response( + JSON.stringify({ + userToken, + userNumber, + room: toRoomForClient(room, userToken), + userName: participant.name, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ) +} diff --git a/backend/handlers/roomHandlers_test.ts b/backend/handlers/roomHandlers_test.ts new file mode 100644 index 0000000..61d397b --- /dev/null +++ b/backend/handlers/roomHandlers_test.ts @@ -0,0 +1,290 @@ +import { clearKvAll } from "../clear_kv.ts" +import { getRoom, getUserToken } from "../kv.ts" +import { + CreateRoomRequest, + CreateRoomResponse, + JoinRoomResponse, + LeaveRoomResponse, + RejoinRoomResponse, +} from "../type.ts" +import { + handleCreateRoom, + handleJoinRoom, + handleLeaveRoom, + handleRejoinRoom, +} from "./roomHandlers.ts" +import { assert, assertEquals, assertExists } from "https://deno.land/std@0.203.0/assert/mod.ts" + +Deno.test({ + name: "handleCreateRoom", + fn: async (t) => { + let kv: Deno.Kv + let res: Response + let json: CreateRoomResponse + await t.step("setup", async () => { + kv = await Deno.openKv("./test") + await clearKvAll(kv) + const reqBody: CreateRoomRequest = { + userName: "テストユーザー", + } + const req = new Request("http://localhost/rooms", { + method: "POST", + body: JSON.stringify(reqBody), + headers: { "content-type": "application/json" }, + }) + res = await handleCreateRoom(req, kv) + json = await res.json() + }) + await t.step("レスポンス: 正常系", () => { + assertEquals(res.status, 201) + assertExists(json.roomId) + assertExists(json.userToken) + assertExists(json.room) + assertEquals(json.userNumber, 0) + assert(json.room.participants.every((p) => !("token" in p))) + assertEquals(json.room.participants[0].userNumber, 0) + assertEquals(json.room.participants[0].isMe, true) + assertEquals(json.room.participants[0].name, "テストユーザー") + assertEquals(json.room.participants[0].answer, "") + }) + await t.step("KV: 登録内容の妥当性", async () => { + const room = await getRoom(kv, json.roomId) + assertExists(room) + assertEquals(room?.participants.length, 1) + assertEquals(room?.participants[0].token, json.userToken) + assertEquals(room?.participants[0].name, "テストユーザー") + assertEquals(room?.participants[0].answer, "") + const userToken = await getUserToken(kv, json.userToken) + assertExists(userToken) + assertEquals(userToken?.name, "テストユーザー") + assertEquals(userToken?.currentRoomId, json.roomId) + await clearKvAll(kv) + kv.close() + }) + }, +}) + +Deno.test({ + name: "handleJoinRoom", + fn: async (t) => { + let kv: Deno.Kv + let roomId: string + let joinRes: Response + let joinJson: JoinRoomResponse + let userToken: string + await t.step("setup", async () => { + kv = await Deno.openKv("./test") + await clearKvAll(kv) + const reqBody: CreateRoomRequest = { userName: "参加者1" } + const createReq = new Request("http://localhost/rooms", { + method: "POST", + body: JSON.stringify(reqBody), + headers: { "content-type": "application/json" }, + }) + const createRes = await handleCreateRoom(createReq, kv) + const createJson = await createRes.json() + roomId = createJson.roomId + const req = new Request("http://localhost/rooms/" + roomId + "/join", { + method: "POST", + body: JSON.stringify({ userName: "参加者2" }), + headers: { "content-type": "application/json" }, + }) + joinRes = await handleJoinRoom(req, roomId, kv) + joinJson = await joinRes.json() + userToken = joinJson.userToken + }) + await t.step("レスポンス: 正常系", () => { + assertEquals(joinRes.status, 200) + assertExists(joinJson.userToken) + assertExists(joinJson.room) + assertEquals(joinJson.room.id, roomId) + assertEquals(joinJson.room.participants.length, 2) + }) + await t.step("cleanup", async () => { + await clearKvAll(kv) + kv.close() + }) + }, +}) + +Deno.test({ + name: "handleLeaveRoom", + fn: async (t) => { + let kv: Deno.Kv + let roomId: string + let userToken: string + let leaveRes: Response + let leaveJson: LeaveRoomResponse + await t.step("setup", async () => { + kv = await Deno.openKv("./test") + await clearKvAll(kv) + const reqBody: CreateRoomRequest = { userName: "離脱ユーザー" } + const createReq = new Request("http://localhost/rooms", { + method: "POST", + body: JSON.stringify(reqBody), + headers: { "content-type": "application/json" }, + }) + const createRes = await handleCreateRoom(createReq, kv) + const createJson = await createRes.json() + roomId = createJson.roomId + userToken = createJson.userToken + const req = new Request("http://localhost/rooms/" + roomId + "/leave", { + method: "POST", + body: JSON.stringify({ userToken }), + headers: { "content-type": "application/json" }, + }) + leaveRes = await handleLeaveRoom(req, roomId, kv) + leaveJson = await leaveRes.json() + }) + await t.step("レスポンス: 正常系", () => { + assertEquals(leaveRes.status, 200) + assertEquals(leaveJson.message, "Successfully left the room") + }) + await t.step("KV: 離脱後の内容", async () => { + const room = await getRoom(kv, roomId) + assertEquals(room, null) + const user = await getUserToken(kv, userToken) + assertEquals(user, null) + await clearKvAll(kv) + kv.close() + }) + }, +}) + +Deno.test({ + name: "handleRejoinRoom", + fn: async (t) => { + let kv: Deno.Kv | undefined + let roomId: string = "" + let userToken: string = "" + let rejoinRes: Response | undefined + let rejoinJson: RejoinRoomResponse | { error: string } + await t.step("setup", async () => { + kv = await Deno.openKv("./test") + await clearKvAll(kv) + const reqBody = { userName: "再入場ユーザー" } + const createReq = new Request("http://localhost/rooms", { + method: "POST", + body: JSON.stringify(reqBody), + headers: { "content-type": "application/json" }, + }) + const createRes = await handleCreateRoom(createReq, kv) + const createJson = await createRes.json() + roomId = createJson.roomId + userToken = createJson.userToken + }) + await t.step("未参加者がrejoinした場合404", async () => { + if (!kv) throw new Error("kv not initialized") + const newUserToken = crypto.randomUUID() + const rejoinReq = new Request( + "http://localhost/rooms/" + roomId + "/rejoin", + { + method: "POST", + body: JSON.stringify({ userToken: newUserToken }), + headers: { "content-type": "application/json" }, + }, + ) + const res = await handleRejoinRoom(rejoinReq, roomId, kv) + assertEquals(res.status, 404) + const json = await res.json() + assert("error" in json) + }) + await t.step("参加済みユーザーがrejoinした場合200", async () => { + if (!kv) throw new Error("kv not initialized") + const rejoinReq = new Request( + "http://localhost/rooms/" + roomId + "/rejoin", + { + method: "POST", + body: JSON.stringify({ userToken }), + headers: { "content-type": "application/json" }, + }, + ) + rejoinRes = await handleRejoinRoom(rejoinReq, roomId, kv) + rejoinJson = await rejoinRes.json() + assertEquals(rejoinRes.status, 200) + assertEquals( + "userToken" in rejoinJson ? rejoinJson.userToken : undefined, + userToken, + ) + assertEquals( + "userNumber" in rejoinJson ? typeof rejoinJson.userNumber : undefined, + "number", + ) + assertExists("room" in rejoinJson ? rejoinJson.room : undefined) + }) + if (kv) { + await clearKvAll(kv) + kv.close() + } + }, +}) + +Deno.test({ + name: "isAudienceフラグの初期値と反映", + fn: async (t) => { + let kv: Deno.Kv + await t.step("参加時はisAudience=false", async () => { + kv = await Deno.openKv("./test") + await clearKvAll(kv) + const reqBody: CreateRoomRequest = { userName: "A" } + const createReq = new Request("http://localhost/rooms", { + method: "POST", + body: JSON.stringify(reqBody), + headers: { "content-type": "application/json" }, + }) + const createRes = await handleCreateRoom(createReq, kv) + const createJson = await createRes.json() + const joinReq = new Request( + "http://localhost/rooms/" + createJson.roomId + "/join", + { + method: "POST", + body: JSON.stringify({ userName: "B" }), + headers: { "content-type": "application/json" }, + }, + ) + const joinRes = await handleJoinRoom(joinReq, createJson.roomId, kv) + const joinJson = await joinRes.json() + assertEquals(joinJson.room.participants[0].isAudience, false) + assertEquals(joinJson.room.participants[1].isAudience, false) + await clearKvAll(kv) + kv.close() + }) + await t.step( + "isAudience=trueで保存した場合toRoomForClientで反映", + async () => { + kv = await Deno.openKv("./test") + await clearKvAll(kv) + const reqBody: CreateRoomRequest = { userName: "A" } + const createReq = new Request("http://localhost/rooms", { + method: "POST", + body: JSON.stringify(reqBody), + headers: { "content-type": "application/json" }, + }) + const createRes = await handleCreateRoom(createReq, kv) + const createJson = await createRes.json() + const room = await getRoom(kv, createJson.roomId) + if (room) { + room.participants[0].isAudience = true + await kv.atomic().set(["rooms", createJson.roomId], room).commit() + } + const rejoinReq = new Request( + "http://localhost/rooms/" + createJson.roomId + "/rejoin", + { + method: "POST", + body: JSON.stringify({ userToken: createJson.userToken }), + headers: { "content-type": "application/json" }, + }, + ) + const rejoinRes = await handleRejoinRoom( + rejoinReq, + createJson.roomId, + kv, + ) + const rejoinJson = await rejoinRes.json() + assertEquals(rejoinJson.room.participants[0].isAudience, true) + await clearKvAll(kv) + kv.close() + }, + ) + }, +}) diff --git a/backend/handlers/wsHandlers.ts b/backend/handlers/wsHandlers.ts new file mode 100644 index 0000000..e1d3ff8 --- /dev/null +++ b/backend/handlers/wsHandlers.ts @@ -0,0 +1,230 @@ +import { Room } from "../type.ts" +import { toRoomForClient } from "./roomHandlers.ts" + +// --- ルームごとのWebSocket接続管理 --- +type SocketWithToken = { socket: WebSocket; userToken: string | null } +const roomSockets = new Map>() +// --- ルームごとのwatcher起動管理 --- +const roomWatchers = new Map() +// --- 切断時の退室タイマー管理 --- +// denoのsetTimeout/clearTimeoutはnumber型 +const disconnectTimers = new Map() + +export function handleWebSocket( + request: Request, + roomId: string, + kv: Deno.Kv, + upgradeWebSocket: (req: Request) => { + socket: WebSocket + response: Response + } = Deno.upgradeWebSocket, +): Response { + if (request.headers.get("upgrade") != "websocket") { + return new Response("Not a websocket request", { status: 400 }) + } + const { socket, response } = upgradeWebSocket(request) + // ルームごとにソケットを管理 + if (!roomSockets.has(roomId)) roomSockets.set(roomId, new Set()) + // userTokenは最初はnull、後でクライアントから受信してセット + const socketObj: SocketWithToken = { socket, userToken: null } + roomSockets.get(roomId)!.add(socketObj) + + // --- 再接続時の退室キャンセル --- + function cancelDisconnectTimer(userToken: string | null) { + if (!userToken) return + const key = `${roomId}:${userToken}` + const timer = disconnectTimers.get(key) + if (timer) { + clearTimeout(timer) + disconnectTimers.delete(key) + } + } + + socket.onopen = () => { + console.log(`WebSocket connected for room: ${roomId}`) + // ルームごとに一度だけwatcherを起動 + if (!roomWatchers.has(roomId)) { + startRoomWatcherForRoom(roomId, kv) + roomWatchers.set(roomId, true) + } + // 再接続時の退室キャンセル + cancelDisconnectTimer(socketObj.userToken) + } + socket.onmessage = async (event) => { + if (handleAuthMessage(socketObj, event.data)) { + // 認証時(userToken受信時)にも退室キャンセル + cancelDisconnectTimer(socketObj.userToken) + return + } + if (event.data === "ping") { + socket.send("pong") + return + } + try { + const msg = JSON.parse(event.data) + if (msg.type === "answer") { + // 回答メッセージ受信時、Roomを更新 + const answer = msg.answer + if (typeof answer === "string" && socketObj.userToken) { + const roomRes = await kv.get([ + "rooms", + roomId, + ]) + const room = roomRes.value + if (room) { + const participant = room.participants.find( + (p) => p.token === socketObj.userToken, + ) + if (participant) { + participant.answer = answer + room.updatedAt = Date.now() + await kv.atomic().set(["rooms", roomId], room).commit() + } + } + } + } else if ( + msg.type === "setAudience" && + typeof msg.isAudience === "boolean" && + socketObj.userToken + ) { + const roomRes = await kv.get([ + "rooms", + roomId, + ]) + const room = roomRes.value + if (room) { + const participant = room.participants.find( + (p) => p.token === socketObj.userToken, + ) + if (participant) { + participant.isAudience = msg.isAudience + room.updatedAt = Date.now() + await kv.atomic().set(["rooms", roomId], room).commit() + } + } + } else if (msg.type === "clearAnswer" && socketObj.userToken) { + const roomRes = await kv.get([ + "rooms", + roomId, + ]) + const room = roomRes.value + if (room) { + // 全員の回答を消去 + for (const participant of room.participants) { + participant.answer = "" + } + room.updatedAt = Date.now() + await kv.atomic().set(["rooms", roomId], room).commit() + } + } + } catch (_e: unknown) { + console.error( + (_e as WebSocketError)?.message || "WebSocket message error", + ) + } + } + socket.onclose = () => { + roomSockets.get(roomId)?.delete(socketObj) + console.log(`WebSocket closed for room: ${roomId}`) + // --- 2秒後に退室・トークン削除タイマー --- + if (socketObj.userToken) { + const key = `${roomId}:${socketObj.userToken}` + // 既存タイマーがあればクリア + const prev = disconnectTimers.get(key) + if (prev) clearTimeout(prev) + // 2秒後に退室処理 + const timer = setTimeout(async () => { + // leaveRoom関数を新しい呼び出し方で利用 + if (socketObj.userToken) { + await import("../kv.ts").then(async (kvmod) => { + await kvmod.leaveRoom(kv, { + roomId, + userToken: socketObj.userToken!, + }) + }) + } + disconnectTimers.delete(key) + }, 2000) + disconnectTimers.set(key, timer as unknown as number) + } + } + socket.onerror = (e) => { + console.error(`WebSocket error:`, e) + } + return response +} + +// --- テストしやすいようuserToken認証処理を分離 --- +export function handleAuthMessage( + socketObj: { userToken: string | null }, + data: string, +) { + try { + const msg = JSON.parse(data) + if (msg.type === "auth" && typeof msg.userToken === "string") { + socketObj.userToken = msg.userToken + return true + } + } catch {} + return false +} + +// --- ルームごとに個別にwatcherを起動 --- +function startRoomWatcherForRoom(roomId: string, kv: Deno.Kv) { + ;(async () => { + const iter = kv.watch([["rooms", roomId]]) + for await (const entries of iter) { + for (const entry of entries) { + const key = entry.key + const value = entry.value + if (!Array.isArray(key) || key[0] !== "rooms" || key[1] !== roomId) { + continue + } + const sockets = roomSockets.get(roomId) + if (!sockets || sockets.size === 0) continue + for (const wsObj of sockets) { + if (!wsObj.userToken) continue // userToken未登録はスキップ + try { + const roomForClient = toRoomForClient( + value as import("../type.ts").Room, + wsObj.userToken, + ) + const msg = JSON.stringify({ type: "room", room: roomForClient }) + wsObj.socket.send(msg) + } catch (_e) {} + } + } + } + })() +} + +// --- テスト用: 1回だけ監視して終了するwatcher --- +export async function startRoomWatcherForRoomOnce(roomId: string, kv: Deno.Kv) { + const iter = kv.watch({ prefix: ["rooms", roomId] } as any) + for await (const entries of iter) { + for (const entry of entries) { + const key = entry.key + const value = entry.value + if (!Array.isArray(key) || key[0] !== "rooms" || key[1] !== roomId) { + continue + } + const sockets = roomSockets.get(roomId) + if (!sockets || sockets.size === 0) continue + for (const wsObj of sockets) { + if (!wsObj.userToken) continue + try { + const roomForClient = toRoomForClient( + value as import("../type.ts").Room, + wsObj.userToken, + ) + const msg = JSON.stringify({ type: "room", room: roomForClient }) + wsObj.socket.send(msg) + } catch (_e) {} + } + } + break // 1回だけで終了 + } +} + +// --- テスト用にエクスポート --- +export { roomSockets, startRoomWatcherForRoom } diff --git a/backend/handlers/wsHandlers_answer_test.ts b/backend/handlers/wsHandlers_answer_test.ts new file mode 100644 index 0000000..cb48361 --- /dev/null +++ b/backend/handlers/wsHandlers_answer_test.ts @@ -0,0 +1,146 @@ +import { assertEquals } from "jsr:@std/assert" +import { handleWebSocket } from "./wsHandlers.ts" +import { Room } from "../type.ts" + +Deno.test( + "WebSocket: answerメッセージで参加者のanswerが更新される", + async () => { + const roomId = "room1" + const userToken = "tokentest" + const testRoom: Room = { + id: roomId, + participants: [ + { + token: userToken, + name: "user", + answer: "", + isAudience: false, + }, + { + token: "other", + name: "other", + answer: "", + isAudience: false, + }, + ], + config: { allowSpectators: true, maxParticipants: 10 }, + createdAt: 1, + updatedAt: 2, + } + let savedRoom: Room | null = null + const fakeSocket = { + send: (_msg: string) => {}, + close: () => {}, + addEventListener: () => {}, + onopen: null, + onmessage: null, + onclose: null, + onerror: null, + } as unknown as WebSocket + const upgradeWebSocket = (_req: Request) => ({ + socket: fakeSocket, + response: new Response(null, { status: 101 }), + }) + const kv = { + get: (_key: unknown) => Promise.resolve({ value: savedRoom ?? testRoom }), + atomic: () => ({ + set: (_key: unknown, value: Room) => { + savedRoom = value as Room + return { commit: () => Promise.resolve({ ok: true }) } + }, + commit: () => Promise.resolve({ ok: true }), + }), + watch: () => ({ async *[Symbol.asyncIterator]() {} }), + } as unknown as Deno.Kv + + const req = new Request("http://localhost/ws/rooms/" + roomId, { + method: "GET", + headers: { + "upgrade": "websocket", + "connection": "Upgrade", + "sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==", + "sec-websocket-version": "13", + }, + }) + handleWebSocket(req, roomId, kv, upgradeWebSocket) + const wsHandlers = await import("./wsHandlers.ts") + const wsSet = wsHandlers.roomSockets.get(roomId) + if (!wsSet) throw new Error("roomSockets not set") + const wsEntry = Array.from(wsSet)[0] + wsEntry.userToken = userToken + const answerMsg = JSON.stringify({ type: "answer", answer: "5" }) + if (!wsEntry.socket.onmessage) throw new Error("onmessage not set") + await wsEntry.socket.onmessage({ data: answerMsg } as MessageEvent) + if (!savedRoom) throw new Error("Room not saved") + const room: Room = savedRoom as Room + assertEquals(room.participants[0].answer, "5") + }, +) + +Deno.test( + "WebSocket: setAudienceメッセージで参加者のisAudienceが更新される", + async () => { + const roomId = "room2" + const userToken = "tokentest2" + const testRoom: Room = { + id: roomId, + participants: [ + { token: userToken, name: "user", answer: "", isAudience: false }, + { token: "other", name: "other", answer: "", isAudience: false }, + ], + config: { allowSpectators: true, maxParticipants: 10 }, + createdAt: 1, + updatedAt: 2, + } + let savedRoom: Room | null = null + const fakeSocket = { + send: (_msg: string) => {}, + close: () => {}, + addEventListener: () => {}, + onopen: null, + onmessage: null, + onclose: null, + onerror: null, + } as unknown as WebSocket + const upgradeWebSocket = (_req: Request) => ({ + socket: fakeSocket, + response: new Response(null, { status: 101 }), + }) + const kv = { + get: (_key: unknown) => Promise.resolve({ value: savedRoom ?? testRoom }), + atomic: () => ({ + set: (_key: unknown, value: Room) => { + savedRoom = value as Room + return { commit: () => Promise.resolve({ ok: true }) } + }, + commit: () => Promise.resolve({ ok: true }), + }), + watch: () => ({ async *[Symbol.asyncIterator]() {} }), + } as unknown as Deno.Kv + + const req = new Request("http://localhost/ws/rooms/" + roomId, { + method: "GET", + headers: { + "upgrade": "websocket", + "connection": "Upgrade", + "sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==", + "sec-websocket-version": "13", + }, + }) + handleWebSocket(req, roomId, kv, upgradeWebSocket) + const wsHandlers = await import("./wsHandlers.ts") + const wsSet = wsHandlers.roomSockets.get(roomId) + if (!wsSet) throw new Error("roomSockets not set") + const wsEntry = Array.from(wsSet)[0] + wsEntry.userToken = userToken + const setAudienceMsg = JSON.stringify({ + type: "setAudience", + isAudience: true, + }) + if (!wsEntry.socket.onmessage) throw new Error("onmessage not set") + await wsEntry.socket.onmessage({ data: setAudienceMsg } as MessageEvent) + if (!savedRoom) throw new Error("Room not saved") + const room: Room = savedRoom as Room + assertEquals(room.participants[0].isAudience, true) + }, +) diff --git a/backend/handlers/wsHandlers_clearAnswer_test.ts b/backend/handlers/wsHandlers_clearAnswer_test.ts new file mode 100644 index 0000000..2377cbe --- /dev/null +++ b/backend/handlers/wsHandlers_clearAnswer_test.ts @@ -0,0 +1,82 @@ +import { assertEquals } from "jsr:@std/assert" +import { handleWebSocket } from "./wsHandlers.ts" +import { genMsgClearAnswer } from "../../wsMsg/msgFromClient.ts" +import type { Room } from "../type.ts" + +Deno.test("WebSocket: clearAnswerメッセージで回答が消去される", async () => { + const roomId = "room_clear_test" + const userToken = "tokentest" + const testRoom: Room = { + id: roomId, + participants: [ + { token: userToken, name: "user", answer: "5", isAudience: false }, + { token: "tokentest2", name: "user2", answer: "8", isAudience: false }, + ], + config: { allowSpectators: true, maxParticipants: 10 }, + createdAt: 1, + updatedAt: 2, + } + let savedRoom: Room | null = null + const kv = { + get: async () => await Promise.resolve({ value: testRoom }), + atomic: () => ({ + set: (_key: unknown, room: Room) => { + savedRoom = JSON.parse(JSON.stringify(room)) + return { commit: async () => {} } + }, + }), + watch: () => ({ async *[Symbol.asyncIterator]() {} }), + } as unknown as Deno.Kv + + let onmessageHandler: ((event: { data: string }) => void) | undefined + const fakeSocket: Partial = { + send: (_msg: string) => {}, + close: () => {}, + addEventListener: () => {}, + onopen: null, + onclose: null, + onerror: null, + } + Object.defineProperty(fakeSocket, "onmessage", { + set(fn) { + onmessageHandler = fn + }, + get() { + return onmessageHandler + }, + configurable: true, + }) + const socketObj = { socket: fakeSocket as WebSocket, userToken } + const wsHandlers = await import("./wsHandlers.ts") + // @ts-ignore: テスト用に型不一致を許容(roomSocketsは本来private想定) + wsHandlers.roomSockets.set(roomId, new Set([socketObj])) + + const req = new Request("http://localhost/ws/rooms/" + roomId, { + method: "GET", + headers: { + "upgrade": "websocket", + "connection": "Upgrade", + "sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==", + "sec-websocket-version": "13", + }, + }) + handleWebSocket(req, roomId, kv, () => ({ + socket: fakeSocket as unknown as WebSocket, + response: new Response(null, { status: 101 }), + })) + + if (typeof onmessageHandler !== "function") { + throw new Error("onmessage not set") + } + const authMsg = { type: "auth", userToken } + await onmessageHandler!({ data: JSON.stringify(authMsg) }) + const clearMsg = genMsgClearAnswer() + await onmessageHandler!({ data: JSON.stringify(clearMsg) }) + await new Promise((r) => setTimeout(r, 10)) + + // 回答が消去されたか確認 + if (!savedRoom) throw new Error("Room not saved") + for (const p of (savedRoom as Room).participants) { + assertEquals(p.answer, "") + } +}) diff --git a/backend/handlers/wsHandlers_test.ts b/backend/handlers/wsHandlers_test.ts new file mode 100644 index 0000000..9a29d50 --- /dev/null +++ b/backend/handlers/wsHandlers_test.ts @@ -0,0 +1,89 @@ +import { assertEquals } from "jsr:@std/assert" +import { handleAuthMessage, handleWebSocket } from "./wsHandlers.ts" + +Deno.test("WebSocket: upgrade request returns response", () => { + const req = new Request("http://localhost/ws/rooms/testroom", { + method: "GET", + headers: { + "upgrade": "websocket", + "connection": "Upgrade", + "sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==", + "sec-websocket-version": "13", + }, + }) + const kv = { + watch: () => ({ + async *[Symbol.asyncIterator]() {}, + }), + } as unknown as Deno.Kv + + const res = handleWebSocket(req, "testroom", kv) + assertEquals(res instanceof Response, true) + assertEquals(res.status, 101) +}) + +Deno.test( + "handleAuthMessage: userToken認証メッセージでuserTokenが記憶される", + () => { + const socketObj = { userToken: null } + const ok = handleAuthMessage( + socketObj, + JSON.stringify({ type: "auth", userToken: "tokentest" }), + ) + assertEquals(ok, true) + assertEquals(socketObj.userToken, "tokentest") + }, +) + +Deno.test("handleAuthMessage: 不正なメッセージは無視される", () => { + const socketObj = { userToken: null } + const ok = handleAuthMessage(socketObj, "invalid json") + assertEquals(ok, false) + assertEquals(socketObj.userToken, null) +}) + +Deno.test( + "startRoomWatcherForRoom: room情報が更新されたらメッセージが送信される", + async () => { + const roomId = "room1" + const testRoom = { + id: roomId, + participants: [{ token: "tokentest", name: "user", answer: "" }], + config: { allowSpectators: true, maxParticipants: 10 }, + createdAt: 1, + updatedAt: 2, + } + let sentMsg = null + const fakeSocket = { + send: (msg: string) => { + sentMsg = msg + }, + close: () => {}, + addEventListener: () => {}, + onopen: null, + onmessage: null, + onclose: null, + onerror: null, + } + const socketObj = { + socket: fakeSocket as unknown as WebSocket, + userToken: "tokentest", + } + const wsHandlers = await import("./wsHandlers.ts") + wsHandlers.roomSockets.set(roomId, new Set([socketObj])) + + const kv = { + watch: () => ({ + async *[Symbol.asyncIterator]() { + yield [{ key: ["rooms", roomId], value: testRoom }] + }, + }), + } as unknown as Deno.Kv + + await wsHandlers.startRoomWatcherForRoomOnce(roomId, kv) + assertEquals(typeof sentMsg, "string") + const parsed = JSON.parse(sentMsg!) + assertEquals(parsed.type, "room") + assertEquals(parsed.room.id, roomId) + }, +) diff --git a/backend/kv.ts b/backend/kv.ts new file mode 100644 index 0000000..aa63a6e --- /dev/null +++ b/backend/kv.ts @@ -0,0 +1,119 @@ +import { Room, RoomId, UserToken, UserTokenInfo } from "./type.ts" + +// KVのキー生成 +export function roomKey(roomId: RoomId): Deno.KvKey { + // 戻り値を Deno.KvKey に変更 + return ["rooms", roomId] // 文字列の配列として返す +} +export function userTokenKey(token: UserToken): Deno.KvKey { + // 戻り値を Deno.KvKey に変更 + return ["user_tokens", token] // 文字列の配列として返す +} + +// Room CRUD +export async function createRoom(kv: Deno.Kv, room: Room): Promise { + await kv + .atomic() + .set(roomKey(room.id), room) // 修正されたキー関数を使用 + .commit() +} +export async function getRoom( + kv: Deno.Kv, + roomId: RoomId, +): Promise { + const res = await kv.get(roomKey(roomId)) // 修正されたキー関数を使用 + return res.value ?? null +} +export async function updateRoom(kv: Deno.Kv, room: Room): Promise { + await kv + .atomic() + .set(roomKey(room.id), room) // 修正されたキー関数を使用 + .commit() +} +export async function deleteRoom(kv: Deno.Kv, roomId: RoomId): Promise { + await kv + .atomic() + .delete(roomKey(roomId)) // 修正されたキー関数を使用 + .commit() +} + +// UserToken CRUD +export async function createUserToken( + kv: Deno.Kv, + info: UserTokenInfo, +): Promise { + await kv + .atomic() + .set(userTokenKey(info.token), info) // 修正されたキー関数を使用 + .commit() +} +export async function getUserToken( + kv: Deno.Kv, + token: UserToken, +): Promise { + const res = await kv.get(userTokenKey(token)) // 修正されたキー関数を使用 + return res.value ?? null +} +export async function updateUserToken( + kv: Deno.Kv, + info: UserTokenInfo, +): Promise { + await kv + .atomic() + .set(userTokenKey(info.token), info) // 修正されたキー関数を使用 + .commit() +} +export async function deleteUserToken( + kv: Deno.Kv, + token: UserToken, +): Promise { + await kv + .atomic() + .delete(userTokenKey(token)) // 修正されたキー関数を使用 + .commit() +} + +export async function leaveRoom( + kv: Deno.Kv, + options: { + room?: Room + roomId?: RoomId + userToken: UserToken + }, +): Promise<{ ok: boolean; error?: string }> { + let { room, roomId, userToken } = options as { + room: Room | null | undefined + roomId: RoomId | undefined + userToken: UserToken + } + // room, roomIdがなければuserTokenから取得 + if (!room || !roomId) { + const userTokenInfo = await getUserToken(kv, userToken) + if (!userTokenInfo || !userTokenInfo.currentRoomId) { + return { ok: false, error: "Room or roomId not found for userToken" } + } + roomId = userTokenInfo.currentRoomId + room = await getRoom(kv, roomId) + if (!room) { + return { ok: false, error: "Room not found" } + } + } + // 指定ユーザーを除外したroomを作成 + const idx = room.participants.findIndex((p) => p.token === userToken) + if (idx !== -1) { + room.participants.splice(idx, 1) + room.updatedAt = Date.now() + } + const atomic = kv.atomic() + if (room.participants.length === 0) { + atomic.delete(roomKey(roomId)) + } else { + atomic.set(roomKey(roomId), room) + } + atomic.delete(userTokenKey(userToken)) + const result = await atomic.commit() + if (!result.ok) { + return { ok: false, error: "Failed to update room or delete user token" } + } + return { ok: true } +} diff --git a/backend/kvCleanupJob.ts b/backend/kvCleanupJob.ts deleted file mode 100644 index 824ee16..0000000 --- a/backend/kvCleanupJob.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * 古いKVレコードを定期的に削除するジョブ - */ - -import { Room } from "@/backend/type.ts" - -/** - * 古いKVレコードを削除する関数 - * @param options テスト用オプション - * @param options.thresholdMs 古いと判定する閾値(ミリ秒)(デフォルト: 24時間) - * @param options.kvInstance テスト用のKVインスタンス - * @returns クリーンアップ結果 { cleanedRooms: 削除したルーム数, cleanedSockets: 削除したソケット数 } - */ -export async function cleanupOldKvRecords(options?: { - thresholdMs?: number; - kvInstance?: Deno.Kv; -}) { - console.log("Starting scheduled cleanup of old KV records...") - const kv = options?.kvInstance || await Deno.openKv() - const now = Date.now() - const oneDayMs = 24 * 60 * 60 * 1000 // 24時間(ミリ秒) - const thresholdMs = options?.thresholdMs || oneDayMs - let cleanedRooms = 0 - let cleanedSockets = 0 - - try { - // room_updatesから古いルームを検索 - const roomEntries = kv.list({ prefix: ["room_updates"] }) - const oldRoomIds: string[] = [] - - for await (const entry of roomEntries) { - const [, roomId] = entry.key as [string, string] - const lastUpdate = entry.value as Date - - // 最終更新から指定時間以上経過しているか確認 - if (now - lastUpdate.getTime() > thresholdMs) { - oldRoomIds.push(roomId) - } - } - - // 古いルームを削除 - for (const roomId of oldRoomIds) { - // ルームの情報を取得 - const roomResult = await kv.get(["rooms", roomId]) - const room = roomResult.value - - if (room) { - // トランザクションの準備 - const atomicOp = kv.atomic() - - // ルームに所属するユーザーの関連データを削除 - for (const p of room.participants) { - atomicOp.delete(["user_rooms", p.token]) - atomicOp.delete(["socket_instances", p.token]) - } - - // ルーム自体を削除 - atomicOp.delete(["rooms", roomId]) - atomicOp.delete(["room_updates", roomId]) - - await atomicOp.commit() - cleanedRooms++ - } else { - // ルームが見つからない場合は、関連するroom_updatesエントリのみ削除 - await kv.delete(["room_updates", roomId]) - } - } - - // 古いsocket_instancesエントリを削除 - // socket_instancesはタイムスタンプを持たないため、関連するuser_roomsがあるかで判断 - const socketEntries = kv.list({ prefix: ["socket_instances"] }) - const socketsToDelete: string[] = [] - - for await (const entry of socketEntries) { - const userToken = entry.key[1] as string - - // このユーザーが部屋に所属しているか確認 - const userRoomResult = await kv.get(["user_rooms", userToken]) - - // 部屋に所属していない場合、socket_instancesエントリを削除 - if (!userRoomResult.value) { - socketsToDelete.push(userToken) - } - } - - // 古いソケットインスタンスを削除 - for (const userToken of socketsToDelete) { - await kv.delete(["socket_instances", userToken]) - cleanedSockets++ - } - - console.log(`Scheduled cleanup completed: Deleted ${cleanedRooms} old rooms and ${cleanedSockets} socket instances`) - - // テスト用に結果を返す - return { cleanedRooms, cleanedSockets } - } catch (error) { - console.error("Error during scheduled KV cleanup:", error) - throw error // テスト中はエラーを伝播させる - } finally { - // 外部から渡されたKVインスタンスでない場合のみクローズ - if (!options?.kvInstance) { - kv.close() - } - } -} \ No newline at end of file diff --git a/backend/kv_test.ts b/backend/kv_test.ts new file mode 100644 index 0000000..401b3bf --- /dev/null +++ b/backend/kv_test.ts @@ -0,0 +1,64 @@ +import { + createRoom, + createUserToken, + deleteRoom, + deleteUserToken, + getRoom, + getUserToken, + updateRoom, + updateUserToken, +} from "./kv.ts" +import { Room, UserTokenInfo } from "./type.ts" + +Deno.test("Room CRUD", async () => { + const kv = await Deno.openKv() + try { + const room: Room = { + id: "testroom", + participants: [], + config: { allowSpectators: true, maxParticipants: 50 }, + createdAt: Date.now(), + updatedAt: Date.now(), + } + await createRoom(kv, room) + const loaded = await getRoom(kv, room.id) + if (!loaded) throw new Error("Room not found") + await updateRoom(kv, loaded) + const updated = await getRoom(kv, room.id) + if (!updated) throw new Error("Room update failed") + await deleteRoom(kv, room.id) + const deleted = await getRoom(kv, room.id) + if (deleted) throw new Error("Room delete failed") + } finally { + kv.close() + } +}) + +Deno.test("UserToken CRUD", async () => { + const kv = await Deno.openKv() + try { + const info: UserTokenInfo = { + token: "usertoken1", + currentRoomId: "testroom", + name: "テストユーザー", + isSpectator: false, + lastAccessedAt: Date.now(), + } + await createUserToken(kv, info) + const loaded = await getUserToken(kv, info.token) + if (!loaded || loaded.name !== "テストユーザー") { + throw new Error("UserToken get failed") + } + loaded.name = "変更後" + await updateUserToken(kv, loaded) + const updated = await getUserToken(kv, info.token) + if (!updated || updated.name !== "変更後") { + throw new Error("UserToken update failed") + } + await deleteUserToken(kv, info.token) + const deleted = await getUserToken(kv, info.token) + if (deleted) throw new Error("UserToken delete failed") + } finally { + kv.close() + } +}) diff --git a/backend/server.ts b/backend/server.ts index 99c9270..5b245ae 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -1,70 +1,75 @@ -import { serveDir } from 'https://deno.land/std@0.209.0/http/file_server.ts'; -import { addSocket } from './store/index.ts'; -import { genMsgConnected } from '@/wsMsg/msgFromServer.ts'; -import { closeHandler, socketMessageHandler } from './socketMessageHandler.ts'; -import { cleanupOldKvRecords } from './kvCleanupJob.ts'; +import { serveDir } from "jsr:@std/http/file-server" +import { handleKvDebugRequest } from "../debug_kv_view/kv_debug_handler.ts" +import { + handleCreateRoom, + handleJoinRoom, + handleLeaveRoom, + handleRejoinRoom, // 追加 +} from "./handlers/roomHandlers.ts" +import { handleWebSocket } from "./handlers/wsHandlers.ts" -// KVモードで実行されている場合のみcronジョブを登録 -if (Deno.env.get('USE_KV_STORE') === 'true') { - console.log('KVモードで実行中: cronジョブを登録します'); - // 2日に1回、古いKVレコードを削除するcronジョブをスケジュール - Deno.cron('clean up old records', '0 0 */2 * *', () => { - cleanupOldKvRecords(); - }); -} - -function handler(request: Request): Promise { - const { pathname } = new URL(request.url); - if (request.headers.get('upgrade') === 'websocket') { - const { socket, response } = Deno.upgradeWebSocket(request); - - const userToken = crypto.randomUUID(); - socket.onopen = async () => { - console.log(`CONNECTED: ${userToken}`); - await addSocket(userToken, socket); - socket.send(JSON.stringify(genMsgConnected(userToken))); - }; - - socket.onmessage = async event => { - if (event.data?.includes('ping')) { - socket.send('pong'); - } else { - await socketMessageHandler(event, socket); - } - }; +const kv = await Deno.openKv() - socket.onclose = async () => { - await closeHandler(userToken); - }; - socket.onerror = async error => { - console.error('ERROR:', error); - await closeHandler(userToken); - }; +async function handler(request: Request): Promise { + const { pathname } = new URL(request.url) - return Promise.resolve(response); + // KV Debug Viewer (Development Only) + if (Deno.env.get("APP_ENV") === "development") { + const debugResponse = await handleKvDebugRequest(request) + if (debugResponse) { + return debugResponse + } } + if (request.method === "POST" && pathname === "/api/rooms") { + return handleCreateRoom(request, kv) + } + if ( + request.method === "POST" && + pathname.match(/^\/api\/rooms\/(.+)\/join$/) + ) { + const roomId = pathname.match(/^\/api\/rooms\/(.+)\/join$/)?.[1] ?? "" + return handleJoinRoom(request, roomId, kv) + } + if ( + request.method === "POST" && + pathname.match(/^\/api\/rooms\/(.+)\/leave$/) + ) { + const roomId = pathname.match(/^\/api\/rooms\/(.+)\/leave$/)?.[1] ?? "" + return handleLeaveRoom(request, roomId, kv) + } + if ( + request.method === "POST" && + pathname.match(/^\/api\/rooms\/(.+)\/rejoin$/) + ) { + const roomId = pathname.match(/^\/api\/rooms\/(.+)\/rejoin$/)?.[1] ?? "" + return handleRejoinRoom(request, roomId, kv) + } + if (request.method === "GET" && pathname.match(/^\/ws\/rooms\/(.+)$/)) { + const roomId = pathname.match(/^\/ws\/rooms\/(.+)$/)?.[1] ?? "" + return handleWebSocket(request, roomId, kv) + } if ( - pathname === '/' || - pathname.startsWith('/assets') || - pathname.endsWith('.png') + pathname === "/" || + pathname.startsWith("/assets") || + pathname.endsWith(".png") ) { - return serveDir(request, { fsRoot: './dist/' }); + return serveDir(request, { fsRoot: "./dist/" }) } return Promise.resolve( - new Response('Not found', { + new Response("Not found", { status: 404, - statusText: 'Not found', + statusText: "Not found", headers: { - 'content-type': 'text/plain', + "content-type": "text/plain", }, - }) - ); + }), + ) } Deno.serve( { - port: Number(Deno.env.get('PORT')) || 8000, + port: Number(Deno.env.get("PORT")) || 8000, }, - handler -); + handler, +) diff --git a/backend/socketMessageHandler.ts b/backend/socketMessageHandler.ts deleted file mode 100644 index c34ecc6..0000000 --- a/backend/socketMessageHandler.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { isMsgFromClient } from "@/wsMsg/msgFromClient.ts" -import { - genMsgIsExistTheRoomResult, - genMsgRoomCreated, - genMsgRoomInfo, - MsgFromServer, -} from "@/wsMsg/msgFromServer.ts" -import { - answer, - clearAnswer, - closeEmptyRoom, - createRoom, - enterTheRoom, - exitRoom, - isExistTheRoom, -} from "./store/index.ts" -import { deleteSocket, getSocket, getStoreManager } from "./store/index.ts" -import { Room, RoomForClientSide, UserToken } from "./type.ts" - -export const socketMessageHandler = async ( - event: MessageEvent, - socket: WebSocket, -): Promise => { - console.log(`RECEIVED: ${event.data}`) - const data = JSON.parse(event.data) - if (isMsgFromClient(data)) { - switch (data.type) { - case "createRoom": { - if (!data.userName) break - const room = await createRoom(data.userToken, data.userName) - const msg = genMsgRoomCreated(room.id) - console.log("SEND:", msg) - socket.send(JSON.stringify(msg)) - broadcastRoomInfo(room) // awaitを削除 - break - } - case "isExistTheRoom": { - const exists = await isExistTheRoom(data.roomId) - const msg = genMsgIsExistTheRoomResult(exists) - console.log("SEND:", msg) - socket.send(JSON.stringify(msg)) - break - } - case "enterTheRoom": { - if (!data.userName) break - const room = await enterTheRoom(data) - if (room == undefined) break - broadcastRoomInfo(room) // awaitを削除 - break - } - case "answer": { - const room = await answer(data) - if (room == undefined) break - broadcastRoomInfo(room) // awaitを削除 - break - } - case "clearAnswer": { - const room = await clearAnswer(data.roomId) - if (room == undefined) break - broadcastRoomInfo(room) // awaitを削除 - break - } - default: - break - } - } -} - -export const broadcastRoomInfo = (room: Room) => { - const genRoomForClientSide = ( - room: Room, - userToken: UserToken, - ): RoomForClientSide => ({ - ...room, - participants: room.participants.map((p, i) => ({ - name: p.name, - answer: p.answer, - userNumber: i, - isMe: p.token === userToken, - })), - }) - - // StoreManagerからKVモードかどうか確認 - const storeManager = getStoreManager(); - const isKVMode = storeManager.isUsingKV(); - - console.log(`BROADCAST: ${room.id} (${isKVMode ? "KV mode" : "Memory mode"})`); - - // KVモードではWatch API経由で通知するためローカルインスタンスのユーザーにのみ直接通知 - // メモリモードでは全てのユーザーに直接通知 - if (isKVMode) { - // KVモードでは、ローカルインスタンスに接続しているユーザーにのみ直接通知 - for (const participant of room.participants) { - const socket = getSocket(participant.token) - if (socket == undefined) continue - const msg = genMsgRoomInfo(genRoomForClientSide(room, participant.token)) - console.log(`Direct notify to local user: ${participant.token.slice(0, 6)}...`); - socket.send(JSON.stringify(msg)) - } - } else { - // メモリモードでは全ユーザーに直接通知 - for (const participant of room.participants) { - const socket = getSocket(participant.token) - if (socket == undefined) continue - const msg = genMsgRoomInfo(genRoomForClientSide(room, participant.token)) - socket.send(JSON.stringify(msg)) - } - } -} - -export const closeHandler = async (userToken: UserToken) => { - console.log("DISCONNECTED:", userToken) - await deleteSocket(userToken) - const room = await exitRoom(userToken) - if (room?.participants?.length) { - broadcastRoomInfo(room) // awaitを削除 - } - await closeEmptyRoom() -} diff --git a/backend/store/index.ts b/backend/store/index.ts deleted file mode 100644 index b4f7533..0000000 --- a/backend/store/index.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Room, RoomId, UserToken } from "@/backend/type.ts"; -import { getStoreManager } from "./storeManager.ts"; - -// StoreManagerを再エクスポート -export { getStoreManager } from "./storeManager.ts"; - -/** - * 既存のAPIと互換性を持つラッパー関数群 - * これらの関数は内部でStoreManagerを使い、適切なストアの実装を呼び出します - */ - -// 部屋の作成 -export const createRoom = async (userToken: UserToken, userName: string): Promise => { - return await getStoreManager().getRoomStore().createRoom(userToken, userName); -}; - -// 部屋の存在確認 -export const isExistTheRoom = async (roomId: RoomId): Promise => { - return await getStoreManager().getRoomStore().isExistTheRoom(roomId); -}; - -// 部屋への入室 -export const enterTheRoom = async (params: { - roomId: RoomId; - userToken: UserToken; - userName: string; -}): Promise => { - return await getStoreManager().getRoomStore().enterTheRoom(params); -}; - -// 回答の設定 -export const answer = async (params: { - roomId: RoomId; - userToken: UserToken; - answer: string; -}): Promise => { - return await getStoreManager().getRoomStore().answer(params); -}; - -// 回答のクリア -export const clearAnswer = async (roomId: RoomId): Promise => { - return await getStoreManager().getRoomStore().clearAnswer(roomId); -}; - -// 部屋からの退出 -export const exitRoom = async (userToken: UserToken): Promise => { - return await getStoreManager().getRoomStore().exitRoom(userToken); -}; - -// 空の部屋を閉じる -export const closeEmptyRoom = async (): Promise => { - await getStoreManager().getRoomStore().closeEmptyRoom(); -}; - -// 部屋を閉じる(内部使用) -export const closeRoom = async (roomId: RoomId): Promise => { - await getStoreManager().getRoomStore().closeRoom(roomId); -}; - -// WebSocket取得 -export const getSocket = (userToken: UserToken): WebSocket | undefined => { - return getStoreManager().getSocketStore().getSocket(userToken); -}; - -// WebSocket追加 -export const addSocket = async (userToken: UserToken, socket: WebSocket): Promise => { - await getStoreManager().getSocketStore().addSocket(userToken, socket); -}; - -// WebSocket削除 -export const deleteSocket = async (userToken: UserToken): Promise => { - await getStoreManager().getSocketStore().deleteSocket(userToken); -}; - -// 現在使用しているストアの種類を取得 -export const isUsingKVStore = (): boolean => { - return getStoreManager().isUsingKV(); -}; \ No newline at end of file diff --git a/backend/store/interfaces.ts b/backend/store/interfaces.ts deleted file mode 100644 index 864bdd9..0000000 --- a/backend/store/interfaces.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Room, RoomId, UserToken } from "@/backend/type.ts"; - -// 部屋情報を管理するためのインターフェース -export interface RoomStore { - createRoom(userToken: UserToken, userName: string): Promise; - isExistTheRoom(roomId: RoomId): Promise; - enterTheRoom(params: { - roomId: RoomId; - userToken: UserToken; - userName: string; - }): Promise; - answer(params: { - roomId: RoomId; - userToken: UserToken; - answer: string; - }): Promise; - clearAnswer(roomId: RoomId): Promise; - exitRoom(userToken: UserToken): Promise; - closeEmptyRoom(): Promise; - closeRoom(roomId: RoomId): Promise; -} - -// WebSocket接続を管理するためのインターフェース -export interface SocketStore { - getSocket(userToken: UserToken): WebSocket | undefined; - addSocket(userToken: UserToken, socket: WebSocket): Promise; - deleteSocket(userToken: UserToken): Promise; - cleanupStaleSocketInstances(): Promise; -} \ No newline at end of file diff --git a/backend/store/kv/kvRoomStore.ts b/backend/store/kv/kvRoomStore.ts deleted file mode 100644 index 4bcce11..0000000 --- a/backend/store/kv/kvRoomStore.ts +++ /dev/null @@ -1,477 +0,0 @@ -import { Room, RoomId, RoomForClientSide, UserToken } from '@/backend/type.ts'; -import { RoomStore } from '../interfaces.ts'; -import { getStoreManager } from '../storeManager.ts'; -import { genMsgRoomInfo } from '@/wsMsg/msgFromServer.ts'; -import { KVSocketStore } from './kvSocketStore.ts'; - -/** - * KVベースのRoomStore実装 - */ -export class KVRoomStore implements RoomStore { - private kv: Deno.Kv | null = null; - private cleanupIntervalId: number | undefined; - // ルームごとのWatcherを管理 - private roomWatchers: Map[]>; - active: boolean; - }> = new Map(); - - constructor() { - // 5分に1回、30分間更新のないルームを閉じる - const thresholdMs = 30 * 1000 * 60; - this.cleanupIntervalId = setInterval(async () => { - console.log('polling kv store...'); - await this.cleanupIdleRooms(thresholdMs); - }, 5 * 1000 * 60); - } - - // KVインスタンスの遅延初期化 - private async ensureKV(): Promise { - if (!this.kv) { - this.kv = await Deno.openKv(); - } - } - - // 特定のルームに対するWatchを設定 - private async watchRoom(roomId: RoomId): Promise { - // 既に監視中なら何もしない - if (this.roomWatchers.get(roomId)?.active) { - console.log(`Room ${roomId.slice(0, 6)}... is already being watched`); - return; - } - - await this.ensureKV(); - - try { - console.log(`Setting up watcher for room ${roomId.slice(0, 6)}...`); - - // このルームに特化したwatchを設定 - const stream = this.kv!.watch<[Room]>([["rooms", roomId]]); - - // 監視情報を保存 - this.roomWatchers.set(roomId, { stream, active: true }); - - // 非同期でwatcherを処理 - (async () => { - try { - console.log(`KV watcher for room ${roomId.slice(0, 6)}... started`); - - for await (const entries of stream) { - // アクティブでなくなったらループを抜ける - if (!this.roomWatchers.get(roomId)?.active) { - console.log(`KV watcher for room ${roomId.slice(0, 6)}... stopped`); - break; - } - - // エントリが空の場合はスキップ - if (!entries) { - continue; - } - - console.log(`Detected changes for room ${roomId.slice(0, 6)}...`); - - for (const entry of entries) { - try { - if (!entry.value) continue; - - const room = entry.value as Room; - await this.notifyLocalUsersAboutRoomChange(room); - } catch (entryError) { - console.error(`Error processing room ${roomId.slice(0, 6)}... update:`, entryError); - } - } - } - - console.log(`KV watcher loop for room ${roomId.slice(0, 6)}... ended normally`); - // ループが正常に終了した場合も監視状態を更新 - this.roomWatchers.delete(roomId); - - } catch (error: unknown) { - // エラー処理 - console.error(`KV watcher error for room ${roomId.slice(0, 6)}...:`, error); - - // ウォッチャーを削除 - this.roomWatchers.delete(roomId); - - // 3秒後に再接続を試みる - setTimeout(() => { - console.log(`Attempting to restart watcher for room ${roomId.slice(0, 6)}...`); - this.watchRoom(roomId).catch(e => - console.error(`Failed to restart watcher for room ${roomId.slice(0, 6)}...:`, e) - ); - }, 3000); - } - })(); - - } catch (error) { - console.error(`Failed to setup watcher for room ${roomId.slice(0, 6)}...:`, error); - // エラー発生時はウォッチャーをセットしない - this.roomWatchers.delete(roomId); - } - } - - // 特定のルームのWatchを停止する - private async stopWatchingRoom(roomId: RoomId): Promise { - const watcher = this.roomWatchers.get(roomId); - if (watcher) { - console.log(`Stopping watcher for room ${roomId.slice(0, 6)}...`); - - try { - // ストリームを直接キャンセルするのではなく、フラグをfalseにして - // for-awaitループが自然に終了するのを待つ - watcher.active = false; - - // Mapからすぐに削除する(新しいウォッチャー作成を許可するため) - this.roomWatchers.delete(roomId); - - console.log(`Watcher for room ${roomId.slice(0, 6)}... marked inactive`); - } catch (error) { - console.error(`Error stopping watcher for room ${roomId.slice(0, 6)}...:`, error); - // エラーが発生しても確実にMapから削除 - this.roomWatchers.delete(roomId); - } - } - } - - // 参加したルームを必要に応じてWatchする - private async checkAndWatchRoom(roomId: RoomId): Promise { - // 既に監視中なら何もしない - if (this.roomWatchers.get(roomId)?.active) { - console.log(`Room ${roomId.slice(0, 6)}... is already being watched`); - return; - } - - // 監視開始 - await this.watchRoom(roomId); - } - - // このインスタンスに関連するユーザーにのみルーム変更を通知 - private async notifyLocalUsersAboutRoomChange(room: Room): Promise { - const socketStore = getStoreManager().getSocketStore() as KVSocketStore; - const instanceId = socketStore.getInstanceId(); - - console.log(`Checking room ${room.id.slice(0, 6)}... participants for instance ${instanceId.slice(0, 6)}...`); - - for (const participant of room.participants) { - try { - // このユーザーが現在のインスタンスに関連しているか確認 - const userInstanceId = await socketStore.getSocketInstance( - participant.token - ); - - if (!userInstanceId) { - console.log(`User ${participant.token.slice(0, 6)}... has no associated instance`); - continue; - } - - // ローカルインスタンスのユーザーかどうか確認 - if (userInstanceId === instanceId) { - const socket = socketStore.getSocket(participant.token); - if (socket && socket.readyState === WebSocket.OPEN) { - // このインスタンスのユーザーにメッセージを送信 - const roomForClient: RoomForClientSide = { - ...room, - participants: room.participants.map((p, i) => ({ - name: p.name, - answer: p.answer, - userNumber: i, - isMe: p.token === participant.token, - })), - }; - - const msg = genMsgRoomInfo(roomForClient); - socket.send(JSON.stringify(msg)); - console.log( - `Notified local user ${participant.token.slice(0, 6)}... about room ${room.id.slice(0, 6)}... change via watch` - ); - } else { - console.log( - `User ${participant.token.slice(0, 6)}... has no active socket or socket is not open` - ); - - // ソケットがない場合、KVストアから該当ユーザーの情報をクリーンアップ - if (!socket) { - console.log(`Cleaning up stale socket instance reference for ${participant.token.slice(0, 6)}...`); - await socketStore.deleteSocket(participant.token); - } - } - } else { - console.log( - `User ${participant.token.slice(0, 6)}... belongs to instance ${userInstanceId.slice(0, 6)}..., not this instance` - ); - } - } catch (error) { - console.error( - `Error notifying user ${participant.token.slice(0, 6)}...`, - error - ); - } - } - } - - // アイドル状態のルームをクリーンアップするプライベートメソッド - private async cleanupIdleRooms(thresholdMs: number): Promise { - await this.ensureKV(); - const now = Date.now(); - const entries = this.kv!.list({ prefix: ['room_updates'] }); - const idleRoomIds: RoomId[] = []; - - for await (const entry of entries) { - const [, roomId] = entry.key as [string, RoomId]; - if (now - entry.value.getTime() > thresholdMs) { - idleRoomIds.push(roomId); - } - } - - for (const roomId of idleRoomIds) { - console.log('room closed due to inactivity:', roomId); - await this.closeRoom(roomId); - } - } - - async createRoom(userToken: UserToken, userName: string): Promise { - await this.ensureKV(); - - const roomId = crypto.randomUUID(); - const room: Room = { - id: roomId, - participants: [ - { - token: userToken, - name: userName, - answer: '', - }, - ], - isOpen: false, - }; - - // トランザクションで原子的に更新 - const atomicOp = this.kv!.atomic(); - atomicOp.set(['rooms', roomId], room); - atomicOp.set(['user_rooms', userToken], roomId); - atomicOp.set(['room_updates', roomId], new Date()); - - const result = await atomicOp.commit(); - if (!result.ok) { - throw new Error('Failed to create room in KV store'); - } - - console.log('room created in KV:', roomId); - - // ルーム作成後にWatchを開始 - await this.watchRoom(roomId); - - return room; - } - - async isExistTheRoom(roomId: RoomId): Promise { - await this.ensureKV(); - const result = await this.kv!.get(['rooms', roomId]); - return result.value !== null; - } - - async enterTheRoom({ - roomId, - userToken, - userName, - }: { - roomId: RoomId; - userToken: UserToken; - userName: string; - }): Promise { - await this.ensureKV(); - const roomResult = await this.kv!.get(['rooms', roomId]); - const room = roomResult.value; - if (room == undefined) return; - - room.participants.push({ - token: userToken, - name: userName, - answer: '', - }); - this.sortParticipants(room); - - // トランザクションで原子的に更新 - const atomicOp = this.kv!.atomic(); - atomicOp.set(['rooms', roomId], room); - atomicOp.set(['user_rooms', userToken], roomId); - atomicOp.set(['room_updates', roomId], new Date()); - - const result = await atomicOp.commit(); - if (!result.ok) { - throw new Error('Failed to enter room in KV store'); - } - - // ルーム参加時にルームがまだWatchされていない場合はWatchを開始 - await this.checkAndWatchRoom(roomId); - - return room; - } - - async answer({ - roomId, - userToken, - answer, - }: { - roomId: RoomId; - userToken: UserToken; - answer: string; - }): Promise { - await this.ensureKV(); - const roomResult = await this.kv!.get(['rooms', roomId]); - const room = roomResult.value; - if (room == undefined) return; - - const user = room.participants.find(u => u.token === userToken); - if (user == undefined) return; - user.answer = answer; - if (room.participants.every(u => u.answer !== '')) { - room.isOpen = true; - } - - // KVに更新を保存 - const atomicOp = this.kv!.atomic(); - atomicOp.set(['rooms', roomId], room); - atomicOp.set(['room_updates', roomId], new Date()); - - const result = await atomicOp.commit(); - if (!result.ok) { - throw new Error('Failed to update answer in KV store'); - } - - return room; - } - - async clearAnswer(roomId: RoomId): Promise { - await this.ensureKV(); - const roomResult = await this.kv!.get(['rooms', roomId]); - const room = roomResult.value; - if (room == undefined) return; - - for (const p of room.participants) { - p.answer = ''; - } - room.isOpen = false; - - // KVに更新を保存 - const atomicOp = this.kv!.atomic(); - atomicOp.set(['rooms', roomId], room); - atomicOp.set(['room_updates', roomId], new Date()); - - const result = await atomicOp.commit(); - if (!result.ok) { - throw new Error('Failed to clear answers in KV store'); - } - - return room; - } - - async exitRoom(userToken: UserToken): Promise { - await this.ensureKV(); - const roomIdResult = await this.kv!.get(['user_rooms', userToken]); - const roomId = roomIdResult.value; - if (roomId == undefined) return; - - const roomResult = await this.kv!.get(['rooms', roomId]); - const room = roomResult.value; - if (room == undefined) return; - - console.log('exit room:', roomId, 'user:', userToken); - room.participants = room.participants.filter(v => v.token !== userToken); - this.sortParticipants(room); - - if (room.participants.every(u => u.answer !== '')) { - room.isOpen = true; - } - - // トランザクションで原子的に更新 - const atomicOp = this.kv!.atomic(); - atomicOp.set(['rooms', roomId], room); - atomicOp.set(['room_updates', roomId], new Date()); - atomicOp.delete(['user_rooms', userToken]); - - const result = await atomicOp.commit(); - if (!result.ok) { - throw new Error('Failed to exit room in KV store'); - } - - // 参加者が0人になった場合は、Watchを解除する - if (room.participants.length === 0) { - await this.stopWatchingRoom(roomId); - } - - return room; - } - - async closeEmptyRoom(): Promise { - await this.ensureKV(); - const entries = this.kv!.list({ prefix: ['rooms'] }); - const emptyRoomIds: RoomId[] = []; - - for await (const entry of entries) { - const [, roomId] = entry.key as [string, RoomId]; - const room = entry.value; - if (room.participants.length === 0) { - emptyRoomIds.push(roomId); - } - } - - // 空の部屋を削除 - for (const roomId of emptyRoomIds) { - const atomicOp = this.kv!.atomic(); - atomicOp.delete(['rooms', roomId]); - atomicOp.delete(['room_updates', roomId]); - - const result = await atomicOp.commit(); - if (result.ok) { - console.log('empty room closed:', roomId); - // Watchを停止 - await this.stopWatchingRoom(roomId); - } - } - } - - async closeRoom(roomId: RoomId): Promise { - await this.ensureKV(); - const roomResult = await this.kv!.get(['rooms', roomId]); - const room = roomResult.value; - if (room == undefined) return; - - console.log('room closed:', roomId); - - // トランザクションの準備 - const atomicOp = this.kv!.atomic(); - - // 残っているユーザーを全員切断 - for (const p of room.participants) { - const socketStore = getStoreManager().getSocketStore(); - const socket = socketStore.getSocket(p.token); - if (socket != undefined) { - socket.close(); - await socketStore.deleteSocket(p.token); - } - // ユーザーと部屋の関連を削除 - atomicOp.delete(['user_rooms', p.token]); - } - - // 部屋自体を削除 - atomicOp.delete(['rooms', roomId]); - atomicOp.delete(['room_updates', roomId]); - - await atomicOp.commit(); - - // ルームが削除されるためWatchも停止 - await this.stopWatchingRoom(roomId); - - // ルームクローズ時に不要なsocket_instancesエントリもクリーンアップ - await getStoreManager().getSocketStore().cleanupStaleSocketInstances(); - } - - private sortParticipants(room: Room): void { - room.participants.sort((a, b) => { - if (a.token > b.token) return 1; - if (a.token < b.token) return -1; - return 0; - }); - } -} diff --git a/backend/store/kv/kvSocketStore.ts b/backend/store/kv/kvSocketStore.ts deleted file mode 100644 index d3ae59a..0000000 --- a/backend/store/kv/kvSocketStore.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { UserToken } from "@/backend/type.ts"; -import { SocketStore } from "../interfaces.ts"; - -/** - * KVベースのSocketStore実装 - * 注: WebSocketオブジェクトは直接KVに保存できないため、 - * インメモリキャッシュとKVを組み合わせて使用します - */ -export class KVSocketStore implements SocketStore { - // WebSocketはシリアライズできないため、メモリに保持する必要がある - private usersSockets: Map = new Map(); - private kv: Deno.Kv | null = null; - private instanceId: string; - private cleanupIntervalId: number | undefined; - - constructor() { - // このインスタンス固有のID - this.instanceId = crypto.randomUUID(); - - // 10分に1回、不要なsocket_instancesエントリをクリーンアップ - this.cleanupIntervalId = setInterval(async () => { - console.log('Cleaning up stale socket_instances in KV store...'); - await this.cleanupStaleSocketInstances(); - }, 10 * 60 * 1000); - } - - // KVインスタンスの遅延初期化 - private async ensureKV(): Promise { - if (!this.kv) { - this.kv = await Deno.openKv(); - } - } - - getSocket(userToken: UserToken): WebSocket | undefined { - return this.usersSockets.get(userToken); - } - - async addSocket(userToken: UserToken, socket: WebSocket): Promise { - this.usersSockets.set(userToken, socket); - - // KVにインスタンスIDを記録 - await this.ensureKV(); - await this.kv!.set(["socket_instances", userToken], this.instanceId); - } - - async deleteSocket(userToken: UserToken): Promise { - if (this.usersSockets.delete(userToken)) { - console.log("deleted socket: ", userToken); - // KVからインスタンスIDを削除 - await this.ensureKV(); - await this.kv!.delete(["socket_instances", userToken]); - } - } - - // このインスタンスIDを取得 - getInstanceId(): string { - return this.instanceId; - } - - // 指定されたユーザートークンがどのインスタンスに関連付けられているかを確認 - async getSocketInstance(userToken: UserToken): Promise { - await this.ensureKV(); - const result = await this.kv!.get(["socket_instances", userToken]); - return result.value; - } - - // staleなsocket_instancesをクリーンアップするメソッド - async cleanupStaleSocketInstances(): Promise { - try { - await this.ensureKV(); - const entries = this.kv!.list({ prefix: ["socket_instances"] }); - const batchSize = 10; - let batch: Deno.KvKey[] = []; - let deleteCount = 0; - - // 自分のインスタンスIDと一致するエントリを確認 - for await (const entry of entries) { - const userToken = entry.key[1] as UserToken; - const instanceId = entry.value as string; - - // このインスタンスに属するエントリだけをチェック - if (instanceId === this.instanceId) { - // ローカルキャッシュにWebSocketが存在するか確認 - const hasSocket = this.usersSockets.has(userToken); - - // WebSocketが存在しない場合は削除対象 - if (!hasSocket) { - batch.push(entry.key); - - // バッチが一定サイズになったら削除を実行 - if (batch.length >= batchSize) { - await this.deleteSocketInstancesBatch(batch); - deleteCount += batch.length; - batch = []; - } - } - } - } - - // 残りのバッチを削除 - if (batch.length > 0) { - await this.deleteSocketInstancesBatch(batch); - deleteCount += batch.length; - } - - if (deleteCount > 0) { - console.log(`Cleaned up ${deleteCount} stale socket_instances entries`); - } else { - console.log('No stale socket_instances entries found'); - } - } catch (error) { - console.error('Error cleaning up socket_instances:', error); - } - } - - // バッチ削除ヘルパーメソッド - private async deleteSocketInstancesBatch(keys: Deno.KvKey[]): Promise { - if (keys.length === 0) return; - - try { - await this.ensureKV(); - const atomicOp = this.kv!.atomic(); - - for (const key of keys) { - atomicOp.delete(key); - } - - await atomicOp.commit(); - } catch (error) { - console.error('Error deleting socket_instances batch:', error); - } - } -} \ No newline at end of file diff --git a/backend/store/memory/memoryRoomStore.ts b/backend/store/memory/memoryRoomStore.ts deleted file mode 100644 index 8d56a57..0000000 --- a/backend/store/memory/memoryRoomStore.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { Room, RoomId, UserToken } from "@/backend/type.ts"; -import { RoomStore } from "../interfaces.ts"; -import { getStoreManager } from "../storeManager.ts"; - -/** - * メモリベースのRoomStore実装 - */ -export class MemoryRoomStore implements RoomStore { - private rooms: Map = new Map(); - private roomsUserAt: Map = new Map(); - private latestUpdateOfRoom: Map = new Map(); - - constructor() { - // 5分に1回、30分間更新のないルームを閉じる - const thresholdMs = 30 * 1000 * 60; - setInterval(() => { - console.log("polling memory store..."); - const now = Date.now(); - const idleRoomIds = [...this.latestUpdateOfRoom] - .filter(([, date]) => now - date.getTime() > thresholdMs) - .map(([roomId]) => roomId); - for (const roomId of idleRoomIds) { - console.log("room closed:", roomId); - this.closeRoom(roomId); - } - }, 5 * 1000 * 60); - } - - createRoom(userToken: UserToken, userName: string): Promise { - const roomId = crypto.randomUUID(); - const room: Room = { - id: roomId, - participants: [ - { - token: userToken, - name: userName, - answer: "", - }, - ], - isOpen: false, - }; - this.rooms.set(roomId, room); - this.roomsUserAt.set(userToken, roomId); - this.latestUpdateOfRoom.set(roomId, new Date()); - console.log("room created:", roomId); - return Promise.resolve(room); - } - - isExistTheRoom(roomId: RoomId): Promise { - return Promise.resolve(this.rooms.has(roomId)); - } - - enterTheRoom({ - roomId, - userToken, - userName, - }: { - roomId: RoomId; - userToken: UserToken; - userName: string; - }): Promise { - const room = this.rooms.get(roomId); - if (room == undefined) return Promise.resolve(undefined); - room.participants.push({ - token: userToken, - name: userName, - answer: "", - }); - this.sortParticipants(room); - this.latestUpdateOfRoom.set(roomId, new Date()); - this.roomsUserAt.set(userToken, roomId); - - return Promise.resolve(room); - } - - answer({ - roomId, - userToken, - answer, - }: { - roomId: RoomId; - userToken: UserToken; - answer: string; - }): Promise { - const room = this.rooms.get(roomId); - if (room == undefined) return Promise.resolve(undefined); - const user = room.participants.find((u) => u.token === userToken); - if (user == undefined) return Promise.resolve(undefined); - user.answer = answer; - if (room.participants.every((u) => u.answer !== "")) { - room.isOpen = true; - } - this.latestUpdateOfRoom.set(roomId, new Date()); - return Promise.resolve(room); - } - - clearAnswer(roomId: RoomId): Promise { - const room = this.rooms.get(roomId); - if (room == undefined) return Promise.resolve(undefined); - for (const p of room.participants) { - p.answer = ""; - } - room.isOpen = false; - this.latestUpdateOfRoom.set(roomId, new Date()); - return Promise.resolve(room); - } - - exitRoom(userToken: UserToken): Promise { - const roomId = this.roomsUserAt.get(userToken); - if (roomId == undefined) return Promise.resolve(undefined); - const room = this.rooms.get(roomId); - if (room == undefined) return Promise.resolve(undefined); - console.log("exit room:", roomId, "user:", userToken); - room.participants = room.participants.filter((v) => v.token !== userToken); - this.sortParticipants(room); - this.roomsUserAt.delete(userToken); - this.latestUpdateOfRoom.set(roomId, new Date()); - if (room.participants.every((u) => u.answer !== "")) { - room.isOpen = true; - } - return Promise.resolve(room); - } - - closeEmptyRoom(): Promise { - for (const [id, room] of this.rooms) { - if (room.participants.length === 0) { - this.rooms.delete(id); - this.latestUpdateOfRoom.delete(id); - console.log("room closed:", id); - } - } - return Promise.resolve(); - } - - async closeRoom(roomId: RoomId): Promise { - const room = this.rooms.get(roomId); - if (room == undefined) return; - console.log("room closed:", roomId); - - // 残っているユーザーを全員切断 - for (const p of room.participants) { - const socketStore = getStoreManager().getSocketStore(); - const socket = socketStore.getSocket(p.token); - if (socket == undefined) continue; - socket.close(); - await socketStore.deleteSocket(p.token); - this.roomsUserAt.delete(p.token); - } - - this.latestUpdateOfRoom.delete(roomId); - this.rooms.delete(roomId); - } - - private sortParticipants(room: Room): void { - room.participants.sort((a, b) => { - if (a.token > b.token) return 1; - if (a.token < b.token) return -1; - return 0; - }); - } -} \ No newline at end of file diff --git a/backend/store/memory/memorySocketStore.ts b/backend/store/memory/memorySocketStore.ts deleted file mode 100644 index 3bc9d21..0000000 --- a/backend/store/memory/memorySocketStore.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { UserToken } from "@/backend/type.ts"; -import { SocketStore } from "../interfaces.ts"; - -/** - * メモリベースのSocketStore実装 - */ -export class MemorySocketStore implements SocketStore { - private usersSockets: Map = new Map(); - - getSocket(userToken: UserToken): WebSocket | undefined { - return this.usersSockets.get(userToken); - } - - async addSocket(userToken: UserToken, socket: WebSocket): Promise { - this.usersSockets.set(userToken, socket); - } - - async deleteSocket(userToken: UserToken): Promise { - if (this.usersSockets.delete(userToken)) { - console.log("deleted socket: ", userToken); - } - } - - // メモリモードでは特に追加のクリーンアップは不要だが、インターフェース互換性のために実装 - async cleanupStaleSocketInstances(): Promise { - // メモリモードではsocket_instancesを使用しないため何もしない - return Promise.resolve(); - } -} \ No newline at end of file diff --git a/backend/store/rooms.ts b/backend/store/rooms.ts deleted file mode 100644 index df42205..0000000 --- a/backend/store/rooms.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { Room, RoomId, UserToken } from "@/backend/type.ts" -import { deleteSocket, getSocket } from "./sockets.ts" - -const rooms: Map = new Map() - -const roomsUserAt: Map = new Map() -const latestUpdateOfRoom: Map = new Map() - -// 5分に1回、30分間更新のないルームを閉じる -const thresholdMs = 30 * 1000 * 60 -setInterval(() => { - console.log("polling...") - const now = Date.now() - const idleRoomIds = [...latestUpdateOfRoom] - .filter(([, date]) => now - date.getTime() > thresholdMs) - .map(([roomId]) => roomId) - for (const roomId of idleRoomIds) { - console.log("room closed:", roomId) - closeRoom(roomId) - } -}, 5 * 1000 * 60) - -export const createRoom = (userToken: UserToken, userName: string): Room => { - const roomId = crypto.randomUUID() - const room: Room = { - id: roomId, - participants: [ - { - token: userToken, - name: userName, - answer: "", - }, - ], - isOpen: false, - } - rooms.set(roomId, room) - roomsUserAt.set(userToken, roomId) - console.log("room created:", roomId) - latestUpdateOfRoom.set(roomId, new Date()) - return room -} - -export const isExistTheRoom = (roomId: RoomId) => { - return rooms.has(roomId) -} -export const enterTheRoom = ({ - roomId, - userName, - userToken, -}: { - roomId: RoomId - userToken: UserToken - userName: string -}): Room | void => { - const room = rooms.get(roomId) - if (room == undefined) return - room.participants.push({ - token: userToken, - name: userName, - answer: "", - }) - sortParticipants(room) - latestUpdateOfRoom.set(roomId, new Date()) - roomsUserAt.set(userToken, roomId) - - return room -} - -export const answer = ({ - roomId, - userToken, - answer, -}: { - roomId: RoomId - userToken: UserToken - answer: string -}): Room | void => { - const room = rooms.get(roomId) - if (room == undefined) return - const user = room.participants.find((u) => u.token === userToken) - if (user == undefined) return - user.answer = answer - if (room.participants.every((u) => u.answer !== "")) { - room.isOpen = true - } - latestUpdateOfRoom.set(roomId, new Date()) - return room -} -export const clearAnswer = (roomId: RoomId): Room | void => { - const room = rooms.get(roomId) - if (room == undefined) return - for (const p of room.participants) { - p.answer = "" - } - room.isOpen = false - latestUpdateOfRoom.set(roomId, new Date()) - return room -} - -export const exitRoom = (userToken: UserToken): Room | void => { - const roomId = roomsUserAt.get(userToken) - if (roomId == undefined) return - const room = rooms.get(roomId) - if (room == undefined) return - console.log("exit room:", roomId, "user:", userToken) - room.participants = room.participants.filter((v) => v.token !== userToken) - sortParticipants(room) - roomsUserAt.delete(userToken) - latestUpdateOfRoom.set(roomId, new Date()) - if (room.participants.every((u) => u.answer !== "")) { - room.isOpen = true - } - return room -} -export const closeEmptyRoom = () => { - for (const [id, room] of rooms) { - if (room.participants.length === 0) { - rooms.delete(id) - latestUpdateOfRoom.delete(id) - console.log("room closed:", id) - } - } -} -const closeRoom = (roomId: RoomId) => { - const room = rooms.get(roomId) - if (room == undefined) return - console.log("room closed:", roomId) - // 残っているユーザーを全員切断 - for (const p of room.participants) { - const socket = getSocket(p.token) - if (socket == undefined) continue - socket.close() - deleteSocket(p.token) - roomsUserAt.delete(p.token) - } - latestUpdateOfRoom.delete(roomId) - rooms.delete(roomId) -} - -const sortParticipants = (room: Room) => { - room.participants.sort((a, b) => { - if (a.token > b.token) return 1 - if (a.token < b.token) return -1 - return 0 - }) -} diff --git a/backend/store/sockets.ts b/backend/store/sockets.ts deleted file mode 100644 index 3e67f43..0000000 --- a/backend/store/sockets.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { UsersSockets, UserToken } from "@/backend/type.ts" - -const usersSockets: UsersSockets = new Map() -export const getSocket = (userToken: UserToken) => usersSockets.get(userToken) -export const addSocket = (userToken: UserToken, socket: WebSocket) => { - usersSockets.set(userToken, socket) -} -export const deleteSocket = (userToken: UserToken) => { - if (usersSockets.delete(userToken)) console.log("deleted: ", userToken) -} diff --git a/backend/store/storeManager.ts b/backend/store/storeManager.ts deleted file mode 100644 index 9def628..0000000 --- a/backend/store/storeManager.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { RoomStore, SocketStore } from "./interfaces.ts"; -import { MemoryRoomStore } from "./memory/memoryRoomStore.ts"; -import { MemorySocketStore } from "./memory/memorySocketStore.ts"; -import { KVRoomStore } from "./kv/kvRoomStore.ts"; -import { KVSocketStore } from "./kv/kvSocketStore.ts"; - -/** - * ストアの実装を切り替えるためのマネージャークラス - */ -export class StoreManager { - private roomStore: RoomStore; - private socketStore: SocketStore; - private useKV: boolean; - - constructor() { - // 環境変数で実装を切り替え - this.useKV = Deno.env.get("USE_KV_STORE") === "true"; - console.log(`StoreManager: Using ${this.useKV ? "KV" : "Memory"} store`); - - // 選択された実装のインスタンスを作成 - if (this.useKV) { - this.roomStore = new KVRoomStore(); - this.socketStore = new KVSocketStore(); - } else { - this.roomStore = new MemoryRoomStore(); - this.socketStore = new MemorySocketStore(); - } - } - - /** - * RoomStoreを取得 - */ - getRoomStore(): RoomStore { - return this.roomStore; - } - - /** - * SocketStoreを取得 - */ - getSocketStore(): SocketStore { - return this.socketStore; - } - - /** - * KVを使用しているかどうか - */ - isUsingKV(): boolean { - return this.useKV; - } -} - -// シングルトンインスタンス -let storeManager: StoreManager | null = null; - -/** - * StoreManagerのシングルトンインスタンスを取得 - */ -export function getStoreManager(): StoreManager { - if (!storeManager) { - storeManager = new StoreManager(); - } - return storeManager; -} \ No newline at end of file diff --git a/backend/type.ts b/backend/type.ts index 8761a25..9a6138c 100644 --- a/backend/type.ts +++ b/backend/type.ts @@ -1,26 +1,83 @@ export type UserToken = string -export type User = { + +export type UserTokenInfo = { token: UserToken + currentRoomId: string | null name: string - answer: string + isSpectator: boolean + lastAccessedAt: number // UNIXタイムスタンプ(ms) } -export type UsersSockets = Map export type RoomId = string export type Room = { - id: string - participants: User[] - isOpen: boolean + id: RoomId + participants: Array<{ + token: UserToken + name: string + answer: string + isAudience: boolean + }> + config: { + allowSpectators: boolean + maxParticipants: number + } + createdAt: number // UNIXタイムスタンプ(ms) + updatedAt: number // UNIXタイムスタンプ(ms) } -export type UserForClientSide = { - name: string - answer: string - userNumber: number - isMe: boolean +// クライアント用のルーム情報型(UserTokenを含まない) +export type RoomForClient = { + id: RoomId + participants: Array<{ + name: string + userNumber: number + isMe: boolean + answer: string + isAudience: boolean + // 必要に応じて他の公開情報を追加 + }> + config: { + allowSpectators: boolean + maxParticipants: number + } + createdAt: number + updatedAt: number +} + +// APIリクエスト・レスポンス型 +export type CreateRoomRequest = { + userName: string // 設計書KVS設計とフローに基づき必須 +} +export type CreateRoomResponse = { + roomId: string + userToken: string + userNumber: number // 追加: 自分のuserNumber + room: RoomForClient +} + +export type JoinRoomRequest = { + userName: string + userToken?: string } -export type RoomForClientSide = { - id: string - participants: UserForClientSide[] - isOpen: boolean +export type JoinRoomResponse = { + userToken: string + userNumber: number // 追加: 自分のuserNumber + room: RoomForClient +} + +export type LeaveRoomRequest = { + userToken: string +} +export type LeaveRoomResponse = { + message: string +} + +export type RejoinRoomRequest = { + userToken: string +} +export type RejoinRoomResponse = { + userToken: string + userNumber: number + room: RoomForClient + userName: string } diff --git a/backend/validate.ts b/backend/validate.ts new file mode 100644 index 0000000..50caa3a --- /dev/null +++ b/backend/validate.ts @@ -0,0 +1,28 @@ +import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts" + +export const sanitize = (str: string) => str.replace(/[<>"'&]/g, "") + +export const CreateRoomRequestSchema = z.object({ + userName: z.string().min(1).max(24).transform(sanitize), +}) + +export const JoinRoomRequestSchema = z.object({ + userName: z.string().min(1).max(24).transform(sanitize), + userToken: z.string().optional(), +}) + +export const LeaveRoomRequestSchema = z.object({ + userToken: z.string().min(1), +}) + +export const UserNameSchema = z.string().min(1).max(24).transform(sanitize) +export const AnswerSchema = z.string().max(256).transform(sanitize) + +// バリデーション用スキーマをまとめてエクスポート +export const Schemas = { + CreateRoomRequestSchema, + JoinRoomRequestSchema, + LeaveRoomRequestSchema, + UserNameSchema, + AnswerSchema, +} diff --git a/debug_kv_view/kv_debug_handler.ts b/debug_kv_view/kv_debug_handler.ts new file mode 100644 index 0000000..6196c40 --- /dev/null +++ b/debug_kv_view/kv_debug_handler.ts @@ -0,0 +1,237 @@ +import { serveDir } from "https://deno.land/std@0.224.0/http/file_server.ts" +import { dirname, fromFileUrl, join } from "https://deno.land/std@0.224.0/path/mod.ts" + +// publicディレクトリの絶対パスを取得 +const __dirname = dirname(fromFileUrl(import.meta.url)) +const publicDir = join(__dirname, "public") + +let kv: Deno.Kv | undefined +const sockets = new Set() +let watcherInitialized = false + +async function initializeKv() { + if (!kv) { + console.log("Opening Deno KV store for debug viewer...") + kv = await Deno.openKv() + console.log("Deno KV store opened for debug viewer.") + } + return kv +} + +async function getAllKvData() { + if (!kv) await initializeKv() + const entries = [] + try { + for await (const entry of kv!.list({ prefix: [] })) { + entries.push({ + key: entry.key.join(" : "), + value: JSON.stringify(entry.value, null, 2), + versionstamp: entry.versionstamp, + }) + } + } catch (e) { + console.error("Error listing KV entries for debug view:", e) + throw e // エラーを呼び出し元に伝える + } + return entries +} + +async function initializeWatcher() { + if (watcherInitialized) return + await initializeKv() + watcherInitialized = true + + console.log("Starting polling-based KV watcher for debug view...") + let lastDataJson = "" + setInterval(async () => { + try { + const currentData = await getAllKvData() + const currentDataJson = JSON.stringify(currentData) + if (currentDataJson !== lastDataJson) { + console.log("KV store changed (polling debug view)") + lastDataJson = currentDataJson + for (const socket of sockets) { + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: "update", data: currentData })) + } + } + } + } catch (err) { + console.error("Polling KV watcher error (debug view):", err) + } + }, 1000) // 1秒ごとに監視 +} + +export async function handleKvDebugRequest( + req: Request, +): Promise { + if (Deno.env.get("APP_ENV") !== "development") { + return undefined // 開発モードでない場合は何もしない + } + + await initializeKv() // KVストアの初期化を試みる + if (!watcherInitialized) { + await initializeWatcher() // ウォッチャーの初期化を試みる + } + + const url = new URL(req.url) + const pathname = url.pathname + + // KVデバッグビューのWebSocketエンドポイント + if (pathname === "/ws/kv-debug") { + if (req.headers.get("upgrade") !== "websocket") { + return new Response("request isn't trying to upgrade to websocket.", { + status: 400, + }) + } + const { socket, response } = Deno.upgradeWebSocket(req) + sockets.add(socket) + socket.onopen = async () => { + try { + const currentData = await getAllKvData() + socket.send(JSON.stringify({ type: "initial", data: currentData })) + } catch (e) { + console.error("Error sending initial KV data to debug client:", e) + socket.send( + JSON.stringify({ + type: "error", + message: "Could not fetch initial KV data.", + }), + ) + } + } + socket.onclose = () => { + sockets.delete(socket) + } + socket.onerror = (e) => { + console.error("KV debug WebSocket error:", e) + } + return response + } + + // KVデバッグビューの静的ファイル (index.html, app.js など) + if (pathname.startsWith("/debug-kv-view")) { + // /debug-kv-view/foo.js -> /foo.js に変換して publicDir から探す + const relativePath = pathname.substring("/debug-kv-view".length) || "/index.html" // ルートならindex.html + try { + return await serveDir(req, { + fsRoot: publicDir, + urlRoot: "debug-kv-view", // これにより /debug-kv-view が publicDir にマッピングされる + quiet: true, + }) + } catch (e) { + console.error( + `Error serving static file ${relativePath} for KV Debug:`, + e, + ) + // serveDir がエラーをResponseとして返す場合があるため、ここではフォールバック + if (e instanceof Response) return e + return new Response("Internal Server Error serving debug static file", { + status: 500, + }) + } + } + + // APIエンドポイント + if (pathname === "/api/kv-debug/delete-all") { + if (req.method === "POST") { + try { + if (!kv) await initializeKv() + const iter = kv!.list({ prefix: [] }) + const promises = [] + for await (const entry of iter) { + promises.push(kv!.delete(entry.key)) + } + await Promise.all(promises) + return new Response( + JSON.stringify({ message: "All KV data deleted" }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ) + } catch (e) { + console.error("Error deleting all KV data for debug:", e) + return new Response( + JSON.stringify({ error: "Failed to delete KV data" }), + { + status: 500, + headers: { "content-type": "application/json" }, + }, + ) + } + } else { + return new Response("Method Not Allowed", { status: 405 }) + } + } + + if (pathname === "/api/kv-debug/delete-entry") { + if (req.method === "DELETE") { + // POSTからDELETEに変更 + try { + if (!kv) await initializeKv() + const body = await req.json() + const key = body.key // keyは配列であることを期待 + if ( + !Array.isArray(key) || + key.some( + (k) => + typeof k !== "string" && + typeof k !== "number" && + !(k instanceof Uint8Array), + ) + ) { + return new Response( + JSON.stringify({ + error: + "Invalid key format. Key should be an array of strings, numbers, or Uint8Array.", + }), + { + status: 400, + headers: { "content-type": "application/json" }, + }, + ) + } + await kv!.delete(key) + + // WebSocket経由で更新を通知 + const currentData = await getAllKvData() + for (const socket of sockets) { + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: "update", data: currentData })) + } + } + + return new Response( + JSON.stringify({ message: "Entry deleted successfully" }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ) + } catch (e) { + console.error("Error deleting KV entry for debug:", e) + return new Response( + JSON.stringify({ error: "Failed to delete KV entry" }), + { + status: 500, + headers: { "content-type": "application/json" }, + }, + ) + } + } else { + return new Response("Method Not Allowed", { status: 405 }) + } + } + + return undefined // KVデバッグビュー関連のリクエストでなければundefinedを返す +} + +// 必要であれば、サーバーシャットダウン時にKVを閉じる処理を追加 +// Deno.addSignalListener("SIGINT", async () => { +// if (kv) { +// console.log("Closing KV store for debug viewer..."); +// await kv.close(); +// } +// Deno.exit(); +// }); diff --git a/debug_kv_view/public/app.js b/debug_kv_view/public/app.js new file mode 100644 index 0000000..5cdc688 --- /dev/null +++ b/debug_kv_view/public/app.js @@ -0,0 +1,129 @@ +const { createApp, ref, computed } = Vue + +createApp({ + setup() { + const kvData = ref([]) + const error = ref(null) + const wsStatus = ref("Connecting...") + let socket = null + + const wsStatusColor = computed(() => { + switch (wsStatus.value) { + case "Connected": + return "green" + case "Disconnected": + return "red" + case "Error": + return "red" + default: + return "orange" + } + }) + + function connectWebSocket() { + const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:" + const wsUrl = `${wsProtocol}//${window.location.host}/ws/kv-debug` + + socket = new WebSocket(wsUrl) + + socket.onopen = () => { + wsStatus.value = "Connected" + error.value = null + } + + socket.onmessage = (event) => { + try { + const message = JSON.parse(event.data) + if (message.type === "initial" || message.type === "update") { + kvData.value = message.data + error.value = null + } else if (message.type === "error") { + error.value = message.message + } + } catch (_e) { + error.value = "Received malformed data from server." + } + } + + socket.onerror = () => { + error.value = + "WebSocket connection error. Check the server console or if the debug server is running on the correct port." + wsStatus.value = "Error" + } + + socket.onclose = () => { + wsStatus.value = "Disconnected" + setTimeout(connectWebSocket, 5000) + } + } + + function deleteEntry(keyString) { + const key = keyString.split(" : ") + fetch("/api/kv-debug/delete-entry", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ key }), + }) + .then((response) => { + if (!response.ok) { + return response.json().then((err) => { + throw new Error(err.error || "Failed to delete entry") + }) + } + }) + .catch((err) => { + error.value = err.message + }) + } + + function deleteAll() { + fetch("/api/kv-debug/delete-all", { + method: "POST", + }) + .then((response) => { + if (!response.ok) { + return response.json().then((err) => { + throw new Error(err.error || "Failed to delete all entries") + }) + } + }) + .catch((err) => { + error.value = err.message + }) + } + + connectWebSocket() + + return { + kvData, + error, + wsStatus, + wsStatusColor, + deleteEntry, + deleteAll, + } + }, + template: ` +
+

Deno KV Debug Viewer

+ +

WebSocket Status: {{ wsStatus }}

+
+

Error: {{ error }}

+
+
+

No data in KV store or not yet loaded.

+
+
    +
  • +
    Key: {{ item.key }}
    +
    {{ item.value }}
    +
    Versionstamp: {{ item.versionstamp }}
    + +
  • +
+
+ `, +}).mount("#app") diff --git a/debug_kv_view/public/index.html b/debug_kv_view/public/index.html new file mode 100644 index 0000000..98aeaa3 --- /dev/null +++ b/debug_kv_view/public/index.html @@ -0,0 +1,95 @@ + + + + + + KV Store Debug View + + + + +
+

KV Store Debug View

+
+

Error: {{ error }}

+
+
+

No data in KV store or waiting for data...

+
+
+
+ {{ item.key }} + (version: {{item.versionstamp}}) +
+
{{ item.value }}
+
+ +
+ + + diff --git a/deno.json b/deno.json index 2da4413..46828f2 100644 --- a/deno.json +++ b/deno.json @@ -4,24 +4,11 @@ "build": "deno run -A --node-modules-dir npm:vite build", "preview": "deno run -A --node-modules-dir npm:vite preview", "serve": "deno run --allow-net --allow-read --allow-env backend/server.ts", - "serve:kv": "USE_KV_STORE=true deno run --unstable-kv --unstable-cron --allow-net --allow-read --allow-env backend/server.ts", - "dev:serve": "deno run --watch --allow-net --allow-read --allow-env backend/server.ts", - "test:e2e": "deno test --allow-all e2e/index.test.ts e2e/room.test.ts", - "test:e2e:headless": "HEADLESS=true deno test --allow-all e2e/index.test.ts e2e/room.test.ts", - "test:e2e:browser": "HEADLESS=false deno test --allow-all e2e/index.test.ts e2e/room.test.ts", - "test:e2e:index": "deno test --allow-all e2e/index.test.ts", - "test:e2e:index:browser": "HEADLESS=false deno test --allow-all e2e/index.test.ts", - "test:e2e:room": "deno test --allow-all e2e/room.test.ts", - "test:e2e:room:browser": "HEADLESS=false deno test --allow-all e2e/room.test.ts", - "test:e2e:kv-cleanup": "deno test --unstable-kv --unstable-cron --allow-read --allow-write e2e/kv-cleanup.test.ts", - "test:e2e:all": "deno task test:e2e && deno task test:e2e:kv-cleanup", + "dev:serve": "APP_ENV='development' deno run --watch --allow-net --allow-read --allow-env --unstable-kv backend/server.ts", "install-browsers": "deno run -A e2e/install_browsers.ts", "deploy:build": "deno run -A --node-modules-dir npm:vite build", "deploy:serve": "USE_KV_STORE=true deno run --unstable-kv --unstable-cron --allow-net --allow-read --allow-env deploy-entry.ts", - "deploy": "deno run --allow-read --allow-write --allow-run --allow-env --allow-net deploy.ts", - "kv:view": "deno run --unstable-kv --allow-read --allow-env tools/kv-explorer.ts", - "kv:view-rooms": "deno run --unstable-kv --allow-read --allow-env tools/kv-explorer.ts rooms", - "kv:view-socket-instances": "deno run --unstable-kv --allow-read --allow-env tools/kv-explorer.ts socket_instances" + "deploy": "deno run --allow-read --allow-write --allow-run --allow-env --allow-net deploy.ts" }, "imports": { "vue": "npm:vue@^3.5.13", @@ -42,7 +29,13 @@ }, "nodeModulesDir": "auto", "compilerOptions": { - "lib": ["deno.unstable", "deno.ns", "dom", "dom.iterable", "dom.asynciterable"] + "lib": [ + "deno.unstable", + "deno.ns", + "dom", + "dom.iterable", + "dom.asynciterable" + ] }, "fmt": { "semiColons": false, diff --git a/deno.lock b/deno.lock index 6f7661f..358b22c 100644 --- a/deno.lock +++ b/deno.lock @@ -1,6 +1,17 @@ { - "version": "4", + "version": "5", "specifiers": { + "jsr:@std/assert@*": "1.0.13", + "jsr:@std/cli@^1.0.18": "1.0.19", + "jsr:@std/encoding@^1.0.10": "1.0.10", + "jsr:@std/fmt@^1.0.8": "1.0.8", + "jsr:@std/html@^1.0.4": "1.0.4", + "jsr:@std/http@*": "1.0.17", + "jsr:@std/internal@^1.0.6": "1.0.8", + "jsr:@std/media-types@^1.1.0": "1.1.0", + "jsr:@std/net@^1.0.4": "1.0.4", + "jsr:@std/path@^1.1.0": "1.1.0", + "jsr:@std/streams@^1.0.9": "1.0.9", "npm:@testing-library/dom@^9.3.4": "9.3.4", "npm:@types/node@*": "22.5.4", "npm:@vitejs/plugin-vue@^5.2.1": "5.2.1_vite@6.0.6_vue@3.5.13", @@ -16,6 +27,54 @@ "npm:vue-router@4": "4.5.0_vue@3.5.13", "npm:vue@^3.5.13": "3.5.13" }, + "jsr": { + "@std/assert@1.0.13": { + "integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/cli@1.0.19": { + "integrity": "b3601a54891f89f3f738023af11960c4e6f7a45dc76cde39a6861124cba79e88" + }, + "@std/encoding@1.0.10": { + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" + }, + "@std/fmt@1.0.8": { + "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" + }, + "@std/html@1.0.4": { + "integrity": "eff3497c08164e6ada49b7f81a28b5108087033823153d065e3f89467dd3d50e" + }, + "@std/http@1.0.17": { + "integrity": "98aec8ab4080d95c21f731e3008f69c29c5012d12f1b4e553f85935db601569f", + "dependencies": [ + "jsr:@std/cli", + "jsr:@std/encoding", + "jsr:@std/fmt", + "jsr:@std/html", + "jsr:@std/media-types", + "jsr:@std/net", + "jsr:@std/path", + "jsr:@std/streams" + ] + }, + "@std/internal@1.0.8": { + "integrity": "fc66e846d8d38a47cffd274d80d2ca3f0de71040f855783724bb6b87f60891f5" + }, + "@std/media-types@1.1.0": { + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" + }, + "@std/net@1.0.4": { + "integrity": "2f403b455ebbccf83d8a027d29c5a9e3a2452fea39bb2da7f2c04af09c8bc852" + }, + "@std/path@1.1.0": { + "integrity": "ddc94f8e3c275627281cbc23341df6b8bcc874d70374f75fec2533521e3d6886" + }, + "@std/streams@1.0.9": { + "integrity": "a9d26b1988cdd7aa7b1f4b51e1c36c1557f3f252880fa6cc5b9f37078b1a5035" + } + }, "npm": { "@alloc/quick-lru@5.2.0": { "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==" @@ -38,7 +97,8 @@ "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", "dependencies": [ "@babel/types" - ] + ], + "bin": true }, "@babel/runtime-corejs3@7.27.0": { "integrity": "sha512-UWjX6t+v+0ckwZ50Y5ShZLnlk95pP5MyW/pon9tiYzl3+18pkTHTFNTKr7rQbfRXPkowt2QAn30o1b6oswszew==", @@ -61,79 +121,129 @@ ] }, "@esbuild/aix-ppc64@0.24.2": { - "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==" + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "os": ["aix"], + "cpu": ["ppc64"] }, "@esbuild/android-arm64@0.24.2": { - "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==" + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "os": ["android"], + "cpu": ["arm64"] }, "@esbuild/android-arm@0.24.2": { - "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==" + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "os": ["android"], + "cpu": ["arm"] }, "@esbuild/android-x64@0.24.2": { - "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==" + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "os": ["android"], + "cpu": ["x64"] }, "@esbuild/darwin-arm64@0.24.2": { - "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==" + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "os": ["darwin"], + "cpu": ["arm64"] }, "@esbuild/darwin-x64@0.24.2": { - "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==" + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "os": ["darwin"], + "cpu": ["x64"] }, "@esbuild/freebsd-arm64@0.24.2": { - "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==" + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "os": ["freebsd"], + "cpu": ["arm64"] }, "@esbuild/freebsd-x64@0.24.2": { - "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==" + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "os": ["freebsd"], + "cpu": ["x64"] }, "@esbuild/linux-arm64@0.24.2": { - "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==" + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "os": ["linux"], + "cpu": ["arm64"] }, "@esbuild/linux-arm@0.24.2": { - "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==" + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "os": ["linux"], + "cpu": ["arm"] }, "@esbuild/linux-ia32@0.24.2": { - "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==" + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "os": ["linux"], + "cpu": ["ia32"] }, "@esbuild/linux-loong64@0.24.2": { - "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==" + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "os": ["linux"], + "cpu": ["loong64"] }, "@esbuild/linux-mips64el@0.24.2": { - "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==" + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "os": ["linux"], + "cpu": ["mips64el"] }, "@esbuild/linux-ppc64@0.24.2": { - "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==" + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "os": ["linux"], + "cpu": ["ppc64"] }, "@esbuild/linux-riscv64@0.24.2": { - "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==" + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "os": ["linux"], + "cpu": ["riscv64"] }, "@esbuild/linux-s390x@0.24.2": { - "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==" + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "os": ["linux"], + "cpu": ["s390x"] }, "@esbuild/linux-x64@0.24.2": { - "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==" + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "os": ["linux"], + "cpu": ["x64"] }, "@esbuild/netbsd-arm64@0.24.2": { - "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==" + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "os": ["netbsd"], + "cpu": ["arm64"] }, "@esbuild/netbsd-x64@0.24.2": { - "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==" + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "os": ["netbsd"], + "cpu": ["x64"] }, "@esbuild/openbsd-arm64@0.24.2": { - "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==" + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "os": ["openbsd"], + "cpu": ["arm64"] }, "@esbuild/openbsd-x64@0.24.2": { - "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==" + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "os": ["openbsd"], + "cpu": ["x64"] }, "@esbuild/sunos-x64@0.24.2": { - "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==" + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "os": ["sunos"], + "cpu": ["x64"] }, "@esbuild/win32-arm64@0.24.2": { - "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==" + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "os": ["win32"], + "cpu": ["arm64"] }, "@esbuild/win32-ia32@0.24.2": { - "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==" + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "os": ["win32"], + "cpu": ["ia32"] }, "@esbuild/win32-x64@0.24.2": { - "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==" + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "os": ["win32"], + "cpu": ["x64"] }, "@isaacs/cliui@8.0.2": { "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", @@ -224,61 +334,99 @@ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==" }, "@rollup/rollup-android-arm-eabi@4.29.1": { - "integrity": "sha512-ssKhA8RNltTZLpG6/QNkCSge+7mBQGUqJRisZ2MDQcEGaK93QESEgWK2iOpIDZ7k9zPVkG5AS3ksvD5ZWxmItw==" + "integrity": "sha512-ssKhA8RNltTZLpG6/QNkCSge+7mBQGUqJRisZ2MDQcEGaK93QESEgWK2iOpIDZ7k9zPVkG5AS3ksvD5ZWxmItw==", + "os": ["android"], + "cpu": ["arm"] }, "@rollup/rollup-android-arm64@4.29.1": { - "integrity": "sha512-CaRfrV0cd+NIIcVVN/jx+hVLN+VRqnuzLRmfmlzpOzB87ajixsN/+9L5xNmkaUUvEbI5BmIKS+XTwXsHEb65Ew==" + "integrity": "sha512-CaRfrV0cd+NIIcVVN/jx+hVLN+VRqnuzLRmfmlzpOzB87ajixsN/+9L5xNmkaUUvEbI5BmIKS+XTwXsHEb65Ew==", + "os": ["android"], + "cpu": ["arm64"] }, "@rollup/rollup-darwin-arm64@4.29.1": { - "integrity": "sha512-2ORr7T31Y0Mnk6qNuwtyNmy14MunTAMx06VAPI6/Ju52W10zk1i7i5U3vlDRWjhOI5quBcrvhkCHyF76bI7kEw==" + "integrity": "sha512-2ORr7T31Y0Mnk6qNuwtyNmy14MunTAMx06VAPI6/Ju52W10zk1i7i5U3vlDRWjhOI5quBcrvhkCHyF76bI7kEw==", + "os": ["darwin"], + "cpu": ["arm64"] }, "@rollup/rollup-darwin-x64@4.29.1": { - "integrity": "sha512-j/Ej1oanzPjmN0tirRd5K2/nncAhS9W6ICzgxV+9Y5ZsP0hiGhHJXZ2JQ53iSSjj8m6cRY6oB1GMzNn2EUt6Ng==" + "integrity": "sha512-j/Ej1oanzPjmN0tirRd5K2/nncAhS9W6ICzgxV+9Y5ZsP0hiGhHJXZ2JQ53iSSjj8m6cRY6oB1GMzNn2EUt6Ng==", + "os": ["darwin"], + "cpu": ["x64"] }, "@rollup/rollup-freebsd-arm64@4.29.1": { - "integrity": "sha512-91C//G6Dm/cv724tpt7nTyP+JdN12iqeXGFM1SqnljCmi5yTXriH7B1r8AD9dAZByHpKAumqP1Qy2vVNIdLZqw==" + "integrity": "sha512-91C//G6Dm/cv724tpt7nTyP+JdN12iqeXGFM1SqnljCmi5yTXriH7B1r8AD9dAZByHpKAumqP1Qy2vVNIdLZqw==", + "os": ["freebsd"], + "cpu": ["arm64"] }, "@rollup/rollup-freebsd-x64@4.29.1": { - "integrity": "sha512-hEioiEQ9Dec2nIRoeHUP6hr1PSkXzQaCUyqBDQ9I9ik4gCXQZjJMIVzoNLBRGet+hIUb3CISMh9KXuCcWVW/8w==" + "integrity": "sha512-hEioiEQ9Dec2nIRoeHUP6hr1PSkXzQaCUyqBDQ9I9ik4gCXQZjJMIVzoNLBRGet+hIUb3CISMh9KXuCcWVW/8w==", + "os": ["freebsd"], + "cpu": ["x64"] }, "@rollup/rollup-linux-arm-gnueabihf@4.29.1": { - "integrity": "sha512-Py5vFd5HWYN9zxBv3WMrLAXY3yYJ6Q/aVERoeUFwiDGiMOWsMs7FokXihSOaT/PMWUty/Pj60XDQndK3eAfE6A==" + "integrity": "sha512-Py5vFd5HWYN9zxBv3WMrLAXY3yYJ6Q/aVERoeUFwiDGiMOWsMs7FokXihSOaT/PMWUty/Pj60XDQndK3eAfE6A==", + "os": ["linux"], + "cpu": ["arm"] }, "@rollup/rollup-linux-arm-musleabihf@4.29.1": { - "integrity": "sha512-RiWpGgbayf7LUcuSNIbahr0ys2YnEERD4gYdISA06wa0i8RALrnzflh9Wxii7zQJEB2/Eh74dX4y/sHKLWp5uQ==" + "integrity": "sha512-RiWpGgbayf7LUcuSNIbahr0ys2YnEERD4gYdISA06wa0i8RALrnzflh9Wxii7zQJEB2/Eh74dX4y/sHKLWp5uQ==", + "os": ["linux"], + "cpu": ["arm"] }, "@rollup/rollup-linux-arm64-gnu@4.29.1": { - "integrity": "sha512-Z80O+taYxTQITWMjm/YqNoe9d10OX6kDh8X5/rFCMuPqsKsSyDilvfg+vd3iXIqtfmp+cnfL1UrYirkaF8SBZA==" + "integrity": "sha512-Z80O+taYxTQITWMjm/YqNoe9d10OX6kDh8X5/rFCMuPqsKsSyDilvfg+vd3iXIqtfmp+cnfL1UrYirkaF8SBZA==", + "os": ["linux"], + "cpu": ["arm64"] }, "@rollup/rollup-linux-arm64-musl@4.29.1": { - "integrity": "sha512-fOHRtF9gahwJk3QVp01a/GqS4hBEZCV1oKglVVq13kcK3NeVlS4BwIFzOHDbmKzt3i0OuHG4zfRP0YoG5OF/rA==" + "integrity": "sha512-fOHRtF9gahwJk3QVp01a/GqS4hBEZCV1oKglVVq13kcK3NeVlS4BwIFzOHDbmKzt3i0OuHG4zfRP0YoG5OF/rA==", + "os": ["linux"], + "cpu": ["arm64"] }, "@rollup/rollup-linux-loongarch64-gnu@4.29.1": { - "integrity": "sha512-5a7q3tnlbcg0OodyxcAdrrCxFi0DgXJSoOuidFUzHZ2GixZXQs6Tc3CHmlvqKAmOs5eRde+JJxeIf9DonkmYkw==" + "integrity": "sha512-5a7q3tnlbcg0OodyxcAdrrCxFi0DgXJSoOuidFUzHZ2GixZXQs6Tc3CHmlvqKAmOs5eRde+JJxeIf9DonkmYkw==", + "os": ["linux"], + "cpu": ["loong64"] }, "@rollup/rollup-linux-powerpc64le-gnu@4.29.1": { - "integrity": "sha512-9b4Mg5Yfz6mRnlSPIdROcfw1BU22FQxmfjlp/CShWwO3LilKQuMISMTtAu/bxmmrE6A902W2cZJuzx8+gJ8e9w==" + "integrity": "sha512-9b4Mg5Yfz6mRnlSPIdROcfw1BU22FQxmfjlp/CShWwO3LilKQuMISMTtAu/bxmmrE6A902W2cZJuzx8+gJ8e9w==", + "os": ["linux"], + "cpu": ["ppc64"] }, "@rollup/rollup-linux-riscv64-gnu@4.29.1": { - "integrity": "sha512-G5pn0NChlbRM8OJWpJFMX4/i8OEU538uiSv0P6roZcbpe/WfhEO+AT8SHVKfp8qhDQzaz7Q+1/ixMy7hBRidnQ==" + "integrity": "sha512-G5pn0NChlbRM8OJWpJFMX4/i8OEU538uiSv0P6roZcbpe/WfhEO+AT8SHVKfp8qhDQzaz7Q+1/ixMy7hBRidnQ==", + "os": ["linux"], + "cpu": ["riscv64"] }, "@rollup/rollup-linux-s390x-gnu@4.29.1": { - "integrity": "sha512-WM9lIkNdkhVwiArmLxFXpWndFGuOka4oJOZh8EP3Vb8q5lzdSCBuhjavJsw68Q9AKDGeOOIHYzYm4ZFvmWez5g==" + "integrity": "sha512-WM9lIkNdkhVwiArmLxFXpWndFGuOka4oJOZh8EP3Vb8q5lzdSCBuhjavJsw68Q9AKDGeOOIHYzYm4ZFvmWez5g==", + "os": ["linux"], + "cpu": ["s390x"] }, "@rollup/rollup-linux-x64-gnu@4.29.1": { - "integrity": "sha512-87xYCwb0cPGZFoGiErT1eDcssByaLX4fc0z2nRM6eMtV9njAfEE6OW3UniAoDhX4Iq5xQVpE6qO9aJbCFumKYQ==" + "integrity": "sha512-87xYCwb0cPGZFoGiErT1eDcssByaLX4fc0z2nRM6eMtV9njAfEE6OW3UniAoDhX4Iq5xQVpE6qO9aJbCFumKYQ==", + "os": ["linux"], + "cpu": ["x64"] }, "@rollup/rollup-linux-x64-musl@4.29.1": { - "integrity": "sha512-xufkSNppNOdVRCEC4WKvlR1FBDyqCSCpQeMMgv9ZyXqqtKBfkw1yfGMTUTs9Qsl6WQbJnsGboWCp7pJGkeMhKA==" + "integrity": "sha512-xufkSNppNOdVRCEC4WKvlR1FBDyqCSCpQeMMgv9ZyXqqtKBfkw1yfGMTUTs9Qsl6WQbJnsGboWCp7pJGkeMhKA==", + "os": ["linux"], + "cpu": ["x64"] }, "@rollup/rollup-win32-arm64-msvc@4.29.1": { - "integrity": "sha512-F2OiJ42m77lSkizZQLuC+jiZ2cgueWQL5YC9tjo3AgaEw+KJmVxHGSyQfDUoYR9cci0lAywv2Clmckzulcq6ig==" + "integrity": "sha512-F2OiJ42m77lSkizZQLuC+jiZ2cgueWQL5YC9tjo3AgaEw+KJmVxHGSyQfDUoYR9cci0lAywv2Clmckzulcq6ig==", + "os": ["win32"], + "cpu": ["arm64"] }, "@rollup/rollup-win32-ia32-msvc@4.29.1": { - "integrity": "sha512-rYRe5S0FcjlOBZQHgbTKNrqxCBUmgDJem/VQTCcTnA2KCabYSWQDrytOzX7avb79cAAweNmMUb/Zw18RNd4mng==" + "integrity": "sha512-rYRe5S0FcjlOBZQHgbTKNrqxCBUmgDJem/VQTCcTnA2KCabYSWQDrytOzX7avb79cAAweNmMUb/Zw18RNd4mng==", + "os": ["win32"], + "cpu": ["ia32"] }, "@rollup/rollup-win32-x64-msvc@4.29.1": { - "integrity": "sha512-+10CMg9vt1MoHj6x1pxyjPSMjHTIlqs8/tBztXvPAx24SKs9jwVnKqHJumlH/IzhaPUaj3T6T6wfZr8okdXaIg==" + "integrity": "sha512-+10CMg9vt1MoHj6x1pxyjPSMjHTIlqs8/tBztXvPAx24SKs9jwVnKqHJumlH/IzhaPUaj3T6T6wfZr8okdXaIg==", + "os": ["win32"], + "cpu": ["x64"] }, "@sinclair/typebox@0.27.8": { "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==" @@ -520,7 +668,8 @@ "picocolors", "postcss", "postcss-value-parser" - ] + ], + "bin": true }, "available-typed-arrays@1.0.7": { "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", @@ -553,7 +702,8 @@ "electron-to-chromium", "node-releases", "update-browserslist-db" - ] + ], + "bin": true }, "call-bind-apply-helpers@1.0.2": { "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", @@ -596,12 +746,14 @@ "dependencies": [ "anymatch", "braces", - "fsevents@2.3.3", "glob-parent@5.1.2", "is-binary-path", "is-glob", "normalize-path", "readdirp" + ], + "optionalDependencies": [ + "fsevents@2.3.3" ] }, "ci-info@3.9.0": { @@ -620,7 +772,8 @@ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==" }, "core-js-pure@3.41.0": { - "integrity": "sha512-71Gzp96T9YPk63aUvE5Q5qP+DryB4ZloUZPSOebGM88VNw8VNfvdA7z6kGA8iGOTEzAomsRidp4jXSmUIJsL+Q==" + "integrity": "sha512-71Gzp96T9YPk63aUvE5Q5qP+DryB4ZloUZPSOebGM88VNw8VNfvdA7z6kGA8iGOTEzAomsRidp4jXSmUIJsL+Q==", + "scripts": true }, "cross-spawn@7.0.6": { "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", @@ -631,7 +784,8 @@ ] }, "cssesc@3.0.0": { - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==" + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": true }, "csstype@3.1.3": { "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" @@ -738,7 +892,7 @@ }, "esbuild@0.24.2": { "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", - "dependencies": [ + "optionalDependencies": [ "@esbuild/aix-ppc64", "@esbuild/android-arm", "@esbuild/android-arm64", @@ -764,7 +918,9 @@ "@esbuild/win32-arm64", "@esbuild/win32-ia32", "@esbuild/win32-x64" - ] + ], + "scripts": true, + "bin": true }, "escalade@3.2.0": { "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" @@ -824,10 +980,14 @@ "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==" }, "fsevents@2.3.2": { - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==" + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "os": ["darwin"], + "scripts": true }, "fsevents@2.3.3": { - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==" + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "os": ["darwin"], + "scripts": true }, "function-bind@1.1.2": { "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" @@ -878,7 +1038,8 @@ "minipass", "package-json-from-dist", "path-scurry" - ] + ], + "bin": true }, "gopd@1.2.0": { "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" @@ -1048,7 +1209,9 @@ "jackspeak@3.4.3": { "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dependencies": [ - "@isaacs/cliui", + "@isaacs/cliui" + ], + "optionalDependencies": [ "@pkgjs/parseargs" ] }, @@ -1099,7 +1262,8 @@ ] }, "jiti@1.21.7": { - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==" + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "bin": true }, "js-tokens@4.0.0": { "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" @@ -1114,7 +1278,8 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" }, "lz-string@1.5.0": { - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==" + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "bin": true }, "magic-string@0.30.17": { "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", @@ -1153,7 +1318,8 @@ ] }, "nanoid@3.3.8": { - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==" + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "bin": true }, "node-releases@2.0.19": { "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==" @@ -1223,7 +1389,8 @@ "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==" }, "playwright-core@1.51.1": { - "integrity": "sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==" + "integrity": "sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==", + "bin": true }, "playwright-testing-library@4.5.0_playwright@1.51.1": { "integrity": "sha512-jgeb/L9Xs1PI3LMQ0eup5w5AyYhHAvc0WyoIFn4RHxc16eZUqamu7Hkq7BYO24zP+byMAbUfk1G+xob/JlOqTg==", @@ -1231,14 +1398,21 @@ "@testing-library/dom@7.31.2", "playwright", "wait-for-expect" + ], + "optionalPeers": [ + "@playwright/test@^1.12.0", + "playwright" ] }, "playwright@1.51.1": { "integrity": "sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==", "dependencies": [ - "fsevents@2.3.2", "playwright-core" - ] + ], + "optionalDependencies": [ + "fsevents@2.3.2" + ], + "bin": true }, "possible-typed-array-names@1.1.0": { "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==" @@ -1265,6 +1439,10 @@ "lilconfig", "postcss", "yaml" + ], + "optionalPeers": [ + "postcss", + "ts-node@>=9.0.0" ] }, "postcss-nested@6.2.0_postcss@8.4.49": { @@ -1358,7 +1536,8 @@ "is-core-module", "path-parse", "supports-preserve-symlinks-flag" - ] + ], + "bin": true }, "reusify@1.0.4": { "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==" @@ -1366,6 +1545,9 @@ "rollup@4.29.1": { "integrity": "sha512-RaJ45M/kmJUzSWDs1Nnd5DdV4eerC98idtUOVr6FfKcgxqvjwHmxc5upLF9qZU9EpsVzzhleFahrT3shLuJzIw==", "dependencies": [ + "@types/estree" + ], + "optionalDependencies": [ "@rollup/rollup-android-arm-eabi", "@rollup/rollup-android-arm64", "@rollup/rollup-darwin-arm64", @@ -1385,9 +1567,9 @@ "@rollup/rollup-win32-arm64-msvc", "@rollup/rollup-win32-ia32-msvc", "@rollup/rollup-win32-x64-msvc", - "@types/estree", "fsevents@2.3.3" - ] + ], + "bin": true }, "run-parallel@1.2.0": { "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", @@ -1528,7 +1710,8 @@ "mz", "pirates", "ts-interface-checker" - ] + ], + "bin": true }, "supports-color@7.2.0": { "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", @@ -1564,7 +1747,8 @@ "postcss-selector-parser", "resolve", "sucrase" - ] + ], + "bin": true }, "thenify-all@1.6.0": { "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", @@ -1596,7 +1780,8 @@ "browserslist", "escalade", "picocolors" - ] + ], + "bin": true }, "util-deprecate@1.0.2": { "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" @@ -1605,26 +1790,63 @@ "integrity": "sha512-NSjmUuckPmDU18bHz7QZ+bTYhRR0iA72cs2QAxCqDpafJ0S6qetco0LB3WW2OxlMHS0JmAv+yZ/R3uPmMyGTjQ==", "dependencies": [ "esbuild", - "fsevents@2.3.3", "postcss", "rollup" - ] + ], + "optionalDependencies": [ + "fsevents@2.3.3" + ], + "optionalPeers": [ + "@types/node", + "jiti", + "less@*", + "lightningcss@^1.21.0", + "sass@*", + "sass-embedded@*", + "stylus@*", + "sugarss@*", + "terser@^5.16.0", + "tsx@^4.8.1", + "yaml" + ], + "bin": true }, "vite@6.0.6_@types+node@22.5.4": { "integrity": "sha512-NSjmUuckPmDU18bHz7QZ+bTYhRR0iA72cs2QAxCqDpafJ0S6qetco0LB3WW2OxlMHS0JmAv+yZ/R3uPmMyGTjQ==", "dependencies": [ "@types/node", "esbuild", - "fsevents@2.3.3", "postcss", "rollup" - ] + ], + "optionalDependencies": [ + "fsevents@2.3.3" + ], + "optionalPeers": [ + "@types/node", + "jiti", + "less@*", + "lightningcss@^1.21.0", + "sass@*", + "sass-embedded@*", + "stylus@*", + "sugarss@*", + "terser@^5.16.0", + "tsx@^4.8.1", + "yaml" + ], + "bin": true }, "vue-demi@0.14.10_vue@3.5.13": { "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", "dependencies": [ "vue" - ] + ], + "optionalPeers": [ + "@vue/composition-api@^1.0.0-rc.1" + ], + "scripts": true, + "bin": true }, "vue-router@4.5.0_vue@3.5.13": { "integrity": "sha512-HDuk+PuH5monfNuY+ct49mNmkCRK4xJAV9Ts4z9UFc4rzdDnxQLyCMGGc8pKhZhHTVzfanpNwB/lwqevcBwI4w==", @@ -1641,6 +1863,9 @@ "@vue/runtime-dom", "@vue/server-renderer", "@vue/shared" + ], + "optionalPeers": [ + "typescript@*" ] }, "wait-for-expect@3.0.2": { @@ -1681,7 +1906,8 @@ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dependencies": [ "isexe" - ] + ], + "bin": true }, "wrap-ansi@7.0.0": { "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", @@ -1700,13 +1926,46 @@ ] }, "yaml@2.7.0": { - "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==" + "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "bin": true } }, "redirects": { "https://deno.land/x/deno_cron/cron.ts": "https://deno.land/x/deno_cron@v1.0.0/cron.ts" }, "remote": { + "https://deno.land/std@0.203.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", + "https://deno.land/std@0.203.0/assert/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", + "https://deno.land/std@0.203.0/assert/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", + "https://deno.land/std@0.203.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee", + "https://deno.land/std@0.203.0/assert/assert_almost_equals.ts": "e15ca1f34d0d5e0afae63b3f5d975cbd18335a132e42b0c747d282f62ad2cd6c", + "https://deno.land/std@0.203.0/assert/assert_array_includes.ts": "6856d7f2c3544bc6e62fb4646dfefa3d1df5ff14744d1bca19f0cbaf3b0d66c9", + "https://deno.land/std@0.203.0/assert/assert_equals.ts": "d8ec8a22447fbaf2fc9d7c3ed2e66790fdb74beae3e482855d75782218d68227", + "https://deno.land/std@0.203.0/assert/assert_exists.ts": "407cb6b9fb23a835cd8d5ad804e2e2edbbbf3870e322d53f79e1c7a512e2efd7", + "https://deno.land/std@0.203.0/assert/assert_false.ts": "0ccbcaae910f52c857192ff16ea08bda40fdc79de80846c206bfc061e8c851c6", + "https://deno.land/std@0.203.0/assert/assert_greater.ts": "ae2158a2d19313bf675bf7251d31c6dc52973edb12ac64ac8fc7064152af3e63", + "https://deno.land/std@0.203.0/assert/assert_greater_or_equal.ts": "1439da5ebbe20855446cac50097ac78b9742abe8e9a43e7de1ce1426d556e89c", + "https://deno.land/std@0.203.0/assert/assert_instance_of.ts": "3aedb3d8186e120812d2b3a5dea66a6e42bf8c57a8bd927645770bd21eea554c", + "https://deno.land/std@0.203.0/assert/assert_is_error.ts": "c21113094a51a296ffaf036767d616a78a2ae5f9f7bbd464cd0197476498b94b", + "https://deno.land/std@0.203.0/assert/assert_less.ts": "aec695db57db42ec3e2b62e97e1e93db0063f5a6ec133326cc290ff4b71b47e4", + "https://deno.land/std@0.203.0/assert/assert_less_or_equal.ts": "5fa8b6a3ffa20fd0a05032fe7257bf985d207b85685fdbcd23651b70f928c848", + "https://deno.land/std@0.203.0/assert/assert_match.ts": "c4083f80600bc190309903c95e397a7c9257ff8b5ae5c7ef91e834704e672e9b", + "https://deno.land/std@0.203.0/assert/assert_not_equals.ts": "9f1acab95bd1f5fc9a1b17b8027d894509a745d91bac1718fdab51dc76831754", + "https://deno.land/std@0.203.0/assert/assert_not_instance_of.ts": "0c14d3dfd9ab7a5276ed8ed0b18c703d79a3d106102077ec437bfe7ed912bd22", + "https://deno.land/std@0.203.0/assert/assert_not_match.ts": "3796a5b0c57a1ce6c1c57883dd4286be13a26f715ea662318ab43a8491a13ab0", + "https://deno.land/std@0.203.0/assert/assert_not_strict_equals.ts": "ca6c6d645e95fbc873d25320efeb8c4c6089a9a5e09f92d7c1c4b6e935c2a6ad", + "https://deno.land/std@0.203.0/assert/assert_object_match.ts": "d8fc2867cfd92eeacf9cea621e10336b666de1874a6767b5ec48988838370b54", + "https://deno.land/std@0.203.0/assert/assert_rejects.ts": "45c59724de2701e3b1f67c391d6c71c392363635aad3f68a1b3408f9efca0057", + "https://deno.land/std@0.203.0/assert/assert_strict_equals.ts": "b1f538a7ea5f8348aeca261d4f9ca603127c665e0f2bbfeb91fa272787c87265", + "https://deno.land/std@0.203.0/assert/assert_string_includes.ts": "b821d39ebf5cb0200a348863c86d8c4c4b398e02012ce74ad15666fc4b631b0c", + "https://deno.land/std@0.203.0/assert/assert_throws.ts": "63784e951475cb7bdfd59878cd25a0931e18f6dc32a6077c454b2cd94f4f4bcd", + "https://deno.land/std@0.203.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", + "https://deno.land/std@0.203.0/assert/equal.ts": "9f1a46d5993966d2596c44e5858eec821859b45f783a5ee2f7a695dfc12d8ece", + "https://deno.land/std@0.203.0/assert/fail.ts": "c36353d7ae6e1f7933d45f8ea51e358c8c4b67d7e7502028598fe1fea062e278", + "https://deno.land/std@0.203.0/assert/mod.ts": "37c49a26aae2b254bbe25723434dc28cd7532e444cf0b481a97c045d110ec085", + "https://deno.land/std@0.203.0/assert/unimplemented.ts": "d56fbeecb1f108331a380f72e3e010a1f161baa6956fd0f7cf3e095ae1a4c75a", + "https://deno.land/std@0.203.0/assert/unreachable.ts": "4600dc0baf7d9c15a7f7d234f00c23bca8f3eba8b140286aaca7aa998cf9a536", + "https://deno.land/std@0.203.0/fmt/colors.ts": "c51c4642678eb690dcf5ffee5918b675bf01a33fba82acf303701ae1a4f8c8d9", "https://deno.land/std@0.209.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", "https://deno.land/std@0.209.0/assert/_diff.ts": "2c9371f17cf08cbb843c924bc31ca77af422ec4fe162f73d42c651d547573fa8", "https://deno.land/std@0.209.0/assert/_format.ts": "335ce8e15c65b679ad142dbc9e5e97e5d58602c39dd3c9175cef6c85fe22d6d5", @@ -1738,7 +1997,15 @@ "https://deno.land/std@0.209.0/assert/mod.ts": "37c49a26aae2b254bbe25723434dc28cd7532e444cf0b481a97c045d110ec085", "https://deno.land/std@0.209.0/assert/unimplemented.ts": "4e3e504792c87c485dbc5f4020489d8806ef697741403af2008dfa7b5a4711e8", "https://deno.land/std@0.209.0/assert/unreachable.ts": "1af8c99421cc5fb7332454b2b9eca074a4e394895a180bc837750dedcca75338", + "https://deno.land/std@0.209.0/bytes/concat.ts": "148a7575649e4a06246203f725f4878dce08690cc33c448f1000632e7a050449", "https://deno.land/std@0.209.0/cli/parse_args.ts": "9bea02050b3f302e706871ff87ecfa3ad82cc34249adbe0dcddfaac75bdb48ff", + "https://deno.land/std@0.209.0/crypto/_fnv/fnv32.ts": "e4649dfdefc5c987ed53c3c25db62db771a06d9d1b9c36d2b5cf0853b8e82153", + "https://deno.land/std@0.209.0/crypto/_fnv/fnv64.ts": "bfa0e4702061fdb490a14e6bf5f9168a22fb022b307c5723499469bfefca555e", + "https://deno.land/std@0.209.0/crypto/_fnv/mod.ts": "f956a95f58910f223e420340b7404702ecd429603acd4491fa77af84f746040c", + "https://deno.land/std@0.209.0/crypto/_fnv/util.ts": "accba12bfd80a352e32a872f87df2a195e75561f1b1304a4cb4f5a4648d288f9", + "https://deno.land/std@0.209.0/crypto/_wasm/lib/deno_std_wasm_crypto.generated.mjs": "524b64057e4c1f0a4f65e9ec8cf7257a618e0f1fe326fa1e0bbff35444a7effb", + "https://deno.land/std@0.209.0/crypto/_wasm/mod.ts": "d7b7dc54bbd6b02c16cd08e8e3d30fa9aa9692efb112a7ab5d8595827b9a0234", + "https://deno.land/std@0.209.0/crypto/crypto.ts": "91c67764abb640b3e2a0b46867704d02077c0b1f978f5c711e4408e5d856717d", "https://deno.land/std@0.209.0/encoding/_util.ts": "f368920189c4fe6592ab2e93bd7ded8f3065b84f95cd3e036a4a10a75649dcba", "https://deno.land/std@0.209.0/encoding/base64.ts": "d31eb3c0b6003daa188a86074603d721d572f927c72ad6cd008145c0d42063ab", "https://deno.land/std@0.209.0/fmt/bytes.ts": "f29cf69e0791d375f9f5d94ae1f0641e5a03b975f32ddf86d70f70fdf37e7b6a", @@ -1832,8 +2099,127 @@ "https://deno.land/std@0.209.0/path/windows/to_file_url.ts": "8e9ea9e1ff364aa06fa72999204229952d0a279dbb876b7b838b2b2fea55cce3", "https://deno.land/std@0.209.0/path/windows/to_namespaced_path.ts": "e0f4d4a5e77f28a5708c1a33ff24360f35637ba6d8f103d19661255ef7bfd50d", "https://deno.land/std@0.209.0/streams/byte_slice_stream.ts": "ecf523d7ff665d554100de981058c60f5207ab718319244f7c742d9e1194fa64", + "https://deno.land/std@0.209.0/uuid/_common.ts": "cb1441f4df460571fc0919e1c5c217f3e7006189b703caf946604b3f791ae34d", + "https://deno.land/std@0.209.0/uuid/constants.ts": "0d0e95561343da44adb4a4edbc1f04cef48b0d75288c4d1704f58743f4a50d88", + "https://deno.land/std@0.209.0/uuid/mod.ts": "5c7ca252dddba1ddf0bca2dc1124328245272650c98251d71996bb9cd8f5a386", + "https://deno.land/std@0.209.0/uuid/v1.ts": "fe36009afce7ced96e1b5928565e12c5a8eb0df1a2b5063c0a72bda6b75c0de5", + "https://deno.land/std@0.209.0/uuid/v3.ts": "397ad58daec8b5ef6ba7e94fe86c9bc56b194adcbe2f70ec40a1fb005203c870", + "https://deno.land/std@0.209.0/uuid/v4.ts": "0f081880c156fd59b9e44e2f84ea0f94a3627e89c224eaf6cc982b53d849f37e", + "https://deno.land/std@0.209.0/uuid/v5.ts": "9daaf769e487b512d25adf8e137e05ff2e3392d27f66d5b273ee28030ff7cd58", "https://deno.land/std@0.209.0/version.ts": "6d941038078dd4970fbfe4f7cf92458aaf10519d9ab0610183e36462adffcaba", - "https://deno.land/x/deno_cron@v1.0.0/cron.ts": "7f984d0c4c7ac4fb1ad3cd241d457e7808a9362735d910abb02dc689883ee3ef" + "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", + "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", + "https://deno.land/std@0.224.0/async/delay.ts": "f90dd685b97c2f142b8069082993e437b1602b8e2561134827eeb7c12b95c499", + "https://deno.land/std@0.224.0/cli/parse_args.ts": "5250832fb7c544d9111e8a41ad272c016f5a53f975ef84d5a9fe5fcb70566ece", + "https://deno.land/std@0.224.0/encoding/_util.ts": "beacef316c1255da9bc8e95afb1fa56ed69baef919c88dc06ae6cb7a6103d376", + "https://deno.land/std@0.224.0/encoding/base64.ts": "dd59695391584c8ffc5a296ba82bcdba6dd8a84d41a6a539fbee8e5075286eaf", + "https://deno.land/std@0.224.0/fmt/bytes.ts": "7b294a4b9cf0297efa55acb55d50610f3e116a0ac772d1df0ae00f0b833ccd4a", + "https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5", + "https://deno.land/std@0.224.0/http/etag.ts": "9ca56531be682f202e4239971931060b688ee5c362688e239eeaca39db9e72cb", + "https://deno.land/std@0.224.0/http/file_server.ts": "2a5392195b8e7713288f274d071711b705bb5b3220294d76cce495d456c61a93", + "https://deno.land/std@0.224.0/http/server.ts": "f9313804bf6467a1704f45f76cb6cd0a3396a3b31c316035e6a4c2035d1ea514", + "https://deno.land/std@0.224.0/http/status.ts": "ed61b4882af2514a81aefd3245e8df4c47b9a8e54929a903577643d2d1ebf514", + "https://deno.land/std@0.224.0/media_types/_db.ts": "19563a2491cd81b53b9c1c6ffd1a9145c355042d4a854c52f6e1424f73ff3923", + "https://deno.land/std@0.224.0/media_types/_util.ts": "e0b8da0c7d8ad2015cf27ac16ddf0809ac984b2f3ec79f7fa4206659d4f10deb", + "https://deno.land/std@0.224.0/media_types/content_type.ts": "ed3f2e1f243b418ad3f441edc95fd92efbadb0f9bde36219c7564c67f9639513", + "https://deno.land/std@0.224.0/media_types/format_media_type.ts": "ffef4718afa2489530cb94021bb865a466eb02037609f7e82899c017959d288a", + "https://deno.land/std@0.224.0/media_types/get_charset.ts": "277ebfceb205bd34e616fe6764ef03fb277b77f040706272bea8680806ae3f11", + "https://deno.land/std@0.224.0/media_types/parse_media_type.ts": "487f000a38c230ccbac25420a50f600862e06796d0eee19d19631b9e84ee9654", + "https://deno.land/std@0.224.0/media_types/type_by_extension.ts": "bf4e3f5d6b58b624d5daa01cbb8b1e86d9939940a77e7c26e796a075b60ec73b", + "https://deno.land/std@0.224.0/media_types/vendor/mime-db.v1.52.0.ts": "0218d2c7d900e8cd6fa4a866e0c387712af4af9a1bae55d6b2546c73d273a1e6", + "https://deno.land/std@0.224.0/path/_common/assert_path.ts": "dbdd757a465b690b2cc72fc5fb7698c51507dec6bfafce4ca500c46b76ff7bd8", + "https://deno.land/std@0.224.0/path/_common/basename.ts": "569744855bc8445f3a56087fd2aed56bdad39da971a8d92b138c9913aecc5fa2", + "https://deno.land/std@0.224.0/path/_common/common.ts": "ef73c2860694775fe8ffcbcdd387f9f97c7a656febf0daa8c73b56f4d8a7bd4c", + "https://deno.land/std@0.224.0/path/_common/constants.ts": "dc5f8057159f4b48cd304eb3027e42f1148cf4df1fb4240774d3492b5d12ac0c", + "https://deno.land/std@0.224.0/path/_common/dirname.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8", + "https://deno.land/std@0.224.0/path/_common/format.ts": "92500e91ea5de21c97f5fe91e178bae62af524b72d5fcd246d6d60ae4bcada8b", + "https://deno.land/std@0.224.0/path/_common/from_file_url.ts": "d672bdeebc11bf80e99bf266f886c70963107bdd31134c4e249eef51133ceccf", + "https://deno.land/std@0.224.0/path/_common/glob_to_reg_exp.ts": "6cac16d5c2dc23af7d66348a7ce430e5de4e70b0eede074bdbcf4903f4374d8d", + "https://deno.land/std@0.224.0/path/_common/normalize.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8", + "https://deno.land/std@0.224.0/path/_common/normalize_string.ts": "33edef773c2a8e242761f731adeb2bd6d683e9c69e4e3d0092985bede74f4ac3", + "https://deno.land/std@0.224.0/path/_common/relative.ts": "faa2753d9b32320ed4ada0733261e3357c186e5705678d9dd08b97527deae607", + "https://deno.land/std@0.224.0/path/_common/strip_trailing_separators.ts": "7024a93447efcdcfeaa9339a98fa63ef9d53de363f1fbe9858970f1bba02655a", + "https://deno.land/std@0.224.0/path/_common/to_file_url.ts": "7f76adbc83ece1bba173e6e98a27c647712cab773d3f8cbe0398b74afc817883", + "https://deno.land/std@0.224.0/path/_interface.ts": "8dfeb930ca4a772c458a8c7bbe1e33216fe91c253411338ad80c5b6fa93ddba0", + "https://deno.land/std@0.224.0/path/_os.ts": "8fb9b90fb6b753bd8c77cfd8a33c2ff6c5f5bc185f50de8ca4ac6a05710b2c15", + "https://deno.land/std@0.224.0/path/basename.ts": "7ee495c2d1ee516ffff48fb9a93267ba928b5a3486b550be73071bc14f8cc63e", + "https://deno.land/std@0.224.0/path/common.ts": "03e52e22882402c986fe97ca3b5bb4263c2aa811c515ce84584b23bac4cc2643", + "https://deno.land/std@0.224.0/path/constants.ts": "0c206169ca104938ede9da48ac952de288f23343304a1c3cb6ec7625e7325f36", + "https://deno.land/std@0.224.0/path/dirname.ts": "85bd955bf31d62c9aafdd7ff561c4b5fb587d11a9a5a45e2b01aedffa4238a7c", + "https://deno.land/std@0.224.0/path/extname.ts": "593303db8ae8c865cbd9ceec6e55d4b9ac5410c1e276bfd3131916591b954441", + "https://deno.land/std@0.224.0/path/format.ts": "6ce1779b0980296cf2bc20d66436b12792102b831fd281ab9eb08fa8a3e6f6ac", + "https://deno.land/std@0.224.0/path/from_file_url.ts": "911833ae4fd10a1c84f6271f36151ab785955849117dc48c6e43b929504ee069", + "https://deno.land/std@0.224.0/path/glob_to_regexp.ts": "7f30f0a21439cadfdae1be1bf370880b415e676097fda584a63ce319053b5972", + "https://deno.land/std@0.224.0/path/is_absolute.ts": "4791afc8bfd0c87f0526eaa616b0d16e7b3ab6a65b62942e50eac68de4ef67d7", + "https://deno.land/std@0.224.0/path/is_glob.ts": "a65f6195d3058c3050ab905705891b412ff942a292bcbaa1a807a74439a14141", + "https://deno.land/std@0.224.0/path/join.ts": "ae2ec5ca44c7e84a235fd532e4a0116bfb1f2368b394db1c4fb75e3c0f26a33a", + "https://deno.land/std@0.224.0/path/join_globs.ts": "5b3bf248b93247194f94fa6947b612ab9d3abd571ca8386cf7789038545e54a0", + "https://deno.land/std@0.224.0/path/mod.ts": "f6bd79cb08be0e604201bc9de41ac9248582699d1b2ee0ab6bc9190d472cf9cd", + "https://deno.land/std@0.224.0/path/normalize.ts": "4155743ccceeed319b350c1e62e931600272fad8ad00c417b91df093867a8352", + "https://deno.land/std@0.224.0/path/normalize_glob.ts": "cc89a77a7d3b1d01053b9dcd59462b75482b11e9068ae6c754b5cf5d794b374f", + "https://deno.land/std@0.224.0/path/parse.ts": "77ad91dcb235a66c6f504df83087ce2a5471e67d79c402014f6e847389108d5a", + "https://deno.land/std@0.224.0/path/posix/_util.ts": "1e3937da30f080bfc99fe45d7ed23c47dd8585c5e473b2d771380d3a6937cf9d", + "https://deno.land/std@0.224.0/path/posix/basename.ts": "d2fa5fbbb1c5a3ab8b9326458a8d4ceac77580961b3739cd5bfd1d3541a3e5f0", + "https://deno.land/std@0.224.0/path/posix/common.ts": "26f60ccc8b2cac3e1613000c23ac5a7d392715d479e5be413473a37903a2b5d4", + "https://deno.land/std@0.224.0/path/posix/constants.ts": "93481efb98cdffa4c719c22a0182b994e5a6aed3047e1962f6c2c75b7592bef1", + "https://deno.land/std@0.224.0/path/posix/dirname.ts": "76cd348ffe92345711409f88d4d8561d8645353ac215c8e9c80140069bf42f00", + "https://deno.land/std@0.224.0/path/posix/extname.ts": "e398c1d9d1908d3756a7ed94199fcd169e79466dd88feffd2f47ce0abf9d61d2", + "https://deno.land/std@0.224.0/path/posix/format.ts": "185e9ee2091a42dd39e2a3b8e4925370ee8407572cee1ae52838aed96310c5c1", + "https://deno.land/std@0.224.0/path/posix/from_file_url.ts": "951aee3a2c46fd0ed488899d024c6352b59154c70552e90885ed0c2ab699bc40", + "https://deno.land/std@0.224.0/path/posix/glob_to_regexp.ts": "76f012fcdb22c04b633f536c0b9644d100861bea36e9da56a94b9c589a742e8f", + "https://deno.land/std@0.224.0/path/posix/is_absolute.ts": "cebe561ad0ae294f0ce0365a1879dcfca8abd872821519b4fcc8d8967f888ede", + "https://deno.land/std@0.224.0/path/posix/is_glob.ts": "8a8b08c08bf731acf2c1232218f1f45a11131bc01de81e5f803450a5914434b9", + "https://deno.land/std@0.224.0/path/posix/join.ts": "7fc2cb3716aa1b863e990baf30b101d768db479e70b7313b4866a088db016f63", + "https://deno.land/std@0.224.0/path/posix/join_globs.ts": "a9475b44645feddceb484ee0498e456f4add112e181cb94042cdc6d47d1cdd25", + "https://deno.land/std@0.224.0/path/posix/mod.ts": "2301fc1c54a28b349e20656f68a85f75befa0ee9b6cd75bfac3da5aca9c3f604", + "https://deno.land/std@0.224.0/path/posix/normalize.ts": "baeb49816a8299f90a0237d214cef46f00ba3e95c0d2ceb74205a6a584b58a91", + "https://deno.land/std@0.224.0/path/posix/normalize_glob.ts": "9c87a829b6c0f445d03b3ecadc14492e2864c3ebb966f4cea41e98326e4435c6", + "https://deno.land/std@0.224.0/path/posix/parse.ts": "09dfad0cae530f93627202f28c1befa78ea6e751f92f478ca2cc3b56be2cbb6a", + "https://deno.land/std@0.224.0/path/posix/relative.ts": "3907d6eda41f0ff723d336125a1ad4349112cd4d48f693859980314d5b9da31c", + "https://deno.land/std@0.224.0/path/posix/resolve.ts": "08b699cfeee10cb6857ccab38fa4b2ec703b0ea33e8e69964f29d02a2d5257cf", + "https://deno.land/std@0.224.0/path/posix/to_file_url.ts": "7aa752ba66a35049e0e4a4be5a0a31ac6b645257d2e031142abb1854de250aaf", + "https://deno.land/std@0.224.0/path/posix/to_namespaced_path.ts": "28b216b3c76f892a4dca9734ff1cc0045d135532bfd9c435ae4858bfa5a2ebf0", + "https://deno.land/std@0.224.0/path/relative.ts": "ab739d727180ed8727e34ed71d976912461d98e2b76de3d3de834c1066667add", + "https://deno.land/std@0.224.0/path/resolve.ts": "a6f977bdb4272e79d8d0ed4333e3d71367cc3926acf15ac271f1d059c8494d8d", + "https://deno.land/std@0.224.0/path/to_file_url.ts": "88f049b769bce411e2d2db5bd9e6fd9a185a5fbd6b9f5ad8f52bef517c4ece1b", + "https://deno.land/std@0.224.0/path/to_namespaced_path.ts": "b706a4103b104cfadc09600a5f838c2ba94dbcdb642344557122dda444526e40", + "https://deno.land/std@0.224.0/path/windows/_util.ts": "d5f47363e5293fced22c984550d5e70e98e266cc3f31769e1710511803d04808", + "https://deno.land/std@0.224.0/path/windows/basename.ts": "6bbc57bac9df2cec43288c8c5334919418d784243a00bc10de67d392ab36d660", + "https://deno.land/std@0.224.0/path/windows/common.ts": "26f60ccc8b2cac3e1613000c23ac5a7d392715d479e5be413473a37903a2b5d4", + "https://deno.land/std@0.224.0/path/windows/constants.ts": "5afaac0a1f67b68b0a380a4ef391bf59feb55856aa8c60dfc01bd3b6abb813f5", + "https://deno.land/std@0.224.0/path/windows/dirname.ts": "33e421be5a5558a1346a48e74c330b8e560be7424ed7684ea03c12c21b627bc9", + "https://deno.land/std@0.224.0/path/windows/extname.ts": "165a61b00d781257fda1e9606a48c78b06815385e7d703232548dbfc95346bef", + "https://deno.land/std@0.224.0/path/windows/format.ts": "bbb5ecf379305b472b1082cd2fdc010e44a0020030414974d6029be9ad52aeb6", + "https://deno.land/std@0.224.0/path/windows/from_file_url.ts": "ced2d587b6dff18f963f269d745c4a599cf82b0c4007356bd957cb4cb52efc01", + "https://deno.land/std@0.224.0/path/windows/glob_to_regexp.ts": "e45f1f89bf3fc36f94ab7b3b9d0026729829fabc486c77f414caebef3b7304f8", + "https://deno.land/std@0.224.0/path/windows/is_absolute.ts": "4a8f6853f8598cf91a835f41abed42112cebab09478b072e4beb00ec81f8ca8a", + "https://deno.land/std@0.224.0/path/windows/is_glob.ts": "8a8b08c08bf731acf2c1232218f1f45a11131bc01de81e5f803450a5914434b9", + "https://deno.land/std@0.224.0/path/windows/join.ts": "8d03530ab89195185103b7da9dfc6327af13eabdcd44c7c63e42e27808f50ecf", + "https://deno.land/std@0.224.0/path/windows/join_globs.ts": "a9475b44645feddceb484ee0498e456f4add112e181cb94042cdc6d47d1cdd25", + "https://deno.land/std@0.224.0/path/windows/mod.ts": "2301fc1c54a28b349e20656f68a85f75befa0ee9b6cd75bfac3da5aca9c3f604", + "https://deno.land/std@0.224.0/path/windows/normalize.ts": "78126170ab917f0ca355a9af9e65ad6bfa5be14d574c5fb09bb1920f52577780", + "https://deno.land/std@0.224.0/path/windows/normalize_glob.ts": "9c87a829b6c0f445d03b3ecadc14492e2864c3ebb966f4cea41e98326e4435c6", + "https://deno.land/std@0.224.0/path/windows/parse.ts": "08804327b0484d18ab4d6781742bf374976de662f8642e62a67e93346e759707", + "https://deno.land/std@0.224.0/path/windows/relative.ts": "3e1abc7977ee6cc0db2730d1f9cb38be87b0ce4806759d271a70e4997fc638d7", + "https://deno.land/std@0.224.0/path/windows/resolve.ts": "8dae1dadfed9d46ff46cc337c9525c0c7d959fb400a6308f34595c45bdca1972", + "https://deno.land/std@0.224.0/path/windows/to_file_url.ts": "40e560ee4854fe5a3d4d12976cef2f4e8914125c81b11f1108e127934ced502e", + "https://deno.land/std@0.224.0/path/windows/to_namespaced_path.ts": "4ffa4fb6fae321448d5fe810b3ca741d84df4d7897e61ee29be961a6aac89a4c", + "https://deno.land/std@0.224.0/streams/byte_slice_stream.ts": "5bbdcadb118390affa9b3d0a0f73ef8e83754f59bb89df349add669dd9369713", + "https://deno.land/std@0.224.0/version.ts": "f6a28c9704d82d1c095988777e30e6172eb674a6570974a0d27a653be769bbbe", + "https://deno.land/x/deno_cron@v1.0.0/cron.ts": "7f984d0c4c7ac4fb1ad3cd241d457e7808a9362735d910abb02dc689883ee3ef", + "https://deno.land/x/zod@v3.22.4/ZodError.ts": "4de18ff525e75a0315f2c12066b77b5c2ae18c7c15ef7df7e165d63536fdf2ea", + "https://deno.land/x/zod@v3.22.4/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef", + "https://deno.land/x/zod@v3.22.4/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe", + "https://deno.land/x/zod@v3.22.4/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c", + "https://deno.land/x/zod@v3.22.4/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7", + "https://deno.land/x/zod@v3.22.4/helpers/parseUtil.ts": "f791e6e65a0340d85ad37d26cd7a3ba67126cd9957eac2b7163162155283abb1", + "https://deno.land/x/zod@v3.22.4/helpers/partialUtil.ts": "998c2fe79795257d4d1cf10361e74492f3b7d852f61057c7c08ac0a46488b7e7", + "https://deno.land/x/zod@v3.22.4/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e", + "https://deno.land/x/zod@v3.22.4/helpers/util.ts": "8baf19b19b2fca8424380367b90364b32503b6b71780269a6e3e67700bb02774", + "https://deno.land/x/zod@v3.22.4/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268", + "https://deno.land/x/zod@v3.22.4/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c", + "https://deno.land/x/zod@v3.22.4/mod.ts": "64e55237cb4410e17d968cd08975566059f27638ebb0b86048031b987ba251c4", + "https://deno.land/x/zod@v3.22.4/types.ts": "724185522fafe43ee56a52333958764c8c8cd6ad4effa27b42651df873fc151e" }, "workspace": { "dependencies": [ diff --git a/deploy-entry.ts b/deploy-entry.ts index 4b485e9..ea438e5 100644 --- a/deploy-entry.ts +++ b/deploy-entry.ts @@ -1,45 +1,45 @@ // Deno Deploy のエントリーポイント -import { serveStatic } from "https://deno.land/x/servest@v1.3.4/mod.ts"; -import { serveDir } from "https://deno.land/std@0.209.0/http/file_server.ts"; -import { addSocket } from "./backend/store/index.ts"; -import { genMsgConnected } from "./wsMsg/msgFromServer.ts"; -import { closeHandler, socketMessageHandler } from "./backend/socketMessageHandler.ts"; +import { serveStatic } from "https://deno.land/x/servest@v1.3.4/mod.ts" +import { serveDir } from "https://deno.land/std@0.209.0/http/file_server.ts" +import { addSocket } from "./backend/store/index.ts" +import { genMsgConnected } from "./wsMsg/msgFromServer.ts" +import { closeHandler, socketMessageHandler } from "./backend/socketMessageHandler.ts" // Deno Deploy では、ビルドされた静的ファイルを含むディレクトリをデプロイする必要があります // 静的ファイルは事前にビルドして、デプロイ時に含める必要があります async function handler(request: Request): Promise { - const { pathname } = new URL(request.url); - + const { pathname } = new URL(request.url) + // WebSocket のハンドリング if (request.headers.get("upgrade") === "websocket") { - const { socket, response } = Deno.upgradeWebSocket(request); + const { socket, response } = Deno.upgradeWebSocket(request) - const userToken = crypto.randomUUID(); + const userToken = crypto.randomUUID() socket.onopen = async () => { - console.log(`CONNECTED: ${userToken}`); - await addSocket(userToken, socket); - socket.send(JSON.stringify(genMsgConnected(userToken))); - }; + console.log(`CONNECTED: ${userToken}`) + await addSocket(userToken, socket) + socket.send(JSON.stringify(genMsgConnected(userToken))) + } socket.onmessage = async (event) => { if (event.data.includes("ping")) { - socket.send("pong"); + socket.send("pong") } else { - await socketMessageHandler(event, socket); + await socketMessageHandler(event, socket) } - }; + } socket.onclose = async () => { - await closeHandler(userToken); - }; - + await closeHandler(userToken) + } + socket.onerror = async (error) => { - console.error("ERROR:", error); - await closeHandler(userToken); - }; + console.error("ERROR:", error) + await closeHandler(userToken) + } - return response; + return response } // 静的ファイルのサーブ @@ -49,19 +49,22 @@ async function handler(request: Request): Promise { pathname.endsWith(".png") ) { // Deno Deploy では、デプロイされたアセットディレクトリから静的ファイルを提供 - return serveDir(request, { fsRoot: "./dist/" }); + return serveDir(request, { fsRoot: "./dist/" }) } - + return new Response("Not found", { status: 404, statusText: "Not found", headers: { "content-type": "text/plain", }, - }); + }) } // サーバー起動 -Deno.serve({ - port: Number(Deno.env.get("PORT")) || 8000, -}, handler); \ No newline at end of file +Deno.serve( + { + port: Number(Deno.env.get("PORT")) || 8000, + }, + handler, +) diff --git "a/docs/KV\347\211\210\345\233\263.md" "b/docs/KV\347\211\210\345\233\263.md" deleted file mode 100644 index a7e9f5c..0000000 --- "a/docs/KV\347\211\210\345\233\263.md" +++ /dev/null @@ -1,356 +0,0 @@ -# 図 - -## 仕組み - -- KV ストアを使用して、ルーム情報やユーザーの状態を管理 -- WebSocket を使用して、リアルタイムでの通知を実現 -- Deno KV の Watch API を使用して、複数インスタンス間でのデータ同期を実現 -- SocketMessageHandler が WebSocket メッセージを処理し、KVRoomStore と連携 -- KVRoomStore がルーム情報を管理し、Deno KV にデータを保存 -- KVSocketStore がソケットインスタンスを管理し、ユーザーに通知 -- SocketMessageHandler がルーム情報をブロードキャスト -- フロントエンド WebSocket がクライアントと通信 -- クライアントは、ルーム作成、参加、回答送信などの操作を行う -- 各インスタンスは、KV ストアの Watch API を使用して、他のインスタンスからの変更通知を受け取る -- ルームの参加者は、他の参加者の状態をリアルタイムで確認できる - -## KV ストアデータスキーマ - -KV ストアには以下のデータが保存されています。各エントリは特定の目的を持ち、アプリケーションの状態管理に使用されます。 - -### 1. ルーム情報 - -| キー | 値 | 説明 | -| ----------------- | ---- | -------------------------------------------------------------- | -| ["rooms", roomId] | Room | ルーム情報オブジェクト。参加者リスト、ルームの状態などを含む。 | - -```typescript -// Roomオブジェクトの構造 -interface Room { - id: RoomId; - participants: { - token: UserToken; - name: string; - answer: string; - }[]; - isOpen: boolean; -} -``` - -### 2. ユーザーとルームの関連付け - -| キー | 値 | 説明 | -| ------------------------- | ------ | ------------------------------------------- | -| ["user_rooms", userToken] | RoomId | 特定のユーザーが現在参加しているルームの ID | - -### 3. ルーム更新タイムスタンプ - -| キー | 値 | 説明 | -| ------------------------ | ---- | -------------------------------------------------------------------------- | -| ["room_updates", roomId] | Date | ルームが最後に更新された日時。アイドル状態のルームのクリーンアップに使用。 | - -### 4. ソケットインスタンスマッピング - -| キー | 値 | 説明 | -| ------------------------------- | ------ | ----------------------------------------------------------------------------------------------- | -| ["socket_instances", userToken] | string | ユーザーが接続しているサーバーインスタンスの ID。複数サーバーインスタンス間での通知制御に使用。 | - -## Watch について - -同一ルームの参加者が別々のサーバーインスタンスに接続することがある。この場合サーバーは別々のメモリを参照することになるため、KV を使用して、ルームの状態を同期する必要がある。 -そこで、KV ストアの Watch API を使用して、ルーム情報の変更を監視する。 - -### Watch が見るもの - -- ルーム情報 - -### Watch を登録するタイミング - -- ルーム作成時 -- ルーム参加時 - -### Watch によって更新を検知するタイミング - -- 回答時 -- メンバーの参加・退出時 - -### Watch の管理 - -- Map で管理 -- ルーム参加時に、既に Watch されている場合は、登録しない -- ルーム退出時に、参加者が 0 人になった場合は、Watch を解除する - -## KVRoomStore のシーケンス図 - -### 1. ルーム作成のフロー - -```mermaid -sequenceDiagram - actor Client - participant FrontendWS as フロントエンドWebSocket - participant SocketMsgHandler as SocketMessageHandler - participant KVRoomStore as KVRoomStore - participant DenoKV as Deno KV - - Client->>FrontendWS: createRoom - FrontendWS->>SocketMsgHandler: {"type": "createRoom", ...} - SocketMsgHandler->>KVRoomStore: createRoom(userToken, userName) - KVRoomStore->>KVRoomStore: ensureKV() - KVRoomStore->>KVRoomStore: roomId = crypto.randomUUID() - KVRoomStore->>DenoKV: atomic.set(['rooms', roomId], room) - KVRoomStore->>DenoKV: atomic.set(['user_rooms', userToken], roomId) - KVRoomStore->>DenoKV: atomic.set(['room_updates', roomId], new Date()) - KVRoomStore->>DenoKV: atomic.commit() - DenoKV-->>KVRoomStore: result.ok - KVRoomStore->>KVRoomStore: watchRoom(roomId) - Note right of KVRoomStore: 作成したルームをWatchに登録、WatchのstreamをMapに格納 - KVRoomStore-->>SocketMsgHandler: room - SocketMsgHandler->>FrontendWS: genMsgRoomCreated - SocketMsgHandler->>SocketMsgHandler: broadcastRoomInfo(room) - FrontendWS-->>Client: ルーム作成完了 -``` - -### 2. ルーム参加のフロー - -```mermaid -sequenceDiagram - actor Client - participant FrontendWS as フロントエンドWebSocket - participant SocketMsgHandler as SocketMessageHandler - participant KVRoomStore as KVRoomStore - participant KVSocketStore as KVSocketStore - participant DenoKV as Deno KV - - Client->>FrontendWS: enterTheRoom - FrontendWS->>SocketMsgHandler: {"type": "enterTheRoom", ...} - SocketMsgHandler->>KVRoomStore: enterTheRoom({roomId, userToken, userName}) - KVRoomStore->>DenoKV: get(['rooms', roomId]) - DenoKV-->>KVRoomStore: room - KVRoomStore->>KVRoomStore: room.participants.push({...}) - KVRoomStore->>KVRoomStore: sortParticipants(room) - KVRoomStore->>DenoKV: atomic.set(['rooms', roomId], room) - KVRoomStore->>DenoKV: atomic.set(['user_rooms', userToken], roomId) - KVRoomStore->>DenoKV: atomic.set(['room_updates', roomId], new Date()) - KVRoomStore->>DenoKV: atomic.commit() - DenoKV-->>KVRoomStore: result.ok - KVRoomStore->>KVRoomStore: checkAndWatchRoom(roomId) - Note right of KVRoomStore: 参加したルームが既にWatchされていなければ登録、WatchのstreamをMapに格納 - KVRoomStore-->>SocketMsgHandler: room - SocketMsgHandler->>SocketMsgHandler: broadcastRoomInfo(room) - - Note over KVRoomStore,DenoKV: KV変更がトリガーされる - DenoKV-->>KVRoomStore: Watch API通知 - KVRoomStore->>KVRoomStore: notifyLocalUsersAboutRoomChange(room) - KVRoomStore->>KVSocketStore: getSocketInstance(participant.token) - KVSocketStore->>DenoKV: get(["socket_instances", userToken]) - DenoKV-->>KVSocketStore: instanceId - KVSocketStore-->>KVRoomStore: instanceId - KVRoomStore->>KVSocketStore: getSocket(participant.token) - KVSocketStore-->>KVRoomStore: socket - KVRoomStore->>FrontendWS: socket.send(roomForClient) - FrontendWS-->>Client: ルーム情報更新 -``` - -### 3. 回答送信のフロー - -```mermaid -sequenceDiagram - actor Client - participant FrontendWS as フロントエンドWebSocket - participant SocketMsgHandler as SocketMessageHandler - participant KVRoomStore as KVRoomStore - participant DenoKV as Deno KV - - Client->>FrontendWS: answer - FrontendWS->>SocketMsgHandler: {"type": "answer", ...} - SocketMsgHandler->>KVRoomStore: answer({roomId, userToken, answer}) - KVRoomStore->>DenoKV: get(['rooms', roomId]) - DenoKV-->>KVRoomStore: room - KVRoomStore->>KVRoomStore: user.answer = answer - KVRoomStore->>KVRoomStore: 全員が回答したか確認 - KVRoomStore->>DenoKV: atomic.set(['rooms', roomId], room) - KVRoomStore->>DenoKV: atomic.set(['room_updates', roomId], new Date()) - KVRoomStore->>DenoKV: atomic.commit() - DenoKV-->>KVRoomStore: result.ok - KVRoomStore-->>SocketMsgHandler: room - SocketMsgHandler->>SocketMsgHandler: broadcastRoomInfo(room) - SocketMsgHandler-->>FrontendWS: 回答送信完了 - FrontendWS-->>Client: 回答送信完了 -``` - -### 4. KV Watch による複数インスタンス間の同期フロー - -以下が起きたとき、当該 room の wacth を登録する - -- そのインスタンスでユーザーが room を作成した -- いずれかのインスタンスでユーザーが room に参加した - -```mermaid -sequenceDiagram - participant Instance1 as サーバーインスタンス1 - participant KV as Deno KV - participant Instance2 as サーバーインスタンス2 - participant Client1A as クライアント1A (roomA参加) - participant Client2A as クライアント2A (roomA参加) - participant Client2B as クライアント2B (roomB参加) - - Note over Instance1,Instance2: 初期化時 - Instance1->>KV: watch({ prefix: ['rooms'] }) - Instance2->>KV: watch({ prefix: ['rooms'] }) - - Note over Client1A,Instance1: Client1AがroomAを更新 - Client1A->>Instance1: roomAに回答を送信 - Instance1->>KV: set(['rooms', roomA-id], updatedRoomA) - Instance1->>Client1A: 直接通知(ローカルインスタンスユーザー) - - Note over KV,Instance2: KV Watch APIがroomAの変更を検知 - KV-->>Instance2: roomAの変更通知 - Instance2->>Instance2: notifyLocalUsersAboutRoomChange(roomA) - - Note right of Instance2: roomAの参加者のみ確認 - Instance2->>KV: get(["socket_instances", participantA.token]) - KV-->>Instance2: "instance2-id" (ローカルインスタンスと一致) - Instance2->>Client2A: socket.send(roomA情報) - - Note right of Instance2: Client2Bは別のルームに所属 - Instance2->>KV: get(["socket_instances", participantB.token]) - KV-->>Instance2: "instance2-id" - Instance2->>Instance2: roomA参加者ではないため通知しない - - Note over Client1A,Client2A: roomAに参加しているユーザーのみが同期される - Note over Client2B: roomBのユーザーには通知されない -``` - -### 5. ユーザー退出のフロー - -```mermaid -sequenceDiagram - actor Client - participant FrontendWS as フロントエンドWebSocket - participant SocketMsgHandler as SocketMessageHandler - participant KVRoomStore as KVRoomStore - participant KVSocketStore as KVSocketStore - participant DenoKV as Deno KV - - alt 退室ボタンによる明示的な退室 - Client->>FrontendWS: exitボタンクリック - FrontendWS->>FrontendWS: sessionStorage.clear() - FrontendWS->>Client: トップページにリダイレクト - Note right of Client: WebSocket接続が閉じられる - FrontendWS-->>SocketMsgHandler: socket.onclose イベント発火 - else ブラウザを閉じるなど - Client->>FrontendWS: WebSocket接続切断 - FrontendWS-->>SocketMsgHandler: socket.onclose イベント発火 - end - - SocketMsgHandler->>SocketMsgHandler: closeHandler(userToken) - SocketMsgHandler->>KVSocketStore: deleteSocket(userToken) - KVSocketStore->>DenoKV: delete(["socket_instances", userToken]) - DenoKV-->>KVSocketStore: 削除完了 - - SocketMsgHandler->>KVRoomStore: exitRoom(userToken) - KVRoomStore->>DenoKV: get(['user_rooms', userToken]) - DenoKV-->>KVRoomStore: roomId - KVRoomStore->>DenoKV: get(['rooms', roomId]) - DenoKV-->>KVRoomStore: room - - KVRoomStore->>KVRoomStore: room.participants.filter(v => v.token !== userToken) - KVRoomStore->>KVRoomStore: sortParticipants(room) - KVRoomStore->>KVRoomStore: 全員が回答したか確認 - - KVRoomStore->>DenoKV: atomic.set(['rooms', roomId], room) - KVRoomStore->>DenoKV: atomic.set(['room_updates', roomId], new Date()) - KVRoomStore->>DenoKV: atomic.delete(['user_rooms', userToken]) - KVRoomStore->>DenoKV: atomic.commit() - DenoKV-->>KVRoomStore: result.ok - - KVRoomStore-->>SocketMsgHandler: 更新後のroom - - Note over KVRoomStore,DenoKV: KV変更がトリガーされる - DenoKV-->>KVRoomStore: Watch API通知 - KVRoomStore->>KVRoomStore: notifyLocalUsersAboutRoomChange(room) - - Note right of KVRoomStore: 残りの参加者に通知 - KVRoomStore->>KVSocketStore: 各参加者のgetSocketInstance(participant.token) - KVSocketStore->>DenoKV: get(["socket_instances", participantToken]) - DenoKV-->>KVSocketStore: instanceId - KVSocketStore-->>KVRoomStore: instanceId - - KVRoomStore->>KVSocketStore: getSocket(participant.token) - KVSocketStore-->>KVRoomStore: socket - KVRoomStore->>FrontendWS: socket.send(roomForClient) - FrontendWS-->>Client: 残っているクライアントに更新通知 - - SocketMsgHandler->>KVRoomStore: closeEmptyRoom() - KVRoomStore->>KVRoomStore: 参加者が0人のルームをチェック - - alt 参加者が0人の場合 - KVRoomStore->>DenoKV: delete(['rooms', roomId]) - KVRoomStore->>DenoKV: delete(['room_updates', roomId]) - DenoKV-->>KVRoomStore: 削除完了 - KVRoomStore->>KVRoomStore: stopWatchingRoom(roomId) - Note right of KVRoomStore: 空のルームを削除、WatchのstreamをMapから削除 - end -``` - -### 6. ルームのクリーンアップ - -```mermaid -sequenceDiagram - participant KVRoomStore as KVRoomStore - participant DenoKV as Deno KV - - Note over KVRoomStore,DenoKV: 定期的に実行されるクリーンアップ処理 - KVRoomStore->>DenoKV: get(['room_updates', roomId]) - DenoKV-->>KVRoomStore: roomUpdates - KVRoomStore->>KVRoomStore: ルームのアイドル状態を確認 - alt アイドル状態の場合 - KVRoomStore->>DenoKV: delete(['rooms', roomId]) - KVRoomStore->>DenoKV: delete(['room_updates', roomId]) - DenoKV-->>KVRoomStore: 削除完了 - KVRoomStore->>KVRoomStore: stopWatchingRoom(roomId) - Note right of KVRoomStore: 空のルームを削除、WatchのstreamをMapから削除 - end -``` - -これらのシーケンス図は、KVRoomStore の主要な処理フローと、複数インスタンス間での同期メカニズムを視覚的に表現しています。特に、KV Watch API を使用した同期の仕組みが重要です。 - -**図の解説:** - -1. **ルーム作成フロー**:ユーザーがルームを作成するときの一連の処理を示しています。KV ストアを使用してルーム情報が保存されます。 - -2. **ルーム参加フロー**:ユーザーがルームに参加する際の処理と、他の参加者への通知を示しています。 - -3. **回答送信フロー**:ユーザーが回答を送信する際の処理を示しています。 - -4. **KV Watch 同期フロー**:異なるサーバーインスタンス間でのデータ同期の仕組みを示しています。KV Watch API を使用して、あるインスタンスの変更が他のインスタンスに伝搬される様子を表現しています。 - -### KV ストアの特徴 - -- **原子的操作**: トランザクションを使用して複数のキーを原子的に更新 -- **分散環境対応**: 複数のサーバーインスタンスでデータを共有 -- **Watch API**: キーの変更を監視して、リアルタイムでの同期を実現 - -### KV ストアの使用パターン - -1. **ルーム作成時**: - - - ルーム情報 - - ユーザーとルームの関連付け - - ルーム更新タイムスタンプの設定 - -2. **ルーム参加時**: - - - 既存のルーム情報を更新 - - 新しいユーザーとルームを関連付け - - ルーム更新タイムスタンプの更新 - -3. **回答送信時**: - - - ルーム情報を更新(ユーザーの回答を保存) - - ルーム更新タイムスタンプの更新 - -4. **ユーザー退出時**: - - ルーム情報を更新(ユーザーを削除) - - ユーザーとルームの関連付けを削除 - - ルーム更新タイムスタンプの更新 diff --git a/llms.md b/docs/llms.md similarity index 61% rename from llms.md rename to docs/llms.md index ad3d658..4a5ffed 100644 --- a/llms.md +++ b/docs/llms.md @@ -1,18 +1,23 @@ ## アプリケーション概要 -このアプリケーションは「PPAP(Planning Poker Application Portable)」です。アジャイル開発でよく使われるプランニングポーカーを実施するためのWebアプリです。ユーザーはルームを作成したり参加したりして、タスクの見積もりを数値(1, 2, 3, 5, 8, 13, 21)で投票できます。 +このアプリケーションは「PPAP(Planning Poker Application +Portable)」です。アジャイル開発でよく使われるプランニングポーカーを実施するための Web +アプリです。ユーザーはルームを作成したり参加したりして、タスクの見積もりを数値(1, 2, 3, 5, 8, 13, +21)で投票できます。 ## 画面構成と主な機能 1. **トップページ(Index.vue)** + - ユーザー名を入力して新しいルームを作成できる - 「create room」ボタンでルーム作成 2. **ルームページ(Room.vue)** + - ルームに参加するためのユーザー名入力画面(初回訪問時) - - URL共有機能(「copy URL」ボタン) + - URL 共有機能(「copy URL」ボタン) - 参加者一覧表示(各参加者のカードと名前) - - 投票機能(1, 2, 3, 5, 8, 13, 21の数字ボタン) + - 投票機能(1, 2, 3, 5, 8, 13, 21 の数字ボタン) - 観戦者(Audience)モード切替 - クリア機能(全員の投票をリセット) - 退出機能(「exit」ボタン) @@ -22,20 +27,13 @@ - 全員が投票するまで結果は非表示(?マークで表示) - 全員の投票完了後、結果が公開される(isOpen=true) -4. **ユーザー状態管理** - - WebSocketで接続状態管理 - - セッションストレージでユーザー名やルームID等を保持 - - 観戦者(Audience)モードでは投票なし - -5. **ルーム管理** - - ルームIDはUUID - - 30分間更新のないルームは自動クローズ - - 参加者が0人になったルームは閉じられる - ## 技術スタック + - フロントエンド: Vue.js, Vue Router, Vite - バックエンド: Deno (v2) ## ルール + ### テスト -- 説明は日本語で書きます。 \ No newline at end of file + +- 説明は日本語で書きます。 diff --git a/docs/todoList.md b/docs/todoList.md new file mode 100644 index 0000000..67c9103 --- /dev/null +++ b/docs/todoList.md @@ -0,0 +1,209 @@ +# バックエンド開発 ToDo リスト + +## フェーズ 1: 基盤・データモデル実装 + +### 1. プロジェクト初期設定 + +- [x] Deno KV 接続の設定 +- [ ] 環境変数設定(開発・本番環境) +- [ ] ログ設定の実装 +- [ ] 基本的なエラーハンドリング構造の作成 + +### 2. データモデル・型定義 + +- [x] KVS データ構造の型定義 + - [x] Room 型の詳細実装 + - [x] UserToken 関連の型定義 + - [x] API リクエスト・レスポンスの型定義 +- [x] 既存`backend/type.ts`との整合性確認・修正 +- [x] バリデーション用スキーマの定義 + +### 3. KVS 基本操作 + +- [x] ルーム情報の CRUD 操作実装 + - [x] ルーム作成(atomic 操作) + - [x] ルーム取得 + - [x] ルーム更新(参加者追加/削除、回答更新) + - [x] ルーム削除 +- [x] UserToken 管理機能 + - [x] UserToken 生成 + - [x] UserToken 検証 + - [x] user_tokens 管理(独立キーか rooms 内管理かの決定) +- [x] アトミック操作の実装とテスト + +## フェーズ 2: REST API 実装 + +### 4. ルーム作成 API + +- [x] `POST /api/rooms` エンドポイント実装 + - [x] リクエストボディ解析・バリデーション + - [x] roomId 生成(UUID v4) + - [x] UserToken 生成 + - [x] 初期ルーム情報を KV に保存 + - [x] レスポンス返却 +- [x] エラーハンドリング(400, 500) + +### 5. ルーム参加 API + +- [x] `POST /api/rooms/:roomId/join` エンドポイント実装 + - [x] リクエストパラメータ・ボディ解析 + - [x] ルーム存在確認 + - [x] UserToken 検証(既存の場合) + - [x] 新規 UserToken 発行(必要な場合) + - [x] 参加者情報の追加・更新(atomic 操作) + - [x] レスポンス返却 +- [x] エラーハンドリング(400, 404, 500) + +### 6. ルーム退出 API + +- [x] `POST /api/rooms/:roomId/leave` エンドポイント実装 + - [x] リクエストボディ解析・バリデーション + - [x] UserToken 検証 + - [x] 参加者情報の削除(atomic 操作) + - [ ] WebSocket 接続の切断処理 + - [x] レスポンス返却 +- [x] navigator.sendBeacon()対応の確認 + +## フェーズ 3: WebSocket 実装 + +### 7. WebSocket 接続処理 + +- [x] `/ws/rooms/:roomId` エンドポイント実装 + - [x] クエリパラメータから UserToken 取得・検証 + - [x] ルーム存在確認 + - [x] WebSocket 接続確立 + - [x] 接続管理(UserToken <-> WebSocket のマッピング) + +### 8. WebSocket メッセージハンドリング + +- [x] クライアントからのメッセージ解析 + - [x] `submit_answer` メッセージ処理(雛形) + - [x] `toggle_spectator` メッセージ処理(雛形) + - [x] `clear_answers` メッセージ処理(雛形) +- [x] メッセージバリデーション(雛形) +- [x] 不正メッセージの対応(雛形) + +### 9. WebSocket 通知機能 + +- [x] サーバーからクライアントへの通知実装 + - [x] `room_update` メッセージ送信(雛形) + - [x] `error` メッセージ送信(雛形) +- [x] 複数クライアントへのブロードキャスト機能(雛形) + +## フェーズ 4: リアルタイム機能・KV 監視 + +### 10. Deno KV Watch 実装 + +- [x] ルーム情報変更の監視設定(雛形) +- [x] watch イベントハンドリング(雛形) +- [x] 変更検知時の WebSocket クライアント通知(雛形) +- [ ] 複数インスタンス対応の考慮 + +### 11. 回答機能 + +- [x] 回答送信処理の実装 + - [x] KVS 回答データ更新(atomic 操作) + - [x] updatedAt 更新 + - [x] 他参加者への通知 +- [x] 回答クリア機能 + - [x] 全回答削除処理(atomic 操作) + - [x] 全参加者への通知 + +### 12. 観覧者モード + +- [x] 観覧者モード切替処理 + - [x] isSpectator フラグ更新(atomic 操作) + - [x] 参加者リスト更新通知(watch 経由) + +## フェーズ 5: セキュリティ・品質向上 + +### 13. セキュリティ対策 + +- [x] 入力値バリデーション強化 + - [x] ルーム名、ユーザー名、回答内容のサニタイズ + - [x] 文字数制限の実装 +- [x] WebSocket セキュリティ + - [x] Origin ヘッダー検証 + - [x] メッセージサイズ・頻度制限 +- [x] エラーメッセージの機密情報除去 + +### 14. エラーハンドリング・ログ + +- [ ] 包括的なエラーハンドリング + - [ ] KV 操作エラーの詳細対応 + - [ ] WebSocket 接続エラーの対応 + - [ ] 書き込み競合の対応 +- [ ] ログ機能の充実 + - [ ] 重要な処理のログ記録 + - [ ] エラーログの詳細化 + - [ ] Deno Deploy ログ機能活用 + +### 15. 自動退出・クリーンアップ + +- [ ] タイムアウト処理 + - [ ] 長時間非アクティブユーザーの自動退出 + - [ ] lastAccessedAt 更新処理 +- [ ] TTL 設定・データクリーンアップ + - [ ] 古いルーム情報の自動削除 + - [ ] 使用されていない UserToken の削除 + +## フェーズ 6: テスト・最適化 + +### 16. テスト実装 + +- [ ] ローカル KV を使用した結合テスト + - [ ] ルーム作成〜参加〜退出の一連フロー + - [ ] WebSocket 通信テスト + - [ ] KV データの直接検証 +- [ ] 単体テスト(必要に応じて) + - [ ] データモデル操作のテスト + - [ ] バリデーション機能のテスト + +### 17. パフォーマンス・運用対応 + +- [ ] 複数インスタンス対応の詳細検討 + - [ ] Deno Queues 利用の検討・実装 + - [ ] クライアント側ポーリングのフォールバック +- [ ] 負荷テスト +- [ ] 監視・メトリクス設定 + +## フェーズ 7: 統合・デプロイ準備 + +### 18. フロントエンド統合 + +- [ ] API エンドポイントの動作確認 +- [ ] WebSocket 通信の動作確認 +- [ ] エラー処理の統合テスト + +### 19. Deno Deploy 対応 + +- [ ] デプロイ設定の確認 +- [ ] 本番環境での動作テスト +- [ ] 環境変数設定 + +--- + +## 実装の優先順位 + +**高優先度(MVP 必須):** + +- フェーズ 1-3: 基本的なルーム作成・参加・WebSocket 通信 +- フェーズ 4 の基本的なリアルタイム機能 + +**中優先度(品質向上):** + +- フェーズ 5: セキュリティ・エラーハンドリング +- フェーズ 6: テスト + +**低優先度(将来的な改善):** + +- 複数インスタンス対応の高度な機能 +- パフォーマンス最適化 + +## 注意事項 + +- 各フェーズの実装中に設計書の不足情報・検討事項を随時決定する +- ユーザー名重複の扱い、user_tokens 管理方法などは実装中に確定 +- テストは実装と並行して進める +- 技術的制約(複数インスタンス問題)は初期は watch()のみで開始し、必要に応じて Deno Queues + 対応を検討 diff --git "a/docs/\350\250\255\350\250\210.md" "b/docs/\350\250\255\350\250\210.md" new file mode 100644 index 0000000..ea737e9 --- /dev/null +++ "b/docs/\350\250\255\350\250\210.md" @@ -0,0 +1,323 @@ +# 設計 + +## 概要 + +- Deno Deploy でホストする +- フロントエンドは Vue.js +- バックエンドは Deno (v2) +- 入退室は REST API で通信 +- 回答は WebSocket で通信 +- データベースとして Deno KV を使用 + +## 技術的制約 + +- Deno Deploy でホストする。Deno Deploy + ではユーザーの最も近くのエッジで実行されるため、同一ルームのユーザーが同一インスタンスに接続されるとは限らない。 + - 回答があった場合、WebSocket + で接続されているユーザー全員に通知する必要があるが、別インスタンスで更新された情報をきちんと通知するためには、Deno + KV を watch して、各インスタンスで情報の更新を監視し、その上で接続ユーザーに通知する必要がある。 + +## ユーザーストーリー + +- 新しいルームを作成できる +- 既存のルームに参加できる +- ルーム内で回答を送信できる +- ルーム内で観覧者になれる +- ルーム内の他のユーザーの回答をリアルタイムで受信できる +- ルーム内の他のユーザーの入退室をリアルタイムで受信できる +- リロードしてもルームに再参加できる +- タブを閉じるとルームから退室する + +## バックエンド + +### やること + +#### REST API + +- ルーム作成を受け付け、その時点でのルーム情報を返す +- ルーム参加を受け付け、その時点でのルーム情報を返す +- ルームからの退出を受け付ける + +#### WebSocket + +- ルーム作成・参加時に接続を確立する +- ルーム情報が更新されたときに通知する + - KV の変更を監視 +- ユーザーの回答を受け付け、ルーム情報を更新する +- 観覧者モードの切り替えを受け付ける +- ルームの回答をクリアする + +### KVS 設計 + +Deno KV +を使用して、アプリケーションの状態を永続化し、リアルタイムな情報共有を実現します。「リロードしてもルームに再参加できる」というユーザーストーリーに対応するため、クライアントはサーバーから発行された +`UserToken` を保持し、リロード時にそれを利用して復帰します。 + +#### 1. キー構造と役割 + +KVS のキーは、以下のプレフィックスと ID を組み合わせて構成します。 + +- `rooms:` + - **役割**: 特定のルームに関する全ての情報を格納します。ルーム名、参加者リスト(各参加者は + `UserToken` で識別)、現在の回答状況などが含まれます。 + - **例**: `rooms:abcdef123456` +- `user_tokens:` + - **役割**: サーバーが発行した `UserToken` + が現在どのルームに参加しているか、最後にアクティブだったのはいつか、などの情報を保持します。これは主に、リロード時の復帰処理や、長時間応答がない場合の自動退出処理の判断材料として使用します。 + - **例**: `user_tokens:unique_user_token_string` + +> **2025-06-04 設計更新:** UserToken +> は必ず`user_tokens:`として独立管理し、ルーム情報(`rooms:`)の`participants`には +> UserToken の参照のみを持つ。UserToken 情報の重複管理は避ける。 + +#### 2. データモデル (Value の構造例) + +##### a. ルーム情報 (`rooms:`) + +ルームの ID (``) をキーとし、以下の情報を持つ JSON オブジェクトを格納します。 + +```json +{ + "id": "string", // ルームID (キーと重複するが、データ内にも保持) + "participants": { + // key: UserToken (サーバーが発行し、クライアントが保持・再利用するトークン) + // value: 参加者情報オブジェクト + // 例: + // "user_token_alice": { + // "token": "string", // UserToken + // "name": "string", // ユーザー名 + // "isSpectator": "boolean", // 観覧者モードか否か + // "joinedAt": "timestamp" // 参加日時 + // } + }, + "answers": { + // key: UserToken + // value: 回答内容 (例: 文字列) + // 例: + // "user_token_alice": "回答内容A" + }, + "config": { + // ルーム固有の設定など (将来的な拡張用) + "allowSpectators": "boolean", // デフォルトtrue + "maxParticipants": "number" // デフォルト مثلا 50 + }, + "createdAt": "timestamp", // ルーム作成日時 + "updatedAt": "timestamp" // 最終更新日時 (参加者の入退室、回答の更新など) +} +``` + +**考慮事項:** + +- `participants` と `answers` のキーには、サーバーが発行する `UserToken` を使用します。 +- `updatedAt` はルーム情報に何らかの変更があった場合に更新し、これを `Deno.Kv.watch()` + で監視することで、リアルタイム更新のトリガーとします。 + +##### b. ユーザートークン情報 (`user_tokens:`)(必須) + +`UserToken` をキーとし、以下の情報を持つ JSON +オブジェクトを格納します。この情報は、ルーム情報と重複しないよう、セッション管理・復帰・自動退出判定などに利用します。 + +```json +{ + "token": "string", // UserToken (キーと重複) + "currentRoomId": "string | null", // 現在参加中のルームID、参加していなければnull + "name": "string", // ユーザー名 + "isSpectator": "boolean", // 観覧者モード + "lastAccessedAt": "timestamp" // TTL設定や自動退出処理の判断材料 +} +``` + +**考慮事項:** + +- ルーム情報の`participants`には UserToken + の参照のみを持ち、ユーザー名や観覧者フラグ等は`user_tokens`側で一元管理する。 +- リロード時や復帰時は`user_tokens`を参照して状態を復元する。 +- タブを閉じた際の退出処理や自動退出判定も`user_tokens`の`lastAccessedAt`を利用する。 + +#### 3. 操作と整合性 + +- **アトミック操作**: 参加者の追加/削除、回答の送信など、ルーム情報を変更する操作は + `Deno.Kv.atomic()` を使用してデータの整合性を保ちます。 +- **ID 生成**: + - `roomId`: ルーム作成時にサーバーサイドで UUID v4 などを利用して生成します。 + - `UserToken`: ユーザーがルームに初めて参加する際、またはリロード後に有効な `UserToken` + を持っていない場合に、サーバーサイドで UUID v4 + などを利用して生成し、クライアントに返します。クライアントはこれを `sessionStorage` または + `localStorage` に保存し、リロード時や再接続時にサーバーに送信します。 +- **データの監視と通知**: + - 特定ルームのキー (`rooms:`) の変更を `Deno.Kv.watch()` で監視します。 + - 変更が検知されたら、そのルームに参加している WebSocket + クライアントに最新のルーム情報を送信します。 + - **技術的制約への対応**: Deno Deploy では各インスタンスが独立して KV + を監視します。`Deno.Kv.watch()` + は同一プロセス内の変更を効率的に検知できますが、別インスタンスによる変更の即時検知と全接続クライアントへの通知には、Deno + Queues + を利用したブロードキャスト機構の導入や、クライアント側での定期的なポーリング(フォールバックとして)などを検討する可能性があります。まずは + `watch` を基本とし、必要に応じて高度な仕組みを検討します。 + +#### 4. 不足情報・検討事項 + +現時点での設計で、以下の点は明確化が必要です。実際に実装を進める中で具体化していきます。 + +- **ユーザー名の重複**: + 同一ルーム内でのユーザー名の重複を許容するか、あるいは何らかの形でユニークにするか(例: `Alice`, + `Alice (2)`)。現状は重複を許容する想定です。 +- **回答の更新/削除**: + 現在の設計では回答は追記・上書きのみを想定していますが、個別の回答を削除したり、更新履歴を持たせたりする要件がある場合は、`answers` + のデータ構造や操作を詳細化する必要があります。 +- **エラーハンドリングの詳細**: KV 操作時のエラー(キーが存在しない、書き込み競合など)、無効な + `UserToken` での復帰試行などにどう対応するか。 +- **データの有効期限 (TTL)**: アクティブでないルーム情報や `UserToken` + 情報を自動的にクリーンアップするための TTL 設定。`user_tokens` の `lastAccessedAt` + はそのための情報の一つです。 +- **`UserToken` の管理**: `user_tokens` を独立したキー構造で持つか、各ルームの `participants` + 内で関連情報 (例: `lastAccessedAt`) を管理するか、どちらが効率的か検討が必要です。 + +### API エンドポイント設計 + +#### REST API + +- **ルーム作成**: + - パス: `/api/rooms` + - メソッド: `POST` + - レスポンスボディ (成功時): `{ "roomId": "string", "userToken": "string", "room": Room }` (Room + は KVS のルーム情報) + - レスポンスボディ (エラー時): `{ "error": "string" }` + - ステータスコード: 成功時 `201 Created`, エラー時 `400 Bad Request` / `500 Internal Server Error` +- **ルーム参加**: + - パス: `/api/rooms/:roomId/join` + - メソッド: `POST` + - リクエストボディ: `{ "userName": "string", "userToken": "string" }` + (`userToken`はリロード時にクライアントが保持していれば送信) + - レスポンスボディ (成功時): `{ "userToken": "string", "room": Room }` + (新しい`userToken`が発行された場合はそれを返す) + - レスポンスボディ (エラー時): `{ "error": "string" }` (例: ルームが存在しない、満員) + - ステータスコード: 成功時 `200 OK`, エラー時 `400 Bad Request` / `404 Not Found` / + `500 Internal Server Error` +- **ルームからの退出**: + - パス: `/api/rooms/:roomId/leave` + - メソッド: `POST` (または `DELETE`) + - リクエストボディ: `{ "userToken": "string" }` + - レスポンスボディ (成功時): `{ "message": "Successfully left the room" }` + - レスポンスボディ (エラー時): `{ "error": "string" }` + - ステータスコード: 成功時 `200 OK`, エラー時 `400 Bad Request` / `500 Internal Server Error` + - 備考: `navigator.sendBeacon()` での呼び出しも考慮。 + +#### WebSocket + +- **接続エンドポイント**: + - パス: `/ws/rooms/:roomId?userToken=` +- **メッセージ形式**: JSON +- **クライアントからサーバーへのメッセージ**: + - 回答送信: `{ "type": "submit_answer", "payload": { "answer": "string" } }` + - 観覧者モード切替: `{ "type": "toggle_spectator", "payload": { "isSpectator": "boolean" } }` + - 回答クリア: `{ "type": "clear_answers" }` +- **サーバーからクライアントへのメッセージ**: + - ルーム情報更新: `{ "type": "room_update", "payload": Room }` (Room は最新のルーム情報) + - エラー通知: `{ "type": "error", "payload": { "message": "string" } }` + +### 認証・認可 + +- `UserToken` はクライアントのセッション(リロードを跨ぐ程度)を識別するために使用する。 +- `UserToken` + の寿命はルームの生存期間に依存し、比較的短いため、厳密な認証トークンとしての扱いはしない。 +- ルームへのアクセスは、有効な `roomId` を知っており、かつルームがアクティブ(例: + 参加者がいる、最近更新された)である場合に許可される。 +- `UserToken` + は、特定のルーム内でのユーザーの操作(回答、観覧モード変更など)を識別するために使用する。 + +### エラーハンドリング + +- **REST API**: + - リクエスト不正 (バリデーションエラー等): `400 Bad Request` と共にエラー詳細を JSON で返す。 + - リソースが見つからない (存在しないルーム ID 等): `404 Not Found`。 + - サーバー内部エラー: `500 Internal Server Error` と共に汎用的なエラーメッセージを返す。 +- **WebSocket**: + - 不正なメッセージ形式や操作: エラーメッセージを WebSocket + 経由で送信し、必要に応じて接続を閉じる。 + - サーバー内部エラー: エラーメッセージを送信し、接続を維持するか閉じるかはエラーの性質による。 +- **ログ**: + - 重要な処理の開始・終了、エラー発生時には詳細なログ(タイムスタンプ、リクエスト情報、エラー内容など)を記録する。 + - Deno Deploy のロギング機能を利用する。 + +### 詳細処理フロー + +- **ルーム作成**: + 1. クライアントからルーム作成リクエストを受信。 + 2. サーバーは新しい `roomId` (UUID v4) を生成。 + 3. 新しい `UserToken` (UUID v4) を生成。 + 4. 初期ルーム情報 (参加者としてリクエスト元ユーザーを追加) を KVS に `rooms:` で保存 + (`atomic` 操作)。 + 5. 必要であれば `user_tokens:` に情報を保存 (検討中)。 + 6. クライアントに `roomId`, `userToken`, 初期ルーム情報を返す。 +- **ルーム参加**: + 1. クライアントからルーム参加リクエスト (`roomId`, ユーザー名、既存の `userToken` (あれば)) + を受信。 + 2. `roomId` で KVS からルーム情報を取得。存在しなければエラー。 + 3. `userToken` がリクエストに含まれていれば、そのトークンが有効か(例: `user_tokens` + に存在し、`currentRoomId` が一致するか、または `participants` に存在するか)を確認。 + - 有効であればその `UserToken` を使用。必要に応じてユーザー名や状態を更新。 + - 無効または提供されていない場合は新しい `UserToken` を発行。 + 4. ルーム情報に参加者としてユーザーを追加/更新 (`atomic` 操作)。`updatedAt` を更新。 + 5. クライアントに `userToken` (新規発行または既存) と最新のルーム情報を返す。 + 6. WebSocket 接続を確立。 +- **回答送信**: + 1. クライアント (WebSocket) から回答情報 (`UserToken` は接続に紐づく) を受信。 + 2. KVS の該当ルームの `answers` に回答を保存、`participants` の該当ユーザーの回答情報も更新 + (`atomic` 操作)。`updatedAt` を更新。 + 3. (KV の watch 経由で) ルーム参加者全員に最新のルーム情報を送信。 +- **ユーザー退出 (API 経由)**: + 1. クライアントから退出リクエスト (`roomId`, `userToken`) を受信。 + 2. KVS のルーム情報から該当 `UserToken` の参加者を削除 (`atomic` 操作)。`updatedAt` を更新。 + 3. 必要であれば `user_tokens:` から情報を削除または更新 (検討中)。 + 4. (KV の watch 経由で) 残りのルーム参加者に最新のルーム情報を送信。 + 5. 該当ユーザーの WebSocket 接続があれば切断。 +- **リロード時の復帰処理**: + 1. クライアントは `localStorage` / `sessionStorage` から `userToken` と `roomId` を取得。 + 2. 通常のルーム参加フローと同様に、`/api/rooms/:roomId/join` に `userToken` を含めてリクエスト。 + 3. サーバーは `userToken` を検証し、既存参加者として復帰させるか、新規参加者 + (または新規トークンでの参加) として扱うかを判断。 + +### Deno KV の具体的な利用方法 + +- **アトミック操作 (`atomic()`)**: + - ルーム作成時の初期データ書き込み。 + - 参加者の追加/削除とルームの `updatedAt` 更新。 + - 回答の追加/更新とルームの `updatedAt` 更新。 + - 観覧者モードの変更とルームの `updatedAt` 更新。 + - 回答クリアとルームの `updatedAt` 更新。 +- **データの監視 (`watch()`)**: + - 各 Deno Deploy インスタンスは、自身が担当するルーム (WebSocket 接続を保持しているルーム) の + `rooms:` キーを `Deno.Kv.watch()` で監視する。 + - 変更が検知されたら、最新のルーム情報を取得し、該当ルームの全 WebSocket クライアントに送信する。 +- **Deno Queues**: + - 技術的制約への対応として検討。現時点では `watch()` を基本とし、Deno Deploy + の複数インスタンス間でのリアルタイム性に問題が生じた場合に、Deno Queues + を利用したブロードキャスト機構(例: あるインスタンスでの KV + 更新をトリガーにキューにメッセージを送信し、全インスタンスがそのキューをリッスンして WebSocket + クライアントに通知する)の導入を検討する。 + +### セキュリティ考慮事項 + +- **入力値のバリデーション**: + - ユーザー名、回答内容など、クライアントからの入力値は長さ、使用可能文字などを検証し、XSS + などの攻撃を防ぐ。 + - Deno 標準ライブラリやサードパーティライブラリを利用したサニタイズ処理を検討。 +- **WebSocket**: + - 接続時に `Origin` ヘッダーを検証し、意図しないドメインからの接続を拒否することを検討。 + - メッセージサイズや頻度に制限を設け、DoS 攻撃のリスクを軽減することを検討。 +- **その他**: + - エラーメッセージに機密情報(スタックトレース等)が含まれないようにする。 + +### 環境変数・設定管理 + +- ログレベルや外部サービスのエンドポイントなど、環境によって変更が必要な設定は環境変数経由で読み込む。 +- Deno Deploy の環境変数設定機能を利用する。 +- 開発環境では `.env` ファイルなどを使用して環境変数を管理することを検討。 + +### テスト方針 + +- ローカル環境で Deno KV を実際に動作させ、API リクエストから KV のデータ変更、WebSocket + 経由での通知までを一貫してテストする結合テストを主軸とする。 +- テスト時には KV のデータを直接参照・検証し、期待通りの状態変化が起きているかを確認する。 +- 必要に応じて、特定のロジック単位での単体テストも追加する。 diff --git a/e2e/helpers.ts b/e2e/helpers.ts deleted file mode 100644 index 8573f4f..0000000 --- a/e2e/helpers.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { chromium, firefox, webkit, Browser, BrowserContext, Page } from "playwright" - -// E2Eテスト用のヘルパー関数 -export async function setupBrowser(browserName: string = 'chromium'): Promise { - const browsers = { - chromium, - firefox, - webkit, - } - - // 環境変数 HEADLESS が "false" の場合はブラウザを表示モードで起動 - // デフォルトはヘッドレスモード(true) - const isHeadless = Deno.env.get("HEADLESS") !== "false"; - const slowMo = isHeadless ? 0 : 100; // ヘッドレスでない場合は操作を少し遅くする - - console.log(`ブラウザを${isHeadless ? "ヘッドレス" : "表示"}モードで起動します`); - - const browser = await browsers[browserName as keyof typeof browsers].launch({ - headless: isHeadless, - slowMo: slowMo, - }) - return browser -} - -export async function createPage(browser: Browser, url: string = 'http://localhost:8000'): Promise<{ context: BrowserContext, page: Page }> { - const context = await browser.newContext() - const page = await context.newPage() - await page.goto(url) - return { context, page } -} - -// サーバーが起動しているかチェックする関数 -export async function isServerRunning(url: string = 'http://localhost:8000'): Promise { - try { - const res = await fetch(url) - const result = res.status === 200 - res.body?.cancel() - return result - } catch (_e) { - return false - } -} - -// KVストアをクリーンアップする関数 -export async function cleanupKVStore(): Promise { - // KVモードでない場合は何もしない - if (Deno.env.get("USE_KV_STORE") !== "true") { - return; - } - - console.log("KVストアをクリーンアップしています..."); - let kv: Deno.Kv | null = null; - try { - kv = await Deno.openKv(); - - // rooms, user_rooms, room_updates, socket_instancesのエントリをすべて削除 - const keysToDelete = [ - { prefix: ["rooms"] }, - { prefix: ["user_rooms"] }, - { prefix: ["room_updates"] }, - { prefix: ["socket_instances"] }, - ]; - - for (const keyPattern of keysToDelete) { - const entries = kv.list(keyPattern); - const batch = []; - - for await (const entry of entries) { - batch.push(entry.key); - // バッチサイズが10になったら削除操作を実行 - if (batch.length >= 10) { - const atomicOp = kv.atomic(); - for (const key of batch) { - atomicOp.delete(key); - } - await atomicOp.commit(); - batch.length = 0; - } - } - - // 残りのエントリを削除 - if (batch.length > 0) { - const atomicOp = kv.atomic(); - for (const key of batch) { - atomicOp.delete(key); - } - await atomicOp.commit(); - } - } - - console.log("KVストアのクリーンアップが完了しました"); - } catch (error) { - console.error("KVストアのクリーンアップ中にエラーが発生しました:", error); - } finally { - // 重要: KVインスタンスをクローズ - if (kv) { - kv.close(); - } - } -} \ No newline at end of file diff --git a/e2e/index.test.ts b/e2e/index.test.ts index e8d1f6c..c66020a 100644 --- a/e2e/index.test.ts +++ b/e2e/index.test.ts @@ -1,8 +1,8 @@ import { Browser as _Browser } from "playwright" import { assertEquals, assertExists } from "std/assert/mod.ts" -import { setupBrowser, createPage, isServerRunning, cleanupKVStore } from "./helpers.ts" +import { cleanupKVStore, createPage, isServerRunning, setupBrowser } from "./helpers.ts" // Testing Libraryをインポート -import { queries, getDocument } from "playwright-testing-library" +import { getDocument, queries } from "playwright-testing-library" // テストをグループ化してリソースを適切に管理 Deno.test("トップページのテスト", async (t) => { @@ -10,80 +10,90 @@ Deno.test("トップページのテスト", async (t) => { await t.step("サーバー接続確認", async () => { const server = await isServerRunning() if (!server) { - throw new Error("テスト実行前にサーバーを起動してください: deno task serve") + throw new Error( + "テスト実行前にサーバーを起動してください: deno task serve", + ) } - }); - + }) + // テスト開始前にKVストアをクリーンアップ - await cleanupKVStore(); - + await cleanupKVStore() + // テスト用のブラウザを一度だけ起動 const browser = await setupBrowser() - + // 各テストケースを実行 await t.step("トップページが正しく表示されること", async () => { const { page, context: _context } = await createPage(browser) - + try { // Testing Libraryを使用してドキュメントを取得 const $document = await getDocument(page) - + // トップページのタイトルが表示されているか確認 const $heading = await queries.getByText($document, "Create Room") assertEquals(await $heading.textContent(), "Create Room") - + // 名前入力フォームが表示されているか確認 (label属性を使用) const $nameInput = await queries.getByLabelText($document, "Your Name") assertExists($nameInput) - + // ボタンが表示されているか確認 - const $createButton = await queries.getByRole($document, "button", { name: "create room" }) + const $createButton = await queries.getByRole($document, "button", { + name: "create room", + }) assertExists($createButton) } finally { // 必ずページとコンテキストをクローズ await page.close() await _context.close() } - }); + }) await t.step("ユーザー名を入力してルームを作成できること", async () => { const { page, context: _context } = await createPage(browser) - + try { // Testing Libraryを使用してドキュメントを取得 const $document = await getDocument(page) - + // 名前を入力 const $nameInput = await queries.getByLabelText($document, "Your Name") await $nameInput.fill("TestUser") - + // フォームを送信 - const $createButton = await queries.getByRole($document, "button", { name: "create room" }) + const $createButton = await queries.getByRole($document, "button", { + name: "create room", + }) await $createButton.click() - + // URLが変わるのを待つ (ルームに遷移したことを確認) await page.waitForURL(/\/#\/[a-z0-9\-]+/) - + // 新しいドキュメントを取得(ページ遷移後) const $newDocument = await getDocument(page) - + // ルームページに移動したことを確認 - const $roomTitle = await queries.getByRole($newDocument, "heading", { name: "Room" }) + const $roomTitle = await queries.getByRole($newDocument, "heading", { + name: "Room", + }) assertEquals(await $roomTitle.textContent(), "Room") - + // コピーURLボタンが表示されているか確認 - const $copyUrlButton = await queries.getByRole($newDocument, "button", { name: "copy URL" }) + const $copyUrlButton = await queries.getByRole($newDocument, "button", { + name: "copy URL", + }) assertExists($copyUrlButton) } finally { // 必ずページとコンテキストをクローズ await page.close() await _context.close() } - }); - + }) + // テスト完了後にブラウザを閉じる await browser.close() - + // テスト完了後にKVストアをクリーンアップ - await cleanupKVStore(); -}); \ No newline at end of file + await cleanupKVStore() +}) diff --git a/e2e/kv-cleanup.test.ts b/e2e/kv-cleanup.test.ts deleted file mode 100644 index 0193d14..0000000 --- a/e2e/kv-cleanup.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { assertEquals, assertNotEquals } from "https://deno.land/std@0.209.0/assert/mod.ts"; -import { cleanupOldKvRecords } from "../backend/kvCleanupJob.ts"; -import { Room } from "../backend/type.ts"; - -// テスト用の一時的なKVパスを設定 -const tempKvPath = `./temp-kv-${Date.now()}.sqlite`; - -Deno.test("KV Cleanup Job E2E Test", async (t) => { - // テスト用のKVインスタンスを作成 - const kv = await Deno.openKv(tempKvPath); - - try { - await t.step("古いレコードが削除され、新しいレコードは残ること", async () => { - // テストデータ(古いルーム)の作成 - const oldRoomId = "old-room-" + crypto.randomUUID(); - const oldRoom: Room = { - id: oldRoomId, - participants: [{ - token: "old-user-token", - name: "Old User", - answer: "", - }], - isOpen: false, - }; - - // テストデータ(新しいルーム)の作成 - const newRoomId = "new-room-" + crypto.randomUUID(); - const newRoom: Room = { - id: newRoomId, - participants: [{ - token: "new-user-token", - name: "New User", - answer: "", - }], - isOpen: false, - }; - - // 古いデータをKVに保存(更新日時を過去に設定) - await kv.set(["rooms", oldRoomId], oldRoom); - await kv.set(["user_rooms", "old-user-token"], oldRoomId); - // 古いルームは30秒前に更新されたことにする - const oldDate = new Date(Date.now() - 30000); - await kv.set(["room_updates", oldRoomId], oldDate); - await kv.set(["socket_instances", "old-user-token"], "instance-id"); - - // 新しいデータをKVに保存(更新日時を現在に設定) - await kv.set(["rooms", newRoomId], newRoom); - await kv.set(["user_rooms", "new-user-token"], newRoomId); - await kv.set(["room_updates", newRoomId], new Date()); - await kv.set(["socket_instances", "new-user-token"], "instance-id"); - - // KVに正しく保存されたことを確認 - const oldRoomCheck = await kv.get(["rooms", oldRoomId]); - const newRoomCheck = await kv.get(["rooms", newRoomId]); - - assertEquals(oldRoomCheck.value?.id, oldRoomId, "古いルームが正しく保存されていない"); - assertEquals(newRoomCheck.value?.id, newRoomId, "新しいルームが正しく保存されていない"); - - // 孤立したソケットインスタンスも作成 - await kv.set(["socket_instances", "orphaned-socket"], "instance-id"); - - // cleanupジョブを実行(閾値を20秒に設定) - const result = await cleanupOldKvRecords({ - thresholdMs: 20000, // 20秒以上前のデータは「古い」と判定 - kvInstance: kv - }); - - // 削除されたレコード数の確認 - assertEquals(result?.cleanedRooms, 1, "古いルームが1つ削除されるべき"); - assertEquals(result?.cleanedSockets, 1, "孤立したソケットが1つ削除されるべき"); - - // 古いデータが削除されたことを確認 - const oldRoomAfter = await kv.get(["rooms", oldRoomId]); - const oldUserRoomAfter = await kv.get(["user_rooms", "old-user-token"]); - const oldRoomUpdatesAfter = await kv.get(["room_updates", oldRoomId]); - const oldSocketAfter = await kv.get(["socket_instances", "old-user-token"]); - - assertEquals(oldRoomAfter.value, null, "古いルームが削除されていない"); - assertEquals(oldUserRoomAfter.value, null, "古いユーザールーム関連が削除されていない"); - assertEquals(oldRoomUpdatesAfter.value, null, "古いルーム更新情報が削除されていない"); - assertEquals(oldSocketAfter.value, null, "古いソケットインスタンスが削除されていない"); - - // 孤立したソケットインスタンスが削除されたことを確認 - const orphanedSocketAfter = await kv.get(["socket_instances", "orphaned-socket"]); - assertEquals(orphanedSocketAfter.value, null, "孤立したソケットインスタンスが削除されていない"); - - // 新しいデータが残っていることを確認 - const newRoomAfter = await kv.get(["rooms", newRoomId]); - const newUserRoomAfter = await kv.get(["user_rooms", "new-user-token"]); - const newRoomUpdatesAfter = await kv.get(["room_updates", newRoomId]); - const newSocketAfter = await kv.get(["socket_instances", "new-user-token"]); - - assertNotEquals(newRoomAfter.value, null, "新しいルームが誤って削除された"); - assertNotEquals(newUserRoomAfter.value, null, "新しいユーザールーム関連が誤って削除された"); - assertNotEquals(newRoomUpdatesAfter.value, null, "新しいルーム更新情報が誤って削除された"); - assertNotEquals(newSocketAfter.value, null, "新しいソケットインスタンスが誤って削除された"); - }); - } finally { - // テスト用KVをクローズ - kv.close(); - - // テスト用のKVファイルを削除(クリーンアップ) - try { - await Deno.remove(tempKvPath); - } catch (e) { - console.warn("一時KVファイルの削除に失敗しました:", e); - } - } -}); \ No newline at end of file diff --git a/e2e/room.test.ts b/e2e/room.test.ts index f34d5a8..f5a2ee7 100644 --- a/e2e/room.test.ts +++ b/e2e/room.test.ts @@ -1,8 +1,8 @@ import { Browser as _Browser } from "playwright" import { assertEquals, assertExists } from "std/assert/mod.ts" -import { setupBrowser, createPage, isServerRunning, cleanupKVStore } from "./helpers.ts" +import { cleanupKVStore, createPage, isServerRunning, setupBrowser } from "./helpers.ts" // Testing Libraryをインポート(存在しないインポートを削除) -import { queries, getDocument } from "playwright-testing-library" +import { getDocument, queries } from "playwright-testing-library" // テストをグループ化してリソースを適切に管理 Deno.test("ルーム機能のテスト", async (t) => { @@ -10,227 +10,249 @@ Deno.test("ルーム機能のテスト", async (t) => { await t.step("サーバー接続確認", async () => { const server = await isServerRunning() if (!server) { - throw new Error("テスト実行前にサーバーを起動してください: deno task serve") + throw new Error( + "テスト実行前にサーバーを起動してください: deno task serve", + ) } - }); - + }) + // テスト開始前にKVストアをクリーンアップ - await cleanupKVStore(); - + await cleanupKVStore() + // テスト用のブラウザを一度だけ起動 const browser = await setupBrowser() let roomUrl = "" - + // 最初にルームを作成 await t.step("テスト用のルームを作成", async () => { const { page, context: _context } = await createPage(browser) - + try { - console.log("トップページにアクセスしました"); - + console.log("トップページにアクセスしました") + // Testing Libraryを使って名前入力と送信 const $document = await getDocument(page) const $nameInput = await queries.getByRole($document, "textbox") await $nameInput.fill("HostUser") const $createButton = await queries.getByRole($document, "button") await $createButton.click() - - console.log("ホストユーザーとしてルームを作成しました"); - + + console.log("ホストユーザーとしてルームを作成しました") + // URLが変わるのを待つ (ルームに遷移したことを確認) await page.waitForURL(/\/#\/[a-z0-9\-]+/) - console.log("ルームページに移動しました"); - + console.log("ルームページに移動しました") + // URLを取得 (Copy URLボタンを押した場合のURLと同等) roomUrl = page.url() - console.log("ルームURL:", roomUrl); - + console.log("ルームURL:", roomUrl) + // Copy URLボタンが表示されるのを待つ const $newDocument = await getDocument(page) await queries.getByRole($newDocument, "button", { name: /copy URL/i }) - console.log("Copy URLボタンが表示されました"); - + console.log("Copy URLボタンが表示されました") + // ルームを作成したら画面は閉じずに、このページをホストとして維持する } finally { // ページもコンテキストも閉じない - ルームを維持するため } - }); - + }) + // 別のユーザーがルームに参加できることを確認 await t.step("別のユーザーがルームに参加できること", async () => { - console.log("ゲストユーザーとして参加を開始します"); - console.log("使用するルームURL:", roomUrl); - + console.log("ゲストユーザーとして参加を開始します") + console.log("使用するルームURL:", roomUrl) + // 2人目のユーザーは、先ほど取得したURLを使用してアクセス - const { page: guestPage, context: guestContext } = await createPage(browser, roomUrl) - + const { page: guestPage, context: guestContext } = await createPage( + browser, + roomUrl, + ) + try { // 名前入力フォームが表示されているか確認 - console.log("名前入力フォーム確認中..."); + console.log("名前入力フォーム確認中...") const $document = await getDocument(guestPage) // timeoutオプションを削除し、代わりにwaitForを使用 const $nameInput = await queries.getByRole($document, "textbox") // 入力フィールドが表示されるまで待機 await guestPage.waitForTimeout(5000) - console.log("名前入力フォームが見つかりました"); - + console.log("名前入力フォームが見つかりました") + // 名前を入力して参加 await $nameInput.fill("GuestUser") const $joinButton = await queries.getByRole($document, "button") await $joinButton.click() - console.log("ゲストユーザーとして名前を入力し、参加しました"); - + console.log("ゲストユーザーとして名前を入力し、参加しました") + // ルームに参加していることを確認(投票オプションが表示されている) - console.log("投票オプションを待機中..."); + console.log("投票オプションを待機中...") const $newDocument = await getDocument(guestPage) // timeoutオプションを削除し、別の方法で待機 - const $voteOption = await queries.getByRole($newDocument, "button", { name: "1" }) + const $voteOption = await queries.getByRole($newDocument, "button", { + name: "1", + }) assertExists($voteOption, "投票オプションが表示されているか") await guestPage.waitForTimeout(5000) - console.log("投票オプションが表示されました"); - + console.log("投票オプションが表示されました") + // 少し待機して参加者情報が更新されるのを待つ - console.log("参加者情報の更新を待機中..."); - await guestPage.waitForTimeout(3000); - + console.log("参加者情報の更新を待機中...") + await guestPage.waitForTimeout(3000) + // 参加者カードが表示されるのを待機中 - console.log("参加者カードが表示されるのを待機中..."); + console.log("参加者カードが表示されるのを待機中...") const $articlesDocument = await getDocument(guestPage) - const $articles = await queries.getAllByRole($articlesDocument, "article") - + const $articles = await queries.getAllByRole( + $articlesDocument, + "article", + ) + // 参加者数を確認 - console.log("参加者数を確認します"); - console.log(`参加者数: ${$articles.length}`); - + console.log("参加者数を確認します") + console.log(`参加者数: ${$articles.length}`) + // 検証: 参加者数は2人(ホストとゲスト) - assertEquals($articles.length, 2, "参加者数は2人(ホストとゲスト)であるべきです"); - console.log("参加者数の検証に成功しました"); - + assertEquals( + $articles.length, + 2, + "参加者数は2人(ホストとゲスト)であるべきです", + ) + console.log("参加者数の検証に成功しました") } finally { // ゲストユーザーのページとコンテキストを閉じる - await guestPage.close(); - await guestContext.close(); + await guestPage.close() + await guestContext.close() } - }); + }) // 投票機能のテスト await t.step("投票機能が正しく動作すること", async () => { const { page, context: _context } = await createPage(browser, roomUrl) - + try { // 名前入力がまだ必要な場合は入力 let $document = await getDocument(page) const $nameInput = await queries.queryByRole($document, "textbox") - + if ($nameInput) { await $nameInput.fill("VoteUser") const $joinButton = await queries.getByRole($document, "button") await $joinButton.click() } - + // Testing Libraryを使用して投票オプションが表示されるまで待機 $document = await getDocument(page) const $voteButton = await queries.getByText($document, "8") - + // 投票する(「8」を選択) await $voteButton.click() - + // 投票後に少し待機して状態の変化を確認できるようにする - await page.waitForTimeout(1000); + await page.waitForTimeout(1000) // テストではボタンが選択されていることを検証 // evalauteの型エラーを修正 - const buttonElement = await $voteButton.evaluate(el => { + const buttonElement = await $voteButton.evaluate((el) => { // HTMLElement型にキャストしてからclassList操作 - return (el as HTMLElement).classList.contains('-translate-y-3'); - }); - - assertEquals(buttonElement, true, "ボタンが選択状態になっているか"); + return (el as HTMLElement).classList.contains("-translate-y-3") + }) + + assertEquals(buttonElement, true, "ボタンが選択状態になっているか") } finally { // 必ずページとコンテキストをクローズ await page.close() await _context.close() } - }); + }) // 観戦者モードのテスト await t.step("観戦者モードに切り替えられること", async () => { const { page, context: _context } = await createPage(browser, roomUrl) - + try { // 名前入力がまだ必要な場合は入力 let $document = await getDocument(page) const $nameInput = await queries.queryByRole($document, "textbox") - + if ($nameInput) { await $nameInput.fill("AudienceUser") - const $joinButton = await queries.getByRole($document, "button") + const $joinButton = await queries.getByRole($document, "button") await $joinButton.click() } - + // 少し待機してUIが更新されるのを確認 - await page.waitForTimeout(1000); - + await page.waitForTimeout(1000) + // Testing Libraryを使用してチェックボックスを探して選択 $document = await getDocument(page) - const $checkbox = await queries.getByLabelText($document, "I'm an audience.") + const $checkbox = await queries.getByLabelText( + $document, + "I'm an audience.", + ) await $checkbox.click() - + // チェックボックスが選択されていることを確認 // 型エラーを修正 - const isChecked = await $checkbox.evaluate(el => { + const isChecked = await $checkbox.evaluate((el) => { // 明示的に型アサーションを追加 - return (el as HTMLInputElement).checked; + return (el as HTMLInputElement).checked }) assertEquals(isChecked, true) - + // 少し待機してUIが更新されるのを確認 - await page.waitForTimeout(1000); - + await page.waitForTimeout(1000) + // Audienceテキストが含まれる要素があるかを確認 - const audienceTextExists = await queries.queryByText($document, "Audience") !== null - assertEquals(audienceTextExists, true, "Audienceテキストが画面上に表示されているか"); + const audienceTextExists = (await queries.queryByText($document, "Audience")) !== null + assertEquals( + audienceTextExists, + true, + "Audienceテキストが画面上に表示されているか", + ) } finally { // 必ずページとコンテキストをクローズ await page.close() await _context.close() } - }); + }) // クリア機能のテスト await t.step("投票をクリアできること", async () => { const { page, context: _context } = await createPage(browser, roomUrl) - + try { // 名前入力がまだ必要な場合は入力 let $document = await getDocument(page) const $nameInput = await queries.queryByRole($document, "textbox") - + if ($nameInput) { await $nameInput.fill("ClearUser") const $joinButton = await queries.getByRole($document, "button") await $joinButton.click() } - + // 投票オプションが表示されるまで待機 $document = await getDocument(page) const $voteButton = await queries.getByText($document, "5") - + // 投票する(「5」を選択) await $voteButton.click() - + // クリアボタンをクリック - const $clearButton = await queries.getByRole($document, "button", { name: "clear" }) + const $clearButton = await queries.getByRole($document, "button", { + name: "clear", + }) await $clearButton.click() - + // クリア後は投票が消えていることを確認(選択状態のクラスが消えている) await page.waitForTimeout(1000) // クリア処理の完了を待つ - + // ボタンの状態を再確認 // evalauteの型エラーを修正 - const isNotSelected = await $voteButton.evaluate(el => { + const isNotSelected = await $voteButton.evaluate((el) => { // HTMLElementとして扱う - return !(el as HTMLElement).classList.contains('-translate-y-3') + return !(el as HTMLElement).classList.contains("-translate-y-3") }) assertEquals(isNotSelected, true) } finally { @@ -238,11 +260,11 @@ Deno.test("ルーム機能のテスト", async (t) => { await page.close() await _context.close() } - }); - + }) + // テスト完了後にブラウザを閉じる - await browser.close(); - + await browser.close() + // テスト完了後にKVストアをクリーンアップ - await cleanupKVStore(); -}); \ No newline at end of file + await cleanupKVStore() +}) diff --git a/heroku_build.ts b/heroku_build.ts deleted file mode 100644 index 81ca79a..0000000 --- a/heroku_build.ts +++ /dev/null @@ -1,4 +0,0 @@ -import $ from "https://deno.land/x/dax@0.36.0/mod.ts" - -const result = await $`deno task build` -console.log(result.code) diff --git a/index.html b/index.html index c9520bd..729c5b1 100644 --- a/index.html +++ b/index.html @@ -21,13 +21,9 @@ -

Planning Poker App Portable

+

Planning Poker App Portable

-