clean up: remove GitHub Pages & Cloudflare Workers, deploy to Uberspace

All-in-one Uberspace deployment:
- Backend: Node.js Express on port 3000 (/api/*)
- Frontend: Static files served by Apache (/)
- URL: https://wtp.uber.space

Removed:
- GitHub Actions workflow
- Cloudflare Worker setup service
- CloudflareSetupPage component
- CloudflareService
- 404.html (GitHub Pages redirect)
- Cloudflare menu item from Settings

Simplified:
- Use VITE_BASE_PATH and VITE_API_URL for config
- Single deployment target (no multi-env complexity)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-02-06 21:24:42 +01:00
parent 0fe09f01f8
commit 576c171a98
14 changed files with 80 additions and 1119 deletions

View File

@@ -1,53 +0,0 @@
name: Deploy to GitHub Pages
on:
push:
branches:
- main
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: "./dist"
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -41,6 +41,7 @@ environment=PORT="3000"
```
Starte den Service:
```bash
supervisorctl reread
supervisorctl update
@@ -72,9 +73,9 @@ Für Uberspace Deployment brauchst du keine spezielle `base`:
```typescript
// vite.config.ts
export default defineConfig({
// base: "/whattoplay/", // <- entfernen für Uberspace
plugins: [react()],
// ...
// base: "/whattoplay/", // <- entfernen für Uberspace
plugins: [react()],
// ...
});
```
@@ -108,6 +109,7 @@ uberspace web domain add your-domain.com
```
Dann DNS Records setzen:
```
A @ <IP von uberspace>
CNAME www <servername>.uberspace.de
@@ -143,6 +145,7 @@ cp -r dist/* ~/html/
## Kosten
Uberspace: ~5€/Monat (pay what you want, Minimum 1€)
- Unbegrenzter Traffic
- SSH Zugriff
- Node.js, PHP, Python, Ruby Support

View File

@@ -8,16 +8,8 @@
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="WhatToPlay" />
<link rel="manifest" href="/whattoplay/manifest.json" />
<link rel="manifest" href="/manifest.json" />
<title>WhatToPlay</title>
<script>
// Restore path from 404 redirect
const redirectPath = sessionStorage.getItem("redirectPath");
if (redirectPath) {
sessionStorage.removeItem("redirectPath");
history.replaceState(null, "", redirectPath);
}
</script>
</head>
<body>
<div id="root"></div>

View File

@@ -1,16 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<script>
// GitHub Pages SPA redirect solution
// Store the path and redirect to index.html
sessionStorage.setItem(
"redirectPath",
window.location.pathname + window.location.search,
);
window.location.replace("/whattoplay/");
</script>
</head>
<body></body>
</html>

View File

@@ -1,58 +1,60 @@
import express from 'express';
import cors from 'cors';
import fetch from 'node-fetch';
import express from "express";
import cors from "cors";
import fetch from "node-fetch";
const app = express();
const PORT = process.env.PORT || 3000;
// Enable CORS for your PWA
app.use(cors({
origin: process.env.ALLOWED_ORIGIN || '*'
}));
app.use(
cors({
origin: process.env.ALLOWED_ORIGIN || "*",
}),
);
app.use(express.json());
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
app.get("/health", (req, res) => {
res.json({ status: "ok" });
});
// Proxy for Steam API - exactly like the worker
app.all('/api/*', async (req, res) => {
const path = req.url.replace('/api', '');
const steamUrl = `https://store.steampowered.com${path}`;
app.all("/api/*", async (req, res) => {
const path = req.url.replace("/api", "");
const steamUrl = `https://store.steampowered.com${path}`;
console.log(`Proxying: ${req.method} ${steamUrl}`);
console.log(`Proxying: ${req.method} ${steamUrl}`);
try {
const response = await fetch(steamUrl, {
method: req.method,
headers: {
'User-Agent': 'WhatToPlay/1.0',
'Accept': 'application/json',
...(req.body && { 'Content-Type': 'application/json' })
},
...(req.body && { body: JSON.stringify(req.body) })
});
try {
const response = await fetch(steamUrl, {
method: req.method,
headers: {
"User-Agent": "WhatToPlay/1.0",
Accept: "application/json",
...(req.body && { "Content-Type": "application/json" }),
},
...(req.body && { body: JSON.stringify(req.body) }),
});
const contentType = response.headers.get('content-type');
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes('application/json')) {
const data = await response.json();
res.json(data);
} else {
const text = await response.text();
res.send(text);
}
} catch (error) {
console.error('Proxy error:', error);
res.status(500).json({
error: 'Proxy error',
message: error.message
});
}
if (contentType && contentType.includes("application/json")) {
const data = await response.json();
res.json(data);
} else {
const text = await response.text();
res.send(text);
}
} catch (error) {
console.error("Proxy error:", error);
res.status(500).json({
error: "Proxy error",
message: error.message,
});
}
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log(`Server running on port ${PORT}`);
});

View File

@@ -1,16 +1,16 @@
{
"name": "whattoplay-server",
"version": "1.0.0",
"type": "module",
"description": "Simple proxy server for WhatToPlay Steam API calls",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"node-fetch": "^3.3.2"
}
"name": "whattoplay-server",
"version": "1.0.0",
"type": "module",
"description": "Simple proxy server for WhatToPlay Steam API calls",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"node-fetch": "^3.3.2"
}
}

View File

@@ -23,14 +23,13 @@ import LibraryPage from "./pages/Library/LibraryPage";
import PlaylistsPage from "./pages/Playlists/PlaylistsPage";
import SettingsPage from "./pages/Settings/SettingsPage";
import SettingsDetailPage from "./pages/Settings/SettingsDetailPage";
import CloudflareSetupPage from "./pages/Settings/CloudflareSetupPage";
import "./App.css";
export default function App() {
return (
<IonApp>
<IonReactRouter basename="/whattoplay">
<IonReactRouter basename={import.meta.env.BASE_URL}>
<IonTabs>
<IonRouterOutlet>
<Switch>
@@ -39,11 +38,6 @@ export default function App() {
<Route exact path="/playlists" component={PlaylistsPage} />
<Route exact path="/discover" component={DiscoverPage} />
<Route exact path="/settings" component={SettingsPage} />
<Route
exact
path="/settings/cloudflare"
component={CloudflareSetupPage}
/>
<Route
exact
path="/settings/:serviceId"

View File

@@ -1,41 +0,0 @@
.cloudflare-setup-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100%;
padding: 20px;
}
.cloudflare-setup-step {
max-width: 600px;
width: 100%;
text-align: center;
}
.cloudflare-setup-icon {
font-size: 4rem;
margin-bottom: 1rem;
color: var(--ion-color-primary);
}
.cloudflare-setup-step h2 {
margin-bottom: 1rem;
}
.cloudflare-setup-step ul {
text-align: left;
padding-left: 20px;
}
.cloudflare-setup-step a {
color: var(--ion-color-primary);
text-decoration: none;
}
.cloudflare-setup-step a:hover {
text-decoration: underline;
}
.cloudflare-setup-step .ion-padding {
padding: 16px;
}

View File

@@ -1,378 +0,0 @@
import React, { useState } from "react";
import {
IonBackButton,
IonButton,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonInput,
IonItem,
IonLabel,
IonList,
IonLoading,
IonPage,
IonText,
IonTitle,
IonToolbar,
} from "@ionic/react";
import {
checkmarkCircleOutline,
cloudOutline,
keyOutline,
rocketOutline,
} from "ionicons/icons";
import { useHistory } from "react-router-dom";
import { CloudflareService } from "../../services/CloudflareService";
import { ConfigService } from "../../services/ConfigService";
import { db } from "../../services/Database";
import "./CloudflareSetupPage.css";
type WizardStep = "welcome" | "token" | "deploy" | "success";
export default function CloudflareSetupPage() {
const history = useHistory();
const [step, setStep] = useState<WizardStep>("welcome");
const [apiToken, setApiToken] = useState("");
const [accountInfo, setAccountInfo] = useState<{
accountId: string;
accountName: string;
subdomain?: string;
} | null>(null);
const [workerName, setWorkerName] = useState("whattoplay-api");
const [workerUrl, setWorkerUrl] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleValidateToken = async () => {
setLoading(true);
setError("");
const result = await CloudflareService.validateToken(apiToken);
setLoading(false);
if (!result.valid) {
setError(result.error || "Token validation failed");
return;
}
if (!result.accountId || !result.accountName) {
setError("Failed to retrieve account information");
return;
}
setAccountInfo({
accountId: result.accountId,
accountName: result.accountName,
subdomain: result.subdomain,
});
setStep("deploy");
};
const handleDeployWorker = async () => {
if (!accountInfo) return;
setLoading(true);
setError("");
const result = await CloudflareService.deployWorker(
apiToken,
accountInfo.accountId,
workerName,
);
setLoading(false);
if (!result.success) {
setError(result.error || "Deployment failed");
return;
}
// Save to config
const config = await ConfigService.loadConfig();
config.workerUrl = result.workerUrl;
config.cloudflare = {
apiToken,
accountId: accountInfo.accountId,
accountName: accountInfo.accountName,
subdomain: accountInfo.subdomain,
workerName,
lastDeployed: new Date().toISOString(),
};
await ConfigService.saveConfig(config);
setWorkerUrl(result.workerUrl || "");
setStep("success");
};
const renderWelcome = () => (
<div className="cloudflare-setup-step">
<div className="cloudflare-setup-icon">
<IonIcon icon={cloudOutline} />
</div>
<h2>Cloudflare Worker einrichten</h2>
<IonText color="medium">
<p>
Für die Nutzung der Steam API auf dem iPhone wird ein Cloudflare
Worker benötigt. Dieser läuft kostenlos auf deinem eigenen Cloudflare
Account (Free Tier).
</p>
<p>
<strong>Vorteile:</strong>
</p>
<ul>
<li>100% Kostenlos (100k Requests/Tag)</li>
<li>Deployment in ~2 Minuten</li>
<li>Du hostest nichts selbst</li>
</ul>
</IonText>
<IonButton expand="block" onClick={() => setStep("token")}>
<IonIcon slot="start" icon={rocketOutline} />
Jetzt einrichten
</IonButton>
</div>
);
const renderTokenStep = () => (
<div className="cloudflare-setup-step">
<div className="cloudflare-setup-icon">
<IonIcon icon={keyOutline} />
</div>
<h2>API Token erstellen</h2>
<IonText color="medium">
<p>
<strong>Schritt 1:</strong> Öffne das{" "}
<a
href="https://dash.cloudflare.com/profile/api-tokens"
target="_blank"
rel="noopener noreferrer"
>
Cloudflare API Token Dashboard
</a>{" "}
(falls du noch keinen Cloudflare Account hast, erstelle zuerst einen
kostenlosen Account)
</p>
<p>
<strong>Schritt 2:</strong> Klicke oben rechts auf den blauen Button{" "}
<strong>"Create Token"</strong>
</p>
<p>
<strong>Schritt 3:</strong> Suche in der Liste nach dem Template{" "}
<strong>"Edit Cloudflare Workers"</strong> und klicke daneben auf{" "}
<strong>"Use template"</strong>
</p>
<p>
<strong>Schritt 4:</strong> Überprüfe die Permissions:
</p>
<ul>
<li>
<strong>Account Resources:</strong> "All accounts" sollte ausgewählt
sein
</li>
<li>
<strong>Zone Resources:</strong> Wähle "All zones" ODER ignoriere
dieses Feld (Workers sind Account-level, nicht Zone-level)
</li>
</ul>
<p>Lass alle anderen Einstellungen wie sie sind.</p>
<p>
<strong>Schritt 5:</strong> Scrolle nach unten und klicke auf{" "}
<strong>"Continue to summary"</strong>
</p>
<p>
<strong>Schritt 6:</strong> Prüfe die Zusammenfassung und klicke auf{" "}
<strong>"Create Token"</strong>
</p>
<p>
<strong>Schritt 7:</strong> Jetzt wird dein Token angezeigt.{" "}
<strong>Kopiere ihn</strong> (er wird nur einmal angezeigt!) und füge
ihn unten ein.
</p>
</IonText>
<IonList inset>
<IonItem>
<IonLabel position="stacked">Cloudflare API Token</IonLabel>
<IonInput
type="password"
placeholder="Füge deinen API Token hier ein"
value={apiToken}
onIonChange={(e) => setApiToken(e.detail.value || "")}
/>
</IonItem>
</IonList>
{error && (
<IonText color="danger">
<p className="ion-padding">{error}</p>
</IonText>
)}
<IonButton
expand="block"
onClick={handleValidateToken}
disabled={!apiToken || loading}
>
Token validieren
</IonButton>
</div>
);
const renderDeployStep = () => (
<div className="cloudflare-setup-step">
<div className="cloudflare-setup-icon">
<IonIcon icon={checkmarkCircleOutline} color="success" />
</div>
<h2>Token validiert!</h2>
<IonList inset>
<IonItem>
<IonLabel>
<h3>Account</h3>
<p>{accountInfo?.accountName}</p>
</IonLabel>
</IonItem>
<IonItem>
<IonLabel>
<h3>Subdomain</h3>
<p>
{accountInfo?.subdomain ? (
`${accountInfo.subdomain}.workers.dev`
) : (
<IonText color="warning">
Nicht konfiguriert - bitte in CF Dashboard aktivieren
</IonText>
)}
</p>
</IonLabel>
</IonItem>
<IonItem>
<IonLabel position="stacked">Worker Name (optional)</IonLabel>
<IonInput
placeholder="whattoplay-api"
value={workerName}
onIonChange={(e) =>
setWorkerName(e.detail.value || "whattoplay-api")
}
/>
</IonItem>
</IonList>
{error && (
<IonText color="danger">
<p className="ion-padding">{error}</p>
</IonText>
)}
<IonButton
expand="block"
onClick={handleDeployWorker}
disabled={loading || !accountInfo?.subdomain}
>
<IonIcon slot="start" icon={rocketOutline} />
Worker jetzt deployen
</IonButton>
{!accountInfo?.subdomain && (
<IonText color="medium">
<p className="ion-padding">
<small>
Um einen Worker zu deployen, aktiviere zuerst die workers.dev
Subdomain in deinem{" "}
<a
href="https://dash.cloudflare.com"
target="_blank"
rel="noopener noreferrer"
>
Cloudflare Dashboard
</a>
.
</small>
</p>
</IonText>
)}
</div>
);
const renderSuccess = () => (
<div className="cloudflare-setup-step">
<div className="cloudflare-setup-icon">
<IonIcon icon={checkmarkCircleOutline} color="success" />
</div>
<h2>Worker deployed!</h2>
<IonList inset>
<IonItem>
<IonLabel>
<h3>Worker URL</h3>
<p>{workerUrl}</p>
</IonLabel>
</IonItem>
<IonItem>
<IonLabel>
<h3>Status</h3>
<p>
<IonText color="success"> Aktiv</IonText>
</p>
</IonLabel>
</IonItem>
</IonList>
<IonText color="medium">
<p className="ion-padding">
Dein Worker ist jetzt einsatzbereit! Du kannst nun die Steam API auch
auf dem iPhone nutzen.
</p>
</IonText>
<IonButton expand="block" onClick={() => history.push("/settings/steam")}>
Zu Steam Settings
</IonButton>
<IonButton
expand="block"
fill="clear"
onClick={() => history.push("/settings")}
>
Zurück zu Settings
</IonButton>
</div>
);
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/settings" />
</IonButtons>
<IonTitle>Cloudflare Worker</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<div className="cloudflare-setup-container">
{step === "welcome" && renderWelcome()}
{step === "token" && renderTokenStep()}
{step === "deploy" && renderDeployStep()}
{step === "success" && renderSuccess()}
</div>
<IonLoading isOpen={loading} message="Bitte warten..." />
</IonContent>
</IonPage>
);
}

View File

@@ -14,7 +14,6 @@ import {
} from "@ionic/react";
import {
cloudOutline,
cloudUploadOutline,
cogOutline,
gameControllerOutline,
} from "ionicons/icons";
@@ -48,15 +47,6 @@ export default function SettingsPage() {
<IonList inset>
<IonListHeader>Verwaltung</IonListHeader>
<IonItem
routerLink="/settings/cloudflare"
routerDirection="forward"
detail
>
<IonIcon slot="start" icon={cloudUploadOutline} />
<IonLabel>Cloudflare Worker</IonLabel>
<IonNote slot="end">Deployment</IonNote>
</IonItem>
<IonItem routerLink="/settings/data" routerDirection="forward" detail>
<IonIcon slot="start" icon={cloudOutline} />
<IonLabel>Datenverwaltung</IonLabel>

View File

@@ -1,148 +0,0 @@
/**
* CloudflareService - API Wrapper für Cloudflare Workers Deployment
* Ermöglicht automatisches Worker Deployment direkt aus der PWA
*
* Uses a CORS proxy service to bypass browser restrictions when calling
* the Cloudflare API. You can deploy your own setup service using
* workers/cf-setup-service.js
*/
// Default setup service URL - you can deploy your own instance
const CF_SETUP_SERVICE_URL =
"https://whattoplay-setup.YOUR-SUBDOMAIN.workers.dev";
export interface CFAccountInfo {
valid: boolean;
accountId?: string;
accountName?: string;
subdomain?: string;
error?: string;
}
export interface CFDeploymentResult {
success: boolean;
workerUrl?: string;
error?: string;
}
export class CloudflareService {
/**
* Validates Cloudflare API Token and retrieves account info
*/
static async validateToken(apiToken: string): Promise<CFAccountInfo> {
try {
const response = await fetch(CF_SETUP_SERVICE_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
action: "validate",
apiToken,
}),
});
if (!response.ok) {
return {
valid: false,
error: `Setup service error: ${response.status} ${response.statusText}`,
};
}
const data = await response.json();
return {
valid: data.valid,
accountId: data.accountId,
accountName: data.accountName,
subdomain: data.subdomain,
error: data.error,
};
} catch (error) {
console.error("Token validation failed:", error);
return {
valid: false,
error:
error instanceof Error
? error.message
: "Failed to connect to setup service",
};
}
}
/**
* Deploys Worker script to Cloudflare
*/
static async deployWorker(
apiToken: string,
accountId: string,
scriptName: string = "whattoplay-api",
): Promise<CFDeploymentResult> {
try {
// Fetch Worker script content
const scriptResponse = await fetch("/whattoplay/workers/steam-proxy.js");
if (!scriptResponse.ok) {
return {
success: false,
error: "Failed to load Worker script",
};
}
const scriptContent = await scriptResponse.text();
// Deploy via setup service
const response = await fetch(CF_SETUP_SERVICE_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
action: "deploy",
apiToken,
accountId,
scriptName,
scriptContent,
}),
});
if (!response.ok) {
return {
success: false,
error: `Setup service error: ${response.status} ${response.statusText}`,
};
}
const data = await response.json();
if (!data.success) {
return {
success: false,
error: data.error || "Deployment failed",
};
}
if (!data.workerUrl) {
return {
success: false,
error:
"Worker deployed, but subdomain not configured. Please enable workers.dev subdomain in Cloudflare Dashboard.",
};
}
return {
success: true,
workerUrl: data.workerUrl,
};
} catch (error) {
console.error("Worker deployment failed:", error);
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to connect to setup service",
};
}
}
}

