From b6601080bcb267401ea133eee7f784caf1402cfa Mon Sep 17 00:00:00 2001 From: Steven Vancoillie Date: Tue, 24 Dec 2024 13:47:47 +0100 Subject: [PATCH] feat(streams)!: use Web Streams API Replace Node.js stream module with Web Streams API. Since the latter is substantially different in some important details regarding stream pipelining, the way pipelines are built are redefined. The component concept as module blocks is removed and instead the components themselves expose streams that can be combined together. The pipelines are then simple stream compositions. Because this is a major (breaking) change, it's done in concert with other planned improvements that are also breaking changes (see section below for details). Improvements: - replacing the Node.js stream module with Web Streams API removes the dependency on the (legacy) stream-browserify package and results in a much smaller library (bundle) size, so there is no longer a need for a separate "light" version - `debug` package replaced by custom internal logging utilities (allowing proper ES module support) Fixes #990, Closes #992 - added audio test signal to the H.264 test Refactoring: - RTSP session and parser are combined in a single component and the session controller has been rewritten as a request-response flow. An async `start` method starts the streams and returns SDP + range. - RTP depay is combined into a single component that detects the proper format based on payloadType, and allows registering a "peeker" that can inspect messages (instead of having to insert an extra transform stream) - Extended use of TypeScript in areas where this was lacking BREAKING CHANGES: - No support for CommonJS: - Node.js has support for ES modules - Browsers have support for ES modules, but you can also still use the IIFE global variable, or use a bundler (all of which support ES modules) - No distinction between Node.js/Browser: - The library targets mainly Browser, so some things rely on `window` and expect it to be present, however most things work both platforms. - Node-only pipelines are removed, these are trivial to re-implement with Web Streams API if necessary. The CLI player has its own TCP source for that reason (replacing the CliXyz pipelines). - The generic "component" and "pipeline" classes were removed: - Components extend Web Streams API instead - Pipelines rely on `pipeTo`/`pipeThrough` composition and a `start` method to initiate flow of data. - Some public methods on pipelines have been removed (refer to their type for details) in cases where a simple alternative is available, or check the examples to see how to modify usage. There are less pipelines but they are more versatile, with accessible readonly components. In general, promises/async methods are preferred over callbacks. --- .gitignore | 1 + example-streams-node/mjpeg-player.js | 65 - example-streams-node/pipeline.mjs | 82 + example-streams-node/player.cjs | 63 - example-streams-node/player.mjs | 48 + .../test/h264-overlay-player.js | 27 +- example-streams-web/test/h264-overlay.html | 1 + example-streams-web/test/h264-player.js | 26 +- example-streams-web/test/h264.html | 2 + .../test/mjpeg-overlay-player.js | 20 +- example-streams-web/test/mjpeg-player.js | 11 +- example-streams-web/test/sdp.html | 14 + example-streams-web/test/sdp.js | 20 + .../test/streaming-mp4-player.js | 9 +- gst-rtsp-launch/README.md | 55 +- gst-rtsp-launch/src/meson.build | 17 + justfile | 33 +- overlay/esbuild.mjs | 29 +- overlay/package.json | 11 +- overlay/tsconfig.json | 7 +- overlay/tsconfig.types.json | 10 + package.json | 7 +- player/esbuild.mjs | 43 +- player/package.json | 14 +- player/src/Container.tsx | 4 +- player/src/HttpMp4Video.tsx | 33 +- player/src/PlaybackArea.tsx | 41 +- player/src/Stats.tsx | 38 +- player/src/StillImage.tsx | 6 +- player/src/WsRtspCanvas.tsx | 102 +- player/src/WsRtspVideo.tsx | 98 +- player/src/hooks/useInterval.ts | 22 + player/src/hooks/useVideoDebug.ts | 32 +- player/src/metadata.ts | 28 +- player/src/utils/log.ts | 58 + player/tsconfig.json | 7 +- player/tsconfig.types.json | 10 + scripts/rtsp-server.sh | 4 +- scripts/tcp-ws-proxy.cjs | 21 - scripts/ws-rtsp-proxy.mjs | 59 + streams/esbuild.mjs | 89 +- streams/package.json | 40 +- streams/polyfill.mjs | 2 - streams/src/components/aacdepay/index.ts | 112 - streams/src/components/aacdepay/parser.ts | 57 - streams/src/components/adapter.ts | 15 + streams/src/components/auth/index.ts | 93 - streams/src/components/basicdepay/index.ts | 54 - streams/src/components/canvas/index.ts | 182 +- streams/src/components/component.ts | 291 -- streams/src/components/h264depay/index.ts | 78 - streams/src/components/h264depay/parser.ts | 103 - streams/src/components/helpers/sleep.ts | 10 - .../src/components/helpers/stream-factory.ts | 105 - streams/src/components/http-mp4/index.ts | 166 - streams/src/components/http-source/index.ts | 154 - streams/src/components/index.browser.ts | 18 - streams/src/components/index.node.ts | 18 - streams/src/components/index.ts | 12 + streams/src/components/inspector/index.ts | 53 - streams/src/components/jpegdepay/index.ts | 81 - streams/src/components/message.ts | 101 - streams/src/components/messageStreams.ts | 17 - .../{mp4capture/index.ts => mp4-capture.ts} | 70 +- .../src/components/mp4-muxer/aacSettings.ts | 208 ++ .../helpers => mp4-muxer}/boxbuilder.ts | 170 +- .../helpers => mp4-muxer}/bufferreader.ts | 0 .../helpers => mp4-muxer}/h264Settings.ts | 30 +- streams/src/components/mp4-muxer/index.ts | 133 + .../{mp4muxer/helpers => mp4-muxer}/isom.ts | 19 +- streams/src/components/mp4-muxer/mime.ts | 10 + .../helpers => mp4-muxer}/spsparser.ts | 0 streams/src/components/mp4-parser/index.ts | 31 +- streams/src/components/mp4-parser/parser.ts | 66 +- .../mp4muxer/helpers/aacSettings.ts | 149 - .../src/components/mp4muxer/helpers/utils.ts | 40 - streams/src/components/mp4muxer/index.ts | 141 - streams/src/components/mse-sink.ts | 153 + streams/src/components/mse/index.ts | 214 -- streams/src/components/onvifdepay/index.ts | 69 - streams/src/components/recorder/index.ts | 68 - streams/src/components/replayer/index.ts | 78 - streams/src/components/rtp/aac-depay.ts | 130 + streams/src/components/rtp/depay.ts | 75 + streams/src/components/rtp/h264-depay.ts | 142 + streams/src/components/rtp/index.ts | 5 + .../parser.ts => rtp/jpeg-depay.ts} | 84 +- .../headers.ts => rtp/jpeg-headers.ts} | 2 +- .../make-qtable.ts => rtp/jpeg-qtable.ts} | 2 +- streams/src/components/rtp/onvif-depay.ts | 44 + streams/src/components/rtsp-parser/index.ts | 55 - streams/src/components/rtsp-session/index.ts | 549 --- .../src/components/{ => rtsp}/auth/digest.ts | 1 + streams/src/components/rtsp/auth/index.ts | 62 + .../{ => rtsp}/auth/www-authenticate.ts | 2 +- streams/src/components/rtsp/header.ts | 184 + streams/src/components/rtsp/index.ts | 1 + .../protocols => components/rtsp}/ntp.ts | 10 +- .../{rtsp-parser => rtsp}/parser.ts | 105 +- .../protocols => components/rtsp}/rtcp.ts | 252 +- streams/src/components/rtsp/rtp.ts | 108 + streams/src/components/rtsp/sdp.ts | 195 + .../builder.ts => rtsp/serializer.ts} | 9 +- streams/src/components/rtsp/session.ts | 451 +++ streams/src/components/tcp/index.ts | 109 - streams/src/components/types/aac.ts | 25 + streams/src/components/types/h264.ts | 36 + streams/src/components/types/index.ts | 11 + streams/src/components/types/isom.ts | 34 + streams/src/components/types/jpeg.ts | 28 + streams/src/components/types/message.ts | 18 + streams/src/components/types/ntp.ts | 4 + streams/src/components/types/rtcp.ts | 115 + streams/src/components/types/rtp.ts | 29 + streams/src/components/types/rtsp.ts | 67 + streams/src/components/types/sdp.ts | 257 ++ streams/src/components/types/xml.ts | 22 + streams/src/{ => components}/utils/bits.ts | 0 streams/src/{ => components}/utils/bytes.ts | 11 + streams/src/{ => components}/utils/clamp.ts | 0 streams/src/{ => components}/utils/clock.ts | 0 streams/src/components/utils/index.ts | 1 + .../src/{ => components}/utils/scheduler.ts | 16 +- streams/src/components/utils/streams.ts | 43 + streams/src/components/ws-sink/index.ts | 71 - streams/src/components/ws-source.ts | 81 + streams/src/components/ws-source/index.ts | 120 - streams/src/{utils => }/config.ts | 0 streams/src/defaults.ts | 37 + streams/src/fetch-sdp.ts | 36 + streams/src/http-mp4-pipeline.ts | 75 + streams/src/index.browser.ts | 9 - streams/src/index.node.ts | 9 - streams/src/index.ts | 8 + streams/src/log.ts | 58 + streams/src/metadata-pipeline.ts | 89 + .../ws-source => }/openwebsocket.ts | 50 +- streams/src/pipelines/cli-mjpeg-pipeline.ts | 40 - streams/src/pipelines/cli-mp4-pipeline.ts | 40 - .../src/pipelines/html5-canvas-pipeline.ts | 99 - .../html5-video-metadata-pipeline.ts | 38 - streams/src/pipelines/html5-video-pipeline.ts | 87 - streams/src/pipelines/http-mse-pipeline.ts | 64 - streams/src/pipelines/index.browser.ts | 13 - streams/src/pipelines/index.node.ts | 9 - streams/src/pipelines/metadata-pipeline.ts | 65 - streams/src/pipelines/pipeline.ts | 158 - streams/src/pipelines/rtsp-mjpeg-pipeline.ts | 23 - streams/src/pipelines/rtsp-mp4-pipeline.ts | 47 - streams/src/pipelines/rtsp-pipeline.ts | 42 - .../src/pipelines/tcp-ws-proxy-pipeline.ts | 38 - streams/src/pipelines/ws-sdp-pipeline.ts | 61 - streams/src/rtsp-jpeg-pipeline.ts | 106 + streams/src/rtsp-mp4-pipeline.ts | 105 + streams/src/utils/index.browser.ts | 3 - streams/src/utils/index.node.ts | 4 - streams/src/utils/protocols/index.ts | 5 - streams/src/utils/protocols/isom.ts | 18 - streams/src/utils/protocols/rtp.ts | 88 - streams/src/utils/protocols/rtsp.ts | 212 -- streams/src/utils/protocols/sdp.ts | 433 --- streams/src/utils/retry.ts | 33 - streams/src/www-authenticate.d.ts | 1 - streams/tests/aacdepay-parser.test.ts | 49 - streams/tests/aacdepay.test.ts | 45 +- streams/tests/auth-digest.test.ts | 10 +- streams/tests/auth.fixtures.ts | 2 +- streams/tests/basicdepay.test.ts | 51 - streams/tests/canvas.test.ts | 13 - streams/tests/components.test.ts | 198 - streams/tests/h264depay-parser.test.ts | 72 - streams/tests/h264depay.test.ts | 80 +- streams/tests/inspector.test.ts | 13 - streams/tests/jpegdepay.test.ts | 9 - streams/tests/mp4-capture.test.ts | 131 + streams/tests/mp4capture.test.ts | 191 - streams/tests/mp4muxer.test.ts | 10 - streams/tests/mse.test.ts | 12 - streams/tests/onvifdepay.test.ts | 72 +- streams/tests/protocols-rtsp.test.ts | 96 - streams/tests/recorder.test.ts | 43 - streams/tests/replayer.test.ts | 44 - ...s.fixtures.ts => rtsp-headers.fixtures.ts} | 20 - streams/tests/rtsp-headers.test.ts | 120 + streams/tests/rtsp-parser-builder.test.ts | 26 - streams/tests/rtsp-parser-parser.test.ts | 227 -- streams/tests/rtsp-parser.fixtures.ts | 7 +- streams/tests/rtsp-parser.test.ts | 249 +- streams/tests/rtsp-rtcp.fixtures.ts | 67 + ...otocols-rtcp.test.ts => rtsp-rtcp.test.ts} | 8 +- streams/tests/rtsp-rtp.fixtures.ts | 19 + ...protocols-rtp.test.ts => rtsp-rtp.test.ts} | 8 +- ...protocols-sdp.test.ts => rtsp-sdp.test.ts} | 16 +- streams/tests/rtsp-session.fixtures.ts | 139 +- streams/tests/rtsp-session.test.ts | 511 ++- streams/tests/tcp.test.ts | 9 - streams/tests/validate-component.ts | 30 - streams/tests/ws-source.test.ts | 58 + streams/tests/ws-source.test_.ts | 57 - streams/tsconfig.json | 7 - streams/tsconfig.types.json | 10 + tsconfig.base.json | 5 +- yarn.lock | 3318 ++++------------- 203 files changed, 6208 insertions(+), 10291 deletions(-) delete mode 100644 example-streams-node/mjpeg-player.js create mode 100644 example-streams-node/pipeline.mjs delete mode 100755 example-streams-node/player.cjs create mode 100755 example-streams-node/player.mjs create mode 100644 example-streams-web/test/sdp.html create mode 100644 example-streams-web/test/sdp.js create mode 100644 gst-rtsp-launch/src/meson.build create mode 100644 overlay/tsconfig.types.json create mode 100644 player/src/hooks/useInterval.ts create mode 100644 player/src/utils/log.ts create mode 100644 player/tsconfig.types.json delete mode 100755 scripts/tcp-ws-proxy.cjs create mode 100755 scripts/ws-rtsp-proxy.mjs delete mode 100644 streams/polyfill.mjs delete mode 100644 streams/src/components/aacdepay/index.ts delete mode 100644 streams/src/components/aacdepay/parser.ts create mode 100644 streams/src/components/adapter.ts delete mode 100644 streams/src/components/auth/index.ts delete mode 100644 streams/src/components/basicdepay/index.ts delete mode 100644 streams/src/components/component.ts delete mode 100644 streams/src/components/h264depay/index.ts delete mode 100644 streams/src/components/h264depay/parser.ts delete mode 100644 streams/src/components/helpers/sleep.ts delete mode 100644 streams/src/components/helpers/stream-factory.ts delete mode 100644 streams/src/components/http-mp4/index.ts delete mode 100644 streams/src/components/http-source/index.ts delete mode 100644 streams/src/components/index.browser.ts delete mode 100644 streams/src/components/index.node.ts create mode 100644 streams/src/components/index.ts delete mode 100644 streams/src/components/inspector/index.ts delete mode 100644 streams/src/components/jpegdepay/index.ts delete mode 100644 streams/src/components/message.ts delete mode 100644 streams/src/components/messageStreams.ts rename streams/src/components/{mp4capture/index.ts => mp4-capture.ts} (52%) create mode 100644 streams/src/components/mp4-muxer/aacSettings.ts rename streams/src/components/{mp4muxer/helpers => mp4-muxer}/boxbuilder.ts (63%) rename streams/src/components/{mp4muxer/helpers => mp4-muxer}/bufferreader.ts (100%) rename streams/src/components/{mp4muxer/helpers => mp4-muxer}/h264Settings.ts (83%) create mode 100644 streams/src/components/mp4-muxer/index.ts rename streams/src/components/{mp4muxer/helpers => mp4-muxer}/isom.ts (98%) create mode 100644 streams/src/components/mp4-muxer/mime.ts rename streams/src/components/{mp4muxer/helpers => mp4-muxer}/spsparser.ts (100%) delete mode 100644 streams/src/components/mp4muxer/helpers/aacSettings.ts delete mode 100644 streams/src/components/mp4muxer/helpers/utils.ts delete mode 100644 streams/src/components/mp4muxer/index.ts create mode 100644 streams/src/components/mse-sink.ts delete mode 100644 streams/src/components/mse/index.ts delete mode 100644 streams/src/components/onvifdepay/index.ts delete mode 100644 streams/src/components/recorder/index.ts delete mode 100644 streams/src/components/replayer/index.ts create mode 100644 streams/src/components/rtp/aac-depay.ts create mode 100644 streams/src/components/rtp/depay.ts create mode 100644 streams/src/components/rtp/h264-depay.ts create mode 100644 streams/src/components/rtp/index.ts rename streams/src/components/{jpegdepay/parser.ts => rtp/jpeg-depay.ts} (66%) rename streams/src/components/{jpegdepay/headers.ts => rtp/jpeg-headers.ts} (99%) rename streams/src/components/{jpegdepay/make-qtable.ts => rtp/jpeg-qtable.ts} (97%) create mode 100644 streams/src/components/rtp/onvif-depay.ts delete mode 100644 streams/src/components/rtsp-parser/index.ts delete mode 100644 streams/src/components/rtsp-session/index.ts rename streams/src/components/{ => rtsp}/auth/digest.ts (99%) create mode 100644 streams/src/components/rtsp/auth/index.ts rename streams/src/components/{ => rtsp}/auth/www-authenticate.ts (91%) create mode 100644 streams/src/components/rtsp/header.ts create mode 100644 streams/src/components/rtsp/index.ts rename streams/src/{utils/protocols => components/rtsp}/ntp.ts (62%) rename streams/src/components/{rtsp-parser => rtsp}/parser.ts (69%) rename streams/src/{utils/protocols => components/rtsp}/rtcp.ts (66%) create mode 100644 streams/src/components/rtsp/rtp.ts create mode 100644 streams/src/components/rtsp/sdp.ts rename streams/src/components/{rtsp-parser/builder.ts => rtsp/serializer.ts} (68%) create mode 100644 streams/src/components/rtsp/session.ts delete mode 100644 streams/src/components/tcp/index.ts create mode 100644 streams/src/components/types/aac.ts create mode 100644 streams/src/components/types/h264.ts create mode 100644 streams/src/components/types/index.ts create mode 100644 streams/src/components/types/isom.ts create mode 100644 streams/src/components/types/jpeg.ts create mode 100644 streams/src/components/types/message.ts create mode 100644 streams/src/components/types/ntp.ts create mode 100644 streams/src/components/types/rtcp.ts create mode 100644 streams/src/components/types/rtp.ts create mode 100644 streams/src/components/types/rtsp.ts create mode 100644 streams/src/components/types/sdp.ts create mode 100644 streams/src/components/types/xml.ts rename streams/src/{ => components}/utils/bits.ts (100%) rename streams/src/{ => components}/utils/bytes.ts (90%) rename streams/src/{ => components}/utils/clamp.ts (100%) rename streams/src/{ => components}/utils/clock.ts (100%) create mode 100644 streams/src/components/utils/index.ts rename streams/src/{ => components}/utils/scheduler.ts (94%) create mode 100644 streams/src/components/utils/streams.ts delete mode 100644 streams/src/components/ws-sink/index.ts create mode 100644 streams/src/components/ws-source.ts delete mode 100644 streams/src/components/ws-source/index.ts rename streams/src/{utils => }/config.ts (100%) create mode 100644 streams/src/defaults.ts create mode 100644 streams/src/fetch-sdp.ts create mode 100644 streams/src/http-mp4-pipeline.ts delete mode 100644 streams/src/index.browser.ts delete mode 100644 streams/src/index.node.ts create mode 100644 streams/src/index.ts create mode 100644 streams/src/log.ts create mode 100644 streams/src/metadata-pipeline.ts rename streams/src/{components/ws-source => }/openwebsocket.ts (65%) delete mode 100644 streams/src/pipelines/cli-mjpeg-pipeline.ts delete mode 100644 streams/src/pipelines/cli-mp4-pipeline.ts delete mode 100644 streams/src/pipelines/html5-canvas-pipeline.ts delete mode 100644 streams/src/pipelines/html5-video-metadata-pipeline.ts delete mode 100644 streams/src/pipelines/html5-video-pipeline.ts delete mode 100644 streams/src/pipelines/http-mse-pipeline.ts delete mode 100644 streams/src/pipelines/index.browser.ts delete mode 100644 streams/src/pipelines/index.node.ts delete mode 100644 streams/src/pipelines/metadata-pipeline.ts delete mode 100644 streams/src/pipelines/pipeline.ts delete mode 100644 streams/src/pipelines/rtsp-mjpeg-pipeline.ts delete mode 100644 streams/src/pipelines/rtsp-mp4-pipeline.ts delete mode 100644 streams/src/pipelines/rtsp-pipeline.ts delete mode 100644 streams/src/pipelines/tcp-ws-proxy-pipeline.ts delete mode 100644 streams/src/pipelines/ws-sdp-pipeline.ts create mode 100644 streams/src/rtsp-jpeg-pipeline.ts create mode 100644 streams/src/rtsp-mp4-pipeline.ts delete mode 100644 streams/src/utils/index.browser.ts delete mode 100644 streams/src/utils/index.node.ts delete mode 100644 streams/src/utils/protocols/index.ts delete mode 100644 streams/src/utils/protocols/isom.ts delete mode 100644 streams/src/utils/protocols/rtp.ts delete mode 100644 streams/src/utils/protocols/rtsp.ts delete mode 100644 streams/src/utils/protocols/sdp.ts delete mode 100644 streams/src/utils/retry.ts delete mode 100644 streams/src/www-authenticate.d.ts delete mode 100644 streams/tests/aacdepay-parser.test.ts delete mode 100644 streams/tests/basicdepay.test.ts delete mode 100644 streams/tests/canvas.test.ts delete mode 100644 streams/tests/components.test.ts delete mode 100644 streams/tests/h264depay-parser.test.ts delete mode 100644 streams/tests/inspector.test.ts delete mode 100644 streams/tests/jpegdepay.test.ts create mode 100644 streams/tests/mp4-capture.test.ts delete mode 100644 streams/tests/mp4capture.test.ts delete mode 100644 streams/tests/mp4muxer.test.ts delete mode 100644 streams/tests/mse.test.ts delete mode 100644 streams/tests/protocols-rtsp.test.ts delete mode 100644 streams/tests/recorder.test.ts delete mode 100644 streams/tests/replayer.test.ts rename streams/tests/{protocols.fixtures.ts => rtsp-headers.fixtures.ts} (88%) create mode 100644 streams/tests/rtsp-headers.test.ts delete mode 100644 streams/tests/rtsp-parser-builder.test.ts delete mode 100644 streams/tests/rtsp-parser-parser.test.ts create mode 100644 streams/tests/rtsp-rtcp.fixtures.ts rename streams/tests/{protocols-rtcp.test.ts => rtsp-rtcp.test.ts} (98%) create mode 100644 streams/tests/rtsp-rtp.fixtures.ts rename streams/tests/{protocols-rtp.test.ts => rtsp-rtp.test.ts} (96%) rename streams/tests/{protocols-sdp.test.ts => rtsp-sdp.test.ts} (89%) delete mode 100644 streams/tests/tcp.test.ts delete mode 100644 streams/tests/validate-component.ts create mode 100644 streams/tests/ws-source.test.ts delete mode 100644 streams/tests/ws-source.test_.ts create mode 100644 streams/tsconfig.types.json 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..e6b33be35 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,28 +42,35 @@ 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, }) + pipeline.mse.mediaSource.addEventListener( + 'sourceopen', + () => { + // Setting a duration of zero seems to force lower latency + // on Firefox, and doesn't seem to affect Chromium. + pipeline.mse.mediaSource.duration = 0 + }, + { once: true } + ) + // 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-overlay.html b/example-streams-web/test/h264-overlay.html index 006d55176..dd24f12f8 100644 --- a/example-streams-web/test/h264-overlay.html +++ b/example-streams-web/test/h264-overlay.html @@ -8,6 +8,7 @@
diff --git a/example-streams-web/test/h264-player.js b/example-streams-web/test/h264-player.js index 5b50849a6..fb6e26492 100644 --- a/example-streams-web/test/h264-player.js +++ b/example-streams-web/test/h264-player.js @@ -1,25 +1,27 @@ -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.mse.mediaSource.addEventListener( + 'sourceopen', + () => { + // Setting a duration of zero seems to force lower latency + // on Firefox, and doesn't seem to affect Chromium. + pipeline.mse.mediaSource.duration = 0 + }, + { once: true } + ) + + pipeline.start() } play(window.location.hostname) diff --git a/example-streams-web/test/h264.html b/example-streams-web/test/h264.html index 6d29c1651..667f983b3 100644 --- a/example-streams-web/test/h264.html +++ b/example-streams-web/test/h264.html @@ -8,7 +8,9 @@
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/gst-rtsp-launch/README.md b/gst-rtsp-launch/README.md
index db3344f32..d753b6cf2 100644
--- a/gst-rtsp-launch/README.md
+++ b/gst-rtsp-launch/README.md
@@ -1,21 +1,36 @@
 # gst-rtsp-launch
 Run an RTSP server using gst-launch syntax.
 
-## Usage
+## Build
 
-To run an RTSP server, either create your own image (see below)
-or use the public image `steabert/gst-rtsp-launch`.
+The code for the RTSP server is an example that
+is part of [gst-rtsp-server](https://github.com/GStreamer/gst-rtsp-server)
+
+### Native
+
+Install necessary dependencies (meson and relevant gstreams libraries
+and plugins). Example below for Ubuntu 24.04:
 
 ```
-docker run --rm -p 8554:8554 steabert/gst-rtsp-launch
+sudo apt install \
+  meson \
+  libgstrtspserver-1.0-dev \
+  gstreamer1.0-plugins-rtp \
+  gstreamer1.0-plugins-base \
+  gstreamer1.0-plugins-ugly \
+  gstreamer1.0-plugins-good \
+  gstreamer1.0-plugins-bad \
+  gstreamer1.0-libav
 ```
-which will run the RTSP server with the default pipeline.
 
-## Building your own image
+Then change to the `src` directory and run:
 
-The code for the RTSP server is an example that
-is part of [gst-rtsp-server](https://github.com/GStreamer/gst-rtsp-server)
-and is compiled and run inside a docker container.
+```
+meson build
+ninja -C build
+```
+
+### Docker
 
 To create the docker container with the executable for
 the RTSP server inside, just run
@@ -28,3 +43,25 @@ then build a docker image that will run the RTSP server.
 
 When finished, you'll be left with a docker image tagged `gst-rtsp-launch`
 which you can use according to the instructions above.
+
+## Run
+
+### Native
+
+Use `src/build/gst-rtsp-launch` as binary.
+
+### Docker
+
+To run an RTSP server, create your own image (see build step)
+or use the public image `steabert/gst-rtsp-launch`.
+
+```
+docker run --rm -p 8554:8554 steabert/gst-rtsp-launch
+```
+which will run the RTSP server with the default pipeline.
+
+### Example
+
+Run a server with video and audio test source:
+
+src/build/gst-rtsp-launch "videotestsrc ! video/x-raw,width=1920,height=1080 ! timeoverlay text='H.264/AAC' valignment=top halignment=left ! x264enc ! rtph264pay name=pay0 pt=96 audiotestsrc ! avenc_aac ! rtpmp4gpay name=pay1 pt=97"
diff --git a/gst-rtsp-launch/src/meson.build b/gst-rtsp-launch/src/meson.build
new file mode 100644
index 000000000..a4866d4b7
--- /dev/null
+++ b/gst-rtsp-launch/src/meson.build
@@ -0,0 +1,17 @@
+project('gst-rtsp-launch', 'c',
+  version : '0.0.0',
+  meson_version : '>= 0.49.0',
+  default_options : [ 'warning_level=2',
+                      'werror=true',
+                      'buildtype=debugoptimized' ])
+
+gst_dep = dependency('gstreamer-1.0')
+gst_rtsp_server_dep = dependency('gstreamer-rtsp-server-1.0')
+
+executable('gst-rtsp-launch',
+  'gst-rtsp-launch.c',
+  dependencies : [
+    gst_dep,
+    gst_rtsp_server_dep,
+  ]
+)
\ No newline at end of file
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..fa54bec61 100755
--- a/scripts/rtsp-server.sh
+++ b/scripts/rtsp-server.sh
@@ -19,7 +19,7 @@
 # To use a different resolution, use a caps filter in the launch pipeline, e.g.:
 #   'videotestsrc ! video/x-raw,width=1280,height=720 ! x264enc ! rtph264pay name=pay0 pt=96'
 
-h264_pipeline="videotestsrc ! video/x-raw,width=1920,height=1080 ! timeoverlay text='H.264' valignment=top halignment=left ! x264enc ! rtph264pay name=pay0 pt=96"
+h264_pipeline="videotestsrc ! video/x-raw,width=1920,height=1080 ! timeoverlay text='H.264/AAC' valignment=top halignment=left ! x264enc ! rtph264pay name=pay0 pt=96 audiotestsrc ! avenc_aac ! rtpmp4gpay name=pay1 pt=97"
 h264_port="8554"
 mjpeg_pipeline="videotestsrc pattern=ball ! video/x-raw,width=1280,height=720,format=YUY2 ! timeoverlay text='MJPEG' valignment=top halignment=left ! jpegenc ! rtpjpegpay name=pay0 pt=96"
 mjpeg_port="8555"
@@ -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/mp4-muxer/aacSettings.ts b/streams/src/components/mp4-muxer/aacSettings.ts
new file mode 100644
index 000000000..ec0f34051
--- /dev/null
+++ b/streams/src/components/mp4-muxer/aacSettings.ts
@@ -0,0 +1,208 @@
+import type { AACMedia } from '../types/sdp'
+import { fromHex } from '../utils/bytes'
+
+import { Box, Container } from './isom'
+
+export const aacSettings = (media: AACMedia, date: number, trackId: number) => {
+  /*
+   * Example SDP media segment for MPEG4-GENERIC audio:
+   *
+
+  {
+     "type": "audio",
+     "port": "0",
+     "proto": "RTP/AVP",
+     "fmt": "97",
+     "connectionData": {
+       "netType": "IN",
+       "addrType": "IP4",
+       "connectionAddress": "0.0.0.0"
+     },
+     "bwtype": "AS",
+     "bandwidth": "32",
+     "rtpmap": {
+       "payloadType": "97",
+       "encodingName": "MPEG4-GENERIC",
+       "clockrate": "16000",
+       "encodingParameters": "1"
+     },
+     "fmtp": {
+       "format": "97",
+       "parameters": {
+         "streamtype": "5",
+         "profile-level-id": "2",
+         "mode": "AAC-hbr",
+         "config": "1408",
+         "sizelength": "13",
+         "indexlength": "3",
+         "indexdeltalength": "3",
+         "bitrate": "32000"
+       }
+     },
+     "control": "rtsp://hostname/axis-media/media.amp/stream=1?audio=1"
+   }
+
+   */
+
+  const bitrate = Number(media.fmtp.parameters.bitrate) || 320000
+
+  const audioConfigBytes = fromHex(media.fmtp.parameters.config)
+  const config = audioSpecificConfig(audioConfigBytes)
+
+  console.log('AAC config', media.fmtp.parameters.config, audioConfigBytes)
+
+  return {
+    tkhd: {
+      track_ID: trackId,
+      creation_time: date,
+      modification_time: date,
+      width: 0,
+      height: 0,
+      volume: 1,
+    },
+    mdhd: {
+      timescale: Number(media.rtpmap.clockrate),
+      creation_time: date,
+      modification_time: date,
+      duration: 0,
+    },
+
+    hdlr: {
+      handler_type: 'soun',
+      name: 'SoundHandler\0', // 00 soundhandler, add 00 if things screws up
+    },
+
+    mediaHeaderBox: new Box('smhd'),
+    sampleEntryBox: new Container(
+      'mp4a',
+      {
+        samplerate: (media.rtpmap.clockrate << 16) >>> 0, // FIXME: Is this  correct?
+      },
+      new Box('esds', {
+        DecoderConfigDescrLength: 15 + audioConfigBytes.length,
+        DecSpecificInfoShortLength: audioConfigBytes.length,
+        audioConfigBytes,
+        maxBitRate: bitrate,
+        avgBitRate: bitrate,
+      })
+    ),
+
+    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
+    to 1024 time-domain samples, which means that a single block (or frame?) is
+    1024 ticks long, which we take as default here.
+    */
+    defaultFrameDuration: 1024,
+
+    // CODEC info used for MIME type
+    codec: `mp4a.40.${config.audioObjectType}`,
+    name: aacEncodingName(config),
+  }
+}
+
+interface AudioSpecificConfig {
+  audioObjectType: number
+  frequencyIndex?: number
+  channelConfig?: number
+}
+
+function audioSpecificConfig(bytes: Uint8Array): AudioSpecificConfig {
+  // The "config" parameter is a hexadecimal representation of the AudioSpecificConfig()
+  // as defined in ISO/IEC 14496-3. Padding bits are added to achieve octet alignment.
+  // To keep it simple, we only try to extract what we need, which is the audio object
+  // type itself. The sampling frequency and channel configuration are only provided
+  // in the simple cases that are supported.
+  // AudioSpecificConfig()                              No. of bits
+  // {
+  //   audioObjectType;                                 5
+  //   if (audioObjectType == 31) {
+  //     audioObjectType = 32 + audioObjectTypeExt;     6
+  //   }
+  //   samplingFrequencyIndex;                          4
+  //   if ( samplingFrequencyIndex == 0xf ) {
+  //     samplingFrequency;                             24
+  //   }
+  //   channelConfiguration;                            4
+  // ... (we don't need the rest)
+  // }
+
+  let audioObjectType = bytes[0] >>> 3
+  if (audioObjectType === 31) {
+    const audioObjectTypeExt = (bytes[0] & 0x07) * 8 + (bytes[1] >>> 5)
+    audioObjectType = 32 + audioObjectTypeExt
+    return { audioObjectType }
+  }
+
+  const frequencyIndex = (bytes[0] & 0x07) * 2 + (bytes[1] >>> 7)
+  if (frequencyIndex === 0x0f) {
+    return { audioObjectType, frequencyIndex }
+  }
+
+  const channelConfig = (bytes[1] >>> 3) & 0x0f
+
+  return {
+    audioObjectType,
+    frequencyIndex,
+    channelConfig,
+  }
+}
+
+// All audio object types defined in ISO/IEC 14496-3 pp. 40
+const AUDIO_OBJECT_TYPE_NAMES: { [key: number]: string } = {
+  1: 'AAC Main',
+  2: 'AAC LC',
+}
+
+// All frequencies defined in ISO/IEC 14496-3 pp. 42
+const FREQUENCY_VALUES: { [key: number]: string } = {
+  0x0: '96 kHz',
+  0x1: '88.2 kHz',
+  0x2: '64 kHz',
+  0x3: '48 kHz',
+  0x4: '44.1 kHz',
+  0x5: '32 kHz',
+  0x6: '24 kHz',
+  0x7: '22.05 kHz',
+  0x8: '16 kHz',
+  0x9: '12 kHz',
+  0xa: '11.025 kHz',
+  0xb: '8 kHz',
+  0xc: '7.35 kHz',
+  0xd: 'unknown',
+  0xe: 'unknown',
+  0xf: 'custom',
+}
+
+// All channels defined in ISO/IEC 14496-3 pp. 42
+const CHANNEL_CONFIG_NAMES: { [key: number]: string } = {
+  1: 'Mono',
+  2: 'Stereo',
+}
+
+function aacEncodingName({
+  audioObjectType,
+  frequencyIndex,
+  channelConfig,
+}: AudioSpecificConfig): string {
+  const audioType =
+    AUDIO_OBJECT_TYPE_NAMES[audioObjectType] || `AAC (${audioObjectType})`
+
+  if (frequencyIndex === undefined) {
+    return `${audioType}`
+  }
+
+  const samplingRate = FREQUENCY_VALUES[frequencyIndex]
+
+  if (channelConfig === undefined) {
+    return `${audioType}, ${samplingRate}`
+  }
+
+  const channels =
+    CHANNEL_CONFIG_NAMES[channelConfig] || channelConfig.toString()
+
+  return `${audioType}, ${samplingRate}, ${channels}`
+}
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 98%
rename from streams/src/components/mp4muxer/helpers/isom.ts
rename to streams/src/components/mp4-muxer/isom.ts
index 4c8a47b23..49f642050 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')
   }
 }
@@ -701,8 +701,7 @@ const BOXSPEC: { [key: string]: BoxSpec } = {
   },
   /* Elementary stream descriptor
   basic box that holds only an ESDescriptor
-  reference: 'https://developer.apple.com/library/content/documentation/QuickTime/
-QTFF/QTFFChap3/qtff3.html#//apple_ref/doc/uid/TP40000939-CH205-124774'
+  reference: 'https://developer.apple.com/library/content/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html#//apple_ref/doc/uid/TP40000939-CH205-124774'
   Descriptors have a tag that identifies them, specified in ISO/IEC 14496-1 8.3.12
   ISO/IEC 14496-1 8.3.3 (pp. 24) ES_Descriptor
   aligned(8) class ES_Descriptor : bit(8) tag=ES_DescrTag {
@@ -798,15 +797,15 @@ QTFF/QTFFChap3/qtff3.html#//apple_ref/doc/uid/TP40000939-CH205-124774'
       ['DecoderConfigDescrTag', UInt8, 4],
       // length of the remainder of this descriptor in bytes,
       // excluding trailing embedded descriptors.
-      ['DecoderConfigDescrLength', UInt8, 17],
+      ['DecoderConfigDescrLength', UInt8, 15],
       ['objectProfileIndication', UInt8, 0x40],
       ['streamTypeUpstreamReserved', UInt8, 0x15],
       ['bufferSizeDB', UInt8Array, [0, 0, 0]],
       ['maxBitRate', UInt32BE, 0],
       ['avgBitRate', UInt32BE, 0],
       ['DecSpecificInfoShortTag', UInt8, 5],
-      ['DecSpecificInfoShortLength', UInt8, 2],
-      ['audioConfigBytes', UInt16BE, 0],
+      ['DecSpecificInfoShortLength', UInt8, 0],
+      ['audioConfigBytes', UInt8Array, []],
       ['SLConfigDescrTag', UInt8, 6],
       ['SLConfigDescrLength', UInt8, 1],
       ['SLConfigDescrPredefined', UInt8, 0x02], // ISO use
@@ -1327,14 +1326,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..6fb7bd3dc 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
@@ -47,10 +49,10 @@ const mp4BoxInfo = (chunks: Uint8Array[]): Mp4BoxInfo => {
  * @type {[type]}
  */
 export class Parser {
-  private _chunks: Uint8Array[] = []
-  private _length = 0
-  private _box?: Mp4BoxInfo
-  private _ftyp?: Uint8Array
+  private chunks: Uint8Array[] = []
+  private length = 0
+  private box?: Mp4BoxInfo
+  private ftyp?: Uint8Array
 
   /**
    * Create a new Parser object.
@@ -64,13 +66,13 @@ export class Parser {
    * values.
    */
   _init(): void {
-    this._chunks = []
-    this._length = 0
+    this.chunks = []
+    this.length = 0
   }
 
   _push(chunk: Uint8Array): void {
-    this._chunks.push(chunk)
-    this._length += chunk.length
+    this.chunks.push(chunk)
+    this.length += chunk.length
   }
 
   /**
@@ -79,17 +81,17 @@ export class Parser {
    */
   _parseBox(): Uint8Array | null {
     // Skip as long as we don't have the first 8 bytes
-    if (this._length < BOX_HEADER_BYTES) {
+    if (this.length < BOX_HEADER_BYTES) {
       return null
     }
 
     // Enough bytes to construct the header and extract packet info.
-    if (!this._box) {
-      this._box = mp4BoxInfo(this._chunks)
+    if (!this.box) {
+      this.box = mp4BoxInfo(this.chunks)
     }
 
     // As long as we don't have enough chunks, skip.
-    if (this._length < this._box.size) {
+    if (this.length < this.box.size) {
       return null
     }
 
@@ -97,23 +99,23 @@ export class Parser {
     // The buffer package has a problem that it doesn't optimize concatenation
     // of an array with only one buffer, check for that (prevents performance issue)
     const buffer =
-      this._chunks.length === 1 ? this._chunks[0] : concat(this._chunks)
-    const box = buffer.slice(0, this._box.size)
-    const trailing = buffer.slice(this._box.size)
+      this.chunks.length === 1 ? this.chunks[0] : concat(this.chunks)
+    const box = buffer.slice(0, this.box.size)
+    const trailing = buffer.slice(this.box.size)
 
     // Prepare next bit.
     this._init()
     this._push(trailing)
 
     // Ignore invalid boxes
-    if (!ISO_BMFF_BOX_TYPES.has(this._box.type)) {
+    if (!ISO_BMFF_BOX_TYPES.has(this.box.type)) {
       console.warn(
-        `ignored non-ISO BMFF Byte Stream box type: ${this._box.type} (${this._box.size} bytes)`
+        `ignored non-ISO BMFF Byte Stream box type: ${this.box.type} (${this.box.size} bytes)`
       )
       return new Uint8Array(0)
     }
 
-    delete this._box
+    delete this.box
 
     return box
   }
@@ -136,18 +138,18 @@ export class Parser {
 
       if (data !== null) {
         if (boxType(data) === 'ftyp') {
-          this._ftyp = data
+          this.ftyp = data
         } 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/aacSettings.ts b/streams/src/components/mp4muxer/helpers/aacSettings.ts
deleted file mode 100644
index b678029f4..000000000
--- a/streams/src/components/mp4muxer/helpers/aacSettings.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-import { AACMedia } from '../../../utils/protocols/sdp'
-
-import { Box, Container } from './isom'
-
-// All audio object types defined in ISO/IEC 14496-3 pp. 40
-const AUDIO_OBJECT_TYPE_NAMES: { [key: number]: string } = {
-  1: 'AAC Main',
-  2: 'AAC LC',
-}
-
-// All frequencies defined in ISO/IEC 14496-3 pp. 42
-const FREQUENCY_VALUES: { [key: number]: string } = {
-  0: '96 kHz',
-  1: '88.2 kHz',
-  2: '64 kHz',
-  3: '48 kHz',
-  4: '44.1 kHz',
-  5: '32 kHz',
-  6: '24 kHz',
-  7: '22.05 kHz',
-  8: '16 kHz',
-  9: '12 kHz',
-  10: '11.025 kHz',
-  11: '8 kHz',
-  12: '7.35 kHz',
-}
-
-// All channels defined in ISO/IEC 14496-3 pp. 42
-const CHANNEL_CONFIG_NAMES: { [key: number]: string } = {
-  1: 'Mono',
-  2: 'Stereo',
-}
-
-export interface AACEncoding {
-  coding: string
-  samplingRate: string
-  channels: string
-}
-
-const aacEncodingName = (audioConfigBytes: number): AACEncoding => {
-  const audioObjectType = (audioConfigBytes >>> 11) & 0x001f
-  const frequencyIndex = (audioConfigBytes >>> 7) & 0x000f
-  const channelConfig = (audioConfigBytes >>> 3) & 0x000f
-
-  const audioType =
-    AUDIO_OBJECT_TYPE_NAMES[audioObjectType] || `AAC (${audioObjectType})`
-  const samplingRate = FREQUENCY_VALUES[frequencyIndex] || 'unknown'
-  const channels =
-    CHANNEL_CONFIG_NAMES[channelConfig] || channelConfig.toString()
-
-  return {
-    coding: audioType,
-    samplingRate,
-    channels,
-  }
-}
-
-export const aacSettings = (media: AACMedia, date: number, trackId: number) => {
-  /*
-   * Example SDP media segment for MPEG4-GENERIC audio:
-   *
-
-  {
-     "type": "audio",
-     "port": "0",
-     "proto": "RTP/AVP",
-     "fmt": "97",
-     "connectionData": {
-       "netType": "IN",
-       "addrType": "IP4",
-       "connectionAddress": "0.0.0.0"
-     },
-     "bwtype": "AS",
-     "bandwidth": "32",
-     "rtpmap": {
-       "payloadType": "97",
-       "encodingName": "MPEG4-GENERIC",
-       "clockrate": "16000",
-       "encodingParameters": "1"
-     },
-     "fmtp": {
-       "format": "97",
-       "parameters": {
-         "streamtype": "5",
-         "profile-level-id": "2",
-         "mode": "AAC-hbr",
-         "config": "1408",
-         "sizelength": "13",
-         "indexlength": "3",
-         "indexdeltalength": "3",
-         "bitrate": "32000"
-       }
-     },
-     "control": "rtsp://hostname/axis-media/media.amp/stream=1?audio=1"
-   }
-
-   */
-
-  const bitrate = Number(media.fmtp.parameters.bitrate) || 320000
-  const audioConfigBytes = parseInt(media.fmtp.parameters.config, 16)
-  const audioObjectType = (audioConfigBytes >>> 11) & 0x001f
-
-  return {
-    tkhd: {
-      track_ID: trackId,
-      creation_time: date,
-      modification_time: date,
-      width: 0,
-      height: 0,
-      volume: 1,
-    },
-    mdhd: {
-      timescale: Number(media.rtpmap.clockrate),
-      creation_time: date,
-      modification_time: date,
-      duration: 0,
-    },
-
-    hdlr: {
-      handler_type: 'soun',
-      name: 'SoundHandler\0', // 00 soundhandler, add 00 if things screws up
-    },
-
-    mediaHeaderBox: new Box('smhd'),
-    sampleEntryBox: new Container(
-      'mp4a',
-      {
-        samplerate: (media.rtpmap.clockrate << 16) >>> 0, // FIXME: Is this  correct?
-      },
-      new Box('esds', {
-        audioConfigBytes, // Converting from hex string to int
-        maxBitRate: bitrate,
-        avgBitRate: bitrate,
-      })
-    ),
-
-    /*
-    https://wiki.multimedia.cx/index.php/Understanding_AAC
-    AAC is a variable bitrate (VBR) block-based codec where each block decodes
-    to 1024 time-domain samples, which means that a single block (or frame?) is
-    1024 ticks long, which we take as default here.
-    */
-    defaultFrameDuration: 1024,
-
-    // MIME type
-    mime: `mp4a.40.${audioObjectType}`,
-    codec: aacEncodingName(audioConfigBytes),
-  }
-}
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..479c7415a
--- /dev/null
+++ b/streams/src/components/mse-sink.ts
@@ -0,0 +1,153 @@
+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 sourceBuffer?: Promise
+  private videoEl: HTMLVideoElement
+
+  constructor(videoEl: HTMLVideoElement, mimeType?: string) {
+    this.lastCheckpointTime = 0
+    this.videoEl = videoEl
+
+    if (mimeType !== undefined) {
+      this.sourceBuffer = newSourceBuffer(
+        this.mediaSource,
+        this.videoEl,
+        mimeType
+      )
+    }
+
+    this.writable = new WritableStream({
+      write: async (msg: IsomMessage, controller) => {
+        if (msg.mimeType !== undefined) {
+          this.sourceBuffer = newSourceBuffer(
+            this.mediaSource,
+            this.videoEl,
+            msg.mimeType
+          )
+        }
+
+        if (!this.sourceBuffer) {
+          controller.error(
+            'missing SourceBuffer, either initialize with MIME type or use MP4 muxer'
+          )
+          return
+        }
+
+        const sourceBuffer = await this.sourceBuffer
+
+        const checkpoint = this.updateCheckpointTime(msg.checkpointTime)
+        if (checkpoint !== undefined) {
+          await freeBuffer(sourceBuffer, checkpoint)
+        }
+
+        await appendBuffer(sourceBuffer, msg.data)
+      },
+      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.sourceBuffer
+    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 appendBuffer(sourceBuffer: SourceBuffer, data: Uint8Array) {
+  return new Promise((resolve, reject) => {
+    try {
+      sourceBuffer.addEventListener('updateend', resolve, { once: true })
+      sourceBuffer.appendBuffer(data)
+    } 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..4b7f7bb57
--- /dev/null
+++ b/streams/src/components/rtp/depay.ts
@@ -0,0 +1,75 @@
+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?.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..9f3567d0a
--- /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: previously, frames 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..f693cf422
--- /dev/null
+++ b/streams/src/components/rtsp/sdp.ts
@@ -0,0 +1,195 @@
+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:
+  }
+}
+
+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 current = struct.session
+  for (const line of sdp) {
+    if (newMediaLevel(line)) {
+      current = {}
+      struct.media.push(current)
+    }
+    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 90%
rename from streams/src/utils/bytes.ts
rename to streams/src/components/utils/bytes.ts
index cbbb9dde7..61577b0ec 100644
--- a/streams/src/utils/bytes.ts
+++ b/streams/src/components/utils/bytes.ts
@@ -119,3 +119,14 @@ export function writeUInt32BE(
     bytes.byteLength
   ).setUint32(byteOffset, value)
 }
+
+export function fromHex(hex: string): Uint8Array {
+  if (hex.length % 2 !== 0) {
+    throw new Error('invalid hex representation of bytes (not a multiple of 2)')
+  }
+  const bytes = new Uint8Array(hex.length / 2)
+  bytes.forEach((_, i) => {
+    bytes[i] = parseInt(hex.substring(2 * i, 2 * (i + 1)), 16)
+  })
+  return bytes
+}
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..5ac76e26b 100644
--- a/streams/tests/h264depay.test.ts
+++ b/streams/tests/h264depay.test.ts
@@ -1,9 +1,77 @@
-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=='
+    )
+    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..b9b167b79 100644
--- a/streams/tests/rtsp-parser.test.ts
+++ b/streams/tests/rtsp-parser.test.ts
@@ -1,9 +1,246 @@
-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 = 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..01d41200d
--- /dev/null
+++ b/streams/tests/rtsp-rtcp.fixtures.ts
@@ -0,0 +1,67 @@
+/* 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..b87e8c0ee
--- /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('done'))
+      .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"