ubiquitous-invention/apps/collab-server/src/index.ts
Randall Stillwell a508ece6e7 feat: Full project management application scaffold
Complete architecture for a ClickUp/Notion/Miro-class project management app:

- Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM)
- Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards)
- NextAuth v5 authentication with credentials + OAuth providers
- tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search
- Three-panel UI: collapsible sidebar, center content area, push-in right panel
- Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS
- Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe
- TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block
- Real-time collaboration via Yjs + Hocuspocus with presence/cursors
- tldraw whiteboard with custom shape cards (task, document, project)
- MCP server exposing all app data/tools for AI agents
- AI chat panel, editor AI slash commands, Cmd+K command palette
- Template system with built-in templates (Bug Report, Meeting Notes, Sprint)
- Full-text search with result highlighting
- Docker Compose for full-stack deployment (web + collab + postgres + redis)

Made-with: Cursor
2026-03-26 22:39:16 -05:00

249 lines
6.5 KiB
TypeScript

import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { createHash } from "node:crypto";
import { Database } from "@hocuspocus/extension-database";
import { Redis as HocuspocusRedis } from "@hocuspocus/extension-redis";
import { Hocuspocus, type Extension, type onAuthenticatePayload } from "@hocuspocus/server";
import { db } from "@tasks/database/client";
import { objects } from "@tasks/database/schema";
import IoRedis from "ioredis";
import { z } from "zod";
import {
base64ToYUpdate,
extractObjectId,
yDocToBase64,
} from "./utils.js";
/**
* Resolve transitive deps (drizzle-orm, @hocuspocus/common) without listing them in this package.
* Uses workspace package roots so `createRequire` works under `tsx` (no `import.meta.resolve`).
*/
const __dirname = dirname(fileURLToPath(import.meta.url));
const requireFromDb = createRequire(
join(__dirname, "../../../packages/database/package.json"),
);
const requireFromServer = createRequire(
join(__dirname, "../node_modules/@hocuspocus/server/package.json"),
);
// Resolved at runtime via @tasks/database's dependency graph (see `requireFromDb` above).
const { eq } = requireFromDb("drizzle-orm") as {
eq: (...args: unknown[]) => any;
};
const { Forbidden } = requireFromServer("@hocuspocus/common") as {
Forbidden: { code: number; reason: string };
};
const contentSchema = z
.object({
yjs: z.string().optional(),
})
.passthrough();
function parseYjsBase64FromContent(content: unknown): string | null {
if (content == null) {
return null;
}
if (typeof content === "string" && content.length > 0) {
return content;
}
const parsed = contentSchema.safeParse(content);
if (!parsed.success || !parsed.data.yjs) {
return null;
}
return parsed.data.yjs;
}
async function tryCreateRedisExtension(): Promise<HocuspocusRedis | null> {
const url = process.env.REDIS_URL?.trim();
if (!url) {
return null;
}
const client = new IoRedis(url, {
maxRetriesPerRequest: null,
enableReadyCheck: true,
lazyConnect: false,
});
try {
await client.ping();
} catch (error) {
console.warn(
"[collab] Redis unavailable, continuing without Redis extension:",
error,
);
try {
client.disconnect();
} catch {
/* ignore */
}
return null;
}
return new HocuspocusRedis({
redis: client,
identifier: `collab-${process.pid}-${Date.now()}`,
});
}
function buildDatabaseExtension() {
return new Database({
fetch: async ({ documentName, document }) => {
const objectId = extractObjectId(documentName);
if (!objectId) {
console.error(
`[collab] fetch: invalid document name (expected object:{id}): ${documentName}`,
);
return null;
}
try {
const rows = await db
.select({ content: objects.content })
.from(objects)
.where(eq(objects.id, objectId))
.limit(1);
const row = rows[0];
if (!row) {
return null;
}
const base64 = parseYjsBase64FromContent(row.content);
if (!base64) {
return null;
}
try {
return base64ToYUpdate(base64);
} catch (e) {
console.error(
`[collab] fetch: invalid Yjs base64 for object ${objectId}:`,
e,
);
return null;
}
} catch (error) {
console.error(
`[collab] fetch: database error for object ${objectId}:`,
error,
);
return null;
}
},
store: async ({ documentName, document }) => {
const objectId = extractObjectId(documentName);
if (!objectId) {
console.error(
`[collab] store: invalid document name (expected object:{id}): ${documentName}`,
);
return;
}
const base64 = yDocToBase64(document);
try {
const existing = await db
.select({ content: objects.content })
.from(objects)
.where(eq(objects.id, objectId))
.limit(1);
const prev = existing[0]?.content;
const merged =
prev !== null &&
prev !== undefined &&
typeof prev === "object" &&
!Array.isArray(prev)
? { ...(prev as Record<string, unknown>), yjs: base64 }
: { yjs: base64 };
const updated = await db
.update(objects)
.set({
content: merged,
updatedAt: new Date(),
})
.where(eq(objects.id, objectId))
.returning({ id: objects.id });
if (updated.length === 0) {
console.warn(
`[collab] store: object ${objectId} not found, skipping persist`,
);
}
} catch (error) {
console.error(
`[collab] store: database error for object ${objectId}:`,
error,
);
}
},
});
}
async function main() {
const port = Number(process.env.PORT) || 1234;
const extensions: Extension[] = [buildDatabaseExtension()];
const redis = await tryCreateRedisExtension();
if (redis) {
extensions.push(redis);
}
const hocuspocus = new Hocuspocus({
quiet: true,
stopOnSignals: false,
address: "0.0.0.0",
port,
extensions,
async onAuthenticate({ token }: onAuthenticatePayload) {
if (!token?.trim()) {
throw Forbidden;
}
const id = createHash("sha256").update(token).digest("hex");
return {
user: { id, name: `User ${id.slice(0, 8)}` },
};
},
async onConnect({ documentName }) {
console.log(`[collab] connect document=${documentName}`);
},
async onDisconnect({ documentName }) {
console.log(`[collab] disconnect document=${documentName}`);
},
async afterUnloadDocument({ documentName }) {
console.log(`[collab] afterUnloadDocument document=${documentName}`);
},
});
const shutdown = async (signal: NodeJS.Signals) => {
console.log(`[collab] received ${signal}, shutting down...`);
try {
await hocuspocus.destroy();
} catch (error) {
console.error("[collab] error during shutdown:", error);
}
process.exit(0);
};
process.once("SIGINT", () => void shutdown("SIGINT"));
process.once("SIGTERM", () => void shutdown("SIGTERM"));
await hocuspocus.listen();
console.log(`Collaboration server running on port ${port}`);
}
main().catch((error) => {
console.error("[collab] fatal error:", error);
process.exit(1);
});