logo
blog tags
Published at

Cligram v2: Bridging Go and JavaScript in a Terminal UI Application

Cligram v2: Bridging Go and JavaScript in a Terminal UI Application

I swapped out Cligram's React-based terminal UI for a new one in Go, but kept the original JavaScript backend. This post breaks down how I connected the two languages using JSON-RPC over stdio.

Hey folks, it’s been a while since I wrote a blog post. I was busy with work and personal stuff. Recently, I released v2 of Cligram. Today, I want to show what I learned by building this project. This is a walk-through of how the project works. You can take a look at the full project on GitHub. V2 doesn’t have new features, but I migrated the UI (TUI) part to Go. Previously, I built it with React. There is an amazing npm package called Ink, which is a React renderer just like React DOM for the web and React Native for mobile. Ink is also a React renderer, but it is for building TUIs (text-based user interfaces). It is used by:

  • Codex - An agentic coding tool, made by OpenAI
  • Claude Code - An agentic coding tool, made by Anthropic.
  • Gemini CLI - An agentic coding tool, made by Google
  • And many more, check out the list of projects and companies to see who is using Ink.

Alright, enough about Ink, you can read more about it here.

OK, what’s wrong with Ink?

Nothing! It’s great, it’s awesome, but it wasn’t a perfect fit for my use case.

The other day, I was experimenting with Bubble Tea for a tiny project. It makes building interactive TUIs a breeze. At that time, I was thinking:

“Should I rewrite Cligram in Go? No, I don’t want to rewrite the entire project in Go. Instead, let’s rewrite only the UI part in Go while keeping the underlying communication with Telegram servers in JavaScript. Ok, then how can I achieve communication between JavaScript and Go?”

At first, I was thinking of using WebSockets, but that felt like overkill, so I decided to go with JSON-RPC over stdio.

How does Cligram work?

Overview

As I said, the project consists of a JavaScript backend and a Go client.

Go Client

The Go client is responsible for the interaction with the JavaScript backend.

The JavaScript single executable is embedded using Go’s embed directive, which is handy for embedding static assets into your binary.

func GetJSExcutable() (*string, error) {
	cacheDir, err := os.UserCacheDir()
	if err != nil {
		return nil, fmt.Errorf("could not get user cache directory: %w", err)
	}

	appDir := filepath.Join(cacheDir, "cligram")
	if err := os.MkdirAll(appDir, 0755); err != nil {
		return nil, fmt.Errorf("could not create app cache directory: %w", err)
	}
   
	backendPath := filepath.Join(appDir, "cligram-js-backend") // js backend file path

	// hash the embedded binary
	embeddedHash := sha256.Sum256(assets.JSBackendBinary)
	embeddedHashStr := hex.EncodeToString(embeddedHash[:])

	// open the file on disk
	fileOnDisk, err := os.Open(backendPath)
	if err == nil {

		hasher := sha256.New()
		if _, err := io.Copy(hasher, fileOnDisk); err == nil {
			// get the hash of the file on disk
			diskHashStr := hex.EncodeToString(hasher.Sum(nil))
			// compare the hash with the one we created earlier
			if diskHashStr == embeddedHashStr {	
				fileOnDisk.Close()
				return &backendPath, nil
			}
		}
		fileOnDisk.Close()
	}

	_ = os.Remove(backendPath); // remove the file from the cache if it exists and write the new one
	if err := os.WriteFile(backendPath, assets.JSBackendBinary, 0755); err != nil {
		slog.Error("error writing file", "error", err.Error())
		return nil, fmt.Errorf("could not write embedded backend binary: %w", err)
	}

	return &backendPath, nil
}

When you run the app, I create a new SHA256 hash for the embedded JavaScript executable. Then, I look for the executable in the user’s cache directory. If it’s not there, I write it. If it is there, I hash the cached file and compare it to the embedded one’s hash. If they match, we’re good to go. If not, I replace the cached file with the new one. This ensures the user always has the latest version of the backend executable. Without this check, it would never get updated, which would be pretty bad.

After getting the executable’s path, we run it in a goroutine; this way, it won’t block the main thread.

   jsExcutable, err := runner.GetJSExcutable()
   cmd := exec.Command(*jsExcutable)
   stdin, err := cmd.StdinPipe(); // get the stdin pipe
   if err != nil {
      //ignore this
    }
   stdout, err := cmd.StdoutPipe(); // get the stdout pipe
   if err != nil {
    //ignore this one also
  }

Then we need to get the command’s stdin and stdout because we need to write to and read from the child process.

After this, the client makes a new JSON-RPC request to get your chat history from JavaScript. Here is an example JSON-RPC request:

Content-Length: 71\r\n\r\n{"jsonrpc":"2.0","id":1,"method":"getUserChats","params":["user"]}

By writing this to the stdin of the child process, we are executing a JavaScript function called getUserChats. Cool, right?

JavaScript Backend

Here is what the JavaScript implementation looks like:

  async function readHeaders(reader: typeof stdin): Promise<{ [key: string]: string }> {
	const headers: { [key: string]: string } = {};
	let lineBuffer = '';


	while (true) { 
		// read one character at a time
		const chunk = reader.read(1);
		if (chunk === null) {
			await new Promise((resolve) => reader.once('readable', resolve)); // wait for the reader to be readable
			continue;
		}
		const char = chunk.toString('utf8'); // convert the chunk to a string
		lineBuffer += char; // add the character to the line buffer

		if (lineBuffer.endsWith('\r\n') || lineBuffer.endsWith('\n')) {
			let line: string; 
			if (lineBuffer.endsWith('\r\n')) {
				line = lineBuffer.slice(0, -2); // remove the '\r\n' from the line buffer
			} else {
				line = lineBuffer.slice(0, -1);
			}
			// if the line is empty, we break the loop
			if (line === '') {
				break;
			}
			// split the line by ':' eg Content-Length: 69 => ['Content-Length', '69']
			const parts = line.split(':');
			if (parts.length >= 2) {
				// we only have one header, so that's why we are using 0 and 1
				const headerName = parts[0]!.trim();
				const headerValue = parts[1]!.trim();
				headers[headerName] = headerValue;
			}
			lineBuffer = '';
		}
	}
	return headers;
}

  async function readMessage(): Promise<IncomingMessage> {
	const headers = await readHeaders(stdin);
	const contentLengthHeader = Object.keys(headers).find(
		(h) => h.toLowerCase() === 'content-length'
	);

	if (!contentLengthHeader) {
		stderr.write('Error: Missing Content-Length header\n' + stringify(headers) + '\n');
		throw new Error('Missing Content-Length header');
	}
	const length = parseInt(headers[contentLengthHeader!]!, 10);
	if (isNaN(length) || length <= 0) {
		stderr.write('Error: Invalid Content-Length header: ' + headers[contentLengthHeader] + '\n');
		throw new Error('Invalid Content-Length header');
	}
    
	// create a buffer with the exact size
	let payloadBuffer = Buffer.alloc(length);
	let bytesRead = 0;
	while (bytesRead < length) {
		const chunk = stdin.read(length - bytesRead); // read the chunk
		if (chunk === null) {
			await new Promise((resolve) => stdin.once('readable', resolve)); // wait for the reader to be readable
			continue;
		}
		chunk.copy(payloadBuffer, bytesRead); // copy the chunk to the payload buffer
		bytesRead += chunk.length; // add the length of the chunk to the bytes read
	}

	const payload = payloadBuffer.toString('utf8', 0, length);

	try {
		return JSON.parse(payload) as IncomingMessage;
	} catch (e) {
		stderr.write('Error: Failed to parse JSON payload: ' + payload + '\n' + e + '\n');
		throw new Error('Parse error');
	}
}

First, we read the header. If you look at the readHeaders function carefully, you’ll see we’re reading one character at a time. We keep reading until we find a \r\n or \n. This signals the end of the header, and we can construct the header object, which will look like this:

const headers = {
 "Content-Length": 69
}

After constructing the header, we read the rest of the content. The header is crucial because we create a buffer with the exact size, and we use it to know when we’ve finished reading. Then we parse it as JSON. It’ll have the following shape:

 type JsonRPCRequest = {
  	jsonrpc: '2.0';
		id: number; 
		method: string; // function name we are going to call
		params: []; // parameters
  }

Lastly, we call the function, write the result to stdout, and then read the stdout in Go.

Conclusion

I hope you enjoyed reading this blog post. This is just a small part of the project, but I think it’s a good starting point to understand how it works. If you want to see the full project, you can check it out on GitHub. Let me know what you think about this post in the comments below. If you have any questions, feel free to ask me on Twitter.

sharing is caring