diff --git a/CHANGELOG.md b/CHANGELOG.md index 014f7fc..65b84bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +- An app-supplied `404.html` at the site root is served with status 404 for + misses, under SPA fallback for paths with an extension and under `--no-spa` + for every miss. Without it, misses keep the plain-text 404. +- Docs: `--no-spa` is now recommended for multi-page and content sites, such as + podcast audio and Markdown transcripts, where unknown URLs should 404. + ## 0.2.1 - Smoother migration story, with more automatic fallbacks to the old component diff --git a/INTEGRATION.md b/INTEGRATION.md index 14d7038..44cf8be 100644 --- a/INTEGRATION.md +++ b/INTEGRATION.md @@ -325,8 +325,9 @@ equivalents: `publicPath` and `assetPrefix`. Requests for an extension-less path that doesn't match an uploaded file fall back to `index.html`, so client-side routes survive a reload. Paths with an extension (e.g. `/missing.js`) always 404 when not found. To turn the fallback -off for a multi-page app (unknown paths become real 404s), deploy with -`--no-spa`: +off for a multi-page or content site (unknown paths become real 404s, which +is the right choice for a site of podcast audio and Markdown transcripts), deploy +with `--no-spa`: ```bash npx @convex-dev/static-hosting deploy --no-spa @@ -337,6 +338,10 @@ The flag is stored on the deployment record (the `deploymentInfo` table), so the serving behavior travels with the code you ship rather than living in a separate env var. Re-deploy without `--no-spa` or with `--spa` to turn it back on. +Upload a `404.html` at the site root to show your own not-found page. A miss is +answered with that page and status 404. Under SPA fallback, extension-less paths +still serve `index.html`; under `--no-spa`, every miss gets the page. + ## Upgrading from 0.1.x 0.2.0 is a **storage-breaking change**. Every existing app must remove its diff --git a/README.md b/README.md index 4ce1f0d..3876dc2 100644 --- a/README.md +++ b/README.md @@ -351,10 +351,20 @@ should be a real 404, deploy with `--no-spa`: npx @convex-dev/static-hosting deploy --no-spa ``` +Use `--no-spa` for any site that is not a single-page app: a multi-page site, or +a content site such as podcast audio and Markdown transcripts. Without it, an +unknown page URL serves your homepage with status 200. With it, unknown URLs +return 404. + The setting is stored with the deployment, so it travels with the code you ship rather than living in a separate env var. Requests for paths with an extension (e.g. `/missing.js`) always 404 when not found, regardless of this setting. +To show your own not-found page, upload a `404.html` at the site root. A miss is +then answered with that page and status 404. Under SPA fallback, extension-less +paths still serve `index.html`, so the page appears for misses such as +`/missing.js`. Under `--no-spa`, it appears for every miss. + ## Upgrading from 0.1.x 0.2.0 moves uploads and file storage into the component. You must remove the diff --git a/src/client/index.test.ts b/src/client/index.test.ts index abd5c62..c52f611 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -302,3 +302,141 @@ describe("exposeDeploymentQuery", () => { }); }); }); +describe("app 404 page through registerStaticRoutes", () => { + test("a miss is answered with the storage-backed 404 page and status 404", async () => { + const handler = staticHandler(); + const runQuery = vi.fn(async (_fn: unknown, args: { path: string }) => + args.path === "/404.html" + ? { + storageUrl: "https://storage.example/404", + contentType: "text/html; charset=utf-8", + etag: '"nf"', + } + : null, + ); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response("

Gone

")), + ); + + const response = await invokeHandler( + handler, + runQuery, + new Request("https://app.convex.site/missing.js"), + ); + + expect(response.status).toBe(404); + expect(response.headers.get("Content-Type")).toBe( + "text/html; charset=utf-8", + ); + expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(await response.text()).toBe("

Gone

"); + }); + + test("a miss without an app 404 page keeps the plain-text 404", async () => { + const handler = staticHandler(); + const runQuery = vi.fn().mockResolvedValue(null); + + const response = await invokeHandler( + handler, + runQuery, + new Request("https://app.convex.site/missing.js"), + ); + + expect(response.status).toBe(404); + expect(response.headers.get("Content-Type")).toBe("text/plain"); + expect(await response.text()).toBe("Not Found"); + }); + + test.each([true, false])( + "an inherited app-storage 404 page falls back when missing: %s", + async (missing) => { + const handler = staticHandler(); + const runQuery = vi.fn(async (_fn: unknown, args: { path: string }) => + args.path === "/404.html" ? { appStorageId: "legacy-id" } : null, + ); + const get = vi + .fn() + .mockResolvedValue(missing ? null : new Blob(["

