├── .gitignore ├── .prettierignore ├── functions ├── [slug].ts ├── create.ts └── utils │ └── generate-random-slug.ts ├── index.html ├── package-lock.json ├── package.json ├── src ├── App.tsx ├── components │ ├── Button.tsx │ ├── Input.tsx │ ├── Logo.tsx │ ├── ShortUrlDisplay.tsx │ └── UrlForm.tsx ├── favicon.ico ├── logo192.png ├── logo512.png ├── main.tsx ├── reset.css └── vite-env.d.ts ├── tsconfig.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /package-lock.json 2 | -------------------------------------------------------------------------------- /functions/[slug].ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | export const onRequest: PagesFunction<{ URLS: KVNamespace }, "slug"> = async ({ 4 | params: { slug }, 5 | env: { URLS }, 6 | }) => { 7 | const url = await URLS.get(slug as string); 8 | 9 | if (!url) { 10 | return new Response(null, { status: 404 }); 11 | } 12 | 13 | return new Response(null, { 14 | status: 302, 15 | headers: { 16 | location: url, 17 | }, 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /functions/create.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import generateRandomSlug from "./utils/generate-random-slug"; 4 | 5 | export const onRequestPost: PagesFunction<{ URLS: KVNamespace }> = async ({ 6 | request, 7 | env: { URLS }, 8 | }) => { 9 | const url = await request.text(); 10 | 11 | try { 12 | new URL(url); 13 | } catch (error) { 14 | return new Response(null, { status: 400 }); 15 | } 16 | 17 | const slug = await (async () => { 18 | const existingSlug = await URLS.get(url); 19 | 20 | if (existingSlug) { 21 | return existingSlug; 22 | } 23 | 24 | const newSlug = generateRandomSlug(); 25 | 26 | await Promise.all([URLS.put(url, newSlug), URLS.put(newSlug, url)]); 27 | 28 | return newSlug; 29 | })(); 30 | 31 | return new Response(slug); 32 | }; 33 | 34 | const handler: ExportedHandler<{ URLS: KVNamespace }> = { 35 | async fetch(request, { URLS }, ctx) { 36 | const requestUrl = new URL(request.url); 37 | 38 | if (requestUrl.pathname === "/create") { 39 | const headers = { 40 | "access-control-allow-origin": "https://shortr-cf.pages.dev", 41 | }; 42 | 43 | if (request.method !== "POST") { 44 | return new Response(null, { status: 405, headers }); 45 | } 46 | 47 | const body = await request.text(); 48 | 49 | if (!body) { 50 | return new Response(null, { status: 400, headers }); 51 | } 52 | 53 | try { 54 | new URL(body); 55 | } catch (error) { 56 | return new Response(null, { status: 400, headers }); 57 | } 58 | 59 | const existingSlug = await URLS.get(body); 60 | 61 | if (existingSlug) { 62 | return new Response(existingSlug, { headers }); 63 | } 64 | 65 | const slug = generateRandomSlug(); 66 | 67 | await Promise.all([URLS.put(body, slug), URLS.put(slug, body)]); 68 | 69 | return new Response(slug, { headers }); 70 | } 71 | 72 | const slugMatch = /^\/([^/]+)$/.exec(requestUrl.pathname); 73 | 74 | if (!slugMatch) { 75 | return new Response(null, { status: 404 }); 76 | } 77 | 78 | const [, slug] = slugMatch; 79 | 80 | const url = await URLS.get(slug); 81 | 82 | if (!url) { 83 | return new Response(null, { status: 404 }); 84 | } 85 | 86 | return new Response(null, { 87 | status: 302, 88 | headers: { 89 | location: url, 90 | }, 91 | }); 92 | }, 93 | }; 94 | 95 | export default handler; 96 | -------------------------------------------------------------------------------- /functions/utils/generate-random-slug.ts: -------------------------------------------------------------------------------- 1 | export default function generateRandomSlug() { 2 | const randomBytes = new Uint8Array(2); 3 | crypto.getRandomValues(randomBytes); 4 | 5 | const slugBuffer = new ArrayBuffer(6); 6 | 7 | // Create a view into the byte array 8 | const view = new DataView(slugBuffer); 9 | 10 | // Fill the first 4 bytes with the current timestamp in seconds 11 | view.setUint32(0, Math.floor(Date.now() / 1000)); 12 | 13 | view.setUint8(4, randomBytes[0]); 14 | view.setUint8(5, randomBytes[1]); 15 | 16 | // Encode the byte array 17 | return base64urlEncode(new Uint8Array(slugBuffer)); 18 | } 19 | 20 | const base64url = [ 21 | "A", 22 | "B", 23 | "C", 24 | "D", 25 | "E", 26 | "F", 27 | "G", 28 | "H", 29 | "I", 30 | "J", 31 | "K", 32 | "L", 33 | "M", 34 | "N", 35 | "O", 36 | "P", 37 | "Q", 38 | "R", 39 | "S", 40 | "T", 41 | "U", 42 | "V", 43 | "W", 44 | "X", 45 | "Y", 46 | "Z", 47 | "a", 48 | "b", 49 | "c", 50 | "d", 51 | "e", 52 | "f", 53 | "g", 54 | "h", 55 | "i", 56 | "j", 57 | "k", 58 | "l", 59 | "m", 60 | "n", 61 | "o", 62 | "p", 63 | "q", 64 | "r", 65 | "s", 66 | "t", 67 | "u", 68 | "v", 69 | "w", 70 | "x", 71 | "y", 72 | "z", 73 | "0", 74 | "1", 75 | "2", 76 | "3", 77 | "4", 78 | "5", 79 | "6", 80 | "7", 81 | "8", 82 | "9", 83 | "-", 84 | "_", 85 | ]; 86 | 87 | function base64urlEncode(data) { 88 | let result = "", 89 | i; 90 | 91 | const l = data.length; 92 | 93 | for (i = 2; i < l; i += 3) { 94 | result += base64url[data[i - 2] >> 2]; 95 | result += base64url[((data[i - 2] & 0x03) << 4) | (data[i - 1] >> 4)]; 96 | result += base64url[((data[i - 1] & 0x0f) << 2) | (data[i] >> 6)]; 97 | result += base64url[data[i] & 0x3f]; 98 | } 99 | 100 | if (i === l + 1) { 101 | // 1 octet yet to write 102 | result += base64url[data[i - 2] >> 2]; 103 | result += base64url[(data[i - 2] & 0x03) << 4]; 104 | result += "=="; 105 | } 106 | 107 | if (i === l) { 108 | // 2 octets yet to write 109 | result += base64url[data[i - 2] >> 2]; 110 | result += base64url[((data[i - 2] & 0x03) << 4) | (data[i - 1] >> 4)]; 111 | result += base64url[(data[i - 1] & 0x0f) << 2]; 112 | result += "="; 113 | } 114 | 115 | return result; 116 | } 117 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Shortr 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shortr-cf", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "npx wrangler pages dev --kv URLS -- npm run client-dev", 6 | "client-dev": "vite", 7 | "build": "vite build", 8 | "serve": "vite preview" 9 | }, 10 | "dependencies": { 11 | "modern-normalize": "^1.1.0", 12 | "react": "^17.0.0", 13 | "react-dom": "^17.0.0" 14 | }, 15 | "devDependencies": { 16 | "@cloudflare/workers-types": "^3.2.0", 17 | "@iconify-json/heroicons-outline": "^1.0.2", 18 | "@types/react": "^17.0.0", 19 | "@types/react-dom": "^17.0.0", 20 | "@vitejs/plugin-react": "^1.0.0", 21 | "prettier": "^2.4.1", 22 | "typescript": "^4.3.2", 23 | "unocss": "^0.7.4", 24 | "vite": "^2.6.4", 25 | "wrangler": "^0.0.0-fsw-beta.5" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useReducer } from "react"; 2 | 3 | import Logo from "./components/Logo"; 4 | import UrlForm from "./components/UrlForm"; 5 | import ShortUrlDisplay from "./components/ShortUrlDisplay"; 6 | 7 | interface InitialAppState { 8 | state: "initial"; 9 | url: string; 10 | loading?: boolean; 11 | error: Error | null; 12 | } 13 | 14 | interface FinalAppState { 15 | state: "final"; 16 | shortUrl: string; 17 | copied: boolean; 18 | } 19 | 20 | type AppState = InitialAppState | FinalAppState; 21 | 22 | type AppAction = 23 | | { type: "SET_URL"; url: string } 24 | | { type: "URL_SUBMITTED" } 25 | | { type: "GOT_SLUG"; shortUrl: string } 26 | | { type: "CREATE_FAILED"; error: Error } 27 | | { type: "BACK_CLICKED" }; 28 | 29 | const initialState: InitialAppState = { 30 | state: "initial", 31 | url: "", 32 | error: null, 33 | }; 34 | 35 | function App() { 36 | const [state, dispatch] = useReducer((state: AppState, action: AppAction) => { 37 | switch (action.type) { 38 | case "SET_URL": 39 | if (state.state !== "initial") { 40 | throw new Error( 41 | `Cannot handle action ${action.type} while in state '${state.state}'` 42 | ); 43 | } 44 | 45 | return { 46 | ...state, 47 | url: action.url, 48 | error: null, 49 | }; 50 | case "URL_SUBMITTED": 51 | if (state.state !== "initial") { 52 | throw new Error( 53 | `Cannot handle action ${action.type} while in state '${state.state}'` 54 | ); 55 | } 56 | 57 | return { 58 | ...state, 59 | error: null, 60 | loading: true, 61 | }; 62 | 63 | case "GOT_SLUG": 64 | if (state.state !== "initial") { 65 | throw new Error( 66 | `Cannot handle action ${action.type} while in state '${state.state}'` 67 | ); 68 | } 69 | 70 | return { 71 | state: "final", 72 | shortUrl: action.shortUrl, 73 | copied: false, 74 | } as const; 75 | case "CREATE_FAILED": 76 | if (state.state !== "initial") { 77 | throw new Error( 78 | `Cannot handle action ${action.type} while in state '${state.state}'` 79 | ); 80 | } 81 | 82 | return { 83 | ...state, 84 | loading: false, 85 | error: action.error, 86 | }; 87 | case "BACK_CLICKED": 88 | if (state.state !== "final") { 89 | throw new Error( 90 | `Cannot handle action ${action.type} while in state '${state.state}'` 91 | ); 92 | } 93 | 94 | return initialState; 95 | } 96 | }, initialState); 97 | 98 | const [darkMode, setDarkMode] = useState( 99 | () => 100 | localStorage.theme === "dark" || 101 | (!("theme" in localStorage) && 102 | "matchMedia" in window && 103 | window.matchMedia("(prefers-color-scheme: dark)").matches) 104 | ); 105 | 106 | useEffect(() => { 107 | if (darkMode) { 108 | document.documentElement.classList.add("dark"); 109 | } else { 110 | document.documentElement.classList.remove("dark"); 111 | } 112 | }, [darkMode]); 113 | 114 | return ( 115 |
116 |
166 | ); 167 | } 168 | 169 | export default App; 170 | -------------------------------------------------------------------------------- /src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export type Ref = HTMLButtonElement; 4 | 5 | const Button = React.forwardRef>( 6 | ({ className, disabled, ...props }, ref) => { 7 | return ( 8 | 32 | ); 33 | } 34 | ); 35 | 36 | export default Button; 37 | -------------------------------------------------------------------------------- /src/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export type Ref = HTMLInputElement; 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, ...props }, ref) => ( 7 | 17 | {props.children} 18 | 19 | ) 20 | ); 21 | 22 | export default Input; 23 | -------------------------------------------------------------------------------- /src/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | export default function Logo() { 2 | return ( 3 |
4 | 5 | Shortr 6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/components/ShortUrlDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | 3 | import Input from "./Input"; 4 | import Button from "./Button"; 5 | 6 | export default function ShortUrlDisplay({ 7 | shortUrl, 8 | onBackClick, 9 | }: { 10 | shortUrl: string; 11 | onBackClick: () => void; 12 | }) { 13 | const [copied, setCopied] = useState(false); 14 | 15 | useEffect(() => { 16 | if (copied) { 17 | const timeout = setTimeout(() => { 18 | setCopied(false); 19 | }, 2000); 20 | 21 | return () => clearTimeout(timeout); 22 | } 23 | }, [copied]); 24 | 25 | return ( 26 | <> 27 |

Your short url:

28 | 29 |
30 | { 36 | (e.target as HTMLInputElement).setSelectionRange( 37 | 0, 38 | shortUrl.length 39 | ); 40 | }} 41 | /> 42 | 56 |
57 | 58 | 67 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /src/components/UrlForm.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from "react"; 2 | import Input from "./Input"; 3 | import Button from "./Button"; 4 | 5 | async function createSlug(url: string): Promise { 6 | const response = await fetch("/create", { 7 | method: "POST", 8 | body: url, 9 | }); 10 | 11 | if (!response.ok) { 12 | throw new Error(`Bad Response: ${response.status} ${response.statusText}`); 13 | } 14 | 15 | const slug = await response.text(); 16 | 17 | return slug; 18 | } 19 | 20 | export default function UrlForm({ 21 | url, 22 | onCreateFulfilled, 23 | onCreateRejected, 24 | onUrlChange, 25 | }: { 26 | url: string; 27 | onCreateFulfilled: (slug: string) => void; 28 | onCreateRejected: (error: Error) => void; 29 | onUrlChange: (url: string) => void; 30 | }) { 31 | const [showError, setShowError] = useState(false); 32 | const isUrlValid = useMemo(() => { 33 | try { 34 | new URL(url); 35 | 36 | return true; 37 | } catch { 38 | return false; 39 | } 40 | }, [url]); 41 | 42 | return ( 43 | <> 44 |
{ 47 | e.preventDefault(); 48 | 49 | setShowError(true); 50 | 51 | if (!isUrlValid) { 52 | return; 53 | } 54 | 55 | createSlug(url).then(onCreateFulfilled, onCreateRejected); 56 | }} 57 | > 58 | { 63 | setShowError(false); 64 | 65 | onUrlChange(e.target.value); 66 | }} 67 | placeholder="Enter your long url..." 68 | autoFocus 69 | aria-invalid={showError && !isUrlValid ? true : undefined} 70 | /> 71 | 72 | 73 |
74 | 75 | {showError && !isUrlValid && ( 76 |

77 | Invalid url 78 |

79 | )} 80 | 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twhitbeck/shortr-cf/43c3e0b4741e4bf3a85bedb8a58af1e265ad3181/src/favicon.ico -------------------------------------------------------------------------------- /src/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twhitbeck/shortr-cf/43c3e0b4741e4bf3a85bedb8a58af1e265ad3181/src/logo192.png -------------------------------------------------------------------------------- /src/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twhitbeck/shortr-cf/43c3e0b4741e4bf3a85bedb8a58af1e265ad3181/src/logo512.png -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import App from "./App"; 5 | 6 | import "./reset.css"; 7 | import "uno.css"; 8 | 9 | ReactDOM.render( 10 | 11 | 12 | , 13 | document.getElementById("root") 14 | ); 15 | -------------------------------------------------------------------------------- /src/reset.css: -------------------------------------------------------------------------------- 1 | @import url("modern-normalize/modern-normalize.css"); 2 | 3 | button, 4 | input, 5 | optgroup, 6 | select, 7 | textarea { 8 | color: inherit; 9 | } 10 | 11 | button, 12 | [role="button"] { 13 | cursor: pointer; 14 | } 15 | 16 | button, 17 | [type="button"], 18 | [type="reset"], 19 | [type="submit"] { 20 | -webkit-appearance: button; 21 | background-color: transparent; 22 | background-image: none; 23 | } 24 | 25 | button { 26 | border-width: 0; 27 | } 28 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": false, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "types": [] 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import unocss from "unocss/vite"; 4 | import { presetUno, presetIcons } from "unocss"; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | react(), 10 | unocss({ 11 | presets: [presetUno(), presetIcons()], 12 | }), 13 | ], 14 | }); 15 | --------------------------------------------------------------------------------