diff --git a/README.md b/README.md
index c439e3e..91df42a 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,7 @@ Cubalyst は記録を [MCP](https://modelcontextprotocol.io/) のツールとし
- **持ち方に合わせた表記**:解法・再生・解析の記号を、自分の持ち方(下の色・前の色)に合わせて読み替えます
- **持ち替えの記録**:ジャイロのあるキューブ(GAN i4 などの Gen4)なら、ソルブ中の持ち替え(y・x・z)も記録し、解法をその時点の持ち方で表示します。設定でオンにし、キューブ側のジャイロは GAN のアプリの省電力の設定で切り替えます(ジャイロは電池を多く使います)
+- **中継**:キューブをつないだブラウザのタイマー画面(スクランブル・タイマー・キューブの状態・直前の段階)を、ほかのブラウザ(スマホなど)の「中継」(`/live`)に映します。見るだけで、記録も操作もしません。状態はサーバーのメモリにだけ持ちます
- **クロス色**を設定でき、白以外のクロスでも段階を正しく分けます(設定は右上の ⚙ から開きます)
- 記録は手元の SQLite ファイルに保存し、外部のサービスには送りません
diff --git a/cmd/cubalyst/main.go b/cmd/cubalyst/main.go
index 80656ed..f273825 100644
--- a/cmd/cubalyst/main.go
+++ b/cmd/cubalyst/main.go
@@ -54,11 +54,14 @@ func run() error {
}
log.Printf("db: %s (schema version %d)", cfg.DBPath, v)
+ app := server.New(database, cfg.StaticDir)
srv := &http.Server{
Addr: cfg.Addr,
- Handler: server.New(database, cfg.StaticDir).Handler(),
+ Handler: app.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
+ // 止めるとき、タイマー画面の中継(SSE)の接続を切る。切らないと Shutdown が終わるのを待ち続ける
+ srv.RegisterOnShutdown(app.Close)
errCh := make(chan error, 1)
go func() {
log.Printf("listening on http://%s", cfg.Addr)
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index a4c27d2..ef31cce 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -28,6 +28,8 @@ tags:
- name: 入出力
- name: 集計
description: 期間の練習の集計。文面にするのは呼ぶ側(通知の本文を作る kawaraban など)
+ - name: 中継
+ description: キューブをつないだブラウザ(メイン)のタイマー画面を、ほかのブラウザ(`/live`)に映す。状態はサーバーのメモリにだけ持つ
- name: その他
paths:
@@ -467,6 +469,46 @@ paths:
schema: { $ref: "#/components/schemas/PracticeStats" }
"400": { $ref: "#/components/responses/BadRequest" }
+ /api/live/events:
+ post:
+ tags: [中継]
+ summary: メインのタイマー画面の出来事を送る
+ description: |
+ キューブをつないだブラウザ(メイン)が、タイマー画面の出来事を順に送る。見る側(`GET /api/live/stream`)にそのまま流す。
+ 何もなくても15秒ごとに送り(`events` は空でよい)、40秒届かなければメインはいなくなったとみなす。
+ メインは1つの前提で、2つのブラウザから送ると混ざる。
+ operationId: publishLiveEvents
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [events]
+ properties:
+ events:
+ type: array
+ items: { $ref: "#/components/schemas/LiveEvent" }
+ responses:
+ "204": { description: 受け取った }
+ "400": { $ref: "#/components/responses/BadRequest" }
+
+ /api/live/stream:
+ get:
+ tags: [中継]
+ summary: メインのタイマー画面の状態を受け取り続ける(SSE)
+ description: |
+ Server-Sent Events。各出来事は `data:` の1行の JSON(LiveMessage)。
+ 最初に今の状態(`snapshot`)を送り、そのあとメインから届いた出来事と、15秒ごとの `ping` を送る。
+ 読むのが遅いと切るので、つなぎ直す(EventSource は自動でつなぎ直し、また `snapshot` から受け取る)。
+ operationId: liveStream
+ responses:
+ "200":
+ description: 出来事の流れ
+ content:
+ text/event-stream:
+ schema: { $ref: "#/components/schemas/LiveMessage" }
+
components:
parameters:
period:
@@ -517,6 +559,52 @@ components:
schema: { $ref: "#/components/schemas/Error" }
schemas:
+ LiveEvent:
+ type: object
+ description: |
+ メインから届く出来事。
+ - main:キューブの接続の状態(cube)
+ - move:1手の回転(move)と、回したあとの状態(facelets)
+ - facelets:状態の置き換え(実機に合わせ直したときなど)
+ - session:タイマーの状態(LiveSession)
+ - bye:メインが画面を閉じた
+ required: [type]
+ properties:
+ type: { type: string, enum: [main, move, facelets, session, bye] }
+ cube: { type: string, enum: [connected, connecting, disconnected] }
+ move: { type: string, example: "R'" }
+ facelets:
+ type: string
+ description: キューブの状態。URFDLB の54文字(Kociemba の並び)
+ example: UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB
+ session: { $ref: "#/components/schemas/LiveSession" }
+ LiveMessage:
+ type: object
+ description: |
+ 見る側に送る出来事。LiveEvent の main・move・facelets・session をそのまま送るほか、
+ - snapshot:見始めたときの今の状態(online・cube・facelets・session・age_ms)
+ - ping:メインが生きているか(online)
+ - main:bye のときは online=false で送る
+ required: [type]
+ properties:
+ type: { type: string, enum: [snapshot, ping, main, move, facelets, session] }
+ online: { type: boolean, description: メインがいるか }
+ cube: { type: string, enum: [connected, connecting, disconnected] }
+ move: { type: string }
+ facelets: { type: string }
+ session: { $ref: "#/components/schemas/LiveSession" }
+ age_ms: { type: integer, description: session を受け取ってからの時間(snapshot のときだけ)。経過時間をずらすのに使う }
+ LiveSession:
+ type: object
+ description: |
+ タイマーの状態。中身は画面(web/src/live/protocol.ts)が決め、サーバーは確かめずにそのまま流す。
+ 時刻は端末ごとに基準が違うので、`*_ago_ms`(送った時点で、計測やインスペクションが始まってから何 ms たったか)で送る。
+ additionalProperties: true
+ properties:
+ phase: { type: string, enum: [generating, scrambling, ready, running] }
+ scramble: { type: [string, "null"] }
+ started_ago_ms: { type: [integer, "null"] }
+ ready_ago_ms: { type: [integer, "null"] }
PracticeStats:
type: object
required: [period, count, full, stages, days, practice]
diff --git a/internal/live/hub.go b/internal/live/hub.go
new file mode 100644
index 0000000..aa256ae
--- /dev/null
+++ b/internal/live/hub.go
@@ -0,0 +1,228 @@
+// Package live は、キューブをつないだブラウザ(メイン)のタイマー画面の状態を、ほかのブラウザに中継する。
+//
+// メインは出来事(回転・状態・タイマー)を POST で送り、見る側は SSE で受け取る。
+// 状態はメモリにだけ持ち、DB には書かない(サーバーを再起動すると消える)。
+package live
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+)
+
+// StaleAfter は、メインから何も届かないとき、メインがいなくなったとみなすまでの時間。
+// メインは何もなくても15秒ごとに送ってくる。
+const StaleAfter = 40 * time.Second
+
+// subscriberBuffer は、見る側1つに溜めておける出来事の数。溢れたら切り、つなぎ直してもらう(最初の状態から送り直す)。
+const subscriberBuffer = 256
+
+// maxSession は、タイマーの状態(JSON)の大きさの上限。
+const maxSession = 64 << 10
+
+// Event はメインから届く出来事。
+//
+// - main:メインのキューブの接続の状態(cube)。何もなくても送り、生きていることを知らせる
+// - move:1手の回転(move)と、回したあとの状態(facelets)
+// - facelets:状態の置き換え(実機に合わせ直したときなど)
+// - session:タイマーの状態(中身はアプリが決める JSON のオブジェクト)
+// - bye:メインが画面を閉じた
+type Event struct {
+ Type string `json:"type"`
+ Cube string `json:"cube,omitempty"`
+ Move string `json:"move,omitempty"`
+ Facelets string `json:"facelets,omitempty"`
+ Session json.RawMessage `json:"session,omitempty"`
+}
+
+// Message は見る側に送る出来事。snapshot は見始めたときの今の状態、ping は生きているかの知らせ。
+type Message struct {
+ Type string `json:"type"`
+ Online *bool `json:"online,omitempty"`
+ Cube string `json:"cube,omitempty"`
+ Move string `json:"move,omitempty"`
+ Facelets string `json:"facelets,omitempty"`
+ Session json.RawMessage `json:"session,omitempty"`
+ // AgeMS は session を受け取ってからの時間(snapshot のときだけ)。タイマーの経過時間をずらすのに使う。
+ AgeMS int64 `json:"age_ms,omitempty"`
+}
+
+// Hub はメインの最新の状態と、見る側の一覧を持つ。
+type Hub struct {
+ now func() time.Time
+
+ mu sync.Mutex
+ lastSeen time.Time
+ bye bool
+ cube string
+ facelets string
+ session json.RawMessage
+ sessionAt time.Time
+ subs map[chan []byte]struct{}
+ closed bool
+}
+
+// NewHub は Hub を作る。
+func NewHub() *Hub {
+ return &Hub{now: time.Now, subs: map[chan []byte]struct{}{}}
+}
+
+// ErrInvalid は、メインから届いた出来事が正しくないときのエラー。
+var ErrInvalid = errors.New("invalid live event")
+
+func invalid(format string, a ...any) error {
+ return fmt.Errorf("%w: %s", ErrInvalid, fmt.Sprintf(format, a...))
+}
+
+// Validate は出来事の中身を確かめる。
+func (e Event) Validate() error {
+ switch e.Type {
+ case "main":
+ switch e.Cube {
+ case "connected", "connecting", "disconnected":
+ default:
+ return invalid("cube must be connected, connecting or disconnected")
+ }
+ case "move":
+ if e.Move == "" || len(e.Move) > 4 {
+ return invalid("move is required")
+ }
+ if err := validFacelets(e.Facelets); err != nil {
+ return err
+ }
+ case "facelets":
+ if err := validFacelets(e.Facelets); err != nil {
+ return err
+ }
+ case "session":
+ if len(e.Session) > maxSession {
+ return invalid("session is too large")
+ }
+ if t := bytes.TrimSpace(e.Session); len(t) == 0 || t[0] != '{' || !json.Valid(t) {
+ return invalid("session must be a JSON object")
+ }
+ case "bye":
+ default:
+ return invalid("unknown type %q", e.Type)
+ }
+ return nil
+}
+
+func validFacelets(f string) error {
+ if len(f) != 54 || strings.Trim(f, "URFDLB") != "" {
+ return invalid("facelets must be 54 letters of URFDLB")
+ }
+ return nil
+}
+
+// Publish はメインから届いた出来事を、状態に反映して見る側に送る。先に Validate で確かめておく。
+func (h *Hub) Publish(events []Event) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ now := h.now()
+ h.lastSeen = now
+ h.bye = false
+ for _, e := range events {
+ msg := Message{Type: e.Type}
+ switch e.Type {
+ case "main":
+ h.cube = e.Cube
+ msg.Online, msg.Cube = boolPtr(true), e.Cube
+ case "move", "facelets":
+ h.facelets = e.Facelets
+ msg.Move, msg.Facelets = e.Move, e.Facelets
+ case "session":
+ h.session, h.sessionAt = compact(e.Session), now
+ msg.Session = h.session
+ case "bye":
+ h.bye = true
+ msg = Message{Type: "main", Online: boolPtr(false), Cube: h.cube}
+ }
+ h.broadcast(msg)
+ }
+}
+
+// Subscribe は見る側を足し、最初に送る今の状態と、続く出来事を受け取るチャネルを返す。
+// チャネルが閉じたら(溢れたとき・Close のとき)接続を終える。終えるときは unsubscribe を呼ぶ。
+func (h *Hub) Subscribe() (snapshot []byte, ch <-chan []byte, unsubscribe func()) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ c := make(chan []byte, subscriberBuffer)
+ if h.closed {
+ close(c)
+ return nil, c, func() {}
+ }
+ h.subs[c] = struct{}{}
+ now := h.now()
+ snap := Message{Type: "snapshot", Online: boolPtr(h.online(now)), Cube: h.cube, Facelets: h.facelets, Session: h.session}
+ if h.session != nil {
+ snap.AgeMS = max(1, now.Sub(h.sessionAt).Milliseconds())
+ }
+ return marshal(snap), c, func() {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if _, ok := h.subs[c]; ok {
+ delete(h.subs, c)
+ close(c)
+ }
+ }
+}
+
+// Ping は、メインが生きているかの知らせ(見る側に定期的に送る)。
+func (h *Hub) Ping() []byte {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return marshal(Message{Type: "ping", Online: boolPtr(h.online(h.now()))})
+}
+
+// Close は見る側をすべて切る(サーバーを止めるとき)。
+func (h *Hub) Close() {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.closed = true
+ for c := range h.subs {
+ delete(h.subs, c)
+ close(c)
+ }
+}
+
+func (h *Hub) online(now time.Time) bool {
+ return !h.lastSeen.IsZero() && !h.bye && now.Sub(h.lastSeen) < StaleAfter
+}
+
+// broadcast は h.mu を持ったまま呼ぶ。
+func (h *Hub) broadcast(msg Message) {
+ b := marshal(msg)
+ for c := range h.subs {
+ select {
+ case c <- b:
+ default:
+ // 読むのが遅い見る側は切る(つなぎ直すと、今の状態から送り直す)
+ delete(h.subs, c)
+ close(c)
+ }
+ }
+}
+
+func marshal(m Message) []byte {
+ b, err := json.Marshal(m)
+ if err != nil {
+ panic(err) // Message は必ず JSON にできる
+ }
+ return b
+}
+
+// compact は JSON を1行にする(SSE の data は1行で送る)。
+func compact(raw json.RawMessage) json.RawMessage {
+ var buf bytes.Buffer
+ if err := json.Compact(&buf, raw); err != nil {
+ return raw
+ }
+ return buf.Bytes()
+}
+
+func boolPtr(b bool) *bool { return &b }
diff --git a/internal/live/hub_test.go b/internal/live/hub_test.go
new file mode 100644
index 0000000..46f1e93
--- /dev/null
+++ b/internal/live/hub_test.go
@@ -0,0 +1,170 @@
+package live
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+)
+
+const solved = "UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB"
+
+func newTestHub() (*Hub, *time.Time) {
+ now := time.Date(2026, 11, 2, 12, 0, 0, 0, time.UTC)
+ h := NewHub()
+ h.now = func() time.Time { return now }
+ return h, &now
+}
+
+func decode(t *testing.T, b []byte) Message {
+ t.Helper()
+ var m Message
+ if err := json.Unmarshal(b, &m); err != nil {
+ t.Fatalf("%s: %v", b, err)
+ }
+ return m
+}
+
+func recv(t *testing.T, ch <-chan []byte) Message {
+ t.Helper()
+ select {
+ case b, ok := <-ch:
+ if !ok {
+ t.Fatal("チャネルが閉じた")
+ }
+ return decode(t, b)
+ default:
+ t.Fatal("出来事が届いていない")
+ }
+ return Message{}
+}
+
+func TestSnapshotBeforeMain(t *testing.T) {
+ h, _ := newTestHub()
+ snap, _, unsub := h.Subscribe()
+ defer unsub()
+ m := decode(t, snap)
+ if m.Type != "snapshot" || m.Online == nil || *m.Online || m.Session != nil || m.AgeMS != 0 {
+ t.Fatalf("%+v", m)
+ }
+}
+
+func TestPublishAndSnapshot(t *testing.T) {
+ h, now := newTestHub()
+ _, ch, unsub := h.Subscribe()
+ defer unsub()
+
+ h.Publish([]Event{
+ {Type: "main", Cube: "connected"},
+ {Type: "move", Move: "R", Facelets: solved},
+ {Type: "session", Session: json.RawMessage("{\n \"phase\": \"running\"\n}")},
+ })
+ if m := recv(t, ch); m.Type != "main" || !*m.Online || m.Cube != "connected" {
+ t.Fatalf("main: %+v", m)
+ }
+ if m := recv(t, ch); m.Type != "move" || m.Move != "R" || m.Facelets != solved {
+ t.Fatalf("move: %+v", m)
+ }
+ if m := recv(t, ch); m.Type != "session" || string(m.Session) != `{"phase":"running"}` || m.AgeMS != 0 {
+ t.Fatalf("session: %+v", m)
+ }
+
+ // あとから見始めた人には、今の状態と、タイマーの状態を受け取ってからの時間を送る
+ *now = now.Add(1500 * time.Millisecond)
+ snap, _, unsub2 := h.Subscribe()
+ defer unsub2()
+ m := decode(t, snap)
+ if !*m.Online || m.Cube != "connected" || m.Facelets != solved || string(m.Session) != `{"phase":"running"}` || m.AgeMS != 1500 {
+ t.Fatalf("snapshot: %+v", m)
+ }
+}
+
+func TestOnline(t *testing.T) {
+ h, now := newTestHub()
+ _, ch, unsub := h.Subscribe()
+ defer unsub()
+ h.Publish([]Event{{Type: "main", Cube: "connected"}})
+ recv(t, ch)
+
+ *now = now.Add(StaleAfter - time.Second)
+ if m := decode(t, h.Ping()); !*m.Online {
+ t.Fatal("まだ生きているはず")
+ }
+ *now = now.Add(2 * time.Second)
+ if m := decode(t, h.Ping()); *m.Online {
+ t.Fatal("届かなくなったら、いなくなったとみなす")
+ }
+
+ // 何も無い POST(生きている知らせ)で戻る
+ h.Publish(nil)
+ if m := decode(t, h.Ping()); !*m.Online {
+ t.Fatal("届いたら戻る")
+ }
+
+ h.Publish([]Event{{Type: "bye"}})
+ if m := recv(t, ch); m.Type != "main" || *m.Online {
+ t.Fatalf("bye: %+v", m)
+ }
+ if m := decode(t, h.Ping()); *m.Online {
+ t.Fatal("閉じたらいない")
+ }
+}
+
+func TestSlowSubscriberIsDropped(t *testing.T) {
+ h, _ := newTestHub()
+ _, ch, unsub := h.Subscribe()
+ defer unsub()
+ for range subscriberBuffer + 1 {
+ h.Publish([]Event{{Type: "move", Move: "R", Facelets: solved}})
+ }
+ n := 0
+ for range ch {
+ n++
+ }
+ if n != subscriberBuffer {
+ t.Fatalf("got %d", n)
+ }
+}
+
+func TestClose(t *testing.T) {
+ h, _ := newTestHub()
+ _, ch, unsub := h.Subscribe()
+ h.Close()
+ if _, ok := <-ch; ok {
+ t.Fatal("閉じるはず")
+ }
+ unsub() // 二重に閉じない
+ if snap, _, _ := h.Subscribe(); snap != nil {
+ t.Fatal("閉じたあとは受け付けない")
+ }
+}
+
+func TestValidate(t *testing.T) {
+ ok := []Event{
+ {Type: "main", Cube: "disconnected"},
+ {Type: "move", Move: "U'", Facelets: solved},
+ {Type: "facelets", Facelets: solved},
+ {Type: "session", Session: json.RawMessage(`{}`)},
+ {Type: "bye"},
+ }
+ for _, e := range ok {
+ if err := e.Validate(); err != nil {
+ t.Errorf("%+v: %v", e, err)
+ }
+ }
+ bad := []Event{
+ {Type: "nope"},
+ {Type: "main", Cube: "maybe"},
+ {Type: "move", Facelets: solved},
+ {Type: "move", Move: "R", Facelets: "UUU"},
+ {Type: "facelets", Facelets: strings.Repeat("X", 54)},
+ {Type: "session", Session: json.RawMessage(`[1]`)},
+ {Type: "session"},
+ {Type: "session", Session: json.RawMessage(`{"a":"` + strings.Repeat("x", maxSession) + `"}`)},
+ }
+ for _, e := range bad {
+ if err := e.Validate(); err == nil {
+ t.Errorf("%+v: エラーになるはず", e)
+ }
+ }
+}
diff --git a/internal/server/live.go b/internal/server/live.go
new file mode 100644
index 0000000..32c4af8
--- /dev/null
+++ b/internal/server/live.go
@@ -0,0 +1,81 @@
+package server
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/riwn/cubalyst/internal/live"
+)
+
+// livePing は、見る側に生きているかを知らせる間隔(プロキシに切られないためでもある)。
+var livePing = 15 * time.Second
+
+type liveEventsRequest struct {
+ Events []live.Event `json:"events"`
+}
+
+// handleLiveEvents は、メイン(キューブをつないだブラウザ)からタイマー画面の出来事を受け取る。
+func (s *Server) handleLiveEvents(w http.ResponseWriter, r *http.Request) {
+ var in liveEventsRequest
+ if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&in); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json: " + err.Error()})
+ return
+ }
+ for i, e := range in.Events {
+ if err := e.Validate(); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("events[%d]: %v", i, err)})
+ return
+ }
+ }
+ s.live.Publish(in.Events)
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// handleLiveStream は、見る側にメインの状態を SSE で送り続ける。最初に今の状態(snapshot)を送る。
+func (s *Server) handleLiveStream(w http.ResponseWriter, r *http.Request) {
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "streaming is not supported"})
+ return
+ }
+ snapshot, ch, unsubscribe := s.live.Subscribe()
+ defer unsubscribe()
+ if snapshot == nil {
+ writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "shutting down"})
+ return
+ }
+ h := w.Header()
+ h.Set("Content-Type", "text/event-stream")
+ h.Set("Cache-Control", "no-cache")
+ h.Set("X-Accel-Buffering", "no")
+ w.WriteHeader(http.StatusOK)
+ send := func(b []byte) bool {
+ if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil {
+ return false
+ }
+ flusher.Flush()
+ return true
+ }
+ if !send(snapshot) {
+ return
+ }
+ ticker := time.NewTicker(livePing)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-r.Context().Done():
+ return
+ case b, ok := <-ch:
+ if !ok || !send(b) {
+ return
+ }
+ case <-ticker.C:
+ if !send(s.live.Ping()) {
+ return
+ }
+ }
+ }
+}
diff --git a/internal/server/live_test.go b/internal/server/live_test.go
new file mode 100644
index 0000000..d586b10
--- /dev/null
+++ b/internal/server/live_test.go
@@ -0,0 +1,84 @@
+package server
+
+import (
+ "bufio"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+const solvedFacelets = "UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB"
+
+func TestLiveEventsRejectsInvalid(t *testing.T) {
+ h := newTestServer(t, t.TempDir())
+ for _, body := range []string{`{`, `{"events":[{"type":"nope"}]}`, `{"events":[{"type":"move","move":"R"}]}`} {
+ if rec := do(t, h, http.MethodPost, "/api/live/events", body); rec.Code != http.StatusBadRequest {
+ t.Errorf("%s: status=%d", body, rec.Code)
+ }
+ }
+ if rec := do(t, h, http.MethodPost, "/api/live/events", `{"events":[]}`); rec.Code != http.StatusNoContent {
+ t.Errorf("空:status=%d %s", rec.Code, rec.Body)
+ }
+}
+
+func TestLiveStream(t *testing.T) {
+ ts := httptest.NewServer(newTestServer(t, t.TempDir()))
+ defer ts.Close()
+
+ res, err := http.Get(ts.URL + "/api/live/stream")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ if ct := res.Header.Get("Content-Type"); ct != "text/event-stream" {
+ t.Fatalf("Content-Type=%q", ct)
+ }
+ lines := make(chan string)
+ go func() {
+ sc := bufio.NewScanner(res.Body)
+ for sc.Scan() {
+ if l, ok := strings.CutPrefix(sc.Text(), "data: "); ok {
+ lines <- l
+ }
+ }
+ close(lines)
+ }()
+ next := func() map[string]any {
+ t.Helper()
+ select {
+ case l := <-lines:
+ var m map[string]any
+ if err := json.Unmarshal([]byte(l), &m); err != nil {
+ t.Fatalf("%s: %v", l, err)
+ }
+ return m
+ case <-time.After(5 * time.Second):
+ t.Fatal("届かない")
+ }
+ return nil
+ }
+
+ if m := next(); m["type"] != "snapshot" || m["online"] != false {
+ t.Fatalf("snapshot: %v", m)
+ }
+ body := `{"events":[{"type":"main","cube":"connected"},{"type":"move","move":"R","facelets":"` + solvedFacelets + `"}]}`
+ req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/live/events", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ pr, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pr.Body.Close()
+ if pr.StatusCode != http.StatusNoContent {
+ t.Fatalf("POST status=%d", pr.StatusCode)
+ }
+ if m := next(); m["type"] != "main" || m["online"] != true {
+ t.Fatalf("main: %v", m)
+ }
+ if m := next(); m["type"] != "move" || m["move"] != "R" {
+ t.Fatalf("move: %v", m)
+ }
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index fb8547b..7200938 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -16,6 +16,7 @@ import (
"github.com/riwn/cubalyst/docs"
"github.com/riwn/cubalyst/internal/analysis"
"github.com/riwn/cubalyst/internal/db"
+ "github.com/riwn/cubalyst/internal/live"
"github.com/riwn/cubalyst/internal/settings"
"github.com/riwn/cubalyst/internal/solves"
)
@@ -27,6 +28,7 @@ type Server struct {
solves *solves.Store
settings *settings.Store
analysis *analysis.Service
+ live *live.Hub
}
// New はサーバーを作る。
@@ -37,9 +39,15 @@ func New(database *sql.DB, staticDir string) *Server {
solves: solves.NewStore(database),
settings: settings.NewStore(database),
analysis: analysis.NewService(database),
+ live: live.NewHub(),
}
}
+// Close は、つなぎっぱなしの接続(タイマー画面の中継)を切る。http.Server の RegisterOnShutdown に渡す。
+func (s *Server) Close() {
+ s.live.Close()
+}
+
// Handler はルーティング済みのハンドラを返す。
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
@@ -66,6 +74,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/analysis-input", s.handleMultiAnalysisInput)
mux.HandleFunc("GET /api/stage-cases", s.handleStageCases)
mux.HandleFunc("GET /api/stats/practice", s.handlePracticeStats)
+ mux.HandleFunc("POST /api/live/events", s.handleLiveEvents)
+ mux.HandleFunc("GET /api/live/stream", s.handleLiveStream)
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
})
@@ -140,7 +150,8 @@ func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
- if strings.HasPrefix(r.URL.Path, "/api/") {
+ // タイマー画面の中継は1手ごとに届くので、ログに残さない
+ if strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/api/live/events" {
log.Printf("%s %s (%s)", r.Method, r.URL.Path, time.Since(start).Round(time.Microsecond))
}
})
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 0f3b959..b93027b 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -5,6 +5,7 @@ import { CubeConnectionControl, CubeNotices } from './components/ConnectionPanel
import { CubeStoreProvider } from './cube/CubeStoreProvider'
import { AnalysesPage, AnalysisDetailPage } from './pages/AnalysesPage'
import { HistoryPage } from './pages/HistoryPage'
+import { LivePage } from './pages/LivePage'
import { PracticePage } from './pages/PracticePage'
import { SettingsPage } from './pages/SettingsPage'
import { SolveDetailPage } from './pages/SolveDetailPage'
@@ -37,6 +38,16 @@ const NAV: { to: string; label: string; icon: ReactNode; end?: boolean; match?:
>
),
},
+ {
+ to: '/live',
+ label: '中継',
+ icon: (
+ <>
+
+ {notice ?? 'メインのタイマー画面を映しています'} +
+ {session && ( + <> +