// Package render is the backend's client for the internal image-render sidecar: it POSTs // a finished game's export payload and receives the rasterized PNG. The sidecar runs the // same drawing code the web client unit-tests (ui/src/lib/gameimage.ts on skia-canvas); // authentication and the signed public URL live in the server layer — this client only // carries bytes. package render import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "time" ) // maxPNG caps a render response; a scale-2 export of the largest plausible game is well // under 2 MiB, so anything bigger is a sidecar malfunction, not a valid image. const maxPNG = 8 << 20 // Client calls the render sidecar over the internal network. type Client struct { base string http *http.Client } // New returns a client for the sidecar at baseURL (e.g. http://renderer:8090). A render // of a full game takes well under a second; the timeout leaves room for a cold start. func New(baseURL string) *Client { return &Client{base: baseURL, http: &http.Client{Timeout: 15 * time.Second}} } // Render posts payload (the JSON request shape the sidecar consumes) and returns the PNG. func (c *Client) Render(ctx context.Context, payload any) ([]byte, error) { body, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("render: marshal payload: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/render", bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("render: build request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("render: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("render: sidecar status %d", resp.StatusCode) } png, err := io.ReadAll(io.LimitReader(resp.Body, maxPNG+1)) if err != nil { return nil, fmt.Errorf("render: read response: %w", err) } if len(png) > maxPNG { return nil, fmt.Errorf("render: response exceeds %d bytes", maxPNG) } return png, nil }