refactor(cli): discover app startup rows from injection
This commit is contained in:
@@ -7,11 +7,11 @@
|
||||
#
|
||||
# Rows this app configures from flags read them from the `webStartup` service:
|
||||
# each names the key it takes and the value it falls back to, so a flag wins
|
||||
# over the value written beside it. The web-startup row is this bundle's
|
||||
# manifest-declared entrypoint, so it runs before any of them and has already
|
||||
# parsed --host/--port/--dev/--workspace-root/--trusted-host by the time their
|
||||
# config is resolved. `dsh --profile web --help` therefore prints this app's own
|
||||
# help and exits before the rest of the composition mounts at all.
|
||||
# over the value written beside it. The web-startup row injects `cmdlineArgs`,
|
||||
# so the launcher runs it first; it has parsed --host/--port/--dev/
|
||||
# --workspace-root/--trusted-host by the time those configs resolve.
|
||||
# `dsh --profile web --help` therefore prints this app's own help and exits
|
||||
# before the rest of the composition mounts at all.
|
||||
|
||||
# ── surface-specific values the base deliberately omits ─────────────────────
|
||||
|
||||
@@ -85,11 +85,12 @@
|
||||
config:
|
||||
workspaceRoot: !!js ctx.get('webStartup')?.workspaceRoot
|
||||
|
||||
# This bundle's entrypoint (declared in its package.json): it owns the web
|
||||
# flag family and its --help, and provides webStartup with the values this
|
||||
# invocation resolved. The boot runs it before every row above.
|
||||
# This app's command-line startup row: its `cmdlineArgs` injection makes the
|
||||
# launcher mount it first. It owns the web flag family and its --help, and
|
||||
# provides webStartup with the values this invocation resolved.
|
||||
- id: web-startup
|
||||
name: '@deepseek-ai/dsh-web-app/startup'
|
||||
inject: [cmdlineArgs]
|
||||
|
||||
# ── layer 2: transport/service ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -33,8 +33,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml",
|
||||
"entrypoint": "web-startup"
|
||||
"patch": "./cordis.patch.yml"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -4,15 +4,17 @@
|
||||
* manifest field). The plugin owns the browser-surface glue: it resolves
|
||||
* the built frontend dist (workspace knowledge of this bundle, never user
|
||||
* config), mounts the `frontend-static` fallback owner over it, registers the
|
||||
* web-surface prompt section and the bash-visible web runtime variables, and
|
||||
* prints the URL line when configured to. Flag-derived values (`mode`,
|
||||
* `lanAddresses`, `printUrl`) arrive as launcher patches over this row.
|
||||
* harness-source and web-surface prompt sections, the bash-visible web runtime
|
||||
* variables, and the URL line. App command-line values arrive through the
|
||||
* `webStartup` service expressions in the bundle patch.
|
||||
* @module @deepseek-ai/dsh-web-app
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
|
||||
import { enableRow } from '@deepseek-ai/dsh-cmdline'
|
||||
import * as FrontendStatic from '@deepseek-ai/dsh-frontend-static'
|
||||
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
||||
@@ -26,13 +28,16 @@ export const name = 'web-app'
|
||||
/** The client-plugin reload chain row this bundle ships disabled, for `--dev`. */
|
||||
const HMR_ROW_ID = 'client-hmr'
|
||||
|
||||
/** This dsh installation's root, from either this package's source or built entry. */
|
||||
const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url))
|
||||
|
||||
/** Services required before the web runtime can mount. */
|
||||
export const inject = ['httpServer']
|
||||
|
||||
/** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */
|
||||
export type WebMode = 'production' | 'development'
|
||||
|
||||
/** Plugin config: the surface facts the launcher patches over this bundle's defaults. */
|
||||
/** Plugin config: composed deployment settings plus per-invocation startup values. */
|
||||
export interface Config {
|
||||
/** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */
|
||||
mode: WebMode
|
||||
@@ -46,7 +51,7 @@ export interface Config {
|
||||
*/
|
||||
surfaceContext: boolean
|
||||
/**
|
||||
* LAN IPv4 addresses sampled once by the launcher when the effective bind
|
||||
* LAN IPv4 addresses sampled once by the app startup row when the effective bind
|
||||
* is all-interfaces — the exact snapshot the /api trust fence was
|
||||
* configured with, so the printed LAN URL can never name an address the
|
||||
* fence rejects. Empty on a loopback bind.
|
||||
@@ -113,16 +118,17 @@ export const internals: { resolveDistIndex: () => string } = { resolveDistIndex
|
||||
* variables, and the URL line.
|
||||
* @param ctx - plugin context carrying the httpServer service.
|
||||
* @param config - validated {@link Config}.
|
||||
* @returns nothing once optional development rows are active and runtime contributions are registered.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })
|
||||
// The client-plugin reload chain is a row this bundle ships off, because it
|
||||
// exists only in development. Turning it on belongs here rather than in the
|
||||
// entrypoint: it needs the host rows this phase of the boot mounts, and the
|
||||
// entrypoint runs before them.
|
||||
if (config.mode === 'development') void enableRow(ctx, HMR_ROW_ID)
|
||||
// startup row: it needs host services that also activate after webStartup.
|
||||
if (config.mode === 'development') await enableRow(ctx, HMR_ROW_ID)
|
||||
if (config.surfaceContext) {
|
||||
ctx.inject(['systemPrompt'], (promptCtx) => {
|
||||
addHarnessSourceSection(promptCtx, SOURCE_ROOT)
|
||||
promptCtx.systemPrompt.section({
|
||||
name: 'app:web-surface',
|
||||
order: -98,
|
||||
@@ -146,16 +152,15 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// sibling rows (the /api route owner) are still mounting. Await Loader
|
||||
// settlement first; a hand-built tree without a Loader prints at once.
|
||||
const printUrl = (): void => {
|
||||
// The launcher's boot-time LAN snapshot, not a fresh sample: the printed
|
||||
// The startup row's boot-time LAN snapshot, not a fresh sample: the printed
|
||||
// LAN URL must name an address the /api trust fence was configured with.
|
||||
const lanCandidate = config.lanAddresses[0]
|
||||
const port = ctx.httpServer.port
|
||||
console.log(`dsh web: ${localWebUrl(ctx)}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`)
|
||||
}
|
||||
// A launcher that mounts in phases tells this row when the whole
|
||||
// composition is up; Loader settlement alone would let the line print
|
||||
// between phases, announcing a server whose boot can still fail. A
|
||||
// hand-built tree has neither and prints at once.
|
||||
// A launcher tells this row when the whole concurrent composition is up;
|
||||
// this row's own activation can precede a sibling failure. A hand-built
|
||||
// tree falls back to Loader settlement, or prints at once without Loader.
|
||||
const settled = ctx.get('appReady') ?? ctx.get('loader')?.await()
|
||||
if (settled === undefined) printUrl()
|
||||
else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The web app's entrypoint row over a REAL Loader tree: every flag lands in the
|
||||
* The web app's startup row over a REAL Loader tree: every flag lands in the
|
||||
* `webStartup` service the web rows read, the bind it reports comes from the
|
||||
* flag or from what the composition falls back to, `--help` resolves nothing,
|
||||
* and a rejected argument exits without resolving anything.
|
||||
@@ -39,7 +39,7 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
/**
|
||||
* Mount the real entrypoint row over a stand-in for the `webserver` row whose
|
||||
* Mount the real startup row over a stand-in for the `webserver` row whose
|
||||
* composed bind it reads, the way a profile mounts phase one.
|
||||
* @param args - the invocation's inner arguments.
|
||||
* @param webserverConfig - the composed `webserver` row config, or `null` to omit the row.
|
||||
@@ -55,7 +55,7 @@ async function bootStartup(
|
||||
// The Loader imports a row through Node's own resolver, which cannot resolve
|
||||
// this workspace's sources; the row delegates to the real plugin the test
|
||||
// imported through the source-plane path mapping.
|
||||
writeFileSync(join(dir, 'entrypoint.mjs'), `
|
||||
writeFileSync(join(dir, 'startup.mjs'), `
|
||||
export const name = 'web-startup'
|
||||
export const inject = ['cmdlineArgs']
|
||||
export const apply = ctx => globalThis.__webStartupApply(ctx)
|
||||
@@ -82,7 +82,8 @@ export const apply = ctx => globalThis.__webStartupApply(ctx)
|
||||
` inject: [${WEB_STARTUP_SERVICE}]`,
|
||||
' disabled: true',
|
||||
'- id: web-startup',
|
||||
` name: ${pathToFileURL(join(dir, 'entrypoint.mjs')).href}`,
|
||||
` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`,
|
||||
' inject: [cmdlineArgs]',
|
||||
'',
|
||||
].join('\n'))
|
||||
const observing = { write: (chunk: string) => { observed.out += chunk; return true } }
|
||||
@@ -155,7 +156,7 @@ describe('web startup', () => {
|
||||
})
|
||||
|
||||
it('fails the boot when the composition lost the row whose bind it reads', async () => {
|
||||
// The bundle patch and this entrypoint must agree on the row set; a
|
||||
// The bundle patch and this startup row must agree on the row set; a
|
||||
// missing row would otherwise silently drop the flag that targets it.
|
||||
await expect(bootStartup([], null))
|
||||
.rejects.toThrow('the web composition has no waiting "webserver" row to configure')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Web runtime glue behavior: dist resolution through the bundle's own hook,
|
||||
* the frontend-static child claiming the fallback seat, the web-surface
|
||||
* prompt section and bash runtime variables, and URL-line printing with the
|
||||
* launcher's LAN snapshot.
|
||||
* app startup row's LAN snapshot.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
@@ -68,15 +68,25 @@ describe('web-app runtime glue', () => {
|
||||
return () => {}
|
||||
},
|
||||
} as never)
|
||||
const hmrUpdates: unknown[] = []
|
||||
ctx.provide('loader', {
|
||||
entries: () => [{
|
||||
options: { id: 'client-hmr' },
|
||||
update: async (options: unknown) => { hmrUpdates.push(options) },
|
||||
}],
|
||||
await: async () => {},
|
||||
} as never)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] }))
|
||||
await apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
// Settle the injected registrations.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
expect(seat()).toBeDefined() // frontend-static claimed the fallback
|
||||
expect(hmrUpdates).toEqual([{ disabled: false }])
|
||||
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)')
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout')
|
||||
const section = assembly.sections.find(entry => entry.name === 'app:web-surface')
|
||||
expect(section?.text).toContain('http://127.0.0.1:4567')
|
||||
expect(section?.text).toContain('--dev')
|
||||
@@ -90,7 +100,7 @@ describe('web-app runtime glue', () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('httpServer', fakeHttpServer().server)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] }))
|
||||
await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
@@ -111,11 +121,12 @@ describe('web-app runtime glue', () => {
|
||||
return () => {}
|
||||
},
|
||||
} as never)
|
||||
apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, lanAddresses: [] }))
|
||||
await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, lanAddresses: [] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections.some(entry => entry.name === 'app:web-surface')).toBe(false)
|
||||
expect(assembly.sections.some(entry => entry.name === 'harness:source')).toBe(false)
|
||||
expect(contributions).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -125,23 +136,23 @@ describe('web-app runtime glue', () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('httpServer', fakeHttpServer().server)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
|
||||
await apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('waits for the launcher readiness the phased boot provides, and stays quiet when that boot failed', async () => {
|
||||
it('waits for launcher readiness and stays quiet when the whole boot failed', async () => {
|
||||
stageDist()
|
||||
// The launcher-provided readiness wins over Loader settlement: a phased
|
||||
// boot settles the Loader between phases, long before the app is up.
|
||||
// Launcher readiness covers siblings that may still be mounting after
|
||||
// this row itself has activated.
|
||||
const ready = new Context()
|
||||
ready.provide('httpServer', fakeHttpServer().server)
|
||||
ready.provide('loader', { await: () => Promise.resolve() } as never)
|
||||
let announce: () => void
|
||||
ready.provide('appReady', new Promise<void>((resolve) => { announce = resolve }))
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
apply(ready, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
|
||||
await apply(ready, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
announce!()
|
||||
@@ -157,7 +168,7 @@ describe('web-app runtime glue', () => {
|
||||
const rejection = Promise.reject(new Error('boot failed'))
|
||||
rejection.catch(() => {})
|
||||
failed.provide('appReady', rejection)
|
||||
apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
|
||||
await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
await failed.fiber.dispose()
|
||||
@@ -173,7 +184,7 @@ describe('web-app runtime glue', () => {
|
||||
const settlement = new Promise<void>((resolve) => { release = resolve })
|
||||
settled.provide('loader', { await: () => settlement } as never)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
|
||||
await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
release!()
|
||||
@@ -192,7 +203,7 @@ describe('web-app runtime glue', () => {
|
||||
let releaseTorn: () => void
|
||||
const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
|
||||
torn.provide('loader', { await: () => tornSettlement } as never)
|
||||
apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
|
||||
await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
|
||||
await child.dispose() // the httpServer service goes away
|
||||
releaseTorn!()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
@@ -208,7 +219,7 @@ describe('web-app runtime glue', () => {
|
||||
const { server } = fakeHttpServer()
|
||||
Object.defineProperty(server, 'port', { get: () => undefined })
|
||||
ctx.provide('httpServer', server)
|
||||
apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] }))
|
||||
await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing')
|
||||
|
||||
Reference in New Issue
Block a user