Skip to content

6. Reaching the outside

preview calls api.microlink.io — functions have no network by default; `network:` is the whole allowlist.

Functions run in a sandbox with no network by default — fetch to anywhere is refused before a packet leaves. A function that needs the outside declares its hosts in function.yaml, and every request exits through the egress proxy that enforces the list.

preview

Pasting a link into the new-asset form is a chore; preview fetches the page’s title and description so the form can prefill itself:

examples/config/shelf/main/_functions/preview/function.yaml

# Fetches a link's title and description for the new-asset form. This is the
# first function that reaches outside: `network` is the whole allowlist —
# api.microlink.io answers, every other host is refused at the egress. It
# touches no collection, so no `admin:` either.
invoke: "user != nil"
network: [api.microlink.io]

Two things to notice:

  • network: [api.microlink.io] is the whole allowlist — a fetch to any other host is refused at the egress, whatever the code says (Outbound network).
  • No admin: — the function touches no collection, so it gets no database access at all. Declare only what the function needs.

examples/config/shelf/main/_functions/preview/index.ts

// preview: given a URL, ask api.microlink.io for the page's title and
// description. `network: [api.microlink.io]` is the whole allowlist — fetch
// anywhere else and the egress refuses the connection; that's the sandbox
// doing the work, not the code.
import type { Context } from "../lib/types.ts";

export default async function handler(ctx: Context) {
  const { url } = ctx.input as { url: string };

  const res = await fetch(`https://api.microlink.io?url=${encodeURIComponent(url)}`, {
    headers: { accept: "application/json" },
  });
  if (!res.ok) {
    // ctx.error is the caller's answer, so only 4xx exists here; 424 says
    // "the dependency failed", not "the function crashed".
    throw ctx.error(424, "preview_failed", `the preview service answered ${res.status}`);
  }
  const payload = await res.json();
  if (payload.status !== "success") {
    throw ctx.error(424, "preview_failed", payload.error?.message ?? "no metadata for that page");
  }
  const { title, description } = payload.data ?? {};
  return { title: title ?? null, description: description ?? null };
}

The egress does the sandboxing, so the handler is ordinary fetch. ctx.error answers the caller, so it only takes 4xx statuses — the function’s own crash is 500 function_error, never yours to emit. 424 preview_failed says “the dependency failed”; callers shouldn’t see transport errors.

Testing without a network

network: lives outside the function, so a unit test can’t observe it — what it can do is replace globalThis.fetch and check the code around the call:

examples/config/shelf/main/_functions/preview/index.test.ts

// Run with `make functions-testing-test` (Deno; no MongoDB, backd or network).
//
// `network:` is enforced at the egress, outside the function — a unit test
// can't see it. What the test can do is replace globalThis.fetch and check
// the code that surrounds the call.
import { createContext, FunctionError } from "../../../../../../clients/functions-testing/src/index.js";
import handler from "./index.ts";

async function withFetch(fake: (url: string) => Promise<Response>, run: () => Promise<void>) {
  const real = globalThis.fetch;
  globalThis.fetch = fake as typeof fetch;
  try {
    await run();
  } finally {
    globalThis.fetch = real;
  }
}

Deno.test("returns title and description, sending the url to the allowlisted host", async () => {
  let asked = "";
  await withFetch(async (url) => {
    asked = url;
    return new Response(JSON.stringify({ status: "success", data: { title: "backd docs", description: "the manual" } }), { status: 200 });
  }, async () => {
    const { ctx } = createContext({ input: { url: "https://backd.example/docs" } });
    const out = (await handler(ctx as never)) as { title: string | null; description: string | null };
    if (out.title !== "backd docs" || out.description !== "the manual") throw new Error(JSON.stringify(out));
  });
  if (!asked.startsWith("https://api.microlink.io?url=") || !asked.includes(encodeURIComponent("https://backd.example/docs"))) {
    throw new Error(`fetched ${asked}`);
  }
});

Deno.test("a failed fetch becomes 424 preview_failed (ctx.error is 4xx only)", async () => {
  await withFetch(async () => new Response("down", { status: 503 }), async () => {
    const { ctx } = createContext({ input: { url: "https://anything.example" } });
    try {
      await handler(ctx as never);
      throw new Error("should have refused");
    } catch (e) {
      const err = e as FunctionError;
      if (err.status !== 424 || err.code !== "preview_failed") throw e;
    }
  });
});

Try it

Create the four files (the listings above; or fetch them) and rebuild:

fetch the chapter's files (optional — the listings above are the same files)

for f in main/_functions/preview/function.yaml main/_functions/preview/index.ts main/_functions/preview/input.schema.json main/_functions/preview/index.test.ts; do
  mkdir -p "config/shelf/$(dirname "$f")"
  curl -fsSL "https://fernandezvara.github.io/backd/tutorial/config/shelf/$f" -o "config/shelf/$f"
done
docker compose run --rm functions-build && docker compose restart backd

The tests run as in chapter 5 (docker run … deno test config/shelf/main/_functions/preview/).

In the app, the New asset URL field gains a Preview button: paste a link, click — title and notes fill from the page’s metadata. The client call is db.fn('preview', { url }), same as chapter 5.

You should see

  • deno test passes the fetch-stub tests.
  • Preview on https://fernandezvara.github.io/backd/ fills title and notes.
  • A preview call for a page that fails answers 424 preview_failed, not a stack trace.
  • See the allowlist work: change the fetch URL in preview/index.ts to another host (say https://example.com/), rebuild, restart and call it: the answer is 500 function_failed, and docker compose logs backd shows NotCapable: Requires net access to "example.com:443" — the function’s code can say anything, the allowlist in function.yaml decides. Put it back and rebuild.

Next: chapter 7 — functions calling functions: notify becomes an internal: building block.

Edit this page · Last updated 08 Oct 2026