Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 73 additions & 49 deletions platform/src/components/aws/apigatewayv2-lambda-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,6 +29,24 @@ export interface Args extends ApiGatewayV2BaseRouteArgs {
* @internal
*/
handlerTransform?: Transform<FunctionArgs>;
/**
* 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<Function>;
permission: lambda.Permission;
};
}

/**
Expand All @@ -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<Function> | undefined;
private readonly permission: lambda.Permission;
private readonly apiRoute: Output<apigatewayv2.Route>;
private readonly integration: apigatewayv2.Integration;
Expand All @@ -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;
}

/**
Expand All @@ -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.
Expand Down
112 changes: 103 additions & 9 deletions platform/src/components/aws/apigatewayv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 | FunctionArgs | FunctionArn>,
): string | null {
return typeof handler === "string" ? handler : null;
}

interface ApiGatewayV2CorsArgs {
/**
* Allow cookies or other credentials in requests to the HTTP API.
Expand Down Expand Up @@ -269,6 +287,42 @@ export interface ApiGatewayV2Args {
*/
subnets: Input<Input<string>[]>;
}>;
/**
* 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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -696,6 +763,7 @@ export class ApiGatewayV2 extends Component implements Link.Linkable {
private apiMapping?: Output<apigatewayv2.ApiMapping>;
private logGroup: cloudwatch.LogGroup;
private vpcLink?: apigatewayv2.VpcLink;
private integrationCache: Map<string, ApiGatewayV2LambdaRoute> = new Map();

constructor(
name: string,
Expand Down Expand Up @@ -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;
}

/**
Expand Down