Skip to content

Commit

Permalink
Add README and documentation
Browse files Browse the repository at this point in the history
  • Loading branch information
Ackuq committed Apr 2, 2021
1 parent 0ae4b66 commit 98f01e3
Show file tree
Hide file tree
Showing 2 changed files with 173 additions and 3 deletions.
143 changes: 141 additions & 2 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,149 @@

This directory contains the source code for the frontend application. The deployed site can be found [here](https://felix-seifert.github.io/serverless-on-heroku/).

## Files

- `index.html` - The main HTML file
- `styles.css` - Simple stylings for making things a little bit more pretty.
- `heroku.js` - The code for communication and interpreting the result from Heroku, the heart of the application.

## Communicating with the Heroku API

TODO
In the `index.html` file we got 3 different input fields, a field for the Heroku project ID we want to communicate with, a field for the Heroku API key (should be generated from an account with write privilege to the specified Heroku project), and a name, which will be the input to the application.

When pressing the button displayed, the function `startDyno`, defined in `heroku.js`, will start to execute. This function will read the values from the input field by using `document.getElementById(elementId: string).value`. Using these values, we create a POST request to the url `https://api.heroku.com/apps/<HEORKU_APP_ID>/dynos` with the data:

```json
{
"command": "serverless",
"env": {
"NAME": "<NAME>"
}
}
```

This will initialize the task `serverless` specified in our `Procfile` in a one-off-dyno using the environment variable `NAME` set to the name we supplied in the form.

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

const herokuApp = document.getElementById("herokuApp").value;
const herokuApiKey = document.getElementById("herokuApiKey").value;
const name = document.getElementById("name").value;

makeHerokuRequest("/dynos", herokuApp, herokuApiKey, "POST", {
command: "serverless",
env: {
NAME: name,
},
}).then(async (res) => {
const content = await res.json();
if (res.ok) {
getLogStream(content.name, herokuApp, herokuApiKey);
} else {
setError(content.message);
}
});
};
```

The JSON response from the request will include a property `name`, which consist 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_ID>/log-sessions` with the data:

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

Where `<DYNO_NAME>` is the dyno name returned from initializing the one-off-dyno. In this request we also include the `tail` property and set it to `true`, this is because we want to be able to stream the output of the dyno as it is executing.

```js
/**
* Get the log stream from heroku
* @param {string} dyno The name of the created dyno
* @param {string} herokuApp The name of the Heroku app
* @param {string} apiToken The API token used to authenticate
*/
const getLogStream = (dyno, herokuApp, apiToken) => {
makeHerokuRequest("/log-sessions", herokuApp, apiToken, "POST", {
dyno,
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 {
setError(content.message);
}
});
};
```

The JSON response from this request will include a property `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.

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

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

```js
/**
* Read the stream and update the output visible to the 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);
});
};
```

## Setting the log and error

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

```js
/**
* Set the logging result to display to the user
* @param {string} message The 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.

```js
/**
* Set the error message to display to the user.
* @param {string} message The error message, defaults to ""
*/
const setError = (message = "") => {
const error = document.getElementById("error");
error.textContent = message;
};
```

## Security considerations

TODO
Since Heroku does not have the functionality to scope you 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, since this could case hackers to get write access to your Heroku project. This is why we require users to input their API token when using this frontend application.
33 changes: 32 additions & 1 deletion frontend/heroku.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,22 @@ const decoder = new TextDecoder();

const createUrl = (endpoint, herokuApp) => `${baseURL}${herokuApp}${endpoint}`;

/**
* Create a request to the Heroku api with the supplied arguments.
* This function applies headers that must be present for all the requests
* @param {string} endpoint The request endpoint, e.g. /dynos
* @param {string} herokuApp The Heroku app used
* @param {string} apiToken The Heroku API token, must have access to the specified Heroku app
* @param {string} method The HTTP method used, e.g. "POST", "GET" or "PATCH"
* @param {any} body JSON data to be sent
* @returns {Promise<Response>} A promise with the response
*/
const makeHerokuRequest = (
endpoint,
herokuApp,
apiToken,
method = "GET",
body = {}
body = undefined
) => {
return fetch(createUrl(endpoint, herokuApp), {
method,
Expand All @@ -22,16 +32,28 @@ const makeHerokuRequest = (
});
};

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

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

/**
* Read the stream and update the output visible to the user
* @param {ReadableStreamReader<Uint8Array>} reader
*/
const readStream = (reader) => {
const log = document.getElementById("output");
reader.read().then(({ done, value }) => {
Expand All @@ -45,6 +67,12 @@ const readStream = (reader) => {
});
};

/**
* Get the log stream from heroku
* @param {string} dyno The name of the created dyno
* @param {string} herokuApp The name of the Heroku app
* @param {string} apiToken The API token used to authenticate
*/
const getLogStream = (dyno, herokuApp, apiToken) => {
makeHerokuRequest("/log-sessions", herokuApp, apiToken, "POST", {
dyno,
Expand All @@ -61,6 +89,9 @@ const getLogStream = (dyno, herokuApp, apiToken) => {
});
};

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

0 comments on commit 98f01e3

Please sign in to comment.