Execution Modes
Section titled “Execution Modes”Every Hoody Exec script declares an execution mode. The mode decides whether the script gets one long-lived JavaScript context or a fresh context per request, and that in turn determines what state it can keep, how it performs, how it handles concurrency, and which features are available to it.
Worker mode
Section titled “Worker mode”// @mode workerWorker mode creates one long-lived JavaScript context per exec instance that stays alive across all requests and does not restart between them. The shared object is its in-memory store: write to it once and read it back on later requests, until it is cleared through POST /api/v1/exec/shared-state/clear or Hoody Exec restarts: a container restart, a restart through POST /api/v1/exec/system/restart, or the automatic recovery after a crash or a script that blocks the service.
Worker mode features
Section titled “Worker mode features”- Long-lived context: built once, then reused for every request
- Shared state: the
sharedobject survives from one request to the next - WebSockets: worker mode is the only mode that supports your own
wshandlers (the remote attach socket works in both modes) - Unlimited concurrency by default: simultaneous requests are handled in parallel; cap parallelism with
@concurrent Nwhen you need to (@concurrentworks in both modes for HTTP requests, cron fires and remotesendandcall; messages to an ordinary script WebSocket are not held back by it, so serialize that work yourself when it matters) - Pre/post middleware:
pre.tsandpost.tswrap HTTP requests matching a script in the same directory (middleware runs in both worker and serverless mode; a WebSocket upgrade runs onlypre.ts, unless@websocket-pre falseturns it off, and a remote attach socket runs neither) - No context build: the context is already warm, so only script loading and compilation happen per request
Worker mode limitations
Section titled “Worker mode limitations”- State is lost on container restart (use SQLite for persistence)
- Higher memory usage, from keeping that context alive
Workloads that fit worker mode
Section titled “Workloads that fit worker mode”- WebSocket servers (chat, live dashboards, real-time updates)
- Session management and caching
- High-traffic APIs, which skip the context build after the first request
- Rate limiting with per-IP tracking
- Hoody AI interception, an MITM proxy for controlling AI requests
- Anything that needs state to survive from one request to the next
Serverless mode
Section titled “Serverless mode”// Or omit the directive; serverless is the default// @mode serverlessServerless mode creates a fresh JavaScript context for every single request, in the style of AWS Lambda, Vercel Functions, and Cloudflare Workers. Context-local state does not survive: variables in the script start empty on every request. Functions a script registers for remote messages and calls are the exception: they keep the variables, and the context, of the run that registered them. With @remote-eval (and a script that does not bind or assign to eval, use with, or replace the global eval, see remote), runs still in progress are kept so they can be inspected (a run counts as finished once its handler returns, even if a stream it returned is still being sent). So is one finished run per room, the most recently started one, for up to 32 rooms per instance. This is not a history: a run that started later replaces the room’s kept run when it ends. A request that hit its @timeout before sending anything counts as ended, even if its code is still running. What does survive is everything outside the context, and that is most of what matters for safety: the per-instance shared object is the same one worker mode uses, modules you load with require or import are cached and keep their own state, and files written, subprocesses started and network calls made all persist.
Serverless mode features
Section titled “Serverless mode features”- Fresh context: a new JavaScript context for every request
- No leftover context state: variables in your script do not reach the next request. Modules you
requireorimportare cached and can keep their own state - Concurrency control:
@concurrent 5limits parallel execution (also available in worker mode) - Lower memory: no long-lived context to keep around, except contexts kept for remote handlers or
@remote-eval, or kept alive by your own timers and callbacks (asetIntervalis not cancelled when the request ends) - Lower idle cost than keeping a worker context alive
Serverless mode limitations
Section titled “Serverless mode limitations”- No
wshandlers of your own, since there are no persistent connections (the remote attach socket still works) - No per-request isolation of side effects: files written, subprocesses started and network calls made persist, and the per-instance
sharedobject is the same one worker mode uses - Slight per-request overhead from building the context
Both modes run with the Exec service’s own authority: scripts can use the filesystem, spawn subprocesses and reach the network, so both need code you trust. Give mutually untrusted workloads separate containers.
Neither mode gives a script its own process either. Every script of an exec instance, in both modes, runs in one server process with one event loop. While one script runs synchronous code (a long loop, a synchronous file read or hash), every other script and request on that instance waits for it. @timeout cannot stop synchronous code. If the block lasts longer than the watchdog limit (30 seconds by default), the server is restarted: every running script ends, in-memory state such as shared is cleared, and requests fail for several seconds while it comes back. Move heavy synchronous work into a subprocess or break it up with await.
Workloads that fit serverless mode
Section titled “Workloads that fit serverless mode”- Webhook receivers (Stripe, GitHub, Slack)
- Independent tasks (data processing, API calls)
- Sporadic traffic, on a pay-per-execution model
- Stateless microservices
- Scripts that keep no module-level state worth preserving between requests
- Anything that gains nothing from a warm context between requests
Shared state
Section titled “Shared state”The shared object is the same in both modes, with the same scope and lifetime; what differs is the context around it:
// @mode worker
// Example inputsconst userId = metadata.parameters.id;const userData = { seenAt: Date.now() };
// Initialize on first requestif (!shared.requestCount) { shared.requestCount = 0; shared.users = new Map(); shared.sessions = new Set();}
shared.requestCount++; // persists across all requestsshared.users.set(userId, userData);
return { count: shared.requestCount, // Increments: 1, 2, 3, 4... cachedUsers: shared.users.size, // Accumulates over time activeSessions: shared.sessions.size};// @mode serverless
// The context is new each time; `shared` is notif (!shared.requestCount) { shared.requestCount = 0; // only on the first request for this instance}
shared.requestCount++; // 1, then 2, then 3: the object is shared
return { count: shared.requestCount};Key differences:
- Worker:
sharedpersists between requests, which is what makes caching, counters, and sessions work - Serverless:
sharedis the same per-instance object, so it persists between requests here too; what resets is context-local state, such as variables in your script. Modules yourequirekeep their own state. Use ordinary local variables for request-scoped data - Both modes:
sharedis lost on container restart or reboot; use SQLite for permanent storage
Shared state persistence
Section titled “Shared state persistence”Shared state is in-memory only and scoped per instance: each exec instance has its own shared object, and two instances under the same subdomain never see each other’s values. There is no automatic TTL. Values persist until Hoody Exec restarts (see above) or the service evicts an idle instance’s state, which it does to stay within its cache limit:
- Data stays in
shareduntil you delete it, the service evicts that instance’s idle state, or Hoody Exec restarts - There is no expiration mechanism, so implement your own if you need one
- On long-running workers, clean up stale data explicitly or memory will grow without bound
// @mode worker// Explicit cleanup pattern for long-running workersif (!shared.cache) shared.cache = new Map();
// Add with timestamp (key and data are whatever you want to cache)const key = 'greeting';const data = 'hello';shared.cache.set(key, { value: data, createdAt: Date.now() });
// Periodic cleanup (e.g., remove entries older than 1 hour)const ONE_HOUR = 3600000;for (const [k, v] of shared.cache) { if (Date.now() - v.createdAt > ONE_HOUR) shared.cache.delete(k);}State loss on restart
Section titled “State loss on restart”The shared object lives entirely in memory, so a container restart clears it. That is by design. If you need data to survive restarts:
- SQLite: use the bundled
Database(bun:sqlite) for structured persistent storage - External databases: connect to an external service for critical data
- File system: write to
/hoody/storage/for simple persistence
// @mode worker// Persist critical data to SQLiteconst db = new Database('/hoody/databases/app.db');db.run('CREATE TABLE IF NOT EXISTS sessions (id TEXT, data TEXT, created INTEGER)');
// Use shared for fast access, SQLite for durabilityif (!shared.sessions) { // Restore from SQLite on first request after restart shared.sessions = new Map(); const rows = db.query('SELECT id, data FROM sessions').all(); for (const row of rows) { shared.sessions.set(row.id, JSON.parse(row.data)); }}Cold start behavior
Section titled “Cold start behavior”The execution mode determines startup latency:
Serverless mode builds a fresh context on every request, so every request pays a small overhead for building it and compiling the script. That is the cold start cost.
Worker mode builds its context on the first request only. After that the context stays warm and later requests skip the context build. The script is still loaded and compiled on every request. The context stays warm while it is cached. The service can evict an idle instance’s context and its shared state when it is over its cache limit, and clearing the cache or restarting Hoody Exec also removes it. Writing, moving or deleting a .env file the script reads through the management API can drop the context too; shared is kept.
| First request | Subsequent requests | |
|---|---|---|
| Serverless | Context build + compilation | Context build + compilation (same cost every time) |
| Worker | Context build + compilation | Compilation only (context already warm) |
For latency-sensitive endpoints under frequent traffic, worker mode removes the context build from every request after the first, for as long as the context stays cached.
Memory considerations
Section titled “Memory considerations”Worker mode’s long-lived context keeps its state alive between requests; the script itself is still read and compiled on every request. What survives is narrower than it looks:
sharedpersists, and so does anything reachable from it, or from a module yourequired- Globals persist too: a property you set on
globalThis, or a variable you assign without declaring it, stays in the context, and every worker script of the same instance sees it - Ordinary variables in your script do not. Your code is wrapped in a function that runs again for each request, so a top-level
let count = 0is back at0every time in both modes. Variables captured by a WebSocket or remote handler are the exception: they live as long as that handler, across messages and connections. Put counters and caches onshared - Memory accumulates: every
Map,Set, array, or object you add tosharedstays in memory until you remove it, and is not garbage collected whilesharedreferences it - Growing an array or map indefinitely will eventually consume all available memory
Memory monitoring endpoint
Section titled “Memory monitoring endpoint”Use the monitoring endpoint to track memory consumption:
curl "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/v1/exec/monitor/stats"Response includes:
{ "memory": { "used": 52428800, "total": 268435456, "percentage": 19.5, "rss": 188743680, "external": 3145728 }}used, total and percentage describe the JavaScript heap, so percentage is heap use, not the share of the container’s memory. To watch the memory of the whole process, including Buffers held outside the heap, track rss.
Cleanup strategies
Section titled “Cleanup strategies”// @mode worker
// 1. Cap collection sizesif (shared.logs && shared.logs.length > 1000) { shared.logs = shared.logs.slice(-500); // Keep last 500}
// 2. Clear the context cache and shared state// Packages loaded with require() keep their own state until Hoody Exec restarts (POST /api/v1/exec/system/restart)// (a reinstall reloads a CommonJS package for later require() calls; ES modules stay loaded)// POST /api/v1/exec/cache/clear with body { "clearAll": true, "clearState": true } for every hostname, or { "hostname": "default", "execId": "1", "clearVm": true, "clearState": true } for one exec instance (public URLs always use the hostname "default"; leave out "execId" to clear every instance)
// 3. Avoid patterns that grow indefinitely// BAD: shared.allRequests.push(req) grows forever// GOOD: shared.recentRequests = shared.recentRequests.slice(-100)WebSocket support (worker mode only)
Section titled “WebSocket support (worker mode only)”Two magic comments enable real-time bidirectional communication:
// @mode worker// @websocket// @cors reflective
// Serve HTML UI for HTTP requests (optional)res.writeHead(200, { 'Content-Type': 'text/html' });res.end('<h1>WebSocket Server</h1>');
// WebSocket handlers (direct assignment pattern)ws.message = (socket, data) => { console.log('Received:', data); ws.broadcast(data); // Send to all clients};
ws.close = (socket, code, reason) => { console.log('Client disconnected');};
// Or use the event emitter pattern instead. Both set the same single handler,// so the last one you register wins:ws.on('message', (socket, data) => { socket.send('Echo: ' + data);});Connection tracking:
console.log('Active connections:', ws.connections.size);Examples
Section titled “Examples”Worker mode, with shared state caching:
// @mode worker// @cors reflective// @timeout 5000// @log-level standard
// Initialize cache on first requestif (!shared.usersCache) { shared.usersCache = new Map(); shared.cacheHits = 0; shared.cacheMisses = 0;}
const userId = metadata.parameters.id;
// Check cache firstif (shared.usersCache.has(userId)) { shared.cacheHits++; return { user: shared.usersCache.get(userId), cached: true, cacheHitRate: shared.cacheHits / (shared.cacheHits + shared.cacheMisses) };}
// Fetch from database (cache miss)// fetchUserFromDatabase is your own function; Exec does not provide itshared.cacheMisses++;const user = await fetchUserFromDatabase(userId);
// Cache for next requestshared.usersCache.set(userId, user);
return { user, cached: false, cacheHitRate: shared.cacheHits / (shared.cacheHits + shared.cacheMisses)};Serverless mode, a fresh context each time:
// @mode serverless// @concurrent 10// @cors reflective// @timeout 5000// @log-level standard
const userId = metadata.parameters.id;
// Validate inputif (typeof userId !== 'string' || !/^[0-9a-fA-F]{24}$/.test(userId)) { res.statusCode = 400; return { error: 'Invalid user ID format', expected: '24-character hex string' };}
// Fresh database query every time (no cache)// fetchUserFromDatabase is your own function; Exec does not provide itconst user = await fetchUserFromDatabase(userId);
if (!user) { res.statusCode = 404; return { error: 'User not found', userId };}
// Clean responsereturn { user, requestId: metadata.executionId};Worker mode only, since WebSocket requires a long-lived context:
// @mode worker// @websocket// @cors reflective// @timeout 0
const roomId = metadata.parameters.roomId;
// Initialize room stateif (!shared.rooms) { shared.rooms = new Map();}
if (!shared.rooms.has(roomId)) { shared.rooms.set(roomId, { users: new Map(), messages: [], created: new Date() });}
const room = shared.rooms.get(roomId);
// Serve HTML UI for HTTP requestsres.writeHead(200, { 'Content-Type': 'text/html' });res.end(` <!DOCTYPE html> <html> <head><title>Chat Room: ${roomId}</title></head> <body> <div id="messages"></div> <input id="input" placeholder="Type message..." /> <script> const ws = new WebSocket(location.href.replace('http', 'ws')); ws.onmessage = e => { const div = document.createElement('div'); div.textContent = e.data; document.getElementById('messages').appendChild(div); }; document.getElementById('input').onkeypress = e => { if (e.key === 'Enter') { ws.send(e.target.value); e.target.value = ''; } }; </script> </body> </html>`);
// WebSocket handlersws.open = (socket, req) => { const userId = socket.data.executionId; room.users.set(userId, { connectedAt: new Date(), ip: socket.data.ip });
// Broadcast join message to all in room ws.broadcast(JSON.stringify({ type: 'join', roomId, userId, userCount: room.users.size }));};
ws.message = (socket, data) => { const message = { type: 'message', roomId, userId: socket.data.executionId, text: data, timestamp: new Date().toISOString() };
// Save to room history room.messages.push(message);
// Broadcast to all connected clients in this room (ws.broadcast is scoped to this // script and its route parameter values, so other rooms do not receive it). // After the script file changes, new connections join a fresh pool: clients // connected before the change do not receive these until they reconnect ws.broadcast(JSON.stringify(message));};
ws.close = (socket, code, reason) => { const userId = socket.data.executionId; room.users.delete(userId);
// Cleanup empty rooms if (room.users.size === 0 && room.messages.length === 0) { shared.rooms.delete(roomId); }
ws.broadcast(JSON.stringify({ type: 'leave', roomId, userId, userCount: room.users.size }));};Serverless mode, where each delivery starts from a clean context:
// @mode serverless// Process webhooks serially// @concurrent false// @cors none// @timeout 30000// @log-level full// @log-request-body true
// Verify the webhook signature before trusting anything in the payload.// Set STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in this script's .env file.const Stripe = require('stripe');const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
let event;try { // req.rawBody is the raw request Buffer, which signature checks need event = stripe.webhooks.constructEvent( req.rawBody, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET );} catch (err) { res.statusCode = 400; return { error: 'Invalid webhook signature' };}
// Handle event types// processPayment, createSubscription and handleFailedPayment are your own// functions; Exec does not provide themswitch (event.type) { case 'payment_intent.succeeded': await processPayment(event.data.object); break;
case 'customer.subscription.created': await createSubscription(event.data.object); break;
case 'invoice.payment_failed': await handleFailedPayment(event.data.object); break;
default: console.log('Unhandled event type:', event.type);}
// Stripe requires 200 responseres.statusCode = 200;return { received: true, eventId: event.id, type: event.type, processedAt: new Date().toISOString()};Worker mode, where shared state tracks request counts per IP:
// @mode worker// @timeout 5000// @log-level standard
// Rate limit: 10 requests per minute per IPconst RATE_LIMIT = 10;const WINDOW_MS = 60000;
// Initialize rate limit trackingif (!shared.rateLimits) { shared.rateLimits = new Map();}
const clientIp = metadata.clientIp;const now = Date.now();
// Get or create IP trackinglet ipData = shared.rateLimits.get(clientIp);if (!ipData) { ipData = { requests: [], firstRequest: now }; shared.rateLimits.set(clientIp, ipData);}
// Clean old requests outside windowipData.requests = ipData.requests.filter( timestamp => now - timestamp < WINDOW_MS);
// Check rate limitif (ipData.requests.length >= RATE_LIMIT) { const oldestRequest = Math.min(...ipData.requests); const resetIn = WINDOW_MS - (now - oldestRequest);
res.statusCode = 429; res.setHeader('Retry-After', Math.ceil(resetIn / 1000)); res.setHeader('X-RateLimit-Limit', RATE_LIMIT); res.setHeader('X-RateLimit-Remaining', 0); res.setHeader('X-RateLimit-Reset', new Date(now + resetIn).toISOString());
return { error: 'Rate limit exceeded', limit: RATE_LIMIT, window: '1 minute', retryAfter: Math.ceil(resetIn / 1000) };}
// Add this request to trackingipData.requests.push(now);
// Set rate limit headersconst remaining = RATE_LIMIT - ipData.requests.length;res.setHeader('X-RateLimit-Limit', RATE_LIMIT);res.setHeader('X-RateLimit-Remaining', remaining);res.setHeader('X-RateLimit-Reset', new Date(now + WINDOW_MS).toISOString());
// Execute actual endpoint logic (processRequest is your own function)const data = await processRequest();
return { success: true, data, rateLimit: { remaining, resetAt: new Date(now + WINDOW_MS).toISOString() }};Worker mode, intercepting and controlling AI requests:
// @mode worker// @timeout 60000// @cors reflective// @log-level full// @log-request-body true// @log-response-body true
// Initialize MITM tracking (cumulative counters live in shared state)if (!shared.aiRequests) { shared.aiRequests = []; shared.blockedCount = 0; shared.modifiedCount = 0;}
// Parse AI request (req.body is the auto-parsed JSON payload)const aiRequest = req.body;
// Log for observabilityconsole.log('AI Request:', { model: aiRequest.model, messageCount: aiRequest.messages?.length, timestamp: new Date().toISOString()});
// BLOCK: Prevent sensitive data leaksconst hasSensitiveData = aiRequest.messages?.some(msg => /api[_-]?key|password|secret|token/i.test(msg.content));
if (hasSensitiveData) { shared.blockedCount++; res.statusCode = 403; return { error: 'Blocked: Sensitive data detected', reason: 'AI request contains potential API keys or secrets', blocked: shared.blockedCount, timestamp: new Date().toISOString() };}
// MODIFY: Add system promptlet modified = false;if (aiRequest.messages[0]?.role !== 'system') { aiRequest.messages.unshift({ role: 'system', content: 'You are a helpful assistant. Be concise and accurate.' }); shared.modifiedCount++; modified = true;}
// TRACK: Store request for analysisshared.aiRequests.push({ model: aiRequest.model, messageCount: aiRequest.messages.length, timestamp: new Date().toISOString(), modified});
// Keep only last 100 requestsif (shared.aiRequests.length > 100) { shared.aiRequests.shift();}
// Forward to actual Hoody AI endpointconst hoodyAIResponse = await fetch('https://ai.hoody.com/api/v1/chat/completions', { method: 'POST', headers: { 'Authorization': req.headers.authorization, 'Content-Type': 'application/json' }, body: JSON.stringify(aiRequest)});
const result = await hoodyAIResponse.json();
// Pass the upstream status through (401, 429, 500 and so on)res.statusCode = hoodyAIResponse.status;
// Return with observability metadatareturn { ...result, _mitm: { modified, blocked: shared.blockedCount, recentRequests: shared.aiRequests.length // the last 100 at most }};See Hoody AI Intercept & Control for the complete MITM guide.