dana-medical-site/internal/api/ssr.go
2026-10-14 13:33:13 +00:00

401 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package api
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"dana/internal/domain"
"dana/internal/storage"
)
type renderCacheEntry struct {
hash [32]byte
html []byte
}
type pageRenderer struct {
script, node, bundle string
slots chan struct{}
mu sync.Mutex
cache map[string]renderCacheEntry
cacheBytes int
}
// WithSSR enables current-data HTML rendering without a new frontend framework.
// Setting SSR_RENDERER=off explicitly selects the build-time snapshot mode.
func (a *API) WithSSR(script, node string) error {
if script == "" || script == "off" || a.website == nil {
return nil
}
script, err := filepath.Abs(script)
if err != nil {
return err
}
if _, err := os.Stat(script); err != nil {
return fmt.Errorf("SSR renderer: %w", err)
}
bundle := filepath.Join(filepath.Dir(filepath.Dir(script)), ".ssr", "entry-server.js")
if _, err := os.Stat(bundle); err != nil {
return fmt.Errorf("SSR bundle is missing; run npm run build: %w", err)
}
binary, err := exec.LookPath(node)
if err != nil {
return fmt.Errorf("Node.js is required for live SSR (or set SSR_RENDERER=off): %w", err)
}
a.website.renderer = &pageRenderer{script: script, node: binary, bundle: bundle, slots: make(chan struct{}, 2), cache: map[string]renderCacheEntry{}}
slog.Info("current-data HTML rendering enabled")
return nil
}
type publicRenderRequest struct {
URL string `json:"url"`
SiteURL string `json:"siteURL"`
Template string `json:"template"`
Common map[string]any `json:"common"`
Initial map[string]any `json:"initial"`
// Source code timestamps are also part of the cache fingerprint.
RendererVersion int64 `json:"rendererVersion"`
BundleVersion int64 `json:"bundleVersion"`
}
func doctorWebPath(d domain.Doctor) string {
section := "vrachi"
if len(d.Specialties) > 0 {
section = d.Specialties[0].Slug
}
return "/company/staff/" + section + "/" + d.Slug + "/"
}
func (a *API) prepareSSR(r *http.Request, path string, svc *domain.Service) (*publicRenderRequest, error) {
ctx := r.Context()
branches, err := a.store.Branches(ctx)
if err != nil {
return nil, err
}
settings, err := a.store.Settings(ctx)
if err != nil {
return nil, err
}
specialties, err := a.store.Specialties(ctx)
if err != nil {
return nil, err
}
doctors, err := a.store.Doctors(ctx, storage.DoctorFilter{})
if err != nil {
return nil, err
}
template, err := os.ReadFile(filepath.Join(a.website.dir, "index.html"))
if err != nil {
return nil, err
}
input := &publicRenderRequest{URL: path, SiteURL: a.website.siteURL, Template: string(template),
Common: map[string]any{"branches": emptyIfNil(branches), "settings": settings}, Initial: map[string]any{}}
if r.URL.RawQuery != "" {
input.URL += "?" + r.URL.RawQuery
}
set := func(key string, data any) { input.Initial[key] = envelope{Data: emptyIfNil(data)} }
set("specialties", specialties)
set("doctors", doctors)
parts := strings.Split(strings.Trim(path, "/"), "/")
switch {
case svc != nil:
set("service:"+svc.Path, svc)
prices, err := a.store.PriceList(ctx, "")
if err != nil {
return nil, err
}
set("prices", prices)
if i := strings.LastIndex(svc.Path, "/"); i > 0 {
parent, err := a.store.ServiceByPath(ctx, svc.Path[:i])
if err != nil {
return nil, err
}
set("service:"+parent.Path, parent)
}
case path == "/" || path == "/services/":
tree, err := a.store.ServiceTree(ctx)
if err != nil {
return nil, err
}
set("services", tree)
if path == "/" {
banners, err := a.store.Banners(ctx, "home_hero")
if err != nil {
return nil, err
}
set("home:banners", banners)
}
case parts[0] == "company" && len(parts) >= 2 && parts[1] == "staff":
if len(parts) == 4 {
doctor, err := a.store.DoctorBySlug(ctx, parts[3])
if err != nil {
return nil, err
}
set("doctor:"+doctor.Slug, doctor)
reviews, err := a.store.Reviews(ctx, doctor.Slug, 50)
if err != nil {
return nil, err
}
set("reviews:"+doctor.Slug, reviews)
} else {
section := ""
if len(parts) == 3 {
section = parts[2]
}
q := r.URL.Query().Get("q")
list, err := a.store.Doctors(ctx, storage.DoctorFilter{Specialty: section, Query: q})
if err != nil {
return nil, err
}
set("doctor-list:"+section+":"+q, list)
}
case parts[0] == "price":
services, err := a.store.ServiceTree(ctx)
if err != nil {
return nil, err
}
set("services", services)
tree, err := a.store.PriceList(ctx, "")
if err != nil {
return nil, err
}
set("prices", tree)
case postKinds[parts[0]] != "":
section := parts[0]
if len(parts) == 2 {
post, err := a.store.PostBySlug(ctx, postKinds[section], parts[1])
if err != nil {
return nil, err
}
set(section+":"+post.Slug, post)
} else {
if err := a.setSectionPage(ctx, set, section); err != nil {
return nil, err
}
posts, err := a.store.Posts(ctx, postKinds[section], 100)
if err != nil {
return nil, err
}
set(section, posts)
}
case path == "/company/" || path == "/company/partners/" || path == "/company/nadzor/" ||
path == "/company/vacancy/" || path == "/info/faq/" || path == "/reviews/" || path == "/company/licenses/":
if err := a.prepareCompanySSR(ctx, path, set); err != nil {
return nil, err
}
case path == "/patients/":
if err := a.setSectionPage(ctx, set, "patients"); err != nil {
return nil, err
}
case parts[0] == "legaldocs":
slug := "legaldocs"
if len(parts) == 2 {
slug = parts[1]
}
page, err := a.store.PageBySlug(ctx, slug)
if err != nil {
return nil, err
}
set("page:"+slug, page)
case parts[0] == "checkup" || path == "/sales/":
if parts[0] == "checkup" && len(parts) == 2 {
checkup, err := a.store.CheckupBySlug(ctx, parts[1])
if err != nil {
return nil, err
}
set("checkup:"+checkup.Slug, checkup)
} else {
list, err := a.store.Checkups(ctx, "")
if err != nil {
return nil, err
}
set("checkups", list)
if path == "/sales/" {
promos, err := a.store.Posts(ctx, "promo", 20)
if err != nil {
return nil, err
}
set("promos", promos)
}
}
}
if stat, err := os.Stat(a.website.renderer.script); err == nil {
input.RendererVersion = stat.ModTime().UnixNano()
}
if stat, err := os.Stat(a.website.renderer.bundle); err == nil {
input.BundleVersion = stat.ModTime().UnixNano()
}
return input, nil
}
// setSectionPage кладёт в данные страницы заголовок, SEO и вводный текст раздела из pages.
func (a *API) setSectionPage(ctx context.Context, set func(string, any), slug string) error {
page, err := a.store.PageBySlug(ctx, slug)
if errors.Is(err, storage.ErrNotFound) {
return nil // раздел работает и без записи в pages — со своим заголовком
}
if err != nil {
return err
}
set("page:"+slug, page)
return nil
}
func (a *API) prepareCompanySSR(ctx context.Context, path string, set func(string, any)) error {
var (
slug string
key string
data any
err error
)
switch path {
case "/company/":
slug, key = "about", "services"
data, err = a.store.ServiceTree(ctx)
case "/company/partners/":
slug, key = "partners", "partners"
data, err = a.store.Partners(ctx)
case "/company/licenses/":
slug, key = "licenses", "documents:"
data, err = a.store.Documents(ctx, "")
case "/company/nadzor/":
slug, key = "nadzor", "documents:supervisor"
data, err = a.store.Documents(ctx, "supervisor")
case "/company/vacancy/":
slug, key = "vacancy", "vacancies"
data, err = a.store.Vacancies(ctx)
case "/info/faq/":
slug, key = "faq", "faq"
data, err = a.store.FAQ(ctx, "")
case "/reviews/":
slug, key = "reviews", "reviews:all"
data, err = a.store.Reviews(ctx, "", 100)
}
if err != nil {
return err
}
set(key, data)
return a.setSectionPage(ctx, set, slug)
}
type cappedBuffer struct {
bytes.Buffer
limit int
overflow bool
}
func (b *cappedBuffer) Write(p []byte) (int, error) {
n := len(p)
remaining := b.limit - b.Len()
if len(p) > remaining {
b.overflow = true
p = p[:remaining]
}
_, _ = b.Buffer.Write(p)
return n, nil
}
func (p *pageRenderer) render(ctx context.Context, key string, request []byte) ([]byte, bool, error) {
hash := sha256.Sum256(request)
p.mu.Lock()
cached, exists := p.cache[key]
p.mu.Unlock()
if exists && cached.hash == hash {
return cached.html, true, nil
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
select {
case p.slots <- struct{}{}:
defer func() { <-p.slots }()
case <-ctx.Done():
return nil, false, ctx.Err()
}
// Another request for the same version might have completed while waiting.
p.mu.Lock()
cached, exists = p.cache[key]
p.mu.Unlock()
if exists && cached.hash == hash {
return cached.html, true, nil
}
command := exec.CommandContext(ctx, p.node, p.script)
command.Dir = filepath.Dir(p.script)
command.Stdin = bytes.NewReader(request)
out := &cappedBuffer{limit: 4 << 20}
stderr := &cappedBuffer{limit: 8 << 10}
command.Stdout, command.Stderr = out, stderr
if err := command.Run(); err != nil {
return nil, false, fmt.Errorf("HTML renderer: %w: %s", err, stderr.String())
}
if out.overflow || !bytes.Contains(out.Bytes(), []byte("<div id=\"root\">")) || !bytes.Contains(out.Bytes(), []byte("page-data")) {
return nil, false, errors.New("HTML renderer returned incomplete or oversized output")
}
html := out.Bytes()
// Query variants are rendered correctly but never displace canonical cache entries.
if !strings.Contains(key, "?") && len(html) <= 1<<20 {
p.mu.Lock()
if previous, ok := p.cache[key]; ok {
p.cacheBytes -= len(previous.html)
delete(p.cache, key)
}
for len(p.cache) >= 64 || p.cacheBytes+len(html) > 16<<20 {
for oldKey, old := range p.cache {
p.cacheBytes -= len(old.html)
delete(p.cache, oldKey)
break
}
}
p.cache[key] = renderCacheEntry{hash: hash, html: html}
p.cacheBytes += len(html)
p.mu.Unlock()
}
return html, false, nil
}
func (a *API) serveSSR(w http.ResponseWriter, r *http.Request, path string, svc *domain.Service) {
input, err := a.prepareSSR(r, path, svc)
if errors.Is(err, storage.ErrNotFound) {
a.website404(w, r)
return
}
if err != nil {
handleErr(w, err)
return
}
data, err := json.Marshal(input)
if err != nil {
handleErr(w, err)
return
}
html, hit, err := a.website.renderer.render(r.Context(), input.URL, data)
if err != nil {
slog.Error("render website HTML", "path", path, "err", err)
w.Header().Set("X-Robots-Tag", "noindex")
w.Header().Set("Retry-After", "5")
http.Error(w, "HTML страницы временно недоступен", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
if r.URL.RawQuery != "" {
w.Header().Set("X-Robots-Tag", "noindex, follow")
}
if hit {
w.Header().Set("X-HTML-Cache", "hit")
} else {
w.Header().Set("X-HTML-Cache", "miss")
}
_, _ = w.Write(html)
}