From 7f70ef3cc8b43d0a343760aa451a0242c7bac028 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 25 Jul 2026 10:52:25 +0200 Subject: [PATCH 1/2] Prevent stale pod-local framework bundles from breaking SSR Distributed transform entries can outlive the pod-local framework artifacts they reference. Validate those local dependencies inside the existing transform singleflight so one caller repairs the graph and followers share the repaired result. Constraint: Distributed transform code is portable while framework vfmod artifacts remain pod-local Rejected: Retry the failed import | it reuses the same stale outer transform Rejected: Distribute every framework artifact | broadens storage and versioning beyond the cache invariant Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep runtime dependency validation inside transform singleflight Tested: 88 focused steps; verify:quick; test:unit 2663 passed/0 failed; npm build; six-route consumer SSR smoke with 12-way burst Not-tested: Authenticated cloud behavior awaits release and deployment --- deno.json | 2 +- .../module-transform-cache.test.ts | 70 +++++-- .../module-loader/module-transform-cache.ts | 84 ++++---- src/transforms/esm/transform-cache.test.ts | 195 ++++++++++++++++++ src/transforms/esm/transform-cache.ts | 33 ++- src/transforms/pipeline/index.ts | 22 +- .../stages/ssr-vf-modules/transform.test.ts | 38 +++- .../stages/ssr-vf-modules/transform.ts | 24 ++- .../shared/framework-bundle-paths.test.ts | 60 +++++- .../shared/framework-bundle-paths.ts | 29 +++ src/utils/version-constant.ts | 2 +- 11 files changed, 483 insertions(+), 76 deletions(-) diff --git a/deno.json b/deno.json index 3795e5ffec..7b29a5fe54 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "veryfront", - "version": "0.1.1142", + "version": "0.1.1144", "license": "Apache-2.0", "nodeModulesDir": "auto", "minimumDependencyAge": { diff --git a/src/rendering/orchestrator/module-loader/module-transform-cache.test.ts b/src/rendering/orchestrator/module-loader/module-transform-cache.test.ts index 6b7d5db9bb..200ecd0724 100644 --- a/src/rendering/orchestrator/module-loader/module-transform-cache.test.ts +++ b/src/rendering/orchestrator/module-loader/module-transform-cache.test.ts @@ -22,6 +22,7 @@ function createDeps( validateCachedBundlesByManifestOrCode: () => { throw new Error("validateCachedBundlesByManifestOrCode was not configured"); }, + findMissingFrameworkBundlePaths: () => Promise.resolve([]), getHttpBundleCacheDir: () => "/tmp/vf-http-bundles", setCachedTransformAsync: () => Promise.resolve(), runPipeline: () => { @@ -71,8 +72,8 @@ describe("module-loader/module-transform-cache", () => { }); it("re-transforms cached code when HTTP bundle validation fails", async () => { - const setCalls: Array<{ key: string; code: string; hash: string; ttl: number }> = []; let transformCalls = 0; + let validatorCalls = 0; const result = await transformModuleCodeWithCache({ fileContent: "export const page = 1;", @@ -83,12 +84,16 @@ describe("module-loader/module-transform-cache", () => { adapter: {} as RuntimeAdapter, ttlSeconds: 123, deps: createDeps({ - getOrComputeTransform: (_key, _compute) => - Promise.resolve({ + getOrComputeTransform: async (_key, compute, _ttl, _onProgress, _signal, validator) => { + validatorCalls++; + const cacheEntry = { code: 'import x from "file:///tmp/veryfront-http-bundle/http-deadbeef.mjs";', cacheHit: true, bundleManifestId: "manifest-abc", - }), + }; + if (await validator?.(cacheEntry)) return cacheEntry; + return { code: await compute(), cacheHit: false }; + }, validateCachedBundlesByManifestOrCode: (code, manifestId, cacheDir) => { assertEquals(code.includes("deadbeef"), true); assertEquals(manifestId, "manifest-abc"); @@ -104,19 +109,60 @@ describe("module-loader/module-transform-cache", () => { transformCalls++; return Promise.resolve("export const page = 1;"); }, - setCachedTransformAsync: (key, code, hash, ttl) => { - setCalls.push({ key, code, hash, ttl: ttl ?? -1 }); - return Promise.resolve(); - }, }), }); assertEquals(result.code, "export const page = 1;"); assertEquals(transformCalls, 1); - assertEquals(setCalls.length, 1); - assertEquals(setCalls[0]!.code, "export const page = 1;"); - assertEquals(setCalls[0]!.hash, hashCodeHex("export const page = 1;")); - assertEquals(setCalls[0]!.ttl, 123); + assertEquals(validatorCalls, 1); + }); + + it("re-transforms cached code when a referenced framework file URL is missing", async () => { + const missingFrameworkPath = + "/tmp/.cache/veryfront/veryfront-mdx-esm/framework/vfmod-vf-framework-deadbeef.mjs"; + const freshCode = "export const page = 3;"; + let transformCalls = 0; + let validatorCalls = 0; + + const result = await transformModuleCodeWithCache({ + fileContent: "export const page = 3;", + filePath: "/project/app/page.tsx", + projectDir: "/project", + effectiveProjectId: "project-3", + mode: "production", + adapter: {} as RuntimeAdapter, + ttlSeconds: 789, + deps: createDeps({ + getOrComputeTransform: async (_key, compute, _ttl, _onProgress, _signal, validator) => { + validatorCalls++; + const cacheEntry = { + code: `import helper from "file://${missingFrameworkPath}";\nexport default helper;`, + cacheHit: true, + bundleManifestId: "manifest-valid", + }; + if (await validator?.(cacheEntry)) return cacheEntry; + return { code: await compute(), cacheHit: false }; + }, + validateCachedBundlesByManifestOrCode: () => + Promise.resolve({ + valid: true, + failedHashes: [], + source: "manifest", + }), + findMissingFrameworkBundlePaths: (code) => { + assertEquals(code.includes(missingFrameworkPath), true); + return Promise.resolve([missingFrameworkPath]); + }, + transformToESM: () => { + transformCalls++; + return Promise.resolve(freshCode); + }, + }), + }); + + assertEquals(result.code, freshCode); + assertEquals(transformCalls, 1); + assertEquals(validatorCalls, 1); }); it("retries through the transform pipeline when cached code has unresolved _vf_modules imports", async () => { diff --git a/src/rendering/orchestrator/module-loader/module-transform-cache.ts b/src/rendering/orchestrator/module-loader/module-transform-cache.ts index 2d3d2bbf78..a3b574715c 100644 --- a/src/rendering/orchestrator/module-loader/module-transform-cache.ts +++ b/src/rendering/orchestrator/module-loader/module-transform-cache.ts @@ -13,8 +13,11 @@ import { getOrComputeTransform, initializeTransformCache, setCachedTransformAsync, + type TransformCachedEntryValidator, } from "#veryfront/transforms/esm/transform-cache.ts"; import { validateCachedBundlesByManifestOrCode } from "#veryfront/transforms/esm/cached-bundle-validation.ts"; +import { exists } from "#veryfront/platform/compat/fs.ts"; +import { findMissingFrameworkBundlePaths } from "#veryfront/transforms/shared/framework-bundle-paths.ts"; import { getHttpBundleCacheDir } from "#veryfront/utils/cache-dir.ts"; import { TRANSFORM_DISTRIBUTED_TTL_SEC } from "#veryfront/utils/constants/cache.ts"; import { REACT_DEFAULT_VERSION } from "#veryfront/utils/constants/cdn.ts"; @@ -64,6 +67,7 @@ export interface ModuleTransformCacheDeps { ttlSeconds: number, onProgress?: TransformProgressListener, signal?: AbortSignal, + validateCachedEntry?: TransformCachedEntryValidator, ) => Promise; transformToESM: ( code: string, @@ -77,6 +81,7 @@ export interface ModuleTransformCacheDeps { bundleManifestId: string | undefined, cacheDir: string, ) => Promise; + findMissingFrameworkBundlePaths: (code: string) => Promise; getHttpBundleCacheDir: typeof getHttpBundleCacheDir; setCachedTransformAsync: typeof setCachedTransformAsync; runPipeline: ( @@ -92,6 +97,15 @@ const defaultDeps: ModuleTransformCacheDeps = { getOrComputeTransform, transformToESM, validateCachedBundlesByManifestOrCode, + findMissingFrameworkBundlePaths: (code) => + findMissingFrameworkBundlePaths(code, exists, { + onError: (path, error) => { + logger.error("Framework bundle validation error", { + path, + error: error instanceof Error ? error.message : String(error), + }); + }, + }), getHttpBundleCacheDir, setCachedTransformAsync, runPipeline: async (code, filePath, projectDir, options) => { @@ -114,6 +128,37 @@ export interface TransformModuleCodeWithCacheInput { deps?: ModuleTransformCacheDeps; } +function createCachedTransformValidator( + filePath: string, + deps: ModuleTransformCacheDeps, +): TransformCachedEntryValidator { + return async (entry) => { + const [httpValidation, missingFrameworkBundles] = await Promise.all([ + deps.validateCachedBundlesByManifestOrCode( + entry.code, + entry.bundleManifestId, + deps.getHttpBundleCacheDir(), + ), + deps.findMissingFrameworkBundlePaths(entry.code), + ]); + + if (httpValidation.valid && missingFrameworkBundles.length === 0) { + return true; + } + + logger.warn("Cached transform dependency validation failed, re-transforming", { + filePath, + manifestId: entry.bundleManifestId?.slice(0, 12), + failedHashes: httpValidation.failedHashes, + reason: httpValidation.valid ? "framework_bundle_missing" : httpValidation.reason, + source: httpValidation.valid ? "framework-bundles" : httpValidation.source, + missingFrameworkBundleCount: missingFrameworkBundles.length, + firstMissingFrameworkBundle: missingFrameworkBundles[0]?.split("/").pop(), + }); + return false; + }; +} + /** Transform module source through the shared cache and stale-cache retry checks. */ export async function transformModuleCodeWithCache( input: TransformModuleCodeWithCacheInput, @@ -160,50 +205,13 @@ export async function transformModuleCodeWithCache( ttlSeconds, input.onProgress, input.signal, + createCachedTransformValidator(input.filePath, deps), ); input.signal?.throwIfAborted(); let transformedCode = transformResult.code; - if (transformResult.cacheHit) { - const validation = await deps.validateCachedBundlesByManifestOrCode( - transformedCode, - transformResult.bundleManifestId, - deps.getHttpBundleCacheDir(), - ); - input.signal?.throwIfAborted(); - if (!validation.valid) { - logger.warn("Cached HTTP bundle validation failed, re-transforming", { - filePath: input.filePath, - manifestId: transformResult.bundleManifestId?.slice(0, 12), - failedHashes: validation.failedHashes, - reason: validation.reason, - source: validation.source, - }); - - transformedCode = await deps.transformToESM( - input.fileContent, - input.filePath, - input.projectDir, - input.adapter, - transformOptions, - ); - - deps.setCachedTransformAsync( - cacheKey, - transformedCode, - contentHash, - ttlSeconds, - ).catch((error) => { - logger.debug("Failed to update transform cache after re-transform", { - filePath: input.filePath, - error, - }); - }); - } - } - // CRITICAL: Validate that no unresolved /_vf_modules/ imports remain after transform. // These imports should have been resolved to file:// paths by ssrVfModulesPlugin. // If they're still present, retry the transform bypassing all caches. diff --git a/src/transforms/esm/transform-cache.test.ts b/src/transforms/esm/transform-cache.test.ts index 603fb2e510..2e31a5d4cf 100644 --- a/src/transforms/esm/transform-cache.test.ts +++ b/src/transforms/esm/transform-cache.test.ts @@ -3,6 +3,8 @@ import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { afterEach, beforeEach, describe, it } from "#veryfront/testing/bdd.ts"; import { FakeTime } from "#std/testing/time"; import type { CacheBackend } from "#veryfront/cache/backend.ts"; +import { CACHE_DIR_TOKEN } from "#veryfront/cache/paths.ts"; +import { getCacheBaseDir } from "#veryfront/utils/cache-dir.ts"; import { __injectCachesForTests, destroyTransformCache, @@ -263,6 +265,199 @@ describe("transforms/esm/transform-cache", () => { assertEquals(result.cacheHit, true); }); + it("recomputes when the cached entry validator rejects a cache hit", async () => { + await getOrComputeTransform("invalid-hit-key", async () => "stale-value"); + + let computeCalls = 0; + let validationCalls = 0; + const result = await getOrComputeTransform( + "invalid-hit-key", + async () => { + computeCalls++; + return "fresh-value"; + }, + 300, + undefined, + undefined, + (entry) => { + validationCalls++; + assertEquals(entry.code, "stale-value"); + assertEquals(entry.cacheHit, true); + return false; + }, + ); + + assertEquals(result, { code: "fresh-value", cacheHit: false }); + assertEquals(computeCalls, 1); + assertEquals(validationCalls, 1); + + const cached = await getOrComputeTransform( + "invalid-hit-key", + async () => "unexpected-value", + ); + assertEquals(cached.code, "fresh-value"); + assertEquals(cached.cacheHit, true); + }); + + it("recomputes when cached-entry validation throws", async () => { + await getOrComputeTransform("validator-error-key", async () => "stale-value"); + + let computeCalls = 0; + const result = await getOrComputeTransform( + "validator-error-key", + async () => { + computeCalls++; + return "fresh-value"; + }, + 300, + undefined, + undefined, + () => { + throw new Error("stat failed"); + }, + ); + + assertEquals(result, { code: "fresh-value", cacheHit: false }); + assertEquals(computeCalls, 1); + }); + + it("shares cached-entry validation and repair across concurrent callers", async () => { + await getOrComputeTransform("invalid-shared-key", async () => "stale-shared-value"); + + let computeCalls = 0; + let validationCalls = 0; + let releaseValidation!: () => void; + let markValidationStarted!: () => void; + const validationGate = new Promise((resolve) => { + releaseValidation = resolve; + }); + const validationStarted = new Promise((resolve) => { + markValidationStarted = resolve; + }); + + const validateCachedEntry = async () => { + validationCalls++; + markValidationStarted(); + await validationGate; + return false; + }; + + const first = getOrComputeTransform( + "invalid-shared-key", + async () => { + computeCalls++; + return "fresh-shared-value"; + }, + 300, + undefined, + undefined, + validateCachedEntry, + ); + + await validationStarted; + + const second = getOrComputeTransform( + "invalid-shared-key", + async () => { + computeCalls++; + return "unexpected-shared-value"; + }, + 300, + undefined, + undefined, + validateCachedEntry, + ); + + await Promise.resolve(); + assertEquals(validationCalls, 1); + assertEquals(computeCalls, 0); + + releaseValidation(); + const [firstResult, secondResult] = await Promise.all([first, second]); + + assertEquals(firstResult, { code: "fresh-shared-value", cacheHit: false }); + assertEquals(secondResult, { code: "fresh-shared-value", cacheHit: false }); + assertEquals(validationCalls, 1); + assertEquals(computeCalls, 1); + }); + + it("repairs a detokenized distributed framework reference once", async () => { + const key = "distributed-framework-repair-key"; + const frameworkPath = + `${getCacheBaseDir()}/veryfront-mdx-esm/framework/vfmod-vf-framework-missing.mjs`; + const staleCode = `import helper from "file://${frameworkPath}";`; + let storedValue: string | null = null; + let setCalls = 0; + const repairPublished = Promise.withResolvers(); + const cacheBackend: CacheBackend = { + type: "redis", + get: () => Promise.resolve(storedValue), + set: (_key, value) => { + storedValue = value; + setCalls++; + if (setCalls === 2) repairPublished.resolve(); + return Promise.resolve(); + }, + del: () => Promise.resolve(), + }; + __injectCachesForTests({ cacheBackend }); + + await setCachedTransformAsync(key, staleCode, "stale-hash"); + const portableEntry = await cacheBackend.get(key); + assertEquals(portableEntry?.includes(CACHE_DIR_TOKEN), true); + assertEquals(portableEntry?.includes(frameworkPath), false); + + let computeCalls = 0; + let validationCalls = 0; + const validationStarted = Promise.withResolvers(); + const releaseValidation = Promise.withResolvers(); + const validateCachedEntry = async (entry: { code: string }) => { + validationCalls++; + assertEquals(entry.code.includes(frameworkPath), true); + assertEquals(entry.code.includes(CACHE_DIR_TOKEN), false); + validationStarted.resolve(); + await releaseValidation.promise; + return false; + }; + + const first = getOrComputeTransform( + key, + async () => { + computeCalls++; + return "export const repaired = true;"; + }, + 300, + undefined, + undefined, + validateCachedEntry, + ); + await validationStarted.promise; + const second = getOrComputeTransform( + key, + async () => { + computeCalls++; + return "export const duplicate = true;"; + }, + 300, + undefined, + undefined, + validateCachedEntry, + ); + + releaseValidation.resolve(); + const [firstResult, secondResult] = await Promise.all([first, second]); + await repairPublished.promise; + + assertEquals(firstResult.code, "export const repaired = true;"); + assertEquals(secondResult.code, "export const repaired = true;"); + assertEquals(validationCalls, 1); + assertEquals(computeCalls, 1); + assertEquals( + (await getCachedTransformAsync(key))?.code, + "export const repaired = true;", + ); + }); + it("coalesces concurrent cold misses for the same key", async () => { let computeCalls = 0; let releaseCompute!: () => void; diff --git a/src/transforms/esm/transform-cache.ts b/src/transforms/esm/transform-cache.ts index eeb47e1a68..64c860958e 100644 --- a/src/transforms/esm/transform-cache.ts +++ b/src/transforms/esm/transform-cache.ts @@ -409,6 +409,11 @@ interface TransformCacheResult { cacheHit: boolean; } +/** Decide whether a cached transform is safe to reuse in the current runtime. */ +export type TransformCachedEntryValidator = ( + entry: TransformCacheResult, +) => boolean | Promise; + function publishComputedTransform( key: string, code: string, @@ -439,6 +444,7 @@ export async function getOrComputeTransform( ttlSeconds: number = DEFAULT_TTL_SECONDS, onProgress?: TransformProgressListener, signal?: AbortSignal, + validateCachedEntry?: TransformCachedEntryValidator, ): Promise { signal?.throwIfAborted(); const flightRegistry = transformFlight; @@ -468,13 +474,34 @@ export async function getOrComputeTransform( }); // Fall through to recompute } else { - logger.debug("Cache hit", { key }); - reportProgress({ phase: "transform-cache:hit" }); - return { + const cacheEntry = { code: cached.code, bundleManifestId: cached.bundleManifestId, cacheHit: true, }; + if (validateCachedEntry) { + reportProgress({ phase: "transform-cache:validating" }); + } + let cacheEntryValid = true; + let cacheValidationError: string | undefined; + if (validateCachedEntry) { + try { + cacheEntryValid = await validateCachedEntry(cacheEntry); + } catch (error) { + cacheEntryValid = false; + cacheValidationError = error instanceof Error ? error.message : String(error); + } + } + if (cacheEntryValid) { + logger.debug("Cache hit", { key }); + reportProgress({ phase: "transform-cache:hit" }); + return cacheEntry; + } + logger.warn("Cached transform failed validation, recomputing", { + key: key.slice(-60), + ...(cacheValidationError ? { error: cacheValidationError } : {}), + }); + reportProgress({ phase: "transform-cache:invalidated" }); } } diff --git a/src/transforms/pipeline/index.ts b/src/transforms/pipeline/index.ts index 3bd4257e1d..f0368eab3e 100644 --- a/src/transforms/pipeline/index.ts +++ b/src/transforms/pipeline/index.ts @@ -35,7 +35,7 @@ import { import { createFileSystem, exists } from "#veryfront/platform/compat/fs.ts"; import { getHttpBundleCacheDir } from "#veryfront/utils/cache-dir.ts"; import { validateCachedBundlesByManifestOrCode } from "../esm/cached-bundle-validation.ts"; -import { extractFrameworkBundlePaths } from "../shared/framework-bundle-paths.ts"; +import { findMissingFrameworkBundlePaths } from "../shared/framework-bundle-paths.ts"; const SSR_PIPELINE: TransformPlugin[] = [ parsePlugin, @@ -92,30 +92,20 @@ async function validateFrameworkBundles( return false; } - const bundlePaths = extractFrameworkBundlePaths(code); - if (bundlePaths.length === 0) return true; - - const missing: string[] = []; - for (const path of bundlePaths) { - try { - if (!(await exists(path))) { - missing.push(path); - } - } catch (error) { - rendererLogger.error("Framework bundle validation error", { + const missing = await findMissingFrameworkBundlePaths(code, exists, { + onError: (path, error) => { + logger.error("Framework bundle validation error", { path, error: error instanceof Error ? error.message : String(error), }); - missing.push(path); - } - } + }, + }); if (missing.length === 0) return true; logger.debug("Framework bundle validation failed", { cacheKey: cacheKey.slice(-40), failedCount: missing.length, - totalBundles: bundlePaths.length, firstMissing: missing[0]?.split("/").pop(), }); return false; diff --git a/src/transforms/pipeline/stages/ssr-vf-modules/transform.test.ts b/src/transforms/pipeline/stages/ssr-vf-modules/transform.test.ts index f84e5c466c..05e95d2f61 100644 --- a/src/transforms/pipeline/stages/ssr-vf-modules/transform.test.ts +++ b/src/transforms/pipeline/stages/ssr-vf-modules/transform.test.ts @@ -3,10 +3,11 @@ import { assert, assertEquals, assertStringIncludes } from "#veryfront/testing/a import { afterAll, describe, it } from "#veryfront/testing/bdd.ts"; import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; import { stop as stopEsbuild } from "veryfront/extensions/bundler"; -import { join } from "#veryfront/compat/path/index.ts"; +import { fromFileUrl, join } from "#veryfront/compat/path/index.ts"; import { isCyclePlaceholder, reactReExportToEsmUrl, + resolveAndTransformVeryfrontImport, stripJsonAttributesFromModuleImports, transformFrameworkCode, } from "./transform.ts"; @@ -18,6 +19,7 @@ import { veryfrontTransformCache, } from "./constants.ts"; import { buildReactUrl } from "#veryfront/transforms/import-rewriter/url-builder.ts"; +import { resolveVeryfrontSourcePath } from "./path-resolver.ts"; describe("reactReExportToEsmUrl", () => { const reactPath = (name: string) => join(FRAMEWORK_ROOT, "react", name); @@ -501,6 +503,40 @@ describe("transformFrameworkCode depth-limit fallback", { } }); + it("rematerializes a cached #veryfront file URL when the target file is missing", async () => { + const tmp = await Deno.makeTempDir({ prefix: "vf-vfmod-stale-url-" }); + const specifier = "#veryfront/utils/hash-utils.ts"; + const sourcePath = await resolveVeryfrontSourcePath(specifier); + assert(sourcePath, `${specifier} did not resolve to a framework source file`); + const content = await Deno.readTextFile(sourcePath); + const transformKey = buildFrameworkTransformCacheKey( + `${specifier}:${sourcePath}`, + "19.2.4", + tmp, + content, + ); + const missingPath = `${tmp}/framework/vfmod-vf-framework-stale.mjs`; + const staleFileUrl = `file://${missingPath}`; + veryfrontTransformCache.set(transformKey, staleFileUrl); + + try { + const resolved = await resolveAndTransformVeryfrontImport(specifier, { + reactVersion: "19.2.4", + projectDir: tmp, + fs: createFileSystem(), + }); + + assert(resolved, "resolver did not return a file URL"); + assertEquals(resolved === staleFileUrl, false); + const resolvedPath = fromFileUrl(resolved); + const stat = await Deno.stat(resolvedPath); + assertEquals(stat.isFile, true); + } finally { + veryfrontTransformCache.delete(transformKey); + await Deno.remove(tmp, { recursive: true }); + } + }); + it("survives one bad .src dep without aborting the whole parent fallback", async () => { const tmp = await Deno.makeTempDir({ prefix: "vf-vfmod-baddep-" }); const srcDir = `${tmp}/src`; diff --git a/src/transforms/pipeline/stages/ssr-vf-modules/transform.ts b/src/transforms/pipeline/stages/ssr-vf-modules/transform.ts index e4baa2cfae..a3170b38db 100644 --- a/src/transforms/pipeline/stages/ssr-vf-modules/transform.ts +++ b/src/transforms/pipeline/stages/ssr-vf-modules/transform.ts @@ -11,7 +11,7 @@ import { } from "#veryfront/transforms/esm/import-attributes.ts"; import { ESBUILD_SUPPORTED_FEATURES } from "#veryfront/transforms/esm/transform-utils.ts"; import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; -import { join } from "#veryfront/compat/path/index.ts"; +import { fromFileUrl, join } from "#veryfront/compat/path/index.ts"; import denoConfig from "#deno-config" with { type: "json" }; import { rendererLogger as logger } from "#veryfront/utils"; import { IMPORT_RESOLUTION_ERROR } from "#veryfront/errors"; @@ -71,6 +71,17 @@ export function isCyclePlaceholder(code: string): boolean { return code.startsWith(CYCLE_PLACEHOLDER_PREFIX); } +async function cachedFileUrlExists( + fileUrl: string, + fs: ReturnType, +): Promise { + try { + return (await fs.stat(fromFileUrl(fileUrl))).isFile; + } catch { + return false; + } +} + /** * Cache transformed framework code and return the file:// path. * @@ -668,8 +679,15 @@ export async function resolveAndTransformVeryfrontImport( ); const cached = veryfrontTransformCache.get(transformKey); if (cached) { - ctx.onProgress?.({ phase: "framework:specifier-cache-hit", filePath: sourcePath }); - return cached; + if (await cachedFileUrlExists(cached, ctx.fs)) { + ctx.onProgress?.({ phase: "framework:specifier-cache-hit", filePath: sourcePath }); + return cached; + } + logger.debug(`${LOG_PREFIX} Cached #veryfront file URL is missing, invalidating`, { + specifier, + sourcePath: sourcePath.slice(-60), + }); + veryfrontTransformCache.delete(transformKey); } // Transform the dependency (recursively handles its own #veryfront/ imports) diff --git a/src/transforms/shared/framework-bundle-paths.test.ts b/src/transforms/shared/framework-bundle-paths.test.ts index 0350cc6346..6a6b4e0c62 100644 --- a/src/transforms/shared/framework-bundle-paths.test.ts +++ b/src/transforms/shared/framework-bundle-paths.test.ts @@ -1,7 +1,10 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { extractFrameworkBundlePaths } from "./framework-bundle-paths.ts"; +import { + extractFrameworkBundlePaths, + findMissingFrameworkBundlePaths, +} from "./framework-bundle-paths.ts"; describe("transforms/shared/framework-bundle-paths", () => { describe("extractFrameworkBundlePaths", () => { @@ -50,4 +53,59 @@ describe("transforms/shared/framework-bundle-paths", () => { assertEquals(result[0], "/home/user/.cache/framework/vfmod-test.mjs"); }); }); + + describe("findMissingFrameworkBundlePaths", () => { + it("returns only referenced framework bundle paths that do not exist", async () => { + const existingPath = "/cache/framework/vfmod-existing.mjs"; + const missingPath = "/cache/framework/vfmod-missing.mjs"; + const code = ` + import "file://${existingPath}"; + import "file://${missingPath}"; + import "file://${missingPath}"; + `; + + const result = await findMissingFrameworkBundlePaths( + code, + (path) => Promise.resolve(path === existingPath), + ); + + assertEquals(result, [missingPath]); + }); + + it("treats existence check failures as missing framework bundles", async () => { + const path = "/cache/framework/vfmod-stat-error.mjs"; + const errors: Array<{ path: string; message: string }> = []; + + const result = await findMissingFrameworkBundlePaths( + `import "file://${path}";`, + () => Promise.reject(new Error("stat failed")), + { + onError: (errorPath, error) => { + errors.push({ + path: errorPath, + message: error instanceof Error ? error.message : String(error), + }); + }, + }, + ); + + assertEquals(result, [path]); + assertEquals(errors, [{ path, message: "stat failed" }]); + }); + + it("does not call the existence check when code has no framework bundles", async () => { + let calls = 0; + + const result = await findMissingFrameworkBundlePaths( + `import "file:///cache/other/module.mjs";`, + () => { + calls++; + return Promise.resolve(false); + }, + ); + + assertEquals(result, []); + assertEquals(calls, 0); + }); + }); }); diff --git a/src/transforms/shared/framework-bundle-paths.ts b/src/transforms/shared/framework-bundle-paths.ts index 340eda5b13..5cc7d8c568 100644 --- a/src/transforms/shared/framework-bundle-paths.ts +++ b/src/transforms/shared/framework-bundle-paths.ts @@ -10,3 +10,32 @@ export function extractFrameworkBundlePaths(code: string): string[] { if (!matches) return []; return [...new Set(matches.map((match) => match.replace(/^file:\/\//, "")))]; } + +type FrameworkBundleExists = (path: string) => boolean | Promise; + +interface FindMissingFrameworkBundlePathsOptions { + onError?: (path: string, error: unknown) => void; +} + +/** Return framework bundle paths referenced by code that are missing locally. */ +export async function findMissingFrameworkBundlePaths( + code: string, + exists: FrameworkBundleExists, + options: FindMissingFrameworkBundlePathsOptions = {}, +): Promise { + const bundlePaths = extractFrameworkBundlePaths(code); + const missing: string[] = []; + + for (const path of bundlePaths) { + try { + if (!(await exists(path))) { + missing.push(path); + } + } catch (error) { + options.onError?.(path, error); + missing.push(path); + } + } + + return missing; +} diff --git a/src/utils/version-constant.ts b/src/utils/version-constant.ts index 09ac45f4e6..7951d65ee8 100644 --- a/src/utils/version-constant.ts +++ b/src/utils/version-constant.ts @@ -1,4 +1,4 @@ // Keep in sync with deno.json version. // scripts/release.ts updates this constant during releases. /** Shared version value. */ -export const VERSION = "0.1.1142"; +export const VERSION = "0.1.1144"; From fb86d5e11949094a2560cc6be4f70fab5ce24e6f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 25 Jul 2026 11:24:41 +0200 Subject: [PATCH 2/2] Keep cache validation latency independent of bundle count Framework bundle existence checks do not depend on one another, so validate the deduplicated paths concurrently while retaining input-order results and per-path error reporting. Constraint: Cache-hit validation runs on the SSR request path Rejected: Keep sequential checks | adds one filesystem round trip per referenced framework bundle Confidence: high Scope-risk: narrow Reversibility: clean Directive: Preserve ordered missing-path results and onError reporting Tested: framework-bundle-paths suite, 13 steps; deno check; deno fmt; git diff --check --- .../shared/framework-bundle-paths.test.ts | 32 +++++++++++++++++++ .../shared/framework-bundle-paths.ts | 22 ++++++------- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/transforms/shared/framework-bundle-paths.test.ts b/src/transforms/shared/framework-bundle-paths.test.ts index 6a6b4e0c62..68f2c37a57 100644 --- a/src/transforms/shared/framework-bundle-paths.test.ts +++ b/src/transforms/shared/framework-bundle-paths.test.ts @@ -93,6 +93,38 @@ describe("transforms/shared/framework-bundle-paths", () => { assertEquals(errors, [{ path, message: "stat failed" }]); }); + it("checks independent framework bundle paths concurrently", async () => { + const firstPath = "/cache/framework/vfmod-first.mjs"; + const secondPath = "/cache/framework/vfmod-second.mjs"; + const started: string[] = []; + let resolveFirst!: (exists: boolean) => void; + + const resultPromise = findMissingFrameworkBundlePaths( + ` + import "file://${firstPath}"; + import "file://${secondPath}"; + `, + (path) => { + started.push(path); + if (path === firstPath) { + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + return Promise.resolve(false); + }, + ); + + await Promise.resolve(); + try { + assertEquals(started, [firstPath, secondPath]); + } finally { + resolveFirst(true); + } + + assertEquals(await resultPromise, [secondPath]); + }); + it("does not call the existence check when code has no framework bundles", async () => { let calls = 0; diff --git a/src/transforms/shared/framework-bundle-paths.ts b/src/transforms/shared/framework-bundle-paths.ts index 5cc7d8c568..3c4f0b0cef 100644 --- a/src/transforms/shared/framework-bundle-paths.ts +++ b/src/transforms/shared/framework-bundle-paths.ts @@ -24,18 +24,16 @@ export async function findMissingFrameworkBundlePaths( options: FindMissingFrameworkBundlePathsOptions = {}, ): Promise { const bundlePaths = extractFrameworkBundlePaths(code); - const missing: string[] = []; - - for (const path of bundlePaths) { - try { - if (!(await exists(path))) { - missing.push(path); + const checkedPaths = await Promise.all( + bundlePaths.map(async (path): Promise => { + try { + return await exists(path) ? undefined : path; + } catch (error) { + options.onError?.(path, error); + return path; } - } catch (error) { - options.onError?.(path, error); - missing.push(path); - } - } + }), + ); - return missing; + return checkedPaths.filter((path): path is string => path !== undefined); }