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();...@@ -13,10 +13,20 @@ const onboardingScheduler = createAtomCommandScheduler();
13export const connectPairingUrl = createRuntimeCommand(connectionAtomRuntime, {13export const connectPairingUrl = createRuntimeCommand(connectionAtomRuntime, {
14 label: "mobile:connection:connect-pairing-url",14 label: "mobile:connection:connect-pairing-url",
15 scheduler: onboardingScheduler,15 scheduler: onboardingScheduler,
16 concurrency: { mode: "singleFlight", key: (pairingUrl: string) => pairingUrl },16 concurrency: {
17 execute: (pairingUrl: string) =>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 }) =>
18 ConnectionOnboarding.ConnectionOnboarding.pipe(28 ConnectionOnboarding.ConnectionOnboarding.pipe(
19 Effect.flatMap((onboarding) => onboarding.registerPairing({ pairingUrl })),29 Effect.flatMap((onboarding) => onboarding.registerPairing(input)),
20 ),30 ),
21});31});
2232
apps/mobile/src/connection/platform.ts+68-22
...@@ -82,29 +82,75 @@ const connectivityLayer = Connectivity.layer({...@@ -82,29 +82,75 @@ const connectivityLayer = Connectivity.layer({
82 ),82 ),
83});83});
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
85const wakeupsLayer = Wakeups.layer({124const wakeupsLayer = Wakeups.layer({
86 changes: Stream.merge(125 changes: Stream.mergeAll(
87 Stream.callback<"application-active-probe" | "application-active-reconnect">((queue) =>126 [
88 Effect.acquireRelease(127 Stream.callback<"application-active-probe" | "application-active-reconnect">((queue) =>
89 Effect.sync(() => {128 Effect.acquireRelease(
90 let backgroundedAtMs = AppState.currentState === "background" ? Date.now() : null;129 Effect.sync(() => {
91 return AppState.addEventListener("change", (state) => {130 let backgroundedAtMs = AppState.currentState === "background" ? Date.now() : null;
92 if (state === "background") {131 return AppState.addEventListener("change", (state) => {
93 backgroundedAtMs = Date.now();132 if (state === "background") {
94 return;133 backgroundedAtMs = Date.now();
95 }134 return;
96 if (state === "active") {135 }
97 Queue.offerUnsafe(queue, mobileApplicationActiveWakeup(backgroundedAtMs, Date.now()));136 if (state === "active") {
98 backgroundedAtMs = null;137 Queue.offerUnsafe(
99 }138 queue,
100 });139 mobileApplicationActiveWakeup(backgroundedAtMs, Date.now()),
101 }),140 );
102 (subscription) => Effect.sync(() => subscription.remove()),141 backgroundedAtMs = null;
103 ).pipe(Effect.asVoid),142 }
104 ),143 });
105 managedRelayAccountChanges(appAtomRegistry).pipe(144 }),
106 Stream.map(() => "credentials-changed" as const),145 (subscription) => Effect.sync(() => subscription.remove()),
107 ),146 ).pipe(Effect.asVoid),
147 ),
148 managedRelayAccountChanges(appAtomRegistry).pipe(
149 Stream.map(() => "credentials-changed" as const),
150 ),
151 networkPathChanges,
152 ],
153 { concurrency: "unbounded" },
108 ),154 ),
109});155});
110156
apps/mobile/src/connection/storage.ts+10-4
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 registerConnectionInCatalog,3 registerConnectionInCatalog,
4 removeConnectionFromCatalog,4 removeConnectionFromCatalog,
5 setConnectionEnabledInCatalog,5 setConnectionEnabledInCatalog,
6 setRoutesInCatalog,
6 removeCatalogValue,7 removeCatalogValue,
7 replaceCatalogValue,8 replaceCatalogValue,
8 Persistence,9 Persistence,
...@@ -27,6 +28,7 @@ function targetPersistenceError(...@@ -27,6 +28,7 @@ function targetPersistenceError(
27 | "list-targets"28 | "list-targets"
28 | "list-disabled-targets"29 | "list-disabled-targets"
29 | "register-connection"30 | "register-connection"
31 | "set-connection-routes"
30 | "remove-connection"32 | "remove-connection"
31 | "set-connection-enabled",33 | "set-connection-enabled",
32 error: ConnectionTransientError,34 error: ConnectionTransientError,
...@@ -57,13 +59,17 @@ export const connectionStorageLayer = Layer.effectContext(...@@ -57,13 +59,17 @@ export const connectionStorageLayer = Layer.effectContext(
57 ),59 ),
58 });60 });
59 const registrationStore = Persistence.ConnectionRegistrationStore.of({61 const registrationStore = Persistence.ConnectionRegistrationStore.of({
60 register: (registration) =>62 register: (registration, routes) =>
61 catalog63 catalog
62 .update((document) => registerConnectionInCatalog(document, registration))64 .update((document) => registerConnectionInCatalog(document, registration, routes))
63 .pipe(Effect.mapError((error) => targetPersistenceError("register-connection", error))),65 .pipe(Effect.mapError((error) => targetPersistenceError("register-connection", error))),
64 remove: (target) =>66 setRoutes: (environmentId, routes) =>
65 catalog67 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))
67 .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))),73 .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))),
68 setEnabled: (environmentId, enabled) =>74 setEnabled: (environmentId, enabled) =>
69 catalog75 catalog
apps/mobile/src/features/cloud/cloud-drafts.ts+5-2
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";1import { EnvironmentRegistry, connectionRoutes } from "@t3tools/client-runtime/connection";
2import { createRuntimeCommand } from "@t3tools/client-runtime/state/runtime";2import { createRuntimeCommand } from "@t3tools/client-runtime/state/runtime";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
...@@ -27,7 +27,10 @@ export const removeCloudEnvironments = createRuntimeCommand(connectionAtomRuntim...@@ -27,7 +27,10 @@ export const removeCloudEnvironments = createRuntimeCommand(connectionAtomRuntim
27 const entries = yield* SubscriptionRef.get(registry.entries);27 const entries = yield* SubscriptionRef.get(registry.entries);
28 const environmentIds = new Set(28 const environmentIds = new Set(
29 [...entries.values()]29 [...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 )
31 .map((entry) => entry.target.environmentId),34 .map((entry) => entry.target.environmentId),
32 );35 );
33 // Credentials are already revoked. A failed backup must leave the local36 // 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 {...@@ -6,6 +6,7 @@ import {
6 useRoute,6 useRoute,
7 type StaticScreenProps,7 type StaticScreenProps,
8} from "@react-navigation/native";8} from "@react-navigation/native";
9import type { EnvironmentId } from "@t3tools/contracts";
9import { AsyncResult } from "effect/unstable/reactivity";10import { AsyncResult } from "effect/unstable/reactivity";
10import { useCallback, useEffect, useRef, useState } from "react";11import { useCallback, useEffect, useRef, useState } from "react";
11import { Alert, Linking, Platform, View } from "react-native";12import { Alert, Linking, Platform, View } from "react-native";
...@@ -23,6 +24,8 @@ type ConnectionsNewRouteParams = {...@@ -23,6 +24,8 @@ type ConnectionsNewRouteParams = {
23 readonly mode?: string;24 readonly mode?: string;
24 readonly pairingUrl?: string;25 readonly pairingUrl?: string;
25 readonly autoConnect?: string;26 readonly autoConnect?: string;
27 /** Adds a route to this saved machine instead of a new environment. */
28 readonly routeFor?: EnvironmentId;
26};29};
2730
28export function ConnectionsNewRouteScreen({31export function ConnectionsNewRouteScreen({
...@@ -158,7 +161,7 @@ export function ConnectionsNewRouteScreen({...@@ -158,7 +161,7 @@ export function ConnectionsNewRouteScreen({
158 setIsSubmitting(true);161 setIsSubmitting(true);
159 onChangeConnectionPairingUrl(pairingUrl);162 onChangeConnectionPairingUrl(pairingUrl);
160 try {163 try {
161 const result = await onConnectPress(pairingUrl);164 const result = await onConnectPress(pairingUrl, params.routeFor);
162 if (AsyncResult.isSuccess(result)) {165 if (AsyncResult.isSuccess(result)) {
163 if (replaceWithHome || !navigation.canGoBack()) {166 if (replaceWithHome || !navigation.canGoBack()) {
164 navigation.dispatch(StackActions.replace("Home"));167 navigation.dispatch(StackActions.replace("Home"));
...@@ -170,7 +173,7 @@ export function ConnectionsNewRouteScreen({...@@ -170,7 +173,7 @@ export function ConnectionsNewRouteScreen({
170 setIsSubmitting(false);173 setIsSubmitting(false);
171 }174 }
172 },175 },
173 [navigation, onChangeConnectionPairingUrl, onConnectPress],176 [navigation, onChangeConnectionPairingUrl, onConnectPress, params.routeFor],
174 );177 );
175178
176 const handleSubmit = useCallback(async () => {179 const handleSubmit = useCallback(async () => {
apps/mobile/src/features/connection/environmentSections.test.ts+17
...@@ -157,4 +157,21 @@ describe("mobile environment settings sections", () => {...@@ -157,4 +157,21 @@ describe("mobile environment settings sections", () => {
157 expect(sections.connectedCloudEnvironments).toEqual([cloud]);157 expect(sections.connectedCloudEnvironments).toEqual([cloud]);
158 expect(sections.availableCloudEnvironments).toEqual([]);158 expect(sections.availableCloudEnvironments).toEqual([]);
159 });159 });
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 });
160});177});
apps/mobile/src/features/connection/useConnectionController.ts+5-1
...@@ -69,7 +69,11 @@ export function useConnectionController() {...@@ -69,7 +69,11 @@ export function useConnectionController() {
69 );69 );
7070
71 const connectPairingUrl = useCallback(71 const connectPairingUrl = useCallback(
72 (pairingUrl: string) => connectPairingUrlMutation(pairingUrl),72 (pairingUrl: string, expectedEnvironmentId?: EnvironmentId) =>
73 connectPairingUrlMutation({
74 pairingUrl,
75 ...(expectedEnvironmentId === undefined ? {} : { expectedEnvironmentId }),
76 }),
73 [connectPairingUrlMutation],77 [connectPairingUrlMutation],
74 );78 );
75 const connectRelayEnvironment = useCallback(79 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 @@...@@ -1,5 +1,5 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import type { StaticScreenProps } from "@react-navigation/native";2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import type { EnvironmentId, ServerProvider } from "@t3tools/contracts";3import type { EnvironmentId, ServerProvider } from "@t3tools/contracts";
4import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";4import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
5import { AsyncResult } from "effect/unstable/reactivity";5import { AsyncResult } from "effect/unstable/reactivity";
...@@ -15,6 +15,7 @@ import { environmentSession } from "../../state/session";...@@ -15,6 +15,7 @@ import { environmentSession } from "../../state/session";
15import { useAtomCommand } from "../../state/use-atom-command";15import { useAtomCommand } from "../../state/use-atom-command";
16import { useRemoteConnections } from "../../state/use-remote-environment-registry";16import { useRemoteConnections } from "../../state/use-remote-environment-registry";
17import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";17import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
18import { EnvironmentRoutesSection } from "./EnvironmentRoutesSection";
18import { SettingsActionRow } from "./components/SettingsActionRow";19import { SettingsActionRow } from "./components/SettingsActionRow";
19import { SettingsScreen } from "./components/SettingsScreen";20import { SettingsScreen } from "./components/SettingsScreen";
20import { SettingsSection } from "./components/SettingsSection";21import { SettingsSection } from "./components/SettingsSection";
...@@ -41,6 +42,7 @@ export function SettingsEnvironmentDetailRouteScreen({...@@ -41,6 +42,7 @@ export function SettingsEnvironmentDetailRouteScreen({
4142
42function EnvironmentDetail({ environmentId }: { readonly environmentId: EnvironmentId }) {43function EnvironmentDetail({ environmentId }: { readonly environmentId: EnvironmentId }) {
43 const insets = useSafeAreaInsets();44 const insets = useSafeAreaInsets();
45 const navigation = useNavigation();
44 const connections = useRemoteConnections();46 const connections = useRemoteConnections();
45 const environment = connections.connectedEnvironments.find(47 const environment = connections.connectedEnvironments.find(
46 (entry) => entry.environmentId === environmentId,48 (entry) => entry.environmentId === environmentId,
...@@ -167,6 +169,19 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm...@@ -167,6 +169,19 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm
167 onUpdate={connections.onUpdateEnvironment}169 onUpdate={connections.onUpdateEnvironment}
168 />170 />
169 </SettingsSection>171 </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 />
170 {!connected ? (185 {!connected ? (
171 <Text className="px-2 text-sm text-foreground-muted">186 <Text className="px-2 text-sm text-foreground-muted">
172 Connect this environment to manage it.187 Connect this environment to manage it.
apps/mobile/src/state/environments.ts+2-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import {2import {
3 connectionCatalogDisplayUrl,3 connectionCatalogDisplayUrl,
4 hasRelayRoute,
4 type EnvironmentPresentation as BaseEnvironmentPresentation,5 type EnvironmentPresentation as BaseEnvironmentPresentation,
5} from "@t3tools/client-runtime/connection";6} from "@t3tools/client-runtime/connection";
6import type { EnvironmentId } from "@t3tools/contracts";7import type { EnvironmentId } from "@t3tools/contracts";
...@@ -26,7 +27,7 @@ export function projectEnvironmentPresentation(...@@ -26,7 +27,7 @@ export function projectEnvironmentPresentation(
26 environmentId,27 environmentId,
27 label: presentation.entry.target.label,28 label: presentation.entry.target.label,
28 displayUrl: connectionCatalogDisplayUrl(presentation.entry),29 displayUrl: connectionCatalogDisplayUrl(presentation.entry),
29 relayManaged: presentation.entry.target._tag === "RelayConnectionTarget",30 relayManaged: hasRelayRoute(presentation.entry),
30 };31 };
31}32}
3233
apps/mobile/src/state/remote-environment-projections.ts+4-1
...@@ -52,7 +52,10 @@ export function createRemoteEnvironmentProjectionAtoms(input: {...@@ -52,7 +52,10 @@ export function createRemoteEnvironmentProjectionAtoms(input: {
52 : displayUrl.replace(/^http:/, "ws:")52 : displayUrl.replace(/^http:/, "ws:")
53 : new URL(socketUrl).origin;53 : new URL(socketUrl).origin;
54 const authorization = prepared?.httpAuthorization ?? null;54 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
57 previousEntry = presentation.entry;60 previousEntry = presentation.entry;
58 previousPrepared = prepared;61 previousPrepared = prepared;
apps/mobile/src/state/use-remote-environment-registry.ts+2-2
...@@ -110,10 +110,10 @@ export function useRemoteConnections() {...@@ -110,10 +110,10 @@ export function useRemoteConnections() {
110 }, []);110 }, []);
111111
112 const onConnectPress = useCallback(112 const onConnectPress = useCallback(
113 async (pairingUrl?: string) => {113 async (pairingUrl?: string, expectedEnvironmentId?: EnvironmentId) => {
114 const nextPairingUrl = pairingUrl ?? connectionPairingUrl;114 const nextPairingUrl = pairingUrl ?? connectionPairingUrl;
115 setPendingConnectionError(null);115 setPendingConnectionError(null);
116 const result = await controller.connectPairingUrl(nextPairingUrl);116 const result = await controller.connectPairingUrl(nextPairingUrl, expectedEnvironmentId);
117 if (AsyncResult.isFailure(result)) {117 if (AsyncResult.isFailure(result)) {
118 const error = Cause.squash(result.cause);118 const error = Cause.squash(result.cause);
119 const message =119 const message =
apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx+1
...@@ -327,6 +327,7 @@ describe("cloud onboarding discovery", () => {...@@ -327,6 +327,7 @@ describe("cloud onboarding discovery", () => {
327 {327 {
328 environmentId: newMachineId,328 environmentId: newMachineId,
329 connection: { phase: "connected", error: null, traceId: null },329 connection: { phase: "connected", error: null, traceId: null },
330 relayManaged: true,
330 },331 },
331 ]}332 ]}
332 showSavedEnvironments333 showSavedEnvironments
apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx+35-10
...@@ -48,6 +48,8 @@ function discoveredCompatibilityError(...@@ -48,6 +48,8 @@ function discoveredCompatibilityError(
48export interface SavedCloudEnvironmentConnection {48export interface SavedCloudEnvironmentConnection {
49 readonly environmentId: EnvironmentId;49 readonly environmentId: EnvironmentId;
50 readonly connection: EnvironmentConnectionPresentation;50 readonly connection: EnvironmentConnectionPresentation;
51 /** False for a machine saved over another route (LAN, Tailscale, SSH) only. */
52 readonly relayManaged: boolean;
51 /** Present once connected; carries the user's icon override. */53 /** Present once connected; carries the user's icon override. */
52 readonly serverConfig?: ServerConfig | null;54 readonly serverConfig?: ServerConfig | null;
53}55}
...@@ -121,7 +123,15 @@ export function CloudEnvironmentConnectRows({...@@ -121,7 +123,15 @@ export function CloudEnvironmentConnectRows({
121 ReadonlySet<EnvironmentId>123 ReadonlySet<EnvironmentId>
122 >(new Set());124 >(new Set());
123 const savedById = new Map(125 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),
125 );135 );
126136
127 useEffect(() => {137 useEffect(() => {
...@@ -153,8 +163,12 @@ export function CloudEnvironmentConnectRows({...@@ -153,8 +163,12 @@ export function CloudEnvironmentConnectRows({
153 if (result._tag === "Success") {163 if (result._tag === "Success") {
154 toastManager.add({164 toastManager.add({
155 type: "success",165 type: "success",
156 title: "Environment added",166 title: savedWithoutRelay.has(environment.environmentId)
157 description: `Connecting to ${environment.label} through T3 Connect.`,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.`,
158 });172 });
159 return true;173 return true;
160 }174 }
...@@ -182,10 +196,14 @@ export function CloudEnvironmentConnectRows({...@@ -182,10 +196,14 @@ export function CloudEnvironmentConnectRows({
182 return false;196 return false;
183 };197 };
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.
185 const visibleEnvironments = [...environmentsState.environments.values()].filter(202 const visibleEnvironments = [...environmentsState.environments.values()].filter(
186 ({ environment }) =>203 ({ environment }) =>
187 environment.environmentId !== primaryEnvironmentId &&204 environment.environmentId !== primaryEnvironmentId &&
188 (showSavedEnvironments || !savedById.has(environment.environmentId)),205 (showSavedEnvironments || !savedById.has(environment.environmentId)) &&
206 !(selection && savedWithoutRelay.has(environment.environmentId)),
189 );207 );
190 const selectNewComputers = useEffectEvent(() => {208 const selectNewComputers = useEffectEvent(() => {
191 const seen = selection?.autoSelectedComputers;209 const seen = selection?.autoSelectedComputers;
...@@ -337,19 +355,22 @@ export function CloudEnvironmentConnectRows({...@@ -337,19 +355,22 @@ export function CloudEnvironmentConnectRows({
337 : availability === "checking"355 : availability === "checking"
338 ? "bg-warning"356 ? "bg-warning"
339 : "bg-muted-foreground/35";357 : "bg-muted-foreground/35";
358 const notAdded = savedWithoutRelay.has(environment.environmentId)
359 ? "Saved without T3 Connect"
360 : "Not added";
340 const statusText =361 const statusText =
341 unsupported && !savedEnvironment362 unsupported && !savedEnvironment
342 ? "T3 Connect · Not added · Client not supported"363 ? `T3 Connect · ${notAdded} · Client not supported`
343 : savedConnection364 : savedConnection
344 ? savedConnection.statusText365 ? savedConnection.statusText
345 : availability === "online"366 : availability === "online"
346 ? "T3 Connect · Not added · Relay online"367 ? `T3 Connect · ${notAdded} · Relay online`
347 : availability === "offline"368 : availability === "offline"
348 ? "T3 Connect · Not added · Relay offline"369 ? `T3 Connect · ${notAdded} · Relay offline`
349 : availability === "checking"370 : availability === "checking"
350 ? "T3 Connect · Not added · Checking relay status…"371 ? `T3 Connect · ${notAdded} · Checking relay status…`
351 : (Option.getOrNull(error)?.message ??372 : (Option.getOrNull(error)?.message ??
352 "T3 Connect · Not added · Relay status unavailable");373 `T3 Connect · ${notAdded} · Relay status unavailable`);
353 if (selection) {374 if (selection) {
354 return (375 return (
355 <label376 <label
...@@ -464,7 +485,11 @@ export function CloudEnvironmentConnectRows({...@@ -464,7 +485,11 @@ export function CloudEnvironmentConnectRows({
464 disabled={connectingEnvironmentIds.size > 0}485 disabled={connectingEnvironmentIds.size > 0}
465 onClick={() => void connectEnvironment(environment)}486 onClick={() => void connectEnvironment(environment)}
466 >487 >
467 {connectingEnvironmentIds.has(environment.environmentId) ? "Adding…" : "Add"}488 {connectingEnvironmentIds.has(environment.environmentId)
489 ? "Adding…"
490 : savedWithoutRelay.has(environment.environmentId)
491 ? "Add route"
492 : "Add"}
468 </Button>493 </Button>
469 )}494 )}
470 </div>495 </div>
apps/web/src/components/settings/ConnectionsSettings.tsx+174-36
...@@ -1,8 +1,10 @@...@@ -1,8 +1,10 @@
1import {1import {
2 ChevronRightIcon,
2 ChevronsLeftRightEllipsisIcon,3 ChevronsLeftRightEllipsisIcon,
3 EllipsisIcon,4 EllipsisIcon,
4 PlusIcon,5 PlusIcon,
5 QrCodeIcon,6 QrCodeIcon,
7 RouteIcon,
6 TerminalIcon,8 TerminalIcon,
7} from "lucide-react";9} from "lucide-react";
8import { useAtomValue } from "@effect/atom-react";10import { useAtomValue } from "@effect/atom-react";
...@@ -41,7 +43,12 @@ import {...@@ -41,7 +43,12 @@ import {
41 type EnvironmentId,43 type EnvironmentId,
42 resolveEnvironmentMachineKind,44 resolveEnvironmentMachineKind,
43} from "@t3tools/contracts";45} 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";
45import {52import {
46 isAtomCommandInterrupted,53 isAtomCommandInterrupted,
47 squashAtomCommandFailure,54 squashAtomCommandFailure,
...@@ -69,6 +76,8 @@ import {...@@ -69,6 +76,8 @@ import {
69import { LocalEnvironmentSetting } from "./LocalEnvironmentSetting";76import { LocalEnvironmentSetting } from "./LocalEnvironmentSetting";
70import { searchableSetting } from "./settingsSearch";77import { searchableSetting } from "./settingsSearch";
71import { EnvironmentIconMenu } from "./EnvironmentIconPicker";78import { EnvironmentIconMenu } from "./EnvironmentIconPicker";
79import { EnvironmentRoutesList } from "./EnvironmentRoutesList";
80import { usePreparedConnection } from "~/state/session";
72import {81import {
73 EnvironmentRow,82 EnvironmentRow,
74 environmentTransportLabel,83 environmentTransportLabel,
...@@ -1433,6 +1442,7 @@ type SavedBackendListRowProps = {...@@ -1433,6 +1442,7 @@ type SavedBackendListRowProps = {
1433 removingEnvironmentId: EnvironmentId | null;1442 removingEnvironmentId: EnvironmentId | null;
1434 onSetEnabled: (environmentId: EnvironmentId, enabled: boolean) => void;1443 onSetEnabled: (environmentId: EnvironmentId, enabled: boolean) => void;
1435 onRemove: (environment: EnvironmentPresentation) => void;1444 onRemove: (environment: EnvironmentPresentation) => void;
1445 onAddRoute: (environment: EnvironmentPresentation) => void;
1436};1446};
14371447
1438/**1448/**
...@@ -1482,7 +1492,9 @@ function SavedBackendListRow({...@@ -1482,7 +1492,9 @@ function SavedBackendListRow({
1482 removingEnvironmentId,1492 removingEnvironmentId,
1483 onSetEnabled,1493 onSetEnabled,
1484 onRemove,1494 onRemove,
1495 onAddRoute,
1485}: SavedBackendListRowProps) {1496}: SavedBackendListRowProps) {
1497 const [routesOpen, setRoutesOpen] = useState(false);
1486 const environmentId = environment.environmentId;1498 const environmentId = environment.environmentId;
1487 const unsupported = environment.connection.phase === "unsupported";1499 const unsupported = environment.connection.phase === "unsupported";
1488 const enabled = environment.entry.enabled && !unsupported;1500 const enabled = environment.entry.enabled && !unsupported;
...@@ -1537,8 +1549,13 @@ function SavedBackendListRow({...@@ -1537,8 +1549,13 @@ function SavedBackendListRow({
1537 environment.serverConfig ??1549 environment.serverConfig ??
1538 (lastDescriptor === undefined ? null : { environment: lastDescriptor }),1550 (lastDescriptor === undefined ? null : { environment: lastDescriptor }),
1539 );1551 );
1552 const prepared = usePreparedConnection(environmentId);
1553 const routeCount = connectionRoutes(environment.entry).length;
1540 const subtitleText = [1554 const subtitleText = [
1541 environmentTransportLabel(environment),1555 environmentTransportLabel(
1556 environment,
1557 isConnected && prepared._tag === "Some" ? prepared.value.target : null,
1558 ),
1542 resumingServerUpdate ? "Restarting" : status.text,1559 resumingServerUpdate ? "Restarting" : status.text,
1543 enabled && versionMismatch ? serverVersion : null,1560 enabled && versionMismatch ? serverVersion : null,
1544 ]1561 ]
...@@ -1571,26 +1588,49 @@ function SavedBackendListRow({...@@ -1571,26 +1588,49 @@ function SavedBackendListRow({
1571 label={environment.label}1588 label={environment.label}
1572 dimmed={!enabled}1589 dimmed={!enabled}
1573 subtitle={1590 subtitle={
1574 <Tooltip>1591 <span className="flex min-w-0 items-center gap-1">
1575 {/* The status can change while the tooltip is open, and base-ui only1592 <Tooltip>
1576 re-measures the popup when the trigger's payload changes. */}1593 {/* The status can change while the tooltip is open, and base-ui only
1577 <TooltipTrigger1594 re-measures the popup when the trigger's payload changes. */}
1578 payload={statusTooltip}1595 <TooltipTrigger
1579 render={1596 payload={statusTooltip}
1580 <span1597 render={
1581 className={cn(1598 <span
1582 "block truncate",1599 className={cn(
1583 enabled && status.tone === "error" && !resumingServerUpdate && "text-destructive",1600 "min-w-0 truncate",
1584 )}1601 enabled &&
1585 />1602 status.tone === "error" &&
1586 }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"
1587 >1623 >
1588 {subtitleText}1624 {routeCount === 1 ? "Routes" : `${routeCount} routes`}
1589 </TooltipTrigger>1625 <ChevronRightIcon
1590 <TooltipPopup side="top" className="whitespace-pre-wrap">1626 aria-hidden
1591 {statusTooltip}1627 className={cn(
1592 </TooltipPopup>1628 "size-3 shrink-0 transition-transform duration-150 motion-reduce:transition-none",
1593 </Tooltip>1629 routesOpen && "rotate-90",
1630 )}
1631 />
1632 </button>
1633 </span>
1594 }1634 }
1595 below={1635 below={
1596 serverUpdateState.status !== "idle" ? (1636 serverUpdateState.status !== "idle" ? (
...@@ -1599,6 +1639,14 @@ function SavedBackendListRow({...@@ -1599,6 +1639,14 @@ function SavedBackendListRow({
1599 </div>1639 </div>
1600 ) : null1640 ) : null
1601 }1641 }
1642 detail={
1643 routesOpen ? (
1644 <EnvironmentRoutesList
1645 environment={environment}
1646 onAddRoute={() => onAddRoute(environment)}
1647 />
1648 ) : null
1649 }
1602 >1650 >
1603 {unsupported &&1651 {unsupported &&
1604 environment.entry.serverUpdateRequired === true &&1652 environment.entry.serverUpdateRequired === true &&
...@@ -1659,6 +1707,10 @@ function SavedBackendListRow({...@@ -1659,6 +1707,10 @@ function SavedBackendListRow({
1659 environmentId={environmentId}1707 environmentId={environmentId}
1660 serverConfig={environment.serverConfig}1708 serverConfig={environment.serverConfig}
1661 />1709 />
1710 <MenuItem onClick={() => setRoutesOpen((open) => !open)}>
1711 <RouteIcon />
1712 {routesOpen ? "Hide routes" : "Routes"}
1713 </MenuItem>
1662 {errorTraceId ? (1714 {errorTraceId ? (
1663 <MenuItem onClick={() => copyTraceId(errorTraceId)}>Copy trace ID</MenuItem>1715 <MenuItem onClick={() => copyTraceId(errorTraceId)}>Copy trace ID</MenuItem>
1664 ) : null}1716 ) : null}
...@@ -1848,6 +1900,10 @@ export function ConnectionsSettings() {...@@ -1848,6 +1900,10 @@ export function ConnectionsSettings() {
1848 reportFailure: false,1900 reportFailure: false,
1849 });1901 });
1850 const removeEnvironment = useAtomCommand(environmentCatalog.remove, { reportFailure: false });1902 const removeEnvironment = useAtomCommand(environmentCatalog.remove, { reportFailure: false });
1903 const registerEnvironment = useAtomCommand(environmentCatalog.register, {
1904 reportFailure: false,
1905 });
1906 const relayDiscoveryState = useRelayEnvironmentDiscovery();
1851 const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, {1907 const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, {
1852 reportFailure: false,1908 reportFailure: false,
1853 });1909 });
...@@ -1974,6 +2030,8 @@ export function ConnectionsSettings() {...@@ -1974,6 +2030,8 @@ export function ConnectionsSettings() {
1974 >(null);2030 >(null);
1975 const [isRevokingOtherDesktopClients, setIsRevokingOtherDesktopClients] = useState(false);2031 const [isRevokingOtherDesktopClients, setIsRevokingOtherDesktopClients] = useState(false);
1976 const [addBackendDialogOpen, setAddBackendDialogOpen] = useState(false);2032 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);
1977 const [savedBackendMode, setSavedBackendMode] = useState<"remote" | "ssh">("remote");2035 const [savedBackendMode, setSavedBackendMode] = useState<"remote" | "ssh">("remote");
1978 const [savedBackendHost, setSavedBackendHost] = useState("");2036 const [savedBackendHost, setSavedBackendHost] = useState("");
1979 const [savedBackendPairingCode, setSavedBackendPairingCode] = useState("");2037 const [savedBackendPairingCode, setSavedBackendPairingCode] = useState("");
...@@ -2316,7 +2374,11 @@ export function ConnectionsSettings() {...@@ -2316,7 +2374,11 @@ export function ConnectionsSettings() {
2316 async (target: DesktopSshEnvironmentTarget) => {2374 async (target: DesktopSshEnvironmentTarget) => {
2317 setIsAddingSavedBackend(true);2375 setIsAddingSavedBackend(true);
2318 setSavedBackendError(null);2376 setSavedBackendError(null);
2319 const result = await connectSshEnvironment({ target, label: "" });2377 const result = await connectSshEnvironment({
2378 target,
2379 label: "",
2380 ...(routeTarget ? { expectedEnvironmentId: routeTarget.environmentId } : {}),
2381 });
2320 if (result._tag === "Failure") {2382 if (result._tag === "Failure") {
2321 if (!isAtomCommandInterrupted(result)) {2383 if (!isAtomCommandInterrupted(result)) {
2322 setSavedBackendError(formatDesktopSshConnectionError(squashAtomCommandFailure(result)));2384 setSavedBackendError(formatDesktopSshConnectionError(squashAtomCommandFailure(result)));
...@@ -2333,12 +2395,14 @@ export function ConnectionsSettings() {...@@ -2333,12 +2395,14 @@ export function ConnectionsSettings() {
2333 setAddBackendDialogOpen(false);2395 setAddBackendDialogOpen(false);
2334 toastManager.add({2396 toastManager.add({
2335 type: "success",2397 type: "success",
2336 title: "Environment connected",2398 title: routeTarget ? "Route added" : "Environment connected",
2337 description: `${target.alias} is ready over an SSH-managed tunnel.`,2399 description: routeTarget
2400 ? `${routeTarget.label} can now be reached over SSH ${target.alias}.`
2401 : `${target.alias} is ready over an SSH-managed tunnel.`,
2338 });2402 });
2339 setIsAddingSavedBackend(false);2403 setIsAddingSavedBackend(false);
2340 },2404 },
2341 [connectSshEnvironment],2405 [connectSshEnvironment, routeTarget],
2342 );2406 );
23432407
2344 const handleAddSavedBackend = useCallback(async () => {2408 const handleAddSavedBackend = useCallback(async () => {
...@@ -2381,7 +2445,10 @@ export function ConnectionsSettings() {...@@ -2381,7 +2445,10 @@ export function ConnectionsSettings() {
2381 return;2445 return;
2382 }2446 }
23832447
2384 const result = await connectPairing(remotePairingInput);2448 const result = await connectPairing({
2449 ...remotePairingInput,
2450 ...(routeTarget ? { expectedEnvironmentId: routeTarget.environmentId } : {}),
2451 });
2385 if (result._tag === "Failure") {2452 if (result._tag === "Failure") {
2386 if (!isAtomCommandInterrupted(result)) {2453 if (!isAtomCommandInterrupted(result)) {
2387 const error = squashAtomCommandFailure(result);2454 const error = squashAtomCommandFailure(result);
...@@ -2405,13 +2472,22 @@ export function ConnectionsSettings() {...@@ -2405,13 +2472,22 @@ export function ConnectionsSettings() {
2405 setSavedBackendSshUsername("");2472 setSavedBackendSshUsername("");
2406 setSavedBackendSshPort("");2473 setSavedBackendSshPort("");
2407 setAddBackendDialogOpen(false);2474 setAddBackendDialogOpen(false);
2408 toastManager.add({2475 toastManager.add(
2409 type: "success",2476 routeTarget
2410 title: "Backend added",2477 ? {
2411 description: "The environment is saved and will reconnect on app startup.",2478 type: "success",
2412 });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 );
2413 setIsAddingSavedBackend(false);2488 setIsAddingSavedBackend(false);
2414 }, [2489 }, [
2490 routeTarget,
2415 connectPairing,2491 connectPairing,
2416 connectSavedBackendSshTarget,2492 connectSavedBackendSshTarget,
2417 savedBackendHost,2493 savedBackendHost,
...@@ -2703,8 +2779,57 @@ export function ConnectionsSettings() {...@@ -2703,8 +2779,57 @@ export function ConnectionsSettings() {
2703 </div>2779 </div>
2704 </div>2780 </div>
2705 );2781 );
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 };
2706 const renderRemoteModeBody = () => (2816 const renderRemoteModeBody = () => (
2707 <div className="space-y-4">2817 <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}
2708 {renderRemoteFields()}2833 {renderRemoteFields()}
2709 {savedBackendError ? <p className="text-xs text-destructive">{savedBackendError}</p> : null}2834 {savedBackendError ? <p className="text-xs text-destructive">{savedBackendError}</p> : null}
2710 <Button2835 <Button
...@@ -2714,7 +2839,7 @@ export function ConnectionsSettings() {...@@ -2714,7 +2839,7 @@ export function ConnectionsSettings() {
2714 onClick={() => void handleAddSavedBackend()}2839 onClick={() => void handleAddSavedBackend()}
2715 >2840 >
2716 <PlusIcon className="size-3.5" />2841 <PlusIcon className="size-3.5" />
2717 {isAddingSavedBackend ? "Adding…" : "Add environment"}2842 {isAddingSavedBackend ? "Adding…" : routeTarget ? "Add route" : "Add environment"}
2718 </Button>2843 </Button>
2719 </div>2844 </div>
2720 );2845 );
...@@ -2835,7 +2960,7 @@ export function ConnectionsSettings() {...@@ -2835,7 +2960,7 @@ export function ConnectionsSettings() {
2835 onClick={() => void handleAddSavedBackend()}2960 onClick={() => void handleAddSavedBackend()}
2836 >2961 >
2837 <PlusIcon className="size-3.5" />2962 <PlusIcon className="size-3.5" />
2838 {isAddingSavedBackend ? "Adding…" : "Add environment"}2963 {isAddingSavedBackend ? "Adding…" : routeTarget ? "Add route" : "Add environment"}
2839 </Button>2964 </Button>
2840 </div>2965 </div>
2841 </div>2966 </div>
...@@ -3726,7 +3851,9 @@ export function ConnectionsSettings() {...@@ -3726,7 +3851,9 @@ export function ConnectionsSettings() {
3726 open={addBackendDialogOpen}3851 open={addBackendDialogOpen}
3727 onOpenChange={(open) => {3852 onOpenChange={(open) => {
3728 setAddBackendDialogOpen(open);3853 setAddBackendDialogOpen(open);
3729 if (!open) {3854 if (open) {
3855 setRouteTarget(null);
3856 } else {
3730 setSavedBackendError(null);3857 setSavedBackendError(null);
3731 }3858 }
3732 }}3859 }}
...@@ -3748,8 +3875,14 @@ export function ConnectionsSettings() {...@@ -3748,8 +3875,14 @@ export function ConnectionsSettings() {
3748 </Tooltip>3875 </Tooltip>
3749 <DialogPopup className="max-h-[80dvh] sm:max-w-3xl">3876 <DialogPopup className="max-h-[80dvh] sm:max-w-3xl">
3750 <DialogHeader>3877 <DialogHeader>
3751 <DialogTitle>Add Environment</DialogTitle>3878 <DialogTitle>
3752 <DialogDescription>Pair another environment to this client.</DialogDescription>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>
3753 </DialogHeader>3886 </DialogHeader>
3754 <DialogPanel>3887 <DialogPanel>
3755 <div className="space-y-4">3888 <div className="space-y-4">
...@@ -3787,6 +3920,11 @@ export function ConnectionsSettings() {...@@ -3787,6 +3920,11 @@ export function ConnectionsSettings() {
3787 removingEnvironmentId={removingSavedEnvironmentId}3920 removingEnvironmentId={removingSavedEnvironmentId}
3788 onSetEnabled={handleSetSavedBackendEnabled}3921 onSetEnabled={handleSetSavedBackendEnabled}
3789 onRemove={handleRemoveSavedBackend}3922 onRemove={handleRemoveSavedBackend}
3923 onAddRoute={(target) => {
3924 setRouteTarget(target);
3925 setSavedBackendError(null);
3926 setAddBackendDialogOpen(true);
3927 }}
3790 />3928 />
3791 ))}3929 ))}
3792 <CloudRemoteEnvironmentRows3930 <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 @@...@@ -1,3 +1,9 @@
1import {
2 type ConnectionTarget,
3 connectionRouteId,
4 connectionRouteLabel,
5 connectionRoutes,
6} from "@t3tools/client-runtime/connection";
1import type { DesktopSshEnvironmentTarget, EnvironmentMachineKind } from "@t3tools/contracts";7import type { DesktopSshEnvironmentTarget, EnvironmentMachineKind } from "@t3tools/contracts";
2import * as Option from "effect/Option";8import * as Option from "effect/Option";
3import type { ReactNode } from "react";9import type { ReactNode } from "react";
...@@ -15,10 +21,26 @@ export function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): str...@@ -15,10 +21,26 @@ export function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): str
15/**21/**
16 * How this client reaches a machine, printed first in every environment row so22 * How this client reaches a machine, printed first in every environment row so
17 * T3 Connect, SSH, WSL, and plain remote links are told apart without a legend.23 * 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.
18 */25 */
19export function environmentTransportLabel(environment: EnvironmentPresentation): string {26export function environmentTransportLabel(
27 environment: EnvironmentPresentation,
28 activeTarget: ConnectionTarget | null = null,
29): string {
20 const { entry } = environment;30 const { entry } = environment;
21 if (entry.target._tag === "PrimaryConnectionTarget") return "This machine";31 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 }
22 if (environment.relayManaged) return "T3 Connect";44 if (environment.relayManaged) return "T3 Connect";
23 if (isDesktopLocalConnectionTarget(entry.target)) return "WSL";45 if (isDesktopLocalConnectionTarget(entry.target)) return "WSL";
24 if (46 if (
...@@ -41,6 +63,7 @@ export function EnvironmentRow({...@@ -41,6 +63,7 @@ export function EnvironmentRow({
41 label,63 label,
42 subtitle,64 subtitle,
43 below,65 below,
66 detail,
44 dimmed = false,67 dimmed = false,
45 className,68 className,
46 children,69 children,
...@@ -50,6 +73,8 @@ export function EnvironmentRow({...@@ -50,6 +73,8 @@ export function EnvironmentRow({
50 readonly subtitle: ReactNode;73 readonly subtitle: ReactNode;
51 /** Extra content under the subtitle, such as update progress. */74 /** Extra content under the subtitle, such as update progress. */
52 readonly below?: ReactNode;75 readonly below?: ReactNode;
76 /** Expanded content under the whole row, aligned with the label, such as its routes. */
77 readonly detail?: ReactNode;
53 readonly dimmed?: boolean;78 readonly dimmed?: boolean;
54 readonly className?: string;79 readonly className?: string;
55 readonly children?: ReactNode;80 readonly children?: ReactNode;
...@@ -69,6 +94,7 @@ export function EnvironmentRow({...@@ -69,6 +94,7 @@ export function EnvironmentRow({
69 {below}94 {below}
70 </div>95 </div>
71 <div className="flex shrink-0 items-center gap-1">{children}</div>96 <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}
72 </div>98 </div>
73 );99 );
74}100}
apps/web/src/connection/onboarding.ts+3-8
...@@ -15,14 +15,9 @@ export const connectPairing = createRuntimeCommand(connectionAtomRuntime, {...@@ -15,14 +15,9 @@ export const connectPairing = createRuntimeCommand(connectionAtomRuntime, {
15 scheduler: onboardingScheduler,15 scheduler: onboardingScheduler,
16 concurrency: {16 concurrency: {
17 mode: "singleFlight",17 mode: "singleFlight",
18 key: (input: { pairingUrl?: string; host?: string; pairingCode?: string }) =>18 key: (input: ConnectionOnboarding.PairingConnectionInput) => JSON.stringify(input),
19 JSON.stringify(input),
20 },19 },
21 execute: (input: {20 execute: (input: ConnectionOnboarding.PairingConnectionInput) =>
22 readonly pairingUrl?: string;
23 readonly host?: string;
24 readonly pairingCode?: string;
25 }) =>
26 ConnectionOnboarding.ConnectionOnboarding.pipe(21 ConnectionOnboarding.ConnectionOnboarding.pipe(
27 Effect.flatMap((onboarding) => onboarding.registerPairing(input)),22 Effect.flatMap((onboarding) => onboarding.registerPairing(input)),
28 ),23 ),
...@@ -35,7 +30,7 @@ export const connectSshEnvironment = createRuntimeCommand(connectionAtomRuntime,...@@ -35,7 +30,7 @@ export const connectSshEnvironment = createRuntimeCommand(connectionAtomRuntime,
35 mode: "serial",30 mode: "serial",
36 key: (input: { readonly target: DesktopSshEnvironmentTarget }) => JSON.stringify(input.target),31 key: (input: { readonly target: DesktopSshEnvironmentTarget }) => JSON.stringify(input.target),
37 },32 },
38 execute: (input: { readonly target: DesktopSshEnvironmentTarget; readonly label?: string }) =>33 execute: (input: ConnectionOnboarding.SshConnectionInput) =>
39 ConnectionOnboarding.ConnectionOnboarding.pipe(34 ConnectionOnboarding.ConnectionOnboarding.pipe(
40 Effect.flatMap((onboarding) => onboarding.registerSsh(input)),35 Effect.flatMap((onboarding) => onboarding.registerSsh(input)),
41 ),36 ),
apps/web/src/connection/platform.test.ts+15
...@@ -83,6 +83,21 @@ describe("desktop SSH pairing", () => {...@@ -83,6 +83,21 @@ describe("desktop SSH pairing", () => {
83 }),83 }),
84 );84 );
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
86 it.effect("does not consume the credential when descriptor discovery fails", () =>101 it.effect("does not consume the credential when descriptor discovery fails", () =>
87 Effect.gen(function* () {102 Effect.gen(function* () {
88 const calls: string[] = [];103 const calls: string[] = [];
apps/web/src/connection/platform.ts+83-30
...@@ -26,6 +26,7 @@ import {...@@ -26,6 +26,7 @@ import {
26 type DesktopBridge,26 type DesktopBridge,
27 type DesktopEnvironmentBootstrap,27 type DesktopEnvironmentBootstrap,
28 type DesktopSshEnvironmentTarget,28 type DesktopSshEnvironmentTarget,
29 type EnvironmentId,
29 PRIMARY_LOCAL_ENVIRONMENT_ID,30 PRIMARY_LOCAL_ENVIRONMENT_ID,
30} from "@t3tools/contracts";31} from "@t3tools/contracts";
31import * as Clock from "effect/Clock";32import * as Clock from "effect/Clock";
...@@ -87,28 +88,68 @@ const connectivityLayer = Connectivity.layer({...@@ -87,28 +88,68 @@ const connectivityLayer = Connectivity.layer({
87 ),88 ),
88});89});
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
90const wakeupsLayer = Wakeups.layer({127const wakeupsLayer = Wakeups.layer({
91 changes: Stream.merge(128 changes: Stream.mergeAll(
92 Stream.callback<"application-active">((queue) =>129 [
93 Effect.acquireRelease(130 Stream.callback<"application-active">((queue) =>
94 Effect.sync(() => {131 Effect.acquireRelease(
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) =>
104 Effect.sync(() => {132 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;
106 }),140 }),
107 ).pipe(Effect.asVoid),141 (listener) =>
108 ),142 Effect.sync(() => {
109 managedRelayAccountChanges(appAtomRegistry).pipe(143 document.removeEventListener("visibilitychange", listener);
110 Stream.map(() => "credentials-changed" as const),144 }),
111 ),145 ).pipe(Effect.asVoid),
146 ),
147 managedRelayAccountChanges(appAtomRegistry).pipe(
148 Stream.map(() => "credentials-changed" as const),
149 ),
150 networkPathChanges,
151 ],
152 { concurrency: "unbounded" },
112 ),153 ),
113});154});
114155
...@@ -141,7 +182,11 @@ function sshPreparationError(cause: unknown) {...@@ -141,7 +182,11 @@ function sshPreparationError(cause: unknown) {
141182
142export const provisionDesktopSshEnvironment = Effect.fn(183export const provisionDesktopSshEnvironment = Effect.fn(
143 "web.connectionPlatform.ssh.provisionDesktop",184 "web.connectionPlatform.ssh.provisionDesktop",
144)(function* (bridge: DesktopBridge, target: DesktopSshEnvironmentTarget) {185)(function* (
186 bridge: DesktopBridge,
187 target: DesktopSshEnvironmentTarget,
188 expectedEnvironmentId?: EnvironmentId,
189) {
145 const bootstrap = yield* Effect.tryPromise({190 const bootstrap = yield* Effect.tryPromise({
146 try: () =>191 try: () =>
147 bridge.ensureSshEnvironment(target, {192 bridge.ensureSshEnvironment(target, {
...@@ -160,6 +205,12 @@ export const provisionDesktopSshEnvironment = Effect.fn(...@@ -160,6 +205,12 @@ export const provisionDesktopSshEnvironment = Effect.fn(
160 try: () => bridge.fetchSshEnvironmentDescriptor(bootstrap.httpBaseUrl),205 try: () => bridge.fetchSshEnvironmentDescriptor(bootstrap.httpBaseUrl),
161 catch: sshPreparationError,206 catch: sshPreparationError,
162 });207 });
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 }
163 const access = yield* Effect.tryPromise({214 const access = yield* Effect.tryPromise({
164 try: () => bridge.bootstrapSshBearerSession(bootstrap.httpBaseUrl, pairingToken),215 try: () => bridge.bootstrapSshBearerSession(bootstrap.httpBaseUrl, pairingToken),
165 catch: sshPreparationError,216 catch: sshPreparationError,
...@@ -222,16 +273,18 @@ const capabilitiesLayer = Layer.effectContext(...@@ -222,16 +273,18 @@ const capabilitiesLayer = Layer.effectContext(
222 }).pipe(Effect.map(Option.fromNullishOr)),273 }).pipe(Effect.map(Option.fromNullishOr)),
223 });274 });
224 const ssh = ClientCapabilities.SshEnvironmentGateway.of({275 const ssh = ClientCapabilities.SshEnvironmentGateway.of({
225 provision: Effect.fn("web.connectionPlatform.ssh.provision")(function* (target) {276 provision: Effect.fn("web.connectionPlatform.ssh.provision")(
226 const bridge = window.desktopBridge;277 function* (target, expectedEnvironmentId) {
227 if (bridge === undefined) {278 const bridge = window.desktopBridge;
228 return yield* new ConnectionBlockedError({279 if (bridge === undefined) {
229 reason: "unsupported",280 return yield* new ConnectionBlockedError({
230 detail: "SSH environments are only available in the desktop app.",281 reason: "unsupported",
231 });282 detail: "SSH environments are only available in the desktop app.",
232 }283 });
233 return yield* provisionDesktopSshEnvironment(bridge, target);284 }
234 }),285 return yield* provisionDesktopSshEnvironment(bridge, target, expectedEnvironmentId);
286 },
287 ),
235 prepare: Effect.fn("web.connectionPlatform.ssh.prepare")(function* (input) {288 prepare: Effect.fn("web.connectionPlatform.ssh.prepare")(function* (input) {
236 const bridge = window.desktopBridge;289 const bridge = window.desktopBridge;
237 if (bridge === undefined) {290 if (bridge === undefined) {
apps/web/src/connection/storage.ts+10-4
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 removeCatalogValue,11 removeCatalogValue,
12 removeConnectionFromCatalog,12 removeConnectionFromCatalog,
13 setConnectionEnabledInCatalog,13 setConnectionEnabledInCatalog,
14 setRoutesInCatalog,
14 replaceCatalogValue,15 replaceCatalogValue,
15 Persistence,16 Persistence,
16} from "@t3tools/client-runtime/platform";17} from "@t3tools/client-runtime/platform";
...@@ -86,6 +87,7 @@ function persistenceError(...@@ -86,6 +87,7 @@ function persistenceError(
86 | "list-targets"87 | "list-targets"
87 | "list-disabled-targets"88 | "list-disabled-targets"
88 | "register-connection"89 | "register-connection"
90 | "set-connection-routes"
89 | "remove-connection"91 | "remove-connection"
90 | "set-connection-enabled"92 | "set-connection-enabled"
91 | "load-shell"93 | "load-shell"
...@@ -474,13 +476,17 @@ export const connectionStorageLayer = Layer.effectContext(...@@ -474,13 +476,17 @@ export const connectionStorageLayer = Layer.effectContext(
474 ),476 ),
475 });477 });
476 const registrationStore = Persistence.ConnectionRegistrationStore.of({478 const registrationStore = Persistence.ConnectionRegistrationStore.of({
477 register: (registration) =>479 register: (registration, routes) =>
478 catalog480 catalog
479 .update((document) => registerConnectionInCatalog(document, registration))481 .update((document) => registerConnectionInCatalog(document, registration, routes))
480 .pipe(Effect.mapError((cause) => persistenceError("register-connection", cause))),482 .pipe(Effect.mapError((cause) => persistenceError("register-connection", cause))),
481 remove: (target) =>483 setRoutes: (environmentId, routes) =>
482 catalog484 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))
484 .pipe(Effect.mapError((cause) => persistenceError("remove-connection", cause))),490 .pipe(Effect.mapError((cause) => persistenceError("remove-connection", cause))),
485 setEnabled: (environmentId, enabled) =>491 setEnabled: (environmentId, enabled) =>
486 catalog492 catalog
apps/web/src/onboarding/targetEnvironment.logic.ts+3-3
...@@ -1,16 +1,16 @@...@@ -1,16 +1,16 @@
1import type { ConnectionTarget } from "@t3tools/client-runtime/connection";1import { type ConnectionCatalogEntry, hasRelayRoute } from "@t3tools/client-runtime/connection";
2import type { EnvironmentId } from "@t3tools/contracts";2import type { EnvironmentId } from "@t3tools/contracts";
33
4interface OnboardingEnvironment {4interface OnboardingEnvironment {
5 readonly environmentId: EnvironmentId;5 readonly environmentId: EnvironmentId;
6 readonly connection: { readonly phase: string };6 readonly connection: { readonly phase: string };
7 readonly entry: { readonly target: ConnectionTarget };7 readonly entry: Pick<ConnectionCatalogEntry, "target" | "alternateRoutes">;
8}8}
99
10export function isOnboardingRelayEnvironment(10export function isOnboardingRelayEnvironment(
11 environment: Pick<OnboardingEnvironment, "entry">,11 environment: Pick<OnboardingEnvironment, "entry">,
12): boolean {12): boolean {
13 return environment.entry.target._tag === "RelayConnectionTarget";13 return hasRelayRoute(environment.entry);
14}14}
1515
16/** Keep a directly paired machine pinned while its initial connection completes. */16/** Keep a directly paired machine pinned while its initial connection completes. */
apps/web/src/state/environments.ts+2-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import {2import {
3 connectionCatalogDisplayUrl,3 connectionCatalogDisplayUrl,
4 hasRelayRoute,
4 type EnvironmentPresentation as BaseEnvironmentPresentation,5 type EnvironmentPresentation as BaseEnvironmentPresentation,
5} from "@t3tools/client-runtime/connection";6} from "@t3tools/client-runtime/connection";
6import { Discovery } from "@t3tools/client-runtime/relay";7import { Discovery } from "@t3tools/client-runtime/relay";
...@@ -34,7 +35,7 @@ function projectEnvironmentPresentation(...@@ -34,7 +35,7 @@ function projectEnvironmentPresentation(
34 environmentId,35 environmentId,
35 label: presentation.entry.target.label,36 label: presentation.entry.target.label,
36 displayUrl: connectionCatalogDisplayUrl(presentation.entry),37 displayUrl: connectionCatalogDisplayUrl(presentation.entry),
37 relayManaged: presentation.entry.target._tag === "RelayConnectionTarget",38 relayManaged: hasRelayRoute(presentation.entry),
38 };39 };
39}40}
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...@@ -24,6 +24,24 @@ prove that a route works. In particular, a host's loopback address refers to a
24different machine when another device opens it. Endpoint selection must not24different machine when another device opens it. Endpoint selection must not
25silently fall back to loopback when a shareable endpoint is unavailable.25silently 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
27## Hosted web is a client45## Hosted web is a client
2846
29The hosted web app stores its connection catalog in the browser and connects47The 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...@@ -62,6 +62,26 @@ created in Settings can only be copied from the client that created them while
62its Connections page stays open. If you leave or reload that page, create62its Connections page stays open. If you leave or reload that page, create
63another link to share.63another 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
65### Balance new threads across machines85### Balance new threads across machines
6686
67Auto balance is off by default. On web and desktop, enable it in87Auto 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...@@ -36,9 +36,21 @@ export class SshConnectionProfile extends Schema.TaggedClass<SshConnectionProfil
36export const ConnectionProfile = Schema.Union([BearerConnectionProfile, SshConnectionProfile]);36export const ConnectionProfile = Schema.Union([BearerConnectionProfile, SshConnectionProfile]);
37export type ConnectionProfile = typeof ConnectionProfile.Type;37export 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 */
39export interface ConnectionCatalogEntry {50export interface ConnectionCatalogEntry {
40 readonly target: ConnectionTarget;51 readonly target: ConnectionTarget;
41 readonly profile: Option.Option<ConnectionProfile>;52 readonly profile: Option.Option<ConnectionProfile>;
53 readonly alternateRoutes?: ReadonlyArray<ConnectionRoute>;
42 /** False when the user switched the environment off: saved, but never connects. */54 /** False when the user switched the environment off: saved, but never connects. */
43 readonly enabled: boolean;55 readonly enabled: boolean;
44 /** Discovery rejection stays visible while the saved connection is switched off. */56 /** Discovery rejection stays visible while the saved connection is switched off. */
packages/client-runtime/src/connection/driver.ts+149-9
...@@ -1,16 +1,22 @@...@@ -1,16 +1,22 @@
1import * as Context from "effect/Context";1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";
4import * as Fiber from "effect/Fiber";
3import * as Layer from "effect/Layer";5import * 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";
7import type {10import type {
8 ConnectionAttemptError,11 ConnectionAttemptError,
9 ConnectionAttemptStage,12 ConnectionAttemptStage,
10 PreparedConnection,13 PreparedConnection,
11} from "./model.ts";14} from "./model.ts";
15import { ConnectionTransientError } from "./model.ts";
12import * as ConnectionResolver from "./resolver.ts";16import * as ConnectionResolver from "./resolver.ts";
17import { connectionRoutes, routeEntry, routeHttpBaseUrl } from "./routes.ts";
13import * as RpcSession from "../rpc/session.ts";18import * as RpcSession from "../rpc/session.ts";
19import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
1420
15export type ConnectionDriverProgress =21export type ConnectionDriverProgress =
16 | {22 | {
...@@ -26,6 +32,15 @@ export interface EnvironmentConnectionLease {...@@ -26,6 +32,15 @@ export interface EnvironmentConnectionLease {
26 readonly session: RpcSession.RpcSession;32 readonly session: RpcSession.RpcSession;
27}33}
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
29export class ConnectionDriver extends Context.Service<44export class ConnectionDriver extends Context.Service<
30 ConnectionDriver,45 ConnectionDriver,
31 {46 {
...@@ -33,13 +48,118 @@ export class ConnectionDriver extends Context.Service<...@@ -33,13 +48,118 @@ export class ConnectionDriver extends Context.Service<
33 entry: ConnectionCatalogEntry,48 entry: ConnectionCatalogEntry,
34 reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect<void>,49 reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect<void>,
35 ) => Effect.Effect<EnvironmentConnectionLease, ConnectionAttemptError, Scope.Scope>;50 ) => 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>;
36 }65 }
37>()("@t3tools/client-runtime/connection/driver/ConnectionDriver") {}66>()("@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
39/** @public Service construction is part of the canonical Effect module API. */138/** @public Service construction is part of the canonical Effect module API. */
40export const make = Effect.gen(function* () {139export const make = Effect.gen(function* () {
41 const resolver = yield* ConnectionResolver.ConnectionResolver;140 const resolver = yield* ConnectionResolver.ConnectionResolver;
42 const sessions = yield* RpcSession.RpcSessionFactory;141 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
44 const connect = Effect.fn("ConnectionDriver.connect")(function* (164 const connect = Effect.fn("ConnectionDriver.connect")(function* (
45 entry: ConnectionCatalogEntry,165 entry: ConnectionCatalogEntry,
...@@ -49,17 +169,37 @@ export const make = Effect.gen(function* () {...@@ -49,17 +169,37 @@ export const make = Effect.gen(function* () {
49 yield* Effect.annotateCurrentSpan({169 yield* Effect.annotateCurrentSpan({
50 "connection.environment.id": target.environmentId,170 "connection.environment.id": target.environmentId,
51 "connection.target.kind": target._tag,171 "connection.target.kind": target._tag,
172 "connection.route.count": connectionRoutes(entry).length,
52 });173 });
53 yield* reportProgress({ stage: "preparing" });174 yield* reportProgress({ stage: "preparing" });
54 const prepared = yield* resolver.prepare(entry);175 return yield* connectOverRoutes(
55 yield* reportProgress({ stage: "opening", prepared });176 entry,
56 const session = yield* sessions.connect(prepared);177 (route) => checkRoute(entry, route),
57 yield* reportProgress({ stage: "synchronizing", prepared });178 Effect.fnUntraced(function* (route) {
58 yield* session.ready;179 const prepared = yield* resolver.prepare(routeEntry(entry, route));
59 return { prepared, session } satisfies EnvironmentConnectionLease;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 );
60 });187 });
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 });
63});203});
64204
65export const layer = Layer.effect(ConnectionDriver, make);205export const layer = Layer.effect(ConnectionDriver, make);
packages/client-runtime/src/connection/githubRoutingPermissions.ts+18-2
...@@ -19,9 +19,25 @@ export const StoredGitHubRoutingPermission = Schema.Struct({...@@ -19,9 +19,25 @@ export const StoredGitHubRoutingPermission = Schema.Struct({
19});19});
20export type StoredGitHubRoutingPermission = typeof StoredGitHubRoutingPermission.Type;20export 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 */
23export function gitHubRoutingConnectionKey(entry: ConnectionCatalogEntry): string | null {27export 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 };
25 if (target._tag === "RelayConnectionTarget")41 if (target._tag === "RelayConnectionTarget")
26 return JSON.stringify([target._tag, target.environmentId]);42 return JSON.stringify([target._tag, target.environmentId]);
27 const profile = Option.getOrNull(entry.profile);43 const profile = Option.getOrNull(entry.profile);
packages/client-runtime/src/connection/index.ts+2
...@@ -9,6 +9,8 @@ export * from "./model.ts";...@@ -9,6 +9,8 @@ export * from "./model.ts";
9export * as ConnectionOnboarding from "./onboarding.ts";9export * as ConnectionOnboarding from "./onboarding.ts";
10export * from "./presentation.ts";10export * from "./presentation.ts";
11export * as ProfileStore from "./profileStore.ts";11export * as ProfileStore from "./profileStore.ts";
12export * from "./routes.ts";
13export { type RouteCheck } from "./driver.ts";
12export * as EnvironmentRegistry from "./registry.ts";14export * as EnvironmentRegistry from "./registry.ts";
13// Flat so consumers' inferred types can name them.15// Flat so consumers' inferred types can name them.
14export { EnvironmentNotRegisteredError, PlatformEnvironmentRemovalError } from "./registry.ts";16export { EnvironmentNotRegisteredError, PlatformEnvironmentRemovalError } from "./registry.ts";
packages/client-runtime/src/connection/layer.ts+8-1
...@@ -10,6 +10,7 @@ import * as ConnectionResolver from "./resolver.ts";...@@ -10,6 +10,7 @@ import * as ConnectionResolver from "./resolver.ts";
10import * as ConnectionDriver from "./driver.ts";10import * as ConnectionDriver from "./driver.ts";
11import * as EnvironmentRegistry from "./registry.ts";11import * as EnvironmentRegistry from "./registry.ts";
12import * as ConnectionOnboarding from "./onboarding.ts";12import * as ConnectionOnboarding from "./onboarding.ts";
13import { connectionRoutes, hasRelayRoute } from "./routes.ts";
13import * as PlatformConnectionSource from "../platform/source.ts";14import * as PlatformConnectionSource from "../platform/source.ts";
14import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";15import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";
15import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";16import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
...@@ -43,7 +44,13 @@ export const watchDiscoveredCompatibility = Effect.fn("connection.watchDiscovere...@@ -43,7 +44,13 @@ export const watchDiscoveredCompatibility = Effect.fn("connection.watchDiscovere
43 // a different server with the same environment id, such as a44 // a different server with the same environment id, such as a
44 // preview app that shares the home directory. Its socket handshake45 // preview app that shares the home directory. Its socket handshake
45 // already checks the protocol.46 // 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;
47 const previous = seenChecks.get(environmentId);54 const previous = seenChecks.get(environmentId);
48 const fresh =55 const fresh =
49 previous?.checkedAt !== status.checkedAt ||56 previous?.checkedAt !== status.checkedAt ||
packages/client-runtime/src/connection/onboarding.test.ts+23-4
...@@ -98,12 +98,12 @@ describe("connection onboarding", () => {...@@ -98,12 +98,12 @@ describe("connection onboarding", () => {
98 target: {98 target: {
99 environmentId: "environment-paired",99 environmentId: "environment-paired",
100 label: "Paired environment",100 label: "Paired environment",
101 connectionId: "bearer:environment-paired",101 connectionId: "bearer:environment-paired:https://remote.example.test",
102 },102 },
103 profile: {103 profile: {
104 environmentId: "environment-paired",104 environmentId: "environment-paired",
105 label: "Paired environment",105 label: "Paired environment",
106 connectionId: "bearer:environment-paired",106 connectionId: "bearer:environment-paired:https://remote.example.test",
107 httpBaseUrl: "https://remote.example.test/",107 httpBaseUrl: "https://remote.example.test/",
108 wsBaseUrl: "wss://remote.example.test/",108 wsBaseUrl: "wss://remote.example.test/",
109 },109 },
...@@ -150,6 +150,25 @@ describe("connection onboarding", () => {...@@ -150,6 +150,25 @@ describe("connection onboarding", () => {
150 }),150 }),
151 );151 );
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
153 it.effect("pairs an outdated server so it can be updated from this client", () =>172 it.effect("pairs an outdated server so it can be updated from this client", () =>
154 Effect.gen(function* () {173 Effect.gen(function* () {
155 const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];174 const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];
...@@ -321,12 +340,12 @@ describe("connection onboarding", () => {...@@ -321,12 +340,12 @@ describe("connection onboarding", () => {
321 target: {340 target: {
322 environmentId: "environment-ssh",341 environmentId: "environment-ssh",
323 label: "Remote development box",342 label: "Remote development box",
324 connectionId: "ssh:environment-ssh",343 connectionId: 'ssh:environment-ssh:["devbox","devbox.example.test","developer",22]',
325 },344 },
326 profile: {345 profile: {
327 environmentId: "environment-ssh",346 environmentId: "environment-ssh",
328 label: "Remote development box",347 label: "Remote development box",
329 connectionId: "ssh:environment-ssh",348 connectionId: 'ssh:environment-ssh:["devbox","devbox.example.test","developer",22]',
330 target,349 target,
331 },350 },
332 });351 });
packages/client-runtime/src/connection/onboarding.ts+51-4
...@@ -32,16 +32,24 @@ import {...@@ -32,16 +32,24 @@ import {
32import * as Persistence from "../platform/persistence.ts";32import * as Persistence from "../platform/persistence.ts";
33import * as EnvironmentRegistry from "./registry.ts";33import * as EnvironmentRegistry from "./registry.ts";
34import { orchestrationProtocolCompatibilityError } from "./compatibility.ts";34import { orchestrationProtocolCompatibilityError } from "./compatibility.ts";
35import { connectionRoutes, routeEntry, sshTargetKey } from "./routes.ts";
3536
36export interface PairingConnectionInput {37export interface PairingConnectionInput {
37 readonly pairingUrl?: string;38 readonly pairingUrl?: string;
38 readonly host?: string;39 readonly host?: string;
39 readonly pairingCode?: string;40 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;
40}46}
4147
42export interface SshConnectionInput {48export interface SshConnectionInput {
43 readonly target: DesktopSshEnvironmentTarget;49 readonly target: DesktopSshEnvironmentTarget;
44 readonly label?: string;50 readonly label?: string;
51 /** Set when adding a route to a saved machine; see `PairingConnectionInput`. */
52 readonly expectedEnvironmentId?: EnvironmentId;
45}53}
4654
47export interface BearerConnectionUpdateInput {55export interface BearerConnectionUpdateInput {
...@@ -84,6 +92,22 @@ const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.reso...@@ -84,6 +92,22 @@ const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.reso
84 },92 },
85);93);
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
87export const preparePairingRegistration = Effect.fn(111export const preparePairingRegistration = Effect.fn(
88 "clientRuntime.connection.onboarding.preparePairingRegistration",112 "clientRuntime.connection.onboarding.preparePairingRegistration",
89)(function* (input: PairingConnectionInput) {113)(function* (input: PairingConnectionInput) {
...@@ -92,6 +116,13 @@ export const preparePairingRegistration = Effect.fn(...@@ -92,6 +116,13 @@ export const preparePairingRegistration = Effect.fn(
92 const descriptor = yield* fetchRemoteEnvironmentDescriptor({116 const descriptor = yield* fetchRemoteEnvironmentDescriptor({
93 httpBaseUrl: target.httpBaseUrl,117 httpBaseUrl: target.httpBaseUrl,
94 }).pipe(Effect.mapError(mapRemoteEnvironmentError));118 }).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 }
95 const compatibilityError = orchestrationProtocolCompatibilityError(descriptor);126 const compatibilityError = orchestrationProtocolCompatibilityError(descriptor);
96 // An outdated server is still saved so it can be updated from this client.127 // An outdated server is still saved so it can be updated from this client.
97 if (compatibilityError !== null && compatibilityError.serverUpdateRequired !== true) {128 if (compatibilityError !== null && compatibilityError.serverUpdateRequired !== true) {
...@@ -103,7 +134,7 @@ export const preparePairingRegistration = Effect.fn(...@@ -103,7 +134,7 @@ export const preparePairingRegistration = Effect.fn(
103 scopes: presentation.scopes,134 scopes: presentation.scopes,
104 clientMetadata: presentation.metadata,135 clientMetadata: presentation.metadata,
105 }).pipe(Effect.mapError(mapRemoteEnvironmentError));136 }).pipe(Effect.mapError(mapRemoteEnvironmentError));
106 const connectionId = `bearer:${descriptor.environmentId}`;137 const connectionId = bearerConnectionId(descriptor.environmentId, target.httpBaseUrl);
107138
108 return new BearerConnectionRegistration({139 return new BearerConnectionRegistration({
109 target: new BearerConnectionTarget({140 target: new BearerConnectionTarget({
...@@ -141,7 +172,15 @@ const updateBearerConnection = Effect.fn(...@@ -141,7 +172,15 @@ const updateBearerConnection = Effect.fn(
141)(function* (input: BearerConnectionUpdateInput) {172)(function* (input: BearerConnectionUpdateInput) {
142 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;173 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
143 const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore;174 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);
145 const credential =184 const credential =
146 entry?.target._tag === "BearerConnectionTarget"185 entry?.target._tag === "BearerConnectionTarget"
147 ? yield* credentials.get(entry.target.connectionId)186 ? yield* credentials.get(entry.target.connectionId)
...@@ -220,8 +259,10 @@ export const prepareSshRegistration = Effect.fn(...@@ -220,8 +259,10 @@ export const prepareSshRegistration = Effect.fn(
220 "clientRuntime.connection.onboarding.prepareSshRegistration",259 "clientRuntime.connection.onboarding.prepareSshRegistration",
221)(function* (input: SshConnectionInput) {260)(function* (input: SshConnectionInput) {
222 const gateway = yield* ClientCapabilities.SshEnvironmentGateway;261 const gateway = yield* ClientCapabilities.SshEnvironmentGateway;
223 const provisioned = yield* gateway.provision(input.target);262 const provisioned = yield* gateway.provision(input.target, input.expectedEnvironmentId);
224 const connectionId = `ssh:${provisioned.environmentId}`;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)}`;
225 const label = input.label?.trim() || provisioned.label || provisioned.bootstrap.target.alias;266 const label = input.label?.trim() || provisioned.label || provisioned.bootstrap.target.alias;
226267
227 return new SshConnectionRegistration({268 return new SshConnectionRegistration({
...@@ -243,6 +284,12 @@ const registerSshConnection = Effect.fn(...@@ -243,6 +284,12 @@ const registerSshConnection = Effect.fn(
243 "clientRuntime.connection.onboarding.registerSshConnection",284 "clientRuntime.connection.onboarding.registerSshConnection",
244)(function* (input: SshConnectionInput) {285)(function* (input: SshConnectionInput) {
245 const registration = yield* prepareSshRegistration(input);286 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 }
246 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;293 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
247 yield* registry.register(registration);294 yield* registry.register(registration);
248 return registration.target.environmentId;295 return registration.target.environmentId;
packages/client-runtime/src/connection/outdatedHostUpdate.ts+24-5
...@@ -26,6 +26,7 @@ import { isLegacyUpdateHandoffLoss, resolveServerUpdateProgressResult } from ".....@@ -26,6 +26,7 @@ import { isLegacyUpdateHandoffLoss, resolveServerUpdateProgressResult } from "..
26import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";26import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";
27import * as ConnectionResolver from "./resolver.ts";27import * as ConnectionResolver from "./resolver.ts";
28import * as EnvironmentRegistry from "./registry.ts";28import * as EnvironmentRegistry from "./registry.ts";
29import { connectionRoutes, hasRelayRoute, routeEntry, routeHttpBaseUrl } from "./routes.ts";
2930
30// A v1 host restarting into v2 runs migrations before its descriptor answers again.31// A v1 host restarting into v2 runs migrations before its descriptor answers again.
31const OUTDATED_HOST_RESTART_TIMEOUT = Duration.minutes(4);32const OUTDATED_HOST_RESTART_TIMEOUT = Duration.minutes(4);
...@@ -63,7 +64,11 @@ export const updateOutdatedHost = Effect.fn("clientRuntime.connection.updateOutd...@@ -63,7 +64,11 @@ export const updateOutdatedHost = Effect.fn("clientRuntime.connection.updateOutd
63 if (entry === undefined) {64 if (entry === undefined) {
64 return yield* new EnvironmentRegistry.EnvironmentNotRegisteredError({ environmentId });65 return yield* new EnvironmentRegistry.EnvironmentNotRegisteredError({ environmentId });
65 }66 }
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 );
67 const capabilities = descriptor.capabilities;72 const capabilities = descriptor.capabilities;
68 if (73 if (
69 capabilities.serverSelfUpdate === undefined ||74 capabilities.serverSelfUpdate === undefined ||
...@@ -154,9 +159,23 @@ export const updateOutdatedHost = Effect.fn("clientRuntime.connection.updateOutd...@@ -154,9 +159,23 @@ export const updateOutdatedHost = Effect.fn("clientRuntime.connection.updateOutd
154 );159 );
155160
156 yield* onStage("resuming");161 yield* onStage("resuming");
157 const resumed = yield* fetchRemoteEnvironmentDescriptor({162 // The restarted host may answer on another saved route, so each poll asks
158 httpBaseUrl: prepared.httpBaseUrl,163 // every direct address, the one that carried the update first.
159 }).pipe(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(
160 Effect.provideService(HttpClient.HttpClient, httpClient),179 Effect.provideService(HttpClient.HttpClient, httpClient),
161 Effect.option,180 Effect.option,
162 Effect.repeat({181 Effect.repeat({
...@@ -175,7 +194,7 @@ export const updateOutdatedHost = Effect.fn("clientRuntime.connection.updateOutd...@@ -175,7 +194,7 @@ export const updateOutdatedHost = Effect.fn("clientRuntime.connection.updateOutd
175194
176 // Discovery still holds the old relay descriptor and would re-block the195 // Discovery still holds the old relay descriptor and would re-block the
177 // environment from it, so replace that before clearing the block.196 // environment from it, so replace that before clearing the block.
178 if (entry.target._tag === "RelayConnectionTarget") {197 if (hasRelayRoute(entry)) {
179 const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;198 const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
180 yield* discovery.refresh;199 yield* discovery.refresh;
181 }200 }
packages/client-runtime/src/connection/registry.test.ts+365-72
...@@ -26,16 +26,21 @@ import {...@@ -26,16 +26,21 @@ import {
26 BearerConnectionCredential,26 BearerConnectionCredential,
27 BearerConnectionProfile,27 BearerConnectionProfile,
28 BearerConnectionRegistration,28 BearerConnectionRegistration,
29 type ConnectionCatalogEntry,
29 type ConnectionRegistration,30 type ConnectionRegistration,
31 type ConnectionRoute,
30 PrimaryConnectionRegistration,32 PrimaryConnectionRegistration,
31 RelayConnectionRegistration,33 RelayConnectionRegistration,
32 SshConnectionProfile,34 SshConnectionProfile,
35 SshConnectionRegistration,
33 type ConnectionCredential,36 type ConnectionCredential,
34 type ConnectionProfile,37 type ConnectionProfile,
35} from "./catalog.ts";38} from "./catalog.ts";
36import * as Connectivity from "./connectivity.ts";39import * as Connectivity from "./connectivity.ts";
37import * as ConnectionCredentialStore from "./credentialStore.ts";40import * as ConnectionCredentialStore from "./credentialStore.ts";
38import * as ConnectionDriver from "./driver.ts";41import * as ConnectionDriver from "./driver.ts";
42import type { RouteCheck } from "./driver.ts";
43import { connectionRouteId } from "./routes.ts";
39import {44import {
40 ConnectionTransientError,45 ConnectionTransientError,
41 ConnectionBlockedError,46 ConnectionBlockedError,
...@@ -52,6 +57,7 @@ import * as ConnectionProfileStore from "./profileStore.ts";...@@ -52,6 +57,7 @@ import * as ConnectionProfileStore from "./profileStore.ts";
52import * as EnvironmentRegistry from "./registry.ts";57import * as EnvironmentRegistry from "./registry.ts";
53import {58import {
54 GitHubRoutingPermissions,59 GitHubRoutingPermissions,
60 type StoredGitHubRoutingPermission,
55 makeGitHubRoutingPermissions,61 makeGitHubRoutingPermissions,
56} from "./githubRoutingPermissions.ts";62} from "./githubRoutingPermissions.ts";
57import * as RpcSession from "../rpc/session.ts";63import * as RpcSession from "../rpc/session.ts";
...@@ -148,14 +154,14 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (...@@ -148,14 +154,14 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
148 registration: ConnectionRegistration,154 registration: ConnectionRegistration,
149 ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>;155 ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>;
150 readonly beforeRegistrationRemove?: (156 readonly beforeRegistrationRemove?: (
151 target: ConnectionTarget,157 environmentId: EnvironmentId,
152 ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>;158 ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>;
159 readonly checkRoute?: (route: ConnectionRoute) => RouteCheck;
160 readonly prepareRoute?: (target: ConnectionTarget) => ConnectionBlockedError | undefined;
153 readonly initialDisabled?: ReadonlyArray<EnvironmentId>;161 readonly initialDisabled?: ReadonlyArray<EnvironmentId>;
154 },162 },
155) {163) {
156 const storedTargets = yield* Ref.make(164 const storedTargets = yield* Ref.make<ReadonlyArray<ConnectionTarget>>(initialTargets);
157 new Map(initialTargets.map((target) => [target.environmentId, target])),
158 );
159 const shellCache = yield* Ref.make(new Map([[TARGET.environmentId, CACHED_SNAPSHOT]]));165 const shellCache = yield* Ref.make(new Map([[TARGET.environmentId, CACHED_SNAPSHOT]]));
160 const cacheClears = yield* Ref.make<ReadonlyArray<EnvironmentId>>([]);166 const cacheClears = yield* Ref.make<ReadonlyArray<EnvironmentId>>([]);
161 const ownedDataClears = yield* Ref.make<ReadonlyArray<EnvironmentId>>([]);167 const ownedDataClears = yield* Ref.make<ReadonlyArray<EnvironmentId>>([]);
...@@ -191,18 +197,16 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (...@@ -191,18 +197,16 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
191 new Set(options?.initialDisabled ?? []),197 new Set(options?.initialDisabled ?? []),
192 );198 );
193 const targetStore = Persistence.ConnectionTargetStore.of({199 const targetStore = Persistence.ConnectionTargetStore.of({
194 list: Ref.get(storedTargets).pipe(Effect.map((targets) => [...targets.values()])),200 list: Ref.get(storedTargets),
195 listDisabled: Ref.get(storedDisabled).pipe(Effect.map((ids) => [...ids])),201 listDisabled: Ref.get(storedDisabled).pipe(Effect.map((ids) => [...ids])),
196 });202 });
197 const registrationStore = Persistence.ConnectionRegistrationStore.of({203 const registrationStore = Persistence.ConnectionRegistrationStore.of({
198 register: (registration) =>204 register: (registration, routes) =>
199 Effect.gen(function* () {205 Effect.gen(function* () {
200 yield* options?.beforeRegistrationRegister?.(registration) ?? Effect.void;206 yield* options?.beforeRegistrationRegister?.(registration) ?? Effect.void;
201 yield* Ref.update(storedTargets, (current) => {207 yield* Ref.update(storedTargets, (current) =>
202 const next = new Map(current);208 replaceRoutes(current, registration.target.environmentId, routes),
203 next.set(registration.target.environmentId, registration.target);209 );
204 return next;
205 });
206 switch (registration._tag) {210 switch (registration._tag) {
207 case "RelayConnectionRegistration":211 case "RelayConnectionRegistration":
208 return;212 return;
...@@ -226,29 +230,34 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (...@@ -226,29 +230,34 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
226 });230 });
227 }231 }
228 }),232 }),
229 remove: (target) =>233 setRoutes: (environmentId, routes) =>
234 Ref.update(storedTargets, (current) => replaceRoutes(current, environmentId, routes)),
235 remove: (environmentId) =>
230 Effect.gen(function* () {236 Effect.gen(function* () {
231 yield* options?.beforeRegistrationRemove?.(target) ?? Effect.void;237 yield* options?.beforeRegistrationRemove?.(environmentId) ?? Effect.void;
232 yield* Ref.update(storedTargets, (current) => {238 const removed = (yield* Ref.get(storedTargets)).filter(
233 const next = new Map(current);239 (target) => target.environmentId === environmentId,
234 next.delete(target.environmentId);240 );
235 return next;241 yield* Ref.update(storedTargets, (current) =>
236 });242 current.filter((target) => target.environmentId !== environmentId),
237 if (target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget") {243 );
238 yield* Ref.update(storedProfiles, (current) => {244 for (const target of removed) {
239 const next = new Map(current);245 if (target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget") {
240 next.delete(target.connectionId);246 yield* Ref.update(storedProfiles, (current) => {
241 return next;247 const next = new Map(current);
242 });248 next.delete(target.connectionId);
243 yield* Ref.update(storedCredentials, (current) => {249 return next;
244 const next = new Map(current);250 });
245 next.delete(target.connectionId);251 yield* Ref.update(storedCredentials, (current) => {
246 return next;252 const next = new Map(current);
247 });253 next.delete(target.connectionId);
254 return next;
255 });
256 }
248 }257 }
249 yield* Ref.update(storedRemoteTokens, (current) => {258 yield* Ref.update(storedRemoteTokens, (current) => {
250 const next = new Map(current);259 const next = new Map(current);
251 next.delete(target.environmentId);260 next.delete(environmentId);
252 return next;261 return next;
253 });262 });
254 }),263 }),
...@@ -363,38 +372,52 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (...@@ -363,38 +372,52 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
363 prepare: () => Effect.die(new Error("SSH preparation is not used.")),372 prepare: () => Effect.die(new Error("SSH preparation is not used.")),
364 disconnect: (target) => Ref.update(disconnectedSshTargets, (current) => [...current, target]),373 disconnect: (target) => Ref.update(disconnectedSshTargets, (current) => [...current, target]),
365 });374 });
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 });
366 const driver = ConnectionDriver.ConnectionDriver.of({409 const driver = ConnectionDriver.ConnectionDriver.of({
367 connect: (entry, reportProgress) =>410 connect: (entry, reportProgress) =>
368 Effect.gen(function* () {411 reportProgress({ stage: "preparing" }).pipe(
369 const target = entry.target;412 Effect.andThen(
370 const prepared = {413 ConnectionDriver.connectOverRoutes(entry, checkRoute, (route) =>
371 ...PREPARED,414 connectRoute(entry, route),
372 environmentId: target.environmentId,415 ),
373 label: target.label,416 ),
374 target,417 ),
375 };418 checkRoute: (_entry, route) => checkRoute(route),
376 yield* reportProgress({ stage: "preparing" });419 preflight: (_entry, route) =>
377 if (options?.prepareError) return yield* options.prepareError;420 checkRoute(route).pipe(Effect.map((check) => check === "answered")),
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 }),
398 });421 });
399422
400 const cacheLayer = Layer.succeed(Persistence.EnvironmentCacheStore, cacheStore);423 const cacheLayer = Layer.succeed(Persistence.EnvironmentCacheStore, cacheStore);
...@@ -434,9 +457,33 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (...@@ -434,9 +457,33 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
434 storedDisabled,457 storedDisabled,
435 disconnectedSshTargets,458 disconnectedSshTargets,
436 networkStatus,459 networkStatus,
460 connectedRoutes,
437 };461 };
438});462});
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
440function awaitConnectionState(487function awaitConnectionState(
441 registry: EnvironmentRegistry.EnvironmentRegistry["Service"],488 registry: EnvironmentRegistry.EnvironmentRegistry["Service"],
442 environmentId: EnvironmentId,489 environmentId: EnvironmentId,
...@@ -649,7 +696,7 @@ describe("EnvironmentRegistry", () => {...@@ -649,7 +696,7 @@ describe("EnvironmentRegistry", () => {
649 );696 );
650697
651 yield* registry.remove(TARGET.environmentId);698 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);
653 expect((yield* Ref.get(harness.shellCache)).has(TARGET.environmentId)).toBe(false);700 expect((yield* Ref.get(harness.shellCache)).has(TARGET.environmentId)).toBe(false);
654 expect(yield* Ref.get(harness.cacheClears)).toEqual([TARGET.environmentId]);701 expect(yield* Ref.get(harness.cacheClears)).toEqual([TARGET.environmentId]);
655 expect((yield* SubscriptionRef.get(registry.entries)).has(TARGET.environmentId)).toBe(702 expect((yield* SubscriptionRef.get(registry.entries)).has(TARGET.environmentId)).toBe(
...@@ -672,8 +719,8 @@ describe("EnvironmentRegistry", () => {...@@ -672,8 +719,8 @@ describe("EnvironmentRegistry", () => {
672 (state) => state.phase === "connected",719 (state) => state.phase === "connected",
673 );720 );
674721
675 expect((yield* Ref.get(harness.storedTargets)).get(RELAY_TARGET.environmentId)).toEqual(722 expect(routesOf(yield* Ref.get(harness.storedTargets), RELAY_TARGET.environmentId)).toEqual(
676 RELAY_TARGET,723 [RELAY_TARGET],
677 );724 );
678 expect(yield* Ref.get(harness.sessions)).toHaveLength(1);725 expect(yield* Ref.get(harness.sessions)).toHaveLength(1);
679 }).pipe(Effect.provide(harness.layer));726 }).pipe(Effect.provide(harness.layer));
...@@ -997,7 +1044,9 @@ describe("EnvironmentRegistry", () => {...@@ -997,7 +1044,9 @@ describe("EnvironmentRegistry", () => {
997 );1044 );
998 expect(entry?.enabled).toBe(false);1045 expect(entry?.enabled).toBe(false);
999 expect((yield* Ref.get(harness.storedDisabled)).has(RELAY_TARGET.environmentId)).toBe(true);1046 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 );
1001 expect(yield* Ref.get(harness.releasedSessions)).toBe(1);1050 expect(yield* Ref.get(harness.releasedSessions)).toBe(1);
10021051
1003 yield* registry.setEnabled(RELAY_TARGET.environmentId, true);1052 yield* registry.setEnabled(RELAY_TARGET.environmentId, true);
...@@ -1060,7 +1109,7 @@ describe("EnvironmentRegistry", () => {...@@ -1060,7 +1109,7 @@ describe("EnvironmentRegistry", () => {
1060 );1109 );
10611110
1062 expect(yield* Ref.get(harness.disconnectedSshTargets)).toEqual([SSH_TARGET]);1111 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(
1064 true,1113 true,
1065 );1114 );
1066 }).pipe(Effect.provide(harness.layer));1115 }).pipe(Effect.provide(harness.layer));
...@@ -1168,9 +1217,9 @@ describe("EnvironmentRegistry", () => {...@@ -1168,9 +1217,9 @@ describe("EnvironmentRegistry", () => {
1168 yield* registry.removeRelayEnvironments();1217 yield* registry.removeRelayEnvironments();
11691218
1170 const targets = yield* Ref.get(harness.storedTargets);1219 const targets = yield* Ref.get(harness.storedTargets);
1171 expect(targets.has(RELAY_TARGET.environmentId)).toBe(false);1220 expect(hasRoutes(targets, RELAY_TARGET.environmentId)).toBe(false);
1172 expect(targets.has(SECOND_RELAY_TARGET.environmentId)).toBe(false);1221 expect(hasRoutes(targets, SECOND_RELAY_TARGET.environmentId)).toBe(false);
1173 expect(targets.get(BEARER_TARGET.environmentId)).toEqual(BEARER_TARGET);1222 expect(routesOf(targets, BEARER_TARGET.environmentId)).toEqual([BEARER_TARGET]);
1174 expect(yield* Ref.get(harness.cacheClears)).toEqual(1223 expect(yield* Ref.get(harness.cacheClears)).toEqual(
1175 expect.arrayContaining([RELAY_TARGET.environmentId, SECOND_RELAY_TARGET.environmentId]),1224 expect.arrayContaining([RELAY_TARGET.environmentId, SECOND_RELAY_TARGET.environmentId]),
1176 );1225 );
...@@ -1212,7 +1261,9 @@ describe("EnvironmentRegistry", () => {...@@ -1212,7 +1261,9 @@ describe("EnvironmentRegistry", () => {
1212 expect((yield* SubscriptionRef.get(registry.entries)).has(RELAY_TARGET.environmentId)).toBe(1261 expect((yield* SubscriptionRef.get(registry.entries)).has(RELAY_TARGET.environmentId)).toBe(
1213 true,1262 true,
1214 );1263 );
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 );
1216 expect(yield* Ref.get(harness.cacheClears)).toEqual([]);1267 expect(yield* Ref.get(harness.cacheClears)).toEqual([]);
1217 expect(yield* Ref.get(harness.ownedDataClears)).toEqual([]);1268 expect(yield* Ref.get(harness.ownedDataClears)).toEqual([]);
1218 }).pipe(Effect.provide(harness.layer), Effect.scoped);1269 }).pipe(Effect.provide(harness.layer), Effect.scoped);
...@@ -1262,7 +1313,7 @@ describe("EnvironmentRegistry", () => {...@@ -1262,7 +1313,7 @@ describe("EnvironmentRegistry", () => {
1262 (state) => state.phase === "connected",1313 (state) => state.phase === "connected",
1263 );1314 );
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);
1266 expect(1317 expect(
1267 (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target,1318 (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target,
1268 ).toEqual(TARGET);1319 ).toEqual(TARGET);
...@@ -1298,14 +1349,14 @@ describe("EnvironmentRegistry", () => {...@@ -1298,14 +1349,14 @@ describe("EnvironmentRegistry", () => {
1298 expect(1349 expect(
1299 (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target,1350 (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target,
1300 ).toEqual(TARGET);1351 ).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
1303 yield* registry.register(new RelayConnectionRegistration({ target: shadowedTarget }));1354 yield* registry.register(new RelayConnectionRegistration({ target: shadowedTarget }));
13041355
1305 expect(1356 expect(
1306 (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target,1357 (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target,
1307 ).toEqual(TARGET);1358 ).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);
1309 }).pipe(1360 }).pipe(
1310 Effect.provide(harness.layer),1361 Effect.provide(harness.layer),
1311 Effect.provideService(GitHubRoutingPermissions, permissions),1362 Effect.provideService(GitHubRoutingPermissions, permissions),
...@@ -1582,3 +1633,245 @@ describe("EnvironmentRegistry", () => {...@@ -1582,3 +1633,245 @@ describe("EnvironmentRegistry", () => {
1582 }),1633 }),
1583 );1634 );
1584});1635});
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";...@@ -15,7 +15,9 @@ import * as SubscriptionRef from "effect/SubscriptionRef";
15import * as ClientCapabilities from "../platform/capabilities.ts";15import * as ClientCapabilities from "../platform/capabilities.ts";
16import {16import {
17 type ConnectionCatalogEntry,17 type ConnectionCatalogEntry,
18 type ConnectionProfile,
18 type ConnectionRegistration,19 type ConnectionRegistration,
20 type ConnectionRoute,
19 type PlatformConnectionRegistration,21 type PlatformConnectionRegistration,
20 type PrimaryConnectionRegistration,22 type PrimaryConnectionRegistration,
21 SshConnectionProfile,23 SshConnectionProfile,
...@@ -28,6 +30,7 @@ import type {...@@ -28,6 +30,7 @@ import type {
28 ConnectionAttemptError,30 ConnectionAttemptError,
29 ConnectionTarget,31 ConnectionTarget,
30 NetworkStatus,32 NetworkStatus,
33 PersistedConnectionTarget,
31 SupervisorConnectionState,34 SupervisorConnectionState,
32} from "./model.ts";35} from "./model.ts";
33import { ConnectionBlockedError } from "./model.ts";36import { ConnectionBlockedError } from "./model.ts";
...@@ -39,6 +42,14 @@ import {...@@ -39,6 +42,14 @@ import {
39 GitHubRoutingPermissions,42 GitHubRoutingPermissions,
40 gitHubRoutingConnectionKey,43 gitHubRoutingConnectionKey,
41} from "./githubRoutingPermissions.ts";44} from "./githubRoutingPermissions.ts";
45import {
46 RELAY_ROUTE_ID,
47 connectionRouteId,
48 connectionRoutes,
49 entryWithRoutes,
50 findRouteToSameAddress,
51 upsertRoute,
52} from "./routes.ts";
4253
43const isSshConnectionProfile = Schema.is(SshConnectionProfile);54const isSshConnectionProfile = Schema.is(SshConnectionProfile);
4455
...@@ -99,6 +110,35 @@ export class EnvironmentRegistry extends Context.Service<...@@ -99,6 +110,35 @@ export class EnvironmentRegistry extends Context.Service<
99 | EnvironmentNotRegisteredError110 | EnvironmentNotRegisteredError
100 | PlatformEnvironmentRemovalError111 | PlatformEnvironmentRemovalError
101 >;112 >;
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 */
102 readonly removeRelayEnvironments: () => Effect.Effect<142 readonly removeRelayEnvironments: () => Effect.Effect<
103 void,143 void,
104 | Persistence.ConnectionPersistenceError144 | Persistence.ConnectionPersistenceError
...@@ -175,21 +215,37 @@ export const make = Effect.gen(function* () {...@@ -175,21 +215,37 @@ export const make = Effect.gen(function* () {
175 const ssh = yield* ClientCapabilities.SshEnvironmentGateway;215 const ssh = yield* ClientCapabilities.SshEnvironmentGateway;
176 const persistedTargets = yield* storage.list;216 const persistedTargets = yield* storage.list;
177 const disabledEnvironmentIds = new Set(yield* storage.listDisabled);217 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 }
178 const initialEntries = new Map(233 const initialEntries = new Map(
179 yield* Effect.forEach(234 yield* Effect.forEach(
180 persistedTargets,235 persistedRoutesByEnvironment,
181 Effect.fn("EnvironmentRegistry.loadCatalogEntry")(function* (target) {236 Effect.fn("EnvironmentRegistry.loadCatalogEntry")(function* ([environmentId, targets]) {
182 const profile =237 const routes = yield* Effect.forEach(targets, loadRoute, { concurrency: "unbounded" });
183 target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget"238 const first = routes[0]!;
184 ? yield* profiles.get(target.connectionId)
185 : Option.none();
186 return [239 return [
187 target.environmentId,240 environmentId,
188 {241 entryWithRoutes(
189 target,242 {
190 profile,243 target: first.target,
191 enabled: !disabledEnvironmentIds.has(target.environmentId),244 profile: first.profile,
192 } satisfies ConnectionCatalogEntry,245 enabled: !disabledEnvironmentIds.has(environmentId),
246 },
247 routes,
248 ),
193 ] as const;249 ] as const;
194 }),250 }),
195 { concurrency: "unbounded" },251 { concurrency: "unbounded" },
...@@ -202,9 +258,9 @@ export const make = Effect.gen(function* () {...@@ -202,9 +258,9 @@ export const make = Effect.gen(function* () {
202 ReadonlyMap<EnvironmentId, EnvironmentServiceScope>258 ReadonlyMap<EnvironmentId, EnvironmentServiceScope>
203 >(new Map());259 >(new Map());
204 const platformEnvironmentIds = yield* Ref.make<ReadonlySet<EnvironmentId>>(new Set());260 const platformEnvironmentIds = yield* Ref.make<ReadonlySet<EnvironmentId>>(new Set());
205 const persistedTargetsByEnvironment = yield* Ref.make<261 const persistedEnvironmentIds = yield* Ref.make<ReadonlySet<EnvironmentId>>(
206 ReadonlyMap<EnvironmentId, ConnectionTarget>262 new Set(persistedRoutesByEnvironment.keys()),
207 >(new Map(persistedTargets.map((target) => [target.environmentId, target])));263 );
208 interface LeaseLock {264 interface LeaseLock {
209 readonly semaphore: Semaphore.Semaphore;265 readonly semaphore: Semaphore.Semaphore;
210 readonly users: number;266 readonly users: number;
...@@ -407,9 +463,9 @@ export const make = Effect.gen(function* () {...@@ -407,9 +463,9 @@ export const make = Effect.gen(function* () {
407 return;463 return;
408 }464 }
409 yield* Effect.forEach(465 yield* Effect.forEach(
410 persistedTargets,466 persistedRoutesByEnvironment.keys(),
411 (target) =>467 (environmentId) =>
412 acquireSupervisor(target.environmentId).pipe(468 acquireSupervisor(environmentId).pipe(
413 Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void),469 Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void),
414 ),470 ),
415 {471 {
...@@ -445,6 +501,38 @@ export const make = Effect.gen(function* () {...@@ -445,6 +501,38 @@ export const make = Effect.gen(function* () {
445 yield* createServiceScope(entry);501 yield* createServiceScope(entry);
446 });502 });
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
448 const register = Effect.fn("EnvironmentRegistry.register")(function* (536 const register = Effect.fn("EnvironmentRegistry.register")(function* (
449 registration: ConnectionRegistration,537 registration: ConnectionRegistration,
450 ) {538 ) {
...@@ -456,44 +544,58 @@ export const make = Effect.gen(function* () {...@@ -456,44 +544,58 @@ export const make = Effect.gen(function* () {
456 if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) {544 if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) {
457 return;545 return;
458 }546 }
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.
460 const previous = (yield* SubscriptionRef.get(entries)).get(environmentId);550 const previous = (yield* SubscriptionRef.get(entries)).get(environmentId);
461 const entry: ConnectionCatalogEntry =551 let entry = registered;
462 previous === undefined552 if (previous !== undefined) {
463 ? registered553 const route: ConnectionRoute = { target: registered.target, profile: registered.profile };
464 : {554 const existing = connectionRoutes(previous);
465 ...registered,555 const sameAddress = findRouteToSameAddress(existing, route);
466 enabled: previous.enabled,556 const routes = existing.filter(
467 ...(previous.unsupportedReason !== undefined &&557 (existing) =>
468 gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered)558 existing !== sameAddress ||
469 ? unsupportedState(previous)559 connectionRouteId(existing.target) === connectionRouteId(route.target),
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 ),
484 );560 );
561 const next = withRoutes(previous, upsertRoute(routes, route));
562 if (next.addressesChanged) {
563 yield* forgetRoutingTrust(environmentId, "register-connection");
564 }
565 entry = next.entry;
485 }566 }
486 yield* registrations.register(registration);567 yield* registrations.register(registration, persistedRoutes(entry));
487 yield* Ref.update(persistedTargetsByEnvironment, (current) => {568 yield* Ref.update(persistedEnvironmentIds, (current) =>
488 const next = new Map(current);569 new Set(current).add(environmentId),
489 next.set(environmentId, registration.target);570 );
490 return next;
491 });
492 yield* installEntryLocked(entry);571 yield* installEntryLocked(entry);
493 }),572 }),
494 );573 );
495 });574 });
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
497 const installPlatformRegistration = Effect.fn("EnvironmentRegistry.installPlatformRegistration")(599 const installPlatformRegistration = Effect.fn("EnvironmentRegistry.installPlatformRegistration")(
498 function* (registration: PlatformConnectionRegistration) {600 function* (registration: PlatformConnectionRegistration) {
499 const registered = connectionRegistrationCatalogEntry(registration);601 const registered = connectionRegistrationCatalogEntry(registration);
...@@ -507,11 +609,9 @@ export const make = Effect.gen(function* () {...@@ -507,11 +609,9 @@ export const make = Effect.gen(function* () {
507 gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered)609 gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered)
508 ? { ...registered, enabled: false, ...unsupportedState(previous) }610 ? { ...registered, enabled: false, ...unsupportedState(previous) }
509 : registered;611 : registered;
510 const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get(612 const persisted = (yield* Ref.get(persistedEnvironmentIds)).has(target.environmentId);
511 target.environmentId,
512 );
513 if (613 if (
514 persistedTarget !== undefined ||614 persisted ||
515 (previous !== undefined &&615 (previous !== undefined &&
516 gitHubRoutingConnectionKey(previous) !== gitHubRoutingConnectionKey(entry))616 gitHubRoutingConnectionKey(previous) !== gitHubRoutingConnectionKey(entry))
517 ) {617 ) {
...@@ -550,11 +650,11 @@ export const make = Effect.gen(function* () {...@@ -550,11 +650,11 @@ export const make = Effect.gen(function* () {
550 );650 );
551 }651 }
552652
553 if (persistedTarget !== undefined) {653 if (persisted) {
554 yield* registrations.remove(persistedTarget).pipe(654 yield* registrations.remove(target.environmentId).pipe(
555 Effect.tap(() =>655 Effect.tap(() =>
556 Ref.update(persistedTargetsByEnvironment, (current) => {656 Ref.update(persistedEnvironmentIds, (current) => {
557 const next = new Map(current);657 const next = new Set(current);
558 next.delete(target.environmentId);658 next.delete(target.environmentId);
559 return next;659 return next;
560 }),660 }),
...@@ -665,64 +765,111 @@ export const make = Effect.gen(function* () {...@@ -665,64 +765,111 @@ export const make = Effect.gen(function* () {
665 yield* Effect.forEach(platformRegistrations, installPlatformRegistration, { discard: true });765 yield* Effect.forEach(platformRegistrations, installPlatformRegistration, { discard: true });
666 });766 });
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
668 const remove = Effect.fn("EnvironmentRegistry.remove")(function* (environmentId: EnvironmentId) {815 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(
670 environmentId,837 environmentId,
671 Effect.gen(function* () {838 Effect.gen(function* () {
672 if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) {839 const entry = yield* userEntry(environmentId);
673 return yield* new PlatformEnvironmentRemovalError({840 const routes = connectionRoutes(entry);
674 environmentId,841 const route = routes.find((candidate) => connectionRouteId(candidate.target) === routeId);
675 });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);
676 }849 }
677 const target = (yield* getEntry(environmentId)).target;850 }),
678 const profile =851 );
679 target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget"852 });
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 );
710853
711 if (854 const reorderRoutes = Effect.fn("EnvironmentRegistry.reorderRoutes")(function* (
712 target._tag === "SshConnectionTarget" &&855 environmentId: EnvironmentId,
713 Option.isSome(profile) &&856 routeIds: ReadonlyArray<string>,
714 isSshConnectionProfile(profile.value)857 ) {
715 ) {858 yield* withLeaseLock(
716 yield* ssh.disconnect(profile.value.target).pipe(859 environmentId,
717 Effect.tapError((error) =>860 Effect.gen(function* () {
718 Effect.logWarning("Could not disconnect the managed SSH environment.", {861 const entry = yield* userEntry(environmentId);
719 environmentId,862 const routes = connectionRoutes(entry);
720 error,863 const byId = new Map(routes.map((route) => [connectionRouteId(route.target), route]));
721 }),864 const reordered = routeIds.flatMap((id) => byId.get(id) ?? []);
722 ),865 if (reordered.length !== routes.length || new Set(routeIds).size !== routes.length) {
723 Effect.ignore,866 return yield* new ConnectionBlockedError({
724 );867 reason: "configuration",
868 detail: "The route order must list every saved route once.",
869 });
725 }870 }
871 if (reordered.every((route, index) => route === routes[index])) return;
872 yield* replaceRoutesLocked(entry, reordered);
726 }),873 }),
727 );874 );
728 });875 });
...@@ -730,13 +877,15 @@ export const make = Effect.gen(function* () {...@@ -730,13 +877,15 @@ export const make = Effect.gen(function* () {
730 const removeRelayEnvironments = Effect.fn("EnvironmentRegistry.removeRelayEnvironments")(877 const removeRelayEnvironments = Effect.fn("EnvironmentRegistry.removeRelayEnvironments")(
731 function* () {878 function* () {
732 const relayEnvironmentIds = [...(yield* SubscriptionRef.get(entries)).values()]879 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 )
734 .map((entry) => entry.target.environmentId);883 .map((entry) => entry.target.environmentId);
735884
736 yield* Effect.forEach(885 yield* Effect.forEach(
737 relayEnvironmentIds,886 relayEnvironmentIds,
738 (environmentId) =>887 (environmentId) =>
739 remove(environmentId).pipe(888 removeRoute(environmentId, RELAY_ROUTE_ID).pipe(
740 Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void),889 Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void),
741 ),890 ),
742 {891 {
...@@ -798,21 +947,13 @@ export const make = Effect.gen(function* () {...@@ -798,21 +947,13 @@ export const make = Effect.gen(function* () {
798 }947 }
799 // The supervisor only owns the RPC session. A managed SSH backend and948 // The supervisor only owns the RPC session. A managed SSH backend and
800 // its tunnel outlive it, so switching off tears those down as well.949 // its tunnel outlive it, so switching off tears those down as well.
801 if (950 if (!enabled) {
802 !enabled &&951 for (const route of connectionRoutes(entry)) {
803 entry.target._tag === "SshConnectionTarget" &&952 const profile = Option.getOrNull(route.profile);
804 Option.isSome(entry.profile) &&953 if (profile !== null && isSshConnectionProfile(profile)) {
805 isSshConnectionProfile(entry.profile.value)954 yield* disconnectSsh(environmentId, profile);
806 ) {955 }
807 yield* ssh.disconnect(entry.profile.value.target).pipe(956 }
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 );
816 }957 }
817 }),958 }),
818 );959 );
...@@ -904,6 +1045,8 @@ export const make = Effect.gen(function* () {...@@ -904,6 +1045,8 @@ export const make = Effect.gen(function* () {
904 registerPlatform,1045 registerPlatform,
905 reconcilePlatform,1046 reconcilePlatform,
906 remove,1047 remove,
1048 removeRoute,
1049 reorderRoutes,
907 removeRelayEnvironments,1050 removeRelayEnvironments,
908 retryNow,1051 retryNow,
909 setEnabled,1052 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";...@@ -19,10 +19,12 @@ import * as ManagedRelay from "../relay/managedRelay.ts";
19import { remoteHttpClientLayer } from "../rpc/http.ts";19import { remoteHttpClientLayer } from "../rpc/http.ts";
20import type { WsRpcProtocolClient } from "../rpc/protocol.ts";20import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
21import { fetchEnvironmentSessionState } from "../state/session.ts";21import { fetchEnvironmentSessionState } from "../state/session.ts";
22import type { ConnectionCatalogEntry } from "./catalog.ts";22import type { ConnectionCatalogEntry, ConnectionRoute } from "./catalog.ts";
23import * as Connectivity from "./connectivity.ts";23import * as Connectivity from "./connectivity.ts";
24import * as ConnectionDriver from "./driver.ts";24import * as ConnectionDriver from "./driver.ts";
25import { BearerConnectionProfile } from "./catalog.ts";
25import {26import {
27 BearerConnectionTarget,
26 ConnectionBlockedError,28 ConnectionBlockedError,
27 ConnectionTransientError,29 ConnectionTransientError,
28 PrimaryConnectionTarget,30 PrimaryConnectionTarget,
...@@ -73,6 +75,35 @@ const PREPARED_CONNECTION: PreparedConnection = {...@@ -73,6 +75,35 @@ const PREPARED_CONNECTION: PreparedConnection = {
7375
74const TEST_RPC_CLIENT = {} as WsRpcProtocolClient;76const 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
76function transient(message = "Connection failed.") {107function transient(message = "Connection failed.") {
77 return new ConnectionTransientError({108 return new ConnectionTransientError({
78 reason: "transport",109 reason: "transport",
...@@ -125,6 +156,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:...@@ -125,6 +156,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
125 ) => Effect.Effect<PreparedConnection, ConnectionAttemptError>;156 ) => Effect.Effect<PreparedConnection, ConnectionAttemptError>;
126 readonly ready?: (attempt: number) => Effect.Effect<void, ConnectionAttemptError>;157 readonly ready?: (attempt: number) => Effect.Effect<void, ConnectionAttemptError>;
127 readonly probe?: (attempt: number) => Effect.Effect<void, ConnectionAttemptError>;158 readonly probe?: (attempt: number) => Effect.Effect<void, ConnectionAttemptError>;
159 readonly checkRoute?: (route: ConnectionRoute) => Effect.Effect<ConnectionDriver.RouteCheck>;
128}) {160}) {
129 const networkStatus = yield* SubscriptionRef.make<NetworkStatus>(161 const networkStatus = yield* SubscriptionRef.make<NetworkStatus>(
130 options?.networkStatus ?? "online",162 options?.networkStatus ?? "online",
...@@ -156,12 +188,13 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:...@@ -156,12 +188,13 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
156 return PREPARED_CONNECTION;188 return PREPARED_CONNECTION;
157 });189 });
158190
159 const connect = Effect.fn("TestConnectionDriver.connect")(function* (191 const checkRoute = (route: ConnectionRoute) =>
160 entry: ConnectionCatalogEntry,192 options?.checkRoute?.(route) ?? Effect.succeed<ConnectionDriver.RouteCheck>("unchecked");
193
194 const connectRoute = Effect.fn("TestConnectionDriver.connectRoute")(function* (
195 target: ConnectionTarget,
161 reportProgress: (progress: ConnectionDriver.ConnectionDriverProgress) => Effect.Effect<void>,196 reportProgress: (progress: ConnectionDriver.ConnectionDriverProgress) => Effect.Effect<void>,
162 ) {197 ) {
163 const target = entry.target;
164 yield* reportProgress({ stage: "preparing" });
165 const prepared = yield* prepare(target);198 const prepared = yield* prepare(target);
166 yield* reportProgress({ stage: "opening", prepared });199 yield* reportProgress({ stage: "opening", prepared });
167200
...@@ -186,6 +219,16 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:...@@ -186,6 +219,16 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
186 return { prepared, session } satisfies ConnectionDriver.EnvironmentConnectionLease;219 return { prepared, session } satisfies ConnectionDriver.EnvironmentConnectionLease;
187 });220 });
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
189 const dependencies = Layer.mergeAll(232 const dependencies = Layer.mergeAll(
190 // Jitter at its maximum, so each retry waits exactly its ceiling: 2s, 4s, 8s...233 // Jitter at its maximum, so each retry waits exactly its ceiling: 2s, 4s, 8s...
191 Layer.succeed(Random.Random, {234 Layer.succeed(Random.Random, {
...@@ -204,7 +247,12 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:...@@ -204,7 +247,12 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
204 ),247 ),
205 Layer.succeed(248 Layer.succeed(
206 ConnectionDriver.ConnectionDriver,249 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 }),
208 ),256 ),
209 );257 );
210258
...@@ -1336,7 +1384,9 @@ describe("EnvironmentSupervisor", () => {...@@ -1336,7 +1384,9 @@ describe("EnvironmentSupervisor", () => {
13361384
1337 it.effect("releases and reconnects a relay session when credentials change", () =>1385 it.effect("releases and reconnects a relay session when credentials change", () =>
1338 Effect.gen(function* () {1386 Effect.gen(function* () {
1339 const harness = yield* makeHarness();1387 const harness = yield* makeHarness({
1388 prepare: () => Effect.succeed(preparedFor(RELAY_TARGET)),
1389 });
1340 const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, {1390 const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, {
1341 initiallyDesired: true,1391 initiallyDesired: true,
1342 }).pipe(Effect.provide(harness.dependencies));1392 }).pipe(Effect.provide(harness.dependencies));
...@@ -1633,3 +1683,304 @@ describe("EnvironmentSupervisor", () => {...@@ -1633,3 +1683,304 @@ describe("EnvironmentSupervisor", () => {
1633 }),1683 }),
1634 );1684 );
1635});1685});
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";...@@ -15,7 +15,7 @@ import * as Stream from "effect/Stream";
15import * as SubscriptionRef from "effect/SubscriptionRef";15import * as SubscriptionRef from "effect/SubscriptionRef";
16import * as Tracer from "effect/Tracer";16import * as Tracer from "effect/Tracer";
1717
18import type { ConnectionCatalogEntry } from "./catalog.ts";18import type { ConnectionCatalogEntry, ConnectionRoute } from "./catalog.ts";
19import * as Connectivity from "./connectivity.ts";19import * as Connectivity from "./connectivity.ts";
20import * as ConnectionDriver from "./driver.ts";20import * as ConnectionDriver from "./driver.ts";
21import {21import {
...@@ -30,15 +30,23 @@ import * as RpcSession from "../rpc/session.ts";...@@ -30,15 +30,23 @@ import * as RpcSession from "../rpc/session.ts";
30import { safeErrorLogAttributes } from "../errors/safeLog.ts";30import { safeErrorLogAttributes } from "../errors/safeLog.ts";
31import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";31import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
32import * as ConnectionWakeups from "./wakeups.ts";32import * as ConnectionWakeups from "./wakeups.ts";
33import { connectionRouteId, connectionRoutes, entryWithRoutes } from "./routes.ts";
3334
34const RETRY_BASE_DELAY_MS = 1_000;35const RETRY_BASE_DELAY_MS = 1_000;
35const RETRY_MAX_DELAY_MS = 300_000;36const RETRY_MAX_DELAY_MS = 300_000;
36const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds";37const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds";
38const establishmentTimeout = Duration.fromInputUnsafe(CONNECTION_ESTABLISHMENT_TIMEOUT);
37const CONNECTION_PROBE_TIMEOUT = "15 seconds";39const CONNECTION_PROBE_TIMEOUT = "15 seconds";
38// Mobile resumes, explicit retries, and offline events want a fast answer:40// Mobile resumes, explicit retries, and offline events want a fast answer:
39// the user is waiting, or the network may be gone.41// the user is waiting, or the network may be gone.
40const QUICK_CONNECTION_PROBE_TIMEOUT = "3 seconds";42const QUICK_CONNECTION_PROBE_TIMEOUT = "3 seconds";
41const BACKOFF_RESET_AFTER_MS = 30_000;43const 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
43interface SupervisorIntent {51interface SupervisorIntent {
44 readonly desired: boolean;52 readonly desired: boolean;
...@@ -50,7 +58,8 @@ type SupervisorSignal =...@@ -50,7 +58,8 @@ type SupervisorSignal =
50 | { readonly _tag: "DisconnectRequested" }58 | { readonly _tag: "DisconnectRequested" }
51 | { readonly _tag: "RetryRequested" }59 | { readonly _tag: "RetryRequested" }
52 | { readonly _tag: "NetworkChanged"; readonly network: NetworkStatus }60 | { 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
55interface PendingRetryTrace {64interface PendingRetryTrace {
56 readonly previousAttempt: Tracer.Span;65 readonly previousAttempt: Tracer.Span;
...@@ -236,8 +245,13 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -236,8 +245,13 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
236 | ConnectionWakeups.ConnectionWakeups245 | ConnectionWakeups.ConnectionWakeups
237> {246> {
238 const target = entry.target;247 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 );
239 const setupTimeoutDetail = `${target.label} did not respond during connection setup.${253 const setupTimeoutDetail = `${target.label} did not respond during connection setup.${
240 target._tag === "RelayConnectionTarget" ? ` ${NETWORK_BLOCKING_HINT}` : ""254 usesRelay ? ` ${NETWORK_BLOCKING_HINT}` : ""
241 }`;255 }`;
242 yield* annotateTarget(target);256 yield* annotateTarget(target);
243257
...@@ -265,6 +279,64 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -265,6 +279,64 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
265 );279 );
266 const session = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>(Option.none());280 const session = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>(Option.none());
267 const prepared = yield* SubscriptionRef.make<Option.Option<PreparedConnection>>(Option.none());281 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
269 const clearLease = Effect.all(341 const clearLease = Effect.all(
270 [SubscriptionRef.set(session, Option.none()), SubscriptionRef.set(prepared, Option.none())],342 [SubscriptionRef.set(session, Option.none()), SubscriptionRef.set(prepared, Option.none())],
...@@ -309,7 +381,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -309,7 +381,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
309 generation: number,381 generation: number,
310 lastFailure: ConnectionAttemptError | null,382 lastFailure: ConnectionAttemptError | null,
311 ) {383 ) {
312 return yield* driver.connect(entry, (progress) =>384 return yield* driver.connect(yield* attemptEntry, (progress) =>
313 reportProgress(attempt, generation, lastFailure, progress),385 reportProgress(attempt, generation, lastFailure, progress),
314 );386 );
315 });387 });
...@@ -362,7 +434,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -362,7 +434,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
362 lastFailure: ConnectionAttemptError | null,434 lastFailure: ConnectionAttemptError | null,
363 pendingRetry: Option.Option<PendingRetryTrace>,435 pendingRetry: Option.Option<PendingRetryTrace>,
364 ) {436 ) {
365 if (target._tag === "RelayConnectionTarget") {437 if (usesRelay) {
366 return yield* traceRelayEstablishment(438 return yield* traceRelayEstablishment(
367 establishConnection(attempt, generation, lastFailure),439 establishConnection(attempt, generation, lastFailure),
368 attempt,440 attempt,
...@@ -395,12 +467,13 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -395,12 +467,13 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
395 }467 }
396 break;468 break;
397 case "ConnectRequested":469 case "ConnectRequested":
470 case "BetterRouteAvailable":
398 break;471 break;
399 case "Wakeup":472 case "Wakeup":
400 if (next.reason === "application-active-reconnect") {473 if (next.reason === "application-active-reconnect") {
401 return true;474 return true;
402 }475 }
403 if (next.reason === "credentials-changed" && target._tag === "RelayConnectionTarget") {476 if (next.reason === "credentials-changed" && usesRelay) {
404 yield* logManagedRelayAccountChange;477 yield* logManagedRelayAccountChange;
405 return false;478 return false;
406 }479 }
...@@ -411,10 +484,22 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -411,10 +484,22 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
411484
412 // Signals that end a connected lease whatever its health: "reset" ends it485 // Signals that end a connected lease whatever its health: "reset" ends it
413 // and restarts the retry ladder, "end" ends it, undefined keeps it.486 // 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 ) {
415 if (next._tag === "DisconnectRequested") {494 if (next._tag === "DisconnectRequested") {
416 return "end" as const;495 return "end" as const;
417 }496 }
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 }
418 if (next._tag !== "Wakeup") {503 if (next._tag !== "Wakeup") {
419 return undefined;504 return undefined;
420 }505 }
...@@ -424,7 +509,8 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -424,7 +509,8 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
424 // out, so a long background resume replaces the session at once.509 // out, so a long background resume replaces the session at once.
425 return "reset" as const;510 return "reset" as const;
426 }511 }
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)) {
428 yield* logManagedRelayAccountChange;514 yield* logManagedRelayAccountChange;
429 return "end" as const;515 return "end" as const;
430 }516 }
...@@ -443,11 +529,13 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -443,11 +529,13 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
443 if (next.reason === "application-active") {529 if (next.reason === "application-active") {
444 return CONNECTION_PROBE_TIMEOUT;530 return CONNECTION_PROBE_TIMEOUT;
445 }531 }
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"
447 ? QUICK_CONNECTION_PROBE_TIMEOUT534 ? QUICK_CONNECTION_PROBE_TIMEOUT
448 : undefined;535 : undefined;
449 case "ConnectRequested":536 case "ConnectRequested":
450 case "DisconnectRequested":537 case "DisconnectRequested":
538 case "BetterRouteAvailable":
451 return undefined;539 return undefined;
452 }540 }
453 };541 };
...@@ -469,12 +557,26 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -469,12 +557,26 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
469 next._tag === "RetryRequested" ? Ref.set(resetRetryState, false) : Effect.void,557 next._tag === "RetryRequested" ? Ref.set(resetRetryState, false) : Effect.void,
470 ),558 ),
471 );559 );
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 }
472 for (;;) {567 for (;;) {
473 const next = yield* takeSignal;568 const next = yield* takeSignal;
474 const end = yield* connectedLeaseEnd(next);569 const end = yield* connectedLeaseEnd(next, lease);
475 if (end !== undefined) {570 if (end !== undefined) {
476 return end === "reset";571 return end === "reset";
477 }572 }
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 }
478 const probeTimeout = probeTimeoutFor(next);580 const probeTimeout = probeTimeoutFor(next);
479 if (probeTimeout === undefined) {581 if (probeTimeout === undefined) {
480 continue;582 continue;
...@@ -508,7 +610,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -508,7 +610,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
508 yield* probeEvent.exit;610 yield* probeEvent.exit;
509 break;611 break;
510 }612 }
511 const endDuringProbe = yield* connectedLeaseEnd(probeEvent.signal);613 const endDuringProbe = yield* connectedLeaseEnd(probeEvent.signal, lease);
512 if (endDuringProbe !== undefined) {614 if (endDuringProbe !== undefined) {
513 yield* Fiber.interrupt(probe);615 yield* Fiber.interrupt(probe);
514 return endDuringProbe === "reset";616 return endDuringProbe === "reset";
...@@ -532,6 +634,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -532,6 +634,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
532 pendingRetry: Option.Option<PendingRetryTrace>,634 pendingRetry: Option.Option<PendingRetryTrace>,
533 ignoreOffline: boolean,635 ignoreOffline: boolean,
534 ) {636 ) {
637 const switchingTo = yield* Ref.get(preferredRouteId);
535 yield* SubscriptionRef.set(prepared, Option.none());638 yield* SubscriptionRef.set(prepared, Option.none());
536 const establishment = yield* Effect.raceAllFirst([639 const establishment = yield* Effect.raceAllFirst([
537 exitUnlessInterrupted(640 exitUnlessInterrupted(
...@@ -548,7 +651,8 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -548,7 +651,8 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
548 resetRetry,651 resetRetry,
549 })),652 })),
550 ),653 ),
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(
552 Effect.as<EstablishmentEvent>({ _tag: "TimedOut" }),656 Effect.as<EstablishmentEvent>({ _tag: "TimedOut" }),
553 ),657 ),
554 ]);658 ]);
...@@ -595,6 +699,16 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -595,6 +699,16 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
595 }699 }
596700
597 const active = establishment.exit.value;701 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 }
598 const currentIntent = yield* Ref.get(intent);712 const currentIntent = yield* Ref.get(intent);
599 if (!currentIntent.desired || (currentIntent.network === "offline" && !ignoreOffline)) {713 if (!currentIntent.desired || (currentIntent.network === "offline" && !ignoreOffline)) {
600 return {714 return {
...@@ -654,21 +768,25 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -654,21 +768,25 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
654 const next = yield* Queue.take(signals);768 const next = yield* Queue.take(signals);
655 switch (next._tag) {769 switch (next._tag) {
656 case "Wakeup":770 case "Wakeup":
657 return ConnectionWakeups.isApplicationActiveWakeup(next.reason);771 return ConnectionWakeups.resetsRetryBackoff(next.reason);
658 case "ConnectRequested":772 case "ConnectRequested":
659 case "DisconnectRequested":773 case "DisconnectRequested":
660 case "RetryRequested":774 case "RetryRequested":
661 case "NetworkChanged":775 case "NetworkChanged":
662 return false;776 return false;
777 case "BetterRouteAvailable":
778 break;
663 }779 }
664 }780 }
665 }),781 }),
666 );782 );
667 });783 });
668784
785 // A better route only matters to a live session, so idle states ignore it.
669 const waitForSignal = Queue.take(signals).pipe(786 const waitForSignal = Queue.take(signals).pipe(
787 Effect.repeat({ while: (next) => next._tag === "BetterRouteAvailable" }),
670 Effect.map(788 Effect.map(
671 (next) => next._tag === "Wakeup" && ConnectionWakeups.isApplicationActiveWakeup(next.reason),789 (next) => next._tag === "Wakeup" && ConnectionWakeups.resetsRetryBackoff(next.reason),
672 ),790 ),
673 );791 );
674792
...@@ -810,6 +928,11 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -810,6 +928,11 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
810 Stream.runForEach((reason) => signal({ _tag: "Wakeup", reason })),928 Stream.runForEach((reason) => signal({ _tag: "Wakeup", reason })),
811 Effect.forkScoped,929 Effect.forkScoped,
812 );930 );
931 yield* Queue.take(betterRouteChecks).pipe(
932 Effect.flatMap(checkBetterRoutes),
933 Effect.forever,
934 Effect.forkScoped,
935 );
813 yield* run().pipe(Effect.forkScoped);936 yield* run().pipe(Effect.forkScoped);
814937
815 const connect = Ref.update(intent, (current) => ({938 const connect = Ref.update(intent, (current) => ({
packages/client-runtime/src/connection/wakeups.ts+10-2
...@@ -6,9 +6,12 @@ export type ConnectionWakeup =...@@ -6,9 +6,12 @@ export type ConnectionWakeup =
6 | "application-active"6 | "application-active"
7 | "application-active-probe"7 | "application-active-probe"
8 | "application-active-reconnect"8 | "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 {
12 return (15 return (
13 reason === "application-active" ||16 reason === "application-active" ||
14 reason === "application-active-probe" ||17 reason === "application-active-probe" ||
...@@ -16,6 +19,11 @@ export function isApplicationActiveWakeup(reason: ConnectionWakeup): boolean {...@@ -16,6 +19,11 @@ export function isApplicationActiveWakeup(reason: ConnectionWakeup): boolean {
16 );19 );
17}20}
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
19// A long resume replaces the session, and the new session subscribes on its own.27// A long resume replaces the session, and the new session subscribes on its own.
20export function shouldResubscribeAfterWakeup(reason: ConnectionWakeup): boolean {28export function shouldResubscribeAfterWakeup(reason: ConnectionWakeup): boolean {
21 return reason === "application-active" || reason === "application-active-probe";29 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<...@@ -59,8 +59,14 @@ export class PrimaryEnvironmentAuth extends Context.Service<
59export class SshEnvironmentGateway extends Context.Service<59export class SshEnvironmentGateway extends Context.Service<
60 SshEnvironmentGateway,60 SshEnvironmentGateway,
61 {61 {
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 */
62 readonly provision: (67 readonly provision: (
63 target: DesktopSshEnvironmentTarget,68 target: DesktopSshEnvironmentTarget,
69 expectedEnvironmentId?: EnvironmentId,
64 ) => Effect.Effect<ProvisionedSshEnvironment, ConnectionAttemptError>;70 ) => Effect.Effect<ProvisionedSshEnvironment, ConnectionAttemptError>;
65 readonly prepare: (input: {71 readonly prepare: (input: {
66 readonly connectionId: string;72 readonly connectionId: string;
packages/client-runtime/src/platform/persistence.ts+16-2
...@@ -12,7 +12,7 @@ import * as Option from "effect/Option";...@@ -12,7 +12,7 @@ import * as Option from "effect/Option";
12import * as Schema from "effect/Schema";12import * as Schema from "effect/Schema";
1313
14import type { ConnectionRegistration } from "../connection/catalog.ts";14import type { ConnectionRegistration } from "../connection/catalog.ts";
15import type { ConnectionTarget } from "../connection/model.ts";15import type { ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts";
1616
17export class ConnectionPersistenceError extends Schema.TaggedError<ConnectionPersistenceError>()(17export class ConnectionPersistenceError extends Schema.TaggedError<ConnectionPersistenceError>()(
18 "ConnectionPersistenceError",18 "ConnectionPersistenceError",
...@@ -21,6 +21,7 @@ export class ConnectionPersistenceError extends Schema.TaggedError<ConnectionPer...@@ -21,6 +21,7 @@ export class ConnectionPersistenceError extends Schema.TaggedError<ConnectionPer
21 "list-targets",21 "list-targets",
22 "list-disabled-targets",22 "list-disabled-targets",
23 "register-connection",23 "register-connection",
24 "set-connection-routes",
24 "remove-connection",25 "remove-connection",
25 "set-connection-enabled",26 "set-connection-enabled",
26 "load-shell",27 "load-shell",
...@@ -52,10 +53,23 @@ export class ConnectionTargetStore extends Context.Service<...@@ -52,10 +53,23 @@ export class ConnectionTargetStore extends Context.Service<
52export class ConnectionRegistrationStore extends Context.Service<53export class ConnectionRegistrationStore extends Context.Service<
53 ConnectionRegistrationStore,54 ConnectionRegistrationStore,
54 {55 {
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 */
55 readonly register: (60 readonly register: (
56 registration: ConnectionRegistration,61 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,
57 ) => Effect.Effect<void, ConnectionPersistenceError>;72 ) => Effect.Effect<void, ConnectionPersistenceError>;
58 readonly remove: (target: ConnectionTarget) => Effect.Effect<void, ConnectionPersistenceError>;
59 readonly setEnabled: (73 readonly setEnabled: (
60 environmentId: EnvironmentId,74 environmentId: EnvironmentId,
61 enabled: boolean,75 enabled: boolean,
packages/client-runtime/src/platform/storageDocument.test.ts+53-5
...@@ -27,6 +27,8 @@ import {...@@ -27,6 +27,8 @@ import {
27import {27import {
28 ConnectionCatalogDocument,28 ConnectionCatalogDocument,
29 EMPTY_CONNECTION_CATALOG_DOCUMENT,29 EMPTY_CONNECTION_CATALOG_DOCUMENT,
30 catalogRoutes,
31 setRoutesInCatalog,
30 putRemoteDpopTokenInCatalog,32 putRemoteDpopTokenInCatalog,
31 registerConnectionInCatalog,33 registerConnectionInCatalog,
32 removeConnectionFromCatalog,34 removeConnectionFromCatalog,
...@@ -123,7 +125,7 @@ describe("ConnectionCatalogDocument", () => {...@@ -123,7 +125,7 @@ describe("ConnectionCatalogDocument", () => {
123 expect(yield* afterRemoval.get(entry)).toBe("off");125 expect(yield* afterRemoval.get(entry)).toBe("off");
124126
125 yield* permissions.set(entry, "read");127 yield* permissions.set(entry, "read");
126 document = removeConnectionFromCatalog(document, BEARER_TARGET);128 document = removeConnectionFromCatalog(document, ENVIRONMENT_ID);
127 const afterCatalogRemoval = yield* makeGitHubRoutingPermissions(storage);129 const afterCatalogRemoval = yield* makeGitHubRoutingPermissions(storage);
128 expect(yield* afterCatalogRemoval.get(entry)).toBe("off");130 expect(yield* afterCatalogRemoval.get(entry)).toBe("off");
129 }),131 }),
...@@ -273,7 +275,7 @@ describe("ConnectionCatalogDocument", () => {...@@ -273,7 +275,7 @@ describe("ConnectionCatalogDocument", () => {
273 }),275 }),
274 );276 );
275277
276 expect(removeConnectionFromCatalog(registered, BEARER_TARGET)).toEqual(278 expect(removeConnectionFromCatalog(registered, ENVIRONMENT_ID)).toEqual(
277 EMPTY_CONNECTION_CATALOG_DOCUMENT,279 EMPTY_CONNECTION_CATALOG_DOCUMENT,
278 );280 );
279 });281 });
...@@ -295,7 +297,7 @@ describe("ConnectionCatalogDocument", () => {...@@ -295,7 +297,7 @@ describe("ConnectionCatalogDocument", () => {
295 EMPTY_CONNECTION_CATALOG_DOCUMENT,297 EMPTY_CONNECTION_CATALOG_DOCUMENT,
296 new RelayConnectionRegistration({ target: RELAY_TARGET }),298 new RelayConnectionRegistration({ target: RELAY_TARGET }),
297 );299 );
298 const removed = removeConnectionFromCatalog(registered, RELAY_TARGET);300 const removed = removeConnectionFromCatalog(registered, ENVIRONMENT_ID);
299301
300 expect(putRemoteDpopTokenInCatalog(removed, REMOTE_TOKEN)).toEqual(302 expect(putRemoteDpopTokenInCatalog(removed, REMOTE_TOKEN)).toEqual(
301 EMPTY_CONNECTION_CATALOG_DOCUMENT,303 EMPTY_CONNECTION_CATALOG_DOCUMENT,
...@@ -309,7 +311,7 @@ describe("ConnectionCatalogDocument", () => {...@@ -309,7 +311,7 @@ describe("ConnectionCatalogDocument", () => {
309 );311 );
310 const refreshed = putRemoteDpopTokenInCatalog(registered, REMOTE_TOKEN);312 const refreshed = putRemoteDpopTokenInCatalog(registered, REMOTE_TOKEN);
311313
312 expect(removeConnectionFromCatalog(refreshed, RELAY_TARGET)).toEqual(314 expect(removeConnectionFromCatalog(refreshed, ENVIRONMENT_ID)).toEqual(
313 EMPTY_CONNECTION_CATALOG_DOCUMENT,315 EMPTY_CONNECTION_CATALOG_DOCUMENT,
314 );316 );
315 });317 });
...@@ -382,7 +384,9 @@ describe("ConnectionCatalogDocument", () => {...@@ -382,7 +384,9 @@ describe("ConnectionCatalogDocument", () => {
382 }),384 }),
383 ).disabledEnvironmentIds,385 ).disabledEnvironmentIds,
384 ).toEqual([ENVIRONMENT_ID]);386 ).toEqual([ENVIRONMENT_ID]);
385 expect(removeConnectionFromCatalog(disabled, BEARER_TARGET).disabledEnvironmentIds).toEqual([]);387 expect(removeConnectionFromCatalog(disabled, ENVIRONMENT_ID).disabledEnvironmentIds).toEqual(
388 [],
389 );
386 });390 });
387391
388 it("persists the normalized SSH profile beside its target", () => {392 it("persists the normalized SSH profile beside its target", () => {
...@@ -411,4 +415,48 @@ describe("ConnectionCatalogDocument", () => {...@@ -411,4 +415,48 @@ describe("ConnectionCatalogDocument", () => {
411 expect(document.profiles).toEqual([profile]);415 expect(document.profiles).toEqual([profile]);
412 expect(document.credentials).toEqual([]);416 expect(document.credentials).toEqual([]);
413 });417 });
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 });
414});462});
packages/client-runtime/src/platform/storageDocument.ts+80-48
...@@ -70,58 +70,77 @@ function connectionIdOf(target: ConnectionTarget): string | null {...@@ -70,58 +70,77 @@ function connectionIdOf(target: ConnectionTarget): string | null {
70 }70 }
71}71}
7272
73function removeConnectionMetadata(73function routeKey(target: ConnectionTarget): string {
74 return connectionIdOf(target) ?? target._tag;
75}
76
77function removeRouteMetadata(
74 document: ConnectionCatalogDocument,78 document: ConnectionCatalogDocument,
75 target: ConnectionTarget,79 removed: ReadonlyArray<ConnectionTarget>,
76 removeRemoteToken: boolean,
77): ConnectionCatalogDocument {80): 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));
79 return {84 return {
80 ...document,85 ...document,
81 targets: removeCatalogValue(86 profiles: document.profiles.filter((value) => !connectionIds.has(value.connectionId)),
82 document.targets,87 credentials: document.credentials.filter((value) => !connectionIds.has(value.connectionId)),
83 (value) => value.environmentId,88 // The DPoP token belongs to the T3 Connect route.
84 target.environmentId,89 remoteDpopTokens: relayRemoved
85 ),90 ? document.remoteDpopTokens.filter((value) => !environmentIds.has(value.environmentId))
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 )
100 : document.remoteDpopTokens,91 : 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,
106 };92 };
107}93}
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. */
109export function registerConnectionInCatalog(136export function registerConnectionInCatalog(
110 document: ConnectionCatalogDocument,137 document: ConnectionCatalogDocument,
111 registration: ConnectionRegistration,138 registration: ConnectionRegistration,
139 routes: ReadonlyArray<PersistedConnectionTarget> = [registration.target],
112): ConnectionCatalogDocument {140): 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);
119 // Re-registering (for example editing a label or URL) keeps the disabled141 // Re-registering (for example editing a label or URL) keeps the disabled
120 // flag; only `setConnectionEnabledInCatalog` or removal changes it.142 // flag; only `setConnectionEnabledInCatalog` or removal changes it.
121 const next: ConnectionCatalogDocument = {143 const next = setRoutesInCatalog(document, registration.target.environmentId, routes);
122 ...cleaned,
123 targets: replaceCatalogValue(cleaned.targets, (value) => value.environmentId, target),
124 };
125144
126 switch (registration._tag) {145 switch (registration._tag) {
127 case "RelayConnectionRegistration":146 case "RelayConnectionRegistration":
...@@ -151,19 +170,32 @@ export function registerConnectionInCatalog(...@@ -151,19 +170,32 @@ export function registerConnectionInCatalog(
151 }170 }
152}171}
153172
173/** Forgets an environment and every route it had. */
154export function removeConnectionFromCatalog(174export function removeConnectionFromCatalog(
155 document: ConnectionCatalogDocument,175 document: ConnectionCatalogDocument,
156 target: ConnectionTarget,176 environmentId: EnvironmentId,
157): ConnectionCatalogDocument {177): ConnectionCatalogDocument {
158 const next = removeConnectionMetadata(document, target, true);178 const next = setRoutesInCatalog(document, environmentId, []);
159 return document.githubRoutingPermissions === undefined179 return {
160 ? next180 ...next,
161 : {181 remoteDpopTokens: removeCatalogValue(
162 ...next,182 next.remoteDpopTokens,
163 githubRoutingPermissions: document.githubRoutingPermissions.filter(183 (value) => value.environmentId,
164 (permission) => permission.environmentId !== target.environmentId,184 environmentId,
165 ),185 ),
166 };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 };
167}199}
168200
169/** Flips the disabled flag for a saved environment; unknown ids are ignored. */201/** 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>(...@@ -161,6 +161,27 @@ export function createEnvironmentCatalogAtoms<R, E>(
161 Effect.flatMap((registry) => registry.setEnabled(input.environmentId, input.enabled)),161 Effect.flatMap((registry) => registry.setEnabled(input.environmentId, input.enabled)),
162 ),162 ),
163 });163 });
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 });
164 const retryNow = createRuntimeCommand(runtime, {185 const retryNow = createRuntimeCommand(runtime, {
165 label: "environment-catalog:retry-now",186 label: "environment-catalog:retry-now",
166 scheduler: commandScheduler,187 scheduler: commandScheduler,
...@@ -181,6 +202,8 @@ export function createEnvironmentCatalogAtoms<R, E>(...@@ -181,6 +202,8 @@ export function createEnvironmentCatalogAtoms<R, E>(
181 stateAtom,202 stateAtom,
182 register,203 register,
183 remove,204 remove,
205 removeRoute,
206 reorderRoutes,
184 removeRelayEnvironments,207 removeRelayEnvironments,
185 retryNow,208 retryNow,
186 setEnabled,209 setEnabled,
packages/client-runtime/src/state/presentation.ts+2-1
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 type EnvironmentPresentation,15 type EnvironmentPresentation,
16} from "../connection/presentation.ts";16} from "../connection/presentation.ts";
17import type { EnvironmentCatalogState } from "./connections.ts";17import type { EnvironmentCatalogState } from "./connections.ts";
18import { hasRelayRoute } from "../connection/routes.ts";
1819
19function mapsEqual<K, V>(left: ReadonlyMap<K, V>, right: ReadonlyMap<K, V>): boolean {20function mapsEqual<K, V>(left: ReadonlyMap<K, V>, right: ReadonlyMap<K, V>): boolean {
20 if (left.size !== right.size) {21 if (left.size !== right.size) {
...@@ -98,7 +99,7 @@ export function projectEnvironmentConnectionSummary(...@@ -98,7 +99,7 @@ export function projectEnvironmentConnectionSummary(
98 environmentId,99 environmentId,
99 environmentLabel: environment.entry.target.label,100 environmentLabel: environment.entry.target.label,
100 displayUrl: connectionCatalogDisplayUrl(environment.entry) ?? "",101 displayUrl: connectionCatalogDisplayUrl(environment.entry) ?? "",
101 isRelayManaged: environment.entry.target._tag === "RelayConnectionTarget",102 isRelayManaged: hasRelayRoute(environment.entry),
102 isEnabled: environment.entry.enabled,103 isEnabled: environment.entry.enabled,
103 connectionState: environment.connection.phase,104 connectionState: environment.connection.phase,
104 connectionError: environment.connection.error,105 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?...@@ -132,6 +132,8 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options?
132 registerPlatform: () => Effect.die("Unexpected environment registration"),132 registerPlatform: () => Effect.die("Unexpected environment registration"),
133 reconcilePlatform: () => Effect.die("Unexpected environment reconciliation"),133 reconcilePlatform: () => Effect.die("Unexpected environment reconciliation"),
134 remove: () => Effect.die("Unexpected environment removal"),134 remove: () => Effect.die("Unexpected environment removal"),
135 removeRoute: () => Effect.die("Unexpected route removal"),
136 reorderRoutes: () => Effect.die("Unexpected route reorder"),
135 removeRelayEnvironments: () => Effect.die("Unexpected environment removal"),137 removeRelayEnvironments: () => Effect.die("Unexpected environment removal"),
136 retryNow: () => Effect.void,138 retryNow: () => Effect.void,
137 setEnabled: () => Effect.die("Unexpected environment toggle"),139 setEnabled: () => Effect.die("Unexpected environment toggle"),
packages/shared/src/hostClassification.ts+13
...@@ -81,6 +81,19 @@ export const isLocalLoopbackHost = (host: string): boolean => {...@@ -81,6 +81,19 @@ export const isLocalLoopbackHost = (host: string): boolean => {
81 return parseIpv4Address(normalized)?.[0] === 127;81 return parseIpv4Address(normalized)?.[0] === 127;
82};82};
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
84export const isPrivateNetworkHost = (host: string): boolean => {97export const isPrivateNetworkHost = (host: string): boolean => {
85 const normalized = normalizeHostname(host);98 const normalized = normalizeHostname(host);
86 if (99 if (