twitch landing page

Twitch solves a couple unique problems, namely, live video streaming and large group chat.

Before we jump into examining how their streaming and chat work, we will cover some terminology and the basics of their site.

Terminology

  • stream – can either be a session of live streaming or refer to a channel
  • channel – page where a user can stream their content
  • subscribe – users can pay ~$5, although there are multiple buy in levels, to unlock perks such as emotes, chat channels, discord servers, and more. Note: These perks can vary greatly between channels.
  • vod – video of a past stream
  • rerun – rebroadcast of a past stream, similar to a vod, but lacks a video scrubber and takes the place of the stream.

Note: values in text and URLs will sometimes be substituted with variables in the form $(VAR_NAME), this helps with describing generalized formats, and helps minimize the JSON examples.

General Architecture

For transferring data between their React App and backend, Twitch uses GraphQL.

When logged in, Twitch authenticates requests using an Authorization header.

Don’t worry, all those secrets are fake.

Request
POST /gql HTTP/1.1
Content-Type: text/plain;charset=UTF-8
Origin: https://www.twitch.tv
Host: gql.twitch.tv
Pragma: no-cache
Accept: */*
Connection: keep-alive
Accept-Encoding: gzip, deflate
Accept-Language: en-US
DNT: 1
Authorization: OAuth w8oa8myl28ttbx9r6hms9ayk8n
Content-Length: 195
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.1.2 Safari/605.1.15
Referer: https://www.twitch.tv/
Cache-Control: no-cache
Client-Id: 4qzwg0zmxv2xqddqoxnydzxbbyoezf
X-Device-Id: ptt47yco0ta1njsz

Storage & Resources

First off, there is a lot of stuff stored in sessionStorage, cookies, and especially localStorage.

Some notiable tid bits from localStorage include Agolia search info, tracking and error reporting related data, ad-block detection properties (blockDetector.detected: true), CSRF token, video player settings, theme settings (the CSS class is stored as the theme value).

In cookies, Twitch stores last login date, name, login, some unique identifiers (probably for tracking), along with a lot of settings referencing twilight, which I think is the name of their React app.

Twitch also uses a service worker. It has some connection to the URL https://spade.twitch.com. If we dig into the minified source, we find the m() function which URL encodes and POSTs to "https://spade.twitch.tv/", which is called twice: m(d.SpadeEventType.NotificationImpression, t), m(d.SpadeEventType.NotificationInteraction, t). Judging by those names, this service working is probably used for tracking.

In the m() function we see the service work does some encoding before sending the following object:

{
  browser: navigator.userAgent,
  platform: d.SpadePlatform.Web,
  time: (new Date).getTime() / 1e3,
  ui_context: "browser"
}

Twitch will JSON.stringify(), .replace(), btoa, Blobify, and then send as the body of a POST request to https://spade.twitch.com.

The rest of the service worker is pretty similar, but there is an unused object, serviceWorkerOption, which a quick google will give us https://github.com/oliviertassinari/serviceworker-webpack-plugin.

Continuing our search through the resources of Twitch, we enter the webpack:// section. We find references to bootstrap, Redux, along with some misc items.

In the static.twitchcdn.net section, we find a config/settings.js which is a ~1,500 line long object assigned to window.__twilightSettings and contains, you guessed it, config settings. Convering things from ad URLs and promotions, to AB experiments. 1,300 lines of it are experiments config.

static-cdn.jtvnw.net serves all the game box art and user profile pictures.

c.amazon-adsystem.com serves a javascript file which is used for ads. Looking at the minified source we can see that this file is using Redux, as we find store.dispatch, store.getState(), and __REDUX_DEVTOOLS_EXTENSION__. Anyway, it’s ~3,000 lines of ads software, not much more to say.

Notifications

Twitch connects to a websocket via

wss://pubsub-edge.twitch.tv/v1

Where it sends page views, navigation events as well as establishes listeners for whispers, sub gifts, raids, commerce events, and more. It seems to be the centralized pub-sub connection for any sort of Twitch event, except chat, which is separated and gets its own websocket.

React

Now we are going to dig into the frontend, specifically the React portion of the site. Earlier we found that Twitch uses Bootstrap, which we can further confirm by looking at the CSS class names. One thing to note is that Twitch prefixes all of the bootstrap classes with tw-.

We also see they use https://grsmto.github.io/simplebar/.

Another thing we can see from the React dev tools are the Apollo, React Router, and a custom withLatencyTracking higher order components.

react dev tools high order components

Twitch implements code splitting for its React components to reduce bundle size. This means that Twitch will only fetch components that are relevant to a give page.

If we navigate to a channel, Twitch fetches the following JavaScript. Note the pages.channel-$(HASH).js, the current page we are on.

URL size
https://static.twitchcdn.net/assets/pages.channel-4e7431955c7411dc5436.js 9,947 bytes
https://player.twitch.tv/js/player.js 346,737 bytes
https://polyfill.twitchsvc.net/v2/polyfill.min.js?unknown=polyfill&flags=gated&features=Array.prototype.find,Array.prototype.findIndex,Array.prototype.includes,default,fetch,Intl.~locale.en,Object.entries,Object.values,URL 74 bytes
https://static.twitchcdn.net/config/settings.86ab32c41a6f1df12947484a47fac07d.js 9,331 bytes
https://static.twitchcdn.net/assets/vendor-044a9e90b10f919b0ed7.js 277,546 bytes
https://c.amazon-adsystem.com/aax2/apstag.js 58,051 bytes
https://imasdk.googleapis.com/js/sdkloader/ima3.js 81,029 bytes
https://d2v02itv0y9u9t.cloudfront.net/dist/0.0.1/v6s.js 44,384 bytes
https://www.gstatic.com/cv/js/sender/v1/cast_sender.js 750 bytes
https://s0.2mdn.net/instream/video/client.js 26,765 bytes
https://cvp.twitch.tv/2.6.3/mediaplayer.min.js 12,843 bytes
https://static.twitchcdn.net/assets/features.verify-email.components.verify-email-bar.component-45e42fd6d41c07f5c215.js 3,736 bytes
https://static.twitchcdn.net/assets/features.whispers-bf11b9979b8d4db494d9.js 6,909 bytes
https://static.twitchcdn.net/assets/features.chat.components.orbis-c7af51740def984faec8.js 1,489 bytes
https://static.twitchcdn.net/assets/features.chat-share-8c0ed90e37dcf7a6cd91.js 5,123 bytes
https://static.twitchcdn.net/assets/features.chat.components.raid-notification-0bd545813513e391fe0f.js 5,685 bytes
https://static.twitchcdn.net/assets/features.chat.components.mystery-gift-chat-banner-queue-6953600fd8110b1785ab.js 2,239 bytes
https://static.twitchcdn.net/assets/features.chat.components.native-broadcast-notification-4379cfabefd5fe9acfc5.js 1,709 bytes
https://static.twitchcdn.net/assets/features.chat.components.rituals-onboarding-sticky-158333526dcd4f0dbf18.js 2,261 bytes
https://static.twitchcdn.net/assets/features.chat.components.new-chatter-ritual-fc6dd087608c5f6b6750.js 4,582 bytes
https://static.twitchcdn.net/assets/features.chat-settings.components.chat-settings.component-55ba2a85e0dee3715331.js 9,424 bytes
https://cdn.krxd.net/controltag?confid=JYk8LCsC 28,435 bytes
https://sb.scorecardresearch.com/beacon.js 1,495 bytes
https://adservice.google.com/adsid/integrator.js?domain=www.twitch.tv 109 bytes

Then if we navigate to https://twitch.tv/directory/following, we fetch

URL size
https://static.twitchcdn.net/assets/pages.following-cc9795beec96ec2aa94c.js 36,628 bytes
https://static.twitchcdn.net/assets/features.video-carousel.components.latest-videos-from-followed-carousel.component-f92bf524e344ebfa4af3.js 4,804 bytes

So by chunking, Twitch prevents having a huge bundle, stuffed with components that aren’t relevant to our current page.

But what about CSS? Twitch also chunks CSS. For the following page, Twitch fetches:

URL size
https://static.twitchcdn.net/assets/pages.following-84fe489e8ee0c6be4c8a.css 1,187 bytes
https://static.twitchcdn.net/assets/features.video-carousel.components.latest-videos-from-followed-carousel.component-3a59f2f3f8da752aec6c.css 398 bytes

In terms of server side rendering, if we curl https://www.twitch.tv/directory -o index.html && open index.html, we can see that Twitch returns HTML for the top navbar, and the loading spinner. The necessary CSS is included in a style tag, while the CSS used in the rest of the site is not loaded. What this means is that the default HTML in the page is entirely separate from the React site. However, Twitch does ensure that the og:url meta tags are the same as page URL, but besides that, the entire page is static.

In the <head> are prefetch link tags,<link rel="dns-prefetch" href="//example.url"/>, which, you gussed it, prefetch the dns lookup for a resource you’re going to use. We also find some <link rel="preload" href="//example.url"/> for ad related resources.

Twitch also includes relevant <meta/> tags such as their Open Graph data.

Sidenote: Twitch appears to have an event listener for page focus, so that when you tab back to the page, the title updates. Not sure why this is – maybe a quark of their react routing library necessitates it.

Twitch directory page

An interesting part of Twitch’s frontend app is their persisting video player, which will popout and float in the bottom left hand corner of the browser when you navigate away from a channel.

Essentially, this works by keeping the React component present in the DOM when a user navigates, while using CSS for position. So if you were to nav from a stream to the settings page, then video player component will still be present.

Seems pretty simple, except that when you navigate to the settings page directly, there is no video player present.

twitch full screen video player

And then we navigate to a different page.

twitch mini video player

Some interesting note about logging out of Twitch, is that a full page refresh will occur. This is a simple way to clear session data, but localStorage will still persist. On login, a full page reload also occurs. I am guessing this is required by way of their authentication, but could likely be avoided since they only need to set/remove their auth-token cookie.

Twitch’s search box uses Agolia, which is a hosted search solution similar to Elasticsearch. On the frontend, the search is composed of a few React components that take the search results as props.

In terms of fetching the data, Twitch contacts https://xluo134hor-dsn.algolia.net/1/indexes/*/queries?x-algolia-agent=Algolia%20for%20vanilla%20JavaScript%203.24.11&x-algolia-application-id=XLUO134HOR&x-algolia-api-key=d157112f6fc2cab93ce4b01227c80a6d which returns a large chunk of JSON in ~60ms. Pretty fast. Although the intitial Agolia query returns most data, including thumbnail urls and user data, Twitch makes a GraphQL query to fetch the current thumbnail for live channels matching the query. This takes about 150ms.

dota2 search

Twitch also uses Agolia for user search in its whisper system.

whisper chat box whisper chat box

The chat uses GraphQL for sending the whispers.

[{
  "operationName": "SendWhisper",
  "variables": {
    "input": {
      "message": "hello",
      "nonce": "9a9632f6a0fcda0f5a0df7a6e5f19339",
      "recipientUserID": "1234"
    }
  },
  "extensions": {
    "persistedQuery": {
      "version": 1,
      "sha256Hash": "8a5eb4b94c03ef9fe84ab8775885bad00d02ca68a2568af6e1e0a707d971a3f6"
    }
  }
}]

Messages

It turns out that Messages have been deprecated, and that Messages are not the same as Whispers.

twitch messages inbox

Chat

Chat can be viewed by anyone, but requires a login to send messages. There are the concepts of separate chat rooms, which can be restricted to subscribers. Users can send predefined emotes. These emotes can be available to everyone or they can be limited to people who subscribe.

Underneath, Twitch chat uses websockets and talks to an IRC backend. The usage of IRC is abstracted away from the user, but we can see its traces in the url, irc-ws.chat.twitch.tv, and also with the initial websocket connection.

When we connect without being logged in, we register with a NICK justinfan41197 (the last bit is just a unique id), and PASS SCHMOOPIIE. These are typically for an IRC server. Then we connect to the IRC channel for the stream, so if we are watching https://twitch.tv/dota2ti, we will JOIN #dota2ti.

Messages are sent in the following format:

@badges=;color=;display-name=;emotes=;id=;mod=;room-id=;subscriber=;tmi-sent-ts=;turbo=;user-id=;user-type= :$(USER_NAME)!$(USER_NAME)@$(USER_NAME).tmi.twitch.tv PRIVMSG #$(CHANNEL_NAME) :$(MSG)

// e.g.

@badges=premium/1;color=#8A2BE2;display-name=example_user;emotes=;id=66a3335a-524b-4940-976f-16b2e58a7d4e;mod=0;room-id=35630634;subscriber=0;tmi-sent-ts=1535211960034;turbo=0;user-id=17461;user-type= :example_user!example_user@example_user.tmi.twitch.tv PRIVMSG #dota2ti :Wow that was cool!

On disconnect, Twitch sends a PART message:

PART #dota2ti

Chat is largely siloed from the frontend app. It forgoes GraphQL, and uses plain HTTP requests.

For instance, Twitch calls GET https://tmi.twitch.tv/group/user/$(CHANNEL_NAME)/chatters to get the current chat members of a channel. Twitch does end up making a GraphQL request when you start watching a stream (see below).

