Skip to content

Commit

Permalink
Simplify rendering
Browse files Browse the repository at this point in the history
  • Loading branch information
natewong1313 committed Oct 25, 2023
1 parent 7deb425 commit 8b31d4f
Show file tree
Hide file tree
Showing 3 changed files with 185 additions and 156 deletions.
20 changes: 0 additions & 20 deletions internal/reactbuilder/render.go

This file was deleted.

145 changes: 9 additions & 136 deletions render.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"github.com/natewong1313/go-react-ssr/internal/html"
"github.com/natewong1313/go-react-ssr/internal/reactbuilder"
"github.com/natewong1313/go-react-ssr/internal/utils"
"github.com/rs/zerolog"
"html/template"
Expand All @@ -20,23 +19,20 @@ type RenderConfig struct {
Props interface{}
}

type renderTask struct {
engine *Engine
logger zerolog.Logger
routeID string
filePath string
props string
config RenderConfig
serverRenderResult chan ServerRenderResult
clientRenderResult chan ClientRenderResult
}

func (engine *Engine) RenderRoute(renderConfig RenderConfig) []byte {
// routeID is the program counter of the caller
pc, _, _, _ := runtime.Caller(1)
routeID := fmt.Sprint(pc)

props, err := propsToString(renderConfig.Props)
if err != nil {
return html.RenderError(err, routeID)
}
task := renderTask{
engine: engine,
logger: zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr}).With().Timestamp().Logger(),
routeID: fmt.Sprint(pc),
routeID: routeID,
props: props,
filePath: filepath.ToSlash(utils.GetFullFilePath(engine.Config.FrontendDir + "/" + renderConfig.File)),
config: renderConfig,
serverRenderResult: make(chan ServerRenderResult),
Expand All @@ -56,33 +52,6 @@ func (engine *Engine) RenderRoute(renderConfig RenderConfig) []byte {
})
}

func (rt *renderTask) Start() (ServerRenderResult, ClientRenderResult, error) {
props, err := propsToString(rt.config.Props)
if err != nil {
return ServerRenderResult{}, ClientRenderResult{}, err
}
rt.props = props

rt.engine.CacheManager.SetParentFile(rt.routeID, rt.filePath)

go rt.serverRender()
go rt.clientRender()

serverRenderResult := <-rt.serverRenderResult
if serverRenderResult.err != nil {
rt.logger.Error().Err(serverRenderResult.err).Msg("Failed to build for server")
return ServerRenderResult{}, ClientRenderResult{}, serverRenderResult.err
}
clientBuildResult := <-rt.clientRenderResult
if clientBuildResult.err != nil {
rt.logger.Error().Err(clientBuildResult.err).Msg("Failed to build for client")
return ServerRenderResult{}, ClientRenderResult{}, clientBuildResult.err
}

go rt.engine.CacheManager.SetParentFileDependencies(rt.filePath, clientBuildResult.dependencies)
return serverRenderResult, clientBuildResult, nil
}

