Files
stanza/apps/web/src/routes/api.search.modules.ts
T

126 lines
4.4 KiB
TypeScript

import { create, insert, search } from "@orama/orama";
import { createFileRoute } from "@tanstack/react-router";
import type { Module, ModuleMetadata, RegistryIndex } from "@withstanza/schema";
import { cache } from "react";
import { loadRegistryFile } from "@/server/registry-base.server";
const moduleSchema = {
category: "string",
moduleId: "string",
label: "string",
description: "string",
// Joined text for full-text matching. These fields never travel back to the
// client — only `metadata[hit.id]` does, which is `ModuleMetadata`-shaped.
deps: "string",
envNames: "string",
envDescriptions: "string",
} as const;
type ModuleIndex = {
db: ReturnType<typeof create<typeof moduleSchema>>;
metadata: Map<string, ModuleMetadata>;
};
// React `cache()` dedupes the (async) build across any concurrent callers
// within the same server render. The returned promise is reused on warm
// calls so the registry-load + Orama-insert work only runs once.
const getModuleIndex = cache((): Promise<ModuleIndex> => buildIndex());
async function buildIndex(): Promise<ModuleIndex> {
const index = await loadRegistryFile<RegistryIndex>("index.json");
const db = create({ schema: moduleSchema });
const metadata = new Map<string, ModuleMetadata>();
// Per-module file missing (shouldn't happen for first-party modules, but
// be tolerant): fall back to metadata-only indexing via `mod: null`.
const loaded = await Promise.all(
index.modules.map(async (meta) => {
try {
const mod = await loadRegistryFile<Module>(meta.path);
return { meta, mod };
} catch {
return { meta, mod: null as Module | null };
}
}),
);
const inserted = await Promise.all(
loaded.map(async ({ meta, mod }) => {
const deps = new Set<string>();
const envNames = new Set<string>();
const envDescriptions: string[] = [];
if (mod) {
for (const k of Object.keys(mod.dependencies ?? {})) deps.add(k);
for (const k of Object.keys(mod.devDependencies ?? {})) deps.add(k);
for (const e of mod.env ?? []) {
envNames.add(e.name);
if (e.description) envDescriptions.push(e.description);
}
for (const adapter of mod.adapters) {
for (const k of Object.keys(adapter.dependencies ?? {})) deps.add(k);
for (const k of Object.keys(adapter.devDependencies ?? {})) deps.add(k);
for (const e of adapter.env ?? []) {
envNames.add(e.name);
if (e.description) envDescriptions.push(e.description);
}
}
}
const id = await insert(db, {
category: meta.category,
moduleId: meta.id,
label: meta.label,
description: meta.description,
deps: [...deps].join(" "),
envNames: [...envNames].join(" "),
envDescriptions: envDescriptions.join(" "),
});
return { id, meta };
}),
);
for (const { id, meta } of inserted) metadata.set(id, meta);
return { db, metadata };
}
/**
* `GET /api/search/modules?q=…` — server-side module search. Builds an Orama
* index from the full module data (deps, env vars, descriptions) so we can
* match on richer fields than the client ever sees. Only `ModuleMetadata`-
* shaped hits travel back, which means we can index README bodies later
* without bloating any client payload.
*
* Empty query returns an empty array — the client renders all modules from
* the registry metadata it already has from the root loader.
*/
export const Route = createFileRoute("/api/search/modules")({
server: {
handlers: {
GET: async ({ request }) => {
try {
const url = new URL(request.url);
const q = url.searchParams.get("q") ?? "";
if (!q) return Response.json({ results: [] });
const { db, metadata } = await getModuleIndex();
const hits = await search(db, { term: q, limit: 50 });
const results: ModuleMetadata[] = [];
for (const hit of hits.hits) {
const meta = metadata.get(hit.id);
if (meta) results.push(meta);
}
return Response.json({ results });
} catch (error) {
console.error("[server-error]", error, { source: "/api/search/modules" });
return Response.json(
{ error: "Search failed", code: "MODULES_SEARCH_FAILED" },
{ status: 500 },
);
}
},
},
},
});