NX

Flyfish File Viewer: The Pure Frontend File Preview Engine That Covers 206+ Formats — With Go Backend and React Integration

🛠️ 开发者实操 x/dev-workshop ·
Flyfish File Viewer: The Pure Frontend File Preview Engine That Covers 206+ Formats — With Go Backend and React Integration

Flyfish File Viewer: The Pure Frontend File Preview Engine That Covers 206+ Formats — With Go Backend and React Integration

Preview anything, anywhere — without a single line of server-side conversion code.


If you've ever built a web app that handles file attachments — be it an HR portal displaying resumes, an engineering archive showing CAD drawings, or an internal knowledge base rendering Markdown alongside Excel reports — you already know the pain. Every format demands its own rendering pipeline. PDFs need PDF.js. Word documents need a converter. CAD files? Good luck. And don't even get me started on OFD, Typst, or compressed email archives.

Enter Flyfish File Viewer: a browser-native, pure frontend file preview component that eats all of this complexity for breakfast — 206+ file extensions across 24 preview pipelines, zero server-side conversion, and native support for Vue, React, Svelte, jQuery, and vanilla Web Components.

In this post, I'll walk you through what makes Flyfish tick, how to wire it up with a Go backend that serves files, and a React frontend that renders them — all without a single file ever leaving your infrastructure.


What Is Flyfish File Viewer?

Flyfish File Viewer is an open-source project by flyfish-dev built for one job: preview business attachments directly in the browser. It's designed for internal tools, OA systems, knowledge bases, approval workflows, attachment centers, and private deployments — the kind of software where files come in every shape and size and you can't afford to push them through a third-party conversion API.

Architecture overview of Go backend, React frontend, and Flyfish File Viewer iframe integration

Here's the headline feature list:

Category Supported Formats
Office DOCX, XLSX, PPTX, PDF, OFD, Typst, RTF, ODT
Engineering DWG, DXF, DWF, GLB/GLTF, STL, STEP, IFC, 3DM, GeoJSON
Archives ZIP, 7Z, RAR, TAR.GZ, ISO, APK — with nested preview
Email EML, MSG, MBOX
Diagrams Mermaid, PlantUML, draw.io, Excalidraw, XMind
Code & Data Markdown, 50+ code languages, SQLite, Parquet, Avro, WASM
Media & Design PSD, AI, EPS, HEIC, all common image/audio/video formats

The killer feature: every single one renders purely in the browser. No LibreOffice headless server. No queue of conversion jobs. No temporary PDF files cluttering your /tmp. Just load the file URL, and the right renderer kicks in automatically.


Architecture: How It Works Under the Hood

Flyfish's architecture is beautifully modular. At its core is @file-viewer/core — a framework-agnostic engine that handles format detection, resource loading, renderer protocol, lifecycle management, and unified operations (search, zoom, print, export, watermark).

On top of that sits a layered ecosystem:

  • Renderers (@file-viewer/renderer-*): Individual rendering engines for PDF, Word, Excel, PowerPoint, CAD, Typst, Archives, Drawings, and more. Each renderer is loaded lazily — only when the specific file type is encountered.
  • Presets (@file-viewer/preset-*): Convenience bundles. preset-lite covers images and code. preset-office covers the Office suite. preset-engineering covers CAD, 3D, and drawings. preset-all is the full kitchen sink.
  • Framework Packages (@file-viewer/react, @file-viewer/vue3, @file-viewer/web, etc.): Native components for each frontend ecosystem that all share the same core, options, events, and controller API.

The heavy lifting — PDF parsing via pdfjs-dist, CAD rendering via LibreDWG WASM, Typst compiling via @myriaddreamin/typst.ts — all happens inside Web Workers and WASM modules that load on demand. Your main thread stays snappy.


Here's a key design decision Flyfish makes: for React, the recommended path is to host the Vue 3 preview baseline as a static asset and embed it via iframe. This isn't a compromise — it's a deliberate architectural choice. The Vue 3 component is the canonical preview engine; the React package wraps it with parameter passing, iframe management, and a binary push protocol.

Why? Because the file viewer is a complex, heavy application by nature. Isolating it in an iframe gives you sandboxing, cleaner CSS scoping, and independence from your React app's render cycles.

Step 1: Install the React Package

npm install @file-viewer/react @file-viewer/preset-office

Step 2: Create the Preview Component

