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.
- package/LICENSE +1 -1
- package/README.md +36 -444
- package/dist/bin.js +1891 -0
- package/dist/bin.js.map +1 -0
- package/dist/chunk-SUPTPSN3.js +31434 -0
- package/dist/chunk-SUPTPSN3.js.map +1 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +4 -3790
- package/dist/index.js.map +1 -0
- package/dist/web/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- package/dist/web/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- package/dist/web/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- package/dist/web/assets/index-C2MAIew3.css +1 -0
- package/dist/web/assets/index-CqfWXqvO.js +118066 -0
- package/dist/web/assets/index-Cz5fEZBc.js +295 -0
- package/dist/web/assets/reaction-picker-emoji-cME0vOsX.js +716 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +14 -0
- package/package.json +43 -22
- package/src/bin.ts +115 -0
- package/src/bootstrap/admin.test.ts +69 -0
- package/src/bootstrap/admin.ts +37 -0
- package/src/bootstrap/configs.test.ts +24 -0
- package/src/bootstrap/configs.ts +64 -0
- package/src/bootstrap/data-layout.test.ts +25 -0
- package/src/bootstrap/data-layout.ts +20 -0
- package/src/bootstrap/derive.test.ts +74 -0
- package/src/bootstrap/derive.ts +40 -0
- package/src/bootstrap/paths.test.ts +22 -0
- package/src/bootstrap/paths.ts +27 -0
- package/src/bootstrap/tokens.test.ts +44 -0
- package/src/bootstrap/tokens.ts +43 -0
- package/src/build-registry.context.test.ts +61 -0
- package/src/build-registry.test.ts +59 -0
- package/src/build-registry.ts +343 -0
- package/src/build-registry.zod043.test.ts +64 -0
- package/src/build-registry.zod044.test.ts +261 -0
- package/src/commands/dev-cascade.test.ts +38 -0
- package/src/commands/dev-cascade.ts +35 -0
- package/src/commands/dev-data-layout.test.ts +55 -0
- package/src/commands/dev.ts +316 -0
- package/src/commands/init/generators.test.ts +97 -0
- package/src/commands/init/generators.ts +124 -0
- package/src/commands/init/prompts.ts +123 -0
- package/src/commands/init/registry.test.ts +33 -0
- package/src/commands/init/registry.ts +75 -0
- package/src/commands/init/sniff.test.ts +41 -0
- package/src/commands/init/sniff.ts +30 -0
- package/src/commands/init.test.ts +131 -0
- package/src/commands/init.ts +158 -0
- package/src/commands/logs.test.ts +105 -0
- package/src/commands/logs.ts +108 -0
- package/src/commands/start.ts +28 -0
- package/src/commands/status.test.ts +73 -0
- package/src/commands/status.ts +105 -0
- package/src/daemon/start-daemon.test.ts +67 -0
- package/src/daemon/start-daemon.ts +270 -0
- package/src/index.ts +2 -0
- package/src/observability/capture-agent.ts +58 -0
- package/src/observability/capture-tuwunel.test.ts +57 -0
- package/src/observability/capture-tuwunel.ts +24 -0
- package/src/observability/file-sink.test.ts +68 -0
- package/src/observability/file-sink.ts +78 -0
- package/src/observability/observability.integration.test.ts +162 -0
- package/src/observability/paths.test.ts +104 -0
- package/src/observability/paths.ts +94 -0
- package/src/prepull-images.test.ts +149 -0
- package/src/prepull-images.ts +100 -0
- package/src/services/tuwunel.test.ts +64 -0
- package/src/services/tuwunel.ts +91 -0
- package/src/web/resolve.test.ts +50 -0
- package/src/web/resolve.ts +31 -0
- package/src/web/static.test.ts +51 -0
- package/src/web/static.ts +61 -0
- package/src/web/watch.ts +103 -0
- package/dist/chunk-67ZRMVHO.js +0 -174
- package/dist/chunk-AR456MHY.js +0 -29
- package/dist/chunk-VBGU2NST.js +0 -139
- package/dist/client-4VMFEFDX.js +0 -22
- package/dist/config-2KK5GX42.js +0 -27
- 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
|
+
}
|
package/src/web/watch.ts
ADDED
|
@@ -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
|
+
}
|