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