├── .circleci └── config.yml ├── .editorconfig ├── .gitignore ├── .vercelignore ├── .yarnrc ├── LICENSE ├── README.md ├── api └── index.js ├── lib ├── aliases.js ├── cache.js ├── index.js ├── platform.js ├── routes.js ├── server.js └── view.js ├── package.json ├── test ├── aliases.test.js ├── cache.test.js ├── platform.test.js └── server.test.js ├── vercel.json ├── views └── index.hbs └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:latest 6 | 7 | working_directory: ~/repo 8 | 9 | steps: 10 | 11 | - checkout 12 | 13 | - restore_cache: 14 | keys: 15 | - v1-dependencies-{{ checksum "package.json" }} 16 | # fallback to using the latest cache if no exact match is found 17 | - v1-dependencies- 18 | 19 | - run: yarn 20 | 21 | - save_cache: 22 | paths: 23 | - node_modules 24 | key: v1-dependencies-{{ checksum "package.json" }} 25 | 26 | - run: yarn test 27 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | # editorconfig-tools is unable to ignore longs strings or urls 11 | max_line_length = null 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # code coverage 2 | coverage 3 | 4 | # dependencies 5 | node_modules 6 | -------------------------------------------------------------------------------- /.vercelignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | test 3 | .circleci 4 | .yarnrc 5 | .gitignore 6 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | save-prefix "" 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Vercel, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hazel 2 | 3 | [![CircleCI](https://circleci.com/gh/vercel/hazel/tree/master.svg?style=svg)](https://circleci.com/gh/vercel/hazel/tree/master) 4 | [![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/sindresorhus/xo) 5 | 6 | This project lets you deploy an update server for [Electron](https://www.electronjs.org) apps with ease: You only need to click a button. 7 | 8 | The result will be faster and more lightweight than any other solution out there! :rocket: 9 | 10 | - Recommended by Electron [here](https://www.electronjs.org/docs/tutorial/updates#deploying-an-update-server) 11 | - Built on top of [micro](https://github.com/zeit/micro), the tiniest HTTP framework for Node.js 12 | - Pulls the latest release data from [GitHub Releases](https://help.github.com/articles/creating-releases/) and caches it in memory 13 | - Refreshes the cache every **15 minutes** (custom interval [possible](#options)) 14 | - When asked for an update, it returns the link to the GitHub asset directly (saves bandwidth) 15 | - Supports **macOS** and **Windows** apps 16 | - Scales infinitely on [Vercel](https://vercel.com) Serverless Functions 17 | 18 | ## Usage 19 | 20 | Open this link in a new tab to deploy Hazel on [Vercel](https://vercel.com): 21 | 22 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fhazel&env=ACCOUNT,REPOSITORY&envDescription=Enter%20your%20GitHub%20user%2Forg%20slug%20and%20the%20name%20of%20the%20repository%20that%20contains%20your%20Electron%20app.&envLink=https%3A%2F%2Fgithub.com%2Fvercel%2Fhazel%23usage&repo-name=hazel-update-server) 23 | 24 | Once it's deployed, paste the deployment address into your code (please keep in mind that updates should only occur in the production version of the app, not while developing): 25 | 26 | ```js 27 | const { app, autoUpdater } = require('electron') 28 | 29 | const server = 30 | const url = `${server}/update/${process.platform}/${app.getVersion()}` 31 | 32 | autoUpdater.setFeedURL({ url }) 33 | ``` 34 | 35 | That's it! :white_check_mark: 36 | 37 | From now on, the auto updater will ask your Hazel deployment for updates! 38 | 39 | ## Options 40 | 41 | The following environment variables can be used optionally: 42 | 43 | - `INTERVAL`: Refreshes the cache every x minutes ([restrictions](https://developer.github.com/changes/2012-10-14-rate-limit-changes/)) (defaults to 15 minutes) 44 | - `PRE`: When defined with a value of `1`, only pre-releases will be cached 45 | - `TOKEN`: Your GitHub token (for private repos) 46 | - `URL`: The server's URL (for private repos - when running on [Vercel](https://vercel.com), this field is filled with the URL of the deployment automatically) 47 | 48 | ## Statistics 49 | 50 | Since Hazel routes all the traffic for downloading the actual application files to [GitHub Releases](https://help.github.com/articles/creating-releases/), you can use their API to determine the download count for a certain release. 51 | 52 | As an example, check out the [latest Hyper release](https://api.github.com/repos/vercel/hyper/releases/latest) and search for `mac.zip`. You'll find a release containing a sub property named `download_count` with the amount of downloads as its value. 53 | 54 | ## Routes 55 | 56 | ### / 57 | 58 | Displays an overview page showing the cached repository with the different available platforms and file sizes. Links to the repo, releases, specific cached version and direct downloads for each platform are present. 59 | 60 | ### /download 61 | 62 | Automatically detects the platform/OS of the visitor by parsing the user agent and then downloads the appropriate copy of your application. 63 | 64 | If the latest version of the application wasn't yet pulled from [GitHub Releases](https://help.github.com/articles/creating-releases/), it will return a message and the status code `404`. The same happens if the latest release doesn't contain a file for the detected platform. 65 | 66 | ### /download/:platform 67 | 68 | Accepts a platform (like "darwin" or "win32") to download the appropriate copy your app for. I generally suggest using either `process.platform` ([more](https://nodejs.org/api/process.html#process_process_platform)) or `os.platform()` ([more](https://nodejs.org/api/os.html#os_os_platform)) to retrieve this string. 69 | 70 | If the cache isn't filled yet or doesn't contain a download link for the specified platform, it will respond like `/`. 71 | 72 | ### /update/:platform/:version 73 | 74 | Checks if there is an update available by reading from the cache. 75 | 76 | If the latest version of the application wasn't yet pulled from [GitHub Releases](https://help.github.com/articles/creating-releases/), it will return the `204` status code. The same happens if the latest release doesn't contain a file for the specified platform. 77 | 78 | ### /update/win32/:version/RELEASES 79 | 80 | This endpoint was specifically crafted for the Windows platform (called "win32" [in Node.js](https://nodejs.org/api/process.html#process_process_platform)). 81 | 82 | Since the [Windows version](https://github.com/Squirrel/Squirrel.Windows) of Squirrel (the software that powers auto updates inside [Electron](https://www.electronjs.org)) requires access to a file named "RELEASES" when checking for updates, this endpoint will respond with a cached version of the file that contains a download link to a `.nupkg` file (the application update). 83 | 84 | ## Programmatic Usage 85 | 86 | You can add Hazel to an existing HTTP server, if you want. For example, this will allow you to implement custom analytics on certain paths. 87 | 88 | ```js 89 | const hazel = require('hazel-server') 90 | 91 | http.createServer((req, res) => { 92 | hazel(req, res) 93 | }) 94 | ``` 95 | 96 | ## Contributing 97 | 98 | 1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device 99 | 2. Move into the directory of your clone: `cd hazel` 100 | 3. Install [Vercel CLI](https://vercel.com/cli) and run the development server: `vercel dev` 101 | 102 | ## Credits 103 | 104 | Huge thanks to my ([@leo](https://github.com/leo)'s) friend [Andy](http://twitter.com/andybitz_), who suggested the name "Hazel" (since the auto updater software inside [Electron](https://www.electronjs.org) is called "Squirrel") and [Matheus](https://twitter.com/matheusfrndes) for collecting ideas with me. 105 | 106 | ## Author 107 | 108 | Leo Lamprecht ([@leo](https://x.com/leo)) - [Vercel](https://vercel.com) 109 | -------------------------------------------------------------------------------- /api/index.js: -------------------------------------------------------------------------------- 1 | const server = require('../lib/server'); 2 | 3 | module.exports = server 4 | -------------------------------------------------------------------------------- /lib/aliases.js: -------------------------------------------------------------------------------- 1 | const aliases = { 2 | darwin: ['mac', 'macos', 'osx'], 3 | exe: ['win32', 'windows', 'win'], 4 | deb: ['debian'], 5 | rpm: ['fedora'], 6 | AppImage: ['appimage'], 7 | dmg: ['dmg'] 8 | } 9 | 10 | for (const existingPlatform of Object.keys(aliases)) { 11 | const newPlatform = existingPlatform + '_arm64'; 12 | aliases[newPlatform] = aliases[existingPlatform].map(alias => `${alias}_arm64`); 13 | } 14 | 15 | module.exports = platform => { 16 | if (typeof aliases[platform] !== 'undefined') { 17 | return platform 18 | } 19 | 20 | for (const guess of Object.keys(aliases)) { 21 | const list = aliases[guess] 22 | 23 | if (list.includes(platform)) { 24 | return guess 25 | } 26 | } 27 | 28 | return false 29 | } 30 | -------------------------------------------------------------------------------- /lib/cache.js: -------------------------------------------------------------------------------- 1 | // Packages 2 | const fetch = require('node-fetch') 3 | const retry = require('async-retry') 4 | const convertStream = require('stream-to-string') 5 | const ms = require('ms') 6 | 7 | // Utilities 8 | const checkPlatform = require('./platform') 9 | 10 | module.exports = class Cache { 11 | constructor(config) { 12 | const { account, repository, token, url } = config 13 | this.config = config 14 | 15 | if (!account || !repository) { 16 | const error = new Error('Neither ACCOUNT, nor REPOSITORY are defined') 17 | error.code = 'missing_configuration_properties' 18 | throw error 19 | } 20 | 21 | if (token && !url) { 22 | const error = new Error( 23 | 'Neither VERCEL_URL, nor URL are defined, which are mandatory for private repo mode' 24 | ) 25 | error.code = 'missing_configuration_properties' 26 | throw error 27 | } 28 | 29 | this.latest = {} 30 | this.lastUpdate = null 31 | 32 | this.cacheReleaseList = this.cacheReleaseList.bind(this) 33 | this.refreshCache = this.refreshCache.bind(this) 34 | this.loadCache = this.loadCache.bind(this) 35 | this.isOutdated = this.isOutdated.bind(this) 36 | } 37 | 38 | async cacheReleaseList(url) { 39 | const { token } = this.config 40 | const headers = { Accept: 'application/vnd.github.preview' } 41 | 42 | if (token && typeof token === 'string' && token.length > 0) { 43 | headers.Authorization = `token ${token}` 44 | } 45 | 46 | const { status, body } = await retry( 47 | async () => { 48 | const response = await fetch(url, { headers }) 49 | 50 | if (response.status !== 200) { 51 | throw new Error( 52 | `Tried to cache RELEASES, but failed fetching ${url}, status ${status}` 53 | ) 54 | } 55 | 56 | return response 57 | }, 58 | { retries: 3 } 59 | ) 60 | 61 | let content = await convertStream(body) 62 | const matches = content.match(/[^ ]*\.nupkg/gim) 63 | 64 | if (matches.length === 0) { 65 | throw new Error( 66 | `Tried to cache RELEASES, but failed. RELEASES content doesn't contain nupkg` 67 | ) 68 | } 69 | 70 | for (let i = 0; i < matches.length; i += 1) { 71 | const nuPKG = url.replace('RELEASES', matches[i]) 72 | content = content.replace(matches[i], nuPKG) 73 | } 74 | return content 75 | } 76 | 77 | async refreshCache() { 78 | const { account, repository, pre, token } = this.config 79 | const repo = account + '/' + repository 80 | const url = `https://api.github.com/repos/${repo}/releases?per_page=100` 81 | const headers = { Accept: 'application/vnd.github.preview' } 82 | 83 | if (token && typeof token === 'string' && token.length > 0) { 84 | headers.Authorization = `token ${token}` 85 | } 86 | 87 | const response = await retry( 88 | async () => { 89 | const response = await fetch(url, { headers }) 90 | 91 | if (response.status !== 200) { 92 | throw new Error( 93 | `GitHub API responded with ${response.status} for url ${url}` 94 | ) 95 | } 96 | 97 | return response 98 | }, 99 | { retries: 3 } 100 | ) 101 | 102 | const data = await response.json() 103 | 104 | if (!Array.isArray(data) || data.length === 0) { 105 | return 106 | } 107 | 108 | const release = data.find(item => { 109 | const isPre = Boolean(pre) === Boolean(item.prerelease) 110 | return !item.draft && isPre 111 | }) 112 | 113 | if (!release || !release.assets || !Array.isArray(release.assets)) { 114 | return 115 | } 116 | 117 | const { tag_name } = release 118 | 119 | if (this.latest.version === tag_name) { 120 | console.log('Cached version is the same as latest') 121 | this.lastUpdate = Date.now() 122 | return 123 | } 124 | 125 | console.log(`Caching version ${tag_name}...`) 126 | 127 | this.latest.version = tag_name 128 | this.latest.notes = release.body 129 | this.latest.pub_date = release.published_at 130 | 131 | // Clear list of download links 132 | this.latest.platforms = {} 133 | 134 | for (const asset of release.assets) { 135 | const { name, browser_download_url, url, content_type, size } = asset 136 | 137 | if (name === 'RELEASES') { 138 | try { 139 | if (!this.latest.files) { 140 | this.latest.files = {} 141 | } 142 | this.latest.files.RELEASES = await this.cacheReleaseList( 143 | browser_download_url 144 | ) 145 | } catch (err) { 146 | console.error(err) 147 | } 148 | continue 149 | } 150 | 151 | const platform = checkPlatform(name) 152 | 153 | if (!platform) { 154 | continue 155 | } 156 | 157 | this.latest.platforms[platform] = { 158 | name, 159 | api_url: url, 160 | url: browser_download_url, 161 | content_type, 162 | size: Math.round(size / 1000000 * 10) / 10 163 | } 164 | } 165 | 166 | console.log(`Finished caching version ${tag_name}`) 167 | this.lastUpdate = Date.now() 168 | } 169 | 170 | isOutdated() { 171 | const { lastUpdate, config } = this 172 | const { interval = 15 } = config 173 | 174 | if (lastUpdate && Date.now() - lastUpdate > ms(`${interval}m`)) { 175 | return true 176 | } 177 | 178 | return false 179 | } 180 | 181 | // This is a method returning the cache 182 | // because the cache would otherwise be loaded 183 | // only once when the index file is parsed 184 | async loadCache() { 185 | const { latest, refreshCache, isOutdated, lastUpdate } = this 186 | 187 | if (!lastUpdate || isOutdated()) { 188 | await refreshCache() 189 | } 190 | 191 | return Object.assign({}, latest) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | // Packages 2 | const Router = require('router') 3 | const finalhandler = require('finalhandler') 4 | const Cache = require('./cache') 5 | 6 | module.exports = config => { 7 | const router = Router() 8 | let cache = null; 9 | 10 | try { 11 | cache = new Cache(config) 12 | } catch (err) { 13 | const { code, message } = err 14 | 15 | if (code) { 16 | return (req, res) => { 17 | res.statusCode = 400; 18 | 19 | res.end(JSON.stringify({ 20 | error: { 21 | code, 22 | message 23 | } 24 | })) 25 | } 26 | } 27 | 28 | throw err 29 | } 30 | 31 | const routes = require('./routes')({ cache, config }) 32 | 33 | // Define a route for every relevant path 34 | router.get('/', routes.overview) 35 | router.get('/download', routes.download) 36 | router.get('/download/:platform', routes.downloadPlatform) 37 | router.get('/update/:platform/:version', routes.update) 38 | router.get('/update/win32/:version/RELEASES', routes.releases) 39 | 40 | return (req, res) => { 41 | router(req, res, finalhandler(req, res)) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/platform.js: -------------------------------------------------------------------------------- 1 | // Native 2 | const { extname } = require('path') 3 | 4 | module.exports = fileName => { 5 | const extension = extname(fileName).slice(1) 6 | const arch = (fileName.includes('arm64') || fileName.includes('aarch64')) ? '_arm64' : '' 7 | 8 | if ( 9 | (fileName.includes('mac') || fileName.includes('darwin')) && 10 | extension === 'zip' 11 | ) { 12 | return 'darwin' + arch 13 | } 14 | 15 | const directCache = ['exe', 'dmg', 'rpm', 'deb', 'AppImage'] 16 | return directCache.includes(extension) ? (extension + arch) : false 17 | } 18 | -------------------------------------------------------------------------------- /lib/routes.js: -------------------------------------------------------------------------------- 1 | // Native 2 | const urlHelpers = require('url'); 3 | 4 | // Packages 5 | const { send } = require('micro') 6 | const { valid, compare } = require('semver') 7 | const { parse } = require('express-useragent') 8 | const fetch = require('node-fetch') 9 | const distanceInWordsToNow = require('date-fns/distance_in_words_to_now') 10 | 11 | // Utilities 12 | const checkAlias = require('./aliases') 13 | const prepareView = require('./view') 14 | 15 | module.exports = ({ cache, config }) => { 16 | const { loadCache } = cache 17 | const exports = {} 18 | const { token, url } = config 19 | const shouldProxyPrivateDownload = 20 | token && typeof token === 'string' && token.length > 0 21 | 22 | // Helpers 23 | const proxyPrivateDownload = (asset, req, res) => { 24 | const redirect = 'manual' 25 | const headers = { Accept: 'application/octet-stream' } 26 | const options = { headers, redirect } 27 | const { api_url: rawUrl } = asset 28 | const finalUrl = rawUrl.replace( 29 | 'https://api.github.com/', 30 | `https://${token}@api.github.com/` 31 | ) 32 | 33 | fetch(finalUrl, options).then(assetRes => { 34 | res.setHeader('Location', assetRes.headers.get('Location')) 35 | send(res, 302) 36 | }) 37 | } 38 | 39 | exports.download = async (req, res) => { 40 | const userAgent = parse(req.headers['user-agent']) 41 | const params = urlHelpers.parse(req.url, true).query 42 | const isUpdate = params && params.update 43 | 44 | let platform 45 | 46 | if (userAgent.isMac && isUpdate) { 47 | platform = 'darwin' 48 | } else if (userAgent.isMac && !isUpdate) { 49 | platform = 'dmg' 50 | } else if (userAgent.isWindows) { 51 | platform = 'exe' 52 | } 53 | 54 | // Get the latest version from the cache 55 | const { platforms } = await loadCache() 56 | 57 | if (!platform || !platforms || !platforms[platform]) { 58 | send(res, 404, 'No download available for your platform!') 59 | return 60 | } 61 | 62 | if (shouldProxyPrivateDownload) { 63 | proxyPrivateDownload(platforms[platform], req, res) 64 | return 65 | } 66 | 67 | res.writeHead(302, { 68 | Location: platforms[platform].url 69 | }) 70 | 71 | res.end() 72 | } 73 | 74 | exports.downloadPlatform = async (req, res) => { 75 | const params = urlHelpers.parse(req.url, true).query 76 | const isUpdate = params && params.update 77 | 78 | let { platform } = req.params 79 | 80 | if (platform === 'mac' && !isUpdate) { 81 | platform = 'dmg' 82 | } 83 | 84 | if (platform === 'mac_arm64' && !isUpdate) { 85 | platform = 'dmg_arm64' 86 | } 87 | 88 | // Get the latest version from the cache 89 | const latest = await loadCache() 90 | 91 | // Check platform for appropiate aliases 92 | platform = checkAlias(platform) 93 | 94 | if (!platform) { 95 | send(res, 500, 'The specified platform is not valid') 96 | return 97 | } 98 | 99 | if (!latest.platforms || !latest.platforms[platform]) { 100 | send(res, 404, 'No download available for your platform') 101 | return 102 | } 103 | 104 | if (token && typeof token === 'string' && token.length > 0) { 105 | proxyPrivateDownload(latest.platforms[platform], req, res) 106 | return 107 | } 108 | 109 | res.writeHead(302, { 110 | Location: latest.platforms[platform].url 111 | }) 112 | 113 | res.end() 114 | } 115 | 116 | exports.update = async (req, res) => { 117 | const { platform: platformName, version } = req.params 118 | 119 | if (!valid(version)) { 120 | send(res, 500, { 121 | error: 'version_invalid', 122 | message: 'The specified version is not SemVer-compatible' 123 | }) 124 | 125 | return 126 | } 127 | 128 | const platform = checkAlias(platformName) 129 | 130 | if (!platform) { 131 | send(res, 500, { 132 | error: 'invalid_platform', 133 | message: 'The specified platform is not valid' 134 | }) 135 | 136 | return 137 | } 138 | 139 | // Get the latest version from the cache 140 | const latest = await loadCache() 141 | 142 | if (!latest.platforms || !latest.platforms[platform]) { 143 | res.statusCode = 204 144 | res.end() 145 | 146 | return 147 | } 148 | 149 | // Previously, we were checking if the latest version is 150 | // greater than the one on the client. However, we 151 | // only need to compare if they're different (even if 152 | // lower) in order to trigger an update. 153 | 154 | // This allows developers to downgrade their users 155 | // to a lower version in the case that a major bug happens 156 | // that will take a long time to fix and release 157 | // a patch update. 158 | 159 | if (compare(latest.version, version) !== 0) { 160 | const { notes, pub_date } = latest 161 | 162 | send(res, 200, { 163 | name: latest.version, 164 | notes, 165 | pub_date, 166 | url: shouldProxyPrivateDownload 167 | ? `${url}/download/${platformName}?update=true` 168 | : latest.platforms[platform].url 169 | }) 170 | 171 | return 172 | } 173 | 174 | res.statusCode = 204 175 | res.end() 176 | } 177 | 178 | exports.releases = async (req, res) => { 179 | // Get the latest version from the cache 180 | const latest = await loadCache() 181 | 182 | if (!latest.files || !latest.files.RELEASES) { 183 | res.statusCode = 204 184 | res.end() 185 | 186 | return 187 | } 188 | 189 | const content = latest.files.RELEASES 190 | 191 | res.writeHead(200, { 192 | 'content-length': Buffer.byteLength(content, 'utf8'), 193 | 'content-type': 'application/octet-stream' 194 | }) 195 | 196 | res.end(content) 197 | } 198 | 199 | exports.overview = async (req, res) => { 200 | const latest = await loadCache() 201 | 202 | try { 203 | const render = await prepareView() 204 | 205 | const details = { 206 | account: config.account, 207 | repository: config.repository, 208 | date: distanceInWordsToNow(latest.pub_date, { addSuffix: true }), 209 | files: latest.platforms, 210 | version: latest.version, 211 | releaseNotes: `https://github.com/${config.account}/${ 212 | config.repository 213 | }/releases/tag/${latest.version}`, 214 | allReleases: `https://github.com/${config.account}/${ 215 | config.repository 216 | }/releases`, 217 | github: `https://github.com/${config.account}/${config.repository}` 218 | } 219 | 220 | send(res, 200, render(details)) 221 | } catch (err) { 222 | console.error(err) 223 | send(res, 500, 'Error reading overview file') 224 | } 225 | } 226 | 227 | return exports 228 | } 229 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | const hazel = require('./index') 2 | 3 | const { 4 | INTERVAL: interval, 5 | ACCOUNT: account, 6 | REPOSITORY: repository, 7 | PRE: pre, 8 | TOKEN: token, 9 | URL: PRIVATE_BASE_URL, 10 | VERCEL_URL 11 | } = process.env 12 | 13 | const url = VERCEL_URL || PRIVATE_BASE_URL 14 | 15 | module.exports = hazel({ 16 | interval, 17 | account, 18 | repository, 19 | pre, 20 | token, 21 | url 22 | }) 23 | -------------------------------------------------------------------------------- /lib/view.js: -------------------------------------------------------------------------------- 1 | // Native 2 | const path = require('path') 3 | const fs = require('fs') 4 | const { promisify } = require('util') 5 | 6 | // Packages 7 | const { compile } = require('handlebars') 8 | 9 | module.exports = async () => { 10 | const viewPath = path.normalize(path.join(__dirname, '/../views/index.hbs')) 11 | const viewContent = await promisify(fs.readFile)(viewPath, 'utf8') 12 | 13 | return compile(viewContent) 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hazel-server", 3 | "version": "5.1.1", 4 | "main": "lib/index.js", 5 | "description": "Lightweight update server for Electron apps", 6 | "scripts": { 7 | "dev": "npx vercel dev", 8 | "test": "xo && jest", 9 | "precommit": "lint-staged" 10 | }, 11 | "license": "MIT", 12 | "repository": "vercel/hazel", 13 | "xo": { 14 | "extends": [ 15 | "prettier" 16 | ], 17 | "rules": { 18 | "camelcase": 0, 19 | "new-cap": 0, 20 | "unicorn/no-process-exit": 0, 21 | "no-await-in-loop": 0, 22 | "unicorn/import-index": 0 23 | } 24 | }, 25 | "lint-staged": { 26 | "*.js": [ 27 | "yarn test && :", 28 | "prettier --single-quote --no-semi --write --no-editorconfig", 29 | "git add" 30 | ] 31 | }, 32 | "dependencies": { 33 | "async-retry": "1.2.3", 34 | "date-fns": "1.29.0", 35 | "express-useragent": "1.0.12", 36 | "fetch": "1.1.0", 37 | "finalhandler": "1.1.0", 38 | "handlebars": "4.0.11", 39 | "jest": "24.0.0", 40 | "micro": "9.3.3", 41 | "ms": "2.1.1", 42 | "node-fetch": "2.0.0", 43 | "router": "1.3.2", 44 | "semver": "5.5.0", 45 | "stream-to-string": "1.1.0", 46 | "test-listen": "1.1.0" 47 | }, 48 | "devDependencies": { 49 | "eslint-config-prettier": "2.9.0", 50 | "husky": "0.14.3", 51 | "lint-staged": "7.0.0", 52 | "prettier": "1.10.2", 53 | "xo": "0.20.3" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/aliases.test.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, expect */ 2 | const aliases = require('../lib/aliases') 3 | 4 | describe('Aliases', () => { 5 | it('Should return the correct platform', () => { 6 | const result = aliases('mac') 7 | expect(result).toBe('darwin') 8 | }) 9 | 10 | it('Should return the platform when the platform is provided', () => { 11 | const result = aliases('darwin') 12 | expect(result).toBe('darwin') 13 | }) 14 | 15 | it('Should return false if no platform is found', () => { 16 | const result = aliases('test') 17 | expect(result).toBe(false) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /test/cache.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-new */ 2 | /* global describe, it, expect */ 3 | const Cache = require('../lib/cache') 4 | 5 | describe('Cache', () => { 6 | it('should throw when account is not defined', () => { 7 | expect(() => { 8 | const config = { repository: 'hyper' } 9 | new Cache(config) 10 | }).toThrow(/ACCOUNT/) 11 | }) 12 | 13 | it('should throw when repository is not defined', () => { 14 | expect(() => { 15 | const config = { account: 'zeit' } 16 | new Cache(config) 17 | }).toThrow(/REPOSITORY/) 18 | }) 19 | 20 | it('should throw when token is defined and url is not', () => { 21 | expect(() => { 22 | const config = { account: 'zeit', repository: 'hyper', token: 'abc' } 23 | new Cache(config) 24 | }).toThrow(/URL/) 25 | }) 26 | 27 | it('should run without errors', () => { 28 | const config = { 29 | account: 'zeit', 30 | repository: 'hyper', 31 | token: process.env.TOKEN, 32 | url: process.env.URL 33 | } 34 | 35 | new Cache(config) 36 | }) 37 | 38 | it('should refresh the cache', async () => { 39 | const config = { 40 | account: 'zeit', 41 | repository: 'hyper', 42 | token: process.env.TOKEN, 43 | url: process.env.URL 44 | } 45 | 46 | const cache = new Cache(config) 47 | const storage = await cache.loadCache() 48 | 49 | expect(typeof storage.version).toBe('string') 50 | expect(typeof storage.platforms).toBe('object') 51 | }) 52 | 53 | it('should set platforms correctly', async () => { 54 | const config = { 55 | account: 'zeit', 56 | repository: 'hyper', 57 | token: process.env.TOKEN, 58 | url: process.env.URL 59 | } 60 | 61 | const cache = new Cache(config) 62 | const storage = await cache.loadCache() 63 | 64 | console.log(storage.platforms.darwin) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /test/platform.test.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, expect */ 2 | const platform = require('../lib/platform') 3 | 4 | describe('Platform', () => { 5 | it('Should parse mac', () => { 6 | const result = platform('hyper-2.1.1-mac.zip') 7 | expect(result).toBe('darwin') 8 | }) 9 | 10 | it('Should parse other platforms', () => { 11 | const result = platform('hyper_2.1.1_amd64.deb') 12 | expect(result).toBe('deb') 13 | }) 14 | 15 | it('Should parse dmg', () => { 16 | const result = platform('hyper-2.1.1.dmg') 17 | expect(result).toBe('dmg') 18 | }) 19 | 20 | it('Should return false for unknown files', () => { 21 | const result = platform('hi.txt') 22 | expect(result).toBe(false) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /test/server.test.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, afterEach */ 2 | const micro = require('micro') 3 | const listen = require('test-listen') 4 | 5 | const initialEnv = Object.assign({}, process.env) 6 | 7 | afterEach(() => { 8 | process.env = initialEnv 9 | }) 10 | 11 | describe('Server', () => { 12 | it('Should start without errors', async () => { 13 | process.env = { 14 | ACCOUNT: 'zeit', 15 | REPOSITORY: 'hyper' 16 | } 17 | 18 | const run = require('../lib/server') 19 | const server = micro(run) 20 | 21 | await listen(server) 22 | server.close() 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { 4 | "source": "/(.*)", 5 | "destination": "/api" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /views/index.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{account}}/{{repository}} 8 | 9 | 124 | 125 | 126 | 127 |
128 |
129 |
130 |
{{account}}/{{repository}}
131 |
{{date}}
132 |
133 | 134 |
135 | {{#each files}} 136 |
137 | 138 |
{{this.size}} MB
139 |
140 | {{/each}} 141 |
142 | 143 | 149 |
150 |
151 | 152 | 153 | --------------------------------------------------------------------------------