refactor(gui): rebuild the client loading kernel as dsh-client-modules with a two-phase boot

The module system moves out of dsh-client-runtime (./loader retired) into
its own package: a lazy CJS table where executing a bundle only registers
its factory and materialization happens at first require, memoized, with
recursive requires self-ordering. ClientModuleSystem is a class; index.ts
keeps the types and a thin factory. Boot is two-phase: phase one prefetches
the immediately tier in parallel (registration only, failures deferred to
phase two's loud import); phase two mounts the vendored Loader with the
module system as internal, creates one entry per graph row plus the
app-shell pseudo-row the kernel appends itself, and settles on an
all-ACTIVE sweep. The shell kernel is self-sufficient: hand-rolled
loader-status stores, no plugin value imports, platform seed list single-
sourced in platform.ts.
This commit is contained in:
imccyu
2026-07-23 21:55:39 +08:00
parent 15fde82f80
commit b58f0989f9
30 changed files with 1064 additions and 1015 deletions

View File

@@ -1,39 +1,46 @@
/**
* Shell root: boot loading page → (loader settled) → real UI in one switch.
* Pure shell component with zero plugin dependencies — before settled it may
* only rely on itself; the real UI is produced by the boot assembly closure
* (renderApp) once every plugin is active. A failed plugin keeps the loading
* page and lists the failures (fail loud, no partial UI).
* Shell root: boot loading page → (boot settled) → real UI in one switch.
* Pure kernel component with zero plugin dependencies — before settled it may
* only rely on itself (the fail-loud presentation must not depend on the
* system whose failure it reports; the status/signal stores are kernel-own,
* web2 shell self-sufficiency rule); the real UI is produced by the
* app-shell entry once every entry is active. A failed boot keeps the
* loading page, lists the per-entry fiber states and the sweep report (fail
* loud, no partial UI).
*/
import { useSyncExternalStore } from 'react'
import type { ReactNode } from 'react'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { LoaderStatus } from '@deepseek-ai/dsh-client-runtime/client'
import type { KernelSignal, LoaderStatus } from './loader-status.ts'
import css from './AppRoot.module.css'
/** AppRoot props: settled signal, loader status feed, deferred real-UI factory. */
/** AppRoot props: settled signal, fiber-state projection feed, boot failure report, deferred real-UI factory. */
export interface AppRootProps {
/** True once loader.settled() resolved (the boot closure flips it; status-derived guesses race an incrementally filled table). */
settled: ObservableSnapshot<boolean>
/** Loader per-plugin status store (drives loading/failed rendering). */
status: SnapshotStore<LoaderStatus>
/** True once the boot chain settled (loader quiesced + all entries ACTIVE); the boot closure flips it. */
settled: KernelSignal<boolean>
/** Per-entry fiber-state projection store (drives loading/failed rendering). */
status: KernelSignal<LoaderStatus>
/** Boot failure report (the settle rejection message); undefined while loading or after success. */
error: KernelSignal<string | undefined>
/** Builds the real UI; called only after settled. */
renderApp: () => ReactNode
}
/** Boot gate: loading page until the loader settles; failures stay here. */
/** Boot gate: loading page until the boot settles; failures stay here. */
export function AppRoot(props: AppRootProps) {
const settled = useSyncExternalStore(props.settled.subscribe, props.settled.getSnapshot)
const status = useSyncExternalStore(props.status.subscribe, props.status.getSnapshot)
const error = useSyncExternalStore(props.error.subscribe, props.error.getSnapshot)
const failed = Object.entries(status).filter(([, s]) => s === 'failed')
if (settled) return <>{props.renderApp()}</>
const loud = error !== undefined || failed.length > 0
return (
<div className={css.boot}>
<div className={css.card}>
<div className={css.wordmark}>HARNESS</div>
{failed.length === 0
{!loud
? (
<>
<div className={css.spinner} />
@@ -44,6 +51,7 @@ export function AppRoot(props: AppRootProps) {
<div className={css.failed}>
<div className={css.failedTitle}>Failed to load plugins</div>
{failed.map(([id]) => <div key={id} className={css.failedItem}>{id}</div>)}
{error !== undefined && <div className={css.failedItem}>{error}</div>}
</div>
)}
</div>

View File

@@ -0,0 +1,59 @@
/**
* App-shell assembly plugin (design §3.4): the shell's ONLY composition
* responsibility, packaged as a normal static-arrival entry so the host graph
* stays the single composition authority. It rides the same entry lifecycle
* as every other plugin — the fiber waits on slots/sessions/layout, so by the
* time apply runs the layout entry is mounted and its export surface is
* readable from the governance side (module loadCache, design §2.6).
*
* The pseudo package id exists only in the host graph and the shell's static
* registry; there is no npm package behind it.
*/
import type { ReactNode } from 'react'
import type { Context } from 'cordis'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import { buildRenderApp } from './app.tsx'
/** Shell-owned pseudo entry id under which the host graph mounts this plugin. */
export const APP_SHELL_ID = '@deepseek-ai/dsh-client-app-shell'
/** The assembled-UI face AppRoot renders once the boot settles. */
export interface AppShellService {
/** Build (once) and render the real UI tree. */
renderApp: () => ReactNode
}
declare module 'cordis' {
interface Context {
/** The shell assembly face, provided by the app-shell entry once its inject set is active. */
appShell: AppShellService
}
}
/** Cordis plugin name. */
export const name = 'app-shell'
/** Required services: the product services the assembly closes over (layout registers the 'root' slot entry). */
export const inject = ['slots', 'sessions', 'layout']
/**
* Plugin body: install the React renderer into the slot system and provide
* the renderApp face (one ctx-level renderSlot('root') call).
* @param ctx - plugin context (inject set active).
*/
export function apply(ctx: Context): void {
// The renderer install is shell territory (web-react is shell-bundled),
// but ctx.slots exists only once the runtime entry is active — so it lands
// here, on the entry whose inject set guarantees that ordering.
ctx.slots.install(createSlotRenderer())
// Assemble once on first render: the closure must be identity-stable
// across AppRoot re-renders.
let renderApp: (() => ReactNode) | undefined
ctx.reflect.provide('appShell', {
renderApp: (): ReactNode => {
renderApp ??= buildRenderApp({ ctx })
return renderApp()
},
})
}

View File

@@ -1,8 +1,9 @@
/**
* Real-UI assembly closure. Runs only after loader.settled(): the whole
* layout tree hangs off the built-in 'root' slot (ui-layout registers
* AppFrame there and renders the child slots internally) — the shell's
* render is the one ctx-level renderSlot call in the program.
* Real-UI assembly closure, invoked by the app-shell plugin once its inject
* set is active: the whole layout tree hangs off the built-in 'root' slot
* (ui-layout registers AppFrame there and renders the child slots
* internally) — the shell's render is the one ctx-level renderSlot call in
* the program.
*/
import type { ReactNode } from 'react'
import type { Context } from 'cordis'
@@ -12,16 +13,14 @@ import { DocumentTitle } from './DocumentTitle.tsx'
// Type-only: pulls the runtime's SlotMap declaration merge (the 'root' key) into this program.
import type {} from '@deepseek-ai/dsh-client-runtime/client'
/** Assembly inputs: the settled root ctx plus the loader's module-table read surface. */
/** Assembly inputs: the active app-shell plugin ctx (slots/sessions/layout services provided). */
export interface AssemblyDeps {
/** Client root context (all plugin services provided). */
/** Client context with the assembly's inject set active. */
ctx: Context
/** Module-table resolver (the loader's require; missing spec = throw). Kept in the seam for future shell needs. */
requireModule: (spec: string) => unknown
}
/**
* Build the renderApp factory handed to AppRoot.
* Build the renderApp factory the app-shell plugin provides to AppRoot.
* @param deps - assembly inputs.
* @returns factory producing the real UI tree (called once per AppRoot render after settled).
*/

View File

@@ -1,73 +1,172 @@
/**
* Web shell boot — the library face consumed by the apps/web entry (api
* contracts v3 §0.3/§9.3): root ctx → hold the loader machinery (statically
* imported; the loader cannot load itself) → seed the module table → render
* the AppRoot loading page → loader.start() → await settled() → flip the
* settled signal so AppRoot switches to the real UI in one pass. Load
* failures reject settled(); AppRoot stays on the loading page listing them
* (fail loud).
* Web shell boot — the kernel face consumed by the apps/web entry. Everything
* here is machinery that cannot itself be an entry, and none of it
* value-imports a plugin package (web2 shell self-sufficiency rule: the
* loading page must work while — especially when — plugins fail).
*
* Two-stage boot (web2 §0):
* Stage one (module face): build the module system over the host graph
* (`window.__DSH_BOOT__`) and prefetch every `immediately` row in parallel
* — fetch + execute registers factories only; module side effects wait for
* materialization. Prefetch failures are non-fatal here: stage two's
* import path retries the fetch and owns the loud failure.
* Stage two (plugin face): mount the vendored cordis Loader, inject the
* module system as its internal seam (BEFORE any entry exists — the
* bare-import fallback in tree.import must never run in a browser), create
* one loader entry per graph row (tree.import materializes each module),
* let fibers activate on service availability, then loader.await() + a
* full fiber sweep (all ACTIVE, else reject listing who/what/which
* service) → flip the settled signal so AppRoot switches to the real UI in
* one pass.
*
* Composition lives in the host graph; the shell makes zero composition
* decisions (the app-shell assembly is itself a graph entry, the only
* shell-own module registered with the module system).
*/
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { createRoot } from 'react-dom/client'
import type { ReactNode } from 'react'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import { createClientLoader, type ClientLoaderOptions } from '@deepseek-ai/dsh-client-runtime/loader'
import {
createClientModuleLoader,
type ClientModuleLoader, type ClientModuleLoaderOptions, type DshWindow, type WebBootGraph,
} from '@deepseek-ai/dsh-client-modules'
import * as AppShell from './app-shell.ts'
import { APP_SHELL_ID } from './app-shell.ts'
import { AppRoot } from './AppRoot.tsx'
import { buildRenderApp } from './app.tsx'
import { seedModules } from './seed.ts'
import { getStaticModules } from './seed.ts'
import {
STATE_LABELS, createLoaderStatusStore, createSignal, type LoaderStatusStore,
} from './loader-status.ts'
import './base.css'
/** Manually flipped settled signal (AppRoot's gate; see AppRootProps.settled). */
function settledSignal(): ObservableSnapshot<boolean> & { flip: () => void } {
let value = false
const listeners = new Set<() => void>()
return {
getSnapshot: () => value,
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
flip: () => {
value = true
for (const fn of [...listeners]) fn()
},
/** Module transport seams the shell passes through (jsdom tests replace the <script> path). */
export type BootSeams = Pick<ClientModuleLoaderOptions, 'fetchBundle' | 'executeBundle'>
/**
* Sweep every loader entry after the tree quiesced: an entry without a fiber
* failed its import; a fiber not ACTIVE is FAILED (apply threw) or PENDING
* (a required service never arrived — cordis inject waiting has no timeout,
* so this sweep is the fail-loud compensation).
*/
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')}`)
}
}
/** Loader transport seams the shell passes through (jsdom tests replace the <script> path). */
export type BootSeams = Pick<ClientLoaderOptions, 'fetchBundle' | 'executeBundle'>
/** Stage one: prefetch the immediately tier (factory registration only; failures defer to stage two's import). */
async function prefetchImmediateTier(modules: ClientModuleLoader, graph: WebBootGraph): Promise<void> {
await Promise.all(graph.entries
.filter((row) => row.immediately === true)
.map((row) => modules.prefetch(row.id).catch(() => {
// Import (stage two) refetches and reports this loudly per entry;
// swallowing here keeps one failing prefetch from masking the others.
})))
}
/** Stage two: mount the Loader, inject the internal seam, create the graph entries, settle, sweep. */
async function runPluginBoot(
ctx: Context, modules: ClientModuleLoader, graph: WebBootGraph, status: LoaderStatusStore,
): Promise<void> {
await ctx.plugin(Loader)
const loader = ctx.loader
// Inject the module system BEFORE any entry exists: tree.import falls back
// to a bare dynamic import when internal is undefined, which in a browser
// is a guaranteed loud failure — correct as a tripwire, never as a path.
loader.internal = modules as never
// Status projection: AppRoot displays fiber truth. Every internal/status
// transition under an entry re-projects that entry's row from its ROOT
// fiber (child plugin fibers share the same entry).
ctx.on('internal/status', (fiber) => {
const entry = fiber.entry
if (entry === undefined || entry.fiber === undefined) return
status.set(entry.options.name, STATE_LABELS[entry.fiber.state])
})
// Entry creation order carries no semantics (fiber inject waiting owns
// activation order); creating concurrently lets non-prefetched bundle
// fetches parallelize. The app-shell assembly entry is appended by the
// kernel: it is shell-own code (host graph rows are all plugin bundles),
// and mounting the assembly is not a composition decision — it rides the
// same entry lifecycle so the sweep and status cover it uniformly.
const rows = [...graph.entries.map((row) => row.id), APP_SHELL_ID]
await Promise.all(rows.map(async (name) => {
status.set(name, 'loading')
const id = await loader.create({ name })
// A failed import leaves the entry fiberless (Entry._init logs and
// returns); project it as failed — no fiber means no status event.
if (loader.resolve(id).fiber === undefined) {
status.set(name, 'failed')
}
}))
await loader.await()
assertEntriesActive(ctx)
}
/**
* Mount the web shell into a DOM element and start the plugin load chain.
* Mount the web shell into a DOM element and start the two-stage boot chain.
* @param el - mount point (the app's #root).
* @param seams - optional loader transport overrides (test environments).
* @param seams - optional module transport overrides (test environments).
* @returns unmount disposer.
*/
export function bootWebShell(el: HTMLElement, seams?: BootSeams): () => void {
const ctx = new Context()
const loader = createClientLoader({ ctx, modules: seedModules(), ...seams })
ctx.reflect.provide('loader', loader)
const graph = (globalThis as DshWindow).__DSH_BOOT__
if (graph === undefined) throw new Error('web boot: no entry graph (window.__DSH_BOOT__ missing)')
const settled = settledSignal()
// Assemble once on first post-settled render: SessionProvider and the slot
// closures must be identity-stable across re-renders.
let renderApp: (() => ReactNode) | undefined
const renderAppOnce = (): ReactNode => {
renderApp ??= buildRenderApp({ ctx, requireModule: (spec) => loader.requireModule(spec) })
return renderApp()
}
const ctx = new Context()
const modules = createClientModuleLoader({ graph, staticModules: getStaticModules(), ...seams })
// The app-shell assembly is the only shell-own module: every other graph
// row is a plugin bundle arriving through fetch (web2 single package form).
modules.registerStatic(APP_SHELL_ID, AppShell)
// Contract C5: the module system is a boot-owned kernel service (ctx.modules).
ctx.reflect.provide('modules', modules)
const status = createLoaderStatusStore()
const settled = createSignal(false)
const error = createSignal<string | undefined>(undefined)
const root = createRoot(el)
root.render(<AppRoot settled={settled} status={loader.status} renderApp={renderAppOnce} />)
loader.start()
loader.settled().then(
() => {
// The renderer install is a shell-boot act, but ctx.slots exists only
// once the runtime plugin loaded — so it lands here, after settled and
// before the flip that lets renderApp call renderSlot('root').
ctx.slots.install(createSlotRenderer())
settled.flip()
},
() => { /* stay on the loading page; failures render from loader.status */ },
root.render(
<AppRoot
settled={settled}
status={status}
error={error}
renderApp={() => {
const shell = ctx.get('appShell')
// Unreachable after a clean settle (the app-shell entry is in every graph).
if (shell === undefined) throw new Error('web boot: appShell service missing after settled')
return shell.renderApp()
}}
/>,
)
prefetchImmediateTier(modules, graph)
.then(() => runPluginBoot(ctx, modules, graph, status))
.then(
() => { settled.set(true) },
(reason: unknown) => {
// Stay on the loading page; surface the sweep report (fail loud).
console.error(reason)
error.set(reason instanceof Error ? reason.message : String(reason))
},
)
return () => { root.unmount() }
}

View File

@@ -1,12 +1,20 @@
/**
* Web shell library entry. The shell's product is {@link bootWebShell} —
* apps/web's vite entry calls it against #root; everything else (AppRoot
* gate, assembly closure, module-table seed) is internal to the boot chain.
* gate, app-shell assembly entry, module-table staticModules, platform constants) is
* internal to the boot chain. PLATFORM_MODULES is re-exported as the C1
* single source of truth for the tsdown client externals projection.
* @module @deepseek-ai/dsh-client-web
*/
export { bootWebShell } from './boot.tsx'
export { bootWebShell, type BootSeams } from './boot.tsx'
export { AppRoot, type AppRootProps } from './AppRoot.tsx'
export { buildRenderApp, type AssemblyDeps } from './app.tsx'
export { DocumentTitle, type DocumentTitleProps } from './DocumentTitle.tsx'
export { seedModules } from './seed.ts'
export { APP_SHELL_ID, type AppShellService } from './app-shell.ts'
export { getStaticModules } from './seed.ts'
export { PLATFORM_MODULES, type PlatformModule } from './platform.ts'
export {
STATE_LABELS, FIBER_STATE, createSignal, createLoaderStatusStore,
type LoaderStatus, type LoaderEntryState, type KernelSignal, type KernelValueSignal, type LoaderStatusStore,
} from './loader-status.ts'

View File

@@ -0,0 +1,111 @@
/**
* Fiber-state projection vocabulary and the kernel-owned status store for the
* boot loading page. The status AppRoot renders is a projection of the real
* cordis fiber states (display the truth, not a retelling) — the boot chain
* subscribes `internal/status` and recomputes one row per loader entry.
*
* The store is hand-rolled here because of the shell self-sufficiency rule
* (web2 §0): the snapshot-store machinery lives in the runtime PLUGIN
* package, and the shell kernel must not value-import any plugin package —
* the loading page has to work while (and especially when) plugins fail.
* @module @deepseek-ai/dsh-client-web/src/loader-status
*/
import type { FiberState } from 'cordis'
/**
* Value mirror of cordis's `FiberState` const enum: a const enum has no
* runtime object to import (and esbuild-based pipelines cannot inline it
* across modules), so these values mirror the pinned vendored definition
* while retaining its type (same rationale as dsh-tool-cordis's mirror).
*/
export const FIBER_STATE = {
PENDING: 0 as FiberState.PENDING,
LOADING: 1 as FiberState.LOADING,
ACTIVE: 2 as FiberState.ACTIVE,
FAILED: 3 as FiberState.FAILED,
DISPOSED: 4 as FiberState.DISPOSED,
UNLOADING: 5 as FiberState.UNLOADING,
} as const
/** One entry's projected state label (lower-case face of {@link FiberState}). */
export type LoaderEntryState = 'pending' | 'loading' | 'active' | 'failed' | 'disposed' | 'unloading'
/** Label for each fiber state, keyed by member (inlining-safe — no reverse mapping). */
export const STATE_LABELS: Record<FiberState, LoaderEntryState> = {
[FIBER_STATE.PENDING]: 'pending',
[FIBER_STATE.LOADING]: 'loading',
[FIBER_STATE.ACTIVE]: 'active',
[FIBER_STATE.FAILED]: 'failed',
[FIBER_STATE.DISPOSED]: 'disposed',
[FIBER_STATE.UNLOADING]: 'unloading',
}
/** Per-entry state projection (AppRoot's status feed), keyed by entry name. */
export type LoaderStatus = Record<string, LoaderEntryState>
/** Minimal observable snapshot the kernel components consume (useSyncExternalStore shape). */
export interface KernelSignal<T> {
/** Current value (stable reference between changes). */
getSnapshot(): T
/**
* Subscribe to changes.
* @param fn - change listener.
* @returns the unsubscribe disposer.
*/
subscribe(fn: () => void): () => void
}
/** Writable one-value signal (settled flag, boot failure report). */
export interface KernelValueSignal<T> extends KernelSignal<T> {
/**
* Publish a new value and notify subscribers.
* @param next - the new value.
*/
set(next: T): void
}
/**
* Create a writable kernel signal.
* @param init - initial value.
* @returns the signal.
*/
export function createSignal<T>(init: T): KernelValueSignal<T> {
let value = init
const listeners = new Set<() => void>()
return {
getSnapshot: () => value,
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
set: (next) => {
value = next
for (const fn of [...listeners]) fn()
},
}
}
/** The boot status store: per-entry rows over a {@link KernelSignal} face. */
export interface LoaderStatusStore extends KernelSignal<LoaderStatus> {
/**
* Project one entry's state (copy-on-write so getSnapshot references only
* change on writes — useSyncExternalStore contract).
* @param id - entry name.
* @param state - projected fiber state.
*/
set(id: string, state: LoaderEntryState): void
}
/**
* Create the boot status store.
* @returns the store (empty until the boot chain projects rows).
*/
export function createLoaderStatusStore(): LoaderStatusStore {
let value: LoaderStatus = {}
const listeners = new Set<() => void>()
return {
getSnapshot: () => value,
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
set: (id, state) => {
value = { ...value, [id]: state }
for (const fn of [...listeners]) fn()
},
}
}

View File

@@ -0,0 +1,20 @@
/**
* Platform singletons the shell shares into the module table.
* Single source of truth (design §3.3, contract C1): seed keys = tsdown
* client externals = the shared surface. The three projections import this
* module — the seed table ({@link ../seed.ts}), the tsdown client preset's
* external judgement (packages/client/tsdown.client.ts), and the vite alias
* check — so the list cannot drift between them.
* @module @deepseek-ai/dsh-client-web/src/platform
*/
/** The module specifiers the shell shares into the frozen module table. */
export const PLATFORM_MODULES = [
'react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', 'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-web-react',
'@deepseek-ai/dsh-client-ui-primitives',
] as const
/** One platform module specifier (a seed-table key). */
export type PlatformModule = (typeof PLATFORM_MODULES)[number]

View File

@@ -1,10 +1,10 @@
/**
* Pure-library module-table seed. These are the ONLY entities statically
* built into the shell bundle besides the loader machinery — every plugin
* (including the infrastructure four) arrives as a dynamic bundle and
* resolves its externals against this table through the loader's require.
* Keys must match the tsdown client preset's external specifiers
* (packages/client/tsdown.client.ts CLIENT_EXTERNALS ∩ pure libraries).
* Platform-singleton module-table. These are the ONLY entities the shell
* shares into the frozen module table — fetch bundles resolve their externals
* against exactly this set through the loader's require. Keys come from the
* platform constant module ({@link ./platform.ts}, contract C1: single source
* of truth with the tsdown client externals); values stay shell-static
* imports so every bundle sees the same instance.
*/
import * as React from 'react'
import * as ReactJsxRuntime from 'react/jsx-runtime'
@@ -14,12 +14,16 @@ import * as Cordis from 'cordis'
import * as UiSlots from '@deepseek-ai/dsh-client-ui-slots'
import * as WebReact from '@deepseek-ai/dsh-client-web-react'
import * as UiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
import type { PlatformModule } from './platform.ts'
/**
* Build the seed table handed to the loader machinery at boot.
* @returns module specifier → export-surface entity.
* Build the static table handed to the module loader at boot.
* @returns module specifier → export-surface entity (one entry per platform word).
*/
export function seedModules(): Record<string, unknown> {
export function getStaticModules(): Record<string, unknown> {
// The satisfies pin is the projection contract: a word added to
// PLATFORM_MODULES without a static import here (or vice versa) fails to
// compile instead of drifting into a runtime require miss.
return {
'react': React,
'react/jsx-runtime': ReactJsxRuntime,
@@ -29,5 +33,5 @@ export function seedModules(): Record<string, unknown> {
'@deepseek-ai/dsh-client-ui-slots': UiSlots,
'@deepseek-ai/dsh-client-web-react': WebReact,
'@deepseek-ai/dsh-client-ui-primitives': UiPrimitives,
}
} satisfies Record<PlatformModule, unknown>
}