| ... | ... | @@ -1,5 +1,4 @@ |
| 1 | 1 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 2 | | import * as Data from "effect/Data"; |
| 3 | 2 | import * as Duration from "effect/Duration"; |
| 4 | 3 | import * as Effect from "effect/Effect"; |
| 5 | 4 | import * as Option from "effect/Option"; |
| ... | ... | @@ -15,23 +14,82 @@ export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); |
| 15 | 14 | |
| 16 | 15 | // tailscale is a real executable everywhere (`tailscale.exe` on Windows), so |
| 17 | 16 | // it is always spawned directly rather than through cmd.exe shell mode. |
| 18 | | const tailscaleCommandForPlatform = (platform: NodeJS.Platform): string => |
| 17 | const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | "tailscale.exe" => |
| 19 | 18 | platform === "win32" ? "tailscale.exe" : "tailscale"; |
| 20 | 19 | |
| 21 | | export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{ |
| 22 | | readonly command: readonly string[]; |
| 23 | | readonly message: string; |
| 24 | | readonly exitCode: number | null; |
| 25 | | readonly stderr: string; |
| 26 | | }> {} |
| 20 | const TailscaleCommandContext = { |
| 21 | executable: Schema.Literals(["tailscale", "tailscale.exe"]), |
| 22 | subcommand: Schema.Literals(["status", "serve"]), |
| 23 | argumentCount: Schema.Number, |
| 24 | }; |
| 25 | |
| 26 | export class TailscaleCommandSpawnError extends Schema.TaggedErrorClass<TailscaleCommandSpawnError>()( |
| 27 | "TailscaleCommandSpawnError", |
| 28 | { |
| 29 | ...TailscaleCommandContext, |
| 30 | cause: Schema.Defect(), |
| 31 | }, |
| 32 | ) { |
| 33 | override get message(): string { |
| 34 | return `Failed to spawn tailscale ${this.subcommand}.`; |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | export class TailscaleCommandOutputError extends Schema.TaggedErrorClass<TailscaleCommandOutputError>()( |
| 39 | "TailscaleCommandOutputError", |
| 40 | { |
| 41 | ...TailscaleCommandContext, |
| 42 | cause: Schema.Defect(), |
| 43 | }, |
| 44 | ) { |
| 45 | override get message(): string { |
| 46 | return `Failed to read output from tailscale ${this.subcommand}.`; |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | export class TailscaleCommandExitError extends Schema.TaggedErrorClass<TailscaleCommandExitError>()( |
| 51 | "TailscaleCommandExitError", |
| 52 | { |
| 53 | ...TailscaleCommandContext, |
| 54 | exitCode: Schema.Number, |
| 55 | stdoutLength: Schema.optional(Schema.Number), |
| 56 | stderrLength: Schema.Number, |
| 57 | }, |
| 58 | ) { |
| 59 | override get message(): string { |
| 60 | return `tailscale ${this.subcommand} exited with code ${this.exitCode}.`; |
| 61 | } |
| 62 | } |
| 27 | 63 | |
| 28 | | export class TailscaleStatusParseError extends Data.TaggedError("TailscaleStatusParseError")<{ |
| 29 | | readonly cause: unknown; |
| 30 | | }> {} |
| 64 | export class TailscaleCommandTimeoutError extends Schema.TaggedErrorClass<TailscaleCommandTimeoutError>()( |
| 65 | "TailscaleCommandTimeoutError", |
| 66 | { |
| 67 | ...TailscaleCommandContext, |
| 68 | timeoutMs: Schema.Number, |
| 69 | cause: Schema.Defect(), |
| 70 | }, |
| 71 | ) { |
| 72 | override get message(): string { |
| 73 | return `tailscale ${this.subcommand} timed out after ${this.timeoutMs}ms.`; |
| 74 | } |
| 75 | } |
| 31 | 76 | |
| 32 | | export class TailscaleUnavailableError extends Data.TaggedError("TailscaleUnavailableError")<{ |
| 33 | | readonly reason: string; |
| 34 | | }> {} |
| 77 | export const TailscaleCommandError = Schema.Union([ |
| 78 | TailscaleCommandSpawnError, |
| 79 | TailscaleCommandOutputError, |
| 80 | TailscaleCommandExitError, |
| 81 | TailscaleCommandTimeoutError, |
| 82 | ]); |
| 83 | export type TailscaleCommandError = typeof TailscaleCommandError.Type; |
| 84 | |
| 85 | export class TailscaleStatusParseError extends Schema.TaggedErrorClass<TailscaleStatusParseError>()( |
| 86 | "TailscaleStatusParseError", |
| 87 | { cause: Schema.Defect() }, |
| 88 | ) { |
| 89 | override get message(): string { |
| 90 | return "Failed to decode tailscale status JSON."; |
| 91 | } |
| 92 | } |
| 35 | 93 | |
| 36 | 94 | const TailscaleStatusSelf = Schema.Struct({ |
| 37 | 95 | DNSName: Schema.optional(Schema.Unknown), |
| ... | ... | @@ -61,19 +119,6 @@ const collectStdout = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<s |
| 61 | 119 | |
| 62 | 120 | const collectStderr = collectStdout; |
| 63 | 121 | |
| 64 | | const tailscaleCommandError = ( |
| 65 | | args: readonly string[], |
| 66 | | message: string, |
| 67 | | exitCode: number | null, |
| 68 | | stderr = "", |
| 69 | | ): TailscaleCommandError => |
| 70 | | new TailscaleCommandError({ |
| 71 | | command: ["tailscale", ...args], |
| 72 | | message, |
| 73 | | exitCode, |
| 74 | | stderr, |
| 75 | | }); |
| 76 | | |
| 77 | 122 | const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson)); |
| 78 | 123 | |
| 79 | 124 | function normalizeMagicDnsName(status: TailscaleStatusJson): string | null { |
| ... | ... | @@ -139,55 +184,52 @@ export const readTailscaleStatus = Effect.gen(function* () { |
| 139 | 184 | const args = ["status", "--json"]; |
| 140 | 185 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 141 | 186 | const hostPlatform = yield* HostProcessPlatform; |
| 142 | | const child = yield* spawner |
| 143 | | .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args)) |
| 144 | | .pipe( |
| 145 | | Effect.mapError((cause) => |
| 146 | | tailscaleCommandError( |
| 147 | | args, |
| 148 | | cause instanceof Error ? cause.message : "Failed to spawn tailscale status.", |
| 149 | | null, |
| 150 | | ), |
| 151 | | ), |
| 152 | | ); |
| 153 | | const [stdout, stderr, exitCode] = yield* Effect.all( |
| 154 | | [ |
| 155 | | collectStdout(child.stdout), |
| 156 | | collectStderr(child.stderr), |
| 157 | | child.exitCode.pipe(Effect.map(Number)), |
| 158 | | ], |
| 159 | | { concurrency: "unbounded" }, |
| 160 | | ).pipe( |
| 161 | | Effect.mapError((cause) => |
| 162 | | tailscaleCommandError( |
| 163 | | args, |
| 164 | | cause instanceof Error ? cause.message : "Failed to run tailscale status.", |
| 165 | | null, |
| 166 | | ), |
| 167 | | ), |
| 168 | | ); |
| 169 | | if (exitCode !== 0) { |
| 170 | | return yield* tailscaleCommandError( |
| 171 | | args, |
| 172 | | `Tailscale status exited with code ${exitCode}.`, |
| 173 | | exitCode, |
| 174 | | stderr, |
| 187 | const executable = tailscaleCommandForPlatform(hostPlatform); |
| 188 | const commandContext = { |
| 189 | executable, |
| 190 | subcommand: "status" as const, |
| 191 | argumentCount: args.length, |
| 192 | }; |
| 193 | return yield* Effect.gen(function* () { |
| 194 | const child = yield* spawner |
| 195 | .spawn(ChildProcess.make(executable, args)) |
| 196 | .pipe( |
| 197 | Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), |
| 198 | ); |
| 199 | const [stdout, stderr, exitCode] = yield* Effect.all( |
| 200 | [ |
| 201 | collectStdout(child.stdout), |
| 202 | collectStderr(child.stderr), |
| 203 | child.exitCode.pipe(Effect.map(Number)), |
| 204 | ], |
| 205 | { concurrency: "unbounded" }, |
| 206 | ).pipe( |
| 207 | Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), |
| 175 | 208 | ); |
| 176 | | } |
| 177 | | return yield* parseTailscaleStatus(stdout); |
| 178 | | }).pipe( |
| 179 | | Effect.scoped, |
| 180 | | Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT), |
| 181 | | Effect.flatMap((result) => |
| 182 | | Option.match(result, { |
| 183 | | onNone: () => |
| 209 | if (exitCode !== 0) { |
| 210 | return yield* new TailscaleCommandExitError({ |
| 211 | ...commandContext, |
| 212 | exitCode, |
| 213 | stdoutLength: stdout.length, |
| 214 | stderrLength: stderr.length, |
| 215 | }); |
| 216 | } |
| 217 | return yield* parseTailscaleStatus(stdout); |
| 218 | }).pipe( |
| 219 | Effect.scoped, |
| 220 | Effect.timeout(TAILSCALE_STATUS_TIMEOUT), |
| 221 | Effect.catchTags({ |
| 222 | TimeoutError: (cause) => |
| 184 | 223 | Effect.fail( |
| 185 | | tailscaleCommandError(["status", "--json"], "Tailscale status timed out.", null), |
| 224 | new TailscaleCommandTimeoutError({ |
| 225 | ...commandContext, |
| 226 | timeoutMs: Duration.toMillis(TAILSCALE_STATUS_TIMEOUT), |
| 227 | cause, |
| 228 | }), |
| 186 | 229 | ), |
| 187 | | onSome: Effect.succeed, |
| 188 | 230 | }), |
| 189 | | ), |
| 190 | | ); |
| 231 | ); |
| 232 | }); |
| 191 | 233 | |
| 192 | 234 | export function buildTailscaleHttpsBaseUrl(input: { |
| 193 | 235 | readonly magicDnsName: string; |
| ... | ... | @@ -204,53 +246,52 @@ export function buildTailscaleHttpsBaseUrl(input: { |
| 204 | 246 | |
| 205 | 247 | const runTailscaleCommand = ( |
| 206 | 248 | args: readonly string[], |
| 207 | | input: { |
| 208 | | readonly spawnMessage: string; |
| 209 | | readonly runMessage: string; |
| 210 | | readonly exitMessage: (exitCode: number) => string; |
| 211 | | readonly timeoutMessage: string; |
| 212 | | readonly timeout: Duration.Input; |
| 213 | | }, |
| 249 | timeoutInput: Duration.Input, |
| 214 | 250 | ): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> => |
| 215 | 251 | Effect.gen(function* () { |
| 216 | 252 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 217 | 253 | const hostPlatform = yield* HostProcessPlatform; |
| 218 | | const child = yield* spawner |
| 219 | | .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args)) |
| 220 | | .pipe( |
| 221 | | Effect.mapError((cause) => |
| 222 | | tailscaleCommandError( |
| 223 | | args, |
| 224 | | cause instanceof Error ? cause.message : input.spawnMessage, |
| 225 | | null, |
| 226 | | ), |
| 227 | | ), |
| 254 | const executable = tailscaleCommandForPlatform(hostPlatform); |
| 255 | const commandContext = { |
| 256 | executable, |
| 257 | subcommand: "serve" as const, |
| 258 | argumentCount: args.length, |
| 259 | }; |
| 260 | const timeout = Duration.fromInputUnsafe(timeoutInput); |
| 261 | return yield* Effect.gen(function* () { |
| 262 | const child = yield* spawner |
| 263 | .spawn(ChildProcess.make(executable, args)) |
| 264 | .pipe( |
| 265 | Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), |
| 266 | ); |
| 267 | const [stderr, exitCode] = yield* Effect.all( |
| 268 | [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], |
| 269 | { concurrency: "unbounded" }, |
| 270 | ).pipe( |
| 271 | Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), |
| 228 | 272 | ); |
| 229 | | const [stderr, exitCode] = yield* Effect.all( |
| 230 | | [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], |
| 231 | | { concurrency: "unbounded" }, |
| 232 | | ).pipe( |
| 233 | | Effect.mapError((cause) => |
| 234 | | tailscaleCommandError( |
| 235 | | args, |
| 236 | | cause instanceof Error ? cause.message : input.runMessage, |
| 237 | | null, |
| 238 | | ), |
| 239 | | ), |
| 240 | | ); |
| 241 | | if (exitCode !== 0) { |
| 242 | | return yield* tailscaleCommandError(args, input.exitMessage(exitCode), exitCode, stderr); |
| 243 | | } |
| 244 | | }).pipe( |
| 245 | | Effect.scoped, |
| 246 | | Effect.timeoutOption(input.timeout), |
| 247 | | Effect.flatMap((result) => |
| 248 | | Option.match(result, { |
| 249 | | onNone: () => Effect.fail(tailscaleCommandError(args, input.timeoutMessage, null)), |
| 250 | | onSome: Effect.succeed, |
| 273 | if (exitCode !== 0) { |
| 274 | return yield* new TailscaleCommandExitError({ |
| 275 | ...commandContext, |
| 276 | exitCode, |
| 277 | stderrLength: stderr.length, |
| 278 | }); |
| 279 | } |
| 280 | }).pipe( |
| 281 | Effect.scoped, |
| 282 | Effect.timeout(timeout), |
| 283 | Effect.catchTags({ |
| 284 | TimeoutError: (cause) => |
| 285 | Effect.fail( |
| 286 | new TailscaleCommandTimeoutError({ |
| 287 | ...commandContext, |
| 288 | timeoutMs: Duration.toMillis(timeout), |
| 289 | cause, |
| 290 | }), |
| 291 | ), |
| 251 | 292 | }), |
| 252 | | ), |
| 253 | | ); |
| 293 | ); |
| 294 | }); |
| 254 | 295 | |
| 255 | 296 | export const ensureTailscaleServe = (input: { |
| 256 | 297 | readonly localPort: number; |
| ... | ... | @@ -260,13 +301,7 @@ export const ensureTailscaleServe = (input: { |
| 260 | 301 | const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; |
| 261 | 302 | const localHost = input.localHost ?? "127.0.0.1"; |
| 262 | 303 | const args = ["serve", "--bg", `--https=${servePort}`, `http://${localHost}:${input.localPort}`]; |
| 263 | | return runTailscaleCommand(args, { |
| 264 | | spawnMessage: "Failed to spawn tailscale serve.", |
| 265 | | runMessage: "Failed to run tailscale serve.", |
| 266 | | exitMessage: (exitCode) => `Tailscale serve exited with code ${exitCode}.`, |
| 267 | | timeoutMessage: "Tailscale serve timed out.", |
| 268 | | timeout: TAILSCALE_SERVE_TIMEOUT, |
| 269 | | }); |
| 304 | return runTailscaleCommand(args, TAILSCALE_SERVE_TIMEOUT); |
| 270 | 305 | }; |
| 271 | 306 | |
| 272 | 307 | export const disableTailscaleServe = ( |
| ... | ... | @@ -276,13 +311,10 @@ export const disableTailscaleServe = ( |
| 276 | 311 | ): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> => |
| 277 | 312 | Effect.gen(function* () { |
| 278 | 313 | const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; |
| 279 | | return yield* runTailscaleCommand(["serve", `--https=${servePort}`, "off"], { |
| 280 | | spawnMessage: "Failed to spawn tailscale serve off.", |
| 281 | | runMessage: "Failed to run tailscale serve off.", |
| 282 | | exitMessage: (exitCode) => `Tailscale serve off exited with code ${exitCode}.`, |
| 283 | | timeoutMessage: "Tailscale serve off timed out.", |
| 284 | | timeout: TAILSCALE_SERVE_TIMEOUT, |
| 285 | | }); |
| 314 | return yield* runTailscaleCommand( |
| 315 | ["serve", `--https=${servePort}`, "off"], |
| 316 | TAILSCALE_SERVE_TIMEOUT, |
| 317 | ); |
| 286 | 318 | }); |
| 287 | 319 | |
| 288 | 320 | export const probeTailscaleHttpsEndpoint = (input: { |