[{
  "operationName": "ChannelPage_SetSessionStatus",
  "variables": {
    "input": {
      "sessionID": "ade0ad79ef2ae75a",
      "availability": "ONLINE",
      "activity": {
        "type": "WATCHING",
        "userID": "121437143",
        "gameID": null
      }
    }
  },
  "extensions": {
    "persistedQuery": {
      "version": 1,
      "sha256Hash": "8521e08af74c8cb5128e4bb99fa53b591391cb19492e65fb0489aeee2f96947f"
    }
  }
}]

You may notice that this GraphQL query is missing the query field. This is because Twitch uses persisted queries, hence the persistedQuery field in extensions, which allows for using unique IDs in place of a query field.

Something interesting to note is that the link generation, rich embeds (for links like ), as well as highlighting for user mentions via `@username`, are all handled on the client side. The client parses the plain text messages and decides what to do.

Video Streaming

Twitch uses HLS as its streaming protocol along with CloudFront, Fastly, and S3.

Live

Twitch initially fetches:

https://usher.ttvnw.net/api/channel/hls/$(CHANNEL_NAME).m3u8

// which has some interesting query params
allow_source: true
fast_bread: true
p: 6240561
player_backend: mediaplayer
playlist_include_framerate: true
reassignments_supported: false
rtqos: control
sig: e9fbe26616daf5bc600701e544407bf49c1d9bb0
token: {
  "adblock": true,
  "authorization": {
    "forbidden": false,
    "reason": ""
  },
  "channel": "dota2ti",
  "channel_id": 35630634,
  "chansub": {
    "restricted_bitrates": [],
    "view_until": 1924905600
  },
  "ci_gb": false,
  "geoblock_reason": "",
  "device_id": "13a3960af9ecdf2e",
  "expires": 1535307382,
  "game": "",
  "hide_ads": false,
  "https_required": true,
  "mature": false,
  "partner": false,
  "platform": "web",
  "player_type": "site",
  "private": {
    "allowed_to_view": true
  },
  "privileged": false,
  "server_ads": false,
  "show_ads": true,
  "subscriber": false,
  "turbo": false,
  "user_id": null,
  "user_ip": "50.19.85.132",
  "version": 2
}
cdm: wv

and returns an .m3u8 file (see below), a plain text utf-8 format known as M3U for declaring a playlist of media.

Notice how Twitch sends its adblock detection on stream start?

#EXTM3U
#EXT-X-TWITCH-INFO:NODE="video-edge-ee80c0.jfk04",MANIFEST-NODE-TYPE="legacyplus",CLUSTER="jfk04",SUPPRESS="false",SERVER-TIME="1535306185.14",TRANSCODESTACK="2017TranscodeX264_V2",USER-IP="72.93.246.22",SERVING-ID="eafc0af1105e4e8ba7a241c5beaea437",MANIFEST-NODE="video-edge-ee80c0.jfk04",ABS="false",BROADCAST-ID="30091197168",STREAM-TIME="1423.14387012",MANIFEST-CLUSTER="jfk04"
#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="chunked",NAME="1080p60 (source)",AUTOSELECT=YES,DEFAULT=YES
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=5113475,RESOLUTION=1920x1080,CODECS="avc1.64002A,mp4a.40.2",VIDEO="chunked",FRAME-RATE=60.000
https://video-edge-ee80c0.jfk04.abs.hls.ttvnw.net/v0/CocDov9gy2FlhSZ19hZK2DVPBM8RX0IWsDojoGtqiQzyh4E-R1dx6ft9X5_KQbZKGtRgBuGLzQkF0BHEszoIwNs8XGKDJcX0Rh6CGqfyhRTNDDFeogSvFnFJrKi5XoKOHgUo2otjtsPYyVwidMMFoqy2PlLWWKVKGhei5Ut3mSTCjeaDWI8BfcIxuLUHzmAoIh6iB5ook_UdLp5bSGBb0tGx0DUitppzN1KDrQfAm8kKgSjoSkvrR1KgXCYvVih805gEIJVTE88wDto8oTYdw0xMvCFUo3grImvymSdPjD4-qGVjB2xDbd4ffzzYHVJPr4pdja_z5KfOgh1Iif2jP5k_woHC-qk9lIWSuO8hlvjUYPoZYmAHV-s_W_UjxK3vxgFSbTRKrVX-tovmFf09oFq-XNC0Av1f6lRIC7wEZjtANGTereUQz_kA3Q8Eof9KwvBapdSKMsSgbv-u84RXX85wt0DQPZg562OjNWhK51muGu11BxDboYnjHGd8s_Zxz7eKa0VtcL_Y2RIQkAOzidc1lAqDCROnOIaaThoMPO5u5U_eDKMilOUY/index-live.m3u8
#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="720p60",NAME="720p60",AUTOSELECT=YES,DEFAULT=YES
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=3424135,RESOLUTION=1280x720,CODECS="avc1.77.31,mp4a.40.2",VIDEO="720p60",FRAME-RATE=60.000
https://video-edge-ee80c0.jfk04.abs.hls.ttvnw.net/v0/CoUDJRXK-Sztcjlos9Kw9V1dYgN98eHFcHzPDLYtEXqqZzLCuUFWL8ujnEh_qsmZWTpfnVrsnmgSV_R9iWAPWSgvpElqsvXz2C6mM2bXb7Brcf0B09VLEP_6epz8NHTSifS49mxtq_-EzoCRLKcWPLkZFc7Ok3DgYMW9zqURTyTm87utG3RzQ_pS9jYS5Xl9dv-R6Wljl60OoKSvqCm-uIp-4EUzT4bCNhyMwKsLhsVPbZaLRQqIzyVGlMGj123m8kjQlbNsxo8HR4SV2b9aMLhjYVammNGZlpG0qUPxkragbxWWScg5yUrGomF1a6D6lQr2l65PMKNUd5XDf8l-UtBVHQF-R_6W7E0UNLJKqu3IKgWi8VQQNs-NSGk8Movrq8N5nHvkbiv-jU8ryZ2jPDIh5b42W_D1dMVUa4KX03pUM1e64qAlJv4jqcd-NLEhS1WL15bLb0iRANyPdDft50970kIOTqU-f5R3upqCB--71McQ1rlGLf0USg7Hy4UwnfV2QNEF3m8SEB2j39737NqyQmjJgniw1R4aDOVPY2ez9jNy0U3GmQ/index-live.m3u8
#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="720p30",NAME="720p",AUTOSELECT=YES,DEFAULT=YES
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2374135,RESOLUTION=1280x720,CODECS="avc1.77.31,mp4a.40.2",VIDEO="720p30",FRAME-RATE=30.000
https://video-edge-ee80c0.jfk04.abs.hls.ttvnw.net/v0/CoUDKJD2okGCgP2IaneUItwRZqzeEyHRM4YSH9JxT3eg8F72494XhIbD0Q70O4sa0Pi0xI1nHj8PXuB-T8wln54DlcrqjJBqSXgrizQpgoH3uvbcJSR8jAlsJ3-29TfMPdYStaKslSooqMZrGOirSnNKWa_Kh4x_QdZCyYpk8J-VfxnynvEWrE8aOW2Gh9o1bvrv1SHfUuOJP4ZtPZU8NV83iP_NyO5CRIV00wZrPvBC-eoxLDUpoBmFmybC95GX5IQ9J9OC67bn2eGUfNd8oggV2XgHiStigUVztSlesW9vvzmHeRb5wADaDTCpJ-YMul_UBrUI3TeE5TVjPNg0b68m8UAtFycVlvDVB7horA5LYtwAt0V0Ved1Tht7z284w9hLFgC0DPpaVzq02zQJNP3ePwkFbN1xO2gubnj5pJ8kqxznndujS8fQzAB-2lX15HiZae_eWfk4VxM2c-SvoWFQT1kwJyJGISAArMXTOTxIO2RufBbEz4Zvq9cgGj15hppdHfji-nISEOGaAutRR9obc8bVwiU2K1MaDLHr-7xIxQ7A_YGvmw/index-live.m3u8
-- snip --

Now we fetch the playlists listed in the playlist file, which will give us the URLs for the HLS video segments.

https://video-weaver.jfk04.hls.ttvnw.net/v1/playlist/CskDy5hLEXD7kMQ8E2BikjYwzUQ5qi8jiIgu7geDxNkUQEqBthOCopvXsbP96NQzarY2VP3Ok0hKxqMh1RpGeGOFlC8FtfF5uLdNJWl8yKpS3gPTqL0Ipqd-oorDs5SNiOsmlJmC-DF8WtdFjDr2fyBovNSiqvAm43wg8jASiHoc-4kqN4GtLcHgiG61B981aeDlFn_mMLTQVEWlsV0edrSblcyMNh5c3XmW5rl2H4MIGqljAy7854WdBjuAyNnVNAdgngsMRJc1gXuV8z2KtUFeW0buYO9flIgGLhDfxQ2981NdvQlOrXvgYcdCnSRd28Bq3jxF1e91W2-x4211C6rWBzSYxThA2q83NVYspIi6QR178fewaDLRtgwvn88U6EaAh2UgOd_hPhjTFon7gJd4Y1dPaGFaf8tFocAySus8NKcvtf20ixIOmp1PJneeoKaME-rptFAPxtXCcbCTbU3C-3yAIgY3PeUw_ithf1lc-uAC49Mj5OYNvN3tICZn5ZE2WOSQnS0TGrgaoa8Q2R2ZaqXgaO9_30m-8FjjAqx7rr7GnYb_oiTO6OU6DfC9mGmVrf7SwaJHMQhqz5raddFVtpuFMgKg9pY_thIQMP0P2Ww68WdfvNOMh5-JCxoMROnHHKRo1Y1nFiic.m3u8

With returns the following video segments:

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:646
#EXT-X-TWITCH-ELAPSED-SECS:1233.050
#EXT-X-TWITCH-TOTAL-SECS:1263.150
#EXT-X-PROGRAM-DATE-TIME:2018-08-26T17:53:24.317Z
#EXTINF:1.217,live
https://video-edge-ee80c0.jfk04.abs.hls.ttvnw.net/v1/segment/CuEDn-hvM0HhTq7TOEc8XxBpnZm250k-LAjXkxQ5h0213IBSHsE2NdXjsHI35YcEJR48-5Gu5kUyGHM6zLWe7LrNR-tl7OgAeClQ0lG3RiOynfukpXndj8HWMn0NbKoBvW9PyRhr0nQRT3jHQocNXTh13X8SynHlusVkCQ8ANqAxMeatCZ9mTryMMWFuywD6uzdquydFAv2XxQwKUnuZPNkMcFGVq6FX00pOeBvESr66OW7knPEWVHQUyqqdHKu_5dmnd0jUOoR08PL_ocaISX8F8PE2LWMWw9haW254vE8C7zDIEctkE1I3EvcoD1POZIj1lkRJgBklXyVeDq1dp5eMRpqi1yERCBMLp1n_4tZN2SZxq5_WLBFdGu5TJypCgEV9nd2swdft-L3JWAevAiH1aw6pDzbtEi4gzb5P2WJiXJIzqOF8oVi0xhTa08Rxgn4G2ftj9Xm42M4NMRIoksPORKZBy9WMvTqkx0n6D_jkNWm66vqWN1znp0f18n8fpicL774tUecsUCG1_DTBBYpH3RDaerqlWDd1Buez8TwA_v4RS_iFc0R9a6RZsMw7nTD-SegvYa_KHgPZVC4mKxSqjAoFdwjC7ShVMaYfCHkiH0x_oxhOll71IbZkApEt2zW1hhIQMaxUzcFmYorDgaBVLDmzpBoMsKQQBE5Xt-vp2R1C.ts
#EXT-X-PROGRAM-DATE-TIME:2018-08-26T17:53:25.534Z
#EXTINF:1.850,live
https://video-edge-ee80c0.jfk04.abs.hls.ttvnw.net/v1/segment/CuEDzRhSintGTeLUEpRsN5mxnaYDjPYNa6F5R72cZxKM_FQSCkzmLHwSNyrwmsJUC2y90N9Vf_HOS3znTLul4a7gj4HUcPpIYq4ybbTbLMx7_p5cBtpkL3KkBDNIPFcxZQr5LXh9VQ7WLBQ_doZ1Bp_HTiiMgihv7vhMIFK1tdwgkcXRk5t6_gi5O2yJ8r9wVd4v7hE8MbWtTak2ce-FvDmRlzo0ZXksead-tjhz6ZhOM5fra7EaWy-QYd0B3pdB82bhQjXaeCgJNKYmgh0hVs5Y5rZFgrD-YgLK2UbBg9wbxW7q3QuRwstLJzXrSR2RGOUwp0VHOtpaXi39DUgYlyHaTHjw1rQ1hzovvEWoizuRaIFh6pj73vh-MxA0uSsmbvMIYkCvtKm9uAOVrkoZqccnhqhjLxeNzcmp5iZCHYRgxS5ahyn_MvjwpaF355ncW_7wlln0VuiXODgcDEvSww8y7G91V_Rfwb3S8RJQ3oFLYtWe1IrwcqAaJStcgwvQxC4TY4KB3QiroAlFcgEm5JyivSWfUILheevlBYvWr5VqMEzPOd2O-coPKGfAKQ3JRWc9LmhX4LrZgCqOqUQC3zkW5084YY1IMzPDQ70Ty32M17DnsiIlphwQ51pVgoSQnLSJDBIQCQiVYigozes3i6XEsDEi-BoM-4NjyG_OSozMi0BW.ts
#EXT-X-PROGRAM-DATE-TIME:2018-08-26T17:53:27.384Z
#EXTINF:1.433,live
https://video-edge-ee80c0.jfk04.abs.hls.ttvnw.net/v1/segment/CuED9JyCn4a1-Jl11NC7esW7jRv-sjpqKQen2OEMre6REYEg1ATCLAtTcnSbc6PzQu3zvr81dd8LjomGbP7MtedcUl04b1XahjZEQaStcOFyiY7CnUbLn5OA5L8mnHMezKB6eI-fguuKyC1nGPcmgVQEb42psjOrGeBwd-GznC08IRdxGOxKTqK7pZMF5gevjtgJ9kwAKVUvclcn9mpY1Svewu7KusxTwNIJawhahgTXuLv2i9hqz6Griwj4xM4hZVTTw9grp2GDoFaRmJ4jhhTdDnI1ckTYqm9f_9XReIA2FTVqfN5s3C-sqvKf0Qj9OZUUAiBbcDnCU3mpjrRY44KbCkBI9h00vUlUab8ClG4R0w21bN6teOOtWdEvca--ggl_20Hw8K7ZdYiYRtCWlMb10UnlF4ZIgN4ZIR4dngrVu6Ei5bFp-riHNhMyAUK3yNUUZoy2QqxK_BT4rvlkaah4p-mqTJNIWCPC7-h4X-MFhTwvfp5BF-bjBzGWte8SPkAIe6SMs_gcRTo2GK4JQEQIs0H5dsN_2Ya8AZOK0tJaDwmTJnfJnN35xEQKYvYfAwQKTuT5n7FJ_Sovn4xKSRNOe-gzCtTaRJyxdsj4uWeEWEOmzFdAxvWa4Z93st8qWufZrBIQaybV4ILHBsCUup7AOBbQ3RoMmVCB5KDHKrNgZf2a.ts
#EXT-X-PROGRAM-DATE-TIME:2018-08-26T17:53:28.817Z
#EXTINF:1.517,live
https://video-edge-ee80c0.jfk04.abs.hls.ttvnw.net/v1/segment/CuEDlhDOFPDUvyBuEmOS6EN95eVHeDWjHCd8LKUqgosUNRJb7XPJbgUDdQTY00pW1Sv4zz4BE4jfFPnlmeQUXYzSxXO7AOV3KBfGmmJ5HD-iyux_YFG_fm9XfZrjqMWDH8ShGqrvdVpoX5o8PpWSbmugMkkRmUBcB_SAwHWrbqD3s59x9DDIdPg1Rnjcs8UNLKt525_m9Usi0AEBcs-c0md-ZiPT2ub51PGARc2R_OU1TBi63bgYAjCyFnhufcYnN8audAxKItwlbcCbPSPuLhY-Istr5Prxg5cQFoy_6CgboSlIEXjQ1wViLi4cS3QSHlkHP0bT7cQIOwXsCfJ4f_Ng2ZtTzKxhnBRRTg3a9hcBzXFQxUt2yAU5vhKrOBMfnq-xXqDf0Swb8Yee1JPT6EtrhX5s4be2a9NV2A5CMXcMV8s4wI7TYO8euyLh6s7VjwF0REoSAIddf1BQ5kvhjLVGc5GS7LjLsVCUIEwCwz8pc0UZ0bwbesRPbmnXRTmcSCWaTJB6qIktye6jiuu7NykSPOU9W5RyJTRy8R4FXEYC8jluy4FW8_HKH4W4oEUAhF9_K9IJpskH_xMZAC56KfAwQ7LvZo76c5NO0SjfLt_oPQroAz1vvFN4GoDOrc2lcMwAGhIQj3sXhbxmK2bXJp-PmYCGVhoMbM5XJtzuujVFi8FC.ts
-- snip --

