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
36 changes: 36 additions & 0 deletions app/Actions/StartLaravelLanguageServerAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Support\Herd;
use App\Support\LaravelLspBridge;
use RuntimeException;

class StartLaravelLanguageServerAction
{
public function __construct(private Herd $herd, private LaravelLspBridge $bridge) {}

public function execute(string $project): int
{
$projectPath = $this->herd->projectPath($project);

throw_if($projectPath === null, RuntimeException::class, "Unknown Herd project: {$project}");

// laravel-lsp is a PHP tool, not the target project's own PHP - this app's own PHP binary
// runs it, resolved the same way this app already resolves any project's PHP binary,
// rather than relying on the `#!/usr/bin/env php` shebang. Herd's PATH for web-server
// processes doesn't reliably include `php` (unlike an interactive shell), so an explicit
// binary is required.
$phpBinary = $this->herd->phpBinary($this->herd->currentProject());

// Separately, laravel-lsp needs the *target* project's own PHP to run artisan commands
// against it (e.g. resolving real config values) - resolved explicitly here rather than
// left to laravel-lsp's own `herd which-php` auto-detection, which fails the same way
// under this nested spawn chain.
$targetPhpBinary = $this->herd->phpBinary($project);

return $this->bridge->start($projectPath, $phpBinary, $targetPhpBinary);
}
}
16 changes: 16 additions & 0 deletions app/Http/Controllers/StartLaravelLanguageServerController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\StartLaravelLanguageServerAction;
use Illuminate\Http\JsonResponse;

class StartLaravelLanguageServerController
{
public function __invoke(StartLaravelLanguageServerAction $action, string $project): JsonResponse
{
return response()->json(['port' => $action->execute($project)]);
}
}
45 changes: 2 additions & 43 deletions app/Support/LanguageServerBridge.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,53 +4,12 @@

namespace App\Support;

use Illuminate\Support\Facades\Process;
use InvalidArgumentException;

class LanguageServerBridge
{
private const int START_TIMEOUT_SECONDS = 60;
public function __construct(private LanguageServerBridgeLauncher $launcher) {}

public function start(string $projectPath, string $phpVersion): int
{
// This request blocks waiting on the bridge process below, bounded by Process's own
// timeout, so PHP's own max_execution_time is lifted past that same bound, with headroom
// for the process to actually be killed, otherwise the request would fatally time out
// from under a bridge that Process::timeout() is still waiting to terminate. Mirrors
// Herd::runSnippet()'s reasoning.
set_time_limit(self::START_TIMEOUT_SECONDS + 30);

$invoked = Process::options(['create_new_console' => true])->timeout(self::START_TIMEOUT_SECONDS)->start([
$this->nvmExec(),
'node',
base_path('app/Support/bin/intelephense-bridge.mjs'),
$projectPath,
$phpVersion,
]);

$port = null;

$invoked->waitUntil(function (string $type, string $line) use (&$port): bool {
if ($type !== 'out') {
return false;
}

$port = (int) mb_trim($line);

return true;
});

throw_unless(is_int($port) && $port >= 1 && $port <= 65535, InvalidArgumentException::class, 'The language server bridge did not report a valid port.');

return $port;
}

private function nvmExec(): string
{
$path = config('services.herd.nvm_exec');

throw_if(! is_string($path) || $path === '', InvalidArgumentException::class, 'The services.herd.nvm_exec configuration must be a non-empty path.');

return $path;
return $this->launcher->start(base_path('app/Support/bin/intelephense-bridge.mjs'), [$projectPath, $phpVersion]);
}
}
58 changes: 58 additions & 0 deletions app/Support/LanguageServerBridgeLauncher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace App\Support;

use Illuminate\Support\Facades\Process;
use InvalidArgumentException;

class LanguageServerBridgeLauncher
{
private const int START_TIMEOUT_SECONDS = 60;

/**
* @param list<string> $args
*/
public function start(string $scriptPath, array $args): int
{
// This request blocks waiting on the bridge process below, bounded by Process's own
// timeout, so PHP's own max_execution_time is lifted past that same bound, with headroom
// for the process to actually be killed, otherwise the request would fatally time out
// from under a bridge that Process::timeout() is still waiting to terminate. Mirrors
// Herd::runSnippet()'s reasoning.
set_time_limit(self::START_TIMEOUT_SECONDS + 30);

$invoked = Process::options(['create_new_console' => true])->timeout(self::START_TIMEOUT_SECONDS)->start([
$this->nvmExec(),
'node',
$scriptPath,
...$args,
]);

$port = null;

$invoked->waitUntil(function (string $type, string $line) use (&$port): bool {
if ($type !== 'out') {
return false;
}

$port = (int) mb_trim($line);

return true;
});

throw_unless(is_int($port) && $port >= 1 && $port <= 65535, InvalidArgumentException::class, 'The language server bridge did not report a valid port.');

return $port;
}

private function nvmExec(): string
{
$path = config('services.herd.nvm_exec');

throw_if(! is_string($path) || $path === '', InvalidArgumentException::class, 'The services.herd.nvm_exec configuration must be a non-empty path.');

return $path;
}
}
15 changes: 15 additions & 0 deletions app/Support/LaravelLspBridge.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace App\Support;

