├── .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 |
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 |
--------------------------------------------------------------------------------