Bliki Phase 3.5: Web Editor with SSE

Phase 3.5 added the inline web editor — the ability to edit content directly in the browser with real-time rebuild notifications and file locking for collaborative editing.

The RebuildHub

At the heart of the web editor is RebuildHub — an in-process broadcast engine that notifies subscribed clients when a rebuild completes:

type RebuildHub struct {
    clients map[*sseClient]bool
    mu      sync.Mutex
}

func (h *RebuildHub) Subscribe(path string) *sseClient
func (h *RebuildHub) Unsubscribe(client *sseClient)
func (h *RebuildHub) Send(event, path string) error

The hub maintains a map of SSE clients, each filtered by content path. When a rebuild completes, Send() broadcasts to all clients subscribed to matching paths.

The data mismatch bug was the most frustrating issue in development. The condition in Send() was inverted — it broadcasted to clients whose path didn't match instead of clients whose path did match. The fix swapped the condition for correct broadcast semantics.

Multiple debug logging commits tracked the issue: adding log.Debug() calls to the hub, the SSE handler, and the client-side JavaScript until the root cause was identified.

SSE Handler

The Server-Sent Events endpoint (GET /api/sse) serves real-time notifications to the browser:

GET /api/sse?path=blog/my-post.md

Clients connect with their content path as a query parameter. The handler sets X-Accel-Buffering: no to prevent nginx from buffering SSE responses — a critical detail for real-time updates behind reverse proxies.

The SSE handler validates JWT authentication before accepting connections. Unauthenticated clients are rejected with a 401 response.

SSE event types:

Event When
save-start User clicked Save
save-success File written to disk
rebuild-start Build process began
rebuild-complete Build finished (page reloads)

Web Components

Two custom elements handle the editing experience:

<bliki-editable> — inline editing decorator that wraps rendered content with edit controls. Attributes: content="blog/post.md" (path relative to content dir).

<bliki-editor> — textarea panel with save and cancel buttons. Used inside <bliki-editable> as a child element.

Both components follow the project's convention: constructor-only initialization, handleEvent dispatcher, emit helper, and light DOM. Child elements are queried via role attributes ([error], [lock]) or element selectors (form, button).

State Machine

<bliki-editable> follows a strict state machine:

loading → idle → unauthenticated → editing → saving → waiting → reload

The flow:

  1. loading — constructor fetches auth state, lock status, and rendered content
  2. idle — content loaded, edit button visible (if authenticated)
  3. unauthenticated — user not logged in; edit button hidden
  4. editing — user clicked edit button; textarea populated with raw markdown; focus
  5. saving — user clicked save; POST to /api/save; show "💾 Saving..." indicator
  6. waiting — server pushed save-success; showing "✅ Done" indicator
  7. reload — server pushed rebuild-complete; window.location.reload()

The unauthenticated state was added after discovering that the edit button was visible even when users weren't logged in. Instead of display: none (which hides from screen readers), the button uses a visibility system based on opacity, visibility, and aria-hidden.

Edit Flow

The complete edit flow chains multiple operations:

POST /api/save
  1. Acquire file lock (10-minute TTL)
  2. Write .md to disk
  3. go-git: add + commit + push
  4. Release lock
  5. Trigger in-process rebuild via RebuildHub

File locking uses TTL-based locks in content/blog/post.lock. The lock prevents concurrent edits by checking if the lock exists and hasn't expired. A lock held by another user shows a [lock] indicator to the viewer.

The known gap: Locks are acquired at Save time, not at Edit time. Between clicking "Edit" and clicking "Save", another user can open an editor and claim the lock first. The original user receives a 409 Conflict with no proactive warning. This is marked as a TODO for the next iteration.

Commit & Push

Every save becomes a git commit:

  1. The file is written to disk via store.WriteFile()
  2. go-git adds the file, creates a commit with timestamp and message
  3. The commit is pushed to the remote (if git_remote_url is configured)
  4. Webhook handlers on the server and other machines receive push notifications

This means the web editor is a full git client — every edit is versioned, pushable, and webhook-triggerable.

SSE Debugging Saga

The SSE implementation required extensive debugging. The commit history tells the story:

  1. a8a05ab — "Add a bunch of log message to debug SSE client-side"
  2. 69e03cc — "Another debug message"
  3. f2bd905 — "Add debug log"
  4. 48b3cdb — "Fix SSE events not reaching client — data mismatch in RebuildHub.Send"
  5. e209174 — "Fix RebuildHub.Send filter — swap condition for correct broadcast semantics"
  6. 098f096 — "Fix SSE event delivery by dispatching from element itself"
  7. f0738b5 — "Fix commit timestamp and add rebuild hub debug log"

The root issues were:

Polish

The final commits focused on user experience:

Known Limitations

Several TODOs remain for future iterations:

What's Next

Phase 3.5 delivered a working web editor with real-time updates, file locking, and git integration. The next phase focuses on wiki features: bidirectional linking, cross-page [[Page Name]] syntax, automatic page indexing, and search functionality.