Placement
tcmts.cli.placement.Placement · #ifjuhcue · class
import type { CodebaseCore } from "#codebase/tcmts/cli/codebase";
import { npmFacts } from "#codebase/tcmts/cli/npm";
import {
eventHandler, globalsIn, isClientReactApi, isDomGlobal, isMutableState, namespacedClientApi, NODE_GLOBALS,
packageRefKey, passedRefs, TEST_FRAMEWORK, usesOf,
} from "#codebase/tcmts/cli/placement";
import type { AppPlan, Place } from "#codebase/tcmts/cli/placement";
import type { IR } from "#codebase/tcmts/core/hash";
import { definesFunction, writtenOutRefs } from "#codebase/tcmts/core/ir";
import { tcmModule } from "#codebase/tcmts/core/types";
import type { DefRecord, ExtRef } from "#codebase/tcmts/core/types";
import { installedPeers, npmAlias, parseModule } from "#codebase/tcmts/loader/packages";
import fs from "node:fs";
import { isBuiltin } from "node:module";
import path_1 from "node:path";
export class Placement {
private cb: CodebaseCore;
private serverWhy = new Map<string, string | undefined>();
private browserWhy = new Map<string, string | undefined>();
private npmServerOnly = new Map<string, boolean>();
/** definitions by hash: the store's, or the store plus unsaved ones (a scratch file's tests, say) */
private get: (h: string) => DefRecord | undefined;
constructor(cb: CodebaseCore, get?: (h: string) => DefRecord | undefined) {
this.cb = cb;
// a definition never changes under its hash, and placement asks about the same ones many times over (a large
// codebase's closures overlap): read each once
const read = get ?? ((h: string) => cb.store.getDef(h));
const defs = new Map<string, DefRecord | undefined>();
this.get = (h) => {
if (!defs.has(h)) defs.set(h, read(h));
return defs.get(h);
};
}
private valueDepsOf = new Map<string, string[]>();
private usesCache = new Map<string, string[]>();
private writtenCache = new Map<string, Set<string>>();
private irFacts = new Map<string, { globals: Set<string>; namespaced: string | undefined }>();
/**
* What `h`'s IR says that placement asks of it again and again: the globals it uses, and a client API it reaches
* through a namespace. Read from one parse of it (an IR is tens of kilobytes; the parse isn't kept).
*/
private factsOf(h: string): { globals: Set<string>; namespaced: string | undefined } {
let facts = this.irFacts.get(h);
if (!facts) {
const rec = this.get(h);
const ir = rec ? (JSON.parse(rec.ir) as IR) : undefined;
facts = {
globals: ir ? globalsIn(ir) : new Set(),
namespaced: ir && rec?.jsx ? namespacedClientApi(ir) : undefined,
};
this.irFacts.set(h, facts);
}
return facts;
}
private name(h: string): string {
return this.cb.displayRef(`#${h}`);
}
private valueDeps(h: string): string[] {
// a file is data: what it names ({@link}s) doesn't run
if (this.get(h)?.kind === "file") return [];
let deps = this.valueDepsOf.get(h);
if (!deps)
this.valueDepsOf.set(
h,
(deps = (this.get(h)?.deps ?? [])
.filter((d) => d.pos === "value" && d.ref.startsWith("#"))
.map((d) => d.ref.slice(1))),
);
return deps;
}
/**
* The functions `h` only writes out as source text (`${f}`, `f.toString()`): their code runs wherever that text
* goes (a generated runtime, a script another process runs), not in `h`, so their needs aren't `h`'s.
*/
private writtenOut(h: string): Set<string> {
let written = this.writtenCache.get(h);
if (!written) {
const rec = this.get(h);
// (what writes code out as text is a template's span, `String(f)` or `f.toString()`: an IR with none of them, most,
// isn't parsed)
written = new Set(
rec &&
rec.kind !== "file" &&
/"TemplateSpan"|\["G","String"\]|\["N","toString"\]/.test(rec.ir)
? [...writtenOutRefs(rec.ir)].filter((d) => {
const dep = this.get(d);
return !!dep && dep.kind === "term" && definesFunction(dep.ir);
})
: [],
);
this.writtenCache.set(h, written);
}
return written;
}
/** What `h` uses other than by rendering it as an element: these pull their needs into `h`. */
private usedBy(h: string): string[] {
let uses = this.usesCache.get(h);
if (!uses) {
const rec = this.get(h);
const written = this.writtenOut(h);
this.usesCache.set(
h,
(uses =
rec && rec.kind !== "file" ? [...usesOf(rec, h)].filter((d) => !written.has(d)) : []),
);
}
return uses;
}
/**
* Whether a test runs in a browser: it, or anything it uses or renders, needs one (a component with state, the
* DOM). Rendering counts here, unlike for placement: a test that renders a browser component runs it.
*/
testNeedsBrowser(h: string): boolean {
return this.testBrowserWhy(h) !== undefined;
}
/**
* Why a test runs in a browser, if it does: what it uses or renders that needs one, through what ("uses
* lib.Chart, which uses document").
*/
testBrowserWhy(h: string): string | undefined {
const seen = new Set<string>();
const from = new Map<string, string>();
const stack = [h];
while (stack.length) {
const x = stack.pop()!;
if (seen.has(x)) continue;
seen.add(x);
const why = this.needsBrowser(x);
if (why) {
const path: string[] = [];
for (let y = x; y !== h; y = from.get(y)!) path.unshift(y);
return [...path.map((y) => `uses ${this.name(y)}, which`), why].join(" ");
}
// stop at what the browser would call across the network: it runs on the server
if (x !== h && this.needsServer(x) && this.crossable(x) && !this.isComponent(x)) continue;
const written = this.writtenOut(x);
for (const d of this.valueDeps(x)) {
if (written.has(d) || seen.has(d)) continue;
if (!from.has(d)) from.set(d, x);
stack.push(d);
}
}
return undefined;
}
/** A React component: a function (or class) with JSX in it, named like one (`Counter`). */
isComponent(h: string): boolean {
const rec = this.get(h);
return (
!!rec &&
(rec.kind === "term" || rec.kind === "class") &&
!!rec.jsx &&
/^[A-Z]/.test(rec.nameHint)
);
}
/**
* `const Theme = createContext(…)` or `const Chart = lazy(…)`: objects a server component's tree sends to the
* browser by hash (a provider, a code-split component), so the browser needs them.
*/
isSentObject(h: string): boolean {
const rec = this.get(h);
return (
!!rec &&
rec.kind === "term" &&
rec.spans.some(
(s) =>
s.ext &&
s.ext.module.startsWith("npm:react@") &&
(s.ext.export === "createContext" || s.ext.export === "lazy"),
)
);
}
/** Renders on the server, so the browser fetches its rendered tree: needs the server, or is async. */
isServerComponent(h: string): boolean {
return this.isComponent(h) && !!this.needsServer(h) && !this.needsBrowser(h);
}
private isBrowserComponent(h: string): boolean {
return this.isComponent(h) && !!this.needsBrowser(h) && !this.needsServer(h);
}
/**
* Why an npm package's export needs the server, if it does: worked out from its code, as the codebase's own is
* (`npmFacts`): its browser build (the `browser` condition, or its only one), through everything it uses, imports
* Node's modules. Where that can't be followed (a computed import), the package needs the server if it doesn't
* declare a browser build and its entry imports Node's modules (`isServerPackage`).
*/
private serverExport(ext: ExtRef): string | undefined {
// (the test framework runs wherever a test does; React is tcm's, the same on both sides)
if (TEST_FRAMEWORK.test(ext.module) || /^npm:react(-dom)?@/.test(ext.module)) return undefined;
const facts = npmFacts(this.cb.lay.cacheDir, ext, "browser");
const pkg = ext.module.slice(4).replace(/@[^@/]*$/, "");
// (Node's modules: a browser build's use of its globals, `Buffer`, `global`, is guarded or filled in, as a rule)
const modules = facts?.node.filter((n) => n.startsWith("node:")) ?? [];
if (facts && !facts.opaque.length)
return modules.length
? `uses ${pkg}'s ${ext.export === "*" ? "exports" : ext.export}, which uses ${modules[0]}`
: undefined;
return this.isServerPackage(ext.module)
? `uses ${ext.module.slice(4)}, a server-side package`
: undefined;
}
/**
* An npm package needs the server if it doesn't declare a browser build and its entry imports Node builtins.
* A heuristic: packages are installed into .tcm/cache/node_modules under their pinned versions.
*/
private isServerPackage(module: string): boolean {
// the test framework runs wherever a test does (Vitest has a Node and a browser side)
if (TEST_FRAMEWORK.test(module)) return false;
if (this.npmServerOnly.has(module)) return this.npmServerOnly.get(module)!;
let result = false;
const dir = path_1.join(this.cb.lay.cacheDir, "node_modules", npmAlias(module));
try {
const pkg = JSON.parse(fs.readFileSync(path_1.join(dir, "package.json"), "utf8")) as Record<
string,
unknown
>;
const exportsText = JSON.stringify(pkg.exports ?? "");
if (!pkg.browser && !exportsText.includes('"browser"')) {
const exp = pkg.exports as Record<string, unknown> | string | undefined;
const dot = typeof exp === "string" ? exp : (exp?.["."] ?? exp);
const pick = (e: unknown): string | undefined =>
typeof e === "string"
? e
: e && typeof e === "object"
? pick(
(e as Record<string, unknown>).import ??
(e as Record<string, unknown>).default ??
(e as Record<string, unknown>).node ??
(e as Record<string, unknown>).require,
)
: undefined;
const entry =
pick(dot) ??
(pkg.module as string | undefined) ??
(pkg.main as string | undefined) ??
"index.js";
const text = fs.readFileSync(path_1.join(dir, entry), "utf8");
const specs = [
...text.matchAll(/(?:require\(|from\s+|import\s*\()\s*["']([^"']+)["']/g),
].map((m) => m[1]!);
result = specs.some((s) => isBuiltin(s));
}
} catch {
result = false;
}
this.npmServerOnly.set(module, result);
return result;
}
private directServer(h: string): string | undefined {
const rec = this.get(h);
if (!rec || rec.kind === "type") return undefined;
for (const s of rec.spans) {
if (!s.ext || s.pos !== "value") continue;
if (s.ext.module.startsWith("node:")) return `uses ${s.ext.module}`;
const own = tcmModule(s.ext.module);
if (own) return `uses ${own.spec} (${s.ext.export})`;
if (s.ext.module.startsWith("npm:")) {
const why = this.serverExport(s.ext);
if (why) return why;
}
}
for (const g of this.factsOf(h).globals) if (NODE_GLOBALS.has(g)) return `uses ${g}`;
// React can't render an async component in the browser: it renders on the server, and its tree is sent
if (this.isComponent(h) && /^(<[^>]*>)?\(.*\) => Promise<.*>$/s.test(rec.signature))
return "is an async component";
return undefined;
}
private directBrowser(h: string): string | undefined {
const rec = this.get(h);
if (!rec || rec.kind === "type") return undefined;
for (const g of this.factsOf(h).globals) if (isDomGlobal(g)) return `uses ${g}`;
for (const s of rec.spans)
if (s.ext && s.pos === "value" && isClientReactApi(s.ext)) return `uses ${s.ext.export}`;
const { namespaced } = this.factsOf(h);
if (namespaced) return `uses ${namespaced}`;
// a class component's instance holds its state
if (rec.kind === "class" && this.isComponent(h)) return "is a class component";
const handler = rec.jsx
? eventHandler(
JSON.parse(rec.ir) as IR,
(d) => !!this.needsServer(d) && this.crossable(d) && !this.isComponent(d),
)
: undefined;
if (handler) return `handles ${handler}`;
return undefined;
}
/**
* Callable across the network: an async function (its callers already expect a promise) or an async generator
* (they already expect to await each item).
*/
crossable(h: string): boolean {
const rec = this.get(h);
return (
!!rec &&
rec.kind === "term" &&
/^(<[^>]*>)?\(.*\) => (Promise|AsyncGenerator|AsyncIterable|AsyncIterableIterator)<.*>$/s.test(
rec.signature,
)
);
}
/** An async generator: streamed to the browser chunk by chunk. */
streams(h: string): boolean {
const rec = this.get(h);
return (
!!rec &&
/^(<[^>]*>)?\(.*\) => (AsyncGenerator|AsyncIterable|AsyncIterableIterator)<.*>$/s.test(
rec.signature,
)
);
}
private closure(h: string): string[] {
const seen = new Set<string>();
const stack = [h];
while (stack.length) {
const x = stack.pop()!;
if (seen.has(x)) continue;
seen.add(x);
stack.push(...this.valueDeps(x));
}
return [...seen];
}
/** Fixpoint over `h`'s closure, so cycles get the right answer. */
private solve(
h: string,
memo: Map<string, string | undefined>,
direct: (x: string) => string | undefined,
propagates: (dep: string) => boolean,
) {
if (memo.has(h)) return;
const nodes = this.closure(h).filter((x) => !memo.has(x));
for (const x of nodes) memo.set(x, direct(x));
for (let changed = true; changed;) {
changed = false;
for (const x of nodes) {
if (memo.get(x)) continue;
const dep = this.usedBy(x).find((d) => memo.get(d) && propagates(d));
if (dep) {
memo.set(x, `uses ${this.name(dep)}, which ${memo.get(dep)!}`);
changed = true;
}
}
}
}
/** Why `h` must run on the server, if it must. Calls to async server functions don't count: they cross. */
needsServer(h: string): string | undefined {
this.solve(
h,
this.serverWhy,
(x) => this.directServer(x),
(d) => !this.crossable(d),
);
return this.serverWhy.get(h);
}
/** Why `h` must run in the browser, if it must. */
needsBrowser(h: string): string | undefined {
this.solve(
h,
this.browserWhy,
(x) => this.directBrowser(x),
() => true,
);
return this.browserWhy.get(h);
}
placeOf(h: string): { place: Place; why?: string; conflict?: string } {
const server = this.needsServer(h);
const browser = this.needsBrowser(h);
if (server && browser) return { place: "server", why: server, conflict: `it also ${browser}` };
if (server) return { place: "server", why: server };
if (browser) return { place: "browser", why: browser };
return { place: "anywhere" };
}
/** Where `h` runs and why, as a sentence ("runs on the server: it uses node:fs"); components "render". */
describe(h: string): string {
const p = this.placeOf(h);
const component = this.isComponent(h);
const verb = component ? "renders" : "runs";
if (p.place === "anywhere")
return component
? "renders anywhere: on the server inside server components, in the browser otherwise"
: "runs anywhere";
const where = `${verb} ${p.place === "server" ? "on the server" : "in the browser"}: it ${p.why}${p.conflict ? `; but ${p.conflict}` : ""}`;
if (p.place === "server" && component && !p.conflict)
return `${where}; the browser fetches its rendered tree by hash`;
if (p.place === "server" && this.crossable(h)) return `${where}; browser code calls it by hash`;
return where;
}
/** `runs on the server`, `renders in the browser`, or nothing for code that runs anywhere: for short labels. */
label(h: string): string {
const p = this.placeOf(h);
if (p.place === "anywhere") return "";
return `${this.isComponent(h) ? "renders" : "runs"} ${p.place === "server" ? "on the server" : "in the browser"}`;
}
/** The sync server dependencies that pull `h` onto the server: `[dep, its dep, ..., the one using Node]`. */
private culprit(h: string): string[] {
if (this.directServer(h)) return [];
const d = this.usedBy(h).find((x) => this.needsServer(x) && !this.crossable(x));
return d ? [d, ...this.culprit(d)] : [];
}
/** Explain why browser code `h` got pulled onto the server, naming the function to make async. */
private explainPull(h: string, via: string): string {
const chain = this.culprit(h);
if (!chain.length && this.isComponent(h)) {
return `${via}: ${this.name(h)} renders on the server (it ${this.directServer(h)}) but ${this.needsBrowser(h)}, which only works in the browser; move that part into a component of its own, and render it from ${this.name(h)}.`;
}
if (!chain.length)
return `${via}: ${this.name(h)} uses Node (it ${this.directServer(h)}) and the DOM (it ${this.needsBrowser(h)}); split it into a server part and a browser part.`;
const sync = chain[0]!;
const path = chain.map((x) => this.name(x)).join(" → ");
return (
`${via}: ${this.name(h)} needs the browser (it ${this.needsBrowser(h)}) but calls ${this.name(sync)}, which runs on the server ` +
`(${path} ${this.directServer(chain.at(-1)!)}) and isn't async, so it can't be called across the network. Make ${this.name(sync)} async.`
);
}
/**
* One page loads one copy of each package range. Two ranges of a package that others build against (React,
* with React DOM as its peer) can't share a page: an error. Two ranges of anything else just ship both.
*/
private checkPackageRanges(plan: AppPlan) {
const users = new Map<string, Map<string, string[]>>();
for (const h of plan.browser) {
const rec = this.get(h);
if (!rec) continue;
const modules = [
...rec.spans.flatMap((s) =>
s.ext?.module.startsWith("npm:") && s.pos === "value" ? [s.ext.module] : [],
),
...(rec.jsx?.startsWith("npm:") ? [rec.jsx] : []),
];
for (const m of new Set(modules)) {
const { name, version: range } = parseModule(m);
const byRange = users.get(name) ?? new Map<string, string[]>();
users.set(name, byRange);
byRange.set(range, [...(byRange.get(range) ?? []), this.name(h)]);
}
}
for (const [name, byRange] of users) {
if (byRange.size < 2) continue;
const which = [...byRange]
.map(
([range, defs]) =>
`${name} ${range} (${defs.slice(0, 3).join(", ")}${defs.length > 3 ? ", …" : ""})`,
)
.join(" and ");
const peered = this.cb.store
.lockedPackages()
.some((l) => name in installedPeers(this.cb.lay, `npm:${l.name}@${l.range}`));
if (peered)
plan.errors.push(
`the app uses ${which}; one page can only run one ${name}, since other packages are built against it. Move them to one range: \`tcm upgrade ${name}@<range> <namespace>\`.`,
);
else plan.warnings.push(`the app ships ${which}`);
}
}
/** What a browser app rooted at `entry` ships, which server functions it calls, and what's wrong with it. */
planApp(entry: string): AppPlan {
const plan: AppPlan = {
browser: new Set(),
components: new Set(),
clientRefs: new Set(),
refsOf: new Map(),
packageRefsOf: new Map(),
packageImports: new Map(),
endpoints: new Set(),
serverRefs: new Set(),
stubbed: new Set(),
streaming: new Set(),
errors: [],
warnings: [],
state: { server: [], browser: [], both: [] },
};
// a server component's tree, followed on the server: browser components it renders become references, and
// their code ships to the browser
const serverTree = (root: string, chain: string[]) => {
const refs = new Set<string>();
plan.refsOf.set(root, refs);
const packageRefs = new Map<string, ExtRef>();
plan.packageRefsOf.set(root, packageRefs);
const seen = new Set<string>();
const walk = (x: string) => {
if (seen.has(x)) return;
seen.add(x);
// a server function a component of the tree gives away (a prop, `.bind(null, id)`): the browser calls it.
// (Only components: other server code passing one along, to a transaction say, keeps it on the server.)
const rec = this.get(x);
// a package's browser component it renders (one that says "use client", or uses hooks): a reference, which the
// browser renders
if (rec && this.isComponent(x))
for (const s of rec.spans) {
if (
!s.ext ||
s.pos !== "value" ||
!s.ext.module.startsWith("npm:") ||
/^npm:react(-dom)?@/.test(s.ext.module)
)
continue;
if (
TEST_FRAMEWORK.test(s.ext.module) ||
!npmFacts(this.cb.lay.cacheDir, s.ext, "browser")?.client.length
)
continue;
const key = packageRefKey(s.ext);
packageRefs.set(key, s.ext);
let imports = plan.packageImports.get(rec.componentHash);
if (!imports) plan.packageImports.set(rec.componentHash, (imports = new Set()));
imports.add(key);
}
if (rec && this.isComponent(x))
for (const d of passedRefs(rec.ir))
if (
this.needsServer(d) &&
this.crossable(d) &&
!this.isComponent(d) &&
!this.streams(d)
) {
plan.serverRefs.add(d);
plan.endpoints.add(d);
}
for (const d of this.valueDeps(x)) {
// a context the tree provides (the browser components under it read the same object), or a lazy
// component (it loads in the browser)
if (this.isSentObject(d)) {
refs.add(d);
visit(d, [...chain, this.name(root)]);
continue;
}
if (this.isBrowserComponent(d)) {
refs.add(d);
plan.clientRefs.add(d);
visit(d, [...chain, this.name(root)]);
} else walk(d);
}
};
walk(root);
};
const visit = (h: string, chain: string[]) => {
if (plan.browser.has(h) || plan.endpoints.has(h)) return;
const via = [...chain, this.name(h)].join(" → ");
const server = this.needsServer(h);
if (server && this.isServerComponent(h)) {
plan.endpoints.add(h);
plan.components.add(h);
plan.stubbed.add(this.get(h)!.componentHash);
serverTree(h, chain);
return;
}
if (server) {
if (!this.crossable(h)) {
plan.errors.push(
this.needsBrowser(h)
? this.explainPull(h, via)
: `${via}: ${this.name(h)} runs on the server (it ${server}) but isn't async, so the browser can't call it across the network. Make it async.`,
);
return;
}
if (this.needsBrowser(h)) {
plan.errors.push(this.explainPull(h, via));
return;
}
plan.endpoints.add(h);
if (this.streams(h)) plan.streaming.add(h);
plan.stubbed.add(this.get(h)!.componentHash);
return;
}
plan.browser.add(h);
for (const d of this.valueDeps(h)) visit(d, [...chain, this.name(h)]);
};
if (this.needsServer(entry) && !this.crossable(entry) && !this.isServerComponent(entry)) {
plan.errors.push(
`${this.name(entry)} runs on the server (it ${this.needsServer(entry)}), so it can't be the browser app's entry point.`,
);
return plan;
}
visit(entry, []);
// where module-level state lives follows from which side's code uses it
const serverSide = new Set([...plan.endpoints].flatMap((h) => this.closure(h)));
for (const h of new Set([...plan.browser, ...serverSide])) {
const rec = this.get(h);
if (!rec || rec.kind !== "term" || !isMutableState(JSON.parse(rec.ir) as IR)) continue;
const [b, s] = [plan.browser.has(h), serverSide.has(h)];
(b && s ? plan.state.both : b ? plan.state.browser : plan.state.server).push(h);
}
this.checkPackageRanges(plan);
// members of one component ship together, so a component can't be half browser, half stub
for (const c of plan.stubbed) {
const local = this.cb.store.componentMembers(c).filter((m) => plan.browser.has(m.hash));
if (local.length)
plan.errors.push(
`${local.map((m) => this.name(m.hash)).join(", ")} are mutually recursive with server code, so they can't run in the browser.`,
);
}
return plan;
}
}
uses
used by