Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 41 additions & 11 deletions src/api/ui-operation-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,14 @@ interface BridgeChannel {
operations: HtkOperation[];
user?: User;
authenticated: boolean;
// Declared by the UI in its operations message. Higher wins the primary role,
// so a UI that knows it's the one being driven (the desktop app) is preferred
// over one that just happens to be open too (a browser tab).
priority: number;
// Operations received before authentication completes are buffered here
// and applied atomically once auth succeeds.
pendingOperations?: HtkOperation[];
pendingPriority?: unknown;
// Monotonic sequence number for in-flight JWT validations. Only the
// latest validation is applied; earlier ones are discarded if superseded.
authSeq: number;
Expand Down Expand Up @@ -77,7 +82,13 @@ export class UiOperationBridge extends EventEmitter {
}

private get primaryChannel(): BridgeChannel | undefined {
return this.channels[0];
// Highest declared priority wins, and connection order breaks ties, so
// channels that declare nothing keep behaving exactly as they always have.
return this.channels.reduce<BridgeChannel | undefined>((best, channel) =>
!best || channel.priority > best.priority
? channel
: best
, undefined);
}

get isReady(): boolean {
Expand Down Expand Up @@ -106,6 +117,7 @@ export class UiOperationBridge extends EventEmitter {
ws,
operations: [],
authenticated: false,
priority: 0,
authSeq: 0
};
this.channels.push(channel);
Expand Down Expand Up @@ -336,13 +348,14 @@ export class UiOperationBridge extends EventEmitter {
// processed until both the token and JWT are fully validated.
if (data.type === 'operations') {
channel.pendingOperations = data.operations ?? [];
channel.pendingPriority = data.priority;
}
return;
}

switch (data.type) {
case 'operations':
this.applyOperations(channel, data.operations ?? []);
this.applyOperations(channel, data.operations ?? [], data.priority);
break;

case 'response':
Expand All @@ -351,17 +364,32 @@ export class UiOperationBridge extends EventEmitter {
}
}

private applyOperations(channel: BridgeChannel, operations: HtkOperation[]): void {
private applyOperations(
channel: BridgeChannel,
operations: HtkOperation[],
priority: unknown
): void {
const wasReady = this.isReady;
channel.operations = operations;
const previousPrimary = this.primaryChannel;

// Only emit events when the primary channel's operations change
if (channel === this.primaryChannel) {
if (!wasReady && this.isReady) {
this.emit('ready');
}
this.emit('operations-changed', this.currentOperations);
channel.operations = operations;
// Anything that isn't a real number means 'no preference', so a malformed
// message can never take the primary role away from a well-behaved UI:
channel.priority = typeof priority === 'number' && Number.isFinite(priority)
? priority
: 0;

// Only emit events when the primary channel's operations change, or when
// this message handed the role to a different channel:
const primaryChanged = this.primaryChannel !== previousPrimary;
if (channel !== this.primaryChannel && !primaryChanged) return;

if (!wasReady && this.isReady) {
this.emit('ready');
} else if (wasReady && !this.isReady) {
this.emit('not-ready');
}
this.emit('operations-changed', this.currentOperations);
}

private handleAuth(channel: BridgeChannel, data: { token?: string; jwt: string | false }): void {
Expand Down Expand Up @@ -428,8 +456,10 @@ export class UiOperationBridge extends EventEmitter {
// This guarantees operations are never exposed until auth succeeds.
if (channel.pendingOperations !== undefined) {
const ops = channel.pendingOperations;
const priority = channel.pendingPriority;
channel.pendingOperations = undefined;
this.applyOperations(channel, ops);
channel.pendingPriority = undefined;
this.applyOperations(channel, ops, priority);
}
}

Expand Down
99 changes: 95 additions & 4 deletions test/integration/ui-operation-bridge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ function waitForMessage(ws: WebSocket, type: string): Promise<any> {
async function connectMockUi(
bridge: UiOperationBridge,
ops: HtkOperation[] = TEST_OPERATIONS,
authToken?: string
authToken?: string,
priority?: number
): Promise<{ clientWs: WebSocket; wss: WebSocketServer }> {
const pair = await createWsPair(bridge);
const wasReady = bridge.isReady;
Expand All @@ -145,7 +146,8 @@ async function connectMockUi(

pair.clientWs.send(JSON.stringify({
type: 'operations',
operations: ops
operations: ops,
priority
}));

if (!wasReady) {
Expand Down Expand Up @@ -183,8 +185,12 @@ describe("UiOperationBridge", () => {
done();
});

async function setupMockUi(ops: HtkOperation[] = TEST_OPERATIONS, authToken?: string) {
const pair = await connectMockUi(bridge, ops, authToken);
async function setupMockUi(
ops: HtkOperation[] = TEST_OPERATIONS,
authToken?: string,
priority?: number
) {
const pair = await connectMockUi(bridge, ops, authToken, priority);
activePairs.push(pair);
return pair;
}
Expand Down Expand Up @@ -857,4 +863,89 @@ describe("UiOperationBridge", () => {
expect(bridge.isReady).to.be.true;
});
});

describe("Channel priority", () => {

function answerRequests(pair: { clientWs: WebSocket }, result: any) {
pair.clientWs.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.type !== 'request') return;
pair.clientWs.send(JSON.stringify({ type: 'response', id: msg.id, result }));
});
}

const OTHER_OPERATIONS: HtkOperation[] = [{
name: 'other.op',
description: 'Different operation',
category: 'other',
tiers: ['free', 'pro'],
inputSchema: { type: 'object', properties: {} }
}];

it("should prefer a higher-priority channel over an earlier one", async () => {
await setupMockUi();
await setupMockUi(OTHER_OPERATIONS, undefined, 1);

const result = await makeApiRequest('GET', '/api/operations');
expect(result).to.have.length(1);
expect(result[0].name).to.equal('other.op');
});

it("should prefer a higher-priority channel that connected first", async () => {
await setupMockUi(OTHER_OPERATIONS, undefined, 1);
await setupMockUi();

const result = await makeApiRequest('GET', '/api/operations');
expect(result).to.have.length(1);
expect(result[0].name).to.equal('other.op');
});

it("should keep connection order between equal priorities", async () => {
await setupMockUi(TEST_OPERATIONS, undefined, 1);
await setupMockUi(OTHER_OPERATIONS, undefined, 1);

const result = await makeApiRequest('GET', '/api/operations');
expect(result).to.have.length(2);
expect(result[0].name).to.equal('proxy.get-config');
});

it("should route execute requests to the higher-priority channel", async () => {
const first = await setupMockUi();
const preferred = await setupMockUi(TEST_OPERATIONS, undefined, 1);

answerRequests(first, { from: 'first' });
answerRequests(preferred, { from: 'preferred' });

const result = await makeApiRequest('POST', '/api/execute', {
name: 'proxy.get-config',
args: {}
});
expect(result).to.deep.equal({ from: 'preferred' });
});

it("should fall back to a lower-priority channel when the preferred one goes", async () => {
const fallback = await setupMockUi();
const preferred = await setupMockUi(TEST_OPERATIONS, undefined, 1);

answerRequests(fallback, { from: 'fallback' });

preferred.clientWs.close();
await new Promise<void>(resolve => bridge.once('operations-changed', () => resolve()));

expect(bridge.isReady).to.be.true;
const result = await makeApiRequest('POST', '/api/execute', {
name: 'proxy.get-config',
args: {}
});
expect(result).to.deep.equal({ from: 'fallback' });
});

it("should ignore a priority that is not a finite number", async () => {
await setupMockUi();
await setupMockUi(OTHER_OPERATIONS, undefined, 'highest' as any);

const result = await makeApiRequest('GET', '/api/operations');
expect(result[0].name).to.equal('proxy.get-config');
});
});
});