From 0d9c70450cdabcb7ef3b7206a58d1bbdf04b3fcf Mon Sep 17 00:00:00 2001 From: Thales <> Date: Sat, 12 Sep 2026 02:53:32 +0100 Subject: [PATCH] Serve the frontend with correct MIME types, whatever the host registry says StaticFiles asks `mimetypes` for a content type, and on Windows `mimetypes` reads HKEY_CLASSES_ROOT. Any program that ever registered `.js` as `text/plain` leaves it that way for every application on that machine, so StemDeck served its own ES modules as plain text there and nowhere else. Browsers enforce strict MIME checking on module scripts and refuse to execute one served as text/plain. Every module was blocked, so nothing wired itself up. The app rendered completely and responded to nothing: hover and text selection still worked because the engine does those itself, while buttons and file drop did nothing because no listener had ever been attached. Nothing in the response is malformed on a healthy machine, which is exactly why this survived CI and every developer box. The dependency on a registry StemDeck does not control is the bug; pinning the handful of types the app actually serves removes it. Diagnosed by Dick Siemer, who supplied the console output and the cause. The tests reproduce the broken host rather than trusting the one they run on. Two of them deliberately cannot fail on a correct registry and say so in their docstrings: they guard the healthy case. The ones that carry the weight install the text/plain mapping first, including an end-to-end request that asserts the broken response before asserting the fixed one. Verified by neutering the fix: 8 of 11 fail, including that one. Closes #617 --- app/main.py | 33 ++++++++++ tests/test_static_mime.py | 126 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 tests/test_static_mime.py diff --git a/app/main.py b/app/main.py index 7488221f..942f7447 100644 --- a/app/main.py +++ b/app/main.py @@ -5,6 +5,7 @@ import io import json import logging +import mimetypes import os import re import signal @@ -1088,6 +1089,38 @@ async def network_gate(request: Request, call_next): compresslevel=COMPRESS_LEVEL, ) + +def _pin_static_mime_types() -> None: + """Serve the frontend with correct content types regardless of the host. + + StaticFiles asks `mimetypes` for a type, and on Windows `mimetypes` reads + HKEY_CLASSES_ROOT. Any program that ever registered `.js` as `text/plain` + leaves it that way for everyone, so StemDeck would serve its own modules as + plain text on that machine and nowhere else. + + Browsers enforce strict MIME checking on ES modules and refuse to execute + one served as `text/plain`. Every module is then blocked, no listener is + ever attached, and the app renders fully but responds to nothing: hover and + typing still work because the engine does those itself (#617). + + Nothing in the response is wrong on a healthy machine, which is why this + survives CI and every developer box. Pinning the handful of types the app + actually serves costs nothing and removes the dependency on a registry + StemDeck does not control. + """ + for ext, ctype in ( + (".js", "text/javascript"), + (".mjs", "text/javascript"), + (".css", "text/css"), + (".json", "application/json"), + (".svg", "image/svg+xml"), + (".wasm", "application/wasm"), + ): + mimetypes.add_type(ctype, ext) + + +_pin_static_mime_types() + app.include_router(router, prefix="/api") app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") diff --git a/tests/test_static_mime.py b/tests/test_static_mime.py new file mode 100644 index 00000000..757a07f3 --- /dev/null +++ b/tests/test_static_mime.py @@ -0,0 +1,126 @@ +"""The frontend must be served with correct content types on any host. + +StaticFiles asks `mimetypes` for a type, and on Windows `mimetypes` reads +HKEY_CLASSES_ROOT. A machine where some other program registered `.js` as +`text/plain` made StemDeck serve its own ES modules as plain text, which +browsers refuse to execute under strict MIME checking. Every module was +blocked, so nothing wired itself up and the app responded to nothing while +still rendering and still hovering (#617). + +Nothing is wrong on a healthy machine, which is exactly why CI and every +developer box missed it. These tests reproduce the broken host instead of +trusting the one they run on. +""" + +from __future__ import annotations + +import mimetypes + +import pytest +from fastapi.testclient import TestClient + +from app.main import _pin_static_mime_types, app + +JS_TYPES = {"text/javascript", "application/javascript"} + + +@pytest.fixture +def _restore_mimetypes(): + """`mimetypes` maps are process-global, so a test that edits them must undo it.""" + saved_types = dict(mimetypes.types_map) + saved_non_standard = dict(mimetypes.common_types) + yield + mimetypes.types_map.clear() + mimetypes.types_map.update(saved_types) + mimetypes.common_types.clear() + mimetypes.common_types.update(saved_non_standard) + + +def test_js_is_javascript_after_import(): + """Importing the app is enough; no request has to happen first. + + Note this cannot fail on a host whose registry is already correct, which is + every CI runner and most developer machines. It guards the healthy case. + The tests that actually exercise the fix are the hostile-host ones below, + which reproduce the broken mapping rather than trusting the host. + """ + guessed, _ = mimetypes.guess_type("anything.js") + assert guessed in JS_TYPES, guessed + + +def test_a_host_that_calls_js_plain_text_is_overridden(_restore_mimetypes): + """The actual reported failure: a registry mapping .js to text/plain.""" + mimetypes.add_type("text/plain", ".js") + assert mimetypes.guess_type("x.js")[0] == "text/plain" + + _pin_static_mime_types() + + assert mimetypes.guess_type("x.js")[0] in JS_TYPES + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("x.js", JS_TYPES), + ("x.mjs", JS_TYPES), + ("x.css", {"text/css"}), + ("x.json", {"application/json"}), + ("x.svg", {"image/svg+xml"}), + ("x.wasm", {"application/wasm"}), + ], +) +def test_every_pinned_type_survives_a_hostile_host(name, expected, _restore_mimetypes): + """A host that calls everything plain text must not change what we serve.""" + for ext in (".js", ".mjs", ".css", ".json", ".svg", ".wasm"): + mimetypes.add_type("text/plain", ext) + + _pin_static_mime_types() + + assert mimetypes.guess_type(name)[0] in expected + + +def test_a_real_module_is_served_as_javascript(): + """End to end through StaticFiles, which is what the browser actually sees. + + A module served as text/plain is refused by strict MIME checking, so this + header is the whole difference between a working app and a dead one. + + Like the import test above, this passes on a healthy host either way. The + next test is the one that proves the fix reaches StaticFiles. + """ + with TestClient(app) as c: + resp = c.get("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/js/main.js") + + assert resp.status_code == 200 + ctype = resp.headers["content-type"].split(";")[0].strip() + assert ctype in JS_TYPES, ctype + + +def test_a_hostile_host_still_serves_javascript_end_to_end(_restore_mimetypes): + """The reported failure, reproduced and then fixed, through a real request. + + This is the test that can fail. It puts the broken mapping in place first, + so without `_pin_static_mime_types` the response really does come back as + text/plain and the assertion fails on any machine, healthy registry or not. + """ + mimetypes.add_type("text/plain", ".js") + with TestClient(app) as c: + broken = c.get("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/js/main.js") + assert broken.headers["content-type"].split(";")[0].strip() == "text/plain" + + _pin_static_mime_types() + + with TestClient(app) as c: + fixed = c.get("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/js/main.js") + + assert fixed.status_code == 200 + ctype = fixed.headers["content-type"].split(";")[0].strip() + assert ctype in JS_TYPES, ctype + + +def test_the_stylesheet_is_served_as_css(): + with TestClient(app) as c: + resp = c.get("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/css/variables.css") + + assert resp.status_code == 200 + assert resp.headers["content-type"].split(";")[0].strip() == "text/css"