feat(web): add self-hosted web frontend for the desktop backend by PeterPange · Pull Request #3250 · generalaction/emdash · GitHub
Skip to content

feat(web): add self-hosted web frontend for the desktop backend - #3250

Open
PeterPange wants to merge 1 commit into
generalaction:mainfrom
PeterPange:feat/web-app
Open

PeterPange wants to merge 1 commit into
generalaction:mainfrom
PeterPange:feat/web-app

Conversation

@PeterPange

Copy link
Copy Markdown

Description

This adds apps/emdash-web: a self-hosted web frontend that runs the desktop
backend stack under plain Node and serves the renderer in a browser — useful
for headless/remote machines where running the Electron app is impractical
(e.g. a lab server accessed over SSH).

The desktop backend is reused unchanged:

  • Boot: the same bootDatabase → bootInfrastructure → bootRuntimes →
    bootServices → bootControllers phases, with an electron module shim
    (paths, dialogs, tray, power monitor) aliased in at build time
  • Transport: a WebSocket gateway speaking the same streamTransport
    framing the workspace-server uses, with domain routing mirroring the desktop
    gateway, plus a token check on upgrade (the token grants full project/git/
    terminal access, so it is printed at startup and never logged elsewhere)
  • Renderer: the existing renderer built for the browser, with the wire seed
    swapped to a reconnecting WebSocket transport and a no-op electronAPI shim;
    one UI override replaces the native directory dialog with manual path entry
    (validated live via inspectProjectPath)
  • Workers: the runtime worker artifacts are bundled to the layout the
    desktopWorkerPath resolver expects, so worker forking works unchanged

Known limitations (desktop-only surfaces are stubbed): OS dialogs, clipboard
writes, tray/menu, auto-update, and the Electron-webview in-app browser.
OAuth flows that target desktop callback ports are also out of scope here.

This is a large PR (a new app), which I realize is outside the "small and
focused" guidance. I'm happy to restructure it into smaller steps if that
helps review — for example landing the electron shim + boot reuse first,
then the gateway, then the renderer build. Feedback on the shim approach
itself is also welcome; if you'd rather see the boot phases decoupled from
Electron properly instead of shimmed, I can take that direction.

Related issues

None yet — this is a new capability. Happy to open a tracking issue first
if that's preferred.

