april 24, 2026 // 6 min read

at some point you install the discord desktop client, open devtools, realise its just electron wrapping a webapp, and think:
what if it talked to my server instead
that was a mistake. but also openDS now exists and (kinda) works on localhost. this is a dump of the funniest / most painful bits.
step 0: the app is just a zip
app.asar is an archive. npx asar extract, and suddenly you have
bootstrap.js, moduleUpdater.js, mainScreenPreload.js etc just sitting
there in plaintext. no obfuscation, no nothing. discord really is "just chromium
with a login form".
the only real constraint: the client hardcodes https://discord.com/api,
wss://gateway.discord.gg, and a bunch of cdn hosts. so step 1 is just:
rewrite urls.
- REST is easy -
DISCORD_API_ENDPOINTenv var already exists in the client, they literally left the override in - gateway is not -
wss://gateway.discord.ggis baked into the bundle - so we patch
window.WebSocketin preload and rewrite the url on construction
const OG = window.WebSocket;
window.WebSocket = function(url, protocols) {
if (typeof url === string' && url.startsWith('wss://gateway.discord.gg')) {
url = url.replace('wss://gateway.discord.gg', 'wss://127.0.0.1:8443');
console.log('[openDS] WS rewrite:', url);
}
return new OG(url, protocols);
};
cute. works. [GatewaySocket] [CONNECTED] in 4ms.
etf... what the fuck
discords gateway by default negotiates encoding=etf, compress=zstd-stream. etf
is erlang external term format. because of course the server is in elixir.
so to say "hello" to my own server, i need to:
- accept the websocket
- read a zstd-compressed stream (not frames - one long stream, reused across the whole connection)
- parse erlang binary term format inside it
- encode responses the same way
the format is honestly fine once you read the spec. small integers, big integers, atoms, binaries, maps, lists... its basically pickle for erlang. but writing a parser for one client that exists in one language (elixir), for a protocol you will never talk to anyone else on - thats funny.
the worst part:
compress=zstd-streamuses a single zstd decoder for the whole connection lifetime. you cant decode one message at a time, you have to feed bytes into a stateful decoder and fish complete etf terms out the other side- every write has to flush the zstd stream
- if you desync the compressor by even one byte, every subsequent message is garbage
the spec? good luck. the client source is mangled but readable. the server? elixir, closed. so you reverse by watching what the official client sends, wireshark-ing your own connection, and praying.
why elixir. i get it, erlang is great at a million concurrent connections,
thats literally what it was built for. but from the outside looking in, "lets
use etf over zstd-stream because it avoids a json parse" is a choice that costs
every third-party implementer weeks of work. theres a
encoding=json, compress=none mode that works fine. nobody uses it.
snowflakes are 64-bit and javascript cant math
discord ids look like 1496949411534606336. thats a
snowflake:
timestamp_ms_since_2015|worker|process|seq
42 bits | 5 | 5 | 12
64 bits total. Number.MAX_SAFE_INTEGER is 2^53. so if you JSON.parse a
discord id as a number you silently lose the bottom 11 bits and two different
users now have the same id. great.
discords client parses these as BigInt / uses big.js. so on the server side
you have to:
- emit ids as strings in json (otherwise
JSON.parsemangles them) - generate them as actual 64-bit values with the timestamp split correctly (the
discord epoch is
2015-01-01T00:00:00Z, not unix epoch) - make the "worker" and "process" bits stable so you dont collide during a restart
a 40-line snowflake.Next() later and the client stops yelling about invalid
ids.
READY is a nightmare
after IDENTIFY, the gateway is supposed to send a READY event with the users
entire state: self, guilds, channels, relationships, presences,
private_channels, user_settings, user_settings_proto, consents, read_state,
experiments, notes, connected_accounts, tutorial, geo_ordered_rtc_regions, and
like 30 other fields.
every single one is consumed by a different flux store on boot. and every store
assumes its field exists and is an array/object, not null/undefined. ship an
empty payload and the first dispatch throws
Cannot read properties of undefined inside a reducer and the whole app goes
white-screen with no useful error.
so the work is: dispatch a bare-bones READY, see what throws, grep the client
bundle (web.js - readable minified js) for the store name in the stack trace,
figure out the expected shape, add a stub, repeat. some of my favourite stubs:
user_guild_settingsneeds a per-guild entry even if no settings were ever setmerged_membersis a nested[[member], [member]]array per guild and flux indexes into it by positionexperimentsis a list of[hash, revision, population, bucket, override, ...]tuples (yes, positional, not a map)
the debugging loop is: rg 'MESSAGE_SEND_FAILED' web.js | less, find the
reducer, read what field it reads off action.payload, go add it to the server.
repeat for every single flux store the client has.
the iteration from hell
at one point i had this loop spinning in the client logs:
TypeError: e is not iterable
at t.default.handleContentInventoryFetchSuccess
every ~100ms. turns out ContentInventoryActivityStore does
for (r of response.entries) on /api/v9/content-inventory/users/@me. my
server was returning [], not { entries: [] }. so the iteration threw, and -
heres the kicker - the refresh scheduler runs in the same tick and also
reads response.expired_at. with no wait_ms_until_next_fetch, expired_at is
undefined, the setTimeout delay becomes 0, and the client tight-loops.
fix:
return fiber.Map{
"entries": []any{},
"wait_ms_until_next_fetch": int64(time.Hour / time.Millisecond),
"request_id": snowflake.Next(),
}
one field. hour of reading web.js to find the one field.
dm channels, finally
the final boss was message send. POST /channels/:id/messages was 500ing
because the channel didn't exist in the db - it was minted in-memory when you
clicked a friend in the sidebar, CHANNEL_CREATE was pushed, but nothing was
written. then INSERT INTO messages tripped the FK on
messages.channel_id REFERENCES channels(id) and the client got a 500 instead
of a "sent".
fix: persist DMs.
CREATE TABLE channel_recipients (
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (channel_id, user_id)
);
plus FindDM(a, b) so re-opening a chat with the same friend gives you back the
same channel (discord does this; if you dont, every click spawns a fresh "dm"
with a new id and the sidebar grows forever).
what actually runs
local stack right now:
- a patched
app.asarwith rewrites in the preload (wheelchair) - etf-speaking!!! gateway
- hostname override so
discord.comresolves to127.0.0.1 - self-signed cert +
session.setCertificateVerifyProcto trust it
what works:
- login / register
- guild auto-created with a
#generalon first login - friend requests with
RELATIONSHIP_ADDpushes - dms, msgs, typing
- user settings / read state / consents all stubbed enough to not crash
what doesnt:
- voice / video (lol)
- attachments
- push notifications
- literally everything interesting
conclusions???
- the discord client is surprisingly approachable. its just js.
- the discord protocol is not approachable at all. etf-over-zstd is a
hostile choice for anyone outside discord, and the
READYpayload shape is basically a thousand undocumented invariants that only exist as flux store expectations. - writing your own backend for someone elses client is a neat way to learn exactly how that client is put together, because every single field you forget to return crashes something visible.
- elixir was still a weird choice i dont care i said what i said
:3
