Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 10 additions & 14 deletions apps/api/src/durable/chat-room.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DurableObject } from "cloudflare:workers";
import { CHAT_COOLDOWN_MS, CHAT_MAX_BANS, CHAT_MAX_CONNECTIONS, CHAT_MAX_MESSAGES, CHAT_TTL_MS, chatHandle, isChatAuthorId, normalizeChatPost, type ChatBan, type ChatBanResult, type ChatFrame, type ChatMessage, type ChatPost, type ChatPostResult } from "@launchpad/chat";
import { CHAT_COOLDOWN_MS, CHAT_MAX_BANS, CHAT_MAX_CONNECTIONS, CHAT_MAX_MESSAGES, chatHandle, isChatAuthorId, normalizeChatPost, type ChatBan, type ChatBanResult, type ChatFrame, type ChatMessage, type ChatPost, type ChatPostResult } from "@launchpad/chat";
import type { Env } from "#api/env";
import { closeWebSocket } from "#api/durable/websocket";

Expand All @@ -8,6 +8,9 @@ const GLOBAL_WINDOW_MS = 10_000;
const GLOBAL_MAX_POSTS = 20;
/** Retry receipts contain no text and have a bounded lifetime and row count. */
const MAX_RECEIPTS = 2_048;
/** Receipts back retries and the per-author cooldown, so they still expire.
* Messages do not: history is bounded by CHAT_MAX_MESSAGES alone. */
const RECEIPT_TTL_MS = 24 * 60 * 60 * 1_000;

