feat(api): add WebSocket foundation and multiplayer game store
- Add ws/ directory: server setup, auth, router, connections map - WebSocket auth rejects upgrade with 401 if no Better Auth session - Router parses WsClientMessageSchema, dispatches to handlers, two-layer error handling (AppError -> WsErrorSchema, unknown -> 500) - connections.ts: in-memory Map<lobbyId, Map<userId, WebSocket>> with addConnection, removeConnection, broadcastToLobby - LobbyGameStore interface + InMemoryLobbyGameStore implementation following existing GameSessionStore pattern - multiplayerGameService: generateMultiplayerQuestions() decoupled from single-player flow, hardcoded defaults en->it nouns easy 3 rounds - handleLobbyJoin and handleLobbyLeave implemented - WsErrorSchema added to shared schemas - server.ts switched to createServer + setupWebSocket
This commit is contained in:
parent
b0aef8cc16
commit
745c5c4e3a
14 changed files with 443 additions and 1 deletions
|
|
@ -14,12 +14,14 @@
|
|||
"@lila/shared": "workspace:*",
|
||||
"better-auth": "^1.6.2",
|
||||
"cors": "^2.8.6",
|
||||
"express": "^5.2.1"
|
||||
"express": "^5.2.1",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.21.0"
|
||||
}
|
||||
|
|
|
|||
24
apps/api/src/lobbyGameStore/InMemoryLobbyGameStore.ts
Normal file
24
apps/api/src/lobbyGameStore/InMemoryLobbyGameStore.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { LobbyGameStore, LobbyGameData } from "./LobbyGameStore.js";
|
||||
|
||||
export class InMemoryLobbyGameStore implements LobbyGameStore {
|
||||
private games = new Map<string, LobbyGameData>();
|
||||
|
||||
async create(lobbyId: string, data: LobbyGameData): Promise<void> {
|
||||
if (this.games.has(lobbyId)) {
|
||||
throw new Error(`Game already exists for lobby: ${lobbyId}`);
|
||||
}
|
||||
this.games.set(lobbyId, data);
|
||||
}
|
||||
|
||||
async get(lobbyId: string): Promise<LobbyGameData | null> {
|
||||
return this.games.get(lobbyId) ?? null;
|
||||
}
|
||||
|
||||
async set(lobbyId: string, data: LobbyGameData): Promise<void> {
|
||||
this.games.set(lobbyId, data);
|
||||
}
|
||||
|
||||
async delete(lobbyId: string): Promise<void> {
|
||||
this.games.delete(lobbyId);
|
||||
}
|
||||
}
|
||||
17
apps/api/src/lobbyGameStore/LobbyGameStore.ts
Normal file
17
apps/api/src/lobbyGameStore/LobbyGameStore.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { MultiplayerQuestion } from "../services/multiplayerGameService.js";
|
||||
|
||||
export type LobbyGameData = {
|
||||
questions: MultiplayerQuestion[];
|
||||
currentIndex: number;
|
||||
// NOTE: Map types are used here for O(1) lookups in-process.
|
||||
// When migrating to Valkey, convert to plain objects for JSON serialization.
|
||||
playerAnswers: Map<string, number | null>; // userId → selectedOptionId, null = timed out
|
||||
scores: Map<string, number>; // userId → running total
|
||||
};
|
||||
|
||||
export interface LobbyGameStore {
|
||||
create(lobbyId: string, data: LobbyGameData): Promise<void>;
|
||||
get(lobbyId: string): Promise<LobbyGameData | null>;
|
||||
set(lobbyId: string, data: LobbyGameData): Promise<void>;
|
||||
delete(lobbyId: string): Promise<void>;
|
||||
}
|
||||
2
apps/api/src/lobbyGameStore/index.ts
Normal file
2
apps/api/src/lobbyGameStore/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export type { LobbyGameStore, LobbyGameData } from "./LobbyGameStore.js";
|
||||
export { InMemoryLobbyGameStore } from "./InMemoryLobbyGameStore.js";
|
||||
|
|
@ -1,8 +1,13 @@
|
|||
import { createServer } from "http";
|
||||
import { createApp } from "./app.js";
|
||||
import { setupWebSocket } from "./ws/index.js";
|
||||
|
||||
const PORT = Number(process.env["PORT"] ?? 3000);
|
||||
|
||||
const app = createApp();
|
||||
const server = createServer(app);
|
||||
|
||||
setupWebSocket(server);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server listening on port ${PORT}`);
|
||||
|
|
|
|||
75
apps/api/src/services/multiplayerGameService.ts
Normal file
75
apps/api/src/services/multiplayerGameService.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { randomUUID } from "crypto";
|
||||
import { getGameTerms, getDistractors } from "@lila/db";
|
||||
import type {
|
||||
GameQuestion,
|
||||
AnswerOption,
|
||||
SupportedLanguageCode,
|
||||
SupportedPos,
|
||||
DifficultyLevel,
|
||||
} from "@lila/shared";
|
||||
|
||||
// TODO(game-mode-slice): replace with lobby settings when mode selection lands
|
||||
const MULTIPLAYER_DEFAULTS = {
|
||||
sourceLanguage: "en" as SupportedLanguageCode,
|
||||
targetLanguage: "it" as SupportedLanguageCode,
|
||||
pos: "noun" as SupportedPos,
|
||||
difficulty: "easy" as DifficultyLevel,
|
||||
rounds: 3,
|
||||
};
|
||||
|
||||
const shuffle = <T>(array: T[]): T[] => {
|
||||
const result = [...array];
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
const temp = result[i]!;
|
||||
result[i] = result[j]!;
|
||||
result[j] = temp;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export type MultiplayerQuestion = GameQuestion & { correctOptionId: number };
|
||||
|
||||
export const generateMultiplayerQuestions = async (): Promise<
|
||||
MultiplayerQuestion[]
|
||||
> => {
|
||||
const correctAnswers = await getGameTerms(
|
||||
MULTIPLAYER_DEFAULTS.sourceLanguage,
|
||||
MULTIPLAYER_DEFAULTS.targetLanguage,
|
||||
MULTIPLAYER_DEFAULTS.pos,
|
||||
MULTIPLAYER_DEFAULTS.difficulty,
|
||||
MULTIPLAYER_DEFAULTS.rounds,
|
||||
);
|
||||
|
||||
const questions: MultiplayerQuestion[] = await Promise.all(
|
||||
correctAnswers.map(async (correctAnswer) => {
|
||||
const distractorTexts = await getDistractors(
|
||||
correctAnswer.termId,
|
||||
correctAnswer.targetText,
|
||||
MULTIPLAYER_DEFAULTS.targetLanguage,
|
||||
MULTIPLAYER_DEFAULTS.pos,
|
||||
MULTIPLAYER_DEFAULTS.difficulty,
|
||||
3,
|
||||
);
|
||||
|
||||
const optionTexts = [correctAnswer.targetText, ...distractorTexts];
|
||||
const shuffledTexts = shuffle(optionTexts);
|
||||
const correctOptionId = shuffledTexts.indexOf(correctAnswer.targetText);
|
||||
|
||||
const options: AnswerOption[] = shuffledTexts.map((text, index) => ({
|
||||
optionId: index,
|
||||
text,
|
||||
}));
|
||||
|
||||
return {
|
||||
questionId: randomUUID(),
|
||||
prompt: correctAnswer.sourceText,
|
||||
gloss: correctAnswer.sourceGloss,
|
||||
options,
|
||||
correctOptionId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return questions;
|
||||
};
|
||||
7
apps/api/src/types/express.d.ts
vendored
7
apps/api/src/types/express.d.ts
vendored
|
|
@ -1,4 +1,5 @@
|
|||
import type { Session, User } from "better-auth";
|
||||
import type { WebSocket } from "ws";
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
|
|
@ -8,4 +9,10 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
declare module "ws" {
|
||||
interface WebSocket {
|
||||
lobbyId?: string | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
|
|
|||
32
apps/api/src/ws/auth.ts
Normal file
32
apps/api/src/ws/auth.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { IncomingMessage } from "http";
|
||||
import type { Duplex } from "stream";
|
||||
import type { WebSocketServer, WebSocket } from "ws";
|
||||
import { fromNodeHeaders } from "better-auth/node";
|
||||
import { auth } from "../lib/auth.js";
|
||||
|
||||
export const handleUpgrade = async (
|
||||
request: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
wss: WebSocketServer,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: fromNodeHeaders(request.headers),
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
wss.handleUpgrade(request, socket, head, (ws: WebSocket) => {
|
||||
wss.emit("connection", ws, request, session);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("WebSocket auth error:", err);
|
||||
socket.write("HTTP/1.1 500 Internal Server Error\r\n\r\n");
|
||||
socket.destroy();
|
||||
}
|
||||
};
|
||||
44
apps/api/src/ws/connections.ts
Normal file
44
apps/api/src/ws/connections.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { WebSocket } from "ws";
|
||||
|
||||
// Map<lobbyId, Map<userId, WebSocket>>
|
||||
const connections = new Map<string, Map<string, WebSocket>>();
|
||||
|
||||
export const addConnection = (
|
||||
lobbyId: string,
|
||||
userId: string,
|
||||
ws: WebSocket,
|
||||
): void => {
|
||||
if (!connections.has(lobbyId)) {
|
||||
connections.set(lobbyId, new Map());
|
||||
}
|
||||
connections.get(lobbyId)!.set(userId, ws);
|
||||
};
|
||||
|
||||
export const removeConnection = (lobbyId: string, userId: string): void => {
|
||||
const lobby = connections.get(lobbyId);
|
||||
if (!lobby) return;
|
||||
lobby.delete(userId);
|
||||
if (lobby.size === 0) {
|
||||
connections.delete(lobbyId);
|
||||
}
|
||||
};
|
||||
|
||||
export const getConnections = (lobbyId: string): Map<string, WebSocket> => {
|
||||
return connections.get(lobbyId) ?? new Map();
|
||||
};
|
||||
|
||||
export const broadcastToLobby = (
|
||||
lobbyId: string,
|
||||
message: unknown,
|
||||
excludeUserId?: string,
|
||||
): void => {
|
||||
const lobby = connections.get(lobbyId);
|
||||
if (!lobby) return;
|
||||
const payload = JSON.stringify(message);
|
||||
for (const [userId, ws] of lobby) {
|
||||
if (excludeUserId && userId === excludeUserId) continue;
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(payload);
|
||||
}
|
||||
}
|
||||
};
|
||||
73
apps/api/src/ws/handlers/lobbyHandlers.ts
Normal file
73
apps/api/src/ws/handlers/lobbyHandlers.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import type { WebSocket } from "ws";
|
||||
import type { User } from "better-auth";
|
||||
import type { WsLobbyJoin, WsLobbyLeave } from "@lila/shared";
|
||||
import { getLobbyByCodeWithPlayers, deleteLobby, removePlayer } from "@lila/db";
|
||||
import {
|
||||
addConnection,
|
||||
removeConnection,
|
||||
broadcastToLobby,
|
||||
} from "../connections.js";
|
||||
import { NotFoundError, ConflictError } from "../../errors/AppError.js";
|
||||
|
||||
export const handleLobbyJoin = async (
|
||||
ws: WebSocket,
|
||||
msg: WsLobbyJoin,
|
||||
user: User,
|
||||
): Promise<void> => {
|
||||
// Load lobby and validate membership
|
||||
const lobby = await getLobbyByCodeWithPlayers(msg.code);
|
||||
if (!lobby) {
|
||||
throw new NotFoundError("Lobby not found");
|
||||
}
|
||||
|
||||
if (lobby.status !== "waiting") {
|
||||
throw new ConflictError("Lobby is not in waiting state");
|
||||
}
|
||||
|
||||
if (!lobby.players.some((p) => p.userId === user.id)) {
|
||||
throw new ConflictError("You are not a member of this lobby");
|
||||
}
|
||||
|
||||
// Register connection and tag the socket with lobbyId
|
||||
addConnection(lobby.id, user.id, ws);
|
||||
ws.lobbyId = lobby.id;
|
||||
|
||||
// Broadcast updated lobby state to all players
|
||||
broadcastToLobby(lobby.id, { type: "lobby:state", lobby });
|
||||
};
|
||||
|
||||
export const handleLobbyLeave = async (
|
||||
ws: WebSocket,
|
||||
msg: WsLobbyLeave,
|
||||
user: User,
|
||||
): Promise<void> => {
|
||||
const lobby = await getLobbyByCodeWithPlayers(msg.lobbyId);
|
||||
if (!lobby) return;
|
||||
|
||||
removeConnection(msg.lobbyId, user.id);
|
||||
ws.lobbyId = undefined;
|
||||
|
||||
if (lobby.hostUserId === user.id) {
|
||||
await deleteLobby(msg.lobbyId);
|
||||
broadcastToLobby(msg.lobbyId, {
|
||||
type: "error",
|
||||
code: "LOBBY_CLOSED",
|
||||
message: "Host left the lobby",
|
||||
});
|
||||
for (const player of lobby.players) {
|
||||
removeConnection(msg.lobbyId, player.userId);
|
||||
}
|
||||
} else {
|
||||
await removePlayer(msg.lobbyId, user.id);
|
||||
const updated = await getLobbyByCodeWithPlayers(lobby.code);
|
||||
if (!updated) return;
|
||||
broadcastToLobby(msg.lobbyId, { type: "lobby:state", lobby: updated });
|
||||
|
||||
// TODO(reconnection-slice): if lobby.status === 'in_progress', the game
|
||||
// continues with remaining players. If only one player remains after this
|
||||
// leave, end the game immediately and declare them winner. Currently we
|
||||
// broadcast updated lobby state and let the game resolve naturally via
|
||||
// timeouts — the disconnected player's answers will be null each round.
|
||||
// When reconnection handling is added, this is the place to change.
|
||||
}
|
||||
};
|
||||
52
apps/api/src/ws/index.ts
Normal file
52
apps/api/src/ws/index.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { WebSocketServer } from "ws";
|
||||
import type { WebSocket } from "ws";
|
||||
import type { Server } from "http";
|
||||
import type { IncomingMessage } from "http";
|
||||
import { handleUpgrade } from "./auth.js";
|
||||
import { handleMessage, type AuthenticatedUser } from "./router.js";
|
||||
import { removeConnection } from "./connections.js";
|
||||
import { handleLobbyLeave } from "./handlers/lobbyHandlers.js";
|
||||
|
||||
export const setupWebSocket = (server: Server): WebSocketServer => {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
if (request.url !== "/ws") {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
handleUpgrade(request, socket, head, wss);
|
||||
});
|
||||
|
||||
wss.on(
|
||||
"connection",
|
||||
(ws: WebSocket, _request: IncomingMessage, auth: AuthenticatedUser) => {
|
||||
ws.on("message", (rawData) => {
|
||||
handleMessage(ws, rawData, auth);
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
handleDisconnect(ws, auth);
|
||||
});
|
||||
|
||||
ws.on("error", (err) => {
|
||||
console.error(`WebSocket error for user ${auth.user.id}:`, err);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return wss;
|
||||
};
|
||||
|
||||
const handleDisconnect = async (
|
||||
ws: WebSocket,
|
||||
auth: AuthenticatedUser,
|
||||
): Promise<void> => {
|
||||
if (!ws.lobbyId) return; // user connected but never joined a lobby
|
||||
removeConnection(ws.lobbyId, auth.user.id);
|
||||
await handleLobbyLeave(
|
||||
ws,
|
||||
{ type: "lobby:leave", lobbyId: ws.lobbyId },
|
||||
auth.user,
|
||||
);
|
||||
};
|
||||
74
apps/api/src/ws/router.ts
Normal file
74
apps/api/src/ws/router.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import type { WebSocket } from "ws";
|
||||
import type { Session, User } from "better-auth";
|
||||
import { WsClientMessageSchema } from "@lila/shared";
|
||||
import {
|
||||
handleLobbyJoin,
|
||||
handleLobbyLeave,
|
||||
handleLobbyStart,
|
||||
} from "./handlers/lobbyHandlers.js";
|
||||
import { handleGameAnswer } from "./handlers/gameHandlers.js";
|
||||
import { AppError } from "../errors/AppError.js";
|
||||
|
||||
export type AuthenticatedUser = { session: Session; user: User };
|
||||
|
||||
const sendError = (ws: WebSocket, code: string, message: string): void => {
|
||||
ws.send(JSON.stringify({ type: "error", code, message }));
|
||||
};
|
||||
|
||||
const assertExhaustive = (_: never): never => {
|
||||
throw new Error("Unhandled message type");
|
||||
};
|
||||
|
||||
export const handleMessage = async (
|
||||
ws: WebSocket,
|
||||
rawData: unknown,
|
||||
auth: AuthenticatedUser,
|
||||
): Promise<void> => {
|
||||
// Layer 1: parse and validate incoming message
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(
|
||||
typeof rawData === "string" ? rawData : (rawData as Buffer).toString(),
|
||||
);
|
||||
} catch {
|
||||
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
||||
return;
|
||||
}
|
||||
const result = WsClientMessageSchema.safeParse(parsed);
|
||||
|
||||
if (!result.success) {
|
||||
ws.send(
|
||||
JSON.stringify({ type: "error", message: "Invalid message format" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = result.data;
|
||||
|
||||
// Layer 2: dispatch to handler, catch and translate errors
|
||||
try {
|
||||
switch (msg.type) {
|
||||
case "lobby:join":
|
||||
await handleLobbyJoin(ws, msg, auth.user);
|
||||
break;
|
||||
case "lobby:leave":
|
||||
await handleLobbyLeave(ws, msg, auth.user);
|
||||
break;
|
||||
case "lobby:start":
|
||||
await handleLobbyStart(ws, msg, auth.user);
|
||||
break;
|
||||
case "game:answer":
|
||||
await handleGameAnswer(ws, msg, auth.user);
|
||||
break;
|
||||
default:
|
||||
assertExhaustive(msg);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AppError) {
|
||||
sendError(ws, err.name, err.message);
|
||||
} else {
|
||||
console.error("Unhandled WS error:", err);
|
||||
sendError(ws, "INTERNAL_ERROR", "An unexpected error occurred");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -112,11 +112,19 @@ export const WsGameFinishedSchema = z.object({
|
|||
|
||||
export type WsGameFinished = z.infer<typeof WsGameFinishedSchema>;
|
||||
|
||||
export const WsErrorSchema = z.object({
|
||||
type: z.literal("error"),
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
});
|
||||
export type WsError = z.infer<typeof WsErrorSchema>;
|
||||
|
||||
export const WsServerMessageSchema = z.discriminatedUnion("type", [
|
||||
WsLobbyStateSchema,
|
||||
WsGameQuestionSchema,
|
||||
WsGameAnswerResultSchema,
|
||||
WsGameFinishedSchema,
|
||||
WsErrorSchema,
|
||||
]);
|
||||
|
||||
export type WsServerMessage = z.infer<typeof WsServerMessageSchema>;
|
||||
|
|
|
|||
27
pnpm-lock.yaml
generated
27
pnpm-lock.yaml
generated
|
|
@ -62,6 +62,9 @@ importers:
|
|||
express:
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1
|
||||
ws:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
devDependencies:
|
||||
'@types/cors':
|
||||
specifier: ^2.8.19
|
||||
|
|
@ -72,6 +75,9 @@ importers:
|
|||
'@types/supertest':
|
||||
specifier: ^7.2.0
|
||||
version: 7.2.0
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
supertest:
|
||||
specifier: ^7.2.2
|
||||
version: 7.2.2
|
||||
|
|
@ -1311,6 +1317,9 @@ packages:
|
|||
'@types/supertest@7.2.0':
|
||||
resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.57.1':
|
||||
resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
|
@ -2983,6 +2992,18 @@ packages:
|
|||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
ws@8.20.0:
|
||||
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xlsx@0.18.5:
|
||||
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
|
@ -3915,6 +3936,10 @@ snapshots:
|
|||
'@types/methods': 1.1.4
|
||||
'@types/superagent': 8.1.9
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 24.12.0
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
|
|
@ -5586,6 +5611,8 @@ snapshots:
|
|||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.20.0: {}
|
||||
|
||||
xlsx@0.18.5:
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue