diff --git a/frontend-plus-api/README.md b/frontend-plus-api/README.md index df65f10..366aed6 100644 --- a/frontend-plus-api/README.md +++ b/frontend-plus-api/README.md @@ -22,12 +22,90 @@ That: `./backend`, `frontend-web` from `./frontend`. 3. Brings them up in `dependsOn` order (`frontend-api`, then `frontend-web`); each app gets its own subdomain — `frontend-api-.rigbox.dev` and - `frontend-web-.rigbox.dev`. The web page derives the API origin from its - own `frontend-web-` hostname (swapping the prefix to `frontend-api-`), - so cross-app wiring needs no per-deploy templating. + `frontend-web-.rigbox.dev`. -The frontend derives the API URL from its own hostname, so no per-deploy -templating is needed. +## How the apps talk (inter-app networking) + +Both apps run as processes on the **same workspace VM**, so a sibling is +reachable over loopback. Because `frontend-web` declares +`dependsOn: [frontend-api]`, Rigbox does the service discovery for you: it +injects + +``` +RIGBOX_FRONTEND_API_URL=http://127.0.0.1:5100 +``` + +into `frontend-web`'s environment (`RIGBOX__URL`, name uppercased, +dashes → underscores). `server.js` reads that var and **proxies `/api`** to the +backend over loopback. The browser only ever talks to `frontend-web`'s own +origin — so there's **no CORS**, and `frontend-api` can stay **private** (the +default). Only `frontend-web` is public; the API is never exposed to the +internet. + +Running locally? The platform var isn't set, so `server.js` falls back to +`http://127.0.0.1:5100` — `node server.js` in each dir just works. + +Apps deploy **private** by default, so open just the public entry point — +`frontend-web` — to the world (the API needs nothing): + +```bash +rig app share --app frontend-web --public +``` + +Re-run that after each `rig deploy` — a deploy resets app visibility to the +`rig.yaml` default (private). + +> The old approach — a browser `fetch` cross-origin to the API's subdomain — +> failed with `CORS request did not succeed` whenever the API was private (the +> fetch followed the login redirect). Proxying server-side over the workspace's +> internal network avoids that entirely and keeps the API key/credentials off +> the client. + +## Toggle: loopback vs the public internet + +The page has a **Private / Internet** route switch. Both reach the same +backend; they differ in *how*: + +- **Private (default)** — `server.js` proxies `/api` to `127.0.0.1:5100` over + the VM's loopback. Fast, never leaves the VM, and needs no credentials. This + is what you should use when the dependency is co-located. +- **Internet** — `server.js` proxies `/api` to `frontend-api`'s **public + subdomain** over the internet (through the gateway). Since `frontend-api` is + private, the call is authenticated with an `X-Rigbox-Key` header — so this + route exercises the metered, public path instead of loopback. + +The key is an **optional deploy secret** the workspace owner sets. It stays +server-side in `frontend-web`'s env and is **never sent to the browser**: + +```bash +export RIGBOX_API_KEY=rb_... # your Rigbox API key (the workspace owner's) +rig deploy +``` + +`rig.yaml` declares it as `secrets: [{ name: RIGBOX_API_KEY, optional: true }]`, +so **deploying without it still works** — the Internet toggle just shows a toast +explaining how to enable it, and the app keeps running on the Private route. +Requires CLI ≥ 0.12.33 (optional secrets). + +Notes: +- Use the **owner's** key — a key from another account gets `403` (the API is + private to its owner). A non-owner or missing key can't read it. +- **Prefer a workspace-scoped key** if you have one: a leak is then bounded to + this workspace's own apps (which the VM already reaches over loopback). The + key does live in the app's server-side env — a deliberate, owner-controlled + tradeoff for this demo. +- Don't reach for Internet mode when loopback works — it's here to show the + public/authenticated path, not because you need it for co-located apps. + +### Or just make the API public + +If you don't want a key at all, share the API and it answers without auth: + +```bash +rig app share --app frontend-api --public +``` + +Then anyone can hit it — fine for this throwaway todo data, not for anything real. ## What's in the box @@ -38,8 +116,8 @@ code; the app's spec lives inline under `apps.`. | Path | What it does | |---|---| | `rig.yaml` | Workspace blueprint + both app specs inline | -| `backend/` | `frontend-api` — in-memory todo API on port 5100 (`app.js`) | -| `frontend/` | `frontend-web` — static UI on port 5101, `dependsOn: [frontend-api]` (`server.js`, `public/`) | +| `backend/` | `frontend-api` — in-memory todo API on port 5100, stays private (`app.js`) | +| `frontend/` | `frontend-web` — static UI on port 5101 that proxies `/api` to its sibling; `dependsOn: [frontend-api]` (`server.js`, `public/`) | Both apps target the base image — no backing services, just `apt install nodejs` at install time. @@ -49,11 +127,15 @@ Both apps target the base image — no backing services, just `apt install nodej # Frontend shows the todo list; add/delete works. open https://frontend-web-.rigbox.dev -# API responds directly. -curl https://frontend-api-.rigbox.dev/api/todos +# The API is reached *through* the frontend's same-origin /api proxy: +curl https://frontend-web-.rigbox.dev/api/todos curl -X POST -H 'content-type: application/json' \ -d '{"title":"first todo"}' \ - https://frontend-api-.rigbox.dev/api/todos + https://frontend-web-.rigbox.dev/api/todos + +# frontend-api itself is private — hitting its subdomain directly redirects +# to login (302). That's expected; the proxy reaches it over loopback. +curl -s -o /dev/null -w '%{http_code}\n' https://frontend-api-.rigbox.dev/api/todos ``` ## Iterate on the source diff --git a/frontend-plus-api/frontend/public/index.html b/frontend-plus-api/frontend/public/index.html index 2493db2..0dcb36e 100644 --- a/frontend-plus-api/frontend/public/index.html +++ b/frontend-plus-api/frontend/public/index.html @@ -8,7 +8,12 @@ :root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, sans-serif; } body { max-width: 540px; margin: 4rem auto; padding: 0 1rem; line-height: 1.5; } h1 { font-weight: 600; margin: 0 0 0.25rem; } - .meta { color: #888; font-size: 0.85rem; margin-bottom: 1.5rem; } + .meta { color: #888; font-size: 0.85rem; margin-bottom: 1rem; } + .route { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1.5rem; font-size: 0.85rem; } + .seg { display: inline-flex; border: 1px solid #888; border-radius: 6px; overflow: hidden; } + .seg button { border: 0; border-radius: 0; padding: 0.3rem 0.7rem; background: transparent; } + .seg button[aria-pressed="true"] { background: rgba(127,127,127,0.25); font-weight: 600; } + .route .hint { color: #888; } form { display: flex; gap: 0.5rem; margin-bottom: 1rem; } input[type=text] { flex: 1; padding: 0.5rem 0.6rem; border: 1px solid #888; border-radius: 6px; font: inherit; background: transparent; color: inherit; } button { padding: 0.5rem 0.9rem; border: 1px solid #888; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; } @@ -17,55 +22,94 @@ li { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid rgba(127,127,127,0.2); } li button { padding: 0.15rem 0.4rem; font-size: 0.75rem; } .empty { color: #888; font-style: italic; padding: 0.5rem 0; } + #toast { position: fixed; left: 50%; bottom: 1.5rem; transform: translateX(-50%); max-width: 90vw; + background: #222; color: #eee; border: 1px solid #555; border-radius: 8px; padding: 0.75rem 1rem; + font-size: 0.85rem; line-height: 1.4; box-shadow: 0 4px 16px rgba(0,0,0,0.3); display: none; } + #toast code { background: rgba(255,255,255,0.12); padding: 0.05rem 0.3rem; border-radius: 4px; }