// Convert props to JSON string, or set to null if no props are passed
func propsToString(props interface{}) (string, error) {
if props != nil {
Expand All @@ -91,99 +60,3 @@ func propsToString(props interface{}) (string, error) {
}
return "null", nil
}

type ServerRenderResult struct {
html string
css string
err error
}

func (rt *renderTask) serverRender() {
serverBuild, ok := rt.engine.CacheManager.GetServerBuild(rt.filePath)
if !ok {
build, err := rt.buildReactServerFile()
if err != nil {
rt.serverRenderResult <- ServerRenderResult{err: err}
return
}
serverBuild = build
rt.engine.CacheManager.SetServerBuild(rt.filePath, build)
}
js := injectProps(serverBuild.JS, rt.props)
serverRenderJSFilePath, err := rt.saveServerRenderFile(js)
if err != nil {
rt.serverRenderResult <- ServerRenderResult{err: err}
return
}
renderedHTML, err := reactbuilder.RenderReactToHTML(serverRenderJSFilePath)
rt.serverRenderResult <- ServerRenderResult{html: renderedHTML, css: serverBuild.CSS, err: err}
}

func (rt *renderTask) buildReactServerFile() (reactbuilder.BuildResult, error) {
var imports []string
if rt.engine.CachedLayoutCSSFilePath != "" {
imports = append(imports, fmt.Sprintf(`import "%s";`, rt.engine.CachedLayoutCSSFilePath))
}
if rt.engine.Config.LayoutFilePath != "" {
imports = append(imports, fmt.Sprintf(`import Layout from "%s";`, rt.engine.Config.LayoutFilePath))
}
contents, err := reactbuilder.GenerateServerBuildContents(imports, rt.filePath, rt.engine.Config.LayoutFilePath != "")
if err != nil {
return reactbuilder.BuildResult{}, err
}
return reactbuilder.BuildServer(contents, rt.engine.Config.FrontendDir, rt.engine.Config.AssetRoute)
}

func (rt *renderTask) saveServerRenderFile(js string) (string, error) {
cacheDir, err := utils.GetServerBuildCacheDir(rt.routeID)
if err != nil {
return "", err
}
jsFilePath := fmt.Sprintf("%s/render.js", cacheDir)
// Write file if not exists
if err = os.WriteFile(jsFilePath, []byte(js), 0644); err != nil {
return "", err
}
return jsFilePath, nil
}

type ClientRenderResult struct {
js string
dependencies []string
err error
}

func (rt *renderTask) clientRender() {
clientBuild, ok := rt.engine.CacheManager.GetClientBuild(rt.filePath)
if !ok {
build, err := rt.buildReactClientFile()
if err != nil {
rt.clientRenderResult <- ClientRenderResult{err: err}
return
}
clientBuild = build
rt.engine.CacheManager.SetClientBuild(rt.filePath, clientBuild)
}
js := injectProps(clientBuild.JS, rt.props)
rt.clientRenderResult <- ClientRenderResult{js: js, dependencies: clientBuild.Dependencies}
}

func (rt *renderTask) buildReactClientFile() (reactbuilder.BuildResult, error) {
var imports []string
if rt.engine.CachedLayoutCSSFilePath != "" {
imports = append(imports, fmt.Sprintf(`import "%s";`, rt.engine.CachedLayoutCSSFilePath))
}
if rt.engine.Config.LayoutFilePath != "" {
imports = append(imports, fmt.Sprintf(`import Layout from "%s";`, rt.engine.Config.LayoutFilePath))
}
contents, err := reactbuilder.GenerateClientBuildContents(imports, rt.filePath, rt.engine.Config.LayoutFilePath != "")
if err != nil {
return reactbuilder.BuildResult{}, err
}
return reactbuilder.BuildClient(contents, rt.engine.Config.FrontendDir, rt.engine.Config.AssetRoute)
}

// injectProps injects the props into the already compiled JS
func injectProps(compiledJS, props string) string {
return fmt.Sprintf(`var props = %s; %s`, props, compiledJS)
}
176 changes: 176 additions & 0 deletions rendertask.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package go_ssr

import (
"errors"
"fmt"
"github.com/natewong1313/go-react-ssr/internal/reactbuilder"
"github.com/natewong1313/go-react-ssr/internal/utils"
"github.com/rs/zerolog"
"os"
"os/exec"
"strings"
)

type renderTask struct {
engine *Engine
logger zerolog.Logger
routeID string
filePath string
props string
config RenderConfig
serverRenderResult chan ServerRenderResult
clientRenderResult chan ClientRenderResult
}

type ServerRenderResult struct {
html string
css string
err error
}

type ClientRenderResult struct {
js string
dependencies []string
err error
}

// Start starts the render task
func (rt *renderTask) Start() (ServerRenderResult, ClientRenderResult, error) {
// Assigns the parent file to the routeID so that the cache can be invalidated when the parent file changes
rt.engine.CacheManager.SetParentFile(rt.routeID, rt.filePath)

// Render for server and client concurrently
go rt.doRender("server")
go rt.doRender("client")

// Wait for both to finish
serverRenderResult := <-rt.serverRenderResult
if serverRenderResult.err != nil {
rt.logger.Error().Err(serverRenderResult.err).Msg("Failed to build for server")
return ServerRenderResult{}, ClientRenderResult{}, serverRenderResult.err
}
clientBuildResult := <-rt.clientRenderResult
if clientBuildResult.err != nil {
rt.logger.Error().Err(clientBuildResult.err).Msg("Failed to build for client")
return ServerRenderResult{}, ClientRenderResult{}, clientBuildResult.err
}

// Set the parent file dependencies so that the cache can be invalidated a dependency changes
go rt.engine.CacheManager.SetParentFileDependencies(rt.filePath, clientBuildResult.dependencies)
return serverRenderResult, clientBuildResult, nil
}

func (rt *renderTask) doRender(buildType string) {
// Check if the build is in the cache
build, buildFound := rt.getBuildFromCache(buildType)
if !buildFound {
// Build the file if it's not in the cache
newBuild, err := rt.buildFile(buildType)
if err != nil {
rt.handleBuildError(err, buildType)
return
}
rt.updateBuildCache(newBuild, buildType)
build = newBuild
}
// JS is built without props so that the props can be injected into cached JS builds
js := injectProps(build.JS, rt.props)
if buildType == "server" {
// Save the server js to a file to be executed by node
jsFilePath, err := rt.saveServerRenderFile(js)
if err != nil {
rt.handleBuildError(err, buildType)
return
}
// Then call that file with node to get the rendered HTML
renderedHTML, err := renderReactToHTML(jsFilePath)
rt.serverRenderResult <- ServerRenderResult{html: renderedHTML, css: build.CSS, err: err}
} else {
rt.clientRenderResult <- ClientRenderResult{js: js, dependencies: build.Dependencies}
}
}

// getBuild returns the build from the cache if it exists
func (rt *renderTask) getBuildFromCache(buildType string) (reactbuilder.BuildResult, bool) {
if buildType == "server" {
return rt.engine.CacheManager.GetServerBuild(rt.filePath)
} else {
return rt.engine.CacheManager.GetClientBuild(rt.filePath)
}
}

// buildFile gets the contents of the file to be built and builds it with reactbuilder
func (rt *renderTask) buildFile(buildType string) (reactbuilder.BuildResult, error) {
buildContents, err := rt.getBuildContents(buildType)
if err != nil {
return reactbuilder.BuildResult{}, err
}
if buildType == "server" {
return reactbuilder.BuildServer(buildContents, rt.engine.Config.FrontendDir, rt.engine.Config.AssetRoute)
} else {
return reactbuilder.BuildClient(buildContents, rt.engine.Config.FrontendDir, rt.engine.Config.AssetRoute)
}
}

// getBuildContents gets the required imports based on the config and returns the contents to be built with reactbuilder
func (rt *renderTask) getBuildContents(buildType string) (string, error) {
var imports []string
if rt.engine.CachedLayoutCSSFilePath != "" {
imports = append(imports, fmt.Sprintf(`import "%s";`, rt.engine.CachedLayoutCSSFilePath))
}
if rt.engine.Config.LayoutFilePath != "" {
imports = append(imports, fmt.Sprintf(`import Layout from "%s";`, rt.engine.Config.LayoutFilePath))
}
if buildType == "server" {
return reactbuilder.GenerateServerBuildContents(imports, rt.filePath, rt.engine.Config.LayoutFilePath != "")
} else {
return reactbuilder.GenerateClientBuildContents(imports, rt.filePath, rt.engine.Config.LayoutFilePath != "")
}
}

// handleBuildError handles the error from building the file and sends it to the appropriate channel
func (rt *renderTask) handleBuildError(err error, buildType string) {
if buildType == "server" {
rt.serverRenderResult <- ServerRenderResult{err: err}
} else {
rt.clientRenderResult <- ClientRenderResult{err: err}
}
}

// updateBuildCache updates the cache with the new build
func (rt *renderTask) updateBuildCache(build reactbuilder.BuildResult, buildType string) {
if buildType == "server" {
rt.engine.CacheManager.SetServerBuild(rt.filePath, build)
} else {
rt.engine.CacheManager.SetClientBuild(rt.filePath, build)
}
}

// injectProps injects the props into the already compiled JS
func injectProps(compiledJS, props string) string {
return fmt.Sprintf(`var props = %s; %s`, props, compiledJS)
}

// saveServerRenderFile saves the generated server js to a file to be executed by node
func (rt *renderTask) saveServerRenderFile(js string) (string, error) {
cacheDir, err := utils.GetServerBuildCacheDir(rt.routeID)
if err != nil {
return "", err
}
jsFilePath := fmt.Sprintf("%s/render.js", cacheDir)
return jsFilePath, os.WriteFile(jsFilePath, []byte(js), 0644)
}

// renderReactToHTML uses node to execute the server js file which outputs the rendered HTML
func renderReactToHTML(jsFilePath string) (string, error) {
cmd := exec.Command("node", jsFilePath)
stdOut := new(strings.Builder)
stdErr := new(strings.Builder)
cmd.Stdout = stdOut
cmd.Stderr = stdErr
err := cmd.Run()
if err != nil {
return "", errors.New(stdErr.String())
}
return stdOut.String(), nil
}

0 comments on commit 8b31d4f

Please sign in to comment.