Skip to content
Hoody.com

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.


Files map to URL paths using Next.js-style patterns:

scripts/default/1/api/hello.ts → GET /api/hello
scripts/default/1/users.ts → GET /users
scripts/default/1/api/users/[id].ts → GET /api/users/123
scripts/default/1/docs/[...slug].ts → GET /docs/api/guide/intro

Hoody Exec derives these mappings from the directory layout, together with any middleware placed beside the route files.


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 Instance

The 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].ts
GET /users → executes scripts/default/1/users.ts
GET /docs/api/guide → executes scripts/default/1/docs/[...slug].ts

The 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 .exclusive file, /1/api/users can also reach the same script, through the scripts/default/ fallback

Access URLs:

https://PROJECT_ID-CONTAINER_ID-exec-1.SERVER.containers.hoody.com/api/hello
https://PROJECT_ID-CONTAINER_ID-exec-2.SERVER.containers.hoody.com/users/123
https://PROJECT_ID-CONTAINER_ID-exec-test.SERVER.containers.hoody.com/docs/api/guide

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).

scripts/default/1/users/[id].ts → /users/123

The value arrives as metadata.parameters.id: "123".

scripts/default/1/blog/[year]/[month].ts → /blog/2024/11

The values arrive as metadata.parameters.year ("2024") and metadata.parameters.month ("11").

Matches one or more path segments:

scripts/default/1/docs/[...slug].ts → /docs/api/guide/intro

The segments arrive as an array in metadata.parameters.slug: ["api", "guide", "intro"].

scripts/default/1/docs/[...slug].ts
const parts = metadata.parameters.slug; // ["api", "guide", "intro"]
const fullPath = parts.join('/'); // "api/guide/intro"
return { section: parts[0], path: fullPath };

Matches zero or more path segments, including the bare prefix:

scripts/default/1/pages/[[...path]].ts → /pages OR /pages/about OR /pages/blog/post/1

The segments arrive as metadata.parameters.path: [], ["about"], or ["blog", "post", "1"].

scripts/default/1/pages/[[...path]].ts
const parts = metadata.parameters.path; // [] for /pages, ["about"] for /pages/about
if (parts.length === 0) {
return { page: "index" }; // Bare /pages
}
return { page: parts.join('/') }; // /pages/about → "about"

PatternFile pathMatchesParameters
Staticscripts/default/1/api/hello.ts/api/hello{}
Indexscripts/default/1/api/index.ts/api{}
Dynamicscripts/default/1/users/[id].ts/users/123{ id: "123" }
Nested dynamicscripts/default/1/blog/[year]/[month].ts/blog/2024/11{ year: "2024", month: "11" }
Catch-allscripts/default/1/docs/[...slug].ts/docs/api/guide/intro{ slug: ["api", "guide", "intro"] }
Optional catch-allscripts/default/1/pages/[[...path]].ts/pages or /pages/about{ path: [] } or { path: ["about"] }

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/settings
scripts/default/1/users/[userId]/posts/[postId].ts → /users/42/posts/99
scripts/default/1/shops/[shopId]/products/[productId].ts → /shops/abc/products/xyz
scripts/default/1/users/[userId]/posts/[postId].ts
const { userId, postId } = metadata.parameters;
// userId = "42", postId = "99"
const post = await db.getPost(userId, postId); // db: your own database helper
return { post };

  • 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/hello and /api/hello.ts both resolve. A .ts or .js ending on the last URL segment is removed before matching, also for dynamic and catch-all routes (/files/a.js gives slug ['a']), and when both hello.ts and hello.js exist, /hello.js runs hello.ts
  • index.ts (or index.js) files match the directory root: api/index.ts handles /api

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:

  1. Exact match: api/users.ts beats api/[param].ts for /api/users
  2. Dynamic segments: api/[id].ts beats api/[...slug].ts for /api/123
  3. Catch-all: api/[...slug].ts catches everything else
  4. Optional catch-all: api/[[...path]].ts is the lowest priority fallback

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/abc
scripts/default/1/products/[slug].ts ← Alphabetical order determines winner

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.

scripts/default/1/api/pre.ts
// Authentication check
if (!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 jsonwebtoken
req.currentUser = { userId, timestamp: Date.now() };
// Return nothing to continue to main script

Behavior in pre.ts:

  • Runs before requests matching a script in the same directory (api/)
  • Return any value other than null or undefined to short-circuit (skip main script). Ending the response yourself also skips it
  • Return nothing, null, or undefined to continue to main script. Before a WebSocket upgrade the rule is stricter: the upgrade goes ahead only if pre.ts returns null or undefined and has not started a response (not even headers); otherwise it is refused with the error status set, or 403
  • Can set properties on req for the main script and post.ts to use. Keep per-request data off shared, which is persistent deployment state

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 executes
scripts/default/1/api/users/post.ts ← Runs after, receives mainResult

Execution 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).


Hoody Exec exposes its router over HTTP for programmatic route management:

Determine which script handles a given URL path:

Terminal window
# Resolve which script handles a URL path
hoody exec routes resolve --path /api/users/123 -c CONTAINER_ID -o json

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.

Terminal window
# Discover all routes in the exec instance
hoody exec routes list --base-dir default/1 -c CONTAINER_ID -o json

Test multiple URL paths against the routing system in a single batch:

Terminal window
# Test multiple paths against routes
hoody exec routes test --paths /api/users/123,/api/health,/nonexistent -c CONTAINER_ID -o json