relocate these

This commit is contained in:
nvms 2024-08-27 18:16:34 -04:00
commit 0a763d2ec5
110 changed files with 5952 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules
dist
^.env$
packages/smol
docs
examples

23
.prettierrc Normal file
View File

@ -0,0 +1,23 @@
{
"printWidth": 80,
"overrides": [
{
"files": "packages/otp/src/index.test.ts",
"options": {
"printWidth": 250
}
},
{
"files": "packages/otp/src/index.ts",
"options": {
"printWidth": 250
}
},
{
"files": "packages/match/src/index.ts",
"options": {
"printWidth": 250
}
}
]
}

73
LICENSE.md Normal file
View File

@ -0,0 +1,73 @@
Apache License, Version 2.0 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License.
Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License.
Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution.
You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
You must give any other recipients of the Work or Derivative Works a copy of this License; and You must cause any modified files to carry prominent notices stating that You changed the files; and You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions.
Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks.
This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty.
Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability.
In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability.
While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright 2024 Jonathan Pyers
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

13
README.md Normal file
View File

@ -0,0 +1,13 @@
# prsm
The **prsm** package namespace contains a collection of packages that have been curated over the years by [nvms](https://github.com/nvms).
* @prsm/arc [![NPM version](https://img.shields.io/npm/v/@prsm/arc?color=0B3437&label=)](https://www.npmjs.com/package/@prsm/arc)
* @prsm/duplex [![NPM version](https://img.shields.io/npm/v/@prsm/duplex?color=0B3437&label=)](https://www.npmjs.com/package/@prsm/duplex)
* @prsm/express-keepalive-ws [![NPM version](https://img.shields.io/npm/v/@prsm/express-keepalive-ws?color=0B3437&label=)](https://www.npmjs.com/package/@prsm/express-keepalive-ws)
* @prsm/express-session-auth [![NPM version](https://img.shields.io/npm/v/@prsm/express-session-auth?color=0B3437&label=)](https://www.npmjs.com/package/@prsm/express-session-auth)
* @prsm/hash [![NPM version](https://img.shields.io/npm/v/@prsm/hash?color=0B3437&label=)](https://www.npmjs.com/package/@prsm/hash)
* @prsm/ids [![NPM version](https://img.shields.io/npm/v/@prsm/ids?color=0B3437&label=)](https://www.npmjs.com/package/@prsm/ids)
* @prsm/jwt [![NPM version](https://img.shields.io/npm/v/@prsm/jwt?color=0B3437&label=)](https://www.npmjs.com/package/@prsm/jwt)
* @prsm/keepalive-ws [![NPM version](https://img.shields.io/npm/v/@prsm/keepalive-ws?color=0B3437&label=)](https://www.npmjs.com/package/@prsm/keepalive-ws)
* @prsm/ms [![NPM version](https://img.shields.io/npm/v/@prsm/ms?color=0B3437&label=)](https://www.npmjs.com/package/@prsm/ms)

1
packages/arc Submodule

@ -0,0 +1 @@
Subproject commit 595e0b41961a6504b957173fa6470e4e21d16296

View File

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

82
packages/duplex/README.md Normal file
View File

@ -0,0 +1,82 @@
# duplex
[![NPM version](https://img.shields.io/npm/v/@prsm/duplex?color=a1b858&label=)](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`.
## Server
```typescript
import { CommandServer } from "@prsm/duplex";
// An insecure CommandServer (`Server` from `node:net`)
const server = new CommandServer({
host: "localhost",
port: 3351,
secure: false,
});
// A secure CommandServer (`Server` from `node:tls`)
// https://nodejs.org/api/tls.html#new-tlstlssocketsocket-options
const server = new CommandServer({
host: "localhost",
port: 3351,
secure: true,
key: fs.readFileSync("certs/server/server.key"),
cert: fs.readFileSync("certs/server/server.crt"),
ca: fs.readFileSync("certs/server/ca.crt"),
requestCert: true,
});
// -------------------
// Defining a command handler
server.command(0, async (payload: any, connection: Connection) => {
return { ok: "OK" };
});
```
## Client
```typescript
import { CommandClient } from "@prsm/duplex";
// An insecure client (`Socket` from `node:net`)
const client = new CommandClient({
host: "localhost",
port: 3351,
secure: false,
});
// A secure client (`TLSSocket` from `node:tls`)
const client = new CommandClient({
host: "localhost",
port: 3351,
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);
```

View File

@ -0,0 +1,7 @@
import { defineConfig } from "bumpp";
export default defineConfig({
commit: "%s release",
push: true,
tag: true,
});

BIN
packages/duplex/bun.lockb Executable file

Binary file not shown.

View File

@ -0,0 +1,27 @@
{
"name": "@prsm/duplex",
"version": "1.1.12",
"author": "nvms",
"main": "./dist/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"description": "",
"keywords": [],
"license": "Apache-2.0",
"scripts": {
"build": "tsup",
"release": "bumpp package.json && npm publish --access public"
},
"type": "module",
"devDependencies": {
"@types/node": "^22.4.1",
"bumpp": "^9.5.1",
"tsup": "^8.2.4",
"typescript": "^5.5.4"
}
}

View File

@ -0,0 +1,230 @@
import { EventEmitter } from "node:events";
import net from "node:net";
import tls from "node:tls";
import { CodeError } from "../common/codeerror";
import { Command } from "../common/command";
import { Connection } from "../common/connection";
import { ErrorSerializer } from "../common/errorserializer";
import { Status } from "../common/status";
import { IdManager } from "../server/ids";
import { Queue } from "./queue";
export type TokenClientOptions = tls.ConnectionOptions & net.NetConnectOpts & {
secure: boolean;
};
class TokenClient extends EventEmitter {
public options: TokenClientOptions;
private socket: tls.TLSSocket | net.Socket;
private connection: Connection | null = null;
private hadError: boolean;
status: Status;
constructor(options: TokenClientOptions) {
super();
this.options = options;
this.connect();
}
connect(callback?: () => void) {
if (this.status >= Status.CLOSED) {
return false;
}
this.hadError = false;
this.status = Status.CONNECTING;
if (this.options.secure) {
this.socket = tls.connect(this.options, callback);
} else {
this.socket = net.connect(this.options, callback);
}
this.connection = null;
this.applyListeners();
return true;
}
close(callback?: () => void) {
if (this.status <= Status.CLOSED) return false;
this.status = Status.CLOSED;
this.socket.end(() => {
this.connection = null;
if (callback) callback();
});
return true;
}
send(buffer: Buffer) {
if (this.connection) {
return this.connection.send(buffer);
}
return false;
}
private applyListeners() {
this.socket.on("error", (error) => {
this.hadError = true;
this.emit("error", error);
});
this.socket.on("close", () => {
this.status = Status.OFFLINE;
this.emit("close", this.hadError);
});
this.socket.on("secureConnect", () => {
this.updateConnection();
this.status = Status.ONLINE;
this.emit("connect");
});
this.socket.on("connect", () => {
this.updateConnection();
this.status = Status.ONLINE;
this.emit("connect");
});
}
private updateConnection() {
const connection = new Connection(this.socket);
connection.on("token", (token) => {
this.emit("token", token, connection);
});
connection.on("remoteClose", () => {
this.emit("remoteClose", connection);
});
this.connection = connection;
}
}
class QueueClient extends TokenClient {
private queue = new Queue<Buffer>();
constructor(options: TokenClientOptions) {
super(options);
this.applyEvents();
}
sendBuffer(buffer: Buffer, expiresIn: number) {
const success = this.send(buffer);
if (!success) {
this.queue.add(buffer, expiresIn);
}
}
private applyEvents() {
this.on("connect", () => {
while (!this.queue.isEmpty) {
const item = this.queue.pop();
this.sendBuffer(item.value, item.expiresIn);
}
});
}
close() {
return super.close();
}
}
export class CommandClient extends QueueClient {
private ids = new IdManager(0xFFFF);
private callbacks: {
[id: number]: (error: Error | null, result?: any) => void
} = {};
constructor(options: TokenClientOptions) {
super(options);
this.init();
}
private init() {
this.on("token", (buffer: Buffer) => {
try {
const data = Command.parse(buffer);
if (this.callbacks[data.id]) {
if (data.command === 255) {
const error = ErrorSerializer.deserialize(data.payload);
this.callbacks[data.id](error, undefined);
} else {
this.callbacks[data.id](null, data.payload);
}
}
} catch (error) {
this.emit("error", error);
}
});
}
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");
}
const id = this.ids.reserve();
const buffer = Command.toBuffer({ id, command, payload })
this.sendBuffer(buffer, expiresIn);
// No 0, null or Infinity.
// Fallback to a reasonable default.
if (expiresIn === 0 || expiresIn === null || expiresIn === Infinity) {
expiresIn = 60_000;
}
const response = this.createResponsePromise(id);
const timeout = this.createTimeoutPromise(id, expiresIn);
if (typeof callback === "function") {
try {
const ret = await Promise.race([response, timeout]);
try {
callback(ret, undefined);
} catch (callbackError) { /* */ }
} catch (error) {
callback(undefined, error);
}
} else {
return Promise.race([response, timeout]);
}
}
private createTimeoutPromise(id: number, expiresIn: number) {
return new Promise((resolve, reject) => {
setTimeout(() => {
this.ids.release(id);
delete this.callbacks[id];
reject(new CodeError("Command timed out.", "ETIMEOUT", "CommandError"));
}, expiresIn);
});
}
private 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 {
resolve(result);
}
}
});
}
close() {
return super.close();
}
}

View File

@ -0,0 +1,51 @@
export class QueueItem<T> {
value: T;
private expiration: number;
constructor(value: T, expiresIn: number) {
this.value = value;
this.expiration = Date.now() + expiresIn;
}
get expiresIn() {
return this.expiration - Date.now();
}
get isExpired() {
return Date.now() > this.expiration;
}
}
export class Queue<T> {
private items: QueueItem<T>[] = [];
add(item: T, expiresIn: number) {
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;
}
pop(): QueueItem<T> | null {
while (this.items.length) {
const item = this.items.shift();
if (!item.isExpired) {
return item;
}
}
return null;
}
}

View File

@ -0,0 +1,15 @@
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;
}
}
}

View File

@ -0,0 +1,25 @@
interface CommandData {
id: number;
command: number;
payload: any;
}
export class Command {
static toBuffer({ payload, id, command }: CommandData): Buffer {
if (payload === undefined) throw new TypeError("The payload must not be undefined!");
const payloadString = JSON.stringify(payload);
const buffer = Buffer.allocUnsafe(payloadString.length + 3);
buffer.writeUInt16LE(id, 0);
buffer.writeUInt8(command, 2);
buffer.write(payloadString, 3);
return buffer;
}
static parse(buffer: Buffer): CommandData {
if (buffer.length < 3) throw new TypeError(`Token too short! Expected at least 3 bytes, got ${buffer.length}!`);
const id = buffer.readUInt16LE(0);
const command = buffer.readUInt8(2);
const payload = JSON.parse(buffer.toString("utf8", 3));
return { id, command, payload };
}
}

View File

@ -0,0 +1,70 @@
import { EventEmitter } from "node:events";
import { Duplex } from "node:stream";
import { Message, NEWLINE } from "./message";
const CLOSE_TOKEN = Buffer.from("\\\n");
export class Connection extends EventEmitter {
private readonly duplex: Duplex;
private buffer = Buffer.allocUnsafe(0);
constructor(duplex: Duplex) {
super();
this.duplex = duplex;
this.applyListeners();
}
private applyListeners() {
this.duplex.on("data", (buffer: Buffer) => {
this.buffer = Buffer.concat([this.buffer, buffer]);
this.parse();
});
this.duplex.on("close", () => {
this.emit("close");
});
}
private parse() {
while (this.buffer.length > 0) {
const i = this.buffer.indexOf(NEWLINE);
if (i === -1) break;
// +1 to include the separating newline.
const data = this.buffer.subarray(0, i + 1);
if (data.equals(CLOSE_TOKEN)) {
this.emit("remoteClose");
} else {
this.emit("token", Message.unescape(data));
}
this.buffer = this.buffer.subarray(i + 1);
}
}
get isDead() {
return !this.duplex.writable || !this.duplex.readable;
}
send(buffer: Buffer) {
if (this.isDead) return false;
this.duplex.write(Message.escape(buffer));
return true;
}
close() {
if (this.isDead) return false;
this.duplex.end();
return true;
}
remoteClose() {
if (this.isDead) return false;
this.duplex.write(CLOSE_TOKEN);
return true;
}
}

View File

@ -0,0 +1,37 @@
export interface SerializedError {
name: string;
message: string;
stack: string;
[prop: string]: any;
}
export class ErrorSerializer {
// Converts an Error into a standard object.
static serialize(error: Error): SerializedError {
const { message, name, stack } = error;
return { message, name, stack, ...error };
}
// Converts an object into an Error instance.
static deserialize (data: SerializedError) {
const Factory = this.getFactory(data);
const error = new Factory(data.message);
Object.assign(error, data);
return error;
}
// Tries to find the global class for the error name and
// returns Error if none is found.
private static getFactory (data: SerializedError): new (message: string) => Error {
const name = data.name;
if (name.endsWith("Error") && global[name]) {
return global[name];
}
return Error;
}
}

View File

@ -0,0 +1,65 @@
export const NEWLINE = Buffer.from("\n")[0];
const ESC = Buffer.from("\\")[0];
const ESC_N = Buffer.from("n")[0];
export class Message {
// Escape all newlines and backslashes in a Buffer.
static escape(data: Buffer): Buffer {
const result: number[] = [];
for (const char of data) {
switch (char) {
case ESC:
// Escape the escaped backslash
result.push(ESC);
result.push(ESC);
break;
case NEWLINE:
// Escape newline
result.push(ESC);
result.push(ESC_N);
break;
default:
result.push(char);
break;
}
}
result.push(NEWLINE);
return Buffer.from(result);
}
// Undoes what the escape method does.
static unescape(data: Buffer): Buffer {
const result: number[] = [];
// Ignore last byte because it's the separating newline.
for (let i = 0; i < data.length - 1; i++) {
const char = data[i];
const next = data[i + 1];
if (char === ESC) {
switch (next) {
case ESC:
// Escaped escaped backslash.
result.push(ESC);
i += 1;
break;
case ESC_N:
// Escaped newline.
result.push(NEWLINE);
i += 1;
break;
default:
throw new Error("Unescaped backslash detected!");
}
} else {
result.push(char);
}
}
return Buffer.from(result);
}
}

View File

@ -0,0 +1,6 @@
export enum Status {
ONLINE = 3,
CONNECTING = 2,
CLOSED = 1,
OFFLINE = 0,
}

View File

@ -0,0 +1,27 @@
import { CommandClient } from "../client/commandclient";
import { CodeError } from "../common/codeerror";
const client = new CommandClient({
host: "localhost",
port: 3351,
secure: false,
});
const payload = { things: "stuff", numbers: [1, 2, 3] };
async function main() {
const callback = (result: any, error: CodeError) => {
if (error) {
console.log("ERR [0]", error.code);
return;
}
console.log("RECV [0]", result);
client.close();
};
client.command(0, payload, 10, callback);
}
main();

View File

@ -0,0 +1,18 @@
import { CodeError } from "../common/codeerror";
import { Connection } from "../common/connection";
import { CommandServer } from "../server/commandserver";
const server = new CommandServer({
host: "localhost",
port: 3351,
secure: false,
});
server.command(0, async (payload: any, connection: Connection) => {
console.log("RECV [0]:", payload);
return { ok: "OK" };
});
server.on("clientError", (error: CodeError) => {
console.log("clientError", error.code);
});

View File

@ -0,0 +1,5 @@
export { CommandClient, type TokenClientOptions } from "./client/commandclient";
export { CommandServer, type TokenServerOptions } from "./server/commandserver";
export { Connection } from "./common/connection";
export { CodeError } from "./common/codeerror";
export { Status } from "./common/status";

View File

@ -0,0 +1,179 @@
import { EventEmitter } from "node:events";
import net, { Socket } from "node:net";
import tls from "node:tls";
import { CodeError } from "../common/codeerror";
import { Command } from "../common/command";
import { Connection } from "../common/connection";
import { ErrorSerializer } from "../common/errorserializer";
import { Status } from "../common/status";
export type TokenServerOptions = tls.TlsOptions & net.ListenOptions & net.SocketConstructorOpts & {
secure?: boolean;
};
export class TokenServer extends EventEmitter {
connections: Connection[] = [];
public options: TokenServerOptions;
public server: tls.Server | net.Server;
private hadError: boolean;
status: Status;
constructor(options: TokenServerOptions) {
super();
this.options = options;
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) => {
this.emit("clientError", err);
});
});
}
this.applyListeners();
this.connect();
}
connect(callback?: () => void) {
if (this.status >= Status.CONNECTING) return false;
this.hadError = false;
this.status = Status.CONNECTING;
this.server.listen(this.options, () => {
if (callback) callback();
});
return true;
}
close(callback?: () => void) {
if (!this.server.listening) return false;
this.status = Status.CLOSED;
this.server.close(() => {
for (const connection of this.connections) {
connection.remoteClose();
}
if (callback) callback();
});
return true;
}
applyListeners() {
this.server.on("listening", () => {
this.status = Status.ONLINE;
this.emit("listening");
});
this.server.on("tlsClientError", (error) => {
this.emit("clientError", error);
});
this.server.on("clientError", (error) => {
this.emit("clientError", error);
});
this.server.on("error", (error) => {
this.hadError = true;
this.emit("error", error);
this.server.close();
});
this.server.on("close", () => {
this.status = Status.OFFLINE;
this.emit("close", this.hadError);
});
this.server.on("secureConnection", (socket: Socket) => {
const connection = new Connection(socket);
this.connections.push(connection);
connection.once("close", () => {
const i = this.connections.indexOf(connection);
if (i !== -1) this.connections.splice(i, 1);
});
connection.on("token", (token) => {
this.emit("token", token, connection);
});
});
this.server.on("connection", (socket: Socket) => {
if (this.options.secure) return;
const connection = new Connection(socket);
this.connections.push(connection);
connection.once("close", () => {
const i = this.connections.indexOf(connection);
if (i !== -1) this.connections.splice(i, 1);
});
connection.on("token", (token) => {
this.emit("token", token, connection);
});
});
}
}
type CommandFn = (payload: any, connection: Connection) => Promise<any>;
export class CommandServer extends TokenServer {
private commands: {
[command: number]: CommandFn
} = {};
constructor(options: TokenServerOptions) {
super(options);
this.init();
}
private init() {
this.on("token", async (buffer, connection) => {
try {
const { id, command, payload } = Command.parse(buffer);
this.runCommand(id, command, payload, connection);
} catch (error) {
this.emit("error", error);
}
});
}
/**
* @param command - The command number to register, a UInt8 (0-255).
* 255 is reserved. You will get an error if you try to use it.
* @param fn - The function to run when the command is received.
*/
command(command: number, fn: CommandFn) {
this.commands[command] = fn;
}
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");
}
const result = await this.commands[command](payload, connection);
// A payload should not be undefined, so if a command returns nothing
// we respond with a simple "OK".
const payloadResult = result === undefined ? "OK" : result;
connection.send(Command.toBuffer({ command, id, payload: payloadResult }));
} catch (error) {
const payload = ErrorSerializer.serialize(error);
connection.send(Command.toBuffer({ command: 255, id, payload }));
}
}
}

View File

@ -0,0 +1,40 @@
export class IdManager {
ids: Array<true | false> = [];
index: number = 0;
maxIndex: number;
constructor(maxIndex: number = 2 ** 16 - 1) {
this.maxIndex = maxIndex;
}
release(id: number) {
if (id < 0 || id > this.maxIndex) {
throw new TypeError(`ID must be between 0 and ${this.maxIndex}. Got ${id}.`);
}
this.ids[id] = false;
}
reserve(): number {
const startIndex = this.index;
while (true) {
const i = this.index;
if (!this.ids[i]) {
this.ids[i] = true;
return i;
}
if (this.index >= this.maxIndex) {
this.index = 0;
} else {
this.index++;
}
if (this.index === startIndex) {
throw new Error(`All IDs are reserved. Make sure to release IDs when they are no longer used.`);
}
}
}
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"module": "es2022",
"target": "esnext",
"outDir": "dist",
"esModuleInterop": true,
"moduleResolution": "node",
"declaration": true,
"declarationDir": "dist"
}
}

View File

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

View File

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

View File

@ -0,0 +1,43 @@
# @prsm/express-keepalive-ws
This is a middleware that creates and exposes a `KeepAliveServer` instance (see [prsm/keepalive-ws](https://github.com/...).
```typescript
import express from "express";
import createWss, { type WSContext } from "@prsm/express-keepalive-ws";
const app = express();
const server = createServer(app);
const { middleware: ws, wss } = createWss({ /** ... */ });
app.use(ws);
// as a middleware:
app.use("/ws", async (req, res) => {
if (req.ws) { // <-- req.ws will be defined if the request is a WebSocket request
const ws = await req.ws(); // handle the upgrade and receive the client WebSocket
ws.send("Hello WS!"); // send a message to the client
} else {
res.send("Hello HTTP!");
}
});
// as a command server:
wss.registerCommand("echo", (c: WSContext) => {
const { payload } = c;
return `echo: ${payload}`;
});
```
Client-side usage (more at https://github.com/node-prism/keepalive-ws):
```typescript
import { KeepAliveClient } from "@prsm/keepalive-ws/client";
const opts = { shouldReconnect: true };
const ws = new KeepAliveClient("ws://localhost:PORT", opts);
const echo = await ws.command("echo", "hello!");
console.log(echo); // "echo: hello!"
```

View File

@ -0,0 +1,7 @@
import { defineConfig } from "bumpp";
export default defineConfig({
commit: "%s release",
push: true,
tag: true,
});

Binary file not shown.

View File

@ -0,0 +1,30 @@
{
"name": "@prsm/express-keepalive-ws",
"version": "1.2.0",
"author": "",
"main": "./dist/index.js",
"devDependencies": {
"@types/ws": "^8.5.12",
"bumpp": "^9.5.1",
"tsup": "^8.2.4",
"typescript": "^5.5.4"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"description": "",
"keywords": [],
"license": "Apache-2.0",
"scripts": {
"build": "tsup",
"release": "bumpp package.json && npm publish --access public"
},
"type": "module",
"dependencies": {
"@prsm/keepalive-ws": "^0.3.1"
}
}

View File

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

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"module": "es2022",
"target": "esnext",
"outDir": "dist",
"esModuleInterop": true,
"moduleResolution": "node",
"declaration": true,
"declarationDir": "dist"
}
}

View File

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

View File

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

View File

@ -0,0 +1,86 @@
# express-session-auth
## Requirements
- `express-session`: https://github.com/expressjs/session
- `cookie-parser`: https://github.com/expressjs/cookie-parser
- TypeORM
- `express-session-auth` exports entities (`User`, `UserReset`, `UserRemember`, `UserConfirmation`) that you need to include in your datasource for migration/sync purposes.
## Quickstart
Wherever you create your express application, include the auth middleware and pass in your TypeORM datasource.
```typescript
import express from "express";
import { createServer } from "node:http";
import auth from "@prsm/express-session-auth";
import datasource from "./my-datasource";
const app = express();
const server = createServer(app);
// the auth middleware needs your datasource instance
app.use(auth({ datasource }));
```
Here's an example TypeORM datasource:
```typescript
// my-datasource.ts
import {
User,
UserConfirmation,
UserRemember,
UserReset,
} from "@prsm/express-session-auth";
import { DataSource } from "typeorm";
const datasource = new DataSource({
type: "mysql", // express-session-auth supports mysql, postgres and sqlite (others not tested)
host: process.env.DB_HOST,
port: process.env.DB_PORT ? +process.env.DB_PORT : 3306,
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
entities: [
User,
UserConfirmation,
UserRemember,
UserReset,
/* the reset of your entities here */
],
});
export default datasource;
```
Environment variables and their defaults:
```bash
HTTP_PORT=3002
AUTH_SESSION_REMEMBER_DURATION=30d
AUTH_SESSION_REMEMBER_COOKIE_NAME=prsm.auth.remember
AUTH_SESSION_RESYNC_INTERVAL=30m
AUTH_MINIMUM_PASSWORD_LENGTH=8
AUTH_MAXIMUM_PASSWORD_LENGTH=64
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=toor
DB_NAME=prsm
```
Because this middleware augments the `Request` object by adding an `auth` property, you will want to add the following to your `tsconfig.json` so that your language server doesn't flag references to `req.auth` as an error:
```json
{
"include": [
"src",
"node_modules/@prsm/express-session-auth/express-session-auth.d.ts"
]
}
```

View File

@ -0,0 +1,7 @@
import { defineConfig } from "bumpp";
export default defineConfig({
commit: "%s release",
push: true,
tag: true,
});

Binary file not shown.

View File

@ -0,0 +1,10 @@
import { createAuth, createAuthAdmin } from "./src/middleware.js";
declare global {
namespace Express {
interface Request {
auth: Awaited<ReturnType<typeof createAuth>>;
authAdmin: ReturnType<typeof createAuthAdmin>;
}
}
}

View File

@ -0,0 +1,46 @@
{
"name": "@prsm/express-session-auth",
"version": "1.5.0",
"description": "",
"main": "./dist/index.js",
"type": "module",
"types": "./dist/index.d.ts",
"files": [
"dist",
"express-session-auth.d.ts"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "tsup",
"release": "bumpp package.json && npm publish --access public"
},
"keywords": [],
"author": "",
"peerDependencies": {
"typeorm": "0.3.20"
},
"license": "Apache-2.0",
"dependencies": {
"@prsm/hash": "^1.0.2",
"@prsm/ids": "^1.1.1",
"@prsm/ms": "^1.0.1",
"@types/express": "^4.17.21",
"cookie-parser": "^1.4.6",
"express": "^4.19.2",
"express-session": "^1.18.0"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.7",
"@types/express-session": "^1.18.0",
"@types/node": "^22.4.1",
"bumpp": "^9.5.1",
"tsup": "^8.2.4",
"typescript": "^5.5.4"
}
}

View File

@ -0,0 +1,118 @@
export class ConfirmationNotFoundError extends Error {
constructor(message: string = "Confirmation selector/token pair not found") {
super(message);
this.name = "ConfirmationNotFoundError";
}
}
export class ConfirmationExpiredError extends Error {
constructor(message: string = "Confirmation selector/token pair expired") {
super(message);
this.name = "ConfirmationExpiredError";
}
}
export class EmailTakenError extends Error {
constructor(message: string = "Email already exists") {
super(message);
this.name = "EmailTakenError";
}
}
export class EmailNotVerifiedError extends Error {
constructor(message: string = "User not verified") {
super(message);
this.name = "EmailNotVerifiedError";
}
}
export class ImpersonationNotAllowedError extends Error {
constructor(message: string = "Impersonation not allowed") {
super(message);
this.name = "ImpersonationNotAllowedError";
}
}
export class InvalidEmailError extends Error {
constructor(message: string = "Invalid email provided") {
super(message);
this.name = "InvalidEmailError";
}
}
export class InvalidPasswordError extends Error {
constructor(message: string = "Invalid password provided") {
super(message);
this.name = "InvalidPasswordError";
}
}
export class InvalidTokenError extends Error {
constructor(message: string = "Invalid selector/token pair provided") {
super(message);
this.name = "InvalidSelectorTokenPairError";
}
}
export class InvalidUsernameError extends Error {
constructor(message: string = "Invalid username provided") {
super(message);
this.name = "InvalidUsernameError";
}
}
export class ResetDisabledError extends Error {
constructor(message: string = "Password reset is disabled") {
super(message);
this.name = "ResetDisabledError";
}
}
export class ResetExpiredError extends Error {
constructor(message: string = "Reset request expired") {
super(message);
this.name = "ResetExpiredError";
}
}
export class ResetNotFoundError extends Error {
constructor(message: string = "Reset request not found") {
super(message);
this.name = "ResetNotFoundError";
}
}
export class TooManyResetsError extends Error {
constructor(message: string = "Too many resets") {
super(message);
this.name = "TooManyResetsError";
}
}
export class UserInactiveError extends Error {
constructor(message: string = "User is inactive") {
super(message);
this.name = "UserInactiveError";
}
}
export class UserNotFoundError extends Error {
constructor(message: string = "User not found") {
super(message);
this.name = "UserNotFoundError";
}
}
export class UserNotLoggedInError extends Error {
constructor(message: string = "User not logged in") {
super(message);
this.name = "UserNotLoggedInError";
}
}
export class UsernameTakenError extends Error {
constructor(message: string = "Username already exists") {
super(message);
this.name = "UsernameTakenError";
}
}

View File

@ -0,0 +1,5 @@
export { middleware as default } from "./middleware.js";
export { User, AuthStatus, AuthRole } from "./user.entity.js";
export { UserReset } from "./user-reset.entity.js";
export { UserConfirmation } from "./user-confirmation.entity.js";
export { UserRemember } from "./user-remember.entity.js";

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,32 @@
import {
Column,
Entity,
Index,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { User } from "./user.entity.js";
@Entity("users_confirmations")
export class UserConfirmation {
@PrimaryGeneratedColumn("increment", { type: "int" })
id: number;
// Eagerly load this so we don't have to do this everywhere:
// relations: ["user"]
@OneToOne(() => User, { eager: true })
@JoinColumn()
user: User;
@Column({ type: "varchar", length: 200 })
@Index()
token: string;
@Column({ type: "varchar", length: 200 })
@Index()
email: string;
@Column({ type: "datetime" })
expires: Date;
}

View File

@ -0,0 +1,26 @@
import {
Column,
Entity,
Index,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { User } from "./user.entity.js";
@Entity("users_remembers")
export class UserRemember {
@PrimaryGeneratedColumn("increment", { type: "int" })
id: number;
@OneToOne(() => User, { eager: true })
@JoinColumn()
user: User;
@Column({ type: "varchar", length: 200 })
@Index()
token: string;
@Column({ type: "datetime" })
expires: Date;
}

View File

@ -0,0 +1,26 @@
import {
Column,
Entity,
Index,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { User } from "./user.entity.js";
@Entity("users_resets")
export class UserReset {
@PrimaryGeneratedColumn("increment", { type: "int" })
id: number;
@OneToOne(() => User, { eager: true })
@JoinColumn()
user: User;
@Column({ type: "varchar", length: 200 })
@Index()
token: string;
@Column({ type: "datetime" })
expires: Date;
}

View File

@ -0,0 +1,106 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from "typeorm";
export const AuthStatus = {
Normal: 0,
Archived: 1,
Banned: 2,
Locked: 3,
PendingReview: 4,
Suspended: 5,
} as const;
export const AuthRole = {
Admin: 1,
Author: 2,
Collaborator: 4,
Consultant: 8,
Consumer: 16,
Contributor: 32,
Coordinator: 64,
Creator: 128,
Developer: 256,
Director: 512,
Editor: 1024,
Employee: 2048,
Maintainer: 4096,
Manager: 8192,
Moderator: 16384,
Publisher: 32768,
Reviewer: 65536,
Subscriber: 131072,
SuperAdmin: 262144,
SuperEditor: 524288,
SuperModerator: 1048576,
Translator: 2097152,
// XX: 4194304,
// XX: 8388608,
// XX: 16777216,
// XX: 33554432,
// XX: 67108864,
// XX: 134217728,
// XX: 268435456,
// XX: 536870912,
} as const;
const createMapFromEnum = (enumObj: Record<string, number>) => {
return Object.fromEntries(
Object.entries(enumObj).map(([key, value]) => [value, key]),
);
};
export const getStatusMap = () => createMapFromEnum(AuthStatus);
export const getRoleMap = () => createMapFromEnum(AuthRole);
@Entity("users")
export class User {
@PrimaryGeneratedColumn("increment", { type: "int" })
id: number;
@Column({ type: "varchar", length: 50, nullable: true })
@Index()
username: string;
@Column({ type: "varchar", length: 100, unique: true })
email: string;
@Column({ type: "varchar", length: 1000 })
password: string;
@Column({ type: "int", default: AuthStatus.Normal })
status: number;
@Column({ type: "boolean", default: false })
verified: boolean;
@Column({ type: "boolean", default: true })
resettable: boolean;
@Column({ type: "int", default: 0 })
rolemask: number;
@Column({ type: "datetime" })
registered: Date;
@Column({ type: "datetime", nullable: true })
lastLogin: Date;
@Column({ type: "int", default: 0 })
forceLogout: number;
@CreateDateColumn({ type: "datetime" })
createdAt: Date;
@UpdateDateColumn({ type: "datetime" })
updatedAt: Date;
@DeleteDateColumn({ type: "datetime" })
deletedAt: Date;
}

View File

@ -0,0 +1,68 @@
import ms from "@prsm/ms";
import cookieParser from "cookie-parser";
import { randomBytes } from "crypto";
import express from "express";
import session, { MemoryStore } from "express-session";
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const isValidEmail = (email: string) => emailRegex.test(email);
// const isMiddlewareUsed = (app: express.Application, name: string) =>
// !!app._router.stack.filter(
// (layer: { handle: { name: string } }) =>
// layer && layer.handle && layer.handle.name === name,
// ).length;
// export const ensureRequiredMiddlewares = (app: express.Application) => {
// const requiredMiddlewares = [
// {
// name: "cookieParser",
// handler: () => cookieParser(),
// },
// {
// name: "session",
// handler: () =>
// session({
// store: new MemoryStore({ captureRejections: true }),
// name: "pine",
// secret: randomBytes(32).toString("hex"),
// resave: false,
// saveUninitialized: true,
// cookie: {
// secure: process.env.NODE_ENV === "production",
// maxAge: ms("30m"),
// httpOnly: !(process.env.NODE_ENV === "production"),
// sameSite: "lax",
// },
// }),
// },
// ];
// for (const { name, handler } of requiredMiddlewares) {
// if (!isMiddlewareUsed(app, name)) {
// console.warn(
// `Required middleware '${name}' not found. It will automatically be used and you may not agree with the default configuration.`,
// );
// app.use(handler());
// }
// }
// };
const isMiddlewareUsed = (app: express.Application, name: string) =>
!!app._router.stack.filter(
(layer: { handle: { name: string } }) =>
layer && layer.handle && layer.handle.name === name,
).length;
export const ensureRequiredMiddlewares = (app: express.Application) => {
const requiredMiddlewares = ["cookieParser", "session"];
for (const name of requiredMiddlewares) {
if (!isMiddlewareUsed(app, name)) {
throw new Error(
`Required middleware '${name}' not found. Please ensure it is added to your express application.`
);
}
}
};

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ESNext"
},
// "include": ["src", "express-session-auth.d.ts"]
}

View File

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

2
packages/hash/.npmignore Normal file
View File

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

43
packages/hash/README.md Normal file
View File

@ -0,0 +1,43 @@
# hash
A very simple string hashing library on top of `node:crypto`.
# Installation
`npm install @prsm/hash`
## create
```typescript
import hash from "@prsm/hash";
hash.create("an unencrypted string");
hash.create("an unencrypted string");
hash.create("an unencrypted string");
// sha256:UfH7lmEc5dr65iFPmvsKthzAgMHtdV6Qb4FXYSqlnOaQoZmqQWLBrPnJGLZmQontirQZKO9nTIz+zs544n0x7Q==:6qG75Cp5hysNWs+8TO65fzc1FaSZxykaWa3iatPrw4s=
// sha256:Wq6vrcGG4mKlM7r8DAuDHcYxJlG8fOEoO2sNWofl/snmsZPTaBuy8Dg6i2J28TdcncSgK8EhrCqgv69h5Kk2xA==:QvAc6op8ScJex38AYrZUtFDd69c4OJv5SsVIRgR+FPw=
// sha256:e16qmZpJiy1qvGycPkJz0qQnCdyAguGAFV8rqCokiFml10nl9lVU1v0hZ6QBy+laI0AYkHsYtt6wMkEOuNhpMw==:L3bHZeriSAjy8wEIz/fURxhOqxa8KltuvpHPE/nE/eQ=
```
## verify
```typescript
import hash from "@prsm/hash";
const valid = hash.verify(
"sha256:0SA+O819D52jZOqWuzIWa+KLyT+Ck+b5ze4HI7fAJOhRW3FYk527GnuVOS/pricLy1KqwUfk5wWyQx4z5x3fsA==:wPs8DRMOrZEJYeaPxZzccGPJSozGvNqRhhS6f8ITOyM=",
"an unencrypted string",
);
// valid = true
```
## custom hasher
```typescript
import { Hasher } from "@prsm/hash";
const hash = new Hasher("sha512", 128);
// hash.create("..");
// hash.verify("..", "sha512:...")
```

View File

@ -0,0 +1,7 @@
import { defineConfig } from "bumpp";
export default defineConfig({
commit: "%s release",
push: true,
tag: true,
});

BIN
packages/hash/bun.lockb Executable file

Binary file not shown.

View File

@ -0,0 +1,27 @@
{
"name": "@prsm/hash",
"version": "1.0.2",
"author": "nvms",
"main": "./dist/index.js",
"devDependencies": {
"@types/node": "^22.4.1",
"bumpp": "^9.1.1",
"tsup": "^8.2.4",
"typescript": "^5.1.6"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"description": "",
"keywords": [],
"license": "Apache-2.0",
"scripts": {
"build": "tsup",
"release": "bumpp package.json && npm publish --access public"
},
"type": "module"
}

View File

@ -0,0 +1,58 @@
import crypto from "node:crypto";
type Algorithm = "sha256" | "sha512";
class Hasher {
private algorithm: "sha256" | "sha512";
private saltLength: number;
constructor(algorithm: Algorithm = "sha256", saltLength: number = 64) {
this.algorithm = algorithm;
this.saltLength = saltLength;
}
verify(encoded: string, unencoded: string): boolean {
const { algorithm, salt } = this.parse(encoded);
return this.hash(unencoded, algorithm, salt) === encoded;
}
encode(string: string): string {
const salt = crypto.randomBytes(this.saltLength).toString("base64");
return this.hash(string, this.algorithm, salt);
}
hash(string: string, algorithm: Algorithm, salt: string): string {
const hash = crypto.createHash(algorithm);
hash.update(string);
hash.update(salt, "utf8");
return `${algorithm}:${salt}:${hash.digest("base64")}`;
}
private parse(encoded: string): {
algorithm: Algorithm;
salt: string;
digest: string;
} {
const parts = encoded.split(":");
if (parts.length !== 3) {
throw new Error(
`Invalid hash string. Expected 3 parts, got ${parts.length}`,
);
}
const algorithm: Algorithm = parts[0] as Algorithm;
const salt: string = parts[1];
const digest: string = parts[2];
return {
algorithm,
salt,
digest,
};
}
}
export default new Hasher();
export { Hasher, type Algorithm };

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"module": "es2022",
"target": "esnext",
"outDir": "dist",
"esModuleInterop": true,
"moduleResolution": "node",
"declaration": true,
"declarationDir": "dist"
}
}

View File

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

2
packages/ids/.npmignore Normal file
View File

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

30
packages/ids/README.md Normal file
View File

@ -0,0 +1,30 @@
# ids
[![NPM version](https://img.shields.io/npm/v/@prsm/ids?color=a1b858&label=)](https://www.npmjs.com/package/@prsm/ids)
Short, obfuscated, collision-proof, and reversible identifiers.
Because sometimes internal identifiers are sensitive, or you just don't want to let a user know that their ID is 1.
```typescript
import ID from "@prsm/ids";
ID.encode(12389125); // phsV8T
ID.decode("phsV8T"); // 12389125
```
You can (and should) set your own alphabet string:
```typescript
ID.alphabet = "GZwBHpfWybgQ5d_2mM-jh84K69tqYknx7LN3zvDrcSJVRPXsCFT";
ID.alphabet = "TgzMhJXtRSVBnHFksZQc5j-yGx84W3rNDfK6p_Cbqd29YLm7Pwv";
ID.alphabet = "kbHn53dZphT2FvGMBxYJKqS7-cPV_Ct6LwjWRDfXmygzrQ48N9s";
```
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.
```typescript
ID.randomizeAlphabet();
```

View File

@ -0,0 +1,7 @@
import { defineConfig } from "bumpp";
export default defineConfig({
commit: "%s release",
push: true,
tag: true,
});

BIN
packages/ids/bun.lockb Executable file

Binary file not shown.

30
packages/ids/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "@prsm/ids",
"version": "1.1.1",
"description": "",
"main": "./dist/index.js",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "tsup",
"test": "bun tests/index.ts",
"release": "bumpp package.json && npm publish --access public"
},
"author": "nvms",
"license": "Apache-2.0",
"dependencies": {
"long": "^5.2.3"
},
"devDependencies": {
"bumpp": "^9.5.1",
"manten": "^0.6.0",
"tsup": "^8.2.4",
"typescript": "^4.9.5"
}
}

74
packages/ids/src/index.ts Normal file
View File

@ -0,0 +1,74 @@
import long from "long";
export default class ID {
private static MAX_INT32 = 2_147_483_647;
private static MULTIPLIER = 4_294_967_296;
static alphabet: string =
"23456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ-_";
static prime: number = 1_125_812_041;
static inverse: number = 348_986_105;
static random: number = 998_048_641;
static get base(): number {
return ID.alphabet.length;
}
private static shorten(id: number): string {
let result = "";
while (id > 0) {
result = ID.alphabet[id % ID.base] + result;
id = Math.floor(id / ID.base);
}
return result;
}
private static unshorten(str: string): number {
let result = 0;
for (let i = 0; i < str.length; i++) {
result = result * ID.base + ID.alphabet.indexOf(str[i]);
}
return result;
}
static encode = (num: number): string => {
if (num > ID.MAX_INT32) {
throw new Error(
`Number (${num}) is too large to encode. MAX_INT32 is ${ID.MAX_INT32}`,
);
}
const n: long = long.fromInt(num);
return ID.shorten(
n
.multiply(ID.prime)
.and(long.fromInt(ID.MAX_INT32))
.xor(ID.random)
.toInt(),
);
};
static decode = (str: string): number => {
const n: long = long.fromInt(ID.unshorten(str));
return n
.xor(ID.random)
.multiply(ID.inverse)
.and(long.fromInt(ID.MAX_INT32))
.toInt();
};
static randomizeAlphabet(): void {
const array = ID.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('');
}
}

View File

@ -0,0 +1,36 @@
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);
})
});

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"module": "es2022",
"target": "esnext",
"outDir": "dist",
"esModuleInterop": true,
"moduleResolution": "node",
"declaration": true,
"declarationDir": "dist"
}
}

