Cloud container: PocketBase's dashboard renders through /pb, its redirects keep /pb, embed-test reads the data API

Three bugs rega found on a laptop run of the container (2026-09-29):
- /pb/_/ never rendered. admin.mjs proxied with fetch(), which returns the body decompressed,
  but forwarded PocketBase's Content-Encoding: gzip and Content-Length, so the browser tried to
  gunzip plain HTML. The proxy now drops both headers (for /pb and /api-prod).
- PocketBase's own redirects left the mount: /pb/_ went to /_/, and /pb/_/index.html to
  http://127.0.0.1:8090/_/ (the internal address). Root-relative and upstream-absolute
  locations are put back under the mount: /pb/_/.
- The research host's /embed-test set no MAP_APP_API_URL, so the maps asked that static host's
  own /api (404s; dashboard-plus-grid drew 489 nodes, not 1,399). The page now sets it from the
  manifest's runtime.apiBaseUrl before any bundle loads, as every generated page does.

Checked both ways against an upstream that answers like PocketBase (gzipped HTML, redirects
to /_/). The old proxy sends a gzip label on a plain body and /_/ locations; the new one
sends a decodable body and /pb/_/.
This commit is contained in:
Doxa Maps 2026-09-29 04:12:43 +00:00
parent 93ebe64984
commit ffebd14377
2 changed files with 18 additions and 1 deletions

View file

@ -22,10 +22,23 @@ const groupsOf = (req) => (req.headers.get('x-forwarded-groups') || '').split(',
const isAdmin = (req) => ADMIN.length > 0 && groupsOf(req).some((g) => ADMIN.includes(g))
const text = (s, status = 200) => new Response(s + '\n', { status, headers: { 'content-type': 'text/plain; charset=utf-8' } })
// fetch() hands back the body DECOMPRESSED, so the upstream Content-Encoding and Content-Length no longer describe
// what is sent on: forwarding them made browsers try to gunzip plain HTML, and PocketBase's dashboard never rendered
// (rega, 2026-09-29). And PocketBase redirects to its own root (/_ -> /_/), which is put back under the mount (/pb/_/).
async function proxy(req, url, prefix, port) {
const target = new URL(url.pathname.slice(prefix.length - 1) + url.search, `http://127.0.0.1:${port}`)
const r = await fetch(target, { method: req.method, headers: req.headers, body: ['GET', 'HEAD'].includes(req.method) ? undefined : req.body, redirect: 'manual' })
return new Response(r.body, { status: r.status, headers: r.headers })
const headers = new Headers(r.headers)
headers.delete('content-encoding')
headers.delete('content-length')
const loc = headers.get('location')
if (loc) {
const mount = prefix.slice(0, -1) // '/pb/' -> '/pb'
const upstream = `http://127.0.0.1:${port}`
const path = loc.startsWith(upstream) ? loc.slice(upstream.length) || '/' : loc
if (path.startsWith('/') && !path.startsWith('//') && !path.startsWith(`${mount}/`)) headers.set('location', mount + path)
}
return new Response(r.body, { status: r.status, headers })
}
// One public build at a time (build-cdn.sh also holds a lock), started by an admin or by the watcher below.

View file

@ -59,6 +59,10 @@
const r = await fetch(`${base}/manifest.json`, { cache: 'no-cache' })
if (!r.ok) throw new Error(`HTTP ${r.status}`)
manifest = await r.json()
// The data API the maps read (people groups, statistics). Every generated page and copy-paste snippet sets it
// before its bundle loads; this page builds its cards itself, so it sets it from the manifest. Without it the
// bundles asked this host's own /api, which the research host doesn't serve (404s, fewer nodes drawn).
if (!window.MAP_APP_API_URL && manifest.runtime && manifest.runtime.apiBaseUrl) window.MAP_APP_API_URL = manifest.runtime.apiBaseUrl
} catch (e) {
status.textContent = `The manifest did not load from ${base}/manifest.json (${e.message}).`
return