Files
hapi/hub/src/web/sseCompression.ts
T
weishu 46fc83d211 feat: cut relay tunnel bandwidth with compression and SSE replay
Relay-metered traffic drops on every channel that carried avoidable
bytes; binary payloads (attachments, voice audio) are unchanged.

Hub:
- gzip /api/* JSON responses, gated by q-aware Accept-Encoding
  negotiation (explicit gzip;q=0 beats a wildcard in either order;
  hono's compress() alone matches by substring)
- enable WebSocket permessage-deflate and default flagless ws.send()
  to compressed frames - Bun negotiates the extension but compresses
  nothing unless each send opts in, and @socket.io/bun-engine never
  passes the flag (measured 96 KB terminal payload -> 608 B on wire)
- replay missed SSE events on reconnect: 256-event/2MB ring buffer,
  per-process epoch ids bound to the authenticated namespace so a
  token swap can never resume from a foreign cursor, standard
  Last-Event-ID header preferred over the ?lastEventId fallback,
  live broadcasts queued until the replay flushes to preserve order

Web:
- skip the full sessions/details/messages resync when the hub answers
  resume:ok - a phone unlock now costs a handshake plus the gap delta
  instead of refetching everything
- own every EventSource retry path: take over browser-native
  CONNECTING retries, defer reconnects while the tab is hidden, and
  raise the backoff ceiling to 5 min after repeated failures
- drop the 30s skills/slash-commands polling; refresh on demand
  without blocking the suggestion menu behind a stalled CLI RPC
- remember the websocket upgrade across terminal socket reconnects
2026-08-05 14:59:31 +08:00

143 lines
5.3 KiB
TypeScript

import zlib from 'node:zlib'
/**
* True when the client is willing to receive gzip.
*
* `Accept-Encoding: gzip;q=0` means the opposite of what a substring match
* would suggest, so parse the q-value rather than looking for the word. All
* entries are read before deciding because an explicit `gzip` entry takes
* precedence over `*` regardless of where it appears in the header (RFC 9110
* §12.5.3): `*;q=1, gzip;q=0` refuses gzip, `*;q=0, gzip;q=1` accepts it.
* Exported because the API compression middleware needs the same q-aware
* negotiation (hono's compress() matches by substring and would gzip for
* clients that explicitly refuse it).
*/
export function acceptsGzip(acceptEncoding: string | undefined): boolean {
if (!acceptEncoding) {
return false
}
let gzipQ: number | null = null
let wildcardQ: number | null = null
for (const part of acceptEncoding.split(',')) {
const [rawName, ...params] = part.split(';')
const name = rawName?.trim().toLowerCase()
if (name !== 'gzip' && name !== '*') {
continue
}
const qParam = params
.map((param) => param.trim().toLowerCase())
.find((param) => param.startsWith('q='))
// Absent or unparseable q counts as 1 (the header's default weight);
// repeated entries let the last one win.
const parsed = qParam ? Number(qParam.slice(2)) : 1
const q = Number.isFinite(parsed) ? parsed : 1
if (name === 'gzip') {
gzipQ = q
} else {
wildcardQ = q
}
}
const effective = gzipQ ?? wildcardQ
return effective !== null && effective > 0
}
/**
* Wraps an SSE response in a gzip stream when the client accepts it.
*
* SSE payloads are plain JSON with the same field names repeated on every
* event, so they compress extremely well (~75% on real traffic). The catch is
* that the standard compressors buffer: `CompressionStream` and Hono's
* `compress()` middleware only emit once the stream ends, which for a
* connection that stays open for hours means events never arrive. We therefore
* drive zlib directly and issue a Z_SYNC_FLUSH after every chunk, which costs
* about one percentage point of ratio and keeps delivery immediate.
*
* (Hono's `compress()` would skip this response anyway - it bails out when
* `Transfer-Encoding` is set, and `streamSSE` always sets it.)
*/
export function compressSseResponse(response: Response, acceptEncoding: string | undefined): Response {
if (!acceptsGzip(acceptEncoding) || !response.body) {
return response
}
const gzip = zlib.createGzip({ flush: zlib.constants.Z_SYNC_FLUSH })
const source = response.body
// Acquired synchronously so `cancel` can always reach it. Cancelling
// `source` directly would throw: it is locked for as long as we hold a
// reader, which is the whole lifetime of the connection.
const reader = source.getReader()
let resumeRead: (() => void) | null = null
const compressed = new ReadableStream<Uint8Array>({
start(controller) {
// Gate reading on downstream demand. Checking only zlib's own
// buffer is not enough: SSE compresses so well that a slow client
// can be megabytes behind while the compressed queue still looks
// nearly empty.
const awaitDemand = (): Promise<void> => {
if ((controller.desiredSize ?? 1) > 0) {
return Promise.resolve()
}
return new Promise<void>((resolve) => {
resumeRead = resolve
})
}
gzip.on('data', (chunk: Buffer) => {
controller.enqueue(new Uint8Array(chunk))
if ((controller.desiredSize ?? 1) <= 0) {
gzip.pause()
}
})
gzip.on('end', () => {
controller.close()
})
gzip.on('error', (error) => {
controller.error(error)
})
void (async () => {
try {
for (;;) {
await awaitDemand()
const { done, value } = await reader.read()
if (done) {
break
}
if (!gzip.write(Buffer.from(value))) {
await new Promise<void>((resolve) => gzip.once('drain', resolve))
}
gzip.flush(zlib.constants.Z_SYNC_FLUSH)
}
} catch (error) {
gzip.destroy(error as Error)
return
}
gzip.end()
})()
},
pull() {
gzip.resume()
const resume = resumeRead
resumeRead = null
resume?.()
},
cancel(reason) {
gzip.destroy()
resumeRead?.()
resumeRead = null
return reader.cancel(reason)
}
})
const headers = new Headers(response.headers)
headers.set('Content-Encoding', 'gzip')
headers.delete('Content-Length')
return new Response(compressed, {
status: response.status,
statusText: response.statusText,
headers
})
}