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.wrnwith a bigif/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 |
soffice |
|
.docx (fidelity) |
soffice |
|
.ppt .pptx |
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 filmstripsGET /_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.tsreads magic bytes before choosing a renderer, so a.pngthat is really an HTML document does not reach an HTML path. - Sandbox all markup.
.html,.htm, and.svgrender inside an iframe withsandbox(noallow-scripts, noallow-same-origin) under a strict CSP. SVG is additionally stripped of<script>,<foreignObject>, external references, and everyon*attribute — sandbox and sanitizer, not either alone. Nothing from a file ever reachesinnerHTML. - Cap what is fetched.
maxInlineBytesper renderer, checked against aHEADbefore the body is pulled; over the cap the ladder drops straight to the download card rather than pulling a 400 MB.mkvinto a tab. - Contain the converters.
soffice,magick, andffmpegare 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
.wrnrender 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.wrncomponents listed under Module layout.Viewer.wrnis the only component.componentis a display label; the runtime does not switch on it, and a renderer with nomountis never selected. - Conversion routes are
/api/viewer/*, not/_viewer/*: package API routes are served under the/apiprefix, as@wrnexus/graphqlalready does. - Image, media and PDF renderers are
streaming: for an http(s) source the element is pointed at the original URL instead of ablob:copy. That lets the browser stream the file, and it keeps the request under the app's owndefault-src/media-src/frame-srcpolicy —blob:URLs are blocked by the framework's default CSP. LocalFile/Blobinput still renders from an object URL served under its sniffed type. - The PDF renderer uses an
iframe, not<object>, becauseobject-src 'none'is a common default while frames fall back todefault-src. Embedding a same-origin PDF still needs the app to allow self-framing; the framework defaults (frame-ancestors 'none'plusX-Frame-Options: DENY,packages/core/src/headers.ts) block an app from framing even its own assets, soexamples/basic-appopts intoframe-ancestors 'self'/SAMEORIGIN. .xlsxand.zipprefer their Tier 2 parser over the converter, since neither needs a server round-trip;.docxprefers the converter, because mammoth's HTML is not a faithful page layout..psd,.tiffand 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.
Core + Tier 1.Done.kinds,sniff,resolve,registry, the toolbar and shell chrome, image, media, native PDF,CodeViewerover the existing read-only CodeMirror, and the download card.Tier 2 in-package parsers.Done. csv/tsv, json, xml, svg sanitiser,tar/gzlisting — still zero runtime dependencies.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.Tier 3.Done.ViewerConverter,/api/viewer/{capabilities,preview,thumb,text}, a bounded response cache,localBinaryConverter, and containment of the child processes.Uploader integration + docsDone.@wrnexus/viewer/uploader,<Viewer key="…">, signed short-lived links for private stores, and/file-viewerinexamples/basic-appexercising 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/.dlland other executables — metadata card only, never executed or introspected.