π ποΈ πΌοΈ Browser-embeddable remote viewer for PDFs, office docs, audio, video, and images.
A small Go control-center fronts a fixed pool of Kasm /
LinuxServer Webtop desktop containers β
pass ?url=... and the file opens in the right app inside an iframe.
πΉ f3l1x.io | π» f3l1x | π¦ @xf3l1x
Embedding rich file viewers in your product is a thousand papercuts: PDF.js for some types, an office viewer for others, a media player for the rest β each with its own quirks, sandbox story, and asset list. Viewdoc skips it. Open a real desktop in a tab, let the desktop open the file with its native app, and stream the pixels back. One iframe, every file type.
flowchart LR
browser["Browser<br/>localhost:8080/?url=β¦"]
subgraph cc["control-center (Go)"]
proxy["reverse-proxy<br/>/slot/{i}/*"]
api["POST /api/slot/{i}/params"]
end
subgraph viewer["viewer-i (Kasm / Webtop)"]
novnc["KasmVNC :6901<br/>or Webtop :3000"]
sidecar["sidecar :7000"]
hook["viewdoc.sh"]
apps["chromium Β· libreoffice Β· vlc<br/>xarchiver Β· wireshark Β· claws-mail"]
end
browser -->|iframe src=/slot/i/| proxy
browser -->|"{url:β¦}"| api
proxy -->|HTTP + WS| novnc
api -->|POST /params| sidecar
sidecar -->|fork| hook
hook -->|dispatch by ext| apps
docker compose build
docker compose up -d
open https://localhost:8443/ # iframe + URL bar + slot tabs
open "https://localhost:8443/?url=https://example.com/x.pdf" # auto-opens in slot 0
curl -sk https://localhost:8443/healthz # {"ready":4,"total":4}Default pool (VIEWER_SLOTS): 2Γ Kasm + 2Γ Webtop.
The dispatcher routes by extension:
| Group | Extensions | Opens with |
|---|---|---|
| Media | mp4, mkv, webm, mov, avi, mpeg, mpg, m4v, 3gp, 3g2, ts, mts, m2ts, vob, wmv, asf, divx, ogv, mp3, wav, flac, ogg, m4a, aac, opus, mka, wma, aiff, ape, mid |
VLC |
| Web / docs | pdf, html, htm |
Chromium (new window) |
| Images | png, jpg, jpeg, gif, webp, svg, bmp |
Chromium (new window) |
| Images (other) | tif, tiff, heic, heif, avif, ico, tga |
Ristretto (downloaded locally first) |
| Office | doc, docx, odt, rtf, xls, xlsx, ods, csv, ppt, pptx, odp, odg, vsd, vsdx, pub, pages, key, numbers |
LibreOffice |
| Ebooks | epub, djvu, xps, oxps, cbz, cbr, cb7 |
Atril (downloaded locally first) |
| Text | txt, log, json, yaml, yml, xml, md, ini, conf, cfg, toml |
Mousepad (downloaded locally first) |
| Archives | zip, 7z, rar, tar, gz, tgz, bz2, tbz2, xz, txz, zst, tzst |
Xarchiver (downloaded locally first) |
eml, msg |
Claws Mail (downloaded locally first; .msg converted via msgconvert) |
|
| Captures | pcap, pcapng, cap |
Wireshark (downloaded locally first) |
| 3D models | stl, obj, ply, glb, gltf, 3ds, vtk, vtp |
F3D (downloaded locally first) |
| CAD | dxf |
LibreCAD (downloaded locally first) |
| Fonts | ttf, otf, ttc |
GNOME Font Viewer (downloaded locally first) |
| Binaries | bin, exe, dll, so, o, elf, dat, dmp |
GHex (downloaded locally first) |
| Other | anything else | Chromium, otherwise xdg-open |
Only http:// and https:// URLs are accepted; anything else is refused before dispatch. Both images also ship exiftool for metadata inspection from a terminal, and Office-metric fonts (Liberation, Carlito, Caladea) so documents keep their original layout.
The system exposes two HTTP services. The control-center is the public entrypoint; the sidecar runs inside every viewer image on the compose network.
| Method | Path | Purpose |
|---|---|---|
| GET | / |
UI: iframe + URL bar + slot tabs |
| GET | /healthz |
{ready, total} reachability summary |
| GET | /api/slots |
slot table with per-slot reachability |
| POST | /api/slot/{i}/params |
forwards {params:{url,β¦}} to slot i |
| POST | /api/slot/{i}/restart |
restarts slot i's container (requires Content-Type: application/json) |
| ANY | /slot/{i}/* |
reverse-proxy β <slot[i]>:port (HTTP + WS) |
| GET | /* |
embedded UI assets (/app.js, /style.css) |
| Var | Default | Purpose |
|---|---|---|
LISTEN_ADDR |
:8080 |
Plain-HTTP bind address |
TLS_ADDR |
(unset) | If set, also bind TLS (self-signed cert) |
VIEWER_SLOTS |
kasm://viewer-kasm-1,kasm://viewer-kasm-2,webtop://viewer-webtop-1,webtop://viewer-webtop-2 (the compose services) |
Comma-separated slot list. See Slot configuration below for the full grammar. |
VIEWER_KASM_DEFAULT_PORT |
6901 |
VNC port used for bare entries and the kasm:// pseudo-scheme. |
VIEWER_WEBTOP_DEFAULT_PORT |
3000 |
VNC port used for the webtop:// pseudo-scheme. |
VIEWER_SIDECAR_DEFAULT_PORT |
7000 |
Sidecar port used when the sidecar half of a slot entry is omitted (or specifies no port). |
Each entry in VIEWER_SLOTS has the shape <vnc>[|<sidecar>]. The pipe is optional β omit it when the sidecar lives on the same host as the VNC endpoint (the compose default).
VNC half β accepted forms (port falls back to VIEWER_KASM_DEFAULT_PORT, except webtop:// which uses VIEWER_WEBTOP_DEFAULT_PORT):
| Input | Parsed as |
|---|---|
viewer-1 |
http://viewer-1:6901 |
viewer-1:9000 |
http://viewer-1:9000 |
http://viewer-1 |
http://viewer-1:6901 |
https://viewer-1 |
https://viewer-1:6901 |
https://viewer-1:6901 |
https://viewer-1:6901 (canonical Kasm) |
kasm://viewer-1 |
https://viewer-1:6901 (kasm pseudo-scheme = https+kasm) |
kasm://viewer-1:9000 |
https://viewer-1:9000 |
webtop://viewer-1 |
http://viewer-1:3000 (webtop pseudo-scheme = http+webtop) |
webtop://viewer-1:9001 |
http://viewer-1:9001 |
Sidecar half β accepted forms (port falls back to VIEWER_SIDECAR_DEFAULT_PORT; kasm:// / webtop:// are not allowed here):
| Input | Parsed as |
|---|---|
| (omitted) | http://<vnc-host>:7000 |
viewer-1 |
http://viewer-1:7000 |
viewer-1:7100 |
http://viewer-1:7100 |
http://other-host:7100 |
http://other-host:7100 |
https://sidecar-1:7443 |
https://sidecar-1:7443 |
Worked examples
# Compose (DNS-friendly, sidecar implicit on same host:7000)
VIEWER_SLOTS="kasm://viewer-kasm-1,kasm://viewer-kasm-2,webtop://viewer-webtop-1,webtop://viewer-webtop-2"
# Equivalent fully-explicit form
VIEWER_SLOTS="https://viewer-kasm-1:6901,https://viewer-kasm-2:6901,http://viewer-webtop-1:3000,http://viewer-webtop-2:3000"
# Nomad / dynamic ports β VNC and sidecar published on different host ports
VIEWER_SLOTS="kasm://10.0.0.5:23456|10.0.0.5:34567,kasm://10.0.0.6:11111|10.0.0.6:22222"
# Override the pool-wide defaults so bare entries become 7901 / 3100 / 7900
VIEWER_KASM_DEFAULT_PORT=7901
VIEWER_WEBTOP_DEFAULT_PORT=3100
VIEWER_SIDECAR_DEFAULT_PORT=7900
VIEWER_SLOTS="kasm://node-1,webtop://node-2"
# β slot 0 vnc=https://node-1:7901 sidecar=http://node-1:7900
# β slot 1 vnc=http://node-2:3100 sidecar=http://node-2:7900| Method | Path | Purpose |
|---|---|---|
| GET | /healthz |
plain-text ok |
| POST | /params |
validate + atomically write /tmp/viewdoc.{json,env}, fork viewdoc.sh |
| POST | /restart |
reply 202, then exit with code 42 β the image turns that into a container exit |
| Var | Default | Purpose |
|---|---|---|
LISTEN_ADDR |
:7000 |
Sidecar bind address (fixed by compose convention) |
The Restart button (POST /api/slot/{i}/restart) makes the viewer container exit; whatever supervises it brings up a fresh one. The sidecar can't kill the container itself (on Webtop it runs as abc, PID 1 is root), so it exits with code 42 and the image's wrapper does the rest:
- Kasm β
custom_startup.shloops on the sidecar; on 42 it sends SIGTERM to PID 1 (vnc_startup.sh, same uid), which exits. - Webtop β the s6
finishscript (runs as root) calls/run/s6/basedir/bin/halt; the container exits with code 42.
Any other sidecar exit just respawns the sidecar. Restarting requires a restart policy: compose's restart: unless-stopped works as-is. On Nomad, the default restart stanza (attempts = 2 per 30m, mode = "fail") runs out after a few clicks, and the allocation gets rescheduled β possibly to another node with new dynamic ports, which breaks a static VIEWER_SLOTS. Allow many quick in-place restarts:
restart {
attempts = 100
interval = "30m"
delay = "2s"
mode = "delay"
}Requires Go 1.27.2+ (go.mod pins the toolchain; older Go downloads it automatically) and Docker with Compose v2.
make test # go test ./...
go vet ./...
make build # docker compose build (all three images)
make cert # regenerate certs/server.{crt,key} for your host IPsCI (.github/workflows/docker.yml) runs go vet, go test -race, staticcheck, govulncheck, shellcheck and hadolint, then builds every image. Pushes to master publish linux/amd64 + linux/arm64 images to Docker Hub:
| Tag | Meaning |
|---|---|
dockette/viewdoc:<variant> |
latest master build |
dockette/viewdoc:<variant>-<short-sha> |
immutable build of that commit |
<variant> is control-center, viewer-kasm or viewer-webtop. The Kasm and Webtop base images are pinned by digest; Dependabot opens weekly PRs for new digests, Go builder images and GitHub Actions.
See how to contribute to this package. Consider to support f3l1x. Thank you for using this package.

