- add edge/rpkgs-router.ts, which redirects PACKAGES, PACKAGES.gz and PACKAGES.rds into …/src/contrib/<x.y>/ for slots listed in UNION_SLOTS - leave tarballs alone: R keeps the pre-redirect contrib URL, so the union index steers per-minor tarballs with a Path field instead - stop resolving an unidentifiable distro to a phantom linux-gnu/linux-musl slot and send those clients to CRAN - mark every redirect no-store, since the target depends on the User-Agent - cover the routing matrix in edge/rpkgs-router.test.ts, run by just edge-test - correct the spec and plan: the union index carries Path: <x.y> on per-minor records, not Path: .. on flat ones
235 lines
7.9 KiB
TypeScript
235 lines
7.9 KiB
TypeScript
/**
|
|
* Edge middleware for cran.rpkgs.com.
|
|
*
|
|
* Two jobs:
|
|
*
|
|
* 1. Resolve the bare `https://cran.rpkgs.com` form to a concrete
|
|
* `<arch>/<os>` slot from the User-Agent, or send the client to CRAN when
|
|
* the distro cannot be identified.
|
|
* 2. Route `PACKAGES*` requests to the per-R-minor slot
|
|
* (`…/latest/src/contrib/<x.y>/`), so a stock `install.packages()` sees the
|
|
* packages that only exist there.
|
|
*
|
|
* Only index files are routed. Tarballs are deliberately left alone: R keeps
|
|
* the contrib URL it asked for, not the one it was redirected to, so every
|
|
* tarball URL is resolved against the flat directory and the union index steers
|
|
* the per-minor ones with a `Path: <x.y>` field. Rewriting a tarball request
|
|
* here would send flat-slot packages into a directory that does not hold them.
|
|
*
|
|
* Routing is gated on UNION_SLOTS. The raw per-minor index holds only the
|
|
* ABI-sensitive subset of a slot; it is safe to route to it only once bincraft
|
|
* has republished it as a union of the per-minor and flat slots.
|
|
*
|
|
* Deployed by OpenTofu from this file (`bunnynet_compute_script.rpkgs_router`).
|
|
* Test with `just edge-test`.
|
|
*/
|
|
import * as BunnySDK from 'https://esm.sh/@bunny.net/edgescript-sdk@0.12';
|
|
|
|
const PUBLIC_CDN_ORIGIN = 'https://cran.rpkgs.com';
|
|
const CRAN_ORIGIN = 'https://cran.r-project.org';
|
|
|
|
/** Slots ("<arch>/<os>", comma separated) whose per-minor index is a union. */
|
|
const UNION_SLOTS = new Set(
|
|
(Deno.env.get('UNION_SLOTS') ?? '')
|
|
.split(',')
|
|
.map((slot) => slot.trim())
|
|
.filter((slot) => slot.length > 0),
|
|
);
|
|
|
|
/** `/<arch>/<os>/latest/src/contrib[/<rest>]` */
|
|
const SLOT_PATH_REGEX = /^\/(amd64|arm64)\/([a-z0-9._-]+)\/latest\/src\/contrib\/?(.*)$/;
|
|
|
|
/** A path that already sits in a per-minor slot, e.g. `4.5/PACKAGES.gz`. */
|
|
const MINOR_DIR_REGEX = /^\d+\.\d+\//;
|
|
|
|
/** The only files this script routes. */
|
|
const INDEX_FILE_REGEX = /^PACKAGES(\.gz|\.rds)?$/;
|
|
|
|
const SRC_CONTRIB_REGEX = /^\/src\/contrib\/(.+)$/;
|
|
|
|
const MACOS_BIN_REGEX =
|
|
/^\/bin\/macosx\/(big-sur-arm64|big-sur-x86_64|monterey-arm64|monterey-x86_64)\/contrib\/([0-9.]+)\/(.+)$/;
|
|
|
|
const RHEL_REGEX = /(almalinux|rocky)[^\d]*(\d+)/i;
|
|
|
|
const UBUNTU_REGEX = /Ubuntu ([\d.]+)/i;
|
|
const UBUNTU_CODENAMES: Record<string, string> = {
|
|
'24.04': 'noble',
|
|
'22.04': 'jammy',
|
|
};
|
|
|
|
const ALPINE_REGEX = /(?:Alpine Linux(?:\s+VERSION_ID=)?|alpine-)\s*(\d+)\.(\d+)/i;
|
|
|
|
/**
|
|
* R's own User-Agent is `R (4.5.3 x86_64-pc-linux-musl …)`; the Posit-style one
|
|
* some sites configure is `R/4.5.3 R (…)`. Both carry the minor, which is why
|
|
* per-minor routing works without the distro being identifiable.
|
|
*/
|
|
const R_MINOR_REGEXES = [/\bR\/(\d+)\.(\d+)/, /\bR \((\d+)\.(\d+)/];
|
|
|
|
function normalizePathname(pathname: string): string {
|
|
return pathname.replace(/\/{2,}/g, '/');
|
|
}
|
|
|
|
function redirectTo(location: string, status = 302): Response {
|
|
return new Response(null, {
|
|
status,
|
|
headers: {
|
|
Location: location,
|
|
// The target depends on the User-Agent, so the redirect itself must
|
|
// never be cached; only its target is a cacheable, UA-independent URL.
|
|
'Cache-Control': 'no-store',
|
|
'X-Via': 'MyMiddleware',
|
|
'X-Rewritten-By': 'rpkgs-edge-middleware',
|
|
},
|
|
});
|
|
}
|
|
|
|
function extractRMinor(userAgent: string): string | null {
|
|
for (const regex of R_MINOR_REGEXES) {
|
|
const match = userAgent.match(regex);
|
|
if (match) {
|
|
return `${match[1]}.${match[2]}`;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function mapArch(arch: string): string {
|
|
if (arch === 'aarch64') return 'arm64';
|
|
if (arch === 'x86_64') return 'amd64';
|
|
return arch;
|
|
}
|
|
|
|
function extractArch(userAgent: string): string {
|
|
const match = userAgent.match(/(x86_64|aarch64|arm64|i386|i686)/);
|
|
return match ? mapArch(match[1]) : '';
|
|
}
|
|
|
|
/**
|
|
* Identify the `<arch>/<os>` slot from the User-Agent, or null.
|
|
*
|
|
* A stock R User-Agent carries only `linux-gnu` / `linux-musl`, which are not
|
|
* slot names: returning them produced redirects into slots that do not exist
|
|
* (`/amd64/linux-musl/latest/…`, a guaranteed 404). An unidentifiable distro
|
|
* is reported as such so the caller can fall back to CRAN.
|
|
*/
|
|
function parseSlot(userAgent: string): string | null {
|
|
const arch = extractArch(userAgent);
|
|
if (!arch) {
|
|
return null;
|
|
}
|
|
|
|
const rhel = userAgent.match(RHEL_REGEX);
|
|
if (rhel) {
|
|
return `${arch}/rhel${rhel[2]}`;
|
|
}
|
|
|
|
const ubuntu = userAgent.match(UBUNTU_REGEX);
|
|
if (ubuntu) {
|
|
const codename = UBUNTU_CODENAMES[ubuntu[1]];
|
|
if (codename) {
|
|
return `${arch}/${codename}`;
|
|
}
|
|
}
|
|
|
|
const alpine = userAgent.match(ALPINE_REGEX);
|
|
if (alpine) {
|
|
return `${arch}/alpine${alpine[1]}${alpine[2]}`;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function parseMacUserAgent(userAgent: string): { os: string; arch: string; rver: string } | null {
|
|
const rverMatch = userAgent.match(/R \((\d+)\.(\d+)/);
|
|
const archMatch = userAgent.match(/(aarch64|arm64|x86_64)/);
|
|
const osMatch = userAgent.match(/darwin(\d+)/);
|
|
|
|
if (!rverMatch || !archMatch || !osMatch) {
|
|
return null;
|
|
}
|
|
|
|
const arch = archMatch[1] === 'aarch64' ? 'arm64' : archMatch[1];
|
|
const darwinVer = parseInt(osMatch[1], 10);
|
|
const os = darwinVer >= 21 && darwinVer < 22 ? `monterey-${arch}` : `big-sur-${arch}`;
|
|
|
|
return { os, arch, rver: `${rverMatch[1]}.${rverMatch[2]}` };
|
|
}
|
|
|
|
/**
|
|
* The contrib path a request should be served from, relative to the slot.
|
|
*
|
|
* Returns the per-minor path for an index file when the slot is known to carry
|
|
* a union index and the client's R minor is known; otherwise the flat path,
|
|
* which is what every client sees today.
|
|
*/
|
|
function contribPath(slot: string, rest: string, userAgent: string): string {
|
|
const flat = rest ? `/${slot}/latest/src/contrib/${rest}` : `/${slot}/latest/src/contrib`;
|
|
|
|
if (!INDEX_FILE_REGEX.test(rest) || !UNION_SLOTS.has(slot)) {
|
|
return flat;
|
|
}
|
|
|
|
const rMinor = extractRMinor(userAgent);
|
|
return rMinor ? `/${slot}/latest/src/contrib/${rMinor}/${rest}` : flat;
|
|
}
|
|
|
|
BunnySDK.net.http
|
|
.servePullZone({ url: 'https://cran.rpkgs.com/' })
|
|
.onOriginRequest((ctx) => {
|
|
const url = new URL(ctx.request.url);
|
|
const path = normalizePathname(url.pathname);
|
|
const userAgent = ctx.request.headers.get('User-Agent') || '';
|
|
|
|
// macOS clients are served from CRAN's own binary tree.
|
|
const srcContrib = path.match(SRC_CONTRIB_REGEX);
|
|
if (srcContrib && /darwin/.test(userAgent)) {
|
|
const mac = parseMacUserAgent(userAgent);
|
|
if (mac) {
|
|
return Promise.resolve(
|
|
redirectTo(`${PUBLIC_CDN_ORIGIN}/bin/macosx/${mac.os}/contrib/${mac.rver}/${srcContrib[1]}`),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (MACOS_BIN_REGEX.test(path)) {
|
|
return Promise.resolve(redirectTo(`${CRAN_ORIGIN}${path}`));
|
|
}
|
|
|
|
// Already-qualified slot URLs: what the runtime images have baked in.
|
|
const slotPath = path.match(SLOT_PATH_REGEX);
|
|
if (slotPath) {
|
|
const slot = `${slotPath[1]}/${slotPath[2]}`;
|
|
const rest = slotPath[3];
|
|
|
|
// Never rewrite a request that is already in a per-minor slot, or the
|
|
// redirect would chase its own tail.
|
|
if (MINOR_DIR_REGEX.test(rest)) {
|
|
return Promise.resolve(ctx.request);
|
|
}
|
|
|
|
const target = contribPath(slot, rest, userAgent);
|
|
if (target === path) {
|
|
return Promise.resolve(ctx.request);
|
|
}
|
|
return Promise.resolve(redirectTo(`${PUBLIC_CDN_ORIGIN}${target}`));
|
|
}
|
|
|
|
// The bare `https://cran.rpkgs.com` form, resolved from the User-Agent.
|
|
if (path === '/' || path === '/src/contrib' || path.startsWith('/src/contrib/')) {
|
|
const slot = parseSlot(userAgent);
|
|
if (!slot) {
|
|
return Promise.resolve(redirectTo(`${CRAN_ORIGIN}${path}`));
|
|
}
|
|
|
|
const rest = srcContrib ? srcContrib[1] : '';
|
|
return Promise.resolve(redirectTo(`${PUBLIC_CDN_ORIGIN}${contribPath(slot, rest, userAgent)}`));
|
|
}
|
|
|
|
return Promise.resolve(ctx.request);
|
|
})
|
|
.onOriginResponse((ctx) => {
|
|
ctx.response.headers.append('X-Via', 'MyMiddleware');
|
|
return Promise.resolve(ctx.response);
|
|
});
|