oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
4 "net/http"
5 "time"
6)
7
8// The idle policy, written down rather than emergent (spec §7). Held
9// sockets and hibernation pull against each other, and this is where
10// that is resolved.
11//
12// pingEvery keeps the connection alive through proxies that drop a
13// silent stream, and gives the client something to notice when the
14// server has gone away.
15//
16// idleTimeout closes a stream that has carried no edit. maxStreamLife
17// closes one regardless of activity, so a tab left open all weekend
18// cannot hold an instance awake and billable indefinitely.
19//
20// Spec §7 words the idle policy as "no edits and no presence
21// heartbeat". There is no heartbeat here, and the reason is worth
22// stating rather than leaving as a gap: a heartbeat POST arrives as a
23// separate request with no way to reach the specific goroutine holding
24// the stream, so it would have had to be routed through the hub by
25// connection id — real machinery to reproduce something the connection
26// itself already tells us. Instead the client CLOSES its stream when
27// the tab is hidden and opens a new one when it comes back, which is
28// strictly stronger than a heartbeat that stops: a hidden tab holds no
29// socket at all rather than an idle one.
30//
31// That only works with the client's cooperation, and it is worth being
32// plain that the server cannot enforce it: EventSource reconnects on
33// its own after any close, so a client that ignores the "bye" event
34// below will simply come straight back. The server's timeouts are
35// therefore a backstop against clients that vanished without saying so
36// — a sleeping laptop, a dropped network — and not the whole policy.
37const (
38 pingEvery = 25 * time.Second
39 idleTimeout = 15 * time.Minute
40 maxStreamLife = 4 * time.Hour
41)
42
43// handleStream is the live connection: a streamed 200 carrying
44// text/event-stream, one message per committed write on this tab.
45//
46// A streamed 200, NOT a 101 (spec §7). SSE is an ordinary response with
47// a streaming body; 101 is the WebSocket upgrade, and a transport check
48// written against 101 would never pass here. The conflation is worth
49// naming because the source document this design came from made it.
50func (a *App) handleStream(w http.ResponseWriter, r *http.Request) {
51 _, ok := a.actor(r)
52 if !ok {
53 a.notFound(w, r)
54 return
55 }
56 sheetID, tabID := sheetIDFrom(r), tabIDFrom(r)
57 if sheetID == 0 || tabID == 0 || !a.tabBelongsTo(sheetID, tabID) {
58 a.notFound(w, r)
59 return
60 }
61 // The stream carries every cell of every edit on this tab, so it
62 // needs the same grant a read of the grid does. Without this check
63 // the fan-out would be the one route that ignored sharing entirely
64 // — and the quietest possible leak, since nothing renders it.
65 if !a.streamAuthorized(r, sheetID) {
66 a.notFound(w, r)
67 return
68 }
69
70 sub := a.hub.Subscribe(tabID)
71 defer sub.Close()
72 rc := http.NewResponseController(w)
73
74 w.Header().Set("Content-Type", "text/event-stream")
75 w.Header().Set("Cache-Control", "no-store")
76 w.Header().Set("Connection", "keep-alive")
77 // The edge will happily buffer a streaming response into a complete
78 // one, which turns live sync into "everything arrives when you
79 // close the tab". This header is what stops it, and its absence is
80 // invisible in every test that does not run through a real proxy.
81 w.Header().Set("X-Accel-Buffering", "no")
82 w.WriteHeader(http.StatusOK)
83
84 // Flushed immediately after subscribing, so the client's onopen
85 // fires immediately rather than on the first edit. A grid that says
86 // "connecting" until somebody types looks broken for as long as
87 // nobody does.
88 if err := rc.Flush(); err != nil {
89 return
90 }
91
92 ping := time.NewTicker(pingEvery)
93 defer ping.Stop()
94 idle := time.NewTimer(idleTimeout)
95 defer idle.Stop()
96 life := time.NewTimer(maxStreamLife)
97 defer life.Stop()
98
99 for {
100 select {
101 case <-r.Context().Done():
102 return
103
104 case <-life.C:
105 // The ceiling. Say so rather than dropping silently: the
106 // client reconnects on a close either way, but a named
107 // reason is the difference between a debuggable log and a
108 // mystery.
109 writeEvent(w, rc, "bye", `{"reason":"max-life"}`)
110 return
111
112 case <-idle.C:
113 writeEvent(w, rc, "bye", `{"reason":"idle"}`)
114 return
115
116 case <-ping.C:
117 // A comment line: valid SSE, ignored by EventSource, and
118 // enough to keep an intermediary from calling the
119 // connection dead.
120 if _, err := w.Write([]byte(": ping\n\n")); err != nil {
121 return
122 }
123 if err := rc.Flush(); err != nil {
124 return
125 }
126
127 case m := <-sub.C():
128 // The grant is re-checked before every delivery, not only
129 // at subscribe.
130 //
131 // Authorization on a long-lived connection is a question
132 // about NOW, and this stream lives for up to
133 // maxStreamLife with every edit resetting the idle timer
134 // that would otherwise end it. Checked once, a revoked
135 // guest keeps receiving cells written after their access
136 // was taken away — which is the quietest possible leak,
137 // because the owner has done the thing that should have
138 // stopped it and sees no sign that it did not. Found by an
139 // independent review (Codex, 2026-09-05); the gate at open
140 // was real and ran exactly once.
141 //
142 // One indexed read per delivered message, not per edit on
143 // the instance: a subscriber only reaches this line when a
144 // message is already addressed to its tab.
145 if !a.streamAuthorized(r, sheetID) {
146 writeEvent(w, rc, "bye", `{"reason":"access-changed"}`)
147 return
148 }
149 if sub.Lost() {
150 // This subscriber overran its buffer and has missed at
151 // least one message, so everything after it would paint
152 // a grid with holes in it. Tell the client to refetch
153 // and stop: a reload is cheap, and a silently wrong
154 // spreadsheet is not.
155 writeEvent(w, rc, "resync", `{"reason":"overrun"}`)
156 return
157 }
158 if !writeEvent(w, rc, "cells", string(m.Data)) {
159 return
160 }
161 // A delivered edit resets the idle clock: this tab is
162 // demonstrably part of a live session.
163 resetTimer(idle, idleTimeout)
164 }
165 }
166}
167
168// writeEvent sends one SSE event and flushes it. It returns false once
169// the connection is gone, which is the only error worth distinguishing:
170// there is nobody left to tell.
171//
172// The data is written as a single line. Every payload this app sends is
173// compact JSON, which contains no newline — and a newline inside an SSE
174// data field would split one message into two, producing a truncated
175// JSON parse on the client rather than an error anybody could read.
176func writeEvent(w http.ResponseWriter, rc *http.ResponseController, event, data string) bool {
177 if _, err := w.Write([]byte("event: " + event + "\ndata: " + data + "\n\n")); err != nil {
178 return false
179 }
180 return rc.Flush() == nil
181}
182
183// resetTimer restarts a timer that may or may not have fired. Stop then
184// Reset is the shape that is correct for both.
185func resetTimer(t *time.Timer, d time.Duration) {
186 t.Stop()
187 t.Reset(d)
188}
189