Routing & Middleware
Section titled “Routing & Middleware”Hoody Exec routes by file path: the directory tree under scripts/ is the routing configuration. There is no route table to maintain and no Express-style router to mount. Create a file at api/users/[id].ts and it serves a dynamic endpoint at /api/users/123. Directories named node_modules, .git, .hoody-cache, _sdk or _hoody are never routed.
File-based routing
Section titled “File-based routing”Files map to URL paths using Next.js-style patterns:
scripts/default/1/api/hello.ts → GET /api/helloscripts/default/1/users.ts → GET /usersscripts/default/1/api/users/[id].ts → GET /api/users/123scripts/default/1/docs/[...slug].ts → GET /docs/api/guide/introHoody Exec derives these mappings from the directory layout, together with any middleware placed beside the route files.
Instance directories
Section titled “Instance directories”Scripts live in instance directories. Each instance has its own hostname and uses scripts/{hostname}/{execId}/ as its highest-priority script directory, with fallbacks to scripts/{hostname}/, scripts/{execId}/, and then the scripts root. A regular file named .exclusive in the instance directory turns those fallbacks off for that instance. Such an instance also never runs a script reached through a symbolic link at or below the instance directory, even one that points inside the instance, as long as the tree is not being changed at the same moment (links in folders above it are followed).
File storage (instance directory):
/hoody/storage/hoody-exec/scripts/default/1/api/users/[id].ts └──┬──┘ └┬┘ Subdomain InstanceThe default segment is the subdomain folder, and public exec URLs, including every URL on this page, always serve from default. A hostname with an extra name in front, such as myapp.PROJECT_ID-CONTAINER_ID-exec-1.SERVER.containers.hoody.com, does not reach your container.
URL paths (no subdomain or instance prefix):
GET /api/users/123 → executes scripts/default/1/api/users/[id].tsGET /users → executes scripts/default/1/users.tsGET /docs/api/guide → executes scripts/default/1/docs/[...slug].tsThe instance ID (1, 2, test, and so on) appears in the hostname and in the storage path, but not in the URL:
- Hostname:
exec-1,exec-2,exec-test - Storage path:
scripts/default/1/,scripts/default/2/,scripts/default/test/ - URL path:
/api/users. Without an.exclusivefile,/1/api/userscan also reach the same script, through thescripts/default/fallback
Access URLs:
https://PROJECT_ID-CONTAINER_ID-exec-1.SERVER.containers.hoody.com/api/hellohttps://PROJECT_ID-CONTAINER_ID-exec-2.SERVER.containers.hoody.com/users/123https://PROJECT_ID-CONTAINER_ID-exec-test.SERVER.containers.hoody.com/docs/api/guideDynamic route patterns
Section titled “Dynamic route patterns”Hoody Exec supports Next.js-style dynamic routing with brackets:
Request paths may contain only ASCII letters, digits, -, _, / and ., checked after URL-decoding. Any other character, including a percent-encoded one, returns 400 (a WebSocket upgrade is accepted and then closed with code 4400).
Single parameter [param]
Section titled “Single parameter [param]”scripts/default/1/users/[id].ts → /users/123The value arrives as metadata.parameters.id: "123".
Multiple parameters [param1]/[param2]
Section titled “Multiple parameters [param1]/[param2]”scripts/default/1/blog/[year]/[month].ts → /blog/2024/11The values arrive as metadata.parameters.year ("2024") and metadata.parameters.month ("11").
Catch-all [...slug]
Section titled “Catch-all [...slug]”Matches one or more path segments:
scripts/default/1/docs/[...slug].ts → /docs/api/guide/introThe segments arrive as an array in metadata.parameters.slug: ["api", "guide", "intro"].
const parts = metadata.parameters.slug; // ["api", "guide", "intro"]const fullPath = parts.join('/'); // "api/guide/intro"return { section: parts[0], path: fullPath };Optional catch-all [[...path]]
Section titled “Optional catch-all [[...path]]”Matches zero or more path segments, including the bare prefix:
scripts/default/1/pages/[[...path]].ts → /pages OR /pages/about OR /pages/blog/post/1The segments arrive as metadata.parameters.path: [], ["about"], or ["blog", "post", "1"].
const parts = metadata.parameters.path; // [] for /pages, ["about"] for /pages/aboutif (parts.length === 0) { return { page: "index" }; // Bare /pages}return { page: parts.join('/') }; // /pages/about → "about"Pattern reference
Section titled “Pattern reference”| Pattern | File path | Matches | Parameters |
|---|---|---|---|
| Static | scripts/default/1/api/hello.ts | /api/hello | {} |
| Index | scripts/default/1/api/index.ts | /api | {} |
| Dynamic | scripts/default/1/users/[id].ts | /users/123 | { id: "123" } |
| Nested dynamic | scripts/default/1/blog/[year]/[month].ts | /blog/2024/11 | { year: "2024", month: "11" } |
| Catch-all | scripts/default/1/docs/[...slug].ts | /docs/api/guide/intro | { slug: ["api", "guide", "intro"] } |
| Optional catch-all | scripts/default/1/pages/[[...path]].ts | /pages or /pages/about | { path: [] } or { path: ["about"] } |
Dynamic directory segments
Section titled “Dynamic directory segments”Single parameters such as [id] can appear in directory names, not only in file names. That is how you build nested REST-style resource paths. A catch-all ([...slug] or [[...slug]]) must be the last part of a route: use [...slug].ts or [...slug]/index.ts. Any other file or folder inside a catch-all folder is not routed.
scripts/default/1/users/[userId]/settings.ts → /users/42/settingsscripts/default/1/users/[userId]/posts/[postId].ts → /users/42/posts/99scripts/default/1/shops/[shopId]/products/[productId].ts → /shops/abc/products/xyzconst { userId, postId } = metadata.parameters;// userId = "42", postId = "99"const post = await db.getPost(userId, postId); // db: your own database helperreturn { post };File extensions
Section titled “File extensions”- Hoody Exec runs both
.ts(TypeScript) and.js(JavaScript) scripts - TypeScript is transpiled automatically, so there is no build step
- URL paths can include or omit the file extension:
/api/helloand/api/hello.tsboth resolve. A.tsor.jsending on the last URL segment is removed before matching, also for dynamic and catch-all routes (/files/a.jsgivesslug['a']), and when bothhello.tsandhello.jsexist,/hello.jsrunshello.ts index.ts(orindex.js) files match the directory root:api/index.tshandles/api
Route priority
Section titled “Route priority”When multiple files could match a URL, Hoody Exec first looks at the directories in order: scripts/{hostname}/{execId}/, then scripts/{hostname}/, then scripts/{execId}/, then the scripts root. The first directory that has a matching file wins, even if a more specific file exists in a later directory. A script that is a symbolic link is not found by that search: it is tried only after every regular-file match, by its exact path. For /api/users on exec-1, scripts/default/1/api/[...slug].ts beats scripts/default/api/users.ts.
If no route matches in any of those directories, Hoody Exec tries a few catch-all scripts for any path (except one passing through a never-routed directory such as node_modules or _sdk) and any HTTP method except OPTIONS, in this order: scripts/default.ts (or .js), scripts/default/index.ts (or .js), then scripts/index.ts (or .js). Instances with an .exclusive file skip these and return 404.
Some HTTP requests never reach a script, not even a catch-all (a WebSocket upgrade always goes to script routing). Every OPTIONS request is answered with 204 before routing. /api/v1/exec/*, and the exact URLs /favicon.ico, /openapi.json and /openapi.yaml (with no query string), are answered by Hoody Exec itself. A URL ending in .md serves the matching Markdown file or returns 404 (unless its path passes through a never-routed directory such as node_modules or _sdk, in which case a matching dynamic or catch-all script answers) (AI system-prompt files, _system.md and *.system.md, always return 404); when a script with the same name sits beside it, that script’s @enabled false hides the file (404) and its @token protects it (401 without a valid token). The one exception is a request that a proxy hook sends to its target script: that script runs.
Within one directory, this priority order applies:
- Exact match:
api/users.tsbeatsapi/[param].tsfor/api/users - Dynamic segments:
api/[id].tsbeatsapi/[...slug].tsfor/api/123 - Catch-all:
api/[...slug].tscatches everything else - Optional catch-all:
api/[[...path]].tsis the lowest priority fallback
Route collisions
Section titled “Route collisions”Two dynamic route files at the same directory level ([id].ts and [slug].ts, for example) compete. The winner is stable, not random: file routes come before directory indexes, then route patterns and file paths are compared alphabetically, so [id].ts beats [slug].ts. Still, avoid competing patterns.
scripts/default/1/products/[id].ts ← These compete for /products/abcscripts/default/1/products/[slug].ts ← Alphabetical order determines winnerMiddleware
Section titled “Middleware”Hoody Exec supports pre/post middleware in both worker and serverless mode. A pre.ts and a post.ts run around any request that matches a script in the same directory as the middleware files. The script’s @enabled and @token checks come first: a 404 or 401 from them (close code 4404 or 4401 for a WebSocket) runs neither file. So does reading the request body (except for GET and HEAD, whose body is never read), unless the script has @rawBody: a malformed body (400), one over the size limit (413) or one that takes over 30 seconds to arrive (408, or 504 if the script’s @timeout runs out first) runs neither file.
Execution order: pre.ts → main script → post.ts
This applies to HTTP requests and to runs started by @schedule. A scheduled run skips the @token check, has req.method set to CRON and carries no client headers, so an authentication check in pre.ts must let it through or the main script never runs. A WebSocket upgrade runs only pre.ts, once before the connection opens (turn that off with @websocket-pre false), and gives it at most 30 seconds, or the script’s @timeout if shorter, before refusing the upgrade with 504; post.ts never runs for WebSocket connections or their messages. Remote operations run neither file, so protect them with tokens.
// Authentication checkif (!req.headers.authorization) { res.statusCode = 401; return { error: "Unauthorized" }; // Early exit: main script skipped}
// Validate token and attach the identity to this request.// Do not put it in `shared`: that object is persistent deployment state// visible to every concurrent request.const userId = validateToken(req.headers.authorization); // your own check, e.g. with jsonwebtokenreq.currentUser = { userId, timestamp: Date.now() };
// Return nothing to continue to main scriptBehavior in pre.ts:
- Runs before requests matching a script in the same directory (
api/) - Return any value other than
nullorundefinedto short-circuit (skip main script). Ending the response yourself also skips it - Return nothing,
null, orundefinedto continue to main script. Before a WebSocket upgrade the rule is stricter: the upgrade goes ahead only ifpre.tsreturnsnullorundefinedand has not started a response (not even headers); otherwise it is refused with the error status set, or403 - Can set properties on
reqfor the main script andpost.tsto use. Keep per-request data offshared, which is persistent deployment state
// Your regular endpoint script (same directory as pre.ts/post.ts)
const users = await db.getUsers(); // db: your own database helperreturn { users };// mainResult contains the main script's return value (concurrency-safe)return { success: true, data: mainResult, user: req.currentUser?.userId, timestamp: new Date().toISOString(), version: "1.0.0"};Behavior in post.ts:
- Runs after requests matching a script in the same directory (
api/) mainResultcontains the return value from the main script. Ifpre.tsreturned a value and skipped the main script, it holds that value instead. Ifpre.tsended the response itself without returning anything, it isundefined- Return a value other than
nullorundefinedto replace or wrap the main script’s response. This works only while the response is still unsent: if the main script already sent it withres.json()orres.send(),post.tsstill runs but cannot replace what the client received. Returningnullorundefinedkeeps the main result - Runs even when
pre.tsshort-circuits, so logging and response envelopes still happen. It does not run whenpre.tsor the main script throws, or once an HTTP request has timed out with nothing sent yet (a response already under way is not cut off, andpost.tsruns after it; a scheduled run’s timeout does not stoppost.ts, and a proxy hook deadline does cut off an unfinished response and skipspost.ts), so put cleanup that must always happen in atry/finallyin the script itself
Middleware discovery
Section titled “Middleware discovery”When a request matches a script, Hoody Exec looks for a pre.ts and a post.ts in the same directory as the matched script file. Either extension works (.js or .ts), and TypeScript is tried first. The middleware files apply to the routes in their own directory. Because these names are reserved, pre.ts, pre.js, post.ts and post.js never serve as endpoints themselves: a file meant to answer /api/post needs another name.
scripts/default/1/api/users/pre.ts ← Runs before scripts in api/users/scripts/default/1/api/users/[id].ts ← Main script executesscripts/default/1/api/users/post.ts ← Runs after, receives mainResultExecution order: pre.ts → main script → post.ts. Discovery does not walk up the directory tree: pre.ts and post.ts apply only to scripts in the directory that contains them. To wrap an entire instance, place the matched scripts and their pre.ts/post.ts in the same directory.
post.ts receives the main script’s return value in mainResult. It runs even when pre.ts short-circuits by returning a value, so it can still log or reshape the response. It is skipped when pre.ts or the main script throws, or after an HTTP request timed out before anything was sent (a scheduled run still runs it).
Route API endpoints
Section titled “Route API endpoints”Hoody Exec exposes its router over HTTP for programmatic route management:
Route resolution
Section titled “Route resolution”Determine which script handles a given URL path:
# Resolve which script handles a URL pathhoody exec routes resolve --path /api/users/123 -c CONTAINER_ID -o jsonconst containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER});const result = await containerClient.exec.routes.resolve({ path: '/api/users/123'});console.log(result.data); // match: { matched: true, path, scriptPath, routePattern, parameters, type, baseDir }; miss: { matched: false, path, hostname, execId, triedDirectories }curl -X POST "https://PROJECT_ID-CONTAINER_ID-exec-1.SERVER.containers.hoody.com/api/v1/exec/route/resolve" \ -H "Content-Type: application/json" \ -d '{"path": "/api/users/123"}'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Resolves which script file matches this path, without invoking it. A script that is a symbolic link is reported as no match, even where a request would run it. It does not apply the checks that answer some requests before routing (OPTIONS, .md URLs, built-in paths) or the script’s own @enabled and @token.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-exec-1.SERVER.containers.hoody.com/api/v1/exec/route/resolve&method=POST&json={"path":"/api/users/123"}&response=transparent Route discovery
Section titled “Route discovery”List the routes found under one directory of the scripts root. Discovery scans only that directory (baseDir), so a script that is served through a fallback directory, such as scripts/default/health.ts answering /health on exec-1, is not listed for default/1.
# Discover all routes in the exec instancehoody exec routes list --base-dir default/1 -c CONTAINER_ID -o json// Without baseDir, discovery scans the whole scripts root; scope it to this instance.const routes = await containerClient.exec.routes.list({ baseDir: 'default/1' });console.log(routes.data.routes); // [{ pattern: "/api/hello", file: "default/1/api/hello.ts", type: "static", parameters: [] }, ...]// response shape: { baseDir, count, routes }curl -X POST "https://PROJECT_ID-CONTAINER_ID-exec-1.SERVER.containers.hoody.com/api/v1/exec/route/discover" \ -H "Content-Type: application/json" \ -d '{"baseDir":"default/1"}'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Lists every route under the given directory, along with its file path, pattern type, and parameters.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-exec-1.SERVER.containers.hoody.com/api/v1/exec/route/discover&method=POST&json={"baseDir":"default/1"}&response=transparent Route testing
Section titled “Route testing”Test multiple URL paths against the routing system in a single batch:
# Test multiple paths against routeshoody exec routes test --paths /api/users/123,/api/health,/nonexistent -c CONTAINER_ID -o jsonconst test = await containerClient.exec.routes.test({ paths: ['/api/users/123', '/api/health', '/nonexistent']});console.log(test.data);// { tested: 3, matched: 2, notMatched: 1, results: [// { path: "/api/users/123", matched: true, scriptPath: "...", parameters: { id: "123" } },// { path: "/api/health", matched: true, scriptPath: "...", parameters: {} },// { path: "/nonexistent", matched: false }// ]}curl -X POST "https://PROJECT_ID-CONTAINER_ID-exec-1.SERVER.containers.hoody.com/api/v1/exec/route/test" \ -H "Content-Type: application/json" \ -d '{"paths": ["/api/users/123", "/api/health", "/nonexistent"]}'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Tests a batch of paths against the routing table in one call and reports which ones match.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-exec-1.SERVER.containers.hoody.com/api/v1/exec/route/test&method=POST&json={"paths":["/api/users/123","/api/health","/nonexistent"]}&response=transparent