dev-web.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. /**
  2. * Watch-build for the web dev loop: rebuilds every artifact the browser reads
  3. * from a source edit. Reload signaling is not this script's business — the host
  4. * webserver stat-polls the bundles it serves and broadcasts `rebuilt` frames
  5. * itself (`dsh web`), so any process that rewrites `lib/client.js` files
  6. * triggers reloads; this script is merely the convenient way to keep them all
  7. * rebuilt on source change.
  8. *
  9. * Three stages, because the compile shell links built lib products rather than
  10. * sources: `tsc -b tsconfig.client.json` emits `lib/types` (the tsdown lib
  11. * entries are that emit, not `src`), tsdown bundles `lib/index.js` and
  12. * `lib/client.js`, and `vite build` rewrites `apps/web/dist`, which `dsh web`
  13. * serves. A missing stage does not fail — it silently shows the previous
  14. * artifact, so an edit appears to do nothing.
  15. *
  16. * MUST NOT run concurrently with `pnpm run build`: both write the same
  17. * `lib/` and `apps/web/dist/` trees.
  18. *
  19. * Usage: `pnpm exec tsx scripts/dev-web.ts [--poll[=ms]]`. Requires one prior
  20. * `pnpm run build`: every stage is incremental over the previous stage's output
  21. * and none of them bootstraps a missing tree. `--poll` switches the source
  22. * watchers to polling (default 500ms): network mounts (weka) deliver no inotify
  23. * events, so native watching sees the initial build only and never a source
  24. * change. Polling has to reach tsc too — a native-watching tsc never re-emits
  25. * `lib/types`, which strands the other two stages on stale input.
  26. *
  27. * Each package keeps its own tsdown.config.ts untouched: this script layers
  28. * `watch` through API-level inline config (tsdown workspace mode fills inline
  29. * keys under each package's file config, and no package config defines it).
  30. */
  31. import { globSync, readFileSync } from 'node:fs'
  32. import { dirname, join, resolve, sep } from 'node:path'
  33. import { fileURLToPath, pathToFileURL } from 'node:url'
  34. import { execa } from 'execa'
  35. import { build } from 'tsdown'
  36. import type { TsdownBundle } from 'tsdown'
  37. import {
  38. CLIENT_BUILD_PROFILE_SELECTOR,
  39. clientBuildProcessEnvironment,
  40. repositoryClientBuildEnvironment,
  41. } from './client-build-environment.ts'
  42. const repoRoot = fileURLToPath(new URL('..', import.meta.url))
  43. /** Client-face type emit feeding every tsdown lib entry in the watch set. */
  44. const CLIENT_TYPE_PROGRAM = 'tsconfig.client.json'
  45. /** Compile-shell workspace whose dist `dsh web` serves. */
  46. const SHELL_PACKAGE = '@deepseek-ai/dsh-web-frontend'
  47. /**
  48. * Test infrastructure builds through the client preset but never enters the
  49. * shell's module graph, so it is not a dev-loop artifact.
  50. */
  51. const TEST_INFRASTRUCTURE_PREFIX = 'packages/test-support/'
  52. /**
  53. * Sample one local public environment for every long-lived watcher stage.
  54. * @param root - repository root supplying version and Git metadata.
  55. * @param environment - watcher launch environment supplying public extensions.
  56. * @returns process environment shared by tsdown and spawned watcher stages.
  57. */
  58. export function devWebBuildEnvironment(
  59. root: string,
  60. environment: NodeJS.ProcessEnv = process.env,
  61. ): NodeJS.ProcessEnv {
  62. return clientBuildProcessEnvironment(environment, repositoryClientBuildEnvironment(root, environment))
  63. }
  64. /**
  65. * Discover the watch workspace by declaration: every packages/<group>/<name>
  66. * whose package.json carries `dsh.client` with platform "web" is a client
  67. * plugin bundle emitter. Scanned once at startup — a package added while
  68. * watching means restarting this script.
  69. * @param root - repository root containing the grouped package directories.
  70. * @returns workspace-relative plugin package directories.
  71. */
  72. export function discoverPluginDirs(root = repoRoot): string[] {
  73. const dirs: string[] = []
  74. for (const manifestPath of globSync('packages/*/*/package.json', { cwd: root }).sort()) {
  75. const manifest = JSON.parse(readFileSync(join(root, manifestPath), 'utf8')) as {
  76. dsh?: { client?: { platform?: unknown } }
  77. }
  78. if (manifest.dsh?.client?.platform === 'web') dirs.push(dirname(manifestPath).split(sep).join('/'))
  79. }
  80. return dirs
  81. }
  82. /**
  83. * Discover the statically linked library packages: the other half of the same
  84. * partition {@link discoverPluginDirs} takes. A package that builds through the
  85. * client preset without declaring `dsh.client` has no loader-delivered browser
  86. * half, so the compile shell links its `lib/index.js` instead — and an edit to
  87. * its source reaches the browser only once that bundle is rewritten. Deriving
  88. * the set from the build preset rather than a hand list keeps it correct when
  89. * dependency sections move around; deriving it from `dependencies` would not,
  90. * because client packages declare their build inputs as devDependencies.
  91. * @param root - repository root containing the grouped package directories.
  92. * @returns workspace-relative library package directories.
  93. */
  94. export function discoverLibraryDirs(root = repoRoot): string[] {
  95. const dirs: string[] = []
  96. for (const configPath of globSync('packages/*/*/tsdown.config.ts', { cwd: root }).sort()) {
  97. const dir = dirname(configPath).split(sep).join('/')
  98. if (dir.startsWith(TEST_INFRASTRUCTURE_PREFIX)) continue
  99. if (!readFileSync(join(root, configPath), 'utf8').includes('tsdown.client.ts')) continue
  100. const manifest = JSON.parse(readFileSync(join(root, dir, 'package.json'), 'utf8')) as {
  101. dsh?: { client?: unknown }
  102. }
  103. if (manifest.dsh?.client === undefined) dirs.push(dir)
  104. }
  105. return dirs
  106. }
  107. /**
  108. * Start the tsdown watch build used by `pnpm run dev:web`.
  109. * @param root - repository or fixture root passed to tsdown.
  110. * @param pluginDirs - workspace-relative package directories to watch.
  111. * @param pollInterval - optional source-watcher polling interval in milliseconds.
  112. * @returns live bundles after every watcher has completed its initial build.
  113. */
  114. export async function watchClientPlugins(
  115. root: string,
  116. pluginDirs: readonly string[],
  117. pollInterval?: number,
  118. ): Promise<TsdownBundle[]> {
  119. let resolveInitialBuilds: (() => void) | undefined
  120. const initialBuilds = new Promise<void>((resolve) => { resolveInitialBuilds = resolve })
  121. const initialized = new WeakSet<object>()
  122. const readiness: { expectedBuilds?: number; initializedBuilds: number } = { initializedBuilds: 0 }
  123. const bundles = await build({
  124. cwd: root,
  125. workspace: [...pluginDirs],
  126. watch: true,
  127. hooks: {
  128. 'build:done': ({ options }) => {
  129. if (initialized.has(options)) return
  130. initialized.add(options)
  131. readiness.initializedBuilds += 1
  132. if (
  133. readiness.expectedBuilds !== undefined
  134. && readiness.initializedBuilds >= readiness.expectedBuilds
  135. ) resolveInitialBuilds?.()
  136. },
  137. },
  138. ...pollInterval !== undefined
  139. ? { inputOptions: { watch: { watcher: { usePolling: true, pollInterval } } } }
  140. : {},
  141. })
  142. readiness.expectedBuilds = bundles.length
  143. if (readiness.initializedBuilds >= readiness.expectedBuilds) resolveInitialBuilds?.()
  144. await initialBuilds
  145. return bundles
  146. }
  147. /**
  148. * Live watcher processes to terminate when this script is interrupted. Stages
  149. * register themselves as they start, so the set is complete from the first
  150. * spawn: an interrupt during a later stage's startup still tears down the
  151. * earlier ones instead of orphaning them.
  152. */
  153. const stages: StageHandle[] = []
  154. /**
  155. * Spawn one watcher stage, inheriting stdio, registering it for teardown, and
  156. * failing loud if it ever exits: a dead stage leaves the artifact chain silently
  157. * stale, which reads as "my edit did nothing" — the one failure this script
  158. * exists to prevent.
  159. * @param stage - command label used in the exit diagnostic.
  160. * @param command - executable, resolved from the workspace bin when local.
  161. * @param args - command arguments.
  162. * @param local - whether to resolve `command` from the workspace's installed bins.
  163. */
  164. function spawnStage(stage: string, command: string, args: readonly string[], local: boolean): void {
  165. const child = execa(command, [...args], {
  166. cwd: repoRoot,
  167. stdio: 'inherit',
  168. preferLocal: local,
  169. reject: false,
  170. })
  171. stages.push({ kill: () => { child.kill() } })
  172. void child.then((result) => {
  173. console.error(`dev-web: ${stage} exited (code ${String(result.exitCode)}); the artifact chain is now stale`)
  174. process.exit(1)
  175. })
  176. }
  177. /** The only capability this script needs from a live watcher process. */
  178. interface StageHandle {
  179. readonly kill: () => void
  180. }
  181. const invokedPath = process.argv[1]
  182. const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href
  183. if (isMain) {
  184. const buildEnvironment = devWebBuildEnvironment(repoRoot, process.env)
  185. for (const name of Object.keys(process.env)) {
  186. if (name === CLIENT_BUILD_PROFILE_SELECTOR || name.startsWith('DSH_CLIENT_')) {
  187. Reflect.deleteProperty(process.env, name)
  188. }
  189. }
  190. for (const [name, value] of Object.entries(buildEnvironment)) {
  191. if (name.startsWith('DSH_CLIENT_') && value !== undefined) process.env[name] = value
  192. }
  193. const pluginDirs = discoverPluginDirs()
  194. const libraryDirs = discoverLibraryDirs()
  195. if (pluginDirs.length === 0) {
  196. console.error('dev-web: no dsh.client (platform "web") packages found under packages/')
  197. process.exit(1)
  198. }
  199. if (libraryDirs.length === 0) {
  200. console.error('dev-web: no client-preset library packages found under packages/ — the compile shell links their lib products, so an empty set means the discovery predicate is stale')
  201. process.exit(1)
  202. }
  203. const args = process.argv.slice(2)
  204. const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll='))
  205. if (args.some(a => a !== pollArg)) {
  206. console.error('dev-web: usage: tsx scripts/dev-web.ts [--poll[=ms]]')
  207. process.exit(1)
  208. }
  209. const pollInterval = pollArg === undefined ? undefined : Number(pollArg.split('=')[1] ?? '500')
  210. if (pollInterval !== undefined && (!Number.isInteger(pollInterval) || pollInterval <= 0)) {
  211. console.error(`dev-web: invalid --poll interval "${pollArg ?? ''}"`)
  212. process.exit(1)
  213. }
  214. // Registered before any stage starts: `stages` is read at signal time, so an
  215. // interrupt during tsdown's initial builds still kills whatever is running.
  216. const stop = (): void => { for (const stage of stages) stage.kill() }
  217. process.once('SIGINT', stop)
  218. process.once('SIGTERM', stop)
  219. // tsc has no polling interval flag, so `--poll` selects its fixed-interval
  220. // watchers rather than an interval. Dropping that translation leaves tsc
  221. // natively watching on a network mount where inotify never fires: it stops
  222. // re-emitting lib/types, and the two later stages then rebuild forever from
  223. // stale input without printing anything.
  224. spawnStage(`tsc -b ${CLIENT_TYPE_PROGRAM} --watch`, 'tsc', [
  225. '-b', CLIENT_TYPE_PROGRAM, '--watch', '--preserveWatchOutput',
  226. ...pollInterval !== undefined
  227. ? ['--watchFile', 'fixedPollingInterval', '--watchDirectory', 'fixedPollingInterval']
  228. : [],
  229. ], true)
  230. // tsdown's initial builds are awaited before the dist watcher starts so vite's
  231. // first build reads current lib bundles rather than whatever the last full
  232. // build left. Its own watch then covers later lib rewrites — those files are
  233. // in its module graph.
  234. await watchClientPlugins(repoRoot, [...pluginDirs, ...libraryDirs], pollInterval)
  235. // Through the shell's own `watch` script rather than vite's API: vite is not a
  236. // repository-root dependency, and more importantly the vite root is its
  237. // working directory — `resolve.dedupe` resolves react from that root, so
  238. // running vite from anywhere but apps/web silently switches which react copy
  239. // the bundle gets.
  240. spawnStage('vite build --watch', 'pnpm', ['--filter', SHELL_PACKAGE, 'run', 'watch'], false)
  241. console.log(
  242. `dev-web: watching ${String(pluginDirs.length)} dsh.client plugin packages`
  243. + ` and ${String(libraryDirs.length)} statically linked library packages`
  244. + (pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : '')
  245. + `, plus tsc -b ${CLIENT_TYPE_PROGRAM} and the ${SHELL_PACKAGE} dist build:\n `
  246. + [...pluginDirs, ...libraryDirs].join('\n '),
  247. )
  248. }