Skip to content

Duktape (JavaScript) ​

The Duktape plugin is the JavaScript twin of the Lua plugin: a shell around one script. The server does sessions, ICE and DTLS, RTP routing and recording; your JavaScript decides what the plugin accepts, what it answers, and who receives whose media. Janus calls functions your script defines, and your script calls host functions that Janus registers as globals. You use it to write a custom plugin in JavaScript without touching Rust or C.

Like Lua, this plugin exists only when a script is configured: with no general.script there is nothing to attach to.

The engine is Boa, not Duktape

The C plugin runs Duktape. This port replaces it with Boa, a pure-Rust JavaScript engine (crate janus-script-js), so the plugin stays free of C. The C host API is kept: the C sample echotest.js loads and runs unchanged, including its Duktape.modSearch module loader. The name Duktape in the config file, the package name and the log lines is historic. getDuktapeVersion() returns 20700, a fixed number that imitates Duktape 2.7.0.

Enable and configure ​

Build with plugin-duktape, then create janus.plugin.duktape.jcfg:

text
general: {
	path = "/etc/janus/js"
	script = "/etc/janus/js/hello.js"
	#config = "/path/to/a/file"
}
KeyMeaning
scriptThe JavaScript file to load. Required: without it the plugin is not registered.
pathThe folder that getModulesFolder() returns inside the script. Scripts use it to find their own modules (see echotest.js, which reads janus-sdp.js from it with readFile).
configA path that is passed unchanged to your init() function.

The full table is in the Duktape config reference. What the server logs at startup, for each kind of script:

jsonc
// The Duktape (JavaScript) plugin only starts when janus.plugin.duktape.jcfg names a script in general.script.


// 1. A config file without general.script: the plugin is left out. Server log line:

//    janus: plugin janus.plugin.duktape is not loaded: general.script is not set in its config file

//    plugins in GET /janus/info: (neither Lua nor Duktape)


// 2. The sample script from the C repo (echotest.js) with its helper modules:

//    Loading plugin 'libjanus_duktape.so'...

//    [echotest.js]  Loading script...

//    [echotest.js]  Modules folder: /srv/janus/duktape

//    [echotest.js]  Loading module: janus-sdp

//    [echotest.js]  Module loaded

//    [echotest.js]  Script loaded

//    [echotest.js]  Initializing...

//    [echotest.js]  Initialized

//    GET /janus/info lists janus.plugin.echojs: {"name":"JavaScript based EchoTest","author":"Lorenzo Miniero","description":"This is echotest.js, a JavaScript/Duktape based clone of janus.plugin.echotest","version_string":"0.0.12","version":12}


// 3. A script that lacks the nine required functions: the plugin is not registered. Server log:

//    Loading plugin 'libjanus_duktape.so'...

//    [ERR] Function 'destroy' is missing in /srv/janus/duktape/bad.js

//    [WARN] The 'janus.plugin.duktape' plugin could not be initialized

//    plugins in GET /janus/info: (neither Lua nor Duktape)


// 4. A script with a syntax error: the plugin is not registered. Server log:

//    Loading plugin 'libjanus_duktape.so'...

//    [ERR] Error loading JS script /srv/janus/duktape/bad.js: SyntaxError: abrupt end

//    [WARN] The 'janus.plugin.duktape' plugin could not be initialized

//    plugins in GET /janus/info: (neither Lua nor Duktape)


// 5. A script file that does not exist: the plugin is not registered. Server log:

//    janus: plugin janus.plugin.duktape is not loaded: No such file or directory (os error 2)

//    plugins in GET /janus/info: (neither Lua nor Duktape)

Unlike the Lua plugin, a Duktape script that fails to load says why: Function 'destroy' is missing in <file> for a missing required function, Error loading JS script <file>: SyntaxError: abrupt end for a syntax error. In both cases the plugin is not registered and is absent from GET /janus/info.

A script to start from ​

hello.js is the script the transcripts below run. It defines the nine required functions, some optional ones, and answers three kinds of request.

