├── .editorconfig ├── .github ├── dependabot.yml ├── logo.svg └── workflows │ ├── docker.yml │ ├── js.yml │ ├── release.yml │ └── rust.yml ├── .gitignore ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── biome.jsonc ├── js ├── .dockerignore ├── Dockerfile ├── api-extractor.json ├── biome.jsonc ├── hang-demo │ ├── README.md │ ├── package.json │ ├── src │ │ ├── announce.html │ │ ├── announce.tsx │ │ ├── favicon.svg │ │ ├── highlight.ts │ │ ├── index.css │ │ ├── index.html │ │ ├── index.ts │ │ ├── publish.html │ │ └── publish.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ └── vite.config.ts ├── hang │ ├── README.md │ ├── api-extractor.json │ ├── package.json │ ├── scripts │ │ └── release.ts │ ├── src │ │ ├── catalog │ │ │ ├── audio.ts │ │ │ ├── broadcast.ts │ │ │ ├── index.ts │ │ │ ├── track.ts │ │ │ └── video.ts │ │ ├── connection.ts │ │ ├── container │ │ │ ├── decoder.ts │ │ │ ├── frame.ts │ │ │ ├── index.ts │ │ │ └── vint.ts │ │ ├── index.ts │ │ ├── publish │ │ │ ├── audio.ts │ │ │ ├── broadcast.ts │ │ │ ├── controls.tsx │ │ │ ├── element.tsx │ │ │ ├── index.ts │ │ │ ├── publish.ts │ │ │ └── video.ts │ │ ├── support │ │ │ ├── element.tsx │ │ │ └── index.tsx │ │ └── watch │ │ │ ├── audio.ts │ │ │ ├── broadcast.ts │ │ │ ├── controls.tsx │ │ │ ├── element.tsx │ │ │ ├── index.ts │ │ │ └── video.ts │ └── tsconfig.json ├── justfile ├── moq │ ├── README.md │ ├── api-extractor.json │ ├── package.json │ ├── scripts │ │ └── release.ts │ ├── src │ │ ├── announced.ts │ │ ├── broadcast.ts │ │ ├── connection.ts │ │ ├── group.ts │ │ ├── index.ts │ │ ├── publisher.ts │ │ ├── subscriber.ts │ │ ├── track.test.ts │ │ ├── track.ts │ │ ├── util │ │ │ ├── error.ts │ │ │ ├── index.ts │ │ │ └── watch.ts │ │ └── wire │ │ │ ├── announce.ts │ │ │ ├── group.ts │ │ │ ├── index.ts │ │ │ ├── session.ts │ │ │ ├── stream.ts │ │ │ └── subscribe.ts │ └── tsconfig.json ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── signals │ ├── README.md │ ├── package.json │ ├── scripts │ │ └── release.ts │ ├── src │ │ └── index.ts │ └── tsconfig.json └── tsconfig.json ├── justfile └── rs ├── .cargo └── config.toml ├── .dockerignore ├── .release-plz.toml ├── .rustfmt.toml ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── hang-bbb ├── hang-cli ├── CHANGELOG.md ├── Cargo.toml └── src │ ├── client.rs │ ├── config.rs │ ├── main.rs │ └── server.rs ├── hang-gst ├── CHANGELOG.md ├── Cargo.toml ├── README.md ├── build.rs └── src │ ├── lib.rs │ ├── sink │ ├── imp.rs │ └── mod.rs │ └── source │ ├── imp.rs │ └── mod.rs ├── hang-wasm ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── README.md ├── hang-bbb ├── package.json ├── pnpm-lock.yaml ├── rspack.config.mjs ├── src │ ├── bridge.rs │ ├── bridge.ts │ ├── connect.rs │ ├── demo │ │ ├── index.ts │ │ ├── publish.html │ │ └── watch.html │ ├── error.rs │ ├── index.ts │ ├── lib.rs │ ├── message.rs │ ├── message.ts │ ├── publish │ │ ├── audio.rs │ │ ├── index.ts │ │ ├── message.rs │ │ ├── message.ts │ │ ├── mod.rs │ │ └── video.rs │ ├── watch │ │ ├── audio.rs │ │ ├── index.ts │ │ ├── message.rs │ │ ├── message.ts │ │ ├── mod.rs │ │ └── video.rs │ └── worklet │ │ ├── index.ts │ │ ├── message.rs │ │ ├── message.ts │ │ └── mod.rs └── tsconfig.json ├── hang ├── CHANGELOG.md ├── Cargo.toml └── src │ ├── audio │ ├── aac.rs │ ├── codec.rs │ └── mod.rs │ ├── broadcast.rs │ ├── catalog.rs │ ├── cmaf │ ├── error.rs │ ├── export.rs │ ├── import.rs │ └── mod.rs │ ├── error.rs │ ├── frame.rs │ ├── group.rs │ ├── lib.rs │ ├── room.rs │ ├── track.rs │ └── video │ ├── av1.rs │ ├── codec.rs │ ├── h264.rs │ ├── h265.rs │ ├── mod.rs │ └── vp9.rs ├── justfile ├── moq-clock ├── CHANGELOG.md ├── Cargo.toml └── src │ ├── clock.rs │ └── main.rs ├── moq-native ├── CHANGELOG.md ├── Cargo.toml └── src │ ├── lib.rs │ ├── log.rs │ ├── quic.rs │ └── tls.rs ├── moq-relay ├── CHANGELOG.md ├── Cargo.toml ├── README.md └── src │ ├── cluster.rs │ ├── connection.rs │ ├── main.rs │ └── web.rs ├── moq ├── CHANGELOG.md ├── Cargo.toml ├── README.md └── src │ ├── coding │ ├── decode.rs │ ├── encode.rs │ ├── mod.rs │ ├── size.rs │ └── varint.rs │ ├── error.rs │ ├── lib.rs │ ├── message │ ├── announce.rs │ ├── extensions.rs │ ├── frame.rs │ ├── group.rs │ ├── mod.rs │ ├── session.rs │ ├── setup.rs │ ├── stream.rs │ ├── subscribe.rs │ └── versions.rs │ ├── model │ ├── broadcast.rs │ ├── frame.rs │ ├── group.rs │ ├── mod.rs │ ├── origin.rs │ └── track.rs │ └── session │ ├── mod.rs │ ├── publisher.rs │ ├── reader.rs │ ├── stream.rs │ ├── subscriber.rs │ └── writer.rs └── rust-toolchain.toml /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | max_line_length = 120 11 | 12 | # YAML doesn't support hard tabs 🙃 13 | [**.yml] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'moq-relay-v*' 7 | - 'moq-clock-v*' 8 | - 'hang-v*' 9 | 10 | env: 11 | REGISTRY: docker.io/kixelated 12 | 13 | jobs: 14 | deploy: 15 | name: Release 16 | 17 | runs-on: ubuntu-latest 18 | permissions: 19 | contents: read 20 | packages: write 21 | id-token: write 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | # Figure out the docker image based on the tag 27 | - id: parse 28 | run: | 29 | ref=${GITHUB_REF#refs/tags/} 30 | if [[ "$ref" =~ ^([a-z-]+)-v([0-9.]+)$ ]]; then 31 | target=${BASH_REMATCH[1]} 32 | version=${BASH_REMATCH[2]} 33 | echo "target=$target" >> $GITHUB_OUTPUT 34 | echo "version=$version" >> $GITHUB_OUTPUT 35 | else 36 | echo "Tag format not recognized." >&2 37 | exit 1 38 | fi 39 | 40 | # I'm paying for Depot for faster ARM builds. 41 | - uses: depot/setup-action@v1 42 | 43 | # Login to docker.io 44 | - uses: docker/login-action@v3 45 | with: 46 | username: ${{ secrets.DOCKER_USERNAME }} 47 | password: ${{ secrets.DOCKER_PASSWORD }} 48 | 49 | # Build and push Docker image with Depot 50 | - uses: depot/build-push-action@v1 51 | with: 52 | project: r257ctfqm6 53 | file: rs/Dockerfile 54 | context: rs 55 | push: true 56 | target: ${{ steps.parse.outputs.target }} 57 | tags: | 58 | ${{ env.REGISTRY }}/${{ steps.parse.outputs.target }}:${{ steps.parse.outputs.version }} 59 | ${{ env.REGISTRY }}/${{ steps.parse.outputs.target }}:latest 60 | platforms: linux/amd64,linux/arm64 61 | -------------------------------------------------------------------------------- /.github/workflows/js.yml: -------------------------------------------------------------------------------- 1 | name: Javascript 2 | 3 | permissions: 4 | id-token: write 5 | contents: read 6 | 7 | on: 8 | pull_request: 9 | branches: ["main"] 10 | 11 | env: 12 | CARGO_TERM_COLOR: always 13 | 14 | jobs: 15 | check: 16 | name: Check 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | # Use depot/docker to run CI so it's semi-cached. 23 | - uses: depot/setup-action@v1 24 | - uses: depot/build-push-action@v1 25 | with: 26 | project: r257ctfqm6 27 | file: js/Dockerfile 28 | context: js -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | pull-requests: write 5 | contents: write 6 | 7 | # Only one release job can run at a time. 8 | concurrency: 9 | group: release 10 | cancel-in-progress: true 11 | 12 | on: 13 | push: 14 | branches: 15 | - main 16 | 17 | jobs: 18 | release: 19 | name: Plz 20 | 21 | runs-on: ubuntu-latest 22 | steps: 23 | # Generating a GitHub token, so that PRs and tags created by 24 | # the release-plz-action can trigger actions workflows. 25 | - name: Generate GitHub token 26 | uses: actions/create-github-app-token@v2 27 | id: generate-token 28 | with: 29 | # GitHub App ID secret name 30 | app-id: ${{ secrets.APP_ID }} 31 | # GitHub App private key secret name 32 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 33 | 34 | # Checkout the repository 35 | - name: Checkout repository 36 | uses: actions/checkout@v4 37 | with: 38 | fetch-depth: 0 39 | token: ${{ steps.generate-token.outputs.token }} 40 | 41 | # Instal Rust 42 | - name: Install Rust toolchain 43 | uses: dtolnay/rust-toolchain@stable 44 | 45 | # Unfortunatly, GStreamer is required for hang-gst to build. 46 | - name: Setup GStreamer 47 | run: | 48 | sudo apt-get update 49 | sudo apt-get remove libunwind-* 50 | sudo apt-get install -y \ 51 | libgstreamer1.0-dev \ 52 | libgstreamer-plugins-base1.0-dev \ 53 | libgstreamer-plugins-bad1.0-dev \ 54 | gstreamer1.0-plugins-base \ 55 | gstreamer1.0-plugins-good \ 56 | gstreamer1.0-plugins-bad \ 57 | gstreamer1.0-plugins-ugly \ 58 | gstreamer1.0-libav \ 59 | gstreamer1.0-tools \ 60 | gstreamer1.0-x \ 61 | gstreamer1.0-alsa \ 62 | gstreamer1.0-gl \ 63 | gstreamer1.0-gtk3 \ 64 | gstreamer1.0-qt5 \ 65 | gstreamer1.0-pulseaudio 66 | 67 | # Run release-plz to create PRs and releases 68 | - name: Release-plz 69 | uses: MarcoIeni/release-plz-action@v0.5 70 | with: 71 | manifest_path: ./rs/Cargo.toml 72 | config: ./rs/.release-plz.toml 73 | 74 | env: 75 | GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} 76 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 77 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | permissions: 4 | id-token: write 5 | contents: read 6 | 7 | on: 8 | pull_request: 9 | branches: ["main"] 10 | 11 | jobs: 12 | check: 13 | name: Check 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | # Use depot/docker to build the binaries and run CI. 20 | - uses: depot/setup-action@v1 21 | - uses: depot/build-push-action@v1 22 | with: 23 | project: r257ctfqm6 24 | file: rs/Dockerfile 25 | context: rs -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Local 2 | .DS_Store 3 | *.local 4 | *.log* 5 | 6 | # IDE 7 | .vscode/* 8 | !.vscode/extensions.json 9 | .idea 10 | 11 | # Build stuff. 12 | target 13 | pkg 14 | dist 15 | out 16 | 17 | # Don't leak dev stuff 18 | *.mp4 19 | *.fmp4 20 | *.crt 21 | *.key 22 | *.hex 23 | 24 | # We're using pnpm 25 | package-lock.json 26 | bun.lockb 27 | yarn.lock 28 | node_modules 29 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Luke Curley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /biome.jsonc: -------------------------------------------------------------------------------- 1 | // TODO This should be removed. I just need to find a way for the VSCode plugin to use js/biome.jsonc. 2 | { 3 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 4 | "files": { 5 | // Biome is too dumb to use our .gitignore. 6 | "ignore": ["**/node_modules", "**/dist"] 7 | }, 8 | "vcs": { 9 | "enabled": true, 10 | "clientKind": "git", 11 | "useIgnoreFile": true 12 | }, 13 | "formatter": { 14 | "enabled": true, 15 | "useEditorconfig": true 16 | }, 17 | "organizeImports": { "enabled": true }, 18 | "linter": { 19 | "enabled": true, 20 | "rules": { 21 | "a11y": { 22 | "useMediaCaption": "off" 23 | }, 24 | "style": { 25 | "useImportType": "off", 26 | "useNodejsImportProtocol": "off" 27 | } 28 | } 29 | }, 30 | "javascript": { 31 | "formatter": { 32 | "quoteStyle": "double" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /js/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /js/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:24.04 AS build 2 | 3 | WORKDIR /build 4 | ENV DEBIAN_FRONTEND=noninteractive 5 | ENV PNPM_HOME="/root/.local/share/pnpm" 6 | ENV PATH="$PNPM_HOME:$PATH" 7 | 8 | # Install required tools 9 | RUN apt-get update && apt-get install -y \ 10 | curl \ 11 | ca-certificates \ 12 | gnupg \ 13 | lsb-release \ 14 | sudo \ 15 | just \ 16 | build-essential 17 | 18 | # Add NodeSource PPA for latest Node.js 19 | RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ 20 | apt-get install -y nodejs 21 | 22 | # Install pnpm so it's cached between builds. 23 | RUN corepack enable && corepack prepare pnpm@10.11.0 --activate 24 | 25 | COPY . . 26 | 27 | # Install deps with cache mount 28 | RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ 29 | pnpm -r install --frozen-lockfile 30 | 31 | # Run the CI checks and make sure it builds. 32 | RUN just check --frozen-lockfile && \ 33 | just build --frozen-lockfile 34 | 35 | FROM scratch AS final 36 | COPY --from=build /build/hang/dist /dist -------------------------------------------------------------------------------- /js/biome.jsonc: -------------------------------------------------------------------------------- 1 | // NOTE: There's another biome.jsonc in the root that's used for VSCode until I can fix it. 2 | // This one is used for docker... 3 | { 4 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 5 | "files": { 6 | // Biome is too dumb to use our .gitignore. 7 | "ignore": ["**/node_modules", "**/dist"] 8 | }, 9 | "vcs": { 10 | "enabled": true, 11 | "clientKind": "git", 12 | "useIgnoreFile": true 13 | }, 14 | "formatter": { 15 | "enabled": true, 16 | "lineWidth": 120, 17 | "indentStyle": "tab", 18 | "indentWidth": 4, 19 | "lineEnding": "lf" 20 | }, 21 | "organizeImports": { "enabled": true }, 22 | "linter": { 23 | "enabled": true, 24 | "rules": { 25 | "a11y": { 26 | "useMediaCaption": "off" 27 | }, 28 | "style": { 29 | "useImportType": "off", 30 | "useNodejsImportProtocol": "off" 31 | } 32 | } 33 | }, 34 | "javascript": { 35 | "formatter": { 36 | "quoteStyle": "double" 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /js/hang-demo/README.md: -------------------------------------------------------------------------------- 1 |

2 | Media over QUIC 3 |

