Skip to content

Commit

Permalink
experimental: support for Server Side Rendering (getfider#921)
Browse files Browse the repository at this point in the history
  • Loading branch information
goenning authored Mar 21, 2021
1 parent 7c75f0b commit 95adeea
Show file tree
Hide file tree
Showing 37 changed files with 540 additions and 181 deletions.
7 changes: 4 additions & 3 deletions .github/workflows/fider.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
name: test-ui
runs-on: ubuntu-latest
container:
image: getfider/githubci:0.0.4
image: getfider/githubci:0.0.5

steps:
- name: checkout code
Expand All @@ -26,7 +26,7 @@ jobs:
name: test-server
runs-on: ubuntu-latest
container:
image: getfider/githubci:0.0.4
image: getfider/githubci:0.0.5

services:
minio:
Expand All @@ -49,6 +49,7 @@ jobs:
steps:
- name: checkout code
uses: actions/checkout@v1
- run: npm ci # required for esbuild
- run: mage lint:server
- name: mage test:server
run: |
Expand All @@ -63,7 +64,7 @@ jobs:
runs-on: ubuntu-latest
needs: [test-server, test-ui]
container:
image: getfider/githubci:0.0.4
image: getfider/githubci:0.0.5

steps:
- name: checkout code
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ logs/

fider
fider.exe
ssr.js
.env
npm-debug.log

Expand Down
13 changes: 7 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
# Build Step
FROM getfider/githubci:0.0.4 AS builder
FROM getfider/githubci:0.0.5 AS builder

RUN mkdir /app
WORKDIR /app

COPY . .
RUN npm ci
RUN node -v
RUN npm -v
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 mage build
RUN GOOS=linux GOARCH=amd64 mage build

# Runtime Step
FROM alpine:3.10
RUN apk update && apk add ca-certificates
FROM debian:buster-slim

RUN apt-get update
RUN apt-get install -y ca-certificates

RUN mkdir /app
WORKDIR /app
Expand All @@ -23,6 +23,7 @@ COPY --from=builder /app/views /app/views
COPY --from=builder /app/dist /app/dist
COPY --from=builder /app/LICENSE /app
COPY --from=builder /app/robots.txt /app
COPY --from=builder /app/ssr.js /app
COPY --from=builder /app/fider /app

EXPOSE 3000
Expand Down
1 change: 1 addition & 0 deletions app/handlers/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Disallow: /admin/
Disallow: /oauth/
Disallow: /terms
Disallow: /privacy
Disallow: /-/ui
Sitemap: https://demo.test.fider.io/sitemap.xml`)
}

