From 20b506cbb5294e6617eeb80d12677b607f8e9abb Mon Sep 17 00:00:00 2001 From: jona62 Date: Sat, 30 May 2026 18:33:54 -0400 Subject: [PATCH 1/2] feat(frontend-plus-api): use inter-app service discovery + server-side proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit frontend-web now proxies /api to its sibling over loopback using the RIGBOX_FRONTEND_API_URL the platform injects from `dependsOn: [frontend-api]` (localhost fallback for local dev). The browser stays same-origin, so the API needs no CORS and frontend-api stays private — only frontend-web is shared public. Replaces the old cross-origin browser fetch that failed with "CORS request did not succeed" against the private API. --- frontend-plus-api/README.md | 53 +++++++++++++++---- frontend-plus-api/frontend/public/index.html | 21 +++----- frontend-plus-api/frontend/server.js | 55 +++++++++++++------- frontend-plus-api/rig.yaml | 22 +++----- 4 files changed, 93 insertions(+), 58 deletions(-) diff --git a/frontend-plus-api/README.md b/frontend-plus-api/README.md index df65f10..e144194 100644 --- a/frontend-plus-api/README.md +++ b/frontend-plus-api/README.md @@ -22,12 +22,41 @@ 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 +``` + +> 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. ## What's in the box @@ -38,8 +67,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 +78,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..b2cb7b9 100644 --- a/frontend-plus-api/frontend/public/index.html +++ b/frontend-plus-api/frontend/public/index.html @@ -31,21 +31,12 @@

frontend-plus-api · todos

    \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 +76,5 @@ const server = http.createServer((req, res) => { }); server.listen(port, '0.0.0.0', () => { - console.log(`frontend listening on :${port} (api_url=${apiUrl})`); + console.log(`frontend listening on :${port} (proxying /api -> ${apiTarget})`); }); diff --git a/frontend-plus-api/rig.yaml b/frontend-plus-api/rig.yaml index 9081804..5849e7a 100644 --- a/frontend-plus-api/rig.yaml +++ b/frontend-plus-api/rig.yaml @@ -2,11 +2,13 @@ # # `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. # # cd frontend-plus-api && rig deploy # @@ -46,13 +48,3 @@ 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 From 6812e1756d40e53b3c9e2048458a163cb478316a Mon Sep 17 00:00:00 2001 From: jona62 Date: Sat, 30 May 2026 23:15:24 -0400 Subject: [PATCH 2/2] feat(frontend-plus-api): add a Private/Internet route toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page now toggles how server.js reaches the backend: - Private (default): proxy /api over the VM loopback (no creds, unmetered). - Internet: proxy /api to frontend-api's PUBLIC subdomain, authenticated server-side with X-Rigbox-Key from an optional RIGBOX_API_KEY deploy secret — exercising the public/metered path while the API stays private. The key is an optional secret (`secrets: [{ name: RIGBOX_API_KEY, optional: true }]`), so deploy succeeds with or without it; when unset the toggle shows a toast with instructions and the app stays on the loopback route. The key never reaches the browser. README documents the toggle, the owner-key requirement, the workspace-scoped-key recommendation, and the `rig app share --public` keyless alternative. Requires CLI >= 0.12.33. --- frontend-plus-api/README.md | 49 ++++++++ frontend-plus-api/frontend/public/index.html | 113 ++++++++++++++++--- frontend-plus-api/frontend/server.js | 106 ++++++++++++----- frontend-plus-api/rig.yaml | 11 ++ 4 files changed, 234 insertions(+), 45 deletions(-) diff --git a/frontend-plus-api/README.md b/frontend-plus-api/README.md index e144194..366aed6 100644 --- a/frontend-plus-api/README.md +++ b/frontend-plus-api/README.md @@ -52,12 +52,61 @@ Apps deploy **private** by default, so open just the public entry point — 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 One root `rig.yaml` holds the whole spec — a `workspace:` block (base image diff --git a/frontend-plus-api/frontend/public/index.html b/frontend-plus-api/frontend/public/index.html index b2cb7b9..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,46 +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 329d28f..c553b42 100644 --- a/frontend-plus-api/frontend/server.js +++ b/frontend-plus-api/frontend/server.js @@ -1,20 +1,28 @@ -// Static-file server that proxies /api to the sibling backend. +// Static-file server that proxies /api to the sibling backend, with a +// runtime toggle between two routes: // -// Rigbox injects RIGBOX_FRONTEND_API_URL=http://127.0.0.1: into this -// app's environment because rig.yaml declares `dependsOn: [frontend-api]` — -// co-located apps share the workspace VM's loopback interface, so the -// backend is reachable at 127.0.0.1 regardless of its (private) external -// visibility. We proxy /api server-side, so the browser only ever talks to -// this same origin: no CORS, and the API never needs to be made public. +// 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. // -// The localhost fallback makes `node server.js` work in local dev too, -// where the platform var isn't set. +// 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); -const apiTarget = process.env.RIGBOX_FRONTEND_API_URL || 'http://127.0.0.1:5100'; +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 = { @@ -27,25 +35,60 @@ const mime = { '.ico': 'image/x-icon', }; -// Forward /api/* to the backend over loopback, streaming both ways. -function proxyToApi(req, res) { - const target = new URL(req.url, apiTarget); - const headers = { ...req.headers, host: target.host }; - const upstream = http.request( - target, - { method: req.method, headers }, - (up) => { - res.writeHead(up.statusCode || 502, up.headers); - up.pipe(res); - }, - ); - upstream.on('error', () => { - res.writeHead(502, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ error: 'backend unreachable', target: apiTarget })); +// 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]; @@ -54,8 +97,16 @@ 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 proxyToApi(req, res); + return proxyApi(req, res); } if (urlPath === '/' || urlPath === '/index.html') { @@ -76,5 +127,6 @@ const server = http.createServer((req, res) => { }); server.listen(port, '0.0.0.0', () => { - console.log(`frontend listening on :${port} (proxying /api -> ${apiTarget})`); + 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 5849e7a..cad5347 100644 --- a/frontend-plus-api/rig.yaml +++ b/frontend-plus-api/rig.yaml @@ -10,6 +10,10 @@ # 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 # # Redeploy just one app: rig deploy --app frontend-web @@ -48,3 +52,10 @@ apps: health: path: /healthz timeoutSeconds: 30 + # 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 }