npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

searchsocket

v1.0.0

Published

Semantic site search and MCP retrieval for SvelteKit static sites

Readme

SearchSocket

npm version ci license

Semantic site search and MCP retrieval for SvelteKit content projects. Index your site, search it from the browser or AI tools, and scroll users to the exact content they're looking for.

Requirements: Node.js >= 22.12 | Backend: Upstash Vector | License: MIT

How it works

SvelteKit Pages → Extractor (Cheerio + Turndown) → Chunker → Upstash Vector
                                                                    ↓
                    Search UI ← SvelteKit API Hook ← Search Engine + Ranking
                                       ↓
                              MCP Endpoint → Claude Code / Claude Desktop

SearchSocket extracts content from your SvelteKit site, converts it to markdown, splits it into chunks, and stores them in Upstash Vector. At runtime, the SvelteKit hook serves both a search API for your frontend and an MCP endpoint for AI tools.

Features

  • Semantic + keyword search — an Upstash Vector hybrid index runs the dense and sparse queries and fuses their rankings with DBSF. There is no separate reranking model and no query-enrichment step
  • Page-first search — pages are ranked first, then the best-matching sections within the top pages are attached as sub-results
  • Scroll-to-text — auto-scroll to the matching section when a user clicks a search result, with CSS Highlight API and Text Fragment support
  • SvelteKit integration — server hook for the search API, Vite plugin for build-triggered indexing
  • Svelte 5 components — reactive createSearch store and <SearchSocket> metadata component (Svelte 5.20 or newer)
  • MCP server — three tools for Claude Code, Claude Desktop, and other MCP clients (stdio + HTTP)
  • llms.txt generation — auto-generate LLM-friendly site indexes during indexing
  • Four source modes — index from static output, build manifest, a running server, or raw markdown files
  • CLI — init, index, search, dev, status, doctor, clean, prune, test, mcp, add

Install

pnpm add searchsocket

A regular dependency is what the quickstart below needs: step 4 wires searchsocketHandle() into src/hooks.server.ts, which runs in your deployed server, not at build time.

The dev-dependency install is the narrower case — you only index at build time and never mount the hook (no search API, no MCP endpoint, e.g. a static site whose frontend queries a search API you host elsewhere):

pnpm add -D searchsocket

Prereleases publish to the next dist-tag; stable releases to latest:

npm install searchsocket@next

Quickstart

1. Initialize

pnpm searchsocket init

Creates searchsocket.config.ts, the .searchsocket/ state directory, wires up your SvelteKit hooks and Vite config, and generates .mcp.json for Claude Code.

2. Configure

Minimal config (searchsocket.config.ts):

export default {
  project: {
    id: "my-site",              // namespaces your records in the vector index
    baseUrl: "https://example.com"
  },
  source: {
    mode: "static-output"       // or "build" | "crawl" | "content-files"
  }
};

Defaults handle everything else, and SearchSocket reads UPSTASH_VECTOR_REST_URL and UPSTASH_VECTOR_REST_TOKEN from your environment automatically.

Set project.id and source.mode explicitly rather than leaving them out. Both are inferred when absent — project.id from package.json's name (falling back to the directory name), source.mode from whether the static output directory exists on disk. That inference works on your machine and in CI, where the checkout is right there. A deployed server bundle has neither the checkout nor package.json at the path the loader expects, so searchsocketHandle() would resolve a different project id than the one you indexed under, or fail outright on source-mode detection. Explicit values are what make the runtime hook work off a checkout.

3. Set environment variables

# .env
UPSTASH_VECTOR_REST_URL=https://...
UPSTASH_VECTOR_REST_TOKEN=...

Create an Upstash Vector index with the bge-large-en-v1.5 embedding model (1024 dimensions). Copy the REST URL and token.

4. Add the SvelteKit hook

The init command does this for you, but if you need to do it manually:

// src/hooks.server.ts
import { searchsocketHandle } from "searchsocket/sveltekit";

export const handle = searchsocketHandle();

This exposes POST /api/search, GET /api/search and GET /api/search/health, plus page retrieval routes.

It also mounts the MCP route at /api/mcp when mcp.enable is on (it defaults to NODE_ENV !== "production"), but that route fails closed: until you set mcp.handle.apiKey/mcp.handle.apiKeyEnv, or opt into mcp.handle.access: "public", every MCP call answers 503. (Only POST carries a call; GET and DELETE are 405 whatever the key says.) See MCP Server for the configuration.

The served API path comes from api.path (default /api/search). You can override it per-hook with searchsocketHandle({ path: "/search-api" }) — if you do, set api.path in your config to the same value, because indexing-time output such as llms.txt reads the config, not the hook option.

searchsocket/sveltekit is server-only. It pulls in the indexing pipeline, the MCP SDK, and Node builtins (fs/promises, child_process, crypto, net, zlib), so importing it from browser code fails the Vite browser build on createRequire. The browser-safe entry points are searchsocket/client (the search client) and searchsocket/scroll (searchsocketScrollToText); neither imports a Node builtin.

If your app already exports a handle, compose the two with sequence() and keep your handle first:

// src/hooks.server.ts
import { sequence } from "@sveltejs/kit/hooks";
import { searchsocketHandle } from "searchsocket/sveltekit";

export const handle = sequence(yourExistingHandle, searchsocketHandle());

Order matters. searchsocketHandle() answers its own endpoints without calling resolve(), so anything sequenced after it never sees those requests. With your handle outer, every SearchSocket route passes through your auth, logging and header middleware first — which also means a handle that short-circuits a request blocks the search API, the MCP endpoint, CORS preflight, GET /api/search/health and llms.txt along with it. Exempt those paths in your own handle if that is not what you want.

searchsocket init writes this composition for a fresh install. On an existing install it does not: if hooks.server.ts already mentions searchsocketHandle it reports "already present" and changes nothing, so an install that has the two handles in the wrong order has to be reordered by hand.

