dev-web.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. /**
  2. * Watch-build for client-plugin HMR: runs every dshClient plugin package
  3. * through the tsdown JS API in watch mode. Reload signaling is not this
  4. * script's business — the host webserver stat-polls the bundles it serves and
  5. * broadcasts `rebuilt` frames itself (`dsh web --dev`), so any process that
  6. * rewrites `lib/client.js` files triggers reloads; this script is merely the
  7. * convenient way to keep them all rebuilt on source change.
  8. *
  9. * Usage: `pnpm exec tsx scripts/dev-web.ts [--poll[=ms]]`. Requires the
  10. * packages' node halves built once (`tsc -b tsconfig.build.json`): the lib
  11. * config's entries are tsc output. `--poll` switches the source-file watcher
  12. * to polling (default 500ms): network mounts (weka) deliver no inotify
  13. * events, so native watching sees the initial build only and never a source
  14. * change.
  15. *
  16. * Each package keeps its own tsdown.config.ts untouched: this script layers
  17. * `watch` through API-level inline config (tsdown workspace mode fills inline
  18. * keys under each package's file config, and no package config defines it).
  19. */
  20. import { globSync, readFileSync } from 'node:fs'
  21. import { dirname, join, resolve, sep } from 'node:path'
  22. import { fileURLToPath, pathToFileURL } from 'node:url'
  23. import { build } from 'tsdown'
  24. import type { TsdownBundle } from 'tsdown'
  25. const repoRoot = fileURLToPath(new URL('..', import.meta.url))
  26. /**
  27. * Discover the watch workspace by declaration: every packages/<group>/<name>
  28. * whose package.json carries `dshClient` with platform "web" is a client
  29. * plugin bundle emitter. Scanned once at startup — a package added while
  30. * watching means restarting this script.
  31. * @param root - repository root containing the grouped package directories.
  32. * @returns workspace-relative plugin package directories.
  33. */
  34. export function discoverPluginDirs(root = repoRoot): string[] {
  35. const dirs: string[] = []
  36. for (const manifestPath of globSync('packages/*/*/package.json', { cwd: root }).sort()) {
  37. const manifest = JSON.parse(readFileSync(join(root, manifestPath), 'utf8')) as { dshClient?: { platform?: unknown } }
  38. if (manifest.dshClient?.platform === 'web') dirs.push(dirname(manifestPath).split(sep).join('/'))
  39. }
  40. return dirs
  41. }
  42. /**
  43. * Start the tsdown watch build used by `pnpm run dev:web`.
  44. * @param root - repository or fixture root passed to tsdown.
  45. * @param pluginDirs - workspace-relative package directories to watch.
  46. * @param pollInterval - optional source-watcher polling interval in milliseconds.
  47. * @returns live bundles after every watcher has completed its initial build.
  48. */
  49. export async function watchClientPlugins(
  50. root: string,
  51. pluginDirs: readonly string[],
  52. pollInterval?: number,
  53. ): Promise<TsdownBundle[]> {
  54. let resolveInitialBuilds: (() => void) | undefined
  55. const initialBuilds = new Promise<void>((resolve) => { resolveInitialBuilds = resolve })
  56. const initialized = new WeakSet<object>()
  57. const readiness: { expectedBuilds?: number; initializedBuilds: number } = { initializedBuilds: 0 }
  58. const bundles = await build({
  59. cwd: root,
  60. workspace: [...pluginDirs],
  61. watch: true,
  62. hooks: {
  63. 'build:done': ({ options }) => {
  64. if (initialized.has(options)) return
  65. initialized.add(options)
  66. readiness.initializedBuilds += 1
  67. if (
  68. readiness.expectedBuilds !== undefined
  69. && readiness.initializedBuilds >= readiness.expectedBuilds
  70. ) resolveInitialBuilds?.()
  71. },
  72. },
  73. ...pollInterval !== undefined
  74. ? { inputOptions: { watch: { watcher: { usePolling: true, pollInterval } } } }
  75. : {},
  76. })
  77. readiness.expectedBuilds = bundles.length
  78. if (readiness.initializedBuilds >= readiness.expectedBuilds) resolveInitialBuilds?.()
  79. await initialBuilds
  80. return bundles
  81. }
  82. const invokedPath = process.argv[1]
  83. const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href
  84. if (isMain) {
  85. const pluginDirs = discoverPluginDirs()
  86. if (pluginDirs.length === 0) {
  87. console.error('dev-web: no dshClient (platform "web") packages found under packages/')
  88. process.exit(1)
  89. }
  90. const args = process.argv.slice(2)
  91. const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll='))
  92. if (args.some(a => a !== pollArg)) {
  93. console.error('dev-web: usage: tsx scripts/dev-web.ts [--poll[=ms]]')
  94. process.exit(1)
  95. }
  96. const pollInterval = pollArg === undefined ? undefined : Number(pollArg.split('=')[1] ?? '500')
  97. if (pollInterval !== undefined && (!Number.isInteger(pollInterval) || pollInterval <= 0)) {
  98. console.error(`dev-web: invalid --poll interval "${pollArg ?? ''}"`)
  99. process.exit(1)
  100. }
  101. await watchClientPlugins(repoRoot, pluginDirs, pollInterval)
  102. console.log(
  103. `dev-web: watching ${String(pluginDirs.length)} dshClient plugin packages`
  104. + `${pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : ''}:\n ${pluginDirs.join('\n ')}`,
  105. )
  106. }