Skip to content

Commit

Permalink
feat: everything
Browse files Browse the repository at this point in the history
  • Loading branch information
Vehmloewff committed Dec 19, 2020
0 parents commit 70ee577
Show file tree
Hide file tree
Showing 9 changed files with 694 additions and 0 deletions.
56 changes: 56 additions & 0 deletions .config/Drakefile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { desc, execute, run, sh, task } from 'https://deno.land/x/drake@v1.4.5/mod.ts'
import { Application, Router, send } from 'https://deno.land/x/oak@v6.4.0/mod.ts'
import { dirname, basename } from 'https://deno.land/std@0.81.0/path/mod.ts'

// Doesn't work. denopack -o option does not work when the -d option is supplied. -d option does not name the file.
// const tmpFile = await Deno.makeTempFile()
const tmpFile = `/tmp/client.js`

desc('Bundle the client')
task('bundle', [], async () => {
console.log(`denopack -i ./test/client.ts -o ${basename(tmpFile)} -d ${dirname(tmpFile)}`)
await sh(`denopack -i ./test/client.ts -o ${basename(tmpFile)} -d ${dirname(tmpFile)}`)
})

desc('Serve this stuff up')
task('serve', [], async () => {
const template = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Live Test</title>
<script defer src="/bundle.js"></script>
</head>
<body>Running some tests. If there are no errors in the browser console after 10 seconds, you should be good.</body>
</html>`

const app = new Application()

const router = new Router()
router
.get('/', context => {
context.response.body = template
})
.get('/bundle.js', async context => {
const js = await Deno.readTextFile(tmpFile)
context.response.body = js
})
.get('/bundle.js.map', async context => {
const map = await Deno.readTextFile(tmpFile + '.map')
context.response.body = map
})

app.use(router.routes())
app.use(router.allowedMethods())

await app.listen({ port: 3000 })
})

desc('Live test the entire thing')
task('live', ['bundle'], async () => {
execute('serve')
await sh('deno run --allow-net test/server.ts')
})

run()
7 changes: 7 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"deno.enable": true,
"deno.unstable": true,
"deno.import_intellisense_origins": {
"https://deno.land": true
}
}
202 changes: 202 additions & 0 deletions client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { Parameters, ErrorResponse, paramsEncoder } from './shared.ts'
import { v4 } from 'https://deno.land/std@0.81.0/uuid/mod.ts'
import { makeArray } from './utils.ts'

export interface ListenOptions {
/** @default null */
params?: Parameters
/** Called whenever a response is recieved */
listener?: (data: Parameters) => void
/** Called whenever an error response is recieved */
errorHandler?: (error: ErrorResponse) => void
}

export interface ConnectOptions {
/**
* Called when a general error is noticed.
* This can be because of a connection issue, or an error response
* from the server that was not targeted to a particular request.
*/
onGeneralError?(error: ErrorResponse): void

/**
* The amount of times to retry a failed connection before erroring
* @default Infinity
*/
retryCount?: number
/**
* The amount of time to delay before retrying a failed connection
*/
retryInterval?: number
}

/**
* Opens a websocket connection
* @param url The url to connect to. Should start with `ws:` or `wss:`
* @param params Params sent over with the initial request. These can be read in the `onClientAdded` hook of `createJsonrpcServer`
*
* ```ts
* const connection = await onnect('ws://localhost:3000/some-path', { auth: '134jasjflaz984s' })
* ```
*/
export async function connect(url: string, params: Parameters, options: ConnectOptions = {}) {
const listeners: Map<string, (error?: ErrorResponse, data?: Parameters) => void> = new Map()
let outgoing: string[] | null = []

let newOutgoingMessageNotifier: () => void = () => {}
let retryCount = 0

function tryToConnect() {
return new Promise<void>(resolve => {
const ws = new WebSocket(url, paramsEncoder.encrypt(JSON.stringify(params)))

const sendAllMessages = () => {
if (!outgoing) return ws.close()
outgoing.forEach(message => ws.send(message))
outgoing = []
}

newOutgoingMessageNotifier = () => {
if (ws.readyState === ws.OPEN) sendAllMessages()
}

ws.onopen = () => {
sendAllMessages()
resolve()
}

ws.onerror = () => {
if (retryCount >= (options.retryCount || Infinity)) {
if (options.onGeneralError) options.onGeneralError({ message: 'Failed to connect', code: 101 })
else throw { message: 'Failed to connect', code: 101 }
}
retryCount++

setTimeout(() => {
if (outgoing) tryToConnect()
}, options.retryInterval || 2000)
}

ws.onmessage = ev => {
const res = parseResponse(ev.data)
if (!res) return

res.forEach(res => {
if (res.id === null) {
if (res.error)
if (options.onGeneralError) options.onGeneralError(res.error)
else throw res.error
} else {
const listener = listeners.get(res.id)
if (listener) listener(res.error, res.result ?? null)
}
})
}
})
}

function sendMessage(method: string, params: Parameters, id?: string) {
const message: any = { jsonrpc: '2.0', method, params }
if (id) message.id = id

if (!outgoing) throw new Error(`Cannot send message because the socket has been manually closed`)
outgoing.push(JSON.stringify(message))
newOutgoingMessageNotifier()
}

await tryToConnect()

/**
* Calls a method on the server. Returns a promise that resolves with the value that the server returns.
* @param method The method to call. These are defined on the server with `server.method('some/method', ...)
* @param params The params to pass along with the method
*/
async function call(method: string, params: Parameters = null): Promise<Parameters> {
return new Promise((resolve, reject) => {
const id = v4.generate()

listeners.set(id, (error, data) => {
listeners.delete(id)

if (error) reject(error)
else if (data !== undefined) resolve(data)
})

sendMessage(method, params, id)
})
}

/**
* Like `call`, except it doesn't expect a response back from the server
*/
function notify(method: string, params: Parameters = null) {
sendMessage(method, params)
}

/**
* Calls a method on the server and expects multipule responses.
* @param method The method to call on the server.
*
* These can methods can be provided on the server with `server.emitter('some/method', ...)`.
*
* NOTE:
* `listen` and `call` are two different things.
* Behind the scenes `listen` ads a `:` at the end of the method to avoid
* conflicts with `call`. Therefore, `listen('foo')` will have nothing to do with `call('foo')`.
*/
function listen(method: string, options: ListenOptions = {}) {
const id = v4.generate()

listeners.set(id, (error, data) => {
if (options.errorHandler && error) options.errorHandler(error)
if (options.listener && data) options.listener(data)
})

sendMessage(method + ':', options.params || null, id)
}

/**
* Closes the connection.
*/
function close() {
outgoing = null
newOutgoingMessageNotifier()
}

return {
call,
notify,
listen,
close,
}
}

interface Response {
id: string
result?: Parameters
error?: ErrorResponse
}

function parseResponse(json: any): Response[] | null {
const warn = () => console.warn(`An invalid JSON rpc request was sent over. Ignoring/..`)

try {
if (typeof json !== 'string') throw 'dummy'
const obj = makeArray(JSON.parse(json))

const res: Response[] = []
obj.forEach(obj => {
if (obj.hasOwnProperty('id') && (obj.hasOwnProperty('result') || obj.error))
res.push({
id: obj.id,
result: obj.result,
error: obj.error,
})
else warn()
})
return res
} catch (_) {
warn()
return null
}
}
43 changes: 43 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# jsonrpc

A JsonRPC library for Deno - client and server

## Usage

```ts
// Server
import { createJsonrpcServer } from 'https://denopkg.com/Vehmloewff/jsonrpc/server.ts'

const app = createJsonrpcServer()

app.method('greet', ({ name }) => `Hello, ${name}!`)

// Client
import { connect } from 'https://denopkg.com/Vehmloewff/jsonrpc/client.ts'

const connection = connect('ws:localhost:3000')

await connection.call('greet', { name: 'Vehmloewff' }) // -> Hello, Vehmloewff!
```

There is a more complete example in the [test](/test) folder.

## Docs

- [Server](https://doc.deno.land/https/denopkg.com/Vehmloewff/jsonrpc/server.ts)
- [Client](https://doc.deno.land/https/denopkg.com/Vehmloewff/jsonrpc/client.ts)

## Contributing

Of course!

You can run the tests like this:

```sh
git clone https://github.com/Vehmloewff/jsonrpc
cd jsonrpc
alias drake="deno run -A .config/Drakefile.ts"
drake live
```

Then head on over to https://localhost:3000
Loading

0 comments on commit 70ee577

Please sign in to comment.