diff --git a/src/template/polyfills/fetch.js b/src/template/polyfills/fetch.js index cad34f8..5ea6a2d 100644 --- a/src/template/polyfills/fetch.js +++ b/src/template/polyfills/fetch.js @@ -11,10 +11,56 @@ */ /* eslint-env serviceworker */ -module.exports = { - // replacing @adobe/fetch with the built-in APIs - fetch, - Request, - Response, - Headers, +/** + * Detects if the code is running in a Cloudflare Workers environment. + * @returns {boolean} true if running on Cloudflare + */ +function isCloudflareEnvironment() { + try { + // caches is a Cloudflare-specific global (CacheStorage API) + return typeof caches !== 'undefined' && caches.default !== undefined; + } catch { + return false; + } +} + +/** + * Wrapper for fetch that provides cross-platform decompression support. + * Maps the @adobe/fetch `decompress` option to platform-specific behavior: + * - Fastly: Sets fastly.decompressGzip based on decompress value + * - Cloudflare: No-op (automatically decompresses) + * - Node.js: Pass through to @adobe/fetch (handles it natively) + * + * @param {RequestInfo} resource - URL or Request object + * @param {RequestInit & {decompress?: boolean, fastly?: object}} options - Fetch options + * @returns {Promise} The fetch response + */ +function wrappedFetch(resource, options = {}) { + // Extract decompress option (default: true to match @adobe/fetch behavior) + const { decompress = true, fastly, ...otherOptions } = options; + + // On Cloudflare: pass through as-is (auto-decompresses) + if (isCloudflareEnvironment()) { + return fetch(resource, options); + } + + // On Fastly/Node.js: map decompress to fastly.decompressGzip + // This will be used on Fastly and ignored on Node.js + const fastlyOptions = { + decompressGzip: decompress, + ...fastly, // explicit fastly options override + }; + return fetch(resource, { ...otherOptions, fastly: fastlyOptions }); +} + +// Export wrapped fetch and native Web APIs +export { wrappedFetch as fetch }; +export const { Request, Response, Headers } = globalThis; + +// Export for CommonJS (for compatibility with require() in bundled code) +export default { + fetch: wrappedFetch, + Request: globalThis.Request, + Response: globalThis.Response, + Headers: globalThis.Headers, }; diff --git a/test/build.test.js b/test/build.test.js index ee4f793..36f9c98 100644 --- a/test/build.test.js +++ b/test/build.test.js @@ -49,6 +49,7 @@ async function assertZipEntries(zipPath, entries) { } const PROJECT_PURE = path.resolve(__rootdir, 'test', 'fixtures', 'pure-action'); +const PROJECT_DECOMPRESS = path.resolve(__rootdir, 'test', 'fixtures', 'decompress-test'); describe('Edge Build Test', () => { let testRoot; @@ -136,4 +137,38 @@ describe('Edge Build Test', () => { */ }) .timeout(50000); + + it('generates the bundle for decompress-test fixture', async () => { + await fse.remove(testRoot); + testRoot = await createTestRoot(); + await fse.copy(PROJECT_DECOMPRESS, testRoot); + + // need to change .cwd() for yargs to pickup `wsk` in package.json + process.chdir(testRoot); + process.env.WSK_AUTH = 'foobar'; + process.env.WSK_NAMESPACE = 'foobar'; + process.env.WSK_APIHOST = 'https://example.com'; + process.env.__OW_ACTION_NAME = '/namespace/package/name@version'; + const builder = await new CLI() + .prepare([ + '--target', 'wsk', + '--plugin', path.resolve(__rootdir, 'src', 'index.js'), + '--bundler', 'webpack', + '--esm', 'false', + '--arch', 'edge', + '--verbose', + '--directory', testRoot, + '--entryFile', 'src/index.js', + ]); + + await builder.run(); + + // The zip is created in dist/{package-name}/ not dist/default/ + await assertZipEntries(path.resolve(testRoot, 'dist', 'decompress-package', 'decompress-test.zip'), [ + 'index.js', + 'package.json', + 'wrangler.toml', + ]); + }) + .timeout(50000); }); diff --git a/test/cloudflare.integration.js b/test/cloudflare.integration.js index 6eabcc3..799e7c7 100644 --- a/test/cloudflare.integration.js +++ b/test/cloudflare.integration.js @@ -66,4 +66,34 @@ describe('Cloudflare Integration Test', () => { const out = builder.cfg._logger.output; assert.ok(out.indexOf('https://simple-package--simple-project.rockerduck.workers.dev') > 0, out); }).timeout(10000000); + + it.skip('Deploy decompress-test fixture to Cloudflare', async () => { + await fse.copy(path.resolve(__rootdir, 'test', 'fixtures', 'decompress-test'), 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', + '--update-package', 'true', + '--test', '/gzip', + '--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; + assert.ok(out.indexOf('decompress-package--decompress-test.rockerduck.workers.dev') > 0, out); + assert.ok(out.indexOf('"test":"decompress-true"') > 0 || out.indexOf('"isDecompressed":true') > 0, `The function output should indicate decompression worked: ${out}`); + }).timeout(10000000); }); diff --git a/test/computeatedge.integration.js b/test/computeatedge.integration.js index 2a8cd36..9c2743a 100644 --- a/test/computeatedge.integration.js +++ b/test/computeatedge.integration.js @@ -72,4 +72,38 @@ describe('Fastly Compute@Edge Integration Test', () => { assert.ok(out.indexOf(`(${serviceID}) ok:`) > 0, `The function output should include the service ID: ${out}`); assert.ok(out.indexOf('dist/Test/fastly-bundle.tar.gz') > 0, out); }).timeout(10000000); + + it('Deploy decompress-test fixture to Compute@Edge', async () => { + const serviceID = '1yv1Wl7NQCFmNBkW4L8htc'; + + await fse.copy(path.resolve(__rootdir, 'test', 'fixtures', 'decompress-test'), 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', 'DecompressTest', + '--fastly-gateway', 'deploy-test.anywhere.run', + '--fastly-service-id', '4u8SAdblhzzbXntBYCjhcK', + '--test', '/gzip', + '--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; + assert.ok(out.indexOf('possibly-working-sawfish.edgecompute.app') > 0, out); + assert.ok(out.indexOf('"test":"decompress-true"') > 0 || out.indexOf('"isDecompressed":true') > 0, `The function output should indicate decompression worked: ${out}`); + assert.ok(out.indexOf('dist/DecompressTest/fastly-bundle.tar.gz') > 0, out); + }).timeout(10000000); }); diff --git a/test/fetch-polyfill.test.js b/test/fetch-polyfill.test.js new file mode 100644 index 0000000..2f577dc --- /dev/null +++ b/test/fetch-polyfill.test.js @@ -0,0 +1,176 @@ +/* + * 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('Fetch Polyfill Test', () => { + let fetchPolyfill; + let originalFetch; + let originalCaches; + let fetchCalls; + + before(async () => { + // Import the module once + fetchPolyfill = await import('../src/template/polyfills/fetch.js'); + }); + + beforeEach(() => { + // Save original fetch and caches + originalFetch = global.fetch; + originalCaches = global.caches; + + // Mock fetch to capture calls + fetchCalls = []; + global.fetch = (resource, options) => { + fetchCalls.push({ resource, options }); + return Promise.resolve(new Response('mocked')); + }; + }); + + afterEach(() => { + // Restore original fetch and caches + global.fetch = originalFetch; + global.caches = originalCaches; + }); + + describe('Cloudflare environment', () => { + beforeEach(() => { + // Mock Cloudflare's caches global + global.caches = { default: {} }; + }); + + it('passes through options as-is with decompress: true', async () => { + await fetchPolyfill.fetch('https://example.com', { decompress: true }); + + assert.strictEqual(fetchCalls.length, 1); + assert.deepStrictEqual(fetchCalls[0].options, { + decompress: true, + }); + }); + + it('passes through options as-is with decompress: false', async () => { + await fetchPolyfill.fetch('https://example.com', { decompress: false }); + + assert.strictEqual(fetchCalls.length, 1); + assert.deepStrictEqual(fetchCalls[0].options, { + decompress: false, + }); + }); + + it('passes through fastly options without modification', async () => { + await fetchPolyfill.fetch('https://example.com', { + fastly: { backend: 'custom' }, + }); + + assert.strictEqual(fetchCalls.length, 1); + assert.deepStrictEqual(fetchCalls[0].options, { + fastly: { backend: 'custom' }, + }); + }); + + it('preserves all options unchanged', async () => { + await fetchPolyfill.fetch('https://example.com', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + decompress: true, + }); + + assert.strictEqual(fetchCalls.length, 1); + assert.deepStrictEqual(fetchCalls[0].options, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + decompress: true, + }); + }); + }); + + describe('Non-Cloudflare environment (Fastly/Node.js)', () => { + beforeEach(() => { + // Ensure no Cloudflare caches global + delete global.caches; + }); + + it('maps decompress: true to fastly.decompressGzip: true by default', async () => { + await fetchPolyfill.fetch('https://example.com'); + + assert.strictEqual(fetchCalls.length, 1); + assert.strictEqual(fetchCalls[0].resource, 'https://example.com'); + assert.deepStrictEqual(fetchCalls[0].options, { + fastly: { decompressGzip: true }, + }); + }); + + it('maps decompress: true to fastly.decompressGzip: true explicitly', async () => { + await fetchPolyfill.fetch('https://example.com', { decompress: true }); + + assert.strictEqual(fetchCalls.length, 1); + assert.deepStrictEqual(fetchCalls[0].options, { + fastly: { decompressGzip: true }, + }); + }); + + it('maps decompress: false to fastly.decompressGzip: false', async () => { + await fetchPolyfill.fetch('https://example.com', { decompress: false }); + + assert.strictEqual(fetchCalls.length, 1); + assert.deepStrictEqual(fetchCalls[0].options, { + fastly: { decompressGzip: false }, + }); + }); + + it('explicit fastly options override decompress mapping', async () => { + await fetchPolyfill.fetch('https://example.com', { + decompress: true, + fastly: { decompressGzip: false }, + }); + + assert.strictEqual(fetchCalls.length, 1); + assert.deepStrictEqual(fetchCalls[0].options, { + fastly: { decompressGzip: false }, + }); + }); + + it('preserves other fetch options', async () => { + await fetchPolyfill.fetch('https://example.com', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + decompress: true, + }); + + assert.strictEqual(fetchCalls.length, 1); + assert.strictEqual(fetchCalls[0].options.method, 'POST'); + assert.deepStrictEqual(fetchCalls[0].options.headers, { + 'Content-Type': 'application/json', + }); + assert.deepStrictEqual(fetchCalls[0].options.fastly, { + decompressGzip: true, + }); + }); + + it('merges fastly options with decompress mapping', async () => { + await fetchPolyfill.fetch('https://example.com', { + decompress: true, + fastly: { backend: 'custom-backend' }, + }); + + assert.strictEqual(fetchCalls.length, 1); + assert.deepStrictEqual(fetchCalls[0].options, { + fastly: { + decompressGzip: true, + backend: 'custom-backend', + }, + }); + }); + }); +}); diff --git a/test/fixtures/decompress-test/package.json b/test/fixtures/decompress-test/package.json new file mode 100644 index 0000000..59030ae --- /dev/null +++ b/test/fixtures/decompress-test/package.json @@ -0,0 +1,19 @@ +{ + "name": "decompress-test", + "version": "1.0.0", + "description": "Test Project for Decompress Functionality", + "private": true, + "license": "Apache-2.0", + "main": "src/index.js", + "type": "module", + "wsk": { + "name": "decompress-test", + "webExport": true, + "package": { + "name": "decompress-package" + } + }, + "devDependencies": { + "@adobe/fetch": "^4.1.8" + } +} diff --git a/test/fixtures/decompress-test/src/index.js b/test/fixtures/decompress-test/src/index.js new file mode 100644 index 0000000..c940cd3 --- /dev/null +++ b/test/fixtures/decompress-test/src/index.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. + */ +import { Response, fetch } from '@adobe/fetch'; + +/** + * Test action that demonstrates the decompress functionality with caching. + * + * Endpoints: + * - /gzip - Fetches gzipped content from httpbin with decompress: true (default) + * - /gzip-compressed - Fetches gzipped content with decompress: false + * - /json - Fetches JSON content with caching + * - /headers - Returns request headers + * + * @param {Request} req - The incoming request + * @param {Object} context - The execution context + * @returns {Response} The response + */ +export async function main(req, context) { + const url = new URL(req.url); + const path = url.pathname; + + try { + // Test different decompress scenarios + if (path.includes('/gzip-compressed')) { + // Fetch with decompress: false - should receive compressed data + const response = await fetch('https://httpbin.org/gzip', { + backend: 'httpbin.org', + decompress: false, + cacheKey: 'gzip-compressed', + }); + + const isGzipped = response.headers.get('content-encoding') === 'gzip'; + + return new Response(JSON.stringify({ + test: 'decompress-false', + contentEncoding: response.headers.get('content-encoding'), + isGzipped, + status: response.status, + message: isGzipped ? 'Content is gzipped as expected' : 'Warning: Content not gzipped', + }), { + headers: { 'content-type': 'application/json' }, + }); + } + + if (path.includes('/gzip')) { + // Fetch with decompress: true (default) - should receive decompressed data + const response = await fetch('https://httpbin.org/gzip', { + backend: 'httpbin.org', + decompress: true, + cacheKey: 'gzip-decompressed', + }); + + const data = await response.json(); + const isDecompressed = !response.headers.get('content-encoding'); + + return new Response(JSON.stringify({ + test: 'decompress-true', + contentEncoding: response.headers.get('content-encoding') || 'none', + isDecompressed, + gzipped: data.gzipped || false, + status: response.status, + message: isDecompressed ? 'Content decompressed successfully' : 'Warning: Content still encoded', + }), { + headers: { 'content-type': 'application/json' }, + }); + } + + if (path.includes('/json')) { + // Test JSON endpoint with caching + const response = await fetch('https://httpbin.org/json', { + backend: 'httpbin.org', + cacheKey: 'json-data', + }); + + const data = await response.json(); + + return new Response(JSON.stringify({ + test: 'json-cached', + slideshow: data.slideshow?.title || 'unknown', + status: response.status, + cached: response.headers.get('x-cache') === 'HIT', + }), { + headers: { 'content-type': 'application/json' }, + }); + } + + if (path.includes('/headers')) { + // Return request headers for debugging + const headers = {}; + req.headers.forEach((value, key) => { + headers[key] = value; + }); + + return new Response(JSON.stringify({ + test: 'headers', + headers, + context: { + functionName: context?.func?.name, + runtime: context?.runtime?.name, + }, + }), { + headers: { 'content-type': 'application/json' }, + }); + } + + // Default response with usage instructions + return new Response(JSON.stringify({ + name: 'decompress-test', + version: '1.0.0', + endpoints: [ + { path: '/gzip', description: 'Test decompress: true (default) - receives decompressed content' }, + { path: '/gzip-compressed', description: 'Test decompress: false - receives compressed content' }, + { path: '/json', description: 'Test JSON endpoint with caching' }, + { path: '/headers', description: 'View request headers and context' }, + ], + runtime: context?.runtime?.name, + region: context?.runtime?.region, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } catch (error) { + return new Response(JSON.stringify({ + error: error.message, + stack: error.stack, + }), { + status: 500, + headers: { 'content-type': 'application/json' }, + }); + } +}