diff --git a/src/cache/keys.test.ts b/src/cache/keys.test.ts index e912659b4e..d4a1e0955a 100644 --- a/src/cache/keys.test.ts +++ b/src/cache/keys.test.ts @@ -340,6 +340,37 @@ describe("cache/keys", () => { assertEquals(release.includes("release:release-1"), true); }); + it("separates preview environments so adapter identities cannot collide", () => { + const unnamed = buildProxyManagerCacheKey("example-project", false, null, "main"); + const preview = buildProxyManagerCacheKey("example-project", false, null, "main", "preview"); + + assertNotEquals(unnamed, preview); + }); + + it("separates distinct preview environment names on the same branch", () => { + const preview = buildProxyManagerCacheKey("example-project", false, null, "main", "preview"); + const staging = buildProxyManagerCacheKey("example-project", false, null, "main", "staging"); + + assertNotEquals(preview, staging); + }); + + it("keeps the branch key stable when no environment is named", () => { + assertEquals( + buildProxyManagerCacheKey("example-project", false, null, "main"), + "proxy:example-project:preview:main", + ); + }); + + it("escapes delimiters in an environment name", () => { + const forged = buildProxyManagerCacheKey("example-project", false, null, "main", "a:b"); + + assertEquals(forged.includes("a:b"), false); + assertNotEquals( + forged, + buildProxyManagerCacheKey("example-project", false, null, "main", "a"), + ); + }); + it("separates canonical projects and credential principals", () => { const first = buildProxyManagerCacheKey( "reusable-slug", diff --git a/src/cache/keys/builders/render.ts b/src/cache/keys/builders/render.ts index de22c2c753..f64c0de8d7 100644 --- a/src/cache/keys/builders/render.ts +++ b/src/cache/keys/builders/render.ts @@ -138,7 +138,12 @@ export function buildProxyManagerCacheKey( } const source = encodeCacheSourceIdentity({ type: "branch", branch: branch ?? "main" }); - return `${CacheKeyPrefix.PROXY}:${projectSlug}:${mode}:${source.qualifier}${authorityKey}`; + // ProxyFSAdapterManager asserts environmentName matches on reuse, so it must + // be part of the key. Omitted when unnamed to keep existing keys stable. + const environmentQualifier = environmentName + ? `:env:${encodeCacheKeyLiteralSegment(environmentName)}` + : ""; + return `${CacheKeyPrefix.PROXY}:${projectSlug}:${mode}:${source.qualifier}${environmentQualifier}${authorityKey}`; } /** diff --git a/src/platform/adapters/fs/veryfront/proxy-manager.test.ts b/src/platform/adapters/fs/veryfront/proxy-manager.test.ts index 4d9877393c..a4cd67cfd4 100644 --- a/src/platform/adapters/fs/veryfront/proxy-manager.test.ts +++ b/src/platform/adapters/fs/veryfront/proxy-manager.test.ts @@ -5,6 +5,7 @@ import { assertExists, assertNotStrictEquals, assertRejects, + assertStrictEquals, assertThrows, } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; @@ -138,6 +139,168 @@ describe("ProxyFSAdapterManager", () => { }); }); + describe("adapter identity", () => { + function stubbedManager(): ProxyFSAdapterManager { + return createManager({ + adapterFactory: (config) => { + const adapter = new VeryfrontFSAdapter(config); + adapter.initialize = () => Promise.resolve(); + return adapter; + }, + }); + } + + it("keeps distinct preview environments on separate adapters", async () => { + const manager = stubbedManager(); + try { + const unnamed = await manager.getAdapter( + "my-project", + "test-token", + undefined, + false, + null, + null, + "main", + ); + const preview = await manager.getAdapter( + "my-project", + "test-token", + undefined, + false, + null, + "preview", + "main", + ); + + assertNotStrictEquals(unnamed, preview); + } finally { + manager.dispose(); + } + }); + + it("reuses an unnamed preview adapter after a named one is created", async () => { + const manager = stubbedManager(); + try { + const first = await manager.getAdapter( + "my-project", + "test-token", + undefined, + false, + null, + null, + "main", + ); + const named = await manager.getAdapter( + "my-project", + "test-token", + undefined, + false, + null, + "preview", + "main", + ); + const again = await manager.getAdapter( + "my-project", + "test-token", + undefined, + false, + null, + null, + "main", + ); + + assertNotStrictEquals(first, named); + assertStrictEquals(first, again); + } finally { + manager.dispose(); + } + }); + + it("treats an empty environment name as unnamed", async () => { + const manager = stubbedManager(); + try { + const empty = await manager.getAdapter( + "my-project", + "test-token", + undefined, + false, + null, + "", + "main", + ); + const unnamed = await manager.getAdapter( + "my-project", + "test-token", + undefined, + false, + null, + null, + "main", + ); + + assertStrictEquals(empty, unnamed); + } finally { + manager.dispose(); + } + }); + + it("ignores releaseId when resolving a preview adapter identity", async () => { + const manager = stubbedManager(); + try { + const withRelease = await manager.getAdapter( + "my-project", + "test-token", + undefined, + false, + "release-7", + null, + "main", + ); + const withoutRelease = await manager.getAdapter( + "my-project", + "test-token", + undefined, + false, + null, + null, + "main", + ); + + assertStrictEquals(withRelease, withoutRelease); + } finally { + manager.dispose(); + } + }); + + it("ignores branch when resolving a production adapter identity", async () => { + const manager = stubbedManager(); + try { + const withBranch = await manager.getAdapter( + "my-project", + "test-token", + undefined, + true, + "release-42", + "Production", + "main", + ); + const withoutBranch = await manager.getAdapter( + "my-project", + "test-token", + undefined, + true, + "release-42", + "Production", + null, + ); + + assertStrictEquals(withBranch, withoutBranch); + } finally { + manager.dispose(); + } + }); + }); + describe("exact production source", () => { it("rejects mutable environment selection without an immutable release", async () => { const manager = createManager(); diff --git a/src/platform/adapters/fs/veryfront/proxy-manager.ts b/src/platform/adapters/fs/veryfront/proxy-manager.ts index 4e9d5076f2..c17b605733 100644 --- a/src/platform/adapters/fs/veryfront/proxy-manager.ts +++ b/src/platform/adapters/fs/veryfront/proxy-manager.ts @@ -123,9 +123,11 @@ export class ProxyFSAdapterManager { const getAdapterStartTime = performance.now(); const effectiveProductionMode = productionMode ?? false; - const effectiveReleaseId = releaseId ?? null; - const effectiveEnvironmentName = environmentName ?? null; - const effectiveBranch = branch ?? (effectiveProductionMode ? null : "main"); + // All three must use the same predicate the cache key uses, or an identity + // that is not part of the key can still differ and fail the reuse assertion. + const effectiveReleaseId = effectiveProductionMode ? (releaseId ?? null) : null; + const effectiveEnvironmentName = environmentName || null; + const effectiveBranch = effectiveProductionMode ? null : (branch ?? "main"); if ( this.baseConfig.veryfront?.proxyMode === true && diff --git a/src/server/handlers/preview/hmr.handler.test.ts b/src/server/handlers/preview/hmr.handler.test.ts index 0718ba8bb9..01c1210d32 100644 --- a/src/server/handlers/preview/hmr.handler.test.ts +++ b/src/server/handlers/preview/hmr.handler.test.ts @@ -547,4 +547,43 @@ describe("server/handlers/preview/hmr.handler", () => { assertEquals(result.response!.status, 501); }); }); + + describe("ensureAdapterInitialized", () => { + it("warms the adapter for the named environment, not the mode", async () => { + let observed: Record | undefined; + const handler = new HMRHandler(); + const ctx = { + projectSlug: "demo-project", + proxyToken: "test-token", + projectId: "proj_123", + resolvedEnvironment: "preview", + environmentName: "Development", + requestContext: { branch: "main" }, + adapter: { + fs: { + isVeryfrontAdapter: () => true, + getUnderlyingAdapter: () => undefined, + isMultiProjectMode: () => true, + runWithContext: ( + _slug: string, + _token: string, + run: () => Promise, + _projectId: string, + options: Record, + ) => { + observed = options; + return run(); + }, + exists: () => Promise.resolve(true), + }, + }, + } as unknown as HandlerContext; + + await (handler as unknown as { + ensureAdapterInitialized(ctx: HandlerContext): Promise; + }).ensureAdapterInitialized(ctx); + + assertEquals(observed?.environmentName, "Development"); + }); + }); }); diff --git a/src/server/handlers/preview/hmr.handler.ts b/src/server/handlers/preview/hmr.handler.ts index 6aa325ebd3..cfd9bce69b 100644 --- a/src/server/handlers/preview/hmr.handler.ts +++ b/src/server/handlers/preview/hmr.handler.ts @@ -248,6 +248,10 @@ export class HMRHandler extends BaseHandler { { productionMode: false, branch: ctx.requestContext?.branch ?? "main", + // The named environment, not the mode in `resolvedEnvironment`. Must + // match what renders resolve, or HMR warms a different adapter and + // its WebSocketManager never receives pokes. + environmentName: ctx.environmentName ?? null, }, ); } catch (error) {