DEV Community

Cover image for Building a Stream Deck plugin to invoke a Lambda function
Maurice Borgmeier for AWS Community Builders

Posted on • Originally published at mauricebrg.com

Building a Stream Deck plugin to invoke a Lambda function

Interacting with Cloud services is rarely a tactile experience. You write some code, run some command or click a button on a screen and things happen. Today we're going to change that. We'll write a plugin for the Elgato Stream Deck to trigger an AWS Lambda function on demand with a customizable event.

In case you haven't heard of it, the Stream Deck is basically a set of buttons with tiny screens behind them that you can customize to do your bidding through plugins. Today, we'll write a small plugin that invokes a Lambda function of our choosing, thereby allowing us to do pretty much anything in AWS.

Stream Deck

My first attempt at this a couple of years ago was not very successful. Since then I've gained more Typescript experience and the state of the Stream Deck SDK has improved substantially, so I gave it another shot.

Let's dive into it. Stream Deck plugins are small Javascript / Typescript apps that talk to the Stream Deck app via a Websocket connection. This means they mostly respond to events such as a button being pressed or an action being added to a Stream Deck. They can also initiate actions or monitor system state and respond to that, but for now we'll stick to simple interaction patterns. Writing plugins isn't that hard if you're a bit familiar with the Typescript ecosystem.

You can find the code for this project here on Github.

For this project, I'm going to assume that you have the AWS CLI configured (i.e. credentials are already prepared) and NodeJS installed (I'm using v24.2.0, minimum is version 20). Also, you need a Stream Deck and ideally VS Code. The prerequisites are also covered in the documentation. Next, we're going to install the Stream Deck CLI, which provides a scaffolding for our plugin and helps with the Stream Deck integration.

npm install -g @elgato/cli
Enter fullscreen mode Exit fullscreen mode

Once that's complete, we're ready to create our plugin using streamdeck create. I'm going to call mine AWS Lambda Invoke with the UUID com.mauricebrg.lambda-invoke - you should probably use your own Domain or UUID to avoid conflicts.

$ streamdeck create
 ___ _                        ___         _   
/ __| |_ _ _ ___ __ _ _ __   |   \ ___ __| |__
\__ \  _| '_/ -_) _` | '  \  | |) / -_) _| / /
|___/\__|_| \___\__,_|_|_|_| |___/\___\__|_\_\

Welcome to the Stream Deck Plugin creation wizard.

This utility will guide you through creating a local development environment for a plugin.
For more information on building plugins see https://docs.elgato.com.

Press ^C at any time to quit.

✔ Author: MauriceBrg
✔ Plugin Name: AWS Lambda Invoke
✔ Plugin UUID: com.mauricebrg.lambda-invoke
✔ Description: Invokes Lambda functions from the Stream Deck

✔ Create Stream Deck plugin from information above? Yes

Creating AWS Lambda Invoke...
✔ Enabling developer mode
✔ Generating plugin
✔ Installing dependencies
✔ Building plugin
✔ Finalizing setup

Successfully created plugin!

✔ Would you like to open the plugin in VS Code? Yes
Enter fullscreen mode Exit fullscreen mode

When that's done, it should open VS Code and the folder structure that you'll see looks something like this. The wizard has helpfully provided an example action that implements a counter. This shows us how to lay out our code. I've added some comments to explain what's located where.

$ tree --gitignore -I node_modules
.
├── com.mauricebrg.lambda-invoke.sdPlugin
│   ├── bin    # Content gets regenerated automatically
│   │   ├── package.json
│   │   └── plugin.js
│   ├── imgs   # (Image) Assets for use by the plugin
│   │   ├── actions
│   │   │   └── counter
│   │   │       ├── icon.png
│   │   │       ├── [email protected]
│   │   │       ├── key.png
│   │   │       └── [email protected]
│   │   └── plugin
│   │       ├── category-icon.png
│   │       ├── [email protected]
│   │       ├── marketplace.png
│   │       └── [email protected]
│   ├── manifest.json  # Describes the actions we provide
│   └── ui             # UI for the action settings
│       └── increment-counter.html  # Sample action settings
├── package-lock.json
├── package.json       # Defines our package, dependencies & scripts
├── rollup.config.mjs  # Configuration for the bundler
├── src
│   ├── actions
│   │   └── increment-counter.ts  # Sample action implementation
│   └── plugin.ts  # Configuration for the plugin
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Before we start customizing things, I should point out that this setup comes with two scripts prepared for us:

  • npm run build to turn our Typescript code into Javascript so it can be used by the Stream Deck app
  • npm run watch which executes the build command once we change anything in the code

