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
2 changes: 1 addition & 1 deletion examples/multimodal/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const fishjamConfig = {
managementToken: process.env.FISHJAM_TOKEN,
};

const fishjam = new FishjamService(fishjamConfig);
const fishjam = await FishjamService.create(fishjamConfig);

new MultimodalService(fishjamConfig, process.env.GEMINI_API_KEY);

Expand Down
9 changes: 7 additions & 2 deletions examples/multimodal/src/service/fishjam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ export class FishjamService {
roomId?: RoomId;
fishjam: FishjamClient;

constructor(config: FishjamConfig) {
this.fishjam = new FishjamClient(config);
private constructor(fishjam: FishjamClient) {
this.fishjam = fishjam;
}

static async create(config: FishjamConfig): Promise<FishjamService> {
const fishjam = await FishjamClient.create(config);
return new FishjamService(fishjam);
}

async createPeer() {
Expand Down
2 changes: 1 addition & 1 deletion examples/room-manager/src/plugins/fishjam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const fishjamPlugin = fastifyPlugin(async (fastify: FastifyInstance): Pro
throw new Error('The `fishjamPlugin` plugin has already been registered.');
}

const fishjamClient = new FishjamClient({
const fishjamClient = await FishjamClient.create({
fishjamId: fastify.config.FISHJAM_ID,
managementToken: fastify.config.FISHJAM_MANAGEMENT_TOKEN,
});
Expand Down
2 changes: 1 addition & 1 deletion examples/selective-subscription/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { peerController } from './controllers/peers';
import { notificationsController } from './controllers/notifications';
import { FishjamService } from './service/fishjam';

const fishjam = new FishjamService({
const fishjam = await FishjamService.create({
fishjamId: process.env.FISHJAM_ID!,
managementToken: process.env.FISHJAM_TOKEN!,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ export class FishjamService extends EventTarget {
roomId?: RoomId;
fishjam: FishjamClient;

constructor(config: FishjamConfig) {
private constructor(fishjam: FishjamClient, config: FishjamConfig) {
super();
this.fishjam = new FishjamClient(config);
this.fishjam = fishjam;
const notifier = new FishjamWSNotifier(
config,
() => {},
Expand All @@ -27,6 +27,11 @@ export class FishjamService extends EventTarget {
notifier.on('trackRemoved', (msg) => this.emit('trackRemoved', msg));
}

static async create(config: FishjamConfig): Promise<FishjamService> {
const fishjam = await FishjamClient.create(config);
return new FishjamService(fishjam, config);
}

async createPeer() {
try {
return await this.makePeer();
Expand Down
2 changes: 1 addition & 1 deletion examples/transcription/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const fishjamConfig = {
managementToken: process.env.FISHJAM_TOKEN,
};

const fishjam = new FishjamService(fishjamConfig);
const fishjam = await FishjamService.create(fishjamConfig);

new TranscriptionService(fishjamConfig, process.env.GEMINI_API_KEY);

Expand Down
9 changes: 7 additions & 2 deletions examples/transcription/src/service/fishjam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ export class FishjamService {
roomId?: RoomId;
fishjam: FishjamClient;

constructor(config: FishjamConfig) {
this.fishjam = new FishjamClient(config);
private constructor(fishjam: FishjamClient) {
this.fishjam = fishjam;
}

static async create(config: FishjamConfig): Promise<FishjamService> {
const fishjam = await FishjamClient.create(config);
return new FishjamService(fishjam);
}

async createPeer() {
Expand Down
46 changes: 45 additions & 1 deletion packages/js-server-sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from '@fishjam-cloud/fishjam-openapi';
import type { AgentCallbacks, FishjamConfig, PeerId, Room, RoomId, Peer } from './types';
import { mapException } from './exceptions/mapper';
import { getFishjamUrl } from './utils';
import { getFishjamUrl, validateFishjamConfig } from './utils';
import { FishjamAgent, TrackId } from './agent';
import packageJson from '../package.json';

Expand All @@ -32,6 +32,12 @@ export class FishjamClient {
/**
* Create new instance of Fishjam Client.
*
* Throws {@link MissingFishjamIdException} or
* {@link MissingManagementTokenException} if either field is missing.
* Does not verify credentials against the backend — use
* {@link FishjamClient.create} or call
* {@link FishjamClient.checkCredentials} afterwards for that.
*
* Example usage:
* ```
* const fishjamClient = new FishjamClient({
Expand All @@ -41,6 +47,7 @@ export class FishjamClient {
* ```
*/
constructor(config: FishjamConfig) {
validateFishjamConfig(config);
const client = axios.create({
headers: {
Authorization: `Bearer ${config.managementToken}`,
Expand All @@ -62,6 +69,43 @@ export class FishjamClient {
this.fishjamConfig = config;
}

/**
* Async factory: constructs a client and verifies credentials against
* the backend.
*
* Throws {@link MissingFishjamIdException} /
* {@link MissingManagementTokenException} for missing fields,
* {@link UnauthorizedException} for bad credentials,
* {@link FishjamNotFoundException} for an unknown `fishjamId`.
*
* Example:
* ```
* const client = await FishjamClient.create({
* fishjamId: process.env.FISHJAM_ID!,
* managementToken: process.env.FISHJAM_MANAGEMENT_TOKEN!,
* });
* ```
*/
static async create(config: FishjamConfig): Promise<FishjamClient> {
const client = new FishjamClient(config);
await client.checkCredentials();
return client;
}

/**
* Verifies the configured credentials by making a single lightweight
* call to the Fishjam backend. Resolves on success, otherwise throws the
* same exception types thrown by other client methods (notably
* {@link UnauthorizedException} and {@link FishjamNotFoundException}).
*/
async checkCredentials(): Promise<void> {
try {
await this.roomApi.getAllRooms();
} catch (error) {
Comment on lines +96 to +104
throw mapException(error);
}
}

private handleDeprecationHeader(headers: RawAxiosResponseHeaders): void {
try {
const deprecationHeader = headers['x-fishjam-api-deprecated'];
Expand Down
6 changes: 6 additions & 0 deletions packages/js-server-sdk/src/exceptions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ export class MissingFishjamIdException extends Error {
}
}

export class MissingManagementTokenException extends Error {
constructor() {
super('Management Token is required');
}
}

export class FishjamBaseException extends Error {
statusCode: number;
axiosCode?: string;
Expand Down
7 changes: 6 additions & 1 deletion packages/js-server-sdk/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MissingFishjamIdException } from './exceptions';
import { MissingFishjamIdException, MissingManagementTokenException } from './exceptions';
import type { FishjamConfig } from './types';

export const httpToWebsocket = (httpUrl: string) => {
Expand All @@ -9,6 +9,11 @@ export const httpToWebsocket = (httpUrl: string) => {
return url.href;
};

export const validateFishjamConfig = (config: FishjamConfig): void => {
if (!config?.fishjamId) throw new MissingFishjamIdException();
if (!config?.managementToken) throw new MissingManagementTokenException();
};

export const getFishjamUrl = (config: FishjamConfig) => {
if (!config.fishjamId) throw new MissingFishjamIdException();

Expand Down
70 changes: 70 additions & 0 deletions packages/js-server-sdk/tests/config-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { RoomsApi } from '@fishjam-cloud/fishjam-openapi';
import { FishjamClient } from '../src/client';
import { MissingFishjamIdException, MissingManagementTokenException, UnauthorizedException } from '../src/exceptions';
import type { FishjamConfig } from '../src/types';

const VALID_CONFIG: FishjamConfig = { fishjamId: 'fjm_test', managementToken: 'tok_test' };

const axiosError = (status: number, detail = 'error') => ({
isAxiosError: true,
message: `Request failed with status code ${status}`,
code: 'ERR_BAD_REQUEST',
response: { status, data: { detail } },
});

describe('FishjamClient constructor sync validation', () => {
it('throws MissingFishjamIdException when fishjamId is empty', () => {
expect(() => new FishjamClient({ fishjamId: '', managementToken: 'tok' })).toThrow(MissingFishjamIdException);
});

it('throws MissingManagementTokenException when managementToken is empty', () => {
expect(() => new FishjamClient({ fishjamId: 'fjm', managementToken: '' })).toThrow(MissingManagementTokenException);
});

it('does not throw when both fields are provided', () => {
expect(() => new FishjamClient(VALID_CONFIG)).not.toThrow();
});
});

describe('FishjamClient live credential checks (mocked)', () => {
const spy = () => vi.spyOn(RoomsApi.prototype, 'getAllRooms');
let getAllRoomsSpy: ReturnType<typeof spy>;

beforeEach(() => {
getAllRoomsSpy = spy();
});

afterEach(() => {
getAllRoomsSpy.mockRestore();
});

it('FishjamClient.create rejects with UnauthorizedException on 401', async () => {
getAllRoomsSpy.mockRejectedValueOnce(axiosError(401, 'Invalid token'));

await expect(FishjamClient.create(VALID_CONFIG)).rejects.toThrow(UnauthorizedException);
});

it('FishjamClient.create resolves and pings backend once on success', async () => {
getAllRoomsSpy.mockResolvedValueOnce({ data: { data: [] } } as never);

const client = await FishjamClient.create(VALID_CONFIG);

expect(client).toBeInstanceOf(FishjamClient);
expect(getAllRoomsSpy).toHaveBeenCalledTimes(1);
});

it('checkCredentials throws UnauthorizedException on 401', async () => {
getAllRoomsSpy.mockRejectedValueOnce(axiosError(401, 'Invalid token'));

const client = new FishjamClient(VALID_CONFIG);
await expect(client.checkCredentials()).rejects.toThrow(UnauthorizedException);
});

it('checkCredentials resolves on success', async () => {
getAllRoomsSpy.mockResolvedValueOnce({ data: { data: [] } } as never);

const client = new FishjamClient(VALID_CONFIG);
await expect(client.checkCredentials()).resolves.toBeUndefined();
});
});
Loading