Initial commit
This commit is contained in:
149
client/src/network/protocol.ts
Normal file
149
client/src/network/protocol.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const itemSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(['radio_station', 'dice']),
|
||||
title: z.string(),
|
||||
x: z.number().int(),
|
||||
y: z.number().int(),
|
||||
createdBy: z.string(),
|
||||
createdAt: z.number().int(),
|
||||
updatedAt: z.number().int(),
|
||||
version: z.number().int(),
|
||||
capabilities: z.array(z.string()),
|
||||
useSound: z.string().optional(),
|
||||
params: z.record(z.string(), z.unknown()),
|
||||
carrierId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const welcomeMessageSchema = z.object({
|
||||
type: z.literal('welcome'),
|
||||
id: z.string(),
|
||||
users: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
nickname: z.string(),
|
||||
x: z.number().int(),
|
||||
y: z.number().int(),
|
||||
}),
|
||||
),
|
||||
items: z.array(itemSchema).optional(),
|
||||
});
|
||||
|
||||
export const signalMessageSchema = z.object({
|
||||
type: z.literal('signal'),
|
||||
senderId: z.string(),
|
||||
senderNickname: z.string().optional(),
|
||||
x: z.number().int().optional(),
|
||||
y: z.number().int().optional(),
|
||||
targetId: z.string().optional(),
|
||||
sdp: z.any().optional(),
|
||||
ice: z.any().optional(),
|
||||
});
|
||||
|
||||
export const updatePositionSchema = z.object({
|
||||
type: z.literal('update_position'),
|
||||
id: z.string(),
|
||||
x: z.number().int(),
|
||||
y: z.number().int(),
|
||||
});
|
||||
|
||||
export const updateNicknameSchema = z.object({
|
||||
type: z.literal('update_nickname'),
|
||||
id: z.string(),
|
||||
nickname: z.string().min(1).max(32),
|
||||
});
|
||||
|
||||
export const userLeftSchema = z.object({
|
||||
type: z.literal('user_left'),
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export const chatMessageSchema = z.object({
|
||||
type: z.literal('chat_message'),
|
||||
message: z.string(),
|
||||
senderId: z.string().optional(),
|
||||
senderNickname: z.string().optional(),
|
||||
system: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const pongSchema = z.object({
|
||||
type: z.literal('pong'),
|
||||
clientSentAt: z.number().int(),
|
||||
});
|
||||
|
||||
export const nicknameResultSchema = z.object({
|
||||
type: z.literal('nickname_result'),
|
||||
accepted: z.boolean(),
|
||||
requestedNickname: z.string(),
|
||||
effectiveNickname: z.string(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
export const itemUpsertSchema = z.object({
|
||||
type: z.literal('item_upsert'),
|
||||
item: itemSchema,
|
||||
});
|
||||
|
||||
export const itemRemoveSchema = z.object({
|
||||
type: z.literal('item_remove'),
|
||||
itemId: z.string(),
|
||||
});
|
||||
|
||||
export const itemActionResultSchema = z.object({
|
||||
type: z.literal('item_action_result'),
|
||||
ok: z.boolean(),
|
||||
action: z.enum(['add', 'pickup', 'drop', 'delete', 'use', 'update']),
|
||||
message: z.string(),
|
||||
itemId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const itemUseSoundSchema = z.object({
|
||||
type: z.literal('item_use_sound'),
|
||||
itemId: z.string(),
|
||||
sound: z.string(),
|
||||
x: z.number().int(),
|
||||
y: z.number().int(),
|
||||
});
|
||||
|
||||
export const incomingMessageSchema = z.discriminatedUnion('type', [
|
||||
welcomeMessageSchema,
|
||||
signalMessageSchema,
|
||||
updatePositionSchema,
|
||||
updateNicknameSchema,
|
||||
userLeftSchema,
|
||||
chatMessageSchema,
|
||||
pongSchema,
|
||||
nicknameResultSchema,
|
||||
itemUpsertSchema,
|
||||
itemRemoveSchema,
|
||||
itemActionResultSchema,
|
||||
itemUseSoundSchema,
|
||||
]);
|
||||
|
||||
export type IncomingMessage = z.infer<typeof incomingMessageSchema>;
|
||||
|
||||
export type OutgoingMessage =
|
||||
| { type: 'signal'; targetId: string; sdp?: RTCSessionDescriptionInit; ice?: RTCIceCandidateInit }
|
||||
| { type: 'update_position'; x: number; y: number }
|
||||
| { type: 'update_nickname'; nickname: string }
|
||||
| { type: 'chat_message'; message: string }
|
||||
| { type: 'ping'; clientSentAt: number }
|
||||
| { type: 'item_add'; itemType: 'radio_station' | 'dice' }
|
||||
| { type: 'item_pickup'; itemId: string }
|
||||
| { type: 'item_drop'; itemId: string; x: number; y: number }
|
||||
| { type: 'item_delete'; itemId: string }
|
||||
| { type: 'item_use'; itemId: string }
|
||||
| {
|
||||
type: 'item_update';
|
||||
itemId: string;
|
||||
title?: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type RemoteUser = {
|
||||
id: string;
|
||||
nickname: string;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
76
client/src/network/signalingClient.ts
Normal file
76
client/src/network/signalingClient.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { incomingMessageSchema, type IncomingMessage, type OutgoingMessage } from './protocol';
|
||||
|
||||
type MessageHandler = (message: IncomingMessage) => void | Promise<void>;
|
||||
type StatusHandler = (message: string) => void;
|
||||
|
||||
export class SignalingClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private timeoutId: number | null = null;
|
||||
|
||||
constructor(private readonly url: string, private readonly status: StatusHandler) {}
|
||||
|
||||
async connect(onMessage: MessageHandler): Promise<void> {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
|
||||
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (!this.ws) {
|
||||
reject(new Error('WebSocket unavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.timeoutId = window.setTimeout(() => {
|
||||
this.status('Connection timed out.');
|
||||
this.disconnect();
|
||||
reject(new Error('Connection timed out'));
|
||||
}, 10_000);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.clearTimeout();
|
||||
this.status('Connected.');
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
this.clearTimeout();
|
||||
reject(new Error('WebSocket error'));
|
||||
};
|
||||
|
||||
this.ws.onmessage = async (event) => {
|
||||
const parsed = JSON.parse(String(event.data));
|
||||
const validated = incomingMessageSchema.safeParse(parsed);
|
||||
if (!validated.success) return;
|
||||
await onMessage(validated.data);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.clearTimeout();
|
||||
this.status('Disconnected.');
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
send(payload: OutgoingMessage): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.clearTimeout();
|
||||
if (!this.ws) return;
|
||||
this.ws.onopen = null;
|
||||
this.ws.onmessage = null;
|
||||
this.ws.onclose = null;
|
||||
this.ws.onerror = null;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
private clearTimeout(): void {
|
||||
if (this.timeoutId !== null) {
|
||||
window.clearTimeout(this.timeoutId);
|
||||
this.timeoutId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user