Skip to content

Commit

Permalink
kek
Browse files Browse the repository at this point in the history
  • Loading branch information
v1rtl committed Sep 24, 2021
1 parent db9c3d6 commit ed919ec
Show file tree
Hide file tree
Showing 6 changed files with 290 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"deno.enable": true
}
17 changes: 17 additions & 0 deletions README.md
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' })
```
32 changes: 32 additions & 0 deletions request.ts
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'
}
}
154 changes: 154 additions & 0 deletions server.ts
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))
}
}
}
42 changes: 42 additions & 0 deletions types.ts
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
}
42 changes: 42 additions & 0 deletions utils.ts
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')

0 comments on commit ed919ec

Please sign in to comment.