Skip to content
Open
58 changes: 52 additions & 6 deletions src/template/polyfills/fetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>} 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,
};
35 changes: 35 additions & 0 deletions test/build.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});
30 changes: 30 additions & 0 deletions test/cloudflare.integration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
34 changes: 34 additions & 0 deletions test/computeatedge.integration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
176 changes: 176 additions & 0 deletions test/fetch-polyfill.test.js
Original file line number Diff line number Diff line change
@@ -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',
},
});
});
});
});
19 changes: 19 additions & 0 deletions test/fixtures/decompress-test/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading