ext: S1 native-messaging spike (ADR 0003) + WebSocket transport
Spike S1 — run on the target machine through real snap confinement
(apparmor snap.firefox.firefox enforced; web-ext's direct-exec of the inner
binary bypasses it, so runs were forced through `snap run firefox`):
- manifest in ~/.mozilla/native-messaging-hosts/ -> WORKS; host launched
unconfined with real $HOME and real $XDG_RUNTIME_DIR, bound a socket in
the real /run/user/<uid>. Corroborated by the machine's 1Password host.
- ~/snap/firefox/common/.mozilla/native-messaging-hosts/ -> not read
- /usr/lib/mozilla/native-messaging-hosts/ -> not read
- flatpak path -> N/A (snap Firefox)
Decision: WebSocket stays the default; native messaging is an opportunistic
upgrade taken only when its handshake succeeds. docs/05 §4 corrected in this
commit to point the snap manifest at ~/.mozilla and mark /usr/lib as
deb/tarball-only. ADR carries a self-contained reproduction; the scratch
harness has been removed.
transport/ (build order item 1):
- types.ts VeloxTransport interface + error taxonomy
- rpc.ts JSON-RPC id correlation, per-call deadline, AbortSignal
- backoff.ts exponential backoff with jitter
- discovery.ts 52000-52016 scan ordering (last-good port first)
- websocket.ts scan -> session.hello -> auto-pair (token in
storage.local) -> reconnect; -32001 fatal, refused/
rate-limited pairing latches needsPairing (no retry storm);
a mid-handshake drop aborts hello immediately
- native.ts connectNative(); distinguishes "not installed" (fatal,
lets the picker fall through) from a crash (reconnect)
- index.ts createTransport() runtime picker + persisted Options override
Toolchain: package.json / tsconfig (strict) / vitest; webextension-polyfill
mocked. 38 tests, incl. the WS suite against a real loopback ws server.
No manifest.json yet, so CI's extension-lint guard stays a no-op.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y9RU58hD1BuwP82DySUHk
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
// Exponential backoff with full jitter, for reconnect scheduling.
|
||||
//
|
||||
// Kept tiny and separate so a test can assert the sequence without faking sockets.
|
||||
|
||||
export interface BackoffOptions {
|
||||
baseMs?: number;
|
||||
factor?: number;
|
||||
maxMs?: number;
|
||||
/** 0 = no jitter (deterministic, for tests); 1 = full jitter. Default 0.2. */
|
||||
jitter?: number;
|
||||
random?: () => number;
|
||||
}
|
||||
|
||||
export class Backoff {
|
||||
private readonly baseMs: number;
|
||||
private readonly factor: number;
|
||||
private readonly maxMs: number;
|
||||
private readonly jitter: number;
|
||||
private readonly random: () => number;
|
||||
private attempt = 0;
|
||||
|
||||
constructor(opts: BackoffOptions = {}) {
|
||||
this.baseMs = opts.baseMs ?? 500;
|
||||
this.factor = opts.factor ?? 2;
|
||||
this.maxMs = opts.maxMs ?? 30_000;
|
||||
this.jitter = opts.jitter ?? 0.2;
|
||||
this.random = opts.random ?? Math.random;
|
||||
}
|
||||
|
||||
get attempts(): number {
|
||||
return this.attempt;
|
||||
}
|
||||
|
||||
/** The delay for the next retry, and advances the counter. */
|
||||
next(): number {
|
||||
const raw = Math.min(this.maxMs, this.baseMs * this.factor ** this.attempt);
|
||||
this.attempt += 1;
|
||||
if (this.jitter <= 0) return Math.round(raw);
|
||||
const spread = raw * this.jitter;
|
||||
return Math.round(raw - spread + this.random() * spread * 2);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.attempt = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Port discovery for the WebSocket transport.
|
||||
//
|
||||
// The extension cannot read $XDG_RUNTIME_DIR/velox/ws.port, so veloxd binds the first
|
||||
// free port in a small fixed range and the extension probes them (docs/05 §4.B). This
|
||||
// module only decides the order to try; the handshake that confirms a port lives in
|
||||
// websocket.ts so there is exactly one copy of it.
|
||||
|
||||
export interface PortRange {
|
||||
readonly start: number;
|
||||
readonly end: number;
|
||||
}
|
||||
|
||||
/** 52000–52016 inclusive — 17 ports, matching docs/05 §4.B. */
|
||||
export const WS_PORT_RANGE: PortRange = { start: 52000, end: 52016 };
|
||||
|
||||
/**
|
||||
* The full range, in the order to try it: the last-known-good port first (when it is
|
||||
* in range), then the rest ascending. A stable order keeps a second daemon on a higher
|
||||
* port from being picked while the real one is still on its usual port.
|
||||
*/
|
||||
export function candidatePorts(range: PortRange = WS_PORT_RANGE, preferred?: number | null): number[] {
|
||||
const ports: number[] = [];
|
||||
if (typeof preferred === 'number' && preferred >= range.start && preferred <= range.end) {
|
||||
ports.push(preferred);
|
||||
}
|
||||
for (let p = range.start; p <= range.end; p += 1) {
|
||||
if (p !== preferred) ports.push(p);
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Runtime transport selection.
|
||||
//
|
||||
// docs/adr/0003: WebSocket is the guaranteed path; native messaging is an opportunistic
|
||||
// upgrade that is only taken when its handshake actually succeeds, never on detection
|
||||
// alone. The user can force one from Options (the override, persisted in storage).
|
||||
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
import type { BackoffOptions } from './backoff.js';
|
||||
import { WS_PORT_RANGE, type PortRange } from './discovery.js';
|
||||
import { NativeTransport, type ConnectNative } from './native.js';
|
||||
import * as storage from './storage.js';
|
||||
import type { TransportOverride } from './storage.js';
|
||||
import type { VeloxTransport } from './types.js';
|
||||
import { WebSocketTransport, type WebSocketCtor, type WebSocketTransportDeps } from './websocket.js';
|
||||
|
||||
export type {
|
||||
VeloxTransport,
|
||||
TransportStatus,
|
||||
TransportState,
|
||||
TransportKind,
|
||||
CallOptions,
|
||||
} from './types.js';
|
||||
export {
|
||||
RpcError,
|
||||
RpcTimeoutError,
|
||||
TransportClosedError,
|
||||
MethodNotAllowedError,
|
||||
} from './types.js';
|
||||
export { WebSocketTransport } from './websocket.js';
|
||||
export { NativeTransport, HOST_NAME } from './native.js';
|
||||
export { WS_PORT_RANGE, candidatePorts } from './discovery.js';
|
||||
export * as transportStorage from './storage.js';
|
||||
|
||||
export interface CreateTransportOptions {
|
||||
/** Defaults to the persisted Options value. */
|
||||
override?: TransportOverride;
|
||||
portRange?: PortRange;
|
||||
backoff?: BackoffOptions;
|
||||
autoPair?: boolean;
|
||||
/** Test seams. */
|
||||
webSocketCtor?: WebSocketCtor;
|
||||
connectNative?: ConnectNative;
|
||||
}
|
||||
|
||||
function wsDeps(opts: CreateTransportOptions): WebSocketTransportDeps {
|
||||
const deps: WebSocketTransportDeps = {
|
||||
getToken: storage.getToken,
|
||||
setToken: storage.setToken,
|
||||
getCachedPort: storage.getCachedWsPort,
|
||||
setCachedPort: storage.setCachedWsPort,
|
||||
extensionId: storage.extensionOriginId(),
|
||||
portRange: opts.portRange ?? WS_PORT_RANGE,
|
||||
};
|
||||
if (opts.webSocketCtor) deps.webSocketCtor = opts.webSocketCtor;
|
||||
if (opts.backoff) deps.backoff = opts.backoff;
|
||||
if (opts.autoPair !== undefined) deps.autoPair = opts.autoPair;
|
||||
return deps;
|
||||
}
|
||||
|
||||
function nativeDeps(opts: CreateTransportOptions): ConstructorParameters<typeof NativeTransport>[0] {
|
||||
const connectNative =
|
||||
opts.connectNative ?? (browser.runtime.connectNative.bind(browser.runtime) as ConnectNative);
|
||||
return opts.backoff ? { connectNative, backoff: opts.backoff } : { connectNative };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a transport and bring it up. The returned transport keeps itself connected
|
||||
* afterwards (reconnect with backoff). On the recoverable failure — veloxd simply not
|
||||
* running yet — this still resolves with a live WebSocket transport whose status reads
|
||||
* 'disconnected' and which is already retrying; callers render that as the red dot.
|
||||
* It rejects only when the user explicitly forced native messaging and that failed.
|
||||
*/
|
||||
export async function createTransport(opts: CreateTransportOptions = {}): Promise<VeloxTransport> {
|
||||
const override = opts.override ?? (await storage.getOverride());
|
||||
|
||||
if (override === 'uds') {
|
||||
const t = new NativeTransport(nativeDeps(opts));
|
||||
await t.connect(); // explicit choice — surface the failure
|
||||
return t;
|
||||
}
|
||||
|
||||
if (override === 'ws') {
|
||||
const t = new WebSocketTransport(wsDeps(opts));
|
||||
await t.connect().catch(() => undefined);
|
||||
return t;
|
||||
}
|
||||
|
||||
// auto: try native, fall back to WebSocket.
|
||||
const native = new NativeTransport(nativeDeps(opts));
|
||||
try {
|
||||
await native.connect();
|
||||
return native;
|
||||
} catch {
|
||||
native.disconnect();
|
||||
}
|
||||
const ws = new WebSocketTransport(wsDeps(opts));
|
||||
await ws.connect().catch(() => undefined);
|
||||
return ws;
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
// NativeTransport — the opportunistic upgrade (docs/adr/0003).
|
||||
//
|
||||
// browser.runtime.connectNative("com.velox.host") → velox-nmhost → veloxd's Unix socket
|
||||
//
|
||||
// No pairing and no token: the Unix socket authorizes by SO_PEERCRED (contract note on
|
||||
// SessionHelloParams.token). On snap Firefox this only works when the manifest is in the
|
||||
// real ~/.mozilla/native-messaging-hosts/ (ADR 0003); when the host is not installed the
|
||||
// connect rejects fast so the runtime picker can fall through to WebSocket.
|
||||
//
|
||||
// State/backoff plumbing parallels WebSocketTransport; it is kept separate because the
|
||||
// connect paths (port scan + pairing vs. a single connectNative) share little.
|
||||
|
||||
import { ErrorCode, METHODS, PROTOCOL_VERSION, isAllowedOn } from '../../shared/protocol/index.js';
|
||||
import type {
|
||||
MethodName,
|
||||
Params,
|
||||
Result,
|
||||
SessionHelloParams,
|
||||
SessionHelloResult,
|
||||
} from '../../shared/protocol/index.js';
|
||||
|
||||
import { Backoff, type BackoffOptions } from './backoff.js';
|
||||
import { RpcConnection } from './rpc.js';
|
||||
import {
|
||||
MethodNotAllowedError,
|
||||
RpcError,
|
||||
TransportClosedError,
|
||||
type CallOptions,
|
||||
type EventListener,
|
||||
type TransportState,
|
||||
type TransportStatus,
|
||||
type VeloxTransport,
|
||||
} from './types.js';
|
||||
|
||||
export const HOST_NAME = 'com.velox.host';
|
||||
const CLIENT_NAME = 'Firefox (Velox extension)';
|
||||
|
||||
export interface NativePort {
|
||||
postMessage(message: unknown): void;
|
||||
disconnect(): void;
|
||||
onMessage: {
|
||||
addListener(cb: (message: unknown) => void): void;
|
||||
removeListener(cb: (message: unknown) => void): void;
|
||||
};
|
||||
onDisconnect: {
|
||||
addListener(cb: (port?: unknown) => void): void;
|
||||
removeListener(cb: (port?: unknown) => void): void;
|
||||
};
|
||||
error?: { message: string } | null;
|
||||
}
|
||||
export type ConnectNative = (application: string) => NativePort;
|
||||
|
||||
const NOT_INSTALLED = /no such native application|not found|no such file|failed to (start|connect|execute)/i;
|
||||
|
||||
export interface NativeTransportDeps {
|
||||
connectNative: ConnectNative;
|
||||
hostName?: string;
|
||||
clientName?: string;
|
||||
backoff?: BackoffOptions;
|
||||
helloTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export class NativeTransport implements VeloxTransport {
|
||||
readonly kind = 'uds' as const;
|
||||
|
||||
private readonly connectNative: ConnectNative;
|
||||
private readonly hostName: string;
|
||||
private readonly clientName: string;
|
||||
private readonly helloTimeoutMs: number;
|
||||
private readonly backoff: Backoff;
|
||||
|
||||
private port: NativePort | null = null;
|
||||
private rpc: RpcConnection | null = null;
|
||||
private portDead = false;
|
||||
|
||||
private stopped = false;
|
||||
private connectPromise: Promise<void> | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private readonly listeners = new Map<string, Set<EventListener>>();
|
||||
private readonly stateListeners = new Set<(status: TransportStatus) => void>();
|
||||
|
||||
private _state: TransportState = 'disconnected';
|
||||
private readonly _status: TransportStatus = {
|
||||
state: 'disconnected',
|
||||
needsPairing: false,
|
||||
fatal: null,
|
||||
retryAfterSec: null,
|
||||
sessionId: null,
|
||||
daemonVersion: null,
|
||||
capabilities: [],
|
||||
};
|
||||
|
||||
constructor(deps: NativeTransportDeps) {
|
||||
this.connectNative = deps.connectNative;
|
||||
this.hostName = deps.hostName ?? HOST_NAME;
|
||||
this.clientName = deps.clientName ?? CLIENT_NAME;
|
||||
this.helloTimeoutMs = deps.helloTimeoutMs ?? METHODS['session.hello'].deadlineMs;
|
||||
this.backoff = new Backoff(deps.backoff);
|
||||
}
|
||||
|
||||
get state(): TransportState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
get status(): TransportStatus {
|
||||
return { ...this._status, capabilities: [...this._status.capabilities] };
|
||||
}
|
||||
|
||||
connect(): Promise<void> {
|
||||
if (this._state === 'connected') return Promise.resolve();
|
||||
if (this.connectPromise) return this.connectPromise;
|
||||
this.stopped = false;
|
||||
this.connectPromise = this.openOnce().finally(() => {
|
||||
this.connectPromise = null;
|
||||
});
|
||||
return this.connectPromise;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.stopped = true;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.teardownPort();
|
||||
this.setState('disconnected');
|
||||
}
|
||||
|
||||
call<M extends MethodName>(method: M, params: Params<M>, opts?: CallOptions): Promise<Result<M>> {
|
||||
if (!isAllowedOn(method, 'uds')) {
|
||||
return Promise.reject(new MethodNotAllowedError(method, 'uds'));
|
||||
}
|
||||
const rpc = this.rpc;
|
||||
if (!rpc || this._state !== 'connected') {
|
||||
return Promise.reject(new TransportClosedError());
|
||||
}
|
||||
const timeoutMs = opts?.timeoutMs ?? METHODS[method].deadlineMs;
|
||||
return rpc.request(method, params, timeoutMs, opts?.signal) as Promise<Result<M>>;
|
||||
}
|
||||
|
||||
on(event: string, cb: EventListener): void {
|
||||
let set = this.listeners.get(event);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.listeners.set(event, set);
|
||||
}
|
||||
set.add(cb);
|
||||
}
|
||||
|
||||
off(event: string, cb: EventListener): void {
|
||||
this.listeners.get(event)?.delete(cb);
|
||||
}
|
||||
|
||||
onStateChange(cb: (status: TransportStatus) => void): () => void {
|
||||
this.stateListeners.add(cb);
|
||||
return () => this.stateListeners.delete(cb);
|
||||
}
|
||||
|
||||
// --- connect ---------------------------------------------------------------------
|
||||
|
||||
private async openOnce(): Promise<void> {
|
||||
this.setState('connecting');
|
||||
|
||||
let port: NativePort;
|
||||
try {
|
||||
port = this.connectNative(this.hostName);
|
||||
} catch (err) {
|
||||
this._status.fatal = `native messaging unavailable: ${String(err)}`;
|
||||
this.setState('disconnected');
|
||||
throw new RpcError(ErrorCode.InternalError, this._status.fatal);
|
||||
}
|
||||
|
||||
const rpc = new RpcConnection((frame) => port.postMessage(frame));
|
||||
const onMessage = (message: unknown): void => {
|
||||
const note = rpc.handleInbound(message as Record<string, unknown>);
|
||||
if (note) this.dispatchEvent(note.method, note.params);
|
||||
};
|
||||
port.onMessage.addListener(onMessage);
|
||||
|
||||
// A missing host surfaces as an immediate onDisconnect, not a throw.
|
||||
const drop = { hit: false, error: '' };
|
||||
const onDisconnect = (): void => {
|
||||
drop.hit = true;
|
||||
drop.error = port.error?.message ?? 'native port disconnected';
|
||||
// Fail the pending hello immediately instead of waiting out its deadline.
|
||||
rpc.failAll(new TransportClosedError('native port disconnected during handshake'));
|
||||
};
|
||||
port.onDisconnect.addListener(onDisconnect);
|
||||
|
||||
let hello: SessionHelloResult;
|
||||
try {
|
||||
hello = await this.hello(rpc);
|
||||
} catch (err) {
|
||||
port.onMessage.removeListener(onMessage);
|
||||
port.onDisconnect.removeListener(onDisconnect);
|
||||
safeDisconnect(port);
|
||||
|
||||
if (drop.hit) {
|
||||
if (NOT_INSTALLED.test(drop.error)) {
|
||||
this._status.fatal = 'native messaging host is not installed';
|
||||
this.setState('disconnected');
|
||||
throw new RpcError(ErrorCode.InternalError, this._status.fatal);
|
||||
}
|
||||
// Host is installed but died: recoverable.
|
||||
this.setState('disconnected');
|
||||
this.scheduleReconnect();
|
||||
throw new TransportClosedError(`native host disconnected: ${drop.error}`);
|
||||
}
|
||||
if (err instanceof RpcError && err.code === ErrorCode.VersionMismatch) {
|
||||
this._status.fatal = `protocol mismatch: ${err.message}`;
|
||||
this.setState('disconnected');
|
||||
throw err;
|
||||
}
|
||||
this.setState('disconnected');
|
||||
this.scheduleReconnect();
|
||||
throw err instanceof Error ? err : new TransportClosedError(String(err));
|
||||
}
|
||||
|
||||
// Handshake done — keep the disconnect listener, swap its job to reconnect.
|
||||
port.onDisconnect.removeListener(onDisconnect);
|
||||
this.adoptPort(port, rpc, onMessage);
|
||||
this.backoff.reset();
|
||||
this.applyHello(hello);
|
||||
this.setState('connected');
|
||||
}
|
||||
|
||||
private async hello(rpc: RpcConnection): Promise<SessionHelloResult> {
|
||||
const params: SessionHelloParams = {
|
||||
clientType: 'extension',
|
||||
clientName: this.clientName,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
token: null,
|
||||
};
|
||||
return (await rpc.request('session.hello', params, this.helloTimeoutMs)) as SessionHelloResult;
|
||||
}
|
||||
|
||||
private adoptPort(port: NativePort, rpc: RpcConnection, onMessage: (m: unknown) => void): void {
|
||||
this.port = port;
|
||||
this.rpc = rpc;
|
||||
this.portDead = false;
|
||||
const onDrop = (): void => this.handleDrop(port, onMessage);
|
||||
port.onDisconnect.addListener(onDrop);
|
||||
}
|
||||
|
||||
private handleDrop(port: NativePort, onMessage: (m: unknown) => void): void {
|
||||
if (port !== this.port || this.portDead) return;
|
||||
this.portDead = true;
|
||||
port.onMessage.removeListener(onMessage);
|
||||
this.rpc?.failAll(new TransportClosedError('native port disconnected'));
|
||||
this.port = null;
|
||||
this.rpc = null;
|
||||
this.setState('disconnected');
|
||||
if (this.stopped || this._status.fatal || this._status.needsPairing) return;
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.stopped || this.reconnectTimer) return;
|
||||
const delay = this.backoff.next();
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
if (this.stopped) return;
|
||||
this.openOnce().catch(() => {
|
||||
/* openOnce() latches status / schedules the next attempt itself */
|
||||
});
|
||||
}, delay);
|
||||
(this.reconnectTimer as { unref?: () => void }).unref?.();
|
||||
}
|
||||
|
||||
private teardownPort(): void {
|
||||
const port = this.port;
|
||||
this.port = null;
|
||||
this.rpc?.failAll(new TransportClosedError('transport closed'));
|
||||
this.rpc = null;
|
||||
if (port) {
|
||||
this.portDead = true;
|
||||
safeDisconnect(port);
|
||||
}
|
||||
}
|
||||
|
||||
private applyHello(hello: SessionHelloResult): void {
|
||||
this._status.sessionId = hello.sessionId;
|
||||
this._status.daemonVersion = hello.daemonVersion;
|
||||
this._status.capabilities = hello.capabilities;
|
||||
}
|
||||
|
||||
private setState(state: TransportState): void {
|
||||
if (state === 'connected') {
|
||||
this._status.needsPairing = false;
|
||||
this._status.fatal = null;
|
||||
this._status.retryAfterSec = null;
|
||||
}
|
||||
if (state !== 'connected') {
|
||||
this._status.sessionId = null;
|
||||
this._status.daemonVersion = null;
|
||||
this._status.capabilities = [];
|
||||
}
|
||||
const changed = this._state !== state;
|
||||
this._state = state;
|
||||
this._status.state = state;
|
||||
if (changed || state === 'disconnected') {
|
||||
const snapshot = this.status;
|
||||
for (const cb of this.stateListeners) {
|
||||
try {
|
||||
cb(snapshot);
|
||||
} catch (err) {
|
||||
console.error('[velox] state listener threw', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchEvent(event: string, payload: unknown): void {
|
||||
const set = this.listeners.get(event);
|
||||
if (!set) return;
|
||||
for (const cb of set) {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error(`[velox] listener for ${event} threw`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function safeDisconnect(port: NativePort): void {
|
||||
try {
|
||||
port.disconnect();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// JSON-RPC 2.0 request/response correlation, shared by both transports.
|
||||
//
|
||||
// It owns no socket. It builds requests, matches replies to them by id, enforces the
|
||||
// per-call deadline, and hands notifications back to the caller to route. A transport
|
||||
// embeds one and feeds it every inbound frame.
|
||||
|
||||
import { RpcError, RpcTimeoutError } from './types.js';
|
||||
|
||||
export interface JsonRpcRequest {
|
||||
jsonrpc: '2.0';
|
||||
id: number;
|
||||
method: string;
|
||||
params: unknown;
|
||||
}
|
||||
|
||||
export interface JsonRpcNotification {
|
||||
jsonrpc: '2.0';
|
||||
method: string;
|
||||
params: unknown;
|
||||
}
|
||||
|
||||
interface JsonRpcResponse {
|
||||
jsonrpc: '2.0';
|
||||
id: number | null;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string; data?: unknown };
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason: unknown) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
signal?: AbortSignal;
|
||||
onAbort?: () => void;
|
||||
}
|
||||
|
||||
export class RpcConnection {
|
||||
private seq = 0;
|
||||
private readonly pending = new Map<number, Pending>();
|
||||
|
||||
constructor(private readonly send: (frame: JsonRpcRequest) => void) {}
|
||||
|
||||
get inFlight(): number {
|
||||
return this.pending.size;
|
||||
}
|
||||
|
||||
request(method: string, params: unknown, timeoutMs: number, signal?: AbortSignal): Promise<unknown> {
|
||||
this.seq += 1;
|
||||
const id = this.seq;
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
const finish = (): Pending | undefined => {
|
||||
const p = this.pending.get(id);
|
||||
if (p) {
|
||||
this.pending.delete(id);
|
||||
clearTimeout(p.timer);
|
||||
if (p.signal && p.onAbort) p.signal.removeEventListener('abort', p.onAbort);
|
||||
}
|
||||
return p;
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
finish();
|
||||
reject(new RpcTimeoutError(method, timeoutMs));
|
||||
}, timeoutMs);
|
||||
|
||||
const entry: Pending = { resolve, reject, timer };
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
clearTimeout(timer);
|
||||
reject(signal.reason ?? new DOMException('call aborted', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
entry.signal = signal;
|
||||
entry.onAbort = () => {
|
||||
finish();
|
||||
reject(signal.reason ?? new DOMException('call aborted', 'AbortError'));
|
||||
};
|
||||
signal.addEventListener('abort', entry.onAbort, { once: true });
|
||||
}
|
||||
this.pending.set(id, entry);
|
||||
|
||||
try {
|
||||
this.send({ jsonrpc: '2.0', id, method, params });
|
||||
} catch (err) {
|
||||
finish();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed one inbound frame. Returns the notification to route, or null when the frame
|
||||
* was a response (already settled here), unparseable, or for an id we don't know.
|
||||
*/
|
||||
handleInbound(raw: string | Record<string, unknown>): JsonRpcNotification | null {
|
||||
let msg: unknown;
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
msg = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
msg = raw;
|
||||
}
|
||||
if (typeof msg !== 'object' || msg === null) return null;
|
||||
const frame = msg as JsonRpcResponse & JsonRpcNotification;
|
||||
if (frame.jsonrpc !== '2.0') return null;
|
||||
|
||||
if (frame.id === undefined || frame.id === null) {
|
||||
return typeof frame.method === 'string' ? { jsonrpc: '2.0', method: frame.method, params: frame.params } : null;
|
||||
}
|
||||
|
||||
const p = this.pending.get(frame.id);
|
||||
if (!p) return null; // unknown id: a late reply to a timed-out call, or not ours
|
||||
this.pending.delete(frame.id);
|
||||
clearTimeout(p.timer);
|
||||
if (p.signal && p.onAbort) p.signal.removeEventListener('abort', p.onAbort);
|
||||
|
||||
if (frame.error) {
|
||||
p.reject(new RpcError(frame.error.code, frame.error.message, frame.error.data));
|
||||
} else {
|
||||
p.resolve(frame.result);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Reject every outstanding call. Used when the connection drops. */
|
||||
failAll(reason: unknown): void {
|
||||
for (const p of this.pending.values()) {
|
||||
clearTimeout(p.timer);
|
||||
if (p.signal && p.onAbort) p.signal.removeEventListener('abort', p.onAbort);
|
||||
p.reject(reason);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// The few pieces of transport state that must survive a browser restart:
|
||||
// the pairing token, the user's manual transport override, and a hint of which
|
||||
// WebSocket port worked last time so the next scan starts there.
|
||||
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
import type { TransportKind } from './types.js';
|
||||
|
||||
const KEY = {
|
||||
token: 'velox.token',
|
||||
override: 'velox.transportOverride',
|
||||
wsPort: 'velox.wsPort',
|
||||
} as const;
|
||||
|
||||
/** 'auto' lets the runtime picker decide (see docs/adr/0003). */
|
||||
export type TransportOverride = 'auto' | TransportKind;
|
||||
|
||||
async function get<T>(key: string): Promise<T | undefined> {
|
||||
const bag = await browser.storage.local.get(key);
|
||||
return bag[key] as T | undefined;
|
||||
}
|
||||
|
||||
export async function getToken(): Promise<string | null> {
|
||||
return (await get<string>(KEY.token)) ?? null;
|
||||
}
|
||||
|
||||
export async function setToken(token: string | null): Promise<void> {
|
||||
if (token === null) await browser.storage.local.remove(KEY.token);
|
||||
else await browser.storage.local.set({ [KEY.token]: token });
|
||||
}
|
||||
|
||||
export async function getOverride(): Promise<TransportOverride> {
|
||||
const v = await get<string>(KEY.override);
|
||||
return v === 'ws' || v === 'uds' ? v : 'auto';
|
||||
}
|
||||
|
||||
export async function setOverride(value: TransportOverride): Promise<void> {
|
||||
await browser.storage.local.set({ [KEY.override]: value });
|
||||
}
|
||||
|
||||
export async function getCachedWsPort(): Promise<number | null> {
|
||||
const v = await get<number>(KEY.wsPort);
|
||||
return typeof v === 'number' ? v : null;
|
||||
}
|
||||
|
||||
export async function setCachedWsPort(port: number | null): Promise<void> {
|
||||
if (port === null) await browser.storage.local.remove(KEY.wsPort);
|
||||
else await browser.storage.local.set({ [KEY.wsPort]: port });
|
||||
}
|
||||
|
||||
/**
|
||||
* The moz-extension origin UUID — what session.pair wants as `extensionId` and what the
|
||||
* daemon sees in the WS upgrade's Origin header. `getURL('/')` is `moz-extension://<uuid>/`.
|
||||
*/
|
||||
export function extensionOriginId(): string {
|
||||
return new URL(browser.runtime.getURL('/')).host;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// The transport boundary between the extension and veloxd.
|
||||
//
|
||||
// docs/05 §4 defines this interface and the reason there are two implementations;
|
||||
// docs/adr/0003 settles which one is the default (WebSocket) and which is the
|
||||
// opportunistic upgrade (native messaging) under snap Firefox.
|
||||
//
|
||||
// The generated protocol module exports a type it also calls `Transport` — the
|
||||
// 'uds' | 'ws' wire discriminator. That is re-exported here as `TransportKind` so the
|
||||
// two names never collide.
|
||||
|
||||
import type {
|
||||
MethodName,
|
||||
Params,
|
||||
Result,
|
||||
Transport as TransportKind,
|
||||
} from '../../shared/protocol/index.js';
|
||||
import type { EventName, EventPayload } from '../../shared/protocol/index.js';
|
||||
|
||||
export type { TransportKind };
|
||||
|
||||
export type TransportState = 'connected' | 'connecting' | 'disconnected';
|
||||
|
||||
export type EventListener = (payload: unknown) => void;
|
||||
|
||||
export interface CallOptions {
|
||||
/** Overrides the method's contract deadline (METHODS[method].deadlineMs). */
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Everything a consumer needs to render "is Velox reachable" without guessing. */
|
||||
export interface TransportStatus {
|
||||
state: TransportState;
|
||||
/** The transport has stopped retrying and needs the user to pair (or re-pair). */
|
||||
needsPairing: boolean;
|
||||
/** Unrecoverable without a version/install change: protocol-major mismatch, or the
|
||||
* native host is not installed. The transport does not retry while this is set. */
|
||||
fatal: string | null;
|
||||
/** Seconds to wait before another pairing attempt makes sense (brute-force lockout). */
|
||||
retryAfterSec: number | null;
|
||||
sessionId: string | null;
|
||||
daemonVersion: string | null;
|
||||
capabilities: readonly string[];
|
||||
}
|
||||
|
||||
export interface VeloxTransport {
|
||||
readonly kind: TransportKind;
|
||||
readonly state: TransportState;
|
||||
readonly status: TransportStatus;
|
||||
|
||||
/** Resolves once a session.hello handshake has succeeded. Rejects if the first
|
||||
* attempt cannot get there; after that the transport reconnects on its own. */
|
||||
connect(): Promise<void>;
|
||||
/** Stop for good: closes the socket and cancels any pending reconnect. */
|
||||
disconnect(): void;
|
||||
|
||||
call<M extends MethodName>(method: M, params: Params<M>, opts?: CallOptions): Promise<Result<M>>;
|
||||
|
||||
on<E extends EventName>(event: E, cb: (payload: EventPayload<E>) => void): void;
|
||||
on(event: string, cb: EventListener): void;
|
||||
off(event: string, cb: EventListener): void;
|
||||
|
||||
/** Fires on every state change. Returns an unsubscribe. */
|
||||
onStateChange(cb: (status: TransportStatus) => void): () => void;
|
||||
}
|
||||
|
||||
// --- errors ---------------------------------------------------------------------------
|
||||
|
||||
/** A JSON-RPC error object came back for our call. `code` is a protocol ErrorCode. */
|
||||
export class RpcError extends Error {
|
||||
constructor(
|
||||
readonly code: number,
|
||||
message: string,
|
||||
readonly data?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'RpcError';
|
||||
}
|
||||
}
|
||||
|
||||
/** The call's deadline elapsed with no reply. The socket is left alone; a late reply
|
||||
* is dropped. The capture path treats this like any rejection: fail open. */
|
||||
export class RpcTimeoutError extends Error {
|
||||
constructor(
|
||||
readonly method: string,
|
||||
readonly timeoutMs: number,
|
||||
) {
|
||||
super(`${method} timed out after ${timeoutMs} ms`);
|
||||
this.name = 'RpcTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
/** The connection dropped (or was never up) while a call was outstanding. */
|
||||
export class TransportClosedError extends Error {
|
||||
constructor(message = 'transport is not connected') {
|
||||
super(message);
|
||||
this.name = 'TransportClosedError';
|
||||
}
|
||||
}
|
||||
|
||||
/** The caller asked for a method the contract does not permit on this transport.
|
||||
* Rejected locally so it fails in one place, not as a puzzling -32003 later. */
|
||||
export class MethodNotAllowedError extends Error {
|
||||
constructor(
|
||||
readonly method: string,
|
||||
readonly transport: TransportKind,
|
||||
) {
|
||||
super(`${method} is not permitted on the ${transport} transport`);
|
||||
this.name = 'MethodNotAllowedError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
// WebSocketTransport — the primary path (docs/adr/0003).
|
||||
//
|
||||
// ws://127.0.0.1:<port> port discovered by scanning 52000–52016 and verifying a
|
||||
// session.hello handshake
|
||||
// pairing session.pair once, token kept in browser.storage.local,
|
||||
// sent on every later connect
|
||||
// reconnect exponential backoff with jitter; stops only on a
|
||||
// protocol-major mismatch or a refused/again-rate-limited pairing
|
||||
//
|
||||
// It holds no download logic and never inspects payloads beyond the JSON-RPC envelope.
|
||||
|
||||
import {
|
||||
ErrorCode,
|
||||
METHODS,
|
||||
PROTOCOL_VERSION,
|
||||
isAllowedOn,
|
||||
} from '../../shared/protocol/index.js';
|
||||
import type {
|
||||
MethodName,
|
||||
Params,
|
||||
Result,
|
||||
SessionHelloParams,
|
||||
SessionHelloResult,
|
||||
SessionPairParams,
|
||||
SessionPairResult,
|
||||
} from '../../shared/protocol/index.js';
|
||||
|
||||
import { Backoff, type BackoffOptions } from './backoff.js';
|
||||
import { candidatePorts, WS_PORT_RANGE, type PortRange } from './discovery.js';
|
||||
import { RpcConnection } from './rpc.js';
|
||||
import {
|
||||
MethodNotAllowedError,
|
||||
RpcError,
|
||||
TransportClosedError,
|
||||
type CallOptions,
|
||||
type EventListener,
|
||||
type TransportState,
|
||||
type TransportStatus,
|
||||
type VeloxTransport,
|
||||
} from './types.js';
|
||||
|
||||
/** The slice of the WebSocket API this transport uses. Satisfied by the DOM `WebSocket`
|
||||
* and by the `ws` package's client, so tests can run against a real loopback server. */
|
||||
export interface MinimalWebSocket {
|
||||
send(data: string): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
addEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (ev: unknown) => void): void;
|
||||
removeEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (ev: unknown) => void): void;
|
||||
}
|
||||
export type WebSocketCtor = new (url: string) => MinimalWebSocket;
|
||||
|
||||
const CLIENT_NAME = 'Firefox (Velox extension)';
|
||||
|
||||
export interface WebSocketTransportDeps {
|
||||
/** The pairing token, or null when unpaired. */
|
||||
getToken(): Promise<string | null>;
|
||||
setToken(token: string | null): Promise<void>;
|
||||
/** Last-known-good port to try first, or null. */
|
||||
getCachedPort(): Promise<number | null>;
|
||||
setCachedPort(port: number | null): Promise<void>;
|
||||
/** moz-extension origin UUID for session.pair. */
|
||||
extensionId: string;
|
||||
|
||||
webSocketCtor?: WebSocketCtor;
|
||||
portRange?: PortRange;
|
||||
clientName?: string;
|
||||
backoff?: BackoffOptions;
|
||||
/** Pair automatically on first connect when there is no token. Options can disable
|
||||
* this and call pair() itself. Default true. */
|
||||
autoPair?: boolean;
|
||||
/** How long to wait for the socket to open before moving to the next port. */
|
||||
openTimeoutMs?: number;
|
||||
}
|
||||
|
||||
type PortOutcome =
|
||||
| { kind: 'connected'; hello: SessionHelloResult }
|
||||
| { kind: 'fatal'; reason: string; code: number }
|
||||
| { kind: 'needs-pairing'; reason: string; code: number; retryAfterSec: number | null }
|
||||
| { kind: 'retry'; reason: string }; // nothing velox-shaped answered here — try the next port
|
||||
|
||||
type HelloOutcome = { ok: true; hello: SessionHelloResult } | { ok: false; error: unknown };
|
||||
|
||||
export class WebSocketTransport implements VeloxTransport {
|
||||
readonly kind = 'ws' as const;
|
||||
|
||||
private readonly ctor: WebSocketCtor;
|
||||
private readonly portRange: PortRange;
|
||||
private readonly clientName: string;
|
||||
private readonly autoPair: boolean;
|
||||
private readonly openTimeoutMs: number;
|
||||
private readonly backoff: Backoff;
|
||||
|
||||
private ws: MinimalWebSocket | null = null;
|
||||
private rpc: RpcConnection | null = null;
|
||||
private socketDropped = false;
|
||||
|
||||
private stopped = false;
|
||||
private connectPromise: Promise<void> | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private readonly listeners = new Map<string, Set<EventListener>>();
|
||||
private readonly stateListeners = new Set<(status: TransportStatus) => void>();
|
||||
|
||||
private _state: TransportState = 'disconnected';
|
||||
private readonly _status: TransportStatus = {
|
||||
state: 'disconnected',
|
||||
needsPairing: false,
|
||||
fatal: null,
|
||||
retryAfterSec: null,
|
||||
sessionId: null,
|
||||
daemonVersion: null,
|
||||
capabilities: [],
|
||||
};
|
||||
|
||||
constructor(private readonly deps: WebSocketTransportDeps) {
|
||||
this.ctor = deps.webSocketCtor ?? (globalThis.WebSocket as unknown as WebSocketCtor);
|
||||
this.portRange = deps.portRange ?? WS_PORT_RANGE;
|
||||
this.clientName = deps.clientName ?? CLIENT_NAME;
|
||||
this.autoPair = deps.autoPair ?? true;
|
||||
this.openTimeoutMs = deps.openTimeoutMs ?? 2000;
|
||||
this.backoff = new Backoff(deps.backoff);
|
||||
}
|
||||
|
||||
get state(): TransportState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
get status(): TransportStatus {
|
||||
return { ...this._status, capabilities: [...this._status.capabilities] };
|
||||
}
|
||||
|
||||
connect(): Promise<void> {
|
||||
if (this._state === 'connected') return Promise.resolve();
|
||||
if (this.connectPromise) return this.connectPromise;
|
||||
this.stopped = false;
|
||||
this.connectPromise = this.openLoop().finally(() => {
|
||||
this.connectPromise = null;
|
||||
});
|
||||
return this.connectPromise;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.stopped = true;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.teardownSocket();
|
||||
this.setState('disconnected');
|
||||
}
|
||||
|
||||
call<M extends MethodName>(method: M, params: Params<M>, opts?: CallOptions): Promise<Result<M>> {
|
||||
if (!isAllowedOn(method, 'ws')) {
|
||||
return Promise.reject(new MethodNotAllowedError(method, 'ws'));
|
||||
}
|
||||
const rpc = this.rpc;
|
||||
if (!rpc || this._state !== 'connected') {
|
||||
return Promise.reject(new TransportClosedError());
|
||||
}
|
||||
const timeoutMs = opts?.timeoutMs ?? METHODS[method].deadlineMs;
|
||||
return rpc.request(method, params, timeoutMs, opts?.signal) as Promise<Result<M>>;
|
||||
}
|
||||
|
||||
on(event: string, cb: EventListener): void {
|
||||
let set = this.listeners.get(event);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.listeners.set(event, set);
|
||||
}
|
||||
set.add(cb);
|
||||
}
|
||||
|
||||
off(event: string, cb: EventListener): void {
|
||||
this.listeners.get(event)?.delete(cb);
|
||||
}
|
||||
|
||||
onStateChange(cb: (status: TransportStatus) => void): () => void {
|
||||
this.stateListeners.add(cb);
|
||||
return () => this.stateListeners.delete(cb);
|
||||
}
|
||||
|
||||
// --- connect loop ------------------------------------------------------------------
|
||||
|
||||
private async openLoop(): Promise<void> {
|
||||
this.setState('connecting');
|
||||
const preferred = await this.deps.getCachedPort();
|
||||
const ports = candidatePorts(this.portRange, preferred);
|
||||
|
||||
let lastReason = 'no daemon found';
|
||||
for (const port of ports) {
|
||||
if (this.stopped) throw new TransportClosedError('connect cancelled');
|
||||
const outcome = await this.tryPort(port);
|
||||
switch (outcome.kind) {
|
||||
case 'connected':
|
||||
await this.deps.setCachedPort(port);
|
||||
this.backoff.reset();
|
||||
this.applyHello(outcome.hello);
|
||||
this.setState('connected');
|
||||
return;
|
||||
case 'fatal':
|
||||
this._status.fatal = outcome.reason;
|
||||
this.setState('disconnected');
|
||||
throw new RpcError(outcome.code, outcome.reason);
|
||||
case 'needs-pairing':
|
||||
this._status.needsPairing = true;
|
||||
this._status.retryAfterSec = outcome.retryAfterSec;
|
||||
this.setState('disconnected');
|
||||
throw new RpcError(outcome.code, outcome.reason);
|
||||
case 'retry':
|
||||
lastReason = outcome.reason;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing answered. This is the recoverable case: veloxd may simply not be up yet.
|
||||
this.setState('disconnected');
|
||||
this.scheduleReconnect();
|
||||
throw new TransportClosedError(
|
||||
`no veloxd on ws://127.0.0.1:${this.portRange.start}-${this.portRange.end} (${lastReason})`,
|
||||
);
|
||||
}
|
||||
|
||||
private async tryPort(port: number): Promise<PortOutcome> {
|
||||
let ws: MinimalWebSocket;
|
||||
try {
|
||||
ws = new this.ctor(`ws://127.0.0.1:${port}`);
|
||||
} catch (err) {
|
||||
return { kind: 'retry', reason: `construct failed: ${String(err)}` };
|
||||
}
|
||||
|
||||
if (!(await this.awaitOpen(ws))) {
|
||||
safeClose(ws);
|
||||
return { kind: 'retry', reason: 'connection refused' };
|
||||
}
|
||||
|
||||
const rpc = new RpcConnection((frame) => ws.send(JSON.stringify(frame)));
|
||||
const onMessage = (ev: unknown): void => {
|
||||
const data = (ev as { data?: unknown }).data;
|
||||
const note = rpc.handleInbound(typeof data === 'string' ? data : String(data));
|
||||
if (note) this.dispatchEvent(note.method, note.params);
|
||||
};
|
||||
ws.addEventListener('message', onMessage);
|
||||
|
||||
// If the socket drops mid-handshake, fail the pending hello now rather than
|
||||
// waiting out its deadline.
|
||||
const onEarlyDrop = (): void => rpc.failAll(new TransportClosedError('socket closed during handshake'));
|
||||
ws.addEventListener('close', onEarlyDrop);
|
||||
ws.addEventListener('error', onEarlyDrop);
|
||||
|
||||
const outcome = await this.runHandshake(rpc);
|
||||
|
||||
ws.removeEventListener('close', onEarlyDrop);
|
||||
ws.removeEventListener('error', onEarlyDrop);
|
||||
|
||||
if (outcome.kind === 'connected') {
|
||||
this.adoptSocket(ws, rpc, onMessage);
|
||||
} else {
|
||||
safeClose(ws);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/** The hello (+ optional pair + re-hello) decision tree. Touches no socket state. */
|
||||
private async runHandshake(rpc: RpcConnection): Promise<PortOutcome> {
|
||||
let hello = await this.hello(rpc);
|
||||
if (hello.ok) return { kind: 'connected', hello: hello.hello };
|
||||
|
||||
if (hello.error instanceof RpcError && hello.error.code === ErrorCode.VersionMismatch) {
|
||||
return { kind: 'fatal', reason: `protocol mismatch: ${hello.error.message}`, code: hello.error.code };
|
||||
}
|
||||
if (!(hello.error instanceof RpcError) || hello.error.code !== ErrorCode.NotPaired) {
|
||||
// Timed out, dropped, or an unexpected error to session.hello.
|
||||
return { kind: 'retry', reason: describe(hello.error) };
|
||||
}
|
||||
|
||||
// Unpaired.
|
||||
if (!this.autoPair) {
|
||||
return { kind: 'needs-pairing', reason: 'no pairing token', code: hello.error.code, retryAfterSec: null };
|
||||
}
|
||||
const paired = await this.pair(rpc);
|
||||
if (!paired.ok) {
|
||||
const err = paired.error;
|
||||
if (err instanceof RpcError && err.code === ErrorCode.RateLimited) {
|
||||
const retry = (err.data as { retryAfterSec?: number } | undefined)?.retryAfterSec ?? null;
|
||||
return { kind: 'needs-pairing', reason: 'pairing rate-limited', code: err.code, retryAfterSec: retry };
|
||||
}
|
||||
const code = err instanceof RpcError ? err.code : ErrorCode.NotPaired;
|
||||
const reason = err instanceof RpcError ? err.message : 'pairing failed';
|
||||
return { kind: 'needs-pairing', reason, code, retryAfterSec: null };
|
||||
}
|
||||
await this.deps.setToken(paired.token);
|
||||
|
||||
hello = await this.hello(rpc);
|
||||
if (hello.ok) return { kind: 'connected', hello: hello.hello };
|
||||
if (hello.error instanceof RpcError && hello.error.code === ErrorCode.VersionMismatch) {
|
||||
return { kind: 'fatal', reason: `protocol mismatch: ${hello.error.message}`, code: hello.error.code };
|
||||
}
|
||||
return { kind: 'retry', reason: `hello after pair failed: ${describe(hello.error)}` };
|
||||
}
|
||||
|
||||
private async hello(rpc: RpcConnection): Promise<HelloOutcome> {
|
||||
const token = await this.deps.getToken();
|
||||
const params: SessionHelloParams = {
|
||||
clientType: 'extension',
|
||||
clientName: this.clientName,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
token: token ?? null,
|
||||
};
|
||||
try {
|
||||
const hello = (await rpc.request(
|
||||
'session.hello',
|
||||
params,
|
||||
METHODS['session.hello'].deadlineMs,
|
||||
)) as SessionHelloResult;
|
||||
return { ok: true, hello };
|
||||
} catch (error) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
}
|
||||
|
||||
private async pair(rpc: RpcConnection): Promise<{ ok: true; token: string } | { ok: false; error: unknown }> {
|
||||
const params: SessionPairParams = {
|
||||
clientName: this.clientName,
|
||||
extensionId: this.deps.extensionId,
|
||||
};
|
||||
try {
|
||||
const res = (await rpc.request(
|
||||
'session.pair',
|
||||
params,
|
||||
METHODS['session.pair'].deadlineMs,
|
||||
)) as SessionPairResult;
|
||||
return { ok: true, token: res.token };
|
||||
} catch (error) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
}
|
||||
|
||||
private awaitOpen(ws: MinimalWebSocket): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (value: boolean): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
ws.removeEventListener('open', onOpen);
|
||||
ws.removeEventListener('error', onError);
|
||||
ws.removeEventListener('close', onClose);
|
||||
resolve(value);
|
||||
};
|
||||
const onOpen = (): void => finish(true);
|
||||
const onError = (): void => finish(false);
|
||||
const onClose = (): void => finish(false);
|
||||
const timer = setTimeout(() => finish(false), this.openTimeoutMs);
|
||||
(timer as { unref?: () => void }).unref?.();
|
||||
ws.addEventListener('open', onOpen);
|
||||
ws.addEventListener('error', onError);
|
||||
ws.addEventListener('close', onClose);
|
||||
});
|
||||
}
|
||||
|
||||
private adoptSocket(ws: MinimalWebSocket, rpc: RpcConnection, onMessage: (ev: unknown) => void): void {
|
||||
this.ws = ws;
|
||||
this.rpc = rpc;
|
||||
this.socketDropped = false;
|
||||
const onDrop = (): void => this.handleDrop(ws);
|
||||
ws.addEventListener('close', onDrop);
|
||||
ws.addEventListener('error', onDrop);
|
||||
// message listener stays attached from tryPort
|
||||
void onMessage;
|
||||
}
|
||||
|
||||
private handleDrop(ws: MinimalWebSocket): void {
|
||||
if (ws !== this.ws || this.socketDropped) return;
|
||||
this.socketDropped = true;
|
||||
this.rpc?.failAll(new TransportClosedError('connection dropped'));
|
||||
this.ws = null;
|
||||
this.rpc = null;
|
||||
this.setState('disconnected');
|
||||
if (this.stopped || this._status.fatal || this._status.needsPairing) return;
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.stopped || this.reconnectTimer) return;
|
||||
const delay = this.backoff.next();
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
if (this.stopped) return;
|
||||
this.openLoop().catch(() => {
|
||||
// openLoop() already schedules the next attempt on the recoverable path,
|
||||
// and latches status on the terminal ones. Nothing to do here.
|
||||
});
|
||||
}, delay);
|
||||
(this.reconnectTimer as { unref?: () => void }).unref?.();
|
||||
}
|
||||
|
||||
private teardownSocket(): void {
|
||||
const ws = this.ws;
|
||||
this.ws = null;
|
||||
this.rpc?.failAll(new TransportClosedError('transport closed'));
|
||||
this.rpc = null;
|
||||
if (ws) {
|
||||
this.socketDropped = true;
|
||||
safeClose(ws);
|
||||
}
|
||||
}
|
||||
|
||||
private applyHello(hello: SessionHelloResult): void {
|
||||
this._status.sessionId = hello.sessionId;
|
||||
this._status.daemonVersion = hello.daemonVersion;
|
||||
this._status.capabilities = hello.capabilities;
|
||||
}
|
||||
|
||||
private setState(state: TransportState): void {
|
||||
if (state === 'connected') {
|
||||
this._status.needsPairing = false;
|
||||
this._status.fatal = null;
|
||||
this._status.retryAfterSec = null;
|
||||
}
|
||||
if (state !== 'connected') {
|
||||
this._status.sessionId = null;
|
||||
this._status.daemonVersion = null;
|
||||
this._status.capabilities = [];
|
||||
}
|
||||
const changed = this._state !== state;
|
||||
this._state = state;
|
||||
this._status.state = state;
|
||||
if (changed || state === 'disconnected') {
|
||||
const snapshot = this.status;
|
||||
for (const cb of this.stateListeners) {
|
||||
try {
|
||||
cb(snapshot);
|
||||
} catch (err) {
|
||||
console.error('[velox] state listener threw', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchEvent(event: string, payload: unknown): void {
|
||||
const set = this.listeners.get(event);
|
||||
if (!set) return;
|
||||
for (const cb of set) {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error(`[velox] listener for ${event} threw`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function safeClose(ws: MinimalWebSocket): void {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* already closing */
|
||||
}
|
||||
}
|
||||
|
||||
function describe(err: unknown): string {
|
||||
if (err instanceof RpcError) return `rpc ${err.code}: ${err.message}`;
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err);
|
||||
}
|
||||
Reference in New Issue
Block a user