Skip to content

Commit b943109

Browse files
committed
Add precompressed static file serving
Adds support for serving pre-compressed static files with .br, .gz, or .zst extensions. When enabled via --static-path-precompressed, the server will: - Check Accept-Encoding header for supported encodings (br, gzip, zstd) - Serve compressed sidecar files if they exist (e.g., file.js.br for file.js) - Respect client q-value priorities for encoding selection - Fall back to uncompressed file if no sidecar exists - Use lazy caching to avoid repeated filesystem checks for sidecars Works with the existing multi-mount and dir-to-file rewrite features.
1 parent 0de704b commit b943109

18 files changed

Lines changed: 496 additions & 96 deletions

File tree

granian/_granian.pyi

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import threading
33
from typing import Any
44

55
from ._types import WebsocketMessage
6+
from .files import StaticFilesSettings
67
from .http import HTTP1Settings, HTTP2Settings
78

89
__version__: str
@@ -71,7 +72,7 @@ class ASGIWorker:
7172
http1_opts: HTTP1Settings | None,
7273
http2_opts: HTTP2Settings | None,
7374
websockets_enabled: bool,
74-
static_files: tuple[str, str, str | None, str | None] | None,
75+
static_files: StaticFilesSettings | None,
7576
ssl_enabled: bool,
7677
ssl_cert: str | None,
7778
ssl_key: str | None,
@@ -95,7 +96,7 @@ class WSGIWorker:
9596
http_mode: str,
9697
http1_opts: HTTP1Settings | None,
9798
http2_opts: HTTP2Settings | None,
98-
static_files: tuple[str, str, str | None, str | None] | None,
99+
static_files: StaticFilesSettings | None,
99100
ssl_enabled: bool,
100101
ssl_cert: str | None,
101102
ssl_key: str | None,
@@ -120,7 +121,7 @@ class RSGIWorker:
120121
http1_opts: HTTP1Settings | None,
121122
http2_opts: HTTP2Settings | None,
122123
websockets_enabled: bool,
123-
static_files: tuple[str, str, str | None, str | None] | None,
124+
static_files: StaticFilesSettings | None,
124125
ssl_enabled: bool,
125126
ssl_cert: str | None,
126127
ssl_key: str | None,

