Skip to content

ConfigScheduledRouter ​

ConfigScheduledRouter routes an AWS Config scheduled evaluation to a handler.

A Config rule with a periodic trigger runs on a fixed frequency rather than on a resource change, and invokes your Lambda with a ScheduledNotification. The router works out which of your routes should run by the rule name or the account id, and hands the handler the rule parameters and the result token it needs to report the outcome.

Resource change evaluations are a different notification type and go to ConfigRouter instead. A scheduled evaluation carries no changed resource, so you query the resources yourself and report on each one.

Install ​

bash
npm install @lambda-event-router/base @lambda-event-router/config

@lambda-event-router/base is a peer dependency, so install it yourself. It exports LambdaRouter, which every router plugs into.

Create the router ​

ts
import { createConfigScheduledRouter } from '@lambda-event-router/config'
import { logInvocation } from './middleware/logInvocation'

const scheduledRouter = createConfigScheduledRouter({
  middleware: [logInvocation],  // Optional
})

middleware is the only option and it can be left out. createConfigScheduledRouter() on its own gives you a router with no shared middleware.

Options ​

OptionTypeRequiredDefaultDescription
middlewareConfigScheduledMiddleware[]No[]Runs for every evaluation this router handles, before any route middleware. See Middleware

Register routes ​

ts
scheduledRouter.route({
  filters: { configRuleName: 'periodic-tag-audit' },
  ruleParametersSchema,               // Optional
  middleware: [withRequestContext],   // Optional
  handler: runTagAudit,
})

filters and handler are the only required keys, and filters can be an empty object to match every scheduled evaluation.

route() returns the router, so you can chain registrations.

ts
scheduledRouter.route(tagAuditRoute).route(crossAccountRoute)

Routes are ranked by how specific they are. Where two overlap, the one matching only a subset of the other is tried first, whatever order you registered them in. Registration order decides the rest, so give each route filters no other route can match. See match order for how ranking works and what it cannot settle.

An evaluation that matches no route throws No route matched for scheduled config rule <name>, which fails the invocation. Register a route with empty filters as a catch-all if you would rather swallow rules you do not recognise, and see nothing matched for what the other routers do instead.

Filters ​

Every filter key across two routes. Both keys are optional, so set the ones that pick out the evaluations you want and leave the rest off.

ts
scheduledRouter.route({
  filters: {
    configRuleName: 'periodic-tag-audit',
    accountId: ['123456789012', '987654321098'], // Or a pattern: /^1234/
  },
  handler: runTagAudit,
})

custom reads the same two fields the dedicated keys match on, so pinning a key and testing it again inside custom is redundant. Show it on its own, for logic across those fields that the separate keys cannot express.

ts
scheduledRouter.route({
  filters: {
    custom: ({ configRuleName, accountId }) =>
      configRuleName.startsWith('prod-') && accountId !== '123456789012',
  },
  handler: runTagAudit,
})
FilterTypeDescription
configRuleNameFilterStringMatcherMatches the Config rule that invoked the Lambda
accountIdFilterStringMatcherMatches the AWS account the evaluation runs in
custom(input: ConfigScheduledFilterInput) => boolean | Promise<boolean>The two fields above and nothing else. Can be async

FilterStringMatcher is string | RegExp | Array<string | RegExp>. See filters for how each form matches, including the * wildcard. Both configRuleName and accountId honour every form, so configRuleName: 'prod-*' or a RegExp matches by pattern the same way accountId does.

A custom is handed only ConfigScheduledFilterInput, the rule name and the account id, not the rule parameters or the raw event. It cannot read anything the dedicated keys do not already cover, so reserve it for combining the two, an OR across them or a relationship between them. See custom for where it sits in the filter order.

Handler ​

Handlers take one argument and return nothing.

ts
import { logger } from '@lambda-event-router/base'
import type { ConfigScheduledRequest } from '@lambda-event-router/config'

export async function runTagAudit(request: ConfigScheduledRequest): Promise<void> {
  logger.info(`Running ${request.configRuleName} for account ${request.accountId}`)
}

