Skip to content

Commit a91fbe5

Browse files
committed
fix(cdn): fix cache logic and optimize CDN module
- Fix getMeta() always returning {} causing cachePackageFromTarball to skip all caching (check cachedMeta?.files instead of cachedMeta) - Fix GitHub route adding wrong v-prefix to tag names from jsDelivr API - Add SKIP_TTL_MS (10min) for skipped packages to auto-retry later - Add AbortSignal.timeout to cachePackageFromTarball download - Extract NPM_REGISTRY_URL and JSR_REGISTRY_URL as constants - Simplify ESM dependency resolution with semver.minVersion - Fix ESM import rewrite regex to escape special characters - Remove unnecessary async from calculateIntegrity - Preserve integrity field in directory listings - Fix cdnjs route mutating API response array in-place - Add npm org listing endpoint (GET /cdn/npm/@scope)
1 parent 9daa81f commit a91fbe5

11 files changed

Lines changed: 73 additions & 65 deletions

File tree

server/routes/cdn/cdnjs/[...path].ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ export default defineHandler(async (event) => {
237237
// If still not found or version not specified, get latest version
238238
if (!versionFound) {
239239
// Sort by semver (descending) and get first
240-
const sortedVersions = allVersions.sort(semver.rcompare);
240+
const sortedVersions = [...allVersions].sort(semver.rcompare);
241241
const latestVersion = sortedVersions[0];
242242
if (latestVersion) {
243243
version = latestVersion;

server/routes/cdn/gh/[...path].ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,7 @@ export default defineHandler(async (event) => {
140140
version = "main";
141141
}
142142

143-
// Normalize semver versions with v-prefix for GitHub (tags always use v-prefix)
144-
if (semver.valid(version) && !version.startsWith("v")) {
145-
version = `v${version}`;
146-
}
147-
143+
// Use version as-is from jsDelivr API — it already returns the exact tag name
148144
const tarballUrl = getGitHubTarballUrl(owner, repo, version);
149145
const storage = cacheStorage;
150146
const cacheBase = `cdn/gh/${owner}/${repo}/${version}`;

server/routes/cdn/jsr/[...path].ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import semver from "semver";
55
import {
66
type CdnFile,
77
type CdnPackageListing,
8+
JSR_REGISTRY_URL,
89
getCacheControl,
910
getContentType,
1011
getDirectoryListing,
@@ -81,8 +82,7 @@ export default defineHandler(async (event) => {
8182
// Fetch package metadata from npm.jsr.io
8283
// JSR uses npm compatibility name: @scope/package -> @jsr/scope__package
8384
const npmCompatName = `@jsr/${scope}__${pkg}`;
84-
const registryUrl = "https://npm.jsr.io";
85-
const metadataRes = await fetch(`${registryUrl}/${npmCompatName}`);
85+
const metadataRes = await fetch(`${JSR_REGISTRY_URL}/${npmCompatName}`);
8686
if (!metadataRes.ok) {
8787
throw new HTTPError({
8888
status: 404,

server/routes/cdn/npm/[...path].ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import semver from "semver";
44

55
import {
66
type CdnFile,
7+
type CdnOrgListing,
78
type CdnPackageListing,
9+
CACHE_CONTROL_SHORT,
10+
NPM_REGISTRY_URL,
811
bundleNpmPackage,
912
getCacheControl,
1013
getContentType,
@@ -65,6 +68,27 @@ export default defineHandler(async (event) => {
6568
let filepath: string;
6669

6770
if (path.startsWith("@")) {
71+
// Handle org listing: @scope or @scope/ (no package name)
72+
const scopeOnlyMatch = path.match(/^@([^/]+)\/?$/);
73+
if (scopeOnlyMatch) {
74+
const [, scope] = scopeOnlyMatch;
75+
const orgRes = await fetch(`${NPM_REGISTRY_URL}/-/org/${scope}/package`);
76+
if (!orgRes.ok) {
77+
throw new HTTPError({ status: 404, statusText: "Organization not found" });
78+
}
79+
const orgData = (await orgRes.json()) as Record<string, string>;
80+
const packages = Object.keys(orgData);
81+
82+
event.res.headers.set("Content-Type", "application/json");
83+
event.res.headers.set("Cache-Control", CACHE_CONTROL_SHORT);
84+
85+
const response: CdnOrgListing = {
86+
name: `@${scope}`,
87+
packages,
88+
};
89+
return response;
90+
}
91+
6892
// Scoped: @types/hast@latest/index.d.ts
6993
const match = path.match(/^@([^/]+)\/([^@/]+)(?:@([^/]+))?(?:\/(.*))?$/);
7094
if (!match) {
@@ -101,8 +125,7 @@ export default defineHandler(async (event) => {
101125
}
102126

103127
// Fetch package metadata from npm registry
104-
const registryUrl = "https://registry.npmjs.org";
105-
const metadataRes = await fetch(`${registryUrl}/${packageName}`);
128+
const metadataRes = await fetch(`${NPM_REGISTRY_URL}/${packageName}`);
106129
if (!metadataRes.ok) {
107130
throw new HTTPError({
108131
status: 404,

server/utils/cdn/constants.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
1-
/** Tarball download timeout in milliseconds (10 seconds) */
2-
export const TARBALL_DOWNLOAD_TIMEOUT = 10_000;
1+
/** Tarball download timeout in milliseconds (30 seconds) */
2+
export const TARBALL_DOWNLOAD_TIMEOUT = 30_000;
33

44
/** Short cache duration for mutable/branch/incomplete versions (10 minutes) */
55
export const CACHE_CONTROL_SHORT = "public, max-age=600";
66

77
/** Long cache duration for immutable/complete semver versions (1 year) */
88
export const CACHE_CONTROL_LONG = "public, max-age=31536000, immutable";
99

10+
/** How long a skipped package stays skipped before retry (10 minutes) */
11+
export const SKIP_TTL_MS = 10 * 60 * 1000;
12+
13+
/** npm registry base URL */
14+
export const NPM_REGISTRY_URL = "https://registry.npmjs.org";
15+
16+
/** JSR registry base URL (npm compatibility layer) */
17+
export const JSR_REGISTRY_URL = "https://npm.jsr.io";
18+
1019
/** Maximum allowed unpacked package size in bytes (50 MB) */
1120
export const MAX_UNPACKED_SIZE = 50 * 1024 * 1024;

server/utils/cdn/esm.ts

Lines changed: 8 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -43,47 +43,12 @@ export async function bundleNpmPackage(options: BundleOptions): Promise<string>
4343

4444
for (const [depName, depRange] of Object.entries(allDependencyRanges)) {
4545
try {
46-
const rangeStr = depRange as string;
47-
// Use semver to parse the range and get the upper bound
48-
const range = new semver.Range(rangeStr);
49-
50-
// Get comparators from the range to find the upper bound
51-
let targetVersion: string | null = null;
52-
53-
for (const comparatorSet of range.set) {
54-
for (const comparator of comparatorSet) {
55-
// Look for the upper bound (comparator with < operator)
56-
if (comparator.operator === "<") {
57-
const version = comparator.semver;
58-
if (version.patch === 0 && version.prerelease && version.prerelease[0] === 0) {
59-
const major = version.major;
60-
const minor = version.minor;
61-
62-
if (minor === 0) {
63-
targetVersion = String(major - 1);
64-
} else {
65-
targetVersion = `${major}.${minor - 1}`;
66-
}
67-
break;
68-
}
69-
}
70-
}
71-
if (targetVersion) break;
72-
}
73-
74-
// Fallback: if no upper bound found, use minVersion
75-
if (!targetVersion) {
76-
const minVersion = semver.minVersion(rangeStr);
77-
if (minVersion) {
78-
targetVersion = minVersion.version;
79-
}
80-
}
81-
82-
if (targetVersion) {
83-
dependencies[depName] = targetVersion;
46+
const minVersion = semver.minVersion(depRange as string);
47+
if (minVersion) {
48+
dependencies[depName] = minVersion.version;
8449
}
85-
} catch (error) {
86-
console.error(`[Bundler] Error resolving ${depName}:`, error);
50+
} catch {
51+
console.error(`[Bundler] Error resolving ${depName}`);
8752
}
8853
}
8954

@@ -204,10 +169,10 @@ export async function bundleNpmPackage(options: BundleOptions): Promise<string>
204169
// Rewrite all bare imports to CDN paths
205170
for (const depName of allImports) {
206171
const cdnPath = cdnPaths[depName] || `/cdn/npm/${depName}/+esm`;
172+
const escapedDepName = depName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
207173
bundledCode = bundledCode.replaceAll(
208-
new RegExp(`(?:from)?(["'])${depName}\\1`, "g"),
209-
(m, quote) =>
210-
m.startsWith("from") ? `from${quote}${cdnPath}${quote}` : `${quote}${cdnPath}${quote}`,
174+
new RegExp(`(from\\s*["'])${escapedDepName}(["'])`, "g"),
175+
`$1${cdnPath}$2`,
211176
);
212177
}
213178

server/utils/cdn/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
export type { CdnFile, CdnDirectoryListing, CdnPackageListing } from "./types";
1+
export type { CdnFile, CdnDirectoryListing, CdnPackageListing, CdnOrgListing } from "./types";
22
export {
33
TARBALL_DOWNLOAD_TIMEOUT,
44
CACHE_CONTROL_SHORT,
55
CACHE_CONTROL_LONG,
66
MAX_UNPACKED_SIZE,
7+
NPM_REGISTRY_URL,
8+
JSR_REGISTRY_URL,
79
} from "./constants";
810
export { getCacheControl } from "./semver";
911
export {

server/utils/cdn/integrity.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @param data - File data as Uint8Array
88
* @returns SRI integrity string in format "sha256-{base64-hash}"
99
*/
10-
export async function calculateIntegrity(data: Uint8Array): Promise<string> {
10+
export function calculateIntegrity(data: Uint8Array): string {
1111
const hasher = new Bun.CryptoHasher("sha256");
1212
hasher.update(data);
1313
return `sha256-${hasher.digest("base64")}`;

server/utils/cdn/listing.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,15 @@ export async function getDirectoryListing(
1717
): Promise<CdnPackageListing | null> {
1818
const storage = cacheStorage;
1919
const meta = await storage.getMeta(cacheBase);
20-
const allFiles = (meta?.files || []) as Array<{ name: string; size: number }>;
20+
const allFiles = (meta?.files || []) as Array<CdnFile>;
2121

2222
const dirPrefix = `${filepath}/`;
2323
const dirContents: CdnFile[] = allFiles
2424
.filter((file) => file.name.startsWith(dirPrefix))
2525
.map((file) => ({
2626
name: file.name.slice(dirPrefix.length),
2727
size: file.size,
28+
...(file.integrity ? { integrity: file.integrity } : {}),
2829
}))
2930
.filter((file) => file.name.length > 0);
3031

server/utils/cdn/tarball.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { HTTPError } from "nitro/h3";
22

33
import { cacheStorage } from "../storage";
4-
import { MAX_UNPACKED_SIZE, TARBALL_DOWNLOAD_TIMEOUT } from "./constants";
4+
import { MAX_UNPACKED_SIZE, SKIP_TTL_MS, TARBALL_DOWNLOAD_TIMEOUT } from "./constants";
55
import { calculateIntegrity } from "./integrity";
66
import type { CdnFile } from "./types";
77

@@ -201,20 +201,23 @@ export async function cachePackageFromTarball(
201201
): Promise<void> {
202202
const storage = cacheStorage;
203203

204-
// Check if already cached or previously skipped
204+
// Check if already cached with file list, or still within skip TTL
205205
const cachedMeta = await storage.getMeta(cacheBase);
206-
if (cachedMeta) return;
206+
if (cachedMeta?.files) return;
207+
if (cachedMeta?.skippedAt && Date.now() - Number(cachedMeta.skippedAt) < SKIP_TTL_MS) return;
207208

208209
// Skip if another call is already downloading this tarball
209210
if (pendingTarballs.has(cacheBase)) return;
210211
pendingTarballs.add(cacheBase);
211212

212213
try {
213214
// Download and extract tarball
214-
const tarballRes = await fetch(tarballUrl);
215+
const tarballRes = await fetch(tarballUrl, {
216+
signal: AbortSignal.timeout(TARBALL_DOWNLOAD_TIMEOUT),
217+
});
215218
if (!tarballRes.ok) {
216219
console.error(`Failed to download tarball for ${logLabel || cacheBase}`);
217-
await storage.setMeta(cacheBase, { missed: true });
220+
await storage.setMeta(cacheBase, { skippedAt: Date.now() });
218221
return;
219222
}
220223

@@ -240,7 +243,7 @@ export async function cachePackageFromTarball(
240243
console.warn(
241244
`Skipping ${logLabel || cacheBase}: unpacked size ${(totalSize / 1024 / 1024).toFixed(1)} MB exceeds ${MAX_UNPACKED_SIZE / 1024 / 1024} MB limit`,
242245
);
243-
await storage.setMeta(cacheBase, { missed: true });
246+
await storage.setMeta(cacheBase, { skippedAt: Date.now() });
244247
return;
245248
}
246249

@@ -257,7 +260,7 @@ export async function cachePackageFromTarball(
257260
await storage.setItemRaw(cacheKey, fileData);
258261

259262
// Calculate SHA-256 integrity for SRI
260-
const integrity = await calculateIntegrity(fileData);
263+
const integrity = calculateIntegrity(fileData);
261264
const fileItem = fileList.find((f) => f.name === relativePath);
262265
if (fileItem) {
263266
fileItem.integrity = integrity;

0 commit comments

Comments
 (0)