svfe-pages prerenders svfe's repo and user pages into static html for a yumak (knot-static) generation, so pages work without js and hydrate into the svfe spa.

  • incremental: runs as a knot-static [[hooks]] entry after each activation, reads <generation>/hook.json and xrpc-diff.jsonl, and re-renders only the pages whose /xrpc/... reads changed.
  • deduplicated: stores a repo's sidebar once per content at <repo>/_shared/<hash>.json so sidebar edits don't re-render every page, and writes open issue/pull counts to <repo>/_latest/counts.json so new tickets rewrite one file instead of the repo.
  • bounded: caps each repo by page count, commit count, and disk bytes (budget.mjs). routes past the budget fall back to the svfe spa (<repo>/_latest/routes.json tells the client which routes have prerendered documents).
  • zero-downtime across deploys: copies each rendering svfe build's _app/immutable/ chunks into <out>/www and keeps older builds' chunks until no served page references them.

git.klbr.net runs svfe-pages and hosts this repo (did:plc:qaxvrxxd6ubibxniuzaktvon).

usage #

svfe-pages <hook.json> --server <svfe>/build-server [--client <svfe>/build] --out <dir> [--read <xrpc url>]
--server <dir>            svfe's build-server/ directory
--client <dir>            svfe's build/ directory (defaults to ../build next to --server)
--out <dir>               output directory (serves from <out>/www)
--read <url>              local knot url to fetch /xrpc/... from (e.g. http://127.0.0.1:8080)
--workers <n>             parallel repo worker processes (default: half of cpu cores)
--jobs <n>                pages in flight per worker (default: 6)
--max-pages <n>           max prerendered pages per repo (default: 1000)
--max-commits <n>         max prerendered commit pages per repo (default: 60)
--max-repo-bytes <bytes>  max gzipped bytes on disk per repo (default: 20 MiB)
--max-total-bytes <bytes> max gzipped bytes across all repos (default: 2 GiB)
--max-blob-bytes <bytes>  max blob size to prerender (default: 128 KiB)
--full                    ignore cached state and re-render every page

nixos example #

see nginx-site.conf for the standalone nginx locations, or wire svfe-pages and svfe into services.knot-static on nixos:

let
  system = pkgs.stdenv.hostPlatform.system;
  svfe = inputs.svfe.packages.${system}.svfe-web;
  svfe-pages = inputs.svfe-pages.packages.${system}.svfe-pages;
  knot = config.services.knot-static;
  inner = "http://${knot.listen}";
  pages = "/var/lib/svfe-pages";
in
{
  services.knot-static = {
    enable = true;
    hostname = "git.klbr.net";
    settings.index.links = "https://git.klbr.net";

    settings.hooks = [
      {
        name = "svfe-pages";
        command = "${pkgs.coreutils}/bin/nice";
        args = [
          "-n" "19"
          (lib.getExe svfe-pages)
          "--server" "${svfe}/build-server"
          "--out" pages
          "--read" inner
          "--workers" "4"
          "--max-repo-bytes" (toString (64 * 1024 * 1024))
          "--max-total-bytes" (toString (8 * 1024 * 1024 * 1024))
        ];
      }
    ];

    nginx.virtualHost.locations = {
      "/" = {
        proxyPass = null;
        root = "${pages}/www";
        extraConfig = ''
          gzip_static always;
          gunzip on;
          if ($arg_page ~ "^0*([2-9]|[1-9][0-9]+)$") {
            set $page $1;
            rewrite ^(.*)$ "$1~page-$page?" last;
          }
          add_header Cache-Control "no-cache";
          try_files $uri.html $uri/index.html @knot;
        '';
      };
      "@knot" = {
        proxyPass = inner;
        extraConfig = ''
          proxy_intercept_errors on;
          error_page 404 = @spa;
        '';
      };
      "@spa" = {
        root = "${svfe}/build";
        extraConfig = ''
          gzip_static on;
          try_files $uri /index.html =404;
        '';
      };
      "^~ /_app/immutable/" = {
        root = "${pages}/www";
        extraConfig = ''
          gzip_static on;
          expires max;
          try_files $uri @immutable;
        '';
      };
      "@immutable" = {
        root = "${svfe}/build";
        extraConfig = ''
          gzip_static on;
          expires max;
        '';
      };
      "~ ^/[^/]+(/[^/]+)?/_latest/" = {
        root = "${pages}/www";
        extraConfig = ''
          gzip_static always;
          gunzip on;
          default_type application/json;
          add_header Cache-Control "no-cache";
          try_files $uri =404;
        '';
      };
      "~ ^/[^/]+/[^/]+/_shared/" = {
        root = "${pages}/www";
        extraConfig = ''
          gzip_static always;
          gunzip on;
          default_type application/json;
          expires max;
          try_files $uri =404;
        '';
      };
    };
  };

  systemd.tmpfiles.rules = [ "d ${pages} 0750 ${knot.user} ${config.services.nginx.group} -" ];
}

tests #

node --test
node verify.mjs <incremental out> <fresh out>   # check an incremental render against a clean one