diff --git a/src/CloudflareDeployer.js b/src/CloudflareDeployer.js index 94e3aca..151fda1 100644 --- a/src/CloudflareDeployer.js +++ b/src/CloudflareDeployer.js @@ -168,8 +168,13 @@ export default class CloudflareDeployer extends BaseDeployer { title: name, }, }); - let { result } = await postres.json(); + const postData = await postres.json(); + let { result } = postData; + const { errors } = postData; if (!result) { + if (errors) { + this.log.debug(`KV namespace creation returned errors: ${JSON.stringify(errors)}`); + } const listres = await this.fetch(`https://api.cloudflare.com/client/v4/accounts/${this._cfg.accountID}/storage/kv/namespaces`, { method: 'GET', headers: { @@ -177,7 +182,10 @@ export default class CloudflareDeployer extends BaseDeployer { }, }); const { result: results } = await listres.json(); - result = results.find((r) => r.title === name); + result = results?.find((r) => r.title === name); + } + if (!result) { + throw new Error(`Failed to create or find KV namespace: ${name}`); } return result; } diff --git a/src/template/polyfills/fetch.js b/src/template/polyfills/fetch.js index 6ded258..a9b8de8 100644 --- a/src/template/polyfills/fetch.js +++ b/src/template/polyfills/fetch.js @@ -171,8 +171,7 @@ class UnifiedCacheOverride { } } -// Store original fetch and other APIs -const originalFetch = globalThis.fetch; +// Store other APIs (but not fetch - we'll call it dynamically for testability) const { Request: OriginalRequest, Response: OriginalResponse, @@ -180,48 +179,75 @@ const { } = globalThis; /** - * Wrapped fetch that supports the cacheOverride option + * Wrapped fetch that supports both cacheOverride and decompress options * @param {string|Request} resource - URL or Request object - * @param {object} [options] - Fetch options with cacheOverride + * @param {object} [options] - Fetch options with cacheOverride and/or decompress + * @param {object} [options.cacheOverride] - CacheOverride instance for cache control + * @param {boolean} [options.decompress=true] - Whether to decompress gzip responses + * @param {object} [options.fastly] - Fastly-specific options * @returns {Promise} Fetch response */ async function wrappedFetch(resource, options = {}) { + // Check for Cloudflare dynamically (for testability) + // Prioritize Fastly detection - if we successfully loaded fastly:cache-override, we're on Fastly + const isInCloudflare = !isFastly && typeof caches !== 'undefined' && caches.default !== undefined; + + // On Cloudflare, strip out Fastly-specific options that aren't supported const { cacheOverride, ...restOptions } = options; - if (!cacheOverride) { - // No cache override, use original fetch - return originalFetch(resource, restOptions); + // Strip Fastly-specific options when on Cloudflare + let fetchOptions; + if (isInCloudflare) { + // eslint-disable-next-line no-unused-vars + const { backend, cacheKey, ...cloudflareOptions } = restOptions; + fetchOptions = cloudflareOptions; + } else { + fetchOptions = restOptions; } - // Initialize native CacheOverride on Fastly if needed - if (fastlyModulePromise || isFastly) { - await cacheOverride.initNative(); - } + // Handle cacheOverride + if (cacheOverride) { + // 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 (isFastly && cacheOverride.native) { + // On Fastly, use native CacheOverride + fetchOptions = { + ...fetchOptions, + cacheOverride: cacheOverride.native, + }; + } else if (isCloudflare) { + // On Cloudflare, convert to cf options + const cfOptions = cacheOverride.toCloudflareOptions(); + if (cfOptions) { + fetchOptions = { + ...fetchOptions, + cf: { + ...(fetchOptions.cf || {}), + ...cfOptions, + }, + }; + } + } } - if (isCloudflare) { - // On Cloudflare, convert to cf options - const cfOptions = cacheOverride.toCloudflareOptions(); - if (cfOptions) { - return originalFetch(resource, { - ...restOptions, - cf: { - ...(restOptions.cf || {}), - ...cfOptions, - }, - }); - } + // Handle decompress option + // On Cloudflare: pass through as-is (Cloudflare auto-decompresses) + // On Fastly/Node.js: map decompress to fastly.decompressGzip (default: true) + if (!isInCloudflare) { + const { decompress = true, fastly, ...otherOptions } = fetchOptions; + fetchOptions = { + ...otherOptions, + fastly: { + decompressGzip: decompress, + ...fastly, // explicit fastly options override + }, + }; } - // Fallback: just use original fetch without cache override - return originalFetch(resource, restOptions); + return globalThis.fetch(resource, fetchOptions); } // Export as default for clean import syntax 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 912c96a..f39940e 100644 --- a/test/cloudflare.integration.js +++ b/test/cloudflare.integration.js @@ -68,7 +68,38 @@ describe('Cloudflare Integration Test', () => { assert.ok(out.indexOf('https://simple-package--simple-project.minivelos.workers.dev') > 0, out); }).timeout(10000000); - it.skip('Deploy logging example to Cloudflare', async () => { + it('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', '155ec15a52a18a14801e04b019da5e5a', + '--cloudflare-test-domain', 'minivelos', + '--cloudflare-auth', process.env.CLOUDFLARE_AUTH, + '--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.minivelos.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); + + it('Deploy logging example to Cloudflare', async () => { await fse.copy(path.resolve(__rootdir, 'test', 'fixtures', 'logging-example'), testRoot); process.chdir(testRoot); const builder = await new CLI() @@ -80,8 +111,9 @@ describe('Cloudflare Integration Test', () => { '--plugin', path.resolve(__rootdir, 'src', 'index.js'), '--arch', 'edge', '--cloudflare-email', 'lars@trieloff.net', - '--cloudflare-account-id', 'b4adf6cfdac0918eb6aa5ad033da0747', - '--cloudflare-test-domain', 'rockerduck', + '--cloudflare-account-id', '155ec15a52a18a14801e04b019da5e5a', + '--cloudflare-test-domain', 'minivelos', + '--cloudflare-auth', process.env.CLOUDFLARE_AUTH, '--package.name', 'logging-test', '--package.params', 'TEST=logging', '--update-package', 'true', @@ -97,7 +129,7 @@ describe('Cloudflare Integration Test', () => { const res = await builder.run(); assert.ok(res); const out = builder.cfg._logger.output; - assert.ok(out.indexOf('rockerduck.workers.dev') > 0, out); + assert.ok(out.indexOf('minivelos.workers.dev') > 0, out); assert.ok(out.indexOf('"status":"ok"') > 0, 'Response should include status ok'); assert.ok(out.indexOf('"logging":"enabled"') > 0, 'Response should indicate logging is enabled'); }).timeout(10000000); diff --git a/test/computeatedge.integration.js b/test/computeatedge.integration.js index 2a29438..15def4c 100644 --- a/test/computeatedge.integration.js +++ b/test/computeatedge.integration.js @@ -94,6 +94,40 @@ describe('Fastly Compute@Edge Integration Test', () => { assert.ok(keyText.indexOf('cacheKey=test-key') > 0, 'Should include cache key parameter'); }).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); + it('Deploy logging example to Compute@Edge', async () => { const serviceID = '1yv1Wl7NQCFmNBkW4L8htc'; 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' }, + }); + } +}