somratpro commited on
Commit
5636339
·
1 Parent(s): bb5c703

refactor: replace UptimeRobot keep-alive implementation with Cloudflare Workers based solution

Browse files
Files changed (7) hide show
  1. .env.example +3 -3
  2. Dockerfile +2 -2
  3. README.md +6 -7
  4. cloudflare-keepalive-setup.py +212 -0
  5. health-server.js +136 -215
  6. setup-uptimerobot.sh +0 -74
  7. start.sh +3 -3
.env.example CHANGED
@@ -50,9 +50,9 @@ CLOUDFLARE_PROXY_SECRET=
50
  # Leave unset to proxy only the built-in default domains.
51
  # CLOUDFLARE_PROXY_DOMAINS=api.sendgrid.com,smtp.sendgrid.net,slack.com
52
 
53
- # UptimeRobot keep-alive: add your Main API key (NOT Read-only or Monitor-specific).
54
- # Monitor is created automatically at boot. Status shown on the dashboard.
55
- # UPTIMEROBOT_API_KEY=ur_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
56
 
57
  # Max seconds to wait for n8n readiness at startup
58
  N8N_STARTUP_TIMEOUT=180
 
50
  # Leave unset to proxy only the built-in default domains.
51
  # CLOUDFLARE_PROXY_DOMAINS=api.sendgrid.com,smtp.sendgrid.net,slack.com
52
 
53
+ # Cloudflare proxy & keep-alive: add your Cloudflare API token.
54
+ # A Worker is created automatically at boot for both outbound proxying and keep-alive.
55
+ # CLOUDFLARE_WORKERS_TOKEN=your_cloudflare_token_here
56
 
57
  # Max seconds to wait for n8n readiness at startup
58
  N8N_STARTUP_TIMEOUT=180
Dockerfile CHANGED
@@ -35,10 +35,10 @@ COPY --chown=node:node cloudflare-proxy-setup.py /home/node/app/cloudflare-proxy
35
  # Set NODE_OPTIONS after preload scripts are copied
36
  ENV NODE_OPTIONS="--require /opt/cloudflare-proxy.js"
37
  COPY --chown=node:node n8n-sync.py /home/node/app/n8n-sync.py
38
- COPY --chown=node:node setup-uptimerobot.sh /home/node/app/setup-uptimerobot.sh
39
  COPY --chown=node:node start.sh /home/node/app/start.sh
40
 
41
- RUN chmod +x /home/node/app/start.sh /home/node/app/setup-uptimerobot.sh /home/node/app/cloudflare-proxy-setup.py
42
 
43
  USER node
44
 
 
35
  # Set NODE_OPTIONS after preload scripts are copied
36
  ENV NODE_OPTIONS="--require /opt/cloudflare-proxy.js"
37
  COPY --chown=node:node n8n-sync.py /home/node/app/n8n-sync.py
38
+ COPY --chown=node:node cloudflare-keepalive-setup.py /home/node/app/cloudflare-keepalive-setup.py
39
  COPY --chown=node:node start.sh /home/node/app/start.sh
40
 
41
+ RUN chmod +x /home/node/app/start.sh /home/node/app/cloudflare-keepalive-setup.py /home/node/app/cloudflare-proxy-setup.py
42
 
43
  USER node
44
 
README.md CHANGED
@@ -11,9 +11,7 @@ secrets:
11
  - name: HF_TOKEN
12
  description: HuggingFace token with write access. Used for automatic workspace backup.
13
  - name: CLOUDFLARE_WORKERS_TOKEN
14
- description: Cloudflare API token for automatic Worker proxy setup.
15
- - name: UPTIMEROBOT_API_KEY
16
- description: UptimeRobot API key for automatic monitor setup.
17
  ---
18
 
19
  <!-- Badges -->
@@ -47,7 +45,7 @@ secrets:
47
  - 🔐 **Secure by Default:** Uses n8n's native user management and restricted file permissions (`umask 0077`).
48
  - 🌐 **Built-in Connectivity:** Includes transparent outbound proxying via Cloudflare Workers for Telegram, WhatsApp-related APIs, Google APIs, Discord, and other external services.
49
  - 📊 **Premium Dashboard:** Beautiful Web UI at `/` for real-time monitoring of uptime, sync health, and n8n status.
50
- - ⏰ **Easy Keep-Alive:** Add `UPTIMEROBOT_API_KEY` as a Space secret and the monitor is created automatically at boot — no manual setup.
51
  - 🐳 **Optimized Infrastructure:** Minimal resource usage with clean startup logs and production-ready proxying.
52
 
53
  ## 🎥 Video Tutorial
@@ -126,7 +124,7 @@ Hugging8n automatically creates a private dataset named `hugging8n-backup` in yo
126
 
127
  ## 💓 Staying Alive *(Recommended on Free HF Spaces)*
128
 
129
- Add your [UptimeRobot](https://uptimerobot.com/) **Main API key** as a Space secret named `UPTIMEROBOT_API_KEY`. Hugging8n will automatically create a monitor for your Space's `/health` endpoint at boot. The dashboard shows the current status (configured, setting up, or failed).
130
 
131
  ## 🔐 Security & Advanced *(Optional)*
132
 
@@ -143,7 +141,7 @@ Customize your instance with these environment variables:
143
  | `CLOUDFLARE_ACCOUNT_ID` | auto | Optional Cloudflare account ID override |
144
  | `SPACE_HOST_OVERRIDE` | — | Override detected host for custom domains |
145
  | `N8N_STARTUP_TIMEOUT` | `180` | Max seconds to wait for n8n readiness |
146
- | `UPTIMEROBOT_API_KEY` | | UptimeRobot Main API key. When set, a monitor is created automatically at boot. |
147
 
148
  ## 💻 Local Development
149
 
@@ -173,7 +171,7 @@ docker run -p 7861:7861 --env-file .env hugging8n
173
 
174
  - **Telegram/Google/WhatsApp not connecting:** Ensure `CLOUDFLARE_WORKERS_TOKEN` or `CLOUDFLARE_PROXY_URL` is set. Use `CLOUDFLARE_PROXY_DOMAINS=*` to proxy all external traffic.
175
  - **Workflows not saving:** Check if `HF_TOKEN` has **Write** access to your account.
176
- - **Space keeps sleeping:** Add `UPTIMEROBOT_API_KEY` as a Space secret to enable automatic keep-awake monitoring.
177
  - **Authentication errors:** n8n v2 uses its own internal users; ensure you created the owner account on first run.
178
 
179
  ## 🌟 More Projects
@@ -182,6 +180,7 @@ Similar projects by [@somratpro](https://github.com/somratpro) — all free, one
182
 
183
  | Project | What it runs | HF Space | GitHub |
184
  | :--- | :--- | :--- | :--- |
 
185
  | **HuggingClaw** | OpenClaw — Claude Code in the browser | [Space](https://huggingface.co/spaces/somratpro/HuggingClaw) | [Repo](https://github.com/somratpro/huggingclaw) |
186
  | **HuggingClip** | Paperclip — AI agent orchestration platform | [Space](https://huggingface.co/spaces/somratpro/HuggingClip) | [Repo](https://github.com/somratpro/huggingclip) |
187
 
 
11
  - name: HF_TOKEN
12
  description: HuggingFace token with write access. Used for automatic workspace backup.
13
  - name: CLOUDFLARE_WORKERS_TOKEN
14
+ description: Cloudflare API token for automatic Worker proxy and KeepAlive setup.
 
 
15
  ---
16
 
17
  <!-- Badges -->
 
45
  - 🔐 **Secure by Default:** Uses n8n's native user management and restricted file permissions (`umask 0077`).
46
  - 🌐 **Built-in Connectivity:** Includes transparent outbound proxying via Cloudflare Workers for Telegram, WhatsApp-related APIs, Google APIs, Discord, and other external services.
47
  - 📊 **Premium Dashboard:** Beautiful Web UI at `/` for real-time monitoring of uptime, sync health, and n8n status.
48
+ - ⏰ **Easy Keep-Alive:** Uses `CLOUDFLARE_WORKERS_TOKEN` to automatically set up a cron-triggered keep-awake worker at boot.
49
  - 🐳 **Optimized Infrastructure:** Minimal resource usage with clean startup logs and production-ready proxying.
50
 
51
  ## 🎥 Video Tutorial
 
124
 
125
  ## 💓 Staying Alive *(Recommended on Free HF Spaces)*
126
 
127
+ Your Space will automatically be kept awake by a background Cloudflare Worker when you configure the `CLOUDFLARE_WORKERS_TOKEN` secret. The worker uses a cron trigger to regularly ping your Space's `/health` endpoint. The dashboard displays the current keep-alive worker status.
128
 
129
  ## 🔐 Security & Advanced *(Optional)*
130
 
 
141
  | `CLOUDFLARE_ACCOUNT_ID` | auto | Optional Cloudflare account ID override |
142
  | `SPACE_HOST_OVERRIDE` | — | Override detected host for custom domains |
143
  | `N8N_STARTUP_TIMEOUT` | `180` | Max seconds to wait for n8n readiness |
144
+ | `CLOUDFLARE_KEEPALIVE_ENABLED` | `true` | Set to `false` to disable the automatic Cloudflare KeepAlive worker |
145
 
146
  ## 💻 Local Development
147
 
 
171
 
172
  - **Telegram/Google/WhatsApp not connecting:** Ensure `CLOUDFLARE_WORKERS_TOKEN` or `CLOUDFLARE_PROXY_URL` is set. Use `CLOUDFLARE_PROXY_DOMAINS=*` to proxy all external traffic.
173
  - **Workflows not saving:** Check if `HF_TOKEN` has **Write** access to your account.
174
+ - **Space keeps sleeping:** Add `CLOUDFLARE_WORKERS_TOKEN` as a Space secret to enable automatic keep-awake monitoring via Cloudflare Workers.
175
  - **Authentication errors:** n8n v2 uses its own internal users; ensure you created the owner account on first run.
176
 
177
  ## 🌟 More Projects
 
180
 
181
  | Project | What it runs | HF Space | GitHub |
182
  | :--- | :--- | :--- | :--- |
183
+ | **HuggingMess** | Hermes — Self-hosted agent gateway | [Space](https://huggingface.co/spaces/somratpro/HuggingMess) | [Repo](https://github.com/somratpro/huggingmess) |
184
  | **HuggingClaw** | OpenClaw — Claude Code in the browser | [Space](https://huggingface.co/spaces/somratpro/HuggingClaw) | [Repo](https://github.com/somratpro/huggingclaw) |
185
  | **HuggingClip** | Paperclip — AI agent orchestration platform | [Space](https://huggingface.co/spaces/somratpro/HuggingClip) | [Repo](https://github.com/somratpro/huggingclip) |
186
 
cloudflare-keepalive-setup.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ """Create or reuse a Cloudflare Worker for Space keep-awake."""
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ import sys
10
+ import time
11
+ import urllib.request
12
+ from pathlib import Path
13
+
14
+ API_BASE = "https://api.cloudflare.com/client/v4"
15
+ KEEPALIVE_STATUS_FILE = Path("/tmp/hugging8n-cloudflare-keepalive-status.json")
16
+
17
+
18
+ def cf_request(method: str, path: str, token: str, body: bytes | None = None, content_type: str = "application/json"):
19
+ req = urllib.request.Request(
20
+ f"{API_BASE}{path}",
21
+ data=body,
22
+ method=method,
23
+ headers={"Authorization": f"Bearer {token}", "Content-Type": content_type},
24
+ )
25
+ with urllib.request.urlopen(req, timeout=30) as response:
26
+ payload = json.loads(response.read().decode("utf-8"))
27
+ if not payload.get("success"):
28
+ errors = payload.get("errors") or [{"message": "Unknown Cloudflare API error"}]
29
+ raise RuntimeError(errors[0].get("message", "Unknown Cloudflare API error"))
30
+ return payload["result"]
31
+
32
+
33
+ def slugify(value: str) -> str:
34
+ cleaned = re.sub(r"[^a-z0-9-]+", "-", value.lower()).strip("-")
35
+ cleaned = re.sub(r"-{2,}", "-", cleaned)
36
+ return (cleaned or "hugging8n-proxy")[:63].rstrip("-")
37
+
38
+
39
+ def get_space_host() -> str:
40
+ space_host = os.environ.get("SPACE_HOST", "").strip()
41
+ if space_host:
42
+ return space_host
43
+
44
+ author = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
45
+ repo = os.environ.get("SPACE_REPO_NAME", "").strip()
46
+ if author and repo:
47
+ return f"{author}-{repo}.hf.space".lower()
48
+
49
+ return ""
50
+
51
+
52
+ def derive_keepalive_worker_name() -> str:
53
+ explicit = os.environ.get("CLOUDFLARE_KEEPALIVE_WORKER_NAME", "").strip()
54
+ if explicit:
55
+ return slugify(explicit)
56
+ space_host = get_space_host()
57
+ if space_host:
58
+ return slugify(f"{space_host.replace('.hf.space', '')}-keepalive")
59
+ return "hugging8n-keepalive"
60
+
61
+
62
+ def render_keepalive_worker(target_url: str) -> str:
63
+ return f"""addEventListener("fetch", (event) => {{
64
+ event.respondWith(handleRequest(event.request));
65
+ }});
66
+
67
+ addEventListener("scheduled", (event) => {{
68
+ event.waitUntil(ping("cron"));
69
+ }});
70
+
71
+ const TARGET_URL = {json.dumps(target_url)};
72
+
73
+ async function ping(source) {{
74
+ const startedAt = new Date().toISOString();
75
+ try {{
76
+ const response = await fetch(TARGET_URL, {{
77
+ method: "GET",
78
+ headers: {{
79
+ "user-agent": "Hugging8n Cloudflare KeepAlive",
80
+ "cache-control": "no-cache"
81
+ }},
82
+ cf: {{ cacheTtl: 0, cacheEverything: false }}
83
+ }});
84
+ return {{
85
+ ok: response.ok,
86
+ status: response.status,
87
+ source,
88
+ target: TARGET_URL,
89
+ timestamp: startedAt
90
+ }};
91
+ }} catch (error) {{
92
+ return {{
93
+ ok: false,
94
+ status: 0,
95
+ source,
96
+ target: TARGET_URL,
97
+ timestamp: startedAt,
98
+ error: error.message
99
+ }};
100
+ }}
101
+ }}
102
+
103
+ async function handleRequest(request) {{
104
+ const url = new URL(request.url);
105
+ if (url.pathname === "/" || url.pathname === "/health" || url.pathname === "/ping") {{
106
+ const result = await ping("manual");
107
+ return new Response(JSON.stringify(result, null, 2), {{
108
+ status: result.ok ? 200 : 502,
109
+ headers: {{ "content-type": "application/json; charset=utf-8" }}
110
+ }});
111
+ }}
112
+ return new Response("Not found", {{ status: 404 }});
113
+ }}
114
+ """
115
+
116
+
117
+ def write_keepalive_status(payload: dict) -> None:
118
+ payload = {
119
+ **payload,
120
+ "timestamp": payload.get("timestamp") or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
121
+ }
122
+ KEEPALIVE_STATUS_FILE.write_text(json.dumps(payload), encoding="utf-8")
123
+ try:
124
+ KEEPALIVE_STATUS_FILE.chmod(0o600)
125
+ except OSError:
126
+ pass
127
+
128
+
129
+ def resolve_account_and_subdomain(api_token: str) -> tuple[str, str]:
130
+ account_id = os.environ.get("CLOUDFLARE_ACCOUNT_ID", "").strip()
131
+ if not account_id:
132
+ accounts = cf_request("GET", "/accounts", api_token)
133
+ if not accounts:
134
+ raise RuntimeError("No Cloudflare account is available for this token.")
135
+ account_id = accounts[0]["id"]
136
+
137
+ subdomain_info = cf_request("GET", f"/accounts/{account_id}/workers/subdomain", api_token)
138
+ subdomain = (subdomain_info or {}).get("subdomain", "").strip()
139
+ if not subdomain:
140
+ raise RuntimeError("Cloudflare Workers subdomain is not configured. Enable workers.dev first.")
141
+ return account_id, subdomain
142
+
143
+
144
+ def setup_keepalive_worker(api_token: str, account_id: str, subdomain: str) -> None:
145
+ enabled = os.environ.get("CLOUDFLARE_KEEPALIVE_ENABLED", "true").strip().lower()
146
+ if enabled in {"0", "false", "no", "off"}:
147
+ write_keepalive_status({"configured": False, "status": "disabled", "message": "Cloudflare keep-awake is disabled."})
148
+ return
149
+
150
+ space_host = get_space_host()
151
+ if not space_host:
152
+ write_keepalive_status({"configured": False, "status": "skipped", "message": "SPACE_HOST could not be determined."})
153
+ return
154
+
155
+ cron = os.environ.get("CLOUDFLARE_KEEPALIVE_CRON", "*/10 * * * *").strip()
156
+ space_host = space_host.removeprefix("https://").removeprefix("http://").split("/")[0]
157
+ target_url = os.environ.get("CLOUDFLARE_KEEPALIVE_URL", f"https://{space_host}/health").strip()
158
+ worker_name = derive_keepalive_worker_name()
159
+ worker_source = render_keepalive_worker(target_url)
160
+
161
+ cf_request(
162
+ "PUT",
163
+ f"/accounts/{account_id}/workers/scripts/{worker_name}",
164
+ api_token,
165
+ body=worker_source.encode("utf-8"),
166
+ content_type="application/javascript",
167
+ )
168
+ cf_request(
169
+ "POST",
170
+ f"/accounts/{account_id}/workers/scripts/{worker_name}/subdomain",
171
+ api_token,
172
+ body=json.dumps({"enabled": True, "previews_enabled": True}).encode("utf-8"),
173
+ )
174
+ cf_request(
175
+ "PUT",
176
+ f"/accounts/{account_id}/workers/scripts/{worker_name}/schedules",
177
+ api_token,
178
+ body=json.dumps([{"cron": cron}]).encode("utf-8"),
179
+ )
180
+
181
+ worker_url = f"https://{worker_name}.{subdomain}.workers.dev"
182
+ write_keepalive_status(
183
+ {
184
+ "configured": True,
185
+ "status": "configured",
186
+ "workerName": worker_name,
187
+ "workerUrl": worker_url,
188
+ "targetUrl": target_url,
189
+ "cron": cron,
190
+ "message": f"Cloudflare Worker cron pings {target_url} on {cron}.",
191
+ }
192
+ )
193
+
194
+
195
+ def main() -> int:
196
+ api_token = os.environ.get("CLOUDFLARE_WORKERS_TOKEN", "").strip()
197
+
198
+ if not api_token:
199
+ return 0
200
+
201
+ try:
202
+ account_id, subdomain = resolve_account_and_subdomain(api_token)
203
+ setup_keepalive_worker(api_token, account_id, subdomain)
204
+ return 0
205
+ except Exception as exc:
206
+ print(f"Cloudflare keepalive setup failed: {exc}", file=sys.stderr)
207
+ write_keepalive_status({"configured": False, "status": "error", "message": str(exc)})
208
+ return 1
209
+
210
+
211
+ if __name__ == "__main__":
212
+ raise SystemExit(main())
health-server.js CHANGED
@@ -6,8 +6,7 @@ const PORT = Number(process.env.PUBLIC_PORT || 7861);
6
  const TARGET_PORT = Number(process.env.N8N_PORT || 5678);
7
  const TARGET_HOST = "127.0.0.1";
8
  const SYNC_STATUS_FILE = "/tmp/hugging8n-sync-status.json";
9
- const UPTIMEROBOT_STATUS_FILE = "/tmp/hugging8n-uptimerobot-status.json";
10
- const UPTIMEROBOT_API_KEY_SET = !!process.env.UPTIMEROBOT_API_KEY;
11
  const startTime = Date.now();
12
 
13
  function parseRequestUrl(url) {
@@ -31,10 +30,10 @@ function getStatus() {
31
  };
32
  }
33
 
34
- function getUptimeRobotStatus() {
35
  try {
36
- if (fs.existsSync(UPTIMEROBOT_STATUS_FILE)) {
37
- return JSON.parse(fs.readFileSync(UPTIMEROBOT_STATUS_FILE, "utf8"));
38
  }
39
  } catch {}
40
  return null;
@@ -62,222 +61,141 @@ function probeN8nHealth(timeoutMs = 1500) {
62
  });
63
  }
64
 
65
- function renderDashboard(data) {
66
- const { status } = data.sync;
67
- const getBadge = (status) => {
68
- let cls = "status-offline";
69
- if (
70
- status === "success" ||
71
- status === "configured" ||
72
- status === "restored" ||
73
- status === "synced"
74
- )
75
- cls = "status-online";
76
- if (status === "syncing" || status === "restoring") cls = "status-syncing";
77
- return `<div class="status-badge ${cls}">${cls === "status-online" ? '<div class="pulse"></div>' : ""}${String(status).toUpperCase()}</div>`;
78
- };
79
-
80
- const urStatus = data.uptimerobotStatus;
81
- let keepAwakeHtml;
82
- if (urStatus?.configured) {
83
- keepAwakeHtml = `
84
- <div class="helper-summary success">
85
- ${getBadge("configured")}
86
- <span>UptimeRobot monitor active for <code>${urStatus.url || "your /health endpoint"}</code>.</span>
87
- </div>`;
88
- } else if (urStatus?.configured === false) {
89
- keepAwakeHtml = `
90
- <div class="helper-summary error">
91
- ${getBadge("failed")}
92
- <span>Monitor setup failed. Check Space logs for details.</span>
93
- </div>`;
94
- } else if (UPTIMEROBOT_API_KEY_SET) {
95
- keepAwakeHtml = `
96
- <div class="helper-summary">
97
- ${getBadge("syncing")} Setting up UptimeRobot monitor&hellip;
98
- </div>`;
99
- } else {
100
- keepAwakeHtml = `
101
- <div class="helper-summary">
102
- <strong>Not configured.</strong> Add <code>UPTIMEROBOT_API_KEY</code> to Space secrets to enable keep-awake monitoring.
103
- </div>`;
104
- }
105
 
106
- return `
107
- <!DOCTYPE html>
108
- <html lang="en">
109
- <head>
110
- <meta charset="UTF-8">
111
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
112
- <title>Hugging8n Dashboard</title>
113
- <link rel="preconnect" href="https://fonts.googleapis.com">
114
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
115
- <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap" rel="stylesheet">
116
- <style>
117
- :root {
118
- --bg: #0f172a;
119
- --card: #1e293b;
120
- --accent: #6366f1;
121
- --text: #f8fafc;
122
- --text-muted: #94a3b8;
123
- --success: #22c55e;
124
- --warning: #f59e0b;
125
- --error: #ef4444;
126
- }
127
- * { box-sizing: border-box; margin: 0; padding: 0; }
128
- body {
129
- font-family: 'Outfit', sans-serif;
130
- background: var(--bg);
131
- color: var(--text);
132
- display: flex;
133
- align-items: center;
134
- justify-content: center;
135
- min-height: 100vh;
136
- padding: 20px;
137
- }
138
- .dashboard {
139
- background: var(--card);
140
- width: 100%;
141
- max-width: 500px;
142
- padding: 40px;
143
- border-radius: 24px;
144
- box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
145
- text-align: center;
146
- border: 1px solid rgba(255,255,255,0.05);
147
- }
148
- h1 { font-size: 2.5rem; margin-bottom: 8px; letter-spacing: -1px; }
149
- .subtitle { color: var(--text-muted); margin-bottom: 32px; font-weight: 300; }
150
 
151
- .stats {
152
- display: grid;
153
- grid-template-columns: 1fr 1fr;
154
- gap: 16px;
155
- margin-bottom: 24px;
156
- }
157
- .stat-card {
158
- background: rgba(255,255,255,0.03);
159
- padding: 20px;
160
- border-radius: 16px;
161
- text-align: left;
162
- }
163
- .stat-label { font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; margin-bottom: 4px; }
164
- .stat-value { font-size: 1.25rem; font-weight: 600; }
165
 
166
- .sync-box {
167
- background: rgba(255,255,255,0.03);
168
- padding: 24px;
169
- border-radius: 16px;
170
- margin-bottom: 32px;
171
- text-align: left;
172
- position: relative;
173
- }
174
- .sync-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
175
- .status-badge {
176
- padding: 4px 10px;
177
- border-radius: 20px;
178
- font-size: 0.7rem;
179
- font-weight: 600;
180
- display: flex;
181
- align-items: center;
182
- gap: 6px;
183
- }
184
- .status-online { background: rgba(34, 197, 94, 0.2); color: var(--success); }
185
- .status-syncing { background: rgba(245, 158, 11, 0.2); color: var(--warning); }
186
- .status-offline { background: rgba(239, 68, 68, 0.2); color: var(--error); }
187
 
188
- .pulse {
189
- width: 8px;
190
- height: 8px;
191
- background: currentColor;
192
- border-radius: 50%;
193
- animation: pulse 2s infinite;
194
- }
195
- @keyframes pulse {
196
- 0% { transform: scale(0.95); opacity: 0.7; }
197
- 70% { transform: scale(1.5); opacity: 0; }
198
- 100% { transform: scale(0.95); opacity: 0; }
199
- }
 
 
 
200
 
201
- .btn-primary {
202
- display: block;
203
- width: 100%;
204
- padding: 18px;
205
- background: var(--accent);
206
- color: white;
207
- text-decoration: none;
208
- border-radius: 16px;
209
- font-weight: 600;
210
- font-size: 1.1rem;
211
- transition: all 0.2s;
212
- box-shadow: 0 10px 15px -3px rgba(99, 102, 241, 0.4);
213
- margin-bottom: 32px;
214
- }
215
- .btn-primary:hover { transform: translateY(-2px); box-shadow: 0 20px 25px -5px rgba(99, 102, 241, 0.4); }
 
 
 
 
 
 
 
 
 
 
 
216
 
217
- .keep-alive {
218
- border-top: 1px solid rgba(255,255,255,0.05);
219
- padding-top: 24px;
220
- text-align: left;
221
- }
222
- .keep-alive h3 { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 12px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
 
224
- .helper-summary {
225
- margin-top: 14px;
226
- padding: 12px 14px;
227
- border-radius: 12px;
228
- background: rgba(255, 255, 255, 0.03);
229
- color: var(--text-muted);
230
- font-size: 0.85rem;
231
- line-height: 1.5;
232
- display: flex;
233
- align-items: center;
234
- gap: 10px;
235
- flex-wrap: wrap;
236
- }
237
- .helper-summary strong { color: var(--text); }
238
- .helper-summary code {
239
- background: rgba(255,255,255,0.06);
240
- padding: 2px 6px;
241
- border-radius: 6px;
242
- font-size: 0.82rem;
243
- color: var(--text);
244
- }
245
- .helper-summary.success { background: rgba(34, 197, 94, 0.08); }
246
- .helper-summary.error { background: rgba(239, 68, 68, 0.08); }
247
- </style>
248
  </head>
249
  <body>
250
- <div class="dashboard">
251
- <h1>🔗 Hugging8n</h1>
252
- <p class="subtitle">Workflow Automation Space</p>
253
-
254
- <div class="stats">
255
- <div class="stat-card">
256
- <div class="stat-label">Uptime</div>
257
- <div class="stat-value">${data.uptimeHuman}</div>
258
- </div>
259
- <div class="stat-card">
260
- <div class="stat-label">n8n Port</div>
261
- <div class="stat-value">${TARGET_PORT}</div>
262
- </div>
263
- </div>
264
-
265
- <div class="sync-box">
266
- <div class="sync-header">
267
- <div class="stat-label">Sync Status</div>
268
- ${getBadge(data.sync.status)}
269
- </div>
270
- <div class="stat-value" style="font-size: 1rem; margin-bottom: 4px;">Last Activity: ${data.sync.timestamp.split(".")[0]}Z</div>
271
- <div class="stat-label" style="text-transform: none;">${data.sync.message}</div>
272
- </div>
273
-
274
- <a href="/home/workflows" target="_blank" class="btn-primary">Open n8n Editor</a>
275
-
276
- <div class="keep-alive">
277
- <span class="stat-label">Keep Space Awake</span>
278
- ${keepAwakeHtml}
279
- </div>
280
- </div>
281
  </body>
282
  </html>`;
283
  }
@@ -295,34 +213,38 @@ const server = http.createServer(async (req, res) => {
295
  status: n8nReady ? "ok" : "degraded",
296
  n8nReady,
297
  ...getStatus(),
 
298
  }),
299
  );
300
  }
301
  if (pathname === "/status") {
302
  const uptime = Math.floor((Date.now() - startTime) / 1000);
303
  const n8nReady = await probeN8nHealth();
 
304
  return res.end(
305
  JSON.stringify({
306
  uptime: `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`,
307
  n8nReady,
308
  sync: getStatus(),
 
309
  }),
310
  );
311
  }
312
  if (pathname === "/" || pathname === "/dashboard") {
313
  const uptime = Math.floor((Date.now() - startTime) / 1000);
 
314
  res.writeHead(200, { "Content-Type": "text/html" });
315
  return res.end(
316
  renderDashboard({
317
  uptimeHuman: `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`,
 
318
  sync: getStatus(),
319
- uptimerobotStatus: getUptimeRobotStatus(),
320
  }),
321
  );
322
  }
323
 
324
  // 2. n8n Proxy Logic
325
- // Any path that isn't a dashboard route gets proxied to n8n.
326
  const proxyHeaders = {
327
  ...req.headers,
328
  host: `127.0.0.1:${TARGET_PORT}`,
@@ -394,7 +316,6 @@ server.on("upgrade", (req, socket, head) => {
394
  proxySocket.on("error", () => socket.destroy());
395
  });
396
 
397
- // Disable overall timeout for SSE, but keep keep-alive healthy
398
  server.timeout = 0;
399
  server.keepAliveTimeout = 65000;
400
  server.listen(PORT, "0.0.0.0", () =>
 
6
  const TARGET_PORT = Number(process.env.N8N_PORT || 5678);
7
  const TARGET_HOST = "127.0.0.1";
8
  const SYNC_STATUS_FILE = "/tmp/hugging8n-sync-status.json";
9
+ const CLOUDFLARE_KEEPALIVE_STATUS_FILE = "/tmp/hugging8n-cloudflare-keepalive-status.json";
 
10
  const startTime = Date.now();
11
 
12
  function parseRequestUrl(url) {
 
30
  };
31
  }
32
 
33
+ function getKeepaliveStatus() {
34
  try {
35
+ if (fs.existsSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE)) {
36
+ return JSON.parse(fs.readFileSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE, "utf8"));
37
  }
38
  } catch {}
39
  return null;
 
61
  });
62
  }
63
 
64
+ function escapeHtml(value) {
65
+ return String(value)
66
+ .replace(/&/g, "&amp;")
67
+ .replace(/</g, "&lt;")
68
+ .replace(/>/g, "&gt;")
69
+ .replace(/"/g, "&quot;");
70
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
+ function toneBadge(label, tone = "neutral") {
73
+ return `<span class="badge ${tone}">${escapeHtml(label)}</span>`;
74
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
+ function renderTile({ title, value, detail = "", tone = "neutral", meta = "" }) {
77
+ return `<article class="tile ${tone}">
78
+ <div class="tile-head">
79
+ <span class="tile-title">${escapeHtml(title)}</span>
80
+ <span class="tile-dot"></span>
81
+ </div>
82
+ <div class="tile-value">${value}</div>
83
+ ${detail ? `<div class="tile-detail">${detail}</div>` : ""}
84
+ ${meta ? `<div class="tile-meta">${meta}</div>` : ""}
85
+ </article>`;
86
+ }
 
 
 
87
 
88
+ function renderDashboard(data) {
89
+ const syncStatus = String(data.sync?.status || "unknown");
90
+ const syncTone = ["success", "restored", "synced", "configured"].includes(syncStatus)
91
+ ? "ok"
92
+ : syncStatus === "disabled"
93
+ ? "warn"
94
+ : "neutral";
95
+ const backupDetail = data.sync?.message ? escapeHtml(data.sync.message) : "No status yet";
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
+ const keepaliveConfigured = data.keepalive?.configured === true;
98
+ const keepaliveStatus = String(
99
+ data.keepalive?.status ||
100
+ (process.env.CLOUDFLARE_WORKERS_TOKEN ? "pending" : "not configured"),
101
+ );
102
+ const keepAliveTone = keepaliveConfigured
103
+ ? "ok"
104
+ : process.env.CLOUDFLARE_WORKERS_TOKEN
105
+ ? "warn"
106
+ : "neutral";
107
+ const keepAliveDetail = keepaliveConfigured
108
+ ? `Pinging <code>${escapeHtml(data.keepalive.targetUrl || "/health")}</code>`
109
+ : process.env.CLOUDFLARE_WORKERS_TOKEN
110
+ ? "Worker pending or failed"
111
+ : "Not configured";
112
 
113
+ const tiles = [
114
+ renderTile({
115
+ title: "n8n Core",
116
+ value: toneBadge(data.n8nReady ? "Online" : "Offline", data.n8nReady ? "ok" : "off"),
117
+ detail: `Internal Port ${TARGET_PORT}`,
118
+ tone: data.n8nReady ? "ok" : "off",
119
+ }),
120
+ renderTile({
121
+ title: "Runtime",
122
+ value: escapeHtml(data.uptimeHuman),
123
+ detail: `Public Port ${PORT}`,
124
+ tone: "neutral",
125
+ }),
126
+ renderTile({
127
+ title: "Backup",
128
+ value: toneBadge(syncStatus.toUpperCase(), syncTone),
129
+ detail: backupDetail,
130
+ tone: syncTone,
131
+ }),
132
+ renderTile({
133
+ title: "Keep Awake",
134
+ value: toneBadge(keepaliveConfigured ? "CF Cron" : keepaliveStatus.toUpperCase(), keepAliveTone),
135
+ detail: keepAliveDetail,
136
+ tone: keepAliveTone,
137
+ }),
138
+ ].join("");
139
 
140
+ return `<!doctype html>
141
+ <html lang="en">
142
+ <head>
143
+ <meta charset="utf-8" />
144
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
145
+ <title>Hugging8n</title>
146
+ <style>
147
+ :root { color-scheme: dark; --bg:#08080f; --panel:#12111b; --panel2:#151421; --line:#26243a; --text:#f6f4ff; --muted:#7f7a9e; --soft:#b8b3d7; --good:#22c55e; --warn:#f5c542; --bad:#fb7185; --accent:#6557df; --accent2:#7c6cf2; }
148
+ * { box-sizing:border-box; }
149
+ body { margin:0; min-height:100vh; font-family:Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); font-size:13px; }
150
+ main { width:min(720px, calc(100% - 32px)); margin:0 auto; padding:36px 0 44px; }
151
+ header { text-align:center; margin-bottom:22px; }
152
+ h1 { margin:0; font-size:1.65rem; line-height:1; letter-spacing:0; }
153
+ .subtitle { margin-top:12px; color:var(--muted); font-size:.72rem; text-transform:uppercase; letter-spacing:.14em; font-weight:800; }
154
+ .hero-action { display:flex; width:100%; min-height:46px; align-items:center; justify-content:center; border-radius:8px; background:#ffffff; color:#000000; text-decoration:none; font-weight:850; font-size:.98rem; margin:24px 0 20px; transition: background 0.15s ease; }
155
+ .hero-action:hover { background:#e5e5e5; }
156
+ .overview { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px; margin-bottom:10px; }
157
+ .tile { border:1px solid var(--line); background:var(--panel); border-radius:11px; padding:18px; min-height:124px; display:flex; flex-direction:column; gap:10px; position:relative; }
158
+ .tile.ok { border-color:rgba(34,197,94,.22); }
159
+ .tile.warn { border-color:rgba(245,197,66,.24); }
160
+ .tile.off { border-color:rgba(251,113,133,.28); }
161
+ .tile-head { display:flex; align-items:center; justify-content:space-between; gap:12px; }
162
+ .tile-title { color:var(--muted); font-size:.67rem; letter-spacing:.18em; text-transform:uppercase; font-weight:850; }
163
+ .tile-dot { width:7px; height:7px; border-radius:50%; background:var(--line); }
164
+ .tile.ok .tile-dot { background:var(--good); }
165
+ .tile.warn .tile-dot { background:var(--warn); }
166
+ .tile.off .tile-dot { background:var(--bad); }
167
+ .tile-value { font-size:1.12rem; font-weight:850; overflow-wrap:anywhere; }
168
+ .tile-detail { color:var(--soft); line-height:1.45; font-size:.83rem; }
169
+ .tile-meta { color:var(--muted); line-height:1.4; font-size:.75rem; margin-top:auto; overflow-wrap:anywhere; }
170
 
171
+ code { background:#232234; border:1px solid #34324c; border-radius:6px; padding:2px 6px; color:var(--text); font-size:.9em; }
172
+ pre { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; background:#0d0d0d; border:1px solid var(--line); border-radius:7px; padding:10px; color:var(--soft); font-size:.82rem; line-height:1.45; }
173
+ .row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; }
174
+ .badge { display:inline-flex; align-items:center; width:max-content; border:1px solid var(--line); border-radius:999px; padding:5px 10px; font-size:.72rem; font-weight:850; line-height:1; text-transform:uppercase; }
175
+ .badge.ok { color:var(--good); border-color:rgba(34,197,94,.34); background:rgba(34,197,94,.11); }
176
+ .badge.warn { color:var(--warn); border-color:rgba(245,197,66,.34); background:rgba(245,197,66,.11); }
177
+ .badge.off { color:var(--bad); border-color:rgba(251,113,133,.34); background:rgba(251,113,133,.11); }
178
+ .badge.neutral { color:var(--soft); }
179
+ .muted { color:var(--muted); }
180
+ .button { display:inline-flex; align-items:center; justify-content:center; min-height:40px; padding:0 16px; border-radius:8px; color:#fff; background:var(--accent); text-decoration:none; font-weight:850; font-size:.9rem; }
181
+ .button.secondary { color:var(--text); background:#242424; border:1px solid var(--line); }
182
+ footer { color:var(--muted); text-align:center; font-size:.74rem; margin-top:18px; }
183
+ footer .live { color:var(--good); }
184
+ @media (max-width: 700px) { .overview { grid-template-columns:1fr; } main { width:min(100% - 22px, 720px); padding-top:28px; } }
185
+ </style>
 
 
 
 
 
 
 
 
 
186
  </head>
187
  <body>
188
+ <main>
189
+ <header>
190
+ <h1>Hugging8n</h1>
191
+ <div class="subtitle">Workflow Automation Space</div>
192
+ </header>
193
+ <a class="hero-action" href="/home/workflows" target="_blank" rel="noopener noreferrer">Open n8n Editor -&gt;</a>
194
+ <section class="overview">
195
+ ${tiles}
196
+ </section>
197
+ <footer><span class="live">Live</span> status - Health endpoint: <code>/health</code></footer>
198
+ </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  </body>
200
  </html>`;
201
  }
 
213
  status: n8nReady ? "ok" : "degraded",
214
  n8nReady,
215
  ...getStatus(),
216
+ keepalive: getKeepaliveStatus(),
217
  }),
218
  );
219
  }
220
  if (pathname === "/status") {
221
  const uptime = Math.floor((Date.now() - startTime) / 1000);
222
  const n8nReady = await probeN8nHealth();
223
+ res.writeHead(200, { "Content-Type": "application/json" });
224
  return res.end(
225
  JSON.stringify({
226
  uptime: `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`,
227
  n8nReady,
228
  sync: getStatus(),
229
+ keepalive: getKeepaliveStatus(),
230
  }),
231
  );
232
  }
233
  if (pathname === "/" || pathname === "/dashboard") {
234
  const uptime = Math.floor((Date.now() - startTime) / 1000);
235
+ const n8nReady = await probeN8nHealth();
236
  res.writeHead(200, { "Content-Type": "text/html" });
237
  return res.end(
238
  renderDashboard({
239
  uptimeHuman: `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`,
240
+ n8nReady,
241
  sync: getStatus(),
242
+ keepalive: getKeepaliveStatus(),
243
  }),
244
  );
245
  }
246
 
247
  // 2. n8n Proxy Logic
 
248
  const proxyHeaders = {
249
  ...req.headers,
250
  host: `127.0.0.1:${TARGET_PORT}`,
 
316
  proxySocket.on("error", () => socket.destroy());
317
  });
318
 
 
319
  server.timeout = 0;
