rspace-online/shared/module.ts

190 lines
6.0 KiB
TypeScript

import { Hono } from "hono";
import type { FlowKind, FeedDefinition } from "../lib/layer-types";
export type { FeedDefinition } from "../lib/layer-types";
import type { SyncServer } from "../server/local-first/sync-server";
// ── Module Scoping ──
export type ModuleScope = 'space' | 'global';
export interface ModuleScoping {
/** Whether the module's data lives per-space or globally by default */
defaultScope: ModuleScope;
/** Whether space owners can override the default scope */
userConfigurable: boolean;
}
// ── Lifecycle Context ──
export interface SpaceLifecycleContext {
spaceSlug: string;
ownerDID: string | null;
enabledModules: string[];
syncServer: SyncServer;
}
// ── Doc Schema (for Automerge document types a module manages) ──
export interface DocSchema<T = unknown> {
/** Document ID pattern, e.g. '{space}:notes:notebooks:{notebookId}' */
pattern: string;
/** Human-readable description */
description: string;
/** Factory to create a fresh empty document */
init: () => T;
}
/** Info for a sub-page shown on bare-domain instead of the functional app. */
export interface SubPageInfo {
/** URL segment (e.g. "thread", "flow", "crm") */
path: string;
/** Display title (e.g. "Thread Builder") */
title: string;
/** Emoji icon */
icon: string;
/** Short tagline shown as a pill above the title */
tagline: string;
/** 1-2 sentence description */
description: string;
/** Optional feature cards for a grid section */
features?: Array<{ icon: string; title: string; text: string }>;
/** Optional: fully custom body HTML (replaces generic template) */
bodyHTML?: () => string;
}
/** A browsable content type that a module produces. */
export interface OutputPath {
/** URL segment: "notebooks" */
path: string;
/** Display name: "Notebooks" */
name: string;
/** Emoji: "📓" */
icon: string;
/** Short description: "Rich-text collaborative notebooks" */
description: string;
}
/**
* The contract every rSpace module must implement.
*
* A module is a self-contained feature area (books, pubs, cart, canvas, etc.)
* that exposes Hono routes and metadata. The shell mounts these routes under
* `/:space/{moduleId}` in unified mode. In standalone mode, the module's own
* `standalone.ts` mounts them at the root with a minimal shell.
*/
export interface RSpaceModule {
/** Short identifier used in URLs: 'books', 'pubs', 'cart', 'canvas', etc. */
id: string;
/** Human-readable name: 'rBooks', 'rPubs', 'rCart', etc. */
name: string;
/** Emoji or SVG string for the app switcher */
icon: string;
/** One-line description */
description: string;
/** Mountable Hono sub-app. Routes are relative to the mount point. */
routes: Hono;
// ── Scoping & Schema ──
/** How this module's data is scoped (space vs global) */
scoping: ModuleScoping;
/** Automerge document schemas this module manages */
docSchemas?: DocSchema[];
// ── Lifecycle hooks ──
/** Called once at server startup (register schemas, init DB, etc.) */
onInit?: (ctx: { syncServer: SyncServer }) => Promise<void>;
/** Called when a new space is created */
onSpaceCreate?: (ctx: SpaceLifecycleContext) => Promise<void>;
/** Called when a space is deleted */
onSpaceDelete?: (ctx: SpaceLifecycleContext) => Promise<void>;
/** Called when this module is enabled for a space */
onSpaceEnable?: (ctx: SpaceLifecycleContext) => Promise<void>;
/** Called when this module is disabled for a space */
onSpaceDisable?: (ctx: SpaceLifecycleContext) => Promise<void>;
// ── Display & routing ──
/** Optional: standalone domain for this module (e.g. 'rbooks.online') */
standaloneDomain?: string;
/** Feeds this module exposes to other layers */
feeds?: FeedDefinition[];
/** Feed kinds this module can consume from other layers */
acceptsFeeds?: FlowKind[];
/** If true, module is hidden from app switcher (still has routes) */
hidden?: boolean;
/** Browsable content types this module produces */
outputPaths?: OutputPath[];
/** Optional: render rich landing page body HTML */
landingPage?: () => string;
/** Info pages for sub-paths on bare domain (replaces demo rewrite with marketing page) */
subPageInfos?: SubPageInfo[];
/** Optional: external app to embed via iframe when ?view=app */
externalApp?: {
url: string;
name: string;
};
/** Seed template/demo data for a space. Called by /template route. */
seedTemplate?: (space: string) => void;
/** If true, write operations (POST/PUT/PATCH/DELETE) skip the space role check.
* Use for modules whose API endpoints are publicly accessible (e.g. thread builder). */
publicWrite?: boolean;
}
/** Registry of all loaded modules */
const modules = new Map<string, RSpaceModule>();
export function registerModule(mod: RSpaceModule): void {
modules.set(mod.id, mod);
}
export function getModule(id: string): RSpaceModule | undefined {
return modules.get(id);
}
export function getAllModules(): RSpaceModule[] {
return Array.from(modules.values());
}
/** Metadata exposed to the client for the app switcher and tab bar */
export interface ModuleInfo {
id: string;
name: string;
icon: string;
description: string;
scoping: ModuleScoping;
standaloneDomain?: string;
feeds?: FeedDefinition[];
acceptsFeeds?: FlowKind[];
hidden?: boolean;
hasLandingPage?: boolean;
externalApp?: {
url: string;
name: string;
};
outputPaths?: OutputPath[];
subPageInfos?: Array<{ path: string; title: string }>;
}
export function getModuleInfoList(): ModuleInfo[] {
return getAllModules()
.filter((m) => !m.hidden)
.map((m) => ({
id: m.id,
name: m.name,
icon: m.icon,
description: m.description,
scoping: m.scoping,
...(m.standaloneDomain ? { standaloneDomain: m.standaloneDomain } : {}),
...(m.feeds ? { feeds: m.feeds } : {}),
...(m.acceptsFeeds ? { acceptsFeeds: m.acceptsFeeds } : {}),
...(m.landingPage ? { hasLandingPage: true } : {}),
...(m.externalApp ? { externalApp: m.externalApp } : {}),
...(m.outputPaths ? { outputPaths: m.outputPaths } : {}),
...(m.subPageInfos ? { subPageInfos: m.subPageInfos.map(s => ({ path: s.path, title: s.title })) } : {}),
}));
}