Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .nx/version-plans/version-plan-1768086780974.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
desktop: patch
---

Revert to manual ASAR binary unpacking and execution due to issues on Windows and MacOS
5 changes: 5 additions & 0 deletions .nx/version-plans/version-plan-1768087084699.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
desktop: patch
---

Fix Windows RPC issues by switching to named pipes
6 changes: 5 additions & 1 deletion apps/desktop/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { fileURLToPath } from 'node:url';

const config: Configuration = {
artifactName: '${productName}-${version}-${platform}-${arch}.${ext}',
asarUnpack: ['resources/**'],
asarUnpack: [
'resources/**',
'**/node_modules/@the-dev-tools/server/dist/server',
'**/node_modules/@the-dev-tools/cli/dist/cli',
],
extraMetadata: {
name: 'DevTools',
},
Expand Down
39 changes: 18 additions & 21 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { Command, FetchHttpClient, Path, Url } from '@effect/platform';
import * as NodeContext from '@effect/platform-node/NodeContext';
import * as NodeRuntime from '@effect/platform-node/NodeRuntime';
import { Config, Console, Effect, pipe, Runtime } from 'effect';
import { Config, Console, Effect, pipe, Runtime, String } from 'effect';
import { app, BrowserWindow, dialog, Dialog, globalShortcut, ipcMain, protocol, shell } from 'electron';
import { autoUpdater } from 'electron-updater';
import child_process from 'node:child_process';
import os from 'node:os';
import { Agent } from 'undici';
import { CustomUpdateProvider, UpdateOptions } from './update';
Expand Down Expand Up @@ -99,19 +98,6 @@ const createWindow = Effect.gen(function* () {
}
});

// Only 'child_process.execFile' is supported for executing binaries inside ASAR archives
// https://www.electronjs.org/docs/latest/tutorial/asar-archives#executing-binaries-inside-asar-archive
const execFile = (file: string, args?: string[], options?: child_process.ExecFileOptions) =>
Effect.async<void, child_process.ExecFileException>((resume) => {
child_process.execFile(file, args, options, (error, stdout, stderr) => {
Effect.gen(function* () {
if (stdout) console.log(stdout);
if (stderr) console.error(stderr);
if (error) yield* Effect.fail(error);
}).pipe(resume);
});
});

const server = pipe(
Effect.gen(function* () {
const path = yield* Path.Path;
Expand All @@ -122,16 +108,22 @@ const server = pipe(
Effect.flatMap(path.fromFileUrl),
);

yield* execFile(path.join(dist, 'server'), undefined, {
env: {
yield* pipe(
path.join(dist, 'server'),
String.replaceAll('app.asar', 'app.asar.unpacked'),
Command.make,
Command.env({
// TODO: we probably shouldn't encrypt local database
DB_ENCRYPTION_KEY: 'secret',
DB_MODE: 'local',
DB_NAME: 'state',
DB_PATH: app.getPath('userData'),
HMAC_SECRET: 'secret',
},
});
}),
Command.stdout('inherit'),
Command.stderr('inherit'),
Command.exitCode,
);

yield* Effect.interrupt;
}),
Expand Down Expand Up @@ -175,11 +167,14 @@ const onReady = Effect.gen(function* () {
});
yield* Effect.tryPromise(() => autoUpdater.checkForUpdatesAndNotify());

let socketPath = path.join(os.tmpdir(), 'the-dev-tools', 'server.socket');
if (os.platform() === 'win32') socketPath = '\\\\.\\pipe\\the-dev-tools_server.socket';

// Redirect server IPC into a UDS
// https://nodejs.org/api/globals.html#custom-dispatcher
// https://undici.nodejs.org/#/docs/api/Client?id=parameter-connectoptions
const dispatcher = new Agent({
socketPath: path.join(os.tmpdir(), 'the-dev-tools', 'server.socket'),
socketPath,

// Disable timeout for sync streams
bodyTimeout: 0,
Expand Down Expand Up @@ -266,7 +261,9 @@ const cli = pipe(
Effect.flatMap(path.fromFileUrl),
);

yield* execFile(path.join(dist, 'cli'), args);
const bin = pipe(path.join(dist, 'cli'), String.replaceAll('app.asar', 'app.asar.unpacked'));

yield* pipe(Command.make(bin, ...args), Command.stdout('inherit'), Command.stderr('inherit'), Command.exitCode);

app.quit();
}),
Expand Down
5 changes: 2 additions & 3 deletions packages/server/cmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -400,15 +400,14 @@ func run() error {
jsHTTPClient = &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
dialer := net.Dialer{}
return dialer.DialContext(ctx, "unix", workerSocketPath)
return api.DialWorker(ctx, workerSocketPath)
},
},
}
// NOTE: ConnectRPC requires an address even for Unix sockets.
// Use placeholder since actual routing is via socket.
jsBaseURL = "http://the-dev-tools:0"
slog.Info("Connecting to worker-js via Unix socket", "path", workerSocketPath)
slog.Info("Connecting to worker-js via socket", "path", workerSocketPath)
}

jsClient := node_js_executorv1connect.NewNodeJsExecutorServiceClient(
Expand Down
1 change: 1 addition & 0 deletions packages/server/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.25

require (
connectrpc.com/connect v1.19.1
github.com/Microsoft/go-winio v0.6.2
github.com/andybalholm/brotli v1.2.0
github.com/expr-lang/expr v1.17.7
github.com/goccy/go-json v0.10.5
Expand Down
2 changes: 2 additions & 0 deletions packages/server/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-202512091757
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
Expand Down
93 changes: 18 additions & 75 deletions packages/server/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,9 @@
package api

import (
"context"
"log"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"time"

"github.com/rs/cors"
Expand Down Expand Up @@ -71,14 +67,19 @@ const (
ServerModeTCP = "tcp"
)

// DefaultServerSocketPath returns the default path for the server Unix socket.
func DefaultServerSocketPath() string {
return filepath.Join(os.TempDir(), "the-dev-tools", "server.socket")
}

// DefaultWorkerSocketPath returns the default path for the worker-js Unix socket.
func DefaultWorkerSocketPath() string {
return filepath.Join(os.TempDir(), "the-dev-tools", "worker-js.socket")
func newH2CServer(mux *http.ServeMux) *http.Server {
return &http.Server{
// NOTE: ConnectRPC requires an address even for Unix sockets.
// Use a placeholder address since actual routing is via socket.
Addr: "the-dev-tools:0",
ReadHeaderTimeout: 10 * time.Second,
// INFO: Use h2c so we can serve HTTP/2 without TLS.
Handler: h2c.NewHandler(newCORS().Handler(mux), &http2.Server{
IdleTimeout: 0,
MaxConcurrentStreams: 100000,
MaxHandlers: 0,
}),
}
}

// ListenServices starts the server listening on either a Unix socket or TCP port.
Expand All @@ -104,75 +105,17 @@ func ListenServices(services []Service, port string) error {
case ServerModeTCP:
return listenTCP(mux, port)
case ServerModeUDS:
return listenUnix(mux)
return listenIPC(mux)
default:
slog.Warn("Unknown SERVER_MODE, falling back to uds", "mode", mode)
return listenUnix(mux)
return listenIPC(mux)
}
}

func listenTCP(mux *http.ServeMux, port string) error {
srv := &http.Server{
Addr: ":" + port,
ReadHeaderTimeout: 10 * time.Second,
// INFO: Use h2c so we can serve HTTP/2 without TLS.
Handler: h2c.NewHandler(newCORS().Handler(mux), &http2.Server{
IdleTimeout: 0,
MaxConcurrentStreams: 100000,
MaxHandlers: 0,
}),
}
srv := newH2CServer(mux)
srv.Addr = ":" + port

slog.Info("Server listening on TCP", "port", port)
return srv.ListenAndServe()
}

func listenUnix(mux *http.ServeMux) error {
socketPath := os.Getenv("SERVER_SOCKET_PATH")
if socketPath == "" {
socketPath = DefaultServerSocketPath()
}

srv := &http.Server{
// NOTE: ConnectRPC requires an address even for Unix sockets.
// Use a placeholder address since actual routing is via socket.
Addr: "the-dev-tools:0",
ReadHeaderTimeout: 10 * time.Second,
// INFO: Use h2c so we can serve HTTP/2 without TLS.
Handler: h2c.NewHandler(newCORS().Handler(mux), &http2.Server{
IdleTimeout: 0,
MaxConcurrentStreams: 100000,
MaxHandlers: 0,
}),
}

socketDir := filepath.Dir(socketPath)

// Create socket directory if it doesn't exist
if err := os.MkdirAll(socketDir, 0o750); err != nil {
return err
}

// Remove stale socket file if present (e.g., from a previous crash)
if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
slog.Warn("Failed to remove stale socket", "path", socketPath, "error", err)
}

// Create Unix socket listener
lc := net.ListenConfig{}
socket, err := lc.Listen(context.Background(), "unix", socketPath)
if err != nil {
log.Fatal(err)
}

slog.Info("Server listening on Unix socket", "path", socketPath)

// Ensure socket cleanup on server close
srv.RegisterOnShutdown(func() {
if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
slog.Warn("Failed to remove socket on shutdown", "path", socketPath, "error", err)
}
})

return srv.Serve(socket)
}
}
67 changes: 67 additions & 0 deletions packages/server/internal/api/api_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//go:build !windows

