diff --git a/bun.lockb b/bun.lockb index 9a219cd..989e24f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 1b82f46..759a3bc 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,9 @@ "devDependencies": { "@stacksjs/cli": "^0.68.2", "@stacksjs/eslint-config": "^3.11.3-beta.4", + "@types/aws-lambda": "^8.10.146", "@types/bun": "^1.1.14", + "aws-cdk": "^2.172.0", "bun-config": "^0.3.2", "bun-plugin-dtsx": "^0.21.9", "typescript": "^5.7.2", diff --git a/src/cloud/connect.ts b/src/cloud/connect.ts new file mode 100644 index 0000000..7255d03 --- /dev/null +++ b/src/cloud/connect.ts @@ -0,0 +1,35 @@ +import type { APIGatewayProxyWebsocketHandlerV2 } from 'aws-lambda' +import process from 'node:process' +import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb' + +const dynamoDB = new DynamoDBClient({}) +const TABLE_NAME = process.env.TABLE_NAME! + +export const handler: APIGatewayProxyWebsocketHandlerV2 = async (event) => { + const connectionId = event.requestContext.connectionId + const subdomain = event.queryStringParameters?.subdomain + + if (!subdomain) { + return { + statusCode: 400, + body: 'Subdomain is required', + } + } + + try { + await dynamoDB.send(new PutItemCommand({ + TableName: TABLE_NAME, + Item: { + connectionId: { S: connectionId }, + subdomain: { S: subdomain }, + timestamp: { N: Date.now().toString() }, + }, + })) + + return { statusCode: 200, body: 'Connected' } + } + catch (error) { + console.error('Connection error:', error) + return { statusCode: 500, body: 'Failed to connect' } + } +} diff --git a/src/cloud/disconnect.ts b/src/cloud/disconnect.ts new file mode 100644 index 0000000..6e2d5aa --- /dev/null +++ b/src/cloud/disconnect.ts @@ -0,0 +1,25 @@ +import type { APIGatewayProxyWebsocketHandlerV2 } from 'aws-lambda' +import process from 'node:process' +import { DeleteItemCommand, DynamoDBClient } from '@aws-sdk/client-dynamodb' + +const dynamoDB = new DynamoDBClient({}) +const TABLE_NAME = process.env.TABLE_NAME! + +export const handler: APIGatewayProxyWebsocketHandlerV2 = async (event) => { + const connectionId = event.requestContext.connectionId + + try { + await dynamoDB.send(new DeleteItemCommand({ + TableName: TABLE_NAME, + Key: { + connectionId: { S: connectionId }, + }, + })) + + return { statusCode: 200, body: 'Disconnected' } + } + catch (error) { + console.error('Disconnection error:', error) + return { statusCode: 500, body: 'Failed to disconnect' } + } +} diff --git a/src/cloud/https.ts b/src/cloud/https.ts new file mode 100644 index 0000000..727e70f --- /dev/null +++ b/src/cloud/https.ts @@ -0,0 +1,89 @@ +import type { APIGatewayProxyHandlerV2 } from 'aws-lambda' +import process from 'node:process' +import { ApiGatewayManagementApiClient, PostToConnectionCommand } from '@aws-sdk/client-apigatewaymanagementapi' +import { DynamoDBClient, QueryCommand } from '@aws-sdk/client-dynamodb' + +const dynamoDB = new DynamoDBClient({}) +const TABLE_NAME = process.env.TABLE_NAME! + +export const handler: APIGatewayProxyHandlerV2 = async (event) => { + const host = event.headers.host || '' + const subdomain = host.split('.')[0] + + if (!subdomain) { + return { + statusCode: 400, + body: 'Invalid subdomain', + } + } + + try { + // Find connection for the subdomain + const connections = await dynamoDB.send(new QueryCommand({ + TableName: TABLE_NAME, + IndexName: 'subdomain-index', + KeyConditionExpression: 'subdomain = :subdomain', + ExpressionAttributeValues: { + ':subdomain': { S: subdomain }, + }, + })) + + if (!connections.Items || connections.Items.length === 0) { + return { + statusCode: 404, + body: 'Tunnel not found', + } + } + + const connection = connections.Items[0] + const connectionId = connection.connectionId.S! + + // Create API Gateway Management API client + const endpoint = new URL(process.env.WEBSOCKET_ENDPOINT!) + const apiGateway = new ApiGatewayManagementApiClient({ + endpoint: `https://${endpoint.host}`, + }) + + // Forward request to WebSocket client + const message = { + action: 'request', + connectionId, + data: { + method: event.requestContext.http.method, + path: event.rawPath, + headers: event.headers, + queryStringParameters: event.queryStringParameters, + body: event.body, + }, + } + + try { + await apiGateway.send(new PostToConnectionCommand({ + ConnectionId: connectionId, + Data: Buffer.from(JSON.stringify(message)), + })) + + // Wait for response (in a real implementation, you'd use a response queue or callback) + await new Promise(resolve => setTimeout(resolve, 1000)) + + return { + statusCode: 200, + body: 'Request forwarded', + } + } + catch (error) { + console.error('WebSocket error:', error) + return { + statusCode: 502, + body: 'Failed to forward request', + } + } + } + catch (error) { + console.error('Request handling error:', error) + return { + statusCode: 500, + body: 'Internal server error', + } + } +} diff --git a/src/cloud/index.ts b/src/cloud/index.ts new file mode 100644 index 0000000..62bd19f --- /dev/null +++ b/src/cloud/index.ts @@ -0,0 +1,13 @@ +import process from 'node:process' +import * as cdk from 'aws-cdk-lib' +import { TunnelStack } from './tunnel-stack' +import 'source-map-support/register' + +const app = new cdk.App() + +new TunnelStack(app, 'TunnelStack', { + env: { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION, + }, +}) diff --git a/src/cloud/tunnel-stack.ts b/src/cloud/tunnel-stack.ts new file mode 100644 index 0000000..adfe27c --- /dev/null +++ b/src/cloud/tunnel-stack.ts @@ -0,0 +1,112 @@ +import type { Construct } from 'constructs' +import * as path from 'node:path' +import * as cdk from 'aws-cdk-lib' +import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2' +import * as integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations' +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb' +import * as iam from 'aws-cdk-lib/aws-iam' +import * as lambda from 'aws-cdk-lib/aws-lambda' +import * as nodeLambda from 'aws-cdk-lib/aws-lambda-nodejs' + +export class TunnelStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props) + + // DynamoDB table for connection tracking + const connectionsTable = new dynamodb.Table(this, 'TunnelConnections', { + partitionKey: { + name: 'connectionId', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: cdk.RemovalPolicy.DESTROY, // For development - change for production + }) + + // Add GSI for subdomain lookup + connectionsTable.addGlobalSecondaryIndex({ + indexName: 'subdomain-index', + partitionKey: { + name: 'subdomain', + type: dynamodb.AttributeType.STRING, + }, + projectionType: dynamodb.ProjectionType.ALL, + }) + + // WebSocket API + const webSocketApi = new apigatewayv2.WebSocketApi(this, 'TunnelWebSocketApi', { + connectRouteOptions: { + integration: new integrations.WebSocketLambdaIntegration('ConnectIntegration', new nodeLambda.NodejsFunction(this, 'ConnectHandler', { + entry: path.join(__dirname, '../lambda/connect.ts'), + handler: 'handler', + runtime: lambda.Runtime.NODEJS_18_X, + environment: { + TABLE_NAME: connectionsTable.tableName, + }, + })), + }, + disconnectRouteOptions: { + integration: new integrations.WebSocketLambdaIntegration('DisconnectIntegration', new nodeLambda.NodejsFunction(this, 'DisconnectHandler', { + entry: path.join(__dirname, '../lambda/disconnect.ts'), + handler: 'handler', + runtime: lambda.Runtime.NODEJS_18_X, + environment: { + TABLE_NAME: connectionsTable.tableName, + }, + })), + }, + }) + + // WebSocket Stage + const webSocketStage = new apigatewayv2.WebSocketStage(this, 'TunnelWebSocketStage', { + webSocketApi, + stageName: 'prod', + autoDeploy: true, + }) + + // HTTP API + const httpApi = new apigatewayv2.HttpApi(this, 'TunnelHttpApi') + + // Lambda for handling HTTP requests + const httpHandler = new nodeLambda.NodejsFunction(this, 'HttpHandler', { + entry: path.join(__dirname, '../lambda/http.ts'), + handler: 'handler', + runtime: lambda.Runtime.NODEJS_18_X, + environment: { + TABLE_NAME: connectionsTable.tableName, + WEBSOCKET_ENDPOINT: webSocketStage.url, + }, + timeout: cdk.Duration.seconds(30), + }) + + // Add route to HTTP API + httpApi.addRoutes({ + path: '/{proxy+}', + methods: [apigatewayv2.HttpMethod.ANY], + integration: new integrations.HttpLambdaIntegration('HttpIntegration', httpHandler), + }) + + // Grant DynamoDB permissions + connectionsTable.grantReadWriteData(httpHandler) + + // Grant permissions to manage WebSocket connections + httpHandler.addToRolePolicy(new iam.PolicyStatement({ + actions: ['execute-api:ManageConnections'], + resources: [ + `arn:aws:execute-api:${this.region}:${this.account}:${webSocketApi.apiId}/${webSocketStage.stageName}/*`, + ], + })) + + // Outputs + // eslint-disable-next-line no-new + new cdk.CfnOutput(this, 'WebSocketApiUrl', { + value: webSocketStage.url, + description: 'WebSocket API URL', + }) + + // eslint-disable-next-line no-new + new cdk.CfnOutput(this, 'HttpApiUrl', { + value: httpApi.url!, + description: 'HTTP API URL', + }) + } +} diff --git a/src/config.ts b/src/config.ts index f6a9417..899d548 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,6 +7,6 @@ export const defaultConfig: TunnelOptions = { // eslint-disable-next-line antfu/no-top-level-await export const config: TunnelOptions = await loadConfig({ - name: 'localtunnel', + name: 'tunnel', defaultConfig, }) diff --git a/localtunnel.config.ts b/tunnel.config.ts similarity index 100% rename from localtunnel.config.ts rename to tunnel.config.ts