跪拜 Guibai
← Back to the summary

Kotae: The 12.7 MB Postman Alternative That Skips Electron and the Cloud


theme: condensed-night-purple

Origin

I deal with a bunch of internal network endpoints daily. Postman has turned more and more into a collaboration platform: accounts, cloud sync, workspaces, team spaces, slow startup, and heavy on memory; under the domestic network, the login state drops from time to time. Insomnia later went down the same path.

What I actually want is tiny: a double-click local program that can compose requests, send packets, view structured responses, manage collections and environment variables, paste a cURL to restore, and all data stays only on my machine.

So came Kotae (Japanese "答え", answer): a pure-local lightweight API debugging tool, all-Chinese interface, no cloud, no backend, no account. Currently at 0.2.3, with installers for Windows / macOS.

The base is MyGo (a Go desktop application framework, currently 0.7.4): the window uses the system webview, the frontend is an ordinary Vite project, and packaging / installer / auto-update / tray / single-instance are handled by the framework.

01-main.png Let me give a few numbers so the later technical trade-offs have context:


Technical form: Go desktop shell + system WebView

The stack uses Go to write the desktop shell (the MyGo framework) plus an ordinary Vite / TypeScript frontend running in the system webview.

The fundamental difference from Electron: it does not bundle Chromium. The window uses the system's built-in webview (on Windows it's WebView2, preinstalled on Win11), so the product does not contain that 100 MB-grade kernel; the frontend dist is only 325 KB, compressed and shipped with the binary.

The costs should also be stated clearly:

For a small local HTTP debugging tool, this trade is very worthwhile.


Layering: Go only does platform capabilities, business logic never touches Go

The project set a line from the start, and all subsequent code grows around it:

The Go side does not parse business data, does not touch collections and tabs, only provides platform capabilities; business logic (collections, history, environments, sending, interpolation, persistence) is written only in src/store.ts.

main.go is nothing but assembly:

jar := newToggleJar()

// All requests are sent by Go: not subject to CORS, can write any request header.
// Timeout is not placed on http.Client; the frontend sets an AbortSignal per request,
// so each request can have a different timeout.
client := &http.Client{Transport: newRoutingTransport(netproxy.Transport()), Jar: jar}

mygo.Use(fetch.New(fetch.Options{Client: client}))
mygo.Use(sqlite.Plugin)
// WebSocket is also established by Go: it can carry handshake headers and is not subject to same-origin policy.
mygo.Use(websocket.New(websocket.Options{Client: client}))
mygo.Use(updater.New(updater.Options{Language: "zh"}))

mygo.Bind(&System{jar: jar})

The methods bound on System number only about a dozen: clipboard, save/open file, select file, open data directory, system notification, cookie jar switch, single-instance ready. None of them know what a "collection" is.

On the frontend side, the bindings are generated: npm run generate compiles the Go public methods into src/mygo.ts, with a header that says DO NOT EDIT. If you change a Go signature and forget to re-run the generation, the frontend gets the old contract. This is written into the project's hard rules.

