All posts

Port Forwarding: a Deno 2.0 Experiment

11 min read

In my day job, we often need to use port forwarding to test our applications during development, as our GKE cluster is not exposed to the public internet and it can be tedious to test your application through the Kong Gateway (particularly when the application is not yet available in production).

Typically, I like to use Google zx to create this kind of script, but it would still require setup on the end user's machine.

So I thought I'd give Deno 2.0 a shot to see how it compares.

Problem

When our application is deployed to a GKE cluster, we need to port forward to it to test it locally, to avoid the need to go through the Kong Gateway quite yet.

Currently, when we need to port forward a Deno application, we need to follow these steps:

bash
# 1. Start the VPN # 2. Authenticate with Google Cloud SDK gcloud auth login # 3. Authenticate with GKE gcloud container clusters get-credentials [cluster-name] --region [region] --project [project-id] # 4. Configure kubectl to use the correct cluster kubectl config use-context [cluster] # 5. Find your pod name kubectl get pods # 6. Port forward your pod to your local machine kubectl port-forward [pod-name] [local-port]:[remote-port]

While this is not the end of the world, it can be a bit tedious to type all of this every time we need to port forward.. Especially since we also use various clusters, in different regions, and with different project IDs, etc.

Solution

The solution is pretty simple: let's use Deno 2.0 to create a small script that will handle all of this for us. As mentioned above, I typically use Google zx for this kind of script, but I thought I'd give Deno 2.0 a shot since it just came out.

Install Deno

First, we need to install Deno:

sh
curl -fsSL https://deno.land/install.sh | sh

Additional installation options can be found here. After installation, you should have the deno executable available on your system path. You can verify the installation by running:

sh
deno --version

Create the script

I created a new file called port-forwarding.ts, with the following content:

tip

For this script to later work on all platforms, I had to add the checkCommand() function to check the OS.

note

I also added ensureCommandsInstalled() to ensure the required commands are available on the system path (gcloud and kubectl).

port-forwarding.ts
typescript
// This script requires Deno 1.32.0 or later import { parse } from "std/flags/mod.ts"; async function checkCommand(command: string): Promise<boolean> { const cmd = Deno.build.os === "windows" ? "where" : "which"; try { const process = new Deno.Command(cmd, { args: [command] }); const { success } = await process.output(); return success; } catch { return false; } } async function ensureCommandsInstalled() { const commands = ["gcloud", "kubectl"]; for (const cmd of commands) { if (!(await checkCommand(cmd))) { console.error(`Error: ${cmd} is not found in the system PATH.`); console.error( `Please install ${cmd} and make sure it's in your system's PATH.` ); Deno.exit(1); } } } async function runCommand(cmd: string[]): Promise<void> { const command = new Deno.Command(cmd[0], { args: cmd.slice(1) }); const { success } = await command.output(); if (!success) { throw new Error(`Command failed: ${cmd.join(" ")}`); } } async function getKubectlPodName( namespace: string, appSelector: string ): Promise<string> { const cmd = [ "kubectl", "get", "pod", "--namespace", namespace, `--selector=app=${appSelector}`, "--output=jsonpath={.items[0].metadata.name}", ]; const command = new Deno.Command(cmd[0], { args: cmd.slice(1), stdout: "piped", }); const { stdout } = await command.output(); return new TextDecoder().decode(stdout).trim(); } async function runPortForward( cluster: string, region: string, project: string, namespace: string, appSelector: string, localPort: number, remotePort: number ) { try { console.log( `gcloud container clusters get-credentials ${cluster} --region ${region} --project ${project}` ); await runCommand([ "gcloud", "container", "clusters", "get-credentials", cluster, "--region", region, "--project", project, ]); const podName = await getKubectlPodName(namespace, appSelector); console.log(`Port forwarding for pod: ${podName}`); await runCommand([ "kubectl", "port-forward", "--namespace", namespace, podName, `${localPort}:${remotePort}`, ]); } catch (error: any) { console.error("An error occurred:", error.message); Deno.exit(1); } } async function promptForValue(prompt: string): Promise<string> { const buf = new Uint8Array(1024); await Deno.stdout.write(new TextEncoder().encode(prompt)); const n = await Deno.stdin.read(buf); return new TextDecoder().decode(buf.subarray(0, n!)).trim(); } const clusterOptions = [ { project: "some-gcp-project", cluster: "some-gke-cluster", region: "some-region", }, // ... ]; async function promptForCluster(): Promise<{ project: string; cluster: string; region: string; }> { console.log("Available clusters:"); clusterOptions.forEach((option, index) => { console.log( `${index + 1}. Project: ${option.project}, Cluster: ${option.cluster}, Region: ${option.region}` ); }); let selection: number; do { const input = await promptForValue("Enter the number of your selection: "); selection = parseInt(input) - 1; } while ( selection < 0 || selection >= clusterOptions.length || isNaN(selection) ); return clusterOptions[selection]; } function printHelp() { console.log(` Port Forwarding Script Usage: deno run --allow-run --allow-env port-forwarding.ts [options] Options: --help, -h Show this help message --cluster <name> Specify the cluster name --region <name> Specify the region --project <name> Specify the project --namespace <name> Specify the namespace (default: my-namespace) --app-selector <name> Specify the app selector (default: my-pod-selector) --local-port <number> Specify the local port (default: 4000) --remote-port <number> Specify the remote port (default: 4000) If cluster, region, and project are not provided, you will be prompted to select from available options. For other options, if not provided, you will be prompted to enter values. `); } async function main() { await ensureCommandsInstalled(); Deno.env.set("HTTP_PROXY", "http://127.0.0.1:8080"); Deno.env.set("HTTPS_PROXY", "http://127.0.0.1:8080"); const args = parse(Deno.args, { string: [ "cluster", "region", "project", "namespace", "app-selector", "local-port", "remote-port", ], boolean: ["help"], alias: { h: "help" }, }); if (args.help) { printHelp(); Deno.exit(0); } let clusterInfo: { project: string; cluster: string; region: string }; if (args.cluster && args.region && args.project) { clusterInfo = { project: args.project, cluster: args.cluster, region: args.region, }; } else { clusterInfo = await promptForCluster(); } const config = { cluster: clusterInfo.cluster, region: clusterInfo.region, project: clusterInfo.project, namespace: args.namespace || (await promptForValue("Enter namespace (default: my-namespace): ")) || "my-namespace", "app-selector": args["app-selector"] || (await promptForValue( "Enter app selector (default: my-pod-selector): " )) || "my-pod-selector", "local-port": args["local-port"] || parseInt(await promptForValue("Enter local port (default: 4000): ")) || 4000, "remote-port": args["remote-port"] || parseInt(await promptForValue("Enter remote port (default: 4000): ")) || 4000, }; await runPortForward( config.cluster, config.region, config.project, config.namespace, config["app-selector"], Number(config["local-port"]), Number(config["remote-port"]) ); } main();

