メインコンテンツまでスキップ

Workers URL Shortener

A URL shortener running on Cloudflare Workers with KV storage.

Location: examples/workers-url-shortener

Features

  • Cloudflare Workers deployment
  • KV storage for persistence
  • URL validation
  • Hit counter for analytics

Running

cd examples/workers-url-shortener
pnpm install
pnpm dev

Key Code

Worker entry point (src/worker.ts):

import { onCloudflareWorkers } from '@zeltjs/adapter-cloudflare-workers';

import { app } from './app';

const cfApp = await onCloudflareWorkers(app);

export default {
  fetch: cfApp.fetch,
};

Controller with KV access (src/url/url.controller.ts):

import {
  Controller,
  Get,
  HTTPException,
  inject,
  Post,
  pathParam,
  requestContext,
  response,
} from '@zeltjs/core';
import { validated } from '@zeltjs/validator-valibot';
import type { Context } from 'hono';
import * as v from 'valibot';

import type { Env } from '../env';

import { KVService } from './kv.service';
import type { UrlRecord } from './types';

type RequestContext = Context<Env>;

const ShortenBody = v.object({
  url: v.pipe(v.string(), v.url()),
});

const generateCode = (): string => {
  const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
  let code = '';
  for (let i = 0; i < 6; i++) {
    code += chars[Math.floor(Math.random() * chars.length)];
  }
  return code;
};

@Controller('/')
export class UrlController {
  constructor(private kv = inject(KVService)) {}

  @Post('/shorten')
  async shorten(
    body = validated(ShortenBody),
    res = response(),
    ctx = requestContext() as RequestContext,
  ) {
    const code = generateCode();
    const record: UrlRecord = {
      url: body.url,
      createdAt: Date.now(),
      hits: 0,
    };
    await this.kv.set(ctx, code, record);
    return res.json({ code, shortUrl: `/${code}` }, 201);
  }

  @Get('/stats/:code')
  async stats(code = pathParam('code'), ctx = requestContext() as RequestContext) {
    const record = await this.kv.get(ctx, code);
    if (!record) {
      throw new HTTPException(404, { message: 'URL not found' });
    }
    return { code, url: record.url, hits: record.hits, createdAt: record.createdAt };
  }

  @Get('/:code')
  async redirect(
    code = pathParam('code'),
    res = response(),
    ctx = requestContext() as RequestContext,
  ) {
    const record = await this.kv.get(ctx, code);
    if (!record) {
      throw new HTTPException(404, { message: 'URL not found' });
    }
    await this.kv.incrementHits(ctx, code);
    return res.redirect(record.url, 302);
  }
}

KV service (src/url/kv.service.ts):

import { Injectable } from '@zeltjs/core';
import type { Context } from 'hono';

import type { Env } from '../env';

import type { UrlRecord } from './types';

type RequestContext = Context<Env>;

const getKV = (c: RequestContext): KVNamespace => c.env.URLS;

@Injectable()
export class KVService {
  async get(c: RequestContext, code: string): Promise<UrlRecord | null> {
    const data = await getKV(c).get(`url:${code}`, 'json');
    return data as UrlRecord | null;
  }

  async set(c: RequestContext, code: string, record: UrlRecord): Promise<void> {
    await getKV(c).put(`url:${code}`, JSON.stringify(record));
  }

  async incrementHits(c: RequestContext, code: string): Promise<void> {
    const record = await this.get(c, code);
    if (record) {
      record.hits += 1;
      await this.set(c, code, record);
    }
  }
}

API Endpoints

MethodPathDescription
POST/shortenCreate a short URL
GET/:codeRedirect to original URL
GET/stats/:codeGet URL statistics

wrangler.toml

name = "url-shortener"
main = "src/worker.ts"
compatibility_date = "2024-01-01"

[[kv_namespaces]]
binding = "URLS"
id = "your-kv-namespace-id"