diff --git a/src/server/runtime-handler/adapter-factory.test.ts b/src/server/runtime-handler/adapter-factory.test.ts index 73b883921b..cfdffff0f4 100644 --- a/src/server/runtime-handler/adapter-factory.test.ts +++ b/src/server/runtime-handler/adapter-factory.test.ts @@ -522,6 +522,53 @@ describe("adapter-factory", () => { assertEquals("isLocalProject" in result, true); }); + it("survives an adapter cache that drops the entry it was just given", async () => { + Deno.env.set("VERYFRONT_TRUST_FORWARDED_HEADERS", "1"); + const cache = new ProjectDiscoveryCache(); + const adapter = createMockAdapter({ + "/trusted/project": { isDirectory: true }, + "/trusted/project/app": { isDirectory: true }, + }); + + // The adapter LRU estimates a RuntimeAdapter's size and evicts it again + // when it exceeds the byte budget, so set() can silently store nothing. + // Observed under Bun 1.3.6, where the same adapter object that caches + // fine on Node is dropped: set() then has()===false and size===0. + // resolveAdapter must not depend on reading back what it just wrote. + cache.adapters.set = () => {}; + + const result = await resolveAdapter({ + req: await makeReq({ projectPath: "/trusted/project", trusted: true }), + projectDir: "/base/project", + adapter, + config: undefined, + projectSlug: "myproject", + projectId: "proj_123", + proxyToken: undefined, + releaseId: undefined, + proxyEnv: "preview", + branch: null, + environmentName: undefined, + parsedDomain: { + slug: null, + branch: null, + environment: null, + isVeryfrontDomain: false, + isDraft: false, + allowIframeEmbed: false, + }, + isProxyMode: true, + cache, + prepareHostedConfigContext: preparePreviewHostedConfigContext, + }); + + assertEquals( + result.adapter === undefined || result.adapter === null, + false, + "resolveAdapter must hand on a real adapter even when the cache drops it", + ); + }); + it("uses injected cache instead of default singleton", async () => { Deno.env.set("VERYFRONT_TRUST_FORWARDED_HEADERS", "1"); const cache = new ProjectDiscoveryCache(); diff --git a/src/server/runtime-handler/adapter-factory.ts b/src/server/runtime-handler/adapter-factory.ts index a082bfa924..9bb4ac91e8 100644 --- a/src/server/runtime-handler/adapter-factory.ts +++ b/src/server/runtime-handler/adapter-factory.ts @@ -222,17 +222,27 @@ export async function resolveAdapter( projectDir: effectiveProjectDir, }); - // Get or create local adapter - if (!cache.adapters.has(effectiveProjectDir)) { - const baseAdapter = await runtime.get(); - cache.adapters.set(effectiveProjectDir, baseAdapter); + // Get or create local adapter. + // + // Hold the adapter rather than reading it back: the cache is an LRU that + // estimates each value's size and can evict an oversized entry as part of + // the same set(), so a write is not guaranteed to be readable afterwards. + // A RuntimeAdapter crosses that budget under Bun, where set() then leaves + // has() === false and size === 0, and the non-null assertion this replaces + // turned that miss into an undefined adapter that reached getConfig and + // threw "undefined is not an object (evaluating 'adapter.fs')" on every + // request. Caching stays best effort; correctness no longer depends on it. + let localAdapter = cache.adapters.get(effectiveProjectDir); + if (!localAdapter) { + localAdapter = await runtime.get(); + cache.adapters.set(effectiveProjectDir, localAdapter); logger.debug("Created local adapter for project", { projectSlug: opts.projectSlug, projectDir: effectiveProjectDir, }); } - effectiveAdapter = cache.adapters.get(effectiveProjectDir)!; + effectiveAdapter = localAdapter; if (shouldDeferConfigLoad(opts)) { effectiveConfig = undefined;