-
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
v1rtl
committed
Sep 24, 2021
1 parent
db9c3d6
commit ed919ec
Showing
6 changed files
with
290 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
{ | ||
"deno.enable": true | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
# rpc | ||
|
||
JSONRPC server implementation with native WebSocket, based on [jsonrpc](https://github.com/Vehmloewff/jsonrpc). | ||
|
||
## Example | ||
|
||
```ts | ||
import { App } from 'https://x.nest.land/rpc/mod.ts' | ||
|
||
const app = new App() | ||
|
||
app.method('hello', (params) => { | ||
return `Hello ${params[0]}` | ||
}) | ||
|
||
app.listen({ port: 8080, hostname: '0.0.0.0' }) | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import { JsonRpcRequest } from './types.ts' | ||
import { makeArray } from './utils.ts' | ||
|
||
export function send(socket: WebSocket, message: any) { | ||
const messages = makeArray(message) | ||
messages.forEach((message) => { | ||
message.jsonrpc = '2.0' | ||
if (messages.length === 1) socket.send(JSON.stringify(message)) | ||
}) | ||
if (messages.length !== 1) socket.send(JSON.stringify(messages)) | ||
} | ||
|
||
export function parseRequest(json: string): (JsonRpcRequest | 'invalid')[] | 'parse-error' { | ||
try { | ||
const arr = makeArray(JSON.parse(json)) | ||
const res: (JsonRpcRequest | 'invalid')[] = [] | ||
|
||
for (let obj of arr) { | ||
if (typeof obj !== 'object') res.push('invalid') | ||
else if (!obj) res.push('invalid') | ||
else if (obj.jsonrpc !== '2.0') res.push('invalid') | ||
else if (typeof obj.method !== 'string') res.push('invalid') | ||
else res.push(obj) | ||
} | ||
|
||
if (!res.length) return ['invalid'] | ||
|
||
return res | ||
} catch (e) { | ||
return 'parse-error' | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,154 @@ | ||
import { parseRequest, send } from './request.ts' | ||
import type { Parameters, RPCOptions } from './types.ts' | ||
import { lazyJSONParse, paramsEncoder } from './utils.ts' | ||
|
||
export class App { | ||
httpConn?: Deno.HttpConn | ||
listener?: Deno.Listener | ||
options: RPCOptions | ||
socks: Map<string, WebSocket> | ||
methods: Map<string, (params: Parameters, clientId: string) => Promise<any>> | ||
emitters: Map<string, (params: Parameters, emit: (data: any) => void, clientId: string) => void> | ||
timeout: number | ||
constructor(options: RPCOptions = { path: '/' }) { | ||
this.options = options | ||
this.socks = new Map() | ||
this.methods = new Map() | ||
this.emitters = new Map() | ||
this.timeout = options.timeout || 1000 * 60 * 60 * 24 | ||
} | ||
/** | ||
* Upgrade a request to WebSocket and handle it | ||
* @param request request object | ||
* @returns response object | ||
*/ | ||
async handle(request: Request) { | ||
const { socket, response } = Deno.upgradeWebSocket(request) | ||
|
||
const protocolHeader = request.headers.get('sec-websocket-protocol') | ||
|
||
const incomingParamaters = protocolHeader ? lazyJSONParse(paramsEncoder.decrypt(protocolHeader)) : {} | ||
|
||
let clientId = await (this.options.clientAdded || (() => crypto.randomUUID()))(incomingParamaters, socket) | ||
|
||
if (!clientId) clientId = crypto.randomUUID() | ||
|
||
if (typeof clientId === 'object') { | ||
send(socket, { id: null, error: clientId.error }) | ||
socket.close() | ||
return response | ||
} | ||
|
||
this.socks.set(clientId, socket) | ||
|
||
// Close the socket after timeout | ||
setTimeout(() => socket.close(), this.timeout) | ||
|
||
socket.onmessage = ({ data }) => { | ||
if (typeof data === 'string') { | ||
send(socket, this.handleRPCMethod(clientId as string, data)) | ||
} else if (data instanceof Uint8Array) { | ||
console.warn('Warn: an invalid jsonrpc message was sent. Skipping.') | ||
} | ||
} | ||
|
||
socket.onclose = async () => { | ||
if (this.options.clientRemoved) await this.options.clientRemoved(clientId as string) | ||
this.socks.delete(clientId as string) | ||
} | ||
|
||
socket.onerror = (err) => { | ||
if (err instanceof Error) console.log(err.message) | ||
if (socket.readyState !== socket.CLOSED) { | ||
socket.close(1000) | ||
} | ||
} | ||
|
||
return response | ||
} | ||
/** | ||
* Add a method handler | ||
* @param method method name | ||
* @param handler method handler | ||
*/ | ||
method(method: string, handler: (params: Parameters, clientId: string) => Promise<any>) { | ||
this.methods.set(method, handler) | ||
} | ||
|
||
/** | ||
* Handle a JSONRPC method | ||
* @param client client ID | ||
* @param data Received data | ||
*/ | ||
async handleRPCMethod(client: string, data: string) { | ||
const sock = this.socks.get(client) | ||
|
||
if (!sock) return console.warn(`Warn: recieved a request from and undefined connection`) | ||
|
||
const requests = parseRequest(data) | ||
if (requests === 'parse-error') return send(sock, { id: null, error: { code: -32700, message: 'Parse error' } }) | ||
|
||
const responses: any[] = [] | ||
|
||
const promises = requests.map(async (request) => { | ||
if (request === 'invalid') | ||
return responses.push({ id: null, error: { code: -32600, message: 'Invalid Request' } }) | ||
|
||
if (!request.method.endsWith(':')) { | ||
const handler = this.methods.get(request.method) | ||
|
||
if (!handler) | ||
if (request.id !== undefined) | ||
return responses.push({ error: { code: -32601, message: 'Method not found' }, id: request.id }) | ||
else return | ||
const result = await handler(request.params, client) | ||
|
||
if (request.id !== undefined) responses.push({ id: request.id, result }) | ||
} else { | ||
// It's an emitter | ||
const handler = this.emitters.get(request.method) | ||
|
||
if (!handler) | ||
if (request.id !== undefined) | ||
return responses.push({ error: { code: -32601, message: 'Emitter not found' }, id: request.id }) | ||
else return | ||
|
||
// Because emitters can return a value at any time, we are going to have to send messages on their schedule. | ||
// This may break batches, but I don't think that is a big deal | ||
handler( | ||
request.params, | ||
(data) => { | ||
send(sock, { result: data, id: request.id }) | ||
}, | ||
client | ||
) | ||
} | ||
}) | ||
|
||
await Promise.all(promises) | ||
|
||
send(sock, responses) | ||
} | ||
|
||
/** | ||
* Start a websocket server and listen it on a specified host/port | ||
* @param options `Deno.listen` options | ||
* @param cb Callback that triggers after HTTP server is started | ||
*/ | ||
async listen(options: Deno.ListenOptions, cb?: () => void) { | ||
const listener = Deno.listen(options) | ||
|
||
const httpConn = Deno.serveHttp(await listener.accept()) | ||
|
||
this.httpConn = httpConn | ||
this.listener = listener | ||
|
||
cb?.() | ||
|
||
const e = await httpConn.nextRequest() | ||
|
||
if (e) { | ||
e.respondWith(this.handle(e.request)) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
export type Parameters = any[] | ||
|
||
export interface JsonRpcRequest { | ||
method: string | ||
id?: string | ||
params: Parameters | ||
} | ||
|
||
export type ClientAdded = (params: Parameters, socket: WebSocket) => Promise<{ error: ErrorResponse } | string | null> | ||
|
||
export interface RPCOptions { | ||
/** | ||
* Creates an ID for a specific client. | ||
* | ||
* If `{ error: ErrorResponse }` is returned, the client will be sent that error and the connection will be closed. | ||
* | ||
* If a `string` is returned, it will become the client's ID | ||
* | ||
* If `null` is returned, or if this function is not specified, the `clientId` will be set to a uuid | ||
*/ | ||
clientAdded?: ClientAdded | ||
/** | ||
* Called when a socket is closed. | ||
*/ | ||
clientRemoved?(clientId: string): Promise<void> | void | ||
/** | ||
* The path to listen for connections at. | ||
* If '*' is specified, all incoming ws requests will be used | ||
* @default '/' // upgrade all connections | ||
*/ | ||
path: string | ||
/** | ||
* Timeout | ||
*/ | ||
timeout?: number | ||
} | ||
|
||
export interface ErrorResponse { | ||
code: number | ||
message: string | ||
data?: Parameters | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
export const makeArray = <T>(val: T | T[]) => (Array.isArray(val) ? val : [val]) | ||
|
||
export function makeEncryptor(key: string) { | ||
const textToChars = (text: string) => text.split('').map((c) => c.charCodeAt(0)) | ||
const byteHex = (n: number) => ('0' + Number(n).toString(16)).substr(-2) | ||
const applyKeyToChar = (code: number) => textToChars(key).reduce((a, b) => a ^ b, code) | ||
|
||
function decrypt(encoded: string) { | ||
return (encoded.match(/.{1,2}/g) || []) | ||
.map((hex) => parseInt(hex, 16)) | ||
.map(applyKeyToChar) | ||
.map((charCode) => String.fromCharCode(charCode)) | ||
.join('') | ||
} | ||
|
||
function encrypt(text: string) { | ||
return textToChars(text).map(applyKeyToChar).map(byteHex).join('') | ||
} | ||
|
||
return { encrypt, decrypt } | ||
} | ||
|
||
export function lazyJSONParse(json: string): any { | ||
try { | ||
return JSON.parse(json) | ||
} catch (e) { | ||
return {} | ||
} | ||
} | ||
|
||
export function delay(time: number) { | ||
return new Promise<void>((resolve) => { | ||
setTimeout(() => resolve(), time) | ||
}) | ||
} | ||
|
||
export function pathsAreEqual(actual: string, expected: string | undefined) { | ||
if (expected === '*') return true | ||
return actual === (expected || '/') | ||
} | ||
|
||
export const paramsEncoder = makeEncryptor('nothing-secret') |