Gone

"])); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const response = await ( + handler as unknown as { + _handler: ( + ctx: { runQuery: typeof runQuery; storage: { get: typeof get } }, + request: Request, + ) => Promise; + } + )._handler( + { runQuery, storage: { get } }, + new Request("https://app.convex.site/missing.js"), + ); + + expect(get).toHaveBeenCalledWith("legacy-id"); + expect(fetchMock).not.toHaveBeenCalled(); + expect(response.status).toBe(404); + expect(response.headers.get("Content-Type")).toBe( + missing ? "text/plain" : "text/html; charset=utf-8", + ); + if (!missing) { + expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff"); + } + expect(await response.text()).toBe( + missing ? "Not Found" : "

Gone

", + ); + }, + ); +}); + +describe("app 404 page upstream failure", () => { + test("a rejected fetch for the 404 page falls back to plain text", async () => { + const handler = staticHandler(); + const runQuery = vi.fn(async (_fn: unknown, args: { path: string }) => + args.path === "/404.html" + ? { storageUrl: "https://storage.example/404" } + : null, + ); + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new TypeError("fetch failed")), + ); + + const response = await invokeHandler( + handler, + runQuery, + new Request("https://app.convex.site/missing.js"), + ); + + expect(response.status).toBe(404); + expect(response.headers.get("Content-Type")).toBe("text/plain"); + expect(await response.text()).toBe("Not Found"); + }); + + test("a failed upstream fetch for the 404 page falls back to plain text", async () => { + const handler = staticHandler(); + const runQuery = vi.fn(async (_fn: unknown, args: { path: string }) => + args.path === "/404.html" + ? { + storageUrl: "https://storage.example/404", + contentType: "text/html; charset=utf-8", + etag: '"nf"', + } + : null, + ); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response("boom", { status: 500 })), + ); + + const response = await invokeHandler( + handler, + runQuery, + new Request("https://app.convex.site/missing.js"), + ); + + expect(response.status).toBe(404); + expect(response.headers.get("Content-Type")).toBe("text/plain"); + expect(await response.text()).toBe("Not Found"); + }); +}); diff --git a/src/client/index.ts b/src/client/index.ts index 15a9534..28715a3 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -12,6 +12,8 @@ import { getSetupHtml, resolveContentType, isHtmlContentType, + NOT_FOUND_PAGE_HEADERS, + NOT_FOUND_PAGE_PATH, } from "../component/serving.js"; const deploymentInfoValidator = v.object({ @@ -100,6 +102,30 @@ export function registerStaticRoutes( }, }); } + const notFoundPage = await ctx.runQuery( + component.lib.resolveAssetForHttp, + { path: NOT_FOUND_PAGE_PATH }, + ); + if (notFoundPage?.appStorageId) { + const blob = await ctx.storage.get( + notFoundPage.appStorageId as GenericId<"_storage">, + ); + if (blob) { + return new Response(blob, { + status: 404, + headers: NOT_FOUND_PAGE_HEADERS, + }); + } + } + if (notFoundPage?.storageUrl) { + const upstream = await fetch(notFoundPage.storageUrl).catch(() => null); + if (upstream?.ok && upstream.body) { + return new Response(upstream.body, { + status: 404, + headers: NOT_FOUND_PAGE_HEADERS, + }); + } + } return new Response("Not Found", { status: 404, headers: { "Content-Type": "text/plain" }, diff --git a/src/component/http.test.ts b/src/component/http.test.ts index 09c1f0f..8590338 100644 --- a/src/component/http.test.ts +++ b/src/component/http.test.ts @@ -289,3 +289,108 @@ describe("serving helpers", () => { }); }); }); +describe("app 404 page", () => { + test("a miss with an app 404 page is answered with that page and status 404", async () => { + const t = initConvexTest(); + await storeAsset( + t, + "/index.html", + "", + "text/html; charset=utf-8", + ); + await storeAsset( + t, + "/404.html", + "

Gone

", + "text/html; charset=utf-8", + ); + + const res = await t.fetch("/missing.js", {}); + expect(res.status).toBe(404); + expect(res.headers.get("Content-Type")).toBe("text/html; charset=utf-8"); + expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(await res.text()).toBe("

Gone

