Creating Google Docs with Go & NextJS
Over the past couple of weeks, we've been setting up a full-stack project using Go and NextJS. You can optionally read parts one and two, and view the starter code in this GitHub repository.
What we'll be building
Recap
We have this project structure:
The directories frontend
and backend
each contain a webserver, frontend
contains a NextJS server which connects to backend
, and backend
serves both the initial content of a document as well as any updates that are made to it.
Cloning the starter code
It's the "websocket" branch of the repository linked above:
user@computer:~$ git clone -b websocket https://github.com/webappio/golang-nextjs-example.git
user@computer:~$ cd golang-nextjs-example
Adding a text editor to our frontend
For now we'll keep things simple: A single text field for the name of the document, and another text field for the contents of the document.
We'll just use basic HTML with <textarea>
and make the title editable with contentEditable="true"
We'll also use the standard React "controlled input" handling (read more about that in the docs here) in order to send updates about the document from the frontend to the backend and vice-versa.
Edit your index.js
file to look like this:
import Head from 'next/head'
import styles from '../styles/Home.module.css'
import {useEffect, useState} from "react";
export async function getServerSideProps() {
const initialData = await fetch("http://localhost:8000/handler-initial-data").then(x => x.json());
return {props: {data: initialData}}
}
export default function Home(props) {
const [data, setData] = useState(props.data);
const [ws, setWS] = useState(null);
useEffect(() => {
const newWS = new WebSocket("ws://localhost:8000/handler")
newWS.onerror = err => console.error(err);
newWS.onopen = () => setWS(newWS);
newWS.onmessage = msg => setData(JSON.parse(msg.data));
}, [])
return (
<div className={styles.container}>
<Head>
<title>OSS Docs</title>
<meta name="description" content="Like Google Docs, but open source!"/>
<link rel="icon" href="/favicon.ico"/>
</Head>
<main className={styles.main}>
<input
className={styles.title}
onChange={e => ws.send(JSON.stringify({
"title": e.target.value,
"body": data.body,
}))}
value={data.title || "Untitled Document"}
/>
<textarea
className={styles.document}
onChange={e => ws.send(JSON.stringify({
"title": data.title,
"body": e.target.value,
}))}
value={data.body || ""}
/>
</main>
</div>
)
}
You'll have to add a class anywhere in Home.module.css
:
.document {
border-radius: 10px;
border: 1px solid #b4b4b4;
display: flex;
align-self: stretch;
flex: 0 1 75vh;
margin: 16px 8px 8px 8px;
padding: 8px;
}
Sending the document from the backend to the frontend using our WebSocket
For now, we'll assume there's one big global document that everyone is editing. Define a Document
struct with fields body
and title
, and an instance of that struct as a global variable above the main method in main.go
Next, we'll return that document as the data from both the SSR endpoint (for when the user initially visits the page) as well as the websocket (for when new changes are made)
After these changes, the entire main.go
file looks like this:
package main
import (
"bytes"
"encoding/json"
"fmt"
"github.com/gobwas/ws"
"github.com/gobwas/ws/wsutil"
"log"
"net/http"
)
type Document struct {
Title string `json:"title"`
Body string `json:"body"`
}
var document = Document{
Title: "Test document",
Body: "Hello world\nhere is a second line",
} //TODO HACK: only one document for now
func main() {
handler := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
if req.URL.Path == "/handler-initial-data" {
var documentBytes bytes.Buffer
err := json.NewEncoder(&documentBytes).Encode(&document)
if err != nil {
log.Println("Error encoding document: ", err)
return
}
rw.Header().Set("Content-Type", "application/json")
rw.Header().Set("Content-Length", fmt.Sprint(documentBytes.Len()))
rw.Write(documentBytes.Bytes())
} else if req.URL.Path == "/handler" {
conn, _, _, err := ws.UpgradeHTTP(req, rw)
if err != nil {
log.Println("Error with WebSocket: ", err)
rw.WriteHeader(http.StatusMethodNotAllowed)
return
}
go func() {
defer conn.Close()
var documentBytes bytes.Buffer
err := json.NewEncoder(&documentBytes).Encode(&document)
if err != nil {
log.Println("Error encoding document: ", err)
return
}
err = wsutil.WriteServerMessage(conn, ws.OpText, documentBytes.Bytes())
if err != nil {
log.Println("Error writing WebSocket data: ", err)
return
}
}()
} else {
rw.WriteHeader(http.StatusNotFound)
}
})
log.Println("Server is available at http://localhost:8000")
log.Fatal(http.ListenAndServe(":8000", handler))
}
Once you start the backend with go run main.go
in the backend
directory, you should see this:
HerpPe, the string "Test document" has been:
- Pulled from the global variable in
main.go
, - Sent through the WebSocket in the
/handler
endpoint, - Set to the value of
data
in theuseEffect
inindex.js
, and - Rendered by referencing
data.title
in the React.js code. Whew!
Sending changes from the frontend to the backend
The index.js
file above already sends events via the WebSocket to when you change the title or the document via onChange
. All that remains to do is to handle those events in the backend.
For that, we'll make another goroutine which listens for updates from the client, uses a mutex to deal with concurrency issues, and a condition variable to notify all of the other clients that the document has changed.
Add a mutex and a condition below the document declaration:
var document = Document{
Title: "Test document",
Body: "Hello world\nhere is a second line",
} //TODO HACK: only one document for now
var documentMutex sync.Mutex
var documentCond = sync.NewCond(&documentMutex)
Modify our goroutine to wait for the the document to change before sending an update:
go func() {
defer conn.Close()
for { //send the document to the frontend when it changes
documentMutex.Lock()
documentCond.Wait()
documentMutex.Unlock()
var documentBytes bytes.Buffer
...
}
...
}
And finally add a second goroutine to listen for updates from the client:
go func() {
for { //the client is asking to change the document
defer conn.Close()
data, err := wsutil.ReadClientText(conn)
if err != nil {
log.Println("Error encoding document: ", err)
return
}
documentMutex.Lock()
err = json.Unmarshal(data, &document)
if err != nil {
documentMutex.Unlock()
log.Println("Error unmarshalling document: ", err)
return
}
documentCond.Broadcast()
documentMutex.Unlock()
}
}()
If everything is set up properly, you should be able to open two tabs, change the document title in one, and see the changes reflected in the other.
We've shared the state of the document between 2 or more browser tabs with only a few hundred lines of code!
The complete source code for this example is available in the google-docs branch of the GitHub repository here.