pi-freeflow 1.4.2 → 1.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +323 -279
- package/package.json +1 -1
- package/src/catalog.ts +250 -207
- package/src/commands.ts +670 -618
- package/src/config.ts +144 -145
- package/src/deploy.ts +496 -126
- package/src/index.ts +301 -277
- package/src/models.ts +355 -399
- package/src/proxy.ts +493 -500
- package/src/relay.ts +213 -210
- package/src/stream-pipe.ts +265 -263
package/src/deploy.ts
CHANGED
|
@@ -1,126 +1,496 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Automated Vercel Edge Relay deployer for pi-freeflow
|
|
3
|
-
*
|
|
4
|
-
* Deploys a private 3-file Vercel edge proxy with strict target domain whitelisting.
|
|
5
|
-
* The provided API token is held in-memory only and never persisted to disk or logs.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { VERCEL_API } from "./config.ts";
|
|
9
|
-
import { log, logError } from "./logger.ts";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Hardened Edge Worker code deployed to Vercel.
|
|
13
|
-
* Strictly whitelists OpenCode Zen and KiloCode Gateway endpoints to prevent open proxy abuse.
|
|
14
|
-
*/
|
|
15
|
-
export const VERCEL_RELAY_WORKER = `// Only the 2 upstreams pi-freeflow talks to. Anything else = open proxy abuse.
|
|
16
|
-
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
17
|
-
export const config = { runtime: "edge" };
|
|
18
|
-
export default async function handler(req) {
|
|
19
|
-
const target = req.headers.get("x-relay-target");
|
|
20
|
-
const relayPath = req.headers.get("x-relay-path") || "/";
|
|
21
|
-
if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
22
|
-
const cleanTarget = target.replace(/\\/$/, "");
|
|
23
|
-
if (!ALLOWED_TARGETS.includes(cleanTarget)) return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
24
|
-
if (!relayPath.startsWith("/")) return new Response(JSON.stringify({ error: "Bad path" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
25
|
-
const targetUrl = cleanTarget + relayPath;
|
|
26
|
-
const headers = new Headers(req.headers);
|
|
27
|
-
headers.delete("x-relay-target"); headers.delete("x-relay-path"); headers.delete("host");
|
|
28
|
-
const response = await fetch(targetUrl, { method: req.method, headers, body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined, duplex: "half" });
|
|
29
|
-
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
30
|
-
}`;
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Deploy a fresh Vercel Edge Relay project in-memory.
|
|
34
|
-
*
|
|
35
|
-
* @param token Vercel personal access token (used in-memory only)
|
|
36
|
-
* @param name Unique project/deployment name (e.g. pi-freeflow-relay-abc123)
|
|
37
|
-
* @param onProgress Optional callback for user-facing progress updates
|
|
38
|
-
* @returns Deployed HTTPS relay URL
|
|
39
|
-
*/
|
|
40
|
-
export async function deployVercelRelay(
|
|
41
|
-
token: string,
|
|
42
|
-
name: string,
|
|
43
|
-
onProgress?: (msg: string) => void,
|
|
44
|
-
): Promise<string> {
|
|
45
|
-
const auth = {
|
|
46
|
-
Authorization: `Bearer ${token}`,
|
|
47
|
-
"Content-Type": "application/json",
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
// 1. Create deployment (3 inline files, no git repository required)
|
|
51
|
-
onProgress?.("Uploading relay files to Vercel…");
|
|
52
|
-
log("info", `Starting Vercel deployment: ${name}`);
|
|
53
|
-
|
|
54
|
-
const dep = await fetch(`${VERCEL_API}/v13/deployments`, {
|
|
55
|
-
method: "POST",
|
|
56
|
-
headers: auth,
|
|
57
|
-
body: JSON.stringify({
|
|
58
|
-
name,
|
|
59
|
-
files: [
|
|
60
|
-
{ file: "api/relay.js", data: VERCEL_RELAY_WORKER },
|
|
61
|
-
{
|
|
62
|
-
file: "package.json",
|
|
63
|
-
data: JSON.stringify({ name, version: "1.0.0" }),
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
file: "vercel.json",
|
|
67
|
-
data: JSON.stringify({
|
|
68
|
-
rewrites: [{ source: "/(.*)", destination: "/api/relay" }],
|
|
69
|
-
}),
|
|
70
|
-
},
|
|
71
|
-
],
|
|
72
|
-
projectSettings: { framework: null },
|
|
73
|
-
target: "production",
|
|
74
|
-
}),
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
if (!dep.ok) {
|
|
78
|
-
const e = (await dep
|
|
79
|
-
.json()
|
|
80
|
-
.catch(() => ({}))) as { error?: { message?: string } };
|
|
81
|
-
const errMsg = e?.error?.message || `Vercel deploy failed (HTTP ${dep.status})`;
|
|
82
|
-
logError(`Vercel deployment failed to create: ${errMsg}`);
|
|
83
|
-
throw new Error(errMsg);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const depJson = (await dep.json()) as { id?: string; uid?: string; projectId?: string };
|
|
87
|
-
const depId = depJson.id || depJson.uid;
|
|
88
|
-
const projectId = depJson.projectId || name;
|
|
89
|
-
|
|
90
|
-
// 2. Make the deployment public (disable SSO protection if enabled on team)
|
|
91
|
-
try {
|
|
92
|
-
await fetch(`${VERCEL_API}/v9/projects/${projectId}`, {
|
|
93
|
-
method: "PATCH",
|
|
94
|
-
headers: auth,
|
|
95
|
-
body: JSON.stringify({ ssoProtection: null }),
|
|
96
|
-
});
|
|
97
|
-
} catch {}
|
|
98
|
-
|
|
99
|
-
// 3. Poll until READY state (3s interval, 120s maximum timeout)
|
|
100
|
-
onProgress?.("Waiting for Edge deployment to go live…");
|
|
101
|
-
const deadline = Date.now() + 120_000;
|
|
102
|
-
|
|
103
|
-
while (Date.now() < deadline) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Automated Vercel Edge Relay deployer for pi-freeflow
|
|
3
|
+
*
|
|
4
|
+
* Deploys a private 3-file Vercel edge proxy with strict target domain whitelisting.
|
|
5
|
+
* The provided API token is held in-memory only and never persisted to disk or logs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { VERCEL_API } from "./config.ts";
|
|
9
|
+
import { log, logError } from "./logger.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Hardened Edge Worker code deployed to Vercel.
|
|
13
|
+
* Strictly whitelists OpenCode Zen and KiloCode Gateway endpoints to prevent open proxy abuse.
|
|
14
|
+
*/
|
|
15
|
+
export const VERCEL_RELAY_WORKER = `// Only the 2 upstreams pi-freeflow talks to. Anything else = open proxy abuse.
|
|
16
|
+
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
17
|
+
export const config = { runtime: "edge" };
|
|
18
|
+
export default async function handler(req) {
|
|
19
|
+
const target = req.headers.get("x-relay-target");
|
|
20
|
+
const relayPath = req.headers.get("x-relay-path") || "/";
|
|
21
|
+
if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
22
|
+
const cleanTarget = target.replace(/\\/$/, "");
|
|
23
|
+
if (!ALLOWED_TARGETS.includes(cleanTarget)) return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
24
|
+
if (!relayPath.startsWith("/")) return new Response(JSON.stringify({ error: "Bad path" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
25
|
+
const targetUrl = cleanTarget + relayPath;
|
|
26
|
+
const headers = new Headers(req.headers);
|
|
27
|
+
headers.delete("x-relay-target"); headers.delete("x-relay-path"); headers.delete("host");
|
|
28
|
+
const response = await fetch(targetUrl, { method: req.method, headers, body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined, duplex: "half" });
|
|
29
|
+
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
30
|
+
}`;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deploy a fresh Vercel Edge Relay project in-memory.
|
|
34
|
+
*
|
|
35
|
+
* @param token Vercel personal access token (used in-memory only)
|
|
36
|
+
* @param name Unique project/deployment name (e.g. pi-freeflow-relay-abc123)
|
|
37
|
+
* @param onProgress Optional callback for user-facing progress updates
|
|
38
|
+
* @returns Deployed HTTPS relay URL
|
|
39
|
+
*/
|
|
40
|
+
export async function deployVercelRelay(
|
|
41
|
+
token: string,
|
|
42
|
+
name: string,
|
|
43
|
+
onProgress?: (msg: string) => void,
|
|
44
|
+
): Promise<string> {
|
|
45
|
+
const auth = {
|
|
46
|
+
Authorization: `Bearer ${token}`,
|
|
47
|
+
"Content-Type": "application/json",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// 1. Create deployment (3 inline files, no git repository required)
|
|
51
|
+
onProgress?.("Uploading relay files to Vercel…");
|
|
52
|
+
log("info", `Starting Vercel deployment: ${name}`);
|
|
53
|
+
|
|
54
|
+
const dep = await fetch(`${VERCEL_API}/v13/deployments`, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: auth,
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
name,
|
|
59
|
+
files: [
|
|
60
|
+
{ file: "api/relay.js", data: VERCEL_RELAY_WORKER },
|
|
61
|
+
{
|
|
62
|
+
file: "package.json",
|
|
63
|
+
data: JSON.stringify({ name, version: "1.0.0" }),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
file: "vercel.json",
|
|
67
|
+
data: JSON.stringify({
|
|
68
|
+
rewrites: [{ source: "/(.*)", destination: "/api/relay" }],
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
projectSettings: { framework: null },
|
|
73
|
+
target: "production",
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (!dep.ok) {
|
|
78
|
+
const e = (await dep
|
|
79
|
+
.json()
|
|
80
|
+
.catch(() => ({}))) as { error?: { message?: string } };
|
|
81
|
+
const errMsg = e?.error?.message || `Vercel deploy failed (HTTP ${dep.status})`;
|
|
82
|
+
logError(`Vercel deployment failed to create: ${errMsg}`);
|
|
83
|
+
throw new Error(errMsg);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const depJson = (await dep.json()) as { id?: string; uid?: string; projectId?: string };
|
|
87
|
+
const depId = depJson.id || depJson.uid;
|
|
88
|
+
const projectId = depJson.projectId || name;
|
|
89
|
+
|
|
90
|
+
// 2. Make the deployment public (disable SSO protection if enabled on team)
|
|
91
|
+
try {
|
|
92
|
+
await fetch(`${VERCEL_API}/v9/projects/${projectId}`, {
|
|
93
|
+
method: "PATCH",
|
|
94
|
+
headers: auth,
|
|
95
|
+
body: JSON.stringify({ ssoProtection: null }),
|
|
96
|
+
});
|
|
97
|
+
} catch {}
|
|
98
|
+
|
|
99
|
+
// 3. Poll until READY state (3s interval, 120s maximum timeout)
|
|
100
|
+
onProgress?.("Waiting for Edge deployment to go live…");
|
|
101
|
+
const deadline = Date.now() + 120_000;
|
|
102
|
+
|
|
103
|
+
while (Date.now() < deadline) {
|
|
104
|
+
let s: Response | null = null;
|
|
105
|
+
try {
|
|
106
|
+
s = await fetch(`${VERCEL_API}/v13/deployments/${depId}`, {
|
|
107
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
108
|
+
});
|
|
109
|
+
} catch (err) {
|
|
110
|
+
log(
|
|
111
|
+
"warn",
|
|
112
|
+
`Vercel deployment status poll failed, retrying: ${(err as Error).message}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if (s?.ok) {
|
|
116
|
+
const j = (await s.json()) as { readyState?: string; url?: string };
|
|
117
|
+
if (j.readyState === "READY" && j.url) {
|
|
118
|
+
const deployedUrl = `https://${j.url}`;
|
|
119
|
+
log("info", `Vercel relay successfully deployed: ${deployedUrl}`);
|
|
120
|
+
return deployedUrl;
|
|
121
|
+
}
|
|
122
|
+
if (j.readyState === "ERROR" || j.readyState === "CANCELED") {
|
|
123
|
+
const err = `Deployment failed with state: ${j.readyState}`;
|
|
124
|
+
logError(err);
|
|
125
|
+
throw new Error(err);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
await new Promise<void>((r) => setTimeout(r, 3000));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const timeoutErr = "Deployment timed out (120s)";
|
|
132
|
+
logError(timeoutErr);
|
|
133
|
+
throw new Error(timeoutErr);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Multi-platform deployment (Cloudflare Workers / Deno Deploy) ────
|
|
137
|
+
|
|
138
|
+
const CLOUDFLARE_API = "https://api.cloudflare.com/client/v4";
|
|
139
|
+
const DENO_API = "https://api.deno.com/v2";
|
|
140
|
+
|
|
141
|
+
export type DeployPlatform = "vercel" | "cloudflare" | "deno";
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Module Worker relay deployed to Cloudflare Workers.
|
|
145
|
+
* Same whitelist contract as the Vercel Edge relay, without Vercel's
|
|
146
|
+
* `config` export or undici-only `duplex` flag (plain body passthrough).
|
|
147
|
+
*/
|
|
148
|
+
export const CLOUDFLARE_RELAY_WORKER = `// Only the 2 upstreams this relay talks to. Anything else = open proxy abuse.
|
|
149
|
+
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
150
|
+
export default {
|
|
151
|
+
async fetch(request) {
|
|
152
|
+
const target = request.headers.get("x-relay-target");
|
|
153
|
+
const relayPath = request.headers.get("x-relay-path") || "/";
|
|
154
|
+
if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
155
|
+
const cleanTarget = target.replace(/\\/$/, "");
|
|
156
|
+
if (!ALLOWED_TARGETS.includes(cleanTarget)) return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
157
|
+
if (!relayPath.startsWith("/")) return new Response(JSON.stringify({ error: "Bad path" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
158
|
+
const headers = new Headers(request.headers);
|
|
159
|
+
headers.delete("x-relay-target"); headers.delete("x-relay-path"); headers.delete("host");
|
|
160
|
+
try {
|
|
161
|
+
const response = await fetch(cleanTarget + relayPath, { method: request.method, headers, body: request.method !== "GET" && request.method !== "HEAD" ? request.body : undefined });
|
|
162
|
+
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return new Response(JSON.stringify({ error: String(error) }), { status: 502, headers: { "content-type": "application/json" } });
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
};`;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Relay script deployed to Deno Deploy (Deno.serve variant).
|
|
171
|
+
* Same whitelist contract; plain streaming passthrough, no duplex flag.
|
|
172
|
+
*/
|
|
173
|
+
export const DENO_RELAY_SCRIPT = `// Only the 2 upstreams this relay talks to. Anything else = open proxy abuse.
|
|
174
|
+
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
175
|
+
Deno.serve(async (request) => {
|
|
176
|
+
const target = request.headers.get("x-relay-target");
|
|
177
|
+
const relayPath = request.headers.get("x-relay-path") || "/";
|
|
178
|
+
if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
179
|
+
const cleanTarget = target.replace(/\\/$/, "");
|
|
180
|
+
if (!ALLOWED_TARGETS.includes(cleanTarget)) return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
181
|
+
if (!relayPath.startsWith("/")) return new Response(JSON.stringify({ error: "Bad path" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
182
|
+
const headers = new Headers(request.headers);
|
|
183
|
+
headers.delete("x-relay-target"); headers.delete("x-relay-path"); headers.delete("host");
|
|
184
|
+
try {
|
|
185
|
+
const response = await fetch(cleanTarget + relayPath, { method: request.method, headers, body: request.method !== "GET" && request.method !== "HEAD" ? request.body : undefined });
|
|
186
|
+
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
187
|
+
} catch (error) {
|
|
188
|
+
return new Response(JSON.stringify({ error: String(error) }), { status: 502, headers: { "content-type": "application/json" } });
|
|
189
|
+
}
|
|
190
|
+
});`;
|
|
191
|
+
|
|
192
|
+
function baseRelayName(name: string): string {
|
|
193
|
+
return name
|
|
194
|
+
.toLowerCase()
|
|
195
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
196
|
+
.replace(/-{2,}/g, "-")
|
|
197
|
+
.replace(/^-+|-+$/g, "");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Cloudflare Worker script names: [a-z0-9-], max 58 chars. */
|
|
201
|
+
function cloudflareScriptName(name: string): string {
|
|
202
|
+
const clean = baseRelayName(name)
|
|
203
|
+
.slice(0, 58)
|
|
204
|
+
.replace(/-+$/g, "");
|
|
205
|
+
return clean || "relay-worker";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Deno Deploy app slugs: [a-z0-9-], 3-32 chars, no edge/consecutive hyphens. */
|
|
209
|
+
function denoProjectName(name: string): string {
|
|
210
|
+
const clean = baseRelayName(name)
|
|
211
|
+
.slice(0, 32)
|
|
212
|
+
.replace(/-+$/, "");
|
|
213
|
+
if (!clean) return "relay-app";
|
|
214
|
+
return clean.length < 3 ? `${clean}-relay` : clean;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function cloudflareError(action: string, res: Response): Promise<Error> {
|
|
218
|
+
const body = (await res
|
|
219
|
+
.json()
|
|
220
|
+
.catch(() => ({}))) as { errors?: Array<{ message?: string }> };
|
|
221
|
+
const detail = body.errors?.[0]?.message || `HTTP ${res.status}`;
|
|
222
|
+
let err: Error;
|
|
223
|
+
if (res.status === 401 || res.status === 403) {
|
|
224
|
+
err = /quota|limit|exceeded/i.test(detail)
|
|
225
|
+
? new Error(`Cloudflare plan or usage limit hit while trying to ${action}: ${detail}. Check your Workers plan limits.`)
|
|
226
|
+
: new Error(`Cloudflare authentication failed while trying to ${action}: ${detail}. Check that your API token is valid and has Workers permissions.`);
|
|
227
|
+
} else {
|
|
228
|
+
err = new Error(`Failed to ${action} (HTTP ${res.status}): ${detail}`);
|
|
229
|
+
}
|
|
230
|
+
logError(err.message);
|
|
231
|
+
return err;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function denoError(action: string, res: Response, override?: string): Promise<Error> {
|
|
235
|
+
if (override) {
|
|
236
|
+
logError(override);
|
|
237
|
+
return new Error(override);
|
|
238
|
+
}
|
|
239
|
+
const raw = await res.text().catch(() => "");
|
|
240
|
+
let detail = raw;
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(raw) as { error?: { message?: string } };
|
|
243
|
+
detail = parsed.error?.message || raw;
|
|
244
|
+
} catch {}
|
|
245
|
+
let err: Error;
|
|
246
|
+
if (res.status === 401 || res.status === 403) {
|
|
247
|
+
err = /quota|limit|exceeded/i.test(detail)
|
|
248
|
+
? new Error(`Deno Deploy plan or usage limit hit while trying to ${action}: ${detail}. Check your organization's limits.`)
|
|
249
|
+
: new Error(`Deno Deploy authentication failed while trying to ${action}: ${detail}. Check that your access token is valid.`);
|
|
250
|
+
} else {
|
|
251
|
+
err = new Error(`Failed to ${action} (HTTP ${res.status}): ${detail}`);
|
|
252
|
+
}
|
|
253
|
+
logError(err.message);
|
|
254
|
+
return err;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Deploy a fresh Cloudflare Workers relay (module worker) in-memory.
|
|
259
|
+
*
|
|
260
|
+
* @param token Cloudflare API token (used in-memory only)
|
|
261
|
+
* @param name Unique worker/script name (sanitized to [a-z0-9-])
|
|
262
|
+
* @param onProgress Optional callback for user-facing progress updates
|
|
263
|
+
* @returns Public *.workers.dev relay URL
|
|
264
|
+
*/
|
|
265
|
+
export async function deployCloudflareWorker(
|
|
266
|
+
token: string,
|
|
267
|
+
name: string,
|
|
268
|
+
onProgress?: (msg: string) => void,
|
|
269
|
+
): Promise<string> {
|
|
270
|
+
const auth = { Authorization: `Bearer ${token}` };
|
|
271
|
+
const scriptName = cloudflareScriptName(name);
|
|
272
|
+
|
|
273
|
+
// 1. Resolve the account scoped to this token
|
|
274
|
+
onProgress?.("Resolving Cloudflare account…");
|
|
275
|
+
log("info", `Starting Cloudflare Worker deployment: ${scriptName}`);
|
|
276
|
+
const accRes = await fetch(`${CLOUDFLARE_API}/accounts`, { headers: auth });
|
|
277
|
+
if (!accRes.ok) throw await cloudflareError("resolve Cloudflare account", accRes);
|
|
278
|
+
const accJson = (await accRes.json()) as { result?: Array<{ id?: string }> };
|
|
279
|
+
const accountId = accJson.result?.[0]?.id;
|
|
280
|
+
if (!accountId) {
|
|
281
|
+
const err = "No Cloudflare account is accessible with this API token";
|
|
282
|
+
logError(err);
|
|
283
|
+
throw new Error(err);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 2. Upload the module worker script (multipart: main module + metadata)
|
|
287
|
+
onProgress?.("Uploading relay worker to Cloudflare…");
|
|
288
|
+
const formData = new FormData();
|
|
289
|
+
formData.append(
|
|
290
|
+
"index.js",
|
|
291
|
+
new Blob([CLOUDFLARE_RELAY_WORKER], { type: "application/javascript+module" }),
|
|
292
|
+
"index.js",
|
|
293
|
+
);
|
|
294
|
+
formData.append(
|
|
295
|
+
"metadata",
|
|
296
|
+
new Blob(
|
|
297
|
+
[
|
|
298
|
+
JSON.stringify({
|
|
299
|
+
main_module: "index.js",
|
|
300
|
+
compatibility_date: "2024-03-20",
|
|
301
|
+
observability: { enabled: true },
|
|
302
|
+
}),
|
|
303
|
+
],
|
|
304
|
+
{ type: "application/json" },
|
|
305
|
+
),
|
|
306
|
+
"metadata.json",
|
|
307
|
+
);
|
|
308
|
+
const uploadRes = await fetch(
|
|
309
|
+
`${CLOUDFLARE_API}/accounts/${accountId}/workers/scripts/${scriptName}`,
|
|
310
|
+
{ method: "PUT", headers: auth, body: formData },
|
|
311
|
+
);
|
|
312
|
+
if (!uploadRes.ok) throw await cloudflareError("upload Worker to Cloudflare", uploadRes);
|
|
313
|
+
|
|
314
|
+
// 3. Enable workers.dev routing for the script (non-fatal if it fails)
|
|
315
|
+
try {
|
|
316
|
+
await fetch(`${CLOUDFLARE_API}/accounts/${accountId}/workers/scripts/${scriptName}/subdomain`, {
|
|
317
|
+
method: "POST",
|
|
318
|
+
headers: { ...auth, "Content-Type": "application/json" },
|
|
319
|
+
body: JSON.stringify({ enabled: true }),
|
|
320
|
+
});
|
|
321
|
+
} catch {}
|
|
322
|
+
|
|
323
|
+
// 4. Read the account-level workers.dev subdomain to assemble the public URL
|
|
324
|
+
onProgress?.("Reading workers.dev routing…");
|
|
325
|
+
const subRes = await fetch(`${CLOUDFLARE_API}/accounts/${accountId}/workers/subdomain`, { headers: auth });
|
|
326
|
+
if (!subRes.ok) throw await cloudflareError("retrieve workers.dev subdomain", subRes);
|
|
327
|
+
const subJson = (await subRes.json()) as { result?: { subdomain?: string } };
|
|
328
|
+
const subdomain = subJson.result?.subdomain;
|
|
329
|
+
if (!subdomain) {
|
|
330
|
+
const err = "Worker deployed but workers.dev subdomain is unavailable. Enable a workers.dev subdomain for your account in the Cloudflare dashboard.";
|
|
331
|
+
logError(err);
|
|
332
|
+
throw new Error(err);
|
|
333
|
+
}
|
|
334
|
+
const url = `https://${scriptName}.${subdomain}.workers.dev`;
|
|
335
|
+
log("info", `Cloudflare relay successfully deployed: ${url}`);
|
|
336
|
+
return url;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
type DenoRevision = {
|
|
340
|
+
status?: string;
|
|
341
|
+
failure_reason?: string | null;
|
|
342
|
+
timelines?: Array<{ slug?: string; domains?: Array<{ domain?: string }> }>;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
function resolveRoutedDomain(revision: DenoRevision): string | null {
|
|
346
|
+
const timelines = revision.timelines ?? [];
|
|
347
|
+
const production = timelines.find((t) => t.slug === "production") ?? timelines[0];
|
|
348
|
+
const host = (production?.domains ?? [])
|
|
349
|
+
.map((d) => d.domain ?? "")
|
|
350
|
+
.find((h) => h.length > 0);
|
|
351
|
+
return host ?? null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function firstManagedDenoDomain(token: string): Promise<string | null> {
|
|
355
|
+
const res = await fetch(`${DENO_API}/domains`, {
|
|
356
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
357
|
+
});
|
|
358
|
+
if (!res.ok) return null;
|
|
359
|
+
const list = (await res.json().catch(() => [])) as Array<{ domain?: string }>;
|
|
360
|
+
const managed = (Array.isArray(list) ? list : [])
|
|
361
|
+
.map((d) => d.domain ?? "")
|
|
362
|
+
.find((h) => h.endsWith(".deno.net"));
|
|
363
|
+
return managed ? managed.replace(/^\*\./, "") : null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Deploy a fresh Deno Deploy relay (Deno.serve script) in-memory.
|
|
368
|
+
* The v2 API is scoped to the token's organization, so no org input is needed.
|
|
369
|
+
*
|
|
370
|
+
* @param token Deno Deploy organization access token (used in-memory only)
|
|
371
|
+
* @param name Unique app/project name (sanitized to a valid slug)
|
|
372
|
+
* @param onProgress Optional callback for user-facing progress updates
|
|
373
|
+
* @returns Public *.deno.dev-style (*.deno.net) relay URL
|
|
374
|
+
*/
|
|
375
|
+
export async function deployDenoRelay(
|
|
376
|
+
token: string,
|
|
377
|
+
name: string,
|
|
378
|
+
onProgress?: (msg: string) => void,
|
|
379
|
+
): Promise<string> {
|
|
380
|
+
const auth = { Authorization: `Bearer ${token}` };
|
|
381
|
+
const jsonHeaders = { ...auth, "Content-Type": "application/json" };
|
|
382
|
+
const slug = denoProjectName(name);
|
|
383
|
+
|
|
384
|
+
// 1. Create the app
|
|
385
|
+
onProgress?.("Creating Deno Deploy app…");
|
|
386
|
+
log("info", `Starting Deno Deploy deployment: ${slug}`);
|
|
387
|
+
const createRes = await fetch(`${DENO_API}/apps`, {
|
|
388
|
+
method: "POST",
|
|
389
|
+
headers: jsonHeaders,
|
|
390
|
+
body: JSON.stringify({
|
|
391
|
+
slug,
|
|
392
|
+
labels: { "custom.kind": "relay" },
|
|
393
|
+
config: {
|
|
394
|
+
install: "deno install",
|
|
395
|
+
runtime: { type: "dynamic", entrypoint: "main.ts" },
|
|
396
|
+
},
|
|
397
|
+
}),
|
|
398
|
+
});
|
|
399
|
+
if (!createRes.ok) {
|
|
400
|
+
throw await denoError(
|
|
401
|
+
`create Deno Deploy app "${slug}"`,
|
|
402
|
+
createRes,
|
|
403
|
+
createRes.status === 409
|
|
404
|
+
? `An app named "${slug}" already exists on Deno Deploy — choose a different name.`
|
|
405
|
+
: undefined,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
const app = (await createRes.json()) as { id?: string };
|
|
409
|
+
const appId = app.id;
|
|
410
|
+
if (!appId) {
|
|
411
|
+
const err = "Deno Deploy did not return an app id";
|
|
412
|
+
logError(err);
|
|
413
|
+
throw new Error(err);
|
|
414
|
+
}
|
|
415
|
+
const deleteApp = (): Promise<void> =>
|
|
416
|
+
fetch(`${DENO_API}/apps/${appId}`, { method: "DELETE", headers: auth })
|
|
417
|
+
.then(() => undefined)
|
|
418
|
+
.catch(() => {});
|
|
419
|
+
|
|
420
|
+
// 2. Push the relay source as a single-file revision
|
|
421
|
+
onProgress?.("Uploading relay script to Deno Deploy…");
|
|
422
|
+
const deployRes = await fetch(`${DENO_API}/apps/${appId}/deploy`, {
|
|
423
|
+
method: "POST",
|
|
424
|
+
headers: jsonHeaders,
|
|
425
|
+
body: JSON.stringify({
|
|
426
|
+
assets: {
|
|
427
|
+
"main.ts": { kind: "file", content: DENO_RELAY_SCRIPT, encoding: "utf-8" },
|
|
428
|
+
},
|
|
429
|
+
}),
|
|
430
|
+
});
|
|
431
|
+
if (!deployRes.ok) {
|
|
432
|
+
await deleteApp();
|
|
433
|
+
throw await denoError("upload relay script", deployRes);
|
|
434
|
+
}
|
|
435
|
+
const revision = (await deployRes.json()) as { id?: string };
|
|
436
|
+
const revisionId = revision.id;
|
|
437
|
+
if (!revisionId) {
|
|
438
|
+
await deleteApp();
|
|
439
|
+
const err = "Deno Deploy did not return a revision id";
|
|
440
|
+
logError(err);
|
|
441
|
+
throw new Error(err);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// 3. Poll until the revision succeeds (2s interval, 120s maximum timeout)
|
|
445
|
+
onProgress?.("Waiting for Deno Deploy build to finish…");
|
|
446
|
+
const deadline = Date.now() + 120_000;
|
|
447
|
+
let info: DenoRevision | undefined;
|
|
448
|
+
|
|
449
|
+
while (Date.now() < deadline) {
|
|
450
|
+
await new Promise<void>((r) => setTimeout(r, 2000));
|
|
451
|
+
let s: Response | null = null;
|
|
452
|
+
try {
|
|
453
|
+
s = await fetch(`${DENO_API}/revisions/${revisionId}`, { headers: auth });
|
|
454
|
+
} catch (err) {
|
|
455
|
+
log(
|
|
456
|
+
"warn",
|
|
457
|
+
`Deno Deploy revision status poll failed, retrying: ${(err as Error).message}`,
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
if (!s?.ok) continue;
|
|
461
|
+
info = (await s.json()) as DenoRevision;
|
|
462
|
+
if (info.status === "succeeded") break;
|
|
463
|
+
if (info.status === "failed" || info.status === "skipped") {
|
|
464
|
+
await deleteApp();
|
|
465
|
+
const reason = info.failure_reason ? ` (${info.failure_reason})` : "";
|
|
466
|
+
const err = `Deno Deploy build failed${reason}`;
|
|
467
|
+
logError(err);
|
|
468
|
+
throw new Error(err);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (info?.status !== "succeeded") {
|
|
472
|
+
await deleteApp();
|
|
473
|
+
const timeoutErr = "Deployment timed out (120s)";
|
|
474
|
+
logError(timeoutErr);
|
|
475
|
+
throw new Error(timeoutErr);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// 4. Resolve the public URL: prefer the hostname routed to this revision,
|
|
479
|
+
// falling back to the org's managed *.deno.net wildcard domain.
|
|
480
|
+
onProgress?.("Resolving public URL…");
|
|
481
|
+
const routed = resolveRoutedDomain(info);
|
|
482
|
+
if (routed) {
|
|
483
|
+
const url = `https://${routed}`;
|
|
484
|
+
log("info", `Deno Deploy relay successfully deployed: ${url}`);
|
|
485
|
+
return url;
|
|
486
|
+
}
|
|
487
|
+
const managed = await firstManagedDenoDomain(token);
|
|
488
|
+
if (!managed) {
|
|
489
|
+
const err = `Deployed but could not determine the public URL for "${slug}". Check the app's domain in the Deno Deploy dashboard.`;
|
|
490
|
+
logError(err);
|
|
491
|
+
throw new Error(err);
|
|
492
|
+
}
|
|
493
|
+
const url = `https://${slug}.${managed}`;
|
|
494
|
+
log("info", `Deno Deploy relay successfully deployed: ${url}`);
|
|
495
|
+
return url;
|
|
496
|
+
}
|