Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,7 @@
# Local dev origin (the Vite dev server). Production value is in `.env.production`.
PUBLIC_BASE_URL=http://localhost:5173
NPM_REGISTRY=https://registry.npmjs.org
PKG_PR_NEW_BASE=https://pkg.pr.new
PREVIEW_OWNER=voidzero-dev
PREVIEW_REPO=vite-plus
# Packages the tarball endpoint serves and whose pkg.pr.new dependency URLs are
# routed through the bridge. Exact names or `prefix*` patterns.
# Packages the tarball endpoint serves. Exact names or `prefix*` patterns.
WORKSPACE_PACKAGES=vite-plus,@voidzero-dev/vite-plus-*
# Max upstream tarball size (bytes). 64 MiB.
MAX_TARBALL_BYTES=67108864
93 changes: 35 additions & 58 deletions .github/actions/publish-preview/dist/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -318,72 +318,27 @@ var HttpError = class extends Error {
}
};

// src/preview/packages.ts
var PREVIEW_PACKAGES = /* @__PURE__ */ new Set([
"@voidzero-dev/vite-plus-core",
"vite-plus"
]);
function isWorkspacePackage(name, env) {
const patterns = (env.WORKSPACE_PACKAGES ?? "").split(",").map((s) => s.trim()).filter(Boolean);
if (patterns.length === 0) return PREVIEW_PACKAGES.has(name);
for (const pattern of patterns) {
if (pattern.endsWith("*")) {
if (name.startsWith(pattern.slice(0, -1))) return true;
} else if (name === pattern) {
return true;
}
}
return false;
}

// src/preview/parsePreviewVersion.ts
function commitVersion(sha) {
return `0.0.0-commit.${sha}`;
}
function shaToVersion(ref) {
return /^[0-9a-f]{7,40}$/i.test(ref) ? commitVersion(ref) : null;
}

