diff --git a/examples/janus-demo.html b/examples/janus-demo.html
new file mode 100644
index 00000000..b15108e4
--- /dev/null
+++ b/examples/janus-demo.html
@@ -0,0 +1,338 @@
+
+
+
+
+ Janus Video Room Demo
+
+
+
+
+
+
+
+
+
Janus Video Room Demo
+
+
+
+
Connection Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/package.json b/package.json
index 84b71d25..db848938 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,9 @@
"punycode": "^2.1.1",
"sdp": "^3.0.2",
"tslib": "^2.2.0",
- "ws": "^8.2.2"
+ "ws": "^8.2.2",
+ "janus-gateway": "^1.1.5",
+ "events": "^3.3.0"
},
"devDependencies": {
"@types/jest": "^27.0.1",
@@ -37,7 +39,9 @@
"typescript": "^4.2.4",
"webpack": "^5.72.1",
"webpack-bundle-analyzer": "^4.4.0",
- "webpack-cli": "^4.5.0"
+ "webpack-cli": "^4.5.0",
+ "@types/events": "^3.0.0",
+ "@types/async": "^3.2.20"
},
"homepage": "https://stanzajs.org",
"jest": {
@@ -86,6 +90,8 @@
"license-check": "npx license-checker --production --excludePrivatePackages --summary",
"lint": "eslint .",
"test": "jest",
- "validate": "npm ls"
+ "validate": "npm ls",
+ "type-check": "tsc --noEmit",
+ "type-check:watch": "tsc --noEmit --watch"
}
}
diff --git a/src/index.ts b/src/index.ts
index 2f79e239..9dfc11b7 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -244,6 +244,14 @@ export interface AgentConfig {
* A list of language codes acceptable to the user.
*/
acceptLanguages?: string[];
+
+ /**
+ * Janus WebRTC Gateway Configuration
+ */
+ janus?: {
+ enabled: boolean;
+ url: string;
+ };
}
export interface Transport {
diff --git a/src/jingle/MediaSession.ts b/src/jingle/MediaSession.ts
index 3bc73802..24b166ba 100644
--- a/src/jingle/MediaSession.ts
+++ b/src/jingle/MediaSession.ts
@@ -22,6 +22,8 @@ import ICESession, { ICESessionOpts } from './ICESession';
import { exportToSDP, importFromSDP } from './sdp/Intermediate';
import { convertIntermediateToRequest, convertRequestToIntermediate } from './sdp/Protocol';
import { ActionCallback } from './Session';
+import { JanusService } from '../services/JanusService';
+import { JanusError, MediaError } from '../types/errors';
function applyStreamsCompatibility(content: JingleContent) {
const application = content.application as JingleRtpDescription;
@@ -49,6 +51,8 @@ function applyStreamsCompatibility(content: JingleContent) {
export interface MediaSessionOpts extends ICESessionOpts {
stream?: MediaStream;
+ useJanus?: boolean;
+ janusUrl?: string;
}
export default class MediaSession extends ICESession {
@@ -59,9 +63,17 @@ export default class MediaSession extends ICESession {
private _ringing = false;
+ private janusService?: JanusService;
+
+ protected localStream?: MediaStream;
+
constructor(opts: MediaSessionOpts) {
super(opts);
+ if (opts.useJanus && opts.janusUrl) {
+ this.janusService = new JanusService(this.parent, opts.janusUrl);
+ }
+
this.pc.addEventListener('track', (e: RTCTrackEvent) => {
this.onAddTrack(e.track, e.streams[0]);
});
@@ -173,9 +185,12 @@ export default class MediaSession extends ICESession {
}
}
- public end(reason: JingleReasonCondition | JingleReason = 'success', silent = false): void {
- for (const receiver of this.pc.getReceivers()) {
- this.onRemoveTrack(receiver.track);
+ public async end(reason: JingleReasonCondition | JingleReason = 'success', silent = false): Promise {
+ if (this.janusService) {
+ await this.janusService.cleanup();
+ }
+ if (this.localStream) {
+ this.localStream.getTracks().forEach(track => track.stop());
}
super.end(reason, silent);
}
@@ -256,6 +271,7 @@ export default class MediaSession extends ICESession {
if (track.kind === 'video') {
this.includesVideo = true;
}
+ this.localStream = stream;
return this.processLocal('addtrack', async () => {
if (this.pc.addTrack) {
this.pc.addTrack(track, stream);
@@ -364,4 +380,58 @@ export default class MediaSession extends ICESession {
}
return cb();
}
+
+ private handleJanusError(error: Error): void {
+ this._log('error', 'Janus error:', error);
+ if (error instanceof JanusError) {
+ this.emit('error', error);
+ } else {
+ this.emit('error', new JanusError(error.message));
+ }
+ }
+
+ public async startWithJanus(
+ roomId?: string,
+ displayName?: string
+ ): Promise {
+ if (!this.janusService) {
+ throw new Error('Janus not configured for this session');
+ }
+
+ try {
+ // Create or join room
+ const actualRoomId = roomId || await this.janusService.createVideoRoom({
+ publishers: 6,
+ bitrate: 512000
+ });
+
+ // Join the room
+ await this.janusService.joinRoom(actualRoomId, displayName || 'anonymous');
+
+ // Publish our stream if we have one
+ if (this.localStream) {
+ await this.janusService.publishStream(this.localStream);
+ }
+
+ this.state = 'active';
+
+ } catch (err) {
+ this.handleJanusError(err);
+ this.end('failed-application', true);
+ }
+ }
+
+ // Add method to subscribe to other participants
+ public async subscribeToParticipant(publisherId: string): Promise {
+ if (!this.janusService) {
+ throw new Error('Janus not configured for this session');
+ }
+
+ try {
+ const remoteStream = await this.janusService.subscribeToFeed(publisherId);
+ this.onAddTrack(remoteStream.getTracks()[0], remoteStream);
+ } catch (err) {
+ this._log('error', 'Could not subscribe to participant', err);
+ }
+ }
}
diff --git a/src/jingle/Session.ts b/src/jingle/Session.ts
index f45b3002..998cd726 100644
--- a/src/jingle/Session.ts
+++ b/src/jingle/Session.ts
@@ -1,4 +1,5 @@
import { AsyncPriorityQueue, priorityQueue } from 'async';
+import { EventEmitter } from 'events';
import {
JingleAction,
@@ -44,7 +45,7 @@ const unsupportedInfo = {
type: 'modify'
};
-export default class JingleSession {
+export default class JingleSession extends EventEmitter {
public parent: SessionManager;
public sid: string;
public peerID: string;
@@ -58,6 +59,7 @@ export default class JingleSession {
private _connectionState: string;
constructor(opts: SessionOpts) {
+ super();
this.parent = opts.parent;
this.sid = opts.sid || uuid();
this.peerID = opts.peerID;
diff --git a/src/jingle/SessionManager.ts b/src/jingle/SessionManager.ts
index faf0705f..5ac736e7 100644
--- a/src/jingle/SessionManager.ts
+++ b/src/jingle/SessionManager.ts
@@ -432,4 +432,8 @@ export default class SessionManager extends EventEmitter {
this.emit('log', level, message, ...args);
this.emit('log:' + level, message, ...args);
}
+
+ public emit(event: string, ...args: any[]): boolean {
+ return super.emit(event, ...args);
+ }
}
diff --git a/src/services/JanusService.ts b/src/services/JanusService.ts
new file mode 100644
index 00000000..eda31780
--- /dev/null
+++ b/src/services/JanusService.ts
@@ -0,0 +1,254 @@
+import { Agent } from '../';
+import { Janus, JanusInstance, PluginHandle } from 'janus-gateway';
+import { EventEmitter } from 'events';
+import { JanusError } from '../types/errors';
+import { JanusServiceEvents } from '../types/events';
+import StrictEventEmitter from '../lib/StrictEventEmitter';
+
+export class JanusService extends (EventEmitter as {
+ new (): StrictEventEmitter;
+}) {
+ private janus?: JanusInstance;
+ private agent: Agent;
+ private roomId: string | null = null;
+ private publisherId: string | null = null;
+ private pluginHandle?: PluginHandle;
+
+ constructor(agent: Agent, janusUrl: string) {
+ super();
+ this.agent = agent;
+ this.initJanus(janusUrl);
+ }
+
+ private initJanus(janusUrl: string): void {
+ Janus.init({
+ debug: true,
+ callback: () => {
+ this.janus = new Janus({
+ server: janusUrl,
+ success: () => {
+ console.log('Janus initialized successfully');
+ },
+ error: (error: any) => {
+ console.error('Janus initialization failed:', error);
+ }
+ });
+ }
+ });
+ }
+
+ public async createVideoRoom(roomConfig: {
+ room?: number,
+ description?: string,
+ publishers?: number,
+ bitrate?: number
+ }): Promise {
+ return new Promise((resolve, reject) => {
+ this.janus?.attach({
+ plugin: 'janus.plugin.videoroom',
+ success: (pluginHandle: PluginHandle) => {
+ const create = {
+ request: 'create',
+ ...roomConfig
+ };
+
+ pluginHandle.send({
+ message: create,
+ success: (response: any) => {
+ this.roomId = response.room;
+ resolve(this.roomId);
+ },
+ error: reject
+ });
+ },
+ error: reject
+ });
+ });
+ }
+
+ public async joinRoom(roomId: string, displayName: string): Promise {
+ return new Promise((resolve, reject) => {
+ this.janus?.attach({
+ plugin: 'janus.plugin.videoroom',
+ success: (pluginHandle: PluginHandle) => {
+ const join = {
+ request: 'join',
+ room: roomId,
+ ptype: 'publisher',
+ display: displayName
+ };
+
+ pluginHandle.send({
+ message: join,
+ success: (response: any) => {
+ this.publisherId = response.id;
+ resolve();
+ },
+ error: reject
+ });
+ },
+ error: reject
+ });
+ });
+ }
+
+ public async publishStream(stream: MediaStream): Promise {
+ return new Promise((resolve, reject) => {
+ if (!this.publisherId || !this.roomId) {
+ reject(new Error('Must join room before publishing'));
+ return;
+ }
+
+ this.janus?.attach({
+ plugin: 'janus.plugin.videoroom',
+ success: (pluginHandle: PluginHandle) => {
+ pluginHandle.createOffer({
+ media: {
+ audioRecv: false,
+ videoRecv: false,
+ audioSend: true,
+ videoSend: true
+ },
+ stream: stream,
+ success: (jsep: any) => {
+ const publish = {
+ request: 'publish',
+ audio: true,
+ video: true
+ };
+
+ pluginHandle.send({
+ message: publish,
+ jsep: jsep,
+ success: resolve,
+ error: reject
+ });
+ },
+ error: reject
+ });
+ },
+ error: reject
+ });
+ });
+ }
+
+ public async subscribeToFeed(publisherId: string): Promise {
+ return new Promise((resolve, reject) => {
+ this.janus?.attach({
+ plugin: 'janus.plugin.videoroom',
+ success: (pluginHandle: PluginHandle) => {
+ const subscribe = {
+ request: 'join',
+ room: this.roomId,
+ ptype: 'subscriber',
+ feed: publisherId
+ };
+
+ pluginHandle.send({
+ message: subscribe,
+ success: (response: any) => {
+ // Handle the subscription response
+ pluginHandle.createAnswer({
+ jsep: response.jsep,
+ media: {
+ audioSend: false,
+ videoSend: false
+ },
+ success: (jsep: any) => {
+ const start = { request: 'start' };
+ pluginHandle.send({
+ message: start,
+ jsep: jsep
+ });
+ }
+ });
+ },
+ error: reject
+ });
+ },
+ onremotestream: (stream: MediaStream) => {
+ resolve(stream);
+ },
+ error: reject
+ });
+ });
+ }
+
+ public async cleanup(): Promise {
+ return new Promise((resolve, reject) => {
+ if (this.pluginHandle) {
+ this.pluginHandle.send({
+ message: { request: 'leave' },
+ success: () => {
+ if (this.janus) {
+ this.janus.destroy({
+ success: () => {
+ this.janus = undefined;
+ this.pluginHandle = undefined;
+ this.roomId = null;
+ this.publisherId = null;
+ resolve();
+ },
+ error: reject
+ });
+ } else {
+ resolve();
+ }
+ },
+ error: reject
+ });
+ } else if (this.janus) {
+ this.janus.destroy({
+ success: () => {
+ this.janus = undefined;
+ resolve();
+ },
+ error: reject
+ });
+ } else {
+ resolve();
+ }
+ });
+ }
+
+ private handleParticipantEvent(event: any): void {
+ if (event.joining) {
+ this.emit('participant-joined', event.id, event.display);
+ } else if (event.leaving) {
+ this.emit('participant-left', event.id);
+ }
+ }
+
+ private attachEventHandlers(pluginHandle: PluginHandle): void {
+ pluginHandle.on('message', (msg: any) => {
+ if (msg.videoroom === 'event') {
+ this.handleParticipantEvent(msg);
+ }
+ });
+
+ pluginHandle.on('error', (error: any) => {
+ this.emit('error', new JanusError(error.message, error.code));
+ });
+ }
+
+ private handleError(error: any): Error {
+ if (error instanceof Error) {
+ return error;
+ }
+
+ if (typeof error === 'string') {
+ return new JanusError(error);
+ }
+
+ if (error.code) {
+ return new JanusError(error.message || 'Unknown Janus error', error.code);
+ }
+
+ return new JanusError('Unknown error occurred');
+ }
+
+ private emitError(error: any): void {
+ const processedError = this.handleError(error);
+ this.emit('error', processedError);
+ }
+}
\ No newline at end of file
diff --git a/src/types/agent.ts b/src/types/agent.ts
new file mode 100644
index 00000000..a0d814c6
--- /dev/null
+++ b/src/types/agent.ts
@@ -0,0 +1,13 @@
+import { EventEmitter } from 'events';
+import { JanusService } from '../services/JanusService';
+
+export interface Agent extends EventEmitter {
+ jid: string;
+ config: AgentConfig;
+ janus?: JanusService;
+ // ... rest of existing Agent interface properties
+
+ // Add Janus-specific methods
+ createJanusSession(url: string): JanusService;
+ destroyJanusSession(): Promise;
+}
\ No newline at end of file
diff --git a/src/types/errors.ts b/src/types/errors.ts
new file mode 100644
index 00000000..c6b34a6e
--- /dev/null
+++ b/src/types/errors.ts
@@ -0,0 +1,13 @@
+export class JanusError extends Error {
+ constructor(message: string, public code?: number) {
+ super(message);
+ this.name = 'JanusError';
+ }
+}
+
+export class MediaError extends Error {
+ constructor(message: string, public constraint?: string) {
+ super(message);
+ this.name = 'MediaError';
+ }
+}
\ No newline at end of file
diff --git a/src/types/events.ts b/src/types/events.ts
new file mode 100644
index 00000000..00d1a8b8
--- /dev/null
+++ b/src/types/events.ts
@@ -0,0 +1,7 @@
+export interface JanusServiceEvents {
+ 'participant-joined': (participantId: string, displayName: string) => void;
+ 'participant-left': (participantId: string) => void;
+ 'stream-started': (stream: MediaStream) => void;
+ 'stream-stopped': (stream: MediaStream) => void;
+ 'error': (error: Error) => void;
+}
\ No newline at end of file
diff --git a/src/types/janus.d.ts b/src/types/janus.d.ts
new file mode 100644
index 00000000..d17309f4
--- /dev/null
+++ b/src/types/janus.d.ts
@@ -0,0 +1,66 @@
+declare module 'janus-gateway' {
+ export interface JanusInitOptions {
+ debug?: boolean | 'all' | string[];
+ callback?: () => void;
+ dependencies?: string[];
+ }
+
+ export interface JanusOptions {
+ server: string | string[];
+ iceServers?: RTCIceServer[];
+ ipv6?: boolean;
+ withCredentials?: boolean;
+ max_poll_events?: number;
+ destroyOnUnload?: boolean;
+ token?: string;
+ apisecret?: string;
+ success?: () => void;
+ error?: (error: any) => void;
+ destroyed?: () => void;
+ }
+
+ export interface JanusEvents {
+ 'participant-joined': (participantId: string, displayName: string) => void;
+ 'participant-left': (participantId: string) => void;
+ 'stream-started': (stream: MediaStream) => void;
+ 'stream-stopped': (stream: MediaStream) => void;
+ 'error': (error: Error) => void;
+ }
+
+ export interface PluginHandle extends EventEmitter {
+ plugin: string;
+ id: string;
+ token?: string;
+ detached: boolean;
+ webrtcStuff: any;
+ createOffer(options: any): void;
+ createAnswer(options: any): void;
+ send(options: any): void;
+ on(event: E, listener: JanusEvents[E]): this;
+ emit(event: E, ...args: Parameters): boolean;
+ }
+
+ export interface JanusInstance {
+ attach(options: {
+ plugin: string;
+ opaqueId?: string;
+ success?: (handle: PluginHandle) => void;
+ error?: (error: any) => void;
+ consentDialog?: (on: boolean) => void;
+ onmessage?: (msg: any, jsep: any) => void;
+ onlocalstream?: (stream: MediaStream) => void;
+ onremotestream?: (stream: MediaStream) => void;
+ ondata?: (data: any) => void;
+ ondataopen?: () => void;
+ oncleanup?: () => void;
+ ondetached?: () => void;
+ }): void;
+ destroy(options?: { success?: () => void; error?: (error: any) => void }): void;
+ }
+
+ export const Janus: {
+ new (options: JanusOptions): JanusInstance;
+ init(options: JanusInitOptions): void;
+ isWebrtcSupported(): boolean;
+ };
+}
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
index 262b0444..32906431 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -11,7 +11,17 @@
"moduleResolution": "node",
"outDir": "./dist/cjs",
"strict": true,
- "target": "es2018"
+ "target": "es2018",
+ "typeRoots": [
+ "./node_modules/@types",
+ "./src/types"
+ ],
+ "types": [
+ "node",
+ "events",
+ "async",
+ "janus-gateway"
+ ]
},
"include": ["src", "typings"],
"exclude": ["dist"]