Cloud container: subdomain apps, a published plugin as its own public site
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

SUBDOMAIN_APPS=host=bundle/profile makes e.g. research-gallery.<domain> serve the /cdn build of
doxa-maps-spa at its root. A separate server on its own port answers only those hosts and only with
published /cdn files (no admin, PocketBase or dev server behind it), so no sign-in is skipped anywhere
else. The root page gets a <base href> at the plugin's real folder, so its relative links resolve as on /cdn.
This commit is contained in:
Doxa Maps 2026-09-28 04:28:00 +00:00
parent 12d7f218f3
commit ded58aa2cc
2 changed files with 36 additions and 0 deletions

View file

@ -68,6 +68,7 @@ there. Only then does it swap the new build in.
| `PB_OIDC_CLIENT_SECRET` (+ `PB_OIDC_CLIENT_ID`, default `dx-maps-pb-dev`) | turns pb-dev into the team backend: laptops use it directly (`DX_PB_URL=https://<host>/pb`), and people sign in to PocketBase through the same OIDC provider, with its own client whose redirect URI is `https://<host>/pb/api/oauth2-redirect`. Only members of `OIDC_ALLOWED_GROUPS` get in (a sign-in hook baked into the image and pinned; the repo's own hooks never run). Password sign-in is off, new collections are superuser-only until a migration opens them, and data never leaves `/data` |
| `PRODUCTION_BRANCH` | the branch `/cdn/` is built from (default `production`); the dev server keeps following `BRANCH`. Promote with `git push origin main:production` |
| `CDN_AUTOBUILD_MINUTES` | rebuild `/cdn/` by itself once `PRODUCTION_BRANCH` has held a new commit this long (default `2`; `0` = only the admin button) |
| `SUBDOMAIN_APPS` | publish plugins as their own PUBLIC sites: `host=bundle/profile`, comma-separated, e.g. `research-gallery.example.org=doxa-maps-spa/doxa-maps-spa`. Served from the `/cdn` build by a separate server on `SUBDOMAIN_PORT` (default `5176`) that answers only those hosts and only with published files; point each host's proxy route at that port. No sign-in is skipped anywhere else |
| `PUBLIC_API_BASE` | the public API URL written into the build's `runtime-config.js` (e.g. `https://<host>/api-prod`) |
| `BUILD_MAX_OLD_SPACE_MB` | Node heap cap for a build (default `1536`) |
| `RUDDER_ENGINE` | `1` runs Rudder's voice-to-text engine here, fed by the browser's microphone (about 1 GB more; its runtime and model, ~742 MB, are baked into the image at `/opt/rudder`, and `/data/rudder` wins when it holds a model). The site serves at once; Rudder shows "starting" until its engine is built, then the dev server restarts once to start it. Give the container at least 2 vCPUs, or transcription falls behind speech |

View file

@ -5,6 +5,7 @@
// its /api/ is PUBLIC (PocketBase signs people in itself), except _superusers and _system collections
// /admin/build POST: build PRODUCTION_BRANCH now (OIDC_ADMIN_GROUPS only); /admin/status GET.
// The public build also follows PRODUCTION_BRANCH by itself (CDN_AUTOBUILD_MINUTES, default 2).
// SUBDOMAIN_APPS public subdomain sites for published plugins, on a separate port (see below)
// /admin/pb-signin-check a page that signs in to the team PocketBase and shows who it thinks you are
// Groups come from oauth2-proxy's X-Forwarded-Groups, which it sets on signed-in routes only.
import { spawn } from 'node:child_process'
@ -65,6 +66,40 @@ if (AUTO_MIN > 0) {
console.log(`[admin] public build follows ${PROD}, ${AUTO_MIN} min after it moves`)
}
// SUBDOMAIN APPS: a published plugin as its own public site, e.g. research-gallery.<domain> → the /cdn build
// of doxa-maps-spa. SUBDOMAIN_APPS = "host=bundle/profile,host2=bundle/profile". A SEPARATE server on its own
// port, reached by the proxy only for those hosts: it serves the published /cdn files and nothing else (no
// admin, no PocketBase, no dev server), so no sign-in is skipped anywhere else. "/" is the plugin's page with a
// <base href> at its real folder, so its relative links resolve exactly as they do on /cdn.
const SUBDOMAINS = Object.fromEntries((env.SUBDOMAIN_APPS || '').split(',').map((x) => x.trim()).filter(Boolean)
.map((x) => x.split('=').map((y) => y.trim())).filter(([h, t]) => h && /^[a-z0-9-]+\/[a-z0-9-]+$/.test(t || '')))
if (Object.keys(SUBDOMAINS).length) {
Bun.serve({
hostname: '0.0.0.0',
port: Number(env.SUBDOMAIN_PORT || 5176),
idleTimeout: 0,
async fetch(req) {
if (req.method !== 'GET' && req.method !== 'HEAD') return text('read-only', 405)
const target = SUBDOMAINS[(req.headers.get('host') || '').split(':')[0].toLowerCase()]
if (!target) return text('not found', 404)
const url = new URL(req.url)
const headers = { 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=300, stale-while-revalidate=86400' }
if (url.pathname === '/' || url.pathname === '/index.html') {
const leaf = join(CDN, 'dx-maps', target, 'index.html')
if (!existsSync(leaf)) return text('this app is not published yet', 503)
const html = readFileSync(leaf, 'utf8').replace(/<head([^>]*)>/i, `<head$1><base href="/dx-maps/${target}/">`)
return new Response(html, { headers: { ...headers, 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' } })
}
const f = normalize(join(CDN, decodeURIComponent(url.pathname)))
if (!f.startsWith(CDN + '/dx-maps/')) return text('not found', 404)
const file = existsSync(f) && statSync(f).isDirectory() ? join(f, 'index.html') : f
if (!existsSync(file)) return text('not found', 404)
return new Response(Bun.file(file), { headers })
},
})
console.log(`[admin] subdomain apps: ${Object.entries(SUBDOMAINS).map(([h, t]) => `${h} → ${t}`).join(', ')}`)
}
Bun.serve({
hostname: '127.0.0.1',
port: 5175,