@effected/lsp

中文文档
0.9.0发布于 2026/11/10MITTypeScriptES Module零依赖构建来源已验证近 30 天下载 3954

Language Server Protocol base-protocol framing for Effect v4, plus a packed-install initialize probe for a Language Server bin

原始使用文档
文档目录7 节
  1. Install
  2. LspFrame
  3. LspStdio
  4. LspUri
  5. LspProbe ( @effected/lsp/testing )
  6. LspProcess ( @effected/lsp/testing )
  7. License

@effected/lsp

Language Server Protocol base-protocol framing for Effect v4 — Content-Length frames encoded by byte length and decoded incrementally — a stdio launcher that keeps stdout the LSP wire and exits with the code the specification requires, and a ./testing subpath whose LspProbe proves a Language Server bin boots and whose LspProcess drives one frame by frame.

npm License: MIT Node.js %3E%3D24.11.0 TypeScript 7.0

Pre-1.0.0. This package is part of the @effected/* kit, built on stable Effect v4 (effect ~4.1.0) and still in 0.x development. Stable Effect makes a kit 1.0.0 possible, not automatic. To keep your effect and @effect/* versions on the line the kit is built and tested against, install @effected/pnpm-plugin-effected.

Stability: unstable. This package's API surface is not yet considered complete and may change across 0.x releases. Pin an exact version — even a package marked stable before 1.0.0 can introduce a breaking change by accident, and an exact pin turns that into a type-check error rather than a runtime surprise. Full policy: release strategy.

Install

npm install @effected/lsp effect
pnpm add @effected/lsp effect

Requires Node.js >=24.11.0.

All @effected/* packages are ESM-only: the exports maps publish only import conditions, so require() fails with Node's ERR_PACKAGE_PATH_NOT_EXPORTED. Import from an ES module.

effect v4 is the only peer dependency. Boundary tier: the main entry is pure, and ./testing runs a child process only through core's ChildProcessSpawner, which you provide with one platform layer (NodeServices.layer from @effect/platform-node).

This is not an LSP server framework. It frames the wire, launches a server you wrote, and tests the bin; the message loop and every handler stay with whatever you serve the protocol with.

LspFrame

An LSP message travels as a header block, a blank line, and a UTF-8 JSON body whose Content-Length counts bytes. A hand-rolled frame that writes body.length is right for ASCII and wrong by one for every extra byte of a multi-byte character, so the server reads into the next frame. LspFrame.encode counts the encoding:

import { LspFrame } from "@effected/lsp";

const frame = LspFrame.encode({ jsonrpc: "2.0", method: "note", params: { text: "✓" } });
console.log(new TextDecoder().decode(frame));
// => Content-Length: 57
//
//    {"jsonrpc":"2.0","method":"note","params":{"text":"✓"}}

Decoding is incremental. LspFrame.decodeResult takes whatever bytes have arrived and returns every complete frame plus the bytes of an incomplete one, so a chunk may end inside a header, a body or a character, and may carry several frames. LspFrame.decodeStream threads that across a Stream of chunks, and LspFrame.decodeAllResult decodes a collected stdout in one go:

import { LspFrame } from "@effected/lsp";
import { Result } from "effect";

const decoded = LspFrame.decodeAllResult('Content-Length: 2\r\n\r\n{}Content-Length: 5\r\n\r\n"✓"');
console.log(Result.getOrThrow(decoded));
// => [ {}, '✓' ]

A malformed stream fails with a typed LspFrameError whose code says which rule broke (MissingContentLength, InvalidContentLength, InvalidHeader, HeaderTooLarge, InvalidBody, Truncated), whose offset is the frame's stream position, and whose excerpt echoes the frame's start — a log line written to stdout before the first frame shows up there verbatim. decode and decodeAll are the Effect forms.

LspStdio

An Effect Language Server over stdio has three traps, and LspStdio closes each:

  • runMain reports a failed program on stdout. Its report runs outside every Effect.provide the program applies, so a LogToStderr provided inside never reaches it: a layer that fails to build (a missing HOME, a bad config) prints its report onto the wire. LspStdio.launch provides LogToStderr to the whole program, reports a failure on stderr itself, and re-raises it marked as already reported, keeping its exit code.
  • The exit code is the specification's. exit without a prior shutdown exits 1; everything else 0. LspStdio.exitCode is that rule, and launch applies it to the LspSessionEnd your message loop returns.
  • A clean exit never happens on its own. runMain ends the process itself only for a non-zero code or a signal, and leaves code 0 to the event loop draining, which it never does while stdin is open — and after exit, stdin is still open. LspStdio.teardown(process) ends the process explicitly, and maps SIGINT and SIGTERM to 0 instead of 130.
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import { LspStdio } from "@effected/lsp";
import { Effect } from "effect";
import { AppLayer, serve } from "./server.js";

// `serve` is your server: it runs the message loop and returns how the session ended.
NodeRuntime.runMain(LspStdio.launch(serve.pipe(Effect.provide(AppLayer))), {
  teardown: LspStdio.teardown(process),
});

The kit does not own the message loop, so it cannot see shutdown or exit itself: your loop records them and returns { reason: "exit" | "closed", shutdownReceived }. A vscode-languageserver connection reports both through onShutdown and onExit.

LogToStderr reaches Effect's default logger and Logger.consolePretty. Logger.consoleJson, consoleLogFmt and consoleStructured write through console.log regardless — wrap a formatter in Logger.withConsoleError instead — and Console.log still writes to stdout.

LspUri

LSP names documents by file: URI, the file system by path. LspUri converts both ways without node:url, so it also runs in a worker:

import { LspUri } from "@effected/lsp";

const platform = process.platform === "win32" ? "win32" : "posix";

LspUri.toPath("file:///home/me/My%20Project/a.ts", { platform }); // Option.some("/home/me/My Project/a.ts")
LspUri.toPath("untitled:Untitled-1", { platform }); // Option.none()
LspUri.fromPath("C:\\Users\\me\\a.ts", { platform: "win32" }); // "file:///c:/Users/me/a.ts"

toPath answers Option.none() instead of throwing for a URI that names no path: another scheme, a malformed escape, a %2F inside a name, a host on posix. . and .. never climb above the root, a drive or a UNC share. The drive letter comes back lowercase however the client spelled it (C:, c: or VS Code's c%3A), so key documents by path, not by the client's URI string.

LspProbe (@effected/lsp/testing)

The packed-install proof for a Language Server bin, the twin of @effected/mcp-kit/testing's McpProbe. It spawns the command and runs the lifecycle an editor runs — initialize, initialized, shutdown, exit — then waits for the server to exit:

import * as NodeServices from "@effect/platform-node/NodeServices";
import { LspProbe } from "@effected/lsp/testing";
import { Effect } from "effect";
import { ChildProcess } from "effect/process";

const program = Effect.gen(function* () {
  const probe = yield* LspProbe.initialize(ChildProcess.make("my-lsp", ["--stdio"]));
  // A clean boot: no JSON-RPC error, exit code 0 (shutdown preceded exit), nothing on stderr.
  return probe.response.error === undefined && probe.exitCode === 0 && probe.stderr === "";
}).pipe(Effect.provide(NodeServices.layer));
  • initialize goes out with processId and rootUri null and empty client capabilities unless LspProbeOptions says otherwise.
  • Stdin stays open after exit: the server must stop on exit, as it must under an editor. A server that waits for stdin to close fails with TimedOut.
  • Every message the server sent is in messages, in order; requests it sends to the client are recorded and never answered.
  • It never hangs: stdout that ends early fails StreamEnded with the exit code and stderr, bytes that are not frames fail InvalidFrame, and the whole exchange runs under timeout (30 seconds by default). Run it under it.live — the timeout reads Clock.

LspProcess (@effected/lsp/testing)

A spawned server a test drives frame by frame — the LSP twin of @effected/mcp-kit/testing's McpProcess. The child lives for the enclosing scope:

import * as NodeServices from "@effect/platform-node/NodeServices";
import { LspProcess } from "@effected/lsp/testing";
import { Effect } from "effect";
import { ChildProcess } from "effect/process";

const program = Effect.gen(function* () {
  const server = yield* LspProcess.spawn(ChildProcess.make("my-lsp", ["--stdio"]));
  yield* server.send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { processId: null, rootUri: null, capabilities: {} } });
  const { response, seen } = yield* server.readUntilResponse(1);
  yield* server.send({ jsonrpc: "2.0", method: "initialized", params: {} });
  yield* server.send({ jsonrpc: "2.0", id: 2, method: "shutdown" });
  yield* server.readUntilResponse(2);
  yield* server.send({ jsonrpc: "2.0", method: "exit" });
  const exitCode = yield* server.exitCode;
  // Stdout held nothing but frames: no stray byte before, between or after them.
  const messages = yield* server.assertOnlyFrames;
  return { response, seen, exitCode, messages };
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer));
  • send frames with LspFrame.encode; sendRaw writes bytes as given, for a malformed or split frame.
  • nextMessage and readUntilResponse never hang: stdout that ends fails StreamEnded (inside a frame too), bytes that are not a frame fail InvalidFrame, and a body that is not JSON-RPC fails NotJsonRpc.
  • stderrUntil(predicate, { timeout }) waits for a report that lands on a later tick, woken by each stderr chunk rather than a polling timer, and fails StreamEnded or TimedOut with the stderr so far. Run it under it.live.
  • stdoutSoFar and stdoutFinal return the raw bytes; assertOnlyFrames waits for stdout to end and fails InvalidFrame, naming the offset and quoting the stray bytes, unless every byte belonged to a well-formed frame.

License

MIT

版本与发布记录

@effected/lsp 共发布 12 个版本,首次发布于 2026/10/06;过去 12 个月发布了 12 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.9.0latest2026/11/102 天前
0.8.02026/11/0310 天前
0.7.02026/11/0112 天前
0.6.02026/10/2915 天前
0.5.02026/10/2321 天前
0.4.02026/10/1924 天前

关于 @effected/lsp 的常见问题

@effected/lsp 是什么?

Language Server Protocol base-protocol framing for Effect v4, plus a packed-install initialize probe for a Language Server bin

如何安装 @effected/lsp?

在项目目录运行 npm install @effected/lsp,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:effect(~4.1.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@effected/lsp 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@effected/lsp 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@effected/lsp 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=24.11.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@effected/lsp 可以商用吗?

@effected/lsp 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@effected/lsp 的下载量有多少?

根据 npm 官方下载统计,@effected/lsp 在 2026/10/13 – 2026/11/11 这 30 天内被下载 3,954 次,日均约 132 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@effected/lsp 还在维护吗?

最新版本 0.9.0 发布于 2026/11/10(2 天前);至今共发布 12 个版本,其中过去 12 个月发布了 12 个;首次发布于 2026/10/06。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。