Then we just fetch those video segments, and repeatedly call the same .m3u8 URL to get more segment URLs.

Reruns

On initial connect to a channel showing a rerun, Twitch POSTs a blob of data to https://video-edge-3194a4.sjc02.hls.ttvnw.net/v1/segment/$(SEGMENT_ID).ts, and gets a 204 back.

Then, like live broadcasts, we contact the usher URL

https://usher.ttvnw.net/api/channel/hls/$(CHANNEL_NAME).m3u8

and then fetch the subsequent playlists from:

https://video-edge-ee7fbc.jfk04.abs.hls.ttvnw.net/v0/$(UNIQUE_ID)/index-live.m3u8

and fetch the segments

https://video-edge-ee7fbc.jfk04.abs.hls.ttvnw.net/v0/$(UNIQUE_ID)/index-$(SEGMENT_ID).ts

Vods (Videos)

Vods are usually past streams that have been kept and stored for replay, although it is also possible to upload videos directly to Twitch.

On viewing a video page, Twitch will send GET requests to:

https://usher.ttvnw.net/vod/$(VIDEO_ID).m3u8

// and

https://vod-metro.twitch.tv/$(CHANNEL_ID)_$(VIDEO_ID)/$(VIDEO_QUALITY)/index-dvr.m3u8
// or sometimes via a CDN (seems that there is some AB testing going on)
https://fastly.vod.hls.ttvnw.net/$(CHANNEL_ID)_$(VIDEO_ID)/$(VIDEO_QUALITY)/index-dvr.m3u8

Which give us the segment URLs to fetch.

Twitch fetches the videos in chunks that can range from a couple hundred KBs to around ~9MB, but this is only a small sample. The size of the chunks depends on your video setting. The 160p option returns ~370KB chunks.

https://vod-metro.twitch.tv/$(CHANNEL_ID)_$(VIDEO_ID)/$(VIDEO_QUALITY)/$(CHUNK_SERIES_ID).ts`

// VIDEO_QUALITY will be set to 'chunked' if you are viewing at source resolution

// some examples:
https://vod-metro.twitch.tv/dccc6d6c0e33975cda7a_dota2ti_30075157776_948322792/chunked/1086.ts
https://vod-metro.twitch.tv/dccc6d6c0e33975cda7a_dota2ti_30075157776_948322792/720p60/1087.ts
https://vod-metro.twitch.tv/dccc6d6c0e33975cda7a_dota2ti_30075157776_948322792/720p30/1087.ts
https://vod-metro.twitch.tv/dccc6d6c0e33975cda7a_dota2ti_30075157776_948322792/160p30/1083.ts

Unlike streams, vods have popover images in the video scrubber, for some reason reruns don’t have these. Twitch fetches both a high quality and low quality versions of these images. When you scrubb further into the video, Twitch will fetch the higher indexed storyboards.

https://vod-storyboards.twitch.tv/$(CHANNEL_ID)_$(VIDEO_ID)/storyboards/$(STORYBOARD_ID)-$(STORYBOARD_QUALITY)-$(STORYBOARD_INDEX).jpg
// example
https://vod-storyboards.twitch.tv/615d2b9305587c72317d_naysayer88_29693719888_924527656/storyboards/290847324-low-0.jpg

