diff --git a/platform/src/components/aws/apigatewayv2-lambda-route.ts b/platform/src/components/aws/apigatewayv2-lambda-route.ts index f511a27264..9a034cfb8a 100644 --- a/platform/src/components/aws/apigatewayv2-lambda-route.ts +++ b/platform/src/components/aws/apigatewayv2-lambda-route.ts @@ -6,7 +6,7 @@ import { output, } from "@pulumi/pulumi"; import { Component, Transform, transform } from "../component"; -import { FunctionArgs, FunctionArn } from "./function.js"; +import { Function, FunctionArgs, FunctionArn } from "./function.js"; import { apigatewayv2, lambda } from "@pulumi/aws"; import { ApiGatewayV2BaseRouteArgs, @@ -29,6 +29,24 @@ export interface Args extends ApiGatewayV2BaseRouteArgs { * @internal */ handlerTransform?: Transform; + /** + * Reuse an already-created Lambda function, invoke permission, and + * `apigatewayv2.Integration` instead of creating new ones. + * + * Populated by `ApiGatewayV2.route()` when `dedupeHandlers: true` is set on + * the parent API and the current call's handler string matches an earlier + * call's. The component type (and therefore the child `apigatewayv2.Route` + * URN) stays the same whether this field is set or not — that's what keeps + * Pulumi from trying to create a second API-Gateway route with the same + * key during a dedup migration. + * + * @internal + */ + sharedIntegration?: { + integration: apigatewayv2.Integration; + lambdaFunction: Output; + permission: lambda.Permission; + }; } /** @@ -42,7 +60,8 @@ export interface Args extends ApiGatewayV2BaseRouteArgs { * You'll find this component returned by the `route` method of the `ApiGatewayV2` component. */ export class ApiGatewayV2LambdaRoute extends Component { - private readonly fn: FunctionBuilder; + private readonly fn: FunctionBuilder | undefined; + private readonly sharedLambdaFunction: Output | undefined; private readonly permission: lambda.Permission; private readonly apiRoute: Output; private readonly integration: apigatewayv2.Integration; @@ -53,59 +72,63 @@ export class ApiGatewayV2LambdaRoute extends Component { const self = this; const api = output(args.api); const route = output(args.route); + const shared = args.sharedIntegration; - const fn = createFunction(); - const permission = createPermission(); - const integration = createIntegration(); - const apiRoute = createApiRoute(name, args, integration.id, self); + if (shared) { + // Dedup path: reuse the first-call's Function, Permission, and Integration. + // Only the `apigatewayv2.Route` resource is created here, targeting the + // existing integration. The component URN stays `ApiGatewayV2LambdaRoute` + // so Pulumi doesn't treat a dedup-migration as a resource replacement. + this.fn = undefined; + this.sharedLambdaFunction = shared.lambdaFunction; + this.permission = shared.permission; + this.integration = shared.integration; + this.apiRoute = createApiRoute(name, args, output(shared.integration.id), self); + return; + } - this.fn = fn; - this.permission = permission; - this.apiRoute = apiRoute; - this.integration = integration; + const fn = functionBuilder( + `${name}Handler`, + args.handler, + { + description: interpolate`${api.name} route ${route}`, + link: args.handlerLink, + }, + args.handlerTransform, + { parent: self }, + ); - function createFunction() { - return functionBuilder( - `${name}Handler`, - args.handler, - { - description: interpolate`${api.name} route ${route}`, - link: args.handlerLink, - }, - args.handlerTransform, - { parent: self }, - ); - } + const permission = new lambda.Permission( + `${name}Permissions`, + { + action: "lambda:InvokeFunction", + function: fn.arn, + qualifier: fn.qualifier.apply((qualifier) => qualifier!), + principal: "apigateway.amazonaws.com", + sourceArn: interpolate`${api.executionArn}/*`, + }, + { parent: self }, + ); - function createPermission() { - return new lambda.Permission( - `${name}Permissions`, + const integration = new apigatewayv2.Integration( + ...transform( + args.transform?.integration, + `${name}Integration`, { - action: "lambda:InvokeFunction", - function: fn.arn, - qualifier: fn.qualifier.apply((qualifier) => qualifier!), - principal: "apigateway.amazonaws.com", - sourceArn: interpolate`${api.executionArn}/*`, + apiId: api.id, + integrationType: "AWS_PROXY", + integrationUri: fn.targetArn, + payloadFormatVersion: "2.0", }, - { parent: self }, - ); - } + { parent: self, dependsOn: [permission] }, + ), + ); - function createIntegration() { - return new apigatewayv2.Integration( - ...transform( - args.transform?.integration, - `${name}Integration`, - { - apiId: api.id, - integrationType: "AWS_PROXY", - integrationUri: fn.targetArn, - payloadFormatVersion: "2.0", - }, - { parent: self, dependsOn: [permission] }, - ), - ); - } + this.fn = fn; + this.sharedLambdaFunction = undefined; + this.permission = permission; + this.apiRoute = createApiRoute(name, args, integration.id, self); + this.integration = integration; } /** @@ -118,7 +141,8 @@ export class ApiGatewayV2LambdaRoute extends Component { * The Lambda function. */ get function() { - return self.fn.apply((fn) => fn.getFunction()); + if (self.sharedLambdaFunction) return self.sharedLambdaFunction; + return self.fn!.apply((fn) => fn.getFunction()); }, /** * The Lambda permission. diff --git a/platform/src/components/aws/apigatewayv2.ts b/platform/src/components/aws/apigatewayv2.ts index 848c5a5781..ea10356209 100644 --- a/platform/src/components/aws/apigatewayv2.ts +++ b/platform/src/components/aws/apigatewayv2.ts @@ -28,6 +28,24 @@ import { import { ApiGatewayV2PrivateRoute } from "./apigatewayv2-private-route"; import { Vpc } from "./vpc"; +/** + * Compute a stable fingerprint for a route handler so that `dedupeHandlers` + * can keep Integrations + Lambdas + Permissions shared across identical + * `route()` calls. + * + * Only plain string handlers (handler paths and function ARNs) are + * fingerprinted — `FunctionArgs` objects and Pulumi `Output`s return `null` + * (no dedup) because we'd need to resolve them asynchronously to compare, + * which doesn't compose with the synchronous `route()` API. Users that want + * dedup with `FunctionArgs` should hoist their handler path to a shared + * string constant. + */ +function fingerprintHandler( + handler: Input, +): string | null { + return typeof handler === "string" ? handler : null; +} + interface ApiGatewayV2CorsArgs { /** * Allow cookies or other credentials in requests to the HTTP API. @@ -269,6 +287,42 @@ export interface ApiGatewayV2Args { */ subnets: Input[]>; }>; + /** + * Deduplicate the underlying Lambda function, invoke permission, and + * `apigatewayv2.Integration` when multiple `route()` calls share the same + * handler. + * + * AWS HTTP APIs cap Integrations at 300 per API — a hard cap that is *not* + * adjustable through Service Quotas. By default, every `route()` call + * creates a brand-new Integration even when the handler is identical, which + * means a route count above ~300 forces you to manually consolidate paths + * behind `{proxy+}` catch-alls inside your handler. + * + * With `dedupeHandlers: true`, the second (and Nth) `route()` call against + * the same handler reuses the first call's Integration + Function + + * Permission and only creates a new `apigatewayv2.Route`. Routes are still + * capped (default 300, raisable to 1000+ via Service Quota + * `L-65B5C802 "Routes per HTTP API"`), but Integrations stay at one per + * unique handler. + * + * Two handlers are treated as the same handler when both calls pass the + * **same plain string** (handler path or function ARN). `FunctionArgs` + * objects and Pulumi `Output`s do not participate in dedup — pass a string + * literal you can compare statically. + * + * @default `false` + * @example + * ```ts + * const api = new sst.aws.ApiGatewayV2("MyApi", { dedupeHandlers: true }); + * + * // 4 routes, 1 Integration, 1 Lambda + * api.route("GET /a", "src/handler.fn"); + * api.route("GET /b", "src/handler.fn"); + * api.route("GET /c", "src/handler.fn"); + * api.route("GET /d", "src/handler.fn"); + * ``` + */ + dedupeHandlers?: boolean; /** * [Transform](/docs/components#transform) how this component creates its underlying * resources. @@ -594,6 +648,19 @@ export interface ApiGatewayV2RouteArgs { * ``` */ name?: string; + /** + * Force this route to create its own Lambda function, permission, and + * `apigatewayv2.Integration` even when the parent `ApiGatewayV2` has + * `dedupeHandlers: true`. + * + * Useful when a single route needs a route-specific transform applied to + * the underlying integration or function (e.g. a different timeout or + * `payloadFormatVersion`) and would otherwise share resources with another + * route that uses the same handler string. + * + * @default `false` + */ + _forceUnique?: boolean; /** * [Transform](/docs/components#transform) how this component creates its underlying * resources. @@ -696,6 +763,7 @@ export class ApiGatewayV2 extends Component implements Link.Linkable { private apiMapping?: Output; private logGroup: cloudwatch.LogGroup; private vpcLink?: apigatewayv2.VpcLink; + private integrationCache: Map = new Map(); constructor( name: string, @@ -1137,22 +1205,48 @@ export class ApiGatewayV2 extends Component implements Link.Linkable { args, { provider: this.constructorOpts.provider }, ); - return new ApiGatewayV2LambdaRoute( - transformed[0], + const [routeId, routeArgs, routeOpts] = transformed; + const baseApi = { + name: this.constructorName, + id: this.api.id, + executionArn: this.api.executionArn, + }; + + const fingerprint = fingerprintHandler(handler); + const dedupeEnabled = this.constructorArgs.dedupeHandlers === true; + const forceUnique = routeArgs._forceUnique === true; + const cached = + dedupeEnabled && !forceUnique && fingerprint + ? this.integrationCache.get(fingerprint) + : undefined; + + const created = new ApiGatewayV2LambdaRoute( + routeId, { - api: { - name: this.constructorName, - id: this.api.id, - executionArn: this.api.executionArn, - }, + api: baseApi, route, handler, handlerLink: this.constructorArgs.link, handlerTransform: this.constructorArgs.transform?.route?.handler, - ...transformed[1], + ...(cached + ? { + sharedIntegration: { + integration: cached.nodes.integration, + lambdaFunction: cached.nodes.function, + permission: cached.nodes.permission, + }, + } + : {}), + ...routeArgs, }, - transformed[2], + routeOpts, ); + + if (dedupeEnabled && !forceUnique && fingerprint) { + this.integrationCache.set(fingerprint, created); + } + + return created; } /**