Skip to content
Draft
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
20 changes: 10 additions & 10 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 38 additions & 0 deletions src/cache/default-cache-dir.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { join } from 'node:path';

/**
* Resolve the default cache directory using platform-aware conventions.
*
* Precedence:
* 1. ALLDOCS_CACHE_DIR env var
* 2. Platform default:
* - macOS: ~/Library/Caches/_all_docs
* - Linux: ${XDG_CACHE_HOME:-$HOME/.cache}/_all_docs
* - Win32: %LOCALAPPDATA%\_all_docs\cache
*
* @param {Object} [env=process.env] - Environment variables (for testability)
* @param {string} [platform=process.platform] - OS platform (for testability)
* @returns {string} Absolute path to cache directory
*/
export function defaultCacheDir(env = process.env, platform = process.platform) {
if (env.ALLDOCS_CACHE_DIR) {
return env.ALLDOCS_CACHE_DIR;
}

// On win32, prefer USERPROFILE (native Windows path) over HOME (may be MSYS2/Git Bash)
const home = platform === 'win32'
? (env.USERPROFILE || env.HOME)
: (env.HOME || env.USERPROFILE);
if (!home) {
throw new Error('Cannot determine home directory: neither HOME nor USERPROFILE is set');
}

switch (platform) {
case 'darwin':
return join(home, 'Library', 'Caches', '_all_docs');
case 'win32':
return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), '_all_docs', 'cache');
default:
return join(env.XDG_CACHE_HOME || join(home, '.cache'), '_all_docs');
}
}
27 changes: 27 additions & 0 deletions src/cache/entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ export class CacheEntry {
const data = JSON.stringify(body);
this.integrity = await this.calculateIntegrity(data);
}

/**
* Set body from a pre-existing JSON string, avoiding re-serialization.
* Integrity is skipped (cacache provides storage-layer integrity).
* @param {Object} body - Parsed body object
* @param {string} rawJsonString - The original JSON string
*/
setBodyRaw(body, rawJsonString) {
this.body = body;
this._rawJson = rawJsonString;
this.integrity = null;
}

