alchemy 0.57.2 → 0.58.0

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.
@@ -0,0 +1,911 @@
1
+ import { alchemy } from "../alchemy.ts";
2
+ import type { Context } from "../context.ts";
3
+ import { Resource, ResourceKind } from "../resource.ts";
4
+ import type { Secret } from "../secret.ts";
5
+ import { logger } from "../util/logger.ts";
6
+ import { handleApiError } from "./api-error.ts";
7
+ import type {
8
+ CloudflareApiListResponse,
9
+ CloudflareApiResponse,
10
+ } from "./api-response.ts";
11
+ import {
12
+ createCloudflareApi,
13
+ type CloudflareApi,
14
+ type CloudflareApiOptions,
15
+ } from "./api.ts";
16
+ import { DnsRecords } from "./dns-records.ts";
17
+ import { findZoneForHostname } from "./zone.ts";
18
+
19
+ /**
20
+ * Tunnel data as returned by Cloudflare API
21
+ */
22
+ interface CloudflareTunnel {
23
+ id: string;
24
+ account_tag: string;
25
+ created_at: string;
26
+ deleted_at: string | null;
27
+ name: string;
28
+ metadata?: Record<string, any>;
29
+ credentials_file?: {
30
+ AccountTag: string;
31
+ TunnelID: string;
32
+ TunnelName: string;
33
+ TunnelSecret: string;
34
+ };
35
+ token?: string;
36
+ }
37
+
38
+ /**
39
+ * Properties for creating or updating a Cloudflare Tunnel
40
+ *
41
+ * @remarks
42
+ * This interface includes all configuration options supported by Cloudflare Tunnels
43
+ * for both remotely-managed (configSrc: 'cloudflare') and locally-managed tunnels.
44
+ */
45
+ export interface TunnelProps extends CloudflareApiOptions {
46
+ /**
47
+ * Name for the tunnel
48
+ *
49
+ * Note: Tunnel names are immutable and cannot be changed after creation.
50
+ * When updating a tunnel, any name change will be ignored.
51
+ *
52
+ * @default id
53
+ */
54
+ name?: string;
55
+
56
+ /**
57
+ * Secret for the tunnel
58
+ * If not provided, will be generated automatically
59
+ */
60
+ tunnelSecret?: Secret<string>;
61
+
62
+ /**
63
+ * Optional metadata object for the tunnel
64
+ */
65
+ metadata?: Record<string, any>;
66
+
67
+ /**
68
+ * Configuration source
69
+ * - 'cloudflare' - Use Cloudflare configuration (default, managed via API)
70
+ * - 'local' - Use local configuration (managed via config file)
71
+ *
72
+ * @default 'cloudflare'
73
+ * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/tunnel-useful-terms/#remotely-managed-tunnel
74
+ * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/tunnel-useful-terms/#locally-managed-tunnel
75
+ */
76
+ configSrc?: "cloudflare" | "local";
77
+
78
+ /**
79
+ * Ingress rules defining how requests are routed
80
+ * Must include a catch-all rule at the end
81
+ * Only used when configSrc is 'cloudflare'
82
+ *
83
+ * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/configure-tunnels/origin-configuration/#ingress-rules
84
+ */
85
+ ingress?: IngressRule[];
86
+
87
+ /**
88
+ * WarpRouting configuration for private network access
89
+ * Only used when configSrc is 'cloudflare'
90
+ *
91
+ * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/private-net/
92
+ */
93
+ warpRouting?: {
94
+ enabled?: boolean;
95
+ };
96
+
97
+ /**
98
+ * Origin request configuration to apply to all rules
99
+ * Only used when configSrc is 'cloudflare'
100
+ *
101
+ * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/configure-tunnels/origin-configuration/#origin-request-parameters
102
+ */
103
+ originRequest?: OriginRequestConfig;
104
+
105
+ /**
106
+ * Whether to adopt an existing tunnel with the same name if it exists
107
+ * If true and a tunnel with the same name exists, it will be adopted rather than creating a new one
108
+ *
109
+ * @default false
110
+ */
111
+ adopt?: boolean;
112
+
113
+ /**
114
+ * Whether to delete the tunnel.
115
+ * If set to false, the tunnel will remain but the resource will be removed from state
116
+ *
117
+ * @default true
118
+ */
119
+ delete?: boolean;
120
+ }
121
+
122
+ /**
123
+ * Tunnel configuration for routing traffic
124
+ */
125
+ export interface TunnelConfig {
126
+ /**
127
+ * Ingress rules defining how requests are routed
128
+ * Must include a catch-all rule at the end
129
+ */
130
+ ingress?: IngressRule[];
131
+
132
+ /**
133
+ * WarpRouting configuration for private network access
134
+ */
135
+ warpRouting?: {
136
+ enabled?: boolean;
137
+ };
138
+
139
+ /**
140
+ * Origin request configuration to apply to all rules
141
+ */
142
+ originRequest?: OriginRequestConfig;
143
+ }
144
+
145
+ /**
146
+ * Ingress rule defining how a hostname is routed
147
+ *
148
+ * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/configure-tunnels/origin-configuration/#ingress-rules
149
+ */
150
+ export interface IngressRule {
151
+ /**
152
+ * Hostname to match for this rule
153
+ * Use service: "http_status:404" as catch-all
154
+ */
155
+ hostname?: string;
156
+
157
+ /**
158
+ * Service to route to (e.g., "http://localhost:8000" or "http_status:404")
159
+ *
160
+ * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/configure-tunnels/origin-configuration/#supported-protocols
161
+ */
162
+ service: string;
163
+
164
+ /**
165
+ * Path to match for this rule
166
+ */
167
+ path?: string;
168
+
169
+ /**
170
+ * Origin request configuration for this specific rule
171
+ */
172
+ originRequest?: OriginRequestConfig;
173
+ }
174
+
175
+ /**
176
+ * Origin request configuration
177
+ *
178
+ * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/configure-tunnels/origin-configuration/#origin-request-parameters
179
+ */
180
+ export interface OriginRequestConfig {
181
+ /**
182
+ * Timeout for origin server to respond to a request
183
+ */
184
+ connectTimeout?: number;
185
+
186
+ /**
187
+ * Timeout for closing the connection to the origin server
188
+ */
189
+ tlsTimeout?: number;
190
+
191
+ /**
192
+ * Timeout for TCP connections to the origin server
193
+ */
194
+ tcpKeepAlive?: number;
195
+
196
+ /**
197
+ * Disable keep-alive connections
198
+ */
199
+ noHappyEyeballs?: boolean;
200
+
201
+ /**
202
+ * Keep connections open after a request
203
+ */
204
+ keepAliveConnections?: number;
205
+
206
+ /**
207
+ * Timeout for keep-alive connections
208
+ */
209
+ keepAliveTimeout?: number;
210
+
211
+ /**
212
+ * HTTP/2 origin support
213
+ */
214
+ http2Origin?: boolean;
215
+
216
+ /**
217
+ * Headers to add to origin requests
218
+ */
219
+ httpHostHeader?: string;
220
+
221
+ /**
222
+ * CA pool for origin TLS verification
223
+ */
224
+ caPool?: string;
225
+
226
+ /**
227
+ * Disable TLS verification
228
+ */
229
+ noTLSVerify?: boolean;
230
+
231
+ /**
232
+ * Disable chunked encoding
233
+ */
234
+ disableChunkedEncoding?: boolean;
235
+
236
+ /**
237
+ * Rewrite the Host header
238
+ */
239
+ bastionMode?: boolean;
240
+
241
+ /**
242
+ * Proxy protocol version
243
+ */
244
+ proxyProtocol?: "off" | "v1" | "v2";
245
+
246
+ /**
247
+ * Proxy outgoing connections through a specified address
248
+ */
249
+ proxyAddress?: string;
250
+
251
+ /**
252
+ * Port to use for proxy connections
253
+ */
254
+ proxyPort?: number;
255
+
256
+ /**
257
+ * Type of proxy to use
258
+ */
259
+ proxyType?: string;
260
+
261
+ /**
262
+ * Enable TCP keep-alive for connection pooling
263
+ */
264
+ tcpKeepAliveInterval?: number;
265
+ }
266
+
267
+ export function isTunnel(resource: Resource): resource is Tunnel {
268
+ return resource[ResourceKind] === "cloudflare::Tunnel";
269
+ }
270
+
271
+ /**
272
+ * Output returned after Tunnel creation/update
273
+ */
274
+ export interface Tunnel
275
+ extends Resource<"cloudflare::Tunnel">,
276
+ Omit<TunnelProps, "delete" | "tunnelSecret"> {
277
+ /**
278
+ * The ID of the tunnel
279
+ */
280
+ tunnelId: string;
281
+
282
+ /**
283
+ * The account ID that owns the tunnel
284
+ */
285
+ accountTag: string;
286
+
287
+ /**
288
+ * Time at which the tunnel was created
289
+ */
290
+ createdAt: string;
291
+
292
+ /**
293
+ * Time at which the tunnel was deleted (null if active)
294
+ */
295
+ deletedAt: string | null;
296
+
297
+ /**
298
+ * Credentials for connecting to the tunnel
299
+ */
300
+ credentials: {
301
+ accountTag: string;
302
+ tunnelId: string;
303
+ tunnelName: string;
304
+ tunnelSecret: Secret<string>;
305
+ };
306
+
307
+ /**
308
+ * Token for running the tunnel
309
+ *
310
+ * @remarks
311
+ * Use this token with `cloudflared tunnel run --token <token>` to start the tunnel
312
+ *
313
+ * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-remote-tunnel-api/#install-and-run-the-tunnel
314
+ */
315
+ token: Secret<string>;
316
+
317
+ /**
318
+ * DNS records automatically created for hostnames in ingress rules
319
+ * Maps hostname to DNS record ID
320
+ * @internal
321
+ */
322
+ dnsRecords?: Record<string, string>;
323
+ }
324
+
325
+ /**
326
+ * Creates and manages a Cloudflare Tunnel, which provides a secure connection between
327
+ * your origin server and Cloudflare's edge. This resource handles the tunnel lifecycle
328
+ * (create, update, delete) and configuration.
329
+ *
330
+ * @remarks
331
+ * After creating a tunnel, use the returned credentials and token to run the
332
+ * cloudflared connector on your origin server.
333
+ *
334
+ * When hostnames are specified in ingress rules, this resource automatically creates
335
+ * the required DNS CNAME records pointing to <tunnel-id>.cfargotunnel.com, following
336
+ * the Cloudflare API documentation for connecting applications (step 3a).
337
+ *
338
+ * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
339
+ * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-remote-tunnel-api/
340
+ * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/configure-tunnels/
341
+ *
342
+ * @example
343
+ * // Create a basic tunnel
344
+ * const tunnel = await Tunnel("my-app", {
345
+ * name: "my-app-tunnel"
346
+ * });
347
+ *
348
+ * // Run cloudflared with:
349
+ * // cloudflared tunnel run --token <tunnel.token.unencrypted>
350
+ *
351
+ * @example
352
+ * // Create a tunnel with ingress configuration for a web app
353
+ * // DNS records are automatically created for each hostname
354
+ * const webTunnel = await Tunnel("web-app", {
355
+ * name: "web-app-tunnel",
356
+ * ingress: [
357
+ * {
358
+ * hostname: "app.example.com",
359
+ * service: "http://localhost:3000"
360
+ * },
361
+ * {
362
+ * service: "http_status:404" // catch-all rule
363
+ * }
364
+ * ]
365
+ * });
366
+ * // A CNAME record for app.example.com → webTunnel.tunnelId.cfargotunnel.com
367
+ * // is automatically created in the appropriate zone
368
+ *
369
+ * @example
370
+ * // Create a tunnel with multiple services and origin configuration
371
+ * const apiTunnel = await Tunnel("api", {
372
+ * name: "api-tunnel",
373
+ * ingress: [
374
+ * {
375
+ * hostname: "api.example.com",
376
+ * path: "/v1/*",
377
+ * service: "http://localhost:8080",
378
+ * originRequest: {
379
+ * httpHostHeader: "api.internal",
380
+ * connectTimeout: 30
381
+ * }
382
+ * },
383
+ * {
384
+ * hostname: "api.example.com",
385
+ * path: "/v2/*",
386
+ * service: "http://localhost:8081"
387
+ * },
388
+ * {
389
+ * service: "http_status:404"
390
+ * }
391
+ * ]
392
+ * });
393
+ *
394
+ * @example
395
+ * // Create a tunnel for private network access with WARP
396
+ * const privateTunnel = await Tunnel("private-network", {
397
+ * name: "private-network-tunnel",
398
+ * warpRouting: {
399
+ * enabled: true
400
+ * }
401
+ * });
402
+ *
403
+ * @example
404
+ * // Create a tunnel with origin request configuration
405
+ * const secureTunnel = await Tunnel("secure", {
406
+ * name: "secure-tunnel",
407
+ * originRequest: {
408
+ * noTLSVerify: false,
409
+ * connectTimeout: 30,
410
+ * httpHostHeader: "internal.service"
411
+ * },
412
+ * ingress: [
413
+ * {
414
+ * hostname: "secure.example.com",
415
+ * service: "https://localhost:8443"
416
+ * },
417
+ * {
418
+ * service: "http_status:404"
419
+ * }
420
+ * ]
421
+ * });
422
+ *
423
+ * @example
424
+ * // Adopt an existing tunnel if it already exists
425
+ * const existingTunnel = await Tunnel("existing", {
426
+ * name: "existing-tunnel",
427
+ * adopt: true,
428
+ * ingress: [
429
+ * {
430
+ * hostname: "updated.example.com",
431
+ * service: "http://localhost:5000"
432
+ * },
433
+ * {
434
+ * service: "http_status:404"
435
+ * }
436
+ * ]
437
+ * });
438
+ *
439
+ * @example
440
+ * // Tunnel with automatic DNS record creation
441
+ * // The Tunnel resource automatically creates DNS records for hostnames in ingress rules
442
+ * const appTunnel = await Tunnel("app", {
443
+ * name: "app-tunnel",
444
+ * ingress: [
445
+ * {
446
+ * hostname: "app.example.com",
447
+ * service: "http://localhost:3000"
448
+ * },
449
+ * {
450
+ * hostname: "api.example.com",
451
+ * service: "http://localhost:8080"
452
+ * },
453
+ * {
454
+ * service: "http_status:404"
455
+ * }
456
+ * ]
457
+ * });
458
+ * // DNS CNAME records are automatically created:
459
+ * // - app.example.com → {tunnelId}.cfargotunnel.com
460
+ * // - api.example.com → {tunnelId}.cfargotunnel.com
461
+ *
462
+ * // Run the tunnel:
463
+ * // cloudflared tunnel run --token <appTunnel.token.unencrypted>
464
+ *
465
+ * @example
466
+ * // For advanced DNS control, you can still manually manage DNS records
467
+ * // by omitting hostnames from ingress rules:
468
+ * const tunnel = await Tunnel("manual-dns", {
469
+ * name: "manual-dns-tunnel",
470
+ * ingress: [
471
+ * {
472
+ * service: "http://localhost:3000"
473
+ * },
474
+ * {
475
+ * service: "http_status:404"
476
+ * }
477
+ * ]
478
+ * });
479
+ * // Then create DNS records separately with custom configuration
480
+ */
481
+ export const Tunnel = Resource(
482
+ "cloudflare::Tunnel",
483
+ async function (
484
+ this: Context<Tunnel>,
485
+ id: string,
486
+ props: TunnelProps,
487
+ ): Promise<Tunnel> {
488
+ // Create Cloudflare API client with automatic account discovery
489
+ const api = await createCloudflareApi(props);
490
+
491
+ const name = props.name ?? id;
492
+
493
+ if (this.phase === "delete") {
494
+ // For delete operations, check if the tunnel ID exists in the output
495
+ const tunnelId = this.output?.tunnelId;
496
+ if (tunnelId && props.delete !== false) {
497
+ await deleteTunnel(api, tunnelId);
498
+ }
499
+
500
+ // Return destroyed state
501
+ return this.destroy();
502
+ }
503
+
504
+ // For create or update operations
505
+ let tunnelData: CloudflareTunnel;
506
+
507
+ if (this.phase === "update" && this.output?.tunnelId) {
508
+ // Get existing tunnel data
509
+ tunnelData = await getTunnel(api, this.output.tunnelId);
510
+
511
+ // Check if name is being changed - tunnel names are immutable
512
+ if (props.name && props.name !== tunnelData.name) {
513
+ this.replace(true);
514
+ }
515
+
516
+ // Update configuration if provided
517
+ if (
518
+ (props.ingress || props.warpRouting || props.originRequest) &&
519
+ props.configSrc !== "local"
520
+ ) {
521
+ const config: TunnelConfig = {
522
+ ingress: props.ingress,
523
+ warpRouting: props.warpRouting,
524
+ originRequest: props.originRequest,
525
+ };
526
+ await updateTunnelConfiguration(api, this.output.tunnelId, config);
527
+ }
528
+ } else {
529
+ // Create new tunnel
530
+ try {
531
+ tunnelData = await createTunnel(api, {
532
+ name,
533
+ configSrc: props.configSrc,
534
+ tunnelSecret: props.tunnelSecret,
535
+ metadata: props.metadata,
536
+ });
537
+
538
+ // Configure tunnel if config is provided
539
+ if (
540
+ (props.ingress || props.warpRouting || props.originRequest) &&
541
+ props.configSrc !== "local"
542
+ ) {
543
+ await updateTunnelConfiguration(api, tunnelData.id, {
544
+ ingress: props.ingress,
545
+ warpRouting: props.warpRouting,
546
+ originRequest: props.originRequest,
547
+ });
548
+ }
549
+ } catch (error) {
550
+ // Check if this is a "tunnel already exists" error and adopt is enabled
551
+ if (
552
+ props.adopt &&
553
+ error instanceof Error &&
554
+ (error.message.includes("already have a tunnel with this name") ||
555
+ error.message.includes("already exists"))
556
+ ) {
557
+ console.log(error);
558
+ logger.log(`Tunnel '${name}' already exists, adopting it`);
559
+
560
+ // Find the existing tunnel by name
561
+ const existingTunnel = await findTunnelByName(api, name);
562
+
563
+ if (!existingTunnel) {
564
+ throw new Error(
565
+ `Failed to find existing tunnel '${name}' for adoption`,
566
+ );
567
+ }
568
+
569
+ tunnelData = existingTunnel;
570
+
571
+ // Update configuration if provided
572
+ if (
573
+ (props.ingress || props.warpRouting || props.originRequest) &&
574
+ props.configSrc !== "local"
575
+ ) {
576
+ const config: TunnelConfig = {
577
+ ingress: props.ingress,
578
+ warpRouting: props.warpRouting,
579
+ originRequest: props.originRequest,
580
+ };
581
+ await updateTunnelConfiguration(api, existingTunnel.id, config);
582
+ }
583
+ } else {
584
+ // Re-throw the error if adopt is false or it's not an "already exists" error
585
+ throw error;
586
+ }
587
+ }
588
+ }
589
+
590
+ // Handle DNS records for ingress hostnames
591
+ let dnsRecords = this.output?.dnsRecords || {};
592
+
593
+ // Extract hostnames from ingress rules
594
+ const hostnames = new Set<string>();
595
+ if (props.ingress) {
596
+ for (const rule of props.ingress) {
597
+ if (rule.hostname && !rule.hostname.includes("*")) {
598
+ // Skip wildcard hostnames as they need special handling
599
+ hostnames.add(rule.hostname);
600
+ }
601
+ }
602
+ }
603
+
604
+ // Create or update DNS records for each hostname
605
+ if (hostnames.size > 0) {
606
+ // Group hostnames by zone
607
+ const hostnamesByZone = new Map<
608
+ string,
609
+ { zoneId: string; hostnames: string[] }
610
+ >();
611
+
612
+ for (const hostname of hostnames) {
613
+ const { zoneId } = await findZoneForHostname(api, hostname);
614
+ if (!hostnamesByZone.has(zoneId)) {
615
+ hostnamesByZone.set(zoneId, { zoneId, hostnames: [] });
616
+ }
617
+ hostnamesByZone.get(zoneId)!.hostnames.push(hostname);
618
+ }
619
+
620
+ // Create DNS records for each zone in parallel
621
+ const dnsPromises = Array.from(hostnamesByZone.entries()).map(
622
+ async ([zoneId, { hostnames: zoneHostnames }]) => {
623
+ const dnsResourceId = `${id}-dns-${zoneId}`;
624
+ const dnsResource = await DnsRecords(dnsResourceId, {
625
+ zoneId,
626
+ records: zoneHostnames.map((hostname) => ({
627
+ name: hostname,
628
+ type: "CNAME" as const,
629
+ content: `${tunnelData.id}.cfargotunnel.com`,
630
+ proxied: true,
631
+ comment: `Cloudflare Tunnel: ${tunnelData.name}`,
632
+ })),
633
+ });
634
+
635
+ // Return records for mapping storage
636
+ return dnsResource.records;
637
+ },
638
+ );
639
+
640
+ const allDnsRecords = await Promise.all(dnsPromises);
641
+
642
+ // Store DNS record mappings
643
+ for (const records of allDnsRecords) {
644
+ for (const record of records) {
645
+ dnsRecords[record.name] = record.id;
646
+ }
647
+ }
648
+ }
649
+
650
+ // Clean up DNS records that are no longer needed
651
+ const currentHostnames = Array.from(hostnames);
652
+ for (const hostname of Object.keys(dnsRecords)) {
653
+ if (!currentHostnames.includes(hostname)) {
654
+ delete dnsRecords[hostname];
655
+ }
656
+ }
657
+
658
+ // Transform API response to our interface
659
+ return this({
660
+ tunnelId: tunnelData.id,
661
+ accountTag: tunnelData.account_tag,
662
+ name: tunnelData.name,
663
+ createdAt: tunnelData.created_at,
664
+ deletedAt: tunnelData.deleted_at,
665
+ credentials: tunnelData.credentials_file
666
+ ? {
667
+ accountTag: tunnelData.credentials_file.AccountTag,
668
+ tunnelId: tunnelData.credentials_file.TunnelID,
669
+ tunnelName: tunnelData.credentials_file.TunnelName,
670
+ tunnelSecret: alchemy.secret(
671
+ tunnelData.credentials_file.TunnelSecret,
672
+ ),
673
+ }
674
+ : this.output?.credentials || {
675
+ accountTag: tunnelData.account_tag,
676
+ tunnelId: tunnelData.id,
677
+ tunnelName: tunnelData.name,
678
+ tunnelSecret: alchemy.secret(""),
679
+ },
680
+ token: tunnelData.token
681
+ ? alchemy.secret(tunnelData.token)
682
+ : this.output?.token || alchemy.secret(""),
683
+ metadata: props.metadata,
684
+ ingress: props.ingress,
685
+ warpRouting: props.warpRouting,
686
+ originRequest: props.originRequest,
687
+ configSrc: props.configSrc,
688
+ dnsRecords: Object.keys(dnsRecords).length > 0 ? dnsRecords : undefined,
689
+ });
690
+ },
691
+ );
692
+
693
+ /**
694
+ * Get tunnel details
695
+ * @internal
696
+ */
697
+ export async function getTunnel(
698
+ api: CloudflareApi,
699
+ tunnelId: string,
700
+ ): Promise<CloudflareTunnel> {
701
+ const response = await api.get(
702
+ `/accounts/${api.accountId}/cfd_tunnel/${tunnelId}`,
703
+ );
704
+
705
+ if (!response.ok) {
706
+ await handleApiError(response, "get", "tunnel", tunnelId);
707
+ }
708
+
709
+ const data =
710
+ (await response.json()) as CloudflareApiResponse<CloudflareTunnel>;
711
+ return data.result;
712
+ }
713
+
714
+ /**
715
+ * Get tunnel configuration
716
+ * @internal
717
+ */
718
+ export async function getTunnelConfiguration(
719
+ api: CloudflareApi,
720
+ tunnelId: string,
721
+ ): Promise<TunnelConfig> {
722
+ const response = await api.get(
723
+ `/accounts/${api.accountId}/cfd_tunnel/${tunnelId}/configurations`,
724
+ );
725
+
726
+ if (!response.ok) {
727
+ await handleApiError(response, "get configuration", "tunnel", tunnelId);
728
+ }
729
+
730
+ const data = (await response.json()) as CloudflareApiResponse<{
731
+ config: TunnelConfig;
732
+ }>;
733
+ return data.result.config;
734
+ }
735
+
736
+ /**
737
+ * Delete a tunnel
738
+ * @internal
739
+ */
740
+ async function deleteTunnel(
741
+ api: CloudflareApi,
742
+ tunnelId: string,
743
+ ): Promise<void> {
744
+ const response = await api.delete(
745
+ `/accounts/${api.accountId}/cfd_tunnel/${tunnelId}`,
746
+ );
747
+
748
+ if (!response.ok && response.status !== 404) {
749
+ await handleApiError(response, "delete", "tunnel", tunnelId);
750
+ }
751
+ }
752
+
753
+ /**
754
+ * Create a new tunnel
755
+ * @internal
756
+ */
757
+ async function createTunnel(
758
+ api: CloudflareApi,
759
+ props: {
760
+ name: string;
761
+ configSrc?: "cloudflare" | "local";
762
+ tunnelSecret?: Secret<string>;
763
+ metadata?: Record<string, any>;
764
+ },
765
+ ): Promise<CloudflareTunnel> {
766
+ const payload: Record<string, any> = {
767
+ name: props.name,
768
+ config_src: props.configSrc || "cloudflare",
769
+ };
770
+
771
+ if (props.tunnelSecret) {
772
+ payload.tunnel_secret = props.tunnelSecret.unencrypted;
773
+ }
774
+
775
+ if (props.metadata) {
776
+ payload.metadata = props.metadata;
777
+ }
778
+
779
+ const response = await api.post(
780
+ `/accounts/${api.accountId}/cfd_tunnel`,
781
+ payload,
782
+ );
783
+
784
+ if (!response.ok) {
785
+ await handleApiError(response, "create", "tunnel", props.name);
786
+ }
787
+
788
+ const data =
789
+ (await response.json()) as CloudflareApiResponse<CloudflareTunnel>;
790
+ return data.result;
791
+ }
792
+
793
+ /**
794
+ * Update tunnel configuration
795
+ * @internal
796
+ */
797
+ async function updateTunnelConfiguration(
798
+ api: CloudflareApi,
799
+ tunnelId: string,
800
+ config: TunnelConfig,
801
+ ): Promise<void> {
802
+ const response = await api.put(
803
+ `/accounts/${api.accountId}/cfd_tunnel/${tunnelId}/configurations`,
804
+ { config },
805
+ );
806
+
807
+ if (!response.ok) {
808
+ await handleApiError(response, "update configuration", "tunnel", tunnelId);
809
+ }
810
+ }
811
+
812
+ /**
813
+ * List all tunnels with pagination support
814
+ * @internal
815
+ */
816
+ export async function listTunnels(
817
+ api: CloudflareApi,
818
+ options?: {
819
+ /** Whether to include deleted tunnels */
820
+ includeDeleted?: boolean;
821
+ /** Maximum number of tunnels to return */
822
+ limit?: number;
823
+ },
824
+ ): Promise<CloudflareTunnel[]> {
825
+ const tunnels: CloudflareTunnel[] = [];
826
+ let page = 1;
827
+ const perPage = 100; // Maximum allowed by API
828
+ let hasMorePages = true;
829
+ const limit = options?.limit;
830
+ const includeDeleted = options?.includeDeleted ?? false;
831
+
832
+ while (hasMorePages) {
833
+ const params = new URLSearchParams({
834
+ page: page.toString(),
835
+ per_page: perPage.toString(),
836
+ });
837
+
838
+ if (!includeDeleted) {
839
+ params.append("is_deleted", "false");
840
+ }
841
+
842
+ const response = await api.get(
843
+ `/accounts/${api.accountId}/cfd_tunnel?${params.toString()}`,
844
+ );
845
+
846
+ if (!response.ok) {
847
+ await handleApiError(response, "list", "tunnel", "all");
848
+ }
849
+
850
+ const data =
851
+ (await response.json()) as CloudflareApiListResponse<CloudflareTunnel>;
852
+
853
+ tunnels.push(...data.result);
854
+ const resultInfo = data.result_info;
855
+
856
+ // Check if we've reached the limit
857
+ if (limit && tunnels.length >= limit) {
858
+ return tunnels.slice(0, limit);
859
+ }
860
+
861
+ // Check if we've seen all pages
862
+ hasMorePages =
863
+ resultInfo.page * resultInfo.per_page < resultInfo.total_count;
864
+ page++;
865
+ }
866
+
867
+ return tunnels;
868
+ }
869
+
870
+ /**
871
+ * Find a tunnel by name with pagination support
872
+ * @internal
873
+ */
874
+ export async function findTunnelByName(
875
+ api: CloudflareApi,
876
+ name: string,
877
+ ): Promise<CloudflareTunnel | null> {
878
+ let page = 1;
879
+ const perPage = 100; // Maximum allowed by API
880
+ let hasMorePages = true;
881
+
882
+ while (hasMorePages) {
883
+ const response = await api.get(
884
+ `/accounts/${api.accountId}/cfd_tunnel?page=${page}&per_page=${perPage}&is_deleted=false`,
885
+ );
886
+
887
+ if (!response.ok) {
888
+ await handleApiError(response, "list", "tunnel", "all");
889
+ }
890
+
891
+ const data =
892
+ (await response.json()) as CloudflareApiListResponse<CloudflareTunnel>;
893
+
894
+ const tunnels = data.result;
895
+ const resultInfo = data.result_info;
896
+
897
+ // Look for a tunnel with matching name
898
+ const match = tunnels.find((tunnel) => tunnel.name === name);
899
+ if (match) {
900
+ return match;
901
+ }
902
+
903
+ // Check if we've seen all pages
904
+ hasMorePages =
905
+ resultInfo.page * resultInfo.per_page < resultInfo.total_count;
906
+ page++;
907
+ }
908
+
909
+ // No matching tunnel found
910
+ return null;
911
+ }