dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
DSH 实时预览标签页:把任意 dev server 嵌入 iframe,透明代理 SPA 路由。
Live preview tab for DeepSeek Harness — embed any dev server in an iframe with transparent SPA proxying.
/preview/:port/* rewrites all routes so the embedded app works as if served from the DSH host404 is served the app shell so the client-side router can boot; assets, API calls and every path the dev server does serve keep their real answer#section links jump inside the page instead of reloading the site root, and anchors rendered after load are still reached/assets/photo.png, srcset and poster are rewritten too, so images and lazy-loaded stylesheets load instead of 404ing against the DSH hostfetch() and XMLHttpRequest.open() to rewrite both relative and absolute URLs (e.g. http://localhost:3001/api/... → /preview/3001/api/...)Set-Cookie are forwarded, so the embedded app can log in, submit forms and use PUT/PATCH/DELETE — not just serve pageshistory.pushState, window.open and links that open a new tab keep the proxy prefix; an app that assigns location.href (which no script can intercept) is brought back instead of leaving you on the DSH app$DSH_HOME/preview-plugin.json and restored when DSH starts againset_preview_port tool lets the agent configure ports programmatically when starting dev serversallow-scripts allow-same-origin allow-forms allow-popups for secure embeddingIn your DSH web profile's package.json:
{
"dependencies": {
"dsh-preview-plugin": "^1.3.2"
}
}
Installing from a local checkout works the same way:
{
"dependencies": {
"dsh-preview-plugin": "file:~/workspace/dsh-preview-plugin"
}
}
In your profile's package.json, add dsh-preview-plugin to the dsh.profile.bundles array:
{
"dsh": {
"profile": {
"bundles": [
"...",
"dsh-preview-plugin"
]
}
}
}
systemctl restart dsh
npm run dev -- --host 0.0.0.0 --port 3000)3000) in the URL field and click GoThe field then switches to the page being shown (e.g. /preview/3000/laptops?page=2#specs),
so you can edit it at any time and press Enter to load something else.
The URL field accepts all of these:
| You type | It loads |
|---|---|
4321 | port 4321, root page |
4321/laptops | port 4321, /laptops |
/laptops?page=2 | that path on the port already in use |
laptops | same as /laptops |
?page=2 / #specs | the current page with that query/fragment |
/preview/4321/laptops | a proxy path, as-is |
http://localhost:4321/laptops | the dev-server URL |
Non-local URLs are rejected (the proxy only reaches 127.0.0.1). ↻ reloads the
page currently shown, ↗ opens it in a browser tab of its own.
Start both servers:
# Backend (e.g. Payload CMS)
cd apps/backend && pnpm dev # port 3001
# Frontend (e.g. Astro)
cd apps/web && pnpm dev # port 4321
Open the Preview tab
Type 4321 in URL and 3001 in Backend (comma-separated for multiple)
Click Go — API calls to http://localhost:3001/api/... are automatically proxied through /preview/3001/api/...
The agent can configure ports programmatically using the set_preview_port tool:
set_preview_port(port: 4321, backendPorts: [3001])
This updates the interceptor script and client UI automatically.
| Method | Endpoint | Description |
|---|---|---|
GET | /api/preview-port | Get the current global port and backend ports |
GET | /api/preview-port?sessionId=<id> | Get ports for a session (falls back to global) |
POST | /api/preview-port | Set global ports ({ "port": 4321, "backendPorts": [3001] }) |
POST | /api/preview-port?sessionId=<id> | Set ports for a session |
/api/preview-port — port management/preview/:port/* — transparent proxy127.0.0.1:<port> with its method, headers and
body; the port must be a real TCP port (1–65535) and a request body larger
than the cap is refused with 413 instead of being bufferedAccept that
includes text/html, a path without a file extension) comes back as 404,
the proxy answers with the dev server's root HTML so the client-side router
handles the route. Missing assets, API 404s and everything the dev server
does serve are passed through untouched<base href="/preview/:port/"> for relative URL resolution#section) itself: with a <base> tag they would otherwise
resolve to /preview/:port/#section, a different path, and reload the site root
instead of jumping inside the page. Targets that appear later (SPA sections) are
retried for a few seconds.fetch() and XMLHttpRequest.open() URLs (relative, absolute frontend, and absolute backend)src, srcset, poster, data-src, and
resource <link href>) on nodes added by JS — a <base> tag only affects truly
relative URLs, so "/assets/x.png" would otherwise be fetched from the DSH hostSet-Cookie comes back, so
sessions and writes work inside the previewDSH_PREVIEW_TIMEOUT_MS),
the request fails with 502 — or, once bytes are on the wire, the response
is torn down instead of hangingThe preview config (frontend port + backend ports) is stored in
${DSH_HOME:-~/.dsh}/preview-plugin.json, so it survives a DSH restart and is shared by
every session. Deleting the file simply resets it; a corrupt file is ignored.
Besides the persisted global config, the host keeps in-memory state: one entry per session (keyed by session id, holding that session's frontend and backend ports) and a frontend-port → backend-ports lookup the proxy reads. Two rules keep both bounded on a long-lived box:
session/disposed
and drops everything the session contributed — its entry, plus a port mapping
that only that session justified.DSH_PREVIEW_MAX_SESSIONS sessions
are tracked (default 64; values below 1 fall back to the default). When
the cap is exceeded, the least recently written session is evicted first —
but never the active session: the one whose preview tab last polled
GET /api/preview-port?sessionId=…, i.e. the session on screen. This is the
safety net for sessions whose session/disposed never arrives.A port mapping survives only while something still points at it: the global config for its own port, or at least one live session. Changing the global port or moving a session to another port releases the mapping it replaced, and a surviving claimant's mapping is re-derived instead of deleted.
location.href = "/…", location.assign() and location.replace() cannot be
intercepted: browsers install them as non-configurable own properties of the location
object ([LegacyUnforgeable]). The preview tab notices the escape and reloads that path
under the proxy, but an app that wants a clean URL should build it from its own base
(e.g. Astro's import.meta.env.BASE_URL) or use a relative path.
pnpm install
pnpm test
node --test runs eight suites:
| Suite | What it covers |
|---|---|
test/client-helpers.test.mjs | URL/port parsing (resolveTarget) and proxy-path helpers |
test/preview-view.test.mjs | The Preview tab component with real React and happy-dom |
test/preview-proxy.test.mjs | The proxy end-to-end against a fixture site with Playwright (skipped when Chromium is unavailable) |
test/proxy-security.test.mjs | Same-origin-only CORS, port range, request body caps |
test/proxy-timeout.test.mjs | Stall timeout over the whole exchange, safe teardown of a half-sent response |
test/spa-fallback.test.mjs | 404 → app shell for HTML navigations, and everything that must keep its real 404 |
test/host-state.test.mjs | The port config surviving a restart, read from $DSH_HOME/preview-plugin.json |
test/session-state.test.mjs | Purge on session/disposed, port-mapping ownership, LRU cap and the active-session exemption |
127.0.0.1 — no external SSRF possibleset_preview_port toolaccess-control-allow-origin is echoed only
when the request's Origin matches the DSH Host, and a cross-origin
preflight is refused with 403. With the previous wildcard, any web site the
user visits could enumerate every loopback port behind DSH and read what those
ports serveDSH_PREVIEW_MAX_BODY_BYTES)
and 64 KiB for the port API — and a larger body is answered with 413 and a
closed connection instead of being bufferedDSH_PREVIEW_TIMEOUT_MS), refreshed on every chunkredirect: 'manual'): the
upstream's Location is handed back to the app as-isallow-scripts allow-same-origin allow-forms allow-popupsMIT