refactor(client): share boot and transport integration points

This commit is contained in:
imccyu
2026-09-10 16:34:28 +08:00
parent 3d6fa12a6d
commit 48d83eca66
14 changed files with 414 additions and 118 deletions
+15 -2
View File
@@ -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 }),
+2 -1
View File
@@ -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"
}
+10 -4
View File
@@ -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,
}
+3 -1
View File
@@ -31,7 +31,9 @@
},
"dsh": {
"client": {
"inject": [],
"inject": [
"@deepseek-ai/dsh-client-modules"
],
"platform": "web",
"immediately": true
}
+19 -12
View File
@@ -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)
+1 -1
View File
@@ -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
+1 -34
View File
@@ -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
+83
View File
@@ -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')}`)
}
}
+13 -62
View File
@@ -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')}`)
}
}
}
+24
View File
@@ -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()
})
})