zooid 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +36 -444
  3. package/dist/bin.js +1891 -0
  4. package/dist/bin.js.map +1 -0
  5. package/dist/chunk-SUPTPSN3.js +31434 -0
  6. package/dist/chunk-SUPTPSN3.js.map +1 -0
  7. package/dist/index.d.ts +64 -0
  8. package/dist/index.js +4 -3790
  9. package/dist/index.js.map +1 -0
  10. package/dist/web/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  11. package/dist/web/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  12. package/dist/web/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  13. package/dist/web/assets/index-C2MAIew3.css +1 -0
  14. package/dist/web/assets/index-CqfWXqvO.js +118066 -0
  15. package/dist/web/assets/index-Cz5fEZBc.js +295 -0
  16. package/dist/web/assets/reaction-picker-emoji-cME0vOsX.js +716 -0
  17. package/dist/web/favicon.svg +1 -0
  18. package/dist/web/index.html +14 -0
  19. package/package.json +43 -22
  20. package/src/bin.ts +115 -0
  21. package/src/bootstrap/admin.test.ts +69 -0
  22. package/src/bootstrap/admin.ts +37 -0
  23. package/src/bootstrap/configs.test.ts +24 -0
  24. package/src/bootstrap/configs.ts +64 -0
  25. package/src/bootstrap/data-layout.test.ts +25 -0
  26. package/src/bootstrap/data-layout.ts +20 -0
  27. package/src/bootstrap/derive.test.ts +74 -0
  28. package/src/bootstrap/derive.ts +40 -0
  29. package/src/bootstrap/paths.test.ts +22 -0
  30. package/src/bootstrap/paths.ts +27 -0
  31. package/src/bootstrap/tokens.test.ts +44 -0
  32. package/src/bootstrap/tokens.ts +43 -0
  33. package/src/build-registry.context.test.ts +61 -0
  34. package/src/build-registry.test.ts +59 -0
  35. package/src/build-registry.ts +343 -0
  36. package/src/build-registry.zod043.test.ts +64 -0
  37. package/src/build-registry.zod044.test.ts +261 -0
  38. package/src/commands/dev-cascade.test.ts +38 -0
  39. package/src/commands/dev-cascade.ts +35 -0
  40. package/src/commands/dev-data-layout.test.ts +55 -0
  41. package/src/commands/dev.ts +316 -0
  42. package/src/commands/init/generators.test.ts +97 -0
  43. package/src/commands/init/generators.ts +124 -0
  44. package/src/commands/init/prompts.ts +123 -0
  45. package/src/commands/init/registry.test.ts +33 -0
  46. package/src/commands/init/registry.ts +75 -0
  47. package/src/commands/init/sniff.test.ts +41 -0
  48. package/src/commands/init/sniff.ts +30 -0
  49. package/src/commands/init.test.ts +131 -0
  50. package/src/commands/init.ts +158 -0
  51. package/src/commands/logs.test.ts +105 -0
  52. package/src/commands/logs.ts +108 -0
  53. package/src/commands/start.ts +28 -0
  54. package/src/commands/status.test.ts +73 -0
  55. package/src/commands/status.ts +105 -0
  56. package/src/daemon/start-daemon.test.ts +67 -0
  57. package/src/daemon/start-daemon.ts +270 -0
  58. package/src/index.ts +2 -0
  59. package/src/observability/capture-agent.ts +58 -0
  60. package/src/observability/capture-tuwunel.test.ts +57 -0
  61. package/src/observability/capture-tuwunel.ts +24 -0
  62. package/src/observability/file-sink.test.ts +68 -0
  63. package/src/observability/file-sink.ts +78 -0
  64. package/src/observability/observability.integration.test.ts +162 -0
  65. package/src/observability/paths.test.ts +104 -0
  66. package/src/observability/paths.ts +94 -0
  67. package/src/prepull-images.test.ts +149 -0
  68. package/src/prepull-images.ts +100 -0
  69. package/src/services/tuwunel.test.ts +64 -0
  70. package/src/services/tuwunel.ts +91 -0
  71. package/src/web/resolve.test.ts +50 -0
  72. package/src/web/resolve.ts +31 -0
  73. package/src/web/static.test.ts +51 -0
  74. package/src/web/static.ts +61 -0
  75. package/src/web/watch.ts +103 -0
  76. package/dist/chunk-67ZRMVHO.js +0 -174
  77. package/dist/chunk-AR456MHY.js +0 -29
  78. package/dist/chunk-VBGU2NST.js +0 -139
  79. package/dist/client-4VMFEFDX.js +0 -22
  80. package/dist/config-2KK5GX42.js +0 -27
  81. package/dist/template-T5IB4YWC.js +0 -92
