1import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5
6// "revoked" leads to no action: relinking a deregistered environment is the
7// user's call, so startup neither retries nor relinks it.
8export type ManagedTunnelRegistrationResult =
9 | { readonly status: "not_linked" | "ready" | "unavailable" | "superseded" | "revoked" }
10 | {
11 readonly status: "recovery_required";
12 readonly config: RelayManagedEndpointRuntimeConfig;
13 };
14
15export type ManagedTunnelStartupAction =
16 | { readonly action: "none" }
17 | { readonly action: "reconcile_link" }
18 | {
19 readonly action: "request_recovery";
20 readonly config: RelayManagedEndpointRuntimeConfig;
21 };
22
23export function managedTunnelStartupAction(input: {
24 readonly wantsCliLink: boolean;
25 readonly registration: ManagedTunnelRegistrationResult;
26}): ManagedTunnelStartupAction {
27 if (input.registration.status === "recovery_required") {
28 return {
29 action: "request_recovery",
30 config: input.registration.config,
31 };
32 }
33 if (input.wantsCliLink && input.registration.status === "not_linked") {
34 return { action: "reconcile_link" };
35 }
36 return { action: "none" };
37}
38
39// After this window the host can start its stored connector config while
40// registration keeps retrying to reconcile the origin when the relay returns.
41const MANAGED_TUNNEL_REGISTRATION_RETRY_WINDOW = Duration.minutes(10);
42
43export const retryManagedTunnelRegistration = <A, E, R>(
44 registration: Effect.Effect<A, E, R>,
45 isRetryable: (error: E) => boolean,
46 onRetryWindowExhausted?: Effect.Effect<void, never, R>,
47) => {
48 const schedule = Schedule.exponential("1 second").pipe(
49 Schedule.modifyDelay(({ duration }) =>
50 Effect.succeed(Duration.min(duration, Duration.seconds(30))),
51 ),
52 Schedule.jittered,
53 );
54 const withinWindow = registration.pipe(
55 Effect.retry({
56 while: isRetryable,
57 schedule: schedule.pipe(
58 Schedule.upTo({ duration: MANAGED_TUNNEL_REGISTRATION_RETRY_WINDOW }),
59 ),
60 }),
61 );
62 if (onRetryWindowExhausted === undefined) return withinWindow;
63 return withinWindow.pipe(
64 Effect.catchIf(isRetryable, () =>
65 onRetryWindowExhausted.pipe(
66 Effect.andThen(registration.pipe(Effect.retry({ while: isRetryable, schedule }))),
67 ),
68 ),
69 );
70};
71
72// A host asks the relay for a replacement tunnel at most this often. Every
73// managed host shares one relay, so a host stuck in a bad loop must not turn
74// into a fleet-wide request storm.
75export const MANAGED_TUNNEL_RECOVERY_COOLDOWN = Duration.minutes(2);
76
77// Existing hosts register on their first boot after an upgrade, and desktop
78// auto-update delivers that boot to many hosts at once. Spread the first
79// registration so the relay and Cloudflare see a ramp instead of a spike.
80export const MANAGED_TUNNEL_FIRST_REGISTRATION_JITTER = Duration.seconds(30);