Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
9 changes: 7 additions & 2 deletions INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
138 changes: 138 additions & 0 deletions src/client/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<h1>Gone</h1>")),
);

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("<h1>Gone</h1>");
});

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(["<h1>Gone</h1>"]));
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<Response>;
}
)._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" : "<h1>Gone</h1>",
);
},
);
});

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");
});
});
26 changes: 26 additions & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
getSetupHtml,
resolveContentType,
isHtmlContentType,
NOT_FOUND_PAGE_HEADERS,
NOT_FOUND_PAGE_PATH,
} from "../component/serving.js";

const deploymentInfoValidator = v.object({
Expand Down Expand Up @@ -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" },
Expand Down
105 changes: 105 additions & 0 deletions src/component/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"<!doctype html>",
"text/html; charset=utf-8",
);
await storeAsset(
t,
"/404.html",
"<h1>Gone</h1>",
"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("<h1>Gone</h1>");
});

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

const res = await t.fetch("/dashboard", {});
expect(res.status).toBe(200);
expect(await res.text()).toBe("<!doctype html>");
});

test("with SPA fallback off, an extension-less miss gets the app 404 page", async () => {
const t = initConvexTest();
await storeAsset(
t,
"/index.html",
"<!doctype html>",
"text/html; charset=utf-8",
);
await storeAsset(
t,
"/404.html",
"<h1>Gone</h1>",
"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("<h1>Gone</h1>");
});

test("without an app 404 page, a miss keeps the plain-text 404", async () => {
const t = initConvexTest();
await storeAsset(
t,
"/index.html",
"<!doctype 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",
"<!doctype 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");
});
});
19 changes: 17 additions & 2 deletions src/component/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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) {
Expand All @@ -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" },
Expand Down
Loading
Loading