Read OSS

The Registry Protocol: Namespace Resolution, Fetching, and Dependency Trees

Advanced

Prerequisites

  • Article 1: architecture-overview
  • Understanding of graph algorithms (topological sort)
  • Familiarity with HTTP APIs and Zod schema validation

The Registry Protocol: Namespace Resolution, Fetching, and Dependency Trees

As we saw in Part 1, shadcn/ui distributes components as static JSON files fetched over HTTP. But the simplicity of "fetch a JSON file" hides a sophisticated 4-stage pipeline: parsing namespace syntax, constructing URLs with placeholder expansion, fetching with caching and auth headers, and recursively resolving the full dependency tree using topological sort. This article traces a single shadcn add @acme/button invocation through every layer.

Namespace Parsing and URL Construction

When you type @acme/button, the first thing that happens is namespace parsing. The parser.ts module splits the string using a regular expression pattern:

/^(@[a-zA-Z0-9](?:[a-zA-Z0-9-_]*[a-zA-Z0-9])?)\/(.+)$/

This produces { registry: "@acme", item: "button" }. Names without an @ prefix default to the built-in @shadcn registry.

The parsed result flows into builder.ts, which performs three operations:

  1. Registry lookup: Finds the URL template from the merged BUILTIN_REGISTRIES + config.registries
  2. Placeholder expansion: Replaces {name} and {style} in the URL template
  3. Environment variable substitution: Expands ${VAR_NAME} patterns via env.ts
sequenceDiagram
    participant User
    participant Parser as parser.ts
    participant Builder as builder.ts
    participant Env as env.ts

    User->>Parser: "@acme/button"
    Parser->>Builder: {registry: "@acme", item: "button"}
    Builder->>Builder: Lookup "@acme" in registries
    Builder->>Builder: Replace {name} → "button"
    Builder->>Builder: Replace {style} → "radix-nova"
    Builder->>Env: Expand ${API_TOKEN}
    Env-->>Builder: resolved URL + headers
    Builder-->>User: {url, headers}

The registry config supports two formats. A simple string like "https://acme.com/r/{name}.json", or an object with auth options:

{
  "url": "https://acme.com/r/{name}.json",
  "params": { "version": "latest" },
  "headers": { "Authorization": "Bearer ${ACME_TOKEN}" }
}

Header values go through the same ${VAR} expansion, but there's a subtlety: shouldIncludeHeader checks whether expansion actually changed the value. If an env var isn't set, the header is silently omitted rather than sending an empty auth token. This allows teams to share components.json across environments where some developers have registry access and others don't.

Tip: The @shadcn registry URL template is ${REGISTRY_URL}/styles/{style}/{name}.json. The {style} placeholder means the registry serves different variants per style — this is how the same button name resolves to different implementations depending on your configured style.

The Fetcher: Caching, Proxies, and Error Handling

Once URLs are constructed, fetcher.ts handles HTTP transport. The module maintains an in-memory promise cache:

const registryCache = new Map<string, Promise<any>>()

This is a promise cache, not a result cache. When two concurrent requests target the same URL, the second gets the same in-flight promise — no duplicate HTTP requests. The cache stores promises before awaiting them (line 115-117), which is the correct pattern for deduplication.

sequenceDiagram
    participant A as Request A
    participant B as Request B
    participant Cache as Promise Cache
    participant HTTP as HTTP

    A->>Cache: Has "button.json"?
    Cache-->>A: No
    A->>HTTP: fetch("button.json")
    A->>Cache: Store promise
    B->>Cache: Has "button.json"?
    Cache-->>B: Yes (pending promise)
    HTTP-->>A: Response
    Note over A,B: Both resolve with same data

Proxy support is automatic via the https_proxy environment variable, using HttpsProxyAgent. Auth headers come from the registry context module.

Error handling is particularly thorough. The fetcher parses RFC 7807 problem detail responses (lines 64-87), extracting detail or message fields from JSON error responses before mapping HTTP status codes to specific error types: 401 → RegistryUnauthorizedError, 404 → RegistryNotFoundError, 410 → RegistryGoneError, 403 → RegistryForbiddenError.

The fetcher also handles local files via fetchRegistryLocal, supporting tilde expansion for home directory paths. This enables local development workflows where registry items are JSON files on disk.

The Registry Context: Header Propagation

Between the builder and fetcher sits the context module — a simple but critical piece of glue. context.ts maintains a global mapping of URL → headers:

interface RegistryContext {
  headers: Record<string, Record<string, string>>
}

When the builder resolves a namespaced item to a URL with auth headers, those headers are stored in context. When the fetcher makes the HTTP request, it retrieves headers by URL. This separation means the recursive dependency resolver doesn't need to thread headers through every function call — they're available globally for any URL that needs them.

