Gateway: status route with per-route results, test push, reconnect and unpair
This commit is contained in:
1 parent
e6fd2b8f51
commit
9707daf1fe
9 files changed
+398
-55
No files matched your search
+62
-1
@@ -42,6 +42,13 @@ PUT /v1/device/push
|
|||||||
|
|
||||||
Debug builds use `sandbox`, TestFlight and App Store builds use `production`.
|
Debug builds use `sandbox`, TestFlight and App Store builds use `production`.
|
||||||
|
|
||||||
|
To unpair, the watch revokes its own token:
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /v1/device
|
||||||
|
204
|
||||||
|
```
|
||||||
|
|
||||||
## Feed
|
## Feed
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -163,7 +170,7 @@ The new thread uses the project's default model, or the host's, and the host's d
|
|||||||
|
|
||||||
The gateway pushes when a thread starts waiting for you, finishes, or fails. Each push has an
|
The gateway pushes when a thread starts waiting for you, finishes, or fails. Each push has an
|
||||||
`aps.category` and the custom keys `hostId`, `threadId` (absent on `host` pushes), and, for
|
`aps.category` and the custom keys `hostId`, `threadId` (absent on `host` pushes), and, for
|
||||||
approvals and questions, `requestId`.
|
approvals and questions, `requestId`. A `test` push carries no custom keys.
|
||||||
|
|
||||||
| Category | When | Actions the watch registers |
|
| Category | When | Actions the watch registers |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -172,6 +179,60 @@ approvals and questions, `requestId`.
|
|||||||
| `finished` | A turn completed | Reply (text input), Open |
|
| `finished` | A turn completed | Reply (text input), Open |
|
||||||
| `failed` | A turn failed | Open |
|
| `failed` | A turn failed | Open |
|
||||||
| `host` | A host's T3 token expires within 7 days, or was rejected | none |
|
| `host` | A host's T3 token expires within 7 days, or was rejected | none |
|
||||||
|
| `test` | The watch asked for a test push | none |
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Everything the settings screens show: each host and its routes, whether the gateway can push, and
|
||||||
|
whether this watch registered for pushes.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/status
|
||||||
|
|
||||||
|
200 {
|
||||||
|
"hosts": [HostStatusDetail],
|
||||||
|
"gateway": { "startedAt": "...", "pushConfigured": true, "lastPush": PushResult | null },
|
||||||
|
"device": { "name": "Apple Watch", "pairedAt": "...", "pushRegistered": true, "apnsEnvironment": "production" }
|
||||||
|
}
|
||||||
|
|
||||||
|
HostStatusDetail {
|
||||||
|
"id": "...", "label": "Laptop", "status": "online", "error": null,
|
||||||
|
"activeUrl": "http://laptopvpn.gib:3773",
|
||||||
|
"routes": [
|
||||||
|
{ "url": "http://laptop.gib:3773", "kind": "direct", "result": "failed", "error": "no answer in 5 s", "checkedAt": "..." },
|
||||||
|
{ "url": "http://laptopvpn.gib:3773", "kind": "vpn", "result": "ok", "error": null, "checkedAt": "..." },
|
||||||
|
{ "url": "https://t3laptop.gbrown.org", "kind": "public", "result": "untried", "error": null, "checkedAt": null }
|
||||||
|
],
|
||||||
|
"onlineSince": "...", "lastOnlineAt": null, "nextRetryAt": null,
|
||||||
|
"tokenExpiresAt": "2026-12-01T00:00:00.000Z", "serverVersion": "0.1.5"
|
||||||
|
}
|
||||||
|
|
||||||
|
PushResult { "ok": true, "at": "...", "status": 200, "reason": null }
|
||||||
|
```
|
||||||
|
|
||||||
|
`routes` are in the order the gateway tries them, and their results come from its latest connect.
|
||||||
|
A route is `untried` when an earlier one answered first. `kind` is a guess from the URL: `public`
|
||||||
|
for HTTPS, `vpn` when the hostname contains "vpn", and `direct` otherwise. `activeUrl` is null unless
|
||||||
|
the host is online. `nextRetryAt` is set while the gateway waits to try again. `lastPush` is Apple's
|
||||||
|
answer to the most recent push to any watch. A `status` of 0 means the push never reached Apple.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/hosts/:hostId/reconnect
|
||||||
|
202
|
||||||
|
```
|
||||||
|
|
||||||
|
Connects again from the first route, skipping any backoff wait. Use it when a better route may be
|
||||||
|
back, such as the LAN after getting home. The gateway otherwise stays on a working fallback until
|
||||||
|
that connection drops.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/device/test-push
|
||||||
|
|
||||||
|
200 PushResult
|
||||||
|
```
|
||||||
|
|
||||||
|
Sends a test notification to the watch asking and returns Apple's answer. A failure names its cause,
|
||||||
|
for example `BadDeviceToken`, which usually means a push environment mismatch.
|
||||||
|
|
||||||
## Health
|
## Health
|
||||||
|
|
||||||
|
|||||||
+54
-2
@@ -11,19 +11,25 @@ import type {
|
|||||||
ProjectsResponse,
|
ProjectsResponse,
|
||||||
PushRegistration,
|
PushRegistration,
|
||||||
RespondRequest,
|
RespondRequest,
|
||||||
|
PushResult,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
|
StatusResponse,
|
||||||
} from "./contract.ts";
|
} from "./contract.ts";
|
||||||
import type { Db, DeviceRow } from "./db.ts";
|
import type { Db, DeviceRow } from "./db.ts";
|
||||||
import type { Hub } from "./hub.ts";
|
import type { Hub } from "./hub.ts";
|
||||||
|
import type { Pusher } from "./push.ts";
|
||||||
import { HostError, type T3Host } from "./t3/host.ts";
|
import { HostError, type T3Host } from "./t3/host.ts";
|
||||||
import { RpcError } from "./t3/rpc.ts";
|
import { RpcError } from "./t3/rpc.ts";
|
||||||
import type { ApprovalDecision, ModelSelection, ThreadShell } from "./t3/types.ts";
|
import type { ApprovalDecision, ModelSelection, ThreadShell } from "./t3/types.ts";
|
||||||
import { buildFeed, buildSummary, buildThreadDetail, hostSummary, type SummaryCache } from "./views.ts";
|
import { buildFeed, buildSummary, buildThreadDetail, hostStatusDetail, hostSummary, type SummaryCache } from "./views.ts";
|
||||||
|
|
||||||
export interface ApiDeps {
|
export interface ApiDeps {
|
||||||
db: Db;
|
db: Db;
|
||||||
hub: Hub;
|
hub: Hub;
|
||||||
cache: SummaryCache;
|
cache: SummaryCache;
|
||||||
|
pusher: Pusher;
|
||||||
|
/** When the gateway started, for the status screen. */
|
||||||
|
startedAt: string;
|
||||||
log?: (message: string) => void;
|
log?: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +56,7 @@ class HttpError extends Error {
|
|||||||
const json = (body: unknown, status = 200) => Response.json(body, { status });
|
const json = (body: unknown, status = 200) => Response.json(body, { status });
|
||||||
const accepted = () => new Response(null, { status: 202 });
|
const accepted = () => new Response(null, { status: 202 });
|
||||||
|
|
||||||
export function createRoutes({ db, hub, cache, log = () => {} }: ApiDeps) {
|
export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {} }: ApiDeps) {
|
||||||
let pairFailures: number[] = [];
|
let pairFailures: number[] = [];
|
||||||
|
|
||||||
/** Wraps a handler with device auth and error mapping. */
|
/** Wraps a handler with device auth and error mapping. */
|
||||||
@@ -131,6 +137,52 @@ export function createRoutes({ db, hub, cache, log = () => {} }: ApiDeps) {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"/v1/device": {
|
||||||
|
/** Unpairs the watch asking. */
|
||||||
|
DELETE: authed<"/v1/device">((_req, device) => {
|
||||||
|
db.devices.revoke(device.id);
|
||||||
|
log(`unpaired device ${device.name} (${device.id})`);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
"/v1/device/test-push": {
|
||||||
|
POST: authed<"/v1/device/test-push">(async (_req, device) =>
|
||||||
|
json(
|
||||||
|
(await pusher.sendTo(device, {
|
||||||
|
title: "WristCode",
|
||||||
|
body: "Test notification. Pushes reach this watch.",
|
||||||
|
category: "test",
|
||||||
|
data: {},
|
||||||
|
collapseId: "test",
|
||||||
|
})) satisfies PushResult,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
"/v1/status": {
|
||||||
|
GET: authed((_req, device) => {
|
||||||
|
const expiries = new Map(hub.rows().map((row) => [row.id, row.token_expires_at]));
|
||||||
|
return json({
|
||||||
|
hosts: hub.list().map((host) => hostStatusDetail(host, expiries.get(host.id) ?? null)),
|
||||||
|
gateway: { startedAt, pushConfigured: pusher.configured, lastPush: pusher.last },
|
||||||
|
device: {
|
||||||
|
name: device.name,
|
||||||
|
pairedAt: device.created_at,
|
||||||
|
pushRegistered: device.apns_token !== null,
|
||||||
|
apnsEnvironment: device.apns_env,
|
||||||
|
},
|
||||||
|
} satisfies StatusResponse);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
"/v1/hosts/:hostId/reconnect": {
|
||||||
|
POST: authed<"/v1/hosts/:hostId/reconnect">((req) => {
|
||||||
|
findHost(req.params.hostId).reconnect();
|
||||||
|
return accepted();
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
"/v1/feed": {
|
"/v1/feed": {
|
||||||
GET: authed(async () => json(await buildFeed(hub.list(), cache))),
|
GET: authed(async () => json(await buildFeed(hub.list(), cache))),
|
||||||
},
|
},
|
||||||
|
|||||||
+61
-2
@@ -13,6 +13,64 @@ export interface HostSummary {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a route reaches its host, guessed from the URL: `public` for HTTPS, `vpn` when the hostname
|
||||||
|
* says vpn, and `direct` for the rest.
|
||||||
|
*/
|
||||||
|
export type RouteKind = "direct" | "vpn" | "public";
|
||||||
|
|
||||||
|
export interface Route {
|
||||||
|
url: string;
|
||||||
|
kind: RouteKind;
|
||||||
|
/** From the latest connect. `untried` when an earlier route answered first. */
|
||||||
|
result: "ok" | "failed" | "untried";
|
||||||
|
/** Why it failed, such as `refused` or `no answer in 5 s`. */
|
||||||
|
error: string | null;
|
||||||
|
checkedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One host as the status screens show it. */
|
||||||
|
export interface HostStatusDetail extends HostSummary {
|
||||||
|
/** The route in use. Null unless online. */
|
||||||
|
activeUrl: string | null;
|
||||||
|
/** Preferred first, the order the gateway tries them. */
|
||||||
|
routes: Route[];
|
||||||
|
onlineSince: string | null;
|
||||||
|
lastOnlineAt: string | null;
|
||||||
|
/** When the gateway tries again, while it waits to retry. */
|
||||||
|
nextRetryAt: string | null;
|
||||||
|
tokenExpiresAt: string | null;
|
||||||
|
serverVersion: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What Apple answered for one push. */
|
||||||
|
export interface PushResult {
|
||||||
|
ok: boolean;
|
||||||
|
at: string;
|
||||||
|
/** Apple's HTTP status, or 0 when the push never reached Apple. */
|
||||||
|
status: number;
|
||||||
|
/** Apple's reason, such as `BadDeviceToken`, or why the gateway could not send. */
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusResponse {
|
||||||
|
hosts: HostStatusDetail[];
|
||||||
|
gateway: {
|
||||||
|
startedAt: string;
|
||||||
|
/** False when the gateway has no APNs key, so nothing can be pushed. */
|
||||||
|
pushConfigured: boolean;
|
||||||
|
lastPush: PushResult | null;
|
||||||
|
};
|
||||||
|
/** The watch asking. */
|
||||||
|
device: {
|
||||||
|
name: string;
|
||||||
|
pairedAt: string;
|
||||||
|
/** True once the watch has sent its APNs token. */
|
||||||
|
pushRegistered: boolean;
|
||||||
|
apnsEnvironment: "sandbox" | "production" | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Something a thread is waiting on: an approval, or questions for the user. */
|
/** Something a thread is waiting on: an approval, or questions for the user. */
|
||||||
export interface PendingRequest {
|
export interface PendingRequest {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -137,11 +195,12 @@ export interface ErrorResponse {
|
|||||||
|
|
||||||
/** Custom keys on every push notification, beside `aps`. */
|
/** Custom keys on every push notification, beside `aps`. */
|
||||||
export interface PushPayload {
|
export interface PushPayload {
|
||||||
hostId: string;
|
/** Absent on `test` pushes. */
|
||||||
|
hostId?: string;
|
||||||
/** Absent on `host` pushes, which are about a host rather than a thread. */
|
/** Absent on `host` pushes, which are about a host rather than a thread. */
|
||||||
threadId?: string;
|
threadId?: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Notification categories the watch registers actions for. */
|
/** Notification categories the watch registers actions for. */
|
||||||
export type PushCategory = "approval" | "finished" | "failed" | "input" | "host";
|
export type PushCategory = "approval" | "finished" | "failed" | "input" | "host" | "test";
|
||||||
+6
-32
@@ -9,7 +9,8 @@ import { createRoutes } from "./api.ts";
|
|||||||
import { loadConfig } from "./config.ts";
|
import { loadConfig } from "./config.ts";
|
||||||
import { openDb } from "./db.ts";
|
import { openDb } from "./db.ts";
|
||||||
import { Hub } from "./hub.ts";
|
import { Hub } from "./hub.ts";
|
||||||
import { Notifier, type SendPush } from "./notify.ts";
|
import { Notifier } from "./notify.ts";
|
||||||
|
import { Pusher } from "./push.ts";
|
||||||
import { SummaryCache } from "./views.ts";
|
import { SummaryCache } from "./views.ts";
|
||||||
|
|
||||||
const HOST_SYNC_MS = 15_000;
|
const HOST_SYNC_MS = 15_000;
|
||||||
@@ -17,6 +18,7 @@ const TOKEN_CHECK_MS = 6 * 60 * 60_000;
|
|||||||
|
|
||||||
const log = (message: string) => console.log(`${new Date().toISOString()} ${message}`);
|
const log = (message: string) => console.log(`${new Date().toISOString()} ${message}`);
|
||||||
|
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
mkdirSync(dirname(config.dbPath), { recursive: true });
|
mkdirSync(dirname(config.dbPath), { recursive: true });
|
||||||
const db = openDb(config.dbPath);
|
const db = openDb(config.dbPath);
|
||||||
@@ -24,37 +26,9 @@ const cache = new SummaryCache(log);
|
|||||||
const apns = config.apns ? new Apns(config.apns) : null;
|
const apns = config.apns ? new Apns(config.apns) : null;
|
||||||
if (!apns) log("APNs is not configured. Pushes will only be logged.");
|
if (!apns) log("APNs is not configured. Pushes will only be logged.");
|
||||||
|
|
||||||
const sendPush: SendPush = async (push) => {
|
const pusher = new Pusher(db, apns, log);
|
||||||
const devices = db.devices.active().filter((device) => device.apns_token && device.apns_env);
|
|
||||||
if (!apns || devices.length === 0) {
|
|
||||||
log(`push (not sent): [${push.category}] ${push.title}: ${push.body}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const payload = {
|
|
||||||
aps: {
|
|
||||||
alert: { title: push.title, ...(push.subtitle ? { subtitle: push.subtitle } : {}), body: push.body },
|
|
||||||
sound: "default",
|
|
||||||
category: push.category,
|
|
||||||
"thread-id": push.data.threadId ?? push.data.hostId,
|
|
||||||
},
|
|
||||||
...push.data,
|
|
||||||
};
|
|
||||||
await Promise.all(
|
|
||||||
devices.map(async (device) => {
|
|
||||||
const result = await apns.send({
|
|
||||||
deviceToken: device.apns_token!,
|
|
||||||
environment: device.apns_env!,
|
|
||||||
payload,
|
|
||||||
collapseId: push.collapseId,
|
|
||||||
});
|
|
||||||
if (result.ok) return;
|
|
||||||
log(`APNs refused a push to ${device.name}: ${result.status} ${result.reason}`);
|
|
||||||
if (result.tokenInvalid) db.devices.clearPush(device.apns_token!);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const notifier = new Notifier(sendPush, cache, log);
|
const notifier = new Notifier(pusher.sendAll, cache, log);
|
||||||
const hub = new Hub(db, { onThreadChange: notifier.onThreadChange, onStatus: notifier.onHostStatus, log });
|
const hub = new Hub(db, { onThreadChange: notifier.onThreadChange, onStatus: notifier.onHostStatus, log });
|
||||||
hub.sync();
|
hub.sync();
|
||||||
setInterval(() => hub.sync(), HOST_SYNC_MS);
|
setInterval(() => hub.sync(), HOST_SYNC_MS);
|
||||||
@@ -63,7 +37,7 @@ setInterval(() => notifier.checkTokenExpiry(hub.rows()), TOKEN_CHECK_MS);
|
|||||||
|
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: config.port,
|
port: config.port,
|
||||||
routes: createRoutes({ db, hub, cache, log }),
|
routes: createRoutes({ db, hub, cache, pusher, startedAt, log }),
|
||||||
fetch: () => Response.json({ error: "Not found." }, { status: 404 }),
|
fetch: () => Response.json({ error: "Not found." }, { status: 404 }),
|
||||||
});
|
});
|
||||||
log(`WristCode gateway listening on :${server.port} with ${hub.list().length} host(s)`);
|
log(`WristCode gateway listening on :${server.port} with ${hub.list().length} host(s)`);
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Delivers pushes to paired watches through APNs, and remembers Apple's last answer so the watch's
|
||||||
|
// status screen can show whether pushes are getting through. Without APNs settings it only logs.
|
||||||
|
|
||||||
|
import type { Apns } from "./apns.ts";
|
||||||
|
import type { PushResult } from "./contract.ts";
|
||||||
|
import type { Db, DeviceRow } from "./db.ts";
|
||||||
|
import type { Push } from "./notify.ts";
|
||||||
|
|
||||||
|
export class Pusher {
|
||||||
|
/** Apple's answer to the most recent push sent to any watch. */
|
||||||
|
last: PushResult | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly db: Db,
|
||||||
|
private readonly apns: Apns | null,
|
||||||
|
private readonly log: (message: string) => void = () => {},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get configured() {
|
||||||
|
return this.apns !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends to every paired watch that registered for pushes. */
|
||||||
|
sendAll = async (push: Push): Promise<void> => {
|
||||||
|
const devices = this.db.devices.active().filter((device) => device.apns_token && device.apns_env);
|
||||||
|
if (!this.apns || devices.length === 0) {
|
||||||
|
this.log(`push (not sent): [${push.category}] ${push.title}: ${push.body}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.all(devices.map((device) => this.sendTo(device, push)));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sends to one watch and returns what Apple said. */
|
||||||
|
async sendTo(device: DeviceRow, push: Push): Promise<PushResult> {
|
||||||
|
const at = new Date().toISOString();
|
||||||
|
if (!this.apns) return { ok: false, at, status: 0, reason: "The gateway has no APNs key configured." };
|
||||||
|
if (!device.apns_token || !device.apns_env) {
|
||||||
|
return { ok: false, at, status: 0, reason: "This watch has not registered for notifications." };
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
aps: {
|
||||||
|
alert: { title: push.title, ...(push.subtitle ? { subtitle: push.subtitle } : {}), body: push.body },
|
||||||
|
sound: "default",
|
||||||
|
category: push.category,
|
||||||
|
"thread-id": push.data.threadId ?? push.data.hostId ?? push.category,
|
||||||
|
},
|
||||||
|
...push.data,
|
||||||
|
};
|
||||||
|
const answer = await this.apns.send({
|
||||||
|
deviceToken: device.apns_token,
|
||||||
|
environment: device.apns_env,
|
||||||
|
payload,
|
||||||
|
collapseId: push.collapseId,
|
||||||
|
});
|
||||||
|
const result: PushResult = answer.ok ? { ok: true, at, status: 200, reason: null } : { ok: false, at, status: answer.status, reason: answer.reason };
|
||||||
|
this.last = result;
|
||||||
|
if (!answer.ok) {
|
||||||
|
this.log(`APNs refused a push to ${device.name}: ${answer.status} ${answer.reason}`);
|
||||||
|
if (answer.tokenInvalid) this.db.devices.clearPush(device.apns_token);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
-1
@@ -35,6 +35,13 @@ export interface HostConfig {
|
|||||||
environmentId: string;
|
environmentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What happened the last time the gateway tried one route. */
|
||||||
|
export interface RouteAttempt {
|
||||||
|
ok: boolean;
|
||||||
|
error: string | null;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ThreadChange {
|
export interface ThreadChange {
|
||||||
host: T3Host;
|
host: T3Host;
|
||||||
previous: ThreadShell | undefined;
|
previous: ThreadShell | undefined;
|
||||||
@@ -71,6 +78,15 @@ export class T3Host {
|
|||||||
readonly projects = new Map<string, Project>();
|
readonly projects = new Map<string, Project>();
|
||||||
readonly threads = new Map<string, ThreadShell>();
|
readonly threads = new Map<string, ThreadShell>();
|
||||||
serverConfig: ServerConfig | null = null;
|
serverConfig: ServerConfig | null = null;
|
||||||
|
serverVersion: string | null = null;
|
||||||
|
/** Results of the latest connect, by route. Routes after the one that answered were not tried. */
|
||||||
|
readonly routeAttempts = new Map<string, RouteAttempt>();
|
||||||
|
/** When the current connection came up. Null when not online. */
|
||||||
|
onlineSince: string | null = null;
|
||||||
|
/** When the last connection ended. Null if it never connected or is online now. */
|
||||||
|
lastOnlineAt: string | null = null;
|
||||||
|
/** When the next connect is scheduled, while waiting to retry. */
|
||||||
|
nextRetryAt: string | null = null;
|
||||||
|
|
||||||
private socket: RpcSocket | null = null;
|
private socket: RpcSocket | null = null;
|
||||||
private sequence: number | null = null;
|
private sequence: number | null = null;
|
||||||
@@ -78,6 +94,8 @@ export class T3Host {
|
|||||||
private stopped = false;
|
private stopped = false;
|
||||||
private attempt = 0;
|
private attempt = 0;
|
||||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
/** Set by reconnect() so a deliberate close connects again at once, with no backoff. */
|
||||||
|
private reconnecting = false;
|
||||||
/** The route this connection uses. Chosen afresh, preferred first, on every connect. */
|
/** The route this connection uses. Chosen afresh, preferred first, on every connect. */
|
||||||
private activeUrl: string;
|
private activeUrl: string;
|
||||||
|
|
||||||
@@ -114,6 +132,22 @@ export class T3Host {
|
|||||||
this.socket?.close();
|
this.socket?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects again now, starting from the preferred route. For when a better route may be back,
|
||||||
|
* such as the LAN after getting home, or to skip a backoff wait. Does nothing mid-connect.
|
||||||
|
*/
|
||||||
|
reconnect() {
|
||||||
|
if (this.stopped || this.status === "connecting") return;
|
||||||
|
this.attempt = 0;
|
||||||
|
if (this.socket) {
|
||||||
|
this.reconnecting = true;
|
||||||
|
this.socket.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.retryTimer) clearTimeout(this.retryTimer);
|
||||||
|
void this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
/** Sends one orchestration command and resolves with its sequence. */
|
/** Sends one orchestration command and resolves with its sequence. */
|
||||||
async dispatch(command: Command): Promise<{ sequence: number }> {
|
async dispatch(command: Command): Promise<{ sequence: number }> {
|
||||||
return this.requireSocket().call("orchestration.dispatchCommand", command);
|
return this.requireSocket().call("orchestration.dispatchCommand", command);
|
||||||
@@ -162,6 +196,11 @@ export class T3Host {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setStatus(status: HostStatus, error: string | null = null) {
|
private setStatus(status: HostStatus, error: string | null = null) {
|
||||||
|
if (status === "online") this.onlineSince = new Date().toISOString();
|
||||||
|
else if (this.status === "online") {
|
||||||
|
this.lastOnlineAt = new Date().toISOString();
|
||||||
|
this.onlineSince = null;
|
||||||
|
}
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.lastError = error;
|
this.lastError = error;
|
||||||
this.options.onStatus?.(this);
|
this.options.onStatus?.(this);
|
||||||
@@ -169,6 +208,7 @@ export class T3Host {
|
|||||||
|
|
||||||
private async connect() {
|
private async connect() {
|
||||||
if (this.stopped) return;
|
if (this.stopped) return;
|
||||||
|
this.nextRetryAt = null;
|
||||||
this.setStatus("connecting", this.lastError);
|
this.setStatus("connecting", this.lastError);
|
||||||
try {
|
try {
|
||||||
const descriptor = await this.pickRoute();
|
const descriptor = await this.pickRoute();
|
||||||
@@ -192,6 +232,11 @@ export class T3Host {
|
|||||||
this.setStatus("online");
|
this.setStatus("online");
|
||||||
await socket.closed;
|
await socket.closed;
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
|
if (this.reconnecting) {
|
||||||
|
this.reconnecting = false;
|
||||||
|
void this.connect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.scheduleRetry("offline", "connection closed");
|
this.scheduleRetry("offline", "connection closed");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.socket?.close();
|
this.socket?.close();
|
||||||
@@ -206,6 +251,7 @@ export class T3Host {
|
|||||||
/** Uses the first route whose well-known descriptor answers, and returns that descriptor. */
|
/** Uses the first route whose well-known descriptor answers, and returns that descriptor. */
|
||||||
private async pickRoute(): Promise<EnvironmentDescriptor> {
|
private async pickRoute(): Promise<EnvironmentDescriptor> {
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
|
this.routeAttempts.clear();
|
||||||
for (const url of this.config.urls) {
|
for (const url of this.config.urls) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(new URL("/.well-known/t3/environment", url), {
|
const response = await fetch(new URL("/.well-known/t3/environment", url), {
|
||||||
@@ -215,9 +261,13 @@ export class T3Host {
|
|||||||
const descriptor = (await response.json()) as EnvironmentDescriptor;
|
const descriptor = (await response.json()) as EnvironmentDescriptor;
|
||||||
if (url !== this.activeUrl) this.options.log?.(`${this.label}: using ${url}`);
|
if (url !== this.activeUrl) this.options.log?.(`${this.label}: using ${url}`);
|
||||||
this.activeUrl = url;
|
this.activeUrl = url;
|
||||||
|
this.serverVersion = descriptor.serverVersion ?? null;
|
||||||
|
this.routeAttempts.set(url, { ok: true, error: null, at: new Date().toISOString() });
|
||||||
return descriptor;
|
return descriptor;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failures.push(`${new URL(url).host} ${error instanceof Error ? error.message : String(error)}`);
|
const message = routeError(error);
|
||||||
|
this.routeAttempts.set(url, { ok: false, error: message, at: new Date().toISOString() });
|
||||||
|
failures.push(`${new URL(url).host} ${message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new HostError(`no route answered (${failures.join(", ")})`, 503);
|
throw new HostError(`no route answered (${failures.join(", ")})`, 503);
|
||||||
@@ -246,6 +296,7 @@ export class T3Host {
|
|||||||
status === "auth_invalid" || status === "incompatible"
|
status === "auth_invalid" || status === "incompatible"
|
||||||
? AUTH_RETRY_MS
|
? AUTH_RETRY_MS
|
||||||
: Math.min(BACKOFF_MAX_MS, BACKOFF_MIN_MS * 2 ** this.attempt++) * (0.5 + Math.random() / 2);
|
: Math.min(BACKOFF_MAX_MS, BACKOFF_MIN_MS * 2 ** this.attempt++) * (0.5 + Math.random() / 2);
|
||||||
|
this.nextRetryAt = new Date(Date.now() + delay).toISOString();
|
||||||
this.retryTimer = setTimeout(() => void this.connect(), delay);
|
this.retryTimer = setTimeout(() => void this.connect(), delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,3 +352,12 @@ export class T3Host {
|
|||||||
this.options.onThreadChange?.({ host: this, previous, next, initial });
|
this.options.onThreadChange?.({ host: this, previous, next, initial });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A short reason a route failed, worded for a watch screen. */
|
||||||
|
function routeError(error: unknown): string {
|
||||||
|
if (error instanceof DOMException && error.name === "TimeoutError") return `no answer in ${ROUTE_TIMEOUT_MS / 1000} s`;
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (/ECONNREFUSED|Unable to connect|refused/i.test(message)) return "refused";
|
||||||
|
if (/ENOTFOUND|getaddrinfo|DNS/i.test(message)) return "name not found";
|
||||||
|
return message;
|
||||||
|
}
|
||||||
+40
-1
@@ -4,7 +4,17 @@
|
|||||||
// the thread's `updatedAt` moves.
|
// the thread's `updatedAt` moves.
|
||||||
|
|
||||||
import { threadPhase, type Phase } from "./awareness.ts";
|
import { threadPhase, type Phase } from "./awareness.ts";
|
||||||
import type { Feed, FeedItem, HostSummary, Message, PendingRequest, Summary, ThreadDetail } from "./contract.ts";
|
import type {
|
||||||
|
Feed,
|
||||||
|
FeedItem,
|
||||||
|
HostStatusDetail,
|
||||||
|
HostSummary,
|
||||||
|
Message,
|
||||||
|
PendingRequest,
|
||||||
|
RouteKind,
|
||||||
|
Summary,
|
||||||
|
ThreadDetail,
|
||||||
|
} from "./contract.ts";
|
||||||
import type { T3Host } from "./t3/host.ts";
|
import type { T3Host } from "./t3/host.ts";
|
||||||
import {
|
import {
|
||||||
isApprovalRequest,
|
isApprovalRequest,
|
||||||
@@ -141,6 +151,35 @@ export const hostSummary = (host: T3Host): HostSummary => ({
|
|||||||
error: host.lastError,
|
error: host.lastError,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** A host with its routes, for the status screens. `tokenExpiresAt` comes from the hosts table. */
|
||||||
|
export function hostStatusDetail(host: T3Host, tokenExpiresAt: string | null): HostStatusDetail {
|
||||||
|
return {
|
||||||
|
...hostSummary(host),
|
||||||
|
activeUrl: host.status === "online" ? host.url : null,
|
||||||
|
routes: host.config.urls.map((url) => {
|
||||||
|
const attempt = host.routeAttempts.get(url);
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
kind: routeKind(url),
|
||||||
|
result: attempt ? (attempt.ok ? "ok" : "failed") : "untried",
|
||||||
|
error: attempt?.error ?? null,
|
||||||
|
checkedAt: attempt?.at ?? null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
onlineSince: host.onlineSince,
|
||||||
|
lastOnlineAt: host.lastOnlineAt,
|
||||||
|
nextRetryAt: host.nextRetryAt,
|
||||||
|
tokenExpiresAt,
|
||||||
|
serverVersion: host.serverVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function routeKind(url: string): RouteKind {
|
||||||
|
const { protocol, hostname } = new URL(url);
|
||||||
|
if (protocol === "https:") return "public";
|
||||||
|
return hostname.includes("vpn") ? "vpn" : "direct";
|
||||||
|
}
|
||||||
|
|
||||||
function feedItem(host: T3Host, thread: ThreadShell, phase: Phase, summary: ThreadSummary | undefined): FeedItem {
|
function feedItem(host: T3Host, thread: ThreadShell, phase: Phase, summary: ThreadSummary | undefined): FeedItem {
|
||||||
return {
|
return {
|
||||||
hostId: host.id,
|
hostId: host.id,
|
||||||
|
|||||||
+41
-15
@@ -5,16 +5,23 @@ import type { Server } from "bun";
|
|||||||
import { T3Host } from "../src/t3/host.ts";
|
import { T3Host } from "../src/t3/host.ts";
|
||||||
|
|
||||||
const ENVIRONMENT_ID = "env-laptop";
|
const ENVIRONMENT_ID = "env-laptop";
|
||||||
let server: Server<undefined> | undefined;
|
let servers: Server<undefined>[] = [];
|
||||||
afterEach(() => server?.stop(true));
|
afterEach(() => {
|
||||||
|
for (const server of servers) server.stop(true);
|
||||||
|
servers = [];
|
||||||
|
});
|
||||||
|
|
||||||
/** Just enough of a T3 server: the descriptor, a ticket, config, and an empty shell snapshot. */
|
/**
|
||||||
function fakeT3() {
|
* Just enough of a T3 server: the descriptor, a ticket, config, and an empty shell snapshot.
|
||||||
server = Bun.serve({
|
* While `state.up` is false its descriptor returns 503, like a route that is down.
|
||||||
|
*/
|
||||||
|
function fakeT3(state = { up: true }) {
|
||||||
|
const server = Bun.serve({
|
||||||
port: 0,
|
port: 0,
|
||||||
fetch(req, srv) {
|
fetch(req, srv) {
|
||||||
const { pathname } = new URL(req.url);
|
const { pathname } = new URL(req.url);
|
||||||
if (pathname === "/.well-known/t3/environment") {
|
if (pathname === "/.well-known/t3/environment") {
|
||||||
|
if (!state.up) return new Response("down", { status: 503 });
|
||||||
return Response.json({ environmentId: ENVIRONMENT_ID, label: "framework-gib", serverVersion: "0.1.5", orchestrationProtocolVersion: 2 });
|
return Response.json({ environmentId: ENVIRONMENT_ID, label: "framework-gib", serverVersion: "0.1.5", orchestrationProtocolVersion: 2 });
|
||||||
}
|
}
|
||||||
if (pathname === "/api/auth/websocket-ticket/rpc") return Response.json({ ticket: "t" });
|
if (pathname === "/api/auth/websocket-ticket/rpc") return Response.json({ ticket: "t" });
|
||||||
@@ -34,25 +41,44 @@ function fakeT3() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
servers.push(server);
|
||||||
return `http://localhost:${server.port}`;
|
return `http://localhost:${server.port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolves once `check` passes, polling every few milliseconds. */
|
||||||
|
async function until(check: () => boolean) {
|
||||||
|
while (!check()) await Bun.sleep(5);
|
||||||
|
}
|
||||||
|
|
||||||
describe("T3Host routes", () => {
|
describe("T3Host routes", () => {
|
||||||
it("falls back to the next route when the preferred one does not answer", async () => {
|
it("falls back to the next route when the preferred one does not answer", async () => {
|
||||||
const working = fakeT3();
|
const working = fakeT3();
|
||||||
// Port 9 (discard) refuses connections, standing in for a LAN route that is down.
|
// Port 9 (discard) refuses connections, standing in for a LAN route that is down.
|
||||||
const host = new T3Host({ id: ENVIRONMENT_ID, label: "Laptop", urls: ["http://127.0.0.1:9", working], token: "x", environmentId: ENVIRONMENT_ID });
|
const refused = "http://127.0.0.1:9";
|
||||||
const online = new Promise<void>((resolve) => {
|
const spare = "https://t3laptop.example.org";
|
||||||
const check = setInterval(() => {
|
const host = new T3Host({ id: ENVIRONMENT_ID, label: "Laptop", urls: [refused, working, spare], token: "x", environmentId: ENVIRONMENT_ID });
|
||||||
if (host.status === "online") {
|
|
||||||
clearInterval(check);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
}, 5);
|
|
||||||
});
|
|
||||||
host.start();
|
host.start();
|
||||||
await online;
|
await until(() => host.status === "online");
|
||||||
expect(host.url).toBe(working);
|
expect(host.url).toBe(working);
|
||||||
|
expect(host.routeAttempts.get(refused)).toMatchObject({ ok: false, error: "refused" });
|
||||||
|
expect(host.routeAttempts.get(working)).toMatchObject({ ok: true, error: null });
|
||||||
|
expect(host.routeAttempts.has(spare)).toBe(false);
|
||||||
|
host.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves back to the preferred route on reconnect once it answers", async () => {
|
||||||
|
const preferredState = { up: false };
|
||||||
|
const preferred = fakeT3(preferredState);
|
||||||
|
const fallback = fakeT3();
|
||||||
|
const host = new T3Host({ id: ENVIRONMENT_ID, label: "Laptop", urls: [preferred, fallback], token: "x", environmentId: ENVIRONMENT_ID });
|
||||||
|
host.start();
|
||||||
|
await until(() => host.status === "online");
|
||||||
|
expect(host.url).toBe(fallback);
|
||||||
|
|
||||||
|
preferredState.up = true;
|
||||||
|
host.reconnect();
|
||||||
|
await until(() => host.status === "online" && host.url === preferred);
|
||||||
|
expect(host.lastOnlineAt).not.toBeNull();
|
||||||
host.stop();
|
host.stop();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test";
|
|||||||
|
|
||||||
import type { BoundedThread, ThreadShell } from "../src/t3/types.ts";
|
import type { BoundedThread, ThreadShell } from "../src/t3/types.ts";
|
||||||
import type { T3Host } from "../src/t3/host.ts";
|
import type { T3Host } from "../src/t3/host.ts";
|
||||||
import { buildSummary, summarize, trimText } from "../src/views.ts";
|
import { buildSummary, routeKind, summarize, trimText } from "../src/views.ts";
|
||||||
|
|
||||||
const thread = (overrides: Partial<ThreadShell> = {}): ThreadShell => ({
|
const thread = (overrides: Partial<ThreadShell> = {}): ThreadShell => ({
|
||||||
id: "thread-1",
|
id: "thread-1",
|
||||||
@@ -129,3 +129,12 @@ describe("buildSummary", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("routeKind", () => {
|
||||||
|
it("tells direct, VPN and public routes apart", () => {
|
||||||
|
expect(routeKind("http://desktop.gib:3773")).toBe("direct");
|
||||||
|
expect(routeKind("http://192.168.2.2:3773")).toBe("direct");
|
||||||
|
expect(routeKind("http://laptopvpn.gib:3773")).toBe("vpn");
|
||||||
|
expect(routeKind("https://t3laptopvpn.gbrown.org")).toBe("public");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user