You can already go ahead and run npm run watch in a separate Terminal session. If you open the Stream Deck app, you should already see a new action, which you can add to your Stream Deck and interact with.

New action menu

Next, we'll add the new folder com.mauricebrg.lambda-invoke.sdPlugin/imgs/actions/lambda and place an SVG with the Lambda logo from the official AWS Icon pack there. This allows us to reference a custom image later on. Now we'll start some preparations for us to invoke the Lambda function. We'll need to install the AWS SDK for that, but that requires another plugin for the bundler, since rollup seems to treat the AWS SDK as an ES Module, which it isn't. To work around that, we'll install the JSON plugin for rollup using npm i --save-dev @rollup/plugin-json and edit rollup.config.mjs:

// rollup.config.mjs
import json from "@rollup/plugin-json";
// ...

const config = {
    output: {
        // Other settings
        inlineDynamicImports: true
    }
    // ...
    plugins: [
        json(),
        // Other plugins
    ]
}
Enter fullscreen mode Exit fullscreen mode

Now we can run npm i --save @aws-sdk/client-lambda to install the AWS SDK without further issues. Okay, all of this has been in preparation for our new action. We'll start by defining the code. Create a new file src/actions/invoke-async.ts with this content:

// src/actions/invoke-async.ts
import streamDeck, { action, KeyDownEvent, SingletonAction } from "@elgato/streamdeck";

const logger = streamDeck.logger

@action({UUID: "com.mauricebrg.lambda-invoke.invoke-async"})
export class InvokeAsync extends SingletonAction<InvokeAsyncSettings> {

    // This function is called when the button is pressed
    public override async onKeyDown(ev: KeyDownEvent<InvokeAsyncSettings>): Promise<void> {
        logger.info(ev)
    }

}

type InvokeAsyncSettings = {
    lambdaSettings?: LambdaInvocationSettings
}

type LambdaInvocationSettings = {
    profile: string // Which SDK Creds to use
    region: string // Region that Lambda is located in
    functionName: string // Name of the function to execute
    event: string // Payload to send to Lambda
}
Enter fullscreen mode Exit fullscreen mode

The code defines a class which extends the SingletonAction from the Stream Deck SDK. Additionally it implements a handler for the onKeyDown event that is emitted whenever the key is pressed. At this point we only log the event. You can also find some data structures that define which settings we'll eventually need for this to work. We need to configure which SDK config to use to talk to AWS and in what region to invoke which Lambda with which event.

At this point our code is not executed. For that to happen, we need to extend the plugin.ts to register our action as well as the manifest.

// src/plugin.ts

// ...
import { InvokeAsync } from "./actions/invoke-async";

// ...
streamDeck.actions.registerAction(new InvokeAsync())

// ... (insert above before this block)
// Finally, connect to the Stream Deck.
streamDeck.connect();
Enter fullscreen mode Exit fullscreen mode

This tells the Stream Deck where to find the backend code and in the manifest we tell it metadata about our action.

// com.mauricebrg.lambda-invoke.sdPlugin/manifest.json
{
    // ...
    "Actions": [
        {
            "Name": "Async Invocation",
            "UUID": "com.mauricebrg.lambda-invoke.invoke-async",
            "Icon": "imgs/actions/lambda/lambda",
            "Tooltip": "Invokes a Lambda Function asynchronously",
            "PropertyInspectorPath": "ui/increment-counter.html",
            "Controllers": [
                "Keypad"
            ],
            "States": [
                {
                    "Image": "imgs/actions/lambda/lambda",
                    "TitleAlignment": "middle"
                }
            ]
        },
        //... other actions
    ]
    // ...
}
Enter fullscreen mode Exit fullscreen mode

If you look closely, you can see the references to the lambda.svg we created earlier in the Icon and States.Image keys. You should now be able to see new action in the UI. Place it on the Stream Deck and press the button.

Action placed in the stream deck app

Pressing the button doesn't to anything in the UI (yet) and the settings also don't match what we're trying to do - we'll fix both of those in a minute. First, let's look at the logs, because there our action was registered. I've abbreviated the logs a bit so it's not completely overwhelming.

// com.mauricebrg.lambda-invoke.sdPlugin/logs/com.mauricebrg.lambda-invoke.0.log

TRACE Connection: {"action":"com.mauricebrg.lambda-invoke.invoke-async","context":"...","device":"...","event":"keyDown","payload":{"coordinates":{"column":2,"row":2},"isInMultiAction":false,"settings":{}}}

INFO  {"type":"keyDown","action":{"controllerType":"Keypad","device":{"id":"..."},"id":"...","manifestId":"com.mauricebrg.lambda-invoke.invoke-async","coordinates":{"column":2,"row":2},"isInMultiAction":false},"payload":{"coordinates":{"column":2,"row":2},"isInMultiAction":false,"settings":{}}}

TRACE Connection: {"action":"com.mauricebrg.lambda-invoke.invoke-async","context":"...","device":"...","event":"keyUp","payload":{"coordinates":{"column":2,"row":2},"isInMultiAction":false,"settings":{}}}
Enter fullscreen mode Exit fullscreen mode

The log level is currently set to TRACE in src/plugin.ts, which lets us see all communication between the Stream Deck and our Plugin. We can see that two events have happened, first a keyDown and last a keyUp event. Additionally we can see which button was pressed on what device through the coordinates and various identifiers. In between we can see our INFO log with the event that our handler received.

Settings are also passed with each event. These are what allow us to go from stateless to stateful. Essentially, settings are a JSON object that is passed to each event listener and can be read and updated from there. Per-action and global (plugin-level) settings exist and today we'll focus on the former. We can provide a UI to interact with the settings an example of which you can see in form of the slider in the screenshot above.

The UI for settings is based on Stream Deck Plugin web components. We can use them to add per-action configuration options. Since we need profile, region, functionName, and event for our action, I've gone ahead and created a template that allows the user to enter this data (in the most basic way possible). Here's an excerpt:

<!-- com.mauricebrg.lambda-invoke.sdPlugin/ui/invoke-async.html -->
<!DOCTYPE html>
<html>

<head lang="en">
    <title>Async Invocation Settings</title>
    <meta charset="utf-8" />
    <script src="https://sdpi-components.dev/releases/v4/sdpi-components.js"></script>
</head>

<body>

    <sdpi-item label="AWS Profile">
    <sdpi-textfield
        setting="lambdaSettings.profile"
        placeholder="default"
        default="default"
        required>
    </sdpi-textfield>
    </sdpi-item>

    <!-- ... -->

    <sdpi-item label="Event">
        <sdpi-textarea
            setting="lambdaSettings.event"
            rows="5"
            showlength
            default="{}"
            required>
        </sdpi-textarea>
    </sdpi-item>

</body>

</html>
Enter fullscreen mode Exit fullscreen mode

Looking at the setting attribute in the Input definitions, you can probably already tell that this is the path where the value of the input is made accessible to the plugin. In the UI, the HTML is rendered like this:

What the settings look like when they're rendered

Finally we have all the plumbing in place to add the logic. First, we add a helper function to parse the settings and add default values for anything not explicitly configured. The second function invokes a Lambda asynchronously based on the settings.

import { LambdaClient, InvokeCommand, InvocationType } from "@aws-sdk/client-lambda";

// ...

// Add default values if the settings are not set.
function parseSettingsAndAddDefaults(settings: InvokeAsyncSettings): InvokeAsyncSettings {
    return {
        lambdaSettings: {
            region: settings.lambdaSettings?.region ?? "eu-central-1",
            functionName: settings.lambdaSettings?.functionName ?? "PythonDemo",
            event: settings.lambdaSettings?.event ?? "{}",
            profile: settings.lambdaSettings?.profile ?? "default"
        }
    }
}

// Invoke the Lambda function based on our settings
async function invokeLambdaAsync(config: LambdaInvocationSettings): Promise<number> {
    const client = new LambdaClient({region: config.region, profile: config.profile})
    const command = new InvokeCommand({
        FunctionName: config.functionName,
        InvocationType: InvocationType.Event
    })

    try {
        const response = await client.send(command)
        logger.info(response)
        return response.StatusCode ?? 500
    } catch (error) {

        logger.error("Failed to invoke Lambda", error)
        return 500
    }
}
Enter fullscreen mode Exit fullscreen mode

The last bit of logic adds the implementation of the onKeyDown event handler. We parse the configured settings while adding defaults for anything that's missing and then call the Lambda function. Any return codes in the 200 range are considered good, everything else concerning. Here, showOk will display a ✅ on the key for a few seconds and showAlert will display ⚠️ for a couple of seconds.

// ...
@action({UUID: "com.mauricebrg.lambda-invoke.invoke-async"})
export class InvokeAsync extends SingletonAction<InvokeAsyncSettings> {

    // This function is called when the button is pressed
    public override async onKeyDown(ev: KeyDownEvent<InvokeAsyncSettings>): Promise<void> {

        const settings = parseSettingsAndAddDefaults(await ev.action.getSettings())
        // Now we know that the lambda settings aren't empty
        const statusCode = await invokeLambdaAsync(settings.lambdaSettings!)

        await ev.action.setTitle(`Response\n${statusCode}`)
        if (200 <= statusCode && statusCode < 300) {
            await ev.action.showOk()
        } else {
            await ev.action.showAlert()
        }
    }

}
// ...
Enter fullscreen mode Exit fullscreen mode

This is it. Once you press the button, it will invoke the Lambda function and show the return code of the invoke API. Note that this is not the return code of the Lambda itself as the invocation is asynchronous. There's a long list of things that could be improved in terms of error handling and better feedback to the user, but we're already running long here, so let's finish up. Before we'll bundle up our plugin, let's remove the sample action and its assets. Delete the files from the following list, remove the instantiation + import from src/plugin.ts and remove the action from the manifest.json.

  • com.mauricebrg.lambda-invoke.sdPlugin/imgs/actions/counter/*
  • com.mauricebrg.lambda-invoke.sdPlugin/ui/increment-counter.html
  • src/actions/increment-counter.ts

Next, we check that everything is fine by running the validator on the plugin:

$ streamdeck validate com.mauricebrg.lambda-invoke.sdPlugin
✔ Validation successful
Enter fullscreen mode Exit fullscreen mode

Finally, we packaged it into a distributable plugin:

$ streamdeck pack com.mauricebrg.lambda-invoke.sdPlugin
📦 AWS Lambda Invoke (v0.1.0.0)

Plugin Contents
├─  993 B     manifest.json
├─  1.3 kB    ui/invoke-async.html
├─  6.1 kB    imgs/.DS_Store
├─  1.1 kB    imgs/plugin/category-icon.png
├─  2.4 kB    imgs/plugin/[email protected]
├─  53.1 kB   imgs/plugin/marketplace.png
├─  123.1 kB  imgs/plugin/[email protected]
├─  6.1 kB    imgs/actions/.DS_Store
├─  6.1 kB    imgs/actions/lambda/.DS_Store
├─  1.8 kB    imgs/actions/lambda/lambda.svg
├─  20 B      bin/package.json
└─  917.9 kB  bin/plugin.js

Plugin Details
  Name:           AWS Lambda Invoke
  Version:        0.1.0.0
  UUID:           com.mauricebrg.lambda-invoke
  Total files:    12
  Unpacked size:  1.1 MB
  File name:      com.mauricebrg.lambda-invoke.streamDeckPlugin

✔ Successfully packaged plugin

lambda-invoke/com.mauricebrg.lambda-invoke.streamDeckPlugin
Enter fullscreen mode Exit fullscreen mode

You can now distribute the com.mauricebrg.lambda-invoke.streamDeckPlugin file from the directory as your plugin. If you want to publish it on the Elgato Marketplace, you can read more about it in the documentation. You can find the complete code on Github, feel free to extend it or build your own actions. Here are some suggestions what to do next:

  • Implement a synchronous Lambda invocation
  • Add a linter, prettier and JSDoc strings as explained in the docs

I hope you enjoyed this post and learned something new.

— Maurice

Top comments (0)