ContentsWho this is for: engineers who want desktop tools in Go without dragging in Electron; developers who need a lightweight HTTP client; anyone curious about Wails v2, WebView2 and
net/httpdetails.Bottom line first: NanoAPI's bet is "reuse the system WebView2, build a minimal HTTP engine, store everything in one JSON file". Sending a request is not the hard part — aligning timeouts, redirects, Windows paths, multipart boundaries and crash-safe writes is.
Reading time ~30 minutes · Level: advanced
Postman is powerful, and that power rests on size, accounts, cloud sync, a plugin ecosystem and telemetry. For a developer who only needs to "enter a URL, pick a method, add a few headers, read the JSON back", most of that is background noise. NanoAPI compresses the goal into one sentence: a sufficient HTTP debugging client, one exe, double-click to run, data stays local.
"Sufficient" here is not an adjective but a checkable set of constraints. go.mod has exactly one direct dependency: github.com/wailsapp/wails/v2 v2.17.0. The frontend has no npm dependencies and no build step; native ES modules are embedded straight into the binary by go:embed. The release asset NanoAPI-0.1.0-x64.exe is 11,341,824 bytes, about 10.8 MiB; the NSIS installer is about 6.3 MB, the MSI about 4.5 MB. For comparison, an Electron app's installer usually counts in hundreds of MB.
Those numbers are not squeezed out by cutting features; they come from choices: rendering belongs to the system WebView2, network and files belong to the Go standard library, and the frontend keeps only the DOM plus a little glue beyond fetch. So there is no Chromium copy, no Node runtime, no third-party HTTP client, no database. There is not even auto-update or crash reporting.
The boundary must be stated too: NanoAPI only supports its own bundle format nanoapi-bundle v1, plus a bare nanoapi.json. It does not import Postman, Bruno, OpenAPI or HAR, and the repository contains no parser for any of them. That is scope control, not an oversight: a format adapter layer turns "a project one can maintain on weekends" into "a compatibility layer that is never finished". For migration, users go through cURL — the universal channel — or tidy the JSON by hand.
A classic two-layer Wails v2 app. main.go owns the window and go:embed; app.go binds the App struct to the frontend; internal/ splits into six packages — proxy, interp, curl, store, bundle, model. The frontend lives in frontend/dist: index.html plus a set of ESM modules, no node_modules, no Vite.
At runtime Wails exposes bound methods on window.go.main.App. bridge.js is a thin promise wrapper that turns synchronous throws into rejections so callers cannot forget .catch:
function call(method, ...args) {
try {
return Promise.resolve(backend()[method](...args));
} catch (err) {
return Promise.reject(err);
}
}
That boundary decides the division of labour. The frontend owns tabs, the collection tree, the request editor, response rendering, context menus and shortcuts; the backend owns URL merging, variable interpolation, authentication, multipart, TLS, redirects, timeouts, timing capture, response reading, file saving and bundle import/export. The frontend never calls fetch directly, so there is no "two sets of network rules" problem.
Bound methods cover data loading, three kinds of save, tabs, history, cookies, bundles, cURL, sending, file picking and response saving. Every method returns a promise, handled with await or .catch. Because all I/O sits in Go, the frontend never deals with CORS, certificates, cookies or filesystem permissions; WebView2 only draws the UI.
That split also explains why response bodies never touch disk: Wails bindings transfer JSON strings, and a large response crossing that boundary once is already enough — persisting another copy only multiplies memory and disk cost. When a response must be kept, the user explicitly clicks "save body", which goes through the system save dialog.
Frontend state lives in tab objects, each owning its own request and response. Response bodies stay in memory and tab state, never in the data file; a restart restores tabs and unsaved edits, but not the last response. That keeps the data file small and stable and avoids repeatedly serialising possibly large bodies.
Startup checks whether window.go exists; if the backend is not ready the app shows an error page rather than an empty window. A failed loadAll raises a toast but initialisation continues, so one read error cannot block the whole app.
The send button lives in app.js. It guards re-entry with state.sending, records the originating tabKey, and after the await finds that tab again and writes the result back to the caller. Switching tabs while waiting therefore cannot leak results into the wrong tab:
async function send() {
if (state.sending) return;
const tabKey = state.activeTabKey;
state.sending = true;
try {
const res = await bridge.sendRequest(tab.request, state.activeEnvId, {
timeoutMs: state.settings.timeoutMs,
followRedirects: state.settings.followRedirects,
insecureTls: state.settings.insecureTls,
});
const target = state.tabs.find((t) => t.key === tabKey);
if (target) target.result = res;
} finally {
state.sending = false;
}
}
Backend SendRequest is a one-way pipeline: Store.Snapshot() takes a data copy, buildVars assembles the variable table, resolveRequest interpolates on a request copy, buildBody produces bytes and a Content-Type, proxy.Options is assembled, and Client.Send takes over. Network failures are not returned as Go errors; they land in Result.Error and Result.ErrorKind. Only program state such as "store unavailable" travels as a Go error, so the frontend can distinguish "the request failed" from "the program is broken".
SendOptions uses pointer booleans to distinguish "the frontend did not specify, use global settings" from "explicitly off". pickInt and pickBool fall back through settings then defaults, and timeout, follow-redirects and skip-TLS all resolve in that order — so a single request can override settings without rewriting them globally.
If buildBody fails to read a file it does not return a Go error either: it builds a Result with ErrorKind: file and records history as usual. The frontend can then show "file read failed" instead of an unhandled exception. The model holds across the chain: once a request has been attempted, failure comes back as a Result.
History stores the original request req, never the interpolated resolved — so history keeps {{token}}, not the resolved secret. Response bodies, timings and headers are not persisted; only status code, size, time and an error summary are. History caps at 200 entries, dropping from the tail. Success and failure both call recordHistory, so history contains 200s as well as timeouts and DNS failures; with status 0 the frontend still renders by error kind.
No third-party HTTP client: internal/proxy/proxy.go uses net/http directly. The reason is practical — full control over TLS verification, redirect policy, timeouts, connection closing and phase timings, all of which high-level wrappers tend to flatten.
Layer one is URL merging. BuildURL first completes a bare domain with http://, then parses the original query string and merges the parameter table; same-name keys overwrite, the rest keep order, and query-style API keys append at the end. One easily missed detail: a bare ?flag with no equals sign must keep its valueless state and must not become ?flag=. The implementation records this with kvPair{hasValue bool}, shared by sending and cURL export so two URL rule sets cannot drift.
Concretely: with the original URL https://api.example.com/users?page=1&flag and a parameter table adding size=20 while overriding page=2, the final query is page=2&flag&size=20. flag has no equals sign and must not gain one; spaces in values become +, and non-ASCII text is percent-encoded.
Layer two is request construction. Timeout and httptrace must be attached to the context before the request is created:
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{ /* DNS/Connect/TLS/TTFB */ })
req, err := http.NewRequestWithContext(ctx, method, finalURL, bodyReader)
Reverse the order and http.Request holds a parent context with no deadline, making the timeout useless. That ordering trap gets its own entry below.
Layer three is transport. Every request clones http.DefaultTransport.(*http.Transport), sets InsecureSkipVerify when the user disabled certificate checks, and calls CloseIdleConnections() in a defer. Go's http.Client handles redirects itself; CheckRedirect does two things: return http.ErrUseLastResponse when following is off so 3xx responses surface as-is; or allow at most 10 hops and record each hop's URL and status in Hop.
Layer four is reading the response. io.LimitReader(resp.Body, max+1) reads 20 MB plus one byte; beyond that the data is cut to 20 MB and Truncated is set. Memory stays bounded and "exactly 20 MB" is distinguishable from "truncated". Content-Type, UTF-8 validity and NUL bytes decide whether the body is binary; binary becomes Base64, text passes through. Cookies live in a shared cookiejar, and ClearCookies swaps in a new jar rather than deleting entries one by one.
One special case when writing headers: Host does not go into Header but onto req.Host, because Go's Header does not participate in host selection. Authentication is applied after headers: Basic via SetBasicAuth, Bearer writing Authorization, and API keys going to a header or a query parameter depending on AddTo.
Proto, RemoteAddr, Redirects and the phase timings in Result are not decoration: they show whether a request is slow in DNS, TCP, TLS or time-to-first-byte, and where a 302 went. When the network fails, timings already collected are kept and the frontend notes "timings above were captured before the failure".
Status text is sliced out of resp.Status after the space; the protocol field records HTTP/1.1 or HTTP/2; headers stay a multi-value map so the frontend can show repeated headers on separate lines instead of merging them.
Syntax is {{name}}, matched by \{\{\s*([^{}]*?)\s*\}\}. interp.Resolve recurses at most three levels, replacing matching keys each round; it exits early when a round changes nothing, and placeholders still unresolved after three rounds stay as written. That handles {{baseUrl}}/{{path}} and variables referring to variables without looping forever on a={{b}}, b={{a}}.
Variable precedence is explicit: merge all IsGlobal environments first, then override same-name keys from the selected envID. Global variables are the floor, environment variables the override. Disabled rows and empty keys are skipped.
For example with globals baseUrl=https://dev.example.com and token=global-token, and the current environment overriding baseUrl to https://staging.example.com, {{baseUrl}}/users goes to staging while {{token}} still resolves to the global value. When several global environments share a key, the later merged one wins, and that merge order comes from the environment list.
The three-level boundary matters: a={{b}}, b={{c}}, c=value resolves; a longer chain leaves placeholders from level four onward in the unresolved list rather than expanding forever. It is a hard anti-cycle cap, not "try a few more times".
Resolution only happens on a copy. cloneRequestForResolve copies the slices that may be rewritten — Params, Headers, Body.Form, Body.Files — while URL, body and auth fields are copied by value. Resolution covers URL, parameter keys and values, header values, raw/form/files bodies, Basic username and password, Bearer and API key pairs; enum fields such as method and body type are not interpolated.
Unresolved variables do not fail the request: it is sent as written and the names come back in Result.UnresolvedVars. The frontend shows "these variables were not resolved and were sent as-is" in red. That degradation is deliberate — during debugging you often need to see how the server responds to an unfilled path rather than being blocked client-side.
cURL import does not shell out; it ships a small lexer handling single and double quotes, backslash continuations, cmd's ^ continuation, the $ prefix and curl.exe. The lexer emits tokens, which are then parsed against an option table.
The supported set covers day-to-day debugging: -X/--request, -H/--header, -d/--data, --data-raw, --data-binary, --json, -F/--form, -u/--user, --oauth2-bearer, -b/--cookie, -A/--user-agent, -e/--referer, -G, -I. Unknown options are never silently dropped; they land in Warnings, shown with the import result.
Method inference follows curl's habits: an explicit -X wins; -I yields HEAD; -F or -d yields POST; -G moves -d content into the query string and returns to GET. --json sets a JSON body and adds Accept: application/json. When an explicit method conflicts with inference, the explicit one wins.
A few key mappings:
-d @file and --data-binary @file read the file, capped at 20 MB and requiring UTF-8; --data-raw @x keeps the literal and does not read a file.-d values join with & into one body, matching curl's form behaviour.-F the request becomes multipart; per-file options such as ;type= and ;filename= are stripped with a warning, because NanoAPI file fields only have a key and a path.-k/--insecure is not written into the request; it warns "turn off TLS certificate verification in settings" instead.-x/--proxy warns and is ignored — there is no proxy configuration UI.-T/--upload-file, -K/--config and client certificates also only warn; the tool never pretends to support them.Export takes another path but reuses proxy.BuildURL and the same header/auth dedupe rules. Output is Bash-style with single quotes, inner single quotes replaced by '\''. Basic auth becomes -u; Bearer and header-form API keys become -H; multipart becomes --form-string plus -F key=@path. Dedupe removes Content-Length, Transfer-Encoding, hand-written multipart Content-Type, and duplicate Authorization or API key headers.
Multipart text fields deliberately use --form-string so a @ or < in the value is not read as a file reference; file fields use -F key=@path with the path handed to shell quoting. HEAD exports as -I rather than -X HEAD, because the latter can leave clients waiting for a body. shellQuote guarantees safety under Bash only, not cmd — output targets Bash, WSL or Git Bash, an explicit platform assumption.
This is not a complete cURL. The goal is "bring the common debugging commands in", not "interpret all of curl syntax", which makes the warning policy important: anything unrecognised is reported instead of quietly producing a request that looks right and behaves differently. For curl.exe -X POST 'https://api.example.com/users' -H 'Content-Type: application/json' -d '{"name":"Sam"}', the lexer does not treat quotes inside the JSON as command boundaries; an unclosed quote returns an error rather than guessing intent.
Uploads assemble multipart on the Go side. Text fields use WriteField; file fields are os.Stated first, directories rejected, single files capped at 20 MB and the total at 50 MB, then written via CreateFormFile with filepath.Base(path). All files are read into memory before assembly, which is exactly why the caps must exist — one huge file would otherwise take the process down. File paths are interpolated too, so {{fixturesDir}}/avatar.png works; resolution happens before sending and a missing path returns a file error.
One crucial step before sending: if the body is multipart, drop any user-written Content-Type. The boundary is generated for this request, so a hand-written one cannot match, and keeping it only makes the server fail to parse. Importing a cURL with both -F and a manual Content-Type removes it with a warning; export skips multipart's Content-Type for the same reason.
Download runs the other way. When saving a response body the filename is picked in order: the name in Content-Disposition, the last segment of the final response URL, then the request name. Content-Disposition supports both plain filename= and RFC 5987's filename*=UTF-8''. The chosen name is reduced to a basename, <>:"/\|?* and control characters are replaced, trailing dots and spaces trimmed, and the result capped at 120 runes. Both sides have cleaning logic: the frontend for suggestions, the backend as the final guard. Image preview has an extra 8 MB ceiling; beyond it, or when truncated, the view falls back to Base64 to avoid stuffing a huge data URL into the DOM.
Binary responses show as Base64 and the backend decodes back to raw bytes on save. Image responses preview directly when untruncated and small; other binaries stay Base64 text. The viewer therefore needs no renderer per format and never feeds binary data into a text highlighter.
The data file is fixed at %APPDATA%\NanoAPI\nanoapi.json, derived from os.UserConfigDir(). It holds collections, environments, global variables, settings, history and open tabs. No database, no schema version, no migration framework. If the user config directory is unavailable, NewApp falls back to the system temp directory so the app still starts — with no persistence across restarts.
Reads do no whole-file validation, only per-field repair: a timeout of zero or less resets to default, an empty theme resets to default, nil collections/environments/history/tabs become empty slices. One bad field therefore cannot cost the user their chosen theme. If JSON parsing fails, the original file is renamed to nanoapi.json.corrupt-<timestamp>.json and the app starts empty. Nothing is deleted, so manual repair or restoring a backup stays possible.
Writes are debounced by 300 ms so typing does not hit the disk. The actual write creates a temp file in the same directory, writes, Syncs, closes, then Renames over the target — a process exiting mid-write leaves no half-JSON. Snapshot returns slice copies so callers cannot mutate underlying data while it is being serialised. That copy looks fussy and is the core of concurrency safety: while an HTTP request runs in the background, the frontend may be editing collections or environments, and serialisation must work from a stable copy.
Close only performs a final flush, and closing the window also triggers the frontend's tab save. Both paths use the same atomic write, so there is no "different write logic on close" risk. History caps at 200 entries; bodies and timings never persist. The file usually stays tens of KB, and "you can see the previous response after restart" is explicitly not a capability. To keep a response, save it to a file.
Bulk editing of environment variables is pure frontend logic in envbulk.js. Input is .env-style KEY=value text; parsing skips blank lines and # comments, drops an export prefix, supports single and double quotes, and inside double quotes resolves \n, \r, \t, \" and \\. Duplicate keys take the last line, and invalid lines are counted without blocking. Existing variables keep their enabled and desc, so one bulk paste cannot wipe carefully written notes.
Formatting is the reverse: values containing newlines, tabs, leading or trailing spaces, or already quoted, get double quotes and escaping. Frontend tests cover parse-format round-trip consistency, which catches quote and escape edge cases that single-line tests miss.
For example, given export BASE_URL=https://api.example.com, TOKEN="line1\nline2", # comment and a line BROKEN with no equals sign, parsing yields two variables and one invalid-line count; the escaped newline in TOKEN becomes a real line break. A repeated KEY overrides the earlier one while preserving its enabled and desc.
Backups use only the project's own nanoapi-bundle v1, exporting collections and environments, never history, settings or tabs; import caps at 32 MB and also accepts a bare nanoapi.json. Import is not overwrite but append-merge: the collection tree recursively generates new ids so nodes cannot collide; environments get new ids with " (imported)" appended on name clashes and " (imported 2)" after that; global variables exist once only, merging by key with existing values winning, skipping duplicates and reporting a count.
To repeat: this import recognises no Postman Collection, Bruno, OpenAPI or HAR. No format sniffing, no field mapping. If you hold a Postman export, the correct path is to export cURL and import that, or convert the JSON by hand. That is a product boundary, not a to-do item.
Symptom. Set a 200 ms timeout against an endpoint that takes 2 seconds; the client still waits the full 2 seconds and reports something other than a timeout.
Cause. The original code called http.NewRequestWithContext(ctx, ...) first and only then attached WithTimeout and httptrace. http.Request keeps the deadline-free context it was built with; deriving a new context later does not affect a request already constructed, and the timer was never on the executed path.
Fix. Reorder: context.WithTimeout, then httptrace.WithClientTrace, then NewRequestWithContext. Added TestTimeoutIsReported: a 200 ms timeout against an httptest server that sleeps 2 seconds, asserting ErrorKind == timeout.
Cost. Every request now creates and cancels a context, which makes the code path wordier; and the timeout is a deadline for the whole request — DNS, connect, TLS, first byte and body read — not a connect timeout. Users who want per-phase limits can only read the timing panel.
Symptom. Following redirects into an infinite 302 shows "network error" rather than "more than 10 redirects", and the internal ErrorKind becomes network.
Cause. When CheckRedirect noticed more than 10 hops it wrote res.Error and ErrTooManyRedirects and returned an error. client.Do wrapped that in *url.Error, and the outer handler then overwrote res via translateError and classify. A specific error lost to a vaguer one.
Fix. In the client.Do error branch, check whether res.ErrorKind is already ErrTooManyRedirects and keep it. Added TestTooManyRedirectsKeepsSpecificError asserting both the kind and the message survive.
Cost. Error handling now has a branch for one specific kind, coupling CheckRedirect to the outer handler. Any future "write the result inside a callback" error type must remember the same guard.
Symptom. Connecting to a closed port is recognised as "connection refused" on Linux but falls into the generic "network error" on Windows — same error, different text per platform.
Cause. Classification originally matched Linux strings like connection refused. Windows' WSAECONNREFUSED surfaces as actively refused or no connection could be made, so string matching failed; the underlying error is also wrapped by *net.OpError and *url.Error, making the outermost text unreliable.
Fix. Use errors.As to extract *net.OpError, then try syscall.Errno and compare against ECONNREFUSED, ECONNRESET, ETIMEDOUT; when no errno is available, fall back to lowercase text matching covering actively refused and no connection could be made.
Cost. String fallbacks are inherently fragile across system languages and Go versions, and can misclassify non-network errors containing connection reset. The errno path is primary; strings are insurance.
Symptom. Hitting a self-signed HTTPS service shows "network error", leaving no hint that certificate verification can be disabled in settings.
Cause. TLS error types vary across Go versions: besides x509.UnknownAuthorityError there are tls.CertificateVerificationError and x509.HostnameError, usually wrapped in url.Error. Checking one type misses most certificate problems.
Fix. classify matches tls.CertificateVerificationError, x509.HostnameError and x509.UnknownAuthorityError in order via errors.As, then falls back to certificate and tls substrings, all mapping to ErrTLS, which the UI shows as "TLS certificate error".
Cost. Substring fallbacks can misread server errors mentioning tls as certificate problems. The project has no client certificates, CA import or chain inspection, so enterprise mutual TLS means bypassing it or using another tool.
Symptom. Importing -F "file=@C:\Users\me\a.txt" loses the backslashes and the file is not found.
Cause. The lexer treated backslashes as escapes by shell rules. Rules meant for \, \" and \\\\ swallow the backslash before characters like \U or \m, destroying Windows paths — and Windows users rarely add extra quotes or double backslashes.
Fix. Escape only space, tab, quotes, backslash, $ and backtick; keep the backslash for everything else. The non-special branch lives in curl.go, and TestSplitCurlCommandKeepsWindowsPaths pins the behaviour.
Cost. One lexer serves both POSIX shell and Windows habits, so semantics cannot be identical. An unquoted path containing spaces still splits into tokens, and \n inside double quotes is still a newline rather than a path fragment.
^ continuation is not recognisedSymptom. Copying a multi-line cURL from a cmd window (lines ending in ^) yields shifted arguments or a parse failure.
Cause. The lexer knew backslash continuations and plain newlines, not cmd's ^; and Windows text may carry CRLF, so ^\r\n was never treated as one continuation.
Fix. The ^ branch handles both \n and \r\n as continuations, while a standalone ^ stays a literal. The $ prefix and curl.exe are also accepted, covering commands copied from PowerShell.
Cost. Only continuation is implemented, not cmd's full escaping: %VAR% expansion, & separation and ^ escaping of other characters are not simulated, so complex batch commands still warn or parse imperfectly.
Symptom. Users assume -d @payload.json and --data-raw @payload.json both read a file, then the latter sends the literal @payload.json; or they assume -k disables certificate checks, yet importing leaves settings untouched.
Cause. These differences are curl's own. -d and --data-binary read files behind @, while --data-raw explicitly keeps the literal; -k is a per-run TLS option in curl, whereas certificate verification here is a global setting that one command should not silently change.
Fix. Dispatch by option name: data and data-binary read files (20 MB, UTF-8), data-raw keeps text, -k/--insecure only warns and points at settings, and --data-urlencode imports verbatim with a warning that no URL encoding was applied.
Cost. Option semantics live in a lookup table that must grow with each new option; stdin, -K config files, client certificates and proxies are unsupported. Users may ignore warnings, especially in bulk pastes.
Symptom. Writing Content-Type: multipart/form-data; boundary=xxx by hand and then choosing multipart upload makes the server fail to parse or receive fields.
Cause. multipart.NewWriter generates the boundary at send time, so the hand-written one differs from the one actually used. Mismatched header and body are rejected by any strict server.
Fix. After building the body and before sending, if the body is multipart, delete Content-Type from the header copy and use the automatic value from w.FormDataContentType(). cURL import removes a hand-written Content-Type when -F is present and warns; export skips it too.
Cost. Users lose the ability to customise multipart Content-Type, for example to set charset by hand. That is the intended trade: making a wrong configuration impossible beats preserving a freedom that usually produces errors.
Symptom. Consecutive requests each re-establish a connection, so load runs and batch debugging take longer than expected. Sharing a transport without closing it leaves idle connections hanging until timeout.
Cause. The app must switch InsecureSkipVerify per request, and TLS configuration belongs to the transport, which cannot be safely mutated on a shared one; sharing would also let one request's TLS policy leak into another. Per-request transports mean no connection reuse.
Fix. Clone DefaultTransport per request, set its TLS configuration, and call CloseIdleConnections() in a defer. Connection lifetime is bound to the request, nothing hangs, and TLS policies stay separate.
Cost. No keep-alive reuse: consecutive requests repeat DNS, TCP and TLS. Acceptable for a debugging tool, poor for high-frequency batch calls. Getting both reuse and per-request TLS would need a pool keyed by TLS policy, a clear step up in complexity.
Symptom. A multi-MB JSON makes the response pane unresponsive and the editor laggy; a large request body triggers JSON validation on every keystroke, which also stutters.
Cause. The renderer ran JSON.parse, formatting and highlighting over the entire body, and live validation ran on every input. At scale, string processing and DOM construction dominate the main thread.
Fix. Three thresholds: response reads cap at 20 MB (truncated with a notice), bodies over 1 MB skip prettifying and highlighting and show raw text, and request bodies over 512 KB skip live validation, debounced to 200 ms. Truncation propagates to the save dialog, which says so.
Cost. Responses over 20 MB are never fully available, and saving keeps the truncated part; JSON over 1 MB loses folding and colour; request bodies over 512 KB stop reporting syntax errors, which then wait for the server.
Symptom. Killing the app mid-write makes nanoapi.json unparsable on restart, and collections and environments vanish.
Cause. Overwriting the target in place is not atomic: exiting mid-write leaves invalid JSON, and a reader with no recovery strategy shows empty data.
Fix. Write a temp file, Write, Sync, Close, then Rename over the target; on a read failure, rename the original to .corrupt-<timestamp>.json and start empty; fill defaults per field so one bad field cannot sink the configuration. Tests assert every flush leaves valid JSON, no temp files remain, and a corrupt file is backed up and recoverable.
Cost. No schema version means field upgrades rely on "fill what is missing, ignore what is unknown", with no real migration; corruption recovery is "keep the file, lose the data" and needs manual repair; 300 ms debounce can lose the last few hundred milliseconds of edits, which the forced flush on close only partly compensates.
Symptom. Importing a backup twice produces duplicate collection nodes where deleting one affects the other, two identically named environments that cannot be told apart, and global variables overwritten wholesale.
Cause. Bundle nodes carry their original ids and collide when appended; environment names have no uniqueness constraint; and global variables are supposed to exist exactly once, so appending them as ordinary environments creates a second copy.
Fix. Collection nodes get fresh recursive ids, and request nodes gain default method, body type and auth type; environments get fresh ids with " (imported)" and an incrementing suffix; global variables stay single, merging by key with existing values winning, duplicates skipped and counted in a warning.
Cost. Import appends rather than deduplicating, so the same backup imported twice still yields two collection sets — only the ids avoid collision. Renamed environments change what the user sees, and "existing value wins" can silently ignore newer values from the backup, leaving only a count in the warning.
The build command deliberately bypasses the frontend toolchain:
wails build -platform windows/amd64 -s -skipbindings -webview2 browser
-s skips the frontend build, -skipbindings skips generated wailsjs bindings, and the frontend calls through the runtime window.go instead. Frontend files are embedded by go:embed, so the artefact is a single executable with no frontend/dist beside it. The release script then drives Wails' NSIS template and WiX to produce a portable exe, an NSIS installer and an MSI.
The script writes the version into wails.json's info before building and restores it afterwards; the portable exe gets its version through -ldflags -X main.appVersion=..., while installer metadata comes from wails.json. The two version sources must stay in sync or the settings page and file properties disagree. The three artefacts serve three distribution stories: the portable exe for running anywhere, NSIS for Start Menu, desktop shortcuts and WebView2 bootstrapping, MSI for domain deployment and silent installs.
For 0.1.0 the portable exe is 11,341,824 bytes, NSIS about 6.3 MB, MSI about 4.5 MB. Three files solve three distribution scenarios, not three feature levels.
The path also surfaced two classic Windows engineering problems. Line endings: running gofmt -l on a Windows runner fails on CRLF, so commit a16b158 added .gitattributes to normalise to LF. MSI shortcuts: WiX enforces ICE43/ICE57 component ownership, and the first definition failed until commit bd8208e reorganised components and shortcuts.
The costs extend to distribution reality: there is no code signing, so SmartScreen warns about an unknown publisher; the MSI requires WebView2 to be present while NSIS tries to install it; and the version must be synced between wails.json and the injected appVersion. A single exe buys simplicity for users and more manual constraints in the build chain.
First, compression is not handled directly. Go's transport only decompresses automatically when it added Accept-Encoding itself; if a user writes Accept-Encoding: gzip by hand, the transport will not decompress on their behalf, and NanoAPI has no br or deflate decoder, so the pane shows raw compressed bytes. It is not "gzip unsupported", it is "do not write that header — let the transport negotiate". That is why the README never advertises gzip: automatic negotiation is safe, manual overrides may return unreadable bytes.
Second, sensitive headers across redirects follow net/http defaults. Authorization is dropped on a cross-origin hop; NanoAPI neither replays it nor tells the user "the auth header was removed because the redirect crossed origins". Same-origin hops are usually fine, but cross-origin debugging produces the classic "it worked, then 401 after the redirect".
Third, proxies are inherited implicitly. Because transports are cloned from DefaultTransport, ProxyFromEnvironment still applies, so HTTP_PROXY, HTTPS_PROXY and NO_PROXY affect requests. There is no proxy UI and cURL's -x/--proxy only warns. Anyone with proxy environment variables set may egress from an unexpected place.
Fourth, import formats are only nanoapi-bundle v1 and bare nanoapi.json. Postman, Bruno, OpenAPI and HAR are unsupported: no sniffing, no mapping, no migration wizard.
Fifth, there is no schema version. Upgrades rely on per-field defaults and ignoring unknown fields, so the file cannot express "this field became meaningful in version X", and destructive migration is unsafe. A real structural change would need a version and migration mechanism first.
Sixth, the capability boundary also includes: no client certificates or custom CAs; multipart Content-Type cannot be customised; responses over 20 MB are always truncated; history keeps 200 entries and no bodies. These are explicit trade-offs, not hidden defects.
One more easily missed boundary: history stores the original request, so variable placeholders never become plaintext secrets — but a token typed directly into a URL or header still lands in nanoapi.json as plaintext. The tool does no field-level encryption and makes no promise against local readers.
What these leftovers share is that each needs extra infrastructure: decompression needs an encoding library, auth replay needs a custom redirect policy, proxies need a settings UI, format compatibility needs adapters, and schema versioning needs a migration framework. None of them is a one-line change.
NanoAPI compresses into three sentences: rendering belongs to the system WebView2, network and files belong to the Go standard library, and the frontend stays zero-dependency native ESM. It never tries to replace Postman; it makes the most common debugging path solid — request construction, variables, cURL, upload/download, response inspection, single-file storage.
What decides quality is not the feature list but the unglamorous failure paths. A timeout must be attached to the right context; a specific redirect error must not be overwritten by a generic one; Windows connection errors and paths need their own handling; multipart boundaries must not be user-supplied; writes must be atomic and corruption must leave evidence; large responses must be bounded at read, render and validate.
Its leftovers are equally explicit: no br/deflate decoding, no auth replay across origins, implicit proxy inheritance from environment variables, a closed import format, and no schema version. Saying so plainly is worth more than pretending everything is supported. A sufficient tool should tell you where it is sufficient and where it is not.
None of these conclusions came from guessing at code: timeouts, redirects, paths, storage and import all have unit tests or reproducible mock servers, so breaking any one of them fails the tests first.