// components/FilePreview.tsx
import FileViewer from '@file-viewer/react'
import officePreset from '@file-viewer/preset-office'

interface FilePreviewProps {
  fileUrl: string
  fileName?: string
}

export function FilePreview({ fileUrl, fileName }: FilePreviewProps) {
  return (
    <div style={{ height: '100vh', width: '100%' }}>
      <FileViewer
        url={fileUrl}
        options={{
          preset: officePreset,
          rendererMode: 'replace',
          theme: 'light',
          toolbar: { position: 'bottom-right' },
          watermark: fileName
            ? { text: `Internal — ${fileName}`, opacity: 0.12 }
            : undefined,
        }}
      />
    </div>
  )
}
// components/FilePreviewIframe.tsx
import { useMemo } from 'react'

interface FilePreviewIframeProps {
  fileUrl: string
}

export function FilePreviewIframe({ fileUrl }: FilePreviewIframeProps) {
  const previewUrl = useMemo(() => {
    const base = '/file-viewer/index.html'
    const params = new URLSearchParams({
      url: fileUrl,
      options: JSON.stringify({
        toolbar: { position: 'bottom-right' },
        theme: 'light',
      }),
    })
    return `${base}?${params.toString()}`
  }, [fileUrl])

  return (
    <iframe
      src={previewUrl}
      style={{ width: '100%', height: '100%', border: 0 }}
      title="File Preview"
    />
  )
}

For the iframe approach, you'll need to host the static preview assets. You can either:

  1. Copy the dist/ folder from @file-viewer/web-full into your public/file-viewer/ directory
  2. Use Docker: flyfishdev/file-viewer:latest as a sidecar service
  3. Serve from CDN (jsDelivr) for quick prototyping

The Go Backend: Serving Files and Assets

The Go backend has two responsibilities: serve the actual files for preview, and (optionally) host the static previewer assets.

Project Structure

.
├── main.go
├── go.mod
├── frontend/          # React app (Vite)
│   └── ...
└── uploads/           # File storage directory

Go Server Implementation

// main.go
package main

import (
	"encoding/base64"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"github.com/go-chi/cors"
	"github.com/google/uuid"
)

// FileMeta holds metadata about an uploaded or stored file.
type FileMeta struct {
	ID          string `json:"id"`
	OriginalName string `json:"original_name"`
	MimeType    string `json:"mime_type"`
	Size        int64  `json:"size"`
	CreatedAt   string `json:"created_at"`
	URL         string `json:"url"`
}

// In-memory file registry (use a database in production).
var fileRegistry = make(map[string]FileMeta)

func main() {
	r := chi.NewRouter()

	// Middleware
	r.Use(middleware.Logger)
	r.Use(middleware.Recoverer)
	r.Use(middleware.RequestID)
	r.Use(cors.Handler(cors.Options{
		AllowedOrigins:   []string{"http://localhost:5173", "http://localhost:3000"},
		AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
		AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type"},
		ExposedHeaders:   []string{"Content-Disposition", "Content-Length", "Content-Range"},
		AllowCredentials: true,
		MaxAge:           300,
	}))

	// API routes
	r.Route("/api", func(r chi.Router) {
		r.Post("/files/upload", handleUpload)
		r.Get("/files", handleListFiles)
		r.Get("/files/{fileID}", handleGetFile)
	})

	// File serving with Range support (critical for PDF streaming)
	r.Get("/files/{fileID}/raw", handleServeFile)

	// Serve static previewer assets (production)
	workDir, _ := os.Getwd()
	previewerPath := filepath.Join(workDir, "public", "file-viewer")
	if _, err := os.Stat(previewerPath); err == nil {
		r.Handle("/file-viewer/*", http.StripPrefix("/file-viewer/",
			http.FileServer(http.Dir(previewerPath))))
	}

	// Serve React frontend in production
	r.Handle("/*", http.FileServer(http.Dir("./frontend/dist")))

	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}

	log.Printf("Server starting on :%s", port)
	if err := http.ListenAndServe(":"+port, r); err != nil {
		log.Fatal(err)
	}
}

