feat(server): add protocol schemas + validation (P4.2)
This commit is contained in:
parent
25695d69b2
commit
817b4d95f3
2 changed files with 838 additions and 0 deletions
505
packages/server/src/protocol.test.ts
Normal file
505
packages/server/src/protocol.test.ts
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
validateMessage,
|
||||
validateMessageString,
|
||||
PROTOCOL_VERSION,
|
||||
ClientMessageSchema,
|
||||
ServerMessageSchema,
|
||||
type AnyMessage,
|
||||
type ClientMessage,
|
||||
type ServerMessage,
|
||||
} from "./protocol.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures — one minimally-valid example per message type.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const UUID = "550e8400-e29b-41d4-a716-446655440000";
|
||||
const UUID2 = "661f9500-f30c-42e5-b827-557766550111";
|
||||
|
||||
const envelope = { v: PROTOCOL_VERSION, seq: 0, ts: 1 } as const;
|
||||
|
||||
const fixtures: Record<string, AnyMessage> = {
|
||||
"room.create": {
|
||||
...envelope,
|
||||
type: "room.create",
|
||||
payload: { rulesetIds: ["pawns-move-backward"] },
|
||||
},
|
||||
"room.create (empty payload)": {
|
||||
...envelope,
|
||||
type: "room.create",
|
||||
payload: {},
|
||||
},
|
||||
"room.join": {
|
||||
...envelope,
|
||||
type: "room.join",
|
||||
payload: { code: "ABC123" },
|
||||
},
|
||||
"room.leave": {
|
||||
...envelope,
|
||||
type: "room.leave",
|
||||
payload: {},
|
||||
},
|
||||
"game.move": {
|
||||
...envelope,
|
||||
type: "game.move",
|
||||
token: UUID,
|
||||
payload: { from: "e2", to: "e4" },
|
||||
},
|
||||
"game.move (promotion)": {
|
||||
...envelope,
|
||||
type: "game.move",
|
||||
token: UUID,
|
||||
payload: { from: "a7", to: "a8", promoteTo: "queen" },
|
||||
},
|
||||
"room.created": {
|
||||
...envelope,
|
||||
seq: 1,
|
||||
type: "room.created",
|
||||
payload: { code: "ABC123", token: UUID, color: "white" },
|
||||
},
|
||||
"room.joined": {
|
||||
...envelope,
|
||||
seq: 1,
|
||||
type: "room.joined",
|
||||
payload: {
|
||||
code: "ABC123",
|
||||
token: UUID2,
|
||||
color: "black",
|
||||
activeRules: ["pawns-move-backward"],
|
||||
},
|
||||
},
|
||||
"game.state": {
|
||||
...envelope,
|
||||
seq: 2,
|
||||
type: "game.state",
|
||||
payload: {
|
||||
facts: [{ id: 1, attr: "PieceType", value: "pawn" }],
|
||||
turn: "white",
|
||||
lastSeq: 42,
|
||||
moveHistory: ["e2-e4"],
|
||||
activeRules: [],
|
||||
fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
|
||||
},
|
||||
},
|
||||
"game.delta (ongoing)": {
|
||||
...envelope,
|
||||
seq: 3,
|
||||
type: "game.delta",
|
||||
payload: {
|
||||
inserted: [{ id: 1, attr: "Position", value: 28 }],
|
||||
retracted: [{ id: 1, attr: "Position", value: 12 }],
|
||||
moveNotation: "e2e4",
|
||||
turn: "black",
|
||||
gameOver: null,
|
||||
},
|
||||
},
|
||||
"game.delta (game over)": {
|
||||
...envelope,
|
||||
seq: 99,
|
||||
type: "game.delta",
|
||||
payload: {
|
||||
inserted: [],
|
||||
retracted: [],
|
||||
moveNotation: "Qh4#",
|
||||
turn: "black",
|
||||
gameOver: { winner: "black", reason: "checkmate" },
|
||||
},
|
||||
},
|
||||
"game.end": {
|
||||
...envelope,
|
||||
seq: 100,
|
||||
type: "game.end",
|
||||
payload: {
|
||||
winner: "white",
|
||||
reason: "checkmate",
|
||||
finalFen: "rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 0 3",
|
||||
},
|
||||
},
|
||||
"error (non-fatal)": {
|
||||
...envelope,
|
||||
seq: 5,
|
||||
type: "error",
|
||||
payload: {
|
||||
code: "ILLEGAL_MOVE",
|
||||
message: "Move e2-e5 is not legal",
|
||||
fatal: false,
|
||||
},
|
||||
},
|
||||
"error (fatal)": {
|
||||
...envelope,
|
||||
seq: 6,
|
||||
type: "error",
|
||||
payload: {
|
||||
code: "VERSION_MISMATCH",
|
||||
message: "expected v=1",
|
||||
fatal: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Round-trip: every fixture must JSON-serialise and re-validate identically.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("validateMessage — round-trip", () => {
|
||||
for (const [name, message] of Object.entries(fixtures)) {
|
||||
it(`round-trips ${name}`, () => {
|
||||
const encoded = JSON.stringify(message);
|
||||
const decoded = JSON.parse(encoded) as unknown;
|
||||
const result = validateMessage(decoded);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(message);
|
||||
expect(result.data.type).toBe(message.type);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("round-trips with an optional token present", () => {
|
||||
const msg = {
|
||||
...envelope,
|
||||
token: UUID,
|
||||
type: "room.leave" as const,
|
||||
payload: {},
|
||||
};
|
||||
const result = validateMessage(JSON.parse(JSON.stringify(msg)));
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.data.token).toBe(UUID);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rejections
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("validateMessage — rejections", () => {
|
||||
it("rejects non-object input (null)", () => {
|
||||
const r = validateMessage(null);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/);
|
||||
});
|
||||
|
||||
it("rejects non-object input (array)", () => {
|
||||
const r = validateMessage([1, 2, 3]);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/);
|
||||
});
|
||||
|
||||
it("rejects non-object input (string)", () => {
|
||||
const r = validateMessage("hello");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/);
|
||||
});
|
||||
|
||||
it("rejects v=2 with VERSION_MISMATCH", () => {
|
||||
const r = validateMessage({
|
||||
v: 2,
|
||||
seq: 0,
|
||||
ts: 1,
|
||||
type: "room.create",
|
||||
payload: {},
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toMatch(/VERSION_MISMATCH/);
|
||||
expect(r.error).toContain("expected v=1");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects missing v with VERSION_MISMATCH (v is undefined)", () => {
|
||||
const r = validateMessage({
|
||||
seq: 0,
|
||||
ts: 1,
|
||||
type: "room.create",
|
||||
payload: {},
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toMatch(/VERSION_MISMATCH/);
|
||||
});
|
||||
|
||||
it("rejects missing type", () => {
|
||||
const r = validateMessage({ v: 1, seq: 0, ts: 1, payload: {} });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/);
|
||||
});
|
||||
|
||||
it("rejects non-string type", () => {
|
||||
const r = validateMessage({
|
||||
v: 1,
|
||||
seq: 0,
|
||||
ts: 1,
|
||||
type: 42,
|
||||
payload: {},
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toMatch(/non-string `type`/);
|
||||
});
|
||||
|
||||
it("rejects unknown message type", () => {
|
||||
const r = validateMessage({
|
||||
v: 1,
|
||||
seq: 0,
|
||||
ts: 1,
|
||||
type: "nope.nope",
|
||||
payload: {},
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toMatch(/unknown message type "nope.nope"/);
|
||||
});
|
||||
|
||||
it("rejects game.move with bad `from` format", () => {
|
||||
const r = validateMessage({
|
||||
...envelope,
|
||||
type: "game.move",
|
||||
payload: { from: "e22", to: "e4" },
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toMatch(/from/);
|
||||
});
|
||||
|
||||
it("rejects game.move with bad `to` file", () => {
|
||||
const r = validateMessage({
|
||||
...envelope,
|
||||
type: "game.move",
|
||||
payload: { from: "e2", to: "z9" },
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects game.move with bad promotion piece", () => {
|
||||
const r = validateMessage({
|
||||
...envelope,
|
||||
type: "game.move",
|
||||
payload: { from: "a7", to: "a8", promoteTo: "king" },
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toMatch(/promoteTo/);
|
||||
});
|
||||
|
||||
it("rejects room.join with 5-char code", () => {
|
||||
const r = validateMessage({
|
||||
...envelope,
|
||||
type: "room.join",
|
||||
payload: { code: "ABC12" },
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects room.join with lowercase code", () => {
|
||||
const r = validateMessage({
|
||||
...envelope,
|
||||
type: "room.join",
|
||||
payload: { code: "abc123" },
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects negative seq", () => {
|
||||
const r = validateMessage({
|
||||
v: 1,
|
||||
seq: -1,
|
||||
ts: 1,
|
||||
type: "room.leave",
|
||||
payload: {},
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-integer seq", () => {
|
||||
const r = validateMessage({
|
||||
v: 1,
|
||||
seq: 1.5,
|
||||
ts: 1,
|
||||
type: "room.leave",
|
||||
payload: {},
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-positive ts", () => {
|
||||
const r = validateMessage({
|
||||
v: 1,
|
||||
seq: 0,
|
||||
ts: 0,
|
||||
type: "room.leave",
|
||||
payload: {},
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects token that is not a UUID", () => {
|
||||
const r = validateMessage({
|
||||
...envelope,
|
||||
token: "not-a-uuid",
|
||||
type: "room.leave",
|
||||
payload: {},
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects room.created with non-UUID token", () => {
|
||||
const r = validateMessage({
|
||||
...envelope,
|
||||
type: "room.created",
|
||||
payload: { code: "ABC123", token: "nope", color: "white" },
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects room.created with invalid color", () => {
|
||||
const r = validateMessage({
|
||||
...envelope,
|
||||
type: "room.created",
|
||||
payload: { code: "ABC123", token: UUID, color: "green" },
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects game.state missing required field", () => {
|
||||
const r = validateMessage({
|
||||
...envelope,
|
||||
type: "game.state",
|
||||
payload: {
|
||||
facts: [],
|
||||
turn: "white",
|
||||
lastSeq: 0,
|
||||
moveHistory: [],
|
||||
activeRules: [],
|
||||
// fen missing
|
||||
},
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toMatch(/fen/);
|
||||
});
|
||||
|
||||
it("rejects error payload with unknown error code", () => {
|
||||
const r = validateMessage({
|
||||
...envelope,
|
||||
type: "error",
|
||||
payload: { code: "UNKNOWN_CODE", message: "x", fatal: false },
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects game.delta gameOver with invalid reason", () => {
|
||||
const r = validateMessage({
|
||||
...envelope,
|
||||
type: "game.delta",
|
||||
payload: {
|
||||
inserted: [],
|
||||
retracted: [],
|
||||
moveNotation: "a",
|
||||
turn: "white",
|
||||
gameOver: { winner: "white", reason: "???" },
|
||||
},
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects game.end with invalid winner", () => {
|
||||
const r = validateMessage({
|
||||
...envelope,
|
||||
type: "game.end",
|
||||
payload: { winner: "nobody", reason: "x", finalFen: "x" },
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects room.leave with extra payload keys (strict)", () => {
|
||||
const r = validateMessage({
|
||||
...envelope,
|
||||
type: "room.leave",
|
||||
payload: { extra: 1 },
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateMessageString — raw-string entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("validateMessageString", () => {
|
||||
it("parses a valid JSON string frame", () => {
|
||||
const msg: ClientMessage = {
|
||||
...envelope,
|
||||
type: "room.create",
|
||||
payload: {},
|
||||
};
|
||||
const r = validateMessageString(JSON.stringify(msg));
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("reports malformed JSON as INVALID_MESSAGE", () => {
|
||||
const r = validateMessageString("{ this is not json");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toMatch(/INVALID_MESSAGE/);
|
||||
expect(r.error).toMatch(/malformed JSON/);
|
||||
}
|
||||
});
|
||||
|
||||
it("propagates version mismatch through the string entry point", () => {
|
||||
const r = validateMessageString(
|
||||
JSON.stringify({ v: 99, seq: 0, ts: 1, type: "room.leave", payload: {} }),
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toMatch(/VERSION_MISMATCH/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type-narrowing smoke test — ensures discriminated unions are usable.
|
||||
// (Executed at runtime, but the value comes from the union's narrowing.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("schema unions", () => {
|
||||
it("ClientMessageSchema parses client-originating types only", () => {
|
||||
const ok = ClientMessageSchema.safeParse({
|
||||
...envelope,
|
||||
type: "game.move",
|
||||
payload: { from: "e2", to: "e4" },
|
||||
});
|
||||
expect(ok.success).toBe(true);
|
||||
|
||||
const bad = ClientMessageSchema.safeParse({
|
||||
...envelope,
|
||||
type: "game.end",
|
||||
payload: { winner: "white", reason: "x", finalFen: "x" },
|
||||
});
|
||||
expect(bad.success).toBe(false);
|
||||
});
|
||||
|
||||
it("ServerMessageSchema parses server-originating types only", () => {
|
||||
const okMsg: ServerMessage = {
|
||||
...envelope,
|
||||
type: "error",
|
||||
payload: { code: "RATE_LIMIT", message: "too fast", fatal: true },
|
||||
};
|
||||
const good = ServerMessageSchema.safeParse(okMsg);
|
||||
expect(good.success).toBe(true);
|
||||
|
||||
const bad = ServerMessageSchema.safeParse({
|
||||
...envelope,
|
||||
type: "room.create",
|
||||
payload: {},
|
||||
});
|
||||
expect(bad.success).toBe(false);
|
||||
});
|
||||
|
||||
it("narrows AnyMessage by `type` discriminant", () => {
|
||||
const r = validateMessage({
|
||||
...envelope,
|
||||
type: "game.move",
|
||||
payload: { from: "e2", to: "e4" },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok && r.data.type === "game.move") {
|
||||
// TS narrowing should let us read these fields without casts.
|
||||
expect(r.data.payload.from).toBe("e2");
|
||||
expect(r.data.payload.to).toBe("e4");
|
||||
}
|
||||
});
|
||||
});
|
||||
333
packages/server/src/protocol.ts
Normal file
333
packages/server/src/protocol.ts
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
// Chess server WebSocket protocol v1 — Zod schemas & validation.
|
||||
// See PROTOCOL.md for the full spec.
|
||||
import { z } from "zod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PROTOCOL_VERSION = 1 as const;
|
||||
|
||||
export const ColorSchema = z.enum(["white", "black"]);
|
||||
export type Color = z.infer<typeof ColorSchema>;
|
||||
|
||||
export const SquareSchema = z
|
||||
.string()
|
||||
.regex(/^[a-h][1-8]$/, "square must be algebraic notation a1..h8");
|
||||
export type Square = z.infer<typeof SquareSchema>;
|
||||
|
||||
export const PromotionPieceSchema = z.enum([
|
||||
"queen",
|
||||
"rook",
|
||||
"bishop",
|
||||
"knight",
|
||||
]);
|
||||
export type PromotionPiece = z.infer<typeof PromotionPieceSchema>;
|
||||
|
||||
export const RoomCodeSchema = z
|
||||
.string()
|
||||
.length(6)
|
||||
.regex(/^[A-Z0-9]{6}$/, "room code must be 6 uppercase [A-Z0-9]");
|
||||
export type RoomCode = z.infer<typeof RoomCodeSchema>;
|
||||
|
||||
export const FactSchema = z.object({
|
||||
id: z.number().int(),
|
||||
attr: z.string(),
|
||||
value: z.unknown(),
|
||||
});
|
||||
export type Fact = z.infer<typeof FactSchema>;
|
||||
|
||||
export const ErrorCodeSchema = z.enum([
|
||||
"ILLEGAL_MOVE",
|
||||
"NOT_YOUR_TURN",
|
||||
"GAME_OVER",
|
||||
"ROOM_NOT_FOUND",
|
||||
"ROOM_FULL",
|
||||
"SERVER_FULL",
|
||||
"VERSION_MISMATCH",
|
||||
"RATE_LIMIT",
|
||||
"MSG_TOO_LARGE",
|
||||
"BAD_TOKEN",
|
||||
"INVALID_MESSAGE",
|
||||
]);
|
||||
export type ErrorCode = z.infer<typeof ErrorCodeSchema>;
|
||||
|
||||
export const GameEndReasonSchema = z.enum([
|
||||
"checkmate",
|
||||
"stalemate",
|
||||
"50-move",
|
||||
"threefold",
|
||||
"insufficient",
|
||||
"player_left",
|
||||
]);
|
||||
export type GameEndReason = z.infer<typeof GameEndReasonSchema>;
|
||||
|
||||
export const WinnerSchema = z.enum(["white", "black", "draw"]);
|
||||
export type Winner = z.infer<typeof WinnerSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Envelope (common wrapper for every message)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const envelopeShape = {
|
||||
v: z.literal(PROTOCOL_VERSION),
|
||||
seq: z.number().int().nonnegative(),
|
||||
ts: z.number().int().positive(),
|
||||
token: z.string().uuid().optional(),
|
||||
} as const;
|
||||
|
||||
// A permissive envelope-only parser used to inspect `v` and `type` before
|
||||
// choosing a discriminated-union branch. Payload is unknown at this layer.
|
||||
export const EnvelopeSchema = z.object({
|
||||
...envelopeShape,
|
||||
type: z.string(),
|
||||
payload: z.unknown(),
|
||||
});
|
||||
export type Envelope = z.infer<typeof EnvelopeSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client → Server payloads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const RoomCreatePayloadSchema = z.object({
|
||||
rulesetIds: z.array(z.string()).optional(),
|
||||
});
|
||||
export type RoomCreatePayload = z.infer<typeof RoomCreatePayloadSchema>;
|
||||
|
||||
export const RoomJoinPayloadSchema = z.object({
|
||||
code: RoomCodeSchema,
|
||||
});
|
||||
export type RoomJoinPayload = z.infer<typeof RoomJoinPayloadSchema>;
|
||||
|
||||
export const RoomLeavePayloadSchema = z.object({}).strict();
|
||||
export type RoomLeavePayload = z.infer<typeof RoomLeavePayloadSchema>;
|
||||
|
||||
export const GameMovePayloadSchema = z.object({
|
||||
from: SquareSchema,
|
||||
to: SquareSchema,
|
||||
promoteTo: PromotionPieceSchema.optional(),
|
||||
});
|
||||
export type GameMovePayload = z.infer<typeof GameMovePayloadSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server → Client payloads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const RoomCreatedPayloadSchema = z.object({
|
||||
code: RoomCodeSchema,
|
||||
token: z.string().uuid(),
|
||||
color: ColorSchema,
|
||||
});
|
||||
export type RoomCreatedPayload = z.infer<typeof RoomCreatedPayloadSchema>;
|
||||
|
||||
export const RoomJoinedPayloadSchema = z.object({
|
||||
code: RoomCodeSchema,
|
||||
token: z.string().uuid(),
|
||||
color: ColorSchema,
|
||||
activeRules: z.array(z.string()),
|
||||
});
|
||||
export type RoomJoinedPayload = z.infer<typeof RoomJoinedPayloadSchema>;
|
||||
|
||||
export const GameStatePayloadSchema = z.object({
|
||||
facts: z.array(FactSchema),
|
||||
turn: ColorSchema,
|
||||
lastSeq: z.number().int().nonnegative(),
|
||||
moveHistory: z.array(z.string()),
|
||||
activeRules: z.array(z.string()),
|
||||
fen: z.string(),
|
||||
});
|
||||
export type GameStatePayload = z.infer<typeof GameStatePayloadSchema>;
|
||||
|
||||
export const GameOverSchema = z.object({
|
||||
winner: WinnerSchema,
|
||||
reason: GameEndReasonSchema,
|
||||
});
|
||||
export type GameOver = z.infer<typeof GameOverSchema>;
|
||||
|
||||
export const GameDeltaPayloadSchema = z.object({
|
||||
inserted: z.array(FactSchema),
|
||||
retracted: z.array(FactSchema),
|
||||
moveNotation: z.string(),
|
||||
turn: ColorSchema,
|
||||
gameOver: GameOverSchema.nullable(),
|
||||
});
|
||||
export type GameDeltaPayload = z.infer<typeof GameDeltaPayloadSchema>;
|
||||
|
||||
export const GameEndPayloadSchema = z.object({
|
||||
winner: WinnerSchema,
|
||||
reason: z.string(),
|
||||
finalFen: z.string(),
|
||||
});
|
||||
export type GameEndPayload = z.infer<typeof GameEndPayloadSchema>;
|
||||
|
||||
export const ErrorPayloadSchema = z.object({
|
||||
code: ErrorCodeSchema,
|
||||
message: z.string(),
|
||||
fatal: z.boolean(),
|
||||
});
|
||||
export type ErrorPayload = z.infer<typeof ErrorPayloadSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discriminated union of full messages (envelope + typed payload)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const msg = <Type extends string, P extends z.ZodTypeAny>(
|
||||
type: Type,
|
||||
payload: P,
|
||||
) =>
|
||||
z.object({
|
||||
...envelopeShape,
|
||||
type: z.literal(type),
|
||||
payload,
|
||||
});
|
||||
|
||||
export const RoomCreateMessageSchema = msg(
|
||||
"room.create",
|
||||
RoomCreatePayloadSchema,
|
||||
);
|
||||
export const RoomJoinMessageSchema = msg("room.join", RoomJoinPayloadSchema);
|
||||
export const RoomLeaveMessageSchema = msg("room.leave", RoomLeavePayloadSchema);
|
||||
export const GameMoveMessageSchema = msg("game.move", GameMovePayloadSchema);
|
||||
|
||||
export const ClientMessageSchema = z.discriminatedUnion("type", [
|
||||
RoomCreateMessageSchema,
|
||||
RoomJoinMessageSchema,
|
||||
RoomLeaveMessageSchema,
|
||||
GameMoveMessageSchema,
|
||||
]);
|
||||
export type ClientMessage = z.infer<typeof ClientMessageSchema>;
|
||||
|
||||
export const RoomCreatedMessageSchema = msg(
|
||||
"room.created",
|
||||
RoomCreatedPayloadSchema,
|
||||
);
|
||||
export const RoomJoinedMessageSchema = msg(
|
||||
"room.joined",
|
||||
RoomJoinedPayloadSchema,
|
||||
);
|
||||
export const GameStateMessageSchema = msg("game.state", GameStatePayloadSchema);
|
||||
export const GameDeltaMessageSchema = msg("game.delta", GameDeltaPayloadSchema);
|
||||
export const GameEndMessageSchema = msg("game.end", GameEndPayloadSchema);
|
||||
export const ErrorMessageSchema = msg("error", ErrorPayloadSchema);
|
||||
|
||||
export const ServerMessageSchema = z.discriminatedUnion("type", [
|
||||
RoomCreatedMessageSchema,
|
||||
RoomJoinedMessageSchema,
|
||||
GameStateMessageSchema,
|
||||
GameDeltaMessageSchema,
|
||||
GameEndMessageSchema,
|
||||
ErrorMessageSchema,
|
||||
]);
|
||||
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
|
||||
|
||||
export const AnyMessageSchema = z.discriminatedUnion("type", [
|
||||
RoomCreateMessageSchema,
|
||||
RoomJoinMessageSchema,
|
||||
RoomLeaveMessageSchema,
|
||||
GameMoveMessageSchema,
|
||||
RoomCreatedMessageSchema,
|
||||
RoomJoinedMessageSchema,
|
||||
GameStateMessageSchema,
|
||||
GameDeltaMessageSchema,
|
||||
GameEndMessageSchema,
|
||||
ErrorMessageSchema,
|
||||
]);
|
||||
export type AnyMessage = z.infer<typeof AnyMessageSchema>;
|
||||
|
||||
export const KNOWN_MESSAGE_TYPES = [
|
||||
"room.create",
|
||||
"room.join",
|
||||
"room.leave",
|
||||
"game.move",
|
||||
"room.created",
|
||||
"room.joined",
|
||||
"game.state",
|
||||
"game.delta",
|
||||
"game.end",
|
||||
"error",
|
||||
] as const;
|
||||
export type MessageType = (typeof KNOWN_MESSAGE_TYPES)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateMessage — Result-style entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Result<T, E> = { ok: true; data: T } | { ok: false; error: E };
|
||||
|
||||
const ok = <T>(data: T): Result<T, never> => ({ ok: true, data });
|
||||
const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
|
||||
|
||||
/**
|
||||
* Validate a decoded JSON value against the protocol.
|
||||
*
|
||||
* The input is `unknown` — callers that start from a raw string MUST
|
||||
* `JSON.parse` first (and catch its throw) before handing a value here.
|
||||
*
|
||||
* On success returns `{ ok: true, data }` with the fully-typed, narrowed
|
||||
* message. On failure returns `{ ok: false, error }` with a descriptive
|
||||
* string. Version mismatches are surfaced with a `VERSION_MISMATCH:` prefix
|
||||
* so callers can disconnect fatally without re-parsing.
|
||||
*/
|
||||
export function validateMessage(raw: unknown): Result<AnyMessage, string> {
|
||||
// 1. Shape-check the envelope first so we can give precise errors about
|
||||
// protocol-version mismatch vs. structural problems.
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
||||
return err("INVALID_MESSAGE: message must be a JSON object");
|
||||
}
|
||||
|
||||
const envelope = raw as Record<string, unknown>;
|
||||
|
||||
// 2. Version gate — anything other than v===1 is VERSION_MISMATCH, even
|
||||
// before we look at `type`. Spec: version mismatch = fatal disconnect.
|
||||
if (envelope["v"] !== PROTOCOL_VERSION) {
|
||||
return err(
|
||||
`VERSION_MISMATCH: expected v=${String(PROTOCOL_VERSION)}, got v=${String(
|
||||
envelope["v"],
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Type gate — must be a known literal before we dispatch to the union.
|
||||
const type = envelope["type"];
|
||||
if (typeof type !== "string") {
|
||||
return err("INVALID_MESSAGE: missing or non-string `type` field");
|
||||
}
|
||||
if (!(KNOWN_MESSAGE_TYPES as readonly string[]).includes(type)) {
|
||||
return err(`INVALID_MESSAGE: unknown message type "${type}"`);
|
||||
}
|
||||
|
||||
// 4. Delegate to the discriminated union for full structural validation.
|
||||
const parsed = AnyMessageSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return err(`INVALID_MESSAGE: ${formatZodError(parsed.error)}`);
|
||||
}
|
||||
return ok(parsed.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: parse a raw WebSocket string frame. Handles the JSON.parse
|
||||
* throw and funnels it into the same Result shape as `validateMessage`.
|
||||
*/
|
||||
export function validateMessageString(
|
||||
raw: string,
|
||||
): Result<AnyMessage, string> {
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return err(`INVALID_MESSAGE: malformed JSON (${msg})`);
|
||||
}
|
||||
return validateMessage(decoded);
|
||||
}
|
||||
|
||||
function formatZodError(error: z.ZodError): string {
|
||||
// Collapse issues into a compact single-line description. Keeping this
|
||||
// deterministic is useful for tests and log greppability.
|
||||
return error.issues
|
||||
.map((issue) => {
|
||||
const path = issue.path.length > 0 ? issue.path.join(".") : "<root>";
|
||||
return `${path}: ${issue.message}`;
|
||||
})
|
||||
.join("; ");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue