diff --git a/.gitignore b/.gitignore index 881e1a2..5dbd1a1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ frontend/dist/* *.swp .idea/ .vscode/ +.nexum-data/ diff --git a/.golangci.yml b/.golangci.yml index a1ebeb2..ef5a40c 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -18,6 +18,10 @@ issues: # Deferred Close() failures are not actionable here. - linters: [errcheck] source: "defer .*\\.Close\\(\\)" + # grpcurl's reflection API returns jhump/protoreflect/desc types; we can't + # migrate off the deprecated package without leaving grpcurl. + - linters: [staticcheck] + text: "SA1019: .*protoreflect/desc" # The nested scaffold under frontend/hypr is a separate module artifact. exclude-dirs: - frontend diff --git a/README.md b/README.md index ad1039d..d4c5e26 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,17 @@ Build, send, and inspect HTTP requests from a native desktop app powered by a Go Hypr screenshot +
+ +Hypr WebSocket panel + ## Features - ⚑ **Native & fast** β€” a single self-contained binary; the HTTP engine runs in Go, not a bundled browser runtime. - πŸ—‚οΈ **Tabbed requests** β€” work on multiple requests side by side. +- πŸ”Œ **WebSocket & gRPC** β€” open live WebSocket streams or invoke gRPC methods (via server reflection); connections and output persist alongside your saved requests. - 🧩 **Header editor** β€” manage request headers as simple key/value pairs. - πŸ“₯ **Import from cURL** β€” paste a `curl` command and it's parsed, populated, and run instantly. - 🎨 **Syntax-highlighted responses** β€” pretty-printed JSON bodies and response headers. diff --git a/app.go b/app.go index f22c587..30dd556 100644 --- a/app.go +++ b/app.go @@ -8,14 +8,19 @@ import ( "io" "net/http" "strings" + "sync" "time" + + "github.com/gorilla/websocket" ) // App struct type App struct { - ctx context.Context - client *http.Client - store *Store + ctx context.Context + client *http.Client + store *Store + wsMu sync.Mutex + wsConns map[string]*websocket.Conn } // NewApp creates a new App application struct @@ -35,6 +40,7 @@ func (a *App) startup(ctx context.Context) { Transport: tr, } a.ctx = ctx + a.wsConns = make(map[string]*websocket.Conn) var err error a.store, err = newStore() diff --git a/docs/images/ws.png b/docs/images/ws.png new file mode 100644 index 0000000..e2f808e Binary files /dev/null and b/docs/images/ws.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 257945d..01efb75 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,11 @@ import { ClearHistory, LoadSession, SaveSession, + ListEnvironments, + GetActiveEnvironment, + SetActiveEnvironment, + SaveEnvironment, + DeleteEnvironment, } from '../wailsjs/go/main/App' import {main} from '../wailsjs/go/models' import { @@ -38,6 +43,11 @@ import { PanelLeftOpen, Layers, Check, + MoreHorizontal, + Columns3, + PlayCircle, + Globe, + Trash2, } from 'lucide-react' import {KVRow} from './components/kv-row' import {AuthForm} from './components/auth-form' @@ -72,9 +82,16 @@ import {applyAuth, emptyAuth, type AuthState} from './lib/auth' import {defaultSettings, type ReqSettings} from './lib/settings' import {encodeFormBody, parseUrlParams, rebuildUrlWithParams} from './lib/url-sync' import {buildCurl} from './lib/curl-builder' +import {buildFetch, buildGo, buildPython} from './lib/code-gen' +import {substituteVars, envVars} from './lib/env' +import {WsPanel} from './components/ws-panel' +import {GrpcPanel} from './components/grpc-panel' +import {NewRequestMenu, kindMeta} from './components/new-request-menu' +import {PayloadDiff} from './components/payload-diff' import {formatDuration, formatSize, statusChipClass} from './lib/formatters' import {readDraft, writeDraft} from './lib/autosave' import { + emptyConn, emptyPayload, emptyRequest, emptyResult, @@ -82,8 +99,11 @@ import { toStoredPayload, updatePayload, updateRequest, + type ConnConfig, type Payload, + type RequestKind, type RequestTab, + type WsLine, } from './lib/model' const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'] as const @@ -137,7 +157,13 @@ function App() { const [search, setSearch] = useState('') const [curlOpen, setCurlOpen] = useState(false) const [curlBody, setCurlBody] = useState('') + const [codeMenuOpen, setCodeMenuOpen] = useState(false) const [saveOpen, setSaveOpen] = useState(false) + const [environments, setEnvironments] = useState([]) + const [activeEnvId, setActiveEnvId] = useState('') + const [envOpen, setEnvOpen] = useState(false) + const [editEnv, setEditEnv] = useState(null) + const [showDiff, setShowDiff] = useState(false) // Persistence state const [collections, setCollections] = useState([]) @@ -160,17 +186,29 @@ function App() { const hasResponse = Boolean( result.Body || result.Error || result.HeadersStr || result.Status ) + const activeEnv = environments.find((e) => e.id === activeEnvId) + + function refreshEnvironments() { + ListEnvironments() + .then((e) => setEnvironments((e as main.Environment[]) ?? [])) + .catch(() => {}) + GetActiveEnvironment() + .then((e: any) => setActiveEnvId(e?.id ?? '')) + .catch(() => {}) + } // ── Session persistence ─────────────────────────────────────── function buildRequestState(r: RequestTab) { return { savedId: r.savedId ?? '', + kind: r.kind, method: r.method, url: r.url, auth: r.auth, settings: r.settings, payloads: r.payloads.map(toStoredPayload), activePayload: r.activePayload, + conn: r.conn, } } @@ -210,6 +248,8 @@ function App() { jsonBody: p?.jsonBody || '', rawBody: p?.rawBody || '', formRows: p?.formRows?.length ? p.formRows : [emptyKV()], + gqlQuery: p?.gqlQuery || '', + gqlVars: p?.gqlVars || '', response: p?.response ? main.RequestResult.createFrom(p.response) : emptyResult(), } } @@ -220,12 +260,14 @@ function App() { : [emptyPayload()] return { savedId: t?.savedId || undefined, + kind: (t?.kind as RequestKind) || 'rest', method: t?.method || 'GET', url: t?.url || '', auth: (t?.auth as AuthState) || emptyAuth(), settings: (t?.settings as ReqSettings) || defaultSettings(), payloads, activePayload: Math.min(t?.activePayload || 0, payloads.length - 1), + conn: {...emptyConn(), ...(t?.conn as Partial)}, } } @@ -261,15 +303,34 @@ function App() { ListHistory(50) .then((h) => setHistory((h as main.HistoryEntry[]) ?? [])) .catch(() => {}) + + refreshEnvironments() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // ── Request-level tab plumbing ──────────────────────────────── - const addRequest = () => { - setRequests((prev) => [...prev, emptyRequest()]) + const addRequest = (kind: RequestKind = 'rest') => { + setRequests((prev) => [...prev, emptyRequest(kind)]) setActiveRequest(requests.length) } + // Patch the active request's ws/grpc connection config. + const setConn = (patch: Partial) => + setRequests((prev) => updateRequest(prev, activeRequest, {conn: {...prev[activeRequest].conn, ...patch}})) + + // Append/clear the active ws tab's persisted stream log. Functional updates + // read the live conn so concurrent messages don't clobber each other. + const appendWsLine = (line: WsLine) => + setRequests((prev) => + updateRequest(prev, activeRequest, { + conn: {...prev[activeRequest].conn, wsLog: [...prev[activeRequest].conn.wsLog, line]}, + }) + ) + const clearWsLog = () => + setRequests((prev) => + updateRequest(prev, activeRequest, {conn: {...prev[activeRequest].conn, wsLog: []}}) + ) + const closeRequest = (e: React.MouseEvent, index: number) => { e.stopPropagation() if (requests.length === 1) return @@ -366,14 +427,22 @@ function App() { setRequests((prev) => updatePayload(prev, activeRequest, pIdx, {rawBody: s})) const setFormRowsForPayload = (rows: KV[]) => setRequests((prev) => updatePayload(prev, activeRequest, pIdx, {formRows: rows})) + const setGqlQuery = (s: string) => + setRequests((prev) => updatePayload(prev, activeRequest, pIdx, {gqlQuery: s})) + const setGqlVars = (s: string) => + setRequests((prev) => updatePayload(prev, activeRequest, pIdx, {gqlVars: s})) // ── Build the final RequestSpec for the active payload ──────── - function buildSpec(): main.RequestSpec { - const userHeaders = kvToRecord(payload.headers) + // Build the RequestSpec for an arbitrary payload index of the active request. + // {{var}} tokens from the active environment are substituted into the final + // url, header values, and body at the very end. + function buildSpecFor(pi: number): main.RequestSpec { + const pl = req.payloads[pi] ?? emptyPayload() + const userHeaders = kvToRecord(pl.headers) const auth = applyAuth(req.auth) const mergedHeaders: Record = {...userHeaders, ...auth.headers} - let finalUrl = rebuildUrlWithParams(req.url, payload.params) + let finalUrl = rebuildUrlWithParams(req.url, pl.params) if (auth.query.length) { try { const u = new URL(finalUrl) @@ -388,25 +457,51 @@ function App() { } let body: main.BodySpec = main.BodySpec.createFrom({type: 'none', raw: ''}) - if (payload.bodyType === 'json') { - const raw = payload.jsonBody + let methodOverride = req.method + if (pl.bodyType === 'graphql') { + let variables: unknown = {} + if (pl.gqlVars.trim()) { + try { + variables = JSON.parse(pl.gqlVars) + } catch { + variables = {} + } + } + const raw = JSON.stringify({query: pl.gqlQuery, variables}) + if (!mergedHeaders['Content-Type']) { + mergedHeaders['Content-Type'] = 'application/json' + } + body = main.BodySpec.createFrom({type: 'raw', raw}) + methodOverride = 'POST' + } else if (pl.bodyType === 'json') { + const raw = pl.jsonBody if (raw && !mergedHeaders['Content-Type']) { mergedHeaders['Content-Type'] = 'application/json' } body = main.BodySpec.createFrom({type: 'json', raw}) - } else if (payload.bodyType === 'form') { - const raw = encodeFormBody(payload.formRows) + } else if (pl.bodyType === 'form') { + const raw = encodeFormBody(pl.formRows) if (raw && !mergedHeaders['Content-Type']) { mergedHeaders['Content-Type'] = 'application/x-www-form-urlencoded' } body = main.BodySpec.createFrom({type: 'form', raw}) - } else if (payload.bodyType === 'raw') { - body = main.BodySpec.createFrom({type: 'raw', raw: payload.rawBody}) + } else if (pl.bodyType === 'raw') { + body = main.BodySpec.createFrom({type: 'raw', raw: pl.rawBody}) + } + + // Environment {{var}} substitution. + const vars = envVars(activeEnv) + if (Object.keys(vars).length) { + finalUrl = substituteVars(finalUrl, vars) + for (const k of Object.keys(mergedHeaders)) { + mergedHeaders[k] = substituteVars(mergedHeaders[k], vars) + } + body = main.BodySpec.createFrom({type: body.type, raw: substituteVars(body.raw, vars)}) } const s = req.settings return main.RequestSpec.createFrom({ - method: req.method, + method: methodOverride, url: finalUrl, headers: mergedHeaders, body, @@ -414,6 +509,64 @@ function App() { }) } + const buildSpec = (): main.RequestSpec => buildSpecFor(pIdx) + + // Fire every payload of the active request and store each response. + async function runAllPayloads() { + setLoading(true) + try { + for (let i = 0; i < req.payloads.length; i++) { + const spec = buildSpecFor(i) + try { + const r = await SendBinding(spec) + setRequests((prev) => + updatePayload(prev, activeRequest, i, {response: r, lastRunAt: new Date().toISOString()}) + ) + } catch { + // continue with remaining payloads + } + } + setShowDiff(true) + } finally { + setLoading(false) + } + } + + // ── Environment editor ──────────────────────────────────────── + const newEnvDraft = (): main.Environment => + main.Environment.createFrom({id: genId(), name: 'New Env', vars: [emptyKV()]}) + + function openEnvEditor() { + const base = activeEnv ?? environments[0] + setEditEnv(base ? main.Environment.createFrom({...base, vars: [...(base.vars ?? [])]}) : newEnvDraft()) + setEnvOpen(true) + } + + function selectActiveEnv(id: string) { + setActiveEnvId(id) + SetActiveEnvironment(id).catch(() => {}) + } + + function patchEditEnv(patch: Partial) { + setEditEnv((prev) => (prev ? main.Environment.createFrom({...prev, ...patch}) : prev)) + } + + async function saveEnvEditor() { + if (!editEnv) return + await SaveEnvironment(editEnv as any).catch(() => {}) + selectActiveEnv(editEnv.id) + setEnvOpen(false) + refreshEnvironments() + } + + async function deleteEditEnv() { + if (!editEnv) return + await DeleteEnvironment(editEnv.id).catch(() => {}) + if (activeEnvId === editEnv.id) setActiveEnvId('') + setEnvOpen(false) + refreshEnvironments() + } + const storeResponse = (r: main.RequestResult) => { setRequests((prev) => updatePayload(prev, activeRequest, pIdx, {response: r})) setResponseTab(r.Body ? 'body' : r.HeadersStr ? 'headers' : 'body') @@ -503,6 +656,25 @@ function App() { bodyFlag: payload.bodyType === 'json' ? '--data-raw' : '-d', }) navigator.clipboard.writeText(cmd) + setCodeMenuOpen(false) + } + + function copyAsCode(lang: 'fetch' | 'go' | 'python') { + const spec = buildSpec() + const input = { + method: spec.method, + url: spec.url, + headers: spec.headers, + body: spec.body.raw || undefined, + } + const code = + lang === 'fetch' + ? buildFetch(input) + : lang === 'go' + ? buildGo(input) + : buildPython(input) + navigator.clipboard.writeText(code) + setCodeMenuOpen(false) } function copyBody() { @@ -550,6 +722,25 @@ function App() { setCollections((updated as main.Collection[]) ?? []) } + // "+" on a collection: create a blank request of the chosen kind in it, then open it. + async function handleAddRequestToCollection(collectionId: string, kind: RequestKind) { + const id = genId() + const base = emptyRequest(kind) + const name = kind === 'ws' ? 'New WebSocket' : kind === 'grpc' ? 'New gRPC' : 'New request' + await SaveRequestBinding(collectionId, { + ...buildRequestState(base), + id, + name, + createdAt: '', + updatedAt: '', + } as any) + const updated = await ListCollections() + setCollections((updated as main.Collection[]) ?? []) + setFocusCollectionId(collectionId) + setRequests((prev) => [...prev, {...base, savedId: id}]) + setActiveRequest(requests.length) + } + async function handleClearHistory() { await ClearHistory() setHistory([]) @@ -586,8 +777,8 @@ function App() { focusCollectionId={focusCollectionId} onLoadRequest={loadRequestToTab} onLoadHistory={loadHistoryToTab} - onSaveRequest={() => setSaveOpen(true)} onNewTab={addRequest} + onAddRequestToCollection={handleAddRequestToCollection} onNewCollection={handleNewCollection} onDeleteCollection={handleDeleteCollection} onDeleteRequest={handleDeleteRequest} @@ -627,28 +818,103 @@ function App() { -
+
{lastSaved && ( - Autosaved {formatClock(lastSaved)} + {formatClock(lastSaved)} )} - - - +
+ + {codeMenuOpen && ( + <> +
setCodeMenuOpen(false)} + /> +
+
+ Copy as +
+ {(['cURL', 'fetch', 'Go', 'Python'] as const).map((label) => ( + + ))} +
+ +
+ + )} +
@@ -657,8 +923,9 @@ function App() { {/* ── Request tabs (top level) ────────────────────────── */}
- {requests.map((_, index) => { + {requests.map((r, index) => { const active = index === activeRequest + const KindIcon = kindMeta(r.kind).icon return ( + + +
+ {req.kind === 'rest' ? ( + <> {/* ── Request bar (shared URL + method) ───────────────── */}
{ + const found = environments.find((x) => x.id === e.target.value) + setEditEnv( + found + ? main.Environment.createFrom({...found, vars: [...(found.vars ?? [])]}) + : newEnvDraft() + ) + }} + className="h-9 flex-1 rounded-md border border-border/70 bg-card/40 px-2 text-sm" + > + {environments.every((e) => e.id !== editEnv?.id) && editEnv && ( + + )} + {environments.map((e) => ( + + ))} + + +
+ {editEnv && ( + <> + patchEditEnv({name: e.target.value})} + placeholder="Environment name" + className="h-9 text-sm" + /> +
+ {editEnv.vars.map((row, idx) => ( + + patchEditEnv({vars: setRow(editEnv.vars as any, i, field, value) as any}) + } + onRemove={(i) => patchEditEnv({vars: dropRow(editEnv.vars as any, i) as any})} + /> + ))} + +
+ + )} +
+ + {editEnv && environments.some((e) => e.id === editEnv.id) && ( + + )} + + + + + + {/* ── Save request dialog ─────────────────────────────── */} diff --git a/frontend/src/components/body-editor.tsx b/frontend/src/components/body-editor.tsx index aeb2aae..22ac857 100644 --- a/frontend/src/components/body-editor.tsx +++ b/frontend/src/components/body-editor.tsx @@ -5,7 +5,7 @@ import {Tabs, TabsList, TabsTrigger} from '@/components/ui/tabs' import {KVRow} from '@/components/kv-row' import type {KV} from '@/lib/kv' -export type BodyType = 'none' | 'json' | 'form' | 'raw' +export type BodyType = 'none' | 'json' | 'form' | 'raw' | 'graphql' interface BodyEditorProps { bodyType: BodyType @@ -16,6 +16,10 @@ interface BodyEditorProps { onRawChange: (s: string) => void formRows: KV[] onFormRowsChange: (rows: KV[]) => void + gqlQuery: string + onGqlQueryChange: (s: string) => void + gqlVars: string + onGqlVarsChange: (s: string) => void } export function BodyEditor(props: BodyEditorProps) { @@ -71,6 +75,12 @@ export function BodyEditor(props: BodyEditorProps) { > Raw + + GraphQL + @@ -137,6 +147,35 @@ export function BodyEditor(props: BodyEditorProps) { className="min-h-[260px] resize-none font-mono text-[0.8125rem] leading-relaxed" /> )} + + {props.bodyType === 'graphql' && ( +
+
+ + Query + +