From 0c72adea97e1b7e630e1a5f56bb2fe455cd87002 Mon Sep 17 00:00:00 2001 From: hachall Date: Wed, 10 Jun 2026 16:49:57 +0100 Subject: [PATCH 1/7] fix to the docs search, and allow scalar to send requests to api.dw --- next.config.ts | 10 ++++ src/app/control-layer/api-reference/route.ts | 3 ++ src/app/inference-api/api-reference/route.ts | 4 ++ src/lib/scalar-api-reference.test.ts | 18 +++++++ src/middleware.test.ts | 56 ++++++++++++++++++++ src/middleware.ts | 8 ++- 6 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 src/middleware.test.ts diff --git a/next.config.ts b/next.config.ts index a81fdb7..48cf9d7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -27,6 +27,16 @@ const securityHeaders = [ const nextConfig: NextConfig = { reactCompiler: true, + // The /api/search route reads public/search-index.json at runtime with fs + // (see src/lib/search-index.ts). On Vercel, files under public/ are deployed + // as CDN static assets and are NOT included in a route handler's serverless + // function bundle, so the runtime read throws ENOENT and the route 500s with + // an empty body. Force Next's output file tracing to bundle the generated + // index (written by the prebuild step) into the search function. + outputFileTracingIncludes: { + '/api/search': ['./public/search-index.json'], + }, + // Apply security response headers to every route. async headers() { return [ diff --git a/src/app/control-layer/api-reference/route.ts b/src/app/control-layer/api-reference/route.ts index 14467b4..e558c41 100644 --- a/src/app/control-layer/api-reference/route.ts +++ b/src/app/control-layer/api-reference/route.ts @@ -7,6 +7,9 @@ import { withCspNonce } from "@/lib/scalar-api-reference"; export const GET = withCspNonce( ApiReference({ url: "/api/control-layer-openapi", + // See inference-api/api-reference: avoid the Google Fonts load blocked by + // our `font-src 'self' data:` CSP. + withDefaultFonts: false, metaData: { title: "API Reference | Control Layer | Doubleword Docs", description: "Complete API reference for the Doubleword Control Layer API", diff --git a/src/app/inference-api/api-reference/route.ts b/src/app/inference-api/api-reference/route.ts index a442ac0..1642902 100644 --- a/src/app/inference-api/api-reference/route.ts +++ b/src/app/inference-api/api-reference/route.ts @@ -7,6 +7,10 @@ import { withCspNonce } from "@/lib/scalar-api-reference"; export const GET = withCspNonce( ApiReference({ url: "/api/openapi", + // Scalar otherwise loads its default web fonts from Google Fonts, which our + // `font-src 'self' data:` CSP blocks ("Refused to load the font"). Use the + // system font stack instead of allowlisting an external font host. + withDefaultFonts: false, metaData: { title: "API Reference | Doubleword Inference API | Doubleword Docs", description: "Complete API reference for the Doubleword API", diff --git a/src/lib/scalar-api-reference.test.ts b/src/lib/scalar-api-reference.test.ts index 6fb83aa..5b7ad34 100644 --- a/src/lib/scalar-api-reference.test.ts +++ b/src/lib/scalar-api-reference.test.ts @@ -10,6 +10,8 @@ vi.mock("next/headers", () => ({ })); import { withCspNonce } from "./scalar-api-reference"; +import { GET as inferenceApiReference } from "@/app/inference-api/api-reference/route"; +import { GET as controlLayerApiReference } from "@/app/control-layer/api-reference/route"; const NONCE = "test-nonce-Zm9vYmFy"; @@ -81,3 +83,19 @@ describe("withCspNonce", () => { expect(html).not.toContain("nonce="); }); }); + +// Scalar's client otherwise pulls its default web fonts from Google Fonts, which +// our `font-src 'self' data:` CSP blocks ("Refused to load the font"). Both +// API-reference routes must opt out (`withDefaultFonts: false`) so Scalar uses +// the system font stack instead of an external font host. Scalar serializes the +// config as JSON into its inline init script, so the flag appears verbatim in the +// rendered HTML. +describe("Scalar API-reference routes opt out of external fonts", () => { + it.each([ + ["inference-api", inferenceApiReference], + ["control-layer", controlLayerApiReference], + ])("%s reference disables Scalar's default fonts", async (_name, handler) => { + const html = await (await handler()).text(); + expect(html).toContain('"withDefaultFonts": false'); + }); +}); diff --git a/src/middleware.test.ts b/src/middleware.test.ts new file mode 100644 index 0000000..ed3ff87 --- /dev/null +++ b/src/middleware.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { NextRequest } from "next/server"; +import { middleware } from "./middleware"; + +// Pull the CSP off the response middleware emits for a normal document request. +function cspFor( + url = "https://docs.doubleword.ai/inference-api/api-reference", +): string { + const res = middleware(new NextRequest(url)); + return res.headers.get("content-security-policy") ?? ""; +} + +// Return the directive (e.g. "connect-src ...") as a single trimmed string. +function directive(csp: string, name: string): string { + return ( + csp + .split(";") + .map((d) => d.trim()) + .find((d) => d === name || d.startsWith(`${name} `)) ?? "" + ); +} + +describe("CSP middleware", () => { + it("allows the inference API host so Scalar's Test Request works", () => { + // The OpenAPI spec's server is https://api.doubleword.ai/v1, so Scalar fires + // its try-it fetch at that host from the browser. Without this entry the + // request is blocked and the user sees "Failed to fetch". + expect(directive(cspFor(), "connect-src")).toContain( + "https://api.doubleword.ai", + ); + }); + + it("keeps the other connect-src allowances intact", () => { + const connectSrc = directive(cspFor(), "connect-src"); + expect(connectSrc).toContain("'self'"); // PostHog via /ingest rewrite + expect(connectSrc).toContain("https://app.doubleword.ai"); // SSO session check + expect(connectSrc).toContain("https://status.doubleword.ai"); // StatusWidget + }); + + it("does not allowlist a font host — fonts stay self/data only", () => { + // We fixed the Scalar font violation by disabling its default fonts, not by + // opening font-src. Guard against a future regression that re-opens it. + expect(directive(cspFor(), "font-src")).toBe("font-src 'self' data:"); + }); + + it("emits a per-request nonce in script-src", () => { + expect(cspFor()).toMatch(/script-src[^;]*'nonce-[^']+'/); + }); + + it("uses a unique nonce per response", () => { + const first = cspFor().match(/'nonce-([^']+)'/)?.[1]; + const second = cspFor().match(/'nonce-([^']+)'/)?.[1]; + expect(first).toBeTruthy(); + expect(first).not.toBe(second); + }); +}); diff --git a/src/middleware.ts b/src/middleware.ts index 2eab6a0..6543659 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -24,6 +24,12 @@ import {NextRequest, NextResponse} from 'next/server' // `credentials: 'include'` to verify the SSO session. CORS is already // allowed on the control-layer side (see // internal/values/control-layer.yaml `allowed_origins`). +// - `https://api.doubleword.ai` for the Scalar API-reference "Test Request" +// feature at /inference-api/api-reference. The OpenAPI spec's server is +// `https://api.doubleword.ai/v1`, so Scalar fires the try-it `fetch` at that +// host from the browser; without it CSP blocks the request ("Failed to +// fetch"). CORS is already allowed on the api side — docs.doubleword.ai is +// in the same `allowed_origins` list (which covers the api proxy too). // - `https://status.doubleword.ai` for the StatusWidget component, which // fetches `/api/v1/summary` from the public status page to render // live incident status inline in docs pages. @@ -35,7 +41,7 @@ function buildCsp(nonce: string): string { "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob: https://cdn.sanity.io", "font-src 'self' data:", - "connect-src 'self' https://app.doubleword.ai https://status.doubleword.ai", + "connect-src 'self' https://app.doubleword.ai https://api.doubleword.ai https://status.doubleword.ai", 'frame-src https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com', "worker-src 'self' blob:", "frame-ancestors 'none'", From 35cfc0ac0970be5cce59ddff9b65f332575da8f2 Mon Sep 17 00:00:00 2001 From: hachall Date: Wed, 10 Jun 2026 18:27:41 +0100 Subject: [PATCH 2/7] moved search build from prebuild --- AGENTS.md | 2 +- next.config.ts | 3 ++- package.json | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 63dc660..2eb00f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ All redirects live in `next.config.ts` under `async redirects()`. Add to the app - **[`doublewordai/batch-skill`](https://github.com/doublewordai/skill)** (agents skill): `SKILL.md` contains explicit links to doc pages. Grep it for the affected slug and update. - **`llms.txt`**: Auto-generated from Sanity by `src/app/llms.txt/route.ts`. No manual action, but sanity-check the output after the change. -- **Search index**: Rebuilt at build time by `scripts/build-search-index.mjs`. No manual action. +- **Search index**: Rebuilt at build time by `scripts/build-search-index.mjs`, which the `build` script runs **explicitly** before `next build` (do not move it back to a `prebuild` hook — `.npmrc` sets `ignore-scripts=true`, so npm/pnpm pre/post lifecycle hooks no longer run). The generated `public/search-index.json` is bundled into the `/api/search` serverless function via `outputFileTracingIncludes` in `next.config.ts`; reading it from `public/` at runtime fails on Vercel without that. No manual action. - **External SDK docs and blog posts**: Search the `doublewordai` org on GitHub for the old slug. Update or rely on the redirect. - **Sitemap**: Auto-generated by `src/app/sitemap.ts`. - **Marketing site / blog**: Check `doubleword.ai` and `blog.doubleword.ai` for inbound links. diff --git a/next.config.ts b/next.config.ts index 48cf9d7..6d0adf1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -32,7 +32,8 @@ const nextConfig: NextConfig = { // as CDN static assets and are NOT included in a route handler's serverless // function bundle, so the runtime read throws ENOENT and the route 500s with // an empty body. Force Next's output file tracing to bundle the generated - // index (written by the prebuild step) into the search function. + // index (written by the build script, which runs build-search-index.mjs + // before `next build`) into the search function. outputFileTracingIncludes: { '/api/search': ['./public/search-index.json'], }, diff --git a/package.json b/package.json index 83bb346..6065fe4 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "private": true, "scripts": { "dev": "next dev", - "prebuild": "node scripts/build-search-index.mjs", - "build": "next build", + "build:search-index": "node scripts/build-search-index.mjs", + "build": "node scripts/build-search-index.mjs && next build", "start": "next start", "lint": "eslint", "test": "vitest", From 7606da8952fe1ec7512c5555f7e7ef68bb031d59 Mon Sep 17 00:00:00 2001 From: hachall Date: Wed, 10 Jun 2026 18:32:29 +0100 Subject: [PATCH 3/7] error handling on sarch build --- scripts/build-search-index.mjs | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/scripts/build-search-index.mjs b/scripts/build-search-index.mjs index c88a075..a9fd54b 100644 --- a/scripts/build-search-index.mjs +++ b/scripts/build-search-index.mjs @@ -165,15 +165,28 @@ async function getModelArtifactSearchItems() { const apiKey = process.env.DOUBLEWORD_SYSTEM_API_KEY; if (!apiKey) return []; - const response = await fetch("https://app.doubleword.ai/admin/api/v1/models?include=pricing", { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json", - }, - }); - if (!response.ok) return []; + // Model pricing is optional enrichment. The Vercel build box can't always + // reach app.doubleword.ai (network egress / WAF), and a fetch failure here + // must NOT fail the whole docs build — the core index (Sanity + external + // docs) is what matters. Swallow any error and skip these entries. + let rawData; + try { + const response = await fetch("https://app.doubleword.ai/admin/api/v1/models?include=pricing", { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + }); + if (!response.ok) { + console.warn(`Skipping model artifacts: HTTP ${response.status}`); + return []; + } + rawData = await response.json(); + } catch (err) { + console.warn(`Skipping model artifacts: ${err.message}`); + return []; + } - const rawData = await response.json(); const models = rawData.data || []; const formatPricePer1M = (price) => `$${(Number(price) * 1_000_000).toFixed(2)}`; From 602284919e8c05fda0c4ab9c5d45580fd37c9578 Mon Sep 17 00:00:00 2001 From: hachall Date: Wed, 10 Jun 2026 19:04:34 +0100 Subject: [PATCH 4/7] search fix attempt --- .gitignore | 2 +- AGENTS.md | 2 +- next.config.ts | 16 ++++++++-------- scripts/build-search-index.mjs | 6 +++++- src/lib/search-index.ts | 2 +- 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 814d6d2..d4751bc 100644 --- a/.gitignore +++ b/.gitignore @@ -34,7 +34,7 @@ yarn-error.log* .env* # generated search index -/public/search-index.json +/data/search-index.json # vercel .vercel diff --git a/AGENTS.md b/AGENTS.md index 2eb00f6..d1a4585 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ All redirects live in `next.config.ts` under `async redirects()`. Add to the app - **[`doublewordai/batch-skill`](https://github.com/doublewordai/skill)** (agents skill): `SKILL.md` contains explicit links to doc pages. Grep it for the affected slug and update. - **`llms.txt`**: Auto-generated from Sanity by `src/app/llms.txt/route.ts`. No manual action, but sanity-check the output after the change. -- **Search index**: Rebuilt at build time by `scripts/build-search-index.mjs`, which the `build` script runs **explicitly** before `next build` (do not move it back to a `prebuild` hook — `.npmrc` sets `ignore-scripts=true`, so npm/pnpm pre/post lifecycle hooks no longer run). The generated `public/search-index.json` is bundled into the `/api/search` serverless function via `outputFileTracingIncludes` in `next.config.ts`; reading it from `public/` at runtime fails on Vercel without that. No manual action. +- **Search index**: Rebuilt at build time by `scripts/build-search-index.mjs`, which the `build` script runs **explicitly** before `next build` (do not move it back to a `prebuild` hook — `.npmrc` sets `ignore-scripts=true`, so npm/pnpm pre/post lifecycle hooks no longer run). The generated `data/search-index.json` is bundled into the `/api/search` serverless function via `outputFileTracingIncludes` in `next.config.ts`. It must live in `data/`, **not** `public/`: Vercel serves `public/` as static CDN assets and strips it from the function bundle, so a runtime `fs` read of `public/` 500s. No manual action. - **External SDK docs and blog posts**: Search the `doublewordai` org on GitHub for the old slug. Update or rely on the redirect. - **Sitemap**: Auto-generated by `src/app/sitemap.ts`. - **Marketing site / blog**: Check `doubleword.ai` and `blog.doubleword.ai` for inbound links. diff --git a/next.config.ts b/next.config.ts index 6d0adf1..dd10aa9 100644 --- a/next.config.ts +++ b/next.config.ts @@ -27,15 +27,15 @@ const securityHeaders = [ const nextConfig: NextConfig = { reactCompiler: true, - // The /api/search route reads public/search-index.json at runtime with fs - // (see src/lib/search-index.ts). On Vercel, files under public/ are deployed - // as CDN static assets and are NOT included in a route handler's serverless - // function bundle, so the runtime read throws ENOENT and the route 500s with - // an empty body. Force Next's output file tracing to bundle the generated - // index (written by the build script, which runs build-search-index.mjs - // before `next build`) into the search function. + // The /api/search route reads data/search-index.json at runtime with fs (see + // src/lib/search-index.ts). It lives in data/ rather than public/ on purpose: + // Vercel serves public/ as static CDN assets and strips it from the route's + // serverless function bundle, so a public/ read 500s with an empty body even + // with a tracing include. Force the generated index (written by the build + // script, which runs build-search-index.mjs before `next build`) into the + // search function's bundle. outputFileTracingIncludes: { - '/api/search': ['./public/search-index.json'], + '/api/search': ['./data/search-index.json'], }, // Apply security response headers to every route. diff --git a/scripts/build-search-index.mjs b/scripts/build-search-index.mjs index a9fd54b..ff52d54 100644 --- a/scripts/build-search-index.mjs +++ b/scripts/build-search-index.mjs @@ -15,7 +15,11 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const OUTPUT_DIR = join(__dirname, "..", "public"); +// NOT public/ — on Vercel, files under public/ are served as static CDN assets +// and are stripped from the serverless function bundle, so the search route +// (which reads this at runtime via fs) would 500. A plain data/ dir is bundled +// into the function via outputFileTracingIncludes in next.config.ts. +const OUTPUT_DIR = join(__dirname, "..", "data"); const OUTPUT_PATH = join(OUTPUT_DIR, "search-index.json"); const client = createClient({ diff --git a/src/lib/search-index.ts b/src/lib/search-index.ts index 32f906c..cdd8c2a 100644 --- a/src/lib/search-index.ts +++ b/src/lib/search-index.ts @@ -6,7 +6,7 @@ let cached: DocSearchIndexItem[] | null = null; export function loadSearchIndex(): DocSearchIndexItem[] { if (cached) return cached; - const filePath = join(process.cwd(), "public", "search-index.json"); + const filePath = join(process.cwd(), "data", "search-index.json"); const data: DocSearchIndexItem[] = JSON.parse(readFileSync(filePath, "utf-8")); cached = data; return data; From ead0ac32abc3f9aef04945a83061d1bbb086d33e Mon Sep 17 00:00:00 2001 From: hachall Date: Wed, 10 Jun 2026 19:22:02 +0100 Subject: [PATCH 5/7] fix: bundle search index via static import instead of runtime fs read --- .gitignore | 6 ++++-- AGENTS.md | 2 +- data/search-index.json | 1 + next.config.ts | 11 ----------- scripts/build-search-index.mjs | 7 +++---- src/lib/search-index.ts | 20 +++++++++++--------- 6 files changed, 20 insertions(+), 27 deletions(-) create mode 100644 data/search-index.json diff --git a/.gitignore b/.gitignore index d4751bc..4c7722f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,8 +33,10 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* -# generated search index -/data/search-index.json +# Generated search index. The committed copy is an empty `[]` placeholder so the +# static import in src/lib/search-index.ts resolves in dev / CI; `npm run build` +# (and the Vercel build) regenerate it with real content before next build. +# It is intentionally tracked — do not commit the regenerated (non-empty) copy. # vercel .vercel diff --git a/AGENTS.md b/AGENTS.md index d1a4585..ec975e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ All redirects live in `next.config.ts` under `async redirects()`. Add to the app - **[`doublewordai/batch-skill`](https://github.com/doublewordai/skill)** (agents skill): `SKILL.md` contains explicit links to doc pages. Grep it for the affected slug and update. - **`llms.txt`**: Auto-generated from Sanity by `src/app/llms.txt/route.ts`. No manual action, but sanity-check the output after the change. -- **Search index**: Rebuilt at build time by `scripts/build-search-index.mjs`, which the `build` script runs **explicitly** before `next build` (do not move it back to a `prebuild` hook — `.npmrc` sets `ignore-scripts=true`, so npm/pnpm pre/post lifecycle hooks no longer run). The generated `data/search-index.json` is bundled into the `/api/search` serverless function via `outputFileTracingIncludes` in `next.config.ts`. It must live in `data/`, **not** `public/`: Vercel serves `public/` as static CDN assets and strips it from the function bundle, so a runtime `fs` read of `public/` 500s. No manual action. +- **Search index**: Rebuilt at build time by `scripts/build-search-index.mjs`, which the `build` script runs **explicitly** before `next build` (do not move it back to a `prebuild` hook — `.npmrc` sets `ignore-scripts=true`, so npm/pnpm pre/post lifecycle hooks no longer run). The generated `data/search-index.json` is **imported statically** by `src/lib/search-index.ts` so webpack inlines it into the `/api/search` function bundle. Do **not** switch this back to a runtime `fs` read: generated files are unreliable to read from a Vercel serverless function (files in `public/` are stripped; traced files don't always land at `process.cwd()`; an edge runtime has no `fs`) — every disk-based attempt 500'd. The committed `data/search-index.json` is an empty `[]` placeholder so dev/CI resolve the import; the build overwrites it with real content. No manual action. - **External SDK docs and blog posts**: Search the `doublewordai` org on GitHub for the old slug. Update or rely on the redirect. - **Sitemap**: Auto-generated by `src/app/sitemap.ts`. - **Marketing site / blog**: Check `doubleword.ai` and `blog.doubleword.ai` for inbound links. diff --git a/data/search-index.json b/data/search-index.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/data/search-index.json @@ -0,0 +1 @@ +[] diff --git a/next.config.ts b/next.config.ts index dd10aa9..a81fdb7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -27,17 +27,6 @@ const securityHeaders = [ const nextConfig: NextConfig = { reactCompiler: true, - // The /api/search route reads data/search-index.json at runtime with fs (see - // src/lib/search-index.ts). It lives in data/ rather than public/ on purpose: - // Vercel serves public/ as static CDN assets and strips it from the route's - // serverless function bundle, so a public/ read 500s with an empty body even - // with a tracing include. Force the generated index (written by the build - // script, which runs build-search-index.mjs before `next build`) into the - // search function's bundle. - outputFileTracingIncludes: { - '/api/search': ['./data/search-index.json'], - }, - // Apply security response headers to every route. async headers() { return [ diff --git a/scripts/build-search-index.mjs b/scripts/build-search-index.mjs index ff52d54..e2aec41 100644 --- a/scripts/build-search-index.mjs +++ b/scripts/build-search-index.mjs @@ -15,10 +15,9 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); -// NOT public/ — on Vercel, files under public/ are served as static CDN assets -// and are stripped from the serverless function bundle, so the search route -// (which reads this at runtime via fs) would 500. A plain data/ dir is bundled -// into the function via outputFileTracingIncludes in next.config.ts. +// Written to data/ and imported statically by src/lib/search-index.ts (webpack +// inlines it into the /api/search function bundle). Must run before `next build` +// so the import resolves to real content rather than the committed `[]` seed. const OUTPUT_DIR = join(__dirname, "..", "data"); const OUTPUT_PATH = join(OUTPUT_DIR, "search-index.json"); diff --git a/src/lib/search-index.ts b/src/lib/search-index.ts index cdd8c2a..529d341 100644 --- a/src/lib/search-index.ts +++ b/src/lib/search-index.ts @@ -1,13 +1,15 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; import type { DocSearchIndexItem } from "@/sanity/types"; - -let cached: DocSearchIndexItem[] | null = null; +// Generated at build time by scripts/build-search-index.mjs, which the `build` +// script runs before `next build`. We import it STATICALLY (rather than reading +// it from disk at runtime) so webpack inlines the data into the /api/search +// function bundle. Runtime fs reads of a generated file are unreliable on +// Vercel — files in public/ are stripped from the function, traced files don't +// always land where process.cwd() expects, and an edge runtime has no fs at +// all. A static import sidesteps all of that. The committed file is an empty +// `[]` placeholder so dev / typecheck / lint resolve the module; the real build +// overwrites it before webpack compiles. +import searchIndex from "../../data/search-index.json"; export function loadSearchIndex(): DocSearchIndexItem[] { - if (cached) return cached; - const filePath = join(process.cwd(), "data", "search-index.json"); - const data: DocSearchIndexItem[] = JSON.parse(readFileSync(filePath, "utf-8")); - cached = data; - return data; + return searchIndex as DocSearchIndexItem[]; } From bcc8356d314a22d7832a6952d148318514d38ece Mon Sep 17 00:00:00 2001 From: hachall Date: Thu, 11 Jun 2026 10:01:22 +0100 Subject: [PATCH 6/7] better logging for issues --- scripts/build-search-index.mjs | 3 +- src/app/api/search/route.ts | 58 +++++++++++++------- src/app/control-layer/api-reference/route.ts | 7 ++- src/lib/scalar-api-reference.test.ts | 2 +- 4 files changed, 47 insertions(+), 23 deletions(-) diff --git a/scripts/build-search-index.mjs b/scripts/build-search-index.mjs index e2aec41..4f8fb93 100644 --- a/scripts/build-search-index.mjs +++ b/scripts/build-search-index.mjs @@ -186,7 +186,8 @@ async function getModelArtifactSearchItems() { } rawData = await response.json(); } catch (err) { - console.warn(`Skipping model artifacts: ${err.message}`); + const message = err instanceof Error ? err.message : String(err); + console.warn(`Skipping model artifacts: ${message}`); return []; } diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 0cabd56..ac79708 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1,6 +1,4 @@ import {NextRequest, NextResponse} from "next/server"; -import {loadSearchIndex} from "@/lib/search-index"; -import {searchDocs} from "@/lib/search"; const LIMIT_DEFAULT = 6; @@ -14,23 +12,45 @@ export async function GET(request: NextRequest) { return NextResponse.json({matches: []}); } - const allDocs = loadSearchIndex(); - const docs = productSlug - ? allDocs.filter((d) => d.productSlug === productSlug) - : allDocs; + try { + // Imported inside the handler so a module-load failure (e.g. the JSON + // import) is caught here and surfaced, rather than crashing the whole route + // module with an empty-body 500 that tells us nothing. + const {loadSearchIndex} = await import("@/lib/search-index"); + const {searchDocs} = await import("@/lib/search"); - const matches = searchDocs(docs, query) - .slice(0, limit) - .map((result) => ({ - id: result._id, - title: result.sidebarLabel || result.title, - productName: result.productName, - categoryName: result.categoryName || "", - snippet: result.snippet, - score: result.score, - href: `/${result.productSlug}/${result.slug}`, - path: `/${result.productSlug}/${result.slug}`, - })); + const allDocs = loadSearchIndex(); + const docs = productSlug + ? allDocs.filter((d) => d.productSlug === productSlug) + : allDocs; - return NextResponse.json({matches}); + const matches = searchDocs(docs, query) + .slice(0, limit) + .map((result) => ({ + id: result._id, + title: result.sidebarLabel || result.title, + productName: result.productName, + categoryName: result.categoryName || "", + snippet: result.snippet, + score: result.score, + href: `/${result.productSlug}/${result.slug}`, + path: `/${result.productSlug}/${result.slug}`, + })); + + return NextResponse.json({matches}); + } catch (err) { + // TEMPORARY diagnostic: the route has been 500ing with an empty body on + // Vercel, which hides the cause. Surface the real error in the response so + // we can see it in the Network tab. Revert to a generic 500 once fixed. + const e = err instanceof Error ? err : new Error(String(err)); + console.error("search route error:", e); + return NextResponse.json( + { + error: e.name, + message: e.message, + stack: e.stack?.split("\n").slice(0, 8), + }, + {status: 500}, + ); + } } diff --git a/src/app/control-layer/api-reference/route.ts b/src/app/control-layer/api-reference/route.ts index e558c41..f866549 100644 --- a/src/app/control-layer/api-reference/route.ts +++ b/src/app/control-layer/api-reference/route.ts @@ -7,8 +7,11 @@ import { withCspNonce } from "@/lib/scalar-api-reference"; export const GET = withCspNonce( ApiReference({ url: "/api/control-layer-openapi", - // See inference-api/api-reference: avoid the Google Fonts load blocked by - // our `font-src 'self' data:` CSP. + // Scalar loads its default web fonts from Google Fonts for BOTH the + // Inference and Control Layer references — independent of the Test Request + // button (hidden here) — and our `font-src 'self' data:` CSP blocks them. + // Use the system font stack instead of allowlisting an external font host. + // Keep this even though hideTestRequestButton is true. withDefaultFonts: false, metaData: { title: "API Reference | Control Layer | Doubleword Docs", diff --git a/src/lib/scalar-api-reference.test.ts b/src/lib/scalar-api-reference.test.ts index 5b7ad34..f834c8d 100644 --- a/src/lib/scalar-api-reference.test.ts +++ b/src/lib/scalar-api-reference.test.ts @@ -96,6 +96,6 @@ describe("Scalar API-reference routes opt out of external fonts", () => { ["control-layer", controlLayerApiReference], ])("%s reference disables Scalar's default fonts", async (_name, handler) => { const html = await (await handler()).text(); - expect(html).toContain('"withDefaultFonts": false'); + expect(html).toMatch(/"withDefaultFonts"\s*:\s*false/); }); }); From 6dea9a2449aa07cec823a5951946342365dfb73a Mon Sep 17 00:00:00 2001 From: hachall Date: Thu, 11 Jun 2026 10:14:48 +0100 Subject: [PATCH 7/7] fixed problematic function call --- scripts/build-search-index.mjs | 22 ++++++++++++++++++- src/app/api/search/route.ts | 24 +++++---------------- src/lib/search.test.ts | 39 ++++++++++++++++++++++++++++++++++ src/lib/search.ts | 5 ++++- 4 files changed, 69 insertions(+), 21 deletions(-) create mode 100644 src/lib/search.test.ts diff --git a/scripts/build-search-index.mjs b/scripts/build-search-index.mjs index 4f8fb93..bc7317a 100644 --- a/scripts/build-search-index.mjs +++ b/scripts/build-search-index.mjs @@ -73,6 +73,26 @@ async function fetchExternalContent(url) { } } +// Sanity bodies are Portable Text (an array of blocks), not strings. The search +// index must store plain strings — the runtime ranking calls .replace() on body +// (see src/lib/search.ts), and an array there throws. Flatten blocks to text; +// pass strings through unchanged; anything else becomes "". +function toPlainText(value) { + if (typeof value === "string") return value; + if (!Array.isArray(value)) return ""; + return value + .map((block) => { + if (!block || block._type !== "block" || !Array.isArray(block.children)) { + return ""; + } + return block.children + .map((child) => (typeof child?.text === "string" ? child.text : "")) + .join(""); + }) + .filter(Boolean) + .join("\n"); +} + async function resolveBody(doc) { if (doc.externalSource) { const content = await fetchExternalContent(doc.externalSource); @@ -249,7 +269,7 @@ async function main() { ); if (needsFetch) externalFetches++; - const body = await resolveBody(doc); + const body = toPlainText(await resolveBody(doc)); if (needsFetch && !body) failures++; return { diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index ac79708..574b95d 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1,4 +1,6 @@ import {NextRequest, NextResponse} from "next/server"; +import {loadSearchIndex} from "@/lib/search-index"; +import {searchDocs} from "@/lib/search"; const LIMIT_DEFAULT = 6; @@ -13,12 +15,6 @@ export async function GET(request: NextRequest) { } try { - // Imported inside the handler so a module-load failure (e.g. the JSON - // import) is caught here and surfaced, rather than crashing the whole route - // module with an empty-body 500 that tells us nothing. - const {loadSearchIndex} = await import("@/lib/search-index"); - const {searchDocs} = await import("@/lib/search"); - const allDocs = loadSearchIndex(); const docs = productSlug ? allDocs.filter((d) => d.productSlug === productSlug) @@ -39,18 +35,8 @@ export async function GET(request: NextRequest) { return NextResponse.json({matches}); } catch (err) { - // TEMPORARY diagnostic: the route has been 500ing with an empty body on - // Vercel, which hides the cause. Surface the real error in the response so - // we can see it in the Network tab. Revert to a generic 500 once fixed. - const e = err instanceof Error ? err : new Error(String(err)); - console.error("search route error:", e); - return NextResponse.json( - { - error: e.name, - message: e.message, - stack: e.stack?.split("\n").slice(0, 8), - }, - {status: 500}, - ); + // Don't let a single malformed doc take down search with an opaque 500. + console.error("search route error:", err); + return NextResponse.json({error: "search failed"}, {status: 500}); } } diff --git a/src/lib/search.test.ts b/src/lib/search.test.ts new file mode 100644 index 0000000..9cd52a2 --- /dev/null +++ b/src/lib/search.test.ts @@ -0,0 +1,39 @@ +import {describe, expect, it} from "vitest"; +import {stripMarkdown, searchDocs} from "./search"; +import type {DocSearchIndexItem} from "@/sanity/types"; + +describe("stripMarkdown", () => { + it("strips markdown from strings", () => { + expect(stripMarkdown("# Title\n`code` **bold**")).toBe("Title code bold"); + }); + + it("returns '' for non-string input instead of throwing", () => { + // Sanity bodies are Portable Text (arrays). Before the guard, this threw + // `e.replace is not a function` and 500'd the whole /api/search request. + expect(stripMarkdown([{_type: "block"}] as unknown as string)).toBe(""); + expect(stripMarkdown(undefined as unknown as string)).toBe(""); + expect(stripMarkdown({} as unknown as string)).toBe(""); + }); +}); + +describe("searchDocs", () => { + it("does not throw when a doc body is Portable Text (non-string)", () => { + const docs = [ + { + _id: "1", + title: "Batch inference", + // Portable Text array, exactly what Sanity stores — not a string. + body: [ + {_type: "block", children: [{_type: "span", text: "about batches"}]}, + ], + slug: "batch-inference", + productSlug: "inference-api", + productName: "Inference API", + }, + ] as unknown as DocSearchIndexItem[]; + + expect(() => searchDocs(docs, "batch")).not.toThrow(); + const results = searchDocs(docs, "batch"); + expect(results[0]?._id).toBe("1"); + }); +}); diff --git a/src/lib/search.ts b/src/lib/search.ts index e98cf14..32af1bc 100644 --- a/src/lib/search.ts +++ b/src/lib/search.ts @@ -6,7 +6,10 @@ export function normalize(text: string): string { return text.toLowerCase().trim(); } -export function stripMarkdown(markdown: string): string { +export function stripMarkdown(markdown: unknown): string { + // Defensive: the index can carry non-string bodies (e.g. Sanity Portable Text + // arrays). Never let that crash search — coerce anything non-string to "". + if (typeof markdown !== "string") return ""; return markdown .replace(/```[\s\S]*?```/g, " ") .replace(/`[^`]*`/g, " ")