pi-freeflow 1.26.0 → 1.26.1
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/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/commands.ts +76 -8
- package/src/deploy.ts +655 -576
- package/src/proxy.ts +113 -47
- package/src/responses.ts +425 -264
package/src/deploy.ts
CHANGED
|
@@ -1,576 +1,655 @@
|
|
|
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
|
-
import { randomBytes } from "node:crypto";
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Canonical guarded request pipeline shared by every platform relay template.
|
|
14
|
-
* The platform wrappers below only adapt the entry point (Vercel fetch
|
|
15
|
-
* handler / Cloudflare module worker / Deno.serve) around this core, so the
|
|
16
|
-
* whitelist, SSRF guard, path resolver, header denylist, auth gate and
|
|
17
|
-
* streaming forward live exactly once.
|
|
18
|
-
*
|
|
19
|
-
* @param relayAuth Per-deployment shared secret. Empty string disables the
|
|
20
|
-
* auth gate (legacy/manual deploys); built-in deploys always embed one.
|
|
21
|
-
*/
|
|
22
|
-
function buildRelayWorkerCore(relayAuth: string): string {
|
|
23
|
-
|
|
24
|
-
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
25
|
-
const RELAY_AUTH = ${JSON.stringify(relayAuth)};
|
|
26
|
-
const resolveRelayTarget = function(target, relayPath) {
|
|
27
|
-
let targetUrl;
|
|
28
|
-
try { targetUrl = new URL(target); } catch { return { ok: false, status: 400, reason: "invalid x-relay-target" }; }
|
|
29
|
-
if (typeof relayPath !== "string" || relayPath.indexOf("@") !== -1 || relayPath.indexOf("
|
|
30
|
-
return { ok: false, status: 403, reason: "forbidden x-relay-path" };
|
|
31
|
-
}
|
|
32
|
-
let finalUrl;
|
|
33
|
-
try { finalUrl = new URL(relayPath, targetUrl); } catch { return { ok: false, status: 403, reason: "forbidden x-relay-path" }; }
|
|
34
|
-
if (finalUrl.hostname !== targetUrl.hostname || finalUrl.protocol !== targetUrl.protocol || finalUrl.port !== targetUrl.port || finalUrl.username || finalUrl.password) {
|
|
35
|
-
return { ok: false, status: 403, reason: "forbidden x-relay-path (host mismatch)" };
|
|
36
|
-
}
|
|
37
|
-
return { ok: true, url: finalUrl.toString() };
|
|
38
|
-
};
|
|
39
|
-
const isPrivateHostname = function(h) {
|
|
40
|
-
if (!h) return true
|
|
41
|
-
let host = String(h).trim().toLowerCase().replace(
|
|
42
|
-
if (host.length > 1 && host.endsWith(".")) host = host.slice(0, -1)
|
|
43
|
-
if (!host) return true
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (
|
|
51
|
-
if (
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
return
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return relayHandler(
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
*
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
export
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
*
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
)
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
}
|
|
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
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Canonical guarded request pipeline shared by every platform relay template.
|
|
14
|
+
* The platform wrappers below only adapt the entry point (Vercel fetch
|
|
15
|
+
* handler / Cloudflare module worker / Deno.serve) around this core, so the
|
|
16
|
+
* whitelist, SSRF guard, path resolver, header denylist, auth gate and
|
|
17
|
+
* streaming forward live exactly once.
|
|
18
|
+
*
|
|
19
|
+
* @param relayAuth Per-deployment shared secret. Empty string disables the
|
|
20
|
+
* auth gate (legacy/manual deploys); built-in deploys always embed one.
|
|
21
|
+
*/
|
|
22
|
+
function buildRelayWorkerCore(relayAuth: string): string {
|
|
23
|
+
return `// Only the 2 upstreams pi-freeflow talks to. Anything else = open proxy abuse.
|
|
24
|
+
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
25
|
+
const RELAY_AUTH = ${JSON.stringify(relayAuth)};
|
|
26
|
+
const resolveRelayTarget = function(target, relayPath) {
|
|
27
|
+
let targetUrl;
|
|
28
|
+
try { targetUrl = new URL(target); } catch { return { ok: false, status: 400, reason: "invalid x-relay-target" }; }
|
|
29
|
+
if (typeof relayPath !== "string" || relayPath.indexOf("@") !== -1 || relayPath.indexOf("\\\\") !== -1 || relayPath.charAt(0) !== "/") {
|
|
30
|
+
return { ok: false, status: 403, reason: "forbidden x-relay-path" };
|
|
31
|
+
}
|
|
32
|
+
let finalUrl;
|
|
33
|
+
try { finalUrl = new URL(relayPath, targetUrl); } catch { return { ok: false, status: 403, reason: "forbidden x-relay-path" }; }
|
|
34
|
+
if (finalUrl.hostname !== targetUrl.hostname || finalUrl.protocol !== targetUrl.protocol || finalUrl.port !== targetUrl.port || finalUrl.username || finalUrl.password) {
|
|
35
|
+
return { ok: false, status: 403, reason: "forbidden x-relay-path (host mismatch)" };
|
|
36
|
+
}
|
|
37
|
+
return { ok: true, url: finalUrl.toString() };
|
|
38
|
+
};
|
|
39
|
+
const isPrivateHostname = function(h) {
|
|
40
|
+
if (!h) return true
|
|
41
|
+
let host = String(h).trim().toLowerCase().replace(/^\\[|\\]$/g, "")
|
|
42
|
+
if (host.length > 1 && host.endsWith(".")) host = host.slice(0, -1)
|
|
43
|
+
if (!host) return true
|
|
44
|
+
// Numeric IP literals outside dotted-decimal (decimal 2130706433, short 127.1,
|
|
45
|
+
// hex 0x7f.0.0.1, octal 0177.0.0.1) skip the dotted-quad check below but
|
|
46
|
+
// inet_aton still routes them. Normalize to dotted-decimal so the range
|
|
47
|
+
// checks see the real address. DNS names fall through untouched.
|
|
48
|
+
const segs = host.split(".");
|
|
49
|
+
const numVal = function(p) {
|
|
50
|
+
if (/^0x[0-9a-f]+$/i.test(p)) return parseInt(p, 16);
|
|
51
|
+
if (/^0[0-7]+$/.test(p)) return parseInt(p, 8);
|
|
52
|
+
if (/^[0-9]+$/.test(p)) return parseInt(p, 10);
|
|
53
|
+
return NaN;
|
|
54
|
+
};
|
|
55
|
+
const allNumeric = segs.length >= 1 && segs.length <= 4 && segs.every(function(p) { return p.length > 0 && !isNaN(numVal(p)); });
|
|
56
|
+
const canonicalV4 = segs.length === 4 && segs.every(function(p) { return /^[0-9]+$/.test(p) && (p.length === 1 || p.charAt(0) !== "0") && Number(p) <= 255; });
|
|
57
|
+
if (allNumeric && !canonicalV4) {
|
|
58
|
+
const vals = segs.map(numVal);
|
|
59
|
+
let n = NaN;
|
|
60
|
+
if (segs.length === 1 && vals[0] <= 4294967295) n = vals[0];
|
|
61
|
+
else if (segs.length === 2 && vals[0] <= 255 && vals[1] <= 16777215) n = vals[0] * 16777216 + vals[1];
|
|
62
|
+
else if (segs.length === 3 && vals[0] <= 255 && vals[1] <= 255 && vals[2] <= 65535) n = (vals[0] * 256 + vals[1]) * 65536 + vals[2];
|
|
63
|
+
else if (vals.every(function(v) { return v <= 255; })) n = ((vals[0] * 256 + vals[1]) * 256 + vals[2]) * 256 + vals[3];
|
|
64
|
+
if (!isNaN(n)) host = ((n >>> 24) & 255) + "." + ((n >>> 16) & 255) + "." + ((n >>> 8) & 255) + "." + (n & 255);
|
|
65
|
+
}
|
|
66
|
+
if (host === "localhost" || host === "0.0.0.0" || host === "127.0.0.1" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) return true
|
|
67
|
+
if (host.startsWith("::")) return true
|
|
68
|
+
const v4 = host.match(/^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/)
|
|
69
|
+
if (v4) {
|
|
70
|
+
const a = Number(v4[1])
|
|
71
|
+
const b = Number(v4[2])
|
|
72
|
+
if (a === 0 || a === 10 || a === 127) return true
|
|
73
|
+
if (a === 169 && b === 254) return true
|
|
74
|
+
if (a === 192 && b === 168) return true
|
|
75
|
+
if (a === 172 && b >= 16 && b <= 31) return true
|
|
76
|
+
if (a === 100 && b >= 64 && b <= 127) return true
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
if (host.includes(":")) {
|
|
80
|
+
if (host.startsWith("fc") || host.startsWith("fd")) return true
|
|
81
|
+
if (/^fe[89ab]/.test(host)) return true
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
return false
|
|
85
|
+
};
|
|
86
|
+
// Constant-time string comparison (no timingSafeEqual in every edge runtime).
|
|
87
|
+
const timingSafeEqualStr = function(a, b) {
|
|
88
|
+
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
89
|
+
let diff = a.length ^ b.length;
|
|
90
|
+
const len = Math.max(a.length, b.length);
|
|
91
|
+
for (let i = 0; i < len; i++) {
|
|
92
|
+
diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
|
|
93
|
+
}
|
|
94
|
+
return diff === 0;
|
|
95
|
+
};
|
|
96
|
+
// x-relay-auth is read by the handler below, so it is stripped explicitly
|
|
97
|
+
// (never forwarded upstream) instead of living in the denylist.
|
|
98
|
+
const DENY_HEADERS = ["host", "connection", "content-length", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization", "transfer-encoding", "te", "trailer", "upgrade", "x-relay-target", "x-relay-path"];
|
|
99
|
+
async function relayHandler(req) {
|
|
100
|
+
if (!["GET", "POST", "HEAD", "OPTIONS"].includes(req.method)) return new Response("Method Not Allowed", { status: 405 });
|
|
101
|
+
if (Number(req.headers.get("content-length") || 0) > 32 * 1024 * 1024) return new Response("Payload Too Large", { status: 413 });
|
|
102
|
+
const target = req.headers.get("x-relay-target");
|
|
103
|
+
if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
104
|
+
if (RELAY_AUTH && !timingSafeEqualStr(req.headers.get("x-relay-auth") || "", RELAY_AUTH)) {
|
|
105
|
+
return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401, headers: { "content-type": "application/json" } });
|
|
106
|
+
}
|
|
107
|
+
let targetUrl;
|
|
108
|
+
try { targetUrl = new URL(target); } catch { return new Response(JSON.stringify({ error: "invalid x-relay-target" }), { status: 400, headers: { "content-type": "application/json" } }); }
|
|
109
|
+
if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") return new Response(JSON.stringify({ error: "forbidden x-relay-target protocol" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
110
|
+
if (targetUrl.username || targetUrl.password) return new Response(JSON.stringify({ error: "forbidden x-relay-target (embedded credentials)" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
111
|
+
if (isPrivateHostname(targetUrl.hostname)) return new Response(JSON.stringify({ error: "forbidden x-relay-target (private/loopback host)" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
112
|
+
const cleanTarget = target.replace(/\\/$/, "");
|
|
113
|
+
if (!ALLOWED_TARGETS.includes(cleanTarget)) return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
114
|
+
const relayPath = req.headers.get("x-relay-path") || "/";
|
|
115
|
+
const resolved = resolveRelayTarget(target, relayPath);
|
|
116
|
+
if (!resolved.ok) return new Response(JSON.stringify({ error: resolved.reason }), { status: resolved.status, headers: { "content-type": "application/json" } });
|
|
117
|
+
const headers = new Headers(req.headers);
|
|
118
|
+
DENY_HEADERS.forEach((h) => headers.delete(h));
|
|
119
|
+
headers.delete("x-relay-auth");
|
|
120
|
+
try {
|
|
121
|
+
const init = { method: req.method, headers };
|
|
122
|
+
if (req.method !== "GET" && req.method !== "HEAD") { init.body = req.body; init.duplex = "half"; }
|
|
123
|
+
const response = await fetch(resolved.url, init);
|
|
124
|
+
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return new Response(JSON.stringify({ error: String(error) }), { status: 502, headers: { "content-type": "application/json" } });
|
|
127
|
+
}
|
|
128
|
+
}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Vercel Edge Function relay template: canonical core wrapped in the edge-runtime module handler.
|
|
133
|
+
*/
|
|
134
|
+
export function buildVercelRelayWorker(relayAuth: string): string {
|
|
135
|
+
return `${buildRelayWorkerCore(relayAuth)}
|
|
136
|
+
export const config = { runtime: "edge" };
|
|
137
|
+
export default async function handler(req) {
|
|
138
|
+
return relayHandler(req);
|
|
139
|
+
}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Cloudflare Workers module relay template: canonical core wrapped in the module fetch handler.
|
|
144
|
+
*/
|
|
145
|
+
export function buildCloudflareRelayWorker(relayAuth: string): string {
|
|
146
|
+
return `${buildRelayWorkerCore(relayAuth)}
|
|
147
|
+
export default {
|
|
148
|
+
async fetch(request) {
|
|
149
|
+
return relayHandler(request);
|
|
150
|
+
},
|
|
151
|
+
};`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Deno Deploy relay script template: canonical core wrapped in Deno.serve.
|
|
156
|
+
*/
|
|
157
|
+
export function buildDenoRelayScript(relayAuth: string): string {
|
|
158
|
+
return `${buildRelayWorkerCore(relayAuth)}
|
|
159
|
+
Deno.serve(async (request) => {
|
|
160
|
+
return relayHandler(request);
|
|
161
|
+
});`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Public template constants (tests/README): the canonical core without a
|
|
166
|
+
* secret, so the auth gate is disabled. Deployed relays always embed their
|
|
167
|
+
* own per-deployment secret.
|
|
168
|
+
*/
|
|
169
|
+
export const VERCEL_RELAY_WORKER = buildVercelRelayWorker("");
|
|
170
|
+
export const CLOUDFLARE_RELAY_WORKER = buildCloudflareRelayWorker("");
|
|
171
|
+
export const DENO_RELAY_SCRIPT = buildDenoRelayScript("");
|
|
172
|
+
|
|
173
|
+
async function vercelError(action: string, res: Response): Promise<Error> {
|
|
174
|
+
const e = (await res
|
|
175
|
+
.json()
|
|
176
|
+
.catch(() => ({}))) as { error?: { message?: string } };
|
|
177
|
+
const detail = e?.error?.message || `HTTP ${res.status}`;
|
|
178
|
+
let err: Error;
|
|
179
|
+
if (res.status === 401 || res.status === 403) {
|
|
180
|
+
err = new Error(`Vercel authentication failed while trying to ${action}: ${detail}. Check that your API token is valid.`);
|
|
181
|
+
} else {
|
|
182
|
+
err = new Error(`Failed to ${action} (HTTP ${res.status}): ${detail}`);
|
|
183
|
+
}
|
|
184
|
+
logError(err.message);
|
|
185
|
+
return err;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Deploy a fresh Vercel Edge Relay project in-memory.
|
|
190
|
+
*
|
|
191
|
+
* @param token Vercel personal access token (used in-memory only)
|
|
192
|
+
* @param name Unique project/deployment name (e.g. pi-freeflow-relay-abc123)
|
|
193
|
+
* @param onProgress Optional callback for user-facing progress updates
|
|
194
|
+
* @returns Deployed { url, auth } — url is the relay URL, auth the embedded shared secret (minted per deployment unless authSecret is passed)
|
|
195
|
+
*/
|
|
196
|
+
export async function deployVercelRelay(
|
|
197
|
+
token: string,
|
|
198
|
+
name: string,
|
|
199
|
+
onProgress?: (msg: string) => void,
|
|
200
|
+
authSecret: string = "",
|
|
201
|
+
): Promise<{ url: string; auth: string }> {
|
|
202
|
+
name = baseRelayName(name) || "relay-worker";
|
|
203
|
+
const auth = {
|
|
204
|
+
Authorization: `Bearer ${token}`,
|
|
205
|
+
"Content-Type": "application/json",
|
|
206
|
+
};
|
|
207
|
+
// 1. Create deployment (3 inline files, no git repository required)
|
|
208
|
+
onProgress?.("Uploading relay files to Vercel…");
|
|
209
|
+
log("info", `Starting Vercel deployment: ${name}`);
|
|
210
|
+
// Private by default: mint a per-deployment secret unless the caller passes
|
|
211
|
+
// one explicitly (e.g. rotation). The secret is embedded in the worker and
|
|
212
|
+
// returned so the pool can send it back as x-relay-auth.
|
|
213
|
+
const relayAuth = authSecret || randomBytes(32).toString("hex");
|
|
214
|
+
|
|
215
|
+
const dep = await fetch(`${VERCEL_API}/v13/deployments`, {
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: auth,
|
|
218
|
+
body: JSON.stringify({
|
|
219
|
+
name,
|
|
220
|
+
files: [
|
|
221
|
+
{ file: "api/relay.js", data: buildVercelRelayWorker(relayAuth) },
|
|
222
|
+
{
|
|
223
|
+
file: "package.json",
|
|
224
|
+
data: JSON.stringify({ name, version: "1.0.0" }),
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
file: "vercel.json",
|
|
228
|
+
data: JSON.stringify({
|
|
229
|
+
rewrites: [{ source: "/(.*)", destination: "/api/relay" }],
|
|
230
|
+
}),
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
projectSettings: { framework: null },
|
|
234
|
+
target: "production",
|
|
235
|
+
}),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (!dep.ok) throw await vercelError("create Vercel deployment", dep);
|
|
239
|
+
|
|
240
|
+
const depJson = (await dep.json()) as { id?: string; uid?: string; projectId?: string };
|
|
241
|
+
const depId = depJson.id || depJson.uid;
|
|
242
|
+
const projectId = depJson.projectId || name;
|
|
243
|
+
|
|
244
|
+
// 2. Make the deployment public (disable SSO protection if enabled on team)
|
|
245
|
+
try {
|
|
246
|
+
await fetch(`${VERCEL_API}/v9/projects/${projectId}`, {
|
|
247
|
+
method: "PATCH",
|
|
248
|
+
headers: auth,
|
|
249
|
+
body: JSON.stringify({ ssoProtection: null }),
|
|
250
|
+
});
|
|
251
|
+
} catch { }
|
|
252
|
+
|
|
253
|
+
// 3. Poll until READY state (3s interval, 120s maximum timeout).
|
|
254
|
+
// A failed/timed-out run removes its own deployment so no orphan (with the
|
|
255
|
+
// embedded secret) lingers. The project shell is left alone: the name may
|
|
256
|
+
// collide with a pre-existing project the token owner already uses.
|
|
257
|
+
onProgress?.("Waiting for Edge deployment to go live…");
|
|
258
|
+
const deadline = Date.now() + 120_000;
|
|
259
|
+
const deleteDeployment = (): Promise<void> =>
|
|
260
|
+
fetch(`${VERCEL_API}/v13/deployments/${depId}`, {
|
|
261
|
+
method: "DELETE",
|
|
262
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
263
|
+
})
|
|
264
|
+
.then(() => undefined)
|
|
265
|
+
.catch(() => { });
|
|
266
|
+
|
|
267
|
+
while (Date.now() < deadline) {
|
|
268
|
+
let s: Response | null = null;
|
|
269
|
+
try {
|
|
270
|
+
s = await fetch(`${VERCEL_API}/v13/deployments/${depId}`, {
|
|
271
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
272
|
+
});
|
|
273
|
+
} catch (err) {
|
|
274
|
+
log(
|
|
275
|
+
"warn",
|
|
276
|
+
`Vercel deployment status poll failed, retrying: ${(err as Error).message}`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (s && !s.ok) {
|
|
280
|
+
if (s.status === 401 || s.status === 403) throw await vercelError("poll Vercel deployment status", s);
|
|
281
|
+
if (s.status === 404) {
|
|
282
|
+
const gone = `Deployment ${depId ?? name} disappeared while waiting (deleted externally?)`;
|
|
283
|
+
logError(gone);
|
|
284
|
+
throw new Error(gone);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (s?.ok) {
|
|
288
|
+
const j = (await s.json()) as { readyState?: string; url?: string };
|
|
289
|
+
if (j.readyState === "READY" && j.url) {
|
|
290
|
+
const deployedUrl = `https://${j.url}`;
|
|
291
|
+
log("info", `Vercel relay successfully deployed: ${deployedUrl}`);
|
|
292
|
+
return { url: deployedUrl, auth: relayAuth };
|
|
293
|
+
}
|
|
294
|
+
if (j.readyState === "ERROR" || j.readyState === "CANCELED") {
|
|
295
|
+
await deleteDeployment();
|
|
296
|
+
const err = `Deployment failed with state: ${j.readyState}`;
|
|
297
|
+
logError(err);
|
|
298
|
+
throw new Error(err);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
await new Promise<void>((r) => setTimeout(r, 3000));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
await deleteDeployment();
|
|
305
|
+
const timeoutErr = "Deployment timed out (120s)";
|
|
306
|
+
logError(timeoutErr);
|
|
307
|
+
throw new Error(timeoutErr);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ── Multi-platform deployment (Cloudflare Workers / Deno Deploy) ────
|
|
311
|
+
|
|
312
|
+
const CLOUDFLARE_API = "https://api.cloudflare.com/client/v4";
|
|
313
|
+
const DENO_API = "https://api.deno.com/v2";
|
|
314
|
+
|
|
315
|
+
export type DeployPlatform = "vercel" | "cloudflare" | "deno";
|
|
316
|
+
|
|
317
|
+
export function baseRelayName(name: string): string {
|
|
318
|
+
return name
|
|
319
|
+
.toLowerCase()
|
|
320
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
321
|
+
.replace(/-{2,}/g, "-")
|
|
322
|
+
.replace(/^-+|-+$/g, "");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Cloudflare Worker script names: [a-z0-9-], max 58 chars. */
|
|
326
|
+
export function cloudflareScriptName(name: string): string {
|
|
327
|
+
const clean = baseRelayName(name)
|
|
328
|
+
.slice(0, 58)
|
|
329
|
+
.replace(/-+$/g, "");
|
|
330
|
+
return clean || "relay-worker";
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Deno Deploy app slugs: [a-z0-9-], 3-32 chars, no edge/consecutive hyphens. */
|
|
334
|
+
export function denoProjectName(name: string): string {
|
|
335
|
+
const clean = baseRelayName(name)
|
|
336
|
+
.slice(0, 32)
|
|
337
|
+
.replace(/-+$/, "");
|
|
338
|
+
if (!clean) return "relay-app";
|
|
339
|
+
return clean.length < 3 ? `${clean}-relay` : clean;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function cloudflareError(action: string, res: Response): Promise<Error> {
|
|
343
|
+
const body = (await res
|
|
344
|
+
.json()
|
|
345
|
+
.catch(() => ({}))) as { errors?: Array<{ message?: string }> };
|
|
346
|
+
const detail = body.errors?.[0]?.message || `HTTP ${res.status}`;
|
|
347
|
+
let err: Error;
|
|
348
|
+
if (res.status === 401 || res.status === 403) {
|
|
349
|
+
err = /quota|limit|exceeded/i.test(detail)
|
|
350
|
+
? new Error(`Cloudflare plan or usage limit hit while trying to ${action}: ${detail}. Check your Workers plan limits.`)
|
|
351
|
+
: new Error(`Cloudflare authentication failed while trying to ${action}: ${detail}. Check that your API token is valid and has Workers permissions.`);
|
|
352
|
+
} else {
|
|
353
|
+
err = new Error(`Failed to ${action} (HTTP ${res.status}): ${detail}`);
|
|
354
|
+
}
|
|
355
|
+
logError(err.message);
|
|
356
|
+
return err;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function denoError(action: string, res: Response, override?: string): Promise<Error> {
|
|
360
|
+
if (override) {
|
|
361
|
+
logError(override);
|
|
362
|
+
return new Error(override);
|
|
363
|
+
}
|
|
364
|
+
const raw = await res.text().catch(() => "");
|
|
365
|
+
let detail = raw;
|
|
366
|
+
try {
|
|
367
|
+
const parsed = JSON.parse(raw) as { error?: { message?: string } };
|
|
368
|
+
detail = parsed.error?.message || raw;
|
|
369
|
+
} catch { }
|
|
370
|
+
let err: Error;
|
|
371
|
+
if (res.status === 401 || res.status === 403) {
|
|
372
|
+
err = /quota|limit|exceeded/i.test(detail)
|
|
373
|
+
? new Error(`Deno Deploy plan or usage limit hit while trying to ${action}: ${detail}. Check your organization's limits.`)
|
|
374
|
+
: new Error(`Deno Deploy authentication failed while trying to ${action}: ${detail}. Check that your access token is valid.`);
|
|
375
|
+
} else {
|
|
376
|
+
err = new Error(`Failed to ${action} (HTTP ${res.status}): ${detail}`);
|
|
377
|
+
}
|
|
378
|
+
logError(err.message);
|
|
379
|
+
return err;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Deploy a fresh Cloudflare Workers relay (module worker) in-memory.
|
|
384
|
+
*
|
|
385
|
+
* @param token Cloudflare API token (used in-memory only)
|
|
386
|
+
* @param name Unique worker/script name (sanitized to [a-z0-9-])
|
|
387
|
+
* @param onProgress Optional callback for user-facing progress updates
|
|
388
|
+
* @returns Deployed { url, auth } — url is the relay URL, auth the embedded shared secret (minted per deployment unless authSecret is passed)
|
|
389
|
+
*/
|
|
390
|
+
export async function deployCloudflareWorker(
|
|
391
|
+
token: string,
|
|
392
|
+
name: string,
|
|
393
|
+
onProgress?: (msg: string) => void,
|
|
394
|
+
authSecret: string = "",
|
|
395
|
+
): Promise<{ url: string; auth: string }> {
|
|
396
|
+
const auth = { Authorization: `Bearer ${token}` };
|
|
397
|
+
const scriptName = cloudflareScriptName(name);
|
|
398
|
+
|
|
399
|
+
// 1. Resolve the account scoped to this token
|
|
400
|
+
onProgress?.("Resolving Cloudflare account…");
|
|
401
|
+
log("info", `Starting Cloudflare Worker deployment: ${scriptName}`);
|
|
402
|
+
const accRes = await fetch(`${CLOUDFLARE_API}/accounts`, { headers: auth });
|
|
403
|
+
if (!accRes.ok) throw await cloudflareError("resolve Cloudflare account", accRes);
|
|
404
|
+
const accJson = (await accRes.json()) as { result?: Array<{ id?: string }> };
|
|
405
|
+
const accountId = accJson.result?.[0]?.id;
|
|
406
|
+
if (!accountId) {
|
|
407
|
+
const err = "No Cloudflare account is accessible with this API token";
|
|
408
|
+
logError(err);
|
|
409
|
+
throw new Error(err);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// 2. Upload the module worker script (multipart: main module + metadata)
|
|
413
|
+
onProgress?.("Uploading relay worker to Cloudflare…");
|
|
414
|
+
// Private by default: mint a per-deployment secret unless the caller passes
|
|
415
|
+
// one explicitly (e.g. rotation). The secret is embedded in the worker and
|
|
416
|
+
// returned so the pool can send it back as x-relay-auth.
|
|
417
|
+
const relayAuth = authSecret || randomBytes(32).toString("hex");
|
|
418
|
+
const formData = new FormData();
|
|
419
|
+
formData.append(
|
|
420
|
+
"index.js",
|
|
421
|
+
new Blob([buildCloudflareRelayWorker(relayAuth)], { type: "application/javascript+module" }),
|
|
422
|
+
"index.js",
|
|
423
|
+
);
|
|
424
|
+
formData.append(
|
|
425
|
+
"metadata",
|
|
426
|
+
new Blob(
|
|
427
|
+
[
|
|
428
|
+
JSON.stringify({
|
|
429
|
+
main_module: "index.js",
|
|
430
|
+
compatibility_date: "2024-03-20",
|
|
431
|
+
observability: { enabled: true },
|
|
432
|
+
}),
|
|
433
|
+
],
|
|
434
|
+
{ type: "application/json" },
|
|
435
|
+
),
|
|
436
|
+
"metadata.json",
|
|
437
|
+
);
|
|
438
|
+
const uploadRes = await fetch(
|
|
439
|
+
`${CLOUDFLARE_API}/accounts/${accountId}/workers/scripts/${scriptName}`,
|
|
440
|
+
{ method: "PUT", headers: auth, body: formData },
|
|
441
|
+
);
|
|
442
|
+
if (!uploadRes.ok) throw await cloudflareError("upload Worker to Cloudflare", uploadRes);
|
|
443
|
+
|
|
444
|
+
// 3. Enable workers.dev routing for the script (non-fatal if it fails)
|
|
445
|
+
try {
|
|
446
|
+
await fetch(`${CLOUDFLARE_API}/accounts/${accountId}/workers/scripts/${scriptName}/subdomain`, {
|
|
447
|
+
method: "POST",
|
|
448
|
+
headers: { ...auth, "Content-Type": "application/json" },
|
|
449
|
+
body: JSON.stringify({ enabled: true }),
|
|
450
|
+
});
|
|
451
|
+
} catch { }
|
|
452
|
+
|
|
453
|
+
// 4. Read the account-level workers.dev subdomain to assemble the public URL.
|
|
454
|
+
// The script is already uploaded here, so every failure below removes it
|
|
455
|
+
// again — otherwise an unreachable worker (with the embedded secret) lingers.
|
|
456
|
+
onProgress?.("Reading workers.dev routing…");
|
|
457
|
+
const deleteScript = (): Promise<void> =>
|
|
458
|
+
fetch(`${CLOUDFLARE_API}/accounts/${accountId}/workers/scripts/${scriptName}`, {
|
|
459
|
+
method: "DELETE",
|
|
460
|
+
headers: auth,
|
|
461
|
+
})
|
|
462
|
+
.then(() => undefined)
|
|
463
|
+
.catch(() => { });
|
|
464
|
+
let subRes: Response;
|
|
465
|
+
try {
|
|
466
|
+
subRes = await fetch(`${CLOUDFLARE_API}/accounts/${accountId}/workers/subdomain`, { headers: auth });
|
|
467
|
+
} catch (err) {
|
|
468
|
+
await deleteScript();
|
|
469
|
+
throw err;
|
|
470
|
+
}
|
|
471
|
+
if (!subRes.ok) {
|
|
472
|
+
await deleteScript();
|
|
473
|
+
throw await cloudflareError("retrieve workers.dev subdomain", subRes);
|
|
474
|
+
}
|
|
475
|
+
const subJson = (await subRes.json()) as { result?: { subdomain?: string } };
|
|
476
|
+
const subdomain = subJson.result?.subdomain;
|
|
477
|
+
if (!subdomain) {
|
|
478
|
+
await deleteScript();
|
|
479
|
+
const err = "Worker deployed but workers.dev subdomain is unavailable. Enable a workers.dev subdomain for your account in the Cloudflare dashboard.";
|
|
480
|
+
logError(err);
|
|
481
|
+
throw new Error(err);
|
|
482
|
+
}
|
|
483
|
+
const url = `https://${scriptName}.${subdomain}.workers.dev`;
|
|
484
|
+
log("info", `Cloudflare relay successfully deployed: ${url}`);
|
|
485
|
+
return { url, auth: relayAuth };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
type DenoRevision = {
|
|
489
|
+
status?: string;
|
|
490
|
+
failure_reason?: string | null;
|
|
491
|
+
timelines?: Array<{ slug?: string; domains?: Array<{ domain?: string }> }>;
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
function resolveRoutedDomain(revision: DenoRevision): string | null {
|
|
495
|
+
const timelines = revision.timelines ?? [];
|
|
496
|
+
const production = timelines.find((t) => t.slug === "production") ?? timelines[0];
|
|
497
|
+
const host = (production?.domains ?? [])
|
|
498
|
+
.map((d) => d.domain ?? "")
|
|
499
|
+
.find((h) => h.length > 0);
|
|
500
|
+
return host ?? null;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function firstManagedDenoDomain(token: string): Promise<string | null> {
|
|
504
|
+
const res = await fetch(`${DENO_API}/domains`, {
|
|
505
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
506
|
+
});
|
|
507
|
+
if (!res.ok) return null;
|
|
508
|
+
const list = (await res.json().catch(() => [])) as Array<{ domain?: string }>;
|
|
509
|
+
const managed = (Array.isArray(list) ? list : [])
|
|
510
|
+
.map((d) => d.domain ?? "")
|
|
511
|
+
.find((h) => h.endsWith(".deno.net"));
|
|
512
|
+
return managed ? managed.replace(/^\*\./, "") : null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Deploy a fresh Deno Deploy relay (Deno.serve script) in-memory.
|
|
517
|
+
* The v2 API is scoped to the token's organization, so no org input is needed.
|
|
518
|
+
*
|
|
519
|
+
* @param token Deno Deploy organization access token (used in-memory only)
|
|
520
|
+
* @param name Unique app/project name (sanitized to a valid slug)
|
|
521
|
+
* @param onProgress Optional callback for user-facing progress updates
|
|
522
|
+
* @returns Deployed { url, auth } — url is the relay URL, auth the embedded shared secret (minted per deployment unless authSecret is passed)
|
|
523
|
+
*/
|
|
524
|
+
export async function deployDenoRelay(
|
|
525
|
+
token: string,
|
|
526
|
+
name: string,
|
|
527
|
+
onProgress?: (msg: string) => void,
|
|
528
|
+
authSecret: string = "",
|
|
529
|
+
): Promise<{ url: string; auth: string }> {
|
|
530
|
+
const slug = denoProjectName(name);
|
|
531
|
+
const auth = { Authorization: `Bearer ${token}` };
|
|
532
|
+
const jsonHeaders = { ...auth, "Content-Type": "application/json" };
|
|
533
|
+
onProgress?.("Creating Deno Deploy app…");
|
|
534
|
+
log("info", `Starting Deno Deploy deployment: ${slug}`);
|
|
535
|
+
const createRes = await fetch(`${DENO_API}/apps`, {
|
|
536
|
+
method: "POST",
|
|
537
|
+
headers: jsonHeaders,
|
|
538
|
+
body: JSON.stringify({
|
|
539
|
+
slug,
|
|
540
|
+
labels: { "custom.kind": "relay" },
|
|
541
|
+
config: {
|
|
542
|
+
install: "deno install",
|
|
543
|
+
runtime: { type: "dynamic", entrypoint: "main.ts" },
|
|
544
|
+
},
|
|
545
|
+
}),
|
|
546
|
+
});
|
|
547
|
+
if (!createRes.ok) {
|
|
548
|
+
throw await denoError(
|
|
549
|
+
`create Deno Deploy app "${slug}"`,
|
|
550
|
+
createRes,
|
|
551
|
+
createRes.status === 409
|
|
552
|
+
? `An app named "${slug}" already exists on Deno Deploy — choose a different name.`
|
|
553
|
+
: undefined,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
const app = (await createRes.json()) as { id?: string };
|
|
557
|
+
const appId = app.id;
|
|
558
|
+
if (!appId) {
|
|
559
|
+
const err = "Deno Deploy did not return an app id";
|
|
560
|
+
logError(err);
|
|
561
|
+
throw new Error(err);
|
|
562
|
+
}
|
|
563
|
+
const deleteApp = (): Promise<void> =>
|
|
564
|
+
fetch(`${DENO_API}/apps/${appId}`, { method: "DELETE", headers: auth })
|
|
565
|
+
.then(() => undefined)
|
|
566
|
+
.catch(() => { });
|
|
567
|
+
|
|
568
|
+
// 2. Push the relay source as a single-file revision
|
|
569
|
+
onProgress?.("Uploading relay script to Deno Deploy…");
|
|
570
|
+
// Private by default: mint a per-deployment secret unless the caller passes
|
|
571
|
+
// one explicitly (e.g. rotation). The secret is embedded in the script and
|
|
572
|
+
// returned so the pool can send it back as x-relay-auth.
|
|
573
|
+
const relayAuth = authSecret || randomBytes(32).toString("hex");
|
|
574
|
+
let deployRes: Response;
|
|
575
|
+
try {
|
|
576
|
+
deployRes = await fetch(`${DENO_API}/apps/${appId}/deploy`, {
|
|
577
|
+
method: "POST",
|
|
578
|
+
headers: jsonHeaders,
|
|
579
|
+
body: JSON.stringify({
|
|
580
|
+
assets: {
|
|
581
|
+
"main.ts": { kind: "file", content: buildDenoRelayScript(relayAuth), encoding: "utf-8" },
|
|
582
|
+
},
|
|
583
|
+
}),
|
|
584
|
+
});
|
|
585
|
+
} catch (err) {
|
|
586
|
+
await deleteApp();
|
|
587
|
+
throw err;
|
|
588
|
+
}
|
|
589
|
+
if (!deployRes.ok) {
|
|
590
|
+
await deleteApp();
|
|
591
|
+
throw await denoError("upload relay script", deployRes);
|
|
592
|
+
}
|
|
593
|
+
const revision = (await deployRes.json()) as { id?: string };
|
|
594
|
+
const revisionId = revision.id;
|
|
595
|
+
if (!revisionId) {
|
|
596
|
+
await deleteApp();
|
|
597
|
+
const err = "Deno Deploy did not return a revision id";
|
|
598
|
+
logError(err);
|
|
599
|
+
throw new Error(err);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// 3. Poll until the revision succeeds (2s interval, 120s maximum timeout)
|
|
603
|
+
onProgress?.("Waiting for Deno Deploy build to finish…");
|
|
604
|
+
const deadline = Date.now() + 120_000;
|
|
605
|
+
let info: DenoRevision | undefined;
|
|
606
|
+
|
|
607
|
+
while (Date.now() < deadline) {
|
|
608
|
+
await new Promise<void>((r) => setTimeout(r, 2000));
|
|
609
|
+
let s: Response | null = null;
|
|
610
|
+
try {
|
|
611
|
+
s = await fetch(`${DENO_API}/revisions/${revisionId}`, { headers: auth });
|
|
612
|
+
} catch (err) {
|
|
613
|
+
log(
|
|
614
|
+
"warn",
|
|
615
|
+
`Deno Deploy revision status poll failed, retrying: ${(err as Error).message}`,
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
if (!s?.ok) continue;
|
|
619
|
+
info = (await s.json()) as DenoRevision;
|
|
620
|
+
if (info.status === "succeeded") break;
|
|
621
|
+
if (info.status === "failed" || info.status === "skipped") {
|
|
622
|
+
await deleteApp();
|
|
623
|
+
const reason = info.failure_reason ? ` (${info.failure_reason})` : "";
|
|
624
|
+
const err = `Deno Deploy build failed${reason}`;
|
|
625
|
+
logError(err);
|
|
626
|
+
throw new Error(err);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
if (info?.status !== "succeeded") {
|
|
630
|
+
await deleteApp();
|
|
631
|
+
const timeoutErr = "Deployment timed out (120s)";
|
|
632
|
+
logError(timeoutErr);
|
|
633
|
+
throw new Error(timeoutErr);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// 4. Resolve the public URL: prefer the hostname routed to this revision,
|
|
637
|
+
// falling back to the org's managed *.deno.net wildcard domain.
|
|
638
|
+
onProgress?.("Resolving public URL…");
|
|
639
|
+
const routed = resolveRoutedDomain(info);
|
|
640
|
+
if (routed) {
|
|
641
|
+
const url = `https://${routed}`;
|
|
642
|
+
log("info", `Deno Deploy relay successfully deployed: ${url}`);
|
|
643
|
+
return { url, auth: relayAuth };
|
|
644
|
+
}
|
|
645
|
+
const managed = await firstManagedDenoDomain(token).catch(() => null);
|
|
646
|
+
if (!managed) {
|
|
647
|
+
await deleteApp();
|
|
648
|
+
const err = `Deployed but could not determine the public URL for "${slug}". Check the app's domain in the Deno Deploy dashboard.`;
|
|
649
|
+
logError(err);
|
|
650
|
+
throw new Error(err);
|
|
651
|
+
}
|
|
652
|
+
const url = `https://${slug}.${managed}`;
|
|
653
|
+
log("info", `Deno Deploy relay successfully deployed: ${url}`);
|
|
654
|
+
return { url, auth: relayAuth };
|
|
655
|
+
}
|