Rebuild the hub on Bun: Elysia API, Better Auth, Drizzle, Next.js website
CI / test (push) Successful in 30s
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:
1 parent
64c552330b
commit
7a06ba49c3
59 files changed
+2640
-3523
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user