diff --git a/apps/docs/src/pages/utils.mdx b/apps/docs/src/pages/utils.mdx new file mode 100644 index 0000000..b2c8339 --- /dev/null +++ b/apps/docs/src/pages/utils.mdx @@ -0,0 +1,74 @@ +# Utils [Don't waste time on implementing these yourself.] + +We've implemented some utilities that you might find useful. You can find them in the `@mina-js/utils` package. + +## Units + +Utils library provides functions for unit conversion. + +### formatMina + +Formats micro-Mina to human-readable Mina value. + +```typescript twoslash +import { formatMina } from '@mina-js/utils' + +const mina = formatMina(5_000_000_000n) +// -> "5" +``` + +### parseMina + +Parses human-readable Mina value to micro-Mina. + +```typescript twoslash +import { parseMina } from '@mina-js/utils' + +const mina = parseMina('5') +// -> 5_000_000_000n +``` + +### formatUnit + +```typescript twoslash +import { formatUnits } from '@mina-js/utils' + +const formatted = formatUnits(4200000000000n, 10) +// -> "420" +``` + +### parseUnits + +```typescript twoslash +import { parseUnits } from '@mina-js/utils' + +const parsed = parseUnits("420", 10) +// -> 4200000000000n +``` + +## Web Workers + +Proof related computations can be heavy and can block the main thread. To avoid this, you can use Web Workers to run these computations in a separate thread. We've prepared a JSON-RPC protocol to easily connect the dots. + +```typescript twoslash +// @filename: worker.ts +import { createRpcHandler } from "@mina-js/utils"; + +const { messageHandler } = createRpcHandler({ + methods: { + ping: async () => 'pong', + } +}) + +self.onmessage = messageHandler + +// @filename: index.ts +import { createRpc } from "@mina-js/utils"; + +const worker = new Worker(new URL('./worker.ts', import.meta.url)) +const rpc = createRpc({ worker }) +const response = await rpc.request({ + method: 'ping', + params: [], +}) +``` diff --git a/apps/docs/vocs.config.ts b/apps/docs/vocs.config.ts index 827f167..e730530 100644 --- a/apps/docs/vocs.config.ts +++ b/apps/docs/vocs.config.ts @@ -81,6 +81,10 @@ export default defineConfig({ text: "Roadmap", link: "/roadmap", }, + { + text: "Utils", + link: "/utils", + }, { text: "MinaJS Connect", link: "/connect", diff --git a/bun.lockb b/bun.lockb index 34fa9dc..7984ec7 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/utils/package.json b/packages/utils/package.json index c363571..e328a9d 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -17,7 +17,8 @@ "cleanup": "rimraf dist .turbo" }, "dependencies": { - "mina-signer": "3.0.7" + "mina-signer": "3.0.7", + "superjson": "2.2.1" }, "devDependencies": { "zod": "3.23.8" diff --git a/packages/utils/src/src/format-mina.spec.ts b/packages/utils/src/format-mina.spec.ts similarity index 100% rename from packages/utils/src/src/format-mina.spec.ts rename to packages/utils/src/format-mina.spec.ts diff --git a/packages/utils/src/src/format-mina.ts b/packages/utils/src/format-mina.ts similarity index 100% rename from packages/utils/src/src/format-mina.ts rename to packages/utils/src/format-mina.ts diff --git a/packages/utils/src/src/format-units.spec.ts b/packages/utils/src/format-units.spec.ts similarity index 100% rename from packages/utils/src/src/format-units.spec.ts rename to packages/utils/src/format-units.spec.ts diff --git a/packages/utils/src/src/format-units.ts b/packages/utils/src/format-units.ts similarity index 100% rename from packages/utils/src/src/format-units.ts rename to packages/utils/src/format-units.ts diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index e0af0bf..c4581b7 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,7 +1,8 @@ export * from "./types"; export * from "./validation"; -export { formatMina } from "./src/format-mina"; -export { formatUnits } from "./src/format-units"; -export { parseMina } from "./src/parse-mina"; -export { parseUnits } from "./src/parse-units"; +export { formatMina } from "./format-mina"; +export { formatUnits } from "./format-units"; +export { parseMina } from "./parse-mina"; +export { parseUnits } from "./parse-units"; +export { createRpc, createRpcHandler } from "./worker-rpc"; export * as Test from "./test/constants"; diff --git a/packages/utils/src/src/parse-mina.spec.ts b/packages/utils/src/parse-mina.spec.ts similarity index 100% rename from packages/utils/src/src/parse-mina.spec.ts rename to packages/utils/src/parse-mina.spec.ts diff --git a/packages/utils/src/src/parse-mina.ts b/packages/utils/src/parse-mina.ts similarity index 100% rename from packages/utils/src/src/parse-mina.ts rename to packages/utils/src/parse-mina.ts diff --git a/packages/utils/src/src/parse-units.spec.ts b/packages/utils/src/parse-units.spec.ts similarity index 100% rename from packages/utils/src/src/parse-units.spec.ts rename to packages/utils/src/parse-units.spec.ts diff --git a/packages/utils/src/src/parse-units.ts b/packages/utils/src/parse-units.ts similarity index 100% rename from packages/utils/src/src/parse-units.ts rename to packages/utils/src/parse-units.ts diff --git a/packages/utils/src/test/worker.ts b/packages/utils/src/test/worker.ts new file mode 100644 index 0000000..05663c6 --- /dev/null +++ b/packages/utils/src/test/worker.ts @@ -0,0 +1,9 @@ +import { createRpcHandler } from "../worker-rpc"; + +const { messageHandler } = createRpcHandler({ + methods: { + ping: async () => 'pong', + } +}) + +self.onmessage = messageHandler diff --git a/packages/utils/src/worker-rpc.spec.ts b/packages/utils/src/worker-rpc.spec.ts new file mode 100644 index 0000000..19dbdf7 --- /dev/null +++ b/packages/utils/src/worker-rpc.spec.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, mock, beforeAll } from 'bun:test' +import { createRpc, createRpcHandler } from './worker-rpc' + +describe("Worker RPC", () => { + let worker: Worker + + beforeAll(() => { + worker = new Worker(new URL('./test/worker.ts', import.meta.url)) + }) + + it("creates RPC handler", async () => { + const mockedHandler = mock(async () => "pong") + const { messageHandler } = createRpcHandler({ + methods: { + ping: mockedHandler, + } + }) + await messageHandler(new MessageEvent('message', { data: { method: 'ping', params: [] } })) + expect(mockedHandler).toHaveBeenCalled() + }) + + it("exchanges messages with Web Worker", async () => { + const rpc = createRpc({ worker }) + const response = await rpc.request({ method: 'ping', params: [] }) + expect(response.result).toBe('pong') + }) + + it("calls non-existing method", async () => { + const rpc = createRpc({ worker }) + expect(rpc.request({ method: 'pang', params: [] })).rejects.toThrow() + }) +}) diff --git a/packages/utils/src/worker-rpc.ts b/packages/utils/src/worker-rpc.ts new file mode 100644 index 0000000..1da9e2b --- /dev/null +++ b/packages/utils/src/worker-rpc.ts @@ -0,0 +1,93 @@ +import { z } from "zod"; +import superjson from "superjson"; + +const DEFAULT_TIMEOUT = 60000; + +export const RequestSchema = z.object({ + method: z.string(), + params: z.array(z.string()).optional(), +}); + +type RequestParams = z.infer; + +export const ResponseSchema = z + .object({ + id: z.string(), + result: z.any().optional(), + error: z.string().optional(), + }) + .strict(); + +type Response = z.infer; + +export type RequestFn = (params: RequestParams) => Promise; + +export const createRpc = ({ + worker, + timeout, +}: { + worker: Worker; + timeout?: number; +}) => { + const request: RequestFn = async ({ method, params }) => { + let resolved = false; + return new Promise((resolve, reject) => { + console.log('>>>M', method, params) + setTimeout(() => { + if (resolved) return; + return reject(new Error("[WorkerRPC] Timeout reached.")); + }, timeout ?? DEFAULT_TIMEOUT); + const responseListener = (event: MessageEvent) => { + resolved = true; + worker.removeEventListener("message", responseListener); + const data = superjson.parse(event.data); + const response = ResponseSchema.parse(data); + if (response.error) + return reject(new Error(`[WorkerRPC] ${response.error}`)); + return resolve(response); + }; + worker.addEventListener("message", responseListener); + worker.postMessage({ method, params }); + }); + }; + return { + request, + }; +}; + +type Method = (params: string[]) => Promise; +type MethodsMap = Record; + +const respond = (data: unknown) => postMessage(superjson.stringify(data)) + +export const createRpcHandler = ({ methods }: { methods: MethodsMap }) => { + const methodKeys = Object.keys(methods); + if (methodKeys.length === 0) throw new Error("No methods provided."); + const MethodEnum = z.enum(['error', ...methodKeys]); + const ExtendedRequestSchema = RequestSchema.extend({ + method: MethodEnum, + }).strict(); + const ExtendedResponseSchema = ResponseSchema.extend({ + id: MethodEnum, + }).strict(); + const messageHandler = async (event: MessageEvent) => { + try { + const action = ExtendedRequestSchema.parse(event.data) + const callable = methods[action.method] + if (!callable) throw new Error(`Method "${action.method}" not found.`); + const result = await callable(action.params ?? []); + const parsedResult = ExtendedResponseSchema.parse({ + id: action.method, + result, + }); + return respond(parsedResult); + // biome-ignore lint/suspicious/noExplicitAny: Error handling + } catch (error: any) { + return respond(ExtendedResponseSchema.parse({ + id: 'error', + error: `[WorkerRPC] ${error.message}`, + })); + } + }; + return { messageHandler }; +};