Skip to content

Web Worker Runtime

@omnicajs/vue-remote does not require iframe-only integration. If your extension model does not need a visual remote document, a dedicated Web Worker can host remote rendering logic and still drive host UI through the same channel contract.

This mode is useful when you want:

  • isolation from the host main thread execution context;
  • predictable runtime topology without hidden iframe lifecycle;
  • a transport that can later evolve into a process/socket bridge.
Host Vue App (main thread)
-> createReceiver()
-> HostedTree(provider, receiver)
-> Endpoint(fromWebWorker(worker))
-> call.run(receiver.receive, hostBridge)
Remote Vue App (dedicated worker)
-> createEndpoint(self-as-MessageEndpoint)
-> expose run/release
-> createRemoteRoot(channel)
-> createRemoteRenderer(root).createApp(...)
import type { Channel } from '@omnicajs/vue-remote/host'
import type { Endpoint } from '@remote-ui/rpc'
import { createApp, defineComponent, h, onBeforeUnmount, onMounted } from 'vue'
import { HostedTree, createProvider, createReceiver } from '@omnicajs/vue-remote/host'
import { createEndpoint, fromWebWorker } from '@remote-ui/rpc'
import VSignalBadge from './components/VSignalBadge.vue'
type HostBridge = {
acknowledge(id: string): void;
}
type WorkerApi = {
run(channel: Channel, bridge: HostBridge): Promise<void>;
release(): void;
}
const provider = createProvider({
VSignalBadge,
})
export function mountWorkerRemote() {
return createApp(defineComponent({
setup() {
const receiver = createReceiver()
const worker = new Worker(new URL('./remote.worker.ts', import.meta.url), { type: 'module' })
let endpoint: Endpoint<WorkerApi> | null = null
onMounted(() => {
endpoint = createEndpoint<WorkerApi>(fromWebWorker(worker))
endpoint.call.run(receiver.receive, {
acknowledge(id: string) {
console.info('Acknowledged signal', id)
},
})
})
onBeforeUnmount(() => {
endpoint?.call.release()
endpoint?.terminate()
})
return () => h(HostedTree, { provider, receiver })
},
}))
}

remote.worker.ts:

import type { MessageEndpoint } from '@remote-ui/rpc'
import type { Channel } from '@omnicajs/vue-remote/host'
import { createEndpoint, release, retain } from '@remote-ui/rpc'
import { createRemoteRenderer, createRemoteRoot, defineRemoteComponent } from '@omnicajs/vue-remote/remote'
import { defineComponent, h, ref } from 'vue'
type HostBridge = {
acknowledge(id: string): void;
}
const endpoint = createEndpoint(self as unknown as MessageEndpoint)
const VSignalBadge = defineRemoteComponent<'VSignalBadge', {
tone: 'neutral' | 'success' | 'warning';
label: string;
}>('VSignalBadge', [
'dismiss',
] as unknown as {
dismiss: (id: string) => true;
})
let onRelease = () => {}
endpoint.expose({
async run(channel: Channel, bridge: HostBridge) {
retain(channel)
retain(bridge)
const root = createRemoteRoot(channel, {
components: ['VSignalBadge'],
})
await root.mount()
const app = createRemoteRenderer(root).createApp(defineComponent({
setup() {
const signalId = ref('signal-42')
const visible = ref(true)
return () => visible.value
? h(VSignalBadge, {
tone: 'warning',
label: 'Background sync is delayed',
onDismiss: (id: string) => {
visible.value = false
bridge.acknowledge(id || signalId.value)
},
})
: h('p', 'All clear')
},
}))
app.mount(root)
onRelease = () => {
release(channel)
release(bridge)
app.unmount()
}
},
release() {
onRelease()
},
})

If your extension is already built and published as static assets, you can load worker code by URL.

Use this when host app and extension assets share the same origin.

const worker = new Worker('/extensions/acme/remote.worker.js', {
type: 'module',
})
const endpoint = createEndpoint<WorkerApi>(fromWebWorker(worker))
await endpoint.call.run(receiver.receive, hostBridge)

This is the simplest option and usually the most stable for production.

Cross-origin URL (same-origin bootstrap + dynamic import)

Section titled “Cross-origin URL (same-origin bootstrap + dynamic import)”

For cross-origin assets, start a local bootstrap worker and let it import the remote module with CORS.

Host side:

import { createEndpoint, fromWebWorker } from '@remote-ui/rpc'
type WorkerApi = {
ready(): boolean;
run(channel: Channel, bridge: HostBridge): Promise<void>;
release(): void;
}
const extensionUrl = encodeURIComponent('https://extensions.example-cdn.com/acme/remote.worker.js')
const worker = new Worker(`/workers/remote-bootstrap.worker.js?extension=${extensionUrl}`, {
type: 'module',
})
const endpoint = createEndpoint<WorkerApi>(fromWebWorker(worker))
await endpoint.call.ready()
await endpoint.call.run(receiver.receive, hostBridge)

/workers/remote-bootstrap.worker.js:

import type { MessageEndpoint } from '@remote-ui/rpc'
import type { Channel } from '@omnicajs/vue-remote/host'
import { createEndpoint } from '@remote-ui/rpc'
type HostBridge = {
acknowledge(id: string): void;
}
type ExtensionApi = {
run(channel: Channel, bridge: HostBridge): Promise<void>;
release(): void;
}
const extensionUrl = new URL(self.location.href).searchParams.get('extension')
if (!extensionUrl) {
throw new Error('Missing extension URL')
}
const mod = await import(/* @vite-ignore */ extensionUrl) as ExtensionApi
const endpoint = createEndpoint<{
ready(): boolean;
run(channel: Channel, bridge: HostBridge): Promise<void>;
release(): void;
}>(self as unknown as MessageEndpoint)
endpoint.expose({
ready: () => true,
run: mod.run,
release: mod.release,
})

Cross-origin requirements:

  • worker bootstrap must be same-origin with host;
  • remote URL must allow CORS for module loading;
  • CSP must allow configured worker-src and script-src;
  • extension URL should be validated by allowlist/signature policy before launch.
  1. No direct browser DOM in remote runtime. Worker code should stay declarative and communicate intent through props/events/methods.
  2. Keep boundary payloads serializable. Treat any non-serializable value as a contract issue at host/remote boundary.
  3. Separate render transport from business transport. channel is for UI tree sync, hostBridge is for product behavior.
  • For SharedWorker or custom multi-peer topologies, prefer a MessagePort-based handshake and build endpoint on top of that port.
  • Keep run/release symmetric and idempotent to simplify lifecycle and future debugger integration.
  • If you later move worker logic to a socket/runtime service, preserve the same channel semantics first, then optimize transport details.