// handleUpload accepts multipart file uploads.
func handleUpload(w http.ResponseWriter, r *http.Request) {
	// Limit upload to 100MB
	r.Body = http.MaxBytesReader(w, r.Body, 100<<20)

	if err := r.ParseMultipartForm(32 << 20); err != nil {
		http.Error(w, "File too large", http.StatusRequestEntityTooLarge)
		return
	}

	file, header, err := r.FormFile("file")
	if err != nil {
		http.Error(w, "Invalid file upload", http.StatusBadRequest)
		return
	}
	defer file.Close()

	// Generate unique filename to prevent collisions
	ext := filepath.Ext(header.Filename)
	id := uuid.New().String()
	storedName := id + ext

	// Ensure uploads directory exists
	uploadDir := "./uploads"
	if err := os.MkdirAll(uploadDir, 0755); err != nil {
		http.Error(w, "Server error", http.StatusInternalServerError)
		return
	}

	destPath := filepath.Join(uploadDir, storedName)
	dest, err := os.Create(destPath)
	if err != nil {
		http.Error(w, "Server error", http.StatusInternalServerError)
		return
	}
	defer dest.Close()

	// Copy file contents
	written, err := dest.ReadFrom(file)
	if err != nil {
		http.Error(w, "Server error", http.StatusInternalServerError)
		return
	}

	// Detect MIME type from first 512 bytes
	mimeType := header.Header.Get("Content-Type")
	if mimeType == "" {
		mimeType = detectMimeType(destPath)
	}

	meta := FileMeta{
		ID:           id,
		OriginalName: header.Filename,
		MimeType:     mimeType,
		Size:         written,
		CreatedAt:    time.Now().UTC().Format(time.RFC3339),
		URL:          fmt.Sprintf("/files/%s/raw", storedName),
	}

	fileRegistry[id] = meta

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(meta)
}

