mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
feat(infra): unify Web and Desktop launch commands
Web and Desktop now share one command pair: start:* launches the artifacts of a prior complete build and dev:* builds first, then launches. start:web is new and spells out the same source launch vector as `pnpm dsh web`; dev:web runs `pnpm run build`, the three rebuild watchers, and `dsh web` in one terminal, with --skip-build and --no-serve for the previous two-terminal flow. The launcher gate pins both scripts to the dsh CLI, and a root Makefile names the same commands as make web|dev-web|desktop|dev-desktop|build. The dev-web stage supervisor waits for stages the terminal already interrupted, escalates only survivors, forwards SIGTERM once, and reports a stage that exits on its own. Signal listeners are persistent because the tsdown watchers bundle signal-exit, which re-raises a signal when it finds no other listener.
This commit is contained in:
@@ -101,6 +101,8 @@ pnpm run test:docs # quick documentation checks (no build; doc-quick aggreg
|
||||
pnpm run website:build # VitePress build (doubles as dead-link check)
|
||||
pnpm dsh --profile headless "task" # run one task from source (needs DEEPSEEK_API_KEY)
|
||||
pnpm run demo:ptc -- "task" # headless PTC mode run (needs key)
|
||||
pnpm run dev:web | dev:desktop # build, then launch; Web also rebuilds client bundles on edits. start:web | start:desktop skip the build
|
||||
make web|dev-web|desktop|dev-desktop|build # the same commands; ARGS='--no-open' forwards options
|
||||
```
|
||||
|
||||
### Host sandbox failures
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Short names for the Web and Desktop application commands. The package.json
|
||||
# scripts they call stay the source of truth; docs/development.md documents both.
|
||||
.DEFAULT_GOAL := help
|
||||
.PHONY: help build web desktop dev-web dev-desktop
|
||||
|
||||
PNPM ?= pnpm
|
||||
ARGS ?=
|
||||
|
||||
help:
|
||||
@echo "make build pnpm run build complete repository build"
|
||||
@echo "make web pnpm run start:web serve the built Web artifacts from source"
|
||||
@echo "make desktop pnpm run start:desktop launch the built Desktop artifacts"
|
||||
@echo "make dev-web pnpm run dev:web build, serve, and rebuild Web on source edits"
|
||||
@echo "make dev-desktop pnpm run dev:desktop build, then launch Desktop"
|
||||
@echo "ARGS='--no-open --port 3081' forwards options to the launched application;"
|
||||
@echo "the Web commands accept dsh web flags, the Desktop launcher accepts none."
|
||||
|
||||
build:
|
||||
$(PNPM) run build
|
||||
|
||||
web:
|
||||
$(PNPM) run start:web $(ARGS)
|
||||
|
||||
desktop:
|
||||
$(PNPM) run start:desktop $(ARGS)
|
||||
|
||||
dev-web:
|
||||
$(PNPM) run dev:web $(ARGS)
|
||||
|
||||
dev-desktop:
|
||||
$(PNPM) run dev:desktop $(ARGS)
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/cli/reference/README.md
|
||||
README.md: cf0e1400f2dc8cd29cc59cefbdda379df2e0b899
|
||||
README.zh.md: a01818f59ccf54f3f826c68505bfb830a9316664
|
||||
README.md: 33465e8c96df9cbfbecc038616caf297146ec971
|
||||
README.zh.md: d25d27851ca789a3db358157368b97c3669ed79d
|
||||
|
||||
@@ -85,7 +85,7 @@ Git-hosted plugins that ship sources build during install through their `prepare
|
||||
|
||||
## Web profile
|
||||
|
||||
`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until a separate `pnpm run dev:web` watcher rebuilds client bundles.
|
||||
`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until `pnpm run dev:web` rebuilds client bundles; that command builds once, starts this same launcher, and keeps client bundles rebuilt, or with `--no-serve` runs only the watchers beside a `dsh web` started elsewhere.
|
||||
|
||||
```sh
|
||||
dsh web
|
||||
|
||||
@@ -87,7 +87,7 @@ dsh --profile tui
|
||||
|
||||
## Web Profile
|
||||
|
||||
`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在单独运行的 `pnpm run dev:web` watcher 重建客户端 bundle 之前保持空闲。
|
||||
`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在 `pnpm run dev:web` 重建客户端 bundle 之前保持空闲;该命令先构建一次,再启动这同一个启动器并持续重建客户端 bundle,加 `--no-serve` 则只运行 watcher、配合别处启动的 `dsh web`。
|
||||
|
||||
```sh
|
||||
dsh web
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/desktop/README.md
|
||||
README.md: 6c9a6a8784732e6fa1369df5632391b789786b22
|
||||
README.zh.md: a0f2184d911eeab46902670cf992b6847e2bca31
|
||||
README.md: 2d7882ec6bf8827dc65e2e9653c407908c0fbab5
|
||||
README.zh.md: 2a0277fa824ce43e45e8408df0ac029bb38c16a5
|
||||
|
||||
@@ -93,7 +93,7 @@ After an explicit build, `start:desktop` reconstructs the disposable project and
|
||||
pnpm run start:desktop
|
||||
```
|
||||
|
||||
Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths.
|
||||
The Web counterparts are `pnpm run dev:web` and `pnpm run start:web`, documented in the [development guide](../../docs/development.md). Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths.
|
||||
|
||||
## Package
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ pnpm run dev:desktop
|
||||
pnpm run start:desktop
|
||||
```
|
||||
|
||||
Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。
|
||||
Web 侧的对应命令是 `pnpm run dev:web` 与 `pnpm run start:web`,见[开发指南](../../docs/development.zh.md)。Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。
|
||||
|
||||
## 打包
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Published dsh web + pnpm dev:web → browser HMR, with no page reload. */
|
||||
/** Built dsh web + the `pnpm run dev:web --no-serve` watchers → browser HMR, with no page reload. */
|
||||
|
||||
import { existsSync, globSync, statSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
@@ -108,12 +108,14 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a
|
||||
const failures: unknown[] = []
|
||||
try {
|
||||
subprocessFiber = await subprocessCtx.plugin(LocalSubprocessRuntime)
|
||||
// Watchers only: the built `dsh web` below is the server under test, and the
|
||||
// built tree is this lane's precondition rather than something to rebuild.
|
||||
watcher = subprocessCtx.subprocess.spawn(spawnSpec(
|
||||
['pnpm', 'run', 'dev:web'],
|
||||
['pnpm', 'run', 'dev:web', '--skip-build', '--no-serve'],
|
||||
REPO_ROOT,
|
||||
{ ...clientBuildEnvironment },
|
||||
))
|
||||
await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web')
|
||||
await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web --skip-build --no-serve')
|
||||
host = subprocessCtx.subprocess.spawn(spawnSpec(
|
||||
[process.execPath, binPath, 'web', '--no-open', '--port', '0'],
|
||||
world,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { productWebBundleIsolation } from './product-isolation.ts'
|
||||
const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url))
|
||||
const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. '
|
||||
+ 'From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. '
|
||||
+ 'For client-plugin HMR, run `pnpm dsh web` together with `pnpm run dev:web`.'
|
||||
+ 'For client-plugin HMR, run `pnpm run dev:web`, which starts `dsh web` and the rebuild watchers together.'
|
||||
const DEFAULT_CLIENT_TITLE = 'DSH Local Build'
|
||||
|
||||
/** Escape build-time text before placing it in the HTML title element. */
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/api-gateway.md
|
||||
api-gateway.md: 2cf7b2f345991fd20639e1294662cc64fc02093d
|
||||
api-gateway.zh.md: 5b80db08de49591b27751e187aa6278384a7444e
|
||||
api-gateway.md: aa8339219a563901d5d132f4f401325ca30a3d53
|
||||
api-gateway.zh.md: 99b2d9866d38e520880e5d13115c2e6370fca474
|
||||
|
||||
+2
-3
@@ -138,14 +138,13 @@ SRC solves only dispatch for a Host process running from source. The Client does
|
||||
|
||||
## Development mode
|
||||
|
||||
Web development prepares current Host, Client, and Web artifacts with `pnpm run build`, then runs the source Host and the Client plugin watcher in separate terminals:
|
||||
Web development runs one command, which builds current Host, Client, and Web artifacts, starts the source Host, and keeps Client bundles rebuilt:
|
||||
|
||||
```sh
|
||||
pnpm dsh web
|
||||
pnpm run dev:web
|
||||
```
|
||||
|
||||
`dsh` starts the Host source through tsx, so the Host can use the SRC fallback; `dev:web` watches only Client plugins with a `dsh.client` declaration and rewrites their `lib/client.js`. It does not analyze Host decorators or generate Remote Client DTS.
|
||||
`dsh` starts the Host source through tsx, so the Host can use the SRC fallback; the `dev:web` watchers rebuild the client-face type emit, Client plugin and library bundles, and the Web shell on source edits. They do not analyze Host decorators or generate Remote Client DTS; only the complete build that `dev:web` runs first (or `pnpm run build`) does.
|
||||
|
||||
Changing only a Remote method's implementation body without changing its contract does not require regenerating the Typert files. After adding or removing a decorator or changing an export name, namespace, parameter, return value, lookup, Context, or cancellation signature, rerun the ordered lib build so the Host generates the strict contract before the Client compiles and bundles the new contribution:
|
||||
|
||||
|
||||
@@ -138,14 +138,13 @@ SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Ho
|
||||
|
||||
## 开发模式
|
||||
|
||||
Web 开发先使用 `pnpm run build` 准备当前 Host、Client 与 Web 产物,然后在两个终端中分别运行源码 Host 和 Client plugin watcher:
|
||||
Web 开发只需一条命令:它先构建当前 Host、Client 与 Web 产物,再启动源码 Host,并持续重建 Client bundle:
|
||||
|
||||
```sh
|
||||
pnpm dsh web
|
||||
pnpm run dev:web
|
||||
```
|
||||
|
||||
`dsh` 通过 tsx 启动 Host 源码,所以 Host 可以使用 SRC 回退;`dev:web` 只监听带 `dsh.client` 声明的 Client 插件并重写其 `lib/client.js`,它不会分析 Host decorator,也不会生成 Remote Client DTS。
|
||||
`dsh` 通过 tsx 启动 Host 源码,所以 Host 可以使用 SRC 回退;`dev:web` 的 watcher 会在源码修改时重建 client 侧类型输出、Client 插件与库 bundle 以及 Web 壳层。它们不会分析 Host decorator,也不会生成 Remote Client DTS;只有 `dev:web` 首先执行的完整构建(或 `pnpm run build`)才会。
|
||||
|
||||
只修改 Remote 方法实现体而不改变约定时,无需重新生成 Typert 文件。新增或删除 decorator、修改导出名、namespace、参数、返回值、lookup、Context 或取消签名时,重新执行有序 lib 构建,让 Host 先生成严格约定,再让 Client 编译并打包新的贡献:
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/architecture.md
|
||||
architecture.md: 0bbf5ef7f4c0444635178ba0ba85fbb9527d76ec
|
||||
architecture.zh.md: 0d28dc349fd9c655529bcc43deabc47055e0d394
|
||||
architecture.md: 5e0bb09770e013349bcc1326bea1eab9cdc3e595
|
||||
architecture.zh.md: a146c93c14ef0f563babf98f7311f1e5686895cc
|
||||
|
||||
@@ -44,7 +44,7 @@ Composition mechanics are in [app-boot](../packages/boot/app-boot/README.md#prof
|
||||
|
||||
Supported Node applications launch through named `dsh` profiles. The shipped profiles are `web`, `headless`, `sdk`, `sdk-minimal`, and `acp`, selected with `dsh --profile <name>` or `dsh <name>`. `plugin` names the management command; a profile with that name requires `--profile plugin`. The TypeScript SDK resolves its same-version `dsh` dependency and selects `sdk`; custom plugin composition remains a profile plus ordered patch files, not another executable or inline application tree. `sdk-minimal` is a repository-owned standalone bundle behind the same launcher, not a caller-supplied Cordis tree.
|
||||
|
||||
Vendored CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are not Harness application launchers. [`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts) keeps every package bin, executable source, and root demo in an explicit class and rejects a Node application path that bypasses `dsh`.
|
||||
Vendored CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are not Harness application launchers. [`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts) keeps every package bin, executable source, root demo, and the root `start:web` and `dev:web` scripts in an explicit class and rejects a Node application path that bypasses `dsh`.
|
||||
|
||||
The Python SDK follows the same application architecture. Its runtime wheel packages the normal `dsh` CLI as `deepseek-harness-sdk-runtime-<platform>-<arch>`, and the client launches `dsh --profile sdk` with an explicit Harness home by default. The minimal example selects the shipped `sdk-minimal` profile. Python exposes profile selection and ordered patch files rather than a complete Cordis tree; persistent external plugins are installed through `dsh plugin`. The removed private direct-config carrier has no compatibility bin or fallback parser.
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ dsh --profile web --dump-config
|
||||
|
||||
受支持的 Node 应用通过具名 `dsh` profile 启动。随附 profile 为 `web`、`headless`、`sdk`、`sdk-minimal` 和 `acp`,可通过 `dsh --profile <name>` 或 `dsh <name>` 选择。`plugin` 表示管理命令;同名 profile 必须用 `--profile plugin` 选择。TypeScript SDK 会解析其同版本 `dsh` 依赖并选择 `sdk`;自定义插件组合继续由 profile 与有序 patch 文件表达,而不是另一个可执行文件或内联应用树。`sdk-minimal` 是位于同一 launcher 后的仓库自有独立组合包,而不是由调用方提供的 Cordis 配置树。
|
||||
|
||||
Vendored CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于 Harness 应用启动器。[`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts)将每个包 bin、可执行源码与根 demo 归入显式类别,并拒绝任何绕过 `dsh` 的 Node 应用路径。
|
||||
Vendored CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于 Harness 应用启动器。[`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts)将每个包 bin、可执行源码、根 demo 以及根脚本 `start:web` 与 `dev:web` 归入显式类别,并拒绝任何绕过 `dsh` 的 Node 应用路径。
|
||||
|
||||
Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI 打包为 `deepseek-harness-sdk-runtime-<platform>-<arch>`,客户端默认以显式 Harness home 启动 `dsh --profile sdk`。极简示例选择随附的 `sdk-minimal` profile。Python 暴露 profile 选择与有序 patch 文件,而不是完整 Cordis 树;持久外部插件通过 `dsh plugin` 安装。已删除的私有直读配置载体没有兼容 bin 或回退 parser。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/development.md
|
||||
development.md: ce8f1e7746562e696c96e6359ec9fcfde596d6e2
|
||||
development.zh.md: 85e95946dfd7210201f0807880d5ed1a92859356
|
||||
development.md: 7e8d0ded70ca880ce986cb3879d959fbe2a04304
|
||||
development.zh.md: 84d0d76d8c76f829c0ed928d28336df3e58ed7f0
|
||||
|
||||
+14
-1
@@ -83,7 +83,7 @@ Both tsdown passes use the same complete workspace match. They neither scan buil
|
||||
|
||||
Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build.
|
||||
|
||||
`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. `pnpm run dev:web` still requires the artifact tree from a prior complete build, but it samples the current version and Git state once at startup and shares that environment across every watcher stage for the session; it does not validate the complete-build record because the watcher stages rewrite its recorded artifacts.
|
||||
`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. `pnpm run dev:web` runs that complete build first (`--skip-build` reuses an existing artifact tree instead), then samples the current version and Git state once and shares that environment across every watcher stage for the session; it does not validate the complete-build record because the watcher stages rewrite its recorded artifacts.
|
||||
|
||||
Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the Typert contract-generation pass or the complete build. See the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership and the [Typert Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.
|
||||
|
||||
@@ -156,6 +156,19 @@ The PTC mode demo runs the same headless profile with code presentation enabled:
|
||||
pnpm run demo:ptc -- "summarize this workspace"
|
||||
```
|
||||
|
||||
### Application commands
|
||||
|
||||
Web and Desktop share one command pair. `start:*` launches the artifacts of a prior `pnpm run build`; `dev:*` runs that build first and then launches. Web additionally keeps client bundles rebuilt on source edits, because its Host runs from source while the browser loads built bundles:
|
||||
|
||||
```sh
|
||||
pnpm run start:web # serve built Web artifacts through the source launcher (the same launch as pnpm dsh web)
|
||||
pnpm run dev:web # build, serve, and rebuild Web client bundles on source edits
|
||||
pnpm run start:desktop # launch built Desktop artifacts
|
||||
pnpm run dev:desktop # build, then launch Desktop
|
||||
```
|
||||
|
||||
Arguments after a Web command reach `dsh web`, for example `pnpm run dev:web --no-open --port 3081`; `dev:web` also accepts `--skip-build` to reuse the existing artifact tree and `--no-serve` to run only the rebuild watchers beside a server started elsewhere. Both Web commands use the normal Harness home, while the Desktop commands use the isolated development home described in the [Desktop README](../apps/desktop/README.md). The root `Makefile` names the same commands as `make web`, `make dev-web`, `make desktop`, `make dev-desktop`, and `make build`; `ARGS='--no-open'` forwards options.
|
||||
|
||||
### TODO markers
|
||||
|
||||
Use one of three comment tags to flag known issues in the code, ordered by urgency:
|
||||
|
||||
+14
-1
@@ -87,7 +87,7 @@ pnpm run build:web
|
||||
|
||||
Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。
|
||||
|
||||
`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 仍需要先执行完整构建来准备产物树,但会在启动时读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。
|
||||
`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 会先执行这次完整构建(`--skip-build` 则复用现有产物树),再读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。
|
||||
|
||||
静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 Typert 约定生成阶段或完整构建。tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md),门禁准备约定见 [Typert Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)。
|
||||
|
||||
@@ -160,6 +160,19 @@ PTC mode 演示启用代码式工具展示,并运行同一个 headless profile
|
||||
pnpm run demo:ptc -- "summarize this workspace"
|
||||
```
|
||||
|
||||
### 应用命令
|
||||
|
||||
Web 与 Desktop 共用同一对命令。`start:*` 启动上一次 `pnpm run build` 的产物;`dev:*` 先执行该构建再启动。Web 还会在源码修改时持续重建 client bundle,因为它的 Host 从源码运行,而浏览器加载的是构建产物:
|
||||
|
||||
```sh
|
||||
pnpm run start:web # serve built Web artifacts through the source launcher (the same launch as pnpm dsh web)
|
||||
pnpm run dev:web # build, serve, and rebuild Web client bundles on source edits
|
||||
pnpm run start:desktop # launch built Desktop artifacts
|
||||
pnpm run dev:desktop # build, then launch Desktop
|
||||
```
|
||||
|
||||
Web 命令后面的参数会传给 `dsh web`,例如 `pnpm run dev:web --no-open --port 3081`;`dev:web` 还接受 `--skip-build` 复用现有产物树,以及 `--no-serve` 只运行重建 watcher、配合别处启动的服务器。两个 Web 命令使用正常的 Harness home,而 Desktop 命令使用 [Desktop README](../apps/desktop/README.zh.md) 描述的隔离开发 home。根目录 `Makefile` 以 `make web`、`make dev-web`、`make desktop`、`make dev-desktop` 和 `make build` 命名同一套命令;`ARGS='--no-open'` 用于转发参数。
|
||||
|
||||
### TODO 标记
|
||||
|
||||
请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:
|
||||
|
||||
@@ -196,6 +196,7 @@
|
||||
"demo:ptc": "node scripts/demo-ptc.mjs",
|
||||
"demo:inspector": "node --import tsx/esm apps/cli/src/bin.ts web --patch ./packages/experimental/inspector/cordis.source.patch.yml",
|
||||
"mock:llm": "node --import tsx packages/test-support/llm-mock-server/src/bin.ts",
|
||||
"start:web": "node --import tsx/esm apps/cli/src/bin.ts web",
|
||||
"dev:web": "tsx scripts/dev-web.ts --poll",
|
||||
"postinstall": "node scripts/install-lefthook.mjs"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/hmr/README.md
|
||||
README.md: e459da10332ffdb3af422b75f639ce25df512487
|
||||
README.zh.md: 54e2f3af63472dedbfc75c5b4400b5d93e14f311
|
||||
README.md: 5dae1463ceed315c3409bf97de80649f10e83fa8
|
||||
README.zh.md: a77befcf63cd78be66b7e5b636d86367af6fdcdf
|
||||
|
||||
@@ -29,7 +29,7 @@ The shipped Web composition mounts this transport for live plugin changes. Durin
|
||||
|
||||
### Starting the reload chain
|
||||
|
||||
Run `pnpm run dev:web` (or a watch process using the shared Client tsdown preset) against the same host; rebuilt plugins are then swapped into the running browser automatically, one at a time. The preset stamps `lib/client.js` after all package-local chunks are written, so a chunk-only rebuild also advances the package revision without Host-side chunk scanning.
|
||||
Run `pnpm run dev:web`, which starts the host and the rebuild watchers together (`--no-serve` attaches only the watchers to a host started elsewhere, as does any watch process using the shared Client tsdown preset); rebuilt plugins are then swapped into the running browser automatically, one at a time. The preset stamps `lib/client.js` after all package-local chunks are written, so a chunk-only rebuild also advances the package revision without Host-side chunk scanning.
|
||||
|
||||
### What a reload does
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ kind: "package-reference"
|
||||
|
||||
### 启动重载链路
|
||||
|
||||
对同一个宿主运行 `pnpm run dev:web`(或使用共享 Client tsdown 预设的 watch 进程);重建后的插件随后会被自动逐个替换进运行中的浏览器。该预设会在所有包内 chunk 写完后标记 `lib/client.js`,因此仅 chunk 发生重建也会推进包 revision,无需 Host 扫描 chunk。
|
||||
运行 `pnpm run dev:web`,它会同时启动宿主与重建 watcher(`--no-serve` 则只把 watcher 接到别处启动的宿主上,使用共享 Client tsdown 预设的任何 watch 进程亦然);重建后的插件随后会被自动逐个替换进运行中的浏览器。该预设会在所有包内 chunk 写完后标记 `lib/client.js`,因此仅 chunk 发生重建也会推进包 revision,无需 Host 扫描 chunk。
|
||||
|
||||
### 一次重载做什么
|
||||
|
||||
|
||||
+98
-1
@@ -1,15 +1,112 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TsdownBundle } from 'tsdown'
|
||||
import { writeClientBuildRecord } from './client-build-environment.ts'
|
||||
import {
|
||||
devWebBuildEnvironment,
|
||||
discoverLibraryDirs,
|
||||
discoverPluginDirs,
|
||||
parseDevWebArguments,
|
||||
StageSupervisor,
|
||||
watchClientPlugins,
|
||||
} from './dev-web.ts'
|
||||
import type { StageHandle } from './dev-web.ts'
|
||||
|
||||
/** A stage double that exits only on the listed signals or an explicit exit code. */
|
||||
function fakeStage(name: string, exitOn: readonly NodeJS.Signals[]): {
|
||||
handle: StageHandle
|
||||
signals: NodeJS.Signals[]
|
||||
exit: (code: number) => void
|
||||
} {
|
||||
const exit = Promise.withResolvers<number | null>()
|
||||
const signals: NodeJS.Signals[] = []
|
||||
return {
|
||||
handle: {
|
||||
name,
|
||||
exited: exit.promise,
|
||||
kill(signal) {
|
||||
signals.push(signal)
|
||||
if (exitOn.includes(signal)) exit.resolve(null)
|
||||
},
|
||||
},
|
||||
signals,
|
||||
exit: (code) => { exit.resolve(code) },
|
||||
}
|
||||
}
|
||||
|
||||
describe('StageSupervisor', () => {
|
||||
it('reports a stage that exits on its own', async () => {
|
||||
const stale: [string, number | null][] = []
|
||||
const supervisor = new StageSupervisor((name, code) => { stale.push([name, code]) })
|
||||
const tsc = fakeStage('tsc', ['SIGTERM'])
|
||||
supervisor.add(tsc.handle)
|
||||
tsc.exit(2)
|
||||
await tsc.handle.exited
|
||||
expect(stale).toEqual([['tsc', 2]])
|
||||
})
|
||||
|
||||
it('forwards one signal, escalates survivors after the grace period, and reports none of them', async () => {
|
||||
const stale: string[] = []
|
||||
const supervisor = new StageSupervisor((name) => { stale.push(name) })
|
||||
const polite = fakeStage('vite', ['SIGTERM'])
|
||||
const stubborn = fakeStage('dsh web', ['SIGKILL'])
|
||||
supervisor.add(polite.handle)
|
||||
supervisor.add(stubborn.handle)
|
||||
await supervisor.stop({ signal: 'SIGTERM', graceMs: 20 })
|
||||
expect(polite.signals).toEqual(['SIGTERM'])
|
||||
expect(stubborn.signals).toEqual(['SIGTERM', 'SIGKILL'])
|
||||
expect(stale).toEqual([])
|
||||
})
|
||||
|
||||
it('waits for stages the terminal already interrupted before signaling survivors', async () => {
|
||||
const supervisor = new StageSupervisor(() => { throw new Error('unexpected stale report') })
|
||||
const interrupted = fakeStage('tsdown', [])
|
||||
const survivor = fakeStage('dsh web', ['SIGTERM'])
|
||||
supervisor.add(interrupted.handle)
|
||||
supervisor.add(survivor.handle)
|
||||
const stopping = supervisor.stop({ graceMs: 20 })
|
||||
interrupted.exit(130)
|
||||
await stopping
|
||||
expect(interrupted.signals).toEqual([])
|
||||
expect(survivor.signals).toEqual(['SIGTERM'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDevWebArguments', () => {
|
||||
it('builds, serves, and watches natively by default', () => {
|
||||
expect(parseDevWebArguments([])).toEqual({ skipBuild: false, serve: true, pollInterval: undefined, appArgs: [] })
|
||||
})
|
||||
|
||||
it('reads the polling interval with its 500ms default', () => {
|
||||
expect(parseDevWebArguments(['--poll']).pollInterval).toBe(500)
|
||||
expect(parseDevWebArguments(['--poll=250']).pollInterval).toBe(250)
|
||||
})
|
||||
|
||||
it.each(['--poll=abc', '--poll=0', '--poll=-5', '--poll=1.5'])('rejects the polling interval %s', (flag) => {
|
||||
expect(() => parseDevWebArguments([flag])).toThrow(`invalid --poll interval "${flag}"`)
|
||||
})
|
||||
|
||||
it('separates its own flags from the arguments forwarded to dsh web', () => {
|
||||
expect(parseDevWebArguments(['--skip-build', '--poll', '--no-open', '--port', '8080'])).toEqual({
|
||||
skipBuild: true, serve: true, pollInterval: 500, appArgs: ['--no-open', '--port', '8080'],
|
||||
})
|
||||
})
|
||||
|
||||
it('runs only the rebuild watchers with --no-serve', () => {
|
||||
expect(parseDevWebArguments(['--no-serve', '--skip-build'])).toMatchObject({ serve: false, skipBuild: true })
|
||||
})
|
||||
|
||||
it('ignores the separator pnpm run forwards verbatim', () => {
|
||||
expect(parseDevWebArguments(['--', '--no-open']).appArgs).toEqual(['--no-open'])
|
||||
expect(parseDevWebArguments(['--no-serve', '--'])).toMatchObject({ serve: false, appArgs: [] })
|
||||
})
|
||||
|
||||
it('rejects dsh web arguments when no server is started', () => {
|
||||
expect(() => parseDevWebArguments(['--no-serve', '--no-open'])).toThrow('--no-serve leaves no dsh web process for --no-open')
|
||||
})
|
||||
})
|
||||
|
||||
it('samples one local environment at startup without validating watcher outputs', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-environment-'))
|
||||
|
||||
+249
-76
@@ -1,28 +1,38 @@
|
||||
/**
|
||||
* Watch-build for the web dev loop: rebuilds every artifact the browser reads
|
||||
* from a source edit. Reload signaling is not this script's business — the host
|
||||
* webserver stat-polls the bundles it serves and broadcasts `rebuilt` frames
|
||||
* itself (`dsh web`), so any process that rewrites `lib/client.js` files
|
||||
* triggers reloads; this script is merely the convenient way to keep them all
|
||||
* rebuilt on source change.
|
||||
* The Web development loop: build once, serve through `dsh web`, and keep every
|
||||
* browser-side artifact rebuilt on source edits, in one terminal.
|
||||
*
|
||||
* Three stages, because the compile shell links built lib products rather than
|
||||
* sources: `tsc -b tsconfig.client.json` emits `lib/types` (the tsdown lib
|
||||
* entries are that emit, not `src`), tsdown bundles `lib/index.js` and
|
||||
* `lib/client.js`, and `vite build` rewrites `apps/web/dist`, which `dsh web`
|
||||
* serves. A missing stage does not fail — it silently shows the previous
|
||||
* artifact, so an edit appears to do nothing.
|
||||
* Stages, in order: `pnpm run build` (skipped by `--skip-build`), three
|
||||
* long-lived watchers, then `dsh web` (skipped by `--no-serve`). The watchers
|
||||
* exist because the compile shell links built lib products rather than
|
||||
* sources: `tsc -b tsconfig.client.json --watch` emits `lib/types` (the tsdown
|
||||
* lib entries are that emit, not `src`), tsdown watch bundles `lib/index.js`
|
||||
* and `lib/client.js`, and `vite build --watch` rewrites `apps/web/dist`, which
|
||||
* `dsh web` serves. A missing watcher does not fail — it silently shows the
|
||||
* previous artifact, so an edit appears to do nothing; any stage exiting on its
|
||||
* own therefore stops the loop with exit code 1.
|
||||
*
|
||||
* MUST NOT run concurrently with `pnpm run build`: both write the same
|
||||
* `lib/` and `apps/web/dist/` trees.
|
||||
* Reload signaling is not this script's business — the host webserver
|
||||
* stat-polls the bundles it serves and broadcasts `rebuilt` frames itself, so
|
||||
* any process that rewrites `lib/client.js` files triggers reloads.
|
||||
*
|
||||
* Usage: `pnpm exec tsx scripts/dev-web.ts [--poll[=ms]]`. Requires one prior
|
||||
* `pnpm run build`: every stage is incremental over the previous stage's output
|
||||
* and none of them bootstraps a missing tree. `--poll` switches the source
|
||||
* watchers to polling (default 500ms): network mounts (weka) deliver no inotify
|
||||
* events, so native watching sees the initial build only and never a source
|
||||
* change. Polling has to reach tsc too — a native-watching tsc never re-emits
|
||||
* `lib/types`, which strands the other two stages on stale input.
|
||||
* MUST NOT run beside `pnpm run build`: both write the same `lib/` and
|
||||
* `apps/web/dist/` trees. The build stage here finishes before any watcher starts.
|
||||
*
|
||||
* Usage: `pnpm run dev:web [--skip-build] [--no-serve] [--poll[=ms]] [dsh web arguments]`.
|
||||
* `--skip-build` requires the artifact tree from a prior complete build: every
|
||||
* watcher is incremental over the previous stage's output and none of them
|
||||
* bootstraps a missing tree. `--no-serve` keeps only the watchers, for a
|
||||
* `dsh web` started elsewhere. `--poll` switches the source watchers to polling
|
||||
* (default 500ms): network mounts (weka) deliver no inotify events, so native
|
||||
* watching sees the initial build only and never a source change. Polling has
|
||||
* to reach tsc too — a native-watching tsc never re-emits `lib/types`, which
|
||||
* strands the other two stages on stale input.
|
||||
*
|
||||
* Shutdown: Ctrl+C reaches every stage through the terminal's process group, so
|
||||
* the script only waits for them and escalates survivors; SIGTERM is addressed
|
||||
* to this process alone and is forwarded once. The exit code is 130 after
|
||||
* SIGINT, 0 after SIGTERM, and 1 when a stage exited on its own.
|
||||
*
|
||||
* Each package keeps its own tsdown.config.ts untouched: this script layers
|
||||
* `watch` through API-level inline config (tsdown workspace mode fills inline
|
||||
@@ -39,6 +49,7 @@ import {
|
||||
clientBuildProcessEnvironment,
|
||||
repositoryClientBuildEnvironment,
|
||||
} from './client-build-environment.ts'
|
||||
import { pnpmInvocation } from './pnpm-invocation.ts'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
|
||||
@@ -67,6 +78,51 @@ export function devWebBuildEnvironment(
|
||||
return clientBuildProcessEnvironment(environment, repositoryClientBuildEnvironment(root, environment))
|
||||
}
|
||||
|
||||
/** Resolved `dev-web` command line: the script's own flags plus the arguments forwarded to `dsh web`. */
|
||||
export interface DevWebArguments {
|
||||
/** Skip the complete `pnpm run build` that otherwise precedes the watchers. */
|
||||
readonly skipBuild: boolean
|
||||
/** Start `dsh web`; false keeps only the rebuild watchers beside an already running server. */
|
||||
readonly serve: boolean
|
||||
/** Source-watcher polling interval in milliseconds; undefined selects native watching. */
|
||||
readonly pollInterval: number | undefined
|
||||
/** Arguments forwarded verbatim to `dsh web`, in order. */
|
||||
readonly appArgs: readonly string[]
|
||||
}
|
||||
|
||||
/** Polling interval selected by a bare `--poll`. */
|
||||
const DEFAULT_POLL_INTERVAL = 500
|
||||
|
||||
/**
|
||||
* Parse the script's command line. `--skip-build`, `--no-serve`, and `--poll[=ms]`
|
||||
* belong to this script; a bare `--` is dropped; every other token is forwarded
|
||||
* to `dsh web`.
|
||||
* @param argv - arguments after the script path.
|
||||
* @returns the resolved flags and forwarded arguments.
|
||||
* @throws Error when `--poll` carries a non-positive or non-integer interval, or
|
||||
* when `--no-serve` leaves forwarded arguments without a `dsh web` process.
|
||||
*/
|
||||
export function parseDevWebArguments(argv: readonly string[]): DevWebArguments {
|
||||
let skipBuild = false
|
||||
let serve = true
|
||||
let pollInterval: number | undefined
|
||||
const appArgs: string[] = []
|
||||
for (const arg of argv) {
|
||||
// `pnpm run` forwards a `--` separator verbatim; it carries no meaning here.
|
||||
if (arg === '--') continue
|
||||
if (arg === '--skip-build') skipBuild = true
|
||||
else if (arg === '--no-serve') serve = false
|
||||
else if (arg === '--poll' || arg.startsWith('--poll=')) {
|
||||
pollInterval = arg === '--poll' ? DEFAULT_POLL_INTERVAL : Number(arg.slice('--poll='.length))
|
||||
if (!Number.isInteger(pollInterval) || pollInterval <= 0) throw new Error(`dev-web: invalid --poll interval "${arg}"`)
|
||||
} else appArgs.push(arg)
|
||||
}
|
||||
if (!serve && appArgs.length > 0) {
|
||||
throw new Error(`dev-web: --no-serve leaves no dsh web process for ${appArgs[0] ?? ''}`)
|
||||
}
|
||||
return { skipBuild, serve, pollInterval, appArgs }
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover the watch workspace by declaration: every packages/<group>/<name>
|
||||
* whose package.json carries `dsh.client` with platform "web" is a client
|
||||
@@ -153,46 +209,166 @@ export async function watchClientPlugins(
|
||||
return bundles
|
||||
}
|
||||
|
||||
/**
|
||||
* Live watcher processes to terminate when this script is interrupted. Stages
|
||||
* register themselves as they start, so the set is complete from the first
|
||||
* spawn: an interrupt during a later stage's startup still tears down the
|
||||
* earlier ones instead of orphaning them.
|
||||
*/
|
||||
const stages: StageHandle[] = []
|
||||
/** One long-lived stage process as the supervisor observes it. */
|
||||
export interface StageHandle {
|
||||
/** Command label used in diagnostics. */
|
||||
readonly name: string
|
||||
/** Settles with the exit code, or null when a signal ended the stage. */
|
||||
readonly exited: Promise<number | null>
|
||||
/**
|
||||
* Deliver one signal; a no-op once the stage has exited.
|
||||
* @param signal - the signal to send.
|
||||
*/
|
||||
kill(signal: NodeJS.Signals): void
|
||||
}
|
||||
|
||||
/** How a requested stop reaches the stages. */
|
||||
export interface StageStop {
|
||||
/** Signal sent to every live stage first; omitted when the terminal already delivered SIGINT to them. */
|
||||
readonly signal?: NodeJS.Signals | undefined
|
||||
/** Milliseconds to wait for exits before escalating to SIGTERM, and again before SIGKILL. */
|
||||
readonly graceMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn one watcher stage, inheriting stdio, registering it for teardown, and
|
||||
* failing loud if it ever exits: a dead stage leaves the artifact chain silently
|
||||
* stale, which reads as "my edit did nothing" — the one failure this script
|
||||
* exists to prevent.
|
||||
* @param stage - command label used in the exit diagnostic.
|
||||
* @param command - executable, resolved from the workspace bin when local.
|
||||
* Own the live stages: report a stage that exits before a stop was requested,
|
||||
* and on a requested stop signal, wait, and escalate until every stage has exited.
|
||||
*/
|
||||
export class StageSupervisor {
|
||||
private readonly live = new Set<StageHandle>()
|
||||
private stopping = false
|
||||
|
||||
/**
|
||||
* @param onStale - receives the stage name and exit code when a stage exits before a stop was requested.
|
||||
*/
|
||||
constructor(private readonly onStale: (stage: string, code: number | null) => void) {}
|
||||
|
||||
/**
|
||||
* Track one started stage until it exits.
|
||||
* @param stage - the stage to supervise.
|
||||
*/
|
||||
add(stage: StageHandle): void {
|
||||
this.live.add(stage)
|
||||
void stage.exited.then((code) => {
|
||||
this.live.delete(stage)
|
||||
if (!this.stopping) this.onStale(stage.name, code)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop every live stage and resolve once all of them have exited.
|
||||
* @param stop - initial signal and the grace period between escalation steps.
|
||||
*/
|
||||
async stop(stop: StageStop): Promise<void> {
|
||||
this.stopping = true
|
||||
const escalation: (NodeJS.Signals | undefined)[] = [stop.signal]
|
||||
if (stop.signal !== 'SIGTERM') escalation.push('SIGTERM')
|
||||
for (const signal of escalation) {
|
||||
if (this.live.size === 0) return
|
||||
if (signal !== undefined) for (const stage of this.live) stage.kill(signal)
|
||||
await this.settled(stop.graceMs)
|
||||
}
|
||||
for (const stage of this.live) stage.kill('SIGKILL')
|
||||
await Promise.all([...this.live].map(stage => stage.exited))
|
||||
}
|
||||
|
||||
private async settled(graceMs: number): Promise<void> {
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
await Promise.race([
|
||||
Promise.all([...this.live].map(stage => stage.exited)),
|
||||
new Promise<void>((resolve) => { timer = setTimeout(resolve, graceMs) }),
|
||||
])
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grace period per escalation step. `dsh web` gives its plugin tree five seconds
|
||||
* to dispose after the first signal; escalating earlier would turn the graceful
|
||||
* drain into a forced exit.
|
||||
*/
|
||||
const STOP_GRACE_MS = 6_000
|
||||
|
||||
/**
|
||||
* Spawn one long-lived stage with inherited stdio and hand it to the supervisor.
|
||||
* @param supervisor - owner that reports an unexpected exit and stops the stage on shutdown.
|
||||
* @param name - command label used in diagnostics.
|
||||
* @param command - executable, resolved from the workspace bins when `local` is set.
|
||||
* @param args - command arguments.
|
||||
* @param local - whether to resolve `command` from the workspace's installed bins.
|
||||
*/
|
||||
function spawnStage(stage: string, command: string, args: readonly string[], local: boolean): void {
|
||||
function spawnStage(
|
||||
supervisor: StageSupervisor,
|
||||
name: string,
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
local: boolean,
|
||||
): void {
|
||||
const child = execa(command, [...args], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
preferLocal: local,
|
||||
reject: false,
|
||||
})
|
||||
stages.push({ kill: () => { child.kill() } })
|
||||
void child.then((result) => {
|
||||
console.error(`dev-web: ${stage} exited (code ${String(result.exitCode)}); the artifact chain is now stale`)
|
||||
process.exit(1)
|
||||
supervisor.add({
|
||||
name,
|
||||
exited: child.then(result => result.exitCode ?? null),
|
||||
kill(signal) { child.kill(signal) },
|
||||
})
|
||||
}
|
||||
|
||||
/** The only capability this script needs from a live watcher process. */
|
||||
interface StageHandle {
|
||||
readonly kill: () => void
|
||||
/**
|
||||
* Run the complete repository build with the launch environment untouched:
|
||||
* `scripts/build.ts` samples the public client values itself.
|
||||
* @returns the build's exit code, or null when a signal ended it.
|
||||
*/
|
||||
async function runBuild(): Promise<number | null> {
|
||||
const invocation = pnpmInvocation(['run', 'build'])
|
||||
const result = await execa(invocation.command, invocation.args, { cwd: repoRoot, stdio: 'inherit', reject: false })
|
||||
return result.exitCode ?? null
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1]
|
||||
const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href
|
||||
if (isMain) {
|
||||
let options: DevWebArguments
|
||||
try {
|
||||
options = parseDevWebArguments(process.argv.slice(2))
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
console.error('dev-web: usage: pnpm run dev:web [--skip-build] [--no-serve] [--poll[=ms]] [dsh web arguments]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Shutdown is requested once, by a terminal signal or by a stage exiting on
|
||||
// its own; `signal` stays unset for SIGINT because the terminal's process
|
||||
// group already delivered it to every stage.
|
||||
const shutdown = Promise.withResolvers<number>()
|
||||
const requested: { code?: number; signal?: NodeJS.Signals } = {}
|
||||
const shutdownRequested = (): boolean => requested.code !== undefined
|
||||
const requestShutdown = (code: number, signal: NodeJS.Signals | undefined): void => {
|
||||
if (shutdownRequested()) return
|
||||
requested.code = code
|
||||
if (signal !== undefined) requested.signal = signal
|
||||
shutdown.resolve(code)
|
||||
}
|
||||
const supervisor = new StageSupervisor((stage, code) => {
|
||||
console.error(`dev-web: ${stage} exited (code ${String(code)}); stopping the other stages`)
|
||||
requestShutdown(1, 'SIGTERM')
|
||||
})
|
||||
// Persistent listeners, not `once`: the tsdown watchers bundle signal-exit,
|
||||
// which re-raises a signal whenever it finds no other listener left and would
|
||||
// terminate this process in the middle of the teardown below.
|
||||
process.on('SIGINT', () => { requestShutdown(130, undefined) })
|
||||
process.on('SIGTERM', () => { requestShutdown(0, 'SIGTERM') })
|
||||
|
||||
const buildExit = options.skipBuild ? 0 : await runBuild()
|
||||
if (shutdownRequested()) process.exit(await shutdown.promise)
|
||||
if (buildExit !== 0) {
|
||||
console.error(`dev-web: pnpm run build exited (code ${String(buildExit)})`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const buildEnvironment = devWebBuildEnvironment(repoRoot, process.env)
|
||||
for (const name of Object.keys(process.env)) {
|
||||
if (name === CLIENT_BUILD_PROFILE_SELECTOR || name.startsWith('DSH_CLIENT_')) {
|
||||
@@ -214,32 +390,14 @@ if (isMain) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll='))
|
||||
if (args.some(a => a !== pollArg)) {
|
||||
console.error('dev-web: usage: tsx scripts/dev-web.ts [--poll[=ms]]')
|
||||
process.exit(1)
|
||||
}
|
||||
const pollInterval = pollArg === undefined ? undefined : Number(pollArg.split('=')[1] ?? '500')
|
||||
if (pollInterval !== undefined && (!Number.isInteger(pollInterval) || pollInterval <= 0)) {
|
||||
console.error(`dev-web: invalid --poll interval "${pollArg ?? ''}"`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Registered before any stage starts: `stages` is read at signal time, so an
|
||||
// interrupt during tsdown's initial builds still kills whatever is running.
|
||||
const stop = (): void => { for (const stage of stages) stage.kill() }
|
||||
process.once('SIGINT', stop)
|
||||
process.once('SIGTERM', stop)
|
||||
|
||||
// tsc has no polling interval flag, so `--poll` selects its fixed-interval
|
||||
// watchers rather than an interval. Dropping that translation leaves tsc
|
||||
// natively watching on a network mount where inotify never fires: it stops
|
||||
// re-emitting lib/types, and the two later stages then rebuild forever from
|
||||
// stale input without printing anything.
|
||||
spawnStage(`tsc -b ${CLIENT_TYPE_PROGRAM} --watch`, 'tsc', [
|
||||
spawnStage(supervisor, `tsc -b ${CLIENT_TYPE_PROGRAM} --watch`, 'tsc', [
|
||||
'-b', CLIENT_TYPE_PROGRAM, '--watch', '--preserveWatchOutput',
|
||||
...pollInterval !== undefined
|
||||
...options.pollInterval !== undefined
|
||||
? ['--watchFile', 'fixedPollingInterval', '--watchDirectory', 'fixedPollingInterval']
|
||||
: [],
|
||||
], true)
|
||||
@@ -248,19 +406,34 @@ if (isMain) {
|
||||
// first build reads current lib bundles rather than whatever the last full
|
||||
// build left. Its own watch then covers later lib rewrites — those files are
|
||||
// in its module graph.
|
||||
await watchClientPlugins(repoRoot, [...pluginDirs, ...libraryDirs], pollInterval)
|
||||
// Through the shell's own `watch` script rather than vite's API: vite is not a
|
||||
// repository-root dependency, and more importantly the vite root is its
|
||||
// working directory — `resolve.dedupe` resolves react from that root, so
|
||||
// running vite from anywhere but apps/web silently switches which react copy
|
||||
// the bundle gets.
|
||||
spawnStage('vite build --watch', 'pnpm', ['--filter', SHELL_PACKAGE, 'run', 'watch'], false)
|
||||
const bundles = await watchClientPlugins(repoRoot, [...pluginDirs, ...libraryDirs], options.pollInterval)
|
||||
if (!shutdownRequested()) {
|
||||
// Through the shell's own `watch` script rather than vite's API: vite is not a
|
||||
// repository-root dependency, and more importantly the vite root is its
|
||||
// working directory — `resolve.dedupe` resolves react from that root, so
|
||||
// running vite from anywhere but apps/web silently switches which react copy
|
||||
// the bundle gets.
|
||||
spawnStage(supervisor, 'vite build --watch', 'pnpm', ['--filter', SHELL_PACKAGE, 'run', 'watch'], false)
|
||||
// The same launch vector as the root `dsh` script, so the served Host runs
|
||||
// from source exactly as `pnpm dsh web` would.
|
||||
if (options.serve) {
|
||||
spawnStage(supervisor, 'dsh web', process.execPath, [
|
||||
'--import', 'tsx/esm', 'apps/cli/src/bin.ts', 'web', ...options.appArgs,
|
||||
], false)
|
||||
}
|
||||
console.log(
|
||||
`dev-web: watching ${String(pluginDirs.length)} dsh.client plugin packages`
|
||||
+ ` and ${String(libraryDirs.length)} statically linked library packages`
|
||||
+ (options.pollInterval !== undefined ? ` (polling ${String(options.pollInterval)}ms)` : '')
|
||||
+ `, plus tsc -b ${CLIENT_TYPE_PROGRAM} and the ${SHELL_PACKAGE} dist build`
|
||||
+ (options.serve ? ', serving through dsh web' : '')
|
||||
+ ':\n '
|
||||
+ [...pluginDirs, ...libraryDirs].join('\n '),
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`dev-web: watching ${String(pluginDirs.length)} dsh.client plugin packages`
|
||||
+ ` and ${String(libraryDirs.length)} statically linked library packages`
|
||||
+ (pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : '')
|
||||
+ `, plus tsc -b ${CLIENT_TYPE_PROGRAM} and the ${SHELL_PACKAGE} dist build:\n `
|
||||
+ [...pluginDirs, ...libraryDirs].join('\n '),
|
||||
)
|
||||
const exitCode = await shutdown.promise
|
||||
await supervisor.stop({ signal: requested.signal, graceMs: STOP_GRACE_MS })
|
||||
for (const bundle of bundles) await bundle[Symbol.asyncDispose]()
|
||||
process.exit(exitCode)
|
||||
}
|
||||
|
||||
@@ -99,8 +99,28 @@ describe('application entrypoints', () => {
|
||||
write(root, 'scripts/demo-ptc.mjs', "spawn('node', ['packages/example/app/src/bin.ts'])\n")
|
||||
|
||||
expect(applicationEntrypointViolations(root)).toEqual([
|
||||
'scripts/demo-ptc.mjs: application demo wrapper must launch apps/cli/src/bin.ts',
|
||||
'scripts/demo-ptc.mjs: application demo wrapper must not launch a package entry directly',
|
||||
'scripts/demo-ptc.mjs: application launcher wrapper must launch apps/cli/src/bin.ts',
|
||||
'scripts/demo-ptc.mjs: application launcher wrapper must not launch a package entry directly',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a start:web script that bypasses the dsh launcher', () => {
|
||||
const root = fixture()
|
||||
write(root, 'package.json', JSON.stringify({ scripts: { 'start:web': 'node packages/example/app/src/bin.ts web' } }))
|
||||
|
||||
expect(applicationEntrypointViolations(root)).toEqual([
|
||||
'package.json scripts.start:web: application launcher script must launch apps/cli/src/bin.ts',
|
||||
'package.json scripts.start:web: application launcher script must not launch a package entry directly',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a dev:web wrapper that serves without the dsh launcher', () => {
|
||||
const root = fixture()
|
||||
write(root, 'package.json', JSON.stringify({ scripts: { 'dev:web': 'tsx scripts/dev-web.ts --poll' } }))
|
||||
write(root, 'scripts/dev-web.ts', "execa('vite', ['build', '--watch'])\n")
|
||||
|
||||
expect(applicationEntrypointViolations(root)).toEqual([
|
||||
'scripts/dev-web.ts: application launcher wrapper must launch apps/cli/src/bin.ts',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ interface RootManifest {
|
||||
readonly scripts?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface DemoPolicy {
|
||||
interface LauncherPolicy {
|
||||
readonly kind: 'dsh-direct' | 'dsh-wrapper'
|
||||
readonly wrapper?: string
|
||||
}
|
||||
@@ -48,10 +48,16 @@ const EXECUTABLE_SOURCE_ALLOWLIST = new Map<string, string>([
|
||||
['python/sdk-runtime/runtime-bootstrap.mjs', 'private packaging-only runtime dispatcher'],
|
||||
])
|
||||
|
||||
/** Root demos are application wrappers and therefore must visibly select dsh. */
|
||||
const ROOT_DEMO_POLICIES = new Map<string, DemoPolicy>([
|
||||
/**
|
||||
* Root scripts that start an application must visibly select dsh: every `demo:*`
|
||||
* script needs a row here, and the Web start and development scripts are pinned
|
||||
* by name.
|
||||
*/
|
||||
const ROOT_LAUNCHER_POLICIES = new Map<string, LauncherPolicy>([
|
||||
['demo:ptc', { kind: 'dsh-wrapper', wrapper: 'scripts/demo-ptc.mjs' }],
|
||||
['demo:inspector', { kind: 'dsh-direct' }],
|
||||
['start:web', { kind: 'dsh-direct' }],
|
||||
['dev:web', { kind: 'dsh-wrapper', wrapper: 'scripts/dev-web.ts' }],
|
||||
])
|
||||
|
||||
const SOURCE_PATTERNS = [
|
||||
@@ -131,22 +137,23 @@ function referencesPackageEntry(source: string): boolean {
|
||||
return /packages\/[^/\s'"`]+\/[^/\s'"`]+\/(?:src|lib)\/[^\s'"`]+/.test(source)
|
||||
}
|
||||
|
||||
function rootDemoViolations(root: string): string[] {
|
||||
function rootLauncherViolations(root: string): string[] {
|
||||
const manifestPath = resolve(root, 'package.json')
|
||||
if (!existsSync(manifestPath)) return []
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as RootManifest
|
||||
const failures: string[] = []
|
||||
for (const [name, commandValue] of Object.entries(manifest.scripts ?? {}).sort(([left], [right]) => left.localeCompare(right))) {
|
||||
if (!name.startsWith('demo:')) continue
|
||||
const command = typeof commandValue === 'string' ? commandValue : ''
|
||||
const policy = ROOT_DEMO_POLICIES.get(name)
|
||||
const policy = ROOT_LAUNCHER_POLICIES.get(name)
|
||||
if (policy === undefined) {
|
||||
failures.push(`package.json scripts.${name}: demo launcher has no explicit dsh or in-process classification`)
|
||||
if (name.startsWith('demo:')) {
|
||||
failures.push(`package.json scripts.${name}: demo launcher has no explicit dsh or in-process classification`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const command = typeof commandValue === 'string' ? commandValue : ''
|
||||
if (policy.kind === 'dsh-direct') {
|
||||
if (!referencesDshCli(command)) failures.push(`package.json scripts.${name}: application demo must launch apps/cli/src/bin.ts`)
|
||||
if (referencesPackageEntry(command)) failures.push(`package.json scripts.${name}: application demo must not launch a package entry directly`)
|
||||
if (!referencesDshCli(command)) failures.push(`package.json scripts.${name}: application launcher script must launch apps/cli/src/bin.ts`)
|
||||
if (referencesPackageEntry(command)) failures.push(`package.json scripts.${name}: application launcher script must not launch a package entry directly`)
|
||||
continue
|
||||
}
|
||||
const wrapper = policy.wrapper
|
||||
@@ -156,12 +163,12 @@ function rootDemoViolations(root: string): string[] {
|
||||
}
|
||||
const wrapperPath = resolve(root, wrapper)
|
||||
if (!existsSync(wrapperPath)) {
|
||||
failures.push(`${wrapper}: classified demo wrapper is missing`)
|
||||
failures.push(`${wrapper}: classified launcher wrapper is missing`)
|
||||
continue
|
||||
}
|
||||
const source = readFileSync(wrapperPath, 'utf8')
|
||||
if (!referencesDshCli(source)) failures.push(`${wrapper}: application demo wrapper must launch apps/cli/src/bin.ts`)
|
||||
if (referencesPackageEntry(source)) failures.push(`${wrapper}: application demo wrapper must not launch a package entry directly`)
|
||||
if (!referencesDshCli(source)) failures.push(`${wrapper}: application launcher wrapper must launch apps/cli/src/bin.ts`)
|
||||
if (referencesPackageEntry(source)) failures.push(`${wrapper}: application launcher wrapper must not launch a package entry directly`)
|
||||
}
|
||||
return failures
|
||||
}
|
||||
@@ -175,7 +182,7 @@ export function applicationEntrypointViolations(root: string): string[] {
|
||||
return [
|
||||
...manifestBinViolations(root),
|
||||
...executableSourceViolations(root),
|
||||
...rootDemoViolations(root),
|
||||
...rootLauncherViolations(root),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user