package main
// Watching a camera in another building, from the app.
//
// The shop PC sits behind a router with no inbound route, so nothing here can
// pull its MJPEG stream - that stream is served on the shop PC's own loopback
// and always will be. Head office's LiveHub is the way round it: the agent
// asks outbound whether anyone is watching and pushes JPEG frames up for
// exactly as long as somebody is. The head-office web app already consumes
// that; this is the same feed, for the app.
//
// It arrives as base64 frames over SSE, which an
cannot render, so this
// re-emits them as multipart MJPEG - which an
renders natively, through
// the relay that already exists for the local engine. That is what keeps ONE
// code path in the screens: a tile points at a loopback URL and does not know
// or care which building the picture came from.
import (
"bufio"
"context"
"encoding/base64"
"fmt"
"net/http"
"strings"
"time"
)
// The boundary is ours to choose; it only has to be a string the JPEG bytes
// cannot contain, and a marker line never appears inside JPEG data.
const mjpegBoundary = "behavisionframe"
// A frame is base64, so ~1.33 bytes on the wire per byte of picture. The
// engine re-encodes to 640 px for the relay and those measure ~20 KB, so this
// is roughly a hundredfold headroom - large enough never to clip a real frame
// and small enough that a broken or hostile stream cannot grow this process's
// memory without bound.
const maxFrameLine = 8 << 20
func (p *streamProxy) relayRemote(w http.ResponseWriter, r *http.Request,
cameraID string, open func(context.Context, string) (*http.Response, error)) {
w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary="+mjpegBoundary)
w.Header().Set("Cache-Control", "no-store")
flusher, _ := w.(http.Flusher)
// Send the headers NOW, before any frame exists. Go writes them on the
// first body write, so without this the whole response - status line
// included - waits for the shop computer to start pushing, and a viewer
// whose camera is slow to answer sees the REQUEST time out rather than a
// stream that has not painted yet. Measured against production: 30
// seconds and not even a Content-Type.
if flusher != nil {
flusher.Flush()
}
// Reconnecting is normal, not an error. The server caps one push at five
// minutes so that a tab left open for a week cannot leave a shop
// uploading for a week - so a viewer who IS still there simply asks
// again. Doing it here rather than in the page is what lets the
// survive the cap: it never sees the stream end.
sent := 0
for {
if r.Context().Err() != nil {
return
}
n, err := p.pumpRemote(w, flusher, r.Context(), cameraID, open)
sent += n
if r.Context().Err() != nil {
return
}
// Nothing was written and the attempt failed. Writing an error body
// now would be writing it into a multipart stream the
is
// already parsing, so the picture simply stays on whatever it last
// showed and the screen's own "not connecting" state is the report.
if err != nil && sent == 0 {
return
}
select {
case <-r.Context().Done():
return
case <-time.After(1500 * time.Millisecond):
}
}
}
// pumpRemote runs one SSE connection to exhaustion and returns how many
// frames it forwarded.
func (p *streamProxy) pumpRemote(w http.ResponseWriter, flusher http.Flusher,
ctx context.Context, cameraID string,
open func(context.Context, string) (*http.Response, error)) (int, error) {
resp, err := open(ctx, cameraID)
if err != nil {
return 0, err
}
defer resp.Body.Close()
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), maxFrameLine)
var event, data string
frames := 0
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = strings.TrimSpace(line[7:])
case strings.HasPrefix(line, "data: "):
data = line[6:]
case line == "":
// End of one SSE event. `waiting` means head office has us
// registered and the shop PC has not started pushing yet - a real
// second or two while the agent is asked, and nothing to draw.
if event == "frame" && data != "" {
if err := writeMJPEGFrame(w, flusher, data); err != nil {
return frames, err // the webview went away
}
frames++
}
event, data = "", ""
}
}
return frames, sc.Err()
}
func writeMJPEGFrame(w http.ResponseWriter, flusher http.Flusher, b64 string) error {
jpg, err := base64.StdEncoding.DecodeString(b64)
if err != nil || len(jpg) == 0 {
// One malformed frame is not a reason to tear down a working view.
return nil
}
if _, err := fmt.Fprintf(w,
"--%s\r\nContent-Type: image/jpeg\r\nContent-Length: %d\r\n\r\n",
mjpegBoundary, len(jpg)); err != nil {
return err
}
if _, err := w.Write(jpg); err != nil {
return err
}
if _, err := w.Write([]byte("\r\n")); err != nil {
return err
}
// Flushed per frame. Anything held waiting for a full buffer is a tile
// that stays blank, which is indistinguishable from the view not working.
if flusher != nil {
flusher.Flush()
}
return nil
}