From 3cdb27086828cc8af7c6010358485b889423b35f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 8 Aug 2026 12:29:38 +0200 Subject: [PATCH 1/2] fix(proxy): answer 404 for a veryfront domain that names no project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `staging.veryfront.com` and `preview.veryfront.com` match the environment-root branch in `parseProjectDomain`: a veryfront domain with slug `null`. The handler returned a forwarding context for that state, so the request reached the runtime with `x-project-slug: ""` and came back 502 "Missing project context". That path could never succeed. The runtime has no project to serve and the header it needs cannot be filled in later, so every such request spent a round trip to report a configuration gap as an upstream failure. A custom domain in exactly this state already answers 404 "No project configured for domain", which is why `development.veryfront.com` — not in the environment-root list, so parsed as a custom domain — behaves correctly today while its two siblings do not. Answer the same way for both. --- src/proxy/handler.test.ts | 31 +++++++++++++++++++++++++++++++ src/proxy/handler.ts | 21 ++++++++++----------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/proxy/handler.test.ts b/src/proxy/handler.test.ts index 946e12b6a4..7ef43b891b 100644 --- a/src/proxy/handler.test.ts +++ b/src/proxy/handler.test.ts @@ -1608,6 +1608,37 @@ describe("Proxy Handler", () => { } }); + it("returns 404 for an environment root that names no project", async () => { + // staging.veryfront.com parses as an environment root: a veryfront domain + // with slug null. It used to be forwarded with x-project-slug: "", which + // the runtime answers 502 "Missing project context" — a config gap + // reported as an upstream failure. It is the same condition a custom + // domain answers 404 for. + const { server, port } = createMockServer((req: Request) => { + const { pathname } = new URL(req.url); + if (pathname === "/auth/token") return createTokenResponse(); + return createNotFoundResponse(); + }); + + try { + const handler = createHandler(port); + + for (const host of ["staging.veryfront.com", "preview.veryfront.com"]) { + const ctx = await handler.processRequest( + new Request(`http://${host}/page`, { headers: { host } }), + ); + + assertEquals(ctx.projectSlug, undefined); + assertEquals(ctx.error?.status, 404); + assertEquals(ctx.error?.message, `No project configured for domain: ${host}`); + } + + await handler.close(); + } finally { + await server.shutdown(); + } + }); + it("returns 404 error when custom domain not found", async () => { const { server, port } = createMockServer((req: Request) => { const { pathname } = new URL(req.url); diff --git a/src/proxy/handler.ts b/src/proxy/handler.ts index 51dc77dfa7..9549b698d8 100644 --- a/src/proxy/handler.ts +++ b/src/proxy/handler.ts @@ -815,17 +815,16 @@ export function createProxyHandler(options: ProxyHandlerOptions) { }, logger); if (!projectSlug && parsedDomain.isVeryfrontDomain) { - return { - token: undefined, - projectSlug: undefined, - projectId: undefined, - environment: "preview", - contentSourceId: "no-project", - localPath: undefined, - host, - parsedDomain, - isLocalProject: false, - }; + // An environment root (staging.veryfront.com) or a bare dev domain names no + // project. Forwarding it sends x-project-slug: "" to the runtime, which + // answers 502 "Missing project context" — a config gap reported as an + // upstream failure. A custom domain in the same state already answers 404, + // so answer the same way here. + logger?.info("No project for veryfront domain", { host }); + return createProxyErrorContext(base, { + status: 404, + message: `No project configured for domain: ${host}`, + }); } const localPath = projectSlug ? await localProjectResolver.find(projectSlug) : undefined; From 9d8bd5616c8164be91322241f4fbb3981d55bec6 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 8 Aug 2026 12:42:10 +0200 Subject: [PATCH 2/2] fix(proxy): limit the project-less 404 to hosted domains The first version of this change returned 404 for every project-less veryfront domain, which broke local development: `ProjectsHandler` is enabled for exactly `isVeryfrontDomain && !projectSlug` and serves the project chooser at `/` and `/_projects`, so the forwarding path was not dead after all. A project-less host means two different things depending on where it is. Locally it means no project has been chosen yet and the chooser answers. Hosted it means the domain names no project at all and nothing can answer. `isHostedVeryfrontDomain` names that distinction so the 404 applies only to the hosted case. `isLocalDevHost` would have been the wrong predicate: it returns false for `staging.lvh.me`, which would have left that chooser broken. --- src/proxy/handler.test.ts | 48 +++++++++++++++++++++++++++++-- src/proxy/handler.ts | 44 +++++++++++++++++++++------- src/server/utils/domain-parser.ts | 12 ++++++++ 3 files changed, 91 insertions(+), 13 deletions(-) diff --git a/src/proxy/handler.test.ts b/src/proxy/handler.test.ts index 7ef43b891b..edeb8974f0 100644 --- a/src/proxy/handler.test.ts +++ b/src/proxy/handler.test.ts @@ -1608,7 +1608,7 @@ describe("Proxy Handler", () => { } }); - it("returns 404 for an environment root that names no project", async () => { + it("returns 404 for a hosted environment root that names no project", async () => { // staging.veryfront.com parses as an environment root: a veryfront domain // with slug null. It used to be forwarded with x-project-slug: "", which // the runtime answers 502 "Missing project context" — a config gap @@ -1623,7 +1623,14 @@ describe("Proxy Handler", () => { try { const handler = createHandler(port); - for (const host of ["staging.veryfront.com", "preview.veryfront.com"]) { + for ( + const host of [ + "staging.veryfront.com", + "preview.veryfront.com", + "production.veryfront.com", + "staging.veryfront.org", + ] + ) { const ctx = await handler.processRequest( new Request(`http://${host}/page`, { headers: { host } }), ); @@ -1639,6 +1646,43 @@ describe("Proxy Handler", () => { } }); + it("keeps project-less local dev hosts reachable for the project chooser", async () => { + // Locally a project-less veryfront host is not a misconfiguration: it is + // how the chooser is reached. ProjectsHandler is enabled for exactly + // `isVeryfrontDomain && !projectSlug`, so these must keep forwarding + // rather than 404 like their hosted counterparts. + const { server, port } = createMockServer((req: Request) => { + const { pathname } = new URL(req.url); + if (pathname === "/auth/token") return createTokenResponse(); + return createNotFoundResponse(); + }); + + try { + const handler = createHandler(port); + + for ( + const host of [ + "lvh.me", + "veryfront.me", + "veryfront.dev", + "preview.lvh.me", + "staging.lvh.me", + ] + ) { + const ctx = await handler.processRequest( + new Request(`http://${host}/`, { headers: { host } }), + ); + + assertEquals(ctx.error, undefined, `${host} must not be an error context`); + assertEquals(ctx.contentSourceId, "no-project"); + } + + await handler.close(); + } finally { + await server.shutdown(); + } + }); + it("returns 404 error when custom domain not found", async () => { const { server, port } = createMockServer((req: Request) => { const { pathname } = new URL(req.url); diff --git a/src/proxy/handler.ts b/src/proxy/handler.ts index 9549b698d8..bbe9d50508 100644 --- a/src/proxy/handler.ts +++ b/src/proxy/handler.ts @@ -1,5 +1,9 @@ import { TokenManager, type TokenScope } from "./token-manager.ts"; -import { type ParsedDomain, parseProjectDomain } from "#veryfront/server/utils/domain-parser.ts"; +import { + isHostedVeryfrontDomain, + type ParsedDomain, + parseProjectDomain, +} from "#veryfront/server/utils/domain-parser.ts"; import type { TokenCache } from "./cache/types.ts"; import { computeContentSourceId } from "#veryfront/cache/keys.ts"; import { getEnv } from "#veryfront/platform/compat/process.ts"; @@ -815,16 +819,34 @@ export function createProxyHandler(options: ProxyHandlerOptions) { }, logger); if (!projectSlug && parsedDomain.isVeryfrontDomain) { - // An environment root (staging.veryfront.com) or a bare dev domain names no - // project. Forwarding it sends x-project-slug: "" to the runtime, which - // answers 502 "Missing project context" — a config gap reported as an - // upstream failure. A custom domain in the same state already answers 404, - // so answer the same way here. - logger?.info("No project for veryfront domain", { host }); - return createProxyErrorContext(base, { - status: 404, - message: `No project configured for domain: ${host}`, - }); + // A hosted environment root (staging.veryfront.com) names no project and + // nothing downstream can supply one, so forwarding only sends + // x-project-slug: "" and earns 502 "Missing project context" — a + // configuration gap reported as an upstream failure. A custom domain in + // that state already answers 404. + // + // Locally the same shape means something else: on lvh.me and friends a + // project-less host is how the project chooser is reached, so those keep + // forwarding. See ProjectsHandler, enabled for exactly this state. + if (isHostedVeryfrontDomain(host)) { + logger?.info("No project for hosted veryfront domain", { host }); + return createProxyErrorContext(base, { + status: 404, + message: `No project configured for domain: ${host}`, + }); + } + + return { + token: undefined, + projectSlug: undefined, + projectId: undefined, + environment: "preview", + contentSourceId: "no-project", + localPath: undefined, + host, + parsedDomain, + isLocalProject: false, + }; } const localPath = projectSlug ? await localProjectResolver.find(projectSlug) : undefined; diff --git a/src/server/utils/domain-parser.ts b/src/server/utils/domain-parser.ts index 44736b89b2..8bbd8a1e7a 100644 --- a/src/server/utils/domain-parser.ts +++ b/src/server/utils/domain-parser.ts @@ -178,6 +178,18 @@ export function parseProjectDomain(host: string): ParsedDomain { return createParsedDomain(null, null, null, false, false); } +/** + * Whether the host is a hosted veryfront domain (veryfront.com / veryfront.org) + * rather than one of the local development domains. + * + * The two differ in what a project-less host means. Locally it means "no project + * chosen yet" and the project chooser answers; hosted it means the domain names + * no project at all and nothing can answer. + */ +export function isHostedVeryfrontDomain(host: string): boolean { + return new RegExp(`^(?:.+\\.)?(${PROD_DOMAINS})$`, "i").test(stripPort(host)); +} + /** * Check if a domain is a valid veryfront domain (includes veryfront.me and lvh.me for local dev) */