DEV Community

Cover image for Cranberry: The Cure for Your URI
Kevin Cole
Kevin Cole

Posted on • Originally published at kevin-cole.com

Cranberry: The Cure for Your URI

Everyone is familiar with the term URL. Even if you couldn't recall off the top of your head that this abbreviation stands for Uniform Resource Locator, 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 Uniform Resource Identifiers (URIs), a much broader syntax 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.

<!-- 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}
Enter fullscreen mode Exit fullscreen mode

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.

That's always bothered me, especially because for my work I make heavy use of the research and citation manager Zotero, 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

Cranberry leverages the advantages of so-called '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 to redirect requests containing a Zotero URI. I decided to use my favorite lightweight web development framework, Astro, 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

# Create a new project with npm
npm create astro@latest
Enter fullscreen mode Exit fullscreen mode

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.

# This will install the adapter and update your astro config file
npx astro add vercel
Enter fullscreen mode Exit fullscreen mode

If you prefer to manually install dependencies:

  1. Install via npm
npm install @astrojs/vercel
Enter fullscreen mode Exit fullscreen mode
  1. Update your astro.config.mjs:
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel/serverless';

export default defineConfig({
    output: 'server',
    adapter: vercel(),
});
Enter fullscreen mode Exit fullscreen mode

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

npm install -g vercel
Enter fullscreen mode Exit fullscreen mode

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 → show project homepage.
  • Case Two: URI is included, and uses the Zotero protocol → immediately redirect to the provided URI.
  • Case Three: URI is included, but is invalid → 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.

// 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,
        },
    });
}
Enter fullscreen mode Exit fullscreen mode

Let's take a look at the contents of ./src/utils/isValidZoteroURI.ts:

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
}
Enter fullscreen mode Exit fullscreen mode

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. 

Top comments (0)