Add the pack package: mirror to zim or self-contained binary

BuildZIM walks a cloned host directory, turns every file into a content
entry with a MIME inferred from its extension, picks a main page, and adds
the standard metadata plus a mainPage redirect. state.json is skipped.
BuildBinary appends the archive to a copy of kage behind a KAGEPCK1 trailer,
and Embedded detects that trailer at startup so the binary serves itself.
Handler maps / to the main page and /path to a content entry, the same
handler the embedded viewer uses.
This commit is contained in:
Duc-Tam Nguyen
2026-06-14 20:17:18 +07:00
parent ffdb4ca969
commit fafa3dfa51
6 changed files with 730 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
package pack
import (
"bytes"
"encoding/binary"
"fmt"
"os"
)
const (
// trailerMagic brackets the appended-archive trailer at both ends, so a stray
// copy of it inside the base binary cannot be mistaken for a real trailer.
trailerMagic = "KAGEPCK1"
// trailerLen is magic + uint64 archive length + magic again.
trailerLen = len(trailerMagic) + 8 + len(trailerMagic)
)
// BinaryOptions controls how a self-contained viewer is assembled.
type BinaryOptions struct {
Out string // output path
Base string // base kage binary; default os.Executable()
}
// BuildBinary writes baseExe ++ zimBytes ++ trailer to opts.Out and marks it
// executable. The base must be a kage binary, since the viewer behaviour lives
// in kage's own startup hook (see Embedded); appending a ZIM to an arbitrary
// executable would only produce a broken file. It returns the output path and
// the total byte size.
func BuildBinary(zimBytes []byte, opts BinaryOptions) (string, int64, error) {
base := opts.Base
if base == "" {
exe, err := os.Executable()
if err != nil {
return "", 0, fmt.Errorf("pack: locate base binary: %w", err)
}
base = exe
}
if opts.Out == "" {
return "", 0, fmt.Errorf("pack: BuildBinary requires an output path")
}
baseBytes, err := os.ReadFile(base)
if err != nil {
return "", 0, fmt.Errorf("pack: read base binary %q: %w", base, err)
}
var tr bytes.Buffer
tr.WriteString(trailerMagic)
_ = binary.Write(&tr, binary.LittleEndian, uint64(len(zimBytes)))
tr.WriteString(trailerMagic)
f, err := os.Create(opts.Out)
if err != nil {
return "", 0, err
}
for _, chunk := range [][]byte{baseBytes, zimBytes, tr.Bytes()} {
if _, err := f.Write(chunk); err != nil {
_ = f.Close()
return opts.Out, 0, err
}
}
if err := f.Close(); err != nil {
return opts.Out, 0, err
}
if err := os.Chmod(opts.Out, 0o755); err != nil {
return opts.Out, 0, err
}
return opts.Out, int64(len(baseBytes) + len(zimBytes) + trailerLen), nil
}
+50
View File
@@ -0,0 +1,50 @@
package pack
import (
"encoding/binary"
"io"
"os"
)
// Embedded inspects the running executable for an appended ZIM archive. If the
// KAGEPCK1 trailer is present, it returns a ReaderAt bounded to the archive, its
// size, and ok=true; the file handle stays open for the life of the process so
// the viewer can serve from it. A normal kage build has no trailer, so the cost
// to every ordinary invocation is one Open plus a 24-byte ReadAt.
func Embedded() (ra io.ReaderAt, size int64, ok bool) {
exe, err := os.Executable()
if err != nil {
return nil, 0, false
}
f, err := os.Open(exe)
if err != nil {
return nil, 0, false
}
info, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, 0, false
}
total := info.Size()
if total < int64(trailerLen) {
_ = f.Close()
return nil, 0, false
}
tr := make([]byte, trailerLen)
if _, err := f.ReadAt(tr, total-int64(trailerLen)); err != nil {
_ = f.Close()
return nil, 0, false
}
if string(tr[:8]) != trailerMagic || string(tr[trailerLen-8:]) != trailerMagic {
_ = f.Close()
return nil, 0, false
}
zlen := int64(binary.LittleEndian.Uint64(tr[8:16]))
start := total - int64(trailerLen) - zlen
if zlen <= 0 || start < 0 {
_ = f.Close()
return nil, 0, false
}
return io.NewSectionReader(f, start, zlen), zlen, true
}
+52
View File
@@ -0,0 +1,52 @@
package pack
import (
"path"
"strings"
)
// mimeByExt maps a lower-case file extension (with the dot) to the MIME type
// kage records for it. Inference is by extension only, never by sniffing the
// bytes, so the same input always yields the same output. Anything not listed
// falls back to application/octet-stream and is stored uncompressed.
var mimeByExt = map[string]string{
".html": "text/html",
".htm": "text/html",
".css": "text/css",
".js": "text/javascript",
".mjs": "text/javascript",
".json": "application/json",
".xml": "application/xml",
".svg": "image/svg+xml",
".txt": "text/plain",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".avif": "image/avif",
".ico": "image/x-icon",
".woff2": "font/woff2",
".woff": "font/woff",
".ttf": "font/ttf",
".otf": "font/otf",
".eot": "application/vnd.ms-fontobject",
".mp4": "video/mp4",
".m4v": "video/mp4",
".webm": "video/webm",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".pdf": "application/pdf",
".zip": "application/zip",
".wasm": "application/wasm",
}
// MimeForExt returns the MIME type for a path's extension, defaulting to
// application/octet-stream when the extension is unknown or absent.
func MimeForExt(p string) string {
ext := strings.ToLower(path.Ext(p))
if m, ok := mimeByExt[ext]; ok {
return m
}
return "application/octet-stream"
}
+253
View File
@@ -0,0 +1,253 @@
package pack
import (
"bytes"
"encoding/binary"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/tamnd/kage/urlx"
"github.com/tamnd/kage/zim"
)
// writeMirror lays down a small kage-style mirror under a temp dir and returns
// the host dir.
func writeMirror(t *testing.T) string {
t.Helper()
root := t.TempDir()
host := filepath.Join(root, "example.com")
files := map[string]string{
"index.html": "<!doctype html><title>Example Home</title><h1>Hi</h1>",
"about/index.html": "<!doctype html><title>About</title><h1>About</h1>",
"_kage/example.com/x/logo.png": "\x89PNGfake",
"_kage/state.json": `{"visited":[]}`, // must be skipped
}
for rel, body := range files {
p := filepath.Join(host, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
return host
}
func TestMimeForExt(t *testing.T) {
cases := map[string]string{
"a/b/index.html": "text/html",
"style.CSS": "text/css",
"data.json": "application/json",
"icon.svg": "image/svg+xml",
"logo.png": "image/png",
"photo.JPEG": "image/jpeg",
"font.woff2": "font/woff2",
"clip.mp4": "video/mp4",
"doc.pdf": "application/pdf",
"mystery": "application/octet-stream",
"archive.tar.zst": "application/octet-stream",
}
for in, want := range cases {
if got := MimeForExt(in); got != want {
t.Errorf("MimeForExt(%q) = %q, want %q", in, got, want)
}
}
}
func TestBuildZIMRoundTrip(t *testing.T) {
host := writeMirror(t)
out := filepath.Join(t.TempDir(), "example.zim")
path, size, err := BuildZIM(host, ZIMOptions{Out: out, Date: "2026-06-14", Version: "test"})
if err != nil {
t.Fatalf("BuildZIM: %v", err)
}
if path != out {
t.Errorf("path = %q, want %q", path, out)
}
fi, err := os.Stat(out)
if err != nil {
t.Fatal(err)
}
if fi.Size() != size {
t.Errorf("reported size %d, file is %d", size, fi.Size())
}
r, err := zim.Open(out)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer r.Close()
// Main page is the root index.
mp, err := r.MainPage()
if err != nil {
t.Fatalf("MainPage: %v", err)
}
if !bytes.Contains(mp.Data, []byte("Example Home")) {
t.Errorf("main page wrong: %.40q", mp.Data)
}
if mp.MimeType != "text/html" {
t.Errorf("main page mime = %q", mp.MimeType)
}
// Binary asset round-trips byte-for-byte.
logo, err := r.Get(zim.NamespaceContent, "_kage/example.com/x/logo.png")
if err != nil {
t.Fatalf("Get logo: %v", err)
}
if string(logo.Data) != "\x89PNGfake" {
t.Errorf("logo bytes wrong: %q", logo.Data)
}
// Title metadata comes from the main page's <title>.
title, err := r.Get(zim.NamespaceMetadata, "Title")
if err != nil || string(title.Data) != "Example Home" {
t.Errorf("M/Title = %q, %v", title.Data, err)
}
// state.json was skipped.
if _, err := r.Get(zim.NamespaceContent, urlx.DefaultReserved+"/state.json"); err == nil {
t.Error("state.json should not be packed")
}
}
func TestBuildZIMDeterministic(t *testing.T) {
host := writeMirror(t)
dir := t.TempDir()
a, _, err := BuildZIM(host, ZIMOptions{Out: filepath.Join(dir, "a.zim"), Date: "2026-06-14"})
if err != nil {
t.Fatal(err)
}
b, _, err := BuildZIM(host, ZIMOptions{Out: filepath.Join(dir, "b.zim"), Date: "2026-06-14"})
if err != nil {
t.Fatal(err)
}
ba, _ := os.ReadFile(a)
bb, _ := os.ReadFile(b)
if !bytes.Equal(ba, bb) {
t.Error("same mirror produced different archives")
}
}
func TestPickMainPage(t *testing.T) {
cases := []struct {
in []string
want string
}{
{[]string{"a/index.html", "index.html", "b.html"}, "index.html"},
{[]string{"z/deep/p.html", "top.html", "a/p.html"}, "top.html"},
{[]string{"b/x.html", "a/x.html"}, "a/x.html"}, // same depth, lexical
{nil, ""},
}
for _, c := range cases {
if got := pickMainPage(c.in); got != c.want {
t.Errorf("pickMainPage(%v) = %q, want %q", c.in, got, c.want)
}
}
}
// TestBinaryTrailerRoundTrip exercises the BuildBinary append contract and the
// trailer it leaves, without depending on os.Executable: it appends a ZIM to a
// fake base, reads the trailer back the way Embedded does, and serves the
// recovered archive.
func TestBinaryTrailerRoundTrip(t *testing.T) {
host := writeMirror(t)
zbytes, err := BuildZIMBytes(host, ZIMOptions{Date: "2026-06-14"})
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
base := filepath.Join(dir, "fakekage")
baseBytes := bytes.Repeat([]byte("BASE"), 64) // stand-in for a kage binary
if err := os.WriteFile(base, baseBytes, 0o755); err != nil {
t.Fatal(err)
}
out := filepath.Join(dir, "viewer")
_, total, err := BuildBinary(zbytes, BinaryOptions{Out: out, Base: base})
if err != nil {
t.Fatalf("BuildBinary: %v", err)
}
if total != int64(len(baseBytes)+len(zbytes)+trailerLen) {
t.Errorf("total %d, want %d", total, len(baseBytes)+len(zbytes)+trailerLen)
}
f, err := os.Open(out)
if err != nil {
t.Fatal(err)
}
defer f.Close()
fi, _ := f.Stat()
end := fi.Size()
tr := make([]byte, trailerLen)
if _, err := f.ReadAt(tr, end-int64(trailerLen)); err != nil {
t.Fatal(err)
}
if string(tr[:8]) != trailerMagic || string(tr[trailerLen-8:]) != trailerMagic {
t.Fatal("trailer magic missing")
}
zlen := int64(binary.LittleEndian.Uint64(tr[8:16]))
if zlen != int64(len(zbytes)) {
t.Errorf("trailer length %d, want %d", zlen, len(zbytes))
}
start := end - int64(trailerLen) - zlen
if start != int64(len(baseBytes)) {
t.Errorf("archive start %d, want %d", start, len(baseBytes))
}
sec := io.NewSectionReader(f, start, zlen)
r, err := zim.NewReader(sec, zlen)
if err != nil {
t.Fatalf("reopen appended zim: %v", err)
}
mp, err := r.MainPage()
if err != nil || !bytes.Contains(mp.Data, []byte("Example Home")) {
t.Errorf("recovered main page wrong: %.40q (%v)", mp.Data, err)
}
}
func TestHandler(t *testing.T) {
host := writeMirror(t)
out := filepath.Join(t.TempDir(), "h.zim")
if _, _, err := BuildZIM(host, ZIMOptions{Out: out, Date: "2026-06-14"}); err != nil {
t.Fatal(err)
}
r, err := zim.Open(out)
if err != nil {
t.Fatal(err)
}
defer r.Close()
srv := httptest.NewServer(Handler(r))
defer srv.Close()
get := func(p string) (int, string) {
resp, err := http.Get(srv.URL + p)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
if code, body := get("/"); code != 200 || !bytes.Contains([]byte(body), []byte("Example Home")) {
t.Errorf("GET / = %d %.30q", code, body)
}
if code, _ := get("/about/index.html"); code != 200 {
t.Errorf("GET /about/index.html = %d", code)
}
if code, _ := get("/" + urlx.DefaultReserved + "/state.json"); code != 404 {
t.Errorf("GET state.json = %d, want 404", code)
}
if code, _ := get("/missing.html"); code != 404 {
t.Errorf("GET missing = %d, want 404", code)
}
}
+62
View File
@@ -0,0 +1,62 @@
package pack
import (
"errors"
"net/http"
"os/exec"
"runtime"
"strings"
"github.com/tamnd/kage/zim"
)
// Handler serves a ZIM archive over HTTP. "/" maps to the archive's main page;
// "/a/b.png" maps to the C/a/b.png content entry. Because the saved HTML's links
// are mirror-relative paths, and those are exactly the C urls, a click in a
// served page hits the right entry with no rewriting. A miss is a plain 404.
func Handler(r *zim.Reader) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
p := strings.TrimPrefix(req.URL.Path, "/")
if p == "" {
blob, err := r.MainPage()
if err != nil {
http.NotFound(w, req)
return
}
serveBlob(w, blob)
return
}
blob, err := r.Get(zim.NamespaceContent, p)
if errors.Is(err, zim.ErrNotFound) {
http.NotFound(w, req)
return
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
serveBlob(w, blob)
})
}
func serveBlob(w http.ResponseWriter, b zim.Blob) {
if b.MimeType != "" {
w.Header().Set("Content-Type", b.MimeType)
}
_, _ = w.Write(b.Data)
}
// OpenInBrowser launches the platform default browser at url. It is best-effort:
// callers print the url regardless and never treat a launch failure as fatal.
func OpenInBrowser(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default:
cmd = exec.Command("xdg-open", url)
}
return cmd.Start()
}
+244
View File
@@ -0,0 +1,244 @@
// Package pack turns a kage mirror on disk into a distributable artifact: a ZIM
// archive, or a self-contained executable that serves the mirror offline. It is
// the only pack-side package that touches the filesystem and the running
// executable; the byte-level format work lives in the zim package.
package pack
import (
"bufio"
"bytes"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"golang.org/x/net/html"
"github.com/tamnd/kage/urlx"
"github.com/tamnd/kage/zim"
)
// ZIMOptions controls how a mirror is packed into a ZIM archive. Date is passed
// in from the CLI boundary rather than read from the clock, so the zim and pack
// packages stay pure and packing the same mirror twice is byte-identical.
type ZIMOptions struct {
Out string // output path (default <mirror-base>.zim)
NoCompress bool // store every cluster raw (code 1)
Title string // overrides M/Title
Description string // M/Description
Language string // M/Language (default "eng")
Date string // M/Date, e.g. "2026-06-14"
Version string // kage version, recorded as M/Scraper
}
// BuildZIM walks mirrorDir, turns every file into a C/ content entry, infers the
// MIME from the extension, picks a main page, adds M/ metadata and a W/mainPage
// redirect, and writes a .zim to opts.Out. It returns the output path and the
// number of bytes written.
func BuildZIM(mirrorDir string, opts ZIMOptions) (string, int64, error) {
w, err := buildWriter(mirrorDir, opts)
if err != nil {
return "", 0, err
}
out := opts.Out
if out == "" {
out = filepath.Base(mirrorDir) + ".zim"
}
f, err := os.Create(out)
if err != nil {
return "", 0, err
}
bw := bufio.NewWriter(f)
n, err := w.WriteTo(bw)
if err != nil {
_ = f.Close()
return out, n, err
}
if err := bw.Flush(); err != nil {
_ = f.Close()
return out, n, err
}
return out, n, f.Close()
}
// BuildZIMBytes is the buffer-returning sibling of BuildZIM: it runs the same
// walk and returns the archive in memory, which the binary path appends to a
// base executable without writing the ZIM to disk first.
func BuildZIMBytes(mirrorDir string, opts ZIMOptions) ([]byte, error) {
w, err := buildWriter(mirrorDir, opts)
if err != nil {
return nil, err
}
var buf bytes.Buffer
if _, err := w.WriteTo(&buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// buildWriter does the shared work of both BuildZIM and BuildZIMBytes: it loads
// every file under mirrorDir into a zim.Writer with metadata and a main page.
func buildWriter(mirrorDir string, opts ZIMOptions) (*zim.Writer, error) {
info, err := os.Stat(mirrorDir)
if err != nil {
return nil, err
}
if !info.IsDir() {
return nil, fmt.Errorf("pack: %q is not a directory", mirrorDir)
}
w := zim.NewWriter()
if opts.NoCompress {
w.SetNoCompress(true)
}
skip := urlx.DefaultReserved + "/state.json"
var htmlPages []string
counts := map[string]int{}
walkErr := filepath.WalkDir(mirrorDir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel := slashRel(mirrorDir, p)
if rel == skip {
return nil
}
data, err := os.ReadFile(p)
if err != nil {
return err
}
mime := MimeForExt(rel)
if mime == "text/html" {
htmlPages = append(htmlPages, rel)
}
counts[mime]++
w.AddContent(zim.NamespaceContent, rel, "", mime, data)
return nil
})
if walkErr != nil {
return nil, walkErr
}
main := pickMainPage(htmlPages)
if main != "" {
w.SetMainPage(zim.NamespaceContent, main)
w.AddRedirect(zim.NamespaceWellKnown, "mainPage", "", zim.NamespaceContent, main)
}
host := filepath.Base(mirrorDir)
title := firstNonEmpty(opts.Title, htmlTitleOf(mirrorDir, main), host)
w.AddMetadata("Title", title)
w.AddMetadata("Language", firstNonEmpty(opts.Language, "eng"))
if opts.Description != "" {
w.AddMetadata("Description", opts.Description)
}
w.AddMetadata("Creator", "kage")
w.AddMetadata("Publisher", "kage")
if opts.Date != "" {
w.AddMetadata("Date", opts.Date)
}
w.AddMetadata("Scraper", strings.TrimSpace("kage "+opts.Version))
w.AddMetadata("Source", host)
w.AddMetadata("Counter", counterString(counts))
return w, nil
}
// pickMainPage chooses the archive's entry point: the root index if present,
// else the shallowest HTML page, ties broken lexicographically for determinism.
// It returns "" when the mirror has no HTML at all.
func pickMainPage(htmlPages []string) string {
for _, p := range htmlPages {
if p == "index.html" {
return p
}
}
sorted := append([]string(nil), htmlPages...)
sort.Slice(sorted, func(i, j int) bool {
di, dj := strings.Count(sorted[i], "/"), strings.Count(sorted[j], "/")
if di != dj {
return di < dj
}
return sorted[i] < sorted[j]
})
if len(sorted) > 0 {
return sorted[0]
}
return ""
}
// htmlTitleOf reads the main page off disk and returns its <title>, or "" if
// there is no main page or no title.
func htmlTitleOf(mirrorDir, mainURL string) string {
if mainURL == "" {
return ""
}
f, err := os.Open(filepath.Join(mirrorDir, filepath.FromSlash(mainURL)))
if err != nil {
return ""
}
defer f.Close()
doc, err := html.Parse(f)
if err != nil {
return ""
}
return strings.TrimSpace(findTitle(doc))
}
// findTitle returns the text of the first <title> element in depth-first order.
func findTitle(n *html.Node) string {
if n.Type == html.ElementNode && n.Data == "title" {
var b strings.Builder
for c := n.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.TextNode {
b.WriteString(c.Data)
}
}
return b.String()
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
if t := findTitle(c); t != "" {
return t
}
}
return ""
}
// counterString renders the M/Counter value Kiwix uses for stats: a
// semicolon-separated list of mime=count pairs, sorted for determinism.
func counterString(counts map[string]int) string {
mimes := make([]string, 0, len(counts))
for m := range counts {
mimes = append(mimes, m)
}
sort.Strings(mimes)
parts := make([]string, len(mimes))
for i, m := range mimes {
parts[i] = fmt.Sprintf("%s=%d", m, counts[m])
}
return strings.Join(parts, ";")
}
// slashRel returns p relative to root using forward slashes, the form ZIM urls
// take regardless of the host filesystem separator.
func slashRel(root, p string) string {
rel, err := filepath.Rel(root, p)
if err != nil {
rel = p
}
return filepath.ToSlash(rel)
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}