grain-serve hands the web's pages to its app, at --web
ClosedThis adds GRAIN_WEB. grain-serve now proxies every request it doesn't answer itself to the SvelteKit app. It strips the hop headers, sets x-forwarded-host and x-forwarded-proto, returns redirects and all set-cookie headers unfollowed, and adds nosniff and same-origin defaults. The proxy sits inside the cookie carryover layer. With GRAIN_WEB unset, unanswered paths stay 404, and router and serve are unchanged. The tests cover the forwarded headers, the body, the 303 with two cookies, the /xrpc 501, the 502 and the unset case. I found nothing that must change.
Three small things, none blocking:
- A request body over 1 MB makes
to_bytesfail, so the client gets a 502 saying the pages aren't being drawn. A 413 would say what happened. x-forwarded-hostis only set when aHostheader is present. A request that carries its authority only in the URI (HTTP/2 straight to grain-serve) reaches the app without it. Behind Caddy over HTTP/1.1 this doesn't arise.- The hop-header list leaves out
keep-alive,proxy-authenticateandproxy-authorization.
Carried forward: the change this pull makes at 8bef4a6c904c1e81a2c71f6988474bd6f4309f43 is the one reviewed at be67d4ba68e7a1268239771ff9180e39f57521ff, so that review stands. What is new comes from its base.
On the three small things: each is how knotless-serve's own fallback (knotless-serve/src/web/pages.rs) behaves, which this follows line for line: the same 1 MB to_bytes limit answering 502, the same six hop headers, and x-forwarded-host from Host. Behind Caddy over HTTP/1.1, as grain runs, a request always carries Host, and hyper drops connection-level keep-alive itself. A 413 for a big body would say more, and is better made in knotless first and copied here, so the two stay one shape; left as it is.
This round is the same change, now read from the --web flag instead of GRAIN_WEB. It adds pages.rs, router_with and serve_with, the Settings::web field with its USAGE entry, and the vite.config.ts note. I read the diff against batch/one and found nothing that must change. Both the 502 and the unset case are tested. The flag's trailing / is dropped in Settings and again in pages(), and the settings test checks that.
My earlier non-blocking notes are not addressed, and I'm not asking for them again:
- an oversized body gives a 502, not a 413
x-forwarded-hostis only set when aHostheader is present- the hop-header list leaves out
keep-alive,proxy-authenticateandproxy-authorization
I didn't see the runner's results, so I haven't confirmed the tests pass there.
This round folds router_with and serve_with into router and serve, which now take web: Option<&str>. I searched the branch and found no caller left on the old signatures. main.rs passes settings.web.as_deref(), and grain-mcp, og.rs and common/mod.rs pass None. The new served_pages helper in tests/common/mod.rs passes Some. The proxy logic in pages.rs is as I reviewed it before, so nothing blocks the merge.
The three small notes from before are still open, as the author said they would be, and I'm not asking for them again: the 413 for an oversized body, x-forwarded-host only when Host is present, and the longer hop-header list. I didn't see the runner's results, so I haven't confirmed the tests pass there.
This round is a merge of feature/rust into gaps/web. The pull's own change is unchanged since I reviewed it at 9ba88a1: pages.rs hands unanswered requests to the app given by --web, and router and serve take web: Option<&str>. I didn't re-read the merged base work, which other pulls have reviewed. I found nothing new that must change.
The three small notes from earlier rounds are still open, and I'm not asking for them again: the 413 for an oversized body, x-forwarded-host only when Host is present, and the longer hop-header list. I didn't see the runner's results, so I haven't confirmed the tests pass there.
Carried forward: the change this pull makes at 71971355449d6449df4ea43edc4dff98cdab641a is the one reviewed at 2b27db8b872ca9b27f6a932e5ff5d9dbeb060e51, so that review stands. What is new comes from its base.
Landed on feature/rust at 7d031dc through the batch pull The web proxy, deletes and the cross-post test land together, checked once, checked there as a whole. This pull's head is in feature/rust's history.
What changes
--web. grain-serve takes the URL of the web's SvelteKit app (--web http://web:3000, among the flagsinfra/flagsgives it; a trailing/is dropped and an empty value is unset, as for the others) and hands it every request it does not answer itself (crates/grain-serve/src/pages.rs), as knotless-serve does with--web(knotless-serve/src/web/pages.rs, mirrored line for line). The request goes on with its method, path, query, cookies and body (up to 1 MB), less the headers about one hop (connection,host,transfer-encoding,content-length,upgrade,te), with the host it was asked for asx-forwarded-hostandx-forwarded-protoashttpwhere nothing in front said otherwise. The app's answer comes back as it gave it, a redirect for the browser to follow and everyset-cookietoo, withx-content-type-options: nosniffandreferrer-policy: same-originwhere the app set neither. An app that does not answer is a 502 saying the pages are not being drawn, logged with why. Not given, nothing is handed on and what grain-serve does not answer is 404, as now.What grain-serve answers stays its own:
/xrpc/*(a method it does not serve is still 501, never the app's),/oauth/*,/oauth-client-metadata.json,/og/*. The pages sit inside the cookie carryover's layer, so a browser that lands on a page with a cookie from before grain-serve is carried there too.The app's URL is
App::pages, whichSettings::appfills from--web, sograin_serve::routerandservekeep their signatures and a test hands pages by building itsAppwith one.Serving in production. Caddy sends everything to grain-serve; grain-serve, with
--web http://web:3000, hands the pages to thewebservice; the web's server-side loads still call grain-serve atGRAIN_SERVE_URL=http://grain-serve:2600(web/src/hooks.server.ts). This replaces the split proposed in the first web pull (Caddy sending/xrpc/*,/oauth/*,/og/*,/.well-known/*to grain-serve and the rest toweb). The node adapter should be told to read the forwarded host and scheme,HOST_HEADER=x-forwarded-host PROTOCOL_HEADER=x-forwarded-proto, as knotless's app is, so itsurl.originis the site's. The dev proxy's note invite.config.tssays so.One of the pulls closing what the clients call and grain-serve did not serve; the table of them all is in
gaps/graph's body (Accounts mute and unmute, and list whom they block and mute).On top of the batch. This head holds feature/rust at 92c202b (the batch, the infra stack and spaces); its own diff is
pages.rs, the--webflag andApp::pages, andvite.config.ts's note, which keeps the DID document among what the dev server sends on.Why
One server in front, as knotless runs, so the routing of paths lives in grain-serve's router rather than in Caddy's config as well, and the two cannot disagree about which paths are whose.
How it was checked
crates/grain-serve/tests/pages.rs, with a stand-in app on loopback: a page asked for with its query and cookie arrives with the forwarded host and scheme and withoutconnection; a form's body and anx-forwarded-proto: httpsare handed on; a 303 comes back unfollowed with both its cookies and the app's ownreferrer-policy;/xrpc/_healthand an unserved method stay grain-serve's; an app that is down is a 502, and with no app a page is a 404.--webis read in the settings test. Run here once (no PDS needed); the rest is the runner's.Shortcomings
/xrpc, never through here./.well-known/apple-app-site-associationis the web's route, so it comes to the app through grain-serve;/.well-known/did.jsonis grain-serve's own.hetzner-infra, not here.🤖 Generated with Claude Code