Business logic is all in TypeScript. The hot file src/store.ts is 2,800 lines, managing collection trees, tabs, environments, interpolation, send plans, SSE/WebSocket streams, layered script execution; src/ui/* only renders and forwards events. This boundary saved me after half a year: changing UI, changing request sending, changing storage never fought in the same file.


Decision one: requests must be sent by Go

Doing API debugging in a browser has four hard problems:

  1. CORS: cross-origin preflight blocks it, and many internal endpoints don't even have Access-Control-Allow-Origin configured
  2. Restricted request headers: Origin, Cookie, User-Agent, Referer cannot be changed, and these are exactly what you often need to fake when debugging interfaces
  3. Cannot read Set-Cookie: this header in the response is invisible to JS
  4. Cookies belong to the browser: login state mixes with your daily browsing, and you can't just clear it

So requests are sent from the Go side, and the frontend just assembles parameters. Four problems disappear at once.

But "handing it to Go" does not mean just throwing net/http at it; there are three non-obvious points in the middle.

Timeout is not on http.Client

http.Client.Timeout is at the client level; one Client can only have one. "Per-request timeout" is a basic requirement of this tool: a stock query endpoint takes 1 second, a report export endpoint takes 60 seconds.

So Client.Timeout is left empty, and the frontend measures time with AbortSignal; when canceling, the signal is passed down.

How per-request proxies fit into a shared Client

Proxy and "skip TLS verification" are options on the UI for each request, but the fetch plugin has only one shared http.Client. Two routes: either modify the framework (not worth it), or "smuggle" request-level options before they reach the Transport.

The latter was chosen: the frontend carries two internal headers per request, and routingTransport.RoundTrip strips them, then switches to a corresponding *http.Transport based on (proxy, insecure):

func (rt *routingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	// Clone before touching headers: the original request remains in http.Client's redirect chain, and mutating it pollutes the via.
	out := req.Clone(req.Context())
	proxy := strings.TrimSpace(out.Header.Get(proxyHeader))

	// ... get insecureHeader, if both empty just go through base

	if proxy != "" || insecure {
		next, err := rt.transportFor(proxy, insecure)
		if err != nil { return nil, err }
		return next.RoundTrip(out)
	}
	return rt.base.RoundTrip(out)
}

transportFor caches *http.Transport by key (connection pools should be reused, not created per request), and validates that the proxy protocol only allows http / https / socks5; if invalid, it returns a Chinese error: Proxy address %q is invalid, example: http://127.0.0.1:7897.

These two headers are removed before sending, the server never sees them, and the user never sees them.

The redirect chain must be collected manually

http.Client performs redirects itself, and the page only receives the final response. But "how many times this request jumped, where each hop landed, how long DNS and TLS took" is key for debugging proxy and gateway issues.

The collection point can only be in the Transport: the frontend gives each request a trace id stuffed into X-Mygo-Trace-Id, and the Transport attaches httptrace.ClientTrace to that hop:

func (t *hopTimer) trace() *httptrace.ClientTrace {
	return &httptrace.ClientTrace{
		DNSStart:  func(httptrace.DNSStartInfo) { t.dnsStart = time.Now() },
		DNSDone:   func(httptrace.DNSDoneInfo) { t.dnsDone = time.Now() },
		GetConn:   func(string) { t.connStart = time.Now() },
		GotConn:   func(info httptrace.GotConnInfo) { t.connDone = time.Now(); t.reused = info.Reused },
		TLSHandshakeStart: func() { t.tlsStart = time.Now() },
		TLSHandshakeDone:  func(cs tls.ConnectionState, err error) { t.tlsDone = time.Now() },
		GotFirstResponseByte: func() { if t.firstByte.IsZero() { t.firstByte = time.Now() } },
	}
}

One record per hop (method, URL, status code + DNS / connect / TLS / first byte / total, and GotConn's Reused can mark "this didn't reconnect"), accumulated by trace id, then JSON + base64 put back into X-Mygo-Trace response header, and the page decodes it and renders into the "Chain" tab.

Two companion decisions:


Decision two: WebSocket also goes to Go

The browser's new WebSocket(url, protocols) has no headers parameter. That means:

This is fatal for an API debugging tool. So WebSocket handshakes and frame encoding/decoding are handled on the Go side, and it reuses the same http.Client — that sentence is the point: the cookie jar is shared, so when the page calls a login endpoint first and gets cookies, the WS connection automatically carries them into the handshake, no manual copying.

Usage looks like this: the protocol switches from HTTP to WebSocket, handshake headers are the ordinary request header table (Authorization, Cookie whatever you like), subprotocols come from Sec-WebSocket-Protocol; once connected, messages are separated into "receive / send / system" categories, and the input box at the bottom of the panel sends on Enter.

Boundaries must be defined, no unbounded reception: a single connection keeps at most 500 messages, each at most 256 KB; beyond that, truncate and show a hint in the UI.

SSE stays in the frontend as-is: it's just an ordinary HTTP response stream, the page parses event chunks itself (event / data / id three field types, comment lines treated as heartbeats and discarded, multi-line data joined into one), reusing the same fetch, the same cancel button. Opening a separate Go channel for it yields no benefit.


Decision three: storage switched from JSON to SQLite, with diff-based commits

Version 0.1 stored everything in workspace.json under userData: every change was fully serialized and fully written to disk.

Three problems showed up early:

  1. Continuous edits (dragging splitters, typing variables) trigger multiple full writes within tens of milliseconds
  2. A crash mid-write = the entire workspace is corrupt, because the overlay is atomic but the content is monolithic
  3. For statistics, grep, or manual data fixes, you're stuck with a text editor

The storage layer is now SQLite, using MyGo's sqlite plugin: a precompiled sqlite3 is loaded via purego, no cgo needed, and mygo build places the native library for the platform (on Windows it's mygo-sqlite3.dll, about 1.4 MB) next to the product and packs it in. Users get it working after install, no separate database installation needed.

Tables are split into collections / folders / requests / history / environments / variables / tabs / settings / meta; the collection tree's hierarchy and order are restored by a position column, and folders and requests share one sequence space.

What's worth writing down is the three properties of save() (error handling omitted below):

export async function save(data: PersistedWorkspace): Promise<void> {
  const next = rows(data);        // in-memory snapshot expanded into rows, each row carries a signature
  const previous = written;       // the last successful written rows
  const statements: Statement[] = [];
  const kept = new Set<string>();

  for (const row of next) {
    const key = `${row.table}:${row.id}`;
    kept.add(key);
    if (previous.get(key) === row.signature) continue;   // content unchanged, skip
    statements.push({ sql: row.sql, params: row.params });
  }
  // present before, absent now = deleted rows (or history trimmed)
  for (const key of previous.keys()) {
    if (kept.has(key)) continue;
    const cut = key.indexOf(":");
    statements.push({ sql: `DELETE FROM ${key.slice(0, cut)} WHERE id = ?`, params: [key.slice(cut + 1)] });
  }
  if (statements.length === 0) return;

  await db.transaction(statements);   // BEGIN IMMEDIATE, entire batch in one transaction

  written = new Map(next.map((row) => [`${row.table}:${row.id}`, row.signature]));
}

One: don't write if content hasn't changed. Each row carries a signature, which is JSON.stringify of that row's content, without timestamps. Otherwise every save would rewrite the entire database because updated_at changed: the first version hit this trap, showing up as "only one environment variable changed, and the database file grinds the disk every second".

Two: deletions must use DELETE. Doing only INSERT … ON CONFLICT DO UPDATE would leave deleted collections in the database, and they'd "resurrect" after restart. So a diff is computed by table:id, and the leftovers are treated as deletions.

Three: one transaction for the entire batch; on failure, don't update the fingerprint. When a write throws, written keeps the old value, and the next save retries the entire batch of diffs. This matters more than "rollback": it guarantees the invariant "what you see in memory = what's in the database" will eventually converge.

A 220ms debounce on top batches continuous edits:

saveTimer = window.setTimeout(() => { /* real save */ }, 220);

