NextJS connect to backend Go api for buffering response

如果是 ASP 或 PHP 類的技術,可以透過設定 response 的 timeout 時間,再加上 flush (或類似機制)達成。這樣的處理用在需要較長執行時間的 api (或頁面)的情境相當適合,但如果是 nextjs 前端搭配 golang 後端,就沒有類似的機制,需要改用其他方式。

Golang WebSocket

以 fiber framework 為例, golang api backend 會長得類似這樣

import {
    "github.com/gofiber/contrib/websocket"
}
func main() {
    app := fiber.New(fiber.Config{})

    app.Get("/ws/resetfolder", websocket.New(handleResetFolder))
}

func handleResetFolder(c *websocket.Conn) {
    if err := ResetFolder(c); err != nil {
        log.Println("Error resetting folder:", err)
        c.Close()
    }
}

func ResetFolder(c *websocket.Conn) error {
    // folder is defined in somewhere else
    if err := os.RemoveAll(folder); err != nil {
        return fmt.Errorf("error removing folder %s: %v", folder, err)
    }

    outputMessage("■", c)
    outputMessage("<br/><br/>finished!", c)
    c.Close()

    return nil
}

nextjs 則需要建立 websocket connection

const [bWSClosed, setWSClosed] = useState(false);
const [bWSOpenedOnce, setWSOpened] = useState(false);

let wsHost = process.env.NEXT_PUBLIC_API_URL;
if (wsHost?.startsWith("http://")) {
    wsHost = "ws" + wsHost?.substring(wsHost.indexOf(":"));
} else if (wsHost?.startsWith("https://")) {
    wsHost = "wss" + wsHost?.substring(wsHost.indexOf(":"));
}

useEffect(() => {
    const socket = new WebSocket(wsHost + 'ws/resetfolder');

    socket.onopen = () => {
        setWSClosed(false);
        setWSOpened(true);
    };

    socket.onmessage = (event) => {
        prevMessageRef.current = prevMessageRef.current + event.data;
        setMessage(prevMessageRef.current);
    };

    socket.onclose = () => {
        setWSClosed(true);
    }

    return () => {
        socket.close();
    };
}, []);

Golang Progressive Response

Go lang 裏頭,是使用 Stream 方式來做 progressive response,所以跟上面的 WebSocket 不同,這裡的 prResetFolder 這個 handler 的參數跟一般 api 呼叫一樣,都還是一個 *fiber.Ctx。

而且在 function 內可以看到 c.Context().SetBodyStreamWriter(),直接用一個 streamwriter 來當作 body 內容輸出使用。

且裏頭還有設定一個 heart beat ticker,避免太久沒有 response 資料被browser client當作 idle 太久而斷線。

func main() {
    app := fiber.New(fiber.Config{})

    app.Get("/pr/resetfolder", prResetFolder)
}

func prResetFolder(c *fiber.Ctx) error {
    c.Set("Content-Type", "text/plain")
    c.Set("Cache-Control", "no-cache")
    c.Set("Connection", "keep-alive")

    c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
        folder := filepath.Join(dirResult, strconv.Itoa(report_year))

        ticker := time.NewTicker(1 * time.Minute) // Send heartbeat every 1 min
        defer ticker.Stop()

        done := make(chan struct{})
        go func() {
            for {
                select {
                case <-ticker.C:
                    fmt.Fprintf(w, ".") // Heartbeat to prevent idle
                    w.Flush()
                case <-done:
                    return
                }
            }
        }()

        if err := os.RemoveAll(folder); err != nil {
            fmt.Printf("error removing folder %s: %v\r\n", folder, err)
        }

        fmt.Fprintf(w, "■")
        w.Flush()

        close(done) // Stop ticker when work is done

        fmt.Fprintf(w, "<br/><br/>finished!")

        w.Flush()
    })
    return nil
}

NextJS 部分會變成

const [bWSClosed, setWSClosed] = useState(false);
const [bWSOpenedOnce, setWSOpened] = useState(false);

useEffect(() => {
    const fetchProgress = async () => {
        const response = await fetch(process.env.NEXT_PUBLIC_API_URL + 'pr/resetfolder');
        const reader = response?.body?.getReader();
        const decoder = new TextDecoder();
        let progressData = '';

        while (true) {
            const result = await reader?.read();
            if (!result) break;
            const { done, value } = result;
            if (done) {
                // console.log("done");
                setWSClosed(true);
                setWSOpened(true);
                break;
            } else {
                console.log("not done");
            }

            progressData += decoder.decode(value, { stream: true });

            prevMessageRef.current = progressData;
            setMessage(prevMessageRef.current);
        }
    };

    fetchProgress();
}, []);

實際測試與心得

因為 websocket 建立的連線跟實際 tcp/ip socke不完全一樣,所以當這個backend api 執行時間需要很久的時候,在瀏覽器這邊確實會發生以為連線已經中斷的情況。解決方式就是要自己加上程式碼,讓 backend 這邊可以定時(或者每做完一些事情)輸出一些資料去給前端。

使用 stream writer 方式加上了 ticker ,理論上就可以避免上述情況發生。但我自己實際上使用時,有時還是會發生這樣的情況,但已經比用 websocket 好很多。