Web apps: no network boundary to write — tcm-ts

25. Web apps: no network boundary to write

// tcm: namespace app
import { readFileSync } from "node:fs";

export async function hostCount(): Promise<number> {       // uses node:fs → runs on the server
  return readFileSync("/etc/hosts", "utf8").split("\n").length;
}
export const render = (n: number) => `hosts: ${n}`;        // runs anywhere
export async function main() {                             // uses document → runs in the browser
  document.querySelector("#app")!.textContent = render(await hostCount());
}

tcm dev app.main serves this through Vite 8. Nothing is annotated: there's no "use server", no routes, no fetch calls and no API layer. React components are placed the same way, server components included (React). tcm update in another shell hot-reloads the browser. For production, see production.

25.1 How placement is inferred

tcm view shows the placement of every definition, and why.

  • A definition runs on the server if it uses Node (node:*, process, Buffer, an npm package's export that does), uses tcm's server host #tcm/host (as the server library's tables, sessions and request do), or uses something that does and can't be called remotely.
  • It runs in the browser if it uses the DOM.
  • Otherwise it runs anywhere: pure code runs where it's called.
  • Only code that runs counts. Types are erased: a DOM or Node type named only in types ((el: Element) => …, x as HTMLElement, let n: Node, Array<HTMLDivElement>, b: Buffer, typeof window in a type, a type imported from node:fs) doesn't place the code, or count as what it reaches. Used as a value, it does: document.body, new IntersectionObserver(…), x instanceof HTMLElement, typeof window !== "undefined".
  • A function whose source is only written out (`(${init})()` in a template, init.toString(), String(init)), for a page or another process to run, doesn't run where it's written out: what it needs (the DOM, Node) doesn't place the code writing it, and isn't counted as what that code reaches. Used any other way as well (called, passed along), it counts as usual; and a constant written into text counts, as its value was computed by its code.
  • An async server function is a cut point. Browser code calling it gets a stub that calls it by hash (POST /__tcm/rpc/<hash>), and the code around the call stays in the browser.
  • A synchronous server function used from the browser is an error that names it ("Make app.machine async"), because a network call can't be synchronous.
  • An npm package's export needs the server if its browser build imports Node's modules, through everything it uses: tcm reads packages' code as it reads the codebase's (packages).

25.2 What this guarantees

  • Server code never reaches the browser. The Vite plugin swaps whole server components for stubs before they're resolved. (Whether an npm package's export is server code is read from its code: see above.)
  • The browser can call only the app's own server functions. The endpoint surface is exactly the server functions the app calls, listed when tcm dev starts. Anything else, and cross-origin calls, are refused. (Under tcm start, /__tcm/page renders a page's server components as the page itself would, to read them before it's gone to; and a server component that renders the same for the same props is asked for with GET too, as it reads no cookies.)
  • No client/server version skew. Calls go by hash, as Unison's typed service calls do, and a hash never changes meaning.
  • Testable in one process: under tcm test everything runs locally (testing).

25.3 Plain data crosses the wire

JSON plus Date, Map, Set, BigInt, undefined, NaN/±Infinity and -0. Functions, class instances, symbols and cycles are refused with a message saying what couldn't be sent, rather than silently turning into {}. The browser and the server share one codec (tcmts.cli.codec).

25.4 Arguments are checked against the parameter types

A server function the browser can call is a public endpoint, and TypeScript types are gone at runtime. So tcm derives a runtime schema from each server function's parameters (objects, arrays, tuples, unions, literals, dates, maps, sets, recursive types). Calls with wrong types, missing fields, unexpected extra fields, or too many arguments are refused with a 400 that says what's wrong, before the function runs. What can't arrive as JSON is refused as such: a function, a class instance, or an object that must hold one (node is an object (its parse is a function), which can't be sent over the network). A type reached more than once is described once.

25.5 CSRF

Server functions read cookies, so calls must be same-origin, must not be cross-site (Sec-Fetch-Site), and must send JSON, which rules out plain HTML form posts. HTTP routes are for other callers and skip these checks.

25.6 Module-level state is placed by who uses it

That follows from inference, not annotation, and every app report shows it:

  • Server state: const visits = new Map() used only by server functions has one copy, shared by every client.
  • Browser state: used only by browser code, each tab has its own copy.
  • Used by both sides: the server and every tab silently get separate copies, so tcm warns.

Only fresh mutable objects count (new …, [], {}); as const, frozen and computed values don't. For state that must survive restarts, use a table.

built from c482a14e