From df4933e1fd14cf9de5074efa46edc51deaa17df4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 31 Aug 2026 20:49:16 +0000 Subject: [PATCH] Ship docs/ in the image, so /docs is not empty in production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .dockerignore excluded docs/, so the built image had no documents to render: every /docs/{api,cli,mcp,plugins} page answered 404 in production while the index rendered its "deployed without its docs directory" fallback and a perfectly healthy 200. Live on tsbb.dev the moment #3 deployed. Nothing that boots the app in a checkout could have caught it — the files are right there — so the guard goes on the thing that was actually wrong. test/docs.test.ts now reads .dockerignore and fails if docs/ is excluded, and the route warns at boot when the directory is missing, because a documentation site that silently serves nothing is a failure with no symptom anyone would notice from the outside. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SnsZkEBoc39vYSN2hKLAwS --- .dockerignore | 6 +++++- apps/server/src/routes/docs.ts | 17 ++++++++++++++++- test/docs.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/.dockerignore b/.dockerignore index 44021c6..f2f978d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,4 +11,8 @@ uploads .env.* !.env.example test -docs + +# docs/ is NOT ignored: the board serves it at /docs, rendering the same +# markdown files the repository keeps. Excluding it builds an image whose +# documentation pages all 404 while the index quietly renders its "deployed +# without its docs directory" fallback — a failure that looks like nothing. diff --git a/apps/server/src/routes/docs.ts b/apps/server/src/routes/docs.ts index 2871fee..e75f279 100644 --- a/apps/server/src/routes/docs.ts +++ b/apps/server/src/routes/docs.ts @@ -1,4 +1,4 @@ -import { readFileSync, statSync } from 'node:fs'; +import { existsSync, readFileSync, statSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Hono } from 'hono'; @@ -180,6 +180,21 @@ function load(doc: Doc): Rendered | null { export function docsRoutes(services: Services) { const app = new Hono(); + /* + * Say so at boot if the documents are not in the image. + * + * They ship as files, so a build that excludes them — one line in + * .dockerignore did exactly this — leaves every /docs page answering 404 + * while the index renders its "no documentation" fallback perfectly happily. + * That is a failure with no symptom anybody would notice from the outside, + * which is precisely the kind worth a line in the log. + */ + if (!existsSync(DOCS_DIR)) { + console.warn( + `[tsbb] no docs directory at ${DOCS_DIR} — /docs will be empty. Is it excluded from the build?`, + ); + } + app.get('/docs', async (c) => { const entries = DOCS.map((doc) => ({ doc, rendered: load(doc) })).filter( (entry): entry is { doc: Doc; rendered: Rendered } => entry.rendered !== null, diff --git a/test/docs.test.ts b/test/docs.test.ts index 0a5e1a5..ae4c857 100644 --- a/test/docs.test.ts +++ b/test/docs.test.ts @@ -113,6 +113,31 @@ describe('the documentation pages', () => { assert.equal(missing.status, 404); }); + /* + * These pages passed every test above and still 404'd in production, because + * .dockerignore excluded docs/ and the deployed image had no files to render. + * Nothing that boots the app in a checkout can catch that — the guard has to + * be on the thing that was wrong, which is the build configuration. + */ + it('ships the documents in the image', async () => { + const { readFileSync } = await import('node:fs'); + const { dirname, join } = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const root = join(dirname(fileURLToPath(import.meta.url)), '..'); + + const ignored = readFileSync(join(root, '.dockerignore'), 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')); + + for (const pattern of ['docs', 'docs/', '/docs', './docs']) { + assert.ok( + !ignored.includes(pattern), + `.dockerignore excludes ${pattern}, so the built image would serve no documentation`, + ); + } + }); + it('points at the docs from the API index, for a human who found the JSON', async () => { const response = await app.fetch(new Request('http://localhost:3993/api/v1')); const index = (await response.json()) as { docs: string };