Rebuild the hub on Bun: Elysia API, Better Auth, Drizzle, Next.js website
CI / test (push) Successful in 30s

The hub becomes a Bun monorepo: apps/api (Elysia, Better Auth for accounts, roles, bans, proxy device
sign-in and JWT keys; tagline linking, presence and the console as before), packages/db (Drizzle; Postgres in
production, PGlite in development and tests), and the Next.js website from apps/web. Self-hostable with
docker compose; the website deploys to Vercel-style hosts, the API to any that runs Bun or containers. The
first version, in Go, is removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-29 05:03:54 -07:00
1 parent 64c552330b
commit 7a06ba49c3
59 files changed
+2640 -3523

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
// The live connection with signed-in proxies: which IMVU account each client is using (presence), and the
// community products it wears. Later, room channels.
//
// Protocol (JSON text messages over the WebSocket at /api/v1/realtime, authorized with the proxy's session token as
// a bearer token):
// proxy -> hub {"type": "session", "imvu_id": 123} the client signed in as this IMVU account
// proxy -> hub {"type": "outfit", "items": [{"id", "name"}]} the community products it wears now
// hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."}
export type Item = { id: string; name: string };
export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[] };
type Session = { userId: string; imvuId: number; since: Date; outfit: Item[]; close: () => void };
export type Owners = (imvuId: number) => Promise<string | undefined>;
export class Realtime {
private sessions = new Map<string, Session>();
private listeners = new Set<() => void>();
constructor(private readonly owner: Owners) {}
/** Calls fn on every change of presence or outfits; returns a function that stops it. */
subscribe(fn: () => void): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private changed() {
for (const fn of this.listeners) fn();
}
/** The IMVU accounts in use now, one entry each (the most recent session), oldest first. */
online(): Online[] {
const byId = new Map<number, Online>();
for (const s of this.sessions.values()) {
if (!s.imvuId) continue;
const prev = byId.get(s.imvuId);
if (prev && prev.since > s.since) continue;
byId.set(s.imvuId, { userId: s.userId, imvuId: s.imvuId, since: s.since, outfit: [...s.outfit] });
}
return [...byId.values()].sort((a, b) => a.since.getTime() - b.since.getTime());
}
connections(): number {
return this.sessions.size;
}
open(id: string, userId: string, close: () => void) {
this.sessions.set(id, { userId, imvuId: 0, since: new Date(), outfit: [], close });
}
close(id: string) {
const s = this.sessions.get(id);
this.sessions.delete(id);
if (s?.imvuId) this.changed();
}
/** Closes a person's connections (banned, or signed out of their computers). */
disconnect(userId: string) {
for (const s of this.sessions.values()) if (s.userId === userId) s.close();
}
/** Handles a message from a proxy; returns the reply to send, if any. */
async message(id: string, raw: unknown): Promise<object | undefined> {
const s = this.sessions.get(id);
if (!s) return;
const m = (typeof raw === "string" ? safeJSON(raw) : raw) as { type?: unknown; imvu_id?: unknown; items?: unknown };
if (!m || typeof m !== "object") return { type: "error", message: "not a message" };
if (m.type === "session") {
const imvuId = Number(m.imvu_id);
const owner = Number.isSafeInteger(imvuId) && imvuId > 0 ? await this.owner(imvuId) : undefined;
if (owner !== s.userId) {
// Not this person's account: no presence, no outfit for it.
s.imvuId = 0;
s.outfit = [];
this.changed();
return { type: "error", message: "not_linked" };
}
s.imvuId = imvuId;
s.since = new Date();
s.outfit = [];
this.changed();
return;
}
if (m.type === "outfit") {
if (!Array.isArray(m.items) || m.items.length > 64) return { type: "error", message: "too many items" };
s.outfit = m.items
.filter((it): it is Item => typeof it?.id === "string" && typeof it?.name === "string")
.filter((it) => it.id && it.id.length <= 128 && it.name.length <= 128)
.map((it) => ({ id: it.id, name: it.name }));
this.changed();
return;
}
return { type: "error", message: "unknown message type" };
}
}
function safeJSON(s: string): unknown {
try {
return JSON.parse(s);
} catch {
return undefined;
}
}