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
4 changes: 2 additions & 2 deletions 404.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
<link rel="canonical" href="https://andyccr.github.io/ZenChat/" />
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
<title>禅聊 ZenChat</title>
<script type="module" crossorigin src="/ZenChat/assets/index-Ckc1AzqL.js"></script>
<link rel="stylesheet" crossorigin href="/ZenChat/assets/index-Bou1xdtq.css">
<script type="module" crossorigin src="/ZenChat/assets/index-DfhOSYdI.js"></script>
<link rel="stylesheet" crossorigin href="/ZenChat/assets/index-9O3FLWNJ.css">
</head>
<body>
<div id="app"></div>
Expand Down
26 changes: 16 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ flowchart LR
RM[RoomManager]
CS[ChatSession]
SM[SessionMachine]
HB[Heartbeat]
PR[PayloadRouter]
OB[Outbound]
P[Presence]
L[Transcript]
Expand All @@ -34,10 +36,12 @@ flowchart LR
end

UI --> RM --> CS
CS --> SM[SessionMachine]
CS --> OB[Outbound]
CS --> P[Presence]
CS --> L[Transcript]
CS --> SM
CS --> HB
CS --> PR
CS --> OB
CS --> P
CS --> L
CS --> TR

TR -->|"SDP / ICE (signalling only)"| WT
Expand All @@ -48,12 +52,14 @@ flowchart LR

| Layer / 层 | Role / 职责 |
|---|---|
| UI Shell | Lobby, tabs, theme. Chat pane is a separate view; shell does not remount on switch. |
| RoomManager | Room lifecycle, log cache, skip reconnect if the same room is already joined. |
| ChatSession | Thin orchestrator: transport in, machine + outbound + presence + transcript out. |
| SessionMachine | Pure join/wait/live/error transitions. |
| Outbound | Delivery timers. Peers that advertise `ack` in hello get a receipt. |
| Presence / Transcript | Members + typing TTL; capped message log and dedupe ids. |
| UI Shell | Lobby, tabs, theme. Chat pane is a separate view; shell does not remount on switch. Failed self lines are tappable to resend. |
| RoomManager | Room lifecycle, durable log cache, skip reconnect if the same room is already joined. UI never reaches into `ChatSession`. |
| ChatSession | Thin orchestrator: transport in, machine + heartbeat + router + outbound + presence + transcript out. |
| SessionMachine | Pure join/wait/live/error transitions. Status copy is derived from the machine. |
| Heartbeat | Hello interval, relay/RTT poll, visibility pause, stale presence prune. |
| PayloadRouter | Decode v1 hello/typing/ack/chat; session only applies effects. |
| Outbound | Delivery timers plus the original payload so a late ack or tap-to-resend can settle. Peers that advertise `ack` in hello get a receipt. |
| Presence / Transcript | Members + typing TTL + last-seen prune; capped message log, dedupe ids, durable hydrate (drop pending, keep failed). |
| Transport factory | Default Trystero torrent/nostr. Tests inject a fake. A `Libp2pTransport` can plug in here. |
| DataChannel | Encrypted chat after ICE succeeds. Trackers never see plaintext. |

Expand Down
2 changes: 1 addition & 1 deletion assets/index-Bou1xdtq.css → assets/index-9O3FLWNJ.css

Large diffs are not rendered by default.

3 changes: 0 additions & 3 deletions assets/index-Ckc1AzqL.js

This file was deleted.

3 changes: 3 additions & 0 deletions assets/index-DfhOSYdI.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
<link rel="canonical" href="https://andyccr.github.io/ZenChat/" />
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
<title>禅聊 ZenChat</title>
<script type="module" crossorigin src="/ZenChat/assets/index-Ckc1AzqL.js"></script>
<link rel="stylesheet" crossorigin href="/ZenChat/assets/index-Bou1xdtq.css">
<script type="module" crossorigin src="/ZenChat/assets/index-DfhOSYdI.js"></script>
<link rel="stylesheet" crossorigin href="/ZenChat/assets/index-9O3FLWNJ.css">
</head>
<body>
<div id="app"></div>
Expand Down
1 change: 1 addition & 0 deletions src/config/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const TYPING_TTL_MS = 2500
export const TYPING_THROTTLE_MS = 1800
export const HELLO_INTERVAL_MS = 25_000
export const RELAY_POLL_MS = 5000
export const PRESENCE_STALE_MS = HELLO_INTERVAL_MS * 3
export const ACK_TIMEOUT_MS = 8000
export const SOURCE_URL = 'https://github.com/Andyccr/ZenChat'
export const PAGES_URL = 'https://andyccr.github.io/ZenChat/'
12 changes: 12 additions & 0 deletions src/core/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,16 @@ describe('room log cache', () => {
saveRoomLog(spec, lines)
expect(loadRoomLog(spec)).toEqual(lines)
})

it('strips in-flight pending markers but keeps failed delivery', () => {
const spec = { name: 'lobby', password: '', strategy: 'torrent' as const }
saveRoomLog(spec, [
{ kind: 'chat', id: 'p1', fromId: 'me', nick: '晚风', text: 'hi', ts: 1, self: true, delivery: 'pending' },
{ kind: 'chat', id: 'f1', fromId: 'me', nick: '晚风', text: 'bye', ts: 2, self: true, delivery: 'failed' },
])
expect(loadRoomLog(spec)).toEqual([
{ kind: 'chat', id: 'p1', fromId: 'me', nick: '晚风', text: 'hi', ts: 1, self: true },
{ kind: 'chat', id: 'f1', fromId: 'me', nick: '晚风', text: 'bye', ts: 2, self: true, delivery: 'failed' },
])
})
})
19 changes: 18 additions & 1 deletion src/core/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,31 @@ export function loadRoomLog(spec: RoomSpec): ChatLine[] {
}
}

export function durableLines(lines: ChatLine[]): ChatLine[] {
return trimLines(lines.map(dropPendingDelivery))
}

export function saveRoomLog(spec: RoomSpec, lines: ChatLine[]): void {
try {
sessionStorage.setItem(cacheKey(spec), JSON.stringify(lines.slice(-MAX_LOG_LINES)))
sessionStorage.setItem(cacheKey(spec), JSON.stringify(durableLines(lines)))
} catch {
// Quota errors are non-fatal; the live session still works.
}
}

function dropPendingDelivery(line: ChatLine): ChatLine {
if (line.kind !== 'chat' || line.delivery !== 'pending') return line
return {
kind: 'chat',
id: line.id,
fromId: line.fromId,
nick: line.nick,
text: line.text,
ts: line.ts,
self: line.self,
}
}

export function trimLines(lines: ChatLine[]): ChatLine[] {
return lines.length > MAX_LOG_LINES ? lines.slice(-MAX_LOG_LINES) : lines
}
Expand Down
36 changes: 36 additions & 0 deletions src/core/heartbeat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { Heartbeat } from './heartbeat'
import { createMemoryRuntime } from './runtime'

describe('Heartbeat', () => {
it('polls and hellos on interval, pauses while hidden, and resumes with a burst', () => {
const clock = createMemoryRuntime(0)
const hellos: number[] = []
const polls: number[] = []
const beat = new Heartbeat(clock.runtime, 25, 10, {
onHello: () => hellos.push(clock.runtime.now()),
onPoll: () => polls.push(clock.runtime.now()),
})

beat.start()
clock.advance(25)
expect(hellos).toEqual([25])
expect(polls).toEqual([10, 20])

clock.setHidden(true)
clock.advance(40)
expect(hellos).toEqual([25])
expect(polls).toEqual([10, 20])

clock.setHidden(false)
expect(hellos.at(-1)).toBe(65)
expect(polls.at(-1)).toBe(65)
clock.advance(10)
expect(polls.at(-1)).toBe(75)

beat.stop()
clock.advance(50)
expect(hellos).toHaveLength(2)
expect(polls.filter((at) => at > 75)).toEqual([])
})
})
58 changes: 58 additions & 0 deletions src/core/heartbeat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { Runtime } from './runtime'

