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.
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”.
In this section we will describe how this application is built and all the parts necessary to understand it.
index.html
: main HTML file for interaction with Herokustyles.css
: simple stylings for making things more prettyloader.css
: stylings for loaderheroku.js
: code for communicating with Heroku and interpreting the resultenvironmentVariables.js
: code for handling environment variablesThe 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>
...
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);
});
};
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;
};
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.