Compare commits

...

71 Commits
v1.2.0 ... main

Author SHA1 Message Date
nvms
d31d083ee3 cleanup 2025-04-22 11:17:31 -04:00
nvms
49caf89101 README 2025-04-22 11:17:04 -04:00
nvms
1b026ecadb add client-utils package 2025-04-22 10:55:56 -04:00
nvms
48b41c9d19 simplify metadata retrieval 2025-04-22 10:53:45 -04:00
nvms
209614c3e8 additional client tests 2025-04-22 10:53:25 -04:00
nvms
fde46ad338 validate presence state shape 2025-04-22 10:53:04 -04:00
nvms
960616b3fb README 2025-04-21 11:56:42 -04:00
nvms
c448abfecc README 2025-04-21 11:48:04 -04:00
nvms
fe163324df feature: ephemeral, room-scoped presence states 2025-04-21 11:27:52 -04:00
nvms
21dd9ccf7e feat(server): assign short unique connection IDs using custom generator 2025-04-21 10:40:05 -04:00
nvms
58980e9f09 fix(presence): add TTL-based expiration cleanup using Redis keyspace notifications 2025-04-21 08:37:05 -04:00
nvms
6e153b1b44 registerCommand -> exposeCommand 2025-04-21 08:05:20 -04:00
nvms
978fd71d85 1.0.7 2025-04-20 17:06:02 -04:00
nvms
9fbd947ad1 refactor for maintainability and modularity 2025-04-20 17:05:32 -04:00
nvms
57af00dc40 add useful room APIs to the client 2025-04-20 16:21:12 -04:00
nvms
fb4f275d58 use pattern subscription for presence and add more tests 2025-04-20 15:40:08 -04:00
nvms
c6cb0da27c add some record subscription tests 2025-04-20 15:17:04 -04:00
nvms
bbd48020de namespace messages 2025-04-19 20:57:48 -04:00
nvms
66803c1177 subscribe -> subscribeChannel 2025-04-18 22:33:19 -04:00
nvms
c492ee8a05 README 2025-04-18 17:57:17 -04:00
nvms
0579c0d150 README 2025-04-18 17:52:25 -04:00
nvms
36eed400b8 README 2025-04-18 17:48:10 -04:00
nvms
9ebef6bdb6 README 2025-04-18 17:47:12 -04:00
nvms
1c797eb1ba README 2025-04-18 17:21:58 -04:00
nvms
c14dba183c 1.0.6 2025-04-18 17:18:42 -04:00
nvms
d6f237152b 1.0.5 2025-04-18 17:17:44 -04:00
nvms
6181227532 feature: presence management 2025-04-18 17:17:12 -04:00
nvms
6bd9803c61 add getRoomsForConnection 2025-04-18 16:25:04 -04:00
nvms
af2cf5a4a4 README 2025-04-18 15:41:10 -04:00
nvms
51fc280d8b add onConnection and onDisconnection 2025-04-18 15:40:55 -04:00
nvms
b4751aefe8 README 2025-04-18 13:24:16 -04:00
nvms
798164bec0 add primitive value test 2025-04-18 13:22:25 -04:00
nvms
9a835e0c76 README 2025-04-18 12:50:02 -04:00
nvms
b25f54ae15 README 2025-04-18 12:47:52 -04:00
nvms
9b9c7bea04 publish mesh-express 2025-04-18 12:17:52 -04:00
nvms
f2b80feab8 README 2025-04-18 11:34:14 -04:00
nvms
9c1370dcbf README 2025-04-18 11:30:50 -04:00
nvms
e993afc07f README 2025-04-18 10:11:30 -04:00
nvms
663c9ab735 1.0.4 2025-04-18 10:07:58 -04:00
nvms
f9ccd98d39 feature: exposeWritableRecord and client.publishRecordUpdate 2025-04-18 10:07:24 -04:00
nvms
5a59182775 include the record identifier in the subscribeRecord callback 2025-04-18 09:22:51 -04:00
nvms
10c18f668e README 2025-04-18 08:56:29 -04:00
nvms
8d114a1285 README 2025-04-18 07:59:58 -04:00
nvms
f26e2ddbac README 2025-04-17 22:16:05 -04:00
nvms
7f7d3168af remove pointless export 2025-04-17 21:15:48 -04:00
nvms
f37f040ecf README 2025-04-17 21:14:10 -04:00
nvms
f6c397e1e2 comments and scoping 2025-04-17 21:14:00 -04:00
nvms
31a53fb274 README 2025-04-17 20:59:55 -04:00
nvms
2965ecb548 1.0.2 2025-04-17 20:52:26 -04:00
nvms
7c2850db27 feature: record subscription 2025-04-17 20:52:01 -04:00
nvms
0133f59e39 1.0.1 2025-04-17 18:44:14 -04:00
nvms
06571ac28a feature: room metadata 2025-04-17 18:43:50 -04:00
nvms
8db63ab664 export type 2025-04-17 17:19:18 -04:00
nvms
9140ea34d8 remove unused type 2025-04-17 17:19:00 -04:00
nvms
22140253fe README 2025-04-17 17:14:33 -04:00
nvms
bffefe344a README 2025-04-17 16:42:50 -04:00
nvms
8a84f6ea04 README 2025-04-17 16:30:08 -04:00
nvms
18f60550e2 change hostname 2025-04-17 16:26:36 -04:00
nvms
6fe63c8d58 publish mesh 2025-04-17 16:18:18 -04:00
nvms
b5cd75a018 add test for getRoom 2025-04-15 20:51:38 -04:00
nvms
8af50f0c00 update .npmignore and bump version 2025-04-15 14:52:03 -04:00
nvms
5bd827515f use latest keepalive-ws 2025-04-15 14:51:34 -04:00
nvms
3395ddb7ac 1.0.2 release 2025-04-15 14:35:49 -04:00
nvms
ada569c83c 1.0.1 release 2025-04-15 14:35:41 -04:00
nvms
437c264895 1.0.0 release 2025-04-15 14:34:41 -04:00
nvms
7170d1bf89 redis-backed room support 2025-04-15 14:33:20 -04:00
nvms
5c322d6bbc Release 2.0.1 2025-03-27 20:42:16 -04:00
nvms
3e6ee88ab7 README 2025-03-27 20:41:52 -04:00
nvms
4f858a5b96 Add missing .gitignore 2025-03-27 20:01:21 -04:00
nvms
48d1205505 feat: "scene" (i.e. world) management
- Add tests to ensure predictable start/stop world behavior.
- Update README to include example usages.
2025-03-27 19:13:41 -04:00
nvms
6e063101cc feat(input): improve safety check of button assignment
feat(time scale): adjust time scale logic, add rawDelta
fix(migrateEntityId): update $ceMap on entity id change
chore(tests): update tests
chore(README): update readme to reflect these new changes
2025-03-27 17:50:10 -04:00
45 changed files with 1970 additions and 242 deletions

2
packages/duplex/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
dist

View File

@ -1,2 +1,6 @@
node_modules
src
data
tsup.config.ts
bump.config.ts
bun.lockb

View File