js
// hello.js: the smallest useful Janus JavaScript plugin (run by Boa in this port).
// Janus calls the nine functions below; the script calls the host functions (pushEvent, timeCallback, ...).
function init(config) {
  console.log("hello.js: init, config file = " + config);
}

function destroy() {}
function createSession(id) {}
function destroySession(id) {}
function setupMedia(id) {}
function hangupMedia(id) {}
function resumeScheduler() {}

function querySession(id) {
  return JSON.stringify({ script: "hello.js", id: id });
}

// Optional: Admin API message_plugin requests land here (a JSON string in, a JSON string out).
function handleAdminMessage(message) {
  return message;
}

// Package name and version: optional, Janus uses these instead of the plugin's own.
function getPackage() { return "janus.plugin.hellojs"; }
function getName() { return "Hello script (JS)"; }
function getVersion() { return 1; }
function getVersionString() { return "0.0.1"; }
function getDescription() { return "A tiny JavaScript plugin for the docs"; }
function getAuthor() { return "docs"; }

// Called later by timeCallback(); the argument is always a string.
function replyLater(arg) {
  var parts = arg.split(":");
  pushEvent(parseInt(parts[0], 10), parts[1], JSON.stringify({ hello: "later" }), null);
}

// Return value: a JSON string is a synchronous reply, a positive number means "I will answer
// later with pushEvent", a negative number is an error.
function handleMessage(id, tr, msg, jsep) {
  var req = JSON.parse(msg);
  if (req.request === "ping")
    return JSON.stringify({ hello: "world", you_sent: req });
  if (req.request === "later") {
    timeCallback("replyLater", id + ":" + tr, 300);
    return 1;
  }
  return -1;
}

By default the plugin is janus.plugin.duktape. If the script defines getPackage() the plugin is registered under that name (janus.plugin.hellojs here) with the name, author, description and version your get* functions return.

Try it ​

Your own script: synchronous, asynchronous, error ​

jsonc
// hello.js (shown on the page) as the plugin. It sets its own package name, so the plugin is listed as janus.plugin.hellojs.


// → POST /janus
{
  "janus": "create",
  "transaction": "t-create"
}

// ← 200 OK
{
  "janus": "success",
  "transaction": "t-create",
  "data": {
    "id": 4100001234567908
  }
}

// GET /janus/info lists it as janus.plugin.hellojs: {"name":"Hello script (JS)","author":"docs","description":"A tiny JavaScript plugin for the docs","version_string":"0.0.1","version":1}


// → POST /janus/4100001234567908
{
  "janus": "attach",
  "plugin": "janus.plugin.hellojs",
  "transaction": "t-attach"
}

// ← 200 OK
{
  "janus": "success",
  "session_id": 4100001234567908,
  "transaction": "t-attach",
  "data": {
    "id": 4100002469135799
  }
}

// A synchronous request: handleMessage returned the reply, so the HTTP answer carries it directly.

// → POST /janus/4100001234567908/4100002469135799
{
  "janus": "message",
  "transaction": "t-1",
  "body": {
    "request": "ping",
    "n": 1
  }
}

// ← 200 OK
{
  "janus": "success",
  "session_id": 4100001234567908,
  "transaction": "t-1",
  "sender": 4100002469135799,
  "plugindata": {
    "plugin": "janus.plugin.hellojs",
    "data": {
      "hello": "world",
      "you_sent": {
        "request": "ping",
        "n": 1
      }
    }
  }
}


// An asynchronous request: handleMessage returned `1`, the reply comes later through pushEvent with the same transaction.

// → POST /janus/4100001234567908/4100002469135799
{
  "janus": "message",
  "transaction": "t-later",
  "body": {
    "request": "later"
  }
}

// ← 200 OK
{
  "janus": "ack",
  "session_id": 4100001234567908,
  "transaction": "t-later"
}

// ⇠ event, long poll GET /janus/<session>
{
  "janus": "event",
  "session_id": 4100001234567908,
  "transaction": "t-later",
  "sender": 4100002469135799,
  "plugindata": {
    "plugin": "janus.plugin.hellojs",
    "data": {
      "hello": "later"
    }
  }
}