// handleListFiles returns all registered files.
func handleListFiles(w http.ResponseWriter, r *http.Request) {
	files := make([]FileMeta, 0, len(fileRegistry))
	for _, meta := range fileRegistry {
		files = append(files, meta)
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(files)
}

// handleGetFile returns file metadata.
func handleGetFile(w http.ResponseWriter, r *http.Request) {
	fileID := chi.URLParam(r, "fileID")
	meta, ok := fileRegistry[fileID]
	if !ok {
		http.Error(w, "File not found", http.StatusNotFound)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(meta)
}

// handleServeFile serves the raw file with proper headers for Flyfish preview.
// CRITICAL: Must support Range requests for PDF streaming.
func handleServeFile(w http.ResponseWriter, r *http.Request) {
	fileID := chi.URLParam(r, "fileID")

	// Look up the file
	meta, ok := fileRegistry[fileID]
	var filePath string
	if ok {
		// Find by metadata ID
		for _, f := range fileRegistry {
			if f.ID == fileID {
				ext := filepath.Ext(meta.OriginalName)
				filePath = filepath.Join("./uploads", fileID+ext)
				break
			}
		}
	}
	if filePath == "" {
		// Try direct path
		filePath = filepath.Join("./uploads", fileID)
	}

	if _, err := os.Stat(filePath); os.IsNotExist(err) {
		http.Error(w, "File not found", http.StatusNotFound)
		return
	}

	// Detect MIME for content-type header
	mimeType := detectMimeType(filePath)

	// Set headers for Flyfish compatibility
	w.Header().Set("Content-Type", mimeType)
	w.Header().Set("Accept-Ranges", "bytes")
	w.Header().Set("Access-Control-Allow-Origin", "*")

	// For inline display in previewer
	originalName := fileID
	if ok {
		originalName = meta.OriginalName
	}
	w.Header().Set("Content-Disposition",
		fmt.Sprintf(`inline; filename="%s"`, originalName))

	// http.ServeFile handles Range requests automatically
	http.ServeFile(w, r, filePath)
}

// detectMimeType returns a MIME type based on file extension.
func detectMimeType(path string) string {
	ext := strings.ToLower(filepath.Ext(path))
	mimeMap := map[string]string{
		".pdf":   "application/pdf",
		".docx":  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
		".xlsx":  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
		".pptx":  "application/vnd.openxmlformats-officedocument.presentationml.presentation",
		".dwg":   "application/acad",
		".dxf":   "application/dxf",
		".zip":   "application/zip",
		".eml":   "message/rfc822",
		".md":    "text/markdown",
		".png":   "image/png",
		".jpg":   "image/jpeg",
		".jpeg":  "image/jpeg",
		".svg":   "image/svg+xml",
		".mp4":   "video/mp4",
		".mp3":   "audio/mpeg",
		".json":  "application/json",
		".txt":   "text/plain",
		".html":  "text/html",
	}
	if mime, ok := mimeMap[ext]; ok {
		return mime
	}
	return "application/octet-stream"
}

Key Points for the Go Backend

  1. Range Requests Are Critical: PDF.js — the engine Flyfish uses internally for PDFs — needs HTTP Range support for progressive loading. http.ServeFile handles this automatically, so you're covered.

  2. CORS Headers: If your React dev server runs on a different port, make sure your Go server sends proper CORS headers. The chi CORS middleware above handles that.

  3. Content-Disposition: Use inline (not attachment) so the browser tries to display rather than download. This is crucial for Flyfish to intercept the response.

  4. Static Asset Hosting: The previewer assets (/file-viewer/*) are served from a public/file-viewer/ directory using http.FileServer. For production, copy these from the npm package's dist folder or use the Docker image.


Putting It All Together: The Full Stack

Split screen showing Go and React code alongside a file preview interface

Here's the complete architecture at a glance:

┌─────────────────────────────────────────────────────────┐
│                    Go Backend (:8080)                    │
│  ┌──────────┐  ┌─────────────┐  ┌───────────────────┐  │
│  │ /api/*   │  │ /files/*/raw│  │ /file-viewer/*     │  │
│  │ REST API │  │ File Server │  │ Static Assets     │  │
│  └──────────┘  └─────────────┘  └───────────────────┘  │
└──────────────────────┬──────────────────────────────────┘
                       │ HTTP (Range support, CORS)
┌──────────────────────▼──────────────────────────────────┐
│                   React Frontend                         │
│  ┌────────────────────────────────────────────────────┐ │
│  │  <FilePreviewIframe fileUrl="/files/report.docx"> │ │
│  │    ┌──────────────────────────────────────────┐    │ │
│  │    │  Flyfish Viewer (iframe)                  │    │ │
│  │    │  → Auto-detects .docx                     │    │ │
│  │    │  → Loads @file-viewer/renderer-word       │    │ │
│  │    │  → Renders in Worker thread               │    │ │
│  │    └──────────────────────────────────────────┘    │ │
│  └────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘

Quick Deployment Checklist

  1. Install the viewer assets: Copy node_modules/@file-viewer/web-full/dist/public/file-viewer/
  2. Configure your Go server: Ensure /file-viewer/ is served as static files alongside your API
  3. Point the React iframe: Set iframe.src to /file-viewer/index.html?url=/files/your-file.pdf
  4. Verify Range support: Run curl -I -H "Range: bytes=0-1023" http://localhost:8080/files/sample.pdf/raw — you should get a 206 Partial Content response

Why This Matters: No More Conversion Infrastructure

The old way of handling file previews was expensive and fragile:

  • LibreOffice headless for Office → PDF conversion (slow, memory-hungry, crashes on malformed files)
  • ImageMagick/Ghostscript for images and PDF thumbnails (security vulnerabilities galore)
  • Cloud conversion APIs (data sovereignty issues, latency, per-file costs)
  • Queue workers to manage conversion jobs

Flyfish eliminates all of it. Everything happens in the user's browser, on demand. For an internal tool serving a few hundred users, that's a massive reduction in operational complexity.

For enterprise deployments with strict data residency requirements, this is even more critical — your files never touch a third-party server. They stay on your Go backend, streamed directly to the browser, rendered locally.


Production Considerations

  • Docker Deployment: Flyfish provides flyfishdev/file-viewer:latest, a ready-to-run container with all static assets pre-built. Perfect as a sidecar in a Kubernetes pod alongside your Go service.
  • Offline/Intranet: All Worker, WASM, font, and vendor assets can be self-hosted. No CDN dependency.
  • Security: The iframe sandbox isolates the viewer from your main app. Use Content-Security-Policy headers to restrict what the iframe can access.
  • File Size Limits: Set reasonable upload limits (100MB default in the Go example above). The libarchive.js WASM module has its own memory ceiling for archive extraction.

Sources

  1. Flyfish Viewer Official Documentation
  2. GitHub — flyfish-dev/file-viewer: Browser-native Office / PDF / CAD / archive viewer
  3. Flyfish Dev — Browser-Native File Preview and Enterprise Office Preview
  4. Flyfish Viewer Quick Start Guide
  5. GitHub English README — File Viewer
  6. Flyfish Viewer Online Demo (Vue 3)
·