[]previous and next page

Define a server function

Define a server function in the Server functions guide.

For agentsUpdated

serverFn(options, handler) returns a callable. The handler receives the validated input and the request event. The call must be the initializer of an exported binding at the top level of a module, so that the build can find and register it.

The project starts with a note type, a Standard Schema validator and an in-memory data module. src/schema.ts is the Standard Schema implementation; any Standard Schema object works in its place. db.server.ts ends in .server, so the build treats it as server-only (see Keep secrets out of the browser).

src/types.tsts
export interface Note {
  id: string;
  title: string;
  owner: string;
}
src/schema.tsts
import type { StandardSchemaV1, StandardSchemaV1Result } from "@shadow-js/dusk/server";

/** Wraps a check function as a Standard Schema: it returns `{ value }` or `{ issues }`. */
export function schema<T>(
  check: (value: unknown) => StandardSchemaV1Result<T>,
): StandardSchemaV1<T, T> {
  return { "~standard": { version: 1, vendor: "app", validate: check } };
}
src/schemas.tsts
import { schema } from "./schema";

function field(value: unknown, name: string): unknown {
  return typeof value === "object" && value !== null && name in value
    ? Reflect.get(value, name)
    : undefined;
}

export const noteId = schema<{ id: string }>((value) => {
  const id = field(value, "id");
  return typeof id === "string"
    ? { value: { id } }
    : { issues: [{ message: "id must be a string", path: ["id"] }] };
});

export const newNote = schema<{ title: string }>((value) => {
  const title = field(value, "title");
  if (typeof title !== "string" || title.trim() === "") {
    return { issues: [{ message: "title is required", path: ["title"] }] };
  }
  if (title.length > 80) {
    return { issues: [{ message: "title is longer than 80 characters", path: ["title"] }] };
  }
  return { value: { title } };
});
src/server/db.server.tsts
import type { Note } from "../types";

/** A stand-in for a database client. */
export const notes = new Map<string, Note>([
  ["1", { id: "1", title: "First note", owner: "u1" }],
]);
src/server/notes.tsts
import { HttpError, serverFn } from "@shadow-js/dusk/server";
import { newNote, noteId } from "../schemas";
import type { Note } from "../types";
import { notes } from "./db.server";

export const getNote = serverFn({ method: "GET", input: noteId }, async ({ id }): Promise<Note> => {
  const note = notes.get(id);
  if (note === undefined) throw new HttpError(404, "No such note");
  return note;
});

export const addNote = serverFn({ input: newNote }, async ({ title }): Promise<Note> => {
  for (const note of notes.values()) {
    if (note.title === title) throw new HttpError(409, "That title exists", { data: { id: note.id } });
  }
  const note: Note = { id: String(notes.size + 1), title, owner: "anonymous" };
  notes.set(note.id, note);
  return note;
});

export const stats = serverFn({ method: "GET" }, async (): Promise<number> => {
  throw new Error("connection to db.internal:5432 refused");
});

A loader may call a server function. On the server for the first request that is a direct call; in the browser, for later navigations, the same line calls the stub.

src/routes/notes/[id].tsxtsx
import { type LoaderArgs, useLoaderData } from "@shadow-js/router";
import type { JSX } from "@shadow-js/web";
import type { Note } from "../../types";
import { getNote } from "../../server/notes";

export const loader = ({ params }: LoaderArgs<"/notes/:id", unknown>): Promise<Note> =>
  getNote({ id: params.id });

export default function NotePage(): JSX.Element {
  const note = useLoaderData<Note>();
  return <h1>{note()?.title}</h1>;
}

Use onError in the server entry to send unexpected errors to your logger.

error-reporting.tsts
import type { HandlerOptions } from "@shadow-js/dusk/server";

export const options: Omit<HandlerOptions, "manifest"> = {
  security: { allowedHosts: ["app.example.com"] },
  onError(error, info) {
    console.error(info.phase, error);
  },
};

The build finds each function, gives it an id, and registers it at /_dusk/fn/<id>. A server function is also a normal function on the server: await addNote({ title }) inside a loader, an API route or another server function validates the input and runs the handler in the current request. Outside a request there is no event; use invoke(input, event) to pass one explicitly (see Middleware).

The build also gives the server-side function the id and url its browser stub has, so markup rendered on the server can write <form action={fn.url}> for a function defined with form: true (see Form posts). Reading id or url on a function the build did not process, for example in a unit test that imports the module on its own, throws SERVER_FN_NOT_BUILT.

What the build accepts is strict, because it reads the source rather than running it:

  • The options must be an object literal. method must be the literal "GET" or "POST" and form the literal true or false; other keys (input, middleware) may be any expression.
  • export const name = serverFn(...), export default serverFn(...) and const f = serverFn(...); export { f } are found. A serverFn(...) call anywhere else (inside a function, as an argument, not exported) is an error.
  • The name must be a plain identifier; destructuring is rejected.