Expand Down
3 changes: 2 additions & 1 deletion app/pkg/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ type config struct {
Rendergun struct {
URL string `env:"RENDERGUN_URL"`
}
Database struct {
Experimental_SSR_SEO bool `env:"EXPERIMENTAL_SSR_SEO,default=false"`
Database struct {
URL string `env:"DATABASE_URL,required"`
MaxIdleConns int `env:"DATABASE_MAX_IDLE_CONNS,default=2,strict"`
MaxOpenConns int `env:"DATABASE_MAX_OPEN_CONNS,default=4,strict"`
Expand Down
55 changes: 55 additions & 0 deletions app/pkg/web/react.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package web

import (
"encoding/json"
"fmt"
"net/url"
"os"

"github.com/getfider/fider/app/pkg/env"
"github.com/getfider/fider/app/pkg/errors"
"rogchap.com/v8go"
)

type ReactRenderer struct {
scriptPath string
scriptContent []byte
ctx *v8go.Context
}

func NewReactRenderer(scriptPath string) *ReactRenderer {
bytes, _ := os.ReadFile(env.Path(scriptPath))
isolate, err := v8go.NewIsolate()
if err != nil {
return &ReactRenderer{scriptPath: scriptPath}
}

v8ctx, _ := v8go.NewContext(isolate)
_, err = v8ctx.RunScript(string(bytes), scriptPath)
if err != nil {
return &ReactRenderer{scriptPath: scriptPath}
}

return &ReactRenderer{ctx: v8ctx, scriptPath: scriptPath, scriptContent: bytes}
}

func (r *ReactRenderer) Render(u *url.URL, props Map) (string, error) {
if len(r.scriptContent) == 0 {
return "", nil
}

jsonArg, err := json.Marshal(props)
if err != nil {
return "", errors.Wrap(err, "failed to marshal props")
}

val, err := r.ctx.RunScript(`ssrRender("`+u.String()+`", "`+u.Path+`", `+string(jsonArg)+`)`, r.scriptPath)
if err != nil {
if jsErr, ok := err.(*v8go.JSError); ok {
err = fmt.Errorf("%v", jsErr.StackTrace)
}
return "", errors.Wrap(err, "failed to execute ssrRender")
}

return val.String(), nil
}
39 changes: 39 additions & 0 deletions app/pkg/web/react_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package web_test

import (
"net/url"
"testing"

"github.com/getfider/fider/app/models"
. "github.com/getfider/fider/app/pkg/assert"
"github.com/getfider/fider/app/pkg/web"
)

func TestReactRenderer_FileNotFound(t *testing.T) {
RegisterT(t)

r := web.NewReactRenderer("unknown.js")
u, _ := url.Parse("https://github.com")
html, err := r.Render(u, web.Map{})
Expect(html).Equals("")
Expect(err).IsNil()
}

func TestReactRenderer_RenderEmptyHomeHTML(t *testing.T) {
RegisterT(t)

r := web.NewReactRenderer("ssr.js")
u, _ := url.Parse("https://demo.test.fider.io")
html, err := r.Render(u, web.Map{
"tenant": &models.Tenant{},
"settings": web.Map{},
"props": web.Map{
"posts": make([]web.Map, 0),
"tags": make([]web.Map, 0),
"countPerStatus": web.Map{},
},
})
Expect(html).Equals(`<div id="c-header"><div class="c-env-info">Env: | Compiler: | Version: | BuildTime: N/A |TenantID: 0 | </div><div class="c-menu"><div class="container"><a href="/" class="c-menu-item-title"><span></span></a><div class="c-menu-item-signin"><span>Sign in</span></div></div></div></div><div id="p-home" class="page container"><div class="row"><div class="l-welcome-col col-md-4"><div class="markdown-body welcome-message"><p>We&#39;d love to hear what you&#39;re thinking about. </p>
<p>What can we do better? This is the place for you to vote, discuss and share ideas.</p></div><form autoComplete="off" class="c-form"><div class="c-form-field"><div class="c-form-field-wrapper"><input type="text" id="input-title" tabindex="-1" maxLength="100" value="" placeholder="Enter your suggestion here..."/></div></div></form></div><div class="l-posts-col col-md-8"><div class="l-lonely center"><p><svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 352 512" class="icon" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M176 80c-52.94 0-96 43.06-96 96 0 8.84 7.16 16 16 16s16-7.16 16-16c0-35.3 28.72-64 64-64 8.84 0 16-7.16 16-16s-7.16-16-16-16zM96.06 459.17c0 3.15.93 6.22 2.68 8.84l24.51 36.84c2.97 4.46 7.97 7.14 13.32 7.14h78.85c5.36 0 10.36-2.68 13.32-7.14l24.51-36.84c1.74-2.62 2.67-5.7 2.68-8.84l.05-43.18H96.02l.04 43.18zM176 0C73.72 0 0 82.97 0 176c0 44.37 16.45 84.85 43.56 115.78 16.64 18.99 42.74 58.8 52.42 92.16v.06h48v-.12c-.01-4.77-.72-9.51-2.15-14.07-5.59-17.81-22.82-64.77-62.17-109.67-20.54-23.43-31.52-53.15-31.61-84.14-.2-73.64 59.67-128 127.95-128 70.58 0 128 57.42 128 128 0 30.97-11.24 60.85-31.65 84.14-39.11 44.61-56.42 91.47-62.1 109.46a47.507 47.507 0 0 0-2.22 14.3v.1h48v-.05c9.68-33.37 35.78-73.18 52.42-92.16C335.55 260.85 352 220.37 352 176 352 78.8 273.2 0 176 0z"></path></svg></p><p>It&#x27;s lonely out here. Start by sharing a suggestion!</p></div></div></div></div><div id="c-footer"><div class="container"><a class="l-powered" target="_blank" href="https://getfider.com/"><img src="https://getfider.com/images/logo-100x100.png" alt="Fider"/><span>Powered by Fider</span></a></div></div>`)
Expect(err).IsNil()
}
40 changes: 30 additions & 10 deletions app/pkg/web/renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"github.com/getfider/fider/app/models/query"
"github.com/getfider/fider/app/pkg/bus"
"github.com/getfider/fider/app/pkg/log"

"io/ioutil"

Expand Down Expand Up @@ -60,14 +61,16 @@ type Renderer struct {
assets *clientAssets
chunkedAssets map[string]*clientAssets
mutex sync.RWMutex
reactRenderer *ReactRenderer
}

// NewRenderer creates a new Renderer
func NewRenderer(settings *models.SystemSettings) *Renderer {
return &Renderer{
templates: make(map[string]*template.Template),
settings: settings,
mutex: sync.RWMutex{},
templates: make(map[string]*template.Template),
settings: settings,
mutex: sync.RWMutex{},
reactRenderer: NewReactRenderer("ssr.js"),
}
}

Expand Down Expand Up @@ -149,7 +152,7 @@ func getClientAssets(assets []distAsset) *clientAssets {
}

//Render a template based on parameters
func (r *Renderer) Render(w io.Writer, statusCode int, name string, props Props, ctx *Context) {
func (r *Renderer) Render(w io.Writer, statusCode int, templateName string, props Props, ctx *Context) {
var err error

if r.assets == nil || env.IsDevelopment() {
Expand All @@ -158,13 +161,11 @@ func (r *Renderer) Render(w io.Writer, statusCode int, name string, props Props,
}
}

tmpl, ok := r.templates[name]
if !ok || env.IsDevelopment() {
tmpl = r.add(name)
}

public := make(Map)
private := make(Map)
if props.Data == nil {
props.Data = make(Map)
}

tenant := ctx.Tenant()
tenantName := "Fider"
Expand Down Expand Up @@ -246,11 +247,30 @@ func (r *Renderer) Render(w io.Writer, statusCode int, name string, props Props,
}
}

// Only index.html template uses React, other templates are already SSR
if env.Config.Experimental_SSR_SEO && ctx.Request.IsCrawler() && templateName == "index.html" {
html, err := r.reactRenderer.Render(ctx.Request.URL, public)
if err != nil {
log.Errorf(ctx, "Failed to render react page: @{Error}", dto.Props{
"Error": err.Error(),
})
}
if html != "" {
templateName = "ssr.html"
props.Data["html"] = template.HTML(html)
}
}

tmpl, ok := r.templates[templateName]
if !ok || env.IsDevelopment() {
tmpl = r.add(templateName)
}

err = tmpl.Execute(w, Map{
"public": public,
"private": private,
})
if err != nil {
panic(errors.Wrap(err, "failed to execute template %s", name))
panic(errors.Wrap(err, "failed to execute template %s", templateName))
}
}
40 changes: 32 additions & 8 deletions app/pkg/web/renderer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ func TestRenderer_WithCanonicalURL(t *testing.T) {
compareRendererResponse(buf, "/app/pkg/web/testdata/canonical.html", ctx)
}

func TestRenderer_Props(t *testing.T) {
func TestRenderer_Home(t *testing.T) {
RegisterT(t)

bus.AddHandler(func(ctx context.Context, q *query.ListActiveOAuthProviders) error {
Expand All @@ -99,16 +99,40 @@ func TestRenderer_Props(t *testing.T) {
Title: "My Page Title",
Description: "My Page Description",
Data: web.Map{
"number": 2,
"array": []string{"1", "2"},
"object": web.Map{
"key1": "value1",
"key2": "value2",
},
"posts": make([]web.Map, 0),
"tags": make([]web.Map, 0),
"countPerStatus": web.Map{},
},
}, ctx)

compareRendererResponse(buf, "/app/pkg/web/testdata/home.html", ctx)
}

func TestRenderer_Home_SSR(t *testing.T) {
RegisterT(t)

env.Config.Experimental_SSR_SEO = true
bus.AddHandler(func(ctx context.Context, q *query.ListActiveOAuthProviders) error {
return nil
})

buf := new(bytes.Buffer)
ctx := newGetContext("https://demo.test.fider.io:3000/", map[string]string{
"User-Agent": "Googlebot",
})
ctx.SetTenant(&models.Tenant{})
renderer := web.NewRenderer(&models.SystemSettings{})
renderer.Render(buf, http.StatusOK, "index.html", web.Props{
Title: "My Page Title",
Description: "My Page Description",
Data: web.Map{
"posts": make([]web.Map, 0),
"tags": make([]web.Map, 0),
"countPerStatus": web.Map{},
},
}, ctx)

compareRendererResponse(buf, "/app/pkg/web/testdata/props.html", ctx)
compareRendererResponse(buf, "/app/pkg/web/testdata/home_ssr.html", ctx)
}

func TestRenderer_AuthenticatedUser(t *testing.T) {
Expand Down
34 changes: 19 additions & 15 deletions app/pkg/web/testdata/basic.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
<link rel="apple-touch-icon" href="https://demo.test.fider.io:3000/favicon?size=180&bg=white" sizes="180x180" type="image/png">



<link rel="stylesheet" href="https://demo.test.fider.io:3000/assets/css/file1.css" />

<link rel="stylesheet" href="https://demo.test.fider.io:3000/assets/css/file1.css" />





<title>Fider</title>
<meta name="description" content="" />
<meta property="og:title" content="Fider" />
Expand All @@ -23,30 +25,32 @@
<meta property="og:image" content="https://getfider.com/images/logo-100x100.png">
</head>
<body>
<noscript class="container">
<div class="c-segment">
<h2>Please enable JavaScript</h2>
<p>This website relies on JavaScript-based technologies and services.</p>
<p>Please enable JavaScript and then reload the page.</p>
</div>
</noscript>

<noscript class="container">
<div class="c-segment">
<h2>Please enable JavaScript</h2>
<p>This website relies on JavaScript-based technologies and services.</p>
<p>Please enable JavaScript and then reload the page.</p>
</div>
</noscript>




<div id="root"></div><div id="root-modal"></div><div id="root-toastify"></div>

<script id="server-data" type="application/json">

{"contextID":"CONTEXT_ID","props":null,"settings":{"baseURL":"https://demo.test.fider.io:3000","buildTime":"","compiler":"","domain":"","environment":"","globalAssetsURL":"https://demo.test.fider.io:3000","googleAnalytics":"","hasLegal":false,"mode":"","oauth":[],"tenantAssetsURL":"https://demo.test.fider.io:3000","version":""},"tenant":null,"title":"Fider"}
{"contextID":"CONTEXT_ID","props":{},"settings":{"baseURL":"https://demo.test.fider.io:3000","buildTime":"","compiler":"","domain":"","environment":"","globalAssetsURL":"https://demo.test.fider.io:3000","googleAnalytics":"","hasLegal":false,"mode":"","oauth":[],"tenantAssetsURL":"https://demo.test.fider.io:3000","version":""},"tenant":null,"title":"Fider"}

</script>



<script src="https://demo.test.fider.io:3000/assets/js/file1.js" crossorigin="anonymous"></script>

<script src="https://demo.test.fider.io:3000/assets/js/file2.js" crossorigin="anonymous"></script>

<script src="https://demo.test.fider.io:3000/assets/js/file1.js" crossorigin="anonymous"></script>
<script src="https://demo.test.fider.io:3000/assets/js/file2.js" crossorigin="anonymous"></script>



Expand Down
Loading

0 comments on commit 95adeea

Please sign in to comment.