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:
45
hermes_cli/web_asset_mime_types.py
Normal file
45
hermes_cli/web_asset_mime_types.py
Normal 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)
|
||||
@@ -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")
|
||||
|
||||
|
||||
155
tests/hermes_cli/test_web_server_asset_mime.py
Normal file
155
tests/hermes_cli/test_web_server_asset_mime.py
Normal 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
|
||||
Reference in New Issue
Block a user