Request object ​

FieldTypeDescription
resultTokenstringThe token you pass to PutEvaluations to report the result
configRuleNamestringThe Config rule that invoked the Lambda
accountIdstringThe AWS account the evaluation runs in
ruleParametersTParamsThe rule's parameters, parsed from JSON. Defaults to {}
eventConfigEventThe untouched event from AWS
contextContextThe Lambda context

Context comes from aws-lambda. ConfigEvent comes from this package.

Response type ​

Handlers return Promise<void>. ConfigResponse is undefined, so the router hands nothing back to Lambda. Reporting compliance to Config is a call you make yourself, see Reporting evaluations. Throwing is how you signal failure, see Failures and retries.

Inferred handlers ​

defineConfigScheduledRoute hands your handler a fully typed request built from the schema, so you never name the request type. It is a two step call: define the route, then attach the handler with .handle().

ts
import { logger } from '@lambda-event-router/base'
import { defineConfigScheduledRoute } from '@lambda-event-router/config'

export const tagAuditRoute = defineConfigScheduledRoute({
  filters: { configRuleName: 'periodic-tag-audit' },
  ruleParametersSchema,
}).handle(async ({ accountId, ruleParameters, resultToken }) => {
  const requiredTags = JSON.parse(ruleParameters.requiredTags) as string[]
  logger.info(`Auditing ${requiredTags.join(', ')} in ${accountId} (token ${resultToken})`)
})

scheduledRouter.route(tagAuditRoute)

Not knowing the request shape pays off most in a Lambda taking several event sources, since you never have to look any of them up. See inferred handlers, where the same source is written both ways to compare.

Annotated handlers ​

Annotating the request yourself splits route setup from business logic, using ConfigScheduledRequest<TParams> and your own type for the parameters. Derive that type from your schema with z.infer rather than hand-writing it.

ts
// handlers/runTagAudit.ts
import { logger } from '@lambda-event-router/base'
import type { ConfigScheduledRequest } from '@lambda-event-router/config'
import type { z } from 'zod'
import type { tagAuditParamsSchema } from '../schemas/rules'

type TagAuditParams = z.infer<typeof tagAuditParamsSchema>

export async function runTagAudit(
  request: ConfigScheduledRequest<TagAuditParams>,
): Promise<void> {
  const requiredTags = JSON.parse(request.ruleParameters.requiredTags) as string[]
  logger.info(`Auditing ${requiredTags.join(', ')} in ${request.accountId}`)
}
ts
// scheduled.ts
import { createConfigScheduledRouter } from '@lambda-event-router/config'
import { runTagAudit } from './handlers/runTagAudit'
import { tagAuditParamsSchema } from './schemas/rules'

const scheduledRouter = createConfigScheduledRouter()

scheduledRouter.route({
  filters: { configRuleName: 'periodic-tag-audit' },
  ruleParametersSchema: tagAuditParamsSchema,
  handler: runTagAudit,
})

See annotated handlers for the worked version.

Schema validation ​

One key takes a schema and it is optional. Any Standard Schema library works, so Zod, Valibot and ArkType are interchangeable.

ts
import { z } from 'zod'
import { defineConfigScheduledRoute } from '@lambda-event-router/config'

const ruleParametersSchema = z.object({
  requiredTags: z.string(), // JSON-encoded array of tag keys
})

export const tagAuditRoute = defineConfigScheduledRoute({
  filters: { configRuleName: 'periodic-tag-audit' },
  ruleParametersSchema,
}).handle(async ({ ruleParameters }) => {
  // ruleParameters is typed from ruleParametersSchema
})
KeyValidatesHandler receives
ruleParametersSchemaThe rule's parameters, parsed from event.ruleParametersruleParameters typed as the schema output

Config passes every rule parameter as a string, so a schema of z.string() fields that you parse yourself matches what actually arrives. A failing schema throws before the handler runs. See validation failures for what your handler gets after coercion.

Failures and retries ​

An evaluation that matches no route throws No route matched for scheduled config rule <name>, and a handler that throws propagates the error out of the Lambda. One event carries one scheduled evaluation, so there is no batch and nothing partial to report.

