feat(web): boot dsh web from a config tree via AppCLIEntry
apps/cli/cordis.yml holds the whole composition flat — the host runtime rows, the api-gateway row, the webserver row, and the ten dshClient rows. AppCLIEntry is the pre-cordis glue: layered env (ambient > cwd .env > $DSH_HOME/.env, fixing DSH_HOME=... dsh web not finding its key), patch composition from the three non-yml sources (profile json through the static PROFILE_MAPPINGS table, CLI flags, the resolved frontend distIndex), the Loader include boot (--dev appends the hmr row before the settle), and the fail-loud triple (assertEntriesLoaded + installFailLoud + an all-ACTIVE sweep for PENDING fibers). web.ts shrinks to argv parsing + the URL line.
This commit is contained in:
@@ -1,160 +1,66 @@
|
||||
/**
|
||||
* `dsh web` — the web-shape assembly: startHost + dist resolution +
|
||||
* startWebServer + the URL line + signal wiring. Mixing host and carrier
|
||||
* concerns is this app module's job (packages stay single-sided).
|
||||
* `dsh web` — thin bin over the config-tree boot: parse argv, run
|
||||
* AppCLIEntry, print the URL line, wire signals. All composition lives in
|
||||
* cordis.yml; all boot glue lives in AppCLIEntry.
|
||||
*/
|
||||
|
||||
import { parseArgs } from 'node:util'
|
||||
import { networkInterfaces } from 'node:os'
|
||||
import { createRequire } from 'node:module'
|
||||
import { mountWebPlugins, startHost } from '@deepseek-ai/dsh-host-runtime'
|
||||
import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { AppCLIEntry } from './app-cli-entry.ts'
|
||||
|
||||
const LOOPBACK_HOST = '127.0.0.1'
|
||||
const ALL_INTERFACES_HOST = '0.0.0.0'
|
||||
|
||||
// --- Client composition (composition decisions live in the composing app) ---
|
||||
// The composition layer owns one decision: which plugin packages mount (the
|
||||
// roster). Dependency edges and the boot prefetch tier live in each package's
|
||||
// dshClient declaration.
|
||||
|
||||
/**
|
||||
* Dev-only plugin: the client HMR driver. Whether it composes in is a
|
||||
* deployment decision — the dev graph includes its row, the prod graph does
|
||||
* not mount it at all.
|
||||
*/
|
||||
const CLIENT_HMR_ID = '@deepseek-ai/dsh-client-hmr'
|
||||
|
||||
/** Bundle stat-poll interval for --dev (held here so the startup log states the real value). */
|
||||
const CLIENT_BUNDLE_POLL_MS = 500
|
||||
|
||||
/** The client plugin roster (flat; per-row boot behavior comes from manifests). */
|
||||
const CLIENT_PACKAGES = [
|
||||
'@deepseek-ai/dsh-client-connection',
|
||||
'@deepseek-ai/dsh-client-runtime',
|
||||
'@deepseek-ai/dsh-client-ui-theme',
|
||||
'@deepseek-ai/dsh-client-i18n',
|
||||
'@deepseek-ai/dsh-client-ui-layout',
|
||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||
'@deepseek-ai/dsh-client-ui-conversation',
|
||||
'@deepseek-ai/dsh-client-ui-question',
|
||||
'@deepseek-ai/dsh-client-ui-trajectory',
|
||||
] as const
|
||||
const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
|
||||
|
||||
export async function runWeb(argv: string[]): Promise<void> {
|
||||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
host: { type: 'string', default: LOOPBACK_HOST },
|
||||
port: { type: 'string', default: '3080' },
|
||||
host: { type: 'string' },
|
||||
port: { type: 'string' },
|
||||
dev: { type: 'boolean', default: false },
|
||||
},
|
||||
allowPositionals: false,
|
||||
})
|
||||
if (values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) {
|
||||
if (values.host !== undefined && values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) {
|
||||
process.stderr.write(
|
||||
`dsh web: invalid --host ${values.host}; expected ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}\n`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
const hostAddress = values.host
|
||||
const port = Number(values.port)
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||
process.stderr.write(`dsh web: invalid --port ${values.port}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
|
||||
const host = await startHost({
|
||||
boot: {
|
||||
persistenceRoot: './.sessions',
|
||||
workspaceContext: { maxBytes: 65_536 },
|
||||
sessionTitleLlm: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Client plugin chain: in-memory Loader tree over the composed roster, then
|
||||
// the registry that feeds the __DSH_BOOT__ entry graph and
|
||||
// /plugins/<id>/client.js. All row content comes from dshClient discovery
|
||||
// over the mounted roster (dev adds the HMR driver row and turns on the
|
||||
// bundle watch that drives rebuilt frames).
|
||||
const roster = [...CLIENT_PACKAGES, ...values.dev ? [CLIENT_HMR_ID] : []]
|
||||
const mounted = await mountWebPlugins(host.ctx, roster, import.meta.url)
|
||||
const webPlugins = createHostWebPluginRegistry({
|
||||
ctx: host.ctx,
|
||||
loader: mounted.loader,
|
||||
resolvePkgJson: mounted.resolvePkgJson,
|
||||
onError: (err: Error) => { process.stderr.write(`dsh web: plugin rescan: ${String(err)}\n`) },
|
||||
...values.dev ? { watch: { intervalMs: CLIENT_BUNDLE_POLL_MS } } : {},
|
||||
})
|
||||
if (values.dev) {
|
||||
// Dev visibility (the registry is a library and never prints): list what
|
||||
// the bundle watch covers, then log every observed rebuild. This is a
|
||||
// second onRebuilt subscription — the SSE relay inside the webserver is
|
||||
// unaffected (multicast).
|
||||
const revs = new Map(webPlugins.graph().entries.map(row => [row.id, row.rev]))
|
||||
const bundlePaths = [...revs.keys()]
|
||||
.map(id => webPlugins.clientPath(id))
|
||||
.filter((path): path is string => path !== undefined)
|
||||
console.log(
|
||||
`dsh web: watching ${String(bundlePaths.length)} plugin bundles (${String(CLIENT_BUNDLE_POLL_MS)}ms poll):\n ${bundlePaths.join('\n ')}`,
|
||||
)
|
||||
webPlugins.onRebuilt((id, rev) => {
|
||||
console.log(`dsh web: plugin rebuilt: ${id} rev ${revs.get(id) ?? '?'} -> ${rev}`)
|
||||
revs.set(id, rev)
|
||||
})
|
||||
}
|
||||
// Published so the webserver invariant companion can audit manifest/bundle
|
||||
// consistency; nothing else reads this key.
|
||||
host.ctx.reflect.provide('webPlugins', webPlugins)
|
||||
|
||||
// Dist location is workspace knowledge of this app: resolved through
|
||||
// @deepseek-ai/dsh-frontend's package exports, not configured.
|
||||
const require = createRequire(import.meta.url)
|
||||
let distIndex: string
|
||||
try {
|
||||
distIndex = require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
|
||||
} catch {
|
||||
process.stderr.write('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first\n')
|
||||
await host.dispose()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let exiting = false
|
||||
async function shutdown(code: number): Promise<void> {
|
||||
if (exiting) return
|
||||
exiting = true
|
||||
try {
|
||||
await server.close()
|
||||
await host.dispose()
|
||||
} finally {
|
||||
process.exit(code)
|
||||
let port: number | undefined
|
||||
if (values.port !== undefined) {
|
||||
port = Number(values.port)
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||
process.stderr.write(`dsh web: invalid --port ${values.port}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
let server: Awaited<ReturnType<typeof startWebServer>>
|
||||
try {
|
||||
server = await startWebServer(
|
||||
{ host: hostAddress, port, distIndex, apiHandler: host.handler, webPlugins },
|
||||
(err: Error) => {
|
||||
process.stderr.write(`dsh web: ${String(err)}\n`)
|
||||
void shutdown(1)
|
||||
},
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
// listen failed (EADDRINUSE…): no server to close, dispose the host directly.
|
||||
process.stderr.write(`dsh web: ${String(error)}\n`)
|
||||
await host.dispose()
|
||||
process.exit(1)
|
||||
const entry = new AppCLIEntry({
|
||||
configPath: CONFIG_PATH,
|
||||
dev: values.dev,
|
||||
...values.host !== undefined ? { host: values.host } : {},
|
||||
...port !== undefined ? { port } : {},
|
||||
})
|
||||
const { ctx, port: boundPort } = await entry.run()
|
||||
|
||||
let exiting = false
|
||||
const shutdown = (code: number): void => {
|
||||
if (exiting) return
|
||||
exiting = true
|
||||
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
|
||||
}
|
||||
|
||||
const lan = hostAddress === ALL_INTERFACES_HOST
|
||||
const lanCandidate = values.host === ALL_INTERFACES_HOST
|
||||
? Object.values(networkInterfaces()).flat()
|
||||
.find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
|
||||
: undefined
|
||||
const localUrl = `http://${LOOPBACK_HOST}:${server.port}`
|
||||
console.log(`dsh web: ${localUrl}${lan === undefined ? '' : ` (LAN: http://${lan.address}:${server.port})`}`)
|
||||
const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
|
||||
console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate.address}:${boundPort})`}`)
|
||||
|
||||
process.on('SIGTERM', () => { void shutdown(0) })
|
||||
process.on('SIGINT', () => { void shutdown(130) })
|
||||
process.on('SIGTERM', () => { shutdown(0) })
|
||||
process.on('SIGINT', () => { shutdown(130) })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user