serverless-on-heroku

Frontend Application

This document explains the implementation and usage of the frontend for one-off dynos. The frontend directory contains the source code for the frontend application. The deployed site can be found here.

Table of Contents

  1. Demo
  2. Usage
  3. How It Works
  4. Security Considerations

Demo

Frontend Demo

Usage

This web application can be used by anyone to invocate commands specified in a Heroku Procfile to be run in a one-off dyno, essentially running in a serverless fashion. To use the application, you will need to enter the Heroku App Name, the task to run and an API token with write access to the project. We do not store your information in any way or form. To add environment variables to the task, add them by clicking the button “Add environment variables” and insert the desired key and value. To start the task, click “Start dyno”.

How It Works

In this section we will describe how this application is built and all the parts necessary to understand it.

Files

The import of the CSS and the JavaScript into the HTML file has to happen in the head section of index.html.

...
<head>
    ...
    <link rel="stylesheet" href="./styles.css" />
    <link rel="stylesheet" href="./loader.css" />
    <script type="text/javascript" src="./heroku.js"></script>
    <script type="text/javascript" src="./environmentVariables.js"></script>
    ...
</head>
...

Communicate With Heroku API

In the index.html file, we have three different input fields: one for the Heroku App Name of the project we want to communicate with, the Heroku API key and the command we want to run in a one-off-dyno. The Heroku API key should be generated from an account with write privilege for the specified Heroku project.

<input type="text" placeholder="Heroku app name (project)" id="herokuApp" />
<input type="text" placeholder="Command to run (dyno)" id="command" />
<input type="text" placeholder="Heroku API key" id="herokuApiKey" />

<p>Environment variables</p>
<div id="environmentVariables"></div>
<button id="addEnvironmentVariables" onclick="addEnvironmentVariables()">Add environment variables</button>
<button id="startButton" onclick="startDyno()">Start dyno</button>

After pressing the button “Add environment variables”, you can enter key-value pairs for setting environment variables which we want to send to the dyno. These environment variables are generated by the following code within environmentVariables.js.

/**
 * Creates input elements that are used to specify environment variables. Remove button is also generated.
 */
const addEnvironmentVariables = () => {
    const environmentVariablesContainer = document.getElementById("environmentVariables");

    const containerNode = document.createElement("div");
    containerNode.setAttribute("class", "environmentVariable");

    const keyNode = document.createElement("input");
    keyNode.setAttribute("type", "text");
    keyNode.setAttribute("class", "environmentVariableKey");
    keyNode.setAttribute("required", true);
    keyNode.setAttribute("placeholder", "Key");

    const valueNode = document.createElement("input");
    valueNode.setAttribute("type", "text");
    valueNode.setAttribute("class", "environmentVariableValue");
    valueNode.setAttribute("required", true);
    valueNode.setAttribute("placeholder", "Value");

    const removeButton = document.createElement("button");
    removeButton.innerText = "Remove";
    removeButton.onclick = () => {
        environmentVariablesContainer.removeChild(containerNode);
    };

    containerNode.appendChild(keyNode);
    containerNode.appendChild(valueNode);
    containerNode.appendChild(removeButton);

    environmentVariablesContainer.appendChild(containerNode);
};

When pressing the displayed start button, the function startDyno, defined in heroku.js, will be triggered. This function will read the values from the input fields by using document.getElementById(elementId: string).value. With these values, we create a POST request to the URL https://api.heroku.com/apps/<HEORKU_APP_NAME>/dynos containing the following data:

{
    "command": "<COMMAND>",
    "env": {
        "<KEY>": "<VALUE>"
        ...
    }
}

This will initialize the task COMMAND specified in your Procfile in a one-off dyno with the environment variables specified in the form.

/**
 * Start dyno and get log stream generated by dyno
 */
