From 898cc6ba1fab58c1599e1d60722c3faec3830245 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 24 Jul 2026 16:22:22 +0200 Subject: [PATCH 1/7] Keep RSC handler construction side-effect free Registry and cache-only consumers should not start project package detection. Resolve the React version on first render or page use and memoize that promise. Constraint: Constructor-only registry operations must not start filesystem work Rejected: Configure React only in tests | hides the production side effect Rejected: Add asynchronous cache teardown | spreads lifecycle complexity without owning the work Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep RSC handler construction side-effect free Tested: focused RSC suites with --trace-leaks; coverage shard 2 Not-tested: staging runtime before release --- .../rsc/endpoints/handler-registry.test.ts | 21 ++++++++++++++++++ .../services/rsc/orchestrators/handler.ts | 22 ++++++++++++------- .../rsc/orchestrators/render-handler.ts | 6 ++--- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/server/services/rsc/endpoints/handler-registry.test.ts b/src/server/services/rsc/endpoints/handler-registry.test.ts index 623d0e181a..2a188a5204 100644 --- a/src/server/services/rsc/endpoints/handler-registry.test.ts +++ b/src/server/services/rsc/endpoints/handler-registry.test.ts @@ -40,6 +40,27 @@ describe("server/services/rsc/endpoints/handler-registry", () => { __destroyRSCHandlerForTests(); }); + it("does not inspect project dependencies until a handler is used", async () => { + const cache = createStubCache(); + __injectCacheForTests(cache); + const originalStat = Deno.stat; + let statCalls = 0; + + Deno.stat = (path: string | URL) => { + statCalls++; + return originalStat(path); + }; + + try { + getRSCHandler("/project/lazy-react-version"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assertEquals(statCalls, 0); + } finally { + Deno.stat = originalStat; + } + }); + describe("invalidateRSCHandlersForProject", () => { it("evicts every handler variant for only the changed project", () => { const cache = createStubCache(); diff --git a/src/server/services/rsc/orchestrators/handler.ts b/src/server/services/rsc/orchestrators/handler.ts index 0a18fa5885..e3714dc602 100644 --- a/src/server/services/rsc/orchestrators/handler.ts +++ b/src/server/services/rsc/orchestrators/handler.ts @@ -31,6 +31,7 @@ export class RSCDevServerHandler { private renderer: RSCRenderer | null = null; private clientManifest: Map | null = null; private rendererPromise: Promise | null = null; + private reactVersionPromise: Promise | null = null; private invalidationGeneration = 0; private readonly manifestHandler: ManifestHandler; @@ -53,10 +54,6 @@ export class RSCDevServerHandler { fs: options.adapter?.fs, contentSourceId, }); - this.reactVersionPromise = resolveProjectReactVersion({ - projectDir, - config: options.config, - }); this.renderHandler = new RenderHandler( projectDir, () => this.renderer, @@ -67,7 +64,7 @@ export class RSCDevServerHandler { projectId: options.projectId, projectSlug: options.projectSlug, contentSourceId, - reactVersion: this.reactVersionPromise, + reactVersion: () => this.getReactVersion(), }, ); this.streamHandler = new StreamHandler(this.renderHandler); @@ -75,13 +72,14 @@ export class RSCDevServerHandler { this.isLocalProject = isLocalProject; this.mode = mode; this.fs = options.adapter?.fs; + this.config = options.config; } private readonly appDir: string; private readonly isLocalProject: boolean; private readonly mode: "development" | "production"; - private readonly reactVersionPromise: Promise; private readonly fs?: RuntimeAdapter["fs"]; + private readonly config?: VeryfrontConfig; handleManifest(): Promise { return this.manifestHandler.handle(this.clientManifest); @@ -109,7 +107,7 @@ export class RSCDevServerHandler { if (!this.pageHandler) { this.pageHandler = new PageHandler( this.mode === "development", - await this.reactVersionPromise, + await this.getReactVersion(), this.isLocalProject ? "fs" : "rsc-module", ); } @@ -140,7 +138,7 @@ export class RSCDevServerHandler { private async initializeRenderer(generation: number): Promise { const clientManifest = await buildClientManifest(this.projectDir, this.appDir, this.fs); - const reactVersion = await this.reactVersionPromise; + const reactVersion = await this.getReactVersion(); if (generation !== this.invalidationGeneration) return; this.clientManifest = clientManifest; @@ -153,4 +151,12 @@ export class RSCDevServerHandler { reactVersion, }); } + + private getReactVersion(): Promise { + this.reactVersionPromise ??= resolveProjectReactVersion({ + projectDir: this.projectDir, + config: this.config, + }); + return this.reactVersionPromise; + } } diff --git a/src/server/services/rsc/orchestrators/render-handler.ts b/src/server/services/rsc/orchestrators/render-handler.ts index 6adbeccaa3..c6917270b8 100644 --- a/src/server/services/rsc/orchestrators/render-handler.ts +++ b/src/server/services/rsc/orchestrators/render-handler.ts @@ -26,7 +26,7 @@ interface RenderHandlerModuleOptions { projectId?: string; projectSlug?: string; contentSourceId?: string; - reactVersion?: Promise; + reactVersion?: () => Promise; } const logger = serverLogger.component("rsc"); @@ -115,7 +115,7 @@ export class RenderHandler { this.projectDir, this.moduleOptions.projectSlug, this.moduleOptions.contentSourceId, - await this.moduleOptions.reactVersion, + await this.moduleOptions.reactVersion?.(), ) as Record; } @@ -130,7 +130,7 @@ export class RenderHandler { contentSourceId: this.moduleOptions.contentSourceId, dev: this.mode === "development", mode: this.mode === "development" ? "preview" : "production", - reactVersion: await this.moduleOptions.reactVersion, + reactVersion: await this.moduleOptions.reactVersion?.(), }); } From f0d51ff3e67a3ebc37ea5727fc3c028107a5258c Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 24 Jul 2026 16:22:31 +0200 Subject: [PATCH 2/7] Keep transient module CDN failures out of renders SSR HTTP module loading failed on the first temporary upstream response. Retry retryable statuses and network failures with bounded backoff while preserving immediate failure for permanent responses, and publish the fixes as 0.1.1126. Constraint: Remote module loading depends on third-party CDN availability Rejected: Retry the complete binary test only | leaves production renders exposed to the same outage Rejected: Retry permanent 4xx responses | adds latency without a recovery path Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep retries bounded and preserve immediate permanent-response failures Tested: HTTP bundle cache suite; focused RSC suites; coverage shard 2; deno task verify:quick Not-tested: live CDN outage longer than the bounded retry window --- deno.json | 2 +- src/transforms/esm/http-cache.test.ts | 68 +++++++++++++++- src/transforms/esm/http-cache.ts | 108 ++++++++++++++++++-------- src/utils/version-constant.ts | 2 +- 4 files changed, 143 insertions(+), 37 deletions(-) diff --git a/deno.json b/deno.json index 7e430de351..dd0f18c228 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "veryfront", - "version": "0.1.1125", + "version": "0.1.1126", "license": "Apache-2.0", "nodeModulesDir": "auto", "minimumDependencyAge": { diff --git a/src/transforms/esm/http-cache.test.ts b/src/transforms/esm/http-cache.test.ts index b4def952c1..b0dd80e814 100644 --- a/src/transforms/esm/http-cache.test.ts +++ b/src/transforms/esm/http-cache.test.ts @@ -1,7 +1,7 @@ import "#veryfront/schemas/_test-setup.ts"; /** @module transforms/esm/http-cache.test */ -import { assert, assertEquals, assertNotEquals } from "#veryfront/testing/assert.ts"; +import { assert, assertEquals, assertNotEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { join } from "#veryfront/compat/path"; import { @@ -58,6 +58,72 @@ function createMemoryBackend(store: Map): CacheBackend { } describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, () => { + it("retries transient esm.sh failures before failing a render", async () => { + const tempDir = await makeTempDir({ prefix: "vf-esm-retry-" }); + const originalFetch = globalThis.fetch; + const moduleUrl = "https://esm.sh/react@19.0.0/jsx-runtime?target=es2022"; + let fetchCount = 0; + + __injectCachesForTests({ + cachedPaths: new Map(), + processingStack: new Set(), + lastDistributedRefresh: new Map(), + }); + __setDistributedCacheAccessorForTests(() => Promise.resolve(null)); + globalThis.fetch = (() => { + fetchCount += 1; + if (fetchCount === 1) { + return Promise.resolve(new Response("upstream failure", { status: 502 })); + } + return Promise.resolve( + new Response("export const jsx = () => null;", { + headers: { "content-type": "application/javascript" }, + }), + ); + }) as typeof fetch; + + try { + const cachedUrl = await cacheModuleToLocal(moduleUrl, tempDir, "19.0.0"); + + assert(cachedUrl.startsWith("file://")); + assertEquals(fetchCount, 2); + } finally { + globalThis.fetch = originalFetch; + __injectCachesForTests(null); + __setDistributedCacheAccessorForTests(null); + __clearInFlightHttpFetches(); + await remove(tempDir, { recursive: true }); + } + }); + + it("does not retry permanent HTTP module failures", async () => { + const tempDir = await makeTempDir({ prefix: "vf-esm-permanent-failure-" }); + const originalFetch = globalThis.fetch; + let fetchCount = 0; + + __injectCachesForTests({ + cachedPaths: new Map(), + processingStack: new Set(), + lastDistributedRefresh: new Map(), + }); + __setDistributedCacheAccessorForTests(() => Promise.resolve(null)); + globalThis.fetch = (() => { + fetchCount += 1; + return Promise.resolve(new Response("not found", { status: 404 })); + }) as typeof fetch; + + try { + await assertRejects(() => cacheModuleToLocal("https://esm.sh/missing-package", tempDir)); + assertEquals(fetchCount, 1); + } finally { + globalThis.fetch = originalFetch; + __injectCachesForTests(null); + __setDistributedCacheAccessorForTests(null); + __clearInFlightHttpFetches(); + await remove(tempDir, { recursive: true }); + } + }); + it("preserves and shares canonical React versions across project import maps", async () => { const tempDir = await makeTempDir({ prefix: "vf-react-singleton-cache-" }); const originalFetch = globalThis.fetch; diff --git a/src/transforms/esm/http-cache.ts b/src/transforms/esm/http-cache.ts index b4b0cff5d1..543f741741 100644 --- a/src/transforms/esm/http-cache.ts +++ b/src/transforms/esm/http-cache.ts @@ -9,7 +9,7 @@ import { createFileSystem, exists } from "#veryfront/platform/compat/fs.ts"; import { join } from "#veryfront/compat/path/index.ts"; -import { rendererLogger as logger } from "#veryfront/utils"; +import { rendererLogger as logger, sleep } from "#veryfront/utils"; import { BUILD_FAILED, BUNDLE_ERROR, FILE_NOT_FOUND } from "#veryfront/errors"; import { SpanNames } from "#veryfront/observability"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; @@ -74,10 +74,82 @@ import { /** Threshold in ms above which an HTTP module fetch is considered slow */ const SLOW_HTTP_FETCH_THRESHOLD_MS = 500; +const HTTP_MODULE_FETCH_MAX_ATTEMPTS = 3; +const HTTP_MODULE_FETCH_RETRY_DELAY_MS = 100; const httpCacheLog = logger.component("http-cache"); const contentMetricsLog = logger.component("content-metrics"); +function shouldRetryHttpModuleFetch(status: number): boolean { + return status === 408 || status === 425 || status === 429 || status >= 500; +} + +async function fetchHttpModule(url: string): Promise { + const urlObj = new URL(url); + + for (let attempt = 1; attempt <= HTTP_MODULE_FETCH_MAX_ATTEMPTS; attempt++) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), HTTP_FETCH_TIMEOUT_MS); + const startedAt = performance.now(); + + try { + const response = await withSpan( + SpanNames.HTTP_CLIENT_FETCH, + () => + fetch(url, { + headers: { "user-agent": "Mozilla/5.0 Veryfront/1.0" }, + signal: controller.signal, + redirect: "follow", + }), + { + "http.method": "GET", + "http.url": url, + "http.host": urlObj.host, + "http.scheme": urlObj.protocol.replace(":", ""), + "esm.package_fetch": true, + }, + ); + + const duration = Math.round(performance.now() - startedAt); + contentMetricsLog.debug("HTTP_MODULE_FETCH", { + url: url.substring(0, 120), + host: urlObj.host, + duration_ms: duration, + status: response.status, + slow: duration > SLOW_HTTP_FETCH_THRESHOLD_MS, + attempt, + }); + + if ( + response.ok || !shouldRetryHttpModuleFetch(response.status) || + attempt === HTTP_MODULE_FETCH_MAX_ATTEMPTS + ) { + return response; + } + + httpCacheLog.warn("Transient HTTP module fetch failed, retrying", { + url, + status: response.status, + attempt, + }); + try { + await response.body?.cancel(); + } catch (_) { + // A failed response is being discarded before retrying. + } + } catch (error) { + if (attempt === HTTP_MODULE_FETCH_MAX_ATTEMPTS) throw error; + httpCacheLog.warn("HTTP module fetch failed, retrying", { url, attempt, error }); + } finally { + clearTimeout(timeout); + } + + await sleep(HTTP_MODULE_FETCH_RETRY_DELAY_MS * attempt); + } + + throw BUILD_FAILED.create({ detail: `Failed to fetch ${url}` }); +} + // Re-export for backwards compatibility export { CACHE_DIR_TOKEN, @@ -252,39 +324,7 @@ async function cacheHttpModuleInternal(url: string, options: CacheOptions): Prom } httpCacheLog.debug("Fetching from network", { url: normalizedUrl }); - - const urlObj = new URL(normalizedUrl); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), HTTP_FETCH_TIMEOUT_MS); - - const httpFetchStartTime = performance.now(); - - const response = await withSpan( - SpanNames.HTTP_CLIENT_FETCH, - () => - fetch(normalizedUrl, { - headers: { "user-agent": "Mozilla/5.0 Veryfront/1.0" }, - signal: controller.signal, - redirect: "follow", - }), - { - "http.method": "GET", - "http.url": normalizedUrl, - "http.host": urlObj.host, - "http.scheme": urlObj.protocol.replace(":", ""), - "esm.package_fetch": true, - }, - ); - clearTimeout(timeout); - - const httpFetchDuration = Math.round(performance.now() - httpFetchStartTime); - contentMetricsLog.debug("HTTP_MODULE_FETCH", { - url: normalizedUrl.substring(0, 120), - host: urlObj.host, - duration_ms: httpFetchDuration, - status: response.status, - slow: httpFetchDuration > SLOW_HTTP_FETCH_THRESHOLD_MS, - }); + const response = await fetchHttpModule(normalizedUrl); if (!response.ok) { throw BUILD_FAILED.create({ detail: `Failed to fetch ${normalizedUrl}: ${response.status}` }); diff --git a/src/utils/version-constant.ts b/src/utils/version-constant.ts index dd340d84ca..abc41ff7ed 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.1125"; +export const VERSION = "0.1.1126"; From 341b22b3ce1df78d41d6447fa282130811dbf724 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 24 Jul 2026 16:39:54 +0200 Subject: [PATCH 3/7] Keep retry owners singular under concurrent renders The new bounded HTTP retry loop can outlive the previous in-flight waiter deadline. Give callers a complete wait window, align the HTTP cache waiter with the retry budget, and discard final error response bodies before surfacing BUILD_FAILED. Constraint: A three-attempt fetch may consume roughly 90 seconds before backoff and scheduling overhead. Rejected: Remove the in-flight deadline | a fetch implementation that ignores abort could block every waiter indefinitely. Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep the in-flight wait budget at least as large as the owning HTTP retry budget. Tested: focused in-flight and HTTP cache suites with trace-leaks (54 steps), verify:quick Not-tested: wall-clock 90-second upstream timeout path; policy is covered with an injected short wait window. --- src/transforms/esm/http-cache.test.ts | 13 +++++++++- src/transforms/esm/http-cache.ts | 25 ++++++++++++++------ src/transforms/esm/in-flight-manager.test.ts | 15 ++++++++++++ src/transforms/esm/in-flight-manager.ts | 6 +++-- 4 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/transforms/esm/http-cache.test.ts b/src/transforms/esm/http-cache.test.ts index b0dd80e814..b974b1aa4e 100644 --- a/src/transforms/esm/http-cache.test.ts +++ b/src/transforms/esm/http-cache.test.ts @@ -100,6 +100,7 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, const tempDir = await makeTempDir({ prefix: "vf-esm-permanent-failure-" }); const originalFetch = globalThis.fetch; let fetchCount = 0; + let bodyCancelled = false; __injectCachesForTests({ cachedPaths: new Map(), @@ -109,12 +110,22 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, __setDistributedCacheAccessorForTests(() => Promise.resolve(null)); globalThis.fetch = (() => { fetchCount += 1; - return Promise.resolve(new Response("not found", { status: 404 })); + return Promise.resolve( + new Response( + new ReadableStream({ + cancel() { + bodyCancelled = true; + }, + }), + { status: 404 }, + ), + ); }) as typeof fetch; try { await assertRejects(() => cacheModuleToLocal("https://esm.sh/missing-package", tempDir)); assertEquals(fetchCount, 1); + assertEquals(bodyCancelled, true); } finally { globalThis.fetch = originalFetch; __injectCachesForTests(null); diff --git a/src/transforms/esm/http-cache.ts b/src/transforms/esm/http-cache.ts index 543f741741..fd0a4981dc 100644 --- a/src/transforms/esm/http-cache.ts +++ b/src/transforms/esm/http-cache.ts @@ -76,6 +76,11 @@ import { const SLOW_HTTP_FETCH_THRESHOLD_MS = 500; const HTTP_MODULE_FETCH_MAX_ATTEMPTS = 3; const HTTP_MODULE_FETCH_RETRY_DELAY_MS = 100; +const HTTP_MODULE_FETCH_WAIT_GRACE_MS = 5_000; +const HTTP_MODULE_FETCH_MAX_WAIT_MS = HTTP_FETCH_TIMEOUT_MS * HTTP_MODULE_FETCH_MAX_ATTEMPTS + + HTTP_MODULE_FETCH_RETRY_DELAY_MS * + ((HTTP_MODULE_FETCH_MAX_ATTEMPTS - 1) * HTTP_MODULE_FETCH_MAX_ATTEMPTS / 2) + + HTTP_MODULE_FETCH_WAIT_GRACE_MS; const httpCacheLog = logger.component("http-cache"); const contentMetricsLog = logger.component("content-metrics"); @@ -84,6 +89,14 @@ function shouldRetryHttpModuleFetch(status: number): boolean { return status === 408 || status === 425 || status === 429 || status >= 500; } +async function discardResponseBody(response: Response): Promise { + try { + await response.body?.cancel(); + } catch (_) { + // The response is already being discarded. + } +} + async function fetchHttpModule(url: string): Promise { const urlObj = new URL(url); @@ -132,11 +145,7 @@ async function fetchHttpModule(url: string): Promise { status: response.status, attempt, }); - try { - await response.body?.cancel(); - } catch (_) { - // A failed response is being discarded before retrying. - } + await discardResponseBody(response); } catch (error) { if (attempt === HTTP_MODULE_FETCH_MAX_ATTEMPTS) throw error; httpCacheLog.warn("HTTP module fetch failed, retrying", { url, attempt, error }); @@ -251,7 +260,7 @@ async function cacheHttpModuleInternal(url: string, options: CacheOptions): Prom let inFlight = inFlightHttpFetches.get(cacheKey); while (inFlight) { - const result = await waitForInFlightFetch(inFlight, cacheKey); + const result = await waitForInFlightFetch(inFlight, cacheKey, HTTP_MODULE_FETCH_MAX_WAIT_MS); if (result !== undefined) return result; if (inFlightHttpFetches.get(cacheKey) === inFlight) { @@ -327,7 +336,9 @@ async function cacheHttpModuleInternal(url: string, options: CacheOptions): Prom const response = await fetchHttpModule(normalizedUrl); if (!response.ok) { - throw BUILD_FAILED.create({ detail: `Failed to fetch ${normalizedUrl}: ${response.status}` }); + const status = response.status; + await discardResponseBody(response); + throw BUILD_FAILED.create({ detail: `Failed to fetch ${normalizedUrl}: ${status}` }); } let code = await response.text(); diff --git a/src/transforms/esm/in-flight-manager.test.ts b/src/transforms/esm/in-flight-manager.test.ts index e8a368585d..244ab8df2c 100644 --- a/src/transforms/esm/in-flight-manager.test.ts +++ b/src/transforms/esm/in-flight-manager.test.ts @@ -58,5 +58,20 @@ describe("transforms/esm/in-flight-manager", () => { const elapsed = Date.now() - start; assertEquals(elapsed < 1000, true); }); + + it("honors a caller-provided wait window", async () => { + let timer: ReturnType; + const slowResult = new Promise((resolve) => { + timer = setTimeout(() => resolve("late"), 25); + }); + + try { + const result = await waitForInFlightFetch(slowResult, "key", 5); + + assertEquals(result, undefined); + } finally { + clearTimeout(timer!); + } + }); }); }); diff --git a/src/transforms/esm/in-flight-manager.ts b/src/transforms/esm/in-flight-manager.ts index f4b9ceec0d..5f95438054 100644 --- a/src/transforms/esm/in-flight-manager.ts +++ b/src/transforms/esm/in-flight-manager.ts @@ -43,15 +43,17 @@ export function __clearInFlightHttpFetches(): void { const IN_FLIGHT_JITTER_MS = 5_000; /** - * Wait for an in-flight fetch with timeout + jitter. + * Wait for an in-flight fetch. The default timeout includes jitter; callers + * with a bounded owner operation can supply its complete wait window. * Returns undefined on timeout so caller can retry. */ export async function waitForInFlightFetch( promise: Promise, cacheKey: string, + waitTimeoutMs?: number, ): Promise { const jitter = Math.floor(Math.random() * IN_FLIGHT_JITTER_MS); - const timeoutMs = IN_FLIGHT_WAIT_TIMEOUT_MS + jitter; + const timeoutMs = waitTimeoutMs ?? IN_FLIGHT_WAIT_TIMEOUT_MS + jitter; let timeoutId: ReturnType; const timeoutPromise = new Promise((resolve) => { From 223e6ad34f0f1c32eca5e08d576724e7ad109530 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 24 Jul 2026 16:56:29 +0200 Subject: [PATCH 4/7] Keep remote module retries bounded through body consumption Remote module failures can occur after response headers, so the shared retry boundary now covers fetch and body consumption together. Each failed response is discarded, transient failures retain one bounded owner, and observability only receives credential-safe URL details. Constraint: HTTP response bodies can fail or stall after headers have already succeeded. Rejected: Keep the manual retry loop | it duplicated shared backoff behavior and left body reads outside the attempt timeout. Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep fetch, body consumption, and failed-body cancellation inside the same HTTP module retry attempt. Tested: deno task verify:quick; 58 trace-leak-checked HTTP cache and in-flight steps; red-green coverage for network, body-read, status exhaustion, cancellation, and credential safety. Not-tested: Live upstream CDN fault injection. --- src/transforms/esm/http-cache.test.ts | 172 +++++++++++++++++++------ src/transforms/esm/http-cache.ts | 173 ++++++++++++++++---------- 2 files changed, 247 insertions(+), 98 deletions(-) diff --git a/src/transforms/esm/http-cache.test.ts b/src/transforms/esm/http-cache.test.ts index b974b1aa4e..b49f7de70d 100644 --- a/src/transforms/esm/http-cache.test.ts +++ b/src/transforms/esm/http-cache.test.ts @@ -25,6 +25,7 @@ import { __setDistributedCacheAccessorForTests } from "./http-cache-wrapper.ts"; import type { CacheBackend } from "#veryfront/cache/types.ts"; import { buildHttpCacheIdentity } from "./http-cache-helpers.ts"; import { simpleHash } from "#veryfront/utils/hash-utils.ts"; +import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; /** Duplicated from http-cache.ts for isolated unit testing of the pattern. */ const BUNDLE_RE = /file:\/\/([^"'\s]+veryfront-http-bundle\/http-([a-f0-9]+)\.mjs)/gi; @@ -57,20 +58,41 @@ function createMemoryBackend(store: Map): CacheBackend { }; } +async function withIsolatedHttpCache( + tempPrefix: string, + mockFetch: typeof fetch, + run: (tempDir: string) => Promise, +): Promise { + const tempDir = await makeTempDir({ prefix: tempPrefix }); + + try { + return await withMockFetch(mockFetch, async () => { + __injectCachesForTests({ + cachedPaths: new Map(), + processingStack: new Set(), + lastDistributedRefresh: new Map(), + }); + __setDistributedCacheAccessorForTests(() => Promise.resolve(null)); + + try { + return await run(tempDir); + } finally { + __injectCachesForTests(null); + __setDistributedCacheAccessorForTests(null); + __clearInFlightHttpFetches(); + } + }); + } finally { + await remove(tempDir, { recursive: true }); + } +} + describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, () => { it("retries transient esm.sh failures before failing a render", async () => { - const tempDir = await makeTempDir({ prefix: "vf-esm-retry-" }); - const originalFetch = globalThis.fetch; const moduleUrl = "https://esm.sh/react@19.0.0/jsx-runtime?target=es2022"; let fetchCount = 0; - __injectCachesForTests({ - cachedPaths: new Map(), - processingStack: new Set(), - lastDistributedRefresh: new Map(), - }); - __setDistributedCacheAccessorForTests(() => Promise.resolve(null)); - globalThis.fetch = (() => { + const mockFetch = (() => { fetchCount += 1; if (fetchCount === 1) { return Promise.resolve(new Response("upstream failure", { status: 502 })); @@ -82,33 +104,19 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, ); }) as typeof fetch; - try { + await withIsolatedHttpCache("vf-esm-retry-", mockFetch, async (tempDir) => { const cachedUrl = await cacheModuleToLocal(moduleUrl, tempDir, "19.0.0"); assert(cachedUrl.startsWith("file://")); assertEquals(fetchCount, 2); - } finally { - globalThis.fetch = originalFetch; - __injectCachesForTests(null); - __setDistributedCacheAccessorForTests(null); - __clearInFlightHttpFetches(); - await remove(tempDir, { recursive: true }); - } + }); }); it("does not retry permanent HTTP module failures", async () => { - const tempDir = await makeTempDir({ prefix: "vf-esm-permanent-failure-" }); - const originalFetch = globalThis.fetch; let fetchCount = 0; let bodyCancelled = false; - __injectCachesForTests({ - cachedPaths: new Map(), - processingStack: new Set(), - lastDistributedRefresh: new Map(), - }); - __setDistributedCacheAccessorForTests(() => Promise.resolve(null)); - globalThis.fetch = (() => { + const mockFetch = (() => { fetchCount += 1; return Promise.resolve( new Response( @@ -122,17 +130,111 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, ); }) as typeof fetch; - try { - await assertRejects(() => cacheModuleToLocal("https://esm.sh/missing-package", tempDir)); + await withIsolatedHttpCache("vf-esm-permanent-failure-", mockFetch, async (tempDir) => { + const error = await assertRejects(() => + cacheModuleToLocal( + "https://esm.sh/missing-package?access_token=super-secret", + tempDir, + ) + ); assertEquals(fetchCount, 1); assertEquals(bodyCancelled, true); - } finally { - globalThis.fetch = originalFetch; - __injectCachesForTests(null); - __setDistributedCacheAccessorForTests(null); - __clearInFlightHttpFetches(); - await remove(tempDir, { recursive: true }); - } + assert(!error.message.includes("super-secret")); + }); + }); + + it("retries failures while reading an HTTP module body", async () => { + let fetchCount = 0; + + const mockFetch = (() => { + fetchCount += 1; + if (fetchCount === 1) { + return Promise.resolve( + new Response( + new ReadableStream({ + start(controller) { + controller.error(new TypeError("body disconnected")); + }, + }), + ), + ); + } + return Promise.resolve( + new Response("export const recovered = true;", { + headers: { "content-type": "application/javascript" }, + }), + ); + }) as typeof fetch; + + await withIsolatedHttpCache("vf-esm-body-retry-", mockFetch, async (tempDir) => { + const cachedUrl = await cacheModuleToLocal( + "https://esm.sh/body-disconnect", + tempDir, + ); + + assert(cachedUrl.startsWith("file://")); + assertEquals(fetchCount, 2); + }); + }); + + it("retries a network rejection before succeeding", async () => { + let fetchCount = 0; + const mockFetch = (() => { + fetchCount += 1; + if (fetchCount === 1) return Promise.reject(new TypeError("network unavailable")); + return Promise.resolve(new Response("export const recovered = true;")); + }) as typeof fetch; + + await withIsolatedHttpCache("vf-esm-network-retry-", mockFetch, async (tempDir) => { + const cachedUrl = await cacheModuleToLocal("https://esm.sh/network-retry", tempDir); + + assert(cachedUrl.startsWith("file://")); + assertEquals(fetchCount, 2); + }); + }); + + it("does not expose URL credentials after network retries are exhausted", async () => { + let fetchCount = 0; + const secretUrl = "https://esm.sh/network-failure?access_token=super-secret"; + const mockFetch = (() => { + fetchCount += 1; + return Promise.reject(new TypeError(`network unavailable for ${secretUrl}`)); + }) as typeof fetch; + + await withIsolatedHttpCache("vf-esm-network-failure-", mockFetch, async (tempDir) => { + const error = await assertRejects(() => cacheModuleToLocal(secretUrl, tempDir)); + + assertEquals(fetchCount, 3); + assert(!error.message.includes("super-secret")); + }); + }); + + it("bounds transient failure attempts and cancels every response body", async () => { + let fetchCount = 0; + let cancelledBodies = 0; + const mockFetch = (() => { + fetchCount += 1; + return Promise.resolve( + new Response( + new ReadableStream({ + cancel() { + cancelledBodies += 1; + }, + }), + { status: 503 }, + ), + ); + }) as typeof fetch; + + await withIsolatedHttpCache("vf-esm-exhausted-retry-", mockFetch, async (tempDir) => { + const error = await assertRejects(() => + cacheModuleToLocal("https://esm.sh/exhausted", tempDir) + ); + + assertEquals(fetchCount, 3); + assertEquals(cancelledBodies, 3); + assert(error.message.includes("503")); + }); }); it("preserves and shares canonical React versions across project import maps", async () => { diff --git a/src/transforms/esm/http-cache.ts b/src/transforms/esm/http-cache.ts index fd0a4981dc..4c38702602 100644 --- a/src/transforms/esm/http-cache.ts +++ b/src/transforms/esm/http-cache.ts @@ -9,10 +9,11 @@ import { createFileSystem, exists } from "#veryfront/platform/compat/fs.ts"; import { join } from "#veryfront/compat/path/index.ts"; -import { rendererLogger as logger, sleep } from "#veryfront/utils"; -import { BUILD_FAILED, BUNDLE_ERROR, FILE_NOT_FOUND } from "#veryfront/errors"; +import { rendererLogger as logger } from "#veryfront/utils"; +import { BUILD_FAILED, BUNDLE_ERROR, FILE_NOT_FOUND, retryWithBackoff } from "#veryfront/errors"; import { SpanNames } from "#veryfront/observability"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; +import { sanitizeUrlForSpan } from "#veryfront/utils/logger/redact.ts"; import { replaceSpecifiers } from "./lexer.ts"; import { createBundleManifest, storeBundleManifest } from "./bundle-manifest.ts"; import { HTTP_MODULE_DISTRIBUTED_TTL_SEC } from "#veryfront/utils/constants/cache.ts"; @@ -85,6 +86,25 @@ const HTTP_MODULE_FETCH_MAX_WAIT_MS = HTTP_FETCH_TIMEOUT_MS * HTTP_MODULE_FETCH_ const httpCacheLog = logger.component("http-cache"); const contentMetricsLog = logger.component("content-metrics"); +interface HttpModuleFetchResult { + code: string; + contentType: string; +} + +class HttpModuleResponseError extends Error { + constructor(readonly status: number) { + super(`HTTP module response returned status ${status}`); + this.name = "HttpModuleResponseError"; + } +} + +class HttpModuleRequestError extends Error { + constructor(readonly requestErrorType: string) { + super(`HTTP module request failed (${requestErrorType})`); + this.name = "HttpModuleRequestError"; + } +} + function shouldRetryHttpModuleFetch(status: number): boolean { return status === 408 || status === 425 || status === 429 || status >= 500; } @@ -97,66 +117,100 @@ async function discardResponseBody(response: Response): Promise { } } -async function fetchHttpModule(url: string): Promise { - const urlObj = new URL(url); +async function fetchHttpModuleAttempt( + url: string, + safeUrl: string, + urlObj: URL, + signal: AbortSignal | undefined, + attempt: number, +): Promise { + let response: Response | undefined; - for (let attempt = 1; attempt <= HTTP_MODULE_FETCH_MAX_ATTEMPTS; attempt++) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), HTTP_FETCH_TIMEOUT_MS); + try { const startedAt = performance.now(); + response = await fetch(url, { + headers: { "user-agent": "Mozilla/5.0 Veryfront/1.0" }, + signal, + redirect: "follow", + }); + + const duration = Math.round(performance.now() - startedAt); + contentMetricsLog.debug("HTTP_MODULE_FETCH", { + url: safeUrl, + host: urlObj.host, + duration_ms: duration, + status: response.status, + slow: duration > SLOW_HTTP_FETCH_THRESHOLD_MS, + attempt: attempt + 1, + }); - try { - const response = await withSpan( - SpanNames.HTTP_CLIENT_FETCH, - () => - fetch(url, { - headers: { "user-agent": "Mozilla/5.0 Veryfront/1.0" }, - signal: controller.signal, - redirect: "follow", - }), - { - "http.method": "GET", - "http.url": url, - "http.host": urlObj.host, - "http.scheme": urlObj.protocol.replace(":", ""), - "esm.package_fetch": true, - }, - ); + if (!response.ok) { + const status = response.status; + await discardResponseBody(response); + throw new HttpModuleResponseError(status); + } - const duration = Math.round(performance.now() - startedAt); - contentMetricsLog.debug("HTTP_MODULE_FETCH", { - url: url.substring(0, 120), - host: urlObj.host, - duration_ms: duration, - status: response.status, - slow: duration > SLOW_HTTP_FETCH_THRESHOLD_MS, - attempt, - }); + return { + code: await response.text(), + contentType: response.headers.get("content-type") ?? "", + }; + } catch (error) { + if (error instanceof HttpModuleResponseError) throw error; + if (response) await discardResponseBody(response); - if ( - response.ok || !shouldRetryHttpModuleFetch(response.status) || - attempt === HTTP_MODULE_FETCH_MAX_ATTEMPTS - ) { - return response; - } + const requestErrorType = error instanceof Error ? error.name : typeof error; + throw new HttpModuleRequestError(requestErrorType); + } +} - httpCacheLog.warn("Transient HTTP module fetch failed, retrying", { - url, - status: response.status, - attempt, +async function fetchHttpModule(url: string): Promise { + const urlObj = new URL(url); + const safeUrl = sanitizeUrlForSpan(url); + + try { + return await retryWithBackoff( + (signal, attempt) => + withSpan( + SpanNames.HTTP_CLIENT_FETCH, + () => fetchHttpModuleAttempt(url, safeUrl, urlObj, signal, attempt), + { + "http.method": "GET", + "http.url": safeUrl, + "http.host": urlObj.host, + "http.scheme": urlObj.protocol.replace(":", ""), + "esm.package_fetch": true, + }, + ), + { + maxAttempts: HTTP_MODULE_FETCH_MAX_ATTEMPTS, + timeoutMs: HTTP_FETCH_TIMEOUT_MS, + shouldRetry: (error) => + !(error instanceof HttpModuleResponseError) || + shouldRetryHttpModuleFetch(error.status), + computeDelay: (attempt) => HTTP_MODULE_FETCH_RETRY_DELAY_MS * (attempt + 1), + onRetry: ({ error, attempt }) => { + httpCacheLog.warn("HTTP module fetch failed, retrying", { + url: safeUrl, + status: error instanceof HttpModuleResponseError ? error.status : undefined, + errorType: error instanceof HttpModuleRequestError + ? error.requestErrorType + : error.name, + attempt: attempt + 1, + }); + }, + }, + ); + } catch (error) { + if (error instanceof HttpModuleResponseError) { + throw BUILD_FAILED.create({ detail: `Failed to fetch ${safeUrl}: ${error.status}` }); + } + if (error instanceof HttpModuleRequestError) { + throw BUILD_FAILED.create({ + detail: `Failed to fetch ${safeUrl}: ${error.requestErrorType}`, }); - await discardResponseBody(response); - } catch (error) { - if (attempt === HTTP_MODULE_FETCH_MAX_ATTEMPTS) throw error; - httpCacheLog.warn("HTTP module fetch failed, retrying", { url, attempt, error }); - } finally { - clearTimeout(timeout); } - - await sleep(HTTP_MODULE_FETCH_RETRY_DELAY_MS * attempt); + throw error; } - - throw BUILD_FAILED.create({ detail: `Failed to fetch ${url}` }); } // Re-export for backwards compatibility @@ -333,17 +387,10 @@ async function cacheHttpModuleInternal(url: string, options: CacheOptions): Prom } httpCacheLog.debug("Fetching from network", { url: normalizedUrl }); - const response = await fetchHttpModule(normalizedUrl); - - if (!response.ok) { - const status = response.status; - await discardResponseBody(response); - throw BUILD_FAILED.create({ detail: `Failed to fetch ${normalizedUrl}: ${status}` }); - } - - let code = await response.text(); + const fetchedModule = await fetchHttpModule(normalizedUrl); + let code = fetchedModule.code; - const contentType = response.headers.get("content-type") ?? ""; + const contentType = fetchedModule.contentType; const isHtmlContent = contentType.includes("text/html") || looksLikeHtmlNotJs(code); if (isHtmlContent) { From 051778edadc560becfe84d50417e4f59d5e2d3c2 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 24 Jul 2026 17:04:03 +0200 Subject: [PATCH 5/7] Keep retry regression assertions type-safe The lint gate checks new test files more strictly than the no-check unit runner, so rejected values must be narrowed before their messages are inspected. Constraint: Preserve the existing runtime assertions and retry coverage. Confidence: high Scope-risk: narrow Tested: deno check --no-lock src/transforms/esm/http-cache.test.ts; deno fmt --check; focused test with --trace-leaks Not-tested: Full repository suite for this assertion-only follow-up --- src/transforms/esm/http-cache.test.ts | 33 +++++++++++++++++++-------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/transforms/esm/http-cache.test.ts b/src/transforms/esm/http-cache.test.ts index b49f7de70d..c5636332a4 100644 --- a/src/transforms/esm/http-cache.test.ts +++ b/src/transforms/esm/http-cache.test.ts @@ -1,7 +1,13 @@ import "#veryfront/schemas/_test-setup.ts"; /** @module transforms/esm/http-cache.test */ -import { assert, assertEquals, assertNotEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { + assert, + assertEquals, + assertInstanceOf, + assertNotEquals, + assertRejects, +} from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { join } from "#veryfront/compat/path"; import { @@ -131,14 +137,17 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, }) as typeof fetch; await withIsolatedHttpCache("vf-esm-permanent-failure-", mockFetch, async (tempDir) => { - const error = await assertRejects(() => - cacheModuleToLocal( - "https://esm.sh/missing-package?access_token=super-secret", - tempDir, - ) + const error = await assertRejects( + () => + cacheModuleToLocal( + "https://esm.sh/missing-package?access_token=super-secret", + tempDir, + ), + Error, ); assertEquals(fetchCount, 1); assertEquals(bodyCancelled, true); + assertInstanceOf(error, Error); assert(!error.message.includes("super-secret")); }); }); @@ -202,9 +211,13 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, }) as typeof fetch; await withIsolatedHttpCache("vf-esm-network-failure-", mockFetch, async (tempDir) => { - const error = await assertRejects(() => cacheModuleToLocal(secretUrl, tempDir)); + const error = await assertRejects( + () => cacheModuleToLocal(secretUrl, tempDir), + Error, + ); assertEquals(fetchCount, 3); + assertInstanceOf(error, Error); assert(!error.message.includes("super-secret")); }); }); @@ -227,12 +240,14 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, }) as typeof fetch; await withIsolatedHttpCache("vf-esm-exhausted-retry-", mockFetch, async (tempDir) => { - const error = await assertRejects(() => - cacheModuleToLocal("https://esm.sh/exhausted", tempDir) + const error = await assertRejects( + () => cacheModuleToLocal("https://esm.sh/exhausted", tempDir), + Error, ); assertEquals(fetchCount, 3); assertEquals(cancelledBodies, 3); + assertInstanceOf(error, Error); assert(error.message.includes("503")); }); }); From 9c2c46d23393db9c01108c32c988941b97dfb12a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 24 Jul 2026 18:28:13 +0200 Subject: [PATCH 6/7] Prevent secrets and global activity from weakening cache diagnostics Sanitize credential-bearing module URLs before logs and user-visible bundle errors, and scope the lazy React version assertion to its project path so unrelated Deno.stat calls cannot make the regression test flaky. Constraint: HTTP cache URLs may contain provider credentials and test processes may perform unrelated filesystem probes. Rejected: Keep the HTML response preview in error logs | upstream bodies can contain secrets and do not add actionable cache diagnostics. Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep externally supplied URLs sanitized in every diagnostic and count only project-owned probes in global API stubs. Tested: deno test -A --no-check focused cache and handler suites; deno fmt --check; deno task verify:quick Not-tested: live upstream HTML failure carrying real provider credentials --- .../rsc/endpoints/handler-registry.test.ts | 6 ++-- src/transforms/esm/http-cache.test.ts | 21 +++++++++++++ src/transforms/esm/http-cache.ts | 30 +++++++++---------- 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/server/services/rsc/endpoints/handler-registry.test.ts b/src/server/services/rsc/endpoints/handler-registry.test.ts index 2a188a5204..1c5930f405 100644 --- a/src/server/services/rsc/endpoints/handler-registry.test.ts +++ b/src/server/services/rsc/endpoints/handler-registry.test.ts @@ -44,15 +44,17 @@ describe("server/services/rsc/endpoints/handler-registry", () => { const cache = createStubCache(); __injectCacheForTests(cache); const originalStat = Deno.stat; + const projectDir = "/project/lazy-react-version"; let statCalls = 0; Deno.stat = (path: string | URL) => { - statCalls++; + if (String(path).includes(projectDir)) statCalls++; return originalStat(path); }; try { - getRSCHandler("/project/lazy-react-version"); + getRSCHandler(projectDir); + await Deno.stat("."); await new Promise((resolve) => setTimeout(resolve, 0)); assertEquals(statCalls, 0); diff --git a/src/transforms/esm/http-cache.test.ts b/src/transforms/esm/http-cache.test.ts index c5636332a4..5abf57f661 100644 --- a/src/transforms/esm/http-cache.test.ts +++ b/src/transforms/esm/http-cache.test.ts @@ -222,6 +222,27 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, }); }); + it("does not expose URL credentials when an upstream returns HTML", async () => { + const secretUrl = "https://esm.sh/html-failure?access_token=super-secret"; + const mockFetch = (() => + Promise.resolve( + new Response("upstream failure", { + headers: { "content-type": "text/html" }, + }), + )) as typeof fetch; + + await withIsolatedHttpCache("vf-esm-html-failure-", mockFetch, async (tempDir) => { + const error = await assertRejects( + () => cacheModuleToLocal(secretUrl, tempDir), + Error, + ); + + assertInstanceOf(error, Error); + assert(!error.message.includes("super-secret")); + assert(error.message.includes("https://esm.sh/html-failure")); + }); + }); + it("bounds transient failure attempts and cancels every response body", async () => { let fetchCount = 0; let cancelledBodies = 0; diff --git a/src/transforms/esm/http-cache.ts b/src/transforms/esm/http-cache.ts index 4c38702602..9af72c985b 100644 --- a/src/transforms/esm/http-cache.ts +++ b/src/transforms/esm/http-cache.ts @@ -231,6 +231,7 @@ export { __injectCachesForTests }; async function cacheHttpModuleInternal(url: string, options: CacheOptions): Promise { const normalizedUrl = normalizeHttpUrl(url); + const safeUrl = sanitizeUrlForSpan(normalizedUrl); const cacheDir = ensureAbsoluteDir(options.cacheDir); const cacheIdentity = await buildHttpCacheIdentity(normalizedUrl, options); const identityMetadata = await buildHttpCacheIdentityMetadata(normalizedUrl, options); @@ -254,7 +255,7 @@ async function cacheHttpModuleInternal(url: string, options: CacheOptions): Prom // dependency could not be prefetched. Retry the prefetch instead of // handing the degradation on. httpCacheLog.debug("Local cache holds a degraded artifact, will re-fetch", { - url: normalizedUrl, + url: safeUrl, hash, }); } else { @@ -264,7 +265,7 @@ async function cacheHttpModuleInternal(url: string, options: CacheOptions): Prom const depsValid = await validateBundleDepsExist(deps, cacheDir); if (!depsValid) { httpCacheLog.debug("Local cache has missing deps, will re-fetch", { - url: normalizedUrl, + url: safeUrl, hash, missingDeps: deps.length, }); @@ -301,11 +302,11 @@ async function cacheHttpModuleInternal(url: string, options: CacheOptions): Prom if (processingStack.has(cacheIdentity)) { if (await exists(cachePath)) { httpCacheLog.debug("Circular dependency detected, file exists", { - url: normalizedUrl, + url: safeUrl, }); } else { httpCacheLog.debug("Circular dependency detected, file pending write", { - url: normalizedUrl, + url: safeUrl, cachePath, }); } @@ -335,7 +336,7 @@ async function cacheHttpModuleInternal(url: string, options: CacheOptions): Prom const depsExist = await validateBundleDepsExist(deps, cacheDir); if (!depsExist) { httpCacheLog.debug("Cached code has missing bundle deps, will re-fetch", { - url: normalizedUrl, + url: safeUrl, hash, missingDeps: deps.length, }); @@ -344,7 +345,7 @@ async function cacheHttpModuleInternal(url: string, options: CacheOptions): Prom cacheResult.wasGzipped ? "[HTTP-CACHE] Distributed cache hit (gzip decoded)" : "[HTTP-CACHE] Distributed cache hit", - { url: normalizedUrl, hash }, + { url: safeUrl, hash }, ); await fs.mkdir(cacheDir, { recursive: true }); await fs.writeTextFile(cachePath, cachedCode); @@ -364,7 +365,7 @@ async function cacheHttpModuleInternal(url: string, options: CacheOptions): Prom cacheResult.wasGzipped ? "[HTTP-CACHE] Distributed cache hit (gzip decoded, no deps)" : "[HTTP-CACHE] Distributed cache hit", - { url: normalizedUrl, hash }, + { url: safeUrl, hash }, ); await fs.mkdir(cacheDir, { recursive: true }); await fs.writeTextFile(cachePath, cachedCode); @@ -381,12 +382,12 @@ async function cacheHttpModuleInternal(url: string, options: CacheOptions): Prom } } else if (cacheResult.failReason && cacheResult.failReason !== "not_found") { httpCacheLog.debug("Distributed cache get failed", { - url: normalizedUrl, + url: safeUrl, reason: cacheResult.failReason, }); } - httpCacheLog.debug("Fetching from network", { url: normalizedUrl }); + httpCacheLog.debug("Fetching from network", { url: safeUrl }); const fetchedModule = await fetchHttpModule(normalizedUrl); let code = fetchedModule.code; @@ -397,14 +398,13 @@ async function cacheHttpModuleInternal(url: string, options: CacheOptions): Prom logger.error( "[HTTP-CACHE] Received HTML instead of JavaScript, likely an esm.sh error page", { - url: normalizedUrl, + url: safeUrl, contentType, - preview: code.slice(0, 200), }, ); throw BUNDLE_ERROR.create({ detail: - `Received HTML instead of JavaScript from ${normalizedUrl}. The package may not exist or failed to build on esm.sh.`, + `Received HTML instead of JavaScript from ${safeUrl}. The package may not exist or failed to build on esm.sh.`, }); } @@ -443,9 +443,9 @@ async function cacheHttpModuleInternal(url: string, options: CacheOptions): Prom // retries the prefetch instead of inheriting one upstream blip for the // lifetime of the distributed entry. httpCacheLog.warn("Not caching a module with unresolved dynamic imports", { - url: normalizedUrl, + url: safeUrl, hash, - degraded, + degraded: degraded.map(sanitizeUrlForSpan), }); return cachePath; } @@ -462,7 +462,7 @@ async function cacheHttpModuleInternal(url: string, options: CacheOptions): Prom if (error instanceof VeryfrontError && error.slug === "cache-invariant-violation") { throw error; } - httpCacheLog.debug("Distributed cache set failed", { url: normalizedUrl, error }); + httpCacheLog.debug("Distributed cache set failed", { url: safeUrl, error }); } getCachedPaths().set(cacheKey, cachePath); From 93e60999684062b9d46c5d4d7e4ea80351e6b21b Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 24 Jul 2026 19:16:41 +0200 Subject: [PATCH 7/7] Close release gaps exposed by security and full-suite verification The release candidate was functionally correct, but review and complete validation uncovered an unnecessary jitter call, an imprecise security assertion, three load-sensitive tests, vulnerable transitive packages, internal public-doc wording, and a broken documented Storybook build. Keep each correction at its existing boundary and add regression evidence where behavior changed. Constraint: Preserve the v0.1.1126 release scope and introduce no new dependency. Rejected: Ignore failures outside the original RSC regression | would ship known security and developer-workflow defects. Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep caller timeout overrides deterministic and keep the Storybook Deno config alias explicit. Tested: verify:quick; 82 focused test steps; Storybook boundary test and production build; frozen OpenTelemetry tests; npm audit. Not-tested: Full pre-push suite after this final commit; GitHub CI is the authoritative full-suite rerun. --- deno.lock | 9 ++++--- docs/api-reference/veryfront/index.client.md | 2 +- .../ext-observability-opentelemetry/deno.json | 1 + scripts/storybook/storybook-workbench.test.ts | 2 ++ src/index.client.ts | 27 +++++-------------- src/proxy/routing-invalidation-redis.test.ts | 4 +-- .../chat/components/inline-citation.test.tsx | 9 ++++--- src/react/runtime/router-provider.test.tsx | 6 ++++- src/transforms/esm/http-cache.test.ts | 6 ++++- src/transforms/esm/in-flight-manager.test.ts | 8 ++++++ src/transforms/esm/in-flight-manager.ts | 5 ++-- storybook/.storybook/veryfront-aliases.ts | 1 + storybook/package-lock.json | 14 +++++----- 13 files changed, 51 insertions(+), 43 deletions(-) diff --git a/deno.lock b/deno.lock index 4c3391d64c..3825020231 100644 --- a/deno.lock +++ b/deno.lock @@ -58,6 +58,7 @@ "npm:pdf-lib@1.17.1": "1.17.1", "npm:playwright@*": "1.60.0", "npm:playwright@1.60.0": "1.60.0", + "npm:protobufjs@7.6.5": "7.6.5", "npm:redis@5.11.0": "5.11.0", "npm:rehype-highlight@7.0.2": "7.0.2", "npm:rehype-raw@7.0.0": "7.0.0", @@ -3590,8 +3591,8 @@ "property-information@7.2.0": { "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==" }, - "protobufjs@7.6.3": { - "integrity": "sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==", + "protobufjs@7.6.5": { + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", "dependencies": [ "@protobufjs/aspromise", "@protobufjs/base64", @@ -3599,7 +3600,6 @@ "@protobufjs/eventemitter", "@protobufjs/fetch", "@protobufjs/float", - "@protobufjs/inquire", "@protobufjs/path", "@protobufjs/pool", "@protobufjs/utf8", @@ -5256,7 +5256,8 @@ "npm:@opentelemetry/sdk-metrics@2.9.0", "npm:@opentelemetry/sdk-node@0.220.0", "npm:@opentelemetry/sdk-trace-base@2.9.0", - "npm:@opentelemetry/semantic-conventions@1.43.0" + "npm:@opentelemetry/semantic-conventions@1.43.0", + "npm:protobufjs@7.6.5" ] }, "extensions/ext-parser-babel": { diff --git a/docs/api-reference/veryfront/index.client.md b/docs/api-reference/veryfront/index.client.md index ac9b352a71..e31f748ed3 100644 --- a/docs/api-reference/veryfront/index.client.md +++ b/docs/api-reference/veryfront/index.client.md @@ -1,6 +1,6 @@ --- title: "veryfront/index.client" -description: "Client and SSR safe mirror of the `veryfront` root barrel. Use this import surface in code that can run in a browser bundle. It exposes the same browser-safe helpers as `veryfront` while omitting server bootstrap functions such as `createHandler`, `startServer`, and `toNodeHandler`." +description: "Browser- and SSR-safe helpers from the `veryfront` package. This entrypoint exposes the root package's client-safe configuration, platform, routing, data, and security helpers without server bootstrap functions. Most app code can import these helpers from `veryfront`; use this explicit entrypoint when a browser or SSR module needs to declare that boundary directly." order: 12 --- diff --git a/extensions/ext-observability-opentelemetry/deno.json b/extensions/ext-observability-opentelemetry/deno.json index fd01dca190..c8f4b3516a 100644 --- a/extensions/ext-observability-opentelemetry/deno.json +++ b/extensions/ext-observability-opentelemetry/deno.json @@ -68,6 +68,7 @@ "@opentelemetry/semantic-conventions": "npm:@opentelemetry/semantic-conventions@1.43.0", "@std/assert": "jsr:@std/assert@1.0.19", "@std/testing/bdd": "jsr:@std/testing@1.0.17/bdd", + "protobufjs": "npm:protobufjs@7.6.5", "veryfront/extensions": "../../src/extensions/index.ts", "veryfront/extensions/observability": "../../src/extensions/observability/index.ts", "veryfront/utils": "../../src/utils/index.ts" diff --git a/scripts/storybook/storybook-workbench.test.ts b/scripts/storybook/storybook-workbench.test.ts index 40346e0655..735a96fe56 100644 --- a/scripts/storybook/storybook-workbench.test.ts +++ b/scripts/storybook/storybook-workbench.test.ts @@ -448,6 +448,8 @@ describe("Storybook UI workbench", () => { assertStringIncludes(main, "createVeryfrontAliases"); assertStringIncludes(aliases, "veryfront\\/chat"); assertStringIncludes(aliases, "#veryfront"); + assertStringIncludes(aliases, "#deno-config"); + assertStringIncludes(aliases, "deno.json"); assertStringIncludes( aliases, "storybook/.storybook/shims/veryfront-utils.ts", diff --git a/src/index.client.ts b/src/index.client.ts index 69a4dbfa9e..dfe643658a 100644 --- a/src/index.client.ts +++ b/src/index.client.ts @@ -1,26 +1,11 @@ /** - * Client/SSR-safe mirror of the `veryfront` root barrel ({@link file://./index.ts}). + * Browser- and SSR-safe helpers from the `veryfront` package. * - * The root barrel re-exports the server bootstrap surface (`createHandler`, - * `startServer`, `toNodeHandler`) from `#veryfront/server`. Because the browser - * and SSR pipelines transform modules per-file (no cross-module tree-shaking), - * an ESM re-export eagerly loads its source module, so pulling the root barrel - * into a client chunk drags the entire server graph in, including - * `src/server/production-server.ts`, which has module top-level `await` and - * cannot be transformed to the es2020 browser target (HTTP 500 on that chunk, - * which aborts hydration). - * - * A client-reachable module doing a used value import from the barrel, for - * example `import { getEnv } from "veryfront"`, is not dead-stripped, so it - * keeps the barrel and the leak. This barrel exposes exactly the same - * browser-safe surface minus the server bootstrap functions, which no - * client/SSR page code legitimately calls. The import rewriter redirects - * `veryfront` to this module for the `browser` and `ssr` targets (see - * `src/transforms/import-rewriter/strategies/veryfront-strategy.ts`), the same - * mechanism `veryfront/workflow` already uses. - * - * Keep the exports below in sync with {@link file://./index.ts}: everything - * except the `createHandler` / `startServer` / `toNodeHandler` value exports. + * This entrypoint exposes the root package's client-safe configuration, + * platform, routing, data, and security helpers without server bootstrap + * functions. Most app code can import these helpers from `veryfront`; use this + * explicit entrypoint when a browser or SSR module needs to declare that + * boundary directly. * * @example * ```ts diff --git a/src/proxy/routing-invalidation-redis.test.ts b/src/proxy/routing-invalidation-redis.test.ts index f09c0e4077..5f6347d426 100644 --- a/src/proxy/routing-invalidation-redis.test.ts +++ b/src/proxy/routing-invalidation-redis.test.ts @@ -167,7 +167,7 @@ describe("proxy routing invalidation Redis bus", () => { redisUrl: "redis://example.test:6379", expectedReplicas: 2, replicaId: "replica-a", - acknowledgementTimeoutMs: 10, + acknowledgementTimeoutMs: 100, createClient: redis.createClient, integritySecret, onInvalidate: () => {}, @@ -176,7 +176,7 @@ describe("proxy routing invalidation Redis bus", () => { redisUrl: "redis://example.test:6379", expectedReplicas: 2, replicaId: "replica-b", - acknowledgementTimeoutMs: 10, + acknowledgementTimeoutMs: 100, createClient: redis.createClient, integritySecret, onInvalidate: () => { diff --git a/src/react/components/chat/chat/components/inline-citation.test.tsx b/src/react/components/chat/chat/components/inline-citation.test.tsx index 06f4048f65..3aeefb6715 100644 --- a/src/react/components/chat/chat/components/inline-citation.test.tsx +++ b/src/react/components/chat/chat/components/inline-citation.test.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { JSDOM } from "npm:jsdom@28.0.0"; import { assert, assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { waitFor } from "#veryfront/testing/deno-compat.ts"; import { InlineCitation } from "./inline-citation.tsx"; function installDomGlobals(dom: JSDOM): () => void { @@ -74,10 +75,10 @@ describe("InlineCitation", () => { assert(trigger.className.includes("vf-citation-trigger")); trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); - await new Promise((resolve) => setTimeout(resolve, 170)); - flushSync(() => { - // Flush the hover timer's state update before inspecting the DOM. - }); + await waitFor( + () => document.querySelector(".vf-citation-card") !== null, + { interval: 10, message: "Citation card did not render after hover" }, + ); const card = document.querySelector(".vf-citation-card"); assert(card, "Expected citation card to render after hover"); diff --git a/src/react/runtime/router-provider.test.tsx b/src/react/runtime/router-provider.test.tsx index 03b8279bee..3a0916f30c 100644 --- a/src/react/runtime/router-provider.test.tsx +++ b/src/react/runtime/router-provider.test.tsx @@ -5,6 +5,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { JSDOM } from "npm:jsdom@28.0.0"; import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { waitFor } from "#veryfront/testing/deno-compat.ts"; import { PageContextProvider, type PageContextValue, @@ -395,7 +396,10 @@ describe("react/runtime/RouterProvider (reactive)", () => { , ); }); - await tick(); + await waitFor( + () => (rootElement.textContent ?? "").includes("m:true"), + { interval: 10, message: "RouterProvider did not finish its client mount" }, + ); assertStringIncludes(rootElement.textContent ?? "", "m:true"); root.unmount(); } finally { diff --git a/src/transforms/esm/http-cache.test.ts b/src/transforms/esm/http-cache.test.ts index 5abf57f661..c2304525de 100644 --- a/src/transforms/esm/http-cache.test.ts +++ b/src/transforms/esm/http-cache.test.ts @@ -239,7 +239,11 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, assertInstanceOf(error, Error); assert(!error.message.includes("super-secret")); - assert(error.message.includes("https://esm.sh/html-failure")); + assertEquals( + error.message, + "Received HTML instead of JavaScript from https://esm.sh/html-failure. " + + "The package may not exist or failed to build on esm.sh.", + ); }); }); diff --git a/src/transforms/esm/in-flight-manager.test.ts b/src/transforms/esm/in-flight-manager.test.ts index 244ab8df2c..e30ecbbfa8 100644 --- a/src/transforms/esm/in-flight-manager.test.ts +++ b/src/transforms/esm/in-flight-manager.test.ts @@ -60,16 +60,24 @@ describe("transforms/esm/in-flight-manager", () => { }); it("honors a caller-provided wait window", async () => { + const originalRandom = Math.random; + let randomCalls = 0; let timer: ReturnType; const slowResult = new Promise((resolve) => { timer = setTimeout(() => resolve("late"), 25); }); try { + Math.random = () => { + randomCalls += 1; + return 0.5; + }; const result = await waitForInFlightFetch(slowResult, "key", 5); assertEquals(result, undefined); + assertEquals(randomCalls, 0); } finally { + Math.random = originalRandom; clearTimeout(timer!); } }); diff --git a/src/transforms/esm/in-flight-manager.ts b/src/transforms/esm/in-flight-manager.ts index 5f95438054..cb706fd341 100644 --- a/src/transforms/esm/in-flight-manager.ts +++ b/src/transforms/esm/in-flight-manager.ts @@ -52,8 +52,9 @@ export async function waitForInFlightFetch( cacheKey: string, waitTimeoutMs?: number, ): Promise { - const jitter = Math.floor(Math.random() * IN_FLIGHT_JITTER_MS); - const timeoutMs = waitTimeoutMs ?? IN_FLIGHT_WAIT_TIMEOUT_MS + jitter; + const timeoutMs = waitTimeoutMs === undefined + ? IN_FLIGHT_WAIT_TIMEOUT_MS + Math.floor(Math.random() * IN_FLIGHT_JITTER_MS) + : waitTimeoutMs; let timeoutId: ReturnType; const timeoutPromise = new Promise((resolve) => { diff --git a/storybook/.storybook/veryfront-aliases.ts b/storybook/.storybook/veryfront-aliases.ts index b4c69f783b..a8015707fa 100644 --- a/storybook/.storybook/veryfront-aliases.ts +++ b/storybook/.storybook/veryfront-aliases.ts @@ -54,6 +54,7 @@ export function createVeryfrontAliases(): AliasOptions { find: /^veryfront\/fonts$/, replacement: sourcePath("src/react/fonts/index.ts"), }, + { find: /^#deno-config$/, replacement: sourcePath("deno.json") }, { find: /^#veryfront\/agent\/react$/, replacement: sourcePath("src/agent/react/index.ts"), diff --git a/storybook/package-lock.json b/storybook/package-lock.json index e29c7d4e00..bebf0712e5 100644 --- a/storybook/package-lock.json +++ b/storybook/package-lock.json @@ -4098,9 +4098,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -4291,9 +4291,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", "dev": true, "funding": [ { @@ -4311,7 +4311,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" },