diff --git a/docker-compose.local.yml b/docker-compose.local.yml index bb531ef..733d949 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -10,6 +10,7 @@ services: - .env.container ports: - '52600:52600' # backend + - '52601:52601' # backend websocket - '52800:52800' # frontend - '5555:5555' # Prisma Studio depends_on: diff --git a/packages/backend/package.json b/packages/backend/package.json index dcf31e4..ddcdad5 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -13,7 +13,8 @@ "migrate:exe": "prisma migrate dev", "migrate:status": "prisma migrate status", "migrate:reset": "prisma migrate reset", - "db:studio": "prisma studio" + "db:studio": "prisma studio", + "prepare": "if [ \"$NODE_ENV\" != \"production\" ]; then ts-patch install; fi" }, "author": "", "license": "ISC", @@ -25,7 +26,9 @@ "express": "^5.1.0", "prisma": "^6.9.0", "typescript": "^5.8.3", - "winston": "^3.17.0" + "typia": "^9.7.1", + "winston": "^3.17.0", + "ws": "^8.18.3" }, "devDependencies": { "@mermaid-js/mermaid-cli": "^11.4.2", @@ -33,10 +36,13 @@ "@types/express": "^5.0.3", "@types/node": "^22.15.30", "@types/winston": "^2.4.4", + "@types/ws": "^8.18.1", "prisma-erd-generator": "^2.0.4", "puppeteer": "^23.11.1", + "ts-patch": "^3.3.0", "tsc-alias": "^1.8.16", "tsc-watch": "^7.1.1", + "typescript": "^5.9.2", "vitest": "^1.6.1" } } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 0908590..1b90158 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -4,6 +4,10 @@ import fs from 'fs'; import path from 'path'; import { errorHandler } from './middlewares/errorHandler'; import { logger } from '@/utils/logger'; +import { WebSocketDonChannel } from './websocket/donChannel'; +import { WebSocketDon } from './websocket/messages'; +import { WebSocketServer } from 'ws'; +import { getActiveDons } from './usecases/getActiveDonsUsecase'; const app = express(); @@ -66,6 +70,18 @@ const server = app.listen(port, host, () => { }); }); +// WebSocketサーバー +const wss = new WebSocketServer({ port: 52601 }); +const donChannel = new WebSocketDonChannel({ wss, getActiveDons }); +logger.info('WebSocket server started on ws://localhost:52601'); + +// 5秒ごとに通知をテスト送信 +setInterval(async () => { + logger.info('Sending notification to active clients...'); + const activeDons = await getActiveDons(); + donChannel.notifyActiveDonState(activeDons); +}, 5000); + // Graceful shutdown process.on('SIGTERM', () => { logger.info('SIGTERM signal received: closing HTTP server'); diff --git a/packages/backend/src/utils/errorMessage.ts b/packages/backend/src/utils/errorMessage.ts new file mode 100644 index 0000000..70626af --- /dev/null +++ b/packages/backend/src/utils/errorMessage.ts @@ -0,0 +1,7 @@ +import typia from 'typia'; + +export const typiaValidationErrorMessage = (errors: typia.IValidation.IError[]): string => { + return `Validation errors found:\n${errors + .map((e) => `On ${e.path} Expected ${e.expected} Received ${e.value}`) + .join('\n')}`; +}; diff --git a/packages/backend/src/utils/logger.ts b/packages/backend/src/utils/logger.ts index 6f7d17a..4a5a4c0 100644 --- a/packages/backend/src/utils/logger.ts +++ b/packages/backend/src/utils/logger.ts @@ -1,6 +1,7 @@ import winston from 'winston'; const isDevelopment = process.env.NODE_ENV !== 'production'; +const isTest = process.env.NODE_ENV === 'test'; const logFormat = winston.format.combine( winston.format.timestamp({ @@ -16,13 +17,14 @@ const devFormat = winston.format.combine( }), winston.format.errors({ stack: true }), winston.format.colorize(), - winston.format.printf(({ timestamp, level, message, stack }) => { - return `${timestamp} [${level}]: ${message}${stack ? `\n${stack}` : ''}`; + winston.format.printf(({ timestamp, level, message, stack, ...metadata }) => { + const metaString = Object.keys(metadata).length > 0 ? `: ${JSON.stringify(metadata)}` : ''; + return `${timestamp} [${level}]: ${message}${metaString}${stack ? `\n${stack}` : ''}`; }), ); const logger = winston.createLogger({ - level: isDevelopment ? 'debug' : 'info', + level: isTest ? 'error' : isDevelopment ? 'debug' : 'info', format: isDevelopment ? devFormat : logFormat, transports: [ new winston.transports.Console(), diff --git a/packages/backend/src/websocket/__tests__/donChannel.test.ts b/packages/backend/src/websocket/__tests__/donChannel.test.ts new file mode 100644 index 0000000..3107e3e --- /dev/null +++ b/packages/backend/src/websocket/__tests__/donChannel.test.ts @@ -0,0 +1,219 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { WebSocketDonChannel } from '../donChannel'; +import { WebSocket, WebSocketServer } from 'ws'; +import { Don } from '../messages'; + +vi.mock('ws'); +vi.mock('typia', () => ({ + default: { + json: { + validateParse: vi.fn(() => ({ + success: true, + data: { type: 'request', data: { include: true } }, + })), + }, + }, +})); + +describe('WebSocketDonChannel', () => { + let mockWss: WebSocketServer; + let donChannel: WebSocketDonChannel; + + // ヘルパー関数:mockWebSocketを作成 + const createMockWebSocket = () => + ({ + on: vi.fn(), + send: vi.fn(), + readyState: WebSocket.OPEN, + }) as any; + + // ヘルパー関数:connectionハンドラーを取得 + const getConnectionHandler = () => { + const calls = (mockWss.on as any).mock.calls; + const connectionCall = calls.find((call: any[]) => call[0] === 'connection'); + return connectionCall[1]; + }; + + beforeEach(() => { + mockWss = { + on: vi.fn(), + clients: new Set(), + } as any; + + donChannel = new WebSocketDonChannel({ wss: mockWss }); + }); + + it('WebSocketDonChannelが初期化される', () => { + expect(donChannel).toBeDefined(); + expect(mockWss.on).toHaveBeenCalledWith('connection', expect.any(Function)); + expect(mockWss.on).toHaveBeenCalledWith('error', expect.any(Function)); + }); + + describe('メッセージ処理', () => { + it('正常なRequestNotificationMessageを受信して通知状態が有効になる', () => { + const mockWs = createMockWebSocket(); + const connectionHandler = getConnectionHandler(); + + // WebSocket接続をシミュレート + connectionHandler(mockWs); + + // messageハンドラーを取得 + const messageHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1]; + + // 正常なメッセージを送信 + const validMessage = JSON.stringify({ type: 'request', data: { include: true } }); + messageHandler(validMessage); + + // 通知が有効になっていることを確認(Don状態通知でテスト) + mockWss.clients = new Set([mockWs]); + donChannel.notifyActiveDonState([{ id: '1', state: 'ordered' }]); + + expect(mockWs.send).toHaveBeenCalled(); + }); + }); + + describe('Don状態通知', () => { + it('通知購読中のクライアントにのみメッセージが送信される', () => { + const subscribedWs = createMockWebSocket(); + const nonSubscribedWs = createMockWebSocket(); + + // 購読済みクライアントをmockWss.clientsに追加 + mockWss.clients = new Set([subscribedWs, nonSubscribedWs]); + + // 購読状態を設定(実際のコードでは通知リクエスト受信時に設定される) + const connectionHandler = getConnectionHandler(); + connectionHandler(subscribedWs); + + const messageHandler = subscribedWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1]; + + // 購読リクエストを送信(これにより通知状態が有効になる) + const requestMessage = JSON.stringify({ type: 'request', data: { include: true } }); + messageHandler(requestMessage); + + // Don状態を通知 + const donState: Don[] = [ + { id: '1', state: 'ordered' }, + { id: '2', state: 'cooking' }, + ]; + donChannel.notifyActiveDonState(donState); + + // 購読済みクライアントにのみ送信されることを確認 + expect(subscribedWs.send).toHaveBeenCalledWith( + JSON.stringify({ + type: 'state', + data: { dons: donState }, + }), + ); + expect(nonSubscribedWs.send).not.toHaveBeenCalled(); + }); + + it('OPEN状態のWebSocketにのみメッセージが送信される', () => { + const openWs = createMockWebSocket(); + const closedWs = { ...createMockWebSocket(), readyState: WebSocket.CLOSED }; + + mockWss.clients = new Set([openWs, closedWs]); + + // 両方のクライアントを購読状態にする + const connectionHandler = getConnectionHandler(); + connectionHandler(openWs); + connectionHandler(closedWs); + + const openMessageHandler = openWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1]; + const closedMessageHandler = closedWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1]; + + const requestMessage = JSON.stringify({ type: 'request', data: { include: true } }); + openMessageHandler(requestMessage); + closedMessageHandler(requestMessage); + + const donState: Don[] = [{ id: '1', state: 'ordered' }]; + donChannel.notifyActiveDonState(donState); + + expect(openWs.send).toHaveBeenCalled(); + expect(closedWs.send).not.toHaveBeenCalled(); + }); + + it('送信されるメッセージのフォーマットが正しい', () => { + const mockWs = createMockWebSocket(); + mockWss.clients = new Set([mockWs]); + + const connectionHandler = getConnectionHandler(); + connectionHandler(mockWs); + + const messageHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1]; + + const requestMessage = JSON.stringify({ type: 'request', data: { include: true } }); + messageHandler(requestMessage); + + const donState: Don[] = [{ id: 'test-id', state: 'cooking' }]; + donChannel.notifyActiveDonState(donState); + + const expectedMessage = JSON.stringify({ + type: 'state', + data: { dons: donState }, + }); + + expect(mockWs.send).toHaveBeenCalledWith(expectedMessage); + }); + }); + + describe('WebSocket切断・エラー処理', () => { + it('closeイベント時に通知状態がクリアされる', () => { + const mockWs = createMockWebSocket(); + const connectionHandler = getConnectionHandler(); + + connectionHandler(mockWs); + + // closeハンドラーを取得 + const closeHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'close')[1]; + + // 通知状態を設定 + const messageHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1]; + const requestMessage = JSON.stringify({ type: 'request', data: { include: true } }); + messageHandler(requestMessage); + + // 通知が有効であることを確認 + mockWs.send.mockClear(); + mockWss.clients = new Set([mockWs]); + donChannel.notifyActiveDonState([{ id: '1', state: 'ordered' }]); + expect(mockWs.send).toHaveBeenCalled(); + + // closeイベントを発火 + closeHandler(); + + // 通知状態がクリアされ、メッセージが送信されないことを確認 + mockWs.send.mockClear(); + donChannel.notifyActiveDonState([{ id: '1', state: 'ordered' }]); + expect(mockWs.send).not.toHaveBeenCalled(); + }); + + it('errorイベント時に通知状態がクリアされる', () => { + const mockWs = createMockWebSocket(); + const connectionHandler = getConnectionHandler(); + + connectionHandler(mockWs); + + // errorハンドラーを取得 + const errorHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'error')[1]; + + // 通知状態を設定 + const messageHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1]; + const requestMessage = JSON.stringify({ type: 'request', data: { include: true } }); + messageHandler(requestMessage); + + // 通知が有効であることを確認 + mockWs.send.mockClear(); + mockWss.clients = new Set([mockWs]); + donChannel.notifyActiveDonState([{ id: '1', state: 'ordered' }]); + expect(mockWs.send).toHaveBeenCalled(); + + // errorイベントを発火 + const error = new Error('Connection error'); + errorHandler(error); + + // 通知状態がクリアされ、メッセージが送信されないことを確認 + mockWs.send.mockClear(); + donChannel.notifyActiveDonState([{ id: '1', state: 'ordered' }]); + expect(mockWs.send).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/backend/src/websocket/__tests__/orderChannel.test.ts b/packages/backend/src/websocket/__tests__/orderChannel.test.ts new file mode 100644 index 0000000..e61dcc6 --- /dev/null +++ b/packages/backend/src/websocket/__tests__/orderChannel.test.ts @@ -0,0 +1,206 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { WebSocketOrderChannel } from '../orderChannel'; +import { WebSocket, WebSocketServer } from 'ws'; +import { Order } from '../messages'; + +vi.mock('ws'); +vi.mock('typia', () => ({ + default: { + json: { + validateParse: vi.fn(() => ({ + success: true, + data: { type: 'request', data: { include: true } }, + })), + }, + }, +})); + +describe('WebSocketOrderChannel', () => { + let orderChannel: WebSocketOrderChannel; + let mockWss: WebSocketServer; + + // ヘルパー関数:mockWebSocketを作成 + const createMockWebSocket = () => + ({ + on: vi.fn(), + send: vi.fn(), + readyState: WebSocket.OPEN, + }) as any; + + // ヘルパー関数:connectionハンドラーを取得 + const getConnectionHandler = () => { + const calls = (mockWss.on as any).mock.calls; + const connectionCall = calls.find((call: any[]) => call[0] === 'connection'); + return connectionCall[1]; + }; + + beforeEach(() => { + mockWss = { + on: vi.fn(), + clients: new Set(), + } as any; + + orderChannel = new WebSocketOrderChannel({ wss: mockWss }); + }); + + it('WebSocketOrderChannelが初期化される', () => { + expect(orderChannel).toBeDefined(); + expect(mockWss.on).toHaveBeenCalledWith('connection', expect.any(Function)); + expect(mockWss.on).toHaveBeenCalledWith('error', expect.any(Function)); + }); + + describe('メッセージ処理', () => { + it('正常なRequestNotificationMessageを受信して通知状態が有効になる', () => { + const mockWs = createMockWebSocket(); + const connectionHandler = getConnectionHandler(); + + connectionHandler(mockWs); + + const messageHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1]; + + const validMessage = JSON.stringify({ type: 'request', data: { include: true } }); + messageHandler(validMessage); + + // 通知が有効になっていることを確認 + mockWss.clients = new Set([mockWs]); + orderChannel.notifyActiveOrderState([{ id: '1', call_num: 1, state: 'ordered' }]); + + expect(mockWs.send).toHaveBeenCalled(); + }); + }); + + describe('Order状態通知', () => { + it('通知購読中のクライアントにのみメッセージが送信される', () => { + const subscribedWs = createMockWebSocket(); + const nonSubscribedWs = createMockWebSocket(); + + mockWss.clients = new Set([subscribedWs, nonSubscribedWs]); + + const connectionHandler = getConnectionHandler(); + connectionHandler(subscribedWs); + + const messageHandler = subscribedWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1]; + + const requestMessage = JSON.stringify({ type: 'request', data: { include: true } }); + messageHandler(requestMessage); + + const orderState: Order[] = [ + { id: '1', call_num: 1, state: 'ordered' }, + { id: '2', call_num: 2, state: 'ready' }, + ]; + orderChannel.notifyActiveOrderState(orderState); + + expect(subscribedWs.send).toHaveBeenCalledWith( + JSON.stringify({ + type: 'state', + data: { orders: orderState }, + }), + ); + expect(nonSubscribedWs.send).not.toHaveBeenCalled(); + }); + + it('OPEN状態のWebSocketにのみメッセージが送信される', () => { + const openWs = createMockWebSocket(); + const closedWs = { ...createMockWebSocket(), readyState: WebSocket.CLOSED }; + + mockWss.clients = new Set([openWs, closedWs]); + + const connectionHandler = getConnectionHandler(); + connectionHandler(openWs); + connectionHandler(closedWs); + + const openMessageHandler = openWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1]; + const closedMessageHandler = closedWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1]; + + const requestMessage = JSON.stringify({ type: 'request', data: { include: true } }); + openMessageHandler(requestMessage); + closedMessageHandler(requestMessage); + + const orderState: Order[] = [{ id: '1', call_num: 1, state: 'ordered' }]; + orderChannel.notifyActiveOrderState(orderState); + + expect(openWs.send).toHaveBeenCalled(); + expect(closedWs.send).not.toHaveBeenCalled(); + }); + + it('送信されるメッセージのフォーマットが正しい', () => { + const mockWs = createMockWebSocket(); + mockWss.clients = new Set([mockWs]); + + const connectionHandler = getConnectionHandler(); + connectionHandler(mockWs); + + const messageHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1]; + + const requestMessage = JSON.stringify({ type: 'request', data: { include: false } }); + messageHandler(requestMessage); + + const orderState: Order[] = [{ id: 'test-id', call_num: 123, state: 'ready' }]; + orderChannel.notifyActiveOrderState(orderState); + + const expectedMessage = JSON.stringify({ + type: 'state', + data: { orders: orderState }, + }); + + expect(mockWs.send).toHaveBeenCalledWith(expectedMessage); + }); + }); + + describe('WebSocket切断・エラー処理', () => { + it('closeイベント時に通知状態がクリアされる', () => { + const mockWs = createMockWebSocket(); + const connectionHandler = getConnectionHandler(); + + connectionHandler(mockWs); + + const closeHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'close')[1]; + + const messageHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1]; + const requestMessage = JSON.stringify({ type: 'request', data: { include: true } }); + messageHandler(requestMessage); + + // 通知が有効であることを確認 + mockWs.send.mockClear(); + mockWss.clients = new Set([mockWs]); + orderChannel.notifyActiveOrderState([{ id: '1', call_num: 1, state: 'ordered' }]); + expect(mockWs.send).toHaveBeenCalled(); + + // closeイベントを発火 + closeHandler(); + + // 通知状態がクリアされ、メッセージが送信されないことを確認 + mockWs.send.mockClear(); + orderChannel.notifyActiveOrderState([{ id: '1', call_num: 1, state: 'ordered' }]); + expect(mockWs.send).not.toHaveBeenCalled(); + }); + + it('errorイベント時に通知状態がクリアされる', () => { + const mockWs = createMockWebSocket(); + const connectionHandler = getConnectionHandler(); + + connectionHandler(mockWs); + + const errorHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'error')[1]; + + const messageHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1]; + const requestMessage = JSON.stringify({ type: 'request', data: { include: true } }); + messageHandler(requestMessage); + + // 通知が有効であることを確認 + mockWs.send.mockClear(); + mockWss.clients = new Set([mockWs]); + orderChannel.notifyActiveOrderState([{ id: '1', call_num: 1, state: 'ordered' }]); + expect(mockWs.send).toHaveBeenCalled(); + + // errorイベントを発火 + const error = new Error('Connection error'); + errorHandler(error); + + // 通知状態がクリアされ、メッセージが送信されないことを確認 + mockWs.send.mockClear(); + orderChannel.notifyActiveOrderState([{ id: '1', call_num: 1, state: 'ordered' }]); + expect(mockWs.send).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/backend/src/websocket/donChannel.ts b/packages/backend/src/websocket/donChannel.ts new file mode 100644 index 0000000..eec2459 --- /dev/null +++ b/packages/backend/src/websocket/donChannel.ts @@ -0,0 +1,106 @@ +import { logger } from '@/utils/logger'; +import typia from 'typia'; +import { WebSocket, WebSocketServer } from 'ws'; +import { WebSocketDon, DonStateMessage, RequestNotificationMessage, donConverter } from './messages'; +import { typiaValidationErrorMessage } from '@/utils/errorMessage'; +import { components } from 'api/schema'; + +// TODO: ここにinterfaceがあるのは正しい状態ではないのでexportが必要になったら直す +interface DonChannel { + notifyActiveDonState: (activeDons: ActiveDon[]) => void; +} + +type ActiveDon = components['schemas']['ActiveDon']; + +type WebSocketDonChannelProps = { + wss: WebSocketServer; + getActiveDons: () => Promise; +}; + +/** + * WebSocketのDonChannelに対応するサービス + * @method notifyActiveDonState - Donの状態を通知する + */ +export class WebSocketDonChannel implements DonChannel { + private readonly wss: WebSocketServer; + private readonly getActiveDons: () => Promise; + /** + * WebSocket接続ごとに通知するかどうかを管理するマップ + */ + private notificationStates: WeakMap = new WeakMap(); + + constructor({ wss, getActiveDons }: WebSocketDonChannelProps) { + this.wss = wss; + this.getActiveDons = getActiveDons; + logger.debug('WebSocketDonChannel initialized'); + + this.wss.on('connection', (ws: WebSocket) => { + logger.debug('New WebSocket connection established'); + ws.on('message', (message: string) => { + logger.debug(`Received WebSocket message: ${message}`); + + const parsed = typia.json.validateParse(message); + + if (!parsed.success) { + logger.warn('Invalid WebSocket message', { data: parsed.data }); + logger.debug(typiaValidationErrorMessage(parsed.errors)); + } else { + logger.debug('Parsed WebSocket message', { data: parsed.data }); + this.onRequestActiveDonStateNotification(ws); + } + }); + + ws.on('close', () => { + logger.debug('WebSocket connection closed'); + this.notificationStates.delete(ws); + }); + + ws.on('error', (error: Error) => { + logger.warn('Error with WebSocket connection', { error }); + this.notificationStates.delete(ws); + }); + }); + + this.wss.on('error', (error: Error) => { + logger.error('Error with WebSocket server', { error }); + }); + } + + /** + * Donの状態を購読しているクライアントに通知する + * @param currentState 通知するDonの状態 + */ + notifyActiveDonState(activeDons: ActiveDon[]): void { + const donStateMessage: DonStateMessage = { + type: 'state', + data: { + dons: activeDons.map((don) => donConverter.fromActiveDon(don)), + }, + }; + + logger.debug('Sending WebSocket message', { data: donStateMessage }); + [...this.wss.clients] + .filter((client: WebSocket) => client.readyState === WebSocket.OPEN && this.notificationStates.get(client)) + .forEach((client: WebSocket) => client.send(JSON.stringify(donStateMessage))); + } + + /** + * 特定のコネクションに対して通知を有効にする + * @param ws - WebSocket接続 + */ + private requestActiveDonStateNotification(ws: WebSocket): void { + this.notificationStates.set(ws, true); + } + + // Donなのでincludeを見る必要がない + /** + * Donの状態取得リクエストを受け取った時の処理 + * @param ws - WebSocket接続 + */ + private async onRequestActiveDonStateNotification(ws: WebSocket): Promise { + this.requestActiveDonStateNotification(ws); + + const activeDons = await this.getActiveDons(); + this.notifyActiveDonState(activeDons); + } +} diff --git a/packages/backend/src/websocket/donConverter.ts b/packages/backend/src/websocket/donConverter.ts new file mode 100644 index 0000000..b3938ba --- /dev/null +++ b/packages/backend/src/websocket/donConverter.ts @@ -0,0 +1,4 @@ +import { components } from 'api/schema'; +import { WebSocketDon } from './messages'; + +type ActiveDon = components['schemas']['ActiveDon']; diff --git a/packages/backend/src/websocket/messages.ts b/packages/backend/src/websocket/messages.ts new file mode 100644 index 0000000..45f2600 --- /dev/null +++ b/packages/backend/src/websocket/messages.ts @@ -0,0 +1,80 @@ +/** + * WebSocketで流れるメッセージの型定義 + */ + +import { components } from 'api/schema'; + +/** + * クライアント側が送信する通知リクエスト + * https://arcircle.github.io/Kojirer/asyncapi/#message-undefined + */ +export type RequestNotificationMessage = { + type: 'request'; + data: RequestOption; +}; + +/** + * サーバーがDonの状態を通知するメッセージ + * https://arcircle.github.io/Kojirer/asyncapi/#message-undefined + */ +export type DonStateMessage = { + type: 'state'; + data: { + dons: WebSocketDon[]; + }; +}; + +/** + * サーバーがOrderの状態を通知するメッセージ + * https://arcircle.github.io/Kojirer/asyncapi/#message-undefined + */ +export type OrderStateMessage = { + type: 'state'; + data: { + orders: WebSocketOrder[]; + }; +}; + +/** + * WebSocketスキーマにおけるRequestOptionオブジェクト + * https://arcircle.github.io/Kojirer/asyncapi/#schema-requestOption + */ +export type RequestOption = { + include: boolean; +}; + +/** + * WebSocketスキーマにおけるDonオブジェクト + * https://arcircle.github.io/Kojirer/asyncapi/#schema-don + */ +export type WebSocketDon = { + id: string; + state: 'ordered' | 'cooking' | 'cooked' | 'delivered' | 'cancelled'; +}; + +/** + * WebSocketスキーマにおけるOrderオブジェクト + * https://arcircle.github.io/Kojirer/asyncapi/#schema-order + */ +export type WebSocketOrder = { + id: string; + call_num: number; // TODO: このnumber微妙すぎるので後で考える + state: 'ordered' | 'ready' | 'delivered' | 'cancelled'; + dons?: WebSocketDon[]; // include: falseならundef, trueだがDonがない場合は空配列 +}; + +type ActiveDon = components['schemas']['ActiveDon']; +/** + * WebSocket用のDonオブジェクトコンバーター + */ +export const donConverter = { + /** + * ActiveDonからWebSocketDonを得る + */ + fromActiveDon: (don: ActiveDon): WebSocketDon => { + return { + id: don.id, + state: don.status, + }; + }, +}; diff --git a/packages/backend/src/websocket/orderChannel.ts b/packages/backend/src/websocket/orderChannel.ts new file mode 100644 index 0000000..5327e82 --- /dev/null +++ b/packages/backend/src/websocket/orderChannel.ts @@ -0,0 +1,112 @@ +import { logger } from '@/utils/logger'; +import typia from 'typia'; +import { WebSocket, WebSocketServer } from 'ws'; +import { WebSocketOrder, OrderStateMessage, RequestNotificationMessage } from './messages'; +import { typiaValidationErrorMessage } from '@/utils/errorMessage'; + +// TODO: ここにinterfaceがあるのは正しい状態ではないのでexportが必要になったら直す +interface OrderChannel { + notifyActiveOrderState: (currentState: WebSocketOrder[]) => void; +} + +type WebSocketOrderChannelProps = { + wss: WebSocketServer; +}; + +type OrderNotificationState = { + notify: boolean; + include: boolean; +}; + +/** + * WebSocketのOrderChannelに対応するサービス + * @method notifyActiveOrderState - Orderの状態を通知する + */ +export class WebSocketOrderChannel implements OrderChannel { + private readonly wss: WebSocketServer; + + /** + * WebSocket接続ごとに通知するかどうかを管理するマップ + */ + private notificationStates: WeakMap = new WeakMap(); + + constructor({ wss }: WebSocketOrderChannelProps) { + this.wss = wss; + logger.debug('WebSocketOrderChannel initialized'); + + this.wss.on('connection', (ws: WebSocket) => { + logger.debug('New WebSocket connection established'); + ws.on('message', (message: string) => { + logger.debug(`Received WebSocket message: ${message}`); + + const parsed = typia.json.validateParse(message); + + if (!parsed.success) { + logger.warn('Invalid WebSocket message', { data: parsed.data }); + logger.debug(typiaValidationErrorMessage(parsed.errors)); + } else { + logger.debug('Parsed WebSocket message', { data: parsed.data }); + this.onRequestActiveOrderStateNotification(ws, parsed.data); + } + }); + + ws.on('close', () => { + logger.debug('WebSocket connection closed'); + this.notificationStates.delete(ws); + }); + + ws.on('error', (error: Error) => { + logger.warn('Error with WebSocket connection', { error }); + this.notificationStates.delete(ws); + }); + }); + + this.wss.on('error', (error: Error) => { + logger.error('Error with WebSocket server', { error }); + }); + } + + // 一旦messagesのOrderにしているがDomainTypeにすべきではある + /** + * Orderの状態を購読しているクライアントに通知する + * @param currentState 通知するOrderの状態 + */ + notifyActiveOrderState(currentState: WebSocketOrder[]): void { + const orderStateMessage: OrderStateMessage = { + type: 'state', + data: { + orders: currentState, + }, + }; + + logger.debug('Sending WebSocket message', { data: orderStateMessage }); + [...this.wss.clients] + .filter((client: WebSocket) => client.readyState === WebSocket.OPEN && this.notificationStates.get(client)) + .forEach((client: WebSocket) => client.send(JSON.stringify(orderStateMessage))); + } + + /** + * 特定のコネクションに対して通知を有効にする + * @param ws - WebSocket接続 + * @param include - 依存データ(Donの状態)を含めるかどうか + */ + private requestActiveOrderStateNotification(ws: WebSocket, include: boolean): void { + this.notificationStates.set(ws, { notify: true, include }); + } + + /** + * Orderの状態取得リクエストを受け取った時の処理 + * @param ws - WebSocket接続 + */ + private onRequestActiveOrderStateNotification(ws: WebSocket, request: RequestNotificationMessage): void { + this.requestActiveOrderStateNotification(ws, request.data.include); + + // TODO: ActiveなOrderの状態を取得する処理を呼び出す + // TODO: Orderなのでincludeを見る必要がある + const exampleOrderState: WebSocketOrder[] = [ + { id: '1', call_num: 1, state: 'ordered' }, + { id: '2', call_num: 2, state: 'ready' }, + ]; + this.notifyActiveOrderState(exampleOrderState); + } +} diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index dfa2185..712da27 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -2,10 +2,8 @@ "compilerOptions": { /* Visit https://aka.ms/tsconfig to read more about this file */ "noImplicitAny": false, - /* Language and Environment */ "target": "ES2020", - /* Modules */ "module": "Node16", "rootDir": "./src", @@ -15,7 +13,6 @@ "@/*": ["./src/*", "./built/*"] }, "resolveJsonModule": true, - /* Emit */ "outDir": "./built", "declaration": true, @@ -24,12 +21,16 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "composite": true, - /* Type Checking */ "strict": true, - /* Completeness */ - "skipLibCheck": true + "skipLibCheck": true, + "plugins": [ + { + "transform": "typia/lib/transform" + } + ], + "strictNullChecks": true }, "include": ["src/**/*"], "exclude": ["src/**/*.test.ts", "src/**/__tests__/**/*"] diff --git a/packages/backend/vitest.config.ts b/packages/backend/vitest.config.ts index ae95488..522f591 100644 --- a/packages/backend/vitest.config.ts +++ b/packages/backend/vitest.config.ts @@ -11,4 +11,7 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, + define: { + 'process.env.NODE_ENV': JSON.stringify('test'), + }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bad7505..fcdd0b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,9 +39,15 @@ importers: typescript: specifier: ^5.8.3 version: 5.8.3 + typia: + specifier: ^9.7.1 + version: 9.7.1(typescript@5.8.3) winston: specifier: ^3.17.0 version: 3.17.0 + ws: + specifier: ^8.18.3 + version: 8.18.3 devDependencies: '@mermaid-js/mermaid-cli': specifier: ^11.4.2 @@ -58,12 +64,18 @@ importers: '@types/winston': specifier: ^2.4.4 version: 2.4.4 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 prisma-erd-generator: specifier: ^2.0.4 version: 2.0.4(@prisma/client@6.12.0(prisma@6.12.0(typescript@5.8.3))(typescript@5.8.3))(@types/react@18.3.23)(puppeteer@23.11.1(typescript@5.8.3)) puppeteer: specifier: ^23.11.1 version: 23.11.1(typescript@5.8.3) + ts-patch: + specifier: ^3.3.0 + version: 3.3.0 tsc-alias: specifier: ^1.8.16 version: 1.8.16 @@ -2805,6 +2817,9 @@ packages: cpu: [x64] os: [win32] + '@samchon/openapi@4.7.1': + resolution: {integrity: sha512-+rkMlSKMt7l3KGWJVWUle1CXEm0vA8FIF2rufHl+T1gN/gGrTEhL1gDK3FHYf8Nl5XReK0r1vL6Q2QTMwQN7xQ==} + '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -3030,6 +3045,9 @@ packages: '@smoya/multi-parser@5.0.9': resolution: {integrity: sha512-GXH3HscWq3Cu2y5/IeZLNvsWQJdAXbdI7AD2OnPrOWJdGp3pXgOu9ojAHH/Fc2WkF8krzWSPIe/NfbmQLokFag==} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@stoplight/better-ajv-errors@1.0.3': resolution: {integrity: sha512-0p9uXkuB22qGdNfy3VeEhxkU5uwvp/KrBTAbrLBURv6ilxIVwanKwjMc41lQfIVgPGcOkmLbTolfFrSsueu7zA==} engines: {node: ^12.20 || >= 14.13} @@ -3418,6 +3436,9 @@ packages: '@types/wrap-ansi@3.0.0': resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -3674,6 +3695,9 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -4129,6 +4153,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + commander@14.0.0: resolution: {integrity: sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==} engines: {node: '>=20'} @@ -4157,6 +4185,10 @@ packages: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} + comment-json@4.2.5: + resolution: {integrity: sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==} + engines: {node: '>= 6'} + common-ancestor-path@1.0.1: resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==} @@ -4591,6 +4623,10 @@ packages: resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} engines: {node: '>=12'} + drange@1.1.1: + resolution: {integrity: sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==} + engines: {node: '>=4'} + driver.js@1.3.6: resolution: {integrity: sha512-g2nNuu+tWmPpuoyk3ffpT9vKhjPz4NrJzq6mkRDZIwXCrFhrKdDJ9TX5tJOBpvCTBrBYjgRQ17XlcQB15q4gMg==} @@ -5152,6 +5188,10 @@ packages: resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} engines: {node: '>=10'} + global-prefix@4.0.0: + resolution: {integrity: sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==} + engines: {node: '>=16'} + globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} engines: {node: '>=8'} @@ -5206,6 +5246,10 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-own-prop@2.0.0: + resolution: {integrity: sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==} + engines: {node: '>=8'} + has-property-descriptors@1.0.2: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} @@ -5807,6 +5851,10 @@ packages: resolution: {integrity: sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==} engines: {node: '>=0.10.0'} + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} @@ -6652,6 +6700,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@0.2.11: + resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + package-manager-detector@1.3.0: resolution: {integrity: sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==} @@ -7054,6 +7105,10 @@ packages: resolution: {integrity: sha512-+wLDAV2NNv9NkfEUOYStaDu/6RYgYXeC1zLtXE+dMU/jDfjpN4iJnBGycDwFTFaIQGosOQhxph7fEX6Mpwxdug==} hasBin: true + randexp@0.5.3: + resolution: {integrity: sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==} + engines: {node: '>=4'} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -7256,6 +7311,10 @@ packages: resolution: {integrity: sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==} hasBin: true + repeat-string@1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} + engines: {node: '>=0.10'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -7306,6 +7365,10 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + ret@0.2.2: + resolution: {integrity: sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==} + engines: {node: '>=4'} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -7963,6 +8026,10 @@ packages: '@swc/wasm': optional: true + ts-patch@3.3.0: + resolution: {integrity: sha512-zAOzDnd5qsfEnjd9IGy1IRuvA7ygyyxxdxesbhMdutt8AHFjD8Vw8hU2rMF89HX1BKRWFYqKHrO8Q6lw0NeUZg==} + hasBin: true + tsc-alias@1.8.16: resolution: {integrity: sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==} engines: {node: '>=16.20.2'} @@ -8076,6 +8143,12 @@ packages: engines: {node: '>=14.17'} hasBin: true + typia@9.7.1: + resolution: {integrity: sha512-HY9hhvAPhFvFHfjA3Ny1DmucUyrbsFalt+wr58oBJyNBFUxE6nTF8aJSpLOv1Bav6dZslNh39MEez0s7qLB1jw==} + hasBin: true + peerDependencies: + typescript: '>=4.8.0 <5.10.0' + uc.micro@1.0.6: resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==} @@ -12186,6 +12259,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.42.0': optional: true + '@samchon/openapi@4.7.1': {} + '@sinclair/typebox@0.27.8': {} '@sindresorhus/is@5.6.0': {} @@ -12541,6 +12616,8 @@ snapshots: transitivePeerDependencies: - encoding + '@standard-schema/spec@1.0.0': {} + '@stoplight/better-ajv-errors@1.0.3(ajv@8.17.1)': dependencies: ajv: 8.17.1 @@ -13134,6 +13211,10 @@ snapshots: '@types/wrap-ansi@3.0.0': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.15.30 + '@types/yauzl@2.10.3': dependencies: '@types/node': 22.15.30 @@ -13443,6 +13524,8 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 + array-timsort@1.0.3: {} + array-union@2.1.0: {} arraybuffer.prototype.slice@1.0.4: @@ -13968,6 +14051,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@10.0.1: {} + commander@14.0.0: {} commander@4.1.1: {} @@ -13982,6 +14067,14 @@ snapshots: commander@9.5.0: {} + comment-json@4.2.5: + dependencies: + array-timsort: 1.0.3 + core-util-is: 1.0.3 + esprima: 4.0.1 + has-own-prop: 2.0.0 + repeat-string: 1.6.1 + common-ancestor-path@1.0.1: {} commondir@1.0.1: {} @@ -14418,6 +14511,8 @@ snapshots: dotenv@16.4.7: {} + drange@1.1.1: {} + driver.js@1.3.6: {} dunder-proto@1.0.1: @@ -15166,6 +15261,12 @@ snapshots: dependencies: ini: 2.0.0 + global-prefix@4.0.0: + dependencies: + ini: 4.1.3 + kind-of: 6.0.3 + which: 4.0.0 + globals@13.24.0: dependencies: type-fest: 0.20.2 @@ -15227,6 +15328,8 @@ snapshots: has-flag@4.0.0: {} + has-own-prop@2.0.0: {} + has-property-descriptors@1.0.2: dependencies: es-define-property: 1.0.1 @@ -15793,6 +15896,8 @@ snapshots: dependencies: is-buffer: 1.1.6 + kind-of@6.0.3: {} + kolorist@1.8.0: {} kuler@2.0.0: {} @@ -16649,6 +16754,10 @@ snapshots: package-json-from-dist@1.0.1: {} + package-manager-detector@0.2.11: + dependencies: + quansync: 0.2.10 + package-manager-detector@1.3.0: {} pacote@13.6.2: @@ -17071,6 +17180,11 @@ snapshots: json-schema-migrate: 0.2.0 webapi-parser: 0.5.0 + randexp@0.5.3: + dependencies: + drange: 1.1.1 + ret: 0.2.2 + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -17332,6 +17446,8 @@ snapshots: dependencies: jsesc: 3.0.2 + repeat-string@1.6.1: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -17375,6 +17491,8 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + ret@0.2.2: {} + retry@0.12.0: {} retry@0.13.1: {} @@ -18215,6 +18333,15 @@ snapshots: yn: 3.1.1 optional: true + ts-patch@3.3.0: + dependencies: + chalk: 4.1.2 + global-prefix: 4.0.0 + minimist: 1.2.8 + resolve: 1.22.10 + semver: 7.7.2 + strip-ansi: 6.0.1 + tsc-alias@1.8.16: dependencies: chokidar: 3.6.0 @@ -18353,6 +18480,17 @@ snapshots: typescript@5.8.3: {} + typia@9.7.1(typescript@5.8.3): + dependencies: + '@samchon/openapi': 4.7.1 + '@standard-schema/spec': 1.0.0 + commander: 10.0.1 + comment-json: 4.2.5 + inquirer: 8.2.6 + package-manager-detector: 0.2.11 + randexp: 0.5.3 + typescript: 5.8.3 + uc.micro@1.0.6: {} ufo@1.6.1: {}