Separate API service, deploy frontend statically (#474)

* extract server into separate service

* fix basic tests with url.parse

* use Next withRouter

* remove old custom next rendering
main
Michael Fix 6 years ago committed by GitHub
parent f80c835328
commit 0580e1c8e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

1
.gitignore vendored

@ -1,6 +1,7 @@
node_modules
.env
.next
out
cypress/videos
cypress/screenshots
.idea

@ -1,38 +1,13 @@
FROM node:9-alpine
# Source https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md
# Installs latest Chromium package.
ENV CHROME_BIN=/usr/bin/chromium-browser
RUN apk update && apk upgrade && \
echo http://nl.alpinelinux.org/alpine/edge/community >> /etc/apk/repositories && \
echo http://nl.alpinelinux.org/alpine/edge/main >> /etc/apk/repositories && \
apk add --no-cache \
chromium \
nss
WORKDIR /app
COPY package.json ./
COPY yarn.lock ./
# Tell Puppeteer to skip installing Chrome. We'll be using the installed package.
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true
# Source: https://github.com/zeit/now-static-build-starter/blob/master/Dockerfile
FROM mhart/alpine-node:10
# We store all our files in /usr/src to perform the build
WORKDIR /usr/src
# We first add only the files required for installing deps
# If package.json or yarn.lock don't change, no need to re-install later
COPY package.json yarn.lock ./
# We install our deps
RUN yarn
# We copy all source files
COPY . .
RUN yarn build
# Add user so we don't need --no-sandbox.
RUN addgroup -S pptruser && adduser -S -g pptruser pptruser \
&& mkdir -p /home/pptruser/Downloads \
&& chown -R pptruser:pptruser /home/pptruser \
&& chown -R pptruser:pptruser /app
# Run everything after as non-privileged user.
USER pptruser
ENV NODE_ENV production
EXPOSE 3000
CMD [ "yarn", "start" ]
# We run the build and expose as /public
RUN yarn build && yarn export -o /public

@ -0,0 +1,36 @@
FROM node:9-alpine
# Source https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md
# Installs latest Chromium package.
ENV CHROME_BIN=/usr/bin/chromium-browser
RUN apk update && apk upgrade && \
echo http://nl.alpinelinux.org/alpine/edge/community >> /etc/apk/repositories && \
echo http://nl.alpinelinux.org/alpine/edge/main >> /etc/apk/repositories && \
apk add --no-cache \
chromium \
nss
WORKDIR /app
COPY package.json ./
COPY yarn.lock ./
# Tell Puppeteer to skip installing Chrome. We'll be using the installed package.
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true
RUN yarn
COPY . .
# Add user so we don't need --no-sandbox.
RUN addgroup -S pptruser && adduser -S -g pptruser pptruser \
&& mkdir -p /home/pptruser/Downloads \
&& chown -R pptruser:pptruser /home/pptruser \
&& chown -R pptruser:pptruser /app
# Run everything after as non-privileged user.
USER pptruser
ENV NODE_ENV production
EXPOSE 4000
CMD [ "node", "server.js" ]

@ -1,5 +1,4 @@
/* global domtoimage */
const PORT = parseInt(process.env.PORT, 10) || 3000
const ARBITRARY_WAIT_TIME = 500
module.exports = browser => async (req, res) => {
@ -9,8 +8,8 @@ module.exports = browser => async (req, res) => {
if (!state) res.status(400).send()
try {
await page.goto(`http://localhost:${PORT}?state=${state}`)
await page.addScriptTag({ path: './lib/customDomToImage.js' })
await page.goto(`http://carbon.now.sh?state=${state}`)
await page.addScriptTag({ path: './customDomToImage.js' })
// wait for page to detect language
await delay(ARBITRARY_WAIT_TIME)

@ -0,0 +1,20 @@
{
"name": "carbon-api",
"alias": "carbon-api.now.sh",
"type": "docker",
"public": true,
"env": {
"NODE_ENV": "production",
"TWITTER_CONSUMER_KEY": "@twitter-consumer-key",
"TWITTER_CONSUMER_SECRET": "@twitter-consumer-secret",
"TWITTER_ACCESS_TOKEN_KEY": "@twitter-access-token-key",
"TWITTER_ACCESS_TOKEN_SECRET": "@twitter-access-token-secret",
"LOGS_SECRET_PREFIX": "@logs_secret_prefix",
"UNSPLASH_SECRET_KEY": "@unsplash_secret_key",
"UNSPLASH_ACCESS_KEY": "@unsplash_access_key",
"UNSPLASH_CALLBACK_URL": "@unsplash_callback_url"
},
"features": {
"cloud": "v1"
}
}

@ -0,0 +1,24 @@
{
"name": "carbon-api",
"version": "0.0.0-semantically-released",
"main": "index.js",
"license": "MIT",
"scripts": {
"dev": "node server.js",
"start": "node server.js",
"deploy": "now"
},
"dependencies": {
"body-parser": "^1.17.2",
"compression": "^1.7.3",
"cors": "^2.8.4",
"express": "^4.16.2",
"isomorphic-fetch": "^2.2.1",
"morgan": "^1.8.2",
"morphmorph": "^0.1.2",
"now-logs": "^0.0.7",
"puppeteer": "1.7.0",
"twit": "^2.2.9",
"unsplash-js": "^4.8.0"
}
}

@ -0,0 +1,63 @@
const express = require('express')
const cors = require('cors')
const compression = require('compression')
const morgan = require('morgan')
const bodyParser = require('body-parser')
const puppeteer = require('puppeteer')
const port = parseInt(process.env.PORT, 10) || 4000
const dev = process.env.NODE_ENV !== 'production'
process.on('SIGINT', process.exit)
if (!dev) {
const LOGS_ID = `${process.env.LOGS_SECRET_PREFIX}:${process.env.NOW_URL}`
require('now-logs')(LOGS_ID)
}
function wrap(handler) {
return (req, res) =>
handler(req, res).catch(err => {
// eslint-disable-next-line
console.log('ERR:', err)
res.status(400).end()
})
}
const puppeteerParams = dev
? {}
: {
executablePath: '/usr/bin/chromium-browser',
args: ['--no-sandbox', '--disable-setuid-sandbox']
}
puppeteer.launch(puppeteerParams).then(browser => {
// set up
const server = express()
const imageHandler = require('./handlers/image')(browser)
const unsplashHandler = require('./handlers/unsplash')
if (dev) {
server.use(morgan('tiny'))
}
server.use(cors())
server.use(compression())
// Service Worker
// const filePath = path.join(__dirname, '.next', 'service-worker.js')
// server.get('/service-worker.js', (req, res) => app.serveStatic(req, res, filePath))
// api endpoints
server.post('/twitter', bodyParser.json({ limit: '5mb' }), require('./handlers/twitter'))
server.post('/image', bodyParser.json({ limit: '5mb' }), wrap(imageHandler))
server.get('/unsplash/random', wrap(unsplashHandler.randomImages))
server.get('/unsplash/download/:imageId', wrap(unsplashHandler.downloadImage))
server.listen(port, '0.0.0.0', err => {
if (err) throw err
// eslint-disable-next-line
console.log(`> Ready on http://localhost:${port}`)
})
})

File diff suppressed because it is too large Load Diff

@ -1,4 +1,5 @@
// Theirs
import url from 'url'
import React from 'react'
import HTML5Backend from 'react-dnd-html5-backend'
import { DragDropContext } from 'react-dnd'
@ -30,8 +31,8 @@ import {
DEFAULT_SETTINGS,
DEFAULT_LANGUAGE
} from '../lib/constants'
import { serializeState } from '../lib/routing'
import { getState } from '../lib/util'
import { serializeState, getQueryStringState } from '../lib/routing'
import { getState, escapeHtml } from '../lib/util'
const saveButtonOptions = {
button: true,
@ -68,11 +69,30 @@ class Editor extends React.Component {
this.setOnline = () => this.setState({ online: true })
}
componentDidMount() {
async componentDidMount() {
const { asPath = '' } = this.props
const { query } = url.parse(asPath, true)
const path = removeQueryString(asPath.split('/').pop())
const queryParams = getQueryStringState(query)
const initialState = Object.keys(queryParams).length ? queryParams : {}
try {
// TODO fix this hack
if (path.length >= 19 && path.indexOf('.') === -1) {
const { content, language } = await api.getGist(path)
if (language) {
initialState.language = language.toLowerCase()
}
initialState.code = content
}
} catch (e) {
// eslint-disable-next-line
console.log(e)
}
// Load from localStorage and then URL params
this.setState({
...getState(localStorage),
...this.props.initialState,
...initialState,
loading: false,
online: Boolean(window && window.navigator && window.navigator.onLine)
})
@ -316,6 +336,11 @@ class Editor extends React.Component {
}
}
function removeQueryString(str) {
const qI = str.indexOf('?')
return escapeHtml(qI >= 0 ? str.substr(0, qI) : str)
}
function formatTimestamp() {
const timezoneOffset = new Date().getTimezoneOffset() * 60000
const timeString = new Date(Date.now() - timezoneOffset)

@ -1,12 +1,13 @@
import React from 'react'
import axios from 'axios'
import Spinner from 'react-spinner'
import api from '../lib/api'
import PhotoCredit from './PhotoCredit'
import { fileToDataURL } from '../lib/util'
const downloadThumbnailImage = img => {
return axios
return api.client
.get(img.url, { responseType: 'blob' })
.then(res => res.data)
.then(fileToDataURL)
@ -14,7 +15,7 @@ const downloadThumbnailImage = img => {
}
const getImageDownloadUrl = img =>
axios.get(`/unsplash/download/${img.id}`).then(res => res.data.url)
api.client.get(`/unsplash/download/${img.id}`).then(res => res.data.url)
class RandomImage extends React.Component {
constructor(props) {
@ -37,7 +38,7 @@ class RandomImage extends React.Component {
}
async getImages() {
const imageUrls = await axios.get('/unsplash/random')
const imageUrls = await api.client.get('/unsplash/random')
return Promise.all(imageUrls.data.map(downloadThumbnailImage))
}
@ -46,7 +47,7 @@ class RandomImage extends React.Component {
this.setState({ loading: true })
getImageDownloadUrl(image)
.then(url => axios.get(url, { responseType: 'blob' }))
.then(url => api.client.get(url, { responseType: 'blob' }))
.then(res => res.data)
.then(blob => this.props.onChange(blob, image))
.then(() => this.setState({ loading: false }))

@ -147,7 +147,8 @@ export default () => (
src: local('Space Mono'), local('SpaceMono-Regular'),
url(https://fonts.gstatic.com/s/spacemono/v2/i7dPIFZifjKcF5UAWdDRYEF8RQ.woff2)
format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
`}
</style>

@ -192,7 +192,50 @@ export default () => (
background: none;
}
.react-spinner{z-index: 999;position:relative;width:32px;height:32px;top:50%;left:50%}.react-spinner_bar{-webkit-animation:react-spinner_spin 1.2s linear infinite;-moz-animation:react-spinner_spin 1.2s linear infinite;animation:react-spinner_spin 1.2s linear infinite;border-radius:5px;background-color:#fff;position:absolute;width:20%;height:7.8%;top:-3.9%;left:-10%}@keyframes react-spinner_spin{0%{opacity:1}100%{opacity:.15}}@-moz-keyframes react-spinner_spin{0%{opacity:1}100%{opacity:.15}}@-webkit-keyframes react-spinner_spin{0%{opacity:1}100%{opacity:.15}}
.react-spinner {
z-index: 999;
position: relative;
width: 32px;
height: 32px;
top: 50%;
left: 50%;
}
.react-spinner_bar {
-webkit-animation: react-spinner_spin 1.2s linear infinite;
-moz-animation: react-spinner_spin 1.2s linear infinite;
animation: react-spinner_spin 1.2s linear infinite;
border-radius: 5px;
background-color: #fff;
position: absolute;
width: 20%;
height: 7.8%;
top: -3.9%;
left: -10%;
}
@keyframes react-spinner_spin {
0% {
opacity: 1;
}
100% {
opacity: 0.15;
}
}
@-moz-keyframes react-spinner_spin {
0% {
opacity: 1;
}
100% {
opacity: 0.15;
}
}
@-webkit-keyframes react-spinner_spin {
0% {
opacity: 1;
}
100% {
opacity: 0.15;
}
}
`}
</style>
)

@ -2,7 +2,11 @@ import axios from 'axios'
import debounce from 'lodash.debounce'
import ms from 'ms'
const DOMAIN = process.browser ? document.location.origin : ''
import getConfig from 'next/config'
const { publicRuntimeConfig } = getConfig()
const client = axios.create({ baseURL: publicRuntimeConfig.API_URL })
const RATE_LIMIT_CODE = 420
const gistClient = axios.create({
@ -17,8 +21,8 @@ const gistClient = axios.create({
async function tweet(content, encodedImage) {
const processedData = encodedImage.split(',')[1]
return axios
.post(`${DOMAIN}/twitter`, {
return client
.post('/twitter', {
imageData: processedData,
altText: content
})
@ -29,8 +33,8 @@ async function tweet(content, encodedImage) {
.catch(checkIfRateLimited)
}
async function image(state) {
return axios.post(`${DOMAIN}/image`, { state }).then(res => res.data.dataUrl)
function image(state) {
return client.post('/image', { state }).then(res => res.data.dataUrl)
}
function getGist(uid) {
@ -64,6 +68,7 @@ function checkIfRateLimited(err) {
}
export default {
client,
getGist,
tweet: debounce(tweet, ms('5s'), { leading: true, trailing: true }),
image: debounce(image, ms('5s'), { leading: true, trailing: true })

@ -8,9 +8,41 @@ CodeMirror.defineMode('nimrod', function(conf, parserConf) {
return new RegExp('^((' + words.join(')|(') + '))\\b')
}
var ops = ['=', '+', '-', '*', '/', '<', '>', '@', '$', '~', '&', '%', '|',
'?', '^', ':', '\\', '[', ']', '(', ')', ',', '{', '}', '.\\.', '.']
var operators = new RegExp(ops.map(function(op) { return '\\' + op; }).join('|'))
var ops = [
'=',
'+',
'-',
'*',
'/',
'<',
'>',
'@',
'$',
'~',
'&',
'%',
'|',
'?',
'^',
':',
'\\',
'[',
']',
'(',
')',
',',
'{',
'}',
'.\\.',
'.'
]
var operators = new RegExp(
ops
.map(function(op) {
return '\\' + op
})
.join('|')
)
var identifiers = new RegExp('^[_A-Za-z][_A-Za-z0-9]*')
var commonkeywords = [

@ -1,3 +1,26 @@
const { PHASE_DEVELOPMENT_SERVER } = require('next/constants')
const withOffline = require('next-offline')
module.exports = withOffline()
module.exports = (phase /* { defaultConfig } */) => {
const config = {
exportPathMap() {
return {
'/about': { page: '/about' },
'/index': { page: '/index' },
'/': { page: '/' }
}
},
publicRuntimeConfig: {
API_URL:
process.env.NODE_ENV === 'production'
? 'https://carbon-api.now.sh'
: 'http://localhost:4000'
}
}
if (phase === PHASE_DEVELOPMENT_SERVER) {
return config
}
return withOffline(config)
}

@ -1,19 +1,13 @@
{
"name": "carbon",
"type": "docker",
"type": "static",
"public": true,
"env": {
"NODE_ENV": "production",
"TWITTER_CONSUMER_KEY": "@twitter-consumer-key",
"TWITTER_CONSUMER_SECRET": "@twitter-consumer-secret",
"TWITTER_ACCESS_TOKEN_KEY": "@twitter-access-token-key",
"TWITTER_ACCESS_TOKEN_SECRET": "@twitter-access-token-secret",
"LOGS_SECRET_PREFIX": "@logs_secret_prefix",
"UNSPLASH_SECRET_KEY": "@unsplash_secret_key",
"UNSPLASH_ACCESS_KEY": "@unsplash_access_key",
"UNSPLASH_CALLBACK_URL": "@unsplash_callback_url"
},
"features": {
"cloud": "v1"
"static": {
"rewrites": [
{
"source": "!/about",
"destination": "/index.html"
}
]
}
}

@ -4,13 +4,14 @@
"main": "index.js",
"license": "MIT",
"scripts": {
"dev": "node server.js",
"dev": "next",
"build": "next build",
"start": "node server.js",
"start": "next start",
"export": "next export",
"test": "npm run cy:run --",
"deploy": "now",
"prettier": "prettier --config .prettierrc --write *.js {components,handlers,lib,pages}/*.js",
"lint": "eslint .",
"prettier": "prettier --config .prettierrc --write *.js {components,api,lib,pages}/**/*.js",
"lint": "eslint components/*.js lib/*.js pages/*.js api/handlers/*.js api/*.js",
"precommit": "npm run contrib:build && git add README.md && lint-staged",
"contrib:add": "all-contributors add",
"contrib:build": "all-contributors generate",
@ -19,29 +20,22 @@
},
"dependencies": {
"axios": "^0.18.0",
"body-parser": "^1.17.2",
"codemirror": "^5.36.0",
"codemirror-graphql": "^0.6.12",
"codemirror-mode-elixir": "^1.1.1",
"compression": "^1.7.3",
"dom-to-image": "^2.5.2",
"downshift": "^2.0.0",
"dropperx": "0.2.1",
"express": "^4.16.2",
"graphql": "^0.13.2",
"highlight.js": "^9.12.0",
"history": "^4.7.2",
"isomorphic-fetch": "^2.2.1",
"lodash.debounce": "^4.0.8",
"match-sorter": "^2.2.0",
"morgan": "^1.8.2",
"morphmorph": "^0.1.0",
"ms": "^2.0.0",
"next": "^6.0.3",
"next-offline": "^2.9.0",
"now-logs": "^0.0.7",
"prettier": "^1.8.1",
"puppeteer": "1.7.0",
"react": "16.3.*",
"react-click-outside": "^3.0.0",
"react-codemirror2": "^5.1.0",
@ -54,8 +48,7 @@
"react-syntax-highlight": "^15.3.1",
"resize-observer-polyfill": "^1.5.0",
"tohash": "^1.0.2",
"twit": "^2.2.9",
"unsplash-js": "^4.8.0"
"url": "^0.11.0"
},
"devDependencies": {
"all-contributors-cli": "^5.0.0",

@ -1,38 +1,19 @@
// Theirs
import React from 'react'
import { withRouter } from "next/router";
// Ours
import Editor from '../components/Editor'
import Page from '../components/Page'
import api from '../lib/api'
import { getQueryStringState, updateQueryString } from '../lib/routing'
import { saveState, escapeHtml } from '../lib/util'
import { updateQueryString } from '../lib/routing'
import { saveState } from '../lib/util'
class Index extends React.Component {
static async getInitialProps({ asPath, query }) {
const path = removeQueryString(asPath.split('/').pop())
const queryParams = getQueryStringState(query)
const initialState = Object.keys(queryParams).length ? queryParams : {}
try {
// TODO fix this hack
if (path.length >= 19 && path.indexOf('.') === -1) {
const { content, language } = await api.getGist(path)
if (language) {
initialState.language = language.toLowerCase()
}
return { content, initialState }
}
} catch (e) {
// eslint-disable-next-line
console.log(e)
}
return { initialState }
}
render() {
return (
<Page enableHeroText={true}>
<Editor {...this.props} onUpdate={onEditorUpdate} tweet={api.tweet} />
<Editor {...this.props.router} onUpdate={onEditorUpdate} tweet={api.tweet} />
</Page>
)
}
@ -47,9 +28,4 @@ function onEditorUpdate(state) {
saveState(localStorage, s)
}
function removeQueryString(str) {
const qI = str.indexOf('?')
return escapeHtml(qI >= 0 ? str.substr(0, qI) : str)
}
export default Index
export default withRouter(Index);

@ -1,73 +0,0 @@
const path = require('path')
const express = require('express')
const compression = require('compression')
const morgan = require('morgan')
const bodyParser = require('body-parser')
const next = require('next')
const puppeteer = require('puppeteer')
const port = parseInt(process.env.PORT, 10) || 3000
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()
process.on('SIGINT', process.exit)
if (!dev) {
const LOGS_ID = `${process.env.LOGS_SECRET_PREFIX}:${process.env.NOW_URL}`
require('now-logs')(LOGS_ID)
}
function wrap(handler) {
return (req, res) =>
handler(req, res).catch(err => {
// eslint-disable-next-line
console.log('ERR:', err)
res.status(400).end()
})
}
const puppeteerParams = dev
? {}
: {
executablePath: '/usr/bin/chromium-browser',
args: ['--no-sandbox', '--disable-setuid-sandbox']
}
app
.prepare()
.then(puppeteer.launch.bind(puppeteer, puppeteerParams))
.then(browser => {
// set up
const server = express()
const imageHandler = require('./handlers/image')(browser)
const unsplashHandler = require('./handlers/unsplash')
if (dev) {
server.use(morgan('tiny'))
}
server.use(compression())
const filePath = path.join(__dirname, '.next', 'service-worker.js')
server.get('/service-worker.js', (req, res) => app.serveStatic(req, res, filePath))
// api endpoints
server.post('/twitter', bodyParser.json({ limit: '5mb' }), require('./handlers/twitter'))
server.post('/image', bodyParser.json({ limit: '5mb' }), wrap(imageHandler))
server.get('/unsplash/random', wrap(unsplashHandler.randomImages))
server.get('/unsplash/download/:imageId', wrap(unsplashHandler.downloadImage))
server.get('/about', (req, res) => app.render(req, res, '/about'))
// if root, render webpage from next
server.get('/*', (req, res) => app.render(req, res, '/', req.query))
// otherwise, try and get gist
server.get('*', handle)
server.listen(port, '0.0.0.0', err => {
if (err) throw err
// eslint-disable-next-line
console.log(`> Ready on http://localhost:${port}`)
})
})

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save