# How Pi-Web Implements a File Viewer with Automatic Refresh: SSE and React Patterns Explained

> Discover how Pi-Web implements automatic file viewer refresh using Server-Sent Events SSE and React patterns. Learn to push file changes and update content seamlessly.

- Repository: [Alex Yang/pi-web](https://github.com/agegr/pi-web)
- Tags: deep-dive
- Published: 2026-08-13

---

**Pi-Web uses a Server-Sent Events (SSE) stream via `EventSource` to push file change notifications from a Node.js `fs.watch` observer to React components, which increment a cache-busting counter to reload content automatically.**

Pi-Web is an open-source file browser built with Next.js that provides a seamless **file viewer with automatic refresh** capabilities. This feature allows users to see live updates as files change on disk without manual page reloading. The implementation combines a server-side file watcher with client-side React hooks to deliver real-time synchronization across images, audio, documents, and source code.

## Server-Side File Watching with SSE

The automatic refresh mechanism starts in the API route **`app/api/files/[...path]/route.ts`**. When a client requests `GET /api/files/[...path]?type=watch`, the server creates a persistent SSE stream that watches the target file’s directory using Node.js `fs.watch`.

### The Watch Stream Implementation

Around lines 370–398 in [`route.ts`](https://github.com/agegr/pi-web/blob/main/route.ts), the handler establishes a `ReadableStream` that encapsulates the file watcher logic. It watches the directory containing the file (not the file itself) to handle atomic save operations that recreate inodes.

```ts
if (type === "watch") {
  if (stat && !stat.isFile()) {
    return NextResponse.json({ error: "Not a file" }, { status: 400 });
  }
  let watcher: fs.FSWatcher | null = null;
  let lastMtimeMs = stat?.mtimeMs ?? 0;
  let lastCtimeMs = stat?.ctimeMs ?? 0;
  let lastIno = stat?.ino ?? 0;
  let lastSize = stat?.size ?? 0;
  let lastExists = stat !== undefined;

  const stream = new ReadableStream({
    start(controller) {
      const send = (eventName: string, data: Record<string, unknown>) => {
        const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
        controller.enqueue(new TextEncoder().encode(payload));
      };

      try {
        const watchedDirectory = path.dirname(filePath);
        watcher = fs.watch(watchedDirectory, (_eventType, changedName) => {
          if (changedName && !samePath(path.join(watchedDirectory, changedName.toString()), filePath))
            return;                                 // ignore unrelated files

          try {
            const s = fs.statSync(filePath);
            // Detect real changes; ignore duplicate events.
            if (lastExists && s.mtimeMs === lastMtimeMs && s.ctimeMs === lastCtimeMs &&
                s.ino === lastIno && s.size === lastSize) return;

            lastExists = true;
            lastMtimeMs = s.mtimeMs;
            lastCtimeMs = s.ctimeMs;
            lastIno = s.ino;
            lastSize = s.size;
            send("change", { mtime: s.mtime.toISOString(), size: s.size });
          } catch {
            if (!lastExists) return;
            lastExists = false;
            send("change", { mtime: new Date().toISOString(), size: 0 });
          }
        });

        watcher.on("error", () => {
          watcher?.close();
          watcher = null;
          controller.close();
        });

        // Notify the client that the watcher is ready.
        send("connected", { filePath });
      } catch {
        send("error", { message: "Failed to watch file" });
        controller.close();
      }
    },
    cancel() {
      watcher?.close();
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
      "X-Accel-Buffering": "no",
    },
  });
}

```

The server emits two critical event types:
- **`connected`** – Sent immediately when the watcher is ready, allowing the client to perform an initial synchronization.
- **`change`** – Sent when `fs.statSync` detects actual metadata changes (mtime, ctime, inode, or size), including the new file size and timestamp.

### Deduplicating File System Events

Node.js `fs.watch` can fire multiple events for a single save operation. Pi-Web deduplicates these by comparing the current `stat` results against cached values for `mtimeMs`, `ctimeMs`, `ino`, and `size`. If all values match the previous state, the event is ignored, preventing unnecessary client refreshes.

## Client-Side EventSource Implementation

The client-side logic resides in **[`components/FileViewer.tsx`](https://github.com/agegr/pi-web/blob/main/components/FileViewer.tsx)** and its specialized viewers (`ImageViewer`, `AudioViewer`, `DocumentViewer`, `TextFileViewer`). Each viewer establishes an `EventSource` connection inside a `useEffect` hook that depends on `filePath`, `sourceSessionId`, and the `watchEnabled` flag.

### Building the Watch URL

The utility function `getFileApiUrl` (lines 76–84 in [`FileViewer.tsx`](https://github.com/agegr/pi-web/blob/main/FileViewer.tsx)) constructs the SSE endpoint URL with proper path encoding:

```tsx
function getFileApiUrl(
  filePath: string,
  type: "read" | "download" | "meta" | "preview" | "watch",
  sourceSessionId?: string | null,
  params: Record<string, string | number | undefined> = {}
): string {
  const encoded = encodeFilePathForApi(filePath);
  const searchParams = new URLSearchParams({ type });
  if (sourceSessionId) searchParams.set("sessionId", sourceSessionId);
  Object.entries(params).forEach(([k, v]) => v !== undefined && searchParams.set(k, String(v)));
  return `/api/files/${encoded}?${searchParams.toString()}`;
}

```

### Managing the SSE Connection

Each viewer component implements a similar `useEffect` pattern. The following excerpt from `ImageViewer` demonstrates how the connection is established, cleaned up, and used to trigger refreshes:

```tsx
useEffect(() => {
  setWatching(false);

  // Clean any previous SSE.
  if (esRef.current) {
    esRef.current.close();
    esRef.current = null;
  }

  if (!watchEnabled) return;

  let active = true;
  const synchronize = () => {
    const requestId = ++syncRequestRef.current;
    fetch(getFileApiUrl(filePath, "meta", sourceSessionId))
      .then(r => r.json())
      .then(next => {
        if (!active || requestId !== syncRequestRef.current) return;
        if (next.error) setError(next.error);
        else {
          if (typeof next.size === "number") setSize(next.size);
          setError(null);
          setBust(b => b + 1);          // force reload
        }
      })
      .catch(err => active && requestId === syncRequestRef.current && setError(String(err)));
  };

  const es = new EventSource(getFileApiUrl(filePath, "watch", sourceSessionId));
  esRef.current = es;

  es.addEventListener("connected", () => {
    setWatching(true);
    synchronize();                     // initial fetch after watcher is ready
  });
  es.addEventListener("change", () => {
    syncRequestRef.current += 1;       // discard any in‑flight fetches
    setSize(null);
    setError(null);
    setBust(b => b + 1);               // trigger UI refresh
  });

  const markDisconnected = () => setWatching(false);
  es.addEventListener("error", markDisconnected);
  es.onerror = markDisconnected;

  return () => {
    active = false;
    es.close();
    if (esRef.current === es) esRef.current = null;
  };
}, [filePath, sourceSessionId, watchEnabled]);

```

The `EventSource` is closed and recreated whenever the file path or session changes, preventing memory leaks and ensuring the watcher always targets the correct file.

## State Management and Cache Busting

Pi-Web uses a **cache-busting pattern** to force the browser to reload file content after changes. Rather than manipulating the DOM directly, the components rely on React state:

- **`bust`** – A counter incremented on every `change` event. This value is appended as a `?v=<number>` query parameter to content URLs (via `type=read` or `type=meta` requests), bypassing the browser cache.
- **`watching`** – A boolean state reflecting the SSE connection status, displayed in the UI toolbar.
- **`size`** – Updated metadata from the `change` event, allowing the viewer to show live file size updates.

The `synchronize()` function fetches fresh metadata and content when the `connected` event fires or when the `change` event signals an update. It uses a `syncRequestRef` counter to race against inflight requests and discard stale responses.

## Supported File Types and Viewer Architecture

The automatic refresh pattern is implemented consistently across all specialized viewers embedded in **[`components/FileViewer.tsx`](https://github.com/agegr/pi-web/blob/main/components/FileViewer.tsx)**:

- **ImageViewer** – Reloads `<img>` tags by updating the `src` URL with a new `bust` parameter.
- **AudioViewer** – Refreshes the audio source when the underlying file changes.
- **DocumentViewer** – Re-renders PDF or DOCX previews by fetching new preview data.
- **TextFileViewer** – Handles source code, diffs, and plain text, including line selection state that persists across refreshes.

All viewers delegate to the same `getFileApiUrl` helper and `useEffect` SSE logic, ensuring uniform behavior regardless of file type.

## Summary

- The server watches file directories using `fs.watch` in **`app/api/files/[...path]/route.ts`**, filtering duplicate events via metadata comparison.
- Server-Sent Events push `connected` and `change` notifications to the client over HTTP.
- React components in **[`components/FileViewer.tsx`](https://github.com/agegr/pi-web/blob/main/components/FileViewer.tsx)** manage `EventSource` connections inside `useEffect` hooks keyed to file path and session.
- A `bust` counter invalidates browser caches by appending version query parameters to fetch requests.
- All file types (images, audio, documents, text) share the same refresh implementation, providing a consistent live-viewing experience.

## Frequently Asked Questions

### How does Pi-Web detect file changes on the server?

The server uses Node.js `fs.watch` on the directory containing the target file, then calls `fs.statSync` to read metadata (mtime, ctime, inode, and size). It compares these values against cached state to filter out duplicate events and only emits SSE `change` events when actual modifications occur.

### Why does Pi-Web use Server-Sent Events instead of WebSockets?

SSE provides unidirectional server-to-client communication over standard HTTP, which is sufficient for pushing file change notifications. According to the Pi-Web source code, this approach is simpler to implement within Next.js API routes than maintaining persistent WebSocket connections, since the client only needs to receive updates rather than bidirectional messaging.

### How does the viewer prevent displaying stale cached content?

The client maintains a `bust` counter in React state that increments on every `change` event. This value is appended as a `?v=<number>` query parameter to subsequent fetch requests for file content and metadata, forcing the browser to bypass its cache and retrieve the latest version from the server.

### Can users disable automatic refresh for specific files?

Yes. Each viewer checks a `watchEnabled` flag before establishing the `EventSource` connection. When this flag is false, the component skips the watcher setup and does not automatically refresh, allowing users to view static snapshots of files that change frequently.