<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="http://alexjball.com/feed.xml" rel="self" type="application/atom+xml" /><link href="http://alexjball.com/" rel="alternate" type="text/html" /><updated>2025-04-04T15:59:11+00:00</updated><id>http://alexjball.com/feed.xml</id><title type="html">ablog by aball</title><subtitle>A blog about science, technology, music, and food</subtitle><author><name>Alex Ball</name><email>me@alexjball.com</email></author><entry><title type="html">Deploying Open Source Pull Requests to Firebase Hosting</title><link href="http://alexjball.com/oss-pr/" rel="alternate" type="text/html" title="Deploying Open Source Pull Requests to Firebase Hosting" /><published>2022-06-05T00:00:00+00:00</published><updated>2022-06-05T00:00:00+00:00</updated><id>http://alexjball.com/oss-pr</id><content type="html" xml:base="http://alexjball.com/oss-pr/"><![CDATA[<h1 id="the-problem">The Problem</h1>

<p>I volunteer at Code for Boston. I help out with web app development, contributing code and reviewing contributions. All of our projects are open source, run on Github, and follow the fork-and-pull development model.</p>

<p>We built my last two projects using Next.js on Firebase Hosting, and our CI runs on Github Actions. Firebase provides some great tooling: <code class="language-plaintext highlighter-rouge">firebase init</code> can set up workflows for CD off of a specific branch and preview deployments for pull requests.</p>

<p>Unfortunately, the preview deployment workflow does not work across forks. Workflows that run on pull requests from forks do not have access to Github secrets. Otherwise, a malicious user could craft a pull request to leak secrets by printing them out. Since we can’t secrets, we can’t provide Firebase credentials to the PR workflow.</p>

<p>Without automated deployments for pull requests, reviewers have to check out the pull request branch and run the site themselves. This can be a significant barrier to participation, especially for non-developers looking at UI.</p>

<h1 id="the-solution">The Solution</h1>

<p>We automate PR deploys by splitting the workflow into two. We build the application in the PR workflow, store the static files as an artifact, and deploy it using a second workflow triggered using the <a href="https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_run"><code class="language-plaintext highlighter-rouge">workflow_run</code></a> action trigger. This trigger allows running a workflow after the completion of another specified workflow. The triggered workflow always runs at the tip of the default branch. This way, it runs with trusted code, and so has access to secrets.</p>

<h2 id="building-the-site">Building The Site</h2>

<p><a href="https://github.com/codeforboston/advocacy-maps/blob/bcd0aa8e04c6046ed1c7a7b4a897a4b9ef3fc60d/.github/workflows/build-pull-request.yml">This workflow</a> runs on each pull request, building the site and uploading to an artifact:</p>

<div class="language-yml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Build Pull Request</span>

<span class="na">on</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">pull_request</span><span class="pi">]</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">build</span><span class="pi">:</span>
    <span class="c1"># Don't build forks, even if actions are enabled</span>
    <span class="na">if</span><span class="pi">:</span> <span class="s">github.repository_owner == 'codeforboston'</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">Build</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>

    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Checkout Code</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v2</span>
      <span class="c1"># Set up the build environment using a local composite action</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Setup Build Environment</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">./.github/actions/setup-repo</span>
      <span class="c1"># Build the app, putting the static files in ./out/</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build App</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">yarn export:nolint</span>
      <span class="c1"># Upload the static files to an artifact</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Export App</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/upload-artifact@v2</span>
        <span class="na">if</span><span class="pi">:</span> <span class="s">github.event_name == 'pull_request'</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">name</span><span class="pi">:</span> <span class="s">digital-testimony-app</span>
          <span class="na">path</span><span class="pi">:</span> <span class="s">out</span>
          <span class="na">retention-days</span><span class="pi">:</span> <span class="m">1</span>
</code></pre></div></div>

<h2 id="deploying-the-site">Deploying The Site</h2>

<p><a href="https://github.com/codeforboston/advocacy-maps/blob/fa041276f73b82037d7a565cc411c80939b967af/.github/workflows/deploy-pull-request.yml#L19">This workflow</a> deploys the artifact uploaded by the previous workflow to a preview URL and posts it to the PR conversation for review:</p>

<div class="language-yml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Deploy Pull Request</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">workflow_run</span><span class="pi">:</span>
    <span class="na">workflows</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">Build</span><span class="nv"> </span><span class="s">Pull</span><span class="nv"> </span><span class="s">Request"</span><span class="pi">]</span>
    <span class="na">types</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">completed</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">deploy</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">Deploy Pull Request</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="c1"># Don't build forks, even if actions are enabled</span>
    <span class="na">if</span><span class="pi">:</span> <span class="s">github.event.workflow_run.conclusion == 'success' &amp;&amp; github.repository_owner == 'codeforboston'</span>
    <span class="na">environment</span><span class="pi">:</span> <span class="s">dev</span>

    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Checkout code</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v2</span>
      <span class="c1"># Download the site's static files and deploy them to Firebase Hosting</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Deploy App to Firebase Hosting</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">alexjball/action-hosting-deploy@v1</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">repoToken</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$"</span>
          <span class="na">firebaseServiceAccount</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$"</span>
          <span class="na">projectId</span><span class="pi">:</span> <span class="s">digital-testimony-dev</span>
          <span class="na">artifactName</span><span class="pi">:</span> <span class="s">digital-testimony-app</span>
