next-yak
Version:
next-yak is a CSS-in-JS solution tailored for Next.js that seamlessly combines the expressive power of styled-components syntax with efficient build-time extraction of CSS using Next.js's built-in CSS configuration
135 lines (127 loc) • 5.36 kB
text/typescript
/**
* Turbopack evaluator adapter.
*
* Bridges the low-level `Evaluator` (worker-thread module evaluator) and
* turbopack's loader lifecycle. A single `Evaluator` instance is kept as a
* singleton on `globalThis` because turbopack may create multiple loader
* instances that share the same process — `Symbol.for` keys guarantee all
* instances converge on the same evaluator and modification time map.
*
* Invalidation uses synchronous modification time polling rather than `fs.watch` because
* turbopack can re-run a loader before a filesystem watcher event fires,
* which would cause the evaluator to return stale cached results.
*/
import { statSync } from "node:fs";
import type { Evaluator } from "../isolated-source-eval/evaluator.js";
const EVALUATOR_KEY = Symbol.for("next-yak-evaluator");
const MTIMES_KEY = Symbol.for("next-yak-mtimes");
/**
* A shared `Map` of file paths to their last known modification times, used to detect changes on disk
*
* e.g. bar.ts -> 1680000000000 (Sat Mar 28 2024 12:00:00 GMT+0000)
*
* fs.fileWatch is not used as it might be out of sync with turbopacks own file watching also turbopack
* spawns a loader pool which could cause a lot of file watchers to be created which can cause performance issues and hitting OS limits
*/
const getFileModificationTimes = () =>
((globalThis as typeof globalThis & { [MTIMES_KEY]?: Map<string, number> })[MTIMES_KEY] ??=
new Map<string, number>());
/**
* Returns the shared evaluator, creating it on first call.
*
* Uses `Symbol.for` keys on `globalThis` so every turbopack loader instance
* in the process converges on the same evaluator and modification time map. A
* `beforeExit` handler terminates the worker threads when the process winds
* down.
*/
export const getEvaluatorSingleton = async (): Promise<Evaluator> => {
const processGlobal = globalThis as typeof globalThis & {
[EVALUATOR_KEY]?: Promise<Evaluator>;
};
if (!processGlobal[EVALUATOR_KEY]) {
processGlobal[EVALUATOR_KEY] = import("../isolated-source-eval/index.js").then(
({ createEvaluator }) => createEvaluator(),
);
// Kill the worker threads when the process exits
process.on("beforeExit", async () => {
const evaluator = await processGlobal[EVALUATOR_KEY]!;
await evaluator.dispose();
});
}
return processGlobal[EVALUATOR_KEY]!;
};
/**
* Turbopack runs the loader for the same compilation file-by-file, but multiple files can share the
* same dependency. This function detects which dependencies actually changed
* on disk and invalidates them once — this prevents re-evaluating the same module multiple times in the same compilation
*/
function invalidateDependenciesWithDiskModifications(evaluator: Evaluator): void {
const modificationTimes = getFileModificationTimes();
const modifiedDependencies: string[] = [];
for (const [dep, lastModificationTime] of modificationTimes) {
try {
const currentModificationTime = statSync(dep).mtimeMs;
if (currentModificationTime !== lastModificationTime) {
modifiedDependencies.push(dep);
modificationTimes.set(dep, currentModificationTime);
}
} catch {
modifiedDependencies.push(dep);
modificationTimes.delete(dep);
}
}
if (modifiedDependencies.length > 0) {
evaluator.invalidate(...modifiedDependencies);
}
}
/** Snapshots the current modification times of `deps` so the next evaluation can detect changes. */
function recordModificationTimes(deps: string[]): void {
const modificationTimes = getFileModificationTimes();
for (const dep of deps) {
try {
modificationTimes.set(dep, statSync(dep).mtimeMs);
} catch {
/* file may not exist */
}
}
}
/**
* Creates an evaluator function scoped to a single loader invocation.
*
* Before evaluation, checks whether any previously recorded dependency has
* changed on disk and invalidates the evaluator cache if so. After a
* successful evaluation, records fresh modification times and notifies the caller of
* every transitive dependency via `onDependency`.
*
* @returns A function that evaluates a yak module and returns its exports.
* The returned function accepts:
* - `modulePath` — absolute path to the module to evaluate.
* - `onDependency` — called once per transitive dependency so the loader
* can register file watchers with turbopack.
* @throws If the evaluated module throws at runtime — the original stack
* trace from the worker thread is preserved.
*/
export async function createCompilationEvaluator(): Promise<
(modulePath: string, onDependency?: (dep: string) => void) => Promise<Record<string, unknown>>
> {
const evaluator = await getEvaluatorSingleton();
invalidateDependenciesWithDiskModifications(evaluator);
return async (modulePath, onDependency?) => {
const result = await evaluator.evaluate(modulePath);
if (!result.ok) {
const error = new Error(result.error.message);
if (result.error.stack) {
error.stack = result.error.stack;
}
throw error;
}
// Store times of newly discovered dependencies for next compilation
recordModificationTimes(result.dependencies);
if (onDependency) {
for (const dep of result.dependencies) {
onDependency(dep);
}
}
return result.value;
};
}