`svfe` is the web frontend for [yumak](https://git.klbr.net/ptr.pet/yumak) (`knot-static`), a static [tangled](https://tangled.org) knot. it lives in [`web/`](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`](https://git.klbr.net/ptr.pet/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`](https://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:

```nix
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:

```nix
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:

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

## development

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

```sh
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`:

```sh
VITE_BOBBIN_URL=https://git.klbr.net
```

other build-time settings live in [`web/src/lib/config.ts`](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](https://tangled.org)'s monorepo (`web/`). self-hosted fonts are [host grotesk](https://github.com/farukgobal/Host-Grotesk) and [kode mono](https://github.com/isaozler/kode-mono) (both ofl-1.1).