If you run into SSR bundling issues, mark SearchSocket as external in your Vite config:

// vite.config.ts
export default defineConfig({
  plugins: [sveltekit()],
  ssr: {
    external: ["searchsocket", "searchsocket/sveltekit", "searchsocket/client", "searchsocket/scroll"]
  }
});

5. Add search to your frontend

Copy the search dialog template into your project:

pnpm searchsocket add search-dialog
pnpm searchsocket add search-trigger

add copies one component per invocation, so the trigger button used below needs its own command. This copies a styled Svelte 5 command palette (the templates use $props.id(), so they need Svelte 5.20 or newer) into src/lib/components/search/, alongside its stylesheet and helpers. It needs no CSS framework — Tailwind optional — and follows your app's light/dark theme by default. Import it in your layout and add the scroll-to-text handler:

<!-- src/routes/+layout.svelte -->
<script>
  import { afterNavigate } from "$app/navigation";
  import { searchsocketScrollToText } from "searchsocket/scroll";
  import SearchDialog from "$lib/components/search/SearchDialog.svelte";
  import SearchTrigger from "$lib/components/search/SearchTrigger.svelte";

  let searchOpen = $state(false);
  afterNavigate(searchsocketScrollToText);
</script>

<SearchTrigger bind:open={searchOpen} />
<SearchDialog bind:open={searchOpen} />

<slot />

Brand it by overriding a few semantic CSS variables — no need to touch the component:

<SearchDialog
  bind:open={searchOpen}
  label="Search documentation"
  pathPrefix="/docs"
  topK={10}
  style="--ss-search-accent: #0f766e; --ss-search-radius: 20px"
/>

See docs/search-ui.md for the full token list, prop tables, and theme modes.

Users can now press Cmd+K to search. See Building a Search UI for scoped search, custom styling, and more patterns.

6. Deploy

SearchSocket is designed to index automatically on deploy. The init command already added the Vite plugin to your config. Set these environment variables on your hosting platform (Vercel, Cloudflare, etc.):

| Variable | Value | |----------|-------| | UPSTASH_VECTOR_REST_URL | Your Upstash Vector REST URL | | UPSTASH_VECTOR_REST_TOKEN | Your Upstash Vector REST token | | SEARCHSOCKET_AUTO_INDEX | 1 |

Every deploy will build your site, index the content, and serve the search API — fully automated.

For local testing, you can also build and index manually:

pnpm build
pnpm searchsocket index
pnpm searchsocket search "getting started"   # confirm the index answers

With that index in place, pnpm dev and Cmd+K give you a working search.

7. Connect Claude Code (optional)

searchsocket init already wrote a .mcp.json that runs the index locally over stdio, so Claude Code works without deploying anything. To connect to your deployed site instead, the route has to be enabled in production and given a key (or opened to anonymous callers deliberately); see MCP Server for both setups.

Querying the API directly

The search API is also available via HTTP and CLI:

# cURL
curl -X POST http://localhost:5173/api/search \
  -H "content-type: application/json" \
  -d '{"q":"getting started","topK":5,"groupBy":"page"}'

# CLI
pnpm searchsocket search "getting started" --top-k 5

Response format

With groupBy: "page" (the default):

{
  "q": "getting started",
  "scope": "main",
  "results": [
    {
      "url": "/docs/intro",
      "title": "Getting Started",
      "sectionTitle": "Installation",
      "snippet": "Install SearchSocket with pnpm add searchsocket...",
      "score": 0.89,
      "chunks": [
        {
          "sectionTitle": "Installation",
          "snippet": "Install SearchSocket with pnpm add searchsocket...",
          "headingPath": ["Getting Started", "Installation"],
          "score": 0.89
        },
        {
          "sectionTitle": "Configuration",
          "snippet": "Create searchsocket.config.ts with your API key...",
          "headingPath": ["Getting Started", "Configuration"],
          "score": 0.74
        }
      ]
    }
  ],
  "meta": {
    "timingsMs": { "search": 128, "total": 135 }
  }
}

Both meta.timingsMs.search and meta.timingsMs.total are required. They belong to the response envelope, which the browser client rejects outright when it is malformed (bad individual rows are dropped instead), so a custom endpoint or a test fixture that omits search surfaces as Invalid search response.

The chunks array contains matching sections within each page. Use groupBy: "chunk" for flat per-chunk results without page aggregation.

HTTP API reference

GET <api.path> takes the query as parameters; POST <api.path> takes the same fields as a JSON object body, plus filters (structured metadata, which has no GET spelling).

| Parameter | Type | Notes | |---|---|---| | q | string | Required. Empty or missing is a 400 | | topK | integer | 1–100 | | maxSubResults | integer | 1–20 | | groupBy | "page" | "chunk" | Default "page" | | pathPrefix | string | Scope results to a path | | scope | string | Index scope | | tags | string (GET) / string[] (POST) | The only parameter that may repeat on GET; a JSON array in a POST body |

curl "http://localhost:5173/api/search?q=getting+started&topK=5&tags=guide&tags=api"

Rules the endpoint enforces:

  • topK and maxSubResults must be whole integers on GET. 10abc, 1.5, 1e3, 0x10, zero and negatives are all 400 — no prefix-parsing.
  • Repeating a singleton parameter (q, topK, scope, pathPrefix, groupBy, maxSubResults) is a 400, identical values included. The same rule applies to ?scope= on the page-retrieval route.
  • debug and rankingOverrides are refused with 403 on GET and POST alike, for being present at all — the value is irrelevant. They stay on the engine's own API, which the CLI and the local playground call directly.
  • GET <api.path>/health returns 200 only when the engine reports healthy, 503 when it does not. The body keeps its existing shape — { "ok": true }, or { "ok": false, "details": "..." } — so only the status changed.
  • Rate-limited responses are 429 and carry Retry-After, exposed to browser JavaScript via Access-Control-Expose-Headers when CORS is on. Error responses carry cache-control: no-store.
  • A record whose score cannot be read as a number is dropped from the results rather than serialized. The browser client would drop such a row anyway; removing it on the server keeps every consumer of the API, not only the bundled client, from seeing it.

Source Modes

SearchSocket supports four ways to load your site content for indexing.

static-output (default)

Reads prerendered HTML files from SvelteKit's build output directory.

export default {
  source: {
    mode: "static-output",
    staticOutputDir: "build"   // default
  }
};

Best for fully prerendered sites. Run vite build first, then searchsocket index.

build

Discovers routes from SvelteKit's build manifest and renders via an ephemeral vite preview server. No manual route lists needed.

export default {
  source: {
    mode: "build",
    build: {
      exclude: ["/api/*", "/admin/*"],
      paramValues: {
        "/blog/[slug]": ["hello-world", "getting-started"],
        "/docs/[category]/[page]": ["guides/quickstart", "api/search"]
      },
      discover: true,        // crawl internal links to find more pages
      seedUrls: ["/"],
      maxPages: 200,
      maxDepth: 5
    }
  }
};

Best for CI/CD pipelines: vite build && searchsocket index with zero route configuration.

crawl

Fetches pages from a running HTTP server.

export default {
  source: {
    mode: "crawl",
    crawl: {
      baseUrl: "http://localhost:4173",
      routes: ["/", "/docs", "/blog"],
      sitemapUrl: "https://example.com/sitemap.xml"
    }
  }
};

content-files

Reads markdown and Svelte source files directly, without building or serving.

export default {
  source: {
    mode: "content-files",
    contentFiles: {
      globs: ["src/routes/**/*.md", "content/**/*.md"],
      baseDir: "."
    }
  }
};

