diff --git a/src/config/platforms.js b/src/config/platforms.js
index eec372b..acafc57 100644
--- a/src/config/platforms.js
+++ b/src/config/platforms.js
@@ -229,6 +229,16 @@ export const PLATFORMS = {
'cr-gitpod': 'https://registry.gitpod.io'
};
+/**
+ * Pre-computed sorted platforms keys for efficient matching.
+ * Sorted by key length (descending) to prioritize more specific paths.
+ */
+export const SORTED_PLATFORMS = Object.keys(PLATFORMS).sort((a, b) => {
+ const pathA = `/${a.replace('-', '/')}/`;
+ const pathB = `/${b.replace('-', '/')}/`;
+ return pathB.length - pathA.length;
+});
+
/**
* Unified path transformation function that converts request paths to platform-specific URLs.
*
diff --git a/src/index.js b/src/index.js
index a4154e4..b3273ad 100644
--- a/src/index.js
+++ b/src/index.js
@@ -6,757 +6,33 @@
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
*/
import { CONFIG, createConfig } from './config/index.js';
-
-/**
- * Monitors performance metrics during request processing.
- *
- * This class tracks timing information throughout request handling lifecycle,
- * allowing measurement of cache hits, upstream fetch attempts, and total processing time.
- *
- * @example
- * // Track request processing performance
- * const monitor = new PerformanceMonitor();
- * monitor.mark('cache_check');
- * // ... check cache ...
- * monitor.mark('upstream_fetch');
- * // ... fetch from upstream ...
- * monitor.mark('complete');
- * const metrics = monitor.getMetrics();
- * // { cache_check: 5, upstream_fetch: 120, complete: 150 }
- *
- * @example
- * // Use with response headers
- * const monitor = new PerformanceMonitor();
- * monitor.mark('operation_complete');
- * const response = addPerformanceHeaders(originalResponse, monitor);
- * // Response will include X-Performance-Metrics header with timing data
- */
-class PerformanceMonitor {
- /**
- * Initializes a new performance monitor.
- *
- * Sets the start time to the current timestamp and creates an empty marks collection.
- * All subsequent timing marks will be relative to this start time.
- */
- constructor() {
- this.startTime = Date.now();
- this.marks = new Map();
- }
-
- /**
- * Marks a timing point with the given name.
- *
- * Records the elapsed time (in milliseconds) since the monitor was created.
- * If a mark with the same name already exists, logs a warning and overwrites it.
- *
- * @param {string} name - The name of the timing mark (e.g., 'cache_hit', 'attempt_0', 'success')
- *
- * @example
- * const monitor = new PerformanceMonitor();
- * monitor.mark('start_fetch');
- * // ... perform fetch ...
- * monitor.mark('fetch_complete');
- * // Marks: { start_fetch: 0, fetch_complete: 245 }
- */
- mark(name) {
- if (this.marks.has(name)) {
- console.warn(`Mark with name ${name} already exists.`);
- }
- this.marks.set(name, Date.now() - this.startTime);
- }
-
- /**
- * Returns all collected metrics as a plain object.
- *
- * Converts the internal Map of timing marks to a JavaScript object suitable for
- * JSON serialization and inclusion in response headers.
- *
- * @returns {Object.} Object containing name-timestamp pairs in milliseconds
- *
- * @example
- * const monitor = new PerformanceMonitor();
- * monitor.mark('cache_check');
- * monitor.mark('upstream_fetch');
- * const metrics = monitor.getMetrics();
- * console.log(metrics);
- * // { cache_check: 5, upstream_fetch: 120 }
- *
- * @example
- * // Serialize metrics for logging
- * const metrics = monitor.getMetrics();
- * console.log(JSON.stringify(metrics));
- * // '{"cache_check":5,"upstream_fetch":120}'
- */
- getMetrics() {
- return Object.fromEntries(this.marks.entries());
- }
-}
-
-/**
- * Detects if a request is a container registry operation (Docker/OCI).
- *
- * Identifies Docker and OCI registry requests by checking for:
- * - Registry API endpoints (/v2/...)
- * - Docker-specific User-Agent headers
- * - Docker/OCI manifest Accept headers
- *
- * @param {Request} request - The incoming request object
- * @param {URL} url - Parsed URL object
- * @returns {boolean} True if this is a container registry operation
- *
- * @example
- * // Docker registry manifest request
- * const req = new Request('https://example.com/v2/library/nginx/manifests/latest');
- * const url = new URL(req.url);
- * isDockerRequest(req, url); // true
- *
- * @example
- * // Docker client User-Agent
- * const req = new Request('https://example.com/some/path', {
- * headers: { 'User-Agent': 'docker/20.10.7' }
- * });
- * const url = new URL(req.url);
- * isDockerRequest(req, url); // true
- *
- * @example
- * // OCI manifest Accept header
- * const req = new Request('https://example.com/some/path', {
- * headers: { 'Accept': 'application/vnd.oci.image.manifest.v1+json' }
- * });
- * const url = new URL(req.url);
- * isDockerRequest(req, url); // true
- *
- * @example
- * // Regular HTTP request (not Docker)
- * const req = new Request('https://example.com/file.tar.gz');
- * const url = new URL(req.url);
- * isDockerRequest(req, url); // false
- */
-function isDockerRequest(request, url) {
- // Check for container registry API endpoints
- if (url.pathname.startsWith('/v2/')) {
- return true;
- }
-
- // Check for Docker-specific User-Agent
- const userAgent = request.headers.get('User-Agent') || '';
- if (userAgent.toLowerCase().includes('docker/')) {
- return true;
- }
-
- // Check for Docker-specific Accept headers
- const accept = request.headers.get('Accept') || '';
- if (
- accept.includes('application/vnd.docker.distribution.manifest') ||
- accept.includes('application/vnd.oci.image.manifest') ||
- accept.includes('application/vnd.docker.image.rootfs.diff.tar.gzip')
- ) {
- return true;
- }
-
- return false;
-}
-
-/**
- * Detects if a request is a Git protocol operation.
- *
- * Identifies Git requests by checking for:
- * - Git-specific endpoints (/info/refs, /git-upload-pack, /git-receive-pack)
- * - Git User-Agent headers
- * - Git service query parameters
- * - Git-specific Content-Type headers
- *
- * @param {Request} request - The incoming request object
- * @param {URL} url - Parsed URL object
- * @returns {boolean} True if this is a Git operation
- *
- * @example
- * // Git clone/fetch request (info/refs)
- * const req = new Request('https://example.com/repo.git/info/refs?service=git-upload-pack');
- * const url = new URL(req.url);
- * isGitRequest(req, url); // true
- *
- * @example
- * // Git push request (git-receive-pack)
- * const req = new Request('https://example.com/repo.git/git-receive-pack', {
- * method: 'POST',
- * headers: { 'Content-Type': 'application/x-git-receive-pack-request' }
- * });
- * const url = new URL(req.url);
- * isGitRequest(req, url); // true
- *
- * @example
- * // Git client User-Agent
- * const req = new Request('https://example.com/repo', {
- * headers: { 'User-Agent': 'git/2.34.1' }
- * });
- * const url = new URL(req.url);
- * isGitRequest(req, url); // true
- *
- * @example
- * // Regular HTTP request (not Git)
- * const req = new Request('https://example.com/file.zip');
- * const url = new URL(req.url);
- * isGitRequest(req, url); // false
- */
-function isGitRequest(request, url) {
- // Check for Git-specific endpoints
- if (url.pathname.endsWith('/info/refs')) {
- return true;
- }
-
- if (url.pathname.endsWith('/git-upload-pack') || url.pathname.endsWith('/git-receive-pack')) {
- return true;
- }
-
- // Check for Git user agents (more comprehensive check)
- const userAgent = request.headers.get('User-Agent') || '';
- if (userAgent.includes('git/') || userAgent.startsWith('git/')) {
- return true;
- }
-
- // Check for Git-specific query parameters
- if (url.searchParams.has('service')) {
- const service = url.searchParams.get('service');
- return service === 'git-upload-pack' || service === 'git-receive-pack';
- }
-
- // Check for Git-specific content types
- const contentType = request.headers.get('Content-Type') || '';
- if (contentType.includes('git-upload-pack') || contentType.includes('git-receive-pack')) {
- return true;
- }
-
- return false;
-}
-
-/**
- * Detects if a request is a Git LFS (Large File Storage) operation.
- *
- * Identifies Git LFS requests by checking for:
- * - LFS-specific endpoints (/info/lfs, /objects/batch)
- * - LFS object storage paths (SHA-256 hash patterns)
- * - Git LFS Accept/Content-Type headers
- * - Git LFS User-Agent
- *
- * @param {Request} request - The incoming request object
- * @param {URL} url - Parsed URL object
- * @returns {boolean} True if this is a Git LFS operation
- *
- * @example
- * // Git LFS batch API request
- * const req = new Request('https://example.com/repo.git/info/lfs/objects/batch', {
- * method: 'POST',
- * headers: { 'Accept': 'application/vnd.git-lfs+json' }
- * });
- * const url = new URL(req.url);
- * isGitLFSRequest(req, url); // true
- *
- * @example
- * // Git LFS object download (SHA-256 hash)
- * const req = new Request(
- * 'https://example.com/repo.git/info/lfs/objects/a1b2c3d4e5f67890123456789abcdef0123456789abcdef0123456789abcdef'
- * );
- * const url = new URL(req.url);
- * isGitLFSRequest(req, url); // true
- *
- * @example
- * // Git LFS client User-Agent
- * const req = new Request('https://example.com/repo', {
- * headers: { 'User-Agent': 'git-lfs/3.0.0 (GitHub; darwin amd64; go 1.17.2)' }
- * });
- * const url = new URL(req.url);
- * isGitLFSRequest(req, url); // true
- *
- * @example
- * // Regular Git request (not LFS)
- * const req = new Request('https://example.com/repo.git/info/refs');
- * const url = new URL(req.url);
- * isGitLFSRequest(req, url); // false
- */
-function isGitLFSRequest(request, url) {
- // Check for LFS-specific endpoints
- if (url.pathname.includes('/info/lfs')) {
- return true;
- }
-
- if (url.pathname.includes('/objects/batch')) {
- return true;
- }
-
- // Check for LFS object storage endpoints (SHA-256 hash is 64 hex characters)
- if (url.pathname.match(/\/objects\/[a-fA-F0-9]{64}$/)) {
- return true;
- }
-
- // Check for LFS-specific headers
- const accept = request.headers.get('Accept') || '';
- const contentType = request.headers.get('Content-Type') || '';
-
- if (
- accept.includes('application/vnd.git-lfs') ||
- contentType.includes('application/vnd.git-lfs')
- ) {
- return true;
- }
-
- // Check for LFS user agent
- const userAgent = request.headers.get('User-Agent') || '';
- if (userAgent.includes('git-lfs')) {
- return true;
- }
-
- return false;
-}
-
-/**
- * Detects if a request is for an AI inference provider API.
- *
- * Identifies AI inference requests by checking for:
- * - AI provider path prefix (/ip/{provider}/...)
- * - Common AI API endpoints (chat, completions, embeddings, etc.)
- * - AI-specific URL patterns with JSON POST requests
- *
- * @param {Request} request - The incoming request object
- * @param {URL} url - Parsed URL object
- * @returns {boolean} True if this is an AI inference request
- *
- * @example
- * // OpenAI chat completions request
- * const req = new Request('https://example.com/ip/openai/v1/chat/completions', {
- * method: 'POST',
- * headers: { 'Content-Type': 'application/json' }
- * });
- * const url = new URL(req.url);
- * isAIInferenceRequest(req, url); // true
- *
- * @example
- * // Anthropic messages API request
- * const req = new Request('https://example.com/ip/anthropic/v1/messages', {
- * method: 'POST'
- * });
- * const url = new URL(req.url);
- * isAIInferenceRequest(req, url); // true
- *
- * @example
- * // Generic AI embeddings endpoint
- * const req = new Request('https://example.com/ip/cohere/v1/embeddings');
- * const url = new URL(req.url);
- * isAIInferenceRequest(req, url); // true
- *
- * @example
- * // Regular API request (not AI)
- * const req = new Request('https://example.com/api/users');
- * const url = new URL(req.url);
- * isAIInferenceRequest(req, url); // false
- */
-function isAIInferenceRequest(request, url) {
- // Check for AI inference provider paths (ip/{provider}/...)
- if (url.pathname.startsWith('/ip/')) {
- return true;
- }
-
- // Check for common AI inference API endpoints
- const aiEndpoints = [
- '/v1/chat/completions',
- '/v1/completions',
- '/v1/messages',
- '/v1/predictions',
- '/v1/generate',
- '/v1/embeddings',
- '/openai/v1/chat/completions'
- ];
-
- if (aiEndpoints.some(endpoint => url.pathname.includes(endpoint))) {
- return true;
- }
-
- // Check for AI-specific content types
- const contentType = request.headers.get('Content-Type') || '';
- if (contentType.includes('application/json') && request.method === 'POST') {
- // Additional check for common AI inference patterns in URL
- if (
- url.pathname.includes('/chat/') ||
- url.pathname.includes('/completions') ||
- url.pathname.includes('/generate') ||
- url.pathname.includes('/predict')
- ) {
- return true;
- }
- }
-
- return false;
-}
-
-/**
- * Validates incoming requests against security rules.
- *
- * Performs security validation including:
- * - HTTP method validation (with special allowances for Git/Docker/AI operations)
- * - URL path length limits
- *
- * Different protocols have different allowed methods:
- * - Regular requests: GET, HEAD (configurable via SECURITY.ALLOWED_METHODS)
- * - Git/LFS/Docker/AI: GET, HEAD, POST, PUT, PATCH
- *
- * @param {Request} request - The incoming request object
- * @param {URL} url - Parsed URL object
- * @param {import('./config/index.js').ApplicationConfig} config - Configuration object
- * @returns {{valid: boolean, error?: string, status?: number}} Validation result object
- *
- * @example
- * // Valid GET request
- * const req = new Request('https://example.com/gh/torvalds/linux');
- * const url = new URL(req.url);
- * const result = validateRequest(req, url);
- * // { valid: true }
- *
- * @example
- * // Invalid method for regular request
- * const req = new Request('https://example.com/npm/lodash', { method: 'DELETE' });
- * const url = new URL(req.url);
- * const result = validateRequest(req, url);
- * // { valid: false, error: 'Method not allowed', status: 405 }
- *
- * @example
- * // Valid POST for Git operation
- * const req = new Request('https://example.com/gh/user/repo.git/git-upload-pack', {
- * method: 'POST'
- * });
- * const url = new URL(req.url);
- * const result = validateRequest(req, url);
- * // { valid: true }
- *
- * @example
- * // Path too long
- * const longPath = '/npm/' + 'a'.repeat(3000);
- * const req = new Request(`https://example.com${longPath}`);
- * const url = new URL(req.url);
- * const result = validateRequest(req, url);
- * // { valid: false, error: 'Path too long', status: 414 }
- */
-function validateRequest(request, url, config = CONFIG) {
- // Allow POST method for Git, Git LFS, Docker, and AI inference operations
- const isGit = isGitRequest(request, url);
- const isGitLFS = isGitLFSRequest(request, url);
- const isDocker = isDockerRequest(request, url);
- const isAI = isAIInferenceRequest(request, url);
-
- const allowedMethods =
- isGit || isGitLFS || isDocker || isAI
- ? ['GET', 'HEAD', 'POST', 'PUT', 'PATCH']
- : config.SECURITY.ALLOWED_METHODS;
-
- if (!allowedMethods.includes(request.method)) {
- return { valid: false, error: 'Method not allowed', status: 405 };
- }
-
- if (url.pathname.length > config.SECURITY.MAX_PATH_LENGTH) {
- return { valid: false, error: 'Path too long', status: 414 };
- }
-
- return { valid: true };
-}
-
-/**
- * Creates a standardized error response with security headers.
- *
- * Generates an HTTP error response with appropriate content type and security headers.
- * Can return either plain text or detailed JSON error format.
- *
- * @param {string} message - Error message to display
- * @param {number} status - HTTP status code (e.g., 400, 404, 500)
- * @param {boolean} includeDetails - Whether to include detailed JSON error information
- * @returns {Response} Error response with security headers
- *
- * @example
- * // Simple text error
- * const response = createErrorResponse('Not found', 404);
- * // Response: "Not found" (text/plain) with status 404
- *
- * @example
- * // Detailed JSON error
- * const response = createErrorResponse('Internal error', 500, true);
- * // Response: { "error": "Internal error", "status": 500, "timestamp": "2024-01-01T00:00:00.000Z" }
- *
- * @example
- * // Validation error
- * const response = createErrorResponse('Method not allowed', 405);
- * // Response: "Method not allowed" (text/plain) with status 405
- */
-function createErrorResponse(message, status, includeDetails = false) {
- const errorBody = includeDetails
- ? JSON.stringify({ error: message, status, timestamp: new Date().toISOString() })
- : message;
-
- return new Response(errorBody, {
- status,
- headers: addSecurityHeaders(
- new Headers({
- 'Content-Type': includeDetails ? 'application/json' : 'text/plain'
- })
- )
- });
-}
-
-/**
- * Adds comprehensive security headers to response headers.
- *
- * Applies industry-standard security headers including:
- * - HSTS (HTTP Strict Transport Security)
- * - X-Frame-Options (clickjacking protection)
- * - X-XSS-Protection (XSS filter)
- * - Referrer-Policy (referrer information control)
- * - Content-Security-Policy (resource loading restrictions)
- * - Permissions-Policy (privacy-invasive feature restrictions)
- *
- * @param {Headers} headers - Headers object to modify (mutates in place)
- * @returns {Headers} Modified headers object (same reference)
- *
- * @example
- * // Add security headers to a response
- * const headers = new Headers({ 'Content-Type': 'text/html' });
- * addSecurityHeaders(headers);
- * // Headers now include HSTS, CSP, etc.
- *
- * @example
- * // Use with error response
- * const errorHeaders = new Headers({ 'Content-Type': 'application/json' });
- * addSecurityHeaders(errorHeaders);
- * const response = new Response(JSON.stringify({ error: 'Not found' }), {
- * status: 404,
- * headers: errorHeaders
- * });
- */
-function addSecurityHeaders(headers) {
- headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
- headers.set('X-Frame-Options', 'DENY');
- headers.set('X-XSS-Protection', '1; mode=block');
- headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
- headers.set('Content-Security-Policy', "default-src 'none'; img-src 'self'; script-src 'none'");
- headers.set('Permissions-Policy', 'interest-cohort=()');
- return headers;
-}
-
-/**
- * Parses Docker/OCI registry WWW-Authenticate header.
- *
- * Extracts authentication realm and service information from the Bearer
- * authentication challenge header returned by container registries.
- *
- * @param {string} authenticateStr - The WWW-Authenticate header value
- * @returns {{realm: string, service: string}} Parsed authentication info with realm URL and service name
- * @throws {Error} If the header format is invalid or missing required fields
- *
- * @example
- * // Parse Docker Hub authentication header
- * const header = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io"';
- * const auth = parseAuthenticate(header);
- * // { realm: 'https://auth.docker.io/token', service: 'registry.docker.io' }
- *
- * @example
- * // Parse GitHub Container Registry header
- * const header = 'Bearer realm="https://ghcr.io/token",service="ghcr.io"';
- * const auth = parseAuthenticate(header);
- * // { realm: 'https://ghcr.io/token', service: 'ghcr.io' }
- *
- * @example
- * // Invalid header throws error
- * try {
- * parseAuthenticate('Basic realm="example"');
- * } catch (error) {
- * console.error(error.message); // "invalid Www-Authenticate Header: Basic realm="example""
- * }
- */
-function parseAuthenticate(authenticateStr) {
- // sample: Bearer realm="https://auth.ipv6.docker.com/token",service="registry.docker.io"
- const realmMatch = authenticateStr.match(/realm="([^"]+)"/);
- const serviceMatch = authenticateStr.match(/service="([^"]+)"/);
-
- if (!realmMatch || !serviceMatch) {
- throw new Error(`invalid Www-Authenticate Header: ${authenticateStr}`);
- }
-
- return {
- realm: realmMatch[1],
- service: serviceMatch[1]
- };
-}
-
-/**
- * Transforms the effective path by removing the platform prefix.
- *
- * @param {string} path - The original path (e.g. /gh/user/repo, /cr/docker/image)
- * @param {string} platform - The platform key (e.g. gh, cr-docker)
- * @returns {string} The transformed path (e.g. /user/repo, /image)
- */
-function transformPath(path, platform) {
- // Convert platform key to path prefix format (e.g. cr-docker -> /cr/docker/)
- const platformPrefix = `/${platform.replace(/-/g, '/')}/`;
-
- if (path.startsWith(platformPrefix)) {
- return path.substring(platformPrefix.length - 1); // Keep the leading slash
- }
-
- return path;
-}
-
-/**
- * Fetches authentication token from container registry token service.
- *
- * Requests a Bearer token from the registry's authentication service,
- * optionally including scope (repository permissions) and authorization credentials.
- *
- * @param {{realm: string, service: string}} wwwAuthenticate - Authentication info from WWW-Authenticate header
- * @param {string} scope - The scope for the token (e.g., "repository:library/nginx:pull")
- * @param {string} authorization - Authorization header value (optional, for authenticated access)
- * @returns {Promise} Token response containing JWT token
- *
- * @example
- * // Fetch anonymous token for public repository
- * const auth = { realm: 'https://auth.docker.io/token', service: 'registry.docker.io' };
- * const scope = 'repository:library/nginx:pull';
- * const response = await fetchToken(auth, scope, '');
- * const data = await response.json();
- * // { token: 'eyJhbGc...', expires_in: 300, issued_at: '...' }
- *
- * @example
- * // Fetch authenticated token for private repository
- * const auth = { realm: 'https://ghcr.io/token', service: 'ghcr.io' };
- * const scope = 'repository:user/private-repo:pull';
- * const authHeader = 'Basic dXNlcjpwYXNz';
- * const response = await fetchToken(auth, scope, authHeader);
- *
- * @example
- * // Fetch token without scope (for catalog access)
- * const auth = { realm: 'https://registry.example.com/token', service: 'example.com' };
- * const response = await fetchToken(auth, '', '');
- */
-async function fetchToken(wwwAuthenticate, scope, authorization) {
- const url = new URL(wwwAuthenticate.realm);
- if (wwwAuthenticate.service.length) {
- url.searchParams.set('service', wwwAuthenticate.service);
- }
- if (scope) {
- url.searchParams.set('scope', scope);
- }
- const headers = new Headers();
- if (authorization) {
- headers.set('Authorization', authorization);
- }
- return await fetch(url, { method: 'GET', headers });
-}
-
-/**
- * Creates an unauthorized (401) response for container registry authentication.
- *
- * Generates a Docker/OCI registry-compliant 401 response with a WWW-Authenticate
- * header that directs clients to the token authentication endpoint.
- *
- * @param {URL} url - Request URL used to construct authentication realm
- * @returns {Response} Unauthorized response with WWW-Authenticate header
- *
- * @example
- * // Create unauthorized response for registry request
- * const url = new URL('https://example.com/v2/library/nginx/manifests/latest');
- * const response = responseUnauthorized(url);
- * // Response status: 401
- * // Response headers: WWW-Authenticate: Bearer realm="https://example.com/v2/auth",service="Xget"
- * // Response body: {"message":"UNAUTHORIZED"}
- *
- * @example
- * // Docker client will follow authentication flow
- * // 1. Receive 401 with WWW-Authenticate
- * // 2. Request token from realm URL
- * // 3. Retry original request with Bearer token
- */
-function responseUnauthorized(url) {
- const headers = new Headers();
- headers.set('WWW-Authenticate', `Bearer realm="https://${url.hostname}/v2/auth",service="Xget"`);
- return new Response(JSON.stringify({ message: 'UNAUTHORIZED' }), {
- status: 401,
- headers
- });
-}
+import { SORTED_PLATFORMS, transformPath } from './config/platforms.js';
+import {
+ fetchToken,
+ handleDockerAuth,
+ parseAuthenticate,
+ responseUnauthorized
+} from './protocols/docker.js';
+import { PerformanceMonitor, addPerformanceHeaders } from './utils/performance.js';
+import { addSecurityHeaders, createErrorResponse } from './utils/security.js';
+import {
+ isAIInferenceRequest,
+ isDockerRequest,
+ isGitLFSRequest,
+ isGitRequest,
+ validateRequest
+} from './utils/validation.js';
/**
* Main request handler with comprehensive caching, retry logic, and security measures.
*
- * This is the core request processing function that:
- * 1. Validates requests against security rules
- * 2. Detects protocol type (Git, Docker, AI, or regular HTTP)
- * 3. Transforms URLs based on platform configuration
- * 4. Implements intelligent caching strategies
- * 5. Handles upstream fetches with retry logic and timeouts
- * 6. Performs protocol-specific operations (Docker auth, URL rewriting)
- * 7. Adds security and performance headers
- *
- * **Request Flow:**
- * ```
- * Request → Validate → Detect Protocol → Transform URL → Check Cache
- * → Fetch Upstream (with retries) → Handle Auth → Rewrite Response
- * → Add Security Headers → Cache → Return with Performance Metrics
- * ```
- *
- * **Caching Strategy:**
- * - Git, Git LFS, Docker, AI inference: No caching (protocol compliance)
- * - Regular downloads: 30-minute cache (configurable)
- * - Range requests: Intelligent full-content caching
- *
- * **Retry Logic:**
- * - Max 3 attempts (configurable)
- * - 30-second timeout per attempt
- * - Exponential backoff between retries
- * - No retry on 4xx client errors
- *
* @param {Request} request - The incoming HTTP request
* @param {Object} env - Cloudflare Workers environment variables for runtime config overrides
* @param {ExecutionContext} ctx - Cloudflare Workers execution context for background tasks
* @returns {Promise} The HTTP response with appropriate headers and body
- *
- * @example
- * // Regular file download (cached)
- * const request = new Request('https://example.com/npm/lodash');
- * const response = await handleRequest(request, {}, ctx);
- * // Returns: Package data with 30-minute cache
- *
- * @example
- * // Git clone operation (not cached)
- * const request = new Request('https://example.com/gh/torvalds/linux/info/refs?service=git-upload-pack');
- * const response = await handleRequest(request, {}, ctx);
- * // Returns: Git protocol response, bypasses cache
- *
- * @example
- * // Docker image pull (with authentication)
- * const request = new Request('https://example.com/v2/cr/docker/library/nginx/manifests/latest');
- * const response = await handleRequest(request, {}, ctx);
- * // Returns: Docker manifest, handles token auth automatically
- *
- * @example
- * // AI inference request (proxied)
- * const request = new Request('https://example.com/ip/openai/v1/chat/completions', {
- * method: 'POST',
- * body: JSON.stringify({ model: 'gpt-4', messages: [...] })
- * });
- * const response = await handleRequest(request, {}, ctx);
- * // Returns: AI API response, bypasses cache
- *
- * @example
- * // With environment variable overrides
- * const env = { TIMEOUT_SECONDS: '60', CACHE_DURATION: '3600' };
- * const response = await handleRequest(request, env, ctx);
- * // Uses 60s timeout and 1-hour cache instead of defaults
*/
async function handleRequest(request, env, ctx) {
try {
@@ -793,8 +69,8 @@ async function handleRequest(request, env, ctx) {
// Handle container registry paths specially
if (isDocker) {
- // For Docker requests (excluding version check which is handled above),
- // check if they have /cr/ prefix, but allow /v2/auth
+ // For Docker requests, check if they have /cr/ prefix
+ // but allow /v2/auth which handles authentication
if (
!url.pathname.startsWith('/cr/') &&
!url.pathname.startsWith('/v2/cr/') &&
@@ -810,96 +86,13 @@ async function handleRequest(request, env, ctx) {
// This must be done before platform detection because /v2/auth doesn't follow the
// standard /platform/path pattern - it encodes the path in the 'scope' parameter
if (isDocker && url.pathname === '/v2/auth') {
- const scope = url.searchParams.get('scope');
- if (!scope) {
- return createErrorResponse('Missing scope parameter', 400);
- }
-
- // Parse scope to find the target platform and repository
- // Format: repository:cr/docker/library/ubuntu:pull
- // We need to extract 'cr/docker' as the platform
- const parts = scope.split(':');
- if (parts.length < 3 || parts[0] !== 'repository') {
- // If not a repository scope, or invalid format, we can't easily proxy it
- // Check if we can just return a generic valid response or if we must fail
- // For now, return 400 if we can't parse it
- return createErrorResponse('Invalid scope format', 400);
- }
-
- const fullRepoPath = parts[1]; // e.g., cr/docker/library/ubuntu
- let platformKey = '';
- let repoPath = '';
-
- // Find the platform from the start of the repo path
- // Try to match 'cr/docker', 'cr/ghcr', etc.
- // We need to find which platform prefix matches the start of fullRepoPath
-
- // Sort platforms by length desc to match most specific first
- const sortedPlatforms = Object.keys(config.PLATFORMS).sort((a, b) => b.length - a.length);
-
- for (const key of sortedPlatforms) {
- if (!key.startsWith('cr-')) continue;
-
- // Convert key cr-docker to cr/docker for matching
- const prefix = key.replace(/-/g, '/');
- if (fullRepoPath.startsWith(prefix + '/')) {
- platformKey = key;
- repoPath = fullRepoPath.slice(prefix.length + 1); // +1 for the slash
- break;
- }
- }
-
- if (!platformKey || !config.PLATFORMS[platformKey]) {
- return createErrorResponse('Unsupported registry platform in scope', 400);
- }
-
- const upstreamUrl = config.PLATFORMS[platformKey];
- const authorization = request.headers.get('Authorization');
-
- // 1. Fetch the upstream root (v2) to get the proper realm and service
- // We use the upstream URL + /v2/
- const v2Url = new URL(`${upstreamUrl}/v2/`);
- const v2Resp = await fetch(v2Url.toString(), {
- method: 'GET',
- redirect: 'follow'
- });
-
- if (v2Resp.status !== 401) {
- // If not 401, maybe no auth needed? Or error.
- // Just forward the response?
- return v2Resp;
- }
-
- const authenticateStr = v2Resp.headers.get('WWW-Authenticate');
- if (authenticateStr === null) {
- return v2Resp;
- }
-
- const wwwAuthenticate = parseAuthenticate(authenticateStr);
-
- // 2. Construct the new scope for the upstream registry
- // We replace our prefixed path with the actual repo path
- // e.g. repository:cr/docker/library/ubuntu:pull -> repository:library/ubuntu:pull
-
- // However, we also need to respect the service name if possible,
- // but usually we just need to fix the repository part of the scope.
- const newScope = `repository:${repoPath}:${parts.slice(2).join(':')}`;
-
- // 3. Fetch the token from the upstream realm
- return await fetchToken(wwwAuthenticate, newScope, authorization || '');
+ return handleDockerAuth(request, url, config);
}
// Platform detection using transform patterns
- // Sort platforms by path length (descending) to prioritize more specific paths
- // e.g., conda/community should match before conda, pypi/files before pypi
- const sortedPlatforms = Object.keys(config.PLATFORMS).sort((a, b) => {
- const pathA = `/${a.replace('-', '/')}/`;
- const pathB = `/${b.replace('-', '/')}/`;
- return pathB.length - pathA.length;
- });
-
+ // Use pre-computed sorted platforms
const platform =
- sortedPlatforms.find(key => {
+ SORTED_PLATFORMS.find(key => {
const expectedPrefix = `/${key.replace('-', '/')}/`;
return effectivePath.startsWith(expectedPrefix);
}) || effectivePath.split('/')[1];
@@ -910,7 +103,6 @@ async function handleRequest(request, env, ctx) {
}
// Check if the path only contains the platform prefix without any actual resource path
- // For example: /gh, /npm, /pypi (should be /gh/user/repo, /npm/package, etc.)
const platformPath = `/${platform.replace(/-/g, '/')}`;
if (effectivePath === platformPath || effectivePath === `${platformPath}/`) {
const HOME_PAGE_URL = 'https://github.com/xixu-me/Xget';
@@ -941,7 +133,6 @@ async function handleRequest(request, env, ctx) {
const isAI = isAIInferenceRequest(request, url);
// Check cache first (skip cache for Git, Git LFS, Docker, and AI inference operations)
- // Note: caches API is only available in Cloudflare Workers, not in standard environments
/** @type {Cache | null} */
// @ts-ignore - Cloudflare Workers cache API
const cache = typeof caches !== 'undefined' && caches.default ? caches.default : null;
@@ -950,7 +141,6 @@ async function handleRequest(request, env, ctx) {
if (cache && !isGit && !isGitLFS && !isDocker && !isAI) {
try {
// For Range requests, try cache match first
- // Always use GET method for cache key to match how we store (cache.put only accepts GET)
const cacheKey = new Request(targetUrl, {
method: 'GET',
headers: request.headers
@@ -977,7 +167,6 @@ async function handleRequest(request, env, ctx) {
}
}
} catch (cacheError) {
- // Cache API not available or failed - continue without caching
console.warn('Cache API unavailable:', cacheError);
}
}
@@ -1016,27 +205,22 @@ async function handleRequest(request, env, ctx) {
requestHeaders.set('User-Agent', 'git/2.34.1');
}
- // For Git upload-pack requests, ensure proper content type
if (isGit && request.method === 'POST' && url.pathname.endsWith('/git-upload-pack')) {
if (!requestHeaders.has('Content-Type')) {
requestHeaders.set('Content-Type', 'application/x-git-upload-pack-request');
}
}
- // For Git receive-pack requests, ensure proper content type
if (isGit && request.method === 'POST' && url.pathname.endsWith('/git-receive-pack')) {
if (!requestHeaders.has('Content-Type')) {
requestHeaders.set('Content-Type', 'application/x-git-receive-pack-request');
}
}
- // Set Git LFS-specific headers
if (isGitLFS) {
if (!requestHeaders.has('User-Agent')) {
requestHeaders.set('User-Agent', 'git-lfs/3.0.0 (GitHub; darwin amd64; go 1.17.2)');
}
-
- // For LFS batch API requests
if (url.pathname.includes('/objects/batch')) {
if (!requestHeaders.has('Accept')) {
requestHeaders.set('Accept', 'application/vnd.git-lfs+json');
@@ -1045,8 +229,6 @@ async function handleRequest(request, env, ctx) {
requestHeaders.set('Content-Type', 'application/vnd.git-lfs+json');
}
}
-
- // For LFS object transfers
if (url.pathname.match(/\/objects\/[a-fA-F0-9]{64}$/)) {
if (!requestHeaders.has('Accept')) {
requestHeaders.set('Accept', 'application/octet-stream');
@@ -1054,14 +236,10 @@ async function handleRequest(request, env, ctx) {
}
}
- // For AI inference requests, ensure proper content type and headers
if (isAI) {
- // Ensure JSON content type for AI API requests if not already set
if (request.method === 'POST' && !requestHeaders.has('Content-Type')) {
requestHeaders.set('Content-Type', 'application/json');
}
-
- // Set appropriate User-Agent for AI requests if not present
if (!requestHeaders.has('User-Agent')) {
requestHeaders.set('User-Agent', 'Xget-AI-Proxy/1.0');
}
@@ -1087,29 +265,20 @@ async function handleRequest(request, env, ctx) {
requestHeaders.set('User-Agent', 'Wget/1.21.3');
requestHeaders.set('Origin', request.headers.get('Origin') || '*');
- // Forward Authorization header if present (needed for authenticated resources)
if (authorization) {
requestHeaders.set('Authorization', authorization);
}
- // Handle range requests - but don't forward Range header if we need to cache full content
const rangeHeader = request.headers.get('Range');
-
- // Detect media files to avoid compression for better Range support
const isMediaFile = targetUrl.match(
/\.(mp4|avi|mkv|mov|wmv|flv|webm|mp3|wav|flac|aac|ogg|jpg|jpeg|png|gif|bmp|svg|pdf|zip|rar|7z|tar|gz|bz2|xz)$/i
);
if (isMediaFile || rangeHeader) {
- // For media files or range requests, avoid compression to ensure proper byte-range support
requestHeaders.set('Accept-Encoding', 'identity');
}
- // For Range requests, we need to decide whether to forward the Range header
- // If we want to cache the full content first, don't send Range to origin
if (rangeHeader) {
- // If we're going to try to get full content for caching, don't send Range header
- // This will be handled in the retry logic
requestHeaders.set('Range', rangeHeader);
}
}
@@ -1120,25 +289,16 @@ async function handleRequest(request, env, ctx) {
try {
monitor.mark(`attempt_${attempts}`);
- // Fetch with timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), config.TIMEOUT_SECONDS * 1000);
- // For Git/Docker operations, don't use Cloudflare-specific options
- const finalFetchOptions =
- isGit || isDocker
- ? { ...fetchOptions, signal: controller.signal }
- : { ...fetchOptions, signal: controller.signal };
+ const finalFetchOptions = { ...fetchOptions, signal: controller.signal };
// Special handling for HEAD requests to ensure Content-Length header
if (request.method === 'HEAD') {
- // First, try the HEAD request
response = await fetch(targetUrl, finalFetchOptions);
- // If HEAD request succeeds but lacks Content-Length, try to get it efficiently
if (response.ok && !response.headers.get('Content-Length')) {
- // Strategy 1: Try a Range request for the first byte to get Content-Range with total size
- // This is much more efficient than downloading the entire file
const rangeHeaders = new Headers(requestHeaders);
rangeHeaders.set('Range', 'bytes=0-0');
@@ -1150,10 +310,8 @@ async function handleRequest(request, env, ctx) {
let contentLength = null;
- // If server supports Range requests, extract size from Content-Range header
if (rangeResponse.status === 206) {
const contentRange = rangeResponse.headers.get('Content-Range');
- // Content-Range format: "bytes 0-0/12345" where 12345 is the total size
if (contentRange) {
const match = contentRange.match(/bytes\s+\d+-\d+\/(\d+)/);
if (match) {
@@ -1161,32 +319,24 @@ async function handleRequest(request, env, ctx) {
}
}
} else if (rangeResponse.ok) {
- // Server doesn't support Range requests, try to get Content-Length from GET response
contentLength = rangeResponse.headers.get('Content-Length');
-
- // If still no Content-Length and response is small enough, calculate from body
if (!contentLength) {
- const sizeLimit = 50 * 1024 * 1024; // 50MB limit for buffering
+ const sizeLimit = 50 * 1024 * 1024;
const contentLengthHint = rangeResponse.headers.get('Content-Length');
-
- // Only buffer if we know it's small or we don't know the size
if (!contentLengthHint || parseInt(contentLengthHint, 10) < sizeLimit) {
try {
const arrayBuffer = await rangeResponse.arrayBuffer();
contentLength = arrayBuffer.byteLength.toString();
} catch (error) {
- // If buffering fails (too large, timeout, etc.), skip it
console.warn('Could not buffer response to get Content-Length:', error);
}
}
}
}
- // Create HEAD response with Content-Length if we got it
if (contentLength) {
const headHeaders = new Headers(response.headers);
headHeaders.set('Content-Length', contentLength);
-
response = new Response(null, {
status: response.status,
statusText: response.statusText,
@@ -1209,8 +359,6 @@ async function handleRequest(request, env, ctx) {
if (isDocker && response.status === 401) {
monitor.mark('docker_auth_challenge');
- // For container registries, first check if we can get a token without credentials
- // This allows access to public repositories
const authenticateStr = response.headers.get('WWW-Authenticate');
if (authenticateStr) {
try {
@@ -1220,23 +368,22 @@ async function handleRequest(request, env, ctx) {
let scope = '';
const pathParts = url.pathname.split('/');
if (pathParts.length >= 4 && pathParts[1] === 'v2') {
- // Extract repository name from path like /v2/cr/ghcr/nginxinc/nginx-unprivileged/manifests/latest
- // Remove /v2 and platform prefix to get the repo path
- const repoPath = pathParts.slice(4).join('/'); // Skip /v2/cr/[registry]
- const repoParts = repoPath.split('/');
- if (repoParts.length >= 1) {
- let repoName = repoParts.slice(0, -2).join('/'); // Remove /manifests/tag or /blobs/sha
+ const platformPrefix = `/${platform.replace(/-/g, '/')}/`;
+ if (effectivePath.startsWith(platformPrefix)) {
+ const repoPathFull = effectivePath.slice(platformPrefix.length);
+ const repoParts = repoPathFull.split('/');
+ if (repoParts.length >= 1) {
+ let repoName = repoParts.slice(0, -2).join('/'); // Remove /manifests/tag or /blobs/sha
- // Special handling for Docker Hub: official images need 'library/' prefix
- // Docker Hub stores official images like nginx, redis, etc. as library/nginx, library/redis
- if (platform === 'cr-docker' && repoName && !repoName.includes('/')) {
- repoName = `library/${repoName}`;
- }
+ if (platform === 'cr-docker' && repoName && !repoName.includes('/')) {
+ repoName = `library/${repoName}`;
+ }
- if (repoName) {
- scope = `repository:${repoName}:pull`;
- }
- }
+ if (repoName) {
+ scope = `repository:${repoName}:pull`;
+ }
+ }
+ }
}
// Try to get a token for public access (without authorization)
@@ -1245,7 +392,6 @@ async function handleRequest(request, env, ctx) {
if (tokenResponse.ok) {
const tokenData = await tokenResponse.json();
if (tokenData.token) {
- // Retry the original request with the obtained token
const retryHeaders = new Headers(requestHeaders);
retryHeaders.set('Authorization', `Bearer ${tokenData.token}`);
@@ -1266,12 +412,9 @@ async function handleRequest(request, env, ctx) {
}
}
- // If token fetch failed or didn't work, return the unauthorized response
- // Only return this if we truly can't access the resource
return responseUnauthorized(url);
}
- // Don't retry on client errors (4xx) - these won't improve with retries
if (response.status >= 400 && response.status < 500) {
monitor.mark('client_error');
break;
@@ -1294,20 +437,15 @@ async function handleRequest(request, env, ctx) {
true
);
}
- // Wait before retrying
await new Promise(resolve => setTimeout(resolve, config.RETRY_DELAY_MS * attempts));
}
}
- // Check if we have a valid response after all attempts
if (!response) {
return createErrorResponse('No response received after all retry attempts', 500, true);
}
- // If response is still not ok after all retries, return the error
if (!response.ok && response.status !== 206) {
- // For Docker authentication errors that we couldn't resolve with anonymous tokens,
- // return a more helpful error message
if (isDocker && response.status === 401) {
const errorText = await response.text().catch(() => '');
return createErrorResponse(
@@ -1324,14 +462,10 @@ async function handleRequest(request, env, ctx) {
);
}
- // Handle URL rewriting for different platforms
let responseBody = response.body;
- // Handle PyPI simple index URL rewriting
if (platform === 'pypi' && response.headers.get('content-type')?.includes('text/html')) {
const originalText = await response.text();
- // Rewrite URLs in the response body to go through the Cloudflare Workers
- // files.pythonhosted.org URLs should be rewritten to go through our pypi/files endpoint
const rewrittenText = originalText.replace(
/https:\/\/files\.pythonhosted\.org/g,
`${url.origin}/pypi/files`
@@ -1344,11 +478,8 @@ async function handleRequest(request, env, ctx) {
});
}
- // Handle npm registry URL rewriting
if (platform === 'npm' && response.headers.get('content-type')?.includes('application/json')) {
const originalText = await response.text();
- // Rewrite tarball URLs in npm registry responses to go through our npm endpoint
- // https://registry.npmjs.org/package/-/package-version.tgz -> https://xget.xi-xu.me/npm/package/-/package-version.tgz
const rewrittenText = originalText.replace(
/https:\/\/registry.npmjs.org\/([^/]+)/g,
`${url.origin}/npm/$1`
@@ -1361,23 +492,14 @@ async function handleRequest(request, env, ctx) {
});
}
- // Prepare response headers
const headers = new Headers(response.headers);
- if (isGit || isDocker) {
- // For Git/Docker operations, preserve all headers from the upstream response
- // These protocols are very sensitive to header changes
- // Don't add any additional headers that might interfere with protocol operation
- // The response headers from upstream should be passed through as-is
- } else {
- // Regular file download headers
+ if (!isGit && !isDocker) {
headers.set('Cache-Control', `public, max-age=${config.CACHE_DURATION}`);
headers.set('X-Content-Type-Options', 'nosniff');
headers.set('Accept-Ranges', 'bytes');
- // Ensure Content-Length is present for proper Range support
if (!headers.has('Content-Length') && response.status === 200) {
- // If Content-Length is missing and we have access to the body, calculate it
try {
const contentLength = response.headers.get('Content-Length');
if (contentLength) {
@@ -1391,29 +513,22 @@ async function handleRequest(request, env, ctx) {
addSecurityHeaders(headers);
}
- // Create final response
const finalResponse = new Response(responseBody, {
status: response.status,
headers
});
- // Cache successful responses (skip caching for Git, Git LFS, Docker, and AI inference operations)
- // Only cache GET requests (HEAD requests cannot be cached due to Cache API limitations)
- // IMPORTANT: Only cache 200 responses, NOT 206 responses (Cloudflare Workers Cache API rejects 206)
- // Note: caching only works in Cloudflare Workers environment
if (
cache &&
!isGit &&
!isGitLFS &&
!isDocker &&
!isAI &&
- request.method === 'GET' && // Only cache GET requests, not HEAD
+ request.method === 'GET' &&
response.ok &&
- response.status === 200 // Only cache complete responses (200), not partial content (206)
+ response.status === 200
) {
- // For Range requests that resulted in 200, cache the full response
const rangeHeader = request.headers.get('Range');
- // Always use GET method for cache key
const cacheKey = rangeHeader
? new Request(targetUrl, {
method: 'GET',
@@ -1423,22 +538,16 @@ async function handleRequest(request, env, ctx) {
})
: new Request(targetUrl, { method: 'GET' });
- // Use waitUntil if available (Cloudflare Workers), otherwise cache synchronously
- // Wrap in try-catch in case cache API fails
try {
if (ctx && typeof ctx.waitUntil === 'function') {
ctx.waitUntil(cache.put(cacheKey, finalResponse.clone()));
} else {
- // In non-Workers environment, cache put happens synchronously
cache.put(cacheKey, finalResponse.clone()).catch(error => {
console.warn('Cache put failed:', error);
});
}
- // If this was originally a Range request and we got a 200 (full content),
- // try cache.match again with the original Range request to get 206 response
if (rangeHeader && response.status === 200) {
- // Always use GET method for cache match, even for Range requests
const rangedResponse = await cache.match(
new Request(targetUrl, {
method: 'GET',
@@ -1451,7 +560,6 @@ async function handleRequest(request, env, ctx) {
}
}
} catch (cacheError) {
- // Cache API not available or failed - continue without caching
console.warn('Cache put/match failed:', cacheError);
}
}
@@ -1467,89 +575,7 @@ async function handleRequest(request, env, ctx) {
}
}
-/**
- * Adds performance metrics to response headers.
- *
- * Creates a new response with an X-Performance-Metrics header containing
- * timing data from the PerformanceMonitor instance. Also ensures security
- * headers are included.
- *
- * **Note:** This header is only added to non-protocol responses (not Git/Docker/AI).
- *
- * @param {Response} response - The original response object
- * @param {PerformanceMonitor} monitor - Performance monitor instance with collected metrics
- * @returns {Response} New response with added performance and security headers
- *
- * @example
- * // Add performance metrics to response
- * const monitor = new PerformanceMonitor();
- * monitor.mark('cache_hit');
- * monitor.mark('complete');
- * const response = new Response('data', { status: 200 });
- * const enhancedResponse = addPerformanceHeaders(response, monitor);
- * // Response headers include: X-Performance-Metrics: {"cache_hit":5,"complete":150}
- *
- * @example
- * // Check performance metrics from client side
- * const response = await fetch('https://example.com/npm/lodash');
- * const metrics = response.headers.get('X-Performance-Metrics');
- * console.log(JSON.parse(metrics));
- * // { cache_check: 2, attempt_0: 10, success: 245, complete: 250 }
- */
-function addPerformanceHeaders(response, monitor) {
- const headers = new Headers(response.headers);
- headers.set('X-Performance-Metrics', JSON.stringify(monitor.getMetrics()));
- addSecurityHeaders(headers);
- return new Response(response.body, {
- status: response.status,
- headers
- });
-}
-
-/**
- * Cloudflare Workers module export.
- *
- * This is the entry point for the Xget acceleration engine deployed on
- * Cloudflare Workers. The fetch handler receives all incoming HTTP requests
- * and delegates processing to the handleRequest function.
- *
- * **Deployment:** This module is deployed as a Cloudflare Workers and handles
- * requests at the edge for optimal performance and global distribution.
- *
- * @example
- * // Cloudflare Workers runtime calls this automatically
- * // Worker receives request → export.fetch() → handleRequest() → Response
- *
- * @example
- * // Local testing with Wrangler
- * // npm run dev
- * // Wrangler dev server simulates Cloudflare Workers environment
- */
export default {
- /**
- * Main entry point for the Cloudflare Workers fetch event.
- *
- * This method is automatically invoked by the Cloudflare Workers runtime
- * for every incoming HTTP request. It delegates all request processing
- * to the handleRequest function.
- *
- * @param {Request} request - The incoming HTTP request from Cloudflare Workers runtime
- * @param {Object} env - Environment variables and bindings (KV, Durable Objects, secrets, etc.)
- * @param {ExecutionContext} ctx - Execution context for waitUntil() and passThroughOnException()
- * @returns {Promise} The HTTP response to return to the client
- *
- * @example
- * // This is called automatically by Cloudflare Workers
- * // User requests: https://xget.example.com/npm/lodash
- * // Runtime invokes: export.default.fetch(request, env, ctx)
- * // Returns: Response with package data
- *
- * @example
- * // Environment variables usage
- * // wrangler.toml: [vars] TIMEOUT_SECONDS = "60"
- * // env object contains: { TIMEOUT_SECONDS: "60" }
- * // handleRequest uses createConfig(env) to override defaults
- */
fetch(request, env, ctx) {
return handleRequest(request, env, ctx);
}
diff --git a/src/protocols/docker.js b/src/protocols/docker.js
new file mode 100644
index 0000000..5a20615
--- /dev/null
+++ b/src/protocols/docker.js
@@ -0,0 +1,161 @@
+/**
+ * Docker/OCI Registry protocol handler for Xget
+ */
+
+import { SORTED_PLATFORMS } from '../config/platforms.js';
+import { createErrorResponse } from '../utils/security.js';
+
+/**
+ * Parses Docker/OCI registry WWW-Authenticate header.
+ *
+ * Extracts authentication realm and service information from the Bearer
+ * authentication challenge header returned by container registries.
+ *
+ * @param {string} authenticateStr - The WWW-Authenticate header value
+ * @returns {{realm: string, service: string}} Parsed authentication info with realm URL and service name
+ * @throws {Error} If the header format is invalid or missing required fields
+ */
+export function parseAuthenticate(authenticateStr) {
+ // sample: Bearer realm="https://auth.ipv6.docker.com/token",service="registry.docker.io"
+ const realmMatch = authenticateStr.match(/realm="([^"]+)"/);
+ const serviceMatch = authenticateStr.match(/service="([^"]+)"/);
+
+ if (!realmMatch || !serviceMatch) {
+ throw new Error(`invalid Www-Authenticate Header: ${authenticateStr}`);
+ }
+
+ return {
+ realm: realmMatch[1],
+ service: serviceMatch[1]
+ };
+}
+
+/**
+ * Fetches authentication token from container registry token service.
+ *
+ * Requests a Bearer token from the registry's authentication service,
+ * optionally including scope (repository permissions) and authorization credentials.
+ *
+ * @param {{realm: string, service: string}} wwwAuthenticate - Authentication info from WWW-Authenticate header
+ * @param {string} scope - The scope for the token (e.g., "repository:library/nginx:pull")
+ * @param {string} authorization - Authorization header value (optional, for authenticated access)
+ * @returns {Promise} Token response containing JWT token
+ */
+export async function fetchToken(wwwAuthenticate, scope, authorization) {
+ const url = new URL(wwwAuthenticate.realm);
+ if (wwwAuthenticate.service.length) {
+ url.searchParams.set('service', wwwAuthenticate.service);
+ }
+ if (scope) {
+ url.searchParams.set('scope', scope);
+ }
+ const headers = new Headers();
+ if (authorization) {
+ headers.set('Authorization', authorization);
+ }
+ return await fetch(url, { method: 'GET', headers });
+}
+
+/**
+ * Creates an unauthorized (401) response for container registry authentication.
+ *
+ * Generates a Docker/OCI registry-compliant 401 response with a WWW-Authenticate
+ * header that directs clients to the token authentication endpoint.
+ *
+ * @param {URL} url - Request URL used to construct authentication realm
+ * @returns {Response} Unauthorized response with WWW-Authenticate header
+ */
+export function responseUnauthorized(url) {
+ const headers = new Headers();
+ headers.set('WWW-Authenticate', `Bearer realm="https://${url.hostname}/v2/auth",service="Xget"`);
+ return new Response(JSON.stringify({ message: 'UNAUTHORIZED' }), {
+ status: 401,
+ headers
+ });
+}
+
+/**
+ * Handles the special /v2/auth endpoint for Docker authentication.
+ *
+ * Proxies generation of auth tokens by negotiating with the upstream registry.
+ *
+ * @param {Request} request - The incoming request
+ * @param {URL} url - The parsed URL
+ * @param {import('../config/index.js').ApplicationConfig} config - App configuration
+ * @returns {Promise} The response (token or error)
+ */
+export async function handleDockerAuth(request, url, config) {
+ const scope = url.searchParams.get('scope');
+ if (!scope) {
+ return createErrorResponse('Missing scope parameter', 400);
+ }
+
+ // Parse scope to find the target platform and repository
+ // Format: repository:cr/docker/library/ubuntu:pull
+ // We need to extract 'cr/docker' as the platform
+ const parts = scope.split(':');
+ if (parts.length < 3 || parts[0] !== 'repository') {
+ // If not a repository scope, or invalid format, we can't easily proxy it
+ return createErrorResponse('Invalid scope format', 400);
+ }
+
+ const [, fullRepoPath] = parts; // e.g., cr/docker/library/ubuntu
+ let platformKey = '';
+ let repoPath = '';
+
+ // Find the platform from the start of the repo path
+ // Try to match 'cr/docker', 'cr/ghcr', etc.
+ // We need to find which platform prefix matches the start of fullRepoPath
+ // Uses global SORTED_PLATFORMS which is imported
+
+ for (const key of SORTED_PLATFORMS) {
+ if (!key.startsWith('cr-')) continue;
+
+ // Convert key cr-docker to cr/docker for matching
+ const prefix = key.replace(/-/g, '/');
+ if (fullRepoPath.startsWith(`${prefix}/`)) {
+ platformKey = key;
+ repoPath = fullRepoPath.slice(prefix.length + 1); // +1 for the slash
+ break;
+ }
+ }
+
+ if (!platformKey || !config.PLATFORMS[platformKey]) {
+ return createErrorResponse('Unsupported registry platform in scope', 400);
+ }
+
+ const upstreamUrl = config.PLATFORMS[platformKey];
+ const authorization = request.headers.get('Authorization');
+
+ // 1. Fetch the upstream root (v2) to get the proper realm and service
+ // We use the upstream URL + /v2/
+ const v2Url = new URL(`${upstreamUrl}/v2/`);
+ const v2Resp = await fetch(v2Url.toString(), {
+ method: 'GET',
+ redirect: 'follow'
+ });
+
+ if (v2Resp.status !== 401) {
+ // If not 401, maybe no auth needed? Or error.
+ // Just forward the response?
+ return v2Resp;
+ }
+
+ const authenticateStr = v2Resp.headers.get('WWW-Authenticate');
+ if (authenticateStr === null) {
+ return v2Resp;
+ }
+
+ const wwwAuthenticate = parseAuthenticate(authenticateStr);
+
+ // 2. Construct the new scope for the upstream registry
+ // We replace our prefixed path with the actual repo path
+ // e.g. repository:cr/docker/library/ubuntu:pull -> repository:library/ubuntu:pull
+
+ // However, we also need to respect the service name if possible,
+ // but usually we just need to fix the repository part of the scope.
+ const newScope = `repository:${repoPath}:${parts.slice(2).join(':')}`;
+
+ // 3. Fetch the token from the upstream realm
+ return await fetchToken(wwwAuthenticate, newScope, authorization || '');
+}
diff --git a/src/utils/performance.js b/src/utils/performance.js
new file mode 100644
index 0000000..d8eb5ec
--- /dev/null
+++ b/src/utils/performance.js
@@ -0,0 +1,74 @@
+/**
+ * Performance monitoring utilities for Xget
+ */
+
+import { addSecurityHeaders } from './security.js';
+
+/**
+ * Monitors performance metrics during request processing.
+ *
+ * This class tracks timing information throughout request handling lifecycle,
+ * allowing measurement of cache hits, upstream fetch attempts, and total processing time.
+ */
+export class PerformanceMonitor {
+ /**
+ * Initializes a new performance monitor.
+ *
+ * Sets the start time to the current timestamp and creates an empty marks collection.
+ * All subsequent timing marks will be relative to this start time.
+ */
+ constructor() {
+ this.startTime = Date.now();
+ this.marks = new Map();
+ }
+
+ /**
+ * Marks a timing point with the given name.
+ *
+ * Records the elapsed time (in milliseconds) since the monitor was created.
+ * If a mark with the same name already exists, logs a warning and overwrites it.
+ *
+ * @param {string} name - The name of the timing mark (e.g., 'cache_hit', 'attempt_0', 'success')
+ */
+ mark(name) {
+ if (this.marks.has(name)) {
+ console.warn(`Mark with name ${name} already exists.`);
+ }
+ this.marks.set(name, Date.now() - this.startTime);
+ }
+
+ /**
+ * Returns all collected metrics as a plain object.
+ *
+ * Converts the internal Map of timing marks to a JavaScript object suitable for
+ * JSON serialization and inclusion in response headers.
+ *
+ * @returns {Object.} Object containing name-timestamp pairs in milliseconds
+ */
+ getMetrics() {
+ return Object.fromEntries(this.marks.entries());
+ }
+}
+
+/**
+ * Adds performance metrics to response headers.
+ *
+ * Creates a new response with an X-Performance-Metrics header containing
+ * timing data from the PerformanceMonitor instance. Also ensures security
+ * headers are included.
+ *
+ * **Note:** This header is only added to non-protocol responses (not Git/Docker/AI).
+ *
+ * @param {Response} response - The original response object
+ * @param {PerformanceMonitor} monitor - Performance monitor instance with collected metrics
+ * @returns {Response} New response with added performance and security headers
+ */
+export function addPerformanceHeaders(response, monitor) {
+ const headers = new Headers(response.headers);
+ headers.set('X-Performance-Metrics', JSON.stringify(monitor.getMetrics()));
+ addSecurityHeaders(headers);
+ return new Response(response.body, {
+ status: response.status,
+ headers
+ });
+}
diff --git a/src/utils/security.js b/src/utils/security.js
new file mode 100644
index 0000000..4eb8c33
--- /dev/null
+++ b/src/utils/security.js
@@ -0,0 +1,53 @@
+/**
+ * Security utility functions for Xget
+ */
+
+/**
+ * Adds comprehensive security headers to response headers.
+ *
+ * applies industry-standard security headers including:
+ * - HSTS (HTTP Strict Transport Security)
+ * - X-Frame-Options (clickjacking protection)
+ * - X-XSS-Protection (XSS filter)
+ * - Referrer-Policy (referrer information control)
+ * - Content-Security-Policy (resource loading restrictions)
+ * - Permissions-Policy (privacy-invasive feature restrictions)
+ *
+ * @param {Headers} headers - Headers object to modify (mutates in place)
+ * @returns {Headers} Modified headers object (same reference)
+ */
+export function addSecurityHeaders(headers) {
+ headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
+ headers.set('X-Frame-Options', 'DENY');
+ headers.set('X-XSS-Protection', '1; mode=block');
+ headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
+ headers.set('Content-Security-Policy', "default-src 'none'; img-src 'self'; script-src 'none'");
+ headers.set('Permissions-Policy', 'interest-cohort=()');
+ return headers;
+}
+
+/**
+ * Creates a standardized error response with security headers.
+ *
+ * Generates an HTTP error response with appropriate content type and security headers.
+ * Can return either plain text or detailed JSON error format.
+ *
+ * @param {string} message - Error message to display
+ * @param {number} status - HTTP status code (e.g., 400, 404, 500)
+ * @param {boolean} includeDetails - Whether to include detailed JSON error information
+ * @returns {Response} Error response with security headers
+ */
+export function createErrorResponse(message, status, includeDetails = false) {
+ const errorBody = includeDetails
+ ? JSON.stringify({ error: message, status, timestamp: new Date().toISOString() })
+ : message;
+
+ return new Response(errorBody, {
+ status,
+ headers: addSecurityHeaders(
+ new Headers({
+ 'Content-Type': includeDetails ? 'application/json' : 'text/plain'
+ })
+ )
+ });
+}
diff --git a/src/utils/validation.js b/src/utils/validation.js
new file mode 100644
index 0000000..f62b91f
--- /dev/null
+++ b/src/utils/validation.js
@@ -0,0 +1,223 @@
+/**
+ * Request validation utilities for Xget
+ */
+
+import { CONFIG } from '../config/index.js';
+
+/**
+ * Detects if a request is a container registry operation (Docker/OCI).
+ *
+ * Identifies Docker and OCI registry requests by checking for:
+ * - Registry API endpoints (/v2/...)
+ * - Docker-specific User-Agent headers
+ * - Docker/OCI manifest Accept headers
+ *
+ * @param {Request} request - The incoming request object
+ * @param {URL} url - Parsed URL object
+ * @returns {boolean} True if this is a container registry operation
+ */
+export function isDockerRequest(request, url) {
+ // Check for container registry API endpoints
+ if (url.pathname.startsWith('/v2/')) {
+ return true;
+ }
+
+ // Check for Docker-specific User-Agent
+ const userAgent = request.headers.get('User-Agent') || '';
+ if (userAgent.toLowerCase().includes('docker/')) {
+ return true;
+ }
+
+ // Check for Docker-specific Accept headers
+ const accept = request.headers.get('Accept') || '';
+ if (
+ accept.includes('application/vnd.docker.distribution.manifest') ||
+ accept.includes('application/vnd.oci.image.manifest') ||
+ accept.includes('application/vnd.docker.image.rootfs.diff.tar.gzip')
+ ) {
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * Detects if a request is a Git protocol operation.
+ *
+ * Identifies Git requests by checking for:
+ * - Git-specific endpoints (/info/refs, /git-upload-pack, /git-receive-pack)
+ * - Git User-Agent headers
+ * - Git service query parameters
+ * - Git-specific Content-Type headers
+ *
+ * @param {Request} request - The incoming request object
+ * @param {URL} url - Parsed URL object
+ * @returns {boolean} True if this is a Git operation
+ */
+export function isGitRequest(request, url) {
+ // Check for Git-specific endpoints
+ if (url.pathname.endsWith('/info/refs')) {
+ return true;
+ }
+
+ if (url.pathname.endsWith('/git-upload-pack') || url.pathname.endsWith('/git-receive-pack')) {
+ return true;
+ }
+
+ // Check for Git user agents (more comprehensive check)
+ const userAgent = request.headers.get('User-Agent') || '';
+ if (userAgent.includes('git/') || userAgent.startsWith('git/')) {
+ return true;
+ }
+
+ // Check for Git-specific query parameters
+ if (url.searchParams.has('service')) {
+ const service = url.searchParams.get('service');
+ return service === 'git-upload-pack' || service === 'git-receive-pack';
+ }
+
+ // Check for Git-specific content types
+ const contentType = request.headers.get('Content-Type') || '';
+ if (contentType.includes('git-upload-pack') || contentType.includes('git-receive-pack')) {
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * Detects if a request is a Git LFS (Large File Storage) operation.
+ *
+ * Identifies Git LFS requests by checking for:
+ * - LFS-specific endpoints (/info/lfs, /objects/batch)
+ * - LFS object storage paths (SHA-256 hash patterns)
+ * - Git LFS Accept/Content-Type headers
+ * - Git LFS User-Agent
+ *
+ * @param {Request} request - The incoming request object
+ * @param {URL} url - Parsed URL object
+ * @returns {boolean} True if this is a Git LFS operation
+ */
+export function isGitLFSRequest(request, url) {
+ // Check for LFS-specific endpoints
+ if (url.pathname.includes('/info/lfs')) {
+ return true;
+ }
+
+ if (url.pathname.includes('/objects/batch')) {
+ return true;
+ }
+
+ // Check for LFS object storage endpoints (SHA-256 hash is 64 hex characters)
+ if (url.pathname.match(/\/objects\/[a-fA-F0-9]{64}$/)) {
+ return true;
+ }
+
+ // Check for LFS-specific headers
+ const accept = request.headers.get('Accept') || '';
+ const contentType = request.headers.get('Content-Type') || '';
+
+ if (
+ accept.includes('application/vnd.git-lfs') ||
+ contentType.includes('application/vnd.git-lfs')
+ ) {
+ return true;
+ }
+
+ // Check for LFS user agent
+ const userAgent = request.headers.get('User-Agent') || '';
+ if (userAgent.includes('git-lfs')) {
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * Detects if a request is for an AI inference provider API.
+ *
+ * Identifies AI inference requests by checking for:
+ * - AI provider path prefix (/ip/{provider}/...)
+ * - Common AI API endpoints (chat, completions, embeddings, etc.)
+ * - AI-specific URL patterns with JSON POST requests
+ *
+ * @param {Request} request - The incoming request object
+ * @param {URL} url - Parsed URL object
+ * @returns {boolean} True if this is an AI inference request
+ */
+export function isAIInferenceRequest(request, url) {
+ // Check for AI inference provider paths (ip/{provider}/...)
+ if (url.pathname.startsWith('/ip/')) {
+ return true;
+ }
+
+ // Check for common AI inference API endpoints
+ const aiEndpoints = [
+ '/v1/chat/completions',
+ '/v1/completions',
+ '/v1/messages',
+ '/v1/predictions',
+ '/v1/generate',
+ '/v1/embeddings',
+ '/openai/v1/chat/completions'
+ ];
+
+ if (aiEndpoints.some(endpoint => url.pathname.includes(endpoint))) {
+ return true;
+ }
+
+ // Check for AI-specific content types
+ const contentType = request.headers.get('Content-Type') || '';
+ if (contentType.includes('application/json') && request.method === 'POST') {
+ // Additional check for common AI inference patterns in URL
+ if (
+ url.pathname.includes('/chat/') ||
+ url.pathname.includes('/completions') ||
+ url.pathname.includes('/generate') ||
+ url.pathname.includes('/predict')
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Validates incoming requests against security rules.
+ *
+ * Performs security validation including:
+ * - HTTP method validation (with special allowances for Git/Docker/AI operations)
+ * - URL path length limits
+ *
+ * Different protocols have different allowed methods:
+ * - Regular requests: GET, HEAD (configurable via SECURITY.ALLOWED_METHODS)
+ * - Git/LFS/Docker/AI: GET, HEAD, POST, PUT, PATCH
+ *
+ * @param {Request} request - The incoming request object
+ * @param {URL} url - Parsed URL object
+ * @param {import('../config/index.js').ApplicationConfig} config - Configuration object
+ * @returns {{valid: boolean, error?: string, status?: number}} Validation result object
+ */
+export function validateRequest(request, url, config = CONFIG) {
+ // Allow POST method for Git, Git LFS, Docker, and AI inference operations
+ const isGit = isGitRequest(request, url);
+ const isGitLFS = isGitLFSRequest(request, url);
+ const isDocker = isDockerRequest(request, url);
+ const isAI = isAIInferenceRequest(request, url);
+
+ const allowedMethods =
+ isGit || isGitLFS || isDocker || isAI
+ ? ['GET', 'HEAD', 'POST', 'PUT', 'PATCH']
+ : config.SECURITY.ALLOWED_METHODS;
+
+ if (!allowedMethods.includes(request.method)) {
+ return { valid: false, error: 'Method not allowed', status: 405 };
+ }
+
+ if (url.pathname.length > config.SECURITY.MAX_PATH_LENGTH) {
+ return { valid: false, error: 'Path too long', status: 414 };
+ }
+
+ return { valid: true };
+}
diff --git a/test/features/security.test.js b/test/features/security.test.js
index ebd85a4..eaad234 100644
--- a/test/features/security.test.js
+++ b/test/features/security.test.js
@@ -231,7 +231,7 @@ describe('Security Features', () => {
if (response.status >= 400) {
const body = await response.text();
// Should not expose internal paths, stack traces, or sensitive info
- expect(body).not.toMatch(/\/[a-zA-Z]:[\\/ /); // Windows paths
+ expect(body).not.toMatch(/\/[a-zA-Z]:[\\/]/); // Windows paths
expect(body).not.toMatch(/\/home\/[^/]+/); // Unix home paths
expect(body).not.toMatch(/at [a-zA-Z]+\.[a-zA-Z]+/); // Stack traces
expect(body).not.toMatch(/Error: .+ at/); // Detailed error messages
@@ -244,6 +244,11 @@ describe('Security Features', () => {
redirect: 'manual'
});
+ // Should return error or redirect
+ expect([400, 404, 302, 301, 405, 501]).toContain(response.status);
+ });
+ });
+
describe('CORS Security', () => {
it('should handle CORS preflight requests securely', async () => {
const response = await SELF.fetch('https://example.com/gh/test/repo', {