// An error: handleMessage returned a negative result.

// → POST /janus/4100001234567908/4100002469135799
{
  "janus": "message",
  "transaction": "t-2",
  "body": {
    "request": "nope"
  }
}

// ← 200 OK
{
  "janus": "error",
  "session_id": 4100001234567908,
  "transaction": "t-2",
  "error": {
    "code": 462,
    "reason": "Duktape error"
  }
}


// Admin API handle_info (not shown in full): plugin_specific is what querySession returned:

//   "plugin_specific": {"script":"hello.js","id":2007654332}


// Admin API message_plugin: handleAdminMessage got the request text and returned it.

// → POST /admin
{
  "janus": "message_plugin",
  "plugin": "janus.plugin.hellojs",
  "request": {
    "request": "hello"
  },
  "transaction": "t-admin",
  "admin_secret": "janusoverlord"
}

// ← 200 OK
{
  "janus": "success",
  "transaction": "t-admin",
  "response": {
    "request": "hello"
  }
}

Note the error: when handleMessage returns a negative number the client always gets {"code": 462, "reason": "Duktape error"}; the text is fixed, unlike Lua, where the script supplies it.

The sample echotest.js from the C repo ​

The C repository ships src/plugins/duktape/echotest.js and its helper janus-sdp.js. Put both in the folder named by general.path and point script at echotest.js; no stand-in modules are needed, because the script loads janus-sdp.js itself. It sets getPackage() to janus.plugin.echojs:

jsonc
// echotest.js from the C repo, loaded as the plugin.

// The script sets its own package name: janus.plugin.echojs. The browser offer is the canned offer of parity/sip-peer/sdp.


// → POST /janus
{
  "janus": "create",
  "transaction": "t-create"
}

// ← 200 OK
{
  "janus": "success",
  "transaction": "t-create",
  "data": {
    "id": 4100001234567908
  }
}

// → POST /janus/4100001234567908
{
  "janus": "attach",
  "plugin": "janus.plugin.echojs",
  "transaction": "t-attach"
}

// ← 200 OK
{
  "janus": "success",
  "session_id": 4100001234567908,
  "transaction": "t-attach",
  "data": {
    "id": 4100002469135799
  }
}

// → POST /janus/4100001234567908/4100002469135799
{
  "janus": "message",
  "transaction": "t-1",
  "body": {
    "audio": true,
    "video": true
  }
}

// ← 200 OK
{
  "janus": "success",
  "session_id": 4100001234567908,
  "transaction": "t-1",
  "sender": 4100002469135799,
  "plugindata": {
    "plugin": "janus.plugin.echojs",
    "data": {
      "echotest": "response",
      "result": "ok"
    }
  }
}

// → POST /janus/4100001234567908/4100002469135799
{
  "janus": "message",
  "transaction": "t-2",
  "body": {
    "audio": true,
    "video": true
  },
  "jsep": {
    "type": "offer",
    "sdp": "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\ns=-\r\n…"
  }
}

// ← 200 OK
{
  "janus": "ack",
  "session_id": 4100001234567908,
  "transaction": "t-2"
}

// ⇠ event, long poll GET /janus/<session>
{
  "janus": "event",
  "session_id": 4100001234567908,
  "transaction": "t-2",
  "sender": 4100002469135799,
  "plugindata": {
    "plugin": "janus.plugin.echojs",
    "data": {
      "echotest": "event",
      "result": "ok"
    }
  },
  "jsep": {
    "type": "answer",
    "sdp": "v=0\r\no=- 4611731400430051336 2 IN IP4 192.0.2.10\r\ns=-\r\n…"
  }
}

// Admin API handle_info (not shown in full): plugin_specific is what the script's querySession returned:

//   "plugin_specific": {"script":"echotest.js","id":2007654332}


// → POST /admin
{
  "janus": "message_plugin",
  "plugin": "janus.plugin.echojs",
  "request": {
    "request": "hello"
  },
  "transaction": "t-admin",
  "admin_secret": "janusoverlord"
}