class LaravelLspBridge
{
public function __construct(private LanguageServerBridgeLauncher $launcher) {}

public function start(string $projectPath, string $phpBinary, string $targetPhpBinary): int
{
return $this->launcher->start(base_path('app/Support/bin/laravel-lsp-bridge.mjs'), [$projectPath, $phpBinary, $targetPhpBinary]);
}
}
66 changes: 6 additions & 60 deletions app/Support/bin/intelephense-bridge.mjs
Original file line number Diff line number Diff line change
@@ -1,52 +1,17 @@
import { readFileSync } from 'node:fs';
import { createServer } from 'node:https';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { toSocket } from 'vscode-ws-jsonrpc';
import { createServerProcess, createWebSocketConnection, forward } from 'vscode-ws-jsonrpc/server';
import { WebSocketServer } from 'ws';
import { runBridge } from './language-server-bridge.mjs';

const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
const INTELEPHENSE_BIN = path.join(PROJECT_ROOT, 'node_modules/.bin/intelephense');

// Safari (macOS 15+) blocks a plain ws:// connection from an https:// page as mixed content, even to
// 127.0.0.1, unlike Chrome/Firefox. Reusing tinkerbench.test's own Herd-issued certificate (signed by the
// already system-trusted Laravel Valet CA) lets the bridge speak wss:// without minting a new certificate.
// TINKERBENCH_HERD_CERTIFICATES_DIR overrides this for environments without Herd (CI), the same way
// services.herd.nvm_exec is already overridable.
const HERD_CERTIFICATES_DIR = process.env.TINKERBENCH_HERD_CERTIFICATES_DIR ?? path.join(
os.homedir(),
'Library/Application Support/Herd/config/valet/Certificates',
);

const [, , projectPath, phpVersion] = process.argv;

if (!projectPath || !phpVersion) {
console.error('Usage: intelephense-bridge.mjs <projectPath> <phpVersion>');
process.exit(1);
}

const httpsServer = createServer({
cert: readFileSync(path.join(HERD_CERTIFICATES_DIR, 'tinkerbench.test.crt')),
key: readFileSync(path.join(HERD_CERTIFICATES_DIR, 'tinkerbench.test.key')),
});
// Rejects any WebSocket handshake not sent from tinkerbench's own page, so another origin open in the
// same browser can't drive this bridge's LSP session (cross-site WebSocket hijacking): unlike fetch(),
// browsers always send Origin on a WS handshake regardless of same-origin, so this is a reliable check.
const server = new WebSocketServer({
server: httpsServer,
verifyClient: ({ origin }) => origin === 'https://tinkerbench.test',
});

let idleTimer = null;

function resetIdleTimer() {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => process.exit(0), IDLE_TIMEOUT_MS);
}

// intelephense's PHP version target isn't an "initialize" param, it only takes effect through a
// workspace/didChangeConfiguration notification, so it has to be injected as a side effect once the
// client's own "initialized" notification passes through, rather than returned from the map itself.
Expand All @@ -68,28 +33,9 @@ function rewriteToTargetProject(message, serverConnection) {
return message;
}

httpsServer.once('listening', () => {
process.stdout.write(`${httpsServer.address().port}\n`);
resetIdleTimer();
});

httpsServer.listen(0, '127.0.0.1');

