ava-engine 0.1.0 → 0.2.0

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/README.md CHANGED
@@ -11,6 +11,8 @@
11
11
  </p>
12
12
 
13
13
  <p align="center">
14
+ <a href="https://www.npmjs.com/package/ava-engine"><img alt="npm" src="https://img.shields.io/npm/v/ava-engine?color=0A0A0C&label=npm"></a>
15
+ <a href="https://scorecard.dev/viewer/?uri=github.com/Mr-vero/ava-engine"><img alt="OpenSSF Scorecard" src="https://api.scorecard.dev/projects/github.com/Mr-vero/ava-engine/badge"></a>
14
16
  <a href="https://github.com/Mr-vero/ava-engine/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/Mr-vero/ava-engine/actions/workflows/ci.yml/badge.svg"></a>
15
17
  <a href="COMMERCIAL-LICENSE.md"><img alt="License: Ava Engine License (source-available)" src="https://img.shields.io/badge/license-source--available-0A0A0C"></a>
16
18
  <img alt="Node 22.9+" src="https://img.shields.io/badge/node-22.9%2B-4F46E5">
@@ -56,25 +58,25 @@ Requires **Node 22.9+** and desktop **Chrome**. One command installs Ava and wal
56
58
  npx ava-engine
57
59
  ```
58
60
 
59
- The setup wizard:
60
- 1. checks your OpenRouter key (or lets you look around with no keys in MOCK mode);
61
- 2. lets you pick Ava's voice;
62
- 3. downloads offline speech recognition, with a progress bar;
63
- 4. protects Ava with a password if other people will use it;
64
- 5. starts Ava and opens **http://localhost:5190/app**.
61
+ Set up **in your browser** (a step-by-step page) or **in the terminal**; both do the same:
62
+ 1. check your OpenRouter key (or look around with no keys, in MOCK mode);
63
+ 2. pick Ava's voice, and download the free offline voice (Kokoro, ~90 MB) with progress;
64
+ 3. download offline speech recognition, also with progress;
65
+ 4. protect Ava with a password if other people will use it;
66
+ 5. start Ava and open **http://localhost:5190/app**.
65
67
 
66
68
  After that, `npx ava-engine` just starts Ava.
67
69
 
68
70
  | Command | What it does |
69
71
  | --- | --- |
70
- | `npx ava-engine setup` | Re-run the wizard to change keys, voice, password or port |
72
+ | `npx ava-engine setup` | Re-run setup to change keys, voice, password or port (`--web` for the browser, `--terminal` for here) |
71
73
  | `npx ava-engine doctor` | Check everything and get a fix for anything that's wrong |
72
- | `npx ava-engine models` | Add or remove offline speech models |
74
+ | `npx ava-engine models` | Download speech models or the Kokoro voice (`models kokoro`) |
73
75
  | `npx ava-engine start --port 8080` | Start on another port |
74
76
 
75
77
  Settings live in `~/.ava-engine` (set `AVA_HOME` to move them). **Watch the demo locally** at `http://localhost:5190/app?demo=simple`.
76
78
 
77
- **For servers and scripts:** `OPENROUTER_API_KEY=… ACCESS_PASSWORD=… npx ava-engine setup --yes --models english-small` sets everything up without questions. There's also a Docker image; see [DEPLOY.md](DEPLOY.md).
79
+ **On a server:** `npx ava-engine setup --web` works over SSH too. Forward the port (`ssh -L 5190:localhost:5190 your-server`) and open the link it prints. Or skip the questions: `OPENROUTER_API_KEY=… ACCESS_PASSWORD=… npx ava-engine setup --yes --models english-small,kokoro`. There's also a Docker image; see [DEPLOY.md](DEPLOY.md).
78
80
 
79
81
  <details>
80
82
  <summary><b>Working on Ava itself? Run from source</b></summary>
package/lib/asr.mjs CHANGED
@@ -76,7 +76,11 @@ function getOffline() {
76
76
  }
77
77
 
78
78
  export function warmAsr() {
79
- if (!asrAvailable()) { console.log(" sherpa ASR: no models found at", MODELS); return; }
79
+ if (!asrAvailable()) {
80
+ if (initErr) console.log(" speech recognition: the offline engine didn't load; using the browser's (run `npx ava-engine doctor`)");
81
+ else console.log(" speech recognition: using the browser's. For offline recognition: npx ava-engine models");
82
+ return;
83
+ }
80
84
  const e = availableEngines();
81
85
  if (e.includes("zipformer") && getOnline()) console.log(" sherpa ASR ready — English streaming (zipformer)");
82
86
  if (e.includes("sensevoice")) console.log(" sherpa ASR ready — multilingual (SenseVoice), lazy-loaded on first use");
@@ -0,0 +1,58 @@
1
+ // Turning setup answers into saved settings. Shared by the terminal wizard and
2
+ // the browser setup, so both produce exactly the same configuration.
3
+ import { MODELS_DIR, DEFAULT_PORT } from "./config.mjs";
4
+
5
+ export const VOICES = {
6
+ kokoro: { label: "Kokoro", hint: "free, runs on this computer (~90 MB download)" },
7
+ elevenlabs: { label: "ElevenLabs", hint: "premium cloud voices, needs an ElevenLabs key" },
8
+ browser: { label: "The browser's voice", hint: "no setup, sounds robotic" },
9
+ };
10
+
11
+ /**
12
+ * answers: {
13
+ * openrouterKey?: string | null (null/"" → MOCK mode; undefined → keep the current key)
14
+ * voice: "kokoro" | "elevenlabs" | "browser",
15
+ * elevenKey?, elevenVoice?,
16
+ * audience: "me" | "others", password?, proxy?: boolean,
17
+ * port?: number
18
+ * }
19
+ */
20
+ export function buildConfig(prev, a) {
21
+ const cfg = { ...prev };
22
+ const set = (k, v) => { if (v === undefined || v === null || v === "") delete cfg[k]; else cfg[k] = String(v); };
23
+
24
+ if (a.openrouterKey !== undefined) set("OPENROUTER_API_KEY", a.openrouterKey);
25
+
26
+ const voice = VOICES[a.voice] ? a.voice : "kokoro";
27
+ set("TTS_DEFAULT", voice);
28
+ set("KOKORO", voice === "kokoro" ? "on" : "off");
29
+ if (voice === "elevenlabs") {
30
+ if (a.elevenKey !== undefined) set("ELEVENLABS_API_KEY", a.elevenKey);
31
+ if (a.elevenVoice !== undefined) set("ELEVEN_VOICE_ID", a.elevenVoice);
32
+ }
33
+
34
+ if (a.audience === "others") {
35
+ set("HOST", "");
36
+ if (a.password !== undefined) set("ACCESS_PASSWORD", a.password);
37
+ set("TRUST_PROXY", a.proxy ? "1" : "");
38
+ } else {
39
+ set("HOST", "127.0.0.1");
40
+ set("ACCESS_PASSWORD", "");
41
+ set("TRUST_PROXY", "");
42
+ }
43
+
44
+ const port = Number(a.port);
45
+ set("PORT", Number.isInteger(port) && port > 0 && port < 65536 ? port : prev.PORT || DEFAULT_PORT);
46
+ set("AVA_MODELS_DIR", MODELS_DIR);
47
+ return cfg;
48
+ }
49
+
50
+ /** Problems that must be fixed before saving, as a list of human messages. */
51
+ export function validateAnswers(a) {
52
+ const errors = [];
53
+ if (a.audience === "others" && (!a.password || String(a.password).length < 10)) errors.push("Choose an access password of at least 10 characters.");
54
+ if (a.voice === "elevenlabs" && a.elevenKey === "") errors.push("Add your ElevenLabs key, or pick another voice.");
55
+ const port = Number(a.port);
56
+ if (a.port !== undefined && !(Number.isInteger(port) && port > 0 && port < 65536)) errors.push("The port must be a number between 1 and 65535.");
57
+ return errors;
58
+ }
package/lib/cli/main.mjs CHANGED
@@ -14,17 +14,19 @@ const HELP = `
14
14
 
15
15
  Usage
16
16
  npx ava-engine first run: setup wizard, then start. After that: start
17
- npx ava-engine setup (re)run the setup wizard
17
+ npx ava-engine setup (re)run setup; asks: in the browser or the terminal
18
+ npx ava-engine setup --web setup in your browser
19
+ npx ava-engine setup --terminal
18
20
  npx ava-engine start start the server
19
21
  npx ava-engine doctor check your setup and tell you what to fix
20
- npx ava-engine models [..] download speech models: ${Object.keys(MODEL_PACKS).join(", ")}
22
+ npx ava-engine models [..] download models: ${Object.keys(MODEL_PACKS).join(", ")}, kokoro (voice)
21
23
 
22
24
  Options
23
25
  --port <n> port to listen on (default ${DEFAULT_PORT})
24
26
  --open open the browser once Ava is up (default on a first run)
25
27
  --no-open don't open the browser
26
28
  --yes setup without questions, from environment variables
27
- --models <a,b> with --yes: model packs to download
29
+ --models <a,b> with --yes: what to download, e.g. english-small,kokoro
28
30
  -v, --version print the version
29
31
  -h, --help show this help
30
32
 
@@ -40,6 +42,8 @@ function parseArgs(argv) {
40
42
  else if (a === "-v" || a === "--version") out.flags.version = true;
41
43
  else if (a === "--yes" || a === "-y") out.flags.yes = true;
42
44
  else if (a === "--open") out.flags.open = true;
45
+ else if (a === "--web") out.flags.web = true;
46
+ else if (a === "--terminal") out.flags.terminal = true;
43
47
  else if (a === "--no-open") out.flags.open = false;
44
48
  else if (a === "--port") out.flags.port = Number(argv[++i]);
45
49
  else if (a.startsWith("--port=")) out.flags.port = Number(a.slice(7));
@@ -116,7 +120,7 @@ export async function main(argv) {
116
120
  case "doctor": return doctor();
117
121
  case "start": await start({ port: flags.port, open: flags.open === true }); return null; // keeps running
118
122
  case "models": {
119
- const { downloadPacks } = await import("./setup.mjs");
123
+ const { downloadPacks, downloadKokoro } = await import("./setup.mjs");
120
124
  let packs = args._.slice(1);
121
125
  if (!packs.length && interactive) {
122
126
  const p = await import("@clack/prompts");
@@ -126,19 +130,50 @@ export async function main(argv) {
126
130
  packs = sel;
127
131
  }
128
132
  if (!packs.length) packs = ["english-small"];
129
- const bad = packs.filter((x) => !MODEL_PACKS[x]);
130
- if (bad.length) { console.error(`✗ unknown model pack: ${bad.join(", ")}. Choose from: ${Object.keys(MODEL_PACKS).join(", ")}`); return 2; }
131
- await downloadPacks(packs);
133
+ const bad = packs.filter((x) => !MODEL_PACKS[x] && x !== "kokoro");
134
+ if (bad.length) { console.error(`✗ unknown model: ${bad.join(", ")}. Choose from: ${Object.keys(MODEL_PACKS).join(", ")}, kokoro`); return 2; }
135
+ if (packs.includes("kokoro")) await downloadKokoro();
136
+ await downloadPacks(packs.filter((x) => x !== "kokoro"));
132
137
  return 0;
133
138
  }
134
139
  case "setup":
135
140
  case "first-run": {
136
141
  const setup = await import("./setup.mjs");
137
- if (!interactive) {
142
+ // --web needs a browser, not a terminal, so it works over SSH and from scripts too.
143
+ if (!interactive && !(flags.web && !flags.yes)) {
138
144
  await setup.runSetupNonInteractive({ models: flags.models || [], port: flags.port });
139
145
  if (cmd === "first-run") { await start({ port: flags.port, open: flags.open === true }); return null; }
140
146
  return 0;
141
147
  }
148
+ // Where to run the wizard: the browser (default) or this terminal.
149
+ let where = flags.web ? "web" : flags.terminal ? "terminal" : null;
150
+ if (!where) {
151
+ const p = await import("@clack/prompts");
152
+ console.log("");
153
+ p.intro(" ΛVΛ Ava Engine setup ");
154
+ where = await p.select({
155
+ message: "Where would you like to set up Ava?",
156
+ options: [
157
+ { value: "web", label: "In my browser", hint: "recommended; a step-by-step page" },
158
+ { value: "terminal", label: "Here in the terminal" },
159
+ ],
160
+ });
161
+ if (p.isCancel(where)) { p.cancel("Setup cancelled. Nothing was changed."); return 0; }
162
+ }
163
+ if (where === "web") {
164
+ const { runWebSetup } = await import("./setup-server.mjs");
165
+ const r = await runWebSetup({
166
+ preferredPort: flags.port,
167
+ formPort: flags.port,
168
+ onReady: ({ url }) => {
169
+ console.log(`\n Setup is open in your browser. If it didn't open, go to:\n ${url}\n\n (Keep this terminal open. Press Ctrl+C to cancel.)\n`);
170
+ if (flags.open !== false) openBrowser(url);
171
+ },
172
+ });
173
+ console.log(" Settings saved. Starting Ava…");
174
+ await start({ port: r.port, open: false }); // the port chosen on the page wins; the page redirects itself
175
+ return null;
176
+ }
142
177
  const r = await setup.runSetup();