// ← 200 OK
{
  "janus": "success",
  "transaction": "t-admin",
  "response": {
    "request": "hello"
  }
}


// Server log lines of the script (the script prints them with its own logger):

//    [echotest.js]  Created new session: 421561188

//    [echotest.js]  Handling message for session: 421561188

//    [echotest.js]  Handling message for session: 421561188

//    [echotest.js]  Resuming coroutines

//    [echotest.js]  Handling async message for session: 421561188

//    [echotest.js]  Got offer: [{"type":"v","name":"0"},{"type":"o","name":"- 4611731400430051336 2 IN IP4 1.1.1.1"},{"type":"s","name":"-"},{"type":"t","name":"0 0"

//    [echotest.js]  Pushing event:

//    [echotest.js]  Coroutines resumed

//    [echotest.js]  Queried session: 421561188

//    [echotest.js]  Got admin message: {"request": "hello"}

With a real browser the media comes back:

jsonc
// echotest.js with a real browser: headless Chrome with a fake camera and microphone.

// The script's setupMedia calls addRecipient(id, id), so the plugin relays the session's media back to itself.


// → POST /janus
{
  "janus": "create",
  "transaction": "t-create"
}

// ← 200 OK
{
  "janus": "success",
  "transaction": "t-create",
  "data": {
    "id": 4100001234567908
  }
}

// → POST /janus/4100001234567908
{
  "janus": "attach",
  "plugin": "janus.plugin.echojs",
  "transaction": "t-attach"
}

// ← 200 OK
{
  "janus": "success",
  "session_id": 4100001234567908,
  "transaction": "t-attach",
  "data": {
    "id": 4100002469135799
  }
}

// → POST /janus/4100001234567908/4100002469135799
{
  "janus": "message",
  "transaction": "t-1",
  "body": {
    "audio": true,
    "video": true
  },
  "jsep": {
    "type": "offer",
    "sdp": "v=0\r\no=- 6100000000001000000 2 IN IP4 127.0.0.1\r\ns=-\r\n…"
  }
}

// ← 200 OK
{
  "janus": "ack",
  "session_id": 4100001234567908,
  "transaction": "t-1"
}

// ⇠ event, long poll GET /janus/<session>
{
  "janus": "event",
  "session_id": 4100001234567908,
  "transaction": "t-1",
  "sender": 4100002469135799,
  "plugindata": {
    "plugin": "janus.plugin.echojs",
    "data": {
      "echotest": "event",
      "result": "ok"
    }
  },
  "jsep": {
    "type": "answer",
    "sdp": "v=0\r\no=- 6100000000001000000 2 IN IP4 192.0.2.10\r\ns=-\r\n…"
  }
}

// ⇠ event, long poll GET /janus/<session>
{
  "janus": "webrtcup",
  "session_id": 4100001234567908,
  "sender": 4100002469135799
}


// Browser getStats about 4 seconds after webrtcup: sent audio yes, sent video yes; received back: audio packets > 0, video packets > 0.

//   (counts: audio in 192, video in 225, audio out 207, video out 244)

The C repository has no videoroom.js; only the echo sample exists for Duktape.

The host functions the port registers ​

The next transcript runs a script that asks typeof of every global, calls eventsIsEnabled(), and shows the error text thrown for a bad call.

jsonc
// probe.js: reports the type of every global host function. The reply is exactly what the script returned.


// → POST /janus
{
  "janus": "create",
  "transaction": "t-create"
}

// ← 200 OK
{
  "janus": "success",
  "transaction": "t-create",
  "data": {
    "id": 4100001234567908
  }
}

// → POST /janus/4100001234567908
{
  "janus": "attach",
  "plugin": "janus.plugin.duktape",
  "transaction": "t-attach"
}

// ← 200 OK
{
  "janus": "success",
  "session_id": 4100001234567908,
  "transaction": "t-attach",
  "data": {
    "id": 4100002469135799
  }
}

// → POST /janus/4100001234567908/4100002469135799
{
  "janus": "message",
  "transaction": "t-1",
  "body": {
    "request": "probe"
  }
}