View File

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

2
packages/jwt/.npmignore Normal file
View File

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

43
packages/jwt/README.md Normal file
View File

@ -0,0 +1,43 @@
# jwt
A package for encoding, decoding, and verifying JWTs.
# Installation
`npm install @prsm/jwt`
## Encoding
```typescript
import { encode } from "@prsm/jwt";
const payload = {
iat: Date.now(),
exp: Date.now() + 3600,
};
const token = encode(payload, process.env.SIGNING_KEY);
```
## Verifying
```typescript
import { verify } from "@prsm/jwt";
const result = verify(token, process.env.SIGNING_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")
// token payload is available at result.decoded.payload
```
## Decoding
```typescript
import { decode } from "@prsm/jwt";
const result = decode(token);
// { header: { alg: "HS256", typ: "JWT" }, payload: { iat: 123456789, exp: 123456789 }, signature: "..."
```

View File

@ -0,0 +1,7 @@
import { defineConfig } from "bumpp";
export default defineConfig({
commit: "%s release",
push: true,
tag: true,
});

BIN
packages/jwt/bun.lockb Executable file

Binary file not shown.

31
packages/jwt/package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "@prsm/jwt",
"version": "1.0.8",
"description": "",
"main": "./dist/index.js",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "tsup",
"test": "bun tests/index.ts",
"release": "bumpp package.json && npm publish --access public"
},
"keywords": [],
"author": "nvms",
"license": "Apache-2.0",
"devDependencies": {
"@types/node": "^22.4.1",
"bumpp": "^9.5.1",
"tsup": "^8.2.4",
"typescript": "^5.5.4"
},
"dependencies": {
"ecdsa-sig-formatter": "^1.0.11"
}
}

314
packages/jwt/src/index.ts Normal file
View File

@ -0,0 +1,314 @@
import { derToJose, joseToDer } from "ecdsa-sig-formatter";
import crypto from "node:crypto";
export interface JWTPayload {
/** expiration */
exp?: number;
/** subject */
sub?: string | number;
/** issued at */
iat?: number;
/** not before */
nbf?: number;
/** jwt id */
jti?: number;
/** issuer */
iss?: string;
/** audience */
aud?: string | number;
/** whatever */
[k: string]: any;
}
export interface JWTHeader {
/** encoding alg used */
alg: string;
/** token type */
type: "JWT";
/** key id */
kid?: string;
}
export interface JWTParts {
header: JWTHeader;
payload: JWTPayload;
signature: Buffer;
}
export interface VerifyOptions {
alg?: string;
exp?: boolean;
sub?: string | number;
iat?: number;
nbf?: boolean;
jti?: number;
iss?: string;
aud?: string | number;
}
export interface VerifyResult {
/** true: signature is valid */
sig?: boolean;
/** true: payload.iat matches opts.iat */
iat?: boolean;
/** true: the current time is later or equal to payload.nbf, false: this jwt should NOT be accepted */
nbf?: boolean;
/** true: token is expired (payload.exp < now) */
exp?: boolean;
/** true: payload.jti matches opts.jti */
jti?: boolean;
/** true: payload.iss matches opts.iss */
iss?: boolean;
/** true: payload.sub matches opts.sub */
sub?: boolean;
/** true: payload.aud matches opts.aud */
aud?: boolean;
decoded: JWTParts;
}
const algorithms = [
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
] as const;
type Algorithm = (typeof algorithms)[number];
function isValidAlgorithm(alg: Algorithm): boolean {
return algorithms.includes(alg);
}
interface IAlgorithm {
sign(encoded: string, secret: string | Buffer): string;
verify(encoded: string, signature: string, secret: string | Buffer): boolean;
}
const Algorithms: { [k: string]: IAlgorithm } = {
HS256: createHmac(256),
HS384: createHmac(384),
HS512: createHmac(512),
RS256: createSign(256),
RS384: createSign(384),
RS512: createSign(512),
ES256: createEcdsa(256),
} as const;
function createHmac(bits: number): IAlgorithm {
function sign(encoded: string, secret: string | Buffer): string {
return crypto
.createHmac(`sha${bits}`, secret)
.update(encoded)
.digest("base64");
}
function verify(
encoded: string,
signature: string,
secret: string | Buffer,
): boolean {
return sign(encoded, secret) === signature;
}
return { sign, verify };
}
function createSign(bits: number): IAlgorithm {
const algorithm = `RSA-SHA${bits}`;
function sign(encoded: string, secret: string | Buffer): string {
return crypto
.createSign(algorithm)
.update(encoded)
.sign(secret.toString(), "base64");
}
function verify(
encoded: string,
signature: string,
secret: string | Buffer,
): boolean {
const v = crypto.createVerify(algorithm);
v.update(encoded);
return v.verify(secret, signature, "base64");
}
return { sign, verify };
}
function createEcdsa(bits: number): IAlgorithm {
const algorithm = `RSA-SHA${bits}`;
function sign(encoded: string, secret: string | Buffer): string {
const sig = crypto
.createSign(algorithm)
.update(encoded)
.sign({ key: secret.toString() }, "base64");
return derToJose(sig, `ES${bits}`);
}
function verify(
encoded: string,
signature: string,
secret: string | Buffer,
): boolean {
signature = joseToDer(signature, `ES${bits}`).toString("base64");
const v = crypto.createVerify(algorithm);
v.update(encoded);
return v.verify(secret, signature, "base64");
}
return { sign, verify };
}
function encodeJSONBase64(obj: any): string {
const j = JSON.stringify(obj);
return Base64ToURLEncoded(Buffer.from(j).toString("base64"));
}
function decodeJSONBase64(str: string) {
const dec = Buffer.from(URLEncodedToBase64(str), "base64").toString("utf-8");
try {
return JSON.parse(dec);
} catch (e) {
throw e;
}
}
function Base64ToURLEncoded(b64: string): string {
return b64.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}
function URLEncodedToBase64(enc: string): string {
enc = enc.toString();
const pad = 4 - (enc.length % 4);
if (pad !== 4) {
for (let i = 0; i < pad; i++) {
enc += "=";
}
}
return enc.replace(/\-/g, "+").replace(/_/g, "/");
}
/**
* Encodes a payload into a JWT string with a specified algorithm.
*
* @param {JWTPayload} payload - The payload to encode into the JWT.
* @param {string | Buffer} key - The secret key used to sign the JWT.
* @param {Algorithm} alg - The algorithm used to sign the JWT. Defaults to "HS256".
* @throws {Error} If an invalid algorithm type is provided.
* @returns {string} The encoded JWT string.
*/
function encode(
payload: JWTPayload,
key: string | Buffer,
alg: Algorithm = "HS256",
): string {
if (!isValidAlgorithm(alg)) {
throw new Error(
`${alg} is an invalid algorithm type. Must be one of ${algorithms}`,
);
}
const b64header = encodeJSONBase64({ alg, type: "JWT" });
const b64payload = encodeJSONBase64(payload);
const unsigned = `${b64header}.${b64payload}`;
const signer = Algorithms[alg];
const sig = Base64ToURLEncoded(signer.sign(unsigned, key));
return `${unsigned}.${sig}`;
}
/**
* Decodes a JWT-encoded string and returns an object containing the decoded header, payload, and signature.
*
* @param {string} encoded - The JWT-encoded string to decode.
* @throws {Error} If the encoded string does not have exactly three parts separated by periods.
* @returns {JWTParts} An object containing the decoded header, payload, and signature of the token.
*/
function decode(encoded: string): JWTParts {
const parts = encoded.split(".");
if (parts.length !== 3) {
throw new Error(
`Decode expected 3 parts to encoded token, got ${parts.length}`,
);
}
const header: JWTHeader = decodeJSONBase64(parts[0]);
const payload: JWTPayload = decodeJSONBase64(parts[1]);
const signature = Buffer.from(URLEncodedToBase64(parts[2]), "base64");
return { header, payload, signature };
}
/**
* Verifies an encoded token with the given secret key and options.
* @param encoded
* @param key Secret key used to verify the signature of the encoded token.
* @param opts The opts parameter of the verify function is an optional object that can contain the following properties:
* - alg: A string specifying the algorithm used to sign the token. If this property is not present in opts, the alg property from the decoded token header will be used.
* - iat: A number representing the timestamp when the token was issued. If present, this property will be compared to the iat claim in the token's payload.
* - iss: A string representing the issuer of the token. If present, this property will be compared to the iss claim in the token's payload.
* - jti: A string representing the ID of the token. If present, this property will be compared to the jti claim in the token's payload.
* - sub: A string representing the subject of the token. If present, this property will be compared to the sub claim in the token's payload.
* - aud: A string or number representing the intended audience(s) for the token. If present, this property will be compared to the aud claim in the token's payload.
* @returns
*/
function verify(
encoded: string,
key: string | Buffer,
opts: VerifyOptions = {},
): VerifyResult {
const decoded = decode(encoded);
const { payload } = decoded;
const parts = encoded.split(".");
const alg = opts.alg ?? decoded.header.alg ?? "HS256";
const now = Date.now();
const verifier = Algorithms[alg];
const result: VerifyResult = { decoded };
result.sig = verifier.verify(
`${parts[0]}.${parts[1]}`,
URLEncodedToBase64(parts[2]),
key,
);
if (payload.exp !== undefined) {
result.exp = payload.exp < now;
}
if (payload.nbf !== undefined) {
result.nbf = now >= payload.nbf;
}
if (opts.iat !== undefined) {
result.iat = payload.iat === opts.iat;
}
if (opts.iss !== undefined) {
result.iss = payload.iss === opts.iss;
}
if (opts.jti !== undefined) {
result.jti = payload.jti !== opts.jti;
}
if (opts.sub !== undefined) {
result.sub = payload.sub === opts.sub;
}
if (opts.aud !== undefined) {
result.aud = payload.aud === opts.aud;
}
return result;
}
const jwt = { encode, decode, verify };
export { decode, encode, verify };
export default jwt;

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"module": "es2022",
"target": "esnext",
"outDir": "dist",
"esModuleInterop": true,
"moduleResolution": "node",
"declaration": true,
"declarationDir": "dist"
}
}

