diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2344680 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +.wrangler diff --git a/CRUSH.md b/CRUSH.md new file mode 100644 index 0000000..178ffe2 --- /dev/null +++ b/CRUSH.md @@ -0,0 +1,96 @@ +# Project: Hop + +Cloudflare Workers project built with Bun. + +## Stack + +- **Runtime**: Cloudflare Workers +- **Package Manager**: Bun +- **Language**: TypeScript + +## Commands + +```sh +bun run dev # Start local dev server (wrangler dev) +bun run deploy # Deploy to Cloudflare +bun run types # Generate Cloudflare Workers types +bun test # Run tests +``` + +## Architecture + +- Worker entry point: `src/index.ts` +- Configuration: `wrangler.toml` +- Compatibility date: `2024-12-11` + +## Bun Preferences + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + + \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..27e9784 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,146 @@ +import { nanoid } from 'nanoid'; +import indexHTML from './index.html'; + +export default { + async fetch( + request: Request, + env: Env, + ctx: ExecutionContext, + ): Promise { + const url = new URL(request.url); + + if (url.pathname === '/' && request.method === 'GET') { + return new Response(indexHTML, { + headers: { 'Content-Type': 'text/html' }, + }); + } + + if (url.pathname === '/api/urls' && request.method === 'GET') { + const list = await env.HOP.list(); + const urls = await Promise.all( + list.keys.map(async (key) => ({ + shortCode: key.name, + url: await env.HOP.get(key.name), + created: key.metadata?.created || Date.now(), + })) + ); + return new Response(JSON.stringify(urls), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (url.pathname.startsWith('/api/urls/') && request.method === 'PUT') { + const shortCode = url.pathname.split('/')[3]; + try { + const { url: newUrl } = await request.json(); + + if (!newUrl) { + return new Response(JSON.stringify({ error: 'URL is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const existing = await env.HOP.get(shortCode); + if (!existing) { + return new Response(JSON.stringify({ error: 'Short URL not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const metadata = await env.HOP.getWithMetadata(shortCode); + await env.HOP.put(shortCode, newUrl, { + metadata: metadata.metadata || { created: Date.now() }, + }); + + return new Response(JSON.stringify({ shortCode, url: newUrl }), { + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + return new Response(JSON.stringify({ error: 'Invalid request' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + } + + if (url.pathname.startsWith('/api/urls/') && request.method === 'DELETE') { + const shortCode = url.pathname.split('/')[3]; + + const existing = await env.HOP.get(shortCode); + if (!existing) { + return new Response(JSON.stringify({ error: 'Short URL not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + await env.HOP.delete(shortCode); + + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (url.pathname === '/api/shorten' && request.method === 'POST') { + try { + const { url: targetUrl, slug } = await request.json(); + + if (!targetUrl) { + return new Response(JSON.stringify({ error: 'URL is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const shortCode = slug || generateShortCode(); + const existing = await env.HOP.get(shortCode); + + if (existing) { + return new Response(JSON.stringify({ error: 'Slug already exists' }), { + status: 409, + headers: { 'Content-Type': 'application/json' }, + }); + } + + await env.HOP.put(shortCode, targetUrl, { + metadata: { created: Date.now() }, + }); + + return new Response(JSON.stringify({ shortCode, url: targetUrl }), { + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + return new Response(JSON.stringify({ error: 'Invalid request' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + } + + const shortCode = url.pathname.slice(1); + if (shortCode && !shortCode.startsWith('api/')) { + const targetUrl = await env.HOP.get(shortCode); + + if (targetUrl) { + return Response.redirect(targetUrl, 302); + } + + return new Response('Short URL not found', { + status: 404, + headers: { 'Content-Type': 'text/plain' }, + }); + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; + +function generateShortCode(): string { + return nanoid(6); +} + +interface Env { + HOP: KVNamespace; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4defa7f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + + // Cloudflare Workers types + "types": ["@cloudflare/workers-types/2023-07-01"] + } +} diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..7249ee7 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,18 @@ +name = "hop" +main = "src/index.ts" +compatibility_date = "2024-12-11" + +[build] +command = "bun build src/index.html --outdir dist --minify" + +[observability] +enabled = true + +[[kv_namespaces]] +binding = "HOP" +id = "ae7cd39a622b466d876b8410d22d1397" + +[rules] +[[rules]] +type = "Text" +globs = ["**/*.html"]