Skip to content

Getting started ​

The server on this page runs as a child process of a Bun or Node backend.

Install ​

sh
bun add @adecore/lsp

The package has no peer dependencies. Install the language server itself the way the app installs other tools; this page uses typescript-language-server.

Connect a transport ​

A transport carries whole JSON-RPC messages. For a process, wrap its streams in a ByteStream and let createStreamTransport do the Content-Length framing:

ts
import { spawn } from 'node:child_process';
import { createStreamTransport, type ByteStream } from '@adecore/lsp';

const child = spawn('typescript-language-server', ['--stdio'], { cwd: '/work' });

const stream: ByteStream = {
    write: (chunk) => void child.stdin.write(chunk),
    onData: (listener) => {
        child.stdout.on('data', listener);
        return { dispose: () => child.stdout.off('data', listener) };
    },
    onClose: (listener) => {
        const exited = (): void => listener();
        child.once('exit', exited);
        return { dispose: () => child.off('exit', exited) };
    },
    close: () => void child.kill()
};

const transport = createStreamTransport(stream);
child.stderr.on('data', (chunk) => console.error(String(chunk)));

Keep stderr apart from stdout, since a log line in stdout breaks the framing. Transports covers the WebSocket transport for a server on another machine.

Start a session ​

An LspSession is one conversation with one server. Await initialize() before anything else.

ts
import { LspSession, pathToFileUri } from '@adecore/lsp';

const session = new LspSession(transport, {
    rootUri: pathToFileUri('/work'),
    snippetSupport: true,
    onLog: (params) => console.log(params.message)
});

await session.initialize();

Sessions lists every option, such as the settings the server reads.

Open a document and ask ​

openDocument sends didOpen and returns the LspDocument that owns the text and version from then on. Each request is a method on it, with zero-based lines and UTF-16 characters.

ts
const document = session.openDocument({
    uri: pathToFileUri('/work/src/example.ts'),
    languageId: 'typescript',
    text: 'const greeting = "hello";\n'
});

const hover = await document.hover({ line: 0, character: 6 });

await document.updateText('const greeting = "hello, world";\n');

updateText sends only the range that changed. A change cancels the requests still running, and an answer that arrives for the old text rejects with a StaleResultError. Drop it and ask again. Documents and requests lists the requests.

Hand a language service to the editor ​

@adecore/editor-react does not take a session. It takes a LanguageService, which can route a document to several servers, start them on demand or reach another machine. Over one session, each method forwards to the open document:

ts
import type { DiagnosticsReport, LanguageDocument, LanguageRequestOptions, LanguageService, LspSession } from '@adecore/lsp';

const requestOptions = (options?: LanguageRequestOptions) => ({ signal: options?.signal, cancelPrevious: options?.parallel !== true });

class SessionService implements LanguageService {
    readonly #session: LspSession;
    readonly #open = new Set<string>();

    constructor(session: LspSession) {
        this.#session = session;
    }

    async openDocument({ uri, languageId, text }: LanguageDocument): Promise<void> {
        this.#open.add(uri);
        const open = this.#session.getDocument(uri);

        if (open !== undefined) {
            await open.updateText(text);
            return;
        }

        await this.#session.openDocument({ uri, languageId, text }).ready;
    }

    changeDocument(uri: string, changes: Parameters<LanguageService['changeDocument']>[1]): Promise<void> {
        return this.#document(uri).applyChanges(changes);
    }

    closeDocument(uri: string): Promise<void> {
        this.#open.delete(uri);
        return this.#document(uri).close();
    }

    supports(method: string, uri: string): boolean {
        const document = this.#session.getDocument(uri);
        return document !== undefined && this.#session.supports(method, document);
    }

    providerOptions(method: string, uri: string) {
        const document = this.#session.getDocument(uri);
        return document === undefined ? undefined : this.#session.providerOptions(method, document);
    }

    onProvidersChanged(listener: (uri: string) => void) {
        return this.#session.onCapabilitiesChanged(() => this.#open.forEach(listener));
    }

    onDiagnostics(listener: (report: DiagnosticsReport) => void) {
        return this.#session.onDiagnostics(({ uri, version, diagnostics }) => listener({ uri, version, diagnostics, source: 'typescript' }));
    }

    hover(uri: string, position: Parameters<LanguageService['hover']>[1], options?: LanguageRequestOptions) {
        return this.#document(uri).hover(position, requestOptions(options));
    }

    // completion, definition, rename and the other request methods forward the same way.

    #document(uri: string) {
        const document = this.#session.getDocument(uri);

        if (document === undefined) {
            throw new Error(`${uri} is not open.`);
        }

        return document;
    }
}

The comment stands for the rest of the requests; TypeScript lists the ones a class still lacks. parallel maps to cancelPrevious: false, so a feature that asks many questions at once does not cancel itself. Language service lists what a service promises, such as rejecting answers for old text.

When the session runs in a backend and the editor in a page, the service in the page forwards these calls over the app's own IPC, and the backend runs the class above.

The editor side then takes the service:

tsx
import { EditorView, ProjectLanguage } from '@adecore/editor-react';

const project = new ProjectLanguage(new SessionService(session), { folder: '/work' });

<EditorView engine={engine} options={options} project={project} uri={pathToFileUri('/work/src/example.ts')} languageId="typescript" className="h-96" />;

Shut down ​

shutdown() closes the documents, sends shutdown and exit and closes the connection. Stopping the process stays with the app.

ts
await session.shutdown();
child.kill();

Checklist ​

  • Stdout carries only protocol messages; stderr goes to a log.
  • initialize() has resolved before the first document opens.
  • URIs come from pathToFileUri, spelled the same way everywhere.
  • A StaleResultError is dropped, not shown.
  • The session shuts down before the process stops, and a closed session is replaced, never restarted.

Licenses and third-party notices are listed in each package.