export type HeartbeatHandlers = {
onHello: () => void
onPoll: () => void
}

export class Heartbeat {
private helloTimer: number | null = null
private pollTimer: number | null = null
private unbindVisibility: (() => void) | null = null

constructor(
private runtime: Pick<Runtime, 'hidden' | 'setInterval' | 'clearInterval' | 'onVisibilityChange'>,
private helloMs: number,
private pollMs: number,
private handlers: HeartbeatHandlers,
) {}

start(): void {
this.stop()
this.unbindVisibility = this.runtime.onVisibilityChange(this.onVisibility)
this.startTimers()
}

stop(): void {
this.unbindVisibility?.()
this.unbindVisibility = null
this.stopTimers()
}

private onVisibility = (): void => {
if (this.runtime.hidden()) {
this.stopTimers()
return
}
this.startTimers()
this.handlers.onHello()
this.handlers.onPoll()
}

private startTimers(): void {
this.stopTimers()
this.helloTimer = this.runtime.setInterval(() => this.handlers.onHello(), this.helloMs)
this.pollTimer = this.runtime.setInterval(() => this.handlers.onPoll(), this.pollMs)
}

private stopTimers(): void {
if (this.helloTimer !== null) {
this.runtime.clearInterval(this.helloTimer)
this.helloTimer = null
}
if (this.pollTimer !== null) {
this.runtime.clearInterval(this.pollTimer)
this.pollTimer = null
}
}
}
13 changes: 8 additions & 5 deletions src/core/outbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,28 @@ import { Outbound } from './outbound'
import { createMemoryRuntime } from './runtime'

