UNPKG

swarpc

Version:

Full type-safe RPC library for service worker -- move things off of the UI thread with ease!

247 lines (181 loc) 7.19 kB
<div align=center> <h1> <img src="./logo.svg" alt="sw&rpc" /> </h1> RPC for Service Workers -- move that heavy computation off of your UI thread! </div> * * * ## Features - Fully typesafe - Cancelable requests - Parallelization with multiple worker instances - Automatic [transfer](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects) of transferable values from- and to- worker code - A way to polyfill a pre-filled `localStorage` to be accessed within the worker code - Supports [Service workers](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker), [Shared workers](https://developer.mozilla.org/en-US/docs/Web/API/SharedWorker) and [Dedicated workers](https://developer.mozilla.org/en-US/docs/Web/API/Worker) ## Installation ```bash npm add swarpc arktype ``` <details> <summary> Bleeding edge </summary> If you want to use the latest commit instead of a published version, you can, either by using the Git URL: ```bash npm add git+https://github.com/gwennlbh/swarpc.git ``` Or by straight up cloning the repository and pointing to the local directory (very useful to hack on sw&rpc while testing out your changes on a more substantial project): ```bash mkdir -p vendored git clone https://github.com/gwennlbh/swarpc.git vendored/swarpc npm add file:vendored/swarpc ``` This works thanks to the fact that `dist/` is published on the repository (and kept up to date with a CI workflow). </details> ## Usage ### 1. Declare your procedures in a shared file ```typescript import type { ProceduresMap } from "swarpc"; import { type } from "arktype"; export const procedures = { searchIMDb: { // Input for the procedure input: type({ query: "string", "pageSize?": "number" }), // Function to be called whenever you can update progress while the procedure is running -- long computations are a first-class concern here. Examples include using the fetch-progress NPM package. progress: type({ transferred: "number", total: "number" }), // Output of a successful procedure call success: type({ id: "string", primary_title: "string", genres: "string[]", }).array(), }, } as const satisfies ProceduresMap; ``` ### 2. Register your procedures in the service worker In your service worker file: ```javascript import fetchProgress from "fetch-progress" import { Server } from "swarpc" import { procedures } from "./procedures.js" // 1. Give yourself a server instance const swarpc = Server(procedures) // 2. Implement your procedures swarpc.searchIMDb(async ({ query, pageSize = 10 }, onProgress) => { const queryParams = new URLSearchParams({ page_size: pageSize.toString(), query, }) return fetch(`https://rest.imdbapi.dev/v2/search/titles?${queryParams}`) .then(fetchProgress({ onProgress })) .then((response) => response.json()) .then(({ titles } => titles) }) // ... // 3. Start the event listener swarpc.start(self) ``` ### 3. Call your procedures from the client Here's a Svelte example! ```svelte <script> import { Client } from "swarpc" import { procedures } from "./procedures.js" const swarpc = Client(procedures) let query = $state("") let results = $state([]) let progress = $state(0) </script> <search> <input type="text" bind:value={query} placeholder="Search IMDb" /> <button onclick={async () => { results = await swarpc.searchIMDb({ query }, (p) => { progress = p.transferred / p.total }) }}> Search </button> </search> {#if progress > 0 && progress < 1} <progress value={progress} max="1" /> {/if} <ul> {#each results as { id, primary_title, genres } (id)} <li>{primary_title} - {genres.join(", ")}</li> {/each} </ul> ``` ### Configure parallelism By default, when a `worker` is passed to the `Client`'s options, the client will automatically spin up `navigator.hardwareConcurrency` worker instances and distribute requests among them. You can customize this behavior by setting the `Client:options.nodes` option to control the number of _nodes_ (worker instances). When `Client:options.worker` is not set, the client will use the Service worker (and thus only a single instance). #### Send to multiple nodes Use `Client#(method name).broadcast` to send the same request to all nodes at once. This method returns a Promise that resolves to an array of `PromiseSettledResult` (with an additional property, `node`, the ID of the node the request was sent to), one per node the request was sent to. For example: ```ts const client = Client(procedures, { worker: "./worker.js", nodes: 4, }); for (const result of await client.initDB.broadcast("localhost:5432")) { if (result.status === "rejected") { console.error( `Could not initialize database on node ${result.node}`, result.reason, ); } } ``` ### Make cancelable requests #### Implementation To make your procedures meaningfully cancelable, you have to make use of the [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) API. This is passed as a third argument when implementing your procedures: ```js server.searchIMDb(async ({ query }, onProgress, abort) => { // If you're doing heavy computation without fetch: let aborted = false abort?.addEventListener("abort", () => { aborted = true }) // Use `aborted` to check if the request was canceled within your hot loop for (...) { /* here */ if (aborted) return ... } // When using fetch: await fetch(..., { signal: abort }) }) ``` #### Call sites Instead of calling `await client.myProcedure()` directly, call `client.myProcedure.cancelable()`. You'll get back an object with - `async cancel(reason)`: a function to cancel the request - `request`: a Promise that resolves to the result of the procedure call. `await` it to wait for the request to finish. Example: ```js // Normal call: const result = await swarpc.searchIMDb({ query }); // Cancelable call: const { request, cancel } = swarpc.searchIMDb.cancelable({ query }); setTimeout(() => cancel().then(() => console.warn("Took too long!!")), 5_000); await request; ``` ### Polyfill a `localStorage` for the Server to access You might call third-party code that accesses on `localStorage` from within your procedures. Some workers don't have access to the browser's `localStorage`, so you'll get an error. You can work around this by specifying to swarpc localStorage items to define on the Server, and it'll create a polyfilled `localStorage` with your data. An example use case is using Paraglide, a i18n library, with [the `localStorage` strategy](https://inlang.com/m/gerre34r/library-inlang-paraglideJs/strategy#localstorage): ```js // In the client import { getLocale } from "./paraglide/runtime.js"; const swarpc = Client(procedures, { localStorage: { PARAGLIDE_LOCALE: getLocale(), }, }); await swarpc.myProcedure(1, 0); // In the server import { m } from "./paraglide/runtime.js"; const swarpc = Server(procedures); swarpc.myProcedure(async (a, b) => { if (b === 0) throw new Error(m.cannot_divide_by_zero()); return a / b; }); ```