Ship file viewer, editor improvements, and private package releases
This commit is contained in:
1 parent
645914955c
commit
a4a31032b9
158 files changed
+10523
-137
No files matched your search
@@ -0,0 +1,332 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,142 @@
|
||||
# File viewer follow-ups (`@wrnexus/viewer`)
|
||||
|
||||
Date: 2026-09-09
|
||||
Status: implemented — accepted limits retained
|
||||
Follows: [file viewer design](2026-09-08-file-viewer-design.md), whose build order is complete
|
||||
|
||||
Build order steps 1-5 are implemented and the package renders every group in the format
|
||||
matrix. What remains falls into four kinds: defects found after the fact, work the design
|
||||
described but that was never built, one verification gap, and limits that were accepted
|
||||
deliberately and should stay accepted until something forces the issue.
|
||||
|
||||
Nothing here blocks using the package. The first section is the only part with a
|
||||
correctness argument behind it.
|
||||
|
||||
## Implementation result (2026-09-09)
|
||||
|
||||
- Stable conversion identities and authorization before cache hits; source streams released on hits.
|
||||
- Exported `firstOf` / `sameOriginSource`; demo uses the composite with signed uploads.
|
||||
- Renderer and chrome `.wrn` presentation components integrated through preserved DOM hooks.
|
||||
- Sanitized HTML preview by default, with Source toggle and strict sandbox/CSP.
|
||||
- Multi-file filmstrip consuming thumbnails, plus on-demand audio waveform and seek slider.
|
||||
- Extracted-text UI and bounded public helper consuming the text route.
|
||||
- Explicit host base URI for conversion/optional-module helpers, with regression tests.
|
||||
- Suppressed plugin factories are skipped before import/invocation.
|
||||
- Same-origin framing is now the framework default, explicitly approved by the user;
|
||||
explicit DENY still sets restrictive framing headers.
|
||||
- pdf.js success observed in ordinary Chrome: a completed page canvas visibly painted the
|
||||
sample document text. Waveform generation and filmstrip switching also verified there.
|
||||
|
||||
The per-process cache, streaming-driver byte-range limitation and host-binary DOCX fidelity
|
||||
remain accepted as this plan specifies. No distributed-cache or uploader-driver redesign was
|
||||
introduced. The findings below are the original rationale, retained for context.
|
||||
|
||||
## Defects worth fixing first
|
||||
|
||||
**A rotating token defeats the conversion cache.** `packages/viewer/src/convert/service.ts`
|
||||
keys cached output on `src`, and for a private uploader object `src` is
|
||||
`/api/viewer/file?token=…` — a link that rotates every `ttlMs` (5 minutes by default). Every
|
||||
view after the link is re-minted is a cache miss, so exactly the files most expensive to
|
||||
convert (a 40 MB `.docx` through LibreOffice) are the ones that never hit. The cache key must
|
||||
identify the _object_, not the URL that happens to reach it: have `resolveSource` return an
|
||||
optional stable identity (`store:key` for uploader sources, the URL for everything else) and
|
||||
hash that instead. Until then, private conversions re-run on a timer.
|
||||
|
||||
**The example app can no longer convert its own static files.**
|
||||
`examples/basic-app/wrnexus.config.ts` sets `resolveSource: uploadedConversionSource()`, which
|
||||
replaces the same-origin default outright. Uploader objects convert; the page's own
|
||||
`/viewer/sample.docx` cannot, because it has no signed link. On this machine that is invisible
|
||||
— no LibreOffice, so the request stops at 415 — and it would surface only on a host with the
|
||||
binaries installed. The example should use whichever composite lands below.
|
||||
|
||||
**There is no way to accept more than one kind of source.** `ConversionOptions.resolveSource`
|
||||
is a single function, so an app that stores some files in an uploader store and serves others
|
||||
statically has to hand-write the fan-out. Ship a composer — `firstOf(uploadedConversionSource(),
|
||||
sameOriginSource())` — and export `sameOriginSource` so the default is nameable rather than
|
||||
private to the module. Small surface, removes the trap the example fell into.
|
||||
|
||||
## Unbuilt from the design
|
||||
|
||||
**Renderer and chrome `.wrn` components.** The design's module layout lists `ViewerShell`,
|
||||
`ViewerToolbar`, `ImageViewer`, `MediaViewer`, `PdfViewer`, `CodeViewer`, `SheetViewer`,
|
||||
`DocViewer`, `ArchiveViewer` and `DownloadCard`; what exists is one `Viewer.wrn` plus
|
||||
imperative browser adapters sharing a `ViewerMountContext`. The adapters work and the seam is
|
||||
clean, so this is a presentation question, not a correctness one: components would let an app
|
||||
restyle the chrome through the normal component-override path instead of registering a
|
||||
renderer. Worth doing only if an app actually asks to restyle it.
|
||||
|
||||
**HTML still renders as inert source.** The design's security section describes `.html` in the
|
||||
same sandboxed, CSP-restricted frame as SVG. The frame exists (`renderers/frame.ts`) and SVG
|
||||
and `.docx` both use it; pointing `.html` at it is a small change. It was left out because
|
||||
inert source is strictly safer and nobody had asked for a rendered preview. Doing it means
|
||||
deciding what the default view is — preview with a source toggle, or the reverse.
|
||||
|
||||
**`/api/viewer/thumb` and `/api/viewer/text` are mounted but unused.** The routes work and the
|
||||
converter implements both. Nothing calls them because the two features that would — a
|
||||
multi-file filmstrip, and feeding extracted text to search — are not built. Either build a
|
||||
consumer or drop the routes; a live endpoint with no caller is a maintenance cost and an
|
||||
attack surface for nothing.
|
||||
|
||||
**Audio waveform and multi-file filmstrip.** Both are in the design, neither is built. The
|
||||
filmstrip is the one with a dependency: it wants `thumb`, and it wants a story for what a
|
||||
viewer showing _several_ files does to the toolbar and the disposal path.
|
||||
|
||||
## The verification gap
|
||||
|
||||
**pdf.js rasterisation has never been observed working.** The library and worker are served
|
||||
verbatim, `getDocument()` resolves and reports the page count in a real browser, and every
|
||||
step up to `page.render()` was verified. `page.render()` itself never completes in this
|
||||
environment, because pdf.js drives rasterisation from `requestAnimationFrame` and the
|
||||
preview pane reports `document.hidden === true` permanently, even when fronted. The renderer
|
||||
now bounds each page with a timeout and degrades to the native frame rather than hanging —
|
||||
that degradation _is_ verified — but the success path is unproven. It needs one run in an
|
||||
ordinary visible browser: open `/file-viewer` after `registerRenderer(richPdfRenderer())` and
|
||||
confirm a canvas paints. Until someone does that, treat rich PDF as untested.
|
||||
|
||||
## Outside the package
|
||||
|
||||
**Plugin discovery calls a suppressed manifest factory anyway.**
|
||||
`packages/plugin/src/discovery.ts:353` decides whether to keep a manifest-built plugin _after_
|
||||
constructing it, so a plugin configured explicitly in `wrnexus.config.ts` still has its
|
||||
factory invoked a second time with no options. That silently cleared the viewer's conversion
|
||||
config until `viewerPlugin` was changed to only ever arm, never disarm. Any plugin using the
|
||||
config-global idiom will hit this. Already queued as its own task.
|
||||
|
||||
**`frame-ancestors 'none'` stops an app framing its own assets.**
|
||||
`packages/core/src/headers.ts:119`, with `X-Frame-Options: DENY` alongside it. Embedding a
|
||||
same-origin PDF — or anything else in a frame — requires every app to opt out, as
|
||||
`examples/basic-app` now does. `'self'` would still block cross-origin clickjacking while
|
||||
letting an app embed its own content. This is a framework-wide security default, so it is a
|
||||
decision to take deliberately rather than a bug to fix in passing.
|
||||
|
||||
## Known limits, deliberately accepted
|
||||
|
||||
**The conversion cache is per-process.** A bounded `Map` with a TTL, so a multi-process deploy
|
||||
converts once per process and a restart loses everything. Moving it to `@wrnexus/cache` with
|
||||
the distributed invalidation layer is the obvious upgrade, and is only worth it once the key
|
||||
is fixed — a shared cache keyed on a rotating token would be shared misses.
|
||||
|
||||
**Byte ranges are served only for buffered objects.** `uploads/service.ts:135` slices a
|
||||
`Uint8Array` body for a `Range` request; a driver returning a stream answers the whole body
|
||||
instead. Seeking a large private video therefore re-reads it. Fixing this properly means a
|
||||
range-aware read on the driver contract, which belongs to `@wrnexus/uploader`, not here.
|
||||
|
||||
**Legacy `.doc` and `.ppt` remain converter-only.** The OOXML and OpenDocument families are
|
||||
ZIP containers of XML and are now read in-package, and RTF is read with no dependency at all.
|
||||
The pre-2007 OLE compound formats are a different problem — a binary record stream with no
|
||||
public schema — and reading them in a browser is not worth the size or the risk. LibreOffice
|
||||
handles them, and without it they keep the download card.
|
||||
|
||||
**`.docx` fidelity depends on a host binary.** Mammoth produces semantic HTML, not a page
|
||||
layout, so the converter's PDF is preferred and mammoth is the fallback. An app with no
|
||||
LibreOffice gets an approximation, and that is the honest ceiling of a pure-JS path.
|
||||
|
||||
**Three client helpers still read the global `document`.** `optional/load.ts:7` and
|
||||
`convert/client.ts:13,36`. `resolveKey` was changed to take the host's `baseURI` when it turned
|
||||
out to be untestable without a global; the others have the same latent problem and no test
|
||||
covering them. Consistency work, not a live bug.
|
||||
|
||||
## Out of scope, still
|
||||
|
||||
Unchanged from the original design: editing or annotation, file management, OCR,
|
||||
collaborative features, and executing anything from an uploaded file.
|
||||
Reference in new issue
Block a user