Proxy Hooks
Section titled “Proxy Hooks”Hooks let you run your own JavaScript on inbound traffic to any of your container services — before it reaches the service. Use them for logging, auth gates, header transforms, payload scans, or “inspect-and-forward” patterns. Same cost, same deploy surface, same SDK as any other hoody-exec script.
Hooks are a tenant-owned MITM layer. Your hook script runs inside your hoody-exec (not the proxy), sees the real client IP, and can do anything a regular hoody-exec script can.
When would I use a hook?
Section titled “When would I use a hook?”- Audit log — record every login attempt with user-agent + outcome, without modifying the login service.
- Rate limit — reject requests above a threshold before they hit an expensive backend.
- Header transform — strip sensitive headers from upstream responses, or add CSP headers uniformly.
- Short-circuit — return a cached or synthetic response without touching the real service.
- Body scan — reject uploads that fail antivirus before they land on disk.
- Traffic mirroring — fan out a copy of the request to a secondary analytics backend.
How it works
Section titled “How it works”Client ─► Hoody Proxy ──(match?)──► YES ──► your hoody-exec ──► your-hook-script.js │ ├─ inspect / transform / short-circuit │ └─ optional: forward to real upstream (container-ip:service-port)- You add an entry to your container’s proxy permissions file.
- The entry says: “for service X, when the request matches these predicates, route it through this script in my hoody-exec.”
- On every matching request, the proxy dispatches to your hoody-exec with the original URL preserved. Your script runs, sees the request, and decides.
- If you want to pass through to the real upstream, your script makes an HTTP call to
metadata.hook.upstream.host:port(the authoritative address, pinned by the proxy).
The client IP is preserved end-to-end via TPROXY — your hook script can see req.socket.remoteAddress just like any other script.
Minimal example: login audit
Section titled “Minimal example: login audit”1. Add a hook in your proxy permissions
Section titled “1. Add a hook in your proxy permissions”{ "project": "<project-id>", "container": "<container-id>", "groups": {}, "permissions": {}, "default": "allow", "hooks": { "terminal": [ { "match": { "method": ["POST"], "path": "/api/login*" }, "script": { "path": "/login-audit" }, "timeout": 500 } ] }}2. Deploy the hook script
Section titled “2. Deploy the hook script”module.exports = async function (req, res, metadata, shared) { // Regular requests (no hook) have metadata.hook === undefined. if (!metadata.hook) { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ mode: 'regular', url: req.url })); return; }
const { auditId, origMethod, origPath } = metadata.hook; const clientIp = req.headers['x-real-ip'] ?? req.socket.remoteAddress;
// Log the attempt. console.log(JSON.stringify({ auditId, clientIp, method: origMethod, path: origPath, ua: req.headers['user-agent'] ?? null, }));
// Forward to the real login backend. `metadata.hook.forward()` handles // URL assembly, RFC 7230 hop-by-hop stripping, multi-value Set-Cookie, // byte-transparent compression passthrough, client-abort propagation, // and sanitized 502 on upstream unavailability. await metadata.hook.forward(req, res);};Helper methods — metadata.hook.forward() / .fetchUpstream() / .pipeResponse()
Section titled “Helper methods — metadata.hook.forward() / .fetchUpstream() / .pipeResponse()”When a hook dispatch is active, metadata.hook carries three non-enumerable helper methods that eliminate ~25 lines of hand-rolled fetch-and-pipe boilerplate.
forward(req, res, overrides?) → Promise<void>
Section titled “forward(req, res, overrides?) → Promise<void>”One-shot passthrough. Reads req, sends to the authoritative upstream, streams the response back into res. Handles:
- URL assembly from
metadata.hook.upstream.{host,port}+origPath+ client query string. Byte-preserving (nonew URL()canonicalization). - Request-side hop-by-hop stripping (RFC 7230 §6.1 base set +
Connection: <token>extension). Client’sHostheader is preserved by default (override viaoverrides.headers.host). - Body source: buffered
req.rawBodywhen present, elseReadable.toWeb(req)when// @rawBodyis set.content-lengthrules enforced. - Response-side hop-by-hop stripping and multi-value
Set-Cookiepreservation viagetSetCookie(). - Compression: always
Bun.fetch({ decompress: false })— upstream bytes forward verbatim withcontent-encodingintact. - Client-abort propagation: guarded wiring on
req.close/req.aborted/res.closeso SSE/long-poll disconnects abort the upstream fetch. - Error translation:
network/abort/timeout→ sanitized 502 withx-hoody-hook-auditheader. Programming errors (invalid-override/no-body/body-consumed/duplex-unsupported/bytes-already-sent) rethrow so the script author sees them as real bugs.
// Transparent passthrough with loggingmodule.exports = async function (req, res, metadata) { if (!metadata.hook) { res.writeHead(404); res.end(); return; } console.log('hook', metadata.hook.auditId, metadata.hook.origMethod, metadata.hook.origPath); await metadata.hook.forward(req, res);};fetchUpstream(req, overrides?) → Promise<Response>
Section titled “fetchUpstream(req, overrides?) → Promise<Response>”Non-consuming fetch for inspect-then-forward. Returns a standard Fetch API Response; caller inspects/mutates/pipes. Does NOT write to res.
// Auth gate — inspect upstream, rewrite response on rejectmodule.exports = async function (req, res, metadata) { if (!metadata.hook) { res.writeHead(404); res.end(); return; } try { const up = await metadata.hook.fetchUpstream(req); if (up.status === 401) { res.writeHead(401, { 'content-type': 'text/plain' }); res.end('upstream rejected token'); return; } res.setHeader('x-hoody-hook-audit', metadata.hook.auditId); await metadata.hook.pipeResponse(up, res, { method: req.method }); } catch (e) { if (e instanceof metadata.hook.HookUpstreamError) { res.writeHead(502); res.end('upstream unavailable'); } else throw e; }};pipeResponse(upstream, res, { method? }?) → Promise<void>
Section titled “pipeResponse(upstream, res, { method? }?) → Promise<void>”Piping half of forward() exposed for inspect-then-forward. Handles status, response-side hop-by-hop, multi-value Set-Cookie, transfer-encoding/content-length reconciliation (RFC 7230 §3.3.3), HEAD/204/304 no-body (pass method: 'HEAD' for HEAD suppression). Client-close during streaming is handled as silent cancellation. Mid-stream upstream errors throw HookUpstreamError('stream-aborted').
Overrides
Section titled “Overrides”interface HookUpstreamOverrides { method?: string; // RFC 7230 token; wire case preserved pathAndQuery?: string; // byte-preserving; rejects # / .. / \ / whitespace / control / absolute-form host?: string; // IPv4 / DNS label; leading-zero octets rejected (octal-parse SSRF guard) port?: number; // 1..65535 headers?: Record<string, string | string[] | null>; // `null` deletes; validates name + value body?: BodyInit | null; // `null` drops body signal?: AbortSignal; // merged with dispatcher abort + timeoutMs timeoutMs?: number; // 1..86_400_000 (24h) onUpstreamError?: (err: HookUpstreamError) => { status; headers?; body?; }; // forward() only}HookUpstreamError
Section titled “HookUpstreamError”User scripts run in the host realm via new Function(), so HookUpstreamError is NOT reachable as a free identifier. Use metadata.hook.HookUpstreamError for instanceof branching, or rely on the realm-independent surface: err.name === 'HookUpstreamError' + err.kind.
kind | When it fires |
|---|---|
network | Upstream TCP error (ECONNREFUSED, reset, DNS) |
abort | Client-abort propagated via dispatcher signal or overrides.signal |
timeout | overrides.timeoutMs fired (Bun TimeoutError properly classified) |
invalid-override | Bad host / port / pathAndQuery / method / headers / timeoutMs / signal / onUpstreamError |
bytes-already-sent | pipeResponse called with res.headersSent already true |
stream-aborted | Upstream read failed mid-stream (while client still connected) |
no-body | Non-@rawBody script consumed req without preserving rawBody, or pre.js drained the @rawBody stream |
body-consumed | fetchUpstream called twice on a streamed @rawBody without overrides.body |
duplex-unsupported | Runtime rejected duplex: 'half' (Bun < 1.3) |
Matrix entry reference
Section titled “Matrix entry reference”hooks.<service> — per-service array (max 8 per service, 32 per file)
Section titled “hooks.<service> — per-service array (max 8 per service, 32 per file)”{ "hooks": { "terminal": [ { ... }, { ... } ], "files": [ { ... } ], "exec": [ { ... } ] }}Allowed services are the tenant-reachable ones your hoody-kit exposes via SNI — in current builds that’s: terminal, files, notes, run, curl, watch, cron, pipe, sqlite, browser, notifications, tunnel, daemon, code, display, exec. Custom hostname aliases resolve to one of these services at the proxy edge; they are not a separate dispatchable surface. Reject-listed: logs, proxy, workspaces (internal infrastructure — the API refuses to persist hooks for them, and the proxy additionally refuses to dispatch them even if an on-disk matrix file is tampered to include them).
match — predicate (AND-joined)
Section titled “match — predicate (AND-joined)”{ "match": { "method": ["POST", "PUT"], // or "*" for any-except-OPTIONS; single string also OK "path": "/api/*", // glob: `*` matches one segment; trailing `*` matches any suffix "headers": { "X-Tenant": "alice" } // optional: all keys must match exactly (keys case-insensitive) }}methoddefaults to"*"."*"does NOT matchOPTIONS— CORS preflight is skipped by default. IncludeOPTIONSexplicitly if you want to hook preflights.pathdefaults to"*"(any).- Requests with
Upgrade: websocketare never hook-routed — WebSocket and hooks are mutually exclusive. - Rules evaluate in order. First match wins.
script — target (one of your hoody-exec scripts)
Section titled “script — target (one of your hoody-exec scripts)”{ "script": { "subdomain": "myapp", // optional, LOWERCASE alphanum 1-64 chars: /^[a-z0-9]{1,64}$/ — or "default" "execId": "obs", // optional, LOWERCASE alphanum 1-64 chars: /^[a-z0-9]{1,64}$/ "path": "/login-audit" // required, exact path matching /^\/[A-Za-z0-9._\-\/]{0,256}$/ — uppercase allowed; no `..` or `.` dot-segments, no `//`, no NUL, no wildcards, no percent-encoding }}Coordinates match your hoody-exec script addressing. A hoody-exec script’s public URL has hostname [<subdomain>.]<projectId>-<containerId>[-exec-<execId>].<node>.<domain> with the script path served at <path> — both the <subdomain>. prefix and the -exec-<execId> segment are optional. The three script fields map to the same coordinates: subdomain (optional), execId (optional), path (required). Specifically:
subdomainandexecIdare lowercase alphanum only (to match the public SNI parser which lowercases the hostname before matching).script.pathallows uppercase ASCII letters but rejects*,%, spaces,:, non-ASCII, and path-traversal segments.match.pathis a glob (allows*) and has its own charset — see below.
timeout — soft client-visible deadline
Section titled “timeout — soft client-visible deadline”{ "timeout": 500 }Milliseconds, clamped to [1, 30000]. Default 500 ms. This is a soft, client-visible deadline enforced inside hoody-exec — it sits well inside nginx’s much-longer upstream read timeout (proxy_read_timeout 86400s in the current edge config), so the hook deadline always fires first. The main reason to keep it tight is tenant UX: a 30s timeout makes a slow hook feel like a broken service.
What the client sees when the deadline fires depends on whether your script has already started writing:
- If headers haven’t been sent → client gets
504 hook timeout(JSON body). - If headers/body streaming has started → hoody-exec destroys the response socket. The client sees a truncated response or a connection reset. There is no trailing 504 — the TCP-level abort is the signal.
What happens to your script when the deadline fires:
JavaScript execution is NOT cancelled. Your script keeps running until it naturally returns. When it later tries to res.write/res.end on the destroyed socket, those calls error out (swallowed by hoody-exec so the worker stays healthy). The practical effect: long-running I/O you kicked off (e.g. an in-flight await fetch(...)) proceeds to completion and its response is discarded. Don’t rely on cooperative cancellation at the deadline — design your hook to fit well inside the timeout.
What your hook script sees
Section titled “What your hook script sees”When invoked as a hook, metadata.hook is populated:
metadata.hook = { auditId: "550e8400-e29b-41d4-a716-446655440000", // UUID, or "none" if audit rate-limited OR audit-gate blocked OR DB write failed origMethod: "POST", // client's original method origPath: "/api/login", // client's original path (query stripped) service: "terminal", // the service the client targeted upstream: { host: "192.168.1.42", // container IP — authoritative HOST for forwarding port: 76 // REAL service port (not hoody-exec's) }};upstream.host:port gives you the host and port of the real service. It does NOT carry the full routing envelope that non-hook requests would resolve to — things like service-specific query arg injection, path rewrites, or explicit https protocol are NOT propagated into metadata.hook.upstream. Use it for the common case of “forward the request as-is to the real service”; if your hook needs to replicate the full non-hook routing, fetch the service’s oracle response yourself or forward via an internal endpoint your tenant owns.
For a non-hook invocation of the same script, metadata.hook is undefined — one script can serve both regular traffic and hook dispatches.
req.url is the client’s original URL (path + query). req.headers is the client’s headers with X-Hoody-Hook-* stripped — but note the edge proxy also injects/rewrites the standard forwarding headers (Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto) the same way it does for non-hook traffic. Use X-Real-IP for the real client IP.
Security model
Section titled “Security model”- Hook scripts run in your own
hoody-exec. They see only your tenant’s traffic. The proxy cannot dispatch another tenant’s request to your hook. - Your script runs with tenant privileges. A hook can’t do anything a
hoody-execscript couldn’t already do — there’s no sandbox escape risk beyond what already exists. - The proxy prevents metadata forgery. Clients cannot forge
X-Hoody-Hook-*headers — they are swept by the proxy before the request reacheshoody-exec, even on direct traffic to theexecservice. - Fail-closed by default. A hook error (exception, timeout, 404 on the target script) returns an error to the client — there is no
fail-openfallback. If you want “optional” hooks, catch errors inside your script and return success. - Audit trail is best-effort. The proxy tries to write one audit row per matched hook dispatch with
op: hook-dispatch, carryingprojectId,containerId,groupName,service,scriptRef,origMethod,origPath. The hook still dispatches (withmetadata.hook.auditId === 'none') if the audit subsystem can’t write the row — specifically on (a) the per-scope SNI rate-limit bucket being exhausted, (b) the audit-gate being in ablockedstate, or (c) a DB write exception. Audit is not on the hot-path gating routing. Don’t rely on the audit log as a source of truth for every hook invocation.
Limitations (v1)
Section titled “Limitations (v1)”- Container-level only. Project-level
hooksis rejected by the API. - WebSocket upgrades skip hooks. Use a REST endpoint if you need hooking.
- Long-lived SSE/streaming — bounded by the hook timeout (max 30s). Streams that outlive the timeout get truncated.
- Hard timeout, no JS cancellation. At the deadline the client sees a 504 (if headers haven’t been sent yet) or a truncated/reset response (if headers are already flying). Your script itself keeps running until it naturally completes — its response just gets discarded. Cooperate with the deadline by yielding.
- Soft cap of 8 hooks per service, 32 per file. Design for first-match-wins; don’t rely on iterating many fine-grained rules.
Recursion — don’t fetch the public domain
Section titled “Recursion — don’t fetch the public domain”Your hook forwards via metadata.hook.upstream.host:port — the container IP, directly. Never fetch the public SNI (https://<projectId>-<containerId>-terminal-1.<domain>/...) from inside a hook — that routes back through the proxy and can re-trigger the hook, potentially infinitely.
The proxy enforces a timeout budget on the outermost client request, so unbounded recursion is self-limiting, but it’s still a waste of container resources. Use metadata.hook.upstream as the only upstream address.
API surface
Section titled “API surface”Hooks are first-class resources with their own CRUD endpoints. You can also manage them in bulk by writing the permissions document, which embeds hooks as a field.
Dedicated hook endpoints
Section titled “Dedicated hook endpoints”| Verb | Path | What it does |
|---|---|---|
GET | /api/v1/containers/{id}/proxy/hooks | List all hooks grouped by service |
GET | /api/v1/containers/{id}/proxy/hooks/{service} | List hooks for one service |
POST | /api/v1/containers/{id}/proxy/hooks/{service} | Append or insert a hook |
DELETE | /api/v1/containers/{id}/proxy/hooks/{service} | Clear all hooks for a service |
GET | /api/v1/containers/{id}/proxy/hooks/{service}/{hookId} | Get a single hook |
PATCH | /api/v1/containers/{id}/proxy/hooks/{service}/{hookId} | Replace a hook in place |
DELETE | /api/v1/containers/{id}/proxy/hooks/{service}/{hookId} | Remove a hook |
PATCH | /api/v1/containers/{id}/proxy/hooks/{service}/{hookId}/position | Move a hook to a new position |
All mutating endpoints require If-Match: file:v<N> (ETag from the last read) for optimistic concurrency.
Via the Hoody CLI
Section titled “Via the Hoody CLI”Read-only calls don’t need an ETag. Mutating calls (create, update, delete, clear-service, move) require --if-match file:v<N> — fetch the current version with list or get first.
Add a single hook:
hoody containers proxy hooks create <container-id> terminal \ --match '{"method":["POST"],"path":"/api/login*"}' \ --script '{"path":"/login-audit"}' \ --timeout 500 \ --if-match file:v1List, get, update, move, delete:
hoody containers proxy hooks list <container-id>hoody containers proxy hooks list-service <container-id> terminalhoody containers proxy hooks get <container-id> terminal <hook-id>hoody containers proxy hooks update <container-id> terminal <hook-id> \ --match '{"method":["POST","PUT"],"path":"/api/login*"}' \ --script '{"path":"/login-audit"}' \ --timeout 1000 \ --if-match file:v2hoody containers proxy hooks move <container-id> terminal <hook-id> --position 0 --if-match file:v3hoody containers proxy hooks delete <container-id> terminal <hook-id> --if-match file:v4hoody containers proxy hooks clear-service <container-id> terminal --if-match file:v5Bulk-write the whole permissions document (hooks inline):
hoody containers proxy permissions replace <container-id> \ --default allow \ --hooks '{"terminal":[{"match":{"method":["POST"]},"script":{"path":"/login-audit"}}]}' \ --if-match file:v5Via the SDK (TypeScript)
Section titled “Via the SDK (TypeScript)”Targeted hook CRUD (methods live under client.api.proxyHooks):
// Add a hookawait client.api.proxyHooks.addContainerProxyHook(containerId, 'terminal', { match: { method: ["POST"], path: "/api/login*" }, script: { path: "/login-audit" }, timeout: 500,}, { ifMatch: 'file:v1' });
// List hooks for a serviceconst { data } = await client.api.proxyHooks.listContainerProxyServiceHooks(containerId, 'terminal');
// Move a hook to the frontawait client.api.proxyHooks.moveContainerProxyHook(containerId, 'terminal', hookId, { position: 0 }, { ifMatch: 'file:v2' });Bulk replace via the permissions document:
await client.api.proxyPermissionsContainer.replace(containerId, { project: projectId, container: containerId, groups: {}, permissions: {}, default: "allow", hooks: { terminal: [ { match: { method: ["POST"], path: "/api/login*" }, script: { path: "/login-audit" }, timeout: 500, }, ], },}, { ifMatch: 'file:v5' });Further reading
Section titled “Further reading”- Proxy Permissions — the file your hooks live in
- API: Script Management — how to deploy the script your hook references
- API: Proxy Permissions — full endpoint reference