Skip to main content

Function

Function is the cloud's swiss army knife - an AWS Lambda Function. Functionless serializes in-line Function closures, automatically configures IAM Policies, Environment Variables, and initializes SDK clients (such as the AWS SDK) at runtime.

Declare a Function

To create a new Function, simply instantiate the Construct and provide an implementation.

new Function(scope, "foo", async () => {
console.log("hello, world");
});

Functionless is all about embedding the business logic within the infrastructure logic, so instead of referencing an external file containing the function implementation, it can be provided in-line as if it were an ordinary function.

Configure Properties

To configure its properties, such as memorySize, timeout, and log group, etc. specify an object as the third argument:

new Function(
scope,
"F",
{
memorySize: 512,
timeout: Duration.minutes(1),
},
async () => {
console.log("hello, world");
}
);

Wrap an existing Function

There are cases in which you want to integrate with an existing Lambda Function - perhaps you need to use a different runtime than NodeJS or you have existing Functions that you want to call from Functionless.

To achieve this, use the Function.from utility to wrap an existing aws_lambda.Function.

import { aws_lambda } from "aws-cdk-lib";
import { Function, StepFunction } from "@functionless/aws-constructs";

const myFunc = Function.from<{ name: string }, string>(
new aws_lambda.Function(this, "MyFunc", {
..
})
);

A wrapped function annotates the type signature of the Function and makes it available to be called from Functionless Constructs.

Request Payload

The callback (FunctionClosure) matches the interface supported by a NodeJS Lambda function handler.

Your Function must have 0, 1, or 2 arguments. The first argument contains the JSON data from the Invoke Lambda API Request payload. The second parameter is the Lambda Context Object.

// valid
async (arg: string) => {};

// valid
async () => {};

// valid
async (arg: string, context: Context) => {};

For example, if you have a Function accepting input of {key: string}:

async (input: { key: string }) => {};

It can be invoked with the following JSON data:

{
"key": "value"
}

Any valid JSON value type (not just objects) is supported - for example, a string, number, boolean or null:

async (input: string | number | boolean | null | string[]) => {};

This function can accept any of the the following input JSON payload:

null
true
false
123
123.456
"hello world"
["hello world"]

Note the surrounding double-quotes (") for strings.

Response Payload

The data returned from teh Function is serialized to JSON as the response payload.

() => ({
key: "value",
});

This function results in the following JSON response payload:

{
"key": "value"
}

Functions can be both synchronous or asynchronous. If a Promise is returned, then the result of the asynchronous execution (value contained within the Promise) is returned as the Response.

// use async/await
async () => ({
key: "value",
});

// or the Promise API
() =>
Promise.resolve({
key: "value",
});

These two functions are equivalent and result in the same JSON response:

{
"key": "value"
}

Call an Integration

Most of Functionless's integrations can be called from within a Lambda Function. Functionless will automatically infer the required IAM Policies, set any environment variables it needs (such as the ARN of a dependency) and instantiate any SDK clients when the Function is first invoked.

const Table = Table.fromTable(scope, "Table");

new Function(scope, "foo", async (id: string) => {
return $AWS.DynamoDB.GetItem({
Table: table,
Key: {
id: {
S: id,
},
},
});
});

This Function infers the following configuration and runtime code boilerplate from the function's implementation:

  1. an IAM Policy Statement allowing GetItem on the Table
{
"Action": ["dynamodb:GetItem"],
"Effect": "Allow",
"Resource": "arn:aws:dynamodb:<region>:<account-ud>:table/Table"
}
  1. an Environment Variable making the ARN of the DynamoDB Table available at runtime
{
"EnvironmentVariables": {
"Ref": "Table"
}
}
  1. when your Lambda Function calls $AWS.DynamoDB.GetItem, underneath a client is being instantiated (once per container) and used for the request. This saves your from worrying about boilerplate plumbing code.
new AWS.DynamoDB();

:::warn Cannot use Infrastructure resource in Function closure (107).

.resource (Function, StepFunction, Table, EventBus) may not be used within a Function.

const table = new Table(this, 'table', { ... });
new Function(this, 'func', async () => {
// valid use of a Table
const $AWS.DynamoDB.GetItem({
Table: table,
...
})
// invalid - .resource is not available
const index = table.resource.tableStreamArn;
});

:::

Call from an Integration

Lambda Functions can be called directly from any of Functionless's primitives, for example AppsyncResolvers, Step Functions and Lambda Functions.

await myFunc({ name: "sam" });

Input to the Lambda Function is a JSON object, as should be expected.

{
"name": "sam"
}

Output from the Lambda Function is the raw JSON value returned by the Lambda Function, for example:

"hello sam"
info

For a list of all Function integrations and more integration options, see Integrations.

Call and receive the entire API Response Envelope

To get the entire AWS SDK response, use $AWS.Lambda.Invoke:

const response = $AWS.Lambda.Invoke({
FunctionName: myFunc,
Payload: {
name,
},
});

Forward Events from an EventBus to a Lambda Function

Finally, you can route Events from an Event Bus to a Lambda Function, provided the Function's signature is compatible.

bus
.when(..)
.pipe(myFunc)

Closure Serialization

Functionless leverages Pulumi's closure serializer to serialize your function into a bundle that can be ran within the AWS Lambda Function.

The serializer captures all of your closure's state.

  1. referenced variables outside the function body
const variable = "hello";

new Function(scope, "foo", () => {
// variable is captured and serialized into the bundle
return variable;
});
  1. calls to Constructs such as other Functions or a DynamoDB Table are re-written as client API calls
const table = Table.fromTable(..)

new Function(scope, "foo", async (key: string) => {
// re-written as a call to an AWS.DynamoDB.GetItem API call
return $AWS.DynamoDB.GetItem({
Table: table,
key
});
});
  1. imported dependencies are included in the bundle as is, similarly to how esbuild performs bundling
import { v4 } from "uuid";

new Function(scope, "foo", () => {
// v4 will be tree-shaken and included in your bundle
return v4();
});

Performance Considerations

You can write arbitrary code from within the Lambda Function but be aware that the function's body will run on EVERY invocation, so you should avoid writing expensive one-off computations inside.

For example, loading a static file into memory should probably not be done within the function body.

new Function(scope, "foo", async () => {
const allowList = await fs.promises.readFile("allow-list.json");
});

Instead, move expensive initialization code outside of the closure.

const allowList = await fs.promises.readFile("allow-list.json");

new Function(scope, "foo", async () => {
// reference the allowList here instead
});

Warning: By moving the value outside of the closure, the allowList value will be serialized as JSON into the bundle. This can also affect your performance by bloating the size of the bundle.

Warning: The allow-list.json file will not be automatically included in your bundle. See #135

Async Invocation

Lambda Async Invocation allows a Function to be invoked from services that cannot handle the Function's response. For example, a Function invoked by an EventBus or by a SNS topic.

const bus = new EventBus<Event<string>>(stack, "bus");
const func = new Function<Event<string>, string>(stack, "asyncFunc", () => {
return "hi";
});
// all events on the bus are piped to the function asynchronously.
bus.all().pipe(func);

A common need to is to handle success or failure events from async invocations. Lambda supports sending onSuccess and onFailure events to Destinations (Event Bridge, SNS, Lambda, SQS). Functionless makes this easier by allowing EventBuses or Functions to be provided directly as Destinations.

const failureBus = new EventBus<AsyncResponseFailureEvent<void>>(
stack,
"failureBus"
);
const successFunction = new Function<AsyncResponseSuccess<void, string>>(
stack,
"successFunction",
async () => {
console.log("yay");
}
);
const func = new Function<void, string>(
stack,
"asyncFunc",
{
// success function will be invoked on each success
onSuccess: successFunction,
// failure bus will get an event for each failure on the bus
onFailure: failureBus,
},
async () => {
return "hi";
}
);

When working with EventBus destinations, Functionless provides Event Sources to easily consume and filter events generated by the Function.

const failureBus = new EventBus<AsyncResponseFailureEvent<void>>(
stack,
"failureBus"
);
const func = new Function<void, string>(
stack,
"asyncFunc",
{
// failure bus will get an event for each failure on the bus
onFailure: failureBus,
},
async () => {
return "hi";
}
);
func
// for all failure events
.onFailure(failureBus, "failures")
// refine to just failure events caused by RetriesExhausted
.when((event) => event.detail.requestContext.condition === "RetriesExhausted")
// send to another function (or any EventBus integration)
.pipe(
new Function(stack, "retryOnlyFunction", async () =>
console.log("not enough retries!")
)
);

Limitations

The bundler does not detect references to static files and include them by default.

See: #135

let _allowList;
const loadAllowList = async () => {
return (_allowList =
_allowList ?? (await fs.promises.readFile("allow-list.json")));
};

new Function(scope, "foo", async () => {
// reference the allowList here instead
const list = await loadAllowList();
});