static_assets.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. """Serving the frontend build's /assets directory (#3175).
  2. Plain ``StaticFiles`` sends every file uncompressed and with no
  3. Cache-Control, so a phone downloads the whole bundle in full and the
  4. browser asks again on every load. This subclass adds two things:
  5. - gzip for text assets when the browser accepts it. Each file is
  6. compressed once and kept in memory, keyed by path, size and mtime: Vite
  7. never changes a hashed file in place, and a rebuild that does replace one
  8. changes the key. Compressing per request rather than shipping ``.gz``
  9. copies keeps ``static/`` (tracked in git) at its current size.
  10. - ``immutable`` caching for the files Vite names by content hash. The
  11. pdf.js runtime data under ``assets/pdfjs/`` keeps its upstream names, so
  12. it is revalidated instead.
  13. Only /assets uses this. An app-wide compression middleware would also
  14. reach the camera streams and the WebSocket.
  15. """
  16. from __future__ import annotations
  17. import gzip
  18. import os
  19. import re
  20. from collections import OrderedDict
  21. import anyio
  22. from starlette.datastructures import Headers
  23. from starlette.responses import FileResponse, Response
  24. from starlette.staticfiles import NotModifiedResponse, StaticFiles
  25. from starlette.types import Scope
  26. # Vite's output name: <name>-<8-char hash>.<ext>, directly in assets/.
  27. _HASHED_ASSET_RE = re.compile(r"^[^/\\]+-[A-Za-z0-9_-]{8}\.[A-Za-z0-9]+$")
  28. IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable"
  29. REVALIDATE_CACHE_CONTROL = "no-cache"
  30. _COMPRESSIBLE_SUFFIXES = (".js", ".mjs", ".css", ".json", ".svg", ".map", ".wasm", ".txt", ".html")
  31. # Below this the gzip header costs more than it saves.
  32. _MIN_COMPRESS_SIZE = 1024
  33. # The whole build gzips to a few MB; the cap only matters if a pile of
  34. # rebuilds runs against one long-lived process.
  35. _CACHE_MAX_BYTES = 64 * 1024 * 1024
  36. def accepts_gzip(accept_encoding: str | None) -> bool:
  37. """Whether an Accept-Encoding header allows gzip (q=0 refuses it)."""
  38. if not accept_encoding:
  39. return False
  40. qualities: dict[str, float] = {}
  41. for part in accept_encoding.split(","):
  42. coding, *params = part.strip().split(";")
  43. q = 1.0
  44. for param in params:
  45. name, _, value = param.strip().partition("=")
  46. if name.strip().lower() == "q":
  47. try:
  48. q = float(value)
  49. except ValueError:
  50. q = 0.0
  51. qualities[coding.strip().lower()] = q
  52. if "gzip" in qualities:
  53. return qualities["gzip"] > 0
  54. return qualities.get("*", 0.0) > 0
  55. class _GzipCache:
  56. """Compressed bodies, least recently used dropped first."""
  57. def __init__(self, max_bytes: int) -> None:
  58. self.max_bytes = max_bytes
  59. self._entries: OrderedDict[tuple[str, int, int], bytes] = OrderedDict()
  60. self._size = 0
  61. async def get(self, path: str, stat_result: os.stat_result) -> bytes:
  62. key = (path, stat_result.st_size, stat_result.st_mtime_ns)
  63. body = self._entries.get(key)
  64. if body is not None:
  65. self._entries.move_to_end(key)
  66. return body
  67. body = await anyio.to_thread.run_sync(_gzip_file, path)
  68. # Two first requests for one file can both get here; the second
  69. # simply replaces the first's entry.
  70. if key in self._entries:
  71. self._size -= len(self._entries.pop(key))
  72. if len(body) <= self.max_bytes:
  73. self._entries[key] = body
  74. self._size += len(body)
  75. while self._size > self.max_bytes:
  76. _, dropped = self._entries.popitem(last=False)
  77. self._size -= len(dropped)
  78. return body
  79. def clear(self) -> None:
  80. self._entries.clear()
  81. self._size = 0
  82. def _gzip_file(path: str) -> bytes:
  83. with open(path, "rb") as f:
  84. # mtime=0 keeps the output identical for identical input.
  85. return gzip.compress(f.read(), compresslevel=6, mtime=0)
  86. _gzip_cache = _GzipCache(_CACHE_MAX_BYTES)
  87. def _gzip_etag(etag: str) -> str:
  88. """The gzip variant's ETag. It must differ from the identity one, or a
  89. cache could answer a gzip request with the uncompressed body."""
  90. return etag[:-1] + '-gzip"' if etag.endswith('"') else etag + "-gzip"
  91. class AssetStaticFiles(StaticFiles):
  92. async def get_response(self, path: str, scope: Scope) -> Response:
  93. response = await super().get_response(path, scope)
  94. if response.status_code not in (200, 304):
  95. return response
  96. response.headers["Cache-Control"] = (
  97. IMMUTABLE_CACHE_CONTROL if _HASHED_ASSET_RE.match(path) else REVALIDATE_CACHE_CONTROL
  98. )
  99. if not path.lower().endswith(_COMPRESSIBLE_SUFFIXES):
  100. return response
  101. response.headers["Vary"] = "Accept-Encoding"
  102. if (
  103. not isinstance(response, FileResponse)
  104. or response.status_code != 200
  105. or scope["method"] != "GET"
  106. or response.stat_result is None
  107. or response.stat_result.st_size < _MIN_COMPRESS_SIZE
  108. ):
  109. return response
  110. request_headers = Headers(scope=scope)
  111. # Ranges are byte offsets into the identity body; leave them to FileResponse.
  112. if "range" in request_headers or not accepts_gzip(request_headers.get("accept-encoding")):
  113. return response
  114. headers = dict(response.headers)
  115. headers.pop("content-length", None)
  116. headers.pop("accept-ranges", None)
  117. headers["content-encoding"] = "gzip"
  118. etag = headers.get("etag")
  119. if etag:
  120. headers["etag"] = _gzip_etag(etag)
  121. if_none_match = request_headers.get("if-none-match")
  122. if if_none_match and headers["etag"] in [t.strip().removeprefix("W/") for t in if_none_match.split(",")]:
  123. return NotModifiedResponse(Headers(headers))
  124. body = await _gzip_cache.get(str(response.path), response.stat_result)
  125. return Response(content=body, status_code=200, headers=headers)