diff --git a/TODO.md b/TODO.md index 6246af7..5d85d5e 100644 --- a/TODO.md +++ b/TODO.md @@ -192,7 +192,7 @@ Breakdown of the _"Per-feed favicon from the last sender's domain"_ item above ( Self-host operational quality-of-life: knowing which version you run, when to update, and how many people run KTN. -- [ ] `P3·S` **Display the running version** **[table-stakes, easy]** — surface the deployed app version (from `package.json` `version`, currently `0.2.1`) somewhere visible: the admin UI footer and/or the public status page (`src/routes/home.tsx`), and ideally the `/health` JSON. Bundle the version at build time (inline the `package.json` version into the Worker, since there's no filesystem at runtime) and render it. Foundation for the update-notification item below. — _origin: internal_ +- [x] `P3·S` **Display the running version** **[table-stakes, easy]** — surface the deployed app version (from `package.json` `version`, currently `0.2.1`) somewhere visible: the admin UI footer and/or the public status page (`src/routes/home.tsx`), and ideally the `/health` JSON. Bundle the version at build time (inline the `package.json` version into the Worker, since there's no filesystem at runtime) and render it. Foundation for the update-notification item below. — **Shipped:** `package.json` version is inlined at bundle time via `src/config/version.ts` (`import pkg from "../../package.json"`, `resolveJsonModule`), exposed as `APP_VERSION`; rendered in the shared admin/status footer (`src/routes/admin/ui.tsx` Layout, so both the status page and admin show it) and added to the `/health` JSON. — _origin: internal_ - [ ] `P3·M` **Notify when an update is available** **[differentiating for self-hosters]** — compare the running version against the latest GitHub Release tag and show a discreet "update available → vX.Y.Z" banner in the admin UI when behind. Fetch `https://api.github.com/repos///releases/latest` (cache aggressively — Cache API / KV with a long TTL — to respect GitHub rate limits and avoid a call per page load), compare semver against the bundled version. Depends on the "display version" item. Keep it opt-out-able (it makes one outbound call). — _origin: internal_ diff --git a/src/config/version.ts b/src/config/version.ts new file mode 100644 index 0000000..2b03aa3 --- /dev/null +++ b/src/config/version.ts @@ -0,0 +1,8 @@ +import pkg from "../../package.json"; + +/** + * The running app version, inlined from package.json at bundle time (the Worker + * has no filesystem at runtime). Surfaced in the admin/status footer and the + * /health JSON so a self-hoster can tell which build is deployed. + */ +export const APP_VERSION: string = pkg.version; diff --git a/src/index.test.ts b/src/index.test.ts index 783259a..40bdf02 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import worker from "./index"; +import { APP_VERSION } from "./config/version"; import { createMockEnv } from "./test/setup"; import { createFeedRecord } from "./application/feed-service"; import { FeedRepository } from "./infrastructure/feed-repository"; @@ -97,6 +98,17 @@ describe("scheduled (cron) TTL cleanup", () => { }); }); +describe("GET /health", () => { + it("reports status ok and the bundled app version", async () => { + const res = await worker.fetch(req("/health"), env as unknown as Env); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string; version: string }; + expect(body.status).toBe("ok"); + expect(body.version).toBe(APP_VERSION); + expect(body.version).toMatch(/^\d+\.\d+\.\d+/); + }); +}); + describe("GET /robots.txt", () => { it("returns 200 and disallows the private feed/entry paths", async () => { const res = await worker.fetch(req("/robots.txt"), env as unknown as Env); diff --git a/src/index.ts b/src/index.ts index 32add21..3de1f6a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { hubRouter } from "./routes/hub"; import { apiApp } from "./routes/api"; import { handleCloudflareEmail } from "./infrastructure/cloudflare-email"; import { Env } from "./types"; +import { APP_VERSION } from "./config/version"; import { logger } from "./infrastructure/logger"; import { FeedRepository } from "./infrastructure/feed-repository"; import { purgeExpiredFeeds } from "./application/feed-cleanup"; @@ -185,7 +186,9 @@ app.get("/favicon.ico", handleFavicon); // readers/browsers that hardcode .ico app.get("/favicon/:feedId", handleFeedFavicon); // Health check endpoint for monitoring -app.get("/health", (c) => c.json({ status: "ok", timestamp: Date.now() })); +app.get("/health", (c) => + c.json({ status: "ok", version: APP_VERSION, timestamp: Date.now() }), +); // Public status page (counters + link to admin) app.get("/", handleHome); diff --git a/src/routes/admin/ui.tsx b/src/routes/admin/ui.tsx index 0d59aad..cf0c4b7 100644 --- a/src/routes/admin/ui.tsx +++ b/src/routes/admin/ui.tsx @@ -3,6 +3,7 @@ import layoutCss from "../../styles/layout.css"; import componentsCss from "../../styles/components.css"; import utilitiesCss from "../../styles/utilities.css"; import { interactiveScripts } from "../../scripts/index"; +import { APP_VERSION } from "../../config/version"; import { FAVICON_PATH } from "../favicon"; import { Env } from "../../types"; import { @@ -77,6 +78,10 @@ export const Layout = ({ title, label = "admin", children }: LayoutProps) => { > ♥ Sponsor + + v{APP_VERSION} diff --git a/src/styles/layout.css b/src/styles/layout.css index ea35e3e..fa76e86 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -247,3 +247,8 @@ .site-footer-sponsor:hover { color: #db61a2 !important; } + +.site-footer-version { + font-variant-numeric: tabular-nums; + opacity: 0.7; +} diff --git a/tsconfig.json b/tsconfig.json index bf72133..5e55c1a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "module": "ESNext", "moduleResolution": "bundler", "esModuleInterop": true, + "resolveJsonModule": true, "strict": true, "lib": ["ES2021"], "types": ["@cloudflare/workers-types"],