Skip to content

Commit 5a2b79b

Browse files
committed
feat(framework): pnpm local docs, a local docs index for search_docs evals
pnpm local docs up --docs <path-to-supabase-monorepo> pnpm local docs seed # full embed, ~$0.12 OpenAI, asks first pnpm local docs api [--port N] # serve the content API, foreground pnpm local docs down pnpm local run <eval> --content-api http://127.0.0.1:3001/docs/api/graphql --mcp <checkout> Lets a docs edit be measured end to end. The docs checkout is YOURS, passed via --docs, so there is no submodule and no patching: edit a page there, re-seed, re-run. The stack runs from a generated .local-docs/ workdir with its own project id and a 443xx port block. That block is off the evals local-stack range (54321+) and below the macOS ephemeral range (49152+), where a transient outbound socket stealing a listen port is a real failure mode we hit. Files are copied rather than symlinked, so it works on Windows too. content-api-server.ts serves the docs app's own GraphQL route over plain node:http, run with the docs checkout's tsx so the route's TS and its react-server condition resolve. It binds every interface and advertises loopback, matching platform-lite in tools mode: a sandboxed agent reaches host-side services through host.docker.internal, which arrives on the bridge interface and not on loopback. The Sentry stub is required, not cosmetic — the real package resolves without captureException outside Next's instrumentation and every request would crash. --content-api is gated, because ungated it is a trap: the server would query PRODUCTION docs while the receipt recorded the local URL, so a paid run would measure the wrong world and report the right one. The gate consults the MCP_SERVER_VERSION pin, since the --content-api-url flag shipped in v0.10.0 (supabase/mcp#343), and only asks for --mcp when the pin cannot honour it. It probes the built server for the FLAG rather than the env var, because the flag is what we pass: createConfig forwards the URL in argv, as a CLI agent spawns that command inside the container where our environment is not inherited. Version handling is deliberately strict. Malformed versions throw, since reading 0.10.foo as 0.10.0 would report "capable" and buy exactly the bad run. No prerelease counts either: semver defines precedence, not content, so a 0.11.0-beta could come off a branch that forked before the flag landed. `docs seed` is full-embed only on purpose. The upstream incremental path has known bugs (guide checksums are never set, so every guide re-embeds; a source skipped for missing credentials has its still-valid rows purged), so it waits for those fixes to land in supabase/supabase. CLI output is buffered and replayed only on failure, because `supabase start` prints ANON_KEY, SERVICE_ROLE_KEY, SECRET_KEY and JWT_SECRET on every run and those do not belong on a screen recording. Dropping stdout is a stream boundary, not a redaction regex, so there is no pattern to keep in step. Verified: smoke 27/27 (8 new), core 115/115, sandbox 39 passed, tsc and biome clean.
1 parent aeaad49 commit 5a2b79b

11 files changed

Lines changed: 721 additions & 16 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,7 @@ evals/*/local/supabase/.branches/
1212
results/*/
1313
.sync-tmp/
1414