View File

@@ -1,5 +1,5 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { defineConfig, loadEnv } from "vite";
import { handleSteamRefresh, handleConfigLoad } from "./server/steam-api.mjs";
import { handleGameAsset } from "./server/assets-api.mjs";
@@ -22,18 +22,22 @@ const apiMiddlewarePlugin = {
},
};
export default defineConfig({
// GitHub Pages: /whattoplay/
// Uberspace: /
base: process.env.VITE_BASE_PATH || "/whattoplay/",
plugins: [react(), apiMiddlewarePlugin],
server: {
port: 5173,
hmr: {
overlay: true,
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
return {
// GitHub Pages: /whattoplay/
// Uberspace: /
base: env.VITE_BASE_PATH || "/whattoplay/",
plugins: [react(), apiMiddlewarePlugin],
server: {
port: 5173,
hmr: {
overlay: true,
},
watch: {
usePolling: true,
},
},
watch: {
usePolling: true,
},
},
};
});

View File

@@ -1,109 +0,0 @@
# Cloudflare Setup Service
This is a CORS proxy service that allows browser-based deployment of Cloudflare Workers. Deploy this once on your Cloudflare account, and all users can use it to deploy their own workers without CORS restrictions.
## Why is this needed?
The Cloudflare API doesn't allow direct browser requests due to CORS restrictions. This service acts as a proxy, forwarding requests from the browser to the Cloudflare API.
## Security
- **API tokens are NOT stored or logged** - they are only used to validate and deploy workers
- All communication happens over HTTPS
- The service only accepts POST requests with specific actions
- CORS is enabled to allow browser access
## Deployment
### Option 1: Quick Deploy with Wrangler
```bash
cd workers
npx wrangler deploy cf-setup-service.js --name whattoplay-setup
```
This will deploy the service and give you a URL like:
`https://whattoplay-setup.YOUR-SUBDOMAIN.workers.dev`
### Option 2: Deploy via Dashboard
1. Go to https://dash.cloudflare.com
2. Navigate to Workers & Pages
3. Click "Create Application" → "Create Worker"
4. Name it `whattoplay-setup`
5. Click "Deploy"
6. Click "Edit Code"
7. Replace the default code with the contents of `cf-setup-service.js`
8. Click "Save and Deploy"
## Configuration
After deployment, update the service URL in your app:
1. Open `src/services/CloudflareService.ts`
2. Replace `YOUR-SUBDOMAIN` with your actual subdomain:
```typescript
const CF_SETUP_SERVICE_URL =
"https://whattoplay-setup.YOUR-SUBDOMAIN.workers.dev";
```
3. Commit and deploy your app
## API
### POST /
**Validate Token:**
```json
{
"action": "validate",
"apiToken": "your-cf-api-token"
}
```
Response:
```json
{
"valid": true,
"accountId": "...",
"accountName": "...",
"subdomain": "..."
}
```
**Deploy Worker:**
```json
{
"action": "deploy",
"apiToken": "your-cf-api-token",
"accountId": "your-account-id",
"scriptName": "worker-name",
"scriptContent": "export default { async fetch(...) { ... } }"
}
```
Response:
```json
{
"success": true,
"workerUrl": "https://worker-name.your-subdomain.workers.dev"
}
```
## Usage Limits
Cloudflare Workers Free Tier:
- 100,000 requests/day
- 10ms CPU time per request
This should be more than enough for personal use. If you expect heavy usage, consider upgrading to the Workers Paid plan ($5/month for 10M requests).
## Sharing
You can share this service URL with other users of your app. They will use your deployment to deploy their own workers on their own Cloudflare accounts.
**Note:** This service does NOT store any data. Each user's worker is deployed on their own Cloudflare account, using their own API token.

View File

@@ -1,279 +0,0 @@
/**
* Cloudflare Worker Setup Service
*
* This worker acts as a CORS proxy for the Cloudflare API, allowing
* browser-based deployment of Workers without exposing API tokens.
*
* Deploy this once on your Cloudflare account, then share the URL.
* All users can use this service to deploy their own workers.
*
* SECURITY: Tokens are NOT stored, only validated and used for deployment.
*/
export default {
async fetch(request, env, ctx) {
// Enable CORS
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
// Handle CORS preflight
if (request.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
// Only allow POST
if (request.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
try {
const { action, apiToken, accountId, scriptName, scriptContent } =
await request.json();
// Validate required fields
if (!action || !apiToken) {
return new Response(
JSON.stringify({ error: "Missing required fields" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Route to appropriate handler
if (action === "validate") {
return await handleValidateToken(apiToken, corsHeaders);
} else if (action === "deploy") {
if (!accountId || !scriptName || !scriptContent) {
return new Response(
JSON.stringify({ error: "Missing deployment fields" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
return await handleDeployWorker(
apiToken,
accountId,
scriptName,
scriptContent,
corsHeaders,
);
} else {
return new Response(JSON.stringify({ error: "Unknown action" }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
} catch (error) {
console.error("Setup service error:", error);
return new Response(
JSON.stringify({
error: "Internal server error",
message: error.message,
}),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
},
};
/**
* Validates API token and retrieves account info
*/
async function handleValidateToken(apiToken, corsHeaders) {
try {
// Get accounts
const accountsResponse = await fetch(
"https://api.cloudflare.com/client/v4/accounts",
{
headers: {
Authorization: `Bearer ${apiToken}`,
},
},
);
if (!accountsResponse.ok) {
const errorData = await accountsResponse.json();
return new Response(
JSON.stringify({
valid: false,
error:
errorData.errors?.[0]?.message ||
`API Error: ${accountsResponse.status}`,
}),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const accountsData = await accountsResponse.json();
if (
!accountsData.success ||
!accountsData.result ||
accountsData.result.length === 0
) {
return new Response(
JSON.stringify({
valid: false,
error: "No Cloudflare accounts found for this token",
}),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const account = accountsData.result[0];
// Get Workers subdomain
const subdomainResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${account.id}/workers/subdomain`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
},
},
);
let subdomain;
if (subdomainResponse.ok) {
const subdomainData = await subdomainResponse.json();
subdomain = subdomainData.result?.subdomain || undefined;
}
return new Response(
JSON.stringify({
valid: true,
accountId: account.id,
accountName: account.name,
subdomain,
}),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
} catch (error) {
return new Response(
JSON.stringify({
valid: false,
error: error.message,
}),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
}
/**
* Deploys Worker script to Cloudflare
*/
async function handleDeployWorker(
apiToken,
accountId,
scriptName,
scriptContent,
corsHeaders,
) {
try {
// Prepare multipart form data
const formData = new FormData();
formData.append(
"metadata",
JSON.stringify({
main_module: "script.js",
compatibility_date: "2024-01-01",
bindings: [],
}),
);
formData.append(
"script.js",
new Blob([scriptContent], { type: "application/javascript+module" }),
"script.js",
);
// Deploy Worker
const deployResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/workers/scripts/${scriptName}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${apiToken}`,
},
body: formData,
},
);
const deployData = await deployResponse.json();
if (!deployData.success) {
const errorMsg = deployData.errors?.[0]?.message || "Deployment failed";
return new Response(
JSON.stringify({
success: false,
error: errorMsg,
}),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Get subdomain for Worker URL
const subdomainResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/workers/subdomain`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
},
},
);
let workerUrl;
if (subdomainResponse.ok) {
const subdomainData = await subdomainResponse.json();
const subdomain = subdomainData.result?.subdomain;
if (subdomain) {
workerUrl = `https://${scriptName}.${subdomain}.workers.dev`;
}
}
return new Response(
JSON.stringify({
success: true,
workerUrl,
}),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
} catch (error) {
return new Response(
JSON.stringify({
success: false,
error: error.message,
}),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
}