fix: distinguish between session / user scoped (mobile) connections

This commit is contained in:
Kirill Dubovitskiy 2025-07-17 02:28:19 -07:00
parent 1ace32e5c3
commit 0b3017ef1b
5 changed files with 256 additions and 78 deletions

5
.gitignore vendored
View File

@ -1,4 +1,7 @@
node_modules
.env
dist
.pgdata
.pgdata
.env.local
.env

View File

@ -1,22 +0,0 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: handy_postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: handy
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:

View File

@ -8,6 +8,7 @@
"scripts": {
"build": "tsc --noEmit",
"start": "tsx ./sources/main.ts",
"dev": "lsof -ti tcp:3005 | xargs kill -9 && tsx --env-file=.env.local ./sources/main.ts",
"test": "vitest",
"migrate": "prisma migrate dev",
"generate": "prisma generate",
@ -52,4 +53,4 @@
"zod": "^3.24.2",
"zod-to-json-schema": "^3.24.3"
}
}
}

View File

@ -7,7 +7,22 @@ import * as privacyKit from "privacy-kit";
import * as tweetnacl from "tweetnacl";
import { db } from "@/storage/db";
import { Account, Update } from "@prisma/client";
import { pubsub } from "@/services/pubsub";
// Connection metadata types
interface SessionScopedConnection {
connectionType: 'session-scoped';
socket: Socket;
userId: string;
sessionId: string;
}
interface UserScopedConnection {
connectionType: 'user-scoped';
socket: Socket;
userId: string;
}
type ClientConnection = SessionScopedConnection | UserScopedConnection;
declare module 'fastify' {
interface FastifyRequest {
@ -18,6 +33,7 @@ declare module 'fastify' {
}
}
export async function startApi() {
// Configure
@ -77,6 +93,42 @@ export async function startApi() {
}
});
// Send session update to all relevant connections
let emitUpdateToInterestedClients = ({event, userId, sessionId, payload, skipSenderConnection}: {
event: string,
userId: string,
sessionId: string,
payload: any,
skipSenderConnection?: ClientConnection
}) => {
const connections = userIdToClientConnections.get(userId);
if (!connections) {
log({ module: 'websocket', level: 'warn' }, `No connections found for user ${userId}`);
return;
}
for (const connection of connections) {
// Skip message echo
if (skipSenderConnection && connection === skipSenderConnection) {
continue;
}
// Send to all user-scoped connections - we already matched user
if (connection.connectionType === 'user-scoped') {
log({ module: 'websocket' }, `Sending ${event} to user-scoped connection ${connection.socket.id}`);
connection.socket.emit(event, payload);
}
// Send to all session-scoped connections, only that match sessionId
if (connection.connectionType === 'session-scoped'
&& connection.sessionId === sessionId
) {
log({ module: 'websocket' }, `Sending ${event} to session-scoped connection ${connection.socket.id}`);
connection.socket.emit(event, payload);
}
}
}
// Auth schema
const authSchema = z.object({
publicKey: z.string(),
@ -259,7 +311,17 @@ export async function startApi() {
});
// Emit update to connected sockets
pubsub.emit('update', userId, result.update);
emitUpdateToInterestedClients({
event: 'update',
userId,
sessionId: result.session.id,
payload: {
id: result.update.id,
seq: result.update.seq,
body: result.update.content,
createdAt: result.update.createdAt.getTime()
}
});
return reply.send({
session: {
@ -352,15 +414,18 @@ export async function startApi() {
serveClient: false // Don't serve the client files
});
// Track connected users
const userSockets = new Map<string, Set<Socket>>();
// Track connections by scope type
const userIdToClientConnections = new Map<string, Set<ClientConnection>>();
// Track RPC listeners: Map<userId, Map<rpcName, Socket>>
// Track RPC listeners: Map<userId, Map<rpcMethodWithSessionPrefix, Socket>>
// Only session-scoped clients (CLI) register handlers, only user-scoped clients (mobile) call them
const rpcListeners = new Map<string, Map<string, Socket>>();
io.on("connection", async (socket) => {
log({ module: 'websocket' }, `New connection attempt from socket: ${socket.id}`);
const token = socket.handshake.auth.token as string;
const clientType = socket.handshake.auth.clientType as 'session-scoped' | 'user-scoped' | undefined;
const sessionId = socket.handshake.auth.sessionId as string | undefined;
if (!token) {
log({ module: 'websocket' }, `No token provided`);
@ -369,6 +434,14 @@ export async function startApi() {
return;
}
// Validate session-scoped clients have sessionId
if (clientType === 'session-scoped' && !sessionId) {
log({ module: 'websocket' }, `Session-scoped client missing sessionId`);
socket.emit('error', { message: 'Session ID required for session-scoped clients' });
socket.disconnect();
return;
}
const verified = await tokenVerifier.verify(token);
if (!verified) {
log({ module: 'websocket' }, `Invalid token provided`);
@ -377,42 +450,38 @@ export async function startApi() {
return;
}
log({ module: 'websocket' }, `Token verified: ${verified.user}`);
const userId = verified.user as string;
log({ module: 'websocket' }, `Token verified: ${userId}, clientType: ${clientType || 'user-scoped'}, sessionId: ${sessionId || 'none'}`);
// Track socket for user
if (!userSockets.has(userId)) {
userSockets.set(userId, new Set());
// Store connection based on type
const metadata = { clientType: clientType || 'user-scoped', sessionId };
let connection: ClientConnection;
if (metadata.clientType === 'session-scoped' && sessionId) {
connection = {
connectionType: 'session-scoped',
socket,
userId,
sessionId
};
} else {
connection = {
connectionType: 'user-scoped',
socket,
userId
};
}
userSockets.get(userId)!.add(socket);
// Subscribe to updates for this user
const updateHandler = (accountId: string, update: Update) => {
if (accountId === userId) {
socket.emit('update', {
id: update.id,
seq: update.seq,
body: update.content,
createdAt: update.createdAt.getTime()
});
}
};
pubsub.on('update', updateHandler);
const updateEphemeralHandler = (accountId: string, update: { type: 'activity', id: string, active: boolean, activeAt: number, thinking: boolean }) => {
if (accountId === userId) {
socket.emit('ephemeral', update);
}
};
pubsub.on('update-ephemeral', updateEphemeralHandler);
if (!userIdToClientConnections.has(userId)) {
userIdToClientConnections.set(userId, new Set());
}
userIdToClientConnections.get(userId)!.add(connection);
socket.on('disconnect', () => {
// Clean up
const sockets = userSockets.get(userId);
if (sockets) {
sockets.delete(socket);
if (sockets.size === 0) {
userSockets.delete(userId);
// Cleanup
const connections = userIdToClientConnections.get(userId);
if (connections) {
connections.delete(connection);
if (connections.size === 0) {
userIdToClientConnections.delete(userId);
}
}
@ -438,8 +507,6 @@ export async function startApi() {
}
}
pubsub.off('update', updateHandler);
pubsub.off('update-ephemeral', updateEphemeralHandler);
log({ module: 'websocket' }, `User disconnected: ${userId}`);
});
@ -471,12 +538,17 @@ export async function startApi() {
});
// Emit update to connected sockets
pubsub.emit('update-ephemeral', userId, {
type: 'activity',
id: sid,
active: true,
activeAt: t,
thinking
emitUpdateToInterestedClients({
event: 'ephemeral',
userId,
sessionId: sid,
payload: {
type: 'activity',
id: sid,
active: true,
activeAt: t,
thinking
}
});
});
@ -508,18 +580,25 @@ export async function startApi() {
});
// Emit update to connected sockets
pubsub.emit('update-ephemeral', userId, {
type: 'activity',
id: sid,
active: false,
activeAt: t,
thinking: false
emitUpdateToInterestedClients({
event: 'ephemeral',
userId,
sessionId: sid,
payload: {
type: 'activity',
id: sid,
active: false,
activeAt: t,
thinking: false
}
});
});
socket.on('message', async (data: any) => {
const { sid, message } = data;
log({ module: 'websocket' }, `Received message from socket ${socket.id}: ${sid} ${message.length} bytes`);
// Resolve session
const session = await db.session.findUnique({
where: { id: sid, accountId: userId }
@ -613,8 +692,19 @@ export async function startApi() {
if (!result) return;
// Emit update to connected sockets
pubsub.emit('update', userId, result.update);
// Emit update to relevant clients
emitUpdateToInterestedClients({
event: 'update',
userId,
sessionId: sid,
payload: {
id: result.update.id,
seq: result.update.seq,
body: result.update.content,
createdAt: result.update.createdAt.getTime()
},
skipSenderConnection: connection
});
});
socket.on('update-metadata', async (data: any, callback: (response: any) => void) => {
@ -695,7 +785,12 @@ export async function startApi() {
}
// Emit update to connected sockets
pubsub.emit('update', userId, result.update);
emitUpdateToInterestedClients({
event: 'update',
userId,
sessionId: sid,
payload: result.update
});
// Send success response with new version via callback
callback({ result: 'success', version: result.newMetadataVersion, metadata: metadata });
@ -780,7 +875,17 @@ export async function startApi() {
}
// Emit update to connected sockets
pubsub.emit('update', userId, result.update);
emitUpdateToInterestedClients({
event: 'update',
userId,
sessionId: sid,
payload: {
id: result.update.id,
seq: result.update.seq,
body: result.update.content,
createdAt: result.update.createdAt.getTime()
}
});
// Send success response with new version via callback
callback({ result: 'success', version: result.newAgentStateVersion, agentState: agentState });

View File

@ -1091,6 +1091,11 @@ color-name@~1.1.4:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
colorette@^2.0.7:
version "2.0.20"
resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a"
integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==
combined-stream@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
@ -1140,6 +1145,11 @@ date-fns@^4.1.0:
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.1.0.tgz#64b3d83fff5aa80438f5b1a633c2e83b8a1c2d14"
integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==
dateformat@^4.6.3:
version "4.6.3"
resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-4.6.3.tgz#556fa6497e5217fedb78821424f8a1c22fa3f4b5"
integrity sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==
debug@^4.1.1, debug@^4.3.4:
version "4.3.4"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
@ -1222,6 +1232,13 @@ elevenlabs@^1.54.0:
readable-stream "^4.5.2"
url-join "4.0.1"
end-of-stream@^1.1.0:
version "1.4.5"
resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.5.tgz#7344d711dea40e0b74abc2ed49778743ccedb08c"
integrity sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==
dependencies:
once "^1.4.0"
engine.io-parser@~5.2.1:
version "5.2.3"
resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz#00dc5b97b1f233a23c9398d0209504cf5f94d92f"
@ -1362,6 +1379,11 @@ expect-type@^1.2.1:
resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.2.1.tgz#af76d8b357cf5fa76c41c09dafb79c549e75f71f"
integrity sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==
fast-copy@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/fast-copy/-/fast-copy-3.0.2.tgz#59c68f59ccbcac82050ba992e0d5c389097c9d35"
integrity sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==
fast-decode-uri-component@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz#46f8b6c22b30ff7a81357d4f59abfae938202543"
@ -1396,6 +1418,11 @@ fast-redact@^3.1.1:
resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.5.0.tgz#e9ea02f7e57d0cd8438180083e93077e496285e4"
integrity sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==
fast-safe-stringify@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884"
integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==
fast-uri@^3.0.0, fast-uri@^3.0.1:
version "3.0.6"
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.0.6.tgz#88f130b77cfaea2378d56bf970dea21257a68748"
@ -1553,6 +1580,11 @@ hasown@^2.0.2:
dependencies:
function-bind "^1.1.2"
help-me@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/help-me/-/help-me-5.0.0.tgz#b1ebe63b967b74060027c2ac61f9be12d354a6f6"
integrity sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==
human-signals@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0"
@ -1603,6 +1635,11 @@ jose@^6.0.11:
resolved "https://registry.yarnpkg.com/jose/-/jose-6.0.11.tgz#0b7ea8b3b21a1bda5e00255a044c3a0e43270882"
integrity sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==
joycon@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03"
integrity sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==
json-schema-ref-resolver@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/json-schema-ref-resolver/-/json-schema-ref-resolver-2.0.1.tgz#c92f16b452df069daac53e1984159e0f9af0598d"
@ -1753,6 +1790,11 @@ mimic-fn@^2.1.0:
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
minimist@^1.2.6:
version "1.2.8"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
mnemonist@0.40.0:
version "0.40.0"
resolved "https://registry.yarnpkg.com/mnemonist/-/mnemonist-0.40.0.tgz#72e866d7f1e261d0c589717ff2bcfd6feb802db2"
@ -1814,6 +1856,13 @@ on-exit-leak-free@^2.1.0:
resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz#fed195c9ebddb7d9e4c3842f93f281ac8dadd3b8"
integrity sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==
once@^1.3.1, once@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==
dependencies:
wrappy "1"
onetime@^5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e"
@ -1853,6 +1902,25 @@ pino-abstract-transport@^2.0.0:
dependencies:
split2 "^4.0.0"
pino-pretty@^13.0.0:
version "13.0.0"
resolved "https://registry.yarnpkg.com/pino-pretty/-/pino-pretty-13.0.0.tgz#21d57fe940e34f2e279905d7dba2d7e2c4f9bf17"
integrity sha512-cQBBIVG3YajgoUjo1FdKVRX6t9XPxwB9lcNJVD5GCnNM4Y6T12YYx8c6zEejxQsU0wrg9TwmDulcE9LR7qcJqA==
dependencies:
colorette "^2.0.7"
dateformat "^4.6.3"
fast-copy "^3.0.2"
fast-safe-stringify "^2.1.1"
help-me "^5.0.0"
joycon "^3.1.1"
minimist "^1.2.6"
on-exit-leak-free "^2.1.0"
pino-abstract-transport "^2.0.0"
pump "^3.0.0"
secure-json-parse "^2.4.0"
sonic-boom "^4.0.1"
strip-json-comments "^3.1.1"
pino-std-serializers@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz#7c625038b13718dbbd84ab446bd673dc52259e3b"
@ -1932,6 +2000,14 @@ proxy-from-env@^1.1.0:
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
pump@^3.0.0:
version "3.0.3"
resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.3.tgz#151d979f1a29668dc0025ec589a455b53282268d"
integrity sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==
dependencies:
end-of-stream "^1.1.0"
once "^1.3.1"
pvtsutils@^1.3.5, pvtsutils@^1.3.6:
version "1.3.6"
resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.3.6.tgz#ec46e34db7422b9e4fdc5490578c1883657d6001"
@ -2060,6 +2136,11 @@ safe-stable-stringify@^2.3.1:
resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd"
integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==
secure-json-parse@^2.4.0:
version "2.7.0"
resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-2.7.0.tgz#5a5f9cd6ae47df23dba3151edd06855d47e09862"
integrity sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==
secure-json-parse@^3.0.1:
version "3.0.2"
resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-3.0.2.tgz#255b03bb0627ba5805f64f384b0a7691d8cb021b"
@ -2217,6 +2298,11 @@ strip-final-newline@^2.0.0:
resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad"
integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==
strip-json-comments@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
supports-color@^7.1.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
@ -2461,6 +2547,11 @@ why-is-node-running@^2.3.0:
siginfo "^2.0.0"
stackback "0.0.2"
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
ws@~8.17.1:
version "8.17.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b"