</code></pre></div></div>

<p>Note that this workflow runs on the default branch, and so uses the Firebase configuration from that branch. Only the content of the site is affected by the PR.</p>

<p>We reused the Firebase deployment action, patching it to accept the <code class="language-plaintext highlighter-rouge">artifactName</code> of the site artifact uploaded in the triggering workflow. The action can resolve the artifact using the Github API.</p>

<p>You can generate a service account for <code class="language-plaintext highlighter-rouge">firebaseServiceAccount</code> using <code class="language-plaintext highlighter-rouge">firebase init</code>. Just ignore the workflow files it generates.</p>

<h1 id="is-this-safe">Is This Safe?</h1>

<p>This process deploys external contributor code. Can this be abused? Not really, at least not any more than one could with a local development frontend, which is publicly accessible. Both use development client keys, and both are allowed domains on our Firebase projects. And the development environment contains no private data, so phishing-styles attacks are not an issue.</p>

<h1 id="conclusion">Conclusion</h1>

<p>Splitting the build and deploy steps between workflows using the <code class="language-plaintext highlighter-rouge">workflow_run</code> trigger allowed us to easily deploy pull requests to preview URL’s. This enables more people to participate in reviews and makes it easy to review frontend changes without checking out the PR branch.</p>]]></content><author><name>Alex Ball</name><email>me@alexjball.com</email></author><summary type="html"><![CDATA[The Problem]]></summary></entry><entry><title type="html">Share Space: Low-latency Desktop Sharing</title><link href="http://alexjball.com/video-streaming/share-space/" rel="alternate" type="text/html" title="Share Space: Low-latency Desktop Sharing" /><published>2019-11-06T14:29:33+00:00</published><updated>2019-11-06T14:29:33+00:00</updated><id>http://alexjball.com/video-streaming/share-space</id><content type="html" xml:base="http://alexjball.com/video-streaming/share-space/"><![CDATA[<h2 id="background">Background</h2>

<p>The computer in my college dorm’s common space was a thing of beauty. It was connected to a big TV, always on, and had a wireless keyboard-trackpad. Students used it to watch lectures, work on assignments, and most importantly relax together. Its convenience promoted its use, and its flexibility made it an indispensible collaboration tool.</p>

<p>But college is uniquely local. In reality, our connections in work and life are distributed. Conference calls and screen-sharing close some of that distance, but screen-sharing video quality is often low and without audio; and remote collaborators are unable to control the desktop being shared. Remote desktop applications like Chrome Remote Desktop and VNC solve remote control, but only for one user and with low video quality.</p>

<p>I built <a href="github.com/alexjball/share-space">Share Space</a> to address these problems and to support the vision of a remote shared computer. Users join Share Space rooms using the Share Space web client and the room’s address and passcode. Each room hosts a virtual desktop, streams high-quality video and audio to all members, and allows members to take over remote control of the desktop. <a href="https://github.com/alexjball/share-space-host">Share Space Host</a> makes it easy to self-host room instances.</p>

<p>The most interesting part of this project has been achieving a low-latency (1-3 second) video stream of the desktop. Much of that work was panning for gold among the many specs and RFC’s that make up web video. So I’ll go over the implementation options for modern web video and then explain the design for Share Space’s video streaming.</p>

<h2 id="modern-web-video">Modern Web Video</h2>

<p>There are roughly two categories of video solutions.</p>

<h3 id="live-or-recorded-scalable-http-transport-3-10s-latency-hls-dash-low-latency-hls-cmaf">live or recorded, scalable, HTTP transport, 3-10s latency: <a href="https://en.wikipedia.org/wiki/HTTP_Live_Streaming">HLS</a>, <a href="https://en.wikipedia.org/wiki/Dynamic_Adaptive_Streaming_over_HTTP">DASH</a>, <a href="https://developer.apple.com/documentation/http_live_streaming/protocol_extension_for_low-latency_hls_preliminary_specification">low-latency HLS</a>, <a href="https://www.wowza.com/blog/low-latency-cmaf-chunked-transfer-encoding">CMAF</a></h3>

<p>The Media Source Extensions define the <code class="language-plaintext highlighter-rouge">MediaSource</code> class, which can be used to pass video stream buffers from Javascript to the video playback element. This allows implementing streaming players in Javascript and is how modern players support HLS/DASH/etc.</p>

<h3 id="real-time-not-scalable-tcpudp-transport-1s-latency-webrtc">real-time, not scalable, TCP/UDP transport, &lt;1s latency: <a href="https://webrtc.org/">WebRTC</a></h3>