Testing

  • oxlint / oxfmt clean for the new app (with a .oxlintrc.json override
    for the build script's dynamic import, following the existing pattern)
  • tsgo --noEmit passes for both new programs (tsconfig.server.json,
    tsconfig.web.json)
  • node build-server.mjs + vite build produce a working server bundle,
    16 worker bundles, and the renderer build
  • End-to-end against a plain Node process with no Electron present:
    WebSocket connect + auth, wire procedure calls, live model snapshots,
    project creation, and the renderer booting against the served build
    (apps/emdash-web/scripts/smoke.mjs automates the wire checks)
  • No automated tests for the new app yet — the smoke script covers the
    critical path; I can add Vitest coverage for the gateway/static handler
    if the direction is welcome

Screenshot/Recording (if applicable)

Can attach a recording of the web UI running the full task flow on request.

Checklist
  • I kept this PR small and focused (it is a new app; see note above — happy to split)
  • I ran a self-review before opening this PR
  • I ran the relevant local checks or explained why not
  • I updated docs when behavior or setup changed (README in the new app)
  • I added or updated tests when behavior changed, or explained why not (explained above)
  • I only added comments where the logic is not obvious
  • I used Conventional Commits for commit messages and the PR title

@greptile-apps

greptile-apps Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Comment thread apps/emdash-web/server/static.ts Outdated
const root = resolve(webRoot);
return function handleStatic(req: IncomingMessage, res: ServerResponse): boolean {
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
const urlPath = decodeURIComponent((req.url ?? '/').split('?')[0]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Malformed URLs Crash Server

An unauthenticated request with a malformed path such as /% causes decodeURIComponent to throw. The HTTP request callback does not catch that exception, so one malformed request can terminate the self-hosted server instead of receiving a 400 response.

How this was verified: The attacker-controlled req.url is decoded synchronously without a surrounding catch in the server's request path.

Suggested change
const urlPath = decodeURIComponent((req.url ?? '/').split('?')[0]);
let urlPath: string;
try {
urlPath = decodeURIComponent((req.url ?? '/').split('?')[0]);
} catch {
res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Bad request');
return true;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/emdash-web/server/static.ts
Line: 33

Comment:
**Malformed URLs Crash Server**

An unauthenticated request with a malformed path such as `/%` causes `decodeURIComponent` to throw. The HTTP request callback does not catch that exception, so one malformed request can terminate the self-hosted server instead of receiving a 400 response.

**How this was verified:** The attacker-controlled `req.url` is decoded synchronously without a surrounding catch in the server's request path.

```suggestion
    let urlPath: string;
    try {
      urlPath = decodeURIComponent((req.url ?? '/').split('?')[0]);
    } catch {
      res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
      res.end('Bad request');
      return true;
    }
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +122 to +128
server.listen(PORT, HOST, () => {
const displayHost = HOST === '0.0.0.0' ? '<machine-ip>' : HOST;
console.log('');
console.log(' Emdash Web is running');
console.log(` URL : http://${displayHost}:${PORT}/`);
console.log(` Token : ${TOKEN}`);
console.log(` Open : http://${displayHost}:${PORT}/?token=${TOKEN}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security LAN Token Uses Plaintext

When the server is bound to 0.0.0.0 for the documented LAN use case, it still uses plaintext HTTP and WebSocket transport. The startup URL and every WebSocket connection place the full-access bearer token in the query string, so anyone able to observe network traffic can recover it and gain project, Git, and terminal access. Non-loopback use needs TLS, a required TLS-terminating proxy, or an explicit secure configuration gate.

How this was verified: The non-loopback startup path prints an http:// token URL and the gateway authenticates that same token from the plaintext WebSocket request query.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/emdash-web/server/index.ts
Line: 122-128

Comment:
**LAN Token Uses Plaintext**

When the server is bound to `0.0.0.0` for the documented LAN use case, it still uses plaintext HTTP and WebSocket transport. The startup URL and every WebSocket connection place the full-access bearer token in the query string, so anyone able to observe network traffic can recover it and gain project, Git, and terminal access. Non-loopback use needs TLS, a required TLS-terminating proxy, or an explicit secure configuration gate.

**How this was verified:** The non-loopback startup path prints an `http://` token URL and the gateway authenticates that same token from the plaintext WebSocket request query.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread apps/emdash-web/server/shim/electron.ts Outdated
Comment on lines +248 to +250
isEncryptionAvailable(): boolean {
return false;
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Secure Credentials Always Fail

This shim always reports secure storage as unavailable, but the reused backend supplies encryptedAppSecretsStore to SSH credentials, provider accounts, and account sessions. That store checks this value before every secret read or write and throws when it is false, so password or passphrase SSH connections and provider or account credential persistence cannot work in the web frontend. The web backend needs a secure Node-compatible secret store or another explicitly supported implementation.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/emdash-web/server/shim/electron.ts
Line: 248-250

Comment:
**Secure Credentials Always Fail**

This shim always reports secure storage as unavailable, but the reused backend supplies `encryptedAppSecretsStore` to SSH credentials, provider accounts, and account sessions. That store checks this value before every secret read or write and throws when it is false, so password or passphrase SSH connections and provider or account credential persistence cannot work in the web frontend. The web backend needs a secure Node-compatible secret store or another explicitly supported implementation.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +109 to +112

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale Tokens Retry Forever

A rejected token is retried forever using the same value stored in localStorage. This happens by default after a server restart without EMDASH_WEB_TOKEN, because the server generates a new token while the browser keeps the old one. The reconnect loop never becomes terminal or clears the token, and the token prompt only appears when no stored value exists, leaving the application stuck on its boot screen until the user manually clears storage or opens the newly printed token URL.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/emdash-web/web/seed-web-wire.ts
Line: 109-112

Comment:
**Stale Tokens Retry Forever**

A rejected token is retried forever using the same value stored in localStorage. This happens by default after a server restart without `EMDASH_WEB_TOKEN`, because the server generates a new token while the browser keeps the old one. The reconnect loop never becomes terminal or clears the token, and the token prompt only appears when no stored value exists, leaving the application stuck on its boot screen until the user manually clears storage or opens the newly printed token URL.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Adds apps/emdash-web, a Node server that boots the desktop backend
stack unchanged (database, infrastructure, runtimes, services, and
the full wire controller bundle) and exposes it over WebSocket,
plus a browser build of the renderer.

- server: electron module shim, boot orchestration reusing the
  desktop boot phases, WebSocket wire gateway with token auth
  (streamTransport framing, domain routing), static file serving
- web: renderer bootstrap with a WebSocket wire seed and an
  electronAPI shim; local-directory selector override replaces the
  native OS dialog with manual path entry
- build: esbuild bundling for the server and the runtime worker
  artifacts, with plugins for ?asset imports, core # subpath
  imports, and import.meta.glob inlining

Security hardening:
- malformed request paths return 400 instead of crashing the server
- non-loopback binds refuse to start without an explicit
  EMDASH_WEB_ALLOW_INSECURE_REMOTE=1 acknowledgment (token travels
  in plaintext; a TLS-terminating proxy is the supported path)
- safeStorage shim implements AES-256-GCM with a 0600 key file so
  the encrypted app secrets store works under plain Node
- rejected tokens close the WebSocket with code 4401; the client
  clears the stale value and re-prompts instead of retrying forever

Verified end-to-end: wire calls, live model snapshots, project
creation, and the renderer build all run against a plain Node
process with no Electron present.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant