Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
29 changes: 29 additions & 0 deletions scripts/build-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ export interface IncomingMessageTypes {
* obs-websocket is responding to a request coming from a client
*/
[WebSocketOpCode.RequestResponse]: ResponseMessage;
/**
* obs-websocket is responding to a batch request coming from a client
*/
[WebSocketOpCode.RequestBatchResponse]: ResponseBatchMessage;
}

export interface OutgoingMessageTypes {
Expand Down Expand Up @@ -197,6 +201,10 @@ export interface OutgoingMessageTypes {
* Client is making a request to obs-websocket. Eg get current scene, create source.
*/
[WebSocketOpCode.Request]: RequestMessage;
/**
* Client is making a batch request to obs-websocket.
*/
[WebSocketOpCode.RequestBatch]: RequestBatchMessage;
}

type EventMessage<T = keyof OBSEventTypes> = T extends keyof OBSEventTypes ? {
Expand All @@ -214,13 +222,34 @@ export type RequestMessage<T = keyof OBSRequestTypes> = T extends keyof OBSReque
requestData: OBSRequestTypes[T];
} : never;

export type RequestBatchRequest<T = keyof OBSRequestTypes> = T extends keyof OBSRequestTypes ? OBSRequestTypes[T] extends never ? {
requestType: T;
requestId?: string;
} : {
requestType: T;
requestId?: string;
requestData: OBSRequestTypes[T];
} : never;

export type RequestBatchMessage = {
requestId: string;
haltOnFailure?: boolean;
executionType?: RequestBatchExecutionType
requests: RequestBatchRequest[];
};

export type ResponseMessage<T = keyof OBSResponseTypes> = T extends keyof OBSResponseTypes ? {
requestType: T;
requestId: string;
requestStatus: {result: true; code: number} | {result: false; code: number; comment: string};
responseData: OBSResponseTypes[T];
} : never;

export type ResponseBatchMessage = {
requestId: string;
results: ResponseMessage[];
}

// Events
export interface OBSEventTypes {
${generateObsEventTypes(protocol.events)}
Expand Down
29 changes: 27 additions & 2 deletions src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import EventEmitter from 'eventemitter3';
import WebSocketIpml from 'isomorphic-ws';
import {Except, Merge, SetOptional} from 'type-fest';

import {OutgoingMessageTypes, WebSocketOpCode, OutgoingMessage, OBSEventTypes, IncomingMessage, IncomingMessageTypes, OBSRequestTypes, OBSResponseTypes, RequestMessage, ResponseMessage} from './types.js';
import {OutgoingMessageTypes, WebSocketOpCode, OutgoingMessage, OBSEventTypes, IncomingMessage, IncomingMessageTypes, OBSRequestTypes, OBSResponseTypes, RequestMessage, RequestBatchExecutionType, RequestBatchRequest, RequestBatchMessage, ResponseMessage, ResponseBatchMessage} from './types.js';
import authenticationHashing from './utils/authenticationHashing.js';

export const debug = createDebug('obs-websocket-js');
Expand Down Expand Up @@ -147,6 +147,30 @@ export abstract class BaseOBSWebSocket extends EventEmitter<MapValueToArgsArray<
return responseData as OBSResponseTypes[Type];
}

/**
* Send a batch request to obs-websocket
*
* @param requests Array of Request objects (type and data)
* @param options A set of options for how the batch will be executed
* @param options.executionType The mode of execution obs-websocket will run the batch in
* @param options.haltOnFailure Whether obs-websocket should stop executing the batch if one request fails
* @returns RequestBatch response
*/
async callBatch(requests: RequestBatchRequest[], options: {haltOnFailure?: boolean; executionType?: RequestBatchExecutionType} = {}): Promise<ResponseMessage[]> {
const requestId = BaseOBSWebSocket.generateMessageId();
const responsePromise = this.internalEventPromise<ResponseBatchMessage>(`res:${requestId}`);

await this.message(WebSocketOpCode.RequestBatch, {
requestId,
requests,
haltOnFailure: options.haltOnFailure,
executionType: options.executionType,
});

const {results} = await responsePromise;
return results;
}

/**
* Cleanup from socket disconnection
*/
Expand Down Expand Up @@ -310,7 +334,8 @@ export abstract class BaseOBSWebSocket extends EventEmitter<MapValueToArgsArray<
return;
}

