From dfca6f5d9d1cd8739c704d46446b627501517543 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 8 Jul 2026 20:54:09 -0700 Subject: [PATCH] feat: add disposable Vercel and Convex PR previews (#3017) Adds isolated Convex-backed Vercel PR previews with shared local/preview seeding, preview-safe routing, and production guards. --- .../skills/convex-create-component/SKILL.md | 7 +- .../agents/openai.yaml | 6 +- .../skills/convex-migration-helper/SKILL.md | 3 +- .../agents/openai.yaml | 3 +- .../skills/convex-performance-audit/SKILL.md | 3 +- .../agents/openai.yaml | 6 +- .../references/hot-path-rules.md | 11 +- .../references/subscription-cost.md | 5 +- .agents/skills/convex-quickstart/SKILL.md | 13 +- .../convex-quickstart/agents/openai.yaml | 6 +- .agents/skills/convex-setup-auth/SKILL.md | 7 +- .../convex-setup-auth/agents/openai.yaml | 6 +- .agents/skills/convex/SKILL.md | 3 +- AGENTS.md | 1 + convex/crons.test.ts | 10 ++ convex/crons.ts | 2 +- convex/devSeed.localFixtures.test.ts | 47 ++++- convex/devSeed.ts | 150 ++++++++++------ package.json | 2 + scripts/dev-worktree.test.ts | 2 +- scripts/dev-worktree.ts | 70 +------- .../public-corpus/seed-public-corpus.test.ts | 126 +++++++++++++ scripts/public-corpus/seed-public-corpus.ts | 170 ++++++++++++++---- scripts/seed.test.ts | 63 +++++++ scripts/seed.ts | 150 ++++++++++++++++ scripts/vercel-build-frontend.test.ts | 26 +++ scripts/vercel-build-frontend.ts | 36 ++++ scripts/vercel-build.test.ts | 65 +++++++ scripts/vercel-build.ts | 76 ++++++++ server/convexProxy.test.ts | 97 ++++++++++ server/convexProxy.ts | 84 +++++++++ server/handlers/convexProxy.ts | 1 + specs/deploy.md | 51 +++++- specs/dev-seeding.md | 13 +- specs/hosted-catalog-feed.md | 10 +- src/__tests__/vercel-preview-config.test.ts | 20 +++ src/lib/convexDeploymentUrl.test.ts | 30 ++++ src/lib/convexDeploymentUrl.ts | 41 +++++ src/lib/publicApiUrl.ts | 4 +- src/lib/runtimeEnv.test.ts | 14 ++ src/lib/runtimeEnv.ts | 13 +- vercel.json | 19 +- vite.config.ts | 4 + 43 files changed, 1228 insertions(+), 248 deletions(-) create mode 100644 scripts/seed.test.ts create mode 100644 scripts/seed.ts create mode 100644 scripts/vercel-build-frontend.test.ts create mode 100644 scripts/vercel-build-frontend.ts create mode 100644 scripts/vercel-build.test.ts create mode 100644 scripts/vercel-build.ts create mode 100644 server/convexProxy.test.ts create mode 100644 server/convexProxy.ts create mode 100644 server/handlers/convexProxy.ts create mode 100644 src/__tests__/vercel-preview-config.test.ts create mode 100644 src/lib/convexDeploymentUrl.test.ts create mode 100644 src/lib/convexDeploymentUrl.ts diff --git a/.agents/skills/convex-create-component/SKILL.md b/.agents/skills/convex-create-component/SKILL.md index 4e5785bd..3c8adb83 100644 --- a/.agents/skills/convex-create-component/SKILL.md +++ b/.agents/skills/convex-create-component/SKILL.md @@ -1,7 +1,6 @@ --- name: convex-create-component -description: - Builds reusable Convex components with isolated tables and app-facing APIs. +description: Builds reusable Convex components with isolated tables and app-facing APIs. Use for new components, reusable backend modules, integrations, or component boundary work. --- @@ -131,9 +130,7 @@ export const listUnread = query({ handler: async (ctx, args) => { return await ctx.db .query("notifications") - .withIndex("by_user_read", (q) => - q.eq("userId", args.userId).eq("read", false), - ) + .withIndex("by_user_read", (q) => q.eq("userId", args.userId).eq("read", false)) .collect(); }, }); diff --git a/.agents/skills/convex-create-component/agents/openai.yaml b/.agents/skills/convex-create-component/agents/openai.yaml index bf58a4c4..acdfd4a7 100644 --- a/.agents/skills/convex-create-component/agents/openai.yaml +++ b/.agents/skills/convex-create-component/agents/openai.yaml @@ -1,12 +1,10 @@ interface: display_name: "Convex Create Component" - short_description: - "Design and build reusable Convex components with clear boundaries." + short_description: "Design and build reusable Convex components with clear boundaries." icon_small: "./assets/icon.svg" icon_large: "./assets/icon.svg" brand_color: "#14B8A6" - default_prompt: - "Help me create a Convex component for this feature. First check that a + default_prompt: "Help me create a Convex component for this feature. First check that a component is actually justified, then design the tables, API surface, and app-facing wrappers before implementing it." diff --git a/.agents/skills/convex-migration-helper/SKILL.md b/.agents/skills/convex-migration-helper/SKILL.md index 8ce9874b..2470ed01 100644 --- a/.agents/skills/convex-migration-helper/SKILL.md +++ b/.agents/skills/convex-migration-helper/SKILL.md @@ -1,7 +1,6 @@ --- name: convex-migration-helper -description: - Plans Convex schema and data migrations with widen-migrate-narrow and +description: Plans Convex schema and data migrations with widen-migrate-narrow and @convex-dev/migrations. Use for breaking schema changes, backfills, table reshaping, or zero-downtime rollouts. --- diff --git a/.agents/skills/convex-migration-helper/agents/openai.yaml b/.agents/skills/convex-migration-helper/agents/openai.yaml index 5f027dd9..075edd5a 100644 --- a/.agents/skills/convex-migration-helper/agents/openai.yaml +++ b/.agents/skills/convex-migration-helper/agents/openai.yaml @@ -4,8 +4,7 @@ interface: icon_small: "./assets/icon.svg" icon_large: "./assets/icon.svg" brand_color: "#8B5CF6" - default_prompt: - "Help me plan and execute this Convex migration safely. Start by identifying + default_prompt: "Help me plan and execute this Convex migration safely. Start by identifying the schema change, the existing data shape, and the widen-migrate-narrow path before making edits." diff --git a/.agents/skills/convex-performance-audit/SKILL.md b/.agents/skills/convex-performance-audit/SKILL.md index 998af1d1..7b4fb528 100644 --- a/.agents/skills/convex-performance-audit/SKILL.md +++ b/.agents/skills/convex-performance-audit/SKILL.md @@ -1,7 +1,6 @@ --- name: convex-performance-audit -description: - Audits Convex performance for reads, subscriptions, write contention, and +description: Audits Convex performance for reads, subscriptions, write contention, and function limits. Use for slow features, insights findings, OCC conflicts, or read amplification. --- diff --git a/.agents/skills/convex-performance-audit/agents/openai.yaml b/.agents/skills/convex-performance-audit/agents/openai.yaml index 0a1d7889..53e73ecc 100644 --- a/.agents/skills/convex-performance-audit/agents/openai.yaml +++ b/.agents/skills/convex-performance-audit/agents/openai.yaml @@ -1,12 +1,10 @@ interface: display_name: "Convex Performance Audit" - short_description: - "Audit slow Convex reads, subscriptions, OCC conflicts, and limits." + short_description: "Audit slow Convex reads, subscriptions, OCC conflicts, and limits." icon_small: "./assets/icon.svg" icon_large: "./assets/icon.svg" brand_color: "#EF4444" - default_prompt: - "Audit this Convex app for performance issues. Start with the strongest + default_prompt: "Audit this Convex app for performance issues. Start with the strongest signal available, identify the problem class, and suggest the smallest high-impact fix before proposing bigger structural changes." diff --git a/.agents/skills/convex-performance-audit/references/hot-path-rules.md b/.agents/skills/convex-performance-audit/references/hot-path-rules.md index 142231df..ea0499e2 100644 --- a/.agents/skills/convex-performance-audit/references/hot-path-rules.md +++ b/.agents/skills/convex-performance-audit/references/hot-path-rules.md @@ -144,10 +144,10 @@ defineTable({ team: v.id("teams"), user: v.id("users") }) ```ts // Good: single compound index serves both query patterns -defineTable({ team: v.id("teams"), user: v.id("users") }).index( - "by_team_and_user", - ["team", "user"], -); +defineTable({ team: v.id("teams"), user: v.id("users") }).index("by_team_and_user", [ + "team", + "user", +]); ``` Exception: `.index("by_foo", ["foo"])` is really an index on `foo` + @@ -195,8 +195,7 @@ const ownerName = project.ownerName ?? "Unknown owner"; ```ts // Good: denormalized data is an optimization, not the only source of truth -const ownerName = - project.ownerName ?? (await ctx.db.get(project.ownerId))?.name ?? null; +const ownerName = project.ownerName ?? (await ctx.db.get(project.ownerId))?.name ?? null; ``` Bad lookup map pattern: diff --git a/.agents/skills/convex-performance-audit/references/subscription-cost.md b/.agents/skills/convex-performance-audit/references/subscription-cost.md index 78f05b0e..9b0fc6d1 100644 --- a/.agents/skills/convex-performance-audit/references/subscription-cost.md +++ b/.agents/skills/convex-performance-audit/references/subscription-cost.md @@ -157,10 +157,7 @@ const profile = useQuery(api.users.getProfile, { userId: selectedId! }); ```ts // Good: skip when there is nothing to fetch -const profile = useQuery( - api.users.getProfile, - selectedId ? { userId: selectedId } : "skip", -); +const profile = useQuery(api.users.getProfile, selectedId ? { userId: selectedId } : "skip"); ``` ### 4. Isolate frequently-updated fields into separate documents diff --git a/.agents/skills/convex-quickstart/SKILL.md b/.agents/skills/convex-quickstart/SKILL.md index 09400d39..00b54ff9 100644 --- a/.agents/skills/convex-quickstart/SKILL.md +++ b/.agents/skills/convex-quickstart/SKILL.md @@ -1,7 +1,6 @@ --- name: convex-quickstart -description: - Creates or adds Convex to an app. Use for new Convex projects, npm create +description: Creates or adds Convex to an app. Use for new Convex projects, npm create convex@latest, frontend setup, env vars, or the first npx convex dev run. --- @@ -222,9 +221,7 @@ Create the `ConvexReactClient` at module scope, not inside a component: ```tsx // Bad: re-creates the client on every render function App() { - const convex = new ConvexReactClient( - import.meta.env.VITE_CONVEX_URL as string, - ); + const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); return ...; } @@ -275,11 +272,7 @@ export function ConvexClientProvider({ children }: { children: ReactNode }) { // app/layout.tsx import { ConvexClientProvider } from "./ConvexClientProvider"; -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { +export default function RootLayout({ children }: { children: React.ReactNode }) { return ( diff --git a/.agents/skills/convex-quickstart/agents/openai.yaml b/.agents/skills/convex-quickstart/agents/openai.yaml index 0347a50e..accea5b1 100644 --- a/.agents/skills/convex-quickstart/agents/openai.yaml +++ b/.agents/skills/convex-quickstart/agents/openai.yaml @@ -1,12 +1,10 @@ interface: display_name: "Convex Quickstart" - short_description: - "Start a new Convex app or add Convex to an existing frontend." + short_description: "Start a new Convex app or add Convex to an existing frontend." icon_small: "./assets/icon.svg" icon_large: "./assets/icon.svg" brand_color: "#F97316" - default_prompt: - "Set up Convex for this project as fast as possible. First decide whether + default_prompt: "Set up Convex for this project as fast as possible. First decide whether this is a new app or an existing app, then scaffold or integrate Convex and verify the setup works." diff --git a/.agents/skills/convex-setup-auth/SKILL.md b/.agents/skills/convex-setup-auth/SKILL.md index 410198f0..4653c6e4 100644 --- a/.agents/skills/convex-setup-auth/SKILL.md +++ b/.agents/skills/convex-setup-auth/SKILL.md @@ -1,7 +1,6 @@ --- name: convex-setup-auth -description: - Sets up Convex auth, identity mapping, and access control. Use for login, auth +description: Sets up Convex auth, identity mapping, and access control. Use for login, auth providers, users tables, protected functions, or roles in a Convex app. --- @@ -131,9 +130,7 @@ export const getMyProfile = query({ return await ctx.db .query("users") - .withIndex("by_tokenIdentifier", (q) => - q.eq("tokenIdentifier", identity.tokenIdentifier), - ) + .withIndex("by_tokenIdentifier", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier)) .unique(); }, }); diff --git a/.agents/skills/convex-setup-auth/agents/openai.yaml b/.agents/skills/convex-setup-auth/agents/openai.yaml index f7e207c9..054e2caa 100644 --- a/.agents/skills/convex-setup-auth/agents/openai.yaml +++ b/.agents/skills/convex-setup-auth/agents/openai.yaml @@ -1,12 +1,10 @@ interface: display_name: "Convex Setup Auth" - short_description: - "Set up Convex auth, user identity mapping, and access control." + short_description: "Set up Convex auth, user identity mapping, and access control." icon_small: "./assets/icon.svg" icon_large: "./assets/icon.svg" brand_color: "#2563EB" - default_prompt: - "Set up authentication for this Convex app. Figure out the provider first, + default_prompt: "Set up authentication for this Convex app. Figure out the provider first, then wire up the user model, identity mapping, and access control with the smallest solid implementation." diff --git a/.agents/skills/convex/SKILL.md b/.agents/skills/convex/SKILL.md index 08c67f14..2bbdff76 100644 --- a/.agents/skills/convex/SKILL.md +++ b/.agents/skills/convex/SKILL.md @@ -1,7 +1,6 @@ --- name: convex -description: - Routes general Convex requests to the right project skill. Use when the user +description: Routes general Convex requests to the right project skill. Use when the user asks which Convex skill to use or gives an underspecified Convex app task. --- diff --git a/AGENTS.md b/AGENTS.md index afec2208..1ba4d8ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,7 @@ Keep this section as the command map agents normally need, not a full `package.j - `bun run setup:worktree` — validate copied `.env.local` / `.convex` state, or link missing fallback state from a usable source worktree. Use `-- --from ` or `CLAWHUB_WORKTREE_SOURCE=` when auto-discovery picks the wrong source. - `bun run dev:worktree` — Worktrunk-managed detached worktree server that also seeds local fixtures plus the public corpus once before starting the app when `VITE_CONVEX_URL` and `CONVEX_DEPLOYMENT` are local. Requires `wt` on `PATH`; from that worktree use `wt --yes url` to print the branch URL and `wt --yes stop` to stop it. - `bun run seed:dev` — manual reseed path; runs worktree setup, waits for local Convex, seeds local fixtures plus the public corpus, and refreshes stats. +- `bun run seed` — shared non-production seed pipeline used by local setup and disposable PR previews after their target Convex deployment is ready. - `bun run build` — production build (Vite + Nitro). - `bun run ci:static` — required pre-handoff static gate: peer checks, audit, formatting, lint, and dead-code checks. - `bun run ci:unit` — Vitest coverage gate; required for source/test PRs unless docs/config-only. diff --git a/convex/crons.test.ts b/convex/crons.test.ts index 48a45db1..a6741c5b 100644 --- a/convex/crons.test.ts +++ b/convex/crons.test.ts @@ -100,10 +100,12 @@ describe("crons", () => { vi.resetModules(); mocks.interval.mockReset(); delete process.env.CLAWHUB_DISABLE_CRONS; + delete process.env.CLAWHUB_PREVIEW; }); afterEach(() => { delete process.env.CLAWHUB_DISABLE_CRONS; + delete process.env.CLAWHUB_PREVIEW; }); it("does not register production cron work when explicitly disabled", async () => { @@ -114,6 +116,14 @@ describe("crons", () => { expect(mocks.interval).not.toHaveBeenCalled(); }); + it("does not register side-effecting cron work in disposable previews", async () => { + process.env.CLAWHUB_PREVIEW = "1"; + + await import("./crons"); + + expect(mocks.interval).not.toHaveBeenCalled(); + }); + it("runs GitHub skill source sync every 15 minutes", async () => { await import("./crons"); diff --git a/convex/crons.ts b/convex/crons.ts index cbdc6818..a84ec82f 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -4,7 +4,7 @@ import { RETENTION_STANDARD_BATCH_SIZE } from "./lib/retentionPolicy"; const crons = cronJobs(); -if (process.env.CLAWHUB_DISABLE_CRONS !== "1") { +if (process.env.CLAWHUB_DISABLE_CRONS !== "1" && process.env.CLAWHUB_PREVIEW !== "1") { crons.interval( "github-skill-source-sync", { minutes: 15 }, diff --git a/convex/devSeed.localFixtures.test.ts b/convex/devSeed.localFixtures.test.ts index ae0e011f..35ccd475 100644 --- a/convex/devSeed.localFixtures.test.ts +++ b/convex/devSeed.localFixtures.test.ts @@ -56,6 +56,7 @@ function createDb() { const tables: Record & { _id: string }>> = {}; const counters: Record = {}; const operations: Array<{ type: "delete"; table: string; id: string }> = []; + const queries: Array<{ table: string; constraints: Record }> = []; const list = (table: string) => { tables[table] ??= []; @@ -114,6 +115,7 @@ function createDb() { withIndex: (_name: string, build: (q: ReturnType) => unknown) => { const constraints: Record = {}; build(chainEq(constraints)); + queries.push({ table, constraints }); const matched = () => list(table).filter((doc) => matches(doc as Record, constraints)); return { @@ -137,7 +139,7 @@ function createDb() { }), }; - return { db, tables, operations }; + return { db, tables, operations, queries }; } function createMutationCtx(db: ReturnType["db"]) { @@ -277,6 +279,49 @@ describe("devSeed local fixtures", () => { ); }); + it("resolves a shared public corpus owner once per mutation batch", async () => { + const { db, queries } = createDb(); + const dummyOwner = { + handle: "corpus-owner", + displayName: "Corpus Owner", + image: "https://example.invalid/avatar.png", + }; + + await seedPublicCorpusBatchHandler( + createMutationCtx(db) as never, + { + rows: [ + { + kind: "skill", + slug: "corpus-one", + displayName: "Corpus One", + version: "0.1.0", + skillMd: "# Corpus one", + storageId: "storage:corpus-one", + embedding: [0, 1, 2], + dummyOwner, + }, + { + kind: "skill", + slug: "corpus-two", + displayName: "Corpus Two", + version: "0.1.0", + skillMd: "# Corpus two", + storageId: "storage:corpus-two", + embedding: [0, 1, 2], + dummyOwner, + }, + ], + } as never, + ); + + expect( + queries.filter( + (query) => query.table === "users" && query.constraints.handle === dummyOwner.handle, + ), + ).toHaveLength(1); + }); + it("backfills daily activity for existing public corpus skills", async () => { const { db, tables } = createDb(); const userId = (await db.insert("users", { diff --git a/convex/devSeed.ts b/convex/devSeed.ts index 41499098..1fb26d89 100644 --- a/convex/devSeed.ts +++ b/convex/devSeed.ts @@ -64,6 +64,12 @@ type PublicCorpusSeedBatchResult = { skipped: string[]; }; +type PublicCorpusSeedBatchHandlerResult = { + ok: true; + seeded: string[]; + skipped: string[]; +}; + function seededPackageRecommendationScore(stats: { downloads: number; installs: number; @@ -189,6 +195,33 @@ const publicCorpusSeedRowValidator = v.union( publicCorpusPluginRowValidator, ); +type PublicCorpusSeedRow = + | { + kind: "skill"; + slug: string; + displayName: string; + version: string; + skillMd: string; + summary?: string; + createdAt?: number; + dummyOwner: PublicCorpusDummyOwner; + } + | { + kind: "plugin"; + name: string; + displayName: string; + version: string; + readme: string; + summary?: string; + categories?: string[]; + topics?: string[]; + family?: "skill" | "code-plugin" | "bundle-plugin"; + channel?: "official" | "community" | "private"; + sourceRepoHost?: string | null; + createdAt?: number; + dummyOwner: PublicCorpusDummyOwner; + }; + const publicCorpusPreparedSkillRowValidator = v.object({ kind: v.literal("skill"), slug: v.string(), @@ -924,63 +957,70 @@ export const backfillExistingPublicCorpusBatchRows = internalMutation({ }, }); +async function seedPublicCorpusBatchHandler( + ctx: ActionCtx, + args: { + reset?: boolean; + resetOwnerHandles?: string[]; + rows: PublicCorpusSeedRow[]; + }, +): Promise { + const existingResult: PublicCorpusExistingRowsResult | null = args.reset + ? null + : await ctx.runMutation(internal.devSeed.backfillExistingPublicCorpusBatchRows, { + rows: args.rows, + }); + const missingKeys = new Set(existingResult?.missingKeys ?? []); + const rowsToPrepare = args.reset + ? args.rows + : args.rows.filter((row) => missingKeys.has(publicCorpusSeedRowKey(row))); + if (!args.reset && rowsToPrepare.length === 0) { + return { ok: true as const, seeded: [], skipped: existingResult?.skipped ?? [] }; + } + + const preparedRows = await Promise.all( + rowsToPrepare.map(async (row) => { + if (row.kind === "skill") { + const storageId = await ctx.storage.store( + new Blob([row.skillMd], { type: "text/markdown" }), + ); + const frontmatter = parseFrontmatter(row.skillMd); + const embeddingText = buildEmbeddingText({ + frontmatter, + readme: row.skillMd, + otherFiles: [], + }); + const embedding = await generateEmbedding(embeddingText); + return { ...row, storageId, embedding }; + } + const storageId = await ctx.storage.store(new Blob([row.readme], { type: "text/markdown" })); + return { ...row, storageId }; + }), + ); + + const seedResult: PublicCorpusSeedBatchResult = await ctx.runMutation( + internal.devSeed.seedPublicCorpusBatchMutation, + { + reset: args.reset, + resetOwnerHandles: args.resetOwnerHandles, + rows: preparedRows, + }, + ); + + return { + ok: true as const, + seeded: seedResult.seeded, + skipped: [...(existingResult?.skipped ?? []), ...seedResult.skipped], + }; +} + export const seedPublicCorpusBatch: ReturnType = internalAction({ args: { reset: v.optional(v.boolean()), resetOwnerHandles: v.optional(v.array(v.string())), rows: v.array(publicCorpusSeedRowValidator), }, - handler: async (ctx, args) => { - const existingResult: PublicCorpusExistingRowsResult | null = args.reset - ? null - : await ctx.runMutation(internal.devSeed.backfillExistingPublicCorpusBatchRows, { - rows: args.rows, - }); - const missingKeys = new Set(existingResult?.missingKeys ?? []); - const rowsToPrepare = args.reset - ? args.rows - : args.rows.filter((row) => missingKeys.has(publicCorpusSeedRowKey(row))); - if (!args.reset && rowsToPrepare.length === 0) { - return { ok: true, seeded: [], skipped: existingResult?.skipped ?? [] }; - } - - const preparedRows = await Promise.all( - rowsToPrepare.map(async (row) => { - if (row.kind === "skill") { - const storageId = await ctx.storage.store( - new Blob([row.skillMd], { type: "text/markdown" }), - ); - const frontmatter = parseFrontmatter(row.skillMd); - const embeddingText = buildEmbeddingText({ - frontmatter, - readme: row.skillMd, - otherFiles: [], - }); - const embedding = await generateEmbedding(embeddingText); - return { ...row, storageId, embedding }; - } - const storageId = await ctx.storage.store( - new Blob([row.readme], { type: "text/markdown" }), - ); - return { ...row, storageId }; - }), - ); - - const seedResult: PublicCorpusSeedBatchResult = await ctx.runMutation( - internal.devSeed.seedPublicCorpusBatchMutation, - { - reset: args.reset, - resetOwnerHandles: args.resetOwnerHandles, - rows: preparedRows, - }, - ); - - return { - ok: true, - seeded: seedResult.seeded, - skipped: [...(existingResult?.skipped ?? []), ...seedResult.skipped], - }; - }, + handler: seedPublicCorpusBatchHandler, }); function publicCorpusSeedRowKey( @@ -1016,9 +1056,15 @@ export const seedPublicCorpusBatchMutation = internalMutation({ const seeded: string[] = []; const skipped: string[] = []; + const owners = new Map; publisherId: Id<"publishers"> }>(); for (const row of args.rows) { - const { userId, publisherId } = await ensurePublicCorpusOwner(ctx, row.dummyOwner); + let owner = owners.get(row.dummyOwner.handle); + if (!owner) { + owner = await ensurePublicCorpusOwner(ctx, row.dummyOwner); + owners.set(row.dummyOwner.handle, owner); + } + const { userId, publisherId } = owner; if (row.kind === "skill") { const existing = await ctx.db .query("skills") diff --git a/package.json b/package.json index 9086b211..c820ef8b 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "scripts": { "admin": "bun packages/clawhub-admin/src/cli.ts", "build": "bun run llms:generate && vite build && bun scripts/copy-og-assets.ts", + "build:vercel": "bun scripts/vercel-build.ts", "check": "bun run lint", "check:peers": "bun scripts/check-peer-deps.ts", "check:release-workflow-action-pins": "node scripts/check-release-workflow-action-pins.mjs", @@ -56,6 +57,7 @@ "release:clawhub:cli:changelog": "node scripts/extract-changelog-release.mjs", "release:clawhub:cli:npm:check": "node scripts/clawhub-cli-npm-release-check.mjs", "security:codex-worker": "bun scripts/security/run-codex-scan-worker.ts", + "seed": "bun scripts/seed.ts", "seed:dev": "bun run setup:worktree -- --quiet && bun scripts/dev-worktree.ts --seed-only", "seed:public-corpus": "bun run setup:worktree -- --quiet && bunx convex dev --once --typecheck=disable && bun scripts/public-corpus/seed-public-corpus.ts", "setup:worktree": "bun scripts/setup-worktree.ts", diff --git a/scripts/dev-worktree.test.ts b/scripts/dev-worktree.test.ts index 37e85eaf..2a46950f 100644 --- a/scripts/dev-worktree.test.ts +++ b/scripts/dev-worktree.test.ts @@ -8,7 +8,6 @@ import { buildEnvFileCandidates, buildViteArgs, applyLocalConvexEnvForUrl, - isConvexFunctionUnavailableOutput, isLocalConvexUrl, isRunningPid, parseArgs, @@ -16,6 +15,7 @@ import { shouldSeedLocalData, shouldStartDevWorkers, } from "./dev-worktree"; +import { isConvexFunctionUnavailableOutput } from "./seed"; describe("dev-worktree helpers", () => { it("seeds local fixtures and public corpus when Worktrunk starts dev services", () => { diff --git a/scripts/dev-worktree.ts b/scripts/dev-worktree.ts index b6f569fc..2081904e 100644 --- a/scripts/dev-worktree.ts +++ b/scripts/dev-worktree.ts @@ -16,7 +16,6 @@ type Options = { const DEFAULT_ENV_SOURCES = [".env.local"]; const CONVEX_START_TIMEOUT_MS = 120_000; -const CONVEX_FUNCTIONS_READY_TIMEOUT_MS = 120_000; const REACHABILITY_POLL_MS = 500; const RUNTIME_DIR = ".codex/runtime"; const DETACHED_PID_FILE = `${RUNTIME_DIR}/dev-worktree.pid`; @@ -305,57 +304,6 @@ export function runSync( ); } -function runSyncBuffered( - command: string, - args: string[], - extraEnv: Record, -) { - const result = spawnSync(command, args, { - cwd: process.cwd(), - encoding: "utf8", - env: { ...process.env, ...extraEnv }, - }); - - return { - output: `${result.stdout ?? ""}${result.stderr ?? ""}`, - status: result.status ?? 1, - }; -} - -function writeBufferedOutput(output: string) { - if (output) process.stdout.write(output); -} - -export function isConvexFunctionUnavailableOutput(output: string) { - return ( - output.includes("Could not find function for") && - output.includes("Did you forget to run `npx convex dev`") - ); -} - -async function runConvexFunctionWhenReady(args: string[]) { - const startedAt = Date.now(); - - while (true) { - const result = runSyncBuffered("bunx", args, {}); - if (result.status === 0) { - writeBufferedOutput(result.output); - return 0; - } - - if ( - !isConvexFunctionUnavailableOutput(result.output) || - Date.now() - startedAt >= CONVEX_FUNCTIONS_READY_TIMEOUT_MS - ) { - writeBufferedOutput(result.output); - return result.status; - } - - console.log("Convex functions are not queryable yet; retrying..."); - await sleep(REACHABILITY_POLL_MS); - } -} - function spawnManaged(command: string, args: string[]) { const child = spawn(command, args, { cwd: process.cwd(), @@ -549,24 +497,8 @@ async function main() { if (seed.seed) { console.log("Seeding local fixtures and public corpus..."); - const seedStatus = await runConvexFunctionWhenReady([ - "convex", - "run", - "--no-push", - "devSeed:seedLocalFixtures", - ]); + const seedStatus = runSync("bun", ["run", "seed"], {}); if (seedStatus !== 0) exitAfterStoppingManagedChildren(seedStatus); - - const publicCorpusStatus = runSync("bun", ["scripts/public-corpus/seed-public-corpus.ts"], {}); - if (publicCorpusStatus !== 0) exitAfterStoppingManagedChildren(publicCorpusStatus); - - const statsStatus = await runConvexFunctionWhenReady([ - "convex", - "run", - "--no-push", - "statsMaintenance:updateGlobalStatsAction", - ]); - if (statsStatus !== 0) exitAfterStoppingManagedChildren(statsStatus); writeSeedSentinel(convexUrl, convexDeployment?.trim() ?? ""); } diff --git a/scripts/public-corpus/seed-public-corpus.test.ts b/scripts/public-corpus/seed-public-corpus.test.ts index fa5c8eb7..220dab01 100644 --- a/scripts/public-corpus/seed-public-corpus.test.ts +++ b/scripts/public-corpus/seed-public-corpus.test.ts @@ -1,12 +1,138 @@ import { describe, expect, it, vi } from "vitest"; import { + DEFAULT_BATCH_BYTES, + DEFAULT_SEED_CONCURRENCY, + MAX_CONVEX_RUN_ARG_BYTES, buildSeedCorpusRow, + chunkRowsByOwner, isRetryableConvexSeedBatchOutput, + runSeedCorpusBatches, runConvexSeedBatchWithRetry, type SeedBatchRunOnce, + type SeedCorpusRow, + serializeConvexSeedArgs, } from "./seed-public-corpus"; describe("public corpus seed runner", () => { + it("uses portable owner-isolated batches", () => { + const owner = { + handle: "dummy-owner", + displayName: "Dummy Owner", + image: "https://example.invalid/avatar.png", + }; + const rows = Array.from({ length: 4 }, (_, index) => + buildSeedCorpusRow( + { + kind: "skill", + slug: `skill-${index}`, + displayName: `Skill ${index}`, + version: "1.0.0", + skillMd: "x".repeat(50_000), + }, + owner, + ), + ); + + expect(DEFAULT_BATCH_BYTES).toBeGreaterThan(96_000); + expect(DEFAULT_SEED_CONCURRENCY).toBe(24); + expect(chunkRowsByOwner(rows, DEFAULT_BATCH_BYTES)).toEqual([rows.slice(0, 2), rows.slice(2)]); + }); + + it("rejects batch arguments above the portable process limit", () => { + expect(() => + serializeConvexSeedArgs({ + rows: [{ skillMd: "x".repeat(MAX_CONVEX_RUN_ARG_BYTES) }], + }), + ).toThrow("Public corpus batch argument is"); + }); + + it("runs different owners concurrently while serializing each owner", async () => { + const buildRow = (ownerHandle: string, index: number) => + buildSeedCorpusRow( + { + kind: "skill", + slug: `${ownerHandle}-${index}`, + displayName: `${ownerHandle} ${index}`, + version: "1.0.0", + skillMd: "# Fixture", + }, + { + handle: ownerHandle, + displayName: ownerHandle, + image: "https://example.invalid/avatar.png", + }, + ); + const batches = [ + [buildRow("owner-a", 1)], + [buildRow("owner-b", 1)], + [buildRow("owner-a", 2)], + [buildRow("owner-c", 1)], + ]; + const activeOwners = new Set(); + let active = 0; + let maxActive = 0; + const runOnce = vi.fn(async (args: unknown) => { + const rows = (args as { rows: (typeof batches)[number] }).rows; + const ownerHandle = rows[0]!.dummyOwner.handle; + expect(activeOwners.has(ownerHandle)).toBe(false); + activeOwners.add(ownerHandle); + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 5)); + active -= 1; + activeOwners.delete(ownerHandle); + return { status: 0, output: "" }; + }); + + await runSeedCorpusBatches(batches, { + concurrency: 2, + runOnce, + log: vi.fn(), + }); + + expect(runOnce).toHaveBeenCalledTimes(4); + expect(maxActive).toBe(2); + }); + + it("waits for active owners and stops scheduling after a batch fails", async () => { + const buildRow = (ownerHandle: string) => + buildSeedCorpusRow( + { + kind: "skill", + slug: ownerHandle, + displayName: ownerHandle, + version: "1.0.0", + skillMd: "# Fixture", + }, + { + handle: ownerHandle, + displayName: ownerHandle, + image: "https://example.invalid/avatar.png", + }, + ); + const completedOwners: string[] = []; + const runOnce = vi.fn(async (args: unknown) => { + const rows = (args as { rows: SeedCorpusRow[] }).rows; + const ownerHandle = rows[0]!.dummyOwner.handle; + await new Promise((resolve) => setTimeout(resolve, ownerHandle === "owner-a" ? 5 : 20)); + completedOwners.push(ownerHandle); + return ownerHandle === "owner-a" + ? { status: 1, output: "failed" } + : { status: 0, output: "" }; + }); + + await expect( + runSeedCorpusBatches([[buildRow("owner-a")], [buildRow("owner-b")], [buildRow("owner-c")]], { + concurrency: 2, + runOnce, + log: vi.fn(), + }), + ).rejects.toThrow("Public corpus batch 1/3 failed"); + + expect(completedOwners).toEqual(["owner-a", "owner-b"]); + expect(runOnce).toHaveBeenCalledTimes(2); + }); + it("strips retired capability metadata before sending rows to Convex", () => { const owner = { handle: "dummy-owner", diff --git a/scripts/public-corpus/seed-public-corpus.ts b/scripts/public-corpus/seed-public-corpus.ts index 915bb075..5b21e808 100644 --- a/scripts/public-corpus/seed-public-corpus.ts +++ b/scripts/public-corpus/seed-public-corpus.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun -import { spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { readFile } from "node:fs/promises"; +import { assertPreviewSeedTargetAllowed } from "../seed"; import { buildDummyOwnerPool, ownerForCorpusKey, type DummyCorpusOwner } from "./dummyOwners"; import { DEFAULT_PUBLIC_CORPUS_FIXTURE, @@ -14,13 +15,18 @@ type Options = { reset: boolean; limit: number | null; batchBytes: number; + concurrency: number; + previewName: string | null; }; -type SeedCorpusRow = PublicCorpusRow & { +export type SeedCorpusRow = PublicCorpusRow & { dummyOwner: DummyCorpusOwner; }; -const DEFAULT_BATCH_BYTES = 96_000; +// Keep the encoded Convex CLI argument below Linux's per-argument exec limit. +export const DEFAULT_BATCH_BYTES = 120_000; +export const DEFAULT_SEED_CONCURRENCY = 24; +export const MAX_CONVEX_RUN_ARG_BYTES = 130_000; const MAX_SEED_BATCH_ATTEMPTS = 4; const BASE_SEED_BATCH_RETRY_DELAY_MS = 500; @@ -29,10 +35,13 @@ export type SeedBatchRunResult = { output: string; }; -export type SeedBatchRunOnce = (args: unknown) => SeedBatchRunResult; +export type SeedBatchRunOnce = (args: unknown) => SeedBatchRunResult | Promise; async function main() { const options = parseArgs(process.argv.slice(2)); + if (options.previewName) { + assertPreviewSeedTargetAllowed(); + } const text = await readFile(options.fixture, "utf8"); const rows = parseCorpusJsonl(text).slice(0, options.limit ?? undefined); const validation = validateCorpusRows(rows); @@ -48,20 +57,12 @@ async function main() { buildSeedCorpusRow(row, ownerForCorpusKey(corpusKey(row), owners)), ); const batches = chunkRowsByOwner(seedRows, options.batchBytes); - - for (let index = 0; index < batches.length; index += 1) { - const batch = batches[index]!; - const args = { - reset: options.reset && index === 0, - resetOwnerHandles: options.reset ? owners.map((owner) => owner.handle) : [], - rows: batch, - }; - const status = await runConvexSeedBatchWithRetry(args); - if (status !== 0) process.exit(status); - console.log( - `Seeded public corpus batch ${index + 1}/${batches.length} (${batch.length} rows).`, - ); - } + await runSeedCorpusBatches(batches, { + concurrency: options.concurrency, + reset: options.reset, + resetOwnerHandles: owners.map((owner) => owner.handle), + runOnce: (batchArgs) => runConvexSeedBatchOnce(batchArgs, options.previewName), + }); console.log( JSON.stringify( @@ -84,6 +85,8 @@ function parseArgs(args: string[]): Options { reset: false, limit: null, batchBytes: DEFAULT_BATCH_BYTES, + concurrency: DEFAULT_SEED_CONCURRENCY, + previewName: null, }; for (let index = 0; index < args.length; index += 1) { @@ -96,6 +99,12 @@ function parseArgs(args: string[]): Options { options.limit = readPositiveInt(readValue(args, ++index, arg), arg); } else if (arg === "--batch-bytes") { options.batchBytes = readPositiveInt(readValue(args, ++index, arg), arg); + } else if (arg === "--concurrency") { + options.concurrency = readPositiveInt(readValue(args, ++index, arg), arg); + } else if (arg === "--preview-name") { + options.previewName = readValue(args, ++index, arg); + } else if (arg.startsWith("--preview-name=")) { + options.previewName = arg.slice("--preview-name=".length); } else { throw new Error(`Unknown argument: ${arg}`); } @@ -104,7 +113,7 @@ function parseArgs(args: string[]): Options { return options; } -function chunkRowsByOwner(rows: SeedCorpusRow[], maxBytes: number) { +export function chunkRowsByOwner(rows: SeedCorpusRow[], maxBytes: number) { const byOwner = new Map(); for (const row of rows) { const ownerRows = byOwner.get(row.dummyOwner.handle) ?? []; @@ -123,7 +132,7 @@ function chunkRows(rows: SeedCorpusRow[], maxBytes: number) { let currentBytes = 0; for (const row of rows) { - const rowBytes = JSON.stringify(row).length + 2; + const rowBytes = Buffer.byteLength(JSON.stringify(row)) + 2; if (current.length > 0 && currentBytes + rowBytes > maxBytes) { batches.push(current); current = []; @@ -177,20 +186,53 @@ export function isRetryableConvexSeedBatchOutput(output: string) { return output.includes("Data read or written in this mutation changed while it was being run"); } -export function runConvexSeedBatchOnce(args: unknown): SeedBatchRunResult { - const result = spawnSync( +export async function runConvexSeedBatchOnce( + args: unknown, + previewName: string | null = null, +): Promise { + const targetArgs = previewName ? ["--preview-name", previewName] : ["--no-push"]; + const serializedArgs = serializeConvexSeedArgs(args); + const child = spawn( "bunx", - ["convex", "run", "--no-push", "devSeed:seedPublicCorpusBatch", JSON.stringify(args)], + ["convex", "run", ...targetArgs, "devSeed:seedPublicCorpusBatch", serializedArgs], { cwd: process.cwd(), - encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], }, ); - const output = `${result.stdout ?? ""}${result.stderr ?? ""}${ - result.error ? `${result.error.message}\n` : "" - }`; - writeCommandOutput(output); - return { status: result.status ?? 1, output }; + let output = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + output += chunk; + }); + child.stderr.on("data", (chunk: string) => { + output += chunk; + }); + + return await new Promise((resolve) => { + let settled = false; + const finish = (status: number, errorOutput = "") => { + if (settled) return; + settled = true; + output += errorOutput; + writeCommandOutput(output); + resolve({ status, output }); + }; + child.on("error", (error) => finish(1, `${error.message}\n`)); + child.on("close", (status) => finish(status ?? 1)); + }); +} + +export function serializeConvexSeedArgs(args: unknown) { + const serialized = JSON.stringify(args); + const bytes = Buffer.byteLength(serialized); + if (bytes > MAX_CONVEX_RUN_ARG_BYTES) { + throw new Error( + `Public corpus batch argument is ${bytes} bytes; maximum is ${MAX_CONVEX_RUN_ARG_BYTES}`, + ); + } + return serialized; } export async function runConvexSeedBatchWithRetry( @@ -210,7 +252,7 @@ export async function runConvexSeedBatchWithRetry( const log = options.log ?? console.warn; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const result = runOnce(args); + const result = await runOnce(args); if (result.status === 0) return 0; if (!isRetryableConvexSeedBatchOutput(result.output) || attempt >= maxAttempts) { return result.status; @@ -225,6 +267,74 @@ export async function runConvexSeedBatchWithRetry( return 1; } +export async function runSeedCorpusBatches( + batches: SeedCorpusRow[][], + options: { + concurrency: number; + reset?: boolean; + resetOwnerHandles?: string[]; + runOnce: SeedBatchRunOnce; + log?: (message: string) => void; + }, +) { + const entries = batches.map((rows, index) => ({ + index, + ownerHandle: rows[0]?.dummyOwner.handle ?? "", + rows, + })); + const totalBatches = entries.length; + const log = options.log ?? console.log; + + const runEntry = async (entry: (typeof entries)[number], reset = false) => { + const status = await runConvexSeedBatchWithRetry( + { + reset, + resetOwnerHandles: reset ? (options.resetOwnerHandles ?? []) : [], + rows: entry.rows, + }, + { runOnce: options.runOnce }, + ); + if (status !== 0) { + throw new Error(`Public corpus batch ${entry.index + 1}/${totalBatches} failed`); + } + log( + `Seeded public corpus batch ${entry.index + 1}/${totalBatches} (${entry.rows.length} rows).`, + ); + }; + + const firstEntry = options.reset ? entries.shift() : undefined; + if (firstEntry) await runEntry(firstEntry, true); + + const ownerQueues = new Map(); + for (const entry of entries) { + const queue = ownerQueues.get(entry.ownerHandle) ?? []; + queue.push(entry); + ownerQueues.set(entry.ownerHandle, queue); + } + + const queues = Array.from(ownerQueues.values()); + let nextQueue = 0; + let firstError: unknown; + const workerCount = Math.min(options.concurrency, queues.length); + const workers = Array.from({ length: workerCount }, async () => { + while (!firstError) { + const queue = queues[nextQueue++]; + if (!queue) return; + try { + for (const entry of queue) { + if (firstError) return; + await runEntry(entry); + } + } catch (error) { + firstError ??= error; + return; + } + } + }); + await Promise.all(workers); + if (firstError) throw firstError; +} + function readValue(args: string[], index: number, flag: string) { const value = args[index]; if (!value) throw new Error(`Missing value for ${flag}`); diff --git a/scripts/seed.test.ts b/scripts/seed.test.ts new file mode 100644 index 00000000..b36fa6bd --- /dev/null +++ b/scripts/seed.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { assertSeedTargetAllowed, buildSeedSteps, parseSeedArgs } from "./seed"; + +describe("shared seed runner", () => { + it("uses the local deployment selected by the environment by default", () => { + expect(buildSeedSteps(parseSeedArgs([]))).toEqual([ + { + command: "bunx", + args: ["convex", "run", "--no-push", "devSeed:seedLocalFixtures"], + }, + { + command: "bun", + args: ["scripts/public-corpus/seed-public-corpus.ts"], + }, + { + command: "bunx", + args: ["convex", "run", "--no-push", "statsMaintenance:updateGlobalStatsAction"], + }, + ]); + }); + + it("targets every seed step at the same named preview deployment", () => { + expect(buildSeedSteps(parseSeedArgs(["--preview-name", "feature/demo"]))).toEqual([ + { + command: "bunx", + args: ["convex", "run", "--preview-name", "feature/demo", "devSeed:seedLocalFixtures"], + }, + { + command: "bun", + args: ["scripts/public-corpus/seed-public-corpus.ts", "--preview-name", "feature/demo"], + }, + { + command: "bunx", + args: [ + "convex", + "run", + "--preview-name", + "feature/demo", + "statsMaintenance:updateGlobalStatsAction", + ], + }, + ]); + }); + + it("allows only local or explicitly keyed preview targets", () => { + expect(() => + assertSeedTargetAllowed(parseSeedArgs([]), { + CONVEX_DEPLOYMENT: "local:local-amantus-clawdhub", + }), + ).not.toThrow(); + expect(() => + assertSeedTargetAllowed(parseSeedArgs(["--preview-name", "feature/demo"]), { + CONVEX_DEPLOY_KEY: "preview:openclaw:clawhub|secret", + }), + ).not.toThrow(); + + expect(() => + assertSeedTargetAllowed(parseSeedArgs(["--preview-name", "feature/demo"]), { + CONVEX_DEPLOY_KEY: "prod:wry-manatee-359|secret", + }), + ).toThrow("requires a Convex Preview deploy key"); + }); +}); diff --git a/scripts/seed.ts b/scripts/seed.ts new file mode 100644 index 00000000..0cdb82f1 --- /dev/null +++ b/scripts/seed.ts @@ -0,0 +1,150 @@ +#!/usr/bin/env bun + +import { spawnSync } from "node:child_process"; + +type SeedOptions = { + previewName: string | null; +}; + +type SeedStep = { + command: string; + args: string[]; +}; + +const CONVEX_FUNCTIONS_READY_TIMEOUT_MS = 120_000; +const REACHABILITY_POLL_MS = 500; + +export function parseSeedArgs(args: string[]): SeedOptions { + const options: SeedOptions = { previewName: null }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--preview-name") { + options.previewName = readValue(args, ++index, arg); + } else if (arg.startsWith("--preview-name=")) { + options.previewName = arg.slice("--preview-name=".length); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + return options; +} + +export function buildSeedSteps(options: SeedOptions): SeedStep[] { + const convexTargetArgs = options.previewName + ? ["--preview-name", options.previewName] + : ["--no-push"]; + const corpusTargetArgs = options.previewName ? ["--preview-name", options.previewName] : []; + + return [ + { + command: "bunx", + args: ["convex", "run", ...convexTargetArgs, "devSeed:seedLocalFixtures"], + }, + { + command: "bun", + args: ["scripts/public-corpus/seed-public-corpus.ts", ...corpusTargetArgs], + }, + { + command: "bunx", + args: ["convex", "run", ...convexTargetArgs, "statsMaintenance:updateGlobalStatsAction"], + }, + ]; +} + +export function isConvexFunctionUnavailableOutput(output: string) { + return ( + output.includes("Could not find function for") && + output.includes("Did you forget to run `npx convex dev`") + ); +} + +export function assertSeedTargetAllowed( + options: SeedOptions, + env: NodeJS.ProcessEnv = process.env, +) { + if (options.previewName) { + assertPreviewSeedTargetAllowed(env); + return; + } + + const deployment = env.CONVEX_DEPLOYMENT?.trim(); + if ( + deployment === "anonymous-agent" || + deployment?.startsWith("anonymous:") || + deployment?.startsWith("local:") + ) { + return; + } + throw new Error("Shared seed without --deployment requires a local Convex deployment"); +} + +export function assertPreviewSeedTargetAllowed(env: NodeJS.ProcessEnv = process.env) { + if (!env.CONVEX_DEPLOY_KEY?.trim().startsWith("preview:")) { + throw new Error("Shared preview seed requires a Convex Preview deploy key"); + } +} + +async function runStep(step: SeedStep) { + if (step.command !== "bunx" || step.args[0] !== "convex" || step.args[1] !== "run") { + return ( + spawnSync(step.command, step.args, { + cwd: process.cwd(), + env: process.env, + stdio: "inherit", + }).status ?? 1 + ); + } + + const startedAt = Date.now(); + while (true) { + const result = spawnSync(step.command, step.args, { + cwd: process.cwd(), + encoding: "utf8", + env: process.env, + }); + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; + if (result.status === 0) { + if (output) process.stdout.write(output); + return 0; + } + if ( + !isConvexFunctionUnavailableOutput(output) || + Date.now() - startedAt >= CONVEX_FUNCTIONS_READY_TIMEOUT_MS + ) { + if (output) process.stdout.write(output); + return result.status ?? 1; + } + console.log("Convex functions are not queryable yet; retrying..."); + await sleep(REACHABILITY_POLL_MS); + } +} + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function readValue(args: string[], index: number, flag: string) { + const value = args[index]?.trim(); + if (!value) throw new Error(`Missing value for ${flag}`); + return value; +} + +async function main() { + const options = parseSeedArgs(process.argv.slice(2)); + assertSeedTargetAllowed(options); + for (const step of buildSeedSteps(options)) { + const status = await runStep(step); + if (status !== 0) process.exit(status); + } +} + +if (import.meta.main) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} diff --git a/scripts/vercel-build-frontend.test.ts b/scripts/vercel-build-frontend.test.ts new file mode 100644 index 00000000..06089242 --- /dev/null +++ b/scripts/vercel-build-frontend.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { resolveFrontendBuildEnv } from "./vercel-build-frontend"; + +describe("Vercel frontend build environment", () => { + it("derives the preview site URL from the Convex CLI injected cloud URL", () => { + const env = resolveFrontendBuildEnv({ + VERCEL_ENV: "preview", + VITE_CONVEX_URL: "https://paired-preview-123.convex.cloud", + VITE_CONVEX_SITE_URL: "https://wry-manatee-359.convex.site", + }); + + expect(env.VITE_CONVEX_SITE_URL).toBe("https://paired-preview-123.convex.site"); + expect(env.VITE_CLAWHUB_DEPLOY_ENV).toBe("preview"); + }); + + it("preserves an explicit production site URL", () => { + const env = resolveFrontendBuildEnv({ + VERCEL_ENV: "production", + VITE_CONVEX_URL: "https://wry-manatee-359.convex.cloud", + VITE_CONVEX_SITE_URL: "https://api.clawhub.example", + }); + + expect(env.VITE_CONVEX_SITE_URL).toBe("https://api.clawhub.example"); + expect(env.VITE_CLAWHUB_DEPLOY_ENV).toBe("production"); + }); +}); diff --git a/scripts/vercel-build-frontend.ts b/scripts/vercel-build-frontend.ts new file mode 100644 index 00000000..a401031b --- /dev/null +++ b/scripts/vercel-build-frontend.ts @@ -0,0 +1,36 @@ +#!/usr/bin/env bun + +import { spawnSync } from "node:child_process"; +import { resolveConvexSiteUrl } from "../src/lib/convexDeploymentUrl"; + +export function resolveFrontendBuildEnv(env: NodeJS.ProcessEnv) { + const convexSiteUrl = resolveConvexSiteUrl({ + CONVEX_URL: env.CONVEX_URL, + VITE_CONVEX_SITE_URL: env.VERCEL_ENV === "preview" ? undefined : env.VITE_CONVEX_SITE_URL, + VITE_CONVEX_URL: env.VITE_CONVEX_URL, + }); + return { + ...env, + VITE_CONVEX_SITE_URL: convexSiteUrl, + VITE_CLAWHUB_DEPLOY_ENV: env.VERCEL_ENV ?? "development", + }; +} + +function main() { + const result = spawnSync("bun", ["run", "build"], { + cwd: process.cwd(), + env: resolveFrontendBuildEnv(process.env), + stdio: "inherit", + }); + if (result.error) throw result.error; + process.exit(result.status ?? 1); +} + +if (import.meta.main) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/vercel-build.test.ts b/scripts/vercel-build.test.ts new file mode 100644 index 00000000..b7665cda --- /dev/null +++ b/scripts/vercel-build.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { resolveVercelBuildPlan } from "./vercel-build"; + +describe("Vercel build plan", () => { + it("recreates and seeds the branch Convex deployment for previews", () => { + expect( + resolveVercelBuildPlan({ + VERCEL_ENV: "preview", + VERCEL_GIT_COMMIT_REF: "pe/claw-413-pr-previews", + CONVEX_DEPLOY_KEY: "preview:openclaw:clawhub|secret", + }), + ).toEqual([ + { + command: "bunx", + args: [ + "convex", + "deploy", + "--preview-create", + "pe/claw-413-pr-previews", + "--cmd", + "bun scripts/vercel-build-frontend.ts", + "--cmd-url-env-var-name", + "VITE_CONVEX_URL", + ], + }, + { + command: "bun", + args: ["run", "seed", "--", "--preview-name", "pe/claw-413-pr-previews"], + }, + ]); + }); + + it("fails closed when a preview deploy key is missing or has the wrong type", () => { + expect(() => + resolveVercelBuildPlan({ + VERCEL_ENV: "preview", + VERCEL_GIT_COMMIT_REF: "feature/demo", + }), + ).toThrow("Preview builds require a Convex Preview deploy key"); + + expect(() => + resolveVercelBuildPlan({ + VERCEL_ENV: "preview", + VERCEL_GIT_COMMIT_REF: "feature/demo", + CONVEX_DEPLOY_KEY: "prod:wry-manatee-359|secret", + }), + ).toThrow("Preview builds require a Convex Preview deploy key"); + }); + + it("runs the ordinary frontend build for production and rejects deploy credentials", () => { + expect(resolveVercelBuildPlan({ VERCEL_ENV: "production" })).toEqual([ + { + command: "bun", + args: ["scripts/vercel-build-frontend.ts"], + }, + ]); + + expect(() => + resolveVercelBuildPlan({ + VERCEL_ENV: "production", + CONVEX_DEPLOY_KEY: "prod:wry-manatee-359|secret", + }), + ).toThrow("Production Vercel builds must not receive CONVEX_DEPLOY_KEY"); + }); +}); diff --git a/scripts/vercel-build.ts b/scripts/vercel-build.ts new file mode 100644 index 00000000..033de3a2 --- /dev/null +++ b/scripts/vercel-build.ts @@ -0,0 +1,76 @@ +#!/usr/bin/env bun + +import { spawnSync } from "node:child_process"; + +type BuildEnv = { + CONVEX_DEPLOY_KEY?: string; + VERCEL_ENV?: string; + VERCEL_GIT_COMMIT_REF?: string; +}; + +type BuildStep = { + command: string; + args: string[]; +}; + +export function resolveVercelBuildPlan(env: BuildEnv): BuildStep[] { + if (env.VERCEL_ENV !== "preview") { + if (env.VERCEL_ENV === "production" && env.CONVEX_DEPLOY_KEY?.trim()) { + throw new Error("Production Vercel builds must not receive CONVEX_DEPLOY_KEY"); + } + return [{ command: "bun", args: ["scripts/vercel-build-frontend.ts"] }]; + } + + const deployKey = env.CONVEX_DEPLOY_KEY?.trim(); + if (!deployKey?.startsWith("preview:")) { + throw new Error("Preview builds require a Convex Preview deploy key"); + } + + const previewName = env.VERCEL_GIT_COMMIT_REF?.trim(); + if (!previewName) { + throw new Error("Preview builds require VERCEL_GIT_COMMIT_REF"); + } + + return [ + { + command: "bunx", + args: [ + "convex", + "deploy", + "--preview-create", + previewName, + "--cmd", + "bun scripts/vercel-build-frontend.ts", + "--cmd-url-env-var-name", + "VITE_CONVEX_URL", + ], + }, + // --preview-create guarantees an empty backend, so the shared seed stays + // idempotent and avoids a destructive full-corpus reset transaction. + { + command: "bun", + args: ["run", "seed", "--", "--preview-name", previewName], + }, + ]; +} + +function main() { + for (const step of resolveVercelBuildPlan(process.env)) { + const result = spawnSync(step.command, step.args, { + cwd: process.cwd(), + env: process.env, + stdio: "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); + } +} + +if (import.meta.main) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/server/convexProxy.test.ts b/server/convexProxy.test.ts new file mode 100644 index 00000000..17e02558 --- /dev/null +++ b/server/convexProxy.test.ts @@ -0,0 +1,97 @@ +/* @vitest-environment node */ + +import { mockEvent } from "h3"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + buildConvexProxyTarget, + isConvexProxyMethodAllowed, + proxyConvexRequest, + resolveConvexProxyEnv, +} from "./convexProxy"; + +describe("Convex HTTP proxy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("maps API and hosted feed paths to the paired Convex site", () => { + const env = { + VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud", + }; + + expect(buildConvexProxyTarget("/api/v1/skills/demo?include=latest", env)).toBe( + "https://preview-branch-123.convex.site/api/v1/skills/demo?include=latest", + ); + expect(buildConvexProxyTarget("/v1/feeds/plugins", env)).toBe( + "https://preview-branch-123.convex.site/api/v1/feeds/plugins", + ); + }); + + it("allows only read methods when the frontend is a preview", () => { + const previewEnv = { VERCEL_ENV: "preview" }; + expect(isConvexProxyMethodAllowed("GET", previewEnv)).toBe(true); + expect(isConvexProxyMethodAllowed("HEAD", previewEnv)).toBe(true); + expect(isConvexProxyMethodAllowed("POST", previewEnv)).toBe(false); + expect(isConvexProxyMethodAllowed("DELETE", previewEnv)).toBe(false); + expect(isConvexProxyMethodAllowed("POST", { VERCEL_ENV: "production" })).toBe(true); + }); + + it("prefers the build-paired Convex URL over stale Vercel runtime values", () => { + expect( + resolveConvexProxyEnv( + { + VERCEL_ENV: "preview", + VITE_CONVEX_SITE_URL: "https://wry-manatee-359.convex.site", + VITE_CONVEX_URL: "https://wry-manatee-359.convex.cloud", + }, + { + VITE_CLAWHUB_DEPLOY_ENV: "preview", + VITE_CONVEX_SITE_URL: "https://paired-preview-123.convex.site", + VITE_CONVEX_URL: "https://paired-preview-123.convex.cloud", + }, + ), + ).toEqual({ + VERCEL_ENV: "preview", + VITE_CLAWHUB_DEPLOY_ENV: "preview", + VITE_CONVEX_SITE_URL: "https://paired-preview-123.convex.site", + VITE_CONVEX_URL: "https://paired-preview-123.convex.cloud", + }); + }); + + it("proxies reads and exposes the non-secret preview deployment name for proof", async () => { + const fetchMock = vi.fn(async () => { + return new Response(JSON.stringify({ ok: true }), { + headers: { "content-type": "application/json" }, + }); + }); + vi.stubGlobal("fetch", fetchMock); + const event = mockEvent("https://preview.example/api/v1/skills/demo?include=latest"); + + const response = await proxyConvexRequest(event, { + VERCEL_ENV: "preview", + VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud", + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://preview-branch-123.convex.site/api/v1/skills/demo?include=latest", + expect.objectContaining({ method: "GET" }), + ); + expect(response.headers.get("X-ClawHub-Preview-Backend")).toBe("preview-branch-123"); + }); + + it("rejects preview writes without contacting Convex", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const event = mockEvent("https://preview.example/api/v1/skills/demo", { + method: "POST", + }); + + const response = await proxyConvexRequest(event, { + VERCEL_ENV: "preview", + VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud", + }); + + expect(response.status).toBe(405); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/server/convexProxy.ts b/server/convexProxy.ts new file mode 100644 index 00000000..75ae9c4b --- /dev/null +++ b/server/convexProxy.ts @@ -0,0 +1,84 @@ +import { + defineEventHandler, + getRequestURL, + proxyRequest, + type H3Event, + type HTTPResponse, +} from "h3"; +import { convexDeploymentName, resolveConvexSiteUrl } from "../src/lib/convexDeploymentUrl"; + +type ProxyEnv = { + CONVEX_URL?: string; + VERCEL_ENV?: string; + VITE_CLAWHUB_DEPLOY_ENV?: string; + VITE_CONVEX_SITE_URL?: string; + VITE_CONVEX_URL?: string; +}; + +const BUNDLED_PROXY_ENV: ProxyEnv = { + VITE_CLAWHUB_DEPLOY_ENV: import.meta.env.VITE_CLAWHUB_DEPLOY_ENV, + VITE_CONVEX_SITE_URL: import.meta.env.VITE_CONVEX_SITE_URL, + VITE_CONVEX_URL: import.meta.env.VITE_CONVEX_URL, +}; + +export function resolveConvexProxyEnv( + runtimeEnv: ProxyEnv, + bundledEnv: ProxyEnv = BUNDLED_PROXY_ENV, +): ProxyEnv { + return { + ...runtimeEnv, + ...(bundledEnv.VITE_CLAWHUB_DEPLOY_ENV + ? { VITE_CLAWHUB_DEPLOY_ENV: bundledEnv.VITE_CLAWHUB_DEPLOY_ENV } + : {}), + ...(bundledEnv.VITE_CONVEX_SITE_URL + ? { VITE_CONVEX_SITE_URL: bundledEnv.VITE_CONVEX_SITE_URL } + : {}), + ...(bundledEnv.VITE_CONVEX_URL ? { VITE_CONVEX_URL: bundledEnv.VITE_CONVEX_URL } : {}), + }; +} + +function isPreviewFrontend(env: ProxyEnv) { + return env.VERCEL_ENV === "preview" || env.VITE_CLAWHUB_DEPLOY_ENV === "preview"; +} + +export function isConvexProxyMethodAllowed(method: string, env: ProxyEnv) { + if (!isPreviewFrontend(env)) return true; + return method === "GET" || method === "HEAD"; +} + +export function buildConvexProxyTarget(pathAndQuery: string, env: ProxyEnv) { + const requestUrl = new URL(pathAndQuery, "https://clawhub.invalid"); + const targetPath = requestUrl.pathname.startsWith("/v1/feeds/") + ? `/api${requestUrl.pathname}` + : requestUrl.pathname; + const targetUrl = new URL(targetPath, resolveConvexSiteUrl(env)); + targetUrl.search = requestUrl.search; + return targetUrl.toString(); +} + +export async function proxyConvexRequest( + event: H3Event, + env: ProxyEnv = resolveConvexProxyEnv(process.env), +): Promise { + if (!isConvexProxyMethodAllowed(event.req.method, env)) { + return new Response("Disposable previews are read-only.", { + status: 405, + headers: { + Allow: "GET, HEAD", + "Cache-Control": "no-store", + "Content-Type": "text/plain; charset=utf-8", + }, + }); + } + + const requestUrl = getRequestURL(event); + const target = buildConvexProxyTarget(`${requestUrl.pathname}${requestUrl.search}`, env); + const response = await proxyRequest(event, target); + if (isPreviewFrontend(env)) { + const deployment = convexDeploymentName(target); + if (deployment) response.headers.set("X-ClawHub-Preview-Backend", deployment); + } + return response; +} + +export default defineEventHandler((event) => proxyConvexRequest(event)); diff --git a/server/handlers/convexProxy.ts b/server/handlers/convexProxy.ts new file mode 100644 index 00000000..748581fc --- /dev/null +++ b/server/handlers/convexProxy.ts @@ -0,0 +1 @@ +export { default } from "../convexProxy"; diff --git a/specs/deploy.md b/specs/deploy.md index abfa156d..ca17b234 100644 --- a/specs/deploy.md +++ b/specs/deploy.md @@ -1,5 +1,5 @@ --- -summary: "Maintainer deploy checklist: Convex backend, Vercel web app, CLI npm release, and /api rewrites." +summary: "Maintainer deploy checklist: Convex backend, Vercel web app and PR previews, CLI npm release, and API routing." --- # Deploy @@ -153,16 +153,53 @@ Deploy order: 3. wait for Vercel production deploy for the same Git SHA 4. smoke +### Disposable PR previews + +Vercel Preview builds use `bun run build:vercel`. The build entrypoint requires a +Convex Preview deploy key, recreates the branch's Convex preview with +`--preview-create`, builds the frontend with that deployment's URL, and runs the +same `bun run seed` pipeline used by local development against that preview name. + +One-time setup: + +1. In the Convex project settings, generate a Preview deploy key. +2. In the Vercel project, set `CONVEX_DEPLOY_KEY` to that key for the **Preview** + environment only. +3. In the Convex project default environment variables for Preview deployments, + set: + - `CLAWHUB_PREVIEW=1` + - `CLAWHUB_DISABLE_CRONS=1` +4. Do not copy production auth, email, webhook, scanner, worker, backup, or + user-channel secrets into Preview defaults. +5. Remove `CONVEX_DEPLOY_KEY` from the Vercel **Production** environment if it + exists. Production Convex deploys remain manual-only through + `.github/workflows/deploy.yml`. + +The shared seed command fails closed unless its target is local or an explicit +preview name selected with a Convex Preview deploy key. It installs +the committed public corpus plus the same synthetic clean, suspicious, and +malicious presentation states used locally. Staging remains snapshot-backed and +production is never seeded. + +Preview browser traffic is public and read-only. Nitro rejects non-GET/HEAD +requests before proxying and adds `X-ClawHub-Preview-Backend` to proxied preview +responses so smoke proof can record the paired non-secret deployment name. +Authenticated write flows belong in the permanent test environment. + ## 3) Route `/api/*` to Convex -This repo currently uses `vercel.json` rewrites: +Nitro handles `/api/**` and `/v1/feeds/**` through the environment-aware Convex +proxy in `server/convexProxy.ts`. The target comes from the build's +`VITE_CONVEX_SITE_URL`, or is derived from the paired `VITE_CONVEX_URL`. Those +build-time values are compiled into the Nitro server output so stale Vercel +runtime variables cannot redirect a Preview deployment to production. -- `source: /api/:path*` -- `destination: https://.convex.site/api/:path*` +Do not add a production deployment hostname back to `vercel.json`. Static +rewrites would make Vercel previews query production even when their Convex +client points at a disposable backend. -For self-host: - -- update `vercel.json` to your deployment's Convex site URL. +For self-hosting, set `VITE_CONVEX_URL` and optionally +`VITE_CONVEX_SITE_URL` to the intended deployment before building. ## 4) Registry discovery diff --git a/specs/dev-seeding.md b/specs/dev-seeding.md index 6d537383..adcb3f7e 100644 --- a/specs/dev-seeding.md +++ b/specs/dev-seeding.md @@ -18,8 +18,15 @@ Local fixture seeding is command-driven by default: the documented first-run local setup path. - CLI seeding (`bun run seed:dev`) runs the same seed path manually without starting the preview and bypasses the first-run sentinel. +- `bun run seed` is the shared seed pipeline used after local setup and by disposable PR previews. + It installs the same moderation fixtures and committed public corpus, then refreshes global stats. + Without `--preview-name` it accepts only a local Convex deployment; remote use requires an + explicit preview name plus a Convex Preview deploy key. Vercel recreates that preview deployment + before invoking the shared seed, so the corpus import does not perform a destructive reset. - `bun run seed:public-corpus` is the lower-level corpus-only import command. Use it for corpus - fixture work, not as the default local setup command. + fixture work, not as the default local setup command. The importer keeps each dummy owner's + batches serialized while running different owners concurrently, so owner creation remains + deterministic without paying one network round trip per corpus row. - `bun run validate:public-corpus` validates the committed public corpus fixture without seeding. - `internal.devSeed.seedCurrentUserFixtures` remains a dev-only internal action for explicit local development tools/tests that need fixtures cloned to a local user. @@ -34,5 +41,5 @@ should not be exposed as a first-run dashboard button unless the UX and ownershi intentionally revisited. Without `OPENAI_API_KEY`, public corpus import may use zero vectors. That is -acceptable for local setup and layout QA, but semantic search quality will be weaker than an -embedding-backed local database. +acceptable for local setup, disposable PR previews, and layout QA, but semantic search quality will +be weaker than an embedding-backed database. diff --git a/specs/hosted-catalog-feed.md b/specs/hosted-catalog-feed.md index 549073fe..d11a66af 100644 --- a/specs/hosted-catalog-feed.md +++ b/specs/hosted-catalog-feed.md @@ -124,11 +124,11 @@ provides: - `Surrogate-Control: max-age=300, stale-while-revalidate=86400` - `304 Not Modified` for matching `If-None-Match` or `If-Modified-Since` -`vercel.json` exposes `/v1/feeds/plugins`, `/v1/feeds/skills`, and -`/v1/feeds/promotions` as edge-friendly rewrites to the Convex endpoints. The -unversioned `/feeds/*` paths permanently redirect to their versioned paths. The -`registry.openclaw.ai` custom domain must point at the same Vercel project -before the public RFC URLs are enabled. +Nitro exposes `/v1/feeds/plugins`, `/v1/feeds/skills`, and +`/v1/feeds/promotions` through the same environment-aware Convex proxy used for +`/api/*`. The unversioned `/feeds/*` paths permanently redirect to their +versioned paths. The `registry.openclaw.ai` custom domain must point at the same +Vercel project before the public RFC URLs are enabled. The serialized payload uses stable object-key ordering and deterministic entry and install-candidate ordering. Additive fields may be introduced within a diff --git a/src/__tests__/vercel-preview-config.test.ts b/src/__tests__/vercel-preview-config.test.ts new file mode 100644 index 00000000..0b0bd456 --- /dev/null +++ b/src/__tests__/vercel-preview-config.test.ts @@ -0,0 +1,20 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +describe("Vercel preview configuration", () => { + it("uses the environment-aware build entrypoint without production-pinned rewrites", async () => { + const configText = await readFile("vercel.json", "utf8"); + const config = JSON.parse(configText) as { + buildCommand?: string; + rewrites?: unknown[]; + }; + const packageJson = JSON.parse(await readFile("package.json", "utf8")) as { + scripts?: Record; + }; + + expect(config.buildCommand).toBe("bun run build:vercel"); + expect(config.rewrites).toBeUndefined(); + expect(configText).not.toContain("wry-manatee-359"); + expect(packageJson.scripts?.["build:vercel"]).toBe("bun scripts/vercel-build.ts"); + }); +}); diff --git a/src/lib/convexDeploymentUrl.test.ts b/src/lib/convexDeploymentUrl.test.ts new file mode 100644 index 00000000..16284ec7 --- /dev/null +++ b/src/lib/convexDeploymentUrl.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { resolveConvexSiteUrl } from "./convexDeploymentUrl"; + +describe("resolveConvexSiteUrl", () => { + it("derives the paired HTTP Actions origin from a Convex cloud URL", () => { + expect( + resolveConvexSiteUrl({ + VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud", + }), + ).toBe("https://preview-branch-123.convex.site"); + }); + + it("prefers an explicit site URL for custom domains", () => { + expect( + resolveConvexSiteUrl({ + VITE_CONVEX_SITE_URL: "https://api.preview.example/", + VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud", + }), + ).toBe("https://api.preview.example"); + }); + + it("rejects malformed or non-Convex cloud URLs instead of guessing", () => { + expect(() => resolveConvexSiteUrl({ VITE_CONVEX_URL: "https://example.com" })).toThrow( + "Cannot derive a Convex site URL", + ); + expect(() => resolveConvexSiteUrl({ VITE_CONVEX_URL: "not-a-url" })).toThrow( + "Cannot derive a Convex site URL", + ); + }); +}); diff --git a/src/lib/convexDeploymentUrl.ts b/src/lib/convexDeploymentUrl.ts new file mode 100644 index 00000000..87d19fcf --- /dev/null +++ b/src/lib/convexDeploymentUrl.ts @@ -0,0 +1,41 @@ +type ConvexUrlEnv = { + CONVEX_URL?: string; + VITE_CONVEX_SITE_URL?: string; + VITE_CONVEX_URL?: string; +}; + +function parseAbsoluteUrl(value: string | undefined) { + const trimmed = value?.trim(); + if (!trimmed) return null; + try { + return new URL(trimmed); + } catch { + return null; + } +} + +export function resolveConvexSiteUrl(env: ConvexUrlEnv) { + const explicitSiteUrl = parseAbsoluteUrl(env.VITE_CONVEX_SITE_URL); + if (explicitSiteUrl) return explicitSiteUrl.origin; + + const cloudUrl = parseAbsoluteUrl(env.VITE_CONVEX_URL ?? env.CONVEX_URL); + if (cloudUrl?.protocol === "https:" && cloudUrl.hostname.endsWith(".convex.cloud")) { + cloudUrl.hostname = `${cloudUrl.hostname.slice(0, -".convex.cloud".length)}.convex.site`; + return cloudUrl.origin; + } + + if ( + cloudUrl?.protocol === "http:" && + ["localhost", "127.0.0.1", "[::1]"].includes(cloudUrl.hostname) + ) { + return cloudUrl.origin; + } + + throw new Error("Cannot derive a Convex site URL from VITE_CONVEX_URL"); +} + +export function convexDeploymentName(url: string) { + const parsed = new URL(url); + if (!parsed.hostname.endsWith(".convex.site")) return null; + return parsed.hostname.slice(0, -".convex.site".length); +} diff --git a/src/lib/publicApiUrl.ts b/src/lib/publicApiUrl.ts index 37327a29..172358a6 100644 --- a/src/lib/publicApiUrl.ts +++ b/src/lib/publicApiUrl.ts @@ -20,8 +20,8 @@ function resolveAbsoluteBaseUrl(...candidates: Array) { export function publicApiUrl(path: string) { const normalizedPath = normalizeApiPath(path); if (typeof window !== "undefined") { - // In production, Vercel rewrites /api/* to the Convex site. Local Nitro - // intercepts those paths, so local browsers use the Convex site directly. + // Hosted browsers use the same-origin Nitro proxy. Local browsers use the + // Convex site directly so anonymous local backends do not need edge routing. const convexClientBaseUrl = resolveAbsoluteBaseUrl( getRuntimeEnv("VITE_CONVEX_SITE_URL"), getRuntimeEnv("VITE_CONVEX_URL"), diff --git a/src/lib/runtimeEnv.test.ts b/src/lib/runtimeEnv.test.ts index a621d203..c773726d 100644 --- a/src/lib/runtimeEnv.test.ts +++ b/src/lib/runtimeEnv.test.ts @@ -25,6 +25,20 @@ describe("runtimeEnv", () => { import.meta.env.VITE_SITE_URL = originalClientValue; }); + it("prefers the bundled Convex URL for preview SSR", () => { + const originalDeployEnv = import.meta.env.VITE_CLAWHUB_DEPLOY_ENV; + const originalConvexUrl = import.meta.env.VITE_CONVEX_URL; + vi.stubEnv("VITE_CLAWHUB_DEPLOY_ENV", "production"); + vi.stubEnv("VITE_CONVEX_URL", "https://wry-manatee-359.convex.cloud"); + import.meta.env.VITE_CLAWHUB_DEPLOY_ENV = "preview"; + import.meta.env.VITE_CONVEX_URL = "https://paired-preview-123.convex.cloud"; + + expect(getRuntimeEnv("VITE_CONVEX_URL")).toBe("https://paired-preview-123.convex.cloud"); + + import.meta.env.VITE_CLAWHUB_DEPLOY_ENV = originalDeployEnv; + import.meta.env.VITE_CONVEX_URL = originalConvexUrl; + }); + it("throws for missing required env", () => { expect(() => getRequiredRuntimeEnv("VITE_MISSING_VALUE")).toThrow( "Missing required environment variable: VITE_MISSING_VALUE", diff --git a/src/lib/runtimeEnv.ts b/src/lib/runtimeEnv.ts index dad1f7b4..70d8c78d 100644 --- a/src/lib/runtimeEnv.ts +++ b/src/lib/runtimeEnv.ts @@ -9,16 +9,19 @@ function readProcessEnv(name: string) { return readString(process.env?.[name]); } -function readClientMetaEnv(name: string) { - if (typeof window === "undefined") return undefined; +function readMetaEnv(name: string) { return readString((import.meta.env as Record)[name]); } export function getRuntimeEnv(name: string) { - if (typeof window !== "undefined") { - return readClientMetaEnv(name) ?? readProcessEnv(name); + const bundledValue = readMetaEnv(name); + const preferBundledValue = + typeof window !== "undefined" || + (name.startsWith("VITE_") && readMetaEnv("VITE_CLAWHUB_DEPLOY_ENV") === "preview"); + if (preferBundledValue) { + return bundledValue ?? readProcessEnv(name); } - return readProcessEnv(name) ?? readClientMetaEnv(name); + return readProcessEnv(name) ?? bundledValue; } export function getRequiredRuntimeEnv(name: string) { diff --git a/vercel.json b/vercel.json index 185a7c62..7dac9fea 100644 --- a/vercel.json +++ b/vercel.json @@ -1,4 +1,5 @@ { + "buildCommand": "bun run build:vercel", "headers": [ { "source": "/(.*)", @@ -57,24 +58,6 @@ "statusCode": 302 } ], - "rewrites": [ - { - "source": "/api/:path*", - "destination": "https://wry-manatee-359.convex.site/api/:path*" - }, - { - "source": "/v1/feeds/plugins", - "destination": "https://wry-manatee-359.convex.site/api/v1/feeds/plugins" - }, - { - "source": "/v1/feeds/skills", - "destination": "https://wry-manatee-359.convex.site/api/v1/feeds/skills" - }, - { - "source": "/v1/feeds/promotions", - "destination": "https://wry-manatee-359.convex.site/api/v1/feeds/promotions" - } - ], "images": { "sizes": [256, 640, 1024, 1920], "formats": ["image/webp"], diff --git a/vite.config.ts b/vite.config.ts index e92eb23e..dd398df1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -201,6 +201,10 @@ const config = defineConfig({ devtools(), nitro({ serverDir: "server", + handlers: [ + { route: "/api/**", handler: "./server/handlers/convexProxy.ts" }, + { route: "/v1/feeds/**", handler: "./server/handlers/convexProxy.ts" }, + ], rollupConfig: { onwarn: handleRollupWarning, },