Files
WRNexusJS/docs/plans/2026-09-08-file-viewer-design.md

17 KiB

Universal file viewer design (@wrnexus/viewer)

Date: 2026-09-08 Status: implemented — build order steps 1-5 complete. Follow-ups: 2026-09-09-file-viewer-follow-ups.md Depends on: @wrnexus/ui (tokens, .wrn components), @wrnexus/plugin (routeEntries), @wrnexus/cache, @wrnexus/block-editor (read-only CodeMirror), @wrnexus/uploader (optional)

Problem

Every app in the workspace that stores a file eventually has to show it. Today each one solves this alone: an <img> here, an <iframe src=…> there, a download link for everything else. There is no shared answer for "the user uploaded a .docx, show it to them", no consistent chrome (zoom, paging, download, fullscreen), and no consistent theming — a preview pane looks nothing like the app around it.

@wrnexus/uploader already owns getting bytes in and serving them back out, including the extension↔MIME table in src/mime.ts. @wrnexus/ui already owns the token system that makes components follow an app's accent and dark mode. @wrnexus/block-editor already wraps CodeMirror read-only in src/source-editor.ts. What is missing is the layer that looks at an arbitrary file and decides how to render it.

Scope

One package that any app drops in as a single component:

<Viewer src={file.url} name={file.name} />

…and gets the best available rendering of that file, themed to the app, with a graceful fallback when the format cannot be rendered at all.

It is not an editor. Everything is read-only; editing stays with @wrnexus/block-editor and WysiwygEditor.wrn. It is also not a file manager — no listing, no tree, no rename. It renders one file at a time (with an optional filmstrip for multi-file callers).

Approach

Three tiers, decided by what the platform can actually do, not by what we would like:

Tier Rendered by Cost
1 The browser, natively Zero new dependencies
2 A JS parser in the client Optional peer dep, lazily imported
3 A server-side binary converter Opt-in plugin + a binary on the host

The tier boundary is a hard fact about the platform: no amount of JavaScript renders a .psd faithfully, and no server round-trip is needed to show a .png. Pretending otherwise is how viewer libraries end up shipping 8 MB to display a JPEG.

Everything degrades down a fixed ladder — full render → thumbnail → metadata card → download card — so an unknown extension, a missing optional dep, and an absent converter binary all land somewhere useful instead of erroring.

Rejected alternatives:

  • One monolithic Viewer.wrn with a big if/else. Every format's dependency ends up in every app's bundle, and apps cannot add their own formats.
  • Server-render everything to PDF/PNG. Uniform output, but it makes a hard host-binary dependency mandatory for viewing a text file, loses text selection and copy, and puts a conversion hop in front of the 60% of files the browser renders for free.
  • Wrap an existing all-in-one viewer library. They bundle their own design system, fight our tokens, and none of them cover Tier 3 anyway.

Module layout