320
  server.keepAliveTimeout = 65000;
321
  server.listen(PORT, "0.0.0.0", () =>
setup-uptimerobot.sh DELETED
@@ -1,74 +0,0 @@
1
- #!/bin/bash
2
- set -euo pipefail
3
-
4
- API_URL="https://api.uptimerobot.com/v2"
5
- API_KEY="${UPTIMEROBOT_API_KEY:-}"
6
- SPACE_HOST_INPUT="${1:-${SPACE_HOST:-}}"
7
- STATUS_FILE="/tmp/hugging8n-uptimerobot-status.json"
8
-
9
- if [ -z "$API_KEY" ]; then
10
- echo "Missing UPTIMEROBOT_API_KEY."
11
- exit 1
12
- fi
13
-
14
- if [ -z "$SPACE_HOST_INPUT" ]; then
15
- echo "Missing Space host."
16
- echo "Usage: UPTIMEROBOT_API_KEY=... ./setup-uptimerobot.sh your-space.hf.space"
17
- exit 1
18
- fi
19
-
20
- SPACE_HOST_CLEAN="${SPACE_HOST_INPUT#https://}"
21
- SPACE_HOST_CLEAN="${SPACE_HOST_CLEAN#http://}"
22
- SPACE_HOST_CLEAN="${SPACE_HOST_CLEAN%%/*}"
23
-
24
- MONITOR_URL="https://${SPACE_HOST_CLEAN}/health"
25
- MONITOR_NAME="${UPTIMEROBOT_MONITOR_NAME:-Hugging8n ${SPACE_HOST_CLEAN}}"
26
- INTERVAL="${UPTIMEROBOT_INTERVAL:-300}"
27
-
28
- MONITORS_RESPONSE=$(curl -sS -X POST "${API_URL}/getMonitors" \
29
- -d "api_key=${API_KEY}" \
30
- -d "format=json" \
31
- -d "logs=0" \
32
- -d "response_times=0" \
33
- -d "response_times_limit=1")
34
-
35
- MONITOR_ID=$(printf '%s' "$MONITORS_RESPONSE" | jq -r --arg url "$MONITOR_URL" '
36
- (.monitors // []) | map(select(.url == $url)) | first | .id // empty
37
- ')
38
-
39
- if [ -n "$MONITOR_ID" ]; then
40
- printf '{"configured":true,"monitorId":"%s","url":"%s","alreadyExisted":true,"timestamp":"%s"}\n' \
41
- "$MONITOR_ID" "$MONITOR_URL" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS_FILE"
42
- echo "Monitor already exists (id=${MONITOR_ID}) for ${MONITOR_URL}"
43
- exit 0
44
- fi
45
-
46
- CURL_ARGS=(
47
- -sS
48
- -X POST "${API_URL}/newMonitor"
49
- -d "api_key=${API_KEY}"
50
- -d "format=json"
51
- -d "type=1"
52
- -d "friendly_name=${MONITOR_NAME}"
53
- -d "url=${MONITOR_URL}"
54
- -d "interval=${INTERVAL}"
55
- )
56
-
57
- if [ -n "${UPTIMEROBOT_ALERT_CONTACTS:-}" ]; then
58
- CURL_ARGS+=(-d "alert_contacts=${UPTIMEROBOT_ALERT_CONTACTS}")
59
- fi
60
-
61
- CREATE_RESPONSE=$(curl "${CURL_ARGS[@]}")
62
- CREATE_STATUS=$(printf '%s' "$CREATE_RESPONSE" | jq -r '.stat // "fail"')
63
-
64
- if [ "$CREATE_STATUS" != "ok" ]; then
65
- printf '{"configured":false,"error":"creation failed","timestamp":"%s"}\n' \
66
- "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS_FILE"
67
- printf '%s\n' "$CREATE_RESPONSE"
68
- exit 1
69
- fi
70
-
71
- NEW_ID=$(printf '%s' "$CREATE_RESPONSE" | jq -r '.monitor.id // empty')
72
- printf '{"configured":true,"monitorId":"%s","url":"%s","timestamp":"%s"}\n' \
73
- "${NEW_ID:-}" "$MONITOR_URL" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS_FILE"
74
- echo "Created UptimeRobot monitor ${NEW_ID:-"(id unavailable)"} for ${MONITOR_URL}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
start.sh CHANGED
@@ -118,9 +118,9 @@ fi
118
  node "$APP_DIR/health-server.js" &
119
  PROXY_PID=$!
120
 
121
- if [ -n "${UPTIMEROBOT_API_KEY:-}" ] && [ -n "${SPACE_HOST_DETECTED:-}" ]; then
122
- echo "Setting up UptimeRobot monitor..."
123
- bash "$APP_DIR/setup-uptimerobot.sh" "$SPACE_HOST_DETECTED" || true
124
  fi
125
 
126
  n8n start &
 
118
  node "$APP_DIR/health-server.js" &
119
  PROXY_PID=$!
120
 
121
+ if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ]; then
122
+ echo "Setting up Cloudflare KeepAlive monitor..."
123
+ python3 "$APP_DIR/cloudflare-keepalive-setup.py" || true
124
  fi
125
 
126
  n8n start &