README
¶
@oarkflow/fh-secure-fetch
TinyGo/WASM Fetch-compatible client for github.com/oarkflow/fh/mw/securetransport.
Build from the repository root (requires TinyGo and
Binaryen's wasm-opt, e.g. brew install tinygo binaryen):
make wasm
TinyGo's -no-debug -opt=z build (plus its own internal wasm-opt pass) is
roughly 90% smaller than the equivalent stock go build output for this
binary -- ~600KB instead of ~5.4MB -- because it doesn't link the parts of
the Go runtime this client never uses. cmd/securefetch/wasm_exec.js is a
patched copy of TinyGo's own runtime glue; see that file's header comment
before regenerating it from a newer TinyGo release, and never substitute
TinyGo's stock targets/wasm_exec.js or a plain Go wasm_exec.js for it --
neither will instantiate this specific binary.
TinyGo also pins a maximum supported Go version below what this module's
go.mod requires (0.41.x supports Go 1.19-1.26; go.mod requires >= 1.26.5), so
go on PATH usually needs to be a matching 1.26.x for this build specifically
-- not whatever newer toolchain go build/go test use elsewhere in this
repo. If tinygo build reports "requires go version 1.19 through 1.26",
install one side by side and put it first on PATH:
go install golang.org/dl/go1.26.5@latest && go1.26.5 download
PATH="$(go1.26.5 env GOROOT)/bin:$PATH" make wasm
Import the generated ES module:
import { createSecureFetch } from "/wasm/index.js";
const secure = await createSecureFetch({
baseURL: location.origin,
pinnedServerKey: "<base64url-server-public-key>",
pinnedServerKeyID: "api-transport-2026-01",
responseSigningPublicKey: "<base64url-ed25519-public-key>",
responseSigningKeyID: "api-response-2026-01",
requireResponseSignature: true,
requireEmbeddedTrust: true,
wasmURL: "/wasm/securefetch.wasm",
wasmExecURL: "/wasm/wasm_exec.js",
wasmIntegrity: "sha256-<from-asset-manifest>",
wasmExecIntegrity: "sha256-<from-asset-manifest>",
requireAssetIntegrity: true,
});
const response = await secure.fetch("/api/profile");
console.log(await response.json());
The package requires WebAssembly, WebCrypto, IndexedDB, a browser Window, and a secure context. Loopback HTTP is accepted for local development. Every non-loopback execution requires a complete origin/X25519/Ed25519 trust bundle embedded by the Makefile; runtime configuration cannot substitute those pins. When requireResponseSignature is enabled it generates signature nonces inside WASM, verifies signed ciphertext before decryption, and fails closed on downgrade or tampering. It intentionally rejects redirects, no-cors/navigation requests, non-canonical base URLs and prefixes, and destinations outside baseURL.
make wasm emits dist/asset-manifest.json with SHA-256 SRI values for the WASM binary and runtime.
See docs/secure-wasm-transport.md for the protocol, server setup, threat model, and production checklist.
Axios-style client
createSecureFetch/secure.fetch above is the low-level, Fetch-compatible
primitive. createClient/createSecureClient (also exported from
/wasm/index.js) wrap any Fetch-compatible function -- native fetch, or
the secure transport above -- with axios-style ergonomics: instances with
shared defaults, request/response interceptors, automatic JSON
transforms, query-param serialization, timeout, retries, and a unified
FHError (with .response.status/.response.data) instead of manually
checking response.ok.
import { createSecureClient, isFHError } from "/wasm/index.js";
// Wraps createSecureFetch(config) internally; or pass an existing
// SecureFetchHandle if you already called createSecureFetch yourself.
const api = await createSecureClient({
baseURL: location.origin,
// ...same config as createSecureFetch above...
});
api.interceptors.request.use((config) => {
console.log(`-> ${config.method} ${config.url}`);
return config;
});
try {
const { data } = await api.get("/api/profile");
console.log(data);
await api.post("/api/echo", { hello: "world" }, { retries: 2, timeout: 5000 });
} catch (error) {
if (isFHError(error)) console.error(error.code, error.response?.status, error.response?.data);
}
// Plain fetch works the same way, without any secure transport:
import { createClient } from "/wasm/index.js";
const plain = createClient(fetch, { baseURL: "https://api.example.com" });
onUploadProgress/onDownloadProgress callbacks are supported for shape
parity with axios, but every event they emit is a synthetic 0%/100%
start/end pair (event.synthetic === true) -- the underlying transport
buffers whole request/response bodies rather than streaming them (see
docs/secure-wasm-transport.md), so true incremental progress isn't
available yet.