@repliql/shared-service
v0.1.0
Published
> Per-tab service instances exposed from a SharedWorker, with automatic lifecycle and tab-death detection.
Readme
@repliql/shared-service
Per-tab service instances exposed from a SharedWorker, with automatic lifecycle and tab-death detection.
@repliql/shared-service is a thin lifecycle layer on top of comlink for SharedWorkers. You declare named services on the worker side; each connecting tab gets its own per-tab instance via onConnectTab(tabId), and disconnects (including crashed tabs) trigger onDisconnectTab(tabId, instance) so you can clean up.
Install
npm install @repliql/shared-service comlink
# or
bun add @repliql/shared-service comlinkcomlink is a peer dependency.
Why
- Per-tab state, not per-worker — each tab gets its own instance keyed by a stable
tabId. The worker can hold tab-scoped state (subscriptions, in-flight requests, etc.) without juggling tab identity itself. - Automatic teardown — when a tab closes (or crashes), Web Locks heartbeat detection invokes
onDisconnectTab. No leaked subscriptions. - Multiple services in one worker — register a map of services and consume them on the tab side as a typed object.
- Pure Comlink under the hood — no novel transport, no novel types.
Usage
Define your service
// MyCalculator.ts
export type MyCalculator = {
square: (n: number) => number
}shared.worker.ts — the hub
import { SharedServicesManager, type SharedService } from '@repliql/shared-service/shared'
import * as Comlink from 'comlink'
import type { MyCalculator } from './MyCalculator'
const calculator: SharedService<MyCalculator> = {
onConnectTab(tabId) {
return {
square(n) {
return n * n
},
}
},
onDisconnectTab(tabId, instance) {
// Clean up any tab-scoped state held by `instance`
},
}
const manager = new SharedServicesManager<{ calculator: MyCalculator }>({
services: { calculator },
})
onconnect = e => Comlink.expose(manager.connector, e.ports[0])tab.ts — the spoke
import { wrapSharedServices, type SharedServicesConnector } from '@repliql/shared-service/tab'
import * as Comlink from 'comlink'
import type { MyCalculator } from './MyCalculator'
const worker = new SharedWorker(new URL('./shared.worker.ts', import.meta.url))
const connector = Comlink.wrap<SharedServicesConnector>(worker.port)
const { calculator } = wrapSharedServices<{ calculator: MyCalculator }>(connector)
console.log(await calculator.square(2)) // 4Lifecycle
- The tab calls
wrapSharedServices, which acquires a Web Locks heartbeat for itstabIdand callsconnector.connect(tabId)on the hub. - The hub instantiates each registered service via
onConnectTab(tabId)and exposes them to the tab. - When the tab is closed or its process dies, the heartbeat lock releases. The hub detects the release and invokes
onDisconnectTab(tabId, instance)for each service so you can dispose of tab-scoped state.
This means crashed tabs are cleaned up just like cleanly closed ones — you don't need a pagehide listener for correctness.
API
SharedServicesManager
new SharedServicesManager<TServices>({
services: SharedServiceMap<TServices>,
heartbeat?: Heartbeat,
logger?: LoggerConfig,
})Hub-side registry. Expose manager.connector over Comlink on the SharedWorker's connect port.
SharedService<T>
interface SharedService<T> {
onConnectTab: (tabId: string) => T
onDisconnectTab?: (tabId: string, instance: T) => void
}The contract each registered service implements.
wrapSharedServices
function wrapSharedServices<TServices>(
connector: Remote<SharedServicesConnector>,
): RemoteServices<TServices>Spoke-side helper that returns a typed proxy where every service method is async (Comlink-wrapped).
License
MIT