import { buildDeviceAuthPayload } from "../../../src/gateway/device-auth.js" ;
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
type GatewayClientMode,
type GatewayClientName,
} from "../../../src/gateway/protocol/client-info.js" ;
import {
ConnectErrorDetailCodes,
formatConnectErrorMessage,
readConnectErrorRecoveryAdvice,
readConnectErrorDetailCode,
} from "../../../src/gateway/protocol/connect-error-details.js" ;
import { clearDeviceAuthToken, loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth.ts" ;
import { loadOrCreateDeviceIdentity, signDevicePayload } from "./device-identity.ts" ;
import { generateUUID } from "./uuid.ts" ;
export type GatewayEventFrame = {
type: "event" ;
event: string;
payload?: unknown;
seq?: number;
stateVersion?: { presence: number; health: number };
};
export type GatewayResponseFrame = {
type: "res" ;
id: string;
ok: boolean ;
payload?: unknown;
error?: {
code: string;
message: string;
details?: unknown;
retryable?: boolean ;
retryAfterMs?: number;
};
};
export type GatewayErrorInfo = {
code: string;
message: string;
details?: unknown;
retryable?: boolean ;
retryAfterMs?: number;
};
export class GatewayRequestError extends Error {
readonly gatewayCode: string;
readonly details?: unknown;
readonly retryable: boolean ;
readonly retryAfterMs?: number;
constructor(error: GatewayErrorInfo) {
super (formatConnectErrorMessage({ message: error.message, details: error.details }));
this .name = "GatewayRequestError" ;
this .gatewayCode = error.code;
this .details = error.details;
this .retryable = error.retryable === true ;
this .retryAfterMs = error.retryAfterMs;
}
}
export function resolveGatewayErrorDetailCode(
error: { details?: unknown } | null | undefined,
): string | null {
return readConnectErrorDetailCode(error?.details);
}
/**
* Auth errors that won't resolve without user action — don't auto-reconnect.
*
* NOTE: AUTH_TOKEN_MISMATCH is intentionally NOT included here because the
* browser client supports a bounded one-time retry with a cached device token
* when the endpoint is trusted. Reconnect suppression for mismatch is handled
* with client state (after retry budget is exhausted).
*/
export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined): boolean {
if (!error) {
return false ;
}
const code = resolveGatewayErrorDetailCode(error);
return (
code === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING ||
code === ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID ||
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||
code === ConnectErrorDetailCodes.PAIRING_REQUIRED ||
code === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
code === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED
);
}
function isTrustedRetryEndpoint(url: string): boolean {
try {
const gatewayUrl = new URL(url, window.location.href);
const host = gatewayUrl.hostname.trim().toLowerCase();
const isLoopbackHost =
host === "localhost" || host === "::1" || host === "[::1]" || host === "127.0.0.1" ;
const isLoopbackIPv4 = host.startsWith("127." );
if (isLoopbackHost || isLoopbackIPv4) {
return true ;
}
const pageUrl = new URL(window.location.href);
return gatewayUrl.host === pageUrl.host;
} catch {
return false ;
}
}
export type GatewayHelloOk = {
type: "hello-ok" ;
protocol: number;
server?: {
version?: string;
connId?: string;
};
features?: { methods?: string[]; events?: string[] };
snapshot?: unknown;
auth?: {
deviceToken?: string;
role?: string;
scopes?: string[];
issuedAtMs?: number;
};
canvasHostUrl?: string;
policy?: { tickIntervalMs?: number };
};
type Pending = {
resolve: (value: unknown) => void ;
reject: (err: unknown) => void ;
};
type SelectedConnectAuth = {
authToken?: string;
authDeviceToken?: string;
authPassword?: string;
resolvedDeviceToken?: string;
storedToken?: string;
canFallbackToShared: boolean ;
};
export const CONTROL_UI_OPERATOR_ROLE = "operator" ;
export const CONTROL_UI_OPERATOR_SCOPES = [
"operator.admin" ,
"operator.read" ,
"operator.write" ,
"operator.approvals" ,
"operator.pairing" ,
] as const ;
export type GatewayConnectAuth = {
token?: string;
deviceToken?: string;
password?: string;
};
export type GatewayConnectDevice = {
id: string;
publicKey: string;
signature: string;
signedAt: number;
nonce: string;
};
export type GatewayConnectClientInfo = {
id: GatewayClientName;
version: string;
platform: string;
mode: GatewayClientMode;
instanceId?: string;
};
export type GatewayConnectParams = {
minProtocol: 3 ;
maxProtocol: 3 ;
client: GatewayConnectClientInfo;
role: string;
scopes: string[];
device?: GatewayConnectDevice;
caps: string[];
auth?: GatewayConnectAuth;
userAgent: string;
locale: string;
};
type ConnectPlan = {
role: string;
scopes: string[];
client: GatewayConnectClientInfo;
explicitGatewayToken?: string;
selectedAuth: SelectedConnectAuth;
auth?: GatewayConnectAuth;
deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null ;
device?: GatewayConnectDevice;
};
type DeviceTokenRetryDecision = {
deviceTokenRetryBudgetUsed: boolean ;
authDeviceToken?: string;
explicitGatewayToken?: string;
deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null ;
storedToken?: string;
canRetryWithDeviceTokenHint: boolean ;
url: string;
};
export type GatewayBrowserClientOptions = {
url: string;
token?: string;
password?: string;
clientName?: GatewayClientName;
clientVersion?: string;
platform?: string;
mode?: GatewayClientMode;
instanceId?: string;
onHello?: (hello: GatewayHelloOk) => void ;
onEvent?: (evt: GatewayEventFrame) => void ;
onClose?: (info: { code: number; reason: string; error?: GatewayErrorInfo }) => void ;
onGap?: (info: { expected: number; received: number }) => void ;
};
export type GatewayEventListener = (evt: GatewayEventFrame) => void ;
// 4008 = application-defined code (browser rejects 1008 "Policy Violation")
const CONNECT_FAILED_CLOSE_CODE = 4008 ;
function buildGatewayConnectAuth(
selectedAuth: SelectedConnectAuth,
): GatewayConnectAuth | undefined {
const authToken = selectedAuth.authToken;
if (!(authToken || selectedAuth.authPassword)) {
return undefined;
}
return {
token: authToken,
deviceToken: selectedAuth.authDeviceToken ?? selectedAuth.resolvedDeviceToken,
password: selectedAuth.authPassword,
};
}
async function buildGatewayConnectDevice(params: {
deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null ;
client: GatewayConnectClientInfo;
role: string;
scopes: string[];
authToken?: string;
connectNonce: string | null ;
}): Promise<GatewayConnectDevice | undefined> {
const { deviceIdentity } = params;
if (!deviceIdentity) {
return undefined;
}
const signedAtMs = Date.now();
const nonce = params.connectNonce ?? "" ;
const payload = buildDeviceAuthPayload({
deviceId: deviceIdentity.deviceId,
clientId: params.client.id,
clientMode: params.client.mode,
role: params.role,
scopes: params.scopes,
signedAtMs,
token: params.authToken ?? null ,
nonce,
});
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
return {
id: deviceIdentity.deviceId,
publicKey: deviceIdentity.publicKey,
signature,
signedAt: signedAtMs,
nonce,
};
}
export function shouldRetryWithDeviceToken(params: DeviceTokenRetryDecision): boolean {
return (
!params.deviceTokenRetryBudgetUsed &&
!params.authDeviceToken &&
Boolean (params.explicitGatewayToken) &&
Boolean (params.deviceIdentity) &&
Boolean (params.storedToken) &&
params.canRetryWithDeviceTokenHint &&
isTrustedRetryEndpoint(params.url)
);
}
export class GatewayBrowserClient {
private ws: WebSocket | null = null ;
private pending = new Map<string, Pending>();
private closed = false ;
private lastSeq: number | null = null ;
private connectNonce: string | null = null ;
private connectSent = false ;
private connectTimer: number | null = null ;
private backoffMs = 800 ;
private pendingConnectError: GatewayErrorInfo | undefined;
private pendingDeviceTokenRetry = false ;
private deviceTokenRetryBudgetUsed = false ;
private eventListeners = new Set<GatewayEventListener>();
constructor(private opts: GatewayBrowserClientOptions) {}
start() {
this .closed = false ;
this .connect();
}
stop() {
this .closed = true ;
this .clearConnectTimer();
this .ws?.close();
this .ws = null ;
this .pendingConnectError = undefined;
this .pendingDeviceTokenRetry = false ;
this .deviceTokenRetryBudgetUsed = false ;
this .flushPending(new Error("gateway client stopped" ));
}
get connected() {
return this .ws?.readyState === WebSocket.OPEN;
}
private connect() {
if (this .closed) {
return ;
}
this .ws = new WebSocket(this .opts.url);
this .ws.addEventListener("open" , () => this .queueConnect());
this .ws.addEventListener("message" , (ev) => this .handleMessage(String(ev.data ?? "" )));
this .ws.addEventListener("close" , (ev) => {
const reason = ev.reason ?? "" ;
const connectError = this .pendingConnectError;
this .pendingConnectError = undefined;
this .ws = null ;
this .flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
this .opts.onClose?.({ code: ev.code, reason, error: connectError });
const connectErrorCode = resolveGatewayErrorDetailCode(connectError);
if (
connectErrorCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH &&
this .deviceTokenRetryBudgetUsed &&
!this .pendingDeviceTokenRetry
) {
return ;
}
if (!isNonRecoverableAuthError(connectError)) {
this .scheduleReconnect();
}
});
this .ws.addEventListener("error" , () => {
// ignored; close handler will fire
});
}
private scheduleReconnect() {
if (this .closed) {
return ;
}
const delay = this .backoffMs;
this .backoffMs = Math.min(this .backoffMs * 1 .7 , 15 _000 );
this .clearConnectTimer();
this .connectTimer = window.setTimeout(() => {
this .connectTimer = null ;
this .connect();
}, delay);
}
private flushPending(err: Error) {
for (const [, p] of this .pending) {
p.reject(err);
}
this .pending.clear();
}
private buildConnectClient(): GatewayConnectClientInfo {
return {
id: this .opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: this .opts.clientVersion ?? "control-ui" ,
platform: this .opts.platform ?? navigator.platform ?? "web" ,
mode: this .opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
instanceId: this .opts.instanceId,
};
}
private buildConnectParams(plan: ConnectPlan): GatewayConnectParams {
return {
minProtocol: 3 ,
maxProtocol: 3 ,
client: plan.client,
role: plan.role,
scopes: plan.scopes,
device: plan.device,
caps: ["tool-events" ],
auth: plan.auth,
userAgent: navigator.userAgent,
locale: navigator.language,
};
}
private async buildConnectPlan(): Promise<ConnectPlan> {
const role = CONTROL_UI_OPERATOR_ROLE;
const scopes = [...CONTROL_UI_OPERATOR_SCOPES];
const client = this .buildConnectClient();
const explicitGatewayToken = this .opts.token?.trim() || undefined;
const explicitPassword = this .opts.password?.trim() || undefined;
// crypto.subtle is only available in secure contexts (HTTPS, localhost).
// Over plain HTTP, we skip device identity and fall back to token-only auth.
// Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled.
const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle;
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null ;
let selectedAuth: SelectedConnectAuth = {
authToken: explicitGatewayToken,
authPassword: explicitPassword,
canFallbackToShared: false ,
};
if (isSecureContext) {
deviceIdentity = await loadOrCreateDeviceIdentity();
selectedAuth = this .selectConnectAuth({
role,
deviceId: deviceIdentity.deviceId,
});
if (this .pendingDeviceTokenRetry && selectedAuth.authDeviceToken) {
this .pendingDeviceTokenRetry = false ;
}
}
return {
role,
scopes,
client,
explicitGatewayToken,
selectedAuth,
auth: buildGatewayConnectAuth(selectedAuth),
deviceIdentity,
device: await buildGatewayConnectDevice({
deviceIdentity,
client,
role,
scopes,
authToken: selectedAuth.authToken,
connectNonce: this .connectNonce,
}),
};
}
private handleConnectHello(hello: GatewayHelloOk, plan: ConnectPlan) {
this .pendingDeviceTokenRetry = false ;
this .deviceTokenRetryBudgetUsed = false ;
if (hello?.auth?.deviceToken && plan.deviceIdentity) {
storeDeviceAuthToken({
deviceId: plan.deviceIdentity.deviceId,
role: hello.auth.role ?? plan.role,
token: hello.auth.deviceToken,
scopes: hello.auth.scopes ?? [],
});
}
this .backoffMs = 800 ;
this .opts.onHello?.(hello);
}
private handleConnectFailure(err: unknown, plan: ConnectPlan) {
const connectErrorCode =
err instanceof GatewayRequestError ? resolveGatewayErrorDetailCode(err) : null ;
const recoveryAdvice =
err instanceof GatewayRequestError ? readConnectErrorRecoveryAdvice(err.details) : {};
const retryWithDeviceTokenRecommended =
recoveryAdvice.recommendedNextStep === "retry_with_device_token" ;
const canRetryWithDeviceTokenHint =
recoveryAdvice.canRetryWithDeviceToken === true ||
retryWithDeviceTokenRecommended ||
connectErrorCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH;
if (
shouldRetryWithDeviceToken({
deviceTokenRetryBudgetUsed: this .deviceTokenRetryBudgetUsed,
authDeviceToken: plan.selectedAuth.authDeviceToken,
explicitGatewayToken: plan.explicitGatewayToken,
deviceIdentity: plan.deviceIdentity,
storedToken: plan.selectedAuth.storedToken,
canRetryWithDeviceTokenHint,
url: this .opts.url,
})
) {
this .pendingDeviceTokenRetry = true ;
this .deviceTokenRetryBudgetUsed = true ;
}
if (err instanceof GatewayRequestError) {
this .pendingConnectError = {
code: err.gatewayCode,
message: err.message,
details: err.details,
retryable: err.retryable,
retryAfterMs: err.retryAfterMs,
};
} else {
this .pendingConnectError = undefined;
}
if (
plan.selectedAuth.canFallbackToShared &&
plan.deviceIdentity &&
connectErrorCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH
) {
clearDeviceAuthToken({ deviceId: plan.deviceIdentity.deviceId, role: plan.role });
}
this .ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed" );
}
private async sendConnect() {
if (this .connectSent) {
return ;
}
this .connectSent = true ;
this .clearConnectTimer();
const plan = await this .buildConnectPlan();
void this .request<GatewayHelloOk>("connect" , this .buildConnectParams(plan))
.then((hello) => this .handleConnectHello(hello, plan))
.catch ((err: unknown) => this .handleConnectFailure(err, plan));
}
private handleMessage(raw: string) {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return ;
}
const frame = parsed as { type?: unknown };
if (frame.type === "event" ) {
const evt = parsed as GatewayEventFrame;
if (evt.event === "connect.challenge" ) {
const payload = evt.payload as { nonce?: unknown } | undefined;
const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null ;
if (nonce) {
this .connectNonce = nonce;
void this .sendConnect();
}
return ;
}
const seq = typeof evt.seq === "number" ? evt.seq : null ;
if (seq !== null ) {
if (this .lastSeq !== null && seq > this .lastSeq + 1 ) {
this .opts.onGap?.({ expected: this .lastSeq + 1 , received: seq });
}
this .lastSeq = seq;
}
try {
this .opts.onEvent?.(evt);
for (const listener of this .eventListeners) {
listener(evt);
}
} catch (err) {
console.error("[gateway] event handler error:" , err);
}
return ;
}
if (frame.type === "res" ) {
const res = parsed as GatewayResponseFrame;
const pending = this .pending.get(res.id);
if (!pending) {
return ;
}
this .pending.delete (res.id);
if (res.ok) {
pending.resolve(res.payload);
} else {
pending.reject(
new GatewayRequestError({
code: res.error?.code ?? "UNAVAILABLE" ,
message: res.error?.message ?? "request failed" ,
details: res.error?.details,
retryable: res.error?.retryable,
retryAfterMs: res.error?.retryAfterMs,
}),
);
}
return ;
}
}
private selectConnectAuth(params: { role: string; deviceId: string }): SelectedConnectAuth {
const explicitGatewayToken = this .opts.token?.trim() || undefined;
const authPassword = this .opts.password?.trim() || undefined;
const storedEntry = loadDeviceAuthToken({
deviceId: params.deviceId,
role: params.role,
});
const storedScopes = storedEntry?.scopes ?? [];
const storedTokenCanRead =
params.role !== CONTROL_UI_OPERATOR_ROLE ||
storedScopes.includes("operator.read" ) ||
storedScopes.includes("operator.write" ) ||
storedScopes.includes("operator.admin" );
const storedToken = storedTokenCanRead ? storedEntry?.token : undefined;
const shouldUseDeviceRetryToken =
this .pendingDeviceTokenRetry &&
Boolean (explicitGatewayToken) &&
Boolean (storedToken) &&
isTrustedRetryEndpoint(this .opts.url);
const resolvedDeviceToken = !(explicitGatewayToken || authPassword)
? (storedToken ?? undefined)
: undefined;
const authToken = explicitGatewayToken ?? resolvedDeviceToken;
return {
authToken,
authDeviceToken: shouldUseDeviceRetryToken ? (storedToken ?? undefined) : undefined,
authPassword,
resolvedDeviceToken,
storedToken: storedToken ?? undefined,
canFallbackToShared: Boolean (storedToken && explicitGatewayToken),
};
}
request<T = unknown>(method: string, params?: unknown): Promise<T> {
if (!this .ws || this .ws.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error("gateway not connected" ));
}
const id = generateUUID();
const frame = { type: "req" , id, method, params };
const p = new Promise<T>((resolve, reject) => {
this .pending.set(id, { resolve: (v) => resolve(v as T), reject });
});
this .ws.send(JSON.stringify(frame));
return p;
}
addEventListener(listener: GatewayEventListener): () => void {
this .eventListeners.add(listener);
return () => {
this .eventListeners.delete (listener);
};
}
private queueConnect() {
this .connectNonce = null ;
this .connectSent = false ;
this .clearConnectTimer();
this .connectTimer = window.setTimeout(() => {
this .connectTimer = null ;
void this .sendConnect();
}, 750 );
}
private clearConnectTimer() {
if (this .connectTimer !== null ) {
window.clearTimeout(this .connectTimer);
this .connectTimer = null ;
}
}
}
Messung V0.5 in Prozent C=99 H=99 G=98
¤ Dauer der Verarbeitung: 0.12 Sekunden
(vorverarbeitet am 2026-06-07)
¤
*© Formatika GbR, Deutschland