Files
Piik/src/shared/protocol.ts
T
AAA a75c755d7c chore: merge main into spike/hybrid-warm-fallback
# Conflicts:
#	docs/README.md
#	docs/project-memory.md
#	docs/research/low-server-media-routes.md
#	docs/status.md
#	src/client/pages/HostPage.tsx
#	src/server/signaling.ts
#	src/shared/protocol.ts
#	tests/protocol.test.ts
#	tests/server-signal.test.ts
2026-08-19 15:06:43 +08:00

410 lines
10 KiB
TypeScript

import { z } from "zod";
export const MAX_VIEWERS_PER_ROOM_LIMIT = 16;
export const MAX_SIGNAL_BYTES = 64 * 1024;
export const ROOM_CODE_LENGTH = 12;
export const MAX_MEDIA_ROUTE_REVISION = Number.MAX_SAFE_INTEGER;
export const MAX_SFU_TOKEN_LENGTH = 8 * 1024;
const opaqueIdSchema = z
.string()
.min(8)
.max(128)
.regex(/^[A-Za-z0-9_-]+$/);
const tokenSchema = z
.string()
.min(32)
.max(128)
.regex(/^[A-Za-z0-9_-]+$/);
const liveKitWebSocketUrlSchema = z
.string()
.url()
.max(2048)
.refine((value) => {
try {
const protocol = new URL(value).protocol;
return protocol === "ws:" || protocol === "wss:";
} catch {
return false;
}
});
export const roomCodeSchema = z
.string()
.min(1)
.max(ROOM_CODE_LENGTH)
.regex(/^[1-9]\d*$/);
export const roleSchema = z.enum(["host", "viewer"]);
export type Role = z.infer<typeof roleSchema>;
export const qualityProfileIdSchema = z.enum([
"1080p60",
"1080p30",
"720p30",
]);
export type QualityProfileId = z.infer<typeof qualityProfileIdSchema>;
export const DEFAULT_QUALITY_PROFILE_ID: QualityProfileId = "1080p60";
export const qualityResolutionSchema = z.enum(["720p", "1080p", "1440p"]);
export type QualityResolution = z.infer<typeof qualityResolutionSchema>;
export const degradationPreferenceSchema = z.enum([
"maintain-resolution",
"balanced",
"maintain-framerate",
]);
export type DegradationPreference = z.infer<
typeof degradationPreferenceSchema
>;
export const qualitySettingsSchema = z
.object({
resolution: qualityResolutionSchema,
maxFramerate: z.number().int().min(15).max(60),
maxBitrate: z.number().int().min(2_000_000).max(12_000_000),
degradationPreference: degradationPreferenceSchema,
})
.strict();
export type QualitySettings = z.infer<typeof qualitySettingsSchema>;
export const DEFAULT_QUALITY_SETTINGS = {
resolution: "1080p",
maxFramerate: 60,
maxBitrate: 8_000_000,
degradationPreference: "maintain-resolution",
} as const satisfies QualitySettings;
export const relayDownstreamEdgesSchema = z.union([
z.literal(0),
z.literal(1),
]);
export type RelayDownstreamEdges = z.infer<typeof relayDownstreamEdgesSchema>;
const iceServerSchema = z
.object({
urls: z.union([
z.string().min(1).max(512),
z.array(z.string().min(1).max(512)).min(1).max(8),
]),
username: z.string().max(512).optional(),
credential: z.string().max(512).optional(),
})
.strict();
export const iceConfigSchema = z
.object({
iceServers: z.array(iceServerSchema).max(8),
expiresAt: z.string().datetime().nullable(),
relayAvailable: z.boolean(),
})
.strict();
export type IceConfig = z.infer<typeof iceConfigSchema>;
const sessionDescriptionSchema = z
.object({
type: z.enum(["offer", "answer"]),
sdp: z.string().min(1).max(48 * 1024),
})
.strict();
const iceCandidateSchema = z
.object({
candidate: z.string().max(4096),
sdpMid: z.string().max(128).nullable().optional(),
sdpMLineIndex: z.number().int().min(0).max(255).nullable().optional(),
usernameFragment: z.string().max(256).nullable().optional(),
})
.strict();
export const signalPayloadSchema = z.discriminatedUnion("kind", [
z
.object({
kind: z.literal("description"),
connectionId: opaqueIdSchema,
description: sessionDescriptionSchema,
})
.strict(),
z
.object({
kind: z.literal("candidate"),
connectionId: opaqueIdSchema,
candidate: iceCandidateSchema.nullable(),
})
.strict(),
]);
export type SignalPayload = z.infer<typeof signalPayloadSchema>;
export const mediaAssignmentSchema = z
.object({
parentPeerId: opaqueIdSchema.nullable(),
childPeerIds: z.array(opaqueIdSchema).max(2),
})
.strict();
export type MediaAssignment = z.infer<typeof mediaAssignmentSchema>;
export const mediaRouteRevisionSchema = z
.number()
.int()
.min(0)
.max(MAX_MEDIA_ROUTE_REVISION);
export const mediaRoutePhaseSchema = z.enum(["prepare", "active"]);
export type MediaRoutePhase = z.infer<typeof mediaRoutePhaseSchema>;
export const mediaRouteUpstreamSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("none") }).strict(),
z
.object({
kind: z.literal("peer"),
peerId: opaqueIdSchema,
})
.strict(),
z.object({ kind: z.literal("sfu") }).strict(),
]);
export type MediaRouteUpstream = z.infer<typeof mediaRouteUpstreamSchema>;
export const sfuPublicationGenerationSchema = opaqueIdSchema;
export const participantRouteAssignmentSchema = z
.object({
upstream: mediaRouteUpstreamSchema,
childPeerIds: z
.array(opaqueIdSchema)
.max(2)
.refine((peerIds) => new Set(peerIds).size === peerIds.length),
sfuPublicationGeneration: sfuPublicationGenerationSchema.nullable(),
})
.strict();
export type ParticipantRouteAssignment = z.infer<
typeof participantRouteAssignmentSchema
>;
const authenticateMessageSchema = z.discriminatedUnion("role", [
z
.object({
type: z.literal("authenticate"),
roomId: roomCodeSchema,
role: z.literal("host"),
token: tokenSchema,
clientId: opaqueIdSchema,
shareGeneration: opaqueIdSchema.optional(),
})
.strict(),
z
.object({
type: z.literal("authenticate"),
roomId: roomCodeSchema,
role: z.literal("viewer"),
clientId: opaqueIdSchema,
})
.strict(),
]);
export const clientMessageSchema = z.union([
authenticateMessageSchema,
z
.object({
type: z.literal("signal"),
targetPeerId: opaqueIdSchema.optional(),
payload: signalPayloadSchema,
})
.strict(),
z
.object({
type: z.literal("restart-request"),
targetPeerId: opaqueIdSchema.optional(),
connectionId: opaqueIdSchema,
rebuild: z.boolean(),
})
.strict(),
z.object({ type: z.literal("refresh-ice") }).strict(),
z
.object({
type: z.literal("set-quality-settings"),
qualitySettings: qualitySettingsSchema,
})
.strict(),
z
.object({
type: z.literal("relay-capacity"),
downstreamEdges: relayDownstreamEdgesSchema,
})
.strict(),
z
.object({
type: z.literal("route-ready"),
revision: mediaRouteRevisionSchema,
phase: mediaRoutePhaseSchema,
})
.strict(),
z
.object({
type: z.literal("route-failed"),
revision: mediaRouteRevisionSchema,
phase: mediaRoutePhaseSchema,
connectionId: opaqueIdSchema.nullable(),
})
.strict(),
z
.object({
type: z.literal("refresh-sfu"),
revision: mediaRouteRevisionSchema,
})
.strict(),
z
.object({
type: z.literal("stop-sharing"),
shareGeneration: opaqueIdSchema.optional(),
})
.strict(),
// Kept as a compatibility alias while previously deployed clients age out.
z.object({ type: z.literal("close-room") }).strict(),
z.object({ type: z.literal("abandon-room") }).strict(),
]);
export type ClientMessage = z.infer<typeof clientMessageSchema>;
const errorCodeSchema = z.enum([
"AUTH_REQUIRED",
"INVALID_MESSAGE",
"INVALID_TOKEN",
"ROOM_EXPIRED",
"ROOM_FULL",
"HOST_ALREADY_CONNECTED",
"PEER_NOT_FOUND",
"FORBIDDEN",
"SERVER_ERROR",
]);
const authenticatedMessageShape = {
type: z.literal("authenticated"),
role: roleSchema,
peerId: opaqueIdSchema,
roomExpiresAt: z.string().datetime().nullable(),
maxViewers: z.number().int().min(1).max(MAX_VIEWERS_PER_ROOM_LIMIT),
hostOnline: z.boolean(),
connectionId: opaqueIdSchema.nullable(),
viewerPeerIds: z.array(opaqueIdSchema).max(MAX_VIEWERS_PER_ROOM_LIMIT),
iceConfig: iceConfigSchema,
};
const authenticatedMessageSchema = z.union([
z.object(authenticatedMessageShape).strict(),
z
.object({
...authenticatedMessageShape,
mediaMode: z.literal("peer-assisted"),
mediaAssignment: mediaAssignmentSchema,
routeRevision: mediaRouteRevisionSchema,
routeAssignment: participantRouteAssignmentSchema,
qualitySettings: qualitySettingsSchema,
sfuStandbyUrl: liveKitWebSocketUrlSchema.optional(),
})
.strict(),
]);
export const serverMessageSchema = z.union([
authenticatedMessageSchema,
z
.object({
type: z.literal("peer-joined"),
peerId: opaqueIdSchema,
})
.strict(),
z
.object({
type: z.literal("peer-left"),
peerId: opaqueIdSchema,
})
.strict(),
z
.object({
type: z.literal("signal"),
fromPeerId: opaqueIdSchema,
payload: signalPayloadSchema,
})
.strict(),
z
.object({
type: z.literal("restart-request"),
fromPeerId: opaqueIdSchema,
connectionId: opaqueIdSchema,
rebuild: z.boolean(),
})
.strict(),
z
.object({
type: z.literal("media-assignment"),
mediaAssignment: mediaAssignmentSchema,
})
.strict(),
z
.object({
type: z.literal("route-update"),
revision: mediaRouteRevisionSchema,
phase: mediaRoutePhaseSchema,
assignment: participantRouteAssignmentSchema,
})
.strict(),
z
.object({
type: z.literal("sfu-config"),
revision: mediaRouteRevisionSchema,
url: liveKitWebSocketUrlSchema,
token: z.string().min(1).max(MAX_SFU_TOKEN_LENGTH),
})
.strict(),
z
.object({
type: z.literal("quality-settings"),
qualitySettings: qualitySettingsSchema,
})
.strict(),
z
.object({
type: z.literal("ice-config"),
iceConfig: iceConfigSchema,
})
.strict(),
z
.object({
type: z.literal("host-status"),
online: z.boolean(),
})
.strict(),
z.object({ type: z.literal("sharing-stopped") }).strict(),
z
.object({
type: z.literal("room-closed"),
reason: z.enum(["host-ended", "expired"]),
})
.strict(),
z
.object({
type: z.literal("error"),
code: errorCodeSchema,
message: z.string().min(1).max(256),
})
.strict(),
]);
export type ServerMessage = z.infer<typeof serverMessageSchema>;
export const createRoomResponseSchema = z
.object({
roomId: roomCodeSchema,
hostToken: tokenSchema,
inviteUrl: z.string().url().max(2048),
expiresAt: z.string().datetime().nullable(),
})
.strict();
export type CreateRoomResponse = z.infer<typeof createRoomResponseSchema>;
export function decodeClientMessage(value: string): ClientMessage {
return clientMessageSchema.parse(JSON.parse(value));
}
export function decodeServerMessage(value: string): ServerMessage {
return serverMessageSchema.parse(JSON.parse(value));
}