PROBLEM:
DefaultChatTransport body is cloned ONCE on mount, so env vars
were always empty {} even after localStorage loaded them.
SOLUTION:
Use a function for body instead of an object. AI SDK v6 calls
body() on each request, ensuring latest env vars are sent.
Changes:
- useChat.ts: body: { env } → body: () => ({ env: buildEnvObject() })
- buildEnvObject() is called fresh on each request
- Env vars now sent correctly to /api/chat
Credit: ChatGPT for identifying the exact AI SDK v6 pattern
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
377 lines
11 KiB
TypeScript
377 lines
11 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
|
|
const moduleCache = new Map<string, any>();
|
|
|
|
/**
|
|
* 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 } = body;
|
|
|
|
if (!packageName || !exportName || !version) {
|
|
return Response.json(
|
|
{
|
|
success: false,
|
|
error: 'Missing required fields: packageName, exportName, version',
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const cacheKey = `${packageName}::${exportName}`;
|
|
|
|
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);
|
|
toolModule = module[exportName];
|
|
|
|
if (!toolModule) {
|
|
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 }
|
|
);
|
|
}
|
|
|
|
// 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 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',
|
|
hasJsonSchema: typeof toolModule.inputSchema?.jsonSchema === 'function',
|
|
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}`);
|
|
|
|
return Response.json({
|
|
success: true,
|
|
tool: {
|
|
exportName,
|
|
description: toolModule.description,
|
|
inputSchema: rawJsonSchema, // 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> {
|
|
try {
|
|
const body = await req.json();
|
|
const { packageName, exportName, version, importUrl, params, env } = body;
|
|
|
|
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}`;
|
|
const startTime = Date.now();
|
|
|
|
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);
|
|
toolModule = module[exportName];
|
|
|
|
if (!toolModule || !toolModule.execute) {
|
|
return Response.json(
|
|
{
|
|
success: false,
|
|
error: 'Tool not found or invalid',
|
|
executionTimeMs: Date.now() - startTime,
|
|
},
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
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)) {
|
|
Deno.env.set(key, String(value));
|
|
console.log(` ✅ Set ${key} = ${String(value).substring(0, 10)}...`);
|
|
}
|
|
// Verify they're set
|
|
console.log(
|
|
`🔍 Verification - Deno.env has:`,
|
|
envKeys.map((k) => `${k}=${Deno.env.get(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
|
|
console.log(`🚀 Executing ${cacheKey} with params:`, params);
|
|
const result = await toolModule.execute(params || {});
|
|
|
|
const executionTimeMs = Date.now() - startTime;
|
|
console.log(`✅ Execution complete in ${executionTimeMs}ms`);
|
|
|
|
return Response.json({
|
|
success: true,
|
|
output: result,
|
|
executionTimeMs,
|
|
});
|
|
} catch (error) {
|
|
const executionTimeMs = Date.now() - Date.now();
|
|
console.error('❌ Tool execution failed:', error);
|
|
|
|
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);
|