granian/cli.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,11 @@ def option(*param_decls: str, cls: type[click.Option] | None = None, **attrs: An
367367
default=86400,
368368
help='Cache headers expiration (in seconds or a human-readable duration) for static file serving. 0 to disable.',
369369
)
370+
@option(
371+
'--static-path-precompressed/--no-static-path-precompressed',
372+
default=False,
373+
help='Enable serving precompressed static files with .br, .gz, or .zst extensions',
374+
)
370375
@option('--metrics/--no-metrics', 'metrics_enabled', default=False, help='Enable the prometheus metrics exporter.')
371376
@option(
372377
'--metrics-scrape-interval', default=15, type=Duration(1, 60), help='Configure the interval for metrics collection.'
@@ -491,6 +496,7 @@ def cli(
491496
static_path_mount: list[pathlib.Path],
492497
static_path_dir_to_file: str | None,
493498
static_path_expires: int,
499+
static_path_precompressed: bool,
494500
metrics_enabled: bool,
495501
metrics_scrape_interval: int,
496502
metrics_address: str,
@@ -581,6 +587,7 @@ def cli(
581587
static_path_mount=static_path_mount,
582588
static_path_dir_to_file=static_path_dir_to_file,
583589
static_path_expires=static_path_expires,
590+
static_path_precompressed=static_path_precompressed,
584591
metrics_enabled=metrics_enabled,
585592
metrics_scrape_interval=metrics_scrape_interval,
586593
metrics_address=metrics_address,

granian/files.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from dataclasses import dataclass
2+
3+
4+
@dataclass
5+
class StaticFilesSettings:
6+
"""Configuration for static file serving.
7+
8+
Attributes:
9+
mounts: List of (route_prefix, filesystem_path) tuples for serving static files.
10+
dir_to_file: Optional filename to serve when a directory is requested (e.g., 'index.html').
11+
expires: Cache-Control max-age value in seconds (as string), or None to disable.
12+
precompressed: Whether to serve pre-compressed sidecar files (.br, .gz, .zst).
13+
"""
14+
15+
mounts: list[tuple[str, str]]
16+
dir_to_file: str | None = None
17+
expires: str | None = None
18+
precompressed: bool = False

granian/server/common.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from .._signals import set_main_signals
2020
from ..constants import HTTPModes, Interfaces, Loops, RuntimeModes, SSLProtocols, TaskImpl
2121
from ..errors import ConfigurationError, PidFileError
22+
from ..files import StaticFilesSettings
2223
from ..http import HTTP1Settings, HTTP2Settings
2324
from ..log import DEFAULT_ACCESSLOG_FMT, LogLevels, configure_logging, logger
2425
from ..net import SocketSpec, UnixSocketSpec
@@ -129,6 +130,7 @@ def __init__(
129130
static_path_mount: Sequence[Path] | None = None,
130131
static_path_dir_to_file: str | None = None,
131132
static_path_expires: int = 86400,
133+
static_path_precompressed: bool = False,
132134
metrics_enabled: bool = False,
133135
metrics_scrape_interval: int = 15,
134136
metrics_address: str = '127.0.0.1',
@@ -186,7 +188,8 @@ def __init__(
186188
self.factory = factory
187189
self.working_dir = working_dir
188190
self.env_files = env_files or ()
189-
self.static_path = None
191+
self.static_files = None
192+
self.static_path_precompressed = static_path_precompressed
190193
self.metrics_enabled = metrics_enabled
191194
self.metrics_scrape_interval = metrics_scrape_interval
192195
self.metrics_address = metrics_address
@@ -243,19 +246,21 @@ def _init_static_mounts(
243246
if not paths:
244247
return
245248
if len(paths) == 1 and not routes:
246-
self.static_path = (
247-
[('/static', str(paths[0].resolve()))],
248-
dir_to_file,
249-
expires,
249+
self.static_files = StaticFilesSettings(
250+
mounts=[('/static', str(paths[0].resolve()))],
251+
dir_to_file=dir_to_file,
252+
expires=expires,
253+
precompressed=self.static_path_precompressed,
250254
)
251255
return
252256
if len(paths) != len(routes):
253257
logger.error('Static path routes and mounts should have the same length')
254258
raise ConfigurationError('static_path')
255-
self.static_path = (
256-
[(routes[idx], str(path.resolve())) for idx, path in enumerate(paths)],
257-
dir_to_file,
258-
expires,
259+
self.static_files = StaticFilesSettings(
260+
mounts=[(routes[idx], str(path.resolve())) for idx, path in enumerate(paths)],
261+
dir_to_file=dir_to_file,
262+
expires=expires,
263+
precompressed=self.static_path_precompressed,
259264
)
260265

261266
def build_ssl_context(

granian/server/embed.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .._types import SSLCtx
1515
from ..asgi import LifespanProtocol, _callback_wrapper as _asgi_call_wrap
1616
from ..errors import ConfigurationError, FatalError
17+
from ..files import StaticFilesSettings
1718
from ..rsgi import _callback_wrapper as _rsgi_call_wrap, _callbacks_from_target as _rsgi_cbs_from_target
1819
from .common import (
1920
_PY_312,
@@ -128,6 +129,7 @@ def __init__(
128129
static_path_mount: Sequence[Path] | None = None,
129130
static_path_dir_to_file: str | None = None,
130131
static_path_expires: int = 86400,
132+
static_path_precompressed: bool = False,
131133
):
132134
super().__init__(
133135
target=target,
@@ -164,6 +166,7 @@ def __init__(
164166
static_path_mount=static_path_mount,
165167
static_path_dir_to_file=static_path_dir_to_file,
166168
static_path_expires=static_path_expires,
169+
static_path_precompressed=static_path_precompressed,
167170
)
168171
self.main_loop_interrupt = asyncio.Event()
169172

@@ -189,7 +192,7 @@ def _spawn_worker(self, idx, target, callback_loader) -> AsyncWorker:
189192
self.http1_settings,
190193
self.http2_settings,
191194
self.websockets,
192-
self.static_path,
195+
self.static_files,
193196
self.log_access_format if self.log_access else None,
194197
self.ssl_ctx,
195198
{'url_path_prefix': self.url_path_prefix},
@@ -215,7 +218,7 @@ async def _spawn_asgi_worker(
215218
http1_settings: HTTP1Settings | None,
216219
http2_settings: HTTP2Settings | None,
217220
websockets: bool,
218-
static_path: tuple[str, str, str | None, str | None] | None,
221+
static_files: StaticFilesSettings | None,
219222
log_access_fmt: str | None,
220223
ssl_ctx: SSLCtx,
221224
scope_opts: dict[str, Any],
@@ -235,7 +238,7 @@ async def _spawn_asgi_worker(
235238
http1_settings,
236239
http2_settings,
237240
websockets,
238-
static_path,
241+
static_files,
239242
*ssl_ctx,
240243
(None, None),
241244
)
@@ -261,7 +264,7 @@ async def _spawn_asgi_lifespan_worker(
261264
http1_settings: HTTP1Settings | None,
262265
http2_settings: HTTP2Settings | None,
263266
websockets: bool,
264-
static_path: tuple[str, str, str | None, str | None] | None,
267+
static_files: StaticFilesSettings | None,
265268
log_access_fmt: str | None,
266269
ssl_ctx: SSLCtx,
267270
scope_opts: dict[str, Any],
@@ -289,7 +292,7 @@ async def _spawn_asgi_lifespan_worker(
289292
http1_settings,
290293
http2_settings,
291294
websockets,
292-
static_path,
295+
static_files,
293296
*ssl_ctx,
294297
(None, None),
295298
)
@@ -316,7 +319,7 @@ async def _spawn_rsgi_worker(
316319
http1_settings: HTTP1Settings | None,
317320
http2_settings: HTTP2Settings | None,
318321
websockets: bool,
319-
static_path: tuple[str, str, str | None, str | None] | None,
322+
static_files: StaticFilesSettings | None,
320323
log_access_fmt: str | None,
321324
ssl_ctx: SSLCtx,
322325
scope_opts: dict[str, Any],
@@ -338,7 +341,7 @@ async def _spawn_rsgi_worker(
338341
http1_settings,
339342
http2_settings,
340343
websockets,
341-
static_path,
344+
static_files,
342345
*ssl_ctx,
343346
(None, None),
344347
)

granian/server/mp.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from .._internal import load_env
2121
from .._types import SSLCtx
2222
from ..asgi import LifespanProtocol, _callback_wrapper as _asgi_call_wrap
23+
from ..files import StaticFilesSettings
2324
from ..rsgi import _callback_wrapper as _rsgi_call_wrap, _callbacks_from_target as _rsgi_cbs_from_target
2425
from ..wsgi import _callback_wrapper as _wsgi_call_wrap
2526
from .common import (
@@ -129,7 +130,7 @@ def _spawn_asgi_worker(
129130
http1_settings: HTTP1Settings | None,
130131
http2_settings: HTTP2Settings | None,
131132
websockets: bool,
132-
static_path: tuple[str, str, str | None, str | None] | None,
133+
static_files: StaticFilesSettings | None,
133134
log_access_fmt: str | None,
134135
ssl_ctx: SSLCtx,
135136
scope_opts: dict[str, Any],
@@ -153,7 +154,7 @@ def _spawn_asgi_worker(
153154
http1_settings,
154155
http2_settings,
155156
websockets,
156-
static_path,
157+
static_files,
157158
*ssl_ctx,
158159
metrics,
159160
)
@@ -180,7 +181,7 @@ def _spawn_asgi_lifespan_worker(
180181
http1_settings: HTTP1Settings | None,
181182
http2_settings: HTTP2Settings | None,
182183
websockets: bool,
183-
static_path: tuple[str, str, str | None] | None,
184+
static_files: StaticFilesSettings | None,
184185
log_access_fmt: str | None,
185186
ssl_ctx: SSLCtx,
186187
scope_opts: dict[str, Any],
@@ -212,7 +213,7 @@ def _spawn_asgi_lifespan_worker(
212213
http1_settings,
213214
http2_settings,
214215
websockets,
215-
static_path,
216+
static_files,
216217
*ssl_ctx,
217218
metrics,
218219
)
@@ -240,7 +241,7 @@ def _spawn_rsgi_worker(
240241
http1_settings: HTTP1Settings | None,
241242
http2_settings: HTTP2Settings | None,
242243
websockets: bool,
243-
static_path: tuple[str, str, str | None] | None,
244+
static_files: StaticFilesSettings | None,
244245
log_access_fmt: str | None,
245246
ssl_ctx: SSLCtx,
246247
scope_opts: dict[str, Any],
@@ -266,7 +267,7 @@ def _spawn_rsgi_worker(
266267
http1_settings,
267268
http2_settings,
268269
websockets,
269-
static_path,
270+
static_files,
270271
*ssl_ctx,
271272
metrics,
272273
)
@@ -294,7 +295,7 @@ def _spawn_wsgi_worker(
294295
http1_settings: HTTP1Settings | None,
295296
http2_settings: HTTP2Settings | None,
296297
websockets: bool,
297-
static_path: tuple[str, str, str | None] | None,
298+
static_files: StaticFilesSettings | None,
298299
log_access_fmt: str | None,
299300
ssl_ctx: SSLCtx,
300301
scope_opts: dict[str, Any],
@@ -317,7 +318,7 @@ def _spawn_wsgi_worker(
317318
http_mode,
318319
http1_settings,
319320
http2_settings,
320-
static_path,
321+
static_files,
321322
*ssl_ctx,
322323
metrics,
323324
)
@@ -427,7 +428,7 @@ def _spawn_worker(self, idx, target, callback_loader) -> WorkerProcess:
427428
self.http1_settings,
428429
self.http2_settings,
429430
self.websockets,
430-
self.static_path,
431+
self.static_files,
431432
self.log_access_format if self.log_access else None,
432433
self.ssl_ctx,
433434
{'url_path_prefix': self.url_path_prefix},

0 commit comments

Comments
 (0)