diff --git a/packages/serverless-runtime-types/package.json b/packages/serverless-runtime-types/package.json index 0eae7047..fb453de2 100644 --- a/packages/serverless-runtime-types/package.json +++ b/packages/serverless-runtime-types/package.json @@ -26,7 +26,7 @@ "url": "https://github.com/twilio-labs/serverless-toolkit/issues" }, "dependencies": { - "twilio": "^3.33.0" + "twilio": "^3.60.0" }, "devDependencies": { "@types/express": "^4.17.11", diff --git a/packages/twilio-run/README.md b/packages/twilio-run/README.md index 67b476d2..b062e650 100644 --- a/packages/twilio-run/README.md +++ b/packages/twilio-run/README.md @@ -152,10 +152,32 @@ twilio-run --inspect # Exposes the Twilio functions via ngrok to share them twilio-run --ngrok -# Exposes the Twilio functions via ngrok using a custom subdomain (requires a paid-for ngrok account) +# Uses a custom project ngrok config and named tunnel to expose the functions via ngrok +twilio-run --ngrok --ngrok-config=./ngrok.yml --ngrok-name=example +``` + +#### ngrok + +`twilio-run` lets you open a tunnel using [ngrok](https://ngrok.com/) (see the examples above). By default, just setting the `--ngrok` flag will use a randomly generated subdomain. If you have a paid for ngrok account, you can customise this experience. + +##### Custom ngrok subdomain + +Run the following command: + +``` twilio-run --ngrok=subdomain ``` +This will start the `twilio-run` server and also tunnel to it over ngrok via the domain `subdomain.ngrok.io`. + +##### Custom ngrok config + +You can create an [ngrok config file](https://ngrok.com/docs#config-location) which allows you to name tunnels and include other settings for those named tunnels, such as the subdomain, auth, or host headers. + +You can choose to run a named tunnel with `twilio-run` by passing the `--ngrok-name` flag. This will find the named tunnel in your default config, over-ride the `proto` to http and the `addr` to the port your Twilio Functions are running on, otherwise keeping the rest of the settings. + +You can also choose to use a different config file by setting the `--ngrok-config` flag. + ### `twilio-run deploy` Deploys your project to Twilio. It will read dependencies automatically from your `package.json`'s `dependencies` field and install them. It will also upload and set the variables that are specified in your `.env` file. You can point it against a different `.env` file via command-line flags. diff --git a/packages/twilio-run/package.json b/packages/twilio-run/package.json index b37afe33..76c3e933 100644 --- a/packages/twilio-run/package.json +++ b/packages/twilio-run/package.json @@ -69,14 +69,15 @@ "serialize-error": "^7.0.1", "terminal-link": "^1.3.0", "title": "^3.4.1", - "twilio": "^3.43.1", + "twilio": "^3.60.0", "type-fest": "^0.15.1", "window-size": "^1.1.1", "wrap-ansi": "^5.1.0", + "yaml": "^1.10.0", "yargs": "^13.2.2" }, "optionalDependencies": { - "ngrok": "^3.3.0" + "ngrok": "^4.0.1" }, "devDependencies": { "@types/cheerio": "^0.22.12", diff --git a/packages/twilio-run/src/commands/start.ts b/packages/twilio-run/src/commands/start.ts index eead014d..da3a8839 100644 --- a/packages/twilio-run/src/commands/start.ts +++ b/packages/twilio-run/src/commands/start.ts @@ -159,6 +159,14 @@ export const cliInfo: CliInfo = { describe: 'Uses ngrok to create a public url. Pass a string to set the subdomain (requires a paid-for ngrok account).', }, + 'ngrok-config': { + type: 'string', + describe: 'Path to custom ngrok config for project specific config.', + }, + 'ngrok-name': { + type: 'string', + describe: 'Name of ngrok tunnel config.', + }, logs: { type: 'boolean', default: true, diff --git a/packages/twilio-run/src/config/start.ts b/packages/twilio-run/src/config/start.ts index e3c96331..ac841e91 100644 --- a/packages/twilio-run/src/config/start.ts +++ b/packages/twilio-run/src/config/start.ts @@ -1,7 +1,10 @@ import { EnvironmentVariables } from '@twilio-labs/serverless-api'; import dotenv from 'dotenv'; -import { readFileSync } from 'fs'; -import path, { resolve } from 'path'; +import { readFile } from 'fs'; +import { promisify } from 'util'; +const readFilePromise = promisify(readFile); +import path, { resolve, join } from 'path'; +import { homedir } from 'os'; import { Arguments } from 'yargs'; import { ExternalCliOptions, SharedFlags } from '../commands/shared'; import { CliInfo } from '../commands/types'; @@ -10,14 +13,11 @@ import { fileExists } from '../utils/fs'; import { getDebugFunction, logger } from '../utils/logger'; import { readSpecializedConfig } from './global'; import { mergeFlagsAndConfig } from './utils/mergeFlagsAndConfig'; +import { Ngrok } from 'ngrok'; +import { parse } from 'yaml'; const debug = getDebugFunction('twilio-run:cli:config'); -type NgrokConfig = { - addr: string | number; - subdomain?: string; -}; - type InspectInfo = { hostPort: string; break: boolean; @@ -47,6 +47,8 @@ export type StartCliFlags = Arguments< env?: string; port: string; ngrok?: string | boolean; + ngrokConfig?: string; + ngrokName?: string; logs: boolean; detailedLogs: boolean; live: boolean; @@ -67,12 +69,73 @@ export async function getUrl(cli: StartCliFlags, port: string | number) { let url = `http://localhost:${port}`; if (typeof cli.ngrok !== 'undefined') { debug('Starting ngrok tunnel'); - const ngrokConfig: NgrokConfig = { addr: port }; + // Setup default ngrok config, setting the protocol and the port number to + // forward to. + const defaultConfig: Ngrok.Options = { addr: port, proto: 'http' }; + let tunnelConfig = defaultConfig; + let ngrokConfig; + if (typeof cli.ngrokConfig === 'string') { + // If we set a config path then try to load that config. If the config + // fails to load then we'll try to load the default config instead. + const configPath = join(cli.cwd || process.cwd(), cli.ngrokConfig); + try { + ngrokConfig = parse(await readFilePromise(configPath, 'utf-8')); + } catch (err) { + logger.warn(`Could not find ngrok config file at ${configPath}`); + } + } + if (!ngrokConfig) { + // Try to load default config. If there is no default config file, set + // `ngrokConfig` to be an empty object. + const configPath = join(homedir(), '.ngrok2', 'ngrok.yml'); + try { + ngrokConfig = parse(await readFilePromise(configPath, 'utf-8')); + } catch (err) { + ngrokConfig = {}; + } + } + if ( + typeof cli.ngrokName === 'string' && + typeof ngrokConfig.tunnels === 'object' + ) { + // If we've asked for a named ngrok tunnel and there are available tunnels + // in the config, then set the `tunnelConfig` to the options from the + // config, overriding the addr and proto to the defaults. + tunnelConfig = { ...ngrokConfig.tunnels[cli.ngrokName], ...tunnelConfig }; + if (!tunnelConfig) { + // If the config does not include the named tunnel, then set it back to + // the default options. + logger.warn( + `Could not find config for named tunnel "${cli.ngrokName}". Falling back to other options.` + ); + tunnelConfig = defaultConfig; + } + } + if (typeof ngrokConfig.authtoken === 'string') { + // If there is an authtoken in the config, add it to the tunnel config. + tunnelConfig.authToken = ngrokConfig.authtoken; + } if (typeof cli.ngrok === 'string' && cli.ngrok.length > 0) { - ngrokConfig.subdomain = cli.ngrok; + // If we've asked for a custom subdomain, override the tunnel config with + // it. + tunnelConfig.subdomain = cli.ngrok; + } + const ngrok = require('ngrok'); + try { + // Try to open the ngrok tunnel. + url = await ngrok.connect(tunnelConfig); + } catch (error) { + // If it fails, it is likely to be because the tunnel config we pass is + // not allowed (e.g. using a custom subdomain without an authtoken). The + // error message from ngrok itself should describe the issue. + logger.warn(error.message); + if ( + typeof error.details !== 'undefined' && + typeof error.details.err !== 'undefined' + ) { + logger.warn(error.details.err); + } } - - url = await require('ngrok').connect(ngrokConfig); debug('ngrok tunnel URL: %s', url); } @@ -106,7 +169,7 @@ export async function getEnvironment( if (await fileExists(fullEnvPath)) { try { debug(`Read .env file at "%s"`, fullEnvPath); - const envContent = readFileSync(fullEnvPath, 'utf8'); + const envContent = await readFilePromise(fullEnvPath, 'utf8'); const envValues = dotenv.parse(envContent); for (const [key, val] of Object.entries(envValues)) { env[key] = val;