├── .gitignore ├── .prettierrc ├── .eslintrc ├── launcher.js ├── tests ├── test.js └── shadow-cljs.edn ├── LICENSE ├── package.json ├── parse-config-file.js ├── README.md └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 100 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb-base", "prettier"], 3 | "plugins": ["prettier"], 4 | "rules": { 5 | "prettier/prettier": ["error"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /launcher.js: -------------------------------------------------------------------------------- 1 | const { Server } = require('http') 2 | const { Bridge } = require('./bridge.js') 3 | 4 | const bridge = new Bridge() 5 | bridge.port = 3000 6 | let listener 7 | 8 | try { 9 | if (!process.env.NODE_ENV) { 10 | process.env.NODE_ENV = 'production' 11 | } 12 | 13 | // PLACEHOLDER 14 | } catch (error) { 15 | console.error(error) 16 | bridge.userError = error 17 | } 18 | 19 | const server = new Server(listener) 20 | server.listen(bridge.port) 21 | 22 | exports.launcher = bridge.launcher 23 | -------------------------------------------------------------------------------- /tests/test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | /* eslint-disable no-console */ 3 | 4 | const path = require('path'); 5 | const parseConfigFile = require('../parse-config-file'); 6 | 7 | test('read shadow-cljs config file', async () => { 8 | const config = await parseConfigFile(path.resolve(__dirname, './shadow-cljs.edn')); 9 | 10 | expect(config.length).toBe(2); 11 | 12 | expect(config[0].target).toBe('node-library'); 13 | expect(config[0].name).toBe('haikus'); 14 | expect(config[1].target).toBe('browser'); 15 | }); 16 | -------------------------------------------------------------------------------- /tests/shadow-cljs.edn: -------------------------------------------------------------------------------- 1 | {:source-paths ["src"] 2 | 3 | :dependencies [[reagent "0.8.1"] 4 | [cider/cider-nrepl "0.17.0"] 5 | [binaryage/devtools "0.9.10"] 6 | [cljs-http "0.1.45"]] 7 | 8 | :builds {:haikus {:target :node-library 9 | :output-to "api/haikus/index.js" 10 | :output-dir "api/haikus" 11 | :exports-var jntn.api.haikus/handler} 12 | :app {:target :browser 13 | :output-dir "public/js" 14 | :asset-path "js" 15 | :module-hash-names true 16 | :modules {:main {:entries [jntn.app.core]}} 17 | :devtools {:http-root "public" 18 | :proxy-url #shadow/env "PROXY_URL" 19 | :http-port 8000}}}} 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Jonatan Granqvist 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jntn/now-shadow-cljs", 3 | "version": "1.0.2", 4 | "license": "MIT", 5 | "description": "A Vercel now builder for ClojureScript projects using shadow-cljs", 6 | "keywords": [ 7 | "now", 8 | "clojure", 9 | "shadow-cljs", 10 | "vercel", 11 | "clojurescript" 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/jntn/now-shadow-cljs.git" 16 | }, 17 | "files": [ 18 | "index.js", 19 | "parse-config-file.js", 20 | "launcher.js" 21 | ], 22 | "publishConfig": { 23 | "access": "public" 24 | }, 25 | "scripts": { 26 | "test": "jest" 27 | }, 28 | "dependencies": { 29 | "@now/node-bridge": "^0.1.11-canary.0", 30 | "execa": "^1.0.0", 31 | "fs-extra": "7.0.1", 32 | "jsedn": "^0.4.1", 33 | "node-fetch": "^2.3.0", 34 | "tar": "^4.4.8" 35 | }, 36 | "devDependencies": { 37 | "eslint": "^5.14.1", 38 | "eslint-config-airbnb-base": "^13.1.0", 39 | "eslint-config-prettier": "^4.0.0", 40 | "eslint-plugin-import": "^2.16.0", 41 | "eslint-plugin-prettier": "^3.0.1", 42 | "jest": "^24.1.0", 43 | "prettier": "^1.13.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /parse-config-file.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | const fs = require('fs-extra'); 4 | const edn = require('jsedn'); 5 | 6 | const supportedBuildTypes = [':browser', ':node-library']; 7 | 8 | // Handle/ignore shadow/env tagged value 9 | edn.setTagAction(new edn.Tag('shadow', 'env'), (obj) => { 10 | return obj; 11 | }); 12 | 13 | function keywordToString(kw) { 14 | return kw && kw.replace(':', ''); 15 | } 16 | 17 | async function parseAndFilterShadowCljsBuilds(input) { 18 | console.log('Reading shadow-cljs config...'); 19 | const entrypointFile = await fs.readFile(input, 'utf8'); 20 | 21 | // Parse edn to js 22 | const shadowCljsConfig = edn.toJS(edn.parse(entrypointFile)); 23 | 24 | // Filter builds that are supported by this builder 25 | const supportedBuildConfigs = Object.entries(shadowCljsConfig[':builds']).filter(([, config]) => 26 | supportedBuildTypes.includes(config[':target']) 27 | ); 28 | 29 | return supportedBuildConfigs.map(([name, config]) => ({ 30 | name: keywordToString(name), 31 | target: keywordToString(config[':target']), 32 | assetPath: config[':asset-path'], 33 | outputDir: config[':output-dir'], 34 | outputTo: config[':output-to'], 35 | })); 36 | } 37 | 38 | module.exports = parseAndFilterShadowCljsBuilds; 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # now-shadow-cljs 2 | A ▲ Vercel Now 2.0 builder for ClojureScript projects using shadow-clj. The builder will build your shadow-cljs targets and deploy them to ▲ Vercel Now. 3 | 4 | ## Usage 5 | 6 | 1. Create a shadow-cljs project. This builder supports building `:browser` and `:node-library` targets. If the target is `:browser` it will be deployed as a static page and if it is `:node-library` it will be deployed as a lambda function. 7 | 8 | 2. Create a `now.json` file that uses `@jntn/now-shadow-cljs` using version 2 (This builder does not currently work with the latest version 3). **Note:** If you do not specify a `routes` section, the build output will be available under the specified output folder. For `:node-library` it is the `:output-to` path and for `:browser` it is the `:output-dir` path. 9 | 10 | For an example using both browser and node builds, see [github.com/jntn/haiku](https://github.com/jntn/haiku) 11 | 12 | ### Example `now.json` 13 | ``` json 14 | { 15 | "version": 2, 16 | "name": "haiku", 17 | "builds": [ 18 | { 19 | "src": "shadow-cljs.edn", 20 | "use": "@jntn/now-shadow-cljs" 21 | } 22 | ], 23 | "routes": [ 24 | { 25 | "src": "/(.*)", 26 | "dest": "/public/$1" 27 | }, 28 | { 29 | "src": "/api/(.*)", 30 | "dest": "/api/$1" 31 | } 32 | ] 33 | } 34 | ``` 35 | 36 | ### Example `shadow-cljs.edn` 37 | ``` clojure 38 | {:builds {:haikus {:target :node-library 39 | ;; Will be available at "/api/haikus" 40 | :output-to "api/haikus/index.js" 41 | :exports-var jntn.api.haikus/handler} 42 | :app {:target :browser 43 | ;; Will by default be available at "/public" but 44 | ;; using the now.json above it is reached at "/" 45 | :output-dir "public/js" 46 | :asset-path "js" 47 | :modules {:main {:entries [jntn.app.core]}} 48 | :devtools {:http-root "public" 49 | :proxy-url "http://localhost:3000" 50 | :http-port 8000}}}} 51 | ``` 52 | 53 | 54 | ## Caveats 55 | * Right now the only supported build targets are `:browser` and `:node-library`. 56 | * The builder does not currently work with `:deps true` in `shadow-cljs.edn`. 57 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | /* eslint-disable no-param-reassign */ 3 | /* eslint-disable no-console */ 4 | 5 | const { createLambda } = require('@now/build-utils/lambda.js'); 6 | const download = require('@now/build-utils/fs/download.js'); 7 | const FileBlob = require('@now/build-utils/file-blob.js'); 8 | const FileFsRef = require('@now/build-utils/file-fs-ref.js'); 9 | const glob = require('@now/build-utils/fs/glob.js'); 10 | const { runNpmInstall } = require('@now/build-utils/fs/run-user-scripts.js'); 11 | const nodeBridge = require('@now/node-bridge'); 12 | 13 | const fs = require('fs-extra'); 14 | const execa = require('execa'); 15 | const path = require('path'); 16 | const tar = require('tar'); 17 | const fetch = require('node-fetch'); 18 | 19 | const parseConfigFile = require('./parse-config-file'); 20 | 21 | const javaVersion = '8.242.07.1'; 22 | const javaUrl = `https://corretto.aws/downloads/resources/${javaVersion}/amazon-corretto-${javaVersion}-linux-x64.tar.gz`; 23 | 24 | async function installJava() { 25 | console.log('Downloading java...'); 26 | const res = await fetch(javaUrl); 27 | 28 | if (!res.ok) { 29 | throw new Error(`Failed to download: ${javaUrl}`); 30 | } 31 | 32 | const { HOME } = process.env; 33 | return new Promise((resolve, reject) => { 34 | res.body 35 | .on('error', reject) 36 | .pipe(tar.extract({ gzip: true, cwd: HOME })) 37 | .on('finish', () => resolve()); 38 | }); 39 | } 40 | 41 | async function installDependencies(files, workPath) { 42 | const hasPkgJSON = Boolean(files['package.json']); 43 | if (hasPkgJSON) { 44 | console.log('Installing dependencies...'); 45 | await runNpmInstall(workPath, ['--prefer-offline']); 46 | } else { 47 | throw new Error('Missing package.json'); 48 | } 49 | } 50 | 51 | async function downloadFiles(files, entrypoint, workPath) { 52 | console.log('Downloading files...'); 53 | const downloadedFiles = await download(files, workPath); 54 | const entryPath = downloadedFiles[entrypoint].fsPath; 55 | 56 | return { files: downloadedFiles, entryPath }; 57 | } 58 | 59 | async function createLambdaForNode(buildConfig, lambdas, workPath) { 60 | console.log(`Creating lambda for ${buildConfig.name} (${buildConfig.target})`); 61 | 62 | const launcherPath = path.join(__dirname, 'launcher.js'); 63 | let launcherData = await fs.readFile(launcherPath, 'utf8'); 64 | 65 | launcherData = launcherData.replace( 66 | '// PLACEHOLDER', 67 | [ 68 | `listener = require('./index.js');`, 69 | 'if (listener.default) listener = listener.default;' 70 | ].join(' ') 71 | ); 72 | 73 | const preparedFiles = { 74 | 'launcher.js': new FileBlob({ data: launcherData }), 75 | 'bridge.js': new FileFsRef({ fsPath: nodeBridge }), 76 | 'index.js': new FileFsRef({ 77 | fsPath: require.resolve(path.join(workPath, buildConfig.outputTo)) 78 | }) 79 | }; 80 | 81 | const lambda = await createLambda({ 82 | files: { ...preparedFiles }, 83 | handler: 'launcher.launcher', 84 | runtime: 'nodejs12.x' 85 | }); 86 | 87 | lambdas[buildConfig.outputTo] = lambda; 88 | } 89 | 90 | async function createLambdaForStatic(buildConfig, lambdas, workPath) { 91 | console.log(`Creating lambda for ${buildConfig.name} (${buildConfig.target})`); 92 | 93 | // Try to compute folder to serve. 94 | const outputPath = buildConfig.outputDir.replace(buildConfig.assetPath, ''); 95 | 96 | const files = await glob(path.join(outputPath, '**'), workPath); 97 | 98 | Object.assign(lambdas, files); 99 | } 100 | 101 | const lambdaBuilders = { 102 | browser: createLambdaForStatic, 103 | 'node-library': createLambdaForNode 104 | }; 105 | 106 | exports.build = async ({ files, entrypoint, workPath } = {}) => { 107 | const { HOME, PATH } = process.env; 108 | 109 | const { files: downloadedFiles } = await downloadFiles(files, entrypoint, workPath); 110 | 111 | const { stdout } = await execa('ls', ['-a'], { 112 | cwd: workPath, 113 | stdio: 'inherit' 114 | }); 115 | 116 | console.log(stdout); 117 | 118 | await installJava(); 119 | await installDependencies(downloadedFiles, workPath); 120 | 121 | const input = downloadedFiles[entrypoint].fsPath; 122 | const buildConfigs = await parseConfigFile(input); 123 | 124 | try { 125 | await execa('npx', ['shadow-cljs', 'release', ...buildConfigs.map(b => b.name)], { 126 | env: { 127 | JAVA_HOME: `${HOME}/amazon-corretto-${javaVersion}-linux-x64`, 128 | PATH: `${PATH}:${HOME}/amazon-corretto-${javaVersion}-linux-x64/bin`, 129 | M2: `${workPath}.m2` 130 | }, 131 | cwd: workPath, 132 | stdio: 'inherit' 133 | }); 134 | } catch (err) { 135 | console.error('Failed to `npx shadow-cljs release ...`'); 136 | throw err; 137 | } 138 | 139 | const lambdas = {}; 140 | 141 | await Promise.all( 142 | buildConfigs.map(async buildConfig => 143 | lambdaBuilders[buildConfig.target](buildConfig, lambdas, workPath) 144 | ) 145 | ); 146 | 147 | return lambdas; 148 | }; 149 | 150 | exports.prepareCache = async ({ cachePath, workPath }) => { 151 | console.log('Preparing cache...'); 152 | ['.m2', '.shadow-cljs', 'node_modules'].forEach(folder => { 153 | const p = path.join(workPath, folder); 154 | const cp = path.join(cachePath, folder); 155 | 156 | if (fs.existsSync(p)) { 157 | console.log(`Caching ${folder} folder`); 158 | fs.removeSync(cp); 159 | fs.renameSync(p, cp); 160 | } 161 | }); 162 | 163 | return { 164 | ...(await glob('.m2/**', cachePath)), 165 | ...(await glob('.shadow-cljs/**', cachePath)), 166 | ...(await glob('node_modules/**', cachePath)) 167 | }; 168 | }; 169 | --------------------------------------------------------------------------------