View File

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

View File

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

View File

@ -0,0 +1,98 @@
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.
Built for [grove](https://github.com/node-prism/grove), but works anywhere.
### Server
For node.
```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,
});
ws.registerCommand(
"authenticate",
async (c: WSContext) => {
// use c.payload to authenticate c.connection
return { ok: true, token: "..." };
},
);
ws.registerCommand(
"throws",
async (c: WSContext) => {
throw new Error("oops");
},
);
```
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.
```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.
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.
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" }
ws.on("latency", (e: CustomEvent<{ latency: number }>) => {
// e.detail.latency is round-trip time in ms
});
```

View File

@ -0,0 +1,7 @@
import { defineConfig } from "bumpp";
export default defineConfig({
commit: "%s release",
push: true,
tag: true,
});

BIN
packages/keepalive-ws/bun.lockb Executable file

Binary file not shown.

View File

@ -0,0 +1,49 @@
{
"name": "@prsm/keepalive-ws",
"version": "0.3.1",
"description": "",
"type": "module",
"main": "./dist/server/index.js",
"exports": {
".": "./dist/index.js",
"./server": {
"types": "./dist/server/index.d.ts",
"import": "./dist/server/index.js",
"require": "./dist/server/index.cjs"
},
"./client": {
"types": "./dist/client/index.d.ts",
"import": "./dist/client/index.js",
"require": "./dist/client/index.cjs"
}
},
"typesVersions": {
"*": {
"server": [
"dist/server/index.d.ts"
],
"client": [
"dist/client/index.d.ts"
]
}
},
"scripts": {
"dev": "tsc --watch",
"build:prep": "rm -rf dist && mkdir dist && mkdir dist/server && mkdir dist/client",
"build:server": "tsup src/server/index.ts --format cjs,esm --dts --clean --minify --out-dir dist/server",
"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",
"release": "bumpp package.json && npm publish --access public"
},
"keywords": [],
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.9.0"
},
"devDependencies": {
"@types/ws": "^8.5.3",
"bumpp": "^9.1.1",
"tsup": "^8.2.4",
"typescript": "^5.5.4"
}
}