Deno Configuration

In order to compile the script, we do need to create a deno.json file with a few configurations:

deno.json
json
{ "compilerOptions": { "lib": ["deno.ns", "deno.window"], "strict": true }, "lint": { "files": { "include": ["./"] }, "rules": { "tags": ["recommended"] } }, "fmt": { "files": { "include": ["./"] }, "options": { "useTabs": false, "lineWidth": 120, "indentWidth": 2, "singleQuote": true, "proseWrap": "preserve" } }, "tasks": { "build": "deno compile --allow-run --allow-env --allow-net --config deno.json --output build/port-forwarding port-forwarding.ts", "start": "deno run --allow-run --allow-env --allow-net port-forwarding.ts" }, "importMap": "import_map.json", "compile": { "allow": ["run", "env", "net"] } }

Let's break down the different sections of this configuration file:

compilerOptions
lint
fmt
tasks
importMap
compile

Running the script

To run the script, we can use the deno run command:

bash
deno run --allow-run --allow-env --allow-net port-forwarding.ts
tip

We can also use Deno Tasks to run the build command, as defined in the deno.json file:


bash
deno task start

Build the script as an executable

I didn't want to force users to install Deno on their machine to run the script, so I used the deno compile command to compile the script into an executable:

bash
deno compile --allow-run --allow-env --allow-net --config deno.json --output build/port-forwarding port-forwarding.ts
tip

Just like the build, we can also use Deno Tasks to run the compile command, as defined in the deno.json file:


bash
deno task build

Github Actions

Now that we can run and compile the script, we can add a Github Actions workflow to:

We use Conventional Commits to automatically generate the changelog and version number.