"); + }); + + test("SPA fallback still serves index.html for an extension-less miss", async () => { + const t = initConvexTest(); + await storeAsset( + t, + "/index.html", + "", + "text/html; charset=utf-8", + ); + await storeAsset( + t, + "/404.html", + "

Gone

", + "text/html; charset=utf-8", + ); + + const res = await t.fetch("/dashboard", {}); + expect(res.status).toBe(200); + expect(await res.text()).toBe(""); + }); + + test("with SPA fallback off, an extension-less miss gets the app 404 page", async () => { + const t = initConvexTest(); + await storeAsset( + t, + "/index.html", + "", + "text/html; charset=utf-8", + ); + await storeAsset( + t, + "/404.html", + "

Gone

", + "text/html; charset=utf-8", + ); + await t.mutation(internal.lib.commitDeployment, { + currentDeploymentId: "deploy-1", + spaFallback: false, + }); + + const res = await t.fetch("/dashboard", {}); + expect(res.status).toBe(404); + expect(await res.text()).toBe("

Gone

"); + }); + + test("without an app 404 page, a miss keeps the plain-text 404", async () => { + const t = initConvexTest(); + await storeAsset( + t, + "/index.html", + "", + "text/html; charset=utf-8", + ); + + const res = await t.fetch("/missing.js", {}); + expect(res.status).toBe(404); + expect(res.headers.get("Content-Type")).toBe("text/plain"); + expect(await res.text()).toBe("Not Found"); + }); +}); + +describe("app 404 page fallbacks", () => { + test("a 404 page stored as a blob without a storage ID falls back to plain text", async () => { + const t = initConvexTest(); + await storeAsset( + t, + "/index.html", + "", + "text/html; charset=utf-8", + ); + await t.mutation(internal.lib.recordAsset, { + path: "/404.html", + blobId: "blob-404", + contentType: "text/html; charset=utf-8", + deploymentId: "deploy-1", + }); + + const res = await t.fetch("/missing.js", {}); + expect(res.status).toBe(404); + expect(res.headers.get("Content-Type")).toBe("text/plain"); + expect(await res.text()).toBe("Not Found"); + }); +}); diff --git a/src/component/http.ts b/src/component/http.ts index 85105c8..dad346b 100644 --- a/src/component/http.ts +++ b/src/component/http.ts @@ -8,6 +8,8 @@ import { getSetupHtml, resolveContentType, isHtmlContentType, + NOT_FOUND_PAGE_HEADERS, + NOT_FOUND_PAGE_PATH, } from "./serving.js"; // CONVEX_SITE_URL reflects the component's mount point (including any httpPrefix). @@ -47,8 +49,9 @@ const serveStaticFile = httpAction(async (ctx, request) => { // One query resolves the asset: an exact match, or, when SPA fallback is // enabled for the current deployment, the index.html asset for an - // extension-less miss. The fallback lookup happens inside the query so the - // HTTP action never makes a second round-trip. + // extension-less miss. The fallback lookup happens inside the query, so a + // hit costs a single round-trip. Only a miss makes a second query, for the + // app's 404.html. const asset = await ctx.runQuery(internal.lib.resolveAsset, { path }); if (!asset) { @@ -62,6 +65,18 @@ const serveStaticFile = httpAction(async (ctx, request) => { }, }); } + const notFoundPage = await ctx.runQuery(internal.lib.resolveAsset, { + path: NOT_FOUND_PAGE_PATH, + }); + const notFoundBlob = notFoundPage?.storageId + ? await ctx.storage.get(notFoundPage.storageId) + : null; + if (notFoundBlob) { + return new Response(notFoundBlob, { + status: 404, + headers: NOT_FOUND_PAGE_HEADERS, + }); + } return new Response("Not Found", { status: 404, headers: { "Content-Type": "text/plain" }, diff --git a/src/component/serving.ts b/src/component/serving.ts index 6db2f3f..a09bdf2 100644 --- a/src/component/serving.ts +++ b/src/component/serving.ts @@ -80,6 +80,18 @@ export function getSetupHtml(): string { `; } +/** + * An app-supplied not-found page. When it exists, a miss is answered with its + * contents and status 404, instead of plain text. + */ +export const NOT_FOUND_PAGE_PATH = "/404.html"; + +export const NOT_FOUND_PAGE_HEADERS = { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "public, max-age=0, must-revalidate", + "X-Content-Type-Options": "nosniff", +}; + export function hasFileExtension(path: string): boolean { const lastSegment = path.split("/").pop() || ""; return lastSegment.includes(".") && !lastSegment.startsWith(".");