mirror of
https://github.com/nvms/prsm.git
synced 2025-12-15 15:50:53 +00:00
publish mesh
This commit is contained in:
parent
b5cd75a018
commit
6fe63c8d58
39
.github/workflows/mesh-tests.yml
vendored
Normal file
39
.github/workflows/mesh-tests.yml
vendored
Normal file
@ -0,0 +1,39 @@
|
||||
name: mesh-tests
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'packages/mesh/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'packages/mesh/**'
|
||||
|
||||
jobs:
|
||||
test-mesh:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: install bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
|
||||
- name: install deps
|
||||
working-directory: packages/mesh
|
||||
run: bun install
|
||||
|
||||
- name: run tests
|
||||
working-directory: packages/mesh
|
||||
env:
|
||||
REDIS_URL: redis://localhost:6379
|
||||
run: bun test
|
||||
34
packages/mesh/.gitignore
vendored
Normal file
34
packages/mesh/.gitignore
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
5
packages/mesh/.npmignore
Normal file
5
packages/mesh/.npmignore
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
src
|
||||
docker-compose.yml
|
||||
bun.lock
|
||||
vitest.config.ts
|
||||
336
packages/mesh/README.md
Normal file
336
packages/mesh/README.md
Normal file
@ -0,0 +1,336 @@
|
||||
# mesh
|
||||
|
||||
Mesh is a command-based WebSocket server and client framework designed for scalable, multi-instance deployments. It uses Redis to coordinate connections, rooms, and metadata across servers, enabling reliable horizontal scaling. Mesh includes built-in ping/latency tracking, automatic reconnection, and a simple command API for clean, asynchronous, RPC-like communication.
|
||||
|
||||
## Quickstart
|
||||
|
||||
### Server
|
||||
|
||||
```ts
|
||||
import { MeshServer, MeshContext } from "@prsm/mesh/server";
|
||||
|
||||
const server = new MeshServer({
|
||||
port: 8080,
|
||||
redisOptions: { host: "localhost", port: 6379 },
|
||||
});
|
||||
|
||||
server.registerCommand("echo", async (ctx) => {
|
||||
return `echo: ${ctx.payload}`;
|
||||
});
|
||||
|
||||
server.registerCommand("this-command-throws", async (ctx) => {
|
||||
throw new Error("Something went wrong");
|
||||
});
|
||||
|
||||
server.registerCommand("join-room", async (ctx) => {
|
||||
const { roomName } = ctx.payload;
|
||||
await server.addToRoom(roomName, ctx.connection);
|
||||
await server.broadcastRoom(roomName, "user-joined", {
|
||||
roomName,
|
||||
id: ctx.connection.id,
|
||||
});
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
server.registerCommand("broadcast", async (ctx) => {
|
||||
server.broadcast("announcement", ctx.payload);
|
||||
return { sent: true };
|
||||
});
|
||||
```
|
||||
|
||||
### Client
|
||||
|
||||
```ts
|
||||
import { MeshClient } from "@prsm/mesh/client";
|
||||
|
||||
const client = new MeshClient("ws://localhost:8080");
|
||||
await client.connect();
|
||||
|
||||
{
|
||||
const response = await client.command("echo", "Hello, world!");
|
||||
console.log(response); // echo: Hello, world!
|
||||
}
|
||||
|
||||
{
|
||||
// Or use the synchronous version which blocks the event loop
|
||||
// until the command is completed.
|
||||
const response = client.commandSync("echo", "Hello, world!");
|
||||
console.log(response); // echo: Hello, world!
|
||||
}
|
||||
|
||||
{
|
||||
const response = await client.command("this-command-throws");
|
||||
console.log(response); // { error: "Something went wrong" }
|
||||
}
|
||||
|
||||
{
|
||||
const response = await client.command("join-room", { roomName: "lobby" });
|
||||
console.log(response); // { success: true }
|
||||
}
|
||||
|
||||
client.on("latency", (event) => {
|
||||
console.log(`Latency: ${event.detail.latency}ms`);
|
||||
});
|
||||
|
||||
client.on("user-joined", (event) => {
|
||||
console.log(`User ${event.detail.id} joined ${event.detail.roomName}`);
|
||||
});
|
||||
|
||||
await client.close();
|
||||
```
|
||||
|
||||
## Room Communication Flow
|
||||
|
||||
The diagram below illustrates how Mesh handles communication across multiple server instances. It uses Redis to look up which connections belong to a room, determine their host instances, and routes messages accordingly — either locally or via pub/sub.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant ClientA
|
||||
participant ServerA
|
||||
participant Redis
|
||||
participant ServerB
|
||||
participant ClientB
|
||||
|
||||
ClientA->>ServerA: connect()
|
||||
ServerA->>Redis: register connection ID + instance ID
|
||||
ServerA->>Redis: add connection to room "room1"
|
||||
|
||||
ClientB->>ServerB: connect()
|
||||
ServerB->>Redis: register connection ID + instance ID
|
||||
ServerB->>Redis: add connection to room "room1"
|
||||
|
||||
ClientA->>ServerA: command("broadcastRoom", { roomName: "room1", payload })
|
||||
|
||||
ServerA->>Redis: getRoomConnectionIds("room1")
|
||||
ServerA->>Redis: getInstanceIdsForConnections([...])
|
||||
alt Local delivery
|
||||
ServerA->>ClientA: send(payload)
|
||||
end
|
||||
alt Remote delivery
|
||||
ServerA->>Redis: publish(pubsub channel for ServerB, payload)
|
||||
Redis-->>ServerB: message received
|
||||
ServerB->>ClientB: send(payload)
|
||||
end
|
||||
```
|
||||
|
||||
### Redis Channel Subscriptions
|
||||
|
||||
Mesh lets clients subscribe to Redis pub/sub channels and receive messages directly over their WebSocket connection. When subscribing, clients can optionally request recent message history.
|
||||
|
||||
#### Server Configuration
|
||||
|
||||
Expose the channels you want to allow subscriptions to:
|
||||
|
||||
```ts
|
||||
server.exposeChannel("notifications:global");
|
||||
server.exposeChannel(/^chat:.+$/);
|
||||
|
||||
// return false to disallow subscription, or true to allow
|
||||
server.exposeChannel(/^private:chat:.+$/, async (conn, channel) => {
|
||||
// per-client guarding
|
||||
const valid = await isPremiumUser(conn);
|
||||
return valid;
|
||||
});
|
||||
```
|
||||
|
||||
#### Client Usage
|
||||
|
||||
```ts
|
||||
const { success, history } = await client.subscribe(
|
||||
"chat:room1",
|
||||
(message) => {
|
||||
console.log("Live message:", message);
|
||||
},
|
||||
{ historyLimit: 3 }
|
||||
);
|
||||
|
||||
if (success) {
|
||||
console.log("Recent messages:", history); // ["msg3", "msg2", "msg1"]
|
||||
}
|
||||
```
|
||||
|
||||
Unsubscribe when no longer needed:
|
||||
|
||||
```ts
|
||||
await client.unsubscribe("chat:room1");
|
||||
```
|
||||
|
||||
#### Return Value
|
||||
|
||||
`client.subscribe(...)` returns an object:
|
||||
|
||||
```ts
|
||||
{
|
||||
success: boolean; // Whether the subscription was accepted
|
||||
history: string[]; // Most recent messages (newest first)
|
||||
}
|
||||
```
|
||||
|
||||
This feature is great for:
|
||||
|
||||
- Real-time chat and collaboration
|
||||
- Live system dashboards
|
||||
- Cross-instance pub/sub messaging
|
||||
- Notification feeds with instant context
|
||||
|
||||
### Metadata
|
||||
|
||||
You'll probably encounter a scenario where you need to relate some data to a particular connection. Mesh provides a way to do this using the `setMetadata` method. This is useful for storing user IDs, tokens, or any other information you need to associate with a connection.
|
||||
|
||||
Metadata is stored in Redis, so it can be safely accessed from any instance of your server.
|
||||
|
||||
```ts
|
||||
server.registerCommand("authenticate", async (ctx) => {
|
||||
// maybe do some actual authentication here
|
||||
const { userId } = ctx.payload;
|
||||
const token = encode({
|
||||
sub: userId,
|
||||
iat: Date.now(),
|
||||
exp: Date.now() + 3600,
|
||||
});
|
||||
|
||||
await server.connectionManager.setMetadata(ctx.connection, {
|
||||
userId,
|
||||
token,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
```
|
||||
|
||||
Get metadata for a specific connection:
|
||||
|
||||
```ts
|
||||
const metadata = await server.connectionManager.getMetadata(connectionId);
|
||||
// { userId, token }
|
||||
```
|
||||
|
||||
Get all metadata for all connections:
|
||||
|
||||
```ts
|
||||
const metadata = await server.connectionManager.getAllMetadata();
|
||||
// [{ [connectionId]: { userId, token } }, ...]
|
||||
```
|
||||
|
||||
Get all metadata for all connections in a specific room:
|
||||
|
||||
```ts
|
||||
const metadata = await server.connectionManager.getAllMetadataForRoom(roomName);
|
||||
// [{ [connectionId]: { userId, token } }, ...]
|
||||
```
|
||||
|
||||
### Command Middleware
|
||||
|
||||
Mesh allows you to define middleware functions that run before your command handlers. This is useful for tasks like authentication, validation, logging, or modifying the context before the main command logic executes.
|
||||
|
||||
Middleware can be applied globally to all commands or specifically to individual commands.
|
||||
|
||||
**Global Middleware:**
|
||||
|
||||
Applied to every command received by the server.
|
||||
|
||||
```ts
|
||||
server.addMiddleware(async (ctx) => {
|
||||
console.log(`Received command: ${ctx.command} from ${ctx.connection.id}`);
|
||||
});
|
||||
|
||||
server.addMiddleware(async (ctx) => {
|
||||
const metadata = await server.connectionManager.getMetadata(ctx.connection);
|
||||
if (!metadata?.userId) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
// add to context for downstream handler access
|
||||
ctx.user = { id: metadata.userId };
|
||||
});
|
||||
```
|
||||
|
||||
**Command-Specific Middleware:**
|
||||
|
||||
Applied only to the specified command, running _after_ any global middleware.
|
||||
|
||||
```ts
|
||||
const validateProfileUpdate = async (ctx) => {
|
||||
const { name, email } = ctx.payload;
|
||||
if (typeof name !== 'string' || name.length === 0) {
|
||||
throw new Error("Invalid name");
|
||||
}
|
||||
if (typeof email !== 'string' || !email.includes('@')) {
|
||||
throw new Error("Invalid email");
|
||||
}
|
||||
};
|
||||
|
||||
server.registerCommand(
|
||||
"update-profile",
|
||||
async (ctx) => {
|
||||
// ..
|
||||
return { success: true };
|
||||
},
|
||||
[validateProfileUpdate]
|
||||
);
|
||||
```
|
||||
|
||||
Middleware functions receive the same `MeshContext` object as command handlers and can be asynchronous. If a middleware function throws an error, the execution chain stops, and the error is sent back to the client.
|
||||
|
||||
## Latency Tracking and Connection Liveness
|
||||
|
||||
Mesh includes a built-in ping/pong system to track latency and detect dead connections. This is implemented at the _application level_ (not via raw WebSocket protocol `ping()` frames) to allow for:
|
||||
|
||||
- Accurate latency measurement from server to client.
|
||||
- Graceful connection closure and multi-instance Redis cleanup.
|
||||
- Fine-tuned control using configurable missed ping/pong thresholds.
|
||||
|
||||
### Server-Side Configuration
|
||||
|
||||
By default, the server sends periodic `ping` commands. Clients respond with `pong`. If the server misses more than `maxMissedPongs` consecutive responses, the connection is considered stale and is closed cleanly. This ensures all connection metadata and room membership are safely cleaned up across distributed instances.
|
||||
|
||||
You can configure the server like so:
|
||||
|
||||
```ts
|
||||
const server = new MeshServer({
|
||||
port: 8080,
|
||||
redisOptions: { host: "localhost", port: 6379 },
|
||||
pingInterval: 30000, // ms between ping commands
|
||||
latencyInterval: 5000, // ms between latency checks
|
||||
maxMissedPongs: 1, // how many consecutive pongs can be missed before closing (default: 1)
|
||||
});
|
||||
```
|
||||
|
||||
With the default `maxMissedPongs` value of 1, a client has roughly 2 \* pingInterval time to respond before being disconnected.
|
||||
|
||||
### Client-Side Configuration
|
||||
|
||||
On the client, Mesh automatically handles incoming `ping` commands by responding with a `pong`, and resets its internal missed pings counter. If the server stops sending `ping` messages (e.g. due to a dropped connection), the client will increment its missed pings counter. Once the counter exceeds `maxMissedPings`, the client will attempt to reconnect if `shouldReconnect` is enabled.
|
||||
|
||||
Client-side configuration looks like this:
|
||||
|
||||
```ts
|
||||
const client = new MeshClient("ws://localhost:8080", {
|
||||
pingTimeout: 30000, // ms between ping timeout checks
|
||||
maxMissedPings: 1, // how many consecutive pings can be missed before reconnecting (default: 1)
|
||||
shouldReconnect: true, // auto-reconnect when connection is lost
|
||||
reconnectInterval: 2000, // ms between reconnection attempts
|
||||
maxReconnectAttempts: 5, // give up after 5 tries (or Infinity by default)
|
||||
});
|
||||
```
|
||||
|
||||
Together, this system provides end-to-end connection liveness guarantees without relying on low-level WebSocket protocol `ping`/`pong` frames, which do not offer cross-instance cleanup or latency tracking. The configurable thresholds on both sides allow for fine-tuning the balance between responsiveness and tolerance for network latency.
|
||||
|
||||
## Comparison
|
||||
|
||||
| | **Mesh** | Socket.IO | Colyseus | Deepstream.io | ws (+ custom) | uWebSockets.js |
|
||||
| ------------------------ | ------------------------ | ------------------------------- | ------------------- | --------------- | -------------------- | ----------------------- |
|
||||
| **Command API (RPC)** | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||
| **Raw Events Support** | ✅ | ✅ | ⚠️ Limited | ✅ | ✅ | ✅ |
|
||||
| **Room Support** | ✅ | ✅ | ✅ | ✅ | ⚠️ DIY | ⚠️ Manual |
|
||||
| **Redis Scaling** | ✅ Native | ✅ With adapter | ✅ | ✅ | ✅ If added | ❌ |
|
||||
| **Connection Metadata** | ✅ Redis-backed | ⚠️ Manual | ⚠️ Limited | ✅ Records | ❌ | ❌ |
|
||||
| **Latency Tracking** | ✅ Built-in | ⚠️ Manual | ❌ | ❌ | ❌ | ❌ |
|
||||
| **Automatic Reconnect** | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
| **Redis Pub/Sub** | ✅ Client subscription | ⚠️ Server-side only | ❌ | ✅ | ❌ | ❌ |
|
||||
| **History on Subscribe** | ✅ Optional Redis-backed | ❌ | ❌ | ⚠️ Streams only | ⚠️ DIY | ❌ |
|
||||
| **Typescript-First** | ✅ Yes, mostly | ⚠️ Mixed | ✅ | ⚠️ | ⚠️ | ❌ |
|
||||
| **Scalability** | ✅ Horizontal via Redis | ✅ Horizontal via Redis Adapter | ✅ | ✅ | ⚠️ Manual | ✅ But no sync |
|
||||
| **Target Use Case** | Real-time/generic async | Real-time apps, chat | Multiplayer games | Pub/Sub, IoT | Anything (low-level) | Anything (perf-focused) |
|
||||
| **Ease of Use** | ✅ Minimal API | ⚠️ Event-centric | ⚠️ More boilerplate | ⚠️ More config | ⚠️ DIY | ⚠️ Very low-level |
|
||||
396
packages/mesh/bun.lock
Normal file
396
packages/mesh/bun.lock
Normal file
@ -0,0 +1,396 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "keepalive-multi",
|
||||
"dependencies": {
|
||||
"deasync": "^0.1.30",
|
||||
"ioredis": "^5.6.1",
|
||||
"uuid": "^11.1.0",
|
||||
"ws": "^8.18.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/deasync": "^0.1.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsup": "^8.4.0",
|
||||
"vitest": "^3.1.1",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.8.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.2", "", { "os": "android", "cpu": "arm" }, "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.2", "", { "os": "android", "cpu": "x64" }, "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.2", "", { "os": "linux", "cpu": "arm" }, "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.2", "", { "os": "linux", "cpu": "none" }, "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.2", "", { "os": "linux", "cpu": "none" }, "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.2", "", { "os": "linux", "cpu": "none" }, "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.2", "", { "os": "linux", "cpu": "x64" }, "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.2", "", { "os": "none", "cpu": "arm64" }, "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.2", "", { "os": "none", "cpu": "x64" }, "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.2", "", { "os": "win32", "cpu": "x64" }, "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA=="],
|
||||
|
||||
"@ioredis/commands": ["@ioredis/commands@1.2.0", "", {}, "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg=="],
|
||||
|
||||
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.40.0", "", { "os": "android", "cpu": "arm" }, "sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.40.0", "", { "os": "android", "cpu": "arm64" }, "sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.40.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.40.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.40.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.40.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.40.0", "", { "os": "linux", "cpu": "arm" }, "sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.40.0", "", { "os": "linux", "cpu": "arm" }, "sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.40.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.40.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.40.0", "", { "os": "linux", "cpu": "none" }, "sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg=="],
|
||||
|
||||
"@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.40.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.40.0", "", { "os": "linux", "cpu": "none" }, "sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.40.0", "", { "os": "linux", "cpu": "none" }, "sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.40.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.40.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.40.0", "", { "os": "linux", "cpu": "x64" }, "sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.40.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.40.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.2.9", "", { "dependencies": { "bun-types": "1.2.9" } }, "sha512-epShhLGQYc4Bv/aceHbmBhOz1XgUnuTZgcxjxk+WXwNyDXavv5QHD1QEFV0FwbTSQtNq6g4ZcV6y0vZakTjswg=="],
|
||||
|
||||
"@types/deasync": ["@types/deasync@0.1.5", "", {}, "sha512-mLov/tw+fOX4ZsrT9xuHOJv8xToOpNsp6W4gp8VDHy2qniJ58izyOzHlisnz5r8HdZ+WItDHtANWZy/W0JEJwg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
|
||||
|
||||
"@types/node": ["@types/node@22.14.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw=="],
|
||||
|
||||
"@types/uuid": ["@types/uuid@10.0.0", "", {}, "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@3.1.1", "", { "dependencies": { "@vitest/spy": "3.1.1", "@vitest/utils": "3.1.1", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-q/zjrW9lgynctNbwvFtQkGK9+vvHA5UzVi2V8APrp1C6fG6/MuYYkmlx4FubuqLycCeSdHD5aadWfua/Vr0EUA=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@3.1.1", "", { "dependencies": { "@vitest/spy": "3.1.1", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-bmpJJm7Y7i9BBELlLuuM1J1Q6EQ6K5Ye4wcyOpOMXMcePYKSIYlpcrCm4l/O6ja4VJA5G2aMJiuZkZdnxlC3SA=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@3.1.1", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@3.1.1", "", { "dependencies": { "@vitest/utils": "3.1.1", "pathe": "^2.0.3" } }, "sha512-X/d46qzJuEDO8ueyjtKfxffiXraPRfmYasoC4i5+mlLEJ10UvPb0XH5M9C3gWuxd7BAQhpK42cJgJtq53YnWVA=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@3.1.1", "", { "dependencies": { "@vitest/pretty-format": "3.1.1", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-bByMwaVWe/+1WDf9exFxWWgAixelSdiwo2p33tpqIlM14vW7PRV5ppayVXtfycqze4Qhtwag5sVhX400MLBOOw=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@3.1.1", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-+EmrUOOXbKzLkTDwlsc/xrwOlPDXyVk3Z6P6K4oiCndxz7YLpp/0R0UsWVOKT0IXWjjBJuSMk6D27qipaupcvQ=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@3.1.1", "", { "dependencies": { "@vitest/pretty-format": "3.1.1", "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" } }, "sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
|
||||
|
||||
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.9", "", { "dependencies": { "@types/node": "*", "@types/ws": "*" } }, "sha512-dk/kOEfQbajENN/D6FyiSgOKEuUi9PWfqKQJEgwKrCMWbjS/S6tEXp178mWvWAcUSYm9ArDlWHZKO3T/4cLXiw=="],
|
||||
|
||||
"bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="],
|
||||
|
||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||
|
||||
"chai": ["chai@5.2.0", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw=="],
|
||||
|
||||
"check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||
|
||||
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"deasync": ["deasync@0.1.30", "", { "dependencies": { "bindings": "^1.5.0", "node-addon-api": "^1.7.1" } }, "sha512-OaAjvEQuQ9tJsKG4oHO9nV1UHTwb2Qc2+fadB0VeVtD0Z9wiG1XPGLJ4W3aLhAoQSYTaLROFRbd5X20Dkzf7MQ=="],
|
||||
|
||||
"debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
|
||||
|
||||
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
|
||||
|
||||
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
||||
|
||||
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@1.6.0", "", {}, "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.2", "@esbuild/android-arm": "0.25.2", "@esbuild/android-arm64": "0.25.2", "@esbuild/android-x64": "0.25.2", "@esbuild/darwin-arm64": "0.25.2", "@esbuild/darwin-x64": "0.25.2", "@esbuild/freebsd-arm64": "0.25.2", "@esbuild/freebsd-x64": "0.25.2", "@esbuild/linux-arm": "0.25.2", "@esbuild/linux-arm64": "0.25.2", "@esbuild/linux-ia32": "0.25.2", "@esbuild/linux-loong64": "0.25.2", "@esbuild/linux-mips64el": "0.25.2", "@esbuild/linux-ppc64": "0.25.2", "@esbuild/linux-riscv64": "0.25.2", "@esbuild/linux-s390x": "0.25.2", "@esbuild/linux-x64": "0.25.2", "@esbuild/netbsd-arm64": "0.25.2", "@esbuild/netbsd-x64": "0.25.2", "@esbuild/openbsd-arm64": "0.25.2", "@esbuild/openbsd-x64": "0.25.2", "@esbuild/sunos-x64": "0.25.2", "@esbuild/win32-arm64": "0.25.2", "@esbuild/win32-ia32": "0.25.2", "@esbuild/win32-x64": "0.25.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"expect-type": ["expect-type@1.2.1", "", {}, "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw=="],
|
||||
|
||||
"fdir": ["fdir@6.4.3", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw=="],
|
||||
|
||||
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||
|
||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
|
||||
|
||||
"ioredis": ["ioredis@5.6.1", "", { "dependencies": { "@ioredis/commands": "^1.1.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
|
||||
|
||||
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||
|
||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
|
||||
"load-tsconfig": ["load-tsconfig@0.2.5", "", {}, "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg=="],
|
||||
|
||||
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
||||
|
||||
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
|
||||
|
||||
"lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="],
|
||||
|
||||
"loupe": ["loupe@3.1.3", "", {}, "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug=="],
|
||||
|
||||
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
|
||||
|
||||
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"pathval": ["pathval@2.0.0", "", {}, "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
|
||||
|
||||
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
|
||||
|
||||
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
|
||||
|
||||
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
|
||||
|
||||
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
|
||||
|
||||
"rollup": ["rollup@4.40.0", "", { "dependencies": { "@types/estree": "1.0.7" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.40.0", "@rollup/rollup-android-arm64": "4.40.0", "@rollup/rollup-darwin-arm64": "4.40.0", "@rollup/rollup-darwin-x64": "4.40.0", "@rollup/rollup-freebsd-arm64": "4.40.0", "@rollup/rollup-freebsd-x64": "4.40.0", "@rollup/rollup-linux-arm-gnueabihf": "4.40.0", "@rollup/rollup-linux-arm-musleabihf": "4.40.0", "@rollup/rollup-linux-arm64-gnu": "4.40.0", "@rollup/rollup-linux-arm64-musl": "4.40.0", "@rollup/rollup-linux-loongarch64-gnu": "4.40.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.40.0", "@rollup/rollup-linux-riscv64-gnu": "4.40.0", "@rollup/rollup-linux-riscv64-musl": "4.40.0", "@rollup/rollup-linux-s390x-gnu": "4.40.0", "@rollup/rollup-linux-x64-gnu": "4.40.0", "@rollup/rollup-linux-x64-musl": "4.40.0", "@rollup/rollup-win32-arm64-msvc": "4.40.0", "@rollup/rollup-win32-ia32-msvc": "4.40.0", "@rollup/rollup-win32-x64-msvc": "4.40.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
|
||||
|
||||
"std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="],
|
||||
|
||||
"string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
||||
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
|
||||
|
||||
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"sucrase": ["sucrase@3.35.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA=="],
|
||||
|
||||
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
||||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.12", "", { "dependencies": { "fdir": "^6.4.3", "picomatch": "^4.0.2" } }, "sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww=="],
|
||||
|
||||
"tinypool": ["tinypool@1.0.2", "", {}, "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
|
||||
|
||||
"tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="],
|
||||
|
||||
"tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="],
|
||||
|
||||
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
|
||||
|
||||
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||
|
||||
"tsup": ["tsup@8.4.0", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.25.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "0.8.0-beta.0", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-b+eZbPCjz10fRryaAA7C8xlIHnf8VnsaRqydheLIqwG/Mcpfk8Z5zp3HayX7GaTygkigHl5cBUs+IhcySiIexQ=="],
|
||||
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
|
||||
|
||||
"vite": ["vite@6.3.0", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.3", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.12" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-9aC0n4pr6hIbvi1YOpFjwQ+QOTGssvbJKoeYkuHHGWwlXfdxQlI8L2qNMo9awEEcCPSiS+5mJZk5jH1PAqoDeQ=="],
|
||||
|
||||
"vite-node": ["vite-node@3.1.1", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", "es-module-lexer": "^1.6.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-V+IxPAE2FvXpTCHXyNem0M+gWm6J7eRyWPR6vYoG/Gl+IscNOjXzztUhimQgTxaAoUoj40Qqimaa0NLIOOAH4w=="],
|
||||
|
||||
"vitest": ["vitest@3.1.1", "", { "dependencies": { "@vitest/expect": "3.1.1", "@vitest/mocker": "3.1.1", "@vitest/pretty-format": "^3.1.1", "@vitest/runner": "3.1.1", "@vitest/snapshot": "3.1.1", "@vitest/spy": "3.1.1", "@vitest/utils": "3.1.1", "chai": "^5.2.0", "debug": "^4.4.0", "expect-type": "^1.2.0", "magic-string": "^0.30.17", "pathe": "^2.0.3", "std-env": "^3.8.1", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", "vite-node": "3.1.1", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.1.1", "@vitest/ui": "3.1.1", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-kiZc/IYmKICeBAZr9DQ5rT7/6bD9G7uqQEki4fxazi1jdVl2mWGzedtBs5s6llz59yQhVb7FFY2MbHzHCnT79Q=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"ws": ["ws@8.18.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w=="],
|
||||
|
||||
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
}
|
||||
}
|
||||
16
packages/mesh/docker-compose.yml
Normal file
16
packages/mesh/docker-compose.yml
Normal file
@ -0,0 +1,16 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- "6379:6379"
|
||||
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
||||
|
||||
redis-commander:
|
||||
image: rediscommander/redis-commander:latest
|
||||
environment:
|
||||
- REDIS_HOSTS=local:redis:6379
|
||||
ports:
|
||||
- "8081:8081"
|
||||
depends_on:
|
||||
- redis
|
||||
51
packages/mesh/package.json
Normal file
51
packages/mesh/package.json
Normal file
@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@prsm/mesh",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js",
|
||||
"require": "./dist/server/index.cjs"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./dist/client/index.d.ts",
|
||||
"import": "./dist/client/index.js",
|
||||
"require": "./dist/client/index.cjs"
|
||||
}
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"server": [
|
||||
"dist/server/index.d.ts"
|
||||
],
|
||||
"client": [
|
||||
"dist/client/index.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run build:prep && bun run build:server && bun run build:client",
|
||||
"build:client": "tsup src/client/index.ts --format cjs,esm --dts --clean --minify --out-dir dist/client",
|
||||
"build:prep": "rm -rf dist && mkdir dist && mkdir dist/server && mkdir dist/client",
|
||||
"build:server": "tsup src/server/index.ts --format cjs,esm --dts --clean --minify --out-dir dist/server",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"deasync": "^0.1.30",
|
||||
"ioredis": "^5.6.1",
|
||||
"uuid": "^11.1.0",
|
||||
"ws": "^8.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/deasync": "^0.1.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsup": "^8.4.0",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
406
packages/mesh/src/client/client.ts
Normal file
406
packages/mesh/src/client/client.ts
Normal file
@ -0,0 +1,406 @@
|
||||
import deasync from "deasync";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { WebSocket } from "ws";
|
||||
import { CodeError } from "../common/codeerror";
|
||||
import { Status } from "../common/status";
|
||||
import { Connection } from "./connection";
|
||||
|
||||
export { Status } from "../common/status";
|
||||
|
||||
export type MeshClientOptions = Partial<{
|
||||
/**
|
||||
* The number of milliseconds to wait before considering the connection closed due to inactivity.
|
||||
* When this happens, the connection will be closed and a reconnect will be attempted if
|
||||
* {@link MeshClientOptions.shouldReconnect} is true. This number should match the server's
|
||||
* `pingInterval` option.
|
||||
*
|
||||
* @default 30000
|
||||
*/
|
||||
pingTimeout: number;
|
||||
|
||||
/**
|
||||
* The maximum number of consecutive ping intervals the client will wait
|
||||
* for a ping message before considering the connection closed.
|
||||
* A value of 1 means the client must receive a ping within roughly 2 * pingTimeout
|
||||
* before attempting to reconnect.
|
||||
*
|
||||
* @default 1
|
||||
*/
|
||||
maxMissedPings: number;
|
||||
|
||||
/**
|
||||
* Whether or not to reconnect automatically.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
shouldReconnect: boolean;
|
||||
|
||||
/**
|
||||
* The number of milliseconds to wait between reconnect attempts.
|
||||
*
|
||||
* @default 2000
|
||||
*/
|
||||
reconnectInterval: number;
|
||||
|
||||
/**
|
||||
* The number of times to attempt to reconnect before giving up and
|
||||
* emitting a `reconnectfailed` event.
|
||||
*
|
||||
* @default Infinity
|
||||
*/
|
||||
maxReconnectAttempts: number;
|
||||
}>;
|
||||
|
||||
export class MeshClient extends EventEmitter {
|
||||
connection: Connection;
|
||||
url: string;
|
||||
socket: WebSocket | null = null;
|
||||
pingTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
missedPings = 0;
|
||||
options: Required<MeshClientOptions>;
|
||||
isReconnecting = false;
|
||||
private _status: Status = Status.OFFLINE;
|
||||
|
||||
constructor(url: string, opts: MeshClientOptions = {}) {
|
||||
super();
|
||||
this.url = url;
|
||||
this.connection = new Connection(null);
|
||||
this.options = {
|
||||
pingTimeout: opts.pingTimeout ?? 30_000,
|
||||
maxMissedPings: opts.maxMissedPings ?? 1,
|
||||
shouldReconnect: opts.shouldReconnect ?? true,
|
||||
reconnectInterval: opts.reconnectInterval ?? 2_000,
|
||||
maxReconnectAttempts: opts.maxReconnectAttempts ?? Infinity,
|
||||
};
|
||||
|
||||
this.setupConnectionEvents();
|
||||
}
|
||||
|
||||
get status(): Status {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
private setupConnectionEvents(): void {
|
||||
this.connection.on("message", (data) => {
|
||||
this.emit("message", data);
|
||||
|
||||
const systemCommands = [
|
||||
"ping",
|
||||
"pong",
|
||||
"latency",
|
||||
"latency:request",
|
||||
"latency:response",
|
||||
];
|
||||
if (data.command && !systemCommands.includes(data.command)) {
|
||||
this.emit(data.command, data.payload);
|
||||
}
|
||||
});
|
||||
|
||||
this.connection.on("close", () => {
|
||||
this._status = Status.OFFLINE;
|
||||
this.emit("close");
|
||||
this.reconnect();
|
||||
});
|
||||
|
||||
this.connection.on("error", (error) => {
|
||||
this.emit("error", error);
|
||||
});
|
||||
|
||||
this.connection.on("ping", () => {
|
||||
this.heartbeat();
|
||||
this.emit("ping");
|
||||
});
|
||||
|
||||
this.connection.on("latency", (data) => {
|
||||
this.emit("latency", data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the WebSocket server.
|
||||
*
|
||||
* @returns {Promise<void>} A promise that resolves when the connection is established.
|
||||
*/
|
||||
connect(): Promise<void> {
|
||||
if (this._status === Status.ONLINE) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (
|
||||
this._status === Status.CONNECTING ||
|
||||
this._status === Status.RECONNECTING
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onConnect = () => {
|
||||
this.removeListener("connect", onConnect);
|
||||
this.removeListener("error", onError);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onError = (error: Error) => {
|
||||
this.removeListener("connect", onConnect);
|
||||
this.removeListener("error", onError);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
this.once("connect", onConnect);
|
||||
this.once("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
this._status = Status.CONNECTING;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.socket = new WebSocket(this.url);
|
||||
|
||||
this.socket.onopen = () => {
|
||||
this._status = Status.ONLINE;
|
||||
this.connection.socket = this.socket;
|
||||
this.connection.status = Status.ONLINE;
|
||||
this.connection.applyListeners();
|
||||
this.heartbeat();
|
||||
|
||||
this.emit("connect");
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.socket.onerror = (error) => {
|
||||
this._status = Status.OFFLINE;
|
||||
reject(
|
||||
new CodeError(
|
||||
"WebSocket connection error",
|
||||
"ECONNECTION",
|
||||
"ConnectionError"
|
||||
)
|
||||
);
|
||||
};
|
||||
} catch (error) {
|
||||
this._status = Status.OFFLINE;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private heartbeat(): void {
|
||||
this.missedPings = 0;
|
||||
|
||||
if (!this.pingTimeout) {
|
||||
this.pingTimeout = setTimeout(() => {
|
||||
this.checkPingStatus();
|
||||
}, this.options.pingTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
private checkPingStatus(): void {
|
||||
this.missedPings++;
|
||||
|
||||
if (this.missedPings > this.options.maxMissedPings) {
|
||||
if (this.options.shouldReconnect) {
|
||||
this.reconnect();
|
||||
}
|
||||
} else {
|
||||
this.pingTimeout = setTimeout(() => {
|
||||
this.checkPingStatus();
|
||||
}, this.options.pingTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect the client from the server.
|
||||
* The client will not attempt to reconnect.
|
||||
*
|
||||
* @returns {Promise<void>} A promise that resolves when the connection is closed.
|
||||
*/
|
||||
close(): Promise<void> {
|
||||
this.options.shouldReconnect = false;
|
||||
|
||||
if (this._status === Status.OFFLINE) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const onClose = () => {
|
||||
this.removeListener("close", onClose);
|
||||
this._status = Status.OFFLINE;
|
||||
this.emit("disconnect");
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.once("close", onClose);
|
||||
|
||||
clearTimeout(this.pingTimeout);
|
||||
this.pingTimeout = undefined;
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private reconnect(): void {
|
||||
if (!this.options.shouldReconnect || this.isReconnecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._status = Status.RECONNECTING;
|
||||
this.isReconnecting = true;
|
||||
|
||||
// Reset ping tracking
|
||||
clearTimeout(this.pingTimeout);
|
||||
this.pingTimeout = undefined;
|
||||
this.missedPings = 0;
|
||||
|
||||
let attempt = 1;
|
||||
|
||||
if (this.socket) {
|
||||
try {
|
||||
this.socket.close();
|
||||
} catch (e) {
|
||||
// ignore errors during close
|
||||
}
|
||||
}
|
||||
|
||||
const connect = () => {
|
||||
this.socket = new WebSocket(this.url);
|
||||
|
||||
this.socket.onerror = () => {
|
||||
attempt++;
|
||||
|
||||
if (attempt <= this.options.maxReconnectAttempts) {
|
||||
setTimeout(connect, this.options.reconnectInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isReconnecting = false;
|
||||
this._status = Status.OFFLINE;
|
||||
this.emit("reconnectfailed");
|
||||
};
|
||||
|
||||
this.socket.onopen = () => {
|
||||
this.isReconnecting = false;
|
||||
this._status = Status.ONLINE;
|
||||
this.connection.socket = this.socket;
|
||||
this.connection.status = Status.ONLINE;
|
||||
this.connection.applyListeners(true);
|
||||
this.heartbeat();
|
||||
|
||||
this.emit("connect");
|
||||
this.emit("reconnect");
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command to the server and wait for a response.
|
||||
*
|
||||
* @param {string} command - The command name to send.
|
||||
* @param {unknown} payload - The payload to send with the command.
|
||||
* @param {number} expiresIn - Timeout in milliseconds.
|
||||
* @returns {Promise<unknown>} A promise that resolves with the command result.
|
||||
*/
|
||||
command(
|
||||
command: string,
|
||||
payload?: any,
|
||||
expiresIn: number = 30000
|
||||
): Promise<any> {
|
||||
if (this._status !== Status.ONLINE) {
|
||||
return this.connect()
|
||||
.then(() => this.connection.command(command, payload, expiresIn))
|
||||
.catch((error) => Promise.reject(error));
|
||||
}
|
||||
|
||||
return this.connection.command(command, payload, expiresIn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously executes a command by internally invoking the asynchronous `command` method,
|
||||
* blocking the event loop until the asynchronous operation completes. The function returns
|
||||
* the result of the command, or throws an error if the command fails.
|
||||
*
|
||||
* @param {string} command - The command to execute.
|
||||
* @param {*} [payload] - Optional payload to send with the command.
|
||||
* @param {number} [expiresIn=30000] - Optional time in milliseconds before the command expires. Defaults to 30,000 ms.
|
||||
* @returns {*} The result of the executed command.
|
||||
* @throws {Error} Throws an error if the command fails.
|
||||
*/
|
||||
commandSync(command: string, payload?: any, expiresIn: number = 30000): any {
|
||||
let result: any;
|
||||
let error: Error | undefined;
|
||||
let done = false;
|
||||
|
||||
this.command(command, payload, expiresIn)
|
||||
.then((res) => {
|
||||
result = res;
|
||||
done = true;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
done = true;
|
||||
});
|
||||
|
||||
// block the event loop until the async operation is done
|
||||
deasync.loopWhile(() => !done);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to a specific channel and registers a callback to be invoked
|
||||
* whenever a message is received on that channel. Optionally retrieves a
|
||||
* limited number of historical messages and passes them to the callback upon subscription.
|
||||
*
|
||||
* @param {string} channel - The name of the channel to subscribe to.
|
||||
* @param {(message: string) => void | Promise<void>} callback - The function to be called for each message received on the channel.
|
||||
* @param {{ historyLimit?: number }} [options] - Optional subscription options, such as the maximum number of historical messages to retrieve.
|
||||
* @returns {Promise<{ success: boolean; history: string[] }>} A promise that resolves with the subscription result,
|
||||
* including a success flag and an array of historical messages.
|
||||
*/
|
||||
subscribe(
|
||||
channel: string,
|
||||
callback: (message: string) => void | Promise<void>,
|
||||
options?: { historyLimit?: number }
|
||||
): Promise<{ success: boolean; history: string[] }> {
|
||||
this.on(
|
||||
"subscription-message",
|
||||
async (data: { channel: string; message: string }) => {
|
||||
if (data.channel === channel) {
|
||||
await callback(data.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const historyLimit = options?.historyLimit;
|
||||
|
||||
return this.command("subscribe-channel", { channel, historyLimit }).then(
|
||||
(result) => {
|
||||
if (result.success && result.history && result.history.length > 0) {
|
||||
result.history.forEach((message: string) => {
|
||||
callback(message);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
history: result.history || [],
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribes from a specified channel.
|
||||
*
|
||||
* @param {string} channel - The name of the channel to unsubscribe from.
|
||||
* @returns {Promise<boolean>} A promise that resolves to true if the unsubscription is successful, or false otherwise.
|
||||
*/
|
||||
unsubscribe(channel: string): Promise<boolean> {
|
||||
return this.command("unsubscribe-channel", { channel });
|
||||
}
|
||||
}
|
||||
175
packages/mesh/src/client/connection.ts
Normal file
175
packages/mesh/src/client/connection.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { WebSocket } from "ws";
|
||||
import { CodeError } from "../common/codeerror";
|
||||
import { type Command, parseCommand, stringifyCommand } from "../common/message";
|
||||
import { Status } from "../common/status";
|
||||
import { IdManager } from "./ids";
|
||||
import { Queue } from "./queue";
|
||||
|
||||
export type LatencyPayload = {
|
||||
/** Round-trip time in milliseconds. */
|
||||
latency: number;
|
||||
};
|
||||
|
||||
export class Connection extends EventEmitter {
|
||||
socket: WebSocket | null = null;
|
||||
ids = new IdManager();
|
||||
queue = new Queue();
|
||||
callbacks: { [id: number]: (result: any, error?: Error) => void } = {};
|
||||
status: Status = Status.OFFLINE;
|
||||
|
||||
constructor(socket: WebSocket | null) {
|
||||
super();
|
||||
this.socket = socket;
|
||||
if (socket) {
|
||||
this.applyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
get isDead(): boolean {
|
||||
return !this.socket || this.socket.readyState !== WebSocket.OPEN;
|
||||
}
|
||||
|
||||
send(command: Command): boolean {
|
||||
try {
|
||||
if (!this.isDead) {
|
||||
this.socket?.send(stringifyCommand(command));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
sendWithQueue(command: Command, expiresIn: number): boolean {
|
||||
const success = this.send(command);
|
||||
|
||||
if (!success) {
|
||||
this.queue.add(command, expiresIn);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
applyListeners(reconnection = false): void {
|
||||
if (!this.socket) return;
|
||||
|
||||
const drainQueue = () => {
|
||||
while (!this.queue.isEmpty) {
|
||||
const item = this.queue.pop();
|
||||
if (item) {
|
||||
this.send(item.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (reconnection) {
|
||||
drainQueue();
|
||||
}
|
||||
|
||||
this.socket.onclose = () => {
|
||||
this.status = Status.OFFLINE;
|
||||
this.emit("close");
|
||||
this.emit("disconnect");
|
||||
};
|
||||
|
||||
this.socket.onerror = (error) => {
|
||||
this.emit("error", error);
|
||||
};
|
||||
|
||||
this.socket.onmessage = (event: any) => {
|
||||
try {
|
||||
const data = parseCommand(event.data as string);
|
||||
|
||||
this.emit("message", data);
|
||||
|
||||
if (data.command === "latency:request") {
|
||||
this.emit("latency:request", data.payload);
|
||||
this.command("latency:response", data.payload, null);
|
||||
} else if (data.command === "latency") {
|
||||
this.emit("latency", data.payload);
|
||||
} else if (data.command === "ping") {
|
||||
this.emit("ping");
|
||||
this.command("pong", {}, null);
|
||||
} else {
|
||||
this.emit(data.command, data.payload);
|
||||
}
|
||||
|
||||
if (data.id !== undefined && this.callbacks[data.id]) {
|
||||
// @ts-ignore
|
||||
this.callbacks[data.id](data.payload);
|
||||
}
|
||||
} catch (error) {
|
||||
this.emit("error", error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
command(
|
||||
command: string,
|
||||
payload: any,
|
||||
expiresIn: number | null = 30_000,
|
||||
callback?: (result: any, error?: Error) => void
|
||||
): Promise<any> {
|
||||
const id = this.ids.reserve();
|
||||
const cmd: Command = { id, command, payload: payload ?? {} };
|
||||
|
||||
this.sendWithQueue(cmd, expiresIn || 30000);
|
||||
|
||||
if (expiresIn === null) {
|
||||
this.ids.release(id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const responsePromise = new Promise<any>((resolve, reject) => {
|
||||
this.callbacks[id] = (result: any, error?: Error) => {
|
||||
this.ids.release(id);
|
||||
delete this.callbacks[id];
|
||||
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<any>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
if (!this.callbacks[id]) return;
|
||||
|
||||
this.ids.release(id);
|
||||
delete this.callbacks[id];
|
||||
reject(
|
||||
new CodeError(
|
||||
`Command timed out after ${expiresIn}ms.`,
|
||||
"ETIMEOUT",
|
||||
"TimeoutError"
|
||||
)
|
||||
);
|
||||
}, expiresIn);
|
||||
});
|
||||
|
||||
if (typeof callback === "function") {
|
||||
Promise.race([responsePromise, timeoutPromise])
|
||||
.then((result) => callback(result))
|
||||
.catch((error) => callback(null, error));
|
||||
|
||||
return responsePromise;
|
||||
}
|
||||
|
||||
return Promise.race([responsePromise, timeoutPromise]);
|
||||
}
|
||||
|
||||
close(): boolean {
|
||||
if (this.isDead) return false;
|
||||
|
||||
try {
|
||||
this.socket?.close();
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
44
packages/mesh/src/client/ids.ts
Normal file
44
packages/mesh/src/client/ids.ts
Normal file
@ -0,0 +1,44 @@
|
||||
export class IdManager {
|
||||
ids: Array<true | false> = [];
|
||||
index: number = 0;
|
||||
maxIndex: number;
|
||||
|
||||
constructor(maxIndex: number = 2 ** 16 - 1) {
|
||||
this.maxIndex = maxIndex;
|
||||
}
|
||||
|
||||
release(id: number) {
|
||||
if (id < 0 || id > this.maxIndex) {
|
||||
throw new TypeError(
|
||||
`ID must be between 0 and ${this.maxIndex}. Got ${id}.`
|
||||
);
|
||||
}
|
||||
this.ids[id] = false;
|
||||
}
|
||||
|
||||
reserve(): number {
|
||||
const startIndex = this.index;
|
||||
|
||||
while (true) {
|
||||
const i = this.index;
|
||||
|
||||
if (!this.ids[i]) {
|
||||
this.ids[i] = true;
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
if (this.index >= this.maxIndex) {
|
||||
this.index = 0;
|
||||
} else {
|
||||
this.index++;
|
||||
}
|
||||
|
||||
if (this.index === startIndex) {
|
||||
throw new Error(
|
||||
`All IDs are reserved. Make sure to release IDs when they are no longer used.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
packages/mesh/src/client/index.ts
Normal file
3
packages/mesh/src/client/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { MeshClient, Status } from "./client";
|
||||
export { Connection } from "./connection";
|
||||
export { CodeError } from "../common/codeerror";
|
||||
46
packages/mesh/src/client/queue.ts
Normal file
46
packages/mesh/src/client/queue.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { type Command } from "../common/message";
|
||||
|
||||
export class QueueItem {
|
||||
value: Command;
|
||||
private expiration: number;
|
||||
|
||||
constructor(value: Command, expiresIn: number) {
|
||||
this.value = value;
|
||||
this.expiration = Date.now() + expiresIn;
|
||||
}
|
||||
|
||||
get expiresIn(): number {
|
||||
return this.expiration - Date.now();
|
||||
}
|
||||
|
||||
get isExpired(): boolean {
|
||||
return Date.now() > this.expiration;
|
||||
}
|
||||
}
|
||||
|
||||
export class Queue {
|
||||
private items: QueueItem[] = [];
|
||||
|
||||
add(item: Command, expiresIn: number): void {
|
||||
this.items.push(new QueueItem(item, expiresIn));
|
||||
}
|
||||
|
||||
get isEmpty(): boolean {
|
||||
this.items = this.items.filter((item) => !item.isExpired);
|
||||
return this.items.length === 0;
|
||||
}
|
||||
|
||||
pop(): QueueItem | null {
|
||||
while (this.items.length > 0) {
|
||||
const item = this.items.shift();
|
||||
if (item && !item.isExpired) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.items = [];
|
||||
}
|
||||
}
|
||||
12
packages/mesh/src/common/codeerror.ts
Normal file
12
packages/mesh/src/common/codeerror.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export class CodeError extends Error {
|
||||
code?: string;
|
||||
name: string;
|
||||
|
||||
constructor(message: string, code?: string, name?: string) {
|
||||
super(message);
|
||||
if (typeof code === "string") {
|
||||
this.code = code;
|
||||
}
|
||||
this.name = typeof name === "string" ? name : "CodeError";
|
||||
}
|
||||
}
|
||||
17
packages/mesh/src/common/message.ts
Normal file
17
packages/mesh/src/common/message.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export interface Command {
|
||||
id?: number;
|
||||
command: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export function parseCommand(data: string): Command {
|
||||
try {
|
||||
return JSON.parse(data) as Command;
|
||||
} catch (e) {
|
||||
return { command: "", payload: {} };
|
||||
}
|
||||
}
|
||||
|
||||
export function stringifyCommand(command: Command): string {
|
||||
return JSON.stringify(command);
|
||||
}
|
||||
6
packages/mesh/src/common/status.ts
Normal file
6
packages/mesh/src/common/status.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export enum Status {
|
||||
ONLINE = 3,
|
||||
CONNECTING = 2,
|
||||
RECONNECTING = 1,
|
||||
OFFLINE = 0,
|
||||
}
|
||||
3
packages/mesh/src/index.ts
Normal file
3
packages/mesh/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { MeshClient } from "./client";
|
||||
export { MeshServer, type MeshContext, type SocketMiddleware } from "./server";
|
||||
export { CodeError } from "./common/codeerror";
|
||||
126
packages/mesh/src/server/connection-manager.ts
Normal file
126
packages/mesh/src/server/connection-manager.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import type Redis from "ioredis";
|
||||
import type { Connection } from "./connection";
|
||||
import type { RoomManager } from "./room-manager";
|
||||
|
||||
const CONNECTIONS_HASH_KEY = "mesh:connections";
|
||||
const INSTANCE_CONNECTIONS_KEY_PREFIX = "mesh:connections:";
|
||||
|
||||
export class ConnectionManager {
|
||||
private redis: Redis;
|
||||
private instanceId: string;
|
||||
private localConnections: { [id: string]: Connection } = {};
|
||||
private roomManager: RoomManager;
|
||||
|
||||
constructor(redis: Redis, instanceId: string, roomManager: RoomManager) {
|
||||
this.redis = redis;
|
||||
this.instanceId = instanceId;
|
||||
this.roomManager = roomManager;
|
||||
}
|
||||
|
||||
getLocalConnections(): Connection[] {
|
||||
return Object.values(this.localConnections);
|
||||
}
|
||||
|
||||
getLocalConnection(id: string): Connection | null {
|
||||
return this.localConnections[id] ?? null;
|
||||
}
|
||||
|
||||
async registerConnection(connection: Connection): Promise<void> {
|
||||
this.localConnections[connection.id] = connection;
|
||||
|
||||
const pipeline = this.redis.pipeline();
|
||||
pipeline.hset(CONNECTIONS_HASH_KEY, connection.id, this.instanceId);
|
||||
pipeline.sadd(
|
||||
this.getInstanceConnectionsKey(this.instanceId),
|
||||
connection.id
|
||||
);
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
private getInstanceConnectionsKey(instanceId: string): string {
|
||||
return `${INSTANCE_CONNECTIONS_KEY_PREFIX}${instanceId}`;
|
||||
}
|
||||
|
||||
async deregisterConnection(connection: Connection): Promise<void> {
|
||||
const instanceId = await this.getInstanceIdForConnection(connection);
|
||||
if (!instanceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pipeline = this.redis.pipeline();
|
||||
pipeline.hdel(CONNECTIONS_HASH_KEY, connection.id);
|
||||
pipeline.srem(this.getInstanceConnectionsKey(instanceId), connection.id);
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
async getInstanceIdForConnection(
|
||||
connection: Connection
|
||||
): Promise<string | null> {
|
||||
return this.redis.hget(CONNECTIONS_HASH_KEY, connection.id);
|
||||
}
|
||||
|
||||
async getInstanceIdsForConnections(
|
||||
connectionIds: string[]
|
||||
): Promise<{ [connectionId: string]: string | null }> {
|
||||
if (connectionIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const instanceIds = await this.redis.hmget(
|
||||
CONNECTIONS_HASH_KEY,
|
||||
...connectionIds
|
||||
);
|
||||
const result: { [connectionId: string]: string | null } = {};
|
||||
|
||||
connectionIds.forEach((id, index) => {
|
||||
result[id] = instanceIds[index] ?? null;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getAllConnectionIds(): Promise<string[]> {
|
||||
return this.redis.hkeys(CONNECTIONS_HASH_KEY);
|
||||
}
|
||||
|
||||
async getLocalConnectionIds(): Promise<string[]> {
|
||||
return this.redis.smembers(this.getInstanceConnectionsKey(this.instanceId));
|
||||
}
|
||||
|
||||
async setMetadata(connection: Connection, metadata: any) {
|
||||
const pipeline = this.redis.pipeline();
|
||||
pipeline.hset(
|
||||
CONNECTIONS_HASH_KEY,
|
||||
connection.id,
|
||||
JSON.stringify(metadata)
|
||||
);
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
async getMetadata(connection: Connection) {
|
||||
const metadata = await this.redis.hget(CONNECTIONS_HASH_KEY, connection.id);
|
||||
return metadata ? JSON.parse(metadata) : null;
|
||||
}
|
||||
|
||||
async getAllMetadata(): Promise<Array<{ [connectionId: string]: any }>> {
|
||||
const connectionIds = await this.getAllConnectionIds();
|
||||
const metadata = await this.getInstanceIdsForConnections(connectionIds);
|
||||
return connectionIds.map((id) => ({
|
||||
[id]: metadata[id] ? JSON.parse(metadata[id]) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
async getAllMetadataForRoom(
|
||||
roomName: string
|
||||
): Promise<Array<{ [connectionId: string]: any }>> {
|
||||
const connectionIds = await this.roomManager.getRoomConnectionIds(roomName);
|
||||
const metadata = await this.getInstanceIdsForConnections(connectionIds);
|
||||
return connectionIds.map((id) => ({
|
||||
[id]: metadata[id] ? JSON.parse(metadata[id]) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
async cleanupConnection(connection: Connection): Promise<void> {
|
||||
await this.deregisterConnection(connection);
|
||||
}
|
||||
}
|
||||
140
packages/mesh/src/server/connection.ts
Normal file
140
packages/mesh/src/server/connection.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { IncomingMessage } from "node:http";
|
||||
import { WebSocket } from "ws";
|
||||
import {
|
||||
type Command,
|
||||
parseCommand,
|
||||
stringifyCommand,
|
||||
} from "../common/message";
|
||||
import { Status } from "../common/status";
|
||||
import { Latency } from "./latency";
|
||||
import { Ping } from "./ping";
|
||||
import type { MeshServerOptions } from "./";
|
||||
|
||||
export class Connection extends EventEmitter {
|
||||
id: string;
|
||||
socket: WebSocket;
|
||||
alive = true;
|
||||
missedPongs = 0;
|
||||
latency!: Latency;
|
||||
ping!: Ping;
|
||||
remoteAddress: string;
|
||||
connectionOptions: MeshServerOptions;
|
||||
status: Status = Status.ONLINE;
|
||||
|
||||
constructor(
|
||||
socket: WebSocket,
|
||||
req: IncomingMessage,
|
||||
options: MeshServerOptions
|
||||
) {
|
||||
super();
|
||||
this.socket = socket;
|
||||
this.id = req.headers["sec-websocket-key"]!;
|
||||
this.remoteAddress = req.socket.remoteAddress!;
|
||||
this.connectionOptions = options;
|
||||
|
||||
this.applyListeners();
|
||||
this.startIntervals();
|
||||
}
|
||||
|
||||
get isDead(): boolean {
|
||||
return !this.socket || this.socket.readyState !== WebSocket.OPEN;
|
||||
}
|
||||
|
||||
private startIntervals(): void {
|
||||
this.latency = new Latency();
|
||||
this.ping = new Ping();
|
||||
|
||||
this.latency.interval = setInterval(() => {
|
||||
if (!this.alive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof this.latency.ms === "number") {
|
||||
this.send({ command: "latency", payload: this.latency.ms });
|
||||
}
|
||||
|
||||
this.latency.onRequest();
|
||||
this.send({ command: "latency:request", payload: {} });
|
||||
}, this.connectionOptions.latencyInterval);
|
||||
|
||||
this.ping.interval = setInterval(() => {
|
||||
if (!this.alive) {
|
||||
this.missedPongs++;
|
||||
const maxMissedPongs = this.connectionOptions.maxMissedPongs ?? 1;
|
||||
if (this.missedPongs > maxMissedPongs) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.missedPongs = 0;
|
||||
}
|
||||
|
||||
this.alive = false;
|
||||
this.send({ command: "ping", payload: {} });
|
||||
}, this.connectionOptions.pingInterval);
|
||||
}
|
||||
|
||||
stopIntervals(): void {
|
||||
clearInterval(this.latency.interval);
|
||||
clearInterval(this.ping.interval);
|
||||
}
|
||||
|
||||
private applyListeners(): void {
|
||||
this.socket.on("close", () => {
|
||||
this.status = Status.OFFLINE;
|
||||
this.emit("close");
|
||||
});
|
||||
|
||||
this.socket.on("error", (error) => {
|
||||
this.emit("error", error);
|
||||
});
|
||||
|
||||
this.socket.on("message", (data: Buffer) => {
|
||||
try {
|
||||
const command = parseCommand(data.toString());
|
||||
|
||||
if (command.command === "latency:response") {
|
||||
this.latency.onResponse();
|
||||
return;
|
||||
} else if (command.command === "pong") {
|
||||
this.alive = true;
|
||||
this.missedPongs = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit("message", data);
|
||||
} catch (error) {
|
||||
this.emit("error", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
send(cmd: Command): boolean {
|
||||
if (this.isDead) return false;
|
||||
|
||||
try {
|
||||
this.socket.send(stringifyCommand(cmd));
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.emit("error", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<boolean> {
|
||||
if (this.isDead) return false;
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.socket.once("close", resolve);
|
||||
this.socket.once("error", reject);
|
||||
this.socket.close();
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.emit("error", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
779
packages/mesh/src/server/index.ts
Normal file
779
packages/mesh/src/server/index.ts
Normal file
@ -0,0 +1,779 @@
|
||||
import { IncomingMessage } from "node:http";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { Redis, type RedisOptions } from "ioredis";
|
||||
import { WebSocket, WebSocketServer, type ServerOptions } from "ws";
|
||||
import { RoomManager } from "./room-manager";
|
||||
import { ConnectionManager } from "./connection-manager";
|
||||
import { CodeError, Status } from "../client";
|
||||
import { Connection } from "./connection";
|
||||
import { parseCommand, type Command } from "../common/message";
|
||||
|
||||
const PUB_SUB_CHANNEL_PREFIX = "mesh:pubsub:";
|
||||
|
||||
export class MeshContext<T = any> {
|
||||
server: MeshServer;
|
||||
command: string;
|
||||
connection: Connection;
|
||||
payload: T;
|
||||
|
||||
constructor(
|
||||
server: MeshServer,
|
||||
command: string,
|
||||
connection: Connection,
|
||||
payload: T
|
||||
) {
|
||||
this.server = server;
|
||||
this.command = command;
|
||||
this.connection = connection;
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
|
||||
export type SocketMiddleware = (
|
||||
context: MeshContext<any>
|
||||
) => any | Promise<any>;
|
||||
|
||||
type PubSubMessagePayload = {
|
||||
targetConnectionIds: string[];
|
||||
command: Command;
|
||||
};
|
||||
|
||||
export type MeshServerOptions = ServerOptions & {
|
||||
/**
|
||||
* The interval at which to send ping messages to the client.
|
||||
*
|
||||
* @default 30000
|
||||
*/
|
||||
pingInterval?: number;
|
||||
|
||||
/**
|
||||
* The interval at which to send both latency requests and updates to the client.
|
||||
*
|
||||
* @default 5000
|
||||
*/
|
||||
latencyInterval?: number;
|
||||
redisOptions: RedisOptions;
|
||||
|
||||
/**
|
||||
* The maximum number of consecutive ping intervals the server will wait
|
||||
* for a pong response before considering the client disconnected.
|
||||
* A value of 1 means the client must respond within roughly 2 * pingInterval
|
||||
* before being disconnected. Setting it to 0 is not recommended as it will
|
||||
* immediately disconnect the client if it doesn't respond to the first ping in
|
||||
* exactly `pingInterval` milliseconds, which doesn't provide wiggle room for
|
||||
* network latency.
|
||||
*
|
||||
* @see pingInterval
|
||||
* @default 1
|
||||
*/
|
||||
maxMissedPongs?: number;
|
||||
};
|
||||
|
||||
type ChannelPattern = string | RegExp;
|
||||
|
||||
export class MeshServer extends WebSocketServer {
|
||||
readonly instanceId: string;
|
||||
redis: Redis;
|
||||
pubClient: Redis;
|
||||
subClient: Redis;
|
||||
roomManager: RoomManager;
|
||||
connectionManager: ConnectionManager;
|
||||
serverOptions: MeshServerOptions;
|
||||
status: Status = Status.OFFLINE;
|
||||
private exposedChannels: ChannelPattern[] = [];
|
||||
private channelSubscriptions: { [channel: string]: Set<Connection> } = {};
|
||||
private channelGuards: Map<
|
||||
ChannelPattern,
|
||||
(connection: Connection, channel: string) => Promise<boolean> | boolean
|
||||
> = new Map();
|
||||
private _isShuttingDown = false;
|
||||
|
||||
commands: {
|
||||
[command: string]: (context: MeshContext<any>) => Promise<any> | any;
|
||||
} = {};
|
||||
private globalMiddlewares: SocketMiddleware[] = [];
|
||||
middlewares: { [key: string]: SocketMiddleware[] } = {};
|
||||
|
||||
private _listening = false;
|
||||
private _subscriptionPromise!: Promise<void>;
|
||||
|
||||
get listening(): boolean {
|
||||
return this._listening;
|
||||
}
|
||||
|
||||
set listening(value: boolean) {
|
||||
this._listening = value;
|
||||
this.status = value ? Status.ONLINE : Status.OFFLINE;
|
||||
}
|
||||
|
||||
constructor(opts: MeshServerOptions) {
|
||||
super(opts);
|
||||
|
||||
this.instanceId = uuidv4();
|
||||
|
||||
this.redis = new Redis({
|
||||
retryStrategy: (times: number) => {
|
||||
if (this._isShuttingDown) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (times > 10) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.min(1000 * Math.pow(2, times), 30000);
|
||||
},
|
||||
...opts.redisOptions,
|
||||
});
|
||||
this.redis.on("error", (err) => console.error("Redis error:", err));
|
||||
|
||||
this.serverOptions = {
|
||||
...opts,
|
||||
pingInterval: opts.pingInterval ?? 30_000,
|
||||
latencyInterval: opts.latencyInterval ?? 5_000,
|
||||
maxMissedPongs: opts.maxMissedPongs ?? 1,
|
||||
};
|
||||
|
||||
this.pubClient = this.redis.duplicate();
|
||||
this.subClient = this.redis.duplicate();
|
||||
|
||||
this.roomManager = new RoomManager(this.redis);
|
||||
this.connectionManager = new ConnectionManager(
|
||||
this.pubClient,
|
||||
this.instanceId,
|
||||
this.roomManager
|
||||
);
|
||||
|
||||
this.subscribeToInstanceChannel();
|
||||
|
||||
this.on("listening", () => {
|
||||
this.listening = true;
|
||||
});
|
||||
|
||||
this.on("error", (err) => {
|
||||
console.error(`[MeshServer] Error: ${err}`);
|
||||
});
|
||||
|
||||
this.on("close", () => {
|
||||
this.listening = false;
|
||||
});
|
||||
|
||||
this.registerBuiltinCommands();
|
||||
this.applyListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until the service is ready by ensuring it is listening and the instance channel subscription is established.
|
||||
*
|
||||
* @returns {Promise<void>} A promise that resolves when the service is fully ready.
|
||||
* @throws {Error} If the readiness process fails or if any awaited promise rejects.
|
||||
*/
|
||||
async ready(): Promise<void> {
|
||||
const listeningPromise = this.listening
|
||||
? Promise.resolve()
|
||||
: new Promise<void>((resolve) => this.once("listening", resolve));
|
||||
|
||||
await Promise.all([listeningPromise, this._subscriptionPromise]);
|
||||
}
|
||||
|
||||
private subscribeToInstanceChannel(): Promise<void> {
|
||||
const channel = `${PUB_SUB_CHANNEL_PREFIX}${this.instanceId}`;
|
||||
|
||||
this._subscriptionPromise = new Promise((resolve, reject) => {
|
||||
this.subClient.subscribe(channel, (err) => {
|
||||
if (err) {
|
||||
if (!this._isShuttingDown) {
|
||||
console.error(`Failed to subscribe to channel ${channel}:`, err);
|
||||
}
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
this.subClient.on("message", async (channel, message) => {
|
||||
if (channel.startsWith(PUB_SUB_CHANNEL_PREFIX)) {
|
||||
this.handlePubSubMessage(channel, message);
|
||||
} else if (this.channelSubscriptions[channel]) {
|
||||
for (const connection of this.channelSubscriptions[channel]) {
|
||||
connection.send({
|
||||
command: "subscription-message",
|
||||
payload: { channel, message },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this._subscriptionPromise;
|
||||
}
|
||||
|
||||
private handlePubSubMessage(channel: string, message: string) {
|
||||
try {
|
||||
const parsedMessage = JSON.parse(message) as PubSubMessagePayload;
|
||||
|
||||
if (
|
||||
!parsedMessage ||
|
||||
!Array.isArray(parsedMessage.targetConnectionIds) ||
|
||||
!parsedMessage.command ||
|
||||
typeof parsedMessage.command.command !== "string"
|
||||
) {
|
||||
throw new Error("Invalid message format");
|
||||
}
|
||||
|
||||
const { targetConnectionIds, command } = parsedMessage;
|
||||
|
||||
targetConnectionIds.forEach((connectionId) => {
|
||||
const connection =
|
||||
this.connectionManager.getLocalConnection(connectionId);
|
||||
|
||||
if (connection && !connection.isDead) {
|
||||
connection.send(command);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
this.emit("error", new Error(`Failed to parse message: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
private applyListeners() {
|
||||
this.on("connection", async (socket: WebSocket, req: IncomingMessage) => {
|
||||
const connection = new Connection(socket, req, this.serverOptions);
|
||||
|
||||
connection.on("message", (buffer: Buffer) => {
|
||||
try {
|
||||
const data = buffer.toString();
|
||||
const command = parseCommand(data);
|
||||
|
||||
if (
|
||||
command.id !== undefined &&
|
||||
!["latency:response", "pong"].includes(command.command)
|
||||
) {
|
||||
this.runCommand(
|
||||
command.id,
|
||||
command.command,
|
||||
command.payload,
|
||||
connection
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.emit("error", err);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await this.connectionManager.registerConnection(connection);
|
||||
} catch (error) {
|
||||
connection.close();
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit("connected", connection);
|
||||
|
||||
connection.on("close", async () => {
|
||||
await this.cleanupConnection(connection);
|
||||
this.emit("disconnected", connection);
|
||||
});
|
||||
|
||||
connection.on("error", (err) => {
|
||||
this.emit("clientError", err, connection);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes a channel for external access and optionally associates a guard function
|
||||
* to control access to that channel. The guard function determines whether a given
|
||||
* connection is permitted to access the channel.
|
||||
*
|
||||
* @param {ChannelPattern} channel - The channel or pattern to expose.
|
||||
* @param {(connection: Connection, channel: string) => Promise<boolean> | boolean} [guard] -
|
||||
* Optional guard function that receives the connection and channel name, returning
|
||||
* a boolean or a promise that resolves to a boolean indicating whether access is allowed.
|
||||
* @returns {void}
|
||||
*/
|
||||
exposeChannel(
|
||||
channel: ChannelPattern,
|
||||
guard?: (
|
||||
connection: Connection,
|
||||
channel: string
|
||||
) => Promise<boolean> | boolean
|
||||
): void {
|
||||
this.exposedChannels.push(channel);
|
||||
if (guard) {
|
||||
this.channelGuards.set(channel, guard);
|
||||
}
|
||||
}
|
||||
|
||||
private async isChannelExposed(
|
||||
channel: string,
|
||||
connection: Connection
|
||||
): Promise<boolean> {
|
||||
// First check if the channel matches any exposed pattern
|
||||
const matchedPattern = this.exposedChannels.find((pattern) =>
|
||||
typeof pattern === "string" ? pattern === channel : pattern.test(channel)
|
||||
);
|
||||
|
||||
if (!matchedPattern) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const guard = this.channelGuards.get(matchedPattern);
|
||||
if (guard) {
|
||||
try {
|
||||
return await Promise.resolve(guard(connection, channel));
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a message to a specified channel and optionally maintains a history of messages.
|
||||
*
|
||||
* @param {string} channel - The name of the channel to which the message will be published.
|
||||
* @param {any} message - The message to be published. Will not be stringified automatically for you. You need to do that yourself.
|
||||
* @param {number} [history=0] - The number of historical messages to retain for the channel. Defaults to 0, meaning no history is retained.
|
||||
* If greater than 0, the message will be added to the channel's history and the history will be trimmed to the specified size.
|
||||
* @returns {Promise<void>} A Promise that resolves once the message has been published and, if applicable, the history has been updated.
|
||||
* @throws {Error} This function may throw an error if the underlying `pubClient` operations (e.g., `lpush`, `ltrim`, `publish`) fail.
|
||||
*/
|
||||
async publishToChannel(
|
||||
channel: string,
|
||||
message: any,
|
||||
history: number = 0
|
||||
): Promise<void> {
|
||||
const parsedHistory = parseInt(history as any, 10);
|
||||
if (!isNaN(parsedHistory) && parsedHistory > 0) {
|
||||
await this.pubClient.lpush(`history:${channel}`, message);
|
||||
await this.pubClient.ltrim(`history:${channel}`, 0, parsedHistory);
|
||||
}
|
||||
await this.pubClient.publish(channel, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one or more middleware functions to the global middleware stack.
|
||||
*
|
||||
* @param {SocketMiddleware[]} middlewares - An array of middleware functions to be added. Each middleware
|
||||
* is expected to conform to the `SocketMiddleware` type.
|
||||
* @returns {void}
|
||||
* @throws {Error} If the provided middlewares are not valid or fail validation (if applicable).
|
||||
*/
|
||||
addMiddleware(...middlewares: SocketMiddleware[]): void {
|
||||
this.globalMiddlewares.push(...middlewares);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a command with an associated callback and optional middleware.
|
||||
*
|
||||
* @template T The type for `MeshContext.payload`. Defaults to `any`.
|
||||
* @template U The command's return value type. Defaults to `any`.
|
||||
* @param {string} command - The unique identifier for the command to register.
|
||||
* @param {(context: MeshContext<T>) => Promise<U> | U} callback - The function to execute when the command is invoked. It receives a `MeshContext` of type `T` and may return a value of type `U` or a `Promise` resolving to `U`.
|
||||
* @param {SocketMiddleware[]} [middlewares=[]] - An optional array of middleware functions to apply to the command. Defaults to an empty array.
|
||||
* @throws {Error} May throw an error if the command registration or middleware addition fails.
|
||||
*/
|
||||
registerCommand<T = any, U = any>(
|
||||
command: string,
|
||||
callback: (context: MeshContext<T>) => Promise<U> | U,
|
||||
middlewares: SocketMiddleware[] = []
|
||||
) {
|
||||
this.commands[command] = callback;
|
||||
|
||||
if (middlewares.length > 0) {
|
||||
this.addMiddlewareToCommand(command, middlewares);
|
||||
}
|
||||
}
|
||||
|
||||
private registerBuiltinCommands() {
|
||||
this.registerCommand<
|
||||
{ channel: string; historyLimit?: number },
|
||||
{ success: boolean; history?: string[] }
|
||||
>("subscribe-channel", async (ctx) => {
|
||||
const { channel, historyLimit } = ctx.payload;
|
||||
|
||||
if (!(await this.isChannelExposed(channel, ctx.connection))) {
|
||||
return { success: false, history: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
if (!this.channelSubscriptions[channel]) {
|
||||
this.channelSubscriptions[channel] = new Set();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.subClient.subscribe(channel, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
this.channelSubscriptions[channel].add(ctx.connection);
|
||||
|
||||
// Fetch channel history if historyLimit is provided
|
||||
let history: string[] = [];
|
||||
if (historyLimit && historyLimit > 0) {
|
||||
const historyKey = `history:${channel}`;
|
||||
history = await this.redis.lrange(historyKey, 0, historyLimit - 1);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
history,
|
||||
};
|
||||
} catch (e) {
|
||||
return { success: false, history: [] };
|
||||
}
|
||||
});
|
||||
|
||||
this.registerCommand<{ channel: string }, boolean>(
|
||||
"unsubscribe-channel",
|
||||
async (ctx) => {
|
||||
const { channel } = ctx.payload;
|
||||
if (this.channelSubscriptions[channel]) {
|
||||
this.channelSubscriptions[channel].delete(ctx.connection);
|
||||
if (this.channelSubscriptions[channel].size === 0) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.subClient.unsubscribe(channel, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
delete this.channelSubscriptions[channel];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an array of middleware functions to a specific command.
|
||||
*
|
||||
* @param {string} command - The name of the command to associate the middleware with.
|
||||
* @param {SocketMiddleware[]} middlewares - An array of middleware functions to be added to the command.
|
||||
* @returns {void}
|
||||
*/
|
||||
addMiddlewareToCommand(
|
||||
command: string,
|
||||
middlewares: SocketMiddleware[]
|
||||
): void {
|
||||
if (middlewares.length) {
|
||||
this.middlewares[command] = this.middlewares[command] || [];
|
||||
this.middlewares[command] = middlewares.concat(this.middlewares[command]);
|
||||
}
|
||||
}
|
||||
|
||||
private async runCommand(
|
||||
id: number,
|
||||
commandName: string,
|
||||
payload: any,
|
||||
connection: Connection
|
||||
) {
|
||||
const context = new MeshContext(this, commandName, connection, payload);
|
||||
|
||||
try {
|
||||
if (!this.commands[commandName]) {
|
||||
throw new CodeError(
|
||||
`Command "${commandName}" not found`,
|
||||
"ENOTFOUND",
|
||||
"CommandError"
|
||||
);
|
||||
}
|
||||
|
||||
if (this.globalMiddlewares.length) {
|
||||
for (const middleware of this.globalMiddlewares) {
|
||||
await middleware(context);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.middlewares[commandName]) {
|
||||
for (const middleware of this.middlewares[commandName]) {
|
||||
await middleware(context);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.commands[commandName](context);
|
||||
connection.send({ id, command: commandName, payload: result });
|
||||
} catch (err) {
|
||||
const errorPayload =
|
||||
err instanceof Error
|
||||
? {
|
||||
error: err.message,
|
||||
code: (err as CodeError).code || "ESERVER",
|
||||
name: err.name || "Error",
|
||||
}
|
||||
: { error: String(err), code: "EUNKNOWN", name: "UnknownError" };
|
||||
|
||||
connection.send({ id, command: commandName, payload: errorPayload });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts a command and payload to a set of connections or all available connections.
|
||||
*
|
||||
* @param {string} command - The command to be broadcasted.
|
||||
* @param {any} payload - The data associated with the command.
|
||||
* @param {Connection[]=} connections - (Optional) A specific list of connections to broadcast to. If not provided, the command will be sent to all connections.
|
||||
*
|
||||
* @throws {Error} Emits an "error" event if broadcasting fails.
|
||||
*/
|
||||
async broadcast(command: string, payload: any, connections?: Connection[]) {
|
||||
const cmd: Command = { command, payload };
|
||||
|
||||
try {
|
||||
if (connections) {
|
||||
const allConnectionIds = connections.map(({ id }) => id);
|
||||
const connectionIds =
|
||||
await this.connectionManager.getAllConnectionIds();
|
||||
const filteredIds = allConnectionIds.filter((id) =>
|
||||
connectionIds.includes(id)
|
||||
);
|
||||
await this.publishOrSend(filteredIds, cmd);
|
||||
} else {
|
||||
const allConnectionIds =
|
||||
await this.connectionManager.getAllConnectionIds();
|
||||
await this.publishOrSend(allConnectionIds, cmd);
|
||||
}
|
||||
} catch (err) {
|
||||
this.emit(
|
||||
"error",
|
||||
new Error(`Failed to broadcast command "${command}": ${err}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts a command and associated payload to all active connections within the specified room.
|
||||
*
|
||||
* @param {string} roomName - The name of the room whose connections will receive the broadcast.
|
||||
* @param {string} command - The command to be broadcasted to the connections.
|
||||
* @param {unknown} payload - The data payload associated with the command.
|
||||
* @returns {Promise<void>} A promise that resolves when the broadcast operation is complete.
|
||||
* @throws {Error} If the broadcast operation fails, an error is thrown and the promise is rejected.
|
||||
*/
|
||||
async broadcastRoom(
|
||||
roomName: string,
|
||||
command: string,
|
||||
payload: any
|
||||
): Promise<void> {
|
||||
const connectionIds = await this.roomManager.getRoomConnectionIds(roomName);
|
||||
|
||||
try {
|
||||
await this.publishOrSend(connectionIds, { command, payload });
|
||||
} catch (err) {
|
||||
this.emit(
|
||||
"error",
|
||||
new Error(`Failed to broadcast command "${command}": ${err}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts a command and payload to all active connections except for the specified one(s).
|
||||
* Excludes the provided connection(s) from receiving the broadcast.
|
||||
*
|
||||
* @param {string} command - The command to broadcast to connections.
|
||||
* @param {any} payload - The payload to send along with the command.
|
||||
* @param {Connection | Connection[]} exclude - A single connection or an array of connections to exclude from the broadcast.
|
||||
* @returns {Promise<void>} A promise that resolves when the broadcast is complete.
|
||||
* @emits {Error} Emits an "error" event if broadcasting the command fails.
|
||||
*/
|
||||
async broadcastExclude(
|
||||
command: string,
|
||||
payload: any,
|
||||
exclude: Connection | Connection[]
|
||||
): Promise<void> {
|
||||
const excludedIds = new Set(
|
||||
(Array.isArray(exclude) ? exclude : [exclude]).map(({ id }) => id)
|
||||
);
|
||||
|
||||
try {
|
||||
const connectionIds = (
|
||||
await this.connectionManager.getAllConnectionIds()
|
||||
).filter((id) => !excludedIds.has(id));
|
||||
await this.publishOrSend(connectionIds, { command, payload });
|
||||
} catch (err) {
|
||||
this.emit(
|
||||
"error",
|
||||
new Error(`Failed to broadcast command "${command}": ${err}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts a command with a payload to all connections in a specified room,
|
||||
* excluding one or more given connections. If the broadcast fails, emits an error event.
|
||||
*
|
||||
* @param {string} roomName - The name of the room to broadcast to.
|
||||
* @param {string} command - The command to broadcast.
|
||||
* @param {any} payload - The payload to send with the command.
|
||||
* @param {Connection | Connection[]} exclude - A connection or array of connections to exclude from the broadcast.
|
||||
* @returns {Promise<void>} A promise that resolves when the broadcast is complete.
|
||||
* @emits {Error} Emits an error event if broadcasting fails.
|
||||
*/
|
||||
async broadcastRoomExclude(
|
||||
roomName: string,
|
||||
command: string,
|
||||
payload: any,
|
||||
exclude: Connection | Connection[]
|
||||
): Promise<void> {
|
||||
const excludedIds = new Set(
|
||||
(Array.isArray(exclude) ? exclude : [exclude]).map(({ id }) => id)
|
||||
);
|
||||
|
||||
try {
|
||||
const connectionIds = (
|
||||
await this.roomManager.getRoomConnectionIds(roomName)
|
||||
).filter((id) => !excludedIds.has(id));
|
||||
await this.publishOrSend(connectionIds, { command, payload });
|
||||
} catch (err) {
|
||||
this.emit(
|
||||
"error",
|
||||
new Error(`Failed to broadcast command "${command}": ${err}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async publishOrSend(connectionIds: string[], command: Command) {
|
||||
if (connectionIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// get instance mapping for the target connection IDs
|
||||
const connectionInstanceMapping =
|
||||
await this.connectionManager.getInstanceIdsForConnections(connectionIds);
|
||||
const instanceMap: { [instanceId: string]: string[] } = {};
|
||||
|
||||
// group connection IDs by instance ID
|
||||
for (const connectionId of connectionIds) {
|
||||
const instanceId = connectionInstanceMapping[connectionId];
|
||||
|
||||
if (instanceId) {
|
||||
if (!instanceMap[instanceId]) {
|
||||
instanceMap[instanceId] = [];
|
||||
}
|
||||
|
||||
instanceMap[instanceId].push(connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
// publish command to each instance
|
||||
for (const [instanceId, targetConnectionIds] of Object.entries(
|
||||
instanceMap
|
||||
)) {
|
||||
if (targetConnectionIds.length === 0) continue;
|
||||
|
||||
if (instanceId === this.instanceId) {
|
||||
// send locally
|
||||
targetConnectionIds.forEach((connectionId) => {
|
||||
const connection =
|
||||
this.connectionManager.getLocalConnection(connectionId);
|
||||
if (connection && !connection.isDead) {
|
||||
connection.send(command);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// publish to remote instance via pubsub
|
||||
const messagePayload: PubSubMessagePayload = {
|
||||
targetConnectionIds,
|
||||
command,
|
||||
};
|
||||
const message = JSON.stringify(messagePayload);
|
||||
|
||||
try {
|
||||
await this.pubClient.publish(
|
||||
this.getPubSubChannel(instanceId),
|
||||
message
|
||||
);
|
||||
} catch (err) {
|
||||
this.emit(
|
||||
"error",
|
||||
new Error(`Failed to publish command "${command.command}": ${err}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async isInRoom(roomName: string, connection: Connection | string) {
|
||||
const connectionId =
|
||||
typeof connection === "string" ? connection : connection.id;
|
||||
return this.roomManager.connectionIsInRoom(roomName, connectionId);
|
||||
}
|
||||
|
||||
async addToRoom(roomName: string, connection: Connection | string) {
|
||||
return this.roomManager.addToRoom(roomName, connection);
|
||||
}
|
||||
|
||||
async removeFromRoom(roomName: string, connection: Connection | string) {
|
||||
return this.roomManager.removeFromRoom(roomName, connection);
|
||||
}
|
||||
|
||||
async removeFromAllRooms(connection: Connection | string) {
|
||||
return this.roomManager.removeFromAllRooms(connection);
|
||||
}
|
||||
|
||||
async clearRoom(roomName: string) {
|
||||
return this.roomManager.clearRoom(roomName);
|
||||
}
|
||||
|
||||
async getRoomMembers(roomName: string): Promise<string[]> {
|
||||
return this.roomManager.getRoomConnectionIds(roomName);
|
||||
}
|
||||
|
||||
private getPubSubChannel(instanceId: string): string {
|
||||
return `${PUB_SUB_CHANNEL_PREFIX}${instanceId}`;
|
||||
}
|
||||
|
||||
private async cleanupConnection(connection: Connection) {
|
||||
connection.stopIntervals();
|
||||
|
||||
await this.connectionManager.cleanupConnection(connection);
|
||||
await this.roomManager.cleanupConnection(connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully closes all active connections, cleans up resources,
|
||||
* and shuts down the service. Optionally accepts a callback function
|
||||
* that will be invoked once shutdown is complete or if an error occurs.
|
||||
*
|
||||
* @param {((err?: Error) => void)=} callback - Optional callback to be invoked when closing is complete or if an error occurs.
|
||||
* @returns {Promise<void>} A promise that resolves when shutdown is complete.
|
||||
* @throws {Error} If an error occurs during shutdown, the promise will be rejected with the error.
|
||||
*/
|
||||
async close(callback?: (err?: Error) => void): Promise<void> {
|
||||
this._isShuttingDown = true;
|
||||
|
||||
const connections = Object.values(
|
||||
this.connectionManager.getLocalConnections()
|
||||
);
|
||||
await Promise.all(
|
||||
connections.map(async (connection) => {
|
||||
if (!connection.isDead) {
|
||||
await connection.close();
|
||||
}
|
||||
await this.cleanupConnection(connection);
|
||||
})
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
super.close((err?: Error) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
this.pubClient.disconnect();
|
||||
this.subClient.disconnect();
|
||||
this.redis.disconnect();
|
||||
|
||||
this.listening = false;
|
||||
this.removeAllListeners();
|
||||
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
15
packages/mesh/src/server/latency.ts
Normal file
15
packages/mesh/src/server/latency.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export class Latency {
|
||||
start = 0;
|
||||
end = 0;
|
||||
ms = 0;
|
||||
interval: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
onRequest() {
|
||||
this.start = Date.now();
|
||||
}
|
||||
|
||||
onResponse() {
|
||||
this.end = Date.now();
|
||||
this.ms = this.end - this.start;
|
||||
}
|
||||
}
|
||||
3
packages/mesh/src/server/ping.ts
Normal file
3
packages/mesh/src/server/ping.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export class Ping {
|
||||
interval: ReturnType<typeof setTimeout> | undefined;
|
||||
}
|
||||
89
packages/mesh/src/server/room-manager.ts
Normal file
89
packages/mesh/src/server/room-manager.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import Redis from "ioredis";
|
||||
import type { Connection } from "./connection";
|
||||
|
||||
export class RoomManager {
|
||||
private redis: Redis;
|
||||
|
||||
constructor(redis: Redis) {
|
||||
this.redis = redis;
|
||||
}
|
||||
|
||||
private roomKey(roomName: string) {
|
||||
return `room:${roomName}`;
|
||||
}
|
||||
|
||||
private connectionsRoomKey(connectionId: string) {
|
||||
return `connection:${connectionId}:rooms`;
|
||||
}
|
||||
|
||||
async getRoomConnectionIds(roomName: string): Promise<string[]> {
|
||||
return this.redis.smembers(this.roomKey(roomName));
|
||||
}
|
||||
|
||||
async connectionIsInRoom(
|
||||
roomName: string,
|
||||
connection: Connection | string
|
||||
): Promise<boolean> {
|
||||
const connectionId =
|
||||
typeof connection === "string" ? connection : connection.id;
|
||||
return !!(await this.redis.sismember(this.roomKey(roomName), connectionId));
|
||||
}
|
||||
|
||||
async addToRoom(
|
||||
roomName: string,
|
||||
connection: Connection | string
|
||||
): Promise<void> {
|
||||
const connectionId =
|
||||
typeof connection === "string" ? connection : connection.id;
|
||||
await this.redis.sadd(this.roomKey(roomName), connectionId);
|
||||
await this.redis.sadd(this.connectionsRoomKey(connectionId), roomName);
|
||||
}
|
||||
|
||||
async removeFromRoom(
|
||||
roomName: string,
|
||||
connection: Connection | string
|
||||
): Promise<void> {
|
||||
const connectionId =
|
||||
typeof connection === "string" ? connection : connection.id;
|
||||
const pipeline = this.redis.pipeline();
|
||||
pipeline.srem(this.roomKey(roomName), connectionId);
|
||||
pipeline.srem(this.connectionsRoomKey(connectionId), roomName);
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
async removeFromAllRooms(connection: Connection | string) {
|
||||
const connectionId =
|
||||
typeof connection === "string" ? connection : connection.id;
|
||||
const rooms = await this.redis.smembers(
|
||||
this.connectionsRoomKey(connectionId)
|
||||
);
|
||||
const pipeline = this.redis.pipeline();
|
||||
for (const room of rooms) {
|
||||
pipeline.srem(this.roomKey(room), connectionId);
|
||||
}
|
||||
pipeline.del(this.connectionsRoomKey(connectionId));
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
async clearRoom(roomName: string) {
|
||||
const connectionIds = await this.getRoomConnectionIds(roomName);
|
||||
const pipeline = this.redis.pipeline();
|
||||
for (const connectionId of connectionIds) {
|
||||
pipeline.srem(this.connectionsRoomKey(connectionId), roomName);
|
||||
}
|
||||
pipeline.del(this.roomKey(roomName));
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
async cleanupConnection(connection: Connection): Promise<void> {
|
||||
const rooms = await this.redis.smembers(
|
||||
this.connectionsRoomKey(connection.id)
|
||||
);
|
||||
const pipeline = this.redis.pipeline();
|
||||
for (const room of rooms) {
|
||||
pipeline.srem(this.roomKey(room), connection.id);
|
||||
}
|
||||
pipeline.del(this.connectionsRoomKey(connection.id));
|
||||
await pipeline.exec();
|
||||
}
|
||||
}
|
||||
133
packages/mesh/src/tests/basic.test.ts
Normal file
133
packages/mesh/src/tests/basic.test.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import Redis from "ioredis";
|
||||
import { MeshServer } from "../server";
|
||||
import { MeshClient, Status } from "../client";
|
||||
|
||||
const REDIS_HOST = process.env.REDIS_HOST || "127.0.0.1";
|
||||
const REDIS_PORT = process.env.REDIS_PORT
|
||||
? parseInt(process.env.REDIS_PORT, 10)
|
||||
: 6379;
|
||||
|
||||
const createTestServer = (port: number) =>
|
||||
new MeshServer({
|
||||
port,
|
||||
redisOptions: {
|
||||
host: REDIS_HOST,
|
||||
port: REDIS_PORT,
|
||||
},
|
||||
});
|
||||
|
||||
const flushRedis = async () => {
|
||||
const redis = new Redis({ host: REDIS_HOST, port: REDIS_PORT });
|
||||
await redis.flushdb();
|
||||
await redis.quit();
|
||||
};
|
||||
|
||||
describe("KeepAliveServer", () => {
|
||||
const port = 8126;
|
||||
let server: MeshServer;
|
||||
let clientA: MeshClient;
|
||||
let clientB: MeshClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
await flushRedis();
|
||||
|
||||
server = createTestServer(port);
|
||||
await server.ready();
|
||||
|
||||
clientA = new MeshClient(`ws://localhost:${port}`);
|
||||
clientB = new MeshClient(`ws://localhost:${port}`);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await clientA.close();
|
||||
await clientB.close();
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("should create a server instance", () => {
|
||||
expect(server).toBeInstanceOf(MeshServer);
|
||||
expect(server.redis).toBeInstanceOf(Redis);
|
||||
expect(server.roomManager).toBeDefined();
|
||||
expect(server.connectionManager).toBeDefined();
|
||||
});
|
||||
|
||||
test("clients can connect to the server", async () => {
|
||||
await clientA.connect();
|
||||
expect(clientA.status).toBe(Status.ONLINE);
|
||||
|
||||
await clientB.connect();
|
||||
expect(clientB.status).toBe(Status.ONLINE);
|
||||
});
|
||||
|
||||
test("clients can disconnect from the server", async () => {
|
||||
await clientA.connect();
|
||||
expect(clientA.status).toBe(Status.ONLINE);
|
||||
|
||||
await clientA.close();
|
||||
expect(clientA.status).toBe(Status.OFFLINE);
|
||||
});
|
||||
|
||||
test("clients can send a command and receive a response", async () => {
|
||||
server.registerCommand("echo", async (c) => `echo: ${c.payload}`);
|
||||
await clientA.connect();
|
||||
const response = await clientA.command("echo", "Hello, World!");
|
||||
expect(response).toBe("echo: Hello, World!");
|
||||
await clientA.close();
|
||||
});
|
||||
|
||||
describe("metadata", () => {
|
||||
test("server can store metadata for a connection", async () => {
|
||||
await clientA.connect();
|
||||
await clientB.connect();
|
||||
const metadataA = { name: "Client A", id: 1 };
|
||||
const metadataB = { name: "Client B", id: 2 };
|
||||
const connectionA = server.connectionManager.getLocalConnections()[0]!;
|
||||
const connectionB = server.connectionManager.getLocalConnections()[1]!;
|
||||
await server.connectionManager.setMetadata(connectionA, metadataA);
|
||||
await server.connectionManager.setMetadata(connectionB, metadataB);
|
||||
const storedMetadataA = await server.connectionManager.getMetadata(
|
||||
connectionA
|
||||
);
|
||||
const storedMetadataB = await server.connectionManager.getMetadata(
|
||||
connectionB
|
||||
);
|
||||
expect(storedMetadataA).toEqual(metadataA);
|
||||
expect(storedMetadataB).toEqual(metadataB);
|
||||
|
||||
const allMetadata = await server.connectionManager.getAllMetadata();
|
||||
expect(allMetadata).toEqual([
|
||||
{ [connectionA.id]: metadataA },
|
||||
{ [connectionB.id]: metadataB },
|
||||
]);
|
||||
|
||||
const allMetadataFromNonExistentRoom =
|
||||
await server.connectionManager.getAllMetadataForRoom(
|
||||
"non-existent-room"
|
||||
);
|
||||
expect(allMetadataFromNonExistentRoom).toEqual([]);
|
||||
});
|
||||
|
||||
test("server can retrieve metadata for a room of connections", async () => {
|
||||
await clientA.connect();
|
||||
await clientB.connect();
|
||||
const metadataA = { name: "Client A", id: 1 };
|
||||
const metadataB = { name: "Client B", id: 2 };
|
||||
const connectionA = server.connectionManager.getLocalConnections()[0]!;
|
||||
const connectionB = server.connectionManager.getLocalConnections()[1]!;
|
||||
await server.connectionManager.setMetadata(connectionA, metadataA);
|
||||
await server.connectionManager.setMetadata(connectionB, metadataB);
|
||||
await server.addToRoom("room-a", connectionA);
|
||||
await server.addToRoom("room-b", connectionB);
|
||||
|
||||
const roomAMetadata =
|
||||
await server.connectionManager.getAllMetadataForRoom("room-a");
|
||||
expect(roomAMetadata).toEqual([{ [connectionA.id]: metadataA }]);
|
||||
|
||||
const roomBMetadata =
|
||||
await server.connectionManager.getAllMetadataForRoom("room-b");
|
||||
expect(roomBMetadata).toEqual([{ [connectionB.id]: metadataB }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
174
packages/mesh/src/tests/client.test.ts
Normal file
174
packages/mesh/src/tests/client.test.ts
Normal file
@ -0,0 +1,174 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import Redis from "ioredis";
|
||||
import { MeshServer } from "../server";
|
||||
import { MeshClient, Status } from "../client";
|
||||
|
||||
const REDIS_HOST = process.env.REDIS_HOST || "127.0.0.1";
|
||||
const REDIS_PORT = process.env.REDIS_PORT
|
||||
? parseInt(process.env.REDIS_PORT, 10)
|
||||
: 6379;
|
||||
|
||||
const createTestServer = (port: number) =>
|
||||
new MeshServer({
|
||||
port,
|
||||
redisOptions: {
|
||||
host: REDIS_HOST,
|
||||
port: REDIS_PORT,
|
||||
},
|
||||
});
|
||||
|
||||
const flushRedis = async () => {
|
||||
const redis = new Redis({ host: REDIS_HOST, port: REDIS_PORT });
|
||||
await redis.flushdb();
|
||||
await redis.quit();
|
||||
};
|
||||
|
||||
describe("KeepAliveClient", () => {
|
||||
const port = 8127;
|
||||
let server: MeshServer;
|
||||
let client: MeshClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
await flushRedis();
|
||||
|
||||
server = createTestServer(port);
|
||||
await server.ready();
|
||||
|
||||
client = new MeshClient(`ws://localhost:${port}`);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("command times out when server doesn't respond", async () => {
|
||||
server.registerCommand("never-responds", async () => new Promise(() => {}));
|
||||
|
||||
await client.connect();
|
||||
|
||||
await expect(
|
||||
client.command("never-responds", "Should time out", 100)
|
||||
).rejects.toThrow(/timed out/);
|
||||
}, 2000);
|
||||
|
||||
test("an unknown command should return an error object", async () => {
|
||||
await client.connect();
|
||||
|
||||
const result = await client.command("unknown-command", "Should fail");
|
||||
expect(result).toEqual({
|
||||
code: "ENOTFOUND",
|
||||
error: 'Command "unknown-command" not found',
|
||||
name: "CommandError",
|
||||
});
|
||||
});
|
||||
|
||||
test("thrown servers errors are serialized to the client", async () => {
|
||||
server.registerCommand("throws-error", async () => {
|
||||
throw new Error("This is a test error");
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
const result = await client.command("throws-error", "Should fail");
|
||||
expect(result).toEqual({
|
||||
code: "ESERVER",
|
||||
error: "This is a test error",
|
||||
name: "Error",
|
||||
});
|
||||
});
|
||||
|
||||
test("handles large payloads without issue", async () => {
|
||||
server.registerCommand("echo", async (ctx) => ctx.payload);
|
||||
await client.connect();
|
||||
|
||||
const largeData = {
|
||||
array: Array(1000)
|
||||
.fill(0)
|
||||
.map((_, i) => `item-${i}`),
|
||||
nested: {
|
||||
deep: {
|
||||
object: {
|
||||
with: "lots of data",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await client.command("echo", largeData, 200);
|
||||
expect(result).toEqual(largeData);
|
||||
});
|
||||
|
||||
test("client emits 'connect' event on successful connection", async () =>
|
||||
new Promise<void>((resolve) => {
|
||||
client.on("connect", () => {
|
||||
expect(client.status).toBe(Status.ONLINE);
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.connect();
|
||||
}));
|
||||
|
||||
test("client emits 'disconnect' and 'close' events on successful disconnection", async () =>
|
||||
new Promise<void>((resolve) => {
|
||||
let disconnectEmitted = false;
|
||||
let closeEmitted = false;
|
||||
|
||||
const checkBothEvents = () => {
|
||||
if (disconnectEmitted && closeEmitted) {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
client.on("disconnect", () => {
|
||||
expect(client.status).toBe(Status.OFFLINE);
|
||||
disconnectEmitted = true;
|
||||
checkBothEvents();
|
||||
});
|
||||
|
||||
client.on("close", () => {
|
||||
expect(client.status).toBe(Status.OFFLINE);
|
||||
closeEmitted = true;
|
||||
checkBothEvents();
|
||||
});
|
||||
|
||||
client.connect().then(() => {
|
||||
client.close();
|
||||
});
|
||||
}));
|
||||
|
||||
test("client emits 'message' event on receiving a message", async () =>
|
||||
new Promise<void>((resolve) => {
|
||||
client.on("message", (data) => {
|
||||
expect(data).toEqual({ command: "hello", payload: "world" });
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.connect().then(() => {
|
||||
server.broadcast("hello", "world");
|
||||
});
|
||||
}));
|
||||
|
||||
test("client receives 'ping' messages", async () => {
|
||||
const server = new MeshServer({
|
||||
port: 8130,
|
||||
pingInterval: 10,
|
||||
maxMissedPongs: 10,
|
||||
redisOptions: { host: REDIS_HOST, port: REDIS_PORT },
|
||||
});
|
||||
|
||||
await server.ready();
|
||||
const client = new MeshClient(`ws://localhost:8130`, { pingTimeout: 10, maxMissedPings: 10 });
|
||||
await client.connect();
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
client.on("ping", () => {
|
||||
expect(client.status).toBe(Status.ONLINE);
|
||||
resolve();
|
||||
});
|
||||
client.on("close", () => {
|
||||
expect(client.status).toBe(Status.OFFLINE);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
155
packages/mesh/src/tests/multiple-instance.test.ts
Normal file
155
packages/mesh/src/tests/multiple-instance.test.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import {
|
||||
describe,
|
||||
test,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
} from "vitest";
|
||||
import Redis from "ioredis";
|
||||
import { MeshServer } from "../server";
|
||||
import { MeshClient } from "../client";
|
||||
|
||||
const REDIS_HOST = process.env.REDIS_HOST || "127.0.0.1";
|
||||
const REDIS_PORT = process.env.REDIS_PORT
|
||||
? parseInt(process.env.REDIS_PORT, 10)
|
||||
: 6379;
|
||||
|
||||
const createTestServer = (port: number) =>
|
||||
new MeshServer({
|
||||
port,
|
||||
redisOptions: {
|
||||
host: REDIS_HOST,
|
||||
port: REDIS_PORT,
|
||||
},
|
||||
});
|
||||
|
||||
const flushRedis = async () => {
|
||||
const redis = new Redis({ host: REDIS_HOST, port: REDIS_PORT });
|
||||
await redis.flushdb();
|
||||
await redis.quit();
|
||||
};
|
||||
|
||||
describe.sequential("Multiple instances", () => {
|
||||
let serverA: MeshServer;
|
||||
let serverB: MeshServer;
|
||||
let clientA: MeshClient;
|
||||
let clientB: MeshClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
await flushRedis();
|
||||
|
||||
serverA = createTestServer(6677);
|
||||
serverB = createTestServer(6688);
|
||||
await serverA.ready();
|
||||
await serverB.ready();
|
||||
|
||||
clientA = new MeshClient(`ws://localhost:${6677}`);
|
||||
clientB = new MeshClient(`ws://localhost:${6688}`);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await clientA.close();
|
||||
await clientB.close();
|
||||
|
||||
await serverA.close();
|
||||
await serverB.close();
|
||||
});
|
||||
|
||||
test("broadcast should work across instances", async () => {
|
||||
serverA.registerCommand("broadcast", async (ctx) => {
|
||||
await serverA.broadcast("hello", "Hello!");
|
||||
});
|
||||
|
||||
await clientA.connect();
|
||||
await clientB.connect();
|
||||
|
||||
let receivedA = false;
|
||||
let receivedB = false;
|
||||
|
||||
clientA.on("hello", (data) => {
|
||||
if (data === "Hello!") receivedA = true;
|
||||
});
|
||||
|
||||
clientB.on("hello", (data) => {
|
||||
if (data === "Hello!") receivedB = true;
|
||||
});
|
||||
|
||||
await clientA.command("broadcast", {});
|
||||
|
||||
// wait for both events, or timeout
|
||||
await new Promise<void>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
if (!(receivedA && receivedB)) return;
|
||||
clearTimeout(timeout);
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}, 10);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
expect(receivedA).toBe(true);
|
||||
expect(receivedB).toBe(true);
|
||||
}, 10000);
|
||||
|
||||
test("broadcastRoom should work across instances", async () => {
|
||||
[serverA, serverB].forEach((server) =>
|
||||
server.registerCommand("join-room", async (ctx) => {
|
||||
await server.addToRoom(ctx.payload.room, ctx.connection);
|
||||
return { joined: true };
|
||||
})
|
||||
);
|
||||
|
||||
serverA.registerCommand("broadcast-room", async (ctx) => {
|
||||
await serverA.broadcastRoom(
|
||||
ctx.payload.room,
|
||||
"room-message",
|
||||
ctx.payload.message
|
||||
);
|
||||
return { sent: true };
|
||||
});
|
||||
|
||||
await clientA.connect();
|
||||
await clientB.connect();
|
||||
|
||||
let receivedA = false;
|
||||
let receivedB = false;
|
||||
|
||||
clientA.on("room-message", (data) => {
|
||||
if (data === "hello") receivedA = true;
|
||||
});
|
||||
|
||||
clientB.on("room-message", (data) => {
|
||||
if (data === "hello") receivedB = true;
|
||||
});
|
||||
|
||||
await clientA.command("join-room", { room: "test-room" });
|
||||
await clientB.command("join-room", { room: "test-room" });
|
||||
|
||||
await clientA.command("broadcast-room", {
|
||||
room: "test-room",
|
||||
message: "hello",
|
||||
});
|
||||
|
||||
// wait for both events, or timeout
|
||||
await new Promise<void>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
if (!(receivedA && receivedB)) return;
|
||||
clearTimeout(timeout);
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}, 10);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
expect(receivedA).toBe(true);
|
||||
expect(receivedB).toBe(true);
|
||||
}, 10000);
|
||||
});
|
||||
72
packages/mesh/src/tests/rooms.test.ts
Normal file
72
packages/mesh/src/tests/rooms.test.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import Redis from "ioredis";
|
||||
import { MeshServer } from "../server";
|
||||
import { MeshClient } from "../client";
|
||||
|
||||
const REDIS_HOST = process.env.REDIS_HOST || "127.0.0.1";
|
||||
const REDIS_PORT = process.env.REDIS_PORT
|
||||
? parseInt(process.env.REDIS_PORT, 10)
|
||||
: 6379;
|
||||
|
||||
const createTestServer = (port: number) =>
|
||||
new MeshServer({
|
||||
port,
|
||||
redisOptions: {
|
||||
host: REDIS_HOST,
|
||||
port: REDIS_PORT,
|
||||
},
|
||||
});
|
||||
|
||||
const flushRedis = async () => {
|
||||
const redis = new Redis({ host: REDIS_HOST, port: REDIS_PORT });
|
||||
await redis.flushdb();
|
||||
await redis.quit();
|
||||
};
|
||||
|
||||
describe("KeepAliveServer", () => {
|
||||
const port = 8128;
|
||||
let server: MeshServer;
|
||||
let clientA: MeshClient;
|
||||
let clientB: MeshClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
await flushRedis();
|
||||
|
||||
server = createTestServer(port);
|
||||
await server.ready();
|
||||
|
||||
clientA = new MeshClient(`ws://localhost:${port}`);
|
||||
clientB = new MeshClient(`ws://localhost:${port}`);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await clientA.close();
|
||||
await clientB.close();
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("isInRoom", async () => {
|
||||
server.registerCommand("join-room", async (ctx) => {
|
||||
const { roomName } = ctx.payload;
|
||||
await server.roomManager.addToRoom(roomName, ctx.connection);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
await clientA.connect();
|
||||
await clientB.connect();
|
||||
|
||||
await clientA.command("join-room", { roomName: "room1" });
|
||||
await clientB.command("join-room", { roomName: "room1" });
|
||||
await clientA.command("join-room", { roomName: "room2" });
|
||||
|
||||
const connectionA = server.connectionManager.getLocalConnections()[0]!;
|
||||
const connectionB = server.connectionManager.getLocalConnections()[1]!;
|
||||
|
||||
expect(await server.isInRoom("room1", connectionA)).toBe(true);
|
||||
expect(await server.isInRoom("room1", connectionB)).toBe(true);
|
||||
expect(await server.isInRoom("room2", connectionA)).toBe(true);
|
||||
expect(await server.isInRoom("room2", connectionB)).toBe(false);
|
||||
expect(await server.isInRoom("room3", connectionA)).toBe(false);
|
||||
});
|
||||
});
|
||||
274
packages/mesh/src/tests/subscription.test.ts
Normal file
274
packages/mesh/src/tests/subscription.test.ts
Normal file
@ -0,0 +1,274 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import Redis from "ioredis";
|
||||
import { MeshServer } from "../server";
|
||||
import { MeshClient } from "../client";
|
||||
|
||||
const REDIS_HOST = process.env.REDIS_HOST || "127.0.0.1";
|
||||
const REDIS_PORT = process.env.REDIS_PORT
|
||||
? parseInt(process.env.REDIS_PORT, 10)
|
||||
: 6379;
|
||||
|
||||
const createTestServer = (port: number) =>
|
||||
new MeshServer({
|
||||
port,
|
||||
redisOptions: {
|
||||
host: REDIS_HOST,
|
||||
port: REDIS_PORT,
|
||||
},
|
||||
});
|
||||
|
||||
const flushRedis = async () => {
|
||||
const redis = new Redis({ host: REDIS_HOST, port: REDIS_PORT });
|
||||
await redis.flushdb();
|
||||
await redis.quit();
|
||||
};
|
||||
|
||||
describe("Redis Channel Subscription", () => {
|
||||
const port = 8129;
|
||||
let server: MeshServer;
|
||||
let client1: MeshClient;
|
||||
let client2: MeshClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
await flushRedis();
|
||||
|
||||
server = createTestServer(port);
|
||||
server.exposeChannel("test:channel");
|
||||
server.exposeChannel("test:channel2");
|
||||
await server.ready();
|
||||
|
||||
client1 = new MeshClient(`ws://localhost:${port}`);
|
||||
client2 = new MeshClient(`ws://localhost:${port}`);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await client1.close();
|
||||
await client2.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("client can subscribe to a Redis channel", async () => {
|
||||
await client1.connect();
|
||||
|
||||
const result = await client1.subscribe("test:channel", () => {});
|
||||
expect(result.success).toBe(true);
|
||||
expect(Array.isArray(result.history)).toBe(true);
|
||||
});
|
||||
|
||||
test("client cannot subscribe to an unexposed channel", async () => {
|
||||
await client1.connect();
|
||||
|
||||
const result = await client1.subscribe("unexposed:channel", () => {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(Array.isArray(result.history)).toBe(true);
|
||||
expect(result.history.length).toBe(0);
|
||||
});
|
||||
|
||||
test("client receives messages from subscribed channel", async () => {
|
||||
await client1.connect();
|
||||
|
||||
let receivedMessage: string | null = null;
|
||||
|
||||
await client1.subscribe("test:channel", (message) => {
|
||||
receivedMessage = message;
|
||||
});
|
||||
|
||||
await server.publishToChannel("test:channel", "Hello, Redis!");
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
if (receivedMessage !== null) {
|
||||
clearInterval(interval);
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
}, 10);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
expect(receivedMessage).toBe("Hello, Redis!");
|
||||
});
|
||||
|
||||
test("client can unsubscribe from a channel", async () => {
|
||||
await client1.connect();
|
||||
|
||||
let messageCount = 0;
|
||||
|
||||
await client1.subscribe("test:channel", () => {
|
||||
messageCount++;
|
||||
});
|
||||
|
||||
await server.publishToChannel("test:channel", "Message 1");
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
|
||||
const unsubResult = await client1.unsubscribe("test:channel");
|
||||
expect(unsubResult).toBe(true);
|
||||
|
||||
await server.publishToChannel("test:channel", "Message 2");
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
|
||||
expect(messageCount).toBe(1);
|
||||
});
|
||||
|
||||
test("multiple clients can subscribe to the same channel", async () => {
|
||||
await client1.connect();
|
||||
await client2.connect();
|
||||
|
||||
let client1Received: string | null = null;
|
||||
let client2Received: string | null = null;
|
||||
|
||||
await client1.subscribe("test:channel", (message) => {
|
||||
client1Received = message;
|
||||
});
|
||||
|
||||
await client2.subscribe("test:channel", (message) => {
|
||||
client2Received = message;
|
||||
});
|
||||
|
||||
await server.publishToChannel("test:channel", "Broadcast message");
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
if (client1Received !== null && client2Received !== null) {
|
||||
clearInterval(interval);
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
}, 10);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
expect(client1Received).toBe("Broadcast message");
|
||||
expect(client2Received).toBe("Broadcast message");
|
||||
});
|
||||
|
||||
test("messages are only delivered to subscribed channels", async () => {
|
||||
await client1.connect();
|
||||
|
||||
const channel1Messages: string[] = [];
|
||||
const channel2Messages: string[] = [];
|
||||
|
||||
await client1.subscribe("test:channel", (message) => {
|
||||
channel1Messages.push(message);
|
||||
});
|
||||
|
||||
await client1.subscribe("test:channel2", (message) => {
|
||||
channel2Messages.push(message);
|
||||
});
|
||||
|
||||
await server.publishToChannel("test:channel", "Message for channel 1");
|
||||
await server.publishToChannel("test:channel2", "Message for channel 2");
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
|
||||
expect(channel1Messages).toContain("Message for channel 1");
|
||||
expect(channel1Messages).not.toContain("Message for channel 2");
|
||||
|
||||
expect(channel2Messages).toContain("Message for channel 2");
|
||||
expect(channel2Messages).not.toContain("Message for channel 1");
|
||||
});
|
||||
|
||||
test("unsubscribing from a non-subscribed channel returns false", async () => {
|
||||
await client1.connect();
|
||||
|
||||
const result = await client1.unsubscribe("not:subscribed");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("channel guard prevents unauthorized subscriptions", async () => {
|
||||
await client1.connect();
|
||||
await client2.connect();
|
||||
|
||||
const connections = Object.values(
|
||||
server.connectionManager.getLocalConnections()
|
||||
);
|
||||
const connection1 = connections[0]!;
|
||||
|
||||
// only allow the first client to subscribe to the channel
|
||||
server.exposeChannel(
|
||||
"guarded:channel",
|
||||
(connection, channel) => connection.id === connection1.id
|
||||
);
|
||||
|
||||
const result1 = await client1.subscribe("guarded:channel", () => {});
|
||||
const result2 = await client2.subscribe("guarded:channel", () => {});
|
||||
|
||||
expect(result1.success).toBe(true);
|
||||
expect(result2.success).toBe(false);
|
||||
});
|
||||
|
||||
test("exposeChannel guard callback passes the correct channel name", async () => {
|
||||
await client1.connect();
|
||||
|
||||
let receivedChannel: string | null = null;
|
||||
|
||||
server.exposeChannel("test:channel", (connection, channel) => {
|
||||
receivedChannel = channel;
|
||||
return true;
|
||||
});
|
||||
|
||||
await client1.subscribe("test:channel", () => {});
|
||||
|
||||
expect(receivedChannel).toBe("test:channel");
|
||||
|
||||
receivedChannel = null;
|
||||
|
||||
server.exposeChannel(/^test:channel:\d+$/, (connection, channel) => {
|
||||
receivedChannel = channel;
|
||||
return true;
|
||||
});
|
||||
|
||||
await client1.subscribe("test:channel:1", () => {});
|
||||
|
||||
expect(receivedChannel).toBe("test:channel:1");
|
||||
});
|
||||
|
||||
test("client receives channel history when subscribing with historyLimit", async () => {
|
||||
await client1.connect();
|
||||
|
||||
const historySize = 10;
|
||||
await server.publishToChannel("test:channel", "History message 1", historySize);
|
||||
await server.publishToChannel("test:channel", "History message 2", historySize);
|
||||
await server.publishToChannel("test:channel", "History message 3", historySize);
|
||||
await server.publishToChannel("test:channel", "History message 4", historySize);
|
||||
await server.publishToChannel("test:channel", "History message 5", historySize);
|
||||
|
||||
const receivedMessages: string[] = [];
|
||||
|
||||
const { success, history } = await client1.subscribe("test:channel", (message) => {
|
||||
receivedMessages.push(message);
|
||||
}, { historyLimit: 3 });
|
||||
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(Array.isArray(history)).toBe(true);
|
||||
expect(history.length).toBe(3);
|
||||
|
||||
// ensure newest are first
|
||||
expect(history[0]).toBe("History message 5");
|
||||
expect(history[1]).toBe("History message 4");
|
||||
expect(history[2]).toBe("History message 3");
|
||||
|
||||
expect(receivedMessages).toContain("History message 3");
|
||||
expect(receivedMessages).toContain("History message 4");
|
||||
expect(receivedMessages).toContain("History message 5");
|
||||
expect(receivedMessages.length).toBe(3);
|
||||
});
|
||||
});
|
||||
28
packages/mesh/tsconfig.json
Normal file
28
packages/mesh/tsconfig.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["esnext"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
15
packages/mesh/vitest.config.ts
Normal file
15
packages/mesh/vitest.config.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: true,
|
||||
maxThreads: 1,
|
||||
},
|
||||
forks: {
|
||||
singleFork: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user