Parse, modify, write and validate Session Description Protocol (RFC 8866) messages for WebRTC and SIP. TypeScript, no runtime dependencies, works in Node.js and browsers.
sdpReader.parse: strict. Line order and mandatory lines are checked against RFC 8866 §5. Every error is anSdpParseErrorwith a line number.- Lossless model: each line maps to a typed field or, for attributes the library
does not interpret, to an ordered
attributesentry with the exact original value. sdpWriter.write: serializes the model back to RFC 8866 text (CRLF).write(parse(x))keeps every line ofx; attribute order and time units are normalized (see Writing).sdpValidator.validate: reports RFC and WebRTC rule violations as a list of issues.- Typed WebRTC attributes: ICE, DTLS, BUNDLE, codecs, simulcast, SSRCs (see below).
npm install @irbisadm/simple-sdpimport {sdpReader, sdpWriter, sdpValidator} from '@irbisadm/simple-sdp';
const sdp = sdpReader.parse(offer.sdp);
// Keep only Opus and ask for stereo
for (const m of sdp.media.filter(m => m.type === 'audio')) {
m.codecs = m.codecs?.filter(c => c.encodingName === 'opus');
m.codecs?.forEach(c => c.parameters = {...c.parameters, stereo: '1'});
}
sdp.origin.sessVersion = String(BigInt(sdp.origin.sessVersion) + 1n);
sdpValidator.validate(sdp, {type: 'offer'}); // [] — no issues
await pc.setLocalDescription({type: 'offer', sdp: sdpWriter.write(sdp)});The m= payload type list is derived from codecs, so removing a codec also removes
its rtpmap, fmtp and rtcp-fb lines.
import {sdpReader, SdpParseError} from '@irbisadm/simple-sdp';
const sdp = sdpReader.parse(pc.localDescription.sdp);
sdp.group; // [{semantics: 'BUNDLE', mids: ['0', '1']}]
const [audio] = sdp.media;
audio.mid; // '0'
audio.direction; // 'sendrecv'
audio.codecs?.[0]; // {payloadType: 111, encodingName: 'opus', clockRate: 48000,
// encodingParameters: 2, parameters: {minptime: '10', useinbandfec: '1'},
// rtcpFeedback: ['transport-cc']}
audio.candidates?.[0].type; // 'host'
try {
sdpReader.parse('v=0\r\ns=-\r\no=- 1 1 IN IP4 127.0.0.1\r\nt=0 0\r\n');
} catch (e) {
if (e instanceof SdpParseError) {
e.lineNumber; // 3
e.reason; // 'line is out of order (session order: v o s i u e p c b t r z k a)'
}
}sdpReader.isValid(text) returns true if parse accepts the text.
All line types of RFC 8866 §5 are supported: v o s i u e p c b t r z k a m, at session
and media level. r=/z= time units (d h m s) are converted to seconds.
| Attribute | Field | Spec |
|---|---|---|
sendrecv sendonly recvonly inactive |
direction |
RFC 8866 §6.7 |
group |
group[] |
RFC 5888, RFC 9143 |
mid |
mid |
RFC 5888 |
rtpmap fmtp rtcp-fb |
codecs[] (per payload type), rtcpFeedbackWildcard |
RFC 8866, RFC 4585 |
extmap extmap-allow-mixed |
extmap[], extmapAllowMixed |
RFC 8285 |
ice-ufrag ice-pwd ice-options ice-lite |
iceUfrag icePwd iceOptions iceLite |
RFC 8839 |
candidate |
candidates[] |
RFC 8839, RFC 6544 (tcptype) |
end-of-candidates |
endOfCandidates |
RFC 8840 |
fingerprint |
fingerprints[] |
RFC 8122 |
setup |
setup |
RFC 4145 |
msid |
msid[] |
RFC 8830 |
ssrc ssrc-group |
ssrcs[] ssrcGroups[] |
RFC 5576 |
rid |
rids[] |
RFC 8851 |
simulcast |
simulcast |
RFC 8853 |
rtcp |
rtcp |
RFC 3605 |
rtcp-mux rtcp-rsize |
rtcpMux rtcpRsize |
RFC 5761, RFC 5506 |
sctp-port max-message-size |
sctpPort maxMessageSize |
RFC 8841 |
Other attributes go to attributes: {name, value?}[] in their original order.
So do known attributes outside their scope (e.g. a=mid at session level) and
rtpmap/fmtp/rtcp-fb for a payload type that is not in the m= line.
sdpWriter.write(sdp) emits lines in RFC 8866 §5 order, each terminated by CRLF.
It throws if origin or timing is missing, or if a media section has neither codecs
nor formats.
The output carries the same lines as the input, but the order of attributes inside a
section is normalized. Session level: group, ice-lite, ICE, DTLS, extmap, direction,
end-of-candidates, then generic attributes. Media level: rtcp, ICE, DTLS, mid,
extmap, direction, msid, rtcp-mux, rtcp-rsize, per codec rtpmap → rtcp-fb → fmtp,
sctp-port, max-message-size, rid, simulcast, ssrc-group, ssrc, candidate,
end-of-candidates, then generic attributes in their original order.
Other normalizations:
r=/z=times are written in seconds (7d→604800);- whitespace around
;ina=fmtpis dropped; a=ssrclines are grouped by SSRC;a=simulcastlistssendbeforerecv.
The round-trip test checks every fixture: the lines of each section are the same as in
the input, non-attribute lines keep their order, and parse(write(parse(x))) deep-equals
parse(x).
sdpValidator.validate(sdp | text, {type?: 'offer' | 'answer'}) returns
SdpIssue[] ({severity, code, message, mediaIndex?, lineNumber?}). Text is parsed first.
A parse failure is reported as one parse-error issue, so the RFC 8866 §5 order rules
are covered as well.
| Code | Severity | Rule |
|---|---|---|
parse-error |
error | sdpReader.parse failed |
invalid-version, missing-origin, missing-session-name, missing-timing |
error | mandatory v=/o=/s=/t= (for hand-built objects) |
empty-session-name |
warning | s= must not be empty (RFC 8866 §5.3) |
missing-connection |
error | c= at session level or in every media description (RFC 8866 §5.7) |
duplicate-mid, group-unknown-mid, bundle-missing-mid |
error | unique mids; group members exist; every media has mid with BUNDLE (RFC 5888, RFC 9143) |
missing-rtpmap |
error | payload types above 34 need a=rtpmap (RFC 8866 §6.6, RFC 3551) |
unknown-payload-type |
warning | rtpmap/fmtp/rtcp-fb for a payload type not in m= |
missing-fingerprint, missing-setup |
error | DTLS transports (UDP/TLS/RTP/SAVPF, UDP/DTLS/SCTP, …) (RFC 8842 §5.2, §5.3) |
setup-actpass-in-answer |
error | with type: 'answer' (RFC 5763 §5) |
invalid-fingerprint |
error | colon-separated hex pairs; digest length matches the hash (sha-256 → 32 bytes, …) (RFC 8122 §5) |
fingerprint-not-uppercase |
warning | hex digits must be upper case (RFC 8122 §5) |
missing-ice-ufrag, missing-ice-pwd |
error | if ICE is used, every active media has credentials (RFC 8839 §5.4) |
ice-ufrag-length, ice-pwd-length |
error | 4–256 / 22–256 characters (RFC 8839 §5.4) |
extmap-invalid-id, extmap-duplicate-id |
error | ids 1–14 or 16–255, unique per section (RFC 8285) |
simulcast-unknown-rid, rid-unknown-payload-type |
error | a=simulcast rids exist with the same direction; pt= values are in m= (RFC 8851, RFC 8853) |
ssrc-group-unknown-ssrc |
warning | a=ssrc-group members have a=ssrc lines (RFC 5576) |
Media sections with port 0 (rejected or bundle-only) are skipped by the ICE and DTLS checks.
sdpReader.parse throws SdpParseError when:
- a line is not
<type>=<value>, is empty, or has an unknown type; - lines are out of the RFC 8866 §5 order (session
v o s i u e p c b t r z k a, mediam i c b k a) or a single-occurrence line repeats; v=,o=,s=ort=is missing, orvis not0;- a typed line or attribute is malformed (e.g.
a=candidatewithouttyp,a=setup:client); - a single-occurrence attribute repeats (
a=mid,a=ice-ufrag, two directions, …).
Accepted deviations: LF-only line endings (RFC 8866 §5 says parsers SHOULD accept them),
a missing final line terminator, RFC 4566-style z= after several t= lines.
Known limitation: more than one c= line in a media description (layered multicast)
is rejected.
Tested on real session descriptions (see test/fixtures).
Every fixture parses, round-trips through the writer and validates without issues:
| Source | Fixtures |
|---|---|
| Chrome 156 | audio+video, audio-only, datachannel, simulcast (rid + simulcast): offer and answer |
| Firefox 159 | audio+video, audio-only, datachannel, simulcast: offer and answer |
| Chrome (2023) | audio+video, datachannel, a perfect-negotiation sequence of 7 descriptions |
| SIP, RFC 4566 style, no ICE/DTLS | Polycom SoundPoint IP 330 offer, FreeSWITCH 1.0.4 answer, LG IP phone answer, carrier gateway offer with static payload types and no rtpmap |
Safari is not covered by fixtures yet.
assembly/ is a port of the library to AssemblyScript.
It compiles to a WebAssembly module (≈90 KB, ≈32 KB gzipped) with no imports, so the same
parser, writer and validator run in any wasm runtime: browsers and Node.js, iOS through a
wasm interpreter (no JIT, as the App Store requires), Android and the JVM as bytecode
compiled from the module at build time.
The port behaves exactly like the TypeScript code. yarn test:wasm runs the test suites of
this package against the wasm build, plus a differential test that compares both
implementations on all fixtures and on thousands of mutations of them: same model, same
written text, same issues, same error messages.
| Platform | Get it | Runtime |
|---|---|---|
| iOS 18+, macOS 15+ | SwiftPM: .package(url: "https://github.com/irbisadm/simple-sdp.git", from: "1.2.0"), product SimpleSdp (platforms/apple) |
WasmKit interpreter |
| Android, JVM 11+ | JitPack: maven("https://jitpack.io"), implementation("com.github.irbisadm:simple-sdp:v1.2.0") (platforms/kotlin) |
Chicory, compiled to bytecode at build time |
| Browser, Node.js | npm: import {sdpReader} from '@irbisadm/simple-sdp/wasm' — the API of the main entry point |
the JS engine |
| Other wasm hosts | simple-sdp.wasm from GitHub Releases or @irbisadm/simple-sdp/wasm/simple-sdp.wasm |
any |
For JavaScript the TypeScript build is the faster choice (see the timings below); the
/wasm entry point exists for parity checks and for environments that prefer one shared
implementation. It is an ES module with top-level await that loads simple-sdp.wasm through
new URL("simple-sdp.wasm", import.meta.url): Node.js 17+ and Vite handle it as is.
The model crosses the wasm boundary as JSON with the shape of the TypeScript Sdp interface.
let sdp = try SimpleSdp()
let json = try sdp.parseJSON(offer) // Data: the model, key order kept
let issues = try sdp.validate(json: json, type: .offer)
let text = try sdp.write(json: json)
let model = try sdp.parse(offer) // [String: Any] (does not keep key order)val sdp = SimpleSdp()
val model = sdp.parse(offer) // kotlinx.serialization JsonObject
val issues = sdp.validate(model, SdpType.OFFER)
val text = sdp.write(model)The module exports a C-style interface on UTF-8 buffers for hosts without AssemblyScript bindings:
| Export | |
|---|---|
sdp_alloc(len) → ptr |
allocate an input buffer; write the UTF-8 bytes at ptr |
sdp_parse(ptr, len) → res |
{"sdp": model} or {"error": {message, reason, lineNumber, line}} |
sdp_write(ptr, len) → res |
model JSON in; {"sdp": "text"} or {"error": {message}} |
sdp_validate(ptr, len, type) → res |
SDP text in, type 0 = none, 1 = offer, 2 = answer; {"issues": [...]} |
sdp_validate_model(ptr, len, type) → res |
model JSON in; {"issues": [...]} or {"error": {message}} |
sdp_version() → res |
version string |
sdp_len(res) → len |
byte length of a buffer |
sdp_free(ptr) |
release an input buffer or a result |
A module instance is single-threaded; the Swift and Kotlin wrappers serialize calls. The module uses the minimal AssemblyScript runtime: memory is reclaimed at the end of every call, so a result stays valid until the next call on the same instance.
Approximate time to parse / write an 18 KB Chrome offer on x86-64, JSON transfer included: wasm in Node.js 1.3 / 1.2 ms (the TypeScript build: 0.35 / 0.25 ms), JVM with the compiled module 6 / 9 ms, WasmKit (release build) ≈10 ms each.
The generated ESM bindings (wasm/build/simple-sdp.js, published as
wasm/dist/simple-sdp.mjs) take strings: parse(text), write(json), validate(text, type),
validateModel(json, type).
Build: yarn asbuild compiles the module and copies it into platforms/ (the copies are
committed so that SwiftPM and Gradle builds do not need Node.js).
MIT