Files
zep/spacetimedb/src/index.ts
T
2026-03-28 22:05:24 -04:00

431 lines
12 KiB
TypeScript

import { schema, t, table, SenderError } from 'spacetimedb/server';
const channel_kind = t.enum('ChannelKind', { text: t.unit(), voice: t.unit() });
const user = table(
{
name: 'user',
public: true,
},
{
identity: t.identity().primaryKey(),
name: t.string().optional(),
online: t.bool(),
issuer: t.string().optional(),
subject: t.string().optional(),
username: t.string().optional(), // For creds-based auth
password: t.string().optional(), // For creds-based auth (Note: plain text for MVP)
}
);
const server = table(
{ name: 'server', public: true },
{
id: t.u64().primaryKey().autoInc(),
name: t.string(),
owner: t.identity().optional(),
}
);
const channel = table(
{
name: 'channel',
public: true,
indexes: [
{ accessor: 'by_server_id', algorithm: 'btree', columns: ['server_id'] }
]
},
{
id: t.u64().primaryKey().autoInc(),
server_id: t.u64(),
name: t.string(),
kind: channel_kind,
}
);
const voice_state = table(
{
name: 'voice_state',
public: true,
indexes: [
{ accessor: 'by_channel_id', algorithm: 'btree', columns: ['channel_id'] }
]
},
{
identity: t.identity().primaryKey(),
channel_id: t.u64(),
}
);
const sdp_offer = table(
{
name: 'sdp_offer',
public: true,
indexes: [
{ accessor: 'by_receiver', algorithm: 'btree', columns: ['receiver'] }
]
},
{
sender: t.identity(),
receiver: t.identity(),
sdp: t.string(),
channel_id: t.u64(),
}
);
const sdp_answer = table(
{
name: 'sdp_answer',
public: true,
indexes: [
{ accessor: 'by_receiver', algorithm: 'btree', columns: ['receiver'] }
]
},
{
sender: t.identity(),
receiver: t.identity(),
sdp: t.string(),
channel_id: t.u64(),
}
);
const ice_candidate = table(
{
name: 'ice_candidate',
public: true,
indexes: [
{ accessor: 'by_receiver', algorithm: 'btree', columns: ['receiver'] }
]
},
{
sender: t.identity(),
receiver: t.identity(),
candidate: t.string(),
channel_id: t.u64(),
}
);
const thread = table(
{
name: 'thread',
public: true,
indexes: [
{ accessor: 'by_channel_id', algorithm: 'btree', columns: ['channel_id'] }
]
},
{
id: t.u64().primaryKey().autoInc(),
channel_id: t.u64(),
parent_message_id: t.u64().unique(),
name: t.string(),
}
);
const message = table(
{
name: 'message',
public: true,
indexes: [
{ accessor: 'by_channel_id', algorithm: 'btree', columns: ['channel_id'] },
{ accessor: 'by_thread_id', algorithm: 'btree', columns: ['thread_id'] }
]
},
{
id: t.u64().primaryKey().autoInc(),
sender: t.identity(),
sent: t.timestamp(),
text: t.string(),
channel_id: t.u64(),
thread_id: t.u64().optional(),
}
);
const spacetimedb = schema({ user, server, channel, voice_state, sdp_offer, sdp_answer, ice_candidate, thread, message });
export default spacetimedb;
function validateName(name: string) {
if (!name || name.trim().length === 0) throw new SenderError('Names must not be empty');
}
export const set_name = spacetimedb.reducer(
{ name: t.string() },
(ctx, { name }) => {
validateName(name);
const user = ctx.db.user.identity.find(ctx.sender);
if (!user) throw new SenderError('Cannot set name for unknown user');
ctx.db.user.identity.update({ ...user, name });
}
);
export const register = spacetimedb.reducer(
{ username: t.string(), password: t.string() },
(ctx, { username, password }) => {
validateName(username);
if (!password || password.length < 4) throw new SenderError('Password must be at least 4 characters');
for (const u of ctx.db.user.iter()) {
if (u.username === username) {
throw new SenderError('Username already taken');
}
}
const user = ctx.db.user.identity.find(ctx.sender);
if (user) {
ctx.db.user.identity.update({
...user,
username,
password,
name: user.name || username
});
} else {
ctx.db.user.insert({
identity: ctx.sender,
username,
password,
name: username,
online: true,
issuer: undefined,
subject: undefined
});
}
}
);
export const login = spacetimedb.reducer(
{ username: t.string(), password: t.string() },
(ctx, { username, password }) => {
let foundUser = null;
for (const u of ctx.db.user.iter()) {
if (u.username === username && u.password === password) {
foundUser = u;
break;
}
}
if (!foundUser) {
throw new SenderError('Invalid username or password');
}
const currentIdentityUser = ctx.db.user.identity.find(ctx.sender);
if (currentIdentityUser && currentIdentityUser.identity.toHexString() !== foundUser.identity.toHexString()) {
ctx.db.user.identity.delete(ctx.sender);
}
if (foundUser.identity.toHexString() !== ctx.sender.toHexString()) {
ctx.db.user.identity.delete(foundUser.identity);
ctx.db.user.insert({
...foundUser,
identity: ctx.sender,
online: true
});
} else {
ctx.db.user.identity.update({
...foundUser,
online: true
});
}
}
);
export const create_server = spacetimedb.reducer(
{ name: t.string() },
(ctx, { name }) => {
validateName(name);
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || (!user.username && !user.subject)) {
throw new SenderError('You must be logged in to create a server');
}
const s = ctx.db.server.insert({ id: 0n, name, owner: ctx.sender });
ctx.db.channel.insert({ id: 0n, server_id: s.id, name: 'general', kind: { tag: 'text' } });
ctx.db.channel.insert({ id: 0n, server_id: s.id, name: 'Voice General', kind: { tag: 'voice' } });
}
);
export const create_channel = spacetimedb.reducer(
{ name: t.string(), serverId: t.u64(), isVoice: t.bool() },
(ctx, { name, serverId, isVoice }) => {
validateName(name);
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || (!user.username && !user.subject)) {
throw new SenderError('You must be logged in to create a channel');
}
const s = ctx.db.server.id.find(serverId);
if (!s) throw new SenderError('Server not found');
ctx.db.channel.insert({
id: 0n,
server_id: serverId,
name,
kind: isVoice ? { tag: 'voice' } : { tag: 'text' }
});
}
);
export const join_voice = spacetimedb.reducer(
{ channelId: t.u64() },
(ctx, { channelId }) => {
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || (!user.username && !user.subject)) {
throw new SenderError('You must be logged in to join voice');
}
const chan = ctx.db.channel.id.find(channelId);
if (!chan || chan.kind.tag !== 'voice') throw new SenderError('Invalid voice channel');
const existing = ctx.db.voice_state.identity.find(ctx.sender);
if (existing) {
if (existing.channel_id !== channelId) {
clearSignalingForUser(ctx, ctx.sender);
ctx.db.voice_state.identity.update({ identity: ctx.sender, channel_id: channelId });
}
} else {
ctx.db.voice_state.insert({ identity: ctx.sender, channel_id: channelId });
}
}
);
export const leave_voice = spacetimedb.reducer((ctx) => {
ctx.db.voice_state.identity.delete(ctx.sender);
clearSignalingForUser(ctx, ctx.sender);
});
export const send_sdp_offer = spacetimedb.reducer(
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
(ctx, { receiver, sdp, channelId }) => {
ctx.db.sdp_offer.insert({ sender: ctx.sender, receiver, sdp, channel_id: channelId });
}
);
export const send_sdp_answer = spacetimedb.reducer(
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
(ctx, { receiver, sdp, channelId }) => {
ctx.db.sdp_answer.insert({ sender: ctx.sender, receiver, sdp, channel_id: channelId });
}
);
export const send_ice_candidate = spacetimedb.reducer(
{ receiver: t.identity(), candidate: t.string(), channelId: t.u64() },
(ctx, { receiver, candidate, channelId }) => {
ctx.db.ice_candidate.insert({ sender: ctx.sender, receiver, candidate, channel_id: channelId });
}
);
function clearSignalingForUser(ctx: any, identity: any) {
// Clean up stale signaling messages for the user
// Note: Iterating and deleting might not be the most performant for large tables.
// In a production scenario, consider TTLs or more targeted deletion strategies.
const userOffers = ctx.db.sdp_offer.iter().filter((offer: any) =>
offer.sender.isEqual(identity) || offer.receiver.isEqual(identity)
);
for (const offer of userOffers) {
ctx.db.sdp_offer.delete(offer.id); // Assuming 'id' is the primary key
}
const userAnswers = ctx.db.sdp_answer.iter().filter((answer: any) =>
answer.sender.isEqual(identity) || answer.receiver.isEqual(identity)
);
for (const answer of userAnswers) {
ctx.db.sdp_answer.delete(answer.id); // Assuming 'id' is the primary key
}
const userCandidates = ctx.db.ice_candidate.iter().filter((candidate: any) =>
candidate.sender.isEqual(identity) || candidate.receiver.isEqual(identity)
);
for (const candidate of userCandidates) {
ctx.db.ice_candidate.delete(candidate.id); // Assuming 'id' is the primary key
}
}
export const create_thread = spacetimedb.reducer(
{ name: t.string(), channelId: t.u64(), parentMessageId: t.u64() },
(ctx, { name, channelId, parentMessageId }) => {
validateName(name);
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || (!user.username && !user.subject)) {
throw new SenderError('You must be logged in to create a thread');
}
const parentMsg = ctx.db.message.id.find(parentMessageId);
if (!parentMsg) throw new SenderError('Parent message not found');
ctx.db.thread.insert({ id: 0n, channel_id: channelId, parent_message_id: parentMessageId, name });
}
);
export const send_message = spacetimedb.reducer(
{ text: t.string(), channelId: t.u64(), threadId: t.u64().optional() },
(ctx, { text, channelId, threadId }) => {
if (!text || text.trim().length === 0) throw new SenderError('Messages must not be empty');
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || (!user.username && !user.subject)) {
throw new SenderError('You must be logged in to send messages');
}
ctx.db.message.insert({
id: 0n,
sender: ctx.sender,
text,
sent: ctx.timestamp,
channel_id: channelId,
thread_id: threadId,
});
}
);
export const init = spacetimedb.init(ctx => {
let hasServers = false;
for (const _ of ctx.db.server.iter()) {
hasServers = true;
break;
}
if (!hasServers) {
const s = ctx.db.server.insert({ id: 0n, name: 'Spacetime Community', owner: undefined });
ctx.db.channel.insert({ id: 0n, server_id: s.id, name: 'general', kind: { tag: 'text' } });
ctx.db.channel.insert({ id: 0n, server_id: s.id, name: 'Voice General', kind: { tag: 'voice' } });
}
});
export const onConnect = spacetimedb.clientConnected(ctx => {
const user = ctx.db.user.identity.find(ctx.sender);
if (ctx.senderAuth.hasJWT && ctx.senderAuth.jwt) {
const jwt = ctx.senderAuth.jwt;
const issuer = jwt.issuer;
const subject = jwt.subject;
const payload = jwt.fullPayload;
const name = (payload.name as string) || (payload.nickname as string) || (payload.preferred_username as string) || (payload.email as string);
if (user) {
ctx.db.user.identity.update({
...user,
online: true,
name: user.name || name,
issuer,
subject
});
} else {
ctx.db.user.insert({
name,
identity: ctx.sender,
online: true,
issuer,
subject,
username: undefined,
password: undefined
});
}
} else if (user) {
ctx.db.user.identity.update({ ...user, online: true });
}
});
export const onDisconnect = spacetimedb.clientDisconnected(ctx => {
const user = ctx.db.user.identity.find(ctx.sender);
if (user) {
ctx.db.user.identity.update({ ...user, online: false });
}
// Auto-leave voice on disconnect
ctx.db.voice_state.identity.delete(ctx.sender);
// Clean up signaling messages associated with the disconnected user
clearSignalingForUser(ctx, ctx.sender);
});