<|start_filename|>front/src/components/App.js<|end_filename|>
import React, { Component } from "react";
import styled from "styled-components";
import Spinner from "react-spinkit";
import "../styles/App.css";

const apiUrl = process.env.REACT_APP_API_URL;

const Container = styled.div`
  width: 100%;
  height: 90vh;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
`;

const Title = styled.h2`text-align: center;`;

const Input = styled.input`
  width: 100%;
  padding: 3px 8px;
  margin-bottom: 20px;
  text-align: center;
  font-size: 1em;
  border: 0;
  outline: 0;
`;

const Button = styled.button`
  font-size: 1em;
  padding: 10px 20px;
  border: 2px solid black;
  background: transparent;
  outline: 0;

  &:hover {
    background: black;
    color: white;
  }
`;

const FooterLinks = styled.div`
  position: absolute;
  display: flex;
  bottom: 10px;
  > a {
    color: #22ac77;
    margin: 0 10px;
  }
`;

class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      isFetching: false
    };
  }

  componentDidMount() {
    this.input.focus();
  }

  fetchSpeech = () => {
    this.setState({ isFetching: true });

    fetch(apiUrl, {
      method: "POST",
      mode: "cors",
      headers: {
        Accept: "application/json, text/plain, */*",
        "Content-Type": "application/json"
      },
      body: JSON.stringify({ href: this.input.value })
    })
      .then(response => response.json())
      .then(json => {
        this.setState({
          isFetching: false,
          audioFile: json.location
        });
      })
      .catch(error => {
        console.error(error);
      });
  };

  renderActionElement = () => {
    if (this.state.isFetching) {
      return <Spinner name="wave" />;
    }

    if (this.state.audioFile) {
      return (
        <audio controls>
          <source src={this.state.audioFile} type="audio/mpeg" />
          Your browser does not support the MP3 😭
        </audio>
      );
    }

    return <Button onClick={this.fetchSpeech}>Tell me a story</Button>;
  };

  render() {
    return (
      <Container>
        <Title>Serverless, Text-to-speech service for Medium stories</Title>
        <Input
          type="url"
          placeholder="Paste Medium story URL"
          innerRef={r => (this.input = r)}
        />
        {this.renderActionElement()}
        <FooterLinks>
          <a
            href="https://twitter.com/RafalWilinski"
            target="_blank"
            rel="noopener noreferrer"
          >
            Twitter
          </a>
          •
          <a
            href="https://github.com/RafalWilinski/serverless-medium-text-to-speech"
            target="_blank"
            rel="noopener noreferrer"
          >
            Github
          </a>
        </FooterLinks>
      </Container>
    );
  }
}

export default App;


<|start_filename|>transcoder.js<|end_filename|>
"use strict";

const AWS = require("aws-sdk");
const axios = require("axios");
const fs = require("fs");
const uuid = require("uuid/v1");

const s3 = new AWS.S3({ region: "us-east-1" });
const polly = new AWS.Polly({ region: "us-east-1" });

const createResponse = (body, statusCode) => ({
  statusCode: statusCode || 200,
  headers: {
    "Access-Control-Allow-Origin": "*", // Required for CORS support to work
    "Access-Control-Allow-Credentials": true // Required for cookies, authorization headers with HTTPS
  },
  body: JSON.stringify(body)
});

const upload = audioStream =>
  s3
    .upload({
      ACL: "public-read",
      ContentType: "audio/mp3",
      Bucket: process.env.TRANSCRIPTS_BUCKET,
      Key: uuid(),
      Body: audioStream,
      StorageClass: "REDUCED_REDUNDANCY"
    })
    .promise()
    .then(data => data.Location);

const join = audioStreams =>
  audioStreams.reduce(
    (total, buffer) =>
      Buffer.concat([total, buffer], total.length + buffer.length),
    Buffer.alloc(1)
  );

const synthesize = ({ title, text }) => {
  const transcript = `${title}. ${text}`;

  // Due to AWS Polly character restrictions, we have to split our text into chunks.
  const splittedText = transcript.match(/.{1500}/g);

  return Promise.all(
    splittedText.map((chunk, index) => {
      return polly
        .synthesizeSpeech({
          OutputFormat: "mp3",
          VoiceId: "Joanna",
          TextType: "text",
          Text: chunk
        })
        .promise()
        .then(data => data.AudioStream);
    })
  );
};

const downloadArticle = (url, callback) =>
  axios.get(url).then(response => {
    const json = JSON.parse(response.data.replace("])}while(1);</x>", ""));
    const text = json.payload.value.content.bodyModel.paragraphs
      .reduce((acc, paragraph) => acc.concat(paragraph.text), [])
      .join(" ");

    return {
      title: json.payload.value.title,
      text
    };
  });

module.exports.handle = (event, context, callback) => {
  const url = `${JSON.parse(event.body).href}?format=json`;

  downloadArticle(url, callback)
    .then(synthesize)
    .then(join)
    .then(upload)
    .then(location => {
      callback(null, createResponse({ location }));
    })
    .catch(error => {
      callback(null, createResponse({ error }, 400));
    });
};


<|start_filename|>front/deploy.js<|end_filename|>
const { spawn } = require("child_process");
const create = require("s3-website").s3site;

const domain = process.env.BUCKET || "medium-speech"; // Probably taken, change to something else
const region = process.env.REGION || "us-east-1";

create(
  {
    region,
    index: "index.html",
    error: "index.html",
    domain
  },
  (err, website) => {
    if (err) {
      console.error(
        "Supplied domain is probably taken, try setting env variable BUCKET to something else."
      );
      throw err;
    }

    console.log("Bucket created!");

    const cmd = spawn("aws", [
      "s3",
      "sync",
      "./build",
      "s3://medium-speech",
      "--acl",
      "public-read"
    ]);

    cmd.stdout.on("data", data => {
      console.log(`STDOUT: ${data}`);
    });

    cmd.stderr.on("data", data => {
      console.log(`ERR: ${data}`);
    });

    cmd.on("close", code => {
      console.log(
        "Deployment complete!",
        `http://${domain}.s3.amazonaws.com/index.html`
      );
    });
  }
);


<|start_filename|>front/src/styles/App.css<|end_filename|>
* {
  font-family: Interface;
}

html,
body {
  padding: 0;
  margin: 0;
}

@font-face {
  font-family: Interface;
  src: url('../fonts/Interface-Regular.woff');
}


<|start_filename|>package.json<|end_filename|>
{
  "name": "serverless-medium-speech",
  "version": "0.1.0",
  "description": "Serverless-based Text-to-Speech service for Medium posts",
  "main": "transcoder.js",
  "dependencies": {
    "axios": "^0.16.2",
    "serverless-offline": "^3.15.3",
    "uuid": "^3.1.0"
  },
  "scripts": {
    "backend": "nodemon --exec serverless offline",
    "front": "cd front && npm start",
    "dev": "concurrently --names \"BACKEND,FRONTEND\" -c \"bgBlue.bold,bgMagenta.bold\" \"npm run backend\" \"npm run front\" --kill-others",
    "deploy-front": "cd front && npm run build && node ./deploy.js",
    "deploy-api": "sls deploy -s production",
    "deploy": "npm run deploy-api && npm run deploy-front"
  },
  "devDependencies": {
    "aws-sdk": "^2.104.0",
    "concurrently": "^3.5.0",
    "nodemon": "^1.11.0"
  },
  "author": {
    "name": "<NAME>",
    "email": "<EMAIL>",
    "url": "http://rwilinski.me"
  },
  "license": "MIT"
}