package api

import (
"context"
"log"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
)

// DefaultServerSocketPath returns the default path for the server Unix socket.
func DefaultServerSocketPath() string {
return filepath.Join(os.TempDir(), "the-dev-tools", "server.socket")
}

// DefaultWorkerSocketPath returns the default path for the worker-js Unix socket.
func DefaultWorkerSocketPath() string {
return filepath.Join(os.TempDir(), "the-dev-tools", "worker-js.socket")
}

func listenIPC(mux *http.ServeMux) error {
socketPath := os.Getenv("SERVER_SOCKET_PATH")
if socketPath == "" {
socketPath = DefaultServerSocketPath()
}

srv := newH2CServer(mux)

socketDir := filepath.Dir(socketPath)

// Create socket directory if it doesn't exist
if err := os.MkdirAll(socketDir, 0o750); err != nil {
return err
}

// Remove stale socket file if present (e.g., from a previous crash)
if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
slog.Warn("Failed to remove stale socket", "path", socketPath, "error", err)
}

// Create Unix socket listener
lc := net.ListenConfig{}
socket, err := lc.Listen(context.Background(), "unix", socketPath)
if err != nil {
log.Fatal(err)
}

slog.Info("Server listening on Unix socket", "path", socketPath)

// Ensure socket cleanup on server close
srv.RegisterOnShutdown(func() {
if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
slog.Warn("Failed to remove socket on shutdown", "path", socketPath, "error", err)
}
})

return srv.Serve(socket)
}

func DialWorker(ctx context.Context, socketPath string) (net.Conn, error) {
dialer := net.Dialer{}
return dialer.DialContext(ctx, "unix", socketPath)
}
45 changes: 45 additions & 0 deletions packages/server/internal/api/api_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//go:build windows

package api

import (
"context"
"log/slog"
"net"
"net/http"
"os"

"github.com/Microsoft/go-winio"
)

// DefaultServerSocketPath returns the default path for the server Unix socket.
func DefaultServerSocketPath() string {
return `\\.\pipe\the-dev-tools_server.socket`
}

// DefaultWorkerSocketPath returns the default path for the worker-js Unix socket.
func DefaultWorkerSocketPath() string {
return `\\.\pipe\the-dev-tools_worker-js.socket`
}

func listenIPC(mux *http.ServeMux) error {
socketPath := os.Getenv("SERVER_SOCKET_PATH")
if socketPath == "" {
socketPath = DefaultServerSocketPath()
}

srv := newH2CServer(mux)

slog.Info("Server listening on Named Pipe", "path", socketPath)

listener, err := winio.ListenPipe(socketPath, nil)
if err != nil {
return err
}

return srv.Serve(listener)
}

func DialWorker(ctx context.Context, socketPath string) (net.Conn, error) {
return winio.DialPipe(socketPath, nil)
}
7 changes: 7 additions & 0 deletions packages/worker-js/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ NodeJsExecutorService(connectRouter);
// - WORKER_PORT: port number (tcp mode, defaults to 9090)

const WorkerServerUdsLive = Effect.gen(function* () {
if (os.platform() === 'win32') {
return yield* pipe(
NodeHttpServer.layer(createServer, { path: '\\\\.\\pipe\\the-dev-tools_worker-js.socket' }),
Layer.build,
);
}

const path = yield* Path.Path;
const fs = yield* FileSystem.FileSystem;

Expand Down
Loading