View File

@ -0,0 +1,157 @@
import { Connection } from "./connection";
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.
* This number should match the server's `pingTimeout` option.
* @default 30000
* @see maxLatency.
*/
pingTimeout: number;
/**
* This number plus @see pingTimeout is the maximum amount of time that can pass before the connection is considered closed.
* @default 2000
*/
maxLatency: number;
/**
* Whether or not to reconnect automatically.
* @default true
*/
shouldReconnect: boolean;
/**
* The number of milliseconds to wait between reconnect attempts.
* @default 2000
*/
reconnectInterval: number;
/**
* The number of times to attempt to reconnect before giving up and
* emitting a `reconnectfailed` event.
* @default Infinity
*/
maxReconnectAttempts: number;
}>;
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 {
connection: Connection;
url: string;
socket: WebSocket;
pingTimeout: ReturnType<typeof setTimeout>;
options: KeepAliveClientOptions;
isReconnecting = false;
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();
}
get on() {
return this.connection.addEventListener.bind(this.connection);
}
applyListeners() {
this.connection.addEventListener("connection", () => {
this.heartbeat();
});
this.connection.addEventListener("close", () => {
this.reconnect();
});
this.connection.addEventListener("ping", () => {
this.heartbeat();
});
this.connection.addEventListener(
"message",
(ev: CustomEventInit<unknown>) => {
this.dispatchEvent(new CustomEvent("message", ev));
},
);
}
heartbeat() {
clearTimeout(this.pingTimeout);
this.pingTimeout = setTimeout(() => {
if (this.options.shouldReconnect) {
this.reconnect();
}
}, this.options.pingTimeout + this.options.maxLatency);
}
async reconnect() {
if (this.isReconnecting) {
return;
}
this.isReconnecting = true;
let attempt = 1;
if (this.socket) {
try {
this.socket.close();
} catch (e) {}
}
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"));
}
};
this.socket.onopen = () => {
this.isReconnecting = false;
this.connection.socket = this.socket;
this.connection.applyListeners(true);
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"));
};
};
connect();
}
async command(
command: string,
payload?: any,
expiresIn?: number,
callback?: Function,
) {
return this.connection.command(command, payload, expiresIn, callback);
}
}

