mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-14 04:01:35 +00:00
refactor(client): share boot and transport integration points
This commit is contained in:
@@ -728,11 +728,24 @@ function withdrawn(endpoint: string): Extract<RemoteResult<never>, { readonly ok
|
||||
return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`)
|
||||
}
|
||||
|
||||
function carrierFailure(endpoint: string, error: unknown): Extract<RemoteResult<never>, { readonly ok: false }> {
|
||||
/**
|
||||
* The error branch a carrier throw (offline, transport fault) folds into: `gateway/internal` naming the endpoint and
|
||||
* the thrown message. Exported so a stand-in for this face folds identically.
|
||||
* @param endpoint - `<namespace>/<method>` that was called.
|
||||
* @param error - what the carrier threw.
|
||||
* @returns the failed result.
|
||||
*/
|
||||
export function carrierFailure(endpoint: string, error: unknown): Extract<RemoteResult<never>, { readonly ok: false }> {
|
||||
return internalFailure(`client api: ${endpoint} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
|
||||
function cancelledFailure(endpoint: string, cause: unknown): Extract<RemoteResult<never>, { readonly ok: false }> {
|
||||
/**
|
||||
* The error branch a call aborted by its caller folds into: `gateway/cancelled` with the carrier's throw as `cause`.
|
||||
* @param endpoint - `<namespace>/<method>` that was called.
|
||||
* @param cause - what the carrier threw when the signal aborted.
|
||||
* @returns the failed result.
|
||||
*/
|
||||
export function cancelledFailure(endpoint: string, cause: unknown): Extract<RemoteResult<never>, { readonly ok: false }> {
|
||||
return {
|
||||
ok: false,
|
||||
error: new RemoteError('gateway/cancelled', `client api: Remote invocation "${endpoint}" was aborted`, {}, { cause }),
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
"@deepseek-ai/dsh-api-gateway/client"
|
||||
],
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-gateway"
|
||||
"@deepseek-ai/dsh-api-gateway",
|
||||
"@deepseek-ai/dsh-client-file-upload"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
|
||||
@@ -78,9 +78,15 @@ export const inject: string[] = []
|
||||
* provides both halves here instead of forking this plugin.
|
||||
*/
|
||||
export interface ClientTransportHooks {
|
||||
/** Transport for generic unary RPC channels (the Typert gateway). */
|
||||
fetch: RpcFetch
|
||||
/** Worker-local Gateway stream carrier; absent when the page uses the Gateway WebSocket. */
|
||||
/**
|
||||
* Already decoded logical RPC carrier. When present it replaces the HTTP
|
||||
* caller outright: no envelopes, no `fetch`, no `openStream` (an in-process
|
||||
* Host such as a test mock plugs in here).
|
||||
*/
|
||||
rpc?: ClientConnectionRpc
|
||||
/** Transport for generic unary RPC channels (the Typert gateway); unused when `rpc` is present. */
|
||||
fetch?: RpcFetch
|
||||
/** Worker-local Gateway stream carrier; absent when the page uses the Gateway WebSocket or `rpc` is present. */
|
||||
openStream?: RpcStreamOpen
|
||||
/**
|
||||
* Bundle transport for the module system, present when the carrier also owns
|
||||
@@ -185,7 +191,7 @@ export function apply(ctx: Context): void {
|
||||
const fixtureRpc = fixture ? createFixtureConnectionRpc() : undefined
|
||||
const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__
|
||||
const recovery = resolveConnectionConfig((globalThis as ClientTransportGlobal).__DSH_CONNECTION_RECOVERY__)
|
||||
const rpc = fixtureRpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream)
|
||||
const rpc = fixtureRpc ?? transport?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream)
|
||||
let generationSource: ConnectionGenerationSource | undefined
|
||||
let owner: ConnectionOwner | undefined
|
||||
let generationId = 0
|
||||
|
||||
@@ -6,8 +6,10 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
apply,
|
||||
type ClientConnectionRpc,
|
||||
type ClientTransportHooks,
|
||||
type ConnectionGenerationSource,
|
||||
type RpcFetch,
|
||||
type ConnectionHandle,
|
||||
type ConnectionState,
|
||||
} from '../src/client/index.ts'
|
||||
@@ -483,6 +485,20 @@ describe('connection client apply', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses an already decoded rpc carrier from the transport hooks instead of the HTTP caller', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'preview.example', search: '' }
|
||||
const rpc: ClientConnectionRpc = {
|
||||
call: vi.fn(async (_channel: string, endpoint: string, payload: unknown) => ({ ok: true as const, value: { endpoint, payload } })),
|
||||
open: vi.fn((_channel: string, endpoint: string) => (async function *(): AsyncGenerator { yield endpoint })()),
|
||||
}
|
||||
;(globalThis as Win).__DSH_TRANSPORT__ = { rpc }
|
||||
const handle = await mount()
|
||||
expect(handle.rpc).toBe(rpc)
|
||||
await expect(handle.rpc.call('/api', 'session/list', { args: [] })).resolves.toEqual({
|
||||
ok: true, value: { endpoint: 'session/list', payload: { args: [] } },
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes a worker-local Gateway stream through connection.rpc.open', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'preview.example', search: '' }
|
||||
const openStream = vi.fn<NonNullable<ClientTransportHooks['openStream']>>(
|
||||
@@ -492,7 +508,7 @@ describe('connection client apply', () => {
|
||||
})(),
|
||||
)
|
||||
;(globalThis as Win).__DSH_TRANSPORT__ = {
|
||||
fetch: vi.fn<ClientTransportHooks['fetch']>(),
|
||||
fetch: vi.fn<RpcFetch>(),
|
||||
openStream,
|
||||
ownsHost: true,
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [],
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-modules"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
|
||||
@@ -75,6 +75,24 @@ export const name = 'client-hmr'
|
||||
/** Required services: the vendored Loader (entry governance) and the client module system (boot provide, service name `modules`). */
|
||||
export const inject = ['loader', 'modules']
|
||||
|
||||
/**
|
||||
* Registry-first teardown of an entry's running fiber so `entry.refresh()`
|
||||
* rebuilds it (see the module comment): delete the runtime record before the
|
||||
* fiber's disposer emits `internal/plugin` (or the Loader flags the entry
|
||||
* disabled), drain the unload so effect disposers finish before a new apply
|
||||
* re-registers, then clear `entry.fiber` so `refresh()` re-imports instead of
|
||||
* no-oping. A fiberless entry is left untouched.
|
||||
* @param entry - the Loader entry to tear down.
|
||||
*/
|
||||
export async function tearDownEntryFiber(entry: Entry): Promise<void> {
|
||||
const oldFiber = entry.fiber
|
||||
if (oldFiber === undefined) return
|
||||
const runtime = oldFiber.runtime
|
||||
if (runtime !== null) entry.ctx.registry.delete(runtime.callback)
|
||||
while (oldFiber.inertia !== undefined) await oldFiber.inertia
|
||||
delete entry.fiber
|
||||
}
|
||||
|
||||
/** Find the loader entry whose module specifier is `id` (entry tree ids are random; the package name lives in `options.name`). */
|
||||
function findEntry(loader: Loader, id: string): Entry | undefined {
|
||||
for (const entry of loader.entries()) {
|
||||
@@ -115,18 +133,7 @@ export function apply(ctx: Context): void {
|
||||
modLoader.invalidate(id, rev)
|
||||
await modLoader.prefetch(id)
|
||||
|
||||
const oldFiber = entry.fiber
|
||||
if (oldFiber !== undefined) {
|
||||
// Registry-first teardown (see module comment): the runtime record must
|
||||
// be gone before the fiber's disposer emits internal/plugin, or the
|
||||
// Loader flags the entry disabled.
|
||||
const runtime = oldFiber.runtime
|
||||
if (runtime !== null) entry.ctx.registry.delete(runtime.callback)
|
||||
// Drain the unload: effect disposers (slots, subscriptions) must finish
|
||||
// before the new bundle executes and the new apply re-registers.
|
||||
while (oldFiber.inertia !== undefined) await oldFiber.inertia
|
||||
delete entry.fiber
|
||||
}
|
||||
await tearDownEntryFiber(entry)
|
||||
// Old owned styles go before materialization re-injects them (the CSS
|
||||
// idempotency guard keys on stable tag ids).
|
||||
removeOwnedStyles(id)
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
} from './manifest.ts'
|
||||
|
||||
export { ClientModuleSystem }
|
||||
export { parseBootManifest, stripClientSuffix } from './manifest.ts'
|
||||
export { exactPackageSpecifier, parseBootManifest, parseDshClient, stripClientSuffix } from './manifest.ts'
|
||||
export type {
|
||||
BootManifest, BootModuleRow, BootPluginRow, ClientBootstrapModule, ClientBundleRegistration,
|
||||
ClientModuleCreateOptions, ClientModuleLoader, ClientModuleLoaderTarget, ClientModuleRecord,
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
*/
|
||||
|
||||
import type {} from '@deepseek-ai/cordis'
|
||||
import type { DshClientManifest } from '@deepseek-ai/dsh-package-manifest'
|
||||
import type { ClientModuleSystem } from './system.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
@@ -144,6 +145,51 @@ export function optionalStringArray(subject: string, field: string, value: unkno
|
||||
return value as string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an unknown parsed JSON value to the `dsh.client` declaration. Shared
|
||||
* by the node half's Loader scan and the roster generator, so both read a
|
||||
* package's browser declaration through one validator.
|
||||
* @param pkgName - package name used as the diagnostic prefix.
|
||||
* @param value - the raw `dsh.client` field of the package manifest.
|
||||
* @returns the validated declaration, or undefined when the field is absent.
|
||||
* @throws {Error} when the field is present but any member is malformed.
|
||||
*/
|
||||
export function parseDshClient(pkgName: string, value: unknown): DshClientManifest | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`)
|
||||
}
|
||||
const decl = value as Record<string, unknown>
|
||||
if (typeof decl.platform !== 'string') {
|
||||
throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`)
|
||||
}
|
||||
const inject = optionalStringArray(pkgName, 'dsh.client.inject', decl.inject)
|
||||
const external = optionalStringArray(pkgName, 'dsh.client.external', decl.external)
|
||||
if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
|
||||
throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`)
|
||||
}
|
||||
return {
|
||||
platform: decl.platform,
|
||||
...(inject !== undefined ? { inject } : {}),
|
||||
...(external !== undefined ? { external } : {}),
|
||||
...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The bare package-root specifier `specifier` names, or undefined for a subpath, a path, or any scheme-qualified
|
||||
* specifier (`cordis:` builtins, `node:` modules, URLs).
|
||||
* @param specifier - Loader row name.
|
||||
* @returns the package name, or undefined.
|
||||
*/
|
||||
export function exactPackageSpecifier(specifier: string): string | undefined {
|
||||
if (specifier.startsWith('@')) {
|
||||
const parts = specifier.split('/')
|
||||
return parts.length === 2 && parts.every(Boolean) ? specifier : undefined
|
||||
}
|
||||
return specifier.length > 0 && !specifier.includes('/') && !specifier.includes(':') ? specifier : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a module specifier onto the graph row that owns it: a plugin bundle
|
||||
* IS its package's client half, so `<id>/client` (the exports subpath external
|
||||
|
||||
@@ -33,8 +33,7 @@ import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { DshClientManifest } from '@deepseek-ai/dsh-package-manifest'
|
||||
import { optionalStringArray, stripClientSuffix } from './client/manifest.ts'
|
||||
import { exactPackageSpecifier, parseDshClient, stripClientSuffix } from './client/manifest.ts'
|
||||
import type { WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph } from './client/manifest.ts'
|
||||
|
||||
export { stripClientSuffix } from './client/manifest.ts'
|
||||
@@ -173,38 +172,6 @@ const SOURCE_MAP_TRAILER = /(?:\r?\n)?\/\/# sourceMappingURL=[^\r\n]*(?:\r?\n)?$
|
||||
/** Debugger source name appended to page bundles in the WebWorker image. */
|
||||
const SOURCE_URL_TRAILER = /(?:\r?\n)?\/\/# sourceURL=([^\r\n]+)(?:\r?\n)?$/
|
||||
|
||||
/** Return a bare package-root specifier, excluding package subpaths and path-like entries. */
|
||||
function exactPackageSpecifier(specifier: string): string | undefined {
|
||||
if (specifier.startsWith('@')) {
|
||||
const parts = specifier.split('/')
|
||||
return parts.length === 2 && parts.every(Boolean) ? specifier : undefined
|
||||
}
|
||||
return specifier.length > 0 && !specifier.includes('/') ? specifier : undefined
|
||||
}
|
||||
|
||||
/** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */
|
||||
function parseDshClient(pkgName: string, value: unknown): DshClientManifest | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`)
|
||||
}
|
||||
const decl = value as Record<string, unknown>
|
||||
if (typeof decl.platform !== 'string') {
|
||||
throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`)
|
||||
}
|
||||
const inject = optionalStringArray(pkgName, 'dsh.client.inject', decl.inject)
|
||||
const external = optionalStringArray(pkgName, 'dsh.client.external', decl.external)
|
||||
if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
|
||||
throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`)
|
||||
}
|
||||
return {
|
||||
platform: decl.platform,
|
||||
...(inject !== undefined ? { inject } : {}),
|
||||
...(external !== undefined ? { external } : {}),
|
||||
...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */
|
||||
function clientExportOf(pkgName: string, exportsField: unknown): string | undefined {
|
||||
if (typeof exportsField !== 'object' || exportsField === null) return undefined
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Production client composition without the page: mount the Loader over a
|
||||
* module system, create every manifest row, wait for quiescence, and audit
|
||||
* activation. `AppWebEntry` and the whole-client test carrier both call it.
|
||||
* @module @deepseek-ai/dsh-client-web/src/boot-client
|
||||
*/
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type { BootManifest, ClientModuleLoader } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { STATE_LABELS } from './loader-status.ts'
|
||||
|
||||
/** Entry state label as the boot page renders it. */
|
||||
export type EntryStateLabel = (typeof STATE_LABELS)[keyof typeof STATE_LABELS] | 'loading' | 'failed'
|
||||
|
||||
/** Inputs of {@link bootClient}. */
|
||||
export interface ClientBootOptions {
|
||||
/** Fresh root Context that will own the plugin tree. */
|
||||
readonly ctx: Context
|
||||
/** Module system installed as `loader.internal`. */
|
||||
readonly modules: ClientModuleLoader
|
||||
/** Parsed manifest whose `plugins` rows become Loader entries (entry name = row id). */
|
||||
readonly manifest: BootManifest
|
||||
/** Per-entry state reporting (the boot page); omitted when no one renders progress. */
|
||||
readonly onEntryState?: (name: string, state: EntryStateLabel) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the client: `ctx.plugin(Loader)`, `loader.internal = modules`, one
|
||||
* `loader.create({ name })` per manifest row, `loader.await()`, then
|
||||
* {@link assertEntriesActive}. A row whose module cannot be imported rejects
|
||||
* `loader.create`, so that import error propagates from here as-is.
|
||||
* @param options - context, module system, manifest, optional progress sink.
|
||||
* @returns resolves after every entry is active; rejects with the audit report otherwise.
|
||||
*/
|
||||
export async function bootClient(options: ClientBootOptions): Promise<void> {
|
||||
const { ctx, manifest, onEntryState } = options
|
||||
await ctx.plugin(Loader)
|
||||
const loader = ctx.loader
|
||||
loader.internal = options.modules as never
|
||||
|
||||
ctx.on('internal/status', (fiber) => {
|
||||
const entry = fiber.entry
|
||||
if (entry === undefined || entry.fiber === undefined) return
|
||||
onEntryState?.(entry.options.name, STATE_LABELS[entry.fiber.state])
|
||||
})
|
||||
|
||||
const rows = manifest.plugins.map(row => row.id)
|
||||
await Promise.all(rows.map(async (name) => {
|
||||
onEntryState?.(name, 'loading')
|
||||
const id = await loader.create({ name })
|
||||
if (loader.resolve(id).fiber === undefined) onEntryState?.(name, 'failed')
|
||||
}))
|
||||
|
||||
await loader.await()
|
||||
assertEntriesActive(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject entries that failed import/apply or still wait on missing services.
|
||||
* @param ctx - root Context carrying the Loader.
|
||||
* @throws {Error} listing every non-active entry with its reason.
|
||||
*/
|
||||
export function assertEntriesActive(ctx: Context): void {
|
||||
const failures: string[] = []
|
||||
for (const entry of ctx.loader.entries()) {
|
||||
const name = entry.options.name
|
||||
if (entry.fiber === undefined) {
|
||||
failures.push(`${name}: import failed (see console for the import error)`)
|
||||
continue
|
||||
}
|
||||
const state = STATE_LABELS[entry.fiber.state]
|
||||
if (state === 'active') continue
|
||||
if (state === 'pending') {
|
||||
const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
|
||||
failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
|
||||
} else {
|
||||
failures.push(`${name}: ${state}`)
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
/**
|
||||
* Web boot kernel. It owns only the module system, Cordis loader, and a
|
||||
* framework-free boot page. The dynamic UI renderer receives the mount
|
||||
* framework-free boot page; plugin composition and the renderer handoff are
|
||||
* `bootClient` and `mountClient`. The dynamic UI renderer receives the mount
|
||||
* point after every client entry activates.
|
||||
* @module @deepseek-ai/dsh-client-web/src/boot
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type {
|
||||
BootManifest, ClientModuleCreateOptions, ClientModuleSystem, DshWindow,
|
||||
} from '@deepseek-ai/dsh-client-modules/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import { bootClient } from './boot-client.ts'
|
||||
import { BootPage } from './boot-page.ts'
|
||||
import { mountClient } from './mount.ts'
|
||||
import { getStaticModules } from './seed.ts'
|
||||
import { STATE_LABELS } from './loader-status.ts'
|
||||
import './base.css'
|
||||
|
||||
/** Module transport hook replaced by jsdom tests. */
|
||||
@@ -76,8 +76,15 @@ export class AppWebEntry {
|
||||
const prefetching = this.prefetchImmediateTier()
|
||||
const ctx = new Context()
|
||||
this.ctx = ctx
|
||||
await this.runPluginBoot(ctx, prefetching)
|
||||
await this.mountApp(ctx)
|
||||
this.page.setTotal(this.manifest.plugins.length)
|
||||
await prefetching
|
||||
await bootClient({
|
||||
ctx,
|
||||
modules: this.modules,
|
||||
manifest: this.manifest,
|
||||
onEntryState: (name, state) => { this.page.setState(name, state) },
|
||||
})
|
||||
await mountClient(ctx, this.container)
|
||||
} catch (reason) {
|
||||
console.error(reason)
|
||||
this.page.fail(reason instanceof Error ? reason.message : String(reason))
|
||||
@@ -92,14 +99,6 @@ export class AppWebEntry {
|
||||
this.page.dispose()
|
||||
}
|
||||
|
||||
/** Mount through a dependency fiber so replacing uiRenderer remounts the application. */
|
||||
private async mountApp(ctx: Context): Promise<void> {
|
||||
const mounted = ctx.inject(['uiRenderer'], (scope) => {
|
||||
scope.effect(() => scope.uiRenderer.mount(this.container), 'web boot: application mount')
|
||||
})
|
||||
await mounted
|
||||
}
|
||||
|
||||
/** Prefetch stage-one bundles and their dynamic requests before concurrent plugin imports. */
|
||||
private async prefetchImmediateTier(): Promise<void> {
|
||||
await Promise.all(this.manifest.plugins
|
||||
@@ -108,52 +107,4 @@ export class AppWebEntry {
|
||||
// Prefetch only starts transport early; the Loader import retries and reports this bundle failure.
|
||||
})))
|
||||
}
|
||||
|
||||
/** Mount the Loader, create all graph entries, await quiescence, and audit activation. */
|
||||
private async runPluginBoot(ctx: Context, prefetching: Promise<void>): Promise<void> {
|
||||
await ctx.plugin(Loader)
|
||||
const loader = ctx.loader
|
||||
loader.internal = this.modules as never
|
||||
|
||||
ctx.on('internal/status', (fiber) => {
|
||||
const entry = fiber.entry
|
||||
if (entry === undefined || entry.fiber === undefined) return
|
||||
this.page.setState(entry.options.name, STATE_LABELS[entry.fiber.state])
|
||||
})
|
||||
|
||||
const rows = this.manifest.plugins.map(row => row.id)
|
||||
this.page.setTotal(rows.length)
|
||||
await prefetching
|
||||
await Promise.all(rows.map(async (name) => {
|
||||
this.page.setState(name, 'loading')
|
||||
const id = await loader.create({ name })
|
||||
if (loader.resolve(id).fiber === undefined) this.page.setState(name, 'failed')
|
||||
}))
|
||||
|
||||
await loader.await()
|
||||
this.assertEntriesActive(ctx)
|
||||
}
|
||||
|
||||
/** Reject entries that failed import/apply or still wait on missing services. */
|
||||
private assertEntriesActive(ctx: Context): void {
|
||||
const failures: string[] = []
|
||||
for (const entry of ctx.loader.entries()) {
|
||||
const name = entry.options.name
|
||||
if (entry.fiber === undefined) {
|
||||
failures.push(`${name}: import failed (see console for the import error)`)
|
||||
continue
|
||||
}
|
||||
const state = STATE_LABELS[entry.fiber.state]
|
||||
if (state === 'active') continue
|
||||
if (state === 'pending') {
|
||||
const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
|
||||
failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
|
||||
} else {
|
||||
failures.push(`${name}: ${state}`)
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Application mount through a dependency fiber, so replacing `uiRenderer`
|
||||
* remounts the application. Shared by `AppWebEntry` and the test carrier.
|
||||
* @module @deepseek-ai/dsh-client-web/src/mount
|
||||
*/
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
|
||||
/**
|
||||
* Mount the UI renderer into `container` through a dependency fiber on
|
||||
* `uiRenderer`: the mount effect installs when the service is provided and
|
||||
* reinstalls when it is replaced.
|
||||
* @param ctx - booted root Context.
|
||||
* @param container - application mount point.
|
||||
* @returns resolves once the dependency fiber exists; with `uiRenderer`
|
||||
* already provided (as after `bootClient`) the mount effect is installed by
|
||||
* then, otherwise it installs when the service arrives.
|
||||
*/
|
||||
export async function mountClient(ctx: Context, container: HTMLElement): Promise<void> {
|
||||
const mounted = ctx.inject(['uiRenderer'], (scope) => {
|
||||
scope.effect(() => scope.uiRenderer.mount(container), 'web boot: application mount')
|
||||
})
|
||||
await mounted
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// @vitest-environment jsdom
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import {
|
||||
createClientModuleSystem, parseBootManifest,
|
||||
type ClientBundleRegistration, type ClientModuleLoader, type ClientModuleLoaderTarget, type WebBootEntry, type WebBootGraph,
|
||||
} from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assertEntriesActive, bootClient, type EntryStateLabel } from '../src/boot-client.ts'
|
||||
import { FIBER_STATE } from '../src/loader-status.ts'
|
||||
|
||||
const BOOTSTRAP_ID = '@deepseek-ai/dsh-client-modules'
|
||||
|
||||
function graphOf(ids: readonly string[]): WebBootGraph {
|
||||
const entries: WebBootEntry[] = ids.map(id => ({ id, url: `/${id}.js`, rev: '1' }))
|
||||
return {
|
||||
rev: 'graph',
|
||||
entries,
|
||||
batches: [{ phase: 'application', url: '/application.js', rev: 'batch', entries: [...ids] }],
|
||||
}
|
||||
}
|
||||
|
||||
/** Module system seeded with inline plugin modules; `loaded` records every transport call. */
|
||||
function modulesOf(graph: WebBootGraph, staticModules: Record<string, unknown>): { modules: ClientModuleLoader; loaded: string[] } {
|
||||
const loaded: string[] = []
|
||||
const pendingQueue: ClientBundleRegistration[] = []
|
||||
const target: ClientModuleLoaderTarget = {
|
||||
mode: 'queue',
|
||||
pendingQueue,
|
||||
load: (registration) => { pendingQueue.push(registration) },
|
||||
create: options => createClientModuleSystem(target, { id: BOOTSTRAP_ID, exports: {} }, options),
|
||||
}
|
||||
const modules = target.create({
|
||||
boot: graph,
|
||||
staticModules,
|
||||
loadBundle: async (url) => { loaded.push(url) },
|
||||
})
|
||||
return { modules, loaded }
|
||||
}
|
||||
|
||||
/** Recording progress sink. */
|
||||
function stateSink(): { states: Map<string, EntryStateLabel[]>; onEntryState: (name: string, state: EntryStateLabel) => void } {
|
||||
const states = new Map<string, EntryStateLabel[]>()
|
||||
return {
|
||||
states,
|
||||
onEntryState: (name, state) => { states.set(name, [...(states.get(name) ?? []), state]) },
|
||||
}
|
||||
}
|
||||
|
||||
describe('bootClient', () => {
|
||||
it('activates every seeded row without touching the bundle transport', async () => {
|
||||
const graph = graphOf(['provider', 'consumer'])
|
||||
const { modules, loaded } = modulesOf(graph, {
|
||||
provider: { apply: (ctx: Context) => { ctx.reflect.provide('x', { marker: 'x' }) } },
|
||||
consumer: { inject: ['x'], apply: () => {} },
|
||||
})
|
||||
const ctx = new Context()
|
||||
const sink = stateSink()
|
||||
|
||||
await bootClient({ ctx, modules, manifest: modules.manifest, onEntryState: sink.onEntryState })
|
||||
|
||||
expect(loaded).toEqual([])
|
||||
const consumer = sink.states.get('consumer') ?? []
|
||||
expect(consumer[0]).toBe('loading')
|
||||
expect(consumer.at(-1)).toBe('active')
|
||||
expect(sink.states.get('provider')?.at(-1)).toBe('active')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('reports a row waiting on a service the roster never provides', async () => {
|
||||
const graph = graphOf(['orphan'])
|
||||
const { modules } = modulesOf(graph, { orphan: { inject: ['nothing'], apply: () => {} } })
|
||||
const ctx = new Context()
|
||||
|
||||
await expect(bootClient({ ctx, modules, manifest: modules.manifest })).rejects.toThrow(
|
||||
'orphan: pending (waiting for service: nothing)',
|
||||
)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('surfaces the Loader import error for a row that is neither seeded nor a graph row', async () => {
|
||||
const { modules } = modulesOf(graphOf(['seeded']), { seeded: { apply: () => {} } })
|
||||
const manifest = parseBootManifest(graphOf(['ghost']))
|
||||
const ctx = new Context()
|
||||
const sink = stateSink()
|
||||
|
||||
await expect(bootClient({ ctx, modules, manifest, onEntryState: sink.onEntryState })).rejects.toThrow(
|
||||
/failed to import loader entry \S+ \(ghost\): client-modules: cannot resolve/,
|
||||
)
|
||||
expect(sink.states.get('ghost')).toEqual(['loading'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertEntriesActive', () => {
|
||||
interface FakeEntry { name: string; fiber?: { state: number; inject: Record<string, null> } }
|
||||
|
||||
/** Loader-shaped double: entries with scripted fiber states, services by name. */
|
||||
function auditCtx(entries: readonly FakeEntry[], services: Record<string, unknown> = {}): Context {
|
||||
return {
|
||||
loader: {
|
||||
* entries() {
|
||||
for (const entry of entries) yield { options: { name: entry.name }, fiber: entry.fiber }
|
||||
},
|
||||
},
|
||||
get: (name: string) => services[name],
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
it('passes when every entry is active', () => {
|
||||
expect(() => { assertEntriesActive(auditCtx([{ name: 'a', fiber: { state: FIBER_STATE.ACTIVE, inject: {} } }])) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('names import failures, missing services, and other non-active states', () => {
|
||||
const ctx = auditCtx([
|
||||
{ name: 'lost' },
|
||||
{ name: 'waiting', fiber: { state: FIBER_STATE.PENDING, inject: { present: null, a: null, b: null } } },
|
||||
{ name: 'opaque', fiber: { state: FIBER_STATE.PENDING, inject: {} } },
|
||||
{ name: 'broken', fiber: { state: FIBER_STATE.FAILED, inject: {} } },
|
||||
], { present: {} })
|
||||
|
||||
expect(() => { assertEntriesActive(ctx) }).toThrow([
|
||||
'web boot: 4 entries did not activate',
|
||||
'lost: import failed (see console for the import error)',
|
||||
'waiting: pending (waiting for services: a, b)',
|
||||
'opaque: pending (waiting for services: unknown)',
|
||||
'broken: failed',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('uses the singular form for one failing entry', () => {
|
||||
expect(() => { assertEntriesActive(auditCtx([{ name: 'lost' }])) }).toThrow('web boot: 1 entry did not activate\n')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment jsdom
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mountClient } from '../src/mount.ts'
|
||||
|
||||
/** Provide a fake `uiRenderer` from its own plugin fiber so it can be replaced. */
|
||||
function provideRenderer(ctx: Context, mount: (container: HTMLElement) => () => void) {
|
||||
return ctx.plugin({ apply: (scope: Context) => { scope.reflect.provide('uiRenderer', { mount }) } })
|
||||
}
|
||||
|
||||
describe('mountClient', () => {
|
||||
it('mounts into the container and unmounts when the tree is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
const unmount = vi.fn()
|
||||
const mount = vi.fn((_container: HTMLElement) => unmount)
|
||||
provideRenderer(ctx, mount)
|
||||
const container = document.createElement('div')
|
||||
|
||||
await mountClient(ctx, container)
|
||||
|
||||
expect(mount).toHaveBeenCalledExactlyOnceWith(container)
|
||||
expect(unmount).not.toHaveBeenCalled()
|
||||
await ctx.fiber.dispose()
|
||||
expect(unmount).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('remounts when uiRenderer is replaced', async () => {
|
||||
const ctx = new Context()
|
||||
const container = document.createElement('div')
|
||||
const first = { unmount: vi.fn(), mount: vi.fn(() => first.unmount) }
|
||||
const second = { unmount: vi.fn(), mount: vi.fn(() => second.unmount) }
|
||||
|
||||
await mountClient(ctx, container)
|
||||
expect(first.mount).not.toHaveBeenCalled()
|
||||
|
||||
const renderer = provideRenderer(ctx, first.mount)
|
||||
await vi.waitFor(() => { expect(first.mount).toHaveBeenCalledExactlyOnceWith(container) })
|
||||
|
||||
await renderer.dispose()
|
||||
expect(first.unmount).toHaveBeenCalledOnce()
|
||||
|
||||
provideRenderer(ctx, second.mount)
|
||||
await vi.waitFor(() => { expect(second.mount).toHaveBeenCalledExactlyOnceWith(container) })
|
||||
await ctx.fiber.dispose()
|
||||
expect(second.unmount).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user