| @@ -1,4 +1,3 @@ |
| 1 | import * as Clock from "effect/Clock"; | | |
| 2 | import type { | 1 | import type { |
| 3 | RelayClientInstallProgressEvent, | 2 | RelayClientInstallProgressEvent, |
| 4 | RelayClientInstallProgressStage, | 3 | RelayClientInstallProgressStage, |
| @@ -8,6 +7,8 @@ import * as ConfigProvider from "effect/ConfigProvider"; · 3 unmodified lines |
| 8 | import * as Context from "effect/Context"; | 7 | import * as Context from "effect/Context"; |
| 9 | import * as Crypto from "effect/Crypto"; | 8 | import * as Crypto from "effect/Crypto"; |
| 10 | import * as Data from "effect/Data"; | 9 | import * as Data from "effect/Data"; |
| | 10 | import * as DateTime from "effect/DateTime"; |
| | 11 | import * as Duration from "effect/Duration"; |
| 11 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 12 | import * as Encoding from "effect/Encoding"; | 13 | import * as Encoding from "effect/Encoding"; |
| 13 | import * as FileSystem from "effect/FileSystem"; | 14 | import * as FileSystem from "effect/FileSystem"; |
| @@ -15,6 +16,7 @@ import * as Layer from "effect/Layer"; · 1 unmodified line |
| 15 | import * as Option from "effect/Option"; | 16 | import * as Option from "effect/Option"; |
| 16 | import * as Path from "effect/Path"; | 17 | import * as Path from "effect/Path"; |
| 17 | import * as PlatformError from "effect/PlatformError"; | 18 | import * as PlatformError from "effect/PlatformError"; |
| | 19 | import * as Schedule from "effect/Schedule"; |
| 18 | import * as Semaphore from "effect/Semaphore"; | 20 | import * as Semaphore from "effect/Semaphore"; |
| 19 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | 21 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; |
| 20 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 22 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| @@ -99,8 +101,18 @@ const CLOUDFLARED_RELEASE_ASSETS: Readonly< · 78 unmodified lines |
| 99 | }; | 101 | }; |
| 100 | | 102 | |
| 101 | const INSTALL_LOCK_RETRY_COUNT = 100; | 103 | const INSTALL_LOCK_RETRY_COUNT = 100; |
| 102 | const INSTALL_LOCK_RETRY_DELAY = "100 millis"; | 104 | const INSTALL_LOCK_RETRY_DELAY = Duration.millis(100); |
| 103 | const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000; | 105 | const INSTALL_LOCK_TIMEOUT = Duration.times(INSTALL_LOCK_RETRY_DELAY, INSTALL_LOCK_RETRY_COUNT); |
| | 106 | const INSTALL_LOCK_STALE_AGE = Duration.minutes(5); |
| | 107 | |
| | 108 | class RelayClientInstallLockBusy extends Data.TaggedError("RelayClientInstallLockBusy")<{ |
| | 109 | readonly lockPath: string; |
| | 110 | }> {} |
| | 111 | |
| | 112 | const retryWhileInstallLockBusy = Schedule.spaced(INSTALL_LOCK_RETRY_DELAY).pipe( |
| | 113 | Schedule.setInputType<RelayClientInstallLockBusy | PlatformError.PlatformError>(), |
| | 114 | Schedule.while(({ input }) => input._tag === "RelayClientInstallLockBusy"), |
| | 115 | ); |
| 104 | | 116 | |
| 105 | const trimmedString = (name: string) => | 117 | const trimmedString = (name: string) => |
| 106 | Config.string(name).pipe( | 118 | Config.string(name).pipe( |
| @@ -161,8 +173,8 @@ export function resolveManagedCloudflaredPath(input: { · 54 unmodified lines |
| 161 | function resolveReleaseAsset( | 173 | function resolveReleaseAsset( |
| 162 | platform: NodeJS.Platform, | 174 | platform: NodeJS.Platform, |
| 163 | arch: string, | 175 | arch: string, |
| 164 | ): CloudflaredReleaseAsset | null { | 176 | ): Option.Option<CloudflaredReleaseAsset> { |
| 165 | return CLOUDFLARED_RELEASE_ASSETS[`${platform}-${arch}`] ?? null; | 177 | return Option.fromUndefinedOr(CLOUDFLARED_RELEASE_ASSETS[`${platform}-${arch}`]); |
| 166 | } | 178 | } |
| 167 | | 179 | |
| 168 | function isAlreadyExists(error: PlatformError.PlatformError): boolean { | 180 | function isAlreadyExists(error: PlatformError.PlatformError): boolean { |
| @@ -207,7 +219,9 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function · 38 unmodified lines |
| 207 | const installSemaphore = yield* Semaphore.make(1); | 219 | const installSemaphore = yield* Semaphore.make(1); |
| 208 | const platform = options.platform ?? process.platform; | 220 | const platform = options.platform ?? process.platform; |
| 209 | const arch = options.arch ?? process.arch; | 221 | const arch = options.arch ?? process.arch; |
| 210 | const releaseAsset = options.releaseAsset ?? resolveReleaseAsset(platform, arch); | 222 | const releaseAsset = Option.fromUndefinedOr(options.releaseAsset).pipe( |
| | 223 | Option.orElse(() => resolveReleaseAsset(platform, arch)), |
| | 224 | ); |
| 211 | const loadCloudflaredConfig = Effect.suspend(() => | 225 | const loadCloudflaredConfig = Effect.suspend(() => |
| 212 | CloudflaredConfig.pipe( | 226 | CloudflaredConfig.pipe( |
| 213 | Effect.provideService( | 227 | Effect.provideService( |
| @@ -236,15 +250,15 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function · 22 unmodified lines |
| 236 | const resolvePathExecutable = Effect.gen(function* () { | 250 | const resolvePathExecutable = Effect.gen(function* () { |
| 237 | const config = yield* loadCloudflaredConfig; | 251 | const config = yield* loadCloudflaredConfig; |
| 238 | const pathValue = Option.getOrUndefined(config.path); | 252 | const pathValue = Option.getOrUndefined(config.path); |
| 239 | if (!pathValue) return null; | 253 | if (!pathValue) return Option.none(); |
| 240 | const delimiter = platform === "win32" ? ";" : ":"; | 254 | const delimiter = platform === "win32" ? ";" : ":"; |
| 241 | for (const directory of pathValue.split(delimiter)) { | 255 | for (const directory of pathValue.split(delimiter)) { |
| 242 | const trimmed = directory.trim().replace(/^"|"$/gu, ""); | 256 | const trimmed = directory.trim().replace(/^"|"$/gu, ""); |
| 243 | if (trimmed.length === 0) continue; | 257 | if (trimmed.length === 0) continue; |
| 244 | const candidate = path.join(trimmed, executableFileName(platform)); | 258 | const candidate = path.join(trimmed, executableFileName(platform)); |
| 245 | if (yield* isExecutableFile(candidate)) return candidate; | 259 | if (yield* isExecutableFile(candidate)) return Option.some(candidate); |
| 246 | } | 260 | } |
| 247 | return null; | 261 | return Option.none(); |
| 248 | }); | 262 | }); |
| 249 | | 263 | |
| 250 | const resolve: RelayClientShape["resolve"] = Effect.gen(function* () { | 264 | const resolve: RelayClientShape["resolve"] = Effect.gen(function* () { |
| @@ -268,15 +282,15 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function · 17 unmodified lines |
| 268 | }; | 282 | }; |
| 269 | } | 283 | } |
| 270 | const pathExecutable = yield* resolvePathExecutable; | 284 | const pathExecutable = yield* resolvePathExecutable; |
| 271 | if (pathExecutable) { | 285 | if (Option.isSome(pathExecutable)) { |
| 272 | return { | 286 | return { |
| 273 | status: "available", | 287 | status: "available", |
| 274 | executablePath: pathExecutable, | 288 | executablePath: pathExecutable.value, |
| 275 | source: "path", | 289 | source: "path", |
| 276 | version: CLOUDFLARED_VERSION, | 290 | version: CLOUDFLARED_VERSION, |
| 277 | }; | 291 | }; |
| 278 | } | 292 | } |
| 279 | return releaseAsset | 293 | return Option.isSome(releaseAsset) |
| 280 | ? { status: "missing", version: CLOUDFLARED_VERSION } | 294 | ? { status: "missing", version: CLOUDFLARED_VERSION } |
| 281 | : { | 295 | : { |
| 282 | status: "unsupported", | 296 | status: "unsupported", |
| @@ -351,31 +365,59 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function · 68 unmodified lines |
| 351 | return bytes; | 365 | return bytes; |
| 352 | }); | 366 | }); |
| 353 | | 367 | |
| | 368 | const isInstallLockStale = Effect.fn("cloudflared.isInstallLockStale")(function* ( |
| | 369 | lockPath: string, |
| | 370 | ) { |
| | 371 | const lockInfo = yield* fileSystem.stat(lockPath).pipe(Effect.option); |
| | 372 | const lockModifiedAt = Option.flatMap(lockInfo, (info) => info.mtime); |
| | 373 | if (Option.isNone(lockModifiedAt)) return false; |
| | 374 | |
| | 375 | const now = yield* DateTime.now; |
| | 376 | const lockAge = DateTime.distance(DateTime.makeUnsafe(lockModifiedAt.value), now); |
| | 377 | return Duration.isGreaterThan(lockAge, INSTALL_LOCK_STALE_AGE); |
| | 378 | }); |
| | 379 | |
| | 380 | const attemptAcquireInstallLock = Effect.fn("cloudflared.attemptAcquireInstallLock")(function* ( |
| | 381 | lockPath: string, |
| | 382 | ) { |
| | 383 | const acquired = yield* fileSystem.writeFileString(lockPath, "", { flag: "wx" }).pipe( |
| | 384 | Effect.as(true), |
| | 385 | Effect.catch((error) => (isAlreadyExists(error) ? Effect.succeed(false) : Effect.fail(error))), |
| | 386 | ); |
| | 387 | if (acquired) return; |
| | 388 | |
| | 389 | if (yield* isInstallLockStale(lockPath)) { |
| | 390 | yield* fileSystem.remove(lockPath, { force: true }); |
| | 391 | return yield* attemptAcquireInstallLock(lockPath); |
| | 392 | } |
| | 393 | |
| | 394 | return yield* new RelayClientInstallLockBusy({ lockPath }); |
| | 395 | }); |
| | 396 | |
| 354 | const acquireInstallLock = Effect.fn("cloudflared.acquireInstallLock")(function* ( | 397 | const acquireInstallLock = Effect.fn("cloudflared.acquireInstallLock")(function* ( |
| 355 | lockPath: string, | 398 | lockPath: string, |
| 356 | ) { | 399 | ) { |
| 357 | for (let attempt = 0; attempt < INSTALL_LOCK_RETRY_COUNT; attempt += 1) { | 400 | return yield* attemptAcquireInstallLock(lockPath).pipe( |
| 358 | const acquired = yield* fileSystem.writeFileString(lockPath, "", { flag: "wx" }).pipe( | 401 | Effect.retry(retryWhileInstallLockBusy), |
| 359 | Effect.as(true), | 402 | Effect.timeoutOrElse({ |
| 360 | Effect.catch((error) => | 403 | duration: INSTALL_LOCK_TIMEOUT, |
| 361 | isAlreadyExists(error) ? Effect.succeed(false) : Effect.fail(error), | 404 | orElse: () => |
| | 405 | Effect.fail( |
| | 406 | new RelayClientInstallError({ |
| | 407 | reason: "install_locked", |
| | 408 | message: "Another relay client installation is still in progress.", |
| | 409 | }), |
| | 410 | ), |
| | 411 | }), |
| | 412 | Effect.catchTag("RelayClientInstallLockBusy", () => |
| | 413 | Effect.fail( |
| | 414 | new RelayClientInstallError({ |
| | 415 | reason: "install_locked", |
| | 416 | message: "Another relay client installation is still in progress.", |
| | 417 | }), |
| 362 | ), | 418 | ), |
| 363 | ); | 419 | ), |
| 364 | if (acquired) return; | 420 | ); |
| 365 | | | |
| 366 | const now = yield* Clock.currentTimeMillis; | | |
| 367 | const lockInfo = yield* fileSystem.stat(lockPath).pipe(Effect.option); | | |
| 368 | const mtime = Option.flatMap(lockInfo, (info) => info.mtime); | | |
| 369 | if (Option.isSome(mtime) && now - mtime.value.getTime() > INSTALL_LOCK_STALE_MS) { | | |
| 370 | yield* fileSystem.remove(lockPath, { force: true }); | | |
| 371 | continue; | | |
| 372 | } | | |
| 373 | yield* Effect.sleep(INSTALL_LOCK_RETRY_DELAY); | | |
| 374 | } | | |
| 375 | return yield* new RelayClientInstallError({ | | |
| 376 | reason: "install_locked", | | |
| 377 | message: "Another relay client installation is still in progress.", | | |
| 378 | }); | | |
| 379 | }); | 421 | }); |
| 380 | | 422 | |
| 381 | const installUnlocked = Effect.fn("cloudflared.installUnlocked")(function* ( | 423 | const installUnlocked = Effect.fn("cloudflared.installUnlocked")(function* ( |
| @@ -391,7 +433,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function · 9 unmodified lines |
| 391 | message: `${CLOUDFLARED_PATH_ENV_NAME} does not point to an executable file.`, | 433 | message: `${CLOUDFLARED_PATH_ENV_NAME} does not point to an executable file.`, |
| 392 | }); | 434 | }); |
| 393 | } | 435 | } |
| 394 | if (!releaseAsset) { | 436 | if (Option.isNone(releaseAsset)) { |
| 395 | return yield* new RelayClientInstallError({ | 437 | return yield* new RelayClientInstallError({ |
| 396 | reason: "unsupported_platform", | 438 | reason: "unsupported_platform", |
| 397 | message: `T3 Code does not provide a managed relay client binary for ${platform}-${arch}.`, | 439 | message: `T3 Code does not provide a managed relay client binary for ${platform}-${arch}.`, |
| @@ -427,16 +469,16 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function · 29 unmodified lines |
| 427 | }); | 469 | }); |
| 428 | const archivePath = path.join( | 470 | const archivePath = path.join( |
| 429 | tempDirectory, | 471 | tempDirectory, |
| 430 | releaseAsset.archive === "tgz" ? "cloudflared.tgz" : executableFileName(platform), | 472 | releaseAsset.value.archive === "tgz" ? "cloudflared.tgz" : executableFileName(platform), |
| 431 | ); | 473 | ); |
| 432 | const download = yield* downloadAsset(releaseAsset, report); | 474 | const download = yield* downloadAsset(releaseAsset.value, report); |
| 433 | yield* report("installing"); | 475 | yield* report("installing"); |
| 434 | yield* fileSystem | 476 | yield* fileSystem |
| 435 | .writeFile(archivePath, download) | 477 | .writeFile(archivePath, download) |
| 436 | .pipe(wrapInstallFailure("write_failed", "Could not write the relay client download.")); | 478 | .pipe(wrapInstallFailure("write_failed", "Could not write the relay client download.")); |
| 437 | | 479 | |
| 438 | const executablePath = path.join(tempDirectory, executableFileName(platform)); | 480 | const executablePath = path.join(tempDirectory, executableFileName(platform)); |
| 439 | if (releaseAsset.archive === "tgz") { | 481 | if (releaseAsset.value.archive === "tgz") { |
| 440 | yield* runCommand("tar", ["-xzf", archivePath, "-C", tempDirectory]).pipe( | 482 | yield* runCommand("tar", ["-xzf", archivePath, "-C", tempDirectory]).pipe( |
| 441 | wrapInstallFailure("write_failed", "Could not extract the relay client."), | 483 | wrapInstallFailure("write_failed", "Could not extract the relay client."), |
| 442 | ); | 484 | ); |