View File

@ -0,0 +1,198 @@
import { IdManager } from "./ids";
import { Queue, QueueItem } from "./queue";
type Command = {
id?: number;
command: string;
payload?: any;
};
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;
ids = new IdManager();
queue = new Queue();
callbacks: { [id: number]: (error: Error | null, result?: any) => void } = {};
constructor(socket: WebSocket) {
super();
this.socket = 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);
}
sendToken(cmd: Command, expiresIn: number) {
try {
this.socket.send(JSON.stringify(cmd));
} catch (e) {
this.queue.add(cmd, expiresIn);
}
}
applyListeners(reconnection = false) {
const drainQueue = () => {
while (!this.queue.isEmpty) {
const item = this.queue.pop() as QueueItem;
this.sendToken(item.value, item.expiresIn);
}
};
if (reconnection) drainQueue();
// @ts-ignore
this.socket.onopen = (socket: WebSocket, ev: Event): any => {
drainQueue();
this.dispatchEvent(new Event("connection"));
this.dispatchEvent(new Event("connected"));
this.dispatchEvent(new Event("connect"));
};
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.onmessage = async (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
this.dispatchEvent(new CustomEvent("message", { detail: data }));
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);
} else if (data.command === "latency") {
this.dispatchEvent(
new CustomEvent<LatencyPayload>(
"latency",
{ detail: { latency: data.payload ?? undefined }}
)
);
} else if (data.command === "ping") {
this.dispatchEvent(new CustomEvent("ping", {}));
this.command("pong", {}, null);
} else {
this.dispatchEvent(new CustomEvent(data.command, { detail: data.payload }));
}
if (this.callbacks[data.id]) {
this.callbacks[data.id](null, data.payload);
}
} catch (e) {
this.dispatchEvent(new Event("error"));
}
};
}
async command(command: string, payload: any, expiresIn: number = 30_000, callback: Function | null = null) {
const id = this.ids.reserve();
const cmd = { id, command, payload: payload ?? {} };
this.sendToken(cmd, expiresIn);
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(() => {
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 {
resolve(result);
}
};
});
}
}

View File

@ -0,0 +1,44 @@
export class IdManager {
ids: Array<true | false> = [];
index: number = 0;
maxIndex: number;
constructor(maxIndex: number = 2 ** 16 - 1) {
this.maxIndex = maxIndex;
}
release(id: number) {
if (id < 0 || id > this.maxIndex) {
throw new TypeError(
`ID must be between 0 and ${this.maxIndex}. Got ${id}.`,
);
}
this.ids[id] = false;
}
reserve(): number {
const startIndex = this.index;
while (true) {
const i = this.index;
if (!this.ids[i]) {
this.ids[i] = true;
return i;
}
if (this.index >= this.maxIndex) {
this.index = 0;
} else {
this.index++;
}
if (this.index === startIndex) {
throw new Error(
`All IDs are reserved. Make sure to release IDs when they are no longer used.`,
);
}
}
}
}

View File

@ -0,0 +1,2 @@
export { KeepAliveClient } from "./client";
export { Connection } from "./connection";

View File

@ -0,0 +1,50 @@
export class QueueItem {
value: any;
expireTime: number;
constructor(value: any, expiresIn: number) {
this.value = value;
this.expireTime = Date.now() + expiresIn;
}
get expiresIn() {
return this.expireTime - Date.now();
}
get isExpired() {
return Date.now() > this.expireTime;
}
}
export class Queue {
items: any[] = [];
add(item: any, expiresIn: number) {
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;
}
pop(): QueueItem | null {
while (this.items.length) {
const item = this.items.shift() as QueueItem;
if (!item.isExpired) {
return item;
}
}
return null;
}
}

View File

@ -0,0 +1,2 @@
export { KeepAliveClient } from "./client";
export { KeepAliveServer } from "./server";

View File

@ -0,0 +1,19 @@
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: {} };
}
};

View File

@ -0,0 +1,88 @@
import EventEmitter from "node:events";
import { IncomingMessage } from "node:http";
import { WebSocket } from "ws";
import { KeepAliveServerOptions } from ".";
import { bufferToCommand, Command } from "./command";
import { Latency } from "./latency";
import { Ping } from "./ping";
export class Connection extends EventEmitter {
id: string;
socket: WebSocket;
alive = true;
latency: Latency;
ping: Ping;
remoteAddress: string;
connectionOptions: KeepAliveServerOptions;
constructor(
socket: WebSocket,
req: IncomingMessage,
options: KeepAliveServerOptions,
) {
super();
this.socket = socket;
this.id = req.headers["sec-websocket-key"]!;
this.remoteAddress = req.socket.remoteAddress!;
this.connectionOptions = options;
this.applyListeners();
this.startIntervals();
}
startIntervals() {
this.latency = new Latency();
this.ping = new Ping();
this.latency.interval = setInterval(() => {
if (!this.alive) {
return;
}
if (typeof this.latency.ms === "number") {
this.send({ command: "latency", payload: this.latency.ms });
}
this.latency.onRequest();
this.send({ command: "latency:request", payload: {} });
}, this.connectionOptions.latencyInterval);
this.ping.interval = setInterval(() => {
if (!this.alive) {
this.emit("close");
}
this.alive = false;
this.send({ command: "ping", payload: {} });
}, this.connectionOptions.pingInterval);
}
stopIntervals() {
clearInterval(this.latency.interval);
clearInterval(this.ping.interval);
}
applyListeners() {
this.socket.on("close", () => {
this.emit("close");
});
this.socket.on("message", (buffer: Buffer) => {
const command = bufferToCommand(buffer);
if (command.command === "latency:response") {
this.latency.onResponse();
return;
} else if (command.command === "pong") {
this.alive = true;
return;
}
this.emit("message", buffer);
});
}
send(cmd: Command) {
this.socket.send(JSON.stringify(cmd));
}
}

View File

