Recipe 10: Deploy a custom provider adapter
Add a new image source to webfetch — proprietary DAM, internal archive, partner API — by writing a 40-line adapter.
webfetch's 24 built-in providers cover public sources. When you have a proprietary source (internal DAM, partner API, a museum collection API no one else has hit) you can add it as a custom adapter without forking the codebase.
#Adapter anatomy
Every provider implements the same interface:
import type { Provider, ImageCandidate, SearchContext } from "@webfetch/core";
export const internalDam: Provider = {
id: "internal-dam",
displayName: "Internal DAM",
defaultLicense: "EDITORIAL_LICENSED",
needsAuth: true,
rateLimitPerSec: 5,
optIn: false,
auth(env) {
return env.INTERNAL_DAM_TOKEN ? { token: env.INTERNAL_DAM_TOKEN } : null;
},
async search(query: string, ctx: SearchContext): Promise<ImageCandidate[]> {
const auth = ctx.auth?.["internal-dam"];
if (!auth?.token) return [];
const r = await fetch(`https://dam.internal/api/v1/search?q=${encodeURIComponent(query)}`, {
headers: { authorization: `Bearer ${auth.token}` },
signal: ctx.signal,
});
if (!r.ok) throw new Error(`DAM: ${r.status}`);
const { hits } = (await r.json()) as { hits: Hit[] };
return hits.map((h) => normalize(h));
},
};
interface Hit {
id: string;
url: string;
thumb: string;
width: number;
height: number;
mime: string;
creator: string;
license: "internal-editorial" | "internal-cc0";
pageUrl: string;
}
function normalize(h: Hit): ImageCandidate {
const license = h.license === "internal-cc0" ? "CC0" : "EDITORIAL_LICENSED";
return {
url: h.url,
thumbnailUrl: h.thumb,
sourcePageUrl: h.pageUrl,
license,
confidence: 0.95,
author: h.creator,
authorUrl: undefined,
attributionLine: `"${h.id}" by ${h.creator} (Internal DAM)`,
provider: "internal-dam",
providerNative: h.id,
width: h.width,
height: h.height,
mime: h.mime,
licenseUrl: undefined,
};
}#Registering the adapter
Two ways:
#A — In-process (same runtime as your code)
If you call @webfetch/core directly, register before the first search:
import { registerProvider, searchImages } from "@webfetch/core";
import { internalDam } from "./providers/internal-dam";
registerProvider(internalDam);
const r = await searchImages("corporate keynote", {
providers: ["internal-dam", "wikimedia"],
});#B — Out-of-process (CLI / MCP / HTTP server)
Create a tiny package that depends on @webfetch/server and your adapter, then registers it at startup. Ship the bundle as your deployment:
// my-webfetch-server/index.ts
import { startServer } from "@webfetch/server";
import { registerProvider } from "@webfetch/core";
import { internalDam } from "./internal-dam";
registerProvider(internalDam);
await startServer({ port: 7777 });Run this instead of the stock server. Your internal users see internal-dam in the providers list and can call it via CLI, HTTP, or MCP like any built-in source.
#Guidelines
- Respect
ctx.signal. Always pass it through tofetchso timeouts propagate. - Return
[]on missing auth, never throw. This keeps the defaultsearchImagescall resilient when some providers aren't configured. - Set confidence conservatively.
0.95only if your source has authoritative license metadata. If you're guessing, use≤ 0.7. - Normalize the license tag into webfetch's seven canonical tags. Don't invent new ones. If your source has a weirder license, map to the closest safe tag and surface the original in a custom field.
- Build
attributionLinein the normalizer. A callers-never-need-to-format-this rule is the whole point. - Respect rate limits. Set
rateLimitPerSecand the core's scheduler will throttle you.
#Testing
import { describe, it, expect } from "bun:test";
import { internalDam } from "./internal-dam";
describe("internal-dam adapter", () => {
it("returns [] when no token configured", async () => {
const r = await internalDam.search("test", { auth: {}, signal: new AbortController().signal });
expect(r).toEqual([]);
});
it("normalizes a hit", async () => {
// mock fetch, assert candidate shape
});
});#When not to write a custom adapter
- If your source is just a restricted Wikimedia mirror, you're better off hitting Wikimedia directly.
- If your source's license model doesn't map to any of webfetch's seven tags, reconsider whether it belongs in this pipeline at all. webfetch's whole value is license uniformity.
- If your source is a generic image search on an already-covered host, use the existing
browserlayer with a custom targeting rule instead of a new adapter.