From 9f8666bfe838c923c28cb34c63f5c4808dc03e0b Mon Sep 17 00:00:00 2001 From: indexzero Date: Tue, 10 Mar 2026 23:44:45 -0400 Subject: [PATCH] feat(cache): add PackumentCache API and defaultCacheDir for shared caching Add a high-level PackumentCache class that hides cache key construction, storage driver creation, and CacheEntry encode/decode from consumers. This enables flatcover and _all_docs CLI to share a single packument cache with origin-keyed entries and content-addressable storage. - PackumentCache: get/put/has/conditionalHeaders with origin isolation - defaultCacheDir(): platform-aware XDG/macOS/Windows cache resolution - CacheEntry.lastModified getter for If-Modified-Since support - CacheEntry.setBodyRaw() zero-stringify fast path for write operations - CacacheDriver: inline cacache wrapper with string passthrough - cacache added as optionalDependency for Node.js environments Co-Authored-By: Claude Opus 4.6 (1M context) --- pnpm-lock.yaml | 20 +- src/cache/default-cache-dir.js | 38 ++++ src/cache/entry.js | 27 +++ src/cache/index.js | 4 +- src/cache/package.json | 5 +- src/cache/packument-cache.js | 194 +++++++++++++++++++ src/cache/test/default-cache-dir.test.js | 50 +++++ src/cache/test/entry.test.js | 69 ++++++- src/cache/test/mock-driver.js | 3 +- src/cache/test/packument-cache.test.js | 225 +++++++++++++++++++++++ 10 files changed, 621 insertions(+), 14 deletions(-) create mode 100644 src/cache/default-cache-dir.js create mode 100644 src/cache/packument-cache.js create mode 100644 src/cache/test/default-cache-dir.test.js create mode 100644 src/cache/test/packument-cache.test.js diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 155ed15..2b17753 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,7 +40,7 @@ importers: version: 10.1.4(debug@4.4.3) p-map: specifier: 'catalog:' - version: 7.0.4 + version: 7.0.3 p-map-series: specifier: 'catalog:' version: 3.0.0 @@ -49,7 +49,7 @@ importers: version: 8.1.1 undici: specifier: 'catalog:' - version: 7.20.0 + version: 7.16.0 devDependencies: '@changesets/cli': specifier: ^2.29.8 @@ -144,6 +144,10 @@ importers: rimraf: specifier: ^6.0.1 version: 6.0.1 + optionalDependencies: + cacache: + specifier: '>=18' + version: 19.0.1 src/config: dependencies: @@ -3106,11 +3110,13 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.0.3: resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.0: @@ -3120,7 +3126,7 @@ packages: glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} @@ -4782,7 +4788,7 @@ packages: tar@7.5.1: resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==} engines: {node: '>=18'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me teeny-request@9.0.0: resolution: {integrity: sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==} @@ -4907,10 +4913,6 @@ packages: resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} engines: {node: '>=20.18.1'} - undici@7.20.0: - resolution: {integrity: sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==} - engines: {node: '>=20.18.1'} - unenv@1.10.0: resolution: {integrity: sha512-wY5bskBQFL9n3Eca5XnhH6KbUo/tfvkwm9OpcdCvLaeA7piBNbavbOKJySEwQ1V0RH6HvNlSAFRTpvTqgKRQXQ==} @@ -9834,8 +9836,6 @@ snapshots: undici@7.16.0: {} - undici@7.20.0: {} - unenv@1.10.0: dependencies: consola: 3.4.2 diff --git a/src/cache/default-cache-dir.js b/src/cache/default-cache-dir.js new file mode 100644 index 0000000..035b06e --- /dev/null +++ b/src/cache/default-cache-dir.js @@ -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'); + } +} diff --git a/src/cache/entry.js b/src/cache/entry.js index df00c5d..8d7c597 100644 --- a/src/cache/entry.js +++ b/src/cache/entry.js @@ -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) { @@ -97,6 +109,10 @@ 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+)/); @@ -104,6 +120,17 @@ export class CacheEntry { } 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, diff --git a/src/cache/index.js b/src/cache/index.js index ff30158..7811d68 100644 --- a/src/cache/index.js +++ b/src/cache/index.js @@ -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'; \ No newline at end of file +export { AuthError, TempError, PermError, categorizeHttpError } from './errors.js'; +export { PackumentCache } from './packument-cache.js'; +export { defaultCacheDir } from './default-cache-dir.js'; \ No newline at end of file diff --git a/src/cache/package.json b/src/cache/package.json index 898a53c..ca1f212 100644 --- a/src/cache/package.json +++ b/src/cache/package.json @@ -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": { @@ -24,6 +24,9 @@ "bloom-filters": "^3.0.2", "undici": "catalog:" }, + "optionalDependencies": { + "cacache": ">=18" + }, "devDependencies": { "rimraf": "^6.0.1" } diff --git a/src/cache/packument-cache.js b/src/cache/packument-cache.js new file mode 100644 index 0000000..a681df2 --- /dev/null +++ b/src/cache/packument-cache.js @@ -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} 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} 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} + */ + async has(name) { + await this._ensureInitialized(); + const key = createPackumentKey(name, this.origin); + return this._cache.has(key); + } +} diff --git a/src/cache/test/default-cache-dir.test.js b/src/cache/test/default-cache-dir.test.js new file mode 100644 index 0000000..2ad3e1d --- /dev/null +++ b/src/cache/test/default-cache-dir.test.js @@ -0,0 +1,50 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { join } from 'node:path'; +import { defaultCacheDir } from '../default-cache-dir.js'; + +describe('defaultCacheDir', () => { + it('should return macOS path for darwin platform', () => { + const result = defaultCacheDir({ HOME: '/Users/alice' }, 'darwin'); + assert.equal(result, join('/Users/alice', 'Library', 'Caches', '_all_docs')); + }); + + it('should return Linux XDG path with default HOME', () => { + const result = defaultCacheDir({ HOME: '/home/alice' }, 'linux'); + assert.equal(result, join('/home/alice', '.cache', '_all_docs')); + }); + + it('should respect XDG_CACHE_HOME on Linux', () => { + const result = defaultCacheDir( + { HOME: '/home/alice', XDG_CACHE_HOME: '/custom/cache' }, + 'linux' + ); + assert.equal(result, join('/custom/cache', '_all_docs')); + }); + + it('should respect ALLDOCS_CACHE_DIR override on any platform', () => { + const override = '/my/custom/cache'; + assert.equal(defaultCacheDir({ ALLDOCS_CACHE_DIR: override, HOME: '/Users/alice' }, 'darwin'), override); + assert.equal(defaultCacheDir({ ALLDOCS_CACHE_DIR: override, HOME: '/home/alice' }, 'linux'), override); + }); + + it('should throw when HOME and USERPROFILE are both unset', () => { + assert.throws( + () => defaultCacheDir({}, 'linux'), + /Cannot determine home directory/ + ); + }); + + it('should return Windows path for win32 platform', () => { + const result = defaultCacheDir( + { USERPROFILE: 'C:\\Users\\alice', LOCALAPPDATA: 'C:\\Users\\alice\\AppData\\Local' }, + 'win32' + ); + assert.equal(result, join('C:\\Users\\alice\\AppData\\Local', '_all_docs', 'cache')); + }); + + it('should fall back to USERPROFILE on win32 without LOCALAPPDATA', () => { + const result = defaultCacheDir({ USERPROFILE: 'C:\\Users\\alice' }, 'win32'); + assert.equal(result, join('C:\\Users\\alice', 'AppData', 'Local', '_all_docs', 'cache')); + }); +}); diff --git a/src/cache/test/entry.test.js b/src/cache/test/entry.test.js index 2207489..970d345 100644 --- a/src/cache/test/entry.test.js +++ b/src/cache/test/entry.test.js @@ -120,8 +120,75 @@ describe('CacheEntry', () => { it('should get etag property', () => { const entry = new CacheEntry(200, { 'etag': '"test-etag"' }); assert.equal(entry.etag, '"test-etag"'); - + const noEtag = new CacheEntry(200, {}); assert.equal(noEtag.etag, undefined); }); + + it('should get lastModified property', () => { + const entry = new CacheEntry(200, { 'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT' }); + assert.equal(entry.lastModified, 'Wed, 21 Oct 2015 07:28:00 GMT'); + }); + + it('should return undefined when no lastModified header', () => { + const entry = new CacheEntry(200, {}); + assert.equal(entry.lastModified, undefined); + }); + + it('should preserve lastModified through encode/decode', async () => { + const original = new CacheEntry(200, { + 'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT', + 'etag': '"abc"' + }); + await original.setBody({ test: true }); + + const decoded = CacheEntry.decode(original.encode()); + assert.equal(decoded.lastModified, 'Wed, 21 Oct 2015 07:28:00 GMT'); + }); + + it('should set body from raw string without stringify', () => { + const entry = new CacheEntry(200, { 'etag': '"raw"' }); + const body = { name: 'lodash', versions: { '4.17.21': {} } }; + const rawJson = JSON.stringify(body); + + entry.setBodyRaw(body, rawJson); + + assert.deepEqual(entry.body, body); + assert.equal(entry.integrity, null, 'integrity skipped for raw path'); + assert.equal(entry._rawJson, rawJson); + }); + + it('should encode to string when _rawJson is set', () => { + const entry = new CacheEntry(200, { 'etag': '"raw"' }); + const body = { name: 'test' }; + const rawJson = '{"name":"test"}'; + + entry.setBodyRaw(body, rawJson); + const encoded = entry.encode(); + + assert.equal(typeof encoded, 'string', 'encode should return a string'); + const decoded = JSON.parse(encoded); + assert.equal(decoded.statusCode, 200); + assert.deepEqual(decoded.body, body); + assert.equal(decoded.integrity, null); + assert.equal(decoded.headers['etag'], '"raw"'); + }); + + it('should round-trip through encode/decode with raw body', () => { + const entry = new CacheEntry(200, { + 'etag': '"round-trip"', + 'cache-control': 'max-age=300' + }); + const body = { name: 'lodash', versions: { '4.17.21': { name: 'lodash' } } }; + const rawJson = JSON.stringify(body); + + entry.setBodyRaw(body, rawJson); + const encoded = entry.encode(); + const decoded = CacheEntry.decode(JSON.parse(encoded)); + + assert.equal(decoded.statusCode, 200); + assert.deepEqual(decoded.body, body); + assert.equal(decoded.etag, '"round-trip"'); + assert.equal(decoded.version, 1); + }); }); \ No newline at end of file diff --git a/src/cache/test/mock-driver.js b/src/cache/test/mock-driver.js index f087e02..2c80280 100644 --- a/src/cache/test/mock-driver.js +++ b/src/cache/test/mock-driver.js @@ -11,7 +11,8 @@ export class MockStorageDriver { async get(key) { const value = this.store.get(key); if (!value) throw new Error(`Key not found: ${key}`); - return value; + // Mirror CacacheDriver: stored strings are deserialized on read + return typeof value === 'string' ? JSON.parse(value) : value; } async put(key, value) { diff --git a/src/cache/test/packument-cache.test.js b/src/cache/test/packument-cache.test.js new file mode 100644 index 0000000..ee378ee --- /dev/null +++ b/src/cache/test/packument-cache.test.js @@ -0,0 +1,225 @@ +import { describe, it, beforeEach } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { PackumentCache } from '../packument-cache.js'; +import { MockStorageDriver } from './mock-driver.js'; + +describe('PackumentCache', () => { + const ORIGIN = 'https://registry.npmjs.org'; + let driver; + + beforeEach(() => { + driver = new MockStorageDriver(); + }); + + it('should require origin', () => { + assert.throws( + () => new PackumentCache({ driver }), + /requires an origin/ + ); + }); + + it('should return null for uncached package', async () => { + const cache = new PackumentCache({ origin: ORIGIN, driver }); + const result = await cache.get('lodash'); + assert.equal(result, null); + }); + + it('should round-trip put and get', async () => { + const cache = new PackumentCache({ origin: ORIGIN, driver }); + const body = { name: 'lodash', versions: { '4.17.21': {} } }; + + await cache.put('lodash', { + statusCode: 200, + headers: { + 'etag': '"abc123"', + 'cache-control': 'max-age=300', + 'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT' + }, + body + }); + + const entry = await cache.get('lodash'); + assert.ok(entry); + assert.deepEqual(entry.body, body); + assert.equal(entry.etag, '"abc123"'); + assert.equal(entry.lastModified, 'Wed, 21 Oct 2015 07:28:00 GMT'); + assert.equal(entry.statusCode, 200); + }); + + it('should return {} from conditionalHeaders for uncached package', async () => { + const cache = new PackumentCache({ origin: ORIGIN, driver }); + const headers = await cache.conditionalHeaders('missing-pkg'); + assert.deepEqual(headers, {}); + }); + + it('should return if-none-match when entry has etag', async () => { + const cache = new PackumentCache({ origin: ORIGIN, driver }); + await cache.put('lodash', { + statusCode: 200, + headers: { 'etag': '"etag-value"' }, + body: { name: 'lodash' } + }); + + const headers = await cache.conditionalHeaders('lodash'); + assert.equal(headers['if-none-match'], '"etag-value"'); + }); + + it('should return if-modified-since when entry has lastModified only', async () => { + const cache = new PackumentCache({ origin: ORIGIN, driver }); + await cache.put('lodash', { + statusCode: 200, + headers: { 'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT' }, + body: { name: 'lodash' } + }); + + const headers = await cache.conditionalHeaders('lodash'); + assert.equal(headers['if-modified-since'], 'Wed, 21 Oct 2015 07:28:00 GMT'); + assert.equal(headers['if-none-match'], undefined); + }); + + it('should return both headers when entry has etag and lastModified', async () => { + const cache = new PackumentCache({ origin: ORIGIN, driver }); + await cache.put('lodash', { + statusCode: 200, + headers: { + 'etag': '"both"', + 'last-modified': 'Thu, 01 Jan 2025 00:00:00 GMT' + }, + body: { name: 'lodash' } + }); + + const headers = await cache.conditionalHeaders('lodash'); + assert.equal(headers['if-none-match'], '"both"'); + assert.equal(headers['if-modified-since'], 'Thu, 01 Jan 2025 00:00:00 GMT'); + }); + + it('should report has() correctly', async () => { + const cache = new PackumentCache({ origin: ORIGIN, driver }); + + assert.equal(await cache.has('lodash'), false); + + await cache.put('lodash', { + statusCode: 200, + headers: {}, + body: { name: 'lodash' } + }); + + assert.equal(await cache.has('lodash'), true); + }); + + it('should isolate entries by origin', async () => { + const npmCache = new PackumentCache({ origin: 'https://registry.npmjs.org', driver }); + const cgrCache = new PackumentCache({ origin: 'https://libraries.cgr.dev/javascript', driver }); + + await npmCache.put('lodash', { + statusCode: 200, + headers: { 'etag': '"npm"' }, + body: { name: 'lodash', source: 'npm' } + }); + + // Same package name, different origin should return null + const cgrEntry = await cgrCache.get('lodash'); + assert.equal(cgrEntry, null); + + // Original origin should still have it + const npmEntry = await npmCache.get('lodash'); + assert.ok(npmEntry); + assert.equal(npmEntry.body.source, 'npm'); + }); + + it('should share entries with same origin', async () => { + const cache1 = new PackumentCache({ origin: ORIGIN, driver }); + const cache2 = new PackumentCache({ origin: ORIGIN, driver }); + + await cache1.put('express', { + statusCode: 200, + headers: { 'etag': '"shared"' }, + body: { name: 'express' } + }); + + const entry = await cache2.get('express'); + assert.ok(entry); + assert.equal(entry.body.name, 'express'); + }); + + it('should report valid entry based on cache-control', async () => { + const cache = new PackumentCache({ origin: ORIGIN, driver }); + + await cache.put('fresh-pkg', { + statusCode: 200, + headers: { 'cache-control': 'max-age=3600' }, + body: { name: 'fresh-pkg' } + }); + + const entry = await cache.get('fresh-pkg'); + assert.ok(entry); + assert.equal(entry.valid, true); + }); + + it('should handle scoped package names', async () => { + const cache = new PackumentCache({ origin: ORIGIN, driver }); + const body = { name: '@babel/core', versions: { '7.23.0': {} } }; + + await cache.put('@babel/core', { + statusCode: 200, + headers: { 'etag': '"scoped"' }, + body + }); + + const entry = await cache.get('@babel/core'); + assert.ok(entry); + assert.deepEqual(entry.body, body); + assert.equal(entry.etag, '"scoped"'); + }); + + it('should use defaultCacheDir when cacheDir not provided', () => { + const cache = new PackumentCache({ origin: ORIGIN, driver }); + // Should not throw -- uses platform default + assert.ok(cache._cacheDir); + assert.ok(typeof cache._cacheDir === 'string'); + assert.ok(cache._cacheDir.length > 0); + }); + + it('should round-trip with bodyRaw (zero-stringify fast path)', async () => { + const cache = new PackumentCache({ origin: ORIGIN, driver }); + const body = { name: 'lodash', versions: { '4.17.21': {} } }; + const bodyRaw = JSON.stringify(body); + + await cache.put('lodash', { + statusCode: 200, + headers: { 'etag': '"fast"', 'cache-control': 'max-age=300' }, + body, + bodyRaw + }); + + const entry = await cache.get('lodash'); + assert.ok(entry); + assert.deepEqual(entry.body, body); + assert.equal(entry.etag, '"fast"'); + assert.equal(entry.statusCode, 200); + }); + + it('should produce identical get() result with and without bodyRaw', async () => { + const body = { name: 'express', versions: { '4.18.2': {} }, time: { '4.18.2': '2024-01-01' } }; + const bodyRaw = JSON.stringify(body); + const headers = { 'etag': '"compare"', 'cache-control': 'max-age=60' }; + + // Store without bodyRaw (slow path) + const slowDriver = new MockStorageDriver(); + const slowCache = new PackumentCache({ origin: ORIGIN, driver: slowDriver }); + await slowCache.put('express', { statusCode: 200, headers, body }); + const slowEntry = await slowCache.get('express'); + + // Store with bodyRaw (fast path) + const fastDriver = new MockStorageDriver(); + const fastCache = new PackumentCache({ origin: ORIGIN, driver: fastDriver }); + await fastCache.put('express', { statusCode: 200, headers, body, bodyRaw }); + const fastEntry = await fastCache.get('express'); + + // Bodies should be identical + assert.deepEqual(fastEntry.body, slowEntry.body); + assert.equal(fastEntry.etag, slowEntry.etag); + assert.equal(fastEntry.statusCode, slowEntry.statusCode); + assert.equal(fastEntry.version, slowEntry.version); + }); +});