1import { EnvironmentHttpCommonError } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
8import { HttpClient, HttpClientError, type HttpClientResponse } from "effect/http";
9
10import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
11import type { PreparedConnection } from "../connection/model.ts";
12import * as ManagedRelay from "../relay/managedRelay.ts";
13import {
14 executeAuthenticatedEnvironmentHttpRequest,
15 withOrchestrationProtocolHeader,
16} from "./environmentHttpAuth.ts";
17import {
18 decodeShellSnapshotDeferringPullRequests,
19 type DeferredShellSnapshot,
20} from "./shellPullRequests.ts";
21
22// Long enough for a slow but alive server to finish. On timeout the socket asks
23// the same server for the same full snapshot, so a short deadline only throws
24// the first build away. The socket fallback is for setups where /api fails but
25// /ws works, such as a proxy that blocks /api. A dead server is caught by the
26// socket ping, which drops the session and interrupts this load. The cached
27// shell renders while this runs.
28const DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS = 20_000;
29
30/**
31 * Load the environment shell snapshot (projects + thread shells) over HTTP
32 * instead of as the WebSocket subscription's first frame. The response is
33 * gzip-compressible by the transport and keeps the (potentially large) list off
34 * the socket.
35 */
36export const fetchEnvironmentShellSnapshot = Effect.fn(
37 "clientRuntime.state.fetchEnvironmentShellSnapshot",
38)(function* (input: {
39 readonly prepared: PreparedConnection;
40 readonly signer: Option.Option<ManagedRelay.ManagedRelayDpopSigner["Service"]>;
41 readonly remoteAuthorization?: Option.Option<
42 RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]
43 >;
44 readonly timeoutMs?: number;
45}) {
46 return yield* executeAuthenticatedEnvironmentHttpRequest({
47 ...input,
48 group: "orchestration",
49 method: "GET",
50 url: (urls) => urls.shellSnapshot(),
51 timeoutMs: input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS,
52 // The body is decoded here so each thread's pull request links can decode after its
53 // rows. Every declared error is an `EnvironmentHttpCommonError`, so decoding one keeps
54 // the error the credential retry checks for.
55 request: ({ client, headers }) =>
56 client
57 .shellSnapshot({
58 headers: withOrchestrationProtocolHeader(headers),
59 responseMode: "response-only",
60 })
61 .pipe(Effect.flatMap(decodeShellSnapshotResponse)),
62 });
63});
64
65const decodeEnvironmentHttpCommonError = Schema.decodeUnknownEffect(
66 Schema.toCodecJson(EnvironmentHttpCommonError),
67);
68
69const decodeShellSnapshotResponse = (
70 response: HttpClientResponse.HttpClientResponse,
71): Effect.Effect<
72 DeferredShellSnapshot,
73 HttpClientError.HttpClientError | Schema.SchemaError | EnvironmentHttpCommonError
74> => {
75 if (response.status === 200) {
76 return response.json.pipe(Effect.flatMap(decodeShellSnapshotDeferringPullRequests));
77 }
78 // A body that is not a declared error keeps its status, as the generated decoder reported.
79 const statusError = new HttpClientError.HttpClientError({
80 reason: new HttpClientError.StatusCodeError({ request: response.request, response }),
81 });
82 return response.json.pipe(
83 Effect.flatMap(decodeEnvironmentHttpCommonError),
84 Effect.matchEffect({
85 onFailure: () => Effect.fail(statusError),
86 onSuccess: Effect.fail,
87 }),
88 );
89};
90
91/**
92 * Loads the environment shell snapshot over HTTP, returning `Option.none()` when
93 * it cannot be loaded (so the caller falls back to the socket-embedded snapshot).
94 * Decouples the shell state machine from the underlying HTTP + DPoP details and
95 * keeps them out of test contexts.
96 */
97export class ShellSnapshotLoader extends Context.Service<
98 ShellSnapshotLoader,
99 {
100 readonly load: (
101 prepared: PreparedConnection,
102 ) => Effect.Effect<Option.Option<DeferredShellSnapshot>>;
103 }
104>()("@t3tools/client-runtime/state/shellSnapshotHttp/ShellSnapshotLoader") {}
105
106export const layer: Layer.Layer<ShellSnapshotLoader, never, HttpClient.HttpClient> = Layer.effect(
107 ShellSnapshotLoader,
108 Effect.gen(function* () {
109 const httpClient = yield* HttpClient.HttpClient;
110 // Resolve the DPoP signer optionally: it is only needed for relay/DPoP
111 // connections, so the loader must not hard-require it.
112 const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner);
113 const remoteAuthorization = yield* Effect.serviceOption(
114 RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization,
115 );
116 return ShellSnapshotLoader.of({
117 load: (prepared: PreparedConnection) =>
118 fetchEnvironmentShellSnapshot({ prepared, signer, remoteAuthorization }).pipe(
119 Effect.map(Option.some<DeferredShellSnapshot>),
120 Effect.provideService(HttpClient.HttpClient, httpClient),
121 Effect.catchCause((cause) =>
122 Effect.logWarning(
123 "Could not load the environment shell snapshot over HTTP; using the socket snapshot instead.",
124 ).pipe(
125 Effect.annotateLogs({ cause: Cause.pretty(cause) }),
126 Effect.as(Option.none<DeferredShellSnapshot>()),
127 ),
128 ),
129 ),
130 });
131 }),
132);