diff --git a/apps/web/src/app/api/agents/[id]/route.ts b/apps/web/src/app/api/agents/[id]/route.ts index 5c94cce..6ddc41e 100644 --- a/apps/web/src/app/api/agents/[id]/route.ts +++ b/apps/web/src/app/api/agents/[id]/route.ts @@ -1,4 +1,4 @@ -import { prisma } from '@tpmjs/db'; +import { Prisma, prisma } from '@tpmjs/db'; import { UpdateAgentSchema } from '@tpmjs/types/agent'; import { headers } from 'next/headers'; import type { NextRequest } from 'next/server'; @@ -178,23 +178,19 @@ export async function PATCH(request: NextRequest, context: RouteContext) { } } + // Build update data, transforming executorConfig for Prisma (null -> Prisma.JsonNull) + const { executorConfig, ...restData } = parsed.data; + const updateData: Prisma.AgentUpdateInput = { + ...restData, + ...(executorConfig !== undefined && { + executorConfig: executorConfig === null ? Prisma.JsonNull : executorConfig, + }), + }; + const agent = await prisma.agent.update({ where: { id }, - data: parsed.data, - select: { - id: true, - uid: true, - name: true, - description: true, - provider: true, - modelId: true, - systemPrompt: true, - temperature: true, - maxToolCallsPerTurn: true, - maxMessagesInContext: true, - isPublic: true, - createdAt: true, - updatedAt: true, + data: updateData, + include: { _count: { select: { tools: true, diff --git a/apps/web/src/app/api/collections/[id]/route.ts b/apps/web/src/app/api/collections/[id]/route.ts index 8817893..236f056 100644 --- a/apps/web/src/app/api/collections/[id]/route.ts +++ b/apps/web/src/app/api/collections/[id]/route.ts @@ -1,4 +1,4 @@ -import { prisma } from '@tpmjs/db'; +import { Prisma, prisma } from '@tpmjs/db'; import { UpdateCollectionSchema } from '@tpmjs/types/collection'; import { headers } from 'next/headers'; import { type NextRequest, NextResponse } from 'next/server'; @@ -243,7 +243,7 @@ export async function PATCH( ); } - const { name, description, isPublic } = parseResult.data; + const { name, description, isPublic, executorType, executorConfig } = parseResult.data; // If name is being changed, check for duplicates if (name && name !== existingCollection.name) { @@ -270,13 +270,17 @@ export async function PATCH( } } - // Update collection + // Update collection (transform null to Prisma.JsonNull for JSON fields) const collection = await prisma.collection.update({ where: { id }, data: { ...(name !== undefined && { name }), ...(description !== undefined && { description }), ...(isPublic !== undefined && { isPublic }), + ...(executorType !== undefined && { executorType }), + ...(executorConfig !== undefined && { + executorConfig: executorConfig === null ? Prisma.JsonNull : executorConfig, + }), }, include: { _count: { select: { tools: true } }, @@ -300,6 +304,8 @@ export async function PATCH( description: collection.description, isPublic: collection.isPublic, toolCount: collection._count.tools, + executorType: collection.executorType, + executorConfig: collection.executorConfig, createdAt: collection.createdAt, updatedAt: collection.updatedAt, }, diff --git a/apps/web/src/app/api/executors/verify/route.ts b/apps/web/src/app/api/executors/verify/route.ts new file mode 100644 index 0000000..db2bc50 --- /dev/null +++ b/apps/web/src/app/api/executors/verify/route.ts @@ -0,0 +1,116 @@ +/** + * Executor Verification Endpoint + * + * POST: Verify that a custom executor URL is reachable and implements the API correctly + */ + +import { VerifyExecutorRequestSchema } from '@tpmjs/types/executor'; +import { type NextRequest, NextResponse } from 'next/server'; + +import { verifyExecutor } from '~/lib/executors'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const maxDuration = 30; + +/** + * POST /api/executors/verify + * Verify a custom executor URL + */ +export async function POST(request: NextRequest): Promise { + try { + const body = await request.json(); + + // Validate request + const parsed = VerifyExecutorRequestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'Invalid request', + details: parsed.error.flatten(), + }, + }, + { status: 400 } + ); + } + + const { url, apiKey } = parsed.data; + + // Validate URL format and security + try { + const parsedUrl = new URL(url); + + // Require HTTPS in production + if (process.env.NODE_ENV === 'production' && parsedUrl.protocol !== 'https:') { + return NextResponse.json( + { + success: false, + error: { + code: 'INSECURE_URL', + message: 'Custom executor URL must use HTTPS in production', + }, + }, + { status: 400 } + ); + } + + // Block internal/private IPs in production + if (process.env.NODE_ENV === 'production') { + const hostname = parsedUrl.hostname; + if ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname.startsWith('192.168.') || + hostname.startsWith('10.') || + hostname.startsWith('172.16.') || + hostname.endsWith('.local') + ) { + return NextResponse.json( + { + success: false, + error: { + code: 'PRIVATE_URL', + message: 'Custom executor URL cannot point to private/internal addresses', + }, + }, + { status: 400 } + ); + } + } + } catch { + return NextResponse.json( + { + success: false, + error: { + code: 'INVALID_URL', + message: 'Invalid URL format', + }, + }, + { status: 400 } + ); + } + + // Verify the executor + const result = await verifyExecutor(url, apiKey); + + return NextResponse.json({ + success: true, + data: result, + }); + } catch (error) { + console.error('Failed to verify executor:', error); + return NextResponse.json( + { + success: false, + error: { + code: 'VERIFICATION_ERROR', + message: error instanceof Error ? error.message : 'Failed to verify executor', + }, + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/dashboard/agents/[id]/page.tsx b/apps/web/src/app/dashboard/agents/[id]/page.tsx index 607217a..0a3bb6d 100644 --- a/apps/web/src/app/dashboard/agents/[id]/page.tsx +++ b/apps/web/src/app/dashboard/agents/[id]/page.tsx @@ -2,6 +2,7 @@ import type { AIProvider } from '@tpmjs/types/agent'; import { PROVIDER_MODELS, SUPPORTED_PROVIDERS } from '@tpmjs/types/agent'; +import type { ExecutorConfig } from '@tpmjs/types/executor'; import { Button } from '@tpmjs/ui/Button/Button'; import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock'; import { Icon } from '@tpmjs/ui/Icon/Icon'; @@ -19,6 +20,7 @@ import { Tabs } from '@tpmjs/ui/Tabs/Tabs'; import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { ExecutorConfigPanel } from '~/components/ExecutorConfigPanel'; import { DashboardLayout } from '~/components/dashboard/DashboardLayout'; interface Agent { @@ -33,6 +35,8 @@ interface Agent { maxToolCallsPerTurn: number; maxMessagesInContext: number; isPublic: boolean; + executorType: string | null; + executorConfig: { url: string; apiKey?: string } | null; toolCount: number; collectionCount: number; createdAt: string; @@ -267,6 +271,7 @@ export default function AgentDetailPage(): React.ReactElement { const [isEditing, setIsEditing] = useState(false); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); + const [executorConfig, setExecutorConfig] = useState(null); // Tools state const [agentTools, setAgentTools] = useState([]); @@ -316,6 +321,16 @@ export default function AgentDetailPage(): React.ReactElement { maxMessagesInContext: data.data.maxMessagesInContext, isPublic: data.data.isPublic, }); + // Initialize executor config state from agent data + if (data.data.executorType === 'custom_url' && data.data.executorConfig) { + setExecutorConfig({ + type: 'custom_url', + url: data.data.executorConfig.url, + apiKey: data.data.executorConfig.apiKey, + }); + } else { + setExecutorConfig(data.data.executorType ? { type: 'default' } : null); + } } else { if (response.status === 401) { router.push('/sign-in'); @@ -576,19 +591,39 @@ export default function AgentDetailPage(): React.ReactElement { const handleSave = async () => { setIsSaving(true); + + // Build update payload including executor config + const updatePayload: Record = { + ...formData, + temperature: Number.parseFloat(formData.temperature.toString()), + maxToolCallsPerTurn: Number.parseInt(formData.maxToolCallsPerTurn.toString(), 10), + maxMessagesInContext: Number.parseInt(formData.maxMessagesInContext.toString(), 10), + description: formData.description || null, + systemPrompt: formData.systemPrompt || null, + isPublic: formData.isPublic, + }; + + // Add executor config + if (executorConfig) { + updatePayload.executorType = executorConfig.type; + if (executorConfig.type === 'custom_url') { + updatePayload.executorConfig = { + url: executorConfig.url, + apiKey: executorConfig.apiKey, + }; + } else { + updatePayload.executorConfig = null; + } + } else { + updatePayload.executorType = null; + updatePayload.executorConfig = null; + } + try { const response = await fetch(`/api/agents/${agentId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ...formData, - temperature: Number.parseFloat(formData.temperature.toString()), - maxToolCallsPerTurn: Number.parseInt(formData.maxToolCallsPerTurn.toString(), 10), - maxMessagesInContext: Number.parseInt(formData.maxMessagesInContext.toString(), 10), - description: formData.description || null, - systemPrompt: formData.systemPrompt || null, - isPublic: formData.isPublic, - }), + body: JSON.stringify(updatePayload), }); const result = await response.json(); @@ -863,6 +898,15 @@ export default function AgentDetailPage(): React.ReactElement { /> + {/* Executor Configuration */} +
+ +
+
)} diff --git a/apps/web/src/app/docs/executors/page.tsx b/apps/web/src/app/docs/executors/page.tsx new file mode 100644 index 0000000..ffe21eb --- /dev/null +++ b/apps/web/src/app/docs/executors/page.tsx @@ -0,0 +1,304 @@ +import { Button } from '@tpmjs/ui/Button/Button'; +import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import type { Metadata } from 'next'; +import Link from 'next/link'; + +import { AppFooter } from '~/components/AppFooter'; +import { AppHeader } from '~/components/AppHeader'; + +export const metadata: Metadata = { + title: 'Custom Executors - TPMJS', + description: + 'Learn how to deploy and configure custom executors for running TPMJS tools on your own infrastructure.', +}; + +const executeToolExample = `// POST /execute-tool +{ + "packageName": "@tpmjs/hello", + "name": "helloWorld", + "version": "latest", + "params": { "name": "World" }, + "env": { "MY_API_KEY": "..." } +}`; + +const executeToolResponse = `// Response +{ + "success": true, + "output": "Hello, World!", + "executionTimeMs": 123 +}`; + +const healthExample = `// GET /health +{ + "status": "ok", + "version": "1.0.0" +}`; + +export default function ExecutorsDocsPage(): React.ReactElement { + return ( +
+ + +
+
+ {/* Header */} +
+

Custom Executors

+

+ Deploy your own executor to run TPMJS tools on your own infrastructure. +

+
+ + {/* Overview Section */} +
+

What is an Executor?

+

+ An executor is a service that runs TPMJS tools. When you use a collection or agent, + TPMJS sends tool execution requests to an executor, which dynamically loads and runs + the tool code. +

+

+ By default, TPMJS uses a shared executor. You can deploy your own for: +

+
+
+
+ +

Full Control

+
+

+ Run tools on your own infrastructure with complete control over the execution + environment. +

+
+
+
+ +

Privacy

+
+

+ Keep tool execution data on your own servers. No data leaves your infrastructure. +

+
+
+
+ +

Performance

+
+

+ Deploy in regions closest to your users for lower latency tool execution. +

+
+
+
+ +

Custom Environment

+
+

+ Inject your own environment variables, secrets, and configuration into tool + execution. +

+
+
+
+ + {/* Deploy Section */} +
+

+ Deploy Your Own Executor +

+

+ The fastest way to get started is to deploy our template to Vercel with one click: +

+ +

+ After deployment, you'll get a URL like{' '} + + https://tpmjs-executor.vercel.app + +

+
+ + {/* Configuration Section */} +
+

Configuration

+

+ Once you have your executor deployed, configure your collections or agents to use it: +

+
    +
  1. Go to your collection or agent settings
  2. +
  3. + In the "Executor Configuration" section, select "Custom + Executor" +
  4. +
  5. + Enter your executor URL (e.g.,{' '} + + https://tpmjs-executor.vercel.app + + ) +
  6. +
  7. Optionally add an API key if your executor requires authentication
  8. +
  9. Click "Verify Connection" to test the configuration
  10. +
+
+

+ Security tip: Set the{' '} + EXECUTOR_API_KEY environment + variable in your Vercel project to require authentication for all requests. +

+
+
+ + {/* API Specification */} +
+

+ Executor API Specification +

+

All executors must implement this API:

+ + {/* POST /execute-tool */} +
+

+ POST{' '} + /execute-tool +

+

+ Execute a TPMJS tool with the provided parameters. +

+
+
+

Request Body:

+ +
+
+

Response:

+ +
+
+
+ + {/* GET /health */} +
+

+ GET{' '} + /health +

+

+ Check executor health status. Used by TPMJS to verify the executor is reachable. +

+
+

Response:

+ +
+
+
+ + {/* Cascade Section */} +
+

Executor Cascade

+

+ Executor configuration follows a cascade resolution order: +

+
+ + Agent Config + + + + Collection Config + + + + System Default + +
+
    +
  • • If an agent has an executor configured, all tools in that agent use it
  • +
  • + • If the agent has no executor but a collection does, tools from that collection use + the collection's executor +
  • +
  • • If neither has an executor configured, the TPMJS default executor is used
  • +
+
+ + {/* FAQ Section */} +
+

FAQ

+
+
+

Can I use any cloud provider?

+

+ Yes! While we provide a Vercel template, you can deploy an executor anywhere that + can run Node.js and expose an HTTP endpoint. The executor just needs to implement + the API specification above. +

+
+
+

What about timeouts?

+

+ The default timeout for tool execution is 30 seconds. On Vercel's free tier, + you get up to 10 seconds per request. For longer-running tools, consider deploying + to a platform with higher timeout limits. +

+
+
+

How do tools get loaded?

+

+ Tools are dynamically imported from{' '} + + esm.sh + + , a CDN for npm packages. The executor fetches the package, finds the tool export, + and calls its execute() function. +

+
+
+
+ + {/* Support */} +
+

Need Help?

+

+ If you run into issues deploying or configuring your executor, we're here to + help. +

+ +
+
+
+ + +
+ ); +} diff --git a/apps/web/src/components/ExecutorConfigPanel.tsx b/apps/web/src/components/ExecutorConfigPanel.tsx new file mode 100644 index 0000000..2985444 --- /dev/null +++ b/apps/web/src/components/ExecutorConfigPanel.tsx @@ -0,0 +1,315 @@ +'use client'; + +import type { ExecutorConfig } from '@tpmjs/types/executor'; +import { Button } from '@tpmjs/ui/Button/Button'; +import { FormField } from '@tpmjs/ui/FormField/FormField'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import { Input } from '@tpmjs/ui/Input/Input'; +import Link from 'next/link'; +import { useState } from 'react'; + +interface ExecutorConfigPanelProps { + value: ExecutorConfig | null; + onChange: (config: ExecutorConfig | null) => void; + disabled?: boolean; +} + +interface VerificationResult { + valid: boolean; + healthCheck?: { + healthy: boolean; + response?: { status: string; version?: string }; + }; + testExecution?: { + success: boolean; + executionTimeMs: number; + }; + errors: string[]; +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Component has multiple states for executor configuration +export function ExecutorConfigPanel({ + value, + onChange, + disabled = false, +}: ExecutorConfigPanelProps): React.ReactElement { + const [executorType, setExecutorType] = useState<'default' | 'custom_url'>( + value?.type === 'custom_url' ? 'custom_url' : 'default' + ); + const [customUrl, setCustomUrl] = useState(value?.type === 'custom_url' ? value.url : ''); + const [apiKey, setApiKey] = useState(value?.type === 'custom_url' ? (value.apiKey ?? '') : ''); + const [isVerifying, setIsVerifying] = useState(false); + const [verificationResult, setVerificationResult] = useState(null); + const [urlError, setUrlError] = useState(null); + + const handleTypeChange = (type: 'default' | 'custom_url') => { + setExecutorType(type); + setVerificationResult(null); + setUrlError(null); + + if (type === 'default') { + onChange({ type: 'default' }); + } else { + // Don't update parent until URL is provided + if (customUrl) { + onChange({ + type: 'custom_url', + url: customUrl, + apiKey: apiKey || undefined, + }); + } + } + }; + + const handleUrlChange = (url: string) => { + setCustomUrl(url); + setVerificationResult(null); + setUrlError(null); + + if (url) { + onChange({ + type: 'custom_url', + url, + apiKey: apiKey || undefined, + }); + } + }; + + const handleApiKeyChange = (key: string) => { + setApiKey(key); + + if (customUrl) { + onChange({ + type: 'custom_url', + url: customUrl, + apiKey: key || undefined, + }); + } + }; + + const handleVerify = async () => { + if (!customUrl) { + setUrlError('URL is required'); + return; + } + + // Validate URL format + try { + new URL(customUrl); + } catch { + setUrlError('Invalid URL format'); + return; + } + + setIsVerifying(true); + setVerificationResult(null); + setUrlError(null); + + try { + const response = await fetch('/api/executors/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: customUrl, apiKey: apiKey || undefined }), + }); + + const data = await response.json(); + + if (data.success) { + setVerificationResult(data.data); + } else { + setUrlError(data.error?.message || 'Verification failed'); + } + } catch (error) { + setUrlError(error instanceof Error ? error.message : 'Failed to verify executor'); + } finally { + setIsVerifying(false); + } + }; + + return ( +
+
+ +

Executor Configuration

+
+ +

+ Choose where tools in this collection/agent will be executed.{' '} + + Learn more + +

+ + {/* Executor Type Selection */} +
+ + + +
+ + {/* Custom URL Configuration */} + {executorType === 'custom_url' && ( +
+ + handleUrlChange(e.target.value)} + placeholder="https://my-executor.vercel.app" + state={urlError ? 'error' : 'default'} + disabled={disabled} + /> + + + + handleApiKeyChange(e.target.value)} + placeholder="sk-..." + disabled={disabled} + /> + + +
+ + + + Deploy your own executor + +
+ + {/* Verification Result */} + {verificationResult && ( +
+
+ + + {verificationResult.valid ? 'Executor verified' : 'Verification failed'} + +
+ + {verificationResult.healthCheck && ( +

+ Health: {verificationResult.healthCheck.response?.status ?? 'unknown'} + {verificationResult.healthCheck.response?.version && + ` (v${verificationResult.healthCheck.response.version})`} +

+ )} + + {verificationResult.testExecution && ( +

+ Test execution: {verificationResult.testExecution.executionTimeMs}ms +

+ )} + + {verificationResult.errors.length > 0 && ( +
    + {verificationResult.errors.map((error) => ( +
  • {error}
  • + ))} +
+ )} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/web/src/lib/agents/build-tools.ts b/apps/web/src/lib/agents/build-tools.ts index 836ed3c..c16ccb6 100644 --- a/apps/web/src/lib/agents/build-tools.ts +++ b/apps/web/src/lib/agents/build-tools.ts @@ -6,7 +6,9 @@ import type { Agent, AgentCollection, AgentTool, Collection, Package, Tool } fro import { prisma } from '@tpmjs/db'; import { createToolDefinition } from '../ai-agent/tool-executor-agent'; +import { parseExecutorConfig, resolveExecutorConfig } from '../executors'; +// Agent type includes executor config fields from Prisma schema type AgentWithRelations = Agent & { collections: (AgentCollection & { collection: Collection & { @@ -22,6 +24,7 @@ type AgentWithRelations = Agent & { /** * Fetch a full agent with all tool relations + * Includes executor config for cascade resolution */ export async function fetchAgentWithTools(agentId: string): Promise { return prisma.agent.findUnique({ @@ -58,6 +61,7 @@ export async function fetchAgentWithTools(agentId: string): Promise { return prisma.agent.findUnique({ @@ -95,6 +99,7 @@ export async function fetchAgentByUidWithTools(uid: string): Promise AI SDK tool definition + * + * Executor config cascade: Agent → Collection → System Default + * - If agent has an executor config, it's used for all tools + * - If agent has no config but collection has one, collection's config is used for tools from that collection + * - If neither has config, system default is used */ export function buildAgentTools( agent: AgentWithRelations @@ -196,9 +207,23 @@ export function buildAgentTools( const tools: Record> = {}; const seenTools = new Set(); + // Parse agent-level executor config + const agentExecutorConfig = parseExecutorConfig(agent.executorType, agent.executorConfig); + // Add tools from collections first for (const agentCollection of agent.collections) { - for (const collectionTool of agentCollection.collection.tools) { + const collection = agentCollection.collection; + + // Parse collection-level executor config + const collectionExecutorConfig = parseExecutorConfig( + collection.executorType, + collection.executorConfig + ); + + // Resolve executor config: Agent → Collection → Default + const resolvedConfig = resolveExecutorConfig(agentExecutorConfig, collectionExecutorConfig); + + for (const collectionTool of collection.tools) { const tool = collectionTool.tool; const toolKey = `${tool.package.npmPackageName}::${tool.name}`; @@ -207,11 +232,14 @@ export function buildAgentTools( seenTools.add(toolKey); const toolName = sanitizeToolName(`${tool.package.npmPackageName}-${tool.name}`); - tools[toolName] = createToolDefinition(tool); + tools[toolName] = createToolDefinition(tool, resolvedConfig); } } // Add individual tools (may override collection tools) + // Individual tools use agent config or system default (no collection context) + const individualToolConfig = agentExecutorConfig ?? { type: 'default' as const }; + for (const agentTool of agent.tools) { const tool = agentTool.tool; const toolKey = `${tool.package.npmPackageName}::${tool.name}`; @@ -221,7 +249,7 @@ export function buildAgentTools( seenTools.add(toolKey); const toolName = sanitizeToolName(`${tool.package.npmPackageName}-${tool.name}`); - tools[toolName] = createToolDefinition(tool); + tools[toolName] = createToolDefinition(tool, individualToolConfig); } return tools; diff --git a/apps/web/src/lib/ai-agent/tool-executor-agent.ts b/apps/web/src/lib/ai-agent/tool-executor-agent.ts index a8c90df..b6bfff1 100644 --- a/apps/web/src/lib/ai-agent/tool-executor-agent.ts +++ b/apps/web/src/lib/ai-agent/tool-executor-agent.ts @@ -5,10 +5,12 @@ import { openai } from '@ai-sdk/openai'; import type { Package, Tool } from '@tpmjs/db'; -import { executePackage } from '@tpmjs/package-executor'; +import type { ExecutorConfig } from '@tpmjs/types/executor'; import { type ModelMessage, generateText } from 'ai'; import { z } from 'zod'; +import { executeWithExecutor } from '../executors'; + /** * Parameter from TPMJS metadata */ @@ -91,8 +93,14 @@ export function tpmjsParamsToZodSchema(parameters: TPMJSParameter[]): z.ZodObjec /** * Create AI SDK v6 tool definition from TPMJS Tool * Requires Tool with Package relation + * + * @param tool - The tool with its package relation + * @param executorConfig - Optional executor config for custom executors */ -export function createToolDefinition(tool: Tool & { package: Package }) { +export function createToolDefinition( + tool: Tool & { package: Package }, + executorConfig?: ExecutorConfig | null +) { const parameters = Array.isArray(tool.parameters) ? (tool.parameters as unknown as TPMJSParameter[]) : []; @@ -118,14 +126,13 @@ export function createToolDefinition(tool: Tool & { package: Package }) { execute: async (params: Record) => { console.log('[Tool execute] Running:', sanitizedName, params); - // Execute the actual npm package in a sandbox + // Execute the actual npm package using resolved executor // Use the actual export name from the Tool record - const result = await executePackage( - tool.package.npmPackageName, - tool.name, // Use actual export name (e.g., "helloWorldTool", "default") + const result = await executeWithExecutor(executorConfig ?? null, { + packageName: tool.package.npmPackageName, + name: tool.name, // Use actual export name (e.g., "helloWorldTool", "default") params, - { timeout: 5000 } - ); + }); if (!result.success) { throw new Error(result.error || 'Package execution failed'); diff --git a/apps/web/src/lib/executors/index.ts b/apps/web/src/lib/executors/index.ts new file mode 100644 index 0000000..689016b --- /dev/null +++ b/apps/web/src/lib/executors/index.ts @@ -0,0 +1,335 @@ +/** + * Executor Resolution Logic + * + * Handles the cascade resolution for hot-swappable executors: + * Agent Config → Collection Config → System Default + */ + +import { executePackage } from '@tpmjs/package-executor'; +import type { + ExecuteToolRequest, + ExecuteToolResponse, + ExecutorConfig, + ExecutorHealthResponse, +} from '@tpmjs/types/executor'; + +const DEFAULT_TIMEOUT = 30000; // 30 seconds for custom executors + +/** + * Resolve executor configuration using cascade: + * Agent Config → Collection Config → System Default + * + * @param agentConfig - Executor config from Agent (highest priority) + * @param collectionConfig - Executor config from Collection (medium priority) + * @returns Resolved executor configuration + */ +export function resolveExecutorConfig( + agentConfig: ExecutorConfig | null | undefined, + collectionConfig: ExecutorConfig | null | undefined +): ExecutorConfig { + // Agent config takes precedence + if (agentConfig && agentConfig.type !== 'default') { + return agentConfig; + } + + // Fall back to collection config + if (collectionConfig && collectionConfig.type !== 'default') { + return collectionConfig; + } + + // System default + return { type: 'default' }; +} + +/** + * Parse executor config from database JSON + * Handles null/undefined and validates the shape + */ +export function parseExecutorConfig( + executorType: string | null | undefined, + executorConfig: unknown +): ExecutorConfig | null { + if (!executorType) { + return null; + } + + if (executorType === 'default') { + return { type: 'default' }; + } + + if (executorType === 'custom_url' && executorConfig && typeof executorConfig === 'object') { + const config = executorConfig as { url?: string; apiKey?: string }; + if (config.url && typeof config.url === 'string') { + return { + type: 'custom_url', + url: config.url, + apiKey: typeof config.apiKey === 'string' ? config.apiKey : undefined, + }; + } + } + + return null; +} + +/** + * Execute a tool using a custom URL executor + */ +async function executeWithCustomUrl( + url: string, + apiKey: string | undefined, + request: ExecuteToolRequest, + timeout: number = DEFAULT_TIMEOUT +): Promise { + const startTime = Date.now(); + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + const response = await fetch(`${url}/execute-tool`, { + method: 'POST', + headers, + body: JSON.stringify(request), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + const executionTimeMs = Date.now() - startTime; + + if (!response.ok) { + const errorData = (await response.json().catch(() => ({ error: 'Unknown error' }))) as { + error?: string; + }; + return { + success: false, + error: errorData.error || `Executor error: ${response.status}`, + executionTimeMs, + }; + } + + const result = (await response.json()) as ExecuteToolResponse; + + return { + success: result.success, + output: result.output, + error: result.error, + executionTimeMs: result.executionTimeMs || executionTimeMs, + }; + } catch (error) { + const executionTimeMs = Date.now() - startTime; + + if (error instanceof Error && error.name === 'AbortError') { + return { + success: false, + error: 'Execution timeout', + executionTimeMs, + }; + } + + return { + success: false, + error: error instanceof Error ? error.message : String(error), + executionTimeMs, + }; + } +} + +/** + * Execute a tool using the resolved executor configuration + * + * @param config - Resolved executor config (or null for default) + * @param request - Tool execution request + * @returns Execution result + */ +export async function executeWithExecutor( + config: ExecutorConfig | null, + request: ExecuteToolRequest +): Promise { + const resolvedConfig = config ?? { type: 'default' }; + + // Use custom URL executor + if (resolvedConfig.type === 'custom_url' && resolvedConfig.url) { + return executeWithCustomUrl(resolvedConfig.url, resolvedConfig.apiKey, request); + } + + // Default: use existing package-executor (which uses SANDBOX_EXECUTOR_URL) + const result = await executePackage(request.packageName, request.name, request.params); + + return { + success: result.success, + output: result.output, + error: result.error, + executionTimeMs: result.executionTimeMs, + }; +} + +/** + * Check the health of a custom executor URL + * + * @param url - Executor URL to check + * @param apiKey - Optional API key for authentication + * @returns Health check response or error + */ +export async function checkExecutorHealth( + url: string, + apiKey?: string +): Promise<{ healthy: boolean; response?: ExecutorHealthResponse; error?: string }> { + const headers: Record = {}; + + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout for health checks + + const response = await fetch(`${url}/health`, { + method: 'GET', + headers, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return { + healthy: false, + error: `Health check failed: ${response.status}`, + }; + } + + const data = (await response.json()) as ExecutorHealthResponse; + + return { + healthy: data.status === 'ok' || data.status === 'degraded', + response: data, + }; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return { + healthy: false, + error: 'Health check timeout', + }; + } + + return { + healthy: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} + +/** + * Test a custom executor by running a simple tool execution + * + * @param url - Executor URL to test + * @param apiKey - Optional API key for authentication + * @returns Test execution result + */ +export async function testExecutor( + url: string, + apiKey?: string +): Promise<{ success: boolean; executionTimeMs: number; error?: string }> { + const testRequest: ExecuteToolRequest = { + packageName: '@anthropic-ai/tpmjs-hello', + name: 'helloWorld', + params: { name: 'test' }, + }; + + const result = await executeWithCustomUrl(url, apiKey, testRequest, 15000); // 15 second timeout for test + + return { + success: result.success, + executionTimeMs: result.executionTimeMs, + error: result.error, + }; +} + +/** + * Verify a custom executor URL is valid and operational + * + * @param url - Executor URL to verify + * @param apiKey - Optional API key for authentication + * @returns Verification result + */ +export async function verifyExecutor( + url: string, + apiKey?: string +): Promise<{ + valid: boolean; + healthCheck?: { healthy: boolean; response?: ExecutorHealthResponse }; + testExecution?: { success: boolean; executionTimeMs: number }; + errors: string[]; +}> { + const errors: string[] = []; + + // Validate URL format + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:' && process.env.NODE_ENV === 'production') { + errors.push('Custom executor URL must use HTTPS in production'); + } + } catch { + errors.push('Invalid URL format'); + return { valid: false, errors }; + } + + // Check health endpoint + const healthResult = await checkExecutorHealth(url, apiKey); + if (!healthResult.healthy) { + errors.push(healthResult.error || 'Health check failed'); + } + + // Test execution (only if health check passed) + let testResult: { success: boolean; executionTimeMs: number } | undefined; + if (healthResult.healthy) { + const execResult = await testExecutor(url, apiKey); + testResult = { + success: execResult.success, + executionTimeMs: execResult.executionTimeMs, + }; + if (!execResult.success) { + errors.push(execResult.error || 'Test execution failed'); + } + } + + return { + valid: errors.length === 0, + healthCheck: { + healthy: healthResult.healthy, + response: healthResult.response, + }, + testExecution: testResult, + errors, + }; +} + +/** + * Get executor description for display + */ +export function getExecutorDescription(config: ExecutorConfig | null): string { + if (!config || config.type === 'default') { + return 'TPMJS Default Executor'; + } + + if (config.type === 'custom_url') { + try { + const url = new URL(config.url); + return `Custom: ${url.hostname}`; + } catch { + return 'Custom Executor'; + } + } + + return 'Unknown Executor'; +} diff --git a/apps/web/src/lib/mcp/handlers.ts b/apps/web/src/lib/mcp/handlers.ts index 5ee5984..a88e705 100644 --- a/apps/web/src/lib/mcp/handlers.ts +++ b/apps/web/src/lib/mcp/handlers.ts @@ -1,44 +1,8 @@ import { prisma } from '@tpmjs/db'; +import { executeWithExecutor, parseExecutorConfig } from '../executors'; import { convertToMcpTool, parseToolName } from './tool-converter'; -const SANDBOX_URL = 'https://executor.tpmjs.com'; - -interface ExecutionResult { - success: boolean; - output?: unknown; - error?: string; - executionTimeMs?: number; -} - -async function executePackageDirect( - packageName: string, - toolName: string, - params: Record -): Promise { - try { - const response = await fetch(`${SANDBOX_URL}/execute-tool`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - packageName, - name: toolName, - version: 'latest', - params, - }), - }); - - if (!response.ok) { - const text = await response.text(); - return { success: false, error: `Sandbox error ${response.status}: ${text}` }; - } - - return await response.json(); - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; - } -} - type JsonRpcId = string | number | null; interface JsonRpcResponse { @@ -114,10 +78,12 @@ export async function handleToolsCall( }; } - // Verify tool exists in collection + // Verify tool exists in collection and get executor config const collection = await prisma.collection.findUnique({ where: { id: collectionId }, - include: { + select: { + executorType: true, + executorConfig: true, tools: { include: { tool: { include: { package: true } } }, }, @@ -137,12 +103,15 @@ export async function handleToolsCall( }; } - // Execute via sandbox (direct call with hardcoded URL) - const result = await executePackageDirect( - parsed.packageName, - parsed.toolName, - params.arguments ?? {} - ); + // Resolve executor configuration (collection config only for MCP - no agent context) + const executorConfig = parseExecutorConfig(collection?.executorType, collection?.executorConfig); + + // Execute via resolved executor + const result = await executeWithExecutor(executorConfig, { + packageName: parsed.packageName, + name: parsed.toolName, + params: params.arguments ?? {}, + }); if (!result.success) { return { diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 6d2f47f..d6d09da 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -422,6 +422,10 @@ model Collection { isPublic Boolean @default(false) @map("is_public") likeCount Int @default(0) @map("like_count") + // Executor configuration (optional - uses system default if not set) + executorType String? @map("executor_type") @db.VarChar(50) + executorConfig Json? @map("executor_config") @db.JsonB + // Timestamps createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -513,6 +517,11 @@ model Agent { isPublic Boolean @default(true) @map("is_public") likeCount Int @default(0) @map("like_count") + // Executor configuration (optional - uses system default if not set) + // Agent executor overrides collection executor overrides system default + executorType String? @map("executor_type") @db.VarChar(50) + executorConfig Json? @map("executor_config") @db.JsonB + // Timestamps createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/packages/types/package.json b/packages/types/package.json index 8db4f49..8c5f51b 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -36,6 +36,10 @@ "./user": { "types": "./dist/user.d.ts", "default": "./dist/user.js" + }, + "./executor": { + "types": "./dist/executor.d.ts", + "default": "./dist/executor.js" } }, "files": ["dist"], diff --git a/packages/types/src/agent.ts b/packages/types/src/agent.ts index acda038..5c65db9 100644 --- a/packages/types/src/agent.ts +++ b/packages/types/src/agent.ts @@ -1,8 +1,19 @@ import { z } from 'zod'; +import { ExecutorTypeSchema } from './executor'; + // Regex for valid agent UID: lowercase alphanumeric and hyphens const UID_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/; +// Executor config for updates (simplified schema that maps to database JSON) +const ExecutorConfigUpdateSchema = z + .object({ + url: z.string().url(), + apiKey: z.string().optional(), + }) + .nullable() + .optional(); + // ============================================================================ // Enums // ============================================================================ @@ -49,6 +60,9 @@ export const UpdateAgentSchema = z.object({ maxToolCallsPerTurn: z.number().int().min(1).max(100).optional(), maxMessagesInContext: z.number().int().min(1).max(100).optional(), isPublic: z.boolean().optional(), + // Executor configuration + executorType: ExecutorTypeSchema.nullable().optional(), + executorConfig: ExecutorConfigUpdateSchema, }); export const AddCollectionToAgentSchema = z.object({ diff --git a/packages/types/src/collection.ts b/packages/types/src/collection.ts index f7fb513..4e84c4e 100644 --- a/packages/types/src/collection.ts +++ b/packages/types/src/collection.ts @@ -1,8 +1,19 @@ import { z } from 'zod'; +import { ExecutorTypeSchema } from './executor'; + // Regex for valid collection names: letters, numbers, spaces, hyphens, underscores const NAME_REGEX = /^[a-zA-Z0-9\s\-_]+$/; +// Executor config for updates (simplified schema that maps to database JSON) +const ExecutorConfigUpdateSchema = z + .object({ + url: z.string().url(), + apiKey: z.string().optional(), + }) + .nullable() + .optional(); + // ============================================================================ // Collection Schemas // ============================================================================ @@ -30,6 +41,9 @@ export const UpdateCollectionSchema = z.object({ .nullable() .optional(), isPublic: z.boolean().optional(), + // Executor configuration + executorType: ExecutorTypeSchema.nullable().optional(), + executorConfig: ExecutorConfigUpdateSchema, }); // ============================================================================ diff --git a/packages/types/src/executor.ts b/packages/types/src/executor.ts new file mode 100644 index 0000000..1c3b742 --- /dev/null +++ b/packages/types/src/executor.ts @@ -0,0 +1,146 @@ +/** + * Executor API Types + * + * These types define the contract between TPMJS and any executor service. + * Custom executors must implement the ExecuteToolRequest/Response interface. + */ + +import { z } from 'zod'; + +// ============================================================================= +// Executor API Specification +// ============================================================================= + +/** + * Request payload for POST /execute-tool + */ +export interface ExecuteToolRequest { + /** NPM package name, e.g., "@tpmjs/hello" */ + packageName: string; + /** Tool name within the package, e.g., "helloWorld" */ + name: string; + /** Package version, e.g., "1.0.0" or "latest" */ + version?: string; + /** Direct esm.sh URL override for the package */ + importUrl?: string; + /** Tool parameters to pass to execute() */ + params: Record; + /** Environment variables to inject during execution */ + env?: Record; +} + +/** + * Response from POST /execute-tool + */ +export interface ExecuteToolResponse { + /** Whether the execution succeeded */ + success: boolean; + /** Tool output on success */ + output?: unknown; + /** Error message on failure */ + error?: string; + /** Execution duration in milliseconds */ + executionTimeMs: number; +} + +/** + * Response from GET /health (optional but recommended) + */ +export interface ExecutorHealthResponse { + /** Executor status */ + status: 'ok' | 'degraded' | 'error'; + /** Executor version string */ + version?: string; + /** Optional additional info */ + info?: Record; +} + +// ============================================================================= +// Executor Configuration Schemas +// ============================================================================= + +/** + * Executor type enum + */ +export const ExecutorTypeSchema = z.enum(['default', 'custom_url']); +export type ExecutorType = z.infer; + +/** + * Default executor config (uses TPMJS Railway executor) + */ +export const DefaultExecutorConfigSchema = z.object({ + type: z.literal('default'), +}); + +/** + * Custom URL executor config + */ +export const CustomUrlExecutorConfigSchema = z.object({ + type: z.literal('custom_url'), + /** URL of the custom executor (must be HTTPS in production) */ + url: z.string().url(), + /** Optional API key for Bearer token authentication */ + apiKey: z.string().optional(), +}); + +/** + * Union of all executor config types + */ +export const ExecutorConfigSchema = z.discriminatedUnion('type', [ + DefaultExecutorConfigSchema, + CustomUrlExecutorConfigSchema, +]); + +export type ExecutorConfig = z.infer; +export type DefaultExecutorConfig = z.infer; +export type CustomUrlExecutorConfig = z.infer; + +// ============================================================================= +// Zod Schemas for Request/Response Validation +// ============================================================================= + +export const ExecuteToolRequestSchema = z.object({ + packageName: z.string().min(1), + name: z.string().min(1), + version: z.string().optional(), + importUrl: z.string().url().optional(), + params: z.record(z.string(), z.unknown()), + env: z.record(z.string(), z.string()).optional(), +}); + +export const ExecuteToolResponseSchema = z.object({ + success: z.boolean(), + output: z.unknown().optional(), + error: z.string().optional(), + executionTimeMs: z.number(), +}); + +export const ExecutorHealthResponseSchema = z.object({ + status: z.enum(['ok', 'degraded', 'error']), + version: z.string().optional(), + info: z.record(z.string(), z.unknown()).optional(), +}); + +// ============================================================================= +// Executor Verification +// ============================================================================= + +export const VerifyExecutorRequestSchema = z.object({ + url: z.string().url(), + apiKey: z.string().optional(), +}); + +export interface VerifyExecutorRequest { + url: string; + apiKey?: string; +} + +export interface VerifyExecutorResponse { + valid: boolean; + healthCheck?: ExecutorHealthResponse; + testExecution?: { + success: boolean; + executionTimeMs: number; + }; + errors?: string[]; +} diff --git a/packages/types/tsup.config.ts b/packages/types/tsup.config.ts index 3a30a19..7ee5e68 100644 --- a/packages/types/tsup.config.ts +++ b/packages/types/tsup.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ 'src/collection.ts', 'src/agent.ts', 'src/user.ts', + 'src/executor.ts', ], format: ['esm'], dts: true, diff --git a/templates/vercel-executor/README.md b/templates/vercel-executor/README.md new file mode 100644 index 0000000..0a8133f --- /dev/null +++ b/templates/vercel-executor/README.md @@ -0,0 +1,110 @@ +# TPMJS Executor Template + +Deploy your own executor for running TPMJS tools on Vercel. + +## One-Click Deploy + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/tpmjs/tpmjs/tree/main/templates/vercel-executor&project-name=tpmjs-executor&repository-name=tpmjs-executor) + +## What is an Executor? + +An executor is a service that runs TPMJS tools. By default, TPMJS uses a shared executor, but you can deploy your own for: + +- **Full control**: Run tools on your own infrastructure +- **Custom environment**: Inject your own environment variables and secrets +- **Privacy**: Keep tool execution data on your own servers +- **Performance**: Deploy in regions closest to your users + +## API Endpoints + +### POST /api/execute-tool + +Execute a TPMJS tool. + +**Request:** +```json +{ + "packageName": "@tpmjs/hello", + "name": "helloWorld", + "version": "latest", + "params": { "name": "World" }, + "env": { "MY_SECRET": "value" } +} +``` + +**Response:** +```json +{ + "success": true, + "output": "Hello, World!", + "executionTimeMs": 123 +} +``` + +### GET /api/health + +Check executor health status. + +**Response:** +```json +{ + "status": "ok", + "version": "1.0.0", + "info": { + "runtime": "vercel-serverless", + "timestamp": "2024-01-01T00:00:00.000Z" + } +} +``` + +## Configuration + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `EXECUTOR_API_KEY` | No | API key for authentication. If set, requests must include `Authorization: Bearer ` header. | + +### Setting Up API Key Authentication + +1. Go to your Vercel project settings +2. Add an environment variable: `EXECUTOR_API_KEY` with a secure random value +3. When configuring your executor in TPMJS, enter this key in the "API Key" field + +## How It Works + +1. TPMJS sends a request to your executor with package name, tool name, and parameters +2. The executor dynamically imports the package from [esm.sh](https://esm.sh) +3. The tool's `execute()` function is called with the provided parameters +4. The result is returned to TPMJS + +## Local Development + +```bash +# Install dependencies +npm install + +# Run development server +npm run dev + +# Test the health endpoint +curl http://localhost:3000/api/health + +# Test tool execution +curl -X POST http://localhost:3000/api/execute-tool \ + -H "Content-Type: application/json" \ + -d '{"packageName":"@anthropic-ai/tpmjs-hello","name":"helloWorld","params":{"name":"Test"}}' +``` + +## Security Considerations + +- Always set `EXECUTOR_API_KEY` in production to prevent unauthorized access +- The executor runs tools in a serverless environment with limited capabilities +- Environment variables injected via `env` are available only during execution +- Tools are imported from esm.sh, a trusted CDN for npm packages + +## Support + +- [TPMJS Documentation](https://tpmjs.com/docs) +- [Executor Documentation](https://tpmjs.com/docs/executors) +- [GitHub Issues](https://github.com/tpmjs/tpmjs/issues) diff --git a/templates/vercel-executor/app/api/execute-tool/route.ts b/templates/vercel-executor/app/api/execute-tool/route.ts new file mode 100644 index 0000000..2d7e9ce --- /dev/null +++ b/templates/vercel-executor/app/api/execute-tool/route.ts @@ -0,0 +1,112 @@ +/** + * Execute Tool Endpoint + * + * POST /api/execute-tool + * Executes a TPMJS tool with the provided parameters + */ + +import { type ExecuteToolRequest, executeTool } from '@/lib/executor'; +import { type NextRequest, NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const maxDuration = 60; // 60 seconds max + +/** + * Verify API key if configured + */ +function verifyApiKey(request: NextRequest): boolean { + const apiKey = process.env.EXECUTOR_API_KEY; + + // If no API key is configured, allow all requests + if (!apiKey) { + return true; + } + + const authHeader = request.headers.get('Authorization'); + if (!authHeader) { + return false; + } + + // Expect "Bearer " format + const [type, token] = authHeader.split(' '); + if (type !== 'Bearer' || token !== apiKey) { + return false; + } + + return true; +} + +/** + * Validate the request body + */ +function validateRequest(body: unknown): body is ExecuteToolRequest { + if (!body || typeof body !== 'object') { + return false; + } + + const req = body as Record; + + return ( + typeof req.packageName === 'string' && + req.packageName.length > 0 && + typeof req.name === 'string' && + req.name.length > 0 && + typeof req.params === 'object' && + req.params !== null + ); +} + +export async function POST(request: NextRequest): Promise { + // Verify API key if configured + if (!verifyApiKey(request)) { + return NextResponse.json( + { success: false, error: 'Unauthorized', executionTimeMs: 0 }, + { status: 401 } + ); + } + + try { + const body = await request.json(); + + // Validate request + if (!validateRequest(body)) { + return NextResponse.json( + { + success: false, + error: 'Invalid request. Required: packageName, name, params', + executionTimeMs: 0, + }, + { status: 400 } + ); + } + + // Execute the tool + const result = await executeTool(body); + + return NextResponse.json(result, { + status: result.success ? 200 : 500, + }); + } catch (error) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Internal server error', + executionTimeMs: 0, + }, + { status: 500 } + ); + } +} + +// Handle OPTIONS for CORS preflight +export async function OPTIONS(): Promise { + return new NextResponse(null, { + status: 200, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }, + }); +} diff --git a/templates/vercel-executor/app/api/health/route.ts b/templates/vercel-executor/app/api/health/route.ts new file mode 100644 index 0000000..21ec383 --- /dev/null +++ b/templates/vercel-executor/app/api/health/route.ts @@ -0,0 +1,40 @@ +/** + * Health Check Endpoint + * + * GET /api/health + * Returns the health status of the executor + */ + +import { NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +interface HealthResponse { + status: 'ok' | 'degraded' | 'error'; + version?: string; + info?: Record; +} + +export async function GET(): Promise> { + return NextResponse.json({ + status: 'ok', + version: '1.0.0', + info: { + runtime: 'vercel-serverless', + timestamp: new Date().toISOString(), + }, + }); +} + +// Handle OPTIONS for CORS preflight +export async function OPTIONS(): Promise { + return new NextResponse(null, { + status: 200, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }, + }); +} diff --git a/templates/vercel-executor/app/layout.tsx b/templates/vercel-executor/app/layout.tsx new file mode 100644 index 0000000..225b603 --- /dev/null +++ b/templates/vercel-executor/app/layout.tsx @@ -0,0 +1,11 @@ +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/templates/vercel-executor/app/page.tsx b/templates/vercel-executor/app/page.tsx new file mode 100644 index 0000000..867088a --- /dev/null +++ b/templates/vercel-executor/app/page.tsx @@ -0,0 +1,20 @@ +export default function Home() { + return ( +
+

TPMJS Executor

+

This is a TPMJS tool executor service.

+

Endpoints

+
    +
  • + GET /api/health - Health check +
  • +
  • + POST /api/execute-tool - Execute a tool +
  • +
+

+ Documentation +

+
+ ); +} diff --git a/templates/vercel-executor/lib/executor.ts b/templates/vercel-executor/lib/executor.ts new file mode 100644 index 0000000..0837479 --- /dev/null +++ b/templates/vercel-executor/lib/executor.ts @@ -0,0 +1,101 @@ +/** + * TPMJS Executor - Core Execution Logic + * + * This module handles dynamic import and execution of TPMJS tools. + * Tools are loaded from esm.sh and executed in a serverless environment. + */ + +export interface ExecuteToolRequest { + /** NPM package name, e.g., "@tpmjs/hello" */ + packageName: string; + /** Tool name within the package, e.g., "helloWorld" */ + name: string; + /** Package version, e.g., "1.0.0" or "latest" */ + version?: string; + /** Direct esm.sh URL override for the package */ + importUrl?: string; + /** Tool parameters to pass to execute() */ + params: Record; + /** Environment variables to inject during execution */ + env?: Record; +} + +export interface ExecuteToolResponse { + /** Whether the execution succeeded */ + success: boolean; + /** Tool output on success */ + output?: unknown; + /** Error message on failure */ + error?: string; + /** Execution duration in milliseconds */ + executionTimeMs: number; +} + +/** + * Build the esm.sh URL for a package + */ +function buildEsmUrl(packageName: string, version?: string): string { + const resolvedVersion = version || 'latest'; + return `https://esm.sh/${packageName}@${resolvedVersion}`; +} + +/** + * Execute a TPMJS tool + * + * @param request - Execution request with package name, tool name, and parameters + * @returns Execution result + */ +export async function executeTool(request: ExecuteToolRequest): Promise { + const startTime = Date.now(); + + try { + // Build the import URL + const importUrl = request.importUrl || buildEsmUrl(request.packageName, request.version); + + // Dynamically import the package from esm.sh + const module = await import(/* webpackIgnore: true */ importUrl); + + // Get the tool export + // Try the specific tool name first, then fall back to default export + const tool = module[request.name] || module.default; + + if (!tool) { + return { + success: false, + error: `Tool '${request.name}' not found in package '${request.packageName}'`, + executionTimeMs: Date.now() - startTime, + }; + } + + // Ensure the tool has an execute function + if (typeof tool.execute !== 'function') { + return { + success: false, + error: `Tool '${request.name}' does not have an execute() function`, + executionTimeMs: Date.now() - startTime, + }; + } + + // Inject environment variables if provided + if (request.env) { + for (const [key, value] of Object.entries(request.env)) { + process.env[key] = value; + } + } + + // Execute the tool + const output = await tool.execute(request.params); + + return { + success: true, + output, + executionTimeMs: Date.now() - startTime, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + executionTimeMs: Date.now() - startTime, + }; + } +} diff --git a/templates/vercel-executor/next.config.js b/templates/vercel-executor/next.config.js new file mode 100644 index 0000000..159f61c --- /dev/null +++ b/templates/vercel-executor/next.config.js @@ -0,0 +1,10 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + // Enable experimental features for better serverless performance + experimental: { + // Allow dynamic imports from esm.sh + serverActions: true, + }, +}; + +module.exports = nextConfig; diff --git a/templates/vercel-executor/package.json b/templates/vercel-executor/package.json new file mode 100644 index 0000000..13175e4 --- /dev/null +++ b/templates/vercel-executor/package.json @@ -0,0 +1,21 @@ +{ + "name": "tpmjs-executor", + "version": "1.0.0", + "private": true, + "description": "TPMJS Tool Executor - Deploy your own executor for running TPMJS tools", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "^15.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/react": "^19.0.0", + "typescript": "^5.6.0" + } +} diff --git a/templates/vercel-executor/tsconfig.json b/templates/vercel-executor/tsconfig.json new file mode 100644 index 0000000..e9ca8d0 --- /dev/null +++ b/templates/vercel-executor/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "paths": { + "@/*": ["./*"] + } + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/templates/vercel-executor/vercel.json b/templates/vercel-executor/vercel.json new file mode 100644 index 0000000..afa288d --- /dev/null +++ b/templates/vercel-executor/vercel.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "nextjs", + "headers": [ + { + "source": "/api/(.*)", + "headers": [ + { "key": "Access-Control-Allow-Origin", "value": "*" }, + { "key": "Access-Control-Allow-Methods", "value": "GET, POST, OPTIONS" }, + { "key": "Access-Control-Allow-Headers", "value": "Content-Type, Authorization" } + ] + } + ] +}