Raw .svelte files are read as source, never rendered, so exclusion signals are read statically. A <SearchSocket noindex />, an ignore attribute, or weight={0} with a literal value is honoured. When a recognised protective signal is present but not statically decidable — the value is an expression, the element sits inside an {#if}, or the props are spread over an element the scanner can tie to a protective attribute — the page is withheld and a warning is logged. Reading an undecidable protective signal as "false" would publish a page someone asked to hold back, so the strict direction is deliberate.

Known gaps, all pinned by tests: content inside {@html} and {@render} is not extracted; signals in a +layout.svelte do not propagate to the pages beneath it; a conditional or expression-valued data-search-ignore drops that subtree without withholding the page; and a spread that hides a protective attribute the scanner cannot see in the source text is not detected. Use the static-output or build source mode when a page's exclusion depends on anything computed at runtime.

Client Library

createSearchClient(options?)

Lightweight browser-side search client.

import { createSearchClient } from "searchsocket/client";

const client = createSearchClient({
  endpoint: "/api/search",   // default
  fetchImpl: fetch            // override for SSR or testing
});

const { results } = await client.search({
  q: "deployment guide",
  topK: 8,
  groupBy: "page",
  pathPrefix: "/docs",
  tags: ["guide"],
  filters: { version: 2 },
  maxSubResults: 3
});

The client validates the response before returning it. A broken envelope — no q or scope, a results value that is not an array, or a meta.timingsMs missing search or total — is rejected with Invalid search response. Individual rows are handled more narrowly: a malformed result (a non-finite score, say) or one whose URL is not safe to navigate to is dropped, as is a malformed chunk within a result, and the rest of the response is returned. createSearch in searchsocket/svelte applies the same rules. To validate a response you fetched yourself, searchsocket/client exports parseSearchResponse (the cleaned response, or null for a broken envelope) and isSearchResponse, a strict guard that fails if any row would be dropped. The client also strips debug and rankingOverrides before sending, so type-correct code that names them does not come back as a 403.

buildResultUrl(result)

Builds a URL from a search result that includes scroll-to-text metadata:

  • _ssk query parameter — section title for SvelteKit client-side navigation
  • _sskt query parameter — text target snippet for precise scroll
  • #:~:text=Text Fragment for native browser scroll on full page loads
import { buildResultUrl } from "searchsocket/client";

const href = buildResultUrl(result);
// "/docs/getting-started?_ssk=Installation&_sskt=Install+with+pnpm#:~:text=Install%20with%20pnpm"

isSafeResultUrl(value)

Whether a result URL is safe to render as an href or hand to a router. javascript:, data:, URLs carrying control characters, and anything the URL parser cannot resolve are refused; ordinary relative, protocol-relative and http(s) URLs pass. Exported from both searchsocket/client and searchsocket/svelte.

buildResultUrl() returns "" for an unsafe result, and the shipped SearchResults template renders such a row as a non-navigable span rather than a link. Treat an empty string as "do not link this".

Svelte 5 Integration

createSearch(options?)

A reactive search store built on Svelte 5 runes with debouncing and LRU caching.

<script>
  import { createSearch } from "searchsocket/svelte";
  import { buildResultUrl } from "searchsocket/client";

  const search = createSearch({
    endpoint: "/api/search",
    debounce: 250,            // ms (default)
    cache: true,              // LRU result caching (default)
    cacheSize: 50,            // max cached queries (default)
    topK: 10,
    groupBy: "page",
    pathPrefix: "/docs"       // scope search to a section
  });
</script>

<input bind:value={search.query} placeholder="Search docs..." />

{#if search.loading}
  <p>Searching...</p>
{/if}

{#if search.error}
  <p class="error">{search.error.message}</p>
{/if}

{#each search.results as result}
  <a href={buildResultUrl(result)}>
    <strong>{result.title}</strong>
    {#if result.sectionTitle}
      <span>— {result.sectionTitle}</span>
    {/if}
  </a>
  <p>{result.snippet}</p>
{/each}

Call search.destroy() when the search is no longer needed. This is not automatic: createSearch() uses $effect.root, which returns a teardown function rather than registering one with the surrounding component. In a component, call it from onDestroy:

<script>
  import { onDestroy } from "svelte";
  import { createSearch } from "searchsocket/svelte";

  const search = createSearch();
  onDestroy(search.destroy);
</script>

<SearchSocket> component

Declarative meta tag component for controlling per-page search behavior:

<script>
  import { SearchSocket } from "searchsocket/svelte";
</script>

<!-- Boost this page's search ranking -->
<SearchSocket weight={1.2} />

<!-- Exclude from search -->
<SearchSocket noindex />

<!-- Add filterable tags -->
<SearchSocket tags={["guide", "advanced"]} />

<!-- Add structured metadata (filterable via search API) -->
<SearchSocket meta={{ version: 2, category: "api" }} />

The component renders <meta> tags in <svelte:head> that SearchSocket reads during indexing.

Template components

Copy ready-made search UI components into your project:

pnpm searchsocket add search-dialog     # Cmd+K command palette
pnpm searchsocket add search-input      # inline field with a dropdown
pnpm searchsocket add search-results    # standalone result list
pnpm searchsocket add search-trigger    # the button that opens the dialog

Each command writes a self-contained kit to src/lib/components/search/ (configurable via --dir): the component, a shared SearchResultRow.svelte, pure helpers in search-ui.ts, and search-theme.css. You own the Svelte 5 source — edit it freely, it is never updated under you. It is not standalone, though: the shared helpers import types from searchsocket, and the interactive components import createSearch from searchsocket/svelte and createSearchClient/buildResultUrl from searchsocket/client. The package stays a dependency; what you own is the markup and the styling.

The default is styled with plain CSS and semantic --ss-search-* variables, so it works with or without Tailwind and looks finished without edits. Existing files are never overwritten without --overwrite, so adding a second component keeps any changes you made to the shared files.

Scroll-to-Text Navigation

When a user clicks a search result, SearchSocket scrolls them to the matching section on the destination page.

Setup

Add the scroll handler to your root layout:

<!-- src/routes/+layout.svelte -->
<script>
  import { afterNavigate } from '$app/navigation';
  import { searchsocketScrollToText } from 'searchsocket/scroll';

  afterNavigate(searchsocketScrollToText);
</script>

How it works

  1. buildResultUrl() encodes the section title and text snippet into the URL
  2. On SvelteKit client-side navigation, the afterNavigate hook reads _ssk/_sskt params
  3. A TreeWalker-based text mapper finds the exact position in the DOM
  4. The page scrolls smoothly to the match
  5. The matching text is highlighted using the CSS Custom Highlight API (with a DOM fallback for older browsers)
  6. On full page loads, browsers that support Text Fragments (#:~:text=) handle scrolling natively

The highlight fades after 2 seconds. Customize with CSS:

::highlight(ssk-search-match) {
  background-color: rgba(250, 204, 21, 0.4);
}

Search & Ranking

Page-first search

SearchSocket searches page-first: one query ranks page summaries, then the best-matching sections within the top pages are retrieved and attached as sub-results. This keeps results coherent at page level while still pointing at the exact section that matched.

Section lookups are bounded — only the top pages are expanded, and those requests run through a concurrency limit — so a large topK cannot fan out into one backend request per result.

Page weights

A page's weight multiplies its final score. It comes from the page itself — <meta name="searchsocket-weight" content="1.5"> or searchsocket.weight in frontmatter — falling back to a ranking.pageWeights pattern. A weight of 0 from either source excludes the page entirely, so an operator can suppress pages regardless of what their markup asks for.

Ranking configuration

export default {
  ranking: {
    enableIncomingLinkBoost: true,    // boost pages with more internal links pointing to them
    enableDepthBoost: true,           // boost shallower pages (/ > /docs > /docs/api)
    enableFreshnessBoost: false,      // boost recently published content
    enableAnchorTextBoost: false,     // boost pages whose link text matches the query

    pageWeights: {                    // per-URL score multipliers. Patterns, not raw
                                      // prefixes: "/docs" is an exact match, "/docs/*"
                                      // is one level down, "/docs/**" is the subtree
      "/": 0.95,
      "/docs": 1.15,
      "/download": 1.05
    },

    minScoreRatio: 0.70,             // drop results scoring below 70% of the top result
    scoreGapThreshold: 0.4,          // page mode only: cut the list at the first
                                     // adjacent pair whose score drops 40% or more

    weights: {
      incomingLinks: 0.05,
      depth: 0.03,
      titleMatch: 0.15,
      freshness: 0.1,
      anchorText: 0.10
    }
  }
};

Use gentle pageWeights values (0.9–1.2) since they compound with other boosts.

Build-Triggered Indexing

The recommended workflow is to index automatically on every deploy. Add the Vite plugin to your config:

// vite.config.ts
import { sveltekit } from "@sveltejs/kit/vite";
import { searchsocketVitePlugin } from "searchsocket/sveltekit";

export default {
  plugins: [
    sveltekit(),
    searchsocketVitePlugin({
      changedOnly: true,    // incremental indexing (default)
      verbose: true
    })
  ]
};

By default the plugin skips indexing with a warning when no Upstash credentials are configured, so a contributor without secrets can still run a build. On a deploy build that is usually the wrong trade — the site ships with a stale index and nothing fails. Pass allowUnconfigured: false to turn the missing backend into a build failure. It only bites when auto-indexing actually runs — the SSR build pass, with enabled: true or SEARCHSOCKET_AUTO_INDEX set — so a deploy build usually wants both:

searchsocketVitePlugin({
  enabled: true,             // or set SEARCHSOCKET_AUTO_INDEX=1 in the deploy env
  allowUnconfigured: false   // fail the build instead of skipping indexing
})

Vercel / Cloudflare / Netlify

Set these environment variables in your hosting platform:

| Variable | Value | |----------|-------| | UPSTASH_VECTOR_REST_URL | Your Upstash Vector REST URL | | UPSTASH_VECTOR_REST_TOKEN | Your Upstash Vector REST token | | SEARCHSOCKET_AUTO_INDEX | 1 |

Every deploy will build your site, index the content into Upstash, and serve the search API and MCP endpoint — fully automated.

Environment variable control

# Enable indexing on build
SEARCHSOCKET_AUTO_INDEX=1 pnpm build

# Disable temporarily
SEARCHSOCKET_DISABLE_AUTO_INDEX=1 pnpm build

# Force full rebuild (ignore incremental cache)
SEARCHSOCKET_FORCE_REINDEX=1 pnpm build

Making Images Searchable

SearchSocket converts images to text during extraction using this priority chain:

  1. data-search-description on the <img> — your explicit description
  2. data-search-description on the parent <figure>
  3. alt text + <figcaption> combined
  4. alt text alone (filters generic words like "image", "icon")
  5. <figcaption> alone
  6. Removed — images with no useful text are dropped
<img
  src="/screenshots/settings.png"
  alt="Settings page"
  data-search-description="The settings page showing API key configuration, theme selection, and notification preferences"
/>

Works with SvelteKit's enhanced:img:

<enhanced:img
  src="./screenshots/dashboard.png"
  alt="Dashboard"
  data-search-description="Main dashboard showing active projects and indexing status"
/>

MCP Server

SearchSocket includes an MCP server that gives Claude Code, Claude Desktop, and other MCP clients direct access to your site's search index. The MCP endpoint is built into searchsocketHandle() — once your site is deployed, any MCP client can connect to it over HTTP.

Available tools

| Tool | Description | |------|-------------| | search | Semantic search with filtering and section sub-results | | get_page | Retrieve a page's indexed markdown and frontmatter | | get_related_pages | Find related pages by links, semantics, and structure |

Connecting to your deployed site

The recommended setup is to connect Claude Code to your deployed site's MCP endpoint. This way the index stays up to date automatically as you deploy, and there's no local process to manage.

The route is off by default in production and, once on, fails closed. Its tools return repository paths, a page's indexed markdown, and any scope the caller names, so every MCP call answers 503 until you configure a key or opt into anonymous access. mcp.enable defaults to NODE_ENV !== "production", so a deployed site must set it explicitly.

Private (API key). Your own agents get full results; anyone without the key gets a 401. Enable the route in searchsocket.config.ts:

import type { SearchSocketConfig } from "searchsocket";

export default {
  project: { id: "my-site", baseUrl: "https://your-site.com" },
  source: { mode: "build" },
  mcp: {
    enable: true, // required: the default is off in production
    handle: {
      access: "private", // default
      // Read from the environment rather than committing the key.
      apiKeyEnv: "SEARCHSOCKET_MCP_API_KEY"
    }
  }
} satisfies SearchSocketConfig;

Pass the whole config to the hook, so it is bundled with the server (serverless hosts cannot read searchsocket.config.ts from disk at runtime):

// src/hooks.server.ts
import { searchsocketHandle } from "searchsocket/sveltekit";
import config from "../searchsocket.config";

export const handle = searchsocketHandle({ rawConfig: config });

Set SEARCHSOCKET_MCP_API_KEY in the deployment environment (for example openssl rand -hex 32), then send the same key from .mcp.json:

{
  "mcpServers": {
    "searchsocket": {
      "type": "http",
      "url": "https://your-site.com/api/mcp",
      "headers": {
        "Authorization": "Bearer ${SEARCHSOCKET_MCP_API_KEY}"
      }
    }
  }
}

The ${SEARCHSOCKET_MCP_API_KEY} syntax references an environment variable so you don't hardcode secrets in .mcp.json.

Anonymous public. For public documentation, set access: "public" instead:

mcp: {
  enable: true,
  handle: {
    access: "public",
    // Optional: a caller presenting this key still gets full results.
    apiKeyEnv: "SEARCHSOCKET_MCP_API_KEY"
  }
}

The hook is unchanged, and .mcp.json needs no headers:

{
  "mcpServers": {
    "searchsocket": {
      "type": "http",
      "url": "https://your-site.com/api/mcp"
    }
  }
}

Anonymous callers get the same redacted result set the browser API returns — routeFile, chunkText and breakdown stripped, any scope argument ignored. A wrong key is still a 401. This is a deliberate opt-in, not a fallback. The full walkthrough is in docs/mcp-claude-code.md.

Auto-approving in Claude Code

Skip the approval prompt each time a tool is called:

{
  "allowedMcpServers": [
    { "serverName": "searchsocket" }
  ]
}

Add this to .claude/settings.json in your project.

Local development

During local development, you can point to your dev server instead:

{
  "mcpServers": {
    "searchsocket": {
      "type": "http",
      "url": "http://localhost:5173/api/mcp"
    }
  }
}

Claude Desktop

Add to ~/Library/Application Support/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "searchsocket": {
      "command": "npx",
      "args": ["searchsocket", "mcp"],
      "cwd": "/path/to/your/project"
    }
  }
}

Standalone HTTP server

Run the MCP server as a standalone process (outside SvelteKit):

pnpm searchsocket mcp --transport http --port 3338

llms.txt Generation

Generate llms.txt files during indexing — a standardized way to make your site content available to LLMs.

export default {
  project: {
    baseUrl: "https://example.com"
  },
  llmsTxt: {
    enable: true,
    title: "My Project",
    description: "Documentation for My Project",
    outputPath: "static/llms.txt",    // default
    generateFull: true,                // also generate llms-full.txt
    serveMarkdownVariants: false       // serve /page.md variants via the hook
  }
};

Both files open with a short Retrieval API section, so an agent that finds the file can query the index instead of crawling the page list. It names the search endpoint (always — the route needs no credential), and the MCP endpoint only when that endpoint would actually answer: MCP enabled, and either mcp.handle.access: "public" or an API key that resolves at index time.

The advertised URLs come from api.path and mcp.handle.path in your config, read at index time. If you override the served path with searchsocketHandle({ path }), set api.path to the same value or the file will advertise a URL your site does not serve.

After indexing, llms.txt (page index with links) and llms-full.txt (full content) are written to your static directory. searchsocketHandle() serves the file at llmsTxt.outputPath directly; llms-full.txt has no handler of its own and is served as a static asset, so it has to be in the deployed output. If your adapter copies static/ before indexing runs, generate these ahead of the adapter step or serve them from your own route.

These are published artifacts with no second copy to recover from, so they are only replaced by a run that held the complete page list. A truncated crawl, a failed extraction, or a refused deletion plan leaves the existing files alone and logs the reason. A chunk limit does not freeze them — it truncates no pages.

There is one sharp edge worth knowing about. If the index holds custom records and a run is not passed customRecords, those pages are absent from the run's page list even though they are still in the index, so publishing would silently drop them from the public list. The exports are therefore left as they are, and the warning says exactly that: pass customRecords to refresh them. Passing customRecords: [] explicitly asserts there are none.

CLI Commands

searchsocket init

Initialize config and state directory. Creates searchsocket.config.ts, .searchsocket/, .mcp.json, and wires up your hooks and Vite config.

pnpm searchsocket init
pnpm searchsocket init --non-interactive

searchsocket index

Index content into Upstash Vector.

pnpm searchsocket index                    # incremental (default: --changed-only)
pnpm searchsocket index --force            # full re-index
pnpm searchsocket index --source build     # override source mode
pnpm searchsocket index --scope staging    # override scope
pnpm searchsocket index --dry-run          # preview without writing
pnpm searchsocket index --max-pages 10     # limit for testing (no deletions when it truncates)
pnpm searchsocket index --verbose          # detailed output
pnpm searchsocket index --json             # machine-readable output

Deletion safety

An indexing run removes stale records only when it observed the complete source of truth. A run truncated by --max-pages/--max-chunks, one that failed to fetch or extract a page, or one whose source unexpectedly returned nothing is reported as deletionEligible: false and deletes nothing — the stale records are left in place rather than risking the loss of a valid index.

Two further guards need an explicit opt-in:

# The source legitimately produced zero pages and you want the index emptied
pnpm searchsocket index --allow-empty

# The run would remove more than indexing.maxDeletionRatio (default 50%)
pnpm searchsocket index --accept-large-deletion

When the ratio guard refuses a run, the run writes nothing — not the deletions and not the additions either. Withholding only the deletions used to leave the replacement records written, which enlarged the inventory the ratio is measured against; an identical rerun then saw the same stale records as a smaller fraction and deleted them without --accept-large-deletion ever being supplied. Refusing the whole mutation is what keeps a refused plan refused however often you repeat it, and leaves the index exactly as it was last accepted. The empty-source guard is narrower: it stops deletions but still writes the run's additions, because an empty site source is a source problem rather than a plan whose arithmetic a retry could game.

An unreadable robots.txt blocks index writes, in crawl mode with respectRobotsTxt on — the only mode that fetches it over the network. If the fetch times out, returns a server error, or exceeds the 512 KiB size cap, the run cannot tell which pages the site means to exclude, so it writes nothing rather than risk publishing a page that was meant to stay out. A 404 or 410 is not unreadable: that is how a site says "no rules here", and indexing proceeds. (static-output and build read robots.txt from the build directory and treat an unreadable file as absent; content-files does not consult it at all.)

Rule matching follows RFC 9309 for the parts that matter in practice: * wildcards, the $ end-anchor, group merging across consecutive User-agent lines, and longest-rule-wins specificity with Allow beating an equally long Disallow. It is not a certified-complete implementation — percent-encoded paths are compared as written rather than canonicalised, so Disallow: /admin does not also block /%61dmin.

Chunk and page metadata is budgeted in UTF-8 bytes against Upstash's 48 KB per-record limit, measured on the exact JSON that will be sent. The one trimmable field (the record's text) is truncated to fit, with a warning naming the record. If the required fields alone still do not fit, the run fails with the page URL and the name and size of the largest offending field, rather than letting the backend reject one item in the middle of a half-written batch.

searchsocket index exits 5 when no vector backend is configured. Pass --allow-unconfigured to skip indexing without failing (the Vite plugin already behaves this way).

searchsocket search

CLI search for testing. The query is a positional argument; --q <query> is also accepted.

pnpm searchsocket search "getting started" --top-k 5
pnpm searchsocket search "api" --path-prefix /docs

searchsocket dev

Watch for file changes and auto-reindex, with optional playground UI.

pnpm searchsocket dev                                # watch + playground at :3337
pnpm searchsocket dev --mcp                          # also start MCP HTTP server
pnpm searchsocket dev --no-playground                 # watch only

searchsocket status

Show indexing status and backend health.

pnpm searchsocket status

searchsocket doctor

Validate config, env vars, provider connectivity, and local write access — it writes a probe file into the state directory. It does not test write permission against the remote index.

pnpm searchsocket doctor

searchsocket test

Run search quality assertions against the live index.

pnpm searchsocket test                              # uses searchsocket.test.json
pnpm searchsocket test --file custom-tests.json     # custom test file

Test file format:

[
  {
    "query": "installation guide",
    "expect": {
      "topResult": "/docs/getting-started",
      "inTop5": ["/docs/getting-started", "/docs/quickstart"]
    }
  }
]

Reports pass/fail per assertion and Mean Reciprocal Rank (MRR) across all queries.

searchsocket clean

Delete local state and optionally remote indexes.

With --remote, the command is plan-only until --apply — it deletes nothing at all, local state included, and prints what it would do. --apply performs both the remote deletion and the local one; --keep-local suppresses the local deletion on an applied run. Without --remote, clean removes the local state directory immediately.

pnpm searchsocket clean                                  # local state only
pnpm searchsocket clean --remote --scope staging         # show the plan
pnpm searchsocket clean --remote --scope staging --apply # delete that scope
pnpm searchsocket clean --keep-local --remote --scope staging --apply

Dropping every scope in the project needs an explicit confirmation token:

pnpm searchsocket clean --remote --all-scopes --apply --confirm-project my-site

--scope applies to the remote deletion only. Without --remote the command just removes the local state directory.

searchsocket prune

List and delete stale scopes. Compares against remote git branches to find orphaned scopes.

pnpm searchsocket prune                                  # dry-run (default)
pnpm searchsocket prune --apply                          # delete orphaned scopes
pnpm searchsocket prune --older-than 30d --apply         # orphaned AND inactive 30d
pnpm searchsocket prune --older-than 30d --match any --apply  # orphaned OR inactive
pnpm searchsocket prune --protect staging,demo --apply   # never touch these

Prune fails closed. It refuses to run when the remote branch list cannot be trusted — a shallow clone, a repository with no remotes, or an empty --scopes-file — because every scope would otherwise look orphaned. In CI, check out with full history (actions/checkout with fetch-depth: 0) or pass --scopes-file. Scopes with no recorded index timestamp are skipped by --older-than rather than assumed old, and the current scope plus main are always protected.

searchsocket mcp

Run the MCP server standalone.

pnpm searchsocket mcp                                   # transport from mcp.transport (default stdio)
pnpm searchsocket mcp --transport http --port 4000       # HTTP on a specific port
pnpm searchsocket mcp --access public --api-key SECRET   # public with auth

--transport, --port and --path override mcp.transport, mcp.http.port and mcp.http.path only when passed; otherwise the config (or its defaults: stdio, 3338, /mcp) applies. The same holds for dev --mcp-port and --mcp-path. --access and --api-key are merged into the config before it is validated, so mcp.access: "public" with the key supplied only by --api-key is accepted.

searchsocket add

Copy Svelte 5 search UI template components into your project. They require Svelte 5.20 or newer.

pnpm searchsocket add search-dialog
pnpm searchsocket add search-input
pnpm searchsocket add search-results
pnpm searchsocket add search-trigger
pnpm searchsocket add search-dialog --dir src/lib/components/ui  # custom dir
pnpm searchsocket add search-dialog --overwrite                  # replace existing files

Exit codes

Every command exits with one of these codes. They are stable: scripts and CI may branch on them.

| Code | Meaning | Examples | | --- | --- | --- | | 0 | Success | | | 1 | Operational failure | a failed doctor check, an unexpected error during a run | | 2 | Invalid usage | unknown command or option, bad flag value (including --top-k above 100), missing or invalid searchsocket.config.ts, unreadable searchsocket test file or a query in it the engine refuses | | 3 | Quality gate failed | searchsocket test assertions did not pass, with every query retrieved | | 4 | Destructive operation refused | clean, prune or migrate cleanup-legacy declined to delete without the required confirmation | | 5 | Search backend unavailable or unconfigured | index, search or test with no vector backend configured; status reporting the backend unhealthy; status, prune, search or test failing against Upstash Vector (including rate limiting) |

Real-World Example

Here's how Canopy integrates SearchSocket into a production SvelteKit site.

Configuration

// searchsocket.config.ts
export default {
  project: {
    id: "canopy-website",
    baseUrl: "https://canopy.dev"
  },
  source: {
    mode: "build"
  },
  extract: {
    dropSelectors: [".nav-blur", ".mobile-overlay", ".docs-sidebar"]
  },
  ranking: {
    minScoreRatio: 0.70,
    pageWeights: {
      "/": 0.95,
      "/download": 1.05,
      "/docs/**": 1.05
    },
  },
  mcp: {
    // Off in production unless enabled; private with a key from the environment.
    enable: true,
    handle: { apiKeyEnv: "SEARCHSOCKET_MCP_API_KEY" }
  }
};

Server hook

// src/hooks.server.ts
import { searchsocketHandle } from "searchsocket/sveltekit";
import { env } from "$env/dynamic/private";
import config from "../searchsocket.config";

// Start from the full config so the hook serves exactly what was indexed, and
// only add what has to come from the runtime environment.
export const handle = searchsocketHandle({
  rawConfig: {
    ...config,
    upstash: {
      url: env.UPSTASH_VECTOR_REST_URL,
      token: env.UPSTASH_VECTOR_REST_TOKEN
    }
  }
});

Search modal with scoped search

<!-- SearchModal.svelte -->
<script>
  import { createSearchClient, buildResultUrl } from "searchsocket/client";

  let { open = $bindable(false), pathPrefix = "", placeholder = "Search..." } = $props();

  const client = createSearchClient();
  let query = $state("");
  let results = $state([]);

  async function doSearch() {
    if (!query.trim()) { results = []; return; }
    const res = await client.search({
      q: query,
      topK: 8,
      groupBy: "page",
      pathPrefix: pathPrefix || undefined
    });
    results = res.results;
  }
</script>

{#if open}
  <dialog open>
    <input bind:value={query} oninput={doSearch} {placeholder} />
    {#each results as result}
      <a href={buildResultUrl(result)} onclick={() => open = false}>
        <strong>{result.title}</strong>
        {#if result.sectionTitle}<span>— {result.sectionTitle}</span>{/if}
        <p>{result.snippet}</p>
      </a>
    {/each}
  </dialog>
{/if}

Scroll-to-text in layout

<!-- src/routes/+layout.svelte -->
<script>
  import { afterNavigate } from "$app/navigation";
  import { searchsocketScrollToText } from "searchsocket/scroll";

  afterNavigate(searchsocketScrollToText);
</script>

Deploy and index

Indexing runs automatically on every Vercel deploy. Set these env vars in the Vercel dashboard:

  • UPSTASH_VECTOR_REST_URL
  • UPSTASH_VECTOR_REST_TOKEN
  • SEARCHSOCKET_AUTO_INDEX=1

The Vite plugin handles the rest. Alternatively, use a postbuild script:

{
  "scripts": {
    "build": "vite build",
    "postbuild": "searchsocket index"
  }
}

Connect Claude Code to the deployed site

With SEARCHSOCKET_MCP_API_KEY set in Vercel and exported in the shell that starts Claude Code:

{
  "mcpServers": {
    "searchsocket": {
      "type": "http",
      "url": "https://canopy.dev/api/mcp",
      "headers": {
        "Authorization": "Bearer ${SEARCHSOCKET_MCP_API_KEY}"
      }
    }
  }
}

A site serving public documentation can use mcp.handle.access: "public" instead and drop the header; see MCP Server.

Now Claude Code can search the live docs, retrieve page content, and find source files — all backed by the production index that stays current with every deploy.

Excluding pages from search

<!-- src/routes/blog/+page.svelte (archive page) -->
<svelte:head>
  <meta name="searchsocket-weight" content="0" />
</svelte:head>

Or with the component:

<script>
  import { SearchSocket } from "searchsocket/svelte";
</script>

<SearchSocket weight={0} />

Vite SSR config

// vite.config.ts
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [sveltekit()],
  ssr: {
    external: ["searchsocket", "searchsocket/sveltekit", "searchsocket/client", "searchsocket/scroll"]
  }
});

Environment Variables

Required

| Variable | Description | |----------|-------------| | UPSTASH_VECTOR_REST_URL | Upstash Vector REST API endpoint | | UPSTASH_VECTOR_REST_TOKEN | Upstash Vector REST API token |

Optional

| Variable | Description | |----------|-------------| | SEARCHSOCKET_SCOPE | Override scope (when scope.mode: "env") | | SEARCHSOCKET_AUTO_INDEX | Enable build-triggered indexing (1, true, or yes) | | SEARCHSOCKET_DISABLE_AUTO_INDEX | Disable build-triggered indexing | | SEARCHSOCKET_FORCE_REINDEX | Force full re-index in CI/CD |

The CLI automatically loads .env from the working directory on startup.

Configuration Reference

See docs/config.md for the full configuration reference. The config is validated strictly: an unknown or misspelled key, a removed option, or a URL pattern outside the supported syntax fails with CONFIG_INVALID and names the offending key. Here's the full example:

export default {
  project: {
    id: "my-site",
    baseUrl: "https://example.com"
  },

  scope: {
    mode: "git",                 // "fixed" | "git" | "env"
    fixed: "main",
    sanitize: true
  },

  exclude: ["/admin/*", "/api/*"],
  respectRobotsTxt: true,

  source: {
    mode: "build",
    staticOutputDir: "build",
    build: {
      exclude: ["/api/*"],
      paramValues: {
        "/blog/[slug]": ["hello-world", "getting-started"]
      },
      discover: true,
      maxPages: 200
    }
  },

  extract: {
    mainSelector: "main",
    dropTags: ["header", "nav", "footer", "aside"],
    dropSelectors: [".sidebar", ".toc"],
    ignoreAttr: "data-search-ignore",
    noindexAttr: "data-search-noindex",
    imageDescAttr: "data-search-description"
  },

  chunking: {
    maxChars: 1500,
    overlapChars: 200,
    minChars: 250,
    prependTitle: true,
    pageSummaryChunk: true
  },

  upstash: {
    urlEnv: "UPSTASH_VECTOR_REST_URL",
    tokenEnv: "UPSTASH_VECTOR_REST_TOKEN"
  },

  ranking: {
    enableIncomingLinkBoost: true,
    enableDepthBoost: true,
    pageWeights: { "/docs": 1.15 },
    minScoreRatio: 0.70,
  },

  api: {
    path: "/api/search",
    cors: { allowOrigins: ["https://example.com"] }
  },

  mcp: {
    enable: true,
    handle: { path: "/api/mcp" }
  },

  llmsTxt: {
    enable: true,
    title: "My Project",
    description: "Documentation for My Project"
  },

  state: {
    dir: ".searchsocket"
  }
};

CI/CD

See docs/ci.md for ready-to-use GitHub Actions workflows covering:

  • Main branch indexing on push
  • PR dry-run validation
  • Preview branch scope isolation
  • Scheduled scope pruning
  • Vercel build-triggered indexing

Further Reading

Contributing

This repo follows Git Flow:

| Branch | Purpose | | --- | --- | | main | Production. Only ever receives merges from release/* and hotfix/*, and carries the v* release tags. | | develop | Integration branch and the default PR target. | | feature/* | Branched from develop, merged back into develop. | | release/* | Branched from develop, merged into both main and develop. | | hotfix/* | Branched from main, merged into both main and develop. |

# start a feature
git switch develop && git pull
git switch -c feature/my-thing

# open the PR against develop
gh pr create --base develop

Local development:

pnpm install
pnpm run typecheck   # tsc --noEmit
pnpm run test        # vitest run
pnpm run build       # tsup → dist/

CI runs typecheck, build, the test suite, and the packed-tarball check on Node 22 and 24 for every push to main/develop/release/*/hotfix/* and every PR into main or develop.

pnpm run test:quality runs Mean Reciprocal Rank assertions against a live index. It needs Upstash credentials and is not part of CI — run it locally when changing src/search/ranking.ts.

Releases are cut from a release/* branch: bump the version, merge to main, then push the v* tag. The publish workflow builds, tests, and publishes to NPM via Trusted Publishing (OIDC).

License

MIT