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