mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-11 04:00:38 +00:00
481 lines
22 KiB
TypeScript
481 lines
22 KiB
TypeScript
/**
|
|
* Worker assembly entry: the whole harness Cordis tree inside one dedicated
|
|
* Web Worker.
|
|
*
|
|
* Every platform object arrives through options — the `node:*` proxy table, the
|
|
* request listener the app's fake `node:http` captured, the image bytes — so this
|
|
* package never reaches back into the application that composes it. **Platform
|
|
* readiness before the call is the caller's responsibility**: anything the
|
|
* proxies need initialized (the zstd WebAssembly module, for one) must be ready
|
|
* before {@link startWorkerHost} runs.
|
|
*
|
|
* Construction is split in two on purpose. {@link createWorkerHost} is
|
|
* synchronous so the worker can accept messages and queue requests that arrive
|
|
* during boot; {@link WorkerHost.start} then mounts the image, the module
|
|
* loader, and the tree. {@link startWorkerHost} performs both and installs the
|
|
* message handler before its first await.
|
|
*
|
|
* The tree itself boots through the host's own `boot()` glue loaded from the
|
|
* image, so entry mounting, the activation audit, and its diagnostics are the
|
|
* same code the Node deployment runs. Only the module seam and the command line
|
|
* are supplied from here.
|
|
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/worker-host
|
|
*/
|
|
import { setActiveModuleLoader, WorkerModuleLoader, type StaticModuleFactory } from './module-system/module-loader.ts'
|
|
import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway'
|
|
import type { AlsCausality } from './polyfill/async-context/als-runtime.ts'
|
|
import { dirname, join } from './module-system/posix-path.ts'
|
|
import { installProcessGlobal } from './node/globals/process.ts'
|
|
import type { RequestListener } from './transport/synthetic-http.ts'
|
|
import { TunnelServer, type TunnelPort } from './transport/tunnel.ts'
|
|
import { inflateImage, inflateImageStream } from './storage/image-gzip.ts'
|
|
import { loadVfsImage, loadVfsOverlay, MemoryVfs } from './storage/memory.ts'
|
|
import { setActiveVfs } from './storage/active.ts'
|
|
import {
|
|
DEFAULT_ROOT, IMAGE_CONFIG_PATH, IMAGE_EMPTY_DIRECTORIES, IMAGE_HOME_DIRECTORY, IMAGE_MANIFEST_PATH,
|
|
LOWERING_VERSION,
|
|
} from './image-layout.ts'
|
|
|
|
export { DEFAULT_ROOT } from './image-layout.ts'
|
|
|
|
/** Port reported to the tree when the caller names none; the bind is fake either way. */
|
|
export const DEFAULT_PORT = 3080
|
|
|
|
// Every literal `require`/`resolve` of an image package below must appear in
|
|
// the packer's IMAGE_ENTRY_SEEDS: no image file references these requests, so
|
|
// the reachability sweep only keeps them when seeded.
|
|
|
|
/** One structured log record, as cordis delivers it to an exporter. */
|
|
export interface LogMessage {
|
|
readonly name: string
|
|
readonly type: 'error' | 'info' | 'warn' | 'debug'
|
|
readonly args: readonly unknown[]
|
|
}
|
|
|
|
/** The exporter face `ctx.logger.exporter()` accepts. */
|
|
export interface LogExporter {
|
|
readonly colors: false
|
|
/** Verbosity gate, per logger name or `default`; cordis drops a message when its level exceeds this. */
|
|
readonly levels: { readonly default: number }
|
|
export(message: LogMessage): void
|
|
}
|
|
|
|
/** Minimal view of the Cordis context the entry itself touches. */
|
|
export interface HostContext {
|
|
loader: { internal: unknown }
|
|
logger: { exporter(exporter: LogExporter): unknown }
|
|
get(service: string): unknown
|
|
provide(name: string, value: unknown): void
|
|
fiber: { dispose(): Promise<void> }
|
|
}
|
|
|
|
/** Construction inputs for {@link createWorkerHost}. */
|
|
export interface WorkerHostOptions {
|
|
/**
|
|
* Modules served from the worker bundle rather than the image: the `node:*`
|
|
* proxies, the not-implemented stubs for excluded npm packages, and anything else whose
|
|
* platform behavior differs. `node:process` and `process` are added when absent,
|
|
* as factories reading the installed global.
|
|
*/
|
|
readonly staticModules: Readonly<Record<string, StaticModuleFactory>>
|
|
/** Prefix-matched proxies, for packages whose subpaths are open-ended. */
|
|
readonly staticModulePrefixes?: Readonly<Record<string, StaticModuleFactory>>
|
|
/**
|
|
* The webserver's request listener, captured by the app's fake `node:http`.
|
|
* Awaited on first tunnel use, so it may resolve after the tree binds.
|
|
*/
|
|
readonly requestListener: () => Promise<RequestListener>
|
|
/** Image bytes, or the URL the worker fetches them from. */
|
|
readonly image: Uint8Array | string
|
|
/** Ordered data overlays applied after the base image and before boot. */
|
|
readonly overlays?: readonly (Uint8Array | string)[]
|
|
/** Virtual root; defaults to {@link DEFAULT_ROOT}. */
|
|
readonly root?: string
|
|
/** Composed configuration inside the image; defaults to `<root>/config/cordis.yml`. */
|
|
readonly configPath?: string
|
|
/**
|
|
* Inner arguments the tree parses. The default binds the web server to the
|
|
* loopback authority the tunnel synthesizes, which also keeps
|
|
* `networkInterfaces()` out of the trust snapshot.
|
|
*/
|
|
readonly cmdlineArgs?: readonly string[]
|
|
/** Port named on the default command line; defaults to {@link DEFAULT_PORT}. */
|
|
readonly port?: number
|
|
/** Environment for the process shim; `DSH_HOME` defaults to `<root>/home`. */
|
|
readonly env?: Readonly<Record<string, string>>
|
|
/**
|
|
* Image manifest path; defaults to `<root>/config/vfs-manifest.json`. Its
|
|
* `lowered` field must name this build's wrapper contract.
|
|
*/
|
|
readonly manifestPath?: string
|
|
/**
|
|
* Ambient-store snapshot face exported by the app's `node:async_hooks` proxy.
|
|
* The rewrite that carries stores across suspension points moves state through
|
|
* it; the proxy remains the only owner of that state.
|
|
*/
|
|
readonly alsCausality?: AlsCausality
|
|
/** Privileged API methods that skip the route lane; see {@link TunnelServer}. */
|
|
readonly privilegedMethods?: ReadonlySet<string>
|
|
/** Escape hatch for the unary `/api` lane; see {@link TunnelServer}. */
|
|
readonly unaryApiLane?: 'route' | 'direct'
|
|
/** Channel back to the page; defaults to the worker global scope. */
|
|
readonly channel?: TunnelPort
|
|
}
|
|
|
|
/** The assembled worker host. */
|
|
export interface WorkerHost {
|
|
/** Feed one `postMessage` payload; safe before {@link WorkerHost.start}. */
|
|
handleMessage(data: unknown): void
|
|
/**
|
|
* Mount the image and boot the tree, then start serving queued requests.
|
|
* @returns Resolves once the tree is active and the tunnel is serving.
|
|
*/
|
|
start(): Promise<void>
|
|
/** Dispose the tree; the tunnel keeps refusing afterwards. */
|
|
stop(): Promise<void>
|
|
/** Filesystem the tree reads, once {@link WorkerHost.start} mounted it. */
|
|
readonly vfs: MemoryVfs | undefined
|
|
/** Module loader behind the Cordis module seam. */
|
|
readonly modules: WorkerModuleLoader | undefined
|
|
}
|
|
|
|
function requireGlobalPort(channel: TunnelPort | undefined): TunnelPort {
|
|
if (channel !== undefined) return channel
|
|
const scope = globalThis as { postMessage?: TunnelPort['postMessage'] }
|
|
const post = scope.postMessage
|
|
if (typeof post !== 'function') {
|
|
throw new Error('webworker host: no channel; pass options.channel outside a dedicated worker')
|
|
}
|
|
return { postMessage: (message, transfer) => { post(message, transfer) } }
|
|
}
|
|
|
|
async function readImage(image: Uint8Array | string): Promise<Uint8Array> {
|
|
if (typeof image !== 'string') return await inflateImage(image, 'the image bytes given to createWorkerHost')
|
|
const response = await fetch(image)
|
|
if (!response.ok) throw new Error(`webworker host: image fetch failed with ${String(response.status)} for ${image}`)
|
|
if (response.body === null) throw new Error(`webworker host: image response for ${image} carried no body`)
|
|
// Inflated off the response stream: the archive is built while the rest of the
|
|
// image is still arriving.
|
|
return await inflateImageStream(response.body, image)
|
|
}
|
|
|
|
/**
|
|
* Build the worker host without touching the network or the image.
|
|
* @param options - Assembly inputs.
|
|
* @returns Handle whose `handleMessage` is ready immediately.
|
|
*/
|
|
export function createWorkerHost(options: WorkerHostOptions): WorkerHost {
|
|
const root = options.root ?? DEFAULT_ROOT
|
|
const configPath = options.configPath ?? join(root, IMAGE_CONFIG_PATH)
|
|
const port = options.port ?? DEFAULT_PORT
|
|
const tunnel = new TunnelServer({
|
|
port: requireGlobalPort(options.channel),
|
|
requestListener: options.requestListener,
|
|
...options.privilegedMethods === undefined ? {} : { privilegedMethods: options.privilegedMethods },
|
|
...options.unaryApiLane === undefined ? {} : { unaryApiLane: options.unaryApiLane },
|
|
})
|
|
|
|
let vfs: MemoryVfs | undefined
|
|
let modules: WorkerModuleLoader | undefined
|
|
let context: HostContext | undefined
|
|
|
|
const start = async (): Promise<void> => {
|
|
try {
|
|
const home = join(root, IMAGE_HOME_DIRECTORY)
|
|
installProcessGlobal({ cwd: root, env: { DSH_HOME: home, HOME: home, ...options.env } })
|
|
|
|
const [bytes, overlays] = await Promise.all([
|
|
readImage(options.image),
|
|
Promise.all((options.overlays ?? []).map(readImage)),
|
|
])
|
|
const mounted = loadVfsImage(bytes, root)
|
|
for (const overlay of overlays) loadVfsOverlay(overlay, root, mounted)
|
|
// Belt and braces over the image's own empty-directory entries: a hand
|
|
// -built image without them still boots.
|
|
for (const directory of IMAGE_EMPTY_DIRECTORIES) {
|
|
mounted.seedDirectory(join(root, directory.replace(/\/$/, '')))
|
|
}
|
|
setActiveVfs(mounted)
|
|
vfs = mounted
|
|
|
|
const manifestPath = options.manifestPath ?? join(root, IMAGE_MANIFEST_PATH)
|
|
requireLoweredImage(mounted, manifestPath)
|
|
const staticModules: Record<string, StaticModuleFactory> = { ...options.staticModules }
|
|
// Read at require time, not here: the table entry then answers whichever
|
|
// global `installProcessGlobal` left in place, in this role's order.
|
|
for (const key of ['node:process', 'process']) {
|
|
staticModules[key] ??= (): unknown => (globalThis as { process?: unknown }).process
|
|
}
|
|
const loader = new WorkerModuleLoader({
|
|
vfs: mounted,
|
|
root,
|
|
staticModules,
|
|
...options.staticModulePrefixes === undefined ? {} : { staticModulePrefixes: options.staticModulePrefixes },
|
|
...options.alsCausality === undefined ? {} : { alsCausality: options.alsCausality },
|
|
})
|
|
setActiveModuleLoader(loader)
|
|
modules = loader
|
|
|
|
const require = loader.requireFrom(dirname(configPath))
|
|
const appBoot = require('@deepseek-ai/dsh-app-boot') as {
|
|
boot(
|
|
binName: string,
|
|
configPath: string,
|
|
patches: unknown[],
|
|
prepare: (ctx: HostContext) => void,
|
|
): Promise<HostContext>
|
|
}
|
|
const cmdline = require('@deepseek-ai/dsh-cmdline') as {
|
|
provideCmdline(ctx: unknown, host: { args: readonly string[]; exit: (code: number) => void }): void
|
|
}
|
|
|
|
const { patches, presetOverlay } = bootPatches(loader, mounted, configPath, root)
|
|
const ctx = await appBoot.boot('dsh-webworker', configPath, patches, (hostCtx) => {
|
|
// Before any entry mounts: the Loader would otherwise fall back to the
|
|
// runtime's own dynamic import for every row.
|
|
hostCtx.loader.internal = loader.internal
|
|
installLogSink(hostCtx, require)
|
|
cmdline.provideCmdline(hostCtx, {
|
|
args: [...(options.cmdlineArgs ?? ['--host', '127.0.0.1', '--port', String(port), '--no-open'])],
|
|
exit: (code: number) => { console.warn(`webworker host: tree requested exit(${String(code)})`) },
|
|
})
|
|
})
|
|
context = ctx
|
|
|
|
const apiProxy = ctx.get('apiProxy')
|
|
if (apiProxy === undefined) throw new Error('webworker host: the tree activated without an apiProxy service')
|
|
const typertGateway = ctx.get('typertGateway') as TypertGateway | undefined
|
|
if (typertGateway === undefined) {
|
|
throw new Error('webworker host: the tree activated without a typertGateway service')
|
|
}
|
|
const { toFetchHandler } = require('@deepseek-ai/dsh-host-apiproxy') as {
|
|
toFetchHandler: (api: unknown) => { fetch(request: Request): Promise<Response> }
|
|
}
|
|
const shared = ctx.get('connection') !== undefined
|
|
const handler = directFetchHandler(ctx, toFetchHandler(apiProxy))
|
|
const usage = loader.usage()
|
|
console.info(`webworker host: tree active (modules=${String(usage.modules)}, data overlays=${String(overlays.length)}, preset root overlay=${presetOverlay ? 'applied' : 'already in roster'}, direct lane=${shared ? 'connection.createSharedFetchHandler (interceptors kept)' : 'api surface only'}, als causality=${options.alsCausality === undefined ? 'inert' : 'snapshot/restore'}, image lowering=${LOWERING_VERSION})`)
|
|
|
|
tunnel.serve({
|
|
directFetch: (request: Request) => handler.fetch(request),
|
|
bootPayload: () => readBootPayload(ctx),
|
|
openStream: typertGateway.wireStream.open,
|
|
streamFailure: typertGateway.wireStream.failure,
|
|
})
|
|
} catch (reason) {
|
|
tunnel.fail(reason)
|
|
throw reason
|
|
}
|
|
}
|
|
|
|
return {
|
|
handleMessage: (data: unknown): void => { tunnel.handleMessage(data) },
|
|
start,
|
|
stop: async (): Promise<void> => {
|
|
tunnel.fail(new Error('webworker host: the tree was disposed'))
|
|
await context?.fiber.dispose()
|
|
},
|
|
get vfs(): MemoryVfs | undefined {
|
|
return vfs
|
|
},
|
|
get modules(): WorkerModuleLoader | undefined {
|
|
return modules
|
|
},
|
|
}
|
|
}
|
|
|
|
/** The cordis message renderer this sink formats through. */
|
|
export interface LogRenderer {
|
|
format(exporter: LogExporter, message: LogMessage): string
|
|
}
|
|
|
|
/**
|
|
* Send the tree's own warnings and errors to the worker console.
|
|
*
|
|
* Cordis's `LoggerService` always exists and always accepts messages, but with
|
|
* no exporter mounted it only fills a ring buffer — and no profile in this
|
|
* repository mounts one, so `ctx.logger.warn(...)` reaches nothing. A provider
|
|
* that fails and is skipped (the skill registry logs exactly that) then looks
|
|
* identical to one that found nothing, which is how an empty skill catalog hid a
|
|
* filesystem fault twice.
|
|
*
|
|
* Warnings and errors only: `info`/`debug` from 131 plugin rows would bury the
|
|
* page console, and this exists to make failures visible rather than to trace.
|
|
* @param ctx - Host context, before any entry mounts.
|
|
* @param require - Image resolver, for cordis's own message renderer.
|
|
*/
|
|
export function installLogSink(ctx: HostContext, require: (specifier: string) => unknown): void {
|
|
const { Logger } = require('@deepseek-ai/cordis') as { Logger: LogRenderer }
|
|
const exporter: LogExporter = {
|
|
colors: false,
|
|
// cordis compares `exporter.levels ?? logger.level ?? INFO` against the
|
|
// message level and drops anything higher, and its scale counts UP with
|
|
// verbosity (ERROR 0, INFO 1, WARN 2, DEBUG 3). An exporter that declares no
|
|
// level therefore admits errors and info but silently drops every warning —
|
|
// which is what the built-in ring-buffer exporter does, so the skipped-provider
|
|
// warning this sink exists for never even reached the buffer.
|
|
levels: { default: 2 },
|
|
export: (message) => {
|
|
if (message.type !== 'warn' && message.type !== 'error') return
|
|
const line = `${message.name}: ${Logger.format(exporter, message)}`
|
|
if (message.type === 'error') console.error(line)
|
|
else console.warn(line)
|
|
},
|
|
}
|
|
ctx.logger.exporter(exporter)
|
|
}
|
|
|
|
/**
|
|
* Require the mounted image to carry bodies this build can wrap.
|
|
*
|
|
* The manifest the packer writes is the single source of truth: the worker holds
|
|
* no transform, so an image that was never lowered — or was lowered against
|
|
* different wrapper semantics — cannot be recovered at load and must be rebuilt.
|
|
* @param vfs - Mounted filesystem.
|
|
* @param path - Manifest path inside the image.
|
|
* @throws When the manifest is missing, unreadable, or names another contract.
|
|
*/
|
|
function requireLoweredImage(vfs: MemoryVfs, path: string): void {
|
|
if (!vfs.existsSync(path)) {
|
|
throw new Error(`webworker host: ${path} is missing, so the image records no lowering; rebuild the image`)
|
|
}
|
|
const parsed: unknown = JSON.parse(vfs.readFileSync(path, 'utf8') as string)
|
|
if (typeof parsed !== 'object' || parsed === null) {
|
|
throw new Error(`webworker host: ${path} does not hold an object`)
|
|
}
|
|
const lowered = (parsed as { lowered?: unknown }).lowered
|
|
if (lowered !== LOWERING_VERSION) {
|
|
throw new Error(`webworker host: image was lowered by ${String(lowered)}, this build runs ${LOWERING_VERSION}; rebuild the image`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the tunnel's direct API entry.
|
|
*
|
|
* The core API surface alone is not the whole `/api` channel: Typert RPC
|
|
* endpoints (`/api/<service>/<method>`) are served by an interceptor the gateway
|
|
* registers on the Connection service, and answer 404 from the core routes. The
|
|
* Connection service composes both halves in `createSharedFetchHandler`, whose
|
|
* fallback — not the composition — carries network authentication and trust, so
|
|
* composing it here keeps every interceptor while leaving out the fences the
|
|
* worker-local direct lane exists to bypass.
|
|
* @param ctx - Booted host context.
|
|
* @param core - Fetch handler over the API surface.
|
|
* @returns Handler covering interceptors and the core surface.
|
|
*/
|
|
function directFetchHandler(
|
|
ctx: HostContext,
|
|
core: { fetch(request: Request): Promise<Response> },
|
|
): { fetch(request: Request): Promise<Response> } {
|
|
const connection = ctx.get('connection') as {
|
|
createSharedFetchHandler(
|
|
channel: '/api',
|
|
fallback: { fetch(request: Request): Promise<Response> },
|
|
): { fetch(request: Request): Promise<Response> }
|
|
} | undefined
|
|
return connection?.createSharedFetchHandler('/api', core) ?? core
|
|
}
|
|
|
|
/**
|
|
* The shipped preset root, as the application layer that owns the composition
|
|
* supplies it.
|
|
*
|
|
* A launcher appends this root itself rather than writing it into the roster —
|
|
* `apps/cli` does it in `composeProfile` (`profile-boot.ts:159-166`) because only
|
|
* the application knows where its own presets sit. The worker's presets travel
|
|
* in the image, so the same overlay names their virtual path. Patching replaces
|
|
* a row's whole `config`, so the current one is read and spread, and a roster
|
|
* that already names roots keeps them.
|
|
* @param loader - Module loader, for the image's YAML reader.
|
|
* @param vfs - Filesystem holding the composed configuration.
|
|
* @param configPath - Composed configuration path.
|
|
* @param root - Virtual root.
|
|
* @returns Boot patches (preset root overlay, frontend serving off) and
|
|
* whether the preset overlay was applied.
|
|
*/
|
|
function bootPatches(
|
|
loader: WorkerModuleLoader,
|
|
vfs: MemoryVfs,
|
|
configPath: string,
|
|
root: string,
|
|
): { patches: unknown[]; presetOverlay: boolean } {
|
|
const text = vfs.readFileSync(configPath, 'utf8') as string
|
|
let rows: unknown
|
|
if (configPath.endsWith('.json')) {
|
|
rows = JSON.parse(text)
|
|
} else {
|
|
// The roster's `!!js` scalars need Include's own YAML dialect.
|
|
const include = loader.load(loader.resolve('@deepseek-ai/cordis-plugin-include', root)) as { entryListSchema: unknown }
|
|
const yaml = loader.load(loader.resolve('js-yaml', root)) as { load(source: string, options: { schema: unknown }): unknown }
|
|
rows = yaml.load(text, { schema: include.entryListSchema })
|
|
}
|
|
const find = (entries: unknown, id: string): Record<string, unknown> | undefined => {
|
|
if (!Array.isArray(entries)) return undefined
|
|
for (const entry of entries as Array<Record<string, unknown>>) {
|
|
if (entry.id === id) return entry
|
|
const nested = find(entry.config, id)
|
|
if (nested !== undefined) return nested
|
|
}
|
|
return undefined
|
|
}
|
|
const configOf = (row: Record<string, unknown>): Record<string, unknown> =>
|
|
(typeof row.config === 'object' && row.config !== null && !Array.isArray(row.config)
|
|
? row.config
|
|
: {}) as Record<string, unknown>
|
|
|
|
const patches: unknown[] = []
|
|
let presetOverlay = false
|
|
const presets = find(rows, 'agent-presets')
|
|
if (presets !== undefined && configOf(presets).roots === undefined) {
|
|
presetOverlay = true
|
|
patches.push({
|
|
id: 'agent-presets',
|
|
config: { ...configOf(presets), roots: [{ path: join(root, 'config/agent-presets'), trust: 'system' }] },
|
|
})
|
|
}
|
|
// The worker carries no compression codec, and the VFS is in-memory anyway:
|
|
// the JSONL backend's plaintext path is the composition's one legal encoding.
|
|
const jsonl = find(rows, 'session-persistence-jsonl')
|
|
if (jsonl !== undefined) {
|
|
patches.push({ id: 'session-persistence-jsonl', config: { ...configOf(jsonl), compression: 'none' } })
|
|
}
|
|
return { patches, presetOverlay }
|
|
}
|
|
|
|
/**
|
|
* Assemble the payload the page's pre-Cordis bootstrap needs: the structured
|
|
* index injection table the served form renders into index.html. Collected
|
|
* from the in-process webserver service, never from the API surface, because
|
|
* the page has no Cordis tree yet.
|
|
* @param ctx - Booted host context.
|
|
* @returns Boot payload for `GET /__boot__`.
|
|
*/
|
|
function readBootPayload(ctx: HostContext): { injections: unknown } {
|
|
const webServer = ctx.get('webServer') as { collectIndexInjections(): unknown } | undefined
|
|
if (webServer === undefined) {
|
|
throw new Error('webworker host: no webServer service, so the page cannot receive its boot injections')
|
|
}
|
|
return { injections: webServer.collectIndexInjections() }
|
|
}
|
|
|
|
/**
|
|
* Install the message handler and boot the tree.
|
|
*
|
|
* The handler is attached before the first await, so requests that arrive
|
|
* during boot queue instead of being dropped. A boot failure refuses the queue
|
|
* with 503 and rejects.
|
|
* @param options - Assembly inputs; `channel` also replaces the message source.
|
|
* @returns Resolves once the tunnel is serving.
|
|
*/
|
|
export async function startWorkerHost(options: WorkerHostOptions): Promise<void> {
|
|
const host = createWorkerHost(options)
|
|
if (options.channel === undefined) {
|
|
const scope = globalThis as { addEventListener?: (type: string, listener: (event: MessageEvent) => void) => void }
|
|
if (typeof scope.addEventListener !== 'function') {
|
|
throw new Error('webworker host: no message source; pass options.channel outside a dedicated worker')
|
|
}
|
|
scope.addEventListener('message', (event: MessageEvent) => { host.handleMessage(event.data) })
|
|
}
|
|
await host.start()
|
|
}
|