Server functions — tcm-ts

Server functions

import { session, setSession, clearSession, rateLimit, HttpError, request } from "#codebase/app/lib/server";

export async function login(name: string, password: string): Promise<string> {
  if (!(await checkPassword(name, password))) throw new HttpError(401, "wrong name or password");
  rateLimit("login:" + request().ip, { max: 5, per: 60 });   // a 429 with Retry-After beyond that
  setSession({ user: name });                    // stored server-side; the cookie is just an id
  return name;
}
export async function me(): Promise<string> {
  const user = session().user;                   // unknown, expired or revoked sessions come back empty
  if (typeof user !== "string") throw new HttpError(401, "sign in first");
  return user;
}

Server functions know who's calling through the server library: tcm lib add tcm server mounts it at <root>.lib.server. Using any of it but HttpError puts a function on the server, with no annotation. The same library has tables and transactions and jobs.

Request context

  • request() gives the method, url, headers (Headers), cookies, client ip (as the socket sees it) and a signal that aborts if the client disconnects. It works at any depth of awaits. Server components get the same context, when fetched and when rendering a page's first paint (React).
  • cookie(name), setCookie(name, value, opts?), deleteCookie(name). Cookies default to HttpOnly and SameSite=Lax, and to Secure over TLS. Options: maxAge, path, httpOnly, secure, sameSite.

Sessions

session(), setSession(data, maxAge?), clearSession() and revokeSessions(match).

  • Session data lives server-side in .tcm/data/sessions.sqlite, apart from the codebase. The cookie holds only an unguessable id.
  • setSession lasts maxAge seconds (30 days by default) and issues a fresh id every time, which blocks session fixation.
  • revokeSessions((s) => s.user === "ada") signs Ada out everywhere and returns how many sessions ended.

Rate limits and errors

  • rateLimit(key, { max, per }) is a sliding window per key (per in seconds). Beyond max it throws a 429 with Retry-After.
  • HttpError(status, message) reaches the browser as a rejected promise carrying status and the message.
  • Other errors are hidden in production. Under tcm start the browser gets "internal error" and the server logs the full stack, so database errors and paths don't leak. tcm dev shows everything.

Streaming

A server function can be an async function*. Browser code iterates it with for await, as if it were local:

  • chunks arrive as they're produced (newline-delimited JSON over one response);
  • the request context, session included, lasts the whole stream;
  • break in the browser aborts the request, which stops the generator on the server.

Plain HTTP endpoints

tcm start <app> --http app.api (or tcm dev) serves every function under app.api at /api/…: app.api.hooks.stripe at /api/hooks/stripe. Use it for webhooks and outside API clients. --http ns=/prefix picks another prefix.

  • A handler takes a standard Request and returns a Response, plain data (sent as JSON) or nothing (204).
  • Names resolve on each request, so tcm mv moves a URL without a restart.
  • These routes skip the CSRF checks. Check signatures (a webhook secret, say) in the handler.

Testing

withRequest(init, fn) runs fn as if answering a request (method, url, headers, cookies, ip), with sessions in memory, and returns its value plus the Set-Cookie headers it produced (testing).

Built from c482a14e