Typesense
duxts einen Build-Hook in eine Typesense-Collection überführen — jede Quelle, jede Version und jede Sprache, per stabiler ID aktualisiert.
duxt gibt einen einzigen Build-Hook aus, und der übergibt jede Seite jeder Quelle, jeder Version und jeder Sprache als flache Records. Eine Seite, die ohnehin Typesense betreibt, macht daraus eine eigene Collection — ohne die gerenderte Seite erneut zu crawlen und ohne duxts Quell-, Versions- und Locale-Logik nachzubauen.
duxt liefert kein Typesense-SDK, keine Zugangsdaten und keine Dienstkonfiguration aus. Die optionale Companion-Layer übernimmt die Integration; der Dienst und seine Schlüssel gehören dir. Ohne Provider oder eigenen Listener baut duxt überhaupt keine Payload.
Die Companion-Layer verwenden
Für die fertige Integration installierst du @kirchdev/duxt-typesense und erweiterst es nach duxt. Es synchronisiert den vollständigen Satz über eine Staging-Collection und einen Aliaswechsel, entfernt die vorherige verwaltete Collection und verbindet den bestehenden Suchdialog mit einem reinen Suchschlüssel. Die folgenden Schritte bleiben ein Rezept für eine eigene Synchronisation.
pnpm add @kirchdev/duxt-typesenseexport default defineNuxtConfig({
extends: ['@kirchdev/duxt', '@kirchdev/duxt-typesense'],
duxtTypesense: {
endpoint: process.env.TYPESENSE_ENDPOINT,
collection: 'docs',
adminKey: process.env.TYPESENSE_ADMIN_KEY,
searchKey: process.env.TYPESENSE_SEARCH_KEY
}
})
Der Admin-Schlüssel gehört ausschließlich in die Build-Konfiguration. searchKey darf nur documents:search für diesen Alias erlauben, und Typesense muss Browseranfragen deiner Seite zulassen.
Schritte
- Installiere den Client in deiner Seite. Er ist eine Abhängigkeit zur
Build-Zeit: die Synchronisation läuft in Node während
nuxt build, nie im Browser.
pnpm add -D typesense- Deklariere die Collection. Jedes Feld eines duxt-Records bildet auf ein Typesense-Feld ab, und die beiden Herkunftsfelder sind das, wonach ein Leser filtert:
import type { CollectionCreateSchema } from 'typesense';
export const docs: CollectionCreateSchema = {
name: 'docs',
fields: [
{ name: 'url', type: 'string' },
{ name: 'title', type: 'string' },
{ name: 'content', type: 'string' },
{ name: 'source', type: 'string', facet: true },
{ name: 'version', type: 'string', facet: true, optional: true },
{ name: 'locale', type: 'string', facet: true }
]
};
Als CollectionCreateSchema typisiert, denn Schritt 4 übergibt genau
dieses Objekt an Typesense, um die Collection damit anzulegen.
id fehlt in der Liste mit Absicht: Typesense behandelt id bereits als
Primärschlüssel des Dokuments, und duxts id ist
ein Hex-Digest der URL — als Schlüssel gültig, auf jeder
Maschine identisch und unverändert, solange die Seite bleibt, wo sie ist. Das
ist es, was der Synchronisation ein Upsert statt eines Neuaufbaus
erlaubt.
version ist optional, denn eine Quelle mit nur einer Version trägt keine.
Aus source ist die Version bewusst herausgenommen, damit eine Facette
darauf ein Release überlebt und version daneben das ist, was auf eine
einzelne eingrenzt.
- Schreibe das Modul, das zuhört. Ein Nuxt-Modul in deiner eigenen Seite
ist die ganze Integration —
defineNuxtModule, ein Hook, ein Import:
import { defineNuxtModule } from '@nuxt/kit';
import { Client } from 'typesense';
import type { DuxtSearchRecord } from '@kirchdev/duxt/search-records';
import { docs } from '../server/typesense-schema';
export default defineNuxtModule({
meta: { name: 'typesense-sync' },
setup(_options, nuxt) {
const key = process.env.TYPESENSE_ADMIN_KEY;
// Kein Schlüssel ist ein lokaler Build, kein Fehler: überspringen,
// statt ihn zu brechen.
if (!key) return;
nuxt.hook('duxt:search:records', async (records) => {
const client = new Client({
nodes: [{ url: process.env.TYPESENSE_URL! }],
apiKey: key
});
await sync(client, records);
});
}
});
Die Payload ist eingefroren. Bilde sie auf eigene Dokumente ab — überschreibe niemals einen Record und sortiere das Array nicht an Ort und Stelle, denn jeder andere Listener bekommt dieselben Objekte.
- Synchronisiere die ganze Menge und entferne, was fort ist. Der Hook übergibt bei jedem Build die vollständige aktuelle Record-Menge, also ist die Menge selbst die Anweisung — alles, was die Collection hält und was nicht darin vorkommt, wurde gelöscht, verschoben oder umbenannt:
async function sync(client: Client, records: readonly DuxtSearchRecord[]) {
const collection = client.collections(docs.name);
// Typesense legt nichts von selbst an: ein Import in eine Collection,
// die es nicht gibt, schlägt fehl — der erste Build legt sie also an.
if (!(await collection.exists())) await client.collections().create(docs);
await collection.documents().import(
records.map((record) => ({ ...record })),
{ action: 'upsert' }
);
// Alles, was der Build nicht übergeben hat, steht nicht mehr auf der Seite.
const kept = new Set(records.map((record) => record.id));
const live = await collection.documents().export();
const stale = live
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line).id as string)
.filter((id) => !kept.has(id));
// Stapelweise löschen: jede id landet im Filter-String, und eine
// einzige große Umbenennung baute einen, den Typesense ablehnt.
for (let index = 0; index < stale.length; index += 100) {
const batch = stale.slice(index, index + 100).join(',');
await collection.documents().delete({ filter_by: `id:[${batch}]` });
}
}
Die Collection wird beim ersten Lauf angelegt und danach in Ruhe gelassen, und die veralteten ids werden stapelweise gelöscht — Typesense nimmt den Filter als einen String, und eine Umbenennung, die eine ganze Quelle verschiebt, baute sonst einen, den er ablehnt.
Importiere in eine nach dem Build benannte Collection, richte dann einen Typesense-Alias darauf und wirf die vorherige weg. Jeder Build legt seine eigene Collection an, also braucht es weder die Existenzprüfung noch das stapelweise Löschen, und Leser sehen nie einen halb geschriebenen Index — zum Preis, bei jedem Build alles neu zu indizieren statt nur das Bewegte.
- Suche aus dem Browser mit einem reinen Suchschlüssel. Der Admin-Schlüssel oben signiert Schreibvorgänge und darf nie eine Seite erreichen. Stelle einen scoped, nur suchenden Schlüssel für den Client aus und filtere über die Facetten, die das Schema deklariert hat:
// `locale` ist die Sprache des INHALTS, nicht die Oberflächensprache.
// `version` fehlt bei einer Quelle ohne Versionen — die Klausel kommt nur
// dann dazu; fest eingebaut filterte sie auf `undefined`.
const filters = [`locale:${locale}`];
if (version) filters.push(`version:${version}`);
const results = await client.collections('docs').documents().search({
q: query,
query_by: 'title,content',
filter_by: filters.join(' && ')
});
- Prüfe einen Build von Anfang bis Ende. Lass einen Build mit gesetztem Schlüssel laufen und bestätige, dass die Collection einen Record für eine Seite in jeder Quelle, Version und Sprache hält, die die Seite ausliefert — nicht nur in der Standardsprache. Der erste Build legt die Collection an; jeder folgende schreibt per Upsert in dieselbe.
Was die Records dir schon abnehmen
Vier Eigenschaften der Payload machen eine so kurze Synchronisation überhaupt erst korrekt, und alle vier sind der Vertrag des Hooks und nicht der Rat dieser Anleitung:
| Eigenschaft | Was sie der Synchronisation bringt |
|---|---|
id leitet sich aus url ab | Ein stabiler Primärschlüssel, also genügt upsert und ein Neuaufbau ist nie nötig |
| Die Payload ist die vollständige Menge | Löschen ist ein Vergleich gegen einen Build, ohne Crawl und ohne Buchführung |
| duxt feuert nicht bei leerem Parse | Ein Build, der nichts geparst hat, kann dir nie sagen, den Index zu leeren |
| Die Reihenfolge ist deterministisch | Zwei Builds unterscheiden sich genau dort, wo die Seite sich unterschied |
Eine Seite, die search: false geschrieben hat, und eine Übersetzung in einer
Sprache, die die Seite nicht routet, erzeugen keine Records — also erreicht
keine von beiden Typesense.
Daraus ein Paket machen
Das Modul oben hat ~40 Zeilen und gehört zu einer Seite. Sobald eine zweite
Seite es braucht, ist der Weg zum Teilen eine Nuxt-Ebene, kein Plugin: eine
Ebene trägt das Modul, seine app.config-Vorgaben und seine Komponenten in
einem versionierten Paket, und ein Konsument fügt sie neben duxt hinzu.
export default defineNuxtConfig({
extends: ['@kirchdev/duxt', 'duxt-typesense']
});
Diese Ebene besitzt die Zugangsdaten, die Dienstkonfiguration, die Synchronisationspolitik und die Such-Oberfläche im Browser. duxt selbst bekommt nichts davon — und genau darum ist der Hook so geschnitten, wie er ist.
Checkliste
- Der Client ist eine Abhängigkeit deiner Seite, nicht der Ebene
- Der Admin-Schlüssel lebt in der Build-Umgebung und erreicht nie eine Seite
- Die Collection wird beim ersten Build angelegt und facettiert
source,versionundlocale - Nicht mehr übergebene Records werden entfernt — per stapelweisem Vergleich oder per Alias-Tausch
- Ein Build ohne Schlüssel überspringt die Synchronisation, statt zu scheitern
- Jede Quelle, Version und Sprache ist in der Collection vertreten