client-server-boundary
| date | 2026-11-08 |
|---|---|
| author | Andrew Lisowski |
The Client/Server Boundary
Every full-stack app has a line running through it: some code runs on the server, some runs in the browser, and a network sits between them. Most tools make you draw that line yourself.
You might split your code into a server/ and a client/ package.
You might write "use server" and "use client" at the top of files.
You might write an API route, then a fetch to call it, then types to keep the two in step.
All of that is bookkeeping, and it goes wrong in familiar ways.
A secret ends up in the browser bundle.
A file that imports node:fs gets pulled into the client, and the build blows up.
A deploy goes out, and a tab that was already open calls an endpoint that changed shape under it.
In tcm, you don't draw the line. tcm works it out from your code.
Writing it
Here's a whole app:
import { readFileSync } from "node:fs";
export async function hostCount(): Promise<number> {
return readFileSync("/etc/hosts", "utf8").split("\n").length;
}
export const render = (n: number) => `hosts: ${n}`;
export async function main() {
document.querySelector("#app")!.textContent = render(await hostCount());
}
There's no annotation anywhere.
hostCount reads a file, so it runs on the server.
main touches document, so it runs in the browser.
render uses neither, so it runs wherever it's called.
When main calls hostCount, that call goes over the network.
The rules you write by are short:
- Use Node, and your code runs on the server. That's
node:*modules,process,Buffer, an npm package that needs Node, or tcm's server library (tables, sessions, cookies). - Use the DOM, and it runs in the browser.
- Use neither, and it runs anywhere. It ships to whichever side calls it, or both.
- Send plain data. JSON, plus
Date,Map,SetandBigInt. A function or class instance is refused with a message saying what couldn't be sent, rather than quietly turning into{}.
And if you're ever unsure, tcm view tells you where a definition runs and why: "runs on the server: it uses node:fs".
Why this is hard with files
A file is a bag of things. Import one function from it, and you pull in everything the file imports, too. So bundlers have to decide where code runs one file at a time. If a file mixes a server helper with something the browser needs, the tools can't split it. That's why you end up annotating files and sorting them into folders: you're doing the analysis for them.
In tcm, there are no files to split.
Your code lives in a database (a codebase), and each definition is its own row: one function, one constant, one type.
Each row records exactly what that definition uses: other definitions (by hash), globals like document or process,
and the npm exports it imports.
So "where can this run?" is a question about one small node in a graph, and its edges are already written down.
How it works under the hood
1. What each definition uses directly
tcm looks at each definition on its own first.
A definition needs the server if it uses a node: module, a Node global (process, Buffer), tcm's server
library, or an npm export whose browser build still imports Node's modules.
tcm reads that last one from the package's code, so it doesn't rely on a list of known packages.
A definition needs the browser if it uses a DOM global (document, window, IntersectionObserver), a client-side
React API like useState, or handles an event.
Only code that runs counts.
Types are erased, so (el: HTMLElement) => … doesn't put anything in the browser.
el instanceof HTMLElement does.
2. Following the graph
Then the answer spreads along the edges.
If loadConfig uses node:fs, and greeting uses loadConfig, then greeting needs the server too.
tcm keeps the chain, so it can tell you why: "uses app.loadConfig, which uses node:fs".
It repeats until nothing changes, so code that calls itself (or two functions that call each other) gets the right answer too.
3. Planning the app
To build an app, tcm walks the graph from its entry point (main, or a React component).
- Code that can run in the browser goes in the browser bundle, and tcm keeps walking through what it uses.
- When it reaches an async server function, it stops. That function becomes an endpoint, and nothing behind it is followed into the browser.
4. Stubs in place of server code
In the browser build, each endpoint's module is swapped out before the bundler ever resolves it. In its place is a one-line stub:
export const hostCount = remote("<hash of hostCount>", "app.hostCount");
remote sends the arguments to POST /__tcm/rpc/<hash>, and turns the reply (or the error) back into a promise.
The server function's code, and everything it uses, never reaches the browser.
5. The server side
On the server, the only callable functions are the endpoints the plan found. Anything else is refused.
TypeScript's types are gone at runtime, so an endpoint is effectively public input. tcm derives a runtime schema from each server function's parameter types, and checks every call against it. Wrong types, missing fields or extra arguments get a 400 before your function runs. Calls also have to come from the same origin and send JSON, which rules out cross-site form posts.
6. Calls by hash
The browser calls a server function by its hash, not its name or a route.
A hash never changes meaning: if hostCount changes, it gets a new hash.
So deploys don't break open tabs. A tab loaded before a deploy keeps calling the hashes it was built with, and the server keeps those builds' endpoints callable.
The line falls out of the graph
None of this is a separate feature bolted on. Once code is stored as a graph of small definitions, each with its uses written down, the boundary is just a question you can ask the graph.
You write functions. tcm works out which side of the network each one belongs on, and builds the bridge between them.