From fe309ca8af015c4a05d0a55e9e7d425332b0f1dd Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sun, 11 Oct 2026 16:21:15 +0800 Subject: [PATCH] feat(gateway): make sends idempotent with a client nonce A send may carry a nonce; a resend with the same nonce from the same handle within ten minutes is answered with the original message instead of being posted again, so a client retrying after a dropped socket cannot duplicate a message. --- gateway/src/gateway.ts | 14 ++++++++++++-- gateway/test/gateway.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/gateway/src/gateway.ts b/gateway/src/gateway.ts index 2704489..406c29b 100644 --- a/gateway/src/gateway.ts +++ b/gateway/src/gateway.ts @@ -11,6 +11,8 @@ const AUTH_TIMEOUT_MS = 5_000; const TYPING_INTERVAL_MS = 2_000; const HISTORY_PAGE = 50; const HISTORY_PAGE_MAX = 100; +const NONCE_TTL_MS = 10 * 60_000; +const NONCE_MAX_LENGTH = 64; const OPEN = 1; type Session = { @@ -47,13 +49,14 @@ export function createGateway(options: GatewayOptions) { const clients = new Set(); const now = options.now ?? (() => new Date()); const typingAt = new Map(); + const sent = new Map(); let online = "[]"; const broadcast = (frame: object) => { const data = JSON.stringify(frame); for (const ws of clients) ws.send(data); }; - const deliver = (message: Message) => broadcast({ type: "message", ...message }); + const deliver = (message: Message, nonce?: string) => broadcast(nonce ? { type: "message", ...message, nonce } : { type: "message", ...message }); const syncPresence = () => { const list = presence.online(); const key = JSON.stringify(list); @@ -107,10 +110,16 @@ export function createGateway(options: GatewayOptions) { const text = validateText(frame.text); if (text === null) return fail(ws, "invalid_text"); const sender = ws.data.handle; + const nonce = typeof frame.nonce === "string" && frame.nonce.length <= NONCE_MAX_LENGTH ? frame.nonce : undefined; + const key = nonce === undefined ? undefined : `${sender}\n${nonce}`; + const earlier = key === undefined ? undefined : sent.get(key); + if (earlier) return ws.send(JSON.stringify({ type: "message", ...earlier.message, nonce })); if (!limiter.take(sender)) return fail(ws, "rate_limited"); options.post(sender, text); const timestamp = now().toISOString().replace(/\.\d{3}Z$/, "Z"); - deliver(options.ledger.add({ timestamp, sender, text })); + const message = options.ledger.add({ timestamp, sender, text }); + if (key !== undefined) sent.set(key, { message, at: Date.now() }); + deliver(message, nonce); } return { @@ -139,6 +148,7 @@ export function createGateway(options: GatewayOptions) { sweep(at?: number) { limiter.sweep(at); historyLimiter.sweep(at); + for (const [key, entry] of sent) if ((at ?? Date.now()) - entry.at > NONCE_TTL_MS) sent.delete(key); for (const [handle, time] of typingAt) if ((at ?? Date.now()) - time > TYPING_INTERVAL_MS) typingAt.delete(handle); }, handleEvent(event: TntEvent) { diff --git a/gateway/test/gateway.test.ts b/gateway/test/gateway.test.ts index 386afc9..99bd0bf 100644 --- a/gateway/test/gateway.test.ts +++ b/gateway/test/gateway.test.ts @@ -127,6 +127,35 @@ test("a second connection for the same user keeps them online when one closes", await expect(bob.next((f) => f.type === "presence" && !f.online.includes("alice"), 300)).rejects.toThrow(); }); +describe("idempotent sends", () => { + test("a resend with the same nonce echoes the first message and posts once", async () => { + const alice = connect(url, "alice"); + await alice.next((f) => f.type === "ready"); + alice.send({ type: "send", text: "hello", nonce: "n1" }); + const first = await alice.next((f) => f.type === "message"); + expect(first.nonce).toBe("n1"); + alice.send({ type: "send", text: "hello", nonce: "n1" }); + const again = await alice.next((f) => f.type === "message"); + expect(again.id).toBe(first.id); + expect(posts).toEqual([["alice", "hello"]]); + alice.send({ type: "send", text: "hello" }); + expect((await alice.next((f) => f.type === "message")).id).not.toBe(first.id); + }); + + test("nonces are private to their sender", async () => { + const alice = connect(url, "alice"); + await alice.next((f) => f.type === "ready"); + const bob = connect(url, "bob"); + await bob.next((f) => f.type === "ready"); + alice.send({ type: "send", text: "hi", nonce: "shared" }); + const a = await alice.next((f) => f.type === "message"); + bob.send({ type: "send", text: "hi", nonce: "shared" }); + const b = await bob.next((f) => f.type === "message" && f.sender === "bob"); + expect(b.id).not.toBe(a.id); + expect(posts.length).toBe(2); + }); +}); + describe("history and typing", () => { test("answers history pages and rejects unknown ids", async () => { const alice = connect(url, "alice");