@wrnexus/viewer
  src/
    kinds.ts       ViewerKind union; ext/MIME -> kind table (extends uploader's)
    resolve.ts     ViewerSource -> ViewerPlan (kind, renderer, fallback chain)
    sniff.ts       magic-byte detection; extensions are never trusted alone
    registry.ts    renderer registry; register/override per kind or per ext
    parse/         csv, json, xml, sql, archive listing, sanitize-svg
    convert/       ViewerConverter contract + the shipped local-binary driver
    plugin.ts      viewerPlugin(): componentDirs + /_viewer/* routeEntries
    uploader.ts    separate export: storage key -> signed URL + metadata
    index.ts
  components/
    Viewer.wrn            the one component apps use
    ViewerToolbar.wrn     zoom, page, rotate, download, fullscreen
    ViewerShell.wrn       chrome, loading, error, and fallback states
    ImageViewer.wrn   VectorViewer.wrn   PdfViewer.wrn    CodeViewer.wrn
    SheetViewer.wrn   DocViewer.wrn      MediaViewer.wrn  ArchiveViewer.wrn
    DownloadCard.wrn
  viewer.css       consumes the same CSS variables as ui.css

Core contract

export type ViewerKind =
  | "image"
  | "vector"
  | "pdf"
  | "code"
  | "text"
  | "markup"
  | "sheet"
  | "doc"
  | "audio"
  | "video"
  | "archive"
  | "binary";

export interface ViewerSource {
  src?: string; // URL, blob:, or data:
  blob?: Blob | File; // client-side preview before upload
  key?: string; // @wrnexus/uploader storage key
  name: string;
  mime?: string; // sniffed when absent
  size?: number;
}

export interface Renderer {
  kind: ViewerKind;
  exts: string[];
  tier: 1 | 2 | 3;
  component: string; // .wrn component name
  load?: () => Promise<unknown>; // lazy optional dep; missing -> degrade
  maxInlineBytes?: number;
}

export interface ViewerPlan {
  kind: ViewerKind;
  renderer: Renderer;
  fallback: Renderer[]; // the degradation ladder, pre-computed
  needsConvert: boolean;
  sandboxed: boolean;
}

export function resolve(source: ViewerSource): Promise<ViewerPlan>;
export function registerRenderer(renderer: Renderer): void;

resolve() runs ext → declared MIME → magic-byte sniff, then picks the highest-tier renderer whose dependency is actually available. Apps override anything:

registerRenderer({ kind: "binary", exts: [".dwg"], tier: 2, component: "CadViewer" });

Format matrix

Tier 1 — browser-native, zero dependencies.

Group Extensions Renderer
Images .jpg .jpeg .png .gif .webp .bmp .ico .avif ImageViewer (zoom/pan)
Video .mp4 .webm .mov MediaViewer (<video>)
Audio .mp3 .wav .aac .flac .ogg MediaViewer + waveform bar
Documents .pdf PdfViewer (native <embed>)
Text .txt CodeViewer (plain mode)
Code .js .py .java .cpp .cs .css .html .htm .json .xml .sql CodeViewer (CodeMirror)

.mov and .flac are codec-dependent in practice; when playback fails the ladder drops to the Tier 3 poster-frame/transcode path, then to the download card.

Tier 2 — pure JS in the client, lazily loaded.

Extensions Parser Notes
.csv in-package Virtualized table; handles quoted/multiline
.json in-package Collapsible tree + raw toggle
.xml DOMParser Tree view + raw toggle
.svg in-package sanitizer Sandboxed; see Security
.xls .xlsx SheetJS Sheet tabs, formulas shown as values
.docx mammoth Semantic HTML, not faithful layout
.zip fflate Entry listing, drill-in re-enters resolve
.pdf (rich mode) pdf.js Opt-in; page API, text layer, theming

All declared as optional peerDependencies and dynamically imported on first use. An app that never views a spreadsheet neither installs nor ships SheetJS.

Tier 3 — requires a server-side converter.

Extensions Converted to Binary
.doc .odt .rtf PDF soffice
.docx (fidelity) PDF soffice
.ppt .pptx PDF soffice
.psd .ai .tiff PNG magick
.avi .mkv poster PNG, or MP4 ffmpeg
.rar .7z entry listing 7z
.tar .gz entry listing in-package

Converter contract

export interface ViewerConverter {
  name: string;
  supports(kind: ViewerKind, ext: string): boolean;
  toPreview(
    input: ReadableStream,
    meta: ConvertMeta,
  ): Promise<{ mime: string; stream: ReadableStream }>;
  toThumb?(input: ReadableStream, meta: ConvertMeta): Promise<Uint8Array>;
  toText?(input: ReadableStream, meta: ConvertMeta): Promise<string>;
}

viewerPlugin() contributes three routeEntries:

  • GET /_viewer/preview — the renderable form (PDF or PNG)
  • GET /_viewer/thumb — a small poster image, for grids and filmstrips
  • GET /_viewer/text — extracted plain text, for search and indexing

Output is content-addressed by input hash + converter version and cached through @wrnexus/cache, so a 40 MB .doc converts once. The shipped localBinaryConverter probes for soffice/magick/ffmpeg/7z once at boot; an absent binary is not an error — the format simply falls to the download card. Apps that would rather call Gotenberg or an internal service implement ViewerConverter themselves and pass it to the plugin.

The converter is opt-in. viewerPlugin() with no options mounts no routes and shells out to nothing.

Security

Uploaded files are hostile input, and this package's whole job is to open them.

  • Never trust the extension. sniff.ts reads magic bytes before choosing a renderer, so a .png that is really an HTML document does not reach an HTML path.
  • Sandbox all markup. .html, .htm, and .svg render inside an iframe with sandbox (no allow-scripts, no allow-same-origin) under a strict CSP. SVG is additionally stripped of <script>, <foreignObject>, external references, and every on* attribute — sandbox and sanitizer, not either alone. Nothing from a file ever reaches innerHTML.
  • Cap what is fetched. maxInlineBytes per renderer, checked against a HEAD before the body is pulled; over the cap the ladder drops straight to the download card rather than pulling a 400 MB .mkv into a tab.
  • Contain the converters. soffice, magick, and ffmpeg are large C/C++ parsers with a long CVE history. Every invocation gets a hard wall-clock timeout, memory and output-size caps, no network, and a per-job temp directory as its only writable path. A converter that exceeds any limit is killed and reported as an unrenderable file.
  • Signed, short-lived URLs. The uploader integration mints per-view URLs; converted output inherits the same access check as its input, never a public cache path.

Theming

viewer.css defines no colors of its own. It consumes the same CSS variables ui.css does, so accent, radius, spacing, and dark mode follow the host app with no configuration — including the CodeMirror theme, which is derived from the tokens rather than shipping a hard-coded editor palette. <Viewer theme="auto" /> follows the app; "light" / "dark" pin it for cases like a print preview.

Uploader integration

Core is source-agnostic: src or blob is enough, and the package has no hard dependency on @wrnexus/uploader. The key path lives behind a separate export (@wrnexus/viewer/uploader) that resolves a storage key to a signed URL and reads MIME and size from the upload record, so uploader-backed apps write <Viewer key={f.key} /> and everyone else is unaffected. The ext↔MIME table extends uploader/src/mime.ts rather than forking it.

Testing

  • resolve() table tests over every extension in the matrix, including conflicting extension/MIME/magic-byte combinations.
  • Sanitizer tests: a corpus of hostile SVG and HTML, asserting no script survives.
  • Degradation tests: optional dep missing, converter binary missing, file over the byte cap — each must land on the expected rung of the ladder, never throw.
  • Converter tests run only when the binaries are present, and are skipped otherwise, so CI without LibreOffice stays green.
  • A .wrn render test per component against the example app.

The remaining presentation and verification follow-ups were completed on 2026-09-09; see the follow-up plan. The checkpoint below records the original implementation.

Build order

Implementation checkpoint (2026-09-08): steps 1, 2 and 4 are done; step 3 was skipped for now and step 5 remains. packages/viewer has classification, signature sniffing, a renderer registry, bounded source loading, native image/PDF/media previews, read-only CodeMirror, the fallback ladder, plugin assets and lifecycle cleanup; the in-package Tier 2 parsers (delimited tables with windowed rendering, lazy JSON and XML trees, the SVG sanitiser behind a sandboxed frame, tar/gzip listings); and the Tier 3 converter contract with its opt-in routes, response cache and contained local-binary driver. examples/basic-app has a /file-viewer demo covering each group, with conversion enabled.

Five deviations from this document, all deliberate:

  • Renderers are imperative browser adapters sharing one ViewerMountContext, not the separate .wrn components listed under Module layout. Viewer.wrn is the only component. component is a display label; the runtime does not switch on it, and a renderer with no mount is never selected.
  • Conversion routes are /api/viewer/*, not /_viewer/*: package API routes are served under the /api prefix, as @wrnexus/graphql already does.
  • Image, media and PDF renderers are streaming: for an http(s) source the element is pointed at the original URL instead of a blob: copy. That lets the browser stream the file, and it keeps the request under the app's own default-src/media-src/frame-src policy — blob: URLs are blocked by the framework's default CSP. Local File/Blob input still renders from an object URL served under its sniffed type.
  • The PDF renderer uses an iframe, not <object>, because object-src 'none' is a common default while frames fall back to default-src. Embedding a same-origin PDF still needs the app to allow self-framing; the framework defaults (frame-ancestors 'none' plus X-Frame-Options: DENY, packages/core/src/headers.ts) block an app from framing even its own assets, so examples/basic-app opts into frame-ancestors 'self' / SAMEORIGIN.
  • .xlsx and .zip prefer their Tier 2 parser over the converter, since neither needs a server round-trip; .docx prefers the converter, because mammoth's HTML is not a faithful page layout. .psd, .tiff and other design formats have signatures so they reach the converter or the download card rather than being mistaken for text.
  • pdf.js is served from its own published build rather than bundled — bundling its ESM entry breaks the worker handshake — and each page render is bounded by a timeout, because pdf.js rasterises from requestAnimationFrame, which never fires while a document is hidden.

A sixth deviation: there is no default access check for a private object. The plan said the uploader integration "mints per-view URLs"; in practice a private store is refused unless the app supplies both a signing secret and an authorize hook, because no default answer to "may this viewer see this object" is safe.

See packages/viewer/README.md for current limits, the renderer extension API, the converter contract, and the uploader options.

  1. Core + Tier 1. Done. kinds, sniff, resolve, registry, the toolbar and shell chrome, image, media, native PDF, CodeViewer over the existing read-only CodeMirror, and the download card.
  2. Tier 2 in-package parsers. Done. csv/tsv, json, xml, svg sanitiser, tar/gz listing — still zero runtime dependencies.
  3. Tier 2 optional deps. Done. SheetJS, mammoth, fflate and opt-in pdf.js, each an optional peer dependency served as its own runtime asset and fetched on first use.
  4. Tier 3. Done. ViewerConverter, /api/viewer/{capabilities,preview,thumb,text}, a bounded response cache, localBinaryConverter, and containment of the child processes.
  5. Uploader integration + docs Done. @wrnexus/viewer/uploader, <Viewer key="…">, signed short-lived links for private stores, and /file-viewer in examples/basic-app exercising every group including public and private storage keys.

Out of scope

  • Editing or annotation of any kind.
  • File management (listing, rename, delete, move).
  • OCR over scanned PDFs and images.
  • Collaborative or presence features on a preview.
  • .exe/.dll and other executables — metadata card only, never executed or introspected.