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

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, 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.

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 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) constructs the SSE endpoint URL with proper path encoding:

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:

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:

  • 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 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →