Skip to content

Get started with TypeScript ​

A bookshop server with four operations, and a client that reads a book, buys a copy and handles a sold-out title. The finished project is examples/typescript. You need Node.js 22 or later.

1. Create the project ​

sh
mkdir bookshop && cd bookshop
npm init -y
npm pkg set type=module
npm install @rayfold/server @rayfold/client @rayfold/explorer jose
npm install --save-dev tsx @rayfold/cli
npm pkg set scripts.server="tsx src/server.ts" scripts.client="tsx src/client.ts" scripts.token="tsx src/token.ts"

2. Describe the API ​

Save the schema as src/bookshop.rayfold:

rayfold
entity Author {
  id: ID
  name: String
}

entity Book @cache(maxAge: 60s, scope: public) {
  id: ID
  title: String
  stock: Int
  author: Author
  """What the shop paid. Only staff can see it."""
  costPrice: Decimal? @allow(read: viewer.role == "staff")
}

error OutOfStock { bookId: ID, available: Int }

event StockChanged { bookId: ID, stock: Int }

query book(id: ID): Book?
query books(page: PageArgs = { first: 20 }): Page<Book>

"""Take copies off the shelf. Fails if there are not enough."""
command buy(bookId: ID, qty: Int = 1 @range(min: 1, max: 10)): Book
  throws OutOfStock
  emits StockChanged
  @allow(write: viewer != null)

command restock(bookId: ID, qty: Int @range(min: 1, max: 1000)): Book
  emits StockChanged
  @allow(write: viewer.role == "staff")

Check it whenever you change it. A mistake is reported with its line and what was probably meant:

sh
npx rayfold check src/bookshop.rayfold

3. Write the resolvers ​

src/resolvers.ts holds the data, its types and the resolvers:

ts
/**
 * The bookshop's data, resolvers and callers. Nothing here needs Node: the playground on the documentation site runs
 * this same file in the browser.
 */
import { RayfoldError, ok, type Resolvers } from "@rayfold/server/core";

export interface Author { id: string; name: string }
export interface Book { id: string; title: string; stock: number; authorId: string; costPrice: string }
export interface Viewer { id: string; role: "customer" | "staff" }

export function seed() {
  return {
    authors: new Map<string, Author>([
      ["a1", { id: "a1", name: "Ursula K. Le Guin" }],
      ["a2", { id: "a2", name: "Frank Herbert" }],
    ]),
    books: new Map<string, Book>([
      ["b1", { id: "b1", title: "A Wizard of Earthsea", stock: 3, authorId: "a1", costPrice: "4.20" }],
      ["b2", { id: "b2", title: "The Left Hand of Darkness", stock: 0, authorId: "a1", costPrice: "5.10" }],
      ["b3", { id: "b3", title: "Dune", stock: 7, authorId: "a2", costPrice: "6.00" }],
    ]),
  };
}
export type Store = ReturnType<typeof seed>;

export function resolvers(store: Store): Resolvers {
  const find = (id: string) => {
    const book = store.books.get(id);
    if (!book) throw new RayfoldError("not_found", `No book ${id}`);
    return book;
  };

  return {
    Query: {
      book: ({ id }: { id: string }) => store.books.get(id) ?? null,
      books: ({ page }: { page: { first: number; after?: string | null } }) => {
        const all = [...store.books.values()];
        const start = page.after ? all.findIndex((b) => b.id === page.after) + 1 : 0;
        const items = all.slice(start, start + page.first);
        return { items, total: all.length, hasMore: start + items.length < all.length, cursor: items.at(-1)?.id ?? null };
      },
    },

    Command: {
      buy: ({ bookId, qty }: { bookId: string; qty: number }) => {
        const book = find(bookId);
        if (book.stock < qty) {
          throw RayfoldError.domain("OutOfStock", { bookId, available: book.stock }, `Only ${book.stock} left`);
        }
        book.stock -= qty;
        return ok(book, { emit: [{ event: "StockChanged", payload: { bookId, stock: book.stock } }] });
      },
      restock: ({ bookId, qty }: { bookId: string; qty: number }) => {
        const book = find(bookId);
        book.stock += qty;
        return ok(book, { emit: [{ event: "StockChanged", payload: { bookId, stock: book.stock } }] });
      },
    },

    // One call per level: a page of 20 books asks for their authors once, not 20 times.
    Book: {
      author: (books: Book[]) => books.map((b) => store.authors.get(b.authorId) ?? null),
    },
  };
}
  • Query and Command have one function per operation, named as in the schema. Arguments arrive already checked against it, @range included.
  • Book.author is a loader. It receives every book in the result at once and returns their authors in the same order, so a page of 50 books looks up authors once.
  • RayfoldError.domain("OutOfStock", ...) is the error the schema declared. The client receives it by name, with its data.
  • ok(book, { emit }) returns the changed book and announces StockChanged. The server turns the book into a patch for every client cache.

