diff --git a/.nycrc.json b/.nycrc.json index 758da34..6767fa5 100644 --- a/.nycrc.json +++ b/.nycrc.json @@ -6,5 +6,8 @@ "check-coverage": true, "lines": 20, "branches": 80, - "statements": 20 + "statements": 20, + "exclude": [ + "test/fixtures/**" + ] } diff --git a/docs/API.md b/docs/API.md index 5c802a5..ff4db8e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,12 +1,158 @@ - +# API Documentation -## main(name) ⇒ string -This is the main function +## CacheOverride -**Kind**: global function -**Returns**: string - a greeting +The `CacheOverride` class provides a unified API for controlling cache behavior across both Fastly Compute and Cloudflare Workers platforms. -| Param | Type | Description | -| --- | --- | --- | -| name | string | name of the person to greet | +### Import +```javascript +import { CacheOverride, fetch } from '@adobe/fetch'; +``` + +### Constructor + +#### `new CacheOverride(mode, init)` + +Creates a new CacheOverride instance with the specified mode and options. + +**Parameters:** + +- `mode` (string): Cache override mode. One of: + - `"none"`: Respect origin cache control headers (default behavior) + - `"pass"`: Prevent caching regardless of origin headers + - `"override"`: Apply custom cache settings +- `init` (object, optional): Cache configuration options + +**Alternative Signature:** + +#### `new CacheOverride(init)` + +Creates a new CacheOverride instance with `"override"` mode and the specified options. + +**Parameters:** + +- `init` (object): Cache configuration options + +### Cross-Platform Options + +The CacheOverride API only includes options that work on **both** Fastly and Cloudflare platforms to ensure true cross-platform compatibility: + +| Option | Type | Description | Platform Mapping | +|--------|------|-------------|------------------| +| `ttl` | number | Time-to-live in seconds | Fastly: native `ttl`
Cloudflare: `cf.cacheTtl` | +| `cacheKey` | string | Custom cache key | Fastly: native `cacheKey`
Cloudflare: `cf.cacheKey` | +| `surrogateKey` | string | Space-separated surrogate keys for cache purging | Fastly: native `surrogateKey`
Cloudflare: `cf.cacheTags` (array) | + +**Note:** Platform-specific options (like Fastly's `swr`, `pci`, `beforeSend`, `afterSend`) are intentionally excluded to maintain cross-platform compatibility. If you pass unsupported options, they will be ignored with a console warning. + +### Usage Examples + +#### Basic TTL Override + +```javascript +import { fetch, CacheOverride } from '@adobe/fetch'; + +const cacheOverride = new CacheOverride('override', { + ttl: 3600 // Cache for 1 hour +}); + +const response = await fetch('https://example.com/api', { + cacheOverride +}); +``` + +#### Prevent Caching + +```javascript +const cacheOverride = new CacheOverride('pass'); + +const response = await fetch('https://example.com/api', { + cacheOverride +}); +``` + +#### Advanced Configuration + +```javascript +const cacheOverride = new CacheOverride({ + ttl: 3600, // Cache for 1 hour + cacheKey: 'my-key', // Custom cache key + surrogateKey: 'api v1' // Surrogate keys for purging +}); + +const response = await fetch('https://example.com/api', { + cacheOverride +}); +``` + +#### Conditional Caching by Path + +```javascript +import { fetch, CacheOverride } from '@adobe/fetch'; + +export async function main(request, context) { + const url = new URL(request.url); + let cacheOverride; + + if (url.pathname.startsWith('/static/')) { + // Long cache for static resources + cacheOverride = new CacheOverride({ ttl: 86400 }); + } else if (url.pathname === '/') { + // Short cache for homepage + cacheOverride = new CacheOverride({ ttl: 60 }); + } else { + // Respect origin cache headers + cacheOverride = new CacheOverride('none'); + } + + return fetch(url, { cacheOverride }); +} +``` + +### Platform-Specific Behavior + +#### Fastly Compute + +On Fastly, `CacheOverride` uses the native `fastly:cache-override` module. Only cross-platform compatible options are passed through to ensure consistent behavior. + +```javascript +// On Fastly, this uses native CacheOverride with cross-platform options +const override = new CacheOverride('override', { + ttl: 3600, + cacheKey: 'my-key', + surrogateKey: 'homepage main' +}); +``` + +#### Cloudflare Workers + +On Cloudflare, `CacheOverride` options are automatically mapped to the `cf` object in fetch options: + +| CacheOverride | Cloudflare cf object | +|---------------|---------------------| +| `mode: "pass"` | `cf: { cacheTtl: 0 }` | +| `mode: "none"` | No cf options added | +| `ttl: 3600` | `cf: { cacheTtl: 3600 }` | +| `cacheKey: "key"` | `cf: { cacheKey: "key" }` | +| `surrogateKey: "a b"` | `cf: { cacheTags: ["a", "b"] }` | + +```javascript +// On Cloudflare, this is converted to: +// fetch(url, { cf: { cacheTtl: 3600, cacheKey: 'my-key', cacheTags: ['api', 'v1'] } }) +const override = new CacheOverride({ + ttl: 3600, + cacheKey: 'my-key', + surrogateKey: 'api v1' +}); + +await fetch(url, { cacheOverride: override }); +``` + +### Notes + +- **Cross-Platform Compatibility**: Only options supported on both platforms are included in this API +- **Unsupported Options**: If you pass platform-specific options (like `swr`, `pci`, `beforeSend`, `afterSend`), they will be ignored with a console warning +- **Cloudflare Enterprise**: The `cacheKey` feature requires a Cloudflare Enterprise plan +- **Surrogate Keys**: On Cloudflare, the space-separated `surrogateKey` string is automatically split into an array for `cf.cacheTags` +- **For Platform-Specific Features**: If you need platform-specific cache features, use platform detection and native APIs directly instead of the cross-platform `CacheOverride` API diff --git a/package.json b/package.json index 91d6ee8..2ada3fd 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "main": "src/index.js", "type": "module", "scripts": { - "test": "c8 mocha -i -g Integration", - "integration-ci": "c8 mocha -g Integration", + "test": "c8 --exclude 'test/fixtures/**' mocha -i -g Integration", + "integration-ci": "c8 --exclude 'test/fixtures/**' mocha -g Integration", "lint": "eslint .", "semantic-release": "semantic-release", "semantic-release-dry": "semantic-release --dry-run --branches $CI_BRANCH", @@ -26,7 +26,10 @@ "require": "test/setup-env.js", "recursive": "true", "reporter": "mocha-multi-reporters", - "reporter-options": "configFile=.mocha-multi.json" + "reporter-options": "configFile=.mocha-multi.json", + "exclude": [ + "test/fixtures/**" + ] }, "devDependencies": { "@adobe/eslint-config-helix": "3.0.14", diff --git a/src/template/polyfills/fetch.js b/src/template/polyfills/fetch.js index cad34f8..9ed30e8 100644 --- a/src/template/polyfills/fetch.js +++ b/src/template/polyfills/fetch.js @@ -11,10 +11,233 @@ */ /* eslint-env serviceworker */ -module.exports = { - // replacing @adobe/fetch with the built-in APIs - fetch, - Request, - Response, - Headers, +// Platform detection and native CacheOverride loading +let nativeCacheOverride = null; +let isFastly = false; +let isCloudflare = false; +let fastlyModulePromise = null; + +// Try to import Fastly's CacheOverride module +// Use a function to prevent webpack from trying to resolve this at build time +async function loadFastlyModule() { + try { + // Dynamic import - webpack will leave this as-is because it's external + const moduleName = 'fastly:cache-override'; + // eslint-disable-next-line import/no-unresolved + const module = await import(/* webpackIgnore: true */ moduleName); + nativeCacheOverride = module.CacheOverride; + isFastly = true; + return module; + } catch { + // Not Fastly environment - this is expected on other platforms + return null; + } +} + +// Start loading the module if available +try { + fastlyModulePromise = loadFastlyModule(); +} catch { + fastlyModulePromise = null; +} + +// Detect Cloudflare environment +try { + if (typeof caches !== 'undefined' && caches.default) { + isCloudflare = true; + } +} catch { + // Not Cloudflare +} + +/** + * Unified CacheOverride class that works across Fastly and Cloudflare platforms + */ +class UnifiedCacheOverride { + /** + * Creates a new CacheOverride instance + * @param {string|object} modeOrInit - Either a mode string or init object + * @param {object} [init] - Optional init object when mode is first param + * @param {number} [init.ttl] - Time-to-live in seconds + * @param {string} [init.cacheKey] - Custom cache key + * @param {string} [init.surrogateKey] - Surrogate keys for cache purging + */ + constructor(modeOrInit, init) { + let mode; + let options; + + // Parse constructor arguments (supports both signatures) + if (typeof modeOrInit === 'string') { + mode = modeOrInit; + options = init || {}; + } else { + mode = 'override'; + options = modeOrInit || {}; + } + + // Validate that only supported cross-platform options are used + const supportedOptions = ['ttl', 'cacheKey', 'surrogateKey']; + const unsupported = Object.keys(options) + .filter((key) => !supportedOptions.includes(key)); + if (unsupported.length > 0) { + // eslint-disable-next-line no-console + console.warn( + `CacheOverride: Unsupported options ignored: ${unsupported.join(', ')}`, + ); + } + + this.mode = mode; + this.options = { + ...(typeof options.ttl === 'number' && { ttl: options.ttl }), + ...(options.cacheKey && { cacheKey: options.cacheKey }), + ...(options.surrogateKey && { surrogateKey: options.surrogateKey }), + }; + + this.modeOrInit = modeOrInit; + this.native = null; + this.nativeInitialized = false; + } + + /** + * Lazy initialization of native Fastly CacheOverride + * @private + */ + async initNative() { + if (this.nativeInitialized) { + return; + } + + this.nativeInitialized = true; + + // Wait for Fastly module to load if needed + if (fastlyModulePromise) { + await fastlyModulePromise; + } + + // Create native instance if on Fastly + if (isFastly && nativeCacheOverride) { + // eslint-disable-next-line new-cap + const NativeCacheOverride = nativeCacheOverride; + if (typeof this.modeOrInit === 'string') { + this.native = new NativeCacheOverride(this.modeOrInit, this.options); + } else { + this.native = new NativeCacheOverride(this.options); + } + } + } + + /** + * Converts this CacheOverride to Cloudflare cf options + * @returns {object|undefined} Cloudflare cf object or undefined + */ + toCloudflareOptions() { + const cf = {}; + + if (this.mode === 'pass') { + // Pass mode = don't cache + cf.cacheTtl = 0; + return cf; + } + + if (this.mode === 'none') { + // None mode = respect origin headers (no cf options needed) + return undefined; + } + + // Override mode - map cross-platform options + if (typeof this.options.ttl === 'number') { + cf.cacheTtl = this.options.ttl; + } + + if (this.options.cacheKey) { + cf.cacheKey = this.options.cacheKey; + } + + if (this.options.surrogateKey) { + // Map surrogateKey to cacheTags (Cloudflare uses array format) + cf.cacheTags = this.options.surrogateKey.split(/\s+/); + } + + return Object.keys(cf).length > 0 ? cf : undefined; + } + + /** + * Gets the native Fastly CacheOverride instance if available + * @returns {Promise} Native CacheOverride or null + */ + async getNative() { + await this.initNative(); + return this.native || null; + } +} + +// Store original fetch and other APIs +const originalFetch = fetch; +const { + Request: OriginalRequest, + Response: OriginalResponse, + Headers: OriginalHeaders, +} = globalThis; + +/** + * Wrapped fetch that supports the cacheOverride option + * @param {string|Request} resource - URL or Request object + * @param {object} [options] - Fetch options with cacheOverride + * @returns {Promise} Fetch response + */ +async function wrappedFetch(resource, options = {}) { + const { cacheOverride, ...restOptions } = options; + + if (!cacheOverride) { + // No cache override, use original fetch + return originalFetch(resource, restOptions); + } + + // Initialize native CacheOverride on Fastly if needed + if (fastlyModulePromise || isFastly) { + await cacheOverride.initNative(); + } + + if (isFastly && cacheOverride.native) { + // On Fastly, use native CacheOverride + return originalFetch(resource, { + ...restOptions, + cacheOverride: cacheOverride.native, + }); + } + + if (isCloudflare) { + // On Cloudflare, convert to cf options + const cfOptions = cacheOverride.toCloudflareOptions(); + if (cfOptions) { + return originalFetch(resource, { + ...restOptions, + cf: { + ...(restOptions.cf || {}), + ...cfOptions, + }, + }); + } + } + + // Fallback: just use original fetch without cache override + return originalFetch(resource, restOptions); +} + +// Export as CommonJS for webpack bundling compatibility +export default { + fetch: wrappedFetch, + Request: OriginalRequest, + Response: OriginalResponse, + Headers: OriginalHeaders, + CacheOverride: UnifiedCacheOverride, +}; + +// Named exports for modern import syntax +export { + wrappedFetch as fetch, + OriginalRequest as Request, + OriginalResponse as Response, + OriginalHeaders as Headers, + UnifiedCacheOverride as CacheOverride, }; diff --git a/test/cache-demo.integration.js b/test/cache-demo.integration.js new file mode 100644 index 0000000..10afcb6 --- /dev/null +++ b/test/cache-demo.integration.js @@ -0,0 +1,140 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +/* eslint-disable no-underscore-dangle */ +import assert from 'assert'; +import { config } from 'dotenv'; +import { CLI } from '@adobe/helix-deploy'; +import fse from 'fs-extra'; +import path, { resolve } from 'path'; +import { createTestRoot, TestLogger } from './utils.js'; + +config(); + +describe('CacheOverride Demo Integration Test', () => { + let testRoot; + let origPwd; + + beforeEach(async () => { + testRoot = await createTestRoot(); + origPwd = process.cwd(); + }); + + afterEach(async () => { + process.chdir(origPwd); + await fse.remove(testRoot); + }); + + it('Deploy cache-demo to Fastly Compute@Edge', async () => { + const serviceID = '1yv1Wl7NQCFmNBkW4L8htc'; + + await fse.copy(path.resolve(__rootdir, 'test', 'fixtures', 'cache-demo'), testRoot); + process.chdir(testRoot); + + const builder = await new CLI() + .prepare([ + '--build', + '--plugin', resolve(__rootdir, 'src', 'index.js'), + '--verbose', + '--deploy', + '--target', 'c@e', + '--arch', 'edge', + '--compute-service-id', serviceID, + '--compute-test-domain', 'possibly-working-sawfish', + '--package.name', 'CacheDemo', + '--fastly-gateway', 'deploy-test.anywhere.run', + '--fastly-service-id', '4u8SAdblhzzbXntBYCjhcK', + '--test', '/', + '--directory', testRoot, + '--entryFile', 'src/index.js', + '--bundler', 'webpack', + '--esm', 'false', + ]); + builder.cfg._logger = new TestLogger(); + + const res = await builder.run(); + assert.ok(res); + const out = builder.cfg._logger.output; + + // Verify deployment + assert.ok(out.indexOf('possibly-working-sawfish.edgecompute.app') > 0, out); + assert.ok(out.indexOf('dist/CacheDemo/fastly-bundle.tar.gz') > 0, out); + + // Verify the response contains expected structure + assert.ok(out.indexOf('CacheOverride API Demo') > 0, 'Should return API info'); + assert.ok(out.indexOf('/cache-demo/long') > 0, 'Should list long cache route'); + assert.ok(out.indexOf('/cache-demo/short') > 0, 'Should list short cache route'); + }).timeout(10000000); + + it('Deploy cache-demo to Cloudflare', async () => { + await fse.copy(path.resolve(__rootdir, 'test', 'fixtures', 'cache-demo'), testRoot); + process.chdir(testRoot); + + const builder = await new CLI() + .prepare([ + '--build', + '--verbose', + '--deploy', + '--target', 'cloudflare', + '--plugin', path.resolve(__rootdir, 'src', 'index.js'), + '--arch', 'edge', + '--cloudflare-email', 'lars@trieloff.net', + '--cloudflare-account-id', 'b4adf6cfdac0918eb6aa5ad033da0747', + '--cloudflare-test-domain', 'rockerduck', + '--test', '/', + '--directory', testRoot, + '--entryFile', 'src/index.js', + '--bundler', 'webpack', + '--esm', 'false', + ]); + builder.cfg._logger = new TestLogger(); + + const res = await builder.run(); + assert.ok(res); + const out = builder.cfg._logger.output; + + // Verify deployment + assert.ok(out.indexOf('helix-services--cache-demo.rockerduck.workers.dev') > 0, out); + + // Verify the response contains expected structure + assert.ok(out.indexOf('CacheOverride API Demo') > 0, 'Should return API info'); + assert.ok(out.indexOf('/cache-demo/long') > 0, 'Should list long cache route'); + }).timeout(10000000); + + it('Build cache-demo and verify CacheOverride is bundled', async () => { + await fse.copy(path.resolve(__rootdir, 'test', 'fixtures', 'cache-demo'), testRoot); + process.chdir(testRoot); + + const builder = await new CLI() + .prepare([ + '--build', + '--plugin', resolve(__rootdir, 'src', 'index.js'), + '--target', 'wsk', + '--arch', 'edge', + '--directory', testRoot, + '--entryFile', 'src/index.js', + '--bundler', 'webpack', + '--esm', 'false', + ]); + builder.cfg._logger = new TestLogger(); + + await builder.run(); + + // Check that bundle was created + const bundlePath = path.resolve(testRoot, 'dist', 'helix-services', 'cache-demo.zip'); + assert.ok(await fse.pathExists(bundlePath), 'Bundle should be created'); + + const out = builder.cfg._logger.output; + assert.ok(out.indexOf('cache-demo.zip') > 0, 'Output should mention the bundle'); + }).timeout(10000000); +}); diff --git a/test/cache-override.test.js b/test/cache-override.test.js new file mode 100644 index 0000000..71d5819 --- /dev/null +++ b/test/cache-override.test.js @@ -0,0 +1,199 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import assert from 'assert'; + +describe('CacheOverride Polyfill Tests', () => { + let CacheOverride; + + before(async () => { + // Import the module once + const modulePath = '../src/template/polyfills/fetch.js'; + const module = await import(modulePath); + CacheOverride = module.CacheOverride; + }); + + afterEach(() => { + // Clean up global state after each test + delete global.CacheOverride; + delete global.caches; + }); + + describe('CacheOverride Constructor', () => { + it('accepts mode string and init object', () => { + const override = new CacheOverride('override', { ttl: 3600 }); + assert.strictEqual(override.mode, 'override'); + assert.strictEqual(override.options.ttl, 3600); + }); + + it('accepts init object only (defaults to override mode)', () => { + const override = new CacheOverride({ ttl: 7200 }); + assert.strictEqual(override.mode, 'override'); + assert.strictEqual(override.options.ttl, 7200); + }); + + it('accepts mode string only', () => { + const override = new CacheOverride('pass'); + assert.strictEqual(override.mode, 'pass'); + assert.deepStrictEqual(override.options, {}); + }); + + it('supports "none" mode', () => { + const override = new CacheOverride('none'); + assert.strictEqual(override.mode, 'none'); + }); + + it('supports "pass" mode', () => { + const override = new CacheOverride('pass'); + assert.strictEqual(override.mode, 'pass'); + }); + + it('supports "override" mode with options', () => { + const override = new CacheOverride('override', { + ttl: 3600, + cacheKey: 'custom-key', + surrogateKey: 'key1 key2', + }); + assert.strictEqual(override.mode, 'override'); + assert.strictEqual(override.options.ttl, 3600); + assert.strictEqual(override.options.cacheKey, 'custom-key'); + assert.strictEqual(override.options.surrogateKey, 'key1 key2'); + }); + }); + + describe('Cloudflare Platform - toCloudflareOptions', () => { + beforeEach(() => { + // Simulate Cloudflare environment + global.caches = { default: new Map() }; + }); + + afterEach(() => { + delete global.caches; + }); + + it('converts "pass" mode to cacheTtl: 0', () => { + const override = new CacheOverride('pass'); + const cfOptions = override.toCloudflareOptions(); + assert.deepStrictEqual(cfOptions, { cacheTtl: 0 }); + }); + + it('returns undefined for "none" mode', () => { + const override = new CacheOverride('none'); + const cfOptions = override.toCloudflareOptions(); + assert.strictEqual(cfOptions, undefined); + }); + + it('converts ttl to cacheTtl', () => { + const override = new CacheOverride({ ttl: 3600 }); + const cfOptions = override.toCloudflareOptions(); + assert.strictEqual(cfOptions.cacheTtl, 3600); + }); + + it('converts cacheKey', () => { + const override = new CacheOverride({ cacheKey: 'my-custom-key' }); + const cfOptions = override.toCloudflareOptions(); + assert.strictEqual(cfOptions.cacheKey, 'my-custom-key'); + }); + + it('converts surrogateKey to cacheTags array', () => { + const override = new CacheOverride({ surrogateKey: 'tag1 tag2 tag3' }); + const cfOptions = override.toCloudflareOptions(); + assert.deepStrictEqual(cfOptions.cacheTags, ['tag1', 'tag2', 'tag3']); + }); + + it('handles multiple options together', () => { + const override = new CacheOverride({ + ttl: 7200, + cacheKey: 'combined-key', + surrogateKey: 'a b c', + }); + const cfOptions = override.toCloudflareOptions(); + assert.strictEqual(cfOptions.cacheTtl, 7200); + assert.strictEqual(cfOptions.cacheKey, 'combined-key'); + assert.deepStrictEqual(cfOptions.cacheTags, ['a', 'b', 'c']); + }); + + it('warns and ignores unsupported options for cross-platform compatibility', () => { + // Capture console.warn calls + const warnings = []; + const originalWarn = console.warn; + // eslint-disable-next-line no-console + console.warn = (msg) => warnings.push(msg); + + const override = new CacheOverride({ ttl: 3600, swr: 86400, pci: true }); + + // Restore console.warn + // eslint-disable-next-line no-console + console.warn = originalWarn; + + // Should have warned about unsupported options + assert.strictEqual(warnings.length, 1); + assert.ok(warnings[0].includes('swr')); + assert.ok(warnings[0].includes('pci')); + + // Only supported options should be stored + assert.strictEqual(override.options.ttl, 3600); + assert.strictEqual(override.options.swr, undefined); + assert.strictEqual(override.options.pci, undefined); + }); + + it('returns undefined when no options are set', () => { + const override = new CacheOverride({}); + const cfOptions = override.toCloudflareOptions(); + assert.strictEqual(cfOptions, undefined); + }); + }); + + describe('Fastly Platform - Native CacheOverride', () => { + it('stores mode and supported cross-platform options', () => { + const override = new CacheOverride('override', { ttl: 3600 }); + assert.strictEqual(override.mode, 'override'); + assert.strictEqual(override.options.ttl, 3600); + }); + + it('returns null for getNative when not in Fastly environment', async () => { + const override = new CacheOverride({ ttl: 7200 }); + const native = await override.getNative(); + assert.strictEqual(native, null); + }); + }); + + describe('Fetch Wrapper - Basic Functionality', () => { + it('CacheOverride provides toCloudflareOptions method', () => { + const override = new CacheOverride({ ttl: 3600 }); + const cfOptions = override.toCloudflareOptions(); + assert.ok(cfOptions); + assert.strictEqual(cfOptions.cacheTtl, 3600); + }); + + it('CacheOverride provides getNative method', async () => { + const override = new CacheOverride({ ttl: 3600 }); + const native = await override.getNative(); + // In non-Fastly environment, should return null + assert.strictEqual(native, null); + }); + + it('toCloudflareOptions handles all supported cross-platform options', () => { + const override = new CacheOverride({ + ttl: 7200, + cacheKey: 'test-key', + surrogateKey: 'tag1 tag2', + }); + const cfOptions = override.toCloudflareOptions(); + assert.strictEqual(cfOptions.cacheTtl, 7200); + assert.strictEqual(cfOptions.cacheKey, 'test-key'); + assert.deepStrictEqual(cfOptions.cacheTags, ['tag1', 'tag2']); + }); + }); +}); diff --git a/test/fixtures/cache-demo/README.md b/test/fixtures/cache-demo/README.md new file mode 100644 index 0000000..f455d46 --- /dev/null +++ b/test/fixtures/cache-demo/README.md @@ -0,0 +1,81 @@ +# CacheOverride API Demo + +This fixture demonstrates the cross-platform `CacheOverride` API for cache control on both Fastly Compute and Cloudflare Workers. + +## Routes + +### Info Page +- **URL**: `/cache-demo/` +- **Description**: Returns API information and available routes + +### Long Cache +- **URL**: `/cache-demo/long` +- **Cache**: 1 hour TTL +- **Surrogate Key**: `cache-demo long-cache` +- **Use Case**: Static content that rarely changes + +### Short Cache +- **URL**: `/cache-demo/short` +- **Cache**: 10 seconds TTL +- **Surrogate Key**: `cache-demo short-cache` +- **Use Case**: Frequently updated content + +### No Cache +- **URL**: `/cache-demo/no-cache` +- **Cache**: Disabled (pass mode) +- **Use Case**: Always-fresh, dynamic content + +### Custom Cache Key +- **URL**: `/cache-demo/custom` +- **Cache**: 5 minutes TTL with custom cache key based on User-Agent +- **Surrogate Key**: `cache-demo custom-key` +- **Use Case**: Per-client caching strategies + +## Testing + +Each route fetches a UUID from `httpbin.org/uuid` and returns: +- The UUID (should be the same for cached responses) +- Timestamp +- Runtime information +- Cache configuration +- Response headers + +To test caching: +1. Call `/cache-demo/short` multiple times quickly - should return the same UUID +2. Wait 10 seconds and call again - should return a new UUID +3. Call `/cache-demo/no-cache` - should always return a new UUID + +## Backend + +Uses `httpbin.org` as a test backend: +- **Endpoint**: `/uuid` - Returns a unique identifier +- **Purpose**: Easy way to verify cache hits (same UUID) vs cache misses (new UUID) + +## Example Response + +```json +{ + "description": "Short cache (10 seconds)", + "timestamp": "2025-11-19T22:50:00.000Z", + "runtime": "cloudflare-workers", + "backend": { + "url": "https://httpbin.org/uuid", + "status": 200, + "data": { + "uuid": "12345678-1234-1234-1234-123456789abc" + } + }, + "cache": { + "mode": "override", + "options": { + "ttl": 10, + "surrogateKey": "cache-demo short-cache" + } + }, + "headers": { + "cache-control": "public, max-age=10", + "age": "5", + "x-cache": "HIT" + } +} +``` diff --git a/test/fixtures/cache-demo/package.json b/test/fixtures/cache-demo/package.json new file mode 100644 index 0000000..73f85df --- /dev/null +++ b/test/fixtures/cache-demo/package.json @@ -0,0 +1,19 @@ +{ + "name": "cache-demo", + "version": "1.0.0", + "description": "CacheOverride API Demo", + "private": true, + "license": "Apache-2.0", + "main": "src/index.js", + "type": "module", + "wsk": { + "name": "cache-demo", + "webExport": true, + "package": { + "name": "helix-services" + } + }, + "devDependencies": { + "@adobe/fetch": "^4.1.8" + } +} diff --git a/test/fixtures/cache-demo/src/index.js b/test/fixtures/cache-demo/src/index.js new file mode 100644 index 0000000..7efaf06 --- /dev/null +++ b/test/fixtures/cache-demo/src/index.js @@ -0,0 +1,128 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { Response, fetch, CacheOverride } from '@adobe/fetch'; + +/** + * Cache Demo Action + * Demonstrates cross-platform CacheOverride API usage with httpbin backend + * + * Routes: + * - /cache-demo/long - Long cache (1 hour) + * - /cache-demo/short - Short cache (10 seconds) + * - /cache-demo/no-cache - No caching (pass mode) + * - /cache-demo/custom - Custom cache key example + * - /cache-demo/ - Info page + */ +export async function main(req, context) { + const url = new URL(req.url); + const path = url.pathname; + + // Backend base URL + const backendBase = 'https://httpbin.org'; + + let cacheOverride; + let description; + const backendPath = '/uuid'; // Default: returns a unique ID + + if (path.includes('/long')) { + // Long cache: 1 hour TTL + cacheOverride = new CacheOverride('override', { + ttl: 3600, + surrogateKey: 'cache-demo long-cache', + }); + description = 'Long cache (1 hour)'; + } else if (path.includes('/short')) { + // Short cache: 10 seconds TTL + cacheOverride = new CacheOverride('override', { + ttl: 10, + surrogateKey: 'cache-demo short-cache', + }); + description = 'Short cache (10 seconds)'; + } else if (path.includes('/no-cache')) { + // No caching + cacheOverride = new CacheOverride('pass'); + description = 'No caching (always fresh)'; + } else if (path.includes('/custom')) { + // Custom cache key example + const userAgent = req.headers.get('user-agent') || 'unknown'; + const cacheKey = `cache-demo-${userAgent.substring(0, 20)}`; + cacheOverride = new CacheOverride({ + ttl: 300, + cacheKey, + surrogateKey: 'cache-demo custom-key', + }); + description = `Custom cache key: ${cacheKey}`; + } else { + // Info page + return new Response( + JSON.stringify({ + name: 'CacheOverride API Demo', + version: '1.0.0', + runtime: context?.runtime?.name || 'unknown', + routes: { + '/cache-demo/long': 'Long cache (1 hour TTL)', + '/cache-demo/short': 'Short cache (10 seconds TTL)', + '/cache-demo/no-cache': 'No caching (pass mode)', + '/cache-demo/custom': 'Custom cache key (5 minutes TTL)', + }, + documentation: 'https://github.com/adobe/helix-deploy-plugin-edge', + }, null, 2), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'X-Cache-Demo': 'info', + }, + }, + ); + } + + // Fetch from backend with cache override + const backendUrl = `${backendBase}${backendPath}`; + const backendResponse = await fetch(backendUrl, { + backend: 'httpbin.org', + cacheOverride, + }); + + const backendData = await backendResponse.json(); + const timestamp = new Date().toISOString(); + + // Build response + const responseData = { + description, + timestamp, + runtime: context?.runtime?.name || 'unknown', + backend: { + url: backendUrl, + status: backendResponse.status, + data: backendData, + }, + cache: { + mode: cacheOverride.mode, + options: cacheOverride.options, + }, + headers: { + 'cache-control': backendResponse.headers.get('cache-control'), + age: backendResponse.headers.get('age'), + 'x-cache': backendResponse.headers.get('x-cache'), + }, + }; + + return new Response(JSON.stringify(responseData, null, 2), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'X-Cache-Demo': description, + 'X-Timestamp': timestamp, + }, + }); +}