tpmjs/apps/railway-executor/server.ts
Thomas Davis 198f9f7d1e fix: separate sync discovery from enrichment and harden Railway executor
Sync system was timing out because discovery endpoints (keyword, changes)
also ran schema extraction (~10-15s per tool). Now discovery is fast
(npm metadata + DB writes only) and a new /api/sync/enrich endpoint
handles schema extraction in time-budgeted chunks.

Railway executor was crashing without restarting due to unhandled promise
rejections, no restart policy, and no health checks. Added crash
protection, graceful shutdown, cache size limits, railway.toml with
ALWAYS restart policy, and upgraded Deno from 1.39 to 2.1.9.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 04:49:54 +10:00

1017 lines
33 KiB
TypeScript

/**
* Railway Dynamic Tool Executor (Deno)
* Uses Deno's native HTTP import support
*/
// Import zod-to-json-schema for Zod v3 support
import { zodToJsonSchema } from 'https://esm.sh/zod-to-json-schema@3.25.0';
// ─── Crash Protection ───────────────────────────────────────────────────────
// Catch unhandled promise rejections so they don't crash the process
globalThis.addEventListener('unhandledrejection', (event) => {
event.preventDefault();
console.error('⚠️ Unhandled promise rejection (caught, process continues):', event.reason);
});
// Catch uncaught errors
globalThis.addEventListener('error', (event) => {
console.error('⚠️ Uncaught error (caught, process continues):', event.error || event.message);
event.preventDefault();
});
// Cache TTL: 2 minutes
const CACHE_TTL_MS = 2 * 60 * 1000;
// Max cache entries to prevent unbounded memory growth
const MAX_CACHE_SIZE = 200;
// Cache entry with expiration
interface CacheEntry {
// biome-ignore lint/suspicious/noExplicitAny: Tool types are dynamic and vary by package
module: any;
expiresAt: number;
isFactory: boolean;
}
// Cache for imported tool modules with TTL
const moduleCache = new Map<string, CacheEntry>();
/**
* Get module from cache if not expired
*/
function getCachedModule(cacheKey: string): CacheEntry | null {
const entry = moduleCache.get(cacheKey);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
moduleCache.delete(cacheKey);
console.log(`🗑️ Cache expired: ${cacheKey}`);
return null;
}
return entry;
}
/**
* Store module in cache with TTL
*/
// biome-ignore lint/suspicious/noExplicitAny: Tool types are dynamic and vary by package
function setCachedModule(cacheKey: string, module: any, isFactory: boolean): void {
// Evict oldest entries if cache is full
if (moduleCache.size >= MAX_CACHE_SIZE) {
const entriesToEvict = Math.max(1, Math.floor(MAX_CACHE_SIZE * 0.2)); // Evict 20%
const keys = Array.from(moduleCache.keys());
for (let i = 0; i < entriesToEvict && i < keys.length; i++) {
moduleCache.delete(keys[i]);
}
console.log(`🗑️ Evicted ${entriesToEvict} cache entries (cache was full at ${MAX_CACHE_SIZE})`);
}
moduleCache.set(cacheKey, {
module,
expiresAt: Date.now() + CACHE_TTL_MS,
isFactory,
});
console.log(`📦 Cached (TTL ${CACHE_TTL_MS / 1000}s): ${cacheKey}`);
}
/**
* Cleanup expired cache entries
*/
function cleanupExpiredCache(): void {
const now = Date.now();
let cleaned = 0;
for (const [key, entry] of moduleCache.entries()) {
if (now > entry.expiresAt) {
moduleCache.delete(key);
cleaned++;
}
}
if (cleaned > 0) {
console.log(`🗑️ Cleaned ${cleaned} expired cache entries`);
}
}
// Run cache cleanup every minute
setInterval(cleanupExpiredCache, 60 * 1000);
// Web app API URL for health status reporting
const TPMJS_API_URL = Deno.env.get('TPMJS_API_URL') || 'https://tpmjs.com';
/**
* Report tool execution result to centralized health service
* Non-blocking - fires and forgets to avoid slowing down execution
*/
async function reportToolHealth(
packageName: string,
name: string,
success: boolean,
error?: string
): Promise<void> {
try {
const response = await fetch(`${TPMJS_API_URL}/api/tools/report-health`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
packageName,
name,
success,
error,
}),
});
if (response.ok) {
console.log(
`📊 Health reported for ${packageName}/${name}: ${success ? 'SUCCESS' : 'FAILURE'}`
);
} else {
console.warn(`⚠️ Failed to report health: ${response.status}`);
}
} catch (err) {
// Non-blocking - just log
console.error('❌ Failed to report tool health:', err);
}
}
/**
* Update tool schema in TPM.js database
* Non-blocking - fires and forgets to keep schemas up to date
*/
async function updateToolSchema(
packageName: string,
name: string,
description: string,
// biome-ignore lint/suspicious/noExplicitAny: JSON Schema can have any structure
inputSchema: any
): Promise<void> {
try {
const response = await fetch(`${TPMJS_API_URL}/api/tools/update-schema`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
packageName,
name,
description,
inputSchema,
}),
});
if (response.ok) {
const data = await response.json();
console.log(
`📋 Schema updated for ${packageName}/${name}:`,
data.updated ? 'UPDATED' : 'NO CHANGE'
);
} else {
const errorText = await response.text();
console.warn(`⚠️ Failed to update schema: ${response.status}`, errorText);
}
} catch (err) {
// Non-blocking - just log
console.error('❌ Failed to update tool schema:', err);
}
}
/**
* Sanitize JSON Schema to fix common issues
* - Replaces invalid type "None" with "object"
* - Ensures type is always set
* - Ensures object schemas have properties
*/
// biome-ignore lint/suspicious/noExplicitAny: JSON Schema can have any structure
function sanitizeJsonSchema(schema: any): any {
if (!schema || typeof schema !== 'object') {
console.warn('⚠️ Invalid schema (not an object), returning default object schema');
return { type: 'object', properties: {}, additionalProperties: false };
}
// Clone the schema to avoid mutating the original
const sanitized = { ...schema };
// Fix invalid type "None" (common in Python-based tools)
if (sanitized.type === 'None' || sanitized.type === 'none' || sanitized.type === null) {
console.warn(`⚠️ Invalid schema type "${sanitized.type}", replacing with "object"`);
sanitized.type = 'object';
if (!sanitized.properties) {
sanitized.properties = {};
}
if (sanitized.additionalProperties === undefined) {
sanitized.additionalProperties = false;
}
}
// Ensure type is set
if (!sanitized.type) {
console.warn('⚠️ Schema missing type, defaulting to "object"');
sanitized.type = 'object';
if (!sanitized.properties) {
sanitized.properties = {};
}
if (sanitized.additionalProperties === undefined) {
sanitized.additionalProperties = false;
}
}
// Recursively sanitize nested schemas
if (sanitized.properties && typeof sanitized.properties === 'object') {
for (const [key, value] of Object.entries(sanitized.properties)) {
if (value && typeof value === 'object') {
sanitized.properties[key] = sanitizeJsonSchema(value);
}
}
}
// Sanitize array items
if (sanitized.items && typeof sanitized.items === 'object') {
sanitized.items = sanitizeJsonSchema(sanitized.items);
}
// Sanitize anyOf/oneOf/allOf
for (const key of ['anyOf', 'oneOf', 'allOf']) {
if (Array.isArray(sanitized[key])) {
// biome-ignore lint/suspicious/noExplicitAny: JSON Schema types are dynamic
sanitized[key] = sanitized[key].map((s: any) => sanitizeJsonSchema(s));
}
}
return sanitized;
}
/**
* Load and describe a tool from esm.sh
*/
async function loadAndDescribe(req: Request): Promise<Response> {
try {
const body = await req.json();
const { packageName, name, version, importUrl, env } = body;
if (!packageName || !name || !version) {
return Response.json(
{
success: false,
error: 'Missing required fields: packageName, name, version',
},
{ status: 400 }
);
}
const cacheKey = `${packageName}::${name}`;
// biome-ignore lint/suspicious/noImplicitAnyLet: Tool type is determined dynamically after import
let toolModule;
// Check cache first (with TTL)
const cachedEntry = getCachedModule(cacheKey);
if (cachedEntry) {
console.log(`✅ Cache hit: ${cacheKey}`);
toolModule = cachedEntry.module;
} else {
// Dynamic import from esm.sh (Deno supports this natively!)
const url = importUrl || `https://esm.sh/${packageName}@${version}`;
console.log(`📦 Importing: ${url}`);
const module = await import(url);
let rawExport = module[name];
if (!rawExport) {
console.error(`❌ Export "${name}" not found. Available:`, Object.keys(module));
return Response.json(
{
success: false,
error: `Export "${name}" not found in module`,
availableExports: Object.keys(module),
},
{ status: 404 }
);
}
// Check if it's a factory function (not a direct tool)
if (typeof rawExport === 'function' && !rawExport.description && !rawExport.execute) {
console.log(`🏭 Detected factory function for ${cacheKey}, attempting to call...`);
let factoryResult = null;
// Strategy 1: Try calling with no arguments
try {
console.log(` Trying: ${name}()`);
factoryResult = rawExport();
if (factoryResult?.description && factoryResult?.execute) {
console.log(' ✅ Success with no-args factory');
rawExport = factoryResult;
}
} catch (error) {
console.log(' ❌ No-args failed:', error.message);
}
// Strategy 2: Try calling with env vars as config object
if (!factoryResult && env && typeof env === 'object') {
// Build multiple config variations to try
const configVariations = [];
// Variation 1: Raw env vars (e.g., { VALYU_API_KEY: 'xxx' })
configVariations.push({ ...env });
// Variation 2: Normalized to camelCase apiKey (e.g., { apiKey: 'xxx' })
const apiKeyValue = Object.entries(env).find(([key]) =>
key.toUpperCase().includes('API_KEY')
)?.[1];
if (apiKeyValue) {
configVariations.push({ apiKey: apiKeyValue });
}
// Variation 3: Normalized to key (e.g., { key: 'xxx' })
if (apiKeyValue) {
configVariations.push({ key: apiKeyValue });
}
// Try each config variation
for (const config of configVariations) {
try {
console.log(` Trying: ${name}(`, Object.keys(config), ')');
factoryResult = rawExport(config);
if (factoryResult?.description && factoryResult?.execute) {
console.log(' ✅ Success with config:', Object.keys(config));
rawExport = factoryResult;
break;
}
} catch (error) {
console.log(' ❌ Config', Object.keys(config), 'failed:', error.message);
}
}
}
// Strategy 3: Try calling with first env var value (single-arg pattern)
if (!factoryResult && env && typeof env === 'object') {
try {
const firstValue = Object.values(env)[0];
if (firstValue) {
console.log(` Trying: ${name}(firstEnvValue)`);
factoryResult = rawExport(firstValue);
if (factoryResult?.description && factoryResult?.execute) {
console.log(' ✅ Success with single-arg factory');
rawExport = factoryResult;
}
}
} catch (error) {
console.log(' ❌ Single-arg failed:', error.message);
}
}
// If all factory strategies failed, return error
if (!factoryResult) {
console.error('❌ Factory function detected but all call strategies failed');
return Response.json(
{
success: false,
error: `Tool "${name}" is a factory function but couldn't be initialized. Tried: no-args, config object, and single-arg patterns.`,
hint: 'This tool may require specific configuration. Check package documentation.',
},
{ status: 400 }
);
}
}
toolModule = rawExport;
// Validate it's an AI SDK tool
if (!toolModule.description || !toolModule.execute) {
console.error('❌ Invalid AI SDK tool structure:', {
hasDescription: !!toolModule.description,
hasExecute: !!toolModule.execute,
hasInputSchema: !!toolModule.inputSchema,
keys: Object.keys(toolModule),
});
return Response.json(
{
success: false,
error: 'Invalid AI SDK tool structure (missing description or execute)',
toolKeys: Object.keys(toolModule),
},
{ status: 400 }
);
}
// Cache it with TTL (mark as non-factory for loadAndDescribe)
setCachedModule(cacheKey, toolModule, false);
}
// Extract tool definition - try multiple schema formats
let rawJsonSchema = null;
if (toolModule.inputSchema) {
// Strategy 1: Try Zod v4 native JSON Schema export
if (typeof toolModule.inputSchema.toJSONSchema === 'function') {
console.log(`📋 Using Zod v4 toJSONSchema() for ${cacheKey}`);
try {
rawJsonSchema = toolModule.inputSchema.toJSONSchema();
} catch (error) {
console.warn(`⚠️ Zod toJSONSchema() failed for ${cacheKey}:`, error);
}
} else if (typeof toolModule.inputSchema.jsonSchema === 'function') {
console.log(`📋 Using Zod v4 jsonSchema() for ${cacheKey}`);
try {
rawJsonSchema = toolModule.inputSchema.jsonSchema();
} catch (error) {
console.warn(`⚠️ Zod jsonSchema() failed for ${cacheKey}:`, error);
}
}
// Strategy 2: Try AI SDK v6 jsonSchema() wrapper (has .schema property)
if (!rawJsonSchema && toolModule.inputSchema.schema) {
console.log(`📋 Using AI SDK jsonSchema.schema for ${cacheKey}`);
rawJsonSchema = toolModule.inputSchema.schema;
}
// Strategy 2.5: Try AI SDK jsonSchema() wrapper (has .jsonSchema property)
// Note: Some versions use .jsonSchema instead of .schema
if (
!rawJsonSchema &&
toolModule.inputSchema.jsonSchema &&
typeof toolModule.inputSchema.jsonSchema === 'object'
) {
console.log(`📋 Using AI SDK jsonSchema.jsonSchema for ${cacheKey}`);
rawJsonSchema = toolModule.inputSchema.jsonSchema;
}
// Strategy 3: Try Zod v4 schema (detect via _zod property - new in Zod v4)
if (!rawJsonSchema && toolModule.inputSchema._zod) {
console.log(`📋 Detected Zod v4 schema for ${cacheKey}`);
try {
// Dynamically import Zod v4 and use its toJSONSchema method
const zod = await import('https://esm.sh/zod@4');
if (zod.toJSONSchema) {
rawJsonSchema = zod.toJSONSchema(toolModule.inputSchema);
console.log(
`✅ Successfully converted Zod v4 schema using z.toJSONSchema for ${cacheKey}`
);
} else if (zod.default?.toJSONSchema) {
rawJsonSchema = zod.default.toJSONSchema(toolModule.inputSchema);
console.log(
`✅ Successfully converted Zod v4 schema using z.default.toJSONSchema for ${cacheKey}`
);
} else {
console.warn('⚠️ Zod v4 toJSONSchema not found. Available exports:', Object.keys(zod));
}
} catch (error) {
console.warn(`⚠️ Zod v4 toJSONSchema conversion failed for ${cacheKey}:`, error);
}
}
// Strategy 4: Try Zod v3 schema (detect via _def property and convert)
if (!rawJsonSchema && toolModule.inputSchema._def) {
console.log(
`📋 Detected Zod schema (v3), converting with zod-to-json-schema for ${cacheKey}`
);
try {
rawJsonSchema = zodToJsonSchema(toolModule.inputSchema);
console.log(`✅ Successfully converted Zod schema for ${cacheKey}`);
} catch (error) {
console.warn(`⚠️ zod-to-json-schema conversion failed for ${cacheKey}:`, error);
}
}
}
// If no schema found, fail with helpful error
if (!rawJsonSchema) {
console.error(`❌ No valid schema found for ${cacheKey}`, {
hasInputSchema: !!toolModule.inputSchema,
inputSchemaType: typeof toolModule.inputSchema,
hasToJSONSchema: typeof toolModule.inputSchema?.toJSONSchema === 'function',
hasJsonSchemaFunction: typeof toolModule.inputSchema?.jsonSchema === 'function',
hasJsonSchemaProperty:
!!toolModule.inputSchema?.jsonSchema &&
typeof toolModule.inputSchema?.jsonSchema === 'object',
hasSchema: !!toolModule.inputSchema?.schema,
keys: toolModule.inputSchema ? Object.keys(toolModule.inputSchema) : [],
});
return Response.json(
{
success: false,
error: `Tool "${name}" has no valid inputSchema. Tools must use AI SDK jsonSchema(), Zod v4 (._zod), or Zod v3 (._def) schemas.`,
debug: {
hasInputSchema: !!toolModule.inputSchema,
availableMethods: toolModule.inputSchema ? Object.keys(toolModule.inputSchema) : [],
hasZodV4: !!toolModule.inputSchema?._zod,
hasZodV3Def: !!toolModule.inputSchema?._def,
},
},
{ status: 400 }
);
}
console.log(`✅ Extracted schema for ${cacheKey}`);
// Sanitize schema - fix common issues with invalid schemas
const sanitizedSchema = sanitizeJsonSchema(rawJsonSchema);
// Update TPM.js database with the schema (async, non-blocking)
updateToolSchema(packageName, name, toolModule.description, sanitizedSchema).catch((err) => {
console.warn('⚠️ Failed to update schema in database:', err);
});
return Response.json({
success: true,
tool: {
name,
description: toolModule.description,
inputSchema: sanitizedSchema, // Plain JSON Schema - fully serializable
},
});
} catch (error) {
console.error('❌ Failed to load tool:', error);
return Response.json(
{
success: false,
error: error.message,
},
{ status: 500 }
);
}
}
/**
* Execute a tool with parameters
*/
async function executeTool(req: Request): Promise<Response> {
const startTime = Date.now();
// Declare these before try block so they're available in catch for error reporting
let packageName = 'unknown';
let toolName = 'unknown';
try {
const body = await req.json();
const { packageName: pkg, name, version, importUrl, params, env } = body;
packageName = pkg || 'unknown';
toolName = name || 'unknown';
console.log('📥 Execute request:', {
packageName,
name: toolName,
version,
envKeys: env ? Object.keys(env) : [],
envValues: env || {},
});
if (!packageName || !toolName || !version) {
return Response.json(
{
success: false,
error: 'Missing required fields: packageName, name, version',
},
{ status: 400 }
);
}
const cacheKey = `${packageName}::${toolName}`;
// Inject environment variables FIRST - before cache check and factory calls
// This ensures process.env is set when factory functions read from it
if (env && typeof env === 'object') {
const envKeys = Object.keys(env);
if (envKeys.length > 0) {
console.log(`🔐 Injecting ${envKeys.length} environment variables:`, envKeys);
for (const [key, value] of Object.entries(env)) {
const stringValue = String(value);
// Set in Deno environment (for esm.sh imports)
Deno.env.set(key, stringValue);
// ALSO set in Node.js process.env (for npm: imports)
// @ts-expect-error - process is available in Node.js compatibility mode
if (typeof globalThis.process !== 'undefined' && globalThis.process.env) {
// @ts-expect-error - process.env exists in Node compat mode
globalThis.process.env[key] = stringValue;
}
console.log(` ✅ Set ${key} = ${stringValue.substring(0, 10)}...`);
}
} else {
console.log('⚠️ No env vars provided in request');
}
} else {
console.log('⚠️ No env object in request body');
}
// biome-ignore lint/suspicious/noImplicitAnyLet: Tool type is determined dynamically after import
let toolModule;
let needsImport = true;
// Check cache first (with TTL) - but skip cache for factory functions
// since they may read env vars at creation time
const cachedEntry = getCachedModule(cacheKey);
if (cachedEntry && !cachedEntry.isFactory) {
console.log(`✅ Cache hit (non-factory): ${cacheKey}`);
toolModule = cachedEntry.module;
needsImport = false;
} else if (cachedEntry?.isFactory) {
console.log(`🏭 Cache hit but factory - will re-import: ${cacheKey}`);
}
if (needsImport) {
const url = importUrl || `https://esm.sh/${packageName}@${version}`;
console.log(`📦 Importing for execution: ${url}`);
const module = await import(url);
let rawExport = module[toolName];
if (!rawExport) {
return Response.json(
{
success: false,
error: 'Tool not found',
executionTimeMs: Date.now() - startTime,
},
{ status: 404 }
);
}
// Track if this is a factory function - we won't cache factory-created tools
// because they may read env vars at creation time
let isFactoryFunction = false;
// Check if it's a factory function (not a direct tool)
if (typeof rawExport === 'function' && !rawExport.description && !rawExport.execute) {
console.log(`🏭 Detected factory function for ${cacheKey}, attempting to call...`);
isFactoryFunction = true;
let factoryResult = null;
// Strategy 1: Try calling with no arguments
try {
console.log(` Trying: ${toolName}()`);
factoryResult = rawExport();
if (factoryResult?.execute) {
console.log(' ✅ Success with no-args factory');
rawExport = factoryResult;
}
} catch (error) {
console.log(' ❌ No-args failed:', error.message);
}
// Strategy 2: Try calling with env vars as config object
if (!factoryResult && env && typeof env === 'object') {
// Build multiple config variations to try
const configVariations = [];
// Variation 1: Raw env vars (e.g., { VALYU_API_KEY: 'xxx' })
configVariations.push({ ...env });
// Variation 2: Normalized to camelCase apiKey (e.g., { apiKey: 'xxx' })
const apiKeyValue = Object.entries(env).find(([key]) =>
key.toUpperCase().includes('API_KEY')
)?.[1];
if (apiKeyValue) {
configVariations.push({ apiKey: apiKeyValue });
}
// Variation 3: Normalized to key (e.g., { key: 'xxx' })
if (apiKeyValue) {
configVariations.push({ key: apiKeyValue });
}
// Try each config variation
for (const config of configVariations) {
try {
console.log(` Trying: ${toolName}(`, Object.keys(config), ')');
factoryResult = rawExport(config);
if (factoryResult?.execute) {
console.log(' ✅ Success with config:', Object.keys(config));
rawExport = factoryResult;
break;
}
} catch (error) {
console.log(' ❌ Config', Object.keys(config), 'failed:', error.message);
}
}
}
// Strategy 3: Try calling with first env var value (single-arg pattern)
if (!factoryResult && env && typeof env === 'object') {
try {
const firstValue = Object.values(env)[0];
if (firstValue) {
console.log(` Trying: ${toolName}(firstEnvValue)`);
factoryResult = rawExport(firstValue);
if (factoryResult?.execute) {
console.log(' ✅ Success with single-arg factory');
rawExport = factoryResult;
}
}
} catch (error) {
console.log(' ❌ Single-arg failed:', error.message);
}
}
if (!factoryResult) {
return Response.json(
{
success: false,
error: `Tool "${toolName}" is a factory function but couldn't be initialized`,
executionTimeMs: Date.now() - startTime,
},
{ status: 400 }
);
}
}
toolModule = rawExport;
if (!toolModule.execute) {
return Response.json(
{
success: false,
error: 'Tool missing execute function',
executionTimeMs: Date.now() - startTime,
},
{ status: 400 }
);
}
// Cache with TTL - mark factory functions so we know to re-import them
setCachedModule(cacheKey, toolModule, isFactoryFunction);
}
// Note: Environment variables are already injected at the start of this function
// before cache check and factory calls, so they're available when tools read process.env
// Execute the tool with AI SDK execution context
// Some tools expect a second argument with { abortSignal, ... }
const abortController = new AbortController();
const executionContext = {
abortSignal: abortController.signal,
// Add other context properties that AI SDK tools might expect
messages: [],
toolCallId: `exec_${Date.now()}`,
};
console.log(`🚀 Executing ${cacheKey} with params:`, params);
const result = await toolModule.execute(params || {}, executionContext);
const executionTimeMs = Date.now() - startTime;
console.log(`✅ Execution complete in ${executionTimeMs}ms`);
// Report successful execution to health service (non-blocking)
reportToolHealth(packageName, toolName, true).catch(() => {});
return Response.json({
success: true,
output: result,
executionTimeMs,
});
} catch (error) {
const executionTimeMs = Date.now() - startTime;
console.error('❌ Tool execution failed:', error);
// Report failed execution to health service (non-blocking)
reportToolHealth(packageName, toolName, false, error.message).catch(() => {});
return Response.json(
{
success: false,
error: error.message,
executionTimeMs,
},
{ status: 500 }
);
}
}
/**
* List all exports from a package and identify which are valid AI SDK tools
*/
async function listExports(req: Request): Promise<Response> {
try {
const body = await req.json();
const { packageName, version, importUrl, env } = body;
if (!packageName || !version) {
return Response.json(
{
success: false,
error: 'Missing required fields: packageName, version',
},
{ status: 400 }
);
}
// Dynamic import from esm.sh
const url = importUrl || `https://esm.sh/${packageName}@${version}`;
console.log(`📦 Listing exports from: ${url}`);
const module = await import(url);
const allExports = Object.keys(module);
// Filter out 'default' and identify which exports are valid tools
const tools: Array<{
name: string;
isValidTool: boolean;
description?: string;
error?: string;
}> = [];
for (const exportKey of allExports) {
if (exportKey === 'default') continue;
let rawExport = module[exportKey];
// Check if it's a factory function
if (typeof rawExport === 'function' && !rawExport.description && !rawExport.execute) {
// Try to call factory with no args
try {
const factoryResult = rawExport();
if (factoryResult?.description && factoryResult?.execute) {
rawExport = factoryResult;
} else if (env && typeof env === 'object') {
// Try with env config
const configResult = rawExport({ ...env });
if (configResult?.description && configResult?.execute) {
rawExport = configResult;
}
}
} catch {
// Factory call failed, continue checking
}
}
// Check if it's a valid AI SDK tool
if (rawExport?.description && rawExport?.execute) {
tools.push({
name: exportKey,
isValidTool: true,
description: rawExport.description,
});
} else if (typeof rawExport === 'object' && rawExport !== null) {
// It's an object but not a valid tool - might be a factory that needs specific config
tools.push({
name: exportKey,
isValidTool: false,
error: 'Not a valid AI SDK tool (missing description or execute)',
});
}
// Skip non-object exports (they're definitely not tools)
}
console.log(`✅ Found ${tools.length} potential tool exports in ${packageName}`);
return Response.json({
success: true,
packageName,
version,
exports: allExports,
tools,
});
} catch (error) {
console.error('❌ Failed to list exports:', error);
return Response.json(
{
success: false,
error: error.message,
},
{ status: 500 }
);
}
}
// Track startup time for uptime reporting
const startedAt = Date.now();
/**
* Health check
*/
function health(): Response {
return Response.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptimeSeconds: Math.floor((Date.now() - startedAt) / 1000),
cacheSize: moduleCache.size,
maxCacheSize: MAX_CACHE_SIZE,
denoVersion: Deno.version.deno,
v8Version: Deno.version.v8,
httpImports: true,
});
}
/**
* Cache stats
*/
function cacheStats(): Response {
const now = Date.now();
const entries = Array.from(moduleCache.entries()).map(([key, entry]) => ({
key,
isFactory: entry.isFactory,
expiresIn: Math.max(0, Math.round((entry.expiresAt - now) / 1000)),
}));
return Response.json({
success: true,
cacheSize: moduleCache.size,
ttlSeconds: CACHE_TTL_MS / 1000,
cachedTools: entries,
});
}
/**
* Clear cache
*/
function clearCache(): Response {
const size = moduleCache.size;
moduleCache.clear();
console.log(`🗑️ Cleared cache (${size} entries)`);
return Response.json({
success: true,
message: `Cleared ${size} cached modules`,
});
}
/**
* Main request handler — wrapped with crash protection so no single request
* can take down the process.
*/
async function handler(req: Request): Promise<Response> {
// Reject requests during shutdown
if (isShuttingDown) {
return new Response('Service shutting down', { status: 503 });
}
const url = new URL(req.url);
// Add CORS headers
const headers = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
if (req.method === 'OPTIONS') {
return new Response(null, { headers });
}
try {
let response: Response;
if (url.pathname === '/health' && req.method === 'GET') {
response = health();
} else if (url.pathname === '/load-and-describe' && req.method === 'POST') {
response = await loadAndDescribe(req);
} else if (url.pathname === '/list-exports' && req.method === 'POST') {
response = await listExports(req);
} else if (url.pathname === '/execute-tool' && req.method === 'POST') {
response = await executeTool(req);
} else if (url.pathname === '/cache/stats' && req.method === 'GET') {
response = cacheStats();
} else if (url.pathname === '/cache/clear' && req.method === 'POST') {
response = clearCache();
} else {
response = Response.json({ error: 'Not found' }, { status: 404 });
}
// Add CORS headers to response
Object.entries(headers).forEach(([key, value]) => {
response.headers.set(key, value);
});
return response;
} catch (error) {
console.error('Request handler error:', error);
return Response.json(
{
success: false,
error: error.message,
},
{ status: 500, headers }
);
}
}
// ─── Graceful Shutdown ──────────────────────────────────────────────────────
let isShuttingDown = false;
function handleShutdown(signal: string) {
if (isShuttingDown) return;
isShuttingDown = true;
console.log(`\n🛑 Received ${signal}, shutting down gracefully...`);
moduleCache.clear();
// Give in-flight requests a moment to complete
setTimeout(() => {
console.log('👋 Goodbye');
Deno.exit(0);
}, 5000);
}
Deno.addSignalListener('SIGTERM', () => handleShutdown('SIGTERM'));
Deno.addSignalListener('SIGINT', () => handleShutdown('SIGINT'));
// Start server
const port = Number.parseInt(Deno.env.get('PORT') || '3002', 10);
console.log(`🚀 Railway Tool Executor (Deno) running on port ${port}`);
console.log('📦 HTTP imports: ENABLED');
console.log(`🔗 Health check: http://localhost:${port}/health`);
console.log('🛠️ Endpoints:');
console.log(' POST /load-and-describe - Load tool and get schema');
console.log(' POST /list-exports - List all exports and identify valid tools');
console.log(' POST /execute-tool - Execute a tool with params');
console.log(' POST /cache/clear - Clear module cache');
console.log(' GET /cache/stats - Get cache statistics');
Deno.serve({ port }, handler);