.github/workflows/release.yml
yaml
name: Build Executables and Release on: push: branches: [main] pull_request: branches: [main] jobs: build: runs-on: ubuntu-latest strategy: matrix: target: [ x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, x86_64-apple-darwin, aarch64-apple-darwin, x86_64-pc-windows-msvc, ] steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Deno uses: denoland/setup-deno@v2 with: deno-version: v2.x - name: Get version id: get_version run: | git fetch --tags LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") if [ "$LATEST_TAG" = "v0.0.0" ]; then NEW_VERSION="v1.0.0" else IFS='.' read -ra VERSION_PARTS <<< "${LATEST_TAG#v}" MAJOR=${VERSION_PARTS[0]} MINOR=${VERSION_PARTS[1]} PATCH=${VERSION_PARTS[2]} # Get commit messages since last tag COMMITS=$(git log ${LATEST_TAG}..HEAD --pretty=format:"%s") # Check commit messages for conventional commit keywords if echo "$COMMITS" | grep -qE '^BREAKING CHANGE:'; then MAJOR=$((MAJOR + 1)) MINOR=0 PATCH=0 elif echo "$COMMITS" | grep -qE '^feat(\([^)]+\))?:'; then MINOR=$((MINOR + 1)) PATCH=0 else PATCH=$((PATCH + 1)) fi NEW_VERSION="v$MAJOR.$MINOR.$PATCH" fi echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT - name: Compile run: | deno compile --allow-run --allow-env --allow-net --target ${{ matrix.target }} --output port-forwarding-${{ matrix.target }} port-forwarding.ts - name: Upload artifact uses: actions/upload-artifact@v4 with: name: port-forwarding-${{ matrix.target }} path: port-forwarding-${{ matrix.target }}* release: needs: build if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Get version id: get_version run: | git fetch --tags LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") if [ "$LATEST_TAG" = "v0.0.0" ]; then NEW_VERSION="v1.0.0" else IFS='.' read -ra VERSION_PARTS <<< "${LATEST_TAG#v}" MAJOR=${VERSION_PARTS[0]} MINOR=${VERSION_PARTS[1]} PATCH=${VERSION_PARTS[2]} # Get commit messages since last tag COMMITS=$(git log ${LATEST_TAG}..HEAD --pretty=format:"%s") # Check commit messages for conventional commit keywords if echo "$COMMITS" | grep -qE '^BREAKING CHANGE:'; then MAJOR=$((MAJOR + 1)) MINOR=0 PATCH=0 elif echo "$COMMITS" | grep -qE '^feat(\([^)]+\))?:'; then MINOR=$((MINOR + 1)) PATCH=0 else PATCH=$((PATCH + 1)) fi NEW_VERSION="v$MAJOR.$MINOR.$PATCH" fi echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT - name: Generate Changelog id: generate_changelog run: | LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") if [ -z "$LATEST_TAG" ]; then COMMITS=$(git log --pretty=format:"- %s" --reverse) else COMMITS=$(git log ${LATEST_TAG}..HEAD --pretty=format:"- %s" --reverse) fi REPO_URL="https://github.com/${{ github.repository }}" VERSION="${{ steps.get_version.outputs.VERSION }}" echo "# [$VERSION]($REPO_URL/releases/tag/$VERSION)" > CHANGELOG.md echo "" >> CHANGELOG.md echo "## Features" >> CHANGELOG.md echo "$COMMITS" | grep "^- feat" >> CHANGELOG.md || echo "No new features" >> CHANGELOG.md echo "" >> CHANGELOG.md echo "## Bug Fixes" >> CHANGELOG.md echo "$COMMITS" | grep "^- fix" >> CHANGELOG.md || echo "No bug fixes" >> CHANGELOG.md echo "" >> CHANGELOG.md echo "## Other Changes" >> CHANGELOG.md echo "$COMMITS" | grep -vE "^- (feat|fix)" >> CHANGELOG.md || echo "No other changes" >> CHANGELOG.md CHANGELOG_CONTENT=$(cat CHANGELOG.md) echo "CHANGELOG<<EOF" >> $GITHUB_OUTPUT echo "$CHANGELOG_CONTENT" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - name: Create Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh release create ${{ steps.get_version.outputs.VERSION }} \ --title "${{ steps.get_version.outputs.VERSION }}" \ --notes "${{ steps.generate_changelog.outputs.CHANGELOG }}" - name: Download artifacts uses: actions/download-artifact@v4 - name: Upload Release Assets env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | for file in port-forwarding-*/*; do gh release upload ${{ steps.get_version.outputs.VERSION }} "$file" done - name: Push new tag run: | git config user.name github-actions git config user.email github-actions@github.com git tag ${{ steps.get_version.outputs.VERSION }} git push origin ${{ steps.get_version.outputs.VERSION }}

End-user Experience

For the end-user, they can simply download the executable for their platform, rename it to port-forwarding if needed, and run it:

bash
./port-forwarding

And it looks something like this:

Port Forwarding Script

Takeaways

This experiment with Deno 2.0 demonstrates how powerful and convenient it can be for building small, efficient utilities like our port forwarding script. Deno's built-in TypeScript support, robust standard library, and straightforward compilation process make it an excellent choice for developers looking to create cross-platform command-line tools.

The ability to easily compile our script into standalone executables for various platforms, coupled with Deno's security-first approach and modern JavaScript features, showcases why Deno 2.0 is gaining traction in the developer community. It provides a streamlined development experience while maintaining the flexibility and performance needed for practical, everyday tools.

Whether you're a seasoned developer or just getting started, Deno 2.0 offers a refreshing and efficient way to build utilities that can simplify your workflow. As we've seen with our port forwarding tool, even complex tasks can be made more accessible and manageable with Deno's ecosystem.


Foxy seeing you here! Let's chat!
Logo