Compare commits
No commits in common. "58878752a8032739031283694654aea69c1f464a" and "21910eca416d4a413c9e84795965edeffb202c22" have entirely different histories.
58878752a8
...
21910eca41
11 changed files with 355 additions and 411 deletions
|
|
@ -52,10 +52,7 @@ export const partyMember = pgTable(
|
||||||
joinedAt: timestamp().defaultNow().notNull(),
|
joinedAt: timestamp().defaultNow().notNull(),
|
||||||
lastSeen: timestamp().defaultNow().notNull(),
|
lastSeen: timestamp().defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(partyMember) => [
|
(partyMember) => [uniqueIndex().on(partyMember.partyId, partyMember.userId)],
|
||||||
uniqueIndex().on(partyMember.partyId, partyMember.userId),
|
|
||||||
index().on(partyMember.userId, partyMember.joinedAt),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const platform = pgEnum("enum_platform", ["spotify", "apple"]);
|
export const platform = pgEnum("enum_platform", ["spotify", "apple"]);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import "./workflows/sync";
|
||||||
import "./workflows/party-analysis";
|
import "./workflows/party-analysis";
|
||||||
import "./dbos.ts";
|
import "./dbos.ts";
|
||||||
import { partyApp } from "./routes/party";
|
import { partyApp } from "./routes/party";
|
||||||
import { partySocketApp, pubsub } from "./routes/party-socket";
|
import { partySocketApp } from "./routes/party-socket";
|
||||||
import { statsApp } from "./routes/stats.ts";
|
import { statsApp } from "./routes/stats.ts";
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
|
|
@ -23,8 +23,6 @@ const app = new Elysia()
|
||||||
)
|
)
|
||||||
.listen(4000);
|
.listen(4000);
|
||||||
|
|
||||||
pubsub.setServer(app.server);
|
|
||||||
|
|
||||||
export type App = typeof app;
|
export type App = typeof app;
|
||||||
|
|
||||||
await DBOS.launch({
|
await DBOS.launch({
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { party, partyMember } from "./db/schema";
|
import { party, partyMember } from "./db/schema";
|
||||||
import type { PartySnapshot } from "./party-types";
|
|
||||||
|
|
||||||
type DbClient = typeof db;
|
type DbClient = typeof db;
|
||||||
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
|
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
|
||||||
|
|
@ -30,16 +29,11 @@ export async function getMemberRecord(dbClient: DbLike, userId: string) {
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
orderBy: {
|
|
||||||
joinedAt: "desc",
|
|
||||||
},
|
|
||||||
})) ?? null
|
})) ?? null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPartyStatus(
|
export async function getPartyStatus(partyId: string) {
|
||||||
partyId: string,
|
|
||||||
): Promise<PartySnapshot | null> {
|
|
||||||
const partyRecord = await db.query.party.findFirst({
|
const partyRecord = await db.query.party.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: partyId,
|
id: partyId,
|
||||||
|
|
|
||||||
159
api/src/party-sockets.ts
Normal file
159
api/src/party-sockets.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
type PartySocketEvent = {
|
||||||
|
type: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WebSocketLike = {
|
||||||
|
send: (data: string) => void;
|
||||||
|
close?: (code?: number, reason?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const partySockets = new Map<string, Map<string, Set<WebSocketLike>>>();
|
||||||
|
const userSockets = new Map<string, Set<WebSocketLike>>();
|
||||||
|
|
||||||
|
function getPartyUserSockets(partyId: string, userId: string) {
|
||||||
|
const partyMap = partySockets.get(partyId);
|
||||||
|
if (!partyMap) return null;
|
||||||
|
return partyMap.get(userId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerPartySocket(
|
||||||
|
partyId: string,
|
||||||
|
userId: string,
|
||||||
|
ws: WebSocketLike,
|
||||||
|
) {
|
||||||
|
let partyMap = partySockets.get(partyId);
|
||||||
|
if (!partyMap) {
|
||||||
|
partyMap = new Map();
|
||||||
|
partySockets.set(partyId, partyMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
let userSockets = partyMap.get(userId);
|
||||||
|
if (!userSockets) {
|
||||||
|
userSockets = new Set();
|
||||||
|
partyMap.set(userId, userSockets);
|
||||||
|
}
|
||||||
|
|
||||||
|
userSockets.add(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterPartySocket(
|
||||||
|
partyId: string,
|
||||||
|
userId: string,
|
||||||
|
ws: WebSocketLike,
|
||||||
|
) {
|
||||||
|
const partyMap = partySockets.get(partyId);
|
||||||
|
if (!partyMap) return;
|
||||||
|
|
||||||
|
const userSockets = partyMap.get(userId);
|
||||||
|
if (!userSockets) return;
|
||||||
|
|
||||||
|
userSockets.delete(ws);
|
||||||
|
|
||||||
|
if (userSockets.size === 0) {
|
||||||
|
partyMap.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partyMap.size === 0) {
|
||||||
|
partySockets.delete(partyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerUserSocket(userId: string, ws: WebSocketLike) {
|
||||||
|
let sockets = userSockets.get(userId);
|
||||||
|
if (!sockets) {
|
||||||
|
sockets = new Set();
|
||||||
|
userSockets.set(userId, sockets);
|
||||||
|
}
|
||||||
|
|
||||||
|
sockets.add(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterUserSocket(userId: string, ws: WebSocketLike) {
|
||||||
|
const sockets = userSockets.get(userId);
|
||||||
|
if (!sockets) return;
|
||||||
|
|
||||||
|
sockets.delete(ws);
|
||||||
|
|
||||||
|
if (sockets.size === 0) {
|
||||||
|
userSockets.delete(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterUserSocketFromAllParties(
|
||||||
|
userId: string,
|
||||||
|
ws: WebSocketLike,
|
||||||
|
) {
|
||||||
|
for (const [partyId, partyMap] of partySockets) {
|
||||||
|
const userSockets = partyMap.get(userId);
|
||||||
|
if (!userSockets) continue;
|
||||||
|
userSockets.delete(ws);
|
||||||
|
if (userSockets.size === 0) {
|
||||||
|
partyMap.delete(userId);
|
||||||
|
}
|
||||||
|
if (partyMap.size === 0) {
|
||||||
|
partySockets.delete(partyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastPartyEvent(partyId: string, event: PartySocketEvent) {
|
||||||
|
const partyMap = partySockets.get(partyId);
|
||||||
|
if (!partyMap) return;
|
||||||
|
|
||||||
|
const payload = JSON.stringify(event);
|
||||||
|
for (const userSockets of partyMap.values()) {
|
||||||
|
for (const ws of userSockets) {
|
||||||
|
ws.send(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendPartyEventToUser(
|
||||||
|
partyId: string,
|
||||||
|
userId: string,
|
||||||
|
event: PartySocketEvent,
|
||||||
|
) {
|
||||||
|
const userSockets = getPartyUserSockets(partyId, userId);
|
||||||
|
if (!userSockets) return;
|
||||||
|
|
||||||
|
const payload = JSON.stringify(event);
|
||||||
|
for (const ws of userSockets) {
|
||||||
|
ws.send(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendDirectEventToUser(userId: string, event: PartySocketEvent) {
|
||||||
|
const sockets = userSockets.get(userId);
|
||||||
|
if (!sockets) return;
|
||||||
|
|
||||||
|
const payload = JSON.stringify(event);
|
||||||
|
for (const ws of sockets) {
|
||||||
|
ws.send(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reassignUserSocketsToParty(
|
||||||
|
userId: string,
|
||||||
|
partyId: string | null,
|
||||||
|
) {
|
||||||
|
for (const [existingPartyId, partyMap] of partySockets) {
|
||||||
|
if (!partyMap.has(userId)) continue;
|
||||||
|
partyMap.delete(userId);
|
||||||
|
if (partyMap.size === 0) {
|
||||||
|
partySockets.delete(existingPartyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!partyId) return;
|
||||||
|
const sockets = userSockets.get(userId);
|
||||||
|
if (!sockets) return;
|
||||||
|
|
||||||
|
let partyMap = partySockets.get(partyId);
|
||||||
|
if (!partyMap) {
|
||||||
|
partyMap = new Map();
|
||||||
|
partySockets.set(partyId, partyMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
partyMap.set(userId, new Set(sockets));
|
||||||
|
}
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import type { InferSelectModel } from "drizzle-orm";
|
|
||||||
import type { party, partyMember, user } from "./db/schema";
|
|
||||||
|
|
||||||
export type Party = InferSelectModel<typeof party>;
|
|
||||||
export type PartyMember = InferSelectModel<typeof partyMember>;
|
|
||||||
export type User = InferSelectModel<typeof user>;
|
|
||||||
|
|
||||||
export type PartyMemberWithUser = PartyMember & { user: User | null };
|
|
||||||
|
|
||||||
export const PARTY_STATUS = ["created", "started", "ended"] as const;
|
|
||||||
export type PartyStatus = (typeof PARTY_STATUS)[number];
|
|
||||||
|
|
||||||
export type PartySnapshot = {
|
|
||||||
party: Party;
|
|
||||||
members: PartyMemberWithUser[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PartyState = {
|
|
||||||
party: Party | null;
|
|
||||||
members: PartyMemberWithUser[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PartySocketOutgoing =
|
|
||||||
| { type: "ping" }
|
|
||||||
| { type: "member_payload"; payload: unknown };
|
|
||||||
|
|
||||||
export type PartySocketEvent =
|
|
||||||
| { type: "snapshot"; party: Party | null; members: PartyMemberWithUser[] }
|
|
||||||
| { type: "party_status"; party: Party; members: PartyMemberWithUser[] }
|
|
||||||
| { type: "member_payload"; fromUserId: string; payload: unknown }
|
|
||||||
| { type: "error"; message: string }
|
|
||||||
| { type: "pong" };
|
|
||||||
|
|
@ -1,133 +1,137 @@
|
||||||
import { Elysia } from "elysia";
|
import Elysia, { t } from "elysia";
|
||||||
|
import { auth, betterAuthElysia } from "../auth";
|
||||||
import { betterAuthElysia } from "../auth";
|
|
||||||
|
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { getMemberRecord, getPartyStatus } from "../party-data";
|
import { getMemberRecord, getPartyStatus } from "../party-data";
|
||||||
|
import {
|
||||||
|
registerPartySocket,
|
||||||
|
registerUserSocket,
|
||||||
|
sendPartyEventToUser,
|
||||||
|
unregisterPartySocket,
|
||||||
|
unregisterUserSocket,
|
||||||
|
unregisterUserSocketFromAllParties,
|
||||||
|
} from "../party-sockets";
|
||||||
|
|
||||||
function userTopic(userId: string) {
|
type PartySocketMessage =
|
||||||
return `user:${userId}`;
|
| {
|
||||||
}
|
type: "member_payload";
|
||||||
|
payload: unknown;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "ping";
|
||||||
|
};
|
||||||
|
|
||||||
function partyTopic(partyId: string) {
|
const MAX_MEMBER_PAYLOAD_SIZE = 8_000;
|
||||||
return `party:${partyId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const socketPartyId = new WeakMap<object, string>();
|
type PartyWsData = {
|
||||||
|
user?: { id: string };
|
||||||
export const pubsub = {
|
partyId?: string | null;
|
||||||
_server: null as ReturnType<typeof Bun.serve> | null,
|
|
||||||
setServer(server: ReturnType<typeof Bun.serve> | null) {
|
|
||||||
this._server = server;
|
|
||||||
},
|
|
||||||
publish(topic: string, data: string) {
|
|
||||||
this._server?.publish(topic, data);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const topic = {
|
function getPayloadSize(payload: unknown) {
|
||||||
user: userTopic,
|
try {
|
||||||
party: partyTopic,
|
return JSON.stringify(payload).length;
|
||||||
};
|
} catch {
|
||||||
|
return Infinity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const partySocketApp = new Elysia()
|
export const partySocketApp = new Elysia()
|
||||||
.use(betterAuthElysia)
|
.use(betterAuthElysia)
|
||||||
.group("/party-socket", (app) =>
|
.group("/party-socket", (app) =>
|
||||||
app
|
app.ws("/ws", {
|
||||||
.get("/test", () => ({ ok: 1 }))
|
beforeHandle: async ({ request, set }) => {
|
||||||
.ws("/ws", {
|
const session = await auth.api.getSession({
|
||||||
auth: true,
|
headers: request.headers,
|
||||||
publishToSelf: true,
|
});
|
||||||
open: async (ws) => {
|
if (!session) {
|
||||||
const user = ws.data.user;
|
set.status = 401;
|
||||||
if (!user) return;
|
return;
|
||||||
|
}
|
||||||
ws.subscribe(userTopic(user.id));
|
return {
|
||||||
|
user: session.user,
|
||||||
const membership = await getMemberRecord(db, user.id);
|
session: session.session,
|
||||||
if (!membership) {
|
};
|
||||||
ws.send(
|
},
|
||||||
JSON.stringify({
|
open: async (ws) => {
|
||||||
type: "snapshot",
|
const data = ws.data as unknown as PartyWsData;
|
||||||
party: null,
|
const user = data.user;
|
||||||
members: [],
|
if (!user) return;
|
||||||
}),
|
registerUserSocket(user.id, ws);
|
||||||
);
|
const membership = await getMemberRecord(db, user.id);
|
||||||
return;
|
if (!membership) {
|
||||||
}
|
ws.send(
|
||||||
|
|
||||||
socketPartyId.set(ws, membership.partyId);
|
|
||||||
ws.subscribe(partyTopic(membership.partyId));
|
|
||||||
|
|
||||||
const snapshot = await getPartyStatus(membership.partyId);
|
|
||||||
if (snapshot) {
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "snapshot",
|
|
||||||
party: snapshot.party,
|
|
||||||
members: snapshot.members,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
message: async (ws, message) => {
|
|
||||||
const data = ws.data;
|
|
||||||
const user = data.user;
|
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
if (typeof message !== "string") return;
|
|
||||||
|
|
||||||
let parsed: { type: string; payload?: unknown };
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(message);
|
|
||||||
} catch {
|
|
||||||
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.type === "ping") {
|
|
||||||
ws.send(JSON.stringify({ type: "pong" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.type !== "member_payload") return;
|
|
||||||
|
|
||||||
const MAX_MEMBER_PAYLOAD_SIZE = 8_000;
|
|
||||||
const payloadString = JSON.stringify(parsed.payload);
|
|
||||||
if (payloadString.length > MAX_MEMBER_PAYLOAD_SIZE) {
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({ type: "error", message: "Payload too large." }),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const membership = await getMemberRecord(db, user.id);
|
|
||||||
if (!membership) return;
|
|
||||||
|
|
||||||
const currentParty = await db.query.party.findFirst({
|
|
||||||
where: { id: membership.partyId },
|
|
||||||
});
|
|
||||||
if (!currentParty) return;
|
|
||||||
|
|
||||||
ws.publish(
|
|
||||||
partyTopic(membership.partyId),
|
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "member_payload",
|
type: "snapshot",
|
||||||
fromUserId: user.id,
|
party: null,
|
||||||
payload: parsed.payload,
|
members: [],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
return;
|
||||||
close: async (ws) => {
|
}
|
||||||
const user = ws.data.user;
|
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
ws.unsubscribe(userTopic(user.id));
|
const snapshot = await getPartyStatus(membership.partyId);
|
||||||
|
data.partyId = membership.partyId;
|
||||||
|
registerPartySocket(membership.partyId, user.id, ws);
|
||||||
|
if (snapshot) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "snapshot",
|
||||||
|
party: snapshot.party,
|
||||||
|
members: snapshot.members,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: async (ws, message: PartySocketMessage) => {
|
||||||
|
const data = ws.data as unknown as PartyWsData;
|
||||||
|
const user = data.user;
|
||||||
|
if (!user) return;
|
||||||
|
if (message.type === "ping") {
|
||||||
|
ws.send(JSON.stringify({ type: "pong" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const partyId = socketPartyId.get(ws);
|
if (message.type !== "member_payload") return;
|
||||||
if (!partyId) return;
|
const membership = await getMemberRecord(db, user.id);
|
||||||
|
if (!membership) return;
|
||||||
|
|
||||||
ws.unsubscribe(partyTopic(partyId));
|
if (getPayloadSize(message.payload) > MAX_MEMBER_PAYLOAD_SIZE) {
|
||||||
},
|
ws.send(
|
||||||
}),
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "Payload too large.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentParty = await db.query.party.findFirst({
|
||||||
|
where: { id: membership.partyId },
|
||||||
|
});
|
||||||
|
if (!currentParty) return;
|
||||||
|
|
||||||
|
sendPartyEventToUser(membership.partyId, currentParty.hostId, {
|
||||||
|
type: "member_payload",
|
||||||
|
fromUserId: user.id,
|
||||||
|
payload: message.payload,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
close: async (ws) => {
|
||||||
|
const data = ws.data as unknown as PartyWsData;
|
||||||
|
const user = data.user;
|
||||||
|
const { partyId } = data;
|
||||||
|
if (!user) return;
|
||||||
|
if (!partyId) {
|
||||||
|
unregisterUserSocketFromAllParties(user.id, ws);
|
||||||
|
unregisterUserSocket(user.id, ws);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unregisterPartySocket(partyId, user.id, ws);
|
||||||
|
unregisterUserSocket(user.id, ws);
|
||||||
|
},
|
||||||
|
body: t.Union([
|
||||||
|
t.Object({ type: t.Literal("ping") }),
|
||||||
|
t.Object({ type: t.Literal("member_payload"), payload: t.Any() }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,29 +10,29 @@ import {
|
||||||
getPartyStatus,
|
getPartyStatus,
|
||||||
leaveParty,
|
leaveParty,
|
||||||
} from "../party-data";
|
} from "../party-data";
|
||||||
import type { PartySnapshot } from "../party-types";
|
import {
|
||||||
import { pubsub, topic } from "./party-socket";
|
broadcastPartyEvent,
|
||||||
|
reassignUserSocketsToParty,
|
||||||
|
sendDirectEventToUser,
|
||||||
|
} from "../party-sockets";
|
||||||
|
|
||||||
|
const PARTY_STATUS = ["created", "started", "ended"] as const;
|
||||||
|
|
||||||
|
type PartyStatus = (typeof PARTY_STATUS)[number];
|
||||||
|
|
||||||
|
type PartySnapshot = NonNullable<Awaited<ReturnType<typeof getPartyStatus>>>;
|
||||||
|
|
||||||
function broadcastSnapshot(partyId: string, snapshot: PartySnapshot | null) {
|
function broadcastSnapshot(partyId: string, snapshot: PartySnapshot | null) {
|
||||||
if (!snapshot) return;
|
if (!snapshot) return;
|
||||||
pubsub.publish(
|
broadcastPartyEvent(partyId, {
|
||||||
topic.party(partyId),
|
type: "party_status",
|
||||||
JSON.stringify({
|
party: snapshot.party,
|
||||||
type: "party_status",
|
members: snapshot.members,
|
||||||
party: snapshot.party,
|
});
|
||||||
members: snapshot.members,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function broadcastToUser(userId: string, event: Record<string, unknown>) {
|
function isValidStatus(status: string): status is PartyStatus {
|
||||||
pubsub.publish(topic.user(userId), JSON.stringify(event));
|
return PARTY_STATUS.includes(status as PartyStatus);
|
||||||
}
|
|
||||||
|
|
||||||
function isValidStatus(
|
|
||||||
status: string,
|
|
||||||
): status is import("../party-types").PartyStatus {
|
|
||||||
return ["created", "started", "ended"].includes(status);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const partyApp = new Elysia()
|
export const partyApp = new Elysia()
|
||||||
|
|
@ -127,19 +127,31 @@ export const partyApp = new Elysia()
|
||||||
if (!partyId) return { party: null, members: [] };
|
if (!partyId) return { party: null, members: [] };
|
||||||
const status = await getPartyStatus(partyId);
|
const status = await getPartyStatus(partyId);
|
||||||
if (leaveResult?.newHostId) {
|
if (leaveResult?.newHostId) {
|
||||||
broadcastSnapshot(leaveResult.partyId, status);
|
broadcastPartyEvent(leaveResult.partyId, {
|
||||||
|
type: "host_changed",
|
||||||
|
hostId: leaveResult.newHostId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (hostChanged) {
|
if (hostChanged) {
|
||||||
broadcastSnapshot(partyId, status);
|
broadcastPartyEvent(partyId, {
|
||||||
|
type: "host_changed",
|
||||||
|
hostId: targetUserId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
broadcastPartyEvent(partyId, {
|
||||||
|
type: "member_joined",
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
broadcastSnapshot(partyId, status);
|
broadcastSnapshot(partyId, status);
|
||||||
|
reassignUserSocketsToParty(user.id, partyId);
|
||||||
|
reassignUserSocketsToParty(targetUserId, partyId);
|
||||||
if (status) {
|
if (status) {
|
||||||
broadcastToUser(targetUserId, {
|
sendDirectEventToUser(targetUserId, {
|
||||||
type: "party_status",
|
type: "party_status",
|
||||||
party: status.party,
|
party: status.party,
|
||||||
members: status.members,
|
members: status.members,
|
||||||
});
|
});
|
||||||
broadcastToUser(user.id, {
|
sendDirectEventToUser(user.id, {
|
||||||
type: "party_status",
|
type: "party_status",
|
||||||
party: status.party,
|
party: status.party,
|
||||||
members: status.members,
|
members: status.members,
|
||||||
|
|
@ -162,7 +174,18 @@ export const partyApp = new Elysia()
|
||||||
});
|
});
|
||||||
if (!result) return { party: null, members: [] };
|
if (!result) return { party: null, members: [] };
|
||||||
const status = await getPartyStatus(result.partyId);
|
const status = await getPartyStatus(result.partyId);
|
||||||
|
broadcastPartyEvent(result.partyId, {
|
||||||
|
type: "member_left",
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
if (result.newHostId) {
|
||||||
|
broadcastPartyEvent(result.partyId, {
|
||||||
|
type: "host_changed",
|
||||||
|
hostId: result.newHostId,
|
||||||
|
});
|
||||||
|
}
|
||||||
broadcastSnapshot(result.partyId, status);
|
broadcastSnapshot(result.partyId, status);
|
||||||
|
reassignUserSocketsToParty(user.id, null);
|
||||||
return status ?? { party: null, members: [] };
|
return status ?? { party: null, members: [] };
|
||||||
},
|
},
|
||||||
{ auth: true },
|
{ auth: true },
|
||||||
|
|
@ -203,7 +226,13 @@ export const partyApp = new Elysia()
|
||||||
await cleanupPartyIfEmpty(tx, currentMembership.partyId);
|
await cleanupPartyIfEmpty(tx, currentMembership.partyId);
|
||||||
});
|
});
|
||||||
const status = await getPartyStatus(currentMembership.partyId);
|
const status = await getPartyStatus(currentMembership.partyId);
|
||||||
|
broadcastPartyEvent(currentMembership.partyId, {
|
||||||
|
type: "member_left",
|
||||||
|
userId: body.memberUserId,
|
||||||
|
kickedBy: user.id,
|
||||||
|
});
|
||||||
broadcastSnapshot(currentMembership.partyId, status);
|
broadcastSnapshot(currentMembership.partyId, status);
|
||||||
|
reassignUserSocketsToParty(body.memberUserId, null);
|
||||||
return status ?? { party: null, members: [] };
|
return status ?? { party: null, members: [] };
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,29 @@
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
import { useParty } from "#/hooks/use-party";
|
|
||||||
import { useUser } from "#/hooks/user";
|
|
||||||
import { initials } from "#/lib/utils";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||||
import {
|
import {
|
||||||
Item,
|
Item,
|
||||||
ItemContent,
|
ItemContent,
|
||||||
ItemDescription,
|
ItemDescription,
|
||||||
ItemMedia,
|
ItemMedia,
|
||||||
ItemTitle,
|
ItemTitle,
|
||||||
} from "./ui/item";
|
} from "./ui/item";
|
||||||
|
import { useUser } from "#/hooks/user";
|
||||||
|
import { initials } from "#/lib/utils";
|
||||||
|
|
||||||
export function UserInfo() {
|
export function UserInfo() {
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const { party, members, isConnecting, isReconnecting } = useParty();
|
return (
|
||||||
return (
|
<Item>
|
||||||
<Item>
|
<ItemMedia>
|
||||||
<ItemMedia>
|
<Avatar>
|
||||||
<Avatar>
|
<AvatarImage src={user?.image || undefined} />
|
||||||
<AvatarImage src={user?.image || undefined} />
|
<AvatarFallback>{initials(user?.name || "")}</AvatarFallback>
|
||||||
<AvatarFallback>{initials(user?.name || "")}</AvatarFallback>
|
</Avatar>
|
||||||
</Avatar>
|
</ItemMedia>
|
||||||
</ItemMedia>
|
<ItemContent>
|
||||||
<ItemContent>
|
<ItemTitle>{user?.name}</ItemTitle>
|
||||||
<ItemTitle>{user?.name}</ItemTitle>
|
<ItemDescription>No party yet</ItemDescription>
|
||||||
<ItemDescription>
|
</ItemContent>
|
||||||
{isConnecting
|
</Item>
|
||||||
? "Connecting..."
|
);
|
||||||
: isReconnecting
|
|
||||||
? "Reconnecting..."
|
|
||||||
: party
|
|
||||||
? `${members.length} in party`
|
|
||||||
: "No party yet"}
|
|
||||||
</ItemDescription>
|
|
||||||
</ItemContent>
|
|
||||||
</Item>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import type { PartySocketEvent } from "../../../api/src/party-types";
|
|
||||||
|
|
||||||
type Handler = (event: PartySocketEvent) => void;
|
|
||||||
|
|
||||||
const PING_INTERVAL_MS = 30_000;
|
|
||||||
const RECONNECT_BASE_MS = 1_000;
|
|
||||||
const RECONNECT_MAX_MS = 30_000;
|
|
||||||
|
|
||||||
export function usePartySocket({
|
|
||||||
apiUrl,
|
|
||||||
onMessage,
|
|
||||||
}: {
|
|
||||||
apiUrl: string | null;
|
|
||||||
onMessage: Handler | null;
|
|
||||||
}) {
|
|
||||||
const [connectionState, setConnectionState] = useState<
|
|
||||||
"disconnected" | "connecting" | "connected" | "reconnecting"
|
|
||||||
>("disconnected");
|
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
|
||||||
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const reconnectAttemptRef = useRef(0);
|
|
||||||
const handlerRef = useRef(onMessage);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
handlerRef.current = onMessage;
|
|
||||||
}, [onMessage]);
|
|
||||||
|
|
||||||
const setupWs = useCallback(
|
|
||||||
(ws: WebSocket) => {
|
|
||||||
ws.onopen = () => {
|
|
||||||
reconnectAttemptRef.current = 0;
|
|
||||||
setConnectionState("connected");
|
|
||||||
pingTimerRef.current = setInterval(() => {
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify({ type: "ping" }));
|
|
||||||
}
|
|
||||||
}, PING_INTERVAL_MS);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
const parsed = JSON.parse(event.data) as PartySocketEvent;
|
|
||||||
handlerRef.current?.(parsed);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
if (pingTimerRef.current) {
|
|
||||||
clearInterval(pingTimerRef.current);
|
|
||||||
pingTimerRef.current = null;
|
|
||||||
}
|
|
||||||
wsRef.current = null;
|
|
||||||
setConnectionState("reconnecting");
|
|
||||||
|
|
||||||
const delay = Math.min(
|
|
||||||
RECONNECT_BASE_MS * 2 ** reconnectAttemptRef.current,
|
|
||||||
RECONNECT_MAX_MS,
|
|
||||||
);
|
|
||||||
reconnectAttemptRef.current++;
|
|
||||||
reconnectTimerRef.current = setTimeout(() => {
|
|
||||||
if (!apiUrl) return;
|
|
||||||
const protocol = apiUrl.startsWith("https") ? "wss" : "ws";
|
|
||||||
const newWs = new WebSocket(
|
|
||||||
`${protocol}://${apiUrl.replace(/https?:\/\//, "")}/api/party-socket/ws`,
|
|
||||||
);
|
|
||||||
wsRef.current = newWs;
|
|
||||||
setupWs(newWs);
|
|
||||||
}, delay);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[apiUrl],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!apiUrl) {
|
|
||||||
if (wsRef.current) {
|
|
||||||
wsRef.current.close();
|
|
||||||
wsRef.current = null;
|
|
||||||
}
|
|
||||||
if (pingTimerRef.current) {
|
|
||||||
clearInterval(pingTimerRef.current);
|
|
||||||
pingTimerRef.current = null;
|
|
||||||
}
|
|
||||||
if (reconnectTimerRef.current) {
|
|
||||||
clearTimeout(reconnectTimerRef.current);
|
|
||||||
reconnectTimerRef.current = null;
|
|
||||||
}
|
|
||||||
setConnectionState("disconnected");
|
|
||||||
reconnectAttemptRef.current = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setConnectionState("connecting");
|
|
||||||
const protocol = apiUrl.startsWith("https") ? "wss" : "ws";
|
|
||||||
const ws = new WebSocket(
|
|
||||||
`${protocol}://${apiUrl.replace(/https?:\/\//, "")}/api/party-socket/ws`,
|
|
||||||
);
|
|
||||||
wsRef.current = ws;
|
|
||||||
setupWs(ws);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
ws.close();
|
|
||||||
wsRef.current = null;
|
|
||||||
if (pingTimerRef.current) {
|
|
||||||
clearInterval(pingTimerRef.current);
|
|
||||||
pingTimerRef.current = null;
|
|
||||||
}
|
|
||||||
if (reconnectTimerRef.current) {
|
|
||||||
clearTimeout(reconnectTimerRef.current);
|
|
||||||
reconnectTimerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [apiUrl, setupWs]);
|
|
||||||
|
|
||||||
const state = useMemo(
|
|
||||||
() => ({
|
|
||||||
connectionState,
|
|
||||||
isConnected: connectionState === "connected",
|
|
||||||
isConnecting: connectionState === "connecting",
|
|
||||||
isReconnecting: connectionState === "reconnecting",
|
|
||||||
}),
|
|
||||||
[connectionState],
|
|
||||||
);
|
|
||||||
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import type {
|
|
||||||
PartyMember,
|
|
||||||
PartySocketEvent,
|
|
||||||
PartyState,
|
|
||||||
} from "../../../api/src/party-types";
|
|
||||||
import { usePartySocket } from "./use-party-socket";
|
|
||||||
import { useUser } from "./user";
|
|
||||||
|
|
||||||
function reducePartyState(
|
|
||||||
state: PartyState,
|
|
||||||
event: PartySocketEvent,
|
|
||||||
): PartyState {
|
|
||||||
switch (event.type) {
|
|
||||||
case "snapshot":
|
|
||||||
case "party_status":
|
|
||||||
return { party: event.party, members: event.members };
|
|
||||||
case "member_payload":
|
|
||||||
case "pong":
|
|
||||||
case "error":
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getApiUrl(): string | null {
|
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
const envUrl = import.meta.env.VITE_BETTER_AUTH_URL;
|
|
||||||
if (envUrl) return envUrl;
|
|
||||||
return `${window.location.protocol}//${window.location.host}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useParty() {
|
|
||||||
const { session } = useUser();
|
|
||||||
const [state, setState] = useState<PartyState>({
|
|
||||||
party: null,
|
|
||||||
members: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleMessage = useCallback((event: PartySocketEvent) => {
|
|
||||||
setState((prev: PartyState) => reducePartyState(prev, event));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const apiUrl = useMemo(() => {
|
|
||||||
const url = getApiUrl();
|
|
||||||
if (!url) return null;
|
|
||||||
return url;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const wsState = usePartySocket({
|
|
||||||
apiUrl,
|
|
||||||
onMessage: session ? handleMessage : null,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
...wsState,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -20,17 +20,7 @@ const config = defineConfig({
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": "http://localhost:4000",
|
||||||
target: "http://localhost:4000",
|
|
||||||
changeOrigin: true,
|
|
||||||
rewrite: (path) =>
|
|
||||||
path.replace(/^\/api/, "/api"),
|
|
||||||
},
|
|
||||||
"/api/party-socket/ws": {
|
|
||||||
target: "ws://localhost:4000",
|
|
||||||
ws: true,
|
|
||||||
rewriteWsOrigin: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue