Zum Inhalt springen
duxt

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.

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-typesense
export 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

  1. 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
  1. 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.

  1. 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.

  1. 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.

  1. 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(' && ')
});
  1. 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:

EigenschaftWas sie der Synchronisation bringt
id leitet sich aus url abEin stabiler Primärschlüssel, also genügt upsert und ein Neuaufbau ist nie nötig
Die Payload ist die vollständige MengeLöschen ist ein Vergleich gegen einen Build, ohne Crawl und ohne Buchführung
duxt feuert nicht bei leerem ParseEin Build, der nichts geparst hat, kann dir nie sagen, den Index zu leeren
Die Reihenfolge ist deterministischZwei 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, version und locale
  • 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
War diese Seite hilfreich?