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.
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:
# 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.
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.
First, we need to install Deno :
macOS Windows Linux
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:
deno --version
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 // 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 ();
In order to compile the script, we do need to create a deno.json
file with a few configurations:
deno.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:
Specifies the compiler options for TypeScript.
lib
: Includes the Deno namespace and window APIs.
strict
: Enables strict type-checking.
Configures the linter.
Includes all files in the current directory for linting.
Uses recommended linting rules.
Configures the code formatter.
Includes all files in the current directory for formatting.
Sets formatting options like using spaces instead of tabs, line width, indent width, single quotes, and preserving prose wrap.
Defines custom tasks that can be run using deno task.
build
: Compiles the port-forwarding.ts file into an executable with specific permissions.
start
: Runs the port-forwarding.ts file with specific permissions.
Specifies the location of the import map file, which is used to control how module specifiers are resolved.
Specifies the permissions allowed when compiling the project.
Allows running subprocesses, accessing environment variables, and network access.
To run the script, we can use the deno run
command:
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:
deno task start
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:
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:
deno task build
Now that we can run and compile the script, we can add a Github Actions workflow to:
Test the build on every pull request
Compile the script on every push to the main branch
Create a release with the compiled executables
We use Conventional Commits to automatically generate the changelog and version number.
.github/workflows/release.yml 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 }}
For the end-user, they can simply download the executable for their platform, rename it to port-forwarding
if needed, and run it:
./port-forwarding
And it looks something like this:
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.
Deno 2.0 Kubernetes Google Cloud