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
160 changes: 99 additions & 61 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { parseConfiguredPreviewRefs } from './preview/parseConfiguredPreviewRefs'
import {
getConfiguredRefs,
getConfiguredRefsWithEtag,
latestVersionByPr,
registerRef,
unregisterRef,
Expand All @@ -34,6 +35,7 @@ import {
} from './tarball/getPreviewBuild'
import type { PreviewMeta } from './tarball/buildPreviewTarball'
import { metaKey, tarballKey, tarballUrl } from './cache/r2Cache'
import { kvCachedText } from './cache/kvCache'
import { requireAdmin } from './security/auth'
import {
packumentCacheControl,
Expand All @@ -48,6 +50,36 @@ import {
*/
const UNPUBLISHED_PREVIEW_TIME = '2020-01-01T00:00:00.000Z'

// Output cache for the assembled packument. Void does not edge-cache the Worker
// response, so without this the ~440KB packument is re-assembled and re-stringified
// on every request. Keyed by the refs-index etag + a hash of the static env refs;
// a short TTL bounds npm stable-version drift (preview freshness comes from the
// etag, not the TTL).
const PACKUMENT_OUT_PREFIX = 'pkgt/'
const PACKUMENT_OUT_TTL_S = 60

/**
* Stable short hash of the static env-configured refs (VITE_PLUS_PREVIEW_REFS),
* folded into the output-cache key. KV persists across deploys, so the key must
* change when these change; the R2 etag only covers runtime ref changes.
*/
function hashRefsEnv(refs: string | undefined): string {
let h = 5381
const s = refs ?? ''
for (let i = 0; i < s.length; i++) h = (Math.imul(h, 33) + s.charCodeAt(i)) | 0
return (h >>> 0).toString(36)
}

/** Build the packument HTTP response from an already-serialized body. */
function packumentResponse(body: string): Response {
return new Response(body, {
headers: {
'content-type': 'application/json; charset=utf-8',
'cache-control': packumentCacheControl(),
},
})
}

type HonoEnv = { Bindings: Env }

export const app = new Hono<HonoEnv>()
Expand Down Expand Up @@ -370,68 +402,74 @@ app.get('*', async (c) => {
const { name } = pkgReq
if (!isWorkspacePackage(name, c.env)) return redirectToNpm(c.env, c.req.raw)

// The npm packument fetch, the npm `time` fetch, and the refs read are all
// independent; overlap them. `time` comes from npm's FULL packument (the
// abbreviated form we serve omits it) but is sourced separately so the served
// response keeps the compact abbreviated version docs.
const [base, npmTime, refs] = await Promise.all([
getNpmPackumentCached(c.env, name),
getNpmTimeCached(c.env, name),
getConfiguredRefs(c.env),
])

const packument: Record<string, any> =
base ?? { name, 'dist-tags': {}, versions: {} }

packument.name = name
packument['dist-tags'] ??= {}
packument.versions ??= {}

// pnpm's time-based resolution (`minimum-release-age`) hard-errors without a
// `time` map (ERR_PNPM_MISSING_TIME). Seed it from npm's real publish times;
// each injected preview version's entry is its server-stamped publish time
// (UNPUBLISHED_PREVIEW_TIME until published), added in the loop below. `npmTime`
// is a fresh per-request object (cache parse or fetch), so mutate it in place.
const time: Record<string, string> = npmTime
packument.time = time

// Inject each configured ref. The R2 meta reads are independent, so run them
// concurrently; each writes a distinct version/time key, and a failing ref is
// isolated so it can't break installs of the package's other versions. After
// the deploy-time warm step these are R2 hits and don't touch upstream.
await Promise.all(
refs.map(async (ref) => {
try {
const preview = await getPreviewMeta(c.env, name, ref.version)
packument.versions[ref.version] = buildVersionMetadata(
c.env,
name,
ref.version,
preview,
)
time[ref.version] = preview.publishedAt ?? UNPUBLISHED_PREVIEW_TIME
} catch (err) {
console.warn(`Failed to inject preview ref ${ref.version}:`, err)
}
}),
)

// Mutable `pr-<n>` dist-tags: point each PR at its latest-published commit
// version present in this packument, so `<pkg>@pr-<n>` installs the PR's head
// build. The per-commit versions stay immutable; only the tag moves.
for (const [prNum, version] of latestVersionByPr(
refs,
(v) => v in packument.versions,
)) {
packument['dist-tags'][`pr-${prNum}`] = version
}

return new Response(JSON.stringify(packument), {
headers: {
'content-type': 'application/json; charset=utf-8',
'cache-control': packumentCacheControl(),
},
// Read the refs first: its etag keys the output cache below, and the refs feed
// the assembly on a miss. Any ref change rewrites the index → new etag → the
// key changes → automatic invalidation (no explicit purge), and R2 is strongly
// consistent read-after-write, so a just-published preview shows up on the very
// next request.
const { refs, etag } = await getConfiguredRefsWithEtag(c.env)
const cacheKey = `${PACKUMENT_OUT_PREFIX}${name}/${etag ?? 'none'}.${hashRefsEnv(c.env.VITE_PLUS_PREVIEW_REFS)}`

const body = await kvCachedText(c.env, cacheKey, PACKUMENT_OUT_TTL_S, async () => {
// Miss: the npm packument fetch and the npm `time` fetch are independent, so
// overlap them. `time` comes from npm's FULL packument (the abbreviated form
// we serve omits it) but is sourced separately so the served response keeps
// the compact abbreviated version docs.
const [base, npmTime] = await Promise.all([
getNpmPackumentCached(c.env, name),
getNpmTimeCached(c.env, name),
])

const packument: Record<string, any> =
base ?? { name, 'dist-tags': {}, versions: {} }

packument.name = name
packument['dist-tags'] ??= {}
packument.versions ??= {}

// pnpm's time-based resolution (`minimum-release-age`) hard-errors without a
// `time` map (ERR_PNPM_MISSING_TIME). Seed it from npm's real publish times;
// each injected preview version's entry is its server-stamped publish time
// (UNPUBLISHED_PREVIEW_TIME until published), added in the loop below. `npmTime`
// is a fresh per-request object (cache parse or fetch), so mutate it in place.
const time: Record<string, string> = npmTime
packument.time = time

// Inject each configured ref. The R2 meta reads are independent, so run them
// concurrently; each writes a distinct version/time key, and a failing ref is
// isolated so it can't break installs of the package's other versions. After
// the deploy-time warm step these are R2 hits and don't touch upstream.
await Promise.all(
refs.map(async (ref) => {
try {
const preview = await getPreviewMeta(c.env, name, ref.version)
packument.versions[ref.version] = buildVersionMetadata(
c.env,
name,
ref.version,
preview,
)
time[ref.version] = preview.publishedAt ?? UNPUBLISHED_PREVIEW_TIME
} catch (err) {
console.warn(`Failed to inject preview ref ${ref.version}:`, err)
}
}),
)

// Mutable `pr-<n>` dist-tags: point each PR at its latest-published commit
// version present in this packument, so `<pkg>@pr-<n>` installs the PR's head
// build. The per-commit versions stay immutable; only the tag moves.
for (const [prNum, version] of latestVersionByPr(
refs,
(v) => v in packument.versions,
)) {
packument['dist-tags'][`pr-${prNum}`] = version
}

return JSON.stringify(packument)
})

return packumentResponse(body)
})

/** Non-GET methods fall through to npm. */
Expand Down
30 changes: 30 additions & 0 deletions src/cache/kvCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,33 @@ export async function kvCached<T>(
}
return value
}

/**
* Like {@link kvCached}, but for an already-serialized string body: stores and
* serves it verbatim (`get(..., 'text')` / `put(value)`), with no JSON parse or
* re-stringify round-trip. Use when the cached value is the exact bytes to serve
* (e.g. a large assembled response) and re-encoding it would be wasted work. The
* fetcher always produces a body, so the result is always cached.
*/
export async function kvCachedText(
env: Env,
key: string,
ttlSeconds: number,
fetcher: () => Promise<string>,
): Promise<string> {
try {
const cached = await env.KV.get(key, 'text')
if (cached !== null) return cached
} catch (err) {
console.warn(`KV cache read failed for ${key}:`, err)
}

const value = await fetcher()
try {
await env.KV.put(key, value, { expirationTtl: ttlSeconds })
} catch (err) {
// Best-effort population; log so a failing runtime stays visible.
console.warn(`KV cache write failed for ${key}:`, err)
}
return value
}
20 changes: 16 additions & 4 deletions src/preview/getConfiguredRefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,20 @@ async function mutateRefIndex(
* Resolve the preview refs to inject into packuments: the static
* `VITE_PLUS_PREVIEW_REFS` var merged with refs registered at runtime in R2.
*/
export async function getConfiguredRefs(
export async function getConfiguredRefsWithEtag(
env: Env,
): Promise<ConfiguredPreviewRef[]> {
): Promise<{ refs: ConfiguredPreviewRef[]; etag: string | null }> {
const fromEnv = parseConfiguredPreviewRefsSafe(env.VITE_PLUS_PREVIEW_REFS)

// The R2 index etag changes on every ref mutation (register/publish/unregister
// all rewrite the object), so it doubles as a cheap version stamp for callers
// that cache derived output and want to invalidate on any ref change. Null when
// the index is absent or unreadable.
let etag: string | null = null
const fromR2: ConfiguredPreviewRef[] = []
try {
const { index } = await readRefIndex(env)
const { index, etag: indexEtag } = await readRefIndex(env)
etag = indexEtag
const now = Date.now()
// Each index entry is already in hand here, so attach its runtime state
// directly instead of re-deriving the key and looking it back up.
Expand All @@ -101,7 +107,13 @@ export async function getConfiguredRefs(

const byVersion = new Map<string, ConfiguredPreviewRef>()
for (const ref of [...fromEnv, ...fromR2]) byVersion.set(ref.version, ref)
return [...byVersion.values()]
return { refs: [...byVersion.values()], etag }
}

export async function getConfiguredRefs(
env: Env,
): Promise<ConfiguredPreviewRef[]> {
return (await getConfiguredRefsWithEtag(env)).refs
}

/**
Expand Down
32 changes: 32 additions & 0 deletions test/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,38 @@ describe('packument endpoint', () => {
expect(abbreviatedFetches - before).toBeLessThanOrEqual(1)
})

it('rebuilds a cached packument when a ref is published (output cache invalidates on etag)', async () => {
// The assembled packument is cached keyed by the refs-index etag. Publishing
// rewrites the index (new etag), so even a warm cache must rebuild on the next
// request and surface the new version, never serve the stale body.
const sha = 'f5f5f5f'
const ver = `0.0.0-commit.${sha}`
// Warm the output cache for vite-plus under the current etag.
await SELF.fetch(`${BASE}/vite-plus`, { headers: { accept: 'application/json' } })
const pub = await SELF.fetch(`${BASE}/-/publish`, {
method: 'POST',
headers: { ...AUTH, 'content-type': 'application/json' },
body: JSON.stringify({
ref: `commit.${sha}`,
packages: [
{
name: 'vite-plus',
version: ver,
packageJson: { name: 'vite-plus', version: ver },
integrity: 'sha512-f5',
shasum: 'f5',
},
],
}),
})
expect(pub.status).toBe(201)
const res = await SELF.fetch(`${BASE}/vite-plus`, {
headers: { accept: 'application/json' },
})
const body = (await res.json()) as Record<string, any>
expect(body.versions[ver]?.dist?.integrity).toBe('sha512-f5')
})

it('stamps the preview release date server-side at publish, stable across requests', async () => {
// The release date is stamped server-side once at publish, not npm's
// package-modified time, not a per-request clock, and not a client value.
Expand Down