mirror of
https://github.com/nvms/prsm.git
synced 2025-12-13 07:20:52 +00:00
relocate these
This commit is contained in:
commit
0a763d2ec5
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
^.env$
|
||||
|
||||
packages/smol
|
||||
docs
|
||||
examples
|
||||
23
.prettierrc
Normal file
23
.prettierrc
Normal 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
73
LICENSE.md
Normal 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
13
README.md
Normal 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 [](https://www.npmjs.com/package/@prsm/arc)
|
||||
* @prsm/duplex [](https://www.npmjs.com/package/@prsm/duplex)
|
||||
* @prsm/express-keepalive-ws [](https://www.npmjs.com/package/@prsm/express-keepalive-ws)
|
||||
* @prsm/express-session-auth [](https://www.npmjs.com/package/@prsm/express-session-auth)
|
||||
* @prsm/hash [](https://www.npmjs.com/package/@prsm/hash)
|
||||
* @prsm/ids [](https://www.npmjs.com/package/@prsm/ids)
|
||||
* @prsm/jwt [](https://www.npmjs.com/package/@prsm/jwt)
|
||||
* @prsm/keepalive-ws [](https://www.npmjs.com/package/@prsm/keepalive-ws)
|
||||
* @prsm/ms [](https://www.npmjs.com/package/@prsm/ms)
|
||||
1
packages/arc
Submodule
1
packages/arc
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 595e0b41961a6504b957173fa6470e4e21d16296
|
||||
2
packages/duplex/.npmignore
Normal file
2
packages/duplex/.npmignore
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
src
|
||||
82
packages/duplex/README.md
Normal file
82
packages/duplex/README.md
Normal file
@ -0,0 +1,82 @@
|
||||
# duplex
|
||||
|
||||
[](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);
|
||||
```
|
||||
7
packages/duplex/bump.config.ts
Normal file
7
packages/duplex/bump.config.ts
Normal 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
BIN
packages/duplex/bun.lockb
Executable file
Binary file not shown.
27
packages/duplex/package.json
Normal file
27
packages/duplex/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
230
packages/duplex/src/client/commandclient.ts
Normal file
230
packages/duplex/src/client/commandclient.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
51
packages/duplex/src/client/queue.ts
Normal file
51
packages/duplex/src/client/queue.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
15
packages/duplex/src/common/codeerror.ts
Normal file
15
packages/duplex/src/common/codeerror.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
25
packages/duplex/src/common/command.ts
Normal file
25
packages/duplex/src/common/command.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
70
packages/duplex/src/common/connection.ts
Normal file
70
packages/duplex/src/common/connection.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
37
packages/duplex/src/common/errorserializer.ts
Normal file
37
packages/duplex/src/common/errorserializer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
65
packages/duplex/src/common/message.ts
Normal file
65
packages/duplex/src/common/message.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
6
packages/duplex/src/common/status.ts
Normal file
6
packages/duplex/src/common/status.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export enum Status {
|
||||
ONLINE = 3,
|
||||
CONNECTING = 2,
|
||||
CLOSED = 1,
|
||||
OFFLINE = 0,
|
||||
}
|
||||
27
packages/duplex/src/example/client.ts
Normal file
27
packages/duplex/src/example/client.ts
Normal 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();
|
||||
18
packages/duplex/src/example/server.ts
Normal file
18
packages/duplex/src/example/server.ts
Normal 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);
|
||||
});
|
||||
5
packages/duplex/src/index.ts
Normal file
5
packages/duplex/src/index.ts
Normal 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";
|
||||
179
packages/duplex/src/server/commandserver.ts
Normal file
179
packages/duplex/src/server/commandserver.ts
Normal 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 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
40
packages/duplex/src/server/ids.ts
Normal file
40
packages/duplex/src/server/ids.ts
Normal 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.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
packages/duplex/tsconfig.json
Normal file
11
packages/duplex/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "es2022",
|
||||
"target": "esnext",
|
||||
"outDir": "dist",
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"declarationDir": "dist"
|
||||
}
|
||||
}
|
||||
11
packages/duplex/tsup.config.ts
Normal file
11
packages/duplex/tsup.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
minify: true,
|
||||
sourcemap: "inline",
|
||||
target: "esnext",
|
||||
});
|
||||
2
packages/express-keepalive-ws/.npmignore
Normal file
2
packages/express-keepalive-ws/.npmignore
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
src
|
||||
43
packages/express-keepalive-ws/README.md
Normal file
43
packages/express-keepalive-ws/README.md
Normal 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!"
|
||||
```
|
||||
7
packages/express-keepalive-ws/bump.config.ts
Normal file
7
packages/express-keepalive-ws/bump.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "bumpp";
|
||||
|
||||
export default defineConfig({
|
||||
commit: "%s release",
|
||||
push: true,
|
||||
tag: true,
|
||||
});
|
||||
BIN
packages/express-keepalive-ws/bun.lockb
Executable file
BIN
packages/express-keepalive-ws/bun.lockb
Executable file
Binary file not shown.
30
packages/express-keepalive-ws/package.json
Normal file
30
packages/express-keepalive-ws/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
65
packages/express-keepalive-ws/src/index.ts
Normal file
65
packages/express-keepalive-ws/src/index.ts
Normal 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";
|
||||
11
packages/express-keepalive-ws/tsconfig.json
Normal file
11
packages/express-keepalive-ws/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "es2022",
|
||||
"target": "esnext",
|
||||
"outDir": "dist",
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"declarationDir": "dist"
|
||||
}
|
||||
}
|
||||
11
packages/express-keepalive-ws/tsup.config.ts
Normal file
11
packages/express-keepalive-ws/tsup.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
minify: true,
|
||||
sourcemap: "inline",
|
||||
target: "esnext",
|
||||
});
|
||||
2
packages/express-session-auth/.npmignore
Normal file
2
packages/express-session-auth/.npmignore
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
src
|
||||
86
packages/express-session-auth/README.md
Normal file
86
packages/express-session-auth/README.md
Normal 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"
|
||||
]
|
||||
}
|
||||
```
|
||||
7
packages/express-session-auth/bump.config.ts
Normal file
7
packages/express-session-auth/bump.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "bumpp";
|
||||
|
||||
export default defineConfig({
|
||||
commit: "%s release",
|
||||
push: true,
|
||||
tag: true,
|
||||
});
|
||||
BIN
packages/express-session-auth/bun.lockb
Executable file
BIN
packages/express-session-auth/bun.lockb
Executable file
Binary file not shown.
10
packages/express-session-auth/express-session-auth.d.ts
vendored
Normal file
10
packages/express-session-auth/express-session-auth.d.ts
vendored
Normal 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>;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
packages/express-session-auth/package.json
Normal file
46
packages/express-session-auth/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
118
packages/express-session-auth/src/errors.ts
Normal file
118
packages/express-session-auth/src/errors.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
5
packages/express-session-auth/src/index.ts
Normal file
5
packages/express-session-auth/src/index.ts
Normal 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";
|
||||
1489
packages/express-session-auth/src/middleware.ts
Normal file
1489
packages/express-session-auth/src/middleware.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
}
|
||||
26
packages/express-session-auth/src/user-remember.entity.ts
Normal file
26
packages/express-session-auth/src/user-remember.entity.ts
Normal 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;
|
||||
}
|
||||
26
packages/express-session-auth/src/user-reset.entity.ts
Normal file
26
packages/express-session-auth/src/user-reset.entity.ts
Normal 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;
|
||||
}
|
||||
106
packages/express-session-auth/src/user.entity.ts
Normal file
106
packages/express-session-auth/src/user.entity.ts
Normal 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;
|
||||
}
|
||||
68
packages/express-session-auth/src/util.ts
Normal file
68
packages/express-session-auth/src/util.ts
Normal 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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
11
packages/express-session-auth/tsconfig.json
Normal file
11
packages/express-session-auth/tsconfig.json
Normal 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"]
|
||||
}
|
||||
11
packages/express-session-auth/tsup.config.ts
Normal file
11
packages/express-session-auth/tsup.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
minify: true,
|
||||
sourcemap: "inline",
|
||||
target: "esnext",
|
||||
});
|
||||
2
packages/hash/.npmignore
Normal file
2
packages/hash/.npmignore
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
src
|
||||
43
packages/hash/README.md
Normal file
43
packages/hash/README.md
Normal 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:...")
|
||||
```
|
||||
7
packages/hash/bump.config.ts
Normal file
7
packages/hash/bump.config.ts
Normal 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
BIN
packages/hash/bun.lockb
Executable file
Binary file not shown.
27
packages/hash/package.json
Normal file
27
packages/hash/package.json
Normal 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"
|
||||
}
|
||||
58
packages/hash/src/index.ts
Normal file
58
packages/hash/src/index.ts
Normal 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 };
|
||||
11
packages/hash/tsconfig.json
Normal file
11
packages/hash/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "es2022",
|
||||
"target": "esnext",
|
||||
"outDir": "dist",
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"declarationDir": "dist"
|
||||
}
|
||||
}
|
||||
11
packages/hash/tsup.config.ts
Normal file
11
packages/hash/tsup.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
minify: true,
|
||||
sourcemap: "inline",
|
||||
target: "esnext",
|
||||
});
|
||||
2
packages/ids/.npmignore
Normal file
2
packages/ids/.npmignore
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
src
|
||||
30
packages/ids/README.md
Normal file
30
packages/ids/README.md
Normal file
@ -0,0 +1,30 @@
|
||||
# ids
|
||||
|
||||
[](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();
|
||||
```
|
||||
7
packages/ids/bump.config.ts
Normal file
7
packages/ids/bump.config.ts
Normal 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
BIN
packages/ids/bun.lockb
Executable file
Binary file not shown.
30
packages/ids/package.json
Normal file
30
packages/ids/package.json
Normal 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
74
packages/ids/src/index.ts
Normal 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('');
|
||||
}
|
||||
}
|
||||
36
packages/ids/tests/index.ts
Normal file
36
packages/ids/tests/index.ts
Normal 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);
|
||||
})
|
||||
});
|
||||
11
packages/ids/tsconfig.json
Normal file
11
packages/ids/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "es2022",
|
||||
"target": "esnext",
|
||||
"outDir": "dist",
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"declarationDir": "dist"
|
||||
}
|
||||
}
|
||||
11
packages/ids/tsup.config.ts
Normal file
11
packages/ids/tsup.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
minify: true,
|
||||
sourcemap: "inline",
|
||||
target: "esnext",
|
||||
});
|
||||
2
packages/jwt/.npmignore
Normal file
2
packages/jwt/.npmignore
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
src
|
||||
43
packages/jwt/README.md
Normal file
43
packages/jwt/README.md
Normal 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: "..."
|
||||
```
|
||||
7
packages/jwt/bump.config.ts
Normal file
7
packages/jwt/bump.config.ts
Normal 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
BIN
packages/jwt/bun.lockb
Executable file
Binary file not shown.
31
packages/jwt/package.json
Normal file
31
packages/jwt/package.json
Normal 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
314
packages/jwt/src/index.ts
Normal 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;
|
||||
11
packages/jwt/tsconfig.json
Normal file
11
packages/jwt/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "es2022",
|
||||
"target": "esnext",
|
||||
"outDir": "dist",
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"declarationDir": "dist"
|
||||
}
|
||||
}
|
||||
11
packages/jwt/tsup.config.ts
Normal file
11
packages/jwt/tsup.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
minify: true,
|
||||
sourcemap: "inline",
|
||||
target: "esnext",
|
||||
});
|
||||
2
packages/keepalive-ws/.npmignore
Normal file
2
packages/keepalive-ws/.npmignore
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
src
|
||||
98
packages/keepalive-ws/README.md
Normal file
98
packages/keepalive-ws/README.md
Normal 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
|
||||
});
|
||||
```
|
||||
7
packages/keepalive-ws/bump.config.ts
Normal file
7
packages/keepalive-ws/bump.config.ts
Normal 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
BIN
packages/keepalive-ws/bun.lockb
Executable file
Binary file not shown.
49
packages/keepalive-ws/package.json
Normal file
49
packages/keepalive-ws/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
157
packages/keepalive-ws/src/client/client.ts
Normal file
157
packages/keepalive-ws/src/client/client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
198
packages/keepalive-ws/src/client/connection.ts
Normal file
198
packages/keepalive-ws/src/client/connection.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
44
packages/keepalive-ws/src/client/ids.ts
Normal file
44
packages/keepalive-ws/src/client/ids.ts
Normal file
@ -0,0 +1,44 @@
|
||||
export class IdManager {
|
||||
ids: Array<true | false> = [];
|
||||
index: number = 0;
|
||||
maxIndex: number;
|
||||
|
||||
constructor(maxIndex: number = 2 ** 16 - 1) {
|
||||
this.maxIndex = maxIndex;
|
||||
}
|
||||
|
||||
release(id: number) {
|
||||
if (id < 0 || id > this.maxIndex) {
|
||||
throw new TypeError(
|
||||
`ID must be between 0 and ${this.maxIndex}. Got ${id}.`,
|
||||
);
|
||||
}
|
||||
this.ids[id] = false;
|
||||
}
|
||||
|
||||
reserve(): number {
|
||||
const startIndex = this.index;
|
||||
|
||||
while (true) {
|
||||
const i = this.index;
|
||||
|
||||
if (!this.ids[i]) {
|
||||
this.ids[i] = true;
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
if (this.index >= this.maxIndex) {
|
||||
this.index = 0;
|
||||
} else {
|
||||
this.index++;
|
||||
}
|
||||
|
||||
if (this.index === startIndex) {
|
||||
throw new Error(
|
||||
`All IDs are reserved. Make sure to release IDs when they are no longer used.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
packages/keepalive-ws/src/client/index.ts
Normal file
2
packages/keepalive-ws/src/client/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { KeepAliveClient } from "./client";
|
||||
export { Connection } from "./connection";
|
||||
50
packages/keepalive-ws/src/client/queue.ts
Normal file
50
packages/keepalive-ws/src/client/queue.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
2
packages/keepalive-ws/src/index.ts
Normal file
2
packages/keepalive-ws/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { KeepAliveClient } from "./client";
|
||||
export { KeepAliveServer } from "./server";
|
||||
19
packages/keepalive-ws/src/server/command.ts
Normal file
19
packages/keepalive-ws/src/server/command.ts
Normal 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: {} };
|
||||
}
|
||||
};
|
||||
88
packages/keepalive-ws/src/server/connection.ts
Normal file
88
packages/keepalive-ws/src/server/connection.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
294
packages/keepalive-ws/src/server/index.ts
Normal file
294
packages/keepalive-ws/src/server/index.ts
Normal 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 };
|
||||
15
packages/keepalive-ws/src/server/latency.ts
Normal file
15
packages/keepalive-ws/src/server/latency.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
3
packages/keepalive-ws/src/server/ping.ts
Normal file
3
packages/keepalive-ws/src/server/ping.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export class Ping {
|
||||
interval: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
18
packages/keepalive-ws/tsconfig.json
Normal file
18
packages/keepalive-ws/tsconfig.json
Normal 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
2
packages/ms/.npmignore
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
src
|
||||
33
packages/ms/README.md
Normal file
33
packages/ms/README.md
Normal file
@ -0,0 +1,33 @@
|
||||
# ms
|
||||
|
||||
[](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
|
||||
```
|
||||
7
packages/ms/bump.config.ts
Normal file
7
packages/ms/bump.config.ts
Normal 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
BIN
packages/ms/bun.lockb
Executable file
Binary file not shown.
26
packages/ms/package.json
Normal file
26
packages/ms/package.json
Normal 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
174
packages/ms/src/index.ts
Normal 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
11
packages/ms/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "es2022",
|
||||
"target": "esnext",
|
||||
"outDir": "dist",
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"declarationDir": "dist"
|
||||
}
|
||||
}
|
||||
11
packages/ms/tsup.config.ts
Normal file
11
packages/ms/tsup.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
minify: true,
|
||||
sourcemap: "inline",
|
||||
target: "esnext",
|
||||
});
|
||||
2
packages/otp/.npmignore
Normal file
2
packages/otp/.npmignore
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
src
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user