Port popup to React; fix backend port colliding with Vite dev server

Popup is now a real React app (App.tsx/main.tsx via createRoot),
wired through @wxt-dev/module-react. tsconfig.json needed an explicit
jsx: "react-jsx" -- WXT's generated .wxt/tsconfig.json doesn't set
it, so tsc --noEmit failed on JSX syntax even though the Vite build
itself was fine.

Root cause of the actually-reported bug (blank popup, predating the
React port too): the backend defaulted to port 3000, the same port
WXT's dev server uses for this extension. With both running, the
popup's script tags pointed at the Vite dev server but the backend
answered instead, so main.tsx never loaded -- "View Page Source"
showed raw unbundled dev-mode HTML pointing at localhost:3000.
Backend now binds :8080 (see backend commit), and
BACKEND_HTTP_URL/BACKEND_WS_URL + host_permissions here follow it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YXiHuScXrjxBh7yFGAPq3B
This commit is contained in:
2026-09-07 11:56:05 +02:00
co-authored by claude
parent 3302a78536
commit 58e32caa5c
10 changed files with 662 additions and 278 deletions
+1 -1
View File
@@ -85,7 +85,7 @@ Note the naming mismatch with the wire protocol below: the frontend's internal `
## Backend connection (implemented)
- `src/shared/config.ts` — `BACKEND_HTTP_URL`/`BACKEND_WS_URL`, currently hardcoded to `localhost:3000` (dev only; `host_permissions` in `wxt.config.ts` must stay in sync with whatever host is configured here).
- `src/shared/config.ts` — `BACKEND_HTTP_URL`/`BACKEND_WS_URL`, currently hardcoded to `localhost:8080` (dev only; `host_permissions` in `wxt.config.ts` must stay in sync with whatever host is configured here). Deliberately not `:3000` — that's this extension's own Vite dev server port (`npm run dev`), and running the backend on the same port breaks the dev popup silently: its script tags point at Vite, but the backend answers instead, so nothing ever renders. If you see a blank popup with `http://localhost:3000/...` script tags in "View Page Source" that 404 or return something unexpected, this port collision is the first thing to check.
- `src/background/identity.ts` — **stub wallet**: a locally-generated ed25519 keypair (via `tweetnacl`), persisted in `browser.storage.local`, used as a stand-in for a real Solana wallet signature. This is deliberately temporary — real wallet integration means bridging into the page's injected `window.solana` provider on axiom.trade (content script + page-context script), which hasn't been built yet. `getIdentity()`/`sign()` is the seam that swap plugs into.
- `src/background/backend-client.ts` — the REST auth flow (`POST /auth/nonce` → sign → `POST /auth/verify` → session token), persisted via `getSessionToken()`.
- `src/background/ws-client.ts` — the WS client described above: connects to `/ws?token=...`, reconnects with the protocol's suggested backoff, force-refreshes the session token on `4001`/`auth_expired` before retrying.
+559 -200
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -15,11 +15,16 @@
"postinstall": "wxt prepare"
},
"devDependencies": {
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"typescript": "^7.0.2",
"wxt": "^0.21.4"
},
"dependencies": {
"@wxt-dev/module-react": "^1.2.2",
"bs58": "^6.0.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"tweetnacl": "^1.0.3"
}
}
+71
View File
@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
import { browser } from 'wxt/browser';
import { DEFAULT_CONNECTION_STATUS, DEFAULT_LOCK_STATE, type ConnectionStatus, type LockState } from '@/shared/types';
import type { NexaMessage } from '@/shared/messaging';
const CONNECTION_LABELS: Record<ConnectionStatus, string> = {
connecting: 'Connecting…',
connected: 'Connected',
disconnected: 'Disconnected — retrying',
'auth-error': 'Sign-in failed — retrying',
};
export function App() {
const [lockState, setLockState] = useState<LockState>(DEFAULT_LOCK_STATE);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>(DEFAULT_CONNECTION_STATUS);
useEffect(() => {
browser.runtime
.sendMessage({ type: 'nexa:get-lock-state' } satisfies NexaMessage)
.then((state: LockState | undefined) => setLockState(state ?? DEFAULT_LOCK_STATE));
browser.runtime
.sendMessage({ type: 'nexa:get-connection-status' } satisfies NexaMessage)
.then((status: ConnectionStatus | undefined) => setConnectionStatus(status ?? DEFAULT_CONNECTION_STATUS));
const listener = (message: NexaMessage) => {
if (message?.type === 'nexa:lock-state-changed') {
setLockState(message.state);
} else if (message?.type === 'nexa:connection-status-changed') {
setConnectionStatus(message.status);
}
};
browser.runtime.onMessage.addListener(listener);
return () => browser.runtime.onMessage.removeListener(listener);
}, []);
return (
<>
<header>
<h1>Nexa</h1>
<p className="subtitle">Trading companion</p>
</header>
<section className="connection">
<span className={`connection-dot connection-${connectionStatus}`} />
{' '}
{CONNECTION_LABELS[connectionStatus]}
</section>
<section className="status">
{lockState.locked ? (
<>
<div className="badge badge-locked">🔒 Locked</div>
<p className="reason">{lockState.reason ?? 'No reason provided.'}</p>
<p className="scope">Scope: {lockState.scope}</p>
</>
) : (
<div className="badge badge-unlocked">Unlocked</div>
)}
</section>
<a
className="thresholds-link"
href="#"
title="Threshold settings are not part of this milestone"
>
Adjust loss thresholds →
</a>
</>
);
}
+1 -1
View File
@@ -7,6 +7,6 @@
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
<script type="module" src="./main.tsx"></script>
</body>
</html>
-71
View File
@@ -1,71 +0,0 @@
import { browser } from 'wxt/browser';
import { DEFAULT_CONNECTION_STATUS, DEFAULT_LOCK_STATE, type ConnectionStatus, type LockState } from '@/shared/types';
import type { NexaMessage } from '@/shared/messaging';
const app = document.getElementById('app');
if (!app) throw new Error('Popup root element #app not found.');
app.innerHTML = `
<header>
<h1>Nexa</h1>
<p class="subtitle">Trading companion</p>
</header>
<section class="connection" id="connection"></section>
<section class="status" id="status"></section>
<a class="thresholds-link" id="thresholds-link" href="#" title="Threshold settings are not part of this milestone">
Adjust loss thresholds →
</a>
`;
const connectionEl = document.getElementById('connection') as HTMLElement;
const statusEl = document.getElementById('status') as HTMLElement;
function escapeHtml(value: string): string {
const div = document.createElement('div');
div.textContent = value;
return div.innerHTML;
}
const CONNECTION_LABELS: Record<ConnectionStatus, string> = {
connecting: 'Connecting…',
connected: 'Connected',
disconnected: 'Disconnected — retrying',
'auth-error': 'Sign-in failed — retrying',
};
function renderConnection(status: ConnectionStatus): void {
connectionEl.innerHTML = `<span class="connection-dot connection-${status}"></span> ${CONNECTION_LABELS[status]}`;
}
function renderStatus(state: LockState): void {
statusEl.innerHTML = state.locked
? `<div class="badge badge-locked">🔒 Locked</div>
<p class="reason">${escapeHtml(state.reason ?? 'No reason provided.')}</p>
<p class="scope">Scope: ${escapeHtml(state.scope)}</p>`
: `<div class="badge badge-unlocked">Unlocked</div>`;
}
async function loadLockState(): Promise<LockState> {
const message: NexaMessage = { type: 'nexa:get-lock-state' };
const state = (await browser.runtime.sendMessage(message)) as LockState | undefined;
return state ?? DEFAULT_LOCK_STATE;
}
async function loadConnectionStatus(): Promise<ConnectionStatus> {
const message: NexaMessage = { type: 'nexa:get-connection-status' };
const status = (await browser.runtime.sendMessage(message)) as ConnectionStatus | undefined;
return status ?? DEFAULT_CONNECTION_STATUS;
}
loadLockState().then(renderStatus);
loadConnectionStatus().then(renderConnection);
browser.runtime.onMessage.addListener((message: NexaMessage) => {
if (message?.type === 'nexa:lock-state-changed') {
renderStatus(message.state);
} else if (message?.type === 'nexa:connection-status-changed') {
renderConnection(message.status);
}
});
+12
View File
@@ -0,0 +1,12 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
const root = document.getElementById('app');
if (!root) throw new Error('Popup root element #app not found.');
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
+5 -2
View File
@@ -4,5 +4,8 @@
* frontend"). `host_permissions` in wxt.config.ts must be kept in sync with
* this host.
*/
export const BACKEND_HTTP_URL = 'http://localhost:3000';
export const BACKEND_WS_URL = 'ws://localhost:3000/ws';
// Port 8080, deliberately not 3000 — that's WXT/Vite's dev server port for
// this extension, and colliding with it breaks the dev popup silently (its
// script tags point at Vite, but the backend answers instead).
export const BACKEND_HTTP_URL = 'http://localhost:8080';
export const BACKEND_WS_URL = 'ws://localhost:8080/ws';
+4 -1
View File
@@ -1,3 +1,6 @@
{
"extends": "./.wxt/tsconfig.json"
"extends": "./.wxt/tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx"
}
}
+4 -2
View File
@@ -3,15 +3,17 @@ import { defineConfig } from 'wxt';
// See https://wxt.dev/api/config.html
export default defineConfig({
srcDir: 'src',
modules: ['@wxt-dev/module-react'],
// Target Manifest V3 on both Chromium and Firefox (modern Firefox / Zen support it).
manifestVersion: 3,
manifest: {
name: 'Nexa',
description: 'Your blockchain powered agent to help with your trading emotions.',
permissions: ['storage'],
// axiom.trade: the site adapter target. localhost:3000: the Nexa backend
// axiom.trade: the site adapter target. localhost:8080: the Nexa backend
// (dev only — swap/extend for the real backend host before shipping).
host_permissions: ['https://axiom.trade/*', 'http://localhost:3000/*'],
// Not 3000 — that's this extension's own Vite dev server port.
host_permissions: ['https://axiom.trade/*', 'http://localhost:8080/*'],
browser_specific_settings: {
gecko: {
// Placeholder id for local/dev builds; replace before publishing to AMO.