438 lines
21 KiB
Markdown
438 lines
21 KiB
Markdown
# @wrnexus/viewer
|
|
|
|
Complete implementation of the
|
|
[file viewer plan](../../docs/plans/2026-09-08-file-viewer-design.md).
|
|
|
|
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.
|
|
|
|
```wrn
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```wrn
|
|
<Viewer key={file.key} store="reports" />
|
|
```
|
|
|
|
```ts
|
|
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.
|
|
|
|
```ts
|
|
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.
|
|
|
|
```ts
|
|
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:
|
|
|
|
```wrn
|
|
<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.
|
|
|
|
```wrn
|
|
<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.
|
|
|
|
```wrn
|
|
<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:
|
|
|
|
```ts
|
|
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"),
|
|
});
|
|
```
|
|
|
|
```ts
|
|
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
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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
|
|
|
|
```wrn
|
|
<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`.
|