@ -0,0 +1,294 @@
import { IncomingMessage } from "node:http";
import { ServerOptions, WebSocket, WebSocketServer } from "ws";
import { bufferToCommand } from "./command";
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;
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;
connection: Connection;
payload: any;
constructor(wss: KeepAliveServer, connection: Connection, payload: any) {
this.wss = wss;
this.connection = connection;
this.payload = payload;
}
}
export type SocketMiddleware = (c: WSContext) => any | Promise<any>;
export type KeepAliveServerOptions = ServerOptions & {
/**
* The interval at which to send ping messages to the client.
* @default 30000
*/
pingInterval?: number;
/**
* The interval at which to send both latency requests and updates to the client.
* @default 5000
*/
latencyInterval?: number;
};
export class KeepAliveServer extends WebSocketServer {
connections: { [id: string]: Connection } = {};
remoteAddressToConnections: { [address: string]: Connection[] } = {};
commands: { [command: string]: (context: WSContext) => Promise<void> } = {};
globalMiddlewares: SocketMiddleware[] = [];
middlewares: { [key: string]: SocketMiddleware[] } = {};
rooms: { [roomName: string]: Set<string> } = {};
declare serverOptions: KeepAliveServerOptions;
constructor(opts: KeepAliveServerOptions) {
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
);
}
if (!this.remoteAddressToConnections[c.remoteAddress].length) {
delete this.remoteAddressToConnections[c.remoteAddress];
}
}
private applyListeners() {
this.on("connection", (socket: WebSocket, req: IncomingMessage) => {
const connection = new Connection(socket, req, this.serverOptions);
this.connections[connection.id] = connection;
if (!this.remoteAddressToConnections[connection.remoteAddress]) {
this.remoteAddressToConnections[connection.remoteAddress] = [];
}
this.remoteAddressToConnections[connection.remoteAddress].push(connection);
this.emit("connected", connection);
connection.once("close", () => {
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("message", (buffer: Buffer) => {
try {
const { id, command, payload } = bufferToCommand(buffer);
this.runCommand(id ?? 0, command, payload, connection);
} catch (e) {
this.emit("error", e);
}
});
});
}
broadcast(command: string, payload: any, connections?: Connection[]) {
const cmd = JSON.stringify({ command, payload });
if (connections) {
connections.forEach((c) => {
c.socket.send(cmd);
});
return;
}
Object.values(this.connections).forEach((c) => {
c.socket.send(cmd);
});
}
/**
* Given a Connection, broadcasts only to all other Connections that share
* the same connection.remoteAddress.
*
* Use cases:
* - 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);
});
}
broadcastRemoteAddressById(id: string, command: string, payload: any) {
const connection = this.connections[id];
if (connection) {
this.broadcastRemoteAddress(connection, command, payload);
}
}
/**
* 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);
}
});
}
/**
* 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);
}
});
}
/**
* @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 });
* });
* ```
*/
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);
}
/**
* Returns a "room", which is simply a Set of Connection ids.
* @param roomName
*/
getRoom(roomName: string): Connection[] {
const ids = this.rooms[roomName] || new Set();
return Array.from(ids).map((id) => this.connections[id]);
}
clearRoom(roomName: string) {
this.rooms[roomName] = new Set();
}
registerCommand(command: string, callback: SocketMiddleware, middlewares: SocketMiddleware[] = []) {
this.commands[command] = callback;
this.prependMiddlewareToCommand(command, middlewares);
}
prependMiddlewareToCommand(command: string, middlewares: SocketMiddleware[]) {
if (middlewares.length) {
this.middlewares[command] = this.middlewares[command] || [];
this.middlewares[command] = middlewares.concat(this.middlewares[command]);
}
}
appendMiddlewareToCommand(command: string, middlewares: SocketMiddleware[]) {
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);
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.`);
}
if (this.globalMiddlewares.length) {
for (const mw of this.globalMiddlewares) {
await mw(c);
}
}
if (this.middlewares[command]) {
for (const mw of this.middlewares[command]) {
await mw(c);
}
}
const result = await this.commands[command](c);
connection.send({ id, command, payload: result });
} catch (e) {
const payload = { error: e.message ?? e ?? "Unknown error" };
connection.send({ id, command, payload });
}
}
}
export { Connection };

View File

@ -0,0 +1,15 @@
export class Latency {
start = 0;
end = 0;
ms = 0;
interval: ReturnType<typeof setTimeout>;
onRequest() {
this.start = Date.now();
}
onResponse() {
this.end = Date.now();
this.ms = this.end - this.start;
}
}

View File

@ -0,0 +1,3 @@
export class Ping {
interval: ReturnType<typeof setTimeout>;
}

View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"module": "es2022",
"target": "es2021",
"moduleResolution": "node",
"outDir": "./lib",
"sourceMap": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"allowSyntheticDefaultImports": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"baseUrl": ".",
"declaration": true,
"declarationMap": true
},
"exclude": ["node_modules", "lib"]
}

2
packages/ms/.npmignore Normal file
View File

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

33
packages/ms/README.md Normal file
View File

@ -0,0 +1,33 @@
# ms
[![NPM version](https://img.shields.io/npm/v/@prsm/ms?color=a1b858&label=)](https://www.npmjs.com/package/@prsm/ms)
Confusingly, not just for converting milliseconds.
```typescript
import ms from "@prsm/ms";
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
ms("0h"); // 0
ms("10.9ms"); // 11
ms("10.9ms", { round: false }); // 10.9
ms("1000.9ms", { round: false, unit: "s" }); // 1.0009
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
```

View File

@ -0,0 +1,7 @@
import { defineConfig } from "bumpp";
export default defineConfig({
commit: "%s release",
push: true,
tag: true,
});

BIN
packages/ms/bun.lockb Executable file

Binary file not shown.

26
packages/ms/package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "@prsm/ms",
"version": "1.0.1",
"author": "",
"main": "./dist/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"description": "",
"keywords": [],
"license": "Apache-2.0",
"scripts": {
"build": "tsup",
"release": "bumpp package.json && npm publish --access public"
},
"type": "module",
"devDependencies": {
"@types/node": "^22.4.1",
"bumpp": "^9.5.1",
"tsup": "^8.2.4"
}
}

174
packages/ms/src/index.ts Normal file
View File

@ -0,0 +1,174 @@
const MS_IN = {
w: 604_800_000,
wk: 604_800_000,
wks: 604_800_000,
week: 604_800_000,
weeks: 604_800_000,
d: 86_400_000,
dy: 86_400_000,
day: 86_400_000,
days: 86_400_000,
h: 3_600_000,
hr: 3_600_000,
hrs: 3_600_000,
hour: 3_600_000,
hours: 3_600_000,
m: 60_000,
mn: 60_000,
min: 60_000,
mins: 60_000,
minute: 60_000,
minutes: 60_000,
s: 1_000,
sec: 1_000,
secs: 1_000,
second: 1_000,
seconds: 1_000,
ms: 1,
msec: 1,
msecs: 1,
millisec: 1,
millisecond: 1,
milliseconds: 1,
};
const UNIT_ALIAS = {
w: "week",
wk: "week",
wks: "week",
week: "week",
weeks: "week",
d: "day",
dy: "day",
day: "day",
days: "day",
h: "hour",
hr: "hour",
hrs: "hour",
hour: "hour",
hours: "hour",
m: "minute",
mn: "minute",
min: "minute",
mins: "minute",
minute: "minute",
minutes: "minute",
s: "second",
sec: "second",
secs: "second",
second: "second",
seconds: "second",
ms: "ms",
msec: "ms",
msecs: "ms",
millisec: "ms",
millisecond: "ms",
milliseconds: "ms",
};
const msRegex = /(-?)([\d\s\-_,.]+)\s*([a-zA-Z]*)/g;
const sanitizeRegex = /[\s\-_,]/g;
const resultCache = {};
function isValid(input: any) {
return (
(typeof input === "string" && input.length > 0) ||
(typeof input === "number" &&
input > -Infinity &&
input < Infinity &&
!isNaN(input))
);
}
function ms(msString: any, defaultOrOptions: any = {}, options: any = {}) {
if (defaultOrOptions && typeof defaultOrOptions === "object") {
options = defaultOrOptions;
defaultOrOptions = 0;
}
let defaultMsString = isValid(defaultOrOptions) ? defaultOrOptions : 0;
const { unit = "ms", round = true } = options;
const cacheKey = `${msString}${defaultMsString}${unit}${round}`;
const cacheExists = cacheKey in resultCache;
if (cacheExists) {
return resultCache[cacheKey];
}
// if defaultDuration is a string, it's something like "1day". we need to
// call ms() on it to get the number of milliseconds it represents.
if (typeof defaultMsString === "string") {
defaultMsString = ms(defaultMsString, 0);
}
let parsed = parseMs(msString, defaultMsString);
parsed = convertToUnit(parsed, unit);
parsed = applyRounding(parsed, round);
if (!cacheExists) {
resultCache[cacheKey] = parsed;
}
return parsed;
}
function parseMs(msString: any, defaultMsString: number): number {
const ms = isValid(msString) ? msString : defaultMsString;
const re = new RegExp(msRegex);
if (typeof ms === "string") {
let totalMs = 0;
if (ms.length > 0) {
let matches: string[];
let anyMatches = false;
while ((matches = re.exec(ms)!)) {
anyMatches = true;
let value = parseFloat(matches[2].replace(sanitizeRegex, ""));
if (matches[1]) {
value = -value;
}
if (!isNaN(value)) {
const unitKey = UNIT_ALIAS[matches[3].toLowerCase()] || "ms";
totalMs += value * MS_IN[unitKey];
}
}
if (!anyMatches) {
return defaultMsString ?? 0;
}
}
return totalMs;
}
return ms;
}
function convertToUnit(ms: number, unit: string): number {
if (unit in MS_IN) {
ms /= MS_IN[unit];
} else {
return 0;
}
return ms;
}
function applyRounding(ms: number, round: boolean): number {
if (ms !== 0 && round) {
ms = Math.round(ms);
if (ms === 0) {
return Math.abs(ms);
}
}
return ms;
}
export default ms;

11
packages/ms/tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"module": "es2022",
"target": "esnext",
"outDir": "dist",
"esModuleInterop": true,
"moduleResolution": "node",
"declaration": true,
"declarationDir": "dist"
}
}

View File

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

2
packages/otp/.npmignore Normal file
View File

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

Some files were not shown because too many files have changed in this diff Show More