From 60a450d35d3507f0e6c177492d298d1e5e23bf47 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Fri, 12 Jun 2026 16:41:49 -0400 Subject: [PATCH] feat(apigatewayv2): add dedupeHandlers to reuse Integration + Function across routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AWS HTTP APIs cap Integrations per API at 300 — a hard cap that is NOT adjustable through Service Quotas (only Routes are, via L-65B5C802). Today every route() call creates a new Integration + Lambda + Permission even when the handler string is identical, so splitting routes for clarity (different auth, rate limits, etc.) burns Integration slots unnecessarily. On large monorepos this is the real scaling ceiling people hit. Add an opt-in `dedupeHandlers: true` flag on ApiGatewayV2. When set, the Nth route() call against the same handler string reuses the first call's Integration + Function + Permission and only creates a new apigatewayv2.Route. Routes stay capped (default 300, raisable to 1000+) but Integrations stay at exactly one per unique handler. The route component keeps a stable URN across the dedup boundary (no separate shared-route component), so migrating an existing app from non-dedup state doesn't make Pulumi recreate routes and hit ConflictException at API Gateway. Refs sst/sst#4559 (duplicate per-route resources). Same approach aws-cdk took in aws/aws-cdk#12528 (dedupe HTTP integrations per route). --- .../aws/apigatewayv2-lambda-route.ts | 122 +++++++++++------- platform/src/components/aws/apigatewayv2.ts | 112 ++++++++++++++-- 2 files changed, 176 insertions(+), 58 deletions(-) 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; } /**