const startDyno = () => {
    setError();
    setLog();

    document.getElementById("startButton").setAttribute("disabled", true);
    createLoader();
    const herokuAppNode = document.getElementById("herokuApp");
    const herokuApiKeyNode = document.getElementById("herokuApiKey");
    const commandNode = document.getElementById("command");

    const environmentVariablesNode = Array.from(document.getElementsByClassName("environmentVariable"));
    try {
        if (!herokuAppNode.value || !herokuApiKeyNode.value || !commandNode.value) {
            throw new Error("Please fill out the form");
        }

        const environmentVariables = environmentVariablesNode.reduce((prev, curr) => {
            const keyNode = curr.getElementsByClassName("environmentVariableKey").item(0);
            const valueNode = curr.getElementsByClassName("environmentVariableValue").item(0);
            if (!keyNode.value && !valueNode.value) {
                throw new Error("Please fill out environment variables values");
            }
            return { ...prev, [keyNode.value]: valueNode.value };
        }, {});

        const herokuApp = herokuAppNode.value;
        const herokuApiKey = herokuApiKeyNode.value;
        const command = commandNode.value;

        makeHerokuRequest("/dynos", herokuApp, herokuApiKey, "POST", {
            command,
            env: environmentVariables,
        })
            .then(async (res) => {
                const content = await res.json();
                if (res.ok) {
                    return getLogStream(content.name, herokuApp, herokuApiKey);
                } else {
                    throw new Error(content.message);
                }
            })
            .finally(() => {
                document.getElementById("startButton").removeAttribute("disabled");
                removeLoader();
            });
    } catch (error) {
        setError(error.message);
        document.getElementById("startButton").removeAttribute("disabled");
        removeLoader();
    }
};

The JSON response from the request will include the key name, which has the value of the name of the created dyno. To get the log stream for the created dyno, we make a POST request to the URL https://api.heroku.com/apps/<HEROKU_APP_NAME>/log-sessions containing the following data:

{
    "dyno": "<DYNO_NAME>",
    "tail": true
}

<DYNO_NAME> is here the dyno name returned from initializing the one-off dyno. In this request, we also include the tail key and set the value to true and the source set to "app", which results in streaming the output of the dyno only from the command itself.

/**
 * Get log stream from heroku
 * @param {string} dyno  Name of created dyno
 * @param {string} herokuApp  Name of Heroku app
 * @param {string} apiToken  API token used to authenticate
 */
const getLogStream = (dyno, herokuApp, apiToken) => {
    return makeHerokuRequest("/log-sessions", herokuApp, apiToken, "POST", {
        dyno,
        source: "app",
        tail: true,
    }).then(async (res) => {
        const content = await res.json();
        if (res.ok) {
            fetch(content.logplex_url)
                .then((response) => response.body.getReader())
                .then(readStream);
        } else {
            throw new Error(content.message);
        }
    });
};

The JSON response from this request will include a key logplex_url, which is the URL we can use to fetch the log stream. We can fetch it simply by using the fetch library and extract the stream reader by calling the method getReader() on the response body.

fetch(content.logplex_url)
    .then((response) => response.body.getReader())
    .then(readStream);

To read the stream and update the webpage, we run the method read of the reader which returns a promise that is resolved when either more data is available or the stream is closed.

/**
 * Read stream and update output visible to user
 * @param {ReadableStreamReader<Uint8Array>} reader
 */
const readStream = (reader) => {
    const log = document.getElementById("output");
    reader.read().then(({ done, value }) => {
        if (done) {
            console.log("Stream complete");
            return;
        }
        setLog(log.textContent + decoder.decode(value));

        return readStream(reader);
    });
};

Set Log and Error

To set the log output of our dyno execution, we can get the DOM element by using document.getElementId and update the text content.

<code id="output"></code>
/**
 * Set logging result to display to user
 * @param {string} message  Logging string, default to ""
 */
const setLog = (message = "") => {
    const log = document.getElementById("output");
    log.textContent = message;
};

In a similar manner, we update the error output if something goes wrong in the process.

<code id="error"></code>
/**
 * Set error message to display to user
 * @param {string} message  Error message, defaults to ""
 */
const setError = (message = "") => {
    const error = document.getElementById("error");
    error.textContent = message;
};

Security Considerations

Since Heroku does not have the functionality to scope your API token to only be able to initialize one-off dynos, it is advised to never push these tokens to GitHub or host them statically on the website. Otherwise, this could allow hackers to get write access to your Heroku project. This is why we require users to input their API tokens when using this frontend application.