Server
DynDyn.Pdf.Web is a self-hosted HTTP job API: upload a PDF, get a job id back, watch progress
over Server-Sent Events, then download or preview the converted HTML. It runs the same conversion
engine as the CLI and the library, so output is identical — the
server is a thin, stateful job wrapper around IPdfToHtmlConverter.
dotnet run --project src/DynDyn.Pdf.Web
# or, published:
./DynDyn.Pdf.Web --urls http://0.0.0.0:8080
A bundled upload page ships at / (static files from wwwroot), including a live drag-and-drop
demo that can also run the same conversion entirely in the browser — see wasm.md.
Job lifecycle
POST /api/jobs (multipart: file + options) ──▶ { jobId, token }
GET /api/jobs/{id}/events?token=… (SSE) ──▶ progress… → done | error
DELETE /api/jobs/{id}?token=… cancel an in-flight/queued job
GET /api/jobs/{id}/download?token=… the HTML (or a .zip for per-page output)
GET /api/jobs/{id}/preview?token=… inline HTML preview (single/viewer only)
GET /health liveness, never gated by auth
GET /ready readiness + capability probe, never gated by auth
A job is created, run on a background task, and its progress/terminal state is streamed over SSE.
Results live on the instance's local disk keyed by jobId — an in-process registry, no persistence
across restarts, no cross-instance sharing (see Scaling in deployment.md).
Every job is ownership-tokened. POST /api/jobs returns both a jobId and a token; every
other job route requires that same token (as a query parameter — events, download, preview
and the cancelling DELETE all check it) and responds 403 if it's missing or wrong. This is what
lets a public deployment serve concurrent anonymous uploads safely: knowing a jobId alone is not
enough to read or cancel someone else's job.
POST /api/jobs — create a job
Content-Type: multipart/form-data. Fields (all but file optional, with the shown default):
| Field | Default | Values |
|---|---|---|
file |
— (required) | the PDF (binary) |
engine |
managed |
managed, native |
format |
viewer |
single, pages, viewer |
rasterFallback |
auto |
never, auto, always |
background |
svg |
svg, raster, none |
dpi |
120 |
integer, clamped 18–600 server-side |
pages |
all | e.g. 1-5,8,10- |
lineGrouping |
always |
always, auto, never |
semanticTags |
off | true/on/1 |
detectDecorative |
off | true/on/1 |
extractStyles |
off | true/on/1 |
jpegQuality |
85 |
1–100 |
rawTtf |
off | true/on/1 |
password |
none | user password for an encrypted PDF (managed engine only) |
curl -sS -X POST http://localhost:8080/api/jobs \
-F file=@report.pdf \
-F format=viewer -F engine=managed -F dpi=150 -F pages=1-20
# {"jobId":"3f2c…","token":"…"}
Responses:
200 OK→{ "jobId": "…", "token": "…" }400 Bad Request→ not multipart, no file, or the file's first 5 bytes aren't%PDF-(a cheap magic-header sniff, not a parse)413 Payload Too Large→ the upload exceedsDynDynPdf:MaxUploadBytes(default 1 GB)503 Service Unavailable→ the instance is at its configured concurrent-job/live-job limit
`GET /api/jobs/
text/event-stream, Cache-Control: no-cache. Each data: line is a JSON object; the stream ends
after a terminal done or error. 404 for an unknown job, 403 for a wrong/missing token. If
the client disconnects before the job finishes, the job is cancelled server-side rather than left
to run out its slot.
{ "type": "progress", "stage": "extract", "total": 20, "extracted": 7, "composed": 3, "fraction": 0.42 }
{ "type": "done", "engine": "managed", "pages": 20, "warnings": 0, "elapsed": 4.2,
"downloadUrl": "/api/jobs/3f2c…/download", "previewUrl": "/api/jobs/3f2c…/preview",
"downloadName": "report.html" }
{ "type": "error", "message": "…" }
engine in the done event is the engine the job was configured to run — not an independent
verification — because the server never silently substitutes a different engine; a request for
native fails outright if pdfium isn't loaded rather than quietly running managed. A
password-protected PDF with no (or the wrong) password fails with a distinct
{ "type": "error", "code": "password_required", "message": "…" } so a client can prompt rather
than show a generic failure.
`DELETE /api/jobs/
Cancels an in-flight or still-queued job and frees its concurrency slot immediately, instead of
letting it run to completion or its timeout. 404 unknown job, 403 wrong/missing token,
200 { "cancelled": true } on success.
`GET /api/jobs/
download returns the converted file with Content-Disposition set to the original document name:
single/viewer → text/html; charset=utf-8; pages → application/zip (the whole per-page
folder). preview serves single/viewer HTML inline for an <iframe>; 404 for the pages format.
Both 404 for an unknown/unfinished job and 403 for a wrong/missing token.
GET /health and GET /ready
Never gated by auth — the only unguarded routes besides static assets.
/health→200 { "status": "ok" }once the process is up; no dependency checks./ready→200/503 { "status": "ready"|"unavailable", "nativeRenderer": bool, "nativeRasterizer": bool, "tempWritable": bool, "liveJobs": int }.nativeRendererreports whether pdfium loaded (gatesengine=native);nativeRasterizerreports whether raster fallback can actually run — it needs pdfium and SkiaSharp loaded independently, so a platform can have one without the other.tempWritablefailing is what flips the HTTP status to503; the native flags are reported, not gating, since the default managed + SVG pipeline needs neither.
Authentication
/api/* is gated by API-key middleware, controlled by DynDynPdf:ApiKeys and
DynDynPdf:AllowAnonymous:
- No keys configured (default),
AllowAnonymous=false(default): only loopback callers (127.0.0.1/::1) are served; everyone else gets401. Safe default for local/dev use. - No keys configured,
AllowAnonymous=true: open to anyone who can reach it — for deployments that terminate auth in front of the app (reverse proxy / gateway). ApiKeysset (comma-separated): every request must present one via theX-Api-Keyheader or anapiKeyquery parameter (soEventSourceand plain links can authenticate);AllowAnonymousis then ignored.
/api/* also carries a per-key / per-IP fixed-window rate limit (DynDynPdf:RateLimitRequestsPerMinute,
default 120/min).
Deployment settings
The Web service reads standard ASP.NET Core host configuration plus its own DynDynPdf section
(appsettings.json, DynDynPdf__Setting env vars, or the command line):
| Setting | Default |
|---|---|
ASPNETCORE_URLS / --urls |
http://localhost:5000 |
ASPNETCORE_ENVIRONMENT |
Production |
DynDynPdf:ApiKeys |
empty (loopback-only unless AllowAnonymous) |
DynDynPdf:AllowAnonymous |
false |
DynDynPdf:MaxConcurrentConversions |
0 (auto: derived from cores + available memory) |
DynDynPdf:MaxLiveJobs |
200 — new uploads past this get 503 |
DynDynPdf:JobTtlMinutes |
30 — idle job (and its temp dir) eviction |
DynDynPdf:CleanupIntervalSeconds |
60 |
DynDynPdf:RateLimitRequestsPerMinute |
120 |
DynDynPdf:MaxUploadBytes |
1 GB |
DynDynPdf:ConversionTimeoutSeconds |
600 (0/negative disables) |
DynDynPdf:MaxPagesPerJob |
0 (unlimited) |
DYNDYN_PDF_NATIVE_LIB |
unset — points at a pinned/patched native rendering library |
Full field-by-field reference: configuration.md and deployment.md.
Container image
No Dockerfile ships in this repository for the Web project. deployment.md
includes a sample Dockerfile you can copy into your own build — the native rendering library
arrives via NuGet under runtimes/<rid>/native, so the runtime image needs no extra OS packages
beyond the stock mcr.microsoft.com/dotnet/aspnet:10.0 (or its -alpine/musl variant) base.
Operational notes
- Job storage is local disk + in-memory. Uploads and results live under
%TEMP%/dyndyn-pdf-web/<jobId>; the registry does not survive a restart and is not shared across instances. See Scaling in deployment.md for horizontal-scale guidance (sticky sessions or job-per-pod today). - Uploads are validated by magic header only — a cheap sniff, not a parse. The conversion pipeline itself is what has to survive a hostile-but-well-headed PDF; see security.md.
- DPI is clamped 18–600 and page count can be capped independently of DPI/size via
DynDynPdf:MaxPagesPerJob— useful for a public demo capped at, e.g., 10 pages.
Related: cli.md for scripting/batch use of the same engine without a server, library.md for driving it in-process, and wasm.md for a zero-upload alternative that runs entirely in the caller's browser.