AWS Config invokes your function directly and does not read a return value, so a throw surfaces to Config as a failed evaluation rather than being redelivered the way an asynchronous source would.

Reporting evaluations ​

Config does not read your handler's return value. You report whether each resource is compliant by calling the Config PutEvaluations API yourself, passing the resultToken from the request. Every request carries resultToken for this.

A scheduled evaluation carries no changed resource, so list the resources you want to check through the relevant AWS APIs, evaluate each one, then report them in one or more PutEvaluations calls.

ts
import { ConfigServiceClient, PutEvaluationsCommand } from '@aws-sdk/client-config-service'
import { defineConfigScheduledRoute } from '@lambda-event-router/config'

const config = new ConfigServiceClient({})

scheduledRouter.route(
  defineConfigScheduledRoute({ filters: { configRuleName: 'periodic-tag-audit' } }).handle(
    async ({ accountId, resultToken }) => {
      // Discover and evaluate resources for this account, then report each one
      await config.send(
        new PutEvaluationsCommand({
          ResultToken: resultToken,
          Evaluations: [
            {
              ComplianceResourceType: 'AWS::S3::Bucket',
              ComplianceResourceId: `${accountId}-logs`,
              ComplianceType: 'COMPLIANT',
              OrderingTimestamp: new Date(),
            },
          ],
        }),
      )
    },
  ),
)

Middleware ​

Router and route middleware are both typed ConfigScheduledMiddleware, and the chain runs once per evaluation.

ts
import { logger } from '@lambda-event-router/base'
import type { ConfigScheduledMiddleware } from '@lambda-event-router/config'

export const logInvocation: ConfigScheduledMiddleware = async (request, next) => {
  logger.info(`Handling ${request.configRuleName} for account ${request.accountId}`)
  return next(request)
}
ts
const scheduledRouter = createConfigScheduledRouter({ middleware: [logInvocation] })

scheduledRouter.route({
  filters: { configRuleName: 'periodic-tag-audit' },
  middleware: [withRequestContext],
  handler: runTagAudit,
})

Route middleware carries the route's parameters type. A route with a ruleParametersSchema needs ConfigScheduledMiddleware<TagAuditParams>. ConfigScheduledMiddleware on its own does not compile there. Router middleware takes no type argument, because it runs for every route.

Router middleware runs before route middleware, and both run before the handler. A middleware that does not call next short-circuits the chain, so the handler never runs. A schema validation failure throws before the chain starts, so middleware never sees it.

See middleware for the execution order and the three levels it attaches at.

Types ​

All exported from @lambda-event-router/config.

TypeDescription
ConfigScheduledRequestThe handler argument
ConfigScheduledFiltersThe filters object
ConfigScheduledFilterInputWhat a custom receives
ConfigScheduledMiddleware<TParams>Router and route middleware
ConfigScheduledRouteDefinitionA full route passed to route()
ConfigScheduledRouterOptionsOptions for createConfigScheduledRouter
ConfigEventThe untouched event from AWS
ConfigResponseThe handler return type, undefined

The ConfigScheduledRouter class and the createConfigScheduledRouter and defineConfigScheduledRoute functions come from the same place.

Generic parameters ​

ParameterTypesDefault
TParamsruleParameters, from ruleParametersSchemaRecord<string, string>

ConfigScheduledRequest<TParams> and ConfigScheduledMiddleware<TParams> take the one parameter, so leave it off to get the default when a route has no ruleParametersSchema.

Code example ​

One Lambda running two scheduled Config rules, each keyed to a distinct rule name, with a Zod schema on the parameters of one and the result reported through PutEvaluations.

Open a file: index.ts | Scheduled router | schemas

File viewer
TSscheduled.ts

The two routes match on distinct rule names, so no evaluation matches both and the order you register them in makes no difference. Each handler reports its result with the resultToken.

index.ts hands the router to LambdaRouter, which is what AWS invokes and what every router in the Lambda gets registered on. See routers for how the two levels of matching fit together.