|
AWS CloudFront Functions (CFF) use a special subset of JavaScript called
JS2.
JS2 syntax is simpler than full JavaScript, but it’s still nice to have modern tooling around it,
such as linting, testing, and IDE autocomplete. This tooling can also help avoid mistakes related to
the minimal JS2 feature set.
Firstly, these docblock comments in the CFF JS2 file allow the IDE to understand more about the
function and offer more useful autocompletion and problem highlighting:
/**
* @typedef {object} CloudFrontValue
* @property {string} value - The value of the header, cookie, or query string field.
*/
/**
* @typedef {object} CloudFrontMultiValue
* @property {Array.<CloudFrontValue>} multiValue - Multiple values for a query string field.
*/
/**
* @typedef {{[key: string]: CloudFrontValue}} CloudFrontHeaders
*/
/**
* @typedef {{[key: string]: CloudFrontValue|CloudFrontMultiValue}} CloudFrontQueryString
*/
/**
* @typedef {{[key: string]: CloudFrontValue}} CloudFrontCookies
*/
/**
* @typedef {object} CloudFrontRequest
* @property {string} method - The HTTP request method.
* @property {string} uri - The request URI path, excluding the query string.
* @property {CloudFrontHeaders} headers - Request headers, keyed by lowercase header name.
* @property {CloudFrontQueryString} querystring - Query string parameters.
* @property {CloudFrontCookies} cookies - Request cookies.
*/
/**
* @typedef {object} CloudFrontResponse
* @property {number} statusCode - The HTTP response status code.
* @property {string} statusDescription - The HTTP response status description.
* @property {CloudFrontHeaders} headers - Response headers, keyed by lowercase header name.
*/
/**
* @typedef {object} CloudFrontEventContext
* @property {string} distributionDomainName - The CloudFront distribution domain name.
* @property {string} distributionId - The CloudFront distribution ID.
* @property {string} eventType - The event type, such as "viewer-request".
*/
/**
* @typedef {object} CloudFrontViewer
* @property {string} ip - The viewer IP address.
*/
/**
* @typedef {object} CloudFrontEvent
* @property {CloudFrontEventContext} context - Metadata about the CloudFront event.
* @property {CloudFrontViewer} viewer - Information about the viewer connection.
* @property {CloudFrontRequest} request - The viewer-to-CloudFront HTTP request.
*/
It’s important that these are split into separate comment blocks, otherwise the IDE might fail to
interpret them properly.
Alternatively, we can add a separate TypeScript types declaration file and include it in the
project’s tsconfig.json:
interface CloudFrontValue {
value: string;
}
interface CloudFrontMultiValue {
multiValue: CloudFrontValue[];
}
type CloudFrontHeaders = Record<string, CloudFrontValue>;
type CloudFrontQueryString = Record<
string,
CloudFrontValue | CloudFrontMultiValue
>;
type CloudFrontCookies = Record<string, CloudFrontValue>;
interface CloudFrontRequest {
method: string;
uri: string;
headers: CloudFrontHeaders;
querystring: CloudFrontQueryString;
cookies: CloudFrontCookies;
}
interface CloudFrontResponse {
statusCode: number;
statusDescription?: string;
headers?: CloudFrontHeaders;
}
interface CloudFrontEventContext {
distributionDomainName?: string;
endpoint?: string;
distributionId?: string;
eventType: "viewer-request" | "viewer-response";
requestId: string;
}
interface CloudFrontViewer {
ip: string;
}
interface CloudFrontEvent {
context: CloudFrontEventContext;
viewer: CloudFrontViewer;
request: CloudFrontRequest;
}
With either of those typings in place, the IDE can now follow the types for the CloudFront Function:
/**
* @param {CloudFrontEvent} event - The CloudFront Functions event object.
* @returns {CloudFrontRequest|CloudFrontResponse} A CloudFront request object or response object.
*/
// noinspection JSUnusedGlobalSymbols
function handler(event) {
var viewerIp = event.viewer.ip; // IDE can follow this structure.
}
The IDE and ESLint will complain about the use of var, which is the only way to declare variables
in CFF JS2 syntax.
We can use a dedicated file suffix like .cff.js to indicate to the IDE and ESLint that these are
special JS files.
For JetBrains IDEs like WebStorm, we can create a
scope for these files via the
settings UI. Then we can configure suitable highlighting rules for that scope. In this case we can
disable the rule named 'var' is used instead of 'let' or 'const' for the CFF scope only.
For ESLint, we can configure rules for CFF files. Create a separate file for these CFF rules
called e.g. cffjs2.eslint.config.ts:
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
export default defineConfig({
// ── CloudFront Functions JS2
files: ["**/*.cff.js"],
extends: [tseslint.configs.disableTypeChecked],
languageOptions: {
parserOptions: {
projectService: false,
},
},
rules: {
"no-var": "off",
"prefer-const": "off",
"object-shorthand": "off",
"prefer-template": "off",
"jsdoc/no-undefined-types": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"no-eval": "error",
"no-new-func": "error",
"no-implied-eval": "error",
"no-restricted-globals": [
"error",
{
name: "fetch",
message: "CloudFront Functions cannot make network requests.",
},
{
name: "XMLHttpRequest",
message: "CloudFront Functions cannot make network requests.",
},
{
name: "WebSocket",
message: "CloudFront Functions cannot open network connections.",
},
{
name: "require",
message:
"CloudFront Functions must be self-contained and cannot require modules.",
},
{
name: "process",
message:
"CloudFront Functions do not have access to Node.js process APIs.",
},
{
name: "Buffer",
message: "CloudFront Functions do not have access to Node.js Buffer.",
},
{
name: "setTimeout",
message: "CloudFront Functions do not support timers.",
},
{
name: "setInterval",
message: "CloudFront Functions do not support timers.",
},
{
name: "setImmediate",
message: "CloudFront Functions do not support timers.",
},
{
name: "Promise",
message: "Avoid Promise usage in CloudFront Functions.",
},
],
"no-restricted-syntax": [
"error",
{
selector: "TemplateLiteral",
message:
"CloudFront Functions JS2 does not support template literals. Use string concatenation instead.",
},
{
selector: "ImportDeclaration",
message:
"CloudFront Functions must be self-contained and should not use import syntax.",
},
{
selector:
"ExportNamedDeclaration:not([declaration.type='FunctionDeclaration'][declaration.id.name='handler'])",
message:
"CloudFront Function files may only export the handler as `export function handler(...)`.",
},
{
selector: "ExportDefaultDeclaration, ExportAllDeclaration",
message:
"CloudFront Function files may only export the handler as `export function handler(...)`.",
},
{
selector: "ClassDeclaration, ClassExpression",
message: "Avoid class syntax in CloudFront Functions JS2 files.",
},
{
selector: "ArrowFunctionExpression",
message:
"Avoid arrow functions in CloudFront Functions JS2 files. Use function declarations/expressions instead.",
},
{
selector:
"AwaitExpression, FunctionDeclaration[async=true], FunctionExpression[async=true], ArrowFunctionExpression[async=true]",
message: "CloudFront Functions should not use async/await.",
},
{
selector:
"YieldExpression, FunctionDeclaration[generator=true], FunctionExpression[generator=true]",
message: "CloudFront Functions should not use generators.",
},
{
selector: "ObjectPattern, ArrayPattern",
message: "Avoid destructuring in CloudFront Functions JS2 files.",
},
{
selector: "SpreadElement, RestElement",
message: "Avoid spread/rest syntax in CloudFront Functions JS2 files.",
},
{
selector: "ForOfStatement",
message:
"Avoid for...of in CloudFront Functions JS2 files. Use index-based loops instead.",
},
],
"no-unused-vars": ["error", { varsIgnorePattern: "^handler$" }],
"@typescript-eslint/no-unused-vars": [
"error",
{ varsIgnorePattern: "^handler$" },
],
},
});
We can then bring that into the main eslint.config.ts file like this:
import { defineConfig } from "eslint/config";
import cffJs2Config from "./config/cffjs2.eslint.config.js";
export default defineConfig(
// ... other ESLint config ...
// ── CloudFront Functions JS2
...cffJs2Config,
);
The above covers CFF JS2 for the IDE and ESLint. It would also be nice if we could run fast tests on
our CloudFront Functions with a test runner such as Vitest.
As a CloudFront Function handler is a pure function, in theory that should be straightforward.
However, CloudFront Functions do not support ES module syntax, so export in deployed source will
fail.
There are a few different options to allow testing a CloudFront Function handler in Vitest, all with
different tradeoffs. Some of the aspects that we might want to tradeoff against each other are:
- Portability: it would be nice if the .cff.js file remained pure valid CFF JS2 syntax so that it
stays portable without modification.
- Deployability: it would be nice if CDK could seamlessly deploy the CFF JS2 file using the
built-in
cloudfront.FunctionCode.fromFile function in CDK.
- IDE inference: it would be nice if the IDE could follow the module resolution for the CFF JS2
file without raising false-positive problems.
- Debugging: it would be nice if we could seamlessly step through the CFF JS2 file in a debugger,
both during tests and when running local simulations with a tool like
yulin.
For example, we could use the Node.js vm module to run the
unmodified CFF JS2 file in tests and local simulations. This would keep the .cff.js file portable so
we could copy it into the AWS console or share it with people who might not know about this special
export situation.
Keeping it portable and deployable in that way would trade off the other benefits. The IDE and
debugger would not be able to follow that the .cff.js is the source code for the function, as it
would be running via the vm module.
On balance I would suggest that prioritising the IDE inference and debugging over the portability is
the better tradeoff. If we’re deploying this with CDK then we’re unlikely to need the pure
portability. We can make a small change to the CDK resource to allow deploying a .cff.js file which
contains an export.
We can implement a small util function to do that conversion of a CFF JS2 file for deployment:
import { readFileSync } from "node:fs";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
/**
* Get CloudFront Function code from a module file and replace the export of the
* handler function.
* This is to allow normal import of CFF JS2 functions in tests and simulations.
* This intentionally assumes a single export function handler(...) convention
* rather than doing full JavaScript parsing.
*/
export function cloudFrontFunctionCodeFromModuleFile(
filePath: string,
): cloudfront.FunctionCode {
// eslint-disable-next-line security/detect-non-literal-fs-filename
const source = readFileSync(filePath, "utf8");
const cloudFrontSource = source.replace(
/\bexport\s+function\s+handler\s*\(/,
"function handler(",
);
return cloudfront.FunctionCode.fromInline(cloudFrontSource);
}
Then we can modify our CDK resource to use that for deploying the CloudFront Function:
const cloudFrontFunction = new cloudfront.Function(
this,
"CloudFrontFunction",
{
code: cloudFrontFunctionCodeFromModuleFile(
repoPath("src/cff/foobar.cff.js"),
),
},
);
Now we can do a normal export in the .cff.js file:
/**
* @param {CloudFrontEvent} event - The CloudFront Functions event object.
* @returns {CloudFrontRequest|CloudFrontResponse} A CloudFront request object or response object.
*/
// noinspection JSUnusedGlobalSymbols
export function handler(event) {
// ... CFF implementation is now easy to test and simulate with yulin ...
}
That might seem like quite a lot of project boilerplate just for CloudFront Functions, but it’s
often these small details on the periphery of the project that encounter unexpected problems and
cause outages or bugs. With the above tooling, we can apply good linting, testing and simulation
practices to CloudFront Functions as a first-class citizen within our AWS projects.
By the way, you can hire me as a
freelance AWS engineer
to get your project done on time and in budget.
View post:
AWS CloudFront Functions JS2 tooling: ESLint, Vitest, IDE
|