4. Say who is calling ​

Your identity provider signs the user in and gives the client an access token (a JWT). src/auth.ts verifies it with jose against the provider's published keys and turns its claims into the viewer:

ts
/**
 * Who is calling: the bearer token the identity provider issued at sign-in, verified here. Nothing in the request is
 * trusted until its signature checks out, so a caller cannot name their own role.
 */
import { RayfoldError } from "@rayfold/server";
import { createRemoteJWKSet, jwtVerify, SignJWT } from "jose";
import type { Viewer } from "./resolvers.ts";

/** Signs development tokens when no identity provider is configured. Never set in production. */
const DEV_SECRET = new TextEncoder().encode("bookshop development key, not a secret");
const DEV_ISSUER = "http://localhost:4000/dev";

// In production, AUTH_JWKS_URL and AUTH_ISSUER name your identity provider (Auth0, Entra ID, Keycloak, Cognito, ...).
// The server fetches its published signing keys, caches them and follows its key rotation.
const jwks = process.env["AUTH_JWKS_URL"] ? createRemoteJWKSet(new URL(process.env["AUTH_JWKS_URL"])) : undefined;
const issuer = jwks ? process.env["AUTH_ISSUER"] : DEV_ISSUER;
if (!issuer) throw new Error("AUTH_ISSUER must be set with AUTH_JWKS_URL: a token from any other issuer is refused");

// checks the signature, the issuer, the audience and the expiry; the claims are read only once all of them pass
const verify = (token: string) =>
  jwks ? jwtVerify(token, jwks, { issuer, audience: "bookshop" }) : jwtVerify(token, DEV_SECRET, { issuer, audience: "bookshop" });

export async function viewerFrom(authorization: string | undefined): Promise<Viewer | null> {
  if (!authorization) return null; // no token: an anonymous caller, who may still read what the schema allows
  const token = /^Bearer (\S+)$/.exec(authorization)?.[1];
  if (!token) throw new RayfoldError("unauthenticated", "Expected Authorization: Bearer <token>");
  try {
    const { payload } = await verify(token);
    // `role` is the claim this provider carries roles in; the schema's rules compare against what it maps to
    return { id: String(payload.sub), role: payload["role"] === "staff" ? "staff" : "customer" };
  } catch {
    throw new RayfoldError("unauthenticated", "Invalid or expired token");
  }
}

/** A token as the identity provider would issue it, signed with the development key: for local runs and tests. */
export function devToken(sub: string, role: Viewer["role"]): Promise<string> {
  return new SignJWT({ role }).setProtectedHeader({ alg: "HS256" }).setSubject(sub).setIssuer(DEV_ISSUER).setAudience("bookshop").setIssuedAt().setExpirationTime("8h").sign(DEV_SECRET);
}

What viewerFrom returns is viewer in the schema's @allow rules. The resolvers never check permissions themselves. Until you have a provider, the server signs and checks tokens with the development key at the top of the file. devToken makes a token as a provider would. src/token.ts prints one for you to try the server with:

ts
// Prints a token signed with the development key, as an identity provider would issue one at sign-in:
//   npm run token -- staff
import { devToken } from "./auth.ts";

const role = process.argv[2] === "staff" ? "staff" : "customer";
console.log(await devToken(role === "staff" ? "s1" : "u1", role));

5. Start the server ​