4 | 5 | Media over QUIC (MoQ) is a live (media) delivery protocol utilizing QUIC. 6 | It utilizes new browser technologies such as [WebTransport](https://developer.mozilla.org/en-US/docs/Web/API/WebTransport_API) and [WebCodecs](https://developer.mozilla.org/en-US/docs/Web/API/WebCodecs_API) to provide WebRTC-like functionality. 7 | Despite the focus on media, the transport is generic and designed to scale to enormous viewership via clustered relay servers (aka a CDN). 8 | See [quic.video](https://quic.video) for more information. 9 | 10 | **Note:** this project is a [fork](https://quic.video/blog/transfork) of the [IETF specification](https://datatracker.ietf.org/group/moq/documents/). 11 | The principles are the same but the implementation is exponentially simpler given a narrower focus (and no politics). 12 | 13 | # Usage 14 | These are demos, duh. 15 | We're using Vite but other bundlers should just work™. 16 | 17 | # License 18 | 19 | Licensed under either: 20 | 21 | - Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) 22 | - MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) 23 | -------------------------------------------------------------------------------- /js/hang-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kixelated/hang-demo", 3 | "private": true, 4 | "type": "module", 5 | "version": "0.1.0", 6 | "description": "Media over QUIC - Demo", 7 | "license": "(MIT OR Apache-2.0)", 8 | "repository": "github:kixelated/moq", 9 | "scripts": { 10 | "dev": "vite --open", 11 | "check": "tsc --noEmit" 12 | }, 13 | "dependencies": { 14 | "@kixelated/moq": "workspace:^0.5.0", 15 | "@kixelated/hang": "workspace:^0.1.2", 16 | "@kixelated/signals": "workspace:^0.1.0", 17 | "solid-js": "^1.9.7" 18 | }, 19 | "devDependencies": { 20 | "@tailwindcss/postcss": "^4.1.7", 21 | "@tailwindcss/typography": "^0.5.16", 22 | "@tailwindcss/vite": "^4.1.7", 23 | "highlight.js": "^11.11.1", 24 | "tailwindcss": "^4.1.7", 25 | "typescript": "^5.8.3", 26 | "vite": "^6.3.5", 27 | "vite-plugin-html": "^3.2.2", 28 | "vite-plugin-solid": "^2.11.6" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /js/hang-demo/src/announce.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MoQ Demo 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 |

Other demos:

21 | 25 | 26 |

Tips:

27 |

28 | The internal/origins/hostname broadcasts are used by the clustering mechanism. 29 | That's right, MoQ relays use MoQ to discover other nodes! 30 | These internal tracks won't be discoverable once authentication is implemented. 31 |

32 |

33 | If you abrubtly kill a broadcast, it can take a few seconds for a connection to fail the keep-alive 34 | check. 35 | The current configuration is a 10 second timeout, then a broadcast will be considered dead. 36 | Kill a broadcast and watch it get removed after a few seconds. 37 |

38 |

39 | The 0s ago is based on the client. 40 | There's no timestamp that gets serialized over the wire; this is just to demonstate that these announcements are 41 | live. 42 |

43 |

44 | Try creating a new broadcast! 45 | The updates are fully live (no polling) so any changes are reflected immediately. 46 | For example, try: just pub tos 47 |

48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /js/hang-demo/src/highlight.ts: -------------------------------------------------------------------------------- 1 | import hljs from "highlight.js/lib/core"; 2 | import bash from "highlight.js/lib/languages/bash"; 3 | import css from "highlight.js/lib/languages/css"; 4 | import javascript from "highlight.js/lib/languages/javascript"; 5 | import typescript from "highlight.js/lib/languages/typescript"; 6 | import html from "highlight.js/lib/languages/xml"; 7 | 8 | import "highlight.js/styles/atom-one-dark.css"; 9 | hljs.configure({ 10 | cssSelector: "code", 11 | }); 12 | hljs.registerLanguage("javascript", javascript); 13 | hljs.registerLanguage("typescript", typescript); 14 | hljs.registerLanguage("bash", bash); 15 | hljs.registerLanguage("html", html); 16 | hljs.registerLanguage("css", css); 17 | hljs.highlightAll(); 18 | -------------------------------------------------------------------------------- /js/hang-demo/src/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @plugin "@tailwindcss/typography"; 3 | 4 | @custom-variant dark (&:where(.dark, .dark *)); 5 | 6 | /* Stop Prism/Tailwind from changing the font size. */ 7 | code[class*="language-"], 8 | pre[class*="language-"] { 9 | font-size: 1rem !important; 10 | } 11 | 12 | button { 13 | cursor: pointer; 14 | } 15 | 16 | body { 17 | /* We're using Tailwind for the prose styles. */ 18 | @apply prose bg-neutral-950 text-white dark:prose-invert lg:prose-xl mx-auto; 19 | } 20 | -------------------------------------------------------------------------------- /js/hang-demo/src/index.ts: -------------------------------------------------------------------------------- 1 | import "./index.css"; 2 | import "./highlight"; 3 | 4 | import HangSupport from "@kixelated/hang/support/element"; 5 | import HangWatch from "@kixelated/hang/watch/element"; 6 | 7 | export { HangWatch, HangSupport }; 8 | 9 | const watch = document.querySelector("hang-watch") as HangWatch; 10 | 11 | // If query params are provided, use it as the broadcast name. 12 | const urlParams = new URLSearchParams(window.location.search); 13 | const name = urlParams.get("name") ?? "demo/bbb"; 14 | watch.setAttribute("url", `http://localhost:4443/${name}.hang`); 15 | -------------------------------------------------------------------------------- /js/hang-demo/src/publish.ts: -------------------------------------------------------------------------------- 1 | import "./index.css"; 2 | import "./highlight"; 3 | 4 | // We need to import Web Components with fully-qualified paths because of tree-shaking. 5 | import HangPublish from "@kixelated/hang/publish/element"; 6 | import HangSupport from "@kixelated/hang/support/element"; 7 | 8 | export { HangPublish, HangSupport }; 9 | 10 | const publish = document.querySelector("hang-publish") as HangPublish; 11 | const watch = document.getElementById("watch") as HTMLAnchorElement; 12 | const watchName = document.getElementById("watch-name") as HTMLSpanElement; 13 | 14 | const urlParams = new URLSearchParams(window.location.search); 15 | const name = urlParams.get("name") ?? "demo/me"; 16 | publish.setAttribute("url", `http://localhost:4443/${name}.hang`); 17 | watch.href = `index.html?name=${name}`; 18 | watchName.textContent = name; 19 | -------------------------------------------------------------------------------- /js/hang-demo/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // tailwind.config.js 2 | module.exports = { 3 | content: ["./src/*.{html,js,ts,jsx,tsx}"], 4 | plugins: [require("@tailwindcss/typography")], 5 | }; 6 | -------------------------------------------------------------------------------- /js/hang-demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["node_modules", "dist"], 5 | "compilerOptions": { 6 | "outDir": "dist", 7 | "jsx": "react-jsx", 8 | "jsxImportSource": "solid-js" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /js/hang-demo/vite.config.ts: -------------------------------------------------------------------------------- 1 | import tailwindcss from "@tailwindcss/vite"; 2 | import { defineConfig } from "vite"; 3 | import solidPlugin from "vite-plugin-solid"; 4 | 5 | export default defineConfig({ 6 | root: "src", 7 | plugins: [tailwindcss(), solidPlugin()], 8 | build: { 9 | target: "esnext", 10 | rollupOptions: { 11 | input: { 12 | watch: "index.html", 13 | publish: "publish.html", 14 | announce: "announce.html", 15 | }, 16 | }, 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /js/hang/README.md: -------------------------------------------------------------------------------- 1 |

2 | Media over QUIC 3 |

4 | 5 | Media over QUIC (MoQ) is a live (media) delivery protocol utilizing QUIC. 6 | It utilizes new browser technologies such as [WebTransport](https://developer.mozilla.org/en-US/docs/Web/API/WebTransport_API) and [WebCodecs](https://developer.mozilla.org/en-US/docs/Web/API/WebCodecs_API) to provide WebRTC-like functionality. 7 | Despite the focus on media, the transport is generic and designed to scale to enormous viewership via clustered relay servers (aka a CDN). 8 | See [quic.video](https://quic.video) for more information. 9 | 10 | **Note:** this project is a [fork](https://quic.video/blog/transfork) of the [IETF specification](https://datatracker.ietf.org/group/moq/documents/). 11 | The principles are the same but the implementation is exponentially simpler given a narrower focus (and no politics). 12 | 13 | # Usage 14 | This library contains a lot of media stuff. 15 | More documentation will be available later, until then refer to the code and especially the [demos](../hang-demo). 16 | 17 | ```html 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ``` 30 | 31 | The API is still evolving, so expect breaking changes. 32 | 33 | # License 34 | 35 | Licensed under either: 36 | 37 | - Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) 38 | - MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) 39 | -------------------------------------------------------------------------------- /js/hang/api-extractor.json: -------------------------------------------------------------------------------- 1 | /** 2 | * Config file for API Extractor. For more info, please visit: https://api-extractor.com 3 | */ 4 | { 5 | "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", 6 | "extends": "../api-extractor.json", 7 | "mainEntryPointFilePath": "dist/index.d.ts" 8 | } 9 | -------------------------------------------------------------------------------- /js/hang/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kixelated/hang", 3 | "type": "module", 4 | "version": "0.1.2", 5 | "description": "Media over QUIC library", 6 | "license": "(MIT OR Apache-2.0)", 7 | "repository": "github:kixelated/moq", 8 | "exports": { 9 | ".": "./src/index.ts", 10 | "./publish": "./src/publish/index.ts", 11 | "./publish/element": "./src/publish/element.tsx", 12 | "./watch": "./src/watch/index.ts", 13 | "./watch/element": "./src/watch/element.tsx", 14 | "./catalog": "./src/catalog/index.ts", 15 | "./container": "./src/container/index.ts", 16 | "./support": "./src/support/index.ts", 17 | "./support/element": "./src/support/element.tsx", 18 | "./connection": "./src/connection.ts" 19 | }, 20 | "sideEffects": ["./src/publish/element.ts", "./src/watch/element.ts", "./src/support/element.ts"], 21 | "files": ["./src", "./dist", "README.md", "tsconfig.json"], 22 | "scripts": { 23 | "build": "tsc -b", 24 | "check": "tsc --noEmit", 25 | "release": "tsx scripts/release.ts" 26 | }, 27 | "dependencies": { 28 | "@kixelated/moq": "workspace:^0.5.0", 29 | "@kixelated/signals": "workspace:^0.1.0", 30 | "buffer": "^6.0.3", 31 | "lodash": "^4.17.21", 32 | "solid-js": "^1.9.7", 33 | "zod": "^3.25.20" 34 | }, 35 | "devDependencies": { 36 | "@types/lodash": "^4.17.17", 37 | "@typescript/lib-dom": "npm:@types/web@^0.0.235", 38 | "typescript": "^5.8.3" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /js/hang/scripts/release.ts: -------------------------------------------------------------------------------- 1 | // ChatGPT made a script that rewrites package.json file to use the correct paths. 2 | // It's not pretty but nothing in NPM is. 3 | 4 | import { execSync } from "node:child_process"; 5 | import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; 6 | 7 | console.log("🧹 Cleaning dist/..."); 8 | rmSync("dist", { recursive: true, force: true }); 9 | 10 | console.log("🛠️ Building..."); 11 | execSync("pnpm i && pnpm build", { stdio: "inherit" }); 12 | 13 | console.log("✍️ Rewriting package.json..."); 14 | const pkg = JSON.parse(readFileSync("package.json", "utf8")); 15 | 16 | function rewritePath(p: string): string { 17 | return p.replace(/^\.\/src/, "./dist").replace(/\.ts(x)?$/, ".js"); 18 | } 19 | 20 | pkg.main &&= rewritePath(pkg.main); 21 | pkg.types &&= rewritePath(pkg.types); 22 | 23 | if (pkg.exports) { 24 | for (const key in pkg.exports) { 25 | const val = pkg.exports[key]; 26 | if (typeof val === "string") { 27 | pkg.exports[key] = rewritePath(val); 28 | } else if (typeof val === "object") { 29 | for (const sub in val) { 30 | if (typeof val[sub] === "string") { 31 | val[sub] = rewritePath(val[sub]); 32 | } 33 | } 34 | } 35 | } 36 | } 37 | 38 | if (pkg.sideEffects) { 39 | pkg.sideEffects = pkg.sideEffects.map(rewritePath); 40 | } 41 | 42 | // biome-ignore lint/performance/noDelete: 43 | delete pkg.devDependencies; 44 | // biome-ignore lint/performance/noDelete: 45 | delete pkg.scripts; 46 | 47 | // Temporarily swap out the package.json with the one that has the correct paths. 48 | renameSync("package.json", "package.backup.json"); 49 | try { 50 | writeFileSync("package.json", JSON.stringify(pkg, null, 2)); 51 | 52 | console.log("🚀 Publishing..."); 53 | execSync("pnpm publish --access=public --no-git-checks", { 54 | stdio: "inherit", 55 | }); 56 | } finally { 57 | renameSync("package.backup.json", "package.json"); 58 | } 59 | -------------------------------------------------------------------------------- /js/hang/src/catalog/audio.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4-mini"; 2 | 3 | import { TrackSchema } from "./track"; 4 | 5 | // Mirrors AudioDecoderConfig 6 | // https://w3c.github.io/webcodecs/#audio-decoder-config 7 | export const AudioConfigSchema = z.object({ 8 | // See: https://w3c.github.io/webcodecs/codec_registry.html 9 | codec: z.string(), 10 | 11 | // The description is used for some codecs. 12 | // If provided, we can initialize the decoder based on the catalog alone. 13 | // Otherwise, the initialization information is in-band. 14 | description: z.optional(z.string()), // hex encoded 15 | 16 | // The sample rate of the audio in Hz 17 | sampleRate: z.uint32(), 18 | 19 | // The number of channels in the audio 20 | numberOfChannels: z.uint32(), 21 | 22 | // The bitrate of the audio in bits per second 23 | // TODO: Support up to Number.MAX_SAFE_INTEGER 24 | bitrate: z.optional(z.uint32()), 25 | }); 26 | 27 | export const AudioSchema = z.object({ 28 | // The MoQ track information. 29 | track: TrackSchema, 30 | 31 | // The configuration of the audio track 32 | config: AudioConfigSchema, 33 | }); 34 | 35 | export type Audio = z.infer; 36 | export type AudioConfig = z.infer; 37 | -------------------------------------------------------------------------------- /js/hang/src/catalog/broadcast.ts: -------------------------------------------------------------------------------- 1 | import * as Moq from "@kixelated/moq"; 2 | import { z } from "zod/v4-mini"; 3 | 4 | import { type Audio, AudioSchema } from "./audio"; 5 | import { type Video, VideoSchema } from "./video"; 6 | 7 | export const BroadcastSchema = z.object({ 8 | video: z.optional(z.array(VideoSchema)), 9 | audio: z.optional(z.array(AudioSchema)), 10 | }); 11 | 12 | export class Broadcast { 13 | video: Video[] = []; 14 | audio: Audio[] = []; 15 | 16 | encode() { 17 | return JSON.stringify(this); 18 | } 19 | 20 | static decode(raw: Uint8Array): Broadcast { 21 | const decoder = new TextDecoder(); 22 | const str = decoder.decode(raw); 23 | const json = JSON.parse(str); 24 | const parsed = BroadcastSchema.parse(json); 25 | 26 | const broadcast = new Broadcast(); 27 | broadcast.video = parsed.video ?? []; 28 | broadcast.audio = parsed.audio ?? []; 29 | 30 | return broadcast; 31 | } 32 | 33 | static async fetch(track: Moq.TrackConsumer): Promise { 34 | const group = await track.nextGroup(); 35 | if (!group) return undefined; // track is done 36 | 37 | try { 38 | const frame = await group.readFrame(); 39 | if (!frame) throw new Error("empty group"); 40 | return Broadcast.decode(frame); 41 | } finally { 42 | group.close(); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /js/hang/src/catalog/index.ts: -------------------------------------------------------------------------------- 1 | export type { Audio } from "./audio"; 2 | export { Broadcast } from "./broadcast"; 3 | export type { Track } from "./track"; 4 | export type { Video } from "./video"; 5 | -------------------------------------------------------------------------------- /js/hang/src/catalog/track.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4-mini"; 2 | 3 | export const TrackSchema = z.object({ 4 | name: z.string(), 5 | priority: z.uint32(), 6 | bitrate: z.optional(z.uint32()), 7 | }); 8 | 9 | export type Track = z.infer; 10 | -------------------------------------------------------------------------------- /js/hang/src/catalog/video.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4-mini"; 2 | 3 | import { TrackSchema } from "./track"; 4 | 5 | export const VideoConfigSchema = z.object({ 6 | // See: https://w3c.github.io/webcodecs/codec_registry.html 7 | codec: z.string(), 8 | 9 | // The description is used for some codecs. 10 | // If provided, we can initialize the decoder based on the catalog alone. 11 | // Otherwise, the initialization information is (repeated) before each key-frame. 12 | description: z.optional(z.string()), // hex encoded 13 | 14 | // The width and height of the video in pixels 15 | codedWidth: z.optional(z.uint32()), 16 | codedHeight: z.optional(z.uint32()), 17 | 18 | // Ratio of display width/height to coded width/height 19 | // Allows stretching/squishing individual "pixels" of the video 20 | // If not provided, the display ratio is 1:1 21 | displayRatioWidth: z.optional(z.uint32()), 22 | displayRatioHeight: z.optional(z.uint32()), 23 | 24 | // The frame rate of the video in frames per second 25 | framerate: z.optional(z.uint32()), 26 | 27 | // The bitrate of the video in bits per second 28 | // TODO: Support up to Number.MAX_SAFE_INTEGER 29 | bitrate: z.optional(z.uint32()), 30 | 31 | // If true, the decoder will optimize for latency. 32 | // Default: true 33 | optimizeForLatency: z.optional(z.boolean()), 34 | 35 | // The rotation of the video in degrees. 36 | // Default: 0 37 | rotation: z.optional(z.number()), 38 | 39 | // If true, the decoder will flip the video horizontally 40 | // Default: false 41 | flip: z.optional(z.boolean()), 42 | }); 43 | 44 | // Mirrors VideoDecoderConfig 45 | // https://w3c.github.io/webcodecs/#video-decoder-config 46 | export const VideoSchema = z.object({ 47 | // The MoQ track information. 48 | track: TrackSchema, 49 | 50 | // The configuration of the video track 51 | config: VideoConfigSchema, 52 | }); 53 | 54 | export type Video = z.infer; 55 | export type VideoConfig = z.infer; 56 | -------------------------------------------------------------------------------- /js/hang/src/connection.ts: -------------------------------------------------------------------------------- 1 | import * as Moq from "@kixelated/moq"; 2 | import { Signal, Signals, signal } from "@kixelated/signals"; 3 | 4 | export type ConnectionProps = { 5 | // The URL of the relay server. 6 | url?: URL; 7 | 8 | // Reload the connection when it disconnects. 9 | // default: true 10 | reload?: boolean; 11 | 12 | // The delay in milliseconds before reconnecting. 13 | // default: 1000 14 | delay?: DOMHighResTimeStamp; 15 | 16 | // The maximum delay in milliseconds. 17 | // default: 30000 18 | maxDelay?: number; 19 | }; 20 | 21 | export class Connection { 22 | url: Signal; 23 | status = signal<"connecting" | "connected" | "disconnected">("disconnected"); 24 | established = signal(undefined); 25 | 26 | readonly reload: boolean; 27 | readonly delay: number; 28 | readonly maxDelay: number; 29 | 30 | #signals = new Signals(); 31 | #delay: number; 32 | 33 | // Increased by 1 each time to trigger a reload. 34 | #tick = signal(0); 35 | 36 | constructor(props?: ConnectionProps) { 37 | this.url = signal(props?.url); 38 | this.reload = props?.reload ?? true; 39 | this.delay = props?.delay ?? 1000; 40 | this.maxDelay = props?.maxDelay ?? 30000; 41 | 42 | this.#delay = this.delay; 43 | 44 | // Create a reactive root so cleanup is easier. 45 | this.#signals.effect(() => this.#connect()); 46 | } 47 | 48 | #connect() { 49 | // Will retry when the tick changes. 50 | this.#tick.get(); 51 | 52 | const url = this.url.get(); 53 | if (!url) return; 54 | 55 | this.status.set("connecting"); 56 | 57 | (async () => { 58 | try { 59 | const connection = await Moq.Connection.connect(url); 60 | this.established.set(connection); 61 | this.status.set("connected"); 62 | 63 | // Reset the exponential backoff on success. 64 | this.#delay = this.delay; 65 | 66 | await connection.closed(); 67 | } catch (err) { 68 | console.warn("connection error:", err); 69 | 70 | this.established.set(undefined); 71 | this.status.set("disconnected"); 72 | 73 | if (!this.reload) return; 74 | const tick = this.#tick.peek() + 1; 75 | 76 | setTimeout(() => { 77 | this.#tick.set((prev) => Math.max(prev, tick)); 78 | }, this.#delay); 79 | 80 | // Exponential backoff. 81 | this.#delay = Math.min(this.#delay * 2, this.maxDelay); 82 | } 83 | })(); 84 | 85 | return () => { 86 | this.established.set((prev) => { 87 | prev?.close(); 88 | return undefined; 89 | }); 90 | this.status.set("disconnected"); 91 | }; 92 | } 93 | 94 | close() { 95 | this.#signals.close(); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /js/hang/src/container/decoder.ts: -------------------------------------------------------------------------------- 1 | import * as Moq from "@kixelated/moq"; 2 | import { Frame } from "./frame"; 3 | 4 | export class Decoder { 5 | #track: Moq.TrackConsumer; 6 | #group?: Moq.GroupConsumer; 7 | 8 | #nextGroup?: Promise; 9 | #nextFrame?: Promise; 10 | 11 | constructor(track: Moq.TrackConsumer) { 12 | this.#track = track; 13 | this.#nextGroup = track.nextGroup(); 14 | } 15 | 16 | async readFrame(): Promise { 17 | for (;;) { 18 | if (this.#nextGroup && this.#nextFrame) { 19 | // Figure out if nextFrame or nextGroup wins the race. 20 | // TODO support variable latency 21 | const next: { frame: Frame | undefined } | { group: Moq.GroupConsumer | undefined } = 22 | await Promise.race([ 23 | this.#nextFrame.then((frame) => ({ frame })), 24 | this.#nextGroup.then((group) => ({ group })), 25 | ]); 26 | 27 | if ("frame" in next) { 28 | const frame = next.frame; 29 | if (!frame) { 30 | // The group is done, wait for the next one. 31 | this.#nextFrame = undefined; 32 | continue; 33 | } 34 | 35 | // We got a frame; return it. 36 | // But first start fetching the next frame (note: group cannot be undefined). 37 | this.#nextFrame = this.#group ? Frame.decode(this.#group, false) : undefined; 38 | return frame; 39 | } 40 | 41 | const group = next.group; 42 | if (!group) { 43 | // The track is done, but finish with the current group. 44 | this.#nextGroup = undefined; 45 | continue; 46 | } 47 | 48 | // Start fetching the next group. 49 | this.#nextGroup = this.#track.nextGroup(); 50 | 51 | if (this.#group && this.#group.id >= group.id) { 52 | // Skip this old group. 53 | console.warn(`skipping old group: ${group.id} < ${this.#group.id}`); 54 | group.close(); 55 | continue; 56 | } 57 | 58 | // Skip the rest of the current group. 59 | this.#group?.close(); 60 | this.#group = next.group; 61 | this.#nextFrame = this.#group ? Frame.decode(this.#group, true) : undefined; 62 | } else if (this.#nextGroup) { 63 | // Wait for the next group. 64 | const group = await this.#nextGroup; 65 | this.#group?.close(); 66 | this.#group = group; 67 | 68 | if (this.#group) { 69 | this.#nextGroup = this.#track.nextGroup(); 70 | this.#nextFrame = Frame.decode(this.#group, true); 71 | } else { 72 | this.#nextGroup = undefined; 73 | this.#nextFrame = undefined; 74 | return undefined; 75 | } 76 | } else if (this.#nextFrame) { 77 | // We got the next frame, or potentially the end of the track. 78 | const frame = await this.#nextFrame; 79 | if (!frame) { 80 | this.#group?.close(); 81 | this.#group = undefined; 82 | 83 | this.#nextFrame = undefined; 84 | this.#nextGroup = undefined; 85 | return undefined; 86 | } 87 | 88 | this.#nextFrame = this.#group ? Frame.decode(this.#group, false) : undefined; 89 | return frame; 90 | } else { 91 | return undefined; 92 | } 93 | } 94 | } 95 | 96 | close() { 97 | this.#nextFrame = undefined; 98 | this.#nextGroup = undefined; 99 | 100 | this.#group?.close(); 101 | this.#group = undefined; 102 | 103 | this.#track.close(); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /js/hang/src/container/frame.ts: -------------------------------------------------------------------------------- 1 | import type { GroupConsumer, GroupProducer } from "@kixelated/moq"; 2 | import { getVint53, setVint53 } from "./vint"; 3 | 4 | export class Frame { 5 | keyframe: boolean; 6 | timestamp: number; 7 | data: Uint8Array; 8 | 9 | constructor(keyframe: boolean, timestamp: number, data: Uint8Array) { 10 | this.keyframe = keyframe; 11 | this.timestamp = timestamp; 12 | this.data = data; 13 | } 14 | 15 | static async decode(group: GroupConsumer, keyframe: boolean): Promise { 16 | const payload = await group.readFrame(); 17 | if (!payload) { 18 | return undefined; 19 | } 20 | 21 | const [timestamp, data] = getVint53(payload); 22 | return new Frame(keyframe, timestamp, data); 23 | } 24 | 25 | encode(group: GroupProducer) { 26 | let frame = new Uint8Array(8 + this.data.byteLength); 27 | const size = setVint53(frame, this.timestamp).byteLength; 28 | frame.set(this.data, size); 29 | frame = new Uint8Array(frame.buffer, 0, this.data.byteLength + size); 30 | 31 | group.writeFrame(frame); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /js/hang/src/container/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./decoder"; 2 | export * from "./frame"; 3 | -------------------------------------------------------------------------------- /js/hang/src/container/vint.ts: -------------------------------------------------------------------------------- 1 | const MAX_U6 = 2 ** 6 - 1; 2 | const MAX_U14 = 2 ** 14 - 1; 3 | const MAX_U30 = 2 ** 30 - 1; 4 | const MAX_U53 = Number.MAX_SAFE_INTEGER; 5 | //const MAX_U62: bigint = 2n ** 62n - 1n; 6 | 7 | // QUIC VarInt 8 | export function getVint53(buf: Uint8Array): [number, Uint8Array] { 9 | const size = 1 << ((buf[0] & 0xc0) >> 6); 10 | 11 | const view = new DataView(buf.buffer, buf.byteOffset, size); 12 | const remain = new Uint8Array(buf.buffer, buf.byteOffset + size, buf.byteLength - size); 13 | let v: number; 14 | 15 | if (size === 1) { 16 | v = buf[0] & 0x3f; 17 | } else if (size === 2) { 18 | v = view.getInt16(0) & 0x3fff; 19 | } else if (size === 4) { 20 | v = view.getUint32(0) & 0x3fffffff; 21 | } else if (size === 8) { 22 | // NOTE: Precision loss above 2^52 23 | v = Number(view.getBigUint64(0) & 0x3fffffffffffffffn); 24 | } else { 25 | throw new Error("impossible"); 26 | } 27 | 28 | return [v, remain]; 29 | } 30 | 31 | export function setVint53(dst: Uint8Array, v: number): Uint8Array { 32 | if (v <= MAX_U6) { 33 | dst[0] = v; 34 | return new Uint8Array(dst.buffer, dst.byteOffset, 1); 35 | } 36 | 37 | if (v <= MAX_U14) { 38 | const view = new DataView(dst.buffer, dst.byteOffset, 2); 39 | view.setUint16(0, v | 0x4000); 40 | return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); 41 | } 42 | 43 | if (v <= MAX_U30) { 44 | const view = new DataView(dst.buffer, dst.byteOffset, 4); 45 | view.setUint32(0, v | 0x80000000); 46 | return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); 47 | } 48 | 49 | if (v <= MAX_U53) { 50 | const view = new DataView(dst.buffer, dst.byteOffset, 8); 51 | view.setBigUint64(0, BigInt(v) | 0xc000000000000000n); 52 | return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); 53 | } 54 | 55 | throw new Error(`overflow, value larger than 53-bits: ${v}`); 56 | } 57 | -------------------------------------------------------------------------------- /js/hang/src/index.ts: -------------------------------------------------------------------------------- 1 | // TODO This should go into MoQ. 2 | export * from "./connection"; 3 | 4 | export * as Catalog from "./catalog"; 5 | export * as Publish from "./publish"; 6 | export * as Watch from "./watch"; 7 | export * as Container from "./container"; 8 | export * as Support from "./support"; 9 | -------------------------------------------------------------------------------- /js/hang/src/publish/controls.tsx: -------------------------------------------------------------------------------- 1 | import { Match, Switch, createSelector } from "solid-js"; 2 | import { JSX } from "solid-js/jsx-runtime"; 3 | import { Device } from "./broadcast"; 4 | import { Publish } from "./publish"; 5 | 6 | export function Controls(props: { lib: Publish }): JSX.Element { 7 | return ( 8 |
17 | changeVolume(e.currentTarget.value)} 78 | /> 79 | {rounded()}% 80 |
81 | ); 82 | } 83 | 84 | function Status(props: { broadcast: Broadcast }): JSX.Element { 85 | const url = props.broadcast.connection.url.get; 86 | const connection = props.broadcast.connection.status.get; 87 | const broadcast = props.broadcast.status.get; 88 | 89 | return ( 90 |
91 | 92 | 🔴 No URL 93 | 🔴 Disconnected 94 | 🟡 Connecting... 95 | 🔴 Offline 96 | 🟡 Loading... 97 | 🟢 Live 98 | 🟢 Connected 99 | 100 |
101 | ); 102 | } 103 | 104 | function Fullscreen(props: { root: HTMLElement }): JSX.Element { 105 | const toggleFullscreen = () => { 106 | if (document.fullscreenElement) { 107 | document.exitFullscreen(); 108 | } else { 109 | props.root.requestFullscreen(); 110 | } 111 | }; 112 | return ( 113 | 116 | ); 117 | } 118 | -------------------------------------------------------------------------------- /js/hang/src/watch/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./audio"; 2 | export * from "./broadcast"; 3 | export * from "./video"; 4 | export * from "./controls"; 5 | 6 | // NOTE: element is not exported from this module 7 | // You have to import it from @kixelated/hang/watch/element instead. 8 | -------------------------------------------------------------------------------- /js/hang/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["node_modules", "dist"], 5 | "compilerOptions": { 6 | "outDir": "dist", 7 | "jsx": "react-jsx", 8 | "jsxImportSource": "solid-js" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /js/justfile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env just --justfile 2 | 3 | # Using Just: https://github.com/casey/just?tab=readme-ov-file#installation 4 | 5 | # List all of the available commands. 6 | default: 7 | just --list 8 | 9 | # Run the web server 10 | web: 11 | pnpm -r i 12 | pnpm -r run dev 13 | 14 | # Run the CI checks 15 | check flags="": 16 | pnpm -r install {{flags}} 17 | 18 | # Make sure Typescript compiles 19 | pnpm -r run check 20 | 21 | # Run the JS tests via node. 22 | pnpm -r test 23 | 24 | # Format/lint the JS packages 25 | pnpm -r exec biome check 26 | 27 | # Make sure the JS packages are not vulnerable 28 | pnpm -r exec pnpm audit 29 | 30 | # TODO: Check for unused imports (fix the false positives) 31 | # pnpm exec knip --no-exit-code 32 | 33 | # Automatically fix some issues. 34 | fix flags="": 35 | # Fix the JS packages 36 | pnpm -r install {{flags}} 37 | 38 | # Format and lint 39 | pnpm -r exec biome check --fix 40 | 41 | # Make sure the JS packages are not vulnerable 42 | pnpm -r exec pnpm audit --fix 43 | 44 | # Upgrade any tooling 45 | upgrade: 46 | # Update the NPM dependencies 47 | pnpm self-update 48 | pnpm -r update 49 | pnpm -r outdated 50 | 51 | # Build the packages 52 | build flags="": 53 | pnpm -r install {{flags}} 54 | pnpm -r run build 55 | 56 | # Generate documentation 57 | doc: build 58 | # Currently bugged for hang 59 | pnpm --dir moq exec api-extractor run --local --verbose 60 | pnpm exec api-documenter markdown --input api --output doc 61 | -------------------------------------------------------------------------------- /js/moq/README.md: -------------------------------------------------------------------------------- 1 |

