- Status: Implemented (MVP1 + MVP2)
- Author: fengmk2
- Created: 2026-06-26
- Source design:
pkg-pr-new-registry-bridge-version-gated-design.md - Tracking repo:
pkg-pr-registry-bridge
Implementation note: the shipped bridge supports only immutable commit refs (
0.0.0-commit.<sha>). PR-number refs (0.0.0-pr.<n>) shown in the examples below were dropped during implementation because a PR ref is mutable (it advances to newer commits), so its generated metadata/tarball could be overwritten and mismatch a consumer's lockfile. Readpr.<n>examples as historical design; usecommit.<sha>for current behavior.
Deployment note: the bridge has since migrated to the Void framework and deploys via
void deploy; the wrangler / own-Cloudflare-account andGITHUB_TOKENspecifics below are historical. See README.md for the current implementation and deployment.
Serving-model note: the shipped bridge no longer builds or fetches preview tarballs at request time. The CPU/memory-heavy work (pack, rewrite, re-pack, hash) moved to a CI publish action that uploads finished artifacts to R2 (see docs/ci-setup.md); the Worker only streams bytes from R2, and a version whose bytes are not there is a 404. As a result the in-Worker on-demand build and the platform-binary redirect to pkg.pr.new described below, the
PKG_PR_NEW_BASEandMAX_TARBALL_BYTESvars, and thetoPkgPrNewUrl/fetchUpstreamTarballmodules were all removed. Read the tarball-build sections (6.x), the upstream-fetch guard, and those vars as historical design; the Worker no longer reaches out to pkg.pr.new at all.
This RFC specifies how to implement the version-gated pkg.pr.new registry bridge as a single Cloudflare Worker.
The bridge is an npm-compatible registry proxy. For a small allowlist of
Vite+ packages it injects synthetic preview versions (0.0.0-pr.<n>,
0.0.0-commit.<sha>) that resolve to pkg.pr.new preview builds, and it
falls back to registry.npmjs.org for everything else. The package name
selects the upstream package; the version pattern selects the source.
The source design document targets a Node.js/Bun server with a local
filesystem cache and the tar CLI. Cloudflare Workers have no filesystem,
no child processes, and a constrained CPU/memory budget. This RFC maps the
design onto Workers primitives: Hono for routing, the Web Streams
compression API plus a pure-JS tar codec for tarball rewriting, the Cache
API plus R2 for caching, and wrangler vars/bindings for configuration.
The end goal is unchanged: make this Bun override install correctly through the bridge configured as the default registry.
{
"overrides": {
"vite": "npm:@voidzero-dev/vite-plus-core@0.0.0-pr.1891"
}
}pkg.pr.new exposes direct tarball URLs but not npm packument endpoints.
Bun/npm overrides and aliases (vite -> npm:@voidzero-dev/vite-plus-core@<version>)
need a real registry that answers GET /<package> with a packument and
GET /<tarball> with a tarball. The bridge supplies that missing layer
while preserving normal npm behavior for normal versions.
For the full product rationale, version-gating rules, and routing semantics, see the source design document. This RFC does not restate them; it specifies the Cloudflare Workers implementation.
- Run the entire bridge as one Cloudflare Worker, deployable with
wrangler. - Serve packuments for the allowlisted packages with injected preview versions, and proxy all other packuments to npm unchanged.
- Generate preview tarballs by rewriting
package/package.jsonof the upstream pkg.pr.new tarball, fully inside the Worker (no shell, no fs). - Cache packuments and tarballs at the edge (Cache API) and durably (R2), with TTLs that respect mutable PR refs and immutable commit refs.
- Keep the supply-chain attack surface narrow: strict allowlist, strict ref validation, no arbitrary upstream URLs, tar path-traversal defense.
Same as the source design: no generic pkg.pr.new proxy, no arbitrary
upstream URLs, no rewriting latest to a preview build, no renaming of
@voidzero-dev/vite-plus-core to vite inside the tarball. Additionally,
this RFC does not cover a multi-region origin or a self-hosted runtime;
Cloudflare's global edge is the deployment target.
- Globally distributed by default, low-latency for
bun installfrom anywhere, no server to operate. - First-class edge cache (Cache API) and object storage (R2) with zero egress fees between Workers and R2, which suits a tarball cache.
wranglergives reproducible config, secrets, and deploys.- Native Web Streams
CompressionStream/DecompressionStreamand Web Crypto cover gzip and integrity hashing without native addons.
The trade-off is that the Node-centric parts of the source design (fs temp
dirs, tar CLI) must be replaced. Section 7 covers the constraints this
imposes and the mitigations.
bun / npm install
|
v
+---------------------------+
| Cloudflare Worker |
| (Hono router) |
+---------------------------+
| | | \
packument | | tarball | purge \ fallback
v v v v
+----------+ +----------+ +------+ +-------------------+
| build | | build | | KV | | proxy to |
| packument| | tarball | |refs | | registry.npmjs.org|
+----------+ +----------+ +------+ +-------------------+
| |
| v
| fetch pkg.pr.new tarball
| -> DecompressionStream(gzip)
| -> tar rewrite package.json
| -> CompressionStream(gzip)
|
v
+-------------------------------+
| Caching tiers |
| - Cache API (caches.default) | edge, response-level
| - R2 bucket (TARBALL_CACHE) | durable, generated tarballs
| - KV (optional, PREVIEW_REFS)| dynamic preview-ref registry
+-------------------------------+
Request classes:
- Packument for an allowlisted preview package: fetch npm packument, inject configured preview versions, return.
- Packument for any other package: pass through to npm.
- Preview tarball (
/tarballs/...): generate-or-serve-from-cache. - npm tarball or any other path: pass through to npm.
POST /-/purge: invalidate cache entries (MVP2+).
Configuration moves from process.env to Worker env bindings. Example
wrangler.toml:
name = "pkg-pr-registry-bridge"
main = "src/index.ts"
compatibility_date = "2026-06-01"
compatibility_flags = ["nodejs_compat"]
[vars]
PUBLIC_BASE_URL = "https://registry-bridge.example.com"
NPM_REGISTRY = "https://registry.npmjs.org"
PKG_PR_NEW_BASE = "https://pkg.pr.new"
PREVIEW_OWNER = "voidzero-dev"
PREVIEW_REPO = "vite-plus"
# MVP: static preview refs. Comma-separated `pr.<n>` / `commit.<sha>`.
VITE_PLUS_PREVIEW_REFS = "pr.1891"
# Tarball safety limit (bytes). Lower than the design's 200MB; see 7.2.
MAX_TARBALL_BYTES = "67108864"
[[r2_buckets]]
binding = "TARBALL_CACHE"
bucket_name = "pkg-pr-registry-bridge-tarballs"
# Optional, MVP2: dynamic preview-ref registry + purge state.
[[kv_namespaces]]
binding = "PREVIEW_REFS"
id = "<kv-namespace-id>"GITHUB_TOKEN (for optional PR/commit existence checks in MVP2) is a
secret set with wrangler secret put GITHUB_TOKEN, never a plaintext var.
compatibility_flags = ["nodejs_compat"] is enabled so a pure-JS tar
library and Buffer-style helpers work, but the implementation must not
depend on node:fs, node:child_process, or any filesystem behavior.
The Env type is defined once and threaded through Hono:
export interface Env {
PUBLIC_BASE_URL: string
NPM_REGISTRY: string
PKG_PR_NEW_BASE: string
PREVIEW_OWNER: string
PREVIEW_REPO: string
VITE_PLUS_PREVIEW_REFS: string
MAX_TARBALL_BYTES: string
TARBALL_CACHE: R2Bucket
PREVIEW_REFS?: KVNamespace
GITHUB_TOKEN?: string
}Hono on Workers. The router must accept three packument spellings plus the tarball, purge, and catch-all fallback routes.
| Method | Path | Handler |
|---|---|---|
| GET | /:pkg (unscoped, e.g. /vite-plus) |
packument |
| GET | /@:scope/:pkg (decoded scoped) |
packument |
| GET | /@:scope%2f:pkg (encoded scoped) |
packument |
| GET | /tarballs/:pkg/:file |
preview tarball |
| GET | /tarballs/@:scope/:pkg/:file |
preview tarball |
| POST | /-/purge |
purge (MVP2) |
| GET | /-/* and any other path |
npm fallback proxy |
Implementation notes:
- npm clients request scoped packages as a single percent-encoded segment
(
/@voidzero-dev%2Fvite-plus-core). Normalize the package name early withdecodeURIComponentand re-encode only when building outbound URLs. - Tarball filenames are
<version>.tgz; the version is parsed from the filename, the package name from the preceding segment(s). - Because the bridge is the default registry, the catch-all proxy is essential: any path the bridge does not own is forwarded to npm.
The pure functions from the source design are runtime-agnostic and used verbatim:
export const PREVIEW_PACKAGES = new Set([
'@voidzero-dev/vite-plus-core',
'vite-plus',
])
export type PreviewRef =
| { type: 'pr'; ref: string }
| { type: 'commit'; ref: string }
export function parsePreviewVersion(version: string): PreviewRef | null {
const pr = version.match(/^0\.0\.0-pr\.(\d+)$/)
if (pr) return { type: 'pr', ref: pr[1] }
const commit = version.match(/^0\.0\.0-commit\.([0-9a-f]{7,40})$/i)
if (commit) return { type: 'commit', ref: commit[1] }
return null
}toPkgPrNewUrl is parameterized by env instead of module constants so
owner/repo/base come from bindings:
export function toPkgPrNewUrl(env: Env, packageName: string, version: string) {
const preview = parsePreviewVersion(version)
if (!preview) return null
return `${env.PKG_PR_NEW_BASE}/${env.PREVIEW_OWNER}/${env.PREVIEW_REPO}` +
`/${packageName}@${preview.ref}`
}Configured preview refs come from parseConfiguredPreviewRefs(env.VITE_PLUS_PREVIEW_REFS)
(MVP), or from KV merged with the env value (MVP2), producing
{ type, ref, version, tag }[].
GET /vite-plus
GET /@voidzero-dev/vite-plus-core (or %2F encoded)
Flow:
- Normalize the package name.
-
fetch(${env.NPM_REGISTRY}/${encoded})for the upstream packument, forwardingAccept(support the abbreviatedapplication/vnd.npm.install-v1+jsonpackument to reduce size). - If the package is not in
PREVIEW_PACKAGES, stream the npm response straight back (see 6.6). - Otherwise parse JSON, then for each configured preview ref:
- Read the upstream
package/package.jsonfor that ref (see 6.4.1). - Build version metadata: start from registry-safe fields of the
upstream package.json, set
name/version, rewrite preview dependencies, and setdist.tarballto${PUBLIC_BASE_URL}/tarballs/${encodeURIComponent(name)}/${version}.tgz. - Insert into
versions[version]anddist-tags[tag].
- Read the upstream
- Leave existing
versionsandlatestuntouched. Return JSON with the cache headers from 6.8.
The design reads package/package.json from an extracted temp dir. On
Workers we instead stream-parse the tarball and stop at that entry:
fetchthe pkg.pr.new tarball, pipe throughDecompressionStream('gzip').- Feed the decompressed byte stream to a streaming tar reader; resolve as
soon as the
package/package.jsonentry is fully read, then abort the rest of the stream.
To avoid downloading the tarball twice (once for the packument's
package.json, once for the tarball endpoint), the first fetch generates and
stores the full rewritten tarball in R2 (6.7). The packument then reads the
already-rewritten package.json and, when integrity is enabled, the stored
tarball's hash from R2 metadata. This makes packument integrity and the
served tarball consistent by construction.
MVP1 simplification: if integrity is omitted (6.5), the packument only
needs the upstream package.json, so the early-exit stream read alone is
enough and tarball generation can stay lazy until the tarball endpoint is
hit.
Generated tarballs differ from upstream, so upstream integrity must not be reused. Hashing uses Web Crypto over the final gzip bytes:
async function digests(bytes: Uint8Array) {
const [sha1, sha512] = await Promise.all([
crypto.subtle.digest('SHA-1', bytes),
crypto.subtle.digest('SHA-512', bytes),
])
return {
shasum: hex(sha1), // dist.shasum
integrity: `sha512-${base64(sha512)}`, // dist.integrity (SRI)
}
}- MVP1: omit
dist.integrityanddist.shasum. npm/bun compute and pin integrity from the downloaded tarball; installs still succeed. Never emit a wrong value. - MVP2+: compute from the generated bytes and store on the R2 object's custom metadata, so the packument can advertise integrity without re-hashing.
Two fallback cases:
- Non-allowlisted package: proxy packument and tarball to npm unchanged.
- Allowlisted package, normal (non-preview) version: serve the npm
packument with preview versions injected; normal-version tarball URLs in
that packument still point at npm and are fetched directly by the client
(or pass through the bridge to npm). The bridge never rewrites
latest.
Proxy implementation:
async function proxyToNpm(env: Env, req: Request, path: string) {
const upstream = new Request(`${env.NPM_REGISTRY}${path}`, {
method: 'GET',
headers: filterHopByHop(req.headers),
})
const res = await fetch(upstream, { cf: { cacheEverything: false } })
// Stream body straight through; preserve content-type and caching.
return new Response(res.body, { status: res.status, headers: res.headers })
}Because Cloudflare follows redirects and npm sometimes 301s tarball hosts,
the proxy follows redirects by default. Hop-by-hop and host headers are
stripped before forwarding.
GET /tarballs/vite-plus/0.0.0-pr.1891.tgz
GET /tarballs/@voidzero-dev/vite-plus-core/0.0.0-pr.1891.tgz
Flow (Workers-native rewrite of the design's extract/repack):
- Parse package name + synthetic version from the path; reject if the
package is not in
PREVIEW_PACKAGES(404) or the version is not a valid preview (400). - Cache lookup: Cache API first, then R2 by key
tarball:<name>:<version>. On hit, stream the cached tarball back. - Miss: build the pkg.pr.new URL,
fetchthe upstream.tgz, enforcingMAX_TARBALL_BYTESwhile reading. DecompressionStream('gzip')-> streaming tar reader -> for each entry:package/package.json: parse, runrewritePackageJson(set name/version, rewrite preview deps/peerDeps/optionalDeps), re-emit with a recomputed tar header (new size/checksum).- any other entry: validate the path (6.9) and pass through unchanged.
- Streaming tar writer ->
CompressionStream('gzip')-> the final body. - Tee the output: one branch to the client
Response, one branch buffered for R2put(with integrity metadata when enabled), scheduled viactx.waitUntilso the client is not blocked on the write. - Set cache headers per 6.8.
Because only one small file changes, the rewrite is a streaming transform:
all large binary entries pass through without being buffered, keeping peak
memory near the size of package.json plus codec windows. See 7.2.
rewritePackageJson and rewritePreviewDependencies are reused verbatim
from the source design.
Three tiers:
- Cache API (
caches.default): response-level edge cache keyed by the request URL. Populated on read, honored before any compute. - R2 (
TARBALL_CACHE): durable store for generated tarballs so a cold edge does not re-download and re-rewrite from pkg.pr.new. Keys:tarball/<name>/<version>.tgz. Commit-ref objects are written once and treated as immutable; PR-ref objects carry a short freshness window. - KV (
PREVIEW_REFS, MVP2): dynamic list of preview refs to inject, and purge bookkeeping, so adding a ref does not require a redeploy.
Header policy mirrors the design:
PR refs (mutable): Cache-Control: public, max-age=300 (5 min)
Commit refs (stable): Cache-Control: public, max-age=31536000, immutable
ETag: "<name>-<version>-<contentHash>"
Packument cache key incorporates a hash of the configured preview refs so a
ref change naturally produces a new cached entry:
packument:<name>:<refsHash>.
ctx.waitUntil is used to populate Cache API and R2 after the response is
returned, so cache writes never add latency to the install.
The Worker enforces the design's restrictions at the edge:
- Owner/repo are fixed by env (
voidzero-dev/vite-plus); request input never selects them. - Only
PREVIEW_PACKAGESreceive synthetic versions; all else proxies. - Ref validation: PR
^[0-9]+$, commit^[0-9a-f]{7,40}$/i. No arbitrary upstream URLs, ever. - Tar safety on every passthrough entry: reject absolute paths,
..segments, and anything outsidepackage/. EnforceMAX_TARBALL_BYTESwhile reading the upstream stream and abort on overflow. Reject a tarball with nopackage/package.json. -
POST /-/purge(MVP2) requires an auth token (secret) and only accepts a{ package, version }body within the allowlist.
POST /-/purge { "package": "...", "version": "0.0.0-pr.1891" }
Validates the body against the allowlist, deletes the R2 object, and calls
caches.default.delete for the corresponding tarball and packument URLs.
Authenticated via a bearer secret.
The design's temp-dir extraction and tar CLI are replaced by in-Worker
streaming codecs:
- gzip: native
DecompressionStream/CompressionStream. - tar: a pure-JS streaming reader/writer (see 8). No
node:fs, noexecFile('tar').
- Memory is ~128 MB per isolate, so buffering a 200 MB tarball fully (the
design's max) is not viable. Mitigation: stream the rewrite so only
package/package.jsonis buffered; pass through all other entries. SetMAX_TARBALL_BYTESto a conservative default (64 MB) and make it a var. - CPU time is bounded per request. gzip recompression of a large tarball is the main cost; R2 caching means it runs at most once per (name, version) per region, amortized across all installs.
- Worst case (cold edge + cold R2 + very large tarball) should fail safe with a clear 5xx rather than silently truncate.
- Each preview tarball generation makes a small number of subrequests (npm packument, pkg.pr.new tarball, R2, Cache API), well within limits.
- The tee-to-R2 write happens in
ctx.waitUntil, decoupled from the client response lifetime.
Changing VITE_PLUS_PREVIEW_REFS in [vars] requires wrangler deploy.
MVP2 moves the ref list to KV so it can be updated without redeploying and
purged via the purge endpoint.
- HTTP router: Hono (first-class Workers support, tiny, typed
env). - gzip: native Web Streams compression API (no dependency).
- tar: a pure-JS, dependency-free streaming tar codec that runs on Workers
(e.g.
nanotar, or a vendored minimal reader/writer). Requirement: it must not touchnode:fsand must support streaming so large entries are not fully buffered. This is the single most important library decision and will be validated with a spike before committing. - hashing: Web Crypto
crypto.subtle(SHA-1, SHA-512). - storage: R2 binding + Cache API + optional KV.
Adapted from the design's layout for a Worker entrypoint:
src/
index.ts # Hono app, route table, fetch handler
config.ts # Env typing, parse vars, base URLs
registry/
parsePackageName.ts
fetchNpmPackument.ts
buildPackument.ts
buildVersionMetadata.ts
proxyToNpm.ts
preview/
parsePreviewVersion.ts
parseConfiguredPreviewRefs.ts
toPkgPrNewUrl.ts
packages.ts # PREVIEW_PACKAGES
tarball/
fetchUpstreamTarball.ts # fetch + size guard
streamRewriteTarball.ts # gunzip -> tar rewrite -> gzip
rewritePackageJson.ts
digests.ts # SHA-1 / SHA-512 SRI
cache/
cacheKey.ts
edgeCache.ts # caches.default helpers
r2Cache.ts # R2 get/put with metadata
kvRefs.ts # MVP2 dynamic refs
security/
validateTarballPath.ts
limits.ts
auth.ts # purge auth
wrangler.toml
test/ # vitest + @cloudflare/vitest-pool-workers
- Unit tests for the pure functions (
parsePreviewVersion,parseConfiguredPreviewRefs,toPkgPrNewUrl,rewritePackageJson,validateTarballPath) run as plain vitest. - Integration tests run inside the Workers runtime via
@cloudflare/vitest-pool-workers(Miniflare), with R2/KV bindings mocked by the pool andfetchto npm/pkg.pr.new stubbed with fixtures. - A golden tarball fixture (a real pkg.pr.new vite-plus build) verifies the
rewrite:
package/package.jsonname/version/deps rewritten, all other entries byte-identical, and the repacked tar is valid gzip+tar. - End-to-end smoke test mirrors the design's validation flow: point a
scratch project's
bunfig.tomlat awrangler devURL, runbun install, and assert thenode_modules/*/package.jsonname/version expectations.
wrangler deployfrom CI on the default branch;wrangler devfor local.- Create the R2 bucket and (MVP2) KV namespace via
wranglerbefore first deploy. - Secrets (
GITHUB_TOKEN, purge token) viawrangler secret put. - A custom domain/route maps
registry-bridge.example.comto the Worker;PUBLIC_BASE_URLmust match it sodist.tarballURLs are correct.
MVP1 (default registry + PR refs, no integrity):
- Hono app, packument inject for
pr.<n>, npm fallback proxy. - Streaming tarball rewrite, R2 + Cache API caching.
dist.integrity/shasumomitted.
MVP2 (commit refs, dist-tags, validation, purge):
commit.<sha>refs with immutable caching.- Preview dist-tags (
pr-1891,commit-a832a55). - GitHub API existence checks (PR/commit) using a secret token.
- KV-backed dynamic refs and authenticated
POST /-/purge. - Computed SHA-512 integrity / SHA-1 shasum stored in R2 metadata.
MVP3 (scale/extras):
- More Vite+ workspace packages via config.
- GitHub webhook-driven cache invalidation.
- Health/metrics endpoint and structured logging (Workers Analytics / Logpush).
- tar codec: confirm a streaming, fs-free library handles real vite-plus tarballs within CPU/memory limits, or vendor a minimal one.
- Integrity timing: advertise integrity in MVP1 (requires generating the tarball during packument build) or defer to MVP2 as written here.
- pkg.pr.new tarball URL shape and redirects: confirm the
name@<ref>form resolves to a single fetchable.tgz. - Abbreviated vs full packument: ensure both
Acceptvariants are handled so all package managers resolve correctly. - Max tarball size: pick the production
MAX_TARBALL_BYTESfrom the actual size distribution of vite-plus preview builds.
Packument handler (integrity-deferred MVP1 path):
app.get('/:pkg{.+}', async (c) => {
const name = normalizePackageName(c.req.param('pkg'))
const npmRes = await fetch(`${c.env.NPM_REGISTRY}/${encode(name)}`, {
headers: { accept: c.req.header('accept') ?? 'application/json' },
})
if (!PREVIEW_PACKAGES.has(name)) {
return new Response(npmRes.body, npmRes) // pass-through
}
const packument = await npmRes.json()
const refs = parseConfiguredPreviewRefs(c.env.VITE_PLUS_PREVIEW_REFS)
packument['dist-tags'] ||= {}
packument.versions ||= {}
for (const ref of refs) {
const pkgJson = await readUpstreamPackageJson(c.env, name, ref) // stream early-exit
packument.versions[ref.version] = buildPreviewVersionMetadata({
env: c.env, packageName: name, version: ref.version, upstreamPackageJson: pkgJson,
})
packument['dist-tags'][ref.tag] = ref.version
}
return json(packument, cacheHeadersForRefs(refs))
})Streaming tarball rewrite (shape):
export async function streamRewriteTarball(
upstream: ReadableStream<Uint8Array>,
packageName: string,
version: string,
): Promise<ReadableStream<Uint8Array>> {
const entries = readTarEntries(upstream.pipeThrough(new DecompressionStream('gzip')))
const out = createTarWriter()
;(async () => {
for await (const entry of entries) {
validateTarballPath(entry.name)
if (entry.name === 'package/package.json') {
const pkg = JSON.parse(await entry.text())
const rewritten = rewritePackageJson(pkg, packageName, version)
out.add('package/package.json', `${JSON.stringify(rewritten, null, 2)}\n`)
} else {
out.passthrough(entry) // header + body, not buffered
}
}
out.end()
})()
return out.readable.pipeThrough(new CompressionStream('gzip'))
}