1import * as Alchemy from "alchemy";
2import * as Cloudflare from "alchemy/Cloudflare";
3import * as Arr from "effect/Array";
4import * as Context from "effect/Context";
5import * as Crypto from "effect/Crypto";
6import * as DateTime from "effect/DateTime";
7import * as Effect from "effect/Effect";
8import * as Hex from "effect/encoding/Hex";
9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";
11import * as Schema from "effect/Schema";
12
13import type {
14 RelayManagedEndpoint,
15 RelayManagedEndpointOrigin,
16 RelayManagedEndpointRuntimeConfig,
17} from "@t3tools/contracts/relay";
18
19import * as RelayConfiguration from "../Config.ts";
20import {
21 managedEndpointDigestInput,
22 managedEndpointForHostname,
23 managedEndpointHostname,
24 managedEndpointTunnelName,
25} from "../deploymentConfig.ts";
26import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts";
27import * as ManagedTunnelLimits from "./ManagedTunnelLimits.ts";
28
29export class ManagedEndpointProvisioningNotConfigured extends Schema.TaggedError<ManagedEndpointProvisioningNotConfigured>()(
30 "ManagedEndpointProvisioningNotConfigured",
31 {
32 userId: Schema.String,
33 environmentId: Schema.String,
34 missingSettings: Schema.Array(
35 Schema.Literals(["managedEndpointBaseDomain", "managedEndpointNamespace"]),
36 ),
37 },
38) {
39 override get message(): string {
40 return `Managed endpoint provisioning is not configured for user '${this.userId}', environment '${this.environmentId}': missing ${this.missingSettings.join(", ")}`;
41 }
42}
43
44const ManagedEndpointProvisioningStage = Schema.Literals([
45 "derive-environment-hash",
46 "check-tunnel-limit",
47 "reserve-allocation",
48 "ensure-tunnel",
49 "validate-tunnel-response",
50 "record-tunnel",
51 "configure-tunnel",
52 "ensure-dns-record",
53 "record-dns",
54 "get-tunnel-token",
55 "mark-allocation-ready",
56 "load-allocation",
57 "verify-endpoint",
58 "verify-tunnel",
59 "sync-origin",
60]);
61
62// Why a stage failed without an underlying error. `claim-lost` means another
63// provision, release, or deprovision changed the allocation's generation or
64// tunnel after this one loaded it.
65const ManagedEndpointProvisioningFailureReason = Schema.Literals([
66 "claim-lost",
67 "endpoint-mismatch",
68 "invalid-tunnel-response",
69]);
70
71export class ManagedEndpointProvisioningFailed extends Schema.TaggedError<ManagedEndpointProvisioningFailed>()(
72 "ManagedEndpointProvisioningFailed",
73 {
74 stage: ManagedEndpointProvisioningStage,
75 reason: Schema.optionalKey(ManagedEndpointProvisioningFailureReason),
76 userId: Schema.String,
77 environmentId: Schema.String,
78 hostname: Schema.optionalKey(Schema.String),
79 tunnelName: Schema.optionalKey(Schema.String),
80 tunnelId: Schema.optionalKey(Schema.String),
81 dnsRecordId: Schema.optionalKey(Schema.String),
82 returnedTunnelName: Schema.optionalKey(Schema.String),
83 returnedTunnelId: Schema.optionalKey(Schema.String),
84 cause: Schema.optional(Schema.Defect()),
85 },
86) {
87 override get message(): string {
88 return `Managed endpoint provisioning failed during '${this.stage}' for user '${this.userId}', environment '${this.environmentId}'`;
89 }
90}
91
92const ManagedEndpointDeprovisioningStage = Schema.Literals([
93 "load-allocation",
94 "load-tunnel",
95 "claim-release",
96 "claim-deprovision",
97 "delete-dns-record",
98 "delete-tunnel",
99 "remove-allocation",
100]);
101
102export class ManagedEndpointDeprovisioningFailed extends Schema.TaggedError<ManagedEndpointDeprovisioningFailed>()(
103 "ManagedEndpointDeprovisioningFailed",
104 {
105 stage: ManagedEndpointDeprovisioningStage,
106 userId: Schema.String,
107 environmentId: Schema.String,
108 tunnelId: Schema.optionalKey(Schema.String),
109 dnsRecordId: Schema.optionalKey(Schema.String),
110 cause: Schema.Defect(),
111 },
112) {
113 override get message(): string {
114 return `Managed endpoint deprovisioning failed during '${this.stage}' for user '${this.userId}', environment '${this.environmentId}'`;
115 }
116}
117
118export class ManagedEndpointOriginNotAllowed extends Schema.TaggedError<ManagedEndpointOriginNotAllowed>()(
119 "ManagedEndpointOriginNotAllowed",
120 {
121 userId: Schema.String,
122 environmentId: Schema.String,
123 host: Schema.String,
124 port: Schema.Number,
125 },
126) {
127 override get message(): string {
128 return `Managed endpoint origin '${this.host}:${this.port}' is not allowed for user '${this.userId}', environment '${this.environmentId}'`;
129 }
130}
131
132export type ManagedEndpointProviderError =
133 | ManagedEndpointProvisioningNotConfigured
134 | ManagedEndpointProvisioningFailed
135 | ManagedEndpointOriginNotAllowed
136 | ManagedTunnelLimits.ManagedTunnelLimitExceeded;
137
138export interface ManagedEndpointProvisioningResult {
139 readonly endpoint: RelayManagedEndpoint;
140 readonly runtime: RelayManagedEndpointRuntimeConfig;
141}
142
143export type ManagedEndpointOriginSyncResult = "ready" | "recovery_required";
144
145export type ManagedEndpointDeprovisionTarget = ManagedEndpointAllocations.ManagedEndpointAllocation;
146
147export class ManagedEndpointProvider extends Context.Service<
148 ManagedEndpointProvider,
149 {
150 readonly provision: (input: {
151 readonly userId: string;
152 readonly environmentId: string;
153 readonly origin: RelayManagedEndpointOrigin;
154 }) => Effect.Effect<ManagedEndpointProvisioningResult, ManagedEndpointProviderError>;
155 readonly reconcileOrigin: (input: {
156 readonly userId: string;
157 readonly environmentId: string;
158 readonly tunnelId: string;
159 readonly origin: RelayManagedEndpointOrigin;
160 readonly endpoint: RelayManagedEndpoint;
161 }) => Effect.Effect<ManagedEndpointOriginSyncResult, ManagedEndpointProviderError>;
162 /**
163 * Captures the allocation generation owned by an unlink before its link
164 * revocation commits. Passing this target to `deprovision` prevents a
165 * concurrent relink from having its newer allocation torn down.
166 */
167 readonly prepareDeprovision: (input: {
168 readonly userId: string;
169 readonly environmentId: string;
170 }) => Effect.Effect<
171 ManagedEndpointDeprovisionTarget | null,
172 ManagedEndpointDeprovisioningFailed
173 >;
174 readonly deprovision: (input: {
175 readonly userId: string;
176 readonly environmentId: string;
177 readonly target?: ManagedEndpointDeprovisionTarget | null;
178 }) => Effect.Effect<boolean, ManagedEndpointDeprovisioningFailed>;
179 /**
180 * Deletes the provisioned Cloudflare tunnel while keeping the allocation
181 * (hostname + tunnel name reservation) and DNS record. Cloudflare bills per
182 * provisioned tunnel, so environments release the tunnel when they shut
183 * down; the next `provision` recreates it under the same name and repoints
184 * the CNAME, preserving the endpoint URL.
185 *
186 * Resolves to whether the caller's connector token is now dead: true when
187 * the tunnel was deleted (or none was recorded to begin with), false when
188 * a concurrent provision outbid the release claim and the recorded tunnel
189 * — and any token issued for it — stays live.
190 */
191 readonly release: (input: {
192 readonly userId: string;
193 readonly environmentId: string;
194 readonly expectedTunnelId?: string;
195 readonly expectedInactiveBefore?: string;
196 readonly expectedStatus?: "inactive" | "down";
197 /**
198 * Record that cleanup removed a legacy host's tunnel, so status tells the
199 * user to update. Other releases leave hosts that recover on their own.
200 */
201 readonly markReleased?: boolean;
202 }) => Effect.Effect<boolean, ManagedEndpointDeprovisioningFailed>;
203 }
204>()("t3code-relay/environments/ManagedEndpointProvider") {}
205
206export interface ManagedEndpointTunnel {
207 readonly id?: string | null;
208 readonly name?: string | null;
209 readonly status?: string | null;
210 readonly createdAt?: string | null;
211 readonly connsInactiveAt?: string | null;
212 readonly deletedAt?: string | null;
213}
214
215export interface ManagedEndpointTunnelListRequest {
216 readonly isDeleted: false;
217 readonly name?: string;
218 readonly includePrefix?: string;
219 readonly status?: "inactive" | "down";
220 readonly existedAt?: string;
221 readonly wasInactiveAt?: string;
222 readonly page?: number;
223 readonly perPage?: number;
224}
225
226const ManagedEndpointTunnelClientOperation = Schema.Literals([
227 "get",
228 "list",
229 "create",
230 "put-configuration",
231 "get-token",
232 "delete",
233]);
234
235export class ManagedEndpointTunnelClientError extends Schema.TaggedError<ManagedEndpointTunnelClientError>()(
236 "ManagedEndpointTunnelClientError",
237 {
238 operation: ManagedEndpointTunnelClientOperation,
239 tunnelName: Schema.optionalKey(Schema.String),
240 tunnelId: Schema.optionalKey(Schema.String),
241 cause: Schema.Defect(),
242 },
243) {
244 override get message(): string {
245 const target = this.tunnelId ?? this.tunnelName;
246 return `Managed endpoint tunnel provider '${this.operation}' request failed${target === undefined ? "" : ` for '${target}'`}`;
247 }
248}
249
250export class ManagedEndpointTunnelClient extends Context.Service<
251 ManagedEndpointTunnelClient,
252 {
253 readonly get: (
254 tunnelId: string,
255 ) => Effect.Effect<ManagedEndpointTunnel, ManagedEndpointTunnelClientError>;
256 readonly list: (request: ManagedEndpointTunnelListRequest) => Effect.Effect<
257 {
258 readonly result: ReadonlyArray<ManagedEndpointTunnel>;
259 readonly resultInfo?: {
260 readonly page?: number | null;
261 readonly perPage?: number | null;
262 readonly totalCount?: number | null;
263 } | null;
264 },
265 ManagedEndpointTunnelClientError
266 >;
267 readonly create: (request: {
268 readonly name: string;
269 readonly configSrc: "cloudflare";
270 }) => Effect.Effect<ManagedEndpointTunnel, ManagedEndpointTunnelClientError>;
271 readonly putConfiguration: (
272 tunnelId: string,
273 config: {
274 readonly ingress: Array<{
275 readonly hostname?: string;
276 readonly service: string;
277 }>;
278 },
279 ) => Effect.Effect<unknown, ManagedEndpointTunnelClientError>;
280 readonly getToken: (
281 tunnelId: string,
282 ) => Effect.Effect<string, ManagedEndpointTunnelClientError>;
283 readonly delete: (tunnelId: string) => Effect.Effect<unknown, ManagedEndpointTunnelClientError>;
284 }
285>()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointTunnelClient") {}
286
287export const layerTunnelClient = (client: ManagedEndpointTunnelClient["Service"]) =>
288 Layer.succeed(ManagedEndpointTunnelClient, client);
289
290interface ManagedEndpointCnameRecordInput {
291 readonly type: "CNAME";
292 readonly name: string;
293 readonly content: string;
294 readonly ttl: 1;
295 readonly proxied: true;
296}
297
298const ManagedEndpointDnsClientOperation = Schema.Literals([
299 "list-records",
300 "create-record",
301 "update-record",
302 "delete-record",
303]);
304
305export class ManagedEndpointDnsClientError extends Schema.TaggedError<ManagedEndpointDnsClientError>()(
306 "ManagedEndpointDnsClientError",
307 {
308 operation: ManagedEndpointDnsClientOperation,
309 hostname: Schema.optionalKey(Schema.String),
310 dnsRecordId: Schema.optionalKey(Schema.String),
311 cause: Schema.Defect(),
312 },
313) {
314 override get message(): string {
315 const target = this.dnsRecordId ?? this.hostname;
316 return `Managed endpoint DNS provider '${this.operation}' request failed${target === undefined ? "" : ` for '${target}'`}`;
317 }
318}
319
320export class ManagedEndpointDnsClient extends Context.Service<
321 ManagedEndpointDnsClient,
322 {
323 readonly listRecords: (
324 hostname: string,
325 ) => Effect.Effect<ReadonlyArray<{ readonly id: string }>, ManagedEndpointDnsClientError>;
326 readonly createRecord: (
327 request: ManagedEndpointCnameRecordInput,
328 ) => Effect.Effect<{ readonly id: string }, ManagedEndpointDnsClientError>;
329 readonly updateRecord: (
330 dnsRecordId: string,
331 request: ManagedEndpointCnameRecordInput,
332 ) => Effect.Effect<unknown, ManagedEndpointDnsClientError>;
333 readonly deleteRecord: (
334 dnsRecordId: string,
335 ) => Effect.Effect<unknown, ManagedEndpointDnsClientError>;
336 }
337>()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointDnsClient") {}
338
339export const layerDnsClient = (client: ManagedEndpointDnsClient["Service"]) =>
340 Layer.succeed(ManagedEndpointDnsClient, client);
341
342const requireCloudflareSettings = Effect.fnUntraced(function* (
343 settings: RelayConfiguration.RelayConfiguration["Service"],
344 input: { readonly userId: string; readonly environmentId: string },
345) {
346 const baseDomain = settings.managedEndpointBaseDomain;
347 const namespace = settings.managedEndpointNamespace;
348 const missingSettings: Array<"managedEndpointBaseDomain" | "managedEndpointNamespace"> = [];
349 if (!baseDomain) {
350 missingSettings.push("managedEndpointBaseDomain");
351 }
352 if (!namespace) {
353 missingSettings.push("managedEndpointNamespace");
354 }
355 if (!baseDomain || !namespace) {
356 return yield* new ManagedEndpointProvisioningNotConfigured({
357 ...input,
358 missingSettings,
359 });
360 }
361 return {
362 baseDomain,
363 namespace,
364 };
365});
366
367function formatOriginService(origin: RelayManagedEndpointOrigin): string {
368 const host = origin.localHttpHost.includes(":")
369 ? `[${origin.localHttpHost.replace(/^\[(.*)\]$/u, "$1")}]`
370 : origin.localHttpHost;
371 return `http://${host}:${origin.localHttpPort}`;
372}
373
374function normalizeHostname(hostname: string): string {
375 return hostname
376 .trim()
377 .toLowerCase()
378 .replace(/\.$/u, "")
379 .replace(/^\[(.*)\]$/u, "$1");
380}
381
382function isLoopbackOrigin(origin: RelayManagedEndpointOrigin): boolean {
383 const hostname = normalizeHostname(origin.localHttpHost);
384 return (
385 (hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost") &&
386 Number.isInteger(origin.localHttpPort) &&
387 origin.localHttpPort > 0 &&
388 origin.localHttpPort <= 65_535
389 );
390}
391
392export function isManagedEndpointNotFound(cause: unknown): boolean {
393 if (typeof cause !== "object" || cause === null) {
394 return false;
395 }
396 if ("_tag" in cause && (cause._tag === "NotFound" || cause._tag === "TunnelNotFound")) {
397 return true;
398 }
399 if ("status" in cause && cause.status === 404) {
400 return true;
401 }
402 return "cause" in cause && isManagedEndpointNotFound(cause.cause);
403}
404
405/**
406 * Cloudflare refuses to delete a tunnel while a connector is still attached,
407 * either one that has not finished draining or another runtime still serving
408 * the tunnel.
409 */
410export function isManagedEndpointTunnelInUse(cause: unknown): boolean {
411 if (typeof cause !== "object" || cause === null) {
412 return false;
413 }
414 if (
415 "message" in cause &&
416 typeof cause.message === "string" &&
417 cause.message.includes("has active connections")
418 ) {
419 return true;
420 }
421 return "cause" in cause && isManagedEndpointTunnelInUse(cause.cause);
422}
423
424type ManagedEndpointClientError = ManagedEndpointTunnelClientError | ManagedEndpointDnsClientError;
425
426const ignoreNotFound = <A>(
427 effect: Effect.Effect<A, ManagedEndpointClientError>,
428): Effect.Effect<void, ManagedEndpointClientError> =>
429 effect.pipe(
430 Effect.asVoid,
431 Effect.catchTags({
432 ManagedEndpointTunnelClientError: (error) =>
433 isManagedEndpointNotFound(error.cause) ? Effect.void : Effect.fail(error),
434 ManagedEndpointDnsClientError: (error) =>
435 isManagedEndpointNotFound(error.cause) ? Effect.void : Effect.fail(error),
436 }),
437 );
438
439export const make = Effect.gen(function* () {
440 const config = yield* RelayConfiguration.RelayConfiguration;
441 const crypto = yield* Crypto.Crypto;
442 const tunnels = yield* ManagedEndpointTunnelClient;
443 const dns = yield* ManagedEndpointDnsClient;
444 const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations;
445 const tunnelLimits = yield* ManagedTunnelLimits.ManagedTunnelLimits;
446
447 const updateExistingDnsRecords = Effect.fnUntraced(function* (
448 records: ReadonlyArray<{ readonly id: string }>,
449 preferredDnsRecordId: string | null,
450 dnsRecord: ManagedEndpointCnameRecordInput,
451 ) {
452 const keptRecord = records.find((record) => record.id === preferredDnsRecordId) ?? records[0];
453 if (keptRecord === undefined) {
454 return null;
455 }
456 yield* Effect.forEach(
457 records,
458 (record) => (record.id === keptRecord.id ? Effect.void : dns.deleteRecord(record.id)),
459 { discard: true },
460 );
461 yield* dns.updateRecord(keptRecord.id, dnsRecord);
462 return keptRecord.id;
463 });
464
465 const ensureDnsRecord = Effect.fnUntraced(function* (
466 hostname: string,
467 preferredDnsRecordId: string | null,
468 dnsRecord: ManagedEndpointCnameRecordInput,
469 ) {
470 if (preferredDnsRecordId !== null) {
471 const checkpointedRecordUpdated = yield* dns
472 .updateRecord(preferredDnsRecordId, dnsRecord)
473 .pipe(
474 Effect.as(true),
475 Effect.catchTags({
476 ManagedEndpointDnsClientError: (error) =>
477 isManagedEndpointNotFound(error.cause) ? Effect.succeed(false) : Effect.fail(error),
478 }),
479 );
480 if (checkpointedRecordUpdated) {
481 return preferredDnsRecordId;
482 }
483 }
484 const existingDnsRecords = yield* dns.listRecords(hostname);
485 const existingDnsRecordId = yield* updateExistingDnsRecords(
486 existingDnsRecords,
487 preferredDnsRecordId,
488 dnsRecord,
489 );
490 if (existingDnsRecordId !== null) {
491 return existingDnsRecordId;
492 }
493 return yield* dns.createRecord(dnsRecord).pipe(
494 Effect.map((record) => record.id),
495 Effect.catchTags({
496 ManagedEndpointDnsClientError: (createError) =>
497 Effect.gen(function* () {
498 let records = yield* dns.listRecords(hostname);
499 for (let attempt = 0; records.length === 0 && attempt < 4; attempt++) {
500 yield* Effect.sleep("200 millis");
501 records = yield* dns.listRecords(hostname);
502 }
503 return records;
504 }).pipe(
505 Effect.flatMap((records) =>
506 records.length > 0
507 ? updateExistingDnsRecords(records, preferredDnsRecordId, dnsRecord)
508 : Effect.fail(createError),
509 ),
510 Effect.filterOrFail(
511 (dnsRecordId) => dnsRecordId !== null,
512 () => createError,
513 ),
514 ),
515 }),
516 );
517 });
518
519 const prepareDeprovision = Effect.fn("relay.managed_endpoint_provider.prepare_deprovision")(
520 function* (input: { readonly userId: string; readonly environmentId: string }) {
521 return yield* allocations.get(input).pipe(
522 Effect.mapError(
523 (cause) =>
524 new ManagedEndpointDeprovisioningFailed({
525 ...input,
526 stage: "load-allocation",
527 cause,
528 }),
529 ),
530 );
531 },
532 );
533
534 const reconcileOrigin = Effect.fn("relay.managed_endpoint_provider.reconcile_origin")(
535 function* (input: {
536 readonly userId: string;
537 readonly environmentId: string;
538 readonly tunnelId: string;
539 readonly origin: RelayManagedEndpointOrigin;
540 readonly endpoint: RelayManagedEndpoint;
541 }) {
542 if (!isLoopbackOrigin(input.origin)) {
543 return yield* new ManagedEndpointOriginNotAllowed({
544 userId: input.userId,
545 environmentId: input.environmentId,
546 host: input.origin.localHttpHost,
547 port: input.origin.localHttpPort,
548 });
549 }
550 const allocation = yield* allocations.get(input).pipe(
551 Effect.mapError(
552 (cause) =>
553 new ManagedEndpointProvisioningFailed({
554 ...input,
555 stage: "load-allocation",
556 cause,
557 }),
558 ),
559 );
560 if (
561 allocation === null ||
562 allocation.tunnelId !== input.tunnelId ||
563 allocation.dnsRecordId === null ||
564 allocation.readyAt === null
565 ) {
566 return "recovery_required";
567 }
568 const cf = yield* requireCloudflareSettings(config, input);
569 const recordedEndpoint = ManagedEndpointAllocations.resolveReadyManagedEndpoint({
570 allocation,
571 baseDomain: cf.baseDomain,
572 });
573 if (
574 recordedEndpoint === null ||
575 recordedEndpoint.httpBaseUrl !== input.endpoint.httpBaseUrl ||
576 recordedEndpoint.wsBaseUrl !== input.endpoint.wsBaseUrl ||
577 recordedEndpoint.providerKind !== input.endpoint.providerKind
578 ) {
579 return yield* new ManagedEndpointProvisioningFailed({
580 ...input,
581 stage: "verify-endpoint",
582 reason: "endpoint-mismatch",
583 hostname: allocation.hostname,
584 });
585 }
586 // A release keeps the recorded tunnel id, so the record alone cannot
587 // tell a live tunnel from one deleted by a shutdown whose host was
588 // killed before it dropped its config. Ask Cloudflare, or the host
589 // starts a connector that can never connect.
590 const recorded = yield* tunnels.get(input.tunnelId).pipe(
591 Effect.asSome,
592 Effect.catchTags({
593 ManagedEndpointTunnelClientError: (cause) =>
594 isManagedEndpointNotFound(cause.cause)
595 ? Effect.succeedNone
596 : Effect.fail(
597 new ManagedEndpointProvisioningFailed({
598 userId: input.userId,
599 environmentId: input.environmentId,
600 stage: "verify-tunnel",
601 tunnelId: input.tunnelId,
602 cause,
603 }),
604 ),
605 }),
606 );
607 if (Option.isNone(recorded) || recorded.value.deletedAt) {
608 return "recovery_required";
609 }
610 if (
611 allocation.origin?.localHttpHost === input.origin.localHttpHost &&
612 allocation.origin.localHttpPort === input.origin.localHttpPort
613 ) {
614 return "ready";
615 }
616
617 const updated = yield* allocations
618 .withClaimedTunnel(
619 {
620 userId: input.userId,
621 environmentId: input.environmentId,
622 tunnelId: input.tunnelId,
623 generation: allocation.generation,
624 },
625 tunnels
626 .putConfiguration(input.tunnelId, {
627 ingress: [
628 {
629 hostname: allocation.hostname,
630 service: formatOriginService(input.origin),
631 },
632 { service: "http_status:404" },
633 ],
634 })
635 .pipe(
636 Effect.as("configured" as const),
637 Effect.catchTags({
638 ManagedEndpointTunnelClientError: (error) =>
639 isManagedEndpointNotFound(error.cause)
640 ? Effect.succeed("missing" as const)
641 : Effect.fail(error),
642 }),
643 Effect.filterOrElse(
644 (result): result is "missing" => result === "missing",
645 () =>
646 allocations
647 .markReady({
648 ...input,
649 generation: allocation.generation,
650 })
651 .pipe(
652 Effect.map((updated) =>
653 updated ? ("configured" as const) : ("stale" as const),
654 ),
655 ),
656 ),
657 ),
658 )
659 .pipe(
660 Effect.mapError(
661 (cause) =>
662 new ManagedEndpointProvisioningFailed({
663 ...input,
664 stage: "sync-origin",
665 cause,
666 }),
667 ),
668 );
669 if (Option.isNone(updated) || updated.value === "stale") {
670 return yield* new ManagedEndpointProvisioningFailed({
671 ...input,
672 stage: "sync-origin",
673 reason: "claim-lost",
674 });
675 }
676 return updated.value === "configured" ? "ready" : "recovery_required";
677 },
678 );
679
680 return ManagedEndpointProvider.of({
681 prepareDeprovision,
682 reconcileOrigin,
683 deprovision: Effect.fn("relay.managed_endpoint_provider.deprovision")(function* (input) {
684 yield* Effect.annotateCurrentSpan({
685 "relay.user_id": input.userId,
686 "relay.environment_id": input.environmentId,
687 });
688 const allocation =
689 input.target === undefined ? yield* prepareDeprovision(input) : input.target;
690 if (allocation === null) {
691 return true;
692 }
693 const claimedGeneration = yield* allocations
694 .claimDeprovision({
695 userId: input.userId,
696 environmentId: input.environmentId,
697 generation: allocation.generation,
698 })
699 .pipe(
700 Effect.mapError(
701 (cause) =>
702 new ManagedEndpointDeprovisioningFailed({
703 ...input,
704 stage: "claim-deprovision",
705 ...(allocation.tunnelId === null ? {} : { tunnelId: allocation.tunnelId }),
706 ...(allocation.dnsRecordId === null ? {} : { dnsRecordId: allocation.dnsRecordId }),
707 cause,
708 }),
709 ),
710 );
711 if (claimedGeneration === null) {
712 return false;
713 }
714 const tunnelId = allocation.tunnelId;
715 const deprovision = Effect.gen(function* () {
716 const dnsRecordId = allocation.dnsRecordId;
717 if (dnsRecordId !== null) {
718 yield* ignoreNotFound(dns.deleteRecord(dnsRecordId)).pipe(
719 Effect.mapError(
720 (cause) =>
721 new ManagedEndpointDeprovisioningFailed({
722 ...input,
723 stage: "delete-dns-record",
724 dnsRecordId,
725 cause,
726 }),
727 ),
728 );
729 }
730 if (tunnelId !== null) {
731 yield* ignoreNotFound(tunnels.delete(tunnelId)).pipe(
732 Effect.mapError(
733 (cause) =>
734 new ManagedEndpointDeprovisioningFailed({
735 ...input,
736 stage: "delete-tunnel",
737 tunnelId,
738 cause,
739 }),
740 ),
741 );
742 }
743 return yield* allocations
744 .removeClaimed({
745 userId: input.userId,
746 environmentId: input.environmentId,
747 generation: claimedGeneration,
748 })
749 .pipe(
750 Effect.mapError(
751 (cause) =>
752 new ManagedEndpointDeprovisioningFailed({
753 ...input,
754 stage: "remove-allocation",
755 ...(allocation.tunnelId === null ? {} : { tunnelId: allocation.tunnelId }),
756 ...(allocation.dnsRecordId === null
757 ? {}
758 : { dnsRecordId: allocation.dnsRecordId }),
759 cause,
760 }),
761 ),
762 );
763 });
764 if (tunnelId === null) {
765 return yield* deprovision;
766 }
767 const removed = yield* allocations
768 .withClaimedTunnel(
769 {
770 userId: input.userId,
771 environmentId: input.environmentId,
772 tunnelId,
773 generation: claimedGeneration,
774 },
775 deprovision,
776 )
777 .pipe(
778 Effect.catchTags({
779 ManagedEndpointAllocationPersistenceError: (cause) =>
780 Effect.fail(
781 new ManagedEndpointDeprovisioningFailed({
782 ...input,
783 stage: "claim-deprovision",
784 tunnelId,
785 cause,
786 }),
787 ),
788 }),
789 );
790 return Option.getOrElse(removed, () => false);
791 }),
792 release: Effect.fn("relay.managed_endpoint_provider.release")(function* (input) {
793 yield* Effect.annotateCurrentSpan({
794 "relay.user_id": input.userId,
795 "relay.environment_id": input.environmentId,
796 });
797 const allocation = yield* allocations.get(input).pipe(
798 Effect.mapError(
799 (cause) =>
800 new ManagedEndpointDeprovisioningFailed({
801 ...input,
802 stage: "load-allocation",
803 cause,
804 }),
805 ),
806 );
807 const tunnelId = allocation?.tunnelId ?? null;
808 if (allocation === null || tunnelId === null) {
809 return true;
810 }
811 if (input.expectedTunnelId !== undefined && input.expectedTunnelId !== tunnelId) {
812 return false;
813 }
814 // Claim the release against the allocation's current generation before
815 // touching Cloudflare. A provision racing this release (fast environment
816 // restart) increments the generation when it records its tunnel, so a stale
817 // claim means the recorded tunnel may already back a fresh connector and
818 // must be left alive. A provision that starts after the claim instead
819 // fails loudly on the deleted tunnel and the client-side retry
820 // provisions a replacement.
821 const claimedGeneration = yield* allocations
822 .claimRelease({
823 userId: input.userId,
824 environmentId: input.environmentId,
825 tunnelId,
826 generation: allocation.generation,
827 })
828 .pipe(
829 Effect.mapError(
830 (cause) =>
831 new ManagedEndpointDeprovisioningFailed({
832 ...input,
833 stage: "claim-release",
834 tunnelId,
835 cause,
836 }),
837 ),
838 );
839 if (claimedGeneration === null) {
840 return false;
841 }
842 // After a failed delete: succeed if the tunnel is gone, since the delete
843 // then took effect and only its response was lost; otherwise keep the
844 // delete's error.
845 const confirmTunnelGone = (
846 failure: ManagedEndpointDeprovisioningFailed,
847 ): Effect.Effect<void, ManagedEndpointDeprovisioningFailed> =>
848 tunnels.get(tunnelId).pipe(
849 Effect.andThen(Effect.fail(failure)),
850 Effect.catchTags({
851 ManagedEndpointTunnelClientError: (lookupFailure) =>
852 isManagedEndpointNotFound(lookupFailure.cause) ? Effect.void : Effect.fail(failure),
853 }),
854 );
855 const deleteTunnel = ignoreNotFound(tunnels.delete(tunnelId)).pipe(
856 Effect.mapError(
857 (cause) =>
858 new ManagedEndpointDeprovisioningFailed({
859 ...input,
860 stage: "delete-tunnel",
861 tunnelId,
862 cause,
863 }),
864 ),
865 );
866 if (input.expectedInactiveBefore !== undefined && input.expectedStatus !== undefined) {
867 const expectedStatus = input.expectedStatus;
868 const inactiveBefore = input.expectedInactiveBefore;
869 const currentTunnel = yield* tunnels.get(tunnelId).pipe(
870 Effect.asSome,
871 Effect.catchTags({
872 ManagedEndpointTunnelClientError: (cause) =>
873 isManagedEndpointNotFound(cause.cause)
874 ? Effect.succeedNone
875 : Effect.fail(
876 new ManagedEndpointDeprovisioningFailed({
877 ...input,
878 stage: "load-tunnel",
879 tunnelId,
880 cause,
881 }),
882 ),
883 }),
884 );
885 if (Option.isNone(currentTunnel)) {
886 return true;
887 }
888 const inactiveAt =
889 expectedStatus === "down"
890 ? currentTunnel.value.connsInactiveAt
891 : currentTunnel.value.createdAt;
892 if (
893 currentTunnel.value.id !== tunnelId ||
894 currentTunnel.value.status !== expectedStatus ||
895 typeof inactiveAt !== "string"
896 ) {
897 return false;
898 }
899 const inactiveTime = DateTime.make(inactiveAt);
900 const cutoff = DateTime.make(inactiveBefore);
901 if (
902 Option.isNone(inactiveTime) ||
903 Option.isNone(cutoff) ||
904 inactiveTime.value.epochMilliseconds > cutoff.value.epochMilliseconds
905 ) {
906 return false;
907 }
908 }
909 const released = yield* allocations
910 .withClaimedTunnel(
911 {
912 userId: input.userId,
913 environmentId: input.environmentId,
914 tunnelId,
915 generation: claimedGeneration,
916 },
917 Effect.gen(function* () {
918 const finalGeneration = yield* allocations
919 .claimRelease({
920 userId: input.userId,
921 environmentId: input.environmentId,
922 tunnelId,
923 generation: claimedGeneration,
924 ...(input.markReleased === true ? { markReleased: true } : {}),
925 })
926 .pipe(
927 Effect.mapError(
928 (cause) =>
929 new ManagedEndpointDeprovisioningFailed({
930 ...input,
931 stage: "claim-release",
932 tunnelId,
933 cause,
934 }),
935 ),
936 );
937 if (finalGeneration === null) {
938 return false;
939 }
940 // A connector still attached means the tunnel is not released. That
941 // is the same answer as losing the claim: the caller keeps its config,
942 // and the reaper deletes the tunnel once it has been down long enough.
943 // A delete whose response is lost (a timeout) may still have
944 // removed the tunnel. Ask Cloudflare before rolling back: if the
945 // tunnel is gone, the delete happened and the claim, including any
946 // released marker, must commit, since no later sweep can find
947 // this tunnel again to retry.
948 return yield* deleteTunnel.pipe(
949 Effect.as(true),
950 Effect.catchIf(
951 (error) => isManagedEndpointTunnelInUse(error.cause),
952 () => Effect.succeed(false),
953 ),
954 Effect.catchTags({
955 ManagedEndpointDeprovisioningFailed: (failure) =>
956 confirmTunnelGone(failure).pipe(Effect.as(true)),
957 }),
958 );
959 }),
960 )
961 .pipe(
962 Effect.catchTags({
963 ManagedEndpointAllocationPersistenceError: (cause) =>
964 Effect.fail(
965 new ManagedEndpointDeprovisioningFailed({
966 ...input,
967 stage: "claim-release",
968 tunnelId,
969 cause,
970 }),
971 ),
972 }),
973 );
974 // The recorded tunnelId is now stale, but the allocation row is left
975 // untouched deliberately: connect/status authorization requires a fully
976 // recorded allocation, and an offline environment must keep reporting
977 // "offline" (health probe fails) rather than "not authorized". The next
978 // provision lists tunnels by name, finds none, creates a replacement and
979 // re-records the fresh id.
980 return Option.getOrElse(released, () => false);
981 }),
982 provision: Effect.fn("relay.managed_endpoint_provider.provision")(function* (input) {
983 yield* Effect.annotateCurrentSpan({
984 "relay.user_id": input.userId,
985 "relay.environment_id": input.environmentId,
986 "relay.managed_endpoint.origin_host": input.origin.localHttpHost,
987 "relay.managed_endpoint.origin_port": input.origin.localHttpPort,
988 });
989 if (!isLoopbackOrigin(input.origin)) {
990 return yield* new ManagedEndpointOriginNotAllowed({
991 userId: input.userId,
992 environmentId: input.environmentId,
993 host: input.origin.localHttpHost,
994 port: input.origin.localHttpPort,
995 });
996 }
997 const cf = yield* requireCloudflareSettings(config, input);
998 const environmentHash = yield* crypto
999 .digest(
1000 "SHA-256",
1001 new TextEncoder().encode(
1002 managedEndpointDigestInput(cf.namespace, input.userId, input.environmentId),
1003 ),
1004 )
1005 .pipe(
1006 Effect.map(Hex.encode),
1007 Effect.mapError(
1008 (cause) =>
1009 new ManagedEndpointProvisioningFailed({
1010 userId: input.userId,
1011 environmentId: input.environmentId,
1012 stage: "derive-environment-hash",
1013 cause,
1014 }),
1015 ),
1016 );
1017 const requestedHostname = managedEndpointHostname(
1018 cf.namespace,
1019 cf.baseDomain,
1020 environmentHash,
1021 );
1022 const requestedTunnelName = managedEndpointTunnelName(cf.namespace, environmentHash);
1023 yield* tunnelLimits
1024 .ensureCapacity({
1025 userId: input.userId,
1026 environmentId: input.environmentId,
1027 })
1028 .pipe(
1029 Effect.catchTags({
1030 ManagedTunnelLimitPersistenceError: (cause) =>
1031 Effect.fail(
1032 new ManagedEndpointProvisioningFailed({
1033 userId: input.userId,
1034 environmentId: input.environmentId,
1035 stage: "check-tunnel-limit",
1036 hostname: requestedHostname,
1037 tunnelName: requestedTunnelName,
1038 cause,
1039 }),
1040 ),
1041 }),
1042 );
1043 const allocation = yield* allocations
1044 .reserve({
1045 userId: input.userId,
1046 environmentId: input.environmentId,
1047 hostname: requestedHostname,
1048 tunnelName: requestedTunnelName,
1049 })
1050 .pipe(
1051 Effect.mapError(
1052 (cause) =>
1053 new ManagedEndpointProvisioningFailed({
1054 userId: input.userId,
1055 environmentId: input.environmentId,
1056 stage: "reserve-allocation",
1057 hostname: requestedHostname,
1058 tunnelName: requestedTunnelName,
1059 cause,
1060 }),
1061 ),
1062 );
1063 const { hostname, tunnelName } = allocation;
1064
1065 const tunnelResponse = yield* tunnels.list({ name: tunnelName, isDeleted: false }).pipe(
1066 Effect.map((tunnels) => tunnels.result),
1067 Effect.map(Arr.findFirst((tunnel) => tunnel.name === tunnelName)),
1068 Effect.flatMap(
1069 Option.match({
1070 onSome: (tunnel) => Effect.succeed(tunnel),
1071 onNone: () => tunnels.create({ name: tunnelName, configSrc: "cloudflare" }),
1072 }),
1073 ),
1074 Effect.mapError(
1075 (cause) =>
1076 new ManagedEndpointProvisioningFailed({
1077 userId: input.userId,
1078 environmentId: input.environmentId,
1079 stage: "ensure-tunnel",
1080 hostname,
1081 tunnelName,
1082 cause,
1083 }),
1084 ),
1085 );
1086 if (!tunnelResponse.id || tunnelResponse.name !== tunnelName) {
1087 return yield* new ManagedEndpointProvisioningFailed({
1088 userId: input.userId,
1089 environmentId: input.environmentId,
1090 stage: "validate-tunnel-response",
1091 reason: "invalid-tunnel-response",
1092 hostname,
1093 tunnelName,
1094 ...(tunnelResponse.id ? { returnedTunnelId: tunnelResponse.id } : {}),
1095 ...(tunnelResponse.name ? { returnedTunnelName: tunnelResponse.name } : {}),
1096 });
1097 }
1098 const tunnel = { id: tunnelResponse.id, name: tunnelResponse.name };
1099 const tunnelGeneration = yield* allocations
1100 .recordTunnel({
1101 userId: input.userId,
1102 environmentId: input.environmentId,
1103 tunnelId: tunnel.id,
1104 generation: allocation.generation,
1105 })
1106 .pipe(
1107 Effect.mapError(
1108 (cause) =>
1109 new ManagedEndpointProvisioningFailed({
1110 userId: input.userId,
1111 environmentId: input.environmentId,
1112 stage: "record-tunnel",
1113 hostname,
1114 tunnelName,
1115 tunnelId: tunnel.id,
1116 cause,
1117 }),
1118 ),
1119 );
1120 if (tunnelGeneration === null) {
1121 // A newer provision can adopt this tunnel by name at any point after
1122 // our claim fails. Leave it available for that provision or a retry.
1123 return yield* new ManagedEndpointProvisioningFailed({
1124 userId: input.userId,
1125 environmentId: input.environmentId,
1126 stage: "record-tunnel",
1127 reason: "claim-lost",
1128 hostname,
1129 tunnelName,
1130 tunnelId: tunnel.id,
1131 });
1132 }
1133
1134 const configured = yield* allocations
1135 .withClaimedTunnel(
1136 {
1137 userId: input.userId,
1138 environmentId: input.environmentId,
1139 tunnelId: tunnel.id,
1140 generation: tunnelGeneration,
1141 },
1142 tunnels
1143 .putConfiguration(tunnel.id, {
1144 ingress: [
1145 {
1146 hostname,
1147 service: formatOriginService(input.origin),
1148 },
1149 { service: "http_status:404" },
1150 ],
1151 })
1152 .pipe(
1153 Effect.mapError(
1154 (cause) =>
1155 new ManagedEndpointProvisioningFailed({
1156 userId: input.userId,
1157 environmentId: input.environmentId,
1158 stage: "configure-tunnel",
1159 hostname,
1160 tunnelName,
1161 tunnelId: tunnel.id,
1162 cause,
1163 }),
1164 ),
1165 ),
1166 )
1167 .pipe(
1168 Effect.catchTags({
1169 ManagedEndpointAllocationPersistenceError: (cause) =>
1170 Effect.fail(
1171 new ManagedEndpointProvisioningFailed({
1172 userId: input.userId,
1173 environmentId: input.environmentId,
1174 stage: "configure-tunnel",
1175 hostname,
1176 tunnelName,
1177 tunnelId: tunnel.id,
1178 cause,
1179 }),
1180 ),
1181 }),
1182 );
1183 if (Option.isNone(configured)) {
1184 return yield* new ManagedEndpointProvisioningFailed({
1185 userId: input.userId,
1186 environmentId: input.environmentId,
1187 stage: "configure-tunnel",
1188 reason: "claim-lost",
1189 hostname,
1190 tunnelName,
1191 tunnelId: tunnel.id,
1192 });
1193 }
1194
1195 const dnsRecord = {
1196 type: "CNAME",
1197 name: hostname,
1198 content: `${tunnel.id}.cfargotunnel.com`,
1199 ttl: 1,
1200 proxied: true,
1201 } as const;
1202
1203 const recordedDns = yield* allocations
1204 .withClaimedTunnel(
1205 {
1206 userId: input.userId,
1207 environmentId: input.environmentId,
1208 tunnelId: tunnel.id,
1209 generation: tunnelGeneration,
1210 },
1211 Effect.gen(function* () {
1212 const dnsRecordId = yield* ensureDnsRecord(
1213 hostname,
1214 allocation.dnsRecordId,
1215 dnsRecord,
1216 ).pipe(
1217 Effect.mapError(
1218 (cause) =>
1219 new ManagedEndpointProvisioningFailed({
1220 userId: input.userId,
1221 environmentId: input.environmentId,
1222 stage: "ensure-dns-record",
1223 hostname,
1224 tunnelName,
1225 tunnelId: tunnel.id,
1226 ...(allocation.dnsRecordId === null
1227 ? {}
1228 : { dnsRecordId: allocation.dnsRecordId }),
1229 cause,
1230 }),
1231 ),
1232 );
1233 const dnsGeneration = yield* allocations
1234 .recordDns({
1235 userId: input.userId,
1236 environmentId: input.environmentId,
1237 dnsRecordId,
1238 tunnelId: tunnel.id,
1239 generation: tunnelGeneration,
1240 })
1241 .pipe(
1242 Effect.mapError(
1243 (cause) =>
1244 new ManagedEndpointProvisioningFailed({
1245 userId: input.userId,
1246 environmentId: input.environmentId,
1247 stage: "record-dns",
1248 hostname,
1249 tunnelName,
1250 tunnelId: tunnel.id,
1251 dnsRecordId,
1252 cause,
1253 }),
1254 ),
1255 );
1256 if (dnsGeneration === null) {
1257 return yield* new ManagedEndpointProvisioningFailed({
1258 userId: input.userId,
1259 environmentId: input.environmentId,
1260 stage: "record-dns",
1261 reason: "claim-lost",
1262 hostname,
1263 tunnelName,
1264 tunnelId: tunnel.id,
1265 dnsRecordId,
1266 });
1267 }
1268 return { dnsRecordId, dnsGeneration };
1269 }),
1270 )
1271 .pipe(
1272 Effect.catchTags({
1273 ManagedEndpointAllocationPersistenceError: (cause) =>
1274 Effect.fail(
1275 new ManagedEndpointProvisioningFailed({
1276 userId: input.userId,
1277 environmentId: input.environmentId,
1278 stage: "record-dns",
1279 hostname,
1280 tunnelName,
1281 tunnelId: tunnel.id,
1282 cause,
1283 }),
1284 ),
1285 }),
1286 );
1287 if (Option.isNone(recordedDns)) {
1288 return yield* new ManagedEndpointProvisioningFailed({
1289 userId: input.userId,
1290 environmentId: input.environmentId,
1291 stage: "record-dns",
1292 reason: "claim-lost",
1293 hostname,
1294 tunnelName,
1295 tunnelId: tunnel.id,
1296 });
1297 }
1298 const { dnsRecordId, dnsGeneration } = recordedDns.value;
1299
1300 const connectorToken = yield* tunnels.getToken(tunnel.id).pipe(
1301 Effect.mapError(
1302 (cause) =>
1303 new ManagedEndpointProvisioningFailed({
1304 userId: input.userId,
1305 environmentId: input.environmentId,
1306 stage: "get-tunnel-token",
1307 hostname,
1308 tunnelName,
1309 tunnelId: tunnel.id,
1310 dnsRecordId,
1311 cause,
1312 }),
1313 ),
1314 );
1315 const ready = yield* allocations
1316 .markReady({
1317 userId: input.userId,
1318 environmentId: input.environmentId,
1319 tunnelId: tunnel.id,
1320 generation: dnsGeneration,
1321 origin: input.origin,
1322 })
1323 .pipe(
1324 Effect.mapError(
1325 (cause) =>
1326 new ManagedEndpointProvisioningFailed({
1327 userId: input.userId,
1328 environmentId: input.environmentId,
1329 stage: "mark-allocation-ready",
1330 hostname,
1331 tunnelName,
1332 tunnelId: tunnel.id,
1333 dnsRecordId,
1334 cause,
1335 }),
1336 ),
1337 );
1338 if (!ready) {
1339 return yield* new ManagedEndpointProvisioningFailed({
1340 userId: input.userId,
1341 environmentId: input.environmentId,
1342 stage: "mark-allocation-ready",
1343 reason: "claim-lost",
1344 hostname,
1345 tunnelName,
1346 tunnelId: tunnel.id,
1347 dnsRecordId,
1348 });
1349 }
1350
1351 return {
1352 endpoint: managedEndpointForHostname(hostname),
1353 runtime: {
1354 providerKind: "cloudflare_tunnel",
1355 connectorToken,
1356 tunnelId: tunnel.id,
1357 tunnelName: tunnel.name,
1358 },
1359 } satisfies ManagedEndpointProvisioningResult;
1360 }),
1361 });
1362});
1363
1364export const layer = Layer.effect(ManagedEndpointProvider, make);
1365
1366export const layerCloudflareBindings = (
1367 tunnelClient: Cloudflare.Tunnel.ReadWriteTunnelClient,
1368 dnsClient: Cloudflare.DNS.ReadWriteDnsClient,
1369 alchemyRuntimeContext: Alchemy.BaseRuntimeContext,
1370) =>
1371 layer.pipe(
1372 Layer.provideMerge(
1373 Layer.mergeAll(
1374 layerTunnelClient({
1375 get: (tunnelId) =>
1376 tunnelClient.get(tunnelId).pipe(
1377 Effect.timeout("8 seconds"),
1378 Effect.mapError(
1379 (cause) =>
1380 new ManagedEndpointTunnelClientError({
1381 operation: "get",
1382 tunnelId,
1383 cause,
1384 }),
1385 ),
1386 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
1387 ),
1388 list: (request) =>
1389 tunnelClient.list(request).pipe(
1390 Effect.timeout("8 seconds"),
1391 Effect.mapError(
1392 (cause) =>
1393 new ManagedEndpointTunnelClientError({
1394 operation: "list",
1395 ...(request.name === undefined ? {} : { tunnelName: request.name }),
1396 cause,
1397 }),
1398 ),
1399 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
1400 ),
1401 create: (request) =>
1402 tunnelClient.create(request).pipe(
1403 Effect.timeout("8 seconds"),
1404 Effect.mapError(
1405 (cause) =>
1406 new ManagedEndpointTunnelClientError({
1407 operation: "create",
1408 tunnelName: request.name,
1409 cause,
1410 }),
1411 ),
1412 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
1413 ),
1414 putConfiguration: (tunnelId, config) =>
1415 tunnelClient.putConfiguration(tunnelId, config).pipe(
1416 Effect.timeout("8 seconds"),
1417 Effect.mapError(
1418 (cause) =>
1419 new ManagedEndpointTunnelClientError({
1420 operation: "put-configuration",
1421 tunnelId,
1422 cause,
1423 }),
1424 ),
1425 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
1426 ),
1427 getToken: (tunnelId) =>
1428 tunnelClient.getToken(tunnelId).pipe(
1429 Effect.timeout("8 seconds"),
1430 Effect.mapError(
1431 (cause) =>
1432 new ManagedEndpointTunnelClientError({
1433 operation: "get-token",
1434 tunnelId,
1435 cause,
1436 }),
1437 ),
1438 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
1439 ),
1440 delete: (tunnelId) =>
1441 tunnelClient.delete(tunnelId).pipe(
1442 Effect.timeout("8 seconds"),
1443 Effect.mapError(
1444 (cause) =>
1445 new ManagedEndpointTunnelClientError({
1446 operation: "delete",
1447 tunnelId,
1448 cause,
1449 }),
1450 ),
1451 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
1452 ),
1453 }),
1454 layerDnsClient({
1455 listRecords: (hostname) =>
1456 dnsClient.listDnsRecords({ search: hostname }).pipe(
1457 Effect.timeout("8 seconds"),
1458 Effect.map((response) =>
1459 response.result.filter(
1460 (record): record is typeof record & { readonly id: string } =>
1461 typeof record.id === "string" &&
1462 normalizeHostname(record.name) === normalizeHostname(hostname),
1463 ),
1464 ),
1465 Effect.mapError(
1466 (cause) =>
1467 new ManagedEndpointDnsClientError({
1468 operation: "list-records",
1469 hostname,
1470 cause,
1471 }),
1472 ),
1473 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
1474 ),
1475 createRecord: (request) =>
1476 dnsClient.createDnsRecord(request).pipe(
1477 Effect.timeout("8 seconds"),
1478 Effect.map((response) => ({ id: response.id })),
1479 Effect.mapError(
1480 (cause) =>
1481 new ManagedEndpointDnsClientError({
1482 operation: "create-record",
1483 hostname: request.name,
1484 cause,
1485 }),
1486 ),
1487 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
1488 ),
1489 updateRecord: (dnsRecordId, request) =>
1490 dnsClient.updateDnsRecord(dnsRecordId, request).pipe(
1491 Effect.timeout("8 seconds"),
1492 Effect.mapError(
1493 (cause) =>
1494 new ManagedEndpointDnsClientError({
1495 operation: "update-record",
1496 hostname: request.name,
1497 dnsRecordId,
1498 cause,
1499 }),
1500 ),
1501 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
1502 ),
1503 deleteRecord: (dnsRecordId) =>
1504 dnsClient.deleteDnsRecord(dnsRecordId).pipe(
1505 Effect.timeout("8 seconds"),
1506 Effect.mapError(
1507 (cause) =>
1508 new ManagedEndpointDnsClientError({
1509 operation: "delete-record",
1510 dnsRecordId,
1511 cause,
1512 }),
1513 ),
1514 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
1515 ),
1516 }),
1517 ),
1518 ),
1519 );