Router Plugins
A plugin that declares the Router type answers HTTP requests itself, so it can serve a custom report page, a chart’s data or a small API next to Zhang’s own. Like every plugin it is a WebAssembly module built with Extism. Router plugins can read the ledger but not change it.
Use a router plugin
Section titled “Use a router plugin”Enable plugins and declare the router plugin like any other, as Using Plugins describes:
option "features.plugin" "true"
plugin "plugins/report.wasm"The Settings page lists it with an Open link to its pages. The rest of this page is for plugin authors: how requests reach the plugin and what it answers.
A router plugin serves /api/plugins/{name} and every path below it, for any HTTP method. {name} is the name the plugin’s name export returns, the one the Settings page and GET /api/plugins list. The Settings page links to the route of each router plugin.
/api/plugins/reportand/api/plugins/report/reach the plugin with the path/./api/plugins/report/by-month?year=2024reaches it with the path/by-monthand the query{"year": ["2024"]}.
The routes sit behind the same authentication as the rest of the API: with sign-in enabled, a request needs a session (or the Basic header a script sends), whatever its method. When two router plugins have the same name, the first one declared serves the route and Zhang logs a warning.
The router export
Section titled “The router export”The plugin exports a function named router. Its input is the request as JSON:
{ "method": "GET", "path": "/by-month", "query": {"year": ["2024"]}, "headers": {"accept": "text/html"}, "body": "", "body_encoding": "utf8"}pathis the part below the plugin’s route, with its percent-encoding as sent.querymaps each key to all its values, in order.- Header names are lower case, and the values of a repeated header are joined with
,. The credential headersauthorization,proxy-authorizationandcookie, which carry your session, are never passed to a plugin. body_encodingisutf8when the body is valid UTF-8, andbase64otherwise.
Its output is the response as JSON. Every field is optional:
{ "status": 200, "headers": {"content-type": "text/html; charset=utf-8"}, "body": "<h1>Monthly report</h1>", "body_encoding": "utf8"}statusdefaults to200.- Without a
content-typeheader the response istext/plain; charset=utf-8. Any content type works, so a plugin can return JSON, an HTML page or, with"body_encoding": "base64", an image. - Zhang sets
content-length,transfer-encodingandconnectionitself and ignores them in the plugin’s headers.
Reading the ledger: zhang_query
Section titled “Reading the ledger: zhang_query”A router plugin reads the ledger through host functions in the extism:host/user namespace. Each one returns JSON, either {"Ok": value} or {"Err": {"kind": "...", "message": "..."}}, and reports problems as values instead of failing the plugin.
zhang_query(bql)runs a read-only query and returns whatPOST /api/queryreturns indata:{"columns": [{"name", "type"}], "rows": [[...]]}. It has the same time and result size limits. A query that fails gives the kindquery, withmessage,lineandcolumn.zhang_ledger_info()returns{"title": "...", "operating_currency": "CNY", "timezone": "Asia/Shanghai"}, with atitleofnullwhen the ledger sets notitleoption.
In Rust with the plain Extism PDK (the Rust SDK wraps this as router::query):
use extism_pdk::*;
#[host_fn]extern "ExtismHost" { fn zhang_query(bql: String) -> String;}
#[plugin_fn]pub fn router(request: String) -> FnResult<String> { let rows = unsafe { zhang_query("SELECT account, sum(position) GROUP BY account".to_owned())? }; Ok(serde_json::json!({ "headers": {"content-type": "application/json"}, "body": rows, }) .to_string())}These functions answer only while router runs. A plugin that is also a Processor or Mapper still loads, and there they return the kind unavailable.
zhang_emit_error works in a router too, but a request has no error list to add to: Zhang only logs what the plugin reports there. To tell the caller about a problem, return it in the response.
Isolation and errors
Section titled “Isolation and errors”Every request runs in a fresh instance of the plugin, so nothing is kept between requests. The plugin gets the same config, allowed_hosts and timeout as its processor: a request running longer than 60 seconds, or the timeout meta of its plugin directive, is stopped. It cannot read files, whatever its allowed_paths: zhang_read_file and zhang_list_dir answer denied in a router. While it runs the ledger does not reload.
When the plugin cannot answer, Zhang responds with JSON {"message": "..."} and logs the details:
| Status | Meaning |
|---|---|
| 404 | No router plugin has that name. |
| 501 | The plugin declares Router but does not export router. |
| 502 | The plugin returned something that is not a valid response. |
| 504 | The plugin ran longer than its timeout. |
| 500 | The plugin trapped, returned an error or could not be loaded. |