@@ -0,0 +1,100 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+
4
+ const pExecFile = promisify(execFile)
5
+
6
+ export interface PrepullExec {
7
+ (cmd: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }>
8
+ }
9
+
10
+ export interface PrepullOptions {
11
+ engine: 'docker' | 'podman'
12
+ runtime: 'local' | 'docker' | 'podman'
13
+ /** Injectable for tests. Defaults to a child_process.execFile-backed exec. */
14
+ exec?: PrepullExec
15
+ /** When true, skip the inspect check and pull every image. */
16
+ refresh?: boolean
17
+ /** When true, the function returns immediately without touching the engine. */
18
+ skip?: boolean
19
+ log?: (line: string) => void
20
+ }
21
+
22
+ export interface PrepullableRegistry {
23
+ agentNames(): string[]
24
+ resolveSpawnImage(name: string): string | undefined
25
+ }
26
+
27
+ /**
28
+ * Walk the registry's unique resolved-image set, `<engine> image inspect`
29
+ * each one, and `<engine> pull` the ones not cached locally. Parallel and
30
+ * fail-fast: any pull exit ≠ 0 throws with the engine's stderr embedded.
31
+ * No-op when `opts.skip` or `opts.runtime === 'local'`.
32
+ */
33
+ export async function prepullImages(
34
+ registry: PrepullableRegistry,
35
+ opts: PrepullOptions,
36
+ ): Promise<void> {
37
+ if (opts.skip || opts.runtime === 'local') return
38
+ const exec = opts.exec ?? defaultExec
39
+ const log = opts.log ?? ((l: string) => console.log(l))
40
+
41
+ const images = new Set<string>()
42
+ for (const name of registry.agentNames()) {
43
+ const img = registry.resolveSpawnImage(name)
44
+ if (img) images.add(img)
45
+ }
46
+
47
+ const toPull: string[] = []
48
+ let cached = 0
49
+ if (opts.refresh) {
50
+ toPull.push(...images)
51
+ } else {
52
+ await Promise.all(
53
+ [...images].map(async (img) => {
54
+ const r = await exec(opts.engine, ['image', 'inspect', img])
55
+ if (r.code === 0) cached++
56
+ else toPull.push(img)
57
+ }),
58
+ )
59
+ }
60
+
61
+ log(
62
+ `[zooid] image prepull: ${images.size} unique images (${cached} cached, ${toPull.length} to pull)`,
63
+ )
64
+ if (toPull.length === 0) return
65
+
66
+ await Promise.all(
67
+ toPull.map(async (img) => {
68
+ log(`[zooid] ${img} pulling…`)
69
+ const start = Date.now()
70
+ const r = await exec(opts.engine, ['pull', img])
71
+ const secs = ((Date.now() - start) / 1000).toFixed(1)
72
+ if (r.code !== 0) {
73
+ throw new Error(
74
+ `image prepull failed for ${img}:\n${r.stderr.trim() || '(no stderr)'}`,
75
+ )
76
+ }
77
+ log(`[zooid] ${img} ✓ ${secs}s`)
78
+ }),
79
+ )
80
+ }
81
+
82
+ const defaultExec: PrepullExec = async (cmd, args) => {
83
+ try {
84
+ const { stdout, stderr } = await pExecFile(cmd, args, {
85
+ maxBuffer: 16 * 1024 * 1024,
86
+ })
87
+ return { code: 0, stdout, stderr }
88
+ } catch (err) {
89
+ const e = err as NodeJS.ErrnoException & {
90
+ code?: number | string
91
+ stdout?: string
92
+ stderr?: string
93
+ }
94
+ return {
95
+ code: typeof e.code === 'number' ? e.code : 1,
96
+ stdout: e.stdout ?? '',
97
+ stderr: e.stderr ?? String(e),
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildRunArgs } from './tuwunel.js'
3
+
4
+ describe('buildRunArgs', () => {
5
+ const base = {
6
+ name: 'zooid-tuwunel',
7
+ image: 'ghcr.io/matrix-construct/tuwunel:latest',
8
+ hostPort: 8448,
9
+ paths: {
10
+ dataDir: '/abs/data/matrix',
11
+ dbDir: '/abs/data/matrix/db',
12
+ mediaDir: '/abs/data/matrix/media',
13
+ configDir: '/abs/data/matrix/config',
14
+ registrationsDir: '/abs/data/matrix/config/registrations',
15
+ tuwunelTomlPath: '/abs/data/matrix/config/tuwunel.toml',
16
+ appserviceYamlPath: '/abs/data/matrix/config/registrations/zooid.yaml',
17
+ envPath: '/abs/data/matrix/config/.env',
18
+ },
19
+ }
20
+
21
+ it('uses docker by default, mounts persistent volumes and config read-only', () => {
22
+ const args = buildRunArgs({ ...base, engine: 'docker' })
23
+ expect(args[0]).toBe('run')
24
+ expect(args).toContain('--rm')
25
+ // Foregrounded so the parent process owns Tuwunel's stdio (logs).
26
+ expect(args).not.toContain('-d')
27
+ expect(args).toContain('--name')
28
+ expect(args).toContain('zooid-tuwunel')
29
+ expect(args).toContain('-p')
30
+ expect(args).toContain('8448:8448')
31
+ expect(args).toContain('/abs/data/matrix/db:/var/lib/tuwunel/db')
32
+ expect(args).toContain('/abs/data/matrix/media:/var/lib/tuwunel/media')
33
+ expect(args).toContain(
34
+ '/abs/data/matrix/config/tuwunel.toml:/etc/tuwunel/tuwunel.toml:ro',
35
+ )
36
+ expect(args).toContain(
37
+ '/abs/data/matrix/config/registrations:/var/lib/tuwunel/registrations:ro',
38
+ )
39
+ // ZOD041 deliberately does NOT mount ${dataRoot}/logs into the container.
40
+ // Today the daemon captures tuwunel's stdout/stderr via captureChildToFile
41
+ // — there's no internal tuwunel log directive yet. A future cycle that flips
42
+ // tuwunel.toml to write its own logs can add the mount alongside that change.
43
+ expect(args.some((a) => a.endsWith(':/var/log/tuwunel'))).toBe(false)
44
+ expect(args).toContain('TUWUNEL_CONFIG=/etc/tuwunel/tuwunel.toml')
45
+ expect(args[args.length - 1]).toBe(base.image)
46
+ })
47
+
48
+ it('engine: podman switches the binary (caller chooses), args are identical', () => {
49
+ const docker = buildRunArgs({ ...base, engine: 'docker' })
50
+ const podman = buildRunArgs({ ...base, engine: 'podman' })
51
+ expect(podman).toEqual(docker)
52
+ })
53
+
54
+ it('honors a custom container name and host port', () => {
55
+ const args = buildRunArgs({
56
+ ...base,
57
+ name: 'my-zooid-tuwunel',
58
+ hostPort: 9000,
59
+ engine: 'docker',
60
+ })
61
+ expect(args).toContain('my-zooid-tuwunel')
62
+ expect(args).toContain('9000:8448')
63
+ })
64
+ })
@@ -0,0 +1,91 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process'
2
+ import type { Paths } from '../bootstrap/paths.js'
3
+
4
+ export interface TuwunelOpts {
5
+ name: string
6
+ image?: string
7
+ hostPort: number
8
+ paths: Paths
9
+ engine: 'docker' | 'podman'
10
+ }
11
+
12
+ const DEFAULT_IMAGE = 'ghcr.io/matrix-construct/tuwunel:latest'
13
+
14
+ export function buildRunArgs(opts: TuwunelOpts): string[] {
15
+ const image = opts.image ?? DEFAULT_IMAGE
16
+ return [
17
+ 'run',
18
+ '--rm',
19
+ '--name',
20
+ opts.name,
21
+ '-p',
22
+ `${opts.hostPort}:8448`,
23
+ '-v',
24
+ `${opts.paths.dbDir}:/var/lib/tuwunel/db`,
25
+ '-v',
26
+ `${opts.paths.mediaDir}:/var/lib/tuwunel/media`,
27
+ '-v',
28
+ `${opts.paths.tuwunelTomlPath}:/etc/tuwunel/tuwunel.toml:ro`,
29
+ '-v',
30
+ `${opts.paths.registrationsDir}:/var/lib/tuwunel/registrations:ro`,
31
+ '-e',
32
+ 'TUWUNEL_CONFIG=/etc/tuwunel/tuwunel.toml',
33
+ image,
34
+ ]
35
+ }
36
+
37
+ export class TuwunelService {
38
+ private child: ChildProcess | null = null
39
+ constructor(private readonly opts: TuwunelOpts) {}
40
+
41
+ start(): ChildProcess {
42
+ this.child = spawn(this.opts.engine, buildRunArgs(this.opts), {
43
+ stdio: ['ignore', 'pipe', 'pipe'],
44
+ })
45
+ return this.child
46
+ }
47
+
48
+ async stop(): Promise<void> {
49
+ // Foregrounded container: kill the engine process; --rm cleans up after.
50
+ if (this.child && this.child.exitCode === null) {
51
+ this.child.kill('SIGTERM')
52
+ await new Promise<void>((resolve) => {
53
+ this.child!.on('exit', () => resolve())
54
+ })
55
+ }
56
+ this.child = null
57
+ // Defensive: the parent may have died while the container was running, in
58
+ // which case --rm doesn't fire. `<engine> stop` is a no-op if it's already
59
+ // gone (errors are swallowed).
60
+ await execEngine(this.opts.engine, ['stop', this.opts.name]).catch(() => {})
61
+ }
62
+
63
+ async waitHealthy(opts: { url: string; timeoutMs: number }): Promise<void> {
64
+ const deadline = Date.now() + opts.timeoutMs
65
+ while (Date.now() < deadline) {
66
+ try {
67
+ const r = await fetch(`${opts.url}/_matrix/client/versions`)
68
+ if (r.ok) return
69
+ } catch {
70
+ // not yet
71
+ }
72
+ await new Promise((resolve) => setTimeout(resolve, 500))
73
+ }
74
+ throw new Error(
75
+ `Tuwunel did not become healthy at ${opts.url} in ${opts.timeoutMs}ms`,
76
+ )
77
+ }
78
+ }
79
+
80
+ function execEngine(engine: string, args: string[]): Promise<void> {
81
+ return new Promise((resolve, reject) => {
82
+ const child = spawn(engine, args, { stdio: 'pipe' })
83
+ let stderr = ''
84
+ child.stderr.on('data', (b) => (stderr += String(b)))
85
+ child.on('exit', (code) => {
86
+ if (code === 0) resolve()
87
+ else reject(new Error(`${engine} ${args.join(' ')} failed: ${stderr.trim()}`))
88
+ })
89
+ child.on('error', reject)
90
+ })
91
+ }
@@ -0,0 +1,50 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
+ import { resolveWebRoot, webSourcePackage } from './resolve.js'
6
+
7
+ let root: string
8
+ beforeEach(() => {
9
+ root = mkdtempSync(join(tmpdir(), 'zooid-resolve-'))
10
+ })
11
+ afterEach(() => rmSync(root, { recursive: true, force: true }))
12
+
13
+ describe('resolveWebRoot', () => {
14
+ it('returns dist/web when present (published-package layout)', () => {
15
+ const distWeb = join(root, 'dist', 'web')
16
+ mkdirSync(distWeb, { recursive: true })
17
+ writeFileSync(join(distWeb, 'index.html'), '<html/>')
18
+ expect(resolveWebRoot(root)).toBe(distWeb)
19
+ })
20
+
21
+ it('falls back to ../../zoon/packages/web/dist when running from source', () => {
22
+ const cliRoot = join(root, 'zooid', 'packages', 'cli')
23
+ mkdirSync(cliRoot, { recursive: true })
24
+ const webPkg = join(root, 'zoon', 'packages', 'web')
25
+ const webDist = join(webPkg, 'dist')
26
+ mkdirSync(webDist, { recursive: true })
27
+ writeFileSync(join(webPkg, 'package.json'), '{"name":"@zoon/web"}')
28
+ writeFileSync(join(webDist, 'index.html'), '<html/>')
29
+ expect(resolveWebRoot(cliRoot)).toBe(webDist)
30
+ })
31
+
32
+ it('throws a helpful error when neither layout has been built', () => {
33
+ expect(() => resolveWebRoot(root)).toThrow(/@zoon\/web.*build/i)
34
+ })
35
+ })
36
+
37
+ describe('webSourcePackage', () => {
38
+ it('returns the source package dir when sibling zoon/packages/web exists', () => {
39
+ const cliRoot = join(root, 'zooid', 'packages', 'cli')
40
+ mkdirSync(cliRoot, { recursive: true })
41
+ const webPkg = join(root, 'zoon', 'packages', 'web')
42
+ mkdirSync(webPkg, { recursive: true })
43
+ writeFileSync(join(webPkg, 'package.json'), '{"name":"@zoon/web"}')
44
+ expect(webSourcePackage(cliRoot)).toBe(webPkg)
45
+ })
46
+
47
+ it('returns null outside the monorepo', () => {
48
+ expect(webSourcePackage(root)).toBeNull()
49
+ })
50
+ })
@@ -0,0 +1,31 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { dirname, join, resolve } from 'node:path'
3
+
4
+ const ENV_OVERRIDE = 'ZOOID_DEV_WEB_ROOT_OVERRIDE'
5
+
6
+ export function resolveWebRoot(cliRoot: string): string {
7
+ const override = process.env[ENV_OVERRIDE]
8
+ if (override && existsSync(join(override, 'index.html'))) return resolve(override)
9
+
10
+ const published = join(cliRoot, 'dist', 'web')
11
+ if (existsSync(join(published, 'index.html'))) return published
12
+
13
+ const fromSource = webSourcePackage(cliRoot)
14
+ if (fromSource && existsSync(join(fromSource, 'dist', 'index.html'))) {
15
+ return join(fromSource, 'dist')
16
+ }
17
+
18
+ throw new Error(
19
+ `@zoon/web build not found.\n Tried: ${published}\n Tried: ${fromSource ?? '(no monorepo source)'}\n` +
20
+ `Run \`pnpm -C zoon/packages/web build\` (or set ${ENV_OVERRIDE} to a built dist).`,
21
+ )
22
+ }
23
+
24
+ // Returns the path to the in-tree @zoon/web package (zoon/packages/web) if the
25
+ // CLI is running from the monorepo source. Returns null when running from an
26
+ // installed package (no sibling zoon/ tree).
27
+ export function webSourcePackage(cliRoot: string): string | null {
28
+ const workspaceRoot = dirname(dirname(dirname(cliRoot)))
29
+ const candidate = join(workspaceRoot, 'zoon', 'packages', 'web')
30
+ return existsSync(join(candidate, 'package.json')) ? candidate : null
31
+ }
@@ -0,0 +1,51 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
+ import { webStatic } from './static.js'
6
+
7
+ let dir: string
8
+ beforeEach(() => {
9
+ dir = mkdtempSync(join(tmpdir(), 'zooid-web-'))
10
+ mkdirSync(join(dir, 'assets'), { recursive: true })
11
+ writeFileSync(join(dir, 'index.html'), '<!doctype html><div id=root></div>')
12
+ writeFileSync(join(dir, 'assets', 'main.js'), 'console.log("ui")')
13
+ })
14
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
15
+
16
+ describe('webStatic', () => {
17
+ it('serves index.html at /', async () => {
18
+ const app = webStatic({ webRoot: dir, homeserverUrl: 'http://localhost:8448' })
19
+ const r = await app.request('/')
20
+ expect(r.status).toBe(200)
21
+ expect(await r.text()).toContain('id=root')
22
+ })
23
+
24
+ it('serves static assets', async () => {
25
+ const app = webStatic({ webRoot: dir, homeserverUrl: 'http://localhost:8448' })
26
+ const r = await app.request('/assets/main.js')
27
+ expect(r.status).toBe(200)
28
+ expect(await r.text()).toContain('console.log("ui")')
29
+ })
30
+
31
+ it('synthesizes /config.json with the homeserver URL', async () => {
32
+ const app = webStatic({ webRoot: dir, homeserverUrl: 'http://localhost:8448' })
33
+ const r = await app.request('/config.json')
34
+ expect(r.status).toBe(200)
35
+ expect(r.headers.get('content-type')).toMatch(/application\/json/)
36
+ expect(await r.json()).toEqual({ homeserver_url: 'http://localhost:8448' })
37
+ })
38
+
39
+ it('falls back to index.html for unknown routes (SPA history)', async () => {
40
+ const app = webStatic({ webRoot: dir, homeserverUrl: 'http://localhost:8448' })
41
+ const r = await app.request('/room/!abc:localhost')
42
+ expect(r.status).toBe(200)
43
+ expect(await r.text()).toContain('id=root')
44
+ })
45
+
46
+ it('returns 404 for a missing asset', async () => {
47
+ const app = webStatic({ webRoot: dir, homeserverUrl: 'http://localhost:8448' })
48
+ const r = await app.request('/assets/nope.js')
49
+ expect(r.status).toBe(404)
50
+ })
51
+ })
@@ -0,0 +1,61 @@
1
+ import { readFileSync, statSync } from 'node:fs'
2
+ import { extname, join, normalize } from 'node:path'
3
+ import { Hono } from 'hono'
4
+
5
+ export interface WebStaticOpts {
6
+ webRoot: string
7
+ homeserverUrl: string
8
+ }
9
+
10
+ const MIME: Record<string, string> = {
11
+ '.html': 'text/html; charset=utf-8',
12
+ '.js': 'application/javascript; charset=utf-8',
13
+ '.mjs': 'application/javascript; charset=utf-8',
14
+ '.css': 'text/css; charset=utf-8',
15
+ '.json': 'application/json; charset=utf-8',
16
+ '.svg': 'image/svg+xml',
17
+ '.png': 'image/png',
18
+ '.jpg': 'image/jpeg',
19
+ '.jpeg': 'image/jpeg',
20
+ '.woff': 'font/woff',
21
+ '.woff2': 'font/woff2',
22
+ }
23
+
24
+ function isAssetPath(p: string): boolean {
25
+ return p.startsWith('/assets/') || /\.[a-z0-9]+$/i.test(p)
26
+ }
27
+
28
+ export function webStatic(opts: WebStaticOpts): Hono {
29
+ const app = new Hono()
30
+
31
+ app.get('/config.json', (c) => c.json({ homeserver_url: opts.homeserverUrl }))
32
+
33
+ app.get('*', (c) => {
34
+ const url = new URL(c.req.url)
35
+ const requested = decodeURIComponent(url.pathname)
36
+ const wantFile = requested === '/' ? '/index.html' : requested
37
+
38
+ const safe = normalize(wantFile).replace(/^(\.\.[/\\])+/g, '')
39
+ const filePath = join(opts.webRoot, safe)
40
+ if (!filePath.startsWith(opts.webRoot)) return c.notFound()
41
+
42
+ try {
43
+ const stat = statSync(filePath)
44
+ if (stat.isFile()) {
45
+ const body = readFileSync(filePath)
46
+ const ct = MIME[extname(filePath)] ?? 'application/octet-stream'
47
+ return c.body(body as unknown as ArrayBuffer, 200, { 'content-type': ct })
48
+ }
49
+ } catch {
50
+ // fall through to history fallback
51
+ }
52
+
53
+ if (isAssetPath(requested)) return c.notFound()
54
+ const indexBytes = readFileSync(join(opts.webRoot, 'index.html'))
55
+ return c.body(indexBytes as unknown as ArrayBuffer, 200, {
56
+ 'content-type': MIME['.html']!,
57
+ })
58
+ })
59
+
60
+ return app
61
+ }
@@ -0,0 +1,103 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process'
2
+ import { existsSync, statSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ export interface WebWatchHandle {
6
+ distPath: string
7
+ // Pipe captured child output onwards. Call once Listr / interactive
8
+ // renderers are done, otherwise vite output will get garbled.
9
+ attachStdio: () => void
10
+ stop: () => Promise<void>
11
+ }
12
+
13
+ export interface WebWatchOpts {
14
+ webPackageDir: string
15
+ // Wait up to this many ms for vite's first build to finish before resolving.
16
+ firstBuildTimeoutMs?: number
17
+ }
18
+
19
+ // Spawns `vite build --watch` in webPackageDir and resolves once the first
20
+ // build has completed (detected via index.html mtime > spawn time).
21
+ export async function startWebWatch(opts: WebWatchOpts): Promise<WebWatchHandle> {
22
+ const distPath = join(opts.webPackageDir, 'dist')
23
+ const indexPath = join(distPath, 'index.html')
24
+ const timeoutMs = opts.firstBuildTimeoutMs ?? 60_000
25
+ const spawnTime = Date.now()
26
+
27
+ const child: ChildProcess = spawn(
28
+ 'pnpm',
29
+ ['-C', opts.webPackageDir, 'exec', 'vite', 'build', '--watch'],
30
+ { stdio: ['ignore', 'pipe', 'pipe'] },
31
+ )
32
+
33
+ let buffered = ''
34
+ const onData = (chunk: Buffer): void => {
35
+ buffered += chunk.toString('utf8')
36
+ }
37
+ child.stdout?.on('data', onData)
38
+ child.stderr?.on('data', onData)
39
+
40
+ let attached = false
41
+ const attachStdio = (): void => {
42
+ if (attached) return
43
+ attached = true
44
+ child.stdout?.off('data', onData)
45
+ child.stderr?.off('data', onData)
46
+ if (buffered) process.stdout.write(buffered)
47
+ buffered = ''
48
+ child.stdout?.pipe(process.stdout)
49
+ child.stderr?.pipe(process.stderr)
50
+ }
51
+
52
+ let exited = false
53
+ child.once('exit', () => {
54
+ exited = true
55
+ })
56
+
57
+ while (Date.now() - spawnTime < timeoutMs) {
58
+ if (exited) {
59
+ attachStdio()
60
+ throw new Error(`vite build --watch exited before first build (code ${child.exitCode})`)
61
+ }
62
+ if (existsSync(indexPath)) {
63
+ const m = statSync(indexPath).mtimeMs
64
+ // Slack for clock granularity on macOS HFS+/APFS.
65
+ if (m >= spawnTime - 500) return makeHandle()
66
+ }
67
+ await new Promise((r) => setTimeout(r, 200))
68
+ }
69
+ // Timed out: clean up and surface what vite said.
70
+ attachStdio()
71
+ await stopChild(child)
72
+ throw new Error(`vite build --watch did not produce ${indexPath} within ${timeoutMs}ms`)
73
+
74
+ function makeHandle(): WebWatchHandle {
75
+ return {
76
+ distPath,
77
+ attachStdio,
78
+ stop: () => stopChild(child),
79
+ }
80
+ }
81
+ }
82
+
83
+ async function stopChild(child: ChildProcess): Promise<void> {
84
+ if (child.exitCode !== null) return
85
+ child.kill('SIGTERM')
86
+ await new Promise<void>((resolve) => {
87
+ let done = false
88
+ const finish = (): void => {
89
+ if (done) return
90
+ done = true
91
+ resolve()
92
+ }
93
+ child.once('exit', finish)
94
+ setTimeout(() => {
95
+ try {
96
+ child.kill('SIGKILL')
97
+ } catch {
98
+ // already gone
99
+ }
100
+ finish()
101
+ }, 2000)
102
+ })
103
+ }