143
178
  if (r.start) { await start({ port: flags.port || r.port, open: flags.open !== false }); return null; }
144
179
  return 0;
@@ -0,0 +1,195 @@
1
+ // `ava-engine setup --web`: the setup wizard as a page in your browser.
2
+ //
3
+ // A small, separate server that runs only during setup:
4
+ // - listens on 127.0.0.1 only,
5
+ // - every API call needs the one-time token from the URL the CLI opens,
6
+ // - rejects requests whose Host isn't localhost (stops DNS-rebinding attacks),
7
+ // - serves a strict Content-Security-Policy.
8
+ // When you press "Launch", it saves the settings, closes, and the CLI starts Ava.
9
+
10
+ import http from "node:http";
11
+ import { readFile } from "node:fs/promises";
12
+ import { join, dirname } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { randomBytes, timingSafeEqual } from "node:crypto";
15
+ import { CONFIG_FILE, MODELS_DIR, DEFAULT_PORT, readConfig, writeConfig, checkOpenRouterKey, checkElevenLabsKey, portFree, suggestPassword } from "./config.mjs";
16
+ import { buildConfig, validateAnswers, VOICES } from "./apply.mjs";
17
+ import { MODEL_PACKS, installedPacks, fetchPack } from "../fetch-models.mjs";
18
+ import { loadKokoro, kokoroInstalled, KOKORO_SIZE } from "../kokoro-model.mjs";
19
+
20
+ const PUBLIC = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "public");
21
+ const STATIC = new Map([
22
+ ["/setup", ["setup.html", "text/html; charset=utf-8"]],
23
+ ["/setup.js", ["setup.js", "text/javascript; charset=utf-8"]],
24
+ ["/setup.css", ["setup.css", "text/css; charset=utf-8"]],
25
+ ["/favicon.svg", ["favicon.svg", "image/svg+xml"]],
26
+ ["/fonts/inter-var.woff2", ["fonts/inter-var.woff2", "font/woff2"]],
27
+ ["/fonts/inter-tight-var.woff2", ["fonts/inter-tight-var.woff2", "font/woff2"]],
28
+ ["/fonts/plex-mono-400.woff2", ["fonts/plex-mono-400.woff2", "font/woff2"]],
29
+ ["/fonts/plex-mono-500.woff2", ["fonts/plex-mono-500.woff2", "font/woff2"]],
30
+ ]);
31
+ const HEADERS = {
32
+ "X-Content-Type-Options": "nosniff",
33
+ "Referrer-Policy": "no-referrer",
34
+ "Cache-Control": "no-store",
35
+ "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:*; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
36
+ };
37
+
38
+ const mask = (v) => (v ? `${v.slice(0, 6)}…${v.slice(-4)}` : "");
39
+
40
+ async function findPort(preferred) {
41
+ for (let p = preferred; p < preferred + 20; p++) if (await portFree(p, "127.0.0.1")) return p;
42
+ return 0; // let the OS pick
43
+ }
44
+
45
+ /**
46
+ * Run the browser setup. Resolves { start: true, port } once you press Launch.
47
+ * onReady({ url }) is called when the page is ready to open.
48
+ */
49
+ export async function runWebSetup({ preferredPort, formPort, onReady } = {}) {
50
+ process.env.AVA_MODELS_DIR ??= MODELS_DIR; // so Kokoro downloads land in ~/.ava-engine/models
51
+ const prev = readConfig() || {};
52
+ const token = randomBytes(24).toString("base64url");
53
+ const jobs = {}; // id → { label, state: "running" | "done" | "error", pct, error }
54
+
55
+ const okToken = (req) => {
56
+ const t = Buffer.from(String(req.headers["x-setup-token"] || ""));
57
+ const want = Buffer.from(token);
58
+ return t.length === want.length && timingSafeEqual(t, want);
59
+ };
60
+
61
+ function startJob(id) {
62
+ if (jobs[id]?.state === "running" || jobs[id]?.state === "done") return;
63
+ if (id === "kokoro") {
64
+ jobs[id] = { label: `Kokoro voice (${KOKORO_SIZE})`, state: "running", pct: 0 };
65
+ if (kokoroInstalled()) { jobs[id] = { ...jobs[id], state: "done", pct: 100 }; return; }
66
+ loadKokoro({ onProgress: ({ pct }) => { jobs[id].pct = pct; } })
67
+ .then(() => { jobs[id].state = "done"; jobs[id].pct = 100; })
68
+ .catch((e) => { jobs[id].state = "error"; jobs[id].error = e.message; });
69
+ return;
70
+ }
71
+ const spec = MODEL_PACKS[id];
72
+ jobs[id] = { label: `${spec.label} (${spec.size})`, state: "running", pct: 0 };
73
+ fetchPack(id, MODELS_DIR, (ev) => {
74
+ if (ev.phase === "download" && ev.total) jobs[id].pct = Math.min(99, Math.floor((ev.done / ev.total) * 100));
75
+ if (ev.phase === "extract") jobs[id].phase = "unpacking";
76
+ }).then(() => { jobs[id].state = "done"; jobs[id].pct = 100; })
77
+ .catch((e) => { jobs[id].state = "error"; jobs[id].error = e.message; });
78
+ }
79
+
80
+ function state() {
81
+ const c = readConfig() || prev;
82
+ return {
83
+ configFile: CONFIG_FILE,
84
+ current: {
85
+ openrouterKey: mask(c.OPENROUTER_API_KEY),
86
+ voice: VOICES[c.TTS_DEFAULT] ? c.TTS_DEFAULT : "kokoro",
87
+ elevenKey: mask(c.ELEVENLABS_API_KEY),
88
+ elevenVoice: c.ELEVEN_VOICE_ID || "",
89
+ audience: c.ACCESS_PASSWORD ? "others" : "me",
90
+ hasPassword: !!c.ACCESS_PASSWORD,
91
+ proxy: c.TRUST_PROXY === "1",
92
+ port: formPort || Number(c.PORT) || DEFAULT_PORT,
93
+ },
94
+ voices: VOICES,
95
+ kokoro: { installed: kokoroInstalled(), size: KOKORO_SIZE },
96
+ packs: Object.fromEntries(Object.entries(MODEL_PACKS).map(([id, m]) => [id, { label: m.label, size: m.size, hint: m.hint }])),
97
+ installedPacks: installedPacks(MODELS_DIR),
98
+ suggestedPassword: suggestPassword(),
99
+ jobs,
100
+ };
101
+ }
102
+
103
+ let finish;
104
+ const finished = new Promise((r) => { finish = r; });
105
+ let listenPort = 0;
106
+
107
+ const server = http.createServer(async (req, res) => {
108
+ const send = (code, body, type = "application/json") => {
109
+ res.writeHead(code, { ...HEADERS, "Content-Type": type });
110
+ res.end(typeof body === "string" || Buffer.isBuffer(body) ? body : JSON.stringify(body));
111
+ };
112
+ // DNS-rebinding guard: only answer requests addressed to this machine.
113
+ const host = String(req.headers.host || "");
114
+ if (!/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(host)) return send(403, { error: "forbidden host" });
115
+ let url;
116
+ try { url = new URL(req.url, "http://localhost"); } catch { return send(400, { error: "bad request" }); }
117
+
118
+ if (req.method === "GET" && STATIC.has(url.pathname)) {
119
+ const [file, type] = STATIC.get(url.pathname);
120
+ try { return send(200, await readFile(join(PUBLIC, file)), type); } catch { return send(404, "not found", "text/plain"); }
121
+ }
122
+ if (req.method === "GET" && url.pathname === "/") { res.writeHead(302, { Location: "/setup" }); return res.end(); }
123
+ if (!url.pathname.startsWith("/api/setup/")) return send(404, "not found", "text/plain");
124
+ if (!okToken(req)) return send(401, { error: "This setup link has expired. Run `npx ava-engine setup --web` for a new one." });
125
+
126
+ let body = {};
127
+ if (req.method === "POST") {
128
+ if (!/^application\/json\b/i.test(String(req.headers["content-type"] || ""))) return send(415, { error: "expected application/json" });
129
+ const chunks = []; let size = 0;
130
+ for await (const c of req) { size += c.length; if (size > 64 * 1024) return send(413, { error: "too large" }); chunks.push(c); }
131
+ try { body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); } catch { return send(400, { error: "invalid JSON" }); }
132
+ }
133
+
134
+ try {
135
+ const route = `${req.method} ${url.pathname}`;
136
+ if (route === "GET /api/setup/state") return send(200, state());
137
+ if (route === "GET /api/setup/jobs") return send(200, { jobs, installedPacks: installedPacks(MODELS_DIR), kokoroInstalled: kokoroInstalled() });
138
+ if (route === "GET /api/setup/password") return send(200, { password: suggestPassword() });
139
+ if (route === "POST /api/setup/check") {
140
+ const key = String(body.key || "").trim();
141
+ if (key.length < 20) return send(200, { ok: false, error: "That doesn't look like a full key." });
142
+ const r = body.service === "elevenlabs" ? await checkElevenLabsKey(key) : await checkOpenRouterKey(key);
143
+ if (r.ok && r.label && /^sk-or/i.test(r.label)) delete r.label; // unnamed keys report a masked key as their label
144
+ return send(200, r);
145
+ }
146
+ if (route === "POST /api/setup/download") {
147
+ const id = String(body.id || "");
148
+ if (id !== "kokoro" && !MODEL_PACKS[id]) return send(400, { error: "unknown download" });
149
+ startJob(id);
150
+ return send(200, { ok: true, job: jobs[id] });
151
+ }
152
+ if (route === "POST /api/setup/save") {
153
+ const a = body.answers || {};
154
+ // "keep" means: leave the saved key as it is.
155
+ const answers = {
156
+ openrouterKey: a.mode === "mock" ? null : a.openrouterKey === "keep" ? undefined : String(a.openrouterKey || "") || null,
157
+ voice: a.voice,
158
+ elevenKey: a.elevenKey === "keep" ? undefined : a.elevenKey,
159
+ elevenVoice: a.elevenVoice,
160
+ audience: a.audience === "others" ? "others" : "me",
161
+ password: a.audience === "others" ? (a.password === "keep" ? prev.ACCESS_PASSWORD || readConfig()?.ACCESS_PASSWORD : a.password) : undefined,
162
+ proxy: !!a.proxy,
163
+ port: a.port,
164
+ };
165
+ const errors = validateAnswers(answers);
166
+ if (errors.length) return send(400, { error: errors.join(" ") });
167
+ const cfg = buildConfig(readConfig() || prev, answers);
168
+ const p = Number(cfg.PORT);
169
+ if (p !== listenPort && !(await portFree(p, cfg.HOST))) return send(409, { error: `Port ${p} is in use by another program. Pick another port.` });
170
+ writeConfig(cfg);
171
+ return send(200, { ok: true, port: p, url: `http://localhost:${p}/app`, configFile: CONFIG_FILE });
172
+ }
173
+ if (route === "POST /api/setup/finish") {
174
+ const cfg = readConfig();
175
+ if (!cfg) return send(400, { error: "Save your settings first." });
176
+ // Downloads still running (e.g. a voice you switched away from) keep going in
177
+ // this process after Ava starts; the page already waited for the ones you chose.
178
+ send(200, { ok: true, port: Number(cfg.PORT) || DEFAULT_PORT });
179
+ // let the response flush, then hand the port over to Ava
180
+ setTimeout(() => { server.closeAllConnections?.(); server.close(() => finish({ start: true, port: Number(cfg.PORT) || DEFAULT_PORT })); }, 150);
181
+ return;
182
+ }
183
+ return send(404, { error: "unknown endpoint" });
184
+ } catch (e) {
185
+ return send(500, { error: e.message });
186
+ }
187
+ });
188
+
189
+ listenPort = await findPort(preferredPort || Number(prev.PORT) || DEFAULT_PORT);
190
+ await new Promise((r) => server.listen(listenPort, "127.0.0.1", r));
191
+ listenPort = server.address().port;
192
+ const url = `http://localhost:${listenPort}/setup#token=${token}`;
193
+ onReady?.({ url, port: listenPort });
194
+ return finished;
195
+ }