@ -1,6 +1,6 @@
{
"name": "@prsm/express-keepalive-ws",
"version": "1.2.7",
"version": "1.2.8",
"author": "",
"main": "./dist/index.js",
"devDependencies": {
@ -27,6 +27,6 @@
"types": "module",
"typings": "./dist/index.d.ts",
"dependencies": {
"@prsm/keepalive-ws": "^0.3.8"
"@prsm/keepalive-ws": "^1.0.3"
}
}

1
packages/jwt/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

View File

@ -1,2 +1,6 @@
node_modules
src
src
tests
docker-compose.yml
bump.config.ts
bun.lockb

View File

@ -26,6 +26,9 @@ const server = new KeepAliveServer({
port: 8080,
pingInterval: 30000,
latencyInterval: 5000,
// Multi-instance room support (optional):
// roomBackend: "redis",
// redisOptions: { host: "localhost", port: 6379 }
});
// Register command handlers
@ -41,8 +44,8 @@ server.registerCommand("throws", async () => {
// Room-based messaging
server.registerCommand("join-room", async (context) => {
const { roomName } = context.payload;
server.addToRoom(roomName, context.connection);
server.broadcastRoom(roomName, "user-joined", {
await server.addToRoom(roomName, context.connection);
await server.broadcastRoom(roomName, "user-joined", {
id: context.connection.id
});
return { success: true };
@ -101,17 +104,17 @@ await client.close();
### Room Management
```typescript
// Add a connection to a room
server.addToRoom("roomName", connection);
// Add a connection to a room (async)
await server.addToRoom("roomName", connection);
// Remove a connection from a room
server.removeFromRoom("roomName", connection);
// Remove a connection from a room (async)
await server.removeFromRoom("roomName", connection);
// Get all connections in a room
const roomConnections = server.getRoom("roomName");
// Get all connections in a room (async)
const roomConnections = await server.getRoom("roomName");
// Clear all connections from a room
server.clearRoom("roomName");
// Clear all connections from a room (async)
await server.clearRoom("roomName");
```
### Broadcasting
@ -162,6 +165,22 @@ server.registerCommand(
);
```
## Multi-Instance Room Support
To enable multi-instance room support (so rooms are shared across all server instances), configure the server with `roomBackend: "redis"` and provide `redisOptions`:
```typescript
import { KeepAliveServer } from "@prsm/keepalive-ws/server";
const server = new KeepAliveServer({
port: 8080,
roomBackend: "redis",
redisOptions: { host: "localhost", port: 6379 }
});
```
All room management methods become async and must be awaited.
## Graceful Shutdown
```typescript

Binary file not shown.

View 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

View File

@ -1,6 +1,6 @@
{
"name": "@prsm/keepalive-ws",
"version": "0.3.8",
"version": "1.0.3",
"description": "",
"type": "module",
"main": "./dist/server/index.js",
@ -40,6 +40,7 @@
"keywords": [],
"license": "Apache-2.0",
"dependencies": {
"ioredis": "^5.6.1",
"ws": "^8.9.0"
},
"devDependencies": {

View File

@ -73,7 +73,21 @@ export class KeepAliveClient extends EventEmitter {
private setupConnectionEvents(): void {
// Forward relevant events from connection to client
this.connection.on("message", (data) => {
// Forward the raw message event
this.emit("message", data);
// Also forward the specific command event if it's not a system event
// (System events like ping/latency are handled separately below)
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", () => {
@ -150,8 +164,8 @@ export class KeepAliveClient extends EventEmitter {
new CodeError(
"WebSocket connection error",
"ECONNECTION",
"ConnectionError",
),
"ConnectionError"
)
);
};
} catch (error) {
@ -233,11 +247,12 @@ export class KeepAliveClient extends EventEmitter {
if (attempt <= this.options.maxReconnectAttempts) {
setTimeout(connect, this.options.reconnectInterval);
} else {
this.isReconnecting = false;
this._status = Status.OFFLINE;
this.emit("reconnectfailed");
return;
}
this.isReconnecting = false;
this._status = Status.OFFLINE;
this.emit("reconnectfailed");
};
this.socket.onopen = () => {
@ -268,13 +283,13 @@ export class KeepAliveClient extends EventEmitter {
command: string,
payload?: any,
expiresIn: number = 30000,
callback?: (result: any, error?: Error) => void,
callback?: (result: any, error?: Error) => void
): Promise<any> {
// Ensure we're connected before sending commands
if (this._status !== Status.ONLINE) {
return this.connect()
.then(() =>
this.connection.command(command, payload, expiresIn, callback),
this.connection.command(command, payload, expiresIn, callback)
)
.catch((error) => {
if (callback) {

View File

@ -115,7 +115,7 @@ export class Connection extends EventEmitter {
command: string,
payload: any,
expiresIn: number | null = 30_000,
callback?: (result: any, error?: Error) => void,
callback?: (result: any, error?: Error) => void
): Promise<any> | null {
const id = this.ids.reserve();
const cmd: Command = { id, command, payload: payload ?? {} };
@ -142,17 +142,17 @@ export class Connection extends EventEmitter {
const timeoutPromise = new Promise<any>((_, reject) => {
setTimeout(() => {
if (this.callbacks[id]) {
this.ids.release(id);
delete this.callbacks[id];
reject(
new CodeError(
`Command timed out after ${expiresIn}ms.`,
"ETIMEOUT",
"TimeoutError",
),
);
}
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);
});

View File

@ -10,7 +10,7 @@ export class IdManager {
release(id: number) {
if (id < 0 || id > this.maxIndex) {
throw new TypeError(
`ID must be between 0 and ${this.maxIndex}. Got ${id}.`,
`ID must be between 0 and ${this.maxIndex}. Got ${id}.`
);
}
this.ids[id] = false;
@ -36,7 +36,7 @@ export class IdManager {
if (this.index === startIndex) {
throw new Error(
`All IDs are reserved. Make sure to release IDs when they are no longer used.`,
`All IDs are reserved. Make sure to release IDs when they are no longer used.`
);
}
}

View File

@ -20,7 +20,7 @@ export class Connection extends EventEmitter {
constructor(
socket: WebSocket,
req: IncomingMessage,
options: KeepAliveServerOptions,
options: KeepAliveServerOptions
) {
super();
this.socket = socket;

View File

@ -1,5 +1,11 @@
import { IncomingMessage } from "node:http";
import { ServerOptions, WebSocket, WebSocketServer } from "ws";
import type { RedisOptions } from "ioredis";
import {
RoomManager,
InMemoryRoomManager,
RedisRoomManager,
} from "./room-manager";
import { CodeError } from "../common/codeerror";
import { Command, parseCommand } from "../common/message";
import { Status } from "../common/status";
@ -34,6 +40,16 @@ export type KeepAliveServerOptions = ServerOptions & {
* @default 5000
*/
latencyInterval?: number;
/**
* Room backend type: "memory" (default) or "redis"
*/
roomBackend?: "memory" | "redis";
/**
* Redis options, required if roomBackend is "redis"
*/
redisOptions?: RedisOptions;
};
export class KeepAliveServer extends WebSocketServer {
@ -44,7 +60,7 @@ export class KeepAliveServer extends WebSocketServer {
} = {};
globalMiddlewares: SocketMiddleware[] = [];
middlewares: { [key: string]: SocketMiddleware[] } = {};
rooms: { [roomName: string]: Set<string> } = {};
roomManager: RoomManager;
serverOptions: ServerOptions & {
pingInterval: number;
latencyInterval: number;
@ -67,6 +83,23 @@ export class KeepAliveServer extends WebSocketServer {
latencyInterval: opts.latencyInterval ?? 5_000,
};
// Room manager selection
if (opts.roomBackend === "redis") {
if (!opts.redisOptions) {
throw new Error(
"redisOptions must be provided when roomBackend is 'redis'"
);
}
this.roomManager = new RedisRoomManager(
opts.redisOptions,
(id: string) => this.connections[id]
);
} else {
this.roomManager = new InMemoryRoomManager(
(id: string) => this.connections[id]
);
}
this.on("listening", () => {
this._listening = true;
this.status = Status.ONLINE;
@ -80,14 +113,14 @@ export class KeepAliveServer extends WebSocketServer {
this.applyListeners();
}
private cleanupConnection(connection: Connection): void {
private async cleanupConnection(connection: Connection): Promise<void> {
connection.stopIntervals();
delete this.connections[connection.id];
if (this.remoteAddressToConnections[connection.remoteAddress]) {
this.remoteAddressToConnections[connection.remoteAddress] =
this.remoteAddressToConnections[connection.remoteAddress].filter(
(conn) => conn.id !== connection.id,
(conn) => conn.id !== connection.id
);
if (
@ -98,9 +131,7 @@ export class KeepAliveServer extends WebSocketServer {
}
// Remove from all rooms
Object.keys(this.rooms).forEach((roomName) => {
this.rooms[roomName].delete(connection.id);
});
await this.roomManager.removeFromAllRooms(connection);
}
private applyListeners(): void {
@ -113,13 +144,13 @@ export class KeepAliveServer extends WebSocketServer {
}
this.remoteAddressToConnections[connection.remoteAddress].push(
connection,
connection
);
this.emit("connected", connection);
connection.on("close", () => {
this.cleanupConnection(connection);
connection.on("close", async () => {
await this.cleanupConnection(connection);
this.emit("close", connection);
});
@ -137,7 +168,7 @@ export class KeepAliveServer extends WebSocketServer {
command.id,
command.command,
command.payload,
connection,
connection
);
}
} catch (error) {
@ -172,7 +203,7 @@ export class KeepAliveServer extends WebSocketServer {
broadcastRemoteAddress(
connection: Connection,
command: string,
payload: any,
payload: any
): void {
const cmd: Command = { command, payload };
const connections =
@ -194,47 +225,30 @@ export class KeepAliveServer extends WebSocketServer {
* Given a roomName, a command and a payload, broadcasts to all Connections
* that are in the room.
*/
broadcastRoom(roomName: string, command: string, payload: any): void {
const cmd: Command = { command, payload };
const room = this.rooms[roomName];
if (!room) return;
room.forEach((connectionId) => {
const connection = this.connections[connectionId];
if (connection) {
connection.send(cmd);
}
});
async broadcastRoom(
roomName: string,
command: string,
payload: any
): Promise<void> {
await this.roomManager.broadcastRoom(roomName, command, payload);
}
/**
* Given a roomName, command, payload, and Connection OR Connection[], broadcasts to all Connections
* that are in the room except the provided Connection(s).
*/
broadcastRoomExclude(
async broadcastRoomExclude(
roomName: string,
command: string,
payload: any,
connection: Connection | Connection[],
): void {
const cmd: Command = { command, payload };
const room = this.rooms[roomName];
if (!room) return;
const excludeIds = Array.isArray(connection)
? connection.map((c) => c.id)
: [connection.id];
room.forEach((connectionId) => {
if (!excludeIds.includes(connectionId)) {
const conn = this.connections[connectionId];
if (conn) {
conn.send(cmd);
}
}
});
connection: Connection | Connection[]
): Promise<void> {
await this.roomManager.broadcastRoomExclude(
roomName,
command,
payload,
connection
);
}
/**
@ -244,7 +258,7 @@ export class KeepAliveServer extends WebSocketServer {
broadcastExclude(
connection: Connection,
command: string,
payload: any,
payload: any
): void {
const cmd: Command = { command, payload };
@ -258,46 +272,39 @@ export class KeepAliveServer extends WebSocketServer {
/**
* Add a connection to a room
*/
addToRoom(roomName: string, connection: Connection): void {
this.rooms[roomName] = this.rooms[roomName] ?? new Set();
this.rooms[roomName].add(connection.id);
async addToRoom(roomName: string, connection: Connection): Promise<void> {
await this.roomManager.addToRoom(roomName, connection);
}
/**
* Remove a connection from a room
*/
removeFromRoom(roomName: string, connection: Connection): void {
if (!this.rooms[roomName]) return;
this.rooms[roomName].delete(connection.id);
async removeFromRoom(
roomName: string,
connection: Connection
): Promise<void> {
await this.roomManager.removeFromRoom(roomName, connection);
}
/**
* Remove a connection from all rooms
*/
removeFromAllRooms(connection: Connection | string): void {
const connectionId =
typeof connection === "string" ? connection : connection.id;
Object.keys(this.rooms).forEach((roomName) => {
this.rooms[roomName].delete(connectionId);
});
async removeFromAllRooms(connection: Connection | string): Promise<void> {
await this.roomManager.removeFromAllRooms(connection);
}
/**
* Returns all connections in a room
*/
getRoom(roomName: string): Connection[] {
const ids = this.rooms[roomName] || new Set();
return Array.from(ids)
.map((id) => this.connections[id])
.filter(Boolean);
async getRoom(roomName: string): Promise<Connection[]> {
return this.roomManager.getRoom(roomName);
}
/**
* Clear all connections from a room
*/
clearRoom(roomName: string): void {
this.rooms[roomName] = new Set();
async clearRoom(roomName: string): Promise<void> {
await this.roomManager.clearRoom(roomName);
}
/**
@ -306,7 +313,7 @@ export class KeepAliveServer extends WebSocketServer {
async registerCommand<T = any>(
command: string,
callback: (context: WSContext<any>) => Promise<T> | T,
middlewares: SocketMiddleware[] = [],
middlewares: SocketMiddleware[] = []
): Promise<void> {
this.commands[command] = callback;
@ -322,7 +329,7 @@ export class KeepAliveServer extends WebSocketServer {
*/
prependMiddlewareToCommand(
command: string,
middlewares: SocketMiddleware[],
middlewares: SocketMiddleware[]
): void {
if (middlewares.length) {
this.middlewares[command] = this.middlewares[command] || [];
@ -335,7 +342,7 @@ export class KeepAliveServer extends WebSocketServer {
*/
appendMiddlewareToCommand(
command: string,
middlewares: SocketMiddleware[],
middlewares: SocketMiddleware[]
): void {
if (middlewares.length) {
this.middlewares[command] = this.middlewares[command] || [];
@ -350,7 +357,7 @@ export class KeepAliveServer extends WebSocketServer {
id: number,
command: string,
payload: any,
connection: Connection,
connection: Connection
): Promise<void> {
const context = new WSContext(this, connection, payload);
@ -359,7 +366,7 @@ export class KeepAliveServer extends WebSocketServer {
throw new CodeError(
`Command [${command}] not found.`,
"ENOTFOUND",
"CommandError",
"CommandError"
);
}

View File

@ -0,0 +1,192 @@
import { Connection } from "./connection";
import Redis from "ioredis";
import type { RedisOptions } from "ioredis";
export interface RoomManager {
addToRoom(roomName: string, connection: Connection): Promise<void>;
removeFromRoom(roomName: string, connection: Connection): Promise<void>;
removeFromAllRooms(connection: Connection | string): Promise<void>;
getRoom(roomName: string): Promise<Connection[]>;
clearRoom(roomName: string): Promise<void>;
broadcastRoom(roomName: string, command: string, payload: any): Promise<void>;
broadcastRoomExclude(
roomName: string,
command: string,
payload: any,
connection: Connection | Connection[]
): Promise<void>;
}
export class InMemoryRoomManager implements RoomManager {
private rooms: { [roomName: string]: Set<string> } = {};
private getConnectionById: (id: string) => Connection | undefined;
constructor(getConnectionById: (id: string) => Connection | undefined) {
this.getConnectionById = getConnectionById;
}
async addToRoom(roomName: string, connection: Connection): Promise<void> {
this.rooms[roomName] = this.rooms[roomName] ?? new Set();
this.rooms[roomName].add(connection.id);
}
async removeFromRoom(
roomName: string,
connection: Connection
): Promise<void> {
if (!this.rooms[roomName]) return;
this.rooms[roomName].delete(connection.id);
}
async removeFromAllRooms(connection: Connection | string): Promise<void> {
const connectionId =
typeof connection === "string" ? connection : connection.id;
Object.keys(this.rooms).forEach((roomName) => {
this.rooms[roomName].delete(connectionId);
});
}
async getRoom(roomName: string): Promise<Connection[]> {
const ids = this.rooms[roomName] || new Set();
return Array.from(ids)
.map((id) => this.getConnectionById(id))
.filter(Boolean) as Connection[];
}
async clearRoom(roomName: string): Promise<void> {
this.rooms[roomName] = new Set();
}
async broadcastRoom(
roomName: string,
command: string,
payload: any
): Promise<void> {
const ids = this.rooms[roomName];
if (!ids) return;
for (const connectionId of ids) {
const connection = this.getConnectionById(connectionId);
if (connection) {
connection.send({ command, payload });
}
}
}
async broadcastRoomExclude(
roomName: string,
command: string,
payload: any,
connection: Connection | Connection[]
): Promise<void> {
const ids = this.rooms[roomName];
if (!ids) return;
const excludeIds = Array.isArray(connection)
? connection.map((c) => c.id)
: [connection.id];
for (const connectionId of ids) {
if (!excludeIds.includes(connectionId)) {
const conn = this.getConnectionById(connectionId);
if (conn) {
conn.send({ command, payload });
}
}
}
}
}
export class RedisRoomManager implements RoomManager {
private redis: Redis;
private getConnectionById: (id: string) => Connection | undefined;
constructor(
redisOptions: RedisOptions,
getConnectionById: (id: string) => Connection | undefined
) {
this.redis = new Redis(redisOptions);
this.getConnectionById = getConnectionById;
// TODO: reconnect logic?
}
private roomKey(roomName: string) {
return `room:${roomName}`;
}
private connRoomsKey(connectionId: string) {
return `connection:${connectionId}:rooms`;
}
async addToRoom(roomName: string, connection: Connection): Promise<void> {
await this.redis.sadd(this.roomKey(roomName), connection.id);
await this.redis.sadd(this.connRoomsKey(connection.id), roomName);
}
async removeFromRoom(
roomName: string,
connection: Connection
): Promise<void> {
await this.redis.srem(this.roomKey(roomName), connection.id);
await this.redis.srem(this.connRoomsKey(connection.id), roomName);
}
async removeFromAllRooms(connection: Connection | string): Promise<void> {
const connectionId =
typeof connection === "string" ? connection : connection.id;
const roomNames = await this.redis.smembers(
this.connRoomsKey(connectionId)
);
if (!(roomNames.length > 0)) return;
const pipeline = this.redis.pipeline();
for (const roomName of roomNames) {
pipeline.srem(this.roomKey(roomName), connectionId);
}
pipeline.del(this.connRoomsKey(connectionId));
await pipeline.exec();
}
async getRoom(roomName: string): Promise<Connection[]> {
const ids = await this.redis.smembers(this.roomKey(roomName));
return ids
.map((id) => this.getConnectionById(id))
.filter(Boolean) as Connection[];
}
async clearRoom(roomName: string): Promise<void> {
await this.redis.del(this.roomKey(roomName));
}
async broadcastRoom(
roomName: string,
command: string,
payload: any
): Promise<void> {
const ids = await this.redis.smembers(this.roomKey(roomName));
for (const connectionId of ids) {
const connection = this.getConnectionById(connectionId);
if (connection) {
connection.send({ command, payload });
}
}
}
async broadcastRoomExclude(
roomName: string,
command: string,
payload: any,
connection: Connection | Connection[]
): Promise<void> {
const ids = await this.redis.smembers(this.roomKey(roomName));
const excludeIds = Array.isArray(connection)
? connection.map((c) => c.id)
: [connection.id];
for (const connectionId of ids) {
if (!excludeIds.includes(connectionId)) {
const conn = this.getConnectionById(connectionId);
if (conn) {
conn.send({ command, payload });
}
}
}
}
}

View File

@ -2,13 +2,12 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { KeepAliveClient, Status } from "../src/client/client";
import { KeepAliveServer } from "../src/server/index";
const createTestServer = (port: number) => {
return new KeepAliveServer({
const createTestServer = (port: number) =>
new KeepAliveServer({
port,
pingInterval: 1000,
latencyInterval: 500,
});
};
describe("Advanced KeepAliveClient and KeepAliveServer Tests", () => {
const port = 8125;
@ -49,14 +48,15 @@ describe("Advanced KeepAliveClient and KeepAliveServer Tests", () => {
});
test("command times out when server doesn't respond", async () => {
await server.registerCommand("never-responds", async () => {
return new Promise(() => {});
});
await server.registerCommand(
"never-responds",
async () => new Promise(() => {})
);
await client.connect();
await expect(
client.command("never-responds", "Should timeout", 500),
client.command("never-responds", "Should timeout", 500)
).rejects.toThrow(/timed out/);
}, 2000);
@ -82,9 +82,10 @@ describe("Advanced KeepAliveClient and KeepAliveServer Tests", () => {
return `Slow: ${context.payload}`;
});
await server.registerCommand("echo", async (context) => {
return `Echo: ${context.payload}`;
});
await server.registerCommand(
"echo",
async (context) => `Echo: ${context.payload}`
);
await client.connect();
@ -98,9 +99,7 @@ describe("Advanced KeepAliveClient and KeepAliveServer Tests", () => {
}, 3000);
test("handles large payloads correctly", async () => {
await server.registerCommand("echo", async (context) => {
return context.payload;
});
await server.registerCommand("echo", async (context) => context.payload);
await client.connect();
@ -123,9 +122,10 @@ describe("Advanced KeepAliveClient and KeepAliveServer Tests", () => {
}, 10000);
test("server handles multiple client connections", async () => {
await server.registerCommand("echo", async (context) => {
return `Echo: ${context.payload}`;
});
await server.registerCommand(
"echo",
async (context) => `Echo: ${context.payload}`
);
const clients = Array(5)
.fill(0)
@ -134,7 +134,7 @@ describe("Advanced KeepAliveClient and KeepAliveServer Tests", () => {
await Promise.all(clients.map((client) => client.connect()));
const results = await Promise.all(
clients.map((client, i) => client.command("echo", `Client ${i}`, 1000)),
clients.map((client, i) => client.command("echo", `Client ${i}`, 1000))
);
results.forEach((result, i) => {

View File

@ -2,13 +2,12 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { KeepAliveClient, Status } from "../src/client/client";
import { KeepAliveServer } from "../src/server/index";
const createTestServer = (port: number) => {
return new KeepAliveServer({
const createTestServer = (port: number) =>
new KeepAliveServer({
port,
pingInterval: 1000,
latencyInterval: 500,
});
};
describe("Basic KeepAliveClient and KeepAliveServer Tests", () => {
const port = 8124;
@ -48,18 +47,17 @@ describe("Basic KeepAliveClient and KeepAliveServer Tests", () => {
});
test("client-server connection should be online", async () => {
await server.registerCommand("echo", async (context) => {
return context.payload;
});
await server.registerCommand("echo", async (context) => context.payload);
await client.connect();
expect(client.status).toBe(Status.ONLINE);
}, 10000);
test("simple echo command", async () => {
await server.registerCommand("echo", async (context) => {
return `Echo: ${context.payload}`;
});
await server.registerCommand(
"echo",
async (context) => `Echo: ${context.payload}`
);
await client.connect();
@ -68,9 +66,7 @@ describe("Basic KeepAliveClient and KeepAliveServer Tests", () => {
}, 10000);
test("connect should resolve when already connected", async () => {
await server.registerCommand("echo", async (context) => {
return context.payload;
});
await server.registerCommand("echo", async (context) => context.payload);
await client.connect();
expect(client.status).toBe(Status.ONLINE);

View File

@ -0,0 +1,394 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import Redis from "ioredis";
import { KeepAliveClient, Status } from "../src/client/client";
import { KeepAliveServer } from "../src/server/index";
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 createRedisServer = (port: number) =>
new KeepAliveServer({
port,
pingInterval: 1000,
latencyInterval: 500,
roomBackend: "redis",
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 with Redis room backend", () => {
const port = 8126;
let server: KeepAliveServer;
let clientA: KeepAliveClient;
let clientB: KeepAliveClient;
beforeEach(async () => {
await flushRedis();
server = createRedisServer(port);
await new Promise<void>((resolve) => {
server.on("listening", () => resolve());
if (server.listening) resolve();
});
clientA = new KeepAliveClient(`ws://localhost:${port}`);
clientB = new KeepAliveClient(`ws://localhost:${port}`);
});
afterEach(async () => {
if (clientA.status === Status.ONLINE) await clientA.close();
if (clientB.status === Status.ONLINE) await clientB.close();
return new Promise<void>((resolve) => {
if (server) {
server.close(() => resolve());
} else {
resolve();
}
});
});
test("multi-instance room membership and broadcast with Redis", async () => {
await server.registerCommand("join-room", async (context) => {
await server.addToRoom(context.payload.room, context.connection);
return { joined: true };
});
await server.registerCommand("broadcast-room", async (context) => {
await server.broadcastRoom(
context.payload.room,
"room-message",
context.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: "testroom" });
await clientB.command("join-room", { room: "testroom" });
await clientA.command("broadcast-room", {
room: "testroom",
message: "hello",
});
// Wait for both events or timeout
await new Promise((resolve, reject) => {
const timeout = setTimeout(resolve, 2000);
const check = () => {
if (receivedA && receivedB) {
clearTimeout(timeout);
resolve(null);
}
};
clientA.on("room-message", check);
clientB.on("room-message", check);
});
expect(receivedA).toBe(true);
expect(receivedB).toBe(true);
}, 10000);
test("removeFromRoom removes a client from a specific room", async () => {
await server.registerCommand("join-room", async (context) => {
await server.addToRoom(context.payload.room, context.connection);
return { joined: true };
});
await server.registerCommand("leave-room", async (context) => {
await server.removeFromRoom(context.payload.room, context.connection);
return { left: true };
});
await server.registerCommand("broadcast-room", async (context) => {
await server.broadcastRoom(
context.payload.room,
"room-message",
context.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 after leave") receivedA = true;
});
clientB.on("room-message", (data) => {
if (data === "hello after leave") receivedB = true;
});
await clientA.command("join-room", { room: "testroom-leave" });
await clientB.command("join-room", { room: "testroom-leave" });
// Ensure both are in before leaving
await new Promise((res) => setTimeout(res, 100)); // Short delay for redis propagation
await clientA.command("leave-room", { room: "testroom-leave" });
// Wait a bit for leave command to process
await new Promise((res) => setTimeout(res, 100));
await clientB.command("broadcast-room", {
room: "testroom-leave",
message: "hello after leave",
});
// Wait for potential message or timeout
await new Promise((resolve) => setTimeout(resolve, 500));
expect(receivedA).toBe(false); // Client A should not receive the message
expect(receivedB).toBe(true); // Client B should receive the message
}, 10000);
test("removeFromAllRooms removes a client from all rooms", async () => {
await server.registerCommand("join-room", async (context) => {
await server.addToRoom(context.payload.room, context.connection);
return { joined: true };
});
await server.registerCommand("leave-all-rooms", async (context) => {
await server.removeFromAllRooms(context.connection);
return { left_all: true };
});
await server.registerCommand("broadcast-room", async (context) => {
await server.broadcastRoom(
context.payload.room,
"room-message",
context.payload.message
);
return { sent: true };
});
await clientA.connect();
await clientB.connect();
let receivedA_room1 = false;
let receivedA_room2 = false;
let receivedB_room1 = false;
clientA.on("room-message", (data) => {
if (data === "hello room1 after all") receivedA_room1 = true;
if (data === "hello room2 after all") receivedA_room2 = true;
});
clientB.on("room-message", (data) => {
if (data === "hello room1 after all") receivedB_room1 = true;
});
await clientA.command("join-room", { room: "room1" });
await clientA.command("join-room", { room: "room2" });
await clientB.command("join-room", { room: "room1" });
// Ensure joins are processed
await new Promise((res) => setTimeout(res, 100));
await clientA.command("leave-all-rooms", {});
// Wait a bit for leave command to process
await new Promise((res) => setTimeout(res, 100));
// Broadcast to room1
await clientB.command("broadcast-room", {
room: "room1",
message: "hello room1 after all",
});
// Broadcast to room2 (no one should be left)
await clientB.command("broadcast-room", {
// Client B isn't in room2, but can still broadcast
room: "room2",
message: "hello room2 after all",
});
// Wait for potential messages or timeout
await new Promise((resolve) => setTimeout(resolve, 500));
expect(receivedA_room1).toBe(false); // Client A should not receive from room1
expect(receivedA_room2).toBe(false); // Client A should not receive from room2
expect(receivedB_room1).toBe(true); // Client B should receive from room1
}, 10000);
test("clearRoom removes all clients from a room", async () => {
await server.registerCommand("join-room", async (context) => {
await server.addToRoom(context.payload.room, context.connection);
return { joined: true };
});
await server.registerCommand("clear-room", async (context) => {
await server.clearRoom(context.payload.room);
return { cleared: true };
});
await server.registerCommand("broadcast-room", async (context) => {
await server.broadcastRoom(
context.payload.room,
"room-message",
context.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 after clear") receivedA = true;
});
clientB.on("room-message", (data) => {
if (data === "hello after clear") receivedB = true;
});
await clientA.command("join-room", { room: "testroom-clear" });
await clientB.command("join-room", { room: "testroom-clear" });
// Ensure joins are processed
await new Promise((res) => setTimeout(res, 100));
await clientA.command("clear-room", { room: "testroom-clear" });
// Wait a bit for clear command to process
await new Promise((res) => setTimeout(res, 100));
// Try broadcasting (client A is still connected, just not in room)
await clientA.command("broadcast-room", {
room: "testroom-clear",
message: "hello after clear",
});
// Wait for potential messages or timeout
await new Promise((resolve) => setTimeout(resolve, 500));
expect(receivedA).toBe(false); // Client A should not receive
expect(receivedB).toBe(false); // Client B should not receive
}, 10000);
test("broadcastRoomExclude sends to all except specified clients", async () => {
const clientC = new KeepAliveClient(`ws://localhost:${port}`);
await server.registerCommand("join-room", async (context) => {
await server.addToRoom(context.payload.room, context.connection);
return { joined: true };
});
await server.registerCommand("broadcast-exclude", async (context) => {
await server.broadcastRoomExclude(
context.payload.room,
"room-message",
context.payload.message,
context.connection // Exclude sender
);
return { sent_exclude: true };
});
await clientA.connect();
await clientB.connect();
await clientC.connect();
let receivedA = false;
let receivedB = false;
let receivedC = false;
clientA.on("room-message", (data) => {
if (data === "hello exclude") receivedA = true;
});
clientB.on("room-message", (data) => {
if (data === "hello exclude") receivedB = true;
});
clientC.on("room-message", (data) => {
if (data === "hello exclude") receivedC = true;
});
await clientA.command("join-room", { room: "testroom-exclude" });
await clientB.command("join-room", { room: "testroom-exclude" });
await clientC.command("join-room", { room: "testroom-exclude" });
// Ensure joins are processed
await new Promise((res) => setTimeout(res, 100));
// Client A broadcasts, excluding itself
await clientA.command("broadcast-exclude", {
room: "testroom-exclude",
message: "hello exclude",
});
// Wait for potential messages or timeout
await new Promise((resolve) => setTimeout(resolve, 500));
expect(receivedA).toBe(false); // Client A (sender) should not receive
expect(receivedB).toBe(true); // Client B should receive
expect(receivedC).toBe(true); // Client C should receive
if (clientC.status === Status.ONLINE) await clientC.close();
}, 10000);
test("getRoom correctly retrieves all connections in a room", async () => {
await server.registerCommand("join-room", async (context) => {
await server.addToRoom(context.payload.room, context.connection);
return { joined: true, id: context.connection.id };
});
await server.registerCommand("get-room-members", async (context) => {
const connections = await server.getRoom(context.payload.room);
return {
count: connections.length,
memberIds: connections.map((conn) => conn.id),
};
});
await clientA.connect();
await clientB.connect();
const { id: clientAID } = await clientA.command("join-room", {
room: "test-get-room-1",
});
const { id: clientBID } = await clientB.command("join-room", {
room: "test-get-room-1",
});
await clientA.command("join-room", { room: "test-get-room-2" });
await new Promise((res) => setTimeout(res, 200));
const room1Result = await clientA.command("get-room-members", {
room: "test-get-room-1",
});
const room2Result = await clientA.command("get-room-members", {
room: "test-get-room-2",
});
const emptyRoomResult = await clientA.command("get-room-members", {
room: "non-existent-room",
});
expect(room1Result.count).toBe(2);
expect(room1Result.memberIds.length).toBe(2);
expect(room1Result.memberIds).toContain(clientAID);
expect(room1Result.memberIds).toContain(clientBID);
expect(room2Result.count).toBe(1);
expect(room2Result.memberIds.length).toBe(1);
expect(room2Result.memberIds).toContain(clientAID);
expect(emptyRoomResult.count).toBe(0);
expect(emptyRoomResult.memberIds).toEqual([]);
}, 10000);
});

34
packages/mesh-express/.gitignore vendored Normal file
View 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

View File

@ -0,0 +1,6 @@
node_modules
src
docker-compose.yml
bun.lock
vitest.config.ts
TODO.md

View File

@ -0,0 +1,121 @@
# @prsm/mesh-express
A simple adapter for running [Mesh](https://github.com/node-prism/mesh) inside an existing Express + HTTP server.
This package wires up a `MeshServer` instance to handle WebSocket upgrades using the native `upgrade` event and exposes an optional Express middleware.
---
## Installation
```bash
npm install @prsm/mesh-express
```
---
## Usage
```ts
import express from "express";
import http from "http";
import createMeshMiddleware from "@prsm/mesh-express";
const app = express();
const server = http.createServer(app);
const { middleware, mesh } = createMeshMiddleware(server, {
path: "/ws",
redisOptions: { host: "localhost", port: 6379 },
});
app.use(middleware); // optional
mesh.registerCommand("echo", async (ctx) => {
return `echo: ${ctx.payload}`;
});
server.listen(3000, () => {
console.log("Server listening on port 3000");
});
```
---
## What does the middleware do?
The middleware enables Express to recognize WebSocket upgrade requests and adds a `.ws()` method to the request object. While most upgrades are handled automatically by Mesh via the `upgrade` event, `.ws()` gives you manual control for custom upgrade logic (e.g. auth).
## When to use `.ws()`
Use `.ws()` when you need to *conditionally accept or reject* WebSocket connections inside an Express route.
```ts
app.use("/ws", (req, res, next) => {
if (!req.ws) return next();
const token = req.query.token;
if (!isValidToken(token)) {
return res.status(401).send("Unauthorized");
}
const ws = await req.ws(); // manually upgrade
ws.send("Upgraded!");
});
```
This is useful for:
- Auth checks during upgrade
- Inspecting query params or headers
- Rejecting based on app state (e.g. maintenance)
In most cases, you won't need `.ws()`—Mesh handles upgrades automatically if the request path matches, but the option is there when you need it.
---
## API
### `createMeshMiddleware(server, options)`
| Param | Type | Description |
|-----------|------------------------|------------------------------------------|
| `server` | `http.Server` | The existing HTTP server to attach to |
| `options` | `MeshServerOptions` | Standard Mesh config (plus optional `path`) |
Returns an object with:
- `middleware`: an Express-compatible async middleware
- `mesh`: the `MeshServer` instance for command registration, etc.
---
## Client usage
On the client, use the standard Mesh client:
```ts
import { MeshClient } from "@prsm/mesh/client";
const client = new MeshClient("ws://localhost:3000/ws");
await client.connect();
const res = await client.command("echo", "hello");
console.log(res); // "echo: hello"
```
---
## Notes
- Defaults to using `/` as the WebSocket upgrade path if `options.path` is not specified.
- If the request does not match the configured path, the socket is rejected with HTTP 400.
- This package does **not** create a server—it binds Mesh to your existing one.
---
## License
MIT

View File

@ -0,0 +1,326 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "mesh-express",
"dependencies": {
"@prsm/mesh": "^1.0.4",
},
"devDependencies": {
"@types/bun": "latest",
"@types/ws": "^8.18.1",
"tsup": "^8.4.0",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"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=="],
"@prsm/mesh": ["@prsm/mesh@1.0.4", "", { "dependencies": { "deasync": "^0.1.30", "fast-json-patch": "^3.1.1", "ioredis": "^5.6.1", "uuid": "^11.1.0", "ws": "^8.18.1" }, "peerDependencies": { "typescript": "^5.8.3" } }, "sha512-3IzdNu06GMgj/A2P0XcaW849p5K4J+vGTNJBY0OwiDLEiOLRa+07Zs6gDiUoOyFEK4vllZhJRSz+iiMaqpyWSw=="],
"@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.10", "", { "dependencies": { "bun-types": "1.2.10" } }, "sha512-eilv6WFM3M0c9ztJt7/g80BDusK98z/FrFwseZgT4bXCq2vPhXD4z8R3oddmAn+R/Nmz9vBn4kweJKmGTZj+lg=="],
"@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/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"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=="],
"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.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-b5ITZMnVdf3m1gMvJHG+gIfeJHiQPJak0f7925Hxu6ZN5VKA8AGy4GZ4lM+Xkn6jtWxg5S3ldWvfmXdvnkp3GQ=="],
"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=="],
"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=="],
"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=="],
"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=="],
"fast-json-patch": ["fast-json-patch@3.1.1", "", {}, "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ=="],
"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=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"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=="],
"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=="],
"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-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=="],
"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=="],
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
}
}

View File

@ -0,0 +1,27 @@
{
"name": "@prsm/mesh-express",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup"
},
"dependencies": {
"@prsm/mesh": "^1.0.4"
},
"devDependencies": {
"@types/bun": "latest",
"@types/ws": "^8.18.1",
"tsup": "^8.4.0"
},
"peerDependencies": {
"typescript": "^5"
}
}

View File

@ -0,0 +1,72 @@
import { STATUS_CODES, type Server as HTTPServer } from "node:http";
import { MeshServer, type MeshServerOptions } from "@prsm/mesh/server";
type Middleware = (req, res, next) => Promise<void>;
interface MeshExpressResult {
middleware: Middleware;
mesh: MeshServer;
}
const createMeshMiddleware = (
server: HTTPServer,
options: MeshServerOptions
): MeshExpressResult => {
const path = options.path || "/";
const mesh = new MeshServer({ ...options, noServer: true });
server.on("upgrade", (request, socket, head) => {
const { pathname } = new URL(
request.url || "",
`http://${request.headers.host}`
);
if (pathname !== path) {
socket.write(
[
`HTTP/1.1 400 ${STATUS_CODES[400]}`,
"Connection: close",
"Content-Type: text/plain",
`Content-Length: ${Buffer.byteLength(STATUS_CODES[400])}`,
"",
STATUS_CODES[400],
].join("\r\n")
);
socket.destroy();
return;
}
mesh.handleUpgrade(request, socket, head, (client, req) => {
mesh.emit("connection", client, req);
});
});
const middleware: Middleware = async (req, res, next) => {
const upgradeHeader =
req.headers.upgrade
?.toLowerCase()
.split(",")
.map((s) => s.trim()) || [];
if (upgradeHeader.includes("websocket")) {
req.ws = () =>
new Promise((resolve) => {
mesh.handleUpgrade(req, req.socket, Buffer.alloc(0), (client) => {
mesh.emit("connection", client, req);
resolve(client);
});
});
}
await next();
};
return { middleware, mesh };
};
export default createMeshMiddleware;
export {
MeshServer,
type MeshServerOptions,
type MeshContext,
} from "@prsm/mesh/server";

View 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": false,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

View File

@ -0,0 +1,11 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
clean: true,
minify: true,
sourcemap: "inline",
target: "esnext",
});

4
packages/mesh/README.md Normal file
View File

@ -0,0 +1,4 @@
> [!NOTE]
> This package has been moved to its own organization and can be found at:
>
> https://github.com/mesh-kit/core

View File

@ -4,31 +4,33 @@ An ECS framework (and robust input system) for the web.
<!-- vim-markdown-toc GFM -->
- [Comprehensive sample](#comprehensive-sample)
- [Installation](#installation)
- [API overview](#api-overview)
- [createWorld](#createworld)
- [Entities](#entities)
- [Components](#components)
- [Extending components](#extending-components)
- [Extras](#extras)
- [Keyboard, mouse and gamepad input](#keyboard-mouse-and-gamepad-input)
- [Input system](#input-system)
- [ButtonState](#buttonstate)
- [Mouse](#mouse)
- [Keyboard](#keyboard)
- [Gamepad](#gamepad)
- [Input usage examples](#input-usage-examples)
- [Gamepad](#gamepad-1)
- [Keyboard](#keyboard-1)
- [Mouse](#mouse-1)
- [Expiring log system](#expiring-log-system)
* [Comprehensive sample](#comprehensive-sample)
* [Installation](#installation)
* [API overview](#api-overview)
* [createWorld](#createworld)
* [Entities](#entities)
* [Components](#components)
* [Extending components](#extending-components)
* [Scene Management](#scene-management)
* [Extras](#extras)
* [Keyboard, mouse and gamepad input](#keyboard-mouse-and-gamepad-input)
* [Input system](#input-system)
* [ButtonState](#buttonstate)
* [Mouse](#mouse)
* [Keyboard](#keyboard)
* [Gamepad](#gamepad)
* [Input usage examples](#input-usage-examples)
* [Gamepad](#gamepad-1)
* [Keyboard](#keyboard-1)
* [Mouse](#mouse-1)
* [Expiring log system](#expiring-log-system)
<!-- vim-markdown-toc -->
# Comprehensive sample
```typescript
import merge from "lodash/merge";
import { createWorld, type WorldState } from "@prsm/ngn";
import {
inputSystem,
@ -40,7 +42,7 @@ import {
// Create a mapping with unique button/key names.
const MyMapping = (): GamepadMapping => {
return Object.assign(SCUFVantage2(), {
return merge(SCUFVantage2(), {
axes: {
2: "LookHorizontal",
3: "LookVertical",
@ -83,6 +85,7 @@ const player =
.addComponent(Alive)
.addTag("player");
// Create a bunch of monsters
Array
.from(Array(50))
.forEach((i) =>
@ -181,20 +184,31 @@ const { state, createEntity, getEntity, onEntityCreated, query, addSystem, remov
- Is passed to all systems (if you use ngn's system mechanics, which is optional).
- Contains a useful `time` object that looks like:
* `state.time.delta` - time since last frame in ms, unaffected by scale.
* `state.time.loopDelta` - time since last call to main game loop, affected by sclae. useful for calculations involving time and scale.
* `state.time.scale` - time scale. (default: `1`, valid: `0.1 - 1`).
- Does not affect framerate at all. The scale determines how often to call the main game loop (if you use choose to use ngn's ticker). On a 60hz display, at a scale of 1, the main game loop is called every 16~ms, and every 33~ms at a scale of 0.5.
* `state.time.delta` - time since last frame in ms, scaled by time.scale. Use this value for all physics and movement calculations to ensure they respect the time scale.
* `state.time.rawDelta` - raw, unscaled time since last frame in ms. This is the actual time between render frames and doesn't change with time scale.
* `state.time.loopDelta` - time since last call to main game loop, affected by scale.
* `state.time.scale` - time scale. (default: `1`).
- Does not affect framerate at all. The scale affects both how often the main game loop is called and the delta time used for physics/movement calculations. At a scale of 1, the main loop is called every frame and delta equals rawDelta. At a scale of 0.5, the main loop is called approximately every other frame and delta is half of rawDelta.
> **Important:** Time scaling separates rendering framerate from simulation speed. The game will always render at the device's refresh rate (e.g., 60fps), but the simulation speed (how fast objects move, animations play, etc.) is controlled by the time scale. Always use `delta` in your movement and physics calculations to ensure they respect the time scale:
> ```typescript
> // This will move at half speed when time.scale is 0.5
> position.x += velocity.x * state.time.delta;
> ```
* `state.time.elapsed` - time since `start` was called in ms.
* `state.time.fps` - frames per second.
This table may help provide clarity to the behavior of `time.scale`.
> **Note:** The "last frame" and "last call to main game loop" are different concepts. The engine always runs at the device's refresh rate (e.g. 60fps), so `rawDelta` and `delta` update every frame. However, the main game loop (where your game logic runs) may be called less frequently based on the time scale. For example, at scale 0.5, the main game loop runs every other frame, resulting in a `loopDelta` that's approximately twice the `delta`.
| scale | fps | delta | loopDelta |
| ----- | --- | ----- | --------- |
| 1 | 120 | 8.33 | 8.33 |
| 0.5 | 120 | 8.33 | 16.66 |
| 0.1 | 120 | 8.33 | 83.33 |
This table may help provide clarity to the behavior of `time.scale`:
| scale | fps | rawDelta | delta | loopDelta | Description |
| ----- | --- | -------- | ----- | --------- | ----------- |
| 1 | 60 | 16.67 | 16.67 | 16.67 | Normal speed - main loop called exactly once per frame |
| 0.5 | 60 | 16.67 | 8.33 | 33.34 | Half speed - main loop called every ~2 frames |
| 2.0 | 60 | 16.67 | 33.34 | 8.33 | Double speed - main loop called ~twice per frame |
The engine always renders at the device's refresh rate (fps), but the frequency of main loop calls and the simulation time (delta) are affected by the time scale.
### Entities
@ -473,6 +487,37 @@ mortals((results) => {
stop();
```
### Scene Management
NGN doesn't enforce any specific scene management pattern, giving you the freedom to implement what works best for your game. The simplest approach is to use separate worlds as scenes:
```typescript
// Create different worlds for different scenes
const menuScene = createWorld();
const gameScene = createWorld();
const pauseScene = createWorld();
// Configure each scene
menuScene.defineMain(() => { /* menu logic */ });
gameScene.defineMain(() => { /* game logic */ });
// Track current scene
let currentScene = menuScene;
// Switch scenes
function switchToScene(newScene) {
currentScene.stop();
currentScene = newScene;
currentScene.start();
}
// Start with menu
menuScene.start();
// Later, switch to game
switchToScene(gameScene);
```
- **`World > step`**
Calls all systems once. Passes the `WorldState` to each system. You should do this in your main program loop, e.g.:

Binary file not shown.

View File

@ -1,6 +1,6 @@
{
"name": "@prsm/ngn",
"version": "2.0.0",
"version": "2.0.1",
"description": "",
"author": "nvms <pyersjonathan@gmail.com>",
"type": "module",
@ -11,7 +11,6 @@
"build:packages:input": "tsup src/packages/input/index.ts --format cjs,esm --dts --minify --clean --out-dir dist/packages/input",
"build:packages:2d": "tsup src/packages/2d/index.ts --format cjs,esm --dts --minify --clean --out-dir dist/packages/2d",
"test": "bun src/tests/index.ts",
"test:watch": "nodemon --watch src --watch tests --exec \"clear && pnpm run test\" --ext ts",
"release": "bumpp package.json --commit 'Release %s' --push --tag && pnpm publish --access public --no-git-checks",
"serve": "esr --serve src/demo.ts"
},

View File

@ -29,8 +29,7 @@ function getCreateId(opts) {
if (!str || num === 256) {
str = "";
num = (1 + len) / 2 | 0;
while (num--)
str += HEX[256 * Math.random() | 0];
while (num--) str += HEX[256 * Math.random() | 0];
str = str.substring(num = 0, len);
}
const date = Date.now().toString(36);
@ -113,8 +112,7 @@ var createWorld = () => {
let xfps = 1;
const xtimes = [];
function handler(now) {
if (!state[$running])
return craf(loopHandler);
if (!state[$running]) return craf(loopHandler);
while (xtimes.length > 0 && xtimes[0] <= now - 1e3) {
xtimes.shift();
}
@ -142,7 +140,9 @@ var createWorld = () => {
};
function step2() {
for (const system of state[$systems]) {
system(state);
if (system(state) === null) {
break;
}
}
}
function addSystem2(...systems) {
@ -192,8 +192,7 @@ var createWorld = () => {
};
const query = ({ and = [], or = [], not = [], tag = [] }) => {
const validQuery = (c) => Object.prototype.hasOwnProperty.call(c, "name");
if (![...and, ...or, ...not].every(validQuery))
throw new Error("Invalid query");
if (![...and, ...or, ...not].every(validQuery)) throw new Error("Invalid query");
const queryName = ["and", ...and.map((c) => c.name), "or", ...or.map((c) => c.name), "not", ...not.map((c) => c.name), "tag", ...tag].join("");
[...and, ...or, ...not].forEach((c) => {
const dependencies = state[$queryDependencies].get(c.name) || /* @__PURE__ */ new Set();
@ -210,8 +209,7 @@ var createWorld = () => {
};
function destroyEntity(e) {
const exists = state[$eMap][e.id];
if (!exists)
return false;
if (!exists) return false;
const componentsToRemove = Object.keys(state[$eciMap][e.id]);
componentsToRemove.forEach((componentName) => {
state[$ceMap][componentName] = state[$ceMap][componentName].filter((id) => id !== e.id);
@ -227,16 +225,14 @@ var createWorld = () => {
return true;
}
function onEntityCreated(fn) {
if (typeof fn !== "function")
return;
if (typeof fn !== "function") return;
state[$onEntityCreated].push(fn);
return () => {
state[$onEntityCreated] = state[$onEntityCreated].filter((f) => f !== fn);
};
}
function createComponent(entity, component, defaults = {}) {
if (state[$eciMap]?.[entity.id]?.[component.name] !== void 0)
return entity;
if (state[$eciMap]?.[entity.id]?.[component.name] !== void 0) return entity;
const affectedQueries = state[$queryDependencies].get(component.name);
if (affectedQueries) {
affectedQueries.forEach(markQueryDirty);
@ -348,8 +344,7 @@ var createWorld = () => {
}
function migrateEntityId(oldId, newId) {
const entity = state[$eMap][oldId];
if (!entity)
return;
if (!entity) return;
entity.id = newId;
state[$eMap][newId] = entity;
delete state[$eMap][oldId];
@ -582,8 +577,7 @@ var createParticleEmitter = (opts) => {
let dead = false;
let paused = false;
const update = (state) => {
if (dead)
return;
if (dead) return;
context.globalCompositeOperation = opts.blendMode ?? "source-over";
const { loopDelta } = state.time;
for (let i = particles.length - 1; i >= 0; i--) {
@ -683,7 +677,6 @@ var createParticleEmitter = (opts) => {
if (opts.burst && particles.length === 0) {
destroy();
}
context.globalCompositeOperation = "source-over";
};
const destroy = () => {
dead = true;
@ -798,7 +791,7 @@ var particleSystem = createParticleSystem({
var emitter = particleSystem.createEmitter({
x: canvas.width / 2,
y: canvas.height / 2,
maxParticles: 100,
maxParticles: 120,
rate: 0.1,
lifetime: 1e3,
lifetimeVariation: 0.2,
@ -829,8 +822,8 @@ var emitter = particleSystem.createEmitter({
particleSystem.createEmitter({
x: particle.x,
y: particle.y,
maxParticles: 3,
lifetimeVariation: 0.2,
maxParticles: 4,
lifetimeVariation: 0.5,
size: 3,
sizeVariation: 2,
colorStart: ["#FF0000", "#ff5100"],
@ -851,8 +844,6 @@ var emitter = particleSystem.createEmitter({
},
onUpdate: (particle, state) => {
particle.size = Math.max(0, particle.size - 0.35);
const v = pulse(state.time.elapsed, 0.25, -1, 1);
particle.x += v * 1;
},
onRemove: (particle, state) => {
}
@ -868,14 +859,20 @@ var fpsDrawSystem = (state) => {
draw.text({ x: 10, y: 20 }, `FPS: ${state.time.fps.toFixed(2)}`, "white");
};
var particleCountSystem = (state) => {
draw.text({ x: 10, y: 40 }, `Particle count: ${particleSystem.numParticles}`, "white");
draw.text({ x: 10, y: 40 }, `Particle count: ${particleSystem.numParticles}. Emitter count: ${emitter.particles.length}`, "white");
};
var particlePositionSystem = (state) => {
const { time } = state;
const xPos = pulse(time.elapsed, 0.25, canvas.width / 2 - 100, canvas.width / 2 + 100);
emitter.x = xPos;
};
addSystem(clearCanvasSystem, fpsDrawSystem, particleCountSystem, particlePositionSystem, particleSystem);
addSystem(
clearCanvasSystem,
fpsDrawSystem,
particleCountSystem,
particlePositionSystem,
particleSystem
);
defineMain(() => {
step();
});

File diff suppressed because one or more lines are too long

View File

@ -4,10 +4,21 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>esr</title>
<style>
#accel {
position: absolute;
top: 200px;
left: 0;
z-index: 100;
color: red;
}
</style>
</head>
<body>
<div id="app"></div>
<div id="accel"></div>
<script src="demo.js" type="module"></script>
{{ livereload }}
</body>
</html>

View File

@ -14,7 +14,7 @@ const particleSystem = createParticleSystem({
const emitter = particleSystem.createEmitter({
x: canvas.width / 2,
y: canvas.height / 2,
maxParticles: 100,
maxParticles: 120,
rate: 0.1,
lifetime: 1000,
lifetimeVariation: 0.2,
@ -49,8 +49,8 @@ const emitter = particleSystem.createEmitter({
particleSystem.createEmitter({
x: particle.x,
y: particle.y,
maxParticles: 3,
lifetimeVariation: 0.2,
maxParticles: 4,
lifetimeVariation: 0.5,
size: 3,
sizeVariation: 2,
colorStart: ["#FF0000", "#ff5100"],
@ -72,8 +72,8 @@ const emitter = particleSystem.createEmitter({
},
onUpdate: (particle: Particle, state: WorldState) => {
particle.size = Math.max(0, particle.size - 0.35);
const v = pulse(state.time.elapsed, 0.25, -1, 1);
particle.x += v * 1;
// const v = pulse(state.time.elapsed, 0.25, -1, 1);
// particle.x += v * 1;
},
onRemove: (particle: Particle, state: WorldState) => {},
});
@ -93,7 +93,7 @@ const fpsDrawSystem = (state: WorldState) => {
};
const particleCountSystem = (state: WorldState) => {
draw.text({ x: 10, y: 40 }, `Particle count: ${particleSystem.numParticles}`, "white");
draw.text({ x: 10, y: 40 }, `Particle count: ${particleSystem.numParticles}. Emitter count: ${emitter.particles.length}`, "white");
};
const particlePositionSystem = (state: WorldState) => {

View File

@ -36,7 +36,7 @@ export type ComponentInstance = () => {
export type QueryConfig = Readonly<
Partial<{
/** Matches entities as long as the entity has all of the components in the provided array. */
/** Matches entities as long as the entity has all the components in the provided array. */
and: Component[];
/** Matches entities as long as the entity has at least one of the components in the provided array. */
or: Component[];
@ -83,13 +83,15 @@ export type WorldState = {
time: {
/** The total elapsed time in seconds since the game loop started. */
elapsed: number;
/** The time in milliseconds since the last frame. */
/** The time in milliseconds since the last frame, scaled by time.scale. */
delta: number;
/** The raw, unscaled time in milliseconds since the last frame. */
rawDelta: number;
/** The time in milliseconds since the last time the main loop was called. */
loopDelta: number;
/** The time in milliseconds of the last call to the main loop. */
lastLoopDelta: number;
/** The time scale of the game loop. */
/** The timescale of the game loop. */
scale: number;
/** The current frames per second. */
fps: number;
@ -111,6 +113,7 @@ export const createWorld = () => {
time: {
elapsed: 0,
delta: 0,
rawDelta: 0,
loopDelta: 0,
lastLoopDelta: 0,
scale: 1,
@ -135,6 +138,7 @@ export const createWorld = () => {
let loopHandler = -1;
const { time } = state;
time.delta = 0;
time.rawDelta = 0;
time.elapsed = 0;
time.fps = 0;
state[$running] = true;
@ -183,23 +187,39 @@ export const createWorld = () => {
xfps = xtimes.length;
time.fps = xfps;
time.delta = now - then;
// Store the raw, unscaled delta time
time.rawDelta = now - then;
then = now;
accumulator += time.delta * time.scale;
// Apply time scale to delta - this represents the simulation time that has passed
time.delta = time.rawDelta * time.scale;
// Use the raw delta for accumulation (behavior remains the same)
accumulator += time.rawDelta * time.scale;
// Calculate the threshold for stepping the world based on the current frame rate
const stepThreshold = 1000 / (time.fps || 60);
// Add a maximum number of iterations to prevent spiral of death
const maxSteps = 5; // Limit the catch-up to prevent freezing
let steps = 0;
// Step the world only when the accumulated scaled time exceeds the threshold
while (accumulator >= stepThreshold) {
while (accumulator >= stepThreshold && steps < maxSteps) {
time.loopDelta = now - time.lastLoopDelta;
time.lastLoopDelta = now;
state[$mainLoop](state);
accumulator -= stepThreshold;
steps++;
}
// If we hit the max steps, discard remaining accumulator time
if (steps >= maxSteps) {
accumulator = 0;
}
// Use the scaled delta for elapsed time calculation
time.elapsed += time.delta * 0.001;
loopHandler = raf(boundLoop);
@ -541,7 +561,7 @@ export const createWorld = () => {
destroy,
});
// If we are focing a specific entity id, we need to migrate any
// If we are forcing a specific entity id, we need to migrate any
// entity that might already occupy this space.
if (spec.id !== undefined && state[$eMap][spec.id]) {
migrateEntityId(spec.id, createId());
@ -575,6 +595,16 @@ export const createWorld = () => {
state[$eciMap][newId] = state[$eciMap][oldId];
delete state[$eciMap][oldId];
// Update component-to-entity mappings, because otherwise queries that
// rely on state[$ceMap] would still reference the old entity ID,
// causing inconsistencies when trying to find entities with specific
// components after ID migration.
Object.keys(state[$ceMap]).forEach((componentName) => {
if (state[$ceMap][componentName].includes(oldId)) {
state[$ceMap][componentName] = state[$ceMap][componentName].map((id) => (id === oldId ? newId : id));
}
});
}
function getEntity(id: string): Entity {

View File

@ -55,32 +55,169 @@ type BlendMode =
| "xor";
export type ParticleEmitterOptions = {
x?: number; // X position
y?: number; // Y position
maxParticles?: number; // Max number of particles
rate?: number; // Particles per second
lifetime?: number; // Lifetime of each particle
lifetimeVariation?: number; // Variation in lifetime
size?: number; // Size of each particle
sizeVariation?: number; // Variation in size
colorStart?: string | string[]; // Start color
colorEnd?: string | string[]; // End color
colorEasing?: ColorEasing; // Easing function for color
/**
* The x coordinate for new particles.
* Default is 0.
* Determines the horizontal start position of particle emission. Can be changed at any time.
*/
x?: number;
/**
* The y coordinate for new particles.
* Default is 0.
* Determines the vertical start position of particle emission. Can be changed at any time.
*/
y?: number;
/**
* Maximum number of particles that can exist at one time.
* Default is 100.
* Helps manage performance by capping particle count.
*/
maxParticles?: number;
/**
* Number of particles emitted per millisecond interval.
* Default is 1.
* Controls the frequency of particle emission in relation to time.
*/
rate?: number;
/**
* Lifetime of each particle in milliseconds.
* Default is 1000 (1 second).
* Determines how long a particle will exist before disappearing.
*/
lifetime?: number;
/**
* Variation in particle lifetime as a fraction of `lifetime`.
* Provide a value between 0 and 1.
* Default is 0.
* Allows particles to have different lifetimes, adding randomness.
*/
lifetimeVariation?: number;
/**
* Base size of each particle.
* Default is 5.
* Represents the default size/scale factor for particles.
*/
size?: number;
/**
* Variation in size as a fraction of `size`.
* Provide a value between 0 and 1.
* Default is 0.
* Introduces variability to particle sizes.
*/
sizeVariation?: number;
/**
* Initial color or array of possible initial colors for particles in hexadecimal format.
* Default is "#000000".
* Specifies the starting color of particles.
*/
colorStart?: string | string[];
/**
* Final color or array of possible end colors for particles in hexadecimal format.
* Default is "#000000".
* Specifies the color particles will transition to over their lifetime.
*/
colorEnd?: string | string[];
/**
* Easing function to interpolate between `colorStart` and `colorEnd`.
* Default is `ColorEasing.LINEAR`.
* Determines how the color changes over the particle's lifetime.
*/
colorEasing?: ColorEasing;
/**
* Easing function for fade out effect.
* Default is `ColorEasing.LINEAR`.
* Controls opacity transition as particles disappear.
*/
fadeOutEasing?: FadeEasing;
speed?: number; // Speed of each particle
speedVariation?: number; // Variation in speed
angle?: number; // Angle of emission
spread?: number; // Spread of emission
gravity?: { x: number; y: number }; // Gravity affecting the particles
blendMode?: BlendMode; // Blend mode
canvas: HTMLCanvasElement; // Canvas to draw on
burst?: boolean; // If true, emit all particles at once and then stop
/** Per-particle initialization callback. */
onInit?: (particle: Particle, state: WorldState) => void; // Callback for particle initialization
/** Per-particle update callback. */
onUpdate?: (particle: Particle, state: WorldState) => void; // Callback for particle update
/** Per-particle removal callback. */
onRemove?: (particle: Particle, state: WorldState) => void; // Callback for particle removal
/**
* Base speed of particle movement in pixels per millisecond.
* Default is 0.1.
* Determines how fast particles move from their origin.
*/
speed?: number;
/**
* Variation in speed as a fraction of `speed`.
* Provide a value between 0 and 1.
* Default is 0.
* Introduces speed variability amongst particles.
*/
speedVariation?: number;
/**
* Emission angle in degrees.
* Default is 0.
* Sets the direction of initial particle movement.
*/
angle?: number;
/**
* Spread angle in degrees around the emission angle for particle dispersion.
* Default is 0.
* Widens the field of initial particle directions.
*/
spread?: number;
/**
* Gravity effect on particles as x and y components.
* Default is {x: 0, y: 0}.
* It simulates gravitational forces affecting particle trajectories.
*/
gravity?: { x: number; y: number };
/**
* Blend mode used for particle rendering.
* Default is the canvas contexts "source-over".
* Determines how particles blend with the background/canvas.
*/
blendMode?: BlendMode;
/**
* HTMLCanvasElement on which particles are drawn.
* Required parameter.
* Represents the rendering surface for the particle system.
*/
canvas: HTMLCanvasElement;
/**
* If true, emit all particles at once and then stop.
* Default is false.
* Changes emitter behavior from continuous to singular burst.
*/
burst?: boolean;
/**
* Callback invoked on each particle initialization.
* Default is undefined.
* Useful for setting initial particle properties dynamically.
*/
onInit?: (particle: Particle, state: WorldState) => void;
/**
* Callback for code execution every frame as each particle updates.
* Default is undefined.
* Allows interaction or modification of particles per update loop.
*/
onUpdate?: (particle: Particle, state: WorldState) => void;
/**
* Callback invoked when a particle is removed.
* Default is undefined.
* Useful for cleanup or concluding actions when particles disappear.
*/
onRemove?: (particle: Particle, state: WorldState) => void;
};
const getDefaultParticleEmitterOptions = (opts: Partial<ParticleEmitterOptions>): ParticleEmitterOptions => ({
@ -306,7 +443,7 @@ export const createParticleEmitter = (opts: ParticleEmitterOptions): ParticleEmi
destroy();
}
context.globalCompositeOperation = "source-over";
// context.globalCompositeOperation = "source-over";
};
const destroy = () => {

View File

@ -39,7 +39,7 @@ export const keyboard = () => ({
* If the key is not found or not pressed, the `pressed`, `justPressed`, and `justReleased` properties will be set to `false`.
*/
getKey(b: string): ButtonState {
const key = Object.keys(keyboardMapping)[Object.values(keyboardMapping).indexOf(b)];
const key = Object.keys(keyboardMapping)[Object.values(keyboardMapping).indexOf(b)] || b;
if (key) return keyboardState.keys[key];
if (keyboardState.keys[b]) return keyboardState.keys[b];
return { pressed: false, justPressed: false, justReleased: false };
@ -54,12 +54,13 @@ export const keyboard = () => ({
*/
export const keyboardUpdate = (): void => {
for (const [key, value] of Object.entries(observedKeyboardState.keys)) {
keyboardState.keys[key] = {
const actualKey = Object.keys(keyboardMapping)[Object.values(keyboardMapping).indexOf(key)] || key;
keyboardState.keys[actualKey] = {
...value,
justReleased: !value.pressed && keysDownLastFrame.keys?.[key]?.pressed,
justReleased: !value.pressed && keysDownLastFrame.keys?.[actualKey]?.pressed,
};
keysDownLastFrame.keys[key] = { ...value, justPressed: false };
observedKeyboardState.keys[key] = { ...value, justPressed: false };
keysDownLastFrame.keys[actualKey] = { ...value, justPressed: false };
observedKeyboardState.keys[actualKey] = { ...value, justPressed: false };
}
};

View File

@ -33,12 +33,13 @@ const buttonsDownLastFrame: ObservedMouseState = { buttons: {} };
*/
export const mouseUpdate = (): void => {
for (const [button, value] of Object.entries(observedMouseState.buttons)) {
mouseState.buttons[button] = {
const actualButton = Object.keys(mouseMapping)[Object.values(mouseMapping).indexOf(button)] || button;
mouseState.buttons[actualButton] = {
...value,
justReleased: !value.pressed && buttonsDownLastFrame.buttons?.[button]?.pressed,
};
buttonsDownLastFrame.buttons[button] = { ...value, justPressed: false };
observedMouseState.buttons[button] = { ...value, justPressed: false };
buttonsDownLastFrame.buttons[actualButton] = { ...value, justPressed: false };
observedMouseState.buttons[actualButton] = { ...value, justPressed: false };
}
};

View File

@ -2,4 +2,5 @@ import { describe } from "manten";
await describe("ngn", async ({ runTestSuite }) => {
runTestSuite(import("./ngn"));
runTestSuite(import("./ngn/scenes.test"));
});

View File

@ -20,4 +20,8 @@ export default testSuite(async ({ describe }) => {
describe("mouse input", async ({ runTestSuite }) => {
runTestSuite(import("./mouse.test.js"));
});
describe("time scaling", async ({ runTestSuite }) => {
runTestSuite(import("./time.test.js"));
});
});

View File

@ -4,6 +4,31 @@ import { KeyboardKey } from "../../packages/input/devices/mappings/keyboard";
export default testSuite(async ({ describe }) => {
describe("keyboard", () => {
test("accepts a custom mapping", () => {
const customMapping = () => ({
[KeyboardKey.KeyA]: "RotateLeft",
[KeyboardKey.KeyD]: "Right",
});
const kb = keyboard();
kb.keyboard.useMapping(customMapping);
expect(kb.keyboard.getKey("RotateLeft")).toEqual({
pressed: false,
justPressed: false,
justReleased: false,
});
onKeyDown({ code: "RotateLeft", repeat: false } as KeyboardEvent);
keyboardUpdate();
expect(kb.keyboard.getKey("RotateLeft")).toEqual({
pressed: true,
justPressed: true,
justReleased: false,
});
});
test("should return an object with a keyboard property containing methods", () => {
const kb = keyboard();
expect(typeof kb.keyboard).toBe("object");

View File

@ -0,0 +1,137 @@
import { expect, test, testSuite } from "manten";
import { createWorld, WorldState } from "../../ngn";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export default testSuite(async () => {
test("can switch between worlds (scenes)", async () => {
// Create two separate worlds (scenes)
const sceneA = createWorld();
const sceneB = createWorld();
// Track execution counts for each scene
let sceneAExecutions = 0;
let sceneBExecutions = 0;
// Set up main loops for each scene
sceneA.defineMain((state: WorldState) => {
sceneAExecutions++;
if (sceneAExecutions >= 3) sceneA.stop();
});
sceneB.defineMain((state: WorldState) => {
sceneBExecutions++;
if (sceneBExecutions >= 3) sceneB.stop();
});
// Start sceneA and let it run for a bit
sceneA.start();
await sleep(500);
// Verify sceneA ran and sceneB didn't
expect(sceneAExecutions).toBe(3);
expect(sceneBExecutions).toBe(0);
// Start sceneB and let it run
sceneB.start();
await sleep(500);
// Verify sceneB ran
expect(sceneBExecutions).toBe(3);
});
test("worlds maintain separate entity collections", () => {
const sceneA = createWorld();
const sceneB = createWorld();
// Create entities in each scene
const entityA = sceneA.createEntity({ name: "EntityA" });
const entityB = sceneB.createEntity({ name: "EntityB" });
// Verify entities exist in their respective scenes
expect(sceneA.getEntity(entityA.id)).toBeDefined();
expect(sceneA.getEntity(entityB.id)).toBeUndefined();
expect(sceneB.getEntity(entityB.id)).toBeDefined();
expect(sceneB.getEntity(entityA.id)).toBeUndefined();
});
test("worlds maintain separate system collections", () => {
const sceneA = createWorld();
const sceneB = createWorld();
// Track system executions
let systemAExecutions = 0;
let systemBExecutions = 0;
// Create systems for each scene
const systemA = () => {
systemAExecutions++;
};
const systemB = () => {
systemBExecutions++;
};
// Add systems to their respective scenes
sceneA.addSystem(systemA);
sceneB.addSystem(systemB);
// Step each scene
sceneA.step();
sceneB.step();
// Verify systems ran in their respective scenes
expect(systemAExecutions).toBe(1);
expect(systemBExecutions).toBe(1);
// Remove system from sceneA
sceneA.removeSystem(systemA);
// Step each scene again
sceneA.step();
sceneB.step();
// Verify systemA didn't run but systemB did
expect(systemAExecutions).toBe(1);
expect(systemBExecutions).toBe(2);
});
test("worlds maintain separate time tracking", async () => {
const sceneA = createWorld();
const sceneB = createWorld();
// Set different time scales
sceneA.state.time.scale = 0.5;
sceneB.state.time.scale = 2.0;
let sceneATime = 0;
let sceneBTime = 0;
// Set up main loops to capture time values
sceneA.defineMain((state: WorldState) => {
sceneATime = state.time.delta;
sceneA.stop();
});
sceneB.defineMain((state: WorldState) => {
sceneBTime = state.time.delta;
sceneB.stop();
});
// Run both scenes
sceneA.start();
await sleep(200);
sceneB.start();
await sleep(200);
// Verify time scales were applied correctly
expect(sceneATime).toBeGreaterThan(0);
expect(sceneBTime).toBeGreaterThan(0);
expect(sceneBTime).toBeGreaterThan(sceneATime);
// Verify the ratio is approximately 4:1 (2.0 vs 0.5)
const ratio = sceneBTime / sceneATime;
expect(ratio).toBeGreaterThan(3); // Allow some flexibility in timing
});
});

View File

@ -0,0 +1,58 @@
import { expect, test, testSuite } from "manten";
import { createWorld, WorldState } from "../../ngn";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export default testSuite(async () => {
test("time.delta should be scaled by time.scale", async () => {
const { state, start, stop, defineMain } = createWorld();
let i = 0;
state.time.scale = 0.5;
defineMain((state: WorldState) => {
if (i > 0) {
// Check that delta is scaled (approximately half of rawDelta)
expect(state.time.delta).toBeCloseTo(state.time.rawDelta * 0.5, 1);
// The raw delta should be around 16.67ms (60fps)
expect(state.time.rawDelta).toBeGreaterThan(15);
expect(state.time.rawDelta).toBeLessThan(20);
// The scaled delta should be around 8.33ms (at scale 0.5)
expect(state.time.delta).toBeGreaterThan(7);
expect(state.time.delta).toBeLessThan(10);
}
if (++i === 3) stop();
});
start();
await sleep(500);
expect(i).toBe(3);
});
test("time.delta should be doubled when time.scale is 2.0", async () => {
const { state, start, stop, defineMain } = createWorld();
let i = 0;
state.time.scale = 2.0;
defineMain((state: WorldState) => {
if (i > 0) {
// Check that delta is scaled (approximately double of rawDelta)
expect(state.time.delta).toBeCloseTo(state.time.rawDelta * 2.0, 1);
// The scaled delta should be around 33.34ms (at scale 2.0)
expect(state.time.delta).toBeGreaterThan(30);
expect(state.time.delta).toBeLessThan(40);
}
if (++i === 3) stop();
});
start();
await sleep(500);
expect(i).toBe(3);
});
});

View File

@ -494,7 +494,9 @@ export default testSuite(async () => {
await sleep(500);
expect(i).toBe(3);
expect(state.time.delta).toBe(16.670000000000016);
// delta is scaled, so it should be half of rawDelta
expect(state.time.delta).toBeCloseTo(state.time.rawDelta * 0.5, 1);
expect(state.time.rawDelta).toBeCloseTo(16.67, 1);
});
test("step calls systems, passing world", async () => {