import { mkdir } from "node:fs/promises";
import { availableParallelism } from "node:os";
import colors from "picocolors";
import { getContext } from "../../server/context.mjs";
import { CONFIG_CONTEXT } from "../../server/symbols.mjs";
import { forRoot } from "../../config/index.mjs";
import banner from "./banner.mjs";
import { createSpinner, isInteractive } from "./output-filter.mjs";
import { emitAllArtifacts, formatLogEntry } from "./static-emit.mjs";
import { runMultiProcess } from "./static-coordinator.mjs";
import { setupStaticRender } from "./static-runtime.mjs";
import { pMapStream } from "./p-map-stream.mjs";
import {
buildPathStream,
dedupedPathStream,
validatedPathStream,
} from "./path-source.mjs";
/**
* Static-site generator entry point.
*
* Two modes:
*
* 1. Single-process (concurrency === 1): in-line streaming export.
* Bounded memory via pMapStream + streaming fanout. No fork/IPC
* overhead. Default for tiny exports and the historical baseline.
*
* 2. Multi-process (concurrency > 1): fork N child processes, each
* running its own RSC main thread + SSR worker. Coordinator owns
* the path stream and feeds children one path at a time. Gives
* true CPU parallelism for RSC-bound workloads (Shiki, heavy
* server components). Children write artifacts directly to disk
* and report log entries over IPC — output bytes never cross IPC,
* and the artifact set (HTML, gz/br sidecars, postpone,
* prerender-cache) matches single-process exactly.
*
* The path source is the same in both modes — a single
* `AsyncIterable<ExportPath>` built from `options.exportPaths` and
* `configRoot.export` via `buildPathStream`. Generators are consumed
* lazily so the path list is never materialized.
*/
// Spinner is module-level only because the streaming pipeline updates
// it from many concurrent points. The throttled writer (~20 Hz)
// coalesces tty writes; without it, 24k pages is 24k tty writes.
let ssgSpinner = null;
let ssgFileCount = 0;
let spinnerReportLast = 0;
function spinnerReport(message) {
if (!ssgSpinner) return;
const now = Date.now();
if (now - spinnerReportLast < 50) return;
spinnerReportLast = now;
ssgSpinner.update(message);
}
function reportLogEntry(entry) {
ssgFileCount++;
if (ssgSpinner) {
spinnerReport(`exporting ${entry.normalizedBasename}`);
return;
}
formatLogEntry(entry);
}
// Default export concurrency. Stays modest by default: forking N
// processes has real startup cost, and going past CPU count yields
// nothing for RSC-bound workloads. Users with I/O-bound RSC can raise
// it; users with tiny exports can drop to 1 to avoid fork overhead.
function defaultConcurrency() {
const cpus = availableParallelism();
return Math.max(2, Math.min(cpus - 1, 4));
}
export default async function staticSiteGenerator(root, options) {
// Empty line before banner — preserves the original layout.
console.log();
banner("static", options.dev);
const config = getContext(CONFIG_CONTEXT);
const configRoot = forRoot();
if (!(options.export || configRoot?.export)) {
return;
}
// CLI passes strings; config passes numbers. Coerce defensively.
const rawConcurrency =
options.exportConcurrency ?? configRoot.exportConcurrency;
const concurrency = Math.max(
1,
rawConcurrency != null ? Number(rawConcurrency) : defaultConcurrency()
);
// Build the streaming path source. Lazy: the source generator is
// pulled exactly when a worker / mapper is free. With an async
// generator path source, the full path list is never materialized.
//
// Pipeline order matters:
// 1. validatedPathStream — normalize strings → descriptors, fail-fast
// on missing path/filename. Dedup must run on normalized shapes.
// 2. dedupedPathStream — drop entries we've already emitted, keyed
// on a 128-bit hash of the *entire* descriptor (stably serialized).
// Exact-match means we never skip work that would produce a
// different artifact, only literal duplicates. Off-by-default would
// force every user to opt in for what is almost always desired
// behavior; the soft cap (1M) protects truly unbounded sources
// from unbounded memory.
// 3. counted — count what *will actually be emitted*, so the "no
// paths to export" warning and the final tally reflect reality.
// Dedup is on by default. `false` (or `0`) opts out cleanly — the
// validated stream passes through untouched, no hashing cost, no
// spurious cap warning. Useful as an escape hatch if a source
// legitimately needs duplicate emissions, though I can't think of
// such a case off-hand.
const dedupeLimitRaw =
options.dedupePathsLimit ?? configRoot.dedupePathsLimit ?? 1_000_000;
const dedupeEnabled = dedupeLimitRaw !== false && dedupeLimitRaw !== 0;
let dedupeCount = 0;
let dedupeCapHit = false;
const validated = validatedPathStream(buildPathStream(options, configRoot));
const pathStream = dedupeEnabled
? dedupedPathStream(validated, {
limit: dedupeLimitRaw,
onDuplicate: () => {
dedupeCount++;
},
onCapExceeded: (limit) => {
dedupeCapHit = true;
console.warn(
colors.yellow(
`static export: deduplication cap (${limit} unique paths) reached — ` +
`further duplicates will be emitted without dedup. ` +
`Raise dedupePathsLimit if intentional, otherwise inspect the path source.`
)
);
},
})
: validated;
// Counted view: we still want the "no paths to export" warning, but
// can't precompute it without forcing materialization. Wrap to count
// as we yield — memory cost is O(1).
let pathCount = 0;
async function* counted(stream) {
for await (const p of stream) {
pathCount++;
yield p;
}
}
if (isInteractive()) {
ssgSpinner = createSpinner("exporting...");
ssgFileCount = 0;
spinnerReportLast = 0;
}
// Per-path error reporting matches the original: each render failure
// is printed in red to stderr so the user can see what broke, then
// counted so the orchestrator can throw a single summary line at the
// end and exit the build non-zero. Errors come from two sources — the
// single-process `pMapStream` mapper's try/catch and the multi-process
// coordinator's IPC `render-error` envelope — both routed through
// `onPathError` so output is identical between modes.
let errorCount = 0;
const onPathError = (e) => {
errorCount++;
const message = e?.stack ?? e?.callstack ?? e?.message ?? String(e);
console.error("\n" + colors.red(message));
};
try {
if (concurrency === 1) {
await runSingleProcess({
root,
options,
config,
configRoot,
pathStream: counted(pathStream),
onError: onPathError,
});
} else {
// Multi-process: each child runs the same render pipeline as
// single-process (`setupStaticRender` + `emitAllArtifacts`); the
// coordinator dispatches one path per free child over IPC.
// Output bytes never cross the IPC boundary — the child writes
// every artifact (HTML, `.gz` / `.br`, postpone, prerender-cache)
// to disk itself and reports back only the small log entries.
await runMultiProcess({
root,
options,
config,
configRoot,
pathStream: counted(pathStream),
workerCount: concurrency,
onLog: reportLogEntry,
onError: onPathError,
});