RPC and worker system
TL;DR: JBrowse runs data-intensive work — parsing adapters, computing
layouts, encoding GPU buffers — in web workers behind an RPC layer. Subclass
RpcMethodType with an execute(), register it with addRpcMethod in your
plugin's install(), and call it with rpcManager.call(sessionId, name, args).
Only structured-clone-safe values cross the boundary.
The RPC lifecycle
Both hooks are yours to override. The serialize step is where refNames are renamed and functions are stripped, which is why a method taking regions extends a rename base — see Renaming regions below.
Sessions are sticky: a sessionId is pinned to one worker, so adapter caches
stay warm across calls from the same session.
Implementing an RPC method
Extend RpcMethodType and implement execute(). GetScoreData from
example-plugins/score-example is a
complete one — it deserializes, resolves the adapter, fetches, and packs the
result into typed arrays:
import { getFeatureAdapterOrThrow } from '@jbrowse/core/data_adapters/getFeatureAdapter'
import RpcMethodType from '@jbrowse/core/pluggableElementTypes/RpcMethodType'
import { buildScoreResult } from './buildScoreResult.ts'
import type { GetScoreDataArgs, ScoreRegionData } from './rpcTypes.ts'
import type { RpcExecuteArgs } from '@jbrowse/core/rpc/RpcRegistry'
// Registering the name here is what types `rpcManager.call(…, 'GetScoreData', …)`
// at every call site: the args are checked and the return type is inferred,
// instead of both being `any`.
declare module '@jbrowse/core/rpc/RpcRegistry' {
interface RpcRegistry {
GetScoreData: {
args: GetScoreDataArgs
return: ScoreRegionData
}
}
}
export default class GetScoreData extends RpcMethodType<'GetScoreData'> {
name = 'GetScoreData' as const
async execute(args: RpcExecuteArgs<'GetScoreData'>) {
const {
sessionId,
adapterConfig,
region,
scoreColumn,
stopToken,
statusCallback,
} = args
const dataAdapter = await getFeatureAdapterOrThrow({
pluginManager: this.pluginManager,
sessionId,
adapterConfig,
})
// statusCallback arrives as an ordinary function: the caller's never
// crossed the boundary, the RPC layer replaced it with a side channel and
// rebuilt one here. Hand it to whatever does the slow work rather than only
// bracketing that work, so the message tracks the download.
statusCallback?.('Fetching features')
const features = await dataAdapter.getFeaturesArray(region, {
stopToken,
statusCallback,
})
return buildScoreResult(features, scoreColumn)
}
}
deserializeArguments comes first because it handles the blob map and the other
transport concerns; read the args off its result, not off the raw args.
Renaming regions
If your method receives Region objects, their refNames are in the assembly's
naming scheme and the data adapter may use another (chr1 vs 1). Extend
RpcMethodTypeWithRenameRegions, which is that serializeArguments override:
import RpcMethodType from './RpcMethodType.ts'
import type { RenameRegionsArgs } from './RpcMethodType.ts'
// Base for RPC methods whose serialize step just maps region refNames into the
// data adapter's naming scheme. Subclasses get region renaming for free;
// override serializeArguments only to add extra transforms, calling super to
// keep the renaming.
export default abstract class RpcMethodTypeWithRenameRegions<
MethodName extends string = string,
> extends RpcMethodType<MethodName> {
async serializeArguments<T extends RenameRegionsArgs>(args: T) {
return super.serializeArguments(await this.renameRegions(args))
}
}
Two siblings cover the shapes that differ:
RpcMethodTypeWithRenameRegionfor a method taking a singleregionrather than aregionsarray.RpcMethodTypeWithFiltersAndRenameRegions, which additionally deserializes a serialized filter chain; the MAF methods use it.
CoreGetFeatures, CoreGetRegionByteEstimate and CoreGetExportData all use
the plural base.
Returning ArrayBuffers zero-copy
Wrap the result with rpcResult to transfer ArrayBuffers instead of copying
them. The MAF alignment method returns several typed arrays this way:
const regionData: MafWireRegionData = { ...packed, coverage, refSampleId }
const result: LinearMafGetAlignmentDataResult = {
samples,
treeNewick,
samplesCanonical: hasConfiguredSamples,
regionData,
bytes,
}
// second arg is the transfer list: these buffers are moved to the main
// thread, not structured-cloned. collectMafTransferables walks the result and
// gathers every ArrayBuffer in it — a fixed handful, because the wire is
// columnar; see that function for why the length of this list is what the
// whole shape is designed around.
return rpcResult(result, collectMafTransferables(regionData))
A transferred buffer is neutered in the worker — it has zero length there afterwards. That is fine for a value you are returning and never touch again, and a bug if the worker keeps it in a cache.
Registering the method
addRpcMethod takes a factory, called once per realm — the main thread and each
worker construct their own instance:
import GetScoreData from './GetScoreData.ts'
import type PluginManager from '@jbrowse/core/PluginManager'
export default function ScoreRPCF(pluginManager: PluginManager) {
pluginManager.addRpcMethod(() => new GetScoreData(pluginManager))
}
Call that from your plugin's install(), alongside whatever else the plugin
registers:
import Plugin from '@jbrowse/core/Plugin'
import LinearScoreDisplayF from './LinearScoreDisplay/index.ts'
import ScoreFeaturePanelF from './ScoreFeaturePanel/index.tsx'
import ScoreRPCF from './ScoreRPC/index.ts'
import type PluginManager from '@jbrowse/core/PluginManager'
export default class ScoreExamplePlugin extends Plugin {
name = 'ScoreExamplePlugin'
install(pluginManager: PluginManager) {
LinearScoreDisplayF(pluginManager)
ScoreRPCF(pluginManager)
ScoreFeaturePanelF(pluginManager)
}
}
Calling from the main thread
getRpcSessionId(self) is the sticky session id and
getSession(self).rpcManager dispatches. Note that sessionId is not
repeated inside the args object — call injects it from its first parameter.
Two fields work that way, and neither belongs in a registry entry: sessionId
(RpcSession) and the stopToken/statusCallback pair (RpcHandles). They
are properties of the call, so every method accepts them and no entry gets to
require or refuse one. EntriesDeclaringCallLevelFields in RpcRegistry.ts
fails compilation, naming the entry, if one declares either.
A per-region display does not await the call itself. fetchEachRegion owns
cancellation, stop tokens and staleness, so LinearScoreDisplay hands it the
call and a place to put each result:
// called by the fetch autorun for the regions that need loading;
// fetchEachRegion handles cancellation, stop tokens and staleness
fetchNeeded(needed: { region: Region; displayedRegionIndex: number }[]) {
// no `if (!adapterConfig)` guard: the `adapter` slot is a union of the
// registered adapter schemas, all of which are creatable from an empty
// snapshot, so MST always materializes an object there and the guard
// could never fire
const { adapterConfig } = self
return fetchEachRegion(self, needed, {
// `ctx.callRpc`, never `rpcManager.call`: the context injects this
// fetch's stop token and its status callback, and forgetting either
// is silent — no cancellation for this display, or no progress. The
// callback here is this region's own slot in the fan-out, so the N
// parallel calls aggregate into one bar instead of overwriting each
// other
call: (region, ctx) =>
ctx.callRpc('GetScoreData', {
adapterConfig,
region,
...self.rpcProps(),
}),
onResult: (idx, result) => {
self.setRpcData(idx, result)
},
})
},
See Data fetching pipeline for what fetchEachRegion does
with that, and Plotting features in a custom display for the rest of the
model. A one-off call — a dialog, a widget — needs none of it and can
await rpcManager.call(...) directly.
What can cross the worker boundary
The worker boundary uses the Structured Clone Algorithm. Safe types:
- Primitives:
string,number,boolean,null,undefined ArrayBuffer, typed arrays (Uint8Array,Float32Array, …) - use therpcResulttransfer list to avoid copyingFile,Blob- Plain objects and arrays (recursively)
Map,Set,Date,RegExp
Not safe, filtered out automatically:
- Functions and callbacks - use the
statusCallbackmechanism below - MST model nodes or observables
- Circular references
Status callbacks
The RPC layer intercepts statusCallback props and channels them back to the
main thread, which is the one exception to "no functions cross the boundary" —
the function never actually goes; a side-channel does. The main-thread half is
the statusCallback in the call above, which a display reads off its
FetchContext. In a per-region fan-out that context is the region's own, so its
callback is that region's slot in the loading UI and the N of them aggregate
into one bar.
In the worker it arrives deserialized and is called normally. Hand it down to
whatever does the slow work so the message tracks the download — GetScoreData
above passes it into getFeaturesArray.
Type-registering your method
The declare module '@jbrowse/core/rpc/RpcRegistry' block at the top of
GetScoreData above is what types rpcManager.call at every call site — and
ctx.callRpc with it, which forwards the same registry lookup, so a fetch gets
per-method arg inference and a typed return without naming the session id.
Without the registration both overloads fall back to any, so a misspelled arg
or a wrong assumption about the return type compiles. It goes in the file that
defines the method, so the two can't drift.
Worker count and configuration
workerCount defaults to 0, which means "decide from hardware":
clamp(hardwareConcurrency - 1, 1, 5). Set it to pin a count instead:
{
"configuration": {
"rpc": {
"defaultDriver": "WebWorkerRpcDriver",
"workerCount": 4
}
}
}
(Older sessions stored workerCount under a per-driver drivers map; that
shape is still read and hoisted to the flat slot on load.)
See also
- Data flow, end to end
- Data fetching pipeline
- GPU displays
- Custom adapters
- RefName aliasing
- PROGRESS_REPORTING.md
— what the
statusCallbackabove feeds: determinate bars, aggregation across concurrent fetches, and cancel