From ea8544c9a6f04909cf18acf682eb7e7def88b36a Mon Sep 17 00:00:00 2001 From: Kaelem Chandra Date: Wed, 1 Jul 2026 01:06:52 +1200 Subject: [PATCH 1/2] pdf optimisations --- cable_map/frontend/dev/pdf.ts | 22 +- cable_map/frontend/src/api/pdf.ts | 34 ++- .../src/components/PdfExportProgress.tsx | 61 ++--- cable_map/operators/cable-map/Dockerfile | 2 +- .../cable-map/internal/server/pdf.go | 218 +++++++++++++----- .../cable-map/internal/server/pdf_jobs.go | 28 ++- go.work.sum | 4 +- 7 files changed, 246 insertions(+), 123 deletions(-) diff --git a/cable_map/frontend/dev/pdf.ts b/cable_map/frontend/dev/pdf.ts index 5ce92b2..222fe01 100644 --- a/cable_map/frontend/dev/pdf.ts +++ b/cable_map/frontend/dev/pdf.ts @@ -1,4 +1,5 @@ import { execFile } from 'node:child_process' +import { createHash } from 'node:crypto' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' @@ -77,9 +78,10 @@ export async function renderDevPdf({ topology, url, frontpanelDir, pdfAssetsDir, onProgress?.({ phase: 'Preparing assets', detail: 'Template ready', percent: 10 }) let renderedPanels = 0 + const panelByHash = new Map() const data = { - nodes: await Promise.all(nodes.map(async (node, index) => { - const panel = await writePanel({ node, index, work, frontpanelDir, svgName: stencils[node.name] || node.platform }) + nodes: await Promise.all(nodes.map(async (node) => { + const panel = await writePanel({ node, work, frontpanelDir, svgName: stencils[node.name] || node.platform, panelByHash }) renderedPanels += 1 onProgress?.({ phase: 'Rendering panels', @@ -113,16 +115,16 @@ export class DevPdfRequestError extends Error { async function writePanel({ node, - index, work, frontpanelDir, svgName, + panelByHash, }: { node: DevPdfNode - index: number work: string frontpanelDir: string svgName: string + panelByHash: Map }): Promise { const svgPath = panelPath(frontpanelDir, svgName) if (!svgPath) return '' @@ -141,8 +143,16 @@ async function writePanel({ if (cage) occupied.add(cage) } - const panel = `panel-${index}.svg` - await fs.writeFile(path.join(work, panel), colourPanel(svg, occupied)) + // Nodes sharing a stencil and cabled-cage set produce identical panels; write each + // distinct one once so Typst embeds a single shared resource (matches operator pdf.go). + const coloured = colourPanel(svg, occupied) + const key = createHash('sha256').update(coloured).digest('hex').slice(0, 24) + let panel = panelByHash.get(key) + if (!panel) { + panel = `panel-${key}.svg` + panelByHash.set(key, panel) + await fs.writeFile(path.join(work, panel), coloured) + } return panel } diff --git a/cable_map/frontend/src/api/pdf.ts b/cable_map/frontend/src/api/pdf.ts index adc17d2..ca6bfef 100644 --- a/cable_map/frontend/src/api/pdf.ts +++ b/cable_map/frontend/src/api/pdf.ts @@ -28,32 +28,44 @@ export async function startPdfJob(request: PdfJobRequest): Promise { return body.id; } +// streamPdfJob polls the job status until it reaches a terminal state. We poll rather than use SSE +// because the EDA httpproxy in front of the app buffers/drops streamed responses, which made the +// progress stream endlessly "reconnect" on the deployed path. export function streamPdfJob( id: string, onEvent: (event: PdfProgressEvent) => void, onReconnect: (message: string) => void, ): () => void { let closed = false; - const es = new EventSource(authenticatedUrl(`api/pdf/jobs/${encodeURIComponent(id)}/events`)); - es.addEventListener('message', (ev) => { - if (closed || !ev.data) return; + let timer: ReturnType | undefined; + + const poll = async () => { + if (closed) return; try { - const event = JSON.parse(ev.data) as PdfProgressEvent; + const res = await authenticatedFetch(`api/pdf/jobs/${encodeURIComponent(id)}/status`); + if (res.status === 404) { + closed = true; + onEvent({ status: 'error', phase: 'PDF export failed', message: 'PDF job expired or was not found' }); + return; + } + if (!res.ok) throw new Error(`status ${res.status}`); + const event = await res.json() as PdfProgressEvent; + if (closed) return; onEvent(event); if (event.status !== 'running') { closed = true; - es.close(); + return; } } catch { - onReconnect('PDF progress update was unreadable'); + if (!closed) onReconnect('Waiting for PDF…'); } - }); - es.addEventListener('error', () => { - if (!closed) onReconnect('PDF progress stream reconnecting...'); - }); + if (!closed) timer = setTimeout(() => void poll(), 700); + }; + void poll(); + return () => { closed = true; - es.close(); + if (timer) clearTimeout(timer); }; } diff --git a/cable_map/frontend/src/components/PdfExportProgress.tsx b/cable_map/frontend/src/components/PdfExportProgress.tsx index 4186966..0d5438f 100644 --- a/cable_map/frontend/src/components/PdfExportProgress.tsx +++ b/cable_map/frontend/src/components/PdfExportProgress.tsx @@ -1,6 +1,5 @@ import { memo, useEffect, useState } from 'react'; import Box from '@mui/material/Box'; -import CircularProgress from '@mui/material/CircularProgress'; import Fade from '@mui/material/Fade'; import LinearProgress from '@mui/material/LinearProgress'; import Paper from '@mui/material/Paper'; @@ -25,14 +24,13 @@ function PdfExportProgress({ state }: { state: PdfState }) { return () => window.clearInterval(timer); }, [loading]); + // Title = the current phase; subtitle = its detail (e.g. "Typst compile", "5 / 1000 nodes"). + // The metric (top-right) carries the % or elapsed, so neither is repeated below. const phase = loading ? state.phase : ''; const detail = loading ? state.detail : ''; const determinate = loading && !indeterminate; const roundedPercent = determinate ? Math.min(100, Math.max(0, Math.round(percent ?? 0))) : undefined; const metric = determinate ? `${roundedPercent}%` : formatElapsed(elapsedSeconds); - const progressDetail = indeterminate - ? [detail, `Elapsed ${formatElapsed(elapsedSeconds)}`].filter(Boolean).join(' · ') - : detail; return ( @@ -61,65 +59,32 @@ function PdfExportProgress({ state }: { state: PdfState }) { border: 1, borderColor: 'divider', overflow: 'hidden', - position: 'relative', pointerEvents: 'auto', }} > - - - - - - Rendering PDF - - - {metric} - - - - {phase} - - + + + {phase} + + + {metric} + - {progressDetail && ( - - {progressDetail} + {detail && ( + + {detail} )} - {loading && indeterminate && ( - - )} diff --git a/cable_map/operators/cable-map/Dockerfile b/cable_map/operators/cable-map/Dockerfile index 94d608a..f98ca24 100644 --- a/cable_map/operators/cable-map/Dockerfile +++ b/cable_map/operators/cable-map/Dockerfile @@ -5,7 +5,7 @@ FROM ${TARGET_IMAGE} # The /pdf endpoint renders with Typst (the static musl binary below). The operator embeds the # NokiaPureText fonts + template and passes them via --font-path; font-noto is just a glyph fallback. ARG TYPST_VERSION=0.15.0 -RUN apk add --no-cache ca-certificates font-noto \ +RUN apk add --no-cache ca-certificates font-noto qpdf \ && apk add --no-cache --virtual .typst-dl wget xz \ && wget -qO /tmp/typst.tar.xz "https://github.com/typst/typst/releases/download/v${TYPST_VERSION}/typst-x86_64-unknown-linux-musl.tar.xz" \ && xz -dc /tmp/typst.tar.xz | tar -x -C /tmp \ diff --git a/cable_map/operators/cable-map/internal/server/pdf.go b/cable_map/operators/cable-map/internal/server/pdf.go index 95c707b..8256686 100644 --- a/cable_map/operators/cable-map/internal/server/pdf.go +++ b/cable_map/operators/cable-map/internal/server/pdf.go @@ -2,7 +2,9 @@ package server import ( "context" + "crypto/sha256" "embed" + "encoding/hex" "encoding/json" "fmt" "net/http" @@ -121,23 +123,41 @@ func (e *pdfEngine) renderWithProgress(ctx context.Context, nodes []topology.Nod }{} totalNodes := len(nodes) + panelByHash := map[string]string{} // coloured-panel content hash -> filename + plans := map[string]*stencilPlan{} // stencil filename -> precomputed plan (nil if missing) + slottedByName := map[string]bool{} // stencil filename -> slotted cage ids for i, n := range nodes { panel := "" svgName := n.Platform if s := stencils[n.Name]; s != "" { svgName = s } - if svgBytes, err := os.ReadFile(filepath.Join(stencilDir, svgName+".svg")); err == nil { - slotted := svgSlotted(svgBytes) + plan, planned := plans[svgName] + if !planned { + if svgBytes, err := os.ReadFile(filepath.Join(stencilDir, svgName+".svg")); err == nil { + plan = planStencil(svgBytes) + slottedByName[svgName] = svgSlotted(svgBytes) + } + plans[svgName] = plan + } + if plan != nil { occupied := map[string]bool{} for _, r := range n.Table { - if c := cageKey(r.Label, slotted); c != "" { + if c := cageKey(r.Label, slottedByName[svgName]); c != "" { occupied[c] = true } } - panel = fmt.Sprintf("panel-%d.svg", i) - if err := os.WriteFile(filepath.Join(work, panel), colourPanel(svgBytes, occupied), 0o644); err != nil { - return nil, err + // Nodes that share a stencil and cabled-cage set produce byte-identical panels; write + // each distinct one once so Typst parses it a single time and embeds one shared resource. + coloured := plan.colour(occupied) + sum := sha256.Sum256(coloured) + key := hex.EncodeToString(sum[:12]) + if panel = panelByHash[key]; panel == "" { + panel = "panel-" + key + ".svg" + if err := os.WriteFile(filepath.Join(work, panel), coloured, 0o644); err != nil { + return nil, err + } + panelByHash[key] = panel } } data.Nodes = append(data.Nodes, pdfNode{Name: n.Name, Platform: n.Platform, Panel: panel, Rows: tableRows(n, remote)}) @@ -160,7 +180,49 @@ func (e *pdfEngine) renderWithProgress(ctx context.Context, nodes []topology.Nod if outp, err := cmd.CombinedOutput(); err != nil { return nil, fmt.Errorf("typst compile failed: %w: %s", err, strings.TrimSpace(string(outp))) } - return os.ReadFile(out) + if !pdfCompressEnabled() { // qpdf recompression trades render time for ~13% size — opt-in + return os.ReadFile(out) + } + reportPDFProgress(progress, "Compressing PDF", "", intPtr(95), true) + return e.compress(ctx, out) +} + +// pdfCompressEnabled gates the lossless qpdf pass (PDF_COMPRESS=1). Off by default: it adds +// noticeable time to large exports and only improves file size. +func pdfCompressEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("PDF_COMPRESS"))) { + case "1", "true", "yes", "on": + return true + } + return false +} + +// compress losslessly shrinks the Typst PDF with qpdf — object streams plus max-level flate +// recompression. Best-effort: if qpdf is absent (e.g. local dev) or fails, the original PDF is +// returned unchanged, so output stays 1:1 either way. +func (e *pdfEngine) compress(ctx context.Context, pdf string) ([]byte, error) { + bin := os.Getenv("QPDF_BIN") + if bin == "" { + bin = "qpdf" + } + if _, err := exec.LookPath(bin); err != nil { + return os.ReadFile(pdf) + } + small := pdf + ".min" + cmd := exec.CommandContext(ctx, bin, + "--object-streams=generate", "--recompress-flate", "--compression-level=9", pdf, small) + err := cmd.Run() + code := -1 + if cmd.ProcessState != nil { + code = cmd.ProcessState.ExitCode() + } + // qpdf exit 0 = success, 3 = warnings only (output still written); both are usable. + if err == nil || code == 3 { + if b, rerr := os.ReadFile(small); rerr == nil { + return b, nil + } + } + return os.ReadFile(pdf) } func reportPDFProgress(progress func(pdfRenderProgress), phase, detail string, percent *int, indeterminate bool) { @@ -259,31 +321,54 @@ func svgSlotted(svg []byte) bool { return false } -// colourPanel fills the occupied cages. -func colourPanel(svg []byte, occupied map[string]bool) []byte { - raised := raiseOccupiedCages(svg, occupied) - return rePathCage.ReplaceAllFunc(raised, func(tag []byte) []byte { - m := rePathCage.FindSubmatch(tag) - if m == nil || !occupied[portAttrBytes(m)] { - return tag +// stencilPlan is a stencil's occupancy-independent structure, computed once per platform so that +// colouring a node's panel doesn't re-scan the (identical-per-platform) ~500KB SVG every time. +// cages are every data-port cage (recoloured in place when occupied); raises are the green copies +// lifted above the pins, each at a precomputed insert position — both filtered by occupancy per node. +type stencilPlan struct { + svg []byte + cages []cageSpan // sorted by start + raises []raiseSpan // sorted by pos (stable in document order) +} + +type cageSpan struct { + start, end int + port string +} + +type raiseSpan struct { + pos int // byte offset in svg to insert the raised copy + port string + copy []byte // the original cage tag with its id stripped (greened per node) +} + +// planStencil precomputes the cage spans and raise insert points for one stencil SVG. +func planStencil(svg []byte) *stencilPlan { + var cages []cageSpan + for _, m := range rePathCage.FindAllSubmatchIndex(svg, -1) { + port := "" + if m[2] >= 0 { + port = string(svg[m[2]:m[3]]) + } else if m[4] >= 0 { + port = string(svg[m[4]:m[5]]) } - return colourCageTag(tag) - }) + cages = append(cages, cageSpan{start: m[0], end: m[1], port: port}) + } + return &stencilPlan{svg: svg, cages: cages, raises: computeRaises(svg)} } -type svgFrame struct { +type raiseFrame struct { name string - inserts []string labelAt int + cands []raiseSpan } -func raiseOccupiedCages(svg []byte, occupied map[string]bool) []byte { - if len(occupied) == 0 { - return svg - } +// computeRaises mirrors the old raise-occupied-cages structure pass, but for every cage (not just +// occupied ones), recording each raised copy's insert position. Occupancy is applied per node. +func computeRaises(svg []byte) []raiseSpan { text := string(svg) - stack := []svgFrame{} - inserts := map[int][]string{} + var stack []raiseFrame + var raises []raiseSpan for _, m := range reTag.FindAllStringSubmatchIndex(text, -1) { tag := text[m[0]:m[1]] name := strings.ToLower(text[m[2]:m[3]]) @@ -300,49 +385,78 @@ func raiseOccupiedCages(svg []byte, occupied map[string]bool) []byte { } frame := stack[i] stack = append(stack[:i], stack[i+1:]...) - if len(frame.inserts) > 0 { - insertAt := frame.labelAt - if insertAt < 0 { - insertAt = m[0] - } - inserts[insertAt] = append(inserts[insertAt], frame.inserts...) + insertAt := frame.labelAt + if insertAt < 0 { + insertAt = m[0] + } + for _, c := range frame.cands { + c.pos = insertAt + raises = append(raises, c) } continue } - if len(stack) > 0 && name == "text" && stack[len(stack)-1].labelAt < 0 && reTextAdded.MatchString(tag) { stack[len(stack)-1].labelAt = m[0] } if len(stack) > 0 && name == "path" && reSelfClose.MatchString(tag) { - if port := tagPort(tag); port != "" && occupied[port] { - stack[len(stack)-1].inserts = append(stack[len(stack)-1].inserts, reIDAttr.ReplaceAllString(tag, "")) + if port := tagPort(tag); port != "" { + top := &stack[len(stack)-1] + top.cands = append(top.cands, raiseSpan{port: port, copy: reIDAttr.ReplaceAll([]byte(tag), nil)}) } } if !reSelfClose.MatchString(tag) { - stack = append(stack, svgFrame{name: name, labelAt: -1}) + stack = append(stack, raiseFrame{name: name, labelAt: -1}) } } - if len(inserts) == 0 { - return svg - } - points := make([]int, 0, len(inserts)) - for point := range inserts { - points = append(points, point) - } - sort.Ints(points) + sort.SliceStable(raises, func(a, b int) bool { return raises[a].pos < raises[b].pos }) + return raises +} - var out strings.Builder - out.Grow(len(text)) - cursor := 0 - for _, point := range points { - out.WriteString(text[cursor:point]) - for _, insert := range inserts[point] { - out.WriteString(insert) +// colour assembles a node's panel: occupied cages recoloured green in place, plus their raised +// green copies inserted at the precomputed points — a single forward pass over the base SVG, no +// per-node regex scanning. Output is byte-identical to the old scan-per-node colourPanel. +func (p *stencilPlan) colour(occupied map[string]bool) []byte { + svg := p.svg + out := make([]byte, 0, len(svg)+512) + cursor, ci, ri := 0, 0, 0 + for ci < len(p.cages) || ri < len(p.raises) { + for ri < len(p.raises) && !occupied[p.raises[ri].port] { + ri++ + } + rp, cp := 1<<62, 1<<62 + if ri < len(p.raises) { + rp = p.raises[ri].pos + } + if ci < len(p.cages) { + cp = p.cages[ci].start + } + if rp == 1<<62 && cp == 1<<62 { + break + } + if rp <= cp { + out = append(out, svg[cursor:rp]...) + cursor = rp + out = append(out, colourCageTag(p.raises[ri].copy)...) + ri++ + } else { + c := p.cages[ci] + out = append(out, svg[cursor:c.start]...) + if occupied[c.port] { + out = append(out, colourCageTag(svg[c.start:c.end])...) + } else { + out = append(out, svg[c.start:c.end]...) + } + cursor = c.end + ci++ } - cursor = point } - out.WriteString(text[cursor:]) - return []byte(out.String()) + return append(out, svg[cursor:]...) +} + +// colourPanel fills the occupied cages. Single-shot callers plan inline; the renderer caches the +// plan per platform (planStencil) so the expensive scan happens once, not once per node. +func colourPanel(svg []byte, occupied map[string]bool) []byte { + return planStencil(svg).colour(occupied) } func tagPort(tag string) string { diff --git a/cable_map/operators/cable-map/internal/server/pdf_jobs.go b/cable_map/operators/cable-map/internal/server/pdf_jobs.go index eebf34c..0778dd7 100644 --- a/cable_map/operators/cable-map/internal/server/pdf_jobs.go +++ b/cable_map/operators/cable-map/internal/server/pdf_jobs.go @@ -130,7 +130,7 @@ func (j *pdfJob) update(event pdfJobEvent) { func (j *pdfJob) ready(pdf []byte) { j.mu.Lock() - j.pdf = append([]byte(nil), pdf...) + j.pdf = pdf // freshly rendered slice, not aliased — take ownership rather than copy (it can be 100s of MB) j.mu.Unlock() j.update(pdfJobEvent{Status: pdfJobReady, Phase: "PDF ready", Percent: intPtr(100)}) } @@ -163,7 +163,15 @@ func (j *pdfJob) subscribe() (<-chan pdfJobEvent, func()) { func (j *pdfJob) result() ([]byte, string, pdfJobEvent) { j.mu.Lock() defer j.mu.Unlock() - return append([]byte(nil), j.pdf...), j.filename, j.event + return j.pdf, j.filename, j.event // j.pdf is set once at ready and never mutated; no need to copy +} + +// status returns the latest progress event without copying the (large) rendered PDF — used by the +// polling endpoint, which the frontend prefers over SSE since the EDA httpproxy buffers streams. +func (j *pdfJob) status() pdfJobEvent { + j.mu.Lock() + defer j.mu.Unlock() + return j.event } func isTerminalPDFJobStatus(status pdfJobStatus) bool { @@ -262,6 +270,8 @@ func (s *Server) pdfJob(w http.ResponseWriter, r *http.Request) { return } switch parts[1] { + case "status": + s.pdfJobStatus(w, r, parts[0]) case "events": s.pdfJobEvents(w, r, parts[0]) case "download": @@ -271,6 +281,20 @@ func (s *Server) pdfJob(w http.ResponseWriter, r *http.Request) { } } +func (s *Server) pdfJobStatus(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + job, ok := s.pdfJobs.get(id) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "PDF job not found"}) + return + } + writeJSON(w, http.StatusOK, job.status()) +} + func (s *Server) pdfJobEvents(w http.ResponseWriter, r *http.Request, id string) { if r.Method != http.MethodGet { w.Header().Set("Allow", http.MethodGet) diff --git a/go.work.sum b/go.work.sum index 15171b0..724e477 100644 --- a/go.work.sum +++ b/go.work.sum @@ -136,15 +136,13 @@ go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools/go/expect v0.1.0-deprecated h1:jY2C5HGYR5lqex3gEniOQL0r7Dq5+VGVgY1nudX5lXY= golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= From 05c4c0c76317572096d6e78cb13f5ca713b6b745 Mon Sep 17 00:00:00 2001 From: flosch62 Date: Tue, 30 Jun 2026 15:40:17 +0200 Subject: [PATCH 2/2] fix mock dev --- cable_map/frontend/vite.config.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cable_map/frontend/vite.config.ts b/cable_map/frontend/vite.config.ts index cbed44e..2d1d533 100644 --- a/cable_map/frontend/vite.config.ts +++ b/cable_map/frontend/vite.config.ts @@ -223,7 +223,7 @@ const mockApiPlugin = (): Plugin => ({ return } const url = new URL(req.url ?? '/', 'http://localhost') - const pdfJobMatch = /^\/api\/pdf\/jobs\/([^/]+)\/(events|download)$/.exec(url.pathname) + const pdfJobMatch = /^\/api\/pdf\/jobs\/([^/]+)\/(status|events|download)$/.exec(url.pathname) if (pdfJobMatch) { cleanupPdfJobs() const job = pdfJobs.get(pdfJobMatch[1]) @@ -231,6 +231,10 @@ const mockApiPlugin = (): Plugin => ({ sendJSON(res, 404, { error: 'PDF job not found' }) return } + if (pdfJobMatch[2] === 'status') { + sendJSON(res, 200, job.event) + return + } if (pdfJobMatch[2] === 'events') { sendPdfJobSSE(req, res, job) return