diff --git a/.changeset/quiet-amber-field.md b/.changeset/quiet-amber-field.md new file mode 100644 index 0000000000..f1cd53cabe --- /dev/null +++ b/.changeset/quiet-amber-field.md @@ -0,0 +1,5 @@ +--- +"shadcn": patch +--- + +Send `Accept: application/vnd.shadcn.v1+json, application/json;q=0.9` and `User-Agent: shadcn` on registry fetches so servers using HTTP content negotiation can reliably serve JSON to the CLI. Fixes #10164. diff --git a/apps/v4/content/docs/registry/getting-started.mdx b/apps/v4/content/docs/registry/getting-started.mdx index 1616b69980..2b96384a81 100644 --- a/apps/v4/content/docs/registry/getting-started.mdx +++ b/apps/v4/content/docs/registry/getting-started.mdx @@ -144,6 +144,106 @@ npm run dev Your files will now be served at `http://localhost:3000/r/[NAME].json` eg. `http://localhost:3000/r/hello-world.json`. +## Content negotiation + +The `shadcn` CLI supports **HTTP Content Negotiation**. This allows you to host your registry at any endpoint — including the root of your domain — and serve different content depending on who is asking. + +From a single URL, you can serve: + +- **HTML** to browsers — a landing page, documentation, or marketing site. +- **JSON** to the `shadcn` CLI — an installable registry item. +- **Markdown** to AI agents and LLMs — a machine-readable version of your content. + +The client signals its preference using the `Accept` request header, and your server decides what to return. + +### Request headers + +When the CLI makes a request to a registry, it sends the following headers: + +- **User-Agent**: `shadcn` +- **Accept**: `application/vnd.shadcn.v1+json, application/json;q=0.9` + +### Root hosting + +By checking these headers on your server, you can route CLI traffic to an installable registry item while keeping browser traffic flowing to your documentation or homepage. + +The examples below assume your built registry item is served at `/r/index.json`. Adjust the path to match your output. + +In Next.js, express this as a rewrite in `next.config.ts`. This keeps the negotiation in the routing layer and avoids a Proxy function for this static rewrite: + +```typescript title="next.config.ts" showLineNumbers +import type { NextConfig } from "next" + +const nextConfig: NextConfig = { + async rewrites() { + return { + beforeFiles: [ + { + source: "/", + has: [ + { + type: "header", + key: "accept", + value: "(.*)application/vnd\\.shadcn\\.v1\\+json(.*)", + }, + ], + destination: "/r/index.json", + }, + { + source: "/", + has: [ + { + type: "header", + key: "user-agent", + value: "shadcn", + }, + ], + destination: "/r/index.json", + }, + ], + } + }, + async headers() { + return [ + { + source: "/", + headers: [{ key: "Vary", value: "Accept, User-Agent" }], + }, + ] + }, +} + +export default nextConfig +``` + +Or, in an Express.js server: + +```javascript title="server.js" showLineNumbers +app.get("/", (req, res) => { + res.vary("Accept") + res.vary("User-Agent") + + // Check if the client prefers the shadcn vendor type. + if (req.accepts("application/vnd.shadcn.v1+json")) { + return res.json(registryData) + } + + // Optional: Secondary check for the User-Agent. + if (req.get("User-Agent") === "shadcn") { + return res.json(registryData) + } + + // Otherwise, serve your documentation or homepage. + res.send(htmlContent) +}) +``` + +This enables: + +- **Branded Registry URLs**: `shadcn add https://ui.example.com` +- **Shorter URLs**: Users type your domain root, not `/r/` or `/registry/` sub-paths. +- **Easy Mnemonics**: Easier for users to remember and share your registry. + ## Publish your registry To make your registry available to other developers, you can publish it by deploying your project to a public URL. diff --git a/packages/shadcn/src/registry/fetcher.test.ts b/packages/shadcn/src/registry/fetcher.test.ts index 549addb04e..7d30448d3e 100644 --- a/packages/shadcn/src/registry/fetcher.test.ts +++ b/packages/shadcn/src/registry/fetcher.test.ts @@ -10,6 +10,7 @@ import { http, HttpResponse } from "msw" import { setupServer } from "msw/node" import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest" +import { clearRegistryContext, setRegistryHeaders } from "./context" import { clearRegistryCache, fetchRegistry } from "./fetcher" const server = setupServer( @@ -204,6 +205,134 @@ describe("fetchRegistry", () => { expect(result[0]).toMatchObject({ name: "button" }) expect(result[1]).toMatchObject({ name: "card" }) }) + + it("should send specific Accept and User-Agent headers", async () => { + let acceptHeader: string | null = null + let userAgentHeader: string | null = null + server.use( + http.get(`${REGISTRY_URL}/header-test.json`, ({ request }) => { + acceptHeader = request.headers.get("accept") + userAgentHeader = request.headers.get("user-agent") + return HttpResponse.json({ + name: "header-test", + type: "registry:ui", + }) + }) + ) + + await fetchRegistry(["header-test.json"], { useCache: false }) + expect(acceptHeader).toBe( + "application/vnd.shadcn.v1+json, application/json;q=0.9" + ) + expect(userAgentHeader).toBe("shadcn") + }) + + it("should allow per-registry headers to override the default Accept and User-Agent", async () => { + let acceptHeader: string | null = null + let userAgentHeader: string | null = null + server.use( + http.get(`${REGISTRY_URL}/override-test.json`, ({ request }) => { + acceptHeader = request.headers.get("accept") + userAgentHeader = request.headers.get("user-agent") + return HttpResponse.json({ + name: "override-test", + type: "registry:ui", + }) + }) + ) + + setRegistryHeaders({ + [`${REGISTRY_URL}/override-test.json`]: { + Accept: "application/custom+json", + "User-Agent": "custom-client/1.0", + }, + }) + + await fetchRegistry(["override-test.json"], { useCache: false }) + + expect(acceptHeader).toBe("application/custom+json") + expect(userAgentHeader).toBe("custom-client/1.0") + + clearRegistryContext() + }) + + it("should allow lowercase per-registry headers to override the default Accept and User-Agent", async () => { + let acceptHeader: string | null = null + let userAgentHeader: string | null = null + server.use( + http.get( + `${REGISTRY_URL}/lowercase-override-test.json`, + ({ request }) => { + acceptHeader = request.headers.get("accept") + userAgentHeader = request.headers.get("user-agent") + return HttpResponse.json({ + name: "lowercase-override-test", + type: "registry:ui", + }) + } + ) + ) + + setRegistryHeaders({ + [`${REGISTRY_URL}/lowercase-override-test.json`]: { + accept: "application/custom+json", + "user-agent": "custom-client/1.0", + }, + }) + + await fetchRegistry(["lowercase-override-test.json"], { useCache: false }) + + expect(acceptHeader).toBe("application/custom+json") + expect(userAgentHeader).toBe("custom-client/1.0") + + clearRegistryContext() + }) + + it("should send specific Accept header for direct external URLs", async () => { + let acceptHeader: string | null = null + server.use( + http.get("https://external.com/registry/item.json", ({ request }) => { + acceptHeader = request.headers.get("accept") + return HttpResponse.json({ + name: "item", + type: "registry:ui", + }) + }) + ) + + await fetchRegistry(["https://external.com/registry/item.json"], { + useCache: false, + }) + expect(acceptHeader).toBe( + "application/vnd.shadcn.v1+json, application/json;q=0.9" + ) + }) + + it("should successfully fetch when the server requires the specific Shadcn Accept header (Content Negotiation)", async () => { + server.use( + http.get(`${REGISTRY_URL}/content-negotiation.json`, ({ request }) => { + const accept = request.headers.get("accept") + if (!accept?.includes("application/vnd.shadcn.v1+json")) { + return new HttpResponse( + "Error: Specific header missing", + { + status: 200, + headers: { "Content-Type": "text/html" }, + } + ) + } + return HttpResponse.json({ + name: "content-negotiation", + type: "registry:ui", + }) + }) + ) + + const [result] = await fetchRegistry(["content-negotiation.json"], { + useCache: false, + }) + expect(result).toMatchObject({ name: "content-negotiation" }) + }) }) describe("clearRegistryCache", () => { diff --git a/packages/shadcn/src/registry/fetcher.ts b/packages/shadcn/src/registry/fetcher.ts index e5df988310..8bc76265df 100644 --- a/packages/shadcn/src/registry/fetcher.ts +++ b/packages/shadcn/src/registry/fetcher.ts @@ -14,7 +14,7 @@ import { } from "@/src/registry/errors" import { registryItemSchema } from "@/src/schema" import { HttpsProxyAgent } from "https-proxy-agent" -import fetch from "node-fetch" +import fetch, { Headers } from "node-fetch" import { z } from "zod" const agent = process.env.https_proxy @@ -50,12 +50,18 @@ export async function fetchRegistry( const fetchPromise = (async () => { // Get headers from context for this URL. const headers = getRegistryHeadersFromContext(url) + const requestHeaders = new Headers({ + Accept: "application/vnd.shadcn.v1+json, application/json;q=0.9", + "User-Agent": "shadcn", + }) + + for (const [key, value] of Object.entries(headers)) { + requestHeaders.set(key, value) + } const response = await fetch(url, { agent, - headers: { - ...headers, - }, + headers: requestHeaders, }) if (!response.ok) {