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 | |