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,81 @@
|
||||
// IMVU's public user API: who an account is, its tagline (for linking) and whether it's age verified or has Access
|
||||
// Pass. The hub calls it rarely (linking, and age checks) and depends on it for nothing else.
|
||||
|
||||
export type ImvuUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
tagline: string;
|
||||
ageVerified: boolean;
|
||||
accessPass: boolean;
|
||||
};
|
||||
|
||||
export class NoSuchImvuUser extends Error {
|
||||
constructor() {
|
||||
super("no such IMVU account");
|
||||
}
|
||||
}
|
||||
|
||||
export class ImvuUnavailable extends Error {}
|
||||
|
||||
// Calls are spaced out so the hub never floods IMVU.
|
||||
const minInterval = 200;
|
||||
|
||||
export class ImvuClient {
|
||||
private next = 0;
|
||||
|
||||
constructor(
|
||||
readonly base = "https://api.imvu.com",
|
||||
private readonly fetchFn: typeof fetch = fetch,
|
||||
) {}
|
||||
|
||||
byUsername(username: string): Promise<ImvuUser> {
|
||||
const name = username.trim();
|
||||
if (!name || name.length > 64) return Promise.reject(new NoSuchImvuUser());
|
||||
return this.get(`/user?username=${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
byId(id: number): Promise<ImvuUser> {
|
||||
return this.get(`/user/user-${id}`);
|
||||
}
|
||||
|
||||
private async wait() {
|
||||
const now = Date.now();
|
||||
const at = Math.max(now, this.next);
|
||||
this.next = at + minInterval;
|
||||
if (at > now) await Bun.sleep(at - now);
|
||||
}
|
||||
|
||||
private async get(path: string): Promise<ImvuUser> {
|
||||
await this.wait();
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetchFn(this.base + path, {
|
||||
headers: { "User-Agent": "t5de-hub (https://git.t5de.dev/t5de/t5de-hub)" },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch (e) {
|
||||
throw new ImvuUnavailable(String(e));
|
||||
}
|
||||
if (response.status === 404) throw new NoSuchImvuUser();
|
||||
if (!response.ok) throw new ImvuUnavailable(`IMVU answered ${response.status}`);
|
||||
return parse(await response.json());
|
||||
}
|
||||
}
|
||||
|
||||
// IMVU's "denormalized" answers: the user is the one /user/user-<id> entry.
|
||||
export function parse(answer: unknown): ImvuUser {
|
||||
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
|
||||
const users = Object.entries(den).filter(([key]) => key.includes("/user/user-"));
|
||||
if (users.length !== 1) throw new NoSuchImvuUser();
|
||||
const data = users[0]![1].data ?? {};
|
||||
const id = Number(data.legacy_cid);
|
||||
const username = typeof data.username === "string" ? data.username : "";
|
||||
if (!Number.isSafeInteger(id) || id <= 0 || !username) throw new NoSuchImvuUser();
|
||||
return {
|
||||
id,
|
||||
username,
|
||||
tagline: typeof data.tagline === "string" ? data.tagline : "",
|
||||
ageVerified: data.is_ageverified === true,
|
||||
accessPass: data.is_ap === true,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user