// src/tarball/rewritePackageJson.ts
var DEPENDENCY_FIELDS = [
"dependencies",
"peerDependencies",
"optionalDependencies"
];
function pkgPrNewUrlToVersion(spec, env) {
if (!env.PKG_PR_NEW_BASE || !env.PREVIEW_OWNER || !env.PREVIEW_REPO) {
return null;
}
const prefix = `${env.PKG_PR_NEW_BASE}/${env.PREVIEW_OWNER}/${env.PREVIEW_REPO}/`;
if (!spec.startsWith(prefix)) return null;
const rest = spec.slice(prefix.length);
const at = rest.lastIndexOf("@");
if (at <= 0) return null;
const name = rest.slice(0, at);
if (!isWorkspacePackage(name, env)) return null;
return shaToVersion(rest.slice(at + 1));
}
function rewriteDependencies(deps, version, env, pinned) {
function rewriteDependencies(deps, version, pinned) {
if (!deps) return deps;
const next = { ...deps };
for (const [name, spec] of Object.entries(deps)) {
if (pinned.has(name)) {
next[name] = version;
continue;
}
const refVersion = pkgPrNewUrlToVersion(spec, env);
if (refVersion) next[name] = refVersion;
for (const name of Object.keys(deps)) {
if (pinned.has(name)) next[name] = version;
}
return next;
}
function rewritePackageJson(pkg, packageName, version, env, batch) {
const pinned = batch ?? PREVIEW_PACKAGES;
function rewritePackageJson(pkg, packageName, version, batch) {
const next = { ...pkg };
next.name = packageName;
next.version = version;
for (const field of DEPENDENCY_FIELDS) {
if (next[field]) {
next[field] = rewriteDependencies(next[field], version, env, pinned);
next[field] = rewriteDependencies(next[field], version, batch);
}
}
return next;
Expand Down Expand Up @@ -467,9 +422,9 @@ async function parsePackageJson(gzippedTarball) {
throw new HttpError(422, "Invalid package/package.json in upstream tarball");
}
}
async function buildPreviewTarball(gzippedTarball, packageName, version, env, batch) {
async function buildPreviewTarball(gzippedTarball, packageName, version, batch) {
const { files, pkgEntry, pkg } = await parsePackageJson(gzippedTarball);
const rewritten = rewritePackageJson(pkg, packageName, version, env, batch);
const rewritten = rewritePackageJson(pkg, packageName, version, batch);
const rewrittenBytes = encodePackageJson(rewritten);
const out = [];
for (const file of files) {
Expand All @@ -486,6 +441,11 @@ async function buildPreviewTarball(gzippedTarball, packageName, version, env, ba
return { tarball, packageJson: rewritten, shasum, integrity };
}

// src/preview/parsePreviewVersion.ts
function commitVersion(sha) {
return `0.0.0-commit.${sha}`;
}

// src/preview/parseConfiguredPreviewRefs.ts
function parseSingleRef(value) {
const commit = value.match(/^commit\.([0-9a-f]{7,40})$/i);
Expand Down Expand Up @@ -524,6 +484,26 @@ import {
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { promisify } from "node:util";

// src/preview/packages.ts
var PREVIEW_PACKAGES = /* @__PURE__ */ new Set([
"@voidzero-dev/vite-plus-core",
"vite-plus"
]);
function isWorkspacePackage(name, env) {
const patterns = (env.WORKSPACE_PACKAGES ?? "").split(",").map((s) => s.trim()).filter(Boolean);
if (patterns.length === 0) return PREVIEW_PACKAGES.has(name);
for (const pattern of patterns) {
if (pattern.endsWith("*")) {
if (name.startsWith(pattern.slice(0, -1))) return true;
} else if (name === pattern) {
return true;
}
}
return false;
}

// .github/actions/publish-preview/src/localPack.ts
var execFileAsync = promisify(execFile);
function parsePackagesInput(raw) {
return raw.split(/[\n,]/).map((s) => s.trim()).filter(Boolean);
Expand Down Expand Up @@ -653,11 +633,8 @@ async function main() {
cwd
);
const packages = dirs.map((dir) => ({ dir, manifest: readManifest(dir) }));
const env = {
PUBLIC_BASE_URL: bridge,
WORKSPACE_PACKAGES: input("workspace-packages") || "vite-plus,@voidzero-dev/vite-plus-*"
};
const batch = assertValidBatch(packages, env);
const workspacePackages = input("workspace-packages") || "vite-plus,@voidzero-dev/vite-plus-*";
const batch = assertValidBatch(packages, { WORKSPACE_PACKAGES: workspacePackages });
console.log(`publishing ${version} (${packages.length} packages) to ${bridge}`);
const post = (path, body, label) => withRetry(label, async () => {
const res = await fetch(`${bridge}${path}`, {
Expand All @@ -679,7 +656,7 @@ async function main() {
const { dir, manifest } = packages[i];
const packed = await nextPack;
if (i + 1 < packages.length) nextPack = startPack(i + 1);
const build = await buildPreviewTarball(packed, manifest.name, version, env, batch);
const build = await buildPreviewTarball(packed, manifest.name, version, batch);
await uploadTarball(bridge, token, manifest.name, version, build.tarball, build.shasum);
const pkg = {
name: manifest.name,
Expand Down
15 changes: 6 additions & 9 deletions .github/actions/publish-preview/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
import { relative } from 'node:path'
import { buildPreviewTarball } from '../../../../src/tarball/buildPreviewTarball'
import { parseConfiguredPreviewRefs } from '../../../../src/preview/parseConfiguredPreviewRefs'
import type { RewriteEnv } from '../../../../src/tarball/rewritePackageJson'
import {
assertValidBatch,
expandPackageDirs,
Expand Down Expand Up @@ -106,16 +105,14 @@ async function main(): Promise<void> {
)
const packages = dirs.map((dir) => ({ dir, manifest: readManifest(dir) }))

// Locally packed manifests never carry pkg.pr.new URL deps, so the
// pkg.pr.new fields of the shared config stay unset here.
const env: RewriteEnv = {
PUBLIC_BASE_URL: bridge,
WORKSPACE_PACKAGES: input('workspace-packages') || 'vite-plus,@voidzero-dev/vite-plus-*',
}
// The workspace allowlist gates which packed manifests may publish and which
// deps the batch must cover.
const workspacePackages =
input('workspace-packages') || 'vite-plus,@voidzero-dev/vite-plus-*'

// The batch (every name publishing together) drives dependency rewriting;
// validation fails up front, before anything is packed or uploaded.
const batch = assertValidBatch(packages, env)
const batch = assertValidBatch(packages, { WORKSPACE_PACKAGES: workspacePackages })

console.log(`publishing ${version} (${packages.length} packages) to ${bridge}`)

Expand Down Expand Up @@ -152,7 +149,7 @@ async function main(): Promise<void> {
const { dir, manifest } = packages[i]
const packed = await nextPack
if (i + 1 < packages.length) nextPack = startPack(i + 1)
const build = await buildPreviewTarball(packed, manifest.name, version, env, batch)
const build = await buildPreviewTarball(packed, manifest.name, version, batch)
await uploadTarball(bridge, token, manifest.name, version, build.tarball, build.shasum)
const pkg = {
name: manifest.name,
Expand Down
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026-present, VoidZero Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
47 changes: 31 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,21 @@ See [`rfcs/0001-pkg-pr-new-registry-bridge-cloudflare-workers.md`](./rfcs/0001-p
for the full design, and [`examples/bun-validation`](./examples/bun-validation)
for a runnable example.

To run preview builds for a different project, fork and reconfigure: the upstream
repo, package allowlist, and origin are all configuration. See
[`docs/self-hosting.md`](./docs/self-hosting.md).

## Why a separate registry

npm caps a package's packument (the metadata document listing every version) at
100 MB uncompressed, and clients download it in full on every fresh install. A
build per commit consumes that budget: Drizzle ORM hit the cap after roughly 763
releases, and the registry then blocked new publishes for about a month ([vlt.io
on packument size limits](https://www.vlt.io/blog/packument-size-limits)). The
bridge keeps per-commit previews out of npm's packument. It serves them from a
separate registry as synthetic versions over a bounded, TTL-pruned ref set, so
the real packument stays small and previews install with normal npm semantics.

## How it works

- **Packument** (`GET /vite-plus`, `GET /@voidzero-dev/vite-plus-core`): fetches
Expand All @@ -55,16 +70,13 @@ for a runnable example.
too, so they resolve through the bridge like the other preview packages, and
the package manager downloads only the binary for the current platform
(reading os/cpu from the packument) instead of all of them. The publish
action pins any dep on a package of the same publish batch; the Worker's
on-demand fallback equivalently rewrites the pkg.pr.new dependency URLs
found in upstream tarballs. The binaries are large (tens of MB), so they are
packed + hashed in CI (where there is no per-request limit) and uploaded;
the binary's `package.json` version is rewritten to the synthetic version so
it matches what the resolver expects (pnpm's strict store check rejects a
mismatch). A platform binary not yet uploaded for a registered ref redirects
to pkg.pr.new as a best-effort fallback. The small preview packages can also
be built in-Worker on demand as a fallback (they are small enough to stay
within the limits).
action pins any dep on a package of the same publish batch. The binaries are
large (tens of MB), so they are packed + hashed in CI (where there is no
per-request limit) and uploaded; the binary's `package.json` version is
rewritten to the synthetic version so it matches what the resolver expects
(pnpm's strict store check rejects a mismatch). Everything is served from R2:
a version whose bytes are not there 404s, the Worker never builds a tarball or
redirects to pkg.pr.new on demand.
- **Publishing** (`POST /-/publish`, `PUT /-/tarball/...`): the
[publish action](#publishing-from-ci) packs each locally built package
directory (in the same CI job that produced the artifacts, no pkg.pr.new
Expand Down Expand Up @@ -166,8 +178,8 @@ curl -I https://registry-bridge.viteplus.dev/voidzero-dev/vite-plus@1891
# x-pkg-name-key: vite-plus
```

Both `GET` and `HEAD` carry `x-commit-key`/`x-pkg-name-key`. Note the bridge
rewrites a preview tarball's transitive deps to versions (not pkg.pr.new URLs),
Both `GET` and `HEAD` carry `x-commit-key`/`x-pkg-name-key`. Note a published
preview tarball's transitive deps are pinned to versions (not pkg.pr.new URLs),
so for a full install of the meta-package with its platform binaries, use the
registry + `pr-<n>` tag above rather than this URL as a bare dependency.

Expand Down Expand Up @@ -218,10 +230,8 @@ are uploaded with `void secret put`:
| --- | --- |
| `PUBLIC_BASE_URL` | Public origin of the bridge; used in `dist.tarball` URLs. Must match the deployed route. |
| `NPM_REGISTRY` | npm fallback registry (`https://registry.npmjs.org`). |
| `PKG_PR_NEW_BASE` | pkg.pr.new base (`https://pkg.pr.new`). |
| `PREVIEW_OWNER` / `PREVIEW_REPO` | Fixed upstream repo (`voidzero-dev` / `vite-plus`). |
| `WORKSPACE_PACKAGES` | Allowlist for the tarball endpoint and pkg.pr.new-URL dep routing. Exact names or `prefix*`, e.g. `vite-plus,@voidzero-dev/vite-plus-*`. |
| `MAX_TARBALL_BYTES` | Max upstream tarball size (default 64 MiB). |
| `WORKSPACE_PACKAGES` | Allowlist for the tarball endpoint. Exact names or `prefix*`, e.g. `vite-plus,@voidzero-dev/vite-plus-*`. |

Bindings/secrets:

Expand All @@ -248,7 +258,8 @@ For local admin testing, put `ADMIN_TOKEN=…` in `.env.local` (gitignored).

Deploys to the [Void](https://void.cloud) managed platform with `void deploy`;
Void provisions the Worker and the `STORAGE` R2 bucket (no Cloudflare account
needed).
needed). To run an independent bridge for another project (fork, configure,
deploy, wire CI), follow [`docs/self-hosting.md`](./docs/self-hosting.md).

```bash
# One-time: authenticate and set the admin secret on the project.
Expand Down Expand Up @@ -298,3 +309,7 @@ repository secret (`void auth token` copies one to your clipboard): the
platform refuses to mint deploy tokens for pull_request events, which run
untrusted code. Run the smoke test locally with `pnpm smoke <url>`, and deploy
staging by hand with `pnpm deploy:staging`.

## License

[MIT](./LICENSE)
Loading