frontend-plus-api · todos

-
- API: +
API: /api
+
+ Route: + + + + +
    +
    diff --git a/frontend-plus-api/frontend/server.js b/frontend-plus-api/frontend/server.js index 5e6c527..c553b42 100644 --- a/frontend-plus-api/frontend/server.js +++ b/frontend-plus-api/frontend/server.js @@ -1,19 +1,28 @@ -// Static-file server with one templating trick: injects the API -// URL (read from process.env.RIGBOX_API_URL) into index.html as -// window.RIGBOX_API_URL so the page knows where to reach the backend -// without baking the address into the HTML at build time. +// Static-file server that proxies /api to the sibling backend, with a +// runtime toggle between two routes: +// +// private (default) — proxy to the sibling over the VM's loopback +// interface (RIGBOX_FRONTEND_API_URL, injected from `dependsOn`). Fast, +// never leaves the VM, never metered, and frontend-api stays private. +// +// internet — proxy to the sibling's PUBLIC subdomain over the internet, +// through the gateway. Because frontend-api is private, we authenticate +// the call with `X-Rigbox-Key: ` — an opt-in secret the +// workspace owner sets at deploy. The key stays server-side here; it is +// never sent to the browser. +// +// The browser always talks to THIS origin (`/api`); server.js decides which +// upstream to use from the `x-demo-route` header the page sets per request. const http = require('http'); +const https = require('https'); const fs = require('fs'); const path = require('path'); const port = Number(process.env.PORT || 5101); -// When RIGBOX_API_URL is set (e.g. the frontend-web app's `params.api_url` -// override), inject it into index.html as window.RIGBOX_API_URL. -// Otherwise leave the page to derive the URL from window.location — -// see public/index.html's deriveApiUrl() — so the default case -// "talk to my sibling frontend-api app on the same workspace" works -// without any per-deploy templating. -const apiUrl = process.env.RIGBOX_API_URL || ''; +const loopbackTarget = process.env.RIGBOX_FRONTEND_API_URL || 'http://127.0.0.1:5100'; +// Optional secret (rig.yaml `secrets: [{ name: RIGBOX_API_KEY, optional: true }]`). +// Empty/unset → the internet toggle is disabled and the UI explains how to set it. +const apiKey = process.env.RIGBOX_API_KEY || ''; const publicDir = path.join(__dirname, 'public'); const mime = { @@ -26,6 +35,60 @@ const mime = { '.ico': 'image/x-icon', }; +// Derive the sibling's public origin from our own deployed hostname: +// frontend-web-.rigbox.dev -> https://frontend-api-.rigbox.dev +// Returns null when not running under that hostname (e.g. local dev). +function publicApiOrigin(host) { + if (host && host.startsWith('frontend-web-')) { + return 'https://' + host.replace(/^frontend-web-/, 'frontend-api-'); + } + return null; +} + +function sendJson(res, status, body) { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +// Stream a proxied request to `target` (a URL), optionally adding headers, +// over http or https depending on the protocol. +function forward(req, res, target, extraHeaders) { + const client = target.protocol === 'https:' ? https : http; + const headers = { ...req.headers, host: target.host, ...extraHeaders }; + const upstream = client.request(target, { method: req.method, headers }, (up) => { + res.writeHead(up.statusCode || 502, up.headers); + up.pipe(res); + }); + upstream.on('error', () => + sendJson(res, 502, { error: 'backend_unreachable', target: target.origin })); + req.pipe(upstream); +} + +function proxyApi(req, res) { + const mode = req.headers['x-demo-route'] === 'internet' ? 'internet' : 'private'; + + if (mode === 'internet') { + const origin = publicApiOrigin(req.headers.host); + if (!origin) { + return sendJson(res, 400, { + error: 'internet_unavailable', + message: 'Internet routing only works on the deployed frontend-web-.rigbox.dev host.', + }); + } + if (!apiKey) { + return sendJson(res, 400, { + error: 'missing_api_key', + message: 'No API key set, so the internet route can\'t authenticate to the private API.', + howto: 'export RIGBOX_API_KEY=rb_... then rig deploy (or make the API public: rig app share --app frontend-api --public)', + }); + } + return forward(req, res, new URL(req.url, origin), { 'x-rigbox-key': apiKey }); + } + + // private (default): loopback, no key, never leaves the VM. + return forward(req, res, new URL(req.url, loopbackTarget)); +} + const server = http.createServer((req, res) => { const urlPath = (req.url || '/').split('?')[0]; @@ -34,15 +97,22 @@ const server = http.createServer((req, res) => { return res.end('ok\n'); } + // Lets the page know, on load, whether the internet toggle is usable. + if (urlPath === '/__route_status') { + return sendJson(res, 200, { + apiKeyConfigured: apiKey.length > 0, + deployed: publicApiOrigin(req.headers.host) !== null, + }); + } + + if (urlPath === '/api' || urlPath.startsWith('/api/')) { + return proxyApi(req, res); + } + if (urlPath === '/' || urlPath === '/index.html') { const html = fs.readFileSync(path.join(publicDir, 'index.html'), 'utf-8'); - // Only inject when there's a real override — otherwise the page's - // deriveApiUrl() picks up the sibling backend automatically. - const out = apiUrl - ? html.replace('', `\n`) - : html; res.writeHead(200, { 'content-type': 'text/html' }); - return res.end(out); + return res.end(html); } const filePath = path.join(publicDir, urlPath); @@ -57,5 +127,6 @@ const server = http.createServer((req, res) => { }); server.listen(port, '0.0.0.0', () => { - console.log(`frontend listening on :${port} (api_url=${apiUrl})`); + const key = apiKey ? 'set' : 'unset'; + console.log(`frontend on :${port} — loopback ${loopbackTarget}, internet key ${key}`); }); diff --git a/frontend-plus-api/rig.yaml b/frontend-plus-api/rig.yaml index 9081804..cad5347 100644 --- a/frontend-plus-api/rig.yaml +++ b/frontend-plus-api/rig.yaml @@ -2,11 +2,17 @@ # # `rig deploy` spins up (or attaches to) a workspace, rsyncs each app's # code from its `path`, installs, and brings them up in dependency order. -# Each app gets its own subdomain from its name + the workspace id: -# frontend-api-.rigbox.dev and frontend-web-.rigbox.dev. The -# frontend derives the API origin from its own frontend-web- hostname -# (swapping the prefix to frontend-api-), so no per-deploy templating -# is needed. +# Both apps run as processes on the same workspace VM. +# +# Because frontend-web declares `dependsOn: [frontend-api]`, Rigbox injects +# RIGBOX_FRONTEND_API_URL=http://127.0.0.1:5100 into frontend-web's env. +# server.js proxies /api to that URL over loopback, so the browser stays +# same-origin and frontend-api never needs a public subdomain — it stays +# private (the default). Only frontend-web is opened to the world. +# +# The page has a Private/Internet route toggle: "Internet" makes server.js +# call frontend-api's *public* subdomain instead, authenticated with the +# optional RIGBOX_API_KEY secret (see frontend-web.secrets below). # # cd frontend-plus-api && rig deploy # @@ -46,13 +52,10 @@ apps: health: path: /healthz timeoutSeconds: 30 - params: - - key: api_url - type: url - label: API base URL - description: > - Where the frontend sends /api/todos requests. Leave empty to let - the page derive it from window.location (its sibling subdomain). - group: Wiring - default: "" - envVar: RIGBOX_API_URL + # Optional: powers the page's "Internet" route toggle. Export a Rigbox + # API key (ideally workspace-scoped) before `rig deploy` and server.js + # will authenticate the public-subdomain call with X-Rigbox-Key. Omit it + # and the deploy still succeeds — the toggle just shows how to enable it. + # export RIGBOX_API_KEY=rb_... + secrets: + - { name: RIGBOX_API_KEY, optional: true }