diff --git a/.editorconfig b/.editorconfig index 2ad4dc4b7..7de5479ef 100644 --- a/.editorconfig +++ b/.editorconfig @@ -19,6 +19,11 @@ indent_style = space [*.py] indent_size = 4 +[*.go] +indent_style = tab +indent_size = 4 +tab_width = 4 + [*.asm] indent_size = 8 diff --git a/packages/now-cli/test/dev/fixtures/go/.gitignore b/packages/now-cli/test/dev/fixtures/go/.gitignore new file mode 100644 index 000000000..58d8196f8 --- /dev/null +++ b/packages/now-cli/test/dev/fixtures/go/.gitignore @@ -0,0 +1 @@ +.vercel \ No newline at end of file diff --git a/packages/now-cli/test/dev/fixtures/go/api/[segment].go b/packages/now-cli/test/dev/fixtures/go/api/[segment].go new file mode 100644 index 000000000..c8d7b1920 --- /dev/null +++ b/packages/now-cli/test/dev/fixtures/go/api/[segment].go @@ -0,0 +1,10 @@ +package handler + +import ( + "fmt" + "net/http" +) + +func Handler(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Req Path: %s", r.URL.Path) +} diff --git a/packages/now-cli/test/dev/fixtures/go/api/another.go b/packages/now-cli/test/dev/fixtures/go/api/another.go new file mode 100644 index 000000000..c638d333f --- /dev/null +++ b/packages/now-cli/test/dev/fixtures/go/api/another.go @@ -0,0 +1,10 @@ +package another + +import ( + "fmt" + "net/http" +) + +func Another(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "This is another page") +} diff --git a/packages/now-cli/test/dev/fixtures/go/api/index.go b/packages/now-cli/test/dev/fixtures/go/api/index.go new file mode 100644 index 000000000..b1b5b1adb --- /dev/null +++ b/packages/now-cli/test/dev/fixtures/go/api/index.go @@ -0,0 +1,10 @@ +package handler + +import ( + "fmt" + "net/http" +) + +func Handler(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "This is the index page") +} diff --git a/packages/now-cli/test/dev/integration.js b/packages/now-cli/test/dev/integration.js index 0cad769fa..6b2c7ffca 100644 --- a/packages/now-cli/test/dev/integration.js +++ b/packages/now-cli/test/dev/integration.js @@ -2,6 +2,7 @@ import ms from 'ms'; import os from 'os'; import fs from 'fs-extra'; import test from 'ava'; +import { isIP } from 'net'; import { join, resolve, delimiter } from 'path'; import _execa from 'execa'; import fetch from 'node-fetch'; @@ -1600,3 +1601,48 @@ test( await testPath(200, '/index.css', 'This is index.css'); }) ); + +test( + '[vercel dev] Should support `*.go` API serverless functions', + testFixtureStdio('go', async testPath => { + await testPath(200, `/api`, 'This is the index page'); + await testPath(200, `/api/index`, 'This is the index page'); + await testPath(200, `/api/index.go`, 'This is the index page'); + await testPath(200, `/api/another`, 'This is another page'); + await testPath(200, '/api/another.go', 'This is another page'); + await testPath(200, `/api/foo`, 'Req Path: /api/foo'); + await testPath(200, `/api/bar`, 'Req Path: /api/bar'); + }) +); + +test( + '[vercel dev] Should set the `ts-node` "target" to match Node.js version', + testFixtureStdio('node-ts-node-target', async testPath => { + await testPath(200, `/api/subclass`, '{"ok":true}'); + await testPath( + 200, + `/api/array`, + '{"months":[1,2,3,4,5,6,7,8,9,10,11,12]}' + ); + + await testPath(200, `/api/dump`, (t, body, res, isDev) => { + const { host } = new URL(res.url); + const { env, headers } = JSON.parse(body); + + // Test that the API endpoint receives the Vercel proxy request headers + t.is(headers['x-forwarded-host'], host); + t.is(headers['x-vercel-deployment-url'], host); + t.truthy(isIP(headers['x-real-ip'])); + t.truthy(isIP(headers['x-forwarded-for'])); + t.truthy(isIP(headers['x-vercel-forwarded-for'])); + + // Test that the API endpoint has the Vercel platform env vars defined. + t.regex(env.NOW_REGION, /^[a-z]{3}\d$/); + if (isDev) { + // Only dev is tested because in production these are opt-in. + t.is(env.VERCEL_URL, host); + t.is(env.VERCEL_REGION, 'dev1'); + } + }); + }) +); diff --git a/packages/now-go/build.sh b/packages/now-go/build.sh index c1a5dbebb..b2d342747 100755 --- a/packages/now-go/build.sh +++ b/packages/now-go/build.sh @@ -1,4 +1,13 @@ +#!/bin/bash +set -euo pipefail + +# Start fresh +rm -rf dist + +# Build with `ncc` ncc build index.ts -e @vercel/build-utils -e @now/build-utils -o dist ncc build install.ts -e @vercel/build-utils -e @now/build-utils -o dist/install + +# Move `install.js` to dist mv dist/install/index.js dist/install.js rm -rf dist/install diff --git a/packages/now-go/go-helpers.ts b/packages/now-go/go-helpers.ts index 122abd796..bd651bad3 100644 --- a/packages/now-go/go-helpers.ts +++ b/packages/now-go/go-helpers.ts @@ -3,14 +3,10 @@ import execa from 'execa'; import fetch from 'node-fetch'; import { mkdirp, pathExists } from 'fs-extra'; import { dirname, join } from 'path'; -import { homedir } from 'os'; import buildUtils from './build-utils'; import stringArgv from 'string-argv'; const { debug } = buildUtils; -const archMap = new Map([ - ['x64', 'amd64'], - ['x86', '386'], -]); +const archMap = new Map([['x64', 'amd64'], ['x86', '386']]); const platformMap = new Map([['win32', 'windows']]); // Location where the `go` binary will be installed after `postinstall` @@ -130,50 +126,35 @@ export async function downloadGo( platform = process.platform, arch = process.arch ) { - // Check default `Go` in user machine - const isUserGo = await pathExists(join(homedir(), 'go')); + // Check if `go` is already installed in user's `$PATH` + const { failed, stdout } = await execa('go', ['version'], { reject: false }); - // If we found GOPATH in ENV, or default `Go` path exists - // asssume that user have `Go` installed - if (isUserGo || process.env.GOPATH !== undefined) { - const { stdout } = await execa('go', ['version']); - - if (parseInt(stdout.split('.')[1]) >= 11) { - return createGo(dir, platform, arch); - } - - throw new Error( - `Your current ${stdout} doesn't support Go Modules. Please update.` - ); - } else { - // Check `Go` bin in builder CWD - const isGoExist = await pathExists(join(dir, 'bin')); - if (!isGoExist) { - debug( - 'Installing `go` v%s to %o for %s %s', - version, - dir, - platform, - arch - ); - const url = getGoUrl(version, platform, arch); - debug('Downloading `go` URL: %o', url); - const res = await fetch(url); - - if (!res.ok) { - throw new Error(`Failed to download: ${url} (${res.status})`); - } - - // TODO: use a zip extractor when `ext === "zip"` - await mkdirp(dir); - await new Promise((resolve, reject) => { - res.body - .on('error', reject) - .pipe(tar.extract({ cwd: dir, strip: 1 })) - .on('error', reject) - .on('finish', resolve); - }); - } + if (!failed && parseInt(stdout.split('.')[1]) >= 11) { + debug('Using system installed version of `go`: %o', stdout.trim()); return createGo(dir, platform, arch); } + + // Check `go` bin in builder CWD + const isGoExist = await pathExists(join(dir, 'bin')); + if (!isGoExist) { + debug('Installing `go` v%s to %o for %s %s', version, dir, platform, arch); + const url = getGoUrl(version, platform, arch); + debug('Downloading `go` URL: %o', url); + const res = await fetch(url); + + if (!res.ok) { + throw new Error(`Failed to download: ${url} (${res.status})`); + } + + // TODO: use a zip extractor when `ext === "zip"` + await mkdirp(dir); + await new Promise((resolve, reject) => { + res.body + .on('error', reject) + .pipe(tar.extract({ cwd: dir, strip: 1 })) + .on('error', reject) + .on('finish', resolve); + }); + } + return createGo(dir, platform, arch); } diff --git a/packages/now-go/index.ts b/packages/now-go/index.ts index a7af174e9..67dbaf422 100644 --- a/packages/now-go/index.ts +++ b/packages/now-go/index.ts @@ -1,8 +1,25 @@ -import { join, sep, dirname, basename, normalize } from 'path'; -import { readFile, writeFile, pathExists, move } from 'fs-extra'; -import { homedir } from 'os'; import execa from 'execa'; -import { BuildOptions, Meta, Files, shouldServe } from '@vercel/build-utils'; +import retry from 'async-retry'; +import { homedir, tmpdir } from 'os'; +import { spawn } from 'child_process'; +import { Readable } from 'stream'; +import once from '@tootallnate/once'; +import { join, dirname, basename, normalize, sep } from 'path'; +import { + readFile, + writeFile, + pathExists, + mkdirp, + move, + remove, +} from 'fs-extra'; +import { + BuildOptions, + Meta, + Files, + StartDevServerOptions, + StartDevServerResult, +} from '@vercel/build-utils'; import buildUtils from './build-utils'; const { @@ -10,12 +27,17 @@ const { download, createLambda, getWriteableDirectory, + shouldServe, debug, } = buildUtils; +const TMP = tmpdir(); + import { createGo, getAnalyzedEntrypoint, OUT_EXTENSION } from './go-helpers'; const handlerFileName = `handler${OUT_EXTENSION}`; +export { shouldServe }; + interface Analyzed { found?: boolean; packageName: string; @@ -23,16 +45,22 @@ interface Analyzed { watch: string[]; } +interface PortInfo { + port: number; +} + // Initialize private git repo for Go Modules async function initPrivateGit(credentials: string) { + const gitCredentialsPath = join(homedir(), '.git-credentials'); + await execa('git', [ 'config', '--global', 'credential.helper', - `store --file ${join(homedir(), '.git-credentials')}`, + `store --file ${gitCredentialsPath}`, ]); - await writeFile(join(homedir(), '.git-credentials'), credentials); + await writeFile(gitCredentialsPath, credentials); } /** @@ -435,4 +463,160 @@ Learn more: https://vercel.com/docs/runtimes#official-runtimes/go }; } -export { shouldServe }; +function isPortInfo(v: any): v is PortInfo { + return v && typeof v.port === 'number'; +} + +function isReadable(v: any): v is Readable { + return v && v.readable === true; +} + +async function copyEntrypoint(entrypoint: string, dest: string): Promise { + const data = await readFile(entrypoint, 'utf8'); + + // Modify package to `package main` + const patched = data.replace(/\bpackage\W+\S+\b/, 'package main'); + + await writeFile(join(dest, 'entrypoint.go'), patched); +} + +async function copyDevServer( + functionName: string, + dest: string +): Promise { + const data = await readFile(join(__dirname, 'dev-server.go'), 'utf8'); + + // Populate the handler function name + const patched = data.replace('__HANDLER_FUNC_NAME', functionName); + + await writeFile(join(dest, 'vercel-dev-server-main.go'), patched); +} + +export async function startDevServer( + opts: StartDevServerOptions +): Promise { + const { entrypoint, workPath, meta = {} } = opts; + const { devCacheDir = join(workPath, '.vercel', 'cache') } = meta; + const entrypointDir = dirname(entrypoint); + + // For some reason, if `entrypoint` is a path segment (filename contains `[]` + // brackets) then the `.go` suffix on the entrypoint is missing. Fix that here… + let entrypointWithExt = entrypoint; + if (!entrypoint.endsWith('.go')) { + entrypointWithExt += '.go'; + } + + const tmp = join(devCacheDir, 'go', Math.random().toString(32).substring(2)); + const tmpPackage = join(tmp, entrypointDir); + await mkdirp(tmpPackage); + + let goModAbsPathDir = ''; + if (await pathExists(join(workPath, 'go.mod'))) { + goModAbsPathDir = workPath; + } + const analyzedRaw = await getAnalyzedEntrypoint( + entrypointWithExt, + goModAbsPathDir + ); + if (!analyzedRaw) { + throw new Error( + `Could not find an exported function in "${entrypointWithExt}" +Learn more: https://vercel.com/docs/runtimes#official-runtimes/go` + ); + } + const analyzed: Analyzed = JSON.parse(analyzedRaw); + + await Promise.all([ + copyEntrypoint(entrypointWithExt, tmpPackage), + copyDevServer(analyzed.functionName, tmpPackage), + ]); + + const portFile = join( + TMP, + `vercel-dev-port-${Math.random().toString(32).substring(2)}` + ); + + const env: typeof process.env = { + ...process.env, + ...meta.env, + VERCEL_DEV_PORT_FILE: portFile, + }; + + const tmpRelative = `.${sep}${entrypointDir}`; + const child = spawn('go', ['run', tmpRelative], { + cwd: tmp, + env, + stdio: ['ignore', 'inherit', 'inherit', 'pipe'], + }); + + child.once('exit', () => { + retry(() => remove(tmp)).catch((err: Error) => { + console.error('Could not delete tmp directory: %j: %s', tmp, err); + }); + }); + + const portPipe = child.stdio[3]; + if (!isReadable(portPipe)) { + throw new Error('File descriptor 3 is not readable'); + } + + // `dev-server.go` writes the ephemeral port number to FD 3 to be consumed here + const onPort = new Promise(resolve => { + portPipe.setEncoding('utf8'); + portPipe.once('data', d => { + resolve({ port: Number(d) }); + }); + }); + const onPortFile = waitForPortFile(portFile); + const onExit = once.spread<[number, string | null]>(child, 'exit'); + const result = await Promise.race([onPort, onPortFile, onExit]); + onExit.cancel(); + onPortFile.cancel(); + + if (isPortInfo(result)) { + return { + port: result.port, + pid: child.pid, + }; + } else if (Array.isArray(result)) { + // Got "exit" event from child process + throw new Error( + `Failed to start dev server for "${entrypointWithExt}" (code=${result[0]}, signal=${result[1]})` + ); + } else { + throw new Error(`Unexpected result type: ${typeof result}`); + } +} + +export interface CancelablePromise extends Promise { + cancel: () => void; +} + +function waitForPortFile(portFile: string) { + const opts = { portFile, canceled: false }; + const promise = waitForPortFile_(opts) as CancelablePromise; + promise.cancel = () => { + opts.canceled = true; + }; + return promise; +} + +async function waitForPortFile_(opts: { + portFile: string; + canceled: boolean; +}): Promise { + while (!opts.canceled) { + await new Promise(resolve => setTimeout(resolve, 100)); + try { + const port = Number(await readFile(opts.portFile, 'ascii')); + retry(() => remove(opts.portFile)).catch((err: Error) => { + console.error('Could not delete port file: %j: %s', opts.portFile, err); + }); + return { port }; + } catch (err) { + if (err.code !== 'ENOENT') { + throw err; + } + } + } +} diff --git a/packages/now-go/main.go b/packages/now-go/main.go index 20d356705..b65a3881e 100644 --- a/packages/now-go/main.go +++ b/packages/now-go/main.go @@ -1,8 +1,8 @@ package main import ( - "net/http" vc "github.com/vercel/go-bridge/go/bridge" + "net/http" ) func main() { diff --git a/packages/now-go/main__mod__.go b/packages/now-go/main__mod__.go index 28b63a218..036a460a6 100644 --- a/packages/now-go/main__mod__.go +++ b/packages/now-go/main__mod__.go @@ -1,12 +1,12 @@ package main import ( - "net/http" - "__NOW_HANDLER_PACKAGE_NAME" + "__NOW_HANDLER_PACKAGE_NAME" + "net/http" - vc "github.com/vercel/go-bridge/go/bridge" + vc "github.com/vercel/go-bridge/go/bridge" ) func main() { - vc.Start(http.HandlerFunc(__NOW_HANDLER_FUNC_NAME)) + vc.Start(http.HandlerFunc(__NOW_HANDLER_FUNC_NAME)) } diff --git a/packages/now-go/package.json b/packages/now-go/package.json index a9b36eee2..53ecbc138 100644 --- a/packages/now-go/package.json +++ b/packages/now-go/package.json @@ -19,6 +19,8 @@ "dist" ], "devDependencies": { + "@tootallnate/once": "1.1.2", + "@types/async-retry": "1.4.2", "@types/execa": "^0.9.0", "@types/fs-extra": "^5.0.5", "@types/node-fetch": "^2.3.0",