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.
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 an isolated script namespace.
File storage (instance directory):
/hoody/storage/hoody-exec/scripts/default/1/api/users/[id].ts └──┬──┘ └┬┘ Subdomain InstanceThe default segment is the subdomain namespace. It is the literal string default for a hostname with no subdomain in front of the project ID, which covers every URL on this page. A request to myapp.PROJECT_ID-CONTAINER_ID-exec-1... resolves under scripts/myapp/1/ instead.
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, never/1/api/users
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:
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”Dynamic parameters can appear in directory names, not only in file names. That is how you build nested REST-style resource paths:
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);return { 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 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 uses this priority order:
- 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 first match in filesystem order wins, and that order is not deterministic.
scripts/default/1/products/[id].ts ← These compete for /products/abcscripts/default/1/products/[slug].ts ← Filesystem 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.
Execution order: pre.ts → main script → post.ts
// @mode worker
// Authentication checkif (!req.headers.authorization) { res.statusCode = 401; return { error: "Unauthorized" }; // Early exit: main script skipped}
// Validate token and add to sharedconst userId = validateToken(req.headers.authorization);shared.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 a value to short-circuit (skip main script)
- Return nothing (or
undefined) to continue to main script - Can set
sharedproperties for the main script to use
// Your regular endpoint script (same directory as pre.ts/post.ts)
const users = await db.getUsers();return { users };// @mode worker
// mainResult contains the main script's return value (concurrency-safe)return { success: true, data: mainResult, user: shared.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- Return a new value to replace or wrap the main script’s response
- Runs even when
pre.tsshort-circuits, so logging, cleanup, and response envelopes still happen
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.
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, clean up, or reshape the response.
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 --body '{"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.route.resolve({ path: '/api/users/123'});console.log(result.data); // { matched: true, path: "/api/users/123", hostname: "...", execId: "1", triedDirectories: [...] }// (a miss returns { 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 would handle this path, without actually invoking it.
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 all available routes in an instance:
# Discover all routes in the exec instancehoody exec routes discover -c CONTAINER_ID -o json// Without baseDir, discovery scans the whole scripts root; scope it to this instance.const routes = await containerClient.exec.route.discover({ 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"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 the instance currently exposes, 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&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 --body '{"paths":["/api/users/123","/api/health","/nonexistent"]}' -c CONTAINER_ID -o jsonconst test = await containerClient.exec.route.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: "...", type: "dynamic", params: { id: "123" } },// { path: "/api/health", matched: true, scriptPath: "...", type: "static", params: {} },// { path: "/nonexistent", matched: false, scriptPath: null, type: null, params: {} }// ]}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