Plattformen
Go
Runlight läuft mit Go 1.25 oder neuer, in jeder net/http-App, mit chi oder Echo, oder als eigenständiger Server.
Das Go-Modul ist Runlight, noch einmal in Go geschrieben. Es liefert dasselbe Dashboard und dieselbe API aus und gibt auf jede Anfrage die Antwort, die auch die TypeScript-Bibliothek gibt. Seine Zahlen landen in denselben Tabellen, deshalb kann jede der beiden eine Datenbank lesen, die die andere geschrieben hat. Es braucht Go 1.25 oder neuer, und das Modul selbst setzt kein anderes Modul voraus.
Installieren
Füge das Modul mit go get hinzu, zusammen mit dem Treiber für deine Datenbank.
bash
go get runlight.sh/go
go get modernc.org/sqliteRunlight spricht über database/sql mit seiner Datenbank, deshalb funktioniert jeder Treiber. Die Tests laufen mit modernc.org/sqlite für SQLite, github.com/jackc/pgx/v5/stdlib für Postgres und github.com/go-sql-driver/mysql für MySQL und MariaDB.
Die Instanz erstellen
Öffne die Datenbank, verpacke sie in einen Store und erstelle eine Instanz für die ganze App.
analytics.go
package main
import (
"database/sql"
_ "modernc.org/sqlite"
runlight "runlight.sh/go"
)
func newRunlight() (*runlight.Runlight, error) {
db, err := sql.Open("sqlite", "data/runlight.db")
if err != nil {
return nil, err
}
return runlight.New(runlight.Options{
Store: runlight.NewStore(runlight.SQLite(db).Owned(0)),
Site: &runlight.SiteOptions{Name: "example.com", Hostnames: []string{"example.com"}, Timezone: "Europe/London"},
})
}Die Optionen heißen genauso wie unter Konfiguration, als Go-Felder geschrieben, etwa Sites, IgnoreProxy, RateLimit, Mail und ManagedSites. Jede Methode, die liest oder schreibt, nimmt als Erstes einen context.Context, und die Tabellen werden bei der ersten Anfrage angelegt.
Stores
Owned übergibt die Verbindung an Runlight, das sie schließt, wenn der Store geschlossen wird. Lass es weg, um eine *sql.DB zu teilen, die deine App schon hat. Runlight schließt sie dann nie.
Runlights Tabellen beginnen mit rl_. runlight.NewStoreWithPrefix(runlight.SQLite(db).Owned(0), "wp_rl_") verwendet stattdessen ein anderes Präfix, damit zwei Apps in einer gemeinsamen Datenbank jeweils ihre eigenen Tabellen behalten. Ein Präfix besteht aus bis zu 32 Buchstaben, Ziffern und Unterstrichen und beginnt nicht mit einer Ziffer, und store.Tables() listet die Tabellen des Stores mit Namen auf, für eine App, die Runlight entfernt.
SQLite
go
db, err := sql.Open("sqlite", "data/runlight.db")
store := runlight.NewStore(runlight.SQLite(db).Owned(0))Runlight hält eine SQLite-Verbindung und schaltet sie in den WAL-Modus mit einem Busy-Timeout. Leg die Datei außerhalb jedes Ordners ab, den dein Webserver ausliefert, in einem Ordner, in den die App schreiben darf, weil SQLite daneben ein Journal schreibt.
Postgres
go
db, err := sql.Open("pgx", "postgres://runlight:password@127.0.0.1:5432/runlight?statement_timeout=120000")
store := runlight.NewStore(runlight.Postgres(db).Owned(0))Importiere github.com/jackc/pgx/v5/stdlib für den Treiber pgx. Der Parameter statement_timeout bricht jede einzelne Anweisung nach so vielen Millisekunden ab, so wie es die TypeScript-Bibliothek nach 120000 tut. Um die Tabellen in einem eigenen Schema zu halten, füge der URL search_path=analytics hinzu. Der Pool braucht mindestens zwei Verbindungen.
MySQL und MariaDB
go
db, err := sql.Open("mysql", "runlight:password@tcp(127.0.0.1:3306)/runlight?charset=utf8mb4&loc=UTC")
store := runlight.NewStore(runlight.MySQL(db).Owned(120000))Dieser Store funktioniert mit MySQL 8.0.20 oder neuer und MariaDB 11.4 oder neuer. Die Zahl, die du Owned übergibst, ist das Statement-Timeout jeder Sitzung in Millisekunden, und 0 schaltet es aus. Der Pool braucht mindestens zwei Verbindungen.
net/http
Die Routen sind ein http.Handler. Häng sie unter ihrem Basispfad ein, standardmäßig /runlight, und füge die Kurzlinks unter /go/{slug} hinzu.
main.go
rl, err := newRunlight()
if err != nil {
log.Fatal(err)
}
routes, err := rl.Routes(runlight.RoutesOptions{})
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("/runlight", routes)
mux.Handle("/runlight/", routes)
mux.Handle("GET /go/{slug}", rl.LinksHTTP())
mux.Handle("/", yourApp)
log.Fatal(http.ListenAndServe(":8080", rl.LinkDomains(rl.Observer(mux))))rl.Observer zeichnet die Abrufe von KI-Agenten auf, während deine App ihre Seiten ausliefert, und reicht jede Anfrage weiter. rl.LinkDomains beantwortet Kurzlinks auf deinen Link-Domains, den eigenen Namen, die du auf die App zeigen lässt, bevor der Mux die Anfrage sieht. Jeden anderen Host reicht es unverändert weiter, deine eigenen Seiten sind also nicht betroffen. In einer App, die jede Anfrage durch einen einzigen Handler schickt, beantwortet routes.Middleware(next) Link-Domains und Runlights eigene Pfade und gibt den Rest an next weiter, ohne deren Body zu lesen. Es beantwortet auch die OAuth-Dokumente, die MCP-Clients an der Wurzel der Website suchen.
rl.LinksHTTP() beantwortet einen Kurzlink, den es nicht gibt, mit einem schlichten 404. Um dort deine eigene 404-Seite zu zeigen, nimm rl.LinksHTTPOr(notFound), das solche Anfragen an deinen Handler notFound gibt. Auf einer Link-Domain ist /go/{slug} ein 404 wie jeder andere Pfad ohne Link.
Füge das Skript in jede Seite ein, direkt vor </head>.
html
<script defer src="/runlight/s.js"></script>Setze dann RUNLIGHT_TOKEN auf eine lange zufällige Zeichenkette und öffne einmal /runlight/?token=, gefolgt von dieser Zeichenkette, um dich anzumelden, wie es Erste Schritte beschreibt. RoutesOptions nimmt dieselben Einstellungen wie routes() unter Konfiguration, deshalb funktionieren Token, Authorize, BasePath und Origin so wie dort. Mit Accounts: true und einem RUNLIGHT_SECRET melden sich Leute mit ihrer eigenen E-Mail-Adresse und ihrem Passwort an, wie unter Konten.
chi
Der Adapter für chi ist ein eigenes Modul, chi wird also nie zur Voraussetzung für das Kernmodul.
bash
go get runlight.sh/go/chigo
r := chi.NewRouter()
r.Use(rl.Observer)
runlightchi.Mount(r, rl, routes)Mount fügt die Routen unter ihrem Basispfad, die OAuth-Dokumente und die Kurzlinks hinzu, und es beantwortet Link-Domains vor deinen eigenen Routen. Ruf es auf dem obersten Router vor deinen eigenen Routen auf, weil die Routen den vollständigen Pfad jeder Anfrage lesen und chi Middleware nur bis zur ersten Route annimmt. Nach einer Route aufgerufen, löst es mit diesem Hinweis eine Panic aus, weil eine Link-Domain sonst jeden Kurzlink verlöre, dessen Pfad eine deiner Routen trifft. Wenn deine Routen zuerst kommen müssen, leg rl.LinkDomains um den ganzen Router und ruf runlightchi.MountRoutes auf, das den Rest hinzufügt. Ein Kurzlink, den es nicht gibt, geht an den NotFound-Handler des Routers, sodass deine eigene 404-Seite antwortet.
Echo
Auch der Adapter für Echo ist ein eigenes Modul, und er funktioniert mit Echo v5.
bash
go get runlight.sh/go/echogo
e := echo.New()
e.Use(runlightecho.Observer(rl))
runlightecho.Register(e, rl, routes)Register fügt die Routen, die OAuth-Dokumente und die Kurzlinks hinzu, so wie Mount es für chi tut. Es beantwortet Link-Domains, bevor Echo eine Anfrage routet, egal wo in der App es aufgerufen wird. Ein Kurzlink, den es nicht gibt, gibt echo.ErrNotFound zurück, sodass dein Fehler-Handler ihn so beantwortet wie jede Seite, die die App nicht hat.
Andere Router
Jeder Router, der einen http.Handler annimmt, kann Runlight genauso ausliefern wie http.ServeMux, für Link-Domains umhüllt von rl.LinkDomains. Um eine Anfrage selbst zu beantworten, wandle sie mit runlight.FromHTTP in eine von Runlight um, übergib sie an routes.Handle und schick die Antwort mit runlight.WriteHTTP zurück. FromHTTP liest den Body mit den Limits, die der Node-Adapter verwendet, 16 KB für den Tracker und 10 MB für alles andere, und behält die Adresse, von der die Verbindung kam. Weitergeleitete Header liest Runlight selbst, außer IgnoreProxy ist gesetzt.
Der stündliche Check
Der stündliche Check wechselt die täglichen Salts, verschickt fällige E-Mail-Berichte, wendet an, wie lange jede Website ihre Besuche behält, und baut die Rollups, die lange Zeiträume schnell halten. Ruf rl.Check(ctx) alle paar Minuten aus einer Goroutine mit einem time.Ticker auf. Wenn in der App nichts nach Zeitplan läuft, setze CRON_SECRET und lass einen Scheduler damit /runlight/api/check aufrufen, wie Stündlicher Check zeigt.
Der eigenständige Server
Der Befehl runlight ist der eigenständige Server in Go. Er betreibt Runlight auf einer eigenen Domain, etwa stats.example.com, mit dem Dashboard an der Wurzel, im Dashboard hinzugefügten Websites, Konten mit Anmeldung und Kurzlinks auf jeder Domain, die du darauf zeigen lässt. Installiere ihn und führe ihn aus.
bash
go install runlight.sh/go/cmd/runlight@latest
runlightDer Server lauscht auf Port 3000 und legt seine Daten in einem Ordner namens runlight-data in dem Verzeichnis ab, aus dem du ihn startest. Beim ersten Start gibt er einen Einrichtungslink mit einem Einmalcode aus. Öffne diesen Link und leg dein Konto an, wie es Dein Konto anlegen beschreibt. Der Server führt den stündlichen Check alle fünf Minuten selbst aus und lädt die Ortsdaten des Monats herunter, wie es der Node-Server tut. Betreibe ihn hinter einem Proxy, der HTTPS ergänzt, wie unter Ins Internet stellen.
Um den Server in einem eigenen Programm zu betreiben, stellt runlight.sh/go/server ihn als http.Handler bereit, wobei server.New dieselben Einstellungen als Felder nimmt.
Einstellungen
Der Server liest seine Einstellungen aus Umgebungsvariablen.
| Variable | Was sie tut |
|---|---|
PORT |
Der Port, auf dem er lauscht. Standard ist 3000. |
HOST |
Die Adresse, auf der er lauscht. Standard ist 0.0.0.0. |
DATA_DIR |
Der Ordner für die SQLite-Datei, das Geheimnis und die Ortsdaten. Standard ist ./runlight-data. |
DATABASE_URL |
Eine postgres://-Adresse speichert die Daten in Postgres statt in SQLite, und eine mysql://- oder mariadb://-Adresse speichert sie in MySQL oder MariaDB. |
RUNLIGHT_SECRET |
Der Schlüssel, der Anmeldungen signiert und gespeicherte Schlüssel verschlüsselt. Ohne ihn erzeugt der Server selbst einen und legt ihn in DATA_DIR ab, lesbar nur für den Benutzer, der ihn ausführt. |
RUNLIGHT_URL |
Die öffentliche Adresse des Servers, etwa https://stats.example.com, die nie zu einer Link-Domain werden kann. E-Mails mit Einladungen und Berichten verlinken darauf. |
RUNLIGHT_TOKEN |
Ein Token, das Skripte als Bearer senden können, zusätzlich zu den im Dashboard erstellten API-Tokens. |
RUNLIGHT_OBSERVE_KEY |
Ein einzelner Schlüssel für die KI-Agenten-Meldungen aller Websites. Der eigene Agenten-Schlüssel jeder Website, angezeigt unter Einstellungen, Einbinden, ist die bessere Wahl. |
TRUST_PROXY |
Auf false setzen, wenn kein Proxy vor dem Server steht, oder auf x-real-ip oder cf-connecting-ip, wenn dieser Header die Adresse des Besuchers enthält. |
RUNLIGHT_GEO |
Woher Orte kommen, wenn kein Header sie liefert, wie unter Orte. Standard ist city, möglich sind auch country, off oder der Pfad zu einer MMDB-Datei. |
Die Tabellen aktualisieren sich beim Start des Servers selbst, für ein Upgrade brauchst du also nur einen neuen Build und einen Neustart. Sichere den Datenordner, denn ohne das Geheimnis darin lassen sich gespeicherte Schlüssel nicht lesen.
Vergessene Passwörter
Führe dort, wo der Server läuft, den Befehl password aus, um einem Konto ein neues Passwort zu geben, das er ausgibt. Er schaltet außerdem die Zwei-Faktor-Anmeldung für dieses Konto aus und legt das Konto an, wenn es noch keines gibt, als Eigentümer auf einem Server ohne Personen und sonst als Admin.
bash
runlight password someone@example.comKI-Agenten aus einem Log
KI-Agenten wie GPTBot und ClaudeBot rufen Seiten ab, ohne JavaScript auszuführen, deshalb sieht das Script-Tag sie nie. Eine Go-App mit Runlight darin zählt sie dort, wo die Seite ausgeliefert wird, mit rl.Observer. Für jede andere Website, die du selbst hostest, stehen sie im Zugriffslog des Webservers, und der Befehl agents liest es.
bash
runlight agents --log /var/log/nginx/access.log --to https://stats.example.com --key rlo_... --site https://example.com --followDer Schlüssel ist der Agenten-Schlüssel der Website, der in Runlight unter Einstellungen, Einbinden angezeigt wird, und er kann nur Abrufe für diese Website melden. Fehlt --to oder --key, liest der Befehl RUNLIGHT_URL oder RUNLIGHT_OBSERVE_KEY aus der Umgebung. Der Befehl funktioniert wie beim eigenständigen Server, mit --follow, um weiterzulaufen, und --state, um dort weiterzumachen, wo der letzte Lauf aufgehört hat. Nur erfolgreiche Seitenabrufe bekannter KI-Agenten verlassen die Maschine, und die Adressen der Besucher bleiben, wo sie sind.
Was anders ist als bei der TypeScript-Bibliothek
Das Go-Modul besteht dieselben Konformitätstests wie die TypeScript-Bibliothek, auf SQLite, Postgres, MySQL und MariaDB. Die Unterschiede kommen daher, wie Go läuft.
- Anfragen laufen in Goroutinen, und die Instanz verträgt viele gleichzeitig. SQLite hält eine Verbindung, die sich alle Anfragen unter einer Sperre teilen, und Postgres und MySQL nutzen den Pool, den
database/sqlverwaltet. - Was die TypeScript-Bibliothek im Hintergrund erledigt, etwa das Löschen von Besuchen, nachdem eine kürzere Aufbewahrungsdauer gewählt wurde, läuft in einer Goroutine neben der Antwort.
rl.Idle()wartet darauf, was ein Programm vor dem Beenden aufrufen kann. - Zeitzonen kommen aus Gos eigener Zeitzonendatenbank. Ein Programm, das für ein System ohne eine solche gebaut wird, importiert
time/tzdata. - Text wird mit Tabellen, die aus denen von Node erstellt sind, in Groß- oder Kleinbuchstaben umgewandelt, sodass Namen in beiden gleich verglichen werden. Websites werden nach Namen mit den Hauptregeln der Standardsortierung von Unicode aufgelistet, deshalb landet ab und zu ein Name in einer selteneren Schrift einen Platz neben der Stelle, an die Node ihn setzt.
- Das Rate-Limit des Trackers und die zwischengespeicherten Listen von Link-Domains und Website-Symbolen liegen im Arbeitsspeicher, jeder Prozess hat also seine eigenen. Die Limits für falsche Passwörter liegen in der Datenbank, deshalb teilen sich alle Prozesse sie.
- Passwörter werden in Go mit scrypt gehasht. Die Hashes stimmen mit denen der TypeScript-Bibliothek überein, Konten lassen sich also übernehmen.