svfe is the web frontend for yumak (knot-static), a static tangled knot. it lives in web/ and is built with sveltekit (svelte 5) and tailwind 4.

  • every read is a plain GET /xrpc/... against the knot serving the app, so a static knot serves the whole read path straight from nginx.
  • writes (issues, pull requests, comments, attachments, settings) go to the user's pds, the knot's write cgi, or the repo's spindle.
  • svfe-pages runs after each knot activation to prerender repo and user pages into static html, so pages work without js and hydrate into the spa when js is on.

git.klbr.net runs svfe and hosts this repo (did:plc:4xstu3hkbaavp4kjddqtvqi2).

how svfe and svfe-pages fit together #

building packages.<system>.svfe-web produces two outputs in $out:

  • build/ — the client spa bundle (index.html, /_app/immutable/*, /fonts/*). nginx serves assets from here and falls back to /index.html for any route that wasn't prerendered (such as deep commit history or repos over their disk budget).
  • build-server/ — the sveltekit ssr bundle (build-server/index.js) with its production node_modules. svfe-pages imports this bundle in worker processes to render pages against the knot's local xrpc surface.

you can serve build/ on its own as a pure client spa in front of a knot, or pair it with svfe-pages as a knot-static hook so pages are prerendered on disk.

installation & nixos example #

flake inputs:

inputs.knot-static.url = "git+https://git.klbr.net/did:plc:amxsvho4opuwe53msnkbwa5j?ref=main";
inputs.svfe.url = "git+https://git.klbr.net/did:plc:4xstu3hkbaavp4kjddqtvqi2?ref=main";
inputs.svfe-pages.url = "git+https://git.klbr.net/did:plc:qaxvrxxd6ubibxniuzaktvon?ref=main";

wiring svfe and svfe-pages 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";

    # prerender repo and user pages after every activation
    settings.hooks = [
      {
        name = "svfe-pages";
        command = lib.getExe svfe-pages;
        args = [
          "--server" "${svfe}/build-server"
          "--out" pages
          "--read" inner
          "--workers" "4"
        ];
      }
    ];

    nginx.virtualHost.locations = {
      # serve prerendered html first, then knot routes, then the client spa
      "/" = {
        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;
        '';
      };
      "/fonts/" = {
        root = "${svfe}/build";
        extraConfig = ''
          gzip_static on;
          add_header Cache-Control "public, max-age=604800";
        '';
      };
      "/_app/" = {
        root = "${svfe}/build";
        extraConfig = ''
          gzip_static on;
          add_header Cache-Control "no-cache";
        '';
      };
      "^~ /_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;
        '';
      };
    };
  };
}

if your knot isn't on https://git.klbr.net, override origin so the oauth client id matches your domain:

svfe = inputs.svfe.packages.${system}.svfe-web.override {
  origin = "https://knot.example.com";
};

development #

inside web/, you need node 24, pnpm 11 (corepack enable), and bun 1.4+:

cd web
pnpm install
pnpm dev        # http://127.0.0.1:5173

by default the app reads from the origin serving it. to point local dev at a deployed knot, set VITE_BOBBIN_URL in web/.env:

VITE_BOBBIN_URL=https://git.klbr.net

other build-time settings live in web/src/lib/config.ts.

pnpm dev          dev server on :5173
pnpm check        svelte-check + typescript
pnpm lint         prettier, eslint, test-id check
pnpm test         unit tests (vitest)
pnpm storybook    component stories on :6006
pnpm build        client app into build/, ssr bundle into build-server/
pnpm codegen      regenerate lexicon types

credits #

forked from tangled's monorepo (web/). self-hosted fonts are host grotesk and kode mono (both ofl-1.1).