refactor(loader): resolve config after injected services
This commit is contained in:
@@ -7,11 +7,10 @@
|
||||
#
|
||||
# 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 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.
|
||||
# over the value written beside it. The web-startup row injects `cmdlineArgs`
|
||||
# and provides `webStartup`; Loader delays dependent-row config interpolation
|
||||
# until that service is active. `dsh --profile web --help` provides no service,
|
||||
# so the server rows never activate.
|
||||
|
||||
# ── surface-specific values the base deliberately omits ─────────────────────
|
||||
|
||||
@@ -85,9 +84,8 @@
|
||||
config:
|
||||
workspaceRoot: !!js ctx.get('webStartup')?.workspaceRoot
|
||||
|
||||
# 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.
|
||||
# This app's command-line startup row. It owns the web flag family and its
|
||||
# --help, and provides webStartup to the rows that inject it.
|
||||
- id: web-startup
|
||||
name: '@deepseek-ai/dsh-web-app/startup'
|
||||
inject: [cmdlineArgs]
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { networkInterfaces } from 'node:os'
|
||||
import { Command } from 'commander'
|
||||
import type { Context } from 'cordis'
|
||||
import type { EntryOptions } from '@cordisjs/plugin-loader'
|
||||
import { interpolate, type EntryOptions } from '@cordisjs/plugin-loader'
|
||||
import { runStartup } from '@deepseek-ai/dsh-cmdline'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
@@ -50,6 +50,20 @@ export interface WebStartupValues {
|
||||
/** The webserver schema's all-interfaces bind literal: only this bind derives LAN authorities. */
|
||||
const ALL_INTERFACES_HOST = '0.0.0.0'
|
||||
|
||||
/**
|
||||
* Read the deployment trust list before its row mounts and validates config.
|
||||
* @param config - the connection row's config resolved before `webStartup` exists.
|
||||
* @returns its configured authorities, or an empty list when absent.
|
||||
* @throws when the file-backed config is not an array of strings.
|
||||
*/
|
||||
function configuredTrustedHosts(config: unknown): string[] {
|
||||
const value = (config as { trustedHosts?: unknown } | undefined)?.trustedHosts
|
||||
if (value === undefined) return []
|
||||
const valid = Array.isArray(value) && value.every((entry: unknown) => typeof entry === 'string')
|
||||
if (!valid) throw new Error('web-startup: the composed connection trustedHosts must be an array of strings')
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-internal IPv4 interface addresses of this machine — the IP-literal
|
||||
* authorities an all-interfaces bind is reachable by on the LAN.
|
||||
@@ -118,19 +132,33 @@ Examples:
|
||||
* Turn the parsed flags into the values the web rows read.
|
||||
* @param program - the parsed web command.
|
||||
* @param rows - the waiting rows' composed options, in tree order.
|
||||
* @param ctx - the startup context used to resolve composed fallbacks before `webStartup` exists.
|
||||
* @returns the web rows' service value.
|
||||
*/
|
||||
function planWebStartup(program: Command, rows: readonly EntryOptions[]): WebStartupValues {
|
||||
function planWebStartup(program: Command, rows: readonly EntryOptions[], ctx: Context): WebStartupValues {
|
||||
const options = program.opts<WebOptions>()
|
||||
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
|
||||
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
|
||||
}
|
||||
const webserver = rows.find(row => row.id === 'webserver')
|
||||
if (webserver === undefined) throw new Error('web-startup: the web composition has no waiting "webserver" row to configure')
|
||||
// The bind this invocation ends on: the flag, else what the row falls back
|
||||
// to, which is the same literal its config expression names.
|
||||
const bindHost = options.host ?? (webserver.config as { host?: string } | undefined)?.host
|
||||
const { lanAddresses, trustedHosts } = resolveLanTrust(bindHost, options.trustedHost ?? [])
|
||||
const row = (id: string): EntryOptions => {
|
||||
const found = rows.find(candidate => candidate.id === id)
|
||||
if (found === undefined) throw new Error(`web-startup: the web composition has no waiting ${JSON.stringify(id)} row to configure`)
|
||||
return found
|
||||
}
|
||||
const webserver = row('webserver')
|
||||
row('api-gateway')
|
||||
row('web-runtime')
|
||||
const connection = row('connection')
|
||||
// Include preserves nested row expressions until their own injections are
|
||||
// active. Resolve just the composed fields this startup plan needs against
|
||||
// the pre-service context, where their `ctx.get('webStartup')` fallback wins.
|
||||
const webserverConfig = interpolate(ctx, webserver.config) as { host?: string } | undefined
|
||||
const connectionConfig: unknown = interpolate(ctx, connection.config)
|
||||
const bindHost = options.host ?? webserverConfig?.host
|
||||
const sampled = resolveLanTrust(bindHost, options.trustedHost ?? [])
|
||||
// Preserve deployment authorities when invocation-derived LAN literals or
|
||||
// explicit extras become the runtime value read by the connection row.
|
||||
const composedTrusted = configuredTrustedHosts(connectionConfig)
|
||||
return {
|
||||
...options.host !== undefined && { host: options.host },
|
||||
...options.port !== undefined && { port: Number(options.port) },
|
||||
@@ -138,15 +166,15 @@ function planWebStartup(program: Command, rows: readonly EntryOptions[]): WebSta
|
||||
// mode and lanAddresses describe this invocation, never the deployment, so
|
||||
// they are resolved on every boot.
|
||||
mode: options.dev === true ? 'development' : 'production',
|
||||
trustedHosts,
|
||||
lanAddresses,
|
||||
trustedHosts: [...composedTrusted, ...sampled.trustedHosts],
|
||||
lanAddresses: sampled.lanAddresses,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the web flag family and start the rows that read it.
|
||||
* Resolve the web flag family for rows waiting on `webStartup`.
|
||||
* @param ctx - plugin context carrying the command line and the Loader.
|
||||
* @returns nothing once the web rows are started, or once `--help` requested exit.
|
||||
* @returns nothing once the values are provided, or once `--help` requested exit.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
runStartup(ctx, WEB_STARTUP_SERVICE, webCommand(), planWebStartup)
|
||||
|
||||
@@ -40,14 +40,16 @@ afterEach(async () => {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* composed bind it reads before the dependent rows activate.
|
||||
* @param args - the invocation's inner arguments.
|
||||
* @param webserverConfig - the composed `webserver` row config, or `null` to omit the row.
|
||||
* @param trustedHosts - authorities the composed connection row already carries, or `null` when it carries none.
|
||||
* @returns the resolved service value (absent when the app requested exit) and what the boot observed.
|
||||
*/
|
||||
async function bootStartup(
|
||||
args: string[],
|
||||
webserverConfig: Record<string, unknown> | null = { host: '127.0.0.1', port: 3080 },
|
||||
trustedHosts: unknown = [],
|
||||
): Promise<{ values: WebStartupValues | undefined; observed: Observed; ctx: Context }> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-web-startup-'))
|
||||
const observed: Observed = { exits: [], out: '' }
|
||||
@@ -68,8 +70,20 @@ export const apply = ctx => globalThis.__webStartupApply(ctx)
|
||||
` inject: [${WEB_STARTUP_SERVICE}]`,
|
||||
' disabled: true',
|
||||
' config:',
|
||||
...Object.entries(webserverConfig).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`),
|
||||
...Object.entries(webserverConfig).map(([key, value]) => ` ${key}: !!js ctx.get('${WEB_STARTUP_SERVICE}')?.${key} ?? ${JSON.stringify(value)}`),
|
||||
],
|
||||
'- id: connection',
|
||||
` name: ${rowUrl}`,
|
||||
` inject: [${WEB_STARTUP_SERVICE}]`,
|
||||
' disabled: true',
|
||||
...trustedHosts === null ? [] : [
|
||||
' config:',
|
||||
` trustedHosts: !!js ctx.get('${WEB_STARTUP_SERVICE}')?.trustedHosts ?? ${JSON.stringify(trustedHosts)}`,
|
||||
],
|
||||
'- id: api-gateway',
|
||||
` name: ${rowUrl}`,
|
||||
` inject: [${WEB_STARTUP_SERVICE}]`,
|
||||
' disabled: true',
|
||||
// A second reader keeps the composition honest when the webserver row is
|
||||
// the one under test: the service must still have someone to serve.
|
||||
'- id: web-runtime',
|
||||
@@ -121,13 +135,36 @@ describe('web startup', () => {
|
||||
expect(values).not.toHaveProperty('port')
|
||||
})
|
||||
|
||||
it('derives the LAN literals for an all-interfaces bind, and the extras with them', async () => {
|
||||
const { values } = await bootStartup(['--host', '0.0.0.0', '--trusted-host', 'lab.internal'])
|
||||
expect(values?.trustedHosts).toEqual(['192.168.1.5', 'lab.internal'])
|
||||
it('adds LAN literals and explicit extras after the composed fence authorities', async () => {
|
||||
const { values } = await bootStartup(
|
||||
['--host', '0.0.0.0', '--trusted-host', 'lab.internal', 'lab-2.internal', '--trusted-host', '10.0.0.9'],
|
||||
{ host: '127.0.0.1', port: 3080 },
|
||||
['profile.internal'],
|
||||
)
|
||||
expect(values?.trustedHosts).toEqual([
|
||||
'profile.internal', '192.168.1.5', 'lab.internal', 'lab-2.internal', '10.0.0.9',
|
||||
])
|
||||
// Display gets the same single sample the fence was configured with.
|
||||
expect(values?.lanAddresses).toEqual(['192.168.1.5'])
|
||||
})
|
||||
|
||||
it('starts from an empty trust list when the composed connection row names none', async () => {
|
||||
const { values } = await bootStartup(
|
||||
['--trusted-host', 'lab.internal'],
|
||||
{ host: '127.0.0.1', port: 3080 },
|
||||
null,
|
||||
)
|
||||
expect(values?.trustedHosts).toEqual(['lab.internal'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
'profile.internal',
|
||||
['profile.internal', 1],
|
||||
])('rejects an invalid composed trust list before transforming it (%j)', async (trustedHosts) => {
|
||||
await expect(bootStartup([], { host: '127.0.0.1', port: 3080 }, trustedHosts))
|
||||
.rejects.toThrow('the composed connection trustedHosts must be an array of strings')
|
||||
})
|
||||
|
||||
it('reads the composed bind when no flag names one, so a configured 0.0.0.0 still derives them', async () => {
|
||||
const { values } = await bootStartup([], { host: '0.0.0.0', port: 3080 })
|
||||
expect(values?.lanAddresses).toEqual(['192.168.1.5'])
|
||||
@@ -135,8 +172,8 @@ describe('web startup', () => {
|
||||
|
||||
it('reports the development mode for --dev, which the web runtime reads', async () => {
|
||||
const { values } = await bootStartup(['--dev'])
|
||||
// The runtime row is what turns the reload chain on, in the phase whose
|
||||
// host rows it needs; this row only reports the mode.
|
||||
// The runtime row turns the reload chain on after its host dependencies
|
||||
// activate; this row only reports the mode.
|
||||
expect(values?.mode).toBe('development')
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user