type MessageRow = { id: string; author_id: string; handle: string; text: string; created_at: number };
type ReceiptRow = { message_id: string; created_at: number; digest: string };
Expand Down Expand Up @@ -104,7 +107,7 @@ export class ChatRoom extends DurableObject<Env> {
if (sender && now - sender.created_at < CHAT_COOLDOWN_MS) return {
ok: false, error: "rate_limited", retryAfter: Math.ceil((sender.created_at + CHAT_COOLDOWN_MS - now) / 1_000),
};
// Fifty retained messages exceed the twenty-message global window, so
// Two hundred retained messages exceed the twenty-message global window, so
// capping history can never erase a still-relevant global rate entry.
const recent = this.ctx.storage.sql.exec<{ count: number; oldest: number | null }>(
"SELECT COUNT(*) AS count, MIN(created_at) AS oldest FROM chat_messages WHERE created_at > ?", now - GLOBAL_WINDOW_MS,
Expand Down Expand Up @@ -165,11 +168,10 @@ export class ChatRoom extends DurableObject<Env> {
webSocketError(ws: WebSocket) { closeWebSocket(ws, 1011, "WebSocket error"); this.publishPresence(); }

async alarm() {
const before = this.history().length;
// Only receipts expire, and they are never shown, so readers hear nothing.
this.ctx.storage.transactionSync(() => this.pruneExpired(Date.now()));
const cleanup = this.scheduleCleanup();
if (this.env.CHAT_ENABLED !== "true") this.closeReaders();
else if (this.history().length !== before) this.broadcast({ type: "history", messages: this.history() });
await cleanup;
}

Expand All @@ -178,8 +180,7 @@ export class ChatRoom extends DurableObject<Env> {
}

private pruneExpired(now: number) {
this.ctx.storage.sql.exec("DELETE FROM chat_messages WHERE created_at <= ?", now - CHAT_TTL_MS);
this.ctx.storage.sql.exec("DELETE FROM chat_receipts WHERE created_at <= ?", now - CHAT_TTL_MS);
this.ctx.storage.sql.exec("DELETE FROM chat_receipts WHERE created_at <= ?", now - RECEIPT_TTL_MS);
}

private pruneCaps() {
Expand All @@ -190,16 +191,11 @@ export class ChatRoom extends DurableObject<Env> {
}

private scheduleCleanup(): Promise<void> {
const messages = this.ctx.storage.sql.exec<{ oldest: number | null }>(
"SELECT MIN(created_at) AS oldest FROM chat_messages",
).one().oldest;
// A direct MIN uses the existing receipt timestamp index. MIN over a
// UNION scanned every retained receipt each time someone opened chat.
const receipts = this.ctx.storage.sql.exec<{ oldest: number | null }>(
// A direct MIN uses the existing receipt timestamp index.
const oldest = this.ctx.storage.sql.exec<{ oldest: number | null }>(
"SELECT MIN(created_at) AS oldest FROM chat_receipts",
).one().oldest;
const oldest = messages === null ? receipts : receipts === null ? messages : Math.min(messages, receipts);
const desired = oldest === null ? null : oldest + CHAT_TTL_MS;
const desired = oldest === null ? null : oldest + RECEIPT_TTL_MS;
// Capture the SQL decision now, then apply decisions in event order. The
// durable alarm remains the source of truth after hibernation, and an
// older empty read cannot delete a newer post's cleanup alarm.
Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/queries/launches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,15 @@ export function paceOrder(tip: number): string {
const SORT_SQL = {
progress: "rank_key DESC",
mcap: "market_cap_rank DESC",
// XCP per whole token, the pool ratio the price column shows. Unlike market
// cap this ignores supply, so a launch that burned part of its supply ranks
// by what one token costs rather than by what all of them are worth. NULL
// (no pool yet) sorts last under DESC.
price: `CASE
WHEN CAST(pool_token_reserve AS REAL) > 0
THEN CAST(pool_xcp_reserve AS REAL) / CAST(pool_token_reserve AS REAL)
ELSE NULL
END DESC`,
// USD return is current TOKEN/XCP * current XCP/USD divided by mint
// TOKEN/XCP * graduation XCP/USD. The current XCP/USD factor is common to
// every launch, so omitting it preserves the exact rank. NULL sorts last
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/app/[lang]/_components/launch-sections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,9 @@ const compareRecent = (a: SectionRow, b: SectionRow): number => {
export const SORTS: Record<string, SortOption[]> = {
graduated: [
{ id: "mcap", label: msg("Market cap"), by: (a, b) => b.marketCapXcp - a.marketCapXcp },
// Per token, not per supply: burns lower market cap but not price. A
// launch with no pool prices at 0 and lands last, as NULL does in SQL.
{ id: "price", label: msg("Price"), by: (a, b) => b.priceXcp - a.priceXcp },
{ id: "performance", label: msg("Performance (All)"), by: comparePerformance },
{ id: "performance_24h", label: msg("Performance (24h)"), by: compareRecent },
{ id: "minters", label: msg("Minters"), by: (a, b) => minterRank(b) - minterRank(a) },
Expand Down
9 changes: 3 additions & 6 deletions apps/web/src/hooks/use-chat.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
"use client";

import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { CHAT_MAX_MESSAGES, CHAT_TTL_MS, isChatAuthorId, isChatHandle, parseChatFrame, type ChatBan, type ChatMessage } from "@launchpad/chat";
import { CHAT_MAX_MESSAGES, isChatAuthorId, isChatHandle, parseChatFrame, type ChatBan, type ChatMessage } from "@launchpad/chat";

const WS_URL = process.env.NEXT_PUBLIC_CHAT_WS_URL ?? "wss://api.xcp.fun/ws/chat";
const MAX_BACKOFF_MS = 30_000;
export type ChatStatus = "connecting" | "live" | "reconnecting" | "offline";

function recentMessages(messages: ChatMessage[]): ChatMessage[] {
const cutoff = Date.now() - CHAT_TTL_MS;
return [...new Map(messages.filter((message) => message.createdAt > cutoff).map((message) => [message.id, message])).values()]
return [...new Map(messages.map((message) => [message.id, message])).values()]
.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id))
.slice(-CHAT_MAX_MESSAGES);
}
Expand Down Expand Up @@ -50,7 +49,7 @@ export function useChat(active: boolean) {
const frame = parseChatFrame(event.data);
if (!frame) return;
if (frame.count !== undefined) setConnections(frame.count);
// Reconnection history is authoritative: expired/removed messages
// Reconnection history is authoritative: capped/removed messages
// must disappear instead of being merged back into the transcript.
if (frame.type === "history") {
attempt = 0;
Expand All @@ -64,11 +63,9 @@ export function useChat(active: boolean) {
ws.onerror = () => { if (current()) ws.close(); };
};
connect();
const expiry = setInterval(() => setMessages((previous) => recentMessages(previous)), 30_000);
return () => {
stopped = true;
clearTimeout(timer);
clearInterval(expiry);
socket?.close();
};
}, [active, acceptMessage]);
Expand Down
11 changes: 8 additions & 3 deletions packages/chat/src/protocol.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
/** Shared wire limits: Unicode codepoints, not UTF-16 units or Latin letters. */
export const CHAT_MAX_CODEPOINTS = 280;
export const CHAT_MAX_LINES = 3;
export const CHAT_MAX_MESSAGES = 50;
export const CHAT_TTL_MS = 24 * 60 * 60 * 1_000;
/**
* History is kept by count, never by age. A quiet room used to expire every
* message after a day and open to an empty transcript; now the last
* CHAT_MAX_MESSAGES stay until newer posts push them out.
*/
export const CHAT_MAX_MESSAGES = 200;
export const CHAT_COOLDOWN_MS = 3_000;
export const CHAT_MAX_RAW_BYTES = 4_096;
export const CHAT_MAX_CONNECTIONS = 500;
Expand Down Expand Up @@ -88,7 +92,8 @@ function parseMessage(value: unknown): ChatMessage | null {
/** Parse only bounded, valid frames; do not forward unknown fields into UI state. */
export function parseChatFrame(value: unknown): ChatFrame | null {
if (typeof value === "string") {
if (value.length > 100_000) return null;
// A full history frame: worst-case escaped text plus envelope per message.
if (value.length > CHAT_MAX_MESSAGES * 2_000) return null;
try { value = JSON.parse(value); } catch { return null; }
}
if (!object(value)) return null;
Expand Down
8 changes: 4 additions & 4 deletions tests/chat-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatPanel } from "@/components/chat-panel";
import { LocaleProvider } from "@/lib/i18n/client";
import { CHAT_COOLDOWN_MS, chatHandle, type ChatMessage } from "@launchpad/chat";
import { CHAT_COOLDOWN_MS, CHAT_MAX_MESSAGES, chatHandle, type ChatMessage } from "@launchpad/chat";
import { ChatProvider, useChatRoute } from "@/providers/chat-context";

const boundary = vi.hoisted(() => ({
Expand Down Expand Up @@ -114,9 +114,9 @@ describe("chat transcript and socket lifecycle", () => {
it("deduplicates messages, bounds history, replaces it on reconnect and ignores stale/malformed frames", async () => {
vi.useFakeTimers(); await render();
const first = newestSocket();
await act(() => first.frame({ type: "history", messages: Array.from({ length: 50 }, (_, n) => message(n + 1, `history ${n}`)) }));
await act(() => { first.frame({ type: "message", message: message(51, "latest") }); first.frame({ type: "message", message: message(51, "latest") }); first.frame("x".repeat(100001)); });
expect(transcript().querySelectorAll("p")).toHaveLength(50);
await act(() => first.frame({ type: "history", messages: Array.from({ length: CHAT_MAX_MESSAGES }, (_, n) => message(n + 1, `history ${n}`)) }));
await act(() => { first.frame({ type: "message", message: message(CHAT_MAX_MESSAGES + 1, "latest") }); first.frame({ type: "message", message: message(CHAT_MAX_MESSAGES + 1, "latest") }); first.frame("x".repeat(CHAT_MAX_MESSAGES * 2_000 + 1)); });
expect(transcript().querySelectorAll("p")).toHaveLength(CHAT_MAX_MESSAGES);
expect(transcript().textContent).not.toContain("history 0");
await act(() => first.close());
await act(async () => vi.advanceTimersByTime(1001));
Expand Down
4 changes: 2 additions & 2 deletions tests/chat-protocol.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { CHAT_MAX_CODEPOINTS, CHAT_MAX_LINES, chatHandle, chatLineCount, normalizeChatPost, normalizeChatText, parseChatFrame, type ChatMessage } from "@launchpad/chat";
import { CHAT_MAX_CODEPOINTS, CHAT_MAX_MESSAGES, CHAT_MAX_LINES, chatHandle, chatLineCount, normalizeChatPost, normalizeChatText, parseChatFrame, type ChatMessage } from "@launchpad/chat";

const message: ChatMessage = { id: "message-123", authorId: "a".repeat(64), handle: "Quiet-Otter-1234", text: "Hello", createdAt: 1_800_000_000_000 };
const compactMessage: ChatMessage = { ...message, handle: chatHandle(message.authorId) };
Expand Down Expand Up @@ -47,7 +47,7 @@ describe("plain-text chat protocol", () => {
expect(parseChatFrame({ type: "history", messages: [message] })).toEqual({ type: "history", messages: [compactMessage] });
expect(parseChatFrame({ type: "history", messages: [] })).toEqual({ type: "history", messages: [] });
expect(parseChatFrame({ type: "history", messages: [message, message] })).toBeNull();
expect(parseChatFrame({ type: "history", messages: Array.from({ length: 51 }, (_, i) => ({ ...message, id: `message-${i}` })) })).toBeNull();
expect(parseChatFrame({ type: "history", messages: Array.from({ length: CHAT_MAX_MESSAGES + 1 }, (_, i) => ({ ...message, id: `message-${i}` })) })).toBeNull();
expect(parseChatFrame({ type: "message", message: { ...message, createdAt: NaN } })).toBeNull();
expect(parseChatFrame({ type: "message", message: { ...message, text: " noncanonical " } })).toBeNull();
expect(parseChatFrame("x".repeat(100001))).toBeNull();
Expand Down
Loading
Loading