Repository: badges/shields (js)

Issue:
Replace badge server onboard rasterization with rasterizing proxy
As discussed in #3112 and badges/svg-to-image-proxy#25 we're in the process of deploying a new proxy server. This will replace the onboard generation of raster badges on the badge server which will redirect.

This is a feature that has real but relatively obscure use cases (https://github.com/badges/shields/issues/3112#issuecomment-468738270, https://github.com/badges/shields/issues/3112#issuecomment-480422502) and the simplification and performance benefits make it seem worth breaking this out.

It makes things slightly less convenient for self-hosting users who might need raster badges, though it is not hard to also self-host the rasterizer.

Our current usage 2.5M invocations will cost about $1/month (or less if the downstream cache handles a chunk of the load). I'm not sure how much execution time we should expect. I doubt the cost will add up but if it does we could reach out to Zeit about a donation.

Here are the steps to be taken:

- [x] Deploy rasterizing proxy to raster.shields.io
- [x] Make sure other people have access to deploy.
- [x] Document the production hosting. (#3643 badges/svg-to-image-proxy#27)
    - [ ] Design a better process and address security (#3647)
- [x] Get the server running stably.
- [ ] Update the main server to redirect the `.png` badges to the production URL of the proxy. (#3644)
- [ ] Remove the raster capability and its dependencies from the main server. (#3644)
- [ ] Document how to self-host the rasterizing proxy. (#3644)
    - [ ] Improve the self-hosting docs (#3648)

Pull request:
Drop gif + png, and redirect png to raster.shields.io
This PR does a few things:

1. Remove rasterization support from the server. This responsibility is delegated to a raster server which proxies the SVG badges and renders them.
2. When a raster server URL is configured, 301 redirect all .png badges to the identical URL on the raster server.
    `https://img.shields.io/npm/v/express.png?style=flat-square` ↪️`https://raster.shields.io/npm/v/express.png?style=flat-square`
3. For configured redirects, redirect to the canonical URL on the raster server.
    `https://img.shields.io/vso/build/totodem/8cf3ec0e-d0c2-4fcd-8206-ad204f254a96/2.png?style=flat-square`
    ↪️`https://img.shields.io/azure-devops/build/totodem/8cf3ec0e-d0c2-4fcd-8206-ad204f254a96/2.png?style=flat-square`
4. Redirect the "legacy badge old version" to the appropriate URL on the raster server.
5. When no raster server is configured (e.g. PRs), render an SVG containing **404 | raster badges not available** for all `.png` badges. (Note that the raster server can be self-hosted; however, this is deferred to a later PR.)
5. Drop support for jpg and gif which are very infrequently used (see #3112). Render an SVG containing **410 | jpg no longer available**.
7. ~~Remove raster dependencies.~~ Remove the raster cache (which is only used in the CLI, and therefore pointless).
8. Move the LRUCache code out of the npm package.
8. A wee bit of refactoring in `server.js`.

Ref #3112
Close #3631 

Patch:
diff --git a/Dockerfile b/Dockerfile
index 68b03be407..30d3ef6a09 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,5 @@
 FROM node:8-alpine
 
-RUN apk add --no-cache gettext imagemagick librsvg git
-
 RUN mkdir -p /usr/src/app
 RUN mkdir /usr/src/app/private
 WORKDIR /usr/src/app
diff --git a/config/custom-environment-variables.yml b/config/custom-environment-variables.yml
index 87b75a1589..10908f7ad0 100644
--- a/config/custom-environment-variables.yml
+++ b/config/custom-environment-variables.yml
@@ -14,6 +14,8 @@ public:
 
   redirectUri: 'REDIRECT_URI'
 
+  rasterUrl: 'RASTER_URL'
+
   cors:
     allowedOrigin:
       __name: 'ALLOWED_ORIGIN'
diff --git a/config/shields-io-production.yml b/config/shields-io-production.yml
index f0f0c88f60..1ca9d5805a 100644
--- a/config/shields-io-production.yml
+++ b/config/shields-io-production.yml
@@ -11,6 +11,8 @@ public:
 
   redirectUrl: 'https://shields.io/'
 
+  rasterUrl: 'https://raster.shields.io'
+
 private:
   # These are not really private; they should be moved to `public`.
   shields_ips: ['192.99.59.72', '51.254.114.150', '149.56.96.133']
diff --git a/core/badge-urls/make-badge-url.js b/core/badge-urls/make-badge-url.js
index 7dd9a3a85c..f272f5ec5f 100644
--- a/core/badge-urls/make-badge-url.js
+++ b/core/badge-urls/make-badge-url.js
@@ -1,5 +1,6 @@
 'use strict'
 
+const { URL } = require('url')
 const queryString = require('query-string')
 const pathToRegexp = require('path-to-regexp')
 
@@ -134,6 +135,15 @@ function dynamicBadgeUrl({
   return `${baseUrl}/badge/dynamic/${datatype}.${format}?${outQueryString}`
 }
 
+function rasterRedirectUrl({ rasterUrl }, badgeUrl) {
+  // Ensure we're always using the `rasterUrl` by using just the path from
+  // the request URL.
+  const { pathname, search } = new URL(badgeUrl, 'https://bogus.test')
+  const result = new URL(pathname, rasterUrl)
+  result.search = search
+  return result
+}
+
 module.exports = {
   badgeUrlFromPath,
   badgeUrlFromPattern,
@@ -141,4 +151,5 @@ module.exports = {
   staticBadgeUrl,
   queryStringStaticBadgeUrl,
   dynamicBadgeUrl,
+  rasterRedirectUrl,
 }
diff --git a/core/base-service/base.spec.js b/core/base-service/base.spec.js
index 4c2f99a4ba..0a9297fca5 100644
--- a/core/base-service/base.spec.js
+++ b/core/base-service/base.spec.js
@@ -316,7 +316,7 @@ describe('BaseService', function() {
   })
 
   describe('ScoutCamp integration', function() {
-    const expectedRouteRegex = /^\/foo\/([^/]+?)\.(svg|png|gif|jpg|json)$/
+    const expectedRouteRegex = /^\/foo\/([^/]+?)\.(svg|json)$/
 
     let mockCamp
     let mockHandleRequest
diff --git a/core/base-service/legacy-request-handler.js b/core/base-service/legacy-request-handler.js
index 30980f7708..0639c098d5 100644
--- a/core/base-service/legacy-request-handler.js
+++ b/core/base-service/legacy-request-handler.js
@@ -4,7 +4,6 @@
 const domain = require('domain')
 const request = require('request')
 const queryString = require('query-string')
-const LruCache = require('../../gh-badges/lib/lru-cache')
 const makeBadge = require('../../gh-badges/lib/make-badge')
 const log = require('../server/log')
 const { setCacheHeaders } = require('./cache-headers')
@@ -14,6 +13,7 @@ const {
   ShieldsRuntimeError,
 } = require('./errors')
 const { makeSend } = require('./legacy-result-sender')
+const LruCache = require('./lru-cache')
 const coalesceBadge = require('./coalesce-badge')
 
 // We avoid calling the vendor's server for computation of the information in a
diff --git a/core/base-service/legacy-result-sender.js b/core/base-service/legacy-result-sender.js
index 8b733eb0fa..01ffc800cc 100644
--- a/core/base-service/legacy-result-sender.js
+++ b/core/base-service/legacy-result-sender.js
@@ -1,15 +1,6 @@
 'use strict'
 
-const fs = require('fs')
-const path = require('path')
 const stream = require('stream')
-const svg2img = require('../../gh-badges/lib/svg-to-img')
-const log = require('../server/log')
-
-const internalError = fs.readFileSync(
-  path.resolve(__dirname, '..', 'server', 'error-pages', '500.html'),
-  'utf-8'
-)
 
 function streamFromString(str) {
   const newStream = new stream.Readable()
@@ -25,21 +16,6 @@ function sendSVG(res, askres, end) {
   end(null, { template: streamFromString(res) })
 }
 
-function sendOther(format, res, askres, end) {
-  askres.setHeader('Content-Type', `image/${format}`)
-  svg2img(res, format)
-    // This interacts with callback code and can't use async/await.
-    // eslint-disable-next-line promise/prefer-await-to-then
-    .then(data => {
-      end(null, { template: streamFromString(data) })
-    })
-    .catch(err => {
-      // This emits status code 200, though 500 would be preferable.
-      log.error('svg2img error', err)
-      end(internalError)
-    })
-}
-
 function sendJSON(res, askres, end) {
   askres.setHeader('Content-Type', 'application/json')
   askres.setHeader('Access-Control-Allow-Origin', '*')
@@ -52,7 +28,7 @@ function makeSend(format, askres, end) {
   } else if (format === 'json') {
     return res => sendJSON(res, askres, end)
   } else {
-    return res => sendOther(format, res, askres, end)
+    throw Error(`Unrecognized format: ${format}`)
   }
 }
 
diff --git a/gh-badges/lib/lru-cache.js b/core/base-service/lru-cache.js
similarity index 100%
rename from gh-badges/lib/lru-cache.js
rename to core/base-service/lru-cache.js
diff --git a/gh-badges/lib/lru-cache.spec.js b/core/base-service/lru-cache.spec.js
similarity index 100%
rename from gh-badges/lib/lru-cache.spec.js
rename to core/base-service/lru-cache.spec.js
diff --git a/core/base-service/redirector.js b/core/base-service/redirector.js
index ab4879faa2..d0a5d15c51 100644
--- a/core/base-service/redirector.js
+++ b/core/base-service/redirector.js
@@ -62,8 +62,11 @@ module.exports = function redirector(attrs) {
       return route
     }
 
-    static register({ camp, requestCounter }) {
-      const { regex, captureNames } = prepareRoute(this.route)
+    static register({ camp, requestCounter }, { rasterUrl }) {
+      const { regex, captureNames } = prepareRoute({
+        ...this.route,
+        withPng: Boolean(rasterUrl),
+      })
 
       const serviceRequestCounter = this._createServiceRequestCounter({
         requestCounter,
@@ -104,7 +107,9 @@ module.exports = function redirector(attrs) {
 
         // The final capture group is the extension.
         const format = match.slice(-1)[0]
-        const redirectUrl = `${targetPath}.${format}${urlSuffix}`
+        const redirectUrl = `${
+          format === 'png' ? rasterUrl : ''
+        }${targetPath}.${format}${urlSuffix}`
         trace.logTrace('outbound', emojic.shield, 'Redirect URL', redirectUrl)
 
         ask.res.statusCode = 301
diff --git a/core/base-service/redirector.spec.js b/core/base-service/redirector.spec.js
index 1bd4ff9d7f..6c83f8b54c 100644
--- a/core/base-service/redirector.spec.js
+++ b/core/base-service/redirector.spec.js
@@ -75,7 +75,10 @@ describe('Redirector', function() {
         transformPath,
         dateAdded,
       })
-      ServiceClass.register({ camp }, {})
+      ServiceClass.register(
+        { camp },
+        { rasterUrl: 'http://raster.example.test' }
+      )
     })
 
     it('should redirect as configured', async function() {
@@ -90,7 +93,7 @@ describe('Redirector', function() {
       expect(headers.location).to.equal('/new/service/hello-world.svg')
     })
 
-    it('should preserve the extension', async function() {
+    it('should redirect raster extensions to the canonical path as configured', async function() {
       const { statusCode, headers } = await got(
         `${baseUrl}/very/old/service/hello-world.png`,
         {
@@ -99,7 +102,9 @@ describe('Redirector', function() {
       )
 
       expect(statusCode).to.equal(301)
-      expect(headers.location).to.equal('/new/service/hello-world.png')
+      expect(headers.location).to.equal(
+        'http://raster.example.test/new/service/hello-world.png'
+      )
     })
 
     it('should forward the query params', async function() {
diff --git a/core/base-service/route.js b/core/base-service/route.js
index 0192243017..3bdf15de7c 100644
--- a/core/base-service/route.js
+++ b/core/base-service/route.js
@@ -26,18 +26,16 @@ function assertValidRoute(route, message = undefined) {
   Joi.assert(route, isValidRoute, message)
 }
 
-function prepareRoute({ base, pattern, format, capture }) {
+function prepareRoute({ base, pattern, format, capture, withPng }) {
+  const extensionRegex = ['svg', 'json']
+    .concat(withPng ? ['png'] : [])
+    .join('|')
   let regex, captureNames
   if (pattern === undefined) {
-    regex = new RegExp(
-      `^${makeFullUrl(base, format)}\\.(svg|png|gif|jpg|json)$`
-    )
+    regex = new RegExp(`^${makeFullUrl(base, format)}\\.(${extensionRegex})$`)
     captureNames = capture || []
   } else {
-    const fullPattern = `${makeFullUrl(
-      base,
-      pattern
-    )}.:ext(svg|png|gif|jpg|json)`
+    const fullPattern = `${makeFullUrl(base, pattern)}.:ext(${extensionRegex})`
     const keys = []
     regex = pathToRegexp(fullPattern, keys, {
       strict: true,
diff --git a/core/base-service/route.spec.js b/core/base-service/route.spec.js
index 8eda13d666..5b5331f8da 100644
--- a/core/base-service/route.spec.js
+++ b/core/base-service/route.spec.js
@@ -33,9 +33,6 @@ describe('Route helpers', function() {
     test(namedParams, () => {
       forCases([
         given('/foo/bar.bar.bar.svg'),
-        given('/foo/bar.bar.bar.png'),
-        given('/foo/bar.bar.bar.gif'),
-        given('/foo/bar.bar.bar.jpg'),
         given('/foo/bar.bar.bar.json'),
       ]).expect({ namedParamA: 'bar.bar.bar' })
     })
@@ -64,9 +61,6 @@ describe('Route helpers', function() {
     test(namedParams, () => {
       forCases([
         given('/foo/bar.bar.bar.svg'),
-        given('/foo/bar.bar.bar.png'),
-        given('/foo/bar.bar.bar.gif'),
-        given('/foo/bar.bar.bar.jpg'),
         given('/foo/bar.bar.bar.json'),
       ]).expect({ namedParamA: 'bar.bar.bar' })
     })
@@ -83,9 +77,6 @@ describe('Route helpers', function() {
     test(namedParams, () => {
       forCases([
         given('/foo/bar.bar.bar.svg'),
-        given('/foo/bar.bar.bar.png'),
-        given('/foo/bar.bar.bar.gif'),
-        given('/foo/bar.bar.bar.jpg'),
         given('/foo/bar.bar.bar.json'),
       ]).expect({})
     })
diff --git a/core/server/server.js b/core/server/server.js
index 1fb650c908..c8000c115c 100644
--- a/core/server/server.js
+++ b/core/server/server.js
@@ -16,7 +16,10 @@ const {
   clearRequestCache,
 } = require('../base-service/legacy-request-handler')
 const { clearRegularUpdateCache } = require('../legacy/regular-update')
-const { staticBadgeUrl } = require('../badge-urls/make-badge-url')
+const {
+  staticBadgeUrl,
+  rasterRedirectUrl,
+} = require('../badge-urls/make-badge-url')
 const log = require('./log')
 const sysMonitor = require('./monitor')
 const PrometheusMetrics = require('./prometheus-metrics')
@@ -52,6 +55,7 @@ const publicConfigSchema = Joi.object({
     cert: Joi.string(),
   },
   redirectUrl: optionalUrl,
+  rasterUrl: optionalUrl,
   cors: {
     allowedOrigin: Joi.array()
       .items(optionalUrl)
@@ -183,16 +187,43 @@ module.exports = class Server {
    * Set up Scoutcamp routes for 404/not found responses
    */
   registerErrorHandlers() {
-    const { camp } = this
+    const { camp, config } = this
+    const {
+      public: { rasterUrl },
+    } = config
 
-    camp.notfound(/\.(svg|png|gif|jpg|json)/, (query, match, end, request) => {
+    camp.notfound(/\.(gif|jpg)$/, (query, match, end, request) => {
       const [, format] = match
-      const svg = makeBadge({
-        text: ['404', 'badge not found'],
-        color: 'red',
-        format,
+      makeSend('svg', request.res, end)(
+        makeBadge({
+          text: ['410', `${format} no longer available`],
+          color: 'lightgray',
+          format: 'svg',
+        })
+      )
+    })
+
+    if (!rasterUrl) {
+      camp.notfound(/\.png$/, (query, match, end, request) => {
+        makeSend('svg', request.res, end)(
+          makeBadge({
+            text: ['404', 'raster badges not available'],
+            color: 'lightgray',
+            format: 'svg',
+          })
+        )
       })
-      makeSend(format, request.res, end)(svg)
+    }
+
+    camp.notfound(/\.(svg|json)$/, (query, match, end, request) => {
+      const [, format] = match
+      makeSend(format, request.res, end)(
+        makeBadge({
+          text: ['404', 'badge not found'],
+          color: 'red',
+          format,
+        })
+      )
     })
 
     camp.notfound(/.*/, (query, match, end, request) => {
@@ -219,6 +250,7 @@ module.exports = class Server {
           cacheHeaders: config.public.cacheHeaders,
           profiling: config.public.profiling,
           fetchLimitBytes: bytes(config.public.fetchLimit),
+          rasterUrl: config.public.rasterUrl,
         }
       )
     )
@@ -234,34 +266,57 @@ module.exports = class Server {
    */
   registerRedirects() {
     const { config, camp } = this
+    const {
+      public: { rasterUrl, redirectUrl },
+    } = config
+
+    if (rasterUrl) {
+      // Any badge, old version.
+      camp.route(/^\/([^/]+)\/(.+).png$/, (queryParams, match, end, ask) => {
+        const [, label, message] = match
+        const { color } = queryParams
+
+        const redirectUrl = staticBadgeUrl({
+          baseUrl: rasterUrl,
+          label,
+          message,
+          // Fixes https://github.com/badges/shields/issues/3260
+          color: color ? color.toString() : undefined,
+          format: 'png',
+        })
+
+        ask.res.statusCode = 301
+        ask.res.setHeader('Location', redirectUrl)
+
+        // The redirect is permanent.
+        const cacheDuration = (365 * 24 * 3600) | 0 // 1 year
+        ask.res.setHeader('Cache-Control', `max-age=${cacheDuration}`)
 
-    // Any badge, old version. This route must be registered last.
-    camp.route(/^\/([^/]+)\/(.+).png$/, (queryParams, match, end, ask) => {
-      const [, label, message] = match
-      const { color } = queryParams
-
-      const redirectUrl = staticBadgeUrl({
-        label,
-        message,
-        // Fixes https://github.com/badges/shields/issues/3260
-        color: color ? color.toString() : undefined,
-        format: 'png',
+        ask.res.end()
       })
 
-      ask.res.statusCode = 301
-      ask.res.setHeader('Location', redirectUrl)
+      // Redirect to the raster server for raster versions of modern badges.
+      camp.route(/\.png$/, (queryParams, match, end, ask) => {
+        ask.res.statusCode = 301
+        ask.res.setHeader(
+          'Location',
+          rasterRedirectUrl({ rasterUrl }, ask.req.url)
+        )
 
-      // The redirect is permanent.
-      const cacheDuration = (365 * 24 * 3600) | 0 // 1 year
-      ask.res.setHeader('Cache-Control', `max-age=${cacheDuration}`)
+        // The redirect is permanent, though let's start off with a shorter
+        // cache time in case we've made mistakes.
+        // const cacheDuration = (365 * 24 * 3600) | 0 // 1 year
+        const cacheDuration = 3600 | 0 // 1 hour
+        ask.res.setHeader('Cache-Control', `max-age=${cacheDuration}`)
 
-      ask.res.end()
-    })
+        ask.res.end()
+      })
+    }
 
-    if (config.public.redirectUrl) {
+    if (redirectUrl) {
       camp.route(/^\/$/, (data, match, end, ask) => {
         ask.res.statusCode = 302
-        ask.res.setHeader('Location', config.public.redirectUrl)
+        ask.res.setHeader('Location', redirectUrl)
         ask.res.end()
       })
     }
diff --git a/core/server/server.spec.js b/core/server/server.spec.js
index 52d21891be..a3c8b81839 100644
--- a/core/server/server.spec.js
+++ b/core/server/server.spec.js
@@ -1,13 +1,8 @@
 'use strict'
 
-const fs = require('fs')
-const path = require('path')
 const { expect } = require('chai')
-const isPng = require('is-png')
 const isSvg = require('is-svg')
-const sinon = require('sinon')
 const portfinder = require('portfinder')
-const svg2img = require('../../gh-badges/lib/svg-to-img')
 const got = require('../got-test-client')
 const { createTestServer } = require('./in-process-server-test-helpers')
 
@@ -37,12 +32,17 @@ describe('The server', function() {
       .and.to.include('apple')
   })
 
-  it('should produce colorscheme PNG badges', async function() {
-    const { statusCode, body } = await got(`${baseUrl}:fruit-apple-green.png`, {
-      encoding: null,
-    })
-    expect(statusCode).to.equal(200)
-    expect(body).to.satisfy(isPng)
+  it('should redirect colorscheme PNG badges as configured', async function() {
+    const { statusCode, headers } = await got(
+      `${baseUrl}:fruit-apple-green.png`,
+      {
+        followRedirect: false,
+      }
+    )
+    expect(statusCode).to.equal(301)
+    expect(headers.location).to.equal(
+      'http://raster.example.com/:fruit-apple-green.png'
+    )
   })
 
   it('should preserve label case', async function() {
@@ -118,28 +118,14 @@ describe('The server', function() {
     expect(headers.location).to.equal('http://badge-server.example.com')
   })
 
-  context('with svg2img error', function() {
-    const expectedError = fs.readFileSync(
-      path.resolve(__dirname, 'error-pages', '500.html')
-    )
-
-    let toBufferStub
-    beforeEach(function() {
-      toBufferStub = sinon
-        .stub(svg2img._imageMagick.prototype, 'toBuffer')
-        .callsArgWith(1, Error('whoops'))
-    })
-    afterEach(function() {
-      toBufferStub.restore()
-    })
-
-    it('should emit the 500 message', async function() {
-      const { statusCode, body } = await got(
-        `${baseUrl}:some_new-badge-green.png`
-      )
-      // This emits status code 200, though 500 would be preferable.
-      expect(statusCode).to.equal(200)
-      expect(body).to.include(expectedError)
+  it('should return the 410 badge for obsolete formats', async function() {
+    const { statusCode, body } = await got(`${baseUrl}npm/v/express.jpg`, {
+      throwHttpErrors: false,
     })
+    expect(statusCode).to.equal(404)
+    expect(body)
+      .to.satisfy(isSvg)
+      .and.to.include('410')
+      .and.to.include('jpg no longer available')
   })
 })
diff --git a/doc/TUTORIAL.md b/doc/TUTORIAL.md
index bfb4667c83..54cb90e3c5 100644
--- a/doc/TUTORIAL.md
+++ b/doc/TUTORIAL.md
@@ -42,11 +42,6 @@ install node and npm: https://nodejs.org/en/download/
 
 In case you get the _"getaddrinfo ENOTFOUND localhost"_ error, visit [http://127.0.0.1:3000/](http://127.0.0.1:3000) instead or take a look at [this issue](https://github.com/angular/angular-cli/issues/2227#issuecomment-358036526).
 
-You may also want to install
-[ImageMagick](https://www.imagemagick.org/script/download.php).
-This is an optional dependency needed for generating badges in raster format,
-but you can get a dev copy running without it.
-
 ## (3) Open an Issue
 
 Before you want to implement your service, you may want to [open an issue](https://github.com/badges/shields/issues/new?template=3_Badge_request.md) and describe what you have in mind:
diff --git a/doc/production-hosting.md b/doc/production-hosting.md
index e96241061b..973e7eaf44 100644
--- a/doc/production-hosting.md
+++ b/doc/production-hosting.md
@@ -73,12 +73,10 @@ Shields has mercifully little persistent state:
     inspectable.
     - The [request cache][]
     - The [regular-update cache][]
-    - The [raster cache][]
 
 [github auth admin endpoint]: https://github.com/badges/shields/blob/master/services/github/auth/admin.js
 [request cache]: https://github.com/badges/shields/blob/master/core/base-service/legacy-request-handler.js#L29-L30
 [regular-update cache]: https://github.com/badges/shields/blob/master/core/legacy/regular-update.js
-[raster cache]: https://github.com/badges/shields/blob/master/gh-badges/lib/svg-to-img.js#L9-L10
 [oauth transfer]: https://developer.github.com/apps/managing-oauth-apps/transferring-ownership-of-an-oauth-app/
 
 ## Configuration
diff --git a/doc/self-hosting.md b/doc/self-hosting.md
index 880d53e795..ae2ab7d485 100644
--- a/doc/self-hosting.md
+++ b/doc/self-hosting.md
@@ -93,6 +93,27 @@ machine.
 
 [shields.example.env]: ../shields.example.env
 
+## Raster server
+
+If you want to host PNG badges, you can also self-host a [raster server][]
+which points to your badge server. It's designed as a web function which is
+tested on Zeit Now, though you may be able to run it on AWS Lambda. It's
+built on the [micro][] framework, and comes with a `start` script that allows
+it to run as a standalone Node service.
+
+- In your raster instance, set `BASE_URL` to your Shields instance, e.g.
+  `https://shields.example.co`.
+- Optionally, in your Shields, instance, configure `RASTER_URL` to the base
+  URL, e.g. `https://raster.example.co`. This will send 301 redirects
+  for the legacy raster URLs instead of 404's.
+
+If anyone has set this up, more documentation on how to do this would be
+welcome! It would also be nice to ship a Docker image that includes a
+preconfigured raster server.
+
+[raster server]: https://github.com/badges/svg-to-image-proxy
+[micro]: https://github.com/zeit/micro
+
 ## Zeit Now
 
 To deploy using Zeit Now:
diff --git a/gh-badges/lib/svg-to-img.js b/gh-badges/lib/svg-to-img.js
index 61c04b616e..a8d11b0ca0 100644
--- a/gh-badges/lib/svg-to-img.js
+++ b/gh-badges/lib/svg-to-img.js
@@ -2,20 +2,10 @@
 
 const { promisify } = require('util')
 const gm = require('gm')
-const LruCache = require('./lru-cache')
 
 const imageMagick = gm.subClass({ imageMagick: true })
 
-// The following is an arbitrary limit (~1.5MB, 1.5kB/image).
-const imgCache = new LruCache(1000)
-
 async function svgToImg(svg, format) {
-  const cacheIndex = `${format}${svg}`
-
-  if (imgCache.has(cacheIndex)) {
-    return imgCache.get(cacheIndex)
-  }
-
   const svgBuffer = Buffer.from(
     `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>${svg}`
   )
@@ -26,14 +16,10 @@ async function svgToImg(svg, format) {
     .flatten()
   const toBuffer = chain.toBuffer.bind(chain)
 
-  const data = await promisify(toBuffer)(format)
-
-  imgCache.set(cacheIndex, data)
-  return data
+  return promisify(toBuffer)(format)
 }
 
 module.exports = svgToImg
 
 // To simplify testing.
-module.exports._imgCache = imgCache
 module.exports._imageMagick = imageMagick
diff --git a/gh-badges/lib/svg-to-img.spec.js b/gh-badges/lib/svg-to-img.spec.js
index 3406d19db0..cd46df6596 100644
--- a/gh-badges/lib/svg-to-img.spec.js
+++ b/gh-badges/lib/svg-to-img.spec.js
@@ -2,32 +2,13 @@
 
 const { expect } = require('chai')
 const isPng = require('is-png')
-const sinon = require('sinon')
 const svg2img = require('./svg-to-img')
 const makeBadge = require('./make-badge')
 
 describe('The rasterizer', function() {
-  let cacheGet
-  beforeEach(function() {
-    cacheGet = sinon.spy(svg2img._imgCache, 'get')
-  })
-  afterEach(function() {
-    cacheGet.restore()
-  })
-
   it('should produce PNG', async function() {
     const svg = makeBadge({ text: ['cactus', 'grown'], format: 'svg' })
     const data = await svg2img(svg, 'png')
     expect(data).to.satisfy(isPng)
   })
-
-  it('should cache its results', async function() {
-    const svg = makeBadge({ text: ['will-this', 'be-cached?'], format: 'svg' })
-    const data1 = await svg2img(svg, 'png')
-    expect(data1).to.satisfy(isPng)
-    expect(cacheGet).not.to.have.been.called
-    const data2 = await svg2img(svg, 'png')
-    expect(data2).to.satisfy(isPng)
-    expect(cacheGet).to.have.been.calledOnce
-  })
 })


Test patch:
diff --git a/config/test.yml b/config/test.yml
index a09fada736..a330d56b14 100644
--- a/config/test.yml
+++ b/config/test.yml
@@ -7,4 +7,6 @@ public:
 
   redirectUrl: 'http://badge-server.example.com'
 
+  rasterUrl: 'http://raster.example.com'
+
   handleInternalErrors: false