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

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. The browser's call to hostCount becomes a network call. Nothing is annotated: no "use server", no routes, no fetch calls, no API layer.

An app is plain TypeScript and the DOM, as here, or React. Give tcm dev a main function, run in the browser, or a React component, rendered on the server first, server components included (React).

tcm update in another shell hot-reloads the browser. For deploying, see production.

How placement is inferred

tcm view shows where every definition runs, and why.

Node (node:*, process, Buffer, an npm export that does), tcm's server host #tcm/host (the server library's tables, sessions and request), or something that does and can't be called remotely
on the server
the DOM
in the browser
neither
anywhere: 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 code. Used as a value, it does: document.body, new IntersectionObserver(…), x instanceof HTMLElement, typeof window !== "undefined".
  • Source that's only written out doesn't run there. A function only written into text (`(${init})()`, init.toString(), String(init)), for a page or another process to run, doesn't place the code writing it. Called or passed along as well, it counts as usual. A constant written into text counts, since its value was computed.
  • 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"): 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 (packages).

What this guarantees

  • Server code never reaches the browser. Browser code gets stubs in place of server modules, swapped in before they're resolved. Whether an npm export is server code is read from its code.
  • The browser can call only the app's own server functions. The endpoints are exactly the server functions the app calls, listed when tcm dev starts. Anything else, and cross-origin calls, are refused. Two more under tcm start: /__tcm/page renders a page's server components as the page would, to read it before it's gone to, and a server component that renders the same for the same props can be fetched with GET, as it reads no cookies.
  • No client/server version skew. Calls go by hash, and a hash never changes meaning.
  • Testable in one process: under tcm test everything runs locally (testing).

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 becoming {}.

Arguments are checked against the parameter types

A server function the browser can call is a public endpoint, and TypeScript's 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). A call with wrong types, missing or unexpected fields, or too many arguments is refused with a 400 saying what's wrong, before the function runs.

A parameter type that 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").

CSRF

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

Module-level state is placed by who uses it

server code only
one, shared by every client
browser code only
one per tab
both sides
one on the server and one per tab: tcm warns

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

Built from c482a14e