Skip to content

Commit

Permalink
chore: add initial cloud
Browse files Browse the repository at this point in the history
chore: cloud updates
  • Loading branch information
chrisbbreuer committed Dec 10, 2024
1 parent 6f98794 commit c48fbef
Show file tree
Hide file tree
Showing 9 changed files with 277 additions and 1 deletion.
Binary file modified bun.lockb
Binary file not shown.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
35 changes: 35 additions & 0 deletions src/cloud/connect.ts
Original file line number Diff line number Diff line change
@@ -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' }
}
}
25 changes: 25 additions & 0 deletions src/cloud/disconnect.ts
Original file line number Diff line number Diff line change
@@ -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' }
}
}
89 changes: 89 additions & 0 deletions src/cloud/https.ts
Original file line number Diff line number Diff line change
@@ -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',
}
}
}
13 changes: 13 additions & 0 deletions src/cloud/index.ts
Original file line number Diff line number Diff line change
@@ -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,
},
})
112 changes: 112 additions & 0 deletions src/cloud/tunnel-stack.ts
Original file line number Diff line number Diff line change
@@ -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',
})
}
}
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
File renamed without changes.

0 comments on commit c48fbef

Please sign in to comment.