mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-01 00:24:20 +00:00
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:
5
.changeset/quiet-amber-field.md
Normal file
5
.changeset/quiet-amber-field.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user