tpmjs/apps/railway-executor/server.ts
Ajax Davis 8f27d1cfa7 fix(executor): declare packageName/exportName before try block
Same issue as startTime - these variables were destructured inside the
try block but referenced in the catch block for health reporting. If
JSON parsing or any early error occurred, the catch block would crash
with 'packageName is not defined'.

Now declares them with 'unknown' defaults before try, then assigns
the actual values inside.
2025-12-12 06:14:08 +10:00

723 lines
24 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';
// Cache for imported tool modules
// biome-ignore lint/suspicious/noExplicitAny: Tool types are dynamic and vary by package
const moduleCache = new Map<string, any>();
// 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,
exportName: 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,
exportName,
success,
error,
}),
});
if (response.ok) {
console.log(
`📊 Health reported for ${packageName}/${exportName}: ${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);
}
}
/**
* 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])) {
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, exportName, version, importUrl, env } = body;
if (!packageName || !exportName || !version) {
return Response.json(
{
success: false,
error: 'Missing required fields: packageName, exportName, version',
},
{ status: 400 }
);
}
const cacheKey = `${packageName}::${exportName}`;
// biome-ignore lint/suspicious/noImplicitAnyLet: Tool type is determined dynamically after import
let toolModule;
// Check cache first
if (moduleCache.has(cacheKey)) {
console.log(`✅ Cache hit: ${cacheKey}`);
toolModule = moduleCache.get(cacheKey);
} 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[exportName];
if (!rawExport) {
console.error(`❌ Export "${exportName}" not found. Available:`, Object.keys(module));
return Response.json(
{
success: false,
error: `Export "${exportName}" 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: ${exportName}()`);
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: ${exportName}(`, 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: ${exportName}(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 "${exportName}" 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
moduleCache.set(cacheKey, toolModule);
console.log(`✅ Cached: ${cacheKey}`);
}
// 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 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 "${exportName}" has no valid inputSchema. Tools must use AI SDK jsonSchema(), Zod v4 toJSONSchema(), or Zod v3 schemas.`,
debug: {
hasInputSchema: !!toolModule.inputSchema,
availableMethods: toolModule.inputSchema ? Object.keys(toolModule.inputSchema) : [],
hasZodDef: !!toolModule.inputSchema?._def,
},
},
{ status: 400 }
);
}
console.log(`✅ Extracted schema for ${cacheKey}`);
// Sanitize schema - fix common issues with invalid schemas
const sanitizedSchema = sanitizeJsonSchema(rawJsonSchema);
return Response.json({
success: true,
tool: {
exportName,
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 exportName = 'unknown';
try {
const body = await req.json();
const { packageName: pkg, exportName: exp, version, importUrl, params, env } = body;
packageName = pkg || 'unknown';
exportName = exp || 'unknown';
console.log('📥 Execute request:', {
packageName,
exportName,
version,
envKeys: env ? Object.keys(env) : [],
envValues: env || {},
});
if (!packageName || !exportName || !version) {
return Response.json(
{
success: false,
error: 'Missing required fields: packageName, exportName, version',
},
{ status: 400 }
);
}
const cacheKey = `${packageName}::${exportName}`;
// biome-ignore lint/suspicious/noImplicitAnyLet: Tool type is determined dynamically after import
let toolModule;
// Check cache or import
if (moduleCache.has(cacheKey)) {
console.log(`✅ Using cached tool: ${cacheKey}`);
toolModule = moduleCache.get(cacheKey);
} else {
const url = importUrl || `https://esm.sh/${packageName}@${version}`;
console.log(`📦 Importing for execution: ${url}`);
const module = await import(url);
let rawExport = module[exportName];
if (!rawExport) {
return Response.json(
{
success: false,
error: 'Tool not found',
executionTimeMs: Date.now() - startTime,
},
{ 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: ${exportName}()`);
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: ${exportName}(`, 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: ${exportName}(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 "${exportName}" 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 }
);
}
moduleCache.set(cacheKey, toolModule);
}
// Inject environment variables from client
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-ignore - process is available in Node.js compatibility mode
if (typeof globalThis.process !== 'undefined' && globalThis.process.env) {
// @ts-ignore - process.env exists in Node compat mode
globalThis.process.env[key] = stringValue;
}
console.log(` ✅ Set ${key} = ${stringValue.substring(0, 10)}...`);
}
// Verify they're set in both places
console.log(
'🔍 Verification - Deno.env has:',
envKeys.map((k) => `${k}=${Deno.env.get(k)?.substring(0, 10)}...`)
);
// @ts-ignore - process is available in Node.js compatibility mode
if (typeof globalThis.process !== 'undefined' && globalThis.process.env) {
console.log(
'🔍 Verification - process.env has:',
// @ts-ignore - process.env exists in Node compat mode
envKeys.map((k) => `${k}=${globalThis.process.env[k]?.substring(0, 10)}...`)
);
}
} else {
console.log('⚠️ No env vars provided in request');
}
} else {
console.log('⚠️ No env object in request body');
}
// 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, exportName, 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, exportName, false, error.message).catch(() => {});
return Response.json(
{
success: false,
error: error.message,
executionTimeMs,
},
{ status: 500 }
);
}
}
/**
* Health check
*/
function health(): Response {
return Response.json({
status: 'ok',
timestamp: new Date().toISOString(),
cacheSize: moduleCache.size,
denoVersion: Deno.version.deno,
v8Version: Deno.version.v8,
httpImports: true,
});
}
/**
* Cache stats
*/
function cacheStats(): Response {
const entries = Array.from(moduleCache.keys());
return Response.json({
success: true,
cacheSize: moduleCache.size,
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
*/
async function handler(req: Request): Promise<Response> {
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 === '/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 }
);
}
}
// Start server
const port = Number.parseInt(Deno.env.get('PORT') || '3002');
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 /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);