The context merges new headers with existing ones rather than replacing them. This matters during dependency resolution: if @acme/button depends on @acme/utils, the headers for both URLs need to coexist.

Recursive Dependency Resolution

The resolver.ts module is the most complex part of the registry system. The resolveRegistryTree function takes a list of component names and returns a fully resolved installation bundle.

flowchart TD
    A["resolveRegistryTree(['button'])"] --> B["fetchRegistryItems(['button'])"]
    B --> C{Has registryDependencies?}
    C -->|Yes| D["resolveDependenciesRecursively()"]
    C -->|No| E["Add to payload"]
    D --> F["Fetch each dependency"]
    F --> G{Dependency has deps?}
    G -->|Yes| D
    G -->|No| H["Add to items"]
    H --> I["Collect all items"]
    E --> I
    I --> J["topologicalSortRegistryItems()"]
    J --> K["Merge: files, deps, cssVars, css, fonts"]
    K --> L["Return resolved tree"]

The recursive resolver at resolveDependenciesRecursively handles three categories of dependencies:

  1. URLs and local files: Fetched directly, then their own dependencies are recursively resolved
  2. Namespaced items (@acme/utils): Resolved through the builder with proper auth headers
  3. Plain names (button): Collected as registry names for later index-based resolution

A visited set prevents infinite loops from circular dependencies. Each dependency is only processed once, even if it appears in multiple dependency chains.

Topological Sort with Kahn's Algorithm

After collecting all items, the resolver must order them so dependencies are installed before dependents. This is implemented as Kahn's algorithm at topologicalSortRegistryItems.

flowchart TD
    A["Build adjacency list + in-degree map"] --> B["Find all nodes with in-degree 0"]
    B --> C["Add to queue"]
    C --> D{Queue empty?}
    D -->|No| E["Dequeue node → sorted list"]
    E --> F["Decrement in-degree of dependents"]
    F --> G{Any new in-degree 0?}
    G -->|Yes| C
    G -->|No| D
    D -->|Yes| H{sorted.length === items.length?}
    H -->|Yes| I["Return sorted"]
    H -->|No| J["Append remaining (circular deps)"]
    J --> I

The implementation is notable for how it handles identity. Each item gets a hash computed from its name and source (computeItemHash). This matters because the same component name can appear from different registries — @acme/button and @other/button might both resolve to items named "button" but they're different items.

If a cycle is detected (sorted items < total items), the algorithm doesn't fail. Instead, it appends the remaining items at the end (lines 722-740). This is a pragmatic choice — circular dependencies in component registries are rare but possible, and crashing the install is worse than a slightly suboptimal order.

After sorting, registry:theme items are bubbled to the front of the list. Themes need to be processed first since they define the CSS variables that components reference.

The Error System

The registry module has a rich error hierarchy defined in errors.ts. The base RegistryError carries:

  • code: One of 14 enum values for programmatic handling
  • statusCode: HTTP status for network errors
  • context: Structured metadata about what went wrong
  • suggestion: Human-readable fix recommendation
  • timestamp: When the error occurred

The 14 specialized error types cover the full surface area: RegistryNotFoundError, RegistryUnauthorizedError, RegistryForbiddenError, RegistryGoneError, RegistryFetchError, RegistryNotConfiguredError, RegistryLocalFileError, RegistryParseError, RegistryMissingEnvironmentVariablesError, RegistryInvalidNamespaceError, ConfigMissingError, ConfigParseError, RegistriesIndexParseError, and InvalidConfigIconLibraryError.

Tip: When building a custom registry, return RFC 7807 problem detail JSON for error responses. The shadcn fetcher will extract the detail field and surface it to users, giving much better error messages than generic HTTP status descriptions.

Built-in Registry and Constants

The constants.ts file is the system's ground truth. The REGISTRY_URL defaults to https://ui.shadcn.com/r but can be overridden via the REGISTRY_URL environment variable. The built-in registry is defined as:

export const BUILTIN_REGISTRIES = {
  "@shadcn": `${REGISTRY_URL}/styles/{style}/{name}.json`,
}

This URL template uses both {name} and {style} placeholders. When your config has style: "radix-nova", requesting @shadcn/button resolves to https://ui.shadcn.com/r/styles/radix-nova/button.json.

Built-in registries cannot be overridden — the getRawConfig function explicitly checks for this and throws if a user tries to redefine @shadcn in their components.json.

What's Next

We've traced how @acme/button turns into a fully resolved dependency tree. But what happens to the raw source code once it's fetched? In Part 3, we'll explore the AST-level transformation pipeline — how ts-morph rewrites imports, PostCSS resolves style maps, and icon libraries get swapped — adapting every component to your project's specific configuration.