src/bookshop.ts builds the server from the schema and the resolvers, then serves it at /rayfold with the explorer beside it:

ts
/**
 * The bookshop's HTTP server, with the explorer beside the endpoint. server.ts starts it on port 4000; the tests start
 * the same server on a free port.
 */
import { readFileSync } from "node:fs";
import { createServer, type Server } from "node:http";
import { createExplorerHandler } from "@rayfold/explorer";
import { createHttpHandler, createRayfoldServer, type RayfoldServer } from "@rayfold/server";
import { viewerFrom } from "./auth.ts";
import { resolvers, seed, type Store } from "./resolvers.ts";

export { devToken, viewerFrom } from "./auth.ts";
export { resolvers, seed, type Author, type Book, type Store, type Viewer } from "./resolvers.ts";

const schema = readFileSync(new URL("./bookshop.rayfold", import.meta.url), "utf8");

export function createBookshop(store: Store = seed()): { server: RayfoldServer; store: Store } {
  return { server: createRayfoldServer({ schema, resolvers: resolvers(store) }), store };
}

export function bookshopHttp(server: RayfoldServer): Server {
  const endpoint = createHttpHandler(server, { viewer: (req) => viewerFrom(req.headers.authorization) });
  const explorer = createExplorerHandler({ endpoint: "/rayfold", title: "Bookshop" });

  return createServer((req, res) => {
    if (explorer(req, res)) return;
    if (req.url?.startsWith("/rayfold")) return void endpoint(req, res);
    res.writeHead(404).end();
  });
}

src/server.ts starts it:

ts
import { bookshopHttp, createBookshop } from "./bookshop.ts";

const { server } = createBookshop();

bookshopHttp(server).listen(4000, () => {
  console.log("Rayfold on http://localhost:4000/rayfold");
  console.log("Explorer on http://localhost:4000/rayfold/explorer");
});
sh
npm run server

Open http://localhost:4000/rayfold/explorer to browse the operations and send requests. To try the commands, paste a token into the auth field. npm run token prints a customer's token, and npm run token -- staff prints a staff member's. Or call it with curl, marking the request as a read with rayfold-safe:

sh
curl -s localhost:4000/rayfold -H 'content-type: application/rayfold+json' -H 'rayfold-safe: true' \
  -d '{"rayfold":"0.1","ops":[{"id":1,"op":"book","args":{"id":"b1"},"shape":"{ title stock author { name } }"}]}'

6. Call it from TypeScript ​

src/client.ts:

ts
// Run the server first (npm run server), then: npm run client
import { RayfoldClient, RayfoldClientError, createFetchTransport } from "@rayfold/client";
import { devToken } from "./auth.ts";

interface Book { id: string; title: string; stock: number; author: { name: string } }

// the access token the user's sign-in produced; a local run signs a customer's with the development key
const accessToken = process.env["TOKEN"] ?? (await devToken("u1", "customer"));

const client = new RayfoldClient({
  transport: createFetchTransport({
    url: "http://localhost:4000/rayfold",
    headers: () => ({ authorization: `Bearer ${accessToken}` }),
  }),
});

const book = await client.query<Book>("book", { id: "b1" }, { shape: "{ id title stock author { name } }" });
console.log(`${book.title} by ${book.author.name}: ${book.stock} in stock`);

The shape asks for exactly the fields this code uses. Leave it out and the book's default fields come back.

A command's result updates the client cache, so anything watching that book sees the change without another request:

ts
// The command returns the changed book, and the client cache applies it: no second request.
const stop = client.watch<Book>("book", { id: "b1" }, { shape: "{ id stock }" }, (b) => console.log("stock is now", b.stock));
await client.command("buy", { bookId: "b1", qty: 1 });
stop();

Errors the schema declares arrive typed:

ts
try {
  await client.command("buy", { bookId: "b2" });
} catch (e) {
  if (!(e instanceof RayfoldClientError && e.is("OutOfStock"))) throw e;
  const { available } = e.data as { available: number };
  console.log(`Sold out: ${available} left`);
}

Run it while the server is up:

sh
npm run client

Next ​

Released under the Apache-2.0 license.