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