* feat(landing-page): plugin detail page interactive preview + share dialog
The new `/plugins/<manifest-id>/` detail page that shipped in #2926
landed without the two affordances PR #2679 added to the legacy
`/skills/<slug>/` and `/templates/<slug>/` pages: a click-to-expand
iframe of the live artifact, and a share dialog with brand-keyword
copy plus four-channel jump buttons (X / LinkedIn / Reddit /
Facebook). This restores both, sourced from the bundled-plugin
manifest under `plugins/_official/<bucket>/<slug>/open-design.json`.
## Interactive preview
Three preview-type behaviours, gated on `od.preview.type`:
- `video` (Cloudflare Stream URLs already in the manifest) —
inline `<video controls poster=...>` with the playable MP4 as
`<source>`. Detail-page row is unchanged from #2926; controls
double as the open-full affordance.
- `html` (a local `example.html` referenced by `od.preview.entry`,
only the `examples/` bucket today) — `<details>` toggle wraps the
poster image as the summary; clicking opens a sandboxed
`<iframe>` that loads the entry HTML lazily, with an
"Open in new tab ↗" pill in the frame's top-right corner so the
artifact can be inspected at full screen.
- `image` or no entry — static `<img>` (existing behaviour).
`copy-example-html.ts` is extended to mirror the local entry and any
`./assets/...` siblings to `out/plugins/<manifest-id>/<entry>` so the
iframe URL resolves on Cloudflare Pages instead of SPA-falling-back to
the homepage. The four examples carrying sibling-asset references
(flowai-live-dashboard-template, trading-analysis-dashboard-template,
open-design-landing, open-design-landing-deck) all render in-place.
## Share dialog
Same `<dialog data-share-dialog>` markup the legacy detail pages use,
so the global click handlers in `header-enhancer.astro`
(`data-share-open` / `data-share-copy` / `data-copy-link`) wire up
the open / copy actions automatically — no extra client bundle. Four
platform jumps (X / LinkedIn / Reddit / Facebook) plus a Copy-text /
Copy-link pair, with a single English template for now (the new
`/plugins/...` routes only generate English pages; localisation can
land alongside the i18n catch-all follow-up).
## Bundled in
- The `copy-example-html.ts` sibling-assets fix from open PR #2880.
Without it the existing `/skills/<slug>/` iframe still 404s on
Cloudflare Pages for after-hours-editorial-template and the four
others; bundling it here means the same script handles both
sources in one pass and sidesteps two PRs touching identical
helper code.
* fix(plugins): remove dangling preview.entry from example-hyperframes
The hyperframes example folder ships a SKILL.md (it's an instruction
manual for using the HyperFrames HTML format) but no runnable
`example.html`. The manifest still claimed `preview.type: html` /
`preview.entry: ./example.html`, which made the marketing site try
to iframe a non-existent file and forced the preview pipeline into
its `Path 3` fallback card — leaving the catalog row visually
inconsistent with the eleven sibling `video-template-hyperframes-*`
plugins that have real Cloudflare-Stream poster URLs.
Drop the preview block entirely so the manifest stops promising a
demo it can't deliver. The landing-page detail row continues to
render the typographic fallback card (sourced from title /
description / mode), which is now the honest representation:
"this is an instruction skill, not a renderable template".
* fix(landing-page): address PR #2958 review feedback on plugin preview pipeline
Two blocking issues called out in code review:
1) `bundled-plugins.ts` exposed `previewEntryUrl` for every manifest
that declared `preview.type: "html"`, even when the entry file
wasn't shipped. Several first-party manifests fall in this state
(example-design-brief's `./brief-preview.html`, example-x-research,
example-pptx-html-fidelity-audit, example-hatch-pet,
example-last30days, example-guizang-ppt, example-replit-deck,
example-live-artifact, example-html-ppt, example-dcf-valuation).
The detail page then rendered a click-to-expand iframe and popout
link to a file that copy-example-html.ts had skipped, so the
iframe URL SPA-fell-back to the homepage on Cloudflare Pages.
`entryRelativeUrl()` now `existsSync()`-checks the resolved local
path before returning a URL. When the file's missing the detail
page falls through to the static thumbnail branch, exactly like
plugins that ship no preview entry at all.
2) `copy-example-html.ts` recognised only `(src|href|poster)="./assets/..."`
and then bulk-copied the entry's sibling `assets/` folder, so it
missed two real ref shapes: bare-relative (`href="assets/styles.css"`,
`src="assets/deck-stage.js"` under example-html-ppt-zhangzara-pin-and-paper)
and cross-folder (`src="../open-design-landing/assets/hero.png"`
under example-open-design-landing-deck).
Replaced the heuristic with a generic walker that:
- Parses every relative ref in the entry HTML
(`(src|href|poster|srcset|data-src)=` plus `url(...)`), splitting
srcset on whitespace/commas so multi-URL attrs are honoured.
- Resolves each ref against `dirname(entrypointSrc)` for the source
and against `dirname(iframeAbsPath)` for the destination —
identical to how a browser resolves the same ref against the
iframe URL. Files outside the source root or the iframe root
are dropped.
- Recurses into copied HTML / CSS / JS / SVG so multi-step chains
(entry → assets/template.html → assets/fonts/foo.woff) don't
strand intermediate files.
- Tracks visited *destinations* rather than sources, so a single
source that legitimately needs to land at two different out-paths
(same-folder copy at /plugins/example-X/assets/foo.png AND a
cross-folder copy at /plugins/open-design-landing/assets/foo.png
for sibling decks that use `../open-design-landing/assets/foo.png`)
gets both copies.
Verified manually:
- /plugins/example-html-ppt-zhangzara-pin-and-paper/assets/styles.css
and assets/deck-stage.js → 200 (bare-relative)
- /plugins/open-design-landing/assets/hero.png and assets/about.png
→ 200 (cross-folder destination, no manifest-id prefix because
iframe URL `..` collapses the prefix)
- /plugins/example-design-brief/ renders the static thumbnail only,
no click-to-expand iframe (broken entry guard)
- /plugins/example-flowai-live-dashboard-template/assets/template.html
→ 200 (existing same-folder behaviour preserved)
Build now reports `copied 266 entry files + 65 referenced files`,
where the 65 includes both the same-folder `./assets/...` payloads
the previous heuristic captured and the bare-relative + cross-folder
shapes it didn't.
---------
Co-authored-by: Joey-nexu <joeylee12629@gmail.com>
* feat(landing): add /share-out redirect for X share button click tracking
Adds a Cloudflare Pages Function at /share-out/:eventId that records each
click of the "Share on X" button surfaced in the contributor card comments
on GitHub, then 302-redirects to the original twitter.com / x.com intent
URL (passed via ?to=, host-allowlisted).
Together with the existing /share/:eventId function this gives us both
sides of the X funnel without an X API key:
- /share-out/:eventId -> GitHub user clicked the X button (funnel step 1)
- /share/:eventId -> someone on X clicked the posted tweet (funnel step 2)
Per-event KV storage is optional (SHARE_OUT_CLICK_EVENTS). When no KV is
bound the function falls back to console.log; aggregate counts are visible
in Cloudflare Pages analytics with no extra setup.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: retrigger CI
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: ashley li <ashleyli@ashleydeMacBook-Air-2.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
The BYOK chat path streams model text directly through the provider API and
does not run the agent-runtime scaffolding that wires up Read/Write/Edit
tools — so a model that "decides" to edit a file just emits HTML or code
back into the chat. When users switch from Local CLI (Claude Code) to
BYOK with an Anthropic key and then ask the agent to keep adjusting a
design, nothing on disk changes and the failure mode is silent.
Until the BYOK tool loop is implemented (#313 / #699 / #719), surface a
clear notice in the BYOK panel of Settings that explains the limitation
and points users at Local CLI mode for file edits.
Generated-By: looper 0.0.0-dev (runner=worker, agent=claude-code)
Co-authored-by: libertecode <libertecode@proton.me>
Adds a new `--to zip` (and `--to all`) tools-pack Windows build target that
produces a portable `.zip` from the cached `win-unpacked` tree using the
bundled 7z. The zip lays files at the archive root so users can extract it
anywhere and launch `Open Design.exe` without going through the NSIS
installer, addressing the no-install download request.
Release plumbing is updated to publish the portable zip and its sha256
beside the existing installer on R2 for beta, preview, and stable channels
(default on, gated by `WINDOWS_INCLUDE_ZIP`/`WIN_INCLUDE_ZIP`). The
electron-updater `latest.yml` feed continues to point only at the
installer; the zip is a manual-download convenience and is intentionally
excluded from the in-app updater.
Closes#1121
Generated-By: looper 0.0.0-dev (runner=worker, agent=claude-code)
Co-authored-by: libertecode <libertecode@proton.me>
AgentIcon fell back to an initial-letter pill for Aider and Trae CLI
because neither id was registered in ICON_EXT and no asset shipped. Add
the bundled brand marks so both agents render their real logo:
- aider.png — Aider's published avatar, downscaled to 96px (~2.6KB).
- trae-cli.png — Trae's app icon, downscaled to 96px (~2.3KB). Keyed on
the `trae-cli` runtime id so the file and ICON_EXT entry match exactly.
Both vendors only publish rasterised marks, so they follow the existing
PNG-fallback path used for Devin.
Pi shipped a stale single-glyph silhouette in MONO_ICONS. Replace it
with the current dark-tile mark (white glyph on #09090b) and drop it
from MONO_ICONS — the tile has baked colors, so CSS-mask rendering with
currentColor would flatten it to a solid square. It now renders through
<img> like the other color-baked brands.
Adds AgentIcon test coverage for all three.
When creating a new conversation, the route-sync effect could fight the
conversation switch if the URL was not updated synchronously. This caused
users to be unable to switch back to older conversations after creating
a new one.
The fix mirrors the approach already used in handleSelectConversation
(added in PR #1710): push the new conversation ID into the URL
synchronously so the route-sync effect sees a matching routeConversationId
before it can revert activeConversationId.
Without this synchronous URL update:
1. handleNewConversation sets activeConversationId to fresh.id
2. The URL-sync effect (L1336) eventually updates the URL
3. But the route-sync effect (L800-819) may see the stale routeConversationId
and pull activeConversationId back to the old conversation
4. This prevents users from switching to other conversations
Fixes#2930
When switching from Edit to Draw mode, the preview could go blank because:
1. exitManualEditModeAfterFlush() clears manualEditFrozenSource
2. previewSource switches back to livePreviewSource
3. But activateSrcDocTransport() was not triggered
This fix adds a useEffect that detects when manualEditMode transitions
from true to false, and explicitly calls activateSrcDocTransport() to
ensure the iframe content is refreshed.
Fixes#2912
Some skill and design-template `example.html` files are thin shells that
iframe a neighbouring `./assets/<file>` (template HTML, mp4 showcases,
hero PNGs, etc.). The post-build copier only mirrored the entrypoint
file itself, so on Cloudflare Pages the asset path 404'd and the SPA
fallback served the OD homepage — users clicked "Click for live
preview" on /skills/after-hours-editorial-template/ and saw the landing
page rendered inside the iframe instead of the actual template.
Walk the entrypoint HTML for `(src|href|poster)="\./assets/..."` refs
and recursively mirror the sibling `assets/` directory only when one is
found. Most skills carry an `assets/` folder of reference PNGs the demo
never loads (open-design-landing alone is 22MB of unused mocks); a
relevance gate keeps the deploy from absorbing tens of MB per skill
that doesn't actually need them.
Six skills/templates currently match (after-hours-editorial-template,
8-bit-orbit-video-template, weread-year-in-review-video-template,
flowai-live-dashboard-template, trading-analysis-dashboard-template,
open-design-landing).
Co-authored-by: Joey-nexu <joeylee12629@gmail.com>
The raw HTML fetch for the preview source used no cache-bust hint, so
an agent edit while Comment mode was on returned stale bytes from the
browser HTTP cache. With identical source, srcDoc was byte-equal to the
last activated HTML, canActivateSrcDocTransport bailed via its dedupe
check, and the iframe stayed on the pre-edit frame until Comment was
toggled off (at which point url-load took over with its own ?v=mtime
cache-bust). Cache-bust on file.mtime + reloadKey + filesRefreshKey
so fresh HTML reaches the shell on every change.
A null mid-burst (chokidar emits agent rewrites as unlink+add+change)
would also blank source and snap srcDoc empty; ignore null responses
so the previous frame stays until valid HTML arrives.
Subsequent activations in the same shell would document.open + write
over the iframe. The window message listener survives, but
iframe.onLoad does not refire for document.write, so host-side re-init
(slide nav sync, scroll restore, bridge replay) is silently skipped —
the visible page can drift out of sync with the host's tracked state
(e.g. the bottom indicator reads 3 while the iframe rendered page 4 of
the freshly edited deck). Under Comment, force a fresh shell mount on
the second activation so onLoad fires and the full re-init pipeline
runs against the new HTML. Manual Edit keeps the postMessage path
(its patched HTML must not lose host-side scroll/slide state).
Co-authored-by: nicejames <nicejames@gmail.com>
* fix(daemon): widen HTTP keep-alive so SSE survives idle gaps
The daemon's `/api/runs/:id/events` SSE stream emits an in-band
`: keepalive` comment every 25s (`SSE_KEEPALIVE_INTERVAL_MS`), but
Node's default `server.keepAliveTimeout` is 5_000ms. When a run is
quiet for more than five seconds — e.g. the agent is still composing,
or the user briefly walks away — Node closes the underlying TCP
connection from under the SSE writer, the next 25s ping lands on a
dead socket, and the browser surfaces it as a generic
"network error" mid-stream.
This is most visible behind any keep-alive-aware middlebox (the
nginx running in the desktop bundle, the socat/docker bridges users
set up for remote access, EC2 security-group idle timers): the
default 5s window is shorter than every reasonable in-band keepalive
cadence, so the connection dies before the application gets a chance
to assert it's still alive.
Set the listener to:
- `keepAliveTimeout = 120_000` — 4.8× the in-band keepalive, plenty
of slack for clock skew and slow flushes.
- `headersTimeout = 125_000` — must exceed `keepAliveTimeout` per the
Node docs, otherwise a misbehaving client can stall request parsing
indefinitely.
- `requestTimeout = 0` — disable the per-request timeout entirely;
an SSE response intentionally runs for as long as the agent runs.
Verified by curling
`/api/runs/<id>/events` from inside the daemon container and
watching the connection stay open through three full 25s keepalive
cycles where it previously RST'd at ~5s.
* fix(daemon): address PR #2557 review — drop requestTimeout, add regression test
Three changes responding to @PerishCode's review (#2557):
1. Drop `server.requestTimeout = 0`. The reviewer is correct: that knob
bounds how long the server waits to *receive* a complete request
(headers + body) and is cleared the moment the request is fully
parsed — it does not gate the duration of an SSE response. Setting
it to 0 only removes Node 18+'s default 300s slow-loris guard, which
is a real regression on a daemon that binds to 0.0.0.0 / Tailscale.
2. Rewrite the comment block. The previous comment claimed
`keepAliveTimeout` "closes any idle SSE connection." Per the Node
docs, `keepAliveTimeout` arms *after* a response finishes writing —
it bounds the between-request idle gap on a kept-alive socket, not
an in-flight streaming response. SSE drops mid-stream are almost
always middlebox idle timers (nginx, socat/docker, EC2 NAT), not
Node's own socket timeout, and this listener-side change cannot
extend a connection past those middleboxes.
What this PR actually fixes: routine kept-alive sockets used around
an SSE stream (status polls, run-status fetches, the initial GET
before the SSE upgrade) surviving normal client pauses. 120s gives
comfortable headroom over the 25s in-band cadence so chat clients
stop reconnect-storming between bursts.
3. Add `apps/daemon/tests/server-keepalive.test.ts` so a future
refactor cannot silently restore the Node defaults. The test uses
the existing `startServer({ port: 0, returnServer: true })` fixture
(mirroring version-route.test.ts) and asserts the listener's
`keepAliveTimeout` and `headersTimeout` invariants.
Verified:
- pnpm --filter @open-design/daemon run typecheck passes
- pnpm vitest run tests/server-keepalive.test.ts → 2 passed
Users can type a prompt in a conversation, reload the app, and expect that unsent text to remain tied to the same conversation. Store only the active conversation's composer draft under a project+conversation localStorage key and clear it once the draft is submitted or queued.
Constraint: The composer already remounts by activeConversationId, so persistence can stay local to ChatPane/ChatComposer without changing daemon contracts.
Rejected: Persist draft text in SQLite messages | unsent drafts are local UI state and should not appear in conversation history.
Confidence: high
Scope-risk: narrow
Directive: Keep initialDraft higher priority than stored drafts so seeded workflows are not overwritten by stale local text.
Tested: pnpm --filter @open-design/web test tests/components/ChatComposer.send-key.test.tsx tests/components/ChatComposer.queue-button.test.tsx
Tested: pnpm --filter @open-design/web typecheck
Co-authored-by: nicejames <nicejames@gmail.com>
Wire Aider (https://aider.chat) into the daemon agent registry alongside
the existing 16 CLIs. Aider is one of the most-used open-source coding
CLIs and routes through LiteLLM, so users can drive any provider they
already have a key for (OpenAI, Anthropic, DeepSeek, Gemini, OpenRouter,
local Ollama, etc.) without an extra adapter per provider.
Implementation follows the DeepSeek TUI pattern: prompt-via-argv with a
30 KB byte budget guard, plain stdout streaming, and the suppression
flags needed to keep aider runnable without a TTY (--yes-always,
--no-pretty, --no-git, --no-auto-commits, --no-suggest-shell-commands,
--no-show-model-warnings). `--message-file -` is not used because aider
treats `-` as a literal filename rather than a stdin sentinel.
Touchpoints mirror the other one-shot adapters:
- runtimes/defs/aider.ts new RuntimeAgentDef
- runtimes/registry.ts register in AGENT_DEFS
- runtimes/executables.ts AIDER_BIN override
- app-config.ts AIDER_BIN in agent env set
- web/utils/agentLabels.ts 'Aider' display label + aliases
- tests/runtimes/agent-args.test.ts buildArgs shape coverage
- tests/runtimes/env-and-detection bin override coverage
- tests/runtimes/helpers shared `aider` test helper
Validated with `pnpm guard`, `pnpm typecheck`, and
`pnpm --filter @open-design/daemon test tests/runtimes` (123 passing).
End-to-end probe against the live aider binary against DeepSeek via the
exact argv the adapter produces returned the expected output.
* feat(daemon): attach structured diagnostics to agent connection test results
Local agent connection-test failures currently flatten everything into
a single free-form `detail` string (e.g. "exit 1"). Settings UI and CLI
consumers can't tell what phase failed, which binary the daemon picked,
or what the child's exit metadata looked like — they have to scrape the
human-readable text.
Add an optional `diagnostics` block on the connection-test response so
callers can read structured fields instead. The existing `kind` and
`detail` strings are kept bit-for-bit identical, so older UIs keep
rendering unchanged.
- packages/contracts: add `ConnectionTestPhase`
(binary_resolution / version_probe / model_list / spawn /
connection_smoke_test / output_parse) and a `ConnectionTestDiagnostics`
interface with optional `binaryPath`, `binaryVersion`, `exitCode`,
`signal`, `stdoutTail`, `stderrTail`; extend
`ConnectionTestResponse.diagnostics?` to carry it.
- apps/daemon/connectionTest.ts: thread a `phase` tracker through
testAgentConnectionInternal, flip it at the meaningful boundaries
(binary_resolution → spawn → connection_smoke_test / output_parse),
and stamp diagnostics into every result return point — the four
result helpers plus both early returns. Tail data already buffered
by `createAgentSink` is reused; nothing new is captured.
- tests: three regressions per #2248 — success path attaches
phase='connection_smoke_test' + exitCode 0, exit-failed path
attaches phase='spawn' + the failing exitCode + the stderr tail,
and a missing-CLI path attaches an early-phase diagnostics block.
This is PR 1 of the #2248 plan (contracts + minimum daemon fill);
follow-ups will introduce a normalized failure classifier
(binary_not_found, unsupported_version, auth_failed, quota_exceeded,
network_failed, unsupported_flags, no_text_output, output_parse_failed,
spawn_failed), candidate-alternative reporting via
inspectAgentExecutableResolution, and the Settings "View details"
disclosure.
Refs #2248.
* fix(connectionTest): honor diagnostics contract on all local return paths
Two follow-ups from review of #2419:
- packages/contracts/src/api/connectionTest.ts advertises diagnostics
as 'Always set on local agent test responses', but three local
returns still bypassed buildDiagnostics(): the buildArgs failure
around 1295, the preflight probeAgentAuthStatus().status === 'missing'
branch around 1317, and the outer catch around 1566. Thread
buildDiagnostics() through all three; phase is still 'binary_resolution'
at the first two and whatever the runtime advanced to at the catch.
- resultFromAgentText() hard-coded exitCode: 0 even though
resultFromChildExit() routes ACP clean-SIGTERM completion through
this success helper (winner.code === null, winner.signal ===
'SIGTERM' with acpCleanCompletion). Add an optional exit argument
threaded from both call sites so the diagnostics reflect the actual
child code/signal pair instead of a synthesized 0 that masks the
SIGTERM teardown. Only synthesize 0 when no exit context is
available (theoretical text-without-exit path).
Tests:
- regression locking the diagnostics contract for the preflight auth
path on Cursor Agent (phase: binary_resolution, binaryPath set)
* docs(contracts): widen diagnostics contract to match early-failure paths
Reviewer flagged that the JSDoc-style comment on
ConnectionTestResponse.diagnostics still said 'Populated only when the
test actually spawned an agent CLI', but the previous follow-up made
the daemon stamp diagnostics on three pre-spawn local-agent failures
too: the unknown-agent and unresolved-binary branches around
connectionTest.ts:1123-1148 and the preflight auth return around
1338-1353. Reword the contract so Settings/CLI consumers do not
incorrectly special-case those early local failures as
diagnostics === undefined.
* fix(connectionTest): keep contracts browser-safe and fold probe output into preflight diagnostics
Two follow-ups from review of #2419:
- ConnectionTestDiagnostics.signal was typed as
`NodeJS.Signals | string | null`, which made the generated .d.ts of
the shared @open-design/contracts surface depend on ambient Node
types. Downstream consumers reading a plain HTTP response shape
should not need @types/node. Narrow to `string | null` (NodeJS.Signals
literals are strings, so the daemon write site is unchanged) and
document the boundary in the field comment.
- The Cursor-style preflight auth path stamped diagnostics built from
the smoke-test sink, which is always empty at that point because the
smoke spawn never happened. As a result the diagnostics block
silently dropped `cursor-agent status`'s own stderr/stdout/exit
context — the only structured failure information available on that
path. Thread the probe output back out of probeAgentAuthStatus()
via new optional stdoutTail/stderrTail/exitCode/signal fields, then
merge them into the diagnostics overrides in connectionTest.ts so
Settings/CLI consumers can render the auth-failure context instead
of just the guidance string.
Tests:
- extended the Cursor preflight regression to assert that diagnostics
carries the probe's stderr ("Not logged in") and exit code (1).
* chore(deps): upgrade express 4.22.1 -> 5.2.1 and @types/express
Breaking changes addressed:
- Renamed all bare wildcard route segments from * to *splat across
src/server.ts, src/static-resource-routes.ts, src/project-routes.ts,
src/import-export-routes.ts, and all three test stubs that define
app.get/options/delete routes using /raw/* or /raw/* patterns
- Updated wildcard param access from (req.params as any)[0] / req.params[0]
to Array.isArray(req.params.splat) ? req.params.splat.join('/') : String(...)
to handle the Express 5 / path-to-regexp v8 change where wildcard params
are now string[] instead of string
- Updated app.get('*') SPA fallback to app.get('/*splat') in server.ts
- Annotated five connector route handlers with Request<{ connectorId: string }>
so the typed param resolves as string, not string | string[], fixing the
10 TS2345 / TS2322 errors that surfaced when @types/express moved to 5.0.6
- Fixed two app.listen() beforeAll callbacks in origin-validation.test.ts to
accept and propagate the optional Error argument Express 5 now passes to
the listen callback, resolving TS2769 overload mismatch
* chore(nix): refresh daemonHash for rebased lockfile
* fix(daemon): await res.sendFile() in async route handlers for Express 5 compatibility
Express 5 res.sendFile() returns a Promise. Without await, async route
handlers return before the response is sent, causing Express to call
next() and fall through to a 404. Add await to all res.sendFile() calls
in async handlers in static-resource-routes.ts and server.ts.
* fix(daemon): use readFile+send for spritesheet route instead of sendFile
Express 5 res.sendFile() returns undefined (not a Promise). ENOENT errors
call next() asynchronously after the route handler's try/catch has returned,
causing unhandled 404 responses. Replacing with fs.promises.readFile + res.send
keeps the error path fully within the handler's try/catch.
---------
Co-authored-by: Patrick A <259201958+eefynet@users.noreply.github.com>