Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 92 additions & 10 deletions frontend-plus-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<ws>.rigbox.dev` and
`frontend-web-<ws>.rigbox.dev`. The web page derives the API origin from its
own `frontend-web-<ws>` hostname (swapping the prefix to `frontend-api-<ws>`),
so cross-app wiring needs no per-deploy templating.
`frontend-web-<ws>.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_<APP_NAME>_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

Expand All @@ -38,8 +116,8 @@ code; the app's spec lives inline under `apps.<name>`.
| 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.

Expand All @@ -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-<ws>.rigbox.dev

# API responds directly.
curl https://frontend-api-<ws>.rigbox.dev/api/todos
# The API is reached *through* the frontend's same-origin /api proxy:
curl https://frontend-web-<ws>.rigbox.dev/api/todos
curl -X POST -H 'content-type: application/json' \
-d '{"title":"first todo"}' \
https://frontend-api-<ws>.rigbox.dev/api/todos
https://frontend-web-<ws>.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-<ws>.rigbox.dev/api/todos
```

## Iterate on the source
Expand Down
120 changes: 94 additions & 26 deletions frontend-plus-api/frontend/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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; }
</style>
</head>
<body>
<h1>frontend-plus-api · todos</h1>
<div class="meta">
API: <code id="api-url"></code>
<div class="meta">API: <code id="api-url">/api</code></div>
<div class="route">
<span>Route:</span>
<span class="seg">
<button id="route-private" aria-pressed="true">Private (loopback)</button>
<button id="route-internet" aria-pressed="false">Internet (public)</button>
</span>
<span class="hint" id="route-hint"></span>
</div>
<form id="new">
<input type="text" id="title" placeholder="add a todo…" autocomplete="off" />
<button type="submit">add</button>
</form>
<ul id="list"></ul>
<div id="toast"></div>

<script>
// rig deploys this app at frontend-web-<ws>.rigbox.dev and its sibling
// backend at frontend-api-<ws>.rigbox.dev (the app names are
// `frontend-web` and `frontend-api`). Deriving the backend host from
// our own location avoids templating the workspace id into a param.
// Both branches end at the `/api` mount point — the backend routes are
// `/api/todos`, `/api/todos/:id`, etc.
function deriveApiUrl() {
const h = location.host;
if (h.startsWith('frontend-web-')) {
return location.protocol + '//' + h.replace(/^frontend-web-/, 'frontend-api-') + '/api';
// The browser always talks to this same origin at /api. server.js proxies
// it to the sibling backend either over the VM's loopback (private) or
// over the public internet with an X-Rigbox-Key header (internet). We just
// tell server.js which route to use via the x-demo-route header.
const apiUrl = '/api';
let routeMode = 'private'; // 'private' | 'internet'
let internetAvailable = false;

const $ = (id) => document.getElementById(id);

let toastTimer;
function toast(html, ms = 7000) {
const el = $('toast');
el.innerHTML = html;
el.style.display = 'block';
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { el.style.display = 'none'; }, ms);
}

function setMode(mode) {
routeMode = mode;
$('route-private').setAttribute('aria-pressed', String(mode === 'private'));
$('route-internet').setAttribute('aria-pressed', String(mode === 'internet'));
$('api-url').textContent = mode === 'internet'
? '/api → frontend-api public subdomain (X-Rigbox-Key)'
: '/api → frontend-api over loopback';
}

// All API calls flow through here so the route header is always attached.
async function api(pathname, opts = {}) {
const headers = { ...(opts.headers || {}), 'x-demo-route': routeMode };
const r = await fetch(apiUrl + pathname, { ...opts, headers });
if (!r.ok) {
// server.js returns a structured JSON error for routing problems.
const body = await r.json().catch(() => ({}));
if (body.error === 'missing_api_key' || body.error === 'internet_unavailable') {
const howto = body.howto ? `<br><code>${body.howto}</code>` : '';
toast(`${body.message}${howto}`);
setMode('private'); // fall back so the app keeps working
}
return null;
}
return '/api';
return r;
}
const apiUrl = window.RIGBOX_API_URL || deriveApiUrl();
document.getElementById('api-url').textContent = apiUrl;

async function fetchTodos() {
const r = await fetch(apiUrl + '/todos');
if (!r.ok) return [];
const r = await api('/todos');
if (!r) return [];
const { todos } = await r.json();
return todos;
}
async function addTodo(title) {
await fetch(apiUrl + '/todos', {
await api('/todos', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title }),
});
}
async function deleteTodo(id) {
await fetch(apiUrl + '/todos/' + id, { method: 'DELETE' });
}
async function deleteTodo(id) { await api('/todos/' + id, { method: 'DELETE' }); }

async function render() {
const todos = await fetchTodos();
const list = document.getElementById('list');
const list = $('list');
list.innerHTML = '';
if (todos.length === 0) {
const li = document.createElement('li');
Expand All @@ -87,16 +131,40 @@ <h1>frontend-plus-api · todos</h1>
list.appendChild(li);
}
}
document.getElementById('new').addEventListener('submit', async (e) => {

$('new').addEventListener('submit', async (e) => {
e.preventDefault();
const i = document.getElementById('title');
const i = $('title');
const v = i.value.trim();
if (!v) return;
await addTodo(v);
i.value = '';
render();
});
render();

$('route-private').onclick = () => { setMode('private'); render(); };
$('route-internet').onclick = () => {
if (!internetAvailable) {
toast('Internet routing needs a Rigbox API key the owner sets:<br>' +
'<code>export RIGBOX_API_KEY=rb_… &nbsp; then &nbsp; rig deploy</code><br>' +
'Or make the API public instead: <code>rig app share --app frontend-api --public</code>');
return;
}
setMode('internet');
render();
};

// Learn whether the internet toggle is usable (key set + deployed host).
(async () => {
try {
const s = await (await fetch('/__route_status')).json();
internetAvailable = !!(s.apiKeyConfigured && s.deployed);
$('route-hint').textContent = internetAvailable
? 'both routes available'
: (s.deployed ? 'internet needs RIGBOX_API_KEY' : 'internet needs deploy');
} catch { /* leave defaults */ }
render();
})();
</script>
</body>
</html>
Loading