Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol. app.exosphere.site
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore: review architecture doc

Hugo 140a5627 ff8ee558

+152 -59
+127 -44
ARCHITECTURE.md
··· 2 2 3 3 ## Project Structure 4 4 5 - Monorepo using Bun workspaces. Each module (feeds, polls, forms, etc.) is an independent package with its own backend routes and frontend components. Modules share common infrastructure but can be developed and deployed independently. 5 + Monorepo using Bun workspaces. Each module (feeds, feature-requests, etc.) is an independent package with its own backend routes and frontend components. Modules share common infrastructure but can be developed and deployed independently. 6 6 7 7 ``` 8 8 exosphere/ 9 9 ├── packages/ 10 - │ ├── core/ # Shared server-side infrastructure 11 - │ │ ├── auth/ # AT Protocol OAuth, session management 12 - │ │ ├── db/ # SQLite setup, base migrations 13 - │ │ └── types/ # Common types and Zod schemas 14 - │ ├── client/ # Shared client-side utilities 15 - │ │ └── src/ # API helpers, auth, router, hooks, theme, styles, types 10 + │ ├── core/ # Shared server-side infrastructure (auth, db, types, permissions) 11 + │ ├── client/ # Shared client-side utilities (API helpers, auth, router, theme, styles) 16 12 │ ├── indexer/ # Jetstream consumer & shared module registry 17 - │ │ ├── src/ 18 - │ │ │ ├── modules.ts # Shared module list (used by app + standalone) 19 - │ │ │ ├── start.ts # startJetstream() — WebSocket consumer 20 - │ │ │ ├── cursor.ts # Cursor persistence for resume-after-crash 21 - │ │ │ └── main.ts # Standalone entry point 22 - │ ├── <module>/ # Feed & discussions module 23 - │ │ ├── api/ # Hono routes 24 - │ │ ├── ui/ # Preact page components 25 - │ │ └── schemas/ # Module-specific types and schemas 26 - │ └── app/ # Host app — assembles selected modules 13 + │ ├── mcp/ # MCP server framework (JSON-RPC, protocol handling) 14 + │ ├── feature-requests/ # Feature requests module 15 + │ ├── feeds/ # Feeds & discussions module 16 + │ └── app/ # Host app — assembles modules, server & client entry points 27 17 ├── bunfig.toml 28 18 └── package.json 29 19 ``` ··· 57 47 - Exposes a Hono sub-app for its API routes 58 48 - Exposes Preact components for its UI 59 49 - Manages its own SQLite tables (migrations scoped per module) 50 + - Exposes MCP tools for AI integration 60 51 - Can depend on `core` and `client` but not on other modules 61 52 62 - ### Server / Client Separation 53 + ### Anatomy of a Module 54 + 55 + A module is a self-contained package that owns its API, UI, database, indexer, and MCP tools. Not every module uses all of these — simpler modules may omit the indexer or MCP tools. The general skeleton looks like this: 56 + 57 + ``` 58 + packages/<module>/ 59 + ├── src/ 60 + │ ├── index.ts # Backend entry — ExosphereModule definition 61 + │ ├── client.ts # Client entry — ClientModule with lazy-loaded routes 62 + │ ├── client.ssr.ts # SSR entry — same routes with eager imports 63 + │ ├── mcp.ts # MCP tool definitions (optional) 64 + │ ├── indexer.ts # Jetstream event handlers (optional) 65 + │ ├── api/ # Hono route handlers 66 + │ ├── db/ # Drizzle table definitions & operations 67 + │ ├── schemas/ # Zod schemas for validation 68 + │ └── ui/ # Preact pages, components, hooks, styles 69 + └── package.json 70 + ``` 71 + 72 + #### Package exports 73 + 74 + Each module declares several entry points in `package.json`, one per concern: 75 + 76 + | Export | File | Purpose | Imported by | 77 + | ---------------- | ------------------- | ----------------------------------------------------------------- | -------------------------- | 78 + | `"."` | `src/index.ts` | `ExosphereModule` — API routes, indexer, permissions | `app/src/server.ts` | 79 + | `"./client"` | `src/client.ts` | `ClientModule` — page routes with lazy imports for code splitting | `app/src/client.tsx` | 80 + | `"./client-ssr"` | `src/client.ssr.ts` | Same routes with eager imports (SSR cannot resolve lazy promises) | `app/src/entry-server.tsx` | 81 + | `"./mcp"` | `src/mcp.ts` | `McpTool[]` — MCP tool definitions for AI integration | `app/src/server.ts` | 82 + | `"./types"` | `src/types.ts` | Shared type definitions | Other module files | 83 + 84 + #### Backend entry (`src/index.ts`) 85 + 86 + The main export implements `ExosphereModule`: 87 + 88 + ```typescript 89 + export const featureRequestsModule: ExosphereModule = { 90 + name: "feature-requests", 91 + api: featureRequestsApi, // Hono sub-app 92 + indexer: featureRequestsIndexer, // Jetstream event handlers 93 + permissions: { 94 + create: { label: "Create feature request", defaultRole: "authenticated" }, 95 + vote: { label: "Vote on feature requests", defaultRole: "authenticated" }, 96 + // ... 97 + }, 98 + permissionsCollection: "site.exosphere.featureRequest.permissions", 99 + }; 100 + ``` 101 + 102 + The `app` package mounts `api` under `/api/<module-name>`, registers the `indexer` with the Jetstream consumer, and loads `permissions` into the admin panel. 103 + 104 + #### Client entry (`src/client.ts`) 105 + 106 + Declares routes with lazy-loaded page components: 107 + 108 + ```typescript 109 + export const featureRequestsModule: ClientModule = { 110 + name: "infuse", 111 + routes: [ 112 + { path: "/infuse", component: FeatureRequestsListPage }, 113 + { path: "/infuse/:number", component: FeatureRequestPage }, 114 + ], 115 + }; 116 + ``` 117 + 118 + #### MCP entry (`src/mcp.ts`) 119 + 120 + Exports an array of `McpTool` definitions. Each tool has a name, description, JSON Schema for inputs, and a handler that calls the module's API routes via `ApiFetch`: 121 + 122 + ```typescript 123 + export const featureRequestMcpTools: McpTool[] = [ 124 + { 125 + name: "list_feature_requests", 126 + description: "List feature requests with optional filtering...", 127 + inputSchema: { 128 + type: "object", 129 + properties: { 130 + /* ... */ 131 + }, 132 + }, 133 + handler: async (args, apiFetch) => { 134 + const res = await apiFetch(`/api/feature-requests?status=${args.status}`); 135 + const data = await res.json(); 136 + return { content: [{ type: "text", text: JSON.stringify(data) }] }; 137 + }, 138 + }, 139 + // ... 140 + ]; 141 + ``` 142 + 143 + The `@exosphere/mcp` package provides the generic MCP framework (JSON-RPC routing, protocol handling). Module tools are passed into `createMcpRoutes()` and merged with core tools (like `get_sphere`). 144 + 145 + #### Indexer (`src/indexer.ts`) 146 + 147 + Implements `ModuleIndexer` — declares which AT Protocol collections to watch and handles create/update/delete events from the Jetstream consumer: 148 + 149 + ```typescript 150 + export const featureRequestsIndexer: ModuleIndexer = { 151 + collections: [ 152 + "site.exosphere.featureRequest.entry", 153 + "site.exosphere.featureRequest.vote", 154 + // ... 155 + ], 156 + handleCreateOrUpdate(event) { 157 + /* index into SQLite */ 158 + }, 159 + handleDelete(event) { 160 + /* remove from SQLite */ 161 + }, 162 + }; 163 + ``` 63 164 64 - Backend and frontend code must stay in separate entry points to avoid bundling server-only dependencies (e.g. `bun:sqlite`) into the browser bundle. 165 + #### Database (`src/db/`) 65 166 66 - Each module exposes two entry points in its `package.json` exports: 167 + - `schema.ts` — Drizzle table definitions (`featureRequests`, `featureRequestComments`, `featureRequestVotes`, etc.) 168 + - `operations.ts` — Insert/update/delete helpers used by both the API routes and the indexer 67 169 68 - | Export | File | Purpose | 69 - | ------------ | --------------- | ---------------------------------------------------------------------------------- | 70 - | `"."` | `src/index.ts` | Backend — `ExosphereModule` with Hono API routes. Imported by `app/src/server.ts`. | 71 - | `"./client"` | `src/client.ts` | Frontend — `ClientModule` with page routes. Imported by `app/src/app.tsx`. | 170 + Each module manages its own tables. The shared `core/db` package handles SQLite setup and provides the Drizzle instance. 171 + 172 + ### Server / Client Separation 72 173 73 - `ExosphereModule` (backend) and `ClientModule` (frontend) are independent types — `ClientModule` does not extend `ExosphereModule` to prevent any transitive import of server code. 174 + Backend and frontend code must stay in separate entry points to avoid bundling server-only dependencies (e.g. `bun:sqlite`) into the browser bundle. `ExosphereModule` (backend) and `ClientModule` (frontend) are independent types — `ClientModule` does not extend `ExosphereModule` to prevent any transitive import of server code. 74 175 75 176 ### Client Module and Route Registration 76 177 ··· 123 224 - The backend indexes relevant data from PDS for fast querying 124 225 - Sphere configuration and membership are published on PDS for interoperability (see below) 125 226 - The local SQLite database caches/indexes all PDS data for fast querying 126 - - Every record type written to a user's PDS has a formal Lexicon schema definition hosted at `landing/.well-known/` 127 - 128 - ### Lexicon schemas 129 - 130 - All AT Protocol record types are defined as Lexicon JSON files: 131 - 132 - | Lexicon ID | Published by | Purpose | 133 - | ------------------------------------------- | ------------ | ------------------------------------------------------------------------ | 134 - | `site.exosphere.sphere.profile` | Sphere owner | Sphere declaration (name, slug, visibility, modules) — enables discovery | 135 - | `site.exosphere.sphere.member` | Member | "I am a member of this Sphere" — member-side of bilateral membership | 136 - | `site.exosphere.sphere.memberApproval` | Owner/admin | "This user is an approved member" — admin-side of bilateral membership | 137 - | `site.exosphere.sphere.permissions` | Sphere owner | Permission overrides for core Sphere actions | 138 - | `site.exosphere.featureRequest.entry` | Author | Feature request content | 139 - | `site.exosphere.featureRequest.vote` | Voter | Upvote on a feature request | 140 - | `site.exosphere.featureRequest.comment` | Commenter | Comment on a feature request | 141 - | `site.exosphere.featureRequest.commentVote` | Voter | Upvote on a comment | 142 - | `site.exosphere.featureRequest.status` | Admin/owner | Status change on a feature request | 143 - | `site.exosphere.featureRequest.permissions` | Sphere owner | Permission overrides for the feature-requests module | 144 - | `site.exosphere.moderation` | Admin/owner | Moderation action on any content (e.g. comment removal) | 227 + - Every record type written to a user's PDS has a formal Lexicon schema definition hosted at `../landing/lexicons` 145 228 146 229 ## Data Ownership 147 230
+2
bun.lock
··· 72 72 "dependencies": { 73 73 "@exosphere/client": "workspace:*", 74 74 "@exosphere/core": "workspace:*", 75 + "@exosphere/mcp": "workspace:*", 75 76 "@preact/signals": "catalog:", 76 77 "@vanilla-extract/css": "catalog:", 77 78 "drizzle-orm": "catalog:", ··· 122 123 "hono": "catalog:", 123 124 }, 124 125 "devDependencies": { 126 + "@exosphere/feature-requests": "workspace:*", 125 127 "@types/bun": "catalog:", 126 128 "typescript": "catalog:", 127 129 },
+2 -1
packages/app/src/server.ts
··· 13 13 import { startJetstream, stopCursorFlushing } from "@exosphere/indexer"; 14 14 import { modules, coreIndexer } from "@exosphere/indexer/modules"; 15 15 import { createMcpRoutes } from "@exosphere/mcp"; 16 + import { featureRequestMcpTools } from "@exosphere/feature-requests/mcp"; 16 17 import { ssrPrefetch } from "./ssr-prefetch.ts"; 17 18 18 19 const app = new Hono(); ··· 49 50 // Each sphere gets its own endpoint: /mcp/:sphereHandle 50 51 app.route( 51 52 "/mcp", 52 - createMcpRoutes((sphereHandle) => (path) => { 53 + createMcpRoutes(featureRequestMcpTools, (sphereHandle) => (path) => { 53 54 // Rewrite /api/spheres/current → /api/spheres/:handle 54 55 if (path === "/api/spheres/current") { 55 56 return app.request(`/api/spheres/${sphereHandle}`);
+2
packages/feature-requests/package.json
··· 7 7 ".": "./src/index.ts", 8 8 "./client": "./src/client.ts", 9 9 "./client-ssr": "./src/client.ssr.ts", 10 + "./mcp": "./src/mcp.ts", 10 11 "./types": "./src/types.ts" 11 12 }, 12 13 "dependencies": { 13 14 "@exosphere/client": "workspace:*", 14 15 "@exosphere/core": "workspace:*", 16 + "@exosphere/mcp": "workspace:*", 15 17 "@preact/signals": "catalog:", 16 18 "@vanilla-extract/css": "catalog:", 17 19 "drizzle-orm": "catalog:",
+1
packages/mcp/package.json
··· 10 10 "hono": "catalog:" 11 11 }, 12 12 "devDependencies": { 13 + "@exosphere/feature-requests": "workspace:*", 13 14 "@types/bun": "catalog:", 14 15 "typescript": "catalog:" 15 16 }
+5 -2
packages/mcp/src/__tests__/routes.test.ts
··· 2 2 import { Hono } from "hono"; 3 3 import { createMcpRoutes } from "../routes.ts"; 4 4 import { PROTOCOL_VERSION, SERVER_INFO } from "../protocol.ts"; 5 - import { tools } from "../tools/index.ts"; 5 + import { sphereTools } from "../tools/index.ts"; 6 + import { featureRequestMcpTools } from "@exosphere/feature-requests/mcp"; 7 + 8 + const tools = [...sphereTools, ...featureRequestMcpTools]; 6 9 7 10 const TEST_SPHERE_HANDLE = "test.bsky.social"; 8 11 ··· 81 84 82 85 api.route( 83 86 "/mcp", 84 - createMcpRoutes((sphereHandle) => (path) => { 87 + createMcpRoutes(featureRequestMcpTools, (sphereHandle) => (path) => { 85 88 // Rewrite sphere/current to sphere/:handle (mirrors server.ts logic) 86 89 if (path === "/api/spheres/current") { 87 90 return api.request(`/api/spheres/${sphereHandle}`);
+1
packages/mcp/src/index.ts
··· 1 1 export { createMcpRoutes } from "./routes.ts"; 2 + export type { McpTool, ApiFetch, ToolResult, ToolInputSchema } from "./tools/types.ts";
+9 -4
packages/mcp/src/routes.ts
··· 9 9 PROTOCOL_VERSION, 10 10 SERVER_INFO, 11 11 } from "./protocol.ts"; 12 - import { tools, type ApiFetch } from "./tools/index.ts"; 12 + import { sphereTools, type McpTool, type ApiFetch } from "./tools/index.ts"; 13 13 14 14 async function handleMessage( 15 15 msg: JsonRpcRequest, 16 + tools: McpTool[], 16 17 apiFetch: ApiFetch, 17 18 ): Promise<JsonRpcResponse | null> { 18 19 // Notifications (no id) don't produce responses ··· 66 67 * Each sphere gets its own MCP endpoint at `/mcp/:sphereHandle`. 67 68 * @param apiFetchFactory - given a sphere handle, returns an `ApiFetch` that routes to the correct sphere API 68 69 */ 69 - export function createMcpRoutes(apiFetchFactory: (sphereHandle: string) => ApiFetch) { 70 + export function createMcpRoutes( 71 + moduleTools: McpTool[], 72 + apiFetchFactory: (sphereHandle: string) => ApiFetch, 73 + ) { 74 + const tools = [...sphereTools, ...moduleTools]; 70 75 const mcp = new Hono(); 71 76 72 77 mcp.post("/:sphereHandle", async (c) => { ··· 78 83 if (Array.isArray(body)) { 79 84 const responses: JsonRpcResponse[] = []; 80 85 for (const msg of body) { 81 - const res = await handleMessage(msg, apiFetch); 86 + const res = await handleMessage(msg, tools, apiFetch); 82 87 if (res) responses.push(res); 83 88 } 84 89 if (responses.length === 0) return c.body(null, 202); ··· 86 91 } 87 92 88 93 // Single request 89 - const response = await handleMessage(body, apiFetch); 94 + const response = await handleMessage(body, tools, apiFetch); 90 95 if (!response) return c.body(null, 202); 91 96 return c.json(response); 92 97 });
+2 -2
packages/mcp/src/tools/feature-requests.ts packages/feature-requests/src/mcp.ts
··· 1 - import type { McpTool, ApiFetch } from "./types.ts"; 1 + import type { McpTool, ApiFetch } from "@exosphere/mcp"; 2 2 3 3 /** Resolve a feature request number to its internal ID via the API. */ 4 4 async function resolveFeatureRequestId( ··· 11 11 return { id: data.featureRequest.id }; 12 12 } 13 13 14 - export const featureRequestTools: McpTool[] = [ 14 + export const featureRequestMcpTools: McpTool[] = [ 15 15 { 16 16 name: "list_feature_requests", 17 17 description:
+1 -6
packages/mcp/src/tools/index.ts
··· 1 - import type { McpTool } from "./types.ts"; 2 - import { sphereTools } from "./sphere.ts"; 3 - import { featureRequestTools } from "./feature-requests.ts"; 4 - 5 1 export type { McpTool, ApiFetch } from "./types.ts"; 6 - 7 - export const tools: McpTool[] = [...sphereTools, ...featureRequestTools]; 2 + export { sphereTools } from "./sphere.ts";