333 lines
17 KiB
Markdown
333 lines
17 KiB
Markdown
# 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](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:
|
|
|
|
```wrn
|
|
<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
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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
|
|
|
|
```ts
|
|
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](2026-09-09-file-viewer-follow-ups.md). 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.
|