Files
WRNexusJS/packages/viewer

@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 with textContent.
  • The SVG frame is its own document, so theme tokens and currentColor cannot 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, no foreignObject, no event handlers, and only same-document #fragment references — and rendered inside an iframe with sandbox="" under default-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 .json or .png that 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 authorize agrees. Signing uses the uploader's own createSignedFileToken.

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.