deepseek-harness/packages/experimental/webworker-runtime/src/client/index.ts

165 lines
7 KiB
TypeScript

/**
* Page half: everything a deployment needs to reach a worker-hosted harness.
*
* This is **pre-Cordis glue, not a client plugin**: it installs the transport
* global and executes the boot injection table that the client plugin graph
* is later loaded through, so it cannot itself be a graph row. A page imports
* it directly and decides where the worker bundle and image live; nothing
* here mounts into a shipped roster.
* @module @deepseek-ai/dsh-experimental-webworker-runtime/client
*/
import { IMAGE_FILE_NAME } from '../image-layout.ts'
import { PREVIEW_FIXTURE_MANIFEST_FILE } from '../fixture-manifest.ts'
import { WorkerApiClient } from './api-client.ts'
import { WorkerTunnel, type TunnelFetch } from './client.ts'
import { applyIndexInjections } from './apply-injections.ts'
import { choosePreviewSource } from './source-chooser.ts'
export { WorkerApiClient } from './api-client.ts'
export { WorkerTunnel, type TunnelFetch } from './client.ts'
export { applyIndexInjections } from './apply-injections.ts'
export { IMAGE_FILE_NAME } from '../image-layout.ts'
export {
parsePreviewFixtureManifest, PREVIEW_FIXTURE_MANIFEST_FILE, PREVIEW_FIXTURE_MANIFEST_VERSION,
type PreviewFixtureManifest, type PreviewFixtureManifestEntry,
} from '../fixture-manifest.ts'
/** Transport global the connection plugin reads instead of building an HTTP carrier. */
interface ClientTransportGlobal {
__DSH_TRANSPORT__?: {
createApiClient: () => WorkerApiClient
fetch: TunnelFetch
openStream: (endpoint: string, payload: unknown, signal: AbortSignal) => AsyncIterable<unknown>
loadBundle: (url: string) => Promise<void>
/** The page spawned the worker the Host runs in, so the page owns it. */
ownsHost: boolean
}
}
/** Inputs for {@link connectWorkerHost}. */
export interface WorkerHostConnectOptions {
/**
* VFS image URL, the one deployment-shaped input. Defaults to
* {@link IMAGE_FILE_NAME} beside the page; a deployment that packs the
* image elsewhere passes its own URL. Data overlays are independent.
*/
readonly image?: string | URL
/** Ordered data overlay URLs, resolved against the page like the base image. */
readonly overlays?: readonly (string | URL)[]
}
/** Inputs for the optional pre-boot filesystem-source chooser. */
export interface WorkerHostSourceOptions {
/** Base VFS image URL; defaults to {@link IMAGE_FILE_NAME} beside the page. */
readonly image?: string | URL
/** Fixture catalog URL; defaults to {@link PREVIEW_FIXTURE_MANIFEST_FILE} beside the image. */
readonly fixtureManifest?: string | URL
}
/** Filesystem inputs selected before {@link connectWorkerHost}. */
export interface WorkerHostSource {
/** Ordered data overlays to pass through unchanged to the Host connection. */
readonly overlays: readonly URL[]
}
/** A page connected to a worker-hosted harness, ready to run a shell entry. */
export interface WorkerHostConnection {
readonly worker: Worker
readonly tunnel: WorkerTunnel
/** Bundle transport for the shell's boot seam. */
loadBundle(url: string): Promise<void>
}
/** Boot-readiness deferred shared with the client entry's pre-boot await. */
interface BootReadyGlobal {
__DSH_BOOT_READY__?: PromiseWithResolvers<void>
}
function bootReadyGate(): PromiseWithResolvers<void> {
return (globalThis as BootReadyGlobal).__DSH_BOOT_READY__ ??= Promise.withResolvers<void>()
}
/**
* Install the page boot barrier before an asynchronous source chooser waits
* for user input. The later {@link connectWorkerHost} call settles the same
* barrier.
*/
function holdWorkerHostBoot(): void {
const ready = bootReadyGate()
// A chooser may remain open indefinitely; if a later connection fails before
// the stock entry subscribes, retain the rejection without browser noise.
void ready.promise.catch(() => {})
}
/**
* Run the optional pre-boot source-selection stage. Calling this stage holds
* the stock shell until the caller passes its result to {@link connectWorkerHost};
* callers that need no chooser call `connectWorkerHost` directly and receive
* the base image with an empty overlay list.
* @param options - Base image and optional fixture-catalog locations.
* @returns The ordered overlays selected by the user.
*/
export async function chooseWorkerHostSource(
options: WorkerHostSourceOptions = {},
): Promise<WorkerHostSource> {
holdWorkerHostBoot()
const image = new URL(options.image ?? IMAGE_FILE_NAME, document.baseURI)
const manifest = new URL(options.fixtureManifest ?? PREVIEW_FIXTURE_MANIFEST_FILE, image)
try {
const overlays = await choosePreviewSource(manifest)
return { overlays }
} catch (reason) {
bootReadyGate().reject(reason)
throw reason
}
}
/**
* Connect a spawned host worker and complete the pre-Cordis handshake.
*
* The caller constructs the Worker so its bundler resolves the bundle URL
* statically; the opening `init` frame then carries the base image and ordered
* overlay locations.
*
* Order is fixed by the web boot protocol: the transport global must exist
* before any bundle executes; the injection table then reproduces the served
* boot rows — the `__ModuleLoader__` registration queue, the parser-preload
* bundles, `__DSH_BOOT__`, the theme bootstrap — in table order. The
* boot-readiness deferred (`__DSH_BOOT_READY__`) is installed before the
* first await and settles with the handshake, so a client entry evaluating
* concurrently in the same document holds at its pre-boot await until every
* row has taken effect, and surfaces a failed handshake instead of
* proceeding on missing globals.
* @param worker - The host worker.
* @param options - Base-image and overlay location overrides.
* @returns The connection; hand `loadBundle` to the shell entry's boot seam.
*/
export async function connectWorkerHost(worker: Worker, options?: WorkerHostConnectOptions): Promise<WorkerHostConnection> {
const ready = bootReadyGate()
// The handshake may fail before any entry awaits the promise; this no-op
// subscription keeps that from surfacing as an unhandled rejection.
void ready.promise.catch(() => {})
try {
const tunnel = new WorkerTunnel(worker)
tunnel.init(
new URL(options?.image ?? IMAGE_FILE_NAME, document.baseURI).href,
(options?.overlays ?? []).map(overlay => new URL(overlay, document.baseURI).href),
)
const payload = await tunnel.bootPayload()
;(globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ = {
createApiClient: () => new WorkerApiClient(tunnel),
fetch: (input, init) => tunnel.fetch(input, init),
openStream: (endpoint, payload, signal) => tunnel.open(endpoint, payload, signal),
loadBundle: (url: string) => tunnel.loadBundle(url),
// The host lives in a worker this page spawned: the page owns it, so
// the privileged surface stays reachable off loopback authorities.
ownsHost: true,
}
await applyIndexInjections(payload.injections, src => tunnel.loadBundle(src))
ready.resolve()
return { worker, tunnel, loadBundle: (url: string) => tunnel.loadBundle(url) }
} catch (reason) {
ready.reject(reason)
throw reason
}
}