A tiny, dependency-free redirect handler built on the standard Request / Response APIs such as Cloudflare Workers https://blog.thms.uk/2026/09/link-shortener-cloudflare-workers/
  • TypeScript 100%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Michael Thomas bb898142fc
All checks were successful
Test / test (push) Successful in 15s
Merge pull request 'Add Renovate' (#3) from renovate into main
Reviewed-on: #3
2026-10-09 15:51:06 +00:00
.forgejo/workflows Add Renovate 2026-10-09 16:49:46 +01:00
src Allow publishing to my own registry 2026-10-09 10:01:42 +01:00
test tidy up tests 2026-10-09 12:18:59 +01:00
.gitignore Allow publishing to my own registry 2026-10-09 10:01:42 +01:00
.nvmrc add CI 2026-10-08 13:00:25 +01:00
.oxlintrc.json remove now superflous rule 2026-10-08 13:23:11 +01:00
.prettierrc inital commit 2026-10-08 12:55:58 +01:00
LICENSE add readme and license 2026-10-08 13:32:42 +01:00
package-lock.json 1.0.1 2026-10-08 21:51:22 +01:00
package.json Allow publishing to my own registry 2026-10-09 10:01:42 +01:00
README.md Allow publishing to my own registry 2026-10-09 10:01:42 +01:00
renovate.json Add Renovate 2026-10-09 16:49:46 +01:00
tsconfig.build.json Allow publishing to my own registry 2026-10-09 10:01:42 +01:00
tsconfig.json Add Deno instructions 2026-10-09 08:16:14 +01:00
vitest.config.ts inital commit 2026-10-08 12:55:58 +01:00

link-shortener

A tiny, dependency-free redirect handler built on the standard Request / Response APIs. You give it a map of slugs to URLs and it gives you back an object with a fetch(request) method, so it runs anywhere that speaks the Fetch API: Cloudflare Workers, Deno, Bun, and Node.js with a small adapter.

Install

The package is published to my Forgejo registry. Point the scope at it once, in your project's (or your home directory's) .npmrc:

@michael.thms.uk:registry=https://code.thms.uk/api/packages/michael/npm/

Then install it:

npm install @michael.thms.uk/link-shortener

Usage

Cloudflare Workers

import { createShortener } from "@michael.thms.uk/link-shortener";

export default createShortener({
  redirects: {
    blog: "https://example.com/blog",
    gh: "https://github.com/github",
  },
  rootRedirect: "https://example.com/",
}) satisfies ExportedHandler<Env>;

Deno

Add the package with deno add npm:@michael.thms.uk/link-shortener (it reads the same .npmrc). Then run with deno run --allow-net main.ts:

import { createShortener } from "@michael.thms.uk/link-shortener";

const shortener = createShortener({
  redirects: {
    blog: "https://example.com/blog",
    gh: "https://github.com/github",
  },
  rootRedirect: "https://example.com/",
});

Deno.serve((request) => shortener.fetch(request));

Node.js

Node's built-in node:http server doesn't take a fetch handler, so convert between its request/response objects and the Fetch API:

import { createServer } from "node:http";
import { createShortener } from "@michael.thms.uk/link-shortener";

const shortener = createShortener({
  redirects: {
    blog: "https://example.com/blog",
    gh: "https://github.com/github",
  },
  rootRedirect: "https://example.com/",
});

createServer(async (req, res) => {
  const url = new URL(
    req.url ?? "/",
    `http://${req.headers.host ?? "localhost"}`,
  );
  const response = await shortener.fetch(
    new Request(url, { method: req.method }),
  );
  res.writeHead(response.status, Object.fromEntries(response.headers));
  res.end(await response.text());
}).listen(3000);

The shortener only looks at the URL and method, so the adapter doesn't forward request headers or bodies.

Bun

import { createShortener } from "@michael.thms.uk/link-shortener";

Bun.serve(
  createShortener({
    redirects: {
      blog: "https://example.com/blog",
      gh: "https://github.com/github",
    },
    rootRedirect: "https://example.com/",
  }),
);

Options

Option Type Default Description
redirects Record<string, string> (required) Map of slug → absolute target URL.
rootRedirect string none: / is a 404 Where requests to / are sent.
status 301 | 302 | 307 | 308 302 HTTP status used for redirects.
acceptTrailingSlash boolean false Treat /slug/ the same as /slug.
stripQueryParams boolean false Drop the incoming query string instead of passing it on to the target.
notFoundPage string built-in page HTML body returned (with status 404) for unknown slugs.

Behaviour

  • Slugs are URL-decoded before lookup, so /caf%C3%A9 matches a café key. Paths with malformed percent-encoding return 404.
  • Slugs are case-sensitive: foo and FOO are different entries.
  • Targets must be absolute URLs. Invalid targets (e.g. /bar) are skipped with a console.error when the shortener is created, and requests for them return 404. An invalid rootRedirect is treated as unset.
  • Query strings are passed through to the target by default. Incoming parameters override parameters of the same name already on the target URL; if a key is repeated in the request, the last value wins.
  • Only exact matches redirect. Nested paths such as /foo/bar are looked up as the slug foo/bar.
  • All HTTP methods are redirected, not only GET.
  • Redirects carry no Cache-Control header. Remember that browsers cache 301 and 308 responses aggressively, so prefer 302 / 307 while links might still change.

Development

Requires Node.js 26 or newer (see .nvmrc).

npm install
npm run check   # typecheck, lint, format and tests

Individual steps are available as npm run typecheck, npm run lint, npm run format and npm test.