Twitch will periodically send a POST request to

https://video-edge-3194a4.sjc02.hls.ttvnw.net/v1/segment/$(SEGMENT_ID).ts

Which I assume is to sync up the timing of chunks.

If you are logged in, Twitch also keeps track of what you are watching by periodically sending a PUT request to https://api.twitch.tv/v5/resumewatching/user/$(USER_ID) with the following data:

{
  position: 123127,
  type: 'vod',
  video_id: 'v594876595',
}

The responses from have the s3 and cloudfront related headers

Server: AmazonS3
Via: 1.1 fd4983be77ace22659323918c5b30f1f.cloudfront.net (CloudFront)
X-Cache: Hit from cloudfront

Something to note is that the URL for fetching the index-live.m3u8 is the same for a given channel. The only difference between each response are the Date:, Expires:, and Tenfoot-Context: headers. Not sure what the Tenfoot-Context: header is for, it contains a varying hex number e.g., 0x275af65e09234ee8.

Clips

Clips are segments of a stream that are saved. Similar to videos, but limited to <1.5 mins in length (this seems to vary slightly).

To create a clip, a user must be signed in and they hit the create clip button in the video player (video player must be an unpaused stream).

create clip button

On click, Twitch sends two requests:

1. GET request to https://clips.twitch.tv/api/v2/clips

with headers:

Access-Control-Request-Headers: authorization
Origin: https://www.twitch.tv
Pragma: no-cache
Accept: */*
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.1.2 Safari/605.1.15
Referer: https://www.twitch.tv/dota2ti
Access-Control-Request-Method: POST

getting a response of:

Content-Type: application/json; charset=utf-8
Date: Tue, 28 Aug 2018 01:50:41 GMT
Connection: keep-alive
Access-Control-Allow-Methods: DELETE
Access-Control-Allow-Origin: *
Content-Length: 0
Accept-Ranges: bytes, bytes
Vary: Accept-Encoding, X-ENV, X-PLAYER, X-TWILIGHT
Access-Control-Allow-Headers: X-Requested-With,Content-Type,Authorization,Distinct-Id
X-Timer: S1535421041.406038,VS0,VE81
X-Served-By: cache-sea1043-SEA, cache-iad2128-IAD
X-Cache: MISS, MISS
X-Cache-Hits: 0, 0

2. POST request to https://clips.twitch.tv/api/v2/clips

with a payload of

MIME Type: application/x-www-form-urlencoded
broadcast_id: 41110642525
channel: dota2ti
offset: 1970
play_session_id: ydTOJak9Cnk8vKqh1Cu0zl43szJrrxWo
player_backend_type: mediaplayer
vod_id

with headers

Content-Type: application/x-www-form-urlencoded
Origin: https://www.twitch.tv
Host: clips.twitch.tv
Pragma: no-cache
Accept: */*
Connection: keep-alive
Accept-Encoding: gzip, deflate
Accept-Language: en-us
DNT: 1
Authorization: OAuth w8oa8myl28ttbx9r6hms9ayk8n
Content-Length: 141
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.1.2 Safari/605.1.15
Referer: https://www.twitch.tv/dota2ti
Cache-Control: no-cache

receiving a response with headers

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Tue, 28 Aug 2018 01:50:41 GMT
Cache-Control: no-cache, max-age=0, s-maxage=0, no-store
Access-Control-Allow-Origin: *
Connection: keep-alive
Content-Length: 1939
Accept-Ranges: bytes, bytes
Vary: X-ENV, X-PLAYER, X-TWILIGHT
X-Timer: S1535421042.588825,VS0,VE305
X-Served-By: cache-sea1025-SEA, cache-iad2128-IAD
X-Cache: MISS, MISS
X-Cache-Hits: 0, 0

and data

{
  "broadcaster_channel_url": "https://www.twitch.tv/dota2ti",
  "broadcaster_display_name": "dota2ti",
  "broadcaster_id": "41463454",
  "broadcaster_login": "dota2ti",
  "broadcaster_logo": "https://static-cdn.jtvnw.net/jtv_user_pictures/a3ab938b-5c7f-470f-bfa3-052aa648ab2f-profile_image-300x300.png",
  "broadcast_id": "30110642464",
  "curator_channel_url": "https://www.twitch.tv/example_user",
  "curator_display_name": "example user",
  "curator_id": "200525315",
  "curator_login": "example-user",
  "curator_logo": "https://static-cdn.jtvnw.net/user-default-pictures/b83b1794-7df9-4878-916c-88c2ad2e4f9f-profile_image-150x150.jpg",
  "preview_image": "https://clips-media-assets2.twitch.tv/$(BROADCAST_ID)-offset-2948-preview.jpg",
  "thumbnails": {
    "medium": "https://clips-media-assets2.twitch.tv/$(BROADCAST_ID)-offset-2948-preview-480x272.jpg",
    "small": "https://clips-media-assets2.twitch.tv/$(BROADCAST_ID)-offset-2948-preview-260x147.jpg",
    "tiny": "https://clips-media-assets2.twitch.tv/$(BROADCAST_ID)-offset-2948-preview-86x45.jpg"
  },
  "game": "Dota 2",
  "communities": [],
  "created_at": "2018-08-28T02:06:54Z",
  "title": "[EN] The International 2018 Main Event",
  "language": "en",
  "url": "https://clips.twitch.tv/$(CLIP_SLUG_NAME)",
  "info_url": "https://clips.twitch.tv/api/v2/clips/$(CLIP_SLUG_NAME)",
  "status_url": "https://clips.twitch.tv/api/v2/clips/$(CLIP_SLUG_NAME)/status",
  "edit_url": "https://clips.twitch.tv/$(CLIP_SLUG_NAME)/edit",
  "embed_url": "https://clips.twitch.tv/embed?clip=$(CLIP_SLUG_NAME)",
  "embed_html": "\u003ciframe src='https://clips.twitch.tv/embed?clip=$(CLIP_SLUG_NAME)' width='640' height='360' frameborder='0' scrolling='no' allowfullscreen='true'\u003e\u003c/iframe\u003e",
  "view_url": "https://clips.twitch.tv/api/v2/clips/$(CLIP_SLUG_NAME)/view",
  "id": "498203828",
  "slug": "MagnificentCalmStapleTerrier",
  "duration": 0,
  "views": 0
}

Then Twitch opens a new window for you with the URL https://clips.twitch.tv/$(CLIP_SLUG_NAME)/edit, which then redirects to https://clips.twitch.tv/create

In a surprising turn, Twitch fetches the video data for clip creation as a plain .mp4.

