From 5a7fa06338b2520fd86170f39bb9ac8eeb07ad52 Mon Sep 17 00:00:00 2001 From: p_craft Date: Sun, 1 Jun 2025 22:57:53 +0900 Subject: [PATCH 01/43] =?UTF-8?q?=E3=82=B5=E3=83=BC=E3=83=90=E3=83=BC?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E6=B6=88=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/kvCleanupJob.ts | 97 ----- backend/server.ts | 42 -- backend/socketMessageHandler.ts | 119 ------ backend/store/index.ts | 78 ---- backend/store/interfaces.ts | 29 -- backend/store/kv/kvRoomStore.ts | 492 ---------------------- backend/store/kv/kvSocketStore.ts | 133 ------ backend/store/memory/memoryRoomStore.ts | 161 ------- backend/store/memory/memorySocketStore.ts | 29 -- backend/store/rooms.ts | 146 ------- backend/store/sockets.ts | 10 - backend/store/storeManager.ts | 63 --- deploy-entry.ts | 67 --- heroku_build.ts | 4 - src/composables/webSocket.ts | 44 +- wsMsg/msgFromClient.ts | 44 +- wsMsg/msgFromServer.ts | 26 +- 17 files changed, 5 insertions(+), 1579 deletions(-) delete mode 100644 backend/kvCleanupJob.ts delete mode 100644 backend/socketMessageHandler.ts delete mode 100644 backend/store/index.ts delete mode 100644 backend/store/interfaces.ts delete mode 100644 backend/store/kv/kvRoomStore.ts delete mode 100644 backend/store/kv/kvSocketStore.ts delete mode 100644 backend/store/memory/memoryRoomStore.ts delete mode 100644 backend/store/memory/memorySocketStore.ts delete mode 100644 backend/store/rooms.ts delete mode 100644 backend/store/sockets.ts delete mode 100644 backend/store/storeManager.ts delete mode 100644 deploy-entry.ts delete mode 100644 heroku_build.ts diff --git a/backend/kvCleanupJob.ts b/backend/kvCleanupJob.ts deleted file mode 100644 index d6a5c09..0000000 --- a/backend/kvCleanupJob.ts +++ /dev/null @@ -1,97 +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 { - // 古いルームを rooms list から直接チェック (room_updates を廃止) - const roomEntries = kv.list({ prefix: ["rooms"] }) - const oldRooms: Array<{ roomId: string; participants: Room["participants"] }> = [] - for await (const entry of roomEntries) { - const [, roomId] = entry.key as [string, string] - const room = entry.value as Room - // 最終更新から指定時間以上経過しているか確認 - if (now - room.updatedAt.getTime() > thresholdMs) { - oldRooms.push({ roomId, participants: room.participants }) - } - } - - // 古いルームを削除 - for (const { roomId, participants } of oldRooms) { - // トランザクションの準備 - const atomicOp = kv.atomic() - - // ルームに所属するユーザーの関連データを削除 - for (const p of participants) { - atomicOp.delete(["user_rooms", p.token]) - atomicOp.delete(["socket_instances", p.token]) - } - - // ルーム自体を削除 - atomicOp.delete(["rooms", roomId]) - // 互換性: room_updates エントリも削除 - atomicOp.delete(["room_updates", roomId]) - - await atomicOp.commit() - cleanedRooms++ - } - - // 古いsocket_instancesエントリを削除 - // socket_instances 削除:user_rooms を一度 list して Set 化し、個別 get を避ける - const userRoomEntries = kv.list({ prefix: ["user_rooms"] }) - const activeUserTokens = new Set() - for await (const ur of userRoomEntries) { - activeUserTokens.add(ur.key[1] as string) - } - const socketEntries = kv.list({ prefix: ["socket_instances"] }) - const socketsToDelete: string[] = [] - for await (const entry of socketEntries) { - const userToken = entry.key[1] as string - // Set になければ孤立とみなす - if (!activeUserTokens.has(userToken)) { - 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() - } - } -} diff --git a/backend/server.ts b/backend/server.ts index 2760bf7..93c6fbf 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -1,49 +1,7 @@ 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" - -// 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) - } - } - - socket.onclose = async () => { - await closeHandler(userToken) - } - socket.onerror = async (error) => { - console.error("ERROR:", error) - await closeHandler(userToken) - } - - return Promise.resolve(response) - } - if ( pathname === "/" || pathname.startsWith("/assets") || diff --git a/backend/socketMessageHandler.ts b/backend/socketMessageHandler.ts deleted file mode 100644 index 1ab132a..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 c32cd49..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() -} diff --git a/backend/store/interfaces.ts b/backend/store/interfaces.ts deleted file mode 100644 index 71a37cc..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 -} diff --git a/backend/store/kv/kvRoomStore.ts b/backend/store/kv/kvRoomStore.ts deleted file mode 100644 index 5ea9490..0000000 --- a/backend/store/kv/kvRoomStore.ts +++ /dev/null @@ -1,492 +0,0 @@ -import { Room, RoomForClientSide, RoomId, 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< - RoomId, - { - stream: ReadableStream[]> - 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 stopWatchingRoom(roomId: RoomId): void { - 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 - console.log( - `Notifying local participants for room ${room.id.slice(0, 6)}...`, - ) - - for (const participant of room.participants) { - try { - const socket = socketStore.getSocket(participant.token) - if (socket) { - if (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( - `Sent room update to local user ${ - participant.token.slice( - 0, - 6, - ) - }...`, - ) - } else { - console.log( - `Cleaning up stale socket for user ${ - participant.token.slice( - 0, - 6, - ) - }...`, - ) - await socketStore.deleteSocket(participant.token) - } - } - } 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: ["rooms"] }) - const idleRoomIds: RoomId[] = [] - - for await (const entry of entries) { - const [, roomId] = entry.key as [string, RoomId] - const room = entry.value - if (!room) continue - if (now - room.updatedAt.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 nowDate = new Date() - const room: Room = { - id: roomId, - participants: [ - { - token: userToken, - name: userName, - answer: "", - }, - ], - isOpen: false, - updatedAt: nowDate, - } - - // トランザクションで原子的に更新 - const atomicOp = this.kv!.atomic() - atomicOp.set(["rooms", roomId], room) - atomicOp.set(["user_rooms", userToken], roomId) - - 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) - // タイムスタンプ更新 - room.updatedAt = new Date() - - // トランザクションで原子的に更新 - const atomicOp = this.kv!.atomic() - atomicOp.set(["rooms", roomId], room) - atomicOp.set(["user_rooms", userToken], roomId) - - 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 - } - - // 更新日時をセット - room.updatedAt = new Date() - // KVに更新を保存 - const atomicOp = this.kv!.atomic() - atomicOp.set(["rooms", roomId], room) - - 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 - - // 更新日時をセット - room.updatedAt = new Date() - // KVに更新を保存 - const atomicOp = this.kv!.atomic() - atomicOp.set(["rooms", roomId], room) - - 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 - } - - // 更新日時をセット - room.updatedAt = new Date() - // トランザクションで原子的に更新 - const atomicOp = this.kv!.atomic() - atomicOp.set(["rooms", roomId], room) - 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]) - - 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]) - - 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 98cf968..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) - } - } -} diff --git a/backend/store/memory/memoryRoomStore.ts b/backend/store/memory/memoryRoomStore.ts deleted file mode 100644 index a251960..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 - }) - } -} diff --git a/backend/store/memory/memorySocketStore.ts b/backend/store/memory/memorySocketStore.ts deleted file mode 100644 index f86d5d2..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() - } -} 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 b6728c9..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 -} diff --git a/deploy-entry.ts b/deploy-entry.ts deleted file mode 100644 index 06d4e50..0000000 --- a/deploy-entry.ts +++ /dev/null @@ -1,67 +0,0 @@ -// 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" - -// Deno Deploy では、ビルドされた静的ファイルを含むディレクトリをデプロイする必要があります -// 静的ファイルは事前にビルドして、デプロイ時に含める必要があります - -async function handler(request: Request): Promise { - const { pathname } = new URL(request.url) - - // WebSocket のハンドリング - 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) - } - } - - socket.onclose = async () => { - await closeHandler(userToken) - } - - socket.onerror = async (error) => { - console.error("ERROR:", error) - await closeHandler(userToken) - } - - return response - } - - // 静的ファイルのサーブ - if ( - pathname === "/" || - pathname.startsWith("/assets") || - pathname.endsWith(".png") - ) { - // Deno Deploy では、デプロイされたアセットディレクトリから静的ファイルを提供 - 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) 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/src/composables/webSocket.ts b/src/composables/webSocket.ts index 7ba4ba0..516b1e1 100644 --- a/src/composables/webSocket.ts +++ b/src/composables/webSocket.ts @@ -3,13 +3,7 @@ import { isMsgFromServer } from "@/wsMsg/msgFromServer.ts" import { room, setName, setRoom, setRoomId, setToken, user } from "./store.ts" import router from "@/src/router/router.ts" import { ref, watch } from "vue" -import { - genMsgAnswer, - genMsgClearAnswer, - genMsgCreateRoom, - genMsgEnterTheRoom, - genMsgIsExistTheRoom, -} from "../../wsMsg/msgFromClient.ts" +import { genMsgAnswer, genMsgClearAnswer } from "../../wsMsg/msgFromClient.ts" import { RoomId } from "../../backend/type.ts" const protocol = location.protocol === "https:" ? "wss:" : "ws:" @@ -41,21 +35,6 @@ const msgHandler = (data: string | null) => { setToken(msg.userToken) break } - case "roomCreated": { - setRoomId(msg.roomId) - - sessionStorage.setItem("roomId", msg.roomId) - sessionStorage.setItem("userName", user.name.value) - router.push(`/${msg.roomId}`) - break - } - case "isExistTheRoomResult": { - if (!msg.isExistTheRoom) { - sessionStorage.clear() - router.push("/") - } - break - } case "roomInfo": { setRoom(msg.room) break @@ -67,27 +46,6 @@ const msgHandler = (data: string | null) => { // ==================== -export const sendCreateRoom = (name: string) => { - setName(name) - isRoomCreator.value = true - webSocket.send( - JSON.stringify(genMsgCreateRoom(user.token.value, user.name.value)), - ) -} -export const sendIsExistTheRoom = (roomId: RoomId) => { - webSocket.send(JSON.stringify(genMsgIsExistTheRoom(roomId))) -} -export const sendEnterTheRoom = (roomId: RoomId, userName: string) => { - webSocket.send( - JSON.stringify( - genMsgEnterTheRoom({ - roomId, - userToken: user.token.value, - userName: userName, - }), - ), - ) -} export const sendAnswer = (answer: string) => { webSocket.send( JSON.stringify( diff --git a/wsMsg/msgFromClient.ts b/wsMsg/msgFromClient.ts index 3471e11..73af118 100644 --- a/wsMsg/msgFromClient.ts +++ b/wsMsg/msgFromClient.ts @@ -8,28 +8,8 @@ const msgType = { clearAnswer: "clearAnswer", } as const -type MsgFromClient = - | MsgCreateRoom - | MsgIsExistTheRoom - | MsgEnterTheRoom - | MsgAnswer - | MsgClearAnswer +type MsgFromClient = MsgAnswer | MsgClearAnswer -type MsgCreateRoom = { - type: (typeof msgType)["createRoom"] - userToken: UserToken - userName: string -} -type MsgIsExistTheRoom = { - type: (typeof msgType)["isExistTheRoom"] - roomId: RoomId -} -type MsgEnterTheRoom = { - type: (typeof msgType)["enterTheRoom"] - userToken: UserToken - userName: string - roomId: RoomId -} type MsgAnswer = { type: (typeof msgType)["answer"] userToken: UserToken @@ -59,28 +39,6 @@ export const isMsgFromClient = (data: unknown): data is MsgFromClient => { // ===================== -export const genMsgCreateRoom = ( - userToken: UserToken, - userName: string, -): MsgFromClient => ({ - type: "createRoom", - userToken, - userName, -}) -export const genMsgIsExistTheRoom = (roomId: RoomId): MsgIsExistTheRoom => ({ - type: "isExistTheRoom", - roomId, -}) -export const genMsgEnterTheRoom = ({ - userToken, - userName, - roomId, -}: Omit): MsgEnterTheRoom => ({ - type: "enterTheRoom", - userToken, - userName, - roomId, -}) export const genMsgAnswer = ({ userToken, roomId, diff --git a/wsMsg/msgFromServer.ts b/wsMsg/msgFromServer.ts index acca0f0..32fa70a 100644 --- a/wsMsg/msgFromServer.ts +++ b/wsMsg/msgFromServer.ts @@ -1,10 +1,6 @@ import { RoomForClientSide, RoomId, UserToken } from "@/backend/type.ts" -export type MsgFromServer = - | MsgConnected - | MsgRoomCreated - | MsgIsExistTheRoomResult - | MsgRoomInfo +export type MsgFromServer = MsgConnected | MsgRoomInfo const _msgType = { connected: "connected", @@ -16,14 +12,7 @@ type MsgConnected = { type: (typeof _msgType)["connected"] userToken: string } -type MsgRoomCreated = { - type: (typeof _msgType)["roomCreated"] - roomId: string -} -type MsgIsExistTheRoomResult = { - type: (typeof _msgType)["isExistTheRoomResult"] - isExistTheRoom: boolean -} + type MsgRoomInfo = { type: (typeof _msgType)["roomInfo"] room: RoomForClientSide @@ -49,16 +38,7 @@ export const genMsgConnected = (userToken: UserToken): MsgConnected => ({ type: "connected", userToken, }) -export const genMsgRoomCreated = (roomId: RoomId): MsgRoomCreated => ({ - type: "roomCreated", - roomId, -}) -export const genMsgIsExistTheRoomResult = ( - isExistTheRoom: boolean, -): MsgIsExistTheRoomResult => ({ - type: "isExistTheRoomResult", - isExistTheRoom, -}) + export const genMsgRoomInfo = (room: RoomForClientSide): MsgRoomInfo => ({ type: "roomInfo", room, From 2a01c25060317b142218d3074bac38c579d39245 Mon Sep 17 00:00:00 2001 From: p_craft Date: Sun, 1 Jun 2025 23:00:39 +0900 Subject: [PATCH 02/43] =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=82=82?= =?UTF-8?q?=E6=B6=88=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deno.json | 13 - "docs/KV\347\211\210\345\233\263.md" | 362 --------------------------- e2e/helpers.ts | 158 ------------ e2e/index.test.ts | 88 ------- e2e/kv-cleanup.test.ts | 120 --------- e2e/room.test.ts | 246 ------------------ 6 files changed, 987 deletions(-) delete mode 100644 "docs/KV\347\211\210\345\233\263.md" delete mode 100644 e2e/helpers.ts delete mode 100644 e2e/index.test.ts delete mode 100644 e2e/kv-cleanup.test.ts delete mode 100644 e2e/room.test.ts diff --git a/deno.json b/deno.json index 2da4413..20b0226 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", "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" }, "imports": { "vue": "npm:vue@^3.5.13", 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 adf2352..0000000 --- "a/docs/KV\347\211\210\345\233\263.md" +++ /dev/null @@ -1,362 +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/e2e/helpers.ts b/e2e/helpers.ts deleted file mode 100644 index 4212b6a..0000000 --- a/e2e/helpers.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { Browser, BrowserContext, chromium, firefox, Page, webkit } from "playwright" - -// サーバー起動用プロセスハンドル -let serverProcess: Deno.ChildProcess | null = null - -/** - * テスト用サーバーを自動起動 - */ -export async function startServer(): Promise { - if (serverProcess) return - // Deno 実行ファイルパスを使って直接サーバーを起動 - serverProcess = new Deno.Command(Deno.execPath(), { - args: [ - "run", - "--allow-net", - "--allow-read", - "--allow-env", - // cronジョブを使わない場合は flags を外す - ...[], - "backend/server.ts", - ], - stdout: "null", - stderr: "null", - }).spawn() - // サーバー応答可能になるまで待機 - const maxRetries = 20 - for (let i = 0; i < maxRetries; i++) { - try { - const res = await fetch("http://localhost:8000") - // 不要なレスポンスボディをキャンセルしてリークを防ぐ - res.body?.cancel() - if (res.ok) return - } catch (_e) { - // 応答なし - } - await new Promise((r) => setTimeout(r, 500)) - } - throw new Error("サーバー起動に失敗しました") -} - -/** - * テスト用サーバーを停止 - */ -export async function stopServer(): Promise { - try { - if (!serverProcess) return - // サーバーを終了 - serverProcess.kill("SIGTERM") - // 終了を待機 (statusはプロパティのPromise) - await serverProcess.status - } catch { - // kill or status時のエラーは無視 - } finally { - serverProcess = null - } -} - -// 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() - } - } -} diff --git a/e2e/index.test.ts b/e2e/index.test.ts deleted file mode 100644 index 1e93447..0000000 --- a/e2e/index.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Browser as _Browser } from "playwright" -import { assertEquals, assertExists } from "std/assert/mod.ts" -import { cleanupKVStore, createPage, setupBrowser, startServer, stopServer } from "./helpers.ts" -// Testing Libraryをインポート -import { getDocument, queries } from "playwright-testing-library" - -// テストをグループ化してリソースを適切に管理 -Deno.test("トップページのテスト", async (t) => { - // テスト開始前にサーバーを自動起動 - await t.step("サーバー起動", async () => { - await startServer() - }) - - // テスト開始前にKVストアをクリーンアップ - 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" }) - 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" }) - await $createButton.click() - - // URLが変わるのを待つ (ルームに遷移したことを確認) - await page.waitForURL(/\/#\/[a-z0-9\-]+/) - - // 新しいドキュメントを取得(ページ遷移後) - const $newDocument = await getDocument(page) - - // ルームページに移動したことを確認 - 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" }) - assertExists($copyUrlButton) - } finally { - // 必ずページとコンテキストをクローズ - await page.close() - await _context.close() - } - }) - - // テスト完了後にブラウザを閉じる - await browser.close() - - // テスト完了後にKVストアをクリーンアップ - await cleanupKVStore() - // サーバー停止 - await stopServer() -}) diff --git a/e2e/kv-cleanup.test.ts b/e2e/kv-cleanup.test.ts deleted file mode 100644 index b92ac12..0000000 --- a/e2e/kv-cleanup.test.ts +++ /dev/null @@ -1,120 +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` - -// Skip test if Deno.openKv is unavailable (requires unstable flag) -Deno.test({ - name: "KV Cleanup Job E2E Test", - ignore: typeof Deno.openKv !== "function", -}, async (t) => { - // テスト用のKVインスタンスを作成 - const kv = await Deno.openKv(tempKvPath) - - try { - await t.step("古いレコードが削除され、新しいレコードは残ること", async () => { - // テストデータ(古いルーム)の作成 - const oldRoomId = "old-room-" + crypto.randomUUID() - // 古い更新日時を設定(30秒前) - const oldDate = new Date(Date.now() - 30000) - const oldRoom: Room = { - id: oldRoomId, - participants: [{ - token: "old-user-token", - name: "Old User", - answer: "", - }], - isOpen: false, - updatedAt: oldDate, - } - - // テストデータ(新しいルーム)の作成 - const newRoomId = "new-room-" + crypto.randomUUID() - const newRoom: Room = { - id: newRoomId, - participants: [{ - token: "new-user-token", - name: "New User", - answer: "", - }], - isOpen: false, - updatedAt: new Date(), - } - - // 古いデータをKVに保存(更新日時を過去に設定) - await kv.set(["rooms", oldRoomId], oldRoom) - await kv.set(["user_rooms", "old-user-token"], oldRoomId) - // 古いルームは30秒前に更新されたことにする - 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) - } - } -}) diff --git a/e2e/room.test.ts b/e2e/room.test.ts deleted file mode 100644 index 4951dbf..0000000 --- a/e2e/room.test.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { Browser as _Browser } from "playwright" -import { assertEquals, assertExists } from "std/assert/mod.ts" -import { cleanupKVStore, createPage, setupBrowser, startServer, stopServer } from "./helpers.ts" -// Testing Libraryをインポート(存在しないインポートを削除) -import { getDocument, queries } from "playwright-testing-library" - -// テストをグループ化してリソースを適切に管理 -Deno.test("ルーム機能のテスト", async (t) => { - // テスト開始前にサーバーを自動起動 - await t.step("サーバー起動", async () => { - await startServer() - }) - - // テスト開始前にKVストアをクリーンアップ - await cleanupKVStore() - - // テスト用のブラウザを一度だけ起動 - const browser = await setupBrowser() - let roomUrl = "" - - // 最初にルームを作成 - await t.step("テスト用のルームを作成", async () => { - const { page, context: _context } = await createPage(browser) - - try { - 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("ホストユーザーとしてルームを作成しました") - - // URLが変わるのを待つ (ルームに遷移したことを確認) - await page.waitForURL(/\/#\/[a-z0-9\-]+/) - console.log("ルームページに移動しました") - - // URLを取得 (Copy URLボタンを押した場合のURLと同等) - roomUrl = page.url() - console.log("ルームURL:", roomUrl) - - // Copy URLボタンが表示されるのを待つ - const $newDocument = await getDocument(page) - await queries.getByRole($newDocument, "button", { name: /copy URL/i }) - console.log("Copy URLボタンが表示されました") - - // ルームを作成したら画面は閉じずに、このページをホストとして維持する - } finally { - // ページもコンテキストも閉じない - ルームを維持するため - } - }) - - // 別のユーザーがルームに参加できることを確認 - await t.step("別のユーザーがルームに参加できること", async () => { - console.log("ゲストユーザーとして参加を開始します") - console.log("使用するルームURL:", roomUrl) - - // 2人目のユーザーは、先ほど取得したURLを使用してアクセス - const { page: guestPage, context: guestContext } = await createPage(browser, roomUrl) - - try { - // 名前入力フォームが表示されているか確認 - console.log("名前入力フォーム確認中...") - const $document = await getDocument(guestPage) - // timeoutオプションを削除し、代わりにwaitForを使用 - const $nameInput = await queries.getByRole($document, "textbox") - // 入力フィールドが表示されるまで待機 - await guestPage.waitForTimeout(5000) - console.log("名前入力フォームが見つかりました") - - // 名前を入力して参加 - await $nameInput.fill("GuestUser") - const $joinButton = await queries.getByRole($document, "button") - await $joinButton.click() - console.log("ゲストユーザーとして名前を入力し、参加しました") - - // ルームに参加していることを確認(投票オプションが表示されている) - console.log("投票オプションを待機中...") - const $newDocument = await getDocument(guestPage) - // timeoutオプションを削除し、別の方法で待機 - const $voteOption = await queries.getByRole($newDocument, "button", { name: "1" }) - assertExists($voteOption, "投票オプションが表示されているか") - await guestPage.waitForTimeout(5000) - console.log("投票オプションが表示されました") - - // 少し待機して参加者情報が更新されるのを待つ - console.log("参加者情報の更新を待機中...") - await guestPage.waitForTimeout(3000) - - // 参加者カードが表示されるのを待機中 - console.log("参加者カードが表示されるのを待機中...") - const $articlesDocument = await getDocument(guestPage) - const $articles = await queries.getAllByRole($articlesDocument, "article") - - // 参加者数を確認 - console.log("参加者数を確認します") - console.log(`参加者数: ${$articles.length}`) - - // 検証: 参加者数は2人(ホストとゲスト) - assertEquals($articles.length, 2, "参加者数は2人(ホストとゲスト)であるべきです") - console.log("参加者数の検証に成功しました") - } finally { - // ゲストユーザーのページとコンテキストを閉じる - 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) - - // テストではボタンが選択されていることを検証 - // evalauteの型エラーを修正 - const buttonElement = await $voteButton.evaluate((el) => { - // HTMLElement型にキャストしてからclassList操作 - 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") - await $joinButton.click() - } - - // 少し待機してUIが更新されるのを確認 - await page.waitForTimeout(1000) - - // Testing Libraryを使用してチェックボックスを探して選択 - $document = await getDocument(page) - const $checkbox = await queries.getByLabelText($document, "I'm an audience.") - await $checkbox.click() - - // チェックボックスが選択されていることを確認 - // 型エラーを修正 - const isChecked = await $checkbox.evaluate((el) => { - // 明示的に型アサーションを追加 - return (el as HTMLInputElement).checked - }) - assertEquals(isChecked, true) - - // 少し待機してUIが更新されるのを確認 - await page.waitForTimeout(1000) - - // 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" }) - await $clearButton.click() - - // クリア後は投票が消えていることを確認(選択状態のクラスが消えている) - await page.waitForTimeout(1000) // クリア処理の完了を待つ - - // ボタンの状態を再確認 - // evalauteの型エラーを修正 - const isNotSelected = await $voteButton.evaluate((el) => { - // HTMLElementとして扱う - return !(el as HTMLElement).classList.contains("-translate-y-3") - }) - assertEquals(isNotSelected, true) - } finally { - // 必ずページとコンテキストをクローズ - await page.close() - await _context.close() - } - }) - - // テスト完了後にブラウザを閉じる - await browser.close() - - // テスト完了後にKVストアをクリーンアップ - await cleanupKVStore() - // サーバー停止 - await stopServer() -}) From 33564a13add4cf7f0fb2c9e7f211e18a6c5ca358 Mon Sep 17 00:00:00 2001 From: p_craft Date: Sun, 1 Jun 2025 23:03:48 +0900 Subject: [PATCH 03/43] =?UTF-8?q?=E6=B6=88=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Procfile | 1 - deno.json | 2 +- src/composables/webSocket.ts | 4 +--- 3 files changed, 2 insertions(+), 5 deletions(-) delete mode 100644 Procfile 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/deno.json b/deno.json index 20b0226..960b2e4 100644 --- a/deno.json +++ b/deno.json @@ -8,7 +8,7 @@ "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", + "deploy": "deno run --allow-read --allow-write --allow-run --allow-env --allow-net deploy.ts" }, "imports": { "vue": "npm:vue@^3.5.13", diff --git a/src/composables/webSocket.ts b/src/composables/webSocket.ts index 516b1e1..8bf555e 100644 --- a/src/composables/webSocket.ts +++ b/src/composables/webSocket.ts @@ -1,10 +1,8 @@ import { useWebSocket } from "@vueuse/core" import { isMsgFromServer } from "@/wsMsg/msgFromServer.ts" -import { room, setName, setRoom, setRoomId, setToken, user } from "./store.ts" -import router from "@/src/router/router.ts" +import { room, setRoom, setToken, user } from "./store.ts" import { ref, watch } from "vue" import { genMsgAnswer, genMsgClearAnswer } from "../../wsMsg/msgFromClient.ts" -import { RoomId } from "../../backend/type.ts" const protocol = location.protocol === "https:" ? "wss:" : "ws:" const url = import.meta.env.DEV From d462ab869f12842adb9aaac8edb2eb9d4c95e3b5 Mon Sep 17 00:00:00 2001 From: p_craft Date: Wed, 4 Jun 2025 23:44:50 +0900 Subject: [PATCH 04/43] =?UTF-8?q?=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1?= =?UTF-8?q?=E3=83=B3=E3=83=88=E6=95=B4=E5=82=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llms.md => docs/llms.md | 19 +- docs/todoList.md | 186 ++++++++++++++++++++ "docs/\350\250\255\350\250\210.md" | 270 +++++++++++++++++++++++++++++ 3 files changed, 461 insertions(+), 14 deletions(-) rename llms.md => docs/llms.md (60%) create mode 100644 docs/todoList.md create mode 100644 "docs/\350\250\255\350\250\210.md" diff --git a/llms.md b/docs/llms.md similarity index 60% rename from llms.md rename to docs/llms.md index 0e7fdf6..fec4999 100644 --- a/llms.md +++ b/docs/llms.md @@ -1,20 +1,21 @@ ## アプリケーション概要 このアプリケーションは「PPAP(Planning Poker Application -Portable)」です。アジャイル開発でよく使われるプランニングポーカーを実施するためのWebアプリです。ユーザーはルームを作成したり参加したりして、タスクの見積もりを数値(1, -2, 3, 5, 8, 13, 21)で投票できます。 +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」ボタン) @@ -24,16 +25,6 @@ Portable)」です。アジャイル開発でよく使われるプランニン - 全員が投票するまで結果は非表示(?マークで表示) - 全員の投票完了後、結果が公開される(isOpen=true) -4. **ユーザー状態管理** - - WebSocketで接続状態管理 - - セッションストレージでユーザー名やルームID等を保持 - - 観戦者(Audience)モードでは投票なし - -5. **ルーム管理** - - ルームIDはUUID - - 30分間更新のないルームは自動クローズ - - 参加者が0人になったルームは閉じられる - ## 技術スタック - フロントエンド: Vue.js, Vue Router, Vite diff --git a/docs/todoList.md b/docs/todoList.md new file mode 100644 index 0000000..332f118 --- /dev/null +++ b/docs/todoList.md @@ -0,0 +1,186 @@ +# バックエンド開発 ToDoリスト + +## フェーズ1: 基盤・データモデル実装 + +### 1. プロジェクト初期設定 +- [ ] Deno KV接続の設定 +- [ ] 環境変数設定(開発・本番環境) +- [ ] ログ設定の実装 +- [ ] 基本的なエラーハンドリング構造の作成 + +### 2. データモデル・型定義 +- [ ] KVSデータ構造の型定義 + - [ ] Room型の詳細実装 + - [ ] UserToken関連の型定義 + - [ ] APIリクエスト・レスポンスの型定義 +- [ ] 既存`backend/type.ts`との整合性確認・修正 +- [ ] バリデーション用スキーマの定義 + +### 3. KVS基本操作 +- [ ] ルーム情報のCRUD操作実装 + - [ ] ルーム作成(atomic操作) + - [ ] ルーム取得 + - [ ] ルーム更新(参加者追加/削除、回答更新) + - [ ] ルーム削除 +- [ ] UserToken管理機能 + - [ ] UserToken生成 + - [ ] UserToken検証 + - [ ] user_tokens管理(独立キーかrooms内管理かの決定) +- [ ] アトミック操作の実装とテスト + +## フェーズ2: REST API実装 + +### 4. ルーム作成API +- [ ] `POST /api/rooms` エンドポイント実装 + - [ ] リクエストボディ解析・バリデーション + - [ ] roomId生成(UUID v4) + - [ ] UserToken生成 + - [ ] 初期ルーム情報をKVに保存 + - [ ] レスポンス返却 +- [ ] エラーハンドリング(400, 500) + +### 5. ルーム参加API +- [ ] `POST /api/rooms/:roomId/join` エンドポイント実装 + - [ ] リクエストパラメータ・ボディ解析 + - [ ] ルーム存在確認 + - [ ] UserToken検証(既存の場合) + - [ ] 新規UserToken発行(必要な場合) + - [ ] 参加者情報の追加・更新(atomic操作) + - [ ] レスポンス返却 +- [ ] エラーハンドリング(400, 404, 500) + +### 6. ルーム退出API +- [ ] `POST /api/rooms/:roomId/leave` エンドポイント実装 + - [ ] リクエストボディ解析・バリデーション + - [ ] UserToken検証 + - [ ] 参加者情報の削除(atomic操作) + - [ ] WebSocket接続の切断処理 + - [ ] レスポンス返却 +- [ ] navigator.sendBeacon()対応の確認 + +## フェーズ3: WebSocket実装 + +### 7. WebSocket接続処理 +- [ ] `/ws/rooms/:roomId` エンドポイント実装 + - [ ] クエリパラメータからUserToken取得・検証 + - [ ] ルーム存在確認 + - [ ] WebSocket接続確立 + - [ ] 接続管理(UserToken <-> WebSocket のマッピング) + +### 8. WebSocketメッセージハンドリング +- [ ] クライアントからのメッセージ解析 + - [ ] `submit_answer` メッセージ処理 + - [ ] `toggle_spectator` メッセージ処理 + - [ ] `clear_answers` メッセージ処理 +- [ ] メッセージバリデーション +- [ ] 不正メッセージの対応 + +### 9. WebSocket通知機能 +- [ ] サーバーからクライアントへの通知実装 + - [ ] `room_update` メッセージ送信 + - [ ] `error` メッセージ送信 +- [ ] 複数クライアントへのブロードキャスト機能 + +## フェーズ4: リアルタイム機能・KV監視 + +### 10. Deno KV Watch実装 +- [ ] ルーム情報変更の監視設定 +- [ ] watch イベントハンドリング +- [ ] 変更検知時のWebSocketクライアント通知 +- [ ] 複数インスタンス対応の考慮 + +### 11. 回答機能 +- [ ] 回答送信処理の実装 + - [ ] KVS回答データ更新(atomic操作) + - [ ] updatedAt更新 + - [ ] 他参加者への通知 +- [ ] 回答クリア機能 + - [ ] 全回答削除処理(atomic操作) + - [ ] 全参加者への通知 + +### 12. 観覧者モード +- [ ] 観覧者モード切替処理 + - [ ] isSpectatorフラグ更新(atomic操作) + - [ ] 参加者リスト更新通知 + +## フェーズ5: セキュリティ・品質向上 + +### 13. セキュリティ対策 +- [ ] 入力値バリデーション強化 + - [ ] ルーム名、ユーザー名、回答内容のサニタイズ + - [ ] 文字数制限の実装 +- [ ] WebSocketセキュリティ + - [ ] Originヘッダー検証 + - [ ] メッセージサイズ・頻度制限 +- [ ] エラーメッセージの機密情報除去 + +### 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対応を検討 \ No newline at end of file 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..c34337c --- /dev/null +++ "b/docs/\350\250\255\350\250\210.md" @@ -0,0 +1,270 @@ +# 設計 + +## 概要 + +- 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:` (検討中、または `rooms` 内で管理) + - **役割**: サーバーが発行した `UserToken` が現在どのルームに参加しているか、最後にアクティブだったのはいつか、などの情報を保持します。これは主に、リロード時の復帰処理や、長時間応答がない場合の自動退出処理の判断材料として使用します。 + - **例**: `user_tokens:unique_user_token_string` + +#### 2. データモデル (Value の構造例) + +##### a. ルーム情報 (`rooms:`) + +ルームの ID (``) をキーとし、以下の情報を持つ JSON オブジェクトを格納します。 + +```json +{ + "id": "string", // ルームID (キーと重複するが、データ内にも保持) + "name": "string", // ルーム名 (ユーザーが設定可能、任意) + "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 オブジェクトを格納します。この情報は、ルーム情報と重複する部分があるかもしれませんが、特定のトークンに紐づくセッション管理を容易にするために設けることを検討します。代替案として、これらの情報を各ルームの `participants` 内に含めることも考えられます。 + +```json +{ + "token": "string", // UserToken (キーと重複) + "currentRoomId": "string | null", // 現在参加中のルームID、参加していなければnull + "lastAccessedAt": "timestamp" // TTL設定や自動退出処理の判断材料 +} +``` + +**考慮事項:** + +- リロード時にクライアントから送信された `UserToken` を使い、この情報を参照して、以前参加していたルームに復帰させます。 +- タブを閉じた際の退出処理は、`navigator.sendBeacon()` や `window.addEventListener('beforeunload', ...)` で API を呼び出すことを想定します。これが失敗した場合も考慮し、`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` + - リクエストボディ: `{ "roomName": "string" }` (任意) + - レスポンスボディ (成功時): `{ "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 のデータを直接参照・検証し、期待通りの状態変化が起きているかを確認する。 +- 必要に応じて、特定のロジック単位での単体テストも追加する。 From ad666e1e8050a64ea1294f46f8be67a1db6425a3 Mon Sep 17 00:00:00 2001 From: p_craft Date: Thu, 12 Jun 2025 21:44:06 +0900 Subject: [PATCH 05/43] wip --- backend/kv.ts | 72 +++++ backend/kv_test.ts | 66 +++++ backend/kv_test_tdd.ts | 71 +++++ backend/server.ts | 385 +++++++++++++++++++++++++- backend/type.ts | 72 +++-- backend/validate.ts | 31 +++ deno.lock | 430 +++++++++++++++++++++++------ docs/todoList.md | 264 ++++++++++-------- "docs/\350\250\255\350\250\210.md" | 16 +- 9 files changed, 1164 insertions(+), 243 deletions(-) create mode 100644 backend/kv.ts create mode 100644 backend/kv_test.ts create mode 100644 backend/kv_test_tdd.ts create mode 100644 backend/validate.ts diff --git a/backend/kv.ts b/backend/kv.ts new file mode 100644 index 0000000..bd46874 --- /dev/null +++ b/backend/kv.ts @@ -0,0 +1,72 @@ +import { Room, RoomId, UserToken, UserTokenInfo } from './type.ts'; + +// KVのキー生成 +export function roomKey(roomId: RoomId): string { + return `rooms:${roomId}`; +} +export function userTokenKey(token: UserToken): string { + 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(); +} diff --git a/backend/kv_test.ts b/backend/kv_test.ts new file mode 100644 index 0000000..b233ee8 --- /dev/null +++ b/backend/kv_test.ts @@ -0,0 +1,66 @@ +import { + createRoom, + getRoom, + updateRoom, + deleteRoom, + createUserToken, + getUserToken, + updateUserToken, + deleteUserToken, +} 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', + name: 'テストルーム', + participants: [], + answers: {}, + 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'); + loaded.name = '変更後'; + await updateRoom(kv, loaded); + const updated = await getRoom(kv, room.id); + if (!updated || updated.name !== '変更後') + 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/kv_test_tdd.ts b/backend/kv_test_tdd.ts new file mode 100644 index 0000000..9678e4a --- /dev/null +++ b/backend/kv_test_tdd.ts @@ -0,0 +1,71 @@ +import { + assertEquals, + assert, +} from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { Room, UserTokenInfo } from './type.ts'; +import { + createRoom, + getRoom, + updateRoom, + deleteRoom, + createUserToken, + getUserToken, + updateUserToken, + deleteUserToken, +} from './kv.ts'; + +Deno.test('Room CRUD: 正常系', async () => { + const kv = await Deno.openKv(); + try { + const room: Room = { + id: 'room1', + name: 'ルーム1', + participants: [], + answers: {}, + config: { allowSpectators: true, maxParticipants: 50 }, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + await createRoom(kv, room); + const loaded = await getRoom(kv, room.id); + assert(loaded); + assertEquals(loaded.name, 'ルーム1'); + loaded.name = 'ルーム1-更新'; + await updateRoom(kv, loaded); + const updated = await getRoom(kv, room.id); + assert(updated); + assertEquals(updated.name, 'ルーム1-更新'); + await deleteRoom(kv, room.id); + const deleted = await getRoom(kv, room.id); + assertEquals(deleted, null); + } finally { + kv.close(); + } +}); + +Deno.test('UserToken CRUD: 正常系', async () => { + const kv = await Deno.openKv(); + try { + const info: UserTokenInfo = { + token: 'token1', + currentRoomId: 'room1', + name: 'ユーザー1', + isSpectator: false, + lastAccessedAt: Date.now(), + }; + await createUserToken(kv, info); + const loaded = await getUserToken(kv, info.token); + assert(loaded); + assertEquals(loaded.name, 'ユーザー1'); + loaded.name = 'ユーザー1-更新'; + await updateUserToken(kv, loaded); + const updated = await getUserToken(kv, info.token); + assert(updated); + assertEquals(updated.name, 'ユーザー1-更新'); + await deleteUserToken(kv, info.token); + const deleted = await getUserToken(kv, info.token); + assertEquals(deleted, null); + } finally { + kv.close(); + } +}); diff --git a/backend/server.ts b/backend/server.ts index 93c6fbf..b59b9df 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -1,28 +1,383 @@ -import { serveDir } from "https://deno.land/std@0.209.0/http/file_server.ts" +import { serveDir } from 'jsr:@std/http/file-server'; +import { + CreateRoomRequest, + CreateRoomResponse, + Room, + UserTokenInfo, +} from './type.ts'; +import { CreateRoomRequestSchema } from './validate.ts'; +import { createRoom, createUserToken } from './kv.ts'; -function handler(request: Request): Promise { - const { pathname } = new URL(request.url) +const kv = await Deno.openKv(); + +async function handleCreateRoom(req: Request): 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 { roomName, userName } = parse.data; + // const roomId = v4.generate(); + // const userToken = v4.generate(); + const roomId = crypto.randomUUID(); + const userToken = crypto.randomUUID(); + const now = Date.now(); + const room: Room = { + id: roomId, + name: roomName ?? 'ルーム', + participants: [userToken], + answers: {}, + 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 res: CreateRoomResponse = { + roomId, + userToken, + room, + }; + 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, + }); + } +} + +async function handleJoinRoom(req: Request, roomId: string): 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([`rooms:${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(); + } + // UserTokenの検証・新規作成 + const userTokenInfoRes = await kv.get([ + `user_tokens:${userToken}`, + ]); + let userTokenInfo = userTokenInfoRes.value; + if (!userTokenInfo) { + userTokenInfo = { + token: userToken, + currentRoomId: roomId, + name: body.userName, + isSpectator: false, + lastAccessedAt: Date.now(), + }; + await kv + .atomic() + .set([`user_tokens:${userToken}`], userTokenInfo) + .commit(); + } else { + userTokenInfo = { + ...userTokenInfo, + currentRoomId: roomId, + name: body.userName, + lastAccessedAt: Date.now(), + }; + await kv + .atomic() + .set([`user_tokens:${userToken}`], userTokenInfo) + .commit(); + } + // ルーム参加者に追加 + if (!room.participants.includes(userToken)) { + room.participants.push(userToken); + room.updatedAt = Date.now(); + await kv + .atomic() + .set([`rooms:${roomId}`], room) + .commit(); + } + return new Response(JSON.stringify({ userToken, room }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +async function handleLeaveRoom( + req: Request, + roomId: string +): 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, + }); + } + // ルーム取得 + const roomRes = await kv.get([`rooms:${roomId}`]); + const room = roomRes.value; + if (!room) { + return new Response(JSON.stringify({ error: 'Room not found' }), { + status: 404, + }); + } + // UserToken検証 + const userTokenInfoRes = await kv.get([ + `user_tokens:${body.userToken}`, + ]); + const userTokenInfo = userTokenInfoRes.value; + if (!userTokenInfo || userTokenInfo.currentRoomId !== roomId) { + return new Response(JSON.stringify({ error: 'Invalid userToken' }), { + status: 400, + }); + } + // 参加者から削除 + const idx = room.participants.indexOf(body.userToken); + if (idx !== -1) { + room.participants.splice(idx, 1); + room.updatedAt = Date.now(); + await kv + .atomic() + .set([`rooms:${roomId}`], room) + .commit(); + } + // userTokenのcurrentRoomIdをnullに + userTokenInfo.currentRoomId = null; + userTokenInfo.lastAccessedAt = Date.now(); + await kv + .atomic() + .set([`user_tokens:${body.userToken}`], userTokenInfo) + .commit(); + // WebSocket切断処理はここでは省略(後で実装) + return new Response( + JSON.stringify({ message: 'Successfully left the room' }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); +} + +// --- WebSocket接続管理・認証・ルーム存在確認・UserToken検証を追加 --- +const wsClients = new Map(); // userToken -> WebSocket + +function handleWebSocket(req: Request, roomId: string): Response { + // Originヘッダー検証 + const allowedOrigins = [ + 'http://localhost:5173', + 'https://your-production-domain.com', + ]; + const origin = req.headers.get('origin'); + if (origin && !allowedOrigins.includes(origin)) { + return new Response('Forbidden', { status: 403 }); + } + + const url = new URL(req.url); + const userToken = url.searchParams.get('userToken'); + if (!userToken) { + return new Response('Missing userToken', { status: 400 }); + } + const { socket, response } = Deno.upgradeWebSocket(req); + (async () => { + const roomRes = await kv.get([`rooms:${roomId}`]); + const userTokenRes = await kv.get([ + `user_tokens:${userToken}`, + ]); + if (!roomRes.value || !userTokenRes.value) { + socket.close(4000, 'Invalid room or userToken'); + return; + } + wsClients.set(userToken, socket); + // ここで初期room情報などを送信可能 + })(); + // --- WebSocketメッセージハンドリング雛形 --- + // クライアントからのメッセージ形式に応じて分岐 + socket.onmessage = e => { + if (typeof e.data === 'string' && e.data.length > 2048) { + _sendError(socket, 'Message too large'); + socket.close(4001, 'Message too large'); + return; + } + try { + const msg = JSON.parse(e.data); + if (msg.type === 'submit_answer') { + // 回答送信処理 + (async () => { + const roomRes = await kv.get([`rooms:${roomId}`]); + if (!roomRes.value) return _sendError(socket, 'Room not found'); + const room = roomRes.value; + room.answers[userToken] = msg.payload.answer; + room.updatedAt = Date.now(); + await kv + .atomic() + .set([`rooms:${roomId}`], room) + .commit(); + // 通知はwatchで行う + })(); + } else if (msg.type === 'toggle_spectator') { + // 観覧者モード切替処理 + (async () => { + const userTokenInfoRes = await kv.get([ + `user_tokens:${userToken}`, + ]); + if (!userTokenInfoRes.value) + return _sendError(socket, 'UserToken not found'); + const info = userTokenInfoRes.value; + info.isSpectator = msg.payload.isSpectator; + info.lastAccessedAt = Date.now(); + await kv + .atomic() + .set([`user_tokens:${userToken}`], info) + .commit(); + // 参加者リスト更新通知はwatch経由 + })(); + } else if (msg.type === 'clear_answers') { + // 回答クリア処理 + (async () => { + const roomRes = await kv.get([`rooms:${roomId}`]); + if (!roomRes.value) return _sendError(socket, 'Room not found'); + const room = roomRes.value; + room.answers = {}; + room.updatedAt = Date.now(); + await kv + .atomic() + .set([`rooms:${roomId}`], room) + .commit(); + })(); + } else { + _sendError(socket, 'Unknown message type'); + } + } catch { + _sendError(socket, 'Invalid message format'); + } + }; + socket.onclose = () => { + wsClients.delete(userToken); + // 切断時のクリーンアップ + }; + socket.onerror = _e => { + // エラーハンドリング + }; + return response; +} + +// --- Deno KV Watch雛形 --- +async function _watchRoomUpdates(roomId: string) { + const iter = kv.watch([[`rooms:${roomId}`]]); + for await (const _event of iter) { + const roomRes = await kv.get([`rooms:${roomId}`]); + if (roomRes.value) { + _broadcastRoomUpdate(roomId, roomRes.value); + } + } +} + +function _broadcastRoomUpdate(_roomId: string, room: Room) { + for (const [userToken, ws] of wsClients.entries()) { + if ( + room.participants.includes(userToken) && + ws.readyState === WebSocket.OPEN + ) { + ws.send(JSON.stringify({ type: 'room_update', payload: room })); + } + } +} +function _sendError(ws: WebSocket, _message: string) { + // クライアントには詳細なエラー内容を送らず、汎用的なメッセージのみ返す + ws.send( + JSON.stringify({ + type: 'error', + payload: { message: 'An error occurred.' }, + }) + ); +} + +function handler(request: Request): Promise | Response { + const { pathname } = new URL(request.url); + if (request.method === 'POST' && pathname === '/api/rooms') { + return handleCreateRoom(request); + } + if ( + request.method === 'POST' && + pathname.match(/^\/api\/rooms\/(.+)\/join$/) + ) { + const roomId = pathname.match(/^\/api\/rooms\/(.+)\/join$/)?.[1] ?? ''; + return handleJoinRoom(request, roomId); + } + if ( + request.method === 'POST' && + pathname.match(/^\/api\/rooms\/(.+)\/leave$/) + ) { + const roomId = pathname.match(/^\/api\/rooms\/(.+)\/leave$/)?.[1] ?? ''; + return handleLeaveRoom(request, roomId); + } + if (request.method === 'GET' && pathname.match(/^\/ws\/rooms\/(.+)$/)) { + const roomId = pathname.match(/^\/ws\/rooms\/(.+)$/)?.[1] ?? ''; + return handleWebSocket(request, roomId); + } 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/type.ts b/backend/type.ts index a698524..bf8a25b 100644 --- a/backend/type.ts +++ b/backend/type.ts @@ -1,28 +1,50 @@ -export type UserToken = string -export type User = { - token: UserToken - name: string - answer: string -} -export type UsersSockets = Map +export type UserToken = string; -export type RoomId = string +export type UserTokenInfo = { + token: UserToken; + currentRoomId: string | null; + name: string; + isSpectator: boolean; + lastAccessedAt: number; // UNIXタイムスタンプ(ms) +}; + +export type RoomId = string; export type Room = { - id: string - participants: User[] - isOpen: boolean - // 最終更新日時 - updatedAt: Date -} + id: RoomId; + name: string; + participants: UserToken[]; // UserTokenの配列のみ保持 + answers: Record; // 回答内容 + config: { + allowSpectators: boolean; + maxParticipants: number; + }; + createdAt: number; // UNIXタイムスタンプ(ms) + updatedAt: number; // UNIXタイムスタンプ(ms) +}; + +// APIリクエスト・レスポンス型 +export type CreateRoomRequest = { + roomName?: string; + userName: string; +}; +export type CreateRoomResponse = { + roomId: string; + userToken: string; + room: Room; +}; + +export type JoinRoomRequest = { + userName: string; + userToken?: string; +}; +export type JoinRoomResponse = { + userToken: string; + room: Room; +}; -export type UserForClientSide = { - name: string - answer: string - userNumber: number - isMe: boolean -} -export type RoomForClientSide = { - id: string - participants: UserForClientSide[] - isOpen: boolean -} +export type LeaveRoomRequest = { + userToken: string; +}; +export type LeaveRoomResponse = { + message: string; +}; diff --git a/backend/validate.ts b/backend/validate.ts new file mode 100644 index 0000000..d1b4017 --- /dev/null +++ b/backend/validate.ts @@ -0,0 +1,31 @@ +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({ + roomName: z.string().max(32).transform(sanitize).optional(), + 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 RoomNameSchema = z.string().max(32).transform(sanitize); +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, + RoomNameSchema, + UserNameSchema, + AnswerSchema, +}; diff --git a/deno.lock b/deno.lock index 6f7661f..1026bda 100644 --- a/deno.lock +++ b/deno.lock @@ -1,6 +1,15 @@ { - "version": "4", + "version": "5", "specifiers": { + "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/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 +25,45 @@ "npm:vue-router@4": "4.5.0_vue@3.5.13", "npm:vue@^3.5.13": "3.5.13" }, + "jsr": { + "@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/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 +86,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 +110,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 +323,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 +657,8 @@ "picocolors", "postcss", "postcss-value-parser" - ] + ], + "bin": true }, "available-typed-arrays@1.0.7": { "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", @@ -553,7 +691,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 +735,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 +761,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 +773,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 +881,7 @@ }, "esbuild@0.24.2": { "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", - "dependencies": [ + "optionalDependencies": [ "@esbuild/aix-ppc64", "@esbuild/android-arm", "@esbuild/android-arm64", @@ -764,7 +907,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 +969,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 +1027,8 @@ "minipass", "package-json-from-dist", "path-scurry" - ] + ], + "bin": true }, "gopd@1.2.0": { "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" @@ -1048,7 +1198,9 @@ "jackspeak@3.4.3": { "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dependencies": [ - "@isaacs/cliui", + "@isaacs/cliui" + ], + "optionalDependencies": [ "@pkgjs/parseargs" ] }, @@ -1099,7 +1251,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 +1267,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 +1307,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 +1378,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 +1387,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 +1428,10 @@ "lilconfig", "postcss", "yaml" + ], + "optionalPeers": [ + "postcss", + "ts-node@>=9.0.0" ] }, "postcss-nested@6.2.0_postcss@8.4.49": { @@ -1358,7 +1525,8 @@ "is-core-module", "path-parse", "supports-preserve-symlinks-flag" - ] + ], + "bin": true }, "reusify@1.0.4": { "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==" @@ -1366,6 +1534,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 +1556,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 +1699,8 @@ "mz", "pirates", "ts-interface-checker" - ] + ], + "bin": true }, "supports-color@7.2.0": { "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", @@ -1564,7 +1736,8 @@ "postcss-selector-parser", "resolve", "sucrase" - ] + ], + "bin": true }, "thenify-all@1.6.0": { "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", @@ -1596,7 +1769,8 @@ "browserslist", "escalade", "picocolors" - ] + ], + "bin": true }, "util-deprecate@1.0.2": { "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" @@ -1605,26 +1779,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 +1852,9 @@ "@vue/runtime-dom", "@vue/server-renderer", "@vue/shared" + ], + "optionalPeers": [ + "typescript@*" ] }, "wait-for-expect@3.0.2": { @@ -1681,7 +1895,8 @@ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dependencies": [ "isexe" - ] + ], + "bin": true }, "wrap-ansi@7.0.0": { "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", @@ -1700,13 +1915,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 +1986,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 +2088,28 @@ "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/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/docs/todoList.md b/docs/todoList.md index 332f118..11ada3f 100644 --- a/docs/todoList.md +++ b/docs/todoList.md @@ -1,163 +1,182 @@ -# バックエンド開発 ToDoリスト +# バックエンド開発 ToDo リスト -## フェーズ1: 基盤・データモデル実装 +## フェーズ 1: 基盤・データモデル実装 ### 1. プロジェクト初期設定 -- [ ] Deno KV接続の設定 + +- [x] Deno KV 接続の設定 - [ ] 環境変数設定(開発・本番環境) - [ ] ログ設定の実装 - [ ] 基本的なエラーハンドリング構造の作成 ### 2. データモデル・型定義 -- [ ] KVSデータ構造の型定義 - - [ ] Room型の詳細実装 - - [ ] UserToken関連の型定義 - - [ ] APIリクエスト・レスポンスの型定義 -- [ ] 既存`backend/type.ts`との整合性確認・修正 -- [ ] バリデーション用スキーマの定義 - -### 3. KVS基本操作 -- [ ] ルーム情報のCRUD操作実装 - - [ ] ルーム作成(atomic操作) - - [ ] ルーム取得 - - [ ] ルーム更新(参加者追加/削除、回答更新) - - [ ] ルーム削除 -- [ ] UserToken管理機能 - - [ ] UserToken生成 - - [ ] UserToken検証 - - [ ] user_tokens管理(独立キーかrooms内管理かの決定) -- [ ] アトミック操作の実装とテスト - -## フェーズ2: REST API実装 - -### 4. ルーム作成API -- [ ] `POST /api/rooms` エンドポイント実装 - - [ ] リクエストボディ解析・バリデーション - - [ ] roomId生成(UUID v4) - - [ ] UserToken生成 - - [ ] 初期ルーム情報をKVに保存 - - [ ] レスポンス返却 -- [ ] エラーハンドリング(400, 500) - -### 5. ルーム参加API -- [ ] `POST /api/rooms/:roomId/join` エンドポイント実装 - - [ ] リクエストパラメータ・ボディ解析 - - [ ] ルーム存在確認 - - [ ] UserToken検証(既存の場合) - - [ ] 新規UserToken発行(必要な場合) - - [ ] 参加者情報の追加・更新(atomic操作) - - [ ] レスポンス返却 -- [ ] エラーハンドリング(400, 404, 500) - -### 6. ルーム退出API -- [ ] `POST /api/rooms/:roomId/leave` エンドポイント実装 - - [ ] リクエストボディ解析・バリデーション - - [ ] UserToken検証 - - [ ] 参加者情報の削除(atomic操作) - - [ ] WebSocket接続の切断処理 - - [ ] レスポンス返却 -- [ ] navigator.sendBeacon()対応の確認 - -## フェーズ3: WebSocket実装 - -### 7. WebSocket接続処理 -- [ ] `/ws/rooms/:roomId` エンドポイント実装 - - [ ] クエリパラメータからUserToken取得・検証 - - [ ] ルーム存在確認 - - [ ] WebSocket接続確立 - - [ ] 接続管理(UserToken <-> WebSocket のマッピング) - -### 8. WebSocketメッセージハンドリング -- [ ] クライアントからのメッセージ解析 - - [ ] `submit_answer` メッセージ処理 - - [ ] `toggle_spectator` メッセージ処理 - - [ ] `clear_answers` メッセージ処理 -- [ ] メッセージバリデーション -- [ ] 不正メッセージの対応 - -### 9. WebSocket通知機能 -- [ ] サーバーからクライアントへの通知実装 - - [ ] `room_update` メッセージ送信 - - [ ] `error` メッセージ送信 -- [ ] 複数クライアントへのブロードキャスト機能 - -## フェーズ4: リアルタイム機能・KV監視 - -### 10. Deno KV Watch実装 -- [ ] ルーム情報変更の監視設定 -- [ ] watch イベントハンドリング -- [ ] 変更検知時のWebSocketクライアント通知 + +- [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. 回答機能 -- [ ] 回答送信処理の実装 - - [ ] KVS回答データ更新(atomic操作) - - [ ] updatedAt更新 - - [ ] 他参加者への通知 -- [ ] 回答クリア機能 - - [ ] 全回答削除処理(atomic操作) - - [ ] 全参加者への通知 + +- [x] 回答送信処理の実装 + - [x] KVS 回答データ更新(atomic 操作) + - [x] updatedAt 更新 + - [x] 他参加者への通知 +- [x] 回答クリア機能 + - [x] 全回答削除処理(atomic 操作) + - [x] 全参加者への通知 ### 12. 観覧者モード -- [ ] 観覧者モード切替処理 - - [ ] isSpectatorフラグ更新(atomic操作) - - [ ] 参加者リスト更新通知 -## フェーズ5: セキュリティ・品質向上 +- [x] 観覧者モード切替処理 + - [x] isSpectator フラグ更新(atomic 操作) + - [x] 参加者リスト更新通知(watch 経由) + +## フェーズ 5: セキュリティ・品質向上 ### 13. セキュリティ対策 -- [ ] 入力値バリデーション強化 - - [ ] ルーム名、ユーザー名、回答内容のサニタイズ - - [ ] 文字数制限の実装 -- [ ] WebSocketセキュリティ - - [ ] Originヘッダー検証 - - [ ] メッセージサイズ・頻度制限 -- [ ] エラーメッセージの機密情報除去 + +- [x] 入力値バリデーション強化 + - [x] ルーム名、ユーザー名、回答内容のサニタイズ + - [x] 文字数制限の実装 +- [x] WebSocket セキュリティ + - [x] Origin ヘッダー検証 + - [x] メッセージサイズ・頻度制限 +- [x] エラーメッセージの機密情報除去 ### 14. エラーハンドリング・ログ + - [ ] 包括的なエラーハンドリング - - [ ] KV操作エラーの詳細対応 - - [ ] WebSocket接続エラーの対応 + - [ ] KV 操作エラーの詳細対応 + - [ ] WebSocket 接続エラーの対応 - [ ] 書き込み競合の対応 - [ ] ログ機能の充実 - [ ] 重要な処理のログ記録 - [ ] エラーログの詳細化 - - [ ] Deno Deployログ機能活用 + - [ ] Deno Deploy ログ機能活用 ### 15. 自動退出・クリーンアップ + - [ ] タイムアウト処理 - [ ] 長時間非アクティブユーザーの自動退出 - - [ ] lastAccessedAt更新処理 -- [ ] TTL設定・データクリーンアップ + - [ ] lastAccessedAt 更新処理 +- [ ] TTL 設定・データクリーンアップ - [ ] 古いルーム情報の自動削除 - - [ ] 使用されていないUserTokenの削除 + - [ ] 使用されていない UserToken の削除 -## フェーズ6: テスト・最適化 +## フェーズ 6: テスト・最適化 ### 16. テスト実装 -- [ ] ローカルKVを使用した結合テスト + +- [ ] ローカル KV を使用した結合テスト - [ ] ルーム作成〜参加〜退出の一連フロー - - [ ] WebSocket通信テスト - - [ ] KVデータの直接検証 + - [ ] WebSocket 通信テスト + - [ ] KV データの直接検証 - [ ] 単体テスト(必要に応じて) - [ ] データモデル操作のテスト - [ ] バリデーション機能のテスト ### 17. パフォーマンス・運用対応 + - [ ] 複数インスタンス対応の詳細検討 - - [ ] Deno Queues利用の検討・実装 + - [ ] Deno Queues 利用の検討・実装 - [ ] クライアント側ポーリングのフォールバック - [ ] 負荷テスト - [ ] 監視・メトリクス設定 -## フェーズ7: 統合・デプロイ準備 +## フェーズ 7: 統合・デプロイ準備 ### 18. フロントエンド統合 -- [ ] APIエンドポイントの動作確認 -- [ ] WebSocket通信の動作確認 + +- [ ] API エンドポイントの動作確認 +- [ ] WebSocket 通信の動作確認 - [ ] エラー処理の統合テスト -### 19. Deno Deploy対応 +### 19. Deno Deploy 対応 + - [ ] デプロイ設定の確認 - [ ] 本番環境での動作テスト - [ ] 環境変数設定 @@ -166,21 +185,24 @@ ## 実装の優先順位 -**高優先度(MVP必須):** -- フェーズ1-3: 基本的なルーム作成・参加・WebSocket通信 -- フェーズ4の基本的なリアルタイム機能 +**高優先度(MVP 必須):** + +- フェーズ 1-3: 基本的なルーム作成・参加・WebSocket 通信 +- フェーズ 4 の基本的なリアルタイム機能 **中優先度(品質向上):** -- フェーズ5: セキュリティ・エラーハンドリング -- フェーズ6: テスト + +- フェーズ 5: セキュリティ・エラーハンドリング +- フェーズ 6: テスト **低優先度(将来的な改善):** + - 複数インスタンス対応の高度な機能 - パフォーマンス最適化 ## 注意事項 - 各フェーズの実装中に設計書の不足情報・検討事項を随時決定する -- ユーザー名重複の扱い、user_tokens管理方法などは実装中に確定 +- ユーザー名重複の扱い、user_tokens 管理方法などは実装中に確定 - テストは実装と並行して進める -- 技術的制約(複数インスタンス問題)は初期はwatch()のみで開始し、必要に応じてDeno Queues対応を検討 \ No newline at end of file +- 技術的制約(複数インスタンス問題)は初期は watch()のみで開始し、必要に応じて Deno Queues 対応を検討 diff --git "a/docs/\350\250\255\350\250\210.md" "b/docs/\350\250\255\350\250\210.md" index c34337c..0c2253a 100644 --- "a/docs/\350\250\255\350\250\210.md" +++ "b/docs/\350\250\255\350\250\210.md" @@ -55,10 +55,13 @@ KVS のキーは、以下のプレフィックスと ID を組み合わせて構 - `rooms:` - **役割**: 特定のルームに関する全ての情報を格納します。ルーム名、参加者リスト(各参加者は `UserToken` で識別)、現在の回答状況などが含まれます。 - **例**: `rooms:abcdef123456` -- `user_tokens:` (検討中、または `rooms` 内で管理) +- `user_tokens:` - **役割**: サーバーが発行した `UserToken` が現在どのルームに参加しているか、最後にアクティブだったのはいつか、などの情報を保持します。これは主に、リロード時の復帰処理や、長時間応答がない場合の自動退出処理の判断材料として使用します。 - **例**: `user_tokens:unique_user_token_string` +> **2025-06-04 設計更新:** +> UserToken は必ず`user_tokens:`として独立管理し、ルーム情報(`rooms:`)の`participants`には UserToken の参照のみを持つ。UserToken 情報の重複管理は避ける。 + #### 2. データモデル (Value の構造例) ##### a. ルーム情報 (`rooms:`) @@ -101,22 +104,25 @@ KVS のキーは、以下のプレフィックスと ID を組み合わせて構 - `participants` と `answers` のキーには、サーバーが発行する `UserToken` を使用します。 - `updatedAt` はルーム情報に何らかの変更があった場合に更新し、これを `Deno.Kv.watch()` で監視することで、リアルタイム更新のトリガーとします。 -##### b. ユーザートークン情報 (`user_tokens:`) (検討中) +##### b. ユーザートークン情報 (`user_tokens:`)(必須) -`UserToken` をキーとし、以下の情報を持つ JSON オブジェクトを格納します。この情報は、ルーム情報と重複する部分があるかもしれませんが、特定のトークンに紐づくセッション管理を容易にするために設けることを検討します。代替案として、これらの情報を各ルームの `participants` 内に含めることも考えられます。 +`UserToken` をキーとし、以下の情報を持つ JSON オブジェクトを格納します。この情報は、ルーム情報と重複しないよう、セッション管理・復帰・自動退出判定などに利用します。 ```json { "token": "string", // UserToken (キーと重複) "currentRoomId": "string | null", // 現在参加中のルームID、参加していなければnull + "name": "string", // ユーザー名 + "isSpectator": "boolean", // 観覧者モード "lastAccessedAt": "timestamp" // TTL設定や自動退出処理の判断材料 } ``` **考慮事項:** -- リロード時にクライアントから送信された `UserToken` を使い、この情報を参照して、以前参加していたルームに復帰させます。 -- タブを閉じた際の退出処理は、`navigator.sendBeacon()` や `window.addEventListener('beforeunload', ...)` で API を呼び出すことを想定します。これが失敗した場合も考慮し、`lastAccessedAt` を利用したタイムアウトによる自動退出の仕組みも検討できます。 +- ルーム情報の`participants`には UserToken の参照のみを持ち、ユーザー名や観覧者フラグ等は`user_tokens`側で一元管理する。 +- リロード時や復帰時は`user_tokens`を参照して状態を復元する。 +- タブを閉じた際の退出処理や自動退出判定も`user_tokens`の`lastAccessedAt`を利用する。 #### 3. 操作と整合性 From 0ba8213190151a67c3f8ac45351e7c3319e65e6a Mon Sep 17 00:00:00 2001 From: p_craft Date: Thu, 12 Jun 2025 23:00:01 +0900 Subject: [PATCH 06/43] wip --- backend/kv.ts | 24 ++-- backend/server.ts | 12 +- backend/type.ts | 4 +- debug_kv_view/debug_server.ts | 113 +++++++++++++++++ debug_kv_view/kv_debug_handler.ts | 169 +++++++++++++++++++++++++ debug_kv_view/public/app.js | 80 ++++++++++++ debug_kv_view/public/index.html | 105 +++++++++++++++ deno.json | 10 +- deno.lock | 99 +++++++++++++++ src/components/Index.vue | 95 +++++++++++++- src/components/Room.vue | 2 +- src/components/RoomParticipantCard.vue | 2 +- src/composables/store.ts | 15 +-- src/composables/webSocket.ts | 29 ++++- src/router/router.ts | 8 +- vite.config.mts | 2 +- 16 files changed, 724 insertions(+), 45 deletions(-) create mode 100644 debug_kv_view/debug_server.ts create mode 100644 debug_kv_view/kv_debug_handler.ts create mode 100644 debug_kv_view/public/app.js create mode 100644 debug_kv_view/public/index.html diff --git a/backend/kv.ts b/backend/kv.ts index bd46874..5f88ff0 100644 --- a/backend/kv.ts +++ b/backend/kv.ts @@ -1,37 +1,37 @@ import { Room, RoomId, UserToken, UserTokenInfo } from './type.ts'; // KVのキー生成 -export function roomKey(roomId: RoomId): string { - return `rooms:${roomId}`; +export function roomKey(roomId: RoomId): Deno.KvKey { // 戻り値を Deno.KvKey に変更 + return ["rooms", roomId]; // 文字列の配列として返す } -export function userTokenKey(token: UserToken): string { - return `user_tokens:${token}`; +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) + .set(roomKey(room.id), room) // 修正されたキー関数を使用 .commit(); } export async function getRoom( kv: Deno.Kv, roomId: RoomId ): Promise { - const res = await kv.get([roomKey(roomId)]); + 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) + .set(roomKey(room.id), room) // 修正されたキー関数を使用 .commit(); } export async function deleteRoom(kv: Deno.Kv, roomId: RoomId): Promise { await kv .atomic() - .delete([roomKey(roomId)]) + .delete(roomKey(roomId)) // 修正されたキー関数を使用 .commit(); } @@ -42,14 +42,14 @@ export async function createUserToken( ): Promise { await kv .atomic() - .set([userTokenKey(info.token)], info) + .set(userTokenKey(info.token), info) // 修正されたキー関数を使用 .commit(); } export async function getUserToken( kv: Deno.Kv, token: UserToken ): Promise { - const res = await kv.get([userTokenKey(token)]); + const res = await kv.get(userTokenKey(token)); // 修正されたキー関数を使用 return res.value ?? null; } export async function updateUserToken( @@ -58,7 +58,7 @@ export async function updateUserToken( ): Promise { await kv .atomic() - .set([userTokenKey(info.token)], info) + .set(userTokenKey(info.token), info) // 修正されたキー関数を使用 .commit(); } export async function deleteUserToken( @@ -67,6 +67,6 @@ export async function deleteUserToken( ): Promise { await kv .atomic() - .delete([userTokenKey(token)]) + .delete(userTokenKey(token)) // 修正されたキー関数を使用 .commit(); } diff --git a/backend/server.ts b/backend/server.ts index b59b9df..fd71035 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -7,6 +7,7 @@ import { } from './type.ts'; import { CreateRoomRequestSchema } from './validate.ts'; import { createRoom, createUserToken } from './kv.ts'; +import { handleKvDebugRequest } from '../debug_kv_view/kv_debug_handler.ts'; const kv = await Deno.openKv(); @@ -334,8 +335,17 @@ function _sendError(ws: WebSocket, _message: string) { ); } -function handler(request: Request): Promise | Response { +async function handler(request: Request): Promise { const { pathname } = new URL(request.url); + + // 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); } diff --git a/backend/type.ts b/backend/type.ts index bf8a25b..14a66c4 100644 --- a/backend/type.ts +++ b/backend/type.ts @@ -24,8 +24,8 @@ export type Room = { // APIリクエスト・レスポンス型 export type CreateRoomRequest = { - roomName?: string; - userName: string; + roomName?: string; // 設計書通り任意 + userName: string; // 設計書KVS設計とフローに基づき必須 }; export type CreateRoomResponse = { roomId: string; diff --git a/debug_kv_view/debug_server.ts b/debug_kv_view/debug_server.ts new file mode 100644 index 0000000..2592601 --- /dev/null +++ b/debug_kv_view/debug_server.ts @@ -0,0 +1,113 @@ +import { serve } from 'https://deno.land/std@0.224.0/http/server.ts'; +import { serveDir } from 'https://deno.land/std@0.224.0/http/file_server.ts'; + +const APP_ENV = Deno.env.get('APP_ENV'); + +if (APP_ENV !== 'development') { + console.log( + 'KV Debug Viewer is disabled. Set APP_ENV=development to enable.' + ); + // 開発モードでない場合は何もしない +} else { + console.log('KV Debug Viewer is enabled on /debug-kv-view'); + + const kv = await Deno.openKv(); + const sockets = new Set(); + + // KVストアの全データを取得して整形する関数 + async function getAllKvData() { + const entries = []; + for await (const entry of kv.list({ prefix: [] })) { + entries.push({ + key: entry.key.join(' : '), + value: JSON.stringify(entry.value, null, 2), + versionstamp: entry.versionstamp, + }); + } + return entries; + } + + // KVストアの変更を監視 + (async () => { + const watcher = kv.watch([[]]); // すべてのキーを監視 + for await (const changes of watcher) { + console.log('KV store changed:', changes); + const currentData = await getAllKvData(); + for (const socket of sockets) { + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'update', data: currentData })); + } + } + } + })().catch(console.error); + + serve( + async req => { + const url = new URL(req.url); + if (url.pathname === '/debug-kv-view') { + // public/index.html を提供 + try { + const file = await Deno.readFile( + '/Users/rikegami/Development/ppap/debug_kv_view/public/index.html' + ); + return new Response(file, { + headers: { 'content-type': 'text/html; charset=utf-8' }, + }); + } catch (e) { + console.error('Error serving index.html:', e); + return new Response('Internal Server Error', { status: 500 }); + } + } + if (url.pathname === '/ws/kv-debug') { + if (req.headers.get('upgrade') !== 'websocket') { + return new Response(null, { status: 501 }); + } + const { socket, response } = Deno.upgradeWebSocket(req); + + socket.onopen = async () => { + console.log('WebSocket client connected'); + sockets.add(socket); + try { + const initialData = await getAllKvData(); + socket.send(JSON.stringify({ type: 'initial', data: initialData })); + } catch (err) { + console.error('Failed to send initial KV data:', err); + socket.send( + JSON.stringify({ + type: 'error', + message: 'Failed to load initial KV data.', + }) + ); + } + }; + socket.onmessage = event => { + console.log('WebSocket message from client:', event.data); + }; + socket.onerror = err => { + console.error('WebSocket error:', err); + sockets.delete(socket); + }; + socket.onclose = () => { + console.log('WebSocket client disconnected'); + sockets.delete(socket); + }; + return response; + } + + // /debug-kv-view/app.js などの静的ファイルを提供 + if (url.pathname.startsWith('/debug-kv-view/')) { + // /debug-kv-view/ を public/ にマッピング + const filePath = url.pathname.replace('/debug-kv-view/', ''); + return serveDir(req, { + fsRoot: '/Users/rikegami/Development/ppap/debug_kv_view/public', + urlRoot: '', // fsRootからの相対パスになるように空にする + quiet: true, + enableCors: true, // CORSを有効にする場合 + }); + } + + return new Response('Not Found', { status: 404 }); + }, + { port: 8081 } + ); +} diff --git a/debug_kv_view/kv_debug_handler.ts b/debug_kv_view/kv_debug_handler.ts new file mode 100644 index 0000000..6ec6b1d --- /dev/null +++ b/debug_kv_view/kv_debug_handler.ts @@ -0,0 +1,169 @@ +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(); // kvインスタンスを確実に初期化 + watcherInitialized = true; + + console.log('Initializing KV watcher for debug view...'); + (async () => { + try { + const watcher = kv!.watch([[]]); + for await (const changes of watcher) { + // console.log("KV store changed (debug view):", changes.map(c => c.key)); + const currentData = await getAllKvData(); + for (const socket of sockets) { + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'update', data: currentData })); + } + } + } + } catch (err) { + console.error('KV watcher error (debug view):', err); + watcherInitialized = false; // エラー発生時は再初期化を試みれるように + } + })().catch(err => { + // このcatchはasync IIFE自体のエラー用 + console.error('Error in KV watcher IIFE (debug view):', err); + watcherInitialized = false; + }); +} + +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 }); + } + } + + 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..bac9ff4 --- /dev/null +++ b/debug_kv_view/public/app.js @@ -0,0 +1,80 @@ +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:'; + // debug_server.ts が 8081 ポートで起動することを想定 + // 通常のWebサーバー(Viteなど)が異なるポートで動作している場合、 + // WebSocketの接続先を明示的に指定する必要があります。 + // 例: const wsUrl = `${wsProtocol}//localhost:8081/ws/kv-debug`; + // 今回は /debug-kv-view と同じホスト・ポートで /ws/kv-debug に接続すると仮定 + const wsUrl = `${wsProtocol}//${window.location.host}/ws/kv-debug`; + + socket = new WebSocket(wsUrl); + + socket.onopen = () => { + console.log('WebSocket connection established'); + 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') { + console.error('Server error:', message.message); + error.value = message.message; + } + } catch (e) { + console.error('Failed to parse WebSocket message:', e); + error.value = 'Received malformed data from server.'; + } + }; + + socket.onerror = err => { + console.error('WebSocket error:', err); + 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 = () => { + console.log('WebSocket connection closed. Attempting to reconnect...'); + wsStatus.value = 'Disconnected'; + // 簡易的な再接続処理 (5秒後) + setTimeout(connectWebSocket, 5000); + }; + } + + connectWebSocket(); + + return { + kvData, + error, + wsStatus, + wsStatusColor, + }; + }, +}).mount('#app'); diff --git a/debug_kv_view/public/index.html b/debug_kv_view/public/index.html new file mode 100644 index 0000000..4bb49f9 --- /dev/null +++ b/debug_kv_view/public/index.html @@ -0,0 +1,105 @@ + + + + + + KV Store Debug View + + + + +
+

KV Store Debug View

+
+

+ WebSocket Status: + {{ wsStatus }} +

+
+
+

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 960b2e4..46828f2 100644 --- a/deno.json +++ b/deno.json @@ -4,7 +4,7 @@ "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", - "dev:serve": "deno run --watch --allow-net --allow-read --allow-env backend/server.ts", + "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", @@ -29,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 1026bda..09232d9 100644 --- a/deno.lock +++ b/deno.lock @@ -2096,6 +2096,105 @@ "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/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", diff --git a/src/components/Index.vue b/src/components/Index.vue index 41c0c91..2e91854 100644 --- a/src/components/Index.vue +++ b/src/components/Index.vue @@ -1,8 +1,95 @@ diff --git a/src/components/Room.vue b/src/components/Room.vue index a669b03..8889e06 100644 --- a/src/components/Room.vue +++ b/src/components/Room.vue @@ -1,6 +1,6 @@