mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-03 17:52:00 +03:00
perf(tui): reduce cold-start latency
This commit is contained in:
+1
-1
@@ -9,7 +9,7 @@ bun run --cwd tui test
|
||||
bun run --cwd tui build
|
||||
```
|
||||
|
||||
`nanobot agent` launches this client, leases the shared local gateway or starts it on demand, and passes an authenticated local endpoint through environment variables. Other terminals and the WebUI keep that gateway alive; the final interactive launcher to exit releases the on-demand process. Only `nanobot gateway --background` makes it persistent without clients. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified archive that keeps the executable together with its licenses, notices, corresponding application source, source offer, and relinking instructions. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`.
|
||||
`nanobot agent` launches this client, leases the shared local gateway or starts it on demand, and passes the local bootstrap endpoint through environment variables. The client paints before gateway readiness, retries bootstrap in the background, and obtains fresh WebSocket and REST credentials for each connection. Other terminals and the WebUI keep that gateway alive; the final interactive launcher to exit releases the on-demand process. Only `nanobot gateway --background` makes it persistent without clients. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified archive that keeps the executable together with its licenses, notices, corresponding application source, source offer, and relinking instructions. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`.
|
||||
|
||||
Standalone terminals use OpenTUI's retained full-screen layout: the transcript reflows with the terminal while the composer stays fixed at the bottom. Mouse and keyboard scrolling operate inside the transcript, and leaving the TUI restores the previous terminal screen.
|
||||
|
||||
|
||||
@@ -37,7 +37,9 @@ def _wait_for(process: Any, needle: str, timeout: float) -> str:
|
||||
output.append(_read(process, min(0.1, deadline - time.monotonic())))
|
||||
text = "".join(output)
|
||||
if needle not in text:
|
||||
raise AssertionError(f"terminal output did not contain {needle!r}")
|
||||
raise AssertionError(
|
||||
f"terminal output did not contain {needle!r}; recent output: {text[-2000:]!r}"
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
@@ -47,7 +49,7 @@ def _wait_for_exit(process: Any, timeout: float) -> int:
|
||||
time.sleep(0.05)
|
||||
if process.isalive():
|
||||
process.close(force=True)
|
||||
raise AssertionError("TUI did not exit after Ctrl+C")
|
||||
raise AssertionError("TUI did not exit after the exit command")
|
||||
return int(process.exitstatus or 0)
|
||||
|
||||
|
||||
@@ -78,6 +80,8 @@ def main() -> int:
|
||||
# width probes. Keep this smoke test focused on application behavior.
|
||||
"OPENTUI_FORCE_EXPLICIT_WIDTH": "false",
|
||||
}
|
||||
env.pop("HERDR_ENV", None)
|
||||
env.pop("HERDR_PANE_ID", None)
|
||||
process = PtyProcess.spawn(
|
||||
[bun, "src/index.ts"],
|
||||
cwd=str(ROOT),
|
||||
@@ -100,11 +104,11 @@ def main() -> int:
|
||||
if "\x1b[18;" not in resized or ";42H" not in resized:
|
||||
raise AssertionError("TUI did not repaint to the resized ConPTY dimensions")
|
||||
|
||||
# First Ctrl+C clears the draft. The second exits the app and must
|
||||
# restore the alternate screen without an unhandled exception.
|
||||
# Ctrl+C clears the draft. The local exit command must still work while
|
||||
# the intentionally unavailable gateway has not attached a chat.
|
||||
process.sendcontrol("c")
|
||||
output.append(_read(process, 0.2))
|
||||
process.sendcontrol("c")
|
||||
process.write("exit\r")
|
||||
output.append(_wait_for(process, LEAVE_ALT_SCREEN, 8))
|
||||
exit_code = _wait_for_exit(process, 8)
|
||||
finally:
|
||||
|
||||
@@ -68,7 +68,7 @@ def _wait_for_exit(pid: int, timeout: float) -> int:
|
||||
time.sleep(0.05)
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
os.waitpid(pid, 0)
|
||||
raise AssertionError("TUI did not exit after Ctrl+C")
|
||||
raise AssertionError("TUI did not exit after the exit command")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -90,6 +90,8 @@ def main() -> int:
|
||||
# terminal emulator's optional OSC 10/11 response.
|
||||
"NANOBOT_TUI_THEME": "dark",
|
||||
}
|
||||
env.pop("HERDR_ENV", None)
|
||||
env.pop("HERDR_PANE_ID", None)
|
||||
|
||||
pid, master = pty.fork()
|
||||
if pid == 0:
|
||||
@@ -117,11 +119,11 @@ def main() -> int:
|
||||
if b"\x1b[18;" not in resized or b";42H" not in resized:
|
||||
raise AssertionError("TUI did not repaint to the resized PTY dimensions")
|
||||
|
||||
# First Ctrl+C clears the draft; the second exits and must restore the
|
||||
# alternate screen without a prompt_toolkit-style traceback.
|
||||
# Ctrl+C clears the draft. The local exit command must still work while
|
||||
# the intentionally unavailable gateway has not attached a chat.
|
||||
os.write(master, b"\x03")
|
||||
output.extend(_read(master, 0.2))
|
||||
os.write(master, b"\x03")
|
||||
os.write(master, b"exit\r")
|
||||
output.extend(_wait_for(master, LEAVE_ALT_SCREEN, 5))
|
||||
exit_code = _wait_for_exit(pid, 5)
|
||||
reaped = True
|
||||
|
||||
@@ -1488,7 +1488,9 @@ describe("NanobotTui layout", () => {
|
||||
test("overlaps automatic terminal detection with connection startup", async () => {
|
||||
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
||||
let connected = false
|
||||
let rendered = false
|
||||
let resolveMode: (mode: "light") => void = () => undefined
|
||||
setup.renderer.start = () => { rendered = true }
|
||||
setup.renderer.waitForThemeMode = () => new Promise((resolve) => {
|
||||
resolveMode = resolve
|
||||
})
|
||||
@@ -1505,6 +1507,7 @@ describe("NanobotTui layout", () => {
|
||||
const starting = app.start()
|
||||
await Bun.sleep(1)
|
||||
expect(connected).toBe(true)
|
||||
expect(rendered).toBe(true)
|
||||
|
||||
resolveMode("light")
|
||||
await starting
|
||||
@@ -1994,6 +1997,26 @@ describe("NanobotTui layout", () => {
|
||||
expect(closed).toBe(true)
|
||||
expect(setup.renderer.isDestroyed).toBe(true)
|
||||
})
|
||||
|
||||
test("accepts the exit command before the gateway connection is ready", async () => {
|
||||
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
||||
let closed = false
|
||||
const transport = client()
|
||||
transport.close = () => { closed = true }
|
||||
const app = NanobotTui.mount(
|
||||
setup.renderer,
|
||||
options,
|
||||
transport,
|
||||
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
||||
)
|
||||
const composer = (app as unknown as { composer: TextareaRenderable }).composer
|
||||
|
||||
composer.setText("exit")
|
||||
composer.submit()
|
||||
await waitUntil(() => closed)
|
||||
|
||||
expect(setup.renderer.isDestroyed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("NanobotTui in a Herdr pane", () => {
|
||||
|
||||
+41
-12
@@ -21,6 +21,7 @@ import {
|
||||
import {
|
||||
NanobotClient,
|
||||
fetchHistory,
|
||||
fetchGatewayConnection,
|
||||
fetchMentionCandidates,
|
||||
fetchSessionContext,
|
||||
fetchSessions,
|
||||
@@ -78,7 +79,9 @@ import {
|
||||
import { createTuiHost, currentGitBranch, type TuiHost } from "./host"
|
||||
|
||||
interface AppOptions {
|
||||
wsUrl: string
|
||||
wsUrl?: string
|
||||
bootstrapUrl?: string
|
||||
bootstrapSecret?: string
|
||||
apiUrl: string
|
||||
apiToken: string
|
||||
chatId?: string
|
||||
@@ -472,7 +475,23 @@ export class NanobotTui {
|
||||
)
|
||||
this.queuePreview = new QueuePreview(renderer, queuePreviewTheme(this.palette))
|
||||
this.client = client || new NanobotClient({
|
||||
url: options.wsUrl,
|
||||
...(options.bootstrapUrl
|
||||
? {
|
||||
resolveConnection: () => fetchGatewayConnection(
|
||||
options.bootstrapUrl || "",
|
||||
options.bootstrapSecret || "",
|
||||
options.apiUrl,
|
||||
`tui-${process.pid}`,
|
||||
),
|
||||
onConnection: (connection) => this.useGatewayConnection(
|
||||
connection.apiUrl,
|
||||
connection.apiToken,
|
||||
),
|
||||
connectionRetryLabel: "Starting local gateway",
|
||||
reconnectDelayMs: 100,
|
||||
startupRetryMaxDelayMs: 250,
|
||||
}
|
||||
: { url: options.wsUrl }),
|
||||
chatId: options.chatId,
|
||||
onEvent: (event) => this.accept(event),
|
||||
onStatus: (status, detail) => this.handleStatus(status, detail),
|
||||
@@ -719,16 +738,15 @@ export class NanobotTui {
|
||||
void this.loadCommands()
|
||||
void this.loadMentions()
|
||||
this.runtimeControls.preload()
|
||||
this.renderer.start()
|
||||
// OpenTUI learns the real terminal background through OSC 10/11. Wait for
|
||||
// that bounded probe before first paint, as OpenCode does, so a light
|
||||
// terminal does not briefly render the dark palette. The app already owns
|
||||
// the renderer here, so a signal during the probe can still restore it.
|
||||
// that bounded probe after first paint. The neutral terminal background is
|
||||
// safe to render immediately, and the detected palette can be applied later.
|
||||
if (this.options.theme === "auto") await this.renderer.waitForThemeMode(1_000)
|
||||
if (this.quitting) return
|
||||
if (this.options.theme === "auto" && this.renderer.themeMode) {
|
||||
this.applyTheme(this.renderer.themeMode)
|
||||
}
|
||||
this.renderer.start()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
@@ -775,6 +793,10 @@ export class NanobotTui {
|
||||
return
|
||||
}
|
||||
if (!visibleContent) return
|
||||
if (["exit", "quit", "/exit", "/quit", ":q"].includes(visibleContent.toLowerCase())) {
|
||||
this.quit()
|
||||
return
|
||||
}
|
||||
const completion = this.commandMenu.completion(visibleContent)
|
||||
if (completion) {
|
||||
this.setComposer(completion)
|
||||
@@ -804,10 +826,6 @@ export class NanobotTui {
|
||||
this.status.content = "Preparing chat…"
|
||||
return
|
||||
}
|
||||
if (["exit", "quit", "/exit", "/quit", ":q"].includes(visibleContent.toLowerCase())) {
|
||||
this.quit()
|
||||
return
|
||||
}
|
||||
const prompt = { content, options: mentionOptions(content, this.availableMentions()) }
|
||||
if (this.activeTurn) {
|
||||
this.sendPrompt(prompt, true)
|
||||
@@ -1132,6 +1150,14 @@ export class NanobotTui {
|
||||
for (const event of events || []) this.accept(event)
|
||||
}
|
||||
|
||||
private useGatewayConnection(apiUrl: string, apiToken: string): void {
|
||||
this.options.apiUrl = apiUrl
|
||||
this.options.apiToken = apiToken
|
||||
this.runtimeControls.useApiConnection(apiUrl, apiToken)
|
||||
void this.loadCommands()
|
||||
void this.loadMentions()
|
||||
}
|
||||
|
||||
private handleStatus(status: ConnectionStatus, detail?: string): void {
|
||||
if (status === "connected") {
|
||||
this.ready = false
|
||||
@@ -1141,9 +1167,12 @@ export class NanobotTui {
|
||||
}
|
||||
if (status === "connecting") {
|
||||
this.ready = false
|
||||
this.host.reportState("unknown", detail ? "Reconnecting" : "Connecting")
|
||||
const label = detail === "Starting local gateway"
|
||||
? detail
|
||||
: detail ? "Reconnecting" : "Connecting"
|
||||
this.host.reportState("unknown", label)
|
||||
if (detail) this.setActive(false)
|
||||
this.status.content = detail ? "Reconnecting…" : "Connecting…"
|
||||
this.status.content = `${label}…`
|
||||
return
|
||||
}
|
||||
if (status === "error") {
|
||||
|
||||
+11
-7
@@ -1,12 +1,6 @@
|
||||
import { NanobotTui, type AppOptions } from "./app"
|
||||
import { currentGitBranch } from "./host"
|
||||
|
||||
function required(name: string): string {
|
||||
const value = process.env[name]?.trim()
|
||||
if (!value) throw new Error(`${name} is required`)
|
||||
return value
|
||||
}
|
||||
|
||||
function themePreference(): AppOptions["theme"] {
|
||||
const value = process.env.NANOBOT_TUI_THEME?.trim() || "auto"
|
||||
if (value === "auto" || value === "dark" || value === "light") return value
|
||||
@@ -15,8 +9,18 @@ function themePreference(): AppOptions["theme"] {
|
||||
|
||||
const workspace = process.env.NANOBOT_TUI_WORKSPACE?.trim() || ""
|
||||
const hostWorkspace = process.cwd()
|
||||
const bootstrapUrl = process.env.NANOBOT_TUI_BOOTSTRAP_URL?.trim() || ""
|
||||
const wsUrl = process.env.NANOBOT_TUI_WS_URL?.trim() || ""
|
||||
if (!bootstrapUrl && !wsUrl) {
|
||||
throw new Error("NANOBOT_TUI_BOOTSTRAP_URL or NANOBOT_TUI_WS_URL is required")
|
||||
}
|
||||
const options: AppOptions = {
|
||||
wsUrl: required("NANOBOT_TUI_WS_URL"),
|
||||
...(bootstrapUrl
|
||||
? {
|
||||
bootstrapUrl,
|
||||
bootstrapSecret: process.env.NANOBOT_TUI_BOOTSTRAP_SECRET?.trim() || "",
|
||||
}
|
||||
: { wsUrl }),
|
||||
apiUrl: process.env.NANOBOT_TUI_API_URL?.trim() || "",
|
||||
apiToken: process.env.NANOBOT_TUI_API_TOKEN?.trim() || "",
|
||||
chatId: process.env.NANOBOT_TUI_CHAT_ID?.trim() || undefined,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"
|
||||
|
||||
import {
|
||||
NanobotClient,
|
||||
GatewayConnectionError,
|
||||
fetchGatewayConnection,
|
||||
fetchHistory,
|
||||
fetchMentionCandidates,
|
||||
fetchRuntimeControls,
|
||||
@@ -37,6 +39,156 @@ class FakeSocket {
|
||||
}
|
||||
|
||||
describe("gateway protocol", () => {
|
||||
test("bootstraps fresh websocket and API credentials", async () => {
|
||||
const original = globalThis.fetch
|
||||
let headers: Headers | undefined
|
||||
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
headers = new Headers(init?.headers)
|
||||
return new Response(JSON.stringify({
|
||||
ws_url: "ws://nanobot.test/ws?mode=local",
|
||||
token: "socket token",
|
||||
api_token: "api-token",
|
||||
}))
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const connection = await fetchGatewayConnection(
|
||||
"http://nanobot.test/webui/bootstrap",
|
||||
"bootstrap-secret",
|
||||
"http://nanobot.test",
|
||||
"tui-42",
|
||||
)
|
||||
expect(headers?.get("X-Nanobot-Auth")).toBe("bootstrap-secret")
|
||||
expect(connection).toEqual({
|
||||
wsUrl: "ws://nanobot.test/ws?mode=local&token=socket+token&client_id=tui-42",
|
||||
apiUrl: "http://nanobot.test",
|
||||
apiToken: "api-token",
|
||||
})
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects malformed bootstrap responses without retrying", async () => {
|
||||
const original = globalThis.fetch
|
||||
globalThis.fetch = (() => Promise.resolve(new Response("not json"))) as unknown as typeof fetch
|
||||
|
||||
try {
|
||||
await expect(fetchGatewayConnection(
|
||||
"http://nanobot.test/webui/bootstrap",
|
||||
"bootstrap-secret",
|
||||
"http://nanobot.test",
|
||||
"tui-42",
|
||||
)).rejects.toMatchObject({
|
||||
message: "gateway bootstrap response is invalid",
|
||||
retryable: false,
|
||||
})
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
})
|
||||
|
||||
test("waits for bootstrap before opening the websocket", async () => {
|
||||
const original = globalThis.WebSocket
|
||||
let resolveConnection: ((value: {
|
||||
wsUrl: string
|
||||
apiUrl: string
|
||||
apiToken: string
|
||||
}) => void) | undefined
|
||||
let requestedUrl = ""
|
||||
Object.defineProperty(globalThis, "WebSocket", {
|
||||
configurable: true,
|
||||
value: class extends FakeSocket {
|
||||
constructor(url: string) {
|
||||
super()
|
||||
requestedUrl = url
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const connections: string[] = []
|
||||
const client = new NanobotClient({
|
||||
resolveConnection: () => new Promise((resolve) => { resolveConnection = resolve }),
|
||||
onConnection: (connection) => connections.push(connection.apiToken),
|
||||
onEvent: () => undefined,
|
||||
onStatus: () => undefined,
|
||||
})
|
||||
client.connect()
|
||||
await Bun.sleep(1)
|
||||
expect(requestedUrl).toBe("")
|
||||
resolveConnection?.({
|
||||
wsUrl: "ws://nanobot.test/ws?token=fresh",
|
||||
apiUrl: "http://nanobot.test",
|
||||
apiToken: "fresh-api-token",
|
||||
})
|
||||
await Bun.sleep(1)
|
||||
expect(requestedUrl).toBe("ws://nanobot.test/ws?token=fresh")
|
||||
expect(connections).toEqual(["fresh-api-token"])
|
||||
client.close()
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
|
||||
}
|
||||
})
|
||||
|
||||
test("retries bootstrap while the local gateway starts", async () => {
|
||||
const original = globalThis.WebSocket
|
||||
let attempts = 0
|
||||
let requestedUrl = ""
|
||||
Object.defineProperty(globalThis, "WebSocket", {
|
||||
configurable: true,
|
||||
value: class extends FakeSocket {
|
||||
constructor(url: string) {
|
||||
super()
|
||||
requestedUrl = url
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const client = new NanobotClient({
|
||||
resolveConnection: async () => {
|
||||
attempts += 1
|
||||
if (attempts === 1) throw new Error("gateway is still starting")
|
||||
return {
|
||||
wsUrl: "ws://nanobot.test/ws?token=second",
|
||||
apiUrl: "http://nanobot.test",
|
||||
apiToken: "second-api-token",
|
||||
}
|
||||
},
|
||||
reconnectDelayMs: 1,
|
||||
onEvent: () => undefined,
|
||||
onStatus: () => undefined,
|
||||
})
|
||||
client.connect()
|
||||
for (let index = 0; index < 20 && !requestedUrl; index += 1) await Bun.sleep(2)
|
||||
expect(attempts).toBe(2)
|
||||
expect(requestedUrl).toBe("ws://nanobot.test/ws?token=second")
|
||||
client.close()
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
|
||||
}
|
||||
})
|
||||
|
||||
test("reports a permanent bootstrap rejection without retrying", async () => {
|
||||
let attempts = 0
|
||||
const statuses: string[] = []
|
||||
const client = new NanobotClient({
|
||||
resolveConnection: async () => {
|
||||
attempts += 1
|
||||
throw new GatewayConnectionError("gateway bootstrap failed: HTTP 401", false)
|
||||
},
|
||||
reconnectDelayMs: 1,
|
||||
onEvent: () => undefined,
|
||||
onStatus: (status, detail) => statuses.push(`${status}:${detail || ""}`),
|
||||
})
|
||||
client.connect()
|
||||
await Bun.sleep(5)
|
||||
expect(attempts).toBe(1)
|
||||
expect(statuses.at(-1)).toBe("error:gateway bootstrap failed: HTTP 401")
|
||||
client.close()
|
||||
})
|
||||
|
||||
test("represents lifecycle frames without browser state", () => {
|
||||
const events: InboundEvent[] = [
|
||||
{
|
||||
|
||||
+110
-9
@@ -151,13 +151,30 @@ type OutboundEvent =
|
||||
}
|
||||
|
||||
export interface ClientOptions {
|
||||
url: string
|
||||
url?: string
|
||||
resolveConnection?: () => Promise<GatewayConnection>
|
||||
onConnection?: (connection: GatewayConnection) => void
|
||||
connectionRetryLabel?: string
|
||||
startupRetryMaxDelayMs?: number
|
||||
chatId?: string
|
||||
reconnectDelayMs?: number
|
||||
onEvent: (event: InboundEvent) => void
|
||||
onStatus: (status: ConnectionStatus, detail?: string) => void
|
||||
}
|
||||
|
||||
export interface GatewayConnection {
|
||||
wsUrl: string
|
||||
apiUrl: string
|
||||
apiToken: string
|
||||
}
|
||||
|
||||
export class GatewayConnectionError extends Error {
|
||||
constructor(message: string, readonly retryable: boolean) {
|
||||
super(message)
|
||||
this.name = "GatewayConnectionError"
|
||||
}
|
||||
}
|
||||
|
||||
export interface HistoryMessage {
|
||||
role: "user" | "assistant" | "activity"
|
||||
content: string
|
||||
@@ -747,12 +764,63 @@ function sessionLabelForMention(session: SessionSummary): string {
|
||||
return (session.title || session.preview || "Untitled chat").replace(/\s+/gu, " ").trim()
|
||||
}
|
||||
|
||||
/** Resolve fresh short-lived credentials once the local gateway is reachable. */
|
||||
export async function fetchGatewayConnection(
|
||||
bootstrapUrl: string,
|
||||
bootstrapSecret: string,
|
||||
apiUrl: string,
|
||||
clientId: string,
|
||||
): Promise<GatewayConnection> {
|
||||
const response = await fetch(bootstrapUrl, {
|
||||
headers: bootstrapSecret ? { "X-Nanobot-Auth": bootstrapSecret } : {},
|
||||
})
|
||||
if (!response.ok) {
|
||||
const retryable = response.status === 408 || response.status === 429 || response.status >= 500
|
||||
throw new GatewayConnectionError(
|
||||
`gateway bootstrap failed: HTTP ${response.status}`,
|
||||
retryable,
|
||||
)
|
||||
}
|
||||
let payload: unknown
|
||||
try {
|
||||
payload = await response.json()
|
||||
} catch {
|
||||
throw new GatewayConnectionError("gateway bootstrap response is invalid", false)
|
||||
}
|
||||
if (!isRecord(payload)) {
|
||||
throw new GatewayConnectionError("gateway bootstrap response is invalid", false)
|
||||
}
|
||||
if (typeof payload.ws_url !== "string" || !payload.ws_url.trim()) {
|
||||
throw new GatewayConnectionError("gateway bootstrap response is missing ws_url", false)
|
||||
}
|
||||
let wsUrl: URL
|
||||
try {
|
||||
wsUrl = new URL(payload.ws_url)
|
||||
} catch {
|
||||
throw new GatewayConnectionError("gateway bootstrap response has an invalid ws_url", false)
|
||||
}
|
||||
if (wsUrl.protocol !== "ws:" && wsUrl.protocol !== "wss:") {
|
||||
throw new GatewayConnectionError("gateway bootstrap response has an invalid ws_url", false)
|
||||
}
|
||||
if (typeof payload.token === "string" && payload.token) {
|
||||
wsUrl.searchParams.append("token", payload.token)
|
||||
}
|
||||
wsUrl.searchParams.append("client_id", clientId)
|
||||
return {
|
||||
wsUrl: wsUrl.toString(),
|
||||
apiUrl,
|
||||
apiToken: typeof payload.api_token === "string" ? payload.api_token : "",
|
||||
}
|
||||
}
|
||||
|
||||
export class NanobotClient {
|
||||
private socket: WebSocket | null = null
|
||||
private chatId = ""
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private reconnectAttempt = 0
|
||||
private closedByClient = false
|
||||
private opening = false
|
||||
private connectedOnce = false
|
||||
|
||||
constructor(private readonly options: ClientOptions) {}
|
||||
|
||||
@@ -762,16 +830,46 @@ export class NanobotClient {
|
||||
|
||||
connect(): void {
|
||||
this.closedByClient = false
|
||||
this.open()
|
||||
void this.open()
|
||||
}
|
||||
|
||||
private open(): void {
|
||||
if (this.socket) return
|
||||
private async open(): Promise<void> {
|
||||
if (this.socket || this.opening || this.closedByClient) return
|
||||
this.opening = true
|
||||
this.options.onStatus("connecting")
|
||||
const socket = new WebSocket(this.options.url)
|
||||
let url = this.options.url
|
||||
try {
|
||||
if (this.options.resolveConnection) {
|
||||
const connection = await this.options.resolveConnection()
|
||||
if (this.closedByClient) return
|
||||
this.options.onConnection?.(connection)
|
||||
url = connection.wsUrl
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.closedByClient) {
|
||||
if (error instanceof GatewayConnectionError && !error.retryable) {
|
||||
this.options.onStatus("error", error.message)
|
||||
return
|
||||
}
|
||||
this.options.onStatus(
|
||||
"connecting",
|
||||
this.options.connectionRetryLabel || "gateway unavailable",
|
||||
)
|
||||
this.scheduleReconnect(false)
|
||||
}
|
||||
return
|
||||
} finally {
|
||||
this.opening = false
|
||||
}
|
||||
if (!url) {
|
||||
this.options.onStatus("error", "gateway URL is not configured")
|
||||
return
|
||||
}
|
||||
const socket = new WebSocket(url)
|
||||
this.socket = socket
|
||||
socket.addEventListener("open", () => {
|
||||
if (this.socket !== socket) return
|
||||
this.connectedOnce = true
|
||||
this.reconnectAttempt = 0
|
||||
this.options.onStatus("connected")
|
||||
})
|
||||
@@ -872,14 +970,17 @@ export class NanobotClient {
|
||||
this.options.onEvent(event)
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
private scheduleReconnect(announce = true): void {
|
||||
if (this.reconnectTimer || this.closedByClient) return
|
||||
const base = this.options.reconnectDelayMs ?? 500
|
||||
const delay = Math.min(8_000, base * 2 ** Math.min(this.reconnectAttempt++, 4))
|
||||
this.options.onStatus("connecting", `reconnecting in ${delay}ms`)
|
||||
const maxDelay = this.connectedOnce
|
||||
? 8_000
|
||||
: this.options.startupRetryMaxDelayMs ?? 8_000
|
||||
const delay = Math.min(maxDelay, base * 2 ** Math.min(this.reconnectAttempt++, 4))
|
||||
if (announce) this.options.onStatus("connecting", `reconnecting in ${delay}ms`)
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.open()
|
||||
void this.open()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,14 @@ export class RuntimeControls {
|
||||
void this.load().catch(() => {})
|
||||
}
|
||||
|
||||
useApiConnection(apiUrl: string, apiToken: string): void {
|
||||
this.options.apiUrl = apiUrl
|
||||
this.options.apiToken = apiToken
|
||||
this.controlsLoaded = false
|
||||
this.controlsLoadedAt = 0
|
||||
this.preload()
|
||||
}
|
||||
|
||||
updateWorkspaceScope(scope: WorkspaceScopePayload): void {
|
||||
this.scope = scope
|
||||
this.render()
|
||||
|
||||
Reference in New Issue
Block a user