describe('Outbound', () => {
it('expires pending ids and ignores a late ack', () => {
it('expires pending ids but keeps the payload for a late ack or resend', () => {
const clock = createMemoryRuntime(0)
const expired: string[] = []
const outbound = new Outbound(clock.runtime, 100, (id) => expired.push(id))
outbound.expect('msg-1')
const outbound = new Outbound<{ text: string }>(clock.runtime, 100, (id) => expired.push(id))
outbound.expect('msg-1', { text: 'hi' })
clock.advance(99)
expect(expired).toEqual([])
clock.advance(1)
expect(expired).toEqual(['msg-1'])
expect(outbound.ack('msg-1')).toBe(false)
expect(outbound.payload('msg-1')).toEqual({ text: 'hi' })
expect(outbound.ack('msg-1')).toBe(true)
expect(outbound.payload('msg-1')).toBeUndefined()
})

it('clears a timer when the ack arrives first', () => {
const clock = createMemoryRuntime(0)
const expired: string[] = []
const outbound = new Outbound(clock.runtime, 100, (id) => expired.push(id))
outbound.expect('msg-2')
outbound.expect('msg-2', { text: 'yo' })
expect(outbound.ack('msg-2')).toBe(true)
clock.advance(100)
expect(expired).toEqual([])
expect(outbound.payload('msg-2')).toBeUndefined()
})
})
27 changes: 19 additions & 8 deletions src/core/outbound.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import type { Runtime } from './runtime'

export class Outbound {
export class Outbound<T = unknown> {
private timers = new Map<string, number>()
private payloads = new Map<string, T>()

constructor(
private runtime: Pick<Runtime, 'setTimeout' | 'clearTimeout'>,
private timeoutMs: number,
private onExpire: (id: string) => void,
) {}

expect(id: string): void {
this.cancel(id)
expect(id: string, payload?: T): void {
this.cancelTimer(id)
if (payload !== undefined) this.payloads.set(id, payload)
this.timers.set(
id,
this.runtime.setTimeout(() => {
Expand All @@ -21,18 +23,27 @@ export class Outbound {
}

ack(id: string): boolean {
if (!this.timers.has(id)) return false
if (!this.timers.has(id) && !this.payloads.has(id)) return false
this.cancel(id)
return true
}

payload(id: string): T | undefined {
return this.payloads.get(id)
}

cancel(id: string): void {
const timer = this.timers.get(id)
if (timer !== undefined) this.runtime.clearTimeout(timer)
this.timers.delete(id)
this.cancelTimer(id)
this.payloads.delete(id)
}

clear(): void {
for (const id of [...this.timers.keys()]) this.cancel(id)
for (const id of [...this.timers.keys(), ...this.payloads.keys()]) this.cancel(id)
}

private cancelTimer(id: string): void {
const timer = this.timers.get(id)
if (timer !== undefined) this.runtime.clearTimeout(timer)
this.timers.delete(id)
}
}
24 changes: 24 additions & 0 deletions src/core/payload-router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { decodeIncoming } from './payload-router'
import { createAckPayload, createChatPayload, createHelloPayload, createTypingPayload } from './protocol'

describe('decodeIncoming', () => {
it('drops invalid payloads and marks duplicate chats', () => {
expect(decodeIncoming({ v: 1, type: 'leave', nick: '晚风' }, () => false)).toBeNull()
expect(decodeIncoming(createHelloPayload('晚风'), () => false)).toEqual({
type: 'hello',
nick: '晚风',
features: ['ack'],
})
expect(decodeIncoming(createTypingPayload('听雨'), () => false)).toEqual({ type: 'typing', nick: '听雨' })
expect(decodeIncoming(createAckPayload('青石', 'aabbccdd12345678'), () => false)).toEqual({
type: 'ack',
nick: '青石',
id: 'aabbccdd12345678',
})

const chat = createChatPayload('青石', '在吗', 'aabbccdd12345678', 1)
expect(decodeIncoming(chat, () => false)).toMatchObject({ type: 'chat', duplicate: false, text: '在吗' })
expect(decodeIncoming(chat, (id) => id === chat.id)).toMatchObject({ type: 'chat', duplicate: true })
})
})
23 changes: 23 additions & 0 deletions src/core/payload-router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { parsePayload } from './protocol'

export type Incoming =
| { type: 'hello'; nick: string; features: string[] }
| { type: 'typing'; nick: string }
| { type: 'ack'; nick: string; id: string }
| { type: 'chat'; nick: string; id: string; text: string; ts: number; duplicate: boolean }

export function decodeIncoming(raw: unknown, seen: (id: string) => boolean): Incoming | null {
const payload = parsePayload(raw)
if (!payload) return null
if (payload.type === 'hello') return { type: 'hello', nick: payload.nick, features: payload.features }
if (payload.type === 'typing') return { type: 'typing', nick: payload.nick }
if (payload.type === 'ack') return { type: 'ack', nick: payload.nick, id: payload.id }
return {
type: 'chat',
nick: payload.nick,
id: payload.id,
text: payload.text,
ts: payload.ts,
duplicate: seen(payload.id),
}
}
11 changes: 11 additions & 0 deletions src/core/presence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,15 @@ describe('Presence', () => {
expect(presence.remove('p1')?.nick).toBe('晚风')
expect(presence.list()).toEqual([])
})

it('prunes members whose lastSeenAt is older than the stale window', () => {
const clock = createMemoryRuntime(1_000)
const presence = new Presence(clock.runtime)
presence.upsert('fresh', '晚风')
clock.advance(80)
presence.upsert('stale', '青石')
clock.advance(30)
expect(presence.prune(100).map((member) => member.id)).toEqual(['fresh'])
expect(presence.list().map((member) => member.id)).toEqual(['stale'])
})
})
7 changes: 7 additions & 0 deletions src/core/presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ export class Presence {
return [...this.members.values()].sort((a, b) => a.joinedAt - b.joinedAt)
}

prune(maxAgeMs: number): Member[] {
const cutoff = this.runtime.now() - maxAgeMs
const gone = this.list().filter((member) => member.lastSeenAt < cutoff)
for (const member of gone) this.remove(member.id)
return gone
}

clear(): void {
for (const id of this.typingTimers.keys()) this.clearTypingTimer(id)
this.members.clear()
Expand Down
Loading
Loading