Skip to content

Instantly share code, notes, and snippets.

@dvv

dvv/jsonrpc.ts Secret

Last active February 3, 2023 09:42
Show Gist options
  • Save dvv/4bf04c824678362b1c9c2c63fa3ab7b4 to your computer and use it in GitHub Desktop.
Save dvv/4bf04c824678362b1c9c2c63fa3ab7b4 to your computer and use it in GitHub Desktop.
On a barebone jsonrpc2 implementation
import { z } from "../../deps.ts"
// import * as Metrics from "./metrics.ts"
// https://www.jsonrpc.org/specification
const JsonRpcIdSchema = z.union([z.number().int(), z.string().min(1), z.null()])
const JsonRpcBaseSchema = z.object({
jsonrpc: z.literal("2.0"),
id: JsonRpcIdSchema,
})
const JsonRpcCallSchema = z.object({
method: z.string().refine(x => !x.startsWith("rpc."), { message: "Method name is reserved" }),
params: z.union([z.record(z.unknown()), z.array(z.unknown())]).optional(),
})
type JsonRpcCallType = z.infer<typeof JsonRpcCallSchema>
const JsonRpcRequestSchema = JsonRpcBaseSchema.merge(JsonRpcCallSchema).extend({
id: JsonRpcIdSchema.optional(),
})
type JsonRpcRequestType = z.infer<typeof JsonRpcRequestSchema>
const JsonRpcSuccessSchema = z.object({
result: z.unknown(),
})
type JsonRpcSuccessType = z.infer<typeof JsonRpcSuccessSchema>
const JsonRpcFailureSchema = z.object({
error: z.object({
code: z.number().int(),
message: z.string().min(1),
data: z.unknown().optional(),
}),
})
type JsonRpcFailureType = z.infer<typeof JsonRpcFailureSchema>
const JsonRpcResponseSuccessSchema = JsonRpcBaseSchema.merge(JsonRpcSuccessSchema)
const JsonRpcResponseErrorSchema = JsonRpcBaseSchema.merge(JsonRpcFailureSchema)
const JsonRpcResponseSchema = z.union([JsonRpcResponseErrorSchema, JsonRpcResponseSuccessSchema]) // NB: order matters
type JsonRpcResponseType = z.infer<typeof JsonRpcResponseSchema>
interface JsonRpcContext {
meta: {
method?: string
args?: unknown
result?: unknown
duration_ms?: number
error?: "method_not_found" | "invalid_arguments" | "invalid_return_type" | "exception" | unknown
stack?: unknown
}
}
interface JsonRpcHandler {
in?: z.ZodSchema
out?: z.ZodSchema
// deno-lint-ignore ban-types
fn: Function
}
export class JsonRpc<Context extends JsonRpcContext> {
public constructor(private readonly rpcMethods: { [method: string]: JsonRpcHandler }) {
}
async handleJson(json: string, ctx: Context): Promise<string | undefined> {
let data
try {
data = this.#parse(json)
} catch {
return this.#stringify({ jsonrpc: "2.0", error: { code: -32700, message: "Parse error" }, id: null })
}
data = await this.handleRequest(data, ctx)
data = data === undefined ? undefined : this.#stringify(data)
return data
}
// protected authorize(_ctx: Context) {
// return true
// }
protected log(_ctx: Context) {
// NB: _ctx.meta contains useful metadata
}
async handleRequest(input: unknown, ctx: Context): Promise<JsonRpcResponseType | JsonRpcResponseType[] | undefined> {
const isBatch = Array.isArray(input)
if (isBatch && input.length < 1) {
return { jsonrpc: "2.0", error: { code: -32600, message: "Invalid Request" }, id: null }
}
const responses = (await Promise.all((isBatch ? input : [input]).map(async input => {
const c = Object.create(ctx, { meta: { value: { ...ctx.meta } } })
const ri = JsonRpcRequestSchema.safeParse(input)
c.meta.args = input
if (!ri.success) {
return { jsonrpc: "2.0", error: { code: -32600, message: "Invalid Request" }, id: null }
}
c.meta.args = ri.data.params
c.meta.method = ri.data.method
if (!(ri.data.method in this.rpcMethods)) {
ctx.meta.error = "method_not_found"
return { jsonrpc: "2.0", error: { code: -32601, message: "Method not found" }, id: ri.data.id! }
}
const output = await this.handleMethod(ri.data, c)
this.log(c)
return { jsonrpc: "2.0", ...output, id: ri.data.id! }
}))).filter(response => response.id !== undefined) as JsonRpcResponseType[]
if (responses.length === 0) return
if (isBatch) return responses
return responses[0]
}
async handleMethod(request: JsonRpcCallType, ctx: Context): Promise<JsonRpcSuccessType | JsonRpcFailureType> {
const handler = this.rpcMethods[request.method]
ctx.meta.args = request.params
if (handler.in) {
const ri = handler.in.safeParse(request.params)
if (!ri.success) {
ctx.meta.error = "invalid_arguments"
const errors = ri.error.issues.map(({ path, message }) => `${path.join(".") || "$"}: ${message}`)
return { error: { code: -32602, message: "Invalid params", data: errors } }
}
ctx.meta.args = ri.data
}
// if (!this.authorize(ctx)) {
// return { error: { code: -32001, message: "Unauthorized" } }
// }
// Metrics.rpcRequestsInFlight.inc()
const start_time = Date.now()
let result
try {
result = await handler.fn.apply(ctx, Array.isArray(request.params) ? [...request.params, ctx] : [request.params, ctx])
ctx.meta.result = result
} catch (e) {
if (e instanceof z.ZodError) {
ctx.meta.error = "validation"
} else {
ctx.meta.error = "exception"
}
ctx.meta.stack = e.stack
return { error: { code: -32603, message: "Internal error" } }
} finally {
ctx.meta.duration_ms = Date.now() - start_time
// Metrics.rpcRequestsDuration.labels({
// method: ctx.meta.method!,
// status: ctx.meta.error as string ?? "ok",
// }).observe(ctx.meta.duration_ms)
// Metrics.rpcRequestsTotal.labels({
// method: ctx.meta.method!,
// status: ctx.meta.error as string ?? "ok",
// }).inc()
// Metrics.rpcRequestsInFlight.dec()
}
if (handler.out) {
const ro = handler.out.safeParse(result)
if (!ro.success) {
ctx.meta.error = "invalid_return_type"
ctx.meta.stack = ro.error.stack
return { error: { code: -32603, message: "Invalid result" } }
}
ctx.meta.result = result = ro.data
}
return { result }
}
#parse(json: string) {
return JSON.parse(json)
}
#stringify(obj: unknown) {
return JSON.stringify(obj)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment