Try   HackMD

Building an Alfred 5 workflow in TypeScript with Deno

Ok, firstly let's get some of the buzz words out of the way:

  • Alfred - "Alfred is an award-winning app for macOS which boosts your efficiency with hotkeys, keywords, text expansion and more"
  • TypeScript - "TypeScript is a strongly typed programming language that builds on JavaScript, giving you better tooling at any scale."
  • Deno - "A modern runtime for JavaScript and TypeScript"

As a MacOS user I actually use Alfred every day rather than the default MacOS Spotlight. In fact so embedded it is into my daily muscle memory, that if it disappeared tomorrow I be stuck hopelessly randomly hitting key combos with an expectation that something would happen. I use it to replace repetitive tasks (search and opening files, browsing websites, playing music, finding contact phone numbers) with "workflows", to boost my productivity. Some of these "workflows" are built in to the app, but some are downloaded and installed having been created by others.

As a developer, one of these "downloaded workflows" was something I used almost every day. It allowed me to navigate around Github.com (open a repo, create a new PR or track an issue) and being able to do it with minimal keystrokes - which became fewer over time due to learning my habits - was invaluable. The problem was the "workflow" I was using was written in PHP. I don't write PHP so if I wanted to extend it or fix bugs it required learning a new language I'm not interested in.

As an aside, I was also looking for a project where I could use Deno. We use TypeScript a lot here at Kyan but we haven't yet found a client project where Deno would be a good fit yet. I think its only a matter of time. Until then though, I wanted to build something a bit more meaty than "hello world", something I could interate on.

So this post is about how I recreated the Github workflow in TypeScript using Deno and some of the problems I overcame along the way. The post doesn't dig too deep into code, but gives an overview of how I solved various problems along the way. If you're into the code, then navigating your way around the source would be your best move.

Workflow basics

So in Alfred a workflow works via keystrokes. The user types a trigger which starts the workflow. Anything you type after than trigger gets passed to the workflow. The workflow then decides what items to show based on the keys pressed. Alfred has a drag and drop interface that actually allows you to build quite sofisticated workflows without writting any code. But in this case we want full control so will will have a mix.

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

For every keystroke we will be passing the keys pressed to our deno app which will return the items we want to show. Here's what the Script Filter above looks like:

$(which deno) run --allow-env --allow-write --allow-read --allow-net mod.min.js filter $argv[1]

So it's passing both filter and $argv[1] to the Deno app. The first thing the Deno app does is parse the values passed in:

export default async function Workflow(query: string) {
  const items: Alfred.Item[] = [];
  const db = await dbConnect();

  await init(db, async (config) => {
    switch (true) {
      case /^>([a-zA-Z0-9_\s]+)?$/.test(query):
        await Setting(queryArgs(query, ">"), items, config);
        break;
      case /^@([a-z-]+(\s+))?([a-z-]+)?$/.test(query):
        await User(queryArgs(query, "@"), items, config);
        break;
      case /^my([a-zA-Z0-9_\s]+)?$/.test(query):
        await Mine(queryArgs(query, "my"), items, config);
        break;
      default:
        await Search(queryArgs(query), items, config);
    }
  });
  db.close();

  return JSON.stringify({ items });
}

Using SQLite as a data store

You can see we are first doing some initialization. The workflow actually uses a SQLite database. It uses this to store configuration information as well as caching requests made to github for speedup. SQLite is an amazing bit of software. A database as a file. You're probably using it without knowing as it's used in mobile phones and tvs as an easy was to manage local data. It supports complex SQL and is pretty dam fast.

So we need to create this and connect to it before we do anything else. Only then do we progress to actually deciding what we need to show. Once the DB is available to use we pass the connection around rather then keeping opening and closing the connection.

export async function dbConnect() {
  try {
    await ensureDir(DB_DIR);
    await Deno.stat(DATABASE);

    return new DB(DATABASE);
  } catch (err) {
    if (err instanceof Deno.errors.NotFound) {
      return createDB(new DB(DATABASE));
    }
    throw err;
  }
}

Here's a glimpse of what the data looks like once stored:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

Authentication

The first interesting challenge before we start showing any Github data is actually getting permission to fetch it. We will be using the Github Rest API in the workflow, but this requires a token. The process of getting this token requires fetching an initial auth code and then passing that code to a service that is able to validate it and convert it into a token which is then passed back to the workflow. Here's an overview:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