// ← 200 OK
{
  "janus": "success",
  "session_id": 4100001234567908,
  "transaction": "t-1",
  "sender": 4100002469135799,
  "plugindata": {
    "plugin": "janus.plugin.duktape",
    "data": {
      "types": {
        "pushEvent": "function",
        "closePc": "function",
        "endSession": "function",
        "relayRtp": "function",
        "relayRtcp": "function",
        "testExtraFunction": "function",
        "readFile": "function",
        "getModulesFolder": "function",
        "pokeScheduler": "function",
        "timeCallback": "function",
        "eventsIsEnabled": "function",
        "notifyEvent": "function",
        "configureMedium": "function",
        "addRecipient": "function",
        "removeRecipient": "function",
        "setBitrate": "function",
        "setPliFreq": "function",
        "setSubstream": "function",
        "setTemporalLayer": "function",
        "sendPli": "function",
        "relayData": "function",
        "relayTextData": "function",
        "relayBinaryData": "function",
        "startRecording": "function",
        "stopRecording": "function",
        "getDuktapeVersion": "function"
      },
      "eventsIsEnabled": 0,
      "modulesFolder": "string",
      "pushEventWithoutArguments": "Error: Invalid argument (expected DUK_TYPE_NUMBER, got DUK_TYPE_UNDEFINED)\n",
      "engine": {
        "Duktape": "object",
        "require": "function",
        "console": "object",
        "getDuktapeVersion": 20700
      }
    }
  }
}

Reference: what your script defines ​

These nine functions are required. If one is missing the plugin does not load.

FunctionCalled whenReturn value
init(config)the plugin starts. config is general.confignone
destroy()Janus shuts downnone
createSession(id)a client attachesnone
destroySession(id)the client detachesnone
querySession(id)the Admin API asks for the handle (handle_info)a JSON string, shown as plugin_specific
handleMessage(id, transaction, message, jsep)the client sends messagea string (JSON text) is the synchronous reply; a number >= 0 means "reply later with pushEvent"; a negative number is an error. message and jsep are JSON strings (jsep is null or undefined when absent)
setupMedia(id)the PeerConnection is upnone
hangupMedia(id)the PeerConnection is downnone
resumeScheduler()the scheduler was pokednone (an empty function is fine)

Optional functions, with the same arguments as in Lua:

FunctionCalled when
handleAdminMessage(message)an Admin API message_plugin arrives; return a JSON string
incomingRtp(id, is_video, buf, len), incomingRtcp(id, is_video, buf, len)an RTP or RTCP packet arrives. If defined, the default relay is skipped
incomingTextData(id, buf, len, label, protocol), incomingBinaryData(…)a data channel message arrives. incomingData is the deprecated name; the server logs a warning if you define it, and again for each binary message when there is no incomingTextData
dataReady(id)the data channel can send
slowLink(id, uplink, is_video)a slow link was detected
substreamChanged(id, substream), temporalLayerChanged(id, temporal)the simulcast layer changed
getVersion(), getVersionString(), getDescription(), getName(), getAuthor(), getPackage()at startup; the first answer is kept

Session ids are JavaScript numbers (integers, no float formatting problem as in Lua). They are random non-zero 32-bit numbers.

Reference: what your script can call ​

All are globals, not methods of a namespace. A call with a wrongly typed argument throws an Error whose text starts with Invalid argument (expected DUK_TYPE_NUMBER, got DUK_TYPE_UNDEFINED): the verified reply of pushEvent() in the probe transcript. Catch it with try. The host functions of the port match janus_duktape.c (lines 1541 to 1590):

