A Fastify-based HTTP server that bridges web requests to CLI commands using an AWS API Gateway-compatible request/response format. Supports REST APIs, WebSocket connections, convention-based Handlebars views, and static file serving.
aux4 aux4 pkger install aux4/api
aux4 api start
With a configuration file:
aux4 api start --configFile config.yaml
Stop the server:
aux4 api stop
config:
port: 8080
server:
timeout: 30000
maxConcurrency: 50
maxQueue: 200
trustProxy: true
media: ./media
limits:
bodySize: 1048576
files: 5
fileSize: 10485760
fieldSize: 1048576
parts: 10
security:
auth:
type: cookie
command: aux4 auth validate
cookie: auth_token
redirect: /auth/signin
rateLimit:
max: 100
timeWindow: 60000
helmet: true
allowedIPs:
- 127.0.0.1
cors:
origin: "*"
tls:
key: path/to/key.pem
cert: path/to/cert.pem
production: false
api:
"GET /contacts":
command: aux4 contacts list
"POST /contacts":
command: aux4 contacts create
redirect: /contacts
"DELETE /contacts/{id}":
command: aux4 contacts delete
redirect: /contacts
ws:
"/chat":
routes:
$connect: aux4 chat-connect
$disconnect: aux4 chat-disconnect
$default: aux4 chat-message
Routes are defined in config.api with the format "METHOD /path". Path parameters use {name} syntax.
| Syntax | Matches | Captured as | |--------|---------|-------------| | GET /users | Exact method + path | — | | GET /users/{id} | A single path segment (no /) | ${params.id} | | ANY /users/{id} | Any HTTP method for that path | ${params.id} | | GET /files/{path...} | The rest of the path, including slashes (greedy) | ${params.path} | | ANY /{path...} | Every method and every path (full catch-all) | ${params.path} |
ANY (or *) as the method to match every HTTP method (GET, POST, PUT, DELETE, PATCH, OPTIONS, and auto-HEAD) for the given path.... inside a path parameter ({path...}) to capture the remaining path segments, slashes and all. A plain {name} still matches exactly one segment. The captured value is available to the command as a normal path parameter (e.g. ${params.path} / .pathParameters.path in the stdin event).ANY, and a single-segment {name} route beats a greedy {path...} route. Among equally-specific routes, declaration order is preserved. This lets you declare GET /health alongside ANY /{path...} and have /health still reach its own command while everything else falls through to the catch-all.To mount a single command that serves every method on every path (e.g. a programmable mock), use:
config:
api:
"ANY /{path...}":
command: aux4 my-handler respond
public: true
The command receives the method via the stdin event's httpMethod (also requestContext.httpMethod) and the full request path via --params as {"path":"the/rest/of/the/path"} (i.e. ${params.path}, or .pathParameters.path in the event). The original path is also on the event as path and requestContext.path.
Request data is automatically injected as command variables:
| Variable | Description | Example | |----------|-------------|---------| | ${params.id} | Path parameters | From {id} in route | | ${query.search} | Query string parameters | From ?search=... | | ${body.name} | Request body fields | From form/JSON body | | ${headers.authorization} | Request headers | Any header | | ${cookies.token} | Request cookies | Any cookie | | ${principal.email} | Authenticated user info | From auth command |
Use value() for safe shell quoting: value(params.id), value(body).
The full AWS API Gateway-style event is also piped to the command via stdin for backward compatibility.
The server accepts a request body for any content-type and delivers it to the command — it never rejects a request at the content-type layer. This makes it a faithful HTTP -> CLI bridge, so a mounted command sees every request regardless of how the client framed it.
| Content-Type | Handling | |--------------|----------| | application/json | Parsed to an object; injected as ${body.field}. An empty body is accepted (no 400) and delivered as no body — real APIs accept this shape (e.g. an endpoint whose data rides in the query string). A non-empty body that is not valid JSON still returns 400. | | application/x-www-form-urlencoded | Parsed into fields; injected as ${body.field}. | | multipart/form-data | Files streamed to a temp dir; fields and file metadata injected as ${body.field}. | | text/plain | Delivered verbatim as the raw body. | | Any other type (e.g. multipart/related, application/octet-stream) or no Content-Type header | The raw body is captured as a UTF-8 string and delivered verbatim on the event as body (isBase64Encoded stays false). |
Note: the raw catch-all path captures the body as a UTF-8 string, so a truly binary payload sent under an unregistered content-type may be lossy. Text bodies (including multipart/related with JSON + text parts, as produced by aux4 curl --upload) are preserved exactly.
| Output | Behavior | |--------|----------| | JSON with statusCode | API Gateway response (status, headers, body) | | JSON without statusCode | 200 with JSON body (or rendered partial if views exist) | | Plain text | 200 with text body | | data:<mimetype>;base64,<data> | Binary response with auto Content-Type | | Command fails (non-zero exit) | 500 with generic error message |
"GET /endpoint":
command: aux4 my-command
public: true # skip authentication
timeout: 60000 # override default timeout
stream: true # enable SSE streaming
redirect: /other # redirect after success
setCookie: # set cookie from response
name: auth_token
field: token
clearCookie: auth_token # clear a cookie
rateLimit: # per-route rate limiting
max: 5
timeWindow: 60000
allowedIPs: # per-route IP allowlist
- 10.0.0.1
openapi: # OpenAPI operation overrides (see OpenAPI section)
summary: My endpoint
Routes without a command field can still use clearCookie and redirect (useful for logout):
"POST /auth/logout":
public: true
clearCookie: auth_token
redirect: /auth/signin
After a successful command, redirect executes the target route's command and returns its response. If the target has a matching partial template, it renders HTML.
Generate an OpenAPI 3.0.3 document from your config.api (plus any mounted components) with:
aux4 api openapi --configFile config.yaml # JSON to stdout
aux4 api openapi --configFile config.yaml --format yaml
Every "METHOD /path" route key becomes an OpenAPI path item and operation:
{name} path segments become required string path parameters.operationId and summary are derived from the route's command (e.g. aux4 auth signin → aux4_auth_signin)./contacts/{id} → contacts).config.components are merged under their mount paths, exactly as the running server resolves them.config.info (title, version, description) populates the OpenAPI info block. Defaults: aux4 API / 1.0.0.As a best effort, the command's execute[] (looked up in the app .aux4 beside the config file) is statically scanned:
${query.X} references → string query parameters (on GET/DELETE).requestBody properties (on POST/PUT/PATCH). Fields are found via ${body.X} and via aliases created with set:alias=json:${body}, e.g. object(data.firstName:firstName, ...).Inference is a heuristic and only sees fields the static scan can reach; everything else should be declared with the openapi: annotation below.
openapi: annotationAny route entry may carry an openapi: block that is overlaid onto the generated operation — the reliable escape hatch for anything inference cannot derive:
config:
api:
"GET /search":
command: aux4 search run
openapi:
summary: Search records
tags:
- search
parameters:
- name: q
in: query
required: true
schema:
type: string
responses:
"200":
description: Matching records
"400":
description: Bad query
Overlay rules:
summary, description, operationId, deprecated, tags — replace the generated value.parameters — merged by in+name (annotation wins, new params appended).requestBody — replaces the generated request body.responses — shallow-merged onto the generated 200.aux4 api handle runs one API Gateway REST v1 proxy event through the same routing engine as api start, but in-process — no HTTP server, no socket. It reads the event as JSON on stdin and writes an API Gateway proxy response as JSON to stdout:
echo '{ "httpMethod": "GET", "path": "/contacts", "headers": {}, "body": null, "isBase64Encoded": false, "requestContext": { "identity": { "sourceIp": "1.2.3.4" } } }' \
| aux4 api handle --configFile config.yaml
{
"statusCode": 200,
"headers": {
"content-type": "application/json"
},
"body": "[{\"id\":\"1\",\"name\":\"Alice\"}]",
"isBase64Encoded": false
}
This is the one-event-per-invocation entrypoint the AWS Lambda runtime calls when running a multi-route aux4/api app as a container image behind API Gateway. It reuses the same routing, authentication, rate-limiting, cookie, redirect, and response-shaping logic as the running server, so a single deployed function can serve every route in config.api.
httpMethod, path, flat headers, queryStringParameters / multiValueQueryStringParameters, body, isBase64Encoded, requestContext.identity.sourceIp. pathParameters are recomputed by matching path against the route patterns.statusCode produces that gateway response; plain JSON is wrapped as 200 application/json; data:/binary output is base64-encoded with isBase64Encoded: true; Set-Cookie is emitted (multiple cookies via multiValueHeaders).isBase64Encoded: true request body is decoded before routing.Note: streaming (stream: true, SSE), multipart uploads (multipart/form-data), and WebSocket routes require a live socket and are not supported through api handle — they are rejected with 501 / 415. Use aux4 api start for those transports.
Configure authentication in security.auth:
config:
security:
auth:
type: cookie # cookie | bearer | apiKey | both
command: aux4 auth validate
cookie: auth_token # cookie name (for cookie/both types)
redirect: /auth/signin # render this partial on 401
| Type | Description | |------|-------------| | cookie | Reads token from an httpOnly cookie | | bearer | Reads token from Authorization: Bearer <token> header | | apiKey | Static API key comparison (no command needed) | | both | Cookie first, bearer fallback (default) | | oauth | Full OAuth2/OIDC web-login flow with a signed session cookie |
security:
auth:
type: cookie
command: aux4 auth validate
cookie: auth_token
redirect: /auth/signin
The auth command receives --cookies and --headers and should return a JSON object with user info (the principal) on success, or exit with non-zero on failure:
{"email": "user@example.com"}
The principal is injected as --principal into route commands, accessible via ${principal.email}.
Auth validation results are cached in memory for 1 minute per token, avoiding repeated command execution for the same session. Configure the TTL:
security:
auth:
cacheTTL: 60000 # milliseconds (default: 60000)
Set cookies from command responses:
"POST /auth/signin":
command: aux4 auth signin
public: true
setCookie:
name: auth_token
field: token
redirect: /contacts
The command returns {"token": "UUID", "email": "user@example.com"}. The API extracts the token field and sets it as an httpOnly cookie. In production mode, the Secure flag is added.
security:
auth:
type: apiKey
apiKey: my-secret-key
header: X-API-Key
Uses timing-safe comparison. No principal is set for API key auth.
security:
auth:
type: bearer
command: aux4 auth validate
Reads from Authorization: Bearer <token> header.
type: oauth enables a full server-side OAuth2/OIDC login flow. The API handles the browser redirect dance, exchanges the authorization code through the aux4 oauth commands, and issues its own signed session cookie. Per-request authentication then verifies that session cookie in-process (no subprocess) and injects the principal into route commands. Works with any OAuth2/OIDC provider.
security:
auth:
type: oauth
session:
secret: "secret://session-secret" # HMAC secret for the session JWT
cookie: auth_token # session cookie name (default: auth_token)
ttl: 86400 # session lifetime in seconds (default: 86400)
redirectAfterLogin: / # where to send the user after a successful login
redirectOnError: /login # where to send the user on login failure / 401 (optional)
providers:
google:
clientId: "..."
clientSecret: "secret://google-client-secret"
redirectUri: https://app.example.com/auth/callback
# optional, for non-bundled providers:
authUrl: https://accounts.google.com/o/oauth2/v2/auth
tokenUrl: https://oauth2.googleapis.com/token
userinfoUrl: https://openidconnect.googleapis.com/v1/userinfo
scopes: openid,email,profile
map: { "id": "sub" } # optional userinfo->principal field map
When type: oauth is set, the API auto-wires three routes:
| Route | Behavior | |-------|----------| | GET /auth/signin?provider=<name> | Builds the provider authorize URL (PKCE S256), stashes {codeVerifier, state, provider} in a short-lived signed httpOnly cookie, and 302s to the provider. provider defaults to the sole configured provider when omitted. | | GET /auth/callback?code&state | Reads and clears the temp cookie, verifies state, exchanges the code for a principal, mints an HS256 session JWT (claims = principal + exp), sets it as the session cookie, and redirects to redirectAfterLogin. On any failure it redirects to redirectOnError. | | GET /auth/logout | Clears the session cookie and redirects (defaults to redirectOnError; override with ?redirect=/path). |
The PKCE state lives entirely in a signed, short-lived cookie — there is no server-side session store. The session cookie is an HS256 JWT signed and verified with session.secret using node's built-in crypto (no JWT library dependency). On every protected request the JWT signature and expiry are checked in-process, and the decoded claims are injected as --principal (accessible via ${principal.email}, ${principal.sub}, etc.). Missing, invalid, or expired session cookies return 401 (browsers are redirected to redirectOnError).
Cookies are httpOnly with SameSite=Lax, and gain the Secure flag in production mode. The provider's clientSecret and access tokens are never logged or stored in the session.
Requires the aux4/oauth package to be installed (it provides aux4 oauth authorize-url and aux4 oauth exchange).
Handlebars templates in the views/ directory are automatically registered as GET routes:
| File | Route | Layout | |------|-------|--------| | views/index.hbs | GET / | Yes | | views/about.hbs | GET /about | Yes | | views/users/{id}.hbs | GET /users/:id | Yes | | views/greet.p.hbs | GET /greet | No |
.hbs files render with the layout (views/layouts/main.hbs).p.hbs files render as partials (no layout wrapper)views/error.p.hbs is used for error responses (not registered as a route){id} segments in filenames/directories become path parameterslayouts/, partials/, and i18n/ directories are skippedWhen an API command returns JSON and a matching .p.hbs partial exists, the server renders it as HTML automatically. The convention maps command names to template paths:
| Command | Partial | |---------|---------| | aux4 contacts list | views/contacts/list.p.hbs | | aux4 contacts get | views/contacts/get.p.hbs | | aux4 auth signin | views/auth/signin.p.hbs |
The JSON response is available in the template as data:
{{#each data}}
<tr><td>{{firstName}}</td><td>{{phone}}</td></tr>
{{/each}}
Clients requesting Accept: application/json receive raw JSON instead.
Built-in helpers available in all templates:
| Helper | Usage | Description | |--------|-------|-------------| | eq | {{#if (eq mode "edit")}} | Equality comparison | | ne | {{#if (ne status "draft")}} | Not-equal comparison | | fileSize | {{fileSize size}} | Human-readable file size (e.g., 38.8 KB) |
Add custom helpers by creating a helpers/ directory. Each .js file becomes a helper named after the filename:
helpers/
uppercase.js → {{uppercase name}}
formatDate.js → {{formatDate createdAt}}
Each file exports a single function:
// helpers/uppercase.js
module.exports = function(str) {
return (str || "").toUpperCase();
};
When views/index.hbs exists, unmatched GET requests (non-API, non-static) serve the index page. This supports client-side URL routing with hx-push-url.
Configure redirects for specific HTTP status codes:
config:
security:
auth:
redirect: /auth/signin # shorthand for 401 redirect
errorRedirects:
"404": /errors/not-found # renders views/errors/not-found.p.hbs
"500": /errors/server # renders views/errors/server.p.hbs
Error templates are checked in order:
views/401.p.hbs, views/404.p.hbs, etc.views/error.p.hbs (receives statusCode, message, error)Accept: application/jsonCommand failures (non-zero exit) return a generic 500 Internal Server Error message. Internal details (stderr, stack traces) are never exposed to clients.
Files in the static/ directory are served at /static/:
static/css/app.css → http://localhost:8080/static/css/app.css
Configure a directory for user-uploaded files:
config:
server:
media: ./media
Files are served at /media/:
media/photo.jpg → http://localhost:8080/media/photo.jpg
Enable with --production true in config:
config:
production: true
Production mode enables:
Secure flag on cookies (requires HTTPS)Development mode (default):
Secure flag (cookies work on HTTP localhost)Components are reusable, mountable web modules. Each component is a self-contained package with commands, routes, and views that can be plugged into any aux4/api application.
A component is an aux4 package with views and a config:
components/aux4/contacts/
.aux4 # commands
config.yaml # routes (relative paths)
views/
list.p.hbs # partials
get.p.hbs
new.p.hbs
edit.p.hbs
The component's config.yaml defines routes relative to its root:
api:
"GET /":
command: aux4 contacts list
"GET /{id}":
command: aux4 contacts get
"POST /":
command: aux4 contacts create
redirect: /
The host app mounts components at a path in its config.yaml:
config:
components:
/contacts:
package: aux4/contacts
/chat:
package: aux4/chat
config:
maxMessages: 100
Routes are automatically prefixed with the mount path. GET / in the contacts component becomes GET /contacts in the host app. Redirects are prefixed too.
Install all components listed in config:
aux4 api init
Or install individually:
aux4 api package install aux4/contacts
The init command:
./components/<scope>/<name>/.aux4aux4 api package list # list installed components
aux4 api package uninstall aux4/contacts # remove a component
Component partials receive {{apiPath}} and {{basePath}} for building links:
{{apiPath}} — the API route prefix (e.g., /api/contacts) for hx-get, hx-post, etc.{{basePath}} — the page route prefix (e.g., /contacts) for hx-push-url and hrefInside {{#each}} loops, use {{../apiPath}} and {{../basePath}}.
<a href="{{../basePath}}/{{id}}" hx-get="{{../apiPath}}/{{id}}" hx-target="#app">
{{firstName}} {{lastName}}
</a>
Use <aux4-component> to embed components in any page. The custom element is auto-injected when components are configured.
<!-- Load the full component with URL routing -->
<aux4-component src="/contacts" route="true"></aux4-component>
<!-- Load a specific view -->
<aux4-component src="/contacts/card" id="abc123"></aux4-component>
<!-- Multiple components on one page -->
<aux4-component src="/contacts" route="true"></aux4-component>
<aux4-component src="/chat/messages" room="general"></aux4-component>
Attributes:
src — the component path (automatically prefixed with /api/)route="true" — use the current page URL path instead of the static src (for SPA-style routing)The component fetches HTML from the API, renders it, and processes HTMX attributes automatically. No JavaScript needed per component.
config.components, loads each component's config.yaml, prefixes routes, and merges them into the APIcomponents/<scope>/<name>/views/ for partial rendering<aux4-component> is auto-injected as a <script> tag before </body> in all HTML pagessecurity.auth — components receive ${principal} automatically<aux4-component> fetches its content from the API and renders it/aux4/batch requestWhen multiple <aux4-component> elements exist on a page, their requests are batched into a single POST to /aux4/batch (10ms debounce). This reduces the number of HTTP requests from N to 1.
The batch endpoint:
/api/ paths allowedComponents should use the api:module profile to avoid command name collisions:
{
"name": "api:module",
"commands": [{
"name": "files",
"execute": ["profile:api:module:files"]
}]
}
Commands are accessed via aux4 api module files list. The api:module profile is provided by aux4/api.
Commands can return binary data via data URIs. By default, files are downloaded (not opened in browser):
data:application/pdf;filename=report.pdf;base64,JVBERi0xLjQ...
To open in browser instead of downloading, add inline:
data:image/png;inline;filename=photo.png;base64,iVBORw0KGgo...
WebSocket routes are defined in config.ws. Each path maps lifecycle events and custom actions to commands.
$connect — fired when a client connects$disconnect — fired when a client disconnects$default — fired when no matching action is found<action> — custom action matched from { "action": "<action>" } in the message bodyPOST /@connections/:connectionId — send a message to a specific connectionDELETE /@connections/:connectionId — disconnect a specific connectionRoutes with stream: true use Server-Sent Events:
config:
api:
"GET /stream":
command: aux4 my-stream
stream: true
Each stdout line is sent as data: <line>\n\n. On exit, event: done is sent.
Global and per-route rate limiting with sliding window by client IP:
config:
security:
rateLimit:
max: 100
timeWindow: 60000
api:
"POST /login":
command: aux4 login
rateLimit:
max: 5
timeWindow: 60000
Headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.
config:
security:
allowedIPs:
- 127.0.0.1
- 192.168.1.0/24
api:
"GET /admin":
command: aux4 admin
allowedIPs:
- 10.0.0.1
Per-route allowedIPs replaces the global list. Behind a reverse proxy, set server.trustProxy: true.
config:
tls:
key: path/to/key.pem
cert: path/to/cert.pem
AUX4_API_PORT — override the port (takes precedence over config file)