Add unified deployment option with single Workers service

Torey Heinz committed Aug 24, 2025
commit 8dba374cc33a9fc304d3800f8d63373ce9d40377
Showing 12 changed files with 867 additions and 9 deletions
DEPLOYMENT-OPTIONS.md +134 -0
@@ @@ -0,0 +1,134 @@
+ # QRurl Deployment Options
+
+ ## Option 1: Unified Service (Recommended) ✅
+
+ **Single Cloudflare Workers service** serving both API and frontend.
+
+ ### Benefits:
+ - No CORS configuration needed
+ - Single domain (qrurl.us)
+ - Simpler deployment
+ - Lower operational complexity
+ - Single service to monitor
+
+ ### Development:
+ ```bash
+ # Using Foreman
+ foreman start -f Procfile.unified
+
+ # Or using npm scripts
+ npm run dev:unified
+ ```
+
+ ### Production Deployment:
+ ```bash
+ # Automated deployment
+ ./scripts/deploy-unified.sh
+
+ # Or manual
+ npm run deploy:unified
+ ```
+
+ ### Configuration Files:
+ - `wrangler.unified.toml` - Production config
+ - `wrangler.unified.dev.toml` - Development config
+ - `Procfile.unified` - Foreman config for unified dev
+
+ ---
+
+ ## Option 2: Separate Services (Current Setup)
+
+ **Cloudflare Workers** for API + **Cloudflare Pages** for frontend.
+
+ ### Benefits:
+ - Frontend served from CDN edge (Pages)
+ - Independent scaling
+ - Separate deployment cycles
+ - Traditional architecture
+
+ ### Development:
+ ```bash
+ # Using Foreman (default)
+ foreman start
+
+ # Or manually
+ npm run dev # Backend
+ cd frontend && npm run dev # Frontend
+ ```
+
+ ### Production Deployment:
+ ```bash
+ # Via GitHub Actions (automatic on push to main)
+ git push origin main
+
+ # Or manual
+ npm run deploy # Backend
+ cd frontend && npm run build && deploy # Frontend
+ ```
+
+ ### Configuration Files:
+ - `wrangler.toml` - Development config
+ - `wrangler.production.toml` - Production config
+ - `Procfile` - Foreman config for separate dev
+
+ ---
+
+ ## Comparison
+
+ | Feature | Unified Service | Separate Services |
+ |---------|----------------|-------------------|
+ | **Domains** | 1 (qrurl.us) | 2 (qrurl.us + api.qrurl.us) |
+ | **CORS** | Not needed | Required |
+ | **Deployment** | Single command | Two deployments |
+ | **Complexity** | Lower | Higher |
+ | **Frontend CDN** | R2 + Workers | Pages CDN |
+ | **Cost** | Single Workers | Workers + Pages |
+ | **GitHub Actions** | Simpler | More complex |
+
+ ---
+
+ ## Migration Path
+
+ ### From Separate → Unified:
+
+ 1. **Deploy unified service:**
+ ```bash
+ ./scripts/deploy-unified.sh
+ ```
+
+ 2. **Update DNS:**
+ - Point `qrurl.us` to Workers service
+ - Remove `api.qrurl.us` subdomain
+
+ 3. **Clean up:**
+ - Delete Pages project
+ - Remove CORS configuration
+
+ ### From Unified → Separate:
+
+ 1. **Deploy frontend to Pages:**
+ ```bash
+ cd frontend
+ npm run build
+ wrangler pages deploy dist --project-name qrurl-frontend
+ ```
+
+ 2. **Deploy backend with CORS:**
+ ```bash
+ wrangler deploy --config wrangler.production.toml
+ ```
+
+ 3. **Update DNS:**
+ - Point `qrurl.us` to Pages
+ - Add `api.qrurl.us` to Workers
+
+ ---
+
+ ## Recommendation
+
+ **Use the Unified Service approach** unless you specifically need:
+ - Separate deployment cycles for frontend/backend
+ - Different scaling characteristics
+ - Multi-region frontend CDN (Pages advantage)
+
+ The unified approach significantly reduces complexity while maintaining all functionality.
\ No newline at end of file
DEPLOYMENT-UNIFIED.md +164 -0
@@ @@ -0,0 +1,164 @@
+ # Unified Deployment Guide for QRurl
+
+ This guide uses a **single Cloudflare Workers service** to serve both the API and frontend, eliminating the need for separate Pages deployment.
+
+ ## Benefits of Unified Deployment
+
+ - ✅ Single service to manage
+ - ✅ No CORS configuration needed
+ - ✅ Simpler deployment process
+ - ✅ Single domain (qrurl.us) for everything
+ - ✅ Lower complexity
+
+ ## Prerequisites
+
+ 1. **Cloudflare Account** with Workers, R2, and D1 enabled
+ 2. **Domain** (qrurl.us) added to Cloudflare
+ 3. **Node.js** and npm installed locally
+
+ ## Initial Setup
+
+ ### 1. Create R2 Bucket for Frontend Assets
+
+ ```bash
+ # Create bucket for static frontend files
+ wrangler r2 bucket create qrurl-frontend-assets
+ ```
+
+ ### 2. Configure Secrets
+
+ ```bash
+ # Generate and set JWT secret
+ openssl rand -base64 32 | wrangler secret put JWT_SECRET --name qrurl-unified
+
+ # Set Postmark API key
+ echo "your-postmark-token" | wrangler secret put EMAIL_API_KEY --name qrurl-unified
+
+ # Set authorized emails
+ echo "email1@example.com,email2@example.com" | wrangler secret put AUTHORIZED_EMAILS --name qrurl-unified
+ ```
+
+ ### 3. Initialize Database
+
+ ```bash
+ # Create database if not exists
+ wrangler d1 create qrurl-db
+
+ # Run migrations
+ wrangler d1 execute qrurl-db --file=./schema/schema.sql
+ ```
+
+ ## Deployment Process
+
+ ### Automatic Deployment
+
+ Use the unified deployment script:
+
+ ```bash
+ ./scripts/deploy-unified.sh
+ ```
+
+ This script will:
+ 1. Build the frontend
+ 2. Upload frontend assets to R2
+ 3. Deploy the Workers service
+
+ ### Manual Deployment
+
+ #### 1. Build Frontend
+ ```bash
+ cd frontend
+ npm run build --mode=unified
+ ```
+
+ #### 2. Upload Frontend to R2
+ ```bash
+ cd dist
+ for file in $(find . -type f); do
+ wrangler r2 object put qrurl-frontend-assets/${file#./} --file=$file
+ done
+ cd ../..
+ ```
+
+ #### 3. Deploy Workers
+ ```bash
+ wrangler deploy --config wrangler.unified.toml
+ ```
+
+ ## Configure Custom Domain
+
+ 1. Go to Cloudflare Dashboard → Workers & Pages → qrurl-unified
+ 2. Settings → Triggers → Custom Domains
+ 3. Add `qrurl.us` (this will handle both frontend and API)
+
+ ## URL Structure
+
+ With unified deployment, everything runs on the same domain:
+
+ - `https://qrurl.us/` - Frontend homepage
+ - `https://qrurl.us/dashboard` - Dashboard (client-side routing)
+ - `https://qrurl.us/api/*` - API endpoints
+ - `https://qrurl.us/abc123` - Short link redirects
+ - `https://qrurl.us/health` - Health check endpoint
+
+ ## Environment Variables
+
+ The unified deployment uses these environment variables:
+
+ ```toml
+ # wrangler.unified.toml
+ [vars]
+ BACKEND_URL = "https://qrurl.us" # No separate frontend URL needed
+ EMAIL_FROM = "noreply@qrurl.us"
+ ```
+
+ ## Testing
+
+ After deployment, test:
+
+ 1. **Frontend**: Visit https://qrurl.us
+ 2. **API Health**: https://qrurl.us/health
+ 3. **Authentication**: Request magic link login
+ 4. **Short Links**: Create and test a short link
+ 5. **QR Codes**: Generate QR code with logo
+
+ ## Rollback
+
+ To rollback to a previous version:
+
+ ```bash
+ # List deployments
+ wrangler deployments list --name qrurl-unified
+
+ # Rollback to specific version
+ wrangler rollback --name qrurl-unified --message "Rolling back"
+ ```
+
+ ## Monitoring
+
+ View metrics in Cloudflare Dashboard:
+ - Workers & Pages → qrurl-unified → Analytics
+ - R2 → qrurl-frontend-assets (frontend storage)
+ - R2 → qrurl-storage (logo storage)
+ - D1 → qrurl-db (database)
+
+ ## Troubleshooting
+
+ ### Frontend Not Loading
+ - Check R2 bucket has frontend files: `wrangler r2 object list qrurl-frontend-assets`
+ - Verify Workers binding: `FRONTEND_ASSETS` in wrangler.unified.toml
+
+ ### API Errors
+ - Check logs: `wrangler tail --name qrurl-unified`
+ - Verify secrets are set: `wrangler secret list --name qrurl-unified`
+
+ ### Database Issues
+ - Check D1 binding in wrangler.unified.toml
+ - Verify migrations ran: `wrangler d1 execute qrurl-db --command "SELECT * FROM schema_version"`
+
+ ## Cost Optimization
+
+ The unified approach reduces costs:
+ - Single Workers instance (instead of Workers + Pages)
+ - R2 storage is very cost-effective for static assets
+ - No cross-service data transfer fees
\ No newline at end of file
Procfile +8 -1
@@ @@ -1,2 +1,9 @@
+ # Development with Foreman
+ # Run: foreman start
+
+ # Default: Separate services (current setup)
backend: npm run dev
- frontend: cd frontend && npm run dev
\ No newline at end of file
+ frontend: cd frontend && npm run dev
+
+ # For unified development, use:
+ # foreman start -f Procfile.unified
\ No newline at end of file
Procfile.unified +8 -0
@@ @@ -0,0 +1,8 @@
+ # Unified development - preparing for single Workers service deployment
+ # Run: foreman start -f Procfile.unified
+
+ # Backend with unified config (will serve both API + static files in production)
+ backend: wrangler dev --config wrangler.unified.dev.toml
+
+ # Frontend dev server (for hot reload during development)
+ frontend: cd frontend && npm run dev
\ No newline at end of file
package-lock.json +297 -2
@@ @@ -12,6 +12,7 @@
"postmark": "^4.0.5"
},
"devDependencies": {
+ "concurrently": "^9.2.0",
"wrangler": "^4.32.0"
}
},
@@ @@ -1058,6 +1059,32 @@
"node": ">=0.4.0"
}
},
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ @@ -1095,6 +1122,51 @@
"node": ">= 0.4"
}
},
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chalk/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ @@ -1152,6 +1224,48 @@
"node": ">= 0.8"
}
},
+ "node_modules/concurrently": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.0.tgz",
+ "integrity": "sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^4.1.2",
+ "lodash": "^4.17.21",
+ "rxjs": "^7.8.1",
+ "shell-quote": "^1.8.1",
+ "supports-color": "^8.1.1",
+ "tree-kill": "^1.2.2",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "conc": "dist/bin/concurrently.js",
+ "concurrently": "dist/bin/concurrently.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
+ }
+ },
+ "node_modules/concurrently/node_modules/supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
@@ @@ -1202,6 +1316,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/error-stack-parser-es": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz",
@@ @@ -1298,6 +1419,16 @@
"@esbuild/win32-x64": "0.25.4"
}
},
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/exit-hook": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz",
@@ @@ -1378,6 +1509,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ @@ -1434,6 +1575,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ @@ -1480,6 +1631,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ @@ -1490,6 +1651,13 @@
"node": ">=6"
}
},
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ @@ -1596,6 +1764,26 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rxjs": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
@@ @@ -1649,6 +1837,19 @@
"@img/sharp-win32-x64": "0.33.5"
}
},
+ "node_modules/shell-quote": {
+ "version": "1.8.3",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
+ "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/simple-swizzle": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
@@ @@ -1670,6 +1871,34 @@
"npm": ">=6"
}
},
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/supports-color": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.0.tgz",
@@ @@ -1683,13 +1912,22 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "tree-kill": "cli.js"
+ }
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
- "license": "0BSD",
- "optional": true
+ "license": "0BSD"
},
"node_modules/ufo": {
"version": "1.6.1",
@@ @@ -1778,6 +2016,24 @@
}
}
},
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
"node_modules/ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
@@ @@ -1800,6 +2056,45 @@
}
}
},
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/youch": {
"version": "4.1.0-beta.10",
"resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz",
package.json +5 -0
@@ @@ -4,7 +4,11 @@
"main": "index.js",
"scripts": {
"dev": "wrangler dev",
+ "dev:unified": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
+ "dev:backend": "wrangler dev --config wrangler.unified.toml --local",
+ "dev:frontend": "cd frontend && npm run dev",
"deploy": "wrangler deploy",
+ "deploy:unified": "./scripts/deploy-unified.sh",
"test": "echo \"Error: no test specified\" && exit 1",
"db:init": "wrangler d1 execute qrurl-db --local --file=./schema/schema.sql",
"db:init:remote": "wrangler d1 execute qrurl-db --file=./schema/schema.sql"
@@ @@ -14,6 +18,7 @@
"license": "ISC",
"description": "",
"devDependencies": {
+ "concurrently": "^9.2.0",
"wrangler": "^4.32.0"
},
"dependencies": {
scripts/deploy-unified.sh +63 -0
@@ @@ -0,0 +1,63 @@
+ #!/bin/bash
+
+ # Unified deployment script for single Workers service
+ # This deploys both API and frontend to a single Workers instance
+
+ set -e
+
+ echo "======================================"
+ echo "QRurl Unified Deployment"
+ echo "======================================"
+ echo ""
+
+ # Build frontend
+ echo "Building frontend..."
+ cd frontend
+ npm run build
+
+ # Upload frontend assets to R2
+ echo ""
+ echo "Uploading frontend to R2..."
+ cd dist
+
+ # Upload each file to R2 bucket
+ for file in $(find . -type f); do
+ # Remove leading ./ from path
+ key=${file#./}
+
+ # Determine content type
+ case "${file##*.}" in
+ html) content_type="text/html" ;;
+ js) content_type="application/javascript" ;;
+ css) content_type="text/css" ;;
+ json) content_type="application/json" ;;
+ png) content_type="image/png" ;;
+ jpg|jpeg) content_type="image/jpeg" ;;
+ svg) content_type="image/svg+xml" ;;
+ ico) content_type="image/x-icon" ;;
+ woff) content_type="font/woff" ;;
+ woff2) content_type="font/woff2" ;;
+ *) content_type="application/octet-stream" ;;
+ esac
+
+ echo "Uploading $key..."
+ wrangler r2 object put qrurl-frontend-assets/$key --file=$file --content-type=$content_type
+ done
+
+ cd ../..
+
+ # Deploy Workers with unified service
+ echo ""
+ echo "Deploying Workers service..."
+ wrangler deploy --config wrangler.unified.toml
+
+ echo ""
+ echo "======================================"
+ echo "✅ Deployment Complete!"
+ echo "======================================"
+ echo ""
+ echo "Your app is available at:"
+ echo "- https://qrurl.us (frontend + API)"
+ echo "- https://qrurl.us/api/* (API endpoints)"
+ echo "- https://qrurl.us/[slug] (short links)"
+ echo ""
\ No newline at end of file
src/index.js +22 -5
@@ @@ -6,6 +6,7 @@ import { logoRoutes } from './routes/logo';
import { applyMiddleware } from './middleware';
import { handleError } from './utils/errors';
import { addCorsHeaders } from './middleware/cors';
+ import { serveStaticAsset } from './lib/static';
export default {
async fetch(request, env, ctx) {
@@ @@ -45,11 +46,27 @@ export default {
applyMiddleware(request, env, ctx, apiRoutes)
);
- // Redirect handler (must be last)
- router.get('/:slug', handleRedirect);
-
- // Default route
- router.all('*', () => {
+ // Try to serve static assets first (for frontend)
+ router.all('*', async (request, env, ctx) => {
+ const url = new URL(request.url);
+
+ // Skip API routes and health check
+ if (url.pathname.startsWith('/api') || url.pathname === '/health') {
+ return null; // Let it fall through to 404
+ }
+
+ // Try serving static asset
+ const staticResponse = await serveStaticAsset(request, env, ctx);
+ if (staticResponse) {
+ return staticResponse;
+ }
+
+ // Try URL redirect (for short links)
+ if (url.pathname.length > 1 && !url.pathname.includes('.')) {
+ return handleRedirect(request, env, ctx);
+ }
+
+ // Default 404 for everything else
return new Response(JSON.stringify({
error: 'Not Found',
message: 'The requested resource was not found'
static.js b/src/lib/static.js +71 -0
@@ @@ -0,0 +1,71 @@
+ // Static file serving for frontend
+ import { getAssetFromKV } from '@cloudflare/kv-asset-handler';
+
+ const MIME_TYPES = {
+ 'html': 'text/html',
+ 'js': 'application/javascript',
+ 'css': 'text/css',
+ 'png': 'image/png',
+ 'jpg': 'image/jpeg',
+ 'jpeg': 'image/jpeg',
+ 'svg': 'image/svg+xml',
+ 'ico': 'image/x-icon',
+ 'json': 'application/json',
+ 'woff': 'font/woff',
+ 'woff2': 'font/woff2',
+ 'ttf': 'font/ttf',
+ 'eot': 'font/eot'
+ };
+
+ export async function serveStaticAsset(request, env, ctx) {
+ const url = new URL(request.url);
+ let pathname = url.pathname;
+
+ // Default to index.html for root
+ if (pathname === '/' || pathname === '') {
+ pathname = '/index.html';
+ }
+
+ // For client-side routing, serve index.html for non-asset paths
+ const ext = pathname.split('.').pop();
+ if (!MIME_TYPES[ext] && !pathname.startsWith('/api') && !pathname.startsWith('/health')) {
+ pathname = '/index.html';
+ }
+
+ try {
+ // Try to get the asset from R2 bucket (FRONTEND_ASSETS)
+ if (env.FRONTEND_ASSETS) {
+ const object = await env.FRONTEND_ASSETS.get(pathname.slice(1)); // Remove leading slash
+
+ if (object) {
+ const headers = new Headers();
+ headers.set('Content-Type', MIME_TYPES[ext] || 'application/octet-stream');
+ headers.set('Cache-Control', 'public, max-age=3600');
+
+ return new Response(object.body, {
+ status: 200,
+ headers
+ });
+ }
+ }
+
+ // Fallback to KV if configured (for Cloudflare Pages migration)
+ if (env.__STATIC_CONTENT) {
+ return await getAssetFromKV(
+ {
+ request,
+ waitUntil: ctx.waitUntil.bind(ctx),
+ },
+ {
+ ASSET_NAMESPACE: env.__STATIC_CONTENT,
+ ASSET_MANIFEST: JSON.parse(env.__STATIC_CONTENT_MANIFEST || '{}'),
+ }
+ );
+ }
+
+ return null;
+ } catch (e) {
+ console.error('Static asset error:', e);
+ return null;
+ }
+ }
\ No newline at end of file
src/middleware/cors.js +7 -1
@@ @@ -16,8 +16,14 @@ export function cors(request, env) {
}
export function addCorsHeaders(response, env) {
+ // If no FRONTEND_URL is set, we're using unified deployment (no CORS needed)
+ if (!env.FRONTEND_URL) {
+ return response;
+ }
+
+ // Add CORS headers for separate frontend deployment
const headers = new Headers(response.headers);
- headers.set('Access-Control-Allow-Origin', env.FRONTEND_URL || '*');
+ headers.set('Access-Control-Allow-Origin', env.FRONTEND_URL);
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
wrangler.unified.dev.toml +46 -0
@@ @@ -0,0 +1,46 @@
+ name = "qrurl-unified"
+ main = "src/index.js"
+ compatibility_date = "2024-12-01"
+ compatibility_flags = ["nodejs_compat"]
+ account_id = "b253e6fbfd2f7757cadd0386de5bde3f"
+
+ # D1 Database
+ [[d1_databases]]
+ binding = "DB"
+ database_name = "qrurl-db"
+ database_id = "17eb6fdb-19da-4ed7-931c-a4cdef281f8c"
+
+ # R2 Storage for logos
+ [[r2_buckets]]
+ binding = "STORAGE"
+ bucket_name = "qrurl-storage"
+
+ # R2 Storage for frontend assets (in dev, we use the frontend dev server)
+ [[r2_buckets]]
+ binding = "FRONTEND_ASSETS"
+ bucket_name = "qrurl-frontend-assets"
+
+ # KV Namespace for caching
+ [[kv_namespaces]]
+ binding = "CACHE"
+ id = "1cacb0f1b44b4324b62c1bc010ff15f5"
+ preview_id = "981af79732c84684b54fbbe10aa81f6e"
+
+ # Environment Variables for local development
+ [vars]
+ # In dev, frontend is served by Vite dev server
+ FRONTEND_URL = "http://localhost:3000"
+ BACKEND_URL = "http://localhost:8787"
+ EMAIL_FROM = "noreply@qrurl.us"
+
+ # Development settings
+ [dev]
+ port = 8787
+ local_protocol = "http"
+
+ # Rate limiting
+ [[unsafe.bindings]]
+ name = "RATE_LIMITER"
+ type = "ratelimit"
+ namespace_id = "1"
+ simple = { limit = 10, period = 60 }
\ No newline at end of file
wrangler.unified.toml +42 -0
@@ @@ -0,0 +1,42 @@
+ name = "qrurl-unified"
+ main = "src/index.js"
+ compatibility_date = "2024-12-01"
+ compatibility_flags = ["nodejs_compat"]
+ account_id = "b253e6fbfd2f7757cadd0386de5bde3f"
+
+ # Custom domain - single domain for everything
+ route = { pattern = "qrurl.us/*", custom_domain = true }
+
+ # D1 Database
+ [[d1_databases]]
+ binding = "DB"
+ database_name = "qrurl-db"
+ database_id = "17eb6fdb-19da-4ed7-931c-a4cdef281f8c"
+
+ # R2 Storage for logos
+ [[r2_buckets]]
+ binding = "STORAGE"
+ bucket_name = "qrurl-storage"
+
+ # R2 Storage for frontend assets
+ [[r2_buckets]]
+ binding = "FRONTEND_ASSETS"
+ bucket_name = "qrurl-frontend-assets"
+
+ # KV Namespace for caching
+ [[kv_namespaces]]
+ binding = "CACHE"
+ id = "1cacb0f1b44b4324b62c1bc010ff15f5"
+
+ # Environment Variables
+ [vars]
+ # No FRONTEND_URL needed since everything is on same domain
+ BACKEND_URL = "https://qrurl.us"
+ EMAIL_FROM = "noreply@qrurl.us"
+
+ # Rate limiting
+ [[unsafe.bindings]]
+ name = "RATE_LIMITER"
+ type = "ratelimit"
+ namespace_id = "1"
+ simple = { limit = 60, period = 60 }
\ No newline at end of file