Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Running the Generated Code

Generated code is only as good as what it does when it runs. In this section we'll start a small Vane service with the generated handler, and call it with the generated clients, the way the two SDKs and the console do. The examples need Node.js 22.18 or later, which runs TypeScript files directly by removing their types; the generated code uses no TypeScript feature that needs more than that. The files that generate.sh makes are in the generated directory of the examples (the next section shows the script).

The service's own code

The generated interface says what the service must do; the service's code does it. The real Vane service keeps its flags in PostgreSQL, in its registry module; this one keeps three in an array:

vane.ts:

// The Vane service's remote functions, written by hand against the
// generated interface. The real service keeps its flags in PostgreSQL;
// this one keeps three in an array, so the example needs no database.
import type { Implementation } from "./generated/server.ts";
import type { FlagConfig, FlagStatus } from "./generated/types.ts";

interface Flag extends FlagConfig {
  owners: string[];
  failedChecks: number;
}

const flags: Flag[] = [
  {
    key: "new-checkout", state: "rolling-out", percentage: 20,
    rules: [{ attribute: "country", values: ["nl"], percentage: 100 }],
    owners: ["ada@example.com"], failedChecks: 0,
  },
  { key: "dark-mode", state: "stable", percentage: 100, rules: [], owners: ["grace@example.com"], failedChecks: 0 },
  { key: "beta-banner", state: "paused", percentage: 5, rules: [], owners: ["ada@example.com"], failedChecks: 3 },
];

function find(key: string): Flag {
  const flag = flags.find((flag) => flag.key === key);
  if (flag === undefined) throw new Error(`no flag has the key ${key}`);
  return flag;
}

function checkOwner(flag: Flag, owner: string) {
  if (!flag.owners.includes(owner)) throw new Error(`${owner} doesn't own ${flag.key}`);
}

/** A user's place from 0 to 99 for a flag: a hash of the flag and the user,
    so the user stays in the share while the percentage grows. */
function place(flag: string, user: string): number {
  let hash = 2166136261;
  for (const character of `${flag}/${user}`) {
    hash = Math.imul(hash ^ (character.codePointAt(0) ?? 0), 16777619) >>> 0;
  }
  return hash % 100;
}

function status(flag: Flag): FlagStatus {
  const { key, state, failedChecks } = flag;
  return state === "paused" ? { flag: key, state, failedChecks, retryAfter: 300 } : { flag: key, state, failedChecks };
}

export const vane: Implementation = {
  async getFlags() {
    // What an SDK needs to evaluate a flag: no owners, no counts.
    return { flags: flags.map(({ key, state, percentage, rules }) => ({ key, state, percentage, rules })) };
  },
  async evaluateFlags({ user, attributes }) {
    return {
      evaluations: flags.map((flag) => {
        const rule = flag.rules?.find((rule) =>
          attributes?.some(({ name, value }) => name === rule.attribute && rule.values?.includes(value)));
        return { flag: flag.key, on: place(flag.key, user) < (rule?.percentage ?? flag.percentage) };
      }),
    };
  },
  async getStatus({ flag }) {
    return status(find(flag));
  },
  async rollOutFlag({ flag: key, percentage, owner }) {
    const flag = find(key);
    checkOwner(flag, owner);
    if (flag.state === "paused") throw new Error(`${key} is paused`);
    Object.assign(flag, { state: "rolling-out", percentage, failedChecks: 0 });
    return status(flag);
  },
  async killFlag({ flag: key, owner }) {
    const flag = find(key);
    checkOwner(flag, owner);
    Object.assign(flag, { state: "stable", percentage: 0, rules: [] });
    return { flag: key, killed: true };
  },
};

The parameters need no type annotations: the interface gives them. Mistype a property name, or return a status without its failed checks, and the TypeScript compiler finds it, because the types come from the schema. The service's flags have more than the API shows, an owner list and a count of failed checks, so getFlags copies only the four properties of a FlagConfig. Forget that, and the generated handler's output check refuses the answer with "is not allowed": the service can't hand its owners' addresses to every SDK by mistake.

evaluateFlags is what the frontend SDK asks for. A rule whose attribute and value match the user gives the percentage; otherwise the flag's own percentage does, and place puts the user in the share or out of it, by a hash of the flag and the user, so the same user gets the same answer on every call. The backend SDK does the same evaluation itself, on the flags that getFlags gave it.

The SDKs on top

The generated clients are the transport of the SDKs: they send a call, check what goes and what comes back, and nothing more. What makes an SDK, such as a cache, is written by hand on top of them. Here is a piece of the backend SDK. The Vane protocol says that an SDK sends at most one status request per second for a flag and uses its cached answer in between:

sdk.ts:

// A piece of the backend SDK, written by hand on the generated client:
// it asks the service for the status of a flag at most once a second,
// and uses its cached answer in between, as the protocol asks of an SDK.
import { getStatus } from "./generated/node.ts";
import type { FlagStatus } from "./generated/types.ts";

const answers = new Map<string, { time: number; status: Promise<FlagStatus> }>();