Above we can also see the status_url, which is used for polling to see if a clip has processed.

After selecting your length and adding an appropriate title, Twitch will send a GraphQL query to create the clip:

[{
  "operationName": "PublishClip",
  "variables": {
    "input": {
      "segments": [{
        "offsetSeconds": 48,
        "durationSeconds": 30,
        "speed": 1
      }],
      "slug": "$(CLIP_SLUG_NAME)",
      "title": "Test"
    }
  },
  "extensions": {
    "persistedQuery": {
      "version": 1,
      "sha256Hash": "f04b575262faae00ee7566fc6a510f5adef93c8d114b666cf549fa5a870021fd"
    }
  }
}]

After some processing (usually instantaneous), share options will pop up. Note, the initial diceware slug name is what is used from initial creation to sharing.

Users can manage their created clips via:

https://www.twitch.tv/$(USERNAME)/manager/clips

Which is a pretty staightforward React page that uses GraphQL to fetch the clip data.

A request made to peaked my interest.

https://r.nexac.com/e/getdata.xgi?foo=bar

I think it might be related to adblock detection. Perhaps, if the Ad network requests fail, but this request doesn’t, then Twitch knows you are running adblock software. Turns out, this network request is made on all pages – only noticed it now.

In terms of fetching video data, Clips fetch their video in the same way as a live stream, they also use GraphQL to fetch the chat messages (this is a recent addition) along with some other, less essential data.

2 Factor

Sudo

Configuring 2 Factor falls under a sudo required operation. When trying to complete a request that requires sudo, you will be redirected to:

https://passport.twitch.tv/sessions/new?client_id=settings_page&redirect_path=https%3A%2F%2Fpassport.twitch.tv%2Fdisable_2fa%2Fnew&sudo_reason=DEFAULT&username=example_user

Which sets a cookie

set-cookie: passport_requested=uwr1m5psv3bztdhz45lfxq50cwint83r; Max-Age=0

Then by filling out the form at

https://passport.twitch.tv/two_factor/new?client_id=settings_page&code=eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3Bhc3Nwb3J0LnR3aXRjaC50diIsInVpZCI6IjIwMDUyNTMxNSIsImxvZ2luIjoiZXhhbXBsZV91c2VyIiwiZXhwIjoxNTM1NTA4MzE4LCJpYXQiOjE1MzU1MDc1OTgsImF1ZCI6InNldHRpbmdzX3BhZ2UiLCJzY29wZSI6InRva2VuX2Zsb3ciLCJhdXRoeV9pZCI6MjI2NzYyNn0%3D.JD6j18EjfuGO-fK6Az7eZcjvHJSx5lQte11-N2P6OCGNJHQ6YSS3OoWrrR4CfWrZyYE_I0fHkWXBfJ7TV1LT3Q%3D%3D&embed=false&origin_uri=&redirect_path=https%3A%2F%2Fpassport.twitch.tv%2Fdisable_2fa%2Fnew&sudo_reason=

and hitting send. We have our sudo cookie along with several others set.

As expected, we send a CSRF token to the backend, but this token is label _goji_csrf created via Goji, specifically goji/crsf.

Also, you may have noticed that the code url param looks base64’d.

>>> import urllib
>>> import base64
>>> raw_data = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3Bhc3Nwb3J0LnR3aXRjaC50diIsInVpZCI6IjIwMDUyNTMxNSIsImxvZ2luIjoiZXhhbXBsZV91c2VyIiwiZXhwIjoxNTM1NTA4MzE4LCJpYXQiOjE1MzU1MDc1OTgsImF1ZCI6InNldHRpbmdzX3BhZ2UiLCJzY29wZSI6InRva2VuX2Zsb3ciLCJhdXRoeV9pZCI6MjI2NzYyNn0%3D.JD6j18EjfuGO-fK6Az7eZcjvHJSx5lQte11-N2P6OCGNJHQ6YSS3OoWrrR4CfWrZyYE_I0fHkWXBfJ7TV1LT3Q%3D%3D"
>>> data = urllib.parse.unquote(raw_data)
>>> base64.b64decode(data)
b'{"alg":"HS512","typ":"JWT"}{"iss":"https://passport.twitch.tv","uid":"200525315","login":"example_user","exp":1535508318,"iat":1535507598,"aud":"settings_page","scope":"token_flow","authy_id":2267626}'

we can make it a little more readable

{
  "alg":"HS512",
  "typ":"JWT"
}
{
  "iss": "https://passport.twitch.tv",
  "uid": "200525315",
  "login": "example_user",
  "exp": 1535508318,
  "iat": 1535507598,
  "aud": "settings_page",
  "scope": "token_flow",
  "authy_id": 2267626
}

JWT, plain and simple.

Note: https://passport.twitch.tv is a seperate, plain HTTP, HTML, non-SPA site. Based on some CSS classes, I believe passport is called Kraken, compared to the main Twitch site which is called Twilight.

Enable

  1. send phone number via https://passport.twitch.tv/register_2fa/new

  2. enter verification code at https://passport.twitch.tv/enable_2fa/new

Note: Twitch has some connection directly with Authy, so if you have Authy connected to your phone, Twitch will automatically use it.

  1. end up at the success page https://passport.twitch.tv/2fa_enabled/new

Disable

  1. https://passport.twitch.tv/disable_2fa/new

  2. success page https://passport.twitch.tv/2fa_disabled/new

Usage

When authenticating Twitch POSTs your username, password, and client_id to https://passport.twitch.tv/login. With 2 factor enabled, this results in a 400 and we get the option to submit our Authy / SMS code.

The response of the initial 400 request, after base64 decode:

{
  "error_code": 3011,
  "error_description": "missing authy token",
  "captcha_proof": '{"alg":"HS512","typ":"JWT"}{"iss":"https://passport.twitch.tv","iat":1535593181,"aud":["captcha_proof"],"sub":"example_user","exp":1535593481}',
  "sms_proof": '{"alg":"HS512","typ":"JWT"}{"iss":"https://passport.twitch.tv","uid":"200525315","login":"example_user","exp":1535593901,"iat":1535593181,"aud":"kimne78kx3ncx6brgo4mv6wki5h1ko","scope":"token_flow","authy_id":2568189}'
}

Sidenote: Twitch’s JWTs expire after 5 minutes.

On submitting our 2-factor code, Twitch POSTS the authy_token, username, password, client_id, and captcha.

On success, we get sudo, login, name, api_token, and a few other auth related cookies set.

After authenticating, Twitch makes a CoreAuthCurrentUser GraphQL request, which doesn’t get a response or sent any headers – not sure of its purpose.

If you forget your 2-factor you have to contact Twitch support since there aren’t any recovery codes like other services.

OAuth Connections

Twitch has a the ability to link your Steam, Blizzard, and a few other accounts to your Twitch account.

If we go to the connections page and hit one of the connect buttons, a new window will open for the other service’s login, allowing us to link our account on the other service.

So if we want to link Steam, we hit the Steam connect button and

https://steamcommunity.com/openid/login?openid.claimed_id=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&openid.identity=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&openid.mode=checkid_setup&openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&openid.realm=https%3A%2F%2Fapi.twitch.tv&openid.return_to=https%3A%2F%2Fapi.twitch.tv%2Fv5%2Fsteam%2F200525315%2Fcallback%3Fclient_id%3Djzkbprff40iqj646a697cyrvl0zt2m6

opens in a new window.

If we decode the url and break it onto new lines it becomes more clear what is going on:

https://steamcommunity.com/openid/login?
openid.claimed_id=http://specs.openid.net/auth/2.0/identifier_select&
openid.identity=http://specs.openid.net/auth/2.0/identifier_select&
openid.mode=checkid_setup&
openid.ns=http://specs.openid.net/auth/2.0&
openid.realm=https://api.twitch.tv&
openid.return_to=https://api.twitch.tv/v5/steam/$(USER_ID)/callback?client_id=jzkbprff40iqj646a697cyrvl0zt2m6

Generally a connection works like follows:

  1. open new window with OAuth login screen and an event listener for a message on the current window.
  2. login in the popup window
  3. OAuth provider redirects to the return_to url, which will send a request to Twitch backend and the window will also send a message to Twitch saying connection sucessfull.

After connecting, Twitch sends a GraphQL request fetching the connection info.

To delete the connection, Twitch sends a DELETE request to:

https://api.twitch.tv/v5/blizzard/$(USER_ID)

And then sends another GraphQL request to refetch the connection data.

Commerce

On Twitch users can purchase Bits, currency that can be given to Streamers, subscriptions, monthly payments to streamers, as well as Twitch Prime, which comes with a Amazon Prime subscription.

Bits are purchased by clicking the bits tab.

twitch bits dropdown

If we decide that we can’t settle for anything less than 25,0000 bits, then a new window will open at

https://www.twitch.tv/bits-checkout/select?asin=B01G4BISOS&br_id=&locale=en-US

where the asin corresponds to the quantity of bits you are buying.

There is a similar message event listener setup to trigger a GraphQL to update bits related info after a purchase, or an attempt at a purchase is made.

Note: this buying window is also a React app.

twitch purchase bits page

The services use a similar OAuth set up as discusses previously in the connections section.

Purchasing subscriptions is more integrated into the site, and has a lot more options, but all the purchase methods work in a similar way to previously discussed.

purchasing a subscription to Bob Ross

Settings Page

The settings page is pretty plain. In a departure, settings primarily uses a REST api and makes a couple calls via GraphQL.

Its consists of pretty basic CRUD operations.

Video Producer

Although video content on Twitch is primarily created through streaming, users can also upload videos directly through https://www.twitch.tv/manager.

A frontend thing to note is that Twitch creates their file dropzone by using CSS to stretch an input element as well as using the HTML Drag and Drop API.

file uploader

The upload process works likes follows:

  1. select file to upload
  2. Twitch sends api request to https://api.twitch.tv/kraken/videos with the following data, initialting a multipart upload to S3

    {
      "channel_id": $(USER_ID),
      "title": "A pretty cool video",
      "viewable": "private",
      "create_premiere": true
    }
    

    getting in response:

    {
      "upload": {
        "token": "kb2OTgG8OIYZ_K6KLWIvC8R.FmHakt.UFmA2hRNoCPV3E.ALo.60yoBEBFedRjgyT0G9DzsCi.WIZt9JLOjk1KqglapSdWYJG8ozDgyWnbp_QNtevoEc0DtuB1WFp27_BTBSRebCWiIpWr40F4DdVqEhBLoA2oKCKwXDuZLiE3A-",
        "url": "https://us-west-2.uploads-regional.twitch.tv/upload/$(VIDEO_ID)"
      },
      "video": {
        "title": "A pretty cool video",
        -- snip --
        "status": "created",
        "url": "https://www.twitch.tv/videos/$(VIDEO_ID)",
        "viewable": "private",
        -- snip ---
        "premiere": {
          "id": "",
          "status": "unscheduled",
          "event": null
        },
      }
    }
    
    
  3. Twitch sends chunk(s) to s3 via a PUT request using the upload_token and video_id created via part 2.

    https://us-west-2.uploads-regional.twitch.tv/upload/$(VIDEO_ID)?part=1&upload_token=$(UPLOAD_TOKEN)
    

    In my case, the 1.5MB file was small enough to fit in one chunk.

    Another note is that while I am closest to us-east-1, Twitch chose us-west.

  4. Complete multipart upload

    Twitch completes the multipart upload with a POST request to:

    https://us-west-2.uploads-regional.twitch.tv/upload/$(VIDEO_ID)/complete?upload_token=$(UPLOAD_TOKEN)
    
  5. Poll for Updates

    While Twitch doesn’t provide a progress bar, they do poll for updates to see if the transcoding is complete. Their endpoint to fetch video data is used to poll video transcode status. When polling, Twitch checks if the status property in the response is set to 'transcoding'. Eventually, the status changes to 'recorded', the polling stops, and Twitch updates the page with its fetched info.

    https://api.twitch.tv/v5/videos/$(VIDEO_ID)
    

In total, Twitch took about 2 minutes to upload and transcode a 1.5 MB, H.264 encoded video.

After uploading, the video is private until you ‘premiere’ it, which is event that you can schedule with a cover image and everything.

The cover image is base64 encoded and uploaded in a request body.

schedule video event

WASM Service Worker

There as an additional service worker to the one we already discussed. This one involves both WASM and js.

https://cvp.twitch.tv/2.6.3/wasmworker.min.js https://cvp.twitch.tv/2.6.3/wasmworker.min.wasm

The .wasm is ~233kB, and the .js is ~23kB.

By looking at the js, we can see that this service worker was compiled down via Empscripten, and that the main job of the javascript is to fetch and load the wasm worker.

The job of the webworker isn’t entirely clear. There are some references to analytics, the video player settings, media codecs and more.

At 233kB, you would expect the worker to be quite capable, so I am guessing it’s a multitasker.

Dashboard

The dashboard is used when streaming video content.

To fetch content the dashboard uses primarly GraphQL with some additional REST endpoints thrown in.

There is an ability to rearrange the cards, although the configuration is only stored locally.

The dashboard has its own video player of the stream, along with a chat, which use the same setup as previously discussed. The rest for the most part is a CRUD.

twitch dashboard

TL;DR

React.js based SPA frontend, using GraphQL for data transfer, websockets for its pub-sub and IRC based chat. Agolia for search. HLS for video streaming – with a sizable pinch of user tracking throughout.