The page and the service worker were strict TypeScript; the tests, the build, the home page's generator, the load tests, boot.js and the directory's site.js were plain JavaScript that nothing checked. All of it is TypeScript now, in five programs, each with its own globals, all checked by npm run typecheck: the page (tsconfig.json, with boot.ts and the directory's site.ts), the worker, Node's (tsconfig.tools.json: the Node tests, the browser suite, the build, the site generator, the load harness), and k6's (tsconfig.k6.json, with @types/k6). Node 24 runs the .ts files by stripping their types, so they hold only TypeScript that erases (erasableSyntaxOnly) and import each other by .ts name; package.json says "type": "module" for it, and Dockerfile.web moves to Node 24 to run build.mts. k6 2.3 strips its scripts' types itself. The browser suite's page.evaluate calls reach the page's globals; the page's tsc run now also writes its declarations to target/page-types, which the suite's program includes, so those calls are typed by the page's own code rather than by hand-written copies. boot.ts is compiled by build.mts with swc, as the worker is, and named by the hash of what is served. The directory's build has no Node, so site.js is compiled from site.ts and committed (node web/build.mts --directory-script), and tests/directory-script.ts fails if they drift. Checked: the site generator's 139 pages are byte for byte what the JavaScript made; the page build's app.js is identical and only boot.js (now minified) and its hash differ; contrast, page-smoke, page-unit, native-bridge, the browser suite (96 passed, 2 skipped) and the directory's and ipx's Rust tests pass; k6 parses each load script. The load tests themselves did not run: they build a release binary outside the capped builder. .agents/skills/security-audit is an installed skill's, not ours, and is left as it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
156 lines
5.3 KiB
Caddyfile
156 lines
5.3 KiB
Caddyfile
# ipx-web (#203): the frontend as static files, and everything else passed to ipx, on one origin,
|
|
# so the session cookie, live updates and Cloudflare Access work as they did when ipx served it.
|
|
# IPX_UPSTREAM where ipx is, host:port (ipx:8099)
|
|
# WEB_BIND where to listen (:8080)
|
|
# WEB_TRACING on: a span for each request to OpenTelemetry (OTEL_EXPORTER_OTLP_ENDPOINT,
|
|
# OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_SERVICE_NAME), continuing the page's traceparent
|
|
# and passing it to ipx, so one trace runs browser, Caddy, ipx (#213). Off: none,
|
|
# and no export failing every few seconds where there is nothing to send to.
|
|
# METRICS_BIND where to serve Caddy's metrics for Prometheus (:9180); keep it off the tunnel
|
|
# TUNNEL_PEER the one address whose Cf-Access-* headers are passed on: where cloudflared's
|
|
# requests come from. Nobody else's are, so nobody else can claim to be signed in
|
|
# by Cloudflare. Unset passes none.
|
|
{
|
|
admin off
|
|
auto_https off
|
|
# Requests, their times and upstream errors as Prometheus metrics, served below (#371).
|
|
metrics
|
|
# Everything else Caddy logs, its errors among them, which carry the request too: the same
|
|
# filter as the access log's below.
|
|
log default {
|
|
output stderr
|
|
format filter {
|
|
wrap json
|
|
fields {
|
|
request>uri query {
|
|
delete token
|
|
}
|
|
request>headers delete
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# Caddy's own metrics (#371), on a port of their own: the tunnel reaches only WEB_BIND's.
|
|
http://{$METRICS_BIND::9180} {
|
|
metrics
|
|
}
|
|
|
|
(tracing-on) {
|
|
tracing {
|
|
# Named for the request, as ipx names its own (#286): one name for all left every span in
|
|
# Tempo looking the same.
|
|
span "{http.request.method} {http.request.uri.path}"
|
|
}
|
|
}
|
|
(tracing-off) {
|
|
}
|
|
|
|
http://{$WEB_BIND::8080} {
|
|
import tracing-{$WEB_TRACING:off}
|
|
|
|
root * {$WEB_ROOT:/srv}
|
|
|
|
# One JSON line a request on stdout, for Loki and the dashboard's Page row (#210). Not the
|
|
# shared token a ?token= link carries, nor the request's headers, which hold the session
|
|
# cookie and Cloudflare Access's token.
|
|
log {
|
|
output stdout
|
|
format filter {
|
|
wrap json
|
|
fields {
|
|
request>uri query {
|
|
delete token
|
|
}
|
|
request>headers delete
|
|
}
|
|
}
|
|
}
|
|
|
|
# Compressed, zstd or gzip as the browser takes them: the feed list and a page of items went
|
|
# out at over 100 KB each (#239). Not the live updates, which a compressor would hold back
|
|
# until it had enough to send, nor /media, which is audio and video already compressed.
|
|
@compress not path /api/events /media/*
|
|
encode @compress zstd gzip
|
|
|
|
# The copies web/build.mts compressed are served in place of their file, never asked for by
|
|
# name: /admin.js.br would be the admin page's script without the check below that keeps it to
|
|
# admins (#253).
|
|
# A handle of its own: a bare respond runs after the handles, and the last one serves any file.
|
|
@packed path *.br *.gz
|
|
handle @packed {
|
|
respond 404
|
|
}
|
|
|
|
# Before anything else, so no page or API call sees them: only ipx sets these.
|
|
@forged not remote_ip {$TUNNEL_PEER:255.255.255.255}
|
|
request_header @forged -Cf-Access-Authenticated-User-Email
|
|
request_header @forged -Cf-Access-Jwt-Assertion
|
|
# Whatever header ipx's [web] trusted_header names, when it is not Cloudflare's: ipx trusts it
|
|
# from this address, and anyone could send it through here (#323).
|
|
request_header @forged -{$TRUSTED_HEADER:Cf-Access-Authenticated-User-Email}
|
|
|
|
@api path /api/* /media/*
|
|
handle @api {
|
|
# Live updates are a stream: flushed as they come, never buffered.
|
|
reverse_proxy {$IPX_UPSTREAM:ipx:8099} {
|
|
flush_interval -1
|
|
}
|
|
}
|
|
|
|
# Signing in with the shared token in the address (?token=): ipx sets the session cookie and
|
|
# sends the browser on to the page without it.
|
|
@tokened {
|
|
path / /admin
|
|
query token=*
|
|
}
|
|
handle @tokened {
|
|
rewrite * /api/signin?{query}
|
|
reverse_proxy {$IPX_UPSTREAM:ipx:8099}
|
|
}
|
|
|
|
# The app and the admin page go to whoever ipx says may have them: a browser not signed in is
|
|
# sent to /login, anyone but an admin asking for /admin to the app.
|
|
@page path / /index.html /admin /admin.html /admin.js
|
|
handle @page {
|
|
forward_auth {$IPX_UPSTREAM:ipx:8099} {
|
|
uri /api/page-check
|
|
}
|
|
rewrite / /index.html
|
|
rewrite /admin /admin.html
|
|
header Cache-Control no-cache
|
|
file_server {
|
|
precompressed br gzip
|
|
}
|
|
}
|
|
|
|
# The sign-in form, except for someone Cloudflare Access vouches for, who is sent to the app.
|
|
@login path /login /login.html
|
|
handle @login {
|
|
forward_auth {$IPX_UPSTREAM:ipx:8099} {
|
|
uri /api/login-check
|
|
}
|
|
rewrite * /login.html
|
|
header Cache-Control no-cache
|
|
file_server {
|
|
precompressed br gzip
|
|
}
|
|
}
|
|
|
|
# Scripts and the stylesheet are named by a hash of their contents (web/build.mts), so a browser
|
|
# keeps them a year; the icons a day, under names that do not change.
|
|
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js /icons/index.json
|
|
header @hashed Cache-Control "public, max-age=31536000, immutable"
|
|
# The service worker (#389): the browser looks for a new one at each visit, and must find it.
|
|
header /sw.js Cache-Control no-cache
|
|
# The same PNG as /favicon.png, for a browser that asks for /favicon.ico on its own.
|
|
header /favicon.ico Content-Type image/png
|
|
@icons path *.png *.svg *.ico *.woff2
|
|
header @icons Cache-Control "public, max-age=86400"
|
|
handle {
|
|
file_server {
|
|
precompressed br gzip
|
|
}
|
|
}
|
|
}
|