2 | Media over QUIC 3 |

4 | 5 | Media over QUIC (MoQ) is a live (media) delivery protocol utilizing QUIC. 6 | It utilizes new browser technologies such as [WebTransport](https://developer.mozilla.org/en-US/docs/Web/API/WebTransport_API) and [WebCodecs](https://developer.mozilla.org/en-US/docs/Web/API/WebCodecs_API) to provide WebRTC-like functionality. 7 | Despite the focus on media, the transport is generic and designed to scale to enormous viewership via clustered relay servers (aka a CDN). 8 | See [quic.video](https://quic.video) for more information. 9 | 10 | **Note:** this project is a [fork](https://quic.video/blog/transfork) of the [IETF specification](https://datatracker.ietf.org/group/moq/documents/). 11 | The principles are the same but the implementation is exponentially simpler given a narrower focus (and no politics). 12 | 13 | # Usage 14 | This library contains just the generic transport. 15 | More documentation will be available later, until then refer to the code. 16 | 17 | ```ts 18 | import * as Moq from "@kixelated/moq"; 19 | 20 | const conn = await Moq.Connection.connect(new URL("http://localhost:4443/")); 21 | 22 | // Optional: Discover broadcasts matching a prefix. 23 | const announced = conn.announced("demo/"); 24 | for (;;) { 25 | const announce = await announced.next(); 26 | if (!announce) break; 27 | 28 | console.log("discovered broadcast:", announce.path); 29 | 30 | // NOTE: This code is untested and the API is subject to change. 31 | const broadcast = conn.consume(`demo/${announce.path}`); 32 | const track = broadcast.subscribe("catalog.json"); 33 | 34 | // A track can have multiple groups (unordered+unreliable). 35 | const group = await track.nextGroup(); 36 | 37 | // A group can have multiple frames (ordered+reliable). 38 | const frame = await group.nextFrame(); 39 | 40 | // This is a Uint8Array; but the contents are actually JSON. 41 | console.log("received frame:", frame); 42 | 43 | // Don't forget to close to release resources. 44 | frame.close(); 45 | group.close(); 46 | track.close(); 47 | broadcast.close(); 48 | } 49 | ``` 50 | 51 | 52 | # License 53 | 54 | Licensed under either: 55 | 56 | - Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) 57 | - MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) 58 | -------------------------------------------------------------------------------- /js/moq/api-extractor.json: -------------------------------------------------------------------------------- 1 | /** 2 | * Config file for API Extractor. For more info, please visit: https://api-extractor.com 3 | */ 4 | { 5 | "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", 6 | "extends": "../api-extractor.json", 7 | "mainEntryPointFilePath": "dist/index.d.ts" 8 | } 9 | -------------------------------------------------------------------------------- /js/moq/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kixelated/moq", 3 | "type": "module", 4 | "version": "0.5.0", 5 | "description": "Media over QUIC library", 6 | "license": "(MIT OR Apache-2.0)", 7 | "repository": "github:kixelated/moq", 8 | "files": ["./src", "README.md"], 9 | "exports": { 10 | ".": "./src/index.js" 11 | }, 12 | "scripts": { 13 | "build": "tsc -b", 14 | "check": "tsc --noEmit", 15 | "test": "tsx --test", 16 | "release": "tsx scripts/release.ts" 17 | }, 18 | "devDependencies": { 19 | "typescript": "^5.8.3", 20 | "@typescript/lib-dom": "npm:@types/web@^0.0.235", 21 | "tsx": "^4.19.4", 22 | "vite": "^6.3.5", 23 | "vite-plugin-html": "^3.2.2" 24 | }, 25 | "dependencies": { 26 | "buffer": "^6.0.3" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /js/moq/scripts/release.ts: -------------------------------------------------------------------------------- 1 | // ChatGPT made a script that rewrites package.json file to use the correct paths. 2 | // It's not pretty but nothing in NPM is. 3 | 4 | import { execSync } from "node:child_process"; 5 | import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; 6 | import { join } from "node:path"; 7 | 8 | console.log("🧹 Cleaning dist/..."); 9 | rmSync("dist", { recursive: true, force: true }); 10 | 11 | console.log("🛠️ Building..."); 12 | execSync("pnpm i && pnpm build", { stdio: "inherit" }); 13 | 14 | console.log("✍️ Rewriting package.json..."); 15 | const pkg = JSON.parse(readFileSync("package.json", "utf8")); 16 | 17 | function rewritePath(p: string): string { 18 | return p.replace(/^\.\/src/, ".").replace(/\.ts(x)?$/, ".js"); 19 | } 20 | 21 | pkg.main &&= rewritePath(pkg.main); 22 | pkg.types &&= rewritePath(pkg.types); 23 | 24 | if (pkg.exports) { 25 | for (const key in pkg.exports) { 26 | const val = pkg.exports[key]; 27 | if (typeof val === "string") { 28 | pkg.exports[key] = rewritePath(val); 29 | } else if (typeof val === "object") { 30 | for (const sub in val) { 31 | if (typeof val[sub] === "string") { 32 | val[sub] = rewritePath(val[sub]); 33 | } 34 | } 35 | } 36 | } 37 | } 38 | 39 | if (pkg.sideEffects) { 40 | pkg.sideEffects = pkg.sideEffects.map(rewritePath); 41 | } 42 | 43 | if (pkg.files) { 44 | pkg.files = pkg.files.map(rewritePath); 45 | } 46 | 47 | // biome-ignore lint/performance/noDelete: 48 | delete pkg.devDependencies; 49 | // biome-ignore lint/performance/noDelete: 50 | delete pkg.scripts; 51 | 52 | console.log(pkg); 53 | 54 | mkdirSync("dist", { recursive: true }); 55 | writeFileSync("dist/package.json", JSON.stringify(pkg, null, 2)); 56 | 57 | // Copy static files 58 | console.log("📄 Copying README.md..."); 59 | copyFileSync("README.md", join("dist", "README.md")); 60 | 61 | console.log("🔍 Installing dependencies..."); 62 | execSync("pnpm install", { 63 | stdio: "inherit", 64 | cwd: "dist", 65 | }); 66 | 67 | console.log("🚀 Publishing..."); 68 | execSync("pnpm publish --access=public", { 69 | stdio: "inherit", 70 | cwd: "dist", 71 | }); 72 | -------------------------------------------------------------------------------- /js/moq/src/announced.ts: -------------------------------------------------------------------------------- 1 | import { WatchConsumer, WatchProducer } from "./util/watch"; 2 | 3 | /** 4 | * The availability of a broadcast. 5 | * 6 | * @public 7 | */ 8 | export interface Announce { 9 | path: string; 10 | active: boolean; 11 | } 12 | 13 | /** 14 | * Handles writing announcements to the announcement queue. 15 | * 16 | * @public 17 | */ 18 | export class AnnouncedProducer { 19 | #queue = new WatchProducer([]); 20 | 21 | /** 22 | * Writes an announcement to the queue. 23 | * @param announcement - The announcement to write 24 | */ 25 | write(announcement: Announce) { 26 | this.#queue.update((announcements) => { 27 | announcements.push(announcement); 28 | return announcements; 29 | }); 30 | } 31 | 32 | /** 33 | * Aborts the writer with an error. 34 | * @param reason - The error reason for aborting 35 | */ 36 | abort(reason: Error) { 37 | this.#queue.abort(reason); 38 | } 39 | 40 | /** 41 | * Closes the writer. 42 | */ 43 | close() { 44 | this.#queue.close(); 45 | } 46 | 47 | /** 48 | * Returns a promise that resolves when the writer is closed. 49 | * @returns A promise that resolves when closed 50 | */ 51 | async closed(): Promise { 52 | await this.#queue.closed(); 53 | } 54 | 55 | /** 56 | * Creates a new AnnouncedConsumer that only returns the announcements for the specified prefix. 57 | * @param prefix - The prefix for the consumer 58 | * @returns A new AnnouncedConsumer instance 59 | */ 60 | consume(prefix = ""): AnnouncedConsumer { 61 | return new AnnouncedConsumer(this.#queue.consume(), prefix); 62 | } 63 | } 64 | 65 | /** 66 | * Handles reading announcements from the announcement queue. 67 | * 68 | * @public 69 | */ 70 | export class AnnouncedConsumer { 71 | /** The prefix for this reader */ 72 | readonly prefix: string; 73 | 74 | #queue: WatchConsumer; 75 | #index = 0; 76 | 77 | /** 78 | * Creates a new AnnounceConsumer with the specified prefix and queue. 79 | * @param prefix - The prefix for the reader 80 | * @param queue - The queue to read announcements from 81 | * 82 | * @internal 83 | */ 84 | constructor(queue: WatchConsumer, prefix = "") { 85 | this.#queue = queue; 86 | this.prefix = prefix; 87 | } 88 | 89 | /** 90 | * Returns the next announcement from the queue. 91 | * @returns A promise that resolves to the next announcement or undefined 92 | */ 93 | async next(): Promise { 94 | for (;;) { 95 | const queue = await this.#queue.when((v) => v.length > this.#index); 96 | if (!queue) return undefined; 97 | 98 | while (this.#index < queue.length) { 99 | const announce = queue.at(this.#index++); 100 | if (!announce?.path.startsWith(this.prefix)) continue; 101 | 102 | // We have to remove the prefix so we only return our suffix. 103 | return { 104 | path: announce.path.slice(this.prefix.length), 105 | active: announce.active, 106 | }; 107 | } 108 | } 109 | } 110 | 111 | /** 112 | * Closes the reader. 113 | */ 114 | close() { 115 | this.#queue.close(); 116 | } 117 | 118 | /** 119 | * Returns a promise that resolves when the reader is closed. 120 | * @returns A promise that resolves when closed 121 | */ 122 | async closed(): Promise { 123 | await this.#queue.closed(); 124 | } 125 | 126 | /** 127 | * Creates a new instance of the reader using the same queue and prefix. 128 | * 129 | * @returns A new AnnounceConsumer instance 130 | */ 131 | clone(): AnnouncedConsumer { 132 | return new AnnouncedConsumer(this.#queue.clone(), this.prefix); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /js/moq/src/group.ts: -------------------------------------------------------------------------------- 1 | import { WatchConsumer, WatchProducer } from "./util/watch"; 2 | 3 | /** 4 | * Handles writing frames to a group. 5 | * 6 | * @public 7 | */ 8 | export class GroupProducer { 9 | /** The unique identifier for this writer */ 10 | readonly id: number; 11 | 12 | // A stream of frames. 13 | #frames = new WatchProducer([]); 14 | 15 | /** 16 | * Creates a new GroupProducer with the specified ID and frames producer. 17 | * @param id - The unique identifier 18 | * 19 | * @internal 20 | */ 21 | constructor(id: number) { 22 | this.id = id; 23 | } 24 | 25 | /** 26 | * Writes a frame to the group. 27 | * @param frame - The frame to write 28 | */ 29 | writeFrame(frame: Uint8Array) { 30 | this.#frames.update((frames) => [...frames, frame]); 31 | } 32 | 33 | /** 34 | * Closes the writer. 35 | */ 36 | close() { 37 | this.#frames.close(); 38 | } 39 | 40 | /** 41 | * Returns a promise that resolves when the writer is unused. 42 | * @returns A promise that resolves when unused 43 | */ 44 | async unused(): Promise { 45 | await this.#frames.unused(); 46 | } 47 | 48 | /** 49 | * Aborts the writer with an error. 50 | * @param reason - The error reason for aborting 51 | */ 52 | abort(reason: Error) { 53 | this.#frames.abort(reason); 54 | } 55 | 56 | consume(): GroupConsumer { 57 | return new GroupConsumer(this.#frames.consume(), this.id); 58 | } 59 | } 60 | 61 | /** 62 | * Handles reading frames from a group. 63 | * 64 | * @public 65 | */ 66 | export class GroupConsumer { 67 | /** The unique identifier for this reader */ 68 | readonly id: number; 69 | 70 | #frames: WatchConsumer; 71 | #index = 0; 72 | 73 | /** 74 | * Creates a new GroupConsumer with the specified ID and frames consumer. 75 | * @param id - The unique identifier 76 | * @param frames - The frames consumer 77 | * 78 | * @internal 79 | */ 80 | constructor(frames: WatchConsumer, id: number) { 81 | this.id = id; 82 | this.#frames = frames; 83 | } 84 | 85 | /** 86 | * Reads the next frame from the group. 87 | * @returns A promise that resolves to the next frame or undefined 88 | */ 89 | async readFrame(): Promise { 90 | const frames = await this.#frames.when((frames) => frames.length > this.#index); 91 | return frames?.at(this.#index++); 92 | } 93 | 94 | /** 95 | * Closes the reader. 96 | */ 97 | close() { 98 | this.#frames.close(); 99 | } 100 | 101 | /** 102 | * Creates a new instance of the reader using the same frames consumer. 103 | * @returns A new GroupConsumer instance 104 | */ 105 | clone(): GroupConsumer { 106 | return new GroupConsumer(this.#frames.clone(), this.id); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /js/moq/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./announced"; 2 | export * from "./broadcast"; 3 | export * from "./connection"; 4 | export * from "./group"; 5 | export * from "./track"; 6 | -------------------------------------------------------------------------------- /js/moq/src/util/error.ts: -------------------------------------------------------------------------------- 1 | // I hate javascript. 2 | export function error(err: unknown): Error { 3 | return err instanceof Error ? err : new Error(String(err)); 4 | } 5 | -------------------------------------------------------------------------------- /js/moq/src/util/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./error"; 2 | export * from "./watch"; 3 | -------------------------------------------------------------------------------- /js/moq/src/wire/announce.ts: -------------------------------------------------------------------------------- 1 | import type { Reader, Writer } from "./stream"; 2 | 3 | export class Announce { 4 | suffix: string; 5 | active: boolean; 6 | 7 | constructor(suffix: string, active: boolean) { 8 | this.suffix = suffix; 9 | this.active = active; 10 | } 11 | 12 | async encode(w: Writer) { 13 | await w.u53(this.active ? 1 : 0); 14 | await w.string(this.suffix); 15 | } 16 | 17 | static async decode(r: Reader): Promise { 18 | const active = (await r.u53()) === 1; 19 | const suffix = await r.string(); 20 | return new Announce(suffix, active); 21 | } 22 | 23 | static async decode_maybe(r: Reader): Promise { 24 | if (await r.done()) return; 25 | return await Announce.decode(r); 26 | } 27 | } 28 | 29 | export class AnnounceInterest { 30 | static StreamID = 0x1; 31 | prefix: string; 32 | 33 | constructor(prefix: string) { 34 | this.prefix = prefix; 35 | } 36 | 37 | async encode(w: Writer) { 38 | await w.string(this.prefix); 39 | } 40 | 41 | static async decode(r: Reader): Promise { 42 | const prefix = await r.string(); 43 | return new AnnounceInterest(prefix); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /js/moq/src/wire/group.ts: -------------------------------------------------------------------------------- 1 | import type { Reader, Writer } from "./stream"; 2 | 3 | export class Group { 4 | subscribe: bigint; 5 | sequence: number; 6 | 7 | static StreamID = 0x0; 8 | 9 | constructor(subscribe: bigint, sequence: number) { 10 | this.subscribe = subscribe; 11 | this.sequence = sequence; 12 | } 13 | 14 | async encode(w: Writer) { 15 | await w.u62(this.subscribe); 16 | await w.u53(this.sequence); 17 | } 18 | 19 | static async decode(r: Reader): Promise { 20 | return new Group(await r.u62(), await r.u53()); 21 | } 22 | } 23 | 24 | export class GroupDrop { 25 | sequence: number; 26 | count: number; 27 | error: number; 28 | 29 | constructor(sequence: number, count: number, error: number) { 30 | this.sequence = sequence; 31 | this.count = count; 32 | this.error = error; 33 | } 34 | 35 | async encode(w: Writer) { 36 | await w.u53(this.sequence); 37 | await w.u53(this.count); 38 | await w.u53(this.error); 39 | } 40 | 41 | static async decode(r: Reader): Promise { 42 | return new GroupDrop(await r.u53(), await r.u53(), await r.u53()); 43 | } 44 | } 45 | 46 | export class Frame { 47 | payload: Uint8Array; 48 | 49 | constructor(payload: Uint8Array) { 50 | this.payload = payload; 51 | } 52 | 53 | async encode(w: Writer) { 54 | await w.u53(this.payload.byteLength); 55 | await w.write(this.payload); 56 | } 57 | 58 | static async decode(r: Reader): Promise { 59 | const size = await r.u53(); 60 | const payload = await r.read(size); 61 | return new Frame(payload); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /js/moq/src/wire/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./announce"; 2 | export * from "./group"; 3 | export * from "./session"; 4 | export * from "./stream"; 5 | export * from "./subscribe"; 6 | -------------------------------------------------------------------------------- /js/moq/src/wire/session.ts: -------------------------------------------------------------------------------- 1 | import type { Reader, Writer } from "./stream"; 2 | 3 | export const Version = { 4 | DRAFT_00: 0xff000000, 5 | DRAFT_01: 0xff000001, 6 | DRAFT_02: 0xff000002, 7 | DRAFT_03: 0xff000003, 8 | FORK_00: 0xff0bad00, 9 | FORK_01: 0xff0bad01, 10 | FORK_02: 0xff0bad02, 11 | FORK_03: 0xff0bad03, 12 | FORK_04: 0xff0bad04, 13 | LITE_00: 0xff0dad00, 14 | } as const; 15 | 16 | export const CURRENT_VERSION = Version.LITE_00; 17 | 18 | export class Extensions { 19 | entries: Map; 20 | 21 | constructor() { 22 | this.entries = new Map(); 23 | } 24 | 25 | set(id: bigint, value: Uint8Array) { 26 | this.entries.set(id, value); 27 | } 28 | 29 | get(id: bigint): Uint8Array | undefined { 30 | return this.entries.get(id); 31 | } 32 | 33 | remove(id: bigint): Uint8Array | undefined { 34 | const value = this.entries.get(id); 35 | this.entries.delete(id); 36 | return value; 37 | } 38 | 39 | async encode(w: Writer) { 40 | await w.u53(this.entries.size); 41 | for (const [id, value] of this.entries) { 42 | await w.u62(id); 43 | await w.u53(value.length); 44 | await w.write(value); 45 | } 46 | } 47 | 48 | static async decode(r: Reader): Promise { 49 | const count = await r.u53(); 50 | const params = new Extensions(); 51 | 52 | for (let i = 0; i < count; i++) { 53 | const id = await r.u62(); 54 | const size = await r.u53(); 55 | const value = await r.read(size); 56 | 57 | if (params.entries.has(id)) { 58 | throw new Error(`duplicate parameter id: ${id.toString()}`); 59 | } 60 | 61 | params.entries.set(id, value); 62 | } 63 | 64 | return params; 65 | } 66 | } 67 | 68 | export class SessionClient { 69 | versions: number[]; 70 | extensions: Extensions; 71 | 72 | static StreamID = 0x0; 73 | 74 | constructor(versions: number[], extensions = new Extensions()) { 75 | this.versions = versions; 76 | this.extensions = extensions; 77 | } 78 | 79 | async encode(w: Writer) { 80 | await w.u53(this.versions.length); 81 | for (const v of this.versions) { 82 | await w.u53(v); 83 | } 84 | 85 | await this.extensions.encode(w); 86 | } 87 | 88 | static async decode(r: Reader): Promise { 89 | const versions: number[] = []; 90 | const count = await r.u53(); 91 | for (let i = 0; i < count; i++) { 92 | versions.push(await r.u53()); 93 | } 94 | 95 | const extensions = await Extensions.decode(r); 96 | return new SessionClient(versions, extensions); 97 | } 98 | } 99 | 100 | export class SessionServer { 101 | version: number; 102 | extensions: Extensions; 103 | 104 | constructor(version: number, extensions = new Extensions()) { 105 | this.version = version; 106 | this.extensions = extensions; 107 | } 108 | 109 | async encode(w: Writer) { 110 | await w.u53(this.version); 111 | await this.extensions.encode(w); 112 | } 113 | 114 | static async decode(r: Reader): Promise { 115 | const version = await r.u53(); 116 | const extensions = await Extensions.decode(r); 117 | return new SessionServer(version, extensions); 118 | } 119 | } 120 | 121 | export class SessionInfo { 122 | bitrate: number; 123 | 124 | constructor(bitrate: number) { 125 | this.bitrate = bitrate; 126 | } 127 | 128 | async encode(w: Writer) { 129 | await w.u53(this.bitrate); 130 | } 131 | 132 | static async decode(r: Reader): Promise { 133 | const bitrate = await r.u53(); 134 | return new SessionInfo(bitrate); 135 | } 136 | 137 | static async decode_maybe(r: Reader): Promise { 138 | if (await r.done()) return; 139 | return await SessionInfo.decode(r); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /js/moq/src/wire/subscribe.ts: -------------------------------------------------------------------------------- 1 | import type { Reader, Writer } from "./stream"; 2 | 3 | export class SubscribeUpdate { 4 | priority: number; 5 | 6 | constructor(priority: number) { 7 | this.priority = priority; 8 | } 9 | 10 | async encode(w: Writer) { 11 | await w.u53(this.priority); 12 | } 13 | 14 | static async decode(r: Reader): Promise { 15 | const priority = await r.u53(); 16 | return new SubscribeUpdate(priority); 17 | } 18 | 19 | static async decode_maybe(r: Reader): Promise { 20 | if (await r.done()) return; 21 | return await SubscribeUpdate.decode(r); 22 | } 23 | } 24 | 25 | export class Subscribe extends SubscribeUpdate { 26 | id: bigint; 27 | broadcast: string; 28 | track: string; 29 | 30 | static StreamID = 0x2; 31 | 32 | constructor(id: bigint, broadcast: string, track: string, priority: number) { 33 | super(priority); 34 | this.id = id; 35 | this.broadcast = broadcast; 36 | this.track = track; 37 | } 38 | 39 | override async encode(w: Writer) { 40 | await w.u62(this.id); 41 | await w.string(this.broadcast); 42 | await w.string(this.track); 43 | await super.encode(w); 44 | } 45 | 46 | static override async decode(r: Reader): Promise { 47 | const id = await r.u62(); 48 | const broadcast = await r.string(); 49 | const track = await r.string(); 50 | const update = await SubscribeUpdate.decode(r); 51 | return new Subscribe(id, broadcast, track, update.priority); 52 | } 53 | } 54 | 55 | export class SubscribeOk { 56 | priority: number; 57 | 58 | constructor(priority: number) { 59 | this.priority = priority; 60 | } 61 | 62 | async encode(w: Writer) { 63 | await w.u53(this.priority); 64 | } 65 | 66 | static async decode(r: Reader): Promise { 67 | const priority = await r.u53(); 68 | return new SubscribeOk(priority); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /js/moq/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["node_modules", "dist"], 5 | "compilerOptions": { 6 | "outDir": "dist" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "moq", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "devDependencies": { 7 | "@biomejs/biome": "^1.9.4", 8 | "@microsoft/api-documenter": "^7.26.27", 9 | "@microsoft/api-extractor": "^7.52.8", 10 | "@types/node": "^22.15.21", 11 | "concurrently": "^9.1.2", 12 | "cpy-cli": "^5.0.0", 13 | "knip": "^5.57.1", 14 | "typescript": "^5.8.3" 15 | }, 16 | "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977" 17 | } 18 | -------------------------------------------------------------------------------- /js/pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - moq 3 | - hang 4 | - hang-demo 5 | - signals 6 | onlyBuiltDependencies: 7 | - core-js 8 | - esbuild 9 | - wasm-pack 10 | -------------------------------------------------------------------------------- /js/signals/README.md: -------------------------------------------------------------------------------- 1 | # Signals 2 | Just a wrapper around SolidJS signals. -------------------------------------------------------------------------------- /js/signals/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kixelated/signals", 3 | "type": "module", 4 | "version": "0.1.0", 5 | "description": "Wrapper around SolidJS signals", 6 | "license": "(MIT OR Apache-2.0)", 7 | "repository": "github:kixelated/moq", 8 | "exports": { 9 | ".": "./src/index.ts" 10 | }, 11 | "files": ["./src"], 12 | "scripts": { 13 | "build": "tsc -b", 14 | "check": "tsc --noEmit", 15 | "release": "tsx scripts/release.ts" 16 | }, 17 | "dependencies": { 18 | "solid-js": "^1.9.7" 19 | }, 20 | "devDependencies": { 21 | "tsx": "^4.19.4", 22 | "typescript": "^5.8.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /js/signals/scripts/release.ts: -------------------------------------------------------------------------------- 1 | // ChatGPT made a script that rewrites package.json file to use the correct paths. 2 | // It's not pretty but nothing in NPM is. 3 | 4 | import { execSync } from "node:child_process"; 5 | import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; 6 | import { join } from "node:path"; 7 | 8 | console.log("🧹 Cleaning dist/..."); 9 | rmSync("dist", { recursive: true, force: true }); 10 | 11 | console.log("🛠️ Building..."); 12 | execSync("pnpm i && pnpm build", { stdio: "inherit" }); 13 | 14 | console.log("✍️ Rewriting package.json..."); 15 | const pkg = JSON.parse(readFileSync("package.json", "utf8")); 16 | 17 | function rewritePath(p: string): string { 18 | return p.replace(/^\.\/src/, ".").replace(/\.ts(x)?$/, ".js"); 19 | } 20 | 21 | pkg.main &&= rewritePath(pkg.main); 22 | pkg.types &&= rewritePath(pkg.types); 23 | 24 | if (pkg.exports) { 25 | for (const key in pkg.exports) { 26 | const val = pkg.exports[key]; 27 | if (typeof val === "string") { 28 | pkg.exports[key] = rewritePath(val); 29 | } else if (typeof val === "object") { 30 | for (const sub in val) { 31 | if (typeof val[sub] === "string") { 32 | val[sub] = rewritePath(val[sub]); 33 | } 34 | } 35 | } 36 | } 37 | } 38 | 39 | if (pkg.sideEffects) { 40 | pkg.sideEffects = pkg.sideEffects.map(rewritePath); 41 | } 42 | 43 | if (pkg.files) { 44 | pkg.files = pkg.files.map(rewritePath); 45 | } 46 | 47 | // biome-ignore lint/performance/noDelete: 48 | delete pkg.devDependencies; 49 | // biome-ignore lint/performance/noDelete: 50 | delete pkg.scripts; 51 | 52 | console.log(pkg); 53 | 54 | mkdirSync("dist", { recursive: true }); 55 | writeFileSync("dist/package.json", JSON.stringify(pkg, null, 2)); 56 | 57 | // Copy static files 58 | console.log("📄 Copying README.md..."); 59 | copyFileSync("README.md", join("dist", "README.md")); 60 | 61 | console.log("🔍 Installing dependencies..."); 62 | execSync("pnpm install", { 63 | stdio: "inherit", 64 | cwd: "dist", 65 | }); 66 | 67 | console.log("🚀 Publishing..."); 68 | execSync("pnpm publish --access=public", { 69 | stdio: "inherit", 70 | cwd: "dist", 71 | }); 72 | -------------------------------------------------------------------------------- /js/signals/src/index.ts: -------------------------------------------------------------------------------- 1 | // A wrapper around solid-js signals to provide a more ergonomic API. 2 | 3 | import { 4 | Owner, 5 | SignalOptions, 6 | createEffect, 7 | createRoot, 8 | createSignal, 9 | getOwner, 10 | onCleanup, 11 | runWithOwner, 12 | untrack, 13 | } from "solid-js"; 14 | 15 | export { batch } from "solid-js"; 16 | 17 | export interface Signal extends Derived { 18 | set(value: T | ((prev: T) => T)): void; 19 | derived(fn: (value: T) => U): Derived; 20 | readonly(): Derived; 21 | } 22 | 23 | export function signal(initial: T, options?: SignalOptions): Signal { 24 | const [get, set] = createSignal(initial, options); 25 | return { 26 | get, 27 | set, 28 | peek: () => untrack(get), 29 | subscribe(fn) { 30 | const temp = new Signals(); 31 | temp.effect(() => { 32 | fn(get()); 33 | }); 34 | return temp.close.bind(temp); 35 | }, 36 | derived(fn) { 37 | return derived(() => fn(get())); 38 | }, 39 | readonly() { 40 | return { 41 | get: () => get(), 42 | peek: () => untrack(get), 43 | subscribe(fn) { 44 | const temp = new Signals(); 45 | temp.effect(() => { 46 | fn(get()); 47 | }); 48 | return temp.close.bind(temp); 49 | }, 50 | }; 51 | }, 52 | }; 53 | } 54 | 55 | export function cleanup(fn: () => void) { 56 | onCleanup(fn); 57 | } 58 | 59 | export type Dispose = () => void; 60 | 61 | // biome-ignore lint/suspicious/noConfusingVoidType: it's required to make the callback optional 62 | export type MaybeDispose = (() => void) | void; 63 | 64 | export interface Derived { 65 | get(): T; 66 | peek(): T; 67 | subscribe(fn: (value: T) => void): Dispose; 68 | } 69 | 70 | export function derived(fn: () => T, options?: SignalOptions): Derived { 71 | const sig = signal(fn(), options); 72 | effect(() => { 73 | sig.set(fn()); 74 | }); 75 | 76 | return sig; 77 | } 78 | 79 | export function effect(fn: () => MaybeDispose) { 80 | createEffect(() => { 81 | const res = fn(); 82 | if (res) { 83 | onCleanup(res); 84 | } 85 | }); 86 | } 87 | 88 | export class Signals { 89 | #dispose: Dispose; 90 | #owner: Owner; 91 | 92 | // @ts-ignore 93 | static dev = import.meta.env?.MODE !== "production"; 94 | 95 | // Sanity check to make sure roots are being disposed on dev. 96 | static #finalizer = new FinalizationRegistry((debugInfo) => { 97 | console.warn(`Root was garbage collected without being closed:\n${debugInfo}`); 98 | }); 99 | 100 | constructor() { 101 | if (Signals.dev) { 102 | const debug = new Error("created here:").stack ?? "No stack"; 103 | Signals.#finalizer.register(this, debug, this); 104 | } 105 | 106 | [this.#dispose, this.#owner] = createRoot((dispose) => { 107 | const owner = getOwner(); 108 | if (!owner) throw new Error("no owner"); 109 | 110 | return [dispose, owner]; 111 | }); 112 | } 113 | 114 | effect(fn: () => MaybeDispose): void { 115 | const res = runWithOwner(this.#owner, () => { 116 | effect(fn); 117 | return true; 118 | }); 119 | if (!res) { 120 | throw new Error("effect called after root was closed"); 121 | } 122 | } 123 | 124 | derived(fn: () => T, options?: SignalOptions): Derived { 125 | const res = runWithOwner(this.#owner, () => derived(fn, options)); 126 | if (!res) { 127 | throw new Error("derived called after root was closed"); 128 | } 129 | 130 | return res; 131 | } 132 | 133 | cleanup(fn: () => void): void { 134 | const ok = runWithOwner(this.#owner, () => { 135 | onCleanup(fn); 136 | return true; 137 | }); 138 | 139 | if (!ok) { 140 | fn(); 141 | } 142 | } 143 | 144 | close(): void { 145 | this.#dispose(); 146 | if (Signals.dev) { 147 | Signals.#finalizer.unregister(this); 148 | } 149 | } 150 | } 151 | 152 | export function signals(): Signals { 153 | return new Signals(); 154 | } 155 | -------------------------------------------------------------------------------- /js/signals/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["node_modules", "dist"], 5 | "compilerOptions": { 6 | "outDir": "dist" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /js/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["esnext", "dom"], 4 | "target": "esnext", 5 | "module": "esnext", 6 | "moduleResolution": "bundler", 7 | 8 | "declaration": true, 9 | "declarationMap": true, 10 | "isolatedModules": true, 11 | "sourceMap": true, 12 | 13 | // https://www.typescriptlang.org/tsconfig/#Type_Checking_6248 14 | "allowUnreachableCode": false, 15 | "allowUnusedLabels": false, 16 | "alwaysStrict": true, 17 | "exactOptionalPropertyTypes": false, // makes ? less usable 18 | "noFallthroughCasesInSwitch": true, 19 | "noImplicitAny": true, 20 | "noImplicitOverride": true, 21 | "noImplicitReturns": true, 22 | "noImplicitThis": true, 23 | "noPropertyAccessFromIndexSignature": true, 24 | "noUncheckedIndexedAccess": false, // Allows [0] access to arrays 25 | "noUnusedLocals": true, 26 | "noUnusedParameters": true, 27 | "strict": true, 28 | "strictBindCallApply": true, 29 | "strictBuiltinIteratorReturn": true, 30 | "strictFunctionTypes": true, 31 | "strictNullChecks": true, 32 | "strictPropertyInitialization": true, 33 | "useUnknownInCatchVariables": true, 34 | 35 | // Disable enums and weird Typescript features. 36 | "erasableSyntaxOnly": true, 37 | 38 | "skipLibCheck": true 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env just --justfile 2 | 3 | # Using Just: https://github.com/casey/just?tab=readme-ov-file#installation 4 | 5 | # These commands have been split into separate files for each language. 6 | # This is just a shim that uses the relevant file or calls both. 7 | 8 | set quiet 9 | 10 | # List all of the available commands. 11 | default: 12 | just --list 13 | 14 | # Install any required dependencies. 15 | setup: 16 | just --justfile rs/justfile setup 17 | 18 | # Run the relay, web server, and publish bbb. 19 | all: build 20 | # Then run the relay with a slight head start. 21 | # It doesn't matter if the web beats BBB because we support automatic reloading. 22 | js/node_modules/.bin/concurrently --kill-others --names srv,bbb,web --prefix-colors auto "just relay" "sleep 1 && just pub bbb" "sleep 1 && just web" 23 | 24 | # Run a localhost relay server 25 | relay: 26 | just --justfile rs/justfile relay 27 | 28 | # Publish a video using ffmpeg to the localhost relay server 29 | pub name: 30 | just --justfile rs/justfile pub {{name}} 31 | 32 | # Publish a video using gstreamer to the localhost relay server 33 | pub-gst name: 34 | just --justfile rs/justfile pub-gst {{name}} 35 | 36 | # Subscribe to a video using gstreamer 37 | sub-gst name: 38 | just --justfile rs/justfile sub-gst {{name}} 39 | 40 | # Publish a video using ffmpeg directly from hang to the localhost 41 | serve name: 42 | just --justfile rs/justfile serve {{name}} 43 | 44 | # Run the web server 45 | web: 46 | just --justfile js/justfile web 47 | 48 | # Publish the clock broadcast 49 | # `action` is either `publish` or `subscribe` 50 | clock action: 51 | just --justfile rs/justfile clock {{action}} 52 | 53 | # Run the CI checks 54 | check flags="": 55 | just --justfile rs/justfile check {{flags}} 56 | just --justfile js/justfile check 57 | 58 | # Automatically fix some issues. 59 | fix flags="": 60 | just --justfile rs/justfile fix {{flags}} 61 | just --justfile js/justfile fix 62 | 63 | # Upgrade any tooling 64 | upgrade: 65 | just --justfile rs/justfile upgrade 66 | just --justfile js/justfile upgrade 67 | 68 | # Build the packages 69 | build: 70 | just --justfile rs/justfile build 71 | just --justfile js/justfile build 72 | -------------------------------------------------------------------------------- /rs/.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | rustflags = ["--cfg=web_sys_unstable_apis"] 3 | -------------------------------------------------------------------------------- /rs/.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | target 3 | dist 4 | dev 5 | node_modules 6 | pkg 7 | -------------------------------------------------------------------------------- /rs/.release-plz.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | dependencies_update = true 3 | -------------------------------------------------------------------------------- /rs/.rustfmt.toml: -------------------------------------------------------------------------------- 1 | # i die on this hill 2 | hard_tabs = true 3 | 4 | max_width = 120 5 | -------------------------------------------------------------------------------- /rs/Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "hang", 4 | "hang-cli", 5 | "hang-gst", 6 | "hang-wasm", 7 | "moq", 8 | "moq-clock", 9 | "moq-native", 10 | "moq-relay", 11 | ] 12 | # without hang-gst because it requires gstreamer to be installed 13 | # and without hang-wasm because it's deprecated and weird 14 | default-members = [ 15 | "hang", 16 | "hang-cli", 17 | "moq", 18 | "moq-clock", 19 | "moq-native", 20 | "moq-relay", 21 | ] 22 | resolver = "2" 23 | 24 | [workspace.dependencies] 25 | web-transport = "0.9.2" 26 | web-async = { version = "0.1.1", features = ["tracing"] } 27 | tokio = "1.45" 28 | serde = { version = "1", features = ["derive"] } 29 | 30 | hang = { version = "0.2", path = "hang" } 31 | moq-lite = { version = "0.2", path = "moq" } 32 | moq-native = { version = "0.6", path = "moq-native" } 33 | 34 | [profile.release.package.hang-wasm] 35 | # Tell `rustc` to optimize for small code size. 36 | opt-level = "s" 37 | -------------------------------------------------------------------------------- /rs/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:24.04 AS build 2 | 3 | WORKDIR /build 4 | ENV DEBIAN_FRONTEND=noninteractive 5 | ENV RUSTFLAGS=--cfg=web_sys_unstable_apis 6 | 7 | # Install base tools and GStreamer 8 | RUN apt-get update && \ 9 | apt-get install -y \ 10 | build-essential \ 11 | curl \ 12 | sudo \ 13 | pkg-config \ 14 | libssl-dev \ 15 | libclang-dev \ 16 | cmake \ 17 | libgstreamer1.0-dev \ 18 | libgstreamer-plugins-base1.0-dev \ 19 | libgstreamer-plugins-bad1.0-dev \ 20 | gstreamer1.0-plugins-base \ 21 | gstreamer1.0-plugins-good \ 22 | gstreamer1.0-plugins-bad \ 23 | gstreamer1.0-plugins-ugly \ 24 | gstreamer1.0-libav \ 25 | gstreamer1.0-tools \ 26 | gstreamer1.0-x \ 27 | gstreamer1.0-alsa \ 28 | gstreamer1.0-gl \ 29 | gstreamer1.0-gtk3 \ 30 | gstreamer1.0-qt5 \ 31 | gstreamer1.0-pulseaudio \ 32 | git \ 33 | unzip \ 34 | just \ 35 | && apt-get clean 36 | 37 | # Install rustup + toolchain 38 | RUN curl https://sh.rustup.rs -sSf | sh -s -- -y 39 | ENV PATH="/root/.cargo/bin:$PATH" 40 | 41 | # Add wasm target + clippy + rustfmt 42 | RUN rustup target add wasm32-unknown-unknown && \ 43 | rustup component add clippy rustfmt 44 | 45 | # Let just do the rest. 46 | COPY justfile . 47 | 48 | # Install dependencies. 49 | # I'm not sure if all of these --mount flags are needed any longer. 50 | RUN --mount=type=cache,target=/usr/local/cargo/registry \ 51 | --mount=type=cache,target=/build/target \ 52 | just setup 53 | 54 | # Copy over the rest of the files. 55 | COPY . . 56 | 57 | # Make sure u guys don't write bad code before we build. 58 | RUN --mount=type=cache,target=/usr/local/cargo/registry \ 59 | --mount=type=cache,target=/build/target \ 60 | just check --release 61 | 62 | # Reuse a cache between builds. 63 | # I tried to `cargo install`, but it doesn't seem to work with workspaces. 64 | # There's also issues with the cache mount since it builds into /usr/local/cargo/bin 65 | # We can't mount that without clobbering cargo itself. 66 | # We instead we build the binaries and copy them to the cargo bin directory. 67 | RUN --mount=type=cache,target=/usr/local/cargo/registry \ 68 | --mount=type=cache,target=/build/target \ 69 | mkdir -p /out && \ 70 | cargo build --release && \ 71 | cp /build/target/release/moq* /out && \ 72 | cp /build/target/release/hang* /out 73 | 74 | # moq-clock 75 | FROM ubuntu:24.04 AS moq-clock 76 | COPY --from=build /out/moq-clock /usr/local/bin 77 | ENTRYPOINT ["moq-clock"] 78 | 79 | ## hang (and hang-bbb) 80 | FROM ubuntu:24.04 AS hang 81 | RUN apt-get update && apt-get install -y ffmpeg wget 82 | COPY ./hang-bbb /usr/local/bin/hang-bbb 83 | COPY --from=build /out/hang /usr/local/bin 84 | ENTRYPOINT ["hang"] 85 | 86 | ## moq-relay 87 | FROM ubuntu:24.04 AS moq-relay 88 | COPY --from=build /out/moq-relay /usr/local/bin 89 | ENTRYPOINT ["moq-relay"] 90 | -------------------------------------------------------------------------------- /rs/hang-bbb: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # TODO Move to quic.video 5 | 6 | URL=${URL:-"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"} 7 | REGION=${REGION:-"now"} 8 | 9 | # Download the funny bunny 10 | wget -nv "${URL}" -O "tmp.mp4" 11 | 12 | # Properly fragment the file 13 | ffmpeg -y -i tmp.mp4 \ 14 | -c copy \ 15 | -f mp4 -movflags cmaf+separate_moof+delay_moov+skip_trailer+frag_every_frame \ 16 | "fragmented.mp4" 17 | 18 | rm tmp.mp4 19 | 20 | # ffmpeg 21 | # -hide_banner: Hide the banner 22 | # -v quiet: and any other output 23 | # -stats: But we still want some stats on stderr 24 | # -stream_loop -1: Loop the broadcast an infinite number of times 25 | # -re: Output in real-time 26 | # -i "${INPUT}": Read from a file on disk 27 | # -vf "drawtext": Render the current time in the corner of the video 28 | # -an: Disable audio for now 29 | # -b:v 3M: Output video at 3Mbps 30 | # -preset ultrafast: Don't use much CPU at the cost of quality 31 | # -tune zerolatency: Optimize for latency at the cost of quality 32 | # -f mp4: Output to mp4 format 33 | # -movflags: Build a fMP4 file with a frame per fragment 34 | # - | moq-pub: Output to stdout and moq-pub to publish 35 | 36 | # Run ffmpeg 37 | ffmpeg \ 38 | -stream_loop -1 \ 39 | -hide_banner \ 40 | -v quiet \ 41 | -re \ 42 | -i "fragmented.mp4" \ 43 | -vf "drawtext=fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf:text='${REGION}\: %{gmtime\: %H\\\\\:%M\\\\\:%S.%3N}':x=(W-tw)-24:y=24:fontsize=48:fontcolor=white:box=1:boxcolor=black@0.5" \ 44 | -an \ 45 | -b:v 3M \ 46 | -preset ultrafast \ 47 | -tune zerolatency \ 48 | -f mp4 \ 49 | -movflags cmaf+separate_moof+delay_moov+skip_trailer+frag_every_frame \ 50 | - | hang "$@" -------------------------------------------------------------------------------- /rs/hang-cli/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.1.0](https://github.com/kixelated/moq/releases/tag/hang-cli-v0.1.0) - 2025-05-21 11 | 12 | ### Other 13 | 14 | - Split into Rust/Javascript halves and rebrand as moq-lite/hang ([#376](https://github.com/kixelated/moq/pull/376)) 15 | -------------------------------------------------------------------------------- /rs/hang-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hang-cli" 3 | description = "Media over QUIC" 4 | authors = ["Luke Curley "] 5 | repository = "https://github.com/kixelated/moq" 6 | license = "MIT OR Apache-2.0" 7 | 8 | version = "0.1.0" 9 | edition = "2021" 10 | 11 | keywords = ["quic", "http3", "webtransport", "media", "live"] 12 | categories = ["multimedia", "network-programming", "web-programming"] 13 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 14 | 15 | [dependencies] 16 | anyhow = { version = "1", features = ["backtrace"] } 17 | axum = { version = "0.8", features = ["tokio"] } 18 | clap = { version = "4", features = ["derive"] } 19 | hang = { workspace = true } 20 | hyper-serve = { version = "0.6", features = ["tls-rustls"] } 21 | moq-native = { workspace = true } 22 | tokio = { workspace = true, features = ["full"] } 23 | tower-http = { version = "0.6", features = ["cors", "fs"] } 24 | tracing = "0.1" 25 | url = "2" 26 | 27 | [[bin]] 28 | name = "hang" 29 | path = "src/main.rs" 30 | -------------------------------------------------------------------------------- /rs/hang-cli/src/client.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use clap::Args; 3 | use hang::cmaf::Import; 4 | use hang::moq_lite; 5 | use hang::{BroadcastConsumer, BroadcastProducer}; 6 | use moq_lite::Session; 7 | use moq_native::quic; 8 | use tokio::io::AsyncRead; 9 | use url::Url; 10 | 11 | use super::Config; 12 | 13 | /// Publish a video stream to the provided URL. 14 | #[derive(Args, Clone)] 15 | pub struct ClientConfig { 16 | /// The URL of the MoQ server. 17 | /// 18 | /// The URL must start with `https://` or `http://`. 19 | /// - If `http` is used, a HTTP fetch to "/certificate.sha256" is first made to get the TLS certificiate fingerprint (insecure). 20 | /// The URL is then upgraded to `https`. 21 | /// 22 | /// - If `https` is used, then A WebTransport connection is made via QUIC to the provided host/port. 23 | /// The path is used to identify the broadcast, with the rest of the URL (ex. query/fragment) currently ignored. 24 | url: Url, 25 | } 26 | 27 | pub struct Client { 28 | config: Config, 29 | url: Url, 30 | } 31 | 32 | impl Client { 33 | pub fn new(config: Config, client_config: ClientConfig) -> Self { 34 | Self { 35 | config, 36 | url: client_config.url, 37 | } 38 | } 39 | 40 | pub async fn run(self, input: &mut T) -> anyhow::Result<()> { 41 | let producer = BroadcastProducer::new(); 42 | let consumer = producer.consume(); 43 | 44 | // Connect to the remote and start parsing stdin in parallel. 45 | tokio::select! { 46 | res = self.connect(consumer) => res, 47 | res = self.publish(producer, input) => res, 48 | } 49 | } 50 | 51 | async fn connect(&self, consumer: BroadcastConsumer) -> anyhow::Result<()> { 52 | let tls = self.config.tls.load()?; 53 | let quic = quic::Endpoint::new(quic::Config { 54 | bind: self.config.bind, 55 | tls, 56 | })?; 57 | 58 | tracing::info!(url = %self.url, "connecting"); 59 | 60 | let session = quic.client.connect(self.url.clone()).await?; 61 | let mut session = Session::connect(session).await?; 62 | 63 | // The path is relative to the URL, so it's empty because we only publish one broadcast. 64 | session.publish("", consumer.inner.clone()); 65 | 66 | tokio::select! { 67 | // On ctrl-c, close the session and exit. 68 | _ = tokio::signal::ctrl_c() => { 69 | session.close(moq_lite::Error::Cancel); 70 | 71 | // Give it a chance to close. 72 | tokio::time::sleep(std::time::Duration::from_millis(100)).await; 73 | 74 | Ok(()) 75 | } 76 | // Otherwise wait for the session to close. 77 | _ = session.closed() => Err(session.closed().await.into()), 78 | } 79 | } 80 | 81 | async fn publish(&self, producer: BroadcastProducer, input: &mut T) -> anyhow::Result<()> { 82 | let mut import = Import::new(producer); 83 | 84 | import 85 | .init_from(input) 86 | .await 87 | .context("failed to initialize cmaf from input")?; 88 | 89 | tracing::info!("initialized"); 90 | 91 | import.read_from(input).await?; 92 | 93 | Ok(()) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /rs/hang-cli/src/config.rs: -------------------------------------------------------------------------------- 1 | use clap::{Parser, Subcommand}; 2 | 3 | use crate::{client::ClientConfig, server::ServerConfig}; 4 | 5 | #[derive(Parser, Clone)] 6 | pub struct Config { 7 | /// Listen for UDP packets on the given address. 8 | #[arg(long, default_value = "[::]:0")] 9 | pub bind: std::net::SocketAddr, 10 | 11 | /// Log configuration. 12 | #[command(flatten)] 13 | pub log: moq_native::log::Args, 14 | 15 | /// The TLS configuration. 16 | #[command(flatten)] 17 | pub tls: moq_native::tls::Args, 18 | 19 | /// If we're publishing or subscribing. 20 | #[command(subcommand)] 21 | pub command: Command, 22 | } 23 | 24 | #[derive(Subcommand, Clone)] 25 | pub enum Command { 26 | /// Host a server, accepting connections from clients. 27 | Serve(ServerConfig), 28 | 29 | /// Publish a video stream to the provided URL. 30 | Publish(ClientConfig), 31 | } 32 | -------------------------------------------------------------------------------- /rs/hang-cli/src/main.rs: -------------------------------------------------------------------------------- 1 | mod client; 2 | mod config; 3 | mod server; 4 | 5 | use client::*; 6 | use config::*; 7 | use server::*; 8 | 9 | use clap::Parser; 10 | 11 | #[tokio::main] 12 | async fn main() -> anyhow::Result<()> { 13 | let config = Config::parse(); 14 | config.log.init(); 15 | 16 | match config.command.clone() { 17 | Command::Serve(server) => Server::new(config, server).await?.run(&mut tokio::io::stdin()).await, 18 | Command::Publish(client) => Client::new(config, client).run(&mut tokio::io::stdin()).await, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /rs/hang-gst/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.1.0](https://github.com/kixelated/moq/releases/tag/hang-gst-v0.1.0) - 2025-05-21 11 | 12 | ### Other 13 | 14 | - Split into Rust/Javascript halves and rebrand as moq-lite/hang ([#376](https://github.com/kixelated/moq/pull/376)) 15 | -------------------------------------------------------------------------------- /rs/hang-gst/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hang-gst" 3 | description = "Media over QUIC - Gstreamer plugin" 4 | authors = ["Luke Curley"] 5 | repository = "https://github.com/kixelated/moq" 6 | license = "MIT OR Apache-2.0" 7 | 8 | version = "0.1.0" 9 | edition = "2021" 10 | 11 | keywords = ["quic", "http3", "webtransport", "media", "live"] 12 | categories = ["multimedia", "network-programming", "web-programming"] 13 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 14 | 15 | [dependencies] 16 | anyhow = { version = "1", features = ["backtrace"] } 17 | env_logger = "0.11" 18 | 19 | gst = { package = "gstreamer", version = "0.23" } 20 | gst-base = { package = "gstreamer-base", version = "0.23" } 21 | hang = { workspace = true } 22 | moq-native = { workspace = true } 23 | #gst-app = { package = "gstreamer-app", version = "0.23", features = ["v1_20"] } 24 | 25 | once_cell = "1" 26 | tokio = { version = "1", features = ["full"] } 27 | url = "2" 28 | 29 | [build-dependencies] 30 | gst-plugin-version-helper = "0.8" 31 | 32 | [lib] 33 | name = "gsthang" 34 | crate-type = ["cdylib", "rlib"] 35 | path = "src/lib.rs" 36 | -------------------------------------------------------------------------------- /rs/hang-gst/README.md: -------------------------------------------------------------------------------- 1 |

2 | Media over QUIC 3 |

4 | 5 | A gstreamer plugin utilizing [moq](https://github.com/kixelated/moq). 6 | 7 | # Usage 8 | ## Requirements 9 | - [Rustup](https://www.rust-lang.org/tools/install) 10 | - [Just](https://github.com/casey/just?tab=readme-ov-file#installation) 11 | 12 | ## Setup 13 | We use `just` to simplify the development process. 14 | Check out the [Justfile](justfile) or run `just` to see the available commands. 15 | 16 | Install any other required tools: 17 | ```sh 18 | just setup 19 | ``` 20 | 21 | ## Development 22 | First make sure you have a local moq-relay server running: 23 | ```sh 24 | just relay 25 | ``` 26 | 27 | Now you can publish and subscribe to a video: 28 | ```sh 29 | # Publish to a localhost moq-relay server 30 | just pub-gst bbb 31 | 32 | # Subscribe from a localhost moq-relay server 33 | just sub bbb 34 | ``` 35 | 36 | # License 37 | 38 | Licensed under either: 39 | 40 | - Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) 41 | - MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) 42 | -------------------------------------------------------------------------------- /rs/hang-gst/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | gst_plugin_version_helper::info() 3 | } 4 | -------------------------------------------------------------------------------- /rs/hang-gst/src/lib.rs: -------------------------------------------------------------------------------- 1 | use gst::glib; 2 | 3 | mod sink; 4 | mod source; 5 | 6 | pub fn plugin_init(plugin: &gst::Plugin) -> Result<(), glib::BoolError> { 7 | env_logger::init(); 8 | sink::register(plugin)?; 9 | source::register(plugin)?; 10 | 11 | Ok(()) 12 | } 13 | 14 | gst::plugin_define!( 15 | hang, 16 | env!("CARGO_PKG_DESCRIPTION"), 17 | plugin_init, 18 | concat!(env!("CARGO_PKG_VERSION"), "-", env!("COMMIT_ID")), 19 | "MIT/Apache-2.0", 20 | env!("CARGO_PKG_NAME"), 21 | env!("CARGO_PKG_NAME"), 22 | env!("CARGO_PKG_REPOSITORY"), 23 | env!("BUILD_REL_DATE") 24 | ); 25 | -------------------------------------------------------------------------------- /rs/hang-gst/src/sink/mod.rs: -------------------------------------------------------------------------------- 1 | use gst::glib; 2 | use gst::prelude::*; 3 | 4 | mod imp; 5 | 6 | glib::wrapper! { 7 | pub struct HangSink(ObjectSubclass) @extends gst_base::BaseSink, gst::Element, gst::Object; 8 | } 9 | 10 | pub fn register(plugin: &gst::Plugin) -> Result<(), glib::BoolError> { 11 | gst::Element::register(Some(plugin), "hangsink", gst::Rank::NONE, HangSink::static_type()) 12 | } 13 | -------------------------------------------------------------------------------- /rs/hang-gst/src/source/mod.rs: -------------------------------------------------------------------------------- 1 | use gst::glib; 2 | use gst::prelude::*; 3 | 4 | mod imp; 5 | 6 | glib::wrapper! { 7 | pub struct HangSrc(ObjectSubclass) @extends gst::Bin, gst::Element, gst::Object; 8 | } 9 | 10 | pub fn register(plugin: &gst::Plugin) -> Result<(), glib::BoolError> { 11 | gst::Element::register(Some(plugin), "hangsrc", gst::Rank::NONE, HangSrc::static_type()) 12 | } 13 | -------------------------------------------------------------------------------- /rs/hang-wasm/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.1.0](https://github.com/kixelated/moq/releases/tag/hang-wasm-v0.1.0) - 2025-05-21 11 | 12 | ### Other 13 | 14 | - Split into Rust/Javascript halves and rebrand as moq-lite/hang ([#376](https://github.com/kixelated/moq/pull/376)) 15 | -------------------------------------------------------------------------------- /rs/hang-wasm/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hang-wasm" 3 | authors = ["Luke Curley "] 4 | edition = "2021" 5 | version = "0.1.0" 6 | license = "MIT OR Apache-2.0" 7 | repository = "https://github.com/kixelated/moq" 8 | description = "Web implementation for MoQ utilizing WebAssembly+Typescript" 9 | 10 | [lib] 11 | crate-type = ["cdylib", "rlib"] 12 | 13 | [dependencies] 14 | console_error_panic_hook = "0.1" 15 | gloo-net = "0.6" 16 | hang = { workspace = true } 17 | hex = "0.4" 18 | js-sys = "0.3.77" 19 | rubato = "0.16" 20 | thiserror = "2" 21 | tokio = { workspace = true, features = ["sync"] } 22 | tracing = "0.1" 23 | ts-rs = { version = "10.1", features = ["url-impl"] } 24 | url = "2" 25 | wasm-bindgen = "0.2" 26 | wasm-bindgen-futures = "0.4" 27 | wasm-tracing = "2.0" 28 | web-async = { workspace = true } 29 | web-codecs = "0.3.7" 30 | web-message = { version = "0.0.2", features = [ 31 | "Url", 32 | "OffscreenCanvas", 33 | "AudioData", 34 | "MessagePort", 35 | "VideoFrame", 36 | ] } 37 | web-streams = "0.1.2" 38 | web-time = "1" 39 | 40 | [dependencies.web-sys] 41 | version = "0.3.77" 42 | features = [ 43 | # DOM 44 | "Window", 45 | "Document", 46 | "HtmlElement", 47 | "Node", 48 | "Text", 49 | "HtmlVideoElement", 50 | 51 | # Custom elements 52 | "HtmlSlotElement", 53 | "AssignedNodesOptions", 54 | "CustomEvent", 55 | "CustomEventInit", 56 | "Event", 57 | "EventTarget", 58 | 59 | # Canvas stuff 60 | "CanvasRenderingContext2d", 61 | "HtmlCanvasElement", 62 | "HtmlImageElement", 63 | "OffscreenCanvas", 64 | "DedicatedWorkerGlobalScope", 65 | "OffscreenCanvasRenderingContext2d", 66 | 67 | # Capture 68 | "MediaStream", 69 | "MediaStreamTrack", 70 | "MediaTrackSettings", 71 | "MediaStreamTrackProcessor", 72 | "MediaStreamTrackProcessorInit", 73 | "ReadableStreamDefaultReader", 74 | 75 | "MessagePort", 76 | 77 | "console", 78 | 79 | "AudioData", 80 | "AudioDataCopyToOptions", 81 | ] 82 | -------------------------------------------------------------------------------- /rs/hang-wasm/hang-bbb: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # TODO move this script to quic.video 5 | 6 | URL=${URL:-"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"} 7 | REGION=${REGION:-"now"} 8 | 9 | # Download the funny bunny 10 | wget -nv "${URL}" -O "tmp.mp4" 11 | 12 | # Properly fragment the file 13 | ffmpeg -y -i tmp.mp4 \ 14 | -c copy \ 15 | -f mp4 -movflags cmaf+separate_moof+delay_moov+skip_trailer+frag_every_frame \ 16 | "fragmented.mp4" 17 | 18 | rm tmp.mp4 19 | 20 | # ffmpeg 21 | # -hide_banner: Hide the banner 22 | # -v quiet: and any other output 23 | # -stats: But we still want some stats on stderr 24 | # -stream_loop -1: Loop the broadcast an infinite number of times 25 | # -re: Output in real-time 26 | # -i "${INPUT}": Read from a file on disk 27 | # -vf "drawtext": Render the current time in the corner of the video 28 | # -an: Disable audio for now 29 | # -b:v 3M: Output video at 3Mbps 30 | # -preset ultrafast: Don't use much CPU at the cost of quality 31 | # -tune zerolatency: Optimize for latency at the cost of quality 32 | # -f mp4: Output to mp4 format 33 | # -movflags: Build a fMP4 file with a frame per fragment 34 | # - | moq-pub: Output to stdout and moq-pub to publish 35 | 36 | # Run ffmpeg 37 | ffmpeg \ 38 | -stream_loop -1 \ 39 | -hide_banner \ 40 | -v quiet \ 41 | -re \ 42 | -i "fragmented.mp4" \ 43 | -vf "drawtext=fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf:text='${REGION}\: %{gmtime\: %H\\\\\:%M\\\\\:%S.%3N}':x=(W-tw)-24:y=24:fontsize=48:fontcolor=white:box=1:boxcolor=black@0.5" \ 44 | -an \ 45 | -b:v 3M \ 46 | -preset ultrafast \ 47 | -tune zerolatency \ 48 | -f mp4 \ 49 | -movflags cmaf+separate_moof+delay_moov+skip_trailer+frag_every_frame \ 50 | - | moq-karp "$@" -------------------------------------------------------------------------------- /rs/hang-wasm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kixelated/hang-wasm", 3 | "type": "module", 4 | "collaborators": ["Luke Curley "], 5 | "description": "Web implementation for MoQ utilizing WebAssembly+Typescript", 6 | "version": "0.1.0", 7 | "license": "MIT OR Apache-2.0", 8 | "repository": "github:kixelated/moq", 9 | "files": ["dist", "pkg"], 10 | "exports": { 11 | ".": "./dist/index.js" 12 | }, 13 | "sideEffects": ["./pkg/index.js", "./pkg/snippets/*"], 14 | "scripts": { 15 | "build": "cargo test export_bindings && rspack build && tsc -b && cp ../LICENSE* ./dist && cp ./README.md ./dist", 16 | "check": "cargo check && tsc --noEmit", 17 | "dev": "rspack dev" 18 | }, 19 | "devDependencies": { 20 | "@rspack/cli": "^1.3.8", 21 | "@rspack/core": "^1.3.8", 22 | "@types/audioworklet": "^0.0.75", 23 | "@wasm-tool/wasm-pack-plugin": "^1.7.0", 24 | "html-webpack-plugin": "^5.6.3", 25 | "ts-loader": "^9.5.2", 26 | "wasm-pack": "^0.13.1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /rs/hang-wasm/rspack.config.mjs: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | 3 | import { fileURLToPath } from "node:url"; 4 | import WasmPackPlugin from "@wasm-tool/wasm-pack-plugin"; 5 | import HtmlWebpackPlugin from "html-webpack-plugin"; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | 10 | const config = { 11 | entry: "./src/demo/index.ts", 12 | output: { 13 | path: path.resolve(__dirname, "out"), 14 | filename: "index.js", 15 | }, 16 | plugins: [ 17 | new WasmPackPlugin({ 18 | crateDirectory: path.resolve(__dirname), 19 | outDir: path.resolve(__dirname, "pkg"), 20 | args: "--log-level warn", 21 | outName: "index", 22 | }), 23 | new HtmlWebpackPlugin({ 24 | template: "src/demo/watch.html", 25 | filename: "index.html", 26 | }), 27 | new HtmlWebpackPlugin({ 28 | template: "src/demo/publish.html", 29 | filename: "publish.html", 30 | }), 31 | ], 32 | mode: "development", 33 | experiments: { 34 | asyncWebAssembly: true, 35 | topLevelAwait: true, 36 | }, 37 | // Typescript support 38 | module: { 39 | rules: [ 40 | { 41 | test: /\.ts(x)?$/, 42 | loader: "builtin:swc-loader", 43 | exclude: /node_modules/, 44 | }, 45 | ], 46 | parser: { 47 | javascript: { 48 | worker: ["*context.audioWorklet.addModule()", "..."], 49 | }, 50 | }, 51 | }, 52 | resolve: { 53 | extensions: [".ts", ".tsx", ".js"], 54 | }, 55 | devServer: { 56 | open: true, 57 | hot: false, 58 | liveReload: false, 59 | }, 60 | optimization: { 61 | sideEffects: true, 62 | }, 63 | }; 64 | 65 | export default config; 66 | -------------------------------------------------------------------------------- /rs/hang-wasm/src/bridge.rs: -------------------------------------------------------------------------------- 1 | use crate::{Command, Event, Publish, Result, Watch}; 2 | 3 | use tokio::sync::mpsc; 4 | use wasm_bindgen::{prelude::Closure, JsCast}; 5 | use web_message::Message; 6 | 7 | pub struct Bridge { 8 | commands: mpsc::UnboundedReceiver, 9 | watch: Watch, 10 | publish: Publish, 11 | } 12 | 13 | impl Bridge { 14 | pub fn new() -> Self { 15 | // Create a channel to receive commands from the worker. 16 | let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); 17 | 18 | // Get the worker Worker scope 19 | let global = js_sys::global().unchecked_into::(); 20 | 21 | let closure = 22 | Closure::wrap(Box::new( 23 | move |event: web_sys::MessageEvent| match Command::from_message(event.data()) { 24 | Ok(command) => tx.send(command).unwrap(), 25 | Err(err) => tracing::error!(?err, "failed to parse command"), 26 | }, 27 | ) as Box); 28 | 29 | global.set_onmessage(Some(closure.as_ref().unchecked_ref())); 30 | closure.forget(); 31 | 32 | Self::send(Event::Init); 33 | 34 | Self { 35 | commands: rx, 36 | watch: Watch::default(), 37 | publish: Publish::default(), 38 | } 39 | } 40 | 41 | pub fn send(event: Event) { 42 | let global = js_sys::global().unchecked_into::(); 43 | let mut transfer = js_sys::Array::new(); 44 | let msg = event.into_message(&mut transfer); 45 | tracing::info!(?msg, "sending event"); 46 | global.post_message_with_transfer(&msg, &transfer).unwrap(); 47 | } 48 | 49 | pub async fn run(mut self) -> Result<()> { 50 | loop { 51 | tokio::select! { 52 | cmd = self.commands.recv() => { 53 | let cmd = cmd.unwrap(); 54 | tracing::debug!(?cmd, "received command"); 55 | 56 | match cmd { 57 | Command::Publish(command) => { 58 | if let Err(err) = self.publish.recv(command).await { 59 | tracing::error!(?err, "failed to process publish command"); 60 | } 61 | } 62 | Command::Watch(command) => { 63 | if let Err(err) = self.watch.recv(command) { 64 | tracing::error!(?err, "failed to process watch command"); 65 | } 66 | } 67 | } 68 | } 69 | // Run these in parallel but they'll never return. 70 | _ = self.watch.run() => {}, 71 | _ = self.publish.run() => {}, 72 | } 73 | } 74 | } 75 | } 76 | 77 | impl Default for Bridge { 78 | fn default() -> Self { 79 | Self::new() 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /rs/hang-wasm/src/bridge.ts: -------------------------------------------------------------------------------- 1 | import type { Command, Event } from "./message"; 2 | export type { Command, Event }; 3 | 4 | export class Bridge { 5 | #worker: Promise; 6 | 7 | constructor() { 8 | this.#worker = new Promise((resolve, reject) => { 9 | // NOTE: This file has to be in the root of `src` to work with the current setup. 10 | // That way `../pkg` works pre and post build. 11 | const worker = new Worker(new URL("../pkg", import.meta.url), { 12 | type: "module", 13 | }); 14 | 15 | worker.addEventListener( 16 | "message", 17 | (event: MessageEvent) => { 18 | if (event.data === "Init") { 19 | resolve(worker); 20 | } else { 21 | console.error("unknown init event", event.data); 22 | reject(new Error(`Unknown init event: ${event.data}`)); 23 | } 24 | }, 25 | { once: true }, 26 | ); 27 | }); 28 | } 29 | 30 | addEventListener(callback: (event: Event) => void) { 31 | this.#worker.then((worker) => { 32 | worker.addEventListener("message", (event: MessageEvent) => { 33 | callback(event.data); 34 | }); 35 | }); 36 | } 37 | 38 | postMessage(cmd: Command) { 39 | const transfer = collectTransferables(cmd); 40 | this.#worker.then((worker) => worker.postMessage(cmd, { transfer })); 41 | } 42 | } 43 | 44 | function collectTransferables(obj: unknown, out: Transferable[] = []): Transferable[] { 45 | if (obj && typeof obj === "object") { 46 | if (isTransferable(obj)) { 47 | out.push(obj); 48 | } else if (Array.isArray(obj)) { 49 | for (const item of obj as unknown[]) { 50 | collectTransferables(item, out); 51 | } 52 | } else { 53 | for (const value of Object.values(obj)) { 54 | collectTransferables(value, out); 55 | } 56 | } 57 | } 58 | return out; 59 | } 60 | 61 | function isTransferable(value: unknown): value is Transferable { 62 | return ( 63 | value instanceof MessagePort || 64 | value instanceof OffscreenCanvas || 65 | value instanceof ReadableStream || 66 | value instanceof WritableStream || 67 | value instanceof TransformStream || 68 | value instanceof ArrayBuffer || 69 | // Add others if needed 70 | false 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /rs/hang-wasm/src/demo/index.ts: -------------------------------------------------------------------------------- 1 | import { WatchElement } from ".."; 2 | export { WatchElement }; 3 | -------------------------------------------------------------------------------- /rs/hang-wasm/src/demo/publish.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MoQ Demo 8 | 17 | 18 | 19 | 20 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 |
50 | 51 |

Tips:

52 | 53 |

Other demos:

54 | 57 | 58 | 59 | 71 | 72 | -------------------------------------------------------------------------------- /rs/hang-wasm/src/error.rs: -------------------------------------------------------------------------------- 1 | use hang::moq_lite; 2 | use wasm_bindgen::JsValue; 3 | 4 | use crate::ConnectError; 5 | 6 | #[derive(Debug, thiserror::Error)] 7 | pub enum Error { 8 | #[error("moq error: {0}")] 9 | Moq(#[from] moq_lite::Error), 10 | 11 | #[error("webcodecs error: {0}")] 12 | WebCodecs(#[from] web_codecs::Error), 13 | 14 | #[error("streams error: {0}")] 15 | Streams(#[from] web_streams::Error), 16 | 17 | #[error("karp error: {0}")] 18 | Karp(#[from] hang::Error), 19 | 20 | #[error("offline")] 21 | Offline, 22 | 23 | #[error("unsupported")] 24 | Unsupported, 25 | 26 | #[error("closed")] 27 | Closed, 28 | 29 | #[error("capture failed")] 30 | InitFailed, 31 | 32 | #[error("no broadcast")] 33 | NoBroadcast, 34 | 35 | #[error("no catalog")] 36 | NoCatalog, 37 | 38 | #[error("no track")] 39 | NoTrack, 40 | 41 | #[error("not visible")] 42 | NotVisible, 43 | 44 | #[error("invalid dimensions")] 45 | InvalidDimensions, 46 | 47 | #[error("unclassified: {0}")] 48 | Js(String), 49 | 50 | #[error("connect error: {0}")] 51 | Connect(#[from] ConnectError), 52 | 53 | #[error("resampler init: {0}")] 54 | ResamplerInit(#[from] rubato::ResamplerConstructionError), 55 | 56 | #[error("resampler: {0}")] 57 | Resampler(#[from] rubato::ResampleError), 58 | } 59 | 60 | pub type Result = std::result::Result; 61 | 62 | impl From for JsValue { 63 | fn from(err: Error) -> JsValue { 64 | JsValue::from_str(&format!("{}", err)) 65 | } 66 | } 67 | 68 | impl From for Error { 69 | fn from(value: JsValue) -> Self { 70 | Error::Js(value.as_string().unwrap_or_default()) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /rs/hang-wasm/src/index.ts: -------------------------------------------------------------------------------- 1 | export { Watch, WatchElement } from "./watch"; 2 | export { Publish, PublishElement } from "./publish"; 3 | -------------------------------------------------------------------------------- /rs/hang-wasm/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod bridge; 2 | mod connect; 3 | mod error; 4 | mod message; 5 | mod publish; 6 | mod watch; 7 | mod worklet; 8 | 9 | pub use bridge::*; 10 | pub use connect::*; 11 | pub use error::*; 12 | pub use message::*; 13 | pub use publish::*; 14 | pub use watch::*; 15 | pub use worklet::*; 16 | 17 | use wasm_bindgen::prelude::wasm_bindgen; 18 | 19 | #[wasm_bindgen(start)] 20 | pub fn start() { 21 | // print pretty errors in wasm https://github.com/rustwasm/console_error_panic_hook 22 | // This is not needed for tracing_wasm to work, but it is a common tool for getting proper error line numbers for panics. 23 | console_error_panic_hook::set_once(); 24 | 25 | let config = wasm_tracing::WasmLayerConfig { 26 | max_level: tracing::Level::DEBUG, 27 | ..Default::default() 28 | }; 29 | wasm_tracing::set_as_global_default_with_config(config).expect("failed to install logger"); 30 | 31 | tracing::info!("creating bridge"); 32 | 33 | wasm_bindgen_futures::spawn_local(async move { 34 | let bridge = Bridge::new(); 35 | if let Err(err) = bridge.run().await { 36 | tracing::error!(?err, "bridge terminated"); 37 | } 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /rs/hang-wasm/src/message.rs: -------------------------------------------------------------------------------- 1 | use ts_rs::TS; 2 | use web_message::Message; 3 | 4 | use crate::{PublishCommand, WatchCommand}; 5 | 6 | #[derive(Message, Debug, TS)] 7 | #[ts(export, export_to = "../src/message.ts")] 8 | pub enum Command { 9 | // Responsible for rendering a broadcast. 10 | Watch(WatchCommand), 11 | 12 | // Responsible for publishing a broadcast. 13 | Publish(PublishCommand), 14 | } 15 | 16 | #[derive(Debug, Message, TS)] 17 | #[ts(export, export_to = "../src/message.ts")] 18 | pub enum Event { 19 | Init, 20 | Connection(ConnectionStatus), 21 | } 22 | 23 | #[derive(Debug, Message, TS)] 24 | #[ts(export, export_to = "../src/message.ts")] 25 | pub enum ConnectionStatus { 26 | Disconnected, 27 | Connecting, 28 | Connected, 29 | Live, 30 | Offline, 31 | Error(String), 32 | } 33 | 34 | impl From for Event { 35 | fn from(status: ConnectionStatus) -> Self { 36 | Event::Connection(status) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /rs/hang-wasm/src/message.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { PublishCommand } from "./publish/message"; 3 | import type { WatchCommand } from "./watch/message"; 4 | 5 | export type Command = { "Watch": WatchCommand } | { "Publish": PublishCommand }; 6 | 7 | export type ConnectionStatus = "Disconnected" | "Connecting" | "Connected" | "Live" | "Offline" | { "Error": string }; 8 | 9 | export type Event = "Init" | { "Connection": ConnectionStatus }; 10 | -------------------------------------------------------------------------------- /rs/hang-wasm/src/publish/audio.rs: -------------------------------------------------------------------------------- 1 | use hang::moq_lite; 2 | 3 | use crate::{Error, Result}; 4 | 5 | pub struct PublishAudio { 6 | // The config that we are using for the encoder. 7 | config: web_codecs::AudioEncoderConfig, 8 | 9 | // The track that we are publishing. 10 | track: hang::TrackProducer, 11 | 12 | // The encoder accepts raw frames and spits out encoded frames. 13 | encoder: web_codecs::AudioEncoder, 14 | encoded: web_codecs::AudioEncoded, 15 | 16 | // When set, publish to the given broadcast. 17 | broadcast: Option, 18 | } 19 | 20 | impl PublishAudio { 21 | pub async fn init(id: usize, channel_count: u32, sample_rate: u32) -> Result { 22 | let track = moq_lite::Track { 23 | name: format!("audio_{}", id), 24 | priority: 1, 25 | } 26 | .produce(); 27 | 28 | let config = Self::config(channel_count, sample_rate).await?; 29 | let (encoder, encoded) = config.clone().init()?; 30 | 31 | Ok(Self { 32 | config, 33 | track: track.into(), 34 | encoder, 35 | encoded, 36 | broadcast: None, 37 | }) 38 | } 39 | 40 | pub async fn encode(&mut self, frame: web_sys::AudioData) -> Result<()> { 41 | let frame = frame.into(); 42 | self.encoder.encode(&frame)?; 43 | 44 | Ok(()) 45 | } 46 | 47 | pub async fn run(&mut self) -> Result<()> { 48 | while let Some(frame) = self.encoded.frame().await? { 49 | if let Some(mut broadcast) = self.broadcast.take() { 50 | self.publish_to(&mut broadcast); 51 | } 52 | 53 | self.track.write(hang::Frame { 54 | timestamp: frame.timestamp, 55 | keyframe: frame.keyframe, 56 | payload: frame.payload, 57 | }); 58 | } 59 | 60 | Ok(()) 61 | } 62 | 63 | async fn config(channel_count: u32, sample_rate: u32) -> Result { 64 | let config = web_codecs::AudioEncoderConfig { 65 | codec: "opus".to_string(), // TODO more codecs 66 | sample_rate: Some(sample_rate as _), 67 | channel_count: Some(channel_count), 68 | bitrate: Some(128_000), // TODO configurable 69 | }; 70 | 71 | if config.is_supported().await? { 72 | Ok(config) 73 | } else { 74 | Err(Error::Unsupported) 75 | } 76 | } 77 | 78 | pub fn publish_to(&mut self, broadcast: &mut hang::BroadcastProducer) { 79 | if let Some(config) = self.encoded.config() { 80 | let info = hang::AudioTrack { 81 | track: self.track.inner.info.clone(), 82 | config: hang::AudioConfig { 83 | codec: config.codec.into(), 84 | description: config.description, 85 | sample_rate: config.sample_rate, 86 | channel_count: config.channel_count, 87 | bitrate: self.config.bitrate.map(|b| b as _), 88 | }, 89 | }; 90 | 91 | broadcast.add_audio(self.track.consume(), info); 92 | } else { 93 | // Save a reference for later after fully initializing. 94 | self.broadcast = Some(broadcast.clone()); 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /rs/hang-wasm/src/publish/index.ts: -------------------------------------------------------------------------------- 1 | import { Bridge } from "../bridge"; 2 | 3 | export class Publish { 4 | #bridge = new Bridge(); 5 | 6 | #url: URL | null = null; 7 | #device: "camera" | "screen" | null = null; 8 | #video = true; 9 | #audio = true; 10 | #preview: HTMLVideoElement | null = null; 11 | 12 | get url(): URL | null { 13 | return this.#url; 14 | } 15 | 16 | set url(url: URL | null) { 17 | this.#url = url; 18 | this.#bridge.postMessage({ Publish: { Connect: url?.toString() ?? null } }); 19 | } 20 | 21 | get device(): "camera" | "screen" | null { 22 | return this.#device; 23 | } 24 | 25 | get video(): boolean { 26 | return this.#video; 27 | } 28 | 29 | get audio(): boolean { 30 | return this.#audio; 31 | } 32 | 33 | set device(device: "camera" | "screen" | null) { 34 | this.#device = device; 35 | } 36 | 37 | set video(video: boolean) { 38 | this.#video = video; 39 | } 40 | 41 | set audio(audio: boolean) { 42 | this.#audio = audio; 43 | } 44 | 45 | get preview(): HTMLVideoElement | null { 46 | return this.#preview; 47 | } 48 | 49 | set preview(preview: HTMLVideoElement | null) { 50 | this.#preview = preview; 51 | } 52 | } 53 | 54 | // A custom element making it easier to insert into the DOM. 55 | export class PublishElement extends HTMLElement { 56 | static observedAttributes = ["url", "device", "no-video", "no-audio"]; 57 | 58 | // Expose the library instance for easy access. 59 | readonly lib = new Publish(); 60 | 61 | constructor() { 62 | super(); 63 | 64 | // Attach a