22 KiB
@wrnexus/viewer
Use hideName={true} on <Viewer> (or { hideName: true } in mountViewer)
when the surrounding app already displays the filename. Defaults to false;
accessible labels, file-type detection and download filenames are preserved.
Complete implementation of the file viewer plan.
Add @wrnexus/viewer to an app's dependencies. Its package plugin discovers the component,
stylesheet and browser runtime automatically. Explicit plugin configuration can import
viewerPlugin from @wrnexus/viewer/plugin; do not register it twice.
import Viewer from "@wrnexus/viewer/components/Viewer.wrn"
<Viewer src={file.url} name={file.name} />
Optional props: mime, size (byte count as a string), class, and theme (auto, light,
dark), files (an array of source descriptors), and richPdf (opt-in canvas rendering). Pinned themes use the host's generated theme tokens. The /file-viewer page in
examples/basic-app provides local samples for every supported group.
For an upload preview before any network request, mount from the browser:
import { mountViewer } from "@wrnexus/viewer/runtime";
const dispose = mountViewer(container, { blob: file, name: file.name });
// Before replacing the preview or removing your component:
dispose();
What renders
Tier 1 — no dependencies. Native image formats with zoom, rotation, reset and
scroll/drag panning; PDF and audio/video through native controls; UTF-8 text and code
through the block editor's lazy read-only CodeMirror mount, with theme tokens, line numbers
and a wrap toggle. HTML/XML use XML highlighting; other languages use plain mode. A text-only
<pre> remains usable if the editor cannot load.
Tier 2 — in-package parsers, still no runtime dependencies.
| Files | Preview |
|---|---|
.csv, .tsv |
Windowed table (RFC 4180 quoting, delimiter detection), raw toggle |
.json |
Lazy collapsible tree, raw toggle |
.xml |
Lazy element tree with attributes, raw toggle |
.svg |
Sanitised markup inside a sandboxed frame, source toggle |
.tar, .gz |
Entry listing with sizes; gzip is expanded in the browser |
Each Tier 2 renderer is registered per extension and sits in front of the source view for
its kind, so a parse failure — malformed JSON, a .csv that is really prose, an unreadable
archive — falls through to the highlighted source rather than erroring.
Tier 2 with an optional dependency. Declared as optional peer dependencies and never
installed for you. Each is served as its own runtime asset, contributed only when the package
resolves on the server, and fetched by the browser the first time such a file is opened — an
app that never views a spreadsheet neither builds nor ships xlsx.
| Files | Package | Preview |
|---|---|---|
.xls, .xlsx |
xlsx |
Sheet tabs over the same windowed table as CSV |
.zip |
fflate |
Entry listing; the archive is never inflated |
.docx |
mammoth |
Semantic HTML in a sandboxed frame — layout is not kept |
.pdf |
pdfjs-dist |
Opt-in page rasterisation with page count (see below) |
.docx sits behind the Tier 3 converter: LibreOffice's PDF keeps the page layout, which
mammoth cannot, so mammoth is the fallback when no converter is reachable. Rich PDF is opt-in
because the native frame costs nothing:
import { registerRenderer } from "@wrnexus/viewer";
import { richPdfRenderer } from "@wrnexus/viewer/renderers/pdf-rich";
registerRenderer(richPdfRenderer());
pdf.js is served from its own published build rather than bundled — putting its ESM entry
through a bundler breaks the worker handshake and getDocument() never settles. Page
rendering is driven by requestAnimationFrame, which does not run while the document is
hidden, so each page render is bounded by a timeout (20s by default) and a page that will not
draw degrades to the native frame rather than hanging.
Office documents. Everything below reads in the browser with no binary on the host. All of it recovers content and structure, not a faithful page layout, so wherever a converter is reachable its PDF is preferred and these are the rung beneath it — the exception is spreadsheets, where the data matters more than the layout and the table view comes first.
| Files | Read by | Preview |
|---|---|---|
.pptx, .ppsx, .potx |
in-package + fflate | Slide-by-slide reader with presenter notes |
.odt |
in-package + fflate | Headings, paragraphs and lists |
.odp |
in-package + fflate | Slide-by-slide reader |
.ods |
SheetJS, then ODF | Sheet tabs over the windowed table |
.rtf |
in-package | Text, with a Source toggle — no dependency |
.docx |
mammoth | Semantic HTML in a sandboxed frame |
.xls, .xlsx |
SheetJS | Sheet tabs over the windowed table |
.doc and .ppt are the legacy OLE compound formats and stay converter-only: there is no
honest way to read them in a browser. With LibreOffice installed every format in this table
converts to PDF instead.
Tier 3 — server-side conversion, opt-in. .doc, .docx, .odt, .rtf, .ppt,
.pptx, .xls and .xlsx convert to PDF; .psd, .ai, .tif and .tiff to PNG; .avi
and .mkv to a poster frame; .zip, .rar and .7z to a listing. Disabled by default —
see Conversion below.
HTML opens in a sanitized, fully sandboxed preview with a Source toggle. Markup masquerading as another format stays inert source. Anything with no renderer and no converter displays a metadata/download fallback, and native playback errors do the same.
Sources and streaming
Image, media and PDF renderers are streaming: for an http(s) source the element is
pointed at the original URL, so the browser fetches and decodes it, playback starts without
buffering the whole file here, and the request is governed by the app's own CSP rather than
by a blob: URL (which default-src 'self' blocks). A local File/Blob still renders
from an object URL served under its sniffed type.
The PDF renderer uses an iframe. The framework now defaults to frame-ancestors 'self'
and X-Frame-Options: SAMEORIGIN, so an app can embed its own files. Apps can retain full
framing denial with security.frameOptions: "DENY"; explicit CSP directives still win.
Conversion
import { viewerPlugin } from "@wrnexus/viewer/plugin";
import { localBinaryConverter } from "@wrnexus/viewer/convert";
plugins: [viewerPlugin({ convert: { converters: [localBinaryConverter()] } })];
That mounts GET /api/viewer/capabilities, /preview, /thumb and /text. Without the
convert option no routes exist and no external process is ever run.
The client asks capabilities once per page and only requests a conversion for an extension
the server actually advertises, so a host without the binaries costs one small request and
every affected file keeps its download card. localBinaryConverter probes for soffice,
magick, ffmpeg and 7z on first use; a missing binary is not an error.
Converted output is cached in-process, keyed by stable source identity, extension, operation, converter name
and version, and returned with X-Viewer-Cache: hit|miss. The source resolver runs before every cache lookup,
so an expired or withdrawn signed link cannot read cached output. Unused source streams are
cancelled on cache hits. Responses are private and no-store.
Apps that store files privately implement the contract themselves:
viewerPlugin({
convert: {
converters: [myGotenbergConverter],
// Apply your own access checks here; the default accepts same-origin URLs only.
resolveSource: async (request, { src, name }) => ({
body: await openStream(src, name),
identity: objectRevision(src),
}),
maxInputBytes: 32 * 1024 * 1024,
},
});
Converter safety
soffice, magick and ffmpeg are large C/C++ parsers with a long CVE history, pointed at
untrusted files. Every run gets a hard wall-clock timeout, a private scratch directory as its
only writable path, a stripped environment (no proxy or credential variables), no stdin, and
a cap on the bytes read back. Input is size-capped before it reaches a binary. Converter
error messages stay server-side — they can carry file paths — and the client sees only a 422.
The default source policy accepts same-origin URLs and fetches them without credentials, so
the endpoint cannot be used to reach a private network or to read anything the caller could
not already read anonymously.
Security
Uploaded files are hostile input, so nothing here trusts a filename.
- Classification uses the extension, declared/server MIME, and then up to 4 KiB of signature bytes. Extension overrides cannot bypass conflicting signatures, and executable names or signatures always fall back.
- Nothing from a file ever reaches
innerHTML. Text, tables and trees are built as DOM nodes withtextContent. - The SVG frame is its own document, so theme tokens and
currentColorcannot reach it and its base background is opaque. The host's resolved text and surface colours are copied into the frame by value, which keeps themed artwork legible in light and dark. - SVG is sanitised to an element/attribute allowlist — no
script, noforeignObject, no event handlers, and only same-document#fragmentreferences — and rendered inside aniframewithsandbox=""underdefault-src 'none'. Neither layer is trusted alone. - A file whose bytes look like active markup is shown as inert source regardless of its name,
including a
.jsonor.pngthat carries a<script>tag.
Resolution and limits
resolve(source) returns a renderer and ordered fallback list. Signature checks select a
renderer; the browser decoder still validates the complete file.
For remote sources the resolver requests HEAD before a bounded Range read. A server without HEAD/Range support can still work: the response stream is cancelled at the sniff limit. Complete preview reads enforce a byte cap even if Content-Length is absent or inaccurate, and the downloaded body is reclassified before rendering. A single network chunk can be larger than the cap; excess bytes are never retained as a preview. HTTP(S), blob and data URLs are supported; remote servers must allow browser CORS access. A kind with no renderer at all reports that no preview exists rather than a size limit.
Defaults: text/code 2 MiB, SVG 2 MiB, CSV/JSON/XML 8 MiB, images/PDF 32 MiB, audio/video and archives 64 MiB. Delimited files are additionally capped at 200,000 parsed rows, and only the visible window of rows is kept in the DOM. Remote native media streams from its original URL; local blobs remain bounded. Disposal aborts pending requests, destroys editors and revokes object URLs. Automatic mounts observe source changes and framework navigation.
Uploader-backed sources
Core is source-agnostic — src or blob is all it needs, and @wrnexus/uploader is an
optional peer dependency. Enabling uploads lets a component name a storage key instead:
<Viewer key={file.key} store="reports" />
import { viewerPlugin } from "@wrnexus/viewer/plugin";
plugins: [
viewerPlugin({
uploads: {
stores: ["media", "reports"],
secret: process.env.VIEWER_FILE_SECRET!,
ttlMs: 5 * 60 * 1000,
authorize: async (request, { store, key }) => canRead(await currentUser(request), store, key),
},
}),
];
That mounts GET /api/viewer/source and GET /api/viewer/file. The browser exchanges the key
for a descriptor, and only then loads anything:
- A public object answers with its own URL — no token, nothing signed, nothing to leak.
- A private object answers with a short-lived signed link to the file route, and only
after
authorizeagrees. Signing uses the uploader's owncreateSignedFileToken.
The file route serves Content-Disposition: inline, X-Content-Type-Options: nosniff and
Cache-Control: private, no-store — a per-viewer link must never enter a shared cache — and
answers HEAD and byte ranges so audio and video can seek. An expired, forged, or
withdrawn-store link is answered exactly like a missing one.
There is deliberately no default access check: a private store without both a secret and
an authorize hook is refused rather than served. stores is an allow-list; anything outside
it is refused before a key is even looked at, and keys that could escape their store
(.., absolute paths, control characters) are rejected outright.
The MIME in the descriptor comes from the uploader's own extension table, not a second copy of it here — and the viewer still sniffs the bytes before choosing a renderer.
For conversion, uploadedConversionSource() makes converted output inherit exactly the access
check of its input: a converter can only open what a valid signed link already grants.
import { uploadedConversionSource } from "@wrnexus/viewer/uploader";
viewerPlugin({
convert: { converters: [localBinaryConverter()], resolveSource: uploadedConversionSource() },
uploads: {/* … */},
});
Where the preview appears
Inline in a card is the default. Three other placements are built in.
Over the page. openViewer puts a file in a native <dialog>, which takes the top layer,
traps focus, closes on Escape and restores focus to whatever opened it. Closing disposes the
viewer, so a video stops and object URLs are revoked rather than leaking behind the overlay.
import { openViewer } from "@wrnexus/viewer";
const preview = openViewer({ src: file.url, name: file.name });
// preview.close() when you need to close it yourself.
Nothing needs wiring for the common case — any element carrying data-viewer-open opens the
file it names:
<button type="button" data-viewer-open data-src={file.url} data-name={file.name}>Preview</button>
It reads the same attributes the component takes (data-src, data-key, data-store,
data-name, data-mime, data-size, data-files), so a storage key or a whole filmstrip
opens the same way.
Covering the page. fullScreen mounts the viewer over the page with a Close control that
returns it inline. It is a layered overlay rather than the Fullscreen API, so it needs no user
gesture and can be the state a viewer starts in. Toggling the attribute later re-mounts it.
<Viewer src={file.url} name={file.name} fullScreen={true} />
A whole page. layout="page" drops the card chrome and fills the container, for a route
given over to one file.
<Viewer src={file.url} name={file.name} layout="page" />
Toolbar actions
Every control is an icon button carrying its name in aria-label, in a visually
hidden span for screen readers, and in a CSS tooltip on hover and focus. The icons are drawn
as SVG nodes from a small built-in set rather than markup strings, because the framework
enables Trusted Types in production where innerHTML is refused.
Print is offered on any preview that actually rendered — slides, tables, documents,
images and PDFs — and withheld on the download card, where there is nothing to print. It
prints the preview rather than the page: the viewer is marked and viewer.css hides
everything else, while a same-origin PDF frame is asked to print itself. Opt out with
<Viewer print={false} /> or mountViewer(host, source, { print: false }).
Apps add their own actions, either globally or per viewer:
import { registerViewerAction } from "@wrnexus/viewer";
const unregister = registerViewerAction({
id: "open-in-tab",
label: "Open in new tab",
icon: "preview", // a name from ICONS; falls back to one from the label
placement: "actions", // or "controls", beside the renderer's own buttons
when: ({ source, kind }) => kind === "pdf" && Boolean(source.src),
run: ({ source, content }) => window.open(source.src, "_blank", "noopener"),
});
mountViewer(host, source, {
actions: [{ id: "send", label: "Send for review", run: ({ source }) => share(source) }],
});
Actions are offered after every render, so when sees the renderer and kind that actually
drew, and a re-render (a Raw/Table toggle, say) never leaves duplicates behind. run receives
the content element the renderer drew into, so an action can read what is on screen instead
of fetching the file again. An action is offered on the download card too — only its when
decides — so "report this file" still works for something nothing can preview.
Custom renderers
import { registerRenderer } from "@wrnexus/viewer";
import { mountViewer } from "@wrnexus/viewer/runtime";
const unregister = registerRenderer({
kind: "text",
exts: [".log"],
tier: 2,
component: "LogViewer",
maxInlineBytes: 1024 * 1024,
load: () => import("./log-parser"),
async mount({ parent, file, button }) {
parent.textContent = await file.text();
button("Reload", () => location.reload());
return () => parent.replaceChildren();
},
});
Every renderer — built-in and app-defined — receives the same ViewerMountContext: the
parent container, the file blob, the source descriptor, the resolved kind, a button
factory for toolbar controls, an objectUrl helper whose URLs are revoked on disposal, a
degrade callback that abandons the renderer for the download card, and an abort signal.
component selects a presentation template when one exists; the mount adapter determines rendering. A renderer without a
mount can never be displayed, and the ladder moves to the next candidate.
Bundle registration and manual mounting in the same browser entry. New registrations take
precedence and return an unregister function; isolated RendererRegistry instances can be
passed to resolve. Failed optional loads move to the next renderer. Custom adapters are
trusted application code and must handle file contents safely.
The parsers are exported for reuse: parseDelimited, detectDelimiter, sanitizeSvg,
parseTar, isTar, gzipName, gunzip.
Source composition
The example accepts both signed uploader objects and anonymous static files:
import { firstOf, sameOriginSource } from "@wrnexus/viewer/convert";
import { uploadedConversionSource } from "@wrnexus/viewer/uploader";
resolveSource: firstOf(uploadedConversionSource(), sameOriginSource());
A resolver returns undefined when it cannot handle a source. Errors stop composition. The
same-origin resolver refuses viewer API links, cross-origin URLs, embedded credentials and
redirects. Uploader identities use the store and object key rather than the rotating token.
For mutable custom sources include a revision in identity; otherwise replacement bytes
may remain cached until TTL expiry. The URL remains the fallback identity.
Presentation components
ViewerShell composes ViewerToolbar, DownloadCard, and renderer templates:
ImageViewer, VectorViewer, PdfViewer, CodeViewer, SheetViewer, DocViewer,
MediaViewer, and ArchiveViewer. Copy a component into app/components with the same
name to override its presentation. Keep its data-viewer-* hooks. The browser mounts the
existing safe adapters into data-viewer-mount, preserves custom classes, and restores the
shell on disposal. Components are presentation shells; source handling stays with Viewer.
Filmstrip, waveform and extracted text
<Viewer files={[{src: '/photo.png', name: 'photo.png'}, {src: '/report.pdf', name: 'report.pdf'}]} />
<Viewer src="/report.pdf" name="report.pdf" richPdf={true} />
Manual mounting accepts the same source array, including local blobs. Up to 500 files are
shown; one preview is active. Arrow keys, Home and End move selection. Switching disposes
media, editors, worker work, object URLs and pending fetches. Image thumbnails use the source;
convertible files request /api/viewer/thumb sequentially with a 2 MiB limit. Missing or
non-image thumbnails keep the filename button. No converter is required to use a filmstrip.
Audio has a Show waveform action. It decodes on demand, caps the encoded read at 8 MiB, and accepts known durations up to ten minutes; unsupported or larger files retain native playback. The waveform combines all channels and includes an accessible seek slider.
Convertible sources have an Extract text action. The exported extractText(source, {baseURI, signal}) returns at most 2 MiB of plain text for search/index consumers. The action
shows the same text inline and reports unavailable extraction without breaking the preview.
Client conversion and optional-module helpers accept the host base URI for embedded documents
and tests, instead of requiring the global document.
Rich PDF success was verified in ordinary Chrome on 2026-09-09: the canvas visibly painted text and the renderer reported one completed page. Timeout fallback remains for hidden tabs.
Accepted limits
The conversion cache is per-process rather than shared, and byte ranges on a signed link are served only for buffered objects — a streaming driver answers the whole body, so seeking a large private video re-reads it.
Run bun test packages/viewer, bun run --cwd packages/viewer typecheck.