diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 853fc4242f..edb58d3d25 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -728,11 +728,24 @@ function withdrawn(endpoint: string): Extract, { readonly ok return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`) } -function carrierFailure(endpoint: string, error: unknown): Extract, { 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 - `/` that was called. + * @param error - what the carrier threw. + * @returns the failed result. + */ +export function carrierFailure(endpoint: string, error: unknown): Extract, { readonly ok: false }> { return internalFailure(`client api: ${endpoint} failed: ${error instanceof Error ? error.message : String(error)}`) } -function cancelledFailure(endpoint: string, cause: unknown): Extract, { 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 - `/` 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, { readonly ok: false }> { return { ok: false, error: new RemoteError('gateway/cancelled', `client api: Remote invocation "${endpoint}" was aborted`, {}, { cause }), diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 10362cdc61..de77c242f3 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -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" } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 24deda96af..590004ef46 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -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 diff --git a/packages/client/connection/tests/client-apply.client.spec.ts b/packages/client/connection/tests/client-apply.client.spec.ts index 60494dab57..3025767fd5 100644 --- a/packages/client/connection/tests/client-apply.client.spec.ts +++ b/packages/client/connection/tests/client-apply.client.spec.ts @@ -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>( @@ -492,7 +508,7 @@ describe('connection client apply', () => { })(), ) ;(globalThis as Win).__DSH_TRANSPORT__ = { - fetch: vi.fn(), + fetch: vi.fn(), openStream, ownsHost: true, } diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 01866dac7a..282d6cc3e8 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -31,7 +31,9 @@ }, "dsh": { "client": { - "inject": [], + "inject": [ + "@deepseek-ai/dsh-client-modules" + ], "platform": "web", "immediately": true } diff --git a/packages/client/hmr/src/client/index.ts b/packages/client/hmr/src/client/index.ts index 66b0e9ad6d..eed2148265 100644 --- a/packages/client/hmr/src/client/index.ts +++ b/packages/client/hmr/src/client/index.ts @@ -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 { + 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) diff --git a/packages/client/modules/src/client/index.ts b/packages/client/modules/src/client/index.ts index c2c6a604c8..d7bf39e518 100644 --- a/packages/client/modules/src/client/index.ts +++ b/packages/client/modules/src/client/index.ts @@ -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, diff --git a/packages/client/modules/src/client/manifest.ts b/packages/client/modules/src/client/manifest.ts index e8feb2ee15..d6f8f99b8f 100644 --- a/packages/client/modules/src/client/manifest.ts +++ b/packages/client/modules/src/client/manifest.ts @@ -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 + 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 `/client` (the exports subpath external diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index c823657be5..36ff79941b 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -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 - 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 diff --git a/packages/client/web/src/boot-client.ts b/packages/client/web/src/boot-client.ts new file mode 100644 index 0000000000..1275c47c55 --- /dev/null +++ b/packages/client/web/src/boot-client.ts @@ -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 { + 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')}`) + } +} diff --git a/packages/client/web/src/boot.ts b/packages/client/web/src/boot.ts index 71ab65c140..84b2d5bdac 100644 --- a/packages/client/web/src/boot.ts +++ b/packages/client/web/src/boot.ts @@ -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 { - 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 { 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): Promise { - 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')}`) - } - } } diff --git a/packages/client/web/src/mount.ts b/packages/client/web/src/mount.ts new file mode 100644 index 0000000000..4836a92a6d --- /dev/null +++ b/packages/client/web/src/mount.ts @@ -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 { + const mounted = ctx.inject(['uiRenderer'], (scope) => { + scope.effect(() => scope.uiRenderer.mount(container), 'web boot: application mount') + }) + await mounted +} diff --git a/packages/client/web/tests/boot-client.client.spec.ts b/packages/client/web/tests/boot-client.client.spec.ts new file mode 100644 index 0000000000..478819ca59 --- /dev/null +++ b/packages/client/web/tests/boot-client.client.spec.ts @@ -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): { 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; onEntryState: (name: string, state: EntryStateLabel) => void } { + const states = new Map() + 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 } } + + /** Loader-shaped double: entries with scripted fiber states, services by name. */ + function auditCtx(entries: readonly FakeEntry[], services: Record = {}): 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') + }) +}) diff --git a/packages/client/web/tests/mount.client.spec.ts b/packages/client/web/tests/mount.client.spec.ts new file mode 100644 index 0000000000..fbeb5635bd --- /dev/null +++ b/packages/client/web/tests/mount.client.spec.ts @@ -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() + }) +})