junegunn/fzf
HTTP server (--listen)
Purpose
--listen [ADDR] puts fzf into a control-plane mode where external programs can push actions over HTTP (or a Unix domain socket). This is what enables building rich pickers that update fzf's preview, prompt, query, or input list from outside the process.
File
| File | Purpose |
|---|---|
src/server.go |
The whole server: listener setup, request parsing, request routing. |
src/terminal.go |
Hosts the actionChannel that the server sends parsed action lists into, and the GET handler that returns the current state. |
Key abstractions
| Symbol | Description |
|---|---|
httpServer (in src/server.go) |
Wraps the API key, the action channel, and the GET handler. |
listenAddress |
Parsed [host:]port or *.sock path. IsLocal() returns true for localhost, 127.0.0.1, or any Unix socket. |
parseListenAddress(string) |
Permissive parser; defaults to localhost / random port. |
startHttpServer(addr, actionChannel, getHandler) |
Spawns the listener and the accept loop. Returns the net.Listener, the bound port, and an error. |
getRegex (regex) |
Matches GET / (with optional query string) requests. |
parseGetParams(query) |
Extracts limit and offset for paginated GETs. |
How it works
graph LR
CLIENT[curl / external program] -->|HTTP POST or GET| LISTENER[net.Listener]
LISTENER --> SERVER[httpServer.handleHttpRequest]
SERVER -->|GET /?limit=...&offset=...| HANDLER[getHandler in terminal.go]
SERVER -->|POST / body=action| PARSE[parseSingleActionList]
PARSE -->|actionChannel| TERM[Terminal action engine]
TERM -->|state changes| RENDERERListener setup
startHttpServer chooses between TCP and Unix sockets based on the address. For *.sock paths it removes a stale socket if one exists (after probing it with a net.Dial to confirm nothing is listening), creates the new socket, and chmods it 0600. For TCP, the address must be localhost (or 127.0.0.1) unless FZF_API_KEY is set, otherwise the call fails before binding.
Request parsing
The server intentionally does not import net/http. The comment in server.go documents the binary-size impact:
* No --listen: 2.8MB
* --listen with net/http: 5.7MB
* --listen w/o net/http: 3.3MBInstead, handleHttpRequest uses a bufio.Scanner with a custom split function to walk request lines, parses headers manually, validates Content-Length (capped at maxContentLength = 1 MiB) and X-API-Key, and reads the body up to the declared length. It supports two methods:
GET /(optionally with?limit=...&offset=...) — returns the current match list as JSON. Implemented bygetHandler, which lives interminal.goand snapshots the current merger.POST /— body is a single fzf action list (the same syntax as--bindaction arguments, e.g.change-prompt(picked> )+reload(ls)). The body is parsed viaparseSingleActionList, then pushed ontoactionChannel(with achannelTimeout = 2sso a stuck terminal returns 503 instead of blocking forever).
API key
If FZF_API_KEY is set, every request must include X-API-Key: <value>. The comparison uses subtle.ConstantTimeCompare to avoid timing leaks. For non-localhost binds the API key is mandatory; for localhost or Unix-socket binds it is optional.
Response shape
200 OKwithContent-Type: application/jsonfor successful GETs (the body is fzf's own JSON encoding of the visible items).200 OKempty for successful POSTs.400 Bad Requestfor malformed requests, invalid action syntax, or oversized bodies.401 Unauthorizedfor bad API keys.503 Service Unavailableif the action queue is full or the GET handler timed out.
Integration points
- Owned by
terminal.go: the server is started duringNewTerminalwhen--listenis set. The terminal feeds the server anactionChannelandgetHandlerclosure that snapshots the currentMerger. - Action engine: actions arriving over HTTP go through the same dispatch as keyboard-triggered ones. There is no special-case path for
--listen. - Cleanup: the listener is closed via
deferfrom terminal cleanup; the listener'sAcceptloop exits gracefully when it seesnet.ErrClosed.
Entry points for modification
- Adding a new HTTP method: branch in
handleHttpRequest. Keep the implementation tight — don't pull innet/http; the savings are part of fzf's identity. - Changing the GET response: edit
getHandlerinsrc/terminal.go. Be careful withparseGetParamsdefaults; consumers (notably the README examples) rely onlimit=100. - Action injection from internal code: push directly onto
actionChannel. This is the same channel HTTP requests target.
Example
# Terminal A
seq 100 | fzf --listen 8123
# Terminal B
curl -X POST -d 'change-prompt(custom> )+change-query(42)' localhost:8123
curl 'localhost:8123/?limit=5'The first curl mutates the running fzf's state; the second returns the first 5 items currently in the visible match list as JSON.
Built by Factory AutoWiki from public repository content. It is a generated preview for codebase exploration, not source-maintained documentation.