case WebSocketOpCode.RequestResponse: {
case WebSocketOpCode.RequestResponse:
case WebSocketOpCode.RequestBatchResponse: {
const {requestId} = d;
this.internalListeners.emit(`res:${requestId}`, d);
return;
Expand Down
29 changes: 29 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@ export interface IncomingMessageTypes {
* obs-websocket is responding to a request coming from a client
*/
[WebSocketOpCode.RequestResponse]: ResponseMessage;
/**
* obs-websocket is responding to a batch request coming from a client
*/
[WebSocketOpCode.RequestBatchResponse]: ResponseBatchMessage;
}

export interface OutgoingMessageTypes {
Expand Down Expand Up @@ -285,6 +289,10 @@ export interface OutgoingMessageTypes {
* Client is making a request to obs-websocket. Eg get current scene, create source.
*/
[WebSocketOpCode.Request]: RequestMessage;
/**
* Client is making a batch request to obs-websocket.
*/
[WebSocketOpCode.RequestBatch]: RequestBatchMessage;
}

type EventMessage<T = keyof OBSEventTypes> = T extends keyof OBSEventTypes ? {
Expand All @@ -302,13 +310,34 @@ export type RequestMessage<T = keyof OBSRequestTypes> = T extends keyof OBSReque
requestData: OBSRequestTypes[T];
} : never;

export type RequestBatchRequest<T = keyof OBSRequestTypes> = T extends keyof OBSRequestTypes ? OBSRequestTypes[T] extends never ? {
requestType: T;
requestId?: string;
} : {
requestType: T;
requestId?: string;
requestData: OBSRequestTypes[T];
} : never;
Comment on lines +313 to +320
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels really dirty, but without it, requests that have never as the requestData value still want a requestData parameter, which is an impossible constraint to satisfy. So this is the compromise.


export type RequestBatchMessage = {
requestId: string;
haltOnFailure?: boolean;
executionType?: RequestBatchExecutionType;
requests: RequestBatchRequest[];
};

export type ResponseMessage<T = keyof OBSResponseTypes> = T extends keyof OBSResponseTypes ? {
requestType: T;
requestId: string;
requestStatus: {result: true; code: number} | {result: false; code: number; comment: string};
responseData: OBSResponseTypes[T];
} : never;

export type ResponseBatchMessage = {
requestId: string;
results: ResponseMessage[];
};

// Events
export interface OBSEventTypes {
CurrentSceneCollectionChanging: {
Expand Down
108 changes: 62 additions & 46 deletions tests/helpers/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import sha256 from 'crypto-js/sha256.js';
import Base64 from 'crypto-js/enc-base64.js';
import {JsonObject} from 'type-fest';
import {AddressInfo, WebSocketServer} from 'ws';
import {IncomingMessage, WebSocketOpCode, OutgoingMessage} from '../../src/types.js';
import {IncomingMessage, WebSocketOpCode, OutgoingMessage, ResponseBatchMessage, OBSRequestTypes, ResponseMessage} from '../../src/types.js';

export interface MockServer {
server: WebSocketServer;
Expand Down Expand Up @@ -54,6 +54,44 @@ const REQUEST_HANDLERS: Record<string, (req?: JsonObject | void) => FailureRespo
/* eslint-enable @typescript-eslint/naming-convention */
};

function handleRequestData<T extends keyof OBSRequestTypes>(requestId: string | undefined, requestType: T, requestData: OBSRequestTypes[T]) {
if (!(requestType in REQUEST_HANDLERS)) {
return {
requestType,
requestId,
requestStatus: {
result: false,
code: 204,
comment: 'unknown type',
},
};
}

const responseData = REQUEST_HANDLERS[requestType](requestData);

if (responseData instanceof FailureResponse) {
return {
requestType,
requestId,
requestStatus: {
result: false,
code: responseData.code,
comment: responseData.message,
},
};
}

return {
requestType,
requestId,
requestStatus: {
result: true,
code: 100,
},
responseData,
};
}

export async function makeServer(
authenticate?: boolean,
): Promise<MockServer> {
Expand Down Expand Up @@ -134,55 +172,33 @@ export async function makeServer(
break;
case WebSocketOpCode.Request: {
const {requestData, requestId, requestType} = message.d;
if (!(requestType in REQUEST_HANDLERS)) {
send({
op: WebSocketOpCode.RequestResponse,
d: {
// @ts-expect-error don't care
requestType,
requestId,
requestStatus: {
result: false,
code: 204,
comment: 'unknown type',
},
},
});
break;
}
const responseData = handleRequestData(requestId, requestType, requestData);
send({
op: WebSocketOpCode.RequestResponse,
// @ts-expect-error RequestTypes and ResponseTypes are non-overlapping according to ts
d: responseData,
});
break;
}

case WebSocketOpCode.RequestBatch: {
const {requests, requestId, haltOnFailure: shouldHalt} = message.d;

const response: ResponseBatchMessage = {requestId, results: []};

const responseData = REQUEST_HANDLERS[requestType](requestData);

if (responseData instanceof FailureResponse) {
send({
op: WebSocketOpCode.RequestResponse,
d: {
// @ts-expect-error don't care
requestType,
requestId,
requestStatus: {
result: false,
code: responseData.code,
comment: responseData.message,
},
},
});
break;
for (const request of requests) {
// @ts-expect-error requestData only exists on _some_ request types, not all
const result = handleRequestData(request.requestId, request.requestType, request.requestData);
response.results.push(result as ResponseMessage);

if (!result.requestStatus.result && shouldHalt) {
break;
}
}

send({
op: WebSocketOpCode.RequestResponse,
d: {
// @ts-expect-error don't care
requestType,
requestId,
requestStatus: {
result: true,
code: 100,
},
// @ts-expect-error don't care
responseData,
},
op: WebSocketOpCode.RequestBatchResponse,
d: response,
});

break;
Expand Down
54 changes: 54 additions & 0 deletions tests/request-batch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import anyTest, {TestFn} from 'ava';

import {makeServer, MockServer} from './helpers/dev-server.js';
import OBSWebSocket, {OBSRequestTypes, OBSResponseTypes, OBSWebSocketError} from '../src/json.js';

const test = anyTest as TestFn<{
server: MockServer;
client: OBSWebSocket;
}>;

test.beforeEach(async t => {
const server = await makeServer();
const client = new OBSWebSocket();
await client.connect(server.url);

t.context = {
server,
client,
};
});

test.afterEach(async t => {
await t.context.client.disconnect();
await t.context.server.teardown();
});

test('disconencted throws', async t => {
const {client} = t.context;
await client.disconnect();
await t.throwsAsync(client.callBatch([{requestType: 'GetVersion'}]), {
instanceOf: Error,
message: 'Not connected',
});
});

test('single request without parameters', async t => {
const {client} = t.context;
const [res] = await client.callBatch([{requestType: 'GetVersion'}]);

t.is((res.responseData as OBSResponseTypes['GetVersion']).obsVersion, '5.0.0-mock.0');
});

test('multiple requests with mixed parameters', async t => {
const {client} = t.context;
const [res1, res2, res3] = await client.callBatch([
{requestType: 'GetVersion'},
{requestType: 'BroadcastCustomEvent', requestData: {eventData: {}}},
{requestType: 'GetVersion'},
]);

t.is((res1.responseData as OBSResponseTypes['GetVersion']).obsVersion, '5.0.0-mock.0');
t.is((res2.responseData as OBSResponseTypes['BroadcastCustomEvent']), undefined);
t.is((res3.responseData as OBSResponseTypes['GetVersion']).obsVersion, '5.0.0-mock.0');
});