server.on('connection', (socket) => {
clearTimeout(idleTimer);

socket.on('close', () => process.exit(0));
socket.on('message', () => resetIdleTimer());

const serverConnection = createServerProcess('intelephense', INTELEPHENSE_BIN, ['--stdio']);

if (!serverConnection) {
socket.close();

return;
}

forward(createWebSocketConnection(toSocket(socket)), serverConnection, (message) =>
rewriteToTargetProject(message, serverConnection),
);
runBridge({
serverName: 'intelephense',
spawnBin: INTELEPHENSE_BIN,
spawnArgs: ['--stdio'],
rewriteMessage: rewriteToTargetProject,
});
75 changes: 75 additions & 0 deletions app/Support/bin/language-server-bridge.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { readFileSync } from 'node:fs';
import { createServer } from 'node:https';
import os from 'node:os';
import path from 'node:path';
import { toSocket } from 'vscode-ws-jsonrpc';
import { createServerProcess, createWebSocketConnection, forward } from 'vscode-ws-jsonrpc/server';
import { WebSocketServer } from 'ws';

// The parent PHP process (Process::options(['create_new_console' => true])) stops reading this
// process's own stdout/stderr once it has the reported port, and closes its read end of those
// pipes once its Process object is garbage collected shortly after. From that point on, this is
// a detached process with nothing left reading its stdio at all, so a write to either (e.g.
// createServerProcess()'s own `${serverName} Server: ...` stderr relay, further down) hits a
// closed pipe (EPIPE) and would otherwise crash the whole bridge as an uncaught exception.
process.stdout.on('error', () => {});
process.stderr.on('error', () => {});

const IDLE_TIMEOUT_MS = 5 * 60 * 1000;

// Safari (macOS 15+) blocks a plain ws:// connection from an https:// page as mixed content, even to
// 127.0.0.1, unlike Chrome/Firefox. Reusing tinkerbench.test's own Herd-issued certificate (signed by the
// already system-trusted Laravel Valet CA) lets the bridge speak wss:// without minting a new certificate.
// TINKERBENCH_HERD_CERTIFICATES_DIR overrides this for environments without Herd (CI), the same way
// services.herd.nvm_exec is already overridable.
const HERD_CERTIFICATES_DIR = process.env.TINKERBENCH_HERD_CERTIFICATES_DIR ?? path.join(
os.homedir(),
'Library/Application Support/Herd/config/valet/Certificates',
);

export function runBridge({ serverName, spawnBin, spawnArgs, rewriteMessage }) {
const httpsServer = createServer({
cert: readFileSync(path.join(HERD_CERTIFICATES_DIR, 'tinkerbench.test.crt')),
key: readFileSync(path.join(HERD_CERTIFICATES_DIR, 'tinkerbench.test.key')),
});
// Rejects any WebSocket handshake not sent from tinkerbench's own page, so another origin open in the
// same browser can't drive this bridge's LSP session (cross-site WebSocket hijacking): unlike fetch(),
// browsers always send Origin on a WS handshake regardless of same-origin, so this is a reliable check.
const server = new WebSocketServer({
server: httpsServer,
verifyClient: ({ origin }) => origin === 'https://tinkerbench.test',
});

let idleTimer = null;

function resetIdleTimer() {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => process.exit(0), IDLE_TIMEOUT_MS);
}

httpsServer.once('listening', () => {
process.stdout.write(`${httpsServer.address().port}\n`);
resetIdleTimer();
});

httpsServer.listen(0, '127.0.0.1');

server.on('connection', (socket) => {
clearTimeout(idleTimer);

socket.on('close', () => process.exit(0));
socket.on('message', () => resetIdleTimer());

const serverConnection = createServerProcess(serverName, spawnBin, spawnArgs);

if (!serverConnection) {
socket.close();

return;
}

forward(createWebSocketConnection(toSocket(socket)), serverConnection, (message) =>
rewriteMessage(message, serverConnection),
);
});
}
42 changes: 42 additions & 0 deletions app/Support/bin/laravel-lsp-bridge.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { runBridge } from './language-server-bridge.mjs';

const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
const LARAVEL_LSP_SCRIPT = path.join(PROJECT_ROOT, 'vendor/bin/laravel-lsp');

const [, , projectPath, phpBinary, targetPhpBinary] = process.argv;

if (!projectPath || !phpBinary || !targetPhpBinary) {
console.error('Usage: laravel-lsp-bridge.mjs <projectPath> <phpBinary> <targetPhpBinary>');
process.exit(1);
}

function rewriteToTargetProject(message) {
if (message.method === 'initialize' && message.params) {
message.params.rootUri = `file://${projectPath}`;
message.params.rootPath = projectPath;
message.params.workspaceFolders = [{ uri: `file://${projectPath}`, name: 'project' }];
message.params.initializationOptions = {
// Bypasses laravel-lsp's own `herd which-php` auto-detection (phpEnvironment: 'herd'),
// which fails under this app's nested spawn chain (PHP web request -> this bridge ->
// php laravel-lsp): that inner `herd` invocation doesn't inherit a PATH with `herd` on
// it either, silently falling back to an unqualified `php` that then can't run
// `artisan tinker` against the target project at all. An already-resolved, explicit
// binary sidesteps that resolution entirely.
phpCommand: [targetPhpBinary],
// pestGenerateDocBlocks defaults to true and writes storage/framework/testing/_pest.php into the
// target project on disk, a side effect this bridge shouldn't cause just by attaching.
pestGenerateDocBlocks: false,
};
}

return message;
}

runBridge({
serverName: 'laravel-lsp',
spawnBin: phpBinary,
spawnArgs: [LARAVEL_LSP_SCRIPT],
rewriteMessage: rewriteToTargetProject,
});
Loading