diff --git a/.gitignore b/.gitignore index 2586e129c..e903ecfcf 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *.log # Coverage directory (tests with coverage) +build/ coverage/ # Bundles diff --git a/example-streams-node/mjpeg-player.js b/example-streams-node/mjpeg-player.js deleted file mode 100644 index 5d1d77f5f..000000000 --- a/example-streams-node/mjpeg-player.js +++ /dev/null @@ -1,65 +0,0 @@ -const yargs = require('yargs') - -const { pipelines } = require('../../lib/index.node.js') - -/** - * Stream live from camera (to be used from Node CLI). - * Command line tool to open a websocket/rtsp connection to a camera. - * - * Example usage: (when live is globally available) - * node mjpeg-player.js --host 192.168.0.90 --vapix resolution=800x600 videocodec=jpeg | mpv --demuxer-lavf-probescore=10 - - * or the equivalend with an explicit URI: - * node mjpeg-player.js --uri 'rtsp://192.168.0.90/axis-media/media.amp?videocodec=jpeg&resolution=800x600' | mpv --demuxer-lavf-probescore=10 - - * - * Some VAPIX options: - * - videocodec=[h264,mpeg4,jpeg] (Select a specific video codec) - * - streamprofile= (Use a specific stream profile) - * - recordingid= (Play a specific recording) - * - resolution= (The required resolution, e.g. 800x600) - * - audio=[0,1] (Enable=1 or disable=0 audio) - * - camera=[1,2,...,quad] (Select a video source) - * - compression=[0..100] (Vary between no=0 and full=100 compression) - * - colorlevel=[0..100] (Vary between grey=0 and color=100) - * - color=[0,1] (Enable=0 or disable=0 color) - * - clock=[0,1] (Show=1 or hide=0 the clock) - * - date=[0,1] (Show=1 or hide=0 the date) - * - text=[0,1] (Show=1 or hide=0 the text overlay) - * - textstring= - * - textcolor=[black,white] - * - textbackgroundcolor=[black,white,transparent,semitransparent] - * - textpos=[0,1] (Show text at top=0 or bottom=0) - * - rotation=[0,90,180,270] (How may degrees to rotate the strea,) - * - duration= (How many seconds of video you want, unlimited=0) - * - nbrofframes= (How many frames of video you want, unlimited=0) - * - fps= (How many frames per second, unlimited=0) - */ - -const argv = yargs.options({ - uri: { type: 'string', describe: 'rtsp://hostname/path' }, - host: { type: 'string', describe: 'hostname', conflicts: 'uri' }, - vapix: { - type: 'string', - describe: 'key=value [key=value ...]', - conflicts: 'uri', - array: true, - }, -}).argv - -if (!(argv.uri || argv.host)) { - console.log('You must specify either a host or full RTSP uri') - yargs.showHelp() - process.exit(1) -} - -// Set up main configuration object. -const config = { - rtsp: { - uri: argv.uri, - hostname: argv.host, - parameters: argv.vapix, - }, -} - -// Setup a new pipeline -const pipeline = new pipelines.CliMjpegPipeline(config) -pipeline.play() diff --git a/example-streams-node/pipeline.mjs b/example-streams-node/pipeline.mjs new file mode 100644 index 000000000..ecf55f789 --- /dev/null +++ b/example-streams-node/pipeline.mjs @@ -0,0 +1,82 @@ +import { createConnection } from 'node:net' +import { Mp4Muxer, RtpDepay, RtspSession } from 'media-stream-library' + +class TcpSource { + constructor(socket) { + if (socket === undefined) { + throw new Error('socket argument missing') + } + + this.readable = new ReadableStream({ + start: (controller) => { + socket.on('data', (chunk) => { + controller.enqueue(new Uint8Array(chunk)) + }) + socket.on('end', () => { + console.error('server closed connection') + controller.close() + }) + }, + cancel: () => { + console.error('canceling TCP client') + socket.close(CLOSE_ABORTED, 'client canceled') + }, + }) + + this.writable = new WritableStream({ + start: (controller) => { + socket.on('end', () => { + controller.error('socket closed') + }) + socket.on('error', () => { + controller.error('socket errored') + }) + }, + write: (chunk) => { + try { + socket.write(chunk) + } catch (err) { + console.error('chunk lost during send:', err) + } + }, + close: () => { + console.error('closing TCP client') + socket.destroy('normal closure') + }, + abort: (reason) => { + console.error('aborting TCP client:', reason && reason.message) + socket.destroy('abort') + }, + }) + } +} + +export async function start(rtspUri) { + const url = new URL(rtspUri) + const socket = createConnection(url.port, url.hostname) + await new Promise((resolve) => { + socket.once('connect', resolve) + }) + + const tcpSource = new TcpSource(socket) + const rtspSession = new RtspSession({ uri: rtspUri }) + const rtpDepay = new RtpDepay() + const mp4Muxer = new Mp4Muxer() + + const stdout = new WritableStream({ + write: (msg, controller) => { + process.stdout.write(msg.data) + }, + }) + + rtspSession.play() + + return Promise.all([ + tcpSource.readable + .pipeThrough(rtspSession.demuxer) + .pipeThrough(rtpDepay) + .pipeThrough(mp4Muxer) + .pipeTo(stdout), + rtspSession.commands.pipeTo(tcpSource.writable), + ]) +} diff --git a/example-streams-node/player.cjs b/example-streams-node/player.cjs deleted file mode 100755 index c6ae263f1..000000000 --- a/example-streams-node/player.cjs +++ /dev/null @@ -1,63 +0,0 @@ -const yargs = require('yargs') - -const { pipelines } = require('media-stream-library') - -/** - * Stream live from camera (to be used from Node CLI). - * Command line tool to open a websocket/rtsp connection to a camera. - * - * Example usage: (when live is globally available) - * node player.js --host 192.168.0.2 --vapix audio=1 resolution=800x600 | vlc - - * - * Some VAPIX options: - * - videocodec=[h264,mpeg4,jpeg] (Select a specific video codec) - * - streamprofile= (Use a specific stream profile) - * - recordingid= (Play a specific recording) - * - resolution= (The required resolution, e.g. 800x600) - * - audio=[0,1] (Enable=1 or disable=0 audio) - * - camera=[1,2,...,quad] (Select a video source) - * - compression=[0..100] (Vary between no=0 and full=100 compression) - * - colorlevel=[0..100] (Vary between grey=0 and color=100) - * - color=[0,1] (Enable=0 or disable=0 color) - * - clock=[0,1] (Show=1 or hide=0 the clock) - * - date=[0,1] (Show=1 or hide=0 the date) - * - text=[0,1] (Show=1 or hide=0 the text overlay) - * - textstring= - * - textcolor=[black,white] - * - textbackgroundcolor=[black,white,transparent,semitransparent] - * - textpos=[0,1] (Show text at top=0 or bottom=0) - * - rotation=[0,90,180,270] (How may degrees to rotate the strea,) - * - duration= (How many seconds of video you want, unlimited=0) - * - nbrofframes= (How many frames of video you want, unlimited=0) - * - fps= (How many frames per second, unlimited=0) - */ - -const argv = yargs.options({ - uri: { type: 'string', describe: 'rtsp://hostname/path' }, - host: { type: 'string', describe: 'hostname', conflicts: 'uri' }, - vapix: { - type: 'string', - describe: 'key=value [key=value ...]', - conflicts: 'uri', - array: true, - }, -}).argv - -if (!(argv.uri || argv.host)) { - console.log('You must specify either a host or full RTSP uri') - yargs.showHelp() - process.exit(1) -} - -// Set up main configuration object. -const config = { - rtsp: { - uri: argv.uri, - hostname: argv.host, - parameters: argv.vapix, - }, -} - -// Setup a new pipeline -const pipeline = new pipelines.CliMp4Pipeline(config) -pipeline.rtsp.play() diff --git a/example-streams-node/player.mjs b/example-streams-node/player.mjs new file mode 100755 index 000000000..2d409d1a4 --- /dev/null +++ b/example-streams-node/player.mjs @@ -0,0 +1,48 @@ +import { start } from './pipeline.mjs' + +function help() { + console.log(` +Stream live from camera (to be used from Node CLI). +Command line tool to open a websocket/rtsp connection to a camera. + +Example usage: + +node player.mjs rtsp://192.168.0.2/axis-media/media.amp?audio=1&resolution=800x600 | vlc - + +Some VAPIX options: + - videocodec=[h264,mpeg4,jpeg] (Select a specific video codec) + - streamprofile= (Use a specific stream profile) + - recordingid= (Play a specific recording) + - resolution= (The required resolution, e.g. 800x600) + - audio=[0,1] (Enable=1 or disable=0 audio) + - camera=[1,2,...,quad] (Select a video source) + - compression=[0..100] (Vary between no=0 and full=100 compression) + - colorlevel=[0..100] (Vary between grey=0 and color=100) + - color=[0,1] (Enable=0 or disable=0 color) + - clock=[0,1] (Show=1 or hide=0 the clock) + - date=[0,1] (Show=1 or hide=0 the date) + - text=[0,1] (Show=1 or hide=0 the text overlay) + - textstring= + - textcolor=[black,white] + - textbackgroundcolor=[black,white,transparent,semitransparent] + - textpos=[0,1] (Show text at top=0 or bottom=0) + - rotation=[0,90,180,270] (How may degrees to rotate the strea,) + - duration= (How many seconds of video you want, unlimited=0) + - nbrofframes= (How many frames of video you want, unlimited=0) + - fps= (How many frames per second, unlimited=0) +`) +} + +const [uri] = process.argv.slice(2) +if (!uri) { + console.error('You must specify either a host or full RTSP uri') + help() + process.exit(1) +} + +// Setup a new pipeline +// const pipeline = new pipelines.CliMp4Pipeline(config) +// pipeline.rtsp.play() +start(uri).catch((err) => { + console.error('failed:', err) +}) diff --git a/example-streams-web/test/h264-overlay-player.js b/example-streams-web/test/h264-overlay-player.js index 577b00c06..dcfbad801 100644 --- a/example-streams-web/test/h264-overlay-player.js +++ b/example-streams-web/test/h264-overlay-player.js @@ -1,4 +1,4 @@ -const { components, pipelines, utils } = window.mediaStreamLibrary +const { RtspMp4Pipeline, Scheduler } = window.mediaStreamLibrary const d3 = window.d3 const play = (host) => { @@ -42,7 +42,7 @@ const play = (host) => { } // Setup a new pipeline - const pipeline = new pipelines.Html5VideoPipeline({ + const pipeline = new RtspMp4Pipeline({ ws: { uri: `ws://${host}:8854/` }, rtsp: { uri: `rtsp://localhost:8554/test` }, mediaElement, @@ -51,19 +51,16 @@ const play = (host) => { // Create a scheduler and insert it into the pipeline with // a peek component, which will call the run method of the // scheduler every time a message passes on the pipeline. - const scheduler = new utils.Scheduler(pipeline, draw) - const runScheduler = components.Tube.fromHandlers((msg) => scheduler.run(msg)) - pipeline.insertBefore(pipeline.lastComponent, runScheduler) + const scheduler = new Scheduler(pipeline, draw) + pipeline.rtp.peek(['h264'], (msg) => scheduler.run(msg)) // When we now the UNIX time of the start of the presentation, // initialize the scheduler with it. - pipeline.onSync = (ntpPresentationTime) => { + pipeline.videoStartTime.then((ntpPresentationTime) => { scheduler.init(ntpPresentationTime) - } - - pipeline.ready.then(() => { - pipeline.rtsp.play() }) + + pipeline.start() } play(window.location.hostname) diff --git a/example-streams-web/test/h264-player.js b/example-streams-web/test/h264-player.js index 5b50849a6..a7f398476 100644 --- a/example-streams-web/test/h264-player.js +++ b/example-streams-web/test/h264-player.js @@ -1,25 +1,23 @@ -const { pipelines } = window.mediaStreamLibrary +const { RtspMp4Pipeline } = window.mediaStreamLibrary const play = (host) => { // Grab a reference to the video element const mediaElement = document.querySelector('video') - console.warn('HI', host) - // Setup a new pipeline - const pipeline = new pipelines.Html5VideoPipeline({ + const pipeline = new RtspMp4Pipeline({ ws: { uri: `ws://${host}:8854/` }, rtsp: { uri: `rtsp://localhost:8554/test` }, mediaElement, }) - pipeline.ready.then(() => { - pipeline.rtsp.play() - }) + pipeline.onSourceOpen = (mse) => { // Setting a duration of zero seems to force lower latency // on Firefox, and doesn't seem to affect Chromium. mse.duration = 0 } + + pipeline.start() } play(window.location.hostname) diff --git a/example-streams-web/test/mjpeg-overlay-player.js b/example-streams-web/test/mjpeg-overlay-player.js index e37aca356..1d3102bb2 100644 --- a/example-streams-web/test/mjpeg-overlay-player.js +++ b/example-streams-web/test/mjpeg-overlay-player.js @@ -1,4 +1,4 @@ -const { components, pipelines, utils } = window.mediaStreamLibrary +const { RtspJpegPipeline, Scheduler } = window.mediaStreamLibrary const d3 = window.d3 const play = (host) => { @@ -38,8 +38,8 @@ const play = (host) => { } // Setup a new pipeline - const pipeline = new pipelines.Html5CanvasPipeline({ - ws: { uri: `ws://${host}:8854/` }, + const pipeline = new RtspJpegPipeline({ + ws: { uri: `ws://${host}:8855/` }, rtsp: { uri: `rtsp://localhost:8555/test` }, mediaElement, }) @@ -47,19 +47,17 @@ const play = (host) => { // Create a scheduler and insert it into the pipeline with // a peek component, which will call the run method of the // scheduler every time a message passes on the pipeline. - const scheduler = new utils.Scheduler(pipeline, draw) - const runScheduler = components.Tube.fromHandlers((msg) => scheduler.run(msg)) - pipeline.insertBefore(pipeline.lastComponent, runScheduler) + const scheduler = new Scheduler(pipeline, draw) + pipeline.rtp.peek(['jpeg'], (msg) => scheduler.run(msg)) // When we now the UNIX time of the start of the presentation, // initialize the scheduler with it. - pipeline.onSync = (ntpPresentationTime) => { + pipeline.videoStartTime.then((ntpPresentationTime) => { scheduler.init(ntpPresentationTime) - } - - pipeline.ready.then(() => { - pipeline.rtsp.play() }) + + pipeline.start() + pipeline.play() } play(window.location.hostname) diff --git a/example-streams-web/test/mjpeg-player.js b/example-streams-web/test/mjpeg-player.js index a1c890af2..5aa837204 100644 --- a/example-streams-web/test/mjpeg-player.js +++ b/example-streams-web/test/mjpeg-player.js @@ -1,18 +1,17 @@ -const { pipelines } = window.mediaStreamLibrary +const { RtspJpegPipeline } = window.mediaStreamLibrary const play = (host) => { // Grab a reference to the video element const mediaElement = document.querySelector('canvas') // Setup a new pipeline - const pipeline = new pipelines.Html5CanvasPipeline({ - ws: { uri: `ws://${host}:8854/` }, + const pipeline = new RtspJpegPipeline({ + ws: { uri: `ws://${host}:8855/` }, rtsp: { uri: `rtsp://localhost:8555/test` }, mediaElement, }) - pipeline.ready.then(() => { - pipeline.rtsp.play() - }) + pipeline.start() + pipeline.play() } play(window.location.hostname) diff --git a/example-streams-web/test/sdp.html b/example-streams-web/test/sdp.html new file mode 100644 index 000000000..5376d4a77 --- /dev/null +++ b/example-streams-web/test/sdp.html @@ -0,0 +1,14 @@ + + + + + Example SDP retrieval + + + +
+  
+  
+
+
+
diff --git a/example-streams-web/test/sdp.js b/example-streams-web/test/sdp.js
new file mode 100644
index 000000000..f7d936bb3
--- /dev/null
+++ b/example-streams-web/test/sdp.js
@@ -0,0 +1,20 @@
+const { fetchSdp } = window.mediaStreamLibrary
+
+const play = (host) => {
+  // Grab a reference to the video element
+  const sdpDiv = document.querySelector('#sdp')
+
+  // Setup a new pipeline
+  fetchSdp({
+    ws: { uri: `ws://${host}:8854/` },
+    rtsp: { uri: `rtsp://localhost:8554/test` },
+  })
+    .then((sdp) => {
+      sdpDiv.innerHTML = JSON.stringify(sdp, undefined, 2)
+    })
+    .catch((err) => {
+      sdpDiv.innerHTML = JSON.stringify(err, undefined, 2)
+    })
+}
+
+play(window.location.hostname)
diff --git a/example-streams-web/test/streaming-mp4-player.js b/example-streams-web/test/streaming-mp4-player.js
index e35a716b5..f3e1b3d36 100644
--- a/example-streams-web/test/streaming-mp4-player.js
+++ b/example-streams-web/test/streaming-mp4-player.js
@@ -1,15 +1,16 @@
-const { pipelines } = window.mediaStreamLibrary
+const { HttpMp4Pipeline } = window.mediaStreamLibrary
 
 const play = (host) => {
   // Grab a reference to the video element
   const mediaElement = document.querySelector('video')
 
   // Setup a new pipeline
-  const pipeline = new pipelines.HttpMsePipeline({
-    http: { uri: `http://${host}/test/bbb.mp4` },
+  const pipeline = new HttpMp4Pipeline({
+    uri: `http://${host}/test/bbb.mp4`,
     mediaElement,
   })
-  pipeline.http.play()
+
+  pipeline.start()
 }
 
 play(window.location.host)
diff --git a/justfile b/justfile
index 53d67153d..85d4559fa 100644
--- a/justfile
+++ b/justfile
@@ -32,8 +32,9 @@ changelog:
     git diff --quiet || (echo "workspace dirty!"; git diff; exit 1)
 
 # report coverage information after running tests
-coverage workspace *args='-r text --all':
-    c8 report --src={{ workspace }}/src {{ args }}
+coverage workspace *args='--src=src/ -r text --all':
+    cd {{workspace}} && c8 report -a {{ args }}
+
 
 # run esbuild, WORKSPACE=(overlay|player|streams)
 esbuild workspace *args:
@@ -66,9 +67,9 @@ release $level='patch':
 rtsp-ws:
     #!/usr/bin/env bash
     set -euo pipefail
-    trap "kill 0" EXIT SIGINT
+    trap "kill 0" EXIT
     scripts/rtsp-server.sh &
-    scripts/tcp-ws-proxy.cjs >& tcp-ws-proxy.log &
+    scripts/ws-rtsp-proxy.mjs 8854:8554 8855:8555 >& ws-rtsp-proxy.log &
     wait
 
 # statically serve a directory
@@ -84,10 +85,6 @@ tools:
     cd tools && esbuild --platform=node --outfile=src/__generated__/changelog.mjs --format=esm --out-extension:.js=.mjs --bundle --external:cmd-ts src/changelog/cli.ts
     just biome format --write 'tools/src/__generated__/*'
 
-# update a specific dependency to latest
-update *packages:
-    yarn update-interactive
-
 # CI verification
 verify:
     just build
@@ -124,11 +121,14 @@ test:
 
 # run tsc in workspace(s) (default current, or all if in project root)
 tsc workspace:
-    cd {{ workspace }} && tsc
+    cd {{ workspace }} && tsc -p tsconfig.types.json
+
+# run UVU tests for a workspace (tests/ directory)
+uvu workspace pattern='': (_clear-tests workspace)
+    cd {{workspace}} && esbuild --bundle --format=esm --outdir=tests/build --packages=external --platform=node --sourcemap=inline \
+        $(glob 'tests/**/*{{pattern}}*.test.{ts,tsx}')
+    cd {{workspace}} && c8 -r none --clean=false -- uvu tests/build '.*{{pattern}}.*\.test\.js$'
 
-# run uvu to test files matching pattern (path = path to tsconfig.json + tests, e.g. "admx/web", or "iam")
-uvu path pattern='.*\.test\.tsx?':
-    c8 -r none --clean=false --src={{ path }} -- tsx --tsconfig {{ path }}/tsconfig.json node_modules/uvu/bin.js {{ path }}/tests/ {{ pattern }}
 
 #
 # hidden commands (these can be run but they are not shown with just --list)
@@ -143,6 +143,9 @@ _build-player: _build-streams (tsc "player")
 _build-overlay: (tsc "overlay")
     just esbuild overlay
 
+_clear-tests workspace:
+    cd {{workspace}} && if [[ -d tests/build ]]; then rm -r tests/build; fi
+
 _copy-player-bundle dst:
     cp player/dist/media-stream-player.min.js {{ dst }}
     cp player/dist/media-stream-player.min.js.map {{ dst }}
@@ -166,10 +169,10 @@ _run-example-streams-node: _build-streams
 _run-example-streams-web: _build-streams (_copy-streams-bundle "example-streams-web")
     #!/usr/bin/env bash
     set -euo pipefail
-    trap "kill 0" EXIT SIGINT
+    trap "kill 0; wait" EXIT
     just rtsp-ws &
-    just serve example-streams-web &&
-    wait
+    just serve example-streams-web &
+    wait -n
 
 _run-overlay: _build-overlay
     echo "no direct playground for overlay yet, running example-overlay-react instead"
diff --git a/overlay/esbuild.mjs b/overlay/esbuild.mjs
index 4927d8d5c..2dc9e62b7 100755
--- a/overlay/esbuild.mjs
+++ b/overlay/esbuild.mjs
@@ -10,23 +10,18 @@ if (!existsSync(buildDir)) {
   mkdirSync(buildDir)
 }
 
-for (const output of [
-  { format: 'esm', name: 'index-esm.js' },
-  { format: 'cjs', name: 'index-cjs.js' },
-]) {
-  buildSync({
-    platform: 'browser',
-    entryPoints: ['src/index.ts'],
-    outfile: join(buildDir, output.name),
-    format: output.format,
-    external: ['@juggle/resize-observer', 'react', 'react-dom', 'pepjs'],
-    bundle: true,
-    minify: true,
-    sourcemap: true,
-    // avoid a list of browser targets by setting a common baseline ES level
-    target: 'es2015',
-  })
-}
+buildSync({
+  platform: 'browser',
+  entryPoints: ['src/index.ts'],
+  outfile: join(buildDir, 'index.js'),
+  format: 'esm',
+  packages: 'external',
+  bundle: true,
+  minify: false,
+  sourcemap: true,
+  // avoid a list of browser targets by setting a common baseline ES level
+  target: 'es2015',
+})
 
 buildSync({
   platform: 'browser',
diff --git a/overlay/package.json b/overlay/package.json
index 54612a032..1e867e87d 100644
--- a/overlay/package.json
+++ b/overlay/package.json
@@ -14,13 +14,12 @@
   "publishConfig": {
     "registry": "https://registry.npmjs.org/"
   },
-  "types": "./dist/index.d.ts",
-  "main": "./dist/index-cjs.js",
-  "module": "./dist/index-esm.js",
+  "type": "module",
   "exports": {
-    "types": "./dist/index.d.ts",
-    "require": "./dist/index-cjs.js",
-    "import": "./dist/index-esm.js"
+    ".": {
+      "types": "./dist/index.d.ts",
+      "import": "./dist/index.js"
+    }
   },
   "files": [
     "dist/**/*",
diff --git a/overlay/tsconfig.json b/overlay/tsconfig.json
index bf1c3b1fa..54483f530 100644
--- a/overlay/tsconfig.json
+++ b/overlay/tsconfig.json
@@ -1,12 +1,7 @@
 {
   "extends": "../tsconfig.base.json",
   "compilerOptions": {
-    "baseUrl": "src",
-    "jsx": "react",
-    "declaration": true,
-    "emitDeclarationOnly": true,
-    "noEmit": false,
-    "outDir": "dist"
+    "jsx": "react"
   },
   "include": ["src", "tests"]
 }
diff --git a/overlay/tsconfig.types.json b/overlay/tsconfig.types.json
new file mode 100644
index 000000000..4d7ef4c28
--- /dev/null
+++ b/overlay/tsconfig.types.json
@@ -0,0 +1,10 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "declaration": true,
+    "emitDeclarationOnly": true,
+    "noEmit": false,
+    "outDir": "dist"
+  },
+  "include": ["src"]
+}
diff --git a/package.json b/package.json
index 73e303a89..1768190d8 100644
--- a/package.json
+++ b/package.json
@@ -20,19 +20,16 @@
   ],
   "devDependencies": {
     "@biomejs/biome": "^1.8.3",
+    "@types/ws": "8.5.13",
     "c8": "10.1.2",
     "cmd-ts": "0.13.0",
     "cypress": "13.13.2",
-    "dpdm": "3.14.0",
     "esbuild": "0.23.0",
     "http-server": "14.1.1",
-    "license-checker": "25.0.1",
     "media-stream-library": "workspace:^",
     "semver": "7.6.3",
-    "stylelint": "16.8.1",
-    "tsx": "4.16.5",
     "typescript": "5.5.4",
-    "yargs": "17.7.2"
+    "ws": "8.18.0"
   },
   "packageManager": "yarn@3.8.1"
 }
diff --git a/player/esbuild.mjs b/player/esbuild.mjs
index 4514884ea..81e3cdc4a 100755
--- a/player/esbuild.mjs
+++ b/player/esbuild.mjs
@@ -1,4 +1,5 @@
 #!/usr/bin/env node
+
 import { existsSync, mkdirSync } from 'node:fs'
 import { join } from 'node:path'
 
@@ -10,35 +11,21 @@ if (!existsSync(buildDir)) {
   mkdirSync(buildDir)
 }
 
-const bundles = [
-  { format: 'esm', name: 'index-esm.js' },
-  { format: 'cjs', name: 'index-cjs.js' },
-]
-
-for (const { name, format } of bundles) {
-  buildSync({
-    platform: 'browser',
-    entryPoints: ['src/index.ts'],
-    outfile: join(buildDir, name),
-    format,
-    external: [
-      '@juggle/resize-observer',
-      'debug',
-      'react-hooks-shareable',
-      'react',
-      'react-dom',
-      'luxon',
-      'styled-components',
-      'media-stream-library',
-    ],
-    bundle: true,
-    minify: false,
-    sourcemap: true,
-    // avoid a list of browser targets by setting a common baseline ES level
-    target: 'es2015',
-  })
-}
+// ES module
+buildSync({
+  platform: 'browser',
+  entryPoints: ['src/index.ts'],
+  outfile: join(buildDir, 'index.js'),
+  format: 'esm',
+  packages: 'external',
+  bundle: true,
+  minify: false,
+  sourcemap: true,
+  // avoid a list of browser targets by setting a common baseline ES level
+  target: 'es2015',
+})
 
+// IIFE
 buildSync({
   platform: 'browser',
   entryPoints: ['src/index.ts'],
diff --git a/player/package.json b/player/package.json
index 341bd0101..81ecf1f80 100644
--- a/player/package.json
+++ b/player/package.json
@@ -18,14 +18,11 @@
   "publishConfig": {
     "registry": "https://registry.npmjs.org/"
   },
-  "types": "./dist/index.d.ts",
-  "main": "./dist/index-cjs.js",
-  "module": "./dist/index-esm.js",
+  "type": "module",
   "exports": {
     ".": {
       "types": "./dist/index.d.ts",
-      "require": "./dist/index-cjs.js",
-      "import": "./dist/index-esm.js"
+      "import": "./dist/index.js"
     }
   },
   "files": [
@@ -34,19 +31,16 @@
     "README.md"
   ],
   "dependencies": {
-    "@juggle/resize-observer": "3.4.0",
-    "debug": "4.3.6",
-    "react-hooks-shareable": "1.53.0"
+    "@juggle/resize-observer": "3.4.0"
   },
   "peerDependencies": {
     "luxon": "^3.0.0",
-    "media-stream-library": "^13.2.0",
+    "media-stream-library": "workspace:^",
     "react": "^17.0.2 || ^18.1.0",
     "react-dom": "^17.0.2 || ^18.1.0",
     "styled-components": "^5.3.5"
   },
   "devDependencies": {
-    "@types/debug": "4.1.12",
     "@types/luxon": "3.4.2",
     "@types/react": "18.3.3",
     "@types/react-dom": "18.3.0",
diff --git a/player/src/Container.tsx b/player/src/Container.tsx
index 487e357bd..52422600d 100644
--- a/player/src/Container.tsx
+++ b/player/src/Container.tsx
@@ -1,4 +1,4 @@
-import React, { ReactNode } from 'react'
+import React from 'react'
 
 import styled from 'styled-components'
 
@@ -53,7 +53,7 @@ export const Layer = styled.div`
 
 interface ContainerProps {
   readonly aspectRatio?: number
-  readonly children: ReactNode
+  readonly children: any // styled-components type mismatch
 }
 
 export const Container: React.FC = ({
diff --git a/player/src/HttpMp4Video.tsx b/player/src/HttpMp4Video.tsx
index d091d3d09..581b61668 100644
--- a/player/src/HttpMp4Video.tsx
+++ b/player/src/HttpMp4Video.tsx
@@ -1,7 +1,6 @@
 import React, { useEffect, useRef, useState } from 'react'
 
-import debug from 'debug'
-import { TransformationMatrix, pipelines } from 'media-stream-library'
+import { HttpMp4Pipeline, TransformationMatrix } from 'media-stream-library'
 import styled from 'styled-components'
 
 import { VideoProperties } from './PlaybackArea'
@@ -10,8 +9,7 @@ import { useEventState } from './hooks/useEventState'
 import { useVideoDebug } from './hooks/useVideoDebug'
 import { MetadataHandler } from './metadata'
 import { Format } from './types'
-
-const debugLog = debug('msp:http-mp4-video')
+import { logDebug } from './utils/log'
 
 const VideoNative = styled.video`
   max-height: 100%;
@@ -81,9 +79,7 @@ export const HttpMp4Video: React.FC = ({
   const [playing, unsetPlaying] = useEventState(videoRef, 'playing')
 
   // State tied to resources
-  const [pipeline, setPipeline] = useState(
-    null
-  )
+  const [pipeline, setPipeline] = useState(null)
   const [fetching, setFetching] = useState(false)
 
   // keep a stable reference to the external onPlaying callback
@@ -96,7 +92,7 @@ export const HttpMp4Video: React.FC = ({
 
   const __sensorTmRef = useRef()
 
-  useVideoDebug(videoRef.current, debugLog)
+  useVideoDebug(videoRef.current)
 
   useEffect(() => {
     const videoEl = videoRef.current
@@ -106,18 +102,15 @@ export const HttpMp4Video: React.FC = ({
     }
 
     if (play && canplay === true && playing === false) {
-      debugLog('play')
+      logDebug('play')
       videoEl.play().catch((err) => {
         console.error('VideoElement error: ', err.message)
       })
 
       const { videoHeight, videoWidth } = videoEl
-      debugLog('%o', {
-        videoHeight,
-        videoWidth,
-      })
+      logDebug(`resolution: ${videoWidth}x${videoHeight}`)
     } else if (!play && playing === true) {
-      debugLog('pause')
+      logDebug('pause')
       videoEl.pause()
       unsetPlaying()
     } else if (play && playing === true) {
@@ -146,9 +139,9 @@ export const HttpMp4Video: React.FC = ({
       const endedCallback = () => {
         __onEndedRef.current?.()
       }
-      debugLog('create pipeline', src)
-      const newPipeline = new pipelines.HttpMsePipeline({
-        http: { uri: src },
+      logDebug('create pipeline', src)
+      const newPipeline = new HttpMp4Pipeline({
+        uri: src,
         mediaElement: videoEl,
       })
       setPipeline(newPipeline)
@@ -156,7 +149,7 @@ export const HttpMp4Video: React.FC = ({
       newPipeline.onServerClose = endedCallback
 
       return () => {
-        debugLog('close pipeline and clear video')
+        logDebug('close pipeline and clear video')
         newPipeline.close()
         videoEl.src = ''
         setPipeline(null)
@@ -175,8 +168,8 @@ export const HttpMp4Video: React.FC = ({
             headers.get('video-metadata-transform')
         )
       }
-      pipeline.http.play()
-      debugLog('initiated data fetching')
+      pipeline.start()
+      logDebug('initiated data fetching')
       setFetching(true)
     }
   }, [play, pipeline, fetching])
diff --git a/player/src/PlaybackArea.tsx b/player/src/PlaybackArea.tsx
index bebbfc075..af4f84188 100644
--- a/player/src/PlaybackArea.tsx
+++ b/player/src/PlaybackArea.tsx
@@ -1,13 +1,13 @@
 import React, { Ref } from 'react'
 
-import debug from 'debug'
 import {
-  Html5CanvasPipeline,
-  Html5VideoPipeline,
-  HttpMsePipeline,
+  HttpMp4Pipeline,
   Rtcp,
+  RtspJpegPipeline,
+  RtspMp4Pipeline,
   Sdp,
   TransformationMatrix,
+  axisWebSocketConfig,
 } from 'media-stream-library'
 
 import { HttpMp4Video } from './HttpMp4Video'
@@ -16,6 +16,7 @@ import { WsRtspCanvas } from './WsRtspCanvas'
 import { WsRtspVideo } from './WsRtspVideo'
 import { MetadataHandler } from './metadata'
 import { Format } from './types'
+import { logDebug } from './utils/log'
 
 export type PlayerNativeElement =
   | HTMLVideoElement
@@ -23,11 +24,9 @@ export type PlayerNativeElement =
   | HTMLImageElement
 
 export type PlayerPipeline =
-  | Html5VideoPipeline
-  | Html5CanvasPipeline
-  | HttpMsePipeline
-
-const debugLog = debug('msp:api')
+  | RtspJpegPipeline
+  | RtspMp4Pipeline
+  | HttpMp4Pipeline
 
 export enum AxisApi {
   AXIS_IMAGE_CGI = 'AXIS_IMAGE_CGI',
@@ -92,8 +91,9 @@ interface PlaybackAreaProps {
   readonly autoRetry?: boolean
 }
 
-const wsUri = (protocol: Protocol.WS | Protocol.WSS, host: string) => {
-  return host.length !== 0 ? `${protocol}//${host}/rtsp-over-websocket` : ''
+const wsUri = (secure: boolean, host: string) => {
+  const scheme = secure ? Protocol.HTTPS : Protocol.HTTP
+  return axisWebSocketConfig(`${scheme}//${host}`)
 }
 
 const rtspUri = (host: string, searchParams: string) => {
@@ -246,7 +246,7 @@ const searchParams = (api: AxisApi, parameters: VapixParameters = {}) => {
   return Object.entries(parameters)
     .map(([key, value]) => {
       if (!parameterList.includes(key)) {
-        debugLog(`undocumented VAPIX parameter ${key}`)
+        logDebug(`undocumented VAPIX parameter ${key}`)
       }
       return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`
     })
@@ -272,7 +272,7 @@ export const PlaybackArea: React.FC = ({
   const timestamp = refresh.toString()
 
   if (format === Format.RTP_H264) {
-    const ws = wsUri(secure ? Protocol.WSS : Protocol.WS, host)
+    const { uri: ws, tokenUri: token } = wsUri(secure, host)
     const rtsp = rtspUri(
       host,
       searchParams(FORMAT_API[format], {
@@ -288,6 +288,7 @@ export const PlaybackArea: React.FC = ({
         forwardedRef={forwardedRef as Ref}
         {...{
           ws,
+          token,
           rtsp,
           play,
           offset,
@@ -303,7 +304,7 @@ export const PlaybackArea: React.FC = ({
   }
 
   if (format === Format.RTP_JPEG) {
-    const ws = wsUri(secure ? Protocol.WSS : Protocol.WS, host)
+    const { uri: ws, tokenUri: token } = wsUri(secure, host)
     const rtsp = rtspUri(
       host,
       searchParams(FORMAT_API[format], {
@@ -317,7 +318,17 @@ export const PlaybackArea: React.FC = ({
       }
-        {...{ ws, rtsp, play, offset, onPlaying, onEnded, onSdp, onRtcp }}
+        {...{
+          ws,
+          token,
+          rtsp,
+          play,
+          offset,
+          onPlaying,
+          onEnded,
+          onSdp,
+          onRtcp,
+        }}
       />
     )
   }
diff --git a/player/src/Stats.tsx b/player/src/Stats.tsx
index 852427509..d87fda791 100644
--- a/player/src/Stats.tsx
+++ b/player/src/Stats.tsx
@@ -4,19 +4,19 @@ import React, {
   useEffect,
   useState,
 } from 'react'
-
-import { Html5VideoPipeline } from 'media-stream-library'
-import { useInterval } from 'react-hooks-shareable'
 import styled from 'styled-components'
 
+import { RtspMp4Pipeline } from 'media-stream-library'
+
 import { PlayerPipeline, VideoProperties } from './PlaybackArea'
+import { useInterval } from './hooks/useInterval'
 import { StreamStats } from './img'
 import { Format } from './types'
 
-const isHtml5VideoPipeline = (
-  pipeline: PlayerPipeline | null | undefined
-): pipeline is Html5VideoPipeline => {
-  return (pipeline as Html5VideoPipeline)?.tracks !== undefined
+const isRtspMp4Pipeline = (
+  pipeline: PlayerPipeline | undefined
+): pipeline is RtspMp4Pipeline => {
+  return (pipeline as RtspMp4Pipeline)?.mp4.tracks !== undefined
 }
 
 const StatsWrapper = styled.div`
@@ -163,35 +163,25 @@ const StatsData: React.FC<
         unit: refresh > 1 ? 'times' : 'time',
       },
     ]
-    if (isHtml5VideoPipeline(pipeline)) {
-      const tracks = pipeline.tracks?.map((track, index) =>
-        Object.assign({ index }, track)
-      )
-      const videoTrack = tracks?.find((track) => track.type === 'video')
-      if (videoTrack !== undefined) {
-        const { coding, profile, level } = videoTrack.codec
-        const framerate = Number(
-          pipeline.framerate[videoTrack.index].toFixed(2)
-        )
-        const bitrate = Math.round(pipeline.bitrate[videoTrack.index] / 1000)
-
+    if (isRtspMp4Pipeline(pipeline)) {
+      pipeline.mp4.tracks.forEach(({ id, name, codec, bitrate, framerate }) => {
         statsData = statsData.concat([
           {
-            name: 'Encoding',
-            value: `${coding} ${profile} (${level})`,
+            name: `Track ${id}`,
+            value: `${name} (${codec})`,
           },
           {
             name: 'Frame rate',
-            value: framerate,
+            value: framerate.toFixed(2),
             unit: 'fps',
           },
           {
             name: 'Bitrate',
-            value: bitrate,
+            value: (bitrate / 1000).toFixed(1),
             unit: 'kbit/s',
           },
         ])
-      }
+      })
     }
 
     if (volume !== undefined) {
diff --git a/player/src/StillImage.tsx b/player/src/StillImage.tsx
index be0c10eff..3a3b498d1 100644
--- a/player/src/StillImage.tsx
+++ b/player/src/StillImage.tsx
@@ -1,14 +1,12 @@
 import React, { useEffect, useRef } from 'react'
 
-import debug from 'debug'
 import styled from 'styled-components'
 
 import { VideoProperties } from './PlaybackArea'
 import { FORMAT_SUPPORTS_AUDIO } from './constants'
 import { useEventState } from './hooks/useEventState'
 import { Format } from './types'
-
-const debugLog = debug('msp:still-image')
+import { logDebug } from './utils/log'
 
 const ImageNative = styled.img`
   max-height: 100%;
@@ -82,6 +80,6 @@ export const StillImage: React.FC = ({
     }
   }, [loaded])
 
-  debugLog('render image', loaded)
+  logDebug('render image', loaded)
   return 
 }
diff --git a/player/src/WsRtspCanvas.tsx b/player/src/WsRtspCanvas.tsx
index 7b2f21deb..0d83f2c4c 100644
--- a/player/src/WsRtspCanvas.tsx
+++ b/player/src/WsRtspCanvas.tsx
@@ -1,22 +1,19 @@
 import React, { useEffect, useRef, useState } from 'react'
 
-import debug from 'debug'
 import {
   Rtcp,
+  RtspJpegPipeline,
   Sdp,
   TransformationMatrix,
   VideoMedia,
   isRtcpBye,
-  pipelines,
-  utils,
 } from 'media-stream-library'
 import styled from 'styled-components'
 
 import { Range, VideoProperties } from './PlaybackArea'
 import { FORMAT_SUPPORTS_AUDIO } from './constants'
 import { Format } from './types'
-
-const debugLog = debug('msp:ws-rtsp-video')
+import { logDebug } from './utils/log'
 
 const CanvasNative = styled.canvas`
   max-height: 100%;
@@ -34,6 +31,7 @@ interface WsRtspCanvasProps {
    * The source URI for the WebSocket server.
    */
   readonly ws?: string
+  readonly token?: string
   /**
    * The RTSP URI.
    */
@@ -79,8 +77,9 @@ interface WsRtspCanvasProps {
 export const WsRtspCanvas: React.FC = ({
   forwardedRef,
   play = true,
-  ws = '',
-  rtsp = '',
+  ws,
+  token,
+  rtsp,
   onPlaying,
   onEnded,
   onSdp,
@@ -98,8 +97,7 @@ export const WsRtspCanvas: React.FC = ({
   }
 
   // State tied to resources
-  const [pipeline, setPipeline] =
-    useState(null)
+  const [pipeline, setPipeline] = useState(null)
   const [fetching, setFetching] = useState(false)
 
   // keep track of changes in starting time
@@ -119,7 +117,7 @@ export const WsRtspCanvas: React.FC = ({
 
     timeout.current = window.setInterval(() => {
       const { currentTime } = pipeline
-      debugLog('%o', { currentTime })
+      logDebug(`currentTime: ${currentTime}`)
     }, 1000)
 
     return () => window.clearTimeout(timeout.current)
@@ -129,24 +127,24 @@ export const WsRtspCanvas: React.FC = ({
     __offsetRef.current = offset
     const canvas = canvasRef.current
     if (ws && rtsp && canvas) {
-      debugLog('create pipeline')
-      const newPipeline = new pipelines.Html5CanvasPipeline({
-        ws: { uri: ws },
+      logDebug('create pipeline', ws, rtsp)
+      const newPipeline = new RtspJpegPipeline({
+        ws: { uri: ws, tokenUri: token },
         rtsp: { uri: rtsp },
         mediaElement: canvas,
       })
       if (autoRetry) {
-        utils.addRTSPRetry(newPipeline.rtsp)
+        newPipeline.rtsp.retry.codes = [503]
       }
       setPipeline(newPipeline)
 
       return () => {
-        debugLog('destroy pipeline')
+        logDebug('destroy pipeline')
         newPipeline.pause()
         newPipeline.close()
         setPipeline(null)
         setFetching(false)
-        debugLog('canvas cleared')
+        logDebug('canvas cleared')
       }
     }
   }, [ws, rtsp, offset, autoRetry])
@@ -171,49 +169,41 @@ export const WsRtspCanvas: React.FC = ({
 
   useEffect(() => {
     if (play && pipeline && !fetching) {
-      pipeline.ready
-        .then(() => {
-          debugLog('fetch')
-          pipeline.onSdp = (sdp) => {
-            const videoMedia = sdp.media.find((m): m is VideoMedia => {
-              return m.type === 'video'
-            })
-            if (videoMedia !== undefined) {
-              __sensorTmRef.current =
-                videoMedia['x-sensor-transform'] ?? videoMedia['transform']
-            }
-            if (__onSdpRef.current !== undefined) {
-              __onSdpRef.current(sdp)
-            }
-          }
-
-          pipeline.rtsp.onRtcp = (rtcp) => {
-            __onRtcpRef.current?.(rtcp)
+      pipeline.rtsp.onRtcp = (rtcp) => {
+        __onRtcpRef.current?.(rtcp)
 
-            if (isRtcpBye(rtcp)) {
-              __onEndedRef.current?.()
-            }
+        if (isRtcpBye(rtcp)) {
+          __onEndedRef.current?.()
+        }
+      }
+      pipeline
+        .start(__offsetRef.current)
+        .then(({ sdp, range }) => {
+          const videoMedia = sdp.media.find((m): m is VideoMedia => {
+            return m.type === 'video'
+          })
+          if (videoMedia !== undefined) {
+            __sensorTmRef.current =
+              videoMedia['x-sensor-transform'] ?? videoMedia['transform']
           }
-
-          pipeline.rtsp.onPlay = (range) => {
-            if (range !== undefined) {
-              __rangeRef.current = [
-                parseFloat(range[0]) || 0,
-                parseFloat(range[1]) || undefined,
-              ]
-            }
+          if (__onSdpRef.current !== undefined) {
+            __onSdpRef.current(sdp)
+          }
+          if (range !== undefined) {
+            __rangeRef.current = [
+              parseFloat(range[0]) || 0,
+              parseFloat(range[1]) || undefined,
+            ]
           }
-          pipeline.rtsp.play(__offsetRef.current)
-          setFetching(true)
         })
-        .catch(console.error)
+        .catch((err) => console.log('failed to start pipeline:', err))
+      logDebug('fetching')
+      setFetching(true)
     } else if (play && pipeline !== null) {
-      debugLog('play')
-      pipeline.play()
-
-      // Callback `onCanPlay` is called when the canvas element is ready to
-      // play. We need to wait for that event to get the correct width/height.
-      pipeline.onCanplay = () => {
+      logDebug('play')
+      // Play only starts when the canvas element is ready to play.
+      /// We need to await that to get the correct width/height.
+      pipeline.play().then(() => {
         if (
           canvasRef.current !== null &&
           __onPlayingRef.current !== undefined
@@ -227,9 +217,9 @@ export const WsRtspCanvas: React.FC = ({
             sensorTm: __sensorTmRef.current,
           })
         }
-      }
+      })
     } else if (!play && pipeline) {
-      debugLog('pause')
+      logDebug('pause')
       pipeline.pause()
     }
   }, [play, pipeline, fetching])
diff --git a/player/src/WsRtspVideo.tsx b/player/src/WsRtspVideo.tsx
index 2148db0b7..f98ed87a4 100644
--- a/player/src/WsRtspVideo.tsx
+++ b/player/src/WsRtspVideo.tsx
@@ -1,14 +1,13 @@
 import React, { useEffect, useRef, useState } from 'react'
 
-import debug from 'debug'
 import {
   Rtcp,
+  RtspMp4Pipeline,
+  Scheduler,
   Sdp,
   TransformationMatrix,
   VideoMedia,
   isRtcpBye,
-  pipelines,
-  utils,
 } from 'media-stream-library'
 import styled from 'styled-components'
 
@@ -22,8 +21,7 @@ import {
   attachMetadataHandler,
 } from './metadata'
 import { Format } from './types'
-
-const debugLog = debug('msp:ws-rtsp-video')
+import { logDebug } from './utils/log'
 
 const VideoNative = styled.video`
   max-height: 100%;
@@ -45,6 +43,7 @@ interface WsRtspVideoProps {
    * The source URI for the WebSocket server.
    */
   readonly ws?: string
+  readonly token?: string
   /**
    * The RTSP URI.
    */
@@ -89,6 +88,7 @@ export const WsRtspVideo: React.FC = ({
   forwardedRef,
   play = false,
   ws,
+  token,
   rtsp,
   autoPlay = true,
   muted = true,
@@ -118,9 +118,7 @@ export const WsRtspVideo: React.FC = ({
   const [playing, unsetPlaying] = useEventState(videoRef, 'playing')
 
   // State tied to resources
-  const [pipeline, setPipeline] = useState(
-    null
-  )
+  const [pipeline, setPipeline] = useState(null)
   const [fetching, setFetching] = useState(false)
 
   // keep track of changes in starting time
@@ -138,7 +136,7 @@ export const WsRtspVideo: React.FC = ({
 
   const __sensorTmRef = useRef()
 
-  useVideoDebug(videoRef.current, debugLog)
+  useVideoDebug(videoRef.current)
 
   useEffect(() => {
     const videoEl = videoRef.current
@@ -148,18 +146,15 @@ export const WsRtspVideo: React.FC = ({
     }
 
     if (play && canplay === true && playing === false) {
-      debugLog('play')
+      logDebug('play')
       videoEl.play().catch((err) => {
         console.error('VideoElement error: ', err.message)
       })
 
       const { videoHeight, videoWidth } = videoEl
-      debugLog('%o', {
-        videoHeight,
-        videoWidth,
-      })
+      logDebug(`resolution: ${videoWidth}x${videoHeight}`)
     } else if (!play && playing === true) {
-      debugLog('pause')
+      logDebug('pause')
       videoEl.pause()
       unsetPlaying()
     } else if (play && playing === true) {
@@ -170,7 +165,9 @@ export const WsRtspVideo: React.FC = ({
           width: videoEl.videoWidth,
           height: videoEl.videoHeight,
           formatSupportsAudio: FORMAT_SUPPORTS_AUDIO[Format.RTP_H264],
-          volume: pipeline?.tracks?.find((track) => track.type === 'audio')
+          volume: pipeline?.mp4.tracks?.find((track) =>
+            track.codec.startsWith('mp4a')
+          )
             ? videoEl.volume
             : undefined,
           range: __rangeRef.current,
@@ -195,18 +192,18 @@ export const WsRtspVideo: React.FC = ({
       rtsp.length > 0 &&
       videoEl !== null
     ) {
-      debugLog('create pipeline', ws, rtsp)
-      const newPipeline = new pipelines.Html5VideoPipeline({
-        ws: { uri: ws },
+      logDebug('create pipeline', ws, rtsp)
+      const newPipeline = new RtspMp4Pipeline({
+        ws: { uri: ws, tokenUri: token },
         rtsp: { uri: rtsp },
         mediaElement: videoEl,
       })
       if (autoRetry) {
-        utils.addRTSPRetry(newPipeline.rtsp)
+        newPipeline.rtsp.retry.codes = [503]
       }
       setPipeline(newPipeline)
 
-      let scheduler: utils.Scheduler | undefined
+      let scheduler: Scheduler | undefined
       if (__metadataHandlerRef.current !== undefined) {
         scheduler = attachMetadataHandler(
           newPipeline,
@@ -215,7 +212,7 @@ export const WsRtspVideo: React.FC = ({
       }
 
       return () => {
-        debugLog('close pipeline and clear video')
+        logDebug('close pipeline and clear video')
         newPipeline.close()
         videoEl.src = ''
         scheduler?.reset()
@@ -237,43 +234,36 @@ export const WsRtspVideo: React.FC = ({
 
   useEffect(() => {
     if (play && pipeline && !fetching) {
-      pipeline.ready
-        .then(() => {
-          pipeline.onSdp = (sdp) => {
-            const videoMedia = sdp.media.find((m): m is VideoMedia => {
-              return m.type === 'video'
-            })
-            if (videoMedia !== undefined) {
-              __sensorTmRef.current =
-                videoMedia['x-sensor-transform'] ?? videoMedia['transform']
-            }
-            if (__onSdpRef.current !== undefined) {
-              __onSdpRef.current(sdp)
-            }
-          }
+      pipeline.rtsp.onRtcp = (rtcp) => {
+        __onRtcpRef.current?.(rtcp)
 
-          pipeline.rtsp.onRtcp = (rtcp) => {
-            __onRtcpRef.current?.(rtcp)
+        if (isRtcpBye(rtcp)) {
+          __onEndedRef.current?.()
+        }
+      }
 
-            if (isRtcpBye(rtcp)) {
-              __onEndedRef.current?.()
-            }
+      pipeline
+        .start(__offsetRef.current)
+        .then(({ sdp, range }) => {
+          const videoMedia = sdp.media.find((m): m is VideoMedia => {
+            return m.type === 'video'
+          })
+          if (videoMedia !== undefined) {
+            __sensorTmRef.current =
+              videoMedia['x-sensor-transform'] ?? videoMedia['transform']
           }
-
-          pipeline.rtsp.onPlay = (range) => {
-            if (range !== undefined) {
-              __rangeRef.current = [
-                parseFloat(range[0]) || 0,
-                parseFloat(range[1]) || undefined,
-              ]
-            }
+          if (__onSdpRef.current !== undefined) {
+            __onSdpRef.current(sdp)
+          }
+          if (range !== undefined) {
+            __rangeRef.current = [
+              parseFloat(range[0]) || 0,
+              parseFloat(range[1]) || undefined,
+            ]
           }
-          pipeline.rtsp.play(__offsetRef.current)
-        })
-        .catch((err) => {
-          console.error(err)
         })
-      debugLog('initiated data fetching')
+        .catch((err) => console.log('failed to start pipeline:', err))
+      logDebug('initiated data fetching')
       setFetching(true)
     }
   }, [play, pipeline, fetching])
diff --git a/player/src/hooks/useInterval.ts b/player/src/hooks/useInterval.ts
new file mode 100644
index 000000000..c29c3241f
--- /dev/null
+++ b/player/src/hooks/useInterval.ts
@@ -0,0 +1,22 @@
+import { useEffect, useRef } from 'react'
+
+export const useInterval = (callback: VoidFunction, delay: number) => {
+  const savedCallback = useRef()
+
+  // Remember the latest callback.
+  useEffect(() => {
+    savedCallback.current = callback
+  }, [callback])
+
+  // Set up the interval.
+  useEffect(() => {
+    const tick = () => {
+      if (savedCallback.current !== undefined) {
+        savedCallback.current()
+      }
+    }
+
+    const id = setInterval(tick, delay)
+    return () => clearInterval(id)
+  }, [delay])
+}
diff --git a/player/src/hooks/useVideoDebug.ts b/player/src/hooks/useVideoDebug.ts
index 288024410..45ddc66f4 100644
--- a/player/src/hooks/useVideoDebug.ts
+++ b/player/src/hooks/useVideoDebug.ts
@@ -1,6 +1,6 @@
 import { useEffect } from 'react'
 
-import { Debugger } from 'debug'
+import { logDebug } from '../utils/log'
 
 /**
  * Show debug logs with information received from
@@ -10,27 +10,34 @@ import { Debugger } from 'debug'
  * currentTime: current playback time
  * delay: the last buffered time - current playback time
  */
-export const useVideoDebug = (
-  videoEl: HTMLVideoElement | null,
-  debugLog: Debugger
-) => {
+export const useVideoDebug = (videoEl: HTMLVideoElement | null) => {
   useEffect(() => {
     if (videoEl === null) {
       return
     }
 
+    // Hacky way of showing delay as a video overlay (don't copy this)
+    // but it prevents the console from overflowing with buffer statements
+    const stats = document.createElement('div')
+    const text = document.createElement('pre')
+    stats.appendChild(text)
+    videoEl.parentElement?.appendChild(stats)
+    stats.setAttribute(
+      'style',
+      'background: rgba(120,255,100,0.4); position: absolute; width: 100px; height: 16px; top: 0; left: 0; font-size: 11px; font-family: "sans";'
+    )
+    text.setAttribute('style', 'margin: 2px;')
+
     const onUpdate = () => {
       try {
         const currentTime = videoEl.currentTime
         const bufferedEnd = videoEl.buffered.end(videoEl.buffered.length - 1)
 
-        debugLog('%o', {
-          delay: bufferedEnd - currentTime,
-          currentTime,
-          bufferedEnd,
-        })
+        const delay = Math.floor((bufferedEnd - currentTime) * 1000)
+        const contents = `buffer: ${String(delay).padStart(4, ' ')}ms`
+        text.innerText = contents
       } catch (err) {
-        debugLog('%o', err)
+        logDebug(err)
       }
     }
 
@@ -40,6 +47,7 @@ export const useVideoDebug = (
     return () => {
       videoEl.removeEventListener('timeupdate', onUpdate)
       videoEl.removeEventListener('progress', onUpdate)
+      stats.remove()
     }
-  }, [debugLog, videoEl])
+  }, [videoEl])
 }
diff --git a/player/src/metadata.ts b/player/src/metadata.ts
index 380208b4d..647776c5d 100644
--- a/player/src/metadata.ts
+++ b/player/src/metadata.ts
@@ -1,10 +1,4 @@
-import {
-  MessageType,
-  XmlMessage,
-  components,
-  pipelines,
-  utils,
-} from 'media-stream-library'
+import { RtspMp4Pipeline, Scheduler, XmlMessage } from 'media-stream-library'
 
 /**
  * Metadata handlers
@@ -46,15 +40,15 @@ export interface MetadataHandler {
  * @param handlers The handlers to deal with XML data
  */
 export const attachMetadataHandler = (
-  pipeline: pipelines.Html5VideoPipeline,
+  pipeline: RtspMp4Pipeline,
   { parser, cb }: MetadataHandler
-): utils.Scheduler => {
+): Scheduler => {
   /**
    * When a metadata handler is available on this component, it will be
    * called in sync with the player, using a scheduler to synchronize the
    * callback with the video presentation time.
    */
-  const scheduler = new utils.Scheduler(pipeline, cb, 30)
+  const scheduler = new Scheduler(pipeline, cb, 30)
   const xmlParser = new DOMParser()
 
   const xmlMessageHandler = (msg: XmlMessage) => {
@@ -68,19 +62,17 @@ export const attachMetadataHandler = (
     }
   }
 
-  // Add extra components to the pipeline.
-  const onvifDepay = new components.ONVIFDepay()
-  const onvifHandlerPipe = components.Tube.fromHandlers((msg) => {
-    if (msg.type === MessageType.XML) {
+  // Peek at the messages coming out of RTP depay
+  pipeline.rtp.peek(['xml'], (msg) => {
+    if (msg.type === 'xml') {
       xmlMessageHandler(msg)
     }
-  }, undefined)
-  pipeline.insertAfter(pipeline.rtsp, onvifDepay)
-  pipeline.insertAfter(onvifDepay, onvifHandlerPipe)
+  })
 
   // Initialize the scheduler when presentation time is ready
-  pipeline.onSync = (ntpPresentationTime: number) =>
+  pipeline.videoStartTime.then((ntpPresentationTime) => {
     scheduler.init(ntpPresentationTime)
+  })
 
   return scheduler
 }
diff --git a/player/src/utils/log.ts b/player/src/utils/log.ts
new file mode 100644
index 000000000..477702f4f
--- /dev/null
+++ b/player/src/utils/log.ts
@@ -0,0 +1,58 @@
+/**
+ * Logging utiltities
+ *
+ * Provides functions to log objects without serialization overhead it
+ * the logs are not active.
+ *
+ * To activate logs, write `localStorage.setItem('msl-player-debug', 'true')` in
+ * the console. Set to false or remove to disable logs.
+ * Errors are always logged.
+ */
+
+const key = 'msl-player-debug'
+
+let active = false
+try {
+  active = Boolean(JSON.parse(localStorage.getItem(key) ?? 'false'))
+} catch {}
+
+if (active) {
+  console.warn(
+    `${key} logs are active, use localStorage.removeItem('${key}') to deactivate`
+  )
+}
+
+const styles = {
+  blue: 'color: light-dark(#0f2b45, #7fb3e0);',
+}
+
+let last = performance.now()
+function out(level: 'debug' | 'error' | 'info' | 'warn', ...args: unknown[]) {
+  const now = performance.now()
+  const elapsed = now - last
+  last = now
+  console[level](
+    `%c[+${elapsed}ms]`,
+    styles.blue,
+    ...args.map((arg) => `${arg}`)
+  )
+}
+
+export function logDebug(...args: unknown[]) {
+  if (!active) return
+  out('debug', ...args)
+}
+
+export function logError(...args: unknown[]) {
+  out('error', ...args)
+}
+
+export function logInfo(...args: unknown[]) {
+  if (!active) return
+  out('info', ...args)
+}
+
+export function logWarn(...args: unknown[]) {
+  if (!active) return
+  out('warn', ...args)
+}
diff --git a/player/tsconfig.json b/player/tsconfig.json
index f3683328e..54483f530 100644
--- a/player/tsconfig.json
+++ b/player/tsconfig.json
@@ -1,12 +1,7 @@
 {
   "extends": "../tsconfig.base.json",
   "compilerOptions": {
-    "baseUrl": "src",
-    "declaration": true,
-    "emitDeclarationOnly": true,
-    "jsx": "react",
-    "noEmit": false,
-    "outDir": "dist"
+    "jsx": "react"
   },
   "include": ["src", "tests"]
 }
diff --git a/player/tsconfig.types.json b/player/tsconfig.types.json
new file mode 100644
index 000000000..4d7ef4c28
--- /dev/null
+++ b/player/tsconfig.types.json
@@ -0,0 +1,10 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "declaration": true,
+    "emitDeclarationOnly": true,
+    "noEmit": false,
+    "outDir": "dist"
+  },
+  "include": ["src"]
+}
diff --git a/scripts/rtsp-server.sh b/scripts/rtsp-server.sh
index 51f1db2bf..996e0fa52 100755
--- a/scripts/rtsp-server.sh
+++ b/scripts/rtsp-server.sh
@@ -43,7 +43,7 @@ if [ -z "${container}" ]; then
   exit 1
 fi
 
-trap "docker kill ${container} >& /dev/null" EXIT
+trap "docker kill ${container}" EXIT
 
 #
 # print some usage information
diff --git a/scripts/tcp-ws-proxy.cjs b/scripts/tcp-ws-proxy.cjs
deleted file mode 100755
index e9692e884..000000000
--- a/scripts/tcp-ws-proxy.cjs
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/usr/bin/env node
-const { pipelines } = require('media-stream-library')
-const yargs = require('yargs')
-const { hideBin } = require('yargs/helpers')
-
-const argv = yargs(hideBin(process.argv))
-  .option('port', {
-    type: 'string',
-    description: 'websocket port (8854)',
-    default: '8854',
-  })
-  .option('rtspHost', { type: 'string', description: 'RTSP host' })
-  .parse() // Setup a new pipeline
-;(function wrap() {
-  console.log(`WebSocket server at ws://localhost:${argv.port}`)
-  console.log(pipelines, pipelines.TcpWsProxyPipeline)
-  return new pipelines.TcpWsProxyPipeline({
-    wsOptions: { host: '::', port: argv.port },
-    rtspHost: argv.rtspHost,
-  })
-})()
diff --git a/scripts/ws-rtsp-proxy.mjs b/scripts/ws-rtsp-proxy.mjs
new file mode 100755
index 000000000..b81f4f320
--- /dev/null
+++ b/scripts/ws-rtsp-proxy.mjs
@@ -0,0 +1,59 @@
+#!/usr/bin/env node
+
+import { connect } from 'node:net'
+import { WebSocketServer } from 'ws'
+
+function usage() {
+  console.error(`
+Usage: ws-rtsp-proxy  [ ...]
+
+Options:
+ port-map       A map of WebSocket server port (proxy) to RTSP server port (destination)
+                Example: 8854:8554
+`)
+}
+
+const [...portmaps] = process.argv.slice(2)
+
+if (portmaps.length === 0) {
+  usage()
+  process.exit(1)
+}
+
+for (const portmap of portmaps) {
+  const [wsPort, rtspPort] = portmap.split(':')
+  console.log(
+    `starting WebSocket server at ws://localhost:${wsPort} proxying data to rtsp://localhost:${rtspPort}`
+  )
+
+  const wss = new WebSocketServer({ host: '::', port: Number(wsPort) })
+  let rtspSocket
+  wss.on('connection', (webSocket) => {
+    rtspSocket?.destroy()
+
+    console.log('new connection', new Date())
+
+    rtspSocket = connect(Number(rtspPort) || 554)
+
+    // pass incoming messages to the RTSP server
+    webSocket.on('message', (data) => {
+      rtspSocket.write(data)
+    })
+    webSocket.on('error', (err) => {
+      console.error('WebSocket fail:', err)
+      rtspSocket.end()
+    })
+    // pass data from the RTSP server back through the WebSocket
+    rtspSocket.on('data', (data) => {
+      webSocket.send(data)
+    })
+    rtspSocket.on('error', (err) => {
+      console.error('RTSP socket fail:', err)
+      webSocket.close()
+    })
+  })
+
+  wss.on('error', (err) => {
+    console.error('WebSocket server fail:', err)
+  })
+}
diff --git a/streams/esbuild.mjs b/streams/esbuild.mjs
index a8b6618d8..68992df0f 100755
--- a/streams/esbuild.mjs
+++ b/streams/esbuild.mjs
@@ -1,4 +1,5 @@
 #!/usr/bin/env node
+
 import { existsSync, mkdirSync } from 'node:fs'
 import { join } from 'node:path'
 
@@ -10,87 +11,25 @@ if (!existsSync(buildDir)) {
   mkdirSync(buildDir)
 }
 
-const browserBundles = [
-  {
-    format: 'esm',
-    name: 'browser-esm.js',
-    external: ['debug', 'ts-md5', 'ws'],
-    inject: ['polyfill.mjs'],
-  },
-  {
-    format: 'cjs',
-    name: 'browser-cjs.js',
-    external: ['debug', 'ts-md5', 'ws'],
-    inject: ['polyfill.mjs'],
-  },
-  {
-    format: 'esm',
-    name: 'browser-light-esm.js',
-    external: ['debug', 'process', 'stream', 'ts-md5', 'ws'],
-    inject: ['polyfill.mjs'],
-  },
-  {
-    format: 'cjs',
-    name: 'browser-light-cjs.js',
-    external: ['debug', 'process', 'stream', 'ts-md5', 'ws'],
-    inject: ['polyfill.mjs'],
-  },
-]
-
-for (const { format, name, external, inject } of browserBundles) {
-  buildSync({
-    platform: 'browser',
-    entryPoints: ['src/index.browser.ts'],
-    outfile: join(buildDir, name),
-    format,
-    // Needed because readable-stream (needed by stream-browserify) still references global.
-    // There are issues on this, but they get closed, so unsure if this will ever change.
-    define: {
-      global: 'window',
-      process: 'process_browser',
-    },
-    inject,
-    external,
-    bundle: true,
-    minify: false,
-    sourcemap: true,
-    // avoid a list of browser targets by setting a common baseline ES level
-    target: 'es2015',
-  })
-}
-
-const nodeBundles = [
-  { format: 'esm', name: 'node.mjs' },
-  { format: 'cjs', name: 'node.cjs' },
-]
-
-for (const { format, name } of nodeBundles) {
-  buildSync({
-    platform: 'node',
-    entryPoints: ['src/index.node.ts'],
-    outfile: join(buildDir, name),
-    format,
-    external: ['stream', 'process', 'ws'],
-    bundle: true,
-    minify: false,
-    sourcemap: true,
-    target: 'node20',
-  })
-}
+buildSync({
+  platform: 'browser',
+  entryPoints: ['src/index.ts'],
+  outfile: join(buildDir, 'index.js'),
+  format: 'esm',
+  packages: 'external',
+  bundle: true,
+  minify: false,
+  sourcemap: true,
+  // avoid a list of browser targets by setting a common baseline ES level
+  target: 'es2015',
+})
 
 buildSync({
   platform: 'browser',
-  entryPoints: ['src/index.browser.ts'],
+  entryPoints: ['src/index.ts'],
   outfile: join(buildDir, 'media-stream-library.min.js'),
   format: 'iife',
   globalName: 'mediaStreamLibrary',
-  // Needed because readable-stream (needed by stream-browserify) still references global.
-  // There are issues on this, but they get closed, so unsure if this will ever change.
-  define: {
-    global: 'window',
-    process: 'process_browser',
-  },
-  inject: ['polyfill.mjs'],
   bundle: true,
   minify: true,
   sourcemap: true,
diff --git a/streams/package.json b/streams/package.json
index 33d089b56..a7279f413 100644
--- a/streams/package.json
+++ b/streams/package.json
@@ -16,57 +16,27 @@
   "publishConfig": {
     "registry": "https://registry.npmjs.org/"
   },
-  "types": "./dist/src/index.browser.d.ts",
-  "main": "./dist/node.cjs",
-  "module": "./dist/node.mjs",
+  "type": "module",
   "exports": {
     ".": {
-      "types": "./dist/src/index.browser.d.ts",
-      "browser": {
-        "types": "./dist/src/index.browser.d.ts",
-        "require": "./dist/browser-cjs.js",
-        "import": "./dist/browser-esm.js"
-      },
-      "node": {
-        "types": "./dist/src/index.node.d.ts",
-        "require": "./dist/node.cjs",
-        "import": "./dist/node.mjs"
-      }
-    },
-    "./light": {
-      "types": "./dist/src/index.browser.d.ts",
-      "require": "./dist/browser-light-cjs.js",
-      "import": "./dist/browser-light-esm.js"
+      "types": "./dist/index.d.ts",
+      "import": "./dist/index.js"
     }
   },
-  "browser": {
-    "./dist/node.cjs": "./dist/browser-cjs.js",
-    "./dist/node.mjs": "./dist/browser-esm.js",
-    "stream": "stream-browserify"
-  },
   "files": [
     "dist/**/*",
     "LICENSE",
     "README.md"
   ],
   "dependencies": {
-    "base64-js": "1.5.1",
-    "debug": "4.3.6",
-    "process": "0.11.10",
-    "ts-md5": "1.3.1",
-    "ws": "8.18.0"
+    "base64-js": "^1.5.1",
+    "ts-md5": "^1.3.1"
   },
   "devDependencies": {
     "@types/debug": "4.1.12",
     "@types/node": "20.12.5",
-    "@types/ws": "8.5.12",
     "esbuild": "0.23.0",
-    "events": "3.3.0",
-    "global-jsdom": "9.2.0",
-    "jsdom": "24.1.1",
     "mock-socket": "9.3.1",
-    "semver": "7.6.3",
-    "stream-browserify": "3.0.0",
     "typescript": "5.5.4",
     "uvu": "0.5.6"
   }
diff --git a/streams/polyfill.mjs b/streams/polyfill.mjs
deleted file mode 100644
index 1fe380950..000000000
--- a/streams/polyfill.mjs
+++ /dev/null
@@ -1,2 +0,0 @@
-import * as process_browser from 'process/browser'
-window.process_browser = process_browser
diff --git a/streams/src/components/aacdepay/index.ts b/streams/src/components/aacdepay/index.ts
deleted file mode 100644
index c81acec6b..000000000
--- a/streams/src/components/aacdepay/index.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import { payloadType } from '../../utils/protocols/rtp'
-import { Tube } from '../component'
-import { Message, MessageType } from '../message'
-import { createTransform } from '../messageStreams'
-
-import { parse } from './parser'
-
-/*
-media: [{ type: 'video',
-   port: '0',
-   proto: 'RTP/AVP',
-   fmt: '96',
-   rtpmap: '96 H264/90000',
-   fmtp: {
-      format: '96',
-      parameters: {
-        'packetization-mode': '1',
-        'profile-level-id': '4d0029',
-        'sprop-parameter-sets': 'Z00AKeKQDwBE/LgLcBAQGkHiRFQ=,aO48gA==',
-      },
-    },
-   control: 'rtsp://hostname/axis-media/media.amp/stream=0?audio=1&video=1',
-   framerate: '25.000000',
-   transform: [[1, 0, 0], [0, 0.75, 0], [0, 0, 1]] },
-   { type: 'audio',
-     port: '0',
-     proto: 'RTP/AVP',
-     fmt: '97',
-     fmtp: {
-       parameters: {
-         bitrate: '32000',
-         config: '1408',
-         indexdeltalength: '3',
-         indexlength: '3',
-         mode: 'AAC-hbr',
-         'profile-level-id': '2',
-         sizelength: '13',
-         streamtype: '5'
-       },
-       format: '97'
-     },
-     rtpmap: '97 MPEG4-GENERIC/16000/1',
-     control: 'rtsp://hostname/axis-media/media.amp/stream=1?audio=1&video=1' }]
-*/
-
-export class AACDepay extends Tube {
-  constructor() {
-    let AACPayloadType: number
-    let hasHeader: boolean
-
-    const incoming = createTransform(function (
-      msg: Message,
-      encoding,
-      callback
-    ) {
-      if (msg.type === MessageType.SDP) {
-        // Check if there is an AAC track in the SDP
-        let validMedia
-        for (const media of msg.sdp.media) {
-          if (
-            media.type === 'audio' &&
-            media.fmtp &&
-            media.fmtp.parameters &&
-            media.fmtp.parameters.mode === 'AAC-hbr'
-          ) {
-            validMedia = media
-          }
-        }
-        if (validMedia && validMedia.rtpmap !== undefined) {
-          AACPayloadType = Number(validMedia.rtpmap.payloadType)
-          const parameters = validMedia.fmtp.parameters
-          // Required
-          const sizeLength = Number(parameters.sizelength) || 0
-          const indexLength = Number(parameters.indexlength) || 0
-          const indexDeltaLength = Number(parameters.indexdeltalength) || 0
-          // Optionals
-          const CTSDeltaLength = Number(parameters.ctsdeltalength) || 0
-          const DTSDeltaLength = Number(parameters.dtsdeltalength) || 0
-          const RandomAccessIndication =
-            Number(parameters.randomaccessindication) || 0
-          const StreamStateIndication =
-            Number(parameters.streamstateindication) || 0
-          const AuxiliaryDataSizeLength =
-            Number(parameters.auxiliarydatasizelength) || 0
-
-          hasHeader =
-            sizeLength +
-              Math.max(indexLength, indexDeltaLength) +
-              CTSDeltaLength +
-              DTSDeltaLength +
-              RandomAccessIndication +
-              StreamStateIndication +
-              AuxiliaryDataSizeLength >
-            0
-        }
-        callback(undefined, msg)
-      } else if (
-        msg.type === MessageType.RTP &&
-        payloadType(msg.data) === AACPayloadType
-      ) {
-        parse(msg, hasHeader, this.push.bind(this))
-        callback()
-      } else {
-        // Not a message we should handle
-        callback(undefined, msg)
-      }
-    })
-
-    // outgoing will be defaulted to a PassThrough stream
-    super(incoming)
-  }
-}
diff --git a/streams/src/components/aacdepay/parser.ts b/streams/src/components/aacdepay/parser.ts
deleted file mode 100644
index 16c5d5b52..000000000
--- a/streams/src/components/aacdepay/parser.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { readUInt16BE } from 'utils/bytes'
-import { payload, payloadType, timestamp } from '../../utils/protocols/rtp'
-import { ElementaryMessage, MessageType, RtpMessage } from '../message'
-
-/*
-From RFC 3640 https://tools.ietf.org/html/rfc3640
-  2.11.  Global Structure of Payload Format
-
-     The RTP payload following the RTP header, contains three octet-
-     aligned data sections, of which the first two MAY be empty, see
-     Figure 1.
-
-           +---------+-----------+-----------+---------------+
-           | RTP     | AU Header | Auxiliary | Access Unit   |
-           | Header  | Section   | Section   | Data Section  |
-           +---------+-----------+-----------+---------------+
-
-                     <----------RTP Packet Payload----------->
-
-              Figure 1: Data sections within an RTP packet
-Note that auxilary section is empty for AAC-hbr
-
-  3.2.1.  The AU Header Section
-
-   When present, the AU Header Section consists of the AU-headers-length
-   field, followed by a number of AU-headers, see Figure 2.
-
-      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+- .. -+-+-+-+-+-+-+-+-+-+
-      |AU-headers-length|AU-header|AU-header|      |AU-header|padding|
-      |                 |   (1)   |   (2)   |      |   (n)   | bits  |
-      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+- .. -+-+-+-+-+-+-+-+-+-+
-
-                   Figure 2: The AU Header Section
-*/
-
-export function parse(
-  rtp: RtpMessage,
-  hasHeader: boolean,
-  callback: (msg: ElementaryMessage) => void
-) {
-  const buffer = payload(rtp.data)
-
-  let headerLength = 0
-  if (hasHeader) {
-    const auHeaderLengthInBits = readUInt16BE(buffer, 0)
-    headerLength = 2 + (auHeaderLengthInBits + (auHeaderLengthInBits % 8)) / 8 // Add padding
-  }
-  const packet: ElementaryMessage = {
-    type: MessageType.ELEMENTARY,
-    data: new Uint8Array(buffer.subarray(headerLength)),
-    payloadType: payloadType(rtp.data),
-    timestamp: timestamp(rtp.data),
-    ntpTimestamp: rtp.ntpTimestamp,
-  }
-
-  callback(packet)
-}
diff --git a/streams/src/components/adapter.ts b/streams/src/components/adapter.ts
new file mode 100644
index 000000000..3256410bc
--- /dev/null
+++ b/streams/src/components/adapter.ts
@@ -0,0 +1,15 @@
+/**
+ * Adapter
+ *
+ * Transform stream that converts raw data chunks to messages,
+ * through a message generator provided by the user.
+ */
+export class Adapter extends TransformStream {
+  constructor(generator: (chunk: Uint8Array) => T) {
+    super({
+      transform: (chunk, controller) => {
+        controller.enqueue(generator(chunk))
+      },
+    })
+  }
+}
diff --git a/streams/src/components/auth/index.ts b/streams/src/components/auth/index.ts
deleted file mode 100644
index 70cff78e3..000000000
--- a/streams/src/components/auth/index.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { encode } from 'utils/bytes'
-import { merge } from '../../utils/config'
-import { statusCode } from '../../utils/protocols/rtsp'
-import { Tube } from '../component'
-import { Message, MessageType, RtspMessage } from '../message'
-import { createTransform } from '../messageStreams'
-
-import { fromByteArray } from 'base64-js'
-import { DigestAuth } from './digest'
-import { parseWWWAuthenticate } from './www-authenticate'
-
-const UNAUTHORIZED = 401
-
-export interface AuthConfig {
-  username?: string
-  password?: string
-}
-
-const DEFAULT_CONFIG = {
-  username: 'root',
-  password: 'pass',
-}
-
-/*
- * This component currently only supports Basic authentication
- * It should be placed between the RTSP parser and the RTSP Session.
- */
-
-export class Auth extends Tube {
-  constructor(config: AuthConfig = {}) {
-    const { username, password } = merge(DEFAULT_CONFIG, config)
-    if (username === undefined || password === undefined) {
-      throw new Error('need username and password')
-    }
-
-    let lastSentMessage: RtspMessage
-    let authHeader: string
-
-    const outgoing = createTransform(function (
-      msg: Message,
-      encoding,
-      callback
-    ) {
-      if (msg.type === MessageType.RTSP) {
-        lastSentMessage = msg
-        if (authHeader && msg.headers) {
-          msg.headers.Authorization = authHeader
-        }
-      }
-
-      callback(undefined, msg)
-    })
-
-    const incoming = createTransform(function (
-      msg: Message,
-      encoding,
-      callback
-    ) {
-      if (
-        msg.type === MessageType.RTSP &&
-        statusCode(msg.data.toString()) === UNAUTHORIZED
-      ) {
-        const headers = msg.data.toString().split('\n')
-        const wwwAuth = headers.find((header) => /WWW-Auth/i.test(header))
-        if (wwwAuth === undefined) {
-          throw new Error('cannot find WWW-Authenticate header')
-        }
-        const challenge = parseWWWAuthenticate(wwwAuth)
-        if (challenge.type === 'basic') {
-          authHeader = `Basic ${fromByteArray(encode(`${username}:${password}`))}`
-        } else if (challenge.type === 'digest') {
-          const digest = new DigestAuth(challenge.params, username, password)
-          authHeader = digest.authorization(
-            lastSentMessage.method,
-            lastSentMessage.uri
-          )
-        } else {
-          // unkown authentication type, give up
-          return
-        }
-
-        // Retry last RTSP message
-        // Write will fire our outgoing transform function.
-        outgoing.write(lastSentMessage, () => callback())
-      } else {
-        // Not a message we should handle
-        callback(undefined, msg)
-      }
-    })
-
-    super(incoming, outgoing)
-  }
-}
diff --git a/streams/src/components/basicdepay/index.ts b/streams/src/components/basicdepay/index.ts
deleted file mode 100644
index 6e284b1f0..000000000
--- a/streams/src/components/basicdepay/index.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { concat } from 'utils/bytes'
-import {
-  marker,
-  payload,
-  payloadType,
-  timestamp,
-} from '../../utils/protocols/rtp'
-import { Tube } from '../component'
-import { Message, MessageType } from '../message'
-import { createTransform } from '../messageStreams'
-
-export class BasicDepay extends Tube {
-  constructor(rtpPayloadType: number) {
-    if (rtpPayloadType === undefined) {
-      throw new Error('you must supply a payload type to BasicDepayComponent')
-    }
-
-    let buffer = new Uint8Array(0)
-
-    const incoming = createTransform(function (
-      msg: Message,
-      encoding,
-      callback
-    ) {
-      if (
-        msg.type === MessageType.RTP &&
-        payloadType(msg.data) === rtpPayloadType
-      ) {
-        const rtpPayload = payload(msg.data)
-        buffer = concat([buffer, rtpPayload])
-
-        if (marker(msg.data)) {
-          if (buffer.length > 0) {
-            this.push({
-              data: buffer,
-              timestamp: timestamp(msg.data),
-              ntpTimestamp: msg.ntpTimestamp,
-              payloadType: payloadType(msg.data),
-              type: MessageType.ELEMENTARY,
-            })
-          }
-          buffer = new Uint8Array(0)
-        }
-        callback()
-      } else {
-        // Not a message we should handle
-        callback(undefined, msg)
-      }
-    })
-
-    // outgoing will be defaulted to a PassThrough stream
-    super(incoming)
-  }
-}
diff --git a/streams/src/components/canvas/index.ts b/streams/src/components/canvas/index.ts
index 82fdc427a..233688551 100644
--- a/streams/src/components/canvas/index.ts
+++ b/streams/src/components/canvas/index.ts
@@ -1,10 +1,8 @@
-import { Readable, Writable } from 'stream'
+import type { JpegMessage } from '../types/jpeg'
+import { SdpMessage, isJpegMedia } from '../types/sdp'
 
-import { Clock } from '../../utils/clock'
-import { VideoMedia } from '../../utils/protocols/sdp'
-import { Scheduler } from '../../utils/scheduler'
-import { Sink } from '../component'
-import { Message, MessageType } from '../message'
+import { Clock } from '../utils/clock'
+import { Scheduler } from '../utils/scheduler'
 
 interface BlobMessage {
   readonly blob: Blob
@@ -57,23 +55,23 @@ const generateUpdateInfo = (clockrate: number) => {
 }
 
 /**
- * Canvas component
- *
  * Draws an incoming stream of JPEG images onto a  element.
  * The RTP timestamps are used to schedule the drawing of the images.
  * An instance can be used as a 'clock' itself, e.g. with a scheduler.
- *
- * The following handlers can be set on a component instance:
- *  - onCanplay: will be called when the first frame is ready and
- *               the correct frame size has been set on the canvas.
- *               At this point, the clock can be started by calling
- *               `.play()` method on the component.
- *  - onSync: will be called when the presentation time offset is
- *            known, with the latter as argument (in UNIX milliseconds)
  */
-export class CanvasSink extends Sink {
-  public onCanplay?: () => void
-  public onSync?: (ntpPresentationTime: number) => void
+export class CanvasSink {
+  public readonly writable: WritableStream
+
+  /** Resolves when the first frame is ready and the correct frame size
+   * has been set on the canvas. At this point, the clock can be started by
+   * calling `.play()` method on the component. */
+  public readonly canplay: Promise
+
+  /** Called when the (approximate) real time corresponding to the start of the
+   * video is known, extrapolated from the first available NTP timestamp and
+   * the duration represented by RTP timestamps since the first frame. */
+  public onSync?: (videoStartTime: number) => void
+
   private readonly _clock: Clock
   private readonly _scheduler: Scheduler
   private readonly _info: RateInfo
@@ -151,115 +149,94 @@ export class CanvasSink extends Sink {
     const clock = new Clock()
     const scheduler = new Scheduler(clock, drawImageBlob)
 
-    let ntpPresentationTime = 0
-    const onCanplay = () => {
-      this.onCanplay && this.onCanplay()
-    }
+    let videoStartTime = 0
     const onSync = (npt: number) => {
       this.onSync && this.onSync(npt)
     }
 
+    let resolveCanPlay: VoidFunction
+    this.canplay = new Promise((resolve) => {
+      resolveCanPlay = resolve
+    })
+
     // Set up an incoming stream and attach it to the image drawing function.
-    const incoming = new Writable({
-      objectMode: true,
-      write: (msg: Message, _encoding, callback) => {
-        if (msg.type === MessageType.SDP) {
+    this.writable = new WritableStream({
+      write: (msg, controller) => {
+        if (msg.type === 'sdp') {
           // start of a new movie, reset timers
           clock.reset()
           scheduler.reset()
 
           // Initialize first timestamp and clockrate
           firstTimestamp = 0
-          const jpegMedia = msg.sdp.media.find((media): media is VideoMedia => {
-            return (
-              media.type === 'video' &&
-              media.rtpmap !== undefined &&
-              media.rtpmap.encodingName === 'JPEG'
-            )
-          })
-
-          if (jpegMedia !== undefined && jpegMedia.rtpmap !== undefined) {
-            clockrate = jpegMedia.rtpmap.clockrate
-            // Initialize the framerate/bitrate data
-            resetInfo(info)
-            updateInfo = generateUpdateInfo(clockrate)
-          }
+          const jpegMedia = msg.media.find(isJpegMedia)
+          clockrate = jpegMedia?.rtpmap?.clockrate ?? 0
 
-          callback()
-        } else if (msg.type === MessageType.JPEG) {
-          const { timestamp, ntpTimestamp } = msg
-
-          // If first frame, store its timestamp, initialize
-          // the scheduler with 0 and start the clock.
-          // Also set the proper size on the canvas.
-          if (!firstTimestamp) {
-            // Initialize timing
-            firstTimestamp = timestamp
-            lastTimestamp = timestamp
-            // Initialize frame size
-            const { width, height } = msg.framesize
-            el.width = width
-            el.height = height
-            // Notify that we can play at this point
-            scheduler.init(0)
-          }
-          // Compute millisecond presentation time (with offset 0
-          // as we initialized the scheduler with 0).
-          const presentationTime =
-            (1000 * (timestamp - firstTimestamp)) / clockrate
-          const blob = new window.Blob([msg.data], { type: 'image/jpeg' })
-
-          // If the actual UTC time of the start of presentation isn't known yet,
-          // and we do have an ntpTimestamp, then compute it here and notify.
-          if (!ntpPresentationTime && ntpTimestamp) {
-            ntpPresentationTime = ntpTimestamp - presentationTime
-            onSync(ntpPresentationTime)
+          if (clockrate === 0) {
+            controller.error(
+              'invalid clockrate, either no JPEG media present or it has no clockrate'
+            )
+            return
           }
 
-          scheduler.run({
-            ntpTimestamp: presentationTime,
-            blob,
-          })
+          // Initialize the framerate/bitrate data
+          resetInfo(info)
+          updateInfo = generateUpdateInfo(clockrate)
+          return
+        }
 
-          // Notify that we can now start the clock.
-          if (timestamp === firstTimestamp) {
-            onCanplay()
-          }
+        const { timestamp, ntpTimestamp } = msg
 
-          // Update bitrate/framerate
-          updateInfo(info, {
-            byteLength: msg.data.length,
-            duration: timestamp - lastTimestamp,
-          })
+        // If first frame, store its timestamp, initialize
+        // the scheduler with 0 and start the clock.
+        // Also set the proper size on the canvas.
+        if (!firstTimestamp) {
+          // Initialize timing
+          firstTimestamp = timestamp
           lastTimestamp = timestamp
+          // Initialize frame size
+          const { width, height } = msg.framesize
+          el.width = width
+          el.height = height
+          // Notify that we can play at this point
+          scheduler.init(0)
+        }
+        // Compute millisecond presentation time (with offset 0
+        // as we initialized the scheduler with 0).
+        const presentationTime =
+          (1000 * (timestamp - firstTimestamp)) / clockrate
+        const blob = new window.Blob([msg.data], { type: 'image/jpeg' })
+
+        // If the actual UTC time of the start of presentation isn't known yet,
+        // and we do have an ntpTimestamp, then compute it here and notify.
+        if (!videoStartTime && ntpTimestamp) {
+          videoStartTime = ntpTimestamp - presentationTime
+          onSync(videoStartTime)
+        }
 
-          callback()
-        } else {
-          callback()
+        scheduler.run({
+          ntpTimestamp: presentationTime,
+          blob,
+        })
+
+        // Notify that we can now start the clock.
+        if (timestamp === firstTimestamp) {
+          resolveCanPlay()
         }
-      },
-    })
 
-    // Set up an outgoing stream.
-    const outgoing = new Readable({
-      objectMode: true,
-      read() {
-        //
+        // Update bitrate/framerate
+        updateInfo(info, {
+          byteLength: msg.data.length,
+          duration: timestamp - lastTimestamp,
+        })
+        lastTimestamp = timestamp
       },
     })
 
-    // When an error is sent on the outgoing stream, whine about it.
-    outgoing.on('error', () => {
-      console.warn('outgoing stream broke somewhere')
-    })
-
-    super(incoming, outgoing)
-
     this._clock = clock
     this._scheduler = scheduler
     this._info = info
 
-    this.onCanplay = undefined
     this.onSync = undefined
   }
 
@@ -281,7 +258,8 @@ export class CanvasSink extends Sink {
   /**
    * Start the presentation.
    */
-  play() {
+  async play() {
+    await this.canplay
     this._clock.play()
     this._scheduler.resume()
   }
diff --git a/streams/src/components/component.ts b/streams/src/components/component.ts
deleted file mode 100644
index 5252dca23..000000000
--- a/streams/src/components/component.ts
+++ /dev/null
@@ -1,291 +0,0 @@
-import { Duplex, PassThrough, Readable, Stream, Writable } from 'stream'
-
-import StreamFactory from './helpers/stream-factory'
-import { GenericMessage, MessageHandler } from './message'
-
-export type Component = Source | Tube | Sink
-
-type ErrorEventHandler = (err: Error) => void
-
-/**
- * Component
- *
- * A component is a set of bi-directional streams consisting of an 'incoming'
- * and 'outgoing' stream.
- *
- * They contain references to other components so they can form a linked list of
- * components, i.e. a pipeline. When linking components, the incoming and
- * outgoing streams are piped, so that data flowing through the incoming stream
- * is transfered to the next component, and data in the outgoing stream flows
- * to the previous component.
- *
- * Components at the end of such a pipeline typically connect the incoming and
- * outgoing streams to a data source or data sink.
- *
- * Typically, for a component that is connected to two other components, both
- * incoming and outgoing will be Transform streams. For a source, 'incoming'
- * will be a Readable stream and 'outgoing' a Writable stream, while for a sink
- * it is reversed. Both source and sink could also use a single Duplex stream,
- * with incoming === outgoing.
- *
- * server end-point                          client end-point
- *  /-------------      -----------------      -------------\
- *  |  Writable  |  <-  |   Transform   |  <-  |  Readable  |
- *  |   source   |      |      tube     |      |    sink    |
- *  |  Readable  |  ->  |   Transform   |  ->  |  Writable  |
- *  \-------------      -----------------      -------------/
- */
-abstract class AbstractComponent {
-  /**
-   * The stream going towards the client end-point
-   */
-  public abstract incoming: Stream
-  /**
-   * The stream going back to the server end-point
-   */
-  public abstract outgoing: Stream
-  /**
-   * The next component (downstream, towards the client)
-   */
-  public abstract next: Tube | Sink | null
-  /**
-   * The previous component (upstream, towards the server)
-   */
-  public abstract prev: Tube | Source | null
-  protected _incomingErrorHandler?: ErrorEventHandler
-  protected _outgoingErrorHandler?: ErrorEventHandler
-  /**
-   * Connect a downstream component (towards the client)
-   */
-  public abstract connect(next: Tube | Sink | null): Component
-  /**
-   * Disconnect a downstream component downstream (towards the client)
-   */
-  public abstract disconnect(): Component
-}
-
-/**
- * Source component
- *
- * A component that can only have a next component connected (no previous) and
- * where the incoming and outgoing streams are connected to an external data
- * source.
- */
-export class Source extends AbstractComponent {
-  /**
-   * Set up a source component that has a message list as data source.
-   *
-   * @param messages - List of objects (with data property) to emit on the
-   * incoming stream
-   */
-  public static fromMessages(messages: GenericMessage[]) {
-    const component = new Source(
-      StreamFactory.producer(messages),
-      StreamFactory.consumer()
-    )
-
-    return component
-  }
-
-  public incoming: Readable
-  public outgoing: Writable
-  public next: Tube | Sink | null
-  public prev: null
-
-  constructor(
-    incoming: Readable = new Readable({ objectMode: true }),
-    outgoing: Writable = new Writable({ objectMode: true })
-  ) {
-    super()
-    this.incoming = incoming
-    this.outgoing = outgoing
-    this.next = null
-    this.prev = null
-  }
-
-  /**
-   * Attach another component so the the 'down' stream flows into the
-   * next component 'down' stream and the 'up' stream of the other component
-   * flows into the 'up' stream of this component. This is what establishes the
-   * meaning of 'up' and 'down'.
-   * @param  next - The component to connect.
-   * @return A reference to the connected component.
-   *
-   *      -------------- pipe --------------
-   *  <-  |  outgoing  |  <-  |  outgoing  | <-
-   *      |    this    |      |    next    |
-   *  ->  |  incoming  |  ->  |  incoming  | ->
-   *      -------------- pipe --------------
-   */
-  public connect(next: Tube | Sink | null): Component {
-    // If the next component is not there, we want to return this component
-    // so that it is possible to continue to chain. If there is a next component,
-    // but this component already has a next one, or the next one already has a
-    // previous component, throw an error.
-    if (next === null) {
-      return this
-    } else if (this.next !== null || next.prev !== null) {
-      throw new Error('connection failed: component(s) already connected')
-    }
-
-    if (!this.incoming.readable || !this.outgoing.writable) {
-      throw new Error('connection failed: this component not compatible')
-    }
-
-    if (!next.incoming.writable || !next.outgoing.readable) {
-      throw new Error('connection failed: next component not compatible')
-    }
-
-    try {
-      this.incoming.pipe(next.incoming)
-      next.outgoing.pipe(this.outgoing)
-    } catch (e) {
-      throw new Error(`connection failed: ${(e as Error).message}`)
-    }
-
-    /**
-     * Propagate errors back upstream, this assures an error will be propagated
-     * to all previous streams (but not further than any endpoints). What happens
-     * when an error is emitted on a stream is up to the stream's implementation.
-     */
-    const incomingErrorHandler: ErrorEventHandler = (err) => {
-      this.incoming.emit('error', err)
-    }
-    next.incoming.on('error', incomingErrorHandler)
-
-    const outgoingErrorHandler: ErrorEventHandler = (err) => {
-      next.outgoing.emit('error', err)
-    }
-    this.outgoing.on('error', outgoingErrorHandler)
-
-    // Keep a bidirectional linked list of components by storing
-    // a reference to the next component and the listeners that we set up.
-    this.next = next
-    next.prev = this
-    this._incomingErrorHandler = incomingErrorHandler
-    this._outgoingErrorHandler = outgoingErrorHandler
-
-    return next
-  }
-
-  /**
-   * Disconnect the next connected component. When there is no next component
-   * the function will just do nothing.
-   * @return {Component} - A reference to this component.
-   */
-  public disconnect(): Component {
-    const next = this.next
-
-    if (next !== null) {
-      this.incoming.unpipe(next.incoming)
-      next.outgoing.unpipe(this.outgoing)
-
-      if (typeof this._incomingErrorHandler !== 'undefined') {
-        next.incoming.removeListener('error', this._incomingErrorHandler)
-      }
-      if (typeof this._outgoingErrorHandler !== 'undefined') {
-        this.outgoing.removeListener('error', this._outgoingErrorHandler)
-      }
-
-      this.next = null
-      next.prev = null
-      delete this._incomingErrorHandler
-      delete this._outgoingErrorHandler
-    }
-
-    return this
-  }
-}
-
-/**
- * Tube component
- *
- * A component where both incoming and outgoing streams are Duplex streams, and
- * can be connected to a previous and next component, typically in the middle of
- * a pipeline.
- */
-export class Tube extends Source {
-  /**
-   * Create a component that calls a handler function for each message passing
-   * through, but otherwise just passes data through.
-   *
-   * Can be used to log messages passing through a pipeline.
-   */
-  public static fromHandlers(
-    fnIncoming: MessageHandler | undefined,
-    fnOutgoing: MessageHandler | undefined
-  ) {
-    const incomingStream = fnIncoming
-      ? StreamFactory.peeker(fnIncoming)
-      : undefined
-    const outgoingStream = fnOutgoing
-      ? StreamFactory.peeker(fnOutgoing)
-      : undefined
-
-    return new Tube(incomingStream, outgoingStream)
-  }
-
-  public incoming: Duplex
-  public outgoing: Duplex
-
-  constructor(
-    incoming: Duplex = new PassThrough({ objectMode: true }),
-    outgoing: Duplex = new PassThrough({ objectMode: true })
-  ) {
-    super(incoming, outgoing)
-    this.incoming = incoming
-    this.outgoing = outgoing
-  }
-}
-
-/**
- * Sink component
- *
- * A component that can only have a previous component connected (no next) and
- * where the incoming and outgoing streams are connected to an external data
- * source.
- */
-export class Sink extends AbstractComponent {
-  /**
-   * Create a component that swallows incoming data (calling fn on it).  To
-   * print data, you would use fn = console.log.
-   *
-   * @param fn - The callback to use for the incoming data.
-   */
-  public static fromHandler(fn: MessageHandler) {
-    const component = new Sink(
-      StreamFactory.consumer(fn),
-      StreamFactory.producer(undefined)
-    )
-    // A sink should propagate when stream is ending.
-    component.incoming.on('finish', () => {
-      component.outgoing.push(null)
-    })
-
-    return component
-  }
-
-  public incoming: Writable
-  public outgoing: Readable
-  public next: null
-  public prev: Tube | Source | null
-
-  constructor(
-    incoming: Writable = new Writable({ objectMode: true }),
-    outgoing: Readable = new Readable({ objectMode: true })
-  ) {
-    super()
-    this.incoming = incoming
-    this.outgoing = outgoing
-    this.next = null
-    this.prev = null
-  }
-
-  public connect(): Component {
-    throw new Error('connection failed: attempting to connect after a sink')
-  }
-
-  public disconnect(): Component {
-    return this
-  }
-}
diff --git a/streams/src/components/h264depay/index.ts b/streams/src/components/h264depay/index.ts
deleted file mode 100644
index 47d29b6ad..000000000
--- a/streams/src/components/h264depay/index.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { Transform } from 'stream'
-
-import { marker, payloadType } from '../../utils/protocols/rtp'
-import { VideoMedia } from '../../utils/protocols/sdp'
-import { Tube } from '../component'
-import { Message, MessageType } from '../message'
-
-import { concat } from 'utils/bytes'
-import { H264DepayParser, NAL_TYPES } from './parser'
-
-export class H264Depay extends Tube {
-  constructor() {
-    let h264PayloadType: number
-    let idrFound = false
-    let packets: Uint8Array[] = []
-
-    const h264DepayParser = new H264DepayParser()
-
-    // Incoming
-
-    const incoming = new Transform({
-      objectMode: true,
-      transform(msg: Message, _encoding, callback) {
-        // Get correct payload types from sdp to identify video and audio
-        if (msg.type === MessageType.SDP) {
-          const h264Media = msg.sdp.media.find((media): media is VideoMedia => {
-            return (
-              media.type === 'video' &&
-              media.rtpmap !== undefined &&
-              media.rtpmap.encodingName === 'H264'
-            )
-          })
-          if (h264Media !== undefined && h264Media.rtpmap !== undefined) {
-            h264PayloadType = h264Media.rtpmap.payloadType
-          }
-          callback(undefined, msg) // Pass on the original SDP message
-        } else if (
-          msg.type === MessageType.RTP &&
-          payloadType(msg.data) === h264PayloadType
-        ) {
-          const endOfFrame = marker(msg.data)
-          const h264Message = h264DepayParser.parse(msg)
-
-          // Skip if not a full H264 frame, or when there hasn't been an I-frame yet
-          if (
-            h264Message === null ||
-            (!idrFound && h264Message.nalType !== NAL_TYPES.IDR_PICTURE)
-          ) {
-            callback()
-            return
-          }
-
-          idrFound = true
-
-          // H.264 over RTP uses the RTP marker bit to indicate a complete
-          // frame.  At this point, the packets can be used to construct a
-          // complete message.
-
-          packets.push(h264Message.data)
-          if (endOfFrame) {
-            this.push({
-              ...h264Message,
-              data: packets.length === 1 ? packets[0] : concat(packets),
-            })
-            packets = []
-          }
-          callback()
-        } else {
-          // Not a message we should handle
-          callback(undefined, msg)
-        }
-      },
-    })
-
-    // outgoing will be defaulted to a PassThrough stream
-    super(incoming)
-  }
-}
diff --git a/streams/src/components/h264depay/parser.ts b/streams/src/components/h264depay/parser.ts
deleted file mode 100644
index bf404383e..000000000
--- a/streams/src/components/h264depay/parser.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import debug from 'debug'
-
-import { concat, writeUInt32BE } from 'utils/bytes'
-import { payload, payloadType, timestamp } from '../../utils/protocols/rtp'
-import { H264Message, MessageType, RtpMessage } from '../message'
-
-export enum NAL_TYPES {
-  UNSPECIFIED = 0,
-  NON_IDR_PICTURE = 1, // P-frame
-  IDR_PICTURE = 5, // I-frame
-  SPS = 7,
-  PPS = 8,
-}
-
-/*
-First byte in payload (rtp payload header):
-      +---------------+
-      |0|1|2|3|4|5|6|7|
-      +-+-+-+-+-+-+-+-+
-      |F|NRI|  Type   |
-      +---------------+
-
-2nd byte in payload: FU header (if type in first byte is 28)
-      +---------------+
-      |0|1|2|3|4|5|6|7|
-      +-+-+-+-+-+-+-+-+
-      |S|E|R|  Type   | S = start, E = end
-      +---------------+
-*/
-
-const h264Debug = debug('msl:h264depay')
-
-export class H264DepayParser {
-  private _buffer: Uint8Array
-
-  constructor() {
-    this._buffer = new Uint8Array(0)
-  }
-
-  parse(rtp: RtpMessage): H264Message | null {
-    const rtpPayload = payload(rtp.data)
-    const type = rtpPayload[0] & 0x1f
-
-    if (type === 28) {
-      /* FU-A NALU */ const fuIndicator = rtpPayload[0]
-      const fuHeader = rtpPayload[1]
-      const startBit = !!(fuHeader >> 7)
-      const nalType = fuHeader & 0x1f
-      const nal = (fuIndicator & 0xe0) | nalType
-      const stopBit = fuHeader & 64
-      if (startBit) {
-        this._buffer = concat([
-          new Uint8Array([0, 0, 0, 0, nal]),
-          rtpPayload.subarray(2),
-        ])
-        return null
-      } else if (stopBit) {
-        /* receieved end bit */ const h264frame = concat([
-          this._buffer,
-          rtpPayload.subarray(2),
-        ])
-        writeUInt32BE(h264frame, 0, h264frame.length - 4)
-        const msg: H264Message = {
-          data: h264frame,
-          type: MessageType.H264,
-          timestamp: timestamp(rtp.data),
-          ntpTimestamp: rtp.ntpTimestamp,
-          payloadType: payloadType(rtp.data),
-          nalType,
-        }
-        this._buffer = new Uint8Array(0)
-        return msg
-      }
-      // Put the received data on the buffer and cut the header bytes
-      this._buffer = concat([this._buffer, rtpPayload.subarray(2)])
-      return null
-    } else if (
-      (type === NAL_TYPES.NON_IDR_PICTURE || type === NAL_TYPES.IDR_PICTURE) &&
-      this._buffer.length === 0
-    ) {
-      /* Single NALU */ const h264frame = concat([
-        new Uint8Array([0, 0, 0, 0]),
-        rtpPayload,
-      ])
-      writeUInt32BE(h264frame, 0, h264frame.length - 4)
-      const msg: H264Message = {
-        data: h264frame,
-        type: MessageType.H264,
-        timestamp: timestamp(rtp.data),
-        ntpTimestamp: rtp.ntpTimestamp,
-        payloadType: payloadType(rtp.data),
-        nalType: type,
-      }
-      this._buffer = new Uint8Array(0)
-      return msg
-    }
-    h264Debug(
-      `H264depayComponent can only extract types 1,5 and 28, got ${type}`
-    )
-    this._buffer = new Uint8Array(0)
-    return null
-  }
-}
diff --git a/streams/src/components/helpers/sleep.ts b/streams/src/components/helpers/sleep.ts
deleted file mode 100644
index a1aa89dbb..000000000
--- a/streams/src/components/helpers/sleep.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-/**
- * Return a promise that resolves after a specific time.
- * @param  ms Waiting time in milliseconds
- * @return Resolves after waiting time
- */
-export const sleep = async (ms: number) => {
-  return await new Promise((resolve) => {
-    setTimeout(resolve, ms)
-  })
-}
diff --git a/streams/src/components/helpers/stream-factory.ts b/streams/src/components/helpers/stream-factory.ts
deleted file mode 100644
index 3f9f329c3..000000000
--- a/streams/src/components/helpers/stream-factory.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { Readable, Transform, Writable } from 'stream'
-
-import { fromByteArray, toByteArray } from 'base64-js'
-
-export default class StreamFactory {
-  /**
-   * Creates a writable stream that sends all messages written to the stream
-   * to a callback function and then considers it written.
-   * @param fn  The callback to be invoked on the message
-   */
-  public static consumer(
-    fn: (msg: any) => void = () => {
-      /* */
-    }
-  ) {
-    return new Writable({
-      objectMode: true,
-      write(msg, _encoding, callback) {
-        fn(msg)
-        callback()
-      },
-    })
-  }
-
-  public static peeker(fn: (msg: any) => void) {
-    if (typeof fn !== 'function') {
-      throw new Error('you must supply a function')
-    }
-    return new Transform({
-      objectMode: true,
-      transform(msg, _encoding, callback) {
-        fn(msg)
-        callback(undefined, msg)
-      },
-    })
-  }
-
-  /**
-   * Creates a readable stream that sends a message for each element of an array.
-   * @param arr  The array with elements to be turned into a stream.
-   */
-  public static producer(messages?: any[]) {
-    let counter = 0
-    return new Readable({
-      objectMode: true,
-      read() {
-        if (messages !== undefined) {
-          if (counter < messages.length) {
-            this.push(messages[counter++])
-          } else {
-            // End the stream
-            this.push(null)
-          }
-        }
-      },
-    })
-  }
-
-  public static recorder(type: string, fileStream: NodeJS.WritableStream) {
-    return new Transform({
-      objectMode: true,
-      transform(msg, encoding, callback) {
-        const timestamp = Date.now()
-        // Replace binary data with base64 string
-        const message = Object.assign({}, msg, {
-          data: fromByteArray(msg.data),
-        })
-        fileStream.write(JSON.stringify({ type, timestamp, message }, null, 2))
-        fileStream.write(',\n')
-        callback(undefined, msg)
-      },
-    })
-  }
-
-  /**
-   * Yield binary messages from JSON packet array until depleted.
-   * @return {Generator} Returns a JSON packet iterator.
-   */
-  public static replayer(packets: any[]) {
-    let packetCounter = 0
-    let lastTimestamp = packets[0].timestamp
-    return new Readable({
-      objectMode: true,
-      read() {
-        const packet = packets[packetCounter++]
-        if (packet) {
-          const { type, timestamp, message } = packet
-          const delay = timestamp - lastTimestamp
-          lastTimestamp = timestamp
-          if (message) {
-            const data = message.data
-              ? toByteArray(message.data)
-              : new Uint8Array(0)
-            const msg = Object.assign({}, message, { data })
-            this.push({ type, delay, msg })
-          } else {
-            this.push({ type, delay, msg: null })
-          }
-        } else {
-          this.push(null)
-        }
-      },
-    })
-  }
-}
diff --git a/streams/src/components/http-mp4/index.ts b/streams/src/components/http-mp4/index.ts
deleted file mode 100644
index 3f6831494..000000000
--- a/streams/src/components/http-mp4/index.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-import registerDebug from 'debug'
-
-import { Readable } from 'stream'
-
-import { Source } from '../component'
-import { MessageType } from '../message'
-
-const debug = registerDebug('msl:http-mp4')
-
-export interface HttpConfig {
-  uri: string
-  options?: RequestInit
-}
-
-/**
- * HttpMp4
- *
- * Stream MP4 data over HTTP/S, and use Axis-specific
- * headers to determine MIME type and stream transformation.
- */
-export class HttpMp4Source extends Source {
-  public uri: string
-  public options?: RequestInit
-  public length?: number
-  public onHeaders?: (headers: Headers) => void
-  public onServerClose?: () => void
-
-  private _reader?: ReadableStreamDefaultReader
-  private _abortController?: AbortController
-  private _allDone: boolean
-
-  /**
-   * Create an HTTP component.
-   *
-   * The constructor sets a single readable stream from a fetch.
-   */
-  constructor(config: HttpConfig) {
-    const { uri, options } = config
-    /**
-     * Set up an incoming stream and attach it to the socket.
-     */
-    const incoming = new Readable({
-      objectMode: true,
-      read() {
-        //
-      },
-    })
-
-    // When an error is sent on the incoming stream, close the socket.
-    incoming.on('error', (e) => {
-      console.warn('closing socket due to incoming error', e)
-      this._reader?.cancel().catch((err) => console.error(err))
-    })
-
-    /**
-     * initialize the component.
-     */
-    super(incoming)
-
-    // When a read is requested, continue to pull data
-    incoming._read = () => {
-      this._pull()
-    }
-
-    this.uri = uri
-    this.options = options
-    this._allDone = false
-  }
-
-  play(): void {
-    if (this.uri === undefined) {
-      throw new Error('cannot start playing when there is no URI')
-    }
-
-    this._abortController = new AbortController()
-
-    this.length = 0
-    fetch(this.uri, {
-      credentials: 'include',
-      signal: this._abortController.signal,
-      ...this.options,
-    })
-      .then((rsp) => {
-        if (rsp.body === null) {
-          throw new Error('empty response body')
-        }
-
-        const contentType = rsp.headers.get('Content-Type')
-        this.incoming.push({
-          data: new Uint8Array(0),
-          type: MessageType.ISOM,
-          mime: contentType,
-        })
-
-        this.onHeaders?.(rsp.headers)
-
-        this._reader = rsp.body.getReader()
-        this._pull()
-      })
-      .catch((err) => {
-        console.error('http-source: fetch failed: ', err)
-      })
-  }
-
-  abort(): void {
-    this._reader?.cancel().catch((err) => {
-      console.log('http-source: cancel reader failed: ', err)
-    })
-    this._abortController?.abort()
-  }
-
-  _isClosed(): boolean {
-    return this._allDone
-  }
-
-  _close(): void {
-    this._reader = undefined
-    this._allDone = true
-    this.incoming.push(null)
-    this.onServerClose?.()
-  }
-
-  _pull(): void {
-    if (this._reader === undefined) {
-      return
-    }
-
-    this._reader
-      .read()
-      .then(({ done, value }) => {
-        if (done) {
-          if (!this._isClosed()) {
-            debug('fetch completed, total downloaded: ', this.length, ' bytes')
-            this._close()
-          }
-          return
-        }
-        if (value === undefined) {
-          throw new Error('expected value to be defined')
-        }
-        if (this.length === undefined) {
-          throw new Error('expected length to be defined')
-        }
-        this.length += value.length
-        const buffer = value
-        if (!this.incoming.push({ data: buffer, type: MessageType.ISOM })) {
-          // Something happened down stream that it is no longer processing the
-          // incoming data, and the stream buffer got full.
-          // This could be because we are downloading too much data at once,
-          // or because the downstream is frozen. The latter is most likely
-          // when dealing with a live stream (as in that case we would expect
-          // downstream to be able to handle the data).
-          debug('downstream back pressure: pausing read')
-        } else {
-          // It's ok to read more data
-          this._pull()
-        }
-      })
-      .catch((err) => {
-        debug('http-source: read failed: ', err)
-        if (!this._isClosed()) {
-          this._close()
-        }
-      })
-  }
-}
diff --git a/streams/src/components/http-source/index.ts b/streams/src/components/http-source/index.ts
deleted file mode 100644
index 9b8f1d133..000000000
--- a/streams/src/components/http-source/index.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-import registerDebug from 'debug'
-
-import { Readable } from 'stream'
-
-import { Source } from '../component'
-import { MessageType } from '../message'
-
-const debug = registerDebug('msl:http-source')
-
-export interface HttpConfig {
-  uri: string
-  options?: RequestInit
-}
-
-export class HttpSource extends Source {
-  public uri: string
-  public options?: RequestInit
-  public length?: number
-  public onHeaders?: (headers: Headers) => void
-  public onServerClose?: () => void
-
-  private _reader?: ReadableStreamDefaultReader
-  private _abortController?: AbortController
-  private _allDone: boolean
-
-  /**
-   * Create an HTTP component.
-   *
-   * The constructor sets a single readable stream from a fetch.
-   */
-  constructor(config: HttpConfig) {
-    const { uri, options } = config
-    /**
-     * Set up an incoming stream and attach it to the socket.
-     */
-    const incoming = new Readable({
-      objectMode: true,
-      read() {
-        //
-      },
-    })
-
-    // When an error is sent on the incoming stream, close the socket.
-    incoming.on('error', (e) => {
-      console.warn('closing socket due to incoming error', e)
-      this._reader && this._reader.cancel().catch((err) => console.error(err))
-    })
-
-    /**
-     * initialize the component.
-     */
-    super(incoming)
-
-    // When a read is requested, continue to pull data
-    incoming._read = () => {
-      this._pull()
-    }
-
-    this.uri = uri
-    this.options = options
-    this._allDone = false
-  }
-
-  play(): void {
-    if (this.uri === undefined) {
-      throw new Error('cannot start playing when there is no URI')
-    }
-
-    this._abortController = new AbortController()
-
-    this.length = 0
-    fetch(this.uri, {
-      credentials: 'include',
-      signal: this._abortController.signal,
-      ...this.options,
-    })
-      .then((rsp) => {
-        if (rsp.body === null) {
-          throw new Error('empty response body')
-        }
-
-        this.onHeaders && this.onHeaders(rsp.headers)
-
-        this._reader = rsp.body.getReader()
-        this._pull()
-      })
-      .catch((err) => {
-        console.error('http-source: fetch failed: ', err)
-      })
-  }
-
-  abort(): void {
-    this._reader &&
-      this._reader.cancel().catch((err) => {
-        console.log('http-source: cancel reader failed: ', err)
-      })
-    this._abortController && this._abortController.abort()
-  }
-
-  _isClosed(): boolean {
-    return this._allDone
-  }
-
-  _close(): void {
-    this._reader = undefined
-    this._allDone = true
-    this.incoming.push(null)
-    this.onServerClose?.()
-  }
-
-  _pull(): void {
-    if (this._reader === undefined) {
-      return
-    }
-
-    this._reader
-      .read()
-      .then(({ done, value }) => {
-        if (done) {
-          if (!this._isClosed()) {
-            debug('fetch completed, total downloaded: ', this.length, ' bytes')
-            this._close()
-          }
-          return
-        }
-        if (value === undefined) {
-          throw new Error('expected value to be defined')
-        }
-        if (this.length === undefined) {
-          throw new Error('expected length to be defined')
-        }
-        this.length += value.length
-        const buffer = value
-        if (!this.incoming.push({ data: buffer, type: MessageType.RAW })) {
-          // Something happened down stream that it is no longer processing the
-          // incoming data, and the stream buffer got full.
-          // This could be because we are downloading too much data at once,
-          // or because the downstream is frozen. The latter is most likely
-          // when dealing with a live stream (as in that case we would expect
-          // downstream to be able to handle the data).
-          debug('downstream back pressure: pausing read')
-        } else {
-          // It's ok to read more data
-          this._pull()
-        }
-      })
-      .catch((err) => {
-        debug('http-source: read failed: ', err)
-        if (!this._isClosed()) {
-          this._close()
-        }
-      })
-  }
-}
diff --git a/streams/src/components/index.browser.ts b/streams/src/components/index.browser.ts
deleted file mode 100644
index 9db6a3328..000000000
--- a/streams/src/components/index.browser.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-export * from './component'
-
-export * from './aacdepay'
-export * from './basicdepay'
-export * from './canvas'
-export * from './h264depay'
-export * from './http-source'
-export * from './inspector'
-export * from './jpegdepay'
-export * from './message'
-export * from './messageStreams'
-export * from './mp4capture'
-export * from './mp4muxer'
-export * from './mse'
-export * from './onvifdepay'
-export * from './rtsp-parser'
-export * from './rtsp-session'
-export * from './ws-source'
diff --git a/streams/src/components/index.node.ts b/streams/src/components/index.node.ts
deleted file mode 100644
index b38222b11..000000000
--- a/streams/src/components/index.node.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-export * from './aacdepay'
-export * from './auth'
-export * from './basicdepay'
-export * from './component'
-export * from './h264depay'
-export * from './inspector'
-export * from './jpegdepay'
-export * from './message'
-export * from './messageStreams'
-export * from './mp4capture'
-export * from './mp4muxer'
-export * from './onvifdepay'
-export * from './recorder'
-export * from './replayer'
-export * from './rtsp-parser'
-export * from './rtsp-session'
-export * from './tcp'
-export * from './ws-sink'
diff --git a/streams/src/components/index.ts b/streams/src/components/index.ts
new file mode 100644
index 000000000..7812be5b5
--- /dev/null
+++ b/streams/src/components/index.ts
@@ -0,0 +1,12 @@
+export * from './types'
+
+export * from './utils'
+
+export * from './adapter'
+export * from './canvas'
+export * from './mp4-capture'
+export * from './mp4-muxer'
+export * from './mse-sink'
+export * from './rtp'
+export * from './rtsp'
+export * from './ws-source'
diff --git a/streams/src/components/inspector/index.ts b/streams/src/components/inspector/index.ts
deleted file mode 100644
index c0bfcd370..000000000
--- a/streams/src/components/inspector/index.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { Transform } from 'stream'
-
-import { Tube } from '../component'
-import { GenericMessage, MessageType } from '../message'
-
-const generateLogger = (prefix: string, type?: MessageType) => {
-  let lastTimestamp = Date.now()
-
-  const log = (msg: GenericMessage) => {
-    const timestamp = Date.now()
-    console.log(`${prefix}: +${timestamp - lastTimestamp}ms`, msg)
-    lastTimestamp = timestamp
-  }
-
-  if (type === undefined) {
-    return log
-  }
-  return (msg: GenericMessage) => msg.type === type && log(msg)
-}
-
-/**
- * Component that logs whatever is passing through.
- */
-export class Inspector extends Tube {
-  /**
-   * Create a new inspector component.
-   * @argument {String} type  The type of message to log (default is to log all).
-   * @return {undefined}
-   */
-  constructor(type?: MessageType) {
-    const incomingLogger = generateLogger('incoming', type)
-
-    const incoming = new Transform({
-      objectMode: true,
-      transform(msg, encoding, callback) {
-        incomingLogger(msg)
-        callback(undefined, msg)
-      },
-    })
-
-    const outgoingLogger = generateLogger('outgoing', type)
-
-    const outgoing = new Transform({
-      objectMode: true,
-      transform(msg, encoding, callback) {
-        outgoingLogger(msg)
-        callback(undefined, msg)
-      },
-    })
-
-    super(incoming, outgoing)
-  }
-}
diff --git a/streams/src/components/jpegdepay/index.ts b/streams/src/components/jpegdepay/index.ts
deleted file mode 100644
index 7a45b3ca8..000000000
--- a/streams/src/components/jpegdepay/index.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { Transform } from 'stream'
-
-import { marker, payloadType, timestamp } from '../../utils/protocols/rtp'
-import { VideoMedia } from '../../utils/protocols/sdp'
-import { Tube } from '../component'
-import { Message, MessageType } from '../message'
-
-import { jpegDepayFactory } from './parser'
-
-export class JPEGDepay extends Tube {
-  constructor() {
-    let jpegPayloadType: number
-    let packets: Uint8Array[] = []
-    let jpegDepay: (packets: Uint8Array[]) => {
-      size: { width: number; height: number }
-      data: Uint8Array
-    }
-
-    const incoming = new Transform({
-      objectMode: true,
-      transform(msg: Message, encoding, callback) {
-        if (msg.type === MessageType.SDP) {
-          const jpegMedia = msg.sdp.media.find((media): media is VideoMedia => {
-            return (
-              media.type === 'video' &&
-              media.rtpmap !== undefined &&
-              media.rtpmap.encodingName === 'JPEG'
-            )
-          })
-          if (jpegMedia !== undefined && jpegMedia.rtpmap !== undefined) {
-            jpegPayloadType = Number(jpegMedia.rtpmap.payloadType)
-            const framesize = jpegMedia.framesize
-            // `framesize` is an SDP field that is present in e.g. Axis camera's
-            // and is used because the width and height that can be sent inside
-            // the JPEG header are both limited to 2040.
-            // If present, we use this width and height as the default values
-            // to be used by the jpeg depay function, otherwise we ignore this
-            // and let the JPEG header inside the RTP packets determine this.
-            if (framesize !== undefined) {
-              const [width, height] = framesize
-              // msg.framesize = { width, height }
-              jpegDepay = jpegDepayFactory(width, height)
-            } else {
-              jpegDepay = jpegDepayFactory()
-            }
-          }
-
-          callback(undefined, msg)
-        } else if (
-          msg.type === MessageType.RTP &&
-          payloadType(msg.data) === jpegPayloadType
-        ) {
-          packets.push(msg.data)
-
-          // JPEG over RTP uses the RTP marker bit to indicate end
-          // of fragmentation. At this point, the packets can be used
-          // to reconstruct a JPEG frame.
-          if (marker(msg.data) && packets.length > 0) {
-            const jpegFrame = jpegDepay(packets)
-            this.push({
-              timestamp: timestamp(msg.data),
-              ntpTimestamp: msg.ntpTimestamp,
-              payloadType: payloadType(msg.data),
-              data: jpegFrame.data,
-              framesize: jpegFrame.size,
-              type: MessageType.JPEG,
-            })
-            packets = []
-          }
-          callback()
-        } else {
-          // Not a message we should handle
-          callback(undefined, msg)
-        }
-      },
-    })
-
-    // outgoing will be defaulted to a PassThrough stream
-    super(incoming)
-  }
-}
diff --git a/streams/src/components/message.ts b/streams/src/components/message.ts
deleted file mode 100644
index dff6c3538..000000000
--- a/streams/src/components/message.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import { MediaTrack } from '../utils/protocols/isom'
-import { Rtcp } from '../utils/protocols/rtcp'
-import { Sdp } from '../utils/protocols/sdp'
-
-export interface GenericMessage {
-  readonly type: MessageType
-  readonly data: Uint8Array
-  ntpTimestamp?: number
-}
-
-export enum MessageType {
-  UNKNOWN = 0,
-  RAW,
-  RTP,
-  RTCP,
-  RTSP,
-  SDP,
-  ELEMENTARY,
-  H264,
-  ISOM,
-  XML,
-  JPEG,
-}
-
-export interface RawMessage extends GenericMessage {
-  readonly type: MessageType.RAW
-}
-
-export interface RtpMessage extends GenericMessage {
-  readonly type: MessageType.RTP
-  readonly channel: number
-}
-
-export interface RtcpMessage extends GenericMessage {
-  readonly type: MessageType.RTCP
-  readonly channel: number
-  readonly rtcp: Rtcp
-}
-
-export interface RtspMessage extends GenericMessage {
-  readonly type: MessageType.RTSP
-  readonly method?: string
-  readonly headers?: { [key: string]: string }
-  readonly uri?: string
-  readonly protocol?: string
-}
-
-export interface SdpMessage extends GenericMessage {
-  readonly type: MessageType.SDP
-  readonly sdp: Sdp
-}
-
-export interface ElementaryMessage extends GenericMessage {
-  readonly type: MessageType.ELEMENTARY
-  readonly payloadType: number
-  readonly timestamp: number
-}
-
-export interface H264Message extends GenericMessage {
-  readonly type: MessageType.H264
-  readonly payloadType: number
-  readonly timestamp: number
-  readonly nalType: number
-}
-
-export interface IsomMessage extends GenericMessage {
-  readonly type: MessageType.ISOM
-  readonly checkpointTime?: number // presentation time of last I-frame (s)
-  readonly tracks?: MediaTrack[]
-  readonly mime?: string
-}
-
-export interface XmlMessage extends GenericMessage {
-  readonly type: MessageType.XML
-  readonly timestamp: number
-  readonly payloadType: number
-}
-
-export interface JpegMessage extends GenericMessage {
-  readonly type: MessageType.JPEG
-  readonly timestamp: number
-  readonly payloadType: number
-  readonly framesize: {
-    readonly width: number
-    readonly height: number
-  }
-}
-
-export type Message =
-  | RawMessage
-  | RtpMessage
-  | RtcpMessage
-  | RtspMessage
-  | SdpMessage
-  | ElementaryMessage
-  | H264Message
-  | IsomMessage
-  | XmlMessage
-  | JpegMessage
-
-export type MessageHandler = (msg: Message) => void
diff --git a/streams/src/components/messageStreams.ts b/streams/src/components/messageStreams.ts
deleted file mode 100644
index 902db01d8..000000000
--- a/streams/src/components/messageStreams.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { Transform, TransformCallback } from 'stream'
-
-import { Message } from './message'
-
-type MessageTransform = (
-  this: Transform,
-  msg: Message,
-  encoding: string,
-  callback: TransformCallback
-) => void
-
-export const createTransform = (transform: MessageTransform) => {
-  return new Transform({
-    objectMode: true,
-    transform,
-  })
-}
diff --git a/streams/src/components/mp4capture/index.ts b/streams/src/components/mp4-capture.ts
similarity index 52%
rename from streams/src/components/mp4capture/index.ts
rename to streams/src/components/mp4-capture.ts
index 25b5dec1c..906c529eb 100644
--- a/streams/src/components/mp4capture/index.ts
+++ b/streams/src/components/mp4-capture.ts
@@ -1,40 +1,32 @@
-import debug from 'debug'
+import { logInfo } from '../log'
 
-import { Transform } from 'stream'
-
-import { Tube } from '../component'
-import { Message, MessageType } from '../message'
+import { IsomMessage } from './types'
 
 const MAX_CAPTURE_BYTES = 225000000 // 5 min at a rate of 6 Mbit/s
 
 /**
  * Component that records MP4 data.
  */
-export class Mp4Capture extends Tube {
-  private _active: boolean
+export class Mp4Capture extends TransformStream {
+  private _activeCallback?: (buffer: Uint8Array) => void
   private _capture: boolean
-  private _captureCallback: (buffer: Uint8Array) => void
   private _bufferOffset: number
   private readonly _bufferSize: number
   private _buffer: Uint8Array
 
   constructor(maxSize = MAX_CAPTURE_BYTES) {
-    const incoming = new Transform({
-      objectMode: true,
-      transform: (msg: Message, _encoding, callback) => {
-        const data: Uint8Array = msg.data
+    super({
+      transform: (msg, controller) => {
+        const type = msg.type
+        const data = msg.data
 
-        // Arrival of ISOM with tracks indicates new movie, start recording if active.
-        if (
-          this._active &&
-          msg.type === MessageType.ISOM &&
-          msg.tracks !== undefined
-        ) {
+        // Arrival of ISOM with MIME type indicates new movie, start recording if active.
+        if (this._activeCallback && msg.mimeType !== undefined) {
           this._capture = true
         }
 
         // If capture enabled, record all ISOM (MP4) boxes
-        if (this._capture && msg.type === MessageType.ISOM) {
+        if (this._capture) {
           if (this._bufferOffset < this._buffer.byteLength - data.byteLength) {
             this._buffer.set(data, this._bufferOffset)
             this._bufferOffset += data.byteLength
@@ -42,27 +34,19 @@ export class Mp4Capture extends Tube {
             this.stop()
           }
         }
-        // Always pass on all messages
-        callback(undefined, msg)
+        controller.enqueue(msg)
+      },
+      flush: () => {
+        this.stop()
       },
     })
 
-    // Stop any recording when the stream is closed.
-    incoming.on('finish', () => {
-      this.stop()
-    })
-
-    super(incoming)
-
     this._buffer = new Uint8Array(0)
     this._bufferSize = maxSize
     this._bufferOffset = 0
 
-    this._active = false
+    this._activeCallback = undefined
     this._capture = false
-    this._captureCallback = () => {
-      /** noop */
-    }
   }
 
   /**
@@ -73,15 +57,11 @@ export class Mp4Capture extends Tube {
    * @param callback  Will be called when data is captured.
    */
   start(callback: (buffer: Uint8Array) => void) {
-    if (!this._active) {
-      debug('msl:capture:start')(callback)
-
-      this._captureCallback = callback
-
+    if (!this._activeCallback) {
+      logInfo('start MP4 capture')
+      this._activeCallback = callback
       this._buffer = new Uint8Array(this._bufferSize)
       this._bufferOffset = 0
-
-      this._active = true
     }
   }
 
@@ -90,19 +70,19 @@ export class Mp4Capture extends Tube {
    * any further capturing.
    */
   stop() {
-    if (this._active) {
-      debug('msl:capture:stop')(`captured bytes: ${this._bufferOffset}`)
+    if (this._activeCallback) {
+      logInfo(`stop MP4 capture, collected ${this._bufferOffset} bytes`)
 
       try {
-        this._captureCallback(this._buffer.slice(0, this._bufferOffset))
-      } catch (e) {
-        console.error(e)
+        this._activeCallback(this._buffer.slice(0, this._bufferOffset))
+      } catch (err) {
+        console.error('capture callback failed:', err)
       }
 
       this._buffer = new Uint8Array(0)
       this._bufferOffset = 0
 
-      this._active = false
+      this._activeCallback = undefined
       this._capture = false
     }
   }
diff --git a/streams/src/components/mp4muxer/helpers/aacSettings.ts b/streams/src/components/mp4-muxer/aacSettings.ts
similarity index 89%
rename from streams/src/components/mp4muxer/helpers/aacSettings.ts
rename to streams/src/components/mp4-muxer/aacSettings.ts
index b678029f4..8cfcaf481 100644
--- a/streams/src/components/mp4muxer/helpers/aacSettings.ts
+++ b/streams/src/components/mp4-muxer/aacSettings.ts
@@ -1,4 +1,4 @@
-import { AACMedia } from '../../../utils/protocols/sdp'
+import type { AACMedia } from '../types/sdp'
 
 import { Box, Container } from './isom'
 
@@ -31,13 +31,7 @@ const CHANNEL_CONFIG_NAMES: { [key: number]: string } = {
   2: 'Stereo',
 }
 
-export interface AACEncoding {
-  coding: string
-  samplingRate: string
-  channels: string
-}
-
-const aacEncodingName = (audioConfigBytes: number): AACEncoding => {
+const aacEncodingName = (audioConfigBytes: number): string => {
   const audioObjectType = (audioConfigBytes >>> 11) & 0x001f
   const frequencyIndex = (audioConfigBytes >>> 7) & 0x000f
   const channelConfig = (audioConfigBytes >>> 3) & 0x000f
@@ -48,11 +42,7 @@ const aacEncodingName = (audioConfigBytes: number): AACEncoding => {
   const channels =
     CHANNEL_CONFIG_NAMES[channelConfig] || channelConfig.toString()
 
-  return {
-    coding: audioType,
-    samplingRate,
-    channels,
-  }
+  return `${audioType}, ${samplingRate}, ${channels}`
 }
 
 export const aacSettings = (media: AACMedia, date: number, trackId: number) => {
@@ -134,6 +124,9 @@ export const aacSettings = (media: AACMedia, date: number, trackId: number) => {
       })
     ),
 
+    id: trackId,
+    payloadType: media.rtpmap.payloadType,
+    clockrate: media.rtpmap.clockrate,
     /*
     https://wiki.multimedia.cx/index.php/Understanding_AAC
     AAC is a variable bitrate (VBR) block-based codec where each block decodes
@@ -142,8 +135,8 @@ export const aacSettings = (media: AACMedia, date: number, trackId: number) => {
     */
     defaultFrameDuration: 1024,
 
-    // MIME type
-    mime: `mp4a.40.${audioObjectType}`,
-    codec: aacEncodingName(audioConfigBytes),
+    // CODEC info used for MIME type
+    codec: `mp4a.40.${audioObjectType}`,
+    name: aacEncodingName(audioConfigBytes),
   }
 }
diff --git a/streams/src/components/mp4muxer/helpers/boxbuilder.ts b/streams/src/components/mp4-muxer/boxbuilder.ts
similarity index 63%
rename from streams/src/components/mp4muxer/helpers/boxbuilder.ts
rename to streams/src/components/mp4-muxer/boxbuilder.ts
index 5d7d34012..15aa13a5b 100644
--- a/streams/src/components/mp4muxer/helpers/boxbuilder.ts
+++ b/streams/src/components/mp4-muxer/boxbuilder.ts
@@ -1,49 +1,60 @@
-import { Sdp } from '../../../utils/protocols/sdp'
+import { MediaDescription, isAACMedia, isH264Media } from '../types/sdp'
 
 import { aacSettings } from './aacSettings'
 import { h264Settings } from './h264Settings'
 import { Box, ByteArray, Container } from './isom'
 
 interface MoofMetadata {
-  trackId: number
+  trackData: TrackData
   timestamp: number
   byteLength: number
 }
 
-const formatDefaults: {
-  [key: string]: (
-    media: any,
-    date: number,
-    trackId: number
-  ) => { mime: string; codec: any; defaultFrameDuration: number }
-} = {
-  'MPEG4-GENERIC': aacSettings,
-  H264: h264Settings,
-}
-
-interface TrackData {
-  lastTimestamp: number
+export interface TrackData {
   baseMediaDecodeTime: number
-  defaultFrameDuration: number
-  clockrate: number
   bitrate: number
-  framerate: number
+  clockrate: number
+  codec: string
   cumulativeByteLength: number
   cumulativeDuration: number
   cumulativeFrames: number
+  defaultFrameDuration: number
+  framerate: number
+  id: number
+  lastTimestamp: number
+  payloadType: number
+  name: string
 }
 
-const createTrackData = (): TrackData => {
+const createTrackData = ({
+  id,
+  clockrate,
+  codec,
+  defaultFrameDuration,
+  name,
+  payloadType,
+}: {
+  clockrate: number
+  codec: string
+  defaultFrameDuration: number
+  id: number
+  name: string
+  payloadType: number
+}): TrackData => {
   return {
-    lastTimestamp: 0,
     baseMediaDecodeTime: 0,
-    defaultFrameDuration: 0,
-    clockrate: 0,
     bitrate: 0,
-    framerate: 0,
+    clockrate,
+    codec,
     cumulativeByteLength: 0,
     cumulativeDuration: 0,
     cumulativeFrames: 0,
+    defaultFrameDuration,
+    framerate: 0,
+    id,
+    lastTimestamp: 0,
+    name,
+    payloadType,
   }
 }
 
@@ -75,26 +86,44 @@ const updateRateInfo = (
   }
 }
 
+type TrackSettings = ReturnType[number]
+export function mediaSettings(
+  mediaDescriptions: MediaDescription[],
+  date: number
+) {
+  let trackId = 0
+  return mediaDescriptions
+    .map((media) => {
+      if (isH264Media(media)) {
+        return h264Settings(media, date, ++trackId)
+      }
+      if (isAACMedia(media)) {
+        return aacSettings(media, date, ++trackId)
+      }
+    })
+    .filter((media) => media !== undefined)
+}
+
 /**
  * Create boxes for a stream initiated by an sdp object
  *
  * @class BoxBuilder
  */
 export class BoxBuilder {
-  public trackIdMap: { [key: number]: number }
   public sequenceNumber: number
-  public ntpPresentationTime: number
-  public trackData: TrackData[]
-  public videoTrackId?: number
+  /** The (approximate) real time corresponding to the start of the video media,
+   * extrapolated from the first available NTP timestamp and decode time. */
+  public videoStartTime: number
+  /** Data for each track indexed by it's payload type (number) */
+  public trackData: Record
 
   constructor() {
-    this.trackIdMap = {}
     this.sequenceNumber = 0
-    this.ntpPresentationTime = 0
-    this.trackData = []
+    this.videoStartTime = 0
+    this.trackData = {}
   }
 
-  trak(settings: any) {
+  trak(settings: TrackSettings) {
     const trak = new Container('trak')
     const mdia = new Container('mdia')
     const minf = new Container('minf')
@@ -127,14 +156,8 @@ export class BoxBuilder {
     return trak
   }
 
-  /**
-   * Creates a Moov box from the provided options.
-   * @method moov
-   * @param  sdp - The session description protocol
-   * @param  date - The creation/modification time of the movie
-   * @return Moov object
-   */
-  moov(sdp: Sdp, date: any) {
+  // Creates a Moov box from the provided options.
+  moov(tracks: ReturnType, date: any) {
     const moov = new Container('moov')
     moov.append(
       new Box('mvhd', {
@@ -150,48 +173,16 @@ export class BoxBuilder {
     // a track in the MP4 file. For each track, a 'trak' box is added to the
     // 'moov' box and a 'trex' box is added to the 'mvex' box.
 
-    this.trackIdMap = {}
     this.sequenceNumber = 0
-    this.ntpPresentationTime = 0
-
-    let trackId = 0
-    this.trackData = []
-
-    sdp.media.forEach((media) => {
-      if (media.rtpmap === undefined) {
-        return
-      }
-
-      const payloadType = media.rtpmap.payloadType
-      const encoding = media.rtpmap.encodingName
-
-      if (formatDefaults[encoding] !== undefined) {
-        // We know how to handle this encoding, add a new track for it, and
-        // register the track for this payloadType.
-        this.trackIdMap[payloadType] = ++trackId
-
-        // Mark the video track
-        if (media.type.toLowerCase() === 'video') {
-          this.videoTrackId = trackId
-        }
+    this.videoStartTime = 0
 
-        // Extract the settings from the SDP media information based on
-        // the encoding name (H264, MPEG4-GENERIC, ...).
-        const settings = formatDefaults[encoding](media, date, trackId)
-        media.mime = settings.mime // add MIME type to the SDP media
-        media.codec = settings.codec // add human readable codec string to the SDP media
+    this.trackData = {}
 
-        const trackData = createTrackData()
-        trackData.clockrate = media.rtpmap.clockrate
-        // Set default frame duration (in ticks) for later use
-        trackData.defaultFrameDuration = settings.defaultFrameDuration
-
-        this.trackData.push(trackData)
-
-        const trak = this.trak(settings)
-        moov.append(trak)
-        mvex.append(new Box('trex', { track_ID: trackId }))
-      }
+    tracks.forEach((track) => {
+      this.trackData[track.payloadType] = createTrackData(track)
+      const trak = this.trak(track)
+      moov.append(trak)
+      mvex.append(new Box('trex', { track_ID: track.id }))
     })
 
     moov.append(mvex)
@@ -210,10 +201,7 @@ export class BoxBuilder {
    * @return moof Container
    */
   moof(metadata: MoofMetadata) {
-    const { trackId, timestamp, byteLength } = metadata
-    const trackOffset = trackId - 1
-
-    const trackData = this.trackData[trackOffset]
+    const { trackData, timestamp, byteLength } = metadata
 
     // The RTP timestamps are unsigned 32 bit and will overflow
     // at some point. We can guard against the overflow by ORing with 0,
@@ -239,7 +227,7 @@ export class BoxBuilder {
     moof.append(
       new Box('mfhd', { sequence_number: this.sequenceNumber++ }),
       traf.append(
-        new Box('tfhd', { track_ID: trackId }),
+        new Box('tfhd', { track_ID: trackData.id }),
         new Box('tfdt', { baseMediaDecodeTime: trackData.baseMediaDecodeTime }),
         trun
       )
@@ -266,20 +254,14 @@ export class BoxBuilder {
     return box
   }
 
-  setPresentationTime(trackId: number, ntpTimestamp?: number) {
+  setVideoStartTime(track: TrackData, ntpTimestamp?: number) {
     // Before updating the baseMediaDecodeTime, we check if
     // there is already a base NTP time to use as a reference
     // for computing presentation times.
-    if (
-      !this.ntpPresentationTime &&
-      ntpTimestamp &&
-      trackId === this.videoTrackId
-    ) {
-      const trackOffset = trackId - 1
-      const trackData = this.trackData[trackOffset]
-      this.ntpPresentationTime =
-        ntpTimestamp -
-        1000 * (trackData.baseMediaDecodeTime / trackData.clockrate)
+    if (!this.videoStartTime && ntpTimestamp) {
+      const { baseMediaDecodeTime, clockrate } = track
+      this.videoStartTime =
+        ntpTimestamp - 1000 * (baseMediaDecodeTime / clockrate)
     }
   }
 }
diff --git a/streams/src/components/mp4muxer/helpers/bufferreader.ts b/streams/src/components/mp4-muxer/bufferreader.ts
similarity index 100%
rename from streams/src/components/mp4muxer/helpers/bufferreader.ts
rename to streams/src/components/mp4-muxer/bufferreader.ts
diff --git a/streams/src/components/mp4muxer/helpers/h264Settings.ts b/streams/src/components/mp4-muxer/h264Settings.ts
similarity index 83%
rename from streams/src/components/mp4muxer/helpers/h264Settings.ts
rename to streams/src/components/mp4-muxer/h264Settings.ts
index a90211d30..f9a0bd897 100644
--- a/streams/src/components/mp4muxer/helpers/h264Settings.ts
+++ b/streams/src/components/mp4-muxer/h264Settings.ts
@@ -1,28 +1,24 @@
 import { toByteArray } from 'base64-js'
 
-import { H264Media } from '../../../utils/protocols/sdp'
+import type { H264Media } from '../types/sdp'
 
 import { Box, Container } from './isom'
 import { SPSParser } from './spsparser'
 
 const PROFILE_NAMES: { [key: number]: string } = {
-  66: 'Baseline',
-  77: 'Main',
-  100: 'High',
+  66: 'Baseline Profile',
+  77: 'Main Profile',
+  100: 'High Profile',
 }
 
-const h264EncodingName = (profileLevelId: string) => {
-  const profileCode = parseInt(profileLevelId.substr(0, 2), 16)
-  const levelCode = parseInt(profileLevelId.substr(4, 2), 16)
+const h264EncodingName = (profileLevelId: string): string => {
+  const profileCode = parseInt(profileLevelId.slice(0, 2), 16)
+  const levelCode = parseInt(profileLevelId.slice(4, 6), 16)
 
   const profile = PROFILE_NAMES[profileCode] || profileCode.toString()
   const level = (levelCode / 10).toFixed(1)
 
-  return {
-    coding: 'H.264',
-    profile,
-    level,
-  }
+  return `H.264, ${profile}, level ${level}`
 }
 
 export const h264Settings = (
@@ -114,14 +110,18 @@ export const h264Settings = (
       duration: 0,
     },
 
+    id: trackId,
+    payloadType: media.rtpmap.payloadType,
+    clockrate: media.rtpmap.clockrate,
     // (ticks / s) / (frames / s) = ticks / frame, e.g. frame duration in ticks
     defaultFrameDuration:
       media.framerate !== undefined && media.framerate > 0
         ? Number(media.rtpmap.clockrate) / Number(media.framerate) ||
           FALLBACK_FRAME_DURATION
         : FALLBACK_FRAME_DURATION,
-    // MIME type
-    mime: `avc1.${profileLevelId}`,
-    codec: h264EncodingName(profileLevelId),
+
+    // CODEC info used for MIME type
+    codec: `avc1.${profileLevelId}`,
+    name: h264EncodingName(profileLevelId),
   }
 }
diff --git a/streams/src/components/mp4-muxer/index.ts b/streams/src/components/mp4-muxer/index.ts
new file mode 100644
index 000000000..42809f7da
--- /dev/null
+++ b/streams/src/components/mp4-muxer/index.ts
@@ -0,0 +1,133 @@
+import { logInfo } from '../../log'
+
+import {
+  ElementaryMessage,
+  H264Message,
+  IsomMessage,
+  SdpMessage,
+} from '../types'
+
+import { BoxBuilder, TrackData, mediaSettings } from './boxbuilder'
+import { Box } from './isom'
+import { mimeType } from './mime'
+
+/**
+ * Mp4Muxer converts H264/AAC stream data into MP4 boxes conforming to
+ * the ISO BMFF Byte Stream format.
+ */
+export class Mp4Muxer extends TransformStream<
+  SdpMessage | ElementaryMessage | H264Message,
+  IsomMessage
+> {
+  public onSync?: (videoStartTime: number) => void
+
+  private _boxBuilder: BoxBuilder
+
+  constructor() {
+    const boxBuilder = new BoxBuilder()
+    const onSync = (videoStartTime: number) => {
+      this.onSync && this.onSync(videoStartTime)
+    }
+
+    super({
+      transform: (msg, controller) => {
+        switch (msg.type) {
+          case 'sdp': {
+            // Arrival of SDP signals the beginning of a new movie.
+            // Set up the ftyp and moov boxes.
+
+            // Why is this here? These should be default inside the mvhd box?
+            // Timestamps are given in seconds since 1904-01-01.
+            const now = Math.floor(new Date().getTime() / 1000 + 2082852000)
+            const tracks = mediaSettings(msg.media, now)
+            tracks.forEach(({ id, name }) =>
+              logInfo(`track ${id}/${tracks.length}: ${name}`)
+            )
+
+            const ftyp = new Box('ftyp')
+            const moov = boxBuilder.moov(tracks, now)
+
+            const data = new Uint8Array(ftyp.byteLength + moov.byteLength)
+            ftyp.copy(data, 0)
+            moov.copy(data, ftyp.byteLength)
+
+            return controller.enqueue(
+              new IsomMessage({ data, mimeType: mimeType(tracks) })
+            )
+          }
+          case 'h264': {
+            const { payloadType, timestamp, ntpTimestamp } = msg
+            const trackData = boxBuilder.trackData[payloadType]
+
+            if (trackData === undefined) {
+              return controller.error(
+                `missing track data for H264 (PT ${payloadType})`
+              )
+            }
+
+            if (!boxBuilder.videoStartTime) {
+              boxBuilder.setVideoStartTime(trackData, ntpTimestamp)
+              if (boxBuilder.videoStartTime) {
+                onSync(boxBuilder.videoStartTime)
+              }
+            }
+
+            let checkpointTime: number | undefined
+            if (
+              boxBuilder.videoStartTime &&
+              msg.idrPicture &&
+              msg.ntpTimestamp !== undefined
+            ) {
+              checkpointTime =
+                (msg.ntpTimestamp - boxBuilder.videoStartTime) / 1000
+            }
+
+            const byteLength = msg.data.byteLength
+            const moof = boxBuilder.moof({ trackData, timestamp, byteLength })
+            const mdat = boxBuilder.mdat(msg.data)
+
+            const data = new Uint8Array(moof.byteLength + mdat.byteLength)
+            moof.copy(data, 0)
+            mdat.copy(data, moof.byteLength)
+
+            return controller.enqueue(
+              new IsomMessage({ data, ntpTimestamp, checkpointTime })
+            )
+          }
+          case 'elementary': {
+            const { payloadType, timestamp, ntpTimestamp } = msg
+            const trackData = boxBuilder.trackData[payloadType]
+
+            if (!trackData) {
+              return controller.error(
+                `missing track data for AAC (PT ${payloadType})`
+              )
+            }
+
+            const byteLength = msg.data.byteLength
+            const moof = boxBuilder.moof({ trackData, timestamp, byteLength })
+            const mdat = boxBuilder.mdat(msg.data)
+
+            const data = new Uint8Array(moof.byteLength + mdat.byteLength)
+            moof.copy(data, 0)
+            mdat.copy(data, moof.byteLength)
+
+            return controller.enqueue(new IsomMessage({ data, ntpTimestamp }))
+          }
+        }
+      },
+    })
+
+    this._boxBuilder = boxBuilder
+  }
+
+  public get tracks(): TrackData[] {
+    return Object.values(this._boxBuilder.trackData).filter(
+      (data) => data !== undefined
+    )
+  }
+
+  public get ntpPresentationTime() {
+    return this._boxBuilder.videoStartTime
+  }
+}
diff --git a/streams/src/components/mp4muxer/helpers/isom.ts b/streams/src/components/mp4-muxer/isom.ts
similarity index 99%
rename from streams/src/components/mp4muxer/helpers/isom.ts
rename to streams/src/components/mp4-muxer/isom.ts
index 4c8a47b23..59705530e 100644
--- a/streams/src/components/mp4muxer/helpers/isom.ts
+++ b/streams/src/components/mp4-muxer/isom.ts
@@ -5,6 +5,7 @@
 // - store(buffer, offset) -> write the value to a buffer
 // - load(buffer, offset) -> read data and store in value
 
+import { MediaTrack } from '../types/isom'
 import {
   decode,
   readUInt8,
@@ -15,8 +16,7 @@ import {
   writeUInt16BE,
   writeUInt24BE,
   writeUInt32BE,
-} from 'utils/bytes'
-import { MediaTrack } from '../../../utils/protocols/isom'
+} from '../utils/bytes'
 
 type BufferMutation = (buffer: Uint8Array, offset: number) => void
 
@@ -232,7 +232,7 @@ export class ByteArray extends BoxElement {
     buffer.set(this.value, offset)
   }
 
-  load: BufferMutation = (buffer, offset) => {
+  load: BufferMutation = (_buffer, _offset) => {
     throw new Error('not implemented')
   }
 }
@@ -1327,14 +1327,14 @@ export class Container extends Box {
               .padStart(2, 0)
             tracks.push({
               type: 'video',
-              mime: `avc1.${profile}${compat}${level}`,
+              codec: `avc1.${profile}${compat}${level}`,
             })
           } else if (boxType === 'esds') {
             const audioConfigBytes = box.element('audioConfigBytes').value
             const objectTypeIndication = (audioConfigBytes >>> 11) & 0x001f
             tracks.push({
               type: 'audio',
-              mime: `mp4a.40.${objectTypeIndication}`,
+              codec: `mp4a.40.${objectTypeIndication}`,
             })
           }
         }
diff --git a/streams/src/components/mp4-muxer/mime.ts b/streams/src/components/mp4-muxer/mime.ts
new file mode 100644
index 000000000..465e6ca64
--- /dev/null
+++ b/streams/src/components/mp4-muxer/mime.ts
@@ -0,0 +1,10 @@
+export function mimeType(codecs: { codec?: string }[]) {
+  // MIME codecs: https://tools.ietf.org/html/rfc6381
+  const mimeCodecs = codecs.map((media) => media.codec).filter((codec) => codec)
+  const codecParams =
+    mimeCodecs.length !== 0 ? mimeCodecs.join(', ') : 'avc1.640029, mp4a.40.2'
+
+  const mimeType = `video/mp4; codecs="${codecParams}"`
+
+  return mimeType
+}
diff --git a/streams/src/components/mp4muxer/helpers/spsparser.ts b/streams/src/components/mp4-muxer/spsparser.ts
similarity index 100%
rename from streams/src/components/mp4muxer/helpers/spsparser.ts
rename to streams/src/components/mp4-muxer/spsparser.ts
diff --git a/streams/src/components/mp4-parser/index.ts b/streams/src/components/mp4-parser/index.ts
index da5efea0c..a74616e57 100644
--- a/streams/src/components/mp4-parser/index.ts
+++ b/streams/src/components/mp4-parser/index.ts
@@ -1,35 +1,20 @@
-import { Transform } from 'stream'
-
-import { Tube } from '../component'
-import { Message, MessageType } from '../message'
+import { IsomMessage } from '../types/isom'
 
 import { Parser } from './parser'
 
 /**
- * A component that converts raw binary MP4 data into ISOM boxes.
- * @extends {Component}
+ * A transform stream that converts raw binary MP4 data into ISOM boxes.
  */
-export class Mp4Parser extends Tube {
-  /**
-   * Create a new RTSP parser component.
-   */
+export class Mp4Parser extends TransformStream {
   constructor() {
     const parser = new Parser()
 
-    // Incoming stream
-    const incoming = new Transform({
-      objectMode: true,
-      transform(msg: Message, _, callback) {
-        if (msg.type === MessageType.RAW) {
-          parser.parse(msg.data).forEach((message) => incoming.push(message))
-          callback()
-        } else {
-          // Not a message we should handle
-          callback(undefined, msg)
-        }
+    super({
+      transform: (chunk, controller) => {
+        parser.parse(chunk).forEach((message) => {
+          controller.enqueue(message)
+        })
       },
     })
-
-    super(incoming)
   }
 }
diff --git a/streams/src/components/mp4-parser/parser.ts b/streams/src/components/mp4-parser/parser.ts
index c4ebb4144..ff3f5a1b6 100644
--- a/streams/src/components/mp4-parser/parser.ts
+++ b/streams/src/components/mp4-parser/parser.ts
@@ -1,11 +1,13 @@
-import registerDebug from 'debug'
+import { Container } from '../mp4-muxer/isom'
+import { mimeType } from '../mp4-muxer/mime'
+import { IsomMessage } from '../types/isom'
+import { concat, decode, readUInt32BE } from '../utils/bytes'
 
-import { concat, readUInt32BE } from 'utils/bytes'
-import { BOX_HEADER_BYTES, boxType } from '../../utils/protocols/isom'
-import { IsomMessage, MessageType } from '../message'
-import { Container } from '../mp4muxer/helpers/isom'
+const BOX_HEADER_BYTES = 8
 
-const debug = registerDebug('msl:mp4-parser')
+const boxType = (buffer: Uint8Array) => {
+  return decode(buffer.subarray(4, 8)).toLowerCase()
+}
 
 // Identify boxes that conforms to an ISO BMFF byte stream:
 //  - header boxes: ftyp + moov
@@ -140,14 +142,14 @@ export class Parser {
         } else if (boxType(data) === 'moov') {
           const moov = new Container('moov')
           const tracks = moov.parse(data)
-          debug('MP4 tracks: ', tracks)
-          messages.push({
-            type: MessageType.ISOM,
-            data: concat([this._ftyp ?? new Uint8Array(0), data]),
-            tracks,
-          })
+          messages.push(
+            new IsomMessage({
+              data: concat([this._ftyp ?? new Uint8Array(0), data]),
+              mimeType: mimeType(tracks),
+            })
+          )
         } else {
-          messages.push({ type: MessageType.ISOM, data })
+          messages.push(new IsomMessage({ data }))
         }
       } else {
         done = true
diff --git a/streams/src/components/mp4muxer/helpers/utils.ts b/streams/src/components/mp4muxer/helpers/utils.ts
deleted file mode 100644
index c5e0b4d29..000000000
--- a/streams/src/components/mp4muxer/helpers/utils.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-export function b64ToUint6(nChr: number) {
-  return nChr > 64 && nChr < 91
-    ? nChr - 65
-    : nChr > 96 && nChr < 123
-      ? nChr - 71
-      : nChr > 47 && nChr < 58
-        ? nChr + 4
-        : nChr === 43
-          ? 62
-          : nChr === 47
-            ? 63
-            : 0
-}
-
-// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Base64_encoding_and_decoding
-export function base64DecToArr(sBase64: string, nBlocksSize: number) {
-  const sB64Enc = sBase64.replace(/[^A-Za-z0-9+/]/g, '')
-  const nInLen = sB64Enc.length
-  const nOutLen = nBlocksSize
-    ? Math.ceil(((nInLen * 3 + 1) >> 2) / nBlocksSize) * nBlocksSize
-    : (nInLen * 3 + 1) >> 2
-  const taBytes = new Uint8Array(nOutLen)
-
-  let nMod3
-  let nMod4
-  let nUint24 = 0
-  let nOutIdx = 0
-  for (let nInIdx = 0; nInIdx < nInLen; nInIdx++) {
-    nMod4 = nInIdx & 3
-    nUint24 |= b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << (18 - 6 * nMod4)
-    if (nMod4 === 3 || nInLen - nInIdx === 1) {
-      for (nMod3 = 0; nMod3 < 3 && nOutIdx < nOutLen; nMod3++, nOutIdx++) {
-        taBytes[nOutIdx] = (nUint24 >>> ((16 >>> nMod3) & 24)) & 255
-      }
-      nUint24 = 0
-    }
-  }
-
-  return taBytes
-}
diff --git a/streams/src/components/mp4muxer/index.ts b/streams/src/components/mp4muxer/index.ts
deleted file mode 100644
index a608be458..000000000
--- a/streams/src/components/mp4muxer/index.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-import debug from 'debug'
-
-import { Transform } from 'stream'
-
-import { Tube } from '../component'
-import { NAL_TYPES } from '../h264depay/parser'
-import { Message, MessageType } from '../message'
-
-import { BoxBuilder } from './helpers/boxbuilder'
-import { Box } from './helpers/isom'
-
-/**
- * Component that converts elementary stream data into MP4 boxes honouring
- * the ISO BMFF Byte Stream (Some extra restrictions are involved).
- */
-export class Mp4Muxer extends Tube {
-  public boxBuilder: BoxBuilder
-  public onSync?: (ntpPresentationTime: number) => void
-  /**
-   * Create a new mp4muxer component.
-   * @return {undefined}
-   */
-  constructor() {
-    const boxBuilder = new BoxBuilder()
-    const onSync = (ntpPresentationTime: number) => {
-      this.onSync && this.onSync(ntpPresentationTime)
-    }
-    const incoming = new Transform({
-      objectMode: true,
-      transform(msg: Message, encoding, callback) {
-        if (msg.type === MessageType.SDP) {
-          /**
-           * Arrival of SDP signals the beginning of a new movie.
-           * Set up the ftyp and moov boxes.
-           */
-
-          // Why is this here? These should be default inside the mvhd box?
-          const now = Math.floor(new Date().getTime() / 1000 + 2082852000)
-          const ftyp = new Box('ftyp')
-          const moov = boxBuilder.moov(msg.sdp, now)
-
-          const data = new Uint8Array(ftyp.byteLength + moov.byteLength)
-          ftyp.copy(data, 0)
-          moov.copy(data, ftyp.byteLength)
-
-          debug('msl:mp4:isom')(`ftyp: ${ftyp.format()}`)
-          debug('msl:mp4:isom')(`moov: ${moov.format()}`)
-
-          // Set up a list of tracks that contain info about
-          // the type of media, encoding, and codec are present.
-          const tracks = msg.sdp.media.map((media) => {
-            return {
-              type: media.type,
-              encoding: media.rtpmap && media.rtpmap.encodingName,
-              mime: media.mime,
-              codec: media.codec,
-            }
-          })
-
-          this.push({ type: MessageType.ISOM, data, tracks, ftyp, moov })
-        } else if (
-          msg.type === MessageType.ELEMENTARY ||
-          msg.type === MessageType.H264
-        ) {
-          /**
-           * Otherwise we are getting some elementary stream data.
-           * Set up the moof and mdat boxes.
-           */
-
-          const { payloadType, timestamp, ntpTimestamp } = msg
-          const trackId = boxBuilder.trackIdMap[payloadType]
-
-          if (trackId) {
-            if (!boxBuilder.ntpPresentationTime) {
-              boxBuilder.setPresentationTime(trackId, ntpTimestamp)
-              if (boxBuilder.ntpPresentationTime) {
-                onSync(boxBuilder.ntpPresentationTime)
-              }
-            }
-
-            let checkpointTime: number | undefined
-            const idrPicture =
-              msg.type === MessageType.H264
-                ? msg.nalType === NAL_TYPES.IDR_PICTURE
-                : undefined
-            if (
-              boxBuilder.ntpPresentationTime &&
-              idrPicture &&
-              msg.ntpTimestamp !== undefined
-            ) {
-              checkpointTime =
-                (msg.ntpTimestamp - boxBuilder.ntpPresentationTime) / 1000
-            }
-
-            const byteLength = msg.data.byteLength
-            const moof = boxBuilder.moof({ trackId, timestamp, byteLength })
-            const mdat = boxBuilder.mdat(msg.data)
-
-            const data = new Uint8Array(moof.byteLength + mdat.byteLength)
-            moof.copy(data, 0)
-            mdat.copy(data, moof.byteLength)
-
-            this.push({
-              type: MessageType.ISOM,
-              data,
-              moof,
-              mdat,
-              ntpTimestamp,
-              checkpointTime,
-            })
-          }
-        } else {
-          // No message type we recognize, pass it on.
-          this.push(msg)
-        }
-        callback()
-      },
-    })
-
-    super(incoming)
-    this.boxBuilder = boxBuilder
-  }
-
-  get bitrate() {
-    return (
-      this.boxBuilder.trackData &&
-      this.boxBuilder.trackData.map((data) => data.bitrate)
-    )
-  }
-
-  get framerate() {
-    return (
-      this.boxBuilder.trackData &&
-      this.boxBuilder.trackData.map((data) => data.framerate)
-    )
-  }
-
-  get ntpPresentationTime() {
-    return this.boxBuilder.ntpPresentationTime
-  }
-}
diff --git a/streams/src/components/mse-sink.ts b/streams/src/components/mse-sink.ts
new file mode 100644
index 000000000..6c6fdc532
--- /dev/null
+++ b/streams/src/components/mse-sink.ts
@@ -0,0 +1,149 @@
+import { logDebug, logError, logInfo } from '../log'
+
+import { IsomMessage } from './types'
+
+/**
+ * Media component.
+ *
+ * Provides a writeable stream for ISOM messages that are passed to the
+ * user's video element through a source buffer, and frees the source
+ * buffer at regular intervals (keeping at least 1 I-frame in the buffer).
+ *
+ * The MIME type of the media stream can be provided when instantiating
+ * the sink, or it can be passed in-band by adding track information to
+ * the first ISOM message (e.g. by the MP4 muxer).
+ */
+export class MseSink {
+  public readonly mediaSource: MediaSource = new MediaSource()
+  public writable: WritableStream
+
+  private _lastCheckpointTime: number
+  private _futureSourceBuffer?: Promise
+  private _videoEl: HTMLVideoElement
+
+  constructor(videoEl: HTMLVideoElement, mimeType?: string) {
+    this._lastCheckpointTime = 0
+    this._videoEl = videoEl
+
+    if (mimeType !== undefined) {
+      this._futureSourceBuffer = newSourceBuffer(
+        this.mediaSource,
+        this._videoEl,
+        mimeType
+      )
+    }
+
+    this.writable = new WritableStream({
+      write: async (msg: IsomMessage, controller) => {
+        if (msg.mimeType !== undefined) {
+          this._futureSourceBuffer = newSourceBuffer(
+            this.mediaSource,
+            this._videoEl,
+            msg.mimeType
+          )
+        }
+
+        if (!this._futureSourceBuffer) {
+          controller.error(
+            'missing SourceBuffer, either initialize with MIME type or use MP4 muxer'
+          )
+          return
+        }
+
+        const sourceBuffer = await this._futureSourceBuffer
+
+        const checkpoint = this.updateCheckpointTime(msg.checkpointTime)
+        if (checkpoint !== undefined) {
+          await freeBuffer(sourceBuffer, checkpoint)
+        }
+
+        await new Promise((resolve, reject) => {
+          try {
+            sourceBuffer.addEventListener('updateend', resolve, { once: true })
+            sourceBuffer.appendBuffer(msg.data)
+          } catch (err) {
+            reject(err)
+          }
+        })
+      },
+      close: async () => {
+        logDebug('media stream complete')
+        this._endOfStream()
+      },
+      abort: async (reason) => {
+        logError('media stream aborted:', reason)
+        this._endOfStream()
+      },
+    })
+  }
+
+  updateCheckpointTime(checkpointTime?: number): number | undefined {
+    this._lastCheckpointTime = checkpointTime ?? this._lastCheckpointTime
+    if (
+      this._lastCheckpointTime === 0 ||
+      Math.floor(this._lastCheckpointTime) % 10 !== 0
+    ) {
+      return
+    }
+    return Math.floor(
+      Math.min(this._videoEl.currentTime, this._lastCheckpointTime) - 10
+    )
+  }
+
+  async _endOfStream() {
+    const endOfStream = () => {
+      this.mediaSource.readyState === 'open' && this.mediaSource.endOfStream()
+    }
+    const sourceBuffer = await this._futureSourceBuffer
+    if (sourceBuffer && sourceBuffer.updating) {
+      sourceBuffer.addEventListener('updateend', endOfStream, { once: true })
+    } else {
+      endOfStream()
+    }
+  }
+}
+
+async function freeBuffer(sourceBuffer: SourceBuffer, end: number) {
+  if (sourceBuffer.buffered.length === 0) {
+    return
+  }
+  const index = sourceBuffer.buffered.length - 1
+  const start = sourceBuffer.buffered.start(index)
+  if (end > start) {
+    return new Promise((resolve, reject) => {
+      try {
+        sourceBuffer.addEventListener('updateend', resolve, { once: true })
+        sourceBuffer.remove(start, end)
+      } catch (err) {
+        reject(err)
+      }
+    })
+  }
+}
+
+async function newSourceBuffer(
+  mse: MediaSource,
+  el: HTMLVideoElement,
+  mimeType: string
+): Promise {
+  // Start a new mediaSource and prepare it with a sourceBuffer.
+  await new Promise((resolve, reject) => {
+    try {
+      mse.addEventListener('sourceopen', resolve, { once: true })
+      el.src = window.URL.createObjectURL(mse)
+    } catch (err) {
+      reject(err)
+    }
+  })
+
+  // // revoke the object URL to avoid a memory leak
+  window.URL.revokeObjectURL(el.src)
+
+  if (!MediaSource.isTypeSupported(mimeType)) {
+    throw new Error(`unsupported media type: ${mimeType}`)
+  } else {
+    logInfo('adding SourceBuffer with MIME type:', mimeType)
+  }
+
+  return mse.addSourceBuffer(mimeType)
+}
diff --git a/streams/src/components/mse/index.ts b/streams/src/components/mse/index.ts
deleted file mode 100644
index cfabb9fd4..000000000
--- a/streams/src/components/mse/index.ts
+++ /dev/null
@@ -1,214 +0,0 @@
-import registerDebug from 'debug'
-
-import { Readable, Writable } from 'stream'
-
-import { MediaTrack } from '../../utils/protocols/isom'
-import { isRtcpBye } from '../../utils/protocols/rtcp'
-import { Sink } from '../component'
-import { Message, MessageType } from '../message'
-
-const TRIGGER_THRESHOLD = 100
-
-const debug = registerDebug('msl:mse')
-
-export class MseSink extends Sink {
-  private readonly _videoEl: HTMLVideoElement
-  private _done?: () => void
-  private _lastCheckpointTime: number
-
-  public onSourceOpen?: (mse: MediaSource, tracks: MediaTrack[]) => void
-
-  /**
-   * Create a Media component.
-   *
-   * The constructor sets up two streams and connects them to the MediaSource.
-   *
-   * @param el - A video element to connect the media source to
-   */
-  constructor(el: HTMLVideoElement) {
-    if (el === undefined) {
-      throw new Error('video element argument missing')
-    }
-
-    let mse: MediaSource | undefined
-    let sourceBuffer: SourceBuffer | undefined
-
-    /**
-     * Set up an incoming stream and attach it to the sourceBuffer.
-     */
-    const incoming = new Writable({
-      objectMode: true,
-      write: (msg: Message, _, callback) => {
-        if (msg.type === MessageType.ISOM) {
-          // ISO BMFF Byte Stream data to be added to the source buffer
-          this._done = callback
-
-          if (msg.tracks !== undefined || msg.mime !== undefined) {
-            const tracks = msg.tracks ?? []
-            // MIME codecs: https://tools.ietf.org/html/rfc6381
-            const mimeCodecs = tracks
-              .map((track) => track.mime)
-              .filter((mime) => mime)
-            const codecs =
-              mimeCodecs.length !== 0
-                ? mimeCodecs.join(', ')
-                : 'avc1.640029, mp4a.40.2'
-
-            // Take MIME type directly from the message, or constructed
-            // from the tracks (with a default fallback to basic H.264).
-            const mimeType = msg.mime ?? `video/mp4; codecs="${codecs}"`
-
-            if (!MediaSource.isTypeSupported(mimeType)) {
-              incoming.emit('error', `unsupported media type: ${mimeType}`)
-              return
-            }
-
-            // Start a new movie (new SDP info available)
-            this._lastCheckpointTime = 0
-
-            // Start a new mediaSource and prepare it with a sourceBuffer.
-            // When ready, this component's .onSourceOpen callback will be called
-            // with the mediaSource, and a list of valid/ignored media.
-            mse = new MediaSource()
-            el.src = window.URL.createObjectURL(mse)
-            const handler = () => {
-              if (mse === undefined) {
-                incoming.emit('error', 'no MediaSource instance')
-                return
-              }
-              // revoke the object URL to avoid a memory leak
-              window.URL.revokeObjectURL(el.src)
-
-              mse.removeEventListener('sourceopen', handler)
-              this.onSourceOpen && this.onSourceOpen(mse, tracks)
-
-              sourceBuffer = this.addSourceBuffer(el, mse, mimeType)
-              sourceBuffer.onerror = (e) => {
-                console.error('error on SourceBuffer: ', e)
-                incoming.emit('error')
-              }
-              try {
-                sourceBuffer.appendBuffer(msg.data)
-              } catch (err) {
-                debug('failed to append to SourceBuffer: ', err, msg)
-              }
-            }
-            mse.addEventListener('sourceopen', handler)
-          } else {
-            // Continue current movie
-            this._lastCheckpointTime =
-              msg.checkpointTime !== undefined
-                ? msg.checkpointTime
-                : this._lastCheckpointTime
-
-            try {
-              sourceBuffer?.appendBuffer(msg.data)
-            } catch (e) {
-              debug('failed to append to SourceBuffer: ', e, msg)
-            }
-          }
-        } else if (msg.type === MessageType.RTCP) {
-          if (isRtcpBye(msg.rtcp)) {
-            mse?.readyState === 'open' && mse.endOfStream()
-          }
-          callback()
-        } else {
-          callback()
-        }
-      },
-    })
-
-    incoming.on('finish', () => {
-      console.warn('incoming stream finished: end stream')
-      mse && mse.readyState === 'open' && mse.endOfStream()
-    })
-
-    // When an error is sent on the incoming stream, close it.
-    incoming.on('error', (msg: string) => {
-      console.error('error on incoming stream: ', msg)
-      if (sourceBuffer && sourceBuffer.updating) {
-        sourceBuffer.addEventListener('updateend', () => {
-          mse?.readyState === 'open' && mse.endOfStream()
-        })
-      } else {
-        mse?.readyState === 'open' && mse.endOfStream()
-      }
-    })
-
-    /**
-     * Set up outgoing stream.
-     */
-    const outgoing = new Readable({
-      objectMode: true,
-      read() {
-        //
-      },
-    })
-
-    // When an error is sent on the outgoing stream, whine about it.
-    outgoing.on('error', () => {
-      console.warn('outgoing stream broke somewhere')
-    })
-
-    /**
-     * initialize the component.
-     */
-    super(incoming, outgoing)
-
-    this._videoEl = el
-    this._lastCheckpointTime = 0
-  }
-
-  /**
-   * Add a new sourceBuffer to the mediaSource and remove old ones.
-   * @param el - The media element holding the media source.
-   * @param mse - The media source the buffer should be attached to.
-   * @param mimeType - MIME type and codecs, e.g.: 'video/mp4; codecs="avc1.4D0029, mp4a.40.2"'
-   */
-  addSourceBuffer(
-    el: HTMLVideoElement,
-    mse: MediaSource,
-    mimeType: string
-  ): SourceBuffer {
-    const sourceBuffer = mse.addSourceBuffer(mimeType)
-
-    let trigger = 0
-    const onUpdateEndHandler = () => {
-      ++trigger
-
-      if (trigger > TRIGGER_THRESHOLD && sourceBuffer.buffered.length) {
-        trigger = 0
-
-        const index = sourceBuffer.buffered.length - 1
-        const start = sourceBuffer.buffered.start(index)
-        const end = Math.min(el.currentTime, this._lastCheckpointTime) - 10
-        try {
-          // remove all material up to 10 seconds before current time
-          if (end > start) {
-            sourceBuffer.remove(start, end)
-
-            return // this._done() will be called on the next updateend event!
-          }
-        } catch (e) {
-          console.warn(e)
-        }
-      }
-      this._done && this._done()
-    }
-    sourceBuffer.addEventListener('updateend', onUpdateEndHandler)
-
-    return sourceBuffer
-  }
-
-  get currentTime(): number {
-    return this._videoEl.currentTime
-  }
-
-  async play(): Promise {
-    return await this._videoEl.play()
-  }
-
-  pause(): void {
-    this._videoEl.pause()
-  }
-}
diff --git a/streams/src/components/onvifdepay/index.ts b/streams/src/components/onvifdepay/index.ts
deleted file mode 100644
index 8ce47e68b..000000000
--- a/streams/src/components/onvifdepay/index.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { Transform } from 'stream'
-
-import { concat } from 'utils/bytes'
-import {
-  marker,
-  payload,
-  payloadType,
-  timestamp,
-} from '../../utils/protocols/rtp'
-import { Tube } from '../component'
-import { Message, MessageType, XmlMessage } from '../message'
-
-export class ONVIFDepay extends Tube {
-  constructor() {
-    let XMLPayloadType: number
-    let packets: Uint8Array[] = []
-
-    const incoming = new Transform({
-      objectMode: true,
-      transform(msg: Message, encoding, callback) {
-        if (msg.type === MessageType.SDP) {
-          let validMedia
-          for (const media of msg.sdp.media) {
-            if (
-              media.type === 'application' &&
-              media.rtpmap &&
-              media.rtpmap.encodingName === 'VND.ONVIF.METADATA'
-            ) {
-              validMedia = media
-            }
-          }
-          if (validMedia && validMedia.rtpmap) {
-            XMLPayloadType = Number(validMedia.rtpmap.payloadType)
-          }
-          callback(undefined, msg)
-        } else if (
-          msg.type === MessageType.RTP &&
-          payloadType(msg.data) === XMLPayloadType
-        ) {
-          // Add payload to packet stack
-          packets.push(payload(msg.data))
-
-          // XML over RTP uses the RTP marker bit to indicate end
-          // of fragmentation. At this point, the packets can be used
-          // to reconstruct an XML packet.
-          if (marker(msg.data) && packets.length > 0) {
-            const xmlMsg: XmlMessage = {
-              timestamp: timestamp(msg.data),
-              ntpTimestamp: msg.ntpTimestamp,
-              payloadType: payloadType(msg.data),
-              data: concat(packets),
-              type: MessageType.XML,
-            }
-            callback(undefined, xmlMsg)
-            packets = []
-            return
-          }
-          callback()
-        } else {
-          // Not a message we should handle
-          callback(undefined, msg)
-        }
-      },
-    })
-
-    // outgoing will be defaulted to a PassThrough stream
-    super(incoming)
-  }
-}
diff --git a/streams/src/components/recorder/index.ts b/streams/src/components/recorder/index.ts
deleted file mode 100644
index 3eb29e83d..000000000
--- a/streams/src/components/recorder/index.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { createWriteStream } from 'fs'
-import { join } from 'path'
-
-import { Tube } from '../component'
-import StreamFactory from '../helpers/stream-factory'
-
-/**
- * Component that writes passing incoming/outgoing streams
- * interleaved to a filestream. The resulting stream (file) stores
- * messages as a JSON array, where each element has a type, timestamp,
- * and the original message (that went through the stream).
- */
-export class Recorder extends Tube {
-  /**
-   * Create a new recorder component that will record to a writable stream.
-   * @param fileStream - The stream to save the messages to.
-   */
-  constructor(fileStream: NodeJS.WritableStream) {
-    const incoming = StreamFactory.recorder('incoming', fileStream)
-    const outgoing = StreamFactory.recorder('outgoing', fileStream)
-
-    const interleaved = { incoming, outgoing }
-
-    const streamsFinished = []
-    for (const [key, value] of Object.entries(interleaved)) {
-      streamsFinished.push(
-        new Promise((resolve) =>
-          value.on('finish', () => {
-            const timestamp = Date.now()
-            const message = null
-            const type = key
-            fileStream.write(
-              JSON.stringify({ type, timestamp, message }, null, 2)
-            )
-            fileStream.write(',\n')
-            resolve()
-          })
-        )
-      )
-    }
-
-    // start of file: begin JSON array
-    fileStream.write('[\n')
-
-    // end of file: close JSON array
-    Promise.all(streamsFinished)
-      .then(() => {
-        fileStream.write(JSON.stringify(null))
-        fileStream.write('\n]\n')
-      })
-      .catch(() => {
-        /** ignore */
-      })
-
-    super(incoming, outgoing)
-  }
-
-  /**
-   * Create a new recorder component that will record to a file.
-   * @param filename - The name of the file (relative to cwd)
-   */
-  static toFile(filename = 'data.json') {
-    const cwd = process.cwd()
-    const fileStream = createWriteStream(join(cwd, filename))
-
-    return new Recorder(fileStream)
-  }
-}
diff --git a/streams/src/components/replayer/index.ts b/streams/src/components/replayer/index.ts
deleted file mode 100644
index 2764fcc8f..000000000
--- a/streams/src/components/replayer/index.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { readFileSync } from 'node:fs'
-import { join } from 'node:path'
-
-import { Readable, Writable } from 'stream'
-
-import { Source } from '../component'
-import { sleep } from '../helpers/sleep'
-import StreamFactory from '../helpers/stream-factory'
-
-export class Replayer extends Source {
-  /**
-   * Create a new replay component that will play provided data.
-   * The packets need to conform to the format:
-   * {
-   *   type: 'incoming'/'outgoing',
-   *   delay: Number,
-   *   msg: Object (original message)
-   * }
-   * @param packetStream - The JSON data to replay.
-   */
-  constructor(packetStream: Readable) {
-    let finished = false
-
-    const incoming = new Readable({
-      objectMode: true,
-      read() {
-        //
-      },
-    })
-
-    /**
-     * Emit incoming items in the queue until an outgoing item is found.
-     */
-    const start = async () => {
-      let packet = packetStream.read()
-
-      while (packet && packet.type === 'incoming') {
-        await sleep(packet.delay)
-        incoming.push(packet.msg)
-        packet = packetStream.read()
-      }
-      if (finished) {
-        incoming.push(null)
-      }
-    }
-
-    const outgoing = new Writable({
-      objectMode: true,
-      write(msg, encoding, callback) {
-        start().catch(() => {
-          /** ignore */
-        }) // resume streaming
-        callback()
-      },
-    })
-
-    outgoing.on('finish', () => {
-      finished = true
-    })
-
-    outgoing.on('pipe', async () => await start())
-
-    super(incoming, outgoing)
-  }
-
-  /**
-   * Create a new replay component that will play from a file.
-   * @param filename - The name of the file (relative to cwd)
-   */
-  static fromFile(filename = 'data.json') {
-    const cwd = process.cwd()
-    const data = readFileSync(join(cwd, filename))
-    const packets = JSON.parse(data.toString())
-    const packetStream = StreamFactory.replayer(packets)
-
-    return new Replayer(packetStream)
-  }
-}
diff --git a/streams/src/components/rtp/aac-depay.ts b/streams/src/components/rtp/aac-depay.ts
new file mode 100644
index 000000000..4ec0639dc
--- /dev/null
+++ b/streams/src/components/rtp/aac-depay.ts
@@ -0,0 +1,130 @@
+import { ElementaryMessage } from '../types/aac'
+import { RtpMessage } from '../types/rtp'
+import { MediaDescription, isAACMedia } from '../types/sdp'
+
+import { readUInt16BE } from '../utils/bytes'
+
+/*
+media: [{ type: 'video',
+   port: '0',
+   proto: 'RTP/AVP',
+   fmt: '96',
+   rtpmap: '96 H264/90000',
+   fmtp: {
+      format: '96',
+      parameters: {
+        'packetization-mode': '1',
+        'profile-level-id': '4d0029',
+        'sprop-parameter-sets': 'Z00AKeKQDwBE/LgLcBAQGkHiRFQ=,aO48gA==',
+      },
+    },
+   control: 'rtsp://hostname/axis-media/media.amp/stream=0?audio=1&video=1',
+   framerate: '25.000000',
+   transform: [[1, 0, 0], [0, 0.75, 0], [0, 0, 1]] },
+   { type: 'audio',
+     port: '0',
+     proto: 'RTP/AVP',
+     fmt: '97',
+     fmtp: {
+       parameters: {
+         bitrate: '32000',
+         config: '1408',
+         indexdeltalength: '3',
+         indexlength: '3',
+         mode: 'AAC-hbr',
+         'profile-level-id': '2',
+         sizelength: '13',
+         streamtype: '5'
+       },
+       format: '97'
+     },
+     rtpmap: '97 MPEG4-GENERIC/16000/1',
+     control: 'rtsp://hostname/axis-media/media.amp/stream=1?audio=1&video=1' }]
+*/
+
+/*
+From RFC 3640 https://tools.ietf.org/html/rfc3640
+  2.11.  Global Structure of Payload Format
+
+     The RTP payload following the RTP header, contains three octet-
+     aligned data sections, of which the first two MAY be empty, see
+     Figure 1.
+
+           +---------+-----------+-----------+---------------+
+           | RTP     | AU Header | Auxiliary | Access Unit   |
+           | Header  | Section   | Section   | Data Section  |
+           +---------+-----------+-----------+---------------+
+
+                     <----------RTP Packet Payload----------->
+
+              Figure 1: Data sections within an RTP packet
+Note that auxilary section is empty for AAC-hbr
+
+  3.2.1.  The AU Header Section
+
+   When present, the AU Header Section consists of the AU-headers-length
+   field, followed by a number of AU-headers, see Figure 2.
+
+      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+- .. -+-+-+-+-+-+-+-+-+-+
+      |AU-headers-length|AU-header|AU-header|      |AU-header|padding|
+      |                 |   (1)   |   (2)   |      |   (n)   | bits  |
+      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+- .. -+-+-+-+-+-+-+-+-+-+
+
+                   Figure 2: The AU Header Section
+*/
+
+export class AACDepay {
+  public payloadType?: number
+
+  private _hasHeader = false
+
+  constructor(media: MediaDescription[]) {
+    const aacMedia = media.find(isAACMedia)
+
+    if (aacMedia?.rtpmap !== undefined) {
+      const parameters = aacMedia.fmtp.parameters
+      // Required
+      const sizeLength = Number(parameters.sizelength) || 0
+      const indexLength = Number(parameters.indexlength) || 0
+      const indexDeltaLength = Number(parameters.indexdeltalength) || 0
+      // Optionals
+      const CTSDeltaLength = Number(parameters.ctsdeltalength) || 0
+      const DTSDeltaLength = Number(parameters.dtsdeltalength) || 0
+      const RandomAccessIndication =
+        Number(parameters.randomaccessindication) || 0
+      const StreamStateIndication =
+        Number(parameters.streamstateindication) || 0
+      const AuxiliaryDataSizeLength =
+        Number(parameters.auxiliarydatasizelength) || 0
+
+      this._hasHeader =
+        sizeLength +
+          Math.max(indexLength, indexDeltaLength) +
+          CTSDeltaLength +
+          DTSDeltaLength +
+          RandomAccessIndication +
+          StreamStateIndication +
+          AuxiliaryDataSizeLength >
+        0
+    }
+
+    this.payloadType = aacMedia?.rtpmap?.payloadType
+  }
+
+  parse(rtp: RtpMessage): ElementaryMessage {
+    const payload = rtp.data
+
+    let headerLength = 0
+    if (this._hasHeader) {
+      const auHeaderLengthInBits = readUInt16BE(payload, 0)
+      headerLength = 2 + (auHeaderLengthInBits + (auHeaderLengthInBits % 8)) / 8 // Add padding
+    }
+
+    return new ElementaryMessage({
+      data: new Uint8Array(payload.subarray(headerLength)),
+      payloadType: rtp.payloadType,
+      timestamp: rtp.timestamp,
+      ntpTimestamp: rtp.ntpTimestamp,
+    })
+  }
+}
diff --git a/streams/src/components/rtp/depay.ts b/streams/src/components/rtp/depay.ts
new file mode 100644
index 000000000..2b76a0008
--- /dev/null
+++ b/streams/src/components/rtp/depay.ts
@@ -0,0 +1,78 @@
+import {
+  ElementaryMessage,
+  H264Message,
+  JpegMessage,
+  RtpMessage,
+  SdpMessage,
+  XmlMessage,
+} from '../types'
+
+import { AACDepay } from './aac-depay'
+import { H264Depay } from './h264-depay'
+import { JPEGDepay } from './jpeg-depay'
+import { ONVIFDepay } from './onvif-depay'
+
+type PayloadMessage = H264Message | ElementaryMessage | XmlMessage | JpegMessage
+type ParserMap = Partial<
+  Record PayloadMessage | undefined>
+>
+
+export class RtpDepay extends TransformStream<
+  SdpMessage | RtpMessage,
+  SdpMessage | PayloadMessage
+> {
+  private _peeker?: {
+    types: PayloadMessage['type'][]
+    cb: (msg: PayloadMessage) => void
+  }
+
+  constructor() {
+    const payloadTypeParser: ParserMap = {}
+
+    super({
+      transform: (msg, controller) => {
+        switch (msg.type) {
+          case 'sdp': {
+            const media = msg.media
+            for (const Depay of [H264Depay, AACDepay, JPEGDepay, ONVIFDepay]) {
+              const depay = new Depay(media)
+              if (depay.payloadType) {
+                payloadTypeParser[depay.payloadType] = (msg: RtpMessage) =>
+                  depay.parse(msg)
+              }
+            }
+            return controller.enqueue(msg)
+          }
+          case 'rtp': {
+            const parse = payloadTypeParser[msg.payloadType]
+            if (!parse) {
+              return controller.error(
+                `no parser for payload type ${msg.payloadType}, expected one of ${Object.keys(payloadTypeParser)}`
+              )
+            }
+
+            const payloadMessage = parse(msg)
+            if (!payloadMessage) {
+              return
+            }
+
+            if (
+              this._peeker &&
+              this._peeker.types.includes(payloadMessage.type)
+            ) {
+              this._peeker.cb(payloadMessage)
+            }
+
+            return controller.enqueue(payloadMessage)
+          }
+        }
+      },
+    })
+  }
+
+  /** Register a function that will peek at payload messages
+   * for the given payload types. */
+  peek(types: PayloadMessage['type'][], peeker: (msg: PayloadMessage) => void) {
+    this._peeker = { types, cb: peeker }
+  }
+}
diff --git a/streams/src/components/rtp/h264-depay.ts b/streams/src/components/rtp/h264-depay.ts
new file mode 100644
index 000000000..58ae3d6bb
--- /dev/null
+++ b/streams/src/components/rtp/h264-depay.ts
@@ -0,0 +1,142 @@
+import { logWarn } from '../../log'
+
+import { concat, writeUInt32BE } from '../utils/bytes'
+
+import {
+  H264Message,
+  MediaDescription,
+  RtpMessage,
+  isH264Media,
+} from '../types'
+
+export enum NAL_TYPES {
+  UNSPECIFIED = 0,
+  NON_IDR_PICTURE = 1, // P-frame
+  IDR_PICTURE = 5, // I-frame
+  SPS = 7,
+  PPS = 8,
+}
+
+const ignoredTypes = new Set()
+
+/*
+First byte in payload (rtp payload header):
+      +---------------+
+      |0|1|2|3|4|5|6|7|
+      +-+-+-+-+-+-+-+-+
+      |F|NRI|  Type   |
+      +---------------+
+
+2nd byte in payload: FU header (if type in first byte is 28)
+      +---------------+
+      |0|1|2|3|4|5|6|7|
+      +-+-+-+-+-+-+-+-+
+      |S|E|R|  Type   | S = start, E = end
+      +---------------+
+*/
+
+export class H264Depay {
+  public payloadType?: number
+
+  private _buffer: Uint8Array = new Uint8Array(0)
+  private _frameFragments: Uint8Array[] = []
+  private _idrFound: boolean = false
+
+  constructor(media: MediaDescription[]) {
+    const h264Media = media.find(isH264Media)
+    this.payloadType = h264Media?.rtpmap?.payloadType
+  }
+
+  public parse(rtp: RtpMessage): H264Message | undefined {
+    const endOfFrame = rtp.marker
+
+    const payload = rtp.data
+    const type = payload[0] & 0x1f
+
+    let h264frame: Uint8Array | undefined = undefined
+    let nalType = type
+
+    if (type === 28) {
+      /* FU-A NALU */ const fuIndicator = payload[0]
+      const fuHeader = payload[1]
+      const startBit = !!(fuHeader >> 7)
+      nalType = fuHeader & 0x1f
+      const nal = (fuIndicator & 0xe0) | nalType
+      const stopBit = fuHeader & 64
+      if (startBit) {
+        this._buffer = concat([
+          new Uint8Array([0, 0, 0, 0, nal]),
+          payload.subarray(2),
+        ])
+      } else if (stopBit) {
+        /* receieved end bit */ h264frame = concat([
+          this._buffer,
+          payload.subarray(2),
+        ])
+        writeUInt32BE(h264frame, 0, h264frame.length - 4)
+        this._buffer = new Uint8Array(0)
+      } else {
+        // Put the received data on the buffer and cut the header bytes
+        this._buffer = concat([this._buffer, payload.subarray(2)])
+      }
+    } else if (
+      (nalType === NAL_TYPES.NON_IDR_PICTURE ||
+        nalType === NAL_TYPES.IDR_PICTURE) &&
+      this._buffer.length === 0
+    ) {
+      /* Single NALU */ h264frame = concat([
+        new Uint8Array([0, 0, 0, 0]),
+        payload,
+      ])
+      writeUInt32BE(h264frame, 0, h264frame.length - 4)
+      this._buffer = new Uint8Array(0)
+    } else {
+      if (!ignoredTypes.has(type)) {
+        ignoredTypes.add(type)
+        logWarn(
+          `H264depayComponent can only extract types 1,5 and 28, got ${type} (quietly ignoring from now on)`
+        )
+      }
+      // FIXME: this could probably be removed
+      this._buffer = new Uint8Array(0)
+    }
+
+    if (h264frame === undefined) {
+      return
+    }
+
+    this._frameFragments.push(h264frame)
+
+    if (!endOfFrame) {
+      return
+    }
+
+    if (nalType === NAL_TYPES.IDR_PICTURE) {
+      this._idrFound = true
+    }
+
+    const frame =
+      this._frameFragments.length === 1
+        ? this._frameFragments[0]
+        : concat(this._frameFragments)
+    this._frameFragments = []
+
+    if (!this._idrFound) {
+      // NOTE: previouslyframes were skipped completely if they arrived before
+      // an IDR was present, but that might interfere with proper timing information
+      // regarding the start of the presentation, so just print a warning.
+      console.warn(
+        'frame preceeds first IDR frame indicating incomplete start of stream'
+      )
+    }
+
+    return new H264Message({
+      data: frame,
+      idrPicture: nalType === NAL_TYPES.IDR_PICTURE,
+      nalType,
+      ntpTimestamp: rtp.ntpTimestamp,
+      payloadType: rtp.payloadType,
+      timestamp: rtp.timestamp,
+    })
+  }
+}
diff --git a/streams/src/components/rtp/index.ts b/streams/src/components/rtp/index.ts
new file mode 100644
index 000000000..e280c684d
--- /dev/null
+++ b/streams/src/components/rtp/index.ts
@@ -0,0 +1,5 @@
+export * from './depay'
+export * from './aac-depay'
+export * from './h264-depay'
+export * from './jpeg-depay'
+export * from './onvif-depay'
diff --git a/streams/src/components/jpegdepay/parser.ts b/streams/src/components/rtp/jpeg-depay.ts
similarity index 66%
rename from streams/src/components/jpegdepay/parser.ts
rename to streams/src/components/rtp/jpeg-depay.ts
index 004146f7a..14c90b37b 100644
--- a/streams/src/components/jpegdepay/parser.ts
+++ b/streams/src/components/rtp/jpeg-depay.ts
@@ -1,5 +1,8 @@
-import { concat, readUInt8, readUInt16BE } from 'utils/bytes'
-import { payload } from '../../utils/protocols/rtp'
+import { JpegMessage } from '../types/jpeg'
+import { RtpMessage } from '../types/rtp'
+import { MediaDescription, isJpegMedia } from '../types/sdp'
+
+import { concat, readUInt8, readUInt16BE } from '../utils/bytes'
 
 import {
   makeDRIHeader,
@@ -8,8 +11,8 @@ import {
   makeImageHeader,
   makeQuantHeader,
   makeScanHeader,
-} from './headers'
-import { makeQtable } from './make-qtable'
+} from './jpeg-headers'
+import { makeQtable } from './jpeg-qtable'
 
 /**
  * Each packet contains a special JPEG header which immediately follows
@@ -45,16 +48,46 @@ import { makeQtable } from './make-qtable'
  * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  */
 
-export function jpegDepayFactory(defaultWidth = 0, defaultHeight = 0) {
-  const IMAGE_HEADER = makeImageHeader()
-  const HUFFMAN_HEADER = makeHuffmanHeader()
-  const SCAN_HEADER = makeScanHeader()
+export class JPEGDepay {
+  public payloadType?: number
+
+  private _width = 0
+  private _height = 0
+  private _headers = {
+    image: makeImageHeader(),
+    huffman: makeHuffmanHeader(),
+    scan: makeScanHeader(),
+  }
+  private _payloads: Uint8Array[] = []
+
+  constructor(media: MediaDescription[]) {
+    const jpegMedia = media.find(isJpegMedia)
+
+    if (!jpegMedia) {
+      return
+    }
+
+    const framesize = jpegMedia?.framesize ?? [0, 0]
+    this._width = framesize[0]
+    this._height = framesize[1]
+
+    this.payloadType = jpegMedia.rtpmap?.payloadType
+  }
+
+  parse(rtp: RtpMessage): JpegMessage | undefined {
+    this._payloads.push(rtp.data)
+
+    // JPEG over RTP uses the RTP marker bit to indicate end
+    // of fragmentation. At this point, the packets can be used
+    // to reconstruct a JPEG frame.
+    if (!rtp.marker) {
+      return
+    }
 
-  return function jpegDepay(packets: Uint8Array[]) {
     let metadata
     const fragments: Uint8Array[] = []
-    for (const packet of packets) {
-      let fragment = payload(packet)
+    for (const payload of this._payloads) {
+      let fragment = payload
 
       // Parse and extract JPEG header.
       const typeSpecific = readUInt8(fragment, 0)
@@ -64,15 +97,15 @@ export function jpegDepayFactory(defaultWidth = 0, defaultHeight = 0) {
         readUInt8(fragment, 3)
       const type = readUInt8(fragment, 4)
       const Q = readUInt8(fragment, 5)
-      const width = readUInt8(fragment, 6) * 8 || defaultWidth
-      const height = readUInt8(fragment, 7) * 8 || defaultHeight
-      fragment = fragment.slice(8)
+      const width = readUInt8(fragment, 6) * 8 || this._width
+      const height = readUInt8(fragment, 7) * 8 || this._height
+      fragment = fragment.subarray(8)
 
       // Parse and extract Restart Marker header if present.
       let DRI = 0
       if (type >= 64 && type <= 127) {
         DRI = readUInt16BE(fragment, 0)
-        fragment = fragment.slice(4)
+        fragment = fragment.subarray(4)
       }
 
       // Parse and extract Quantization Table header if present.
@@ -80,7 +113,7 @@ export function jpegDepayFactory(defaultWidth = 0, defaultHeight = 0) {
         // const MBZ = fragment.readUInt8()
         const precision = readUInt8(fragment, 1)
         const length = readUInt16BE(fragment, 2)
-        const qTable = fragment.slice(4, 4 + length)
+        const qTable = fragment.subarray(4, 4 + length)
         metadata = {
           typeSpecific,
           type,
@@ -90,7 +123,7 @@ export function jpegDepayFactory(defaultWidth = 0, defaultHeight = 0) {
           precision,
           qTable,
         }
-        fragment = fragment.slice(4 + length)
+        fragment = fragment.subarray(4 + length)
       } // Compute Quantization Table
       else if (Q < 128 && fragmentOffset === 0) {
         const precision = 0
@@ -122,17 +155,22 @@ export function jpegDepayFactory(defaultWidth = 0, defaultHeight = 0) {
 
     const frameHeader = makeFrameHeader(width, height, type)
 
-    return {
-      size: { width, height },
+    this._payloads = []
+
+    return new JpegMessage({
+      timestamp: rtp.timestamp,
+      ntpTimestamp: rtp.ntpTimestamp,
+      payloadType: rtp.payloadType,
+      framesize: { width, height },
       data: concat([
-        IMAGE_HEADER,
+        this._headers.image,
         quantHeader,
         driHeader,
         frameHeader,
-        HUFFMAN_HEADER,
-        SCAN_HEADER,
+        this._headers.huffman,
+        this._headers.scan,
         ...fragments,
       ]),
-    }
+    })
   }
 }
diff --git a/streams/src/components/jpegdepay/headers.ts b/streams/src/components/rtp/jpeg-headers.ts
similarity index 99%
rename from streams/src/components/jpegdepay/headers.ts
rename to streams/src/components/rtp/jpeg-headers.ts
index b73761935..755a37ad1 100644
--- a/streams/src/components/jpegdepay/headers.ts
+++ b/streams/src/components/rtp/jpeg-headers.ts
@@ -7,7 +7,7 @@
  * https://tools.ietf.org/html/rfc2435
  */
 
-import { concat } from 'utils/bytes'
+import { concat } from '../utils/bytes'
 
 export function makeImageHeader() {
   return new Uint8Array([0xff, 0xd8])
diff --git a/streams/src/components/jpegdepay/make-qtable.ts b/streams/src/components/rtp/jpeg-qtable.ts
similarity index 97%
rename from streams/src/components/jpegdepay/make-qtable.ts
rename to streams/src/components/rtp/jpeg-qtable.ts
index edd1158c3..deafb2308 100644
--- a/streams/src/components/jpegdepay/make-qtable.ts
+++ b/streams/src/components/rtp/jpeg-qtable.ts
@@ -1,4 +1,4 @@
-import { clamp } from '../../utils/clamp'
+import { clamp } from '../utils/clamp'
 /**
  * @function makeQtable
  * Creating a quantization table from a Q factor
diff --git a/streams/src/components/rtp/onvif-depay.ts b/streams/src/components/rtp/onvif-depay.ts
new file mode 100644
index 000000000..92bd8b9ce
--- /dev/null
+++ b/streams/src/components/rtp/onvif-depay.ts
@@ -0,0 +1,44 @@
+import { RtpMessage } from '../types/rtp'
+import { MediaDescription, OnvifMedia } from '../types/sdp'
+import { XmlMessage } from '../types/xml'
+
+import { concat } from '../utils/bytes'
+
+function isOnvifMedia(media: MediaDescription): media is OnvifMedia {
+  return (
+    media.type === 'application' &&
+    media.rtpmap?.encodingName === 'VND.ONVIF.METADATA'
+  )
+}
+
+export class ONVIFDepay {
+  public payloadType?: number
+
+  private _payloads: Uint8Array[] = []
+
+  constructor(media: MediaDescription[]) {
+    const onvifMedia = media.find(isOnvifMedia)
+    this.payloadType = onvifMedia?.rtpmap?.payloadType
+  }
+
+  parse(rtp: RtpMessage): XmlMessage | undefined {
+    this._payloads.push(rtp.data)
+
+    // XML over RTP uses the RTP marker bit to indicate end of fragmentation.
+    // At this point, the stacked payloads can be used to reconstruct an XML
+    // packet.
+    if (!rtp.marker) {
+      return
+    }
+
+    const data = concat(this._payloads)
+    this._payloads = []
+
+    return new XmlMessage({
+      data,
+      ntpTimestamp: rtp.ntpTimestamp,
+      payloadType: rtp.payloadType,
+      timestamp: rtp.timestamp,
+    })
+  }
+}
diff --git a/streams/src/components/rtsp-parser/index.ts b/streams/src/components/rtsp-parser/index.ts
deleted file mode 100644
index 143617f22..000000000
--- a/streams/src/components/rtsp-parser/index.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Transform } from 'stream'
-
-import { Tube } from '../component'
-import { Message, MessageType } from '../message'
-
-import { builder } from './builder'
-import { Parser } from './parser'
-
-/**
- * A component that converts raw binary data into RTP/RTSP/RTCP packets on the
- * incoming stream, and converts RTSP commands to raw binary data on the outgoing
- * stream. The component is agnostic of any RTSP session details (you need an
- * RTSP session component in the pipeline).
- * @extends {Component}
- */
-export class RtspParser extends Tube {
-  constructor() {
-    const parser = new Parser()
-
-    // Incoming stream
-    const incoming = new Transform({
-      objectMode: true,
-      transform(msg: Message, encoding, callback) {
-        if (msg.type === MessageType.RAW) {
-          try {
-            parser.parse(msg.data).forEach((message) => incoming.push(message))
-            callback()
-          } catch (e) {
-            const err = e as Error
-            callback(err)
-          }
-        } else {
-          // Not a message we should handle
-          callback(undefined, msg)
-        }
-      },
-    })
-
-    // Outgoing stream
-    const outgoing = new Transform({
-      objectMode: true,
-      transform(msg: Message, encoding, callback) {
-        if (msg.type === MessageType.RTSP) {
-          const data = builder(msg)
-          callback(undefined, { type: MessageType.RAW, data })
-        } else {
-          // don't touch other types
-          callback(undefined, msg)
-        }
-      },
-    })
-
-    super(incoming, outgoing)
-  }
-}
diff --git a/streams/src/components/rtsp-session/index.ts b/streams/src/components/rtsp-session/index.ts
deleted file mode 100644
index 965809909..000000000
--- a/streams/src/components/rtsp-session/index.ts
+++ /dev/null
@@ -1,549 +0,0 @@
-import debug from 'debug'
-
-import { Transform } from 'stream'
-
-import { merge } from '../../utils/config'
-import { getTime } from '../../utils/protocols/ntp'
-import { Rtcp, isRtcpSR } from '../../utils/protocols/rtcp'
-import { timestamp } from '../../utils/protocols/rtp'
-import {
-  connectionEnded,
-  contentBase,
-  contentLocation,
-  range,
-  sequence,
-  sessionId,
-  sessionTimeout,
-  statusCode,
-} from '../../utils/protocols/rtsp'
-import { Sdp } from '../../utils/protocols/sdp'
-import { Tube } from '../component'
-import {
-  Message,
-  MessageType,
-  RtcpMessage,
-  RtpMessage,
-  RtspMessage,
-  SdpMessage,
-} from '../message'
-
-function isAbsolute(url: string) {
-  return /^[^:]+:\/\//.test(url)
-}
-
-enum STATE {
-  IDLE = 'idle',
-  PLAYING = 'playing',
-  PAUSED = 'paused',
-}
-
-export enum RTSP_METHOD {
-  OPTIONS = 'OPTIONS',
-  DESCRIBE = 'DESCRIBE',
-  SETUP = 'SETUP',
-  PLAY = 'PLAY',
-  PAUSE = 'PAUSE',
-  TEARDOWN = 'TEARDOWN',
-}
-
-const MIN_SESSION_TIMEOUT = 5 // minimum timeout for a rtsp session in seconds
-
-interface Headers {
-  [key: string]: string
-}
-
-interface Command {
-  method: RTSP_METHOD
-  headers?: Headers
-  uri?: string
-}
-
-interface MethodHeaders {
-  [key: string]: Headers
-}
-
-export interface RtspConfig {
-  hostname?: string
-  parameters?: string[]
-  uri?: string
-  headers?: MethodHeaders
-  defaultHeaders?: Headers
-}
-
-// Default RTSP configuration
-const defaultConfig = (
-  hostname: string = typeof window === 'undefined'
-    ? ''
-    : window.location.hostname,
-  parameters: string[] = []
-): RtspConfig => {
-  const uri =
-    parameters.length > 0
-      ? `rtsp://${hostname}/axis-media/media.amp?${parameters.join('&')}`
-      : `rtsp://${hostname}/axis-media/media.amp`
-
-  return { uri }
-}
-
-export class RTSPResponseError extends Error {
-  public code: number
-
-  constructor(message: string, code: number) {
-    super(message)
-    this.name = 'RTSPResponseError'
-    this.code = code
-  }
-}
-
-/**
- * A component that sets up a command queue in order to interact with the RTSP
- * server. Allows control over the RTSP session by listening to incoming messages
- * and sending request on the outgoing stream.
- *
- * The following handlers can be set on the component:
- *  - onSdp: will be called when an SDP object is sent with the object as argument
- *  - onPlay: will be called when an RTSP PLAY response is sent with the media range
- *            as argument. The latter is an array [start, stop], where start is "now"
- *            (for live) or a time in seconds, and stop is undefined (for live or
- *            ongoing streams) or a time in seconds.
- * @extends {Component}
- */
-export class RtspSession extends Tube {
-  public uri?: string
-  public headers?: MethodHeaders
-  public defaultHeaders?: Headers
-  public t0?: { [key: number]: number }
-  public n0?: { [key: number]: number }
-  public clockrates?: { [key: number]: number }
-  public startTime?: number
-
-  public onRtcp?: (rtcp: Rtcp) => void
-  public onSdp?: (sdp: Sdp) => void
-  public onError?: (err: RTSPResponseError) => void
-  public onPlay?: (range?: string[]) => void
-
-  public retry?: () => void
-
-  private _outgoingClosed: boolean
-  private _sequence?: number
-  private _callStack?: Command[]
-  private _callHistory?: any[]
-  private _state?: STATE
-  private _waiting?: boolean
-  private _contentBase?: string | null
-  private _contentLocation?: string | null
-  private _sessionId?: string | null
-  private _sessionControlURL: string
-  private _renewSessionInterval?: number | null
-
-  /**
-   * Create a new RTSP session controller component.
-   * @param  [config] Details about the session.
-   * @param  [config.hostname] The RTSP server hostname
-   * @param  [config.parameters] The RTSP URI parameters
-   * @param  [config.uri] The full RTSP URI (overrides any hostname/parameters)
-   * @param  [config.defaultHeaders] Default headers to use (for all methods).
-   * @param  [config.headers] Headers to use (mapped to each method).
-   */
-  constructor(config: RtspConfig = {}) {
-    const { uri, headers, defaultHeaders } = merge(
-      defaultConfig(config.hostname, config.parameters),
-      config
-    )
-
-    const incoming = new Transform({
-      objectMode: true,
-      transform: (msg: Message, _, callback) => {
-        if (msg.type === MessageType.RTSP) {
-          this._onRtsp(msg)
-          callback() // Consumes the RTSP packages
-        } else if (msg.type === MessageType.RTCP) {
-          this._onRtcp(msg)
-          // Execute externally registered SDP handler
-          this.onRtcp && this.onRtcp(msg.rtcp)
-          // Pass SDP forward
-          callback(undefined, msg)
-        } else if (msg.type === MessageType.RTP) {
-          this._onRtp(msg)
-          callback(undefined, msg)
-        } else if (msg.type === MessageType.SDP) {
-          this._onSdp(msg)
-          // Execute externally registered SDP handler
-          this.onSdp && this.onSdp(msg.sdp)
-          // Pass SDP forward
-          callback(undefined, msg)
-        } else {
-          // Not a message we should handle
-          callback(undefined, msg)
-        }
-      },
-    })
-
-    incoming.on('end', () => {
-      // Incoming was ended, assume that outgoing is closed as well
-      this._outgoingClosed = true
-    })
-
-    super(incoming)
-
-    this._outgoingClosed = false
-
-    this._reset()
-    this.update(uri, headers, defaultHeaders)
-
-    this._sessionControlURL = this._controlURL()
-  }
-
-  /**
-   * Update the cached RTSP uri and headers.
-   * @param  uri - The RTSP URI.
-   * @param  headers - Maps commands to headers.
-   * @param  defaultHeaders - Default headers.
-   */
-  update(
-    uri: string | undefined,
-    headers: MethodHeaders = {},
-    defaultHeaders: Headers = {}
-  ) {
-    if (uri === undefined) {
-      throw new Error(
-        'You must supply an uri when creating a RtspSessionComponent'
-      )
-    }
-    this.uri = uri
-    this.defaultHeaders = defaultHeaders
-    this.headers = Object.assign(
-      {
-        [RTSP_METHOD.OPTIONS]: {},
-        [RTSP_METHOD.PLAY]: {},
-        [RTSP_METHOD.SETUP]: { Blocksize: '64000' },
-        [RTSP_METHOD.DESCRIBE]: { Accept: 'application/sdp' },
-        [RTSP_METHOD.PAUSE]: {},
-      },
-      headers
-    )
-  }
-
-  /**
-   * Restore the initial values to the state they were in before any RTSP
-   * connection was made.
-   */
-  _reset() {
-    this._sequence = 1
-    this.retry = () => console.error("No request sent, can't retry")
-    this._callStack = []
-    this._callHistory = []
-    this._state = STATE.IDLE
-    this._waiting = false
-
-    this._contentBase = null
-    this._sessionId = null
-    if (this._renewSessionInterval !== null) {
-      clearInterval(this._renewSessionInterval)
-    }
-    this._renewSessionInterval = null
-
-    this.t0 = undefined
-    this.n0 = undefined
-    this.clockrates = undefined
-  }
-
-  _controlURL(attribute?: string) {
-    if (attribute !== undefined && isAbsolute(attribute)) {
-      return attribute
-    }
-
-    // Not defined or not absolute, we need a base URI
-    const baseURL = this._contentBase ?? this._contentLocation ?? this.uri
-    if (baseURL === null || baseURL === undefined) {
-      throw new Error(
-        'relative or missing control attribute but no base URL available'
-      )
-    }
-    if (attribute === undefined || attribute === '*') {
-      return baseURL
-    }
-    return new URL(attribute, baseURL).href
-  }
-
-  /**
-   * Handles incoming RTSP messages and send the next command in the queue.
-   * @param  msg - An incoming RTSP message.
-   */
-  _onRtsp(msg: RtspMessage) {
-    this._waiting = false
-
-    const dec = new TextDecoder()
-    const content = dec.decode(msg.data)
-
-    const status = statusCode(content)
-    const ended = connectionEnded(content)
-    const seq = sequence(content)
-    if (seq === null) {
-      throw new Error('rtsp: expected sequence number')
-    }
-    if (this._callHistory === undefined) {
-      throw new Error('rtsp: internal error')
-    }
-    const method = this._callHistory[seq - 1]
-
-    debug('msl:rtsp:incoming')(`${content}`)
-    if (!this._sessionId && !ended) {
-      // Response on first SETUP
-      this._sessionId = sessionId(content)
-      const _sessionTimeout = sessionTimeout(content)
-      if (_sessionTimeout !== null) {
-        // The server specified that sessions will timeout if not renewed.
-        // In order to keep it alive we need periodically send a RTSP_OPTIONS message
-        if (this._renewSessionInterval !== null) {
-          clearInterval(this._renewSessionInterval)
-        }
-        this._renewSessionInterval = setInterval(
-          () => {
-            this._enqueue({ method: RTSP_METHOD.OPTIONS })
-            this._dequeue()
-          },
-          Math.max(MIN_SESSION_TIMEOUT, _sessionTimeout - 5) * 1000
-        ) as unknown as number
-      }
-    }
-
-    if (!this._contentBase) {
-      this._contentBase = contentBase(content)
-    }
-    if (!this._contentLocation) {
-      this._contentLocation = contentLocation(content)
-    }
-    if (status >= 400) {
-      // TODO: Retry in certain cases?
-      this.onError && this.onError(new RTSPResponseError(content, status))
-    }
-
-    if (method === RTSP_METHOD.PLAY) {
-      // When starting to play, send the actual range to an external handler.
-      this.onPlay && this.onPlay(range(content))
-    }
-
-    if (ended) {
-      debug('msl:rtsp:incoming')(
-        `RTSP Session ${this._sessionId} ended with statusCode: ${status}`
-      )
-      this._sessionId = null
-    }
-
-    this._dequeue()
-  }
-
-  _onRtcp(msg: RtcpMessage) {
-    if (this.t0 === undefined || this.n0 === undefined) {
-      throw new Error('rtsp: internal error')
-    }
-    if (isRtcpSR(msg.rtcp)) {
-      const rtpChannel = msg.channel - 1
-      this.t0[rtpChannel] = msg.rtcp.rtpTimestamp
-      this.n0[rtpChannel] = getTime(msg.rtcp.ntpMost, msg.rtcp.ntpLeast)
-    }
-  }
-
-  _onRtp(msg: RtpMessage) {
-    if (
-      this.t0 === undefined ||
-      this.n0 === undefined ||
-      this.clockrates === undefined
-    ) {
-      throw new Error('rtsp: internal error')
-    }
-    const rtpChannel = msg.channel
-    const t0 = this.t0[rtpChannel]
-    const n0 = this.n0[rtpChannel]
-    if (typeof t0 !== 'undefined' && typeof n0 !== 'undefined') {
-      const clockrate = this.clockrates[rtpChannel]
-      const t = timestamp(msg.data)
-      // The RTP timestamps are unsigned 32 bit and will overflow
-      // at some point. We can guard against the overflow by ORing with 0,
-      // which will bring any difference back into signed 32-bit domain.
-      const dt = (t - t0) | 0
-      msg.ntpTimestamp = (dt / clockrate) * 1000 + n0
-    }
-  }
-
-  /**
-   * Handles incoming SDP messages, reply with SETUP and optionally PLAY.
-   * @param  msg - An incoming SDP message.
-   */
-  _onSdp(msg: SdpMessage) {
-    this.n0 = {}
-    this.t0 = {}
-    this.clockrates = {}
-
-    this._sessionControlURL = this._controlURL(msg.sdp.session.control)
-
-    msg.sdp.media.forEach((media, index) => {
-      // We should actually be able to handle
-      // non-dynamic payload types, but ignored for now.
-      if (media.rtpmap === undefined) {
-        return
-      }
-      const { clockrate } = media.rtpmap
-
-      const rtp = index * 2
-      const rtcp = rtp + 1
-
-      const uri =
-        media.control === undefined
-          ? this._sessionControlURL
-          : this._controlURL(media.control)
-
-      this._enqueue({
-        method: RTSP_METHOD.SETUP,
-        headers: {
-          Transport: `RTP/AVP/TCP;unicast;interleaved=${rtp}-${rtcp}`,
-        },
-        uri,
-      })
-
-      // TODO: see if we can get rid of this check somehow
-      if (this.clockrates === undefined) {
-        return
-      }
-      this.clockrates[rtp] = clockrate
-    })
-    if (this._state === STATE.PLAYING) {
-      this._enqueue({
-        method: RTSP_METHOD.PLAY,
-        headers: {
-          Range: `npt=${this.startTime || 0}-`,
-        },
-        uri: this._sessionControlURL,
-      })
-    }
-    this._dequeue()
-  }
-
-  /**
-   * Set up command queue in order to start playing, i.e. PLAY optionally
-   * preceeded by OPTIONS/DESCRIBE commands. If not waiting, immediately
-   * start sending.
-   * @param  startTime - Time (seconds) at which to start playing
-   */
-  play(startTime = 0) {
-    if (this._state === STATE.IDLE) {
-      this.startTime = Number(startTime) || 0
-      this._enqueue({ method: RTSP_METHOD.OPTIONS })
-      this._enqueue({ method: RTSP_METHOD.DESCRIBE })
-    } else if (this._state === STATE.PAUSED) {
-      if (this._sessionId === null || this._sessionId === undefined) {
-        throw new Error('rtsp: internal error')
-      }
-      this._enqueue({
-        method: RTSP_METHOD.PLAY,
-        headers: {
-          Session: this._sessionId,
-        },
-        uri: this._sessionControlURL,
-      })
-    }
-    this._state = STATE.PLAYING
-    this._dequeue()
-  }
-
-  /**
-   * Queue a pause command, and send if not waiting.
-   * @return {undefined}
-   */
-  pause() {
-    this._enqueue({ method: RTSP_METHOD.PAUSE })
-    this._state = STATE.PAUSED
-    this._dequeue()
-  }
-
-  /**
-   * End the session if there is one, otherwise just cancel
-   * any outstanding calls on the stack.
-   * @return {undefined}
-   */
-  stop() {
-    if (this._sessionId) {
-      this._enqueue({ method: RTSP_METHOD.TEARDOWN })
-    } else {
-      this._callStack = []
-    }
-    this._state = STATE.IDLE
-    if (this._renewSessionInterval !== null) {
-      clearInterval(this._renewSessionInterval)
-      this._renewSessionInterval = null
-    }
-    this._dequeue()
-  }
-
-  /**
-   * Pushes an RTSP request onto the outgoing stream.
-   * @param  cmd - The details about the command to send.
-   */
-  send(cmd: Command) {
-    const { method, headers, uri } = cmd
-    if (method === undefined) {
-      throw new Error('missing method when send request')
-    }
-    this._waiting = true
-    this.retry = this.send.bind(this, cmd)
-
-    if (
-      this._sequence === undefined ||
-      this.headers === undefined ||
-      this._callHistory === undefined
-    ) {
-      throw new Error('rtsp: internal error')
-    }
-    const message = Object.assign(
-      {
-        type: MessageType.RTSP,
-        uri: uri || this._sessionControlURL,
-        data: new Uint8Array(0), // data is a mandatory field. Not used by session -> parser messages.
-      },
-      { method, headers },
-      {
-        headers: Object.assign(
-          { CSeq: this._sequence++ },
-          this.defaultHeaders, // default headers (for all methods)
-          this.headers[method], // preset headers for this method
-          headers // headers that came with the invokation
-        ),
-      }
-    )
-    this._sessionId && (message.headers.Session = this._sessionId)
-    this._callHistory.push(method)
-    if (!this._outgoingClosed) {
-      this.outgoing.push(message)
-    } else {
-      // If the socket is closed, dont attempt to send any data
-      debug('msl:rtsp:outgoing')(`Unable to send ${method}, connection closed`)
-    }
-  }
-
-  /**
-   * Push a command onto the call stack.
-   * @param  cmd - The command to queue
-   */
-  _enqueue(cmd: Command) {
-    if (this._callStack === undefined) {
-      throw new Error('rtsp: internal error')
-    }
-    this._callStack.push(cmd)
-  }
-
-  /**
-   * If possible, send the next command on the call stack.
-   */
-  _dequeue() {
-    if (this._callStack === undefined) {
-      throw new Error('rtsp: internal error')
-    }
-    if (!this._waiting && this._callStack.length > 0) {
-      const cmd = this._callStack.shift()
-      if (cmd !== undefined) {
-        this.send(cmd)
-      }
-    }
-  }
-}
diff --git a/streams/src/components/auth/digest.ts b/streams/src/components/rtsp/auth/digest.ts
similarity index 99%
rename from streams/src/components/auth/digest.ts
rename to streams/src/components/rtsp/auth/digest.ts
index 6f1237678..75314a4a1 100644
--- a/streams/src/components/auth/digest.ts
+++ b/streams/src/components/rtsp/auth/digest.ts
@@ -1,4 +1,5 @@
 // https://tools.ietf.org/html/rfc2617#section-3.2.1
+
 import { Md5 as MD5 } from 'ts-md5'
 
 import { ChallengeParams } from './www-authenticate'
diff --git a/streams/src/components/rtsp/auth/index.ts b/streams/src/components/rtsp/auth/index.ts
new file mode 100644
index 000000000..fd7283d2d
--- /dev/null
+++ b/streams/src/components/rtsp/auth/index.ts
@@ -0,0 +1,62 @@
+import { fromByteArray } from 'base64-js'
+
+import type { RtspRequestMessage, RtspResponseMessage } from '../../types/rtsp'
+import { encode } from '../../utils/bytes'
+
+import { DigestAuth } from './digest'
+import { parseWWWAuthenticate } from './www-authenticate'
+
+const UNAUTHORIZED = 401
+
+export interface AuthConfig {
+  username: string
+  password: string
+}
+
+/**
+ * Handles authentication on an RTSP session.
+ * Note: this is a mostly untested experimental implementation
+ * intended mainly for use in development and debugging.
+ */
+export class Auth {
+  constructor(
+    private readonly username: string,
+    private readonly password: string
+  ) {}
+
+  /** Checks for WWW-Authenticate header and writes an Authentication header to the request.
+   * Returns true if the request should be retried, false otherwise. */
+  public authHeader(
+    req: RtspRequestMessage,
+    rsp: RtspResponseMessage
+  ): boolean {
+    if (rsp.statusCode !== UNAUTHORIZED) {
+      return false
+    }
+
+    const wwwAuth = rsp.headers.get('WWW-Authenticate')
+    if (!wwwAuth) {
+      throw new Error('cannot find WWW-Authenticate header')
+    }
+
+    let authHeader: string | undefined = undefined
+    const challenge = parseWWWAuthenticate(wwwAuth)
+    if (challenge.type === 'basic') {
+      authHeader = `Basic ${fromByteArray(encode(`${this.username}:${this.password}`))}`
+    } else if (challenge.type === 'digest') {
+      const digest = new DigestAuth(
+        challenge.params,
+        this.username,
+        this.password
+      )
+      authHeader = digest.authorization(req.method, req.uri)
+    }
+
+    if (authHeader) {
+      req.headers.Authorization = authHeader
+      return true
+    }
+
+    return false
+  }
+}
diff --git a/streams/src/components/auth/www-authenticate.ts b/streams/src/components/rtsp/auth/www-authenticate.ts
similarity index 91%
rename from streams/src/components/auth/www-authenticate.ts
rename to streams/src/components/rtsp/auth/www-authenticate.ts
index 00bdae7ee..d67e8e35e 100644
--- a/streams/src/components/auth/www-authenticate.ts
+++ b/streams/src/components/rtsp/auth/www-authenticate.ts
@@ -6,7 +6,7 @@ export interface Challenge {
 }
 
 export const parseWWWAuthenticate = (header: string): Challenge => {
-  const [, type, ...challenge] = header.split(' ')
+  const [type, ...challenge] = header.split(' ')
 
   const pairs: Array<[string, string]> = []
   const re = /\s*([^=]+)="([^"]*)",?/gm
diff --git a/streams/src/components/rtsp/header.ts b/streams/src/components/rtsp/header.ts
new file mode 100644
index 000000000..9a558d802
--- /dev/null
+++ b/streams/src/components/rtsp/header.ts
@@ -0,0 +1,184 @@
+/*
+ * The RTSP response format is defined in RFC 7826,
+ * using ABNF notation specified in RFC 5234.
+ * Strings in ABNF rules ("...") are always case insensitive!
+ *
+ * Basic rules to help with the headers below:
+ * ====
+ * CR              =  %x0D ; US-ASCII CR, carriage return (13)
+ * LF              =  %x0A  ; US-ASCII LF, linefeed (10)
+ * SP              =  %x20  ; US-ASCII SP, space (32)
+ * HT              =  %x09  ; US-ASCII HT, horizontal-tab (9)
+ * CRLF            =  CR LF
+ * LWS             =  [CRLF] 1*( SP / HT ) ; Line-breaking whitespace
+ * SWS             =  [LWS] ; Separating whitespace
+ * HCOLON          =  *( SP / HT ) ":" SWS
+ *
+ * RTSP response rules (a `*` means zero or more):
+ * ====
+ * Status-Line  = RTSP-Version SP Status-Code SP Reason-Phrase CRLF
+ * Response     = Status-Line
+ *                *((general-header
+ *                /  response-header
+ *                /  message-body-header) CRLF)
+ *                CRLF
+ *                [ message-body-data ]
+ *
+ * header specifications:
+ *
+ * Content-Base       =  "Content-Base" HCOLON RTSP-URI
+ * Content-Length     =  "Content-Length" HCOLON 1*19DIGIT
+ * Content-Location   =  "Content-Location" HCOLON RTSP-REQ-Ref
+ * Session            =  "Session" HCOLON session-id
+ *                       [ SEMI "timeout" EQUAL delta-seconds ]
+ * session-id         =  1*256( ALPHA / DIGIT / safe )
+ * delta-seconds      =  1*19DIGIT
+ * Connection         =  "Connection" HCOLON connection-token
+ *                       *(COMMA connection-token)
+ * connection-token   =  "close" / token
+ * CSeq               =  "CSeq" HCOLON cseq-nr
+ * cseq-nr            =  1*9DIGIT
+ * Range              =  "Range" HCOLON ranges-spec
+ * ranges-spec        =  npt-range / utc-range / smpte-range
+ *                       /  range-ext
+ * npt-range          =  "npt" [EQUAL npt-range-spec]
+ * npt-range-spec     =  ( npt-time "-" [ npt-time ] ) / ( "-" npt-time )
+ * npt-time           =  "now" / npt-sec / npt-hhmmss / npt-hhmmss-comp
+ * npt-sec            =  1*19DIGIT [ "." 1*9DIGIT ]
+ * npt-hhmmss         =  npt-hh ":" npt-mm ":" npt-ss [ "." 1*9DIGIT ]
+ * npt-hh             =  2*19DIGIT   ; any positive number
+ * npt-mm             =  2*2DIGIT  ; 0-59
+ * npt-ss             =  2*2DIGIT  ; 0-59
+ * npt-hhmmss-comp    =  npt-hh-comp ":" npt-mm-comp ":" npt-ss-comp
+ *                       [ "." 1*9DIGIT ] ; Compatibility format
+ * npt-hh-comp        =  1*19DIGIT   ; any positive number
+ * npt-mm-comp        =  1*2DIGIT  ; 0-59
+ * npt-ss-comp        =  1*2DIGIT  ; 0-59
+ *
+ * Example response:
+ * ====
+ * RTSP/1.0 200 OK
+ * CSeq: 3
+ * Content-Type: application/sdp
+ * Content-Base: rtsp://192.168.0.3/axis-media/media.amp/
+ * Server: GStreamer RTSP server
+ * Date: Wed, 03 Jun 2015 14:23:42 GMT
+ * Content-Length: 623
+ *
+ * v=0
+ * ....
+ */
+
+/**
+ * Parse headers from an RTSP response. The body can be included as the
+ * parser will stop at the first empty line.
+ *
+ * First line is "start-line":
+ *  SP  SP  CRLF
+ *
+ * Rest is actual headers:
+ * Content-Base       =  "Content-Base" HCOLON RTSP-URI
+ */
+export function parseResponse(response: string): {
+  statusCode: number
+  headers: Headers
+} {
+  const messageLines = response
+    .trimStart()
+    .split('\n')
+    .map((line) => line.trim())
+  const [startline, ...headerlines] = messageLines
+
+  const [_rtspVersion, statusCode, _reasonPhrase] = startline.split(' ')
+
+  const headers = new Headers()
+  for (const line of headerlines) {
+    if (line === '') break
+    const separator = line.indexOf(':')
+    const key = line.substring(0, separator).trim().toLowerCase()
+    const value = line.substring(separator + 1).trim()
+    headers.set(key.trim(), value.trim())
+  }
+
+  return {
+    statusCode: Number.parseInt(statusCode),
+    headers,
+  }
+}
+
+const ASCII = { LF: 10, CR: 13 } as const
+interface HeaderTerminator {
+  byteLength: number
+  sequence: string
+  startByte: (typeof ASCII)[keyof typeof ASCII]
+}
+const headerTerminators: HeaderTerminator[] = [
+  // expected
+  { sequence: '\r\n\r\n', startByte: ASCII.CR, byteLength: 4 },
+  // legacy compatibility
+  { sequence: '\r\r', startByte: ASCII.CR, byteLength: 2 },
+  { sequence: '\n\n', startByte: ASCII.LF, byteLength: 2 },
+]
+
+/**
+ * Determine the offset of the RTSP body, where the header ends.
+ * If there is no header ending, -1 is returned
+ * @param  chunk - A piece of data
+ * @return The body offset, or -1 if no header end found
+ */
+export const bodyOffset = (chunk: Uint8Array) => {
+  // Strictly speaking, it seems RTSP MUST have CRLF and doesn't allow CR or LF on its own.
+  // That means that the end of the header part should be a pair of CRLF, but we're being
+  // flexible here and also allow LF LF or CR CR instead of CRLF CRLF (should be handled
+  // according to version 1.0)
+  const dec = new TextDecoder()
+
+  for (const terminator of headerTerminators) {
+    const terminatorOffset = chunk.findIndex((value, index, array) => {
+      if (value === terminator.startByte) {
+        const candidate = dec.decode(
+          array.slice(index, index + terminator.byteLength)
+        )
+
+        if (candidate === terminator.sequence) {
+          return true
+        }
+      }
+      return false
+    })
+    if (terminatorOffset !== -1) {
+      return terminatorOffset + terminator.byteLength
+    }
+  }
+
+  return -1
+}
+
+// Parse value from a session header "Session: [;timeout=]"
+// Examples:
+//   1234567890;timeout=600
+//   60D9C7BC;timeout=3
+export const parseSession = (
+  session: string
+): { id: string; timeout?: number } => {
+  let sep = session.indexOf(';')
+  if (sep === -1) {
+    return { id: session }
+  }
+  const id = session.slice(0, sep)
+  const timeoutKeyValue = session.slice(sep + 1).trim()
+  sep = timeoutKeyValue.indexOf('=')
+  const timeout = Number.parseInt(timeoutKeyValue.slice(sep + 1).trim())
+  return { id, timeout }
+}
+
+// Parse value from a Range header "Range: npt=-"
+// Examples:
+//   npt=now-
+//   npt=1154.598701-3610.259146
+export const parseRange = (range: string): [string, string] => {
+  let sep = range.indexOf('=')
+  const npt = range.slice(sep + 1)
+  const [start, end] = npt.split('-')
+  return [start, end]
+}
diff --git a/streams/src/components/rtsp/index.ts b/streams/src/components/rtsp/index.ts
new file mode 100644
index 000000000..e2bb99a20
--- /dev/null
+++ b/streams/src/components/rtsp/index.ts
@@ -0,0 +1 @@
+export * from './session'
diff --git a/streams/src/utils/protocols/ntp.ts b/streams/src/components/rtsp/ntp.ts
similarity index 62%
rename from streams/src/utils/protocols/ntp.ts
rename to streams/src/components/rtsp/ntp.ts
index 5164d711d..710331631 100644
--- a/streams/src/utils/protocols/ntp.ts
+++ b/streams/src/components/rtsp/ntp.ts
@@ -1,18 +1,14 @@
+import { NtpMilliSeconds } from '../types/ntp'
+
 // NTP is offset from 01.01.1900
 const NTP_UNIX_EPOCH_OFFSET = Date.UTC(1900, 0, 1)
 
-// Convenience types
-export type seconds = number
-export type milliSeconds = number
-export type NtpSeconds = number
-export type NtpMilliSeconds = number
-
 /**
  * Convert NTP time to milliseconds since January 1, 1970, 00:00:00 UTC (Unix Epoch)
  * @param ntpMost - Seconds since 01.01.1900
  * @param ntpLeast - Fractions since 01.01.1900
  */
-export function getTime(ntpMost: number, ntpLeast: number): NtpMilliSeconds {
+export function getMillis(ntpMost: number, ntpLeast: number): NtpMilliSeconds {
   const ntpMilliSeconds = (ntpMost + ntpLeast / 0x100000000) * 1000
   return NTP_UNIX_EPOCH_OFFSET + ntpMilliSeconds
 }
diff --git a/streams/src/components/rtsp-parser/parser.ts b/streams/src/components/rtsp/parser.ts
similarity index 69%
rename from streams/src/components/rtsp-parser/parser.ts
rename to streams/src/components/rtsp/parser.ts
index 5737aaf6e..03e9fd071 100644
--- a/streams/src/components/rtsp-parser/parser.ts
+++ b/streams/src/components/rtsp/parser.ts
@@ -1,14 +1,12 @@
-import { concat, decode, readUInt16BE } from 'utils/bytes'
-import { rtcpMessageFromBuffer } from '../../utils/protocols/rtcp'
-import { bodyOffset, extractHeaderValue } from '../../utils/protocols/rtsp'
-import { sdpFromBody } from '../../utils/protocols/sdp'
-import { MessageType } from '../message'
-import type {
-  RtcpMessage,
-  RtpMessage,
-  RtspMessage,
-  SdpMessage,
-} from '../message'
+import { concat, decode, readUInt16BE } from '../utils/bytes'
+
+import { RtcpMessage } from '../types/rtcp'
+import { RtpMessage } from '../types/rtp'
+import { RtspResponseMessage } from '../types/rtsp'
+
+import { bodyOffset, parseResponse } from './header'
+import { parseRtcp } from './rtcp'
+import { parseRtp } from './rtp'
 
 /**
  * The different possible internal parser states.
@@ -54,68 +52,58 @@ const rtpPacketInfo = (chunks: Uint8Array[]): RtpPacketInfo => {
 }
 
 /**
- * Parser class with a public method that takes a data chunk and
+ * RTSP Parser
+ *
+ * Provides a parse method that takes a data chunk and
  * returns an array of RTP/RTSP/RTCP message objects. The parser
  * keeps track of the added chunks internally in an array and only
  * concatenates chunks when data is needed to construct a message.
- * @type {[type]}
  */
-export class Parser {
+export class RtspParser {
   private _chunks: Uint8Array[] = []
   private _length = 0
   private _state: STATE = STATE.IDLE
   private _packet?: RtpPacketInfo
 
-  /**
-   * Create a new Parser object.
-   * @return {undefined}
-   */
   constructor() {
     this._init()
   }
 
-  /**
-   * Initialize the internal properties to their default starting
-   * values.
-   * @return {undefined}
-   */
+  // Initialize the internal properties to their default starting values.
   _init() {
     this._chunks = []
     this._length = 0
     this._state = STATE.IDLE
   }
 
+  // Add a chunk of data to the internal stack.
   _push(chunk: Uint8Array) {
     this._chunks.push(chunk)
     this._length += chunk.length
   }
 
-  /**
-   * Extract RTSP messages.
-   * @return {Array} An array of messages, possibly empty.
-   */
-  _parseRtsp(): Array {
-    const messages: Array = []
-
+  // Extract an RTSP message from the internal stack.
+  _parseRtsp(): Array {
     const data = concat(this._chunks)
     const chunkBodyOffset = bodyOffset(data)
     // If last added chunk does not have the end of the header, return.
     if (chunkBodyOffset === -1) {
-      return messages
+      return []
     }
 
     const rtspHeaderLength = chunkBodyOffset
 
     const dec = new TextDecoder()
-    const header = dec.decode(data.subarray(0, rtspHeaderLength))
+    const startAndHeader = dec.decode(data.subarray(0, rtspHeaderLength))
+    const { statusCode, headers } = parseResponse(startAndHeader)
 
-    const contentLength = extractHeaderValue(header, 'Content-Length')
+    const contentLength = headers.get('content-length')
     if (
       contentLength &&
       Number.parseInt(contentLength) > data.length - rtspHeaderLength
     ) {
       // we do not have the whole body
-      return messages
+      return []
     }
 
     this._init() // resets this._chunks and this._length
@@ -125,27 +113,20 @@ export class Parser {
       data[rtspHeaderLength] === ASCII_DOLLAR
     ) {
       // No body in this chunk, assume there is no body?
-      const packet = data.subarray(0, rtspHeaderLength)
-      messages.push({ type: MessageType.RTSP, data: packet })
-
       // Add the remaining data to the chunk stack.
       const trailing = data.subarray(rtspHeaderLength)
       this._push(trailing)
-    } else {
-      // Body is assumed to be the remaining data of the last chunk.
-      const body = data.subarray(rtspHeaderLength)
 
-      messages.push({ type: MessageType.RTSP, data })
-      messages.push(sdpFromBody(decode(body)))
+      return [new RtspResponseMessage({ statusCode, headers })]
     }
 
-    return messages
+    // Body is assumed to be the remaining data of the last chunk.
+    const body = data.subarray(rtspHeaderLength)
+
+    return [new RtspResponseMessage({ statusCode, headers, body })]
   }
 
-  /**
-   * Extract RTP/RTCP messages.
-   * @return {Array} An array of messages, possibly empty.
-   */
+  // Extract RTP/RTCP messages from the internal stack.
   _parseInterleaved(): Array {
     const messages: Array = []
 
@@ -179,30 +160,25 @@ export class Parser {
     // Extract messages
     if (channel % 2 === 0) {
       // Even channels 0, 2, ...
-      messages.push({
-        type: MessageType.RTP,
-        data: packet,
-        channel,
-      })
+      messages.push(new RtpMessage({ channel, ...parseRtp(packet) }))
     } else {
       // Odd channels 1, 3, ...
       let rtcpPackets = packet
       do {
         // RTCP packets can be packed together, unbundle them:
         const rtcpByteSize = readUInt16BE(rtcpPackets, 2) * 4 + 4
-        messages.push(
-          rtcpMessageFromBuffer(channel, rtcpPackets.slice(0, rtcpByteSize))
-        )
-        rtcpPackets = rtcpPackets.slice(rtcpByteSize)
+        const packet = rtcpPackets.subarray(0, rtcpByteSize)
+
+        messages.push(new RtcpMessage({ channel, rtcp: parseRtcp(packet) }))
+
+        rtcpPackets = rtcpPackets.subarray(rtcpByteSize)
       } while (rtcpPackets.length > 0)
     }
 
     return messages
   }
 
-  /**
-   * Set the internal state based on the type of the first chunk
-   */
+  // Set the internal state based on the type of the first chunk
   _setState() {
     // Remove leading 0-sized chunks.
     while (this._chunks.length > 0 && this._chunks[0].length === 0) {
@@ -226,26 +202,21 @@ export class Parser {
    * Add the next chunk of data to the parser and extract messages.
    * If no message can be extracted, an empty array is returned, otherwise
    * an array of messages is returned.
-   * @param  chunk - The next piece of data.
-   * @return An array of messages, possibly empty.
    */
   parse(
     chunk: Uint8Array
-  ): Array {
+  ): Array {
     this._push(chunk)
 
     if (this._state === STATE.IDLE) {
       this._setState()
     }
 
-    let messages: Array =
-      []
+    let messages: Array = []
     let done = false
 
     while (!done) {
-      let extracted: Array<
-        SdpMessage | RtspMessage | RtpMessage | RtcpMessage
-      > = []
+      let extracted: Array = []
       switch (this._state) {
         case STATE.IDLE:
           break
diff --git a/streams/src/utils/protocols/rtcp.ts b/streams/src/components/rtsp/rtcp.ts
similarity index 66%
rename from streams/src/utils/protocols/rtcp.ts
rename to streams/src/components/rtsp/rtcp.ts
index 61a6f9fd1..c2c4081d4 100644
--- a/streams/src/utils/protocols/rtcp.ts
+++ b/streams/src/components/rtsp/rtcp.ts
@@ -1,15 +1,27 @@
+// Real Time Control Protocol (RTCP) - parsers
+// https://tools.ietf.org/html/rfc3550#section-6
+
+import {
+  RTCPPacketType,
+  Rtcp,
+  RtcpApp,
+  RtcpBye,
+  RtcpRR,
+  RtcpReportBlock,
+  RtcpSDES,
+  RtcpSDESBlock,
+  RtcpSR,
+  SDESItem,
+} from '../types/rtcp'
+
+import { POS } from '../utils/bits'
 import {
   decode,
   readUInt8,
   readUInt16BE,
   readUInt24BE,
   readUInt32BE,
-} from 'utils/bytes'
-import { MessageType, RtcpMessage } from '../../components/message'
-import { POS } from '../bits'
-
-// Real Time Control Protocol (RTCP)
-// https://tools.ietf.org/html/rfc3550#section-6
+} from '../utils/bytes'
 
 /*
 Common RTCP packed header:
@@ -20,23 +32,8 @@ Common RTCP packed header:
 header |V=2|P|    RC   |   PT=SR=200   |             length            |
        +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 */
-export enum RTCPPacketType {
-  SR = 200,
-  RR = 201,
-  SDES = 202,
-  BYE = 203,
-  APP = 204,
-}
-
-export interface Rtcp {
-  readonly version: number
-  readonly padding: boolean
-  readonly count: number
-  readonly packetType: RTCPPacketType | number
-  readonly length: number
-}
 
-const parseBase = (buffer: Uint8Array): Rtcp => ({
+const parseHeader = (buffer: Uint8Array): Rtcp => ({
   version: buffer[0] >>> 6,
   padding: !!(buffer[0] & POS[2]),
   count: buffer[0] & 0x1f,
@@ -45,38 +42,26 @@ const parseBase = (buffer: Uint8Array): Rtcp => ({
 })
 
 export const parseRtcp = (
-  buffer: Uint8Array
+  bytes: Uint8Array
 ): Rtcp | RtcpSR | RtcpRR | RtcpSDES | RtcpBye | RtcpApp => {
-  const base = parseBase(buffer)
+  const base = parseHeader(bytes)
 
   switch (base.packetType) {
     case RTCPPacketType.SR:
-      return parseSR(buffer, base)
+      return parseSR(bytes, base)
     case RTCPPacketType.RR:
-      return parseRR(buffer, base)
+      return parseRR(bytes, base)
     case RTCPPacketType.SDES:
-      return parseSDES(buffer, base)
+      return parseSDES(bytes, base)
     case RTCPPacketType.BYE:
-      return parseBYE(buffer, base)
+      return parseBYE(bytes, base)
     case RTCPPacketType.APP:
-      return parseAPP(buffer, base)
+      return parseAPP(bytes, base)
     default:
       return base
   }
 }
 
-export const rtcpMessageFromBuffer = (
-  channel: number,
-  data: Uint8Array
-): RtcpMessage => {
-  return {
-    type: MessageType.RTCP,
-    data,
-    channel,
-    rtcp: parseRtcp(data),
-  }
-}
-
 /*
 SR: Sender Report RTCP Packet
 
@@ -117,67 +102,6 @@ block  +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
        +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 */
 
-export const SR = {
-  packetType: 200,
-}
-
-export interface RtcpReportBlock {
-  readonly syncSource: number
-  readonly fractionLost: number
-  readonly cumulativeNumberOfPacketsLost: number
-  readonly extendedHighestSequenceNumberReceived: number
-  readonly interarrivalJitter: number
-  readonly lastSRTimestamp: number
-  readonly delaySinceLastSR: number
-}
-
-const parseReportBlocks = (
-  count: number,
-  buffer: Uint8Array,
-  offset: number
-): RtcpReportBlock[] => {
-  const reports: RtcpReportBlock[] = []
-  for (let reportNumber = 0; reportNumber < count; reportNumber++) {
-    const o = offset + reportNumber * 24
-    reports.push({
-      syncSource: readUInt32BE(buffer, o + 0),
-      fractionLost: readUInt8(buffer, o + 4),
-      cumulativeNumberOfPacketsLost: readUInt24BE(buffer, o + 5),
-      extendedHighestSequenceNumberReceived: readUInt32BE(buffer, o + 8),
-      interarrivalJitter: readUInt32BE(buffer, o + 12),
-      lastSRTimestamp: readUInt32BE(buffer, o + 16),
-      delaySinceLastSR: readUInt32BE(buffer, o + 20),
-    })
-  }
-  return reports
-}
-
-export interface RtcpSR extends Rtcp {
-  readonly version: RTCPPacketType.SR
-
-  readonly syncSource: number
-  readonly ntpMost: number
-  readonly ntpLeast: number
-  readonly rtpTimestamp: number
-  readonly sendersPacketCount: number
-  readonly sendersOctetCount: number
-  readonly reports: readonly RtcpReportBlock[]
-}
-
-const parseSR = (buffer: Uint8Array, base: Rtcp): RtcpSR => ({
-  ...base,
-  syncSource: readUInt32BE(buffer, 4),
-  ntpMost: readUInt32BE(buffer, 8),
-  ntpLeast: readUInt32BE(buffer, 12),
-  rtpTimestamp: readUInt32BE(buffer, 16),
-  sendersPacketCount: readUInt32BE(buffer, 20),
-  sendersOctetCount: readUInt32BE(buffer, 24),
-  reports: parseReportBlocks(base.count, buffer, 28),
-})
-
-export const isRtcpSR = (rtcp: Rtcp): rtcp is RtcpSR =>
-  rtcp.packetType === RTCPPacketType.SR
-
 /*
 RR: Receiver Report RTCP Packet
 
@@ -208,21 +132,43 @@ block  +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
        +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 */
 
-export interface RtcpRR extends Rtcp {
-  readonly version: RTCPPacketType.RR
-
-  readonly syncSource: number
-  readonly reports: readonly RtcpReportBlock[]
+const parseReportBlocks = (
+  count: number,
+  bytes: Uint8Array,
+  offset: number
+): RtcpReportBlock[] => {
+  const reports: RtcpReportBlock[] = []
+  for (let reportNumber = 0; reportNumber < count; reportNumber++) {
+    const o = offset + reportNumber * 24
+    reports.push({
+      syncSource: readUInt32BE(bytes, o + 0),
+      fractionLost: readUInt8(bytes, o + 4),
+      cumulativeNumberOfPacketsLost: readUInt24BE(bytes, o + 5),
+      extendedHighestSequenceNumberReceived: readUInt32BE(bytes, o + 8),
+      interarrivalJitter: readUInt32BE(bytes, o + 12),
+      lastSRTimestamp: readUInt32BE(bytes, o + 16),
+      delaySinceLastSR: readUInt32BE(bytes, o + 20),
+    })
+  }
+  return reports
 }
 
-const parseRR = (buffer: Uint8Array, base: Rtcp): RtcpRR => ({
+const parseSR = (bytes: Uint8Array, base: Rtcp): RtcpSR => ({
   ...base,
-  syncSource: readUInt32BE(buffer, 4),
-  reports: parseReportBlocks(base.count, buffer, 8),
+  syncSource: readUInt32BE(bytes, 4),
+  ntpMost: readUInt32BE(bytes, 8),
+  ntpLeast: readUInt32BE(bytes, 12),
+  rtpTimestamp: readUInt32BE(bytes, 16),
+  sendersPacketCount: readUInt32BE(bytes, 20),
+  sendersOctetCount: readUInt32BE(bytes, 24),
+  reports: parseReportBlocks(base.count, bytes, 28),
 })
 
-export const isRtcpRR = (rtcp: Rtcp): rtcp is RtcpRR =>
-  rtcp.packetType === RTCPPacketType.RR
+const parseRR = (bytes: Uint8Array, base: Rtcp): RtcpRR => ({
+  ...base,
+  syncSource: readUInt32BE(bytes, 4),
+  reports: parseReportBlocks(base.count, bytes, 8),
+})
 
 /*
 SDES: Source Description RTCP Packet
@@ -244,41 +190,18 @@ chunk  |                          SSRC/CSRC_2                          |
        +=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+
 */
 
-export enum SDESItem {
-  CNAME = 1,
-  NAME = 2,
-  EMAIL = 3,
-  PHONE = 4,
-  LOC = 5,
-  TOOL = 6,
-  NOTE = 7,
-  PRIV = 8,
-}
-
-export interface RtcpSDESBlock {
-  readonly source: number
-  readonly items: Array<[number, string] | [SDESItem.PRIV, string, string]>
-}
-
-export interface RtcpSDES extends Rtcp {
-  readonly version: RTCPPacketType.SDES
-
-  readonly syncSource: number
-  readonly sourceDescriptions: readonly RtcpSDESBlock[]
-}
-
-const parseSDES = (buffer: Uint8Array, base: Rtcp): RtcpSDES => {
+const parseSDES = (bytes: Uint8Array, base: Rtcp): RtcpSDES => {
   const sourceDescriptions: RtcpSDESBlock[] = []
   let offset = 4
   for (let block = 0; block < base.count; block++) {
     const chunk: RtcpSDESBlock = {
-      source: readUInt32BE(buffer, offset),
+      source: readUInt32BE(bytes, offset),
       items: [],
     }
     offset += 4
 
     while (true) {
-      const itemType = readUInt8(buffer, offset++)
+      const itemType = readUInt8(bytes, offset++)
 
       if (itemType === 0) {
         // start next block at word boundary
@@ -288,19 +211,19 @@ const parseSDES = (buffer: Uint8Array, base: Rtcp): RtcpSDES => {
         break
       }
 
-      const length = readUInt8(buffer, offset++)
+      const length = readUInt8(bytes, offset++)
 
       if (itemType === SDESItem.PRIV) {
-        const prefixLength = readUInt8(buffer, offset)
+        const prefixLength = readUInt8(bytes, offset)
         const prefix = decode(
-          buffer.subarray(offset + 1, offset + 1 + prefixLength)
+          bytes.subarray(offset + 1, offset + 1 + prefixLength)
         )
         const value = decode(
-          buffer.subarray(offset + 1 + prefixLength, offset + length)
+          bytes.subarray(offset + 1 + prefixLength, offset + length)
         )
         chunk.items.push([SDESItem.PRIV, prefix, value])
       } else {
-        const value = decode(buffer.subarray(offset, offset + length))
+        const value = decode(bytes.subarray(offset, offset + length))
         chunk.items.push([itemType, value])
       }
 
@@ -311,14 +234,11 @@ const parseSDES = (buffer: Uint8Array, base: Rtcp): RtcpSDES => {
 
   return {
     ...base,
-    syncSource: readUInt32BE(buffer, 4),
+    syncSource: readUInt32BE(bytes, 4),
     sourceDescriptions,
   }
 }
 
-export const isRtcpSDES = (rtcp: Rtcp): rtcp is RtcpSDES =>
-  rtcp.packetType === RTCPPacketType.SDES
-
 /*
 BYE: Goodbye RTCP Packet
 
@@ -335,24 +255,17 @@ BYE: Goodbye RTCP Packet
       +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 */
 
-export interface RtcpBye extends Rtcp {
-  readonly version: RTCPPacketType.BYE
-
-  readonly sources: number[]
-  readonly reason?: string
-}
-
-const parseBYE = (buffer: Uint8Array, base: Rtcp): RtcpBye => {
+const parseBYE = (bytes: Uint8Array, base: Rtcp): RtcpBye => {
   const sources: number[] = []
   for (let block = 0; block < base.count; block++) {
-    sources.push(readUInt32BE(buffer, 4 + 4 * block))
+    sources.push(readUInt32BE(bytes, 4 + 4 * block))
   }
 
   let reason
   if (base.length > base.count) {
     const start = 4 + 4 * base.count
-    const length = readUInt8(buffer, start)
-    reason = decode(buffer.subarray(start + 1, start + 1 + length))
+    const length = readUInt8(bytes, start)
+    reason = decode(bytes.subarray(start + 1, start + 1 + length))
   }
 
   return {
@@ -362,9 +275,6 @@ const parseBYE = (buffer: Uint8Array, base: Rtcp): RtcpBye => {
   }
 }
 
-export const isRtcpBye = (rtcp: Rtcp): rtcp is RtcpBye =>
-  rtcp.packetType === RTCPPacketType.BYE
-
 /*
 APP: Application-Defined RTCP Packet
 
@@ -382,24 +292,12 @@ APP: Application-Defined RTCP Packet
 
 */
 
-export interface RtcpApp extends Rtcp {
-  readonly version: RTCPPacketType.APP
-
-  readonly subtype: number
-  readonly source: number
-  readonly name: string
-  readonly data: Uint8Array
-}
-
-const parseAPP = (buffer: Uint8Array, base: Rtcp): RtcpApp => {
+const parseAPP = (bytes: Uint8Array, base: Rtcp): RtcpApp => {
   return {
     ...base,
     subtype: base.count,
-    source: readUInt32BE(buffer, 4),
-    name: decode(buffer.subarray(8, 12)),
-    data: buffer.subarray(12),
+    source: readUInt32BE(bytes, 4),
+    name: decode(bytes.subarray(8, 12)),
+    data: bytes.subarray(12),
   }
 }
-
-export const isRtcpApp = (rtcp: Rtcp): rtcp is RtcpApp =>
-  rtcp.packetType === RTCPPacketType.APP
diff --git a/streams/src/components/rtsp/rtp.ts b/streams/src/components/rtsp/rtp.ts
new file mode 100644
index 000000000..4aa6a98ff
--- /dev/null
+++ b/streams/src/components/rtsp/rtp.ts
@@ -0,0 +1,108 @@
+import { POS } from '../utils/bits'
+import { readUInt16BE, readUInt32BE } from '../utils/bytes'
+
+// Real Time Protocol (RTP)
+// https://tools.ietf.org/html/rfc3550#section-5.1
+
+/*
+RTP Fixed Header Fields
+
+  0               1               2               3
+  0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+  +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+  |V=2|P|X|  CC   |M|     PT      |       sequence number         |
+  +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+  |                           timestamp                           |
+  +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+  |           synchronization source (SSRC) identifier            |
+  +=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+
+  |            contributing source (CSRC) identifiers             |
+  |                             ....                              |
+  +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+  |   profile-specific ext. id    | profile-specific ext. length  |
+  +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+  |                 profile-specific extension                    |
+  |                             ....                              |
+  +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+*/
+
+export interface Rtp {
+  readonly version: number
+  readonly marker: boolean
+  readonly data: Uint8Array
+  readonly payloadType: number
+  readonly timestamp: number
+}
+
+export function parseRtp(packet: Uint8Array): Rtp {
+  return {
+    data: payload(packet),
+    marker: marker(packet),
+    payloadType: payloadType(packet),
+    timestamp: timestamp(packet),
+    version: version(packet),
+  }
+}
+
+export const version = (packet: Uint8Array): number => {
+  return packet[0] >>> 6
+}
+
+export const padding = (packet: Uint8Array): boolean => {
+  return !!(packet[0] & POS[2])
+}
+
+export const extension = (packet: Uint8Array): boolean => {
+  return !!(packet[0] & POS[3])
+}
+
+export const cSrcCount = (packet: Uint8Array): number => {
+  return packet[0] & 0x0f
+}
+
+export const marker = (packet: Uint8Array): boolean => {
+  return !!(packet[1] & POS[0])
+}
+
+export const payloadType = (packet: Uint8Array): number => {
+  return packet[1] & 0x7f
+}
+
+export const sequenceNumber = (packet: Uint8Array): number => {
+  return readUInt16BE(packet, 2)
+}
+
+export const timestamp = (packet: Uint8Array): number => {
+  return readUInt32BE(packet, 4)
+}
+
+export const sSrc = (packet: Uint8Array): number => {
+  return readUInt32BE(packet, 8)
+}
+
+export const cSrc = (packet: Uint8Array, rank = 0): number => {
+  return cSrcCount(packet) > rank ? readUInt32BE(packet, 12 + rank * 4) : 0
+}
+
+export const extHeaderLength = (packet: Uint8Array): number => {
+  return !extension(packet)
+    ? 0
+    : readUInt16BE(packet, 12 + cSrcCount(packet) * 4 + 2)
+}
+
+export const extHeader = (packet: Uint8Array): Uint8Array => {
+  return extHeaderLength(packet) === 0
+    ? new Uint8Array(0)
+    : packet.subarray(
+        12 + cSrcCount(packet) * 4,
+        12 + cSrcCount(packet) * 4 + 4 + extHeaderLength(packet) * 4
+      )
+}
+
+export const payload = (packet: Uint8Array): Uint8Array => {
+  return !extension(packet)
+    ? packet.subarray(12 + cSrcCount(packet) * 4)
+    : packet.subarray(
+        12 + cSrcCount(packet) * 4 + 4 + extHeaderLength(packet) * 4
+      )
+}
diff --git a/streams/src/components/rtsp/sdp.ts b/streams/src/components/rtsp/sdp.ts
new file mode 100644
index 000000000..8f7fcdc15
--- /dev/null
+++ b/streams/src/components/rtsp/sdp.ts
@@ -0,0 +1,198 @@
+import { Sdp } from '../types/sdp'
+
+const extractLineVals = (body: string, lineStart: string, start = 0) => {
+  const anchor = `\n${lineStart}`
+  start = body.indexOf(anchor, start)
+  let end = 0
+  const ret: string[] = []
+  while (start >= 0) {
+    end = body.indexOf('\n', start + anchor.length)
+    ret.push(body.substring(start + anchor.length, end).trim())
+    start = body.indexOf(anchor, end)
+  }
+  return ret
+}
+
+/** Identify the start of a session-level or media-level section. */
+const newMediaLevel = (line: string) => {
+  return line.match(/^m=/)
+}
+
+const splitOnFirst = (c: string, text: string) => {
+  const p = text.indexOf(c)
+  if (p < 0) {
+    return [text.slice(0)]
+  }
+  return [text.slice(0, p), text.slice(p + 1)]
+}
+
+const attributeParsers: any = {
+  fmtp: (value: string) => {
+    const [format, stringParameters] = splitOnFirst(' ', value)
+    switch (format) {
+      default: {
+        const pairs = stringParameters.trim().split(';')
+        const parameters: { [key: string]: any } = {}
+        pairs.forEach((pair) => {
+          const [key, val] = splitOnFirst('=', pair)
+          const normalizedKey = key.trim().toLowerCase()
+          if (normalizedKey !== '') {
+            parameters[normalizedKey] = val.trim()
+          }
+        })
+        return { format, parameters }
+      }
+    }
+  },
+  framerate: Number,
+  rtpmap: (value: string) => {
+    const [payloadType, encoding] = splitOnFirst(' ', value)
+    const [encodingName, clockrate, encodingParameters] = encoding
+      .toUpperCase()
+      .split('/')
+    if (encodingParameters === undefined) {
+      return {
+        payloadType: Number(payloadType),
+        encodingName,
+        clockrate: Number(clockrate),
+      }
+    }
+    return {
+      payloadType: Number(payloadType),
+      encodingName,
+      clockrate: Number(clockrate),
+      encodingParameters,
+    }
+  },
+  transform: (value: string) => {
+    return value.split(';').map((row) => row.split(',').map(Number))
+  },
+  'x-sensor-transform': (value: string) => {
+    return value.split(';').map((row) => row.split(',').map(Number))
+  },
+  framesize: (value: string) => {
+    return value.split(' ')[1].split('-').map(Number)
+  },
+}
+
+const parseAttribute = (body: string) => {
+  const [attribute, value] = splitOnFirst(':', body)
+  if (value === undefined) {
+    return { [attribute]: true }
+  }
+  if (attributeParsers[attribute] !== undefined) {
+    return { [attribute]: attributeParsers[attribute](value) }
+  }
+  return { [attribute]: value }
+}
+
+const extractField = (line: string) => {
+  const prefix = line.slice(0, 1)
+  const body = line.slice(2)
+  switch (prefix) {
+    case 'v':
+      return { version: body }
+    case 'o': {
+      const [
+        username,
+        sessionId,
+        sessionVersion,
+        netType,
+        addrType,
+        unicastAddress,
+      ] = body.split(' ')
+      return {
+        origin: {
+          addrType,
+          netType,
+          sessionId,
+          sessionVersion,
+          unicastAddress,
+          username,
+        },
+      }
+    }
+    case 's':
+      return { sessionName: body }
+    case 'i':
+      return { sessionInformation: body }
+    case 'u':
+      return { uri: body }
+    case 'e':
+      return { email: body }
+    case 'p':
+      return { phone: body }
+    // c=  
+    case 'c': {
+      const [connectionNetType, connectionAddrType, connectionAddress] =
+        body.split(' ')
+      return {
+        connectionData: {
+          addrType: connectionAddrType,
+          connectionAddress,
+          netType: connectionNetType,
+        },
+      }
+    }
+    // b=:
+    case 'b': {
+      const [bwtype, bandwidth] = body.split(':')
+      return { bwtype, bandwidth }
+    }
+    // t= 
+    case 't': {
+      const [startTime, stopTime] = body.split(' ').map(Number)
+      return { time: { startTime, stopTime } }
+    }
+    // r=  
+    case 'r': {
+      const [repeatInterval, activeDuration, ...offsets] = body
+        .split(' ')
+        .map(Number)
+      return {
+        repeatTimes: { repeatInterval, activeDuration, offsets },
+      }
+    }
+    // z=    ....
+    case 'z':
+      return
+    // k=
+    // k=:
+    case 'k':
+      return
+    // a=
+    // a=:
+    case 'a':
+      return parseAttribute(body)
+    case 'm': {
+      // Only the first fmt field is parsed!
+      const [type, port, protocol, fmt] = body.split(' ')
+      return { type, port: Number(port), protocol, fmt: Number(fmt) }
+    }
+    default:
+    // console.log('unknown SDP prefix ', prefix);
+  }
+}
+
+export const extractURIs = (body: string) => {
+  // There is a control URI above the m= line, which should not be used
+  const seekFrom = body.indexOf('\nm=')
+  return extractLineVals(body, 'a=control:', seekFrom)
+}
+
+/** Parse an SDP text into a data structure with session and media objects. */
+export const parseSdp = (body: string): Sdp => {
+  const sdp = body.split('\n').map((s) => s.trim())
+  const struct: { [key: string]: any } = { session: {}, media: [] }
+  let mediaCounter = 0
+  let current = struct.session
+  for (const line of sdp) {
+    if (newMediaLevel(line)) {
+      struct.media[mediaCounter] = {}
+      current = struct.media[mediaCounter]
+      ++mediaCounter
+    }
+    current = Object.assign(current, extractField(line))
+  }
+  return struct as Sdp
+}
diff --git a/streams/src/components/rtsp-parser/builder.ts b/streams/src/components/rtsp/serializer.ts
similarity index 68%
rename from streams/src/components/rtsp-parser/builder.ts
rename to streams/src/components/rtsp/serializer.ts
index dfb368993..639f3c8c5 100644
--- a/streams/src/components/rtsp-parser/builder.ts
+++ b/streams/src/components/rtsp/serializer.ts
@@ -1,11 +1,10 @@
-import debug from 'debug'
+import type { RtspRequestMessage } from '../types/rtsp'
 
-import { encode } from 'utils/bytes'
-import { RtspMessage } from '../message'
+import { encode } from '../utils/bytes'
 
 const DEFAULT_PROTOCOL = 'RTSP/1.0'
 
-export const builder = (msg: RtspMessage): Uint8Array => {
+export const serialize = (msg: RtspRequestMessage): Uint8Array => {
   if (!msg.method || !msg.uri) {
     throw new Error('message needs to contain a method and a uri')
   }
@@ -15,11 +14,11 @@ export const builder = (msg: RtspMessage): Uint8Array => {
   const messageString = [
     `${msg.method} ${msg.uri} ${protocol}`,
     Object.entries(headers)
+      .filter(([_, value]) => value !== undefined)
       .map(([key, value]) => `${key}: ${value}`)
       .join('\r\n'),
     '\r\n',
   ].join('\r\n')
-  debug('msl:rtsp:outgoing')(messageString)
 
   return encode(messageString)
 }
diff --git a/streams/src/components/rtsp/session.ts b/streams/src/components/rtsp/session.ts
new file mode 100644
index 000000000..0606edc19
--- /dev/null
+++ b/streams/src/components/rtsp/session.ts
@@ -0,0 +1,451 @@
+import { logDebug, logError, logWarn } from '../../log'
+
+import { decode } from '../utils/bytes'
+
+import {
+  Rtcp,
+  RtcpMessage,
+  RtpMessage,
+  RtspRequestHeaders,
+  RtspRequestMessage,
+  RtspRequestMethod,
+  RtspResponseMessage,
+  Sdp,
+  SdpMessage,
+  isRtcpSR,
+} from '../types'
+
+import { Auth } from './auth'
+import { parseRange, parseSession } from './header'
+import { getMillis } from './ntp'
+import { RtspParser } from './parser'
+import { parseSdp } from './sdp'
+import { serialize } from './serializer'
+
+function isAbsolute(url: string) {
+  return /^[^:]+:\/\//.test(url)
+}
+
+const DEFAULT_SESSION_TIMEOUT = 60 // default timeout in seconds
+const MIN_SESSION_TIMEOUT = 5 // minimum timeout in seconds
+
+type MethodHeaders = Record
+export interface RtspConfig {
+  uri: string
+  headers?: Partial
+  customHeaders?: RtspRequestHeaders
+}
+
+function defaultHeaders(commonHeaders: RtspRequestHeaders = {}): MethodHeaders {
+  return {
+    OPTIONS: commonHeaders,
+    PLAY: commonHeaders,
+    SETUP: { ...commonHeaders, Blocksize: '64000' },
+    DESCRIBE: { ...commonHeaders, Accept: 'application/sdp' },
+    PAUSE: commonHeaders,
+    TEARDOWN: commonHeaders,
+  } as const
+}
+
+type RtspState = 'idle' | 'playing' | 'paused'
+
+/**
+ * A component that sets up a command queue in order to interact with the RTSP
+ * server. Allows control over the RTSP session by listening to incoming messages
+ * and sending request on the outgoing stream.
+ */
+export class RtspSession {
+  public readonly demuxer: TransformStream<
+    Uint8Array,
+    RtpMessage | RtcpMessage | SdpMessage
+  >
+  public readonly commands: ReadableStream
+  public onRtcp?: (rtcp: Rtcp) => void
+  public retry = { max: 20, codes: [503] }
+  public auth?: Auth
+
+  private clockrates?: { [key: number]: number }
+  private cseq: number = 0
+  private emitSdp?: (sdp: SdpMessage) => void
+  private headers: MethodHeaders
+  private keepaliveInterval?: ReturnType
+  private n0?: { [key: number]: number }
+  private recvResponse?: (rsp: RtspResponseMessage) => void
+  private sendRequest?: (req: RtspRequestMessage) => void
+  private sessionControlUrl: string
+  private sessionId?: string
+  private state?: RtspState
+  private t0?: { [key: number]: number }
+  private uri: string
+
+  /** Create a new RTSP session controller component. */
+  public constructor({
+    uri,
+    headers,
+    customHeaders: commonHeaders = {},
+  }: RtspConfig) {
+    this.headers = {
+      ...defaultHeaders(commonHeaders),
+      ...headers,
+    }
+    this.sessionControlUrl = uri
+    this.state = 'idle'
+    this.uri = uri
+
+    const parser = new RtspParser()
+    this.demuxer = new TransformStream({
+      start: (controller) => {
+        this.emitSdp = (sdp: SdpMessage) => {
+          controller.enqueue(sdp)
+        }
+      },
+      transform: (chunk, controller) => {
+        const messages = parser.parse(chunk)
+        for (const message of messages) {
+          switch (message.type) {
+            case 'rtsp_rsp': {
+              if (!this.recvResponse) {
+                logWarn('ignored server-command: ', message)
+                break
+              }
+              this.recvResponse(message)
+              break
+            }
+            case 'rtcp': {
+              this.recordNtpInfo(message)
+              this.onRtcp && this.onRtcp(message.rtcp)
+              controller.enqueue(message)
+              break
+            }
+            case 'rtp': {
+              this.addNtpTimestamp(message)
+              controller.enqueue(message)
+              break
+            }
+          }
+        }
+      },
+    })
+
+    this.commands = new ReadableStream({
+      start: (controller) => {
+        this.sendRequest = (msg: RtspRequestMessage) =>
+          controller.enqueue(serialize(msg))
+      },
+    })
+  }
+
+  /** Send an OPTIONS request, used mainly for keepalive purposes. */
+  public async options(): Promise {
+    const rsp = await this.fetch(
+      new RtspRequestMessage({
+        method: 'OPTIONS',
+        uri: this.sessionControlUrl,
+        headers: { ...this.headers.OPTIONS },
+      })
+    )
+
+    if (rsp.statusCode !== 200) {
+      throw new Error(`response not OK: ${rsp.statusCode}`)
+    }
+  }
+
+  /** Send a DESCRIBE request to get a presentation description of available media . */
+  public async describe(): Promise {
+    const rsp = await this.fetch(
+      new RtspRequestMessage({
+        method: 'DESCRIBE',
+        uri: this.sessionControlUrl,
+        headers: { ...this.headers.DESCRIBE },
+      })
+    )
+
+    if (rsp.statusCode !== 200) {
+      throw new Error(`response not OK: ${rsp.statusCode}`)
+    }
+
+    this.sessionControlUrl =
+      rsp.headers.get('Content-Base') ??
+      rsp.headers.get('Content-Location') ??
+      this.uri
+
+    if (
+      rsp.headers.get('Content-Type') !== 'application/sdp' ||
+      rsp.body === undefined
+    ) {
+      throw new Error('expected SDP in describe response body')
+    }
+
+    const sdp = parseSdp(decode(rsp.body))
+
+    this.emitSdp?.(new SdpMessage(sdp))
+
+    return sdp
+  }
+
+  /** Sends one SETUP request per media in the presentation description. The
+   * server MUST return a session ID in the first reply to a succesful SETUP
+   * request. The ID is stored and a keepalive is initiated to make sure the
+   * session persists. */
+  public async setup(sdp: Sdp) {
+    this.n0 = {}
+    this.t0 = {}
+    this.clockrates = {}
+
+    this.sessionControlUrl = this.controlUrl(sdp.session.control)
+
+    for (const [i, media] of sdp.media.entries()) {
+      if (media.rtpmap === undefined) {
+        // We should actually be able to handle non-dynamic payload types, but ignored for now.
+        logWarn('skipping media description without rtpmap', media)
+        return
+      }
+
+      const rtp = 2 * i
+      const rtcp = 2 * i + 1
+      const { clockrate } = media.rtpmap
+      this.clockrates[rtp] = clockrate
+
+      const uri = this.controlUrl(media.control ?? sdp.session.control)
+
+      const rsp = await this.fetch(
+        new RtspRequestMessage({
+          method: 'SETUP',
+          uri,
+          headers: {
+            ...this.headers.SETUP,
+            Transport: `RTP/AVP/TCP;unicast;interleaved=${rtp}-${rtcp}`,
+          },
+        })
+      )
+
+      if (rsp.statusCode !== 200) {
+        throw new Error(`response not OK: ${rsp.statusCode}`)
+      }
+
+      if (this.sessionId) {
+        continue
+      }
+
+      const session = rsp.headers.get('Session')
+      if (!session) {
+        throw new Error('expected session ID in first SETUP response')
+      }
+
+      const { id, timeout } = parseSession(session)
+      this.sessionId = id
+
+      const sessionTimeout = timeout ?? DEFAULT_SESSION_TIMEOUT
+
+      this.setKeepalive(sessionTimeout)
+    }
+  }
+
+  /** Sends a PLAY request which will cause all media streams to be played.
+   * A range can be specified. If no range is specified, the stream is played
+   * from the beginning and plays to the end, or, if the stream is paused,
+   * it is resumed at the point it was paused. Returns the actual range being
+   * played (this can be relevant e.g. when playing recordings that do not start
+   * exactly at the requested start) */
+  public async play(startTime?: number): Promise<[string, string] | undefined> {
+    const rsp = await this.fetch(
+      new RtspRequestMessage({
+        method: 'PLAY',
+        uri: this.sessionControlUrl,
+        headers: {
+          ...this.headers.PLAY,
+          ...(startTime !== undefined
+            ? { Range: `npt=${Number(startTime) || 0}-` }
+            : undefined),
+        },
+      })
+    )
+
+    if (rsp.statusCode !== 200) {
+      throw new Error(`response not OK: ${rsp.statusCode}`)
+    }
+
+    this.state = 'playing'
+
+    const range = rsp.headers.get('Range')
+    if (range) {
+      return parseRange(range)
+    } else {
+      return undefined
+    }
+  }
+
+  /** Sends a PAUSE request. */
+  public async pause() {
+    if (this.state === 'paused') {
+      return
+    }
+
+    const rsp = await this.fetch(
+      new RtspRequestMessage({
+        method: 'PAUSE',
+        uri: this.sessionControlUrl,
+        headers: { ...this.headers.PAUSE },
+      })
+    )
+
+    if (rsp.statusCode !== 200) {
+      throw new Error(`response not OK: ${rsp.statusCode}`)
+    }
+
+    this.state = 'paused'
+  }
+
+  /** Sends a TEARDOWN request. */
+  public async teardown() {
+    this.clearKeepalive()
+
+    if (!this.sessionId) {
+      logWarn('trying to teardown a non-existing session')
+      return
+    }
+
+    const rsp = await this.fetch(
+      new RtspRequestMessage({
+        method: 'TEARDOWN',
+        uri: this.sessionControlUrl,
+        headers: { ...this.headers.TEARDOWN },
+      })
+    )
+
+    if (rsp.statusCode !== 200) {
+      throw new Error(`response not OK: ${rsp.statusCode}`)
+    }
+
+    this.sessionId = undefined
+
+    this.state = 'idle'
+  }
+
+  /** Starts an entire media playback. */
+  public async start(
+    startTime?: number
+  ): Promise<{ sdp: Sdp; range?: [string, string] }> {
+    if (this.state !== 'idle') {
+      throw new Error(`pipeline can not be started in "${this.state}" state`)
+    }
+    const sdp = await this.describe()
+    await this.setup(sdp)
+    const range = await this.play(startTime)
+    return { sdp, range }
+  }
+
+  /** Send a command and wait for response, setting CSeq and Session headers and
+   * taking care of any authentication or retry logic if present.*/
+  private async fetch(req: RtspRequestMessage): Promise {
+    req.headers.Session = this.sessionId
+    req.headers.CSeq = this.cseq++
+    let rsp = await this.do(req)
+
+    if (rsp.statusCode === 401 && this.auth?.authHeader(req, rsp)) {
+      req.headers.CSeq = this.cseq++
+      rsp = await this.do(req)
+    }
+
+    let retries = 0
+    while (
+      rsp.statusCode !== 200 &&
+      retries < this.retry.max &&
+      this.retry.codes.includes(rsp.statusCode)
+    ) {
+      await new Promise((r) => setTimeout(r, 1000))
+      retries++
+      req.headers.CSeq = this.cseq++
+      rsp = await this.do(req)
+    }
+
+    return rsp
+  }
+
+  /** Perform an RTSP request and wait for the response. The communication
+   * flows over two streams, and this function wraps that in a promise by using
+   * callbacks that tie into the stream setup. This forms the basis for all
+   * request-response communication. */
+  private async do(req: RtspRequestMessage): Promise {
+    logDebug(req)
+    const rsp = await new Promise((resolve) => {
+      this.recvResponse = (rsp) => resolve(rsp)
+      this.sendRequest?.(req)
+    })
+    this.recvResponse = undefined
+    logDebug(rsp)
+    return rsp
+  }
+
+  private controlUrl(attribute?: string) {
+    if (attribute !== undefined && isAbsolute(attribute)) {
+      return attribute
+    }
+
+    if (attribute === undefined || attribute === '*') {
+      return this.sessionControlUrl
+    }
+
+    return new URL(attribute, this.sessionControlUrl).href
+  }
+
+  /** Sends a periodic OPTIONS request to keep a session alive. */
+  private setKeepalive(timeout: number) {
+    clearInterval(this.keepaliveInterval)
+    this.keepaliveInterval = setInterval(
+      () => {
+        // Note: An OPTIONS request intended for keeping alive an RTSP
+        // session MUST include the Session header with the associated
+        // session identifier. Such a request SHOULD also use the media or the
+        // aggregated control URI as the Request-URI.
+        this.options().catch((err) => {
+          logError('failed to keep alive RTSP session:', err)
+        })
+      },
+      Math.max(MIN_SESSION_TIMEOUT, timeout - 5) * 1000
+    )
+  }
+
+  /** Stop sending periodic OPTIONS requests. */
+  private clearKeepalive() {
+    clearInterval(this.keepaliveInterval)
+    this.keepaliveInterval = undefined
+  }
+
+  /** Store the NTP timestamp information. */
+  private recordNtpInfo(msg: RtcpMessage) {
+    if (this.t0 === undefined || this.n0 === undefined) {
+      throw new Error('no NTP information defined')
+    }
+    if (isRtcpSR(msg.rtcp)) {
+      const rtpChannel = msg.channel - 1
+      this.t0[rtpChannel] = msg.rtcp.rtpTimestamp
+      this.n0[rtpChannel] = getMillis(msg.rtcp.ntpMost, msg.rtcp.ntpLeast)
+    }
+  }
+
+  /** Use the stored NTP information to set a real time on the RTP messages.
+   * Note that it takes a few seconds for the first RTCP to arrive, so the initial
+   * RTP messages will not have this NTP timestamp. */
+  private addNtpTimestamp(msg: RtpMessage) {
+    if (
+      this.t0 === undefined ||
+      this.n0 === undefined ||
+      this.clockrates === undefined
+    ) {
+      throw new Error('no NTP information defined')
+    }
+    const rtpChannel = msg.channel
+    const t0 = this.t0[rtpChannel]
+    const n0 = this.n0[rtpChannel]
+    if (typeof t0 !== 'undefined' && typeof n0 !== 'undefined') {
+      const clockrate = this.clockrates[rtpChannel]
+      const t = msg.timestamp
+      // The RTP timestamps are unsigned 32 bit and will overflow
+      // at some point. We can guard against the overflow by ORing with 0,
+      // which will bring any difference back into signed 32-bit domain.
+      const dt = (t - t0) | 0
+      msg.ntpTimestamp = (dt / clockrate) * 1000 + n0
+    }
+  }
+}
diff --git a/streams/src/components/tcp/index.ts b/streams/src/components/tcp/index.ts
deleted file mode 100644
index 12fcadbe8..000000000
--- a/streams/src/components/tcp/index.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { Socket, connect } from 'node:net'
-
-import { Readable, Writable } from 'stream'
-
-import { Source } from '../component'
-import { MessageType } from '../message'
-
-export class TcpSource extends Source {
-  /**
-   * Create a TCP component.
-   * A TCP socket will be created from parsing the URL of the first outgoing message.
-   * @param host  Force RTSP host (overrides OPTIONS URL)
-   */
-  constructor(host?: string) {
-    let socket: Socket
-    /**
-     * Set up an incoming stream and attach it to the socket.
-     */
-    const incoming = new Readable({
-      objectMode: true,
-      read() {
-        //
-      },
-    })
-
-    /**
-     * Set up outgoing stream and attach it to the socket.
-     */
-    const outgoing = new Writable({
-      objectMode: true,
-      write(msg, encoding, callback) {
-        const b = msg.data
-
-        if (!socket) {
-          // Create socket on first outgoing message
-          /*
-          `OPTIONS rtsp://192.168.0.3:554/axis-media/media.amp?resolution=176x144&fps=1 RTSP/1.0
-          CSeq: 1
-          Date: Wed, 03 Jun 2015 14:26:16 GMT
-          `
-          */
-          let url: string
-          if (host === undefined) {
-            const firstSpace = b.indexOf(' ')
-            const secondSpace = b.indexOf(' ', (firstSpace as number) + 1)
-            url = b.slice(firstSpace, secondSpace).toString('ascii')
-          } else {
-            url = `rtsp://${host}`
-          }
-          const { hostname, port } = new URL(url)
-          socket = connect(
-            Number(port) || 554,
-            hostname === null ? undefined : hostname
-          )
-          socket.on('error', (e) => {
-            console.error('TCP socket error:', e)
-            socket.destroy()
-            incoming.push(null)
-          })
-
-          socket.on('data', (buffer) => {
-            if (!incoming.push({ data: buffer, type: MessageType.RAW })) {
-              console.warn(
-                'TCP Component internal error: not allowed to push more data'
-              )
-            }
-          })
-          // When closing a socket, indicate there is no more data to be sent,
-          // but leave the outgoing stream open to check if more requests are coming.
-          socket.on('end', () => {
-            console.warn('socket ended')
-            incoming.push(null)
-          })
-        }
-        try {
-          socket.write(msg.data, encoding, callback)
-        } catch (e) {
-          console.warn('message lost during send:', msg)
-        }
-      },
-    })
-
-    // When an error is sent on the incoming stream, close the socket.
-    incoming.on('error', (e) => {
-      console.log('closing TCP socket due to incoming error', e)
-      socket && socket.end()
-    })
-
-    // When there is no more data going to be sent, close!
-    incoming.on('finish', () => {
-      socket && socket.end()
-    })
-
-    // When an error happens on the outgoing stream, just warn.
-    outgoing.on('error', (e) => {
-      console.warn('error during TCP send, ignoring:', e)
-    })
-
-    // When there is no more data going to be written, close!
-    outgoing.on('finish', () => {
-      socket && socket.end()
-    })
-
-    /**
-     * initialize the component.
-     */
-    super(incoming, outgoing)
-  }
-}
diff --git a/streams/src/components/types/aac.ts b/streams/src/components/types/aac.ts
new file mode 100644
index 000000000..7dd7efd85
--- /dev/null
+++ b/streams/src/components/types/aac.ts
@@ -0,0 +1,25 @@
+import { Message } from './message'
+
+export class ElementaryMessage extends Message<'elementary'> {
+  readonly data: Uint8Array
+  readonly ntpTimestamp?: number
+  readonly payloadType: number
+  readonly timestamp: number
+
+  constructor({
+    data,
+    ntpTimestamp,
+    payloadType,
+    timestamp,
+  }: Pick<
+    ElementaryMessage,
+    'data' | 'ntpTimestamp' | 'payloadType' | 'timestamp'
+  >) {
+    super('elementary')
+
+    this.data = data
+    this.ntpTimestamp = ntpTimestamp
+    this.payloadType = payloadType
+    this.timestamp = timestamp
+  }
+}
diff --git a/streams/src/components/types/h264.ts b/streams/src/components/types/h264.ts
new file mode 100644
index 000000000..7e8eae275
--- /dev/null
+++ b/streams/src/components/types/h264.ts
@@ -0,0 +1,36 @@
+import { Message } from './message'
+
+export class H264Message extends Message<'h264'> {
+  readonly data: Uint8Array
+  readonly idrPicture: boolean
+  readonly nalType: number
+  readonly ntpTimestamp?: number
+  readonly payloadType: number
+  readonly timestamp: number
+
+  constructor({
+    data,
+    idrPicture,
+    nalType,
+    ntpTimestamp,
+    payloadType,
+    timestamp,
+  }: Pick<
+    H264Message,
+    | 'data'
+    | 'idrPicture'
+    | 'nalType'
+    | 'ntpTimestamp'
+    | 'payloadType'
+    | 'timestamp'
+  >) {
+    super('h264')
+
+    this.data = data
+    this.idrPicture = idrPicture
+    this.nalType = nalType
+    this.ntpTimestamp = ntpTimestamp
+    this.payloadType = payloadType
+    this.timestamp = timestamp
+  }
+}
diff --git a/streams/src/components/types/index.ts b/streams/src/components/types/index.ts
new file mode 100644
index 000000000..f4b0235f4
--- /dev/null
+++ b/streams/src/components/types/index.ts
@@ -0,0 +1,11 @@
+export * from './aac'
+export * from './h264'
+export * from './isom'
+export * from './jpeg'
+export * from './message'
+export * from './ntp'
+export * from './rtcp'
+export * from './rtp'
+export * from './rtsp'
+export * from './sdp'
+export * from './xml'
diff --git a/streams/src/components/types/isom.ts b/streams/src/components/types/isom.ts
new file mode 100644
index 000000000..0c9313d52
--- /dev/null
+++ b/streams/src/components/types/isom.ts
@@ -0,0 +1,34 @@
+import { Message } from './message'
+
+export class IsomMessage extends Message<'isom'> {
+  /** presentation time of last I-frame (s) */
+  readonly checkpointTime?: number
+  /** ISO-BMFF boxes */
+  readonly data: Uint8Array
+  /** MIME type of the media stream (e.g. "video/mp4")
+   * (needs to be on the first message if sent in-band)*/
+  readonly mimeType?: string
+  readonly ntpTimestamp?: number
+
+  constructor({
+    checkpointTime,
+    data,
+    mimeType,
+    ntpTimestamp,
+  }: Pick<
+    IsomMessage,
+    'checkpointTime' | 'data' | 'mimeType' | 'ntpTimestamp'
+  >) {
+    super('isom')
+
+    this.checkpointTime = checkpointTime
+    this.data = data
+    this.mimeType = mimeType
+    this.ntpTimestamp = ntpTimestamp
+  }
+}
+
+export interface MediaTrack {
+  type: 'audio' | 'video'
+  codec?: string
+}
diff --git a/streams/src/components/types/jpeg.ts b/streams/src/components/types/jpeg.ts
new file mode 100644
index 000000000..e974a5e32
--- /dev/null
+++ b/streams/src/components/types/jpeg.ts
@@ -0,0 +1,28 @@
+import { Message } from './message'
+
+export class JpegMessage extends Message<'jpeg'> {
+  readonly data: Uint8Array
+  readonly framesize: { readonly width: number; readonly height: number }
+  readonly ntpTimestamp?: number
+  readonly payloadType: number
+  readonly timestamp: number
+
+  constructor({
+    data,
+    framesize,
+    ntpTimestamp,
+    payloadType,
+    timestamp,
+  }: Pick<
+    JpegMessage,
+    'data' | 'framesize' | 'ntpTimestamp' | 'payloadType' | 'timestamp'
+  >) {
+    super('jpeg')
+
+    this.data = data
+    this.framesize = framesize
+    this.ntpTimestamp = ntpTimestamp
+    this.payloadType = payloadType
+    this.timestamp = timestamp
+  }
+}
diff --git a/streams/src/components/types/message.ts b/streams/src/components/types/message.ts
new file mode 100644
index 000000000..c9d91e3d0
--- /dev/null
+++ b/streams/src/components/types/message.ts
@@ -0,0 +1,18 @@
+const messageTypes = [
+  'rtp',
+  'rtsp_req',
+  'rtsp_rsp',
+  'rtcp',
+  'sdp',
+  'elementary',
+  'h264',
+  'isom',
+  'xml',
+  'jpeg',
+] as const
+
+export type MessageType = (typeof messageTypes)[number]
+
+export class Message {
+  constructor(public readonly type: T) {}
+}
diff --git a/streams/src/components/types/ntp.ts b/streams/src/components/types/ntp.ts
new file mode 100644
index 000000000..15b54cc73
--- /dev/null
+++ b/streams/src/components/types/ntp.ts
@@ -0,0 +1,4 @@
+export type seconds = number
+export type milliSeconds = number
+export type NtpSeconds = number
+export type NtpMilliSeconds = number
diff --git a/streams/src/components/types/rtcp.ts b/streams/src/components/types/rtcp.ts
new file mode 100644
index 000000000..dcda5ffd4
--- /dev/null
+++ b/streams/src/components/types/rtcp.ts
@@ -0,0 +1,115 @@
+import { Message } from './message'
+
+// Real Time Control Protocol (RTCP) - types
+// https://tools.ietf.org/html/rfc3550#section-6
+
+export class RtcpMessage extends Message<'rtcp'> {
+  readonly channel: number
+  readonly rtcp: Rtcp | RtcpSR | RtcpRR | RtcpSDES | RtcpBye | RtcpApp
+
+  constructor({ channel, rtcp }: Pick) {
+    super('rtcp')
+
+    this.channel = channel
+    this.rtcp = rtcp
+  }
+}
+
+export enum RTCPPacketType {
+  SR = 200,
+  RR = 201,
+  SDES = 202,
+  BYE = 203,
+  APP = 204,
+}
+
+export interface Rtcp {
+  readonly version: number
+  readonly padding: boolean
+  readonly count: number
+  readonly packetType: RTCPPacketType | number
+  readonly length: number
+}
+
+export interface RtcpReportBlock {
+  readonly syncSource: number
+  readonly fractionLost: number
+  readonly cumulativeNumberOfPacketsLost: number
+  readonly extendedHighestSequenceNumberReceived: number
+  readonly interarrivalJitter: number
+  readonly lastSRTimestamp: number
+  readonly delaySinceLastSR: number
+}
+
+export interface RtcpSR extends Rtcp {
+  readonly version: RTCPPacketType.SR
+
+  readonly syncSource: number
+  readonly ntpMost: number
+  readonly ntpLeast: number
+  readonly rtpTimestamp: number
+  readonly sendersPacketCount: number
+  readonly sendersOctetCount: number
+  readonly reports: readonly RtcpReportBlock[]
+}
+
+export const isRtcpSR = (rtcp: Rtcp): rtcp is RtcpSR =>
+  rtcp.packetType === RTCPPacketType.SR
+
+export interface RtcpRR extends Rtcp {
+  readonly version: RTCPPacketType.RR
+
+  readonly syncSource: number
+  readonly reports: readonly RtcpReportBlock[]
+}
+
+export const isRtcpRR = (rtcp: Rtcp): rtcp is RtcpRR =>
+  rtcp.packetType === RTCPPacketType.RR
+
+export enum SDESItem {
+  CNAME = 1,
+  NAME = 2,
+  EMAIL = 3,
+  PHONE = 4,
+  LOC = 5,
+  TOOL = 6,
+  NOTE = 7,
+  PRIV = 8,
+}
+
+export interface RtcpSDESBlock {
+  readonly source: number
+  readonly items: Array<[number, string] | [SDESItem.PRIV, string, string]>
+}
+
+export interface RtcpSDES extends Rtcp {
+  readonly version: RTCPPacketType.SDES
+
+  readonly syncSource: number
+  readonly sourceDescriptions: readonly RtcpSDESBlock[]
+}
+
+export const isRtcpSDES = (rtcp: Rtcp): rtcp is RtcpSDES =>
+  rtcp.packetType === RTCPPacketType.SDES
+
+export interface RtcpBye extends Rtcp {
+  readonly version: RTCPPacketType.BYE
+
+  readonly sources: number[]
+  readonly reason?: string
+}
+
+export const isRtcpBye = (rtcp: Rtcp): rtcp is RtcpBye =>
+  rtcp.packetType === RTCPPacketType.BYE
+
+export interface RtcpApp extends Rtcp {
+  readonly version: RTCPPacketType.APP
+
+  readonly subtype: number
+  readonly source: number
+  readonly name: string
+  readonly data: Uint8Array
+}
+
+export const isRtcpApp = (rtcp: Rtcp): rtcp is RtcpApp =>
+  rtcp.packetType === RTCPPacketType.APP
diff --git a/streams/src/components/types/rtp.ts b/streams/src/components/types/rtp.ts
new file mode 100644
index 000000000..be339f6e2
--- /dev/null
+++ b/streams/src/components/types/rtp.ts
@@ -0,0 +1,29 @@
+import { Message } from './message'
+
+export class RtpMessage extends Message<'rtp'> {
+  readonly channel: number
+  readonly data: Uint8Array
+  readonly marker: boolean
+  readonly payloadType: number
+  readonly timestamp: number
+  ntpTimestamp?: number
+
+  constructor({
+    channel,
+    data,
+    marker,
+    payloadType,
+    timestamp,
+  }: Pick<
+    RtpMessage,
+    'channel' | 'data' | 'marker' | 'payloadType' | 'timestamp'
+  >) {
+    super('rtp')
+
+    this.channel = channel
+    this.data = data
+    this.marker = marker
+    this.payloadType = payloadType
+    this.timestamp = timestamp
+  }
+}
diff --git a/streams/src/components/types/rtsp.ts b/streams/src/components/types/rtsp.ts
new file mode 100644
index 000000000..4a771b1c2
--- /dev/null
+++ b/streams/src/components/types/rtsp.ts
@@ -0,0 +1,67 @@
+import { Message } from './message'
+
+export class RtspResponseMessage extends Message<'rtsp_rsp'> {
+  readonly body?: Uint8Array
+  readonly headers: Headers
+  readonly statusCode: number
+
+  constructor({
+    body,
+    headers,
+    statusCode,
+  }: Pick) {
+    super('rtsp_rsp')
+
+    this.body = body
+    this.headers = headers
+    this.statusCode = statusCode
+  }
+
+  toString() {
+    return `S->C: ${this.statusCode}\n${[...this.headers.entries()].map(([h, v]) => `${h}: ${v}`).join('\n')}`
+  }
+}
+
+export type RtspRequestHeaders =
+  | {
+      CSeq?: number
+      Session?: string
+      Date?: string
+      Authorization?: string
+      Transport?: string
+      Range?: string
+    }
+  | { [key: string]: string }
+
+export type RtspRequestMethod =
+  | 'OPTIONS'
+  | 'DESCRIBE'
+  | 'SETUP'
+  | 'PLAY'
+  | 'PAUSE'
+  | 'TEARDOWN'
+
+export class RtspRequestMessage extends Message<'rtsp_req'> {
+  readonly method: RtspRequestMethod
+  readonly uri: string
+  readonly headers: RtspRequestHeaders
+  readonly protocol?: string
+
+  constructor({
+    headers,
+    method,
+    protocol,
+    uri,
+  }: Pick) {
+    super('rtsp_req')
+
+    this.headers = headers
+    this.method = method
+    this.protocol = protocol
+    this.uri = uri
+  }
+
+  toString() {
+    return `C->S: ${this.method} ${this.uri}\n${[...Object.entries(this.headers)].map(([h, v]) => `${h}: ${v}`).join('\n')}`
+  }
+}
diff --git a/streams/src/components/types/sdp.ts b/streams/src/components/types/sdp.ts
new file mode 100644
index 000000000..0df1126fb
--- /dev/null
+++ b/streams/src/components/types/sdp.ts
@@ -0,0 +1,257 @@
+import { Message } from './message'
+
+import { NtpSeconds, seconds } from './ntp'
+
+/**
+ * The session description protocol (SDP) message
+ * carries all data related to an RTSP session.
+ *
+ * NOTE: not all SDP attributes have been implemented,
+ * and in some cases the handling of attributes has been
+ * simplified to not cover multiple identical attributes.
+ */
+
+export class SdpMessage extends Message<'sdp'> {
+  readonly session: SessionDescription
+  readonly media: MediaDescription[]
+
+  constructor({ session, media }: Sdp) {
+    super('sdp')
+
+    this.session = session
+    this.media = media
+  }
+}
+
+export interface Sdp {
+  readonly session: SessionDescription
+  readonly media: MediaDescription[]
+}
+
+// RTSP extensions: https://tools.ietf.org/html/rfc7826 (22.15)
+// exists on both session and media level
+interface RtspExtensions {
+  readonly range?: string
+  readonly control?: string
+  readonly mtag?: string
+}
+
+/**
+ * Session description
+ *
+ * Optional items are marked with a '*'.
+ *
+ * v=  (protocol version)
+ * o=  (owner/creator and session identifier).
+ * s=  (session name)
+ * i=* (session information)
+ * u=* (URI of description)
+ * e=* (email address)
+ * p=* (phone number)
+ * c=* (connection information - not required if included in all media)
+ * b=* (bandwidth information)
+ * One or more time descriptions (see below)
+ * z=* (time zone adjustments)
+ * k=* (encryption key)
+ * a=* (zero or more session attribute lines)
+ * Zero or more media descriptions (see below)
+ *
+ * Names of the fields below are annotated above with
+ * the names used in Appendix A: SDP Grammar of RFC 2327.
+ */
+export interface SessionDescription extends RtspExtensions {
+  // v=0
+  readonly version: 0
+  // o=     
+  readonly originField: OriginField
+  // s=
+  readonly name: string
+  // i=
+  readonly description?: string
+  // u=
+  readonly uri?: string
+  // e=
+  readonly email?: string | string[]
+  // p=
+  readonly phone?: string | string[]
+  // c=  
+  readonly connection?: ConnectionField
+  // b=:
+  readonly bandwidth?: BandwidthField
+  // One or more time descriptions
+  readonly time: TimeDescription
+  readonly repeatTimes?: RepeatTimeDescription
+  // Zero or more media descriptions
+  readonly media: MediaDescription[]
+}
+
+interface OriginField {
+  // o=     
+  username: string
+  sessionId: number
+  sessionVersion: number
+  networkType: 'IN'
+  addressType: 'IP4' | 'IP6'
+  address: string
+}
+
+interface ConnectionField {
+  // c=  
+  networkType: 'IN'
+  addressType: 'IP4' | 'IP6'
+  connectionAddress: string
+}
+
+interface BandwidthField {
+  readonly type: string
+  readonly value: number
+}
+
+/**
+ * Time description
+ *
+ * t=  (time the session is active)
+ * r=* (zero or more repeat times)
+ */
+export interface TimeDescription {
+  // t= 
+  readonly startTime: NtpSeconds
+  readonly stopTime: NtpSeconds
+}
+
+export interface RepeatTimeDescription {
+  // r=  
+  readonly repeatInterval: seconds
+  readonly activeDuration: seconds
+  readonly offsets: seconds[]
+}
+
+/**
+ * Media description
+ *
+ * m=  (media name and transport address)
+ * i=* (media title)
+ * c=* (connection information -- optional if included at session level)
+ * b=* (zero or more bandwidth information lines)
+ * k=* (encryption key)
+ * a=* (zero or more media attribute lines)
+ *
+ * The parser only handles a single fmt value
+ * and only one rtpmap attribute (in theory there
+ * can be multiple fmt values with corresponding rtpmap
+ * attributes)
+ */
+export interface MediaDescription extends RtspExtensions {
+  // m=    ...
+  // m= /   ...
+  readonly type: 'audio' | 'video' | 'application' | 'data' | 'control'
+  readonly port: number
+  readonly protocol: 'udp' | 'RTP/AVP' | 'RTP/SAVP'
+  readonly fmt: number // Payload type(s)
+  readonly connection?: ConnectionField
+  readonly bandwidth?: BandwidthField
+  /**
+   * Any remaining attributes
+   * a=...
+   */
+  // a=rtpmap: / [/]
+  readonly rtpmap?: {
+    readonly clockrate: number
+    readonly encodingName: string
+    readonly payloadType: number
+  }
+  // a=fmtp: 
+  readonly fmtp: {
+    readonly format: string
+    readonly parameters: { [key: string]: any }
+  }
+  // Extra non-SDP properties
+  // TODO: refactor this away
+  mime?: string
+  codec?: any
+}
+
+export type TransformationMatrix = readonly [
+  readonly [number, number, number],
+  readonly [number, number, number],
+  readonly [number, number, number],
+]
+
+export interface VideoMedia extends MediaDescription {
+  readonly type: 'video'
+  readonly framerate?: number
+  // Transformation matrix
+  readonly transform?: TransformationMatrix
+  readonly 'x-sensor-transform'?: TransformationMatrix
+}
+
+export interface H264Media extends VideoMedia {
+  readonly rtpmap: {
+    readonly clockrate: number
+    readonly encodingName: 'H264'
+    readonly payloadType: number
+  }
+}
+
+export interface JpegMedia extends MediaDescription {
+  readonly framesize?: [number, number]
+  readonly rtpmap: {
+    readonly clockrate: number
+    readonly encodingName: 'JPEG'
+    readonly payloadType: number
+  }
+}
+
+export interface AudioMedia extends MediaDescription {
+  readonly type: 'audio'
+}
+
+export interface OnvifMedia extends MediaDescription {
+  readonly type: 'application'
+}
+
+export interface AACParameters {
+  readonly bitrate: string
+  readonly config: string
+  readonly indexdeltalength: string
+  readonly indexlength: string
+  readonly mode: 'AAC-hbr'
+  readonly 'profile-level-id': string
+  readonly sizelength: string
+  readonly streamtype: string
+  readonly ctsdeltalength: string
+  readonly dtsdeltalength: string
+  readonly randomaccessindication: string
+  readonly streamstateindication: string
+  readonly auxiliarydatasizelength: string
+}
+
+export interface AACMedia extends AudioMedia {
+  readonly fmtp: {
+    readonly format: 'AAC-hbr'
+    readonly parameters: AACParameters
+  }
+  readonly rtpmap: {
+    readonly clockrate: number
+    readonly encodingName: 'MPEG4-GENERIC'
+    readonly payloadType: number
+  }
+}
+
+// Type guards
+
+export function isAACMedia(media: MediaDescription): media is AACMedia {
+  return (
+    media.type === 'audio' &&
+    media.rtpmap?.encodingName === 'MPEG4-GENERIC' &&
+    media.fmtp.parameters.mode === 'AAC-hbr'
+  )
+}
+
+export function isH264Media(media: MediaDescription): media is H264Media {
+  return media.type === 'video' && media.rtpmap?.encodingName === 'H264'
+}
+
+export function isJpegMedia(media: MediaDescription): media is JpegMedia {
+  return media.type === 'video' && media.rtpmap?.encodingName === 'JPEG'
+}
diff --git a/streams/src/components/types/xml.ts b/streams/src/components/types/xml.ts
new file mode 100644
index 000000000..65bdbb2da
--- /dev/null
+++ b/streams/src/components/types/xml.ts
@@ -0,0 +1,22 @@
+import { Message } from './message'
+
+export class XmlMessage extends Message<'xml'> {
+  readonly data: Uint8Array
+  readonly ntpTimestamp?: number
+  readonly payloadType: number
+  readonly timestamp: number
+
+  constructor({
+    data,
+    ntpTimestamp,
+    payloadType,
+    timestamp,
+  }: Pick) {
+    super('xml')
+
+    this.data = data
+    this.ntpTimestamp = ntpTimestamp
+    this.payloadType = payloadType
+    this.timestamp = timestamp
+  }
+}
diff --git a/streams/src/utils/bits.ts b/streams/src/components/utils/bits.ts
similarity index 100%
rename from streams/src/utils/bits.ts
rename to streams/src/components/utils/bits.ts
diff --git a/streams/src/utils/bytes.ts b/streams/src/components/utils/bytes.ts
similarity index 100%
rename from streams/src/utils/bytes.ts
rename to streams/src/components/utils/bytes.ts
diff --git a/streams/src/utils/clamp.ts b/streams/src/components/utils/clamp.ts
similarity index 100%
rename from streams/src/utils/clamp.ts
rename to streams/src/components/utils/clamp.ts
diff --git a/streams/src/utils/clock.ts b/streams/src/components/utils/clock.ts
similarity index 100%
rename from streams/src/utils/clock.ts
rename to streams/src/components/utils/clock.ts
diff --git a/streams/src/components/utils/index.ts b/streams/src/components/utils/index.ts
new file mode 100644
index 000000000..69006f9f8
--- /dev/null
+++ b/streams/src/components/utils/index.ts
@@ -0,0 +1 @@
+export * from './scheduler'
diff --git a/streams/src/utils/scheduler.ts b/streams/src/components/utils/scheduler.ts
similarity index 94%
rename from streams/src/utils/scheduler.ts
rename to streams/src/components/utils/scheduler.ts
index 4d29ba173..0d9664b14 100644
--- a/streams/src/utils/scheduler.ts
+++ b/streams/src/components/utils/scheduler.ts
@@ -36,8 +36,8 @@ export class Scheduler {
   private readonly _clock: Clock
   private readonly _handler: (msg: T) => void
   private readonly _tolerance: number
-  private _nextRun: number
-  private _nextPlay: number
+  private _nextRun?: ReturnType
+  private _nextPlay?: ReturnType
   private _fifo: T[]
   private _ntpPresentationTime: number
   private _suspended: boolean
@@ -56,8 +56,6 @@ export class Scheduler {
     this._clock = clock
     this._handler = handler
     this._tolerance = tolerance
-    this._nextRun = 0
-    this._nextPlay = 0
     this._fifo = []
     this._ntpPresentationTime = 0
     this._suspended = false
@@ -167,18 +165,12 @@ export class Scheduler {
       // is called.
       clearTimeout(this._nextPlay)
       this._clock.pause()
-      this._nextPlay = window.setTimeout(
-        () => this._clock.play(),
-        -timeToPresent
-      )
+      this._nextPlay = setTimeout(() => this._clock.play(), -timeToPresent)
     } else if (timeToPresent > this._tolerance) {
       // message is later than video, add it back to the queue and
       // re-run the scheduling at a later point in time
       this._fifo.unshift(currentMessage)
-      this._nextRun = window.setTimeout(
-        () => this.run(undefined),
-        timeToPresent
-      )
+      this._nextRun = setTimeout(() => this.run(undefined), timeToPresent)
     }
   }
 }
diff --git a/streams/src/components/utils/streams.ts b/streams/src/components/utils/streams.ts
new file mode 100644
index 000000000..02b92447d
--- /dev/null
+++ b/streams/src/components/utils/streams.ts
@@ -0,0 +1,43 @@
+export function producer(messages: T[]) {
+  let counter = 0
+  return new ReadableStream({
+    pull(controller: ReadableStreamDefaultController) {
+      try {
+        if (counter < messages.length) {
+          controller.enqueue(messages[counter++])
+        } else {
+          controller.close()
+        }
+      } catch (err) {
+        controller.error(err)
+      }
+    },
+  })
+}
+
+export function consumer(fn: (msg: T) => void = () => {}) {
+  return new WritableStream({
+    write(msg: T, controller) {
+      try {
+        fn(msg)
+      } catch (err) {
+        controller.error(err)
+      }
+    },
+    abort(reason) {
+      console.error('consumer aborted:', reason)
+    },
+  })
+}
+
+export function peeker(fn: (msg: T) => void) {
+  if (typeof fn !== 'function') {
+    throw new Error('you must supply a function')
+  }
+  return new TransformStream({
+    transform(msg: T, controller: TransformStreamDefaultController) {
+      fn(msg)
+      controller.enqueue(msg)
+    },
+  })
+}
diff --git a/streams/src/components/ws-sink/index.ts b/streams/src/components/ws-sink/index.ts
deleted file mode 100644
index 83125460b..000000000
--- a/streams/src/components/ws-sink/index.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import { Readable, Writable } from 'stream'
-
-import { Sink } from '../component'
-import { MessageType } from '../message'
-
-/**
- * The socket used here is a ws socket returned by
- * a ws Server's 'connection' event.
- */
-
-export class WSSink extends Sink {
-  constructor(socket: any) {
-    const outgoing = new Readable({
-      objectMode: true,
-      read: () => {
-        /** noop */
-      },
-    })
-
-    const incoming = new Writable({
-      objectMode: true,
-      write: (msg, encoding, callback) => {
-        try {
-          socket.send(msg.data)
-        } catch (e) {
-          console.warn('message lost during send:', msg)
-        }
-        callback()
-      },
-    })
-
-    socket.on('message', function (data: Uint8Array) {
-      outgoing.push({ data, type: MessageType.RAW })
-    })
-
-    socket.on('close', function () {
-      outgoing.push(null)
-    })
-    socket.on('error', (e: Error) => {
-      console.error('WebSocket error:', e)
-      socket.terminate()
-      outgoing.push(null)
-    })
-
-    // When an error is sent on the incoming stream, close the socket.
-    incoming.on('error', (e) => {
-      console.log('closing WebSocket due to incoming error', e)
-      socket && socket.close && socket.close()
-    })
-
-    // When there is no more data going to be sent, close!
-    incoming.on('finish', () => {
-      socket && socket.close && socket.close()
-    })
-
-    // When an error happens on the outgoing stream, just warn.
-    outgoing.on('error', (e) => {
-      console.warn('error during WebSocket send, ignoring:', e)
-    })
-
-    // When there is no more data going to be written, close!
-    outgoing.on('finish', () => {
-      socket && socket.close && socket.close()
-    })
-
-    /**
-     * initialize the component.
-     */
-    super(incoming, outgoing)
-  }
-}
diff --git a/streams/src/components/ws-source.ts b/streams/src/components/ws-source.ts
new file mode 100644
index 000000000..e09570408
--- /dev/null
+++ b/streams/src/components/ws-source.ts
@@ -0,0 +1,81 @@
+// Named status codes for CloseEvent
+
+import { logWarn } from '../log'
+
+// https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code
+const CLOSE_NORMAL_CLOSURE = 1000
+const CLOSE_GOING_AWAY = 1001
+const CLOSE_ABORTED = 4000
+
+/**
+ * WebSocket source.
+ *
+ * Sets up a readable and writable stream of raw messages
+ * connected to the provided WebSocket. The socket has to
+ * have binaryType "ArrayBuffer".
+ */
+export class WSSource {
+  public readable: ReadableStream
+  public writable: WritableStream
+
+  constructor(socket: WebSocket) {
+    if (socket === undefined) {
+      throw new Error('socket argument missing')
+    }
+
+    if (socket.binaryType !== 'arraybuffer') {
+      throw new Error('socket must be of binaryType "arraybuffer"')
+    }
+
+    this.readable = new ReadableStream({
+      start: (controller) => {
+        socket.addEventListener(
+          'message',
+          (e: MessageEvent) => {
+            controller.enqueue(new Uint8Array(e.data))
+          }
+        )
+        socket.addEventListener('close', (e) => {
+          if (e.code === CLOSE_GOING_AWAY) {
+            logWarn('server closed connection')
+          }
+          controller.close()
+        })
+      },
+      cancel: () => {
+        logWarn('canceling WebSocket client')
+        socket.close(CLOSE_ABORTED, 'client canceled')
+      },
+    })
+
+    this.writable = new WritableStream({
+      start: (controller) => {
+        socket.addEventListener('close', (e) => {
+          controller.error(`WebSocket closed with code ${e.code}`)
+        })
+        socket.addEventListener('error', () => {
+          controller.error('WebSocket errored')
+        })
+      },
+      write: (chunk) => {
+        try {
+          socket.send(chunk)
+        } catch (err) {
+          logWarn('message lost during send:', err)
+        }
+      },
+      close: () => {
+        if (socket.readyState !== WebSocket.CLOSED) {
+          logWarn('closing WebSocket client')
+          socket.close(CLOSE_NORMAL_CLOSURE)
+        }
+      },
+      abort: (reason) => {
+        if (socket.readyState !== WebSocket.CLOSED) {
+          logWarn('aborting WebSocket client:', reason && reason.message)
+          socket.close(CLOSE_ABORTED, reason && reason.message)
+        }
+      },
+    })
+  }
+}
diff --git a/streams/src/components/ws-source/index.ts b/streams/src/components/ws-source/index.ts
deleted file mode 100644
index d621646a4..000000000
--- a/streams/src/components/ws-source/index.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import debug from 'debug'
-
-import { Readable, Writable } from 'stream'
-
-import { Source } from '../component'
-import { MessageType } from '../message'
-
-import { WSConfig, openWebSocket } from './openwebsocket'
-
-// Named status codes for CloseEvent, see:
-// https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent
-const CLOSE_GOING_AWAY = 1001
-
-export class WSSource extends Source {
-  public onServerClose?: () => void
-
-  /**
-   * Create a WebSocket component.
-   *
-   * The constructor sets up two streams and connects them to the socket as
-   * soon as the socket is available (and open).
-   *
-   * @param socket - an open WebSocket.
-   */
-  constructor(socket: WebSocket) {
-    if (socket === undefined) {
-      throw new Error('socket argument missing')
-    }
-
-    /**
-     * Set up an incoming stream and attach it to the socket.
-     * @type {Readable}
-     */
-    const incoming = new Readable({
-      objectMode: true,
-      read() {
-        //
-      },
-    })
-
-    socket.onmessage = (msg) => {
-      const buffer = new Uint8Array(msg.data)
-      if (!incoming.push({ data: buffer, type: MessageType.RAW })) {
-        // Something happened down stream that it is no longer processing the
-        // incoming data, and the stream buffer got full. In this case it is
-        // best to just close the socket instead of throwing away data in the
-        // hope that the situation will get resolved.
-        if (socket.readyState === WebSocket.OPEN) {
-          debug('msl:websocket:incoming')('downstream frozen')
-          socket.close()
-        }
-      }
-    }
-
-    // When an error is sent on the incoming stream, close the socket.
-    incoming.on('error', (e) => {
-      console.warn('closing socket due to incoming error', e)
-      socket.close()
-    })
-
-    /**
-     * Set up outgoing stream and attach it to the socket.
-     * @type {Writable}
-     */
-    const outgoing = new Writable({
-      objectMode: true,
-      write(msg, encoding, callback) {
-        try {
-          socket.send(msg.data)
-        } catch (e) {
-          console.warn('message lost during send:', msg)
-        }
-        callback()
-      },
-    })
-
-    // When an error happens on the outgoing stream, just warn.
-    outgoing.on('error', (e) => {
-      console.warn('error during websocket send, ignoring:', e)
-    })
-
-    // When there is no more data going to be written, close!
-    outgoing.on('finish', () => {
-      debug('msl:websocket:outgoing')('finish')
-      if (socket.readyState !== WebSocket.CLOSED) {
-        socket.close()
-      }
-    })
-
-    /**
-     * Handler for when WebSocket is CLOSED
-     * @param  e - The event associated with a close
-     * @param  e.code The status code sent by the server
-     *   Possible codes are documented here:
-     *   https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent
-     */
-    socket.onclose = (e) => {
-      debug('msl:websocket:close')(`${e.code}`)
-      if (e.code === CLOSE_GOING_AWAY) {
-        this.onServerClose && this.onServerClose()
-      }
-      // Terminate the streams.
-      incoming.push(null)
-      outgoing.end()
-    }
-
-    /**
-     * initialize the component.
-     */
-    super(incoming, outgoing)
-  }
-
-  /**
-   * Expose websocket opener as a class method that returns a promise which
-   * resolves with a new WebSocketComponent.
-   */
-  static async open(config?: WSConfig) {
-    return await openWebSocket(config).then((socket) => new WSSource(socket))
-  }
-}
diff --git a/streams/src/utils/config.ts b/streams/src/config.ts
similarity index 100%
rename from streams/src/utils/config.ts
rename to streams/src/config.ts
diff --git a/streams/src/defaults.ts b/streams/src/defaults.ts
new file mode 100644
index 000000000..730f00885
--- /dev/null
+++ b/streams/src/defaults.ts
@@ -0,0 +1,37 @@
+/** Generates an Axis RTSP URI for a hostname (no default parameters) */
+export function axisRtspMediaUri(
+  hostname: string = typeof window === 'undefined'
+    ? ''
+    : window.location.hostname,
+  parameters: string[] = []
+) {
+  return parameters.length > 0
+    ? `rtsp://${hostname}/axis-media/media.amp?${parameters.join('&')}`
+    : `rtsp://${hostname}/axis-media/media.amp`
+}
+
+/** Generates an Axis RTSP URI for a hostname with parameters such
+ * that only events are streamed (no video or audio), suitable for
+ * pure metadata streaming */
+export function axisRtspMetadataUri(
+  hostname: string = typeof window === 'undefined'
+    ? ''
+    : window.location.hostname
+) {
+  return axisRtspMediaUri(hostname, [
+    'audio=0',
+    'video=0',
+    'event=on',
+    'ptz=all',
+  ])
+}
+
+/** Generates an Axis rtsp-over-websocket configuration with a
+ * WebSocket URI and a token URI (for authentication) */
+export function axisWebSocketConfig(href = window.location.href) {
+  const url = new URL(href)
+  const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
+  const uri = new URL(`${wsProtocol}//${url.host}/rtsp-over-websocket`).href
+  const tokenUri = new URL('/axis-cgi/rtspwssession.cgi', url).href
+  return { uri, tokenUri, protocol: 'binary' }
+}
diff --git a/streams/src/fetch-sdp.ts b/streams/src/fetch-sdp.ts
new file mode 100644
index 000000000..5659886fe
--- /dev/null
+++ b/streams/src/fetch-sdp.ts
@@ -0,0 +1,36 @@
+import { Sdp } from './components/types'
+
+import { WebSocketConfig, openWebSocket } from './openwebsocket'
+
+import { RtspConfig, RtspSession, WSSource } from './components'
+import { consumer } from './components/utils/streams'
+
+export interface TransformConfig {
+  ws: WebSocketConfig
+  rtsp: RtspConfig
+}
+
+/**
+ * fetchSdp sends a DESCRIBE command to an RTSP server and then
+ * immediately tears down the RTSP session, returning the SDP
+ * information contained in the RTSP response.
+ */
+export async function fetchSdp(config: TransformConfig): Promise {
+  const { ws: wsConfig, rtsp: rtspConfig } = config
+
+  const rtsp = new RtspSession(rtspConfig)
+  const socket = await openWebSocket(wsConfig)
+  const wsSource = new WSSource(socket)
+
+  const drained = Promise.allSettled([
+    wsSource.readable.pipeThrough(rtsp.demuxer).pipeTo(consumer()),
+    rtsp.commands.pipeTo(wsSource.writable),
+  ])
+
+  const sdp = await rtsp.describe()
+
+  socket.close(1000)
+  await drained
+
+  return sdp
+}
diff --git a/streams/src/http-mp4-pipeline.ts b/streams/src/http-mp4-pipeline.ts
new file mode 100644
index 000000000..ca88e579b
--- /dev/null
+++ b/streams/src/http-mp4-pipeline.ts
@@ -0,0 +1,75 @@
+import { Adapter, IsomMessage, MseSink } from './components'
+
+export interface HttpMp4Config {
+  uri: string
+  options?: RequestInit
+  mediaElement: HTMLVideoElement
+}
+
+/*
+ * HttpMsePipeline
+ *
+ * A pipeline that connects to an HTTP server and can process an MP4 data stream
+ * that is then sent to a HTML video element
+ *
+ * Handlers that can be set on the pipeline:
+ * - `onServerClose`: called when the server closes the connection
+ */
+export class HttpMp4Pipeline {
+  public onHeaders?: (headers: Headers) => void
+  public onServerClose?: () => void
+  /** Initiates the stream and resolves when the media stream has completed */
+  public start: () => Promise
+
+  private _mediaElement: HTMLVideoElement
+  private _abortController: AbortController
+  private _downloadedBytes: number = 0
+
+  constructor(config: HttpMp4Config) {
+    const { uri, options, mediaElement } = config
+
+    this._mediaElement = mediaElement
+    this._abortController = new AbortController()
+
+    this.start = () =>
+      fetch(uri, { signal: this._abortController.signal, ...options })
+        .then(({ headers, body }) => {
+          const mimeType = headers.get('Content-Type')
+          if (!mimeType) {
+            throw new Error('missing MIME type in HTTP response headers')
+          }
+          if (body === null) {
+            throw new Error('missing body in HTTP response')
+          }
+          const adapter = new Adapter((chunk) => {
+            this._downloadedBytes += chunk.byteLength
+            return new IsomMessage({ data: chunk })
+          })
+          const mseSink = new MseSink(mediaElement, mimeType)
+          return body.pipeThrough(adapter).pipeTo(mseSink.writable)
+        })
+        .catch((err) => {
+          console.error('failed to stream media:', err)
+        })
+  }
+
+  close() {
+    this._abortController.abort()
+  }
+
+  get currentTime() {
+    return this._mediaElement.currentTime
+  }
+
+  play() {
+    return this._mediaElement.play()
+  }
+
+  pause() {
+    return this._mediaElement.pause()
+  }
+
+  byteLength() {
+    return this._downloadedBytes
+  }
+}
diff --git a/streams/src/index.browser.ts b/streams/src/index.browser.ts
deleted file mode 100644
index db772b2a0..000000000
--- a/streams/src/index.browser.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import * as components from './components/index.browser'
-import * as pipelines from './pipelines/index.browser'
-import * as utils from './utils/index.browser'
-
-export { components, pipelines, utils }
-
-export * from './components/index.browser'
-export * from './pipelines/index.browser'
-export * from './utils/index.browser'
diff --git a/streams/src/index.node.ts b/streams/src/index.node.ts
deleted file mode 100644
index 2d0b28e9a..000000000
--- a/streams/src/index.node.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import * as components from './components/index.node'
-import * as pipelines from './pipelines/index.node'
-import * as utils from './utils/index.node'
-
-export { components, pipelines, utils }
-
-export * from './components/index.node'
-export * from './pipelines/index.node'
-export * from './utils/index.node'
diff --git a/streams/src/index.ts b/streams/src/index.ts
new file mode 100644
index 000000000..508f219aa
--- /dev/null
+++ b/streams/src/index.ts
@@ -0,0 +1,8 @@
+export * from './components'
+export * from './defaults'
+export * from './fetch-sdp'
+export * from './http-mp4-pipeline'
+export * from './metadata-pipeline'
+export * from './openwebsocket'
+export * from './rtsp-jpeg-pipeline'
+export * from './rtsp-mp4-pipeline'
diff --git a/streams/src/log.ts b/streams/src/log.ts
new file mode 100644
index 000000000..99871a4f7
--- /dev/null
+++ b/streams/src/log.ts
@@ -0,0 +1,58 @@
+/**
+ * Logging utiltities
+ *
+ * Provides functions to log objects without serialization overhead it
+ * the logs are not active.
+ *
+ * To activate logs, write `localStorage.setItem('msl-player-debug', 'true')` in
+ * the console. Set to false or remove to disable logs.
+ * Errors are always logged.
+ */
+
+const key = 'msl-streams-debug'
+
+let active = false
+try {
+  active = Boolean(JSON.parse(localStorage.getItem(key) ?? 'false'))
+} catch {}
+
+if (active) {
+  console.warn(
+    `${key} logs are active, use localStorage.removeItem('${key}') to deactivate`
+  )
+}
+
+const styles = {
+  blue: 'color: light-dark(#0f2b45, #7fb3e0);',
+}
+
+let last = performance.now()
+function out(level: 'debug' | 'error' | 'info' | 'warn', ...args: unknown[]) {
+  const now = performance.now()
+  const elapsed = now - last
+  last = now
+  console[level](
+    `%c[+${elapsed}ms]`,
+    styles.blue,
+    ...args.map((arg) => `${arg}`)
+  )
+}
+
+export function logDebug(...args: unknown[]) {
+  if (!active) return
+  out('debug', ...args)
+}
+
+export function logError(...args: unknown[]) {
+  out('error', ...args)
+}
+
+export function logInfo(...args: unknown[]) {
+  if (!active) return
+  out('info', ...args)
+}
+
+export function logWarn(...args: unknown[]) {
+  if (!active) return
+  out('warn', ...args)
+}
diff --git a/streams/src/metadata-pipeline.ts b/streams/src/metadata-pipeline.ts
new file mode 100644
index 000000000..62046dd5a
--- /dev/null
+++ b/streams/src/metadata-pipeline.ts
@@ -0,0 +1,89 @@
+import { logDebug } from './log'
+
+import {
+  Message,
+  MessageType,
+  RtpDepay,
+  RtspConfig,
+  RtspSession,
+  WSSource,
+  XmlMessage,
+} from './components'
+
+import { WebSocketConfig, openWebSocket } from './openwebsocket'
+
+export interface WsRtspMetadataConfig {
+  ws: WebSocketConfig
+  rtsp: RtspConfig
+  metadataHandler: (msg: XmlMessage) => void
+}
+
+/** Creates a writable stream that consumes XML messages by
+ * passing them to the provided handler. */
+class XmlSink extends WritableStream<
+  XmlMessage | Message>
+> {
+  constructor(metadataHandler: (msg: XmlMessage) => void) {
+    super({
+      write: (msg, controller) => {
+        if (msg.type === 'xml') {
+          try {
+            metadataHandler(msg)
+          } catch (err) {
+            controller.error(err)
+          }
+        }
+      },
+    })
+  }
+}
+
+/*
+ * MetadataPipeline
+ *
+ * A pipeline that connects to an RTSP server over a WebSocket connection and
+ * can process XML RTP data and calls a handler to process the XML messages.
+ *
+ * Handlers that can be set on the pipeline:
+ * - all handlers inherited from the RtspPipeline
+ * - `onServerClose`: called when the WebSocket server closes the connection
+ *   (only then, not when the connection is closed in a different way)
+ */
+export class MetadataPipeline {
+  public readonly rtp = new RtpDepay()
+  public readonly rtsp: RtspSession
+  public readonly xml: XmlSink
+
+  private readonly socket: Promise
+
+  constructor(config: WsRtspMetadataConfig) {
+    const { ws: wsConfig, rtsp: rtspConfig, metadataHandler } = config
+
+    this.rtsp = new RtspSession(rtspConfig)
+    this.socket = openWebSocket(wsConfig)
+    this.xml = new XmlSink(metadataHandler)
+  }
+
+  /** Initiates the stream (starting at optional offset in seconds) and resolves
+   * when the media stream has completed. */
+  public async start(): Promise {
+    const socket = await this.socket
+    const wsSource = new WSSource(socket)
+    Promise.allSettled([
+      wsSource.readable
+        .pipeThrough(this.rtsp.demuxer)
+        .pipeThrough(this.rtp)
+        .pipeTo(this.xml),
+      this.rtsp.commands.pipeTo(wsSource.writable),
+    ]).then((results) => {
+      const [down, up] = results.map((r) =>
+        r.status === 'rejected' ? r.reason : 'stream ended'
+      )
+      logDebug(`metadata pipeline ended: downstream: ${down} upstream: ${up}`)
+    })
+  }
+
+  close() {
+    this.socket.then((socket) => socket.close)
+  }
+}
diff --git a/streams/src/components/ws-source/openwebsocket.ts b/streams/src/openwebsocket.ts
similarity index 65%
rename from streams/src/components/ws-source/openwebsocket.ts
rename to streams/src/openwebsocket.ts
index acccb1526..b3b1ecaf5 100644
--- a/streams/src/components/ws-source/openwebsocket.ts
+++ b/streams/src/openwebsocket.ts
@@ -1,50 +1,22 @@
-import { merge } from '../../utils/config'
-
 // Time in milliseconds we want to wait for a websocket to open
 const WEBSOCKET_TIMEOUT = 10007
 
-export interface WSConfig {
-  host?: string
-  scheme?: string
-  uri?: string
+export interface WebSocketConfig {
+  uri: string
   tokenUri?: string
   protocol?: string
   timeout?: number
 }
 
-// Default configuration
-const defaultConfig = (
-  host: string = window.location.host,
-  scheme: string = window.location.protocol
-): WSConfig => {
-  const wsScheme = scheme === 'https:' ? 'wss:' : 'ws:'
-
-  return {
-    uri: `${wsScheme}//${host}/rtsp-over-websocket`,
-    tokenUri: `${scheme}//${host}/axis-cgi/rtspwssession.cgi`,
-    protocol: 'binary',
-    timeout: WEBSOCKET_TIMEOUT,
-  }
-}
-
 /**
  * Open a new WebSocket, fallback to token-auth on failure and retry.
- * @param  [config]  WebSocket configuration.
- * @param  [config.host]  Specify different host
- * @param  [config.sheme]  Specify different scheme.
- * @param  [config.uri]  Full uri for websocket connection
- * @param  [config.tokenUri]  Full uri for token API
- * @param  [config.protocol] Websocket protocol
- * @param  [config.timeout] Websocket connection timeout
  */
-export const openWebSocket = async (
-  config: WSConfig = {}
-): Promise => {
-  const { uri, tokenUri, protocol, timeout } = merge(
-    defaultConfig(config.host, config.scheme),
-    config
-  )
-
+export const openWebSocket = async ({
+  uri,
+  tokenUri,
+  protocol = 'binary',
+  timeout = WEBSOCKET_TIMEOUT,
+}: WebSocketConfig): Promise => {
   if (uri === undefined) {
     throw new Error('ws: internal error')
   }
@@ -62,6 +34,12 @@ export const openWebSocket = async (
       ws.binaryType = 'arraybuffer'
       ws.onerror = (originalError: Event) => {
         clearTimeout(countdown)
+        if (!tokenUri) {
+          console.warn(
+            'websocket open failed and no token URI specified, quiting'
+          )
+          reject(originalError)
+        }
         // try fetching an authentication token
         function onLoadToken(this: XMLHttpRequest) {
           if (this.status >= 400) {
diff --git a/streams/src/pipelines/cli-mjpeg-pipeline.ts b/streams/src/pipelines/cli-mjpeg-pipeline.ts
deleted file mode 100644
index ee4b069b6..000000000
--- a/streams/src/pipelines/cli-mjpeg-pipeline.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { Auth, AuthConfig } from '../components/auth'
-import { Sink } from '../components/component'
-import { MessageType } from '../components/message'
-import { RtspConfig } from '../components/rtsp-session'
-import { TcpSource } from '../components/tcp'
-
-import { RtspMjpegPipeline } from './rtsp-mjpeg-pipeline'
-
-interface RtspAuthConfig {
-  rtsp?: RtspConfig
-  auth?: AuthConfig
-}
-
-/**
- * CliMjpegPipeline
- *
- * A pipeline which connects to an RTSP server over TCP and can process JPEG
- * over RTP data producing a stream of JPEG images.
- */
-export class CliMjpegPipeline extends RtspMjpegPipeline {
-  constructor(config: RtspAuthConfig) {
-    const { rtsp: rtspConfig, auth: authConfig } = config
-
-    super(rtspConfig)
-
-    const auth = new Auth(authConfig)
-    this.insertBefore(this.rtsp, auth)
-
-    const tcpSource = new TcpSource()
-
-    const dataSaver = process.stdout.isTTY
-      ? (msg: any) => console.log(msg.type, msg.data)
-      : (msg: any) =>
-          msg.type === MessageType.JPEG && process.stdout.write(msg.data)
-    const videoSink = Sink.fromHandler(dataSaver)
-
-    this.prepend(tcpSource)
-    this.append(videoSink)
-  }
-}
diff --git a/streams/src/pipelines/cli-mp4-pipeline.ts b/streams/src/pipelines/cli-mp4-pipeline.ts
deleted file mode 100644
index 2c8ced735..000000000
--- a/streams/src/pipelines/cli-mp4-pipeline.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { Auth, AuthConfig } from '../components/auth'
-import { Sink } from '../components/component'
-import { MessageType } from '../components/message'
-import { RtspConfig } from '../components/rtsp-session'
-import { TcpSource } from '../components/tcp'
-
-import { RtspMp4Pipeline } from './rtsp-mp4-pipeline'
-
-interface RtspAuthConfig {
-  rtsp?: RtspConfig
-  auth?: AuthConfig
-}
-
-/**
- * CliMp4Pipeline
- *
- * A pipeline which connects to an RTSP server over TCP and process H.264/AAC
- * over RTP to produce a stream of MP4 data.
- */
-export class CliMp4Pipeline extends RtspMp4Pipeline {
-  constructor(config: RtspAuthConfig) {
-    const { rtsp: rtspConfig, auth: authConfig } = config
-
-    super(rtspConfig)
-
-    const auth = new Auth(authConfig)
-    this.insertBefore(this.rtsp, auth)
-
-    const tcpSource = new TcpSource()
-
-    const dataSaver = process.stdout.isTTY
-      ? (msg: any) => console.log(msg.type, msg.data)
-      : (msg: any) =>
-          msg.type === MessageType.ISOM && process.stdout.write(msg.data)
-    const videoSink = Sink.fromHandler(dataSaver)
-
-    this.prepend(tcpSource)
-    this.append(videoSink)
-  }
-}
diff --git a/streams/src/pipelines/html5-canvas-pipeline.ts b/streams/src/pipelines/html5-canvas-pipeline.ts
deleted file mode 100644
index e8d9f3554..000000000
--- a/streams/src/pipelines/html5-canvas-pipeline.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import { Auth, AuthConfig } from '../components/auth'
-import { CanvasSink } from '../components/canvas'
-import { RtspConfig } from '../components/rtsp-session'
-import { WSSource } from '../components/ws-source'
-import { WSConfig } from '../components/ws-source/openwebsocket'
-
-import { RtspMjpegPipeline } from './rtsp-mjpeg-pipeline'
-
-export interface Html5CanvasConfig {
-  ws?: WSConfig
-  rtsp?: RtspConfig
-  mediaElement: HTMLCanvasElement
-  auth?: AuthConfig
-}
-
-/**
- * Html5CanvasPipeline
- *
- * A pipeline that connects to an RTSP server over a WebSocket connection and
- * can process JPEG RTP data to produce an motion JPEG data stream that is sent
- * to a HTML canvas element.
- *
- * Handlers that can be set on the pipeline:
- * - all handlers inherited from the RtspMjpegPipeline
- * - `onSync`: called when the NTP time of the first frame is known, with the
- *   timestamp as argument (the timestamp is UNIX milliseconds)
- * - `onServerClose`: called when the WebSocket server closes the connection
- *   (only then, not when the connection is closed in a different way)
- */
-export class Html5CanvasPipeline extends RtspMjpegPipeline {
-  public onCanplay?: () => void
-  public onSync?: (ntpPresentationTime: number) => void
-  public onServerClose?: () => void
-  public ready: Promise
-
-  private _src?: WSSource
-  private readonly _sink: CanvasSink
-
-  constructor(config: Html5CanvasConfig) {
-    const {
-      ws: wsConfig,
-      rtsp: rtspConfig,
-      mediaElement,
-      auth: authConfig,
-    } = config
-
-    super(rtspConfig)
-
-    if (authConfig) {
-      const auth = new Auth(authConfig)
-      this.insertBefore(this.rtsp, auth)
-    }
-
-    const canvasSink = new CanvasSink(mediaElement)
-    canvasSink.onCanplay = () => {
-      canvasSink.play()
-      this.onCanplay && this.onCanplay()
-    }
-    canvasSink.onSync = (ntpPresentationTime) => {
-      this.onSync && this.onSync(ntpPresentationTime)
-    }
-    this.append(canvasSink)
-    this._sink = canvasSink
-
-    const waitForWs = WSSource.open(wsConfig)
-    this.ready = waitForWs.then((wsSource) => {
-      wsSource.onServerClose = () => {
-        this.onServerClose && this.onServerClose()
-      }
-      this.prepend(wsSource)
-      this._src = wsSource
-    })
-  }
-
-  close() {
-    this.rtsp.stop()
-    this._src && this._src.outgoing.end()
-  }
-
-  get currentTime() {
-    return this._sink.currentTime
-  }
-
-  play() {
-    return this._sink.play()
-  }
-
-  pause() {
-    return this._sink.pause()
-  }
-
-  get bitrate() {
-    return this._sink.bitrate
-  }
-
-  get framerate() {
-    return this._sink.framerate
-  }
-}
diff --git a/streams/src/pipelines/html5-video-metadata-pipeline.ts b/streams/src/pipelines/html5-video-metadata-pipeline.ts
deleted file mode 100644
index a3da8df59..000000000
--- a/streams/src/pipelines/html5-video-metadata-pipeline.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { Tube } from '../components/component'
-import { MessageType, XmlMessage } from '../components/message'
-import { ONVIFDepay } from '../components/onvifdepay'
-
-import { Html5VideoConfig, Html5VideoPipeline } from './html5-video-pipeline'
-
-export interface Html5VideoMetadataConfig extends Html5VideoConfig {
-  metadataHandler: (msg: XmlMessage) => void
-}
-
-/*
- * Html5VideoPipeline
- *
- * A pipeline that connects to an RTSP server over a WebSocket connection and
- * can process H.264/AAC RTP data to produce an MP4 data stream that is sent to
- * a HTML video element.  Additionally, this pipeline passes XML metadata sent
- * in the same stream to a separate handler.
- *
- * Handlers that can be set on the pipeline:
- * - all handlers inherited from the Html5VideoPipeline
- */
-export class Html5VideoMetadataPipeline extends Html5VideoPipeline {
-  constructor(config: Html5VideoMetadataConfig) {
-    const { metadataHandler } = config
-
-    super(config)
-
-    const onvifDepay = new ONVIFDepay()
-    this.insertAfter(this.rtsp, onvifDepay)
-
-    const onvifHandlerPipe = Tube.fromHandlers((msg) => {
-      if (msg.type === MessageType.XML) {
-        metadataHandler(msg)
-      }
-    }, undefined)
-    this.insertAfter(onvifDepay, onvifHandlerPipe)
-  }
-}
diff --git a/streams/src/pipelines/html5-video-pipeline.ts b/streams/src/pipelines/html5-video-pipeline.ts
deleted file mode 100644
index 8e59a4148..000000000
--- a/streams/src/pipelines/html5-video-pipeline.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { Auth, AuthConfig } from '../components/auth'
-import { MseSink } from '../components/mse'
-import { RtspConfig } from '../components/rtsp-session'
-import { WSSource } from '../components/ws-source'
-import { WSConfig } from '../components/ws-source/openwebsocket'
-import { MediaTrack } from '../utils/protocols/isom'
-
-import { RtspMp4Pipeline } from './rtsp-mp4-pipeline'
-
-export interface Html5VideoConfig {
-  ws?: WSConfig
-  rtsp?: RtspConfig
-  mediaElement: HTMLVideoElement
-  auth?: AuthConfig
-}
-
-/*
- * Html5VideoPipeline
- *
- * A pipeline that connects to an RTSP server over a WebSocket connection and
- * can process H.264/AAC RTP data to produce an MP4 data stream that is sent to
- * a HTML video element.
- *
- * Handlers that can be set on the pipeline:
- * - all handlers inherited from the RtspMp4Pipeline
- * - `onServerClose`: called when the WebSocket server closes the connection
- *   (only then, not when the connection is closed in a different way)
- */
-export class Html5VideoPipeline extends RtspMp4Pipeline {
-  public onSourceOpen?: (mse: MediaSource, tracks: MediaTrack[]) => void
-  public onServerClose?: () => void
-  public ready: Promise
-  public tracks?: MediaTrack[]
-
-  private _src?: WSSource
-  private readonly _sink: MseSink
-
-  constructor(config: Html5VideoConfig) {
-    const {
-      ws: wsConfig,
-      rtsp: rtspConfig,
-      mediaElement,
-      auth: authConfig,
-    } = config
-
-    super(rtspConfig)
-
-    if (authConfig) {
-      const auth = new Auth(authConfig)
-      this.insertBefore(this.rtsp, auth)
-    }
-
-    const mseSink = new MseSink(mediaElement)
-    mseSink.onSourceOpen = (mse, tracks) => {
-      this.tracks = tracks
-      this.onSourceOpen && this.onSourceOpen(mse, tracks)
-    }
-    this.append(mseSink)
-    this._sink = mseSink
-
-    const waitForWs = WSSource.open(wsConfig)
-    this.ready = waitForWs.then((wsSource) => {
-      wsSource.onServerClose = () => {
-        this.onServerClose && this.onServerClose()
-      }
-      this.prepend(wsSource)
-      this._src = wsSource
-    })
-  }
-
-  close() {
-    this.rtsp.stop()
-    this._src && this._src.outgoing.end()
-  }
-
-  get currentTime() {
-    return this._sink.currentTime
-  }
-
-  async play() {
-    return await this._sink.play()
-  }
-
-  pause() {
-    return this._sink.pause()
-  }
-}
diff --git a/streams/src/pipelines/http-mse-pipeline.ts b/streams/src/pipelines/http-mse-pipeline.ts
deleted file mode 100644
index 743eaa59c..000000000
--- a/streams/src/pipelines/http-mse-pipeline.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { HttpConfig, HttpMp4Source } from '../components/http-mp4'
-import { MseSink } from '../components/mse'
-
-import { Pipeline } from './pipeline'
-
-export interface HttpMseConfig {
-  http: HttpConfig
-  mediaElement: HTMLVideoElement
-}
-
-/*
- * HttpMsePipeline
- *
- * A pipeline that connects to an HTTP server and can process an MP4 data stream
- * that is then sent to a HTML video element
- *
- * Handlers that can be set on the pipeline:
- * - `onServerClose`: called when the server closes the connection
- */
-export class HttpMsePipeline extends Pipeline {
-  public onHeaders?: (headers: Headers) => void
-  public onServerClose?: () => void
-  public http: HttpMp4Source
-
-  private readonly _src?: HttpMp4Source
-  private readonly _sink: MseSink
-
-  constructor(config: HttpMseConfig) {
-    const { http: httpConfig, mediaElement } = config
-
-    const httpSource = new HttpMp4Source(httpConfig)
-    const mseSink = new MseSink(mediaElement)
-
-    httpSource.onHeaders = (headers) => {
-      this.onHeaders && this.onHeaders(headers)
-    }
-
-    httpSource.onServerClose = () => this.onServerClose?.()
-
-    super(httpSource, mseSink)
-
-    this._src = httpSource
-    this._sink = mseSink
-
-    // Expose session for external use
-    this.http = httpSource
-  }
-
-  close() {
-    this._src && this._src.abort()
-  }
-
-  get currentTime() {
-    return this._sink.currentTime
-  }
-
-  async play() {
-    return await this._sink.play()
-  }
-
-  pause() {
-    return this._sink.pause()
-  }
-}
diff --git a/streams/src/pipelines/index.browser.ts b/streams/src/pipelines/index.browser.ts
deleted file mode 100644
index d0f373be9..000000000
--- a/streams/src/pipelines/index.browser.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-export * from './pipeline'
-
-export * from './rtsp-mjpeg-pipeline'
-export * from './rtsp-mp4-pipeline'
-export * from './rtsp-pipeline'
-
-export * from './html5-canvas-pipeline'
-export * from './html5-video-metadata-pipeline'
-export * from './html5-video-pipeline'
-export * from './metadata-pipeline'
-export * from './ws-sdp-pipeline'
-
-export * from './http-mse-pipeline'
diff --git a/streams/src/pipelines/index.node.ts b/streams/src/pipelines/index.node.ts
deleted file mode 100644
index 12c8e3407..000000000
--- a/streams/src/pipelines/index.node.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export * from './pipeline'
-
-export * from './rtsp-mjpeg-pipeline'
-export * from './rtsp-mp4-pipeline'
-export * from './rtsp-pipeline'
-
-export * from './cli-mjpeg-pipeline'
-export * from './cli-mp4-pipeline'
-export * from './tcp-ws-proxy-pipeline'
diff --git a/streams/src/pipelines/metadata-pipeline.ts b/streams/src/pipelines/metadata-pipeline.ts
deleted file mode 100644
index 11ee7e26d..000000000
--- a/streams/src/pipelines/metadata-pipeline.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { Sink } from '../components/component'
-import { MessageType, XmlMessage } from '../components/message'
-import { ONVIFDepay } from '../components/onvifdepay'
-import { RtspConfig } from '../components/rtsp-session'
-import { WSSource } from '../components/ws-source'
-import { WSConfig } from '../components/ws-source/openwebsocket'
-
-import { RtspPipeline } from './rtsp-pipeline'
-
-// Default configuration for XML event stream
-const DEFAULT_RTSP_PARAMETERS = {
-  parameters: ['audio=0', 'video=0', 'event=on', 'ptz=all'],
-}
-
-export interface WsRtspMetadataConfig {
-  ws?: WSConfig
-  rtsp?: RtspConfig
-  metadataHandler: (msg: XmlMessage) => void
-}
-
-/*
- * MetadataPipeline
- *
- * A pipeline that connects to an RTSP server over a WebSocket connection and
- * can process XML RTP data and calls a handler to process the XML messages.
- *
- * Handlers that can be set on the pipeline:
- * - all handlers inherited from the RtspPipeline
- * - `onServerClose`: called when the WebSocket server closes the connection
- *   (only then, not when the connection is closed in a different way)
- */
-export class MetadataPipeline extends RtspPipeline {
-  public onServerClose?: () => void
-  public ready: Promise
-
-  private _src?: WSSource
-
-  constructor(config: WsRtspMetadataConfig) {
-    const { ws: wsConfig, rtsp: rtspConfig, metadataHandler } = config
-
-    super(Object.assign({}, DEFAULT_RTSP_PARAMETERS, rtspConfig))
-
-    const onvifDepay = new ONVIFDepay()
-    this.append(onvifDepay)
-    const handlerSink = Sink.fromHandler((msg) => {
-      if (msg.type === MessageType.XML) {
-        metadataHandler(msg)
-      }
-    })
-    this.append(handlerSink)
-
-    const waitForWs = WSSource.open(wsConfig)
-    this.ready = waitForWs.then((wsSource) => {
-      wsSource.onServerClose = () => {
-        this.onServerClose && this.onServerClose()
-      }
-      this.prepend(wsSource)
-      this._src = wsSource
-    })
-  }
-
-  close() {
-    this._src && this._src.outgoing.end()
-  }
-}
diff --git a/streams/src/pipelines/pipeline.ts b/streams/src/pipelines/pipeline.ts
deleted file mode 100644
index 49d365797..000000000
--- a/streams/src/pipelines/pipeline.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import { Component, Sink, Source, Tube } from '../components/component'
-
-/**
- * Pipeline
- *
- * A pipeline is a linked list of components with some convenience methods to
- * handle inserting or removing components from the linked list.
- *
- * A internal set keeps track of which components the pipeline contains, while
- * any order is completely determined by the component's connectedness.
- */
-export class Pipeline {
-  public firstComponent: Component
-  public lastComponent: Component
-
-  private _set: Set
-  /**
-   * @param components - The components of the pipeline in order.
-   */
-  constructor(...components: Component[]) {
-    const [car, ...cdr] = components
-
-    this._set = new Set(components)
-
-    this.firstComponent = car
-    this.lastComponent = cdr.reduce((last, component) => {
-      return last.connect(component as Tube | Sink)
-    }, car)
-  }
-
-  /**
-   * @param components - The components of the pipeline in order.
-   */
-  init(...components: Component[]) {
-    const [car, ...cdr] = components
-
-    this._set = new Set(components)
-
-    this.firstComponent = car
-    this.lastComponent = cdr.reduce((last, component) => {
-      return last.connect(component as Tube | Sink)
-    }, car)
-  }
-
-  /**
-   * Inserts a component into the pipeline.
-   *
-   * @param component - Tube or Source behind which to insert a new component.
-   * @param component - Tube or Sink to insert.
-   */
-  insertAfter(component: Source | Tube, newComponent: Tube | Sink) {
-    if (!this._set.has(component)) {
-      throw new Error('insertion point not part of pipeline')
-    }
-    if (this._set.has(newComponent)) {
-      throw new Error('new component already in the pipeline')
-    }
-
-    const cdr = component.next
-    if (cdr === null) {
-      component.connect(newComponent)
-      this.lastComponent = newComponent
-    } else {
-      component.disconnect()
-      component.connect(newComponent).connect(cdr)
-    }
-    this._set.add(newComponent)
-
-    return this
-  }
-
-  /**
-   * Inserts a component into the pipeline.
-   *
-   * @param component - Tube or Sink in front of which to insert a new component.
-   * @param component - Tube or Source to insert.
-   */
-  insertBefore(component: Tube | Sink, newComponent: Source | Tube) {
-    if (!this._set.has(component)) {
-      throw new Error('insertion point not part of pipeline')
-    }
-    if (this._set.has(newComponent)) {
-      throw new Error('new component already in the pipeline')
-    }
-
-    const car = component.prev
-    if (car === null) {
-      newComponent.connect(component)
-      this.firstComponent = newComponent
-    } else {
-      car.disconnect()
-      car.connect(newComponent as Tube).connect(component)
-    }
-    this._set.add(newComponent)
-
-    return this
-  }
-
-  /**
-   * Removes a component from the pipeline.
-   *
-   * @param component - Component to remove.
-   */
-  remove(component: Component) {
-    if (!this._set.has(component)) {
-      throw new Error('component not part of pipeline')
-    }
-
-    const car = component.prev
-    const cdr = component.next
-    if (car === null && cdr === null) {
-      throw new Error('cannot remove last component')
-    } else if (car === null && cdr !== null) {
-      component.disconnect()
-      this.firstComponent = cdr
-    } else if (car !== null && cdr === null) {
-      car.disconnect()
-      this.lastComponent = car
-    } else if (car !== null && cdr !== null) {
-      car.disconnect()
-      // FIXME: upgrade to Typescript 4.5.5
-      // infers component as "never" in this case.
-      // Try to revert this with newer TS versions.
-      const cmp = component as unknown as Component
-      cmp.disconnect()
-      car.connect(cdr)
-    }
-    this._set.delete(component)
-
-    return this
-  }
-
-  /**
-   * Inserts a component at the end of the pipeline.
-   *
-   * @param component - Tube or Sink to insert.
-   */
-  append(...components: Array) {
-    components.forEach((component) => {
-      this.insertAfter(this.lastComponent as Source | Tube, component)
-    })
-
-    return this
-  }
-
-  /**
-   * Inserts a component at the beginning of the pipeline.
-   *
-   * @param component - Tube or Source to insert.
-   */
-  prepend(...components: Array) {
-    components.forEach((component) => {
-      this.insertBefore(this.firstComponent as Tube | Sink, component)
-    })
-
-    return this
-  }
-}
diff --git a/streams/src/pipelines/rtsp-mjpeg-pipeline.ts b/streams/src/pipelines/rtsp-mjpeg-pipeline.ts
deleted file mode 100644
index d57fea90a..000000000
--- a/streams/src/pipelines/rtsp-mjpeg-pipeline.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { JPEGDepay } from '../components/jpegdepay'
-import { RtspConfig } from '../components/rtsp-session'
-
-import { RtspPipeline } from './rtsp-pipeline'
-
-/**
- * RtspMjpegPipeline
- *
- * A pipeline that can process JPEG RTP data, and converts it to streaming
- * motion JPEG format (sequence of JPEG images).
- *
- * The following handlers can be defined:
- * - all handlers from the RtspPipeline
- */
-export class RtspMjpegPipeline extends RtspPipeline {
-  constructor(rtspConfig?: RtspConfig) {
-    super(rtspConfig)
-
-    const jpegDepay = new JPEGDepay()
-
-    this.append(jpegDepay)
-  }
-}
diff --git a/streams/src/pipelines/rtsp-mp4-pipeline.ts b/streams/src/pipelines/rtsp-mp4-pipeline.ts
deleted file mode 100644
index a26c232c3..000000000
--- a/streams/src/pipelines/rtsp-mp4-pipeline.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { AACDepay } from '../components/aacdepay'
-import { H264Depay } from '../components/h264depay'
-import { Mp4Muxer } from '../components/mp4muxer'
-import { RtspConfig } from '../components/rtsp-session'
-
-import { RtspPipeline } from './rtsp-pipeline'
-
-/**
- * RtspMp4Pipeline
- *
- * A pipeline that can process H264/AAC RTP data, and converts it to streaming
- * MP4 format (ISO BMFF bytestream).
- *
- * The following handlers can be defined:
- * - all handlers from the RtspPipeline
- * - `onSync`: called when the NTP time of the first frame is known, with the
- *   timestamp as argument (the timestamp is UNIX milliseconds)
- */
-export class RtspMp4Pipeline extends RtspPipeline {
-  public onSync?: (ntpPresentationTime: number) => void
-
-  private readonly _mp4Muxer: Mp4Muxer
-
-  constructor(rtspConfig?: RtspConfig) {
-    super(rtspConfig)
-
-    const h264Depay = new H264Depay()
-    const aacDepay = new AACDepay()
-    const mp4Muxer = new Mp4Muxer()
-
-    mp4Muxer.onSync = (ntpPresentationTime) => {
-      this.onSync && this.onSync(ntpPresentationTime)
-    }
-
-    this.append(h264Depay, aacDepay, mp4Muxer)
-
-    this._mp4Muxer = mp4Muxer
-  }
-
-  get bitrate() {
-    return this._mp4Muxer.bitrate
-  }
-
-  get framerate() {
-    return this._mp4Muxer.framerate
-  }
-}
diff --git a/streams/src/pipelines/rtsp-pipeline.ts b/streams/src/pipelines/rtsp-pipeline.ts
deleted file mode 100644
index cc008776e..000000000
--- a/streams/src/pipelines/rtsp-pipeline.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { RtspParser } from '../components/rtsp-parser'
-import { RtspConfig, RtspSession } from '../components/rtsp-session'
-import { Sdp } from '../utils/protocols/sdp'
-
-import { Pipeline } from './pipeline'
-
-/**
- * RtspPipeline
- *
- * A pipeline that converts interleaved RTSP/RTP into a series of RTP, RTCP, and
- * RTSP packets.  The pipeline exposes the RTSP session component as
- * `this.session`, and wraps its play, pause and stop methods.
- *
- * The following handlers can be defined:
- * - onSdp: called when the session descript protocol is available, with the SDP
- *   object as argument
- * - onPlay: called when a response from the PLAY command arrives, with the play
- *   range as argument
- */
-export class RtspPipeline extends Pipeline {
-  public onSdp?: (sdp: Sdp) => void
-  public onPlay?: (range: string[] | undefined) => void
-  public rtsp: RtspSession
-
-  constructor(rtspConfig?: RtspConfig) {
-    const rtspParser = new RtspParser()
-    const rtspSession = new RtspSession(rtspConfig)
-
-    rtspSession.onSdp = (sdp) => {
-      this.onSdp && this.onSdp(sdp)
-    }
-
-    rtspSession.onPlay = (range) => {
-      this.onPlay && this.onPlay(range)
-    }
-
-    super(rtspParser, rtspSession)
-
-    // Expose session for external use
-    this.rtsp = rtspSession
-  }
-}
diff --git a/streams/src/pipelines/tcp-ws-proxy-pipeline.ts b/streams/src/pipelines/tcp-ws-proxy-pipeline.ts
deleted file mode 100644
index 09de45cc4..000000000
--- a/streams/src/pipelines/tcp-ws-proxy-pipeline.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { ServerOptions, WebSocketServer } from 'ws'
-
-import { TcpSource } from '../components/tcp'
-import { WSSink } from '../components/ws-sink'
-
-import { Pipeline } from './pipeline'
-
-interface TcpWsConfig {
-  readonly wsOptions?: ServerOptions
-  readonly rtspHost?: string
-}
-
-/**
- * TcpWsProxyPipeline
- *
- * A (two-component) pipeline that listens for WebSocket connections and
- * connects them to another server over TCP. This can be used as a WebSocket
- * proxy for an RTSP server.
- */
-export class TcpWsProxyPipeline extends Pipeline {
-  public wss: WebSocketServer
-
-  public constructor(config: TcpWsConfig = {}) {
-    const { wsOptions, rtspHost } = config
-    const wss = new WebSocketServer(wsOptions)
-    wss.on('connection', (socket) => {
-      const wsSink = new WSSink(socket)
-      const tcpSource = new TcpSource(rtspHost)
-
-      this.init(tcpSource, wsSink)
-    })
-
-    super()
-
-    // Expose WebSocket Server for external use
-    this.wss = wss
-  }
-}
diff --git a/streams/src/pipelines/ws-sdp-pipeline.ts b/streams/src/pipelines/ws-sdp-pipeline.ts
deleted file mode 100644
index 9ae9c2f0d..000000000
--- a/streams/src/pipelines/ws-sdp-pipeline.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { Auth, AuthConfig } from '../components/auth'
-import { RTSP_METHOD, RtspConfig } from '../components/rtsp-session'
-import { WSSource } from '../components/ws-source'
-import { WSConfig } from '../components/ws-source/openwebsocket'
-import { Sdp } from '../utils/protocols'
-
-import { RtspPipeline } from './rtsp-pipeline'
-
-export interface TransformConfig {
-  ws?: WSConfig
-  rtsp?: RtspConfig
-  auth?: AuthConfig
-}
-
-/**
- * WsSdpPipeline
- *
- * Pipeline requesting an SDP object from an RTSP server and then
- * immediately tears down the RTSP session.
- */
-export class WsSdpPipeline extends RtspPipeline {
-  public onServerClose?: () => void
-  public ready: Promise
-
-  private _src?: WSSource
-
-  constructor(config: TransformConfig) {
-    const { ws: wsConfig, rtsp: rtspConfig, auth: authConfig } = config
-
-    super(rtspConfig)
-
-    if (authConfig) {
-      const auth = new Auth(authConfig)
-      this.insertBefore(this.rtsp, auth)
-    }
-
-    const waitForWs = WSSource.open(wsConfig)
-    this.ready = waitForWs.then((wsSource) => {
-      wsSource.onServerClose = () => {
-        this.onServerClose && this.onServerClose()
-      }
-      this.prepend(wsSource)
-      this._src = wsSource
-    })
-  }
-
-  close() {
-    this._src && this._src.outgoing.end()
-  }
-
-  get sdp() {
-    return this.ready.then(async () => {
-      const sdpPromise = new Promise((resolve) => {
-        this.rtsp.onSdp = resolve
-      })
-      this.rtsp.send({ method: RTSP_METHOD.DESCRIBE })
-      this.rtsp.send({ method: RTSP_METHOD.TEARDOWN })
-      return await sdpPromise
-    })
-  }
-}
diff --git a/streams/src/rtsp-jpeg-pipeline.ts b/streams/src/rtsp-jpeg-pipeline.ts
new file mode 100644
index 000000000..4912bd22e
--- /dev/null
+++ b/streams/src/rtsp-jpeg-pipeline.ts
@@ -0,0 +1,106 @@
+import { logDebug } from './log'
+
+import {
+  CanvasSink,
+  RtpDepay,
+  RtspConfig,
+  RtspSession,
+  Sdp,
+  WSSource,
+} from './components'
+
+import { WebSocketConfig, openWebSocket } from './openwebsocket'
+
+export interface RtspJpegConfig {
+  ws: WebSocketConfig
+  rtsp: RtspConfig
+  mediaElement: HTMLCanvasElement
+}
+
+/**
+ * Html5CanvasPipeline
+ *
+ * A pipeline that connects to an RTSP server over a WebSocket connection and
+ * can process JPEG RTP data to produce an motion JPEG data stream that is sent
+ * to a HTML canvas element.
+ *
+ * Handlers that can be set on the pipeline:
+ * - all handlers inherited from the RtspMjpegPipeline
+ * - `onSync`: called when the NTP time of the first frame is known, with the
+ *   timestamp as argument (the timestamp is UNIX milliseconds)
+ * - `onServerClose`: called when the WebSocket server closes the connection
+ *   (only then, not when the connection is closed in a different way)
+ */
+export class RtspJpegPipeline {
+  public readonly canvas: CanvasSink
+  public readonly rtp = new RtpDepay()
+  public readonly rtsp: RtspSession
+  /** The real time corresponding to the start of the video media. */
+  public readonly videoStartTime: Promise
+
+  private readonly socket: Promise
+
+  constructor({
+    ws: wsConfig,
+    rtsp: rtspConfig,
+    mediaElement,
+  }: RtspJpegConfig) {
+    this.canvas = new CanvasSink(mediaElement)
+    this.rtsp = new RtspSession(rtspConfig)
+    this.socket = openWebSocket(wsConfig)
+    this.videoStartTime = new Promise((resolve) => {
+      this.canvas.onSync = resolve
+    })
+  }
+
+  /** Initiates the stream (starting at optional offset in seconds) and resolves
+   * when the media stream has completed. */
+  public async start(
+    offset?: number
+  ): Promise<{ sdp: Sdp; range?: [string, string] }> {
+    const socket = await this.socket
+    const result = this.rtsp.start(offset)
+
+    const wsSource = new WSSource(socket)
+    Promise.allSettled([
+      wsSource.readable
+        .pipeThrough(this.rtsp.demuxer)
+        .pipeThrough(this.rtp)
+        .pipeTo(this.canvas.writable),
+      this.rtsp.commands.pipeTo(wsSource.writable),
+    ]).then((results) => {
+      const [down, up] = results.map((r) =>
+        r.status === 'rejected' ? r.reason : 'stream ended'
+      )
+      logDebug(
+        `ws-rtsp-jpeg pipeline ended: downstream: ${down} upstream: ${up}`
+      )
+    })
+
+    return result
+  }
+
+  close() {
+    this.socket.then((socket) => socket.close())
+  }
+
+  get currentTime() {
+    return this.canvas.currentTime
+  }
+
+  play() {
+    return this.canvas.play()
+  }
+
+  pause() {
+    return this.canvas.pause()
+  }
+
+  get bitrate() {
+    return this.canvas.bitrate
+  }
+
+  get framerate() {
+    return this.canvas.framerate
+  }
+}
diff --git a/streams/src/rtsp-mp4-pipeline.ts b/streams/src/rtsp-mp4-pipeline.ts
new file mode 100644
index 000000000..b965543b8
--- /dev/null
+++ b/streams/src/rtsp-mp4-pipeline.ts
@@ -0,0 +1,105 @@
+import { logDebug } from './log'
+
+import {
+  Mp4Muxer,
+  MseSink,
+  RtpDepay,
+  RtspConfig,
+  RtspSession,
+  Sdp,
+  WSSource,
+} from './components'
+
+import { WebSocketConfig, openWebSocket } from './openwebsocket'
+
+export interface Html5VideoConfig {
+  ws: WebSocketConfig
+  rtsp: RtspConfig
+  mediaElement: HTMLVideoElement
+}
+
+/*
+ * Html5VideoPipeline
+ *
+ * A pipeline that connects to an RTSP server over a WebSocket connection and
+ * can process H.264/AAC RTP data to produce an MP4 data stream that is sent to
+ * a HTML video element.
+ *
+ * Handlers that can be set on the pipeline:
+ * - all handlers inherited from the RtspMp4Pipeline
+ * - `onServerClose`: called when the WebSocket server closes the connection
+ *   (only then, not when the connection is closed in a different way)
+ */
+export class RtspMp4Pipeline {
+  public readonly mp4 = new Mp4Muxer()
+  public readonly mse: MseSink
+  public readonly rtp = new RtpDepay()
+  public readonly rtsp: RtspSession
+  public readonly videoEl: HTMLVideoElement
+  /** The real time corresponding to the start of the video media. */
+  public readonly videoStartTime: Promise
+
+  private readonly socket: Promise
+
+  constructor({
+    ws: wsConfig,
+    rtsp: rtspConfig,
+    mediaElement,
+  }: Html5VideoConfig) {
+    this.mse = new MseSink(mediaElement)
+    this.rtsp = new RtspSession(rtspConfig)
+    this.socket = openWebSocket(wsConfig)
+    this.videoEl = mediaElement
+    this.videoStartTime = new Promise((resolve) => {
+      this.mp4.onSync = resolve
+    })
+  }
+
+  /** Initiates the stream (starting at optional offset in seconds) and resolves
+   * when the media stream has completed. */
+  public async start(
+    offset?: number
+  ): Promise<{ sdp: Sdp; range?: [string, string] }> {
+    const socket = await this.socket
+    socket.addEventListener('close', (e) => {
+      console.warn('WebSocket closed with code:', e.code)
+    })
+
+    const result = this.rtsp.start(offset)
+
+    const wsSource = new WSSource(socket)
+    Promise.allSettled([
+      wsSource.readable
+        .pipeThrough(this.rtsp.demuxer)
+        .pipeThrough(this.rtp)
+        .pipeThrough(this.mp4)
+        .pipeTo(this.mse.writable),
+      this.rtsp.commands.pipeTo(wsSource.writable),
+    ]).then((results) => {
+      const [down, up] = results.map((r) =>
+        r.status === 'rejected' ? r.reason : 'stream ended'
+      )
+      logDebug(
+        `ws-rtsp-mp4 pipeline ended: downstream: ${down} upstream: ${up}`
+      )
+    })
+
+    return result
+  }
+
+  close() {
+    this.socket.then((socket) => socket.close())
+  }
+
+  get currentTime() {
+    return this.videoEl.currentTime
+  }
+
+  play() {
+    return this.videoEl.play()
+  }
+
+  pause() {
+    return this.videoEl.pause()
+  }
+}
diff --git a/streams/src/utils/index.browser.ts b/streams/src/utils/index.browser.ts
deleted file mode 100644
index ca0013b8b..000000000
--- a/streams/src/utils/index.browser.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from './protocols'
-export * from './retry'
-export { Scheduler } from './scheduler'
diff --git a/streams/src/utils/index.node.ts b/streams/src/utils/index.node.ts
deleted file mode 100644
index a47a0d3e0..000000000
--- a/streams/src/utils/index.node.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export { Clock } from './clock'
-export * from './protocols'
-export * from './retry'
-export { Scheduler } from './scheduler'
diff --git a/streams/src/utils/protocols/index.ts b/streams/src/utils/protocols/index.ts
deleted file mode 100644
index aacf62d0c..000000000
--- a/streams/src/utils/protocols/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export * from './ntp'
-export * from './rtcp'
-export * from './rtp'
-export * from './rtsp'
-export * from './sdp'
diff --git a/streams/src/utils/protocols/isom.ts b/streams/src/utils/protocols/isom.ts
deleted file mode 100644
index 284ab6931..000000000
--- a/streams/src/utils/protocols/isom.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { decode } from 'utils/bytes'
-
-/*
- * Track data which can be attached to an ISOM message.
- * It indicates the start of a new movie.
- */
-export interface MediaTrack {
-  type: string
-  encoding?: string
-  mime?: string
-  codec?: any
-}
-
-export const BOX_HEADER_BYTES = 8
-
-export const boxType = (buffer: Uint8Array) => {
-  return decode(buffer.subarray(4, 8)).toLowerCase()
-}
diff --git a/streams/src/utils/protocols/rtp.ts b/streams/src/utils/protocols/rtp.ts
deleted file mode 100644
index 12bfc197e..000000000
--- a/streams/src/utils/protocols/rtp.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { readUInt16BE, readUInt32BE } from 'utils/bytes'
-import { POS } from '../bits'
-
-// Real Time Protocol (RTP)
-// https://tools.ietf.org/html/rfc3550#section-5.1
-
-/*
-RTP Fixed Header Fields
-
-  0               1               2               3
-  0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
-  +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
-  |V=2|P|X|  CC   |M|     PT      |       sequence number         |
-  +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
-  |                           timestamp                           |
-  +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
-  |           synchronization source (SSRC) identifier            |
-  +=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+
-  |            contributing source (CSRC) identifiers             |
-  |                             ....                              |
-  +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
-  |   profile-specific ext. id    | profile-specific ext. length  |
-  +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
-  |                 profile-specific extension                    |
-  |                             ....                              |
-  +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
-*/
-
-export const version = (bytes: Uint8Array): number => {
-  return bytes[0] >>> 6
-}
-
-export const padding = (bytes: Uint8Array): boolean => {
-  return !!(bytes[0] & POS[2])
-}
-
-export const extension = (bytes: Uint8Array): boolean => {
-  return !!(bytes[0] & POS[3])
-}
-
-export const cSrcCount = (bytes: Uint8Array): number => {
-  return bytes[0] & 0x0f
-}
-
-export const marker = (bytes: Uint8Array): boolean => {
-  return !!(bytes[1] & POS[0])
-}
-
-export const payloadType = (bytes: Uint8Array): number => {
-  return bytes[1] & 0x7f
-}
-
-export const sequenceNumber = (bytes: Uint8Array): number => {
-  return readUInt16BE(bytes, 2)
-}
-
-export const timestamp = (bytes: Uint8Array): number => {
-  return readUInt32BE(bytes, 4)
-}
-
-export const sSrc = (bytes: Uint8Array): number => {
-  return readUInt32BE(bytes, 8)
-}
-
-export const cSrc = (bytes: Uint8Array, rank = 0): number => {
-  return cSrcCount(bytes) > rank ? readUInt32BE(bytes, 12 + rank * 4) : 0
-}
-
-export const extHeaderLength = (bytes: Uint8Array): number => {
-  return !extension(bytes)
-    ? 0
-    : readUInt16BE(bytes, 12 + cSrcCount(bytes) * 4 + 2)
-}
-
-export const extHeader = (bytes: Uint8Array): Uint8Array => {
-  return extHeaderLength(bytes) === 0
-    ? new Uint8Array(0)
-    : bytes.subarray(
-        12 + cSrcCount(bytes) * 4,
-        12 + cSrcCount(bytes) * 4 + 4 + extHeaderLength(bytes) * 4
-      )
-}
-
-export const payload = (bytes: Uint8Array): Uint8Array => {
-  return !extension(bytes)
-    ? bytes.subarray(12 + cSrcCount(bytes) * 4)
-    : bytes.subarray(12 + cSrcCount(bytes) * 4 + 4 + extHeaderLength(bytes) * 4)
-}
diff --git a/streams/src/utils/protocols/rtsp.ts b/streams/src/utils/protocols/rtsp.ts
deleted file mode 100644
index 231dd2249..000000000
--- a/streams/src/utils/protocols/rtsp.ts
+++ /dev/null
@@ -1,212 +0,0 @@
-/*
- * The RTSP response format is defined in RFC 7826,
- * using ABNF notation specified in RFC 5234.
- * Strings in ABNF rules ("...") are always case insensitive!
- *
- * Basic rules to help with the headers below:
- * ====
- * CR              =  %x0D ; US-ASCII CR, carriage return (13)
- * LF              =  %x0A  ; US-ASCII LF, linefeed (10)
- * SP              =  %x20  ; US-ASCII SP, space (32)
- * HT              =  %x09  ; US-ASCII HT, horizontal-tab (9)
- * CRLF            =  CR LF
- * LWS             =  [CRLF] 1*( SP / HT ) ; Line-breaking whitespace
- * SWS             =  [LWS] ; Separating whitespace
- * HCOLON          =  *( SP / HT ) ":" SWS
- *
- * RTSP response rules (a `*` means zero or more):
- * ====
- * Status-Line  = RTSP-Version SP Status-Code SP Reason-Phrase CRLF
- * Response     = Status-Line
- *                *((general-header
- *                /  response-header
- *                /  message-body-header) CRLF)
- *                CRLF
- *                [ message-body-data ]
- *
- * Example response:
- * ====
- * RTSP/1.0 200 OK
- * CSeq: 3
- * Content-Type: application/sdp
- * Content-Base: rtsp://192.168.0.3/axis-media/media.amp/
- * Server: GStreamer RTSP server
- * Date: Wed, 03 Jun 2015 14:23:42 GMT
- * Content-Length: 623
- *
- * v=0
- * ....
- */
-
-export const ASCII = {
-  LF: 10,
-  CR: 13,
-} as const
-
-/**
- * Extract the value of a header.
- *
- * @param buffer The response bytes
- * @param key The header to search for
- */
-export const extractHeaderValue = (header: string, key: string) => {
-  const anchor = `\n${key.toLowerCase()}: `
-  const start = header.toLowerCase().indexOf(anchor)
-  if (start >= 0) {
-    const end = header.indexOf('\n', start + anchor.length)
-    const headerValue = header.substring(start + anchor.length, end).trim()
-    return headerValue
-  }
-  return null
-}
-
-export const sequence = (header: string) => {
-  /**
-   * CSeq           =  "CSeq" HCOLON cseq-nr
-   * cseq-nr        =  1*9DIGIT
-   */
-  const val = extractHeaderValue(header, 'CSeq')
-  if (val !== null) {
-    return Number(val)
-  }
-  return null
-}
-
-export const sessionId = (header: string) => {
-  /**
-   * Session          =  "Session" HCOLON session-id
-   *                     [ SEMI "timeout" EQUAL delta-seconds ]
-   * session-id        =  1*256( ALPHA / DIGIT / safe )
-   * delta-seconds     =  1*19DIGIT
-   */
-  const val = extractHeaderValue(header, 'Session')
-  return val ? val.split(';')[0] : null
-}
-
-export const sessionTimeout = (header: string) => {
-  /**
-   * Session          =  "Session" HCOLON session-id
-   *                     [ SEMI "timeout" EQUAL delta-seconds ]
-   * session-id        =  1*256( ALPHA / DIGIT / safe )
-   * delta-seconds     =  1*19DIGIT
-   */
-  const val = extractHeaderValue(header, 'Session')
-  if (val === null) {
-    return null
-  }
-  const defaultTimeout = 60
-  const timeoutToken = 'timeout='
-  const timeoutPosition = val.toLowerCase().indexOf(timeoutToken)
-  if (timeoutPosition !== -1) {
-    let timeoutVal = val.substring(timeoutPosition + timeoutToken.length)
-    timeoutVal = timeoutVal.split(';')[0]
-    const parsedTimeout = parseInt(timeoutVal)
-    return Number.isNaN(parsedTimeout) ? defaultTimeout : parsedTimeout
-  }
-  return defaultTimeout
-}
-
-export const statusCode = (header: string) => {
-  return Number(header.substring(9, 12))
-}
-
-export const contentBase = (header: string) => {
-  /**
-   * Content-Base       =  "Content-Base" HCOLON RTSP-URI
-   */
-  return extractHeaderValue(header, 'Content-Base')
-}
-
-export const contentLocation = (header: string) => {
-  /**
-   * Content-Location   =  "Content-Location" HCOLON RTSP-REQ-Ref
-   */
-  return extractHeaderValue(header, 'Content-Location')
-}
-
-export const connectionEnded = (header: string) => {
-  /**
-   * Connection         =  "Connection" HCOLON connection-token
-   *                       *(COMMA connection-token)
-   * connection-token   =  "close" / token
-   */
-  const connectionToken = extractHeaderValue(header, 'Connection')
-  return connectionToken !== null && connectionToken.toLowerCase() === 'close'
-}
-
-export const range = (header: string) => {
-  /**
-   * Range              =  "Range" HCOLON ranges-spec
-   * ranges-spec        =  npt-range / utc-range / smpte-range
-   *                       /  range-ext
-   * npt-range        =  "npt" [EQUAL npt-range-spec]
-   * npt-range-spec   =  ( npt-time "-" [ npt-time ] ) / ( "-" npt-time )
-   * npt-time         =  "now" / npt-sec / npt-hhmmss / npt-hhmmss-comp
-   * npt-sec          =  1*19DIGIT [ "." 1*9DIGIT ]
-   * npt-hhmmss       =  npt-hh ":" npt-mm ":" npt-ss [ "." 1*9DIGIT ]
-   * npt-hh           =  2*19DIGIT   ; any positive number
-   * npt-mm           =  2*2DIGIT  ; 0-59
-   * npt-ss           =  2*2DIGIT  ; 0-59
-   * npt-hhmmss-comp  =  npt-hh-comp ":" npt-mm-comp ":" npt-ss-comp
-   *                     [ "." 1*9DIGIT ] ; Compatibility format
-   * npt-hh-comp      =  1*19DIGIT   ; any positive number
-   * npt-mm-comp      =  1*2DIGIT  ; 0-59
-   * npt-ss-comp      =  1*2DIGIT  ; 0-59
-   */
-
-  // Example range headers:
-  // Range: npt=now-
-  // Range: npt=1154.598701-3610.259146
-  const npt = extractHeaderValue(header, 'Range')
-  if (npt !== null) {
-    return npt.split('=')[1].split('-')
-  }
-  return undefined
-}
-
-interface HeaderTerminator {
-  byteLength: number
-  sequence: string
-  startByte: number
-}
-const headerTerminators: HeaderTerminator[] = [
-  // expected
-  { sequence: '\r\n\r\n', startByte: ASCII.CR, byteLength: 4 },
-  // legacy compatibility
-  { sequence: '\r\r', startByte: ASCII.CR, byteLength: 2 },
-  { sequence: '\n\n', startByte: ASCII.LF, byteLength: 2 },
-]
-
-/**
- * Determine the offset of the RTSP body, where the header ends.
- * If there is no header ending, -1 is returned
- * @param  chunk - A piece of data
- * @return The body offset, or -1 if no header end found
- */
-export const bodyOffset = (chunk: Uint8Array) => {
-  // Strictly speaking, it seems RTSP MUST have CRLF and doesn't allow CR or LF on its own.
-  // That means that the end of the header part should be a pair of CRLF, but we're being
-  // flexible here and also allow LF LF or CR CR instead of CRLF CRLF (should be handled
-  // according to version 1.0)
-  const dec = new TextDecoder()
-
-  for (const terminator of headerTerminators) {
-    const terminatorOffset = chunk.findIndex((value, index, array) => {
-      if (value === terminator.startByte) {
-        const candidate = dec.decode(
-          array.slice(index, index + terminator.byteLength)
-        )
-
-        if (candidate === terminator.sequence) {
-          return true
-        }
-      }
-      return false
-    })
-    if (terminatorOffset !== -1) {
-      return terminatorOffset + terminator.byteLength
-    }
-  }
-
-  return -1
-}
diff --git a/streams/src/utils/protocols/sdp.ts b/streams/src/utils/protocols/sdp.ts
deleted file mode 100644
index 1a82b0349..000000000
--- a/streams/src/utils/protocols/sdp.ts
+++ /dev/null
@@ -1,433 +0,0 @@
-import { MessageType, SdpMessage } from '../../components/message'
-
-import { NtpSeconds, seconds } from './ntp'
-
-interface ConnectionField {
-  // c=  
-  networkType: 'IN'
-  addressType: 'IP4' | 'IP6'
-  connectionAddress: string
-}
-
-interface BandwidthField {
-  readonly type: string
-  readonly value: number
-}
-
-// RTSP extensions: https://tools.ietf.org/html/rfc7826 (22.15)
-// exists on both session and media level
-interface RtspExtensions {
-  readonly range?: string
-  readonly control?: string
-  readonly mtag?: string
-}
-
-/**
- * The session description protocol (SDP).
- *
- * Contains parser to convert SDP data into an SDP structure.
- * https://tools.ietf.org/html/rfc4566
- *
- * NOTE: not all SDP attributes have been implemented,
- * and in some cases the handling of attributes has been
- * simplified to not cover multiple identical attributes.
- */
-
-/**
- * Session description
- *
- * Optional items are marked with a '*'.
- *
- * v=  (protocol version)
- * o=  (owner/creator and session identifier).
- * s=  (session name)
- * i=* (session information)
- * u=* (URI of description)
- * e=* (email address)
- * p=* (phone number)
- * c=* (connection information - not required if included in all media)
- * b=* (bandwidth information)
- * One or more time descriptions (see below)
- * z=* (time zone adjustments)
- * k=* (encryption key)
- * a=* (zero or more session attribute lines)
- * Zero or more media descriptions (see below)
- *
- * Names of the fields below are annotated above with
- * the names used in Appendix A: SDP Grammar of RFC 2327.
- */
-export interface SessionDescription extends RtspExtensions {
-  // v=0
-  readonly version: 0
-  // o=     
-  readonly originField: OriginField
-  // s=
-  readonly name: string
-  // i=
-  readonly description?: string
-  // u=
-  readonly uri?: string
-  // e=
-  readonly email?: string | string[]
-  // p=
-  readonly phone?: string | string[]
-  // c=  
-  readonly connection?: ConnectionField
-  // b=:
-  readonly bandwidth?: BandwidthField
-  // One or more time descriptions
-  readonly time: TimeDescription
-  readonly repeatTimes?: RepeatTimeDescription
-  // Zero or more media descriptions
-  readonly media: MediaDescription[]
-}
-
-interface OriginField {
-  // o=     
-  username: string
-  sessionId: number
-  sessionVersion: number
-  networkType: 'IN'
-  addressType: 'IP4' | 'IP6'
-  address: string
-}
-
-/**
- * Time description
- *
- * t=  (time the session is active)
- * r=* (zero or more repeat times)
- */
-export interface TimeDescription {
-  // t= 
-  readonly startTime: NtpSeconds
-  readonly stopTime: NtpSeconds
-}
-
-export interface RepeatTimeDescription {
-  // r=  
-  readonly repeatInterval: seconds
-  readonly activeDuration: seconds
-  readonly offsets: seconds[]
-}
-
-/**
- * Media description
- *
- * m=  (media name and transport address)
- * i=* (media title)
- * c=* (connection information -- optional if included at session level)
- * b=* (zero or more bandwidth information lines)
- * k=* (encryption key)
- * a=* (zero or more media attribute lines)
- *
- * The parser only handles a single fmt value
- * and only one rtpmap attribute (in theory there
- * can be multiple fmt values with corresponding rtpmap
- * attributes)
- */
-export interface MediaDescription extends RtspExtensions {
-  // m=    ...
-  // m= /   ...
-  readonly type: 'audio' | 'video' | 'application' | 'data' | 'control'
-  readonly port: number
-  readonly protocol: 'udp' | 'RTP/AVP' | 'RTP/SAVP'
-  readonly fmt: number // Payload type(s)
-  readonly connection?: ConnectionField
-  readonly bandwidth?: BandwidthField
-  /**
-   * Any remaining attributes
-   * a=...
-   */
-  // a=rtpmap: / [/]
-  readonly rtpmap?: {
-    readonly clockrate: number
-    readonly encodingName: string
-    readonly payloadType: number
-  }
-  // a=fmtp: 
-  readonly fmtp: {
-    readonly format: string
-    readonly parameters: { [key: string]: any }
-  }
-  // Extra non-SDP properties
-  // TODO: refactor this away
-  mime?: string
-  codec?: any
-}
-
-export type TransformationMatrix = readonly [
-  readonly [number, number, number],
-  readonly [number, number, number],
-  readonly [number, number, number],
-]
-
-export interface VideoMedia extends MediaDescription {
-  readonly type: 'video'
-  readonly framerate?: number
-  // Transformation matrix
-  readonly transform?: TransformationMatrix
-  readonly 'x-sensor-transform'?: TransformationMatrix
-  // JPEG
-  readonly framesize?: [number, number]
-}
-
-export interface H264Media extends VideoMedia {
-  readonly rtpmap: {
-    readonly clockrate: number
-    readonly encodingName: string
-    readonly payloadType: number
-  }
-}
-
-export interface AudioMedia extends MediaDescription {
-  readonly type: 'audio'
-}
-
-export interface AACParameters {
-  readonly bitrate: string
-  readonly config: string
-  readonly indexdeltalength: string
-  readonly indexlength: string
-  readonly mode: 'AAC-hbr'
-  readonly 'profile-level-id': string
-  readonly sizelength: string
-  readonly streamtype: string
-  readonly ctsdeltalength: string
-  readonly dtsdeltalength: string
-  readonly randomaccessindication: string
-  readonly streamstateindication: string
-  readonly auxiliarydatasizelength: string
-}
-
-export interface AACMedia extends AudioMedia {
-  readonly fmtp: {
-    readonly format: string
-    readonly parameters: AACParameters
-  }
-  readonly rtpmap: {
-    readonly clockrate: number
-    readonly encodingName: string
-    readonly payloadType: number
-  }
-}
-
-export interface Sdp {
-  readonly session: SessionDescription
-  readonly media: MediaDescription[]
-}
-
-const extractLineVals = (body: string, lineStart: string, start = 0) => {
-  const anchor = `\n${lineStart}`
-  start = body.indexOf(anchor, start)
-  let end = 0
-  const ret: string[] = []
-  while (start >= 0) {
-    end = body.indexOf('\n', start + anchor.length)
-    ret.push(body.substring(start + anchor.length, end).trim())
-    start = body.indexOf(anchor, end)
-  }
-  return ret
-}
-
-// SDP parsing
-
-/**
- * Identify the start of a session-level or media-level section.
- * @param  line - The line to parse
- */
-const newMediaLevel = (line: string) => {
-  return line.match(/^m=/)
-}
-
-const splitOnFirst = (c: string, text: string) => {
-  const p = text.indexOf(c)
-  if (p < 0) {
-    return [text.slice(0)]
-  }
-  return [text.slice(0, p), text.slice(p + 1)]
-}
-
-const attributeParsers: any = {
-  fmtp: (value: string) => {
-    const [format, stringParameters] = splitOnFirst(' ', value)
-    switch (format) {
-      default: {
-        const pairs = stringParameters.trim().split(';')
-        const parameters: { [key: string]: any } = {}
-        pairs.forEach((pair) => {
-          const [key, val] = splitOnFirst('=', pair)
-          const normalizedKey = key.trim().toLowerCase()
-          if (normalizedKey !== '') {
-            parameters[normalizedKey] = val.trim()
-          }
-        })
-        return { format, parameters }
-      }
-    }
-  },
-  framerate: Number,
-  rtpmap: (value: string) => {
-    const [payloadType, encoding] = splitOnFirst(' ', value)
-    const [encodingName, clockrate, encodingParameters] = encoding
-      .toUpperCase()
-      .split('/')
-    if (encodingParameters === undefined) {
-      return {
-        payloadType: Number(payloadType),
-        encodingName,
-        clockrate: Number(clockrate),
-      }
-    }
-    return {
-      payloadType: Number(payloadType),
-      encodingName,
-      clockrate: Number(clockrate),
-      encodingParameters,
-    }
-  },
-  transform: (value: string) => {
-    return value.split(';').map((row) => row.split(',').map(Number))
-  },
-  'x-sensor-transform': (value: string) => {
-    return value.split(';').map((row) => row.split(',').map(Number))
-  },
-  framesize: (value: string) => {
-    return value.split(' ')[1].split('-').map(Number)
-  },
-}
-
-const parseAttribute = (body: string) => {
-  const [attribute, value] = splitOnFirst(':', body)
-  if (value === undefined) {
-    return { [attribute]: true }
-  }
-  if (attributeParsers[attribute] !== undefined) {
-    return { [attribute]: attributeParsers[attribute](value) }
-  }
-  return { [attribute]: value }
-}
-
-const extractField = (line: string) => {
-  const prefix = line.slice(0, 1)
-  const body = line.slice(2)
-  switch (prefix) {
-    case 'v':
-      return { version: body }
-    case 'o': {
-      const [
-        username,
-        sessionId,
-        sessionVersion,
-        netType,
-        addrType,
-        unicastAddress,
-      ] = body.split(' ')
-      return {
-        origin: {
-          addrType,
-          netType,
-          sessionId,
-          sessionVersion,
-          unicastAddress,
-          username,
-        },
-      }
-    }
-    case 's':
-      return { sessionName: body }
-    case 'i':
-      return { sessionInformation: body }
-    case 'u':
-      return { uri: body }
-    case 'e':
-      return { email: body }
-    case 'p':
-      return { phone: body }
-    // c=  
-    case 'c': {
-      const [connectionNetType, connectionAddrType, connectionAddress] =
-        body.split(' ')
-      return {
-        connectionData: {
-          addrType: connectionAddrType,
-          connectionAddress,
-          netType: connectionNetType,
-        },
-      }
-    }
-    // b=:
-    case 'b': {
-      const [bwtype, bandwidth] = body.split(':')
-      return { bwtype, bandwidth }
-    }
-    // t= 
-    case 't': {
-      const [startTime, stopTime] = body.split(' ').map(Number)
-      return { time: { startTime, stopTime } }
-    }
-    // r=  
-    case 'r': {
-      const [repeatInterval, activeDuration, ...offsets] = body
-        .split(' ')
-        .map(Number)
-      return {
-        repeatTimes: { repeatInterval, activeDuration, offsets },
-      }
-    }
-    // z=    ....
-    case 'z':
-      return
-    // k=
-    // k=:
-    case 'k':
-      return
-    // a=
-    // a=:
-    case 'a':
-      return parseAttribute(body)
-    case 'm': {
-      // Only the first fmt field is parsed!
-      const [type, port, protocol, fmt] = body.split(' ')
-      return { type, port: Number(port), protocol, fmt: Number(fmt) }
-    }
-    default:
-    // console.log('unknown SDP prefix ', prefix);
-  }
-}
-
-export const extractURIs = (body: string) => {
-  // There is a control URI above the m= line, which should not be used
-  const seekFrom = body.indexOf('\nm=')
-  return extractLineVals(body, 'a=control:', seekFrom)
-}
-
-/**
- * Parse an SDP text into a data structure with session and media objects.
- *
- * @param  body - The buffer containing the SDP plain text
- * @return Structured SDP data
- */
-export const parse = (body: string): Sdp => {
-  const sdp = body.split('\n').map((s) => s.trim())
-  const struct: { [key: string]: any } = { session: {}, media: [] }
-  let mediaCounter = 0
-  let current = struct.session
-  for (const line of sdp) {
-    if (newMediaLevel(line)) {
-      struct.media[mediaCounter] = {}
-      current = struct.media[mediaCounter]
-      ++mediaCounter
-    }
-    current = Object.assign(current, extractField(line))
-  }
-  return struct as Sdp
-}
-
-export const sdpFromBody = (body: string): SdpMessage => {
-  return {
-    type: MessageType.SDP,
-    data: new Uint8Array(0),
-    sdp: parse(body),
-  }
-}
diff --git a/streams/src/utils/retry.ts b/streams/src/utils/retry.ts
deleted file mode 100644
index fc8bc59a0..000000000
--- a/streams/src/utils/retry.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { RtspSession } from '../components/rtsp-session'
-
-/**
- * Retry failed commands.
- *
- * This retries RTSP commands that fails up to a certain
- * limit of times.
- */
-export const addRTSPRetry = (
-  rtspSession: RtspSession,
-  { maxRetries, errors } = { maxRetries: 20, errors: [503] }
-) => {
-  let retries = 0
-
-  const oldOnError = rtspSession.onError
-
-  rtspSession.onError = (err) => {
-    oldOnError?.(err)
-
-    if (!errors.includes(err.code)) {
-      return
-    }
-
-    // Stop retrying after 20 tries (~20 seconds)
-    if ((retries += 1) > maxRetries) {
-      console.log('retry, too many', retries, maxRetries)
-      return
-    }
-
-    // Retry
-    setTimeout(() => rtspSession.retry?.(), retries * 100)
-  }
-}
diff --git a/streams/src/www-authenticate.d.ts b/streams/src/www-authenticate.d.ts
deleted file mode 100644
index f10a0f903..000000000
--- a/streams/src/www-authenticate.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-declare module 'www-authenticate'
diff --git a/streams/tests/aacdepay-parser.test.ts b/streams/tests/aacdepay-parser.test.ts
deleted file mode 100644
index 767c594d0..000000000
--- a/streams/tests/aacdepay-parser.test.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import * as assert from 'uvu/assert'
-
-import { parse } from 'components/aacdepay/parser'
-import { Message, MessageType } from 'components/message'
-
-import { describe } from './uvu-describe'
-
-// biome-ignore format: custom formatting
-const audioData = new Uint8Array([
-  128, 225, 117, 55, 79, 22, 14, 25, 166, 135, 0, 245, 0, 16, 9, 64, 1, 64, 159,
-  154, 208, 253, 108, 176, 235, 80, 155, 170, 36, 110, 165, 86, 196, 139, 206,
-  106, 75, 50, 197, 254, 218, 215, 141, 101, 86, 179, 169, 204, 111, 84, 85, 75,
-  168, 146, 185, 106, 171, 153, 151, 37, 75, 164, 76, 140, 166, 106, 225, 206,
-  97, 214, 75, 189, 200, 199, 234, 171, 252, 226, 185, 80, 212, 143, 27, 57, 86,
-  82, 85, 251, 214, 108, 247, 246, 122, 42, 132, 72, 50, 47, 80, 74, 144, 193,
-  74, 144, 22, 84, 76, 91, 71, 84, 126, 51, 148, 237, 58, 163, 212, 53, 4, 3,
-  168, 158, 29, 106, 238, 197, 132, 198, 173, 166, 147, 12, 2, 215, 88, 154, 59,
-  115, 62, 127, 40, 138, 8, 58, 167, 156, 158, 255, 70, 15, 208, 28, 26, 155,
-  142, 242, 148, 98, 104, 123, 116, 61, 186, 116, 72, 127, 87, 92, 26, 169, 24,
-  153, 100, 166, 88, 162, 84, 220, 176, 157, 219, 81, 108, 167, 225, 131, 83,
-  179, 38, 37, 237, 186, 150, 121, 39, 231, 5, 9, 11, 139, 85, 56, 160, 69, 83,
-  246, 87, 179, 177, 96, 185, 243, 245, 8, 176, 44, 68, 173, 108, 37, 181, 26,
-  34, 252, 236, 92, 253, 67, 94, 170, 229, 241, 182, 156, 237, 69, 159, 108, 14,
-  51, 16, 6, 134, 40, 221, 41, 233, 139, 6, 77, 146, 213, 86, 1, 74, 112, 17,
-  15, 38, 90, 186, 55, 252, 16, 199, 34, 118, 135, 28, 208, 101, 113, 23, 4,
-  230, 120, 14, 175, 205, 207, 13, 81, 34, 222, 86, 142, 13, 154, 120, 250, 89,
-  228, 10, 153, 215, 39, 187, 132, 37, 7, 36, 216, 214, 142, 202, 253, 107, 2,
-  9, 229, 50, 135,
-])
-
-/*
- * The H264Handler is more thoroughly tested in the end2end test.
- */
-describe('AAC depayer', (it) => {
-  it('should parse a normal package', (ctx) => {
-    let called = 0
-    const cb = (msg: Message) => {
-      ++called
-      ctx.msg = msg
-    }
-    const hasHeader = true
-    parse({ type: MessageType.RTP, data: audioData, channel: 0 }, hasHeader, cb)
-
-    assert.is(called, 1)
-    assert.equal(ctx.msg.data, audioData.slice(16))
-    assert.is(ctx.msg.type, MessageType.ELEMENTARY)
-    assert.is(ctx.msg.payloadType, 97)
-  })
-})
diff --git a/streams/tests/aacdepay.test.ts b/streams/tests/aacdepay.test.ts
index 88d51e62f..344a80f35 100644
--- a/streams/tests/aacdepay.test.ts
+++ b/streams/tests/aacdepay.test.ts
@@ -1,16 +1,14 @@
 import * as assert from 'uvu/assert'
+import { describe } from './uvu-describe'
 
 import { toByteArray } from 'base64-js'
 
-import { AACDepay } from 'components/aacdepay'
-import { Message, MessageType } from 'components/message'
-import { payload } from 'utils/protocols/rtp'
-import { sdpFromBody } from 'utils/protocols/sdp'
+import { AACDepay, RtpMessage } from '../src/components'
 
-import { describe } from './uvu-describe'
-import { runComponentTests } from './validate-component'
+import { parseRtp } from '../src/components/rtsp/rtp'
+import { parseSdp } from '../src/components/rtsp/sdp'
 
-const sdpMessage = sdpFromBody(`
+const sdp = parseSdp(`
 v=0
 o=- 18315797286303868614 1 IN IP4 127.0.0.1
 s=Session streamed with GStreamer
@@ -36,27 +34,26 @@ a=fmtp:97 streamtype=5;profile-level-id=2;mode=AAC-hbr;config=1408;sizeLength=13
 a=control:rtsp://hostname/axis-media/media.amp/stream=1?audio=1&video=1
 `)
 
-const rtpMessage = {
-  type: MessageType.RTP,
-  data: toByteArray(
-    'gOE3VgfSkcjtSUMVABAHyAEmNa0UobgEQutal+Vl5JNVvdLtBVkkrFXytphSh4iIAIi/D647wkC+' +
-      '+19nzXfn1DVGN9b7rquOONOLHxYfa+X1KnPvneEN+D/t5v152p9RC8X9/5/DcR/M65g/v/P7XxH+T9pePb/5/f8F/g/oU' +
-      'vtf9fh77sHwGgZn6v5/d7B95wlR7Ht67gOMPgE3FyU104ciaEGj5lElEouptaCTg0M3yBAizaANAjth8RpWzgLktGZd8xw' +
-      'uDXEzn3j+Gn55+yaLrEDqB1iVbQBWbRmv1ZjfCQBmKBw/b5Mw/xH+kP8LADeDgAAAAAAAAAAAAAAAAAAAAAAAAAAAew=='
-  ),
-}
+const rtpData = toByteArray(
+  'gOE3VgfSkcjtSUMVABAHyAEmNa0UobgEQutal+Vl5JNVvdLtBVkkrFXytphSh4iIAIi/D647wkC+' +
+    '+19nzXfn1DVGN9b7rquOONOLHxYfa+X1KnPvneEN+D/t5v152p9RC8X9/5/DcR/M65g/v/P7XxH+T9pePb/5/f8F/g/oU' +
+    'vtf9fh77sHwGgZn6v5/d7B95wlR7Ht67gOMPgE3FyU104ciaEGj5lElEouptaCTg0M3yBAizaANAjth8RpWzgLktGZd8xw' +
+    'uDXEzn3j+Gn55+yaLrEDqB1iVbQBWbRmv1ZjfCQBmKBw/b5Mw/xH+kP8LADeDgAAAAAAAAAAAAAAAAAAAAAAAAAAAew=='
+)
+
+const rtpMessage = new RtpMessage({
+  channel: 2,
+  ...parseRtp(rtpData),
+})
 
 describe('aacdepay component', (test) => {
-  const c = new AACDepay()
-  runComponentTests(c, 'aacdepay', test)
+  const aacDepay = new AACDepay(sdp.media)
 
   test('Emits an AAC package with headers cut', () => {
-    c.incoming.write(sdpMessage)
-    c.incoming.write(rtpMessage)
-    c.incoming.read() // Skip sdp which is passed through
-    const msg: Message = c.incoming.read()
-    assert.is(msg.type, MessageType.ELEMENTARY)
+    const msg = aacDepay.parse(rtpMessage)
+    assert.ok(msg)
+    assert.is(msg.type, 'elementary')
     // The header should be cut
-    assert.is(msg.data.length + 4, payload(rtpMessage.data).length)
+    assert.is(msg.data.length, rtpMessage.data.length - 4)
   })
 })
diff --git a/streams/tests/auth-digest.test.ts b/streams/tests/auth-digest.test.ts
index c764c85d0..069db73d8 100644
--- a/streams/tests/auth-digest.test.ts
+++ b/streams/tests/auth-digest.test.ts
@@ -1,14 +1,14 @@
 import * as assert from 'uvu/assert'
-
-import { DigestAuth } from 'components/auth/digest'
-import { parseWWWAuthenticate } from 'components/auth/www-authenticate'
+import { describe } from './uvu-describe'
 
 import { authHeaders, cnonce, credentials, request } from './auth.fixtures'
-import { describe } from './uvu-describe'
+
+import { DigestAuth } from '../src/components/rtsp/auth/digest'
+import { parseWWWAuthenticate } from '../src/components/rtsp/auth/www-authenticate'
 
 const header = authHeaders['WWW-Authenticate']
 
-describe('digest challenge', (test) => {
+describe('RTSP: auth digest challenge', (test) => {
   test('generates the correct authentication header', () => {
     const challenge = parseWWWAuthenticate(header)
     const digest = new DigestAuth(
diff --git a/streams/tests/auth.fixtures.ts b/streams/tests/auth.fixtures.ts
index c36cd29bc..01b872db7 100644
--- a/streams/tests/auth.fixtures.ts
+++ b/streams/tests/auth.fixtures.ts
@@ -12,7 +12,7 @@ export const cnonce = '0a4f113b'
 
 export const authHeaders = {
   'WWW-Authenticate':
-    'WWW-Authenticate: Digest realm="Test realm@host.com", qop="auth,auth-int", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", opaque="5ccc069c403ebaf9f0171e9517f40e41"',
+    'Digest realm="Test realm@host.com", qop="auth,auth-int", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", opaque="5ccc069c403ebaf9f0171e9517f40e41"',
   Authorization:
     'Digest username="Mufasa", realm="Test realm@host.com", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", uri="/dir/index.html", qop=auth, nc=00000001, cnonce="0a4f113b", response="e53a925871f91ac8c6169f94c3eba658", opaque="5ccc069c403ebaf9f0171e9517f40e41"',
 }
diff --git a/streams/tests/basicdepay.test.ts b/streams/tests/basicdepay.test.ts
deleted file mode 100644
index 93d398b37..000000000
--- a/streams/tests/basicdepay.test.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import * as assert from 'uvu/assert'
-
-import { toByteArray } from 'base64-js'
-
-import { BasicDepay } from 'components/basicdepay'
-import { MessageType } from 'components/message'
-
-import { decode } from 'utils/bytes'
-import { describe } from './uvu-describe'
-import { runComponentTests } from './validate-component'
-
-const rtpMessage1 = {
-  type: MessageType.RTP,
-  data: toByteArray(
-    'gGIrsXKxrCZG6KGHPD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZ' +
-      'z0iVVRGLTgiPz4KPHR0Ok1ldGFkYXRhU3RyZWFtIHhtbG5zOnR0PSJodHRwOi8vd3d3Lm9u' +
-      'dmlmLm9yZy92ZXIxMC9zY2hlbWEiPgo8dHQ6UFRaPgo8dHQ6UFRaU3RhdHVzPgogIDx0dDp' +
-      'VdGNUaW1lPjIwMTctMDMtMjlUMTI6MTU6MzEuNjEwMDIwWjwvdHQ6VXRjVGltZT4KPC90dDpQVFpTdGF0dXM='
-  ),
-}
-const rtpMessage2 = {
-  type: MessageType.RTP,
-  data: toByteArray('gOIrsnKxrCZG6KGHL3R0OlBUWj4KPC90dDpNZXRhZGF0YVN0cmVhbT4K'),
-}
-
-describe('basicdepay', (test) => {
-  const c = new BasicDepay(98)
-
-  runComponentTests(c, 'basicdepay component', test)
-
-  test('Rebuilds objects split over multiple RTP packages', () => {
-    c.incoming.write(rtpMessage1)
-    assert.is(c.incoming.read(), null) // No data should be available
-
-    // Write the second part of the message
-    c.incoming.write(rtpMessage2)
-    const msg = c.incoming.read()
-    assert.is(msg.type, MessageType.ELEMENTARY)
-    assert.is(
-      decode(msg.data),
-      `
-
-
-
-  2017-03-29T12:15:31.610020Z
-
-
-`
-    )
-  })
-})
diff --git a/streams/tests/canvas.test.ts b/streams/tests/canvas.test.ts
deleted file mode 100644
index 4bb1f930f..000000000
--- a/streams/tests/canvas.test.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import 'global-jsdom/register'
-
-import { CanvasSink } from 'components/canvas'
-
-import { describe } from './uvu-describe'
-import { runComponentTests } from './validate-component'
-
-describe('canvas component', (test) => {
-  const fakeCanvas = document.createElement('canvas')
-  fakeCanvas.getContext = () => null
-  const canvasComponent = new CanvasSink(fakeCanvas)
-  runComponentTests(canvasComponent, 'Canvas', test)
-})
diff --git a/streams/tests/components.test.ts b/streams/tests/components.test.ts
deleted file mode 100644
index 128bd47c5..000000000
--- a/streams/tests/components.test.ts
+++ /dev/null
@@ -1,198 +0,0 @@
-import * as assert from 'uvu/assert'
-
-import { Sink, Source, Tube } from 'components/component'
-import StreamFactory from 'components/helpers/stream-factory'
-
-import { describe } from './uvu-describe'
-import { runComponentTests } from './validate-component'
-
-// tests
-const source = () =>
-  new Source(StreamFactory.producer(), StreamFactory.consumer())
-const pass = () => new Tube()
-const sink = () => new Sink(StreamFactory.consumer(), StreamFactory.producer())
-
-// Validate components.
-const components = {
-  source,
-  pass,
-  sink,
-}
-
-describe('valid component', (test) => {
-  for (const [key, value] of Object.entries(components)) {
-    runComponentTests(value(), key, test)
-  }
-})
-
-/**
- * Components can only be connected when they have complementary
- * incoming and outgoing streams. The following tests assure that
- * the 'attach' method catches bad combinations.
- *
- * Schematic of components:
- *
- *     +-----------+                     +-----------+
- *     |           <---+                 |           <---+
- *     |           |     X               |           |
- *     |   source  |     X               |   source  |
- *     |           |     X               |           |
- *     |           +--->                 |           +--->
- *     +-----------+                     +-----------+
- *
- *     +-----------+                     +-----------+
- * <-------------------+             <-------------------+
- *     |           |     X         X     |           |
- *     |   pass    |     X         X     |   pass    |
- *     |           |     X         X     |           |
- * +------------------->             +------------------->
- *     +-----------+                     +-----------+
- *
- *     +-----------+                     +-----------+
- * <---+           |                 <---+           |
- *     |           |               X     |           |
- *     |   sink    |               X     |   sink    |
- *     |           |               X     |           |
- * +--->           |                 +--->           |
- *     +-----------+                     +-----------+
- */
-
-const badPairs = [
-  [source, source],
-  [pass, source],
-  [sink, source],
-  [sink, pass],
-  [sink, sink],
-]
-
-const goodPairs = [
-  [source, pass],
-  [source, sink],
-  [pass, pass],
-  [pass, sink],
-]
-
-describe('connect', (test) => {
-  test('bad pairs should not be allowed to be connected', () => {
-    for (const [srcGen, dstGen] of badPairs) {
-      const src = srcGen()
-      const dst = dstGen()
-
-      assert.throws(() => src.connect(dst as any), 'connection failed')
-    }
-  })
-
-  test('good pairs should be able to connect without throwing', () => {
-    for (const [srcGen, dstGen] of goodPairs) {
-      const src = srcGen()
-      const dst = dstGen()
-
-      assert.is(src.connect(dst as any), dst)
-    }
-  })
-
-  test('null components should not break the chaining', () => {
-    const src = source()
-    const dst = sink()
-    assert.is(src.connect(null).connect(dst), dst)
-  })
-
-  test('already connected source should not be allowed to connect', () => {
-    const src = source()
-    const dst1 = sink()
-    const dst2 = sink()
-    src.connect(dst1)
-    assert.throws(() => src.connect(dst2), 'connection failed')
-  })
-
-  test('already connected destination should not be allowed to connect', () => {
-    const src1 = source()
-    const src2 = source()
-    const dst = sink()
-    src1.connect(dst)
-    assert.throws(() => src2.connect(dst), 'connection failed')
-  })
-})
-
-describe('disconnect', (test) => {
-  test('not-connected components should be able to disconnect', () => {
-    const src = source()
-    assert.is(src.disconnect(), src)
-  })
-
-  test('connected components should be able to disconnect', () => {
-    const src = source()
-    const dst = sink()
-    src.connect(dst)
-    assert.is(src.disconnect(), src)
-  })
-
-  test('disconnected components should be able to reconnect', () => {
-    const src = source()
-    const dst = sink()
-    src.connect(dst)
-    src.disconnect()
-    assert.is(src.connect(dst), dst)
-  })
-})
-
-describe('error propagation', (test) => {
-  test('errors should be propagated if connected', (ctx) => {
-    const src = source()
-    const dst = sink()
-
-    // Set up spies that will be called when an error occurs
-    ctx.srcIncomingError = undefined
-    ctx.srcOutgoingError = undefined
-    ctx.dstIncomingError = undefined
-    ctx.dstOutgoingError = undefined
-    const srcIncomingError = (error: unknown) => (ctx.srcIncomingError = error)
-    const srcOutgoingError = (error: unknown) => (ctx.srcOutgoingError = error)
-    const dstIncomingError = (error: unknown) => (ctx.dstIncomingError = error)
-    const dstOutgoingError = (error: unknown) => (ctx.dstOutgoingError = error)
-
-    // Handle all error events (jest doesn't like unhandled events)
-    src.incoming.on('error', srcIncomingError)
-    src.outgoing.on('error', srcOutgoingError)
-    dst.incoming.on('error', dstIncomingError)
-    dst.outgoing.on('error', dstOutgoingError)
-
-    src.connect(dst)
-
-    dst.incoming.emit('error', 'testError')
-    assert.is(ctx.srcIncomingError, 'testError')
-
-    src.outgoing.emit('error', 'testError')
-    assert.is(ctx.dstOutgoingError, 'testError')
-  })
-
-  test('errors should not be propagated depending if disconnected', (ctx) => {
-    const src = source()
-    const dst = sink()
-
-    // Set up spies that will be called when an error occurs
-    ctx.srcIncomingError = undefined
-    ctx.srcOutgoingError = undefined
-    ctx.dstIncomingError = undefined
-    ctx.dstOutgoingError = undefined
-    const srcIncomingError = (error: unknown) => (ctx.srcIncomingError = error)
-    const srcOutgoingError = (error: unknown) => (ctx.srcOutgoingError = error)
-    const dstIncomingError = (error: unknown) => (ctx.dstIncomingError = error)
-    const dstOutgoingError = (error: unknown) => (ctx.dstOutgoingError = error)
-
-    // Handle all error events (jest doesn't like unhandled events)
-    src.incoming.on('error', srcIncomingError)
-    src.outgoing.on('error', srcOutgoingError)
-    dst.incoming.on('error', dstIncomingError)
-    dst.outgoing.on('error', dstOutgoingError)
-
-    src.connect(dst)
-    src.disconnect()
-
-    dst.incoming.emit('error', 'testError')
-    assert.is(ctx.srcIncomingError, undefined)
-
-    src.outgoing.emit('error', 'testError')
-    assert.is(ctx.dstOutgoingError, undefined)
-  })
-})
diff --git a/streams/tests/h264depay-parser.test.ts b/streams/tests/h264depay-parser.test.ts
deleted file mode 100644
index b2ae047d0..000000000
--- a/streams/tests/h264depay-parser.test.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import * as assert from 'uvu/assert'
-
-import { toByteArray } from 'base64-js'
-
-import { AssertionError } from 'assert'
-import { H264DepayParser } from 'components/h264depay/parser'
-import { MessageType } from 'components/message'
-
-import { describe } from './uvu-describe'
-
-/*
- * The h264Handler is more thoroughly tested in the end2end test.
- */
-describe('h264 parser', (test) => {
-  test.before.each((ctx) => {
-    ctx.h264Parser = new H264DepayParser()
-  })
-
-  test('parses a single NALU packet', (ctx) => {
-    const singleNalu = toByteArray('gOATzCCbbTXpPLiiQZrALBJ/AEphqA==')
-    const msg = ctx.h264Parser.parse({
-      type: MessageType.RTP,
-      data: singleNalu,
-      channel: 0,
-    })
-
-    assert.is.not(msg, null)
-
-    assert.is((ctx.h264Parser as any)._buffer.length, 0)
-
-    if (msg === null) {
-      throw new AssertionError()
-    }
-    assert.is(msg.timestamp, 547056949)
-    assert.is(msg.type, MessageType.H264)
-    assert.is(msg.data.length, 14)
-    assert.is(msg.payloadType, 96)
-  })
-
-  test('parses a FU-A frame split on two RTP packages', (ctx) => {
-    const fuaPart1 = toByteArray(
-      'gGBwUAkfABNeSvUmfIWIgwAAv7fhaOZ7/8I48OQXY7Fpl6o9HpvJiYz5b2JyowHtuVDBxLY9ZL8FHJOD6rs6h91CSMQmA9fgnTDCVgJ5vdm99c7OMzF3l4K9+VJeZ4eKyC32WVXoVh3h+KVVJERORlYXJDq+1IlMC0EzAqltdPKwC1UmwbsMgtz6fjR/v19wZf0DXOfxTBnb0OnN83kR5G8TffuGm2njvkWsEX7ecpJDzhu0Wn0RZ9Z0I39RuOT5hHrKKSMQSfwWbITrzL+j5bneysE7nAD9mPsEQxqH99GPZodENIbuYhog8TS/Qlv+Ty20GkAZfbZILfjoELO9ahh2wQgLaGd031W4Z7bmM7WACu7fPVm4blRP1rhomufuUAD8ceqjqxcivy5CxeyWS764bBNkffWBVHL7PpzXPhd4e56YduXnWwQO1REIs2MiPfyx7UumMIwDCCKhgDf3BUxWuSXVqcORn0aSp7k8SFCM/767e1peyADK+WKuWVDbrDvPW2igZKBADyashVjvNhdaHJBCWPOpVwfghRhSjeaK2k6/OdY6ebpRDv4J7ZnUCGnNspqy6fo5WbUoQwc4+3xXbq8lN7kYP9zSH4iExe7f//+9flejgJql61Z4A34bwazQ/KlCmySYm/cbIyWuZVQo0R8='
-    )
-    const fuaPart2 = toByteArray(
-      'gOBwUQkfABNeSvUmfEV10JWHPGgQDhsFYeRYLNcUCLF5ek1hA7BRpPeURyWGQa9vOSr5DM0WpqX78A=='
-    )
-    /* eslint-enable */
-    let msg = ctx.h264Parser.parse({
-      type: MessageType.RTP,
-      data: fuaPart1,
-      channel: 0,
-    })
-    assert.is(msg, null)
-
-    assert.ok((ctx.h264Parser as any)._buffer.length > 0)
-
-    msg = ctx.h264Parser.parse({
-      type: MessageType.RTP,
-      data: fuaPart2,
-      channel: 0,
-    })
-    assert.is.not(msg, null)
-
-    if (msg === null) {
-      throw new AssertionError()
-    }
-    assert.is(msg.timestamp, 153026579)
-    assert.is(msg.type, MessageType.H264)
-    assert.is(msg.data.length, 535)
-    assert.is(msg.payloadType, 96)
-  })
-})
diff --git a/streams/tests/h264depay.test.ts b/streams/tests/h264depay.test.ts
index 9b03cd912..34854fd21 100644
--- a/streams/tests/h264depay.test.ts
+++ b/streams/tests/h264depay.test.ts
@@ -1,9 +1,78 @@
-import { H264Depay } from 'components/h264depay'
-
+import * as assert from 'uvu/assert'
 import { describe } from './uvu-describe'
-import { runComponentTests } from './validate-component'
 
-describe('h264 component', (test) => {
-  const c = new H264Depay()
-  runComponentTests(c, 'h264depay', test)
+import { toByteArray } from 'base64-js'
+
+import { H264Depay, H264Media, RtpMessage } from '../src/components'
+
+import { parseRtp } from '../src/components/rtsp/rtp'
+
+const mediaFixture: H264Media = {
+  type: 'video',
+  port: 0,
+  protocol: 'RTP/AVP',
+  fmt: 96,
+  rtpmap: { payloadType: 96, clockrate: 90000, encodingName: 'H264' },
+  fmtp: {
+    format: '96',
+    parameters: {
+      'packetization-mode': '1',
+      'profile-level-id': '4d0029',
+      'sprop-parameter-sets': 'Z00AKeKQDwBE/LgLcBAQGkHiRFQ=,aO48gA==',
+    },
+  },
+}
+
+/*
+ * The h264Handler is more thoroughly tested in the end2end test.
+ */
+describe('h264 parser', (test) => {
+  test('parses a single NALU packet', () => {
+    const h264Depay = new H264Depay([mediaFixture])
+    const singleNalu = toByteArray('gOATzCCbbTXpPLiiQZrALBJ/AEphqA==')
+    const msg = h264Depay.parse(
+      new RtpMessage({
+        channel: 0,
+        ...parseRtp(singleNalu),
+      })
+    )
+
+    assert.ok(msg)
+    assert.is(msg.timestamp, 547056949)
+    assert.is(msg.type, 'h264')
+    assert.is(msg.data.length, 14)
+    assert.is(msg.payloadType, 96)
+  })
+
+  test('parses a FU-A frame split on two RTP packages', () => {
+    const h264Depay = new H264Depay([mediaFixture])
+    const fuaPart1 = toByteArray(
+      'gGBwUAkfABNeSvUmfIWIgwAAv7fhaOZ7/8I48OQXY7Fpl6o9HpvJiYz5b2JyowHtuVDBxLY9ZL8FHJOD6rs6h91CSMQmA9fgnTDCVgJ5vdm99c7OMzF3l4K9+VJeZ4eKyC32WVXoVh3h+KVVJERORlYXJDq+1IlMC0EzAqltdPKwC1UmwbsMgtz6fjR/v19wZf0DXOfxTBnb0OnN83kR5G8TffuGm2njvkWsEX7ecpJDzhu0Wn0RZ9Z0I39RuOT5hHrKKSMQSfwWbITrzL+j5bneysE7nAD9mPsEQxqH99GPZodENIbuYhog8TS/Qlv+Ty20GkAZfbZILfjoELO9ahh2wQgLaGd031W4Z7bmM7WACu7fPVm4blRP1rhomufuUAD8ceqjqxcivy5CxeyWS764bBNkffWBVHL7PpzXPhd4e56YduXnWwQO1REIs2MiPfyx7UumMIwDCCKhgDf3BUxWuSXVqcORn0aSp7k8SFCM/767e1peyADK+WKuWVDbrDvPW2igZKBADyashVjvNhdaHJBCWPOpVwfghRhSjeaK2k6/OdY6ebpRDv4J7ZnUCGnNspqy6fo5WbUoQwc4+3xXbq8lN7kYP9zSH4iExe7f//+9flejgJql61Z4A34bwazQ/KlCmySYm/cbIyWuZVQo0R8='
+    )
+    const fuaPart2 = toByteArray(
+      'gOBwUQkfABNeSvUmfEV10JWHPGgQDhsFYeRYLNcUCLF5ek1hA7BRpPeURyWGQa9vOSr5DM0WpqX78A=='
+    )
+    /* eslint-enable */
+    let msg = h264Depay.parse(
+      new RtpMessage({
+        channel: 0,
+        ...parseRtp(fuaPart1),
+      })
+    )
+
+    assert.is(msg, undefined)
+
+    msg = h264Depay.parse(
+      new RtpMessage({
+        channel: 0,
+        ...parseRtp(fuaPart2),
+      })
+    )
+
+    assert.ok(msg)
+    assert.is(msg.timestamp, 153026579)
+    assert.is(msg.type, 'h264')
+    assert.is(msg.data.length, 535)
+    assert.is(msg.payloadType, 96)
+  })
 })
diff --git a/streams/tests/inspector.test.ts b/streams/tests/inspector.test.ts
deleted file mode 100644
index dafc326a1..000000000
--- a/streams/tests/inspector.test.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { Inspector } from 'components/inspector'
-
-import { describe } from './uvu-describe'
-import { runComponentTests } from './validate-component'
-
-// utils
-// const StreamFactory = require('../helpers/stream-factory');
-
-// tests
-const inspector = new Inspector()
-describe('inspector component', (test) => {
-  runComponentTests(inspector, 'inspector', test)
-})
diff --git a/streams/tests/jpegdepay.test.ts b/streams/tests/jpegdepay.test.ts
deleted file mode 100644
index 3adbd7be9..000000000
--- a/streams/tests/jpegdepay.test.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { JPEGDepay } from 'components/jpegdepay'
-
-import { describe } from './uvu-describe'
-import { runComponentTests } from './validate-component'
-
-describe('JPEG depay component', (test) => {
-  const c = new JPEGDepay()
-  runComponentTests(c, 'jpegdepay', test)
-})
diff --git a/streams/tests/mp4-capture.test.ts b/streams/tests/mp4-capture.test.ts
new file mode 100644
index 000000000..92a93f2c7
--- /dev/null
+++ b/streams/tests/mp4-capture.test.ts
@@ -0,0 +1,131 @@
+import * as assert from 'uvu/assert'
+import { describe } from './uvu-describe'
+
+import EventEmitter from 'node:events'
+
+import { IsomMessage, Mp4Capture } from '../src/components'
+import { consumer, peeker, producer } from '../src/components/utils/streams'
+
+// Mocks
+const MOCK_BUFFER_SIZE = 10 // Jest has problems with large buffers
+const MOCK_MOVIE_DATA = 0xff
+const MOCK_MOVIE_ENDING_DATA = 0xfe
+
+// A movie consists of ISOM packets, starting with an ISOM message that has a
+// tracks property.  We want to simulate the beginning and end of a movie, as
+// well as non-movie packets.
+const MOCK_MOVIE = [
+  new IsomMessage({
+    mimeType: 'video/mp4',
+    data: new Uint8Array(1).fill(MOCK_MOVIE_DATA),
+  }),
+  new IsomMessage({ data: new Uint8Array(1).fill(MOCK_MOVIE_DATA) }),
+] as const
+const MOCK_MOVIE_BUFFER = new Uint8Array(2).fill(MOCK_MOVIE_DATA)
+
+const MOCK_MOVIE_ENDING = [
+  new IsomMessage({ data: new Uint8Array(1).fill(MOCK_MOVIE_ENDING_DATA) }),
+  new IsomMessage({ data: new Uint8Array(1).fill(MOCK_MOVIE_ENDING_DATA) }),
+  new IsomMessage({ data: new Uint8Array(1).fill(MOCK_MOVIE_ENDING_DATA) }),
+  new IsomMessage({ data: new Uint8Array(1).fill(MOCK_MOVIE_ENDING_DATA) }),
+] as const
+
+// Set up a pipeline: source - capture - sink.
+const pipelineFactory = (
+  ...fragments: ReadonlyArray>
+) => {
+  const sourceMessages = ([] as ReadonlyArray).concat(...fragments)
+  const sinkCalled = { value: 0 }
+  const sinkHandler = () => {
+    sinkCalled.value++
+  }
+
+  const broadcast = new EventEmitter()
+
+  const source = producer(sourceMessages)
+  const peek = peeker((msg: IsomMessage) => {
+    console.log('message', msg)
+    broadcast.emit('message', msg)
+  })
+  const capture = new Mp4Capture(MOCK_BUFFER_SIZE)
+  const sink = consumer(sinkHandler)
+
+  const flow = () => source.pipeThrough(peek).pipeThrough(capture).pipeTo(sink)
+
+  return {
+    broadcast,
+    source,
+    capture,
+    sink,
+    sinkCalled,
+    flow,
+  }
+}
+
+describe('data copying', (test) => {
+  test('should not occur when capture inactive', async () => {
+    const pipeline = pipelineFactory(MOCK_MOVIE)
+
+    console.log('flow')
+    // Start the pipeline (this will flow the messages)
+    await pipeline.flow()
+    console.log('flow2')
+
+    assert.is(pipeline.sinkCalled.value, MOCK_MOVIE.length)
+    // @ts-ignore _bufferOffset is private but we want to check nothing was captured
+    assert.is(pipeline.capture._bufferOffset, 0)
+  })
+
+  test('should occur when capture active', async () => {
+    const pipeline = pipelineFactory(MOCK_MOVIE)
+
+    // Activate capture.
+    let capturedBuffer = new Uint8Array(0)
+    const captureHandler = (buffer: Uint8Array) => {
+      capturedBuffer = buffer
+    }
+    pipeline.capture.start(captureHandler)
+
+    // Start the pipeline (this will flow the messages)
+    await pipeline.flow()
+
+    assert.equal(capturedBuffer, MOCK_MOVIE_BUFFER)
+  })
+
+  test('should only occur when new movie has started', async () => {
+    const pipeline = pipelineFactory(MOCK_MOVIE_ENDING, MOCK_MOVIE)
+
+    // Activate capture.
+    let capturedBuffer = new Uint8Array(0)
+    const captureHandler = (buffer: Uint8Array) => {
+      capturedBuffer = buffer
+    }
+    pipeline.capture.start(captureHandler)
+
+    // Start the pipeline (this will flow the messages)
+    await pipeline.flow()
+
+    assert.equal(capturedBuffer, MOCK_MOVIE_BUFFER)
+  })
+
+  test('should stop when requested', async () => {
+    const pipeline = pipelineFactory(MOCK_MOVIE, MOCK_MOVIE_ENDING)
+
+    // Activate capture.
+    let capturedBuffer = new Uint8Array(0)
+    const captureHandler = (buffer: Uint8Array) => {
+      capturedBuffer = buffer
+    }
+    pipeline.capture.start(captureHandler)
+    pipeline.broadcast.on('message', (msg) => {
+      if (msg.data[0] === 0xfe) {
+        pipeline.capture.stop()
+      }
+    })
+
+    // Start the pipeline (this will flow the messages)
+    await pipeline.flow()
+
+    assert.equal(capturedBuffer, MOCK_MOVIE_BUFFER)
+  })
+})
diff --git a/streams/tests/mp4capture.test.ts b/streams/tests/mp4capture.test.ts
deleted file mode 100644
index 2b78e93b0..000000000
--- a/streams/tests/mp4capture.test.ts
+++ /dev/null
@@ -1,191 +0,0 @@
-import * as assert from 'uvu/assert'
-
-import { Sink, Source } from 'components/component'
-import { GenericMessage, MessageType } from 'components/message'
-import { Mp4Capture } from 'components/mp4capture'
-
-import { describe } from './uvu-describe'
-import { runComponentTests } from './validate-component'
-
-// Mocks
-const MOCK_BUFFER_SIZE = 10 // Jest has problems with large buffers
-const MOCK_MOVIE_DATA = 0xff
-const MOCK_MOVIE_ENDING_DATA = 0xfe
-
-// FIXME: remove and use GenericMessage when it's data is Uint8Array
-interface MockMessage {
-  readonly type: MessageType
-  readonly data: any
-  ntpTimestamp?: number
-}
-
-// A movie consists of ISOM packets, starting with an ISOM message that has a
-// tracks property.  We want to simulate the beginning and end of a movie, as
-// well as non-movie packets.
-const MOCK_MOVIE = [MessageType.ISOM, MessageType.ISOM].map((type, idx) => {
-  if (idx === 0) {
-    return {
-      type,
-      tracks: [],
-      data: new Uint8Array(1).fill(MOCK_MOVIE_DATA),
-    }
-  }
-  return { type, data: new Uint8Array(1).fill(MOCK_MOVIE_DATA) }
-})
-const MOCK_MOVIE_BUFFER = new Uint8Array(2).fill(MOCK_MOVIE_DATA)
-
-const MOCK_MOVIE_ENDING = [
-  MessageType.ISOM,
-  MessageType.ISOM,
-  MessageType.ISOM,
-  MessageType.ISOM,
-].map((type) => {
-  return { type, data: new Uint8Array(1).fill(MOCK_MOVIE_ENDING_DATA) }
-})
-
-const MOCK_NOT_MOVIE = ['', ''].map((type) => {
-  return {
-    type: type as unknown as MessageType, // Intentionally bad type for testing
-    data: new Uint8Array(1).fill(0),
-  }
-})
-
-/**
- * Set up a pipeline: source - capture - sink.
- * @param  fragments - Messages to send from source.
- * @return Components and function to start flow.
- */
-const pipelineFactory = (
-  ...fragments: ReadonlyArray>
-) => {
-  const sourceMessages = ([] as ReadonlyArray).concat(...fragments)
-  const sinkCalled = { value: 0 }
-  const sinkHandler = () => {
-    sinkCalled.value++
-  }
-
-  const source = Source.fromMessages(sourceMessages)
-  const capture = new Mp4Capture(MOCK_BUFFER_SIZE)
-  const sink = Sink.fromHandler(sinkHandler)
-
-  return {
-    source,
-    capture,
-    sink,
-    sinkCalled,
-    flow: () => source.connect(capture).connect(sink),
-  }
-}
-
-// Tests
-describe('it should follow standard component rules', (test) => {
-  const mp4capture = new Mp4Capture()
-  runComponentTests(mp4capture, 'mp4capture component', test)
-})
-
-describe('data copying', (test) => {
-  test('should not occur when capture inactive', async (ctx) => {
-    const pipeline = pipelineFactory(MOCK_MOVIE)
-
-    // Start the pipeline (this will flow the messages)
-    pipeline.flow()
-
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-    pipeline.sink.incoming.on('finish', () => {
-      assert.is(pipeline.sinkCalled.value, MOCK_MOVIE.length)
-      // @ts-ignore _bufferOffset is private but we want to check nothing was captured
-      assert.is(pipeline.capture._bufferOffset, 0)
-      ctx.resolve()
-    })
-    await done
-  })
-
-  test('should occur when capture active', async (ctx) => {
-    const pipeline = pipelineFactory(MOCK_MOVIE)
-
-    // Activate capture.
-    let capturedBuffer: Uint8Array
-    const captureHandler = (buffer: Uint8Array) => {
-      capturedBuffer = buffer
-    }
-    pipeline.capture.start(captureHandler)
-
-    // Start the pipeline (this will flow the messages)
-    pipeline.flow()
-
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-    pipeline.sink.incoming.on('finish', () => {
-      assert.equal(capturedBuffer, MOCK_MOVIE_BUFFER)
-      ctx.resolve()
-    })
-    await done
-  })
-
-  test('should only occur when new movie has started', async (ctx) => {
-    const pipeline = pipelineFactory(MOCK_MOVIE_ENDING, MOCK_MOVIE)
-
-    // Activate capture.
-    let capturedBuffer: Uint8Array
-    const captureHandler = (buffer: Uint8Array) => {
-      capturedBuffer = buffer
-    }
-    pipeline.capture.start(captureHandler)
-
-    // Start the pipeline (this will flow the messages)
-    pipeline.flow()
-
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-    pipeline.sink.incoming.on('finish', () => {
-      assert.equal(capturedBuffer, MOCK_MOVIE_BUFFER)
-      ctx.resolve()
-    })
-    await done
-  })
-
-  test('should not occur when not a movie', async (ctx) => {
-    const pipeline = pipelineFactory(MOCK_MOVIE, MOCK_NOT_MOVIE)
-
-    // Activate capture.
-    let capturedBuffer: Uint8Array
-    const captureHandler = (buffer: Uint8Array) => {
-      capturedBuffer = buffer
-    }
-    pipeline.capture.start(captureHandler)
-
-    // Start the pipeline (this will flow the messages)
-    pipeline.flow()
-
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-    pipeline.sink.incoming.on('finish', () => {
-      assert.equal(capturedBuffer, MOCK_MOVIE_BUFFER)
-      ctx.resolve()
-    })
-    await done
-  })
-
-  test('should stop when requested', async (ctx) => {
-    const pipeline = pipelineFactory(MOCK_MOVIE, MOCK_MOVIE_ENDING)
-
-    // Activate capture.
-    let capturedBuffer: Uint8Array
-    const captureHandler = (buffer: Uint8Array) => {
-      capturedBuffer = buffer
-    }
-    pipeline.capture.start(captureHandler)
-    pipeline.source.incoming.on('data', (msg) => {
-      if (msg.data[0] === 0xfe) {
-        pipeline.capture.stop()
-      }
-    })
-
-    // Start the pipeline (this will flow the messages)
-    pipeline.flow()
-
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-    pipeline.sink.incoming.on('finish', () => {
-      assert.equal(capturedBuffer, MOCK_MOVIE_BUFFER)
-      ctx.resolve()
-    })
-    await done
-  })
-})
diff --git a/streams/tests/mp4muxer.test.ts b/streams/tests/mp4muxer.test.ts
deleted file mode 100644
index 0b5d4b22a..000000000
--- a/streams/tests/mp4muxer.test.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { Mp4Muxer } from 'components/mp4muxer'
-
-import { describe } from './uvu-describe'
-import { runComponentTests } from './validate-component'
-
-// tests
-describe('mp4muxer component', (test) => {
-  const mp4muxer = new Mp4Muxer()
-  runComponentTests(mp4muxer, 'mp4muxer', test)
-})
diff --git a/streams/tests/mse.test.ts b/streams/tests/mse.test.ts
deleted file mode 100644
index 0aec400b6..000000000
--- a/streams/tests/mse.test.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import 'global-jsdom/register'
-
-import { MseSink } from 'components/mse'
-
-import { describe } from './uvu-describe'
-import { runComponentTests } from './validate-component'
-
-// tests
-describe('mse component', (test) => {
-  const mse = new MseSink(document.createElement('video'))
-  runComponentTests(mse, 'mse', test)
-})
diff --git a/streams/tests/onvifdepay.test.ts b/streams/tests/onvifdepay.test.ts
index f04ca3f65..bc755f405 100644
--- a/streams/tests/onvifdepay.test.ts
+++ b/streams/tests/onvifdepay.test.ts
@@ -1,9 +1,69 @@
-import { ONVIFDepay } from 'components/onvifdepay'
-
+import * as assert from 'uvu/assert'
 import { describe } from './uvu-describe'
-import { runComponentTests } from './validate-component'
 
-describe('onvifdepay component', (test) => {
-  const c = new ONVIFDepay()
-  runComponentTests(c, 'onvifdepay', test)
+import { toByteArray } from 'base64-js'
+
+import { ONVIFDepay, RtpMessage, XmlMessage } from '../src/components'
+
+import { parseRtp } from '../src/components/rtsp/rtp'
+import { decode } from '../src/components/utils/bytes'
+
+const rtp1 = toByteArray(
+  'gGIrsXKxrCZG6KGHPD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZ' +
+    'z0iVVRGLTgiPz4KPHR0Ok1ldGFkYXRhU3RyZWFtIHhtbG5zOnR0PSJodHRwOi8vd3d3Lm9u' +
+    'dmlmLm9yZy92ZXIxMC9zY2hlbWEiPgo8dHQ6UFRaPgo8dHQ6UFRaU3RhdHVzPgogIDx0dDp' +
+    'VdGNUaW1lPjIwMTctMDMtMjlUMTI6MTU6MzEuNjEwMDIwWjwvdHQ6VXRjVGltZT4KPC90dDpQVFpTdGF0dXM='
+)
+
+const rtp2 = toByteArray(
+  'gOIrsnKxrCZG6KGHL3R0OlBUWj4KPC90dDpNZXRhZGF0YVN0cmVhbT4K'
+)
+
+const rtpMessage1 = new RtpMessage({
+  channel: 2,
+  ...parseRtp(rtp1),
+})
+
+const rtpMessage2 = new RtpMessage({
+  channel: 2,
+  ...parseRtp(rtp2),
+})
+
+describe('onvifdepay', (test) => {
+  test('Rebuilds objects split over multiple RTP packages', () => {
+    const onvifDepay = new ONVIFDepay([
+      {
+        type: 'application',
+        port: 0,
+        protocol: 'RTP/AVP',
+        fmt: 98,
+        fmtp: { format: '', parameters: {} },
+        rtpmap: {
+          payloadType: 98,
+          encodingName: 'VND.ONVIF.METADATA',
+          clockrate: 0,
+        },
+      },
+    ])
+
+    let msg: XmlMessage | undefined
+    msg = onvifDepay.parse(rtpMessage1)
+    assert.is(msg, undefined) // No data should be available
+
+    // Write the second part of the message
+    msg = onvifDepay.parse(rtpMessage2)
+    assert.ok(msg)
+    assert.is(msg.type, 'xml')
+    assert.is(
+      decode(msg.data),
+      `
+
+
+
+  2017-03-29T12:15:31.610020Z
+
+
+`
+    )
+  })
 })
diff --git a/streams/tests/protocols-rtsp.test.ts b/streams/tests/protocols-rtsp.test.ts
deleted file mode 100644
index 1492981f7..000000000
--- a/streams/tests/protocols-rtsp.test.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import * as assert from 'uvu/assert'
-
-import {
-  bodyOffset,
-  connectionEnded,
-  contentBase,
-  sequence,
-  sessionId,
-  sessionTimeout,
-  statusCode,
-} from 'utils/protocols/rtsp'
-
-import {
-  optionsResponseLowerCase,
-  sdpResponse,
-  sdpResponseLive555,
-  setupResponse,
-  setupResponseNoTimeout,
-  teardownResponse,
-} from './protocols.fixtures'
-import { describe } from './uvu-describe'
-
-describe('sequence', (test) => {
-  test('should return an int', () => {
-    assert.is(sequence(sdpResponse), 3)
-    assert.is(sequence(setupResponse), 5)
-    assert.is(sequence(optionsResponseLowerCase), 1)
-  })
-})
-
-describe('sessionId', (test) => {
-  test('should be a null before SETUP', () => {
-    assert.is(sessionId(sdpResponse), null)
-  })
-  test('should be present in a SETUP response', () => {
-    assert.is(sessionId(setupResponse), 'Bk48Ak7wjcWaAgRD')
-  })
-  test('should be present in a TEARDOWN response', () => {
-    assert.is(sessionId(teardownResponse), 'ZyHdf8Mn.$epq_8Z')
-  })
-})
-
-describe('sessionTimeout', (test) => {
-  test('should be null before SETUP', () => {
-    assert.is(sessionTimeout(sdpResponse), null)
-  })
-  test('should be extracted correctly when in a SETUP response', () => {
-    assert.is(sessionTimeout(setupResponse), 120)
-  })
-  test('should be 60 when not specified in a SETUP response', () => {
-    assert.is(sessionTimeout(setupResponseNoTimeout), 60)
-  })
-})
-
-describe('statusCode', (test) => {
-  test('should return an integer', () => {
-    assert.is(statusCode(sdpResponseLive555), 200)
-    assert.is(statusCode(teardownResponse), 200)
-  })
-})
-
-describe('contentBase', (test) => {
-  test('should return correct contentBase', () => {
-    assert.is(
-      contentBase(sdpResponse),
-      'rtsp://192.168.0.3/axis-media/media.amp/'
-    )
-  })
-  test('should return correct contentBase using live555', () => {
-    assert.is(contentBase(sdpResponseLive555), 'rtsp://127.0.0.1:8554/out.svg/')
-  })
-})
-
-describe('connectionEnded', (test) => {
-  test('should be true in a TEARDOWN response', () => {
-    assert.is(connectionEnded(teardownResponse), true)
-  })
-
-  test('should be false otherwise', () => {
-    assert.is(connectionEnded(setupResponse), false)
-  })
-})
-
-describe('bodyOffset', (test) => {
-  test('should return the lowest index of all possible line breaks', () => {
-    const bodyWithLinebreaks = '\r\r\r\n\r\n\n\n'
-    const buf = new Uint8Array(setupResponse.length + bodyWithLinebreaks.length)
-    setupResponse.split('').forEach((character, index) => {
-      buf[index] = character.charCodeAt(0)
-    })
-    bodyWithLinebreaks.split('').forEach((character, index) => {
-      buf[index + setupResponse.length] = character.charCodeAt(0)
-    })
-    assert.is(bodyOffset(buf), setupResponse.length)
-  })
-})
diff --git a/streams/tests/recorder.test.ts b/streams/tests/recorder.test.ts
deleted file mode 100644
index 9b211849e..000000000
--- a/streams/tests/recorder.test.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import * as assert from 'uvu/assert'
-
-import { Sink, Source } from 'components/component'
-import StreamFactory from 'components/helpers/stream-factory'
-import { Message } from 'components/message'
-import { Recorder } from 'components/recorder'
-
-import { describe } from './uvu-describe'
-import { runComponentTests } from './validate-component'
-
-describe('recorder component', (test) => {
-  const fakeStorage = () => {
-    /** empty */
-  }
-  const recorder = new Recorder(StreamFactory.consumer(fakeStorage))
-
-  runComponentTests(recorder, 'recorder', test)
-
-  test('recorder saves data', async (ctx) => {
-    // Prepare data to be sent by server, send it, then close the connection.
-    const send = [{ data: 'spam' }, { data: 'eggs' }]
-    ctx.loggerCalled = 0
-    ctx.loggerData = []
-    const logger = (msg: Message) => {
-      ctx.loggerCalled++
-      ctx.loggerData.push(msg)
-    }
-
-    const source = Source.fromMessages(send as any) // We want to signal end of stream.
-    const sink = Sink.fromHandler(logger)
-
-    source.connect(recorder).connect(sink)
-
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-    // Wait for stream to end, then check what has happened.
-    sink.incoming.on('finish', () => {
-      assert.is(ctx.loggerCalled, send.length)
-      assert.equal(send, ctx.loggerData)
-      ctx.resolve()
-    })
-    await done
-  })
-})
diff --git a/streams/tests/replayer.test.ts b/streams/tests/replayer.test.ts
deleted file mode 100644
index 9b02da845..000000000
--- a/streams/tests/replayer.test.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import * as assert from 'uvu/assert'
-
-import { Sink } from 'components/component'
-import StreamFactory from 'components/helpers/stream-factory'
-import { Message } from 'components/message'
-import { Replayer } from 'components/replayer'
-
-import { describe } from './uvu-describe'
-import { runComponentTests } from './validate-component'
-
-describe('replayer component', (test) => {
-  const send = [{ data: 'spam' }, { data: 'eggs' }]
-  const fakePackets = [
-    { delay: 10, type: 'incoming', msg: send[0] },
-    { delay: 10, type: 'incoming', msg: send[1] },
-    { delay: 10, type: 'incoming', msg: null },
-  ]
-
-  const replayer = new Replayer(StreamFactory.producer(fakePackets as any))
-
-  runComponentTests(replayer, 'replayer', test)
-
-  test('replayer emits data', async (ctx) => {
-    // Prepare data to be sent by server, send it, then close the connection.
-    ctx.loggerCalled = 0
-    ctx.loggerData = []
-    const logger = (msg: Message) => {
-      ctx.loggerCalled++
-      ctx.loggerData.push(msg)
-    }
-    const sink = Sink.fromHandler(logger)
-
-    replayer.connect(sink)
-
-    // Wait for stream to end, then check what has happened.
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-    sink.incoming.on('finish', () => {
-      assert.is(ctx.loggerCalled, fakePackets.length - 1)
-      assert.equal(send, ctx.loggerData)
-      ctx.resolve()
-    })
-    await done
-  })
-})
diff --git a/streams/tests/protocols.fixtures.ts b/streams/tests/rtsp-headers.fixtures.ts
similarity index 88%
rename from streams/tests/protocols.fixtures.ts
rename to streams/tests/rtsp-headers.fixtures.ts
index 7254ce130..2bebee779 100644
--- a/streams/tests/protocols.fixtures.ts
+++ b/streams/tests/rtsp-headers.fixtures.ts
@@ -1,23 +1,3 @@
-/* biome-ignore format: custom formatting */
-export const rtpBuffers = [
-  new Uint8Array([128, 96, 80, 56, 225, 39, 20, 132, 25, 190, 186, 105]),
-  new Uint8Array([128, 224, 80, 76, 225, 39, 108, 97, 25, 190, 186, 105, 1, 2, 3]),
-  new Uint8Array([
-    129, 224, 80, 95, 225, 40, 57, 104, 25, 190, 186, 105, 0, 0, 0, 1, 1, 2, 3,
-  ]),
-]
-
-/* biome-ignore format: custom formatting */
-export const rtpBuffersWithHeaderExt = [
-  new Uint8Array([
-    144, 224, 80, 76, 225, 39, 108, 97, 25, 190, 186, 105, 1, 2, 0, 0, 1, 2, 3,
-  ]),
-  new Uint8Array([
-    144, 224, 80, 76, 225, 39, 108, 97, 25, 190, 186, 105, 1, 2, 0, 1, 1, 2, 3,
-    4, 1, 2, 3,
-  ]),
-]
-
 /* biome-ignore format: custom formatting */
 export const rtcpSRBuffers = [
   // 0 reports
diff --git a/streams/tests/rtsp-headers.test.ts b/streams/tests/rtsp-headers.test.ts
new file mode 100644
index 000000000..b087fab60
--- /dev/null
+++ b/streams/tests/rtsp-headers.test.ts
@@ -0,0 +1,120 @@
+import * as assert from 'uvu/assert'
+import { describe } from './uvu-describe'
+
+import {
+  optionsResponseLowerCase,
+  sdpResponse,
+  sdpResponseLive555,
+  setupResponse,
+  setupResponseNoTimeout,
+  teardownResponse,
+} from './rtsp-headers.fixtures'
+
+import {
+  bodyOffset,
+  parseResponse,
+  parseSession,
+} from '../src/components/rtsp/header'
+
+describe('sequence', (test) => {
+  test('should represent an int', () => {
+    assert.is(parseResponse(sdpResponse).headers.get('CSeq'), '3')
+    assert.is(parseResponse(setupResponse).headers.get('CSeq'), '5')
+    assert.is(parseResponse(optionsResponseLowerCase).headers.get('CSeq'), '1')
+  })
+})
+
+describe('sessionId', (test) => {
+  test('should be missing before SETUP', () => {
+    assert.is(parseResponse(sdpResponse).headers.get('Session'), null)
+  })
+  test('should be present in a SETUP response', () => {
+    const rsp = parseResponse(setupResponse)
+    const session = rsp.headers.get('Session')
+    assert.ok(session)
+    assert.is(session, 'Bk48Ak7wjcWaAgRD; timeout=120')
+    const { id, timeout } = parseSession(session)
+    assert.is(id, 'Bk48Ak7wjcWaAgRD')
+    assert.is(timeout, 120)
+  })
+  test('should be present in a TEARDOWN response', () => {
+    assert.is(
+      parseResponse(teardownResponse).headers.get('Session'),
+      'ZyHdf8Mn.$epq_8Z; timeout=60'
+    )
+  })
+})
+
+describe('sessionTimeout', (test) => {
+  test('should be missing before SETUP', () => {
+    assert.is(
+      parseSession(parseResponse(sdpResponse).headers.get('Session') ?? '')
+        .timeout,
+      undefined
+    )
+  })
+  test('should be extracted correctly when in a SETUP response', () => {
+    assert.is(
+      parseSession(parseResponse(setupResponse).headers.get('Session') ?? '')
+        .timeout,
+      120
+    )
+  })
+  test('should be missing when not specified in a SETUP response', () => {
+    assert.is(
+      parseSession(
+        parseResponse(setupResponseNoTimeout).headers.get('Session') ?? ''
+      ).timeout,
+      undefined
+    )
+  })
+})
+
+describe('statusCode', (test) => {
+  test('should return an integer', () => {
+    assert.is(parseResponse(sdpResponseLive555).statusCode, 200)
+    assert.is(parseResponse(teardownResponse).statusCode, 200)
+  })
+})
+
+describe('contentBase', (test) => {
+  test('should return correct contentBase', () => {
+    assert.is(
+      parseResponse(sdpResponse).headers.get('Content-Base'),
+      'rtsp://192.168.0.3/axis-media/media.amp/'
+    )
+  })
+  test('should return correct contentBase using live555', () => {
+    assert.is(
+      parseResponse(sdpResponseLive555).headers.get('Content-Base'),
+      'rtsp://127.0.0.1:8554/out.svg/'
+    )
+  })
+})
+
+describe('connection', (test) => {
+  test('should be closed in a TEARDOWN response', () => {
+    assert.is(
+      parseResponse(teardownResponse).headers.get('Connection'),
+      'close'
+    )
+  })
+
+  test('should be missing on setup', () => {
+    assert.is(parseResponse(setupResponse).headers.get('Connection'), null)
+  })
+})
+
+describe('bodyOffset', (test) => {
+  test('should return the lowest index of all possible line breaks', () => {
+    const bodyWithLinebreaks = '\r\r\r\n\r\n\n\n'
+    const buf = new Uint8Array(setupResponse.length + bodyWithLinebreaks.length)
+    setupResponse.split('').forEach((character, index) => {
+      buf[index] = character.charCodeAt(0)
+    })
+    bodyWithLinebreaks.split('').forEach((character, index) => {
+      buf[index + setupResponse.length] = character.charCodeAt(0)
+    })
+    assert.is(bodyOffset(buf), setupResponse.length)
+  })
+})
diff --git a/streams/tests/rtsp-parser-builder.test.ts b/streams/tests/rtsp-parser-builder.test.ts
deleted file mode 100644
index db392f52e..000000000
--- a/streams/tests/rtsp-parser-builder.test.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import * as assert from 'uvu/assert'
-
-import { MessageType, RtspMessage } from 'components/message'
-import { builder } from 'components/rtsp-parser/builder'
-import { decode } from 'utils/bytes'
-
-import { optionsRequest } from './rtsp-parser.fixtures'
-import { describe } from './uvu-describe'
-
-describe('rtsp-parser builder', (test) => {
-  test('builds a valid RTSP message from the passed in data', () => {
-    const msg: RtspMessage = {
-      type: MessageType.RTSP,
-      method: 'OPTIONS',
-      uri: 'rtsp://192.168.0.3/axis-media/media.amp?resolution=176x144&fps=1',
-      headers: {
-        CSeq: '1',
-        Date: 'Wed, 03 Jun 2015 14:26:16 GMT',
-      },
-      data: new Uint8Array(0),
-    }
-    const data = builder(msg)
-
-    assert.is(decode(data), optionsRequest)
-  })
-})
diff --git a/streams/tests/rtsp-parser-parser.test.ts b/streams/tests/rtsp-parser-parser.test.ts
deleted file mode 100644
index 289d3522d..000000000
--- a/streams/tests/rtsp-parser-parser.test.ts
+++ /dev/null
@@ -1,227 +0,0 @@
-import * as assert from 'uvu/assert'
-
-import { MessageType, SdpMessage } from 'components/message'
-import { Parser } from 'components/rtsp-parser/parser'
-import { concat, decode, encode } from 'utils/bytes'
-
-import {
-  frames,
-  rtspRtpRtcpCombined,
-  rtspWithTrailingRtp,
-  sdpResponse,
-  setupResponse,
-} from './rtsp-parser.fixtures'
-import { describe } from './uvu-describe'
-
-describe('parsing of interleaved data', (test) => {
-  test('can append buffers', () => {
-    const parser = new Parser()
-    const a = parser.parse(new Uint8Array(0))
-    assert.equal(a, [])
-  })
-
-  test('should handle a [36, 0, x] buffer correctly', () => {
-    const parser = new Parser()
-    const messages = parser.parse(new Uint8Array([36, 0, 5]))
-    assert.is(messages.length, 0)
-
-    assert.is((parser as any)._length, 3)
-  })
-
-  test('should handle a [36] buffer correctly', () => {
-    const parser = new Parser()
-    const messages = parser.parse(new Uint8Array([36]))
-    assert.is(messages.length, 0)
-
-    assert.is((parser as any)._length, 1)
-  })
-
-  test('should throw an error when coming across an unknown buffer', () => {
-    const parser = new Parser()
-    assert.throws(() => parser.parse(new Uint8Array([1, 2, 3])))
-  })
-})
-
-describe('1 buffer = 1 rtp package', (test) => {
-  let buffer1: Uint8Array
-  test.before(() => {
-    buffer1 = new Uint8Array(frames.onePointZero.length)
-    frames.onePointZero.forEach((byte, index) => {
-      buffer1[index] = byte
-    })
-  })
-
-  test('extracts one message', () => {
-    const parser = new Parser()
-    const messages = parser.parse(buffer1)
-    assert.is(messages.length, 1)
-  })
-
-  test('extracts message with correct data', () => {
-    const parser = new Parser()
-    const messages = parser.parse(buffer1)
-    const msg = messages[0]
-    assert.equal(concat([msg.data]), buffer1.slice(4))
-
-    assert.is((msg as any).channel, 0)
-  })
-
-  test('the buffer should be empty afterwards (no messages data buffered)', () => {
-    const parser = new Parser()
-    parser.parse(buffer1)
-
-    assert.is((parser as any)._length, 0)
-  })
-})
-
-describe('1 buffer = 1,5 rtp package', (test) => {
-  let buffer15: Uint8Array
-  test.before(() => {
-    buffer15 = new Uint8Array(frames.onePointFive.length)
-    frames.onePointFive.forEach((byte, index) => {
-      buffer15[index] = byte
-    })
-  })
-
-  test('extracts one message', () => {
-    const parser = new Parser()
-    const messages = parser.parse(buffer15)
-    assert.is(messages.length, 1)
-  })
-
-  test('extracts the full rtp frame', () => {
-    const parser = new Parser()
-    const messages = parser.parse(buffer15)
-    const msg = messages[0]
-    const emittedBuffer = msg.data
-    assert.is(msg.type, MessageType.RTP)
-    assert.equal(
-      concat([emittedBuffer]),
-      buffer15.slice(4, 4 + emittedBuffer.length)
-    )
-  })
-
-  test('the buffer should not be empty afterwards (half a frame messages)', () => {
-    const parser = new Parser()
-    const messages = parser.parse(buffer15)
-    const emittedBuffer = messages[0].data
-    assert.equal(
-      // @ts-ignore we want to check a private field
-      parser._chunks[0],
-      buffer15.slice(4 + emittedBuffer.length)
-    )
-  })
-})
-
-describe('2 buffers = 1,5 +0,5 rtp package', (test) => {
-  let buffer15: Uint8Array
-  let buffer05: Uint8Array
-  test.before(() => {
-    buffer15 = new Uint8Array(frames.onePointFive.length)
-    frames.onePointFive.forEach((byte, index) => {
-      buffer15[index] = byte
-    })
-    buffer05 = new Uint8Array(frames.zeroPointFive.length)
-    frames.zeroPointFive.forEach((byte, index) => {
-      buffer05[index] = byte
-    })
-  })
-
-  test('extracts two messages', () => {
-    const parser = new Parser()
-    let messages = parser.parse(buffer15)
-    assert.is(messages.length, 1)
-
-    assert.ok((parser as any)._length > 0)
-    messages = parser.parse(buffer05)
-    assert.is(messages.length, 1)
-  })
-
-  test('the buffer should be empty afterwards', () => {
-    const parser = new Parser()
-    parser.parse(buffer15)
-    parser.parse(buffer05)
-
-    assert.is((parser as any)._length, 0)
-  })
-})
-
-describe('RTSP package', (test) => {
-  let RtspBuffer: Uint8Array
-  test.before(() => {
-    RtspBuffer = new Uint8Array(setupResponse.length)
-    setupResponse.split('').forEach((character, index) => {
-      RtspBuffer[index] = character.charCodeAt(0)
-    })
-  })
-
-  test('extracts the RTSP buffer', () => {
-    const parser = new Parser()
-    const messages = parser.parse(RtspBuffer)
-    assert.is(messages.length, 1)
-    const msg = messages[0]
-    assert.is(msg.type, MessageType.RTSP)
-    assert.equal(msg.data, encode(setupResponse))
-  })
-
-  test('the buffer should be empty afterwards (no messages data buffered)', () => {
-    const parser = new Parser()
-    parser.parse(RtspBuffer)
-
-    assert.is((parser as any)._length, 0)
-  })
-
-  test('should detect RTP data in same buffer as RTSP', () => {
-    const parser = new Parser()
-    parser.parse(rtspWithTrailingRtp)
-
-    assert.is((parser as any)._length, 4)
-  })
-
-  test('should find RTSP, RTP and RTCP packages in the same buffer', () => {
-    const parser = new Parser()
-    const messages: Array = parser.parse(rtspRtpRtcpCombined)
-    assert.is(messages.length, 4)
-    assert.is(messages[0].type, MessageType.RTSP)
-    assert.is(messages[1].type, MessageType.RTP)
-    assert.is(messages[1].channel, 0)
-    assert.is(messages[2].type, MessageType.RTCP)
-    assert.is(messages[2].channel, 1)
-    assert.is(messages[3].type, MessageType.RTCP)
-    assert.is(messages[3].channel, 1)
-  })
-})
-
-describe('SDP data', (test) => {
-  let sdpBuffer: Uint8Array
-  test.before(() => {
-    sdpBuffer = encode(sdpResponse)
-  })
-
-  test('should extract twice, once with the full RTSP and once with the SDP data', () => {
-    const parser = new Parser()
-    const messages = parser.parse(sdpBuffer)
-    assert.is(messages.length, 2)
-    assert.is(messages[0].type, MessageType.RTSP)
-    assert.is(messages[1].type, MessageType.SDP)
-    assert.equal(messages[0].data, sdpBuffer)
-    assert.equal(messages[1].data.byteLength, 0)
-
-    const sdp = (messages[1] as SdpMessage).sdp
-    assert.is(typeof sdp, 'object')
-    assert.is(typeof sdp.session, 'object')
-    assert.ok(Array.isArray(sdp.media))
-    assert.is(sdp.media[0].type, 'video')
-  })
-
-  test('should handle segmented RTSP/SDP', () => {
-    const parser = new Parser()
-    const segmentedRTSP = sdpResponse.split(/(?<=\r\n\r\n)/g)
-    const RTSPBuffer: Uint8Array = encode(segmentedRTSP[0])
-    const SDPBuffer: Uint8Array = encode(segmentedRTSP[1])
-    let messages = parser.parse(RTSPBuffer)
-    assert.is(messages.length, 0)
-    messages = parser.parse(SDPBuffer)
-    assert.is(messages.length, 2)
-  })
-})
diff --git a/streams/tests/rtsp-parser.fixtures.ts b/streams/tests/rtsp-parser.fixtures.ts
index 0c2f6329a..ec6d67f32 100644
--- a/streams/tests/rtsp-parser.fixtures.ts
+++ b/streams/tests/rtsp-parser.fixtures.ts
@@ -1,10 +1,11 @@
-export const optionsRequest = `OPTIONS rtsp://192.168.0.3/axis-media/media.amp?resolution=176x144&fps=1 RTSP/1.0
+export const optionsRequest =
+  `OPTIONS rtsp://192.168.0.3/axis-media/media.amp?resolution=176x144&fps=1 RTSP/1.0
 CSeq: 1
 Date: Wed, 03 Jun 2015 14:26:16 GMT
 
 `
-  .split('\n')
-  .join('\r\n')
+    .split('\n')
+    .join('\r\n')
 
 export const sdpResponse = `RTSP/1.0 200 OK
 CSeq: 3
diff --git a/streams/tests/rtsp-parser.test.ts b/streams/tests/rtsp-parser.test.ts
index ece0cef95..6e5d324e1 100644
--- a/streams/tests/rtsp-parser.test.ts
+++ b/streams/tests/rtsp-parser.test.ts
@@ -1,9 +1,250 @@
-import { RtspParser } from 'components/rtsp-parser'
-
+import * as assert from 'uvu/assert'
 import { describe } from './uvu-describe'
-import { runComponentTests } from './validate-component'
 
-describe('rtsp-parser component', (test) => {
-  const c = new RtspParser()
-  runComponentTests(c, 'rtsp-parser', test)
+import {
+  frames,
+  optionsRequest,
+  rtspRtpRtcpCombined,
+  rtspWithTrailingRtp,
+  sdpResponse,
+  setupResponse,
+} from './rtsp-parser.fixtures'
+
+import type { RtspRequestMessage } from '../src/components'
+
+import { RtspParser } from '../src/components/rtsp/parser'
+import { serialize } from '../src/components/rtsp/serializer'
+import { concat, decode, encode } from '../src/components/utils/bytes'
+
+describe('parsing of interleaved data', (test) => {
+  test('can append buffers', () => {
+    const parser = new RtspParser()
+    const a = parser.parse(new Uint8Array(0))
+    assert.equal(a, [])
+  })
+
+  test('should handle a [36, 0, x] buffer correctly', () => {
+    const parser = new RtspParser()
+    const messages = parser.parse(new Uint8Array([36, 0, 5]))
+    assert.is(messages.length, 0)
+
+    assert.is((parser as any)._length, 3)
+  })
+
+  test('should handle a [36] buffer correctly', () => {
+    const parser = new RtspParser()
+    const messages = parser.parse(new Uint8Array([36]))
+    assert.is(messages.length, 0)
+
+    assert.is((parser as any)._length, 1)
+  })
+
+  test('should throw an error when coming across an unknown buffer', () => {
+    const parser = new RtspParser()
+    assert.throws(() => parser.parse(new Uint8Array([1, 2, 3])))
+  })
+})
+
+describe('1 buffer = 1 rtp package', (test) => {
+  let buffer1: Uint8Array
+  test.before(() => {
+    buffer1 = new Uint8Array(frames.onePointZero.length)
+    frames.onePointZero.forEach((byte, index) => {
+      buffer1[index] = byte
+    })
+  })
+
+  test('extracts one message', () => {
+    const parser = new RtspParser()
+    const messages = parser.parse(buffer1)
+    assert.is(messages.length, 1)
+  })
+
+  test('extracts message with correct data', () => {
+    const parser = new RtspParser()
+    const messages = parser.parse(buffer1)
+    const msg = messages[0]
+    assert.ok(msg.type === 'rtp', 'should be RTP message')
+    assert.equal(concat([msg.data]), buffer1.slice(16))
+
+    assert.is((msg as any).channel, 0)
+  })
+
+  test('the buffer should be empty afterwards (no messages data buffered)', () => {
+    const parser = new RtspParser()
+    parser.parse(buffer1)
+
+    assert.is((parser as any)._length, 0)
+  })
+})
+
+describe('1 buffer = 1,5 rtp package', (test) => {
+  let buffer15: Uint8Array
+  test.before(() => {
+    buffer15 = new Uint8Array(frames.onePointFive.length)
+    frames.onePointFive.forEach((byte, index) => {
+      buffer15[index] = byte
+    })
+  })
+
+  test('extracts one message', () => {
+    const parser = new RtspParser()
+    const messages = parser.parse(buffer15)
+    assert.is(messages.length, 1)
+  })
+
+  test('extracts the full rtp frame', () => {
+    const parser = new RtspParser()
+    const messages = parser.parse(buffer15)
+    const msg = messages[0]
+    assert.ok(msg.type === 'rtp', 'should be RTP message')
+    const emittedBuffer = msg.data
+    assert.is(msg.type, 'rtp')
+    assert.equal(
+      concat([emittedBuffer]),
+      buffer15.slice(16, 16 + emittedBuffer.length)
+    )
+  })
+
+  test('the buffer should not be empty afterwards (half a frame messages)', () => {
+    const parser = new RtspParser()
+    const messages = parser.parse(buffer15)
+    const msg = messages[0]
+    assert.ok(msg.type === 'rtp', 'should be RTP message')
+    const emittedBuffer = msg.data
+    assert.equal(
+      // @ts-ignore we want to check a private field
+      parser._chunks[0],
+      buffer15.slice(16 + emittedBuffer.length)
+    )
+  })
+})
+
+describe('2 buffers = 1,5 +0,5 rtp package', (test) => {
+  let buffer15: Uint8Array
+  let buffer05: Uint8Array
+  test.before(() => {
+    buffer15 = new Uint8Array(frames.onePointFive.length)
+    frames.onePointFive.forEach((byte, index) => {
+      buffer15[index] = byte
+    })
+    buffer05 = new Uint8Array(frames.zeroPointFive.length)
+    frames.zeroPointFive.forEach((byte, index) => {
+      buffer05[index] = byte
+    })
+  })
+
+  test('extracts two messages', () => {
+    const parser = new RtspParser()
+    let messages = parser.parse(buffer15)
+    assert.is(messages.length, 1)
+
+    assert.ok((parser as any)._length > 0)
+    messages = parser.parse(buffer05)
+    assert.is(messages.length, 1)
+  })
+
+  test('the buffer should be empty afterwards', () => {
+    const parser = new RtspParser()
+    parser.parse(buffer15)
+    parser.parse(buffer05)
+
+    assert.is((parser as any)._length, 0)
+  })
+})
+
+describe('RTSP package', (test) => {
+  let RtspBuffer: Uint8Array
+  test.before(() => {
+    // RtspBuffer = new Uint8Array(setupResponse.length)
+    // setupResponse.split('').forEach((character, index) => {
+    //   RtspBuffer[index] = character.charCodeAt(0)
+    // })
+    RtspBuffer = encode(setupResponse)
+  })
+
+  test('extracts the RTSP buffer', () => {
+    const parser = new RtspParser()
+    const messages = parser.parse(RtspBuffer)
+    assert.is(messages.length, 1)
+    const msg = messages[0]
+    assert.ok(msg.type === 'rtsp_rsp')
+    assert.equal(
+      msg.headers.get('RTP-Info'),
+      'url=rtsp://192.168.0.3/axis-media/media.amp/stream=0?resolution=176x144&fps=1;seq=10176;rtptime=2419713327'
+    )
+  })
+
+  test('the buffer should be empty afterwards (no messages data buffered)', () => {
+    const parser = new RtspParser()
+    parser.parse(RtspBuffer)
+
+    assert.is((parser as any)._length, 0)
+  })
+
+  test('should detect RTP data in same buffer as RTSP', () => {
+    const parser = new RtspParser()
+    parser.parse(rtspWithTrailingRtp)
+
+    assert.is((parser as any)._length, 4)
+  })
+
+  test('should find RTSP, RTP and RTCP packages in the same buffer', () => {
+    const parser = new RtspParser()
+    const messages: Array = parser.parse(rtspRtpRtcpCombined)
+    assert.is(messages.length, 4)
+    assert.is(messages[0].type, 'rtsp_rsp')
+    assert.is(messages[1].type, 'rtp')
+    assert.is(messages[1].channel, 0)
+    assert.is(messages[2].type, 'rtcp')
+    assert.is(messages[2].channel, 1)
+    assert.is(messages[3].type, 'rtcp')
+    assert.is(messages[3].channel, 1)
+  })
+})
+
+describe('SDP data', (test) => {
+  let sdpBuffer: Uint8Array
+  let sdpBody: Uint8Array
+  test.before(() => {
+    sdpBuffer = encode(sdpResponse)
+    sdpBody = encode(sdpResponse.split('\r\n\r\n')[1])
+  })
+
+  test('should extract SDP data', () => {
+    const parser = new RtspParser()
+    const messages = parser.parse(sdpBuffer)
+    assert.is(messages.length, 1)
+    const msg = messages[0]
+    assert.ok(msg.type === 'rtsp_rsp')
+    assert.equal(msg.body, sdpBody)
+  })
+
+  test('should handle segmented RTSP/SDP', () => {
+    const parser = new RtspParser()
+    const segmentedRTSP = sdpResponse.split(/(?<=\r\n\r\n)/g)
+    const part1: Uint8Array = encode(segmentedRTSP[0])
+    const part2: Uint8Array = encode(segmentedRTSP[1])
+    let messages = parser.parse(part1)
+    assert.is(messages.length, 0)
+    messages = parser.parse(part2)
+    assert.is(messages.length, 1)
+  })
+})
+
+describe('rtsp request serializer', (test) => {
+  test('builds a valid RTSP message from the passed in data', () => {
+    const msg: RtspRequestMessage = {
+      type: 'rtsp_req',
+      method: 'OPTIONS',
+      uri: 'rtsp://192.168.0.3/axis-media/media.amp?resolution=176x144&fps=1',
+      headers: {
+        CSeq: 1,
+        Date: 'Wed, 03 Jun 2015 14:26:16 GMT',
+      },
+    }
+    const data = serialize(msg)
+
+    assert.is(decode(data), optionsRequest)
+  })
 })
diff --git a/streams/tests/rtsp-rtcp.fixtures.ts b/streams/tests/rtsp-rtcp.fixtures.ts
new file mode 100644
index 000000000..0bca5f55e
--- /dev/null
+++ b/streams/tests/rtsp-rtcp.fixtures.ts
@@ -0,0 +1,117 @@
+/* biome-ignore format: custom formatting */
+export const rtcpSRBuffers = [
+  // 0 reports
+  new Uint8Array([
+    128, 200, 0, 6, 243, 203, 32, 1, 131, 171, 3, 161, 235, 2, 11, 58, 0, 0,
+    148, 32, 0, 0, 0, 158, 0, 0, 155, 136,
+  ]),
+
+  // 3 reports
+  new Uint8Array([
+    131, 200, 0, 24, 243, 203, 32, 1, 131, 171, 3, 161, 235, 2, 11, 58, 0, 0,
+    148, 32, 0, 0, 0, 158, 0, 0, 155, 136, 0, 0, 0, 1, 4, 0, 0, 10, 0, 0, 0,
+    1000, 0, 0, 0, 5, 0, 0, 0, 6, 0, 0, 0, 7, 0, 0, 0, 2, 4, 0, 0, 11, 0, 0, 0,
+    1001, 0, 0, 0, 8, 0, 0, 0, 9, 0, 0, 0, 10, 0, 0, 0, 3, 4, 0, 0, 12, 0, 0, 0,
+    1002, 0, 0, 0, 11, 0, 0, 0, 12, 0, 0, 0, 13,
+  ]),
+]
+
+/* biome-ignore format: custom formatting */
+export const rtcpRRBuffers = [
+  // 0 reports
+  new Uint8Array([128, 201, 0, 1, 27, 117, 249, 76]),
+
+  // 3 reports
+  new Uint8Array([
+    131, 201, 0, 19, 27, 117, 249, 76, 0, 0, 0, 1, 4, 0, 0, 10, 0, 0, 0, 1000,
+    0, 0, 0, 5, 0, 0, 0, 6, 0, 0, 0, 7, 0, 0, 0, 2, 4, 0, 0, 11, 0, 0, 0, 1001,
+    0, 0, 0, 8, 0, 0, 0, 9, 0, 0, 0, 10, 0, 0, 0, 3, 4, 0, 0, 12, 0, 0, 0, 1002,
+    0, 0, 0, 11, 0, 0, 0, 12, 0, 0, 0, 13,
+  ]),
+]
+
+/* biome-ignore format: custom formatting */
+export const rtcpSDESBuffers = [
+  new Uint8Array([
+    129, 202, 0, 12, 217, 157, 189, 215, 1, 28, 117, 115, 101, 114, 50, 53, 48,
+    51, 49, 52, 53, 55, 54, 54, 64, 104, 111, 115, 116, 45, 50, 57, 50, 48, 53,
+    57, 53, 50, 6, 9, 71, 83, 116, 114, 101, 97, 109, 101, 114, 0, 0, 0,
+  ]),
+
+  // 2 chunks (1+2 priv)
+  new Uint8Array([
+    130,
+    202,
+    0,
+    12,
+    0,
+    0,
+    0,
+    1,
+    1,
+    6,
+    67,
+    78,
+    65,
+    77,
+    69,
+    49,
+    8,
+    5,
+    2,
+    67,
+    49,
+    86,
+    49,
+    0, // 5 words
+    0,
+    0,
+    0,
+    2,
+    1,
+    6,
+    67,
+    78,
+    65,
+    77,
+    69,
+    50,
+    8,
+    5,
+    2,
+    67,
+    50,
+    86,
+    50,
+    8,
+    5,
+    2,
+    67,
+    51,
+    86,
+    51,
+    0,
+    0, // 7 words
+  ]),
+]
+
+/* biome-ignore format: custom formatting */
+export const rtcpBYEBuffers = [
+  new Uint8Array([129, 203, 0, 1, 38, 197, 204, 95]),
+
+  // 0 byes (valid, but useless)
+  new Uint8Array([128, 203, 0, 0]),
+
+  // 3 byes + reason (valid, but useless)
+  new Uint8Array([
+    131, 203, 0, 5, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 4, 76, 111, 115, 116, 0,
+    0, 0,
+  ]),
+]
+
+/* biome-ignore format: custom formatting */
+export const rtcpAPPBuffers = [
+  new Uint8Array([
+    133, 204, 0, 4, 0, 0, 0, 42, 76, 105, 102, 101, 0, 1, 2, 3, 42, 42, 42, 42,
+  ]),
+]
diff --git a/streams/tests/protocols-rtcp.test.ts b/streams/tests/rtsp-rtcp.test.ts
similarity index 98%
rename from streams/tests/protocols-rtcp.test.ts
rename to streams/tests/rtsp-rtcp.test.ts
index 76ef746f1..cd52716ea 100644
--- a/streams/tests/protocols-rtcp.test.ts
+++ b/streams/tests/rtsp-rtcp.test.ts
@@ -1,6 +1,5 @@
 import * as assert from 'uvu/assert'
-
-import { parseRtcp } from 'utils/protocols/rtcp'
+import { describe } from './uvu-describe'
 
 import {
   rtcpAPPBuffers,
@@ -8,8 +7,9 @@ import {
   rtcpRRBuffers,
   rtcpSDESBuffers,
   rtcpSRBuffers,
-} from './protocols.fixtures'
-import { describe } from './uvu-describe'
+} from './rtsp-rtcp.fixtures'
+
+import { parseRtcp } from '../src/components/rtsp/rtcp'
 
 describe('Rtcp parsing', (test) => {
   test('parsed SR correctly', () => {
diff --git a/streams/tests/rtsp-rtp.fixtures.ts b/streams/tests/rtsp-rtp.fixtures.ts
new file mode 100644
index 000000000..adcbfc51e
--- /dev/null
+++ b/streams/tests/rtsp-rtp.fixtures.ts
@@ -0,0 +1,19 @@
+/* biome-ignore format: custom formatting */
+export const rtpBuffers = [
+  new Uint8Array([128, 96, 80, 56, 225, 39, 20, 132, 25, 190, 186, 105]),
+  new Uint8Array([128, 224, 80, 76, 225, 39, 108, 97, 25, 190, 186, 105, 1, 2, 3]),
+  new Uint8Array([
+    129, 224, 80, 95, 225, 40, 57, 104, 25, 190, 186, 105, 0, 0, 0, 1, 1, 2, 3,
+  ]),
+]
+
+/* biome-ignore format: custom formatting */
+export const rtpBuffersWithHeaderExt = [
+  new Uint8Array([
+    144, 224, 80, 76, 225, 39, 108, 97, 25, 190, 186, 105, 1, 2, 0, 0, 1, 2, 3,
+  ]),
+  new Uint8Array([
+    144, 224, 80, 76, 225, 39, 108, 97, 25, 190, 186, 105, 1, 2, 0, 1, 1, 2, 3,
+    4, 1, 2, 3,
+  ]),
+]
diff --git a/streams/tests/protocols-rtp.test.ts b/streams/tests/rtsp-rtp.test.ts
similarity index 96%
rename from streams/tests/protocols-rtp.test.ts
rename to streams/tests/rtsp-rtp.test.ts
index d1cc6a097..c0e8c1f6c 100644
--- a/streams/tests/protocols-rtp.test.ts
+++ b/streams/tests/rtsp-rtp.test.ts
@@ -1,4 +1,7 @@
 import * as assert from 'uvu/assert'
+import { describe } from './uvu-describe'
+
+import { rtpBuffers, rtpBuffersWithHeaderExt } from './rtsp-rtp.fixtures'
 
 import {
   cSrc,
@@ -13,10 +16,7 @@ import {
   sequenceNumber,
   timestamp,
   version,
-} from 'utils/protocols/rtp'
-
-import { rtpBuffers, rtpBuffersWithHeaderExt } from './protocols.fixtures'
-import { describe } from './uvu-describe'
+} from '../src/components/rtsp/rtp'
 
 describe('Rtp parsing', (test) => {
   for (const buffer of rtpBuffers) {
diff --git a/streams/tests/protocols-sdp.test.ts b/streams/tests/rtsp-sdp.test.ts
similarity index 89%
rename from streams/tests/protocols-sdp.test.ts
rename to streams/tests/rtsp-sdp.test.ts
index 7b4d07664..94f4900e5 100644
--- a/streams/tests/protocols-sdp.test.ts
+++ b/streams/tests/rtsp-sdp.test.ts
@@ -1,10 +1,8 @@
 import * as assert from 'uvu/assert'
-
-import { MessageType } from 'components/message'
-import { extractURIs, parse, sdpFromBody } from 'utils/protocols/sdp'
-
 import { describe } from './uvu-describe'
 
+import { extractURIs, parseSdp } from '../src/components/rtsp/sdp'
+
 const example = `
 v=0
 o=- 18315797286303868614 1 IN IP4 127.0.0.1
@@ -40,7 +38,7 @@ describe('Sdp', (test) => {
     ])
   })
   test('should parse to a JS object', () => {
-    assert.equal(parse(example), {
+    assert.equal(parseSdp(example), {
       session: {
         version: '0',
         origin: {
@@ -131,12 +129,4 @@ describe('Sdp', (test) => {
       ],
     })
   })
-
-  test('sdpFromBody should produce a message with the correct structure', () => {
-    const message = sdpFromBody(example)
-    assert.equal(Object.keys(message), ['type', 'data', 'sdp'])
-
-    assert.equal(message.data.byteLength, 0)
-    assert.is(message.type, MessageType.SDP)
-  })
 })
diff --git a/streams/tests/rtsp-session.fixtures.ts b/streams/tests/rtsp-session.fixtures.ts
index 1682c1b83..bce753e03 100644
--- a/streams/tests/rtsp-session.fixtures.ts
+++ b/streams/tests/rtsp-session.fixtures.ts
@@ -1,107 +1,100 @@
-export const responsesRaw = [
-  `RTSP/1.0 200 OK
-CSeq: 1
-Public: OPTIONS, DESCRIBE, GET_PARAMETER, PAUSE, PLAY, SETUP, SET_PARAMETER, TEARDOWN
-Server: GStreamer RTSP server
-Date: Wed, 03 Jun 2015 14:23:41 GMT
+const crnl = (s: string): string => {
+  return s.split('\n').join('\r\n')
+}
 
-`,
-  `RTSP/1.0 200 OK
-CSeq: 2
+function crlf(s: string): string {
+  return s.split('\n').join('\r\n')
+}
+
+export const responses = {
+  DESCRIBE: crlf(`RTSP/1.0 200 OK
+CSeq: 0
 Content-Type: application/sdp
-Content-Base: rtsp://192.168.0.3/axis-media/media.amp/
+Content-Base: rtsp://192.168.0.90/axis-media/media.amp/
 Server: GStreamer RTSP server
 Date: Wed, 03 Jun 2015 14:23:42 GMT
-Content-Length: 623
+Content-Length: 1073
 
 v=0
-o=- 1188340656180883 1 IN IP4 192.168.0.3
+o=- 1188340656180883 1 IN IP4 192.168.0.96
 s=Session streamed with GStreamer
 i=rtsp-server
 t=0 0
 a=tool:GStreamer
 a=type:broadcast
 a=range:npt=now-
-a=control:rtsp://192.168.0.3/axis-media/media.amp?resolution=176x144&fps=1
+a=control:rtsp://192.168.0.90/axis-media/media.amp?video=1&audio=1&svg=on
 m=video 0 RTP/AVP 96
 c=IN IP4 0.0.0.0
 b=AS:50000
 a=rtpmap:96 H264/90000
-a=fmtp:96 packetization-mode=1;profile-level-id=4d0029;sprop-parameter-sets=Z00AKeKQWJ2AtwEBAaQeJEVA,aO48gA==
-a=control:rtsp://192.168.0.3/axis-media/media.amp/stream=0?resolution=176x144&fps=1
-a=framerate:1.000000
-a=transform:0.916667,0.000000,0.000000;0.000000,1.000000,0.000000;0.000000,0.000000,1.000000
-`,
-  `RTSP/1.0 200 OK
-CSeq: 3
+a=fmtp:96 packetization-mode=1;profile-level-id=4d0032;sprop-parameter-sets=Z00AMuKQBRAevy4C3AQEBpB4kRU=,aO48gA==
+a=control:rtsp://192.168.0.90/axis-media/media.amp/stream=0?video=1&audio=1&svg=on
+a=framerate:12.000000
+a=transform:1.000000,0.000000,0.000000;0.000000,1.000000,0.000000;0.000000,0.000000,1.000000
+m=audio 0 RTP/AVP 97
+c=IN IP4 0.0.0.0
+b=AS:32
+a=rtpmap:97 MPEG4-GENERIC/16000/1
+a=fmtp:97 streamtype=5;profile-level-id=2;mode=AAC-hbr;config=1408;sizelength=13;indexlength=3;indexdeltalength=3
+a=control:rtsp://192.168.0.90/axis-media/media.amp/stream=1?video=1&audio=1&svg=on
+m=application 0 RTP/AVP 99
+c=IN IP4 0.0.0.0
+a=rtpmap:99 image.svg.data/90000
+a=control:rtsp://192.168.0.90/axis-media/media.amp/stream=2?video=1&audio=1&svg=on
+
+`),
+  SETUP_VIDEO: crlf(`RTSP/1.0 200 OK
+CSeq: 1
 Transport: RTP/AVP;unicast;client_port=40472-40473;server_port=50000-50001;ssrc=363E6C43;mode="PLAY"
 Server: GStreamer RTSP server
 Session: Bk48Ak7wjcWaAgRD; timeout=60
 Date: Wed, 03 Jun 2015 14:23:42 GMT
 
-`,
-  `RTSP/1.0 200 OK
-CSeq: 4
-RTP-Info: url=rtsp://192.168.0.3/axis-media/media.amp/stream=0?resolution=176x144&fps=1;seq=10176;rtptime=2419713327
-Range: npt=now-
+`),
+  SETUP_AUDIO: crlf(`RTSP/1.0 200 OK
+CSeq: 2
+Transport: RTP/AVP;unicast;client_port=40474-40475;server_port=50002-50003;ssrc=363E6C43;mode="PLAY"
 Server: GStreamer RTSP server
 Session: Bk48Ak7wjcWaAgRD; timeout=60
 Date: Wed, 03 Jun 2015 14:23:42 GMT
 
-`,
-  `RTSP/1.0 200 OK
-CSeq: 5
+`),
+  SETUP_APPLICATION: crlf(`RTSP/1.0 200 OK
+CSeq: 3
+Transport: RTP/AVP;unicast;client_port=40476-40477;server_port=50004-50005;ssrc=363E6C43;mode="PLAY"
 Server: GStreamer RTSP server
 Session: Bk48Ak7wjcWaAgRD; timeout=60
-Date: Wed, 03 Jun 2015 14:23:48 GMT
-
-`,
-]
-
-export const responses = responsesRaw.map((item) => {
-  return item.split('\n').join('\r\n')
-})
+Date: Wed, 03 Jun 2015 14:23:42 GMT
 
-export const setupResponse = `RTSP/1.0 200 OK
-CSeq: 5
-RTP-Info: url=rtsp://192.168.0.3/axis-media/media.amp/stream=0?resolution=176x144&fps=1;seq=10176;rtptime=2419713327
-Range: npt=now-
+`),
+  PLAY: crlf(`RTSP/1.0 200 OK
+CSeq: 4
+RTP-Info: url=rtsp://192.168.0.90/axis-media/media.amp/stream=0?resolution=176x144&fps=1;seq=10176;rtptime=2419713327
+Range: npt=678-
 Server: GStreamer RTSP server
 Session: Bk48Ak7wjcWaAgRD; timeout=60
 Date: Wed, 03 Jun 2015 14:23:42 GMT
 
-`
-  .split('\n')
-  .join('\r\n')
+`),
+  OPTIONS: crlf(`RTSP/1.0 200 OK
+CSeq: 5
+Public: OPTIONS, DESCRIBE, GET_PARAMETER, PAUSE, PLAY, SETUP, SET_PARAMETER, TEARDOWN
+Server: GStreamer RTSP server
+Date: Wed, 03 Jun 2015 14:23:41 GMT
 
-export const sdpResponseVideoAudioSVG = `v=0
-o=- 1188340656180883 1 IN IP4 192.168.0.96
-s=Session streamed with GStreamer
-i=rtsp-server
-t=0 0
-a=tool:GStreamer
-a=type:broadcast
-a=range:npt=now-
-a=control:rtsp://192.168.0.90/axis-media/media.amp?video=1&audio=1&svg=on
-m=video 0 RTP/AVP 96
-c=IN IP4 0.0.0.0
-b=AS:50000
-a=rtpmap:96 H264/90000
-a=fmtp:96 packetization-mode=1;profile-level-id=4d0032;sprop-parameter-sets=Z00AMuKQBRAevy4C3AQEBpB4kRU=,aO48gA==
-a=control:rtsp://192.168.0.90/axis-media/media.amp/stream=0?video=1&audio=1&svg=on
-a=framerate:12.000000
-a=transform:1.000000,0.000000,0.000000;0.000000,1.000000,0.000000;0.000000,0.000000,1.000000
-m=audio 0 RTP/AVP 97
-c=IN IP4 0.0.0.0
-b=AS:32
-a=rtpmap:97 MPEG4-GENERIC/16000/1
-a=fmtp:97 streamtype=5;profile-level-id=2;mode=AAC-hbr;config=1408;sizelength=13;indexlength=3;indexdeltalength=3
-a=control:rtsp://192.168.0.90/axis-media/media.amp/stream=1?video=1&audio=1&svg=on
-m=application 0 RTP/AVP 99
-c=IN IP4 0.0.0.0
-a=rtpmap:99 image.svg.data/90000
-a=control:rtsp://192.168.0.90/axis-media/media.amp/stream=2?video=1&audio=1&svg=on
+`),
+  PAUSE: crlf(`RTSP/1.0 200 OK
+CSeq: 6
+Server: GStreamer RTSP server
+Session: Bk48Ak7wjcWaAgRD; timeout=60
+Date: Wed, 03 Jun 2015 14:23:48 GMT
+
+`),
+  TEARDOWN: crlf(`RTSP/1.0 200 OK
+CSeq: 7
+Server: GStreamer RTSP server
+Date: Wed, 03 Jun 2015 14:23:48 GMT
 
-`
-  .split('\n')
-  .join('\r\n')
+`),
+} as const
diff --git a/streams/tests/rtsp-session.test.ts b/streams/tests/rtsp-session.test.ts
index 9f101cc4a..feda31a55 100644
--- a/streams/tests/rtsp-session.test.ts
+++ b/streams/tests/rtsp-session.test.ts
@@ -1,314 +1,231 @@
 import * as assert from 'uvu/assert'
-
-import { Writable } from 'stream'
-import { MessageType } from 'components/message'
-import { RTSP_METHOD, RtspSession } from 'components/rtsp-session'
-import { sdpFromBody } from 'utils/protocols/sdp'
-
-import { encode } from 'utils/bytes'
-import {
-  responses,
-  sdpResponseVideoAudioSVG,
-  setupResponse,
-} from './rtsp-session.fixtures'
 import { describe } from './uvu-describe'
-import { runComponentTests } from './validate-component'
-
-const sdp = `v=0
-o=- 12566106766544666011 1 IN IP4 192.168.0.90
-s=Session streamed with GStreamer
-i=rtsp-server
-t=0 0
-a=tool:GStreamer
-a=type:broadcast
-a=range:npt=now-
-a=control:rtsp://192.168.0.90/axis-media/media.amp?audio=1
-m=video 0 RTP/AVP 96
-c=IN IP4 0.0.0.0
-b=AS:50000
-a=rtpmap:96 H264/90000
-a=fmtp:96 packetization-mode=1;profile-level-id=4d0029;sprop-parameter-sets=blabla=,aO48gA==
-a=control:rtsp://192.168.0.90/axis-media/media.amp/stream=0?audio=1
-a=framerate:25.000000
-a=transform:1.000000,0.000000,0.000000;0.000000,1.000000,0.000000;0.000000,0.000000,1.000000
-m=audio 0 RTP/AVP 0
-c=IN IP4 0.0.0.0
-b=AS:64
-a=rtpmap:0 PCMU/8000
-a=control:rtsp://192.168.0.90/axis-media/media.amp/stream=1?audio=1
-`
-
-describe('rtsp-session component', (test) => {
-  const c = new RtspSession({ uri: 'rtsp://whatever/path' })
-
-  runComponentTests(c, 'rtsp-session', test)
-
-  test('should generate uri if no URI is given', () => {
-    const s = new RtspSession({ hostname: 'hostname' })
-    assert.is(s.uri, 'rtsp://hostname/axis-media/media.amp')
-  })
-})
 
-describe('rtsp-session send method', (test) => {
-  test('send should throw if no method is given', () => {
-    const s = new RtspSession({ uri: 'myURI' })
-    assert.throws(() => s.send(undefined as never))
-  })
+import { responses } from './rtsp-session.fixtures'
 
-  test('should emit a message with the correct method', async (ctx) => {
-    const s = new RtspSession({ uri: 'rtsp://whatever/path' })
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-    s.outgoing.once('data', (msg) => {
-      assert.is(msg.method, RTSP_METHOD.DESCRIBE)
-      ctx.resolve()
-    })
-    s.send({ method: RTSP_METHOD.DESCRIBE })
-    await done
-  })
+import { RtspSession } from '../src/components'
 
-  test('should use 1 as first sequence', async (ctx) => {
-    const s = new RtspSession({ uri: 'rtsp://whatever/path' })
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-    s.outgoing.once('data', (msg) => {
-      assert.is(msg.headers.CSeq, 1)
-      ctx.resolve()
-    })
-    s.send({ method: RTSP_METHOD.DESCRIBE })
-    await done
-  })
+import { decode, encode } from '../src/components/utils/bytes'
+import { consumer } from '../src/components/utils/streams'
+import { axisRtspMediaUri } from '../src/defaults'
 
-  test('should use the supplied URI', async (ctx) => {
-    const uri = 'rtsp://whatever/path'
-    const s = new RtspSession({ uri })
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-    s.outgoing.once('data', (req) => {
-      assert.is(req.uri, uri)
-      ctx.resolve()
-    })
-    s.send({ method: RTSP_METHOD.DESCRIBE })
-    await done
+describe('rtsp-session', (test) => {
+  test('correct generated default uri', () => {
+    const uri = axisRtspMediaUri('hostname')
+    assert.is(uri, 'rtsp://hostname/axis-media/media.amp')
   })
 
-  test('should use the supplied headers', async (ctx) => {
-    const defaultHeaders = { customheader: 'customVal' }
-    const s = new RtspSession({
-      uri: 'rtsp://whatever/path',
-      defaultHeaders,
+  test('separate commands', async () => {
+    const rtsp = new RtspSession({
+      uri: 'rtsp://192.168.0.90/axis-media/media.amp',
     })
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-    s.outgoing.once('data', (req) => {
-      assert.is(req.headers.customheader, 'customVal')
-      ctx.resolve()
-    })
-    s.send({ method: RTSP_METHOD.DESCRIBE })
-    await done
-  })
-
-  test('should not send if incoming is closed', async (ctx) => {
-    const s = new RtspSession()
-    const w = new Writable()
-    w._write = function (_msg, _enc, next) {
-      // consume the msg
-      next()
-    }
-    s.incoming.pipe(w)
-
-    assert.is((s as any)._outgoingClosed, false)
-    // close the incoming stream
-    s.incoming.push(null)
-    // Use setTimeout to ensure the 'on end' callback has fired before
-    // we do the test
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-    setTimeout(() => {
-      assert.is((s as any)._outgoingClosed, true)
-      ctx.resolve()
-    }, 0)
-    await done
-  })
-})
 
-describe('rtsp-sessiont onIncoming method', (test) => {
-  test('should get the controlURIs from a SDP message', async (ctx) => {
-    const s = new RtspSession({ uri: 'whatever' })
-    const expectedControlUri =
-      'rtsp://192.168.0.90/axis-media/media.amp/stream=0?audio=1'
-    const expectedControlUri2 =
-      'rtsp://192.168.0.90/axis-media/media.amp/stream=1?audio=1'
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-    s.outgoing.once('data', (msg) => {
-      assert.is(msg.type, MessageType.RTSP)
-      assert.is(expectedControlUri, msg.uri)
-      assert.is(msg.method, 'SETUP')
-
-      assert.is((s as any)._callStack[0].uri, expectedControlUri2)
-
-      assert.is((s as any)._callStack[0].method, 'SETUP')
-      ctx.resolve()
+    const drain = rtsp.demuxer.readable.pipeTo(consumer())
+    const rspWriter = rtsp.demuxer.writable.getWriter()
+    const reqReader = rtsp.commands.getReader()
+
+    /**
+     * DESCRIBE
+     */
+
+    const describe = rtsp.describe()
+
+    // MOCK describe request/response
+    const { value: describeRequest } = await reqReader.read()
+    assert.ok(describeRequest)
+    assert.is(
+      decode(describeRequest),
+      'DESCRIBE rtsp://192.168.0.90/axis-media/media.amp RTSP/1.0\r\nAccept: application/sdp\r\nCSeq: 0\r\n\r\n',
+      'describe request'
+    )
+    await rspWriter.write(encode(responses.DESCRIBE))
+
+    const sdp = await describe
+
+    /**
+     * SETUP
+     */
+
+    const setup = rtsp.setup(sdp)
+
+    // MOCK setup request/response (3 pairs expected for this setup)
+    const { value: setupVideo } = await reqReader.read()
+    assert.ok(setupVideo)
+    assert.is(
+      decode(setupVideo),
+      'SETUP rtsp://192.168.0.90/axis-media/media.amp/stream=0?video=1&audio=1&svg=on RTSP/1.0\r\nBlocksize: 64000\r\nTransport: RTP/AVP/TCP;unicast;interleaved=0-1\r\nCSeq: 1\r\n\r\n',
+      'video setup request'
+    )
+    await rspWriter.write(encode(responses.SETUP_VIDEO))
+    const { value: setupAudio } = await reqReader.read()
+    assert.ok(setupAudio)
+    assert.is(
+      decode(setupAudio),
+      'SETUP rtsp://192.168.0.90/axis-media/media.amp/stream=1?video=1&audio=1&svg=on RTSP/1.0\r\nBlocksize: 64000\r\nTransport: RTP/AVP/TCP;unicast;interleaved=2-3\r\nSession: Bk48Ak7wjcWaAgRD\r\nCSeq: 2\r\n\r\n',
+      'audio setup request'
+    )
+    await rspWriter.write(encode(responses.SETUP_VIDEO))
+    const { value: setupApplication } = await reqReader.read()
+    assert.ok(setupApplication)
+    assert.is(
+      decode(setupApplication),
+      'SETUP rtsp://192.168.0.90/axis-media/media.amp/stream=2?video=1&audio=1&svg=on RTSP/1.0\r\nBlocksize: 64000\r\nTransport: RTP/AVP/TCP;unicast;interleaved=4-5\r\nSession: Bk48Ak7wjcWaAgRD\r\nCSeq: 3\r\n\r\n',
+      'application setup request'
+    )
+    await rspWriter.write(encode(responses.SETUP_APPLICATION))
+
+    await setup
+
+    /**
+     * keep-alive
+     */
+
+    assert.ok(
+      // @ts-ignore access private property
+      rtsp.keepaliveInterval,
+      'expect session to be kept alive after setup'
+    )
+    // @ts-ignore access private property
+    assert.is(rtsp.sessionId, 'Bk48Ak7wjcWaAgRD', 'session ID')
+
+    /**
+     * PLAY
+     */
+
+    const play = rtsp.play(678)
+
+    // MOCK play request/response
+    const { value: playRequest } = await reqReader.read()
+    assert.ok(playRequest)
+    assert.is(
+      decode(playRequest),
+      'PLAY rtsp://192.168.0.90/axis-media/media.amp?video=1&audio=1&svg=on RTSP/1.0\r\nRange: npt=678-\r\nSession: Bk48Ak7wjcWaAgRD\r\nCSeq: 4\r\n\r\n',
+      'play request'
+    )
+    await rspWriter.write(encode(responses.PLAY))
+
+    const range = await play
+    assert.equal(range, ['678', ''])
+
+    /**
+     * OPTIONS
+     */
+
+    const options = rtsp.options()
+
+    // MOCK options request/response
+    const { value: optionsRequest } = await reqReader.read()
+    assert.ok(optionsRequest)
+    assert.is(
+      decode(optionsRequest),
+      'OPTIONS rtsp://192.168.0.90/axis-media/media.amp?video=1&audio=1&svg=on RTSP/1.0\r\nSession: Bk48Ak7wjcWaAgRD\r\nCSeq: 5\r\n\r\n',
+      'options request'
+    )
+    await rspWriter.write(encode(responses.OPTIONS))
+
+    await options
+
+    /**
+     * PAUSE
+     */
+
+    const pause = rtsp.pause()
+
+    // MOCK pause request/response
+    const { value: pauseRequest } = await reqReader.read()
+    assert.ok(pauseRequest)
+    assert.is(
+      decode(pauseRequest),
+      'PAUSE rtsp://192.168.0.90/axis-media/media.amp?video=1&audio=1&svg=on RTSP/1.0\r\nSession: Bk48Ak7wjcWaAgRD\r\nCSeq: 6\r\n\r\n',
+      'pause request'
+    )
+    await rspWriter.write(encode(responses.PAUSE))
+
+    await pause
+
+    /**
+     * TEARDOWN
+     */
+
+    const teardown = rtsp.teardown()
+
+    // MOCK teardown request/response
+    const { value: teardownRequest } = await reqReader.read()
+    assert.ok(teardownRequest)
+    assert.is(
+      decode(teardownRequest),
+      'TEARDOWN rtsp://192.168.0.90/axis-media/media.amp?video=1&audio=1&svg=on RTSP/1.0\r\nSession: Bk48Ak7wjcWaAgRD\r\nCSeq: 7\r\n\r\n',
+      'teardown request'
+    )
+    await rspWriter.write(encode(responses.TEARDOWN))
+
+    await teardown
+
+    /**
+     * session finished
+     */
+
+    assert.not.ok(
+      // @ts-ignore access private property
+      rtsp.keepaliveInterval,
+      'no keepalive for ended session'
+    )
+    // @ts-ignore access private property
+    assert.is(rtsp.sessionId, undefined, 'no session ID')
+
+    // @ts-ignore
+    rspWriter.close()
+
+    await drain
+    console.log('drained')
+  })
+
+  test('start should end up playing stream', async () => {
+    const rtsp = new RtspSession({
+      uri: 'rtsp://192.168.0.90/axis-media/media.amp',
     })
-    s.incoming.write(sdpFromBody(sdp))
-    await done
-  })
-
-  test('should get the session from a Response containing session info', () => {
-    const s = new RtspSession({ uri: 'whatever' })
-
-    assert.is((s as any)._sessionId, null)
-
-    assert.is((s as any)._renewSessionInterval, null)
-    const res = encode(setupResponse)
-    s.incoming.write({ data: res, type: MessageType.RTSP })
 
-    assert.is((s as any)._sessionId, 'Bk48Ak7wjcWaAgRD')
-
-    assert.is.not((s as any)._renewSessionInterval, null)
-    s.stop()
-  })
-
-  test('should emit a Request using SETUP command', async (ctx) => {
-    const s = new RtspSession({ uri: 'whatever' })
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-    s.outgoing.on('data', (msg) => {
-      assert.is(msg.type, MessageType.RTSP)
-      assert.is(msg.method, 'SETUP')
-      assert.is(
-        msg.uri,
-        'rtsp://192.168.0.90/axis-media/media.amp/stream=0?video=1&audio=1&svg=on'
-      )
-
-      assert.is((s as any)._callStack.length, 2)
-      ctx.resolve()
+    // MOCK an RTSP server
+    let downstreamController: ReadableStreamDefaultController
+    let upstreamController: WritableStreamDefaultController
+    const responseStack = Object.values(responses)
+    const downstream = new ReadableStream({
+      start(controller) {
+        downstreamController = controller
+      },
     })
-    s.incoming.write(sdpFromBody(sdpResponseVideoAudioSVG))
-    await done
-  })
-
-  test('The SETUP request should contain the Blocksize header by default', async (ctx) => {
-    const s = new RtspSession({ uri: 'whatever' })
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-    s.outgoing.once('data', (msg) => {
-      assert.is(msg.headers.Blocksize, '64000')
-      ctx.resolve()
+    const upstream = new WritableStream({
+      start(controller) {
+        upstreamController = controller
+      },
+      write() {
+        downstreamController.enqueue(encode(responseStack.shift() ?? ''))
+      },
     })
-    s.incoming.write(sdpFromBody(sdpResponseVideoAudioSVG))
-    await done
-  })
-})
-
-describe('rtsp-session retry', (test) => {
-  test('should emit a Request with similar props', async (ctx) => {
-    const s = new RtspSession({ uri: 'rtsp://whatever/path' }) as any
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-    s.outgoing.once('data', () => {
-      s.outgoing.once('data', (retry: any) => {
-        assert.is(RTSP_METHOD.DESCRIBE, retry.method)
-        assert.is(retry.uri, s.uri)
-        ctx.resolve()
-      })
-      s.retry()
-    })
-    s.send({ method: RTSP_METHOD.DESCRIBE })
-    await done
-  })
-
-  test('should increment the sequence', async (ctx) => {
-    const s = new RtspSession({ uri: 'rtsp://whatever/path' }) as any
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-
-    s.outgoing.once('data', (req: any) => {
-      s.outgoing.once('data', (retry: any) => {
-        assert.is(retry.headers.CSeq, (req.headers.CSeq as number) + 1)
-        ctx.resolve()
-      })
-      s.retry()
-    })
-    s.send({ method: RTSP_METHOD.DESCRIBE })
-    await done
-  })
-})
-
-describe('rtsp-session play', (test) => {
-  test('should emit 1 OPTIONS request and wait for an answer', async (ctx) => {
-    const s = new RtspSession({ uri: 'rtsp://whatever/path' })
-    let calls = 0
-    let method: RTSP_METHOD
-    s.outgoing.on('data', (req) => {
-      calls++
-      method = req.method
-    })
-    s.play()
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-    setTimeout(() => {
-      try {
-        assert.is(calls, 1)
-        assert.is(method, RTSP_METHOD.OPTIONS)
-        ctx.resolve()
-      } catch (e) {
-        ctx.resolve(e)
-      }
-    }, 10)
-    await done
-  })
 
-  test('should emit 4 commands in a given sequence', async (ctx) => {
-    const s = new RtspSession({ uri: 'rtsp://whatever/path' })
-    let calls = 0
-    const methods: Array = []
-    s.outgoing.on('data', (req) => {
-      if (req.type !== MessageType.RTSP) {
-        return
-      }
-      methods.push(req.method)
-      const rtspResponse = responses[calls++]
-      const rtspMessage = {
-        data: encode(rtspResponse),
-        type: MessageType.RTSP,
-      }
-      s.incoming.write(rtspMessage) // Give a canned response
-      if (req.method === 'DESCRIBE') {
-        const sdpMessage = sdpFromBody(rtspResponse)
-        s.incoming.write(sdpMessage)
-      }
-      if (req.method === 'PLAY') {
-        s.incoming.end()
-      }
-    })
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-    s.incoming.on('finish', () => {
-      assert.is(methods.join(), ['OPTIONS', 'DESCRIBE', 'SETUP', 'PLAY'].join())
-      // clean up any outstanding timeouts (e.g. renew interval)
-      s._reset()
-      ctx.resolve()
-    })
-    s.play()
-    await done
-  })
-})
-
-describe('rtsp-sessiont pause', (test) => {
-  test('should emit 1 PAUSE request', async (ctx) => {
-    const s = new RtspSession({ uri: 'rtsp://whatever/path' })
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-    s.outgoing.once('data', (req) => {
-      assert.is(req.method, 'PAUSE')
-      ctx.resolve()
-    })
-    s.pause()
-    await done
-  })
-})
-
-describe('rtsp-sessiont stop', (test) => {
-  test('should emit 1 TEARDOWN request', async (ctx) => {
-    const s = new RtspSession({ uri: 'rtsp://whatever/path' }) as any
-    // Fake that SETUP was issued to trigger an actual TEARDOWN
-    s._sessionId = '18315797286303868614'
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-
-    s.outgoing.once('data', (req: any) => {
-      assert.is(req.method, 'TEARDOWN')
-      ctx.resolve()
-    })
-    s.stop()
-    await done
+    const drain = Promise.allSettled([
+      downstream.pipeThrough(rtsp.demuxer).pipeTo(consumer()),
+      rtsp.commands.pipeTo(upstream),
+    ])
+
+    const result = await rtsp.start(678)
+    assert.ok(result.sdp)
+    assert.is(
+      result.sdp.session.control,
+      'rtsp://192.168.0.90/axis-media/media.amp?video=1&audio=1&svg=on'
+    )
+    assert.equal(result.range, ['678', ''])
+
+    await rtsp.options()
+    await rtsp.pause()
+    await rtsp.teardown()
+
+    // @ts-ignore
+    downstreamController.close()
+    // @ts-ignore
+    upstreamController.error()
+
+    await drain
+    console.log('done')
   })
 })
diff --git a/streams/tests/tcp.test.ts b/streams/tests/tcp.test.ts
deleted file mode 100644
index 564fd53f3..000000000
--- a/streams/tests/tcp.test.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { TcpSource } from 'components/tcp'
-
-import { describe } from './uvu-describe'
-import { runComponentTests } from './validate-component'
-
-describe('tcp component', (test) => {
-  const c = new TcpSource()
-  runComponentTests(c, 'tcp', test)
-})
diff --git a/streams/tests/validate-component.ts b/streams/tests/validate-component.ts
deleted file mode 100644
index 3609be7b5..000000000
--- a/streams/tests/validate-component.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { Test } from 'uvu'
-import * as assert from 'uvu/assert'
-
-/**
- * Tests for validating generic component properties.
- * These should be run for each derived Component.
- * @param  component - The component instance to test.
- * @param  name - A name for the component.
- */
-
-export const runComponentTests = (component: any, name = '', test: Test) => {
-  test(`${name} should have incoming/outgoing stream`, () => {
-    assert.is.not(component.incoming, undefined)
-    assert.is.not(component.outgoing, undefined)
-  })
-
-  test(`${name} should have complementary streams`, () => {
-    assert.equal(component.incoming.readable, component.outgoing.writable)
-    assert.equal(component.incoming.writable, component.outgoing.readable)
-  })
-
-  test(`${name} should have objectMode on all streams`, () => {
-    const incoming = component.incoming
-    const outgoing = component.outgoing
-    incoming.ReadableState && assert.ok(incoming.ReadableState.objectMode)
-    incoming.WritableState && assert.ok(incoming.WritableState.objectMode)
-    outgoing.ReadableState && assert.ok(outgoing.ReadableState.objectMode)
-    outgoing.WritableState && assert.ok(outgoing.WritableState.objectMode)
-  })
-}
diff --git a/streams/tests/ws-source.test.ts b/streams/tests/ws-source.test.ts
new file mode 100644
index 000000000..789fa8dff
--- /dev/null
+++ b/streams/tests/ws-source.test.ts
@@ -0,0 +1,58 @@
+import * as assert from 'uvu/assert'
+import { describe } from './uvu-describe'
+
+import { Server, WebSocket } from 'mock-socket'
+
+import { WSSource } from '../src/components'
+
+import { decode, encode } from '../src/components/utils/bytes'
+import { consumer } from '../src/components/utils/streams'
+
+describe('ws-source component', (test) => {
+  test('websocket incoming emits data on message', async () => {
+    // Prepare data to be sent by server, send it, then close the connection.
+    const server = new Server('ws://host')
+
+    const send = ['data1', 'data2', 'x', 'SOAP :/', 'bunch of XML']
+    server.on('connection', (socket) => {
+      socket.on('message', (message) => {
+        if (decode(message as Uint8Array) === 'start') {
+          send.forEach((data) => socket.send(encode(data).buffer))
+          socket.close()
+        }
+      })
+    })
+
+    // Set up spy to listen for arrived messages
+    let called = 0
+    const messages: Array = []
+    const spy = (chunk: Uint8Array) => {
+      called++
+      messages.push(decode(chunk))
+    }
+
+    // Create WebSocket and wait for it to open.
+    const ws = new WebSocket('ws://host')
+    ws.binaryType = 'arraybuffer'
+    await new Promise((resolve) => {
+      ws.onopen = () => resolve()
+    })
+
+    // Set up streams
+    const source = new WSSource(ws)
+    const sink = consumer(spy)
+
+    // Simulate sending a starting message to trigger data flow.
+    const writer = source.writable.getWriter()
+    writer.write(encode('start')).catch((err) => console.error(err))
+
+    // Wait for stream to end.
+    await source.readable
+      .pipeTo(sink)
+      .then(() => console.log('deon'))
+      .catch((err) => console.error(err))
+
+    assert.is(called, send.length)
+    assert.equal(messages, send)
+  })
+})
diff --git a/streams/tests/ws-source.test_.ts b/streams/tests/ws-source.test_.ts
deleted file mode 100644
index 3c622aa9f..000000000
--- a/streams/tests/ws-source.test_.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { Server, WebSocket } from 'mock-socket'
-import * as assert from 'uvu/assert'
-
-import { Sink } from 'components/component'
-import StreamFactory from 'components/helpers/stream-factory'
-import { Message } from 'components/message'
-import { WSSource } from 'components/ws-source'
-
-import { describe } from './uvu-describe'
-import { runComponentTests } from './validate-component'
-
-describe('ws-source component', (test) => {
-  const server = new Server('ws://hostname')
-  const ws = new WebSocket('ws://hostname')
-
-  let called = 0
-  const messages: Array = []
-  const spy = (msg: Message) => {
-    called++
-    messages.push(msg)
-  }
-
-  const source = new WSSource(ws)
-  const sink = new Sink(StreamFactory.consumer(spy), StreamFactory.producer())
-
-  runComponentTests(source, 'websocket', test)
-
-  test('websocket component has two streams', () => {
-    assert.is.not(source.incoming, undefined)
-    assert.is.not(source.outgoing, undefined)
-  })
-
-  test('websocket incoming emits data on message', async (ctx) => {
-    // Prepare data to be sent by server, send it, then close the connection.
-    const send = ['data1', 'data2', 'x', 'SOAP :/', 'bunch of XML']
-    server.on('connection', (socket) => {
-      send.forEach((data) => socket.send(data))
-    })
-
-    // Wait for stream to end, then check what has happened.
-    const done = new Promise((resolve) => (ctx.resolve = resolve))
-    sink.incoming.on('finish', () => {
-      assert.is(called, send.length)
-      assert.equal(
-        send,
-        messages.map(({ data }) => data.toString())
-      )
-      server.close()
-      ws.close()
-      setTimeout(() => ctx.resolve(), 2000)
-    })
-
-    source.connect(sink)
-
-    await done
-  })
-})
diff --git a/streams/tsconfig.json b/streams/tsconfig.json
index 8a5b18562..35d9475d0 100644
--- a/streams/tsconfig.json
+++ b/streams/tsconfig.json
@@ -1,11 +1,4 @@
 {
   "extends": "../tsconfig.base.json",
-  "compilerOptions": {
-    "baseUrl": "src",
-    "declaration": true,
-    "emitDeclarationOnly": true,
-    "noEmit": false,
-    "outDir": "dist"
-  },
   "include": ["src", "tests"]
 }
diff --git a/streams/tsconfig.types.json b/streams/tsconfig.types.json
new file mode 100644
index 000000000..4d7ef4c28
--- /dev/null
+++ b/streams/tsconfig.types.json
@@ -0,0 +1,10 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "declaration": true,
+    "emitDeclarationOnly": true,
+    "noEmit": false,
+    "outDir": "dist"
+  },
+  "include": ["src"]
+}
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 0b3d6f3d3..b71d72b64 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -5,10 +5,9 @@
     "esModuleInterop": true,
     "incremental": true,
     "isolatedModules": true,
-    "module": "nodenext",
-    "moduleResolution": "nodenext",
+    "module": "esnext",
+    "moduleResolution": "bundler",
     "noEmit": true,
-    "emitDeclarationOnly": true,
     "resolveJsonModule": true,
     "skipLibCheck": true,
     "strict": true,
diff --git a/yarn.lock b/yarn.lock
index 1cfaa2fd8..6cead9e94 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -15,429 +15,220 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.23.5, @babel/code-frame@npm:^7.24.1":
-  version: 7.24.2
-  resolution: "@babel/code-frame@npm:7.24.2"
+"@babel/code-frame@npm:^7.25.9, @babel/code-frame@npm:^7.26.0, @babel/code-frame@npm:^7.26.2":
+  version: 7.26.2
+  resolution: "@babel/code-frame@npm:7.26.2"
   dependencies:
-    "@babel/highlight": ^7.24.2
-    picocolors: ^1.0.0
-  checksum: 70e867340cfe09ca5488b2f36372c45cabf43c79a5b6426e6df5ef0611ff5dfa75a57dda841895693de6008f32c21a7c97027a8c7bcabd63a7d17416cbead6f8
-  languageName: node
-  linkType: hard
-
-"@babel/code-frame@npm:^7.24.7":
-  version: 7.24.7
-  resolution: "@babel/code-frame@npm:7.24.7"
-  dependencies:
-    "@babel/highlight": ^7.24.7
+    "@babel/helper-validator-identifier": ^7.25.9
+    js-tokens: ^4.0.0
     picocolors: ^1.0.0
-  checksum: 830e62cd38775fdf84d612544251ce773d544a8e63df667728cc9e0126eeef14c6ebda79be0f0bc307e8318316b7f58c27ce86702e0a1f5c321d842eb38ffda4
+  checksum: db13f5c42d54b76c1480916485e6900748bbcb0014a8aca87f50a091f70ff4e0d0a6db63cade75eb41fcc3d2b6ba0a7f89e343def4f96f00269b41b8ab8dd7b8
   languageName: node
   linkType: hard
 
-"@babel/compat-data@npm:^7.24.8":
-  version: 7.24.8
-  resolution: "@babel/compat-data@npm:7.24.8"
-  checksum: 75b2cf8220ad17ec50486a461c3fecb60cae6498b1beec3946dabf894129d03d34d9b545bbd3e81c8f9d36570a8b4d1965c694b16c02868926510c3374822c39
+"@babel/compat-data@npm:^7.25.9":
+  version: 7.26.3
+  resolution: "@babel/compat-data@npm:7.26.3"
+  checksum: 85c5a9fb365231688c7faeb977f1d659da1c039e17b416f8ef11733f7aebe11fe330dce20c1844cacf243766c1d643d011df1d13cac9eda36c46c6c475693d21
   languageName: node
   linkType: hard
 
 "@babel/core@npm:^7.24.5":
-  version: 7.24.8
-  resolution: "@babel/core@npm:7.24.8"
+  version: 7.26.0
+  resolution: "@babel/core@npm:7.26.0"
   dependencies:
     "@ampproject/remapping": ^2.2.0
-    "@babel/code-frame": ^7.24.7
-    "@babel/generator": ^7.24.8
-    "@babel/helper-compilation-targets": ^7.24.8
-    "@babel/helper-module-transforms": ^7.24.8
-    "@babel/helpers": ^7.24.8
-    "@babel/parser": ^7.24.8
-    "@babel/template": ^7.24.7
-    "@babel/traverse": ^7.24.8
-    "@babel/types": ^7.24.8
+    "@babel/code-frame": ^7.26.0
+    "@babel/generator": ^7.26.0
+    "@babel/helper-compilation-targets": ^7.25.9
+    "@babel/helper-module-transforms": ^7.26.0
+    "@babel/helpers": ^7.26.0
+    "@babel/parser": ^7.26.0
+    "@babel/template": ^7.25.9
+    "@babel/traverse": ^7.25.9
+    "@babel/types": ^7.26.0
     convert-source-map: ^2.0.0
     debug: ^4.1.0
     gensync: ^1.0.0-beta.2
     json5: ^2.2.3
     semver: ^6.3.1
-  checksum: 1ccb168b7c170f9816b66a2e80f89684c6b56058b4abe21ae43e0aa0645a1bb2553790199f5a29d0d3dd778f7d5e9b33f5048edf97a39e218d305d99e35a9350
-  languageName: node
-  linkType: hard
-
-"@babel/generator@npm:^7.24.1":
-  version: 7.24.1
-  resolution: "@babel/generator@npm:7.24.1"
-  dependencies:
-    "@babel/types": ^7.24.0
-    "@jridgewell/gen-mapping": ^0.3.5
-    "@jridgewell/trace-mapping": ^0.3.25
-    jsesc: ^2.5.1
-  checksum: 98c6ce5ec7a1cba2bdf35cdf607273b90cf7cf82bbe75cd0227363fb84d7e1bd8efa74f40247d5900c8c009123f10132ad209a05283757698de918278c3c6700
+  checksum: b296084cfd818bed8079526af93b5dfa0ba70282532d2132caf71d4060ab190ba26d3184832a45accd82c3c54016985a4109ab9118674347a7e5e9bc464894e6
   languageName: node
   linkType: hard
 
-"@babel/generator@npm:^7.24.8":
-  version: 7.24.8
-  resolution: "@babel/generator@npm:7.24.8"
+"@babel/generator@npm:^7.26.0, @babel/generator@npm:^7.26.3":
+  version: 7.26.3
+  resolution: "@babel/generator@npm:7.26.3"
   dependencies:
-    "@babel/types": ^7.24.8
+    "@babel/parser": ^7.26.3
+    "@babel/types": ^7.26.3
     "@jridgewell/gen-mapping": ^0.3.5
     "@jridgewell/trace-mapping": ^0.3.25
-    jsesc: ^2.5.1
-  checksum: 167ecc888ac4ba72eec18209d05e867ad730685ca5e5af2ad0682cfcf33f3b4819a2c087a414100e4f03c2d4e806054442f7b368753ab7d8462ad820190f09d1
+    jsesc: ^3.0.2
+  checksum: fb09fa55c66f272badf71c20a3a2cee0fa1a447fed32d1b84f16a668a42aff3e5f5ddc6ed5d832dda1e952187c002ca1a5cdd827022efe591b6ac44cada884ea
   languageName: node
   linkType: hard
 
 "@babel/helper-annotate-as-pure@npm:^7.22.5":
-  version: 7.22.5
-  resolution: "@babel/helper-annotate-as-pure@npm:7.22.5"
+  version: 7.25.9
+  resolution: "@babel/helper-annotate-as-pure@npm:7.25.9"
   dependencies:
-    "@babel/types": ^7.22.5
-  checksum: 53da330f1835c46f26b7bf4da31f7a496dee9fd8696cca12366b94ba19d97421ce519a74a837f687749318f94d1a37f8d1abcbf35e8ed22c32d16373b2f6198d
+    "@babel/types": ^7.25.9
+  checksum: 41edda10df1ae106a9b4fe617bf7c6df77db992992afd46192534f5cff29f9e49a303231733782dd65c5f9409714a529f215325569f14282046e9d3b7a1ffb6c
   languageName: node
   linkType: hard
 
-"@babel/helper-compilation-targets@npm:^7.24.8":
-  version: 7.24.8
-  resolution: "@babel/helper-compilation-targets@npm:7.24.8"
+"@babel/helper-compilation-targets@npm:^7.25.9":
+  version: 7.25.9
+  resolution: "@babel/helper-compilation-targets@npm:7.25.9"
   dependencies:
-    "@babel/compat-data": ^7.24.8
-    "@babel/helper-validator-option": ^7.24.8
-    browserslist: ^4.23.1
+    "@babel/compat-data": ^7.25.9
+    "@babel/helper-validator-option": ^7.25.9
+    browserslist: ^4.24.0
     lru-cache: ^5.1.1
     semver: ^6.3.1
-  checksum: 40c9e87212fffccca387504b259a629615d7df10fc9080c113da6c51095d3e8b622a1409d9ed09faf2191628449ea28d582179c5148e2e993a3140234076b8da
-  languageName: node
-  linkType: hard
-
-"@babel/helper-environment-visitor@npm:^7.22.20":
-  version: 7.22.20
-  resolution: "@babel/helper-environment-visitor@npm:7.22.20"
-  checksum: d80ee98ff66f41e233f36ca1921774c37e88a803b2f7dca3db7c057a5fea0473804db9fb6729e5dbfd07f4bed722d60f7852035c2c739382e84c335661590b69
-  languageName: node
-  linkType: hard
-
-"@babel/helper-environment-visitor@npm:^7.24.7":
-  version: 7.24.7
-  resolution: "@babel/helper-environment-visitor@npm:7.24.7"
-  dependencies:
-    "@babel/types": ^7.24.7
-  checksum: 079d86e65701b29ebc10baf6ed548d17c19b808a07aa6885cc141b690a78581b180ee92b580d755361dc3b16adf975b2d2058b8ce6c86675fcaf43cf22f2f7c6
-  languageName: node
-  linkType: hard
-
-"@babel/helper-function-name@npm:^7.23.0":
-  version: 7.23.0
-  resolution: "@babel/helper-function-name@npm:7.23.0"
-  dependencies:
-    "@babel/template": ^7.22.15
-    "@babel/types": ^7.23.0
-  checksum: e44542257b2d4634a1f979244eb2a4ad8e6d75eb6761b4cfceb56b562f7db150d134bc538c8e6adca3783e3bc31be949071527aa8e3aab7867d1ad2d84a26e10
-  languageName: node
-  linkType: hard
-
-"@babel/helper-function-name@npm:^7.24.7":
-  version: 7.24.7
-  resolution: "@babel/helper-function-name@npm:7.24.7"
-  dependencies:
-    "@babel/template": ^7.24.7
-    "@babel/types": ^7.24.7
-  checksum: 142ee08922074dfdc0ff358e09ef9f07adf3671ab6eef4fca74dcf7a551f1a43717e7efa358c9e28d7eea84c28d7f177b7a58c70452fc312ae3b1893c5dab2a4
-  languageName: node
-  linkType: hard
-
-"@babel/helper-hoist-variables@npm:^7.22.5":
-  version: 7.22.5
-  resolution: "@babel/helper-hoist-variables@npm:7.22.5"
-  dependencies:
-    "@babel/types": ^7.22.5
-  checksum: 394ca191b4ac908a76e7c50ab52102669efe3a1c277033e49467913c7ed6f7c64d7eacbeabf3bed39ea1f41731e22993f763b1edce0f74ff8563fd1f380d92cc
-  languageName: node
-  linkType: hard
-
-"@babel/helper-hoist-variables@npm:^7.24.7":
-  version: 7.24.7
-  resolution: "@babel/helper-hoist-variables@npm:7.24.7"
-  dependencies:
-    "@babel/types": ^7.24.7
-  checksum: 6cfdcf2289cd12185dcdbdf2435fa8d3447b797ac75851166de9fc8503e2fd0021db6baf8dfbecad3753e582c08e6a3f805c8d00cbed756060a877d705bd8d8d
-  languageName: node
-  linkType: hard
-
-"@babel/helper-module-imports@npm:^7.0.0, @babel/helper-module-imports@npm:^7.22.5":
-  version: 7.24.3
-  resolution: "@babel/helper-module-imports@npm:7.24.3"
-  dependencies:
-    "@babel/types": ^7.24.0
-  checksum: c23492189ba97a1ec7d37012336a5661174e8b88194836b6bbf90d13c3b72c1db4626263c654454986f924c6da8be7ba7f9447876d709cd00bd6ffde6ec00796
+  checksum: 3af536e2db358b38f968abdf7d512d425d1018fef2f485d6f131a57a7bcaed32c606b4e148bb230e1508fa42b5b2ac281855a68eb78270f54698c48a83201b9b
   languageName: node
   linkType: hard
 
-"@babel/helper-module-imports@npm:^7.24.7":
-  version: 7.24.7
-  resolution: "@babel/helper-module-imports@npm:7.24.7"
+"@babel/helper-module-imports@npm:^7.0.0, @babel/helper-module-imports@npm:^7.22.5, @babel/helper-module-imports@npm:^7.25.9":
+  version: 7.25.9
+  resolution: "@babel/helper-module-imports@npm:7.25.9"
   dependencies:
-    "@babel/traverse": ^7.24.7
-    "@babel/types": ^7.24.7
-  checksum: 8ac15d96d262b8940bc469052a048e06430bba1296369be695fabdf6799f201dd0b00151762b56012a218464e706bc033f27c07f6cec20c6f8f5fd6543c67054
+    "@babel/traverse": ^7.25.9
+    "@babel/types": ^7.25.9
+  checksum: 1b411ce4ca825422ef7065dffae7d8acef52023e51ad096351e3e2c05837e9bf9fca2af9ca7f28dc26d596a588863d0fedd40711a88e350b736c619a80e704e6
   languageName: node
   linkType: hard
 
-"@babel/helper-module-transforms@npm:^7.24.8":
-  version: 7.24.8
-  resolution: "@babel/helper-module-transforms@npm:7.24.8"
+"@babel/helper-module-transforms@npm:^7.26.0":
+  version: 7.26.0
+  resolution: "@babel/helper-module-transforms@npm:7.26.0"
   dependencies:
-    "@babel/helper-environment-visitor": ^7.24.7
-    "@babel/helper-module-imports": ^7.24.7
-    "@babel/helper-simple-access": ^7.24.7
-    "@babel/helper-split-export-declaration": ^7.24.7
-    "@babel/helper-validator-identifier": ^7.24.7
+    "@babel/helper-module-imports": ^7.25.9
+    "@babel/helper-validator-identifier": ^7.25.9
+    "@babel/traverse": ^7.25.9
   peerDependencies:
     "@babel/core": ^7.0.0
-  checksum: a7a515f4786e2c2e354721c5806c07a3ccb7ee73da7cd8c305d2d4c573d9170eadd9393e9eb993b9cd9b0ad28249d8290a525cd38e1fdfaf9f0fa04c1932c204
-  languageName: node
-  linkType: hard
-
-"@babel/helper-plugin-utils@npm:^7.24.0":
-  version: 7.24.0
-  resolution: "@babel/helper-plugin-utils@npm:7.24.0"
-  checksum: e2baa0eede34d2fa2265947042aa84d444aa48dc51e9feedea55b67fc1bc3ab051387e18b33ca7748285a6061390831ab82f8a2c767d08470b93500ec727e9b9
-  languageName: node
-  linkType: hard
-
-"@babel/helper-plugin-utils@npm:^7.24.7":
-  version: 7.24.8
-  resolution: "@babel/helper-plugin-utils@npm:7.24.8"
-  checksum: 73b1a83ba8bcee21dc94de2eb7323207391715e4369fd55844bb15cf13e3df6f3d13a40786d990e6370bf0f571d94fc31f70dec96c1d1002058258c35ca3767a
-  languageName: node
-  linkType: hard
-
-"@babel/helper-simple-access@npm:^7.24.7":
-  version: 7.24.7
-  resolution: "@babel/helper-simple-access@npm:7.24.7"
-  dependencies:
-    "@babel/traverse": ^7.24.7
-    "@babel/types": ^7.24.7
-  checksum: ddbf55f9dea1900213f2a1a8500fabfd21c5a20f44dcfa957e4b0d8638c730f88751c77f678644f754f1a1dc73f4eb8b766c300deb45a9daad000e4247957819
-  languageName: node
-  linkType: hard
-
-"@babel/helper-split-export-declaration@npm:^7.22.6":
-  version: 7.22.6
-  resolution: "@babel/helper-split-export-declaration@npm:7.22.6"
-  dependencies:
-    "@babel/types": ^7.22.5
-  checksum: e141cace583b19d9195f9c2b8e17a3ae913b7ee9b8120246d0f9ca349ca6f03cb2c001fd5ec57488c544347c0bb584afec66c936511e447fd20a360e591ac921
-  languageName: node
-  linkType: hard
-
-"@babel/helper-split-export-declaration@npm:^7.24.7":
-  version: 7.24.7
-  resolution: "@babel/helper-split-export-declaration@npm:7.24.7"
-  dependencies:
-    "@babel/types": ^7.24.7
-  checksum: e3ddc91273e5da67c6953f4aa34154d005a00791dc7afa6f41894e768748540f6ebcac5d16e72541aea0c89bee4b89b4da6a3d65972a0ea8bfd2352eda5b7e22
-  languageName: node
-  linkType: hard
-
-"@babel/helper-string-parser@npm:^7.23.4":
-  version: 7.24.1
-  resolution: "@babel/helper-string-parser@npm:7.24.1"
-  checksum: 8404e865b06013979a12406aab4c0e8d2e377199deec09dfe9f57b833b0c9ce7b6e8c1c553f2da8d0bcd240c5005bd7a269f4fef0d628aeb7d5fe035c436fb67
+  checksum: 942eee3adf2b387443c247a2c190c17c4fd45ba92a23087abab4c804f40541790d51ad5277e4b5b1ed8d5ba5b62de73857446b7742f835c18ebd350384e63917
   languageName: node
   linkType: hard
 
-"@babel/helper-string-parser@npm:^7.24.8":
-  version: 7.24.8
-  resolution: "@babel/helper-string-parser@npm:7.24.8"
-  checksum: 39b03c5119216883878655b149148dc4d2e284791e969b19467a9411fccaa33f7a713add98f4db5ed519535f70ad273cdadfd2eb54d47ebbdeac5083351328ce
+"@babel/helper-plugin-utils@npm:^7.25.9":
+  version: 7.25.9
+  resolution: "@babel/helper-plugin-utils@npm:7.25.9"
+  checksum: e19ec8acf0b696756e6d84531f532c5fe508dce57aa68c75572a77798bd04587a844a9a6c8ea7d62d673e21fdc174d091c9097fb29aea1c1b49f9c6eaa80f022
   languageName: node
   linkType: hard
 
-"@babel/helper-validator-identifier@npm:^7.22.20":
-  version: 7.22.20
-  resolution: "@babel/helper-validator-identifier@npm:7.22.20"
-  checksum: 136412784d9428266bcdd4d91c32bcf9ff0e8d25534a9d94b044f77fe76bc50f941a90319b05aafd1ec04f7d127cd57a179a3716009ff7f3412ef835ada95bdc
+"@babel/helper-string-parser@npm:^7.25.9":
+  version: 7.25.9
+  resolution: "@babel/helper-string-parser@npm:7.25.9"
+  checksum: 6435ee0849e101681c1849868278b5aee82686ba2c1e27280e5e8aca6233af6810d39f8e4e693d2f2a44a3728a6ccfd66f72d71826a94105b86b731697cdfa99
   languageName: node
   linkType: hard
 
-"@babel/helper-validator-identifier@npm:^7.24.7":
-  version: 7.24.7
-  resolution: "@babel/helper-validator-identifier@npm:7.24.7"
-  checksum: 6799ab117cefc0ecd35cd0b40ead320c621a298ecac88686a14cffceaac89d80cdb3c178f969861bf5fa5e4f766648f9161ea0752ecfe080d8e89e3147270257
+"@babel/helper-validator-identifier@npm:^7.25.9":
+  version: 7.25.9
+  resolution: "@babel/helper-validator-identifier@npm:7.25.9"
+  checksum: 5b85918cb1a92a7f3f508ea02699e8d2422fe17ea8e82acd445006c0ef7520fbf48e3dbcdaf7b0a1d571fc3a2715a29719e5226636cb6042e15fe6ed2a590944
   languageName: node
   linkType: hard
 
-"@babel/helper-validator-option@npm:^7.24.8":
-  version: 7.24.8
-  resolution: "@babel/helper-validator-option@npm:7.24.8"
-  checksum: a52442dfa74be6719c0608fee3225bd0493c4057459f3014681ea1a4643cd38b68ff477fe867c4b356da7330d085f247f0724d300582fa4ab9a02efaf34d107c
-  languageName: node
-  linkType: hard
-
-"@babel/helpers@npm:^7.24.8":
-  version: 7.24.8
-  resolution: "@babel/helpers@npm:7.24.8"
-  dependencies:
-    "@babel/template": ^7.24.7
-    "@babel/types": ^7.24.8
-  checksum: 2d7301b1b9c91e518c4766bae171230e243d98461c15eabbd44f8f9c83c297fad5c4a64ad80cfec9ca8e90412fc2b41ee86d7eb35dc8a7611c268bcf1317fe46
+"@babel/helper-validator-option@npm:^7.25.9":
+  version: 7.25.9
+  resolution: "@babel/helper-validator-option@npm:7.25.9"
+  checksum: 9491b2755948ebbdd68f87da907283698e663b5af2d2b1b02a2765761974b1120d5d8d49e9175b167f16f72748ffceec8c9cf62acfbee73f4904507b246e2b3d
   languageName: node
   linkType: hard
 
-"@babel/highlight@npm:^7.24.2":
-  version: 7.24.2
-  resolution: "@babel/highlight@npm:7.24.2"
+"@babel/helpers@npm:^7.26.0":
+  version: 7.26.0
+  resolution: "@babel/helpers@npm:7.26.0"
   dependencies:
-    "@babel/helper-validator-identifier": ^7.22.20
-    chalk: ^2.4.2
-    js-tokens: ^4.0.0
-    picocolors: ^1.0.0
-  checksum: 5f17b131cc3ebf3ab285a62cf98a404aef1bd71a6be045e748f8d5bf66d6a6e1aefd62f5972c84369472e8d9f22a614c58a89cd331eb60b7ba965b31b1bbeaf5
+    "@babel/template": ^7.25.9
+    "@babel/types": ^7.26.0
+  checksum: d77fe8d45033d6007eadfa440355c1355eed57902d5a302f450827ad3d530343430a21210584d32eef2f216ae463d4591184c6fc60cf205bbf3a884561469200
   languageName: node
   linkType: hard
 
-"@babel/highlight@npm:^7.24.7":
-  version: 7.24.7
-  resolution: "@babel/highlight@npm:7.24.7"
+"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.25.9, @babel/parser@npm:^7.26.0, @babel/parser@npm:^7.26.3":
+  version: 7.26.3
+  resolution: "@babel/parser@npm:7.26.3"
   dependencies:
-    "@babel/helper-validator-identifier": ^7.24.7
-    chalk: ^2.4.2
-    js-tokens: ^4.0.0
-    picocolors: ^1.0.0
-  checksum: 5cd3a89f143671c4ac129960024ba678b669e6fc673ce078030f5175002d1d3d52bc10b22c5b916a6faf644b5028e9a4bd2bb264d053d9b05b6a98690f1d46f1
-  languageName: node
-  linkType: hard
-
-"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.24.0, @babel/parser@npm:^7.24.1":
-  version: 7.24.1
-  resolution: "@babel/parser@npm:7.24.1"
-  bin:
-    parser: ./bin/babel-parser.js
-  checksum: a1068941dddf82ffdf572565b8b7b2cddb963ff9ddf97e6e28f50e843d820b4285e6def8f59170104a94e2a91ae2e3b326489886d77a57ea29d468f6a5e79bf9
-  languageName: node
-  linkType: hard
-
-"@babel/parser@npm:^7.24.7, @babel/parser@npm:^7.24.8":
-  version: 7.24.8
-  resolution: "@babel/parser@npm:7.24.8"
+    "@babel/types": ^7.26.3
   bin:
     parser: ./bin/babel-parser.js
-  checksum: 76f866333bfbd53800ac027419ae523bb0137fc63daa968232eb780e4390136bb6e497cb4a2cf6051a2c318aa335c2e6d2adc17079d60691ae7bde89b28c5688
+  checksum: e2bff2e9fa6540ee18fecc058bc74837eda2ddcecbe13454667314a93fc0ba26c1fb862c812d84f6d5f225c3bd8d191c3a42d4296e287a882c4e1f82ff2815ff
   languageName: node
   linkType: hard
 
 "@babel/plugin-syntax-jsx@npm:^7.22.5":
-  version: 7.24.1
-  resolution: "@babel/plugin-syntax-jsx@npm:7.24.1"
+  version: 7.25.9
+  resolution: "@babel/plugin-syntax-jsx@npm:7.25.9"
   dependencies:
-    "@babel/helper-plugin-utils": ^7.24.0
+    "@babel/helper-plugin-utils": ^7.25.9
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 712f7e7918cb679f106769f57cfab0bc99b311032665c428b98f4c3e2e6d567601d45386a4f246df6a80d741e1f94192b3f008800d66c4f1daae3ad825c243f0
+  checksum: bb609d1ffb50b58f0c1bac8810d0e46a4f6c922aa171c458f3a19d66ee545d36e782d3bffbbc1fed0dc65a558bdce1caf5279316583c0fff5a2c1658982a8563
   languageName: node
   linkType: hard
 
 "@babel/plugin-transform-react-jsx-self@npm:^7.24.5":
-  version: 7.24.7
-  resolution: "@babel/plugin-transform-react-jsx-self@npm:7.24.7"
+  version: 7.25.9
+  resolution: "@babel/plugin-transform-react-jsx-self@npm:7.25.9"
   dependencies:
-    "@babel/helper-plugin-utils": ^7.24.7
+    "@babel/helper-plugin-utils": ^7.25.9
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 2d72c33664e614031b8a03fc2d4cfd185e99efb1d681cbde4b0b4ab379864b31d83ee923509892f6d94b2c5893c309f0217d33bcda3e470ed42297f958138381
+  checksum: 41c833cd7f91b1432710f91b1325706e57979b2e8da44e83d86312c78bbe96cd9ef778b4e79e4e17ab25fa32c72b909f2be7f28e876779ede28e27506c41f4ae
   languageName: node
   linkType: hard
 
 "@babel/plugin-transform-react-jsx-source@npm:^7.24.1":
-  version: 7.24.7
-  resolution: "@babel/plugin-transform-react-jsx-source@npm:7.24.7"
+  version: 7.25.9
+  resolution: "@babel/plugin-transform-react-jsx-source@npm:7.25.9"
   dependencies:
-    "@babel/helper-plugin-utils": ^7.24.7
+    "@babel/helper-plugin-utils": ^7.25.9
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: c9afcb2259dd124a2de76f8a578589c18bd2f24dbcf78fe02b53c5cbc20c493c4618369604720e4e699b52be10ba0751b97140e1ef8bc8f0de0a935280e9d5b7
-  languageName: node
-  linkType: hard
-
-"@babel/template@npm:^7.22.15":
-  version: 7.24.0
-  resolution: "@babel/template@npm:7.24.0"
-  dependencies:
-    "@babel/code-frame": ^7.23.5
-    "@babel/parser": ^7.24.0
-    "@babel/types": ^7.24.0
-  checksum: f257b003c071a0cecdbfceca74185f18fe62c055469ab5c1d481aab12abeebed328e67e0a19fd978a2a8de97b28953fa4bc3da6d038a7345fdf37923b9fcdec8
+  checksum: a3e0e5672e344e9d01fb20b504fe29a84918eaa70cec512c4d4b1b035f72803261257343d8e93673365b72c371f35cf34bb0d129720bf178a4c87812c8b9c662
   languageName: node
   linkType: hard
 
-"@babel/template@npm:^7.24.7":
-  version: 7.24.7
-  resolution: "@babel/template@npm:7.24.7"
+"@babel/template@npm:^7.25.9":
+  version: 7.25.9
+  resolution: "@babel/template@npm:7.25.9"
   dependencies:
-    "@babel/code-frame": ^7.24.7
-    "@babel/parser": ^7.24.7
-    "@babel/types": ^7.24.7
-  checksum: ea90792fae708ddf1632e54c25fe1a86643d8c0132311f81265d2bdbdd42f9f4fac65457056c1b6ca87f7aa0d6a795b549566774bba064bdcea2034ab3960ee9
+    "@babel/code-frame": ^7.25.9
+    "@babel/parser": ^7.25.9
+    "@babel/types": ^7.25.9
+  checksum: 103641fea19c7f4e82dc913aa6b6ac157112a96d7c724d513288f538b84bae04fb87b1f1e495ac1736367b1bc30e10f058b30208fb25f66038e1f1eb4e426472
   languageName: node
   linkType: hard
 
-"@babel/traverse@npm:^7.24.7, @babel/traverse@npm:^7.24.8":
-  version: 7.24.8
-  resolution: "@babel/traverse@npm:7.24.8"
+"@babel/traverse@npm:^7.25.9, @babel/traverse@npm:^7.4.5":
+  version: 7.26.4
+  resolution: "@babel/traverse@npm:7.26.4"
   dependencies:
-    "@babel/code-frame": ^7.24.7
-    "@babel/generator": ^7.24.8
-    "@babel/helper-environment-visitor": ^7.24.7
-    "@babel/helper-function-name": ^7.24.7
-    "@babel/helper-hoist-variables": ^7.24.7
-    "@babel/helper-split-export-declaration": ^7.24.7
-    "@babel/parser": ^7.24.8
-    "@babel/types": ^7.24.8
+    "@babel/code-frame": ^7.26.2
+    "@babel/generator": ^7.26.3
+    "@babel/parser": ^7.26.3
+    "@babel/template": ^7.25.9
+    "@babel/types": ^7.26.3
     debug: ^4.3.1
     globals: ^11.1.0
-  checksum: ee7955476ce031613249f2b0ce9e74a3b7787c9d52e84534fcf39ad61aeb0b811a4cd83edc157608be4886f04c6ecf210861e211ba2a3db4fda729cc2048b5ed
+  checksum: dcdf51b27ab640291f968e4477933465c2910bfdcbcff8f5315d1f29b8ff861864f363e84a71fb489f5e9708e8b36b7540608ce019aa5e57ef7a4ba537e62700
   languageName: node
   linkType: hard
 
-"@babel/traverse@npm:^7.4.5":
-  version: 7.24.1
-  resolution: "@babel/traverse@npm:7.24.1"
+"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.25.9, @babel/types@npm:^7.26.0, @babel/types@npm:^7.26.3":
+  version: 7.26.3
+  resolution: "@babel/types@npm:7.26.3"
   dependencies:
-    "@babel/code-frame": ^7.24.1
-    "@babel/generator": ^7.24.1
-    "@babel/helper-environment-visitor": ^7.22.20
-    "@babel/helper-function-name": ^7.23.0
-    "@babel/helper-hoist-variables": ^7.22.5
-    "@babel/helper-split-export-declaration": ^7.22.6
-    "@babel/parser": ^7.24.1
-    "@babel/types": ^7.24.0
-    debug: ^4.3.1
-    globals: ^11.1.0
-  checksum: 92a5ca906abfba9df17666d2001ab23f18600035f706a687055a0e392a690ae48d6fec67c8bd4ef19ba18699a77a5b7f85727e36b83f7d110141608fe0c24fe9
-  languageName: node
-  linkType: hard
-
-"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.22.5, @babel/types@npm:^7.23.0, @babel/types@npm:^7.24.0, @babel/types@npm:^7.8.3":
-  version: 7.24.0
-  resolution: "@babel/types@npm:7.24.0"
-  dependencies:
-    "@babel/helper-string-parser": ^7.23.4
-    "@babel/helper-validator-identifier": ^7.22.20
-    to-fast-properties: ^2.0.0
-  checksum: 4b574a37d490f621470ff36a5afaac6deca5546edcb9b5e316d39acbb20998e9c2be42f3fc0bf2b55906fc49ff2a5a6a097e8f5a726ee3f708a0b0ca93aed807
-  languageName: node
-  linkType: hard
-
-"@babel/types@npm:^7.24.7, @babel/types@npm:^7.24.8":
-  version: 7.24.8
-  resolution: "@babel/types@npm:7.24.8"
-  dependencies:
-    "@babel/helper-string-parser": ^7.24.8
-    "@babel/helper-validator-identifier": ^7.24.7
-    to-fast-properties: ^2.0.0
-  checksum: e3f58ce9272c6ad519ce2ccf66efb1bfc84a62a344c0e252580d258638e0f0754eb060ec3aea3296c961973c188959f8fd3dc12f8ab6ed4ead1fb7723d693a33
+    "@babel/helper-string-parser": ^7.25.9
+    "@babel/helper-validator-identifier": ^7.25.9
+  checksum: 195f428080dcaadbcecc9445df7f91063beeaa91b49ccd78f38a5af6b75a6a58391d0c6614edb1ea322e57889a1684a0aab8e667951f820196901dd341f931e9
   languageName: node
   linkType: hard
 
@@ -449,17 +240,17 @@ __metadata:
   linkType: hard
 
 "@biomejs/biome@npm:^1.8.3":
-  version: 1.8.3
-  resolution: "@biomejs/biome@npm:1.8.3"
-  dependencies:
-    "@biomejs/cli-darwin-arm64": 1.8.3
-    "@biomejs/cli-darwin-x64": 1.8.3
-    "@biomejs/cli-linux-arm64": 1.8.3
-    "@biomejs/cli-linux-arm64-musl": 1.8.3
-    "@biomejs/cli-linux-x64": 1.8.3
-    "@biomejs/cli-linux-x64-musl": 1.8.3
-    "@biomejs/cli-win32-arm64": 1.8.3
-    "@biomejs/cli-win32-x64": 1.8.3
+  version: 1.9.4
+  resolution: "@biomejs/biome@npm:1.9.4"
+  dependencies:
+    "@biomejs/cli-darwin-arm64": 1.9.4
+    "@biomejs/cli-darwin-x64": 1.9.4
+    "@biomejs/cli-linux-arm64": 1.9.4
+    "@biomejs/cli-linux-arm64-musl": 1.9.4
+    "@biomejs/cli-linux-x64": 1.9.4
+    "@biomejs/cli-linux-x64-musl": 1.9.4
+    "@biomejs/cli-win32-arm64": 1.9.4
+    "@biomejs/cli-win32-x64": 1.9.4
   dependenciesMeta:
     "@biomejs/cli-darwin-arm64":
       optional: true
@@ -479,62 +270,62 @@ __metadata:
       optional: true
   bin:
     biome: bin/biome
-  checksum: c5e6379aa640ab1d2b6490abd3820b6998b08d94bb6a3907c39d40c4396efe26996cbf1f03bda6ab86cd83adbb52b5124d81a68dd914c3a03eedc8070d72bce9
+  checksum: 0bb448d9cf07c76556e0af62cec4262ccdf2d2800a472459c0666c180fdb74ac602a5d87325e926e860cc41c34166fca27f753afc24b2264317f2f29861005b5
   languageName: node
   linkType: hard
 
-"@biomejs/cli-darwin-arm64@npm:1.8.3":
-  version: 1.8.3
-  resolution: "@biomejs/cli-darwin-arm64@npm:1.8.3"
+"@biomejs/cli-darwin-arm64@npm:1.9.4":
+  version: 1.9.4
+  resolution: "@biomejs/cli-darwin-arm64@npm:1.9.4"
   conditions: os=darwin & cpu=arm64
   languageName: node
   linkType: hard
 
-"@biomejs/cli-darwin-x64@npm:1.8.3":
-  version: 1.8.3
-  resolution: "@biomejs/cli-darwin-x64@npm:1.8.3"
+"@biomejs/cli-darwin-x64@npm:1.9.4":
+  version: 1.9.4
+  resolution: "@biomejs/cli-darwin-x64@npm:1.9.4"
   conditions: os=darwin & cpu=x64
   languageName: node
   linkType: hard
 
-"@biomejs/cli-linux-arm64-musl@npm:1.8.3":
-  version: 1.8.3
-  resolution: "@biomejs/cli-linux-arm64-musl@npm:1.8.3"
+"@biomejs/cli-linux-arm64-musl@npm:1.9.4":
+  version: 1.9.4
+  resolution: "@biomejs/cli-linux-arm64-musl@npm:1.9.4"
   conditions: os=linux & cpu=arm64 & libc=musl
   languageName: node
   linkType: hard
 
-"@biomejs/cli-linux-arm64@npm:1.8.3":
-  version: 1.8.3
-  resolution: "@biomejs/cli-linux-arm64@npm:1.8.3"
+"@biomejs/cli-linux-arm64@npm:1.9.4":
+  version: 1.9.4
+  resolution: "@biomejs/cli-linux-arm64@npm:1.9.4"
   conditions: os=linux & cpu=arm64 & libc=glibc
   languageName: node
   linkType: hard
 
-"@biomejs/cli-linux-x64-musl@npm:1.8.3":
-  version: 1.8.3
-  resolution: "@biomejs/cli-linux-x64-musl@npm:1.8.3"
+"@biomejs/cli-linux-x64-musl@npm:1.9.4":
+  version: 1.9.4
+  resolution: "@biomejs/cli-linux-x64-musl@npm:1.9.4"
   conditions: os=linux & cpu=x64 & libc=musl
   languageName: node
   linkType: hard
 
-"@biomejs/cli-linux-x64@npm:1.8.3":
-  version: 1.8.3
-  resolution: "@biomejs/cli-linux-x64@npm:1.8.3"
+"@biomejs/cli-linux-x64@npm:1.9.4":
+  version: 1.9.4
+  resolution: "@biomejs/cli-linux-x64@npm:1.9.4"
   conditions: os=linux & cpu=x64 & libc=glibc
   languageName: node
   linkType: hard
 
-"@biomejs/cli-win32-arm64@npm:1.8.3":
-  version: 1.8.3
-  resolution: "@biomejs/cli-win32-arm64@npm:1.8.3"
+"@biomejs/cli-win32-arm64@npm:1.9.4":
+  version: 1.9.4
+  resolution: "@biomejs/cli-win32-arm64@npm:1.9.4"
   conditions: os=win32 & cpu=arm64
   languageName: node
   linkType: hard
 
-"@biomejs/cli-win32-x64@npm:1.8.3":
-  version: 1.8.3
-  resolution: "@biomejs/cli-win32-x64@npm:1.8.3"
+"@biomejs/cli-win32-x64@npm:1.9.4":
+  version: 1.9.4
+  resolution: "@biomejs/cli-win32-x64@npm:1.9.4"
   conditions: os=win32 & cpu=x64
   languageName: node
   linkType: hard
@@ -546,44 +337,9 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@csstools/css-parser-algorithms@npm:^2.7.1":
-  version: 2.7.1
-  resolution: "@csstools/css-parser-algorithms@npm:2.7.1"
-  peerDependencies:
-    "@csstools/css-tokenizer": ^2.4.1
-  checksum: 304e6f92e583042c310e368a82b694af563a395e5c55911caefe52765c5acb000b9daa17356ea8a4dd37d4d50132b76de48ced75159b169b53e134ff78b362ba
-  languageName: node
-  linkType: hard
-
-"@csstools/css-tokenizer@npm:^2.4.1":
-  version: 2.4.1
-  resolution: "@csstools/css-tokenizer@npm:2.4.1"
-  checksum: 395c51f8724ddc4851d836f484346bb3ea6a67af936dde12cbf9a57ae321372e79dee717cbe4823599eb0e6fd2d5405cf8873450e986c2fca6e6ed82e7b10219
-  languageName: node
-  linkType: hard
-
-"@csstools/media-query-list-parser@npm:^2.1.13":
-  version: 2.1.13
-  resolution: "@csstools/media-query-list-parser@npm:2.1.13"
-  peerDependencies:
-    "@csstools/css-parser-algorithms": ^2.7.1
-    "@csstools/css-tokenizer": ^2.4.1
-  checksum: 7754b4b9fcc749a51a2bcd34a167ad16e7227ff087f6c4e15b3593d3342413446b72dad37f1adb99c62538730c77e3e47842987ce453fbb3849d329a39ba9ad7
-  languageName: node
-  linkType: hard
-
-"@csstools/selector-specificity@npm:^3.1.1":
-  version: 3.1.1
-  resolution: "@csstools/selector-specificity@npm:3.1.1"
-  peerDependencies:
-    postcss-selector-parser: ^6.0.13
-  checksum: 3786a6afea97b08ad739ee8f4004f7e0a9e25049cee13af809dbda6462090744012a54bd9275a44712791e8f103f85d21641f14e81799f9dab946b0459a5e1ef
-  languageName: node
-  linkType: hard
-
 "@cypress/request@npm:^3.0.1":
-  version: 3.0.1
-  resolution: "@cypress/request@npm:3.0.1"
+  version: 3.0.7
+  resolution: "@cypress/request@npm:3.0.7"
   dependencies:
     aws-sign2: ~0.7.0
     aws4: ^1.8.0
@@ -591,19 +347,19 @@ __metadata:
     combined-stream: ~1.0.6
     extend: ~3.0.2
     forever-agent: ~0.6.1
-    form-data: ~2.3.2
-    http-signature: ~1.3.6
+    form-data: ~4.0.0
+    http-signature: ~1.4.0
     is-typedarray: ~1.0.0
     isstream: ~0.1.2
     json-stringify-safe: ~5.0.1
     mime-types: ~2.1.19
     performance-now: ^2.1.0
-    qs: 6.10.4
+    qs: 6.13.1
     safe-buffer: ^5.1.2
-    tough-cookie: ^4.1.3
+    tough-cookie: ^5.0.0
     tunnel-agent: ^0.6.0
     uuid: ^8.3.2
-  checksum: 7175522ebdbe30e3c37973e204c437c23ce659e58d5939466615bddcd58d778f3a8ea40f087b965ae8b8138ea8d102b729c6eb18c6324f121f3778f4a2e8e727
+  checksum: af1736764789d8023ce35d1aeb6e2f317943e65a1e83c97d83d6230257a725832d299be8c2432e508e07b5fbe03ac00112247686756511f5ec380f82bc8e69ff
   languageName: node
   linkType: hard
 
@@ -617,26 +373,19 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@dual-bundle/import-meta-resolve@npm:^4.1.0":
-  version: 4.1.0
-  resolution: "@dual-bundle/import-meta-resolve@npm:4.1.0"
-  checksum: 8a79576624d66f3ee578cb4cd7f7dac9282dc4c6f757c7c1f4c364fdbabc99584e1407e49e1ef00c0b42dab01d1f8490ec1d08a02c1238bad657a7335303d462
-  languageName: node
-  linkType: hard
-
 "@emotion/is-prop-valid@npm:^1.1.0":
-  version: 1.2.2
-  resolution: "@emotion/is-prop-valid@npm:1.2.2"
+  version: 1.3.1
+  resolution: "@emotion/is-prop-valid@npm:1.3.1"
   dependencies:
-    "@emotion/memoize": ^0.8.1
-  checksum: 61f6b128ea62b9f76b47955057d5d86fcbe2a6989d2cd1e583daac592901a950475a37d049b9f7a7c6aa8758a33b408735db759fdedfd1f629df0f85ab60ea25
+    "@emotion/memoize": ^0.9.0
+  checksum: fe6549d54f389e1a17cb02d832af7ee85fb6ea126fc18d02ca47216e8ff19332c1983f4a0ba68602cfcd3b325ffd4ebf0b2d0c6270f1e7e6fe3fca4ba7741e1a
   languageName: node
   linkType: hard
 
-"@emotion/memoize@npm:^0.8.1":
-  version: 0.8.1
-  resolution: "@emotion/memoize@npm:0.8.1"
-  checksum: a19cc01a29fcc97514948eaab4dc34d8272e934466ed87c07f157887406bc318000c69ae6f813a9001c6a225364df04249842a50e692ef7a9873335fbcc141b0
+"@emotion/memoize@npm:^0.9.0":
+  version: 0.9.0
+  resolution: "@emotion/memoize@npm:0.9.0"
+  checksum: 038132359397348e378c593a773b1148cd0cf0a2285ffd067a0f63447b945f5278860d9de718f906a74c7c940ba1783ac2ca18f1c06a307b01cc0e3944e783b1
   languageName: node
   linkType: hard
 
@@ -997,6 +746,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@isaacs/fs-minipass@npm:^4.0.0":
+  version: 4.0.1
+  resolution: "@isaacs/fs-minipass@npm:4.0.1"
+  dependencies:
+    minipass: ^7.0.4
+  checksum: 5d36d289960e886484362d9eb6a51d1ea28baed5f5d0140bbe62b99bac52eaf06cc01c2bc0d3575977962f84f6b2c4387b043ee632216643d4787b0999465bf2
+  languageName: node
+  linkType: hard
+
 "@istanbuljs/schema@npm:^0.1.2, @istanbuljs/schema@npm:^0.1.3":
   version: 0.1.3
   resolution: "@istanbuljs/schema@npm:0.1.3"
@@ -1005,13 +763,13 @@ __metadata:
   linkType: hard
 
 "@jridgewell/gen-mapping@npm:^0.3.5":
-  version: 0.3.5
-  resolution: "@jridgewell/gen-mapping@npm:0.3.5"
+  version: 0.3.8
+  resolution: "@jridgewell/gen-mapping@npm:0.3.8"
   dependencies:
     "@jridgewell/set-array": ^1.2.1
     "@jridgewell/sourcemap-codec": ^1.4.10
     "@jridgewell/trace-mapping": ^0.3.24
-  checksum: ff7a1764ebd76a5e129c8890aa3e2f46045109dabde62b0b6c6a250152227647178ff2069ea234753a690d8f3c4ac8b5e7b267bbee272bffb7f3b0a370ab6e52
+  checksum: c0687b5227461717aa537fe71a42e356bcd1c43293b3353796a148bf3b0d6f59109def46c22f05b60e29a46f19b2e4676d027959a7c53a6c92b9d5b0d87d0420
   languageName: node
   linkType: hard
 
@@ -1030,9 +788,9 @@ __metadata:
   linkType: hard
 
 "@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14":
-  version: 1.4.15
-  resolution: "@jridgewell/sourcemap-codec@npm:1.4.15"
-  checksum: b881c7e503db3fc7f3c1f35a1dd2655a188cc51a3612d76efc8a6eb74728bef5606e6758ee77423e564092b4a518aba569bbb21c9bac5ab7a35b0c6ae7e344c8
+  version: 1.5.0
+  resolution: "@jridgewell/sourcemap-codec@npm:1.5.0"
+  checksum: 05df4f2538b3b0f998ea4c1cd34574d0feba216fa5d4ccaef0187d12abf82eafe6021cec8b49f9bb4d90f2ba4582ccc581e72986a5fcf4176ae0cfeb04cf52ec
   languageName: node
   linkType: hard
 
@@ -1053,52 +811,25 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@nodelib/fs.scandir@npm:2.1.5":
-  version: 2.1.5
-  resolution: "@nodelib/fs.scandir@npm:2.1.5"
-  dependencies:
-    "@nodelib/fs.stat": 2.0.5
-    run-parallel: ^1.1.9
-  checksum: a970d595bd23c66c880e0ef1817791432dbb7acbb8d44b7e7d0e7a22f4521260d4a83f7f9fd61d44fda4610105577f8f58a60718105fb38352baed612fd79e59
-  languageName: node
-  linkType: hard
-
-"@nodelib/fs.stat@npm:2.0.5, @nodelib/fs.stat@npm:^2.0.2":
-  version: 2.0.5
-  resolution: "@nodelib/fs.stat@npm:2.0.5"
-  checksum: 012480b5ca9d97bff9261571dbbec7bbc6033f69cc92908bc1ecfad0792361a5a1994bc48674b9ef76419d056a03efadfce5a6cf6dbc0a36559571a7a483f6f0
-  languageName: node
-  linkType: hard
-
-"@nodelib/fs.walk@npm:^1.2.3":
-  version: 1.2.8
-  resolution: "@nodelib/fs.walk@npm:1.2.8"
-  dependencies:
-    "@nodelib/fs.scandir": 2.1.5
-    fastq: ^1.6.0
-  checksum: 190c643f156d8f8f277bf2a6078af1ffde1fd43f498f187c2db24d35b4b4b5785c02c7dc52e356497b9a1b65b13edc996de08de0b961c32844364da02986dc53
-  languageName: node
-  linkType: hard
-
-"@npmcli/agent@npm:^2.0.0":
-  version: 2.2.1
-  resolution: "@npmcli/agent@npm:2.2.1"
+"@npmcli/agent@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "@npmcli/agent@npm:3.0.0"
   dependencies:
     agent-base: ^7.1.0
     http-proxy-agent: ^7.0.0
     https-proxy-agent: ^7.0.1
     lru-cache: ^10.0.1
-    socks-proxy-agent: ^8.0.1
-  checksum: c69aca42dbba393f517bc5777ee872d38dc98ea0e5e93c1f6d62b82b8fecdc177a57ea045f07dda1a770c592384b2dd92a5e79e21e2a7cf51c9159466a8f9c9b
+    socks-proxy-agent: ^8.0.3
+  checksum: e8fc25d536250ed3e669813b36e8c6d805628b472353c57afd8c4fde0fcfcf3dda4ffe22f7af8c9070812ec2e7a03fb41d7151547cef3508efe661a5a3add20f
   languageName: node
   linkType: hard
 
-"@npmcli/fs@npm:^3.1.0":
-  version: 3.1.0
-  resolution: "@npmcli/fs@npm:3.1.0"
+"@npmcli/fs@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "@npmcli/fs@npm:4.0.0"
   dependencies:
     semver: ^7.3.5
-  checksum: a50a6818de5fc557d0b0e6f50ec780a7a02ab8ad07e5ac8b16bf519e0ad60a144ac64f97d05c443c3367235d337182e1d012bbac0eb8dbae8dc7b40b193efd0e
+  checksum: 68951c589e9a4328698a35fd82fe71909a257d6f2ede0434d236fa55634f0fbcad9bb8755553ce5849bd25ee6f019f4d435921ac715c853582c4a7f5983c8d4a
   languageName: node
   linkType: hard
 
@@ -1109,107 +840,135 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@rollup/rollup-android-arm-eabi@npm:4.14.0":
-  version: 4.14.0
-  resolution: "@rollup/rollup-android-arm-eabi@npm:4.14.0"
+"@rollup/rollup-android-arm-eabi@npm:4.30.1":
+  version: 4.30.1
+  resolution: "@rollup/rollup-android-arm-eabi@npm:4.30.1"
   conditions: os=android & cpu=arm
   languageName: node
   linkType: hard
 
-"@rollup/rollup-android-arm64@npm:4.14.0":
-  version: 4.14.0
-  resolution: "@rollup/rollup-android-arm64@npm:4.14.0"
+"@rollup/rollup-android-arm64@npm:4.30.1":
+  version: 4.30.1
+  resolution: "@rollup/rollup-android-arm64@npm:4.30.1"
   conditions: os=android & cpu=arm64
   languageName: node
   linkType: hard
 
-"@rollup/rollup-darwin-arm64@npm:4.14.0":
-  version: 4.14.0
-  resolution: "@rollup/rollup-darwin-arm64@npm:4.14.0"
+"@rollup/rollup-darwin-arm64@npm:4.30.1":
+  version: 4.30.1
+  resolution: "@rollup/rollup-darwin-arm64@npm:4.30.1"
   conditions: os=darwin & cpu=arm64
   languageName: node
   linkType: hard
 
-"@rollup/rollup-darwin-x64@npm:4.14.0":
-  version: 4.14.0
-  resolution: "@rollup/rollup-darwin-x64@npm:4.14.0"
+"@rollup/rollup-darwin-x64@npm:4.30.1":
+  version: 4.30.1
+  resolution: "@rollup/rollup-darwin-x64@npm:4.30.1"
   conditions: os=darwin & cpu=x64
   languageName: node
   linkType: hard
 
-"@rollup/rollup-linux-arm-gnueabihf@npm:4.14.0":
-  version: 4.14.0
-  resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.14.0"
-  conditions: os=linux & cpu=arm
+"@rollup/rollup-freebsd-arm64@npm:4.30.1":
+  version: 4.30.1
+  resolution: "@rollup/rollup-freebsd-arm64@npm:4.30.1"
+  conditions: os=freebsd & cpu=arm64
+  languageName: node
+  linkType: hard
+
+"@rollup/rollup-freebsd-x64@npm:4.30.1":
+  version: 4.30.1
+  resolution: "@rollup/rollup-freebsd-x64@npm:4.30.1"
+  conditions: os=freebsd & cpu=x64
+  languageName: node
+  linkType: hard
+
+"@rollup/rollup-linux-arm-gnueabihf@npm:4.30.1":
+  version: 4.30.1
+  resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.30.1"
+  conditions: os=linux & cpu=arm & libc=glibc
   languageName: node
   linkType: hard
 
-"@rollup/rollup-linux-arm64-gnu@npm:4.14.0":
-  version: 4.14.0
-  resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.14.0"
+"@rollup/rollup-linux-arm-musleabihf@npm:4.30.1":
+  version: 4.30.1
+  resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.30.1"
+  conditions: os=linux & cpu=arm & libc=musl
+  languageName: node
+  linkType: hard
+
+"@rollup/rollup-linux-arm64-gnu@npm:4.30.1":
+  version: 4.30.1
+  resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.30.1"
   conditions: os=linux & cpu=arm64 & libc=glibc
   languageName: node
   linkType: hard
 
-"@rollup/rollup-linux-arm64-musl@npm:4.14.0":
-  version: 4.14.0
-  resolution: "@rollup/rollup-linux-arm64-musl@npm:4.14.0"
+"@rollup/rollup-linux-arm64-musl@npm:4.30.1":
+  version: 4.30.1
+  resolution: "@rollup/rollup-linux-arm64-musl@npm:4.30.1"
   conditions: os=linux & cpu=arm64 & libc=musl
   languageName: node
   linkType: hard
 
-"@rollup/rollup-linux-powerpc64le-gnu@npm:4.14.0":
-  version: 4.14.0
-  resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.14.0"
-  conditions: os=linux & cpu=ppc64le & libc=glibc
+"@rollup/rollup-linux-loongarch64-gnu@npm:4.30.1":
+  version: 4.30.1
+  resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.30.1"
+  conditions: os=linux & cpu=loong64 & libc=glibc
+  languageName: node
+  linkType: hard
+
+"@rollup/rollup-linux-powerpc64le-gnu@npm:4.30.1":
+  version: 4.30.1
+  resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.30.1"
+  conditions: os=linux & cpu=ppc64 & libc=glibc
   languageName: node
   linkType: hard
 
-"@rollup/rollup-linux-riscv64-gnu@npm:4.14.0":
-  version: 4.14.0
-  resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.14.0"
+"@rollup/rollup-linux-riscv64-gnu@npm:4.30.1":
+  version: 4.30.1
+  resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.30.1"
   conditions: os=linux & cpu=riscv64 & libc=glibc
   languageName: node
   linkType: hard
 
-"@rollup/rollup-linux-s390x-gnu@npm:4.14.0":
-  version: 4.14.0
-  resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.14.0"
+"@rollup/rollup-linux-s390x-gnu@npm:4.30.1":
+  version: 4.30.1
+  resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.30.1"
   conditions: os=linux & cpu=s390x & libc=glibc
   languageName: node
   linkType: hard
 
-"@rollup/rollup-linux-x64-gnu@npm:4.14.0":
-  version: 4.14.0
-  resolution: "@rollup/rollup-linux-x64-gnu@npm:4.14.0"
+"@rollup/rollup-linux-x64-gnu@npm:4.30.1":
+  version: 4.30.1
+  resolution: "@rollup/rollup-linux-x64-gnu@npm:4.30.1"
   conditions: os=linux & cpu=x64 & libc=glibc
   languageName: node
   linkType: hard
 
-"@rollup/rollup-linux-x64-musl@npm:4.14.0":
-  version: 4.14.0
-  resolution: "@rollup/rollup-linux-x64-musl@npm:4.14.0"
+"@rollup/rollup-linux-x64-musl@npm:4.30.1":
+  version: 4.30.1
+  resolution: "@rollup/rollup-linux-x64-musl@npm:4.30.1"
   conditions: os=linux & cpu=x64 & libc=musl
   languageName: node
   linkType: hard
 
-"@rollup/rollup-win32-arm64-msvc@npm:4.14.0":
-  version: 4.14.0
-  resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.14.0"
+"@rollup/rollup-win32-arm64-msvc@npm:4.30.1":
+  version: 4.30.1
+  resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.30.1"
   conditions: os=win32 & cpu=arm64
   languageName: node
   linkType: hard
 
-"@rollup/rollup-win32-ia32-msvc@npm:4.14.0":
-  version: 4.14.0
-  resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.14.0"
+"@rollup/rollup-win32-ia32-msvc@npm:4.30.1":
+  version: 4.30.1
+  resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.30.1"
   conditions: os=win32 & cpu=ia32
   languageName: node
   linkType: hard
 
-"@rollup/rollup-win32-x64-msvc@npm:4.14.0":
-  version: 4.14.0
-  resolution: "@rollup/rollup-win32-x64-msvc@npm:4.14.0"
+"@rollup/rollup-win32-x64-msvc@npm:4.30.1":
+  version: 4.30.1
+  resolution: "@rollup/rollup-win32-x64-msvc@npm:4.30.1"
   conditions: os=win32 & cpu=x64
   languageName: node
   linkType: hard
@@ -1247,11 +1006,11 @@ __metadata:
   linkType: hard
 
 "@types/babel__traverse@npm:*":
-  version: 7.20.5
-  resolution: "@types/babel__traverse@npm:7.20.5"
+  version: 7.20.6
+  resolution: "@types/babel__traverse@npm:7.20.6"
   dependencies:
     "@babel/types": ^7.20.7
-  checksum: 608e0ab4fc31cd47011d98942e6241b34d461608c0c0e153377c5fd822c436c475f1ded76a56bfa76a1adf8d9266b727bbf9bfac90c4cb152c97f30dadc5b7e8
+  checksum: 2bdc65eb62232c2d5c1086adeb0c31e7980e6fd7e50a3483b4a724a1a1029c84d9cb59749cf8de612f9afa2bc14c85b8f50e64e21f8a4398fa77eb9059a4283c
   languageName: node
   linkType: hard
 
@@ -1264,20 +1023,20 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@types/estree@npm:1.0.5":
-  version: 1.0.5
-  resolution: "@types/estree@npm:1.0.5"
-  checksum: dd8b5bed28e6213b7acd0fb665a84e693554d850b0df423ac8076cc3ad5823a6bc26b0251d080bdc545af83179ede51dd3f6fa78cad2c46ed1f29624ddf3e41a
+"@types/estree@npm:1.0.6":
+  version: 1.0.6
+  resolution: "@types/estree@npm:1.0.6"
+  checksum: 8825d6e729e16445d9a1dd2fb1db2edc5ed400799064cd4d028150701031af012ba30d6d03fe9df40f4d7a437d0de6d2b256020152b7b09bde9f2e420afdffd9
   languageName: node
   linkType: hard
 
 "@types/hoist-non-react-statics@npm:*":
-  version: 3.3.5
-  resolution: "@types/hoist-non-react-statics@npm:3.3.5"
+  version: 3.3.6
+  resolution: "@types/hoist-non-react-statics@npm:3.3.6"
   dependencies:
     "@types/react": "*"
     hoist-non-react-statics: ^3.3.0
-  checksum: b645b062a20cce6ab1245ada8274051d8e2e0b2ee5c6bd58215281d0ec6dae2f26631af4e2e7c8abe238cdcee73fcaededc429eef569e70908f82d0cc0ea31d7
+  checksum: f03e43bd081876c49584ffa0eb690d69991f258203efca44dcc30efdda49a50653ff06402917d1edc9cb7e2adebbe9e2d1d0e739bc99c1b5372103b1cc534e47
   languageName: node
   linkType: hard
 
@@ -1303,11 +1062,11 @@ __metadata:
   linkType: hard
 
 "@types/node@npm:*":
-  version: 20.12.2
-  resolution: "@types/node@npm:20.12.2"
+  version: 22.10.5
+  resolution: "@types/node@npm:22.10.5"
   dependencies:
-    undici-types: ~5.26.4
-  checksum: 3242ab04fe69ae32a2da29a7a2fce41fccb370bc1189de43d2dfbb491bd3253d3ee2070cbb5613061148e4862fdaa9cf62722c43128ce5c7d33fe83750956613
+    undici-types: ~6.20.0
+  checksum: 3b0e966df4e130edac3ad034f1cddbe134e70f11556062468c9fbd749a3b07a44445a3a75a7eec68a104930bf05d4899f1a418c4ae48493d2c8c1544d8594bcc
   languageName: node
   linkType: hard
 
@@ -1321,9 +1080,9 @@ __metadata:
   linkType: hard
 
 "@types/prop-types@npm:*":
-  version: 15.7.12
-  resolution: "@types/prop-types@npm:15.7.12"
-  checksum: ac16cc3d0a84431ffa5cfdf89579ad1e2269549f32ce0c769321fdd078f84db4fbe1b461ed5a1a496caf09e637c0e367d600c541435716a55b1d9713f5035dfe
+  version: 15.7.14
+  resolution: "@types/prop-types@npm:15.7.14"
+  checksum: d0c5407b9ccc3dd5fae0ccf9b1007e7622ba5e6f1c18399b4f24dff33619d469da4b9fa918a374f19dc0d9fe6a013362aab0b844b606cfc10676efba3f5f736d
   languageName: node
   linkType: hard
 
@@ -1337,12 +1096,11 @@ __metadata:
   linkType: hard
 
 "@types/react@npm:*":
-  version: 18.2.73
-  resolution: "@types/react@npm:18.2.73"
+  version: 19.0.3
+  resolution: "@types/react@npm:19.0.3"
   dependencies:
-    "@types/prop-types": "*"
     csstype: ^3.0.2
-  checksum: 0921d3e3286f11365e796f01eff4fb64de315c68f569e0bbfdaa7680dc4b774c7e8dc416d72d77f7f16a0c2075048429386a55bbfd43ac507d1dddc8d44142e7
+  checksum: a6c2bcd032522f5c041601a0df1c56288ad66c7973fa672b6c375334dd93295a4d1bfebf9c3498bafb86525f1fd8f4d58175267ed41ef5534b64ba28bd274bb6
   languageName: node
   linkType: hard
 
@@ -1364,9 +1122,9 @@ __metadata:
   linkType: hard
 
 "@types/sizzle@npm:^2.3.2":
-  version: 2.3.8
-  resolution: "@types/sizzle@npm:2.3.8"
-  checksum: 2ac62443dc917f5f903cbd9afc51c7d6cc1c6569b4e1a15faf04aea5b13b486e7f208650014c3dc4fed34653eded3e00fe5abffe0e6300cbf0e8a01beebf11a6
+  version: 2.3.9
+  resolution: "@types/sizzle@npm:2.3.9"
+  checksum: 413811a79e7e9f1d8f47e6047ae0aea1530449d612304cdda1c30018e3d053b8544861ec2c70bdeca75a0a010192e6bb78efc6fb4caaafdd65c4eee90066686a
   languageName: node
   linkType: hard
 
@@ -1381,12 +1139,12 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@types/ws@npm:8.5.12":
-  version: 8.5.12
-  resolution: "@types/ws@npm:8.5.12"
+"@types/ws@npm:8.5.13":
+  version: 8.5.13
+  resolution: "@types/ws@npm:8.5.13"
   dependencies:
     "@types/node": "*"
-  checksum: ddefb6ad1671f70ce73b38a5f47f471d4d493864fca7c51f002a86e5993d031294201c5dced6d5018fb8905ad46888d65c7f20dd54fc165910b69f42fba9a6d0
+  checksum: f17023ce7b89c6124249c90211803a4aaa02886e12bc2d0d2cd47fa665eeb058db4d871ce4397d8e423f6beea97dd56835dd3fdbb921030fe4d887601e37d609
   languageName: node
   linkType: hard
 
@@ -1414,13 +1172,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"abbrev@npm:1":
-  version: 1.1.1
-  resolution: "abbrev@npm:1.1.1"
-  checksum: a4a97ec07d7ea112c517036882b2ac22f3109b7b19077dc656316d07d308438aac28e4d9746dc4d84bf6b1e75b4a7b0a5f3cb30592419f128ca9a8cee3bcfa17
-  languageName: node
-  linkType: hard
-
 "abbrev@npm:^2.0.0":
   version: 2.0.0
   resolution: "abbrev@npm:2.0.0"
@@ -1428,12 +1179,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"agent-base@npm:^7.0.2, agent-base@npm:^7.1.0, agent-base@npm:^7.1.1":
-  version: 7.1.1
-  resolution: "agent-base@npm:7.1.1"
-  dependencies:
-    debug: ^4.3.4
-  checksum: 51c158769c5c051482f9ca2e6e1ec085ac72b5a418a9b31b4e82fe6c0a6699adb94c1c42d246699a587b3335215037091c79e0de512c516f73b6ea844202f037
+"agent-base@npm:^7.1.0, agent-base@npm:^7.1.2":
+  version: 7.1.3
+  resolution: "agent-base@npm:7.1.3"
+  checksum: 87bb7ee54f5ecf0ccbfcba0b07473885c43ecd76cb29a8db17d6137a19d9f9cd443a2a7c5fd8a3f24d58ad8145f9eb49116344a66b107e1aeab82cf2383f4753
   languageName: node
   linkType: hard
 
@@ -1447,18 +1196,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"ajv@npm:^8.0.1":
-  version: 8.12.0
-  resolution: "ajv@npm:8.12.0"
-  dependencies:
-    fast-deep-equal: ^3.1.1
-    json-schema-traverse: ^1.0.0
-    require-from-string: ^2.0.2
-    uri-js: ^4.2.2
-  checksum: 4dc13714e316e67537c8b31bc063f99a1d9d9a497eb4bbd55191ac0dcd5e4985bbb71570352ad6f1e76684fb6d790928f96ba3b2d4fd6e10024be9612fe3f001
-  languageName: node
-  linkType: hard
-
 "ansi-colors@npm:^4.1.1":
   version: 4.1.3
   resolution: "ansi-colors@npm:4.1.3"
@@ -1483,18 +1220,9 @@ __metadata:
   linkType: hard
 
 "ansi-regex@npm:^6.0.1":
-  version: 6.0.1
-  resolution: "ansi-regex@npm:6.0.1"
-  checksum: 1ff8b7667cded1de4fa2c9ae283e979fc87036864317da86a2e546725f96406746411d0d85e87a2d12fa5abd715d90006de7fa4fa0477c92321ad3b4c7d4e169
-  languageName: node
-  linkType: hard
-
-"ansi-styles@npm:^3.2.1":
-  version: 3.2.1
-  resolution: "ansi-styles@npm:3.2.1"
-  dependencies:
-    color-convert: ^1.9.0
-  checksum: d85ade01c10e5dd77b6c89f34ed7531da5830d2cb5882c645f330079975b716438cd7ebb81d0d6e6b4f9c577f19ae41ab55f07f19786b02f9dfd9e0377395665
+  version: 6.1.0
+  resolution: "ansi-regex@npm:6.1.0"
+  checksum: 495834a53b0856c02acd40446f7130cb0f8284f4a39afdab20d5dc42b2e198b1196119fe887beed8f9055c4ff2055e3b2f6d4641d0be018cdfb64fedf6fc1aac
   languageName: node
   linkType: hard
 
@@ -1521,34 +1249,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"argparse@npm:^2.0.1":
-  version: 2.0.1
-  resolution: "argparse@npm:2.0.1"
-  checksum: 83644b56493e89a254bae05702abf3a1101b4fa4d0ca31df1c9985275a5a5bd47b3c27b7fa0b71098d41114d8ca000e6ed90cad764b306f8a503665e4d517ced
-  languageName: node
-  linkType: hard
-
-"array-find-index@npm:^1.0.2":
-  version: 1.0.2
-  resolution: "array-find-index@npm:1.0.2"
-  checksum: aac128bf369e1ac6c06ff0bb330788371c0e256f71279fb92d745e26fb4b9db8920e485b4ec25e841c93146bf71a34dcdbcefa115e7e0f96927a214d237b7081
-  languageName: node
-  linkType: hard
-
-"array-union@npm:^2.1.0":
-  version: 2.1.0
-  resolution: "array-union@npm:2.1.0"
-  checksum: 5bee12395cba82da674931df6d0fea23c4aa4660cb3b338ced9f828782a65caa232573e6bf3968f23e0c5eb301764a382cef2f128b170a9dc59de0e36c39f98d
-  languageName: node
-  linkType: hard
-
-"asap@npm:^2.0.0":
-  version: 2.0.6
-  resolution: "asap@npm:2.0.6"
-  checksum: b296c92c4b969e973260e47523207cd5769abd27c245a68c26dc7a0fe8053c55bb04360237cb51cab1df52be939da77150ace99ad331fb7fb13b3423ed73ff3d
-  languageName: node
-  linkType: hard
-
 "asn1@npm:~0.2.3":
   version: 0.2.6
   resolution: "asn1@npm:0.2.6"
@@ -1582,9 +1282,9 @@ __metadata:
   linkType: hard
 
 "async@npm:^3.2.0":
-  version: 3.2.5
-  resolution: "async@npm:3.2.5"
-  checksum: 5ec77f1312301dee02d62140a6b1f7ee0edd2a0f983b6fd2b0849b969f245225b990b47b8243e7b9ad16451a53e7f68e753700385b706198ced888beedba3af4
+  version: 3.2.6
+  resolution: "async@npm:3.2.6"
+  checksum: ee6eb8cd8a0ab1b58bd2a3ed6c415e93e773573a91d31df9d5ef559baafa9dab37d3b096fa7993e84585cac3697b2af6ddb9086f45d3ac8cae821bb2aab65682
   languageName: node
   linkType: hard
 
@@ -1610,9 +1310,9 @@ __metadata:
   linkType: hard
 
 "aws4@npm:^1.8.0":
-  version: 1.12.0
-  resolution: "aws4@npm:1.12.0"
-  checksum: 68f79708ac7c335992730bf638286a3ee0a645cf12575d557860100767c500c08b30e24726b9f03265d74116417f628af78509e1333575e9f8d52a80edfe8cbc
+  version: 1.13.2
+  resolution: "aws4@npm:1.13.2"
+  checksum: 9ac924e4a91c088b4928ea86b68d8c4558b0e6289ccabaae0e3e96a611bd75277c2eab6e3965821028768700516f612b929a5ce822f33a8771f74ba2a8cedb9c
   languageName: node
   linkType: hard
 
@@ -1638,14 +1338,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"balanced-match@npm:^2.0.0":
-  version: 2.0.0
-  resolution: "balanced-match@npm:2.0.0"
-  checksum: 9a5caad6a292c5df164cc6d0c38e0eedf9a1413f42e5fece733640949d74d0052cfa9587c1a1681f772147fb79be495121325a649526957fd75b3a216d1fbc68
-  languageName: node
-  linkType: hard
-
-"base64-js@npm:1.5.1, base64-js@npm:^1.3.1":
+"base64-js@npm:^1.3.1, base64-js@npm:^1.5.1":
   version: 1.5.1
   resolution: "base64-js@npm:1.5.1"
   checksum: 669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005
@@ -1670,17 +1363,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"bl@npm:^4.1.0":
-  version: 4.1.0
-  resolution: "bl@npm:4.1.0"
-  dependencies:
-    buffer: ^5.5.0
-    inherits: ^2.0.4
-    readable-stream: ^3.4.0
-  checksum: 9e8521fa7e83aa9427c6f8ccdcba6e8167ef30cc9a22df26effcc5ab682ef91d2cbc23a239f945d099289e4bbcfae7a192e9c28c84c6202e710a0dfec3722662
-  languageName: node
-  linkType: hard
-
 "blob-util@npm:^2.0.2":
   version: 2.0.2
   resolution: "blob-util@npm:2.0.2"
@@ -1695,16 +1377,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"brace-expansion@npm:^1.1.7":
-  version: 1.1.11
-  resolution: "brace-expansion@npm:1.1.11"
-  dependencies:
-    balanced-match: ^1.0.0
-    concat-map: 0.0.1
-  checksum: faf34a7bb0c3fcf4b59c7808bc5d2a96a40988addf2e7e09dfbb67a2251800e0d14cd2bfc1aa79174f2f5095c54ff27f46fb1289fe2d77dac755b5eb3434cc07
-  languageName: node
-  linkType: hard
-
 "brace-expansion@npm:^2.0.1":
   version: 2.0.1
   resolution: "brace-expansion@npm:2.0.1"
@@ -1714,35 +1386,17 @@ __metadata:
   languageName: node
   linkType: hard
 
-"braces@npm:^3.0.2":
-  version: 3.0.2
-  resolution: "braces@npm:3.0.2"
-  dependencies:
-    fill-range: ^7.0.1
-  checksum: e2a8e769a863f3d4ee887b5fe21f63193a891c68b612ddb4b68d82d1b5f3ff9073af066c343e9867a393fe4c2555dcb33e89b937195feb9c1613d259edfcd459
-  languageName: node
-  linkType: hard
-
-"braces@npm:^3.0.3":
-  version: 3.0.3
-  resolution: "braces@npm:3.0.3"
-  dependencies:
-    fill-range: ^7.1.1
-  checksum: b95aa0b3bd909f6cd1720ffcf031aeaf46154dd88b4da01f9a1d3f7ea866a79eba76a6d01cbc3c422b2ee5cdc39a4f02491058d5df0d7bf6e6a162a832df1f69
-  languageName: node
-  linkType: hard
-
-"browserslist@npm:^4.23.1":
-  version: 4.23.2
-  resolution: "browserslist@npm:4.23.2"
+"browserslist@npm:^4.24.0":
+  version: 4.24.3
+  resolution: "browserslist@npm:4.24.3"
   dependencies:
-    caniuse-lite: ^1.0.30001640
-    electron-to-chromium: ^1.4.820
-    node-releases: ^2.0.14
-    update-browserslist-db: ^1.1.0
+    caniuse-lite: ^1.0.30001688
+    electron-to-chromium: ^1.5.73
+    node-releases: ^2.0.19
+    update-browserslist-db: ^1.1.1
   bin:
     browserslist: cli.js
-  checksum: 8212af37f6ca6355da191cf2d4ad49bd0b82854888b9a7e103638fada70d38cbe36d28feeeaa98344cb15d9128f9f74bcc8ce1bfc9011b5fd14381c1c6fb542c
+  checksum: 016efc9953350e3a7212edcfdd72210cb33b339c1a974a77c0715eb67d23d7e5cd0a073ce1c801ab09235d8c213425ca51b92d41bbb829b833872b45f885fe7c
   languageName: node
   linkType: hard
 
@@ -1753,7 +1407,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"buffer@npm:^5.5.0, buffer@npm:^5.7.1":
+"buffer@npm:^5.7.1":
   version: 5.7.1
   resolution: "buffer@npm:5.7.1"
   dependencies:
@@ -1789,11 +1443,11 @@ __metadata:
   languageName: node
   linkType: hard
 
-"cacache@npm:^18.0.0":
-  version: 18.0.2
-  resolution: "cacache@npm:18.0.2"
+"cacache@npm:^19.0.1":
+  version: 19.0.1
+  resolution: "cacache@npm:19.0.1"
   dependencies:
-    "@npmcli/fs": ^3.1.0
+    "@npmcli/fs": ^4.0.0
     fs-minipass: ^3.0.0
     glob: ^10.2.2
     lru-cache: ^10.0.1
@@ -1801,11 +1455,11 @@ __metadata:
     minipass-collect: ^2.0.1
     minipass-flush: ^1.0.5
     minipass-pipeline: ^1.2.4
-    p-map: ^4.0.0
-    ssri: ^10.0.0
-    tar: ^6.1.11
-    unique-filename: ^3.0.0
-  checksum: 0250df80e1ad0c828c956744850c5f742c24244e9deb5b7dc81bca90f8c10e011e132ecc58b64497cc1cad9a98968676147fb6575f4f94722f7619757b17a11b
+    p-map: ^7.0.2
+    ssri: ^12.0.0
+    tar: ^7.4.3
+    unique-filename: ^4.0.0
+  checksum: e95684717de6881b4cdaa949fa7574e3171946421cd8291769dd3d2417dbf7abf4aa557d1f968cca83dcbc95bed2a281072b09abfc977c942413146ef7ed4525
   languageName: node
   linkType: hard
 
@@ -1816,23 +1470,23 @@ __metadata:
   languageName: node
   linkType: hard
 
-"call-bind@npm:^1.0.7":
-  version: 1.0.7
-  resolution: "call-bind@npm:1.0.7"
+"call-bind-apply-helpers@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "call-bind-apply-helpers@npm:1.0.1"
   dependencies:
-    es-define-property: ^1.0.0
     es-errors: ^1.3.0
     function-bind: ^1.1.2
-    get-intrinsic: ^1.2.4
-    set-function-length: ^1.2.1
-  checksum: 295c0c62b90dd6522e6db3b0ab1ce26bdf9e7404215bda13cfee25b626b5ff1a7761324d58d38b1ef1607fc65aca2d06e44d2e18d0dfc6c14b465b00d8660029
+  checksum: 3c55343261bb387c58a4762d15ad9d42053659a62681ec5eb50690c6b52a4a666302a01d557133ce6533e8bd04530ee3b209f23dd06c9577a1925556f8fcccdf
   languageName: node
   linkType: hard
 
-"callsites@npm:^3.0.0":
-  version: 3.1.0
-  resolution: "callsites@npm:3.1.0"
-  checksum: 072d17b6abb459c2ba96598918b55868af677154bec7e73d222ef95a8fdb9bbf7dae96a8421085cdad8cd190d86653b5b6dc55a4484f2e5b2e27d5e0c3fc15b3
+"call-bound@npm:^1.0.2":
+  version: 1.0.3
+  resolution: "call-bound@npm:1.0.3"
+  dependencies:
+    call-bind-apply-helpers: ^1.0.1
+    get-intrinsic: ^1.2.6
+  checksum: a93bbe0f2d0a2d6c144a4349ccd0593d5d0d5d9309b69101710644af8964286420062f2cc3114dca120b9bc8cc07507952d4b1b3ea7672e0d7f6f1675efedb32
   languageName: node
   linkType: hard
 
@@ -1843,10 +1497,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"caniuse-lite@npm:^1.0.30001640":
-  version: 1.0.30001641
-  resolution: "caniuse-lite@npm:1.0.30001641"
-  checksum: f131829f7746374ae4a19a8fb5aef9bbc5649682afb0ffd6a74f567389cb6efadbab600cc83384a3e694e1646772ff14ac3c791593aedb41fb2ce1942a1aa208
+"caniuse-lite@npm:^1.0.30001688":
+  version: 1.0.30001690
+  resolution: "caniuse-lite@npm:1.0.30001690"
+  checksum: f2c1b595f15d8de4d9ccd155d61ac9f00ac62f1515870505a0186266fd52aef169fcddc90d8a4814e52b77107244806466fadc2c216662f23f1022a430e735ee
   languageName: node
   linkType: hard
 
@@ -1864,17 +1518,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"chalk@npm:^2.4.1, chalk@npm:^2.4.2":
-  version: 2.4.2
-  resolution: "chalk@npm:2.4.2"
-  dependencies:
-    ansi-styles: ^3.2.1
-    escape-string-regexp: ^1.0.5
-    supports-color: ^5.3.0
-  checksum: ec3661d38fe77f681200f878edbd9448821924e0f93a9cefc0e26a33b145f1027a2084bf19967160d11e1f03bfe4eaffcabf5493b89098b2782c3fe0b03d80c2
-  languageName: node
-  linkType: hard
-
 "chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.2":
   version: 4.1.2
   resolution: "chalk@npm:4.1.2"
@@ -1892,10 +1535,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"chownr@npm:^2.0.0":
-  version: 2.0.0
-  resolution: "chownr@npm:2.0.0"
-  checksum: c57cf9dd0791e2f18a5ee9c1a299ae6e801ff58fee96dc8bfd0dcb4738a6ce58dd252a3605b1c93c6418fe4f9d5093b28ffbf4d66648cb2a9c67eaef9679be2f
+"chownr@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "chownr@npm:3.0.0"
+  checksum: fd73a4bab48b79e66903fe1cafbdc208956f41ea4f856df883d0c7277b7ab29fd33ee65f93b2ec9192fc0169238f2f8307b7735d27c155821d886b84aa97aa8d
   languageName: node
   linkType: hard
 
@@ -1922,23 +1565,16 @@ __metadata:
   languageName: node
   linkType: hard
 
-"cli-spinners@npm:^2.5.0":
-  version: 2.9.2
-  resolution: "cli-spinners@npm:2.9.2"
-  checksum: 1bd588289b28432e4676cb5d40505cfe3e53f2e4e10fbe05c8a710a154d6fe0ce7836844b00d6858f740f2ffe67cdc36e0fce9c7b6a8430e80e6388d5aa4956c
-  languageName: node
-  linkType: hard
-
 "cli-table3@npm:~0.6.1":
-  version: 0.6.4
-  resolution: "cli-table3@npm:0.6.4"
+  version: 0.6.5
+  resolution: "cli-table3@npm:0.6.5"
   dependencies:
     "@colors/colors": 1.5.0
     string-width: ^4.2.0
   dependenciesMeta:
     "@colors/colors":
       optional: true
-  checksum: 0942d9977c05b31e9c7e0172276246b3ac2124c2929451851c01dbf5fc9b3d40cc4e1c9d468ff26dd3cfd18617963fe227b4cfeeae2881b70f302d69d792b5bb
+  checksum: ab7afbf4f8597f1c631f3ee6bb3481d0bfeac8a3b81cffb5a578f145df5c88003b6cfff46046a7acae86596fdd03db382bfa67f20973b6b57425505abc47e42c
   languageName: node
   linkType: hard
 
@@ -1963,13 +1599,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"clone@npm:^1.0.2":
-  version: 1.0.4
-  resolution: "clone@npm:1.0.4"
-  checksum: d06418b7335897209e77bdd430d04f882189582e67bd1f75a04565f3f07f5b3f119a9d670c943b6697d0afb100f03b866b3b8a1f91d4d02d72c4ecf2bb64b5dd
-  languageName: node
-  linkType: hard
-
 "cmd-ts@npm:0.13.0":
   version: 0.13.0
   resolution: "cmd-ts@npm:0.13.0"
@@ -1982,15 +1611,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"color-convert@npm:^1.9.0":
-  version: 1.9.3
-  resolution: "color-convert@npm:1.9.3"
-  dependencies:
-    color-name: 1.1.3
-  checksum: fd7a64a17cde98fb923b1dd05c5f2e6f7aefda1b60d67e8d449f9328b4e53b228a428fd38bfeaeb2db2ff6b6503a776a996150b80cdf224062af08a5c8a3a203
-  languageName: node
-  linkType: hard
-
 "color-convert@npm:^2.0.1":
   version: 2.0.1
   resolution: "color-convert@npm:2.0.1"
@@ -2000,13 +1620,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"color-name@npm:1.1.3":
-  version: 1.1.3
-  resolution: "color-name@npm:1.1.3"
-  checksum: 09c5d3e33d2105850153b14466501f2bfb30324a2f76568a408763a3b7433b0e50e5b4ab1947868e65cb101bb7cb75029553f2c333b6d4b8138a73fcc133d69d
-  languageName: node
-  linkType: hard
-
 "color-name@npm:~1.1.4":
   version: 1.1.4
   resolution: "color-name@npm:1.1.4"
@@ -2014,13 +1627,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"colord@npm:^2.9.3":
-  version: 2.9.3
-  resolution: "colord@npm:2.9.3"
-  checksum: 95d909bfbcfd8d5605cbb5af56f2d1ce2b323990258fd7c0d2eb0e6d3bb177254d7fb8213758db56bb4ede708964f78c6b992b326615f81a18a6aaf11d64c650
-  languageName: node
-  linkType: hard
-
 "colorette@npm:^2.0.16":
   version: 2.0.20
   resolution: "colorette@npm:2.0.20"
@@ -2028,7 +1634,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"combined-stream@npm:^1.0.6, combined-stream@npm:^1.0.8, combined-stream@npm:~1.0.6":
+"combined-stream@npm:^1.0.8, combined-stream@npm:~1.0.6":
   version: 1.0.8
   resolution: "combined-stream@npm:1.0.8"
   dependencies:
@@ -2051,13 +1657,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"concat-map@npm:0.0.1":
-  version: 0.0.1
-  resolution: "concat-map@npm:0.0.1"
-  checksum: 902a9f5d8967a3e2faf138d5cb784b9979bad2e6db5357c5b21c568df4ebe62bcb15108af1b2253744844eb964fc023fbd9afbbbb6ddd0bcc204c6fb5b7bf3af
-  languageName: node
-  linkType: hard
-
 "convert-source-map@npm:^2.0.0":
   version: 2.0.0
   resolution: "convert-source-map@npm:2.0.0"
@@ -2079,31 +1678,14 @@ __metadata:
   languageName: node
   linkType: hard
 
-"cosmiconfig@npm:^9.0.0":
-  version: 9.0.0
-  resolution: "cosmiconfig@npm:9.0.0"
-  dependencies:
-    env-paths: ^2.2.1
-    import-fresh: ^3.3.0
-    js-yaml: ^4.1.0
-    parse-json: ^5.2.0
-  peerDependencies:
-    typescript: ">=4.9.5"
-  peerDependenciesMeta:
-    typescript:
-      optional: true
-  checksum: a30c424b53d442ea0bdd24cb1b3d0d8687c8dda4a17ab6afcdc439f8964438801619cdb66e8e79f63b9caa3e6586b60d8bab9ce203e72df6c5e80179b971fe8f
-  languageName: node
-  linkType: hard
-
-"cross-spawn@npm:^7.0.0":
-  version: 7.0.3
-  resolution: "cross-spawn@npm:7.0.3"
+"cross-spawn@npm:^7.0.0":
+  version: 7.0.6
+  resolution: "cross-spawn@npm:7.0.6"
   dependencies:
     path-key: ^3.1.0
     shebang-command: ^2.0.0
     which: ^2.0.1
-  checksum: 671cc7c7288c3a8406f3c69a3ae2fc85555c04169e9d611def9a675635472614f1c0ed0ef80955d5b6d4e724f6ced67f0ad1bb006c2ea643488fcfef994d7f52
+  checksum: 8d306efacaf6f3f60e0224c287664093fa9185680b2d195852ba9a863f85d02dcc737094c6e512175f8ee0161f9b87c73c6826034c2422e39de7d6569cf4503b
   languageName: node
   linkType: hard
 
@@ -2114,13 +1696,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"css-functions-list@npm:^3.2.2":
-  version: 3.2.2
-  resolution: "css-functions-list@npm:3.2.2"
-  checksum: b8a564118b93b87b63236a57132a3ef581416896a70c1d0df73360a9ec43dc582f7c2a586b578feb8476179518e557c6657570a8b6185b16300c7232a84d43e3
-  languageName: node
-  linkType: hard
-
 "css-to-react-native@npm:^3.0.0":
   version: 3.2.0
   resolution: "css-to-react-native@npm:3.2.0"
@@ -2132,34 +1707,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"css-tree@npm:^2.3.1":
-  version: 2.3.1
-  resolution: "css-tree@npm:2.3.1"
-  dependencies:
-    mdn-data: 2.0.30
-    source-map-js: ^1.0.1
-  checksum: 493cc24b5c22b05ee5314b8a0d72d8a5869491c1458017ae5ed75aeb6c3596637dbe1b11dac2548974624adec9f7a1f3a6cf40593dc1f9185eb0e8279543fbc0
-  languageName: node
-  linkType: hard
-
-"cssesc@npm:^3.0.0":
-  version: 3.0.0
-  resolution: "cssesc@npm:3.0.0"
-  bin:
-    cssesc: bin/cssesc
-  checksum: f8c4ababffbc5e2ddf2fa9957dda1ee4af6048e22aeda1869d0d00843223c1b13ad3f5d88b51caa46c994225eacb636b764eb807a8883e2fb6f99b4f4e8c48b2
-  languageName: node
-  linkType: hard
-
-"cssstyle@npm:^4.0.1":
-  version: 4.0.1
-  resolution: "cssstyle@npm:4.0.1"
-  dependencies:
-    rrweb-cssom: ^0.6.0
-  checksum: 4b2fdd81c565b1f8f24a792f85d3a19269a2f201e731c3fe3531d7fc78b4bc6b31906ed17aba7edba7b1c8b7672574fc6c09fe925556da3a9a9458dbf8c4fa22
-  languageName: node
-  linkType: hard
-
 "csstype@npm:^3.0.2":
   version: 3.1.3
   resolution: "csstype@npm:3.1.3"
@@ -2228,44 +1775,22 @@ __metadata:
   languageName: node
   linkType: hard
 
-"data-urls@npm:^5.0.0":
-  version: 5.0.0
-  resolution: "data-urls@npm:5.0.0"
-  dependencies:
-    whatwg-mimetype: ^4.0.0
-    whatwg-url: ^14.0.0
-  checksum: 5c40568c31b02641a70204ff233bc4e42d33717485d074244a98661e5f2a1e80e38fe05a5755dfaf2ee549f2ab509d6a3af2a85f4b2ad2c984e5d176695eaf46
-  languageName: node
-  linkType: hard
-
 "dayjs@npm:^1.10.4":
-  version: 1.11.10
-  resolution: "dayjs@npm:1.11.10"
-  checksum: a6b5a3813b8884f5cd557e2e6b7fa569f4c5d0c97aca9558e38534af4f2d60daafd3ff8c2000fed3435cfcec9e805bcebd99f90130c6d1c5ef524084ced588c4
+  version: 1.11.13
+  resolution: "dayjs@npm:1.11.13"
+  checksum: f388db88a6aa93956c1f6121644e783391c7b738b73dbc54485578736565c8931bdfba4bb94e9b1535c6e509c97d5deb918bbe1ae6b34358d994de735055cca9
   languageName: node
   linkType: hard
 
 "debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.4":
-  version: 4.3.4
-  resolution: "debug@npm:4.3.4"
-  dependencies:
-    ms: 2.1.2
-  peerDependenciesMeta:
-    supports-color:
-      optional: true
-  checksum: 3dbad3f94ea64f34431a9cbf0bafb61853eda57bff2880036153438f50fb5a84f27683ba0d8e5426bf41a8c6ff03879488120cf5b3a761e77953169c0600a708
-  languageName: node
-  linkType: hard
-
-"debug@npm:4.3.6, debug@npm:^4.3.6":
-  version: 4.3.6
-  resolution: "debug@npm:4.3.6"
+  version: 4.4.0
+  resolution: "debug@npm:4.4.0"
   dependencies:
-    ms: 2.1.2
+    ms: ^2.1.3
   peerDependenciesMeta:
     supports-color:
       optional: true
-  checksum: 1630b748dea3c581295e02137a9f5cbe2c1d85fea35c1e6597a65ca2b16a6fce68cec61b299d480787ef310ba927dc8c92d3061faba0ad06c6a724672f66be7f
+  checksum: fb42df878dd0e22816fc56e1fdca9da73caa85212fbe40c868b1295a6878f9101ae684f4eeef516c13acfc700f5ea07f1136954f43d4cd2d477a811144136479
   languageName: node
   linkType: hard
 
@@ -2278,40 +1803,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"debuglog@npm:^1.0.1":
-  version: 1.0.1
-  resolution: "debuglog@npm:1.0.1"
-  checksum: 970679f2eb7a73867e04d45b52583e7ec6dee1f33c058e9147702e72a665a9647f9c3d6e7c2f66f6bf18510b23eb5ded1b617e48ac1db23603809c5ddbbb9763
-  languageName: node
-  linkType: hard
-
-"decimal.js@npm:^10.4.3":
-  version: 10.4.3
-  resolution: "decimal.js@npm:10.4.3"
-  checksum: 796404dcfa9d1dbfdc48870229d57f788b48c21c603c3f6554a1c17c10195fc1024de338b0cf9e1efe0c7c167eeb18f04548979bcc5fdfabebb7cc0ae3287bae
-  languageName: node
-  linkType: hard
-
-"defaults@npm:^1.0.3":
-  version: 1.0.4
-  resolution: "defaults@npm:1.0.4"
-  dependencies:
-    clone: ^1.0.2
-  checksum: 3a88b7a587fc076b84e60affad8b85245c01f60f38fc1d259e7ac1d89eb9ce6abb19e27215de46b98568dd5bc48471730b327637e6f20b0f1bc85cf00440c80a
-  languageName: node
-  linkType: hard
-
-"define-data-property@npm:^1.1.4":
-  version: 1.1.4
-  resolution: "define-data-property@npm:1.1.4"
-  dependencies:
-    es-define-property: ^1.0.0
-    es-errors: ^1.3.0
-    gopd: ^1.0.1
-  checksum: 8068ee6cab694d409ac25936eb861eea704b7763f7f342adbdfe337fc27c78d7ae0eff2364b2917b58c508d723c7a074326d068eef2e45c4edcd85cf94d0313b
-  languageName: node
-  linkType: hard
-
 "delayed-stream@npm:~1.0.0":
   version: 1.0.0
   resolution: "delayed-stream@npm:1.0.0"
@@ -2326,16 +1817,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"dezalgo@npm:^1.0.0":
-  version: 1.0.4
-  resolution: "dezalgo@npm:1.0.4"
-  dependencies:
-    asap: ^2.0.0
-    wrappy: 1
-  checksum: 895389c6aead740d2ab5da4d3466d20fa30f738010a4d3f4dcccc9fc645ca31c9d10b7e1804ae489b1eb02c7986f9f1f34ba132d409b043082a86d9a4e745624
-  languageName: node
-  linkType: hard
-
 "didyoumean@npm:^1.2.2":
   version: 1.2.2
   resolution: "didyoumean@npm:1.2.2"
@@ -2350,29 +1831,14 @@ __metadata:
   languageName: node
   linkType: hard
 
-"dir-glob@npm:^3.0.1":
-  version: 3.0.1
-  resolution: "dir-glob@npm:3.0.1"
-  dependencies:
-    path-type: ^4.0.0
-  checksum: fa05e18324510d7283f55862f3161c6759a3f2f8dbce491a2fc14c8324c498286c54282c1f0e933cb930da8419b30679389499b919122952a4f8592362ef4615
-  languageName: node
-  linkType: hard
-
-"dpdm@npm:3.14.0":
-  version: 3.14.0
-  resolution: "dpdm@npm:3.14.0"
+"dunder-proto@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "dunder-proto@npm:1.0.1"
   dependencies:
-    chalk: ^4.1.2
-    fs-extra: ^11.1.1
-    glob: ^10.3.4
-    ora: ^5.4.1
-    tslib: ^2.6.2
-    typescript: ^5.2.2
-    yargs: ^17.7.2
-  bin:
-    dpdm: lib/bin/dpdm.js
-  checksum: 86870216326db1b5d714d8297271e7cbafb0bb4e4e60b7c8eeffd22cab8cf8a1464b66db5ce62a4ba9ddb4efdba25b7903b8932b5aeb97df0e89374651c77c11
+    call-bind-apply-helpers: ^1.0.1
+    es-errors: ^1.3.0
+    gopd: ^1.2.0
+  checksum: 149207e36f07bd4941921b0ca929e3a28f1da7bd6b6ff8ff7f4e2f2e460675af4576eeba359c635723dc189b64cdd4787e0255897d5b135ccc5d15cb8685fc90
   languageName: node
   linkType: hard
 
@@ -2393,10 +1859,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"electron-to-chromium@npm:^1.4.820":
-  version: 1.4.827
-  resolution: "electron-to-chromium@npm:1.4.827"
-  checksum: ce0b6b28d6555b4a1f0341331def5011d0f5c56542f95d114d5cedce218fb4a4415254494322ca40663ce9e9e5590623b0c0c09170838675d602367251bde677
+"electron-to-chromium@npm:^1.5.73":
+  version: 1.5.79
+  resolution: "electron-to-chromium@npm:1.5.79"
+  checksum: b51178250d8a3f5e8af74b6268c607d8572d62fe0771ef94054c27b504cdb0ef1e33b757c8cc0d771436176bb102c7bc02586a4b01daa5fe629edc655367e5e4
   languageName: node
   linkType: hard
 
@@ -2442,14 +1908,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"entities@npm:^4.4.0":
-  version: 4.5.0
-  resolution: "entities@npm:4.5.0"
-  checksum: 853f8ebd5b425d350bffa97dd6958143179a5938352ccae092c62d1267c4e392a039be1bae7d51b6e4ffad25f51f9617531fedf5237f15df302ccfb452cbf2d7
-  languageName: node
-  linkType: hard
-
-"env-paths@npm:^2.2.0, env-paths@npm:^2.2.1":
+"env-paths@npm:^2.2.0":
   version: 2.2.1
   resolution: "env-paths@npm:2.2.1"
   checksum: 65b5df55a8bab92229ab2b40dad3b387fad24613263d103a97f91c9fe43ceb21965cd3392b1ccb5d77088021e525c4e0481adb309625d0cb94ade1d1fb8dc17e
@@ -2463,21 +1922,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"error-ex@npm:^1.3.1":
-  version: 1.3.2
-  resolution: "error-ex@npm:1.3.2"
-  dependencies:
-    is-arrayish: ^0.2.1
-  checksum: c1c2b8b65f9c91b0f9d75f0debaa7ec5b35c266c2cac5de412c1a6de86d4cbae04ae44e510378cb14d032d0645a36925d0186f8bb7367bcc629db256b743a001
-  languageName: node
-  linkType: hard
-
-"es-define-property@npm:^1.0.0":
-  version: 1.0.0
-  resolution: "es-define-property@npm:1.0.0"
-  dependencies:
-    get-intrinsic: ^1.2.4
-  checksum: f66ece0a887b6dca71848fa71f70461357c0e4e7249696f81bad0a1f347eed7b31262af4a29f5d726dc026426f085483b6b90301855e647aa8e21936f07293c6
+"es-define-property@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "es-define-property@npm:1.0.1"
+  checksum: 0512f4e5d564021c9e3a644437b0155af2679d10d80f21adaf868e64d30efdfbd321631956f20f42d655fedb2e3a027da479fad3fa6048f768eb453a80a5f80a
   languageName: node
   linkType: hard
 
@@ -2488,6 +1936,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"es-object-atoms@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "es-object-atoms@npm:1.0.0"
+  dependencies:
+    es-errors: ^1.3.0
+  checksum: 26f0ff78ab93b63394e8403c353842b2272836968de4eafe97656adfb8a7c84b9099bf0fe96ed58f4a4cddc860f6e34c77f91649a58a5daa4a9c40b902744e3c
+  languageName: node
+  linkType: hard
+
 "esbuild@npm:0.23.0":
   version: 0.23.0
   resolution: "esbuild@npm:0.23.0"
@@ -2571,7 +2028,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"esbuild@npm:^0.21.3, esbuild@npm:~0.21.5":
+"esbuild@npm:^0.21.3":
   version: 0.21.5
   resolution: "esbuild@npm:0.21.5"
   dependencies:
@@ -2651,10 +2108,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"escalade@npm:^3.1.1, escalade@npm:^3.1.2":
-  version: 3.1.2
-  resolution: "escalade@npm:3.1.2"
-  checksum: 1ec0977aa2772075493002bdbd549d595ff6e9393b1cb0d7d6fcaf78c750da0c158f180938365486f75cb69fba20294351caddfce1b46552a7b6c3cde52eaa02
+"escalade@npm:^3.1.1, escalade@npm:^3.2.0":
+  version: 3.2.0
+  resolution: "escalade@npm:3.2.0"
+  checksum: 47b029c83de01b0d17ad99ed766347b974b0d628e848de404018f3abee728e987da0d2d370ad4574aa3d5b5bfc368754fd085d69a30f8e75903486ec4b5b709e
   languageName: node
   linkType: hard
 
@@ -2679,13 +2136,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"events@npm:3.3.0":
-  version: 3.3.0
-  resolution: "events@npm:3.3.0"
-  checksum: f6f487ad2198aa41d878fa31452f1a3c00958f46e9019286ff4787c84aac329332ab45c9cdc8c445928fc6d7ded294b9e005a7fce9426488518017831b272780
-  languageName: node
-  linkType: hard
-
 "example-overlay-react-12c560@workspace:example-overlay-react":
   version: 0.0.0-use.local
   resolution: "example-overlay-react-12c560@workspace:example-overlay-react"
@@ -2814,42 +2264,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"fast-deep-equal@npm:^3.1.1":
-  version: 3.1.3
-  resolution: "fast-deep-equal@npm:3.1.3"
-  checksum: e21a9d8d84f53493b6aa15efc9cfd53dd5b714a1f23f67fb5dc8f574af80df889b3bce25dc081887c6d25457cce704e636395333abad896ccdec03abaf1f3f9d
-  languageName: node
-  linkType: hard
-
-"fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.2":
-  version: 3.3.2
-  resolution: "fast-glob@npm:3.3.2"
-  dependencies:
-    "@nodelib/fs.stat": ^2.0.2
-    "@nodelib/fs.walk": ^1.2.3
-    glob-parent: ^5.1.2
-    merge2: ^1.3.0
-    micromatch: ^4.0.4
-  checksum: 900e4979f4dbc3313840078419245621259f349950411ca2fa445a2f9a1a6d98c3b5e7e0660c5ccd563aa61abe133a21765c6c0dec8e57da1ba71d8000b05ec1
-  languageName: node
-  linkType: hard
-
-"fastest-levenshtein@npm:^1.0.16":
-  version: 1.0.16
-  resolution: "fastest-levenshtein@npm:1.0.16"
-  checksum: a78d44285c9e2ae2c25f3ef0f8a73f332c1247b7ea7fb4a191e6bb51aa6ee1ef0dfb3ed113616dcdc7023e18e35a8db41f61c8d88988e877cf510df8edafbc71
-  languageName: node
-  linkType: hard
-
-"fastq@npm:^1.6.0":
-  version: 1.17.1
-  resolution: "fastq@npm:1.17.1"
-  dependencies:
-    reusify: ^1.0.4
-  checksum: a8c5b26788d5a1763f88bae56a8ddeee579f935a831c5fe7a8268cea5b0a91fbfe705f612209e02d639b881d7b48e461a50da4a10cfaa40da5ca7cc9da098d88
-  languageName: node
-  linkType: hard
-
 "fd-slicer@npm:~1.1.0":
   version: 1.1.0
   resolution: "fd-slicer@npm:1.1.0"
@@ -2868,33 +2282,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"file-entry-cache@npm:^9.0.0":
-  version: 9.0.0
-  resolution: "file-entry-cache@npm:9.0.0"
-  dependencies:
-    flat-cache: ^5.0.0
-  checksum: 850ab258497cfad2aff166319068101202d6969415227a425b9de9acdb152dc33506c7208906289a6dcb2a7f9390601f4b80ccf6353510c58710b75d5a02df6e
-  languageName: node
-  linkType: hard
-
-"fill-range@npm:^7.0.1":
-  version: 7.0.1
-  resolution: "fill-range@npm:7.0.1"
-  dependencies:
-    to-regex-range: ^5.0.1
-  checksum: cc283f4e65b504259e64fd969bcf4def4eb08d85565e906b7d36516e87819db52029a76b6363d0f02d0d532f0033c9603b9e2d943d56ee3b0d4f7ad3328ff917
-  languageName: node
-  linkType: hard
-
-"fill-range@npm:^7.1.1":
-  version: 7.1.1
-  resolution: "fill-range@npm:7.1.1"
-  dependencies:
-    to-regex-range: ^5.0.1
-  checksum: b4abfbca3839a3d55e4ae5ec62e131e2e356bf4859ce8480c64c4876100f4df292a63e5bb1618e1d7460282ca2b305653064f01654474aa35c68000980f17798
-  languageName: node
-  linkType: hard
-
 "find-up@npm:^5.0.0":
   version: 5.0.0
   resolution: "find-up@npm:5.0.0"
@@ -2905,40 +2292,23 @@ __metadata:
   languageName: node
   linkType: hard
 
-"flat-cache@npm:^5.0.0":
-  version: 5.0.0
-  resolution: "flat-cache@npm:5.0.0"
-  dependencies:
-    flatted: ^3.3.1
-    keyv: ^4.5.4
-  checksum: a7d03de79b603f5621009f75d84d2c5cd8fb762911df93c0ed16cd1cd4f7b8d2357d4aaed8806b5943ce71ebcd4fc4998faf061f33879c56c5294b3f5c3698ef
-  languageName: node
-  linkType: hard
-
-"flatted@npm:^3.3.1":
-  version: 3.3.1
-  resolution: "flatted@npm:3.3.1"
-  checksum: 85ae7181650bb728c221e7644cbc9f4bf28bc556f2fc89bb21266962bdf0ce1029cc7acc44bb646cd469d9baac7c317f64e841c4c4c00516afa97320cdac7f94
-  languageName: node
-  linkType: hard
-
 "follow-redirects@npm:^1.0.0":
-  version: 1.15.6
-  resolution: "follow-redirects@npm:1.15.6"
+  version: 1.15.9
+  resolution: "follow-redirects@npm:1.15.9"
   peerDependenciesMeta:
     debug:
       optional: true
-  checksum: a62c378dfc8c00f60b9c80cab158ba54e99ba0239a5dd7c81245e5a5b39d10f0c35e249c3379eae719ff0285fff88c365dd446fab19dee771f1d76252df1bbf5
+  checksum: 859e2bacc7a54506f2bf9aacb10d165df78c8c1b0ceb8023f966621b233717dab56e8d08baadc3ad3b9db58af290413d585c999694b7c146aaf2616340c3d2a6
   languageName: node
   linkType: hard
 
 "foreground-child@npm:^3.1.0, foreground-child@npm:^3.1.1":
-  version: 3.1.1
-  resolution: "foreground-child@npm:3.1.1"
+  version: 3.3.0
+  resolution: "foreground-child@npm:3.3.0"
   dependencies:
     cross-spawn: ^7.0.0
     signal-exit: ^4.0.1
-  checksum: 139d270bc82dc9e6f8bc045fe2aae4001dc2472157044fdfad376d0a3457f77857fa883c1c8b21b491c6caade9a926a4bed3d3d2e8d3c9202b151a4cbbd0bcd5
+  checksum: 1989698488f725b05b26bc9afc8a08f08ec41807cd7b92ad85d96004ddf8243fd3e79486b8348c64a3011ae5cc2c9f0936af989e1f28339805d8bc178a75b451
   languageName: node
   linkType: hard
 
@@ -2949,36 +2319,14 @@ __metadata:
   languageName: node
   linkType: hard
 
-"form-data@npm:^4.0.0":
-  version: 4.0.0
-  resolution: "form-data@npm:4.0.0"
+"form-data@npm:~4.0.0":
+  version: 4.0.1
+  resolution: "form-data@npm:4.0.1"
   dependencies:
     asynckit: ^0.4.0
     combined-stream: ^1.0.8
     mime-types: ^2.1.12
-  checksum: 01135bf8675f9d5c61ff18e2e2932f719ca4de964e3be90ef4c36aacfc7b9cb2fceb5eca0b7e0190e3383fe51c5b37f4cb80b62ca06a99aaabfcfd6ac7c9328c
-  languageName: node
-  linkType: hard
-
-"form-data@npm:~2.3.2":
-  version: 2.3.3
-  resolution: "form-data@npm:2.3.3"
-  dependencies:
-    asynckit: ^0.4.0
-    combined-stream: ^1.0.6
-    mime-types: ^2.1.12
-  checksum: 10c1780fa13dbe1ff3100114c2ce1f9307f8be10b14bf16e103815356ff567b6be39d70fc4a40f8990b9660012dc24b0f5e1dde1b6426166eb23a445ba068ca3
-  languageName: node
-  linkType: hard
-
-"fs-extra@npm:^11.1.1":
-  version: 11.2.0
-  resolution: "fs-extra@npm:11.2.0"
-  dependencies:
-    graceful-fs: ^4.2.0
-    jsonfile: ^6.0.1
-    universalify: ^2.0.0
-  checksum: b12e42fa40ba47104202f57b8480dd098aa931c2724565e5e70779ab87605665594e76ee5fb00545f772ab9ace167fe06d2ab009c416dc8c842c5ae6df7aa7e8
+  checksum: ccee458cd5baf234d6b57f349fe9cc5f9a2ea8fd1af5ecda501a18fd1572a6dd3bf08a49f00568afd995b6a65af34cb8dec083cf9d582c4e621836499498dd84
   languageName: node
   linkType: hard
 
@@ -2994,15 +2342,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"fs-minipass@npm:^2.0.0":
-  version: 2.1.0
-  resolution: "fs-minipass@npm:2.1.0"
-  dependencies:
-    minipass: ^3.0.0
-  checksum: 1b8d128dae2ac6cc94230cc5ead341ba3e0efaef82dab46a33d171c044caaa6ca001364178d42069b2809c35a1c3c35079a32107c770e9ffab3901b59af8c8b1
-  languageName: node
-  linkType: hard
-
 "fs-minipass@npm:^3.0.0":
   version: 3.0.3
   resolution: "fs-minipass@npm:3.0.3"
@@ -3012,13 +2351,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"fs.realpath@npm:^1.0.0":
-  version: 1.0.0
-  resolution: "fs.realpath@npm:1.0.0"
-  checksum: 99ddea01a7e75aa276c250a04eedeffe5662bce66c65c07164ad6264f9de18fb21be9433ead460e54cff20e31721c811f4fb5d70591799df5f85dce6d6746fd0
-  languageName: node
-  linkType: hard
-
 "fsevents@npm:~2.3.2, fsevents@npm:~2.3.3":
   version: 2.3.3
   resolution: "fsevents@npm:2.3.3"
@@ -3059,16 +2391,31 @@ __metadata:
   languageName: node
   linkType: hard
 
-"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.4":
-  version: 1.2.4
-  resolution: "get-intrinsic@npm:1.2.4"
+"get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6":
+  version: 1.2.7
+  resolution: "get-intrinsic@npm:1.2.7"
   dependencies:
+    call-bind-apply-helpers: ^1.0.1
+    es-define-property: ^1.0.1
     es-errors: ^1.3.0
+    es-object-atoms: ^1.0.0
     function-bind: ^1.1.2
-    has-proto: ^1.0.1
-    has-symbols: ^1.0.3
-    hasown: ^2.0.0
-  checksum: 414e3cdf2c203d1b9d7d33111df746a4512a1aa622770b361dadddf8ed0b5aeb26c560f49ca077e24bfafb0acb55ca908d1f709216ccba33ffc548ec8a79a951
+    get-proto: ^1.0.0
+    gopd: ^1.2.0
+    has-symbols: ^1.1.0
+    hasown: ^2.0.2
+    math-intrinsics: ^1.1.0
+  checksum: a1597b3b432074f805b6a0ba1182130dd6517c0ea0c4eecc4b8834c803913e1ea62dfc412865be795b3dacb1555a21775b70cf9af7a18b1454ff3414e5442d4a
+  languageName: node
+  linkType: hard
+
+"get-proto@npm:^1.0.0":
+  version: 1.0.1
+  resolution: "get-proto@npm:1.0.1"
+  dependencies:
+    dunder-proto: ^1.0.1
+    es-object-atoms: ^1.0.0
+  checksum: 4fc96afdb58ced9a67558698b91433e6b037aaa6f1493af77498d7c85b141382cf223c0e5946f334fb328ee85dfe6edd06d218eaf09556f4bc4ec6005d7f5f7b
   languageName: node
   linkType: hard
 
@@ -3081,15 +2428,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"get-tsconfig@npm:^4.7.5":
-  version: 4.7.5
-  resolution: "get-tsconfig@npm:4.7.5"
-  dependencies:
-    resolve-pkg-maps: ^1.0.0
-  checksum: e5b271fae2b4cd1869bbfc58db56983026cc4a08fdba988725a6edd55d04101507de154722503a22ee35920898ff9bdcba71f99d93b17df35dddb8e8a2ad91be
-  languageName: node
-  linkType: hard
-
 "getos@npm:^3.2.1":
   version: 3.2.1
   resolution: "getos@npm:3.2.1"
@@ -3108,31 +2446,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"glob-parent@npm:^5.1.2":
-  version: 5.1.2
-  resolution: "glob-parent@npm:5.1.2"
-  dependencies:
-    is-glob: ^4.0.1
-  checksum: f4f2bfe2425296e8a47e36864e4f42be38a996db40420fe434565e4480e3322f18eb37589617a98640c5dc8fdec1a387007ee18dbb1f3f5553409c34d17f425e
-  languageName: node
-  linkType: hard
-
-"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.4":
-  version: 10.3.12
-  resolution: "glob@npm:10.3.12"
-  dependencies:
-    foreground-child: ^3.1.0
-    jackspeak: ^2.3.6
-    minimatch: ^9.0.1
-    minipass: ^7.0.4
-    path-scurry: ^1.10.2
-  bin:
-    glob: dist/esm/bin.mjs
-  checksum: 2b0949d6363021aaa561b108ac317bf5a97271b8a5d7a5fac1a176e40e8068ecdcccc992f8a7e958593d501103ac06d673de92adc1efcbdab45edefe35f8d7c6
-  languageName: node
-  linkType: hard
-
-"glob@npm:^10.4.1":
+"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7, glob@npm:^10.4.1":
   version: 10.4.5
   resolution: "glob@npm:10.4.5"
   dependencies:
@@ -3148,20 +2462,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"glob@npm:^7.1.1":
-  version: 7.2.3
-  resolution: "glob@npm:7.2.3"
-  dependencies:
-    fs.realpath: ^1.0.0
-    inflight: ^1.0.4
-    inherits: 2
-    minimatch: ^3.1.1
-    once: ^1.3.0
-    path-is-absolute: ^1.0.0
-  checksum: 29452e97b38fa704dabb1d1045350fb2467cf0277e155aa9ff7077e90ad81d1ea9d53d3ee63bd37c05b09a065e90f16aec4a65f5b8de401d1dac40bc5605d133
-  languageName: node
-  linkType: hard
-
 "global-dirs@npm:^3.0.0":
   version: 3.0.1
   resolution: "global-dirs@npm:3.0.1"
@@ -3171,35 +2471,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"global-jsdom@npm:9.2.0":
-  version: 9.2.0
-  resolution: "global-jsdom@npm:9.2.0"
-  peerDependencies:
-    jsdom: ">=23 <24"
-  checksum: de1b2676f8f511567dc029d0cf89d39be8798d1ecb0dc6405db86a22dcc300a144c91529c33f87f4c59ded76d1786c72fdca79351920580d638373f1e5b7b94f
-  languageName: node
-  linkType: hard
-
-"global-modules@npm:^2.0.0":
-  version: 2.0.0
-  resolution: "global-modules@npm:2.0.0"
-  dependencies:
-    global-prefix: ^3.0.0
-  checksum: d6197f25856c878c2fb5f038899f2dca7cbb2f7b7cf8999660c0104972d5cfa5c68b5a0a77fa8206bb536c3903a4615665acb9709b4d80846e1bb47eaef65430
-  languageName: node
-  linkType: hard
-
-"global-prefix@npm:^3.0.0":
-  version: 3.0.0
-  resolution: "global-prefix@npm:3.0.0"
-  dependencies:
-    ini: ^1.3.5
-    kind-of: ^6.0.2
-    which: ^1.3.1
-  checksum: 8a82fc1d6f22c45484a4e34656cc91bf021a03e03213b0035098d605bfc612d7141f1e14a21097e8a0413b4884afd5b260df0b6a25605ce9d722e11f1df2881d
-  languageName: node
-  linkType: hard
-
 "globals@npm:^11.1.0":
   version: 11.12.0
   resolution: "globals@npm:11.12.0"
@@ -3207,37 +2478,14 @@ __metadata:
   languageName: node
   linkType: hard
 
-"globby@npm:^11.1.0":
-  version: 11.1.0
-  resolution: "globby@npm:11.1.0"
-  dependencies:
-    array-union: ^2.1.0
-    dir-glob: ^3.0.1
-    fast-glob: ^3.2.9
-    ignore: ^5.2.0
-    merge2: ^1.4.1
-    slash: ^3.0.0
-  checksum: b4be8885e0cfa018fc783792942d53926c35c50b3aefd3fdcfb9d22c627639dc26bd2327a40a0b74b074100ce95bb7187bfeae2f236856aa3de183af7a02aea6
-  languageName: node
-  linkType: hard
-
-"globjoin@npm:^0.1.4":
-  version: 0.1.4
-  resolution: "globjoin@npm:0.1.4"
-  checksum: 0a47d88d566122d9e42da946453ee38b398e0021515ac6a95d13f980ba8c1e42954e05ee26cfcbffce1ac1ee094d0524b16ce1dd874ca52408d6db5c6d39985b
-  languageName: node
-  linkType: hard
-
-"gopd@npm:^1.0.1":
-  version: 1.0.1
-  resolution: "gopd@npm:1.0.1"
-  dependencies:
-    get-intrinsic: ^1.1.3
-  checksum: a5ccfb8806e0917a94e0b3de2af2ea4979c1da920bc381667c260e00e7cafdbe844e2cb9c5bcfef4e5412e8bf73bab837285bc35c7ba73aaaf0134d4583393a6
+"gopd@npm:^1.2.0":
+  version: 1.2.0
+  resolution: "gopd@npm:1.2.0"
+  checksum: cc6d8e655e360955bdccaca51a12a474268f95bb793fc3e1f2bdadb075f28bfd1fd988dab872daf77a61d78cbaf13744bc8727a17cfb1d150d76047d805375f3
   languageName: node
   linkType: hard
 
-"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.6":
+"graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.6":
   version: 4.2.11
   resolution: "graceful-fs@npm:4.2.11"
   checksum: ac85f94da92d8eb6b7f5a8b20ce65e43d66761c55ce85ac96df6865308390da45a8d3f0296dd3a663de65d30ba497bd46c696cc1e248c72b13d6d567138a4fc7
@@ -3258,30 +2506,14 @@ __metadata:
   languageName: node
   linkType: hard
 
-"has-property-descriptors@npm:^1.0.2":
-  version: 1.0.2
-  resolution: "has-property-descriptors@npm:1.0.2"
-  dependencies:
-    es-define-property: ^1.0.0
-  checksum: fcbb246ea2838058be39887935231c6d5788babed499d0e9d0cc5737494c48aba4fe17ba1449e0d0fbbb1e36175442faa37f9c427ae357d6ccb1d895fbcd3de3
-  languageName: node
-  linkType: hard
-
-"has-proto@npm:^1.0.1":
-  version: 1.0.3
-  resolution: "has-proto@npm:1.0.3"
-  checksum: fe7c3d50b33f50f3933a04413ed1f69441d21d2d2944f81036276d30635cad9279f6b43bc8f32036c31ebdfcf6e731150f46c1907ad90c669ffe9b066c3ba5c4
-  languageName: node
-  linkType: hard
-
-"has-symbols@npm:^1.0.3":
-  version: 1.0.3
-  resolution: "has-symbols@npm:1.0.3"
-  checksum: a054c40c631c0d5741a8285010a0777ea0c068f99ed43e5d6eb12972da223f8af553a455132fdb0801bdcfa0e0f443c0c03a68d8555aa529b3144b446c3f2410
+"has-symbols@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "has-symbols@npm:1.1.0"
+  checksum: b2316c7302a0e8ba3aaba215f834e96c22c86f192e7310bdf689dd0e6999510c89b00fbc5742571507cebf25764d68c988b3a0da217369a73596191ac0ce694b
   languageName: node
   linkType: hard
 
-"hasown@npm:^2.0.0":
+"hasown@npm:^2.0.2":
   version: 2.0.2
   resolution: "hasown@npm:2.0.2"
   dependencies:
@@ -3308,13 +2540,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"hosted-git-info@npm:^2.1.4":
-  version: 2.8.9
-  resolution: "hosted-git-info@npm:2.8.9"
-  checksum: c955394bdab888a1e9bb10eb33029e0f7ce5a2ac7b3f158099dc8c486c99e73809dca609f5694b223920ca2174db33d32b12f9a2a47141dc59607c29da5a62dd
-  languageName: node
-  linkType: hard
-
 "html-encoding-sniffer@npm:^3.0.0":
   version: 3.0.0
   resolution: "html-encoding-sniffer@npm:3.0.0"
@@ -3324,15 +2549,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"html-encoding-sniffer@npm:^4.0.0":
-  version: 4.0.0
-  resolution: "html-encoding-sniffer@npm:4.0.0"
-  dependencies:
-    whatwg-encoding: ^3.1.1
-  checksum: 3339b71dab2723f3159a56acf541ae90a408ce2d11169f00fe7e0c4663d31d6398c8a4408b504b4eec157444e47b084df09b3cb039c816660f0dd04846b8957d
-  languageName: node
-  linkType: hard
-
 "html-escaper@npm:^2.0.0":
   version: 2.0.2
   resolution: "html-escaper@npm:2.0.2"
@@ -3340,13 +2556,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"html-tags@npm:^3.3.1":
-  version: 3.3.1
-  resolution: "html-tags@npm:3.3.1"
-  checksum: b4ef1d5a76b678e43cce46e3783d563607b1d550cab30b4f511211564574770aa8c658a400b100e588bc60b8234e59b35ff72c7851cc28f3b5403b13a2c6cbce
-  languageName: node
-  linkType: hard
-
 "http-cache-semantics@npm:^4.1.1":
   version: 4.1.1
   resolution: "http-cache-semantics@npm:4.1.1"
@@ -3354,7 +2563,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"http-proxy-agent@npm:^7.0.0, http-proxy-agent@npm:^7.0.2":
+"http-proxy-agent@npm:^7.0.0":
   version: 7.0.2
   resolution: "http-proxy-agent@npm:7.0.2"
   dependencies:
@@ -3398,34 +2607,24 @@ __metadata:
   languageName: node
   linkType: hard
 
-"http-signature@npm:~1.3.6":
-  version: 1.3.6
-  resolution: "http-signature@npm:1.3.6"
+"http-signature@npm:~1.4.0":
+  version: 1.4.0
+  resolution: "http-signature@npm:1.4.0"
   dependencies:
     assert-plus: ^1.0.0
     jsprim: ^2.0.2
-    sshpk: ^1.14.1
-  checksum: 10be2af4764e71fee0281392937050201ee576ac755c543f570d6d87134ce5e858663fe999a7adb3e4e368e1e356d0d7fec6b9542295b875726ff615188e7a0c
+    sshpk: ^1.18.0
+  checksum: f07f4cc0481e4461c68b9b7d1a25bf2ec4cef8e0061812b989c1e64f504b4b11f75f88022102aea05d25d47a87789599f1a310b1f8a56945a50c93e54c7ee076
   languageName: node
   linkType: hard
 
 "https-proxy-agent@npm:^7.0.1":
-  version: 7.0.4
-  resolution: "https-proxy-agent@npm:7.0.4"
-  dependencies:
-    agent-base: ^7.0.2
-    debug: 4
-  checksum: daaab857a967a2519ddc724f91edbbd388d766ff141b9025b629f92b9408fc83cee8a27e11a907aede392938e9c398e240d643e178408a59e4073539cde8cfe9
-  languageName: node
-  linkType: hard
-
-"https-proxy-agent@npm:^7.0.5":
-  version: 7.0.5
-  resolution: "https-proxy-agent@npm:7.0.5"
+  version: 7.0.6
+  resolution: "https-proxy-agent@npm:7.0.6"
   dependencies:
-    agent-base: ^7.0.2
+    agent-base: ^7.1.2
     debug: 4
-  checksum: 2e1a28960f13b041a50702ee74f240add8e75146a5c37fc98f1960f0496710f6918b3a9fe1e5aba41e50f58e6df48d107edd9c405c5f0d73ac260dabf2210857
+  checksum: b882377a120aa0544846172e5db021fa8afbf83fea2a897d397bd2ddd8095ab268c24bc462f40a15f2a8c600bf4aa05ce52927f70038d4014e68aefecfa94e8d
   languageName: node
   linkType: hard
 
@@ -3452,23 +2651,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"ignore@npm:^5.2.0, ignore@npm:^5.3.1":
-  version: 5.3.1
-  resolution: "ignore@npm:5.3.1"
-  checksum: 71d7bb4c1dbe020f915fd881108cbe85a0db3d636a0ea3ba911393c53946711d13a9b1143c7e70db06d571a5822c0a324a6bcde5c9904e7ca5047f01f1bf8cd3
-  languageName: node
-  linkType: hard
-
-"import-fresh@npm:^3.3.0":
-  version: 3.3.0
-  resolution: "import-fresh@npm:3.3.0"
-  dependencies:
-    parent-module: ^1.0.0
-    resolve-from: ^4.0.0
-  checksum: 2cacfad06e652b1edc50be650f7ec3be08c5e5a6f6d12d035c440a42a8cc028e60a5b99ca08a77ab4d6b1346da7d971915828f33cdab730d3d42f08242d09baa
-  languageName: node
-  linkType: hard
-
 "imurmurhash@npm:^0.1.4":
   version: 0.1.4
   resolution: "imurmurhash@npm:0.1.4"
@@ -3483,23 +2665,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"inflight@npm:^1.0.4":
-  version: 1.0.6
-  resolution: "inflight@npm:1.0.6"
-  dependencies:
-    once: ^1.3.0
-    wrappy: 1
-  checksum: f4f76aa072ce19fae87ce1ef7d221e709afb59d445e05d47fba710e85470923a75de35bfae47da6de1b18afc3ce83d70facf44cfb0aff89f0a3f45c0a0244dfd
-  languageName: node
-  linkType: hard
-
-"inherits@npm:2, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.4":
-  version: 2.0.4
-  resolution: "inherits@npm:2.0.4"
-  checksum: 4a48a733847879d6cf6691860a6b1e3f0f4754176e4d71494c41f3475553768b10f84b5ce1d40fbd0e34e6bfbb864ee35858ad4dd2cf31e02fc4a154b724d7f1
-  languageName: node
-  linkType: hard
-
 "ini@npm:2.0.0":
   version: 2.0.0
   resolution: "ini@npm:2.0.0"
@@ -3507,13 +2672,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"ini@npm:^1.3.5":
-  version: 1.3.8
-  resolution: "ini@npm:1.3.8"
-  checksum: dfd98b0ca3a4fc1e323e38a6c8eb8936e31a97a918d3b377649ea15bdb15d481207a0dda1021efbd86b464cae29a0d33c1d7dcaf6c5672bee17fa849bc50a1b3
-  languageName: node
-  linkType: hard
-
 "ip-address@npm:^9.0.5":
   version: 9.0.5
   resolution: "ip-address@npm:9.0.5"
@@ -3524,13 +2682,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"is-arrayish@npm:^0.2.1":
-  version: 0.2.1
-  resolution: "is-arrayish@npm:0.2.1"
-  checksum: eef4417e3c10e60e2c810b6084942b3ead455af16c4509959a27e490e7aee87cfb3f38e01bbde92220b528a0ee1a18d52b787e1458ee86174d8c7f0e58cd488f
-  languageName: node
-  linkType: hard
-
 "is-ci@npm:^3.0.1":
   version: 3.0.1
   resolution: "is-ci@npm:3.0.1"
@@ -3542,22 +2693,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"is-core-module@npm:^2.13.0":
-  version: 2.13.1
-  resolution: "is-core-module@npm:2.13.1"
-  dependencies:
-    hasown: ^2.0.0
-  checksum: 256559ee8a9488af90e4bad16f5583c6d59e92f0742e9e8bb4331e758521ee86b810b93bae44f390766ffbc518a0488b18d9dab7da9a5ff997d499efc9403f7c
-  languageName: node
-  linkType: hard
-
-"is-extglob@npm:^2.1.1":
-  version: 2.1.1
-  resolution: "is-extglob@npm:2.1.1"
-  checksum: df033653d06d0eb567461e58a7a8c9f940bd8c22274b94bf7671ab36df5719791aae15eef6d83bbb5e23283967f2f984b8914559d4449efda578c775c4be6f85
-  languageName: node
-  linkType: hard
-
 "is-fullwidth-code-point@npm:^3.0.0":
   version: 3.0.0
   resolution: "is-fullwidth-code-point@npm:3.0.0"
@@ -3565,15 +2700,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"is-glob@npm:^4.0.1":
-  version: 4.0.3
-  resolution: "is-glob@npm:4.0.3"
-  dependencies:
-    is-extglob: ^2.1.1
-  checksum: d381c1319fcb69d341cc6e6c7cd588e17cd94722d9a32dbd60660b993c4fb7d0f19438674e68dfec686d09b7c73139c9166b47597f846af387450224a8101ab4
-  languageName: node
-  linkType: hard
-
 "is-installed-globally@npm:~0.4.0":
   version: 0.4.0
   resolution: "is-installed-globally@npm:0.4.0"
@@ -3584,27 +2710,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"is-interactive@npm:^1.0.0":
-  version: 1.0.0
-  resolution: "is-interactive@npm:1.0.0"
-  checksum: 824808776e2d468b2916cdd6c16acacebce060d844c35ca6d82267da692e92c3a16fdba624c50b54a63f38bdc4016055b6f443ce57d7147240de4f8cdabaf6f9
-  languageName: node
-  linkType: hard
-
-"is-lambda@npm:^1.0.1":
-  version: 1.0.1
-  resolution: "is-lambda@npm:1.0.1"
-  checksum: 93a32f01940220532e5948538699ad610d5924ac86093fcee83022252b363eb0cc99ba53ab084a04e4fb62bf7b5731f55496257a4c38adf87af9c4d352c71c35
-  languageName: node
-  linkType: hard
-
-"is-number@npm:^7.0.0":
-  version: 7.0.0
-  resolution: "is-number@npm:7.0.0"
-  checksum: 456ac6f8e0f3111ed34668a624e45315201dff921e5ac181f8ec24923b99e9f32ca1a194912dc79d539c97d33dba17dc635202ff0b2cf98326f608323276d27a
-  languageName: node
-  linkType: hard
-
 "is-path-inside@npm:^3.0.2":
   version: 3.0.3
   resolution: "is-path-inside@npm:3.0.3"
@@ -3612,20 +2717,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"is-plain-object@npm:^5.0.0":
-  version: 5.0.0
-  resolution: "is-plain-object@npm:5.0.0"
-  checksum: e32d27061eef62c0847d303125440a38660517e586f2f3db7c9d179ae5b6674ab0f469d519b2e25c147a1a3bc87156d0d5f4d8821e0ce4a9ee7fe1fcf11ce45c
-  languageName: node
-  linkType: hard
-
-"is-potential-custom-element-name@npm:^1.0.1":
-  version: 1.0.1
-  resolution: "is-potential-custom-element-name@npm:1.0.1"
-  checksum: ced7bbbb6433a5b684af581872afe0e1767e2d1146b2207ca0068a648fb5cab9d898495d1ac0583524faaf24ca98176a7d9876363097c2d14fee6dd324f3a1ab
-  languageName: node
-  linkType: hard
-
 "is-stream@npm:^2.0.0":
   version: 2.0.1
   resolution: "is-stream@npm:2.0.1"
@@ -3696,19 +2787,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"jackspeak@npm:^2.3.6":
-  version: 2.3.6
-  resolution: "jackspeak@npm:2.3.6"
-  dependencies:
-    "@isaacs/cliui": ^8.0.2
-    "@pkgjs/parseargs": ^0.11.0
-  dependenciesMeta:
-    "@pkgjs/parseargs":
-      optional: true
-  checksum: 57d43ad11eadc98cdfe7496612f6bbb5255ea69fe51ea431162db302c2a11011642f50cfad57288bd0aea78384a0612b16e131944ad8ecd09d619041c8531b54
-  languageName: node
-  linkType: hard
-
 "jackspeak@npm:^3.1.2":
   version: 3.4.3
   resolution: "jackspeak@npm:3.4.3"
@@ -3729,17 +2807,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"js-yaml@npm:^4.1.0":
-  version: 4.1.0
-  resolution: "js-yaml@npm:4.1.0"
-  dependencies:
-    argparse: ^2.0.1
-  bin:
-    js-yaml: bin/js-yaml.js
-  checksum: c7830dfd456c3ef2c6e355cc5a92e6700ceafa1d14bba54497b34a99f0376cecbb3e9ac14d3e5849b426d5a5140709a66237a8c991c675431271c4ce5504151a
-  languageName: node
-  linkType: hard
-
 "jsbn@npm:1.1.0":
   version: 1.1.0
   resolution: "jsbn@npm:1.1.0"
@@ -3754,67 +2821,12 @@ __metadata:
   languageName: node
   linkType: hard
 
-"jsdom@npm:24.1.1":
-  version: 24.1.1
-  resolution: "jsdom@npm:24.1.1"
-  dependencies:
-    cssstyle: ^4.0.1
-    data-urls: ^5.0.0
-    decimal.js: ^10.4.3
-    form-data: ^4.0.0
-    html-encoding-sniffer: ^4.0.0
-    http-proxy-agent: ^7.0.2
-    https-proxy-agent: ^7.0.5
-    is-potential-custom-element-name: ^1.0.1
-    nwsapi: ^2.2.12
-    parse5: ^7.1.2
-    rrweb-cssom: ^0.7.1
-    saxes: ^6.0.0
-    symbol-tree: ^3.2.4
-    tough-cookie: ^4.1.4
-    w3c-xmlserializer: ^5.0.0
-    webidl-conversions: ^7.0.0
-    whatwg-encoding: ^3.1.1
-    whatwg-mimetype: ^4.0.0
-    whatwg-url: ^14.0.0
-    ws: ^8.18.0
-    xml-name-validator: ^5.0.0
-  peerDependencies:
-    canvas: ^2.11.2
-  peerDependenciesMeta:
-    canvas:
-      optional: true
-  checksum: c3f3c9c8f6ac4ce308de6f005980d0f3da4d504686a0fc20c59760f1e3be714d48adf3d31f8d3a352d8adb4961e6cfebfc6b6c3c9841408cf6e7f8c0cd4dcdc4
-  languageName: node
-  linkType: hard
-
-"jsesc@npm:^2.5.1":
-  version: 2.5.2
-  resolution: "jsesc@npm:2.5.2"
+"jsesc@npm:^3.0.2":
+  version: 3.1.0
+  resolution: "jsesc@npm:3.1.0"
   bin:
     jsesc: bin/jsesc
-  checksum: 4dc190771129e12023f729ce20e1e0bfceac84d73a85bc3119f7f938843fe25a4aeccb54b6494dce26fcf263d815f5f31acdefac7cc9329efb8422a4f4d9fa9d
-  languageName: node
-  linkType: hard
-
-"json-buffer@npm:3.0.1":
-  version: 3.0.1
-  resolution: "json-buffer@npm:3.0.1"
-  checksum: 9026b03edc2847eefa2e37646c579300a1f3a4586cfb62bf857832b60c852042d0d6ae55d1afb8926163fa54c2b01d83ae24705f34990348bdac6273a29d4581
-  languageName: node
-  linkType: hard
-
-"json-parse-even-better-errors@npm:^2.3.0":
-  version: 2.3.1
-  resolution: "json-parse-even-better-errors@npm:2.3.1"
-  checksum: 798ed4cf3354a2d9ccd78e86d2169515a0097a5c133337807cdf7f1fc32e1391d207ccfc276518cc1d7d8d4db93288b8a50ba4293d212ad1336e52a8ec0a941f
-  languageName: node
-  linkType: hard
-
-"json-schema-traverse@npm:^1.0.0":
-  version: 1.0.0
-  resolution: "json-schema-traverse@npm:1.0.0"
-  checksum: 02f2f466cdb0362558b2f1fd5e15cce82ef55d60cd7f8fa828cf35ba74330f8d767fcae5c5c2adb7851fa811766c694b9405810879bc4e1ddd78a7c0e03658ad
+  checksum: 19c94095ea026725540c0d29da33ab03144f6bcf2d4159e4833d534976e99e0c09c38cefa9a575279a51fc36b31166f8d6d05c9fe2645d5f15851d690b41f17f
   languageName: node
   linkType: hard
 
@@ -3866,22 +2878,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"keyv@npm:^4.5.4":
-  version: 4.5.4
-  resolution: "keyv@npm:4.5.4"
-  dependencies:
-    json-buffer: 3.0.1
-  checksum: 74a24395b1c34bd44ad5cb2b49140d087553e170625240b86755a6604cd65aa16efdbdeae5cdb17ba1284a0fbb25ad06263755dbc71b8d8b06f74232ce3cdd72
-  languageName: node
-  linkType: hard
-
-"kind-of@npm:^6.0.2":
-  version: 6.0.3
-  resolution: "kind-of@npm:6.0.3"
-  checksum: 3ab01e7b1d440b22fe4c31f23d8d38b4d9b91d9f291df683476576493d5dfd2e03848a8b05813dd0c3f0e835bc63f433007ddeceb71f05cb25c45ae1b19c6d3b
-  languageName: node
-  linkType: hard
-
 "kleur@npm:^4.0.3":
   version: 4.1.5
   resolution: "kleur@npm:4.1.5"
@@ -3889,13 +2885,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"known-css-properties@npm:^0.34.0":
-  version: 0.34.0
-  resolution: "known-css-properties@npm:0.34.0"
-  checksum: 2f1c562767164672442949c44310cf8bbd0cc8a1fbf76b2437a0a2ed364876f40b03d18ffee3de2490e789e916be6c635823e4137fcb3c2f690a96e79bd66a8c
-  languageName: node
-  linkType: hard
-
 "lazy-ass@npm:^1.6.0":
   version: 1.6.0
   resolution: "lazy-ass@npm:1.6.0"
@@ -3903,33 +2892,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"license-checker@npm:25.0.1":
-  version: 25.0.1
-  resolution: "license-checker@npm:25.0.1"
-  dependencies:
-    chalk: ^2.4.1
-    debug: ^3.1.0
-    mkdirp: ^0.5.1
-    nopt: ^4.0.1
-    read-installed: ~4.0.3
-    semver: ^5.5.0
-    spdx-correct: ^3.0.0
-    spdx-expression-parse: ^3.0.0
-    spdx-satisfies: ^4.0.0
-    treeify: ^1.1.0
-  bin:
-    license-checker: ./bin/license-checker
-  checksum: deaabf56b471cfcd7bda190753becf2589c1fede585f21160f1c3bdcff255ef013401bdeaeb6ddcfde796ed4d03612508a6338b2465132205aaf73df45c9c2b5
-  languageName: node
-  linkType: hard
-
-"lines-and-columns@npm:^1.1.6":
-  version: 1.2.4
-  resolution: "lines-and-columns@npm:1.2.4"
-  checksum: 0c37f9f7fa212b38912b7145e1cd16a5f3cd34d782441c3e6ca653485d326f58b3caccda66efce1c5812bde4961bbde3374fae4b0d11bf1226152337f3894aa5
-  languageName: node
-  linkType: hard
-
 "listr2@npm:^3.8.3":
   version: 3.14.0
   resolution: "listr2@npm:3.14.0"
@@ -3967,13 +2929,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"lodash.truncate@npm:^4.4.2":
-  version: 4.4.2
-  resolution: "lodash.truncate@npm:4.4.2"
-  checksum: b463d8a382cfb5f0e71c504dcb6f807a7bd379ff1ea216669aa42c52fc28c54e404bfbd96791aa09e6df0de2c1d7b8f1b7f4b1a61f324d38fe98bc535aeee4f5
-  languageName: node
-  linkType: hard
-
 "lodash@npm:^4.17.14, lodash@npm:^4.17.21":
   version: 4.17.21
   resolution: "lodash@npm:4.17.21"
@@ -3981,7 +2936,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"log-symbols@npm:^4.0.0, log-symbols@npm:^4.1.0":
+"log-symbols@npm:^4.0.0":
   version: 4.1.0
   resolution: "log-symbols@npm:4.1.0"
   dependencies:
@@ -4015,9 +2970,9 @@ __metadata:
   linkType: hard
 
 "lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0":
-  version: 10.2.0
-  resolution: "lru-cache@npm:10.2.0"
-  checksum: eee7ddda4a7475deac51ac81d7dd78709095c6fa46e8350dc2d22462559a1faa3b81ed931d5464b13d48cbd7e08b46100b6f768c76833912bc444b99c37e25db
+  version: 10.4.3
+  resolution: "lru-cache@npm:10.4.3"
+  checksum: 6476138d2125387a6d20f100608c2583d415a4f64a0fecf30c9e2dda976614f09cad4baa0842447bd37dd459a7bd27f57d9d8f8ce558805abd487c583f3d774a
   languageName: node
   linkType: hard
 
@@ -4030,15 +2985,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"lru-cache@npm:^6.0.0":
-  version: 6.0.0
-  resolution: "lru-cache@npm:6.0.0"
-  dependencies:
-    yallist: ^4.0.0
-  checksum: f97f499f898f23e4585742138a22f22526254fdba6d75d41a1c2526b3b6cc5747ef59c5612ba7375f42aca4f8461950e925ba08c991ead0651b4918b7c978297
-  languageName: node
-  linkType: hard
-
 "luxon@npm:3.5.0":
   version: 3.5.0
   resolution: "luxon@npm:3.5.0"
@@ -4055,36 +3001,29 @@ __metadata:
   languageName: node
   linkType: hard
 
-"make-fetch-happen@npm:^13.0.0":
-  version: 13.0.0
-  resolution: "make-fetch-happen@npm:13.0.0"
+"make-fetch-happen@npm:^14.0.3":
+  version: 14.0.3
+  resolution: "make-fetch-happen@npm:14.0.3"
   dependencies:
-    "@npmcli/agent": ^2.0.0
-    cacache: ^18.0.0
+    "@npmcli/agent": ^3.0.0
+    cacache: ^19.0.1
     http-cache-semantics: ^4.1.1
-    is-lambda: ^1.0.1
     minipass: ^7.0.2
-    minipass-fetch: ^3.0.0
+    minipass-fetch: ^4.0.0
     minipass-flush: ^1.0.5
     minipass-pipeline: ^1.2.4
-    negotiator: ^0.6.3
+    negotiator: ^1.0.0
+    proc-log: ^5.0.0
     promise-retry: ^2.0.1
-    ssri: ^10.0.0
-  checksum: 7c7a6d381ce919dd83af398b66459a10e2fe8f4504f340d1d090d3fa3d1b0c93750220e1d898114c64467223504bd258612ba83efbc16f31b075cd56de24b4af
-  languageName: node
-  linkType: hard
-
-"mathml-tag-names@npm:^2.1.3":
-  version: 2.1.3
-  resolution: "mathml-tag-names@npm:2.1.3"
-  checksum: 1201a25a137d6b9e328facd67912058b8b45b19a6c4cc62641c9476195da28a275ca6e0eca070af5378b905c2b11abc1114676ba703411db0b9ce007de921ad0
+    ssri: ^12.0.0
+  checksum: 6fb2fee6da3d98f1953b03d315826b5c5a4ea1f908481afc113782d8027e19f080c85ae998454de4e5f27a681d3ec58d57278f0868d4e0b736f51d396b661691
   languageName: node
   linkType: hard
 
-"mdn-data@npm:2.0.30":
-  version: 2.0.30
-  resolution: "mdn-data@npm:2.0.30"
-  checksum: d6ac5ac7439a1607df44b22738ecf83f48e66a0874e4482d6424a61c52da5cde5750f1d1229b6f5fa1b80a492be89465390da685b11f97d62b8adcc6e88189aa
+"math-intrinsics@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "math-intrinsics@npm:1.1.0"
+  checksum: 0e513b29d120f478c85a70f49da0b8b19bc638975eca466f2eeae0071f3ad00454c621bf66e16dd435896c208e719fc91ad79bbfba4e400fe0b372e7c1c9c9a2
   languageName: node
   linkType: hard
 
@@ -4093,19 +3032,16 @@ __metadata:
   resolution: "media-js@workspace:."
   dependencies:
     "@biomejs/biome": ^1.8.3
+    "@types/ws": 8.5.13
     c8: 10.1.2
     cmd-ts: 0.13.0
     cypress: 13.13.2
-    dpdm: 3.14.0
     esbuild: 0.23.0
     http-server: 14.1.1
-    license-checker: 25.0.1
     media-stream-library: "workspace:^"
     semver: 7.6.3
-    stylelint: 16.8.1
-    tsx: 4.16.5
     typescript: 5.5.4
-    yargs: 17.7.2
+    ws: 8.18.0
   languageName: unknown
   linkType: soft
 
@@ -4140,21 +3076,12 @@ __metadata:
   dependencies:
     "@types/debug": 4.1.12
     "@types/node": 20.12.5
-    "@types/ws": 8.5.12
-    base64-js: 1.5.1
-    debug: 4.3.6
+    base64-js: ^1.5.1
     esbuild: 0.23.0
-    events: 3.3.0
-    global-jsdom: 9.2.0
-    jsdom: 24.1.1
     mock-socket: 9.3.1
-    process: 0.11.10
-    semver: 7.6.3
-    stream-browserify: 3.0.0
-    ts-md5: 1.3.1
+    ts-md5: ^1.3.1
     typescript: 5.5.4
     uvu: 0.5.6
-    ws: 8.18.0
   languageName: unknown
   linkType: soft
 
@@ -4163,20 +3090,17 @@ __metadata:
   resolution: "media-stream-player@workspace:player"
   dependencies:
     "@juggle/resize-observer": 3.4.0
-    "@types/debug": 4.1.12
     "@types/luxon": 3.4.2
     "@types/react": 18.3.3
     "@types/react-dom": 18.3.0
     "@types/styled-components": 5.1.34
     "@vitejs/plugin-react": 4.3.1
     chalk: 5.3.0
-    debug: 4.3.6
     esbuild: 0.23.0
     luxon: 3.5.0
     media-stream-library: "workspace:^"
     react: 18.3.1
     react-dom: 18.3.1
-    react-hooks-shareable: 1.53.0
     react-is: 18.3.1
     semver: 7.6.3
     styled-components: 5.3.11
@@ -4184,20 +3108,13 @@ __metadata:
     vite: 5.4.0
   peerDependencies:
     luxon: ^3.0.0
-    media-stream-library: ^13.2.0
+    media-stream-library: "workspace:^"
     react: ^17.0.2 || ^18.1.0
     react-dom: ^17.0.2 || ^18.1.0
     styled-components: ^5.3.5
   languageName: unknown
   linkType: soft
 
-"meow@npm:^13.2.0":
-  version: 13.2.0
-  resolution: "meow@npm:13.2.0"
-  checksum: 79c61dc02ad448ff5c29bbaf1ef42181f1eae9947112c0e23db93e84cbc2708ecda53e54bfc6689f1e55255b2cea26840ec76e57a5773a16ca45f4fe2163ec1c
-  languageName: node
-  linkType: hard
-
 "merge-stream@npm:^2.0.0":
   version: 2.0.0
   resolution: "merge-stream@npm:2.0.0"
@@ -4205,33 +3122,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"merge2@npm:^1.3.0, merge2@npm:^1.4.1":
-  version: 1.4.1
-  resolution: "merge2@npm:1.4.1"
-  checksum: 7268db63ed5169466540b6fb947aec313200bcf6d40c5ab722c22e242f651994619bcd85601602972d3c85bd2cc45a358a4c61937e9f11a061919a1da569b0c2
-  languageName: node
-  linkType: hard
-
-"micromatch@npm:^4.0.4":
-  version: 4.0.5
-  resolution: "micromatch@npm:4.0.5"
-  dependencies:
-    braces: ^3.0.2
-    picomatch: ^2.3.1
-  checksum: 02a17b671c06e8fefeeb6ef996119c1e597c942e632a21ef589154f23898c9c6a9858526246abb14f8bca6e77734aa9dcf65476fca47cedfb80d9577d52843fc
-  languageName: node
-  linkType: hard
-
-"micromatch@npm:^4.0.7":
-  version: 4.0.7
-  resolution: "micromatch@npm:4.0.7"
-  dependencies:
-    braces: ^3.0.3
-    picomatch: ^2.3.1
-  checksum: 3cde047d70ad80cf60c787b77198d680db3b8c25b23feb01de5e2652205d9c19f43bd81882f69a0fd1f0cde6a7a122d774998aad3271ddb1b8accf8a0f480cf7
-  languageName: node
-  linkType: hard
-
 "mime-db@npm:1.52.0":
   version: 1.52.0
   resolution: "mime-db@npm:1.52.0"
@@ -4264,24 +3154,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"minimatch@npm:^3.1.1":
-  version: 3.1.2
-  resolution: "minimatch@npm:3.1.2"
-  dependencies:
-    brace-expansion: ^1.1.7
-  checksum: c154e566406683e7bcb746e000b84d74465b3a832c45d59912b9b55cd50dee66e5c4b1e5566dba26154040e51672f9aa450a9aef0c97cfc7336b78b7afb9540a
-  languageName: node
-  linkType: hard
-
-"minimatch@npm:^9.0.1":
-  version: 9.0.4
-  resolution: "minimatch@npm:9.0.4"
-  dependencies:
-    brace-expansion: ^2.0.1
-  checksum: cf717f597ec3eed7dabc33153482a2e8d49f4fd3c26e58fd9c71a94c5029a0838728841b93f46bf1263b65a8010e2ee800d0dc9b004ab8ba8b6d1ec07cc115b5
-  languageName: node
-  linkType: hard
-
 "minimatch@npm:^9.0.4":
   version: 9.0.5
   resolution: "minimatch@npm:9.0.5"
@@ -4307,18 +3179,18 @@ __metadata:
   languageName: node
   linkType: hard
 
-"minipass-fetch@npm:^3.0.0":
-  version: 3.0.4
-  resolution: "minipass-fetch@npm:3.0.4"
+"minipass-fetch@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "minipass-fetch@npm:4.0.0"
   dependencies:
     encoding: ^0.1.13
     minipass: ^7.0.3
     minipass-sized: ^1.0.3
-    minizlib: ^2.1.2
+    minizlib: ^3.0.1
   dependenciesMeta:
     encoding:
       optional: true
-  checksum: af7aad15d5c128ab1ebe52e043bdf7d62c3c6f0cecb9285b40d7b395e1375b45dcdfd40e63e93d26a0e8249c9efd5c325c65575aceee192883970ff8cb11364a
+  checksum: 7d59a31011ab9e4d1af6562dd4c4440e425b2baf4c5edbdd2e22fb25a88629e1cdceca39953ff209da504a46021df520f18fd9a519f36efae4750ff724ddadea
   languageName: node
   linkType: hard
 
@@ -4358,38 +3230,24 @@ __metadata:
   languageName: node
   linkType: hard
 
-"minipass@npm:^5.0.0":
-  version: 5.0.0
-  resolution: "minipass@npm:5.0.0"
-  checksum: 425dab288738853fded43da3314a0b5c035844d6f3097a8e3b5b29b328da8f3c1af6fc70618b32c29ff906284cf6406b6841376f21caaadd0793c1d5a6a620ea
-  languageName: node
-  linkType: hard
-
-"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4":
-  version: 7.0.4
-  resolution: "minipass@npm:7.0.4"
-  checksum: 87585e258b9488caf2e7acea242fd7856bbe9a2c84a7807643513a338d66f368c7d518200ad7b70a508664d408aa000517647b2930c259a8b1f9f0984f344a21
-  languageName: node
-  linkType: hard
-
-"minipass@npm:^7.1.2":
+"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2":
   version: 7.1.2
   resolution: "minipass@npm:7.1.2"
   checksum: 2bfd325b95c555f2b4d2814d49325691c7bee937d753814861b0b49d5edcda55cbbf22b6b6a60bb91eddac8668771f03c5ff647dcd9d0f798e9548b9cdc46ee3
   languageName: node
   linkType: hard
 
-"minizlib@npm:^2.1.1, minizlib@npm:^2.1.2":
-  version: 2.1.2
-  resolution: "minizlib@npm:2.1.2"
+"minizlib@npm:^3.0.1":
+  version: 3.0.1
+  resolution: "minizlib@npm:3.0.1"
   dependencies:
-    minipass: ^3.0.0
-    yallist: ^4.0.0
-  checksum: f1fdeac0b07cf8f30fcf12f4b586795b97be856edea22b5e9072707be51fc95d41487faec3f265b42973a304fe3a64acd91a44a3826a963e37b37bafde0212c3
+    minipass: ^7.0.4
+    rimraf: ^5.0.5
+  checksum: da0a53899252380475240c587e52c824f8998d9720982ba5c4693c68e89230718884a209858c156c6e08d51aad35700a3589987e540593c36f6713fe30cd7338
   languageName: node
   linkType: hard
 
-"mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.6":
+"mkdirp@npm:^0.5.6":
   version: 0.5.6
   resolution: "mkdirp@npm:0.5.6"
   dependencies:
@@ -4400,12 +3258,12 @@ __metadata:
   languageName: node
   linkType: hard
 
-"mkdirp@npm:^1.0.3":
-  version: 1.0.4
-  resolution: "mkdirp@npm:1.0.4"
+"mkdirp@npm:^3.0.1":
+  version: 3.0.1
+  resolution: "mkdirp@npm:3.0.1"
   bin:
-    mkdirp: bin/cmd.js
-  checksum: a96865108c6c3b1b8e1d5e9f11843de1e077e57737602de1b82030815f311be11f96f09cce59bd5b903d0b29834733e5313f9301e3ed6d6f6fba2eae0df4298f
+    mkdirp: dist/cjs/src/bin.js
+  checksum: 972deb188e8fb55547f1e58d66bd6b4a3623bf0c7137802582602d73e6480c1c2268dcbafbfb1be466e00cc7e56ac514d7fd9334b7cf33e3e2ab547c16f83a8d
   languageName: node
   linkType: hard
 
@@ -4423,14 +3281,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"ms@npm:2.1.2":
-  version: 2.1.2
-  resolution: "ms@npm:2.1.2"
-  checksum: 673cdb2c3133eb050c745908d8ce632ed2c02d85640e2edb3ace856a2266a813b30c613569bf3354fdf4ea7d1a1494add3bfa95e2713baa27d0c2c71fc44f58f
-  languageName: node
-  linkType: hard
-
-"ms@npm:^2.1.1":
+"ms@npm:^2.1.1, ms@npm:^2.1.3":
   version: 2.1.3
   resolution: "ms@npm:2.1.3"
   checksum: aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d
@@ -4438,94 +3289,56 @@ __metadata:
   linkType: hard
 
 "nanoid@npm:^3.3.7":
-  version: 3.3.7
-  resolution: "nanoid@npm:3.3.7"
+  version: 3.3.8
+  resolution: "nanoid@npm:3.3.8"
   bin:
     nanoid: bin/nanoid.cjs
-  checksum: d36c427e530713e4ac6567d488b489a36582ef89da1d6d4e3b87eded11eb10d7042a877958c6f104929809b2ab0bafa17652b076cdf84324aa75b30b722204f2
+  checksum: dfe0adbc0c77e9655b550c333075f51bb28cfc7568afbf3237249904f9c86c9aaaed1f113f0fddddba75673ee31c758c30c43d4414f014a52a7a626efc5958c9
   languageName: node
   linkType: hard
 
-"negotiator@npm:^0.6.3":
-  version: 0.6.3
-  resolution: "negotiator@npm:0.6.3"
-  checksum: b8ffeb1e262eff7968fc90a2b6767b04cfd9842582a9d0ece0af7049537266e7b2506dfb1d107a32f06dd849ab2aea834d5830f7f4d0e5cb7d36e1ae55d021d9
+"negotiator@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "negotiator@npm:1.0.0"
+  checksum: 20ebfe79b2d2e7cf9cbc8239a72662b584f71164096e6e8896c8325055497c96f6b80cd22c258e8a2f2aa382a787795ec3ee8b37b422a302c7d4381b0d5ecfbb
   languageName: node
   linkType: hard
 
 "node-gyp@npm:latest":
-  version: 10.1.0
-  resolution: "node-gyp@npm:10.1.0"
+  version: 11.0.0
+  resolution: "node-gyp@npm:11.0.0"
   dependencies:
     env-paths: ^2.2.0
     exponential-backoff: ^3.1.1
     glob: ^10.3.10
     graceful-fs: ^4.2.6
-    make-fetch-happen: ^13.0.0
-    nopt: ^7.0.0
-    proc-log: ^3.0.0
+    make-fetch-happen: ^14.0.3
+    nopt: ^8.0.0
+    proc-log: ^5.0.0
     semver: ^7.3.5
-    tar: ^6.1.2
-    which: ^4.0.0
+    tar: ^7.4.3
+    which: ^5.0.0
   bin:
     node-gyp: bin/node-gyp.js
-  checksum: 72e2ab4b23fc32007a763da94018f58069fc0694bf36115d49a2b195c8831e12cf5dd1e7a3718fa85c06969aedf8fc126722d3b672ec1cb27e06ed33caee3c60
-  languageName: node
-  linkType: hard
-
-"node-releases@npm:^2.0.14":
-  version: 2.0.14
-  resolution: "node-releases@npm:2.0.14"
-  checksum: 59443a2f77acac854c42d321bf1b43dea0aef55cd544c6a686e9816a697300458d4e82239e2d794ea05f7bbbc8a94500332e2d3ac3f11f52e4b16cbe638b3c41
+  checksum: d7d5055ccc88177f721c7cd4f8f9440c29a0eb40e7b79dba89ef882ec957975dfc1dcb8225e79ab32481a02016eb13bbc051a913ea88d482d3cbdf2131156af4
   languageName: node
   linkType: hard
 
-"nopt@npm:^4.0.1":
-  version: 4.0.3
-  resolution: "nopt@npm:4.0.3"
-  dependencies:
-    abbrev: 1
-    osenv: ^0.1.4
-  bin:
-    nopt: bin/nopt.js
-  checksum: 66cd3b6021fc8130fc201236bc3dce614fc86988b78faa91377538b09d57aad9ba4300b5d6a01dc93d6c6f2c170f81cc893063d496d108150b65191beb4a50a4
+"node-releases@npm:^2.0.19":
+  version: 2.0.19
+  resolution: "node-releases@npm:2.0.19"
+  checksum: 917dbced519f48c6289a44830a0ca6dc944c3ee9243c468ebd8515a41c97c8b2c256edb7f3f750416bc37952cc9608684e6483c7b6c6f39f6bd8d86c52cfe658
   languageName: node
   linkType: hard
 
-"nopt@npm:^7.0.0":
-  version: 7.2.0
-  resolution: "nopt@npm:7.2.0"
+"nopt@npm:^8.0.0":
+  version: 8.0.0
+  resolution: "nopt@npm:8.0.0"
   dependencies:
     abbrev: ^2.0.0
   bin:
     nopt: bin/nopt.js
-  checksum: a9c0f57fb8cb9cc82ae47192ca2b7ef00e199b9480eed202482c962d61b59a7fbe7541920b2a5839a97b42ee39e288c0aed770e38057a608d7f579389dfde410
-  languageName: node
-  linkType: hard
-
-"normalize-package-data@npm:^2.0.0":
-  version: 2.5.0
-  resolution: "normalize-package-data@npm:2.5.0"
-  dependencies:
-    hosted-git-info: ^2.1.4
-    resolve: ^1.10.0
-    semver: 2 || 3 || 4 || 5
-    validate-npm-package-license: ^3.0.1
-  checksum: 7999112efc35a6259bc22db460540cae06564aa65d0271e3bdfa86876d08b0e578b7b5b0028ee61b23f1cae9fc0e7847e4edc0948d3068a39a2a82853efc8499
-  languageName: node
-  linkType: hard
-
-"normalize-path@npm:^3.0.0":
-  version: 3.0.0
-  resolution: "normalize-path@npm:3.0.0"
-  checksum: 88eeb4da891e10b1318c4b2476b6e2ecbeb5ff97d946815ffea7794c31a89017c70d7f34b3c2ebf23ef4e9fc9fb99f7dffe36da22011b5b5c6ffa34f4873ec20
-  languageName: node
-  linkType: hard
-
-"npm-normalize-package-bin@npm:^1.0.0":
-  version: 1.0.1
-  resolution: "npm-normalize-package-bin@npm:1.0.1"
-  checksum: ae7f15155a1e3ace2653f12ddd1ee8eaa3c84452fdfbf2f1943e1de264e4b079c86645e2c55931a51a0a498cba31f70022a5219d5665fbcb221e99e58bc70122
+  checksum: 2cfc65e7ee38af2e04aea98f054753b0230011c0eeca4ecf131bd7d25984cbbf6f214586e0ae5dfcc2e830bc0bffa5a7fb28ea8d0b306ffd4ae8ea2d814c1ab3
   languageName: node
   linkType: hard
 
@@ -4538,21 +3351,14 @@ __metadata:
   languageName: node
   linkType: hard
 
-"nwsapi@npm:^2.2.12":
-  version: 2.2.12
-  resolution: "nwsapi@npm:2.2.12"
-  checksum: 4dbce7ecbcf336eef1edcbb5161cbceea95863e63a16d9bcec8e81cbb260bdab3d07e6c7b58354d465dc803eef6d0ea4fb20220a80fa148ae65f18d56df81799
-  languageName: node
-  linkType: hard
-
-"object-inspect@npm:^1.13.1":
-  version: 1.13.1
-  resolution: "object-inspect@npm:1.13.1"
-  checksum: 7d9fa9221de3311dcb5c7c307ee5dc011cdd31dc43624b7c184b3840514e118e05ef0002be5388304c416c0eb592feb46e983db12577fc47e47d5752fbbfb61f
+"object-inspect@npm:^1.13.3":
+  version: 1.13.3
+  resolution: "object-inspect@npm:1.13.3"
+  checksum: 8c962102117241e18ea403b84d2521f78291b774b03a29ee80a9863621d88265ffd11d0d7e435c4c2cea0dc2a2fbf8bbc92255737a05536590f2df2e8756f297
   languageName: node
   linkType: hard
 
-"once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0":
+"once@npm:^1.3.1, once@npm:^1.4.0":
   version: 1.4.0
   resolution: "once@npm:1.4.0"
   dependencies:
@@ -4579,47 +3385,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"ora@npm:^5.4.1":
-  version: 5.4.1
-  resolution: "ora@npm:5.4.1"
-  dependencies:
-    bl: ^4.1.0
-    chalk: ^4.1.0
-    cli-cursor: ^3.1.0
-    cli-spinners: ^2.5.0
-    is-interactive: ^1.0.0
-    is-unicode-supported: ^0.1.0
-    log-symbols: ^4.1.0
-    strip-ansi: ^6.0.0
-    wcwidth: ^1.0.1
-  checksum: 28d476ee6c1049d68368c0dc922e7225e3b5600c3ede88fade8052837f9ed342625fdaa84a6209302587c8ddd9b664f71f0759833cbdb3a4cf81344057e63c63
-  languageName: node
-  linkType: hard
-
-"os-homedir@npm:^1.0.0":
-  version: 1.0.2
-  resolution: "os-homedir@npm:1.0.2"
-  checksum: af609f5a7ab72de2f6ca9be6d6b91a599777afc122ac5cad47e126c1f67c176fe9b52516b9eeca1ff6ca0ab8587fe66208bc85e40a3940125f03cdb91408e9d2
-  languageName: node
-  linkType: hard
-
-"os-tmpdir@npm:^1.0.0":
-  version: 1.0.2
-  resolution: "os-tmpdir@npm:1.0.2"
-  checksum: 5666560f7b9f10182548bf7013883265be33620b1c1b4a4d405c25be2636f970c5488ff3e6c48de75b55d02bde037249fe5dbfbb4c0fb7714953d56aed062e6d
-  languageName: node
-  linkType: hard
-
-"osenv@npm:^0.1.4":
-  version: 0.1.5
-  resolution: "osenv@npm:0.1.5"
-  dependencies:
-    os-homedir: ^1.0.0
-    os-tmpdir: ^1.0.0
-  checksum: 779d261920f2a13e5e18cf02446484f12747d3f2ff82280912f52b213162d43d312647a40c332373cbccd5e3fb8126915d3bfea8dde4827f70f82da76e52d359
-  languageName: node
-  linkType: hard
-
 "ospath@npm:^1.2.2":
   version: 1.2.2
   resolution: "ospath@npm:1.2.2"
@@ -4654,40 +3419,17 @@ __metadata:
   languageName: node
   linkType: hard
 
-"package-json-from-dist@npm:^1.0.0":
-  version: 1.0.0
-  resolution: "package-json-from-dist@npm:1.0.0"
-  checksum: ac706ec856a5a03f5261e4e48fa974f24feb044d51f84f8332e2af0af04fbdbdd5bbbfb9cbbe354190409bc8307c83a9e38c6672c3c8855f709afb0006a009ea
+"p-map@npm:^7.0.2":
+  version: 7.0.3
+  resolution: "p-map@npm:7.0.3"
+  checksum: 8c92d533acf82f0d12f7e196edccff773f384098bbb048acdd55a08778ce4fc8889d8f1bde72969487bd96f9c63212698d79744c20bedfce36c5b00b46d369f8
   languageName: node
   linkType: hard
 
-"parent-module@npm:^1.0.0":
+"package-json-from-dist@npm:^1.0.0":
   version: 1.0.1
-  resolution: "parent-module@npm:1.0.1"
-  dependencies:
-    callsites: ^3.0.0
-  checksum: 6ba8b255145cae9470cf5551eb74be2d22281587af787a2626683a6c20fbb464978784661478dd2a3f1dad74d1e802d403e1b03c1a31fab310259eec8ac560ff
-  languageName: node
-  linkType: hard
-
-"parse-json@npm:^5.2.0":
-  version: 5.2.0
-  resolution: "parse-json@npm:5.2.0"
-  dependencies:
-    "@babel/code-frame": ^7.0.0
-    error-ex: ^1.3.1
-    json-parse-even-better-errors: ^2.3.0
-    lines-and-columns: ^1.1.6
-  checksum: 62085b17d64da57f40f6afc2ac1f4d95def18c4323577e1eced571db75d9ab59b297d1d10582920f84b15985cbfc6b6d450ccbf317644cfa176f3ed982ad87e2
-  languageName: node
-  linkType: hard
-
-"parse5@npm:^7.1.2":
-  version: 7.1.2
-  resolution: "parse5@npm:7.1.2"
-  dependencies:
-    entities: ^4.4.0
-  checksum: 59465dd05eb4c5ec87b76173d1c596e152a10e290b7abcda1aecf0f33be49646ea74840c69af975d7887543ea45564801736356c568d6b5e71792fd0f4055713
+  resolution: "package-json-from-dist@npm:1.0.1"
+  checksum: 58ee9538f2f762988433da00e26acc788036914d57c71c246bf0be1b60cdbd77dd60b6a3e1a30465f0b248aeb80079e0b34cb6050b1dfa18c06953bb1cbc7602
   languageName: node
   linkType: hard
 
@@ -4698,13 +3440,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"path-is-absolute@npm:^1.0.0":
-  version: 1.0.1
-  resolution: "path-is-absolute@npm:1.0.1"
-  checksum: 060840f92cf8effa293bcc1bea81281bd7d363731d214cbe5c227df207c34cd727430f70c6037b5159c8a870b9157cba65e775446b0ab06fd5ecc7e54615a3b8
-  languageName: node
-  linkType: hard
-
 "path-key@npm:^3.0.0, path-key@npm:^3.1.0":
   version: 3.1.1
   resolution: "path-key@npm:3.1.1"
@@ -4712,23 +3447,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"path-parse@npm:^1.0.7":
-  version: 1.0.7
-  resolution: "path-parse@npm:1.0.7"
-  checksum: 49abf3d81115642938a8700ec580da6e830dde670be21893c62f4e10bd7dd4c3742ddc603fe24f898cba7eb0c6bc1777f8d9ac14185d34540c6d4d80cd9cae8a
-  languageName: node
-  linkType: hard
-
-"path-scurry@npm:^1.10.2":
-  version: 1.10.2
-  resolution: "path-scurry@npm:1.10.2"
-  dependencies:
-    lru-cache: ^10.2.0
-    minipass: ^5.0.0 || ^6.0.2 || ^7.0.0
-  checksum: 6739b4290f7d1a949c61c758b481c07ac7d1a841964c68cf5e1fa153d7e18cbde4872b37aadf9c5173c800d627f219c47945859159de36c977dd82419997b9b8
-  languageName: node
-  linkType: hard
-
 "path-scurry@npm:^1.11.1":
   version: 1.11.1
   resolution: "path-scurry@npm:1.11.1"
@@ -4739,13 +3457,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"path-type@npm:^4.0.0":
-  version: 4.0.0
-  resolution: "path-type@npm:4.0.0"
-  checksum: 5b1e2daa247062061325b8fdbfd1fb56dde0a448fb1455453276ea18c60685bdad23a445dc148cf87bc216be1573357509b7d4060494a6fd768c7efad833ee45
-  languageName: node
-  linkType: hard
-
 "pend@npm:~1.2.0":
   version: 1.2.0
   resolution: "pend@npm:1.2.0"
@@ -4767,17 +3478,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"picocolors@npm:^1.0.0":
-  version: 1.0.0
-  resolution: "picocolors@npm:1.0.0"
-  checksum: a2e8092dd86c8396bdba9f2b5481032848525b3dc295ce9b57896f931e63fc16f79805144321f72976383fc249584672a75cc18d6777c6b757603f372f745981
-  languageName: node
-  linkType: hard
-
-"picocolors@npm:^1.0.1":
-  version: 1.0.1
-  resolution: "picocolors@npm:1.0.1"
-  checksum: fa68166d1f56009fc02a34cdfd112b0dd3cf1ef57667ac57281f714065558c01828cdf4f18600ad6851cbe0093952ed0660b1e0156bddf2184b6aaf5817553a5
+"picocolors@npm:^1.0.0, picocolors@npm:^1.1.0, picocolors@npm:^1.1.1":
+  version: 1.1.1
+  resolution: "picocolors@npm:1.1.1"
+  checksum: e1cf46bf84886c79055fdfa9dcb3e4711ad259949e3565154b004b260cd356c5d54b31a1437ce9782624bf766272fe6b0154f5f0c744fb7af5d454d2b60db045
   languageName: node
   linkType: hard
 
@@ -4806,33 +3510,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"postcss-resolve-nested-selector@npm:^0.1.4":
-  version: 0.1.4
-  resolution: "postcss-resolve-nested-selector@npm:0.1.4"
-  checksum: 8de7abd1ae129233480ac123be243e2a722a4bb73fa54fb09cbbe6f10074fac960b9caadad8bc658982b96934080bd7b7f01b622ca7d5d78a25dc9c0532b17cb
-  languageName: node
-  linkType: hard
-
-"postcss-safe-parser@npm:^7.0.0":
-  version: 7.0.0
-  resolution: "postcss-safe-parser@npm:7.0.0"
-  peerDependencies:
-    postcss: ^8.4.31
-  checksum: dba4d782393e6f07339c24bdb8b41166e483d5e7b8f34174c35c64065aef36aadef94b53e0501d7a630d42f51bbd824671e8fb1c2b417333b08b71c9b0066c76
-  languageName: node
-  linkType: hard
-
-"postcss-selector-parser@npm:^6.1.1":
-  version: 6.1.1
-  resolution: "postcss-selector-parser@npm:6.1.1"
-  dependencies:
-    cssesc: ^3.0.0
-    util-deprecate: ^1.0.2
-  checksum: 1c6a5adfc3c19c6e1e7d94f8addb89a5166fcca72c41f11713043d381ecbe82ce66360c5524e904e17b54f7fc9e6a077994ff31238a456bc7320c3e02e88d92e
-  languageName: node
-  linkType: hard
-
-"postcss-value-parser@npm:^4.0.2, postcss-value-parser@npm:^4.2.0":
+"postcss-value-parser@npm:^4.0.2":
   version: 4.2.0
   resolution: "postcss-value-parser@npm:4.2.0"
   checksum: 819ffab0c9d51cf0acbabf8996dffbfafbafa57afc0e4c98db88b67f2094cb44488758f06e5da95d7036f19556a4a732525e84289a425f4f6fd8e412a9d7442f
@@ -4840,13 +3518,13 @@ __metadata:
   linkType: hard
 
 "postcss@npm:^8.4.40":
-  version: 8.4.41
-  resolution: "postcss@npm:8.4.41"
+  version: 8.4.49
+  resolution: "postcss@npm:8.4.49"
   dependencies:
     nanoid: ^3.3.7
-    picocolors: ^1.0.1
-    source-map-js: ^1.2.0
-  checksum: f865894929eb0f7fc2263811cc853c13b1c75103028b3f4f26df777e27b201f1abe21cb4aa4c2e901c80a04f6fb325ee22979688fe55a70e2ea82b0a517d3b6f
+    picocolors: ^1.1.1
+    source-map-js: ^1.2.1
+  checksum: eb5d6cbdca24f50399aafa5d2bea489e4caee4c563ea1edd5a2485bc5f84e9ceef3febf170272bc83a99c31d23a316ad179213e853f34c2a7a8ffa534559d63a
   languageName: node
   linkType: hard
 
@@ -4857,14 +3535,14 @@ __metadata:
   languageName: node
   linkType: hard
 
-"proc-log@npm:^3.0.0":
-  version: 3.0.0
-  resolution: "proc-log@npm:3.0.0"
-  checksum: 02b64e1b3919e63df06f836b98d3af002b5cd92655cab18b5746e37374bfb73e03b84fe305454614b34c25b485cc687a9eebdccf0242cda8fda2475dd2c97e02
+"proc-log@npm:^5.0.0":
+  version: 5.0.0
+  resolution: "proc-log@npm:5.0.0"
+  checksum: c78b26ecef6d5cce4a7489a1e9923d7b4b1679028c8654aef0463b27f4a90b0946cd598f55799da602895c52feb085ec76381d007ab8dcceebd40b89c2f9dfe0
   languageName: node
   linkType: hard
 
-"process@npm:0.11.10, process@npm:^0.11.10":
+"process@npm:^0.11.10":
   version: 0.11.10
   resolution: "process@npm:0.11.10"
   checksum: bfcce49814f7d172a6e6a14d5fa3ac92cc3d0c3b9feb1279774708a719e19acd673995226351a082a9ae99978254e320ccda4240ddc474ba31a76c79491ca7c3
@@ -4888,59 +3566,22 @@ __metadata:
   languageName: node
   linkType: hard
 
-"psl@npm:^1.1.33":
-  version: 1.9.0
-  resolution: "psl@npm:1.9.0"
-  checksum: 20c4277f640c93d393130673f392618e9a8044c6c7bf61c53917a0fddb4952790f5f362c6c730a9c32b124813e173733f9895add8d26f566ed0ea0654b2e711d
-  languageName: node
-  linkType: hard
-
 "pump@npm:^3.0.0":
-  version: 3.0.0
-  resolution: "pump@npm:3.0.0"
+  version: 3.0.2
+  resolution: "pump@npm:3.0.2"
   dependencies:
     end-of-stream: ^1.1.0
     once: ^1.3.1
-  checksum: e42e9229fba14732593a718b04cb5e1cfef8254544870997e0ecd9732b189a48e1256e4e5478148ecb47c8511dca2b09eae56b4d0aad8009e6fac8072923cfc9
-  languageName: node
-  linkType: hard
-
-"punycode@npm:^2.1.0, punycode@npm:^2.1.1, punycode@npm:^2.3.1":
-  version: 2.3.1
-  resolution: "punycode@npm:2.3.1"
-  checksum: bb0a0ceedca4c3c57a9b981b90601579058903c62be23c5e8e843d2c2d4148a3ecf029d5133486fb0e1822b098ba8bba09e89d6b21742d02fa26bda6441a6fb2
-  languageName: node
-  linkType: hard
-
-"qs@npm:6.10.4":
-  version: 6.10.4
-  resolution: "qs@npm:6.10.4"
-  dependencies:
-    side-channel: ^1.0.4
-  checksum: 31e4fedd759d01eae52dde6692abab175f9af3e639993c5caaa513a2a3607b34d8058d3ae52ceeccf37c3025f22ed5e90e9ddd6c2537e19c0562ddd10dc5b1eb
+  checksum: e0c4216874b96bd25ddf31a0b61a5613e26cc7afa32379217cf39d3915b0509def3565f5f6968fafdad2894c8bbdbd67d340e84f3634b2a29b950cffb6442d9f
   languageName: node
   linkType: hard
 
-"qs@npm:^6.4.0":
-  version: 6.12.0
-  resolution: "qs@npm:6.12.0"
+"qs@npm:6.13.1, qs@npm:^6.4.0":
+  version: 6.13.1
+  resolution: "qs@npm:6.13.1"
   dependencies:
     side-channel: ^1.0.6
-  checksum: ba007fb2488880b9c6c3df356fe6888b9c1f4c5127552edac214486cfe83a332de09a5c40d490d79bb27bef977ba1085a8497512ff52eaac72e26564f77ce908
-  languageName: node
-  linkType: hard
-
-"querystringify@npm:^2.1.1":
-  version: 2.2.0
-  resolution: "querystringify@npm:2.2.0"
-  checksum: 5641ea231bad7ef6d64d9998faca95611ed4b11c2591a8cae741e178a974f6a8e0ebde008475259abe1621cb15e692404e6b6626e927f7b849d5c09392604b15
-  languageName: node
-  linkType: hard
-
-"queue-microtask@npm:^1.2.2":
-  version: 1.2.3
-  resolution: "queue-microtask@npm:1.2.3"
-  checksum: b676f8c040cdc5b12723ad2f91414d267605b26419d5c821ff03befa817ddd10e238d22b25d604920340fd73efd8ba795465a0377c4adf45a4a41e4234e42dc4
+  checksum: 86c5059146955fab76624e95771031541328c171b1d63d48a7ac3b1fdffe262faf8bc5fcadc1684e6f3da3ec87a8dedc8c0009792aceb20c5e94dc34cf468bb9
   languageName: node
   linkType: hard
 
@@ -4956,16 +3597,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"react-hooks-shareable@npm:1.53.0":
-  version: 1.53.0
-  resolution: "react-hooks-shareable@npm:1.53.0"
-  peerDependencies:
-    "@juggle/resize-observer": ^3.4.0
-    react: ^17.0.2 || ^18.0.0
-  checksum: 1c088018e1f5fcf6e61e5f275bb6d244673db836f9a8ebe544797161a4ecb46778f0524e0ca3828f6e3c65b8449c60a61bb945effa03033ea4894cb61309d7e7
-  languageName: node
-  linkType: hard
-
 "react-is@npm:18.3.1":
   version: 18.3.1
   resolution: "react-is@npm:18.3.1"
@@ -4996,59 +3627,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"read-installed@npm:~4.0.3":
-  version: 4.0.3
-  resolution: "read-installed@npm:4.0.3"
-  dependencies:
-    debuglog: ^1.0.1
-    graceful-fs: ^4.1.2
-    read-package-json: ^2.0.0
-    readdir-scoped-modules: ^1.0.0
-    semver: 2 || 3 || 4 || 5
-    slide: ~1.1.3
-    util-extend: ^1.0.1
-  dependenciesMeta:
-    graceful-fs:
-      optional: true
-  checksum: 44429cd2085a294184bfb83fbd06558815a6c84ce23df28e66c63feb605cb87438b5aa93d3bc7c70679e2bd3ae774b59c2d13a39e8ea993629759eef2bd9e2fa
-  languageName: node
-  linkType: hard
-
-"read-package-json@npm:^2.0.0":
-  version: 2.1.2
-  resolution: "read-package-json@npm:2.1.2"
-  dependencies:
-    glob: ^7.1.1
-    json-parse-even-better-errors: ^2.3.0
-    normalize-package-data: ^2.0.0
-    npm-normalize-package-bin: ^1.0.0
-  checksum: 56a2642851e9321a68e1708263944bf5ab8a2c172daf3f13f18aad32fbe2f2ba516935b068c93771d9671012aec4596962c20417aca8b5e73501bc647691337a
-  languageName: node
-  linkType: hard
-
-"readable-stream@npm:^3.4.0, readable-stream@npm:^3.5.0":
-  version: 3.6.2
-  resolution: "readable-stream@npm:3.6.2"
-  dependencies:
-    inherits: ^2.0.3
-    string_decoder: ^1.1.1
-    util-deprecate: ^1.0.1
-  checksum: bdcbe6c22e846b6af075e32cf8f4751c2576238c5043169a1c221c92ee2878458a816a4ea33f4c67623c0b6827c8a400409bfb3cf0bf3381392d0b1dfb52ac8d
-  languageName: node
-  linkType: hard
-
-"readdir-scoped-modules@npm:^1.0.0":
-  version: 1.1.0
-  resolution: "readdir-scoped-modules@npm:1.1.0"
-  dependencies:
-    debuglog: ^1.0.1
-    dezalgo: ^1.0.0
-    graceful-fs: ^4.1.2
-    once: ^1.3.0
-  checksum: 6d9f334e40dfd0f5e4a8aab5e67eb460c95c85083c690431f87ab2c9135191170e70c2db6d71afcafb78e073d23eb95dcb3fc33ef91308f6ebfe3197be35e608
-  languageName: node
-  linkType: hard
-
 "request-progress@npm:^3.0.0":
   version: 3.0.0
   resolution: "request-progress@npm:3.0.0"
@@ -5065,13 +3643,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"require-from-string@npm:^2.0.2":
-  version: 2.0.2
-  resolution: "require-from-string@npm:2.0.2"
-  checksum: a03ef6895445f33a4015300c426699bc66b2b044ba7b670aa238610381b56d3f07c686251740d575e22f4c87531ba662d06937508f0f3c0f1ddc04db3130560b
-  languageName: node
-  linkType: hard
-
 "requires-port@npm:^1.0.0":
   version: 1.0.0
   resolution: "requires-port@npm:1.0.0"
@@ -5079,53 +3650,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"resolve-from@npm:^4.0.0":
-  version: 4.0.0
-  resolution: "resolve-from@npm:4.0.0"
-  checksum: f4ba0b8494846a5066328ad33ef8ac173801a51739eb4d63408c847da9a2e1c1de1e6cbbf72699211f3d13f8fc1325648b169bd15eb7da35688e30a5fb0e4a7f
-  languageName: node
-  linkType: hard
-
-"resolve-from@npm:^5.0.0":
-  version: 5.0.0
-  resolution: "resolve-from@npm:5.0.0"
-  checksum: 4ceeb9113e1b1372d0cd969f3468fa042daa1dd9527b1b6bb88acb6ab55d8b9cd65dbf18819f9f9ddf0db804990901dcdaade80a215e7b2c23daae38e64f5bdf
-  languageName: node
-  linkType: hard
-
-"resolve-pkg-maps@npm:^1.0.0":
-  version: 1.0.0
-  resolution: "resolve-pkg-maps@npm:1.0.0"
-  checksum: 1012afc566b3fdb190a6309cc37ef3b2dcc35dff5fa6683a9d00cd25c3247edfbc4691b91078c97adc82a29b77a2660c30d791d65dab4fc78bfc473f60289977
-  languageName: node
-  linkType: hard
-
-"resolve@npm:^1.10.0":
-  version: 1.22.8
-  resolution: "resolve@npm:1.22.8"
-  dependencies:
-    is-core-module: ^2.13.0
-    path-parse: ^1.0.7
-    supports-preserve-symlinks-flag: ^1.0.0
-  bin:
-    resolve: bin/resolve
-  checksum: f8a26958aa572c9b064562750b52131a37c29d072478ea32e129063e2da7f83e31f7f11e7087a18225a8561cfe8d2f0df9dbea7c9d331a897571c0a2527dbb4c
-  languageName: node
-  linkType: hard
-
-"resolve@patch:resolve@^1.10.0#~builtin":
-  version: 1.22.8
-  resolution: "resolve@patch:resolve@npm%3A1.22.8#~builtin::version=1.22.8&hash=c3c19d"
-  dependencies:
-    is-core-module: ^2.13.0
-    path-parse: ^1.0.7
-    supports-preserve-symlinks-flag: ^1.0.0
-  bin:
-    resolve: bin/resolve
-  checksum: 5479b7d431cacd5185f8db64bfcb7286ae5e31eb299f4c4f404ad8aa6098b77599563ac4257cb2c37a42f59dfc06a1bec2bcf283bb448f319e37f0feb9a09847
-  languageName: node
-  linkType: hard
-
 "restore-cursor@npm:^3.1.0":
   version: 3.1.0
   resolution: "restore-cursor@npm:3.1.0"
@@ -5143,40 +3667,48 @@ __metadata:
   languageName: node
   linkType: hard
 
-"reusify@npm:^1.0.4":
-  version: 1.0.4
-  resolution: "reusify@npm:1.0.4"
-  checksum: c3076ebcc22a6bc252cb0b9c77561795256c22b757f40c0d8110b1300723f15ec0fc8685e8d4ea6d7666f36c79ccc793b1939c748bf36f18f542744a4e379fcc
+"rfdc@npm:^1.3.0":
+  version: 1.4.1
+  resolution: "rfdc@npm:1.4.1"
+  checksum: 3b05bd55062c1d78aaabfcea43840cdf7e12099968f368e9a4c3936beb744adb41cbdb315eac6d4d8c6623005d6f87fdf16d8a10e1ff3722e84afea7281c8d13
   languageName: node
   linkType: hard
 
-"rfdc@npm:^1.3.0":
-  version: 1.3.1
-  resolution: "rfdc@npm:1.3.1"
-  checksum: d5d1e930aeac7e0e0a485f97db1356e388bdbeff34906d206fe524dd5ada76e95f186944d2e68307183fdc39a54928d4426bbb6734851692cfe9195efba58b79
+"rimraf@npm:^5.0.5":
+  version: 5.0.10
+  resolution: "rimraf@npm:5.0.10"
+  dependencies:
+    glob: ^10.3.7
+  bin:
+    rimraf: dist/esm/bin.mjs
+  checksum: 50e27388dd2b3fa6677385fc1e2966e9157c89c86853b96d02e6915663a96b7ff4d590e14f6f70e90f9b554093aa5dbc05ac3012876be558c06a65437337bc05
   languageName: node
   linkType: hard
 
 "rollup@npm:^4.13.0":
-  version: 4.14.0
-  resolution: "rollup@npm:4.14.0"
-  dependencies:
-    "@rollup/rollup-android-arm-eabi": 4.14.0
-    "@rollup/rollup-android-arm64": 4.14.0
-    "@rollup/rollup-darwin-arm64": 4.14.0
-    "@rollup/rollup-darwin-x64": 4.14.0
-    "@rollup/rollup-linux-arm-gnueabihf": 4.14.0
-    "@rollup/rollup-linux-arm64-gnu": 4.14.0
-    "@rollup/rollup-linux-arm64-musl": 4.14.0
-    "@rollup/rollup-linux-powerpc64le-gnu": 4.14.0
-    "@rollup/rollup-linux-riscv64-gnu": 4.14.0
-    "@rollup/rollup-linux-s390x-gnu": 4.14.0
-    "@rollup/rollup-linux-x64-gnu": 4.14.0
-    "@rollup/rollup-linux-x64-musl": 4.14.0
-    "@rollup/rollup-win32-arm64-msvc": 4.14.0
-    "@rollup/rollup-win32-ia32-msvc": 4.14.0
-    "@rollup/rollup-win32-x64-msvc": 4.14.0
-    "@types/estree": 1.0.5
+  version: 4.30.1
+  resolution: "rollup@npm:4.30.1"
+  dependencies:
+    "@rollup/rollup-android-arm-eabi": 4.30.1
+    "@rollup/rollup-android-arm64": 4.30.1
+    "@rollup/rollup-darwin-arm64": 4.30.1
+    "@rollup/rollup-darwin-x64": 4.30.1
+    "@rollup/rollup-freebsd-arm64": 4.30.1
+    "@rollup/rollup-freebsd-x64": 4.30.1
+    "@rollup/rollup-linux-arm-gnueabihf": 4.30.1
+    "@rollup/rollup-linux-arm-musleabihf": 4.30.1
+    "@rollup/rollup-linux-arm64-gnu": 4.30.1
+    "@rollup/rollup-linux-arm64-musl": 4.30.1
+    "@rollup/rollup-linux-loongarch64-gnu": 4.30.1
+    "@rollup/rollup-linux-powerpc64le-gnu": 4.30.1
+    "@rollup/rollup-linux-riscv64-gnu": 4.30.1
+    "@rollup/rollup-linux-s390x-gnu": 4.30.1
+    "@rollup/rollup-linux-x64-gnu": 4.30.1
+    "@rollup/rollup-linux-x64-musl": 4.30.1
+    "@rollup/rollup-win32-arm64-msvc": 4.30.1
+    "@rollup/rollup-win32-ia32-msvc": 4.30.1
+    "@rollup/rollup-win32-x64-msvc": 4.30.1
+    "@types/estree": 1.0.6
     fsevents: ~2.3.2
   dependenciesMeta:
     "@rollup/rollup-android-arm-eabi":
@@ -5187,12 +3719,20 @@ __metadata:
       optional: true
     "@rollup/rollup-darwin-x64":
       optional: true
+    "@rollup/rollup-freebsd-arm64":
+      optional: true
+    "@rollup/rollup-freebsd-x64":
+      optional: true
     "@rollup/rollup-linux-arm-gnueabihf":
       optional: true
+    "@rollup/rollup-linux-arm-musleabihf":
+      optional: true
     "@rollup/rollup-linux-arm64-gnu":
       optional: true
     "@rollup/rollup-linux-arm64-musl":
       optional: true
+    "@rollup/rollup-linux-loongarch64-gnu":
+      optional: true
     "@rollup/rollup-linux-powerpc64le-gnu":
       optional: true
     "@rollup/rollup-linux-riscv64-gnu":
@@ -5213,30 +3753,7 @@ __metadata:
       optional: true
   bin:
     rollup: dist/bin/rollup
-  checksum: 1d005c7ae85e7d960e517e7ccbb47e219310fafef904f8717da6c9d354938502c241b7623bc3615abecb01ecb4e8a3fdd55917c70c9c97741f585547b142663e
-  languageName: node
-  linkType: hard
-
-"rrweb-cssom@npm:^0.6.0":
-  version: 0.6.0
-  resolution: "rrweb-cssom@npm:0.6.0"
-  checksum: 182312f6e4f41d18230ccc34f14263bc8e8a6b9d30ee3ec0d2d8e643c6f27964cd7a8d638d4a00e988d93e8dc55369f4ab5a473ccfeff7a8bab95b36d2b5499c
-  languageName: node
-  linkType: hard
-
-"rrweb-cssom@npm:^0.7.1":
-  version: 0.7.1
-  resolution: "rrweb-cssom@npm:0.7.1"
-  checksum: 62e410ddbaaba6abc196c3bbfa8de4952e0a134d9f2b454ee293039bf9931322d806e14d52ed122a5c2bd332a868b9da2e99358fb6232c33758b5ede86d992c8
-  languageName: node
-  linkType: hard
-
-"run-parallel@npm:^1.1.9":
-  version: 1.2.0
-  resolution: "run-parallel@npm:1.2.0"
-  dependencies:
-    queue-microtask: ^1.2.2
-  checksum: cb4f97ad25a75ebc11a8ef4e33bb962f8af8516bb2001082ceabd8902e15b98f4b84b4f8a9b222e5d57fc3bd1379c483886ed4619367a7680dad65316993021d
+  checksum: 4a3df04dc639f36cb2d7746c829c4957a3df54b449171280a108c32c4f578677207f330e358c48637d7414ef30c1542964641c82bebc0643d5d5baee4044542e
   languageName: node
   linkType: hard
 
@@ -5265,7 +3782,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.2, safe-buffer@npm:~5.2.0":
+"safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.2":
   version: 5.2.1
   resolution: "safe-buffer@npm:5.2.1"
   checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491
@@ -5279,15 +3796,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"saxes@npm:^6.0.0":
-  version: 6.0.0
-  resolution: "saxes@npm:6.0.0"
-  dependencies:
-    xmlchars: ^2.2.0
-  checksum: d3fa3e2aaf6c65ed52ee993aff1891fc47d5e47d515164b5449cbf5da2cbdc396137e55590472e64c5c436c14ae64a8a03c29b9e7389fc6f14035cf4e982ef3b
-  languageName: node
-  linkType: hard
-
 "scheduler@npm:^0.23.2":
   version: 0.23.2
   resolution: "scheduler@npm:0.23.2"
@@ -5304,16 +3812,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"semver@npm:2 || 3 || 4 || 5, semver@npm:^5.5.0":
-  version: 5.7.2
-  resolution: "semver@npm:5.7.2"
-  bin:
-    semver: bin/semver
-  checksum: fb4ab5e0dd1c22ce0c937ea390b4a822147a9c53dbd2a9a0132f12fe382902beef4fbf12cf51bb955248d8d15874ce8cd89532569756384f994309825f10b686
-  languageName: node
-  linkType: hard
-
-"semver@npm:7.6.3":
+"semver@npm:7.6.3, semver@npm:^7.3.5, semver@npm:^7.5.3":
   version: 7.6.3
   resolution: "semver@npm:7.6.3"
   bin:
@@ -5331,31 +3830,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"semver@npm:^7.3.5, semver@npm:^7.5.3":
-  version: 7.6.0
-  resolution: "semver@npm:7.6.0"
-  dependencies:
-    lru-cache: ^6.0.0
-  bin:
-    semver: bin/semver.js
-  checksum: 7427f05b70786c696640edc29fdd4bc33b2acf3bbe1740b955029044f80575fc664e1a512e4113c3af21e767154a94b4aa214bf6cd6e42a1f6dba5914e0b208c
-  languageName: node
-  linkType: hard
-
-"set-function-length@npm:^1.2.1":
-  version: 1.2.2
-  resolution: "set-function-length@npm:1.2.2"
-  dependencies:
-    define-data-property: ^1.1.4
-    es-errors: ^1.3.0
-    function-bind: ^1.1.2
-    get-intrinsic: ^1.2.4
-    gopd: ^1.0.1
-    has-property-descriptors: ^1.0.2
-  checksum: a8248bdacdf84cb0fab4637774d9fb3c7a8e6089866d04c817583ff48e14149c87044ce683d7f50759a8c50fb87c7a7e173535b06169c87ef76f5fb276dfff72
-  languageName: node
-  linkType: hard
-
 "shallowequal@npm:^1.1.0":
   version: 1.1.0
   resolution: "shallowequal@npm:1.1.0"
@@ -5379,15 +3853,51 @@ __metadata:
   languageName: node
   linkType: hard
 
-"side-channel@npm:^1.0.4, side-channel@npm:^1.0.6":
-  version: 1.0.6
-  resolution: "side-channel@npm:1.0.6"
+"side-channel-list@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "side-channel-list@npm:1.0.0"
+  dependencies:
+    es-errors: ^1.3.0
+    object-inspect: ^1.13.3
+  checksum: 603b928997abd21c5a5f02ae6b9cc36b72e3176ad6827fab0417ead74580cc4fb4d5c7d0a8a2ff4ead34d0f9e35701ed7a41853dac8a6d1a664fcce1a044f86f
+  languageName: node
+  linkType: hard
+
+"side-channel-map@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "side-channel-map@npm:1.0.1"
+  dependencies:
+    call-bound: ^1.0.2
+    es-errors: ^1.3.0
+    get-intrinsic: ^1.2.5
+    object-inspect: ^1.13.3
+  checksum: 42501371cdf71f4ccbbc9c9e2eb00aaaab80a4c1c429d5e8da713fd4d39ef3b8d4a4b37ed4f275798a65260a551a7131fd87fe67e922dba4ac18586d6aab8b06
+  languageName: node
+  linkType: hard
+
+"side-channel-weakmap@npm:^1.0.2":
+  version: 1.0.2
+  resolution: "side-channel-weakmap@npm:1.0.2"
+  dependencies:
+    call-bound: ^1.0.2
+    es-errors: ^1.3.0
+    get-intrinsic: ^1.2.5
+    object-inspect: ^1.13.3
+    side-channel-map: ^1.0.1
+  checksum: a815c89bc78c5723c714ea1a77c938377ea710af20d4fb886d362b0d1f8ac73a17816a5f6640f354017d7e292a43da9c5e876c22145bac00b76cfb3468001736
+  languageName: node
+  linkType: hard
+
+"side-channel@npm:^1.0.6":
+  version: 1.1.0
+  resolution: "side-channel@npm:1.1.0"
   dependencies:
-    call-bind: ^1.0.7
     es-errors: ^1.3.0
-    get-intrinsic: ^1.2.4
-    object-inspect: ^1.13.1
-  checksum: bfc1afc1827d712271453e91b7cd3878ac0efd767495fd4e594c4c2afaa7963b7b510e249572bfd54b0527e66e4a12b61b80c061389e129755f34c493aad9b97
+    object-inspect: ^1.13.3
+    side-channel-list: ^1.0.0
+    side-channel-map: ^1.0.1
+    side-channel-weakmap: ^1.0.2
+  checksum: bf73d6d6682034603eb8e99c63b50155017ed78a522d27c2acec0388a792c3ede3238b878b953a08157093b85d05797217d270b7666ba1f111345fbe933380ff
   languageName: node
   linkType: hard
 
@@ -5405,13 +3915,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"slash@npm:^3.0.0":
-  version: 3.0.0
-  resolution: "slash@npm:3.0.0"
-  checksum: 94a93fff615f25a999ad4b83c9d5e257a7280c90a32a7cb8b4a87996e4babf322e469c42b7f649fd5796edd8687652f3fb452a86dc97a816f01113183393f11c
-  languageName: node
-  linkType: hard
-
 "slice-ansi@npm:^3.0.0":
   version: 3.0.0
   resolution: "slice-ansi@npm:3.0.0"
@@ -5434,13 +3937,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"slide@npm:~1.1.3":
-  version: 1.1.6
-  resolution: "slide@npm:1.1.6"
-  checksum: 5768635d227172e215b7a1a91d32f8781f5783b4961feaaf3d536bbf83cc51878928c137508cde7659fea6d7c04074927cab982731302771ee0051518ff24896
-  languageName: node
-  linkType: hard
-
 "smart-buffer@npm:^4.2.0":
   version: 4.2.0
   resolution: "smart-buffer@npm:4.2.0"
@@ -5448,94 +3944,31 @@ __metadata:
   languageName: node
   linkType: hard
 
-"socks-proxy-agent@npm:^8.0.1":
-  version: 8.0.3
-  resolution: "socks-proxy-agent@npm:8.0.3"
+"socks-proxy-agent@npm:^8.0.3":
+  version: 8.0.5
+  resolution: "socks-proxy-agent@npm:8.0.5"
   dependencies:
-    agent-base: ^7.1.1
+    agent-base: ^7.1.2
     debug: ^4.3.4
-    socks: ^2.7.1
-  checksum: 8fab38821c327c190c28f1658087bc520eb065d55bc07b4a0fdf8d1e0e7ad5d115abbb22a95f94f944723ea969dd771ad6416b1e3cde9060c4c71f705c8b85c5
+    socks: ^2.8.3
+  checksum: b4fbcdb7ad2d6eec445926e255a1fb95c975db0020543fbac8dfa6c47aecc6b3b619b7fb9c60a3f82c9b2969912a5e7e174a056ae4d98cb5322f3524d6036e1d
   languageName: node
   linkType: hard
 
-"socks@npm:^2.7.1":
-  version: 2.8.1
-  resolution: "socks@npm:2.8.1"
+"socks@npm:^2.8.3":
+  version: 2.8.3
+  resolution: "socks@npm:2.8.3"
   dependencies:
     ip-address: ^9.0.5
     smart-buffer: ^4.2.0
-  checksum: 29586d42e9c36c5016632b2bcb6595e3adfbcb694b3a652c51bc8741b079c5ec37bdd5675a1a89a1620078c8137208294991fabb50786f92d47759a725b2b62e
-  languageName: node
-  linkType: hard
-
-"source-map-js@npm:^1.0.1, source-map-js@npm:^1.2.0":
-  version: 1.2.0
-  resolution: "source-map-js@npm:1.2.0"
-  checksum: 791a43306d9223792e84293b00458bf102a8946e7188f3db0e4e22d8d530b5f80a4ce468eb5ec0bf585443ad55ebbd630bf379c98db0b1f317fd902500217f97
-  languageName: node
-  linkType: hard
-
-"spdx-compare@npm:^1.0.0":
-  version: 1.0.0
-  resolution: "spdx-compare@npm:1.0.0"
-  dependencies:
-    array-find-index: ^1.0.2
-    spdx-expression-parse: ^3.0.0
-    spdx-ranges: ^2.0.0
-  checksum: 7d8b55b31163ba8e7abeaf69d8d7accba5aee324dd55e22a796a685ec4d5e3c3cbc2683b9a2edff5543ee6f6242f4ec22c15dc2e493eb807690fb65e1051e5eb
-  languageName: node
-  linkType: hard
-
-"spdx-correct@npm:^3.0.0":
-  version: 3.2.0
-  resolution: "spdx-correct@npm:3.2.0"
-  dependencies:
-    spdx-expression-parse: ^3.0.0
-    spdx-license-ids: ^3.0.0
-  checksum: e9ae98d22f69c88e7aff5b8778dc01c361ef635580e82d29e5c60a6533cc8f4d820803e67d7432581af0cc4fb49973125076ee3b90df191d153e223c004193b2
+  checksum: 7a6b7f6eedf7482b9e4597d9a20e09505824208006ea8f2c49b71657427f3c137ca2ae662089baa73e1971c62322d535d9d0cf1c9235cf6f55e315c18203eadd
   languageName: node
   linkType: hard
 
-"spdx-exceptions@npm:^2.1.0":
-  version: 2.5.0
-  resolution: "spdx-exceptions@npm:2.5.0"
-  checksum: bb127d6e2532de65b912f7c99fc66097cdea7d64c10d3ec9b5e96524dbbd7d20e01cba818a6ddb2ae75e62bb0c63d5e277a7e555a85cbc8ab40044984fa4ae15
-  languageName: node
-  linkType: hard
-
-"spdx-expression-parse@npm:^3.0.0":
-  version: 3.0.1
-  resolution: "spdx-expression-parse@npm:3.0.1"
-  dependencies:
-    spdx-exceptions: ^2.1.0
-    spdx-license-ids: ^3.0.0
-  checksum: a1c6e104a2cbada7a593eaa9f430bd5e148ef5290d4c0409899855ce8b1c39652bcc88a725259491a82601159d6dc790bedefc9016c7472f7de8de7361f8ccde
-  languageName: node
-  linkType: hard
-
-"spdx-license-ids@npm:^3.0.0":
-  version: 3.0.17
-  resolution: "spdx-license-ids@npm:3.0.17"
-  checksum: 0aba5d16292ff604dd20982200e23b4d425f6ba364765039bdbde2f6c956b9909fce1ad040a897916a5f87388e85e001f90cb64bf706b6e319f3908cfc445a59
-  languageName: node
-  linkType: hard
-
-"spdx-ranges@npm:^2.0.0":
-  version: 2.1.1
-  resolution: "spdx-ranges@npm:2.1.1"
-  checksum: f807bd915aa2975bcebd9c4b4805661f248efcd4953ee62557626452fcd2933183f5b1a307d65507d8be6b9519b4e46dce05b61db0fbd5fce253b8f6d69bbbad
-  languageName: node
-  linkType: hard
-
-"spdx-satisfies@npm:^4.0.0":
-  version: 4.0.1
-  resolution: "spdx-satisfies@npm:4.0.1"
-  dependencies:
-    spdx-compare: ^1.0.0
-    spdx-expression-parse: ^3.0.0
-    spdx-ranges: ^2.0.0
-  checksum: a44c3665b1eb991285b6c1019d97a3df43290b39436e7a914dc99498580039cfe7d20e0680856c1d420ffa365fd7de3c39ee534abd3130a6fec6c2bd24259cca
+"source-map-js@npm:^1.2.1":
+  version: 1.2.1
+  resolution: "source-map-js@npm:1.2.1"
+  checksum: 4eb0cd997cdf228bc253bcaff9340afeb706176e64868ecd20efbe6efea931465f43955612346d6b7318789e5265bdc419bc7669c1cebe3db0eb255f57efa76b
   languageName: node
   linkType: hard
 
@@ -5546,7 +3979,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"sshpk@npm:^1.14.1":
+"sshpk@npm:^1.18.0":
   version: 1.18.0
   resolution: "sshpk@npm:1.18.0"
   dependencies:
@@ -5567,22 +4000,12 @@ __metadata:
   languageName: node
   linkType: hard
 
-"ssri@npm:^10.0.0":
-  version: 10.0.5
-  resolution: "ssri@npm:10.0.5"
+"ssri@npm:^12.0.0":
+  version: 12.0.0
+  resolution: "ssri@npm:12.0.0"
   dependencies:
     minipass: ^7.0.3
-  checksum: 0a31b65f21872dea1ed3f7c200d7bc1c1b91c15e419deca14f282508ba917cbb342c08a6814c7f68ca4ca4116dd1a85da2bbf39227480e50125a1ceffeecb750
-  languageName: node
-  linkType: hard
-
-"stream-browserify@npm:3.0.0":
-  version: 3.0.0
-  resolution: "stream-browserify@npm:3.0.0"
-  dependencies:
-    inherits: ~2.0.4
-    readable-stream: ^3.5.0
-  checksum: 4c47ef64d6f03815a9ca3874e2319805e8e8a85f3550776c47ce523b6f4c6cd57f40e46ec6a9ab8ad260fde61863c2718f250d3bedb3fe9052444eb9abfd9921
+  checksum: ef4b6b0ae47b4a69896f5f1c4375f953b9435388c053c36d27998bc3d73e046969ccde61ab659e679142971a0b08e50478a1228f62edb994105b280f17900c98
   languageName: node
   linkType: hard
 
@@ -5608,15 +4031,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"string_decoder@npm:^1.1.1":
-  version: 1.3.0
-  resolution: "string_decoder@npm:1.3.0"
-  dependencies:
-    safe-buffer: ~5.2.0
-  checksum: 8417646695a66e73aefc4420eb3b84cc9ffd89572861fe004e6aeb13c7bc00e2f616247505d2dbbef24247c372f70268f594af7126f43548565c68c117bdeb56
-  languageName: node
-  linkType: hard
-
 "strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1":
   version: 6.0.1
   resolution: "strip-ansi@npm:6.0.1"
@@ -5626,7 +4040,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.0":
+"strip-ansi@npm:^7.0.1":
   version: 7.1.0
   resolution: "strip-ansi@npm:7.1.0"
   dependencies:
@@ -5664,56 +4078,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"stylelint@npm:16.8.1":
-  version: 16.8.1
-  resolution: "stylelint@npm:16.8.1"
-  dependencies:
-    "@csstools/css-parser-algorithms": ^2.7.1
-    "@csstools/css-tokenizer": ^2.4.1
-    "@csstools/media-query-list-parser": ^2.1.13
-    "@csstools/selector-specificity": ^3.1.1
-    "@dual-bundle/import-meta-resolve": ^4.1.0
-    balanced-match: ^2.0.0
-    colord: ^2.9.3
-    cosmiconfig: ^9.0.0
-    css-functions-list: ^3.2.2
-    css-tree: ^2.3.1
-    debug: ^4.3.6
-    fast-glob: ^3.3.2
-    fastest-levenshtein: ^1.0.16
-    file-entry-cache: ^9.0.0
-    global-modules: ^2.0.0
-    globby: ^11.1.0
-    globjoin: ^0.1.4
-    html-tags: ^3.3.1
-    ignore: ^5.3.1
-    imurmurhash: ^0.1.4
-    is-plain-object: ^5.0.0
-    known-css-properties: ^0.34.0
-    mathml-tag-names: ^2.1.3
-    meow: ^13.2.0
-    micromatch: ^4.0.7
-    normalize-path: ^3.0.0
-    picocolors: ^1.0.1
-    postcss: ^8.4.40
-    postcss-resolve-nested-selector: ^0.1.4
-    postcss-safe-parser: ^7.0.0
-    postcss-selector-parser: ^6.1.1
-    postcss-value-parser: ^4.2.0
-    resolve-from: ^5.0.0
-    string-width: ^4.2.3
-    strip-ansi: ^7.1.0
-    supports-hyperlinks: ^3.0.0
-    svg-tags: ^1.0.0
-    table: ^6.8.2
-    write-file-atomic: ^5.0.1
-  bin:
-    stylelint: bin/stylelint.mjs
-  checksum: 4fe1695901b15b04b25b038835901803e2b0af5cd79c516f99c69c7bb2904807c1a6d4824a677c2a89b75a7a318e82348aa29bd2ddd855696f5ea35acb77c3c1
-  languageName: node
-  linkType: hard
-
-"supports-color@npm:^5.3.0, supports-color@npm:^5.5.0":
+"supports-color@npm:^5.5.0":
   version: 5.5.0
   resolution: "supports-color@npm:5.5.0"
   dependencies:
@@ -5722,7 +4087,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"supports-color@npm:^7.0.0, supports-color@npm:^7.1.0":
+"supports-color@npm:^7.1.0":
   version: 7.2.0
   resolution: "supports-color@npm:7.2.0"
   dependencies:
@@ -5740,61 +4105,17 @@ __metadata:
   languageName: node
   linkType: hard
 
-"supports-hyperlinks@npm:^3.0.0":
-  version: 3.0.0
-  resolution: "supports-hyperlinks@npm:3.0.0"
+"tar@npm:^7.4.3":
+  version: 7.4.3
+  resolution: "tar@npm:7.4.3"
   dependencies:
-    has-flag: ^4.0.0
-    supports-color: ^7.0.0
-  checksum: 41021305de5255b10d821bf93c7a781f783e1693d0faec293d7fc7ccf17011b90bde84b0295fa92ba75c6c390351fe84fdd18848cad4bf656e464a958243c3e7
-  languageName: node
-  linkType: hard
-
-"supports-preserve-symlinks-flag@npm:^1.0.0":
-  version: 1.0.0
-  resolution: "supports-preserve-symlinks-flag@npm:1.0.0"
-  checksum: 53b1e247e68e05db7b3808b99b892bd36fb096e6fba213a06da7fab22045e97597db425c724f2bbd6c99a3c295e1e73f3e4de78592289f38431049e1277ca0ae
-  languageName: node
-  linkType: hard
-
-"svg-tags@npm:^1.0.0":
-  version: 1.0.0
-  resolution: "svg-tags@npm:1.0.0"
-  checksum: 407e5ef87cfa2fb81c61d738081c2decd022ce13b922d035b214b49810630bf5d1409255a4beb3a940b77b32f6957806deff16f1bf0ce1ab11c7a184115a0b7f
-  languageName: node
-  linkType: hard
-
-"symbol-tree@npm:^3.2.4":
-  version: 3.2.4
-  resolution: "symbol-tree@npm:3.2.4"
-  checksum: 6e8fc7e1486b8b54bea91199d9535bb72f10842e40c79e882fc94fb7b14b89866adf2fd79efa5ebb5b658bc07fb459ccce5ac0e99ef3d72f474e74aaf284029d
-  languageName: node
-  linkType: hard
-
-"table@npm:^6.8.2":
-  version: 6.8.2
-  resolution: "table@npm:6.8.2"
-  dependencies:
-    ajv: ^8.0.1
-    lodash.truncate: ^4.4.2
-    slice-ansi: ^4.0.0
-    string-width: ^4.2.3
-    strip-ansi: ^6.0.1
-  checksum: 61188652f53a980d1759ca460ca8dea5c5322aece3210457e7084882f053c2b6a870041295e08a82cb1d676e31b056406845d94b0abf3c79a4b104777bec413b
-  languageName: node
-  linkType: hard
-
-"tar@npm:^6.1.11, tar@npm:^6.1.2":
-  version: 6.2.1
-  resolution: "tar@npm:6.2.1"
-  dependencies:
-    chownr: ^2.0.0
-    fs-minipass: ^2.0.0
-    minipass: ^5.0.0
-    minizlib: ^2.1.1
-    mkdirp: ^1.0.3
-    yallist: ^4.0.0
-  checksum: f1322768c9741a25356c11373bce918483f40fa9a25c69c59410c8a1247632487edef5fe76c5f12ac51a6356d2f1829e96d2bc34098668a2fc34d76050ac2b6c
+    "@isaacs/fs-minipass": ^4.0.0
+    chownr: ^3.0.0
+    minipass: ^7.1.2
+    minizlib: ^3.0.1
+    mkdirp: ^3.0.1
+    yallist: ^5.0.0
+  checksum: 8485350c0688331c94493031f417df069b778aadb25598abdad51862e007c39d1dd5310702c7be4a6784731a174799d8885d2fde0484269aea205b724d7b2ffa
   languageName: node
   linkType: hard
 
@@ -5823,26 +4144,28 @@ __metadata:
   languageName: node
   linkType: hard
 
-"tmp@npm:~0.2.3":
-  version: 0.2.3
-  resolution: "tmp@npm:0.2.3"
-  checksum: 73b5c96b6e52da7e104d9d44afb5d106bb1e16d9fa7d00dbeb9e6522e61b571fbdb165c756c62164be9a3bbe192b9b268c236d370a2a0955c7689cd2ae377b95
+"tldts-core@npm:^6.1.71":
+  version: 6.1.71
+  resolution: "tldts-core@npm:6.1.71"
+  checksum: f2a6e02a04c869ae4ba6e7d5e6c55e8b27d09196fb2c650a3b61593f13594cd5015aa581b0e24e446e246c516d4caf217401e57cc3409e54697829c0306d089e
   languageName: node
   linkType: hard
 
-"to-fast-properties@npm:^2.0.0":
-  version: 2.0.0
-  resolution: "to-fast-properties@npm:2.0.0"
-  checksum: be2de62fe58ead94e3e592680052683b1ec986c72d589e7b21e5697f8744cdbf48c266fa72f6c15932894c10187b5f54573a3bcf7da0bfd964d5caf23d436168
+"tldts@npm:^6.1.32":
+  version: 6.1.71
+  resolution: "tldts@npm:6.1.71"
+  dependencies:
+    tldts-core: ^6.1.71
+  bin:
+    tldts: bin/cli.js
+  checksum: 0831e1941fecb069413ba3bc0c0d60b3cd49433af0deb5f309a752e6c64da08e61ad40d6bec75ec30610cbbd328741db4e0186d5c2390a45b6c6595a662d6315
   languageName: node
   linkType: hard
 
-"to-regex-range@npm:^5.0.1":
-  version: 5.0.1
-  resolution: "to-regex-range@npm:5.0.1"
-  dependencies:
-    is-number: ^7.0.0
-  checksum: f76fa01b3d5be85db6a2a143e24df9f60dd047d151062d0ba3df62953f2f697b16fe5dad9b0ac6191c7efc7b1d9dcaa4b768174b7b29da89d4428e64bc0a20ed
+"tmp@npm:~0.2.3":
+  version: 0.2.3
+  resolution: "tmp@npm:0.2.3"
+  checksum: 73b5c96b6e52da7e104d9d44afb5d106bb1e16d9fa7d00dbeb9e6522e61b571fbdb165c756c62164be9a3bbe192b9b268c236d370a2a0955c7689cd2ae377b95
   languageName: node
   linkType: hard
 
@@ -5856,73 +4179,26 @@ __metadata:
   languageName: unknown
   linkType: soft
 
-"tough-cookie@npm:^4.1.3":
-  version: 4.1.3
-  resolution: "tough-cookie@npm:4.1.3"
-  dependencies:
-    psl: ^1.1.33
-    punycode: ^2.1.1
-    universalify: ^0.2.0
-    url-parse: ^1.5.3
-  checksum: c9226afff36492a52118432611af083d1d8493a53ff41ec4ea48e5b583aec744b989e4280bcf476c910ec1525a89a4a0f1cae81c08b18fb2ec3a9b3a72b91dcc
-  languageName: node
-  linkType: hard
-
-"tough-cookie@npm:^4.1.4":
-  version: 4.1.4
-  resolution: "tough-cookie@npm:4.1.4"
-  dependencies:
-    psl: ^1.1.33
-    punycode: ^2.1.1
-    universalify: ^0.2.0
-    url-parse: ^1.5.3
-  checksum: 5815059f014c31179a303c673f753f7899a6fce94ac93712c88ea5f3c26e0c042b5f0c7a599a00f8e0feeca4615dba75c3dffc54f3c1a489978aa8205e09307c
-  languageName: node
-  linkType: hard
-
-"tr46@npm:^5.0.0":
+"tough-cookie@npm:^5.0.0":
   version: 5.0.0
-  resolution: "tr46@npm:5.0.0"
+  resolution: "tough-cookie@npm:5.0.0"
   dependencies:
-    punycode: ^2.3.1
-  checksum: 8d8b021f8e17675ebf9e672c224b6b6cfdb0d5b92141349e9665c14a2501c54a298d11264bbb0b17b447581e1e83d4fc3c038c929f3d210e3964d4be47460288
+    tldts: ^6.1.32
+  checksum: 774f6c939c96f74b5847361f7e11e0d69383681d21a35a2d37a20956638e614ec521782d2d20bcb32b58638ff337bba87cc72fb72c987bd02ea0fdfc93994cdb
   languageName: node
   linkType: hard
 
-"treeify@npm:^1.1.0":
-  version: 1.1.0
-  resolution: "treeify@npm:1.1.0"
-  checksum: aa00dded220c1dd052573bd6fc2c52862f09870851a284f0d3650d72bf913ba9b4f6b824f4f1ab81899bae29375f4266b07fe47cbf82343a1efa13cc09ce87af
-  languageName: node
-  linkType: hard
-
-"ts-md5@npm:1.3.1":
+"ts-md5@npm:^1.3.1":
   version: 1.3.1
   resolution: "ts-md5@npm:1.3.1"
   checksum: 88fc4df837e17949fef92a8e71a0691b20e2f8b02a002876a162a102e7c9d364f1eb00cbd217f9578fec5fa07bb66ffa14ed054cdfe164486fab2173c8e9ea23
   languageName: node
   linkType: hard
 
-"tslib@npm:^2.1.0, tslib@npm:^2.6.2":
-  version: 2.6.2
-  resolution: "tslib@npm:2.6.2"
-  checksum: 329ea56123005922f39642318e3d1f0f8265d1e7fcb92c633e0809521da75eeaca28d2cf96d7248229deb40e5c19adf408259f4b9640afd20d13aecc1430f3ad
-  languageName: node
-  linkType: hard
-
-"tsx@npm:4.16.5":
-  version: 4.16.5
-  resolution: "tsx@npm:4.16.5"
-  dependencies:
-    esbuild: ~0.21.5
-    fsevents: ~2.3.3
-    get-tsconfig: ^4.7.5
-  dependenciesMeta:
-    fsevents:
-      optional: true
-  bin:
-    tsx: dist/cli.mjs
-  checksum: 4307313e1688afe7346a0fe86509bcd1111e4f98c9fad8fa56bdcf14effbea9093cc613fb79299ab51915eeaaa9524bff6db6eebd98699ee984c1944c7182eed
+"tslib@npm:^2.1.0":
+  version: 2.8.1
+  resolution: "tslib@npm:2.8.1"
+  checksum: e4aba30e632b8c8902b47587fd13345e2827fa639e7c3121074d5ee0880723282411a8838f830b55100cbe4517672f84a2472667d355b81e8af165a55dc6203a
   languageName: node
   linkType: hard
 
@@ -5959,16 +4235,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"typescript@npm:^5.2.2":
-  version: 5.4.3
-  resolution: "typescript@npm:5.4.3"
-  bin:
-    tsc: bin/tsc
-    tsserver: bin/tsserver
-  checksum: d74d731527e35e64d8d2dcf2f897cf8cfbc3428be0ad7c48434218ba4ae41239f53be7c90714089db1068c05cae22436af2ecba71fd36ecc5e7a9118af060198
-  languageName: node
-  linkType: hard
-
 "typescript@patch:typescript@5.5.4#~builtin":
   version: 5.5.4
   resolution: "typescript@patch:typescript@npm%3A5.5.4#~builtin::version=5.5.4&hash=5adc0c"
@@ -5979,16 +4245,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"typescript@patch:typescript@^5.2.2#~builtin":
-  version: 5.4.3
-  resolution: "typescript@patch:typescript@npm%3A5.4.3#~builtin::version=5.4.3&hash=5adc0c"
-  bin:
-    tsc: bin/tsc
-    tsserver: bin/tsserver
-  checksum: 523ec29bc308b4ca0cec9a139ac08a27939891d8ad620d249cf5e1a35454e37cd7bb52613a1884f1206aa11d2fb1c4c83fb9e939bde542aaa8527ba818cb8177
-  languageName: node
-  linkType: hard
-
 "undici-types@npm:~5.26.4":
   version: 5.26.5
   resolution: "undici-types@npm:5.26.5"
@@ -5996,6 +4252,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"undici-types@npm:~6.20.0":
+  version: 6.20.0
+  resolution: "undici-types@npm:6.20.0"
+  checksum: b7bc50f012dc6afbcce56c9fd62d7e86b20a62ff21f12b7b5cbf1973b9578d90f22a9c7fe50e638e96905d33893bf2f9f16d98929c4673c2480de05c6c96ea8b
+  languageName: node
+  linkType: hard
+
 "union@npm:~0.5.0":
   version: 0.5.0
   resolution: "union@npm:0.5.0"
@@ -6005,28 +4268,21 @@ __metadata:
   languageName: node
   linkType: hard
 
-"unique-filename@npm:^3.0.0":
-  version: 3.0.0
-  resolution: "unique-filename@npm:3.0.0"
+"unique-filename@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "unique-filename@npm:4.0.0"
   dependencies:
-    unique-slug: ^4.0.0
-  checksum: 8e2f59b356cb2e54aab14ff98a51ac6c45781d15ceaab6d4f1c2228b780193dc70fae4463ce9e1df4479cb9d3304d7c2043a3fb905bdeca71cc7e8ce27e063df
+    unique-slug: ^5.0.0
+  checksum: 6a62094fcac286b9ec39edbd1f8f64ff92383baa430af303dfed1ffda5e47a08a6b316408554abfddd9730c78b6106bef4ca4d02c1231a735ddd56ced77573df
   languageName: node
   linkType: hard
 
-"unique-slug@npm:^4.0.0":
-  version: 4.0.0
-  resolution: "unique-slug@npm:4.0.0"
+"unique-slug@npm:^5.0.0":
+  version: 5.0.0
+  resolution: "unique-slug@npm:5.0.0"
   dependencies:
     imurmurhash: ^0.1.4
-  checksum: 0884b58365af59f89739e6f71e3feacb5b1b41f2df2d842d0757933620e6de08eff347d27e9d499b43c40476cbaf7988638d3acb2ffbcb9d35fd035591adfd15
-  languageName: node
-  linkType: hard
-
-"universalify@npm:^0.2.0":
-  version: 0.2.0
-  resolution: "universalify@npm:0.2.0"
-  checksum: e86134cb12919d177c2353196a4cc09981524ee87abf621f7bc8d249dbbbebaec5e7d1314b96061497981350df786e4c5128dbf442eba104d6e765bc260678b5
+  checksum: 222d0322bc7bbf6e45c08967863212398313ef73423f4125e075f893a02405a5ffdbaaf150f7dd1e99f8861348a486dd079186d27c5f2c60e465b7dcbb1d3e5b
   languageName: node
   linkType: hard
 
@@ -6044,26 +4300,17 @@ __metadata:
   languageName: node
   linkType: hard
 
-"update-browserslist-db@npm:^1.1.0":
-  version: 1.1.0
-  resolution: "update-browserslist-db@npm:1.1.0"
+"update-browserslist-db@npm:^1.1.1":
+  version: 1.1.1
+  resolution: "update-browserslist-db@npm:1.1.1"
   dependencies:
-    escalade: ^3.1.2
-    picocolors: ^1.0.1
+    escalade: ^3.2.0
+    picocolors: ^1.1.0
   peerDependencies:
     browserslist: ">= 4.21.0"
   bin:
     update-browserslist-db: cli.js
-  checksum: 7b74694d96f0c360f01b702e72353dc5a49df4fe6663d3ee4e5c628f061576cddf56af35a3a886238c01dd3d8f231b7a86a8ceaa31e7a9220ae31c1c1238e562
-  languageName: node
-  linkType: hard
-
-"uri-js@npm:^4.2.2":
-  version: 4.4.1
-  resolution: "uri-js@npm:4.4.1"
-  dependencies:
-    punycode: ^2.1.0
-  checksum: 7167432de6817fe8e9e0c9684f1d2de2bb688c94388f7569f7dbdb1587c9f4ca2a77962f134ec90be0cc4d004c939ff0d05acc9f34a0db39a3c797dada262633
+  checksum: 2ea11bd2562122162c3e438d83a1f9125238c0844b6d16d366e3276d0c0acac6036822dc7df65fc5a89c699cdf9f174acf439c39bedf3f9a2f3983976e4b4c3e
   languageName: node
   linkType: hard
 
@@ -6074,30 +4321,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"url-parse@npm:^1.5.3":
-  version: 1.5.10
-  resolution: "url-parse@npm:1.5.10"
-  dependencies:
-    querystringify: ^2.1.1
-    requires-port: ^1.0.0
-  checksum: fbdba6b1d83336aca2216bbdc38ba658d9cfb8fc7f665eb8b17852de638ff7d1a162c198a8e4ed66001ddbf6c9888d41e4798912c62b4fd777a31657989f7bdf
-  languageName: node
-  linkType: hard
-
-"util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2":
-  version: 1.0.2
-  resolution: "util-deprecate@npm:1.0.2"
-  checksum: 474acf1146cb2701fe3b074892217553dfcf9a031280919ba1b8d651a068c9b15d863b7303cb15bd00a862b498e6cf4ad7b4a08fb134edd5a6f7641681cb54a2
-  languageName: node
-  linkType: hard
-
-"util-extend@npm:^1.0.1":
-  version: 1.0.3
-  resolution: "util-extend@npm:1.0.3"
-  checksum: da57f399b331f40fe2cea5409b1f4939231433db9b52dac5593e4390a98b7b0d1318a0daefbcc48123fffe5026ef49f418b3e4df7a4cd7649a2583e559c608a5
-  languageName: node
-  linkType: hard
-
 "uuid@npm:^8.3.2":
   version: 8.3.2
   resolution: "uuid@npm:8.3.2"
@@ -6122,23 +4345,13 @@ __metadata:
   linkType: hard
 
 "v8-to-istanbul@npm:^9.0.0":
-  version: 9.2.0
-  resolution: "v8-to-istanbul@npm:9.2.0"
+  version: 9.3.0
+  resolution: "v8-to-istanbul@npm:9.3.0"
   dependencies:
     "@jridgewell/trace-mapping": ^0.3.12
     "@types/istanbul-lib-coverage": ^2.0.1
     convert-source-map: ^2.0.0
-  checksum: 31ef98c6a31b1dab6be024cf914f235408cd4c0dc56a5c744a5eea1a9e019ba279e1b6f90d695b78c3186feed391ed492380ccf095009e2eb91f3d058f0b4491
-  languageName: node
-  linkType: hard
-
-"validate-npm-package-license@npm:^3.0.1":
-  version: 3.0.4
-  resolution: "validate-npm-package-license@npm:3.0.4"
-  dependencies:
-    spdx-correct: ^3.0.0
-    spdx-expression-parse: ^3.0.0
-  checksum: 35703ac889d419cf2aceef63daeadbe4e77227c39ab6287eeb6c1b36a746b364f50ba22e88591f5d017bc54685d8137bc2d328d0a896e4d3fd22093c0f32a9ad
+  checksum: ded42cd535d92b7fd09a71c4c67fb067487ef5551cc227bfbf2a1f159a842e4e4acddaef20b955789b8d3b455b9779d036853f4a27ce15007f6364a4d30317ae
   languageName: node
   linkType: hard
 
@@ -6196,31 +4409,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"w3c-xmlserializer@npm:^5.0.0":
-  version: 5.0.0
-  resolution: "w3c-xmlserializer@npm:5.0.0"
-  dependencies:
-    xml-name-validator: ^5.0.0
-  checksum: 593acc1fdab3f3207ec39d851e6df0f3fa41a36b5809b0ace364c7a6d92e351938c53424a7618ce8e0fbaffee8be2e8e070a5734d05ee54666a8bdf1a376cc40
-  languageName: node
-  linkType: hard
-
-"wcwidth@npm:^1.0.1":
-  version: 1.0.1
-  resolution: "wcwidth@npm:1.0.1"
-  dependencies:
-    defaults: ^1.0.3
-  checksum: 814e9d1ddcc9798f7377ffa448a5a3892232b9275ebb30a41b529607691c0491de47cba426e917a4d08ded3ee7e9ba2f3fe32e62ee3cd9c7d3bafb7754bd553c
-  languageName: node
-  linkType: hard
-
-"webidl-conversions@npm:^7.0.0":
-  version: 7.0.0
-  resolution: "webidl-conversions@npm:7.0.0"
-  checksum: f05588567a2a76428515333eff87200fae6c83c3948a7482ebb109562971e77ef6dc49749afa58abb993391227c5697b3ecca52018793e0cb4620a48f10bd21b
-  languageName: node
-  linkType: hard
-
 "whatwg-encoding@npm:^2.0.0":
   version: 2.0.0
   resolution: "whatwg-encoding@npm:2.0.0"
@@ -6230,43 +4418,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"whatwg-encoding@npm:^3.1.1":
-  version: 3.1.1
-  resolution: "whatwg-encoding@npm:3.1.1"
-  dependencies:
-    iconv-lite: 0.6.3
-  checksum: f75a61422421d991e4aec775645705beaf99a16a88294d68404866f65e92441698a4f5b9fa11dd609017b132d7b286c3c1534e2de5b3e800333856325b549e3c
-  languageName: node
-  linkType: hard
-
-"whatwg-mimetype@npm:^4.0.0":
-  version: 4.0.0
-  resolution: "whatwg-mimetype@npm:4.0.0"
-  checksum: f97edd4b4ee7e46a379f3fb0e745de29fe8b839307cc774300fd49059fcdd560d38cb8fe21eae5575b8f39b022f23477cc66e40b0355c2851ce84760339cef30
-  languageName: node
-  linkType: hard
-
-"whatwg-url@npm:^14.0.0":
-  version: 14.0.0
-  resolution: "whatwg-url@npm:14.0.0"
-  dependencies:
-    tr46: ^5.0.0
-    webidl-conversions: ^7.0.0
-  checksum: 4b5887e50f786583bead70916413e67a381d2126899b9eb5c67ce664bba1e7ec07cdff791404581ce73c6190d83c359c9ca1d50711631217905db3877dec075c
-  languageName: node
-  linkType: hard
-
-"which@npm:^1.3.1":
-  version: 1.3.1
-  resolution: "which@npm:1.3.1"
-  dependencies:
-    isexe: ^2.0.0
-  bin:
-    which: ./bin/which
-  checksum: f2e185c6242244b8426c9df1510e86629192d93c1a986a7d2a591f2c24869e7ffd03d6dac07ca863b2e4c06f59a4cc9916c585b72ee9fa1aa609d0124df15e04
-  languageName: node
-  linkType: hard
-
 "which@npm:^2.0.1":
   version: 2.0.2
   resolution: "which@npm:2.0.2"
@@ -6278,14 +4429,14 @@ __metadata:
   languageName: node
   linkType: hard
 
-"which@npm:^4.0.0":
-  version: 4.0.0
-  resolution: "which@npm:4.0.0"
+"which@npm:^5.0.0":
+  version: 5.0.0
+  resolution: "which@npm:5.0.0"
   dependencies:
     isexe: ^3.1.1
   bin:
     node-which: bin/which.js
-  checksum: f17e84c042592c21e23c8195108cff18c64050b9efb8459589116999ea9da6dd1509e6a1bac3aeebefd137be00fabbb61b5c2bc0aa0f8526f32b58ee2f545651
+  checksum: 6ec99e89ba32c7e748b8a3144e64bfc74aa63e2b2eacbb61a0060ad0b961eb1a632b08fb1de067ed59b002cec3e21de18299216ebf2325ef0f78e0f121e14e90
   languageName: node
   linkType: hard
 
@@ -6329,17 +4480,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"write-file-atomic@npm:^5.0.1":
-  version: 5.0.1
-  resolution: "write-file-atomic@npm:5.0.1"
-  dependencies:
-    imurmurhash: ^0.1.4
-    signal-exit: ^4.0.1
-  checksum: 8dbb0e2512c2f72ccc20ccedab9986c7d02d04039ed6e8780c987dc4940b793339c50172a1008eed7747001bfacc0ca47562668a069a7506c46c77d7ba3926a9
-  languageName: node
-  linkType: hard
-
-"ws@npm:8.18.0, ws@npm:^8.18.0":
+"ws@npm:8.18.0":
   version: 8.18.0
   resolution: "ws@npm:8.18.0"
   peerDependencies:
@@ -6354,20 +4495,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"xml-name-validator@npm:^5.0.0":
-  version: 5.0.0
-  resolution: "xml-name-validator@npm:5.0.0"
-  checksum: 86effcc7026f437701252fcc308b877b4bc045989049cfc79b0cc112cb365cf7b009f4041fab9fb7cd1795498722c3e9fe9651afc66dfa794c16628a639a4c45
-  languageName: node
-  linkType: hard
-
-"xmlchars@npm:^2.2.0":
-  version: 2.2.0
-  resolution: "xmlchars@npm:2.2.0"
-  checksum: 8c70ac94070ccca03f47a81fcce3b271bd1f37a591bf5424e787ae313fcb9c212f5f6786e1fa82076a2c632c0141552babcd85698c437506dfa6ae2d58723062
-  languageName: node
-  linkType: hard
-
 "y18n@npm:^5.0.5":
   version: 5.0.8
   resolution: "y18n@npm:5.0.8"
@@ -6389,6 +4516,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"yallist@npm:^5.0.0":
+  version: 5.0.0
+  resolution: "yallist@npm:5.0.0"
+  checksum: eba51182400b9f35b017daa7f419f434424410691bbc5de4f4240cc830fdef906b504424992700dc047f16b4d99100a6f8b8b11175c193f38008e9c96322b6a5
+  languageName: node
+  linkType: hard
+
 "yargs-parser@npm:^21.1.1":
   version: 21.1.1
   resolution: "yargs-parser@npm:21.1.1"