mirror of
https://github.com/nvms/prsm.git
synced 2025-12-16 16:10:54 +00:00
Compare commits
124 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d31d083ee3 | ||
|
|
49caf89101 | ||
|
|
1b026ecadb | ||
|
|
48b41c9d19 | ||
|
|
209614c3e8 | ||
|
|
fde46ad338 | ||
|
|
960616b3fb | ||
|
|
c448abfecc | ||
|
|
fe163324df | ||
|
|
21dd9ccf7e | ||
|
|
58980e9f09 | ||
|
|
6e153b1b44 | ||
|
|
978fd71d85 | ||
|
|
9fbd947ad1 | ||
|
|
57af00dc40 | ||
|
|
fb4f275d58 | ||
|
|
c6cb0da27c | ||
|
|
bbd48020de | ||
|
|
66803c1177 | ||
|
|
c492ee8a05 | ||
|
|
0579c0d150 | ||
|
|
36eed400b8 | ||
|
|
9ebef6bdb6 | ||
|
|
1c797eb1ba | ||
|
|
c14dba183c | ||
|
|
d6f237152b | ||
|
|
6181227532 | ||
|
|
6bd9803c61 | ||
|
|
af2cf5a4a4 | ||
|
|
51fc280d8b | ||
|
|
b4751aefe8 | ||
|
|
798164bec0 | ||
|
|
9a835e0c76 | ||
|
|
b25f54ae15 | ||
|
|
9b9c7bea04 | ||
|
|
f2b80feab8 | ||
|
|
9c1370dcbf | ||
|
|
e993afc07f | ||
|
|
663c9ab735 | ||
|
|
f9ccd98d39 | ||
|
|
5a59182775 | ||
|
|
10c18f668e | ||
|
|
8d114a1285 | ||
|
|
f26e2ddbac | ||
|
|
7f7d3168af | ||
|
|
f37f040ecf | ||
|
|
f6c397e1e2 | ||
|
|
31a53fb274 | ||
|
|
2965ecb548 | ||
|
|
7c2850db27 | ||
|
|
0133f59e39 | ||
|
|
06571ac28a | ||
|
|
8db63ab664 | ||
|
|
9140ea34d8 | ||
|
|
22140253fe | ||
|
|
bffefe344a | ||
|
|
8a84f6ea04 | ||
|
|
18f60550e2 | ||
|
|
6fe63c8d58 | ||
|
|
b5cd75a018 | ||
|
|
8af50f0c00 | ||
|
|
5bd827515f | ||
|
|
3395ddb7ac | ||
|
|
ada569c83c | ||
|
|
437c264895 | ||
|
|
7170d1bf89 | ||
|
|
5c322d6bbc | ||
|
|
3e6ee88ab7 | ||
|
|
4f858a5b96 | ||
|
|
48d1205505 | ||
|
|
6e063101cc | ||
|
|
061c50da90 | ||
|
|
910e19d690 | ||
|
|
fc2f2bea12 | ||
|
|
7714d71b0a | ||
|
|
2acba51367 | ||
|
|
6be7fbbfe0 | ||
|
|
20fa3707ff | ||
|
|
0fa7229471 | ||
|
|
98df494b76 | ||
|
|
83f618cca7 | ||
|
|
00472d978e | ||
|
|
8bf6823d31 | ||
|
|
648aba03e8 | ||
|
|
507a2fe341 | ||
|
|
3226bbc604 | ||
|
|
19481c1e1c | ||
|
|
4e72f0a8b7 | ||
|
|
e6d198ddc4 | ||
|
|
dcacf3f988 | ||
|
|
c546375d06 | ||
|
|
9ad1eeeeb9 | ||
|
|
91152871d3 | ||
|
|
bba17d3141 | ||
|
|
ae07092705 | ||
|
|
64640c8407 | ||
|
|
82c13fce56 | ||
|
|
b8184b0e52 | ||
|
|
43f46c28f4 | ||
|
|
0bc316f0aa | ||
|
|
2214a58113 | ||
|
|
5546e845ac | ||
|
|
bafe78e6b2 | ||
|
|
3ca5470ef9 | ||
|
|
5b2dae803c | ||
|
|
89397d1314 | ||
|
|
bd34ffdbde | ||
|
|
c4045f9906 | ||
|
|
002e7b0bcc | ||
|
|
74bc7163a2 | ||
|
|
a05a3810a2 | ||
|
|
f5b43651ed | ||
|
|
3f02b12089 | ||
|
|
af47133077 | ||
|
|
0ecaaddef4 | ||
|
|
90e7f8b58a | ||
|
|
48619da543 | ||
|
|
75715ece00 | ||
|
|
8dbc214e6f | ||
|
|
8ef12272be | ||
|
|
f52a92ccda | ||
|
|
4279f28589 | ||
|
|
fcab68acf6 | ||
|
|
e8d489bbfe |
2
packages/duplex/.gitignore
vendored
Normal file
2
packages/duplex/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@ -2,25 +2,43 @@
|
||||
|
||||
[](https://www.npmjs.com/package/@prsm/duplex)
|
||||
|
||||
An optionally-secure, full-duplex TCP command server and client on top of `node:tls` and `node:net`.
|
||||
An optionally-secure, full-duplex TCP command server and client built on top of `node:tls` and `node:net`. Provides reliable, Promise-based communication with automatic reconnection and command queueing.
|
||||
|
||||
## Features
|
||||
|
||||
- **Promise-based API** - All operations return Promises for easy async/await usage
|
||||
- **Command queueing** - Commands are automatically queued when offline
|
||||
- **Reliable connections** - Robust error handling and reconnection
|
||||
- **Secure communication** - Optional TLS encryption
|
||||
- **Bidirectional communication** - Full-duplex TCP communication
|
||||
- **Lightweight** - No external dependencies
|
||||
|
||||
## Server
|
||||
|
||||
```typescript
|
||||
import { CommandServer } from "@prsm/duplex";
|
||||
import fs from "node:fs";
|
||||
|
||||
// An insecure CommandServer (`Server` from `node:net`)
|
||||
// Create a server instance
|
||||
const server = new CommandServer({
|
||||
host: "localhost",
|
||||
port: 3351,
|
||||
secure: false,
|
||||
secure: false, // For TLS, set to true and provide certificates
|
||||
});
|
||||
|
||||
// A secure CommandServer (`Server` from `node:tls`)
|
||||
// https://nodejs.org/api/tls.html#new-tlstlssocketsocket-options
|
||||
const server = new CommandServer({
|
||||
// Connect the server (returns a Promise)
|
||||
await server.connect();
|
||||
|
||||
// Register command handlers
|
||||
server.command(0, async (payload, connection) => {
|
||||
console.log("Received:", payload);
|
||||
return { status: "success", data: "Command processed" };
|
||||
});
|
||||
|
||||
// For secure connections (TLS)
|
||||
const secureServer = new CommandServer({
|
||||
host: "localhost",
|
||||
port: 3351,
|
||||
port: 3352,
|
||||
secure: true,
|
||||
key: fs.readFileSync("certs/server/server.key"),
|
||||
cert: fs.readFileSync("certs/server/server.crt"),
|
||||
@ -28,55 +46,79 @@ const server = new CommandServer({
|
||||
requestCert: true,
|
||||
});
|
||||
|
||||
// -------------------
|
||||
// Defining a command handler
|
||||
server.command(0, async (payload: any, connection: Connection) => {
|
||||
return { ok: "OK" };
|
||||
});
|
||||
await secureServer.connect();
|
||||
```
|
||||
|
||||
## Client
|
||||
|
||||
```typescript
|
||||
import { CommandClient } from "@prsm/duplex";
|
||||
import fs from "node:fs";
|
||||
|
||||
// An insecure client (`Socket` from `node:net`)
|
||||
// Create a client instance
|
||||
const client = new CommandClient({
|
||||
host: "localhost",
|
||||
port: 3351,
|
||||
secure: false,
|
||||
secure: false, // For TLS, set to true and provide certificates
|
||||
});
|
||||
|
||||
// A secure client (`TLSSocket` from `node:tls`)
|
||||
const client = new CommandClient({
|
||||
// Connect to the server (returns a Promise)
|
||||
await client.connect();
|
||||
|
||||
// Using Promise-based API
|
||||
try {
|
||||
const response = await client.command(0, { action: "getData" }, 5000);
|
||||
console.log("Response:", response.result);
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
}
|
||||
|
||||
// Using callback API
|
||||
client.command(0, { action: "getData" }, 5000, (result, error) => {
|
||||
if (error) {
|
||||
console.error("Error:", error);
|
||||
return;
|
||||
}
|
||||
console.log("Response:", result);
|
||||
});
|
||||
|
||||
// For secure connections (TLS)
|
||||
const secureClient = new CommandClient({
|
||||
host: "localhost",
|
||||
port: 3351,
|
||||
port: 3352,
|
||||
secure: true,
|
||||
key: fs.readFileSync("certs/client/client.key"),
|
||||
cert: fs.readFileSync("certs/client/client.crt"),
|
||||
ca: fs.readFileSync("certs/ca/ca.crt"),
|
||||
});
|
||||
|
||||
// -------------------
|
||||
// Awaiting the response
|
||||
try {
|
||||
const response = await client.command(0, { some: "payload" }, 1000);
|
||||
// command^ ^payload ^expiration
|
||||
// response: { ok: "OK" };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
// ...or receiving the response in a callback
|
||||
const callback = (response: any, error: CodeError) => {
|
||||
if (error) {
|
||||
console.error(error.code);
|
||||
return;
|
||||
}
|
||||
|
||||
// response is { ok: "OK" }
|
||||
};
|
||||
|
||||
// Sending a command to the server
|
||||
client.command(0, { some: "payload" }, 1000, callback);
|
||||
await secureClient.connect();
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The library provides detailed error information with error codes:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await client.command(0, payload, 1000);
|
||||
} catch (error) {
|
||||
if (error.code === 'ETIMEOUT') {
|
||||
console.log('Command timed out');
|
||||
} else if (error.code === 'ENOTFOUND') {
|
||||
console.log('Command not found on server');
|
||||
} else {
|
||||
console.error('Other error:', error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Graceful Shutdown
|
||||
|
||||
```typescript
|
||||
// Close client connection
|
||||
await client.close();
|
||||
|
||||
// Close server
|
||||
await server.close();
|
||||
```
|
||||
|
||||
Binary file not shown.
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@prsm/duplex",
|
||||
"version": "1.1.12",
|
||||
"version": "1.2.0",
|
||||
"author": "nvms",
|
||||
"main": "./dist/index.js",
|
||||
"exports": {
|
||||
@ -15,13 +15,15 @@
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"release": "bumpp package.json && npm publish --access public"
|
||||
"release": "bumpp package.json && npm publish --access public",
|
||||
"test": "vitest"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.4.1",
|
||||
"bumpp": "^9.5.1",
|
||||
"tsup": "^8.2.4",
|
||||
"typescript": "^5.5.4"
|
||||
"typescript": "^5.5.4",
|
||||
"vitest": "^2.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,8 @@ import { Status } from "../common/status";
|
||||
import { IdManager } from "../server/ids";
|
||||
import { Queue } from "./queue";
|
||||
|
||||
export type TokenClientOptions = tls.ConnectionOptions & net.NetConnectOpts & {
|
||||
export type TokenClientOptions = tls.ConnectionOptions &
|
||||
net.NetConnectOpts & {
|
||||
secure: boolean;
|
||||
};
|
||||
|
||||
@ -23,39 +24,52 @@ class TokenClient extends EventEmitter {
|
||||
constructor(options: TokenClientOptions) {
|
||||
super();
|
||||
this.options = options;
|
||||
this.connect();
|
||||
this.status = Status.OFFLINE; // Initialize status but don't connect yet
|
||||
}
|
||||
|
||||
connect(callback?: () => void) {
|
||||
connect(callback?: () => void): Promise<void> {
|
||||
if (this.status >= Status.CLOSED) {
|
||||
return false;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.hadError = false;
|
||||
this.status = Status.CONNECTING;
|
||||
|
||||
const onConnect = () => {
|
||||
if (callback) callback();
|
||||
resolve();
|
||||
};
|
||||
|
||||
if (this.options.secure) {
|
||||
this.socket = tls.connect(this.options, callback);
|
||||
this.socket = tls.connect(this.options, onConnect);
|
||||
} else {
|
||||
this.socket = net.connect(this.options, callback);
|
||||
this.socket = net.connect(this.options, onConnect);
|
||||
}
|
||||
|
||||
this.socket.once("error", (err) => {
|
||||
if (this.status === Status.CONNECTING) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
this.connection = null;
|
||||
this.applyListeners();
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
close(callback?: () => void) {
|
||||
if (this.status <= Status.CLOSED) return false;
|
||||
close(callback?: () => void): Promise<void> {
|
||||
if (this.status <= Status.CLOSED) return Promise.resolve();
|
||||
|
||||
this.status = Status.CLOSED;
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
this.socket.end(() => {
|
||||
this.connection = null;
|
||||
if (callback) callback();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
send(buffer: Buffer) {
|
||||
@ -69,7 +83,12 @@ class TokenClient extends EventEmitter {
|
||||
private applyListeners() {
|
||||
this.socket.on("error", (error) => {
|
||||
this.hadError = true;
|
||||
|
||||
// Don't emit ECONNRESET errors during normal disconnection scenarios
|
||||
// @ts-ignore
|
||||
if (error.code !== "ECONNRESET" || this.status !== Status.CLOSED) {
|
||||
this.emit("error", error);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("close", () => {
|
||||
@ -123,22 +142,28 @@ class QueueClient extends TokenClient {
|
||||
|
||||
private applyEvents() {
|
||||
this.on("connect", () => {
|
||||
while (!this.queue.isEmpty) {
|
||||
const item = this.queue.pop();
|
||||
this.sendBuffer(item.value, item.expiresIn);
|
||||
}
|
||||
this.processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
return super.close();
|
||||
private processQueue() {
|
||||
while (!this.queue.isEmpty) {
|
||||
const item = this.queue.pop();
|
||||
if (item) {
|
||||
this.sendBuffer(item.value, item.expiresIn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close(callback?: () => void): Promise<void> {
|
||||
return super.close(callback);
|
||||
}
|
||||
}
|
||||
|
||||
export class CommandClient extends QueueClient {
|
||||
private ids = new IdManager(0xFFFF);
|
||||
private ids = new IdManager(0xffff);
|
||||
private callbacks: {
|
||||
[id: number]: (error: Error | null, result?: any) => void
|
||||
[id: number]: (result: any, error?: Error) => void;
|
||||
} = {};
|
||||
|
||||
constructor(options: TokenClientOptions) {
|
||||
@ -154,9 +179,9 @@ export class CommandClient extends QueueClient {
|
||||
if (this.callbacks[data.id]) {
|
||||
if (data.command === 255) {
|
||||
const error = ErrorSerializer.deserialize(data.payload);
|
||||
this.callbacks[data.id](error, undefined);
|
||||
this.callbacks[data.id](undefined, error);
|
||||
} else {
|
||||
this.callbacks[data.id](null, data.payload);
|
||||
this.callbacks[data.id](data.payload, null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@ -165,13 +190,39 @@ export class CommandClient extends QueueClient {
|
||||
});
|
||||
}
|
||||
|
||||
async command(command: number, payload: any, expiresIn: number = 30_000, callback: (result: any, error: CodeError | Error | null) => void | undefined = undefined) {
|
||||
async command(
|
||||
command: number,
|
||||
payload: any,
|
||||
expiresIn: number = 30_000,
|
||||
callback: (
|
||||
result: any,
|
||||
error: CodeError | Error | null,
|
||||
) => void | undefined = undefined,
|
||||
) {
|
||||
if (command === 255) {
|
||||
throw new CodeError("Command 255 is reserved.", "ERESERVED", "CommandError");
|
||||
throw new CodeError(
|
||||
"Command 255 is reserved.",
|
||||
"ERESERVED",
|
||||
"CommandError",
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure we're connected before sending commands
|
||||
if (this.status < Status.ONLINE) {
|
||||
try {
|
||||
await this.connect();
|
||||
} catch (err) {
|
||||
if (typeof callback === "function") {
|
||||
callback(undefined, err as Error);
|
||||
return;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const id = this.ids.reserve();
|
||||
const buffer = Command.toBuffer({ id, command, payload })
|
||||
const buffer = Command.toBuffer({ id, command, payload });
|
||||
|
||||
this.sendBuffer(buffer, expiresIn);
|
||||
|
||||
@ -189,10 +240,18 @@ export class CommandClient extends QueueClient {
|
||||
const ret = await Promise.race([response, timeout]);
|
||||
|
||||
try {
|
||||
callback(ret, undefined);
|
||||
} catch (callbackError) { /* */ }
|
||||
} catch (error) {
|
||||
callback(undefined, error);
|
||||
if (ret.error) {
|
||||
callback(undefined, ret.error);
|
||||
} else {
|
||||
callback(ret.result, undefined);
|
||||
}
|
||||
// callback(ret, undefined);
|
||||
} catch (callbackError) {
|
||||
/* */
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as { result: any; error: any };
|
||||
callback(undefined, err.error);
|
||||
}
|
||||
} else {
|
||||
return Promise.race([response, timeout]);
|
||||
@ -200,31 +259,38 @@ export class CommandClient extends QueueClient {
|
||||
}
|
||||
|
||||
private createTimeoutPromise(id: number, expiresIn: number) {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise<{ error: any; result: any }>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
this.ids.release(id);
|
||||
delete this.callbacks[id];
|
||||
reject(new CodeError("Command timed out.", "ETIMEOUT", "CommandError"));
|
||||
reject({
|
||||
error: new CodeError(
|
||||
"Command timed out.",
|
||||
"ETIMEOUT",
|
||||
"CommandError",
|
||||
),
|
||||
result: null,
|
||||
});
|
||||
}, expiresIn);
|
||||
});
|
||||
}
|
||||
|
||||
private createResponsePromise(id: number) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.callbacks[id] = (error: Error | null, result?: any) => {
|
||||
return new Promise<{ error: any; result: any }>((resolve, reject) => {
|
||||
this.callbacks[id] = (result: any, error?: Error) => {
|
||||
this.ids.release(id);
|
||||
delete this.callbacks[id];
|
||||
|
||||
if (error) {
|
||||
reject(error);
|
||||
reject({ error, result: null });
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
resolve({ result, error: null });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
return super.close();
|
||||
close(callback?: () => void): Promise<void> {
|
||||
return super.close(callback);
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,9 @@ const client = new CommandClient({
|
||||
const payload = { things: "stuff", numbers: [1, 2, 3] };
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await client.connect();
|
||||
|
||||
const callback = (result: any, error: CodeError) => {
|
||||
if (error) {
|
||||
console.log("ERR [0]", error.code);
|
||||
@ -21,7 +24,9 @@ async function main() {
|
||||
};
|
||||
|
||||
client.command(0, payload, 10, callback);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Connection error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@ -8,6 +8,11 @@ const server = new CommandServer({
|
||||
secure: false,
|
||||
});
|
||||
|
||||
server.connect().catch((err) => {
|
||||
console.error("Failed to start server:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
server.command(0, async (payload: any, connection: Connection) => {
|
||||
console.log("RECV [0]:", payload);
|
||||
return { ok: "OK" };
|
||||
|
||||
@ -7,7 +7,9 @@ import { Connection } from "../common/connection";
|
||||
import { ErrorSerializer } from "../common/errorserializer";
|
||||
import { Status } from "../common/status";
|
||||
|
||||
export type TokenServerOptions = tls.TlsOptions & net.ListenOptions & net.SocketConstructorOpts & {
|
||||
export type TokenServerOptions = tls.TlsOptions &
|
||||
net.ListenOptions &
|
||||
net.SocketConstructorOpts & {
|
||||
secure?: boolean;
|
||||
};
|
||||
|
||||
@ -24,13 +26,14 @@ export class TokenServer extends EventEmitter {
|
||||
super();
|
||||
|
||||
this.options = options;
|
||||
this.status = Status.OFFLINE;
|
||||
|
||||
if (this.options.secure) {
|
||||
this.server = tls.createServer(this.options, function (clientSocket) {
|
||||
clientSocket.on("error", (err) => {
|
||||
this.emit("clientError", err);
|
||||
});
|
||||
})
|
||||
});
|
||||
} else {
|
||||
this.server = net.createServer(this.options, function (clientSocket) {
|
||||
clientSocket.on("error", (err) => {
|
||||
@ -40,32 +43,40 @@ export class TokenServer extends EventEmitter {
|
||||
}
|
||||
|
||||
this.applyListeners();
|
||||
this.connect();
|
||||
// Don't automatically connect in constructor
|
||||
}
|
||||
|
||||
connect(callback?: () => void) {
|
||||
if (this.status >= Status.CONNECTING) return false;
|
||||
connect(callback?: () => void): Promise<void> {
|
||||
if (this.status >= Status.CONNECTING) return Promise.resolve();
|
||||
|
||||
this.hadError = false;
|
||||
this.status = Status.CONNECTING;
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
this.server.listen(this.options, () => {
|
||||
// Wait a small tick to ensure the server socket is fully bound
|
||||
setImmediate(() => {
|
||||
if (callback) callback();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
close(callback?: () => void) {
|
||||
if (!this.server.listening) return false;
|
||||
close(callback?: () => void): Promise<void> {
|
||||
if (!this.server.listening) return Promise.resolve();
|
||||
|
||||
this.status = Status.CLOSED;
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
this.server.close(() => {
|
||||
for (const connection of this.connections) {
|
||||
connection.remoteClose();
|
||||
}
|
||||
if (callback) callback();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
applyListeners() {
|
||||
@ -129,7 +140,7 @@ type CommandFn = (payload: any, connection: Connection) => Promise<any>;
|
||||
|
||||
export class CommandServer extends TokenServer {
|
||||
private commands: {
|
||||
[command: number]: CommandFn
|
||||
[command: number]: CommandFn;
|
||||
} = {};
|
||||
|
||||
constructor(options: TokenServerOptions) {
|
||||
@ -157,10 +168,26 @@ export class CommandServer extends TokenServer {
|
||||
this.commands[command] = fn;
|
||||
}
|
||||
|
||||
private async runCommand(id: number, command: number, payload: any, connection: Connection) {
|
||||
private async runCommand(
|
||||
id: number,
|
||||
command: number,
|
||||
payload: any,
|
||||
connection: Connection,
|
||||
) {
|
||||
try {
|
||||
if (!this.commands[command]) {
|
||||
throw new CodeError(`Command (${command}) not found.`, "ENOTFOUND", "CommandError");
|
||||
connection.send(
|
||||
Command.toBuffer({
|
||||
command: 255,
|
||||
id,
|
||||
payload: new CodeError(
|
||||
`Command (${command}) not found.`,
|
||||
"ENOTFOUND",
|
||||
"CommandError",
|
||||
),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.commands[command](payload, connection);
|
||||
@ -169,7 +196,9 @@ export class CommandServer extends TokenServer {
|
||||
// we respond with a simple "OK".
|
||||
const payloadResult = result === undefined ? "OK" : result;
|
||||
|
||||
connection.send(Command.toBuffer({ command, id, payload: payloadResult }));
|
||||
connection.send(
|
||||
Command.toBuffer({ command, id, payload: payloadResult }),
|
||||
);
|
||||
} catch (error) {
|
||||
const payload = ErrorSerializer.serialize(error);
|
||||
|
||||
|
||||
@ -9,7 +9,9 @@ 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}.`);
|
||||
throw new TypeError(
|
||||
`ID must be between 0 and ${this.maxIndex}. Got ${id}.`,
|
||||
);
|
||||
}
|
||||
this.ids[id] = false;
|
||||
}
|
||||
@ -33,7 +35,9 @@ 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.`);
|
||||
throw new Error(
|
||||
`All IDs are reserved. Make sure to release IDs when they are no longer used.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
260
packages/duplex/tests/advanced.test.ts
Normal file
260
packages/duplex/tests/advanced.test.ts
Normal file
@ -0,0 +1,260 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import { CommandClient, CommandServer, Status } from "../src/index";
|
||||
|
||||
describe("Advanced CommandClient and CommandServer Tests", () => {
|
||||
const serverOptions = { host: "localhost", port: 8125, secure: false };
|
||||
const clientOptions = { host: "localhost", port: 8125, secure: false };
|
||||
let server: CommandServer;
|
||||
let client: CommandClient;
|
||||
|
||||
beforeEach(() => {
|
||||
server = new CommandServer(serverOptions);
|
||||
server.command(100, async (payload) => {
|
||||
return `Echo: ${payload}`;
|
||||
});
|
||||
|
||||
client = new CommandClient(clientOptions);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Close connections in order
|
||||
if (client.status === Status.ONLINE) {
|
||||
await client.close();
|
||||
}
|
||||
|
||||
if (server.status === Status.ONLINE) {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("client reconnects after server restart", async () => {
|
||||
await server.connect();
|
||||
await client.connect();
|
||||
|
||||
// Verify initial connection
|
||||
expect(client.status).toBe(Status.ONLINE);
|
||||
|
||||
// First close the client gracefully
|
||||
await client.close();
|
||||
|
||||
// Then close the server
|
||||
await server.close();
|
||||
|
||||
// Restart server
|
||||
await server.connect();
|
||||
|
||||
// Reconnect client
|
||||
await client.connect();
|
||||
|
||||
// Verify reconnection worked
|
||||
expect(client.status).toBe(Status.ONLINE);
|
||||
|
||||
// Verify functionality after reconnection
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
client.command(100, "After Reconnect", 5000, (result, error) => {
|
||||
try {
|
||||
expect(error).toBeUndefined();
|
||||
expect(result).toBe("Echo: After Reconnect");
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
test("command times out when server doesn't respond", async () => {
|
||||
await server.connect();
|
||||
await client.connect();
|
||||
|
||||
// A command that never responds
|
||||
server.command(101, async () => {
|
||||
return new Promise(() => {});
|
||||
});
|
||||
|
||||
// Expect it to fail after a short timeout
|
||||
await expect(
|
||||
new Promise((resolve, reject) => {
|
||||
client.command(101, "Should timeout", 500, (result, error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
}),
|
||||
).rejects.toHaveProperty("code", "ETIMEOUT");
|
||||
}, 2000);
|
||||
|
||||
test("server errors are properly serialized to client", async () => {
|
||||
await server.connect();
|
||||
await client.connect();
|
||||
|
||||
server.command(102, async () => {
|
||||
const error = new Error("Custom server error") as any;
|
||||
error.code = "ECUSTOM";
|
||||
error.name = "CustomError";
|
||||
throw error;
|
||||
});
|
||||
|
||||
// Expect to receive this error
|
||||
await expect(
|
||||
new Promise((resolve, reject) => {
|
||||
client.command(102, "Will error", 1000, (result, error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
message: "Custom server error",
|
||||
name: "CustomError",
|
||||
code: "ECUSTOM",
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
test("commands are queued when client is offline and sent when reconnected", async () => {
|
||||
// Start with server but no client connection
|
||||
await server.connect();
|
||||
|
||||
// Create client but don't connect yet
|
||||
const queuedClient = new CommandClient(clientOptions);
|
||||
|
||||
// Queue a command while offline
|
||||
const commandPromise = new Promise((resolve, reject) => {
|
||||
queuedClient.command(100, "Queued Message", 5000, (result, error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Now connect the client - the queued command should be sent
|
||||
await queuedClient.connect();
|
||||
|
||||
// Verify the queued command was processed
|
||||
await expect(commandPromise).resolves.toBe("Echo: Queued Message");
|
||||
|
||||
// Clean up
|
||||
await queuedClient.close();
|
||||
}, 3000);
|
||||
|
||||
test("multiple concurrent commands are handled correctly", async () => {
|
||||
await server.connect();
|
||||
await client.connect();
|
||||
|
||||
// Register commands with different delays
|
||||
server.command(103, async (payload) => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
return `Fast: ${payload}`;
|
||||
});
|
||||
|
||||
server.command(104, async (payload) => {
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
return `Slow: ${payload}`;
|
||||
});
|
||||
|
||||
// Send multiple commands concurrently
|
||||
const results = await Promise.all([
|
||||
new Promise((resolve, reject) => {
|
||||
client.command(103, "First", 1000, (result, error) => {
|
||||
if (error) reject(error);
|
||||
else resolve(result);
|
||||
});
|
||||
}),
|
||||
new Promise((resolve, reject) => {
|
||||
client.command(104, "Second", 1000, (result, error) => {
|
||||
if (error) reject(error);
|
||||
else resolve(result);
|
||||
});
|
||||
}),
|
||||
new Promise((resolve, reject) => {
|
||||
client.command(100, "Third", 1000, (result, error) => {
|
||||
if (error) reject(error);
|
||||
else resolve(result);
|
||||
});
|
||||
}),
|
||||
]);
|
||||
|
||||
// Verify all commands completed successfully
|
||||
expect(results).toEqual(["Fast: First", "Slow: Second", "Echo: Third"]);
|
||||
}, 3000);
|
||||
|
||||
test("handles large payloads correctly", async () => {
|
||||
await server.connect();
|
||||
await client.connect();
|
||||
|
||||
const largeData = {
|
||||
array: Array(1000)
|
||||
.fill(0)
|
||||
.map((_, i) => `item-${i}`),
|
||||
nested: {
|
||||
deep: {
|
||||
object: {
|
||||
with: "lots of data",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
client.command(100, largeData, 5000, (result, error) => {
|
||||
if (error) reject(error);
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
|
||||
// Verify the response contains the expected prefix
|
||||
expect(typeof result).toBe("string");
|
||||
expect((result as string).startsWith("Echo: ")).toBe(true);
|
||||
}, 10000);
|
||||
|
||||
test("server handles multiple client connections", async () => {
|
||||
await server.connect();
|
||||
|
||||
// Create multiple clients
|
||||
const clients = Array(5)
|
||||
.fill(0)
|
||||
.map(() => new CommandClient(clientOptions));
|
||||
|
||||
// Connect all clients
|
||||
await Promise.all(clients.map((client) => client.connect()));
|
||||
|
||||
// Send a command from each client
|
||||
const results = await Promise.all(
|
||||
clients.map(
|
||||
(client, i) =>
|
||||
new Promise((resolve, reject) => {
|
||||
client.command(100, `Client ${i}`, 1000, (result, error) => {
|
||||
if (error) reject(error);
|
||||
else resolve(result);
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Verify all commands succeeded
|
||||
results.forEach((result, i) => {
|
||||
expect(result).toBe(`Echo: Client ${i}`);
|
||||
});
|
||||
|
||||
// Clean up
|
||||
await Promise.all(clients.map((client) => client.close()));
|
||||
}, 5000);
|
||||
|
||||
test("command returns promise when no callback provided", async () => {
|
||||
await server.connect();
|
||||
await client.connect();
|
||||
|
||||
// Use the promise-based API
|
||||
const result = await client.command(100, "Promise API");
|
||||
|
||||
// Verify the result
|
||||
expect(result).toHaveProperty("result", "Echo: Promise API");
|
||||
expect(result).toHaveProperty("error", null);
|
||||
}, 2000);
|
||||
});
|
||||
64
packages/duplex/tests/commandclient.test.ts
Normal file
64
packages/duplex/tests/commandclient.test.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import { CommandClient, CommandServer } from "../src/index";
|
||||
|
||||
describe("CommandClient and CommandServer", () => {
|
||||
const serverOptions = { host: "localhost", port: 8124, secure: false };
|
||||
const clientOptions = { host: "localhost", port: 8124, secure: false };
|
||||
let server: CommandServer;
|
||||
let client: CommandClient;
|
||||
|
||||
beforeEach(() => {
|
||||
server = new CommandServer(serverOptions);
|
||||
server.command(100, async (payload) => {
|
||||
return `Echo: ${payload}`;
|
||||
});
|
||||
|
||||
client = new CommandClient(clientOptions);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (client.status === 3) {
|
||||
// ONLINE
|
||||
await new Promise<void>((resolve) => {
|
||||
client.once("close", () => resolve());
|
||||
client.close();
|
||||
});
|
||||
}
|
||||
|
||||
if (server.status === 3) {
|
||||
// ONLINE
|
||||
await new Promise<void>((resolve) => {
|
||||
server.once("close", () => resolve());
|
||||
server.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("client-server connection should be online", async () => {
|
||||
await server.connect();
|
||||
await client.connect();
|
||||
expect(client.status).toBe(3); // ONLINE
|
||||
}, 1000);
|
||||
|
||||
test("simple echo command", async () => {
|
||||
try {
|
||||
await server.connect();
|
||||
|
||||
await client.connect();
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
client.command(100, "Hello", 5000, (result, error) => {
|
||||
try {
|
||||
expect(error).toBeUndefined();
|
||||
expect(result).toBe("Echo: Hello");
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
@ -1,2 +1,6 @@
|
||||
node_modules
|
||||
src
|
||||
data
|
||||
tsup.config.ts
|
||||
bump.config.ts
|
||||
bun.lockb
|
||||
Binary file not shown.
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@prsm/express-keepalive-ws",
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.8",
|
||||
"author": "",
|
||||
"main": "./dist/index.js",
|
||||
"devDependencies": {
|
||||
@ -9,7 +9,6 @@
|
||||
"tsup": "^8.2.4",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"typings": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
@ -26,7 +25,8 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "module",
|
||||
"typings": "./dist/index.d.ts",
|
||||
"dependencies": {
|
||||
"@prsm/keepalive-ws": "^0.3.6"
|
||||
"@prsm/keepalive-ws": "^1.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,8 +2,7 @@ import {
|
||||
KeepAliveServer,
|
||||
type KeepAliveServerOptions,
|
||||
} from "@prsm/keepalive-ws/server";
|
||||
import { type Server } from "node:http";
|
||||
import { STATUS_CODES } from "node:http";
|
||||
import { STATUS_CODES, type Server } from "node:http";
|
||||
|
||||
const createWsMiddleware = (
|
||||
server: Server,
|
||||
@ -62,4 +61,8 @@ const createWsMiddleware = (
|
||||
};
|
||||
|
||||
export default createWsMiddleware;
|
||||
export { type WSContext } from "@prsm/keepalive-ws/server";
|
||||
export {
|
||||
type KeepAliveServer,
|
||||
type WSContext,
|
||||
Connection,
|
||||
} from "@prsm/keepalive-ws/server";
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@prsm/express-session-auth",
|
||||
"version": "1.5.3",
|
||||
"version": "1.6.4",
|
||||
"description": "",
|
||||
"main": "./dist/index.js",
|
||||
"type": "module",
|
||||
@ -31,15 +31,18 @@
|
||||
"@prsm/hash": "^1.0.2",
|
||||
"@prsm/ids": "^1.1.1",
|
||||
"@prsm/ms": "^1.0.1",
|
||||
"@prsm/otp": "^1.0.1",
|
||||
"@types/express": "^4.17.21",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"express": "^4.19.2",
|
||||
"express-session": "^1.18.0"
|
||||
"express-session": "^1.18.0",
|
||||
"qrcode": "^1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/node": "^22.4.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"bumpp": "^9.5.1",
|
||||
"tsup": "^8.2.4",
|
||||
"typescript": "^5.5.4"
|
||||
|
||||
@ -47,6 +47,7 @@ type AuthSession = {
|
||||
rolemask: number;
|
||||
remembered: boolean;
|
||||
lastResync: Date;
|
||||
lastRememberCheck: Date;
|
||||
forceLogout: number;
|
||||
verified: boolean;
|
||||
};
|
||||
@ -164,6 +165,8 @@ const createUserManager = ({ req, res, datasource }: ReqResDatasource) => {
|
||||
*/
|
||||
const getVerified = (): number => getSessionProperty("verified");
|
||||
|
||||
const getUsername = () => getSessionProperty("username");
|
||||
|
||||
/**
|
||||
* Operates on session.auth.
|
||||
* Returns the logged-in user.
|
||||
@ -404,6 +407,7 @@ const createUserManager = ({ req, res, datasource }: ReqResDatasource) => {
|
||||
getEmail,
|
||||
getStatus,
|
||||
getVerified,
|
||||
getUsername,
|
||||
getRoleNames,
|
||||
getStatusName,
|
||||
|
||||
@ -439,6 +443,7 @@ export const createAuth = async ({
|
||||
if (!datasource) {
|
||||
throw new Error("datasource is required");
|
||||
}
|
||||
|
||||
const um = createUserManager({ req, res, datasource });
|
||||
|
||||
const isLoggedIn = () => req.session?.auth?.loggedIn ?? false;
|
||||
@ -453,7 +458,7 @@ export const createAuth = async ({
|
||||
*
|
||||
* @throws {Error} When session regeneration fails.
|
||||
*/
|
||||
const resyncSession = async () => {
|
||||
const resyncSession = async (force = false) => {
|
||||
if (!isLoggedIn()) {
|
||||
return;
|
||||
}
|
||||
@ -462,7 +467,7 @@ export const createAuth = async ({
|
||||
|
||||
const lastResync = new Date(req.session.auth.lastResync);
|
||||
|
||||
if (lastResync && lastResync.getTime() > Date.now() - interval) {
|
||||
if (!force && lastResync && lastResync.getTime() > Date.now() - interval) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -495,6 +500,15 @@ export const createAuth = async ({
|
||||
|
||||
const { token } = getRememberToken();
|
||||
|
||||
if (
|
||||
req.session.auth.lastRememberCheck &&
|
||||
Date.now() - new Date(req.session.auth.lastRememberCheck).getTime() < 5000
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
req.session.auth.lastRememberCheck = new Date();
|
||||
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
@ -740,10 +754,6 @@ export const createAuth = async ({
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!req.session?.regenerate) {
|
||||
console.log(
|
||||
"COULD NOT REGENERATE SESSION WTF. req:session:",
|
||||
req.session,
|
||||
);
|
||||
resolve();
|
||||
}
|
||||
req.session.regenerate(async (err) => {
|
||||
@ -761,6 +771,7 @@ export const createAuth = async ({
|
||||
rolemask: user.rolemask,
|
||||
remembered: remember,
|
||||
lastResync: new Date(),
|
||||
lastRememberCheck: new Date(),
|
||||
forceLogout: user.forceLogout,
|
||||
verified: user.verified,
|
||||
};
|
||||
@ -1031,6 +1042,7 @@ export const createAuth = async ({
|
||||
|
||||
return {
|
||||
processRememberDirective,
|
||||
resyncSession,
|
||||
|
||||
forceLogoutForUser,
|
||||
forceLogoutForUserById,
|
||||
@ -1063,6 +1075,7 @@ export const createAuth = async ({
|
||||
getEmail: um.getEmail,
|
||||
getStatus: um.getStatus,
|
||||
getVerified: um.getVerified,
|
||||
getUsername: um.getUsername,
|
||||
getUser: um.getUser,
|
||||
getRoleNames: um.getRoleNames,
|
||||
getStatusName: um.getStatusName,
|
||||
|
||||
@ -10,14 +10,17 @@ const isMiddlewareUsed = (app: express.Application, name: string) =>
|
||||
layer && layer.handle && layer.handle.name === name,
|
||||
).length;
|
||||
|
||||
const warnedMiddlewares = new Set<string>();
|
||||
|
||||
export const ensureRequiredMiddlewares = (app: express.Application) => {
|
||||
const requiredMiddlewares = ["cookieParser", "session"];
|
||||
|
||||
for (const name of requiredMiddlewares) {
|
||||
if (!isMiddlewareUsed(app, name)) {
|
||||
throw new Error(
|
||||
if (!isMiddlewareUsed(app, name) && !warnedMiddlewares.has(name)) {
|
||||
console.warn(
|
||||
`Required middleware '${name}' not found. Please ensure it is added to your express application.`,
|
||||
);
|
||||
warnedMiddlewares.add(name);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -2,29 +2,41 @@
|
||||
|
||||
[](https://www.npmjs.com/package/@prsm/ids)
|
||||
|
||||
Short, obfuscated, collision-proof, and reversible identifiers.
|
||||
Short, obfuscated, collision-proof, reversible identifiers.
|
||||
|
||||
Because sometimes internal identifiers are sensitive, or you just don't want to let a user know that their ID is 1.
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import ID from "@prsm/ids";
|
||||
import id from "@prsm/ids";
|
||||
|
||||
ID.encode(12389125); // phsV8T
|
||||
ID.decode("phsV8T"); // 12389125
|
||||
id.encode(12389125); // "7rYTs_"
|
||||
id.decode("7rYTs_"); // 12389125
|
||||
```
|
||||
|
||||
You can (and should) set your own alphabet string:
|
||||
## Configuration
|
||||
|
||||
Set custom alphabet:
|
||||
```typescript
|
||||
ID.alphabet = "GZwBHpfWybgQ5d_2mM-jh84K69tqYknx7LN3zvDrcSJVRPXsCFT";
|
||||
ID.alphabet = "TgzMhJXtRSVBnHFksZQc5j-yGx84W3rNDfK6p_Cbqd29YLm7Pwv";
|
||||
ID.alphabet = "kbHn53dZphT2FvGMBxYJKqS7-cPV_Ct6LwjWRDfXmygzrQ48N9s";
|
||||
id.setAlphabet("GZwBHpfWybgQ5d_2mM-jh84K69tqYknx7LN3zvDrcSJVRPXsCFT");
|
||||
```
|
||||
|
||||
If your use case makes sense, you can also generate a random alphabet string with `randomizeAlphabet`.
|
||||
|
||||
When the alphabet changes, though, the encoded IDs will change as well. Decoding will still work, but the decoded value will be different.
|
||||
|
||||
Randomize alphabet:
|
||||
```typescript
|
||||
ID.randomizeAlphabet();
|
||||
id.randomizeAlphabet();
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| Function | Description |
|
||||
|-----------------------|-------------------------------------------|
|
||||
| `encode(num)` | Converts number to obfuscated string |
|
||||
| `decode(str)` | Converts obfuscated string back to number |
|
||||
| `setAlphabet(str)` | Sets custom alphabet for encoding |
|
||||
| `getAlphabet()` | Returns current alphabet |
|
||||
| `randomizeAlphabet()` | Shuffles alphabet characters randomly |
|
||||
|
||||
## Notes
|
||||
|
||||
- Maximum encodable value: 2,147,483,647 (MAX_INT32)
|
||||
- Changing alphabet changes encoded values
|
||||
- Encoded values must be decoded with same alphabet
|
||||
|
||||
Binary file not shown.
@ -13,7 +13,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"test": "bun tests/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"release": "bumpp package.json && npm publish --access public"
|
||||
},
|
||||
"author": "nvms",
|
||||
@ -23,8 +24,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"bumpp": "^9.5.1",
|
||||
"manten": "^0.6.0",
|
||||
"tsup": "^8.2.4",
|
||||
"typescript": "^4.9.5"
|
||||
"typescript": "^4.9.5",
|
||||
"vitest": "^3.0.9"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,74 +1,76 @@
|
||||
import long from "long";
|
||||
|
||||
export default class ID {
|
||||
private static MAX_INT32 = 2_147_483_647;
|
||||
private static MULTIPLIER = 4_294_967_296;
|
||||
const MAX_INT32 = 2_147_483_647;
|
||||
const PRIME = 1_125_812_041;
|
||||
const INVERSE = 348_986_105;
|
||||
const RANDOM = 998_048_641;
|
||||
const DEFAULT_ALPHABET = "23456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ-_";
|
||||
|
||||
static alphabet: string =
|
||||
"23456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ-_";
|
||||
static prime: number = 1_125_812_041;
|
||||
static inverse: number = 348_986_105;
|
||||
static random: number = 998_048_641;
|
||||
let alphabet = DEFAULT_ALPHABET;
|
||||
|
||||
static get base(): number {
|
||||
return ID.alphabet.length;
|
||||
}
|
||||
const getBase = () => alphabet.length;
|
||||
|
||||
private static shorten(id: number): string {
|
||||
const shorten = (id: number): string => {
|
||||
let result = "";
|
||||
const base = getBase();
|
||||
|
||||
while (id > 0) {
|
||||
result = ID.alphabet[id % ID.base] + result;
|
||||
id = Math.floor(id / ID.base);
|
||||
result = alphabet[id % base] + result;
|
||||
id = Math.floor(id / base);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
private static unshorten(str: string): number {
|
||||
const unshorten = (str: string): number => {
|
||||
let result = 0;
|
||||
const base = getBase();
|
||||
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
result = result * ID.base + ID.alphabet.indexOf(str[i]);
|
||||
result = result * base + alphabet.indexOf(str[i]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
static encode = (num: number): string => {
|
||||
if (num > ID.MAX_INT32) {
|
||||
const id = {
|
||||
MAX_INT32,
|
||||
DEFAULT_ALPHABET,
|
||||
|
||||
encode: (num: number): string => {
|
||||
if (num > MAX_INT32) {
|
||||
throw new Error(
|
||||
`Number (${num}) is too large to encode. MAX_INT32 is ${ID.MAX_INT32}`,
|
||||
`Number (${num}) is too large to encode. MAX_INT32 is ${MAX_INT32}`,
|
||||
);
|
||||
}
|
||||
|
||||
const n: long = long.fromInt(num);
|
||||
const n = long.fromInt(num);
|
||||
|
||||
return ID.shorten(
|
||||
n
|
||||
.multiply(ID.prime)
|
||||
.and(long.fromInt(ID.MAX_INT32))
|
||||
.xor(ID.random)
|
||||
.toInt(),
|
||||
return shorten(
|
||||
n.multiply(PRIME).and(long.fromInt(MAX_INT32)).xor(RANDOM).toInt(),
|
||||
);
|
||||
};
|
||||
},
|
||||
|
||||
static decode = (str: string): number => {
|
||||
const n: long = long.fromInt(ID.unshorten(str));
|
||||
decode: (str: string): number => {
|
||||
const n = long.fromInt(unshorten(str));
|
||||
|
||||
return n
|
||||
.xor(ID.random)
|
||||
.multiply(ID.inverse)
|
||||
.and(long.fromInt(ID.MAX_INT32))
|
||||
.toInt();
|
||||
};
|
||||
return n.xor(RANDOM).multiply(INVERSE).and(long.fromInt(MAX_INT32)).toInt();
|
||||
},
|
||||
|
||||
static randomizeAlphabet(): void {
|
||||
const array = ID.alphabet.split('');
|
||||
getAlphabet: (): string => alphabet,
|
||||
|
||||
setAlphabet: (newAlphabet: string): void => {
|
||||
alphabet = newAlphabet;
|
||||
},
|
||||
|
||||
randomizeAlphabet: (): void => {
|
||||
const array = alphabet.split("");
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
ID.alphabet = array.join('');
|
||||
}
|
||||
}
|
||||
alphabet = array.join("");
|
||||
},
|
||||
};
|
||||
|
||||
export default id;
|
||||
|
||||
51
packages/ids/tests/index.test.ts
Normal file
51
packages/ids/tests/index.test.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import id from "../src";
|
||||
|
||||
describe("ids", () => {
|
||||
// Reset alphabet before each test
|
||||
beforeEach(() => {
|
||||
id.setAlphabet(id.DEFAULT_ALPHABET);
|
||||
});
|
||||
|
||||
it("encodes as expected", () => {
|
||||
const encoded = id.encode(12389125);
|
||||
expect(encoded).toBe("7rYTs_");
|
||||
});
|
||||
|
||||
it("decodes as expected", () => {
|
||||
const decoded = id.decode("7rYTs_");
|
||||
expect(decoded).toBe(12389125);
|
||||
});
|
||||
|
||||
it("changing the alphabet is effective", () => {
|
||||
id.setAlphabet("GZwBHpfWybgQ5d_2mM-jh84K69tqYknx7LN3zvDrcSJVRPXsCFT");
|
||||
expect(id.encode(12389125)).toBe("phsV8T");
|
||||
expect(id.decode("phsV8T")).toBe(12389125);
|
||||
});
|
||||
|
||||
it("shuffling the alphabet changes encoding but preserves round-trip integrity", () => {
|
||||
// First randomization
|
||||
id.randomizeAlphabet();
|
||||
const encoded1 = id.encode(12389125);
|
||||
const decoded1 = id.decode(encoded1);
|
||||
expect(decoded1).toBe(12389125);
|
||||
|
||||
// Store the current alphabet
|
||||
const alphabet1 = id.getAlphabet();
|
||||
|
||||
// Second randomization
|
||||
id.randomizeAlphabet();
|
||||
const alphabet2 = id.getAlphabet();
|
||||
|
||||
// Encode with the new alphabet
|
||||
const encoded2 = id.encode(12389125);
|
||||
const decoded2 = id.decode(encoded2);
|
||||
|
||||
// Each alphabet should produce different encodings for the same number
|
||||
expect(alphabet1).not.toBe(alphabet2);
|
||||
expect(encoded1).not.toBe(encoded2);
|
||||
|
||||
// But round-trip encoding/decoding should work with each alphabet
|
||||
expect(decoded2).toBe(12389125);
|
||||
});
|
||||
});
|
||||
@ -1,36 +0,0 @@
|
||||
import { describe, expect } from "manten";
|
||||
import ID from "../src";
|
||||
|
||||
describe("ids", async ({ test }) => {
|
||||
test("encodes as expected", () => {
|
||||
const encoded = ID.encode(12389125);
|
||||
expect(encoded).toBe("7rYTs_");
|
||||
});
|
||||
|
||||
test("decodes as expected", () => {
|
||||
const decoded = ID.decode("7rYTs_");
|
||||
expect(decoded).toBe(12389125);
|
||||
});
|
||||
|
||||
test("changing the alphabet is effective", () => {
|
||||
ID.alphabet = "GZwBHpfWybgQ5d_2mM-jh84K69tqYknx7LN3zvDrcSJVRPXsCFT";
|
||||
expect(ID.encode(12389125)).toBe("phsV8T");
|
||||
expect(ID.decode("phsV8T")).toBe(12389125);
|
||||
});
|
||||
|
||||
test("shuffling the alphabet still allows you to decode things", () => {
|
||||
ID.randomizeAlphabet();
|
||||
const encoded = ID.encode(12389125);
|
||||
const decoded = ID.decode(encoded);
|
||||
expect(decoded).toBe(12389125);
|
||||
|
||||
console.log(ID.alphabet);
|
||||
|
||||
ID.randomizeAlphabet();
|
||||
// const encoded2 = ID.encode(12389125);
|
||||
const decoded2 = ID.decode(encoded);
|
||||
expect(decoded2).toBe(12389125);
|
||||
|
||||
// expect(encoded).not.toBe(encoded2);
|
||||
})
|
||||
});
|
||||
7
packages/ids/vitest.config.ts
Normal file
7
packages/ids/vitest.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
1
packages/jwt/.gitignore
vendored
Normal file
1
packages/jwt/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
node_modules
|
||||
@ -1,43 +1,72 @@
|
||||
# jwt
|
||||
# @prsm/jwt
|
||||
|
||||
A package for encoding, decoding, and verifying JWTs.
|
||||
[](https://www.npmjs.com/package/@prsm/jwt)
|
||||
|
||||
# Installation
|
||||
A lightweight JWT implementation for encoding, decoding, and verifying JSON Web Tokens.
|
||||
|
||||
`npm install @prsm/jwt`
|
||||
## Installation
|
||||
|
||||
## Encoding
|
||||
```bash
|
||||
npm install @prsm/jwt
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Encoding Tokens
|
||||
|
||||
```typescript
|
||||
import { encode } from "@prsm/jwt";
|
||||
|
||||
const payload = {
|
||||
// Create a token with standard claims
|
||||
const token = encode(
|
||||
{
|
||||
sub: "user123",
|
||||
iat: Date.now(),
|
||||
exp: Date.now() + 3600,
|
||||
};
|
||||
exp: Date.now() + 3600000, // 1 hour from now
|
||||
},
|
||||
"your-secret-key"
|
||||
);
|
||||
|
||||
const token = encode(payload, process.env.SIGNING_KEY);
|
||||
// Specify algorithm (default is HS256)
|
||||
const rsaToken = encode(payload, privateKey, "RS256");
|
||||
```
|
||||
|
||||
## Verifying
|
||||
### Verifying Tokens
|
||||
|
||||
```typescript
|
||||
import { verify } from "@prsm/jwt";
|
||||
|
||||
const result = verify(token, process.env.SIGNING_KEY);
|
||||
const result = verify(token, "your-secret-key");
|
||||
|
||||
if (!result.sig) throw new Error("signature verification failed");
|
||||
if (result.exp) throw new Error("token has expired");
|
||||
if (!result.nbf) throw new Error("token is not yet valid")
|
||||
// Check verification results
|
||||
if (!result.sig) {
|
||||
console.error("Invalid signature");
|
||||
}
|
||||
|
||||
// token payload is available at result.decoded.payload
|
||||
if (result.exp) {
|
||||
console.error("Token expired");
|
||||
}
|
||||
|
||||
// Access the decoded payload
|
||||
const { sub, iat } = result.decoded.payload;
|
||||
```
|
||||
|
||||
## Decoding
|
||||
### Decoding Without Verification
|
||||
|
||||
```typescript
|
||||
import { decode } from "@prsm/jwt";
|
||||
|
||||
const result = decode(token);
|
||||
// { header: { alg: "HS256", typ: "JWT" }, payload: { iat: 123456789, exp: 123456789 }, signature: "..."
|
||||
const { header, payload, signature } = decode(token);
|
||||
```
|
||||
|
||||
## Supported Algorithms
|
||||
|
||||
| Algorithm | Description |
|
||||
|-----------|--------------------------------|
|
||||
| HS256 | HMAC with SHA-256 (default) |
|
||||
| HS384 | HMAC with SHA-384 |
|
||||
| HS512 | HMAC with SHA-512 |
|
||||
| RS256 | RSA Signature with SHA-256 |
|
||||
| RS384 | RSA Signature with SHA-384 |
|
||||
| RS512 | RSA Signature with SHA-512 |
|
||||
| ES256 | ECDSA Signature with SHA-256 |
|
||||
|
||||
@ -1,2 +1,6 @@
|
||||
node_modules
|
||||
src
|
||||
tests
|
||||
docker-compose.yml
|
||||
bump.config.ts
|
||||
bun.lockb
|
||||
@ -1,98 +1,192 @@
|
||||
For a TCP-based, node-only solution with a similar API, see [duplex](https://github.com/node-prism/duplex).
|
||||
|
||||
# keepalive-ws
|
||||
|
||||
A command server and client for simplified WebSocket communication, with builtin ping and latency messaging.
|
||||
[](https://www.npmjs.com/package/@prsm/keepalive-ws)
|
||||
|
||||
Built for [grove](https://github.com/node-prism/grove), but works anywhere.
|
||||
A command server and client for simplified WebSocket communication, with built-in ping and latency messaging. Provides reliable, Promise-based communication with automatic reconnection and command queueing.
|
||||
|
||||
### Server
|
||||
For a TCP-based, node-only solution with a similar API, see [duplex](https://github.com/node-prism/duplex).
|
||||
|
||||
For node.
|
||||
## Features
|
||||
|
||||
- **Promise-based API** - All operations return Promises for easy async/await usage
|
||||
- **Command queueing** - Commands are automatically queued when offline
|
||||
- **Reliable connections** - Robust error handling and reconnection
|
||||
- **Bidirectional communication** - Full-duplex WebSocket communication
|
||||
- **Latency monitoring** - Built-in ping/pong and latency measurement
|
||||
- **Room-based messaging** - Group connections into rooms for targeted broadcasts
|
||||
- **Lightweight** - Minimal dependencies
|
||||
|
||||
## Server
|
||||
|
||||
```typescript
|
||||
import { KeepAliveServer, WSContext } from "@prsm/keepalive-ws/server";
|
||||
|
||||
const ws = new KeepAliveServer({
|
||||
// Where to mount this server and listen to messages.
|
||||
path: "/",
|
||||
// How often to send ping messages to connected clients.
|
||||
pingInterval: 30_000,
|
||||
// Calculate round-trip time and send latency updates
|
||||
// to clients every 5s.
|
||||
latencyInterval: 5_000,
|
||||
// Create a server instance
|
||||
const server = new KeepAliveServer({
|
||||
port: 8080,
|
||||
pingInterval: 30000,
|
||||
latencyInterval: 5000,
|
||||
// Multi-instance room support (optional):
|
||||
// roomBackend: "redis",
|
||||
// redisOptions: { host: "localhost", port: 6379 }
|
||||
});
|
||||
|
||||
ws.registerCommand(
|
||||
"authenticate",
|
||||
async (c: WSContext) => {
|
||||
// use c.payload to authenticate c.connection
|
||||
return { ok: true, token: "..." };
|
||||
},
|
||||
);
|
||||
// Register command handlers
|
||||
server.registerCommand("echo", async (context) => {
|
||||
return `Echo: ${context.payload}`;
|
||||
});
|
||||
|
||||
ws.registerCommand(
|
||||
"throws",
|
||||
async (c: WSContext) => {
|
||||
throw new Error("oops");
|
||||
},
|
||||
);
|
||||
// Error handling
|
||||
server.registerCommand("throws", async () => {
|
||||
throw new Error("Something went wrong");
|
||||
});
|
||||
|
||||
// Room-based messaging
|
||||
server.registerCommand("join-room", async (context) => {
|
||||
const { roomName } = context.payload;
|
||||
await server.addToRoom(roomName, context.connection);
|
||||
await server.broadcastRoom(roomName, "user-joined", {
|
||||
id: context.connection.id
|
||||
});
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Broadcasting to all clients
|
||||
server.registerCommand("broadcast", async (context) => {
|
||||
server.broadcast("announcement", context.payload);
|
||||
return { sent: true };
|
||||
});
|
||||
```
|
||||
|
||||
Extended API:
|
||||
|
||||
- Rooms
|
||||
|
||||
It can be useful to collect connections into rooms.
|
||||
|
||||
- `addToRoom(roomName: string, connection: Connection): void`
|
||||
- `removeFromRoom(roomName: string, connection: Connection): void`
|
||||
- `getRoom(roomName: string): Connection[]`
|
||||
- `clearRoom(roomName: string): void`
|
||||
- Command middleware
|
||||
- Broadcasting to:
|
||||
- all
|
||||
- `broadcast(command: string, payload: any, connections?: Connection[]): void`
|
||||
- all connections that share the same IP
|
||||
- `broadcastRemoteAddress(c: Connection, command: string, payload: any): void`
|
||||
- rooms
|
||||
- `broadcastRoom(roomName: string, command: string, payload: any): void`
|
||||
|
||||
### Client
|
||||
|
||||
For the browser.
|
||||
## Client
|
||||
|
||||
```typescript
|
||||
import { KeepAliveClient } from "@prsm/keepalive-ws/client";
|
||||
|
||||
const opts = {
|
||||
// After 30s (+ maxLatency) of no ping, assume we've disconnected and attempt a
|
||||
// reconnection if shouldReconnect is true.
|
||||
// This number should be coordinated with the pingInterval from KeepAliveServer.
|
||||
pingTimeout: 30_000,
|
||||
// Try to reconnect whenever we are disconnected.
|
||||
// Create a client instance
|
||||
const client = new KeepAliveClient("ws://localhost:8080", {
|
||||
pingTimeout: 30000,
|
||||
maxLatency: 2000,
|
||||
shouldReconnect: true,
|
||||
// This number, added to pingTimeout, is the maximum amount of time
|
||||
// that can pass before the connection is considered closed.
|
||||
// In this case, 32s.
|
||||
maxLatency: 2_000,
|
||||
// How often to try and connect during reconnection phase.
|
||||
reconnectInterval: 2_000,
|
||||
// How many times to try and reconnect before giving up.
|
||||
reconnectInterval: 2000,
|
||||
maxReconnectAttempts: Infinity,
|
||||
};
|
||||
|
||||
const ws = new KeepAliveClient("ws://localhost:8080", opts);
|
||||
|
||||
const { ok, token } = await ws.command("authenticate", {
|
||||
username: "user",
|
||||
password: "pass",
|
||||
});
|
||||
|
||||
const result = await ws.command("throws", {});
|
||||
// result is: { error: "oops" }
|
||||
// Connect to the server (returns a Promise)
|
||||
await client.connect();
|
||||
|
||||
ws.on("latency", (e: CustomEvent<{ latency: number }>) => {
|
||||
// e.detail.latency is round-trip time in ms
|
||||
// Using Promise-based API
|
||||
try {
|
||||
const response = await client.command("echo", "Hello world", 5000);
|
||||
console.log("Response:", response);
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
}
|
||||
|
||||
// Join a room
|
||||
await client.command("join-room", { roomName: "lobby" });
|
||||
|
||||
// Listen for events
|
||||
client.on("user-joined", (event) => {
|
||||
console.log("User joined:", event.detail.id);
|
||||
});
|
||||
|
||||
// Monitor latency
|
||||
client.on("latency", (event) => {
|
||||
console.log("Current latency:", event.detail.latency, "ms");
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
await client.close();
|
||||
```
|
||||
|
||||
## Extended Server API
|
||||
|
||||
### Room Management
|
||||
```typescript
|
||||
// Add a connection to a room (async)
|
||||
await server.addToRoom("roomName", connection);
|
||||
|
||||
// Remove a connection from a room (async)
|
||||
await server.removeFromRoom("roomName", connection);
|
||||
|
||||
// Get all connections in a room (async)
|
||||
const roomConnections = await server.getRoom("roomName");
|
||||
|
||||
// Clear all connections from a room (async)
|
||||
await server.clearRoom("roomName");
|
||||
```
|
||||
|
||||
### Broadcasting
|
||||
```typescript
|
||||
// Broadcast to all connections
|
||||
server.broadcast("eventName", payload);
|
||||
|
||||
// Broadcast to specific connections
|
||||
server.broadcast("eventName", payload, connections);
|
||||
|
||||
// Broadcast to all connections except one
|
||||
server.broadcastExclude(connection, "eventName", payload);
|
||||
|
||||
// Broadcast to all connections in a room
|
||||
server.broadcastRoom("roomName", "eventName", payload);
|
||||
|
||||
// Broadcast to all connections in a room except one
|
||||
server.broadcastRoomExclude("roomName", "eventName", payload, connection);
|
||||
|
||||
// Broadcast to all connections with the same IP
|
||||
server.broadcastRemoteAddress(connection, "eventName", payload);
|
||||
```
|
||||
|
||||
### Middleware
|
||||
```typescript
|
||||
// Global middleware for all commands
|
||||
server.globalMiddlewares.push(async (context) => {
|
||||
// Validate authentication, etc.
|
||||
if (!isAuthenticated(context)) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
});
|
||||
|
||||
// Command-specific middleware
|
||||
server.registerCommand(
|
||||
"protected-command",
|
||||
async (context) => {
|
||||
return "Protected data";
|
||||
},
|
||||
[
|
||||
async (context) => {
|
||||
// Command-specific validation
|
||||
if (!hasPermission(context)) {
|
||||
throw new Error("Forbidden");
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
## 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
|
||||
// Close client connection
|
||||
await client.close();
|
||||
|
||||
// Close server
|
||||
server.close();
|
||||
```
|
||||
|
||||
Binary file not shown.
16
packages/keepalive-ws/docker-compose.yml
Normal file
16
packages/keepalive-ws/docker-compose.yml
Normal file
@ -0,0 +1,16 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- "6379:6379"
|
||||
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
||||
|
||||
redis-commander:
|
||||
image: rediscommander/redis-commander:latest
|
||||
environment:
|
||||
- REDIS_HOSTS=local:redis:6379
|
||||
ports:
|
||||
- "8081:8081"
|
||||
depends_on:
|
||||
- redis
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@prsm/keepalive-ws",
|
||||
"version": "0.3.6",
|
||||
"version": "1.0.3",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"main": "./dist/server/index.js",
|
||||
@ -33,17 +33,21 @@
|
||||
"build:server": "tsup src/server/index.ts --format cjs,esm --dts --clean --minify --out-dir dist/server",
|
||||
"build:client": "tsup src/client/index.ts --format cjs,esm --dts --clean --minify --out-dir dist/client",
|
||||
"build": "npm run build:prep && npm run build:server && npm run build:client",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"release": "bumpp package.json && npm publish --access public"
|
||||
},
|
||||
"keywords": [],
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ioredis": "^5.6.1",
|
||||
"ws": "^8.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.3",
|
||||
"bumpp": "^9.1.1",
|
||||
"tsup": "^8.2.4",
|
||||
"typescript": "^5.5.4"
|
||||
"typescript": "^5.5.4",
|
||||
"vitest": "^3.0.9"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { WebSocket } from "ws";
|
||||
import { CodeError } from "../common/codeerror";
|
||||
import { Status } from "../common/status";
|
||||
import { Connection } from "./connection";
|
||||
|
||||
type KeepAliveClientOptions = Partial<{
|
||||
export { Status } from "../common/status";
|
||||
|
||||
export type KeepAliveClientOptions = Partial<{
|
||||
/**
|
||||
* The number of milliseconds to wait before considering the connection closed due to inactivity.
|
||||
* When this happens, the connection will be closed and a reconnect will be attempted if @see KeepAliveClientOptions.shouldReconnect is true.
|
||||
@ -36,58 +42,140 @@ type KeepAliveClientOptions = Partial<{
|
||||
maxReconnectAttempts: number;
|
||||
}>;
|
||||
|
||||
const defaultOptions = (opts: KeepAliveClientOptions = {}) => {
|
||||
opts.pingTimeout = opts.pingTimeout ?? 30_000;
|
||||
opts.maxLatency = opts.maxLatency ?? 2_000;
|
||||
opts.shouldReconnect = opts.shouldReconnect ?? true;
|
||||
opts.reconnectInterval = opts.reconnectInterval ?? 2_000;
|
||||
opts.maxReconnectAttempts = opts.maxReconnectAttempts ?? Infinity;
|
||||
return opts;
|
||||
};
|
||||
|
||||
export class KeepAliveClient extends EventTarget {
|
||||
export class KeepAliveClient extends EventEmitter {
|
||||
connection: Connection;
|
||||
url: string;
|
||||
socket: WebSocket;
|
||||
socket: WebSocket | null = null;
|
||||
pingTimeout: ReturnType<typeof setTimeout>;
|
||||
options: KeepAliveClientOptions;
|
||||
options: Required<KeepAliveClientOptions>;
|
||||
isReconnecting = false;
|
||||
private _status: Status = Status.OFFLINE;
|
||||
|
||||
constructor(url: string, opts: KeepAliveClientOptions = {}) {
|
||||
super();
|
||||
this.url = url;
|
||||
this.socket = new WebSocket(url);
|
||||
this.connection = new Connection(this.socket);
|
||||
this.options = defaultOptions(opts);
|
||||
this.applyListeners();
|
||||
this.connection = new Connection(null);
|
||||
this.options = {
|
||||
pingTimeout: opts.pingTimeout ?? 30_000,
|
||||
maxLatency: opts.maxLatency ?? 2_000,
|
||||
shouldReconnect: opts.shouldReconnect ?? true,
|
||||
reconnectInterval: opts.reconnectInterval ?? 2_000,
|
||||
maxReconnectAttempts: opts.maxReconnectAttempts ?? Infinity,
|
||||
};
|
||||
|
||||
this.setupConnectionEvents();
|
||||
}
|
||||
|
||||
get on() {
|
||||
return this.connection.addEventListener.bind(this.connection);
|
||||
get status(): Status {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
applyListeners() {
|
||||
this.connection.addEventListener("connection", () => {
|
||||
this.heartbeat();
|
||||
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.addEventListener("close", () => {
|
||||
this.connection.on("close", () => {
|
||||
this._status = Status.OFFLINE;
|
||||
this.emit("close");
|
||||
this.reconnect();
|
||||
});
|
||||
|
||||
this.connection.addEventListener("ping", () => {
|
||||
this.heartbeat();
|
||||
this.connection.on("error", (error) => {
|
||||
this.emit("error", error);
|
||||
});
|
||||
|
||||
this.connection.addEventListener(
|
||||
"message",
|
||||
(ev: CustomEventInit<unknown>) => {
|
||||
this.dispatchEvent(new CustomEvent("message", ev));
|
||||
},
|
||||
);
|
||||
this.connection.on("ping", () => {
|
||||
this.heartbeat();
|
||||
this.emit("ping");
|
||||
});
|
||||
|
||||
this.connection.on("latency", (data) => {
|
||||
this.emit("latency", data);
|
||||
});
|
||||
}
|
||||
|
||||
heartbeat() {
|
||||
/**
|
||||
* Connect to the WebSocket server.
|
||||
* @returns A promise that resolves when the connection is established.
|
||||
*/
|
||||
connect(): Promise<void> {
|
||||
if (this._status === Status.ONLINE) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (
|
||||
this._status === Status.CONNECTING ||
|
||||
this._status === Status.RECONNECTING
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onConnect = () => {
|
||||
this.removeListener("connect", onConnect);
|
||||
this.removeListener("error", onError);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onError = (error: Error) => {
|
||||
this.removeListener("connect", onConnect);
|
||||
this.removeListener("error", onError);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
this.once("connect", onConnect);
|
||||
this.once("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
this._status = Status.CONNECTING;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.socket = new WebSocket(this.url);
|
||||
|
||||
this.socket.onopen = () => {
|
||||
this._status = Status.ONLINE;
|
||||
this.connection.socket = this.socket;
|
||||
this.connection.status = Status.ONLINE;
|
||||
this.connection.applyListeners();
|
||||
this.heartbeat();
|
||||
|
||||
this.emit("connect");
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.socket.onerror = (error) => {
|
||||
this._status = Status.OFFLINE;
|
||||
reject(
|
||||
new CodeError(
|
||||
"WebSocket connection error",
|
||||
"ECONNECTION",
|
||||
"ConnectionError"
|
||||
)
|
||||
);
|
||||
};
|
||||
} catch (error) {
|
||||
this._status = Status.OFFLINE;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
heartbeat(): void {
|
||||
clearTimeout(this.pingTimeout);
|
||||
|
||||
this.pingTimeout = setTimeout(() => {
|
||||
@ -100,23 +188,45 @@ export class KeepAliveClient extends EventTarget {
|
||||
/**
|
||||
* Disconnect the client from the server.
|
||||
* The client will not attempt to reconnect.
|
||||
* To reconnect, create a new KeepAliveClient.
|
||||
* @returns A promise that resolves when the connection is closed.
|
||||
*/
|
||||
disconnect() {
|
||||
close(): Promise<void> {
|
||||
this.options.shouldReconnect = false;
|
||||
|
||||
if (this._status === Status.OFFLINE) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const onClose = () => {
|
||||
this.removeListener("close", onClose);
|
||||
this._status = Status.OFFLINE;
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.once("close", onClose);
|
||||
|
||||
clearTimeout(this.pingTimeout);
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.close();
|
||||
}
|
||||
|
||||
clearTimeout(this.pingTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
private async reconnect() {
|
||||
if (this.isReconnecting) {
|
||||
/**
|
||||
* @deprecated Use close() instead
|
||||
*/
|
||||
disconnect(): Promise<void> {
|
||||
return this.close();
|
||||
}
|
||||
|
||||
private reconnect(): void {
|
||||
if (!this.options.shouldReconnect || this.isReconnecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._status = Status.RECONNECTING;
|
||||
this.isReconnecting = true;
|
||||
|
||||
let attempt = 1;
|
||||
@ -124,49 +234,72 @@ export class KeepAliveClient extends EventTarget {
|
||||
if (this.socket) {
|
||||
try {
|
||||
this.socket.close();
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
// Ignore errors during close
|
||||
}
|
||||
}
|
||||
|
||||
const connect = () => {
|
||||
this.socket = new WebSocket(this.url);
|
||||
|
||||
this.socket.onerror = () => {
|
||||
attempt++;
|
||||
|
||||
if (attempt <= this.options.maxReconnectAttempts) {
|
||||
setTimeout(connect, this.options.reconnectInterval);
|
||||
} else {
|
||||
this.isReconnecting = false;
|
||||
|
||||
this.connection.dispatchEvent(new Event("reconnectfailed"));
|
||||
this.connection.dispatchEvent(new Event("reconnectionfailed"));
|
||||
return;
|
||||
}
|
||||
|
||||
this.isReconnecting = false;
|
||||
this._status = Status.OFFLINE;
|
||||
this.emit("reconnectfailed");
|
||||
};
|
||||
|
||||
this.socket.onopen = () => {
|
||||
this.isReconnecting = false;
|
||||
this._status = Status.ONLINE;
|
||||
this.connection.socket = this.socket;
|
||||
|
||||
this.connection.status = Status.ONLINE;
|
||||
this.connection.applyListeners(true);
|
||||
this.heartbeat();
|
||||
|
||||
this.connection.dispatchEvent(new Event("connection"));
|
||||
this.connection.dispatchEvent(new Event("connected"));
|
||||
this.connection.dispatchEvent(new Event("connect"));
|
||||
|
||||
this.connection.dispatchEvent(new Event("reconnection"));
|
||||
this.connection.dispatchEvent(new Event("reconnected"));
|
||||
this.connection.dispatchEvent(new Event("reconnect"));
|
||||
this.emit("connect");
|
||||
this.emit("reconnect");
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
}
|
||||
|
||||
async command(
|
||||
/**
|
||||
* Send a command to the server and wait for a response.
|
||||
* @param command The command name to send
|
||||
* @param payload The payload to send with the command
|
||||
* @param expiresIn Timeout in milliseconds
|
||||
* @param callback Optional callback function
|
||||
* @returns A promise that resolves with the command result
|
||||
*/
|
||||
command(
|
||||
command: string,
|
||||
payload?: any,
|
||||
expiresIn?: number,
|
||||
callback?: Function,
|
||||
) {
|
||||
expiresIn: number = 30000,
|
||||
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)
|
||||
)
|
||||
.catch((error) => {
|
||||
if (callback) {
|
||||
callback(null, error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
});
|
||||
}
|
||||
|
||||
return this.connection.command(command, payload, expiresIn, callback);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,192 +1,137 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { WebSocket } from "ws";
|
||||
import { CodeError } from "../common/codeerror";
|
||||
import { Command, parseCommand, stringifyCommand } from "../common/message";
|
||||
import { Status } from "../common/status";
|
||||
import { IdManager } from "./ids";
|
||||
import { Queue, QueueItem } from "./queue";
|
||||
import { Queue } from "./queue";
|
||||
|
||||
type Command = {
|
||||
id?: number;
|
||||
command: string;
|
||||
payload?: any;
|
||||
};
|
||||
|
||||
type LatencyPayload = {
|
||||
export type LatencyPayload = {
|
||||
/** Round trip time in milliseconds. */
|
||||
latency: number;
|
||||
};
|
||||
|
||||
export declare interface Connection extends EventTarget {
|
||||
addEventListener(type: "message", listener: (ev: CustomEvent) => any, options?: boolean | AddEventListenerOptions): void;
|
||||
|
||||
/** Emits when a connection is made. */
|
||||
addEventListener(type: "connection", listener: () => any, options?: boolean | AddEventListenerOptions): void;
|
||||
/** Emits when a connection is made. */
|
||||
addEventListener(type: "connected", listener: () => any, options?: boolean | AddEventListenerOptions): void;
|
||||
/** Emits when a connection is made. */
|
||||
addEventListener(type: "connect", listener: () => any, options?: boolean | AddEventListenerOptions): void;
|
||||
|
||||
/** Emits when a connection is closed. */
|
||||
addEventListener(type: "close", listener: () => any, options?: boolean | AddEventListenerOptions): void;
|
||||
/** Emits when a connection is closed. */
|
||||
addEventListener(type: "closed", listener: () => any, options?: boolean | AddEventListenerOptions): void;
|
||||
/** Emits when a connection is closed. */
|
||||
addEventListener(type: "disconnect", listener: () => any, options?: boolean | AddEventListenerOptions): void;
|
||||
/** Emits when a connection is closed. */
|
||||
addEventListener(type: "disconnected", listener: () => any, options?: boolean | AddEventListenerOptions): void;
|
||||
|
||||
/** Emits when a reconnect event is successful. */
|
||||
addEventListener(type: "reconnect", listener: () => any, options?: boolean | AddEventListenerOptions): void;
|
||||
|
||||
/** Emits when a reconnect fails after @see KeepAliveClientOptions.maxReconnectAttempts attempts. */
|
||||
addEventListener(type: "reconnectfailed", listener: () => any, options?: boolean | AddEventListenerOptions): void;
|
||||
|
||||
/** Emits when a ping message is received from @see KeepAliveServer from `@prsm/keepalive-ws/server`. */
|
||||
addEventListener(type: "ping", listener: (ev: CustomEventInit<{}>) => any, options?: boolean | AddEventListenerOptions): void;
|
||||
|
||||
/** Emits when a latency event is received from @see KeepAliveServer from `@prsm/keepalive-ws/server`. */
|
||||
addEventListener(type: "latency", listener: (ev: CustomEventInit<LatencyPayload>) => any, options?: boolean | AddEventListenerOptions): void;
|
||||
|
||||
addEventListener(type: string, listener: (ev: CustomEvent) => any, options?: boolean | AddEventListenerOptions): void;
|
||||
}
|
||||
|
||||
export class Connection extends EventTarget {
|
||||
socket: WebSocket;
|
||||
export class Connection extends EventEmitter {
|
||||
socket: WebSocket | null = null;
|
||||
ids = new IdManager();
|
||||
queue = new Queue();
|
||||
callbacks: { [id: number]: (error: Error | null, result?: any) => void } = {};
|
||||
callbacks: { [id: number]: (result: any, error?: Error) => void } = {};
|
||||
status: Status = Status.OFFLINE;
|
||||
|
||||
constructor(socket: WebSocket) {
|
||||
constructor(socket: WebSocket | null) {
|
||||
super();
|
||||
this.socket = socket;
|
||||
if (socket) {
|
||||
this.applyListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an event listener to the target.
|
||||
* @param event The name of the event to listen for.
|
||||
* @param listener The function to call when the event is fired.
|
||||
* @param options An options object that specifies characteristics about the event listener.
|
||||
*/
|
||||
on(event: string, listener: (ev: CustomEvent) => any, options?: boolean | AddEventListenerOptions) {
|
||||
this.addEventListener(event, listener, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the event listener previously registered with addEventListener.
|
||||
* @param event A string that specifies the name of the event for which to remove an event listener.
|
||||
* @param listener The event listener to be removed.
|
||||
* @param options An options object that specifies characteristics about the event listener.
|
||||
*/
|
||||
off(event: string, listener: (ev: CustomEvent) => any, options?: boolean | AddEventListenerOptions) {
|
||||
this.removeEventListener(event, listener, options);
|
||||
get isDead(): boolean {
|
||||
return !this.socket || this.socket.readyState !== WebSocket.OPEN;
|
||||
}
|
||||
|
||||
sendToken(cmd: Command, expiresIn: number) {
|
||||
send(command: Command): boolean {
|
||||
try {
|
||||
this.socket.send(JSON.stringify(cmd));
|
||||
if (!this.isDead) {
|
||||
this.socket.send(stringifyCommand(command));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
this.queue.add(cmd, expiresIn);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
applyListeners(reconnection = false) {
|
||||
sendWithQueue(command: Command, expiresIn: number): boolean {
|
||||
const success = this.send(command);
|
||||
|
||||
if (!success) {
|
||||
this.queue.add(command, expiresIn);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
applyListeners(reconnection = false): void {
|
||||
if (!this.socket) return;
|
||||
|
||||
const drainQueue = () => {
|
||||
while (!this.queue.isEmpty) {
|
||||
const item = this.queue.pop() as QueueItem;
|
||||
this.sendToken(item.value, item.expiresIn);
|
||||
const item = this.queue.pop();
|
||||
if (item) {
|
||||
this.send(item.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (reconnection) drainQueue();
|
||||
|
||||
// @ts-ignore
|
||||
this.socket.onopen = (socket: WebSocket, ev: Event): any => {
|
||||
if (reconnection) {
|
||||
drainQueue();
|
||||
this.dispatchEvent(new Event("connection"));
|
||||
this.dispatchEvent(new Event("connected"));
|
||||
this.dispatchEvent(new Event("connect"));
|
||||
}
|
||||
|
||||
this.socket.onclose = () => {
|
||||
this.status = Status.OFFLINE;
|
||||
this.emit("close");
|
||||
this.emit("disconnect");
|
||||
};
|
||||
|
||||
this.socket.onclose = (event: CloseEvent) => {
|
||||
this.dispatchEvent(new Event("close"));
|
||||
this.dispatchEvent(new Event("closed"));
|
||||
this.dispatchEvent(new Event("disconnected"));
|
||||
this.dispatchEvent(new Event("disconnect"));
|
||||
this.socket.onerror = (error) => {
|
||||
this.emit("error", error);
|
||||
};
|
||||
|
||||
this.socket.onmessage = async (event: MessageEvent) => {
|
||||
this.socket.onmessage = (event: any) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
const data = parseCommand(event.data as string);
|
||||
|
||||
this.dispatchEvent(new CustomEvent("message", { detail: data }));
|
||||
// Emit the raw message event
|
||||
this.emit("message", data);
|
||||
|
||||
// Handle special system commands
|
||||
if (data.command === "latency:request") {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent<LatencyPayload>(
|
||||
"latency:request",
|
||||
{ detail: { latency: data.payload.latency ?? undefined }}
|
||||
)
|
||||
);
|
||||
this.command("latency:response", { latency: data.payload.latency ?? undefined }, null);
|
||||
this.emit("latency:request", data.payload);
|
||||
this.command("latency:response", data.payload, null);
|
||||
} else if (data.command === "latency") {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent<LatencyPayload>(
|
||||
"latency",
|
||||
{ detail: { latency: data.payload ?? undefined }}
|
||||
)
|
||||
);
|
||||
this.emit("latency", data.payload);
|
||||
} else if (data.command === "ping") {
|
||||
this.dispatchEvent(new CustomEvent("ping", {}));
|
||||
this.emit("ping");
|
||||
this.command("pong", {}, null);
|
||||
} else {
|
||||
this.dispatchEvent(new CustomEvent(data.command, { detail: data.payload }));
|
||||
// Emit command-specific event
|
||||
this.emit(data.command, data.payload);
|
||||
}
|
||||
|
||||
if (this.callbacks[data.id]) {
|
||||
this.callbacks[data.id](null, data.payload);
|
||||
// Resolve any pending command promises
|
||||
if (data.id !== undefined && this.callbacks[data.id]) {
|
||||
// Always resolve with the payload, even if it contains an error
|
||||
// This allows the test to check for error properties in the result
|
||||
this.callbacks[data.id](data.payload);
|
||||
}
|
||||
} catch (e) {
|
||||
this.dispatchEvent(new Event("error"));
|
||||
} catch (error) {
|
||||
this.emit("error", error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async command(command: string, payload: any, expiresIn: number = 30_000, callback: Function | null = null) {
|
||||
command(
|
||||
command: string,
|
||||
payload: any,
|
||||
expiresIn: number | null = 30_000,
|
||||
callback?: (result: any, error?: Error) => void
|
||||
): Promise<any> | null {
|
||||
const id = this.ids.reserve();
|
||||
const cmd = { id, command, payload: payload ?? {} };
|
||||
const cmd: Command = { id, command, payload: payload ?? {} };
|
||||
|
||||
this.sendToken(cmd, expiresIn);
|
||||
this.sendWithQueue(cmd, expiresIn || 30000);
|
||||
|
||||
if (expiresIn === null) {
|
||||
this.ids.release(id);
|
||||
delete this.callbacks[id];
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = this.createResponsePromise(id);
|
||||
const timeout = this.createTimeoutPromise(id, expiresIn);
|
||||
|
||||
if (typeof callback === "function") {
|
||||
const ret = await Promise.race([response, timeout]);
|
||||
callback(ret);
|
||||
return ret;
|
||||
} else {
|
||||
return Promise.race([response, timeout]);
|
||||
}
|
||||
}
|
||||
|
||||
createTimeoutPromise(id: number, expiresIn: number) {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
const responsePromise = new Promise<any>((resolve, reject) => {
|
||||
this.callbacks[id] = (result: any, error?: Error) => {
|
||||
this.ids.release(id);
|
||||
delete this.callbacks[id];
|
||||
reject(new Error(`Command ${id} timed out after ${expiresIn}ms.`));
|
||||
}, expiresIn);
|
||||
});
|
||||
}
|
||||
|
||||
createResponsePromise(id: number) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.callbacks[id] = (error: Error | null, result?: any) => {
|
||||
this.ids.release(id);
|
||||
delete this.callbacks[id];
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
@ -194,5 +139,42 @@ export class Connection extends EventTarget {
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<any>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
if (!this.callbacks[id]) return;
|
||||
|
||||
this.ids.release(id);
|
||||
delete this.callbacks[id];
|
||||
reject(
|
||||
new CodeError(
|
||||
`Command timed out after ${expiresIn}ms.`,
|
||||
"ETIMEOUT",
|
||||
"TimeoutError"
|
||||
)
|
||||
);
|
||||
}, expiresIn);
|
||||
});
|
||||
|
||||
if (typeof callback === "function") {
|
||||
Promise.race([responsePromise, timeoutPromise])
|
||||
.then((result) => callback(result))
|
||||
.catch((error) => callback(null, error));
|
||||
|
||||
return responsePromise;
|
||||
}
|
||||
|
||||
return Promise.race([responsePromise, timeoutPromise]);
|
||||
}
|
||||
|
||||
close(): boolean {
|
||||
if (this.isDead) return false;
|
||||
|
||||
try {
|
||||
this.socket.close();
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export { KeepAliveClient } from "./client";
|
||||
export { KeepAliveClient, Status } from "./client";
|
||||
export { Connection } from "./connection";
|
||||
export { CodeError } from "../common/codeerror";
|
||||
|
||||
@ -1,50 +1,48 @@
|
||||
import { Command } from "../common/message";
|
||||
|
||||
export class QueueItem {
|
||||
value: any;
|
||||
expireTime: number;
|
||||
value: Command;
|
||||
private expiration: number;
|
||||
|
||||
constructor(value: any, expiresIn: number) {
|
||||
constructor(value: Command, expiresIn: number) {
|
||||
this.value = value;
|
||||
this.expireTime = Date.now() + expiresIn;
|
||||
this.expiration = Date.now() + expiresIn;
|
||||
}
|
||||
|
||||
get expiresIn() {
|
||||
return this.expireTime - Date.now();
|
||||
get expiresIn(): number {
|
||||
return this.expiration - Date.now();
|
||||
}
|
||||
|
||||
get isExpired() {
|
||||
return Date.now() > this.expireTime;
|
||||
get isExpired(): boolean {
|
||||
return Date.now() > this.expiration;
|
||||
}
|
||||
}
|
||||
|
||||
export class Queue {
|
||||
items: any[] = [];
|
||||
private items: QueueItem[] = [];
|
||||
|
||||
add(item: any, expiresIn: number) {
|
||||
add(item: Command, expiresIn: number): void {
|
||||
this.items.push(new QueueItem(item, expiresIn));
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
let i = this.items.length;
|
||||
|
||||
while (i--) {
|
||||
if (this.items[i].isExpired) {
|
||||
this.items.splice(i, 1);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
get isEmpty(): boolean {
|
||||
// Remove expired items first
|
||||
this.items = this.items.filter((item) => !item.isExpired);
|
||||
return this.items.length === 0;
|
||||
}
|
||||
|
||||
pop(): QueueItem | null {
|
||||
while (this.items.length) {
|
||||
const item = this.items.shift() as QueueItem;
|
||||
if (!item.isExpired) {
|
||||
// Find the first non-expired item
|
||||
while (this.items.length > 0) {
|
||||
const item = this.items.shift();
|
||||
if (item && !item.isExpired) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.items = [];
|
||||
}
|
||||
}
|
||||
|
||||
14
packages/keepalive-ws/src/common/codeerror.ts
Normal file
14
packages/keepalive-ws/src/common/codeerror.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export class CodeError extends Error {
|
||||
code: string;
|
||||
name: string;
|
||||
|
||||
constructor(message: string, code?: string, name?: string) {
|
||||
super(message);
|
||||
if (typeof code === "string") {
|
||||
this.code = code;
|
||||
}
|
||||
if (typeof name === "string") {
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
packages/keepalive-ws/src/common/message.ts
Normal file
17
packages/keepalive-ws/src/common/message.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export interface Command {
|
||||
id?: number;
|
||||
command: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export function parseCommand(data: string): Command {
|
||||
try {
|
||||
return JSON.parse(data) as Command;
|
||||
} catch (e) {
|
||||
return { command: "", payload: {} };
|
||||
}
|
||||
}
|
||||
|
||||
export function stringifyCommand(command: Command): string {
|
||||
return JSON.stringify(command);
|
||||
}
|
||||
6
packages/keepalive-ws/src/common/status.ts
Normal file
6
packages/keepalive-ws/src/common/status.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export enum Status {
|
||||
ONLINE = 3,
|
||||
CONNECTING = 2,
|
||||
RECONNECTING = 1,
|
||||
OFFLINE = 0,
|
||||
}
|
||||
@ -1,2 +1,3 @@
|
||||
export { KeepAliveClient } from "./client";
|
||||
export { KeepAliveServer } from "./server";
|
||||
export { KeepAliveClient, Status } from "./client";
|
||||
export { KeepAliveServer, WSContext } from "./server";
|
||||
export { CodeError } from "./common/codeerror";
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
export interface Command {
|
||||
id?: number;
|
||||
command: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export const bufferToCommand = (buffer: Buffer): Command => {
|
||||
const decoded = new TextDecoder("utf-8").decode(buffer);
|
||||
if (!decoded) {
|
||||
return { id: 0, command: "", payload: {} };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(decoded) as Command;
|
||||
return { id: parsed.id, command: parsed.command, payload: parsed.payload };
|
||||
} catch (e) {
|
||||
return { id: 0, command: "", payload: {} };
|
||||
}
|
||||
};
|
||||
@ -1,10 +1,11 @@
|
||||
import EventEmitter from "node:events";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { IncomingMessage } from "node:http";
|
||||
import { WebSocket } from "ws";
|
||||
import { KeepAliveServerOptions } from ".";
|
||||
import { bufferToCommand, Command } from "./command";
|
||||
import { Command, parseCommand, stringifyCommand } from "../common/message";
|
||||
import { Status } from "../common/status";
|
||||
import { Latency } from "./latency";
|
||||
import { Ping } from "./ping";
|
||||
import { KeepAliveServerOptions } from "./";
|
||||
|
||||
export class Connection extends EventEmitter {
|
||||
id: string;
|
||||
@ -14,11 +15,12 @@ export class Connection extends EventEmitter {
|
||||
ping: Ping;
|
||||
remoteAddress: string;
|
||||
connectionOptions: KeepAliveServerOptions;
|
||||
status: Status = Status.ONLINE;
|
||||
|
||||
constructor(
|
||||
socket: WebSocket,
|
||||
req: IncomingMessage,
|
||||
options: KeepAliveServerOptions,
|
||||
options: KeepAliveServerOptions
|
||||
) {
|
||||
super();
|
||||
this.socket = socket;
|
||||
@ -30,7 +32,11 @@ export class Connection extends EventEmitter {
|
||||
this.startIntervals();
|
||||
}
|
||||
|
||||
startIntervals() {
|
||||
get isDead(): boolean {
|
||||
return !this.socket || this.socket.readyState !== WebSocket.OPEN;
|
||||
}
|
||||
|
||||
startIntervals(): void {
|
||||
this.latency = new Latency();
|
||||
this.ping = new Ping();
|
||||
|
||||
@ -50,6 +56,7 @@ export class Connection extends EventEmitter {
|
||||
this.ping.interval = setInterval(() => {
|
||||
if (!this.alive) {
|
||||
this.emit("close");
|
||||
return;
|
||||
}
|
||||
|
||||
this.alive = false;
|
||||
@ -57,18 +64,24 @@ export class Connection extends EventEmitter {
|
||||
}, this.connectionOptions.pingInterval);
|
||||
}
|
||||
|
||||
stopIntervals() {
|
||||
stopIntervals(): void {
|
||||
clearInterval(this.latency.interval);
|
||||
clearInterval(this.ping.interval);
|
||||
}
|
||||
|
||||
applyListeners() {
|
||||
applyListeners(): void {
|
||||
this.socket.on("close", () => {
|
||||
this.status = Status.OFFLINE;
|
||||
this.emit("close");
|
||||
});
|
||||
|
||||
this.socket.on("message", (buffer: Buffer) => {
|
||||
const command = bufferToCommand(buffer);
|
||||
this.socket.on("error", (error) => {
|
||||
this.emit("error", error);
|
||||
});
|
||||
|
||||
this.socket.on("message", (data: Buffer) => {
|
||||
try {
|
||||
const command = parseCommand(data.toString());
|
||||
|
||||
if (command.command === "latency:response") {
|
||||
this.latency.onResponse();
|
||||
@ -78,11 +91,34 @@ export class Connection extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit("message", buffer);
|
||||
this.emit("message", data);
|
||||
} catch (error) {
|
||||
this.emit("error", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
send(cmd: Command) {
|
||||
this.socket.send(JSON.stringify(cmd));
|
||||
send(cmd: Command): boolean {
|
||||
if (this.isDead) return false;
|
||||
|
||||
try {
|
||||
this.socket.send(stringifyCommand(cmd));
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.emit("error", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
close(): boolean {
|
||||
if (this.isDead) return false;
|
||||
|
||||
try {
|
||||
this.socket.close();
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.emit("error", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,59 +1,32 @@
|
||||
import { IncomingMessage } from "node:http";
|
||||
import { ServerOptions, WebSocket, WebSocketServer } from "ws";
|
||||
import { bufferToCommand } from "./command";
|
||||
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";
|
||||
import { Connection } from "./connection";
|
||||
|
||||
export declare interface KeepAliveServer extends WebSocketServer {
|
||||
on(event: "connection", handler: (socket: WebSocket, req: IncomingMessage) => void): this;
|
||||
on(event: "connected", handler: (c: Connection) => void): this;
|
||||
on(event: "close", handler: (c: Connection) => void): this;
|
||||
on(event: "error", cb: (this: WebSocketServer, error: Error) => void): this;
|
||||
on(event: "headers", cb: (this: WebSocketServer, headers: string[], request: IncomingMessage) => void): this;
|
||||
on(event: string | symbol, listener: (this: WebSocketServer, ...args: any[]) => void): this;
|
||||
export { Status } from "../common/status";
|
||||
export { Connection } from "./connection";
|
||||
|
||||
emit(event: "connection", socket: WebSocket, req: IncomingMessage): boolean;
|
||||
emit(event: "connected", connection: Connection): boolean;
|
||||
emit(event: "close", connection: Connection): boolean;
|
||||
emit(event: "error", connection: Connection): boolean;
|
||||
|
||||
once(event: "connection", cb: (this: WebSocketServer, socket: WebSocket, request: IncomingMessage) => void): this;
|
||||
once(event: "error", cb: (this: WebSocketServer, error: Error) => void): this;
|
||||
once(event: "headers", cb: (this: WebSocketServer, headers: string[], request: IncomingMessage) => void): this;
|
||||
once(event: "close" | "listening", cb: (this: WebSocketServer) => void): this;
|
||||
once(event: string | symbol, listener: (this: WebSocketServer, ...args: any[]) => void): this;
|
||||
|
||||
off(event: "connection", cb: (this: WebSocketServer, socket: WebSocket, request: IncomingMessage) => void): this;
|
||||
off(event: "error", cb: (this: WebSocketServer, error: Error) => void): this;
|
||||
off(event: "headers", cb: (this: WebSocketServer, headers: string[], request: IncomingMessage) => void): this;
|
||||
off(event: "close" | "listening", cb: (this: WebSocketServer) => void): this;
|
||||
off(event: string | symbol, listener: (this: WebSocketServer, ...args: any[]) => void): this;
|
||||
|
||||
addListener(event: "connection", cb: (client: WebSocket, request: IncomingMessage) => void): this;
|
||||
addListener(event: "error", cb: (err: Error) => void): this;
|
||||
addListener(event: "headers", cb: (headers: string[], request: IncomingMessage) => void): this;
|
||||
addListener(event: "close" | "listening", cb: () => void): this;
|
||||
addListener(event: string | symbol, listener: (...args: any[]) => void): this;
|
||||
|
||||
removeListener(event: "connection", cb: (client: WebSocket) => void): this;
|
||||
removeListener(event: "error", cb: (err: Error) => void): this;
|
||||
removeListener(event: "headers", cb: (headers: string[], request: IncomingMessage) => void): this;
|
||||
removeListener(event: "close" | "listening", cb: () => void): this;
|
||||
removeListener(event: string | symbol, listener: (...args: any[]) => void): this;
|
||||
}
|
||||
export class WSContext {
|
||||
wss: KeepAliveServer;
|
||||
export class WSContext<T = any> {
|
||||
server: KeepAliveServer;
|
||||
connection: Connection;
|
||||
payload: any;
|
||||
payload: T;
|
||||
|
||||
constructor(wss: KeepAliveServer, connection: Connection, payload: any) {
|
||||
this.wss = wss;
|
||||
constructor(server: KeepAliveServer, connection: Connection, payload: T) {
|
||||
this.server = server;
|
||||
this.connection = connection;
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export type SocketMiddleware = (c: WSContext) => any | Promise<any>;
|
||||
export type SocketMiddleware = (context: WSContext<any>) => any | Promise<any>;
|
||||
|
||||
export type KeepAliveServerOptions = ServerOptions & {
|
||||
/**
|
||||
@ -67,42 +40,101 @@ 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 {
|
||||
connections: { [id: string]: Connection } = {};
|
||||
remoteAddressToConnections: { [address: string]: Connection[] } = {};
|
||||
commands: { [command: string]: (context: WSContext) => Promise<void> } = {};
|
||||
commands: {
|
||||
[command: string]: (context: WSContext<any>) => Promise<any> | any;
|
||||
} = {};
|
||||
globalMiddlewares: SocketMiddleware[] = [];
|
||||
middlewares: { [key: string]: SocketMiddleware[] } = {};
|
||||
rooms: { [roomName: string]: Set<string> } = {};
|
||||
declare serverOptions: KeepAliveServerOptions;
|
||||
roomManager: RoomManager;
|
||||
serverOptions: ServerOptions & {
|
||||
pingInterval: number;
|
||||
latencyInterval: number;
|
||||
};
|
||||
status: Status = Status.OFFLINE;
|
||||
private _listening: boolean = false;
|
||||
|
||||
/**
|
||||
* Whether the server is currently listening for connections
|
||||
*/
|
||||
get listening(): boolean {
|
||||
return this._listening;
|
||||
}
|
||||
|
||||
constructor(opts: KeepAliveServerOptions) {
|
||||
super({ ...opts });
|
||||
super(opts);
|
||||
this.serverOptions = {
|
||||
...opts,
|
||||
pingInterval: opts.pingInterval ?? 30_000,
|
||||
latencyInterval: opts.latencyInterval ?? 5_000,
|
||||
};
|
||||
this.applyListeners();
|
||||
}
|
||||
|
||||
private cleanupConnection(c: Connection) {
|
||||
c.stopIntervals();
|
||||
delete this.connections[c.id];
|
||||
if (this.remoteAddressToConnections[c.remoteAddress]) {
|
||||
this.remoteAddressToConnections[c.remoteAddress] = this.remoteAddressToConnections[c.remoteAddress].filter(
|
||||
(cn) => cn.id !== c.id
|
||||
// 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]
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.remoteAddressToConnections[c.remoteAddress].length) {
|
||||
delete this.remoteAddressToConnections[c.remoteAddress];
|
||||
this.on("listening", () => {
|
||||
this._listening = true;
|
||||
this.status = Status.ONLINE;
|
||||
});
|
||||
|
||||
this.on("close", () => {
|
||||
this._listening = false;
|
||||
this.status = Status.OFFLINE;
|
||||
});
|
||||
|
||||
this.applyListeners();
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
if (
|
||||
this.remoteAddressToConnections[connection.remoteAddress].length === 0
|
||||
) {
|
||||
delete this.remoteAddressToConnections[connection.remoteAddress];
|
||||
}
|
||||
}
|
||||
|
||||
private applyListeners() {
|
||||
// Remove from all rooms
|
||||
await this.roomManager.removeFromAllRooms(connection);
|
||||
}
|
||||
|
||||
private applyListeners(): void {
|
||||
this.on("connection", (socket: WebSocket, req: IncomingMessage) => {
|
||||
const connection = new Connection(socket, req, this.serverOptions);
|
||||
this.connections[connection.id] = connection;
|
||||
@ -111,49 +143,53 @@ export class KeepAliveServer extends WebSocketServer {
|
||||
this.remoteAddressToConnections[connection.remoteAddress] = [];
|
||||
}
|
||||
|
||||
this.remoteAddressToConnections[connection.remoteAddress].push(connection);
|
||||
|
||||
this.remoteAddressToConnections[connection.remoteAddress].push(
|
||||
connection
|
||||
);
|
||||
|
||||
this.emit("connected", connection);
|
||||
|
||||
connection.once("close", () => {
|
||||
this.cleanupConnection(connection);
|
||||
connection.on("close", async () => {
|
||||
await this.cleanupConnection(connection);
|
||||
this.emit("close", connection);
|
||||
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.close();
|
||||
}
|
||||
|
||||
Object.keys(this.rooms).forEach((roomName) => {
|
||||
this.rooms[roomName].delete(connection.id);
|
||||
});
|
||||
|
||||
connection.on("error", (error) => {
|
||||
this.emit("clientError", error);
|
||||
});
|
||||
|
||||
connection.on("message", (buffer: Buffer) => {
|
||||
try {
|
||||
const { id, command, payload } = bufferToCommand(buffer);
|
||||
this.runCommand(id ?? 0, command, payload, connection);
|
||||
} catch (e) {
|
||||
this.emit("error", e);
|
||||
const data = buffer.toString();
|
||||
const command = parseCommand(data);
|
||||
|
||||
if (command.id !== undefined) {
|
||||
this.runCommand(
|
||||
command.id,
|
||||
command.command,
|
||||
command.payload,
|
||||
connection
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.emit("error", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
broadcast(command: string, payload: any, connections?: Connection[]) {
|
||||
const cmd = JSON.stringify({ command, payload });
|
||||
broadcast(command: string, payload: any, connections?: Connection[]): void {
|
||||
const cmd: Command = { command, payload };
|
||||
|
||||
if (connections) {
|
||||
connections.forEach((c) => {
|
||||
c.socket.send(cmd);
|
||||
connections.forEach((connection) => {
|
||||
connection.send(cmd);
|
||||
});
|
||||
} else {
|
||||
Object.values(this.connections).forEach((connection) => {
|
||||
connection.send(cmd);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Object.values(this.connections).forEach((c) => {
|
||||
c.socket.send(cmd);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -164,14 +200,21 @@ export class KeepAliveServer extends WebSocketServer {
|
||||
* - Push notifications.
|
||||
* - Auth changes, e.g., logging out in one tab should log you out in all tabs.
|
||||
*/
|
||||
broadcastRemoteAddress(c: Connection, command: string, payload: any) {
|
||||
const cmd = JSON.stringify({ command, payload });
|
||||
this.remoteAddressToConnections[c.remoteAddress].forEach((cn) => {
|
||||
cn.socket.send(cmd);
|
||||
broadcastRemoteAddress(
|
||||
connection: Connection,
|
||||
command: string,
|
||||
payload: any
|
||||
): void {
|
||||
const cmd: Command = { command, payload };
|
||||
const connections =
|
||||
this.remoteAddressToConnections[connection.remoteAddress] || [];
|
||||
|
||||
connections.forEach((conn) => {
|
||||
conn.send(cmd);
|
||||
});
|
||||
}
|
||||
|
||||
broadcastRemoteAddressById(id: string, command: string, payload: any) {
|
||||
broadcastRemoteAddressById(id: string, command: string, payload: any): void {
|
||||
const connection = this.connections[id];
|
||||
if (connection) {
|
||||
this.broadcastRemoteAddress(connection, command, payload);
|
||||
@ -182,113 +225,180 @@ 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) {
|
||||
const cmd = JSON.stringify({ command, payload });
|
||||
const room = this.rooms[roomName];
|
||||
|
||||
if (!room) return;
|
||||
|
||||
room.forEach((connectionId) => {
|
||||
const connection = this.connections[connectionId];
|
||||
if (connection) {
|
||||
connection.socket.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).
|
||||
*/
|
||||
async broadcastRoomExclude(
|
||||
roomName: string,
|
||||
command: string,
|
||||
payload: any,
|
||||
connection: Connection | Connection[]
|
||||
): Promise<void> {
|
||||
await this.roomManager.broadcastRoomExclude(
|
||||
roomName,
|
||||
command,
|
||||
payload,
|
||||
connection
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a connection, broadcasts a message to all connections except
|
||||
* the provided connection.
|
||||
*/
|
||||
broadcastExclude(connection: Connection, command: string, payload: any) {
|
||||
const cmd = JSON.stringify({ command, payload });
|
||||
Object.values(this.connections).forEach((c) => {
|
||||
if (c.id !== connection.id) {
|
||||
c.socket.send(cmd);
|
||||
broadcastExclude(
|
||||
connection: Connection,
|
||||
command: string,
|
||||
payload: any
|
||||
): void {
|
||||
const cmd: Command = { command, payload };
|
||||
|
||||
Object.values(this.connections).forEach((conn) => {
|
||||
if (conn.id !== connection.id) {
|
||||
conn.send(cmd);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* ```typescript
|
||||
* server.registerCommand("join:room", async (payload: { roomName: string }, connection: Connection) => {
|
||||
* server.addToRoom(payload.roomName, connection);
|
||||
* server.broadcastRoom(payload.roomName, "joined", { roomName: payload.roomName });
|
||||
* });
|
||||
* ```
|
||||
* Add a connection to a room
|
||||
*/
|
||||
addToRoom(roomName: string, connection: Connection) {
|
||||
this.rooms[roomName] = this.rooms[roomName] ?? new Set();
|
||||
this.rooms[roomName].add(connection.id);
|
||||
}
|
||||
|
||||
removeFromRoom(roomName: string, connection: Connection) {
|
||||
if (!this.rooms[roomName]) return;
|
||||
this.rooms[roomName].delete(connection.id);
|
||||
async addToRoom(roomName: string, connection: Connection): Promise<void> {
|
||||
await this.roomManager.addToRoom(roomName, connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a "room", which is simply a Set of Connection ids.
|
||||
* @param roomName
|
||||
* Remove a connection from a room
|
||||
*/
|
||||
getRoom(roomName: string): Connection[] {
|
||||
const ids = this.rooms[roomName] || new Set();
|
||||
return Array.from(ids).map((id) => this.connections[id]);
|
||||
async removeFromRoom(
|
||||
roomName: string,
|
||||
connection: Connection
|
||||
): Promise<void> {
|
||||
await this.roomManager.removeFromRoom(roomName, connection);
|
||||
}
|
||||
|
||||
clearRoom(roomName: string) {
|
||||
this.rooms[roomName] = new Set();
|
||||
/**
|
||||
* Remove a connection from all rooms
|
||||
*/
|
||||
async removeFromAllRooms(connection: Connection | string): Promise<void> {
|
||||
await this.roomManager.removeFromAllRooms(connection);
|
||||
}
|
||||
|
||||
registerCommand(command: string, callback: SocketMiddleware, middlewares: SocketMiddleware[] = []) {
|
||||
/**
|
||||
* Returns all connections in a room
|
||||
*/
|
||||
async getRoom(roomName: string): Promise<Connection[]> {
|
||||
return this.roomManager.getRoom(roomName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all connections from a room
|
||||
*/
|
||||
async clearRoom(roomName: string): Promise<void> {
|
||||
await this.roomManager.clearRoom(roomName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a command handler
|
||||
*/
|
||||
async registerCommand<T = any>(
|
||||
command: string,
|
||||
callback: (context: WSContext<any>) => Promise<T> | T,
|
||||
middlewares: SocketMiddleware[] = []
|
||||
): Promise<void> {
|
||||
this.commands[command] = callback;
|
||||
|
||||
if (middlewares.length > 0) {
|
||||
this.prependMiddlewareToCommand(command, middlewares);
|
||||
}
|
||||
|
||||
prependMiddlewareToCommand(command: string, middlewares: SocketMiddleware[]) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add middleware to be executed before a command
|
||||
*/
|
||||
prependMiddlewareToCommand(
|
||||
command: string,
|
||||
middlewares: SocketMiddleware[]
|
||||
): void {
|
||||
if (middlewares.length) {
|
||||
this.middlewares[command] = this.middlewares[command] || [];
|
||||
this.middlewares[command] = middlewares.concat(this.middlewares[command]);
|
||||
}
|
||||
}
|
||||
|
||||
appendMiddlewareToCommand(command: string, middlewares: SocketMiddleware[]) {
|
||||
/**
|
||||
* Add middleware to be executed after other middleware but before the command
|
||||
*/
|
||||
appendMiddlewareToCommand(
|
||||
command: string,
|
||||
middlewares: SocketMiddleware[]
|
||||
): void {
|
||||
if (middlewares.length) {
|
||||
this.middlewares[command] = this.middlewares[command] || [];
|
||||
this.middlewares[command] = this.middlewares[command].concat(middlewares);
|
||||
}
|
||||
}
|
||||
|
||||
private async runCommand(id: number, command: string, payload: any, connection: Connection) {
|
||||
const c = new WSContext(this, connection, payload);
|
||||
/**
|
||||
* Execute a command with the given id, name, payload and connection
|
||||
*/
|
||||
private async runCommand(
|
||||
id: number,
|
||||
command: string,
|
||||
payload: any,
|
||||
connection: Connection
|
||||
): Promise<void> {
|
||||
const context = new WSContext(this, connection, payload);
|
||||
|
||||
try {
|
||||
if (!this.commands[command]) {
|
||||
// An onslaught of commands that don't exist is a sign of a bad
|
||||
// or otherwise misconfigured client.
|
||||
throw new Error(`Command [${command}] not found.`);
|
||||
throw new CodeError(
|
||||
`Command [${command}] not found.`,
|
||||
"ENOTFOUND",
|
||||
"CommandError"
|
||||
);
|
||||
}
|
||||
|
||||
// Run global middlewares
|
||||
if (this.globalMiddlewares.length) {
|
||||
for (const mw of this.globalMiddlewares) {
|
||||
await mw(c);
|
||||
for (const middleware of this.globalMiddlewares) {
|
||||
await middleware(context);
|
||||
}
|
||||
}
|
||||
|
||||
// Run command-specific middlewares
|
||||
if (this.middlewares[command]) {
|
||||
for (const mw of this.middlewares[command]) {
|
||||
await mw(c);
|
||||
for (const middleware of this.middlewares[command]) {
|
||||
await middleware(context);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.commands[command](c);
|
||||
// Execute the command
|
||||
const result = await this.commands[command](context);
|
||||
connection.send({ id, command, payload: result });
|
||||
} catch (e) {
|
||||
const payload = { error: e.message ?? e ?? "Unknown error" };
|
||||
connection.send({ id, command, payload });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle and serialize errors
|
||||
const errorPayload =
|
||||
error instanceof Error
|
||||
? {
|
||||
error: error.message,
|
||||
code: (error as CodeError).code || "ESERVER",
|
||||
name: error.name || "Error",
|
||||
}
|
||||
: { error: String(error) };
|
||||
|
||||
export { Connection };
|
||||
connection.send({ id, command, payload: errorPayload });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
192
packages/keepalive-ws/src/server/room-manager.ts
Normal file
192
packages/keepalive-ws/src/server/room-manager.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
146
packages/keepalive-ws/tests/advanced.test.ts
Normal file
146
packages/keepalive-ws/tests/advanced.test.ts
Normal file
@ -0,0 +1,146 @@
|
||||
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) =>
|
||||
new KeepAliveServer({
|
||||
port,
|
||||
pingInterval: 1000,
|
||||
latencyInterval: 500,
|
||||
});
|
||||
|
||||
describe("Advanced KeepAliveClient and KeepAliveServer Tests", () => {
|
||||
const port = 8125;
|
||||
let server: KeepAliveServer;
|
||||
let client: KeepAliveClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
server = createTestServer(port);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.on("listening", () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
// In case the server is already listening
|
||||
if (server.listening) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
client = new KeepAliveClient(`ws://localhost:${port}`);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (client.status === Status.ONLINE) {
|
||||
await client.close();
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
if (server) {
|
||||
server.close(() => {
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("command times out when server doesn't respond", async () => {
|
||||
await server.registerCommand(
|
||||
"never-responds",
|
||||
async () => new Promise(() => {})
|
||||
);
|
||||
|
||||
await client.connect();
|
||||
|
||||
await expect(
|
||||
client.command("never-responds", "Should timeout", 500)
|
||||
).rejects.toThrow(/timed out/);
|
||||
}, 2000);
|
||||
|
||||
test("server errors are properly serialized to client", async () => {
|
||||
await server.registerCommand("throws-error", async () => {
|
||||
throw new Error("Custom server error");
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
const result = await client.command("throws-error", "Will error", 1000);
|
||||
expect(result).toHaveProperty("error", "Custom server error");
|
||||
}, 2000);
|
||||
|
||||
test("multiple concurrent commands are handled correctly", async () => {
|
||||
await server.registerCommand("fast", async (context) => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
return `Fast: ${context.payload}`;
|
||||
});
|
||||
|
||||
await server.registerCommand("slow", async (context) => {
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
return `Slow: ${context.payload}`;
|
||||
});
|
||||
|
||||
await server.registerCommand(
|
||||
"echo",
|
||||
async (context) => `Echo: ${context.payload}`
|
||||
);
|
||||
|
||||
await client.connect();
|
||||
|
||||
const results = await Promise.all([
|
||||
client.command("fast", "First", 1000),
|
||||
client.command("slow", "Second", 1000),
|
||||
client.command("echo", "Third", 1000),
|
||||
]);
|
||||
|
||||
expect(results).toEqual(["Fast: First", "Slow: Second", "Echo: Third"]);
|
||||
}, 3000);
|
||||
|
||||
test("handles large payloads correctly", async () => {
|
||||
await server.registerCommand("echo", async (context) => context.payload);
|
||||
|
||||
await client.connect();
|
||||
|
||||
const largeData = {
|
||||
array: Array(1000)
|
||||
.fill(0)
|
||||
.map((_, i) => `item-${i}`),
|
||||
nested: {
|
||||
deep: {
|
||||
object: {
|
||||
with: "lots of data",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await client.command("echo", largeData, 5000);
|
||||
|
||||
expect(result).toEqual(largeData);
|
||||
}, 10000);
|
||||
|
||||
test("server handles multiple client connections", async () => {
|
||||
await server.registerCommand(
|
||||
"echo",
|
||||
async (context) => `Echo: ${context.payload}`
|
||||
);
|
||||
|
||||
const clients = Array(5)
|
||||
.fill(0)
|
||||
.map(() => new KeepAliveClient(`ws://localhost:${port}`));
|
||||
|
||||
await Promise.all(clients.map((client) => client.connect()));
|
||||
|
||||
const results = await Promise.all(
|
||||
clients.map((client, i) => client.command("echo", `Client ${i}`, 1000))
|
||||
);
|
||||
|
||||
results.forEach((result, i) => {
|
||||
expect(result).toBe(`Echo: Client ${i}`);
|
||||
});
|
||||
|
||||
await Promise.all(clients.map((client) => client.close()));
|
||||
}, 5000);
|
||||
});
|
||||
85
packages/keepalive-ws/tests/basic.test.ts
Normal file
85
packages/keepalive-ws/tests/basic.test.ts
Normal file
@ -0,0 +1,85 @@
|
||||
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) =>
|
||||
new KeepAliveServer({
|
||||
port,
|
||||
pingInterval: 1000,
|
||||
latencyInterval: 500,
|
||||
});
|
||||
|
||||
describe("Basic KeepAliveClient and KeepAliveServer Tests", () => {
|
||||
const port = 8124;
|
||||
let server: KeepAliveServer;
|
||||
let client: KeepAliveClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
server = createTestServer(port);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.on("listening", () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
if (server.listening) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
client = new KeepAliveClient(`ws://localhost:${port}`);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (client.status === Status.ONLINE) {
|
||||
await client.close();
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
if (server) {
|
||||
server.close(() => {
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("client-server connection should be online", async () => {
|
||||
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) => `Echo: ${context.payload}`
|
||||
);
|
||||
|
||||
await client.connect();
|
||||
|
||||
const result = await client.command("echo", "Hello", 5000);
|
||||
expect(result).toBe("Echo: Hello");
|
||||
}, 10000);
|
||||
|
||||
test("connect should resolve when already connected", async () => {
|
||||
await server.registerCommand("echo", async (context) => context.payload);
|
||||
|
||||
await client.connect();
|
||||
expect(client.status).toBe(Status.ONLINE);
|
||||
|
||||
await client.connect();
|
||||
expect(client.status).toBe(Status.ONLINE);
|
||||
}, 10000);
|
||||
|
||||
test("close should resolve when already closed", async () => {
|
||||
await client.close();
|
||||
expect(client.status).toBe(Status.OFFLINE);
|
||||
|
||||
await client.close();
|
||||
expect(client.status).toBe(Status.OFFLINE);
|
||||
}, 10000);
|
||||
});
|
||||
394
packages/keepalive-ws/tests/redis-room.test.ts
Normal file
394
packages/keepalive-ws/tests/redis-room.test.ts
Normal 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
34
packages/mesh-express/.gitignore
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
6
packages/mesh-express/.npmignore
Normal file
6
packages/mesh-express/.npmignore
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
src
|
||||
docker-compose.yml
|
||||
bun.lock
|
||||
vitest.config.ts
|
||||
TODO.md
|
||||
121
packages/mesh-express/README.md
Normal file
121
packages/mesh-express/README.md
Normal 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
|
||||
326
packages/mesh-express/bun.lock
Normal file
326
packages/mesh-express/bun.lock
Normal 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=="],
|
||||
}
|
||||
}
|
||||
27
packages/mesh-express/package.json
Normal file
27
packages/mesh-express/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
72
packages/mesh-express/src/index.ts
Normal file
72
packages/mesh-express/src/index.ts
Normal 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";
|
||||
28
packages/mesh-express/tsconfig.json
Normal file
28
packages/mesh-express/tsconfig.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["esnext"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": false,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
11
packages/mesh-express/tsup.config.ts
Normal file
11
packages/mesh-express/tsup.config.ts
Normal 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
4
packages/mesh/README.md
Normal 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
|
||||
@ -1,33 +1,82 @@
|
||||
# ms
|
||||
# @prsm/ms
|
||||
|
||||
[](https://www.npmjs.com/package/@prsm/ms)
|
||||
|
||||
Confusingly, not just for converting milliseconds.
|
||||
A lightweight utility for parsing and converting time strings to milliseconds and other time units.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @prsm/ms
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import ms from "@prsm/ms";
|
||||
|
||||
// Convert complex time expressions to milliseconds
|
||||
ms("1day 2hrs 30min"); // 95400000
|
||||
ms("15mins 12s"); // 912000
|
||||
ms("1w 3d 12h"); // 907200000
|
||||
|
||||
// Negative values
|
||||
ms("-30min"); // -1800000
|
||||
|
||||
// Numbers are passed through
|
||||
ms(100); // 100
|
||||
ms("100"); // 100
|
||||
|
||||
ms("10s"); // 10_000
|
||||
ms("10sec"); // 10_000
|
||||
ms("10secs"); // 10_000
|
||||
ms("10second"); // 10_000
|
||||
ms("10,000,000seconds"); // 10_000_000_000
|
||||
// Format flexibility
|
||||
ms("1,000 seconds"); // 1000000
|
||||
ms("2_000ms"); // 2000
|
||||
```
|
||||
|
||||
ms("0h"); // 0
|
||||
### Options
|
||||
|
||||
```typescript
|
||||
// Disable rounding (default: true)
|
||||
ms("10.9ms"); // 11
|
||||
ms("10.9ms", { round: false }); // 10.9
|
||||
|
||||
ms("1000.9ms", { round: false, unit: "s" }); // 1.0009
|
||||
// Convert to different units (default: "ms")
|
||||
ms("1000.9ms", { unit: "s" }); // 1
|
||||
|
||||
// All supported unit aliases:
|
||||
// ms, msec, msecs, millisec, milliseconds
|
||||
// s, sec, secs, second, seconds
|
||||
// m, min, mins, minute, minutes
|
||||
// h, hr, hrs, hour, hours
|
||||
// d, day, days
|
||||
// w, wk, wks, week, weeks
|
||||
ms("1000.9ms", { round: false, unit: "s" }); // 1.0009
|
||||
ms("60s", { unit: "m" }); // 1
|
||||
ms("60m", { unit: "h" }); // 1
|
||||
```
|
||||
|
||||
### Unit Conversion
|
||||
|
||||
```typescript
|
||||
// Convert between different time units
|
||||
ms("1h", { unit: "ms" }); // 3600000
|
||||
ms("90min", { unit: "h" }); // 1.5
|
||||
ms("1d", { unit: "h" }); // 24
|
||||
ms("1w", { unit: "d" }); // 7
|
||||
|
||||
// Precision control
|
||||
ms("90.5min", { unit: "h" }); // 2 (rounded by default)
|
||||
ms("90.5min", { unit: "h", round: false }); // 1.5083333333333333
|
||||
```
|
||||
|
||||
### Default Values
|
||||
|
||||
```typescript
|
||||
// Provide default values for invalid inputs
|
||||
ms("", 500); // 500
|
||||
ms(null, "1s"); // 1000
|
||||
ms("invalid", "5m"); // 300000
|
||||
```
|
||||
|
||||
## Supported Time Units
|
||||
|
||||
| Unit | Aliases |
|
||||
|--------------|------------------------------------------------------------------|
|
||||
| Milliseconds | `ms`, `msec`, `msecs`, `millisec`, `millisecond`, `milliseconds` |
|
||||
| Seconds | `s`, `sec`, `secs`, `second`, `seconds` |
|
||||
| Minutes | `m`, `min`, `mins`, `minute`, `minutes` |
|
||||
| Hours | `h`, `hr`, `hrs`, `hour`, `hours` |
|
||||
| Days | `d`, `dy`, `day`, `days` |
|
||||
| Weeks | `w`, `wk`, `wks`, `week`, `weeks` |
|
||||
|
||||
Binary file not shown.
@ -15,12 +15,14 @@
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"test": "vitest run",
|
||||
"release": "bumpp package.json && npm publish --access public"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.4.1",
|
||||
"bumpp": "^9.5.1",
|
||||
"tsup": "^8.2.4"
|
||||
"tsup": "^8.2.4",
|
||||
"vitest": "^3.0.9"
|
||||
}
|
||||
}
|
||||
|
||||
157
packages/ms/src/index.test.ts
Normal file
157
packages/ms/src/index.test.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import ms from "./index";
|
||||
|
||||
describe("ms", () => {
|
||||
describe("basic functionality", () => {
|
||||
it("should convert string time to milliseconds", () => {
|
||||
expect(ms("10s")).toBe(10000);
|
||||
expect(ms("10sec")).toBe(10000);
|
||||
expect(ms("10secs")).toBe(10000);
|
||||
expect(ms("10second")).toBe(10000);
|
||||
expect(ms("10seconds")).toBe(10000);
|
||||
});
|
||||
|
||||
it("should handle numeric inputs", () => {
|
||||
expect(ms(100)).toBe(100);
|
||||
expect(ms("100")).toBe(100);
|
||||
});
|
||||
|
||||
it("should handle decimal values with rounding options", () => {
|
||||
expect(ms("10.9ms")).toBe(11);
|
||||
expect(ms("10.9ms", { round: false })).toBe(10.9);
|
||||
});
|
||||
|
||||
it("should convert to different units", () => {
|
||||
expect(ms("1000ms", { unit: "s" })).toBe(1);
|
||||
expect(ms("1000.9ms", { round: false, unit: "s" })).toBe(1.0009);
|
||||
});
|
||||
|
||||
it("should handle multiple time units and negative values", () => {
|
||||
expect(ms("1m 30s")).toBe(90000);
|
||||
expect(ms("-1h")).toBe(-3600000);
|
||||
expect(ms("2h 30m")).toBe(9000000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unit conversions", () => {
|
||||
it("should handle all time unit aliases", () => {
|
||||
// Milliseconds
|
||||
expect(ms("100ms")).toBe(100);
|
||||
expect(ms("100msec")).toBe(100);
|
||||
expect(ms("100msecs")).toBe(100);
|
||||
expect(ms("100millisec")).toBe(100);
|
||||
expect(ms("100millisecond")).toBe(100);
|
||||
expect(ms("100milliseconds")).toBe(100);
|
||||
|
||||
// Seconds
|
||||
expect(ms("10s")).toBe(10000);
|
||||
expect(ms("10sec")).toBe(10000);
|
||||
expect(ms("10secs")).toBe(10000);
|
||||
expect(ms("10second")).toBe(10000);
|
||||
expect(ms("10seconds")).toBe(10000);
|
||||
|
||||
// Minutes
|
||||
expect(ms("5m")).toBe(300000);
|
||||
expect(ms("5min")).toBe(300000);
|
||||
expect(ms("5mins")).toBe(300000);
|
||||
expect(ms("5minute")).toBe(300000);
|
||||
expect(ms("5minutes")).toBe(300000);
|
||||
|
||||
// Hours
|
||||
expect(ms("2h")).toBe(7200000);
|
||||
expect(ms("2hr")).toBe(7200000);
|
||||
expect(ms("2hrs")).toBe(7200000);
|
||||
expect(ms("2hour")).toBe(7200000);
|
||||
expect(ms("2hours")).toBe(7200000);
|
||||
|
||||
// Days
|
||||
expect(ms("1d")).toBe(86400000);
|
||||
expect(ms("1dy")).toBe(86400000);
|
||||
expect(ms("1day")).toBe(86400000);
|
||||
expect(ms("1days")).toBe(86400000);
|
||||
|
||||
// Weeks
|
||||
expect(ms("1w")).toBe(604800000);
|
||||
expect(ms("1wk")).toBe(604800000);
|
||||
expect(ms("1wks")).toBe(604800000);
|
||||
expect(ms("1week")).toBe(604800000);
|
||||
expect(ms("1weeks")).toBe(604800000);
|
||||
});
|
||||
|
||||
it("should convert between different units", () => {
|
||||
expect(ms("60s", { unit: "m" })).toBe(1);
|
||||
expect(ms("60m", { unit: "h" })).toBe(1);
|
||||
expect(ms("24h", { unit: "d" })).toBe(1);
|
||||
expect(ms("7d", { unit: "w" })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatting and parsing", () => {
|
||||
it("should handle various number formats", () => {
|
||||
expect(ms("1,000ms")).toBe(1000);
|
||||
expect(ms("1_000ms")).toBe(1000);
|
||||
expect(ms("1-000ms")).toBe(1000);
|
||||
expect(ms("1 000ms")).toBe(1000);
|
||||
});
|
||||
|
||||
it("should handle spaces between values and units", () => {
|
||||
expect(ms("10 s")).toBe(10000);
|
||||
expect(ms("5 minutes")).toBe(300000);
|
||||
});
|
||||
|
||||
it("should handle multiple space-separated time units", () => {
|
||||
expect(ms("1h 30m")).toBe(5400000);
|
||||
expect(ms("1d 12h")).toBe(129600000);
|
||||
expect(ms("1w 2d 3h 4m 5s")).toBe(788645000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle invalid inputs", () => {
|
||||
expect(ms("")).toBe(0);
|
||||
expect(ms(null)).toBe(0);
|
||||
expect(ms(undefined)).toBe(0);
|
||||
expect(ms("invalid")).toBe(0);
|
||||
expect(ms("123invalid")).toBe(123);
|
||||
});
|
||||
|
||||
it("should handle default values", () => {
|
||||
expect(ms("", 500)).toBe(500);
|
||||
expect(ms(null, "1s")).toBe(1000);
|
||||
expect(ms("invalid", "5m")).toBe(300000);
|
||||
});
|
||||
|
||||
it("should handle zero values", () => {
|
||||
expect(ms("0ms")).toBe(0);
|
||||
expect(ms("0s")).toBe(0);
|
||||
expect(ms("0m")).toBe(0);
|
||||
expect(ms("0h")).toBe(0);
|
||||
expect(ms("0d")).toBe(0);
|
||||
expect(ms("0w")).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle very large values", () => {
|
||||
expect(ms("1000000s")).toBe(1000000000);
|
||||
expect(ms("1000h")).toBe(3600000000);
|
||||
});
|
||||
|
||||
it("should handle very small decimal values", () => {
|
||||
expect(ms("0.001s")).toBe(1);
|
||||
expect(ms("0.0001s", { round: false })).toBe(0.1);
|
||||
});
|
||||
|
||||
it("should handle mixed case units", () => {
|
||||
expect(ms("10S")).toBe(10000);
|
||||
expect(ms("5Min")).toBe(300000);
|
||||
expect(ms("2HR")).toBe(7200000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("caching behavior", () => {
|
||||
it("should return the same result for identical inputs", () => {
|
||||
const result1 = ms("10s");
|
||||
const result2 = ms("10s");
|
||||
expect(result1).toBe(result2);
|
||||
});
|
||||
});
|
||||
});
|
||||
7
packages/ms/vitest.config.ts
Normal file
7
packages/ms/vitest.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@ -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.
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@prsm/ngn",
|
||||
"version": "1.5.5",
|
||||
"version": "2.0.1",
|
||||
"description": "",
|
||||
"author": "nvms <pyersjonathan@gmail.com>",
|
||||
"type": "module",
|
||||
@ -11,20 +11,19 @@
|
||||
"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",
|
||||
"release": "bumpp package.json --commit 'Release %s' --push --tag && pnpm publish --access public --no-git-checks",
|
||||
"serve": "esr --serve src/demo.ts"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./input": {
|
||||
"require": "./dist/index.cjs",
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
"types": "./dist/packages/input/index.d.ts",
|
||||
"import": "./dist/packages/input/index.js",
|
||||
"require": "./dist/packages/input/index.cjs"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@ -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);
|
||||
@ -90,7 +89,13 @@ var createWorld = () => {
|
||||
let raf = null;
|
||||
let craf = null;
|
||||
if (typeof window !== "undefined") {
|
||||
raf = requestAnimationFrame;
|
||||
let now = performance.now();
|
||||
raf = (cb) => {
|
||||
return requestAnimationFrame((timestamp) => {
|
||||
now = timestamp;
|
||||
cb(now);
|
||||
});
|
||||
};
|
||||
craf = cancelAnimationFrame;
|
||||
} else {
|
||||
let now = 0;
|
||||
@ -107,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();
|
||||
}
|
||||
@ -136,7 +140,9 @@ var createWorld = () => {
|
||||
};
|
||||
function step2() {
|
||||
for (const system of state[$systems]) {
|
||||
system(state);
|
||||
if (system(state) === null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
function addSystem2(...systems) {
|
||||
@ -186,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();
|
||||
@ -204,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);
|
||||
@ -221,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);
|
||||
@ -342,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];
|
||||
@ -576,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--) {
|
||||
@ -677,7 +677,6 @@ var createParticleEmitter = (opts) => {
|
||||
if (opts.burst && particles.length === 0) {
|
||||
destroy();
|
||||
}
|
||||
context.globalCompositeOperation = "source-over";
|
||||
};
|
||||
const destroy = () => {
|
||||
dead = true;
|
||||
@ -792,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,
|
||||
@ -823,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"],
|
||||
@ -845,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) => {
|
||||
}
|
||||
@ -862,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
@ -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>
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export { Component, createWorld, Entity, QueryConfig, type ComponentInstance, type WorldState } from "./ngn";
|
||||
export { create2D, createCanvas, CreateCanvasOptions, createDraw, type Vector2 } from "./packages/2d";
|
||||
export * from "./packages/input";
|
||||
export { onGamepadConnected, onGamepadDisconnected } from "./packages/input/devices/gamepad";
|
||||
export { GamepadMapping, PlayStation4, PlayStation5, SCUFVantage2, Xbox } from "./packages/input/devices/mappings/gamepad";
|
||||
export { KeyboardKey, KeyboardMapping, StandardKeyboard } from "./packages/input/devices/mappings/keyboard";
|
||||
|
||||
@ -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,8 +83,10 @@ 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. */
|
||||
@ -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;
|
||||
@ -147,7 +151,13 @@ export const createWorld = () => {
|
||||
* so that we can run tests for this in node.
|
||||
*/
|
||||
if (typeof window !== "undefined") {
|
||||
raf = requestAnimationFrame;
|
||||
let now = performance.now();
|
||||
raf = (cb: FrameRequestCallback): number => {
|
||||
return requestAnimationFrame((timestamp: number) => {
|
||||
now = timestamp;
|
||||
cb(now);
|
||||
});
|
||||
};
|
||||
craf = cancelAnimationFrame;
|
||||
} else {
|
||||
let now = 0;
|
||||
@ -177,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);
|
||||
@ -210,7 +236,9 @@ export const createWorld = () => {
|
||||
|
||||
function step() {
|
||||
for (const system of state[$systems]) {
|
||||
system(state);
|
||||
if (system(state) === null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -263,7 +291,7 @@ export const createWorld = () => {
|
||||
* @param {string} queryName - The name of the query to retrieve or update results for.
|
||||
* @returns {any[]} An array of result objects, each containing an entity and its components as properties.
|
||||
*/
|
||||
const getQuery = (queryConfig: QueryConfig, queryName: string) => {
|
||||
const getQuery = (queryConfig: QueryConfig, queryName: string): any[] => {
|
||||
// If we have non-dirty query results for this queryName, return them
|
||||
if (!state[$dirtyQueries].has(queryName) && state[$queryResults][queryName]) {
|
||||
return state[$queryResults][queryName].results;
|
||||
@ -533,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());
|
||||
@ -567,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 {
|
||||
|
||||
@ -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 context’s "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 = () => {
|
||||
|
||||
@ -37,6 +37,8 @@ const squash = (number) => (Math.abs(number) >= deadzone ? number : Math.abs(num
|
||||
|
||||
export const gamepadUpdate = () => {
|
||||
for (const pad of navigator.getGamepads()) {
|
||||
if (!pad) continue;
|
||||
|
||||
gamepadState[pad.index] = {
|
||||
axes: {
|
||||
0: squash(pad.axes[0]),
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -16,14 +16,6 @@ export interface GamepadButtonState extends ButtonState {
|
||||
value: number;
|
||||
}
|
||||
|
||||
let $mousemove = null;
|
||||
let $mousedown = null;
|
||||
let $mouseup = null;
|
||||
let $mousewheel = null;
|
||||
let $keydown = null;
|
||||
let $keyup = null;
|
||||
let $gamepadconnected = null;
|
||||
let $gamepaddisconnected = null;
|
||||
let boundEvents = false;
|
||||
|
||||
const setDefaultStates = () => {
|
||||
@ -53,14 +45,14 @@ export const destroyInput = () => {
|
||||
};
|
||||
|
||||
const bindEvents = () => {
|
||||
$mousemove = window.addEventListener("mousemove", onMouseMove);
|
||||
$mousedown = window.addEventListener("mousedown", onMouseDown);
|
||||
$mouseup = window.addEventListener("mouseup", onMouseUp);
|
||||
$mousewheel = window.addEventListener("mousewheel", onMouseWheel);
|
||||
$keydown = window.addEventListener("keydown", onKeyDown);
|
||||
$keyup = window.addEventListener("keyup", onKeyUp);
|
||||
$gamepadconnected = window.addEventListener("gamepadconnected", onGamepadConnected);
|
||||
$gamepaddisconnected = window.addEventListener("gamepaddisconnected", onGamepadDisconnected);
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mousedown", onMouseDown);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
window.addEventListener("mousewheel", onMouseWheel);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
window.addEventListener("gamepadconnected", onGamepadConnected);
|
||||
window.addEventListener("gamepaddisconnected", onGamepadDisconnected);
|
||||
};
|
||||
|
||||
const destroyEvents = () => {
|
||||
|
||||
@ -2,4 +2,5 @@ import { describe } from "manten";
|
||||
|
||||
await describe("ngn", async ({ runTestSuite }) => {
|
||||
runTestSuite(import("./ngn"));
|
||||
runTestSuite(import("./ngn/scenes.test"));
|
||||
});
|
||||
|
||||
@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@ -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");
|
||||
|
||||
137
packages/ngn/src/tests/ngn/scenes.test.ts
Normal file
137
packages/ngn/src/tests/ngn/scenes.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
58
packages/ngn/src/tests/ngn/time.test.ts
Normal file
58
packages/ngn/src/tests/ngn/time.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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 () => {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@prsm/otp",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"description": "",
|
||||
"main": "./dist/index.js",
|
||||
"type": "module",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user