FunctionArgumentsWhat it does
pushEvent(id, transaction, event, jsep)session id, transaction string, event as a JSON string, JSEP as a JSON string or nullsend an event to the client (the late reply to a request you answered with a number). The JSEP is parsed; a bad SDP throws
notifyEvent(id, event)session id or 0, JSON stringsend an event to event handlers
eventsIsEnabled()none1 or 0
closePc(id), endSession(id)session idclose the PeerConnection, or detach the handle
configureMedium(id, medium, direction, enabled)session id, "audio", "video" or "data", "in" or "out", booleanallow or stop a medium
addRecipient(id, recipient), removeRecipient(id, recipient)session idsroute the session's media to another session, or stop
setBitrate(id, bps), setPliFreq(id, seconds)session id, numberREMB bitrate cap, PLI frequency
setSubstream(id, n), setTemporalLayer(id, n)session id, numbersimulcast layer
sendPli(id)session idrequest a keyframe
relayRtp(id, is_video, buf, len), relayRtcp(id, is_video, buf, len)session id, boolean, packet string, lengthsend a packet you built
relayTextData(id, text, len [, label [, protocol]]), relayBinaryData(…), relayData(…) (deprecated)session id, payload, length, optional label and protocolsend a data channel message
startRecording(id, type, codec, folder, filename [, …])groups of four: "audio", "video" or "data", codec, folder, base file namestart .mjr recordings
stopRecording(id, type [, …])session id, then typesstop recordings
pokeScheduler()nonewake the scheduler, which calls resumeScheduler()
timeCallback(function_name, argument, ms)global function name, string or null, delaycall that function after ms ms. hello.js uses it with replyLater
readFile(path)stringthe text content of a file; throws Error opening file: <path> if it cannot be opened
getModulesFolder()nonegeneral.path (or . when unset)
getDuktapeVersion()none20700
testExtraFunction()nonereturns 1234 (the C plugin's sample extra function)

The engine also provides console.log, JSON, a CommonJS-style require and the Duktape object with modSearch, as the probe shows (Duktape: "object", require: "function", console: "object"). There is no janusLog: log with console.log.

Behaviour you may not expect ​

  • The client error for a failed handleMessage (a thrown exception, a negative number, an invalid JSON string or any other return type such as undefined) is always {"code": 462, "reason": "Duktape error"}, verified for all four. A thrown exception is logged once as Duktape error: Error: handler boom (verified). An invalid JSON reply and an undefined return leave no log line here; C logs JSON error: on line N: … for the invalid JSON.
  • A JavaScript exception in the other hooks is logged once per call with C's text (parity/status.json, waves 232 and 233).
  • readFile("/nonexistent/file") throws Error: Error opening file: /nonexistent/file (verified), which a script can try/catch.
  • The scheduler runs resumeScheduler and timeCallback functions on its own thread, one engine call at a time (the engine is locked like C's duktape_mutex).
  • The engine has a loop-iteration limit for runaway scripts. The exact policy is an owner decision open in parity/status.json; it was not run here.

Known gaps ​

  • gap Boa, not Duktape. Scripts that rely on Duktape-only features (the Duktape error fields such as lineNumber, Duktape.fin, Duktape.act, Duktape's custom Buffer types) may not behave the same. The C echotest.js runs. Its debug output of the parsed SDP shows a value carried from one SDP line to the next: that comes from janus-sdp.js (it declares var n, v outside the branch, so v keeps its old value), which any ES engine does, not from Boa.
  • gap A reply that is not valid JSON is not logged (C logs the JSON error). readFile on a file that is not valid UTF-8 is open (needs a capture from C). getDuktapeVersion() is a constant.
  • Not exercised here: relayRtp, relayRtcp, startRecording, relayTextData, the data and slowLink hooks, and Duktape.modSearch with modules other than janus-sdp. They are written from janus_duktape.c and the Rust host table. The simulcast path, the PLI relay and the RTP record are ported and covered by the crate's tests (parity/status.json, plugin-duktape).
  • No videoroom.js exists in the C repository to try.

Test it in Janus Bench ​

Open the Bench Run all checks page and run duktape.echotest (group Duktape (JavaScript)). It attaches to janus.plugin.echojs, negotiates through the script and waits for echoed audio and video. The Bench has no result recorded for it yet; the duktape-echotest-media transcript above is the same flow run by hand, and it echoed both audio and video.

GPL-3.0-or-later, like the Janus gateway it ports.