Merge pull request #10179 from EthanThatOneKid/fix/accept-header-issue-10164

fix(cli): add Accept: application/json header to registry fetch
This commit is contained in:
shadcn
2026-04-20 12:15:20 +04:00
committed by GitHub
4 changed files with 244 additions and 4 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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(
"<!DOCTYPE html><html><body>Error: Specific header missing</body></html>",
{
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", () => {

View File

@@ -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) {