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:
@@ -85,7 +85,7 @@ Note the naming mismatch with the wire protocol below: the frontend's internal `
|
|||||||
|
|
||||||
## Backend connection (implemented)
|
## 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/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/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.
|
- `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.
|
||||||
|
|||||||
Generated
+559
-200
File diff suppressed because it is too large
Load Diff
@@ -15,11 +15,16 @@
|
|||||||
"postinstall": "wxt prepare"
|
"postinstall": "wxt prepare"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.7",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"wxt": "^0.21.4"
|
"wxt": "^0.21.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@wxt-dev/module-react": "^1.2.2",
|
||||||
"bs58": "^6.0.0",
|
"bs58": "^6.0.0",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
"tweetnacl": "^1.0.3"
|
"tweetnacl": "^1.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="./main.ts"></script>
|
<script type="module" src="./main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -4,5 +4,8 @@
|
|||||||
* frontend"). `host_permissions` in wxt.config.ts must be kept in sync with
|
* frontend"). `host_permissions` in wxt.config.ts must be kept in sync with
|
||||||
* this host.
|
* this host.
|
||||||
*/
|
*/
|
||||||
export const BACKEND_HTTP_URL = 'http://localhost:3000';
|
// Port 8080, deliberately not 3000 — that's WXT/Vite's dev server port for
|
||||||
export const BACKEND_WS_URL = 'ws://localhost:3000/ws';
|
// 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
@@ -1,3 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": "./.wxt/tsconfig.json"
|
"extends": "./.wxt/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -3,15 +3,17 @@ import { defineConfig } from 'wxt';
|
|||||||
// See https://wxt.dev/api/config.html
|
// See https://wxt.dev/api/config.html
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
srcDir: 'src',
|
srcDir: 'src',
|
||||||
|
modules: ['@wxt-dev/module-react'],
|
||||||
// Target Manifest V3 on both Chromium and Firefox (modern Firefox / Zen support it).
|
// Target Manifest V3 on both Chromium and Firefox (modern Firefox / Zen support it).
|
||||||
manifestVersion: 3,
|
manifestVersion: 3,
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'Nexa',
|
name: 'Nexa',
|
||||||
description: 'Your blockchain powered agent to help with your trading emotions.',
|
description: 'Your blockchain powered agent to help with your trading emotions.',
|
||||||
permissions: ['storage'],
|
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).
|
// (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: {
|
browser_specific_settings: {
|
||||||
gecko: {
|
gecko: {
|
||||||
// Placeholder id for local/dev builds; replace before publishing to AMO.
|
// Placeholder id for local/dev builds; replace before publishing to AMO.
|
||||||
|
|||||||
Reference in New Issue
Block a user