The migration path is also fixed: on first launch, the old workspace.json is read and migrated into the database, then the original file is renamed to workspace.json.bak (not deleted); if parsing fails, skip migration and show a hint in the UI. There is only one entry point for version checking: meta.schema_version; guessing versions by "whether a table exists" is forbidden.


Decision four: scripts don't pretend to be sandboxed

Version 0.2.x added Postman-style pm scripts: pre-scripts compute signatures and modify requests; post-scripts decrypt responses, take a 401, swap tokens, and resend.

Implementation took a not-very-"secure" but honest decision: scripts run directly in the page via AsyncFunction, no sandbox isolation. The same code can reach window, localStorage, and the entire document DOM.

Why not wrap an iframe + sandbox or another isolation layer? Because true isolation would require serializing all of pm's capabilities across a boundary, and handling async, object references, error stacks: that's the engineering effort of another project, and the security it gives is fake (the isolation layer itself can be bypassed).

So the boundary is made explicit:

Scripts are the user's own local JS. So any script that comes from an imported file (Postman Collection / HAR) is marked "untrusted" and is not in the execution chain; only after the user clicks "trust and enable" in the script library will it execute.

This is a security model a user can understand in a second, not a statement like "we have a sandbox".

Two implementation details worth mentioning:

1. 5-second budget, but network wait time isn't counted.