15+
1516
# local-dev runner (apps/framework/scripts/local.ts)
1617
/results-local/
18+
/.local-docs/
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// fallow-ignore-file unused-file -- loaded at runtime (spawned/injected by local-docs.ts), never statically imported
2+
/**
3+
* Standalone docs content GraphQL API for `search_docs`.
4+
*
5+
* Serves the docs app's own route handler (apps/docs/app/api/graphql/route.ts
6+
* in a supabase/supabase checkout) over plain node:http — no Next server.
7+
* Launched by `pnpm local docs api` with the docs checkout's tsx so the
8+
* route's TS + tsconfig conditions resolve; DOCS_ROUTE_PATH points at the
9+
* checkout, PORT picks the listen port.
10+
*/
11+
import { createServer } from 'node:http';
12+
import { pathToFileURL } from 'node:url';
13+
14+
const routePath = process.env.DOCS_ROUTE_PATH;
15+
if (!routePath) {
16+
console.error(
17+
'DOCS_ROUTE_PATH not set — run this through `pnpm local docs api`'
18+
);
19+
process.exit(1);
20+
}
21+
// The docs checkout location is user-supplied at runtime; a static import
22+
// cannot name it.
23+
const route = await import(pathToFileURL(routePath).href);
24+
const handlers: Record<string, (req: Request) => Promise<Response>> = {
25+
GET: route.GET,
26+
OPTIONS: route.OPTIONS,
27+
POST: route.POST,
28+
};
29+
const port = Number(process.env.PORT ?? 3001);
30+
31+
createServer(async (incoming, outgoing) => {
32+
const url = new URL(
33+
incoming.url ?? '/',
34+
`http://${incoming.headers.host ?? `127.0.0.1:${port}`}`
35+
);
36+
const handler = handlers[incoming.method ?? ''];
37+
if (url.pathname !== '/docs/api/graphql' || !handler) {
38+
outgoing.writeHead(404).end();
39+
return;
40+
}
41+
42+
const headers = new Headers();
43+
for (const [name, value] of Object.entries(incoming.headers)) {
44+
if (Array.isArray(value))
45+
for (const item of value) headers.append(name, item);
46+
else if (value !== undefined) headers.set(name, value);
47+
}
48+
49+
const chunks: Buffer[] = [];
50+
for await (const chunk of incoming) chunks.push(Buffer.from(chunk));
51+
const body =
52+
incoming.method === 'GET' || incoming.method === 'HEAD'
53+
? undefined
54+
: Buffer.concat(chunks).toString('utf8');
55+
const response = await handler(
56+
new Request(url, { method: incoming.method, headers, body })
57+
);
58+
59+
outgoing.writeHead(
60+
response.status,
61+
Object.fromEntries(response.headers.entries())
62+
);
63+
outgoing.end(Buffer.from(await response.arrayBuffer()));
64+
// Bind every interface, advertise loopback — same rule as platform-lite in
65+
// tools mode (see run-eval.ts: sandboxed CLI agents run their MCP servers
66+
// INSIDE the container and reach host-side services via
67+
// host.docker.internal, which arrives on the host's bridge interface, not
68+
// loopback; a 127.0.0.1-only listener refuses those connections).
69+
}).listen(port, '0.0.0.0', () => {
70+
console.log(`Docs content API: http://127.0.0.1:${port}/docs/api/graphql`);
71+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// fallow-ignore-file unused-file -- registered at runtime by sentry-stub-register.mjs via module.register()
2+
// Loader-thread resolve hook: '@sentry/nextjs' -> the no-op stub.
3+
const stubUrl = new URL('./sentry-stub.mjs', import.meta.url).href;
4+
5+
// fallow-ignore-next-line unused-export -- Node loader-hook contract: the module system calls `resolve`
6+
export async function resolve(specifier, context, next) {
7+
if (specifier === '@sentry/nextjs') {
8+
return { url: stubUrl, shortCircuit: true };
9+
}
10+
return next(specifier, context);
11+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// fallow-ignore-file unused-file -- loaded at runtime (spawned/injected by local-docs.ts), never statically imported
2+
// Registers a resolve hook that short-circuits '@sentry/nextjs' to the local
3+
// no-op stub. Injected via NODE_OPTIONS from `pnpm local docs api`; chains with tsx's
4+
// own hooks (ours only intercepts the one specifier). Uses module.register()
5+
// (Node 20.6+) rather than registerHooks() (22.15+) — mise pins node "22",
6+
// which an older 22.x install satisfies.
7+
import { register } from 'node:module';
8+
9+
register('./sentry-stub-loader.mjs', import.meta.url);
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// fallow-ignore-file unused-file -- loaded at runtime (spawned/injected by local-docs.ts), never statically imported
2+
// No-op @sentry/nextjs stand-in for the standalone docs content API.
3+
// The route handler calls Sentry.captureException/flush; under plain tsx
4+
// (outside Next's Sentry instrumentation) the real package's ESM build
5+
// resolves without those functions and every request crashes. A local dev
6+
// adapter has no business sending telemetry anyway. Wired up by
7+
// sentry-stub-register.mjs (see local-docs.ts).
8+
export const captureException = () => '';
9+
export const flush = async () => true;

0 commit comments

Comments
 (0)