export function flagStatus(flag: string, now: number = Date.now()): Promise<FlagStatus> {
  const answer = answers.get(flag);
  if (answer !== undefined && now - answer.time < 1000) return answer.status;
  const status = getStatus({ flag });
  answers.set(flag, { time: now, status });
  return status;
}

Calling it

demo.ts starts the service on a free port, points the clients at it, and calls the functions as their callers would. To show what the SDK's cache saves, it counts the status requests that the service answers: Implementation is an ordinary interface, so an object that wraps one method of vane is an implementation too. Then it tries two calls that should fail:

demo.ts:

// Starts the service, and calls it the way the SDKs and the console do.
import { createServer } from "node:http";
import { createHandler, type Implementation } from "./generated/server.ts";
import { evaluateFlags } from "./generated/browser.ts";
import { getFlags } from "./generated/node.ts";
import { killFlag, rollOutFlag } from "./generated/console.ts";
import type { EvaluationContext } from "./generated/types.ts";
import { settings } from "./runtime.ts";
import { flagStatus } from "./sdk.ts";
import { vane } from "./vane.ts";

// The service, with a count of the status requests it answers.
let statusRequests = 0;
const service: Implementation = {
  ...vane,
  getStatus(input) {
    statusRequests += 1;
    return vane.getStatus(input);
  },
};

const server = createServer(createHandler(service));
server.listen(0);
await new Promise((resolve) => server.once("listening", resolve));
const address = server.address();
settings.endpoint = `http://localhost:${typeof address === "object" ? address?.port : 0}/api`;

async function show(label: string, result: Promise<unknown>) {
  try {
    console.log(label, JSON.stringify(await result));
  } catch (error) {
    console.log(label, "failed:", (error as Error).message);
  }
}

// The backend SDK downloads every flag; the frontend SDK asks about one user.
const user: EvaluationContext = { environment: "staging", user: "user-7", attributes: [{ name: "country", value: "nl" }] };
await show("getFlags", getFlags({ environment: "staging" }));
await show("evaluateFlags", evaluateFlags(user));

// The backend SDK asks for a status twice; the service answers once.
await show("flagStatus", flagStatus("beta-banner"));
await show("flagStatus", flagStatus("beta-banner"));
console.log("status requests:", statusRequests);

// The console pulls the kill switch, and the flag is off for everyone.
await show("killFlag", killFlag({ flag: "new-checkout", owner: "ada@example.com" }));
await show("evaluateFlags", evaluateFlags(user));

// Values the types allow but the schema doesn't: the client refuses them.
await show("rollOutFlag", rollOutFlag({ flag: "new-checkout", percentage: 150, owner: "ada" }));

// A request that skips the client: the service refuses it.
const response = await fetch(`${settings.endpoint}/killFlag`, {
  method: "POST",
  body: JSON.stringify({ flag: "New Checkout", owner: "ada@example.com", force: true }),
});
console.log("fetch", response.status, await response.text());

server.close();
$ node demo.ts
getFlags {"flags":[{"key":"new-checkout","state":"rolling-out","percentage":20,"rules":[{"attribute":"country","values":["nl"],"percentage":100}]},{"key":"dark-mode","state":"stable","percentage":100,"rules":[]},{"key":"beta-banner","state":"paused","percentage":5,"rules":[]}]}
evaluateFlags {"evaluations":[{"flag":"new-checkout","on":true},{"flag":"dark-mode","on":true},{"flag":"beta-banner","on":false}]}
flagStatus {"flag":"beta-banner","state":"paused","failedChecks":3,"retryAfter":300}
flagStatus {"flag":"beta-banner","state":"paused","failedChecks":3,"retryAfter":300}
status requests: 1
killFlag {"flag":"new-checkout","killed":true}
evaluateFlags {"evaluations":[{"flag":"new-checkout","on":false},{"flag":"dark-mode","on":true},{"flag":"beta-banner","on":false}]}
rollOutFlag failed: input.percentage: must be at most 100; input.owner: must match [^@ ]+@[^@ ]+
fetch 400 {"problems":["input.flag: must match [a-z0-9][a-z0-9-]*","input.force: is not allowed"]}

The backend SDK gets every flag with its rules, but no owners. The user user-7 from the Netherlands gets new-checkout from its country rule and dark-mode from its 100 percent; beta-banner is rolled out to 5 percent, and this user's place isn't in them. The two status calls give the same answer, beta-banner paused after three failed checks, to be asked about again in 300 seconds, and the service answered only one of them. After the console's kill, new-checkout is off for this user too: a kill sets the percentage to 0 and drops the rules.

The next call has the right TypeScript types, strings and a number, but values that the schema's facets refuse, a percentage of 150 and an owner without a domain, and the client stops it before it goes over the network. The last request doesn't use a client at all, as a request from another program wouldn't, and the service refuses it with the same rules, and an answer that says why: a flag key is written in lowercase, and a kill has no force.

The frontend SDK uses browser.ts the same way in a browser page, with settings.endpoint left at /api, and the service runs the same handler, whatever framework serves the requests.