async calculateIntegrity(data) {
if (globalThis.crypto && globalThis.crypto.subtle) {
Expand Down Expand Up @@ -97,13 +109,28 @@ export class CacheEntry {
return this.headers['etag'];
}

get lastModified() {
return this.headers['last-modified'];
}

extractMaxAge(cacheControl) {
if (!cacheControl) return null;
const match = cacheControl.match(/max-age=(\d+)/);
return match ? parseInt(match[1], 10) : null;
}

encode() {
if (this._rawJson) {
// Fast path: splice raw body JSON into metadata to avoid re-stringify
const meta = JSON.stringify({
statusCode: this.statusCode,
headers: this.headers,
integrity: this.integrity,
timestamp: this.timestamp,
version: this.version
});
return meta.slice(0, -1) + ',"body":' + this._rawJson + '}';
}
return {
statusCode: this.statusCode,
headers: this.headers,
Expand Down
4 changes: 3 additions & 1 deletion src/cache/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ export { CacheEntry } from './entry.js';
export { createCacheKey, decodeCacheKey, createPartitionKey, createPackumentKey, encodeOrigin } from './cache-key.js';
export { PartitionCheckpoint } from './checkpoint.js';
export { createStorageDriver, LocalDirStorageDriver, isLocalPath } from './storage-driver.js';
export { AuthError, TempError, PermError, categorizeHttpError } from './errors.js';
export { AuthError, TempError, PermError, categorizeHttpError } from './errors.js';
export { PackumentCache } from './packument-cache.js';
export { defaultCacheDir } from './default-cache-dir.js';
5 changes: 4 additions & 1 deletion src/cache/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@_all_docs/cache",
"description": "A hierarchical file-system buffer cache for HTTP responses",
"version": "0.5.1",
"version": "0.6.0",
"main": "index.js",
"type": "module",
"repository": {
Expand All @@ -24,6 +24,9 @@
"bloom-filters": "^3.0.2",
"undici": "catalog:"
},
"optionalDependencies": {
"cacache": ">=18"
},
"devDependencies": {
"rimraf": "^6.0.1"
}
Expand Down
194 changes: 194 additions & 0 deletions src/cache/packument-cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { Cache } from './cache.js';
import { CacheEntry } from './entry.js';
import { createPackumentKey } from './cache-key.js';
import { defaultCacheDir } from './default-cache-dir.js';

/**
* Minimal cacache storage driver for Node.js environments.
* Used as the default when no external driver is injected.
* @private
*/
class CacacheDriver {
constructor(cachePath) {
this.cachePath = cachePath;
this.supportsBatch = false;
this.supportsBloom = false;
/** @type {import('cacache') | null} */
this._cacache = null;
}

async _ensureCacache() {
if (this._cacache) return this._cacache;
try {
this._cacache = (await import('cacache')).default;
} catch {
throw new Error(
"PackumentCache requires 'cacache' for Node.js caching.\n" +
' Install it: npm install cacache\n' +
' Or provide a custom driver: new PackumentCache({ driver, origin })'
);
}
return this._cacache;
}

async get(key) {
const cacache = await this._ensureCacache();
try {
const { data } = await cacache.get(this.cachePath, key);
return JSON.parse(data.toString('utf8'));
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error(`Key not found: ${key}`);
}
throw error;
}
}

async put(key, value) {
const cacache = await this._ensureCacache();
const data = typeof value === 'string' ? value : JSON.stringify(value);
await cacache.put(this.cachePath, key, data);
}

async has(key) {
const cacache = await this._ensureCacache();
const info = await cacache.get.info(this.cachePath, key);
return info !== null;
}

async delete(key) {
const cacache = await this._ensureCacache();
await cacache.rm.entry(this.cachePath, key);
}

async *list(prefix) {
const cacache = await this._ensureCacache();
const stream = cacache.ls.stream(this.cachePath);
for await (const entry of stream) {
if (entry.key.startsWith(prefix)) {
yield entry.key;
}
}
}
}

/**
* High-level packument cache API.
*
* Hides cache key construction, storage driver creation,
* and CacheEntry encode/decode from consumers.
*
* @example
* ```js
* import { PackumentCache } from '@_all_docs/cache';
*
* const cache = new PackumentCache({
* origin: 'https://registry.npmjs.org'
* });
*
* // Read
* const entry = await cache.get('lodash');
*
* // Write (from HTTP response)
* await cache.put('lodash', {
* statusCode: 200,
* headers: { etag: '"abc"', 'cache-control': 'max-age=300' },
* body: packumentJson
* });
*
* // Conditional request headers
* const headers = await cache.conditionalHeaders('lodash');
* ```
*/
export class PackumentCache {
/**
* @param {Object} options
* @param {string} options.origin - Registry origin URL (required)
* @param {string} [options.cacheDir] - Cache directory override. Defaults to platform-specific location.
* @param {Object} [options.driver] - Custom storage driver. When omitted, a cacache-based driver is created.
*/
constructor({ origin, cacheDir, driver } = {}) {
if (!origin) {
throw new Error('PackumentCache requires an origin (registry URL)');
}
this.origin = origin;
this._cacheDir = cacheDir || defaultCacheDir();
this._externalDriver = driver || null;
/** @type {Cache | null} */
this._cache = null;
}

/** @private */
async _ensureInitialized() {
if (this._cache) return;
const driver = this._externalDriver || new CacacheDriver(this._cacheDir);
this._cache = new Cache({
path: this._cacheDir,
driver
});
}

/**
* Read a packument from the cache.
* @param {string} name - Raw package name (e.g. '@babel/core')
* @returns {Promise<CacheEntry | null>} Decoded cache entry, or null if not cached
*/
async get(name) {
await this._ensureInitialized();
const key = createPackumentKey(name, this.origin);
const raw = await this._cache.fetch(key);
if (!raw) return null;
return CacheEntry.decode(raw);
}

/**
* Write a packument to the cache from an HTTP response.
* @param {string} name - Raw package name (e.g. '@babel/core')
* @param {Object} response - Response-shaped object
* @param {number} response.statusCode - HTTP status code
* @param {Object|Headers} response.headers - Response headers
* @param {Object} response.body - Parsed packument JSON
* @param {string} [response.bodyRaw] - Raw JSON string (skips re-serialization when provided)
*/
async put(name, { statusCode, headers, body, bodyRaw }) {
await this._ensureInitialized();
const entry = new CacheEntry(statusCode, headers);
if (bodyRaw) {
entry.setBodyRaw(body, bodyRaw);
} else {
await entry.setBody(body);
}
const key = createPackumentKey(name, this.origin);
await this._cache.set(key, entry.encode());
}

/**
* Get conditional request headers for a cached packument.
* Returns headers suitable for If-None-Match / If-Modified-Since.
* @param {string} name - Raw package name
* @returns {Promise<Object>} Header object (may be empty if not cached)
*/
async conditionalHeaders(name) {
const entry = await this.get(name);
if (!entry) return {};
const result = {};
if (entry.etag) {
result['if-none-match'] = entry.etag;
}
if (entry.lastModified) {
result['if-modified-since'] = entry.lastModified;
}
return result;
}

/**
* Check if a packument is in the cache.
* @param {string} name - Raw package name
* @returns {Promise<boolean>}
*/
async has(name) {
await this._ensureInitialized();
const key = createPackumentKey(name, this.origin);
return this._cache.has(key);
}
}
Loading
Loading