diff --git a/README.md b/README.md index 14bf4ff..e6ddfa2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # tsbb **Live at [tsbb.dev](https://tsbb.dev)** — that board runs this code. +Every board running tsbb is listed at **[tsbb.dev/boards](https://tsbb.dev/boards)** (and on each +board's own `/boards`); add yours with a pull request to `apps/server/src/boards.json`. A TypeScript bulletin board. Forums, topics, replies, moderation, private messages, avatars, signatures, search, feeds — and a plugin system that ships diff --git a/apps/cli/package.json b/apps/cli/package.json index f76ffb5..50fb4e2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/tsbb", - "version": "0.13.1", + "version": "0.14.0", "description": "A TypeScript bulletin board. Forums, plugins, avatars, signatures and a terminal client.", "type": "module", "license": "MIT", diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 75a1975..07c54c8 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/tsbb-mcp", - "version": "0.13.1", + "version": "0.14.0", "description": "MCP server for a tsbb bulletin board. Lets an AI assistant read and post on any board, as the member whose token it holds.", "type": "module", "license": "MIT", diff --git a/apps/server/package.json b/apps/server/package.json index e5872bc..b6694cb 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/server", - "version": "0.13.1", + "version": "0.14.0", "private": true, "type": "module", "dependencies": { diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index c6cc998..4178359 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -11,6 +11,7 @@ import { adminRoutes } from './routes/admin.ts'; import { pwaRoutes } from './routes/pwa.ts'; import { apiRoutes } from './routes/api.ts'; import { boardRoutes } from './routes/board.ts'; +import { boardsRoutes } from './routes/boards.ts'; import { discoverRoutes } from './routes/discover.ts'; import { discoveryRoutes } from './routes/discovery.ts'; import { docsRoutes } from './routes/docs.ts'; @@ -255,6 +256,7 @@ export function createApp(registry: Registry, baseUrl: string): Hono { app.route('/', docsRoutes(services)); app.route('/', discoveryRoutes(services)); app.route('/', discoverRoutes(services)); + app.route('/', boardsRoutes(services)); app.route('/', writeRoutes(services)); app.route('/', settingsRoutes(services)); app.route('/', boardRoutes(services)); diff --git a/apps/server/src/boards.json b/apps/server/src/boards.json new file mode 100644 index 0000000..44f65c2 --- /dev/null +++ b/apps/server/src/boards.json @@ -0,0 +1,22 @@ +{ + "about": "Boards running tsbb. Run one? Open a pull request adding it to apps/server/src/boards.json and it appears on every tsbb board at /boards.", + "boards": [ + { "url": "https://tsbb.dev", "name": "tsbb", "about": "The project's own board: help, plugins, skins and what ships next." }, + { "url": "https://bbs.hqtui.com", "name": "HQTUI BBS", "about": "Terminal UI talk, for people who build terminal UIs." }, + { "url": "https://c0upons.com/bbs", "name": "c0upons forums", "about": "Hot deals, coupon codes and freebies, shared by the people who found them." }, + { "url": "https://w3bs.org/bbs", "name": "w3bs", "about": "The W3BS community board: the web desktop, packages and training." }, + { "url": "https://bbs.alt.2600.pit.world", "name": "alt.2600", "about": "Phreaking, hacking and the old zine spirit." }, + { "url": "https://bbs.alt.cyberpunk.pit.world", "name": "alt.cyberpunk", "about": "High tech, low life." }, + { "url": "https://bbs.agent.bbs.pit.world", "name": "agent.bbs", "about": "A board for AI agents and the people who build them." }, + { "url": "https://bbs.built.indie.pit.world", "name": "built.indie", "about": "Indie makers show what they shipped." }, + { "url": "https://bbs.chovy.hacker.pit.world", "name": "chovy.hacker", "about": "Hacker projects and late-night builds." }, + { "url": "https://bbs.deadman.switch.pit.world", "name": "deadman.switch", "about": "Dead man's switches, digital estates and what happens after." }, + { "url": "https://bbs.family.ancestry.pit.world", "name": "family.ancestry", "about": "Genealogy and family history." }, + { "url": "https://bbs.family.tree.pit.world", "name": "family.tree", "about": "Family trees, records and research help." }, + { "url": "https://bbs.live.streaming.pit.world", "name": "live.streaming", "about": "Streaming setups, tools and channels." }, + { "url": "https://bbs.number.station.pit.world", "name": "number.station", "about": "Numbers stations, shortwave and SIGINT curiosities." }, + { "url": "https://bbs.pay.crypto.pit.world", "name": "pay.crypto", "about": "Taking and sending crypto payments." }, + { "url": "https://bbs.seo.rank.pit.world", "name": "seo.rank", "about": "Search ranking, crawlers and getting found." }, + { "url": "https://bbs.watch.wallet.pit.world", "name": "watch.wallet", "about": "Wallet watching, on-chain alerts and tracking." } + ] +} diff --git a/apps/server/src/context.ts b/apps/server/src/context.ts index fda4adc..d06ac81 100644 --- a/apps/server/src/context.ts +++ b/apps/server/src/context.ts @@ -284,6 +284,7 @@ export async function render( board: [ ...board.filter((item) => !item.requires).map((item) => ({ ...item, match: undefined })), { label: 'Search', href: '/search' }, + { label: 'More tsbb boards', href: '/boards' }, ], }, showThemeToggle: !lightOnly, diff --git a/apps/server/src/routes/boards.ts b/apps/server/src/routes/boards.ts new file mode 100644 index 0000000..a6cb13b --- /dev/null +++ b/apps/server/src/routes/boards.ts @@ -0,0 +1,165 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Hono } from 'hono'; +import { html } from 'hono/html'; +import { Card, CardContent, TimeAgo } from '@tsbb/ui'; +import { render, type AppEnv, type Services } from '../context.ts'; + +/** + * The tsbb network: every board that runs tsbb, listed on every board. + * + * The list is a file in the repository, not a setting, so a self-hoster joins + * it with a pull request and every board picks the change up with its next + * update. Each entry's numbers come from that board's own /api/v1/stats, + * fetched server side (the board's CSP allows no cross-origin request from the + * page) and cached, so a slow or dead board costs one timeout every ten + * minutes and never a page view. + * + * A board that answers with zero topics is left off the page: an empty room + * is a worse advertisement than no room. A board that does not answer at all + * stays listed, because one failed fetch says nothing about the board. + */ + +export interface BoardEntry { + url: string; + name: string; + about: string; +} + +export interface BoardStats { + members: number; + topics: number; + posts: number; + latestPostAt: number | null; +} + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REGISTRY = JSON.parse(readFileSync(join(HERE, '../boards.json'), 'utf8')) as { + about: string; + boards: BoardEntry[]; +}; + +const CACHE_MS = 10 * 60 * 1000; +const TIMEOUT_MS = 4000; + +let cache: { at: number; stats: Map } | null = null; +let inflight: Promise> | null = null; + +async function fetchStats(board: BoardEntry): Promise { + try { + const response = await fetch(`${board.url.replace(/\/+$/, '')}/api/v1/stats`, { + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { accept: 'application/json', 'user-agent': 'tsbb-network/1 (+https://tsbb.dev/boards)' }, + }); + if (!response.ok) return null; + const body = (await response.json()) as Partial; + return { + members: Number(body.members ?? 0), + topics: Number(body.topics ?? 0), + posts: Number(body.posts ?? 0), + latestPostAt: typeof body.latestPostAt === 'number' ? body.latestPostAt : null, + }; + } catch { + return null; + } +} + +async function networkStats(): Promise> { + if (process.env.TSBB_NETWORK_STATS === 'off') return new Map(); + if (cache && Date.now() - cache.at < CACHE_MS) return cache.stats; + // A stale answer now beats a fresh one in four seconds; refresh behind it. + inflight ??= Promise.all(REGISTRY.boards.map(async (board) => [board.url, await fetchStats(board)] as const)) + .then((pairs) => { + cache = { at: Date.now(), stats: new Map(pairs) }; + return cache.stats; + }) + .finally(() => { + inflight = null; + }); + return cache ? cache.stats : inflight; +} + +/** The boards worth showing, busiest first; unknown numbers sort last but stay. */ +export function visibleBoards( + boards: BoardEntry[], + stats: Map, +): { board: BoardEntry; stats: BoardStats | null }[] { + return boards + .map((board) => ({ board, stats: stats.get(board.url) ?? null })) + .filter((row) => row.stats === null || row.stats.topics > 0) + .sort((a, b) => (b.stats?.posts ?? -1) - (a.stats?.posts ?? -1)); +} + +export function boardsRoutes(services: Services) { + const app = new Hono(); + + // The words people reach for when they look for this page. + for (const alias of ['/partners', '/instances', '/network']) { + app.get(alias, (c) => c.redirect('/boards', 301)); + } + + app.get('/api/v1/boards', async (c) => { + const rows = visibleBoards(REGISTRY.boards, await networkStats()); + return c.json({ + boards: rows.map(({ board, stats }) => ({ ...board, ...(stats ?? {}) })), + join: 'https://github.com/profullstack/tsbb/edit/main/apps/server/src/boards.json', + }); + }); + + app.get('/boards', async (c) => { + const here = services.baseUrl.replace(/\/+$/, ''); + const rows = visibleBoards(REGISTRY.boards, await networkStats()); + + const body = html` +
+
+

The tsbb network

+

${rows.length} boards running tsbb. Pick one and say hello.

+
+
+ ${Card( + CardContent( + html`
+ + + + ${rows.map( + ({ board, stats }) => html` + + + + + `, + )} + +
BoardTopicsPostsLast post
+ ${board.name}${board.url === here ? ' (you are here)' : ''} +
${board.about}
+
${stats ? stats.topics : ''}${stats ? stats.posts : ''}${stats ? TimeAgo(stats.latestPostAt) : ''}
+
`, + { flush: true }, + ), + )} + ${Card( + CardContent(html`

Add your board

+

+ tsbb is free and MIT licensed, and a board is one Node process and a SQLite file: + set one up, then open a pull request + adding it to + boards.json. + Once merged it is listed here and on every other tsbb board. +

+

Boards with no topics yet are left off until their first post. The list is also at + /api/v1/boards.

`), + )}`; + + return render(c, services, { + title: 'The tsbb network', + description: 'Every bulletin board running tsbb, with live topic and post counts. Join one, or add your own.', + body, + }); + }); + + return app; +} diff --git a/apps/server/src/routes/discovery.ts b/apps/server/src/routes/discovery.ts index b91d7ba..9d23549 100644 --- a/apps/server/src/routes/discovery.ts +++ b/apps/server/src/routes/discovery.ts @@ -194,6 +194,7 @@ export function discoveryRoutes(services: Services) { url('/about', '0.6', 'monthly'), url('/members', '0.4', 'daily'), url('/feeds', '0.3', 'monthly'), + url('/boards', '0.5', 'daily'), url('/docs', '0.6', 'monthly'), ...DOCS.map((doc) => url(`/docs/${doc.slug}`, '0.6', 'monthly')), ...forums.map((forum) => url(`/f/${forum.slug}`, '0.7', 'daily')), diff --git a/apps/tui/package.json b/apps/tui/package.json index 73f6d62..de70fb8 100644 --- a/apps/tui/package.json +++ b/apps/tui/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/tsbb-tui", - "version": "0.13.1", + "version": "0.14.0", "description": "Terminal client for a tsbb bulletin board. Reads and posts against any centralised install.", "type": "module", "license": "MIT", diff --git a/apps/worker/package.json b/apps/worker/package.json index bfd3145..80d2a4e 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/worker", - "version": "0.13.1", + "version": "0.14.0", "private": true, "type": "module", "dependencies": { diff --git a/package.json b/package.json index fac5a7b..34c4da2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tsbb", - "version": "0.13.1", + "version": "0.14.0", "private": true, "type": "module", "packageManager": "pnpm@11.18.0", diff --git a/packages/client/package.json b/packages/client/package.json index 994f817..0fa5f67 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/client", - "version": "0.13.1", + "version": "0.14.0", "private": true, "type": "module", "exports": { diff --git a/packages/core/package.json b/packages/core/package.json index f6c75fd..c30e75e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/core", - "version": "0.13.1", + "version": "0.14.0", "private": true, "type": "module", "exports": { diff --git a/packages/db/package.json b/packages/db/package.json index 8350d32..dcba75c 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/db", - "version": "0.13.1", + "version": "0.14.0", "private": true, "type": "module", "exports": { diff --git a/packages/design-tokens/package.json b/packages/design-tokens/package.json index 2c84ff7..8584221 100644 --- a/packages/design-tokens/package.json +++ b/packages/design-tokens/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/design-tokens", - "version": "0.13.1", + "version": "0.14.0", "private": true, "type": "module", "exports": { "./tokens.css": "./src/tokens.css" } diff --git a/packages/mail/package.json b/packages/mail/package.json index 45425eb..ed96a37 100644 --- a/packages/mail/package.json +++ b/packages/mail/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/mail", - "version": "0.13.1", + "version": "0.14.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" }, diff --git a/packages/markup/package.json b/packages/markup/package.json index 0e0a9c0..1fab4fe 100644 --- a/packages/markup/package.json +++ b/packages/markup/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/markup", - "version": "0.13.1", + "version": "0.14.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" } diff --git a/packages/mcp/package.json b/packages/mcp/package.json index a058697..3ec8527 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/mcp", - "version": "0.13.1", + "version": "0.14.0", "private": true, "type": "module", "exports": { diff --git a/packages/plugin-api/package.json b/packages/plugin-api/package.json index 14e4c60..26ebbb6 100644 --- a/packages/plugin-api/package.json +++ b/packages/plugin-api/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/plugin-api", - "version": "0.13.1", + "version": "0.14.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" } diff --git a/packages/plugin-host/package.json b/packages/plugin-host/package.json index 4f47197..0f8af1d 100644 --- a/packages/plugin-host/package.json +++ b/packages/plugin-host/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/plugin-host", - "version": "0.13.1", + "version": "0.14.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" }, diff --git a/packages/ui/package.json b/packages/ui/package.json index 9957b3d..8543f01 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/ui", - "version": "0.13.1", + "version": "0.14.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts", "./styles": "./src/styles/index.ts" }, diff --git a/test/boards.test.ts b/test/boards.test.ts new file mode 100644 index 0000000..9bc266f --- /dev/null +++ b/test/boards.test.ts @@ -0,0 +1,74 @@ +import { strict as assert } from 'node:assert'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { before, describe, it } from 'node:test'; + +/** + * The tsbb network page. Live numbers are switched off so the test never + * touches the network; the filter that hides empty boards is tested directly. + */ +const scratch = mkdtempSync(join(tmpdir(), 'tsbb-boards-')); +process.env.TSBB_DATABASE_URL = `file:${join(scratch, 'board.db')}`; +process.env.TSBB_BASE_URL = 'http://localhost:3996'; +process.env.TSBB_SESSION_SECRET = 'test-secret'; +process.env.TSBB_MAIL_TRANSPORT = 'console'; +process.env.TSBB_NETWORK_STATS = 'off'; + +const { seed } = await import('../packages/db/src/seed.ts'); +const { boot } = await import('../apps/server/src/index.ts'); +const { visibleBoards } = await import('../apps/server/src/routes/boards.ts'); + +let app: { fetch: (req: Request) => Response | Promise }; +const get = (path: string) => app.fetch(new Request(`http://localhost:3996${path}`, { redirect: 'manual' })); + +describe('the tsbb network', () => { + before(async () => { + await seed({ quiet: true }); + app = (await boot({ listen: false })).app; + }); + + it('lists the boards, with a way to add one', async () => { + const response = await get('/boards'); + assert.equal(response.status, 200); + const page = await response.text(); + assert.match(page, /The tsbb network/); + assert.match(page, /href="https:\/\/tsbb\.dev"/); + assert.match(page, /Add your board/); + }); + + it('answers the other names for the page with a redirect', async () => { + for (const path of ['/partners', '/instances', '/network']) { + const response = await get(path); + assert.equal(response.status, 301, path); + assert.equal(response.headers.get('location'), '/boards'); + } + }); + + it('serves the list as JSON', async () => { + const body = (await (await get('/api/v1/boards')).json()) as { boards: { url: string }[] }; + assert.ok(body.boards.some((board) => board.url === 'https://tsbb.dev')); + }); + + it('links the page from the footer and the sitemap', async () => { + assert.match(await (await get('/')).text(), /href="\/boards"/); + assert.match(await (await get('/sitemaps/pages.xml')).text(), /\/boards { + const boards = [ + { url: 'https://a', name: 'a', about: '' }, + { url: 'https://b', name: 'b', about: '' }, + { url: 'https://c', name: 'c', about: '' }, + ]; + const stats = new Map([ + ['https://a', { members: 1, topics: 0, posts: 0, latestPostAt: null }], + ['https://b', { members: 4, topics: 9, posts: 30, latestPostAt: 1 }], + ['https://c', null], + ]); + assert.deepEqual( + visibleBoards(boards, stats).map((row) => row.board.name), + ['b', 'c'], + ); + }); +});