+36
.gitignore
+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
+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
+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
+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
+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
+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
+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
+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"]