Files
Warp/server/internal/syncapi/token.go
T
tajniak81andClaude Opus 5.5 b6f3be6ac4
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
Web client (warp-web) with E2EE, and two sync fixes
apps/web: a Matrix client for the browser on Vue 3, Tailwind and
matrix-js-sdk, built on @warp/ui. Like the admin panel it is an optional,
separate service: warp-web embeds the built app, serves /config.json and
proxies only /_matrix/client and /_matrix/media to the homeserver (or,
with -public-homeserver, lets the browser call it directly).
Sign-in and sign-up with registration tokens, room list, timeline with
back-pagination, text, images, video and files over authenticated media,
local echo with sent/read/failed states, receipts, typing, invites,
creating, joining and leaving rooms, room info, settings, phone layout.
End-to-end encryption through the Rust crypto: encrypted messages and
files, emoji verification, key backup setup and restore.

apps/ui: WRoomItem, WMessageBubble and WComposer from the brand package,
and the icons the client needs.

syncapi fixes found with the client:
- A room joined inside the sync window was sent as the join alone with
  limited: false, so clients thought they had the whole room. It is now
  read like an initial sync; a profile change is not mistaken for a join.
- A live typing notification was reported on every sync, so every long
  poll returned at once while anybody typed. Typing now has a position
  in the sync token and is reported when it changes, including the empty
  list when the last person stops.

Also: Dockerfile, a `web` compose profile, Makefile targets, a CI job
with a proxy smoke test, docs. PLAN.md moves out of the repository.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:22:11 +02:00

158 lines
5.2 KiB
Go

package syncapi
import (
"fmt"
"strconv"
"strings"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Token is a position in every stream a sync response carries.
//
// A sync response is assembled from streams that move independently: room
// events, read receipts, to-device messages, account data and presence. A
// client that has seen receipt 7 but only event 4 has to be able to say so
// in the one opaque string the API gives it, so the token names a position
// in each.
//
// The wire form is
//
// s{pdu}_{receipt}_{to_device}_{account_data}_{presence}_{device_list}_{notification}_{typing}
//
// with trailing zeroes left off, so a client that has only ever seen room
// events carries "s12" rather than "s12_0_0_0_0_0_0". The device list
// position came with Phase 7 and the notification count position with
// Phase 8 and the typing position after them, each added at the end,
// which is what lets every token handed out before them go on parsing. The leading "s" is there
// to make a token recognisable in a log and to leave room for a second
// token shape later — Simplified Sliding Sync (PLAN.md phase 10) has its
// own — without any ambiguity about which is which.
//
// The spec says only that the token is opaque, and clients treat it that
// way. It is still worth being readable: a token is the first thing in
// every bug report about /sync.
type Token struct {
PDU int64
Receipt int64
ToDevice int64
AccountData int64
Presence int64
DeviceList int64
// Notification is the push server's notification count stream.
Notification int64
// Typing is the typing tracker's position. It lives in memory and
// starts again at zero when the server restarts, so it is compared
// for difference, never for order.
Typing int64
}
// tokenPositions is how many positions a token carries.
const tokenPositions = 8
// tokenPrefix marks a classic /sync token.
const tokenPrefix = "s"
// IsInitial reports whether the token is the start of every stream, which
// is what a sync with no ?since= means.
func (t Token) IsInitial() bool { return t == Token{} }
// String renders the wire form.
func (t Token) String() string {
parts := []int64{t.PDU, t.Receipt, t.ToDevice, t.AccountData, t.Presence, t.DeviceList, t.Notification, t.Typing}
// Trailing zeroes carry no information, and leaving them off keeps the
// common token short.
last := 0
for i, p := range parts {
if p != 0 {
last = i
}
}
var b strings.Builder
b.WriteString(tokenPrefix)
for i, p := range parts[:last+1] {
if i > 0 {
b.WriteByte('_')
}
b.WriteString(strconv.FormatInt(p, 10))
}
return b.String()
}
// ParseToken reads a token a client sent back.
//
// An empty string is the initial token: /sync without ?since= and
// /messages without ?from= both mean "from the beginning", and refusing
// the empty string would make every caller special-case it.
//
// A token with fewer fields than this build writes is accepted and the
// missing ones are zero. That is what lets a token stored by a client
// before a stream existed go on working: the stream it did not know about
// reads as "seen nothing", so the client is sent that stream in full once
// and is in step from then on.
func ParseToken(raw string) (Token, error) {
if raw == "" {
return Token{}, nil
}
body, ok := strings.CutPrefix(raw, tokenPrefix)
if !ok {
return Token{}, fmt.Errorf("%q is not a sync token: it must start with %q", raw, tokenPrefix)
}
fields := strings.Split(body, "_")
if len(fields) > tokenPositions {
return Token{}, fmt.Errorf("%q is not a sync token: it has %d positions, this server writes at most %d",
raw, len(fields), tokenPositions)
}
var positions [tokenPositions]int64
for i, field := range fields {
value, err := strconv.ParseInt(field, 10, 64)
// The PDU position alone may be negative: /messages hands out
// positions inside history backfilled from other servers, which
// is numbered downwards from -1. Every other stream starts at 0.
if err != nil || (value < 0 && i > 0) {
return Token{}, fmt.Errorf("%q is not a sync token: position %d is %q", raw, i+1, field)
}
positions[i] = value
}
return Token{
PDU: positions[0],
Receipt: positions[1],
ToDevice: positions[2],
AccountData: positions[3],
Presence: positions[4],
DeviceList: positions[5],
Notification: positions[6],
Typing: positions[7],
}, nil
}
// tokenAt is the token naming the top of every stream, which is what a
// sync response hands back when it has drained all of them.
func tokenAt(positions storage.StreamPositions) Token {
return Token{
PDU: positions.PDU,
Receipt: positions.Receipt,
ToDevice: positions.ToDevice,
AccountData: positions.AccountData,
Presence: positions.Presence,
DeviceList: positions.DeviceList,
Notification: positions.Notification,
}
}
// pduToken is the token for a position in the room event stream alone.
//
// It is what /messages and a room's prev_batch hand out: both are about
// room events only, and a token from either has to be usable as a ?since=
// for /sync, where the other streams then read as unseen. That is the
// conservative direction — the client is sent those streams again rather
// than having them skipped.
func pduToken(pos int64) Token { return Token{PDU: pos} }