# docker/nginx.conf # --------------------------------------------------------------------------- # nginx server block for the Wisp static web app (used by docker/web.Dockerfile). # # Responsibilities: # 1. Serve the Expo/Metro static SPA, falling back to /index.html for client # routes (expo-router uses HTML5 history routing). # 2. Emit the cross-origin isolation headers required for multi-threaded WASM # (SharedArrayBuffer). Without these, `crossOriginIsolated` is false in the # browser and the threaded Whisper WASM backend cannot start. # 3. gzip responses and cache immutable hashed assets aggressively. # 4. Serve the signed Android APK at /wisp.apk when one is present. # # This file is copied to /etc/nginx/conf.d/default.conf, replacing the stock # nginx site, so it is a `server { }` block (the surrounding http{}/events{} # come from the base image's /etc/nginx/nginx.conf). # --------------------------------------------------------------------------- # Map the request to a Cache-Control value: hashed build assets get a 1-year # immutable cache; everything else (HTML, the service worker, the APK) is # revalidated so deploys take effect immediately. map $uri $wisp_cache_control { default "no-cache"; # Expo emits content-hashed files under /_expo/ and /assets/. These are safe # to cache forever because the hash changes whenever the content changes. ~*^/_expo/ "public, max-age=31536000, immutable"; ~*^/assets/ "public, max-age=31536000, immutable"; ~*\.(?:js|css|woff2?|ttf|otf|png|jpg|jpeg|gif|svg|webp|wasm)$ "public, max-age=31536000, immutable"; } server { # Non-privileged port; docker-compose maps/exposes 8080 and the host's # reverse proxy forwards the wisp host/path here. listen 8080; listen [::]:8080; server_name _; root /usr/share/nginx/html; index index.html; # ---- Compression -------------------------------------------------------- gzip on; gzip_vary on; # vary on Accept-Encoding so proxies cache correctly gzip_comp_level 6; gzip_min_length 1024; # don't bother compressing tiny responses gzip_proxied any; gzip_types text/plain text/css text/javascript application/javascript application/json application/wasm image/svg+xml font/ttf font/otf; # NOTE: nginx cannot brotli-compress without the (non-default) ngx_brotli # module. The large .wasm model-runtime files benefit most from brotli; if # you need it, switch to an nginx image that bundles ngx_brotli and add # `brotli on; brotli_static on;` here. # ---- Cross-origin isolation (REQUIRED for threaded WASM) ---------------- # These two headers make the document "cross-origin isolated", which is what # unlocks SharedArrayBuffer and therefore multi-threaded WASM. They are set # on EVERY response (`always`) so they apply even to error responses. # # COOP same-origin -> process-isolate this page from cross-origin openers # COEP require-corp -> every subresource must explicitly opt in (via CORP # or CORS) to being embedded here add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Embedder-Policy "require-corp" always; # Our own assets are same-origin; declare CORP so they remain loadable under # COEP and can also be used by other isolated origins if ever needed. add_header Cross-Origin-Resource-Policy "same-origin" always; # ---------------------------------------------------------------------- # IMPORTANT — Hugging Face model weights & CORS/CORP interaction # ---------------------------------------------------------------------- # The app downloads Whisper model weights at runtime from the Hugging Face # Hub (a cross-origin host). Under `Cross-Origin-Embedder-Policy: require-corp` # every cross-origin subresource must be served with either: # Cross-Origin-Resource-Policy: cross-origin (a CORP header), or # valid CORS headers AND be fetched with crossorigin/CORS mode. # # The HF Hub / its CDN generally DO send permissive CORS (Access-Control- # Allow-Origin) headers, so CORS-mode fetches usually work under require-corp. # However, if HF (or a future CDN) ever omits CORP/CORS for a given asset, # require-corp will BLOCK the download and the model load will fail. # # The robust fallback is COEP "credentialless": it keeps the page cross-origin # isolated (SharedArrayBuffer stays available) but lets no-CORS cross-origin # resources load by sending them WITHOUT credentials, removing the hard CORP # requirement. To switch, comment out the `require-corp` line above and # enable the line below instead: # # add_header Cross-Origin-Embedder-Policy "credentialless" always; # # (Browser support: credentialless is supported in modern Chromium/Firefox; # Safari only supports require-corp, so require-corp is the safer default.) # ---- SPA routing -------------------------------------------------------- location / { # Serve the file if it exists, else a matching directory, else fall back # to index.html so client-side (expo-router) routes resolve. This is the # canonical SPA fallback. try_files $uri $uri/ /index.html; # Apply the computed cache policy (see the map{} above). The header is # re-asserted here because `add_header` does not inherit into locations # once a location defines its own add_header directives. add_header Cache-Control $wisp_cache_control; # Re-assert the isolation headers inside this location (add_header in an # outer scope is dropped as soon as a location sets ANY add_header). add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Embedder-Policy "require-corp" always; add_header Cross-Origin-Resource-Policy "same-origin" always; } # ---- Android APK download ---------------------------------------------- # The CI build-apk job scps the signed APK to the server, where it is mounted # into the web root as /usr/share/nginx/html/wisp.apk (see docker-compose.yml). # If no APK is present yet, this 404s cleanly rather than falling through to # the SPA index.html (which would download an HTML file named wisp.apk). location = /wisp.apk { # Don't cache the APK aggressively so a freshly deployed build is served. add_header Cache-Control "no-cache" always; # CORP must still be present on this response under COEP. add_header Cross-Origin-Resource-Policy "same-origin" always; # Force a download with a sensible filename and correct MIME type. types { } # clear inherited type map default_type application/vnd.android.package-archive; add_header Content-Disposition 'attachment; filename="wisp.apk"' always; try_files /wisp.apk =404; } # Health check endpoint for the reverse proxy / container orchestrator. location = /healthz { access_log off; add_header Content-Type text/plain; return 200 "ok\n"; } }