chore: add full source code
This commit is contained in:
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 tonecn
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
220
README.md
Normal file
220
README.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# TypeSRPC
|
||||
|
||||
**TypeSRPC** is a lightweight, type-safe RPC (Remote Procedure Call) framework for TypeScript that enables seamless communication between client and server with full type inference and deep nested method support.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Full TypeScript Support**: Automatic type inference.
|
||||
- **Deep Nested API**: Supports arbitrarily nested method structures (e.g., `api.math.utils.absolute()`).
|
||||
- **Promise-Based**: All remote calls return promises—no callback hell.
|
||||
- **Pluggable Transport Layer**: Easily swap underlying socket implementations (default: Socket.IO).
|
||||
- **Access Control**: Built-in access key authentication for secure connections.
|
||||
- **Lightweight & Zero Boilerplate**: Define your provider once—no need to manually declare RPC interfaces.
|
||||
- **Bidirectional Communication**: Both client and server can expose and consume APIs.
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
```bash
|
||||
npm install typesrpc
|
||||
# or
|
||||
yarn add typesrpc
|
||||
# or
|
||||
pnpm add typesrpc
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Define Your Providers
|
||||
|
||||
```ts
|
||||
// Server-side provider
|
||||
type ServerProvider = {
|
||||
add: (a: number, b: number) => number;
|
||||
math: {
|
||||
multiply: (a: number, b: number) => number;
|
||||
utils: {
|
||||
absolute: (num: number) => number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const serverProvider = {
|
||||
add(a: number, b: number) { return a + b; },
|
||||
math: {
|
||||
multiply(a: number, b: number) { return a * b; },
|
||||
utils: {
|
||||
absolute(num: number) { return Math.abs(num); }
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
```ts
|
||||
// Client-side provider (optional, for bidirectional RPC)
|
||||
type ClientProvider = {
|
||||
getName: () => string;
|
||||
sub: {
|
||||
getName: () => string;
|
||||
};
|
||||
};
|
||||
|
||||
const clientProvider = {
|
||||
name: 'Client1',
|
||||
getName() { return this.name; },
|
||||
sub: {
|
||||
name: 'SubClient',
|
||||
getName() { return this.name; }
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Set Up Server & Client
|
||||
|
||||
```ts
|
||||
import { RPCHandler } from 'typesrpc';
|
||||
|
||||
// Server
|
||||
const server = new RPCHandler();
|
||||
server.setProvider(serverProvider);
|
||||
await server.listen({ port: 3000 });
|
||||
|
||||
// Client
|
||||
const client = new RPCHandler();
|
||||
client.setProvider(clientProvider);
|
||||
const session = await client.connect({ url: 'http://localhost:3000' });
|
||||
|
||||
// Get typed API proxies
|
||||
const serverAPI = session.getAPI<ServerProvider>();
|
||||
const clientAPI = session.getAPI<ClientProvider>(); // if server also consumes client API
|
||||
```
|
||||
|
||||
### 3. Make Remote Calls
|
||||
|
||||
```ts
|
||||
// All methods return Promises
|
||||
const sum = await serverAPI.add(2, 3); // 5
|
||||
const product = await serverAPI.math.multiply(4, 5); // 20
|
||||
const abs = await serverAPI.math.utils.absolute(-10); // 10
|
||||
|
||||
const name = await clientAPI.getName(); // 'Client1'
|
||||
```
|
||||
|
||||
> 💡 **Note**: The `ToDeepPromise<T>` utility (exported internally) automatically converts all synchronous methods in your provider to async (`Promise<T>`), so you can `await` them on the client side.
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Access Key Authentication
|
||||
|
||||
Secure your RPC endpoints with access keys:
|
||||
|
||||
### Server (require access key)
|
||||
```ts
|
||||
const server = new RPCHandler();
|
||||
server.setAccessKey('my-secret-key');
|
||||
await server.listen({ port: 3001 });
|
||||
```
|
||||
|
||||
### Client (provide access key)
|
||||
```ts
|
||||
const client = new RPCHandler();
|
||||
await client.connect({
|
||||
url: 'http://localhost:3001',
|
||||
accessKey: 'my-secret-key' // required!
|
||||
});
|
||||
```
|
||||
|
||||
Connections without a valid access key will be rejected.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Custom Socket Implementation
|
||||
|
||||
TypeSRPC supports pluggable transport layers. By default, it uses **Socket.IO**, but you can replace it with any real-time communication library (e.g., WebSocket, SignalR, etc.).
|
||||
|
||||
### How to Inject a Custom Implementation
|
||||
|
||||
1. Implement the required interfaces:
|
||||
- `SocketClient`
|
||||
- `SocketServer`
|
||||
- `SocketConnection`
|
||||
|
||||
2. Inject your implementations:
|
||||
|
||||
```ts
|
||||
// my-socket-impl/index.ts
|
||||
import { injectSocketClient, injectSocketServer } from 'typesrpc';
|
||||
import { MySocketClient } from './MySocketClient';
|
||||
import { MySocketServer } from './MySocketServer';
|
||||
|
||||
export function injectMySocketImpl() {
|
||||
injectSocketClient(MySocketClient);
|
||||
injectSocketServer(MySocketServer);
|
||||
}
|
||||
```
|
||||
|
||||
3. Call the injector **before** creating any `RPCHandler` instances:
|
||||
|
||||
```ts
|
||||
import { injectMySocketImpl } from './my-socket-impl';
|
||||
injectMySocketImpl(); // Must be called once at app startup
|
||||
|
||||
const handler = new RPCHandler(); // Now uses your custom socket layer
|
||||
```
|
||||
|
||||
> ✅ The built-in Socket.IO implementation is injected automatically when you import typesrpc. To override it, call your custom injector before creating any RPCHandler instances (but after importing typesrpc).
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── core/ # Core RPC logic (transport-agnostic)
|
||||
├── implements/ # Transport implementations
|
||||
│ └── socket.io/ # Default: Socket.IO adapter
|
||||
├── utils/ # Utilities (e.g., ToDeepPromise, EventEmitter)
|
||||
└── index.ts # Public API exports + default Socket.IO injection
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
The project includes comprehensive tests:
|
||||
|
||||
- **Unit**: `npm run test:unit`
|
||||
- **Integration**: `npm run test:integration`
|
||||
- **E2E**: `npm run test:e2e`
|
||||
- **Coverage**: `npm run test:coverage`
|
||||
|
||||
Run all tests with:
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 API Reference
|
||||
|
||||
### `RPCHandler`
|
||||
Main entry point for both client and server.
|
||||
|
||||
- `.setProvider(provider: T)` – Register local methods.
|
||||
- `.setAccessKey(key: string)` – Set access key (server-side).
|
||||
- `.listen(options?)` – Start server.
|
||||
- `.connect(options?)` – Connect to server; returns `Promise<RPCSession>`.
|
||||
|
||||
### `RPCSession`
|
||||
Represents an active RPC connection.
|
||||
|
||||
- `.getAPI<T>()` – Get a typed proxy to the remote provider.
|
||||
|
||||
---
|
||||
|
||||
## 📜 License
|
||||
|
||||
MIT License © [tonecn](https://github.com/tonecn)
|
||||
|
||||
---
|
||||
|
||||
> **Note**: This library is designed for development and internal tooling. For production use in public-facing services, ensure proper authentication, rate limiting, and input validation are implemented on top of the access key mechanism.
|
||||
220
README_zh.md
Normal file
220
README_zh.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# TypeSRPC
|
||||
|
||||
**TypeSRPC** 是一个轻量级、类型安全的 TypeScript RPC(远程过程调用)框架,支持客户端与服务器之间的无缝通信,并提供完整的类型推断和深度嵌套方法支持。
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- **完整的 TypeScript 支持**:自动类型推断。
|
||||
- **深度嵌套 API**:支持任意层级嵌套的方法结构(例如 `api.math.utils.absolute()`)。
|
||||
- **基于 Promise**:所有远程调用均返回 Promise,告别回调地狱。
|
||||
- **可插拔传输层**:轻松更换底层 socket 实现(默认使用 Socket.IO)。
|
||||
- **访问控制**:内置访问密钥认证,保障连接安全。
|
||||
- **轻量且零样板代码**:只需定义一次服务提供者,无需手动声明 RPC 接口。
|
||||
- **双向通信**:客户端和服务器均可暴露和调用对方的 API。
|
||||
|
||||
## 📦 安装
|
||||
|
||||
```bash
|
||||
npm install typesrpc
|
||||
# 或
|
||||
yarn add typesrpc
|
||||
# 或
|
||||
pnpm add typesrpc
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 定义你的服务提供者
|
||||
|
||||
```ts
|
||||
// 服务端提供者
|
||||
type ServerProvider = {
|
||||
add: (a: number, b: number) => number;
|
||||
math: {
|
||||
multiply: (a: number, b: number) => number;
|
||||
utils: {
|
||||
absolute: (num: number) => number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const serverProvider = {
|
||||
add(a: number, b: number) { return a + b; },
|
||||
math: {
|
||||
multiply(a: number, b: number) { return a * b; },
|
||||
utils: {
|
||||
absolute(num: number) { return Math.abs(num); }
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
```ts
|
||||
// 客户端提供者(可选,用于双向 RPC)
|
||||
type ClientProvider = {
|
||||
getName: () => string;
|
||||
sub: {
|
||||
getName: () => string;
|
||||
};
|
||||
};
|
||||
|
||||
const clientProvider = {
|
||||
name: 'Client1',
|
||||
getName() { return this.name; },
|
||||
sub: {
|
||||
name: 'SubClient',
|
||||
getName() { return this.name; }
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 设置服务端与客户端
|
||||
|
||||
```ts
|
||||
import { RPCHandler } from 'typesrpc';
|
||||
|
||||
// 服务端
|
||||
const server = new RPCHandler();
|
||||
server.setProvider(serverProvider);
|
||||
await server.listen({ port: 3000 });
|
||||
|
||||
// 客户端
|
||||
const client = new RPCHandler();
|
||||
client.setProvider(clientProvider);
|
||||
const session = await client.connect({ url: 'http://localhost:3000' });
|
||||
|
||||
// 获取带类型的 API 代理
|
||||
const serverAPI = session.getAPI<ServerProvider>();
|
||||
const clientAPI = session.getAPI<ClientProvider>(); // 如果服务端也需要调用客户端 API
|
||||
```
|
||||
|
||||
### 3. 发起远程调用
|
||||
|
||||
```ts
|
||||
// 所有方法均返回 Promise
|
||||
const sum = await serverAPI.add(2, 3); // 5
|
||||
const product = await serverAPI.math.multiply(4, 5); // 20
|
||||
const abs = await serverAPI.math.utils.absolute(-10); // 10
|
||||
|
||||
const name = await clientAPI.getName(); // 'Client1'
|
||||
```
|
||||
|
||||
> 💡 **提示**:内部导出的工具类型 `ToDeepPromise<T>` 会自动将你提供者中的所有同步方法转换为异步方法(返回 `Promise<T>`),因此你可以在客户端直接使用 `await`。
|
||||
|
||||
---
|
||||
|
||||
## 🔐 访问密钥认证
|
||||
|
||||
通过访问密钥保护你的 RPC 端点:
|
||||
|
||||
### 服务端(要求访问密钥)
|
||||
```ts
|
||||
const server = new RPCHandler();
|
||||
server.setAccessKey('my-secret-key');
|
||||
await server.listen({ port: 3001 });
|
||||
```
|
||||
|
||||
### 客户端(提供访问密钥)
|
||||
```ts
|
||||
const client = new RPCHandler();
|
||||
await client.connect({
|
||||
url: 'http://localhost:3001',
|
||||
accessKey: 'my-secret-key' // 必填!
|
||||
});
|
||||
```
|
||||
|
||||
未提供有效访问密钥的连接将被拒绝。
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 自定义 Socket 实现
|
||||
|
||||
TypeSRPC 支持可插拔的传输层。默认使用 **Socket.IO**,但你可以替换为任意实时通信库(如原生 WebSocket、SignalR 等)。
|
||||
|
||||
### 如何注入自定义实现
|
||||
|
||||
1. 实现以下接口:
|
||||
- `SocketClient`
|
||||
- `SocketServer`
|
||||
- `SocketConnection`
|
||||
|
||||
2. 注入你的实现:
|
||||
|
||||
```ts
|
||||
// my-socket-impl/index.ts
|
||||
import { injectSocketClient, injectSocketServer } from 'typesrpc';
|
||||
import { MySocketClient } from './MySocketClient';
|
||||
import { MySocketServer } from './MySocketServer';
|
||||
|
||||
export function injectMySocketImpl() {
|
||||
injectSocketClient(MySocketClient);
|
||||
injectSocketServer(MySocketServer);
|
||||
}
|
||||
```
|
||||
|
||||
3. 在创建任何 `RPCHandler` 实例**之前**调用注入器:
|
||||
|
||||
```ts
|
||||
import { injectMySocketImpl } from './my-socket-impl';
|
||||
injectMySocketImpl(); // 必须在应用启动时调用一次
|
||||
|
||||
const handler = new RPCHandler(); // 现在使用你的自定义 socket 层
|
||||
```
|
||||
|
||||
> ✅ 内置的 Socket.IO 实现在你导入 typesrpc 时会自动注入。若要覆盖它,请在创建任何 `RPCHandler` 实例之前(但在导入 typesrpc 之后)调用你的自定义注入器。
|
||||
|
||||
---
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── core/ # 核心 RPC 逻辑(与传输层无关)
|
||||
├── implements/ # 传输层实现
|
||||
│ └── socket.io/ # 默认:Socket.IO 适配器
|
||||
├── utils/ # 工具函数(如 ToDeepPromise、EventEmitter)
|
||||
└── index.ts # 公共 API 导出 + 默认 Socket.IO 注入
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
项目包含全面的测试:
|
||||
|
||||
- **单元测试**:`npm run test:unit`
|
||||
- **集成测试**:`npm run test:integration`
|
||||
- **端到端测试**:`npm run test:e2e`
|
||||
- **覆盖率**:`npm run test:coverage`
|
||||
|
||||
运行全部测试:
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 API 参考
|
||||
|
||||
### `RPCHandler`
|
||||
客户端和服务端的主入口。
|
||||
|
||||
- `.setProvider(provider: T)` – 注册本地方法。
|
||||
- `.setAccessKey(key: string)` – 设置访问密钥(服务端)。
|
||||
- `.listen(options?)` – 启动服务端。
|
||||
- `.connect(options?)` – 连接服务端;返回 `Promise<RPCSession>`。
|
||||
|
||||
### `RPCSession`
|
||||
表示一个活跃的 RPC 连接。
|
||||
|
||||
- `.getAPI<T>()` – 获取远程提供者的类型化代理。
|
||||
|
||||
---
|
||||
|
||||
## 📜 许可证
|
||||
|
||||
MIT 许可证 © [tonecn](https://github.com/tonecn)
|
||||
|
||||
---
|
||||
|
||||
> **注意**:本库适用于开发和内部工具场景。若用于面向公众的生产服务,请在访问密钥机制之上额外实现完善的认证、限流和输入验证措施。
|
||||
35
__tests__/e2e/rpc-accesskey.test.ts
Normal file
35
__tests__/e2e/rpc-accesskey.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { RPCHandler } from "@/index"
|
||||
|
||||
describe('Rpc accessKey test', () => {
|
||||
test('none accesskey', async () => {
|
||||
const server = new RPCHandler();
|
||||
await server.listen({
|
||||
port: 5202
|
||||
});
|
||||
|
||||
const client = new RPCHandler();
|
||||
expect(client.connect({
|
||||
url: 'http://localhost:5202'
|
||||
})).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
test('server required', async () => {
|
||||
const serverAccesskey = 'abc123';
|
||||
const server = new RPCHandler();
|
||||
server.setAccessKey(serverAccesskey)
|
||||
await server.listen({
|
||||
port: 5203
|
||||
});
|
||||
|
||||
const client = new RPCHandler();
|
||||
expect(client.connect({
|
||||
url: 'http://localhost:5203'
|
||||
})).rejects.toThrow()
|
||||
|
||||
const client2 = new RPCHandler();
|
||||
expect(client2.connect({
|
||||
url: 'http://localhost:5203',
|
||||
accessKey: serverAccesskey
|
||||
})).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
85
__tests__/e2e/rpc-full-flow.test.ts
Normal file
85
__tests__/e2e/rpc-full-flow.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { RPCHandler } from "@/index"
|
||||
import { ToDeepPromise } from "@/utils/utils";
|
||||
|
||||
type ServerProvider = {
|
||||
add: (a: number, b: number) => number;
|
||||
math: {
|
||||
multiply: (a: number, b: number) => number;
|
||||
utils: {
|
||||
absolute: (num: number) => number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const serverProvider = {
|
||||
add(a: number, b: number) {
|
||||
return a + b;
|
||||
},
|
||||
math: {
|
||||
multiply(a: number, b: number) {
|
||||
return a * b
|
||||
},
|
||||
utils: {
|
||||
absolute(num: number) {
|
||||
return Math.abs(num);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
type ClientProvider = {
|
||||
getName: () => string;
|
||||
sub: {
|
||||
getName: () => string;
|
||||
}
|
||||
}
|
||||
|
||||
const clientProvider = {
|
||||
name: '1',
|
||||
getName() {
|
||||
return this.name;
|
||||
},
|
||||
sub: {
|
||||
name: '2',
|
||||
getName() {
|
||||
return this.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let clientAPI: ToDeepPromise<ClientProvider>
|
||||
let serverAPI: ToDeepPromise<ServerProvider>
|
||||
describe('Rpc full flow test', () => {
|
||||
beforeAll(async () => {
|
||||
const server = new RPCHandler();
|
||||
server.setProvider(serverProvider)
|
||||
await server.listen();
|
||||
|
||||
server.on('connnect', (rpcSession) => {
|
||||
clientAPI = rpcSession.getAPI<ClientProvider>();
|
||||
})
|
||||
|
||||
const client = new RPCHandler();
|
||||
client.setProvider(clientProvider);
|
||||
await client.connect().then((rpcSession) => {
|
||||
serverAPI = rpcSession.getAPI<ServerProvider>();
|
||||
});
|
||||
})
|
||||
|
||||
test('server', async () => {
|
||||
const addResult = await serverAPI.add(1, 1);
|
||||
expect(addResult).toBe(2);
|
||||
const multiplyResult = await serverAPI.math.multiply(2, 3);
|
||||
expect(multiplyResult).toBe(6);
|
||||
const absoluteResult = await serverAPI.math.utils.absolute(-1);
|
||||
expect(absoluteResult).toBe(1);
|
||||
})
|
||||
|
||||
test('client', async () => {
|
||||
const name1 = await clientAPI.getName();
|
||||
expect(name1).toBe(clientProvider.name);
|
||||
const name2 = await clientAPI.sub.getName();
|
||||
expect(name2).toBe(clientProvider.sub.name);
|
||||
})
|
||||
})
|
||||
23
__tests__/unit/utils/utils.test.ts
Normal file
23
__tests__/unit/utils/utils.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { isObject, isString, makeId } from "@/utils/utils"
|
||||
|
||||
test('makeId', () => {
|
||||
const id = makeId();
|
||||
expect(id.length).toBe(32);
|
||||
expect(typeof id === 'string').toBeTruthy();
|
||||
})
|
||||
|
||||
test('isObject', () => {
|
||||
const nullObj = null;
|
||||
expect(isObject(nullObj)).toBeFalsy();
|
||||
const normalObj = {};
|
||||
expect(isObject(normalObj)).toBeTruthy();
|
||||
})
|
||||
|
||||
test('isString', () => {
|
||||
const emptyStr = '';
|
||||
expect(isString(emptyStr)).toBeTruthy();
|
||||
const str = 'str';
|
||||
expect(isString(str)).toBeTruthy();
|
||||
const aNumber = 1;
|
||||
expect(isString(aNumber)).toBeFalsy();
|
||||
})
|
||||
17
jest.config.js
Normal file
17
jest.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: [
|
||||
'**/__tests__/(unit|e2e|integration)/**/*.test.ts'
|
||||
],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{ts,js}',
|
||||
'!src/**/*.d.ts'
|
||||
],
|
||||
coverageDirectory: './coverage',
|
||||
coverageReporters: ['html', 'lcov', 'text-summary'],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1'
|
||||
},
|
||||
testTimeout: 15000
|
||||
};
|
||||
50
package.json
Normal file
50
package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "typesrpc",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"test:unit": "jest __tests__/unit",
|
||||
"test:integration": "jest __tests__/integration",
|
||||
"test:e2e": "jest __tests__/e2e",
|
||||
"test:coverage": "jest --coverage",
|
||||
"build": "tsc",
|
||||
"start": "tsc && tsc-alias && node dist/index.js"
|
||||
},
|
||||
"keywords": [
|
||||
"rpc",
|
||||
"typescript",
|
||||
"remote-procedure-call",
|
||||
"socket",
|
||||
"realtime",
|
||||
"type-safe",
|
||||
"nested-api",
|
||||
"bidirectional",
|
||||
"pluggable",
|
||||
"socket.io",
|
||||
"websocket",
|
||||
"microservices",
|
||||
"client-server",
|
||||
"lightweight"
|
||||
],
|
||||
"author": "tonecn",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/md5": "^2.3.5",
|
||||
"express": "^5.1.0",
|
||||
"md5": "^2.3.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.0.15",
|
||||
"jest": "^30.0.5",
|
||||
"ts-jest": "^29.4.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
4081
pnpm-lock.yaml
generated
Normal file
4081
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
106
src/core/RPCClient.ts
Normal file
106
src/core/RPCClient.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { isHandshakeAccepted, makeHandshakePacket } from "./RPCCommon";
|
||||
import { RPCConnection } from "./RPCConnection";
|
||||
import { RPCHandler } from "./RPCHandler";
|
||||
import { RPCPacket } from "./RPCPacket";
|
||||
import { RPCSession } from "./RPCSession";
|
||||
import { createSocketClient } from "./SocketClient";
|
||||
import { SocketConnection } from "./SocketConnection";
|
||||
|
||||
export class RPCClient {
|
||||
|
||||
constructor(private rpcHandler: RPCHandler) { }
|
||||
|
||||
public async connect(options: {
|
||||
url: string;
|
||||
accessKey?: string;
|
||||
timeout: number;
|
||||
}): Promise<RPCSession> {
|
||||
// make socket connection
|
||||
const socket = createSocketClient();
|
||||
|
||||
// handshake by socket
|
||||
const thisAccessKey = this.rpcHandler.getAccessKey();
|
||||
/** set 'null' is to make sure all property will not be remove in network transmission */
|
||||
const handshakePacket = makeHandshakePacket({
|
||||
state: 0,
|
||||
thisAccessKey: thisAccessKey || null,
|
||||
accessKey: options.accessKey || null,
|
||||
});
|
||||
|
||||
/** send handshake request */
|
||||
const finalClearFns: (() => any | Promise<any>)[] = [];
|
||||
async function finalClear() {
|
||||
for (const fn of finalClearFns) {
|
||||
try { await fn() } catch { }
|
||||
}
|
||||
}
|
||||
let connection: SocketConnection | undefined;
|
||||
|
||||
// task1: timeout
|
||||
let isTimeouted = false;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
let t = setTimeout(async () => {
|
||||
if (isRequestFinished) {
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('Connect timeout'));
|
||||
}, options.timeout);
|
||||
|
||||
finalClearFns.push(() => {
|
||||
clearTimeout(t);
|
||||
})
|
||||
})
|
||||
|
||||
// task2: send and wait for response
|
||||
let isRequestFinished = false;
|
||||
let requestPromise = new Promise<RPCSession>(async (resolve, reject) => {
|
||||
/** timeout, but connection is still keep */
|
||||
finalClearFns.push(() => {
|
||||
if (isTimeouted && connection) {
|
||||
connection.close().catch(e => { })
|
||||
}
|
||||
})
|
||||
|
||||
/** clear listener of waiting for response */
|
||||
finalClearFns.push(() => {
|
||||
if (connection) {
|
||||
connection.off('msg', handleListenHandshakeReply);
|
||||
}
|
||||
})
|
||||
|
||||
connection = await socket.connect(options.url);
|
||||
if (isTimeouted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleListenHandshakeReply = (msg: unknown) => {
|
||||
const packet = RPCPacket.Parse(msg, true);
|
||||
if (packet === null) {
|
||||
reject(new Error('Connect occured an unknown error'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHandshakeAccepted(packet)) {
|
||||
resolve(new RPCSession(
|
||||
new RPCConnection(connection!),
|
||||
this.rpcHandler,
|
||||
));
|
||||
} else {
|
||||
reject(new Error('Server rejected handshake request'));
|
||||
}
|
||||
}
|
||||
/** listen msg from server, and make sure handshake status */
|
||||
connection.on('msg', handleListenHandshakeReply);
|
||||
|
||||
await connection.send(handshakePacket);
|
||||
if (isTimeouted) {
|
||||
return;
|
||||
}
|
||||
})
|
||||
|
||||
return Promise
|
||||
.race([timeoutPromise, requestPromise])
|
||||
.finally(() => finalClear());
|
||||
}
|
||||
}
|
||||
229
src/core/RPCCommon.ts
Normal file
229
src/core/RPCCommon.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { isObject } from "@/utils/utils";
|
||||
import { RPC_ERROR_MESSAGES, RPCErrorCode } from "./RPCError";
|
||||
import { RPCPacket } from "./RPCPacket";
|
||||
|
||||
function makeHandshakePacket(data: {
|
||||
state: 0;
|
||||
thisAccessKey: string | null;
|
||||
accessKey: string | null;
|
||||
}): RPCPacket;
|
||||
function makeHandshakePacket(data: {
|
||||
state: 1;
|
||||
accept: boolean;
|
||||
reason?: string;
|
||||
}): RPCPacket;
|
||||
function makeHandshakePacket(data: {
|
||||
state: 0 | 1,
|
||||
thisAccessKey?: string | null;
|
||||
accessKey?: string | null;
|
||||
accept?: boolean;
|
||||
reason?: string;
|
||||
}): RPCPacket {
|
||||
return new RPCPacket({
|
||||
type: 'handshake',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
makeHandshakePacket,
|
||||
};
|
||||
|
||||
export function verifyHandshakeRequest(options: {
|
||||
/** handshake request packet */
|
||||
packet: RPCPacket;
|
||||
/** If empty, all connections are allowed */
|
||||
thisAccessKey?: string;
|
||||
/** If empty, all connections are allowed */
|
||||
thatAccessKeys?: string[];
|
||||
}) {
|
||||
const { data } = options.packet;
|
||||
if (!isObject(data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('state' in data && 'thisAccessKey' in data && 'accessKey' in data) {
|
||||
const { state, thisAccessKey: thatAccessKey, accessKey } = data;
|
||||
if (state !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.thisAccessKey && options.thisAccessKey !== accessKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.thatAccessKeys && !options.thatAccessKeys.includes(thatAccessKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isHandshakeAccepted(packet: RPCPacket) {
|
||||
const { data } = packet;
|
||||
if (!isObject(data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('state' in data && data.state === 1) {
|
||||
if ('accept' in data && typeof data.accept === 'boolean') {
|
||||
return data.accept;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
function isValidMethodPathExtended(str: unknown): str is string {
|
||||
if (typeof str !== 'string' || str.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const regex = /^[a-zA-Z0-9_$]+(?::[a-zA-Z0-9_$]+)*$/;
|
||||
return regex.test(str);
|
||||
}
|
||||
|
||||
|
||||
export function makeCallPacket(options: {
|
||||
fnPath: string;
|
||||
args: any[];
|
||||
// timeout: number;
|
||||
}) {
|
||||
const data = {
|
||||
...options,
|
||||
}
|
||||
|
||||
return new RPCPacket({
|
||||
type: 'call',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function parseCallPacket(packet: RPCPacket) {
|
||||
if (!RPCPacket.isCallPacket(packet)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isObject(packet.data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data } = packet;
|
||||
if ('fnPath' in data && 'args' in data) {
|
||||
const { fnPath, args } = data;
|
||||
if (!isValidMethodPathExtended(fnPath)) {
|
||||
console.log('66', fnPath)
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Array.isArray(args)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
fnPath,
|
||||
args,
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type BaseCallResponseOptions = (
|
||||
| { requestPacket: RPCPacket; requestPacketId?: never }
|
||||
| { requestPacket?: never; requestPacketId: string }
|
||||
);
|
||||
|
||||
type SuccessResponseOptions = BaseCallResponseOptions & {
|
||||
status: 'success';
|
||||
data: any;
|
||||
requestPacket?: RPCPacket;
|
||||
requestPacketId?: string;
|
||||
};
|
||||
|
||||
type ErrorResponseOptions = BaseCallResponseOptions & {
|
||||
status: 'error';
|
||||
errorCode?: number;
|
||||
reason?: string;
|
||||
data?: never;
|
||||
requestPacket?: RPCPacket;
|
||||
requestPacketId?: string;
|
||||
};
|
||||
|
||||
export function makeCallResponsePacket(options: SuccessResponseOptions | ErrorResponseOptions): RPCPacket {
|
||||
let { requestPacket, requestPacketId, ...o } = options;
|
||||
requestPacketId = requestPacketId ?? requestPacket?.id;
|
||||
if (!requestPacketId) {
|
||||
throw new Error('Request Packet Id is required');
|
||||
}
|
||||
|
||||
const data = {
|
||||
...o,
|
||||
}
|
||||
if (data.status === 'error') {
|
||||
if (!data.errorCode) {
|
||||
const errorCode = RPCErrorCode.GENERAL_ERROR;
|
||||
data.errorCode = errorCode;
|
||||
data.reason = RPC_ERROR_MESSAGES[errorCode];
|
||||
}
|
||||
|
||||
if (data.errorCode && !data.reason) {
|
||||
data.reason = RPC_ERROR_MESSAGES[data.errorCode]
|
||||
?? RPC_ERROR_MESSAGES[RPCErrorCode.GENERAL_ERROR];
|
||||
}
|
||||
}
|
||||
|
||||
return new RPCPacket({
|
||||
id: requestPacketId,
|
||||
type: 'response',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export function parseCallResponsePacket(packet: RPCPacket) {
|
||||
if (!RPCPacket.isCallResponsePacket(packet)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isObject(packet.data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data } = packet;
|
||||
if (!('status' in data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { status } = data;
|
||||
if (typeof status !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === 'success') {
|
||||
return {
|
||||
success: {
|
||||
data: data.data,
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
} else if (status === 'error') {
|
||||
if ('errorCode' in data && 'reason' in data) {
|
||||
const { errorCode, reason } = data;
|
||||
if (typeof errorCode !== 'number' || typeof reason !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
success: null,
|
||||
error: {
|
||||
errorCode,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
204
src/core/RPCConnection.ts
Normal file
204
src/core/RPCConnection.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { EventEmitter } from "@/utils/EventEmitter";
|
||||
import { SocketConnection } from "./SocketConnection";
|
||||
import { RPCPacket } from "./RPCPacket";
|
||||
import { makeCallPacket, makeCallResponsePacket, parseCallPacket, parseCallResponsePacket } from "./RPCCommon";
|
||||
import { RPCProvider } from "./RPCProvider";
|
||||
import { RPCError, RPCErrorCode } from "./RPCError";
|
||||
|
||||
interface RPCConnectionEvents {
|
||||
call: RPCPacket;
|
||||
callResponse: RPCPacket;
|
||||
handshake: RPCPacket;
|
||||
unknownMsg: unknown;
|
||||
unknownPacket: RPCPacket;
|
||||
closed: void;
|
||||
}
|
||||
|
||||
export class RPCConnection extends EventEmitter<RPCConnectionEvents> {
|
||||
|
||||
closed: boolean = false;
|
||||
|
||||
private callResponseEmitter = new EventEmitter<{
|
||||
[id: string]: RPCPacket;
|
||||
}>();
|
||||
|
||||
constructor(public socket: SocketConnection) {
|
||||
super();
|
||||
socket.on('closed', () => {
|
||||
this.emit('closed');
|
||||
this.callResponseEmitter.removeAllListeners();
|
||||
this.closed = true;
|
||||
});
|
||||
|
||||
socket.on('msg', (msg) => {
|
||||
const packet = RPCPacket.Parse(msg, true);
|
||||
if (packet === null) {
|
||||
this.emit('unknownMsg', msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (RPCPacket.isCallPacket(packet)) {
|
||||
this.emit('call', packet);
|
||||
return;
|
||||
}
|
||||
|
||||
if (RPCPacket.isCallResponsePacket(packet)) {
|
||||
this.emit('callResponse', packet);
|
||||
return;
|
||||
}
|
||||
|
||||
/** In fact, it will never be triggered */
|
||||
if (RPCPacket.isHandshakePacket(packet)) {
|
||||
this.emit('handshake', packet);
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit('unknownPacket', packet);
|
||||
});
|
||||
|
||||
/** route by packet.id */
|
||||
this.on('callResponse', (packet) => {
|
||||
this.callResponseEmitter.emit(packet.id, packet);
|
||||
})
|
||||
}
|
||||
|
||||
/** @throws */
|
||||
public async callRequest(options: {
|
||||
fnPath: string;
|
||||
args: any[];
|
||||
timeout: number;
|
||||
}): Promise<any> {
|
||||
const { fnPath, args } = options;
|
||||
const packet = makeCallPacket({
|
||||
fnPath,
|
||||
args
|
||||
});
|
||||
|
||||
let resolve: (data: any) => void;
|
||||
let reject: (data: any) => void;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
const cancelTimeoutTimer = (() => {
|
||||
const t = setTimeout(() => {
|
||||
reject(new RPCError({
|
||||
errorCode: RPCErrorCode.TIMEOUT_ERROR,
|
||||
}))
|
||||
}, options.timeout);
|
||||
|
||||
return () => clearTimeout(t);
|
||||
})();
|
||||
|
||||
promise.finally(() => {
|
||||
this.callResponseEmitter.removeAllListeners(packet.id);
|
||||
cancelTimeoutTimer();
|
||||
})
|
||||
|
||||
const handleCallResponsePacket = (packet: RPCPacket) => {
|
||||
const result = parseCallResponsePacket(packet);
|
||||
if (result === null) {
|
||||
return reject(new RPCError({
|
||||
errorCode: RPCErrorCode.UNKNOWN_ERROR,
|
||||
}));;
|
||||
}
|
||||
|
||||
const { success, error } = result;
|
||||
if (success) {
|
||||
return resolve(success.data);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return reject(new RPCError({
|
||||
errorCode: error.errorCode,
|
||||
reason: error.reason
|
||||
}));
|
||||
}
|
||||
|
||||
return reject(new RPCError({
|
||||
errorCode: RPCErrorCode.UNKNOWN_ERROR,
|
||||
}));;
|
||||
}
|
||||
this.callResponseEmitter.on(packet.id, handleCallResponsePacket);
|
||||
|
||||
/** send call request */
|
||||
this.socket.send(packet);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
public onCallRequest(getProvider: () => RPCProvider | undefined) {
|
||||
this.on('call', async (packet) => {
|
||||
const request = parseCallPacket(packet);
|
||||
if (request === null) {
|
||||
return this.socket.send(makeCallResponsePacket({
|
||||
status: 'error',
|
||||
requestPacket: packet,
|
||||
errorCode: RPCErrorCode.CALL_PROTOCOL_ERROR,
|
||||
})).catch(() => { })
|
||||
}
|
||||
|
||||
// call the function
|
||||
const provider = getProvider();
|
||||
if (!provider) {
|
||||
return this.socket.send(makeCallResponsePacket({
|
||||
status: 'error',
|
||||
requestPacket: packet,
|
||||
errorCode: RPCErrorCode.PROVIDER_NOT_AVAILABLE,
|
||||
}))
|
||||
}
|
||||
|
||||
const { fnPath, args } = request;
|
||||
const fn = this.getProviderFunction(provider, fnPath);
|
||||
if (!fn) {
|
||||
return this.socket.send(makeCallResponsePacket({
|
||||
status: 'error',
|
||||
requestPacket: packet,
|
||||
errorCode: RPCErrorCode.METHOD_NOT_FOUND,
|
||||
}))
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fn(...args);
|
||||
this.socket.send(makeCallResponsePacket({
|
||||
status: 'success',
|
||||
requestPacket: packet,
|
||||
data: result,
|
||||
}))
|
||||
} catch (error) {
|
||||
this.socket.send(makeCallResponsePacket({
|
||||
status: 'error',
|
||||
requestPacket: packet,
|
||||
errorCode: RPCErrorCode.SERVER_ERROR,
|
||||
...(error instanceof RPCError ? {
|
||||
errorCode: error.errorCode,
|
||||
reason: error.reason,
|
||||
} : {})
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private getProviderFunction(provider: RPCProvider, fnPath: string) {
|
||||
const paths = fnPath.split(':');
|
||||
let fnThis: any = provider;
|
||||
let fn: any = provider;
|
||||
try {
|
||||
while (paths.length) {
|
||||
const path = paths.shift()!;
|
||||
fn = fn[path];
|
||||
if (paths.length !== 0) {
|
||||
fnThis = fn;
|
||||
}
|
||||
}
|
||||
if (typeof fn === 'function') {
|
||||
return fn.bind(fnThis);
|
||||
}
|
||||
|
||||
throw new Error();
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/core/RPCError.ts
Normal file
48
src/core/RPCError.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export enum RPCErrorCode {
|
||||
GENERAL_ERROR = -1,
|
||||
UNKNOWN_ERROR = -2,
|
||||
SERVER_ERROR = -200,
|
||||
METHOD_NOT_FOUND = -201,
|
||||
METHOD_PROTECTED = -202,
|
||||
PROVIDER_NOT_AVAILABLE = -203,
|
||||
AUTH_REJECTED = -300,
|
||||
HANDSHAKE_INCOMPLETE = -400,
|
||||
TIMEOUT_ERROR = -500,
|
||||
CALL_PROTOCOL_ERROR = -600,
|
||||
}
|
||||
|
||||
export const RPC_ERROR_MESSAGES: Record<RPCErrorCode | number, string> = {
|
||||
[RPCErrorCode.GENERAL_ERROR]: 'General error',
|
||||
[RPCErrorCode.UNKNOWN_ERROR]: 'Unknown error',
|
||||
[RPCErrorCode.SERVER_ERROR]: 'Server error',
|
||||
[RPCErrorCode.METHOD_NOT_FOUND]: 'Method not found',
|
||||
[RPCErrorCode.METHOD_PROTECTED]: 'Method is protected',
|
||||
[RPCErrorCode.PROVIDER_NOT_AVAILABLE]: 'Provider not available',
|
||||
[RPCErrorCode.AUTH_REJECTED]: 'Authentication rejected',
|
||||
[RPCErrorCode.HANDSHAKE_INCOMPLETE]: 'Handshake not completed',
|
||||
[RPCErrorCode.TIMEOUT_ERROR]: 'Request timeout',
|
||||
[RPCErrorCode.CALL_PROTOCOL_ERROR]: 'Call protocol error',
|
||||
} as const;
|
||||
|
||||
export class RPCError extends Error {
|
||||
|
||||
public errorCode: number;
|
||||
public reason: string;
|
||||
|
||||
constructor(
|
||||
args: {
|
||||
errorCode?: number;
|
||||
reason?: string;
|
||||
} = {}
|
||||
) {
|
||||
let { errorCode, reason } = args;
|
||||
errorCode = errorCode ?? RPCErrorCode.GENERAL_ERROR;
|
||||
reason = reason
|
||||
?? RPC_ERROR_MESSAGES[errorCode]
|
||||
?? RPC_ERROR_MESSAGES[RPCErrorCode.UNKNOWN_ERROR];
|
||||
|
||||
super(`[${errorCode}] ${reason}`);
|
||||
this.errorCode = errorCode;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
142
src/core/RPCHandler.ts
Normal file
142
src/core/RPCHandler.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { EventEmitter } from "@/utils/EventEmitter";
|
||||
import { RPCClient } from "./RPCClient";
|
||||
import { RPCServer } from "./RPCServer";
|
||||
import { RPCProvider } from "./RPCProvider";
|
||||
import { RPCSession } from "./RPCSession";
|
||||
|
||||
const DefaultListenOptions = {
|
||||
port: 5201,
|
||||
path: '/'
|
||||
} as const;
|
||||
|
||||
const DefaultConnectOptions = {
|
||||
url: new URL(DefaultListenOptions.path, `http://localhost:${DefaultListenOptions.port}`).href,
|
||||
/** default is 30 * 1000 in server side */
|
||||
timeout: 10 * 3000,
|
||||
} as const;
|
||||
|
||||
interface RPCHandlerEvents {
|
||||
connnect: RPCSession;
|
||||
}
|
||||
|
||||
export class RPCHandler extends EventEmitter<RPCHandlerEvents> {
|
||||
|
||||
private rpcClient?: RPCClient;
|
||||
private rpcServer?: RPCServer;
|
||||
private provider?: RPCProvider;
|
||||
private accessKey?: string;
|
||||
|
||||
constructor(
|
||||
args?: {
|
||||
rpcClient?: RPCClient;
|
||||
rpcServer?: RPCServer;
|
||||
}
|
||||
) {
|
||||
super();
|
||||
|
||||
if (args?.rpcClient) {
|
||||
this.setRPCProvider(args.rpcClient);
|
||||
}
|
||||
|
||||
if (args?.rpcServer) {
|
||||
this.setRPCProvider(args.rpcServer);
|
||||
}
|
||||
}
|
||||
|
||||
setProvider<T extends RPCProvider>(provider: T) {
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
getProvider() {
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
setAccessKey(accessKey: string | undefined) {
|
||||
this.accessKey = accessKey;
|
||||
}
|
||||
|
||||
getAccessKey() {
|
||||
return this.accessKey;
|
||||
}
|
||||
|
||||
setRPCProvider(provider?: RPCClient | RPCServer) {
|
||||
if (provider instanceof RPCServer) {
|
||||
this.rpcServer = provider;
|
||||
} else if (provider instanceof RPCClient) {
|
||||
this.rpcClient = provider;
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
async connect(options: {
|
||||
url?: string;
|
||||
accessKey?: string;
|
||||
timeout?: number;
|
||||
} = {}) {
|
||||
const rpcClient = this.getRPCProvider('client', true);
|
||||
|
||||
return rpcClient.connect({
|
||||
...DefaultConnectOptions,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async listen(options: {
|
||||
port?: number;
|
||||
} = {}) {
|
||||
const rpcServer = this.getRPCProvider('server', true);
|
||||
|
||||
return rpcServer.listen({
|
||||
...DefaultListenOptions,
|
||||
...options,
|
||||
}).finally(() => {
|
||||
rpcServer.on('connect', rpcSession => {
|
||||
this.emit('connnect', rpcSession);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
public getRPCProvider(type: 'client', init: true): RPCClient;
|
||||
public getRPCProvider(type: 'client', init?: boolean): RPCClient | undefined;
|
||||
public getRPCProvider(type: 'server', init: true): RPCServer;
|
||||
public getRPCProvider(type: 'server', init?: boolean): RPCServer | undefined;
|
||||
public getRPCProvider(type: 'client' | 'server', init?: boolean) {
|
||||
if (type === 'client') {
|
||||
if (!this.rpcClient && init) {
|
||||
this.rpcClient = new RPCClient(this);
|
||||
}
|
||||
return this.rpcClient;
|
||||
} else if (type === 'server') {
|
||||
if (!this.rpcServer && init) {
|
||||
this.rpcServer = new RPCServer(this);
|
||||
}
|
||||
return this.rpcServer;
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// const h = new RPCHandler();
|
||||
|
||||
// h.setProvider<{
|
||||
// plus: (a: number, b: number) => number;
|
||||
// math: {
|
||||
// minus: (a: number, b: number) => number;
|
||||
// multiply: (a: number, b: number) => number;
|
||||
// }
|
||||
// }>({
|
||||
// plus(a, b) {
|
||||
// return a + b
|
||||
// },
|
||||
// math: {
|
||||
// minus(a, b) {
|
||||
// return a - b;
|
||||
// },
|
||||
// multiply(a, b) {
|
||||
// return a * b;
|
||||
// },
|
||||
// }
|
||||
// })
|
||||
100
src/core/RPCPacket.ts
Normal file
100
src/core/RPCPacket.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { isObject, isString, makeId, ObjectType } from "@/utils/utils";
|
||||
|
||||
export type RPCPacketType = 'handshake' | 'call' | 'response';
|
||||
|
||||
export class RPCPacket {
|
||||
|
||||
id: string;
|
||||
type: RPCPacketType;
|
||||
data: ObjectType;
|
||||
|
||||
constructor(
|
||||
args: {
|
||||
id?: string,
|
||||
type: RPCPacketType,
|
||||
data: ObjectType;
|
||||
}
|
||||
) {
|
||||
this.id = args.id ?? makeId();
|
||||
this.type = args.type;
|
||||
this.data = args.data;
|
||||
}
|
||||
|
||||
static Parse(value: unknown, safe: true): RPCPacket | null;
|
||||
static Parse(value: unknown, safe?: boolean): RPCPacket;
|
||||
static Parse(value: unknown, safe: boolean = false) {
|
||||
try {
|
||||
if (isObject(value) && this.isRPCPacket(value)) {
|
||||
return this.ObjToRPCPacket(value);
|
||||
} else if (isString(value)) {
|
||||
const obj = JSON.parse(value);
|
||||
return this.ObjToRPCPacket(obj);
|
||||
}
|
||||
|
||||
throw new Error(`${value} is not a RPCPacket`);
|
||||
} catch (error) {
|
||||
if (safe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static ObjToRPCPacket(obj: RPCPacket) {
|
||||
const { id, type, data } = obj;
|
||||
return new RPCPacket({
|
||||
id, type, data,
|
||||
})
|
||||
}
|
||||
|
||||
static isRPCPacket(value: unknown): value is RPCPacket {
|
||||
if (!isObject(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!('id' in value) || !('type' in value) || !('data' in value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (this.isRPCPacketID(value.id)
|
||||
&& this.isRPCPacketType(value.type)
|
||||
&& this.isRPCPacketData(value.data))
|
||||
}
|
||||
|
||||
static isRPCPacketID(value: unknown) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static isRPCPacketType(type: unknown) {
|
||||
if (typeof type !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!['handshake', 'call', 'response'].includes(type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static isRPCPacketData(data: unknown) {
|
||||
return isObject(data);
|
||||
}
|
||||
|
||||
static isHandshakePacket(packet: RPCPacket) {
|
||||
return packet.type === 'handshake';
|
||||
}
|
||||
|
||||
static isCallPacket(packet: RPCPacket) {
|
||||
return packet.type === 'call';
|
||||
}
|
||||
|
||||
static isCallResponsePacket(packet: RPCPacket) {
|
||||
return packet.type === 'response';
|
||||
}
|
||||
}
|
||||
1
src/core/RPCProvider.ts
Normal file
1
src/core/RPCProvider.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type RPCProvider = { [key: string]: RPCProvider | Function | any };
|
||||
101
src/core/RPCServer.ts
Normal file
101
src/core/RPCServer.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { EventEmitter } from "@/utils/EventEmitter";
|
||||
import { RPCHandler } from "./RPCHandler";
|
||||
import { createSocketServer, SocketServer } from "./SocketServer";
|
||||
import { RPCSession } from "./RPCSession";
|
||||
import { RPCPacket } from "./RPCPacket";
|
||||
import { makeCallResponsePacket, makeHandshakePacket, verifyHandshakeRequest } from "./RPCCommon";
|
||||
import { RPCErrorCode } from "./RPCError";
|
||||
import { RPCConnection } from "./RPCConnection";
|
||||
|
||||
interface RPCServerEvents {
|
||||
connect: RPCSession;
|
||||
}
|
||||
|
||||
const DefaultRPCServerConfig = {
|
||||
handshakeTimeout: 30 * 1000,
|
||||
}
|
||||
|
||||
export class RPCServer extends EventEmitter<RPCServerEvents> {
|
||||
|
||||
private socketServer?: SocketServer;
|
||||
|
||||
constructor(private rpcHandler: RPCHandler) {
|
||||
super();
|
||||
}
|
||||
|
||||
public async listen(options: {
|
||||
port: number;
|
||||
}) {
|
||||
// call the listen method of socket server
|
||||
if (!this.socketServer) {
|
||||
this.socketServer = createSocketServer();
|
||||
}
|
||||
|
||||
const socketServer = this.socketServer;
|
||||
this.registerSocketServerListener(socketServer);
|
||||
|
||||
return socketServer.listen(options);
|
||||
}
|
||||
|
||||
private registerSocketServerListener(socketServer: SocketServer) {
|
||||
socketServer.on('connect', (socketConnection) => {
|
||||
let cancelTimeoutTimer = (() => {
|
||||
let t = setTimeout(() => {
|
||||
socketConnection.send(makeHandshakePacket({
|
||||
state: 1,
|
||||
accept: false,
|
||||
reason: 'Timeout',
|
||||
})).catch(() => { });
|
||||
socketConnection.close();
|
||||
}, DefaultRPCServerConfig.handshakeTimeout);
|
||||
return () => clearTimeout(t);
|
||||
})();
|
||||
|
||||
const handleHandshakeRequest = (msg: unknown) => {
|
||||
// before handshake successfully, it should reject any packet expect handshake
|
||||
const packet = RPCPacket.Parse(msg, true);
|
||||
if (!packet) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (RPCPacket.isCallPacket(packet) || RPCPacket.isCallResponsePacket(packet)) {
|
||||
socketConnection.send(makeCallResponsePacket({
|
||||
requestPacket: packet,
|
||||
status: 'error',
|
||||
errorCode: RPCErrorCode.HANDSHAKE_INCOMPLETE,
|
||||
})).catch(() => { });
|
||||
return;
|
||||
}
|
||||
|
||||
const acceptHandshake = verifyHandshakeRequest({
|
||||
packet,
|
||||
/** @todo */
|
||||
thatAccessKeys: undefined,
|
||||
thisAccessKey: this.rpcHandler.getAccessKey(),
|
||||
});
|
||||
|
||||
if (acceptHandshake) {
|
||||
this.emit('connect', new RPCSession(
|
||||
new RPCConnection(socketConnection),
|
||||
this.rpcHandler,
|
||||
));
|
||||
}
|
||||
|
||||
socketConnection.send(makeHandshakePacket({
|
||||
state: 1,
|
||||
accept: acceptHandshake,
|
||||
}));
|
||||
cancelTimeoutTimer();
|
||||
removeListener();
|
||||
}
|
||||
socketConnection.on('msg', handleHandshakeRequest);
|
||||
|
||||
let removeListener = () => {
|
||||
socketConnection.off('msg', handleHandshakeRequest);
|
||||
removeListener = () => { };
|
||||
}
|
||||
|
||||
socketConnection.on('closed', removeListener);
|
||||
});
|
||||
}
|
||||
}
|
||||
40
src/core/RPCSession.ts
Normal file
40
src/core/RPCSession.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ToDeepPromise } from "@/utils/utils";
|
||||
import { RPCConnection } from "./RPCConnection";
|
||||
import { RPCHandler } from "./RPCHandler";
|
||||
import { RPCProvider } from "./RPCProvider";
|
||||
|
||||
|
||||
export class RPCSession {
|
||||
|
||||
constructor(
|
||||
public readonly connection: RPCConnection,
|
||||
public readonly rpcHandler: RPCHandler,
|
||||
) {
|
||||
connection.onCallRequest(rpcHandler.getProvider.bind(rpcHandler));
|
||||
}
|
||||
|
||||
getAPI<T extends RPCProvider>(): ToDeepPromise<T> {
|
||||
const createProxy = (path: string[] = []) => {
|
||||
const func = function () { };
|
||||
|
||||
const handler: ProxyHandler<any> = {
|
||||
get(target, prop) {
|
||||
const newPath = [...path, prop.toString()];
|
||||
return createProxy(newPath);
|
||||
},
|
||||
apply: (target, thisArg, args) => {
|
||||
return this.connection.callRequest({
|
||||
fnPath: path.join(':'),
|
||||
args: args,
|
||||
/** @todo accept from caller */
|
||||
timeout: 10 * 1000,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
return new Proxy(func, handler);
|
||||
}
|
||||
|
||||
return createProxy() as unknown as ToDeepPromise<T>;
|
||||
}
|
||||
}
|
||||
27
src/core/SocketClient.ts
Normal file
27
src/core/SocketClient.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { SocketConnection } from "./SocketConnection";
|
||||
|
||||
export abstract class SocketClient {
|
||||
public abstract connect(url: string): Promise<SocketConnection>;
|
||||
}
|
||||
|
||||
interface SocketClientConstructor {
|
||||
new(...args: any[]): SocketClient;
|
||||
};
|
||||
|
||||
let socketClient: SocketClientConstructor | null = null;
|
||||
|
||||
export function injectSocketClient(constructor: SocketClientConstructor) {
|
||||
socketClient = constructor;
|
||||
}
|
||||
|
||||
export function getSocketClient() {
|
||||
if (!socketClient) {
|
||||
throw new Error('No SocketClient constructor has been injected')
|
||||
}
|
||||
return socketClient;
|
||||
}
|
||||
|
||||
export function createSocketClient(...args: any[]): SocketClient {
|
||||
const Constructor = getSocketClient();
|
||||
return new Constructor(...args);
|
||||
}
|
||||
12
src/core/SocketConnection.ts
Normal file
12
src/core/SocketConnection.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { EventEmitter } from "@/utils/EventEmitter";
|
||||
|
||||
export interface SocketConnectionBaseEvents {
|
||||
msg: any;
|
||||
/** reason */
|
||||
closed: string | undefined;
|
||||
}
|
||||
|
||||
export abstract class SocketConnection extends EventEmitter<SocketConnectionBaseEvents> {
|
||||
public abstract send(data: any): Promise<void>;
|
||||
public abstract close(): Promise<void>;
|
||||
}
|
||||
35
src/core/SocketServer.ts
Normal file
35
src/core/SocketServer.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { EventEmitter } from "@/utils/EventEmitter";
|
||||
import { SocketConnection } from "./SocketConnection";
|
||||
|
||||
export interface SocketServerBaseEvents {
|
||||
connect: SocketConnection;
|
||||
}
|
||||
|
||||
export abstract class SocketServer extends EventEmitter<SocketServerBaseEvents> {
|
||||
/** @throws Error */
|
||||
public abstract listen(options: {
|
||||
port: number;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
interface SocketServerConstructor {
|
||||
new(...args: any[]): SocketServer;
|
||||
};
|
||||
|
||||
let socketServer: SocketServerConstructor | null = null;
|
||||
|
||||
export function injectSocketServer(constructor: SocketServerConstructor) {
|
||||
socketServer = constructor;
|
||||
}
|
||||
|
||||
export function getSocketServer() {
|
||||
if (!socketServer) {
|
||||
throw new Error('No SocketServer constructor has been injected')
|
||||
}
|
||||
return socketServer;
|
||||
}
|
||||
|
||||
export function createSocketServer(...args: any[]): SocketServer {
|
||||
const Constructor = getSocketServer();
|
||||
return new Constructor(...args);
|
||||
}
|
||||
40
src/implements/socket.io/SocketClient.ts
Normal file
40
src/implements/socket.io/SocketClient.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { SocketClient as SocketClientBase } from "@/core/SocketClient";
|
||||
import { io } from "socket.io-client";
|
||||
import { SocketConnection } from "./SocketConnection";
|
||||
|
||||
export class SocketClient implements SocketClientBase {
|
||||
|
||||
public async connect(url: string): Promise<SocketConnection> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = io(url, {
|
||||
autoConnect: false,
|
||||
reconnection: false,
|
||||
});
|
||||
|
||||
const conn = new SocketConnection({
|
||||
sendMethod: (data) => {
|
||||
socket.emit('c', data);
|
||||
},
|
||||
closeMethod: () => {
|
||||
socket.close();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
resolve(conn);
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason, description) => {
|
||||
conn.emit('closed', reason);
|
||||
})
|
||||
|
||||
/** subscribe messages from server */
|
||||
socket.on('s', (data) => {
|
||||
conn.emit('msg', data);
|
||||
})
|
||||
|
||||
socket.connect();
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
25
src/implements/socket.io/SocketConnection.ts
Normal file
25
src/implements/socket.io/SocketConnection.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { SocketConnection as SocketConnectionBase, SocketConnectionBaseEvents } from "@/core/SocketConnection";
|
||||
import { EventEmitter } from "@/utils/EventEmitter";
|
||||
|
||||
interface SocketConnectionEvents extends SocketConnectionBaseEvents {
|
||||
|
||||
};
|
||||
|
||||
export class SocketConnection
|
||||
extends EventEmitter<SocketConnectionBaseEvents> implements SocketConnectionBase {
|
||||
|
||||
constructor(private args: {
|
||||
sendMethod: (data: any) => void;
|
||||
closeMethod: () => void;
|
||||
}) {
|
||||
super();
|
||||
}
|
||||
|
||||
public async send(data: any): Promise<void> {
|
||||
this.args.sendMethod(data);
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
this.args.closeMethod();
|
||||
}
|
||||
}
|
||||
42
src/implements/socket.io/SocketServer.ts
Normal file
42
src/implements/socket.io/SocketServer.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { SocketServer as SocketServerBase, SocketServerBaseEvents } from "@/core/SocketServer";
|
||||
import { EventEmitter } from "@/utils/EventEmitter";
|
||||
import { SocketConnection } from "./SocketConnection";
|
||||
|
||||
interface SocketServerEvents extends SocketServerBaseEvents {
|
||||
|
||||
}
|
||||
|
||||
export class SocketServer extends EventEmitter<SocketServerEvents> implements SocketServerBase {
|
||||
|
||||
public async listen(options: { port: number; }): Promise<void> {
|
||||
const { port } = options;
|
||||
/** only run it */
|
||||
const { Server } = await import("socket.io");
|
||||
const io = new Server();
|
||||
|
||||
io.on('connection', socket => {
|
||||
const conn = new SocketConnection({
|
||||
sendMethod: (data) => {
|
||||
socket.emit('s', data);
|
||||
},
|
||||
closeMethod: () => {
|
||||
socket.conn.close();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason, description) => {
|
||||
conn.emit('closed', reason);
|
||||
})
|
||||
|
||||
/** subscribe messages from client */
|
||||
socket.on('c', (data) => {
|
||||
conn.emit('msg', data);
|
||||
})
|
||||
|
||||
this.emit('connect', conn);
|
||||
})
|
||||
|
||||
io.listen(port);
|
||||
}
|
||||
|
||||
}
|
||||
9
src/implements/socket.io/index.ts
Normal file
9
src/implements/socket.io/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { injectSocketClient } from "@/core/SocketClient";
|
||||
import { SocketClient } from "./SocketClient";
|
||||
import { injectSocketServer } from "@/core/SocketServer";
|
||||
import { SocketServer } from "./SocketServer";
|
||||
|
||||
export function injectSocketIOImplements() {
|
||||
injectSocketClient(SocketClient);
|
||||
injectSocketServer(SocketServer);
|
||||
}
|
||||
22
src/index.ts
Normal file
22
src/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export { RPCHandler } from "./core/RPCHandler";
|
||||
export { RPCClient } from "./core/RPCClient";
|
||||
export { RPCServer } from "./core/RPCServer";
|
||||
export { RPCConnection } from "./core/RPCConnection";
|
||||
export { RPC_ERROR_MESSAGES, RPCErrorCode } from "./core/RPCError";
|
||||
export type { RPCProvider } from './core/RPCProvider';
|
||||
export type { RPCPacketType } from './core/RPCPacket';
|
||||
export { RPCPacket } from './core/RPCPacket';
|
||||
export { RPCSession } from "./core/RPCSession";
|
||||
export { SocketClient } from "./core/SocketClient";
|
||||
export { SocketConnection } from "./core/SocketConnection";
|
||||
export { SocketServer } from "./core/SocketServer";
|
||||
|
||||
export { injectSocketClient } from "./core/SocketClient";
|
||||
export { injectSocketServer } from "./core/SocketServer";
|
||||
import { injectSocketIOImplements } from "./implements/socket.io";
|
||||
|
||||
injectSocketIOImplements();
|
||||
|
||||
export {
|
||||
injectSocketIOImplements,
|
||||
}
|
||||
0
src/libs/core.errors.ts
Normal file
0
src/libs/core.errors.ts
Normal file
66
src/utils/EventEmitter.ts
Normal file
66
src/utils/EventEmitter.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
type EventType = Record<string, any>;
|
||||
|
||||
export class BaseEventEmitter<T extends EventType> {
|
||||
|
||||
private events: Map<keyof T, Set<(args: any) => void>> = new Map();
|
||||
|
||||
public on<K extends keyof T>(event: K, listener: (args: T[K]) => void) {
|
||||
this.addListener({ event, listener });
|
||||
return this;
|
||||
}
|
||||
|
||||
public once<K extends keyof T>(event: K, listener: (args: T[K]) => void) {
|
||||
this.addListener({ event, listener, once: true });
|
||||
return this;
|
||||
}
|
||||
|
||||
private addListener<K extends keyof T>({ event, listener, ...args }: {
|
||||
event: K,
|
||||
listener: (args: T[K]) => void,
|
||||
once?: boolean
|
||||
}) {
|
||||
if (!this.events.has(event)) {
|
||||
this.events.set(event, new Set());
|
||||
}
|
||||
|
||||
const fn = args.once ? (data: T[K]) => {
|
||||
listener(data);
|
||||
this.off(event, fn);
|
||||
} : listener;
|
||||
|
||||
this.events.get(event)!.add(fn);
|
||||
}
|
||||
|
||||
public off<K extends keyof T>(event: K, listener: (args: T[K]) => void) {
|
||||
if (!this.events.has(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.events.get(event)!.delete(listener);
|
||||
return this;
|
||||
}
|
||||
|
||||
protected emit<K extends keyof T>(event: K, data?: T[K]) {
|
||||
if (!this.events.has(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listeners = new Set(this.events.get(event)!);
|
||||
listeners.forEach(fn => fn(data));
|
||||
}
|
||||
|
||||
public removeAllListeners<K extends keyof T>(event?: K) {
|
||||
if (event !== undefined) {
|
||||
this.events.delete(event);
|
||||
} else {
|
||||
this.events.clear();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export class EventEmitter<T extends EventType> extends BaseEventEmitter<T> {
|
||||
public emit<K extends keyof T>(event: K, data?: T[K] | undefined): void {
|
||||
return super.emit(event, data);
|
||||
}
|
||||
}
|
||||
17
src/utils/utils.ts
Normal file
17
src/utils/utils.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import md5 from 'md5';
|
||||
|
||||
export const makeId = () => md5(`${Date.now()}${Math.random()}`);
|
||||
|
||||
export const isObject = (v: unknown): v is Record<string, any> => typeof v === 'object' && v !== null;
|
||||
|
||||
export const isString = (v: unknown): v is string => typeof v === 'string';
|
||||
|
||||
export type ObjectType = Record<string, any>;
|
||||
|
||||
export type ToDeepPromise<T> = {
|
||||
[K in keyof T]: T[K] extends (...args: infer P) => infer R
|
||||
? (...args: P) => Promise<R>
|
||||
: T[K] extends object
|
||||
? ToDeepPromise<T[K]>
|
||||
: T[K]
|
||||
};
|
||||
115
tsconfig.json
Normal file
115
tsconfig.json
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
/* Language and Environment */
|
||||
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
/* Modules */
|
||||
"module": "commonjs", /* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
"baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*",
|
||||
],
|
||||
}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||
// "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
|
||||
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
"outDir": "./dist/", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"__tests__/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user