jolly-http
v0.3.1
Published
Workflow-as-code HTTP tool: simplicity of httpie, speed of xh, sequential + parallel + watched modes from one .mjs file
Maintainers
Readme
jolly-http
Workflow-as-code HTTP tool built on jolly-coop.
Simplicity of httpie, speed of xh, plus a thing neither has: the same .mjs file is your debug script, your test, and your load scenario.
Install
npm install -g jolly-httpRequires Node.js ≥ 22. Also runs on Bun and Deno via the published npm package.
Three modes, one mental model
1. Ad-hoc (httpie-shaped)
jolly-http GET https://api.github.com/users/arijit-gogoi
jolly-http POST https://httpbin.org/post name=ari age:=30 Auth:tok
jolly-http PUT https://api/users/1 --json '{"name":"ari"}'Body shorthand:
| Form | Effect |
|-------------------|-----------------------------------------------|
| key=value | JSON string field |
| key:=value | JSON literal (number, bool, null, array, obj) |
| Header:value | Request header |
| key==value | Query parameter |
| key@path | File upload (form field) |
2. Workflow file (sequential)
jolly-http run flow.mjs// flow.mjs
import { request, assert, env } from "jolly-http"
export default async function (vu, signal) {
const login = await request.POST(`${env.API}/login`, {
json: { user: vu.id },
signal,
timeout: "5s",
})
assert(login.status === 200, "login failed")
const { token } = await login.json()
const me = await request.GET(`${env.API}/me`, {
headers: { authorization: `Bearer ${token}` },
signal,
})
assert(me.status === 200)
return { ok: true }
}3. Same workflow, under load
jolly-http run flow.mjs -c 50 -d 30s --out samples.ndjsonThe same file. No rewriting, no separate load-test DSL. SIGINT propagates through the in-process load runner; per-request samples go to NDJSON.
Workflow API (frozen — permanent public surface)
default export: (vu: VuContext, signal: AbortSignal) => Promise<any>
vu: { id: number, iteration: number, env: Readonly<Record<string,string>> }
signal: AbortSignal (from the scope — pass to every fetch)
import { request, assert, env, sleep } from "jolly-http"
request.GET / POST / PUT / PATCH / DELETE / HEAD / OPTIONS (url, init) → Response
init: {
headers?: Record<string,string>
json?: unknown // sets body to JSON + content-type
form?: Record<string,string> // url-encoded
body?: string | Uint8Array
query?: Record<string, string | number | boolean>
timeout?: string | number // "5s", 1000
signal?: AbortSignal // composed with scope signal
cookies?: boolean // false → opt this request out of the jar
}
assert(cond, msg?) // throws AssertionError when falsy
env.FOO // --env flags + process.env + .env files
sleep("200ms" | 200) // signal-awareCommon options
--header, -H <k:v> Add a header (repeatable)
--json <str> Body as JSON string (overrides shorthand)
--form Send x-www-form-urlencoded
--timeout <dur> Per-request timeout ("500ms", "30s", "2m")
--user-agent <str> Override User-Agent
--quiet, -q Suppress per-request output
--out <path> Append NDJSON samples to path
--env KEY=VAL Set workflow env var (repeatable)
--env-file <path> Load env vars from a file (repeatable; later wins)
--no-env-file Skip auto-loading ./.env
--require-env <path> Fail-fast if any key from <path> is unset/empty
--cookies <dir> Persist cookies as <dir>/vu-N.json (jar is always on)
--har <dir> Record HAR as <dir>/vu-N.har
--har-replay <path> Replay responses from a recorded HAR (file or dir)
--insecure, -k (no-op; see "Self-signed certs" below)
Load mode:
-c, --concurrency <n> Virtual users
-d, --duration <dur> Total duration ("30s", "2m")
--rps <n> Target requests/sec
--warmup <dur> Exclude first N from stats
Watch mode (run only):
--watch Rerun workflow on file change
--watch-mode <mode> eager (cancel mid-flight, default) | lazy (queue)Exit codes: 0 success · 1 fatal or assertion failure · 2 bad args · 130 SIGINT.
NDJSON schema
One line per HTTP request emitted by request.*:
{"ok":true,"t":0.142,"vu":7,"iteration":0,"method":"POST","url":"https://api/login","status":200,"duration_ms":38.2,"size":312,"ts":"2026-04-18T03:14:15.926Z"}
{"ok":false,"t":0.191,"vu":3,"iteration":1,"method":"GET","url":"https://api/me","duration_ms":501.1,"error":"AbortError","message":"request timed out after 500ms","ts":"2026-04-18T03:14:16.427Z"}Same shape in single-run and load mode — any tool that reads one reads both.
Cookies
Cookies are on by default. Each workflow run gets a per-VU jar that auto-includes cookies on outbound requests and absorbs Set-Cookie headers from responses. A login → me-call workflow Just Works without configuration.
await request.GET(`${env.API}/login`) // jar absorbs Set-Cookie
await request.GET(`${env.API}/me`) // cookie sent automatically
await request.GET(`${env.API}/public`, { cookies: false }) // opt out per call--cookies <dir> adds disk persistence (load on start, save on exit, including Ctrl-C):
jolly-http run flow.mjs --cookies ./jar
ls jar/ # → vu-0.json (per-VU files in load mode: vu-0.json, vu-1.json, …)The jar implements RFC 6265 (Domain/Path/Secure/HttpOnly/Expires/Max-Age). It does not handle the public-suffix list or third-party cookie blocking — those are browser concerns.
Environment files
./.env is auto-loaded from cwd if present. Read values via the frozen env import:
const res = await request.GET(`${env.API_BASE}/users`, {
headers: { authorization: `Bearer ${env.API_TOKEN}` },
signal,
})Precedence (highest wins)
--env KEY=VAL > process.env > --env-file files (later > earlier) > auto ./.envSame as dotenv, Next.js, Vite, every modern framework.
jolly-http run flow.mjs --env-file .env.staging # one file, no auto-load
jolly-http run flow.mjs --env-file .env --env-file .env.local # later overrides earlier
jolly-http run flow.mjs --no-env-file # skip ./.envExplicit --env-file disables auto-loading ./.env.
Validation with --require-env
Pair a committed .env.example (placeholder values, in git) with a gitignored .env:
# .env.example # .env (gitignored)
API_BASE= API_BASE=https://api.example.com
API_TOKEN= API_TOKEN=tok-xyz
jolly-http run flow.mjs --env-file .env --require-env .env.exampleIf any key listed in .env.example is unset or empty after the merge, the run fails fast before the workflow's first request, listing every missing key:
missing required env vars from .env.example:
- API_TOKEN
set them in .env, export them, or pass --env KEY=VALFormat
Standard dotenv dialect:
# Comment
KEY=value
QUOTED="value with spaces"
SINGLE='no $interpolation here'
INTERPOLATED=${KEY}/path
MULTILINE="line1
line2"
export FOO=bar # bash-compat prefix; "export " is stripped${VAR} interpolation only resolves against keys defined earlier in the same file. Bare $VAR is literal — no eating of $1, $@, currency strings.
Watch mode
Rerun on workflow file change:
jolly-http run flow.mjs --watch # eager (default)
jolly-http run flow.mjs --watch --watch-mode lazy # queue, finish current first- eager — cancel in-flight requests and start a new run. Matches
nodemon/vitest. Fast feedback. - lazy — queue file changes; current run finishes naturally, then reload.
Watch composes with load mode: jolly-http run flow.mjs --watch -c 50 -d 30s reruns the load on every change. Ctrl-C exits 130.
HAR recording
Capture full request/response pairs for inspection in DevTools or HAR viewers:
jolly-http run flow.mjs --har ./har-out
ls har-out/ # → vu-0.har (per-VU in load: vu-0.har, vu-1.har, …)HAR 1.2 — opens in Chrome DevTools (Network → Import HAR), Firefox, or any HAR viewer. Bodies truncated to 64 KB.
Replay
Re-run a workflow against canned responses from a recorded HAR — useful for offline debugging, deterministic CI, or sharing fixtures:
jolly-http run flow.mjs --har ./fixtures # record once
jolly-http run flow.mjs --har-replay ./fixtures # replay (per-VU dir)
jolly-http run flow.mjs --har-replay ./fixtures/vu-0.har # replay (single shared file)Path auto-detects: *.har → shared file across VUs; directory → <dir>/vu-N.har per VU.
Matching is strict: (method, full URL with query, request body) must match an entry exactly. First-match-wins, no consume — workflows that loop over the same endpoint replay correctly. Misses throw HarReplayMissError (with .method, .url); CLI exits 1.
--har and --har-replay cannot both be set.
Caveats:
- Form bodies (
URLSearchParams) match by exact string — field order matters. Usejson:for canonical ordering. - Headers are not part of the match (cookie drift between record/replay is tolerated).
- Multipart bodies have non-deterministic boundaries; not reliably replayable.
Self-signed certs / internal CAs
To skip TLS validation, use your runtime's built-in flag — cross-runtime, zero-dep, properly scoped:
NODE_TLS_REJECT_UNAUTHORIZED=0 jolly-http run flow.mjs # Node
bun --tls-no-verify run jolly-http run flow.mjs # Bun
deno run --unsafely-ignore-certificate-errors jolly-http run flow.mjs # DenoNode prints a stderr warning — that's intentional UX. The proper fix for internal CAs is your system trust store; the runtime flags are an escape hatch for CI / dev iteration.
The
--insecure, -kCLI flag is a no-op and may be removed in a future major.
Why .mjs?
Workflows are real JavaScript modules, not a DSL:
- No parser divergence — if it runs in Node, it works.
- Editor support is free — ESLint, Prettier, TypeScript JSDoc, go-to-definition all work.
- Helpers compose. Write a retry wrapper, import it in three workflows.
- Load and debug are the same file. No "production config" vs. "test script" drift.
Philosophy
Small surface, permanent contract. The workflow function signature ((vu, signal)) and the four runtime imports (request, assert, env, sleep) are the API. Everything else is implementation detail and can change.
Hurl and httpyac drowned in feature creep. jolly-http picks a different tradeoff: power comes from the .mjs file being real JavaScript, not from a thousand config options.
License
MIT — see LICENSE.
