This commit is contained in:
nuno maduro
2026-05-01 22:36:15 +01:00
parent 53db68e005
commit bf48e20880
35 changed files with 21 additions and 923 deletions

View File

@ -16,30 +16,14 @@ final class JsModuleGraph
private const string CACHE_FILE = 'js-module-graph.cache.json';
/** Active warmer subprocess, or null when none is in flight. */
private static ?Process $warmer = null;
/** Fingerprint the warmer was started against — used to detect drift between warm and build. */
private static ?string $warmerFingerprint = null;
/** True when the warmer found a fresh cache and skipped spawning Node. */
private static bool $warmerCacheHit = false;
/** Project root the warmer was launched for. */
private static ?string $warmerProjectRoot = null;
/**
* Kicks off the Node helper in the background, so by the time
* `build()` is called at flush time the result is (usually) already
* sitting on stdout. Idempotent — a second call while a warmer is
* already in flight is a no-op. Cheap when the cache is fresh: it
* checks the fingerprint first and skips the subprocess.
*
* Safe to call from any TIA entry point that will eventually write
* the graph from the main process. Workers must NOT call this — they
* don't flush the graph and would duplicate the Node bootstrap on
* every worker.
*/
public static function warmInBackground(string $projectRoot): void
{
if (self::$warmer instanceof Process || self::$warmerCacheHit) {
@ -76,7 +60,7 @@ final class JsModuleGraph
}
/**
* @return array<string, list<string>> project-relative source path → sorted list of page component names
* @return array<string, list<string>>
*/
public static function build(string $projectRoot): array
{
@ -86,13 +70,6 @@ final class JsModuleGraph
}
/**
* Strict variant — returns null when the Node resolver isn't
* available, so callers can distinguish "Vite says nothing imports
* this file" (empty list) from "we couldn't ask Vite" (null).
*
* Used at replay time when we need to *trust a negative result*
* (i.e., "no page imports this file, so it's orphan, safe to skip").
*
* @return array<string, list<string>>|null
*/
public static function buildStrict(string $projectRoot): ?array
@ -100,22 +77,12 @@ final class JsModuleGraph
return self::resolve($projectRoot);
}
/**
* True when the project looks like a Vite + Node project we can
* ask for a module graph. Gate for callers that want to skip the
* resolver entirely on non-Vite apps.
*/
public static function isApplicable(string $projectRoot): bool
{
if (! self::hasViteConfig($projectRoot)) {
return false;
}
// Both the classic Inertia-Vue (`Pages/`) and the Laravel React
// starter kit (`pages/`) conventions are accepted — projects
// running on a case-sensitive filesystem (Linux CI) get
// exactly one of the two, and we shouldn't refuse to walk the
// graph based on which one it picks.
foreach (['Pages', 'pages'] as $dir) {
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) {
return true;
@ -142,14 +109,7 @@ final class JsModuleGraph
}
}
// Pick up the warmer when it was launched against the same
// fingerprint and project root. Drift between warm and build
// (rare — would require a JS file to change mid-test-run)
// discards the warmer and re-runs synchronously.
if (self::$warmerCacheHit
&& self::$warmerFingerprint === $fingerprint
&& self::$warmerProjectRoot === $projectRoot
&& $fingerprint !== null) {
if (self::$warmerCacheHit && $fingerprint !== null) {
$cached = self::readCache($projectRoot, $fingerprint);
self::$warmerCacheHit = false;
self::$warmerFingerprint = null;
@ -160,60 +120,28 @@ final class JsModuleGraph
}
}
if (self::$warmer instanceof Process
&& self::$warmerFingerprint === $fingerprint
&& self::$warmerProjectRoot === $projectRoot) {
$process = self::$warmer;
self::$warmer = null;
self::$warmerFingerprint = null;
self::$warmerProjectRoot = null;
$process->wait();
if ($process instanceof Process && $process->isSuccessful()) {
$result = self::parseNodeOutput($process->getOutput());
if ($result !== null) {
if ($fingerprint !== null) {
self::writeCache($projectRoot, $fingerprint, $result);
}
return $result;
}
}
} else {
// Different fingerprint or different project root: discard
// any stale warmer before we start a fresh run.
self::reapWarmer();
}
$viaNode = self::runNodeSync($projectRoot);
if ($viaNode !== null && $fingerprint !== null) {
self::writeCache($projectRoot, $fingerprint, $viaNode);
}
return $viaNode;
}
/**
* @return array<string, list<string>>|null
*/
private static function runNodeSync(string $projectRoot): ?array
{
$process = self::buildNodeProcess($projectRoot);
$process = self::$warmer;
self::$warmer = null;
self::$warmerFingerprint = null;
self::$warmerProjectRoot = null;
if (! $process instanceof Process) {
return null;
}
$process->run();
$process->wait();
if (! $process->isSuccessful()) {
return null;
}
return self::parseNodeOutput($process->getOutput());
$result = self::parseNodeOutput($process->getOutput());
if ($result !== null && $fingerprint !== null) {
self::writeCache($projectRoot, $fingerprint, $result);
}
return $result;
}
private static function buildNodeProcess(string $projectRoot): ?Process
@ -238,11 +166,6 @@ final class JsModuleGraph
return null;
}
// Tell the Node helper which casing this project uses for its
// pages directory. The helper defaults to `resources/js/Pages`;
// the Laravel React starter ships lowercase `resources/js/pages`,
// and on a case-sensitive filesystem the helper would otherwise
// walk a non-existent directory and emit an empty module graph.
$env = [];
foreach (['resources/js/Pages', 'resources/js/pages'] as $candidate) {
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
@ -298,10 +221,6 @@ final class JsModuleGraph
return $out;
}
/**
* Stop and discard a leftover warmer subprocess (e.g. on shutdown,
* or when `build()` resolved from cache without needing the warmer).
*/
private static function reapWarmer(): void
{
$process = self::$warmer;