dev-web.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  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. const repoRoot = fileURLToPath(new URL('..', import.meta.url))
  38. /** Client-face type emit feeding every tsdown lib entry in the watch set. */
  39. const CLIENT_TYPE_PROGRAM = 'tsconfig.client.json'
  40. /** Compile-shell workspace whose dist `dsh web` serves. */
  41. const SHELL_PACKAGE = '@deepseek-ai/dsh-web-frontend'
  42. /**
  43. * Test infrastructure builds through the client preset but never enters the
  44. * shell's module graph, so it is not a dev-loop artifact.
  45. */
  46. const TEST_INFRASTRUCTURE_PREFIX = 'packages/test-support/'
  47. /**
  48. * Discover the watch workspace by declaration: every packages/<group>/<name>
  49. * whose package.json carries `dsh.client` with platform "web" is a client
  50. * plugin bundle emitter. Scanned once at startup — a package added while
  51. * watching means restarting this script.
  52. * @param root - repository root containing the grouped package directories.
  53. * @returns workspace-relative plugin package directories.
  54. */
  55. export function discoverPluginDirs(root = repoRoot): string[] {
  56. const dirs: string[] = []
  57. for (const manifestPath of globSync('packages/*/*/package.json', { cwd: root }).sort()) {
  58. const manifest = JSON.parse(readFileSync(join(root, manifestPath), 'utf8')) as {
  59. dsh?: { client?: { platform?: unknown } }
  60. }
  61. if (manifest.dsh?.client?.platform === 'web') dirs.push(dirname(manifestPath).split(sep).join('/'))
  62. }
  63. return dirs
  64. }
  65. /**
  66. * Discover the statically linked library packages: the other half of the same
  67. * partition {@link discoverPluginDirs} takes. A package that builds through the
  68. * client preset without declaring `dsh.client` has no loader-delivered browser
  69. * half, so the compile shell links its `lib/index.js` instead — and an edit to
  70. * its source reaches the browser only once that bundle is rewritten. Deriving
  71. * the set from the build preset rather than a hand list keeps it correct when
  72. * dependency sections move around; deriving it from `dependencies` would not,
  73. * because client packages declare their build inputs as devDependencies.
  74. * @param root - repository root containing the grouped package directories.
  75. * @returns workspace-relative library package directories.
  76. */
  77. export function discoverLibraryDirs(root = repoRoot): string[] {
  78. const dirs: string[] = []
  79. for (const configPath of globSync('packages/*/*/tsdown.config.ts', { cwd: root }).sort()) {
  80. const dir = dirname(configPath).split(sep).join('/')
  81. if (dir.startsWith(TEST_INFRASTRUCTURE_PREFIX)) continue
  82. if (!readFileSync(join(root, configPath), 'utf8').includes('tsdown.client.ts')) continue
  83. const manifest = JSON.parse(readFileSync(join(root, dir, 'package.json'), 'utf8')) as {
  84. dsh?: { client?: unknown }
  85. }
  86. if (manifest.dsh?.client === undefined) dirs.push(dir)
  87. }
  88. return dirs
  89. }
  90. /**
  91. * Start the tsdown watch build used by `pnpm run dev:web`.
  92. * @param root - repository or fixture root passed to tsdown.
  93. * @param pluginDirs - workspace-relative package directories to watch.
  94. * @param pollInterval - optional source-watcher polling interval in milliseconds.
  95. * @returns live bundles after every watcher has completed its initial build.
  96. */
  97. export async function watchClientPlugins(
  98. root: string,
  99. pluginDirs: readonly string[],
  100. pollInterval?: number,
  101. ): Promise<TsdownBundle[]> {
  102. let resolveInitialBuilds: (() => void) | undefined
  103. const initialBuilds = new Promise<void>((resolve) => { resolveInitialBuilds = resolve })
  104. const initialized = new WeakSet<object>()
  105. const readiness: { expectedBuilds?: number; initializedBuilds: number } = { initializedBuilds: 0 }
  106. const bundles = await build({
  107. cwd: root,
  108. workspace: [...pluginDirs],
  109. watch: true,
  110. hooks: {
  111. 'build:done': ({ options }) => {
  112. if (initialized.has(options)) return
  113. initialized.add(options)
  114. readiness.initializedBuilds += 1
  115. if (
  116. readiness.expectedBuilds !== undefined
  117. && readiness.initializedBuilds >= readiness.expectedBuilds
  118. ) resolveInitialBuilds?.()
  119. },
  120. },
  121. ...pollInterval !== undefined
  122. ? { inputOptions: { watch: { watcher: { usePolling: true, pollInterval } } } }
  123. : {},
  124. })
  125. readiness.expectedBuilds = bundles.length
  126. if (readiness.initializedBuilds >= readiness.expectedBuilds) resolveInitialBuilds?.()
  127. await initialBuilds
  128. return bundles
  129. }
  130. /**
  131. * Live watcher processes to terminate when this script is interrupted. Stages
  132. * register themselves as they start, so the set is complete from the first
  133. * spawn: an interrupt during a later stage's startup still tears down the
  134. * earlier ones instead of orphaning them.
  135. */
  136. const stages: StageHandle[] = []
  137. /**
  138. * Spawn one watcher stage, inheriting stdio, registering it for teardown, and
  139. * failing loud if it ever exits: a dead stage leaves the artifact chain silently
  140. * stale, which reads as "my edit did nothing" — the one failure this script
  141. * exists to prevent.
  142. * @param stage - command label used in the exit diagnostic.
  143. * @param command - executable, resolved from the workspace bin when local.
  144. * @param args - command arguments.
  145. * @param local - whether to resolve `command` from the workspace's installed bins.
  146. */
  147. function spawnStage(stage: string, command: string, args: readonly string[], local: boolean): void {
  148. const child = execa(command, [...args], {
  149. cwd: repoRoot,
  150. stdio: 'inherit',
  151. preferLocal: local,
  152. reject: false,
  153. })
  154. stages.push({ kill: () => { child.kill() } })
  155. void child.then((result) => {
  156. console.error(`dev-web: ${stage} exited (code ${String(result.exitCode)}); the artifact chain is now stale`)
  157. process.exit(1)
  158. })
  159. }
  160. /** The only capability this script needs from a live watcher process. */
  161. interface StageHandle {
  162. readonly kill: () => void
  163. }
  164. const invokedPath = process.argv[1]
  165. const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href
  166. if (isMain) {
  167. const pluginDirs = discoverPluginDirs()
  168. const libraryDirs = discoverLibraryDirs()
  169. if (pluginDirs.length === 0) {
  170. console.error('dev-web: no dsh.client (platform "web") packages found under packages/')
  171. process.exit(1)
  172. }
  173. if (libraryDirs.length === 0) {
  174. 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')
  175. process.exit(1)
  176. }
  177. const args = process.argv.slice(2)
  178. const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll='))
  179. if (args.some(a => a !== pollArg)) {
  180. console.error('dev-web: usage: tsx scripts/dev-web.ts [--poll[=ms]]')
  181. process.exit(1)
  182. }
  183. const pollInterval = pollArg === undefined ? undefined : Number(pollArg.split('=')[1] ?? '500')
  184. if (pollInterval !== undefined && (!Number.isInteger(pollInterval) || pollInterval <= 0)) {
  185. console.error(`dev-web: invalid --poll interval "${pollArg ?? ''}"`)
  186. process.exit(1)
  187. }
  188. // Registered before any stage starts: `stages` is read at signal time, so an
  189. // interrupt during tsdown's initial builds still kills whatever is running.
  190. const stop = (): void => { for (const stage of stages) stage.kill() }
  191. process.once('SIGINT', stop)
  192. process.once('SIGTERM', stop)
  193. // tsc has no polling interval flag, so `--poll` selects its fixed-interval
  194. // watchers rather than an interval. Dropping that translation leaves tsc
  195. // natively watching on a network mount where inotify never fires: it stops
  196. // re-emitting lib/types, and the two later stages then rebuild forever from
  197. // stale input without printing anything.
  198. spawnStage(`tsc -b ${CLIENT_TYPE_PROGRAM} --watch`, 'tsc', [
  199. '-b', CLIENT_TYPE_PROGRAM, '--watch', '--preserveWatchOutput',
  200. ...pollInterval !== undefined
  201. ? ['--watchFile', 'fixedPollingInterval', '--watchDirectory', 'fixedPollingInterval']
  202. : [],
  203. ], true)
  204. // tsdown's initial builds are awaited before the dist watcher starts so vite's
  205. // first build reads current lib bundles rather than whatever the last full
  206. // build left. Its own watch then covers later lib rewrites — those files are
  207. // in its module graph.
  208. await watchClientPlugins(repoRoot, [...pluginDirs, ...libraryDirs], pollInterval)
  209. // Through the shell's own `watch` script rather than vite's API: vite is not a
  210. // repository-root dependency, and more importantly the vite root is its
  211. // working directory — `resolve.dedupe` resolves react from that root, so
  212. // running vite from anywhere but apps/web silently switches which react copy
  213. // the bundle gets.
  214. spawnStage('vite build --watch', 'pnpm', ['--filter', SHELL_PACKAGE, 'run', 'watch'], false)
  215. console.log(
  216. `dev-web: watching ${String(pluginDirs.length)} dsh.client plugin packages`
  217. + ` and ${String(libraryDirs.length)} statically linked library packages`
  218. + (pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : '')
  219. + `, plus tsc -b ${CLIENT_TYPE_PROGRAM} and the ${SHELL_PACKAGE} dist build:\n `
  220. + [...pluginDirs, ...libraryDirs].join('\n '),
  221. )
  222. }