Execution Modes
Section titled “Execution Modes”Every Hoody Exec script declares an execution mode. The mode decides whether the script gets one persistent VM or a fresh VM 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 persistent VM 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 the container restarts.
Worker mode features
Section titled “Worker mode features”- Persistent VM: created 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 them
- Unlimited concurrency by default: simultaneous requests are handled in parallel; cap parallelism with
@concurrent Nwhen you need to (@concurrentworks in both modes) - Pre/post middleware:
pre.tsandpost.tswrap requests matching a script in the same directory (middleware runs in both worker and serverless mode) - No cold start: the VM is already warm
Worker mode limitations
Section titled “Worker mode limitations”- State is lost on container restart (use SQLite for persistence)
- Higher memory usage, from the persistent VM overhead
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 pay no cold start 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”// @mode serverless // or omit (default)Serverless mode creates a brand new VM for every single request, the model used by AWS Lambda, Vercel Functions, and Cloudflare Workers. Requests are fully isolated from each other: state from one request is never visible to the next, memory is not shared, and side effects do not carry over.
Serverless mode features
Section titled “Serverless mode features”- Fresh context: a brand new VM for every request
- Complete isolation: no state leaks between requests
- Concurrency control:
@concurrent 5limits parallel execution (also available in worker mode) - Lower memory: no persistent VM to keep around
- Stronger isolation guarantees for code you do not fully trust
Serverless mode limitations
Section titled “Serverless mode limitations”- No WebSocket support, since there are no persistent connections
- No shared state across requests
- Slight per-request overhead from creating the VM
Workloads that fit serverless mode
Section titled “Workloads that fit serverless mode”- Webhook receivers (Stripe, GitHub, Slack)
- Isolated tasks (data processing, API calls)
- Sporadic traffic, on a pay-per-execution model
- Stateless microservices
- Untrusted user scripts, which get better isolation here
- Anything where isolation matters more than performance
Shared state
Section titled “Shared state”The shared object exists in both modes, but behaves differently in each:
// @mode worker
// 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
// This always starts freshif (!shared.requestCount) { shared.requestCount = 0; // runs on every request}
shared.requestCount++; // Always equals 1 (resets each time)
return { count: shared.requestCount // Always returns 1};Key differences:
- Worker:
sharedpersists between requests, which is what makes caching, counters, and sessions work - Serverless:
sharedresets on every request, so it buys you no persistence; use it for request-scoped temporary 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 hostname, so each exec instance has its own shared object. There is no automatic TTL, and values persist for the entire lifetime of the server process:
- Data stays in
shareduntil you delete it or the container 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 timestampshared.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 creates a fresh VM on every request, so every request pays a small overhead for VM creation and script compilation. That is the cold start cost.
Worker mode pays that cost on the first request only. After that the persistent VM stays warm and subsequent requests have no cold start at all. The VM remains cached until it is cleared through the cache-clear API or the container restarts.
| First request | Subsequent requests | |
|---|---|---|
| Serverless | VM creation + compilation | VM creation + compilation (same cost every time) |
| Worker | VM creation + compilation | Zero overhead (VM already warm) |
For latency-sensitive endpoints under frequent traffic, worker mode removes that overhead from every request after the first, for as long as the VM stays cached.
Memory considerations
Section titled “Memory considerations”Worker mode’s persistent VM retains every variable between requests:
- Memory accumulates: every
Map,Set, array, or object added toshared(or to any persistent variable) stays in memory - Shared data is not garbage collected: only unreferenced local variables are collected between requests
- 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 }}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 VM cache and shared state (full reset)// POST /api/v1/exec/cache/clear with body { "clearAll": true } (or { "clearVm": true, "clearState": true })
// 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: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)shared.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, fresh VM and no state:
// @mode serverless// @concurrent 10// @cors reflective// @timeout 5000// @log-level standard
const userId = metadata.parameters.id;
// Validate inputif (!userId || userId.length !== 24) { res.statusCode = 400; return { error: 'Invalid user ID format', expected: '24-character hex string' };}
// Fresh database query every time (no cache)const user = await fetchUserFromDatabase(userId);
if (!user) { res.statusCode = 404; return { error: 'User not found', userId };}
// Clean response (isolated execution)return { user, requestId: metadata.executionId};Worker mode only, since WebSocket requires a persistent VM:
// @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 (note: ws.broadcast reaches all rooms on this hostname) 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 isolation keeps one webhook’s state out of the next:
// @mode serverless// @concurrent false // Process webhooks serially// @cors none// @timeout 30000// @log-level full// @log-request-body true
// Validate webhook signatureconst signature = req.headers['stripe-signature'];if (!signature) { res.statusCode = 401; return { error: 'Missing signature', message: 'Stripe-Signature header required' };}
// Parse webhook payloadlet event;try { // req.rawBody is the raw request Buffer (preserved for signature verification) const rawBody = req.rawBody.toString(); event = JSON.parse(rawBody);} catch (err) { res.statusCode = 400; return { error: 'Invalid payload', message: err.message };}
// Handle event typesswitch (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 logicconst 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 trackingif (!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 promptif (aiRequest.messages[0]?.role !== 'system') { aiRequest.messages.unshift({ role: 'system', content: 'You are a helpful assistant. Be concise and accurate.' }); shared.modifiedCount++;}
// TRACK: Store request for analysisshared.aiRequests.push({ model: aiRequest.model, messageCount: aiRequest.messages.length, timestamp: new Date().toISOString(), modified: shared.modifiedCount > 0});
// 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();
// Return with observability metadatareturn { ...result, _mitm: { modified: shared.modifiedCount > 0, blocked: shared.blockedCount, totalRequests: shared.aiRequests.length }};See Hoody AI Intercept & Control for the complete MITM guide.