<p>WebRTC defines the <code class="language-plaintext highlighter-rouge">WebRTCPeerConnection</code> class, which can be used to set up RTP connections for media and data. RTP (Real-time Transport Protocol) supports lower latency and higher bandwidth than HTTP or WebSockets, and is how web-based video chat is implemented.</p>

<h2 id="share-space-video-attempt-1-webrtc">Share Space Video Attempt 1: WebRTC</h2>

<p>WebRTC initially seemed like the best option because of its latency claims. To serve the desktop stream, I would need a server that speaks WebRTC and that could share the encoded stream between all clients. The <a href="https://github.com/node-webrtc/node-webrtc">node-webrtc</a> project provides a Node wrapper around <a href="https://webrtc.org/native-code/native-apis/">Chromium’s WebRTC implementation</a>. Since browser WebRTC encodes video for each connection, though, the server would encode once for each of its clients, rather than sharing one encoded stream. Other options include the <a href="https://gstreamer.freedesktop.org/documentation/webrtc/index.html?gi-language=c">GStreamer WebRTC</a> plugin, <a href="https://github.com/Kurento/kurento-media-server">Kurento Media Server</a>, and forking Chromium’s implementation.</p>

<p>In every case, the WebRTC protocol stack introduced significant complexity to the streaming component. So instead, I decided to prototype a solution using MediaSource and WebSockets, which turned out to be simpler and good enough for the MVP.</p>

<p>WebRTC will be worth re-evaluating for video/voice chat features and further improving latency. Two paths forward:</p>

<ul>
  <li>Use <code class="language-plaintext highlighter-rouge">node-webrtc</code> to pass the video stream over a data channel, replacing the WebSocket transport currently used.</li>
  <li>Investigate Kurento, which I found after implementation.</li>
</ul>

<h2 id="share-space-video-attempt-2-mediasource-and-websockets">Share Space Video Attempt 2: MediaSource and WebSockets</h2>

<p>Traditional on-demand video streaming works by breaking up video files into segments and serving each segment over HTTP(S). Players first download a manifest with media information and a mapping from video timestamp ranges to segment URL’s. Players then download the segments, buffering them as the video element decodes and displays the stream. This approach scales well since the segments can easily be cached by content delivery networks.</p>

<p>With this approach, latency is determined by:</p>

<ol>
  <li>App server: The time to encode and publish a segment for download</li>
  <li>App client: The time to download and push a segment to the video element</li>
  <li>Browser: The time to process and display a segment in the video element’s buffer</li>
</ol>

<p>Traditionally, the entire segment is encoded before being published, and browsers maintain a buffer of multiple segments to ensure smooth playback. Segments are 2-6 seconds, so this implies a latency of at least 10 seconds.</p>

<p>Optimizations like <a href="https://www.theoplayer.com/blog/low-latency-hls-lhls">low-latency HLS</a> use HTTP <a href="https://en.wikipedia.org/wiki/Chunked_transfer_encoding">chunked transfer encoding</a> to stream segments as they’re encoded, allowing 2-5 second latencies. This reduces the latency of (1) and (2) to the propagation time of the CDN.</p>

<p>Since Share Space scales by room, each stream serves a max of 10-20 users, so stream scalability was not a requirement. In that case it’s much simpler to stream video over a WebSocket. Furthermore, the room server need only serve the latest segment as a live stream, so the encoder can write directly to the room server rather than a CDN.</p>

<p>FFMpeg was used to capture the virtual desktop and encode the video stream. A Node server accepts the video stream from FFMpeg and multiplexes it between clients. It also manages connections to VNC controls and authenticates all connections using the room code.</p>

<p>The client feeds the video stream directly into a MediaSource instance using the <code class="language-plaintext highlighter-rouge">video/webm; codecs="vp9,vorbis"</code> mimeType. According to the <a href="https://w3c.github.io/media-source/webm-byte-stream-format.html">spec</a>, MediaSource expects an initialization segment followed by media segments. Each Cluster element in the WebM stream is a media segment, and everything before the first cluster is the initialization segment.</p>

<p>So in order to multiplex the FFMpeg stream, the server needed to serve the initialization segment to each client before starting to stream the latest media segment. I customized the <code class="language-plaintext highlighter-rouge">webm_chunk</code> muxer to write the video stream to the primary FFMpeg output and segment boundary positions to a secondary output, both of which were accepted by the room server. The room server then used this information to split the stream into segments and multiplex the stream between all clients.</p>

<h2 id="conclusion">Conclusion</h2>

<p>MediaSource and WebSockets provide a simple mechanism to serve low-latency video to web clients.</p>

<p>I’d love to hear your recommendations for WebRTC server components!</p>]]></content><author><name>Alex Ball</name><email>me@alexjball.com</email></author><category term="video-streaming" /><summary type="html"><![CDATA[Background]]></summary></entry></feed>