The biggest challenge here was to create a running webserver that is only needed for one request! You can see the source code here https://github.com/whomwah/alfred-github-workflow/blob/main/src/server.ts where I've used timeouts to shutdown the server once its finshed with. It works really well and stores the auth token in the db. The tiny auth service that is needed to convert the access code into a token is running on https://deno.com/deploy. Super super simple to setup and run.

Data fetching and caching

Now we have this auth token when the user searches for something we can first fetch and cache the results so subsiquent searches come from the DB. We use a recursive data fetch from the Github API using the header Link information:

export async function fetchNewDataFromAPIandStore<T>(
  config: Config,
  url: string,
  results: T[],
  urlToStore?: string,
): Promise<void> {
  console.warn("fetchNewDataFromAPIandStore:", { url, urlToStore });
  const response = await fetchData(url, config.token);
  const data: T | T[] = await response.json();
  const linkMatch: RegExpMatchArray | null | undefined = response.headers
    .get("Link")
    ?.match(/<([^>]+)>; rel="next"/);

  if ([200, 304].includes(response.status)) {
    updateCache(config.db, [
      url,
      new Date().getTime(),
      JSON.stringify(data),
      urlToStore,
    ]);

    results.push(...(Array.isArray(data) ? data : [data]));

    if (linkMatch) {
      return fetchNewDataFromAPIandStore(config, linkMatch[1], results, url);
    }
  } else {
    console.warn("Invalid Github Response:", data);
  }
}

You can see this recursivly makes API requests if there are more pages and then caches the result in the DB. Fetching and caching the data like this makes for really fast responses to the user even though we need to parse the data first.

Auto updating the workflow

Alfred workflows are installed. A workflow is effectively a ZIP file with a different .workflow extension. One useful bit of functionality is checking for a new version of the workflow and showing it in the results so the user can easily install it. This is done by periodically checking and comparing installed vs latest version. Once a latest version is determined and the user choose to install, we can then trigger a bash script that does the magic:

# Setting github variables
readonly gh_repo='whomwah/alfred-github-workflow'
readonly gh_url="https://api.github.com/repos/${gh_repo}/releases/latest"

# Fetch latest version
function fetch_remote_version {
  echo $1 | grep 'tag_name' | head -1 | sed -E 's/.*tag_name": "v?(.*)".*/\1/'
}

# Fetch download url
function fetch_download_url {
  echo $1 | grep 'browser_download_url.*\.alfredworkflow"' | head -1 | sed -E 's/.*browser_download_url": "(.*)".*/\1/'
}

# Download and install workflow
function download_and_install {
  readonly tmpfile="$(mktemp).alfredworkflow"
  echo "Downloading and installing version ${2}…"
  curl --silent --location --output "${tmpfile}" "${1}"
  open "${tmpfile}"
  exit 0;
}

# Setting version and download url for later use
readonly response=$(curl --silent "${gh_url}")
readonly version="$(fetch_remote_version $response)"
readonly download_url="$(fetch_download_url $response)"

# Compare current version to installed version and download if required
[ $(printf "%d%03d%03d%03d\n" $(echo ${alfred_workflow_version} | tr '.' ' ')) -lt $(printf "%d%03d%03d%03d\n" $(echo ${version} | tr '.' ' ')) ] &amp;&amp; download_and_install ${download_url} ${version}

echo "You are running the latest version (${alfred_workflow_version})"

This works really well giving the user the choice of installing or ignoring the update.

Finishing up

So I've gone through a few of the problems that needed solving in building a workflow for Alfred in Deno and TypeScript. You could be thinking, but you could of just written this in JavaScript or just TypeScript without Deno. Correct, but Deno made the process of writing the app so much nicer and developer experience is super important.

Deno is a single executable with all the tooling built in. So for example I could write and run tests with deno test. I could make sure all the code was consistently formatted with deno fmt or type checked with deno check. No extra build systems required. I also was able to compile the app down to a single JS file with deno bundle and then run it with deno run, simple.

If you use Alfred, then please so grab the latest workflow and try it yourself. Or if you just want to see the code visit https://github.com/whomwah/alfred-github-workflow.

Here's a little demo of the workflow in action:

Alfred Github

Future post on build and release

One thing I never went through in this post is how the workflow is built and released. This is something that will addressed in a future post explaining how it uses semantic-release and Github actions to make building and releasing a new version as simple as a commit with a message of: feat: Create a new release with feature X

I hope you find this post useful, thanks for reading.