├── .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 |
--------------------------------------------------------------------------------