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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/mcp/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tsbb/server",
"version": "0.13.1",
"version": "0.14.0",
"private": true,
"type": "module",
"dependencies": {
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -255,6 +256,7 @@ export function createApp(registry: Registry, baseUrl: string): Hono<AppEnv> {
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));
Expand Down
22 changes: 22 additions & 0 deletions apps/server/src/boards.json
Original file line number Diff line number Diff line change
@@ -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." }
]
}
1 change: 1 addition & 0 deletions apps/server/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
165 changes: 165 additions & 0 deletions apps/server/src/routes/boards.ts
Original file line number Diff line number Diff line change
@@ -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<string, BoardStats | null> } | null = null;
let inflight: Promise<Map<string, BoardStats | null>> | null = null;

async function fetchStats(board: BoardEntry): Promise<BoardStats | null> {
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<BoardStats>;
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<Map<string, BoardStats | null>> {
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<string, BoardStats | null>,
): { 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<AppEnv>();

// 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`
<div class="page-head">
<div>
<h1 class="page-title">The tsbb network</h1>
<p class="page-subtitle">${rows.length} boards running tsbb. Pick one and say hello.</p>
</div>
</div>
${Card(
CardContent(
html`<div class="table-wrap">
<table class="table">
<thead><tr><th>Board</th><th>Topics</th><th>Posts</th><th>Last post</th></tr></thead>
<tbody>
${rows.map(
({ board, stats }) => html`<tr>
<td>
<a href="${board.url}" rel="noopener">${board.name}</a>${board.url === here ? ' (you are here)' : ''}
<div class="small muted">${board.about}</div>
</td>
<td>${stats ? stats.topics : ''}</td>
<td>${stats ? stats.posts : ''}</td>
<td>${stats ? TimeAgo(stats.latestPostAt) : ''}</td>
</tr>`,
)}
</tbody>
</table>
</div>`,
{ flush: true },
),
)}
${Card(
CardContent(html`<h2>Add your board</h2>
<p>
tsbb is free and MIT licensed, and a board is one Node process and a SQLite file:
<a href="https://github.com/profullstack/tsbb#readme">set one up</a>, then open a pull request
adding it to
<a href="https://github.com/profullstack/tsbb/edit/main/apps/server/src/boards.json">boards.json</a>.
Once merged it is listed here and on every other tsbb board.
</p>
<p class="small muted">Boards with no topics yet are left off until their first post. The list is also at
<a href="/api/v1/boards">/api/v1/boards</a>.</p>`),
)}`;

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;
}
1 change: 1 addition & 0 deletions apps/server/src/routes/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')),
Expand Down
2 changes: 1 addition & 1 deletion apps/tui/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/worker/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tsbb/worker",
"version": "0.13.1",
"version": "0.14.0",
"private": true,
"type": "module",
"dependencies": {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tsbb",
"version": "0.13.1",
"version": "0.14.0",
"private": true,
"type": "module",
"packageManager": "pnpm@11.18.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tsbb/client",
"version": "0.13.1",
"version": "0.14.0",
"private": true,
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tsbb/core",
"version": "0.13.1",
"version": "0.14.0",
"private": true,
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/db/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tsbb/db",
"version": "0.13.1",
"version": "0.14.0",
"private": true,
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/design-tokens/package.json
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
2 changes: 1 addition & 1 deletion packages/mail/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tsbb/mail",
"version": "0.13.1",
"version": "0.14.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
Expand Down
2 changes: 1 addition & 1 deletion packages/markup/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tsbb/markup",
"version": "0.13.1",
"version": "0.14.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" }
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tsbb/mcp",
"version": "0.13.1",
"version": "0.14.0",
"private": true,
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tsbb/plugin-api",
"version": "0.13.1",
"version": "0.14.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" }
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-host/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tsbb/plugin-host",
"version": "0.13.1",
"version": "0.14.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
@@ -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" },
Expand Down
Loading
Loading