├── .github └── FUNDING.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── app.ts ├── deno.json ├── deno.lock ├── egg.json ├── example ├── client.ts └── server.ts ├── mod.ts ├── request.ts ├── request_test.ts ├── types.ts ├── utils.ts └── utils_test.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | ko_fi: v1rtl 4 | liberapay: v1rtl 5 | github: [talentlessguy] 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage* -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Deno libraries 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # rpc 4 | 5 | [![nest badge][nest-badge]](https://nest.land/package/rpc/mod.ts) 6 | [![][docs-badge]][docs] [![][code-quality-img]][code-quality] 7 | 8 |
9 | 10 | JSONRPC server router for Deno using native WebSocket, based on 11 | [jsonrpc](https://github.com/Vehmloewff/jsonrpc). 12 | 13 | ## Features 14 | 15 | - No dependencies 16 | - Typed parameters 17 | 18 | ## Example 19 | 20 | ```ts 21 | import { App } from 'https://deno.land/x/rpc/app.ts' 22 | 23 | const app = new App() 24 | 25 | app.method<[string]>('hello', (params) => { 26 | return `Hello ${params[0]}` 27 | }) 28 | 29 | app.listen({ port: 8080, hostname: '0.0.0.0' }) 30 | ``` 31 | 32 | See client-server example [here](/example) 33 | 34 | [docs-badge]: https://img.shields.io/github/v/release/deno-libs/rpc?label=Docs&logo=deno&style=for-the-badge&color=black 35 | [docs]: https://doc.deno.land/https/deno.land/x/rpc/mod.ts 36 | [code-quality-img]: https://img.shields.io/codefactor/grade/github/deno-libs/rpc?style=for-the-badge&color=black& 37 | [code-quality]: https://www.codefactor.io/repository/github/deno-libs/rpc 38 | [nest-badge]: https://img.shields.io/badge/publushed%20on-nest.land-black?style=for-the-badge 39 | -------------------------------------------------------------------------------- /app.ts: -------------------------------------------------------------------------------- 1 | import { parseRequest, send } from './request.ts' 2 | import type { JsonRpcResponse, RPCOptions } from './types.ts' 3 | import { lazyJSONParse, paramsEncoder } from './utils.ts' 4 | 5 | export class App { 6 | httpConn?: Deno.HttpConn 7 | listener?: Deno.Listener 8 | options: RPCOptions 9 | socks: Map 10 | methods: Map< 11 | string, 12 | (params: unknown[], clientId: string) => unknown | Promise 13 | > 14 | emitters: Map< 15 | string, 16 | (params: unknown[], emit: (data: unknown) => void, clientId: string) => void 17 | > 18 | #timeout: number 19 | constructor(options: RPCOptions = { path: '/' }) { 20 | this.options = options 21 | this.socks = new Map() 22 | this.methods = new Map() 23 | this.emitters = new Map() 24 | this.#timeout = options.timeout || 1000 * 60 * 60 * 24 25 | } 26 | /** 27 | * Upgrade a request to WebSocket and handle it 28 | * @param request request object 29 | * @returns response object 30 | */ 31 | async handle(request: Request) { 32 | const { socket, response } = Deno.upgradeWebSocket(request) 33 | 34 | const protocolHeader = request.headers.get('sec-websocket-protocol') 35 | 36 | const incomingParamaters = protocolHeader 37 | ? lazyJSONParse(paramsEncoder.decrypt(protocolHeader)) 38 | : {} 39 | 40 | let clientId = 41 | await (this.options.clientAdded || (() => crypto.randomUUID()))( 42 | incomingParamaters, 43 | socket, 44 | ) 45 | 46 | if (!clientId) clientId = crypto.randomUUID() 47 | 48 | if (typeof clientId === 'object') { 49 | send(socket, { id: null, error: clientId.error }) 50 | socket.close() 51 | return response 52 | } 53 | 54 | this.socks.set(clientId, socket) 55 | 56 | // Close the socket after timeout 57 | setTimeout(() => socket.close(), this.#timeout) 58 | 59 | socket.onmessage = ({ data }) => { 60 | if (typeof data === 'string') { 61 | this.#handleRPCMethod(clientId as string, data) 62 | } else { 63 | console.warn('Warn: an invalid jsonrpc message was sent. Skipping.') 64 | } 65 | } 66 | 67 | socket.onclose = async () => { 68 | if (this.options.clientRemoved) { 69 | await this.options.clientRemoved(clientId as string) 70 | } 71 | this.socks.delete(clientId as string) 72 | } 73 | 74 | socket.onerror = (err) => { 75 | if (err instanceof Error) console.log(err.message) 76 | if (socket.readyState !== socket.CLOSED) { 77 | socket.close(1000) 78 | } 79 | } 80 | 81 | return response 82 | } 83 | /** 84 | * Add a method handler 85 | * @param method method name 86 | * @param handler method handler 87 | */ 88 | method( 89 | method: string, 90 | handler: (params: T, clientId: string) => unknown | Promise, 91 | ) { 92 | this.methods.set( 93 | method, 94 | handler as ( 95 | params: unknown, 96 | clientId: string, 97 | ) => unknown | Promise, 98 | ) 99 | } 100 | 101 | /** 102 | * Handle a JSONRPC method 103 | * @param client client ID 104 | * @param data Received data 105 | */ 106 | async #handleRPCMethod(client: string, data: string) { 107 | const sock = this.socks.get(client) 108 | 109 | if (!sock) { 110 | return console.warn( 111 | `Warn: recieved a request from and undefined connection`, 112 | ) 113 | } 114 | const requests = parseRequest(data) 115 | if (requests === 'parse-error') { 116 | return send(sock, { 117 | id: null, 118 | error: { code: -32700, message: 'Parse error' }, 119 | }) 120 | } 121 | 122 | const responses: JsonRpcResponse[] = [] 123 | 124 | const promises = requests.map(async (request) => { 125 | if (request === 'invalid') { 126 | return responses.push({ 127 | id: null, 128 | error: { code: -32600, message: 'Invalid Request' }, 129 | }) 130 | } 131 | 132 | if (!request.method.endsWith(':')) { 133 | const handler = this.methods.get(request.method) 134 | 135 | if (!handler) { 136 | return responses.push({ 137 | error: { code: -32601, message: 'Method not found' }, 138 | id: request.id!, 139 | }) 140 | } 141 | const result = await handler(request.params, client) 142 | responses.push({ id: request.id!, result }) 143 | } else { 144 | // It's an emitter 145 | const handler = this.emitters.get(request.method) 146 | 147 | if (!handler) { 148 | return responses.push({ 149 | error: { code: -32601, message: 'Emitter not found' }, 150 | id: request.id!, 151 | }) 152 | } 153 | 154 | // Because emitters can return a value at any time, we are going to have to send messages on their schedule. 155 | // This may break batches, but I don't think that is a big deal 156 | handler( 157 | request.params, 158 | (data) => { 159 | send(sock, { result: data, id: request.id || null }) 160 | }, 161 | client, 162 | ) 163 | } 164 | }) 165 | 166 | await Promise.all(promises) 167 | 168 | send(sock, responses) 169 | } 170 | 171 | /** 172 | * Start a websocket server and listen it on a specified host/port 173 | * @param options `Deno.listen` options 174 | * @param cb Callback that triggers after HTTP server is started 175 | */ 176 | async listen(options: Deno.ListenOptions, cb?: (addr: Deno.NetAddr) => void) { 177 | const listener = Deno.listen(options) 178 | cb?.(listener.addr as Deno.NetAddr) 179 | 180 | for await (const conn of listener) { 181 | const requests = Deno.serveHttp(conn) 182 | for await (const { request, respondWith } of requests) { 183 | const response = await this.handle(request) 184 | if (response) { 185 | respondWith(response) 186 | } 187 | } 188 | } 189 | } 190 | /** 191 | * Close the server 192 | */ 193 | close() { 194 | this.httpConn?.close() 195 | this.listener?.close() 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "fmt": { 3 | "useTabs": false, 4 | "lineWidth": 80, 5 | "indentWidth": 2, 6 | "singleQuote": true, 7 | "semiColons": false 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2", 3 | "remote": { 4 | "https://deno.land/std@0.190.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e", 5 | "https://deno.land/std@0.190.0/testing/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", 6 | "https://deno.land/std@0.190.0/testing/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", 7 | "https://deno.land/std@0.190.0/testing/asserts.ts": "e16d98b4d73ffc4ed498d717307a12500ae4f2cbe668f1a215632d19fcffc22f" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /egg.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://x.nest.land/eggs@0.3.8/src/schema.json", 3 | "name": "rpc", 4 | "entry": "./mod.ts", 5 | "description": "📡 JSONRPC server implementation for Deno", 6 | "homepage": "https://github.com/deno-libs/rpc", 7 | "version": "0.1.1", 8 | "releaseType": "patch", 9 | "unstable": false, 10 | "unlisted": false, 11 | "files": [ 12 | "README.md", 13 | "*.ts", 14 | "LICENSE" 15 | ], 16 | "ignore": [], 17 | "checkFormat": false, 18 | "checkTests": false, 19 | "checkInstallation": true, 20 | "check": true, 21 | "checkAll": true, 22 | "repository": "https://github.com/deno-libs/rpc" 23 | } 24 | -------------------------------------------------------------------------------- /example/client.ts: -------------------------------------------------------------------------------- 1 | import { lazyJSONParse } from '../utils.ts' 2 | 3 | const socket = new WebSocket('ws://localhost:8080') 4 | 5 | socket.onopen = () => { 6 | if (socket.readyState === socket.OPEN) { 7 | socket.send( 8 | JSON.stringify({ method: 'hello', params: ['world'], jsonrpc: '2.0' }), 9 | ) 10 | } 11 | } 12 | 13 | socket.onmessage = (ev) => { 14 | console.log(lazyJSONParse(ev.data)) 15 | socket.close() 16 | } 17 | -------------------------------------------------------------------------------- /example/server.ts: -------------------------------------------------------------------------------- 1 | import { App } from '../mod.ts' 2 | 3 | const app = new App() 4 | 5 | app.method<[string]>('hello', (params) => { 6 | return `Hello ${params[0]}` 7 | }) 8 | 9 | await app.listen({ port: 8080, hostname: '0.0.0.0' }, (addr) => { 10 | console.log(`Listening on ${addr.port}`) 11 | }) 12 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export * from './app.ts' 2 | export * from './request.ts' 3 | export type { ErrorResponse, JsonRpcRequest, RPCOptions } from './types.ts' 4 | -------------------------------------------------------------------------------- /request.ts: -------------------------------------------------------------------------------- 1 | import type { JsonRpcRequest, JsonRpcResponse } from './types.ts' 2 | import { makeArray } from './utils.ts' 3 | 4 | export function send( 5 | socket: WebSocket, 6 | message: JsonRpcResponse | JsonRpcResponse[], 7 | ): void { 8 | const messages = makeArray(message) 9 | messages.forEach((message) => { 10 | message.jsonrpc = '2.0' 11 | if (messages.length === 1) socket.send(JSON.stringify(message)) 12 | }) 13 | if (messages.length !== 1) socket.send(JSON.stringify(messages)) 14 | } 15 | 16 | export function parseRequest( 17 | json: string, 18 | ): (JsonRpcRequest | 'invalid')[] | 'parse-error' { 19 | try { 20 | const arr = makeArray(JSON.parse(json)) 21 | const res: (JsonRpcRequest | 'invalid')[] = [] 22 | for (const obj of arr) { 23 | if ( 24 | typeof obj !== 'object' || !obj || obj.jsonrpc !== '2.0' || 25 | typeof obj.method !== 'string' 26 | ) res.push('invalid') 27 | else res.push(obj) 28 | } 29 | 30 | if (!res.length) return ['invalid'] 31 | 32 | return res 33 | } catch { 34 | return 'parse-error' 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /request_test.ts: -------------------------------------------------------------------------------- 1 | import { parseRequest } from './request.ts' 2 | import { assertEquals } from 'https://deno.land/std@0.190.0/testing/asserts.ts' 3 | 4 | Deno.test('parseRequest', async (t) => { 5 | await t.step('Returns an error if not object', () => { 6 | assertEquals(parseRequest('i am text'), 'parse-error') 7 | }) 8 | await t.step('Marks as invalid if empty array', () => { 9 | assertEquals(parseRequest('[]'), ['invalid']) 10 | }) 11 | await t.step('Marks as invalid if not array of objects', () => { 12 | assertEquals(parseRequest('["i am text"]'), ['invalid']) 13 | }) 14 | await t.step('Marks as invalid if version is not 2.0', () => { 15 | assertEquals(parseRequest(JSON.stringify({ method: 'hello' })), ['invalid']) 16 | assertEquals( 17 | parseRequest(JSON.stringify({ method: 'hello', jsonrpc: '1.0' })), 18 | ['invalid'], 19 | ) 20 | }) 21 | await t.step('Marks as invalid if method is missing', () => { 22 | assertEquals(parseRequest(JSON.stringify({ jsonrpc: '2.0' })), ['invalid']) 23 | }) 24 | await t.step('Properly parses valid request', () => { 25 | const response = { jsonrpc: '2.0', method: 'hello' } 26 | assertEquals(parseRequest(JSON.stringify(response)), [response]) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | export interface JsonRpcRequest { 2 | method: string 3 | id?: string 4 | params?: T 5 | } 6 | 7 | export type JsonRpcError = { 8 | name?: string 9 | code: number 10 | message: string 11 | } 12 | 13 | export type JsonRpcResponse = 14 | & ({ 15 | result: T | null 16 | } | { error: JsonRpcError | null }) 17 | & { jsonrpc?: '2.0'; id: string | null } 18 | 19 | export type ClientAdded = ( 20 | params: T, 21 | socket: WebSocket, 22 | ) => Promise<{ error: JsonRpcError } | string | null> 23 | 24 | export type RPCOptions = Partial<{ 25 | /** 26 | * Creates an ID for a specific client. 27 | * 28 | * If `{ error: ErrorResponse }` is returned, the client will be sent that error and the connection will be closed. 29 | * 30 | * If a `string` is returned, it will become the client's ID 31 | * 32 | * If `null` is returned, or if this function is not specified, the `clientId` will be set to a uuid 33 | */ 34 | clientAdded: ClientAdded 35 | /** 36 | * Called when a socket is closed. 37 | */ 38 | clientRemoved(clientId: string): Promise | void 39 | /** 40 | * The path to listen for connections at. 41 | * If '*' is specified, all incoming ws requests will be used 42 | * @default '/' // upgrade all connections 43 | */ 44 | path: string 45 | /** 46 | * Timeout 47 | */ 48 | timeout: number 49 | }> 50 | -------------------------------------------------------------------------------- /utils.ts: -------------------------------------------------------------------------------- 1 | export const makeArray = (val: T | T[]) => (Array.isArray(val) ? val : [val]) 2 | 3 | export function makeEncryptor(key: string) { 4 | const textToChars = (text: string) => 5 | text.split('').map((c) => c.charCodeAt(0)) 6 | const byteHex = (n: number) => ('0' + Number(n).toString(16)).substring(-2) 7 | const applyKeyToChar = (code: number) => 8 | textToChars(key).reduce((a, b) => a ^ b, code) 9 | 10 | function decrypt(encoded: string) { 11 | return (encoded.match(/.{1,2}/g) || []) 12 | .map((hex) => parseInt(hex, 16)) 13 | .map(applyKeyToChar) 14 | .map((charCode) => String.fromCharCode(charCode)) 15 | .join('') 16 | } 17 | 18 | function encrypt(text: string) { 19 | return textToChars(text).map(applyKeyToChar).map(byteHex).join('') 20 | } 21 | 22 | return { encrypt, decrypt } 23 | } 24 | 25 | export function lazyJSONParse(json: string): any { 26 | try { 27 | return JSON.parse(json) 28 | } catch { 29 | return {} 30 | } 31 | } 32 | 33 | export function delay(time: number) { 34 | return new Promise((resolve) => void setTimeout(() => resolve(), time)) 35 | } 36 | 37 | export const pathsAreEqual = (actual: string, expected?: string) => 38 | expected === '*' ? true : actual === (expected || '/') 39 | 40 | export const paramsEncoder = makeEncryptor('nothing-secret') 41 | -------------------------------------------------------------------------------- /utils_test.ts: -------------------------------------------------------------------------------- 1 | import { delay, lazyJSONParse, pathsAreEqual } from './utils.ts' 2 | import { assertEquals, assert } from 'https://deno.land/std@0.190.0/testing/asserts.ts' 3 | 4 | Deno.test('lazyJSONParse', async (t) => { 5 | await t.step('should parse JSON like JSON.parse', () => { 6 | assertEquals(lazyJSONParse('{ "a": "b" }'), JSON.parse('{ "a": "b" }')) 7 | }) 8 | await t.step('should return an empty object on failed parse', () => { 9 | assertEquals(lazyJSONParse('{ "a": "b"'), {}) 10 | }) 11 | }) 12 | 13 | Deno.test('pathsAreEqual', async (t) => { 14 | await t.step('if expected path is asterisk, return true', () => { 15 | assertEquals(pathsAreEqual('/hello', '*'), true) 16 | }) 17 | await t.step('should assert equal paths', () => { 18 | assertEquals(pathsAreEqual('/hello', '/hello'), true) 19 | }) 20 | await t.step('if nothing is expected, default to "/"', () => { 21 | assertEquals(pathsAreEqual('/'), true) 22 | }) 23 | }) 24 | 25 | Deno.test('delay', async (t) => { 26 | await t.step('it delays a function for given time', async () => { 27 | const then = performance.now() 28 | await delay(10) 29 | assert(performance.now() - then < 15) // there's extra run-time 30 | }) 31 | }) 32 | --------------------------------------------------------------------------------