Share page

On-Hold

Video showing the share page web app on the left and OBS on the right. A YouTube link is shared in the app, resulting in the video being played in OBS.

Share Page is an application I built that allows my friends and me to submit content (e.g. YouTube video, GIF, audio file, Instagram Reel) to be played on a streamer's live feed.

Why I built it

A friend of mine was streaming games on Twitch while playing with a few of our friends and me. While streaming we would often reference some content, react to circumstances verbally, or share content in a private Discord chat. My streaming friend felt like it would be more engaging for the stream viewers to see or hear some of the things we were sharing.

How I built it

One day I committed myself to bringing my friend's idea to life: an app that allows us to share content with each other, including the viewers. In near real time I would probe my friend for user experience information, implement her ideas, share the result with her, and repeat.

Under the hood

The "browser source" in OBS is a very powerful tool for augmenting your stream with interesting features because it allows you to display any web page as a video and/or audio source. I took advantage of this by creating a client/server web application that allows any single client to broadcast content to all other connected clients, with two client routes: one for a human to control and one for display as an OBS browser source.

    sequenceDiagram
        %% Participants
            actor Kevin
            actor Bob
            actor Jane
            participant JaneOBS as Jane (OBS)
            participant Server
        %% Sequence
            Kevin->>Server: share url
            Server->>Server: detect url as video
            Server->>Kevin: share video url
            Server->>Bob: share video url
            Server->>Jane: share video url
            Server->>JaneOBS: share video url

Server

The server runs on Node.js. Node.js made sense because it is familiar and suited well for rapidly prototyping this kind of application. The server is horribly simple: listen for client connections, wait for client messages, parse and decorate messages, broadcast message to all connected clients.

Real time bidirectional messaging

The client shares its content with the server and server must broadcast that content to all clients in real time. WebSockets were made for this , so it was an obvious choice. My experience with WebSockets was limited, so I was also looking to learn about the tech without much abstraction. Running on Node.js, the small, fast, and reliable library ws was chosen as a pre-built implementation of the WebSocket server protocol.

Language

With the server running on Node.js and the client on the web, both the client and the server have the potential to share some code JavaScript code. At the time of building this application I had found TypeScript to be almost strictly an improvement to JavaScript, so it was chosen as the source language which is transpiled into JavaScript.

The client also uses the other standard web languages with no additional transpilation: HTML and CSS.

Message validation

This particular application didn't warrant the sharing of much business logic, but with message passing back and forth over WebSockets, it's important to validate messages on the server and client. Unfortunately ws does not provide message type validation out of the box. At first I tried writing my own TypeScript interfaces and type predicates to be shared by the client and server, but I found them to be verbose and error prone, as if I had to write each interface twice: once as an actual interface and again as assertions against an unknown type.

interface SubmitContentMessage {
    type: 'submit-content',
    content: string,
}

function isSubmitContentMessage(thing: unknown): thing is SubmitContentMessage {
    return typeof thing === 'object'
        && thing !== null
        && 'type' in thing
        && thing.type === 'submit-content'
        && 'content' in thing
        && typeof thing.content === 'string'
}

websocketServer.onMessage((client, msmsgObjg) => {
    if (isSubmitContentMessage(msgObj)) {
        websocketServer.broadcast(decorateSubmission(msgObj))
        client.ok()
    } else {
        client.badRequest()
    }
})

I searched the internet for an idiomatic solution and stumbled upon a library called io-ts that allows you to define an interface using code that provides both build-time and run-time versions of the interface:

import { literal, string, type, TypeOf } from 'io-ts'

const SubmitContentMessage = type({
    type: literal('submit-content'),
    content: string,
})

type SubmitContentMessageType = TypeOf<typeof SubmitContentMessage>

websocketServer.onMessage((client, msgObj) => {
    if (SubmitContentMessage.is(msgObj)) {
        websocketServer.broadcast(decorateSubmission(msgObj))
        client.ok()
    } else {
        client.badRequest()
    }
})

This was especially useful when defining a large set of message types as a discriminated union because I could define types like above, then use io-ts to define a new type that is a union of all the message types:

import { TypeOf, union } from 'io-ts'
import { PlaybackSkipMessage } from './playback-skip'
import { PlaybackStopMessage } from './playback-stop'
import { SubmitContentMessage } from './submit-content'

export const ClientMessage = union([PlaybackSkipMessage, PlaybackStopMessage, SubmitContentMessage])
export type ClientMessageType = TypeOf<typeof ClientMessage>

Then the usage felt smooth:

import { ClientMessage } from './client-message'

websocketServer.onMessage((client, msgObj) => {
    if (!ClientMessage.is(msgObj)) {
        client.badRequest()
        return
    }
    switch (msgObj.type) {
        case 'playback-skip':
        case 'playback-stop':
            return websocketServer.broadcast(msgObj)
        case 'submit-content':
            return websocketServer.broadcast(decorateSubmission(msgObj))
        // no need for a `default` case because TypeScript recognizes the above cases as exhaustive
    }
})

Client

WebSockets were the big new thing I was attempting to learn on this project, so the client technologies were chosen mostly out of familiarity, so I could prototype my friends ideas a little quicker than if I had tried tackling many new technologies. With this in mind, I reached for React as the primary client framework, React Router for routing, Emotion for styling, Parcel for bundling, and React Player for playing content submissions. TypeScript was also used along with the same io-ts types used for the server code.

Reflection

This application was used almost weekly for a year by three to five people for a few hours at a time. During that time the application has changed a lot and I have had some time to reflect on the choices made.

Multi-tenant

My streaming friend and I have talked about how this could be a product that people might pay for, so I've been thinking about how to build multi-tenancy into the product. Right now anyone with access to the URL can submit content, so I'll need to start with some kind of authentication/authorization mechanism. I'm also thinking each user would have their own room where users could join to share things and the owner could provide different levels of restriction in their own room: Discord-only, friends-only, or public. If I allow public submissions then I'll likely need to add some moderation tools.

The current application is nowhere near being capable of supporting these features, so it'll likely require a significant rewrite.

Messaging vs. request/response

When the client sends messages to the server, the user experience demands that the server communication behave as if there is a response for every request sent. At the moment submitting content to the server gives limited feedback until the server broadcasts the submitted content to all clients. The server's response could take a long time or never come at all due to an error on the server, resulting in a user questioning whether their submission was ever sent.

In previous user interface work that I have done, this pattern is often associated with an HTTP request where the request is sent, remains pending for some time, then eventually resolves or rejects (server response or timeout). WebSockets do not provide such a request/response mechanism because their primary interface is one-way messages. It would be possible to build a request/response system on top of the existing WebSocket implementation, but I have a strong suspicion that reverting these types of requests back to simple HTTP requests would be much simpler.

    graph LR
        %% States
            Initialized
            Pending
            Resolved
            Rejected
        %% Transitions
            Initialized -- send to server --> Pending
            Pending -- server ok --> Resolved
            Pending -- server error --> Rejected
            Pending -- timeout --> Rejected

Peeling these message types away from WebSockets leaves only server-sent messages being used by the WebSockets, because we still need the real-time server-to-client broadcasting capability. This leads me to believe that I could swap out WebSockets with server-sent events (SSE). I haven't done any research into implications of such a switch, but I have a feeling SSE will be easier to implement and possibly scale better.

    graph TB
        %% Entities
            subgraph Client
                UI
                EventSource
            end
            subgraph Server
                HTTPAPI[HTTP API]
                SSE
            end
        %% Relationships
            UI -- sends requests to --> HTTPAPI[HTTP API]
            HTTPAPI[HTTP API] -- sends responses to --> UI
            HTTPAPI[HTTP API] -- notifies --> SSE
            SSE -- sends messages to --> EventSource
            EventSource -- updates --> UI

Client/server experimentation

I started this project excited to learn about WebSockets, but I feel like WebSockets at this scale are quite simple. Having such a simple implementation has led me to think that rewriting this application with an alternate stack could be a good opportunity to learn some new things. Some of my top choices for a new stack:

  • Go is touted as a great language for server development and I have been meaning to give it a shot for several years now.
  • Alternatively, Python is used quite widely with Django or Flask. My interest here is mostly due to the seemingly high demand in the software developer market for full-stack or server-side Python web developers.
  • Next.js experience is also in high demand, but I have much less personal interest in this particular technology (perhaps I'll blog about it someday).
  • I have been writing a lot of [Zig] lately, so that could be fun to try, but it's probably a little too immature for building a production-ready product.
  • [HTMX] is probably one of my top choices. I think it could simplify the client from a complex React frontend to almost purely HTML and CSS code.
  • If not HTMX, then there are some other client-side frameworks I have been meaning to try: SolidJS, Svelte, and Web Components (for a no-framework client implementation).
  • [Redis] for pub/sub and replicated server state.
  • [SQLite] for data persistence (currently persists nothing).

At the moment my preference for personal projects like this tends to lean towards standards-based, minimal framework, no-build implementations. With that in mind, the stack that entices me most would be Go + HTMX + Web Components (likely required for an alternative to the React Player, if not more components).

Server state

There are several features that my friend and I would like to implement that require some stateful data to be stored on the server and synchronized across clients. With the current architecture this would require a near-complete rewrite of the application, making some of the previously mentioned changes much more desirable. I imagine this will come almost for free after switching to HTTP for request/response messaging, SSE for real-time server-to-client messaging, and Redis for replicated server state and state-change subscriptions.

Alternate submission interface

One problem with the current implementation is that my friends can only consume shared content in a single way: by opening the OBS route in a separate tab of their browser. My friends often forget to do this step, so I need to find a new way to make sure everyone can consume shared content as soon as it is shared. I have three different ideas to solve this problem. I think implementing both would be ideal:

  1. Use Discord as the submission interface. All of us are already chatting on Discord, so consumption would require opening the channel where shared content is posted. I would also need to integrate my server with the Discord API.

  2. Show a content preview within the route used by humans. This still requires that everyone have the webpage open, but would ensure that our video/audio could be synchronized.

Conclusion

Not knowning precisely what my friend wanted when she presented her vision meant that I didn't really know what to build until I had already built it, meaning I had to make some questionable decisions along the way. I think it's a great way to get a simple prototype off the ground, but it means that the application in its current state is considered a prototype not yet production-ready. The ideas above should bring it much closer to production-readiness and perhaps monetization, but it's still unclear whether there is market demand for such a tool or if this is purely a novelty for my friends and me.

In recent times my friends and I have been busy with our own lives, so this application hasn't gotten much attention. I'm hopeful that someday we'll find ourselves reaching for this application again, but until then I think I'll consider this project on-hold and focus my desire to learn on alternate projects.

At the very least this was a fun project to build and to use.