refactor(executor): use Vercel Sandbox SDK for isolated VM execution
- Replace community Deno runtime with @vercel/sandbox SDK - Next.js API routes create ephemeral sandbox VMs per execution - Each tool execution: create VM → npm install → run → cleanup - node22 runtime in sandbox handles npm packages natively - Region pinned to iad1 (only region with Sandbox support) - Proper authentication support via EXECUTOR_API_KEY This provides true isolation - each tool runs in its own VM that's destroyed after execution. More secure than shared serverless.
This commit is contained in:
parent
300083aff9
commit
846676ba15
10 changed files with 385 additions and 302 deletions
|
|
@ -1,10 +1,10 @@
|
||||||
# TPMJS Executor for Vercel
|
# TPMJS Executor for Vercel
|
||||||
|
|
||||||
Deploy your own TPMJS tool executor using the **Deno runtime** on Vercel for secure, isolated code execution.
|
Deploy your own TPMJS tool executor using **Vercel Sandbox** for secure, isolated code execution.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Native HTTP Imports**: Deno natively supports importing from esm.sh
|
- **Secure Execution**: Tools run in isolated Vercel Sandbox VMs
|
||||||
- **Full Control**: Your infrastructure, your environment variables
|
- **Full Control**: Your infrastructure, your environment variables
|
||||||
- **Privacy**: No data passes through TPMJS servers
|
- **Privacy**: No data passes through TPMJS servers
|
||||||
- **One-Click Deploy**: Deploy to Vercel in minutes
|
- **One-Click Deploy**: Deploy to Vercel in minutes
|
||||||
|
|
@ -15,14 +15,14 @@ Deploy your own TPMJS tool executor using the **Deno runtime** on Vercel for sec
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
This executor uses the [Vercel Deno Runtime](https://github.com/vercel-community/deno) to:
|
This executor uses [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox) to:
|
||||||
|
|
||||||
1. Receive tool execution requests via POST `/api/execute-tool`
|
1. Create an isolated VM for each tool execution
|
||||||
2. Dynamically import the npm package from esm.sh (Deno natively supports HTTP imports!)
|
2. Install the npm package in the sandbox
|
||||||
3. Execute the tool with your parameters
|
3. Execute the tool with your parameters
|
||||||
4. Return the result
|
4. Return the result and destroy the sandbox
|
||||||
|
|
||||||
This provides the same execution model as the Railway executor, but deployed to your own Vercel account.
|
This provides secure, isolated execution without the limitations of Node.js serverless functions.
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
|
|
@ -40,8 +40,8 @@ curl https://your-executor.vercel.app/api/health
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"info": {
|
"info": {
|
||||||
"runtime": "deno",
|
"runtime": "vercel-sandbox",
|
||||||
"httpImports": true,
|
"region": "iad1",
|
||||||
"timestamp": "2024-01-01T00:00:00.000Z"
|
"timestamp": "2024-01-01T00:00:00.000Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -56,7 +56,7 @@ curl -X POST https://your-executor.vercel.app/api/execute-tool \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "Authorization: Bearer your-api-key" \
|
-H "Authorization: Bearer your-api-key" \
|
||||||
-d '{
|
-d '{
|
||||||
"packageName": "@anthropic-ai/tpmjs-hello",
|
"packageName": "@tpmjs/hello",
|
||||||
"name": "helloWorld",
|
"name": "helloWorld",
|
||||||
"version": "latest",
|
"version": "latest",
|
||||||
"params": { "name": "World" }
|
"params": { "name": "World" }
|
||||||
|
|
@ -68,7 +68,7 @@ curl -X POST https://your-executor.vercel.app/api/execute-tool \
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"output": "Hello, World!",
|
"output": "Hello, World!",
|
||||||
"executionTimeMs": 234
|
"executionTimeMs": 2345
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -99,45 +99,45 @@ Add custom environment variables for your tools (e.g., `MY_API_KEY`, `DATABASE_U
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install Vercel CLI
|
# Install dependencies
|
||||||
npm install -g vercel
|
npm install
|
||||||
|
|
||||||
# Login to Vercel
|
# Login to Vercel (required for sandbox access)
|
||||||
vercel login
|
vercel login
|
||||||
|
|
||||||
|
# Link to your Vercel project
|
||||||
|
vercel link
|
||||||
|
|
||||||
|
# Pull environment variables
|
||||||
|
vercel env pull
|
||||||
|
|
||||||
# Run development server
|
# Run development server
|
||||||
vercel dev
|
npm run dev
|
||||||
|
|
||||||
# Test the health endpoint
|
# Test the health endpoint
|
||||||
curl http://localhost:3000/api/health
|
curl http://localhost:3000/api/health
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** Local development requires the Vercel CLI. Run `vercel login` first.
|
**Note:** Vercel Sandbox requires authentication even in development. Run `vercel login` and `vercel link` first.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- Set `EXECUTOR_API_KEY` to require authentication for all requests
|
- Set `EXECUTOR_API_KEY` to require authentication for all requests
|
||||||
- Tools are loaded dynamically from esm.sh
|
- Tools run in isolated VMs with no access to your Vercel project
|
||||||
- Each request gets fresh environment variable injection
|
- Each execution gets a fresh sandbox instance
|
||||||
- CORS headers allow cross-origin requests (configurable)
|
- Sandboxes are destroyed after execution completes
|
||||||
|
|
||||||
## How It Compares to TPMJS Default Executor
|
|
||||||
|
|
||||||
| Feature | TPMJS Default (Railway) | Your Vercel Executor |
|
|
||||||
|---------|------------------------|---------------------|
|
|
||||||
| Runtime | Deno on Railway | Deno on Vercel |
|
|
||||||
| Cost | Free (TPMJS hosted) | Your Vercel usage |
|
|
||||||
| Env Vars | Stored in TPMJS | Stored in your Vercel project |
|
|
||||||
| Privacy | Requests go through TPMJS | Direct to your executor |
|
|
||||||
| Control | Managed by TPMJS | Fully yours |
|
|
||||||
|
|
||||||
## Pricing
|
## Pricing
|
||||||
|
|
||||||
Vercel's free tier includes generous limits for serverless functions. See [Vercel Pricing](https://vercel.com/pricing) for details.
|
Vercel Sandbox usage is billed based on compute time. See [Vercel Sandbox Pricing](https://vercel.com/docs/vercel-sandbox/pricing) for details.
|
||||||
|
|
||||||
|
- **Hobby**: 45 min max runtime
|
||||||
|
- **Pro**: 5 hour max runtime
|
||||||
|
- **Region**: Currently only available in `iad1`
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
- [TPMJS Custom Executors Documentation](https://tpmjs.com/docs/executors)
|
- [TPMJS Custom Executors Documentation](https://tpmjs.com/docs/executors)
|
||||||
- [TPMJS Custom Executor Tutorial](https://tpmjs.com/docs/tutorials/custom-executor)
|
- [TPMJS Custom Executor Tutorial](https://tpmjs.com/docs/tutorials/custom-executor)
|
||||||
- [Vercel Deno Runtime](https://github.com/vercel-community/deno)
|
- [Vercel Sandbox Documentation](https://vercel.com/docs/vercel-sandbox)
|
||||||
- [GitHub Issues](https://github.com/tpmjs/tpmjs/issues)
|
- [GitHub Issues](https://github.com/tpmjs/tpmjs/issues)
|
||||||
|
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
/**
|
|
||||||
* Tool Execution Endpoint (Deno Runtime)
|
|
||||||
*
|
|
||||||
* POST /api/execute-tool
|
|
||||||
* Execute a TPMJS tool with parameters
|
|
||||||
*
|
|
||||||
* Uses Deno's native HTTP import support to load tools from esm.sh
|
|
||||||
*/
|
|
||||||
|
|
||||||
// @ts-ignore - Deno global
|
|
||||||
declare const Deno: {
|
|
||||||
env: {
|
|
||||||
get(key: string): string | undefined;
|
|
||||||
set(key: string, value: string): void;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ExecuteToolRequest {
|
|
||||||
packageName: string;
|
|
||||||
name: string;
|
|
||||||
version?: string;
|
|
||||||
importUrl?: string;
|
|
||||||
params: Record<string, unknown>;
|
|
||||||
env?: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExecuteToolResponse {
|
|
||||||
success: boolean;
|
|
||||||
output?: unknown;
|
|
||||||
error?: string;
|
|
||||||
executionTimeMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple in-memory cache for tool modules
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: Tool types are dynamic
|
|
||||||
const moduleCache = new Map<string, { module: any; expiresAt: number }>();
|
|
||||||
const CACHE_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
|
||||||
|
|
||||||
export default async function handler(req: Request): Promise<Response> {
|
|
||||||
// Handle CORS preflight
|
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
return new Response(null, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
||||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check authorization if EXECUTOR_API_KEY is set
|
|
||||||
const apiKey = Deno.env.get('EXECUTOR_API_KEY');
|
|
||||||
if (apiKey) {
|
|
||||||
const authHeader = req.headers.get('Authorization');
|
|
||||||
if (!authHeader || authHeader !== `Bearer ${apiKey}`) {
|
|
||||||
return Response.json(
|
|
||||||
{ success: false, error: 'Unauthorized' },
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
let packageName = 'unknown';
|
|
||||||
let toolName = 'unknown';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body: ExecuteToolRequest = await req.json();
|
|
||||||
packageName = body.packageName || 'unknown';
|
|
||||||
toolName = body.name || 'unknown';
|
|
||||||
|
|
||||||
const { version, importUrl, params, env } = body;
|
|
||||||
|
|
||||||
if (!body.packageName || !body.name) {
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: 'Missing required fields: packageName, name',
|
|
||||||
executionTimeMs: Date.now() - startTime,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedVersion = version || 'latest';
|
|
||||||
const cacheKey = `${packageName}@${resolvedVersion}::${toolName}`;
|
|
||||||
|
|
||||||
// Inject environment variables before loading/executing tool
|
|
||||||
if (env && typeof env === 'object') {
|
|
||||||
for (const [key, value] of Object.entries(env)) {
|
|
||||||
Deno.env.set(key, String(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: Tool types are dynamic
|
|
||||||
let toolModule: any;
|
|
||||||
const cachedEntry = moduleCache.get(cacheKey);
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
if (cachedEntry && now < cachedEntry.expiresAt) {
|
|
||||||
toolModule = cachedEntry.module;
|
|
||||||
} else {
|
|
||||||
// Dynamic import from esm.sh - Deno supports this natively!
|
|
||||||
const url = importUrl || `https://esm.sh/${packageName}@${resolvedVersion}`;
|
|
||||||
const module = await import(url);
|
|
||||||
|
|
||||||
let rawExport = module[toolName];
|
|
||||||
|
|
||||||
if (!rawExport) {
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: `Export "${toolName}" not found in module`,
|
|
||||||
availableExports: Object.keys(module),
|
|
||||||
executionTimeMs: Date.now() - startTime,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle factory functions (tools that need to be called to create the tool instance)
|
|
||||||
if (typeof rawExport === 'function' && !rawExport.execute) {
|
|
||||||
let factoryResult = null;
|
|
||||||
|
|
||||||
// Strategy 1: Try calling with no arguments
|
|
||||||
try {
|
|
||||||
factoryResult = rawExport();
|
|
||||||
if (factoryResult?.execute) {
|
|
||||||
rawExport = factoryResult;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Try other strategies
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 2: Try with env config object
|
|
||||||
if (!factoryResult?.execute && env) {
|
|
||||||
const configVariations = [
|
|
||||||
{ ...env },
|
|
||||||
// Extract API key if present
|
|
||||||
(() => {
|
|
||||||
const apiKeyEntry = Object.entries(env).find(([key]) =>
|
|
||||||
key.toUpperCase().includes('API_KEY')
|
|
||||||
);
|
|
||||||
return apiKeyEntry ? { apiKey: apiKeyEntry[1] } : null;
|
|
||||||
})(),
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
for (const config of configVariations) {
|
|
||||||
try {
|
|
||||||
factoryResult = rawExport(config);
|
|
||||||
if (factoryResult?.execute) {
|
|
||||||
rawExport = factoryResult;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Try next config
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 3: Try with first env value (single-arg pattern)
|
|
||||||
if (!factoryResult?.execute && env) {
|
|
||||||
const firstValue = Object.values(env)[0];
|
|
||||||
if (firstValue) {
|
|
||||||
try {
|
|
||||||
factoryResult = rawExport(firstValue);
|
|
||||||
if (factoryResult?.execute) {
|
|
||||||
rawExport = factoryResult;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Factory failed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rawExport?.execute) {
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: `Tool "${toolName}" is a factory function but couldn't be initialized`,
|
|
||||||
hint: 'This tool may require specific configuration. Check package documentation.',
|
|
||||||
executionTimeMs: Date.now() - startTime,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toolModule = rawExport;
|
|
||||||
|
|
||||||
if (!toolModule.execute) {
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: 'Tool missing execute function',
|
|
||||||
executionTimeMs: Date.now() - startTime,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the module
|
|
||||||
moduleCache.set(cacheKey, {
|
|
||||||
module: toolModule,
|
|
||||||
expiresAt: now + CACHE_TTL_MS,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute the tool with AI SDK execution context
|
|
||||||
const abortController = new AbortController();
|
|
||||||
const executionContext = {
|
|
||||||
abortSignal: abortController.signal,
|
|
||||||
messages: [],
|
|
||||||
toolCallId: `exec_${Date.now()}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await toolModule.execute(params || {}, executionContext);
|
|
||||||
|
|
||||||
const response: ExecuteToolResponse = {
|
|
||||||
success: true,
|
|
||||||
output: result,
|
|
||||||
executionTimeMs: Date.now() - startTime,
|
|
||||||
};
|
|
||||||
|
|
||||||
return Response.json(response, {
|
|
||||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const response: ExecuteToolResponse = {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
executionTimeMs: Date.now() - startTime,
|
|
||||||
};
|
|
||||||
|
|
||||||
return Response.json(response, {
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
256
templates/vercel-executor/app/api/execute-tool/route.ts
Normal file
256
templates/vercel-executor/app/api/execute-tool/route.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
/**
|
||||||
|
* Tool Execution Endpoint using Vercel Sandbox
|
||||||
|
*
|
||||||
|
* POST /api/execute-tool
|
||||||
|
* Execute a TPMJS tool in an isolated Vercel Sandbox VM
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Sandbox } from '@vercel/sandbox';
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 300; // 5 minutes max
|
||||||
|
|
||||||
|
interface ExecuteToolRequest {
|
||||||
|
packageName: string;
|
||||||
|
name: string;
|
||||||
|
version?: string;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExecuteToolResponse {
|
||||||
|
success: boolean;
|
||||||
|
output?: unknown;
|
||||||
|
error?: string;
|
||||||
|
executionTimeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest): Promise<NextResponse<ExecuteToolResponse>> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Check authorization if EXECUTOR_API_KEY is set
|
||||||
|
const apiKey = process.env.EXECUTOR_API_KEY;
|
||||||
|
if (apiKey) {
|
||||||
|
const authHeader = req.headers.get('Authorization');
|
||||||
|
if (!authHeader || authHeader !== `Bearer ${apiKey}`) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Unauthorized', executionTimeMs: Date.now() - startTime },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sandbox: Sandbox | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body: ExecuteToolRequest = await req.json();
|
||||||
|
const { packageName, name, version = 'latest', params, env } = body;
|
||||||
|
|
||||||
|
if (!packageName || !name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Missing required fields: packageName, name',
|
||||||
|
executionTimeMs: Date.now() - startTime,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageSpec = `${packageName}@${version}`;
|
||||||
|
|
||||||
|
// Create sandbox with node22 runtime
|
||||||
|
sandbox = await Sandbox.create({
|
||||||
|
runtime: 'node22',
|
||||||
|
timeout: 2 * 60 * 1000, // 2 minute timeout for the sandbox
|
||||||
|
});
|
||||||
|
|
||||||
|
// Install the npm package
|
||||||
|
await sandbox.runCommand({
|
||||||
|
cmd: 'npm',
|
||||||
|
args: ['install', '--no-save', packageSpec],
|
||||||
|
cwd: '/vercel/sandbox',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build environment setup code
|
||||||
|
const envSetup = env
|
||||||
|
? Object.entries(env)
|
||||||
|
.map(([key, value]) => `process.env[${JSON.stringify(key)}] = ${JSON.stringify(value)};`)
|
||||||
|
.join('\n')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Create the execution script
|
||||||
|
const script = `
|
||||||
|
${envSetup}
|
||||||
|
|
||||||
|
const pkg = require('${packageName}');
|
||||||
|
|
||||||
|
// Get the tool export
|
||||||
|
let tool = pkg['${name}'] || pkg.default?.['${name}'] || pkg.default;
|
||||||
|
|
||||||
|
if (!tool) {
|
||||||
|
console.log(JSON.stringify({ __tpmjs_error__: 'Tool "${name}" not found in package "${packageName}"' }));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle factory functions (tools that need to be called to create the tool instance)
|
||||||
|
async function resolveFactory(rawTool, envVars) {
|
||||||
|
if (typeof rawTool !== 'function' || rawTool.execute) {
|
||||||
|
return rawTool;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 1: Try calling with no arguments
|
||||||
|
try {
|
||||||
|
const result = rawTool();
|
||||||
|
if (result && typeof result.execute === 'function') {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Strategy 2: Try with env config object
|
||||||
|
if (envVars) {
|
||||||
|
const configVariations = [
|
||||||
|
envVars,
|
||||||
|
// Extract API key if present
|
||||||
|
(() => {
|
||||||
|
const entry = Object.entries(envVars).find(([k]) => k.toUpperCase().includes('API_KEY'));
|
||||||
|
return entry ? { apiKey: entry[1] } : null;
|
||||||
|
})(),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
for (const config of configVariations) {
|
||||||
|
try {
|
||||||
|
const result = rawTool(config);
|
||||||
|
if (result && typeof result.execute === 'function') {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawTool;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const envVars = ${env ? JSON.stringify(env) : 'null'};
|
||||||
|
const resolvedTool = await resolveFactory(tool, envVars);
|
||||||
|
|
||||||
|
if (!resolvedTool || typeof resolvedTool.execute !== 'function') {
|
||||||
|
console.log(JSON.stringify({ __tpmjs_error__: 'Tool "${name}" does not have an execute() function' }));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = ${JSON.stringify(params)};
|
||||||
|
const result = await resolvedTool.execute(params);
|
||||||
|
|
||||||
|
console.log(JSON.stringify({ __tpmjs_result__: result }));
|
||||||
|
} catch (err) {
|
||||||
|
console.log(JSON.stringify({ __tpmjs_error__: err.message || String(err) }));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Write the script to a file
|
||||||
|
await sandbox.writeFiles([
|
||||||
|
{ path: '/vercel/sandbox/execute.cjs', content: Buffer.from(script) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Run the script and capture output
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
await sandbox.runCommand({
|
||||||
|
cmd: 'node',
|
||||||
|
args: ['execute.cjs'],
|
||||||
|
cwd: '/vercel/sandbox',
|
||||||
|
stdout: {
|
||||||
|
write(chunk: Buffer | string) {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
} as NodeJS.WritableStream,
|
||||||
|
stderr: {
|
||||||
|
write(chunk: Buffer | string) {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
} as NodeJS.WritableStream,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse the output to extract the result
|
||||||
|
const lines = stdout.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.includes('__tpmjs_result__')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
output: parsed.__tpmjs_result__,
|
||||||
|
executionTimeMs: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Continue searching
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line.includes('__tpmjs_error__')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: parsed.__tpmjs_error__,
|
||||||
|
executionTimeMs: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Continue searching
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we couldn't find structured output, check stderr
|
||||||
|
if (stderr) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: stderr.trim(),
|
||||||
|
executionTimeMs: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return raw stdout if no structured result
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
output: stdout.trim() || null,
|
||||||
|
executionTimeMs: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
executionTimeMs: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
// Always stop the sandbox
|
||||||
|
if (sandbox) {
|
||||||
|
try {
|
||||||
|
await sandbox.stop();
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle OPTIONS for CORS preflight
|
||||||
|
export async function OPTIONS(): Promise<NextResponse> {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,28 +1,37 @@
|
||||||
/**
|
/**
|
||||||
* Health Check Endpoint (Deno Runtime)
|
* Health Check Endpoint
|
||||||
*
|
*
|
||||||
* GET /api/health
|
* GET /api/health
|
||||||
* Returns the health status of the executor
|
* Returns the health status of the executor
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
interface HealthResponse {
|
interface HealthResponse {
|
||||||
status: 'ok' | 'degraded' | 'error';
|
status: 'ok' | 'degraded' | 'error';
|
||||||
version?: string;
|
version?: string;
|
||||||
info?: Record<string, unknown>;
|
info?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function handler(_req: Request): Response {
|
export async function GET(): Promise<NextResponse<HealthResponse>> {
|
||||||
const response: HealthResponse = {
|
return NextResponse.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
info: {
|
info: {
|
||||||
runtime: 'deno',
|
runtime: 'vercel-sandbox',
|
||||||
httpImports: true,
|
region: 'iad1',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return Response.json(response, {
|
// Handle OPTIONS for CORS preflight
|
||||||
|
export async function OPTIONS(): Promise<NextResponse> {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
12
templates/vercel-executor/app/layout.tsx
Normal file
12
templates/vercel-executor/app/layout.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export const metadata = {
|
||||||
|
title: 'TPMJS Executor',
|
||||||
|
description: 'TPMJS Tool Executor using Vercel Sandbox',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
templates/vercel-executor/app/page.tsx
Normal file
31
templates/vercel-executor/app/page.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<main style={{ fontFamily: 'system-ui', padding: '2rem', maxWidth: '600px', margin: '0 auto' }}>
|
||||||
|
<h1>TPMJS Executor</h1>
|
||||||
|
<p>This is a custom TPMJS tool executor using Vercel Sandbox.</p>
|
||||||
|
|
||||||
|
<h2>Endpoints</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<code>GET /api/health</code> - Health check
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>POST /api/execute-tool</code> - Execute a tool
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Documentation</h2>
|
||||||
|
<p>
|
||||||
|
See the{' '}
|
||||||
|
<a
|
||||||
|
href="https://tpmjs.com/docs/tutorials/custom-executor"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
>
|
||||||
|
Custom Executor Tutorial
|
||||||
|
</a>{' '}
|
||||||
|
for setup instructions.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
templates/vercel-executor/next.config.js
Normal file
6
templates/vercel-executor/next.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
// Minimal config for API-only executor
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
|
|
@ -2,11 +2,18 @@
|
||||||
"name": "tpmjs-executor",
|
"name": "tpmjs-executor",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "TPMJS Tool Executor - Deploy your own executor using Deno on Vercel",
|
"description": "TPMJS Tool Executor - Deploy your own executor using Vercel Sandbox",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vercel dev"
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vercel/sandbox": "^0.1.0",
|
||||||
|
"next": "^15.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vercel": "^39.0.0"
|
"@types/node": "^22.0.0",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
templates/vercel-executor/tsconfig.json
Normal file
21
templates/vercel-executor/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"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,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||||
"functions": {
|
"framework": "nextjs",
|
||||||
"api/**/*.ts": {
|
"regions": ["iad1"],
|
||||||
"runtime": "vercel-deno@3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"headers": [
|
"headers": [
|
||||||
{
|
{
|
||||||
"source": "/api/(.*)",
|
"source": "/api/(.*)",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue