Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(utils): add web worker rpc #4

Merged
merged 1 commit into from
Nov 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions apps/docs/src/pages/utils.mdx
Original file line number Diff line number Diff line change
@@ -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: [],
})
```
4 changes: 4 additions & 0 deletions apps/docs/vocs.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ export default defineConfig({
text: "Roadmap",
link: "/roadmap",
},
{
text: "Utils",
link: "/utils",
},
{
text: "MinaJS Connect",
link: "/connect",
Expand Down
Binary file modified bun.lockb
Binary file not shown.
3 changes: 2 additions & 1 deletion packages/utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 5 additions & 4 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
File renamed without changes.
9 changes: 9 additions & 0 deletions packages/utils/src/test/worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createRpcHandler } from "../worker-rpc";

const { messageHandler } = createRpcHandler({
methods: {
ping: async () => 'pong',
}
})

self.onmessage = messageHandler
32 changes: 32 additions & 0 deletions packages/utils/src/worker-rpc.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
93 changes: 93 additions & 0 deletions packages/utils/src/worker-rpc.ts
Original file line number Diff line number Diff line change
@@ -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<typeof RequestSchema>;

export const ResponseSchema = z
.object({
id: z.string(),
result: z.any().optional(),
error: z.string().optional(),
})
.strict();

type Response = z.infer<typeof ResponseSchema>;

export type RequestFn = (params: RequestParams) => Promise<Response>;

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<unknown>;
type MethodsMap = Record<string, Method>;

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 };
};