fix(dashboard): serve SPA assets with explicit MIME types

On Windows, mimetypes.init() reads the registry, where .js/.mjs can be
mapped to text/plain; starlette StaticFiles/FileResponse then serve the
dashboard bundle as text/plain, browsers reject the ESM module script
under strict MIME checking and the SPA stays blank (#28987).

Pin the served extensions (.js/.mjs/.cjs/.css/.json/.map/.svg/.wasm/
.webp/.webmanifest/.woff/.woff2/.ico) with explicit mimetypes.add_type
calls at web_server_dashboard import, so Content-Type is
platform-independent regardless of the host map. Idempotent.

Reuses the approach of closed PRs #28996 (xxxigm) and #29254
(Jiahui-Gu).
This commit is contained in:
Austin Pickett
2026-10-06 09:59:11 -04:00
parent 727dbb17f2
commit db26e55742
3 changed files with 209 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
"""MIME types for served web assets, normalised in the serving layer.
Starlette's ``StaticFiles`` and ``FileResponse`` derive ``Content-Type`` from
``mimetypes.guess_type``. On Windows, ``mimetypes.init()`` reads the registry,
where installers (or user-scoped ``HKCU\\Software\\Classes`` entries) can map
``.js``/``.mjs`` to ``text/plain``. Browsers then refuse the dashboard's ESM
bundle under strict module MIME checking and the SPA stays blank (#28987).
Fix: pin the types we serve with explicit ``mimetypes.add_type`` calls so the
served values are platform-independent, regardless of the host map.
"""
from __future__ import annotations
import mimetypes
# RFC 9239: ``text/javascript`` is the IANA-blessed JavaScript type and the
# WHATWG HTML spec's canonical essence for module scripts.
WEB_ASSET_MIME_TYPES: dict[str, str] = {
".js": "text/javascript",
".mjs": "text/javascript",
".cjs": "text/javascript",
".css": "text/css",
".json": "application/json",
".map": "application/json",
".svg": "image/svg+xml",
".wasm": "application/wasm",
".webp": "image/webp",
".webmanifest": "application/manifest+json",
".woff": "font/woff",
".woff2": "font/woff2",
".ico": "image/x-icon",
}
def normalize_web_asset_mime_types() -> None:
"""Pin web-standard ``Content-Type`` values for dashboard assets.
Idempotent; overrides any host-provided (e.g. Windows registry) mapping.
Called at ``web_server_dashboard`` import so every ``StaticFiles`` mount
and ``FileResponse`` built afterwards sees the corrected map (#28987).
"""
mimetypes.init()
for ext, mime in WEB_ASSET_MIME_TYPES.items():
mimetypes.add_type(mime, ext, strict=True)

View File

@@ -15,9 +15,18 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from pathlib import Path
from typing import Any, Dict, List, Optional
from hermes_cli.config import cfg_get, get_process_hermes_home
from utils import env_var_enabled
# Serve assets with explicit MIME types: the host's map (on Windows the
# registry can map .js -> text/plain) must not decide the Content-Type of
# dashboard bundles (#28987). Runs before any StaticFiles/FileResponse below
# serves a request, and is idempotent.
from hermes_cli.web_asset_mime_types import normalize_web_asset_mime_types
normalize_web_asset_mime_types()
# Same logger the code used before extraction (record parity).
_log = logging.getLogger("hermes_cli.web_server")

View File

@@ -0,0 +1,155 @@
"""Dashboard asset MIME types are platform-independent (#28987).
On Windows, ``mimetypes.init()`` reads the registry, where ``.js`` can be
mapped to ``text/plain`` (user-scoped HKCU entries suffice). Starlette's
``StaticFiles``/``FileResponse`` then serve the dashboard bundle as
``text/plain``, browsers reject the ESM module script under strict MIME
checking, and the SPA stays blank.
These tests poison the process-wide ``mimetypes`` map the same way the
Windows registry does, then assert the serving layer pins a JavaScript
``Content-Type`` for ``.js`` assets regardless of the host map.
"""
import mimetypes
import pytest
JAVASCRIPT_MIME_ESSENCES = ("text/javascript", "application/javascript")
def _essence(content_type: str) -> str:
return content_type.split(";", 1)[0].strip().lower()
def _is_javascript(content_type: "str | None") -> bool:
if not content_type:
return False
return _essence(content_type) in JAVASCRIPT_MIME_ESSENCES
@pytest.fixture
def poisoned_mimetypes():
"""Simulate the Windows registry poisoning described in #28987.
Writes through ``mimetypes.add_type`` so the poison lands in the same
table ``guess_type`` consults (on Python >= 3.13 that is the lazily
created ``_db`` instance, not the module-level ``types_map`` copy).
Restores the exact prior entries on teardown so other tests are unaffected.
"""
mimetypes.init()
poisoned = {".js": "text/plain", ".mjs": "text/plain", ".css": "text/plain"}
saved = {ext: mimetypes.types_map.get(ext) for ext in poisoned}
for ext, mime in poisoned.items():
mimetypes.add_type(mime, ext, strict=True)
# Sanity inside the fixture: the poison really is in effect before the fix runs.
assert mimetypes.guess_type("a.js")[0] == "text/plain"
try:
yield
finally:
for ext, mime in saved.items():
if mime is None:
mimetypes.types_map.pop(ext, None)
else:
mimetypes.add_type(mime, ext, strict=True)
class TestServedJsAssetContentType:
"""End-to-end: a .js asset served through the SPA mount keeps a JS Content-Type."""
@pytest.fixture
def spa_client(self, tmp_path, monkeypatch, poisoned_mimetypes):
from fastapi import FastAPI
from starlette.testclient import TestClient
import hermes_cli.web_server as ws
import hermes_cli.web_server_dashboard as wsd # imports pin the MIME map
dist = tmp_path / "web_dist"
(dist / "assets").mkdir(parents=True)
(dist / "index.html").write_text(
"<html><head><title>t</title></head><body>SPA</body></html>",
encoding="utf-8",
)
(dist / "assets" / "index-CqUa8pQa.js").write_text(
"export const boot = () => {};", encoding="utf-8"
)
(dist / "assets" / "worker-Bd0k2mZx.mjs").write_text(
"export const worker = true;", encoding="utf-8"
)
# The registry poison (fixture) predates the dashboard process; mirror
# that by re-applying the startup normalization NOW, as import of
# ``web_server_dashboard`` did at process start (and keeps the test
# meaningful even when an earlier test already imported the module).
from hermes_cli.web_asset_mime_types import normalize_web_asset_mime_types
normalize_web_asset_mime_types()
monkeypatch.setattr(ws, "WEB_DIST", dist)
monkeypatch.delenv("HERMES_SERVE_HEADLESS", raising=False)
app = FastAPI()
wsd.mount_spa(app)
return TestClient(app)
def test_js_asset_served_as_javascript_despite_host_map(self, spa_client):
# The host mimetypes table was poisoned to text/plain (Windows registry
# case) before the serving layer applied its explicit mappings.
resp = spa_client.get("/assets/index-CqUa8pQa.js")
assert resp.status_code == 200
assert _is_javascript(resp.headers["content-type"]), (
f"expected a JavaScript Content-Type, got {resp.headers['content-type']!r}"
)
def test_mjs_asset_served_as_javascript_despite_host_map(self, spa_client):
resp = spa_client.get("/assets/worker-Bd0k2mZx.mjs")
assert resp.status_code == 200
assert _is_javascript(resp.headers["content-type"])
class TestNormalizeWebAssetMimeTypes:
"""Unit: the normalization overrides the poisoned host map."""
def test_reapplies_over_poisoned_map(self, poisoned_mimetypes):
from hermes_cli.web_asset_mime_types import normalize_web_asset_mime_types
assert mimetypes.guess_type("a.js")[0] == "text/plain"
normalize_web_asset_mime_types()
assert _is_javascript(mimetypes.guess_type("a.js")[0])
assert _is_javascript(mimetypes.guess_type("a.mjs")[0])
def test_idempotent(self):
from hermes_cli.web_asset_mime_types import normalize_web_asset_mime_types
normalize_web_asset_mime_types()
normalize_web_asset_mime_types()
assert _is_javascript(mimetypes.guess_type("a.js")[0])
@pytest.mark.parametrize(
("filename", "expected"),
[
("bundle.js", JAVASCRIPT_MIME_ESSENCES),
("worker.mjs", JAVASCRIPT_MIME_ESSENCES),
("legacy.cjs", JAVASCRIPT_MIME_ESSENCES),
("styles.css", ("text/css",)),
("logo.svg", ("image/svg+xml",)),
("font.woff2", ("font/woff2",)),
("icon.ico", ("image/x-icon",)),
("bundle.js.map", ("application/json",)),
("module.wasm", ("application/wasm",)),
("manifest.webmanifest", ("application/manifest+json",)),
],
)
def test_pinned_extensions_resolve(self, filename, expected):
from hermes_cli.web_asset_mime_types import normalize_web_asset_mime_types
normalize_web_asset_mime_types()
assert mimetypes.guess_type(filename)[0] in expected
def test_table_is_complete_for_served_assets(self):
from hermes_cli.web_asset_mime_types import WEB_ASSET_MIME_TYPES
# Every extension we pin must be present in the strict map after import
# of the dashboard module (module-level call already ran).
import hermes_cli.web_server_dashboard # noqa: F401
for ext, mime in WEB_ASSET_MIME_TYPES.items():
assert mimetypes.guess_type(f"file{ext}")[0] == mime