Gateway: fallback routes per host, for the laptop's VPN address
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 22s

This commit is contained in:
gib committed 2026-11-01 13:20:02 -05:00
1 parent f46c803ad4
commit 42b6a4920c
8 files changed
+151 -20

No files matched your search

+42 -6
View File
@@ -26,8 +26,11 @@ export type HostStatus =
export interface HostConfig {
id: string;
label: string;
/** The server's HTTP origin, such as `https://t3mac.gbrown.org`. */
url: string;
/**
* The server's HTTP origins, preferred first, such as a LAN route then a VPN route. Every one
* must reach the same T3 server, since they share the environment ID and the token.
*/
urls: string[];
token: string;
environmentId: string;
}
@@ -49,6 +52,8 @@ export interface HostOptions {
const BACKOFF_MIN_MS = 1_000;
const BACKOFF_MAX_MS = 5 * 60_000;
const AUTH_RETRY_MS = 10 * 60_000;
/** How long one route gets to answer before the next is tried. */
const ROUTE_TIMEOUT_MS = 5_000;
export class HostError extends Error {
constructor(
@@ -73,11 +78,17 @@ export class T3Host {
private stopped = false;
private attempt = 0;
private retryTimer: ReturnType<typeof setTimeout> | null = null;
/** The route this connection uses. Chosen afresh, preferred first, on every connect. */
private activeUrl: string;
constructor(
readonly config: HostConfig,
private readonly options: HostOptions = {},
) {}
) {
const [first] = config.urls;
if (!first) throw new Error(`${config.label} has no URL`);
this.activeUrl = first;
}
get id() {
return this.config.id;
@@ -87,6 +98,11 @@ export class T3Host {
return this.config.label;
}
/** The route in use, or the one that will be tried first. */
get url() {
return this.activeUrl;
}
start() {
this.stopped = false;
void this.connect();
@@ -130,7 +146,7 @@ export class T3Host {
}
private async http<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(new URL(path, this.config.url), {
const response = await fetch(new URL(path, this.activeUrl), {
...init,
headers: {
authorization: `Bearer ${this.config.token}`,
@@ -155,7 +171,7 @@ export class T3Host {
if (this.stopped) return;
this.setStatus("connecting", this.lastError);
try {
const descriptor = await this.http<EnvironmentDescriptor>("/.well-known/t3/environment");
const descriptor = await this.pickRoute();
const protocol = descriptor.orchestrationProtocolVersion ?? 1;
if (protocol !== ORCHESTRATION_PROTOCOL_VERSION) {
throw new HostError(`speaks orchestration protocol ${protocol}, the gateway speaks ${ORCHESTRATION_PROTOCOL_VERSION}`, 426);
@@ -164,7 +180,7 @@ export class T3Host {
throw new HostError(`is environment ${descriptor.environmentId}, expected ${this.config.environmentId}`, 409);
}
const { ticket } = await this.http<{ ticket: string }>("/api/auth/websocket-ticket/rpc", { method: "POST" });
const wsUrl = new URL("/ws", this.config.url);
const wsUrl = new URL("/ws", this.activeUrl);
wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
wsUrl.searchParams.set("wsTicket", ticket);
wsUrl.searchParams.set("orchestrationProtocol", String(ORCHESTRATION_PROTOCOL_VERSION));
@@ -187,6 +203,26 @@ export class T3Host {
}
}
/** Uses the first route whose well-known descriptor answers, and returns that descriptor. */
private async pickRoute(): Promise<EnvironmentDescriptor> {
const failures: string[] = [];
for (const url of this.config.urls) {
try {
const response = await fetch(new URL("/.well-known/t3/environment", url), {
signal: AbortSignal.timeout(ROUTE_TIMEOUT_MS),
});
if (!response.ok) throw new Error(`returned ${response.status}`);
const descriptor = (await response.json()) as EnvironmentDescriptor;
if (url !== this.activeUrl) this.options.log?.(`${this.label}: using ${url}`);
this.activeUrl = url;
return descriptor;
} catch (error) {
failures.push(`${new URL(url).host} ${error instanceof Error ? error.message : String(error)}`);
}
}
throw new HostError(`no route answered (${failures.join(", ")})`, 503);
}
private subscribe(socket: RpcSocket) {
const input = this.hasSnapshot && this.sequence !== null ? { afterSequence: this.sequence } : {};
const { done } = socket.stream("orchestration.subscribeShell", input, (value) =>