|
|
import os |
|
|
import httpx |
|
|
import tempfile |
|
|
import shutil |
|
|
import brotli |
|
|
from fastapi import Request |
|
|
from fastapi.responses import StreamingResponse, FileResponse |
|
|
from starlette.background import BackgroundTask |
|
|
|
|
|
def _get_file_headers(local_path: str) -> dict: |
|
|
headers = { |
|
|
"Cross-Origin-Opener-Policy": "same-origin", |
|
|
"Cross-Origin-Embedder-Policy": "require-corp" |
|
|
} |
|
|
|
|
|
if local_path.endswith(".br"): |
|
|
headers["Content-Encoding"] = "br" |
|
|
headers["Content-Type"] = "application/octet-stream" |
|
|
|
|
|
return headers |
|
|
|
|
|
def _get_media_type(local_path: str) -> str: |
|
|
"""Get appropriate media type based on file extension.""" |
|
|
if local_path.endswith(".wasm.br") or local_path.endswith(".wasm"): |
|
|
return "application/wasm" |
|
|
if local_path.endswith(".br"): |
|
|
return "application/octet-stream" |
|
|
return None |
|
|
|
|
|
def get_local_file(local_path: str, request: Request = None) -> FileResponse | StreamingResponse | None: |
|
|
""" |
|
|
Get a local file as response. If it's a .br file and client doesn't accept brotli, |
|
|
decompress it on the fly. |
|
|
|
|
|
Args: |
|
|
local_path: Path to the local file |
|
|
request: Optional request object to check Accept-Encoding header |
|
|
|
|
|
Returns: |
|
|
FileResponse, StreamingResponse (for decompressed .br), or None if file not found |
|
|
""" |
|
|
if not os.path.isfile(local_path): |
|
|
return None |
|
|
|
|
|
headers = _get_file_headers(local_path) |
|
|
media_type = _get_media_type(local_path) |
|
|
|
|
|
|
|
|
is_br_file = local_path.endswith(".br") |
|
|
need_decompress = is_br_file and request and not _client_accepts_brotli(request) |
|
|
|
|
|
if need_decompress: |
|
|
|
|
|
headers.pop("Content-Encoding", None) |
|
|
headers["Content-Type"] = "application/octet-stream" |
|
|
|
|
|
def iterate_decompressed(): |
|
|
with open(local_path, "rb") as f: |
|
|
decompressor = brotli.Decompressor() |
|
|
while chunk := f.read(65536): |
|
|
yield decompressor.process(chunk) |
|
|
|
|
|
return StreamingResponse( |
|
|
iterate_decompressed(), |
|
|
media_type="application/octet-stream", |
|
|
headers=headers |
|
|
) |
|
|
|
|
|
if media_type: |
|
|
return FileResponse(local_path, media_type=media_type, headers=headers) |
|
|
return FileResponse(local_path, headers=headers) |
|
|
|
|
|
def _client_accepts_brotli(request: Request) -> bool: |
|
|
"""Check if client accepts brotli encoding.""" |
|
|
accept_encoding = request.headers.get("accept-encoding", "") |
|
|
return "br" in accept_encoding.lower() |
|
|
|
|
|
async def proxy_and_cache(request: Request, url: str, local_path: str = None, disable_cache: bool = False): |
|
|
""" |
|
|
Proxy request to upstream URL and optionally cache the response. |
|
|
|
|
|
Args: |
|
|
request: FastAPI request object |
|
|
url: Upstream URL to proxy to |
|
|
local_path: Local file path for caching (required if disable_cache is False) |
|
|
disable_cache: If True, just proxy without caching or reading from local file |
|
|
""" |
|
|
if not disable_cache and local_path: |
|
|
if response := get_local_file(local_path, request): |
|
|
return response |
|
|
|
|
|
|
|
|
is_br_file = url.endswith(".br") |
|
|
client_accepts_br = _client_accepts_brotli(request) |
|
|
need_decompress = is_br_file and not client_accepts_br |
|
|
|
|
|
client = httpx.AsyncClient(timeout=None) |
|
|
headers = {k: v for k, v in request.headers.items() if k.lower() not in ["host", "content-length", "accept-encoding"]} |
|
|
|
|
|
req = client.build_request(request.method, url, headers=headers) |
|
|
r = await client.send(req, stream=True) |
|
|
|
|
|
excluded_headers = {"transfer-encoding", "connection", "keep-alive", "upgrade", "content-security-policy"} |
|
|
response_headers = {k: v for k, v in r.headers.items() if k.lower() not in excluded_headers} |
|
|
response_headers["Cross-Origin-Opener-Policy"] = "same-origin" |
|
|
response_headers["Cross-Origin-Embedder-Policy"] = "require-corp" |
|
|
|
|
|
|
|
|
if need_decompress: |
|
|
response_headers.pop("content-encoding", None) |
|
|
response_headers.pop("Content-Encoding", None) |
|
|
|
|
|
response_headers.pop("content-length", None) |
|
|
response_headers.pop("Content-Length", None) |
|
|
|
|
|
if r.status_code != 200 or disable_cache or not local_path: |
|
|
async def stream_with_decompress(): |
|
|
decompressor = brotli.Decompressor() if need_decompress else None |
|
|
try: |
|
|
async for chunk in r.aiter_raw(): |
|
|
if decompressor: |
|
|
yield decompressor.process(chunk) |
|
|
else: |
|
|
yield chunk |
|
|
finally: |
|
|
await r.aclose() |
|
|
await client.aclose() |
|
|
|
|
|
return StreamingResponse( |
|
|
stream_with_decompress(), |
|
|
status_code=r.status_code, |
|
|
headers=response_headers |
|
|
) |
|
|
|
|
|
os.makedirs(os.path.dirname(local_path), exist_ok=True) |
|
|
|
|
|
temp_file = tempfile.NamedTemporaryFile(delete=False, dir=os.path.dirname(local_path)) |
|
|
temp_file_path = temp_file.name |
|
|
|
|
|
async def iterate_and_save(): |
|
|
success = False |
|
|
decompressor = brotli.Decompressor() if need_decompress else None |
|
|
try: |
|
|
async for chunk in r.aiter_raw(): |
|
|
|
|
|
temp_file.write(chunk) |
|
|
|
|
|
if decompressor: |
|
|
yield decompressor.process(chunk) |
|
|
else: |
|
|
yield chunk |
|
|
temp_file.close() |
|
|
|
|
|
|
|
|
shutil.move(temp_file_path, local_path) |
|
|
success = True |
|
|
finally: |
|
|
if not success: |
|
|
temp_file.close() |
|
|
if os.path.exists(temp_file_path): |
|
|
os.remove(temp_file_path) |
|
|
await r.aclose() |
|
|
await client.aclose() |
|
|
|
|
|
return StreamingResponse( |
|
|
iterate_and_save(), |
|
|
status_code=r.status_code, |
|
|
headers=response_headers |
|
|
) |
|
|
|