638 lines
17 KiB
TypeScript
638 lines
17 KiB
TypeScript
import { z } from "zod";
|
|
|
|
import { isCanonicalVideoCodecEvidence } from "./video-codec-evidence.js";
|
|
|
|
export const MAX_VIEWERS_PER_ROOM_LIMIT = 16;
|
|
export const MAX_SIGNAL_BYTES = 64 * 1024;
|
|
export const SIGNALING_PROTOCOL = "screener-v2";
|
|
export const ROOM_CODE_LENGTH = 12;
|
|
export const MAX_MEDIA_ROUTE_REVISION = Number.MAX_SAFE_INTEGER;
|
|
export const MAX_SFU_TOKEN_LENGTH = 8 * 1024;
|
|
export const MAX_ICE_SERVER_URLS = 8;
|
|
export const MAX_VIEWER_QUALITY_EVIDENCE_BYTES = 2 * 1024;
|
|
export const MAX_PARENT_EDGE_QUALITY_EVIDENCE_BYTES = 2 * 1024;
|
|
export const VIEWER_QUALITY_EVIDENCE_INTERVAL_MS = 2_000;
|
|
export const VIEWER_QUALITY_EVIDENCE_EXPIRY_MS = 5_000;
|
|
|
|
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_-]+$/);
|
|
|
|
export const viewerGrantSchema = z
|
|
.string()
|
|
.min(50)
|
|
.max(96)
|
|
.regex(/^g1\.[1-9]\d{0,11}\.[1-9]\d{0,12}\.[A-Za-z0-9_-]{43}$/);
|
|
|
|
export const viewerAccessPolicySchema = z.enum([
|
|
"private-link",
|
|
"public-watch",
|
|
]);
|
|
export type ViewerAccessPolicy = z.infer<typeof viewerAccessPolicySchema>;
|
|
|
|
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>;
|
|
|
|
function isValidStunUrl(value: string): boolean {
|
|
const schemeSeparator = value.indexOf(":");
|
|
if (
|
|
schemeSeparator <= 0 ||
|
|
value.slice(0, schemeSeparator).toLowerCase() !== "stun"
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const authorityText = value.slice(schemeSeparator + 1);
|
|
if (
|
|
!authorityText ||
|
|
/[\\/\s?#]/.test(authorityText) ||
|
|
authorityText.endsWith(":")
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
let authority: URL;
|
|
try {
|
|
authority = new URL(`http://${authorityText}`);
|
|
} catch {
|
|
return false;
|
|
}
|
|
return Boolean(
|
|
authority.hostname &&
|
|
!authority.username &&
|
|
!authority.password &&
|
|
authority.pathname === "/" &&
|
|
!authority.search &&
|
|
!authority.hash &&
|
|
(!authority.port || Number(authority.port) > 0),
|
|
);
|
|
}
|
|
|
|
export const stunUrlSchema = z
|
|
.string()
|
|
.min(1)
|
|
.max(512)
|
|
.refine(isValidStunUrl, { message: "Invalid STUN URL" });
|
|
|
|
const iceServerSchema = z
|
|
.object({
|
|
urls: z.union([
|
|
stunUrlSchema,
|
|
z.array(stunUrlSchema).min(1).max(MAX_ICE_SERVER_URLS),
|
|
]),
|
|
})
|
|
.strict();
|
|
|
|
export const iceConfigSchema = z
|
|
.object({
|
|
iceServers: z.array(iceServerSchema).max(8),
|
|
})
|
|
.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 nullableEvidenceNumber = (maximum: number) =>
|
|
z.number().finite().min(0).max(maximum).nullable();
|
|
|
|
const nullableEvidenceInteger = (maximum: number) =>
|
|
z.number().int().min(0).max(maximum).nullable();
|
|
|
|
export const viewerQualityEvidenceMetricsSchema = z
|
|
.object({
|
|
width: z.number().int().min(1).max(16_384).nullable(),
|
|
height: z.number().int().min(1).max(16_384).nullable(),
|
|
framesPerSecond: nullableEvidenceNumber(240),
|
|
bitrateKbps: nullableEvidenceNumber(100_000),
|
|
packetsReceivedDelta: nullableEvidenceInteger(1_000_000),
|
|
packetsLostDelta: nullableEvidenceInteger(1_000_000),
|
|
jitterMs: nullableEvidenceNumber(60_000),
|
|
framesDecodedDelta: nullableEvidenceInteger(10_000),
|
|
framesDroppedDelta: nullableEvidenceInteger(10_000),
|
|
decodeMsPerFrame: nullableEvidenceNumber(60_000),
|
|
freezeCountDelta: nullableEvidenceInteger(10_000),
|
|
freezeDurationMsDelta: nullableEvidenceNumber(5_000),
|
|
codec: z
|
|
.string()
|
|
.max(64)
|
|
.regex(/^video\/[A-Za-z0-9.+-]{1,32}$/i)
|
|
.nullable(),
|
|
codecProfile: z
|
|
.string()
|
|
.max(64)
|
|
.regex(/^[a-z0-9-]+=[a-z0-9]+$/)
|
|
.nullable(),
|
|
codecParameters: z
|
|
.string()
|
|
.max(128)
|
|
.regex(/^[a-z0-9-]+=[a-z0-9]+(?:; [a-z0-9-]+=[a-z0-9]+)*$/)
|
|
.nullable(),
|
|
})
|
|
.strict()
|
|
.refine(
|
|
(metrics) =>
|
|
(metrics.width === null && metrics.height === null) ||
|
|
(metrics.width !== null && metrics.height !== null),
|
|
{ message: "Viewer quality dimensions must be present together" },
|
|
)
|
|
.refine(isCanonicalVideoCodecEvidence, {
|
|
message: "Viewer codec evidence is not canonical",
|
|
path: ["codecParameters"],
|
|
});
|
|
export type ViewerQualityEvidenceMetrics = z.infer<
|
|
typeof viewerQualityEvidenceMetricsSchema
|
|
>;
|
|
|
|
const viewerQualityEvidenceGuardSchema = z
|
|
.object({
|
|
connectionId: opaqueIdSchema,
|
|
routeRevision: mediaRouteRevisionSchema,
|
|
})
|
|
.strict();
|
|
|
|
const viewerQualityEvidenceWindowShape = {
|
|
sequence: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
|
|
windowMs: z.number().int().min(1_000).max(5_000),
|
|
metrics: viewerQualityEvidenceMetricsSchema,
|
|
};
|
|
|
|
function freezeFitsEvidenceWindow(value: {
|
|
windowMs: number;
|
|
metrics: ViewerQualityEvidenceMetrics;
|
|
}): boolean {
|
|
return (
|
|
value.metrics.freezeDurationMsDelta === null ||
|
|
value.metrics.freezeDurationMsDelta <= value.windowMs
|
|
);
|
|
}
|
|
|
|
export const viewerQualityEvidenceMessageSchema = z
|
|
.object({
|
|
type: z.literal("viewer-quality-evidence"),
|
|
guard: viewerQualityEvidenceGuardSchema,
|
|
...viewerQualityEvidenceWindowShape,
|
|
})
|
|
.strict()
|
|
.refine(freezeFitsEvidenceWindow, {
|
|
message: "Viewer freeze duration exceeds its evidence window",
|
|
});
|
|
export type ViewerQualityEvidenceMessage = z.infer<
|
|
typeof viewerQualityEvidenceMessageSchema
|
|
>;
|
|
|
|
const parentEdgeQualityProofSchema = z.discriminatedUnion("kind", [
|
|
z
|
|
.object({
|
|
kind: z.literal("sending"),
|
|
packetsSentDelta: z.number().int().min(1).max(1_000_000),
|
|
})
|
|
.strict(),
|
|
z
|
|
.object({
|
|
kind: z.literal("sender-limited"),
|
|
packetsSentDelta: z.number().int().min(1).max(1_000_000),
|
|
reason: z.enum(["cpu", "bandwidth"]),
|
|
})
|
|
.strict(),
|
|
z
|
|
.object({
|
|
kind: z.literal("remote-loss"),
|
|
packetsSentDelta: z.number().int().min(1).max(1_000_000),
|
|
remotePacketsLostDelta: z.number().int().min(1).max(1_000_000),
|
|
})
|
|
.strict(),
|
|
]);
|
|
export type ParentEdgeQualityProof = z.infer<
|
|
typeof parentEdgeQualityProofSchema
|
|
>;
|
|
|
|
export const parentEdgeQualityEvidenceMessageSchema = z
|
|
.object({
|
|
type: z.literal("parent-edge-quality-evidence"),
|
|
viewerPeerId: opaqueIdSchema,
|
|
guard: viewerQualityEvidenceGuardSchema,
|
|
viewerSequence: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
|
|
proof: parentEdgeQualityProofSchema,
|
|
})
|
|
.strict();
|
|
export type ParentEdgeQualityEvidenceMessage = z.infer<
|
|
typeof parentEdgeQualityEvidenceMessageSchema
|
|
>;
|
|
|
|
const authenticateMessageSchema = z.discriminatedUnion("role", [
|
|
z
|
|
.object({
|
|
type: z.literal("authenticate"),
|
|
protocol: z.literal(SIGNALING_PROTOCOL),
|
|
roomId: roomCodeSchema,
|
|
role: z.literal("host"),
|
|
token: tokenSchema,
|
|
clientId: opaqueIdSchema,
|
|
shareGeneration: opaqueIdSchema.optional(),
|
|
})
|
|
.strict(),
|
|
z
|
|
.object({
|
|
type: z.literal("authenticate"),
|
|
protocol: z.literal(SIGNALING_PROTOCOL),
|
|
roomId: roomCodeSchema,
|
|
role: z.literal("viewer"),
|
|
clientId: opaqueIdSchema,
|
|
viewerGrant: viewerGrantSchema.optional(),
|
|
})
|
|
.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("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(),
|
|
viewerQualityEvidenceMessageSchema,
|
|
parentEdgeQualityEvidenceMessageSchema,
|
|
z
|
|
.object({
|
|
type: z.literal("set-viewer-access"),
|
|
action: z.enum(["public-watch", "rotate", "revoke"]),
|
|
})
|
|
.strict(),
|
|
z
|
|
.object({
|
|
type: z.literal("stop-sharing"),
|
|
shareGeneration: opaqueIdSchema.optional(),
|
|
})
|
|
.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"),
|
|
protocol: z.literal(SIGNALING_PROTOCOL),
|
|
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,
|
|
viewerPolicy: viewerAccessPolicySchema,
|
|
viewerAuthorizationGeneration: opaqueIdSchema,
|
|
};
|
|
|
|
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("viewer-quality-evidence"),
|
|
viewerPeerId: opaqueIdSchema,
|
|
parentPeerId: opaqueIdSchema,
|
|
guard: viewerQualityEvidenceGuardSchema,
|
|
...viewerQualityEvidenceWindowShape,
|
|
})
|
|
.strict()
|
|
.refine(freezeFitsEvidenceWindow, {
|
|
message: "Viewer freeze duration exceeds its evidence window",
|
|
}),
|
|
z
|
|
.object({
|
|
type: z.literal("host-status"),
|
|
online: z.boolean(),
|
|
})
|
|
.strict(),
|
|
z
|
|
.object({
|
|
type: z.literal("viewer-access-updated"),
|
|
viewerPolicy: viewerAccessPolicySchema,
|
|
viewerAuthorizationGeneration: opaqueIdSchema,
|
|
inviteUrl: z.string().url().max(2048).nullable(),
|
|
viewerGrantExpiresAt: z.string().datetime().nullable(),
|
|
})
|
|
.strict(),
|
|
z
|
|
.object({
|
|
type: z.literal("viewer-access-revoked"),
|
|
viewerAuthorizationGeneration: opaqueIdSchema,
|
|
})
|
|
.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),
|
|
viewerPolicy: viewerAccessPolicySchema,
|
|
viewerGrantExpiresAt: z.string().datetime().nullable(),
|
|
expiresAt: z.string().datetime().nullable(),
|
|
})
|
|
.strict();
|
|
export type CreateRoomResponse = z.infer<typeof createRoomResponseSchema>;
|
|
|
|
export const MAX_HOST_CLAIM_TTL_SECONDS = 5 * 60;
|
|
|
|
export const createRoomRequestSchema = z
|
|
.object({
|
|
viewerPolicy: viewerAccessPolicySchema,
|
|
hostClaimTtlSeconds: z.literal(MAX_HOST_CLAIM_TTL_SECONDS).optional(),
|
|
})
|
|
.strict();
|
|
export type CreateRoomRequest = z.infer<typeof createRoomRequestSchema>;
|
|
|
|
export function decodeClientMessage(value: string): ClientMessage {
|
|
return clientMessageSchema.parse(JSON.parse(value));
|
|
}
|
|
|
|
export function decodeServerMessage(value: string): ServerMessage {
|
|
return serverMessageSchema.parse(JSON.parse(value));
|
|
}
|