1import * as Cache from "effect/Cache";
2import * as Clock from "effect/Clock";
3import type {
4 RelayClientInstallProgressEvent,
5 RelayClientInstallProgressStage,
6} from "@t3tools/contracts";
7import * as Config from "effect/Config";
8import * as Context from "effect/Context";
9import * as Crypto from "effect/Crypto";
10import * as Data from "effect/Data";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";
14import * as Hex from "effect/encoding/Hex";
15import * as FileSystem from "effect/FileSystem";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Path from "effect/Path";
19import * as PlatformError from "effect/PlatformError";
20import * as Schedule from "effect/Schedule";
21import * as Semaphore from "effect/Semaphore";
22import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
23import { ChildProcess, ChildProcessSpawner } from "effect/process";
24import * as HostProcess from "./HostProcess.ts";
25
26export const CLOUDFLARED_VERSION = "2026.10.0";
27// The oldest release that accepts every flag the connector is started with
28// (`--output` arrived in 2025.6.1). Override, PATH, and older managed binaries
29// below it are skipped, since they exit immediately on the unknown flag.
30export const CLOUDFLARED_MIN_VERSION = "2025.6.1";
31const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH";
32
33// Generous bound for a ~40MB binary download; without it a stalled
34// connection parks the install forever (same unbounded-wait family as the
35// other relay/cloud network calls).
36const CLOUDFLARED_DOWNLOAD_TIMEOUT = "10 minutes";
37
38export type RelayClientExecutableSource = "override" | "managed" | "path";
39
40export type RelayClientStatus =
41 | {
42 readonly status: "available";
43 readonly executablePath: string;
44 readonly source: RelayClientExecutableSource;
45 readonly version: string;
46 }
47 | {
48 readonly status: "missing";
49 readonly version: string;
50 }
51 | {
52 readonly status: "unsupported";
53 readonly platform: NodeJS.Platform;
54 readonly arch: string;
55 readonly version: string;
56 };
57
58export type AvailableRelayClient = Extract<RelayClientStatus, { readonly status: "available" }>;
59
60export class RelayClientInstallError extends Data.TaggedError("RelayClientInstallError")<{
61 readonly reason:
62 | "download_failed"
63 | "invalid_checksum"
64 | "install_locked"
65 | "override_missing"
66 | "unsupported_platform"
67 | "validation_failed"
68 | "write_failed";
69 readonly message: string;
70 readonly cause?: unknown;
71}> {}
72
73class CloudflaredCommandError extends Data.TaggedError("CloudflaredCommandError")<{
74 readonly command: string;
75 readonly exitCode: number;
76}> {}
77
78export interface CloudflaredReleaseAsset {
79 readonly url: string;
80 readonly sha256: string;
81 readonly archive: "binary" | "tgz";
82}
83
84const CLOUDFLARED_RELEASE_ASSETS: Readonly<
85 Partial<Record<`${NodeJS.Platform}-${string}`, CloudflaredReleaseAsset>>
86> = {
87 "darwin-arm64": {
88 url: "https://github.com/cloudflare/cloudflared/releases/download/2026.10.0/cloudflared-darwin-arm64.tgz",
89 sha256: "a2f79ff7b9420aa537d74af239f376da170bbabeb529aec416002adac6a72e70",
90 archive: "tgz",
91 },
92 "darwin-x64": {
93 url: "https://github.com/cloudflare/cloudflared/releases/download/2026.10.0/cloudflared-darwin-amd64.tgz",
94 sha256: "903845b81828c8cb3c5d13d816a2de71c06a3da5785469df8eb0e1b736d92f9f",
95 archive: "tgz",
96 },
97 "linux-arm64": {
98 url: "https://github.com/cloudflare/cloudflared/releases/download/2026.10.0/cloudflared-linux-arm64",
99 sha256: "e6422b9d4f72d3194bc5a38676f13667c06666523217b842a877d72a80b5ac08",
100 archive: "binary",
101 },
102 "linux-x64": {
103 url: "https://github.com/cloudflare/cloudflared/releases/download/2026.10.0/cloudflared-linux-amd64",
104 sha256: "d33ff2d14475178d2012c2c56beba87389ac5ded27649519f198a7d3134a99db",
105 archive: "binary",
106 },
107 "win32-x64": {
108 url: "https://github.com/cloudflare/cloudflared/releases/download/2026.10.0/cloudflared-windows-amd64.exe",
109 sha256: "86aee4017b26625cee8484c113558f48effa4cd47f7aa05fcf425604e5d2b23c",
110 archive: "binary",
111 },
112 // Cloudflare publishes no Windows ARM64 build; Windows 11 on ARM runs x64 under emulation.
113 "win32-arm64": {
114 url: "https://github.com/cloudflare/cloudflared/releases/download/2026.10.0/cloudflared-windows-amd64.exe",
115 sha256: "86aee4017b26625cee8484c113558f48effa4cd47f7aa05fcf425604e5d2b23c",
116 archive: "binary",
117 },
118};
119
120const INSTALL_LOCK_RETRY_COUNT = 100;
121const INSTALL_LOCK_RETRY_DELAY = "100 millis";
122const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000;
123const VERSION_PROBE_TIMEOUT = "10 seconds";
124
125const ACTIVATE_RETRY_COUNT = 40;
126const ACTIVATE_RETRY_DELAY = "250 millis";
127
128const trimmedString = (name: string) =>
129 Config.String(name).pipe(
130 Config.option,
131 Config.map(
132 Option.flatMap((value) => {
133 const trimmed = value.trim();
134 return trimmed.length > 0 ? Option.some(trimmed) : Option.none();
135 }),
136 ),
137 );
138
139const CloudflaredConfig = Config.all({
140 executableOverride: trimmedString(CLOUDFLARED_PATH_ENV_NAME),
141 path: trimmedString("PATH"),
142});
143
144export interface CloudflaredRelayClientOptions {
145 readonly baseDir: string;
146 readonly releaseAsset?: CloudflaredReleaseAsset;
147}
148
149export interface RelayClientShape {
150 /**
151 * Finds the relay client to run, without downloading anything: the override,
152 * else the pinned managed folder, else the newest older managed folder, else
153 * `cloudflared` on PATH. Every candidate must report a compatible version, and
154 * the status carries the version the binary reports.
155 */
156 readonly resolve: Effect.Effect<RelayClientStatus>;
157 /** Installs the pinned managed release unless it, or a valid override, is already present. */
158 readonly install: Effect.Effect<AvailableRelayClient, RelayClientInstallError>;
159 readonly installWithProgress: (
160 report: (event: RelayClientInstallProgressEvent) => Effect.Effect<void>,
161 ) => Effect.Effect<AvailableRelayClient, RelayClientInstallError>;
162 /** Removes managed releases older than the pin, except the newest one. Call once the pin has connected. */
163 readonly pruneManagedVersions: Effect.Effect<void>;
164}
165
166/** True when this is the pinned managed release, which needs no update. */
167export function isPinnedManagedRelayClient(client: AvailableRelayClient): boolean {
168 return client.source === "managed" && client.version === CLOUDFLARED_VERSION;
169}
170
171export class RelayClient extends Context.Service<RelayClient, RelayClientShape>()(
172 "@t3tools/shared/relayClient",
173) {}
174
175function executableFileName(platform: NodeJS.Platform): string {
176 return platform === "win32" ? "cloudflared.exe" : "cloudflared";
177}
178
179function resolveReleaseAsset(
180 platform: NodeJS.Platform,
181 arch: string,
182): CloudflaredReleaseAsset | null {
183 return CLOUDFLARED_RELEASE_ASSETS[`${platform}-${arch}`] ?? null;
184}
185
186const VERSION_PATTERN = /\b(\d{4})\.(\d{1,2})\.(\d+)\b/u;
187
188function parseVersion(value: string): readonly [number, number, number] | null {
189 const match = VERSION_PATTERN.exec(value);
190 return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
191}
192
193/** Orders `YYYY.M.P` cloudflared versions; unparseable versions sort first. */
194export function compareCloudflaredVersions(left: string, right: string): number {
195 const a = parseVersion(left) ?? [0, 0, 0];
196 const b = parseVersion(right) ?? [0, 0, 0];
197 return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
198}
199
200/** Reads the version from `cloudflared version` output, e.g. `cloudflared version 2026.5.2 (built ...)`. */
201export function parseCloudflaredVersionOutput(output: string): string | null {
202 const version = parseVersion(output);
203 return version ? version.join(".") : null;
204}
205
206// A changed file is a new key, so a replaced binary is probed again.
207class VersionProbeKey extends Data.Class<{
208 readonly executablePath: string;
209 readonly size: number;
210 readonly mtimeMillis: number;
211}> {}
212
213function isAlreadyExists(error: PlatformError.PlatformError): boolean {
214 return error.reason._tag === "AlreadyExists";
215}
216
217// Windows refuses to rename a file another process briefly holds open, such as a virus
218// scanner reading the binary that just ran. These codes clear once it lets go.
219function isTransientWindowsLock(error: PlatformError.PlatformError): boolean {
220 const code = (error.reason.cause as NodeJS.ErrnoException | undefined)?.code;
221 return code === "EBUSY" || code === "EPERM" || code === "EACCES";
222}
223
224const wrapInstallFailure =
225 (
226 reason: RelayClientInstallError["reason"],
227 message: string,
228 ): (<E, R>(
229 effect: Effect.Effect<void, E, R>,
230 ) => Effect.Effect<void, RelayClientInstallError, R>) =>
231 (effect) =>
232 effect.pipe(
233 Effect.mapError(
234 (cause) =>
235 new RelayClientInstallError({
236 reason,
237 message,
238 cause,
239 }),
240 ),
241 );
242
243export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function* (
244 options: CloudflaredRelayClientOptions,
245): Effect.fn.Return<
246 RelayClientShape,
247 never,
248 | ChildProcessSpawner.ChildProcessSpawner
249 | Crypto.Crypto
250 | FileSystem.FileSystem
251 | HttpClient.HttpClient
252 | Path.Path
253> {
254 const crypto = yield* Crypto.Crypto;
255 const fileSystem = yield* FileSystem.FileSystem;
256 const httpClient = yield* HttpClient.HttpClient;
257 const path = yield* Path.Path;
258 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
259 const installSemaphore = yield* Semaphore.make(1);
260 const platform = yield* HostProcess.Platform;
261 const arch = yield* HostProcess.Architecture;
262 const releaseAsset = options.releaseAsset ?? resolveReleaseAsset(platform, arch);
263 const loadCloudflaredConfig = Effect.suspend(() => CloudflaredConfig).pipe(Effect.orDie);
264 const managedRoot = path.join(options.baseDir, "tools", "cloudflared");
265 const managedPathFor = (version: string) =>
266 path.join(managedRoot, version, `${platform}-${arch}`, executableFileName(platform));
267 const managedPath = managedPathFor(CLOUDFLARED_VERSION);
268
269 const isExecutableFile = Effect.fn("cloudflared.isExecutableFile")(function* (
270 executablePath: string,
271 ) {
272 const info = yield* fileSystem.stat(executablePath).pipe(Effect.option);
273 if (Option.isNone(info) || info.value.type !== "File") return false;
274 return platform === "win32" || (info.value.mode & 0o111) !== 0;
275 });
276
277 // `cloudflared version` costs a process spawn, so answers are cached per file
278 // revision. Before `--no-autoupdate`, a managed binary could replace itself in
279 // place, so the version is read from the binary, never from its folder name.
280 // A failed or timed-out probe is not cached, so a slow spawn under an AV scan
281 // does not hide a good binary until it changes on disk.
282 const versionCache = yield* Cache.makeWith(
283 (key: VersionProbeKey) =>
284 spawner
285 .string(
286 ChildProcess.make(key.executablePath, ["version"], { stdin: "ignore", stderr: "ignore" }),
287 )
288 .pipe(
289 Effect.map(parseCloudflaredVersionOutput),
290 Effect.timeoutOption(VERSION_PROBE_TIMEOUT),
291 Effect.map(Option.getOrNull),
292 Effect.orElseSucceed(() => null),
293 ),
294 {
295 capacity: 16,
296 timeToLive: (exit) =>
297 Exit.isSuccess(exit) && exit.value !== null ? Duration.infinity : Duration.zero,
298 },
299 );
300 const probeVersion = Effect.fn("cloudflared.probeVersion")(function* (executablePath: string) {
301 const info = yield* fileSystem.stat(executablePath).pipe(Effect.option);
302 if (Option.isNone(info) || info.value.type !== "File") return null;
303 if (platform !== "win32" && (info.value.mode & 0o111) === 0) return null;
304 return yield* Cache.get(
305 versionCache,
306 new VersionProbeKey({
307 executablePath,
308 size: Number(info.value.size),
309 mtimeMillis: Option.getOrUndefined(info.value.mtime)?.getTime() ?? 0,
310 }),
311 );
312 });
313
314 const compatibleCandidate = Effect.fn("cloudflared.compatibleCandidate")(function* (
315 executablePath: string,
316 source: RelayClientExecutableSource,
317 expectedVersion?: string,
318 ) {
319 const version = yield* probeVersion(executablePath);
320 if (version === null || compareCloudflaredVersions(version, CLOUDFLARED_MIN_VERSION) < 0) {
321 return null;
322 }
323 if (expectedVersion !== undefined && version !== expectedVersion) {
324 yield* Effect.logWarning("Ignoring a managed relay client that reports another version", {
325 executablePath,
326 expectedVersion,
327 version,
328 });
329 return null;
330 }
331 return { status: "available", executablePath, source, version } satisfies AvailableRelayClient;
332 });
333
334 // Managed releases older than the pin, newest first. Newer folders belong to
335 // a newer server sharing this base dir and are never used or pruned here.
336 const olderManagedVersions = Effect.gen(function* () {
337 const entries = yield* fileSystem
338 .readDirectory(managedRoot)
339 .pipe(Effect.orElseSucceed(() => []));
340 return entries
341 .filter(
342 (entry) =>
343 parseVersion(entry) !== null &&
344 compareCloudflaredVersions(entry, CLOUDFLARED_VERSION) < 0,
345 )
346 .sort((left, right) => compareCloudflaredVersions(right, left));
347 });
348
349 const resolvePathExecutable = Effect.gen(function* () {
350 const config = yield* loadCloudflaredConfig;
351 const pathValue = Option.getOrUndefined(config.path);
352 if (!pathValue) return null;
353 const delimiter = platform === "win32" ? ";" : ":";
354 for (const directory of pathValue.split(delimiter)) {
355 const trimmed = directory.trim().replace(/^"|"$/gu, "");
356 if (trimmed.length === 0) continue;
357 const candidate = path.join(trimmed, executableFileName(platform));
358 if (!(yield* isExecutableFile(candidate))) continue;
359 const available = yield* compatibleCandidate(candidate, "path");
360 if (available) return available;
361 yield* Effect.logWarning("Skipping an incompatible relay client on PATH", {
362 executablePath: candidate,
363 minimumVersion: CLOUDFLARED_MIN_VERSION,
364 });
365 }
366 return null;
367 });
368
369 const missingStatus: RelayClientStatus = { status: "missing", version: CLOUDFLARED_VERSION };
370 const unsupportedStatus: RelayClientStatus = {
371 status: "unsupported",
372 platform,
373 arch,
374 version: CLOUDFLARED_VERSION,
375 };
376 const resolve: RelayClientShape["resolve"] = Effect.gen(function* () {
377 const config = yield* loadCloudflaredConfig;
378 if (Option.isSome(config.executableOverride)) {
379 const override = yield* compatibleCandidate(config.executableOverride.value, "override");
380 if (override) return override;
381 yield* Effect.logWarning(
382 `${CLOUDFLARED_PATH_ENV_NAME} must point to cloudflared ${CLOUDFLARED_MIN_VERSION} or newer`,
383 { executablePath: config.executableOverride.value },
384 );
385 return missingStatus;
386 }
387 // A managed binary reports the version it actually runs. One that replaced
388 // itself in place still serves as a fallback; the runtime sees it is not the
389 // pinned release and installs that.
390 const pinned = yield* compatibleCandidate(managedPath, "managed");
391 if (pinned) return pinned;
392 for (const version of yield* olderManagedVersions) {
393 const older = yield* compatibleCandidate(managedPathFor(version), "managed");
394 if (older) return older;
395 }
396 const pathExecutable = yield* resolvePathExecutable;
397 if (pathExecutable) return pathExecutable;
398 return releaseAsset ? missingStatus : unsupportedStatus;
399 }).pipe(Effect.withSpan("cloudflared.resolve"));
400
401 const pruneManagedVersions: RelayClientShape["pruneManagedVersions"] = Effect.gen(function* () {
402 // The newest older release stays: an older server sharing this base dir, or a
403 // rollback after a failed update, may still need it and cannot redownload it.
404 for (const version of (yield* olderManagedVersions).slice(1)) {
405 // A connector from another server sharing this base dir may still run an
406 // older release; Windows refuses to delete it, so the next prune retries.
407 yield* fileSystem.remove(path.join(managedRoot, version), { recursive: true }).pipe(
408 Effect.tap(() => Effect.logInfo("Removed an older managed relay client", { version })),
409 Effect.catch((cause) =>
410 Effect.logDebug("Could not remove an older managed relay client", { version, cause }),
411 ),
412 );
413 }
414 }).pipe(Effect.withSpan("cloudflared.pruneManagedVersions"));
415
416 /** Moves the new binary toward its final path, waiting out a brief Windows lock. */
417 const renameWhenUnlocked = (from: string, to: string) =>
418 fileSystem.rename(from, to).pipe(
419 Effect.retry({
420 times: ACTIVATE_RETRY_COUNT,
421 schedule: Schedule.spaced(ACTIVATE_RETRY_DELAY),
422 while: (error) => platform === "win32" && isTransientWindowsLock(error),
423 }),
424 );
425
426 const runCommand = Effect.fn("cloudflared.runCommand")(function* (
427 command: string,
428 args: ReadonlyArray<string>,
429 ) {
430 const child = yield* spawner.spawn(
431 ChildProcess.make(command, args, {
432 shell: false,
433 stdout: "ignore",
434 stderr: "ignore",
435 }),
436 );
437 const exitCode = Number(yield* child.exitCode);
438 if (exitCode !== 0) {
439 return yield* new CloudflaredCommandError({ command, exitCode });
440 }
441 });
442
443 const downloadAsset = Effect.fn("cloudflared.downloadAsset")(function* (
444 asset: CloudflaredReleaseAsset,
445 report: (stage: RelayClientInstallProgressStage) => Effect.Effect<void>,
446 ) {
447 yield* report("downloading");
448 const response = yield* httpClient.execute(HttpClientRequest.get(asset.url)).pipe(
449 Effect.timeout(CLOUDFLARED_DOWNLOAD_TIMEOUT),
450 Effect.flatMap(HttpClientResponse.filterStatusOk),
451 Effect.mapError(
452 (cause) =>
453 new RelayClientInstallError({
454 reason: "download_failed",
455 message: "Could not download the relay client.",
456 cause,
457 }),
458 ),
459 );
460 const bytes = new Uint8Array(
461 yield* response.arrayBuffer.pipe(
462 Effect.timeout(CLOUDFLARED_DOWNLOAD_TIMEOUT),
463 Effect.mapError(
464 (cause) =>
465 new RelayClientInstallError({
466 reason: "download_failed",
467 message: "Could not read the downloaded relay client binary.",
468 cause,
469 }),
470 ),
471 ),
472 );
473 yield* report("verifying");
474 const checksum = yield* crypto.digest("SHA-256", bytes).pipe(
475 Effect.mapError(
476 (cause) =>
477 new RelayClientInstallError({
478 reason: "validation_failed",
479 message: "Could not verify the downloaded relay client checksum.",
480 cause,
481 }),
482 ),
483 );
484 if (Hex.encode(checksum) !== asset.sha256) {
485 return yield* new RelayClientInstallError({
486 reason: "invalid_checksum",
487 message: "Downloaded relay client checksum did not match the pinned release.",
488 });
489 }
490 return bytes;
491 });
492
493 const acquireInstallLock = Effect.fn("cloudflared.acquireInstallLock")(function* (
494 lockPath: string,
495 ) {
496 for (let attempt = 0; attempt < INSTALL_LOCK_RETRY_COUNT; attempt += 1) {
497 const acquired = yield* Effect.acquireRelease(
498 fileSystem.writeFileString(lockPath, "", { flag: "wx" }),
499 () => fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore),
500 ).pipe(
501 Effect.as(true),
502 Effect.catchIf(isAlreadyExists, () => Effect.succeed(false)),
503 );
504 if (acquired) return;
505
506 const now = yield* Clock.currentTimeMillis;
507 const lockInfo = yield* fileSystem.stat(lockPath).pipe(Effect.option);
508 const mtime = Option.flatMap(lockInfo, (info) => info.mtime);
509 if (Option.isSome(mtime) && now - mtime.value.getTime() > INSTALL_LOCK_STALE_MS) {
510 yield* fileSystem.remove(lockPath, { force: true });
511 continue;
512 }
513 yield* Effect.sleep(INSTALL_LOCK_RETRY_DELAY);
514 }
515 return yield* new RelayClientInstallError({
516 reason: "install_locked",
517 message: "Another relay client installation is still in progress.",
518 });
519 });
520
521 const installUnlocked = Effect.fn("cloudflared.installUnlocked")(function* (
522 report: (stage: RelayClientInstallProgressStage) => Effect.Effect<void>,
523 ) {
524 yield* report("checking");
525 const config = yield* loadCloudflaredConfig;
526 if (Option.isSome(config.executableOverride)) {
527 const override = yield* compatibleCandidate(config.executableOverride.value, "override");
528 if (override) return override;
529 return yield* new RelayClientInstallError({
530 reason: "override_missing",
531 message: `${CLOUDFLARED_PATH_ENV_NAME} must point to cloudflared ${CLOUDFLARED_MIN_VERSION} or newer.`,
532 });
533 }
534 const existing = yield* compatibleCandidate(managedPath, "managed", CLOUDFLARED_VERSION);
535 if (existing) return existing;
536 if (!releaseAsset) {
537 return yield* new RelayClientInstallError({
538 reason: "unsupported_platform",
539 message: `T3 Code does not provide a managed relay client binary for ${platform}-${arch}.`,
540 });
541 }
542
543 const managedDirectory = path.dirname(managedPath);
544 const lockPath = `${managedPath}.lock`;
545 yield* fileSystem
546 .makeDirectory(managedDirectory, { recursive: true })
547 .pipe(
548 wrapInstallFailure("write_failed", "Could not create the relay client tool directory."),
549 );
550 yield* report("waiting_for_lock");
551 yield* acquireInstallLock(lockPath).pipe(
552 Effect.catchTags({
553 PlatformError: (cause) =>
554 Effect.fail(
555 new RelayClientInstallError({
556 reason: "write_failed",
557 message: "Could not acquire the relay client installation lock.",
558 cause,
559 }),
560 ),
561 }),
562 );
563 return yield* Effect.gen(function* () {
564 const afterLock = yield* compatibleCandidate(managedPath, "managed", CLOUDFLARED_VERSION);
565 if (afterLock) return afterLock;
566
567 const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({
568 directory: managedDirectory,
569 prefix: ".install-",
570 });
571 const archivePath = path.join(
572 tempDirectory,
573 releaseAsset.archive === "tgz" ? "cloudflared.tgz" : executableFileName(platform),
574 );
575 const download = yield* downloadAsset(releaseAsset, report);
576 yield* report("installing");
577 yield* fileSystem
578 .writeFile(archivePath, download)
579 .pipe(wrapInstallFailure("write_failed", "Could not write the relay client download."));
580
581 const executablePath = path.join(tempDirectory, executableFileName(platform));
582 if (releaseAsset.archive === "tgz") {
583 yield* runCommand("tar", ["-xzf", archivePath, "-C", tempDirectory]).pipe(
584 wrapInstallFailure("write_failed", "Could not extract the relay client."),
585 );
586 }
587 if (platform !== "win32") {
588 yield* fileSystem
589 .chmod(executablePath, 0o755)
590 .pipe(wrapInstallFailure("write_failed", "Could not make the relay client executable."));
591 }
592 yield* report("validating");
593 // Requiring the pinned version here keeps a mislabelled asset from being
594 // activated, ignored by resolve, and downloaded again on every reconcile.
595 const installedVersion = yield* probeVersion(executablePath);
596 if (installedVersion !== CLOUDFLARED_VERSION) {
597 return yield* new RelayClientInstallError({
598 reason: "validation_failed",
599 message:
600 installedVersion === null
601 ? "The downloaded relay client binary did not run."
602 : `The downloaded relay client reports version ${installedVersion}, not ${CLOUDFLARED_VERSION}.`,
603 });
604 }
605
606 const stagedPath = `${managedPath}.${yield* crypto.randomUUIDv4()}.tmp`;
607 yield* report("activating");
608 yield* renameWhenUnlocked(executablePath, stagedPath).pipe(
609 wrapInstallFailure("write_failed", "Could not stage the relay client."),
610 );
611 yield* renameWhenUnlocked(stagedPath, managedPath).pipe(
612 wrapInstallFailure("write_failed", "Could not activate the relay client."),
613 Effect.ensuring(fileSystem.remove(stagedPath, { force: true }).pipe(Effect.ignore)),
614 );
615 return {
616 status: "available",
617 executablePath: managedPath,
618 source: "managed",
619 version: CLOUDFLARED_VERSION,
620 } satisfies AvailableRelayClient;
621 }).pipe(
622 Effect.scoped,
623 Effect.catchIf(
624 (cause) => !(cause instanceof RelayClientInstallError),
625 (cause) =>
626 Effect.fail(
627 new RelayClientInstallError({
628 reason: "write_failed",
629 message: "Could not install the relay client.",
630 cause,
631 }),
632 ),
633 ),
634 );
635 });
636 const installWithProgress: RelayClientShape["installWithProgress"] = (report) =>
637 installSemaphore.withPermit(
638 installUnlocked((stage) =>
639 report({
640 type: "progress",
641 stage,
642 }),
643 ).pipe(Effect.scoped),
644 );
645 const install = installWithProgress(() => Effect.void);
646
647 return RelayClient.of({ resolve, install, installWithProgress, pruneManagedVersions });
648});
649
650export const layerCloudflared = (options: CloudflaredRelayClientOptions) =>
651 Layer.effect(RelayClient, makeCloudflaredRelayClient(options));