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-BhwSy4pj.js"></script>
<link rel="stylesheet" crossorigin href="/ZenChat/assets/index-CbGVd7dV.css">
<script type="module" crossorigin src="/ZenChat/assets/index-Ckc1AzqL.js"></script>
<link rel="stylesheet" crossorigin href="/ZenChat/assets/index-Bou1xdtq.css">
</head>
<body>
<div id="app"></div>
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ flowchart LR
UI[UI Shell]
RM[RoomManager]
CS[ChatSession]
SM[SessionMachine]
OB[Outbound]
P[Presence]
L[Transcript]
TR[SignallingTransport]
Expand All @@ -32,6 +34,8 @@ flowchart LR
end

UI --> RM --> CS
CS --> SM[SessionMachine]
CS --> OB[Outbound]
CS --> P[Presence]
CS --> L[Transcript]
CS --> TR
Expand All @@ -46,7 +50,9 @@ flowchart LR
|---|---|
| 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 | Orchestrates hello / chat / typing over an injected transport and runtime. |
| 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. |
| 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
3 changes: 0 additions & 3 deletions assets/index-BhwSy4pj.js

This file was deleted.

2 changes: 1 addition & 1 deletion assets/index-CbGVd7dV.css → assets/index-Bou1xdtq.css

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions assets/index-Ckc1AzqL.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-BhwSy4pj.js"></script>
<link rel="stylesheet" crossorigin href="/ZenChat/assets/index-CbGVd7dV.css">
<script type="module" crossorigin src="/ZenChat/assets/index-Ckc1AzqL.js"></script>
<link rel="stylesheet" crossorigin href="/ZenChat/assets/index-Bou1xdtq.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,5 +12,6 @@ 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 ACK_TIMEOUT_MS = 8000
export const SOURCE_URL = 'https://github.com/Andyccr/ZenChat'
export const PAGES_URL = 'https://andyccr.github.io/ZenChat/'
27 changes: 27 additions & 0 deletions src/core/outbound.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { Outbound } from './outbound'
import { createMemoryRuntime } from './runtime'

describe('Outbound', () => {
it('expires pending ids and ignores a late ack', () => {
const clock = createMemoryRuntime(0)
const expired: string[] = []
const outbound = new Outbound(clock.runtime, 100, (id) => expired.push(id))
outbound.expect('msg-1')
clock.advance(99)
expect(expired).toEqual([])
clock.advance(1)
expect(expired).toEqual(['msg-1'])
expect(outbound.ack('msg-1')).toBe(false)
})

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')
expect(outbound.ack('msg-2')).toBe(true)
clock.advance(100)
expect(expired).toEqual([])
})
})
38 changes: 38 additions & 0 deletions src/core/outbound.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { Runtime } from './runtime'

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

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

expect(id: string): void {
this.cancel(id)
this.timers.set(
id,
this.runtime.setTimeout(() => {
this.timers.delete(id)
this.onExpire(id)
}, this.timeoutMs),
)
}

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

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

clear(): void {
for (const id of [...this.timers.keys()]) this.cancel(id)
}
}
7 changes: 6 additions & 1 deletion src/core/presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export class Presence {
private typingTtl = TYPING_TTL_MS,
) {}

upsert(id: string, nick: string): Member {
upsert(id: string, nick: string, features?: string[]): Member {
const existing = this.members.get(id)
const now = this.runtime.now()
const member: Member = {
Expand All @@ -22,11 +22,16 @@ export class Presence {
lastSeenAt: now,
rttMs: existing?.rttMs ?? null,
typing: existing?.typing ?? false,
features: features ?? existing?.features ?? [],
}
this.members.set(id, member)
return member
}

supports(feature: string): boolean {
return this.list().some((member) => member.features.includes(feature))
}

remove(id: string): Member | undefined {
this.clearTypingTimer(id)
const member = this.members.get(id)
Expand Down
19 changes: 18 additions & 1 deletion src/core/protocol.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { createChatPayload, parsePayload } from './protocol'
import { createAckPayload, createChatPayload, createHelloPayload, parsePayload } from './protocol'

describe('parsePayload', () => {
it('accepts a well-formed chat message', () => {
Expand Down Expand Up @@ -30,4 +30,21 @@ describe('parsePayload', () => {
nick: '听雨',
})
})

it('reads hello features and ack ids, ignoring unknown types', () => {
expect(parsePayload(createHelloPayload('晚风'))).toEqual({
v: 1,
type: 'hello',
nick: '晚风',
features: ['ack'],
})
expect(parsePayload({ v: 1, type: 'hello', nick: '晚风' })).toMatchObject({ features: [] })
expect(parsePayload(createAckPayload('晚风', 'aabbccdd12345678'))).toEqual({
v: 1,
type: 'ack',
id: 'aabbccdd12345678',
nick: '晚风',
})
expect(parsePayload({ v: 1, type: 'leave', nick: '晚风' })).toBeNull()
})
})
43 changes: 36 additions & 7 deletions src/core/protocol.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { MAX_MESSAGE_LENGTH, PROTOCOL_VERSION } from '../config/app'
import { sanitizeNick } from './identity'

export const FEATURE_ACK = 'ack'

export type HelloPayload = {
v: typeof PROTOCOL_VERSION
type: 'hello'
nick: string
features: string[]
}

export type ChatPayload = {
Expand All @@ -22,7 +25,14 @@ export type TypingPayload = {
nick: string
}

export type WirePayload = HelloPayload | ChatPayload | TypingPayload
export type AckPayload = {
v: typeof PROTOCOL_VERSION
type: 'ack'
id: string
nick: string
}

export type WirePayload = HelloPayload | ChatPayload | TypingPayload | AckPayload

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
Expand All @@ -32,6 +42,16 @@ function isVersion(value: unknown): value is typeof PROTOCOL_VERSION {
return value === PROTOCOL_VERSION
}

function readFeatures(value: unknown): string[] {
if (!Array.isArray(value)) return []
return [...new Set(value.filter((item): item is string => item === FEATURE_ACK))]
}

function readId(value: unknown): string | null {
if (typeof value !== 'string' || value.length < 8 || value.length > 80) return null
return value
}

export function createChatPayload(nick: string, text: string, id: string, ts: number): ChatPayload {
return {
v: PROTOCOL_VERSION,
Expand All @@ -44,13 +64,17 @@ export function createChatPayload(nick: string, text: string, id: string, ts: nu
}

export function createHelloPayload(nick: string): HelloPayload {
return { v: PROTOCOL_VERSION, type: 'hello', nick: sanitizeNick(nick) }
return { v: PROTOCOL_VERSION, type: 'hello', nick: sanitizeNick(nick), features: [FEATURE_ACK] }
}

export function createTypingPayload(nick: string): TypingPayload {
return { v: PROTOCOL_VERSION, type: 'typing', nick: sanitizeNick(nick) }
}

export function createAckPayload(nick: string, id: string): AckPayload {
return { v: PROTOCOL_VERSION, type: 'ack', id, nick: sanitizeNick(nick) }
}

export function parsePayload(value: unknown): WirePayload | null {
if (!isRecord(value) || !isVersion(value.v) || typeof value.type !== 'string') {
return null
Expand All @@ -60,17 +84,22 @@ export function parsePayload(value: unknown): WirePayload | null {
if (!nick) return null

if (value.type === 'hello') {
return { v: PROTOCOL_VERSION, type: 'hello', nick }
return { v: PROTOCOL_VERSION, type: 'hello', nick, features: readFeatures(value.features) }
}

if (value.type === 'typing') {
return { v: PROTOCOL_VERSION, type: 'typing', nick }
}

if (value.type === 'ack') {
const id = readId(value.id)
if (!id) return null
return { v: PROTOCOL_VERSION, type: 'ack', id, nick }
}

if (value.type === 'chat') {
if (typeof value.id !== 'string' || value.id.length < 8 || value.id.length > 80) {
return null
}
const id = readId(value.id)
if (!id) return null
if (typeof value.ts !== 'number' || !Number.isFinite(value.ts)) {
return null
}
Expand All @@ -79,7 +108,7 @@ export function parsePayload(value: unknown): WirePayload | null {
}
const text = value.text.trim().slice(0, MAX_MESSAGE_LENGTH)
if (!text) return null
return { v: PROTOCOL_VERSION, type: 'chat', id: value.id, ts: value.ts, nick, text }
return { v: PROTOCOL_VERSION, type: 'chat', id, ts: value.ts, nick, text }
}

return null
Expand Down
5 changes: 5 additions & 0 deletions src/core/room-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ describe('RoomManager', () => {
expect(rm.getSession()?.isJoined()).toBe(true)
})

it('returns closed when sending without a live session', async () => {
const { rm } = manager()
expect(await rm.sendChat('hi')).toBe('closed')
})

it('retries after a failed join instead of sticking on the dead session', async () => {
const clock = createMemoryRuntime()
let fail = true
Expand Down
20 changes: 19 additions & 1 deletion src/core/room-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { loadRoomLog, saveRoomLog } from './cache'
import { rememberRoom } from './recent'
import { sameRoom } from './room'
import { ChatSession, type SessionListener, type SessionOptions } from './session'
import type { Identity, RoomSpec } from './types'
import type { Identity, RoomSpec, SendResult } from './types'

export class RoomManager {
private identity: Identity
Expand All @@ -26,6 +26,24 @@ export class RoomManager {
return this.session
}

selfId(): string {
return this.session?.selfId ?? this.identity.id
}

sendChat(text: string): Promise<SendResult> {
return this.session?.sendChat(text) ?? Promise.resolve('closed')
}

sendTyping(): void {
this.session?.sendTyping()
}

retry(): Promise<ChatSession | null> {
const spec = this.spec
if (!spec) return Promise.resolve(null)
return this.open(spec, true)
}

setIdentity(identity: Identity): void {
this.identity = identity
this.session?.setNick(identity.nick)
Expand Down
31 changes: 31 additions & 0 deletions src/core/session-machine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { idleLife, isJoined, reduceLife, uiPhase } from './session-machine'

describe('session machine', () => {
it('joins to waiting, then live, then waiting when the last peer leaves', () => {
let life = reduceLife(idleLife(), { type: 'join' })
expect(life.state).toBe('joining')
expect(uiPhase(life)).toBe('connecting')
life = reduceLife(life, { type: 'join_ok' })
expect(life.state).toBe('waiting')
life = reduceLife(life, { type: 'peers', count: 1 })
expect(life.state).toBe('live')
expect(uiPhase(life)).toBe('connected')
life = reduceLife(life, { type: 'peers', count: 0 })
expect(life.state).toBe('waiting')
})

it('marks failed joins as not joined and maps closed relays to error', () => {
let life = reduceLife(idleLife(), { type: 'join' })
life = reduceLife(life, { type: 'join_err' })
expect(life.state).toBe('failed')
expect(isJoined(life)).toBe(false)
expect(uiPhase(life)).toBe('error')

life = reduceLife(idleLife(), { type: 'join' })
life = reduceLife(life, { type: 'join_ok' })
life = reduceLife(life, { type: 'relays', down: true })
expect(life.state).toBe('relay_down')
expect(uiPhase(life)).toBe('error')
})
})
Loading
Loading