const SCRIPT_TIMEOUT_MS = 5000;

The problem is a script can await pm.sendRequest(...) to actually send another HTTP request. If the timer keeps running, a single slow login request can drag the whole script into timeout. So before sending a request, the timer is disarmed; after returning, the spent time is deducted and the timer is re-armed:

The script itself runs at most 5 seconds; network wait is not counted.

2. pm.execution.resend(), so a 401 can trigger a login and re-run the chain.

After a post-script gets a 401, it can pm.sendRequest to call the login endpoint, get a token, write it to an environment variable, then call pm.execution.resend() to send the same request again. Resend is at most 3 times, and variables are re-interpolated.

This "auto-refresh and retry on expired token" flow in other tools either requires building it with collection-level pre-requests, or the user clicks twice. Here it's a three-step script:

if (pm.response.code === 401) {
  const res = await pm.sendRequest({
    url: "{{baseUrl}}/auth/refresh",
    method: "POST",
    body: JSON.stringify({ token: pm.environment.get("refreshToken") }),
  });
  pm.environment.set("accessToken", res.json().access_token);
  pm.execution.resend();
}

One interaction decision: pre/post operations are layered ordered lists: global → collection → folder (outer → inner) → request; post-scripts run in reverse, and within a layer they follow the list order. Each layer can mix "script", "script library reference", and "wait time" as steps, one line per step, with arrows to expand in place, and a … menu to move up/down.

No drag-and-drop reordering. These lists usually have only three to five lines; drag targets are too small and misoperation cost is high; up/down buttons are both precise and fast.


Decision five: design system enforced by mechanical gates, not by willpower

With one person maintaining and AI helping change CSS, the most realistic risk isn't "can't write a UI" but "three months later you have a second color scheme, four border radii, five font sizes".

So there's an authoritative document docs/设计系统.md first, with hard lines:

Dimension Stance
Accent color Only one: indigo. No second accent color is approved
Border radius Small rounded, hard-edged feel, 6 levels
Shadow only for "floating" elements, not for section separation
Animation ≤ 180ms, ease-out, no bounce
Copy All Chinese, verb-first, one sentence finishes the point

Tokens have three layers: global physical values appear only in :root and theme blocks → alias tokens (--accent / --text-2 / --fs-md) → component rules may only reference the alias layer.

But documentation is just documentation. What actually governs is the script scripts/check-ui-tokens.mjs, run by npm run lint:ui, passing both in CI and before commits:

Each violation carries a file line number and a one-line fix hint, like:

src/style.css:<line>  Bare color #3b82f6  → use var(--accent/--text-2/--border …), or define a semantic token first in the token section
src/style.css:<line>  Bare font-size: 12px  → use var(--fs-2xs|xs|sm|md|lg|xl|2xl)

One sentence summary of this experience: "keeping style consistent" is not a rule; what can exit 1 is a rule.

Another companion gate is "both themes must be checked": screenshots are generated by e2e, one set each for light and dark; committing after verifying only one theme counts as unverified.


Two WebView2 performance traps

One: cold GPU cache causes a long first frame

The symptom is odd: when a user first clicks "New Script", there's a stutter; later clicks don't stutter. Every e2e run uses a fresh userData, so every run reproduces it.

The root cause is that WebView2's GPU shader/raster cache (the GPUCache under userData) is cold; a certain type of widget has to compile shaders on first draw, measured once as a 74ms-class long frame.

The fix isn't "optimize rendering" but "pay this cost in advance": after startup completes, before the user does anything, draw the shapes of common widgets for two frames and then discard them.

The difficulty lies in two prerequisites; missing either one makes it worthless:

So the pre-warm layer uses opacity: 0.01 + pointer-events: none (invisible to the eye, but actually drawing), kept for two frames, then removed. Measured result: "New Script" first frame dropped from 74ms to 0.

The long-term cost: from now on, any widget that is first drawn after the initial screen must be added to the pre-warm list, otherwise that 70ms will leak to users as a "random stutter".

Two: the cost of backdrop-filter

The modal overlay originally had a layer of backdrop-filter: blur(). Visually it almost didn't show, since the overlay already darkens the background, but on software-rendered machines, it took several hundred milliseconds per frame, showing up as "the switch overlay turns into a slideshow when opening the settings panel".

After removing it, no visual perception difference, and interaction is noticeably smoother. Blur is the lowest-cost visual effect in this kind of tool: it costs several hundred milliseconds but only provides a "more expensive" look.


Testing: real binary + real windows + real Go requests

No Playwright / Cypress. Reason: we're testing a desktop application, not a web page.

The approach: the application links MyGo's Automation plugin under //go:build e2e, only in that tag; the release build doesn't have that tag, so the artifact contains no inspector. In tests, MYGO_AUTOMATION points to a Unix socket, and the protocol is newline-delimited JSON. The page window can only do three things: eval (execute page scripts), capture (screenshot), menu (click menus) — it can't click native controls.

Isolation is done cleanly:

One testing discipline written into the rules: wait conditions must anchor to "the observable difference caused by this operation". No > 0, no "just check the element exists", no sleep. If A changed, assert A changed; don't use B as a stand-in.

And one reverse rule: changing src/ sample data or default behavior just to make a test green is cheating.

Now a default run has 105 cases, from "every widget on every screen has a name, a size, and a center point that can actually be clicked" to whole-chain scenarios like "post-script detects 401, logs in, writes environment variable, resends to 200". Packaging cases (MYGO_E2E_PACKAGING=1, assert the UI comes from the binary) and README screenshot cases are separate switches and don't occupy the daily run.


Explicitly not done

Boundaries are written in the docs and in the README:

The reason for each is one sentence: these all turn a "lightweight local tool" into a "heavyweight testing platform". And "lightweight, pure-local" is this project's only reason to exist.

Known costs are also honestly recorded in the README, not hidden:


Summary

One person + one agent, from project start to 0.2.3, 105 e2e cases. Looking back, the reusable experience is roughly these:

  1. In a desktop shell, capabilities that can be handed to the native side shouldn't be worked around in the page. Sending packets, WebSocket, cookie jar go to Go; problems like "CORS error" simply never appear.
  2. When the framework gives you only one shared Client, smuggle request-level options into the Transport; don't modify the framework for a single request-level toggle.
  3. "Don't write if content hasn't changed" matters more than "write faster". Fingerprint + diff + single transaction, these three properties are more substantial than any caching strategy.
  4. If you can't build a sandbox, say it clearly. Converging the security boundary to a toggle a user can understand (trusted/untrusted) is more responsible than building a fake isolation layer.
  5. Style consistency relies on lint, not willpower. What can exit 1 is the rule.
  6. Pay the cold-cache first-frame cost in advance. Desktop webview performance problems are often not "algorithm is slow" but "first-time shader compilation".
  7. Either test real things, or don't test. Real binary, real windows, real Go requests: this is the only path that proves "usable after packaging" for this desktop stack.

Project on GitHub: fayvo-hub/Kotae; Releases contain Windows installer / standalone single-file exe and macOS dmg. If you're willing to try it, feel free to file an issue; all data is in the local workspace.db, and if you install and aren't satisfied, deleting the data directory leaves a clean slate.

Example screenshots

02-json-tree.png

03-script.png

04-environment.png

05-curl-import.png

06-import.png

07-palette.png

08-settings.png

09-history.png

10-light.png

11-stream.png

12-stream-light.png

13-script-library.png

Comments

Top 1 from juejin.cn, machine-translated. The original thread is authoritative.

小Z先生

Ultra-lightweight Postman, welcome to try and give a star. https://github.com/fayvo-hub/Kotae