Skip to content

Instantly share code, notes, and snippets.

@Pagebakers
Last active May 26, 2021 15:29
Show Gist options
  • Save Pagebakers/addc7aab4f98082d346cc50504cae4bb to your computer and use it in GitHub Desktop.
Save Pagebakers/addc7aab4f98082d346cc50504cae4bb to your computer and use it in GitHub Desktop.
A CLI script to test and deploy functions on Google Cloud Functions
#!/usr/bin/env node
/*
This CLI script makes it easy to manage, test and deploy
multiple functions/services in Typescript
Requirements
You will need the Google Cloud SDK installed and
authentication configured to support deployments.
Configuration
You can pass the project, runtime and region as cli args,
or use these environment variables in your .env file.
SERVICES_PROJECT=test-project-123
SERVICES_RUNTIME=nodejs12
SERVICES_REGION=europe-west3
File structure
Default root directory:
./src/services
Each service has it's own folder and can have multiple functions.
./src/services/[service-name]/index.ts
Running services locally
node services.js run <services> --watch
This will transpile your services to es6 and run the output with
the functions framework.
You can run multiple services by passing a comma separated list.
eg: 'node services.js run email,webhooks'
Once running you can access the services through the http server
http://localhost:8080/[service]-[handler]
eg: http://localhost:8080/email-welcome
Deploying services
node services.js deploy <service> --entry-point handler --http --unauthenticated
This will transpile your service to es6 and use the Cloud SDK
to deploy the entry point to the Cloud Functions platform
http functions will be availabble at
https://<region>-<project>.cloudfunctions.net/[service]-[env]-[handler]
eg: https://europe-eu3-my-project-123.cloudfunctions.net/email-production-welcome
*/
require('dotenv').config()
const path = require('path')
const fs = require('fs')
const yargs = require("yargs");
const nodeExternals = require('webpack-node-externals');
const webpack = require('webpack');
const { spawn } = require("child_process");
yargs
.command('run <services>', 'Run one or more services locally', (yargs) => {
return yargs.positional('services', {
describe: 'A list of services'
})
.option('p', {alias: 'port', describe: 'Listen to this port', type: 'string'})
.option('w', {alias: 'watch', describe: 'Watch for changes', type: 'boolean'})
.option('r', {alias: 'root', describe: 'Root directory', type: 'string'})
}, async (argv) => {
try {
const services = argv.services.split(',')
const options = {
watch: argv.w,
rootDir: argv.r || './src/services',
mode: argv.m || process.env.NODE_ENV || 'development',
port: argv.p || 8080
}
await buildServices(services, options)
runLocally(options)
} catch (e) {
console.error('Whoops, that went sideways.', e)
}
})
.command('deploy <service>', 'Deploy a service to Google Cloud Functions', (yargs) => {
return yargs.positional('service', {
describe: 'The service to deploy'
})
.option('e', {alias: 'entry-point', describe: 'The function entry point', type: 'string'})
.option('p', {alias: 'project', describe: 'Google Cloud project', type: 'string'})
.option('r', {alias: 'region', describe: 'Google Cloud region', type: 'boolean'})
.option('a', {alias: 'allow-unauthenticated', describe: 'Allow unauthenticated', type: 'boolean'})
.option('h', {alias: 'http', describe: 'Trigger HTTP', type: 'boolean'})
.option('r', {alias: 'root', describe: 'Root directory', type: 'string'})
}, async (argv) => {
try {
const service = argv.service
const options = {
entry: argv.e || 'handler',
mode: argv.m || process.env.NODE_ENV || 'development',
runtime: argv.t || process.env.SERVICES_RUNTIME || 'nodejs14',
project: argv.p || process.env.SERVICES_PROJECT,
region: argv.p || process.env.SERVICES_REGION,
allowUnauthenticated: argv.a,
http: argv.h,
rootDir: argv.r || './src/services'
}
await buildServices([service], options)
await deployService(service, options)
} catch (e) {
console.error('Whoops, that went sideways.', e)
}
})
.argv;
function getWebpackConfig({
service,
mode,
rootDir,
webpackConfig
}) {
return {
mode,
entry: path.join(__dirname, rootDir, service, 'index.ts'),
output: {
path: path.resolve(__dirname, rootDir, '.build', service),
filename: 'index.js',
libraryTarget: 'this'
},
target: 'node',
module: {
rules: [
{
test: /\.ts?$/,
loader: 'ts-loader',
options: {
transpileOnly: true
}
}
]
},
resolve: {
extensions: [ '.ts', '.js' ]
},
externals: [nodeExternals()],
...webpackConfig
}
}
async function buildServices(services = [], {
mode,
rootDir,
webpackConfig,
watch
}) {
return new Promise((resolve, reject) => {
try {
console.log(`Building, ${services.join(', ')}`)
const compiler = webpack(services.map((service) => getWebpackConfig({
mode,
service,
rootDir,
webpackConfig
})));
compiler.hooks.done.tap('CloudFunctions', () => {
const fullPath = path.join(__dirname, rootDir, '.build', 'index.js')
fs.writeFileSync(fullPath, DEV_TEMPLATE);
})
if (watch) {
compiler.watch({}, (err, stats) => {
console.log('Watch changes')
if (err) {
console.error(err)
}
})
resolve()
} else {
compiler.run((err, stats) => {
if (err) {
console.error(err.stack || err);
if (err.details) {
console.error(err.details);
}
return reject(err);
}
const info = stats.toJson();
if (stats.hasErrors()) {
console.error(info.errors);
return reject(info.errors)
}
if (stats.hasWarnings()) {
console.warn(info.warnings);
}
resolve()
})
}
} catch (e) {
reject(e)
}
})
}
function deployService(service, {
mode,
entry,
runtime,
http,
allowUnauthenticated,
project,
region,
rootDir
}) {
const source = path.join(rootDir, '.build', service)
const args = [
'functions',
'deploy',
`${service}-${mode}-${entry}`,
`--source=${source}`,
`--runtime=${runtime}`,
`--project=${project}`,
`--entry-point=${entry}`,
`--region=${region}`
]
if (http) {
args.push('--trigger-http')
}
if (allowUnauthenticated) {
args.push('--allow-unauthenticated')
}
const ls = spawn('gcloud', args, {
stdio: ['pipe', 'pipe', 'pipe']
})
ls.stderr.on("data", data => {
const out = data.toString()
if (out === '.') {
return process.stdout.write(".")
} else if (out.match(/y\/N/)) {
ls.stdin.write("y\n");
}
process.stdout.write(out)
});
ls.on('error', (error) => {
console.log(`error: ${error.message}`);
});
}
function runLocally({
port,
watch,
rootDir
}) {
const framework = 'node_modules/@google-cloud/functions-framework/build/src/index.js'
const args = [
`--source=${path.join(__dirname, rootDir, '.build/index.js')}`,
'--target=dev',
`--port=${port}`
]
let ls
if (watch) {
const nodemonArgs = [
'--watch', path.join(rootDir, '.build/**/*.js'),
'--exec',
framework,
'--'
].concat(args)
ls = spawn(`nodemon`, nodemonArgs, {
stdio: ['pipe', 'pipe', 'pipe', 'ipc']
})
} else {
ls = spawn(`node`, [framework].concat(args))
}
ls.on('message', function (event) {
if (event.type === 'start') {
console.log('nodemon started');
} else if (event.type === 'crash') {
console.log('script crashed for some reason');
}
});
ls.stderr.on("data", data => {
console.log(`stderr: ${data}`);
});
ls.on('error', (error) => {
console.log(`error: ${error.message}`);
});
ls.on("close", code => {
console.log(`child process exited with code ${code}`);
});
console.log(`Your services can now be accessed via http://localhost:${port}/[service]-[target]`)
}
// This allows you to test all your functions locally on a single port
// @todo add eventType http/event
const DEV_TEMPLATE = `
function dev(req, res) {
const handler = getHandler(req.path)
if (handler) {
return handler(req, res)
}
return res.send('No function found')
}
function getHandler(path) {
const [service, entry] = path.replace('/', '').split('-')
const functions = require(\`./\${service}\`)
return functions[entry]
}
exports.dev = dev
`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment