blazing fast link redirects on cloudflare kv hop.dunkirk.sh/u/tacy

feat: add inital version

dunkirk.sh 67a24e97 2b924b72

verified
+36
.gitignore
··· 1 + # dependencies (bun install) 2 + node_modules 3 + 4 + # output 5 + out 6 + dist 7 + *.tgz 8 + 9 + # code coverage 10 + coverage 11 + *.lcov 12 + 13 + # logs 14 + logs 15 + _.log 16 + report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 17 + 18 + # dotenv environment variable files 19 + .env 20 + .env.development.local 21 + .env.test.local 22 + .env.production.local 23 + .env.local 24 + 25 + # caches 26 + .eslintcache 27 + .cache 28 + *.tsbuildinfo 29 + 30 + # IntelliJ based IDEs 31 + .idea 32 + 33 + # Finder (MacOS) folder config 34 + .DS_Store 35 + 36 + .wrangler
+96
CRUSH.md
··· 1 + # Project: Hop 2 + 3 + Cloudflare Workers project built with Bun. 4 + 5 + ## Stack 6 + 7 + - **Runtime**: Cloudflare Workers 8 + - **Package Manager**: Bun 9 + - **Language**: TypeScript 10 + 11 + ## Commands 12 + 13 + ```sh 14 + bun run dev # Start local dev server (wrangler dev) 15 + bun run deploy # Deploy to Cloudflare 16 + bun run types # Generate Cloudflare Workers types 17 + bun test # Run tests 18 + ``` 19 + 20 + ## Architecture 21 + 22 + - Worker entry point: `src/index.ts` 23 + - Configuration: `wrangler.toml` 24 + - Compatibility date: `2024-12-11` 25 + 26 + ## Bun Preferences 27 + 28 + Default to using Bun instead of Node.js. 29 + 30 + - Use `bun <file>` instead of `node <file>` or `ts-node <file>` 31 + - Use `bun test` instead of `jest` or `vitest` 32 + - Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild` 33 + - Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` 34 + - Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>` 35 + - Use `bunx <package> <command>` instead of `npx <package> <command>` 36 + - Bun automatically loads .env, so don't use dotenv. 37 + 38 + ## Bun APIs 39 + 40 + - `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`. 41 + - `bun:sqlite` for SQLite. Don't use `better-sqlite3`. 42 + - `Bun.redis` for Redis. Don't use `ioredis`. 43 + - `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`. 44 + - `WebSocket` is built-in. Don't use `ws`. 45 + - Prefer `Bun.file` over `node:fs`'s readFile/writeFile 46 + - Bun.$`ls` instead of execa. 47 + 48 + **Note**: When building for Cloudflare Workers, use Web Standard APIs (Request, Response, fetch) instead of Bun-specific APIs, as they won't be available in the Workers runtime. 49 + 50 + ## Cloudflare Workers Development 51 + 52 + Workers use the standard Fetch API handler: 53 + 54 + ```ts 55 + export default { 56 + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { 57 + return new Response('Hello World'); 58 + }, 59 + } satisfies ExportedHandler<Env>; 60 + ``` 61 + 62 + ### Environment Variables 63 + 64 + Define bindings in `wrangler.toml` and access via the `env` parameter: 65 + 66 + ```toml 67 + [vars] 68 + MY_VAR = "value" 69 + 70 + [[kv_namespaces]] 71 + binding = "MY_KV" 72 + id = "..." 73 + ``` 74 + 75 + ```ts 76 + interface Env { 77 + MY_VAR: string; 78 + MY_KV: KVNamespace; 79 + } 80 + ``` 81 + 82 + ### Testing 83 + 84 + Use `bun test` with Cloudflare Workers types: 85 + 86 + ```ts 87 + import { test, expect } from "bun:test"; 88 + 89 + test("worker responds", async () => { 90 + const request = new Request("http://localhost/"); 91 + const response = await worker.fetch(request, {}, {}); 92 + expect(response.status).toBe(200); 93 + }); 94 + ``` 95 + 96 + For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
+227
bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "hop", 7 + "dependencies": { 8 + "nanoid": "^5.1.6", 9 + }, 10 + "devDependencies": { 11 + "@cloudflare/workers-types": "^4.20251211.0", 12 + "@types/bun": "latest", 13 + "wrangler": "^4.54.0", 14 + }, 15 + "peerDependencies": { 16 + "typescript": "^5", 17 + }, 18 + }, 19 + }, 20 + "packages": { 21 + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.1", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-Nu8ahitGFFJztxUml9oD/DLb7Z28C8cd8F46IVQ7y5Btz575pvMY8AqZsXkX7Gds29eCKdMgIHjIvzskHgPSFg=="], 22 + 23 + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.7.13", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20251202.0" }, "optionalPeers": ["workerd"] }, "sha512-NulO1H8R/DzsJguLC0ndMuk4Ufv0KSlN+E54ay9rn9ZCQo0kpAPwwh3LhgpZ96a3Dr6L9LqW57M4CqC34iLOvw=="], 24 + 25 + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20251210.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Nn9X1moUDERA9xtFdCQ2XpQXgAS9pOjiCxvOT8sVx9UJLAiBLkfSCGbpsYdarODGybXCpjRlc77Yppuolvt7oQ=="], 26 + 27 + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20251210.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Mg8iYIZQFnbevq/ls9eW/eneWTk/EE13Pej1MwfkY5et0jVpdHnvOLywy/o+QtMJFef1AjsqXGULwAneYyBfHw=="], 28 + 29 + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20251210.0", "", { "os": "linux", "cpu": "x64" }, "sha512-kjC2fCZhZ2Gkm1biwk2qByAYpGguK5Gf5ic8owzSCUw0FOUfQxTZUT9Lp3gApxsfTLbbnLBrX/xzWjywH9QR4g=="], 30 + 31 + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20251210.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-2IB37nXi7PZVQLa1OCuO7/6pNxqisRSO8DmCQ5x/3sezI5op1vwOxAcb1osAnuVsVN9bbvpw70HJvhKruFJTuA=="], 32 + 33 + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20251210.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Uaz6/9XE+D6E7pCY4OvkCuJHu7HcSDzeGcCGY1HLhojXhHd7yL52c3yfiyJdS8hPatiAa0nn5qSI/42+aTdDSw=="], 34 + 35 + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251211.0", "", {}, "sha512-e87o6KbelCz7dnI5ngrGT2ca15vJZ+COb2eqJ52iDHmOaujyC6aYZ71e2vor8X6V9T6tcDElC5sAqPR93j09EA=="], 36 + 37 + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], 38 + 39 + "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], 40 + 41 + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="], 42 + 43 + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="], 44 + 45 + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ=="], 46 + 47 + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.0", "", { "os": "android", "cpu": "x64" }, "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q=="], 48 + 49 + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg=="], 50 + 51 + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g=="], 52 + 53 + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw=="], 54 + 55 + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g=="], 56 + 57 + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ=="], 58 + 59 + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ=="], 60 + 61 + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw=="], 62 + 63 + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg=="], 64 + 65 + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg=="], 66 + 67 + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA=="], 68 + 69 + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ=="], 70 + 71 + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w=="], 72 + 73 + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw=="], 74 + 75 + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w=="], 76 + 77 + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.0", "", { "os": "none", "cpu": "x64" }, "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA=="], 78 + 79 + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ=="], 80 + 81 + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A=="], 82 + 83 + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA=="], 84 + 85 + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA=="], 86 + 87 + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg=="], 88 + 89 + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ=="], 90 + 91 + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], 92 + 93 + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], 94 + 95 + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], 96 + 97 + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="], 98 + 99 + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="], 100 + 101 + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="], 102 + 103 + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="], 104 + 105 + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="], 106 + 107 + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="], 108 + 109 + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="], 110 + 111 + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="], 112 + 113 + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="], 114 + 115 + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="], 116 + 117 + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="], 118 + 119 + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="], 120 + 121 + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="], 122 + 123 + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="], 124 + 125 + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="], 126 + 127 + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="], 128 + 129 + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], 130 + 131 + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], 132 + 133 + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], 134 + 135 + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], 136 + 137 + "@poppinss/colors": ["@poppinss/colors@4.1.5", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw=="], 138 + 139 + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], 140 + 141 + "@poppinss/exception": ["@poppinss/exception@1.2.2", "", {}, "sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg=="], 142 + 143 + "@sindresorhus/is": ["@sindresorhus/is@7.1.1", "", {}, "sha512-rO92VvpgMc3kfiTjGT52LEtJ8Yc5kCWhZjLQ3LwlA4pSgPpQO7bVpYXParOD8Jwf+cVQECJo3yP/4I8aZtUQTQ=="], 144 + 145 + "@speed-highlight/core": ["@speed-highlight/core@1.2.12", "", {}, "sha512-uilwrK0Ygyri5dToHYdZSjcvpS2ZwX0w5aSt3GCEN9hrjxWCoeV4Z2DTXuxjwbntaLQIEEAlCeNQss5SoHvAEA=="], 146 + 147 + "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], 148 + 149 + "@types/node": ["@types/node@25.0.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew=="], 150 + 151 + "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], 152 + 153 + "acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="], 154 + 155 + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], 156 + 157 + "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], 158 + 159 + "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], 160 + 161 + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], 162 + 163 + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], 164 + 165 + "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], 166 + 167 + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], 168 + 169 + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], 170 + 171 + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], 172 + 173 + "esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], 174 + 175 + "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], 176 + 177 + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 178 + 179 + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], 180 + 181 + "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], 182 + 183 + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], 184 + 185 + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], 186 + 187 + "miniflare": ["miniflare@4.20251210.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20251210.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-k6kIoXwGVqlPZb0hcn+X7BmnK+8BjIIkusQPY22kCo2RaQJ/LzAjtxHQdGXerlHSnJyQivDQsL6BJHMpQfUFyw=="], 188 + 189 + "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], 190 + 191 + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], 192 + 193 + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], 194 + 195 + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], 196 + 197 + "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], 198 + 199 + "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], 200 + 201 + "stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="], 202 + 203 + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], 204 + 205 + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 206 + 207 + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 208 + 209 + "undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="], 210 + 211 + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 212 + 213 + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], 214 + 215 + "workerd": ["workerd@1.20251210.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20251210.0", "@cloudflare/workerd-darwin-arm64": "1.20251210.0", "@cloudflare/workerd-linux-64": "1.20251210.0", "@cloudflare/workerd-linux-arm64": "1.20251210.0", "@cloudflare/workerd-windows-64": "1.20251210.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-9MUUneP1BnRE9XAYi94FXxHmiLGbO75EHQZsgWqSiOXjoXSqJCw8aQbIEPxCy19TclEl/kHUFYce8ST2W+Qpjw=="], 216 + 217 + "wrangler": ["wrangler@4.54.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.1", "@cloudflare/unenv-preset": "2.7.13", "blake3-wasm": "2.1.5", "esbuild": "0.27.0", "miniflare": "4.20251210.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20251210.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20251210.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-bANFsjDwJLbprYoBK+hUDZsVbUv2SqJd8QvArLIcZk+fPq4h/Ohtj5vkKXD3k0s2bD1DXLk08D+hYmeNH+xC6A=="], 218 + 219 + "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], 220 + 221 + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], 222 + 223 + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], 224 + 225 + "zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], 226 + } 227 + }
+22
package.json
··· 1 + { 2 + "name": "hop", 3 + "module": "index.ts", 4 + "type": "module", 5 + "private": true, 6 + "scripts": { 7 + "dev": "wrangler dev", 8 + "deploy": "wrangler deploy", 9 + "types": "wrangler types" 10 + }, 11 + "devDependencies": { 12 + "@cloudflare/workers-types": "^4.20251211.0", 13 + "@types/bun": "latest", 14 + "wrangler": "^4.54.0" 15 + }, 16 + "peerDependencies": { 17 + "typescript": "^5" 18 + }, 19 + "dependencies": { 20 + "nanoid": "^5.1.6" 21 + } 22 + }
+561
src/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>hop</title> 8 + <link rel="icon" 9 + href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛎️</text></svg>"> 10 + <style> 11 + * { 12 + margin: 0; 13 + padding: 0; 14 + box-sizing: border-box; 15 + } 16 + 17 + body { 18 + font-family: 'Courier New', monospace; 19 + background: #1d1e18; 20 + color: #d9fff5; 21 + min-height: 100vh; 22 + padding: 40px 20px; 23 + } 24 + 25 + .container { 26 + max-width: 900px; 27 + margin: 0 auto; 28 + } 29 + 30 + h1 { 31 + font-size: 2.5rem; 32 + margin-bottom: 0.5rem; 33 + font-weight: 700; 34 + background: linear-gradient(135deg, #6b8f71, #aad2ba, #b9f5d8); 35 + -webkit-background-clip: text; 36 + -webkit-text-fill-color: transparent; 37 + background-clip: text; 38 + letter-spacing: -1px; 39 + } 40 + 41 + .subtitle { 42 + color: #6b8f71; 43 + margin-bottom: 24px; 44 + font-size: 13px; 45 + font-family: monospace; 46 + } 47 + 48 + .shortcut { 49 + color: #6b8f71; 50 + font-size: 11px; 51 + margin-bottom: 16px; 52 + font-family: monospace; 53 + } 54 + 55 + .shortcut kbd { 56 + background: #2a2b25; 57 + padding: 2px 6px; 58 + border-radius: 3px; 59 + border: 1px solid #3a3b35; 60 + font-size: 11px; 61 + } 62 + 63 + .form-section { 64 + margin-bottom: 32px; 65 + } 66 + 67 + .form-row { 68 + display: flex; 69 + gap: 8px; 70 + align-items: center; 71 + } 72 + 73 + .form-row input[type="url"] { 74 + flex: 1; 75 + margin: 0; 76 + } 77 + 78 + .form-row input[type="text"] { 79 + width: 140px; 80 + margin: 0; 81 + } 82 + 83 + .form-row button { 84 + width: auto; 85 + padding: 12px 20px; 86 + margin: 0; 87 + } 88 + 89 + input { 90 + width: 100%; 91 + padding: 12px 14px; 92 + background: #2a2b25; 93 + border: 1px solid #3a3b35; 94 + border-radius: 4px; 95 + color: #d9fff5; 96 + font-size: 14px; 97 + font-family: 'Courier New', monospace; 98 + margin-bottom: 8px; 99 + transition: border-color 0.15s; 100 + } 101 + 102 + input:focus { 103 + outline: none; 104 + border-color: #6b8f71; 105 + background: #2d2e28; 106 + } 107 + 108 + input::placeholder { 109 + color: #4a4b45; 110 + } 111 + 112 + button { 113 + width: 100%; 114 + padding: 12px; 115 + background: #6b8f71; 116 + color: #1d1e18; 117 + border: none; 118 + border-radius: 4px; 119 + font-size: 14px; 120 + font-weight: 700; 121 + cursor: pointer; 122 + font-family: 'Courier New', monospace; 123 + margin-top: 4px; 124 + transition: background 0.15s; 125 + } 126 + 127 + button:hover { 128 + background: #7a9e80; 129 + } 130 + 131 + button:active { 132 + background: #5a7f61; 133 + } 134 + 135 + button:disabled { 136 + opacity: 0.4; 137 + cursor: not-allowed; 138 + } 139 + 140 + button:focus { 141 + outline: 2px solid #aad2ba; 142 + outline-offset: 2px; 143 + } 144 + 145 + .result { 146 + margin-top: 16px; 147 + padding: 12px 14px; 148 + background: #2a2b25; 149 + border: 1px solid #3a3b35; 150 + border-radius: 4px; 151 + display: none; 152 + font-family: 'Courier New', monospace; 153 + } 154 + 155 + .result.show { 156 + display: block; 157 + } 158 + 159 + .result.error { 160 + border-color: #c75146; 161 + color: #ff7b6d; 162 + } 163 + 164 + .short-url { 165 + display: flex; 166 + gap: 8px; 167 + margin-top: 8px; 168 + } 169 + 170 + .short-url input { 171 + margin: 0; 172 + flex: 1; 173 + color: #b9f5d8; 174 + font-weight: 700; 175 + } 176 + 177 + .copy-btn { 178 + width: auto; 179 + padding: 12px 16px; 180 + margin: 0; 181 + background: #aad2ba; 182 + } 183 + 184 + .copy-btn:hover { 185 + background: #b9f5d8; 186 + } 187 + 188 + .table-section { 189 + margin-top: 48px; 190 + } 191 + 192 + .table-header { 193 + color: #6b8f71; 194 + font-size: 13px; 195 + margin-bottom: 12px; 196 + display: flex; 197 + justify-content: space-between; 198 + align-items: center; 199 + } 200 + 201 + table { 202 + width: 100%; 203 + border-collapse: collapse; 204 + background: #2a2b25; 205 + border: 1px solid #3a3b35; 206 + border-radius: 4px; 207 + overflow: hidden; 208 + } 209 + 210 + th, 211 + td { 212 + padding: 12px 14px; 213 + text-align: left; 214 + border-bottom: 1px solid #3a3b35; 215 + font-size: 13px; 216 + } 217 + 218 + th { 219 + background: #252620; 220 + color: #aad2ba; 221 + font-weight: 700; 222 + text-transform: uppercase; 223 + font-size: 11px; 224 + letter-spacing: 0.5px; 225 + } 226 + 227 + tr:last-child td { 228 + border-bottom: none; 229 + } 230 + 231 + tr:hover { 232 + background: #2d2e28; 233 + } 234 + 235 + .url-cell { 236 + max-width: 400px; 237 + overflow: hidden; 238 + text-overflow: ellipsis; 239 + white-space: nowrap; 240 + color: #d9fff5; 241 + } 242 + 243 + .short-cell { 244 + color: #b9f5d8; 245 + font-weight: 700; 246 + } 247 + 248 + .short-cell a { 249 + color: #b9f5d8; 250 + text-decoration: none; 251 + } 252 + 253 + .short-cell a:hover { 254 + text-decoration: underline; 255 + } 256 + 257 + .date-cell { 258 + color: #6b8f71; 259 + font-size: 12px; 260 + } 261 + 262 + .actions-cell { 263 + text-align: right; 264 + } 265 + 266 + .btn-small { 267 + padding: 6px 12px; 268 + font-size: 11px; 269 + background: #3a3b35; 270 + color: #aad2ba; 271 + border: none; 272 + border-radius: 3px; 273 + cursor: pointer; 274 + font-family: 'Courier New', monospace; 275 + transition: background 0.15s; 276 + margin-left: 4px; 277 + } 278 + 279 + .btn-small:hover { 280 + background: #4a4b45; 281 + } 282 + 283 + .btn-delete { 284 + color: #ff7b6d; 285 + } 286 + 287 + .btn-delete:hover { 288 + background: #c75146; 289 + color: #1d1e18; 290 + } 291 + 292 + .empty { 293 + text-align: center; 294 + padding: 40px; 295 + color: #6b8f71; 296 + } 297 + 298 + .loading { 299 + text-align: center; 300 + padding: 20px; 301 + color: #6b8f71; 302 + } 303 + 304 + .edit-form { 305 + display: flex; 306 + gap: 8px; 307 + align-items: center; 308 + } 309 + 310 + .edit-form input { 311 + margin: 0; 312 + padding: 8px 10px; 313 + font-size: 12px; 314 + } 315 + 316 + .edit-form button { 317 + margin: 0; 318 + padding: 8px 12px; 319 + font-size: 11px; 320 + width: auto; 321 + } 322 + </style> 323 + </head> 324 + 325 + <body> 326 + <div class="container"> 327 + <h1>🛎️ hop</h1> 328 + <p class="subtitle">// fast url shortener</p> 329 + 330 + <div class="form-section"> 331 + <div class="shortcut"> 332 + <kbd>tab</kbd> navigate • <kbd>enter</kbd> submit • <kbd>esc</kbd> clear • <kbd>cmd+k</kbd> focus 333 + </div> 334 + <form id="shortenForm"> 335 + <div class="form-row"> 336 + <input type="url" id="url" placeholder="https://example.com/very/long/url" required autofocus> 337 + <input type="text" id="slug" placeholder="slug" pattern="[a-zA-Z0-9-_]+"> 338 + <button type="submit">shorten</button> 339 + </div> 340 + </form> 341 + <div id="result" class="result"> 342 + <div id="resultMessage"></div> 343 + <div id="shortUrlContainer"></div> 344 + </div> 345 + </div> 346 + 347 + <div class="table-section"> 348 + <div class="table-header"> 349 + <span>// existing urls</span> 350 + </div> 351 + <div id="urlsTable"> 352 + <div class="loading">loading...</div> 353 + </div> 354 + </div> 355 + </div> 356 + 357 + <script> 358 + const form = document.getElementById('shortenForm'); 359 + const result = document.getElementById('result'); 360 + const resultMessage = document.getElementById('resultMessage'); 361 + const shortUrlContainer = document.getElementById('shortUrlContainer'); 362 + const urlInput = document.getElementById('url'); 363 + const urlsTable = document.getElementById('urlsTable'); 364 + 365 + document.addEventListener('keydown', (e) => { 366 + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { 367 + e.preventDefault(); 368 + urlInput.focus(); 369 + urlInput.select(); 370 + } 371 + if (e.key === 'Escape') { 372 + form.reset(); 373 + result.classList.remove('show', 'error'); 374 + urlInput.focus(); 375 + } 376 + }); 377 + 378 + async function loadUrls() { 379 + try { 380 + const response = await fetch('/api/urls'); 381 + const urls = await response.json(); 382 + 383 + if (urls.length === 0) { 384 + urlsTable.innerHTML = '<div class="empty">no urls yet</div>'; 385 + return; 386 + } 387 + 388 + urls.sort((a, b) => b.created - a.created); 389 + 390 + urlsTable.innerHTML = ` 391 + <table> 392 + <thead> 393 + <tr> 394 + <th>short</th> 395 + <th>url</th> 396 + <th>created</th> 397 + <th></th> 398 + </tr> 399 + </thead> 400 + <tbody> 401 + ${urls.map(item => ` 402 + <tr data-short="${item.shortCode}"> 403 + <td class="short-cell"><a href="/${item.shortCode}" target="_blank">/${item.shortCode}</a></td> 404 + <td class="url-cell" title="${item.url}">${item.url}</td> 405 + <td class="date-cell">${new Date(item.created).toLocaleDateString()}</td> 406 + <td class="actions-cell"> 407 + <button class="btn-small" onclick="editUrl('${item.shortCode}', '${item.url.replace(/'/g, "\\'")}')">edit</button> 408 + <button class="btn-small btn-delete" onclick="deleteUrl('${item.shortCode}')">delete</button> 409 + </td> 410 + </tr> 411 + `).join('')} 412 + </tbody> 413 + </table> 414 + `; 415 + } catch (error) { 416 + urlsTable.innerHTML = '<div class="empty">failed to load urls</div>'; 417 + } 418 + } 419 + 420 + loadUrls(); 421 + 422 + form.addEventListener('submit', async (e) => { 423 + e.preventDefault(); 424 + 425 + const url = urlInput.value; 426 + const slug = document.getElementById('slug').value; 427 + const submitBtn = form.querySelector('button'); 428 + 429 + submitBtn.disabled = true; 430 + submitBtn.textContent = 'shortening...'; 431 + result.classList.remove('show', 'error'); 432 + 433 + try { 434 + const response = await fetch('/api/shorten', { 435 + method: 'POST', 436 + headers: {'Content-Type': 'application/json'}, 437 + body: JSON.stringify({url, slug: slug || undefined}), 438 + }); 439 + 440 + const data = await response.json(); 441 + 442 + if (!response.ok) { 443 + throw new Error(data.error || 'Failed to shorten URL'); 444 + } 445 + 446 + const shortUrl = window.location.origin + '/' + data.shortCode; 447 + 448 + resultMessage.textContent = ''; 449 + shortUrlContainer.innerHTML = ` 450 + <div class="short-url"> 451 + <input type="text" value="${shortUrl}" readonly id="shortUrlInput"> 452 + <button class="copy-btn" onclick="copyToClipboard()">copy</button> 453 + </div> 454 + `; 455 + result.classList.add('show'); 456 + 457 + const shortUrlInput = document.getElementById('shortUrlInput'); 458 + shortUrlInput.select(); 459 + shortUrlInput.focus(); 460 + 461 + form.reset(); 462 + loadUrls(); 463 + } catch (error) { 464 + resultMessage.textContent = error.message; 465 + shortUrlContainer.innerHTML = ''; 466 + result.classList.add('show', 'error'); 467 + } finally { 468 + submitBtn.disabled = false; 469 + submitBtn.textContent = 'shorten'; 470 + } 471 + }); 472 + 473 + window.copyToClipboard = async () => { 474 + const input = document.getElementById('shortUrlInput'); 475 + try { 476 + await navigator.clipboard.writeText(input.value); 477 + const btn = document.querySelector('.copy-btn'); 478 + const originalText = btn.textContent; 479 + btn.textContent = 'copied'; 480 + setTimeout(() => btn.textContent = originalText, 2000); 481 + } catch (err) { 482 + input.select(); 483 + document.execCommand('copy'); 484 + } 485 + }; 486 + 487 + window.editUrl = (shortCode, currentUrl) => { 488 + const row = document.querySelector(`tr[data-short="${shortCode}"]`); 489 + const urlCell = row.querySelector('.url-cell'); 490 + 491 + urlCell.innerHTML = ` 492 + <div class="edit-form"> 493 + <input type="url" value="${currentUrl}" id="editInput-${shortCode}" required> 494 + <button onclick="saveEdit('${shortCode}')">save</button> 495 + <button onclick="cancelEdit('${shortCode}', '${currentUrl.replace(/'/g, "\\'")}')">cancel</button> 496 + </div> 497 + `; 498 + 499 + const input = document.getElementById(`editInput-${shortCode}`); 500 + input.focus(); 501 + input.addEventListener('keydown', (e) => { 502 + if (e.key === 'Enter') { 503 + e.preventDefault(); 504 + saveEdit(shortCode); 505 + } else if (e.key === 'Escape') { 506 + cancelEdit(shortCode, currentUrl); 507 + } 508 + }); 509 + }; 510 + 511 + window.saveEdit = async (shortCode) => { 512 + const input = document.getElementById(`editInput-${shortCode}`); 513 + const newUrl = input.value; 514 + 515 + try { 516 + const response = await fetch(`/api/urls/${shortCode}`, { 517 + method: 'PUT', 518 + headers: {'Content-Type': 'application/json'}, 519 + body: JSON.stringify({url: newUrl}), 520 + }); 521 + 522 + if (!response.ok) { 523 + const data = await response.json(); 524 + throw new Error(data.error || 'Failed to update URL'); 525 + } 526 + 527 + loadUrls(); 528 + } catch (error) { 529 + alert(error.message); 530 + } 531 + }; 532 + 533 + window.cancelEdit = (shortCode, originalUrl) => { 534 + const row = document.querySelector(`tr[data-short="${shortCode}"]`); 535 + const urlCell = row.querySelector('.url-cell'); 536 + urlCell.innerHTML = originalUrl; 537 + urlCell.title = originalUrl; 538 + }; 539 + 540 + window.deleteUrl = async (shortCode) => { 541 + if (!confirm(`Delete /${shortCode}?`)) return; 542 + 543 + try { 544 + const response = await fetch(`/api/urls/${shortCode}`, { 545 + method: 'DELETE', 546 + }); 547 + 548 + if (!response.ok) { 549 + const data = await response.json(); 550 + throw new Error(data.error || 'Failed to delete URL'); 551 + } 552 + 553 + loadUrls(); 554 + } catch (error) { 555 + alert(error.message); 556 + } 557 + }; 558 + </script> 559 + </body> 560 + 561 + </html>
+146
src/index.ts
··· 1 + import { nanoid } from 'nanoid'; 2 + import indexHTML from './index.html'; 3 + 4 + export default { 5 + async fetch( 6 + request: Request, 7 + env: Env, 8 + ctx: ExecutionContext, 9 + ): Promise<Response> { 10 + const url = new URL(request.url); 11 + 12 + if (url.pathname === '/' && request.method === 'GET') { 13 + return new Response(indexHTML, { 14 + headers: { 'Content-Type': 'text/html' }, 15 + }); 16 + } 17 + 18 + if (url.pathname === '/api/urls' && request.method === 'GET') { 19 + const list = await env.HOP.list(); 20 + const urls = await Promise.all( 21 + list.keys.map(async (key) => ({ 22 + shortCode: key.name, 23 + url: await env.HOP.get(key.name), 24 + created: key.metadata?.created || Date.now(), 25 + })) 26 + ); 27 + return new Response(JSON.stringify(urls), { 28 + headers: { 'Content-Type': 'application/json' }, 29 + }); 30 + } 31 + 32 + if (url.pathname.startsWith('/api/urls/') && request.method === 'PUT') { 33 + const shortCode = url.pathname.split('/')[3]; 34 + try { 35 + const { url: newUrl } = await request.json(); 36 + 37 + if (!newUrl) { 38 + return new Response(JSON.stringify({ error: 'URL is required' }), { 39 + status: 400, 40 + headers: { 'Content-Type': 'application/json' }, 41 + }); 42 + } 43 + 44 + const existing = await env.HOP.get(shortCode); 45 + if (!existing) { 46 + return new Response(JSON.stringify({ error: 'Short URL not found' }), { 47 + status: 404, 48 + headers: { 'Content-Type': 'application/json' }, 49 + }); 50 + } 51 + 52 + const metadata = await env.HOP.getWithMetadata(shortCode); 53 + await env.HOP.put(shortCode, newUrl, { 54 + metadata: metadata.metadata || { created: Date.now() }, 55 + }); 56 + 57 + return new Response(JSON.stringify({ shortCode, url: newUrl }), { 58 + headers: { 'Content-Type': 'application/json' }, 59 + }); 60 + } catch (error) { 61 + return new Response(JSON.stringify({ error: 'Invalid request' }), { 62 + status: 400, 63 + headers: { 'Content-Type': 'application/json' }, 64 + }); 65 + } 66 + } 67 + 68 + if (url.pathname.startsWith('/api/urls/') && request.method === 'DELETE') { 69 + const shortCode = url.pathname.split('/')[3]; 70 + 71 + const existing = await env.HOP.get(shortCode); 72 + if (!existing) { 73 + return new Response(JSON.stringify({ error: 'Short URL not found' }), { 74 + status: 404, 75 + headers: { 'Content-Type': 'application/json' }, 76 + }); 77 + } 78 + 79 + await env.HOP.delete(shortCode); 80 + 81 + return new Response(JSON.stringify({ success: true }), { 82 + headers: { 'Content-Type': 'application/json' }, 83 + }); 84 + } 85 + 86 + if (url.pathname === '/api/shorten' && request.method === 'POST') { 87 + try { 88 + const { url: targetUrl, slug } = await request.json(); 89 + 90 + if (!targetUrl) { 91 + return new Response(JSON.stringify({ error: 'URL is required' }), { 92 + status: 400, 93 + headers: { 'Content-Type': 'application/json' }, 94 + }); 95 + } 96 + 97 + const shortCode = slug || generateShortCode(); 98 + const existing = await env.HOP.get(shortCode); 99 + 100 + if (existing) { 101 + return new Response(JSON.stringify({ error: 'Slug already exists' }), { 102 + status: 409, 103 + headers: { 'Content-Type': 'application/json' }, 104 + }); 105 + } 106 + 107 + await env.HOP.put(shortCode, targetUrl, { 108 + metadata: { created: Date.now() }, 109 + }); 110 + 111 + return new Response(JSON.stringify({ shortCode, url: targetUrl }), { 112 + headers: { 'Content-Type': 'application/json' }, 113 + }); 114 + } catch (error) { 115 + return new Response(JSON.stringify({ error: 'Invalid request' }), { 116 + status: 400, 117 + headers: { 'Content-Type': 'application/json' }, 118 + }); 119 + } 120 + } 121 + 122 + const shortCode = url.pathname.slice(1); 123 + if (shortCode && !shortCode.startsWith('api/')) { 124 + const targetUrl = await env.HOP.get(shortCode); 125 + 126 + if (targetUrl) { 127 + return Response.redirect(targetUrl, 302); 128 + } 129 + 130 + return new Response('Short URL not found', { 131 + status: 404, 132 + headers: { 'Content-Type': 'text/plain' }, 133 + }); 134 + } 135 + 136 + return new Response('Not found', { status: 404 }); 137 + }, 138 + } satisfies ExportedHandler<Env>; 139 + 140 + function generateShortCode(): string { 141 + return nanoid(6); 142 + } 143 + 144 + interface Env { 145 + HOP: KVNamespace; 146 + }
+32
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + // Environment setup & latest features 4 + "lib": ["ESNext"], 5 + "target": "ESNext", 6 + "module": "Preserve", 7 + "moduleDetection": "force", 8 + "jsx": "react-jsx", 9 + "allowJs": true, 10 + 11 + // Bundler mode 12 + "moduleResolution": "bundler", 13 + "allowImportingTsExtensions": true, 14 + "verbatimModuleSyntax": true, 15 + "noEmit": true, 16 + 17 + // Best practices 18 + "strict": true, 19 + "skipLibCheck": true, 20 + "noFallthroughCasesInSwitch": true, 21 + "noUncheckedIndexedAccess": true, 22 + "noImplicitOverride": true, 23 + 24 + // Some stricter flags (disabled by default) 25 + "noUnusedLocals": false, 26 + "noUnusedParameters": false, 27 + "noPropertyAccessFromIndexSignature": false, 28 + 29 + // Cloudflare Workers types 30 + "types": ["@cloudflare/workers-types/2023-07-01"] 31 + } 32 + }
+18
wrangler.toml
··· 1 + name = "hop" 2 + main = "src/index.ts" 3 + compatibility_date = "2024-12-11" 4 + 5 + [build] 6 + command = "bun build src/index.html --outdir dist --minify" 7 + 8 + [observability] 9 + enabled = true 10 + 11 + [[kv_namespaces]] 12 + binding = "HOP" 13 + id = "ae7cd39a622b466d876b8410d22d1397" 14 + 15 + [rules] 16 + [[rules]] 17 + type = "Text" 18 + globs = ["**/*.html"]