Cranberry: The Cure for Your URI
Tags: webdev, javascript, vercel, showdev
Published: 2023-10-21 05:33:21



Everyone is familiar with the term URL. Even if you couldn't recall off the top of your head that this abbreviation stands for **U**niform **R**esource **L**ocator, you'd be able to describe URLs as the addresses of specific websites that you put into your browser's address bar or, more frequently, click to via hyperlinks on other webpages.

You may not be so familiar with **U**niform **R**esource **I**dentifiers (URIs), a [much broader syntax](https://en.wikipedia.org/wiki/List_of_URI_schemes) for identifying different resources across the web. For example, there are URI schemes which allow you to invoke a device's SMS messaging capabilities to compose a message, and perhaps a more familiar example are Spotify's URIs that allow you to open a specific song, artist or playlist in the desktop/mobile application via a link.

```html
<!-- An example of an SMS URI -->
sms:+15105550101?body=hello

<!-- An example of the Spotify URI scheme -->
spotify:artist:7t0rwkOPGlDPEhaOcVtOt9

<!-- An example of the Git protocol URI scheme -->
git://github.com/whatwg/url.git

<!-- Zotero's URI Protocol -->
zotero://select/library/collections/{collection_key}/items/{item_key}
```

While URIs provide a ton of useful functionality, actually using them can be a bit difficult. Many of the places you'd think to use a hyperlink to provide clickable access to a URI refuse to validate the input URI. For example, it's (currently) not possible to provide a URI as the target of a link in the popular note taking application [Notion.so](https://notion.so).

That's always bothered me, especially because for my work I make heavy use of the research and citation manager [Zotero](https://zotero.org), which allows you to create URIs that, when opened, point you to a specific item in your library of saved materials. When working with a team of researchers and in a library of hundreds and even thousands of documents, being able to send someone one of these URIs is a huge time saver!

As I was driving the other day I had a shockingly simple realization: why not make a micro-service which accepts a Zotero URI as a part of a traditional URL, and redirects the browser to that URI when opened? In part, I was inspired by the ease with which Gitpod allows you to append a repository's URL to their own web address in order to open the repository in Gitpod. And so, I set out to solve this uncomfortable URI woe. And thus was born: [Cranberry](https://cranberry.vercel.app/)  

Cranberry leverages the advantages of so-called ['edge computing'](https://en.wikipedia.org/wiki/Edge_computing), a paradigm focused on ensuring low-latency by performing computational manipulation as close to the data source and client as possible. This is made possible by hosting Cranberry via Vercel and utilizing their [Edge Middleware](https://vercel.com/docs/functions/edge-middleware) to redirect requests containing a Zotero URI. I decided to use my favorite lightweight web development framework, [Astro](https://astro.build), although Vercel and its Edge Middleware services are compatible with a number of different frameworks. 

In this article, I'll walk provide a step-by-step walk-through documenting how to set up this type of micro-service and also reflect on some of the benefits and drawbacks of the approach taken.
## Defining Our Goals
For this micro-service, we want to be able to do the following:
1. Serve a small homepage describing the project.
2. Redirect any URL containing a Zotero URI to the specified URI. (We'll use a query parameter `?uri={USER_PROVIDED_URI}` )[^1]
3. Provide appropriate and understandable error messages.

It's also helpful to define a few anti-goals:
1. We don't want to redirect to user-provided URLs or other URI protocols, as doing so could make our service useful for bad actors trying to maliciously redirect users.[^2]
2. We don't want to send HTML to the client unless useful (e.g., visitors to the homepage who haven't come via a link containing a URI or in case of errors).

## Initializing Your Astro Project

```sh
# Create a new project with npm
npm create astro@latest
```

Houston, Astro's adorable mascot, will guide you through the initial set up. Choose a name and location for your project, as well as whether or not to start with some basic scaffolding files and whether or not you plan to use Typescript. Let's use the basic project template, and in this project we'll use a mixture of both Javascript and Typescript.

After your project initializes, be sure to change into the project directory.

## Enabling Server-Side Rendering
As mentioned, I decided to use Server-side Rendering (SSR) for this project, both because I wanted to familiarize myself with this rendering mode, as well as to take advantage of a few benefits that Edge-enabled SSR integrations can offer us for this use case, namely:

1. Because the majority of requests to our website's servers are merely meant to instantly redirect to the provided URI, there's no need to actually serve any website content. We'll only want to serve HTML to users who are visiting the homepage, or in case of an error.
    
2. By using an SSR host with a global Content Delivery Network (CDN) and Edge-powered middleware, we can further minimize latency, especially for users who would otherwise be further away from our server.
    
3. Since we perform our operations in at the Edge, we can ensure that all user agents and browsers will be able to utilize our service in accordance to the principle of progressive enhancement. If we were to rely on client-side manipulation, the service would only work if Javascript is supported and enabled.
    
However, there's a flip side to this coin! For one, depending on a (company's) CDN network and using their proprietary Edge function service and runtime means that our website and its functionality is __much less portable__ than relying on more traditional middleware strategies or even taking a client-side approach.

Since we're planning to deploy via Vercel, our next step will be to install and configure the Vercel SSR Adapter for Astro.

```sh
# This will install the adapter and update your astro config file
npx astro add vercel
```

If you prefer to manually install dependencies:
1. Install via npm
```sh
npm install @astrojs/vercel
```

2. Update your `astro.config.mjs`:
```js
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel/serverless';

export default defineConfig({
	output: 'server',
	adapter: vercel(),
});
```

In order to test our edge middleware locally in development, we'll also need to install the Vercel CLI tool:

```sh
npm install -g vercel
```

## Setting Up Our Middleware
So, what exactly does 'middleware' do? In the context of web development, middleware describes computations and modifications done between the time a server receives an incoming request and the response to that request is issued. It's a kind of interception layer that allows for all kinds of important procedures like authenticating users before serving protected content, fetching data from databases or other APIs and including that data in the response, among many others.

In our project, middleware will allow us intercept incoming requests (e.g., when someone clicks on a Cranberry-fied link or navigates to the homepage) and to decide on the appropriate response based on the data available in that request. More specifically, we'll want to evaluate whether or not the request contains a URI and, if so, whether or not the URI provided is 'valid' for our purposes (e.g., if the URI uses the `zotero://` protocol).

In other words, there should be three potential responses returned:
- Case One: No URI Included &rarr; show project homepage.
- Case Two: URI is included, and uses the Zotero protocol &rarr; immediately redirect to the provided URI.
- Case Three: URI is included, but is invalid &rarr; return error message and show error page.

With this plan in mind, let's set up our `middleware.js` file which will be used by Vercel to set up Edge Middleware functions. 

```javascript
// Originally this function was defined locally, but we'll use it again later to validate user input on our homepage so I've encapsulated it and imported it here
import isValidURI from './src/utils/isValidZoteroURI'

// We use the config export to define a matcher pattern, which tells Vercel which paths to run our middlewear on
export const config = {
	matcher: ['/'] // Match the root path
}

// Here's where we define our middlewear functionality.
// The function should return a response, and is async
// so that we can fetch the Error page and return it in our response
export default async function middleware(req) {
	// Grab the incoming request's url
	const url = new URL(req.url);
	// Then extract the URI appended to the url
	const uriParam = url.searchParams.get('uri');
	
	// CASE ONE
	// If there's no URI, return null to make no change to the response
	if (!uriParam) {
		return null; // This means a normal GET request will fetch the home page
	}

	// CASE TWO
	// If theres a URI and it's valid, try to redirect
	if (isValidZoteroURI(uriParam)) {
	// See below for why we have to catch errors even after validating URIs
		try {
			return Response.redirect(uriParam, 302); // Redirect to the URI
		} catch (error) {
			console.error('Redirection error:', error);
			return await handleError(url, 'Failed to redirect.');
		}
	}
		return await handleError(url, 'The URI provided is invalid.');
}

// Handle errors by returning a 400 status & displaying error page
async function handleError(url, errorMessage) {
	// Fetch the page created by `error.astro`
	const errorUrl = new URL('/error', url.origin);
	const errorPageResponse = await fetch(errorUrl);
	const errorPageContent = await errorPageResponse.text();
	// Serve our error page to provide direction for human users 
	return new Response(errorPageContent, {
		status: 400,
		headers: {
			'Content-Type': 'text/html',
			'X-Error-Message': errorMessage,
		},
	});
}
```

Let's take a look at the contents of `./src/utils/isValidZoteroURI.ts`:
```javascript
export default function isValidZoteroURI(uri: string): boolean {
	// We grab the scheme (text before the first colon)
	const scheme = uri.split(':')[0].toLowerCase()

	// Return true if the protocol uses zotero's scheme
	if (['zotero'].includes(scheme)) {
		return true
	}
	
	// Otherwise, return false
	return false
}
```

With that, we should have functioning middleware to intercept and respond to incoming requests accordingly. All that's left is to put together the `.astro` files to handle our front-end, and then to deploy to Vercel.

You can test your middleware locally by running `vercel dev` in your project's root directory -- but you'll need to have a valid HTML page returned at the endpoints `/index.html` and `error.html`, otherwise the middleware function will throw a 404 error. If you'd like to see how I put together my minimal frontend, take a look at Cranberry's [GitHub repository]().

To test in a staging deployment, run `vercel deploy`. By deploying to a staging environment, you'll be able to see your Vercel Edge/Serverless function logs for debugging.

When you're happy with the way things are looking and working, you can deploy to production with `vercel deploy --prod`.


[^1]: I originally planned to use the shorter URL fragment syntax (`#`), but this approach only works for client-side manipulation, as URL fragments aren't sent to the server and are only used in the browser.
[^2]: Initially, I planned to make a kind of 'universal URI-as-URL wrapper' that would allow any URI which could be successfully constructed into a URL with the WebAPI's `new URL()` constructor to be used to create a redirect. However, I ran into a few issues. For one, this was an obvious security concern since potential bad actors could use the service to obfuscate malicious URLs/URIs. On a more practical note, Vercel's Edge Runtime seems to use different polyfills and a non-standard implementation of `Response.redirect()`, which made it impossible to redirect to some (valid) URIs. This led me down the rabbit hole of URL technical specifications, state-machine based parsing and validation, and a firm reminder of how much of a miracle internet interoperability is.