diff --git a/.changeset/gold-worlds-switch.md b/.changeset/gold-worlds-switch.md new file mode 100644 index 00000000..7275c63b --- /dev/null +++ b/.changeset/gold-worlds-switch.md @@ -0,0 +1,5 @@ +--- +"@bluecadet/launchpad-controller": patch +--- + +Fix invalid named pipe on windows diff --git a/package-lock.json b/package-lock.json index eb72d268..13ef113c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10384,6 +10384,7 @@ "license": "MIT", "dependencies": { "@bluecadet/launchpad-utils": "~2.1.0", + "chalk": "^5.0.0", "devalue": "^5.4.2", "immer": "^10.2.0", "neverthrow": "^8.1.1", @@ -10398,6 +10399,18 @@ "node": ">=18" } }, + "packages/controller/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "packages/dashboard": { "name": "@bluecadet/launchpad-dashboard", "version": "2.0.0", diff --git a/packages/controller/package.json b/packages/controller/package.json index 6648b9fe..9f73e912 100644 --- a/packages/controller/package.json +++ b/packages/controller/package.json @@ -37,6 +37,7 @@ "homepage": "https://github.com/bluecadet/launchpad/packages/controller", "dependencies": { "@bluecadet/launchpad-utils": "~2.1.0", + "chalk": "^5.0.0", "devalue": "^5.4.2", "immer": "^10.2.0", "neverthrow": "^8.1.1", diff --git a/packages/controller/src/ipc/__tests__/ipc-client.test.ts b/packages/controller/src/ipc/__tests__/ipc-client.test.ts index 6d35b516..aacf87a5 100644 --- a/packages/controller/src/ipc/__tests__/ipc-client.test.ts +++ b/packages/controller/src/ipc/__tests__/ipc-client.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { IPCEvent, IPCResponse } from "../../transports/ipc-transport.js"; import { IPCClient } from "../ipc-client.js"; import { IPCSerializer } from "../ipc-serializer.js"; +import { getOSSocketPath } from "../ipc-utils.js"; type Cb = (...args: any[]) => void; @@ -84,7 +85,10 @@ describe("IPCClient", () => { const result = await client.connect("/test/socket"); expect(result.isOk()).toBe(true); - expect(net.createConnection).toHaveBeenCalledWith("/test/socket", expect.any(Function)); + expect(net.createConnection).toHaveBeenCalledWith( + getOSSocketPath("/test/socket"), + expect.any(Function), + ); }); it("should return error if connection fails", async () => { diff --git a/packages/controller/src/ipc/__tests__/ipc-utils.test.ts b/packages/controller/src/ipc/__tests__/ipc-utils.test.ts new file mode 100644 index 00000000..d91fecde --- /dev/null +++ b/packages/controller/src/ipc/__tests__/ipc-utils.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { getOSSocketPath } from "../ipc-utils.js"; + +describe("getOSSocketPath", () => { + describe.runIf(process.platform !== "win32")("on posix", () => { + it("returns the same path", () => { + const inputPath = "/tmp/socket.sock"; + const result = getOSSocketPath(inputPath); + expect(result).toBe(inputPath); + }); + }); + + describe.runIf(process.platform === "win32")("on Windows", () => { + it("updates the path to use named pipe format if not already in that format", () => { + const inputPath = "C:\\temp\\socket.sock"; + const expectedPath = "\\\\?\\pipe\\C:\\temp\\socket.sock"; + const result = getOSSocketPath(inputPath); + expect(result).toBe(expectedPath); + }); + + it("returns the same path if already in named pipe format with \\\\?\\pipe prefix", () => { + const inputPath = "\\\\?\\pipe\\my_named_pipe"; + const result = getOSSocketPath(inputPath); + expect(result).toBe(inputPath); + }); + }); +}); diff --git a/packages/controller/src/ipc/ipc-client.ts b/packages/controller/src/ipc/ipc-client.ts index 664f5ce3..eacd69d8 100644 --- a/packages/controller/src/ipc/ipc-client.ts +++ b/packages/controller/src/ipc/ipc-client.ts @@ -12,6 +12,7 @@ import type { LaunchpadState } from "../core/state-store.js"; import { IPCConnectionError, IPCMessageError, IPCTimeoutError } from "../errors.js"; import type { IPCBroadcastMessage, IPCMessage, IPCResponse } from "../transports/ipc-transport.js"; import { IPCSerializer } from "./ipc-serializer.js"; +import { getOSSocketPath } from "./ipc-utils.js"; enablePatches(); @@ -37,7 +38,9 @@ export class IPCClient { /** * Connect to the IPC socket */ - connect(socketPath: string): ResultAsync { + connect(originalSocketPath: string): ResultAsync { + const socketPath = getOSSocketPath(originalSocketPath); + return ResultAsync.fromPromise( new Promise((resolve, reject) => { this._socket = net.createConnection(socketPath, () => { @@ -229,7 +232,7 @@ export class IPCClient { try { this._lastState = applyPatches(this._lastState, patches); this._lastStateVersion = version; - } catch (e) { + } catch (_e) { return errAsync(new IPCMessageError("Failed to apply patches")); } diff --git a/packages/controller/src/ipc/ipc-utils.ts b/packages/controller/src/ipc/ipc-utils.ts new file mode 100644 index 00000000..21c72c83 --- /dev/null +++ b/packages/controller/src/ipc/ipc-utils.ts @@ -0,0 +1,18 @@ +import path from "node:path"; + +/** + * Ensure path conforms with OS requirements (only relevant for windows ATM) + * https://nodejs.org/api/net.html#identifying-paths-for-ipc-connections + */ +export function getOSSocketPath(socketPath: string) { + if (process.platform === "win32") { + // On Windows, we need to use a named pipe + + if (socketPath.includes("\\\\?\\pipe") || socketPath.includes("\\\\.\\pipe")) { + // Already in correct format + return socketPath; + } + return path.join("\\\\?\\pipe", socketPath); + } + return socketPath; +} diff --git a/packages/controller/src/transports/ipc-transport.ts b/packages/controller/src/transports/ipc-transport.ts index 8288ba8e..30fbbfd1 100644 --- a/packages/controller/src/transports/ipc-transport.ts +++ b/packages/controller/src/transports/ipc-transport.ts @@ -7,6 +7,7 @@ import fs from "node:fs"; import net from "node:net"; import path from "node:path"; import type { LaunchpadEvents } from "@bluecadet/launchpad-utils"; +import chalk from "chalk"; import type { Patch } from "immer"; import { ResultAsync } from "neverthrow"; import type { VersionedLaunchpadState } from "../core/state-store.js"; @@ -18,6 +19,7 @@ import { TransportError, } from "../errors.js"; import { IPCSerializer } from "../ipc/ipc-serializer.js"; +import { getOSSocketPath } from "../ipc/ipc-utils.js"; export type IPCTransportOptions = { /** Path to the Unix socket file */ @@ -54,12 +56,25 @@ export function createIPCTransport(options: IPCTransportOptions): Transport { let server: net.Server | null = null; const clients = new Set(); + const socketPath = getOSSocketPath(options.socketPath); + return { id: "ipc", start(ctx: TransportContext): ResultAsync { const { logger, abortSignal } = ctx; - const { socketPath } = options; + + if (process.platform === "win32" && options.socketPath !== socketPath) { + // notify user that the socket path has been updated to conform with windows named pipe reqs, + // as it might not be where they expect it + + logger.warn( + `Windows named pipes must be located in ${chalk.grey("\\\\?\\pipe\\")} or ${chalk.grey("\\\\.\\pipe\\")}. `, + ); + logger.warn( + `The configured socketPath has been moved to the ${chalk.grey("\\\\?\\pipe\\")} directory to conform with this requirement.`, + ); + } const promise = new Promise((resolve, reject) => { // Clean up existing socket @@ -75,18 +90,21 @@ export function createIPCTransport(options: IPCTransportOptions): Transport { } } - // Ensure directory exists - try { - const dir = path.dirname(socketPath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); + // This step isn't relevant for windows named pipes + if (process.platform !== "win32") { + // Ensure directory exists + try { + const dir = path.dirname(socketPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + } catch (e) { + return reject( + new TransportError("Failed to create socket directory", { + cause: e instanceof Error ? e : new Error(String(e)), + }), + ); } - } catch (e) { - return reject( - new TransportError("Failed to create socket directory", { - cause: e instanceof Error ? e : new Error(String(e)), - }), - ); } // Create server @@ -139,7 +157,7 @@ export function createIPCTransport(options: IPCTransportOptions): Transport { }); server.listen(socketPath, () => { - logger.info(`IPC transport listening at ${socketPath}`); + logger.info(`IPC transport listening at ${chalk.grey(socketPath)}`); // Subscribe to EventBus for event streaming with type-safe handler const handleEvent = ( @@ -208,7 +226,6 @@ export function createIPCTransport(options: IPCTransportOptions): Transport { stop(ctx: TransportContext): ResultAsync { const { logger } = ctx; - const { socketPath } = options; const promise = new Promise((resolve, reject) => { if (!server) {