authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-04 18:11:18-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 18:11:18-07:00
log979ca66cedf10d685c7486da1929297c3693fe43
treeea3cb12b24446f5867e1b2997ed2e8febad752d8
parentfe417dff4cdc149d6873bb4fbaaa388731628d0b
signature Signed by PGP key B5690EEEBB952194

feat(clients): reach one environment over several routes (#15467)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

48 files changed, 2962 insertions(+), 433 deletions(-)

apps/mobile/src/connection/onboarding.ts+13-3
......@@ -13,10 +13,20 @@ const onboardingScheduler = createAtomCommandScheduler();
1313export const connectPairingUrl = createRuntimeCommand(connectionAtomRuntime, {
1414 label: "mobile:connection:connect-pairing-url",
1515 scheduler: onboardingScheduler,
16 concurrency: { mode: "singleFlight", key: (pairingUrl: string) => pairingUrl },
17 execute: (pairingUrl: string) =>
16 concurrency: {
17 mode: "singleFlight",
18 // Adding a route to a different machine with the same link is its own
19 // operation: it must check its own expected machine.
20 key: (input: { readonly pairingUrl: string; readonly expectedEnvironmentId?: EnvironmentId }) =>
21 JSON.stringify([input.pairingUrl, input.expectedEnvironmentId ?? null]),
22 },
23 execute: (input: {
24 readonly pairingUrl: string;
25 /** Set when adding a route to this saved machine. */
26 readonly expectedEnvironmentId?: EnvironmentId;
27 }) =>
1828 ConnectionOnboarding.ConnectionOnboarding.pipe(
19 Effect.flatMap((onboarding) => onboarding.registerPairing({ pairingUrl })),
29 Effect.flatMap((onboarding) => onboarding.registerPairing(input)),
2030 ),
2131});
2232
apps/mobile/src/connection/platform.ts+68-22
......@@ -82,29 +82,75 @@ const connectivityLayer = Connectivity.layer({
8282 ),
8383});
8484
85/**
86 * Wakes connections when the device moves between networks while staying
87 * online, such as Wi-Fi to cellular. Connectivity only reports online or
88 * offline, so leaving home on cellular would otherwise go unnoticed until the
89 * LAN socket times out.
90 */
91const networkPathChanges = Stream.callback<"network-changed">((queue) =>
92 Effect.acquireRelease(
93 Effect.sync(() => {
94 let active = true;
95 let previous: Network.NetworkStateType | undefined;
96 const record = (state: Network.NetworkState) => {
97 const type = state.isConnected === true ? state.type : undefined;
98 if (previous !== undefined && type !== undefined && type !== previous) {
99 Queue.offerUnsafe(queue, "network-changed");
100 }
101 previous = type ?? previous;
102 };
103 // The listener reports changes only, so seed the current type; without
104 // it the first Wi-Fi to cellular move would go unnoticed.
105 void Network.getNetworkStateAsync()
106 .then((state) => {
107 if (active && previous === undefined && state.isConnected === true) {
108 previous = state.type;
109 }
110 })
111 .catch(() => undefined);
112 const subscription = Network.addNetworkStateListener(record);
113 return {
114 remove: () => {
115 active = false;
116 subscription.remove();
117 },
118 };
119 }),
120 (subscription) => Effect.sync(() => subscription.remove()),
121 ).pipe(Effect.asVoid),
122);
123
85124const wakeupsLayer = Wakeups.layer({
86 changes: Stream.merge(
87 Stream.callback<"application-active-probe" | "application-active-reconnect">((queue) =>
88 Effect.acquireRelease(
89 Effect.sync(() => {
90 let backgroundedAtMs = AppState.currentState === "background" ? Date.now() : null;
91 return AppState.addEventListener("change", (state) => {
92 if (state === "background") {
93 backgroundedAtMs = Date.now();
94 return;
95 }
96 if (state === "active") {
97 Queue.offerUnsafe(queue, mobileApplicationActiveWakeup(backgroundedAtMs, Date.now()));
98 backgroundedAtMs = null;
99 }
100 });
101 }),
102 (subscription) => Effect.sync(() => subscription.remove()),
103 ).pipe(Effect.asVoid),
104 ),
105 managedRelayAccountChanges(appAtomRegistry).pipe(
106 Stream.map(() => "credentials-changed" as const),
107 ),
125 changes: Stream.mergeAll(
126 [
127 Stream.callback<"application-active-probe" | "application-active-reconnect">((queue) =>
128 Effect.acquireRelease(
129 Effect.sync(() => {
130 let backgroundedAtMs = AppState.currentState === "background" ? Date.now() : null;
131 return AppState.addEventListener("change", (state) => {
132 if (state === "background") {
133 backgroundedAtMs = Date.now();
134 return;
135 }
136 if (state === "active") {
137 Queue.offerUnsafe(
138 queue,
139 mobileApplicationActiveWakeup(backgroundedAtMs, Date.now()),
140 );
141 backgroundedAtMs = null;
142 }
143 });
144 }),
145 (subscription) => Effect.sync(() => subscription.remove()),
146 ).pipe(Effect.asVoid),
147 ),
148 managedRelayAccountChanges(appAtomRegistry).pipe(
149 Stream.map(() => "credentials-changed" as const),
150 ),
151 networkPathChanges,
152 ],
153 { concurrency: "unbounded" },
108154 ),
109155});
110156
apps/mobile/src/connection/storage.ts+10-4
......@@ -3,6 +3,7 @@ import {
33 registerConnectionInCatalog,
44 removeConnectionFromCatalog,
55 setConnectionEnabledInCatalog,
6 setRoutesInCatalog,
67 removeCatalogValue,
78 replaceCatalogValue,
89 Persistence,
......@@ -27,6 +28,7 @@ function targetPersistenceError(
2728 | "list-targets"
2829 | "list-disabled-targets"
2930 | "register-connection"
31 | "set-connection-routes"
3032 | "remove-connection"
3133 | "set-connection-enabled",
3234 error: ConnectionTransientError,
......@@ -57,13 +59,17 @@ export const connectionStorageLayer = Layer.effectContext(
5759 ),
5860 });
5961 const registrationStore = Persistence.ConnectionRegistrationStore.of({
60 register: (registration) =>
62 register: (registration, routes) =>
6163 catalog
62 .update((document) => registerConnectionInCatalog(document, registration))
64 .update((document) => registerConnectionInCatalog(document, registration, routes))
6365 .pipe(Effect.mapError((error) => targetPersistenceError("register-connection", error))),
64 remove: (target) =>
66 setRoutes: (environmentId, routes) =>
6567 catalog
66 .update((document) => removeConnectionFromCatalog(document, target))
68 .update((document) => setRoutesInCatalog(document, environmentId, routes))
69 .pipe(Effect.mapError((error) => targetPersistenceError("set-connection-routes", error))),
70 remove: (environmentId) =>
71 catalog
72 .update((document) => removeConnectionFromCatalog(document, environmentId))
6773 .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))),
6874 setEnabled: (environmentId, enabled) =>
6975 catalog
apps/mobile/src/features/cloud/cloud-drafts.ts+5-2
......@@ -1,4 +1,4 @@
1import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
1import { EnvironmentRegistry, connectionRoutes } from "@t3tools/client-runtime/connection";
22import { createRuntimeCommand } from "@t3tools/client-runtime/state/runtime";
33import * as Effect from "effect/Effect";
44import * as Schema from "effect/Schema";
......@@ -27,7 +27,10 @@ export const removeCloudEnvironments = createRuntimeCommand(connectionAtomRuntim
2727 const entries = yield* SubscriptionRef.get(registry.entries);
2828 const environmentIds = new Set(
2929 [...entries.values()]
30 .filter((entry) => entry.target._tag === "RelayConnectionTarget")
30 // Only environments that sign-out removes entirely lose their drafts.
31 .filter((entry) =>
32 connectionRoutes(entry).every((route) => route.target._tag === "RelayConnectionTarget"),
33 )
3134 .map((entry) => entry.target.environmentId),
3235 );
3336 // Credentials are already revoked. A failed backup must leave the local
apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx+5-2
......@@ -6,6 +6,7 @@ import {
66 useRoute,
77 type StaticScreenProps,
88} from "@react-navigation/native";
9import type { EnvironmentId } from "@t3tools/contracts";
910import { AsyncResult } from "effect/unstable/reactivity";
1011import { useCallback, useEffect, useRef, useState } from "react";
1112import { Alert, Linking, Platform, View } from "react-native";
......@@ -23,6 +24,8 @@ type ConnectionsNewRouteParams = {
2324 readonly mode?: string;
2425 readonly pairingUrl?: string;
2526 readonly autoConnect?: string;
27 /** Adds a route to this saved machine instead of a new environment. */
28 readonly routeFor?: EnvironmentId;
2629};
2730
2831export function ConnectionsNewRouteScreen({
......@@ -158,7 +161,7 @@ export function ConnectionsNewRouteScreen({
158161 setIsSubmitting(true);
159162 onChangeConnectionPairingUrl(pairingUrl);
160163 try {
161 const result = await onConnectPress(pairingUrl);
164 const result = await onConnectPress(pairingUrl, params.routeFor);
162165 if (AsyncResult.isSuccess(result)) {
163166 if (replaceWithHome || !navigation.canGoBack()) {
164167 navigation.dispatch(StackActions.replace("Home"));
......@@ -170,7 +173,7 @@ export function ConnectionsNewRouteScreen({
170173 setIsSubmitting(false);
171174 }
172175 },
173 [navigation, onChangeConnectionPairingUrl, onConnectPress],
176 [navigation, onChangeConnectionPairingUrl, onConnectPress, params.routeFor],
174177 );
175178
176179 const handleSubmit = useCallback(async () => {
apps/mobile/src/features/connection/environmentSections.test.ts+17
......@@ -157,4 +157,21 @@ describe("mobile environment settings sections", () => {
157157 expect(sections.connectedCloudEnvironments).toEqual([cloud]);
158158 expect(sections.availableCloudEnvironments).toEqual([]);
159159 });
160
161 it("offers T3 Connect for a machine saved only over the LAN, as an added route", () => {
162 const local = connectedEnvironment({
163 environmentId: "environment-desk",
164 isRelayManaged: false,
165 });
166
167 const sections = splitEnvironmentSections({
168 connectedEnvironments: [local],
169 cloudEnvironments: [cloudEnvironment("environment-desk")],
170 });
171
172 expect(sections.localEnvironments).toEqual([local]);
173 expect(
174 sections.availableCloudEnvironments.map((environment) => environment.environmentId),
175 ).toEqual([EnvironmentId.make("environment-desk")]);
176 });
160177});
apps/mobile/src/features/connection/useConnectionController.ts+5-1
......@@ -69,7 +69,11 @@ export function useConnectionController() {
6969 );
7070
7171 const connectPairingUrl = useCallback(
72 (pairingUrl: string) => connectPairingUrlMutation(pairingUrl),
72 (pairingUrl: string, expectedEnvironmentId?: EnvironmentId) =>
73 connectPairingUrlMutation({
74 pairingUrl,
75 ...(expectedEnvironmentId === undefined ? {} : { expectedEnvironmentId }),
76 }),
7377 [connectPairingUrlMutation],
7478 );
7579 const connectRelayEnvironment = useCallback(
apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx created+305
......@@ -0,0 +1,305 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 type ConnectionRoute,
4 connectionRouteAddress,
5 connectionRouteId,
6 connectionRouteLabel,
7 connectionRoutes,
8} from "@t3tools/client-runtime/connection";
9import type { EnvironmentId } from "@t3tools/contracts";
10import * as Option from "effect/Option";
11import { useEffect, useMemo, useRef, useState } from "react";
12import { Alert, Pressable, View } from "react-native";
13import { Gesture, GestureDetector } from "react-native-gesture-handler";
14import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated";
15
16import { SymbolView } from "../../components/AppSymbol";
17import { AppText as Text } from "../../components/AppText";
18import { environmentCatalog } from "../../connection/catalog";
19import { environmentSession } from "../../state/session";
20import { useAtomCommand } from "../../state/use-atom-command";
21import { SettingsSection } from "./components/SettingsSection";
22
23const ROW_HEIGHT = 64;
24
25/**
26 * The ways this device reaches an environment, preferred first. The first
27 * route that answers is used, and the connection moves back up the list when
28 * a better route is reachable again. Edit shows drag handles and remove
29 * buttons; Add route pairs this machine over another address.
30 */
31export function EnvironmentRoutesSection({
32 environmentId,
33 connected,
34 onAddRoute,
35}: {
36 readonly environmentId: EnvironmentId;
37 readonly connected: boolean;
38 readonly onAddRoute: () => void;
39}) {
40 const entry = useAtomValue(environmentCatalog.catalogValueAtom).entries.get(environmentId);
41 const prepared = useAtomValue(environmentSession.preparedConnectionValueAtom(environmentId));
42 const reorder = useAtomCommand(environmentCatalog.reorderRoutes, "route reorder");
43 const removeRoute = useAtomCommand(environmentCatalog.removeRoute, "route removal");
44 const [editing, setEditing] = useState(false);
45 const saved = entry === undefined ? [] : connectionRoutes(entry);
46 const savedIds = saved.map((route) => connectionRouteId(route.target));
47 // A dropped order shows until the catalog matches it.
48 const [pending, setPending] = useState<ReadonlyArray<string> | null>(null);
49 const order =
50 pending !== null &&
51 pending.length === savedIds.length &&
52 pending.some((id, index) => id !== savedIds[index])
53 ? pending
54 : savedIds;
55 const [drag, setDrag] = useState<{ readonly id: string; readonly translation: number } | null>(
56 null,
57 );
58 if (entry === undefined) return null;
59
60 const byId = new Map(saved.map((route) => [connectionRouteId(route.target), route]));
61 const routes = order.flatMap((id) => byId.get(id) ?? []);
62 const activeRouteId =
63 connected && Option.isSome(prepared) ? connectionRouteId(prepared.value.target) : null;
64
65 const commit = (next: ReadonlyArray<string>) => {
66 setPending(next);
67 void reorder({ environmentId, routeIds: next }).then((result) => {
68 if (result._tag === "Failure") setPending(null);
69 });
70 };
71 const move = (from: number, to: number) => {
72 if (to < 0 || to >= order.length || from === to) return;
73 const next = [...order];
74 const [moved] = next.splice(from, 1);
75 next.splice(to, 0, moved!);
76 commit(next);
77 };
78 const dropIndex = (from: number, translation: number) =>
79 Math.max(0, Math.min(order.length - 1, from + Math.round(translation / ROW_HEIGHT)));
80 const confirmRemove = (route: ConnectionRoute) =>
81 Alert.alert(
82 `Remove ${connectionRouteLabel(route)}?`,
83 connectionRouteAddress(route) ?? undefined,
84 [
85 { text: "Cancel", style: "cancel" },
86 {
87 text: "Remove",
88 style: "destructive",
89 onPress: () =>
90 void removeRoute({ environmentId, routeId: connectionRouteId(route.target) }),
91 },
92 ],
93 );
94
95 const dragFrom = drag === null ? -1 : order.indexOf(drag.id);
96 const dragTo = drag === null ? -1 : dropIndex(dragFrom, drag.translation);
97
98 return (
99 <SettingsSection
100 title="Routes"
101 trailing={
102 routes.length > 1 ? (
103 <Pressable
104 accessibilityRole="button"
105 onPress={() => setEditing((value) => !value)}
106 className="px-2 py-1 active:opacity-70"
107 >
108 <Text className="text-sm font-t3-medium text-primary-text">
109 {editing ? "Done" : "Edit"}
110 </Text>
111 </Pressable>
112 ) : undefined
113 }
114 >
115 {routes.map((route, index) => {
116 const id = connectionRouteId(route.target);
117 // Rows between the lifted row and its drop slot shift to make room.
118 const shift =
119 dragFrom === -1 || index === dragFrom
120 ? 0
121 : dragFrom < dragTo && index > dragFrom && index <= dragTo
122 ? -ROW_HEIGHT
123 : dragFrom > dragTo && index < dragFrom && index >= dragTo
124 ? ROW_HEIGHT
125 : 0;
126 return (
127 <RouteRow
128 key={id}
129 route={route}
130 position={index + 1}
131 count={routes.length}
132 inUse={id === activeRouteId}
133 editing={editing}
134 offset={index === dragFrom ? (drag?.translation ?? 0) : shift}
135 lifted={index === dragFrom}
136 onDragStart={() => setDrag({ id, translation: 0 })}
137 onDragMove={(translation) => setDrag({ id, translation })}
138 onDragEnd={(translation, cancelled) => {
139 setDrag(null);
140 if (!cancelled) move(index, dropIndex(index, translation));
141 }}
142 onStep={(direction) => move(index, direction === "up" ? index - 1 : index + 1)}
143 // The last route goes with the machine, which is "Remove" on the row.
144 onRemove={routes.length > 1 ? () => confirmRemove(route) : undefined}
145 />
146 );
147 })}
148 <Pressable
149 accessibilityRole="button"
150 onPress={onAddRoute}
151 className="flex-row items-center gap-3 px-4 py-3.5 active:opacity-70"
152 >
153 <SymbolView name="plus" size={16} tintColorClassName="accent-icon" type="monochrome" />
154 <Text className="text-base text-primary-text">Add route</Text>
155 </Pressable>
156 </SettingsSection>
157 );
158}
159
160function RouteRow(props: {
161 readonly route: ConnectionRoute;
162 readonly position: number;
163 readonly count: number;
164 readonly inUse: boolean;
165 readonly editing: boolean;
166 readonly offset: number;
167 readonly lifted: boolean;
168 readonly onDragStart: () => void;
169 readonly onDragMove: (translation: number) => void;
170 readonly onDragEnd: (translation: number, cancelled: boolean) => void;
171 readonly onStep: (direction: "up" | "down") => void;
172 readonly onRemove: (() => void) | undefined;
173}) {
174 const { route, lifted, offset } = props;
175 const label = connectionRouteLabel(route);
176 const address = connectionRouteAddress(route);
177 const style = useAnimatedStyle(() => ({
178 transform: [
179 {
180 translateY: lifted
181 ? offset
182 : withTiming(offset, { duration: 160, reduceMotion: ReduceMotion.System }),
183 },
184 ],
185 zIndex: lifted ? 1 : 0,
186 }));
187 return (
188 <Reanimated.View
189 style={[{ height: ROW_HEIGHT }, style]}
190 className={
191 lifted ? "flex-row items-center bg-grouped-card shadow-md" : "flex-row items-center"
192 }
193 >
194 {props.editing && props.onRemove ? (
195 <Pressable
196 accessibilityRole="button"
197 accessibilityLabel={`Remove ${label} route`}
198 onPress={props.onRemove}
199 className="h-full items-center justify-center pl-4 active:opacity-70"
200 >
201 <SymbolView
202 name="xmark.circle.fill"
203 size={20}
204 tintColorClassName="accent-danger-foreground"
205 type="monochrome"
206 />
207 </Pressable>
208 ) : null}
209 <View
210 accessible
211 accessibilityLabel={[
212 label,
213 address,
214 props.inUse ? "In use" : null,
215 `Route ${props.position} of ${props.count}`,
216 ]
217 .filter((part) => part !== null)
218 .join(", ")}
219 className="min-w-0 flex-1 gap-0.5 px-4"
220 >
221 <View className="flex-row items-center gap-2">
222 <Text className="text-base text-foreground">{label}</Text>
223 {props.inUse ? (
224 <Text className="text-xs font-t3-medium text-success-foreground">In use</Text>
225 ) : null}
226 </View>
227 {address !== null ? (
228 <Text numberOfLines={1} className="text-sm text-foreground-muted">
229 {address}
230 </Text>
231 ) : null}
232 </View>
233 {props.editing ? (
234 <DragHandle
235 title={label}
236 canMoveUp={props.position > 1}
237 canMoveDown={props.position < props.count}
238 onStart={props.onDragStart}
239 onMove={props.onDragMove}
240 onEnd={props.onDragEnd}
241 onStep={props.onStep}
242 />
243 ) : null}
244 </Reanimated.View>
245 );
246}
247
248/** Pan recognition wins over the settings scroll view only inside the handle. */
249function DragHandle(props: {
250 readonly title: string;
251 readonly canMoveUp: boolean;
252 readonly canMoveDown: boolean;
253 readonly onStart: () => void;
254 readonly onMove: (translation: number) => void;
255 readonly onEnd: (translation: number, cancelled: boolean) => void;
256 readonly onStep: (direction: "up" | "down") => void;
257}) {
258 const latest = useRef(props);
259 useEffect(() => {
260 latest.current = props;
261 });
262 const translation = useRef(0);
263 const gesture = useMemo(
264 () =>
265 Gesture.Pan()
266 .minDistance(0)
267 .shouldCancelWhenOutside(false)
268 .runOnJS(true)
269 .onStart(() => {
270 translation.current = 0;
271 latest.current.onStart();
272 })
273 .onUpdate((event) => {
274 translation.current = event.translationY;
275 latest.current.onMove(event.translationY);
276 })
277 .onFinalize((_, success) => latest.current.onEnd(translation.current, !success)),
278 [],
279 );
280 return (
281 <GestureDetector gesture={gesture}>
282 <View
283 collapsable={false}
284 accessible
285 accessibilityRole="adjustable"
286 accessibilityLabel={`Reorder ${props.title}`}
287 accessibilityActions={[
288 ...(props.canMoveUp ? [{ name: "decrement", label: "Move up" }] : []),
289 ...(props.canMoveDown ? [{ name: "increment", label: "Move down" }] : []),
290 ]}
291 onAccessibilityAction={({ nativeEvent }) => {
292 if (nativeEvent.actionName === "decrement" && props.canMoveUp) props.onStep("up");
293 if (nativeEvent.actionName === "increment" && props.canMoveDown) props.onStep("down");
294 }}
295 style={{ width: 48, height: ROW_HEIGHT, alignItems: "center", justifyContent: "center" }}
296 >
297 <SymbolView
298 name="line.3.horizontal"
299 size={20}
300 tintColorClassName="accent-foreground-muted"
301 />
302 </View>
303 </GestureDetector>
304 );
305}
apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx+16-1
......@@ -1,5 +1,5 @@
11import { useAtomValue } from "@effect/atom-react";
2import type { StaticScreenProps } from "@react-navigation/native";
2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
33import type { EnvironmentId, ServerProvider } from "@t3tools/contracts";
44import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
55import { AsyncResult } from "effect/unstable/reactivity";
......@@ -15,6 +15,7 @@ import { environmentSession } from "../../state/session";
1515import { useAtomCommand } from "../../state/use-atom-command";
1616import { useRemoteConnections } from "../../state/use-remote-environment-registry";
1717import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
18import { EnvironmentRoutesSection } from "./EnvironmentRoutesSection";
1819import { SettingsActionRow } from "./components/SettingsActionRow";
1920import { SettingsScreen } from "./components/SettingsScreen";
2021import { SettingsSection } from "./components/SettingsSection";
......@@ -41,6 +42,7 @@ export function SettingsEnvironmentDetailRouteScreen({
4142
4243function EnvironmentDetail({ environmentId }: { readonly environmentId: EnvironmentId }) {
4344 const insets = useSafeAreaInsets();
45 const navigation = useNavigation();
4446 const connections = useRemoteConnections();
4547 const environment = connections.connectedEnvironments.find(
4648 (entry) => entry.environmentId === environmentId,
......@@ -167,6 +169,19 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm
167169 onUpdate={connections.onUpdateEnvironment}
168170 />
169171 </SettingsSection>
172 <EnvironmentRoutesSection
173 environmentId={environmentId}
174 connected={connected}
175 onAddRoute={() =>
176 navigation.navigate("SettingsSheet", {
177 screen: "SettingsContent",
178 params: {
179 screen: "SettingsEnvironmentNew",
180 params: { routeFor: environmentId },
181 },
182 })
183 }
184 />
170185 {!connected ? (
171186 <Text className="px-2 text-sm text-foreground-muted">
172187 Connect this environment to manage it.
apps/mobile/src/state/environments.ts+2-1
......@@ -1,6 +1,7 @@
11import { useAtomValue } from "@effect/atom-react";
22import {
33 connectionCatalogDisplayUrl,
4 hasRelayRoute,
45 type EnvironmentPresentation as BaseEnvironmentPresentation,
56} from "@t3tools/client-runtime/connection";
67import type { EnvironmentId } from "@t3tools/contracts";
......@@ -26,7 +27,7 @@ export function projectEnvironmentPresentation(
2627 environmentId,
2728 label: presentation.entry.target.label,
2829 displayUrl: connectionCatalogDisplayUrl(presentation.entry),
29 relayManaged: presentation.entry.target._tag === "RelayConnectionTarget",
30 relayManaged: hasRelayRoute(presentation.entry),
3031 };
3132}
3233
apps/mobile/src/state/remote-environment-projections.ts+4-1
......@@ -52,7 +52,10 @@ export function createRemoteEnvironmentProjectionAtoms(input: {
5252 : displayUrl.replace(/^http:/, "ws:")
5353 : new URL(socketUrl).origin;
5454 const authorization = prepared?.httpAuthorization ?? null;
55 const relayManaged = presentation.entry.target._tag === "RelayConnectionTarget";
55 // Credentials follow the route in use, which may not be the preferred one.
56 const relayManaged = prepared
57 ? prepared.target._tag === "RelayConnectionTarget"
58 : presentation.entry.target._tag === "RelayConnectionTarget";
5659
5760 previousEntry = presentation.entry;
5861 previousPrepared = prepared;
apps/mobile/src/state/use-remote-environment-registry.ts+2-2
......@@ -110,10 +110,10 @@ export function useRemoteConnections() {
110110 }, []);
111111
112112 const onConnectPress = useCallback(
113 async (pairingUrl?: string) => {
113 async (pairingUrl?: string, expectedEnvironmentId?: EnvironmentId) => {
114114 const nextPairingUrl = pairingUrl ?? connectionPairingUrl;
115115 setPendingConnectionError(null);
116 const result = await controller.connectPairingUrl(nextPairingUrl);
116 const result = await controller.connectPairingUrl(nextPairingUrl, expectedEnvironmentId);
117117 if (AsyncResult.isFailure(result)) {
118118 const error = Cause.squash(result.cause);
119119 const message =
apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx+1
......@@ -327,6 +327,7 @@ describe("cloud onboarding discovery", () => {
327327 {
328328 environmentId: newMachineId,
329329 connection: { phase: "connected", error: null, traceId: null },
330 relayManaged: true,
330331 },
331332 ]}
332333 showSavedEnvironments
apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx+35-10
......@@ -48,6 +48,8 @@ function discoveredCompatibilityError(
4848export interface SavedCloudEnvironmentConnection {
4949 readonly environmentId: EnvironmentId;
5050 readonly connection: EnvironmentConnectionPresentation;
51 /** False for a machine saved over another route (LAN, Tailscale, SSH) only. */
52 readonly relayManaged: boolean;
5153 /** Present once connected; carries the user's icon override. */
5254 readonly serverConfig?: ServerConfig | null;
5355}
......@@ -121,7 +123,15 @@ export function CloudEnvironmentConnectRows({
121123 ReadonlySet<EnvironmentId>
122124 >(new Set());
123125 const savedById = new Map(
124 savedEnvironments.map((environment) => [environment.environmentId, environment]),
126 savedEnvironments
127 .filter((environment) => environment.relayManaged)
128 .map((environment) => [environment.environmentId, environment]),
129 );
130 // Saved over another route only: T3 Connect would be an added fallback.
131 const savedWithoutRelay = new Set(
132 savedEnvironments
133 .filter((environment) => !environment.relayManaged)
134 .map((environment) => environment.environmentId),
125135 );
126136
127137 useEffect(() => {
......@@ -153,8 +163,12 @@ export function CloudEnvironmentConnectRows({
153163 if (result._tag === "Success") {
154164 toastManager.add({
155165 type: "success",
156 title: "Environment added",
157 description: `Connecting to ${environment.label} through T3 Connect.`,
166 title: savedWithoutRelay.has(environment.environmentId)
167 ? "T3 Connect route added"
168 : "Environment added",
169 description: savedWithoutRelay.has(environment.environmentId)
170 ? `${environment.label} falls back to T3 Connect when its other routes are unreachable.`
171 : `Connecting to ${environment.label} through T3 Connect.`,
158172 });
159173 return true;
160174 }
......@@ -182,10 +196,14 @@ export function CloudEnvironmentConnectRows({
182196 return false;
183197 };
184198
199 // During onboarding selection a machine saved over another route already
200 // has its own row elsewhere, and selecting it must not add a T3 Connect
201 // route as a side effect, so it is left out here.
185202 const visibleEnvironments = [...environmentsState.environments.values()].filter(
186203 ({ environment }) =>
187204 environment.environmentId !== primaryEnvironmentId &&
188 (showSavedEnvironments || !savedById.has(environment.environmentId)),
205 (showSavedEnvironments || !savedById.has(environment.environmentId)) &&
206 !(selection && savedWithoutRelay.has(environment.environmentId)),
189207 );
190208 const selectNewComputers = useEffectEvent(() => {
191209 const seen = selection?.autoSelectedComputers;
......@@ -337,19 +355,22 @@ export function CloudEnvironmentConnectRows({
337355 : availability === "checking"
338356 ? "bg-warning"
339357 : "bg-muted-foreground/35";
358 const notAdded = savedWithoutRelay.has(environment.environmentId)
359 ? "Saved without T3 Connect"
360 : "Not added";
340361 const statusText =
341362 unsupported && !savedEnvironment
342 ? "T3 Connect · Not added · Client not supported"
363 ? `T3 Connect · ${notAdded} · Client not supported`
343364 : savedConnection
344365 ? savedConnection.statusText
345366 : availability === "online"
346 ? "T3 Connect · Not added · Relay online"
367 ? `T3 Connect · ${notAdded} · Relay online`
347368 : availability === "offline"
348 ? "T3 Connect · Not added · Relay offline"
369 ? `T3 Connect · ${notAdded} · Relay offline`
349370 : availability === "checking"
350 ? "T3 Connect · Not added · Checking relay status…"
371 ? `T3 Connect · ${notAdded} · Checking relay status…`
351372 : (Option.getOrNull(error)?.message ??
352 "T3 Connect · Not added · Relay status unavailable");
373 `T3 Connect · ${notAdded} · Relay status unavailable`);
353374 if (selection) {
354375 return (
355376 <label
......@@ -464,7 +485,11 @@ export function CloudEnvironmentConnectRows({
464485 disabled={connectingEnvironmentIds.size > 0}
465486 onClick={() => void connectEnvironment(environment)}
466487 >
467 {connectingEnvironmentIds.has(environment.environmentId) ? "Adding…" : "Add"}
488 {connectingEnvironmentIds.has(environment.environmentId)
489 ? "Adding…"
490 : savedWithoutRelay.has(environment.environmentId)
491 ? "Add route"
492 : "Add"}
468493 </Button>
469494 )}
470495 </div>
apps/web/src/components/settings/ConnectionsSettings.tsx+174-36
......@@ -1,8 +1,10 @@
11import {
2 ChevronRightIcon,
23 ChevronsLeftRightEllipsisIcon,
34 EllipsisIcon,
45 PlusIcon,
56 QrCodeIcon,
7 RouteIcon,
68 TerminalIcon,
79} from "lucide-react";
810import { useAtomValue } from "@effect/atom-react";
......@@ -41,7 +43,12 @@ import {
4143 type EnvironmentId,
4244 resolveEnvironmentMachineKind,
4345} from "@t3tools/contracts";
44import { connectionStatusText } from "@t3tools/client-runtime/connection";
46import {
47 RelayConnectionRegistration,
48 RelayConnectionTarget,
49 connectionRoutes,
50 connectionStatusText,
51} from "@t3tools/client-runtime/connection";
4552import {
4653 isAtomCommandInterrupted,
4754 squashAtomCommandFailure,
......@@ -69,6 +76,8 @@ import {
6976import { LocalEnvironmentSetting } from "./LocalEnvironmentSetting";
7077import { searchableSetting } from "./settingsSearch";
7178import { EnvironmentIconMenu } from "./EnvironmentIconPicker";
79import { EnvironmentRoutesList } from "./EnvironmentRoutesList";
80import { usePreparedConnection } from "~/state/session";
7281import {
7382 EnvironmentRow,
7483 environmentTransportLabel,
......@@ -1433,6 +1442,7 @@ type SavedBackendListRowProps = {
14331442 removingEnvironmentId: EnvironmentId | null;
14341443 onSetEnabled: (environmentId: EnvironmentId, enabled: boolean) => void;
14351444 onRemove: (environment: EnvironmentPresentation) => void;
1445 onAddRoute: (environment: EnvironmentPresentation) => void;
14361446};
14371447
14381448/**
......@@ -1482,7 +1492,9 @@ function SavedBackendListRow({
14821492 removingEnvironmentId,
14831493 onSetEnabled,
14841494 onRemove,
1495 onAddRoute,
14851496}: SavedBackendListRowProps) {
1497 const [routesOpen, setRoutesOpen] = useState(false);
14861498 const environmentId = environment.environmentId;
14871499 const unsupported = environment.connection.phase === "unsupported";
14881500 const enabled = environment.entry.enabled && !unsupported;
......@@ -1537,8 +1549,13 @@ function SavedBackendListRow({
15371549 environment.serverConfig ??
15381550 (lastDescriptor === undefined ? null : { environment: lastDescriptor }),
15391551 );
1552 const prepared = usePreparedConnection(environmentId);
1553 const routeCount = connectionRoutes(environment.entry).length;
15401554 const subtitleText = [
1541 environmentTransportLabel(environment),
1555 environmentTransportLabel(
1556 environment,
1557 isConnected && prepared._tag === "Some" ? prepared.value.target : null,
1558 ),
15421559 resumingServerUpdate ? "Restarting" : status.text,
15431560 enabled && versionMismatch ? serverVersion : null,
15441561 ]
......@@ -1571,26 +1588,49 @@ function SavedBackendListRow({
15711588 label={environment.label}
15721589 dimmed={!enabled}
15731590 subtitle={
1574 <Tooltip>
1575 {/* The status can change while the tooltip is open, and base-ui only
1576 re-measures the popup when the trigger's payload changes. */}
1577 <TooltipTrigger
1578 payload={statusTooltip}
1579 render={
1580 <span
1581 className={cn(
1582 "block truncate",
1583 enabled && status.tone === "error" && !resumingServerUpdate && "text-destructive",
1584 )}
1585 />
1586 }
1591 <span className="flex min-w-0 items-center gap-1">
1592 <Tooltip>
1593 {/* The status can change while the tooltip is open, and base-ui only
1594 re-measures the popup when the trigger's payload changes. */}
1595 <TooltipTrigger
1596 payload={statusTooltip}
1597 render={
1598 <span
1599 className={cn(
1600 "min-w-0 truncate",
1601 enabled &&
1602 status.tone === "error" &&
1603 !resumingServerUpdate &&
1604 "text-destructive",
1605 )}
1606 />
1607 }
1608 >
1609 {subtitleText}
1610 </TooltipTrigger>
1611 <TooltipPopup side="top" className="whitespace-pre-wrap">
1612 {statusTooltip}
1613 </TooltipPopup>
1614 </Tooltip>
1615 <span aria-hidden className="shrink-0">
1616 ·
1617 </span>
1618 <button
1619 type="button"
1620 aria-expanded={routesOpen}
1621 onClick={() => setRoutesOpen((open) => !open)}
1622 className="inline-flex shrink-0 items-center gap-0.5 rounded-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring"
15871623 >
1588 {subtitleText}
1589 </TooltipTrigger>
1590 <TooltipPopup side="top" className="whitespace-pre-wrap">
1591 {statusTooltip}
1592 </TooltipPopup>
1593 </Tooltip>
1624 {routeCount === 1 ? "Routes" : `${routeCount} routes`}
1625 <ChevronRightIcon
1626 aria-hidden
1627 className={cn(
1628 "size-3 shrink-0 transition-transform duration-150 motion-reduce:transition-none",
1629 routesOpen && "rotate-90",
1630 )}
1631 />
1632 </button>
1633 </span>
15941634 }
15951635 below={
15961636 serverUpdateState.status !== "idle" ? (
......@@ -1599,6 +1639,14 @@ function SavedBackendListRow({
15991639 </div>
16001640 ) : null
16011641 }
1642 detail={
1643 routesOpen ? (
1644 <EnvironmentRoutesList
1645 environment={environment}
1646 onAddRoute={() => onAddRoute(environment)}
1647 />
1648 ) : null
1649 }
16021650 >
16031651 {unsupported &&
16041652 environment.entry.serverUpdateRequired === true &&
......@@ -1659,6 +1707,10 @@ function SavedBackendListRow({
16591707 environmentId={environmentId}
16601708 serverConfig={environment.serverConfig}
16611709 />
1710 <MenuItem onClick={() => setRoutesOpen((open) => !open)}>
1711 <RouteIcon />
1712 {routesOpen ? "Hide routes" : "Routes"}
1713 </MenuItem>
16621714 {errorTraceId ? (
16631715 <MenuItem onClick={() => copyTraceId(errorTraceId)}>Copy trace ID</MenuItem>
16641716 ) : null}
......@@ -1848,6 +1900,10 @@ export function ConnectionsSettings() {
18481900 reportFailure: false,
18491901 });
18501902 const removeEnvironment = useAtomCommand(environmentCatalog.remove, { reportFailure: false });
1903 const registerEnvironment = useAtomCommand(environmentCatalog.register, {
1904 reportFailure: false,
1905 });
1906 const relayDiscoveryState = useRelayEnvironmentDiscovery();
18511907 const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, {
18521908 reportFailure: false,
18531909 });
......@@ -1974,6 +2030,8 @@ export function ConnectionsSettings() {
19742030 >(null);
19752031 const [isRevokingOtherDesktopClients, setIsRevokingOtherDesktopClients] = useState(false);
19762032 const [addBackendDialogOpen, setAddBackendDialogOpen] = useState(false);
2033 // Set when the dialog adds a route to a saved machine instead of a new one.
2034 const [routeTarget, setRouteTarget] = useState<EnvironmentPresentation | null>(null);
19772035 const [savedBackendMode, setSavedBackendMode] = useState<"remote" | "ssh">("remote");
19782036 const [savedBackendHost, setSavedBackendHost] = useState("");
19792037 const [savedBackendPairingCode, setSavedBackendPairingCode] = useState("");
......@@ -2316,7 +2374,11 @@ export function ConnectionsSettings() {
23162374 async (target: DesktopSshEnvironmentTarget) => {
23172375 setIsAddingSavedBackend(true);
23182376 setSavedBackendError(null);
2319 const result = await connectSshEnvironment({ target, label: "" });
2377 const result = await connectSshEnvironment({
2378 target,
2379 label: "",
2380 ...(routeTarget ? { expectedEnvironmentId: routeTarget.environmentId } : {}),
2381 });
23202382 if (result._tag === "Failure") {
23212383 if (!isAtomCommandInterrupted(result)) {
23222384 setSavedBackendError(formatDesktopSshConnectionError(squashAtomCommandFailure(result)));
......@@ -2333,12 +2395,14 @@ export function ConnectionsSettings() {
23332395 setAddBackendDialogOpen(false);
23342396 toastManager.add({
23352397 type: "success",
2336 title: "Environment connected",
2337 description: `${target.alias} is ready over an SSH-managed tunnel.`,
2398 title: routeTarget ? "Route added" : "Environment connected",
2399 description: routeTarget
2400 ? `${routeTarget.label} can now be reached over SSH ${target.alias}.`
2401 : `${target.alias} is ready over an SSH-managed tunnel.`,
23382402 });
23392403 setIsAddingSavedBackend(false);
23402404 },
2341 [connectSshEnvironment],
2405 [connectSshEnvironment, routeTarget],
23422406 );
23432407
23442408 const handleAddSavedBackend = useCallback(async () => {
......@@ -2381,7 +2445,10 @@ export function ConnectionsSettings() {
23812445 return;
23822446 }
23832447
2384 const result = await connectPairing(remotePairingInput);
2448 const result = await connectPairing({
2449 ...remotePairingInput,
2450 ...(routeTarget ? { expectedEnvironmentId: routeTarget.environmentId } : {}),
2451 });
23852452 if (result._tag === "Failure") {
23862453 if (!isAtomCommandInterrupted(result)) {
23872454 const error = squashAtomCommandFailure(result);
......@@ -2405,13 +2472,22 @@ export function ConnectionsSettings() {
24052472 setSavedBackendSshUsername("");
24062473 setSavedBackendSshPort("");
24072474 setAddBackendDialogOpen(false);
2408 toastManager.add({
2409 type: "success",
2410 title: "Backend added",
2411 description: "The environment is saved and will reconnect on app startup.",
2412 });
2475 toastManager.add(
2476 routeTarget
2477 ? {
2478 type: "success",
2479 title: "Route added",
2480 description: `${routeTarget.label} now has another way to connect.`,
2481 }
2482 : {
2483 type: "success",
2484 title: "Backend added",
2485 description: "The environment is saved and will reconnect on app startup.",
2486 },
2487 );
24132488 setIsAddingSavedBackend(false);
24142489 }, [
2490 routeTarget,
24152491 connectPairing,
24162492 connectSavedBackendSshTarget,
24172493 savedBackendHost,
......@@ -2703,8 +2779,57 @@ export function ConnectionsSettings() {
27032779 </div>
27042780 </div>
27052781 );
2782 // T3 Connect is offered as a route when this account can reach the machine
2783 // through it and it is not one of the machine's routes yet.
2784 const relayRouteOffer =
2785 routeTarget !== null &&
2786 !routeTarget.relayManaged &&
2787 relayDiscoveryState.environments.has(routeTarget.environmentId)
2788 ? relayDiscoveryState.environments.get(routeTarget.environmentId)!.environment
2789 : null;
2790 const addRelayRoute = async () => {
2791 if (relayRouteOffer === null || routeTarget === null) return;
2792 setIsAddingSavedBackend(true);
2793 const result = await registerEnvironment(
2794 new RelayConnectionRegistration({
2795 target: new RelayConnectionTarget({
2796 environmentId: relayRouteOffer.environmentId,
2797 label: routeTarget.label,
2798 }),
2799 }),
2800 );
2801 setIsAddingSavedBackend(false);
2802 if (result._tag === "Failure") {
2803 if (!isAtomCommandInterrupted(result)) {
2804 const error = squashAtomCommandFailure(result);
2805 setSavedBackendError(error instanceof Error ? error.message : "Could not add the route.");
2806 }
2807 return;
2808 }
2809 setAddBackendDialogOpen(false);
2810 toastManager.add({
2811 type: "success",
2812 title: "Route added",
2813 description: `${routeTarget.label} falls back to T3 Connect when its other routes are unreachable.`,
2814 });
2815 };
27062816 const renderRemoteModeBody = () => (
27072817 <div className="space-y-4">
2818 {relayRouteOffer !== null ? (
2819 <div className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2">
2820 <p className="text-xs text-muted-foreground">
2821 This machine is on your T3 Connect account. Use it as a fallback route.
2822 </p>
2823 <Button
2824 size="xs"
2825 variant="outline"
2826 disabled={isAddingSavedBackend}
2827 onClick={() => void addRelayRoute()}
2828 >
2829 Add T3 Connect
2830 </Button>
2831 </div>
2832 ) : null}
27082833 {renderRemoteFields()}
27092834 {savedBackendError ? <p className="text-xs text-destructive">{savedBackendError}</p> : null}
27102835 <Button
......@@ -2714,7 +2839,7 @@ export function ConnectionsSettings() {
27142839 onClick={() => void handleAddSavedBackend()}
27152840 >
27162841 <PlusIcon className="size-3.5" />
2717 {isAddingSavedBackend ? "Adding…" : "Add environment"}
2842 {isAddingSavedBackend ? "Adding…" : routeTarget ? "Add route" : "Add environment"}
27182843 </Button>
27192844 </div>
27202845 );
......@@ -2835,7 +2960,7 @@ export function ConnectionsSettings() {
28352960 onClick={() => void handleAddSavedBackend()}
28362961 >
28372962 <PlusIcon className="size-3.5" />
2838 {isAddingSavedBackend ? "Adding…" : "Add environment"}
2963 {isAddingSavedBackend ? "Adding…" : routeTarget ? "Add route" : "Add environment"}
28392964 </Button>
28402965 </div>
28412966 </div>
......@@ -3726,7 +3851,9 @@ export function ConnectionsSettings() {
37263851 open={addBackendDialogOpen}
37273852 onOpenChange={(open) => {
37283853 setAddBackendDialogOpen(open);
3729 if (!open) {
3854 if (open) {
3855 setRouteTarget(null);
3856 } else {
37303857 setSavedBackendError(null);
37313858 }
37323859 }}
......@@ -3748,8 +3875,14 @@ export function ConnectionsSettings() {
37483875 </Tooltip>
37493876 <DialogPopup className="max-h-[80dvh] sm:max-w-3xl">
37503877 <DialogHeader>
3751 <DialogTitle>Add Environment</DialogTitle>
3752 <DialogDescription>Pair another environment to this client.</DialogDescription>
3878 <DialogTitle>
3879 {routeTarget ? `Add a route to ${routeTarget.label}` : "Add Environment"}
3880 </DialogTitle>
3881 <DialogDescription>
3882 {routeTarget
3883 ? "Pair this machine again over another address, such as its Tailscale name. It joins the existing routes instead of adding a second machine."
3884 : "Pair another environment to this client."}
3885 </DialogDescription>
37533886 </DialogHeader>
37543887 <DialogPanel>
37553888 <div className="space-y-4">
......@@ -3787,6 +3920,11 @@ export function ConnectionsSettings() {
37873920 removingEnvironmentId={removingSavedEnvironmentId}
37883921 onSetEnabled={handleSetSavedBackendEnabled}
37893922 onRemove={handleRemoveSavedBackend}
3923 onAddRoute={(target) => {
3924 setRouteTarget(target);
3925 setSavedBackendError(null);
3926 setAddBackendDialogOpen(true);
3927 }}
37903928 />
37913929 ))}
37923930 <CloudRemoteEnvironmentRows
apps/web/src/components/settings/EnvironmentRoutesList.tsx created+229
......@@ -0,0 +1,229 @@
1import {
2 DndContext,
3 type DragEndEvent,
4 KeyboardSensor,
5 PointerSensor,
6 closestCenter,
7 useSensor,
8 useSensors,
9} from "@dnd-kit/core";
10import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers";
11import {
12 SortableContext,
13 arrayMove,
14 sortableKeyboardCoordinates,
15 useSortable,
16 verticalListSortingStrategy,
17} from "@dnd-kit/sortable";
18import { CSS } from "@dnd-kit/utilities";
19import {
20 type ConnectionRoute,
21 connectionRouteAddress,
22 connectionRouteId,
23 connectionRouteLabel,
24 connectionRoutes,
25} from "@t3tools/client-runtime/connection";
26import { GripVerticalIcon, PlusIcon, XIcon } from "lucide-react";
27import { useState } from "react";
28
29import { requestConfirmDialog } from "~/confirmDialog";
30import { environmentCatalog } from "~/connection/catalog";
31import { cn } from "~/lib/utils";
32import type { EnvironmentPresentation } from "~/state/environments";
33import { usePreparedConnection } from "~/state/session";
34import { useAtomCommand } from "~/state/use-atom-command";
35import { Button } from "../ui/button";
36import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
37
38/**
39 * The ways this client reaches one saved machine, preferred first. Drag to
40 * reorder; the first route that answers is used and the connection moves back
41 * up when a better one is reachable again. Shown under the machine's row.
42 */
43export function EnvironmentRoutesList({
44 environment,
45 onAddRoute,
46}: {
47 readonly environment: EnvironmentPresentation;
48 readonly onAddRoute: () => void;
49}) {
50 const saved = connectionRoutes(environment.entry);
51 const savedIds = saved.map((route) => connectionRouteId(route.target));
52 // A dropped order shows until the catalog matches it, so the row does not
53 // jump back while the reorder is being saved.
54 const [pending, setPending] = useState<ReadonlyArray<string> | null>(null);
55 const order: Array<string> =
56 pending !== null &&
57 pending.length === savedIds.length &&
58 pending.some((id, index) => id !== savedIds[index])
59 ? [...pending]
60 : savedIds;
61 const byId = new Map(saved.map((route) => [connectionRouteId(route.target), route]));
62 const routes = order.flatMap((id) => byId.get(id) ?? []);
63
64 const prepared = usePreparedConnection(environment.environmentId);
65 const activeRouteId =
66 prepared._tag === "Some" && environment.connection.phase === "connected"
67 ? connectionRouteId(prepared.value.target)
68 : null;
69 const reorder = useAtomCommand(environmentCatalog.reorderRoutes, "Reorder routes");
70 const removeRoute = useAtomCommand(environmentCatalog.removeRoute, "Remove route");
71 const sensors = useSensors(
72 useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
73 useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
74 );
75
76 // Removing a paired route forgets its credential, so it asks first, like
77 // on mobile. No mounted confirm host means no removal.
78 const confirmRemove = async (route: ConnectionRoute) => {
79 const address = connectionRouteAddress(route);
80 const confirmed = await requestConfirmDialog(
81 `Remove ${connectionRouteLabel(route)} route?${address === null ? "" : `\n${address}`}`,
82 { variant: "destructive" },
83 );
84 if (confirmed !== true) return;
85 await removeRoute({
86 environmentId: environment.environmentId,
87 routeId: connectionRouteId(route.target),
88 });
89 };
90
91 const handleDragEnd = (event: DragEndEvent) => {
92 if (event.over === null || event.active.id === event.over.id) return;
93 const next = arrayMove(
94 order,
95 order.indexOf(String(event.active.id)),
96 order.indexOf(String(event.over.id)),
97 );
98 setPending(next);
99 void reorder({ environmentId: environment.environmentId, routeIds: next }).then((result) => {
100 if (result._tag === "Failure") setPending(null);
101 });
102 };
103
104 return (
105 <div className="mt-2 rounded-md border border-border/70">
106 <DndContext
107 sensors={sensors}
108 collisionDetection={closestCenter}
109 modifiers={[restrictToVerticalAxis, restrictToParentElement]}
110 onDragEnd={handleDragEnd}
111 >
112 <SortableContext items={order} strategy={verticalListSortingStrategy}>
113 <ol aria-label={`Routes to ${environment.label}, preferred first`}>
114 {routes.map((route, index) => (
115 <SortableRouteRow
116 key={connectionRouteId(route.target)}
117 route={route}
118 position={index + 1}
119 inUse={connectionRouteId(route.target) === activeRouteId}
120 // The last route goes with the machine; that is "Remove from
121 // this device", not a route action.
122 removable={routes.length > 1}
123 onRemove={() => void confirmRemove(route)}
124 />
125 ))}
126 </ol>
127 </SortableContext>
128 </DndContext>
129 <div className="border-t border-border/70 px-1 py-1">
130 <Button size="xs" variant="ghost-muted" onClick={onAddRoute}>
131 <PlusIcon className="size-3" />
132 Add route
133 </Button>
134 </div>
135 </div>
136 );
137}
138
139function SortableRouteRow({
140 route,
141 position,
142 inUse,
143 removable,
144 onRemove,
145}: {
146 readonly route: ConnectionRoute;
147 readonly position: number;
148 readonly inUse: boolean;
149 readonly removable: boolean;
150 readonly onRemove: () => void;
151}) {
152 const id = connectionRouteId(route.target);
153 const label = connectionRouteLabel(route);
154 const address = connectionRouteAddress(route);
155 const {
156 attributes,
157 listeners,
158 setNodeRef,
159 setActivatorNodeRef,
160 transform,
161 transition,
162 isDragging,
163 } = useSortable({ id });
164
165 return (
166 <li
167 ref={setNodeRef}
168 style={{ transform: CSS.Translate.toString(transform), transition }}
169 className={cn(
170 "flex items-center gap-2 border-b border-border/70 px-1 py-1.5 last:border-b-0",
171 isDragging && "relative z-10 rounded-md bg-background shadow-md",
172 )}
173 >
174 <button
175 type="button"
176 ref={setActivatorNodeRef}
177 {...attributes}
178 {...listeners}
179 aria-label={`Reorder ${label}, position ${position}`}
180 className="flex size-6 shrink-0 cursor-grab touch-none items-center justify-center rounded text-muted-foreground/70 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing"
181 >
182 <GripVerticalIcon className="size-3.5" />
183 </button>
184 <div className="min-w-0 flex-1">
185 <p className="flex items-center gap-1.5 text-xs font-medium text-foreground">
186 {label}
187 {inUse ? (
188 <span className="rounded-sm bg-success/12 px-1 text-2xs font-normal text-success-foreground">
189 In use
190 </span>
191 ) : null}
192 </p>
193 {address !== null ? (
194 <p className="truncate text-2xs text-muted-foreground">
195 {address}
196 {isLearnedRoute(route) ? " · found automatically" : ""}
197 </p>
198 ) : null}
199 </div>
200 {removable ? (
201 <Tooltip>
202 <TooltipTrigger
203 render={
204 <Button
205 type="button"
206 variant="ghost-muted"
207 size="icon-xs"
208 aria-label={`Remove ${label} route`}
209 onClick={onRemove}
210 />
211 }
212 >
213 <XIcon className="size-3" />
214 </TooltipTrigger>
215 <TooltipPopup side="top">Remove route</TooltipPopup>
216 </Tooltip>
217 ) : null}
218 </li>
219 );
220}
221
222function isLearnedRoute(route: ConnectionRoute): boolean {
223 return (
224 route.profile._tag === "Some" &&
225 route.profile.value._tag === "BearerConnectionProfile" &&
226 "learned" in route.profile.value &&
227 route.profile.value.learned === true
228 );
229}
apps/web/src/components/settings/EnvironmentRow.tsx+27-1
......@@ -1,3 +1,9 @@
1import {
2 type ConnectionTarget,
3 connectionRouteId,
4 connectionRouteLabel,
5 connectionRoutes,
6} from "@t3tools/client-runtime/connection";
17import type { DesktopSshEnvironmentTarget, EnvironmentMachineKind } from "@t3tools/contracts";
28import * as Option from "effect/Option";
39import type { ReactNode } from "react";
......@@ -15,10 +21,26 @@ export function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): str
1521/**
1622 * How this client reaches a machine, printed first in every environment row so
1723 * T3 Connect, SSH, WSL, and plain remote links are told apart without a legend.
24 * A machine with several routes names the one in use, or how many it has.
1825 */
19export function environmentTransportLabel(environment: EnvironmentPresentation): string {
26export function environmentTransportLabel(
27 environment: EnvironmentPresentation,
28 activeTarget: ConnectionTarget | null = null,
29): string {
2030 const { entry } = environment;
2131 if (entry.target._tag === "PrimaryConnectionTarget") return "This machine";
32 const routes = connectionRoutes(entry);
33 if (routes.length > 1) {
34 const active =
35 activeTarget === null
36 ? undefined
37 : routes.find(
38 (route) => connectionRouteId(route.target) === connectionRouteId(activeTarget),
39 );
40 return active === undefined
41 ? connectionRouteLabel(routes[0]!)
42 : `via ${connectionRouteLabel(active)}`;
43 }
2244 if (environment.relayManaged) return "T3 Connect";
2345 if (isDesktopLocalConnectionTarget(entry.target)) return "WSL";
2446 if (
......@@ -41,6 +63,7 @@ export function EnvironmentRow({
4163 label,
4264 subtitle,
4365 below,
66 detail,
4467 dimmed = false,
4568 className,
4669 children,
......@@ -50,6 +73,8 @@ export function EnvironmentRow({
5073 readonly subtitle: ReactNode;
5174 /** Extra content under the subtitle, such as update progress. */
5275 readonly below?: ReactNode;
76 /** Expanded content under the whole row, aligned with the label, such as its routes. */
77 readonly detail?: ReactNode;
5378 readonly dimmed?: boolean;
5479 readonly className?: string;
5580 readonly children?: ReactNode;
......@@ -69,6 +94,7 @@ export function EnvironmentRow({
6994 {below}
7095 </div>
7196 <div className="flex shrink-0 items-center gap-1">{children}</div>
97 {detail ? <div className="col-span-2 col-start-2 min-w-0">{detail}</div> : null}
7298 </div>
7399 );
74100}
apps/web/src/connection/onboarding.ts+3-8
......@@ -15,14 +15,9 @@ export const connectPairing = createRuntimeCommand(connectionAtomRuntime, {
1515 scheduler: onboardingScheduler,
1616 concurrency: {
1717 mode: "singleFlight",
18 key: (input: { pairingUrl?: string; host?: string; pairingCode?: string }) =>
19 JSON.stringify(input),
18 key: (input: ConnectionOnboarding.PairingConnectionInput) => JSON.stringify(input),
2019 },
21 execute: (input: {
22 readonly pairingUrl?: string;
23 readonly host?: string;
24 readonly pairingCode?: string;
25 }) =>
20 execute: (input: ConnectionOnboarding.PairingConnectionInput) =>
2621 ConnectionOnboarding.ConnectionOnboarding.pipe(
2722 Effect.flatMap((onboarding) => onboarding.registerPairing(input)),
2823 ),
......@@ -35,7 +30,7 @@ export const connectSshEnvironment = createRuntimeCommand(connectionAtomRuntime,
3530 mode: "serial",
3631 key: (input: { readonly target: DesktopSshEnvironmentTarget }) => JSON.stringify(input.target),
3732 },
38 execute: (input: { readonly target: DesktopSshEnvironmentTarget; readonly label?: string }) =>
33 execute: (input: ConnectionOnboarding.SshConnectionInput) =>
3934 ConnectionOnboarding.ConnectionOnboarding.pipe(
4035 Effect.flatMap((onboarding) => onboarding.registerSsh(input)),
4136 ),
apps/web/src/connection/platform.test.ts+15
......@@ -83,6 +83,21 @@ describe("desktop SSH pairing", () => {
8383 }),
8484 );
8585
86 it.effect("does not consume the credential when adding a route to a different machine", () =>
87 Effect.gen(function* () {
88 const calls: string[] = [];
89
90 const error = yield* provisionDesktopSshEnvironment(
91 makeBridge(calls),
92 TARGET,
93 EnvironmentId.make("some-other-machine"),
94 ).pipe(Effect.flip);
95
96 expect(error).toMatchObject({ reason: "configuration" });
97 expect(calls).toEqual(["ensure", "descriptor"]);
98 }),
99 );
100
86101 it.effect("does not consume the credential when descriptor discovery fails", () =>
87102 Effect.gen(function* () {
88103 const calls: string[] = [];
apps/web/src/connection/platform.ts+83-30
......@@ -26,6 +26,7 @@ import {
2626 type DesktopBridge,
2727 type DesktopEnvironmentBootstrap,
2828 type DesktopSshEnvironmentTarget,
29 type EnvironmentId,
2930 PRIMARY_LOCAL_ENVIRONMENT_ID,
3031} from "@t3tools/contracts";
3132import * as Clock from "effect/Clock";
......@@ -87,28 +88,68 @@ const connectivityLayer = Connectivity.layer({
8788 ),
8889});
8990
91interface NetworkInformationLike extends EventTarget {
92 readonly type?: string;
93}
94
95/**
96 * Wakes connections when the browser reports a different network type, such
97 * as a laptop moving from Wi-Fi to a phone hotspot. `change` also fires for
98 * bandwidth and latency estimates on the same network, so only a type change
99 * counts. Browsers without `navigator.connection.type` rely on the periodic
100 * route check instead.
101 */
102const networkPathChanges = Stream.callback<"network-changed">((queue) =>
103 Effect.acquireRelease(
104 Effect.sync(() => {
105 const connection =
106 typeof navigator === "undefined"
107 ? undefined
108 : (navigator as Navigator & { readonly connection?: NetworkInformationLike }).connection;
109 if (connection?.type === undefined) return undefined;
110 let previous = connection.type;
111 const listener = () => {
112 const type = connection.type;
113 if (type === undefined || type === previous) return;
114 previous = type;
115 Queue.offerUnsafe(queue, "network-changed");
116 };
117 connection.addEventListener("change", listener);
118 return { connection, listener };
119 }),
120 (subscription) =>
121 Effect.sync(() =>
122 subscription?.connection.removeEventListener("change", subscription.listener),
123 ),
124 ).pipe(Effect.asVoid),
125);
126
90127const wakeupsLayer = Wakeups.layer({
91 changes: Stream.merge(
92 Stream.callback<"application-active">((queue) =>
93 Effect.acquireRelease(
94 Effect.sync(() => {
95 const listener = () => {
96 if (document.visibilityState === "visible") {
97 Queue.offerUnsafe(queue, "application-active");
98 }
99 };
100 document.addEventListener("visibilitychange", listener);
101 return listener;
102 }),
103 (listener) =>
128 changes: Stream.mergeAll(
129 [
130 Stream.callback<"application-active">((queue) =>
131 Effect.acquireRelease(
104132 Effect.sync(() => {
105 document.removeEventListener("visibilitychange", listener);
133 const listener = () => {
134 if (document.visibilityState === "visible") {
135 Queue.offerUnsafe(queue, "application-active");
136 }
137 };
138 document.addEventListener("visibilitychange", listener);
139 return listener;
106140 }),
107 ).pipe(Effect.asVoid),
108 ),
109 managedRelayAccountChanges(appAtomRegistry).pipe(
110 Stream.map(() => "credentials-changed" as const),
111 ),
141 (listener) =>
142 Effect.sync(() => {
143 document.removeEventListener("visibilitychange", listener);
144 }),
145 ).pipe(Effect.asVoid),
146 ),
147 managedRelayAccountChanges(appAtomRegistry).pipe(
148 Stream.map(() => "credentials-changed" as const),
149 ),
150 networkPathChanges,
151 ],
152 { concurrency: "unbounded" },
112153 ),
113154});
114155
......@@ -141,7 +182,11 @@ function sshPreparationError(cause: unknown) {
141182
142183export const provisionDesktopSshEnvironment = Effect.fn(
143184 "web.connectionPlatform.ssh.provisionDesktop",
144)(function* (bridge: DesktopBridge, target: DesktopSshEnvironmentTarget) {
185)(function* (
186 bridge: DesktopBridge,
187 target: DesktopSshEnvironmentTarget,
188 expectedEnvironmentId?: EnvironmentId,
189) {
145190 const bootstrap = yield* Effect.tryPromise({
146191 try: () =>
147192 bridge.ensureSshEnvironment(target, {
......@@ -160,6 +205,12 @@ export const provisionDesktopSshEnvironment = Effect.fn(
160205 try: () => bridge.fetchSshEnvironmentDescriptor(bootstrap.httpBaseUrl),
161206 catch: sshPreparationError,
162207 });
208 if (expectedEnvironmentId !== undefined && descriptor.environmentId !== expectedEnvironmentId) {
209 return yield* new ConnectionBlockedError({
210 reason: "configuration",
211 detail: `That host reaches ${descriptor.label}, a different machine. Add it as its own environment instead.`,
212 });
213 }
163214 const access = yield* Effect.tryPromise({
164215 try: () => bridge.bootstrapSshBearerSession(bootstrap.httpBaseUrl, pairingToken),
165216 catch: sshPreparationError,
......@@ -222,16 +273,18 @@ const capabilitiesLayer = Layer.effectContext(
222273 }).pipe(Effect.map(Option.fromNullishOr)),
223274 });
224275 const ssh = ClientCapabilities.SshEnvironmentGateway.of({
225 provision: Effect.fn("web.connectionPlatform.ssh.provision")(function* (target) {
226 const bridge = window.desktopBridge;
227 if (bridge === undefined) {
228 return yield* new ConnectionBlockedError({
229 reason: "unsupported",
230 detail: "SSH environments are only available in the desktop app.",
231 });
232 }
233 return yield* provisionDesktopSshEnvironment(bridge, target);
234 }),
276 provision: Effect.fn("web.connectionPlatform.ssh.provision")(
277 function* (target, expectedEnvironmentId) {
278 const bridge = window.desktopBridge;
279 if (bridge === undefined) {
280 return yield* new ConnectionBlockedError({
281 reason: "unsupported",
282 detail: "SSH environments are only available in the desktop app.",
283 });
284 }
285 return yield* provisionDesktopSshEnvironment(bridge, target, expectedEnvironmentId);
286 },
287 ),
235288 prepare: Effect.fn("web.connectionPlatform.ssh.prepare")(function* (input) {
236289 const bridge = window.desktopBridge;
237290 if (bridge === undefined) {
apps/web/src/connection/storage.ts+10-4
......@@ -11,6 +11,7 @@ import {
1111 removeCatalogValue,
1212 removeConnectionFromCatalog,
1313 setConnectionEnabledInCatalog,
14 setRoutesInCatalog,
1415 replaceCatalogValue,
1516 Persistence,
1617} from "@t3tools/client-runtime/platform";
......@@ -86,6 +87,7 @@ function persistenceError(
8687 | "list-targets"
8788 | "list-disabled-targets"
8889 | "register-connection"
90 | "set-connection-routes"
8991 | "remove-connection"
9092 | "set-connection-enabled"
9193 | "load-shell"
......@@ -474,13 +476,17 @@ export const connectionStorageLayer = Layer.effectContext(
474476 ),
475477 });
476478 const registrationStore = Persistence.ConnectionRegistrationStore.of({
477 register: (registration) =>
479 register: (registration, routes) =>
478480 catalog
479 .update((document) => registerConnectionInCatalog(document, registration))
481 .update((document) => registerConnectionInCatalog(document, registration, routes))
480482 .pipe(Effect.mapError((cause) => persistenceError("register-connection", cause))),
481 remove: (target) =>
483 setRoutes: (environmentId, routes) =>
482484 catalog
483 .update((document) => removeConnectionFromCatalog(document, target))
485 .update((document) => setRoutesInCatalog(document, environmentId, routes))
486 .pipe(Effect.mapError((cause) => persistenceError("set-connection-routes", cause))),
487 remove: (environmentId) =>
488 catalog
489 .update((document) => removeConnectionFromCatalog(document, environmentId))
484490 .pipe(Effect.mapError((cause) => persistenceError("remove-connection", cause))),
485491 setEnabled: (environmentId, enabled) =>
486492 catalog
apps/web/src/onboarding/targetEnvironment.logic.ts+3-3
......@@ -1,16 +1,16 @@
1import type { ConnectionTarget } from "@t3tools/client-runtime/connection";
1import { type ConnectionCatalogEntry, hasRelayRoute } from "@t3tools/client-runtime/connection";
22import type { EnvironmentId } from "@t3tools/contracts";
33
44interface OnboardingEnvironment {
55 readonly environmentId: EnvironmentId;
66 readonly connection: { readonly phase: string };
7 readonly entry: { readonly target: ConnectionTarget };
7 readonly entry: Pick<ConnectionCatalogEntry, "target" | "alternateRoutes">;
88}
99
1010export function isOnboardingRelayEnvironment(
1111 environment: Pick<OnboardingEnvironment, "entry">,
1212): boolean {
13 return environment.entry.target._tag === "RelayConnectionTarget";
13 return hasRelayRoute(environment.entry);
1414}
1515
1616/** Keep a directly paired machine pinned while its initial connection completes. */
apps/web/src/state/environments.ts+2-1
......@@ -1,6 +1,7 @@
11import { useAtomValue } from "@effect/atom-react";
22import {
33 connectionCatalogDisplayUrl,
4 hasRelayRoute,
45 type EnvironmentPresentation as BaseEnvironmentPresentation,
56} from "@t3tools/client-runtime/connection";
67import { Discovery } from "@t3tools/client-runtime/relay";
......@@ -34,7 +35,7 @@ function projectEnvironmentPresentation(
3435 environmentId,
3536 label: presentation.entry.target.label,
3637 displayUrl: connectionCatalogDisplayUrl(presentation.entry),
37 relayManaged: presentation.entry.target._tag === "RelayConnectionTarget",
38 relayManaged: hasRelayRoute(presentation.entry),
3839 };
3940}
4041
docs/internals/remote.md+18
......@@ -24,6 +24,24 @@ prove that a route works. In particular, a host's loopback address refers to a
2424different machine when another device opens it. Endpoint selection must not
2525silently fall back to loopback when a shareable endpoint is unavailable.
2626
27A saved environment holds an ordered list of routes, and the
28[driver](../../packages/client-runtime/src/connection/driver.ts) connects over
29the first that works. Each direct route is first checked with the public
30descriptor, so a saved LAN address that a different machine answers on another
31network receives no credential. That check is not proof of a working route:
32when every route stays silent, each is still tried. A route that fails to
33connect, including a blocked one such as a signed-out T3 Connect, moves on to
34the next; only an incompatible server stops the walk, because it is the same
35server on every route. While connected over a later route the
36[supervisor](../../packages/client-runtime/src/connection/supervisor.ts)
37preflights the earlier ones and replaces the session when one would connect.
38Preflight includes authorization so a route that answers but rejects this
39client never costs a working session; a route that still fails afterwards is
40held back for a cooldown so a flaky network cannot bounce the connection.
41
42GitHub routing trust covers the whole route list. Adding or changing a route
43revokes it; reordering does not, because the same addresses remain trusted.
44
2745## Hosted web is a client
2846
2947The hosted web app stores its connection catalog in the browser and connects
docs/user/remote-access.md+20
......@@ -62,6 +62,26 @@ created in Settings can only be copied from the client that created them while
6262its Connections page stays open. If you leave or reload that page, create
6363another link to share.
6464
65### Reach one machine several ways
66
67A machine can have more than one route: LAN, Tailscale, a public URL, SSH, or
68T3 Connect. To add one, choose **Add route** in the machine's route list, or
69next to it in the T3 Connect list. Pairing the same machine again over another
70address also adds a route instead of a second machine. A new route is placed by
71speed, in that order, and you can reorder routes at any time.
72
73T3 Code connects over the first route that answers. Away from home, a LAN
74address that does not answer is checked briefly and skipped. It is only tried
75again, after the other routes, if none of them connect. While connected over a
76later route, T3 Code checks the earlier ones when your network changes, when you
77return to the app, and every minute, and moves back as soon as one works.
78
79On web and desktop, select the route count under the machine's name in
80**Settings → Connections** to see its routes. Drag a route to change the order,
81or remove it. On mobile, open the machine under **Settings → Environments** and
82choose **Edit**. Signing out of T3 Connect removes only that route; a machine
83you can still reach another way stays saved.
84
6585### Balance new threads across machines
6686
6787Auto balance is off by default. On web and desktop, enable it in
packages/client-runtime/src/connection/catalog.ts+12
......@@ -36,9 +36,21 @@ export class SshConnectionProfile extends Schema.TaggedClass<SshConnectionProfil
3636export const ConnectionProfile = Schema.Union([BearerConnectionProfile, SshConnectionProfile]);
3737export type ConnectionProfile = typeof ConnectionProfile.Type;
3838
39/** One way to reach an environment: T3 Connect, a direct URL, or SSH. */
40export interface ConnectionRoute {
41 readonly target: ConnectionTarget;
42 readonly profile: Option.Option<ConnectionProfile>;
43}
44
45/**
46 * A saved environment. `target` and `profile` are its preferred route;
47 * `alternateRoutes` holds the others in preference order. Read them together
48 * with `connectionRoutes`.
49 */
3950export interface ConnectionCatalogEntry {
4051 readonly target: ConnectionTarget;
4152 readonly profile: Option.Option<ConnectionProfile>;
53 readonly alternateRoutes?: ReadonlyArray<ConnectionRoute>;
4254 /** False when the user switched the environment off: saved, but never connects. */
4355 readonly enabled: boolean;
4456 /** Discovery rejection stays visible while the saved connection is switched off. */
packages/client-runtime/src/connection/driver.ts+149-9
......@@ -1,16 +1,22 @@
11import * as Context from "effect/Context";
22import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";
4import * as Fiber from "effect/Fiber";
35import * as Layer from "effect/Layer";
4import type * as Scope from "effect/Scope";
6import * as Scope from "effect/Scope";
7import * as HttpClient from "effect/unstable/http/HttpClient";
58
6import type { ConnectionCatalogEntry } from "./catalog.ts";
9import type { ConnectionCatalogEntry, ConnectionRoute } from "./catalog.ts";
710import type {
811 ConnectionAttemptError,
912 ConnectionAttemptStage,
1013 PreparedConnection,
1114} from "./model.ts";
15import { ConnectionTransientError } from "./model.ts";
1216import * as ConnectionResolver from "./resolver.ts";
17import { connectionRoutes, routeEntry, routeHttpBaseUrl } from "./routes.ts";
1318import * as RpcSession from "../rpc/session.ts";
19import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
1420
1521export type ConnectionDriverProgress =
1622 | {
......@@ -26,6 +32,15 @@ export interface EnvironmentConnectionLease {
2632 readonly session: RpcSession.RpcSession;
2733}
2834
35/**
36 * The result of an unauthenticated reachability check. T3 Connect and SSH
37 * routes have no cheap check, so they are "unchecked".
38 */
39export type RouteCheck = "answered" | "silent" | "unchecked";
40
41/** How long a direct route has to answer before it counts as unreachable from here. */
42const ROUTE_CHECK_TIMEOUT_MS = 2_500;
43
2944export class ConnectionDriver extends Context.Service<
3045 ConnectionDriver,
3146 {
......@@ -33,13 +48,118 @@ export class ConnectionDriver extends Context.Service<
3348 entry: ConnectionCatalogEntry,
3449 reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect<void>,
3550 ) => Effect.Effect<EnvironmentConnectionLease, ConnectionAttemptError, Scope.Scope>;
51 /** Whether a direct route answers as the entry's environment, without credentials. */
52 readonly checkRoute: (
53 entry: ConnectionCatalogEntry,
54 route: ConnectionRoute,
55 ) => Effect.Effect<RouteCheck>;
56 /**
57 * Whether a direct route answers and accepts this client's credential,
58 * without opening a socket. Switching to a route that fails this would
59 * drop a working connection for nothing.
60 */
61 readonly preflight: (
62 entry: ConnectionCatalogEntry,
63 route: ConnectionRoute,
64 ) => Effect.Effect<boolean>;
3665 }
3766>()("@t3tools/client-runtime/connection/driver/ConnectionDriver") {}
3867
68/**
69 * Connects over the first route, in preference order, that is worth trying.
70 * Every route is checked at once, but a route only waits for its own check,
71 * so a reachable LAN address connects without waiting on a silent tailnet
72 * one. A silent route is skipped on the first pass so a LAN address from
73 * another network costs one short check, not a connection timeout. A route
74 * that fails to connect moves on to the next: a signed-out T3 Connect must
75 * not hide a working LAN. Silent routes are tried last, since a check is not
76 * proof.
77 *
78 * The reported error is a transient one when any route failed transiently,
79 * so the supervisor keeps retrying a route that may come back; a blocked
80 * error is reported only when every attempted route was blocked.
81 */
82export const connectOverRoutes = Effect.fn("ConnectionDriver.connectOverRoutes")(function* <R>(
83 entry: ConnectionCatalogEntry,
84 checkRoute: (route: ConnectionRoute) => Effect.Effect<RouteCheck>,
85 connectRoute: (
86 route: ConnectionRoute,
87 ) => Effect.Effect<EnvironmentConnectionLease, ConnectionAttemptError, R | Scope.Scope>,
88) {
89 const routes = connectionRoutes(entry);
90 const checks =
91 routes.length === 1
92 ? []
93 : yield* Effect.forEach(routes, (route) => Effect.forkChild(checkRoute(route)));
94 const attemptScope = yield* Scope.Scope;
95 let transient: ConnectionAttemptError | undefined;
96 let blocked: ConnectionAttemptError | undefined;
97 // Each route gets its own scope so a half-open session closes before the next try.
98 const attempt = Effect.fnUntraced(function* (route: ConnectionRoute) {
99 const routeScope = yield* Scope.fork(attemptScope);
100 const result = yield* connectRoute(route).pipe(
101 Scope.provide(routeScope),
102 Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : Scope.close(routeScope, exit))),
103 Effect.result,
104 );
105 if (result._tag === "Failure") {
106 if (result.failure._tag === "ConnectionTransientError") transient ??= result.failure;
107 else blocked ??= result.failure;
108 }
109 return result;
110 });
111 const silent: Array<ConnectionRoute> = [];
112 for (const [index, route] of routes.entries()) {
113 const check = checks.length === 0 ? "unchecked" : yield* Fiber.join(checks[index]!);
114 if (check === "silent") {
115 silent.push(route);
116 continue;
117 }
118 const result = yield* attempt(route);
119 if (result._tag === "Success") return result.success;
120 // An incompatible server is the same server on every route.
121 if (result.failure.reason === "unsupported") return yield* result.failure;
122 }
123 for (const route of silent) {
124 const result = yield* attempt(route);
125 if (result._tag === "Success") return result.success;
126 if (result.failure.reason === "unsupported") return yield* result.failure;
127 }
128 return yield* (
129 transient ??
130 blocked ??
131 new ConnectionTransientError({
132 reason: "endpoint-unavailable",
133 detail: `${entry.target.label} did not answer on any saved route.`,
134 })
135 );
136});
137
39138/** @public Service construction is part of the canonical Effect module API. */
40139export const make = Effect.gen(function* () {
41140 const resolver = yield* ConnectionResolver.ConnectionResolver;
42141 const sessions = yield* RpcSession.RpcSessionFactory;
142 const httpClient = yield* HttpClient.HttpClient;
143
144 const checkRoute = (entry: ConnectionCatalogEntry, route: ConnectionRoute) => {
145 const httpBaseUrl = routeHttpBaseUrl(route);
146 if (httpBaseUrl === null) return Effect.succeed<RouteCheck>("unchecked");
147 // The descriptor is public, so this sends no credential to whatever
148 // answers at a saved LAN address on a different network.
149 return fetchRemoteEnvironmentDescriptor({
150 httpBaseUrl,
151 timeoutMs: ROUTE_CHECK_TIMEOUT_MS,
152 }).pipe(
153 Effect.map((descriptor): RouteCheck =>
154 descriptor.environmentId === entry.target.environmentId ? "answered" : "silent",
155 ),
156 Effect.orElseSucceed((): RouteCheck => "silent"),
157 Effect.provideService(HttpClient.HttpClient, httpClient),
158 Effect.withSpan("ConnectionDriver.checkRoute", {
159 attributes: { "connection.target.kind": route.target._tag },
160 }),
161 );
162 };
43163
44164 const connect = Effect.fn("ConnectionDriver.connect")(function* (
45165 entry: ConnectionCatalogEntry,
......@@ -49,17 +169,37 @@ export const make = Effect.gen(function* () {
49169 yield* Effect.annotateCurrentSpan({
50170 "connection.environment.id": target.environmentId,
51171 "connection.target.kind": target._tag,
172 "connection.route.count": connectionRoutes(entry).length,
52173 });
53174 yield* reportProgress({ stage: "preparing" });
54 const prepared = yield* resolver.prepare(entry);
55 yield* reportProgress({ stage: "opening", prepared });
56 const session = yield* sessions.connect(prepared);
57 yield* reportProgress({ stage: "synchronizing", prepared });
58 yield* session.ready;
59 return { prepared, session } satisfies EnvironmentConnectionLease;
175 return yield* connectOverRoutes(
176 entry,
177 (route) => checkRoute(entry, route),
178 Effect.fnUntraced(function* (route) {
179 const prepared = yield* resolver.prepare(routeEntry(entry, route));
180 yield* reportProgress({ stage: "opening", prepared });
181 const session = yield* sessions.connect(prepared);
182 yield* reportProgress({ stage: "synchronizing", prepared });
183 yield* session.ready;
184 return { prepared, session } satisfies EnvironmentConnectionLease;
185 }),
186 );
60187 });
61188
62 return ConnectionDriver.of({ connect });
189 const preflight = (entry: ConnectionCatalogEntry, route: ConnectionRoute) =>
190 checkRoute(entry, route).pipe(
191 Effect.flatMap((check) =>
192 check === "answered"
193 ? resolver.prepare(routeEntry(entry, route)).pipe(
194 Effect.as(true),
195 Effect.orElseSucceed(() => false),
196 )
197 : Effect.succeed(false),
198 ),
199 Effect.withSpan("ConnectionDriver.preflight"),
200 );
201
202 return ConnectionDriver.of({ connect, checkRoute, preflight });
63203});
64204
65205export const layer = Layer.effect(ConnectionDriver, make);
packages/client-runtime/src/connection/githubRoutingPermissions.ts+18-2
......@@ -19,9 +19,25 @@ export const StoredGitHubRoutingPermission = Schema.Struct({
1919});
2020export type StoredGitHubRoutingPermission = typeof StoredGitHubRoutingPermission.Type;
2121
22/** Trust belongs to the saved endpoint, never to an environment id advertised by a server alone. */
22/**
23 * Trust belongs to the saved endpoints, never to an environment id advertised
24 * by a server alone. With several routes the key covers all of them, sorted so
25 * reordering keeps trust but adding or changing an address revokes it.
26 */
2327export function gitHubRoutingConnectionKey(entry: ConnectionCatalogEntry): string | null {
24 const target = entry.target;
28 const alternates = entry.alternateRoutes ?? [];
29 if (alternates.length === 0) return routeConnectionKey(entry.target, entry.profile);
30 const keys = [{ target: entry.target, profile: entry.profile }, ...alternates].map((route) =>
31 routeConnectionKey(route.target, route.profile),
32 );
33 return keys.every((key) => key !== null) ? JSON.stringify([...keys].sort()) : null;
34}
35
36function routeConnectionKey(
37 target: ConnectionCatalogEntry["target"],
38 routeProfile: ConnectionCatalogEntry["profile"],
39): string | null {
40 const entry = { profile: routeProfile };
2541 if (target._tag === "RelayConnectionTarget")
2642 return JSON.stringify([target._tag, target.environmentId]);
2743 const profile = Option.getOrNull(entry.profile);
packages/client-runtime/src/connection/index.ts+2
......@@ -9,6 +9,8 @@ export * from "./model.ts";
99export * as ConnectionOnboarding from "./onboarding.ts";
1010export * from "./presentation.ts";
1111export * as ProfileStore from "./profileStore.ts";
12export * from "./routes.ts";
13export { type RouteCheck } from "./driver.ts";
1214export * as EnvironmentRegistry from "./registry.ts";
1315// Flat so consumers' inferred types can name them.
1416export { EnvironmentNotRegisteredError, PlatformEnvironmentRemovalError } from "./registry.ts";
packages/client-runtime/src/connection/layer.ts+8-1
......@@ -10,6 +10,7 @@ import * as ConnectionResolver from "./resolver.ts";
1010import * as ConnectionDriver from "./driver.ts";
1111import * as EnvironmentRegistry from "./registry.ts";
1212import * as ConnectionOnboarding from "./onboarding.ts";
13import { connectionRoutes, hasRelayRoute } from "./routes.ts";
1314import * as PlatformConnectionSource from "../platform/source.ts";
1415import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";
1516import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
......@@ -43,7 +44,13 @@ export const watchDiscoveredCompatibility = Effect.fn("connection.watchDiscovere
4344 // a different server with the same environment id, such as a
4445 // preview app that shares the home directory. Its socket handshake
4546 // already checks the protocol.
46 if (registered.get(environmentId)?.target._tag !== "RelayConnectionTarget") continue;
47 const saved = registered.get(environmentId);
48 if (
49 saved === undefined ||
50 connectionRoutes(saved).length !== 1 ||
51 !hasRelayRoute(saved)
52 )
53 continue;
4754 const previous = seenChecks.get(environmentId);
4855 const fresh =
4956 previous?.checkedAt !== status.checkedAt ||
packages/client-runtime/src/connection/onboarding.test.ts+23-4
......@@ -98,12 +98,12 @@ describe("connection onboarding", () => {
9898 target: {
9999 environmentId: "environment-paired",
100100 label: "Paired environment",
101 connectionId: "bearer:environment-paired",
101 connectionId: "bearer:environment-paired:https://remote.example.test",
102102 },
103103 profile: {
104104 environmentId: "environment-paired",
105105 label: "Paired environment",
106 connectionId: "bearer:environment-paired",
106 connectionId: "bearer:environment-paired:https://remote.example.test",
107107 httpBaseUrl: "https://remote.example.test/",
108108 wsBaseUrl: "wss://remote.example.test/",
109109 },
......@@ -150,6 +150,25 @@ describe("connection onboarding", () => {
150150 }),
151151 );
152152
153 it.effect("refuses to add a route that reaches a different machine, keeping the code", () =>
154 Effect.gen(function* () {
155 const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];
156 const error = yield* preparePairingRegistration({
157 host: "remote.example.test",
158 pairingCode: "pairing-token",
159 expectedEnvironmentId: EnvironmentId.make("some-other-machine"),
160 }).pipe(
161 Effect.provide(Layer.mergeAll(CLIENT_PRESENTATION_LAYER, pairingHttpLayer(calls))),
162 Effect.flip,
163 );
164 expect(error).toMatchObject({ reason: "configuration" });
165 expect(error.message).toContain("different machine");
166 expect(calls.map((call) => call.url)).toEqual([
167 "https://remote.example.test/.well-known/t3/environment",
168 ]);
169 }),
170 );
171
153172 it.effect("pairs an outdated server so it can be updated from this client", () =>
154173 Effect.gen(function* () {
155174 const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];
......@@ -321,12 +340,12 @@ describe("connection onboarding", () => {
321340 target: {
322341 environmentId: "environment-ssh",
323342 label: "Remote development box",
324 connectionId: "ssh:environment-ssh",
343 connectionId: 'ssh:environment-ssh:["devbox","devbox.example.test","developer",22]',
325344 },
326345 profile: {
327346 environmentId: "environment-ssh",
328347 label: "Remote development box",
329 connectionId: "ssh:environment-ssh",
348 connectionId: 'ssh:environment-ssh:["devbox","devbox.example.test","developer",22]',
330349 target,
331350 },
332351 });
packages/client-runtime/src/connection/onboarding.ts+51-4
......@@ -32,16 +32,24 @@ import {
3232import * as Persistence from "../platform/persistence.ts";
3333import * as EnvironmentRegistry from "./registry.ts";
3434import { orchestrationProtocolCompatibilityError } from "./compatibility.ts";
35import { connectionRoutes, routeEntry, sshTargetKey } from "./routes.ts";
3536
3637export interface PairingConnectionInput {
3738 readonly pairingUrl?: string;
3839 readonly host?: string;
3940 readonly pairingCode?: string;
41 /**
42 * Set when adding a route to a saved machine: the pairing must reach this
43 * environment, or nothing is saved.
44 */
45 readonly expectedEnvironmentId?: EnvironmentId;
4046}
4147
4248export interface SshConnectionInput {
4349 readonly target: DesktopSshEnvironmentTarget;
4450 readonly label?: string;
51 /** Set when adding a route to a saved machine; see `PairingConnectionInput`. */
52 readonly expectedEnvironmentId?: EnvironmentId;
4553}
4654
4755export interface BearerConnectionUpdateInput {
......@@ -84,6 +92,22 @@ const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.reso
8492 },
8593);
8694
95/**
96 * One bearer route per address, so pairing over Tailscale adds a route next
97 * to the LAN one instead of replacing it. Pairing the same address again
98 * reuses the id and replaces that route.
99 */
100function bearerConnectionId(environmentId: EnvironmentId, httpBaseUrl: string): string {
101 return `bearer:${environmentId}:${new URL(httpBaseUrl).origin}`;
102}
103
104function differentMachineError(label: string) {
105 return new ConnectionBlockedError({
106 reason: "configuration",
107 detail: `That address reaches ${label}, a different machine. Add it as its own environment instead.`,
108 });
109}
110
87111export const preparePairingRegistration = Effect.fn(
88112 "clientRuntime.connection.onboarding.preparePairingRegistration",
89113)(function* (input: PairingConnectionInput) {
......@@ -92,6 +116,13 @@ export const preparePairingRegistration = Effect.fn(
92116 const descriptor = yield* fetchRemoteEnvironmentDescriptor({
93117 httpBaseUrl: target.httpBaseUrl,
94118 }).pipe(Effect.mapError(mapRemoteEnvironmentError));
119 // Checked before redeeming the one-time code, so a wrong link is not spent.
120 if (
121 input.expectedEnvironmentId !== undefined &&
122 descriptor.environmentId !== input.expectedEnvironmentId
123 ) {
124 return yield* differentMachineError(descriptor.label);
125 }
95126 const compatibilityError = orchestrationProtocolCompatibilityError(descriptor);
96127 // An outdated server is still saved so it can be updated from this client.
97128 if (compatibilityError !== null && compatibilityError.serverUpdateRequired !== true) {
......@@ -103,7 +134,7 @@ export const preparePairingRegistration = Effect.fn(
103134 scopes: presentation.scopes,
104135 clientMetadata: presentation.metadata,
105136 }).pipe(Effect.mapError(mapRemoteEnvironmentError));
106 const connectionId = `bearer:${descriptor.environmentId}`;
137 const connectionId = bearerConnectionId(descriptor.environmentId, target.httpBaseUrl);
107138
108139 return new BearerConnectionRegistration({
109140 target: new BearerConnectionTarget({
......@@ -141,7 +172,15 @@ const updateBearerConnection = Effect.fn(
141172)(function* (input: BearerConnectionUpdateInput) {
142173 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
143174 const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore;
144 const entry = (yield* SubscriptionRef.get(registry.entries)).get(input.environmentId);
175 const saved = (yield* SubscriptionRef.get(registry.entries)).get(input.environmentId);
176 // Editing changes the environment's first direct route; others stay as saved.
177 const route =
178 saved === undefined
179 ? undefined
180 : connectionRoutes(saved).find(
181 (candidate) => candidate.target._tag === "BearerConnectionTarget",
182 );
183 const entry = saved === undefined || route === undefined ? saved : routeEntry(saved, route);
145184 const credential =
146185 entry?.target._tag === "BearerConnectionTarget"
147186 ? yield* credentials.get(entry.target.connectionId)
......@@ -220,8 +259,10 @@ export const prepareSshRegistration = Effect.fn(
220259 "clientRuntime.connection.onboarding.prepareSshRegistration",
221260)(function* (input: SshConnectionInput) {
222261 const gateway = yield* ClientCapabilities.SshEnvironmentGateway;
223 const provisioned = yield* gateway.provision(input.target);
224 const connectionId = `ssh:${provisioned.environmentId}`;
262 const provisioned = yield* gateway.provision(input.target, input.expectedEnvironmentId);
263 // One id per SSH target, so a second host or alias for the same machine
264 // adds a route instead of replacing the first.
265 const connectionId = `ssh:${provisioned.environmentId}:${sshTargetKey(provisioned.bootstrap.target)}`;
225266 const label = input.label?.trim() || provisioned.label || provisioned.bootstrap.target.alias;
226267
227268 return new SshConnectionRegistration({
......@@ -243,6 +284,12 @@ const registerSshConnection = Effect.fn(
243284 "clientRuntime.connection.onboarding.registerSshConnection",
244285)(function* (input: SshConnectionInput) {
245286 const registration = yield* prepareSshRegistration(input);
287 if (
288 input.expectedEnvironmentId !== undefined &&
289 registration.target.environmentId !== input.expectedEnvironmentId
290 ) {
291 return yield* differentMachineError(registration.target.label);
292 }
246293 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
247294 yield* registry.register(registration);
248295 return registration.target.environmentId;
packages/client-runtime/src/connection/outdatedHostUpdate.ts+24-5
......@@ -26,6 +26,7 @@ import { isLegacyUpdateHandoffLoss, resolveServerUpdateProgressResult } from "..
2626import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";
2727import * as ConnectionResolver from "./resolver.ts";
2828import * as EnvironmentRegistry from "./registry.ts";
29import { connectionRoutes, hasRelayRoute, routeEntry, routeHttpBaseUrl } from "./routes.ts";
2930
3031// A v1 host restarting into v2 runs migrations before its descriptor answers again.
3132const OUTDATED_HOST_RESTART_TIMEOUT = Duration.minutes(4);
......@@ -63,7 +64,11 @@ export const updateOutdatedHost = Effect.fn("clientRuntime.connection.updateOutd
6364 if (entry === undefined) {
6465 return yield* new EnvironmentRegistry.EnvironmentNotRegisteredError({ environmentId });
6566 }
66 const { prepared, descriptor } = yield* resolver.prepareForUpdate(entry);
67 // An outdated server cannot connect normally, so the routes are tried in
68 // order here. The first that authorizes carries the update.
69 const { prepared, descriptor } = yield* Effect.firstSuccessOf(
70 connectionRoutes(entry).map((route) => resolver.prepareForUpdate(routeEntry(entry, route))),
71 );
6772 const capabilities = descriptor.capabilities;
6873 if (
6974 capabilities.serverSelfUpdate === undefined ||
......@@ -154,9 +159,23 @@ export const updateOutdatedHost = Effect.fn("clientRuntime.connection.updateOutd
154159 );
155160
156161 yield* onStage("resuming");
157 const resumed = yield* fetchRemoteEnvironmentDescriptor({
158 httpBaseUrl: prepared.httpBaseUrl,
159 }).pipe(
162 // The restarted host may answer on another saved route, so each poll asks
163 // every direct address, the one that carried the update first.
164 const pollUrls = [
165 prepared.httpBaseUrl,
166 ...connectionRoutes(entry).flatMap((route) => routeHttpBaseUrl(route) ?? []),
167 ].filter((url, index, all) => all.indexOf(url) === index);
168 const resumed = yield* Effect.firstSuccessOf(
169 pollUrls.map((httpBaseUrl) =>
170 fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe(
171 Effect.filterOrFail(
172 (descriptor) =>
173 descriptor.environmentId === environmentId && isCompatibleDescriptor(descriptor),
174 () => "not-ready" as const,
175 ),
176 ),
177 ),
178 ).pipe(
160179 Effect.provideService(HttpClient.HttpClient, httpClient),
161180 Effect.option,
162181 Effect.repeat({
......@@ -175,7 +194,7 @@ export const updateOutdatedHost = Effect.fn("clientRuntime.connection.updateOutd
175194
176195 // Discovery still holds the old relay descriptor and would re-block the
177196 // environment from it, so replace that before clearing the block.
178 if (entry.target._tag === "RelayConnectionTarget") {
197 if (hasRelayRoute(entry)) {
179198 const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
180199 yield* discovery.refresh;
181200 }
packages/client-runtime/src/connection/registry.test.ts+365-72
......@@ -26,16 +26,21 @@ import {
2626 BearerConnectionCredential,
2727 BearerConnectionProfile,
2828 BearerConnectionRegistration,
29 type ConnectionCatalogEntry,
2930 type ConnectionRegistration,
31 type ConnectionRoute,
3032 PrimaryConnectionRegistration,
3133 RelayConnectionRegistration,
3234 SshConnectionProfile,
35 SshConnectionRegistration,
3336 type ConnectionCredential,
3437 type ConnectionProfile,
3538} from "./catalog.ts";
3639import * as Connectivity from "./connectivity.ts";
3740import * as ConnectionCredentialStore from "./credentialStore.ts";
3841import * as ConnectionDriver from "./driver.ts";
42import type { RouteCheck } from "./driver.ts";
43import { connectionRouteId } from "./routes.ts";
3944import {
4045 ConnectionTransientError,
4146 ConnectionBlockedError,
......@@ -52,6 +57,7 @@ import * as ConnectionProfileStore from "./profileStore.ts";
5257import * as EnvironmentRegistry from "./registry.ts";
5358import {
5459 GitHubRoutingPermissions,
60 type StoredGitHubRoutingPermission,
5561 makeGitHubRoutingPermissions,
5662} from "./githubRoutingPermissions.ts";
5763import * as RpcSession from "../rpc/session.ts";
......@@ -148,14 +154,14 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
148154 registration: ConnectionRegistration,
149155 ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>;
150156 readonly beforeRegistrationRemove?: (
151 target: ConnectionTarget,
157 environmentId: EnvironmentId,
152158 ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>;
159 readonly checkRoute?: (route: ConnectionRoute) => RouteCheck;
160 readonly prepareRoute?: (target: ConnectionTarget) => ConnectionBlockedError | undefined;
153161 readonly initialDisabled?: ReadonlyArray<EnvironmentId>;
154162 },
155163) {
156 const storedTargets = yield* Ref.make(
157 new Map(initialTargets.map((target) => [target.environmentId, target])),
158 );
164 const storedTargets = yield* Ref.make<ReadonlyArray<ConnectionTarget>>(initialTargets);
159165 const shellCache = yield* Ref.make(new Map([[TARGET.environmentId, CACHED_SNAPSHOT]]));
160166 const cacheClears = yield* Ref.make<ReadonlyArray<EnvironmentId>>([]);
161167 const ownedDataClears = yield* Ref.make<ReadonlyArray<EnvironmentId>>([]);
......@@ -191,18 +197,16 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
191197 new Set(options?.initialDisabled ?? []),
192198 );
193199 const targetStore = Persistence.ConnectionTargetStore.of({
194 list: Ref.get(storedTargets).pipe(Effect.map((targets) => [...targets.values()])),
200 list: Ref.get(storedTargets),
195201 listDisabled: Ref.get(storedDisabled).pipe(Effect.map((ids) => [...ids])),
196202 });
197203 const registrationStore = Persistence.ConnectionRegistrationStore.of({
198 register: (registration) =>
204 register: (registration, routes) =>
199205 Effect.gen(function* () {
200206 yield* options?.beforeRegistrationRegister?.(registration) ?? Effect.void;
201 yield* Ref.update(storedTargets, (current) => {
202 const next = new Map(current);
203 next.set(registration.target.environmentId, registration.target);
204 return next;
205 });
207 yield* Ref.update(storedTargets, (current) =>
208 replaceRoutes(current, registration.target.environmentId, routes),
209 );
206210 switch (registration._tag) {
207211 case "RelayConnectionRegistration":
208212 return;
......@@ -226,29 +230,34 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
226230 });
227231 }
228232 }),
229 remove: (target) =>
233 setRoutes: (environmentId, routes) =>
234 Ref.update(storedTargets, (current) => replaceRoutes(current, environmentId, routes)),
235 remove: (environmentId) =>
230236 Effect.gen(function* () {
231 yield* options?.beforeRegistrationRemove?.(target) ?? Effect.void;
232 yield* Ref.update(storedTargets, (current) => {
233 const next = new Map(current);
234 next.delete(target.environmentId);
235 return next;
236 });
237 if (target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget") {
238 yield* Ref.update(storedProfiles, (current) => {
239 const next = new Map(current);
240 next.delete(target.connectionId);
241 return next;
242 });
243 yield* Ref.update(storedCredentials, (current) => {
244 const next = new Map(current);
245 next.delete(target.connectionId);
246 return next;
247 });
237 yield* options?.beforeRegistrationRemove?.(environmentId) ?? Effect.void;
238 const removed = (yield* Ref.get(storedTargets)).filter(
239 (target) => target.environmentId === environmentId,
240 );
241 yield* Ref.update(storedTargets, (current) =>
242 current.filter((target) => target.environmentId !== environmentId),
243 );
244 for (const target of removed) {
245 if (target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget") {
246 yield* Ref.update(storedProfiles, (current) => {
247 const next = new Map(current);
248 next.delete(target.connectionId);
249 return next;
250 });
251 yield* Ref.update(storedCredentials, (current) => {
252 const next = new Map(current);
253 next.delete(target.connectionId);
254 return next;
255 });
256 }
248257 }
249258 yield* Ref.update(storedRemoteTokens, (current) => {
250259 const next = new Map(current);
251 next.delete(target.environmentId);
260 next.delete(environmentId);
252261 return next;
253262 });
254263 }),
......@@ -363,38 +372,52 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
363372 prepare: () => Effect.die(new Error("SSH preparation is not used.")),
364373 disconnect: (target) => Ref.update(disconnectedSshTargets, (current) => [...current, target]),
365374 });
375 const connectedRoutes = yield* Ref.make<ReadonlyArray<string>>([]);
376 const checkRoute = (route: ConnectionRoute) =>
377 Effect.succeed(options?.checkRoute?.(route) ?? "unchecked");
378 const connectRoute = (entry: ConnectionCatalogEntry, route: ConnectionRoute) =>
379 Effect.gen(function* () {
380 const target = route.target;
381 const prepared = {
382 ...PREPARED,
383 environmentId: target.environmentId,
384 label: target.label,
385 target,
386 };
387 if (options?.prepareError) return yield* options.prepareError;
388 const routeError = options?.prepareRoute?.(target);
389 if (routeError !== undefined) return yield* routeError;
390 yield* Ref.update(connectedRoutes, (current) => [...current, connectionRouteId(target)]);
391 yield* options?.beforeSessionConnect?.(target.environmentId) ?? Effect.void;
392 const closed = yield* Deferred.make<never, ConnectionTransientError>();
393 yield* Ref.update(sessions, (current) => [...current, { closed }]);
394 const session = yield* Effect.acquireRelease(
395 Effect.succeed({
396 client: {} as RpcSession.RpcSession["client"],
397 initialConfig: Effect.die(new Error("Config is not used by registry tests.")),
398 subscribeServerConfig: () =>
399 Stream.die(new Error("Config is not used by registry tests.")),
400 ready: Effect.void,
401 probe: Effect.void,
402 closed: Deferred.await(closed),
403 } satisfies RpcSession.RpcSession),
404 () => Ref.update(releasedSessions, (count) => count + 1),
405 );
406 yield* session.ready;
407 return { prepared, session };
408 });
366409 const driver = ConnectionDriver.ConnectionDriver.of({
367410 connect: (entry, reportProgress) =>
368 Effect.gen(function* () {
369 const target = entry.target;
370 const prepared = {
371 ...PREPARED,
372 environmentId: target.environmentId,
373 label: target.label,
374 target,
375 };
376 yield* reportProgress({ stage: "preparing" });
377 if (options?.prepareError) return yield* options.prepareError;
378 yield* reportProgress({ stage: "opening", prepared });
379 yield* options?.beforeSessionConnect?.(target.environmentId) ?? Effect.void;
380 const closed = yield* Deferred.make<never, ConnectionTransientError>();
381 yield* Ref.update(sessions, (current) => [...current, { closed }]);
382 const session = yield* Effect.acquireRelease(
383 Effect.succeed({
384 client: {} as RpcSession.RpcSession["client"],
385 initialConfig: Effect.die(new Error("Config is not used by registry tests.")),
386 subscribeServerConfig: () =>
387 Stream.die(new Error("Config is not used by registry tests.")),
388 ready: Effect.void,
389 probe: Effect.void,
390 closed: Deferred.await(closed),
391 } satisfies RpcSession.RpcSession),
392 () => Ref.update(releasedSessions, (count) => count + 1),
393 );
394 yield* reportProgress({ stage: "synchronizing", prepared });
395 yield* session.ready;
396 return { prepared, session };
397 }),
411 reportProgress({ stage: "preparing" }).pipe(
412 Effect.andThen(
413 ConnectionDriver.connectOverRoutes(entry, checkRoute, (route) =>
414 connectRoute(entry, route),
415 ),
416 ),
417 ),
418 checkRoute: (_entry, route) => checkRoute(route),
419 preflight: (_entry, route) =>
420 checkRoute(route).pipe(Effect.map((check) => check === "answered")),
398421 });
399422
400423 const cacheLayer = Layer.succeed(Persistence.EnvironmentCacheStore, cacheStore);
......@@ -434,9 +457,33 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
434457 storedDisabled,
435458 disconnectedSshTargets,
436459 networkStatus,
460 connectedRoutes,
437461 };
438462});
439463
464function routesOf(targets: ReadonlyArray<ConnectionTarget>, environmentId: EnvironmentId) {
465 return targets.filter((target) => target.environmentId === environmentId);
466}
467
468function hasRoutes(targets: ReadonlyArray<ConnectionTarget>, environmentId: EnvironmentId) {
469 return routesOf(targets, environmentId).length > 0;
470}
471
472function replaceRoutes(
473 current: ReadonlyArray<ConnectionTarget>,
474 environmentId: EnvironmentId,
475 routes: ReadonlyArray<ConnectionTarget>,
476): ReadonlyArray<ConnectionTarget> {
477 const firstIndex = current.findIndex((target) => target.environmentId === environmentId);
478 const others = current.filter((target) => target.environmentId !== environmentId);
479 const insertAt =
480 firstIndex === -1
481 ? others.length
482 : current.slice(0, firstIndex).filter((target) => target.environmentId !== environmentId)
483 .length;
484 return [...others.slice(0, insertAt), ...routes, ...others.slice(insertAt)];
485}
486
440487function awaitConnectionState(
441488 registry: EnvironmentRegistry.EnvironmentRegistry["Service"],
442489 environmentId: EnvironmentId,
......@@ -649,7 +696,7 @@ describe("EnvironmentRegistry", () => {
649696 );
650697
651698 yield* registry.remove(TARGET.environmentId);
652 expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(false);
699 expect(hasRoutes(yield* Ref.get(harness.storedTargets), TARGET.environmentId)).toBe(false);
653700 expect((yield* Ref.get(harness.shellCache)).has(TARGET.environmentId)).toBe(false);
654701 expect(yield* Ref.get(harness.cacheClears)).toEqual([TARGET.environmentId]);
655702 expect((yield* SubscriptionRef.get(registry.entries)).has(TARGET.environmentId)).toBe(
......@@ -672,8 +719,8 @@ describe("EnvironmentRegistry", () => {
672719 (state) => state.phase === "connected",
673720 );
674721
675 expect((yield* Ref.get(harness.storedTargets)).get(RELAY_TARGET.environmentId)).toEqual(
676 RELAY_TARGET,
722 expect(routesOf(yield* Ref.get(harness.storedTargets), RELAY_TARGET.environmentId)).toEqual(
723 [RELAY_TARGET],
677724 );
678725 expect(yield* Ref.get(harness.sessions)).toHaveLength(1);
679726 }).pipe(Effect.provide(harness.layer));
......@@ -997,7 +1044,9 @@ describe("EnvironmentRegistry", () => {
9971044 );
9981045 expect(entry?.enabled).toBe(false);
9991046 expect((yield* Ref.get(harness.storedDisabled)).has(RELAY_TARGET.environmentId)).toBe(true);
1000 expect((yield* Ref.get(harness.storedTargets)).has(RELAY_TARGET.environmentId)).toBe(true);
1047 expect(hasRoutes(yield* Ref.get(harness.storedTargets), RELAY_TARGET.environmentId)).toBe(
1048 true,
1049 );
10011050 expect(yield* Ref.get(harness.releasedSessions)).toBe(1);
10021051
10031052 yield* registry.setEnabled(RELAY_TARGET.environmentId, true);
......@@ -1060,7 +1109,7 @@ describe("EnvironmentRegistry", () => {
10601109 );
10611110
10621111 expect(yield* Ref.get(harness.disconnectedSshTargets)).toEqual([SSH_TARGET]);
1063 expect((yield* Ref.get(harness.storedTargets)).has(SSH_CONNECTION.environmentId)).toBe(
1112 expect(hasRoutes(yield* Ref.get(harness.storedTargets), SSH_CONNECTION.environmentId)).toBe(
10641113 true,
10651114 );
10661115 }).pipe(Effect.provide(harness.layer));
......@@ -1168,9 +1217,9 @@ describe("EnvironmentRegistry", () => {
11681217 yield* registry.removeRelayEnvironments();
11691218
11701219 const targets = yield* Ref.get(harness.storedTargets);
1171 expect(targets.has(RELAY_TARGET.environmentId)).toBe(false);
1172 expect(targets.has(SECOND_RELAY_TARGET.environmentId)).toBe(false);
1173 expect(targets.get(BEARER_TARGET.environmentId)).toEqual(BEARER_TARGET);
1220 expect(hasRoutes(targets, RELAY_TARGET.environmentId)).toBe(false);
1221 expect(hasRoutes(targets, SECOND_RELAY_TARGET.environmentId)).toBe(false);
1222 expect(routesOf(targets, BEARER_TARGET.environmentId)).toEqual([BEARER_TARGET]);
11741223 expect(yield* Ref.get(harness.cacheClears)).toEqual(
11751224 expect.arrayContaining([RELAY_TARGET.environmentId, SECOND_RELAY_TARGET.environmentId]),
11761225 );
......@@ -1212,7 +1261,9 @@ describe("EnvironmentRegistry", () => {
12121261 expect((yield* SubscriptionRef.get(registry.entries)).has(RELAY_TARGET.environmentId)).toBe(
12131262 true,
12141263 );
1215 expect((yield* Ref.get(harness.storedTargets)).has(RELAY_TARGET.environmentId)).toBe(true);
1264 expect(hasRoutes(yield* Ref.get(harness.storedTargets), RELAY_TARGET.environmentId)).toBe(
1265 true,
1266 );
12161267 expect(yield* Ref.get(harness.cacheClears)).toEqual([]);
12171268 expect(yield* Ref.get(harness.ownedDataClears)).toEqual([]);
12181269 }).pipe(Effect.provide(harness.layer), Effect.scoped);
......@@ -1262,7 +1313,7 @@ describe("EnvironmentRegistry", () => {
12621313 (state) => state.phase === "connected",
12631314 );
12641315
1265 expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(false);
1316 expect(hasRoutes(yield* Ref.get(harness.storedTargets), TARGET.environmentId)).toBe(false);
12661317 expect(
12671318 (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target,
12681319 ).toEqual(TARGET);
......@@ -1298,14 +1349,14 @@ describe("EnvironmentRegistry", () => {
12981349 expect(
12991350 (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target,
13001351 ).toEqual(TARGET);
1301 expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(false);
1352 expect(hasRoutes(yield* Ref.get(harness.storedTargets), TARGET.environmentId)).toBe(false);
13021353
13031354 yield* registry.register(new RelayConnectionRegistration({ target: shadowedTarget }));
13041355
13051356 expect(
13061357 (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target,
13071358 ).toEqual(TARGET);
1308 expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(false);
1359 expect(hasRoutes(yield* Ref.get(harness.storedTargets), TARGET.environmentId)).toBe(false);
13091360 }).pipe(
13101361 Effect.provide(harness.layer),
13111362 Effect.provideService(GitHubRoutingPermissions, permissions),
......@@ -1582,3 +1633,245 @@ describe("EnvironmentRegistry", () => {
15821633 }),
15831634 );
15841635});
1636
1637describe("EnvironmentRegistry routes", () => {
1638 // The relay environment, reachable on the LAN as well.
1639 const LAN_TARGET = new BearerConnectionTarget({
1640 environmentId: RELAY_TARGET.environmentId,
1641 label: RELAY_TARGET.label,
1642 connectionId: "bearer:lan",
1643 });
1644 const LAN_PROFILE = new BearerConnectionProfile({
1645 connectionId: LAN_TARGET.connectionId,
1646 environmentId: LAN_TARGET.environmentId,
1647 label: LAN_TARGET.label,
1648 httpBaseUrl: "http://192.168.1.10:3773/",
1649 wsBaseUrl: "ws://192.168.1.10:3773/",
1650 });
1651 const lanRegistration = (target = LAN_TARGET, profile = LAN_PROFILE) =>
1652 new BearerConnectionRegistration({ target, profile, credential: BEARER_CREDENTIAL });
1653
1654 it.effect("adds a paired LAN route ahead of T3 Connect instead of replacing it", () =>
1655 Effect.gen(function* () {
1656 const harness = yield* makeHarness([RELAY_TARGET]);
1657 yield* Effect.gen(function* () {
1658 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
1659 yield* registry.register(lanRegistration());
1660
1661 expect(routesOf(yield* Ref.get(harness.storedTargets), LAN_TARGET.environmentId)).toEqual([
1662 LAN_TARGET,
1663 RELAY_TARGET,
1664 ]);
1665 const entry = (yield* SubscriptionRef.get(registry.entries)).get(LAN_TARGET.environmentId);
1666 expect(entry?.target).toEqual(LAN_TARGET);
1667 expect(entry?.alternateRoutes?.map((route) => route.target)).toEqual([RELAY_TARGET]);
1668 }).pipe(Effect.provide(harness.layer), Effect.scoped);
1669 }),
1670 );
1671
1672 it.effect("re-pairing the same address replaces that route rather than adding another", () =>
1673 Effect.gen(function* () {
1674 const harness = yield* makeHarness(
1675 [LAN_TARGET, RELAY_TARGET],
1676 [LAN_PROFILE],
1677 [[LAN_TARGET.connectionId, BEARER_CREDENTIAL]],
1678 );
1679 yield* Effect.gen(function* () {
1680 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
1681 const repaired = new BearerConnectionTarget({
1682 ...LAN_TARGET,
1683 connectionId: "bearer:lan-again",
1684 });
1685 yield* registry.register(
1686 lanRegistration(
1687 repaired,
1688 new BearerConnectionProfile({ ...LAN_PROFILE, connectionId: repaired.connectionId }),
1689 ),
1690 );
1691
1692 expect(routesOf(yield* Ref.get(harness.storedTargets), LAN_TARGET.environmentId)).toEqual([
1693 repaired,
1694 RELAY_TARGET,
1695 ]);
1696 }).pipe(Effect.provide(harness.layer), Effect.scoped);
1697 }),
1698 );
1699
1700 it.effect("a second SSH host for the same machine adds a route beside the first", () =>
1701 Effect.gen(function* () {
1702 const harness = yield* makeHarness([SSH_CONNECTION], [SSH_PROFILE]);
1703 yield* Effect.gen(function* () {
1704 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
1705 const other = new SshConnectionTarget({
1706 ...SSH_CONNECTION,
1707 connectionId: "ssh-connection-other-host",
1708 });
1709 yield* registry.register(
1710 new SshConnectionRegistration({
1711 target: other,
1712 profile: new SshConnectionProfile({
1713 ...SSH_PROFILE,
1714 connectionId: other.connectionId,
1715 target: { ...SSH_TARGET, alias: "other", hostname: "other.example.test" },
1716 }),
1717 }),
1718 );
1719
1720 expect(
1721 routesOf(yield* Ref.get(harness.storedTargets), SSH_CONNECTION.environmentId),
1722 ).toEqual([SSH_CONNECTION, other]);
1723 }).pipe(Effect.provide(harness.layer), Effect.scoped);
1724 }),
1725 );
1726
1727 it.effect(
1728 "adding a saved SSH host again replaces its route, whatever id it was saved under",
1729 () =>
1730 Effect.gen(function* () {
1731 const harness = yield* makeHarness([SSH_CONNECTION], [SSH_PROFILE]);
1732 yield* Effect.gen(function* () {
1733 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
1734 const again = new SshConnectionTarget({
1735 ...SSH_CONNECTION,
1736 connectionId: "ssh-connection-new-id",
1737 });
1738 yield* registry.register(
1739 new SshConnectionRegistration({
1740 target: again,
1741 profile: new SshConnectionProfile({
1742 ...SSH_PROFILE,
1743 connectionId: again.connectionId,
1744 }),
1745 }),
1746 );
1747
1748 expect(
1749 routesOf(yield* Ref.get(harness.storedTargets), SSH_CONNECTION.environmentId),
1750 ).toEqual([again]);
1751 }).pipe(Effect.provide(harness.layer), Effect.scoped);
1752 }),
1753 );
1754
1755 it.effect("signing out of T3 Connect keeps an environment that still has a LAN route", () =>
1756 Effect.gen(function* () {
1757 const harness = yield* makeHarness(
1758 [LAN_TARGET, RELAY_TARGET, SECOND_RELAY_TARGET],
1759 [LAN_PROFILE],
1760 [[LAN_TARGET.connectionId, BEARER_CREDENTIAL]],
1761 );
1762 yield* Effect.gen(function* () {
1763 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
1764 yield* registry.removeRelayEnvironments();
1765
1766 const targets = yield* Ref.get(harness.storedTargets);
1767 expect(routesOf(targets, LAN_TARGET.environmentId)).toEqual([LAN_TARGET]);
1768 expect(hasRoutes(targets, SECOND_RELAY_TARGET.environmentId)).toBe(false);
1769 // Only the environment that lost its last route is forgotten.
1770 expect(yield* Ref.get(harness.cacheClears)).toEqual([SECOND_RELAY_TARGET.environmentId]);
1771 expect(
1772 (yield* SubscriptionRef.get(registry.entries)).get(LAN_TARGET.environmentId)?.target,
1773 ).toEqual(LAN_TARGET);
1774 }).pipe(Effect.provide(harness.layer), Effect.scoped);
1775 }),
1776 );
1777
1778 it.effect("reorders routes and rejects an order that drops one", () =>
1779 Effect.gen(function* () {
1780 const harness = yield* makeHarness(
1781 [LAN_TARGET, RELAY_TARGET],
1782 [LAN_PROFILE],
1783 [[LAN_TARGET.connectionId, BEARER_CREDENTIAL]],
1784 );
1785 yield* Effect.gen(function* () {
1786 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
1787 yield* registry.reorderRoutes(LAN_TARGET.environmentId, ["relay", LAN_TARGET.connectionId]);
1788 expect(routesOf(yield* Ref.get(harness.storedTargets), LAN_TARGET.environmentId)).toEqual([
1789 RELAY_TARGET,
1790 LAN_TARGET,
1791 ]);
1792
1793 const error = yield* registry
1794 .reorderRoutes(LAN_TARGET.environmentId, ["relay"])
1795 .pipe(Effect.flip);
1796 expect(error._tag).toBe("ConnectionBlockedError");
1797 }).pipe(Effect.provide(harness.layer), Effect.scoped);
1798 }),
1799 );
1800
1801 it.effect("removing the last route forgets the environment", () =>
1802 Effect.gen(function* () {
1803 const harness = yield* makeHarness(
1804 [LAN_TARGET, RELAY_TARGET],
1805 [LAN_PROFILE],
1806 [[LAN_TARGET.connectionId, BEARER_CREDENTIAL]],
1807 );
1808 yield* Effect.gen(function* () {
1809 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
1810 yield* registry.removeRoute(LAN_TARGET.environmentId, LAN_TARGET.connectionId);
1811 expect(routesOf(yield* Ref.get(harness.storedTargets), LAN_TARGET.environmentId)).toEqual([
1812 RELAY_TARGET,
1813 ]);
1814 expect(yield* Ref.get(harness.cacheClears)).toEqual([]);
1815
1816 yield* registry.removeRoute(LAN_TARGET.environmentId, "relay");
1817 expect(hasRoutes(yield* Ref.get(harness.storedTargets), LAN_TARGET.environmentId)).toBe(
1818 false,
1819 );
1820 expect(yield* Ref.get(harness.cacheClears)).toEqual([LAN_TARGET.environmentId]);
1821 }).pipe(Effect.provide(harness.layer), Effect.scoped);
1822 }),
1823 );
1824
1825 it.effect("revokes GitHub routing trust when a route is added but keeps it on reorder", () =>
1826 Effect.gen(function* () {
1827 const harness = yield* makeHarness([RELAY_TARGET]);
1828 let stored: ReadonlyArray<StoredGitHubRoutingPermission> = [];
1829 const permissions = yield* makeGitHubRoutingPermissions({
1830 read: Effect.sync(() => stored),
1831 write: (next) => Effect.sync(() => void (stored = next)),
1832 });
1833 yield* Effect.gen(function* () {
1834 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
1835 const current = () =>
1836 SubscriptionRef.get(registry.entries).pipe(
1837 Effect.map((entries) => entries.get(RELAY_TARGET.environmentId)!),
1838 );
1839 yield* permissions.set(yield* current(), "read");
1840 yield* registry.register(lanRegistration());
1841 expect(yield* permissions.get(yield* current())).toBe("off");
1842
1843 yield* permissions.set(yield* current(), "read");
1844 yield* registry.reorderRoutes(LAN_TARGET.environmentId, ["relay", LAN_TARGET.connectionId]);
1845 expect(yield* permissions.get(yield* current())).toBe("read");
1846 }).pipe(
1847 Effect.provide(harness.layer),
1848 Effect.provideService(GitHubRoutingPermissions, permissions),
1849 Effect.scoped,
1850 );
1851 }),
1852 );
1853
1854 it.effect("loads saved routes grouped by environment, in saved order", () =>
1855 Effect.gen(function* () {
1856 const harness = yield* makeHarness(
1857 [LAN_TARGET, BEARER_TARGET, RELAY_TARGET],
1858 [LAN_PROFILE, BEARER_PROFILE],
1859 [
1860 [LAN_TARGET.connectionId, BEARER_CREDENTIAL],
1861 [BEARER_TARGET.connectionId, BEARER_CREDENTIAL],
1862 ],
1863 );
1864 yield* Effect.gen(function* () {
1865 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
1866 const entries = yield* SubscriptionRef.get(registry.entries);
1867 expect([...entries.keys()]).toEqual([
1868 LAN_TARGET.environmentId,
1869 BEARER_TARGET.environmentId,
1870 ]);
1871 expect(entries.get(LAN_TARGET.environmentId)?.alternateRoutes?.[0]?.target).toEqual(
1872 RELAY_TARGET,
1873 );
1874 }).pipe(Effect.provide(harness.layer), Effect.scoped);
1875 }),
1876 );
1877});
packages/client-runtime/src/connection/registry.ts+268-125
......@@ -15,7 +15,9 @@ import * as SubscriptionRef from "effect/SubscriptionRef";
1515import * as ClientCapabilities from "../platform/capabilities.ts";
1616import {
1717 type ConnectionCatalogEntry,
18 type ConnectionProfile,
1819 type ConnectionRegistration,
20 type ConnectionRoute,
1921 type PlatformConnectionRegistration,
2022 type PrimaryConnectionRegistration,
2123 SshConnectionProfile,
......@@ -28,6 +30,7 @@ import type {
2830 ConnectionAttemptError,
2931 ConnectionTarget,
3032 NetworkStatus,
33 PersistedConnectionTarget,
3134 SupervisorConnectionState,
3235} from "./model.ts";
3336import { ConnectionBlockedError } from "./model.ts";
......@@ -39,6 +42,14 @@ import {
3942 GitHubRoutingPermissions,
4043 gitHubRoutingConnectionKey,
4144} from "./githubRoutingPermissions.ts";
45import {
46 RELAY_ROUTE_ID,
47 connectionRouteId,
48 connectionRoutes,
49 entryWithRoutes,
50 findRouteToSameAddress,
51 upsertRoute,
52} from "./routes.ts";
4253
4354const isSshConnectionProfile = Schema.is(SshConnectionProfile);
4455
......@@ -99,6 +110,35 @@ export class EnvironmentRegistry extends Context.Service<
99110 | EnvironmentNotRegisteredError
100111 | PlatformEnvironmentRemovalError
101112 >;
113 /**
114 * Drops one route. Removing an environment's last route removes the
115 * environment, the same as `remove`.
116 */
117 readonly removeRoute: (
118 environmentId: EnvironmentId,
119 routeId: string,
120 ) => Effect.Effect<
121 void,
122 | Persistence.ConnectionPersistenceError
123 | ConnectionAttemptError
124 | EnvironmentNotRegisteredError
125 | PlatformEnvironmentRemovalError
126 >;
127 /** Reorders an environment's routes; `routeIds` lists every route, preferred first. */
128 readonly reorderRoutes: (
129 environmentId: EnvironmentId,
130 routeIds: ReadonlyArray<string>,
131 ) => Effect.Effect<
132 void,
133 | Persistence.ConnectionPersistenceError
134 | EnvironmentNotRegisteredError
135 | PlatformEnvironmentRemovalError
136 | ConnectionBlockedError
137 >;
138 /**
139 * Drops the T3 Connect route of every environment, after a cloud sign-out
140 * or account change. Environments with no other route are removed.
141 */
102142 readonly removeRelayEnvironments: () => Effect.Effect<
103143 void,
104144 | Persistence.ConnectionPersistenceError
......@@ -175,21 +215,37 @@ export const make = Effect.gen(function* () {
175215 const ssh = yield* ClientCapabilities.SshEnvironmentGateway;
176216 const persistedTargets = yield* storage.list;
177217 const disabledEnvironmentIds = new Set(yield* storage.listDisabled);
218 const loadRoute = Effect.fn("EnvironmentRegistry.loadRoute")(function* (
219 target: ConnectionTarget,
220 ) {
221 const profile: Option.Option<ConnectionProfile> =
222 target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget"
223 ? yield* profiles.get(target.connectionId)
224 : Option.none();
225 return { target, profile } satisfies ConnectionRoute;
226 });
227 const persistedRoutesByEnvironment = new Map<EnvironmentId, Array<ConnectionTarget>>();
228 for (const target of persistedTargets) {
229 const routes = persistedRoutesByEnvironment.get(target.environmentId) ?? [];
230 routes.push(target);
231 persistedRoutesByEnvironment.set(target.environmentId, routes);
232 }
178233 const initialEntries = new Map(
179234 yield* Effect.forEach(
180 persistedTargets,
181 Effect.fn("EnvironmentRegistry.loadCatalogEntry")(function* (target) {
182 const profile =
183 target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget"
184 ? yield* profiles.get(target.connectionId)
185 : Option.none();
235 persistedRoutesByEnvironment,
236 Effect.fn("EnvironmentRegistry.loadCatalogEntry")(function* ([environmentId, targets]) {
237 const routes = yield* Effect.forEach(targets, loadRoute, { concurrency: "unbounded" });
238 const first = routes[0]!;
186239 return [
187 target.environmentId,
188 {
189 target,
190 profile,
191 enabled: !disabledEnvironmentIds.has(target.environmentId),
192 } satisfies ConnectionCatalogEntry,
240 environmentId,
241 entryWithRoutes(
242 {
243 target: first.target,
244 profile: first.profile,
245 enabled: !disabledEnvironmentIds.has(environmentId),
246 },
247 routes,
248 ),
193249 ] as const;
194250 }),
195251 { concurrency: "unbounded" },
......@@ -202,9 +258,9 @@ export const make = Effect.gen(function* () {
202258 ReadonlyMap<EnvironmentId, EnvironmentServiceScope>
203259 >(new Map());
204260 const platformEnvironmentIds = yield* Ref.make<ReadonlySet<EnvironmentId>>(new Set());
205 const persistedTargetsByEnvironment = yield* Ref.make<
206 ReadonlyMap<EnvironmentId, ConnectionTarget>
207 >(new Map(persistedTargets.map((target) => [target.environmentId, target])));
261 const persistedEnvironmentIds = yield* Ref.make<ReadonlySet<EnvironmentId>>(
262 new Set(persistedRoutesByEnvironment.keys()),
263 );
208264 interface LeaseLock {
209265 readonly semaphore: Semaphore.Semaphore;
210266 readonly users: number;
......@@ -407,9 +463,9 @@ export const make = Effect.gen(function* () {
407463 return;
408464 }
409465 yield* Effect.forEach(
410 persistedTargets,
411 (target) =>
412 acquireSupervisor(target.environmentId).pipe(
466 persistedRoutesByEnvironment.keys(),
467 (environmentId) =>
468 acquireSupervisor(environmentId).pipe(
413469 Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void),
414470 ),
415471 {
......@@ -445,6 +501,38 @@ export const make = Effect.gen(function* () {
445501 yield* createServiceScope(entry);
446502 });
447503
504 const forgetRoutingTrust = (
505 environmentId: EnvironmentId,
506 operation: "register-connection" | "set-connection-routes",
507 ) =>
508 githubRoutingPermissions.forget(environmentId).pipe(
509 Effect.mapError(
510 (error) =>
511 new Persistence.ConnectionPersistenceError({
512 operation,
513 message: error.message,
514 }),
515 ),
516 );
517
518 const persistedRoutes = (entry: ConnectionCatalogEntry) =>
519 connectionRoutes(entry).map((route) => route.target as PersistedConnectionTarget);
520
521 /**
522 * The entry after its routes change. GitHub trust and an unsupported
523 * verdict both belong to the saved addresses: a changed set may reach a
524 * different server, so both are dropped and the next connection decides.
525 * Reordering keeps the same addresses, so both stay.
526 */
527 const withRoutes = (previous: ConnectionCatalogEntry, routes: ReadonlyArray<ConnectionRoute>) => {
528 const next = entryWithRoutes(previous, routes);
529 if (gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(next)) {
530 return { entry: next, addressesChanged: false };
531 }
532 const { unsupportedReason: _reason, serverUpdateRequired: _update, ...rest } = next;
533 return { entry: rest, addressesChanged: true };
534 };
535
448536 const register = Effect.fn("EnvironmentRegistry.register")(function* (
449537 registration: ConnectionRegistration,
450538 ) {
......@@ -456,44 +544,58 @@ export const make = Effect.gen(function* () {
456544 if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) {
457545 return;
458546 }
459 // Editing a saved environment must preserve its disabled state.
547 // A new route joins the environment's saved routes. Registering one it
548 // already has (the same id, or a pairing to the same address) replaces
549 // it in place. Editing keeps the disabled state.
460550 const previous = (yield* SubscriptionRef.get(entries)).get(environmentId);
461 const entry: ConnectionCatalogEntry =
462 previous === undefined
463 ? registered
464 : {
465 ...registered,
466 enabled: previous.enabled,
467 ...(previous.unsupportedReason !== undefined &&
468 gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered)
469 ? unsupportedState(previous)
470 : {}),
471 };
472 if (
473 previous !== undefined &&
474 gitHubRoutingConnectionKey(previous) !== gitHubRoutingConnectionKey(entry)
475 ) {
476 yield* githubRoutingPermissions.forget(environmentId).pipe(
477 Effect.mapError(
478 (error) =>
479 new Persistence.ConnectionPersistenceError({
480 operation: "register-connection",
481 message: error.message,
482 }),
483 ),
551 let entry = registered;
552 if (previous !== undefined) {
553 const route: ConnectionRoute = { target: registered.target, profile: registered.profile };
554 const existing = connectionRoutes(previous);
555 const sameAddress = findRouteToSameAddress(existing, route);
556 const routes = existing.filter(
557 (existing) =>
558 existing !== sameAddress ||
559 connectionRouteId(existing.target) === connectionRouteId(route.target),
484560 );
561 const next = withRoutes(previous, upsertRoute(routes, route));
562 if (next.addressesChanged) {
563 yield* forgetRoutingTrust(environmentId, "register-connection");
564 }
565 entry = next.entry;
485566 }
486 yield* registrations.register(registration);
487 yield* Ref.update(persistedTargetsByEnvironment, (current) => {
488 const next = new Map(current);
489 next.set(environmentId, registration.target);
490 return next;
491 });
567 yield* registrations.register(registration, persistedRoutes(entry));
568 yield* Ref.update(persistedEnvironmentIds, (current) =>
569 new Set(current).add(environmentId),
570 );
492571 yield* installEntryLocked(entry);
493572 }),
494573 );
495574 });
496575
576 /** Writes a new route list for a user-saved environment and restarts its connection. */
577 const replaceRoutesLocked = Effect.fn("EnvironmentRegistry.replaceRoutesLocked")(function* (
578 previous: ConnectionCatalogEntry,
579 routes: ReadonlyArray<ConnectionRoute>,
580 ) {
581 const environmentId = previous.target.environmentId;
582 const next = withRoutes(previous, routes);
583 if (next.addressesChanged) {
584 yield* forgetRoutingTrust(environmentId, "set-connection-routes");
585 }
586 yield* registrations.setRoutes(environmentId, persistedRoutes(next.entry));
587 yield* installEntryLocked(next.entry);
588 });
589
590 const userEntry = Effect.fn("EnvironmentRegistry.userEntry")(function* (
591 environmentId: EnvironmentId,
592 ) {
593 if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) {
594 return yield* new PlatformEnvironmentRemovalError({ environmentId });
595 }
596 return yield* getEntry(environmentId);
597 });
598
497599 const installPlatformRegistration = Effect.fn("EnvironmentRegistry.installPlatformRegistration")(
498600 function* (registration: PlatformConnectionRegistration) {
499601 const registered = connectionRegistrationCatalogEntry(registration);
......@@ -507,11 +609,9 @@ export const make = Effect.gen(function* () {
507609 gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered)
508610 ? { ...registered, enabled: false, ...unsupportedState(previous) }
509611 : registered;
510 const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get(
511 target.environmentId,
512 );
612 const persisted = (yield* Ref.get(persistedEnvironmentIds)).has(target.environmentId);
513613 if (
514 persistedTarget !== undefined ||
614 persisted ||
515615 (previous !== undefined &&
516616 gitHubRoutingConnectionKey(previous) !== gitHubRoutingConnectionKey(entry))
517617 ) {
......@@ -550,11 +650,11 @@ export const make = Effect.gen(function* () {
550650 );
551651 }
552652
553 if (persistedTarget !== undefined) {
554 yield* registrations.remove(persistedTarget).pipe(
653 if (persisted) {
654 yield* registrations.remove(target.environmentId).pipe(
555655 Effect.tap(() =>
556 Ref.update(persistedTargetsByEnvironment, (current) => {
557 const next = new Map(current);
656 Ref.update(persistedEnvironmentIds, (current) => {
657 const next = new Set(current);
558658 next.delete(target.environmentId);
559659 return next;
560660 }),
......@@ -665,64 +765,111 @@ export const make = Effect.gen(function* () {
665765 yield* Effect.forEach(platformRegistrations, installPlatformRegistration, { discard: true });
666766 });
667767
768 /** Forgets a user-saved environment. Callers hold its lease lock. */
769 const removeLocked = Effect.fn("EnvironmentRegistry.removeLocked")(function* (
770 environmentId: EnvironmentId,
771 ) {
772 if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) {
773 return yield* new PlatformEnvironmentRemovalError({
774 environmentId,
775 });
776 }
777 const entry = yield* getEntry(environmentId);
778
779 yield* githubRoutingPermissions.forget(environmentId);
780 yield* registrations.remove(environmentId);
781 yield* Ref.update(persistedEnvironmentIds, (current) => {
782 const next = new Set(current);
783 next.delete(environmentId);
784 return next;
785 });
786 yield* closeServiceScope(environmentId);
787 yield* SubscriptionRef.update(entries, (current) => {
788 const next = new Map(current);
789 next.delete(environmentId);
790 return next;
791 });
792 yield* Effect.all(
793 [
794 cache.clear(environmentId).pipe(
795 Effect.catch((error) =>
796 Effect.logWarning("Could not clear cached environment data after removal.", {
797 environmentId,
798 error,
799 }),
800 ),
801 ),
802 ownedDataCleanup.clear(environmentId),
803 ],
804 { concurrency: "unbounded", discard: true },
805 );
806
807 for (const route of connectionRoutes(entry)) {
808 const profile = Option.getOrNull(route.profile);
809 if (profile !== null && isSshConnectionProfile(profile)) {
810 yield* disconnectSsh(environmentId, profile);
811 }
812 }
813 });
814
668815 const remove = Effect.fn("EnvironmentRegistry.remove")(function* (environmentId: EnvironmentId) {
669 return yield* withLeaseLock(
816 return yield* withLeaseLock(environmentId, removeLocked(environmentId));
817 });
818
819 const disconnectSsh = (environmentId: EnvironmentId, profile: SshConnectionProfile) =>
820 ssh.disconnect(profile.target).pipe(
821 Effect.tapError((error) =>
822 Effect.logWarning("Could not disconnect the managed SSH environment.", {
823 environmentId,
824 error,
825 }),
826 ),
827 Effect.ignore,
828 );
829
830 const removeRoute = Effect.fn("EnvironmentRegistry.removeRoute")(function* (
831 environmentId: EnvironmentId,
832 routeId: string,
833 ) {
834 // One lock for the whole decision: a route registered between "this was
835 // the last route" and the removal must not be deleted with it.
836 yield* withLeaseLock(
670837 environmentId,
671838 Effect.gen(function* () {
672 if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) {
673 return yield* new PlatformEnvironmentRemovalError({
674 environmentId,
675 });
839 const entry = yield* userEntry(environmentId);
840 const routes = connectionRoutes(entry);
841 const route = routes.find((candidate) => connectionRouteId(candidate.target) === routeId);
842 if (route === undefined) return;
843 const remaining = routes.filter((candidate) => candidate !== route);
844 if (remaining.length === 0) return yield* removeLocked(environmentId);
845 yield* replaceRoutesLocked(entry, remaining);
846 const profile = Option.getOrNull(route.profile);
847 if (profile !== null && isSshConnectionProfile(profile)) {
848 yield* disconnectSsh(environmentId, profile);
676849 }
677 const target = (yield* getEntry(environmentId)).target;
678 const profile =
679 target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget"
680 ? yield* profiles.get(target.connectionId)
681 : Option.none();
682
683 yield* githubRoutingPermissions.forget(environmentId);
684 yield* registrations.remove(target);
685 yield* Ref.update(persistedTargetsByEnvironment, (current) => {
686 const next = new Map(current);
687 next.delete(environmentId);
688 return next;
689 });
690 yield* closeServiceScope(environmentId);
691 yield* SubscriptionRef.update(entries, (current) => {
692 const next = new Map(current);
693 next.delete(environmentId);
694 return next;
695 });
696 yield* Effect.all(
697 [
698 cache.clear(environmentId).pipe(
699 Effect.catch((error) =>
700 Effect.logWarning("Could not clear cached environment data after removal.", {
701 environmentId,
702 error,
703 }),
704 ),
705 ),
706 ownedDataCleanup.clear(environmentId),
707 ],
708 { concurrency: "unbounded", discard: true },
709 );
850 }),
851 );
852 });
710853
711 if (
712 target._tag === "SshConnectionTarget" &&
713 Option.isSome(profile) &&
714 isSshConnectionProfile(profile.value)
715 ) {
716 yield* ssh.disconnect(profile.value.target).pipe(
717 Effect.tapError((error) =>
718 Effect.logWarning("Could not disconnect the managed SSH environment.", {
719 environmentId,
720 error,
721 }),
722 ),
723 Effect.ignore,
724 );
854 const reorderRoutes = Effect.fn("EnvironmentRegistry.reorderRoutes")(function* (
855 environmentId: EnvironmentId,
856 routeIds: ReadonlyArray<string>,
857 ) {
858 yield* withLeaseLock(
859 environmentId,
860 Effect.gen(function* () {
861 const entry = yield* userEntry(environmentId);
862 const routes = connectionRoutes(entry);
863 const byId = new Map(routes.map((route) => [connectionRouteId(route.target), route]));
864 const reordered = routeIds.flatMap((id) => byId.get(id) ?? []);
865 if (reordered.length !== routes.length || new Set(routeIds).size !== routes.length) {
866 return yield* new ConnectionBlockedError({
867 reason: "configuration",
868 detail: "The route order must list every saved route once.",
869 });
725870 }
871 if (reordered.every((route, index) => route === routes[index])) return;
872 yield* replaceRoutesLocked(entry, reordered);
726873 }),
727874 );
728875 });
......@@ -730,13 +877,15 @@ export const make = Effect.gen(function* () {
730877 const removeRelayEnvironments = Effect.fn("EnvironmentRegistry.removeRelayEnvironments")(
731878 function* () {
732879 const relayEnvironmentIds = [...(yield* SubscriptionRef.get(entries)).values()]
733 .filter((entry) => entry.target._tag === "RelayConnectionTarget")
880 .filter((entry) =>
881 connectionRoutes(entry).some((route) => route.target._tag === "RelayConnectionTarget"),
882 )
734883 .map((entry) => entry.target.environmentId);
735884
736885 yield* Effect.forEach(
737886 relayEnvironmentIds,
738887 (environmentId) =>
739 remove(environmentId).pipe(
888 removeRoute(environmentId, RELAY_ROUTE_ID).pipe(
740889 Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void),
741890 ),
742891 {
......@@ -798,21 +947,13 @@ export const make = Effect.gen(function* () {
798947 }
799948 // The supervisor only owns the RPC session. A managed SSH backend and
800949 // its tunnel outlive it, so switching off tears those down as well.
801 if (
802 !enabled &&
803 entry.target._tag === "SshConnectionTarget" &&
804 Option.isSome(entry.profile) &&
805 isSshConnectionProfile(entry.profile.value)
806 ) {
807 yield* ssh.disconnect(entry.profile.value.target).pipe(
808 Effect.tapError((error) =>
809 Effect.logWarning("Could not disconnect the switched-off SSH environment.", {
810 environmentId,
811 error,
812 }),
813 ),
814 Effect.ignore,
815 );
950 if (!enabled) {
951 for (const route of connectionRoutes(entry)) {
952 const profile = Option.getOrNull(route.profile);
953 if (profile !== null && isSshConnectionProfile(profile)) {
954 yield* disconnectSsh(environmentId, profile);
955 }
956 }
816957 }
817958 }),
818959 );
......@@ -904,6 +1045,8 @@ export const make = Effect.gen(function* () {
9041045 registerPlatform,
9051046 reconcilePlatform,
9061047 remove,
1048 removeRoute,
1049 reorderRoutes,
9071050 removeRelayEnvironments,
9081051 retryNow,
9091052 setEnabled,
packages/client-runtime/src/connection/routes.test.ts created+62
......@@ -0,0 +1,62 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as Option from "effect/Option";
4
5import { BearerConnectionProfile, type ConnectionRoute } from "./catalog.ts";
6import { BearerConnectionTarget, RelayConnectionTarget } from "./model.ts";
7import { connectionRouteKind, connectionRouteLabel, insertRoute, upsertRoute } from "./routes.ts";
8
9const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
10
11function direct(id: string, httpBaseUrl: string): ConnectionRoute {
12 return {
13 target: new BearerConnectionTarget({
14 environmentId: ENVIRONMENT_ID,
15 label: "Desk",
16 connectionId: id,
17 }),
18 profile: Option.some(
19 new BearerConnectionProfile({
20 connectionId: id,
21 environmentId: ENVIRONMENT_ID,
22 label: "Desk",
23 httpBaseUrl,
24 wsBaseUrl: httpBaseUrl.replace(/^http/, "ws"),
25 }),
26 ),
27 };
28}
29
30const RELAY: ConnectionRoute = {
31 target: new RelayConnectionTarget({ environmentId: ENVIRONMENT_ID, label: "Desk" }),
32 profile: Option.none(),
33};
34const LAN = direct("lan", "http://192.168.1.10:3773/");
35const TAILNET = direct("tailnet", "https://desk.tail1234.ts.net/");
36const PUBLIC = direct("public", "https://desk.example.com/");
37
38describe("connection routes", () => {
39 it("classifies direct routes by address", () => {
40 expect(connectionRouteKind(LAN)).toBe("lan");
41 expect(connectionRouteKind(direct("ip", "http://100.101.102.103:3773/"))).toBe("tailnet");
42 expect(connectionRouteKind(TAILNET)).toBe("tailnet");
43 expect(connectionRouteKind(PUBLIC)).toBe("public");
44 expect(connectionRouteKind(direct("lo", "http://127.0.0.1:3773/"))).toBe("loopback");
45 expect(connectionRouteKind(direct("ts6", "http://[fd7a:115c:a1e0::1]:3773/"))).toBe("tailnet");
46 expect(connectionRouteLabel(TAILNET)).toBe("Tailscale");
47 expect(connectionRouteLabel(RELAY)).toBe("T3 Connect");
48 });
49
50 it("places a new route after faster kinds and ahead of T3 Connect", () => {
51 expect(insertRoute([RELAY], LAN)).toEqual([LAN, RELAY]);
52 expect(insertRoute([LAN, RELAY], TAILNET)).toEqual([LAN, TAILNET, RELAY]);
53 expect(insertRoute([TAILNET], LAN)).toEqual([LAN, TAILNET]);
54 expect(insertRoute([LAN, TAILNET], RELAY)).toEqual([LAN, TAILNET, RELAY]);
55 });
56
57 it("keeps a user's order when a saved route is replaced", () => {
58 // The user preferred T3 Connect over the LAN; re-pairing the LAN keeps that.
59 const repaired = direct("lan", "http://192.168.1.11:3773/");
60 expect(upsertRoute([RELAY, LAN], repaired)).toEqual([RELAY, repaired]);
61 });
62});
packages/client-runtime/src/connection/routes.ts created+208
......@@ -0,0 +1,208 @@
1import {
2 isLocalLoopbackHost,
3 isPrivateNetworkHost,
4 isTailnetHost,
5} from "@t3tools/shared/hostClassification";
6import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts";
7import * as Option from "effect/Option";
8
9import type { ConnectionCatalogEntry, ConnectionRoute } from "./catalog.ts";
10import type { ConnectionTarget } from "./model.ts";
11
12/**
13 * A saved environment can hold several routes: T3 Connect, direct URLs (LAN,
14 * tailnet, public), and SSH. The client connects over the first route in
15 * preference order that answers as the expected environment, and moves back
16 * to a better one when it becomes reachable again.
17 */
18
19export type ConnectionRouteKind = "relay" | "loopback" | "lan" | "tailnet" | "public" | "ssh";
20
21/** An environment has at most one T3 Connect route, so it needs no per-route id. */
22export const RELAY_ROUTE_ID = "relay";
23
24export function connectionRouteId(target: ConnectionTarget): string {
25 switch (target._tag) {
26 case "PrimaryConnectionTarget":
27 return "primary";
28 case "RelayConnectionTarget":
29 return RELAY_ROUTE_ID;
30 case "BearerConnectionTarget":
31 case "SshConnectionTarget":
32 return target.connectionId;
33 }
34}
35
36/** Every route of an entry, preferred first. */
37export function connectionRoutes(entry: ConnectionCatalogEntry): ReadonlyArray<ConnectionRoute> {
38 return [{ target: entry.target, profile: entry.profile }, ...(entry.alternateRoutes ?? [])];
39}
40
41/** Builds an entry whose preferred route is the first of `routes`, which must not be empty. */
42export function entryWithRoutes(
43 entry: ConnectionCatalogEntry,
44 routes: ReadonlyArray<ConnectionRoute>,
45): ConnectionCatalogEntry {
46 const [first, ...rest] = routes;
47 if (first === undefined) {
48 throw new Error("A saved environment needs at least one route.");
49 }
50 const { alternateRoutes: _previous, ...base } = entry;
51 return {
52 ...base,
53 target: first.target,
54 profile: first.profile,
55 ...(rest.length === 0 ? {} : { alternateRoutes: rest }),
56 };
57}
58
59/** The entry a single route connects with. */
60export function routeEntry(
61 entry: ConnectionCatalogEntry,
62 route: ConnectionRoute,
63): ConnectionCatalogEntry {
64 return entryWithRoutes(entry, [route]);
65}
66
67/** The base URL of a direct route, or null for T3 Connect and SSH. */
68export function routeHttpBaseUrl(route: ConnectionRoute): string | null {
69 if (route.target._tag === "PrimaryConnectionTarget") return route.target.httpBaseUrl;
70 const profile = Option.getOrNull(route.profile);
71 return profile?._tag === "BearerConnectionProfile" ? profile.httpBaseUrl : null;
72}
73
74function routeHostname(route: ConnectionRoute): string | null {
75 const httpBaseUrl = routeHttpBaseUrl(route);
76 if (httpBaseUrl === null) return null;
77 try {
78 return new URL(httpBaseUrl).hostname;
79 } catch {
80 return null;
81 }
82}
83
84export function connectionRouteKind(route: ConnectionRoute): ConnectionRouteKind {
85 switch (route.target._tag) {
86 case "RelayConnectionTarget":
87 return "relay";
88 case "SshConnectionTarget":
89 return "ssh";
90 case "PrimaryConnectionTarget":
91 case "BearerConnectionTarget": {
92 const hostname = routeHostname(route);
93 if (hostname === null) return "public";
94 if (isLocalLoopbackHost(hostname)) return "loopback";
95 if (isTailnetHost(hostname)) return "tailnet";
96 return isPrivateNetworkHost(hostname) ? "lan" : "public";
97 }
98 }
99}
100
101const ROUTE_KIND_RANK: Record<ConnectionRouteKind, number> = {
102 loopback: 0,
103 lan: 1,
104 tailnet: 2,
105 public: 3,
106 ssh: 4,
107 relay: 5,
108};
109
110/**
111 * Where a newly added route goes: after every saved route of the same or a
112 * faster kind, so LAN lands ahead of tailnet and both ahead of T3 Connect.
113 * Users can reorder afterwards; this only picks a sensible starting point.
114 */
115export function insertRoute(
116 routes: ReadonlyArray<ConnectionRoute>,
117 route: ConnectionRoute,
118): ReadonlyArray<ConnectionRoute> {
119 const rank = ROUTE_KIND_RANK[connectionRouteKind(route)];
120 const index = routes.findIndex(
121 (existing) => ROUTE_KIND_RANK[connectionRouteKind(existing)] > rank,
122 );
123 return index === -1
124 ? [...routes, route]
125 : [...routes.slice(0, index), route, ...routes.slice(index)];
126}
127
128/** Replaces the route with the same id in place, or inserts it by kind. */
129export function upsertRoute(
130 routes: ReadonlyArray<ConnectionRoute>,
131 route: ConnectionRoute,
132): ReadonlyArray<ConnectionRoute> {
133 const id = connectionRouteId(route.target);
134 return routes.some((existing) => connectionRouteId(existing.target) === id)
135 ? routes.map((existing) => (connectionRouteId(existing.target) === id ? route : existing))
136 : insertRoute(routes, route);
137}
138
139/**
140 * A saved route that reaches the same address as `route`: the same bearer
141 * URL, or the same SSH target (alias, host, user, and port, as desktop keys
142 * its tunnels). Registering it again replaces that route, even when it was
143 * saved under another id.
144 */
145export function findRouteToSameAddress(
146 routes: ReadonlyArray<ConnectionRoute>,
147 route: ConnectionRoute,
148): ConnectionRoute | undefined {
149 const key = routeAddressKey(route);
150 return key === null ? undefined : routes.find((existing) => routeAddressKey(existing) === key);
151}
152
153function routeAddressKey(route: ConnectionRoute): string | null {
154 const profile = Option.getOrNull(route.profile);
155 switch (profile?._tag) {
156 case "BearerConnectionProfile":
157 return `bearer:${profile.httpBaseUrl.replace(/\/+$/, "")}`;
158 case "SshConnectionProfile":
159 return `ssh:${sshTargetKey(profile.target)}`;
160 default:
161 return null;
162 }
163}
164
165/** Short user-facing route description: "LAN", "Tailscale", "T3 Connect", a URL, or an SSH host. */
166export function connectionRouteLabel(route: ConnectionRoute): string {
167 switch (connectionRouteKind(route)) {
168 case "relay":
169 return "T3 Connect";
170 case "loopback":
171 return "This device";
172 case "lan":
173 return "LAN";
174 case "tailnet":
175 return "Tailscale";
176 case "ssh": {
177 const profile = Option.getOrNull(route.profile);
178 return profile?._tag === "SshConnectionProfile"
179 ? `SSH ${profile.target.username ? `${profile.target.username}@` : ""}${profile.target.hostname}`
180 : "SSH";
181 }
182 case "public":
183 return routeHostname(route) ?? "Remote link";
184 }
185}
186
187/** The address shown under a route, or null for T3 Connect. */
188export function connectionRouteAddress(route: ConnectionRoute): string | null {
189 if (route.target._tag === "SshConnectionTarget") {
190 const profile = Option.getOrNull(route.profile);
191 return profile?._tag === "SshConnectionProfile" ? profile.target.alias : null;
192 }
193 return routeHttpBaseUrl(route);
194}
195
196/** Whether the environment can be reached through T3 Connect. */
197export function hasRelayRoute(
198 entry: Pick<ConnectionCatalogEntry, "target" | "alternateRoutes">,
199): boolean {
200 return [entry.target, ...(entry.alternateRoutes ?? []).map((route) => route.target)].some(
201 (target) => target._tag === "RelayConnectionTarget",
202 );
203}
204
205/** One SSH target, as desktop keys its tunnels: alias, host, user, and port. */
206export function sshTargetKey(target: DesktopSshEnvironmentTarget): string {
207 return JSON.stringify([target.alias, target.hostname, target.username, target.port]);
208}
packages/client-runtime/src/connection/supervisor.test.ts+358-7
......@@ -19,10 +19,12 @@ import * as ManagedRelay from "../relay/managedRelay.ts";
1919import { remoteHttpClientLayer } from "../rpc/http.ts";
2020import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
2121import { fetchEnvironmentSessionState } from "../state/session.ts";
22import type { ConnectionCatalogEntry } from "./catalog.ts";
22import type { ConnectionCatalogEntry, ConnectionRoute } from "./catalog.ts";
2323import * as Connectivity from "./connectivity.ts";
2424import * as ConnectionDriver from "./driver.ts";
25import { BearerConnectionProfile } from "./catalog.ts";
2526import {
27 BearerConnectionTarget,
2628 ConnectionBlockedError,
2729 ConnectionTransientError,
2830 PrimaryConnectionTarget,
......@@ -73,6 +75,35 @@ const PREPARED_CONNECTION: PreparedConnection = {
7375
7476const TEST_RPC_CLIENT = {} as WsRpcProtocolClient;
7577
78const LAN_TARGET = new BearerConnectionTarget({
79 environmentId: TARGET.environmentId,
80 label: TARGET.label,
81 connectionId: "bearer:lan",
82});
83const LAN_ROUTE: ConnectionRoute = {
84 target: LAN_TARGET,
85 profile: Option.some(
86 new BearerConnectionProfile({
87 connectionId: LAN_TARGET.connectionId,
88 environmentId: TARGET.environmentId,
89 label: TARGET.label,
90 httpBaseUrl: "http://192.168.1.10:3773/",
91 wsBaseUrl: "ws://192.168.1.10:3773/",
92 }),
93 ),
94};
95// LAN first, T3 Connect as the fallback.
96const LAN_THEN_RELAY_ENTRY: ConnectionCatalogEntry = {
97 target: LAN_ROUTE.target,
98 profile: LAN_ROUTE.profile,
99 alternateRoutes: [{ target: RELAY_TARGET, profile: Option.none() }],
100 enabled: true,
101};
102
103function preparedFor(target: ConnectionTarget): PreparedConnection {
104 return { ...PREPARED_CONNECTION, target };
105}
106
76107function transient(message = "Connection failed.") {
77108 return new ConnectionTransientError({
78109 reason: "transport",
......@@ -125,6 +156,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
125156 ) => Effect.Effect<PreparedConnection, ConnectionAttemptError>;
126157 readonly ready?: (attempt: number) => Effect.Effect<void, ConnectionAttemptError>;
127158 readonly probe?: (attempt: number) => Effect.Effect<void, ConnectionAttemptError>;
159 readonly checkRoute?: (route: ConnectionRoute) => Effect.Effect<ConnectionDriver.RouteCheck>;
128160}) {
129161 const networkStatus = yield* SubscriptionRef.make<NetworkStatus>(
130162 options?.networkStatus ?? "online",
......@@ -156,12 +188,13 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
156188 return PREPARED_CONNECTION;
157189 });
158190
159 const connect = Effect.fn("TestConnectionDriver.connect")(function* (
160 entry: ConnectionCatalogEntry,
191 const checkRoute = (route: ConnectionRoute) =>
192 options?.checkRoute?.(route) ?? Effect.succeed<ConnectionDriver.RouteCheck>("unchecked");
193
194 const connectRoute = Effect.fn("TestConnectionDriver.connectRoute")(function* (
195 target: ConnectionTarget,
161196 reportProgress: (progress: ConnectionDriver.ConnectionDriverProgress) => Effect.Effect<void>,
162197 ) {
163 const target = entry.target;
164 yield* reportProgress({ stage: "preparing" });
165198 const prepared = yield* prepare(target);
166199 yield* reportProgress({ stage: "opening", prepared });
167200
......@@ -186,6 +219,16 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
186219 return { prepared, session } satisfies ConnectionDriver.EnvironmentConnectionLease;
187220 });
188221
222 const connect = Effect.fn("TestConnectionDriver.connect")(function* (
223 entry: ConnectionCatalogEntry,
224 reportProgress: (progress: ConnectionDriver.ConnectionDriverProgress) => Effect.Effect<void>,
225 ) {
226 yield* reportProgress({ stage: "preparing" });
227 return yield* ConnectionDriver.connectOverRoutes(entry, checkRoute, (route) =>
228 connectRoute(route.target, reportProgress),
229 );
230 });
231
189232 const dependencies = Layer.mergeAll(
190233 // Jitter at its maximum, so each retry waits exactly its ceiling: 2s, 4s, 8s...
191234 Layer.succeed(Random.Random, {
......@@ -204,7 +247,12 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
204247 ),
205248 Layer.succeed(
206249 ConnectionDriver.ConnectionDriver,
207 ConnectionDriver.ConnectionDriver.of({ connect }),
250 ConnectionDriver.ConnectionDriver.of({
251 connect,
252 checkRoute: (_entry, route) => checkRoute(route),
253 preflight: (_entry, route) =>
254 checkRoute(route).pipe(Effect.map((check) => check === "answered")),
255 }),
208256 ),
209257 );
210258
......@@ -1336,7 +1384,9 @@ describe("EnvironmentSupervisor", () => {
13361384
13371385 it.effect("releases and reconnects a relay session when credentials change", () =>
13381386 Effect.gen(function* () {
1339 const harness = yield* makeHarness();
1387 const harness = yield* makeHarness({
1388 prepare: () => Effect.succeed(preparedFor(RELAY_TARGET)),
1389 });
13401390 const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, {
13411391 initiallyDesired: true,
13421392 }).pipe(Effect.provide(harness.dependencies));
......@@ -1633,3 +1683,304 @@ describe("EnvironmentSupervisor", () => {
16331683 }),
16341684 );
16351685});
1686
1687describe("EnvironmentSupervisor routes", () => {
1688 it.effect("skips a silent LAN route and connects over T3 Connect", () =>
1689 Effect.gen(function* () {
1690 const harness = yield* makeHarness({
1691 checkRoute: (route) =>
1692 Effect.succeed(route.target._tag === "BearerConnectionTarget" ? "silent" : "unchecked"),
1693 prepare: (_attempt, target) => Effect.succeed(preparedFor(target)),
1694 });
1695 const supervisor = yield* EnvironmentSupervisor.make(LAN_THEN_RELAY_ENTRY, {
1696 initiallyDesired: true,
1697 }).pipe(Effect.provide(harness.dependencies));
1698
1699 yield* awaitState(supervisor.state, (state) => state.phase === "connected");
1700 const prepared = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared));
1701 expect(prepared.target._tag).toBe("RelayConnectionTarget");
1702 // The silent route never reached the resolver.
1703 expect(yield* Ref.get(harness.prepareCount)).toBe(1);
1704 }),
1705 );
1706
1707 it.effect("moves to a blocked route's fallback instead of stopping", () =>
1708 Effect.gen(function* () {
1709 const harness = yield* makeHarness({
1710 checkRoute: () => Effect.succeed("answered"),
1711 prepare: (_attempt, target) =>
1712 target._tag === "BearerConnectionTarget"
1713 ? Effect.fail(blocked("The environment credential is invalid."))
1714 : Effect.succeed(preparedFor(target)),
1715 });
1716 const supervisor = yield* EnvironmentSupervisor.make(LAN_THEN_RELAY_ENTRY, {
1717 initiallyDesired: true,
1718 }).pipe(Effect.provide(harness.dependencies));
1719
1720 yield* awaitState(supervisor.state, (state) => state.phase === "connected");
1721 const prepared = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared));
1722 expect(prepared.target._tag).toBe("RelayConnectionTarget");
1723 }),
1724 );
1725
1726 it.effect("tries every route when none answers the reachability check", () =>
1727 Effect.gen(function* () {
1728 const harness = yield* makeHarness({
1729 checkRoute: () => Effect.succeed("silent"),
1730 prepare: (_attempt, target) => Effect.succeed(preparedFor(target)),
1731 });
1732 const supervisor = yield* EnvironmentSupervisor.make(LAN_THEN_RELAY_ENTRY, {
1733 initiallyDesired: true,
1734 }).pipe(Effect.provide(harness.dependencies));
1735
1736 yield* awaitState(supervisor.state, (state) => state.phase === "connected");
1737 const prepared = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared));
1738 expect(prepared.target._tag).toBe("BearerConnectionTarget");
1739 }),
1740 );
1741
1742 it.effect("still tries a silent LAN route after the T3 Connect route fails", () =>
1743 Effect.gen(function* () {
1744 const harness = yield* makeHarness({
1745 checkRoute: (route) =>
1746 Effect.succeed(route.target._tag === "BearerConnectionTarget" ? "silent" : "unchecked"),
1747 // Signed out of T3 Connect; the LAN is up but its check timed out.
1748 prepare: (_attempt, target) =>
1749 target._tag === "RelayConnectionTarget"
1750 ? Effect.fail(blocked("Sign in to T3 Connect."))
1751 : Effect.succeed(preparedFor(target)),
1752 });
1753 const supervisor = yield* EnvironmentSupervisor.make(LAN_THEN_RELAY_ENTRY, {
1754 initiallyDesired: true,
1755 }).pipe(Effect.provide(harness.dependencies));
1756
1757 yield* awaitState(supervisor.state, (state) => state.phase === "connected");
1758 expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).target._tag).toBe(
1759 "BearerConnectionTarget",
1760 );
1761 }),
1762 );
1763
1764 it.effect("keeps retrying when one route is blocked and another failed transiently", () =>
1765 Effect.gen(function* () {
1766 const harness = yield* makeHarness({
1767 checkRoute: () => Effect.succeed("answered"),
1768 prepare: (_attempt, target) =>
1769 target._tag === "RelayConnectionTarget"
1770 ? Effect.fail(blocked("Sign in to T3 Connect."))
1771 : Effect.fail(transient("LAN socket refused.")),
1772 });
1773 const supervisor = yield* EnvironmentSupervisor.make(LAN_THEN_RELAY_ENTRY, {
1774 initiallyDesired: true,
1775 }).pipe(Effect.provide(harness.dependencies));
1776
1777 // A blocked state would stop retrying; the LAN may come back.
1778 const state = yield* awaitState(supervisor.state, (value) => value.phase === "backoff");
1779 expect(state.lastFailure?._tag).toBe("ConnectionTransientError");
1780 }),
1781 );
1782
1783 it.effect("moves back to the LAN route when the network changes and it answers again", () =>
1784 Effect.gen(function* () {
1785 const lanReachable = yield* Ref.make(false);
1786 const harness = yield* makeHarness({
1787 checkRoute: (route) =>
1788 route.target._tag === "BearerConnectionTarget"
1789 ? Ref.get(lanReachable).pipe(
1790 Effect.map((reachable) => (reachable ? "answered" : "silent")),
1791 )
1792 : Effect.succeed("unchecked"),
1793 prepare: (_attempt, target) => Effect.succeed(preparedFor(target)),
1794 });
1795 const supervisor = yield* EnvironmentSupervisor.make(LAN_THEN_RELAY_ENTRY, {
1796 initiallyDesired: true,
1797 }).pipe(Effect.provide(harness.dependencies));
1798
1799 yield* awaitState(
1800 supervisor.state,
1801 (state) => state.phase === "connected" && state.generation === 1,
1802 );
1803 expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).target._tag).toBe(
1804 "RelayConnectionTarget",
1805 );
1806
1807 // Back home: the LAN answers, and a network change asks for a better route.
1808 yield* Ref.set(lanReachable, true);
1809 yield* harness.setNetworkStatus("unknown");
1810 yield* harness.setNetworkStatus("online");
1811
1812 yield* awaitState(
1813 supervisor.state,
1814 (state) => state.phase === "connected" && state.generation === 2,
1815 );
1816 expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).target._tag).toBe(
1817 "BearerConnectionTarget",
1818 );
1819 expect(yield* Ref.get(harness.releaseCount)).toBe(1);
1820 }),
1821 );
1822
1823 it.effect("checks for a better route periodically while on a fallback", () =>
1824 Effect.gen(function* () {
1825 const lanReachable = yield* Ref.make(false);
1826 const harness = yield* makeHarness({
1827 checkRoute: (route) =>
1828 route.target._tag === "BearerConnectionTarget"
1829 ? Ref.get(lanReachable).pipe(
1830 Effect.map((reachable) => (reachable ? "answered" : "silent")),
1831 )
1832 : Effect.succeed("unchecked"),
1833 prepare: (_attempt, target) => Effect.succeed(preparedFor(target)),
1834 });
1835 const supervisor = yield* EnvironmentSupervisor.make(LAN_THEN_RELAY_ENTRY, {
1836 initiallyDesired: true,
1837 }).pipe(Effect.provide(harness.dependencies));
1838
1839 yield* awaitState(
1840 supervisor.state,
1841 (state) => state.phase === "connected" && state.generation === 1,
1842 );
1843 yield* Ref.set(lanReachable, true);
1844 yield* TestClock.adjust("60 seconds");
1845
1846 yield* awaitState(
1847 supervisor.state,
1848 (state) => state.phase === "connected" && state.generation === 2,
1849 );
1850 expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).target._tag).toBe(
1851 "BearerConnectionTarget",
1852 );
1853 }),
1854 );
1855
1856 it.effect("does not retry a better route that answered but failed to connect", () =>
1857 Effect.gen(function* () {
1858 const lanAnswers = yield* Ref.make(false);
1859 const harness = yield* makeHarness({
1860 checkRoute: (route) =>
1861 route.target._tag === "BearerConnectionTarget"
1862 ? Ref.get(lanAnswers).pipe(Effect.map((answers) => (answers ? "answered" : "silent")))
1863 : Effect.succeed("unchecked"),
1864 // The LAN answers its check but the socket never opens.
1865 prepare: (_attempt, target) =>
1866 target._tag === "BearerConnectionTarget"
1867 ? Effect.fail(transient("Socket refused."))
1868 : Effect.succeed(preparedFor(target)),
1869 });
1870 const supervisor = yield* EnvironmentSupervisor.make(LAN_THEN_RELAY_ENTRY, {
1871 initiallyDesired: true,
1872 }).pipe(Effect.provide(harness.dependencies));
1873
1874 yield* awaitState(
1875 supervisor.state,
1876 (state) => state.phase === "connected" && state.generation === 1,
1877 );
1878 yield* Ref.set(lanAnswers, true);
1879 yield* TestClock.adjust("60 seconds");
1880 // The switch lands back on T3 Connect.
1881 yield* awaitState(
1882 supervisor.state,
1883 (state) => state.phase === "connected" && state.generation === 2,
1884 );
1885 const preparesAfterSwitch = yield* Ref.get(harness.prepareCount);
1886
1887 // The failed LAN is cooling down, so the next periodic check leaves the
1888 // session alone.
1889 yield* TestClock.adjust("60 seconds");
1890 yield* Effect.yieldNow;
1891 expect(yield* Ref.get(harness.prepareCount)).toBe(preparesAfterSwitch);
1892 expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({
1893 phase: "connected",
1894 generation: 2,
1895 });
1896 }),
1897 );
1898
1899 it.effect("stays on the preferred route without checking other routes", () =>
1900 Effect.gen(function* () {
1901 const checks = yield* Ref.make(0);
1902 const harness = yield* makeHarness({
1903 checkRoute: () => Ref.update(checks, (count) => count + 1).pipe(Effect.as("answered")),
1904 prepare: (_attempt, target) => Effect.succeed(preparedFor(target)),
1905 });
1906 const supervisor = yield* EnvironmentSupervisor.make(LAN_THEN_RELAY_ENTRY, {
1907 initiallyDesired: true,
1908 }).pipe(Effect.provide(harness.dependencies));
1909
1910 yield* awaitState(supervisor.state, (state) => state.phase === "connected");
1911 const checksAtConnect = yield* Ref.get(checks);
1912 yield* harness.setNetworkStatus("unknown");
1913 yield* harness.setNetworkStatus("online");
1914 yield* TestClock.adjust("5 minutes");
1915 yield* Effect.yieldNow;
1916
1917 expect(yield* Ref.get(checks)).toBe(checksAtConnect);
1918 expect(yield* Ref.get(harness.sessionCount)).toBe(1);
1919 }),
1920 );
1921
1922 it.effect("moves off a dead LAN socket when the phone switches to cellular", () =>
1923 Effect.gen(function* () {
1924 const lanReachable = yield* Ref.make(true);
1925 const harness = yield* makeHarness({
1926 checkRoute: (route) =>
1927 route.target._tag === "BearerConnectionTarget"
1928 ? Ref.get(lanReachable).pipe(
1929 Effect.map((reachable) => (reachable ? "answered" : "silent")),
1930 )
1931 : Effect.succeed("unchecked"),
1932 prepare: (_attempt, target) => Effect.succeed(preparedFor(target)),
1933 // The LAN socket never answers once the phone has left the network.
1934 probe: () =>
1935 Ref.get(lanReachable).pipe(
1936 Effect.flatMap((reachable) => (reachable ? Effect.void : Effect.never)),
1937 ),
1938 });
1939 const supervisor = yield* EnvironmentSupervisor.make(LAN_THEN_RELAY_ENTRY, {
1940 initiallyDesired: true,
1941 }).pipe(Effect.provide(harness.dependencies));
1942
1943 yield* awaitState(
1944 supervisor.state,
1945 (state) => state.phase === "connected" && state.generation === 1,
1946 );
1947 expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).target._tag).toBe(
1948 "BearerConnectionTarget",
1949 );
1950
1951 yield* Ref.set(lanReachable, false);
1952 yield* harness.wake("network-changed");
1953 yield* TestClock.adjust("3 seconds");
1954
1955 yield* awaitState(
1956 supervisor.state,
1957 (state) => state.phase === "connected" && state.generation === 2,
1958 );
1959 expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).target._tag).toBe(
1960 "RelayConnectionTarget",
1961 );
1962 }),
1963 );
1964
1965 it.effect("keeps a LAN session when the T3 Connect account changes", () =>
1966 Effect.gen(function* () {
1967 const harness = yield* makeHarness({
1968 checkRoute: () => Effect.succeed("answered"),
1969 prepare: (_attempt, target) => Effect.succeed(preparedFor(target)),
1970 });
1971 const supervisor = yield* EnvironmentSupervisor.make(LAN_THEN_RELAY_ENTRY, {
1972 initiallyDesired: true,
1973 }).pipe(Effect.provide(harness.dependencies));
1974
1975 yield* awaitState(supervisor.state, (state) => state.phase === "connected");
1976 yield* harness.wake("credentials-changed");
1977 yield* Effect.yieldNow;
1978
1979 expect(yield* Ref.get(harness.sessionCount)).toBe(1);
1980 expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({
1981 phase: "connected",
1982 generation: 1,
1983 });
1984 }),
1985 );
1986});
packages/client-runtime/src/connection/supervisor.ts+137-14
......@@ -15,7 +15,7 @@ import * as Stream from "effect/Stream";
1515import * as SubscriptionRef from "effect/SubscriptionRef";
1616import * as Tracer from "effect/Tracer";
1717
18import type { ConnectionCatalogEntry } from "./catalog.ts";
18import type { ConnectionCatalogEntry, ConnectionRoute } from "./catalog.ts";
1919import * as Connectivity from "./connectivity.ts";
2020import * as ConnectionDriver from "./driver.ts";
2121import {
......@@ -30,15 +30,23 @@ import * as RpcSession from "../rpc/session.ts";
3030import { safeErrorLogAttributes } from "../errors/safeLog.ts";
3131import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
3232import * as ConnectionWakeups from "./wakeups.ts";
33import { connectionRouteId, connectionRoutes, entryWithRoutes } from "./routes.ts";
3334
3435const RETRY_BASE_DELAY_MS = 1_000;
3536const RETRY_MAX_DELAY_MS = 300_000;
3637const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds";
38const establishmentTimeout = Duration.fromInputUnsafe(CONNECTION_ESTABLISHMENT_TIMEOUT);
3739const CONNECTION_PROBE_TIMEOUT = "15 seconds";
3840// Mobile resumes, explicit retries, and offline events want a fast answer:
3941// the user is waiting, or the network may be gone.
4042const QUICK_CONNECTION_PROBE_TIMEOUT = "3 seconds";
4143const BACKOFF_RESET_AFTER_MS = 30_000;
44// While connected over a fallback route, how often to look for a better one.
45// Network changes and returning to the app also trigger a check.
46const BETTER_ROUTE_CHECK_INTERVAL = "60 seconds";
47// A better route that answered the check but then failed to connect is not
48// tried again for this long, so a flaky LAN cannot bounce the connection.
49const BETTER_ROUTE_COOLDOWN_MS = 300_000;
4250
4351interface SupervisorIntent {
4452 readonly desired: boolean;
......@@ -50,7 +58,8 @@ type SupervisorSignal =
5058 | { readonly _tag: "DisconnectRequested" }
5159 | { readonly _tag: "RetryRequested" }
5260 | { readonly _tag: "NetworkChanged"; readonly network: NetworkStatus }
53 | { readonly _tag: "Wakeup"; readonly reason: ConnectionWakeups.ConnectionWakeup };
61 | { readonly _tag: "Wakeup"; readonly reason: ConnectionWakeups.ConnectionWakeup }
62 | { readonly _tag: "BetterRouteAvailable"; readonly routeId: string };
5463
5564interface PendingRetryTrace {
5665 readonly previousAttempt: Tracer.Span;
......@@ -236,8 +245,13 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
236245 | ConnectionWakeups.ConnectionWakeups
237246> {
238247 const target = entry.target;
248 // Relay-specific handling applies when any route is T3 Connect, since the
249 // attempt or the live session may be using it.
250 const usesRelay = connectionRoutes(entry).some(
251 (route) => route.target._tag === "RelayConnectionTarget",
252 );
239253 const setupTimeoutDetail = `${target.label} did not respond during connection setup.${
240 target._tag === "RelayConnectionTarget" ? ` ${NETWORK_BLOCKING_HINT}` : ""
254 usesRelay ? ` ${NETWORK_BLOCKING_HINT}` : ""
241255 }`;
242256 yield* annotateTarget(target);
243257
......@@ -265,6 +279,64 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
265279 );
266280 const session = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>(Option.none());
267281 const prepared = yield* SubscriptionRef.make<Option.Option<PreparedConnection>>(Option.none());
282 const routes = connectionRoutes(entry);
283 // Set when a better route answered while connected over a worse one; the
284 // replacement attempt tries it first. Cleared once an attempt starts.
285 const preferredRouteId = yield* Ref.make(Option.none<string>());
286 // Monotonic deadline per route that answered a check but failed to connect.
287 const routeCooldowns = yield* Ref.make<ReadonlyMap<string, bigint>>(new Map());
288
289 const attemptEntry = Effect.gen(function* () {
290 const preferred = yield* Ref.getAndSet(preferredRouteId, Option.none());
291 if (Option.isNone(preferred)) return entry;
292 const route = routes.find(
293 (candidate) => connectionRouteId(candidate.target) === preferred.value,
294 );
295 return route === undefined
296 ? entry
297 : entryWithRoutes(entry, [route, ...routes.filter((candidate) => candidate !== route)]);
298 });
299
300 const routeIndex = (lease: ConnectionDriver.EnvironmentConnectionLease) =>
301 routes.findIndex(
302 (route) => connectionRouteId(route.target) === connectionRouteId(lease.prepared.target),
303 );
304
305 /**
306 * Preflights the routes ranked above the one in use and signals the best
307 * that would connect. Routes without a cheap check (T3 Connect, SSH) never
308 * pass, so they are fallbacks, not destinations.
309 */
310 const checkBetterRoutes = Effect.fnUntraced(function* (
311 lease: ConnectionDriver.EnvironmentConnectionLease,
312 ) {
313 const current = routeIndex(lease);
314 if (current <= 0) return;
315 const now = yield* Clock.monotonicTimeNanos;
316 const cooldowns = yield* Ref.get(routeCooldowns);
317 const better = routes
318 .slice(0, current)
319 .filter((route) => (cooldowns.get(connectionRouteId(route.target)) ?? 0n) <= now);
320 const passed = yield* Effect.forEach(
321 better,
322 (route: ConnectionRoute) => driver.preflight(entry, route),
323 { concurrency: "unbounded" },
324 );
325 const index = passed.indexOf(true);
326 if (index === -1) return;
327 // The check may outlive the session it was asked about.
328 const live = yield* SubscriptionRef.get(session);
329 if (Option.isNone(live) || live.value !== lease.session) return;
330 yield* signal({
331 _tag: "BetterRouteAvailable",
332 routeId: connectionRouteId(better[index]!.target),
333 });
334 });
335
336 // One check at a time; a trigger during a check is dropped, not queued.
337 const betterRouteChecks = yield* Queue.dropping<ConnectionDriver.EnvironmentConnectionLease>(1);
338 const requestBetterRouteCheck = (lease: ConnectionDriver.EnvironmentConnectionLease) =>
339 routeIndex(lease) > 0 ? Queue.offer(betterRouteChecks, lease).pipe(Effect.asVoid) : Effect.void;
268340
269341 const clearLease = Effect.all(
270342 [SubscriptionRef.set(session, Option.none()), SubscriptionRef.set(prepared, Option.none())],
......@@ -309,7 +381,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
309381 generation: number,
310382 lastFailure: ConnectionAttemptError | null,
311383 ) {
312 return yield* driver.connect(entry, (progress) =>
384 return yield* driver.connect(yield* attemptEntry, (progress) =>
313385 reportProgress(attempt, generation, lastFailure, progress),
314386 );
315387 });
......@@ -362,7 +434,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
362434 lastFailure: ConnectionAttemptError | null,
363435 pendingRetry: Option.Option<PendingRetryTrace>,
364436 ) {
365 if (target._tag === "RelayConnectionTarget") {
437 if (usesRelay) {
366438 return yield* traceRelayEstablishment(
367439 establishConnection(attempt, generation, lastFailure),
368440 attempt,
......@@ -395,12 +467,13 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
395467 }
396468 break;
397469 case "ConnectRequested":
470 case "BetterRouteAvailable":
398471 break;
399472 case "Wakeup":
400473 if (next.reason === "application-active-reconnect") {
401474 return true;
402475 }
403 if (next.reason === "credentials-changed" && target._tag === "RelayConnectionTarget") {
476 if (next.reason === "credentials-changed" && usesRelay) {
404477 yield* logManagedRelayAccountChange;
405478 return false;
406479 }
......@@ -411,10 +484,22 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
411484
412485 // Signals that end a connected lease whatever its health: "reset" ends it
413486 // and restarts the retry ladder, "end" ends it, undefined keeps it.
414 const connectedLeaseEnd = Effect.fnUntraced(function* (next: SupervisorSignal) {
487 const isRelayLease = (lease: ConnectionDriver.EnvironmentConnectionLease) =>
488 lease.prepared.target._tag === "RelayConnectionTarget";
489
490 const connectedLeaseEnd = Effect.fnUntraced(function* (
491 next: SupervisorSignal,
492 lease: ConnectionDriver.EnvironmentConnectionLease,
493 ) {
415494 if (next._tag === "DisconnectRequested") {
416495 return "end" as const;
417496 }
497 if (next._tag === "BetterRouteAvailable") {
498 // Replaced like a long resume: the new attempt prefers the better route
499 // and its session takes over the durable subscriptions.
500 yield* Ref.set(preferredRouteId, Option.some(next.routeId));
501 return "reset" as const;
502 }
418503 if (next._tag !== "Wakeup") {
419504 return undefined;
420505 }
......@@ -424,7 +509,8 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
424509 // out, so a long background resume replaces the session at once.
425510 return "reset" as const;
426511 }
427 if (next.reason === "credentials-changed" && target._tag === "RelayConnectionTarget") {
512 // Only a session over T3 Connect holds the old account's credential.
513 if (next.reason === "credentials-changed" && isRelayLease(lease)) {
428514 yield* logManagedRelayAccountChange;
429515 return "end" as const;
430516 }
......@@ -443,11 +529,13 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
443529 if (next.reason === "application-active") {
444530 return CONNECTION_PROBE_TIMEOUT;
445531 }
446 return next.reason === "application-active-probe"
532 // A socket opened on the previous network may now be unroutable.
533 return next.reason === "application-active-probe" || next.reason === "network-changed"
447534 ? QUICK_CONNECTION_PROBE_TIMEOUT
448535 : undefined;
449536 case "ConnectRequested":
450537 case "DisconnectRequested":
538 case "BetterRouteAvailable":
451539 return undefined;
452540 }
453541 };
......@@ -469,12 +557,26 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
469557 next._tag === "RetryRequested" ? Ref.set(resetRetryState, false) : Effect.void,
470558 ),
471559 );
560 if (routes.length > 1 && routeIndex(lease) > 0) {
561 yield* Stream.tick(BETTER_ROUTE_CHECK_INTERVAL).pipe(
562 Stream.drop(1),
563 Stream.runForEach(() => requestBetterRouteCheck(lease)),
564 Effect.forkScoped,
565 );
566 }
472567 for (;;) {
473568 const next = yield* takeSignal;
474 const end = yield* connectedLeaseEnd(next);
569 const end = yield* connectedLeaseEnd(next, lease);
475570 if (end !== undefined) {
476571 return end === "reset";
477572 }
573 // A new network or a return to the app may have brought a better route back.
574 if (
575 (next._tag === "NetworkChanged" && next.network !== "offline") ||
576 (next._tag === "Wakeup" && ConnectionWakeups.resetsRetryBackoff(next.reason))
577 ) {
578 yield* requestBetterRouteCheck(lease);
579 }
478580 const probeTimeout = probeTimeoutFor(next);
479581 if (probeTimeout === undefined) {
480582 continue;
......@@ -508,7 +610,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
508610 yield* probeEvent.exit;
509611 break;
510612 }
511 const endDuringProbe = yield* connectedLeaseEnd(probeEvent.signal);
613 const endDuringProbe = yield* connectedLeaseEnd(probeEvent.signal, lease);
512614 if (endDuringProbe !== undefined) {
513615 yield* Fiber.interrupt(probe);
514616 return endDuringProbe === "reset";
......@@ -532,6 +634,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
532634 pendingRetry: Option.Option<PendingRetryTrace>,
533635 ignoreOffline: boolean,
534636 ) {
637 const switchingTo = yield* Ref.get(preferredRouteId);
535638 yield* SubscriptionRef.set(prepared, Option.none());
536639 const establishment = yield* Effect.raceAllFirst([
537640 exitUnlessInterrupted(
......@@ -548,7 +651,8 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
548651 resetRetry,
549652 })),
550653 ),
551 Effect.sleep(CONNECTION_ESTABLISHMENT_TIMEOUT).pipe(
654 // Each route may use the full setup time before the next is tried.
655 Effect.sleep(Duration.times(establishmentTimeout, routes.length)).pipe(
552656 Effect.as<EstablishmentEvent>({ _tag: "TimedOut" }),
553657 ),
554658 ]);
......@@ -595,6 +699,16 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
595699 }
596700
597701 const active = establishment.exit.value;
702 if (Option.isSome(switchingTo)) {
703 const landed = connectionRouteId(active.lease.prepared.target);
704 if (landed !== switchingTo.value) {
705 const until =
706 (yield* Clock.monotonicTimeNanos) + BigInt(BETTER_ROUTE_COOLDOWN_MS) * 1_000_000n;
707 yield* Ref.update(routeCooldowns, (current) =>
708 new Map(current).set(switchingTo.value, until),
709 );
710 }
711 }
598712 const currentIntent = yield* Ref.get(intent);
599713 if (!currentIntent.desired || (currentIntent.network === "offline" && !ignoreOffline)) {
600714 return {
......@@ -654,21 +768,25 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
654768 const next = yield* Queue.take(signals);
655769 switch (next._tag) {
656770 case "Wakeup":
657 return ConnectionWakeups.isApplicationActiveWakeup(next.reason);
771 return ConnectionWakeups.resetsRetryBackoff(next.reason);
658772 case "ConnectRequested":
659773 case "DisconnectRequested":
660774 case "RetryRequested":
661775 case "NetworkChanged":
662776 return false;
777 case "BetterRouteAvailable":
778 break;
663779 }
664780 }
665781 }),
666782 );
667783 });
668784
785 // A better route only matters to a live session, so idle states ignore it.
669786 const waitForSignal = Queue.take(signals).pipe(
787 Effect.repeat({ while: (next) => next._tag === "BetterRouteAvailable" }),
670788 Effect.map(
671 (next) => next._tag === "Wakeup" && ConnectionWakeups.isApplicationActiveWakeup(next.reason),
789 (next) => next._tag === "Wakeup" && ConnectionWakeups.resetsRetryBackoff(next.reason),
672790 ),
673791 );
674792
......@@ -810,6 +928,11 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
810928 Stream.runForEach((reason) => signal({ _tag: "Wakeup", reason })),
811929 Effect.forkScoped,
812930 );
931 yield* Queue.take(betterRouteChecks).pipe(
932 Effect.flatMap(checkBetterRoutes),
933 Effect.forever,
934 Effect.forkScoped,
935 );
813936 yield* run().pipe(Effect.forkScoped);
814937
815938 const connect = Ref.update(intent, (current) => ({
packages/client-runtime/src/connection/wakeups.ts+10-2
......@@ -6,9 +6,12 @@ export type ConnectionWakeup =
66 | "application-active"
77 | "application-active-probe"
88 | "application-active-reconnect"
9 | "credentials-changed";
9 | "credentials-changed"
10 // The device moved to a different network (another Wi-Fi, Wi-Fi to
11 // cellular) while staying online. Saved routes may have changed reach.
12 | "network-changed";
1013
11export function isApplicationActiveWakeup(reason: ConnectionWakeup): boolean {
14function isApplicationActiveWakeup(reason: ConnectionWakeup): boolean {
1215 return (
1316 reason === "application-active" ||
1417 reason === "application-active-probe" ||
......@@ -16,6 +19,11 @@ export function isApplicationActiveWakeup(reason: ConnectionWakeup): boolean {
1619 );
1720}
1821
22/** Conditions changed enough that a pending retry should run now, from the first rung. */
23export function resetsRetryBackoff(reason: ConnectionWakeup): boolean {
24 return isApplicationActiveWakeup(reason) || reason === "network-changed";
25}
26
1927// A long resume replaces the session, and the new session subscribes on its own.
2028export function shouldResubscribeAfterWakeup(reason: ConnectionWakeup): boolean {
2129 return reason === "application-active" || reason === "application-active-probe";
packages/client-runtime/src/platform/capabilities.ts+6
......@@ -59,8 +59,14 @@ export class PrimaryEnvironmentAuth extends Context.Service<
5959export class SshEnvironmentGateway extends Context.Service<
6060 SshEnvironmentGateway,
6161 {
62 /**
63 * Starts or reuses the remote server and pairs with it. With
64 * `expectedEnvironmentId`, a server that is a different machine fails
65 * before its one-time pairing credential is spent.
66 */
6267 readonly provision: (
6368 target: DesktopSshEnvironmentTarget,
69 expectedEnvironmentId?: EnvironmentId,
6470 ) => Effect.Effect<ProvisionedSshEnvironment, ConnectionAttemptError>;
6571 readonly prepare: (input: {
6672 readonly connectionId: string;
packages/client-runtime/src/platform/persistence.ts+16-2
......@@ -12,7 +12,7 @@ import * as Option from "effect/Option";
1212import * as Schema from "effect/Schema";
1313
1414import type { ConnectionRegistration } from "../connection/catalog.ts";
15import type { ConnectionTarget } from "../connection/model.ts";
15import type { ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts";
1616
1717export class ConnectionPersistenceError extends Schema.TaggedError<ConnectionPersistenceError>()(
1818 "ConnectionPersistenceError",
......@@ -21,6 +21,7 @@ export class ConnectionPersistenceError extends Schema.TaggedError<ConnectionPer
2121 "list-targets",
2222 "list-disabled-targets",
2323 "register-connection",
24 "set-connection-routes",
2425 "remove-connection",
2526 "set-connection-enabled",
2627 "load-shell",
......@@ -52,10 +53,23 @@ export class ConnectionTargetStore extends Context.Service<
5253export class ConnectionRegistrationStore extends Context.Service<
5354 ConnectionRegistrationStore,
5455 {
56 /**
57 * Saves one route's records and sets the environment's full route list,
58 * preferred first. Records of routes missing from `routes` are dropped.
59 */
5560 readonly register: (
5661 registration: ConnectionRegistration,
62 routes: ReadonlyArray<PersistedConnectionTarget>,
63 ) => Effect.Effect<void, ConnectionPersistenceError>;
64 /** Reorders or drops routes without adding one. `routes` must not be empty. */
65 readonly setRoutes: (
66 environmentId: EnvironmentId,
67 routes: ReadonlyArray<PersistedConnectionTarget>,
68 ) => Effect.Effect<void, ConnectionPersistenceError>;
69 /** Forgets the environment and every route it had. */
70 readonly remove: (
71 environmentId: EnvironmentId,
5772 ) => Effect.Effect<void, ConnectionPersistenceError>;
58 readonly remove: (target: ConnectionTarget) => Effect.Effect<void, ConnectionPersistenceError>;
5973 readonly setEnabled: (
6074 environmentId: EnvironmentId,
6175 enabled: boolean,
packages/client-runtime/src/platform/storageDocument.test.ts+53-5
......@@ -27,6 +27,8 @@ import {
2727import {
2828 ConnectionCatalogDocument,
2929 EMPTY_CONNECTION_CATALOG_DOCUMENT,
30 catalogRoutes,
31 setRoutesInCatalog,
3032 putRemoteDpopTokenInCatalog,
3133 registerConnectionInCatalog,
3234 removeConnectionFromCatalog,
......@@ -123,7 +125,7 @@ describe("ConnectionCatalogDocument", () => {
123125 expect(yield* afterRemoval.get(entry)).toBe("off");
124126
125127 yield* permissions.set(entry, "read");
126 document = removeConnectionFromCatalog(document, BEARER_TARGET);
128 document = removeConnectionFromCatalog(document, ENVIRONMENT_ID);
127129 const afterCatalogRemoval = yield* makeGitHubRoutingPermissions(storage);
128130 expect(yield* afterCatalogRemoval.get(entry)).toBe("off");
129131 }),
......@@ -273,7 +275,7 @@ describe("ConnectionCatalogDocument", () => {
273275 }),
274276 );
275277
276 expect(removeConnectionFromCatalog(registered, BEARER_TARGET)).toEqual(
278 expect(removeConnectionFromCatalog(registered, ENVIRONMENT_ID)).toEqual(
277279 EMPTY_CONNECTION_CATALOG_DOCUMENT,
278280 );
279281 });
......@@ -295,7 +297,7 @@ describe("ConnectionCatalogDocument", () => {
295297 EMPTY_CONNECTION_CATALOG_DOCUMENT,
296298 new RelayConnectionRegistration({ target: RELAY_TARGET }),
297299 );
298 const removed = removeConnectionFromCatalog(registered, RELAY_TARGET);
300 const removed = removeConnectionFromCatalog(registered, ENVIRONMENT_ID);
299301
300302 expect(putRemoteDpopTokenInCatalog(removed, REMOTE_TOKEN)).toEqual(
301303 EMPTY_CONNECTION_CATALOG_DOCUMENT,
......@@ -309,7 +311,7 @@ describe("ConnectionCatalogDocument", () => {
309311 );
310312 const refreshed = putRemoteDpopTokenInCatalog(registered, REMOTE_TOKEN);
311313
312 expect(removeConnectionFromCatalog(refreshed, RELAY_TARGET)).toEqual(
314 expect(removeConnectionFromCatalog(refreshed, ENVIRONMENT_ID)).toEqual(
313315 EMPTY_CONNECTION_CATALOG_DOCUMENT,
314316 );
315317 });
......@@ -382,7 +384,9 @@ describe("ConnectionCatalogDocument", () => {
382384 }),
383385 ).disabledEnvironmentIds,
384386 ).toEqual([ENVIRONMENT_ID]);
385 expect(removeConnectionFromCatalog(disabled, BEARER_TARGET).disabledEnvironmentIds).toEqual([]);
387 expect(removeConnectionFromCatalog(disabled, ENVIRONMENT_ID).disabledEnvironmentIds).toEqual(
388 [],
389 );
386390 });
387391
388392 it("persists the normalized SSH profile beside its target", () => {
......@@ -411,4 +415,48 @@ describe("ConnectionCatalogDocument", () => {
411415 expect(document.profiles).toEqual([profile]);
412416 expect(document.credentials).toEqual([]);
413417 });
418
419 it("keeps every route of an environment and drops only a removed route's records", () => {
420 const withBearer = registerConnectionInCatalog(
421 { ...EMPTY_CONNECTION_CATALOG_DOCUMENT, remoteDpopTokens: [REMOTE_TOKEN] },
422 new BearerConnectionRegistration({
423 target: BEARER_TARGET,
424 profile: BEARER_PROFILE,
425 credential: BEARER_CREDENTIAL,
426 }),
427 );
428 const withRelay = registerConnectionInCatalog(
429 withBearer,
430 new RelayConnectionRegistration({ target: RELAY_TARGET }),
431 [BEARER_TARGET, RELAY_TARGET],
432 );
433 expect(catalogRoutes(withRelay, ENVIRONMENT_ID)).toEqual([BEARER_TARGET, RELAY_TARGET]);
434 expect(withRelay.credentials).toHaveLength(1);
435
436 // Dropping the bearer route forgets its credential; T3 Connect keeps its token.
437 const relayOnly = setRoutesInCatalog(withRelay, ENVIRONMENT_ID, [RELAY_TARGET]);
438 expect(relayOnly.targets).toEqual([RELAY_TARGET]);
439 expect(relayOnly.profiles).toEqual([]);
440 expect(relayOnly.credentials).toEqual([]);
441 expect(relayOnly.remoteDpopTokens).toEqual([REMOTE_TOKEN]);
442
443 // Dropping T3 Connect forgets its token; the bearer route keeps its records.
444 const bearerOnly = setRoutesInCatalog(withRelay, ENVIRONMENT_ID, [BEARER_TARGET]);
445 expect(bearerOnly.credentials).toHaveLength(1);
446 expect(bearerOnly.remoteDpopTokens).toEqual([]);
447 });
448
449 it("keeps an environment's position in the catalog when its routes change", () => {
450 const other = new RelayConnectionTarget({
451 environmentId: EnvironmentId.make("environment-2"),
452 label: "Other",
453 });
454 const document = {
455 ...EMPTY_CONNECTION_CATALOG_DOCUMENT,
456 targets: [RELAY_TARGET, other],
457 };
458 expect(
459 setRoutesInCatalog(document, ENVIRONMENT_ID, [BEARER_TARGET, RELAY_TARGET]).targets,
460 ).toEqual([BEARER_TARGET, RELAY_TARGET, other]);
461 });
414462});
packages/client-runtime/src/platform/storageDocument.ts+80-48
......@@ -70,58 +70,77 @@ function connectionIdOf(target: ConnectionTarget): string | null {
7070 }
7171}
7272
73function removeConnectionMetadata(
73function routeKey(target: ConnectionTarget): string {
74 return connectionIdOf(target) ?? target._tag;
75}
76
77function removeRouteMetadata(
7478 document: ConnectionCatalogDocument,
75 target: ConnectionTarget,
76 removeRemoteToken: boolean,
79 removed: ReadonlyArray<ConnectionTarget>,
7780): ConnectionCatalogDocument {
78 const connectionId = connectionIdOf(target);
81 const connectionIds = new Set(removed.flatMap((target) => connectionIdOf(target) ?? []));
82 const relayRemoved = removed.some((target) => target._tag === "RelayConnectionTarget");
83 const environmentIds = new Set(removed.map((target) => target.environmentId));
7984 return {
8085 ...document,
81 targets: removeCatalogValue(
82 document.targets,
83 (value) => value.environmentId,
84 target.environmentId,
85 ),
86 profiles:
87 connectionId === null
88 ? document.profiles
89 : removeCatalogValue(document.profiles, (value) => value.connectionId, connectionId),
90 credentials:
91 connectionId === null
92 ? document.credentials
93 : removeCatalogValue(document.credentials, (value) => value.connectionId, connectionId),
94 remoteDpopTokens: removeRemoteToken
95 ? removeCatalogValue(
96 document.remoteDpopTokens,
97 (value) => value.environmentId,
98 target.environmentId,
99 )
86 profiles: document.profiles.filter((value) => !connectionIds.has(value.connectionId)),
87 credentials: document.credentials.filter((value) => !connectionIds.has(value.connectionId)),
88 // The DPoP token belongs to the T3 Connect route.
89 remoteDpopTokens: relayRemoved
90 ? document.remoteDpopTokens.filter((value) => !environmentIds.has(value.environmentId))
10091 : document.remoteDpopTokens,
101 // Re-registration passes `removeRemoteToken: false` and must keep the
102 // switched-off flag; only a real removal clears it.
103 disabledEnvironmentIds: removeRemoteToken
104 ? removeCatalogValue(document.disabledEnvironmentIds, (value) => value, target.environmentId)
105 : document.disabledEnvironmentIds,
10692 };
10793}
10894
95/**
96 * An environment's saved routes in preference order. Targets of one
97 * environment keep their relative order in `targets`, so a document written
98 * before routes existed is one environment with one route.
99 */
100export function catalogRoutes(
101 document: ConnectionCatalogDocument,
102 environmentId: EnvironmentId,
103): ReadonlyArray<PersistedConnectionTarget> {
104 return document.targets.filter((target) => target.environmentId === environmentId);
105}
106
107/**
108 * Replaces an environment's routes with `routes`, preferred first. Records
109 * owned by a dropped route go with it; the environment keeps its position in
110 * the catalog. An empty list leaves the environment's other records in place;
111 * use `removeConnectionFromCatalog` to forget the environment.
112 */
113export function setRoutesInCatalog(
114 document: ConnectionCatalogDocument,
115 environmentId: EnvironmentId,
116 routes: ReadonlyArray<PersistedConnectionTarget>,
117): ConnectionCatalogDocument {
118 const kept = new Set(routes.map(routeKey));
119 const dropped = catalogRoutes(document, environmentId).filter(
120 (target) => !kept.has(routeKey(target)),
121 );
122 const firstIndex = document.targets.findIndex((target) => target.environmentId === environmentId);
123 const others = document.targets.filter((target) => target.environmentId !== environmentId);
124 const insertAt =
125 firstIndex === -1
126 ? others.length
127 : document.targets.slice(0, firstIndex).filter((t) => t.environmentId !== environmentId)
128 .length;
129 return {
130 ...removeRouteMetadata(document, dropped),
131 targets: [...others.slice(0, insertAt), ...routes, ...others.slice(insertAt)],
132 };
133}
134
135/** Saves one route of an environment, keeping its other routes. */
109136export function registerConnectionInCatalog(
110137 document: ConnectionCatalogDocument,
111138 registration: ConnectionRegistration,
139 routes: ReadonlyArray<PersistedConnectionTarget> = [registration.target],
112140): ConnectionCatalogDocument {
113 const target = registration.target;
114 const previous = document.targets.find(
115 (candidate) => candidate.environmentId === target.environmentId,
116 );
117 const cleaned =
118 previous === undefined ? document : removeConnectionMetadata(document, previous, false);
119141 // Re-registering (for example editing a label or URL) keeps the disabled
120142 // flag; only `setConnectionEnabledInCatalog` or removal changes it.
121 const next: ConnectionCatalogDocument = {
122 ...cleaned,
123 targets: replaceCatalogValue(cleaned.targets, (value) => value.environmentId, target),
124 };
143 const next = setRoutesInCatalog(document, registration.target.environmentId, routes);
125144
126145 switch (registration._tag) {
127146 case "RelayConnectionRegistration":
......@@ -151,19 +170,32 @@ export function registerConnectionInCatalog(
151170 }
152171}
153172
173/** Forgets an environment and every route it had. */
154174export function removeConnectionFromCatalog(
155175 document: ConnectionCatalogDocument,
156 target: ConnectionTarget,
176 environmentId: EnvironmentId,
157177): ConnectionCatalogDocument {
158 const next = removeConnectionMetadata(document, target, true);
159 return document.githubRoutingPermissions === undefined
160 ? next
161 : {
162 ...next,
163 githubRoutingPermissions: document.githubRoutingPermissions.filter(
164 (permission) => permission.environmentId !== target.environmentId,
165 ),
166 };
178 const next = setRoutesInCatalog(document, environmentId, []);
179 return {
180 ...next,
181 remoteDpopTokens: removeCatalogValue(
182 next.remoteDpopTokens,
183 (value) => value.environmentId,
184 environmentId,
185 ),
186 disabledEnvironmentIds: removeCatalogValue(
187 next.disabledEnvironmentIds,
188 (value) => value,
189 environmentId,
190 ),
191 ...(next.githubRoutingPermissions === undefined
192 ? {}
193 : {
194 githubRoutingPermissions: next.githubRoutingPermissions.filter(
195 (permission) => permission.environmentId !== environmentId,
196 ),
197 }),
198 };
167199}
168200
169201/** Flips the disabled flag for a saved environment; unknown ids are ignored. */
packages/client-runtime/src/state/connections.ts+23
......@@ -161,6 +161,27 @@ export function createEnvironmentCatalogAtoms<R, E>(
161161 Effect.flatMap((registry) => registry.setEnabled(input.environmentId, input.enabled)),
162162 ),
163163 });
164 const removeRoute = createRuntimeCommand(runtime, {
165 label: "environment-catalog:remove-route",
166 scheduler: commandScheduler,
167 concurrency: serial,
168 execute: (input: { readonly environmentId: EnvironmentIdType; readonly routeId: string }) =>
169 EnvironmentRegistry.EnvironmentRegistry.pipe(
170 Effect.flatMap((registry) => registry.removeRoute(input.environmentId, input.routeId)),
171 ),
172 });
173 const reorderRoutes = createRuntimeCommand(runtime, {
174 label: "environment-catalog:reorder-routes",
175 scheduler: commandScheduler,
176 concurrency: serial,
177 execute: (input: {
178 readonly environmentId: EnvironmentIdType;
179 readonly routeIds: ReadonlyArray<string>;
180 }) =>
181 EnvironmentRegistry.EnvironmentRegistry.pipe(
182 Effect.flatMap((registry) => registry.reorderRoutes(input.environmentId, input.routeIds)),
183 ),
184 });
164185 const retryNow = createRuntimeCommand(runtime, {
165186 label: "environment-catalog:retry-now",
166187 scheduler: commandScheduler,
......@@ -181,6 +202,8 @@ export function createEnvironmentCatalogAtoms<R, E>(
181202 stateAtom,
182203 register,
183204 remove,
205 removeRoute,
206 reorderRoutes,
184207 removeRelayEnvironments,
185208 retryNow,
186209 setEnabled,
packages/client-runtime/src/state/presentation.ts+2-1
......@@ -15,6 +15,7 @@ import {
1515 type EnvironmentPresentation,
1616} from "../connection/presentation.ts";
1717import type { EnvironmentCatalogState } from "./connections.ts";
18import { hasRelayRoute } from "../connection/routes.ts";
1819
1920function mapsEqual<K, V>(left: ReadonlyMap<K, V>, right: ReadonlyMap<K, V>): boolean {
2021 if (left.size !== right.size) {
......@@ -98,7 +99,7 @@ export function projectEnvironmentConnectionSummary(
9899 environmentId,
99100 environmentLabel: environment.entry.target.label,
100101 displayUrl: connectionCatalogDisplayUrl(environment.entry) ?? "",
101 isRelayManaged: environment.entry.target._tag === "RelayConnectionTarget",
102 isRelayManaged: hasRelayRoute(environment.entry),
102103 isEnabled: environment.entry.enabled,
103104 connectionState: environment.connection.phase,
104105 connectionError: environment.connection.error,
packages/client-runtime/src/state/threads-atoms.test.ts+2
......@@ -132,6 +132,8 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options?
132132 registerPlatform: () => Effect.die("Unexpected environment registration"),
133133 reconcilePlatform: () => Effect.die("Unexpected environment reconciliation"),
134134 remove: () => Effect.die("Unexpected environment removal"),
135 removeRoute: () => Effect.die("Unexpected route removal"),
136 reorderRoutes: () => Effect.die("Unexpected route reorder"),
135137 removeRelayEnvironments: () => Effect.die("Unexpected environment removal"),
136138 retryNow: () => Effect.void,
137139 setEnabled: () => Effect.die("Unexpected environment toggle"),
packages/shared/src/hostClassification.ts+13
......@@ -81,6 +81,19 @@ export const isLocalLoopbackHost = (host: string): boolean => {
8181 return parseIpv4Address(normalized)?.[0] === 127;
8282};
8383
84/**
85 * A Tailscale address: a MagicDNS name, the 100.64.0.0/10 range Tailscale
86 * assigns, or its IPv6 range fd7a:115c:a1e0::/48.
87 */
88export const isTailnetHost = (host: string): boolean => {
89 const normalized = normalizeHostname(host);
90 if (normalized.endsWith(".ts.net")) return true;
91 const parts = parseIpv4Address(normalized);
92 if (parts !== null) return parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127;
93 const ipv6 = parseIpv6Address(normalized);
94 return ipv6 !== null && ipv6PrefixMatches(ipv6, [0xfd7a, 0x115c, 0xa1e0, 0, 0, 0, 0, 0], 48);
95};
96
8497export const isPrivateNetworkHost = (host: string): boolean => {
8598 const normalized = normalizeHostname(host);
8699 if (