1// @effect-diagnostics-next-line nodeBuiltinImport:off -- FileSystem.remove cannot remove an empty directory nonrecursively.
2import * as NodeFSP from "node:fs/promises";
3import * as Cache from "effect/Cache";
4import * as Clock from "effect/Clock";
5import type {
6 RelayClientInstallProgressEvent,
7 RelayClientInstallProgressStage,
8} from "@t3tools/contracts";
9import * as Config from "effect/Config";
10import * as Context from "effect/Context";
11import * as Digest from "effect/crypto/Digest";
12import * as SecureRandom from "effect/crypto/SecureRandom";
13import * as Data from "effect/Data";
14import * as Duration from "effect/Duration";
15import * as Effect from "effect/Effect";
16import * as Exit from "effect/Exit";
17import * as Hex from "effect/encoding/Hex";
18import * as FileSystem from "effect/FileSystem";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import * as Path from "effect/Path";
22import type * as PlatformError from "effect/PlatformError";
23import * as Schedule from "effect/Schedule";
24import * as Schema from "effect/Schema";
25import * as Semaphore from "effect/Semaphore";
26import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
27import { ChildProcess, ChildProcessSpawner } from "effect/process";
28import { isWindowsRenameLock } from "./atomicWrite.ts";
29import * as HostProcess from "./HostProcess.ts";
30
31export const CLOUDFLARED_VERSION = "2026.10.4";
32// Minimum for --output support; older connectors exit on that flag.
33export const CLOUDFLARED_MIN_VERSION = "2025.6.1";
34const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH";
35
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
60class RelayClientLockRemoveError extends Schema.TaggedError<RelayClientLockRemoveError>()(
61 "RelayClientLockRemoveError",
62 { lockPath: Schema.String, cause: Schema.Defect() },
63) {
64 override get message(): string {
65 return `Could not remove the relay client lock ${this.lockPath}.`;
66 }
67}
68
69export class RelayClientInstallError extends Schema.TaggedError<RelayClientInstallError>()(
70 "RelayClientInstallError",
71 {
72 reason: Schema.Literals([
73 "download_failed",
74 "invalid_checksum",
75 "install_locked",
76 "managed_by_policy",
77 "override_missing",
78 "unsupported_platform",
79 "validation_failed",
80 "write_failed",
81 ]),
82 message: Schema.String,
83 cause: Schema.optional(Schema.Defect()),
84 },
85) {}
86
87const isRelayClientInstallError = Schema.is(RelayClientInstallError);
88
89class CloudflaredCommandError extends Schema.TaggedError<CloudflaredCommandError>()(
90 "CloudflaredCommandError",
91 {
92 command: Schema.String,
93 exitCode: Schema.Number,
94 },
95) {}
96
97export interface CloudflaredReleaseAsset {
98 readonly url: string;
99 readonly sha256: string;
100 readonly archive: "binary" | "tgz";
101}
102
103const CLOUDFLARED_RELEASE_ASSETS: Readonly<
104 Partial<Record<`${NodeJS.Platform}-${string}`, CloudflaredReleaseAsset>>
105> = {
106 "darwin-arm64": {
107 url: "https://github.com/cloudflare/cloudflared/releases/download/2026.10.4/cloudflared-darwin-arm64.tgz",
108 sha256: "68b6bc4080ac1330234db2a9002d07f98b000537a1bd531a687b660d4160fa7f",
109 archive: "tgz",
110 },
111 "darwin-x64": {
112 url: "https://github.com/cloudflare/cloudflared/releases/download/2026.10.4/cloudflared-darwin-amd64.tgz",
113 sha256: "f11fbef4207d715892331917dafd437f032cf4b7137ed64dea3008338c1c613d",
114 archive: "tgz",
115 },
116 "linux-arm64": {
117 url: "https://github.com/cloudflare/cloudflared/releases/download/2026.10.4/cloudflared-linux-arm64",
118 sha256: "2b7203b4fb0ba28a0c1e12f8855ce2c25f76d266a8d5edafac5540b6812acff6",
119 archive: "binary",
120 },
121 "linux-x64": {
122 url: "https://github.com/cloudflare/cloudflared/releases/download/2026.10.4/cloudflared-linux-amd64",
123 sha256: "ae5e8add7884b0531d66bbca4570d1de491ff6da51e4ebe7eb160cdf241d9e42",
124 archive: "binary",
125 },
126 "win32-x64": {
127 url: "https://github.com/cloudflare/cloudflared/releases/download/2026.10.4/cloudflared-windows-amd64.exe",
128 sha256: "411b16d40c8d872243efeb52c550053d00af0a8a97fbb32e730fb8aba429eaa8",
129 archive: "binary",
130 },
131 // Cloudflare publishes no Windows ARM64 build; Windows 11 on ARM runs x64 under emulation.
132 "win32-arm64": {
133 url: "https://github.com/cloudflare/cloudflared/releases/download/2026.10.4/cloudflared-windows-amd64.exe",
134 sha256: "411b16d40c8d872243efeb52c550053d00af0a8a97fbb32e730fb8aba429eaa8",
135 archive: "binary",
136 },
137};
138
139const INSTALL_LOCK_RETRY_COUNT = 100;
140const INSTALL_LOCK_RETRY_DELAY = "100 millis";
141// Legacy locks have no owner; allow both ten-minute download stages and the install tail.
142const INSTALL_LOCK_LEGACY_STALE_MS = 30 * 60 * 1_000;
143const VERSION_PROBE_TIMEOUT = "10 seconds";
144// Antivirus scans can hold the first launch of a freshly downloaded binary for 20-30 s.
145const INSTALL_VALIDATION_TIMEOUT = "2 minutes";
146
147const ACTIVATE_RETRY_COUNT = 40;
148const ACTIVATE_RETRY_DELAY = "250 millis";
149
150const trimmedString = (name: string) =>
151 Config.String(name).pipe(
152 Config.option,
153 Config.map(
154 Option.flatMap((value) => {
155 const trimmed = value.trim();
156 return trimmed.length > 0 ? Option.some(trimmed) : Option.none();
157 }),
158 ),
159 );
160
161const CloudflaredConfig = Config.all({
162 executableOverride: trimmedString(CLOUDFLARED_PATH_ENV_NAME),
163});
164
165export interface CloudflaredRelayClientOptions {
166 readonly baseDir: string;
167 readonly releaseAsset?: CloudflaredReleaseAsset;
168}
169
170export interface RelayClientShape {
171 readonly resolve: Effect.Effect<RelayClientStatus>;
172 readonly install: Effect.Effect<AvailableRelayClient, RelayClientInstallError>;
173 readonly installWithProgress: (
174 report: (event: RelayClientInstallProgressEvent) => Effect.Effect<void>,
175 ) => Effect.Effect<AvailableRelayClient, RelayClientInstallError>;
176 /** Removes managed releases older than the pin, except the newest one. Call once the pin has connected. */
177 readonly pruneManagedVersions: Effect.Effect<void>;
178}
179
180export function isPinnedManagedRelayClient(client: AvailableRelayClient): boolean {
181 return client.source === "managed" && client.version === CLOUDFLARED_VERSION;
182}
183
184export class RelayClient extends Context.Service<RelayClient, RelayClientShape>()(
185 "@t3tools/shared/relayClient",
186) {}
187
188function executableFileName(platform: NodeJS.Platform): string {
189 return platform === "win32" ? "cloudflared.exe" : "cloudflared";
190}
191
192function resolveReleaseAsset(
193 platform: NodeJS.Platform,
194 arch: string,
195): CloudflaredReleaseAsset | null {
196 return CLOUDFLARED_RELEASE_ASSETS[`${platform}-${arch}`] ?? null;
197}
198
199const VERSION_PATTERN = /\b(\d{4})\.(\d{1,2})\.(\d+)\b/u;
200
201function parseVersion(value: string): readonly [number, number, number] | null {
202 const match = VERSION_PATTERN.exec(value);
203 return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
204}
205
206export function compareCloudflaredVersions(left: string, right: string): number {
207 const a = parseVersion(left) ?? [0, 0, 0];
208 const b = parseVersion(right) ?? [0, 0, 0];
209 return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
210}
211
212export function parseCloudflaredVersionOutput(output: string): string | null {
213 const version = parseVersion(output);
214 return version ? version.join(".") : null;
215}
216
217class VersionProbeKey extends Data.Class<{
218 readonly executablePath: string;
219 readonly size: number;
220 readonly mtimeMillis: number;
221}> {}
222
223function isLockContention(error: PlatformError.PlatformError): boolean {
224 const code = (error.reason.cause as NodeJS.ErrnoException | undefined)?.code;
225 return (
226 error.reason._tag === "AlreadyExists" ||
227 error.reason._tag === "BadResource" ||
228 code === "ENOTEMPTY" ||
229 isWindowsRenameLock(error)
230 );
231}
232
233function parseLockOwnerPid(owner: string): number | null {
234 const separator = owner.indexOf("-");
235 if (separator <= 0) return null;
236 const pid = Number(owner.slice(0, separator));
237 return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
238}
239
240function processIsAlive(pid: number): boolean {
241 try {
242 process.kill(pid, 0);
243 return true;
244 } catch (error) {
245 return (error as { readonly code?: unknown }).code !== "ESRCH";
246 }
247}
248
249const wrapInstallFailure =
250 (
251 reason: RelayClientInstallError["reason"],
252 message: string,
253 ): (<E, R>(
254 effect: Effect.Effect<void, E, R>,
255 ) => Effect.Effect<void, RelayClientInstallError, R>) =>
256 (effect) =>
257 effect.pipe(
258 Effect.mapError(
259 (cause) =>
260 new RelayClientInstallError({
261 reason,
262 message,
263 cause,
264 }),
265 ),
266 );
267
268export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function* (
269 options: CloudflaredRelayClientOptions,
270): Effect.fn.Return<
271 RelayClientShape,
272 never,
273 | ChildProcessSpawner.ChildProcessSpawner
274 | SecureRandom.SecureRandom
275 | Digest.Digest
276 | FileSystem.FileSystem
277 | HttpClient.HttpClient
278 | Path.Path
279> {
280 const random = yield* SecureRandom.SecureRandom;
281 const digest = yield* Digest.Digest;
282 const fileSystem = yield* FileSystem.FileSystem;
283 const httpClient = yield* HttpClient.HttpClient;
284 const path = yield* Path.Path;
285 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
286 const installSemaphore = yield* Semaphore.make(1);
287 const platform = yield* HostProcess.Platform;
288 const arch = yield* HostProcess.Architecture;
289 const releaseAsset = options.releaseAsset ?? resolveReleaseAsset(platform, arch);
290 const loadCloudflaredConfig = Effect.suspend(() => CloudflaredConfig).pipe(Effect.orDie);
291 const managedRoot = path.join(options.baseDir, "tools", "cloudflared");
292 const managedPathFor = (version: string) =>
293 path.join(managedRoot, version, `${platform}-${arch}`, executableFileName(platform));
294 const managedPath = managedPathFor(CLOUDFLARED_VERSION);
295
296 const isExecutableFile = Effect.fn("cloudflared.isExecutableFile")(function* (
297 executablePath: string,
298 ) {
299 const info = yield* fileSystem.stat(executablePath).pipe(Effect.option);
300 if (Option.isNone(info) || info.value.type !== "File") return false;
301 return platform === "win32" || (info.value.mode & 0o111) !== 0;
302 });
303
304 const runVersionProbe = Effect.fn("cloudflared.runVersionProbe")(function* (
305 executablePath: string,
306 timeout: Duration.Input,
307 ) {
308 return yield* spawner
309 .string(ChildProcess.make(executablePath, ["version"], { stdin: "ignore", stderr: "ignore" }))
310 .pipe(
311 Effect.map(parseCloudflaredVersionOutput),
312 Effect.timeoutOption(timeout),
313 Effect.map(Option.getOrNull),
314 Effect.orElseSucceed(() => null),
315 );
316 });
317
318 // Cache successful version probes per file revision; do not let transient failures hide valid binaries.
319 const versionCache = yield* Cache.makeWith(
320 (key: VersionProbeKey) => runVersionProbe(key.executablePath, VERSION_PROBE_TIMEOUT),
321 {
322 capacity: 16,
323 timeToLive: (exit) =>
324 Exit.isSuccess(exit) && exit.value !== null ? Duration.infinity : Duration.zero,
325 },
326 );
327 const probeVersion = Effect.fn("cloudflared.probeVersion")(function* (executablePath: string) {
328 const info = yield* fileSystem.stat(executablePath).pipe(Effect.option);
329 if (Option.isNone(info) || info.value.type !== "File") return null;
330 if (platform !== "win32" && (info.value.mode & 0o111) === 0) return null;
331 return yield* Cache.get(
332 versionCache,
333 new VersionProbeKey({
334 executablePath,
335 size: Number(info.value.size),
336 mtimeMillis: Option.getOrUndefined(info.value.mtime)?.getTime() ?? 0,
337 }),
338 );
339 });
340
341 const compatibleCandidate = Effect.fn("cloudflared.compatibleCandidate")(function* (
342 executablePath: string,
343 source: RelayClientExecutableSource,
344 expectedVersion?: string,
345 ) {
346 const version = yield* probeVersion(executablePath);
347 if (version === null || compareCloudflaredVersions(version, CLOUDFLARED_MIN_VERSION) < 0) {
348 return null;
349 }
350 if (expectedVersion !== undefined && version !== expectedVersion) {
351 yield* Effect.logWarning("Ignoring a managed relay client that reports another version", {
352 executablePath,
353 expectedVersion,
354 version,
355 });
356 return null;
357 }
358 return { status: "available", executablePath, source, version } satisfies AvailableRelayClient;
359 });
360
361 // Newer managed folders may belong to another server; never use or prune them.
362 const olderManagedVersions = Effect.gen(function* () {
363 const entries = yield* fileSystem
364 .readDirectory(managedRoot)
365 .pipe(Effect.orElseSucceed(() => []));
366 return entries
367 .filter(
368 (entry) =>
369 parseVersion(entry) !== null &&
370 compareCloudflaredVersions(entry, CLOUDFLARED_VERSION) < 0,
371 )
372 .sort((left, right) => compareCloudflaredVersions(right, left));
373 });
374
375 const resolvePathExecutable = Effect.gen(function* () {
376 // Read hydrated PATH directly; ConfigProvider may have cached it before fixPath.
377 const pathValue = (yield* HostProcess.Environment).PATH?.trim();
378 if (!pathValue) return null;
379 const delimiter = platform === "win32" ? ";" : ":";
380 for (const directory of pathValue.split(delimiter)) {
381 const trimmed = directory.trim().replace(/^"|"$/gu, "");
382 if (trimmed.length === 0) continue;
383 const candidate = path.join(trimmed, executableFileName(platform));
384 if (!(yield* isExecutableFile(candidate))) continue;
385 const available = yield* compatibleCandidate(candidate, "path");
386 if (available) return available;
387 yield* Effect.logWarning("Skipping an incompatible relay client on PATH", {
388 executablePath: candidate,
389 minimumVersion: CLOUDFLARED_MIN_VERSION,
390 });
391 }
392 return null;
393 });
394
395 const missingStatus: RelayClientStatus = { status: "missing", version: CLOUDFLARED_VERSION };
396 const unsupportedStatus: RelayClientStatus = {
397 status: "unsupported",
398 platform,
399 arch,
400 version: CLOUDFLARED_VERSION,
401 };
402 const resolve: RelayClientShape["resolve"] = Effect.gen(function* () {
403 const config = yield* loadCloudflaredConfig;
404 if (Option.isSome(config.executableOverride)) {
405 const override = yield* compatibleCandidate(config.executableOverride.value, "override");
406 if (override) return override;
407 yield* Effect.logWarning(
408 `${CLOUDFLARED_PATH_ENV_NAME} must point to cloudflared ${CLOUDFLARED_MIN_VERSION} or newer`,
409 { executablePath: config.executableOverride.value },
410 );
411 return missingStatus;
412 }
413 const pinned = yield* compatibleCandidate(managedPath, "managed");
414 if (pinned) return pinned;
415 for (const version of yield* olderManagedVersions) {
416 const older = yield* compatibleCandidate(managedPathFor(version), "managed");
417 if (older) return older;
418 }
419 const pathExecutable = yield* resolvePathExecutable;
420 if (pathExecutable) return pathExecutable;
421 return releaseAsset ? missingStatus : unsupportedStatus;
422 }).pipe(Effect.withSpan("cloudflared.resolve"));
423
424 const pruneManagedVersions: RelayClientShape["pruneManagedVersions"] = Effect.gen(function* () {
425 // Retain the newest older release for rollback or older servers that cannot redownload it.
426 for (const version of (yield* olderManagedVersions).slice(1)) {
427 // Windows cannot delete another server's running connector; retry next prune.
428 yield* fileSystem.remove(path.join(managedRoot, version), { recursive: true }).pipe(
429 Effect.tap(() => Effect.logInfo("Removed an older managed relay client", { version })),
430 Effect.catch((cause) =>
431 Effect.logDebug("Could not remove an older managed relay client", { version, cause }),
432 ),
433 );
434 }
435 }).pipe(Effect.withSpan("cloudflared.pruneManagedVersions"));
436
437 const renameWhenUnlocked = (from: string, to: string) =>
438 fileSystem.rename(from, to).pipe(
439 Effect.retry({
440 times: ACTIVATE_RETRY_COUNT,
441 schedule: Schedule.spaced(ACTIVATE_RETRY_DELAY),
442 while: (error) => platform === "win32" && isWindowsRenameLock(error),
443 }),
444 );
445
446 const runCommand = Effect.fn("cloudflared.runCommand")(function* (
447 command: string,
448 args: ReadonlyArray<string>,
449 ) {
450 const child = yield* spawner.spawn(
451 ChildProcess.make(command, args, {
452 shell: false,
453 stdout: "ignore",
454 stderr: "ignore",
455 }),
456 );
457 const exitCode = Number(yield* child.exitCode);
458 if (exitCode !== 0) {
459 return yield* new CloudflaredCommandError({ command, exitCode });
460 }
461 });
462
463 const downloadAsset = Effect.fn("cloudflared.downloadAsset")(function* (
464 asset: CloudflaredReleaseAsset,
465 report: (stage: RelayClientInstallProgressStage) => Effect.Effect<void>,
466 ) {
467 yield* report("downloading");
468 const response = yield* httpClient.execute(HttpClientRequest.get(asset.url)).pipe(
469 Effect.timeout(CLOUDFLARED_DOWNLOAD_TIMEOUT),
470 Effect.flatMap(HttpClientResponse.filterStatusOk),
471 Effect.mapError(
472 (cause) =>
473 new RelayClientInstallError({
474 reason: "download_failed",
475 message: "Could not download the relay client.",
476 cause,
477 }),
478 ),
479 );
480 const bytes = new Uint8Array(
481 yield* response.arrayBuffer.pipe(
482 Effect.timeout(CLOUDFLARED_DOWNLOAD_TIMEOUT),
483 Effect.mapError(
484 (cause) =>
485 new RelayClientInstallError({
486 reason: "download_failed",
487 message: "Could not read the downloaded relay client binary.",
488 cause,
489 }),
490 ),
491 ),
492 );
493 yield* report("verifying");
494 const checksum = yield* digest.digest(Digest.sha256, bytes).pipe(
495 Effect.mapError(
496 (cause) =>
497 new RelayClientInstallError({
498 reason: "validation_failed",
499 message: "Could not verify the downloaded relay client checksum.",
500 cause,
501 }),
502 ),
503 );
504 if (Hex.encode(checksum) !== asset.sha256) {
505 return yield* new RelayClientInstallError({
506 reason: "invalid_checksum",
507 message: "Downloaded relay client checksum did not match the pinned release.",
508 });
509 }
510 return bytes;
511 });
512
513 const removeEmptyLock = (lockPath: string) =>
514 Effect.tryPromise({
515 try: () => NodeFSP.rmdir(lockPath),
516 catch: (cause) => new RelayClientLockRemoveError({ lockPath, cause }),
517 }).pipe(Effect.ignore);
518
519 const releaseOwnedLock = Effect.fn("cloudflared.releaseOwnedLock")(function* (
520 lockPath: string,
521 owner: string,
522 ) {
523 yield* fileSystem.remove(path.join(lockPath, owner), { force: true }).pipe(Effect.ignore);
524 // A replacement contains a different owner entry, so rmdir cannot remove it.
525 yield* removeEmptyLock(lockPath);
526 });
527
528 const acquireInstallLock = Effect.fn("cloudflared.acquireInstallLock")(function* (
529 lockPath: string,
530 ) {
531 const owner = `${process.pid}-${yield* random.uuidV4()}`;
532 const candidate = yield* Effect.acquireRelease(
533 fileSystem.makeTempDirectory({ directory: path.dirname(lockPath), prefix: ".lock-" }),
534 (directory) =>
535 fileSystem.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore),
536 );
537 yield* fileSystem.writeFileString(path.join(candidate, owner), "");
538 for (let attempt = 0; attempt < INSTALL_LOCK_RETRY_COUNT; attempt += 1) {
539 const acquired = yield* Effect.acquireRelease(
540 // Publish a populated directory atomically, with no ownerless acquisition window.
541 fileSystem.rename(candidate, lockPath),
542 () => releaseOwnedLock(lockPath, owner),
543 ).pipe(
544 Effect.as(true),
545 Effect.catchIf(isLockContention, () => Effect.succeed(false)),
546 );
547 if (acquired) return;
548
549 const lockInfo = yield* fileSystem.stat(lockPath).pipe(Effect.option);
550 if (Option.isNone(lockInfo)) continue;
551 if (lockInfo.value.type === "Directory") {
552 const owners = yield* fileSystem
553 .readDirectory(lockPath)
554 .pipe(Effect.orElseSucceed(() => []));
555 let recovered = owners.length === 0;
556 for (const entry of owners) {
557 const pid = parseLockOwnerPid(entry);
558 if (pid !== null && !processIsAlive(pid)) {
559 yield* releaseOwnedLock(lockPath, entry);
560 recovered = true;
561 }
562 }
563 if (recovered) {
564 yield* removeEmptyLock(lockPath);
565 continue;
566 }
567 } else {
568 const now = yield* Clock.currentTimeMillis;
569 const mtime = lockInfo.value.mtime;
570 if (Option.isSome(mtime) && now - mtime.value.getTime() > INSTALL_LOCK_LEGACY_STALE_MS) {
571 // Nonrecursive removal also refuses a populated directory that replaced this file.
572 yield* fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore);
573 continue;
574 }
575 }
576 yield* Effect.sleep(INSTALL_LOCK_RETRY_DELAY);
577 }
578 return yield* new RelayClientInstallError({
579 reason: "install_locked",
580 message: "Another relay client installation is still in progress.",
581 });
582 });
583
584 const installUnlocked = Effect.fn("cloudflared.installUnlocked")(function* (
585 report: (stage: RelayClientInstallProgressStage) => Effect.Effect<void>,
586 ) {
587 yield* report("checking");
588 const config = yield* loadCloudflaredConfig;
589 if (Option.isSome(config.executableOverride)) {
590 const override = yield* compatibleCandidate(config.executableOverride.value, "override");
591 if (override) return override;
592 return yield* new RelayClientInstallError({
593 reason: "override_missing",
594 message: `${CLOUDFLARED_PATH_ENV_NAME} must point to cloudflared ${CLOUDFLARED_MIN_VERSION} or newer.`,
595 });
596 }
597 const existing = yield* compatibleCandidate(managedPath, "managed", CLOUDFLARED_VERSION);
598 if (existing) return existing;
599 if (!releaseAsset) {
600 return yield* new RelayClientInstallError({
601 reason: "unsupported_platform",
602 message: `T3 Code does not provide a managed relay client binary for ${platform}-${arch}.`,
603 });
604 }
605
606 const managedDirectory = path.dirname(managedPath);
607 const lockPath = `${managedPath}.lock`;
608 yield* fileSystem
609 .makeDirectory(managedDirectory, { recursive: true })
610 .pipe(
611 wrapInstallFailure("write_failed", "Could not create the relay client tool directory."),
612 );
613 yield* report("waiting_for_lock");
614 yield* acquireInstallLock(lockPath).pipe(
615 Effect.catchTags({
616 PlatformError: (cause) =>
617 Effect.fail(
618 new RelayClientInstallError({
619 reason: "write_failed",
620 message: "Could not acquire the relay client installation lock.",
621 cause,
622 }),
623 ),
624 }),
625 );
626 return yield* Effect.gen(function* () {
627 const afterLock = yield* compatibleCandidate(managedPath, "managed", CLOUDFLARED_VERSION);
628 if (afterLock) return afterLock;
629
630 const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({
631 directory: managedDirectory,
632 prefix: ".install-",
633 });
634 const archivePath = path.join(
635 tempDirectory,
636 releaseAsset.archive === "tgz" ? "cloudflared.tgz" : executableFileName(platform),
637 );
638 const download = yield* downloadAsset(releaseAsset, report);
639 yield* report("installing");
640 yield* fileSystem
641 .writeFile(archivePath, download)
642 .pipe(wrapInstallFailure("write_failed", "Could not write the relay client download."));
643
644 const executablePath = path.join(tempDirectory, executableFileName(platform));
645 if (releaseAsset.archive === "tgz") {
646 yield* runCommand("tar", ["-xzf", archivePath, "-C", tempDirectory]).pipe(
647 wrapInstallFailure("write_failed", "Could not extract the relay client."),
648 );
649 }
650 if (platform !== "win32") {
651 yield* fileSystem
652 .chmod(executablePath, 0o755)
653 .pipe(wrapInstallFailure("write_failed", "Could not make the relay client executable."));
654 }
655 yield* report("validating");
656 // Verify the pin so mislabeled assets cannot cause repeated redownloads.
657 const installedVersion = yield* runVersionProbe(executablePath, INSTALL_VALIDATION_TIMEOUT);
658 if (installedVersion !== CLOUDFLARED_VERSION) {
659 return yield* new RelayClientInstallError({
660 reason: "validation_failed",
661 message:
662 installedVersion === null
663 ? "The downloaded relay client binary did not run."
664 : `The downloaded relay client reports version ${installedVersion}, not ${CLOUDFLARED_VERSION}.`,
665 });
666 }
667
668 const stagedPath = `${managedPath}.${yield* random.uuidV4()}.tmp`;
669 yield* report("activating");
670 yield* renameWhenUnlocked(executablePath, stagedPath).pipe(
671 wrapInstallFailure("write_failed", "Could not stage the relay client."),
672 );
673 yield* renameWhenUnlocked(stagedPath, managedPath).pipe(
674 wrapInstallFailure("write_failed", "Could not activate the relay client."),
675 Effect.ensuring(fileSystem.remove(stagedPath, { force: true }).pipe(Effect.ignore)),
676 );
677 return {
678 status: "available",
679 executablePath: managedPath,
680 source: "managed",
681 version: CLOUDFLARED_VERSION,
682 } satisfies AvailableRelayClient;
683 }).pipe(
684 Effect.scoped,
685 Effect.catchIf(
686 (cause) => !isRelayClientInstallError(cause),
687 (cause) =>
688 Effect.fail(
689 new RelayClientInstallError({
690 reason: "write_failed",
691 message: "Could not install the relay client.",
692 cause,
693 }),
694 ),
695 ),
696 );
697 });
698 const installWithProgress: RelayClientShape["installWithProgress"] = (report) =>
699 installSemaphore.withPermit(
700 installUnlocked((stage) =>
701 report({
702 type: "progress",
703 stage,
704 }),
705 ).pipe(Effect.scoped),
706 );
707 const install = installWithProgress(() => Effect.void);
708
709 return RelayClient.of({ resolve, install, installWithProgress, pruneManagedVersions });
710});
711
712export const layerCloudflared = (options: CloudflaredRelayClientOptions) =>
713 Layer.effect(RelayClient, makeCloudflaredRelayClient(options));