Step 2: Two places your code runs
Give your project a second folder — api/ — for the one thing a browser can't be trusted with.
Everything you've built on this site so far — the notes board, a sheet, a pin board — ran
entirely in the browser. That's fine for data two people edit together. It's not fine for an
API key: anything in src/ ships to every reader's browser, in cleartext, visible in their
network tab.
So a vvd project can have a second folder, next to src/:
oracle-roll/
├── src/
│ └── tool.tsx the UI — runs in the browser
└── api/
└── roll-oracle.ts one endpoint — runs on the server, never ships to the browserEach file in api/ is one endpoint. The file's path is its route —
api/roll-oracle.ts becomes the roll-oracle endpoint, no router to configure. Its mere
presence turns on a second build; there's nothing to switch on.
Here's the whole endpoint:
import { defineHandler } from "@vvd/sdk/server"
export const config = {
fetch: ["www.random.org"], // egress allowlist — exact hostnames, no wildcards
}
export default defineHandler(async (ctx) => {
const res = await ctx.fetch(
"https://www.random.org/integers/?num=1&min=1&max=20&col=1&base=10&format=plain&rnd=new",
)
const n = Number((await res.text()).trim())
return { roll: bandResult(n) }
})
function bandResult(n: number): string {
if (n <= 5) return "No — and it gets worse."
if (n <= 10) return "No, but it's not hopeless."
if (n <= 15) return "Yes, but there's a complication."
return "Yes."
}config is enforcement, not documentation. ctx.fetch can reach www.random.org and
nothing else declared here. Try to call a hostname you didn't list and the request never
leaves the server — the error names the exact line to add.
Why not just fetch() from the component?
A plain fetch("https://www.random.org/integers/...") right there in tool.tsx would
actually work for this particular call — random.org's basic integer generator allows
cross-origin requests with no key. Most APIs worth calling don't: they need a key on every
request, and a key in src/ is a key in every reader's browser. Routing every external call
through api/ means the pattern is identical whether today's call needs a key or not — and
by Step 4, once your group starts streaming sessions and wants a provably-fair roll, this
one will.
Why does my own dev session run this in-process, but a published tool doesn't?Deep dive
Your vvd run session runs api/ in-process — it's your code and your account, so there's
nothing to isolate it from. A published, third-party tool runs the same
defineHandler inside a sandboxed isolate that reveals only that tool's own secrets and
permits outbound requests only to the hosts its config.fetch declared. Same handler,
same code, different provider underneath — the swap is plumbing, never something you write
for.
Still the same oracle roll from Step 1 — nothing about the UI changed yet: