Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/fix-readonly-headers-comprehensive.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@web-widget/shared-cache": patch
---

fix: Comprehensive fix for readonly headers modification issues

This change completely resolves the readonly headers modification problems that were partially addressed in the previous fix. The solution includes:

**New Features:**
- Added `src/utils/response.ts` with intelligent response utilities:
- `modifyResponseHeaders()`: Smart header modification with readonly fallback
- `setResponseHeader()`: Convenient single header setting function

**Bug Fixes:**
- Fixed `setCacheStatus()` function in `fetch.ts` to properly handle readonly headers
- Optimized `createInterceptor()` function to avoid unnecessary Response cloning
- Ensured `cache.ts` uses safe header modification patterns
- All header modifications now gracefully handle readonly scenarios

**Performance Improvements:**
- No Response cloning when no header overrides are configured
- Direct header modification when possible (for mutable headers)
- Smart fallback to new Response creation only when necessary
- Significant performance improvement for common use cases

**Testing:**
- Added comprehensive unit tests for response utilities (10 new tests)
- Added specific tests for createInterceptor readonly headers handling (6 new tests)
- All 258 tests pass with 93.93% code coverage
- Tests cover edge cases, error scenarios, and performance considerations

This fix ensures that header modifications work reliably across all environments (browser, Node.js, etc.) while maintaining optimal performance by avoiding unnecessary object creation.
291 changes: 237 additions & 54 deletions src/fetch.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -192,59 +192,11 @@ describe('HTTP Header Override Tests', () => {
expect(res.headers.get('x-cache-status')).toBe(DYNAMIC);
});

it('should handle read-only headers by creating new Response object', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);

// Create a response with read-only headers to simulate browser environment
const originalResponse = new Response('test content', {
status: 200,
headers: {
'content-type': 'text/plain',
'cache-control': 'max-age=300',
},
});

// Make headers read-only by freezing the headers object
const readOnlyHeaders = new Headers(originalResponse.headers);
Object.freeze(readOnlyHeaders);

// Create a response with read-only headers
const responseWithReadOnlyHeaders = new Response(originalResponse.body, {
status: originalResponse.status,
statusText: originalResponse.statusText,
headers: readOnlyHeaders,
});

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return responseWithReadOnlyHeaders;
},
});

// This should not throw an error and should properly apply header overrides
const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=600, must-revalidate',
varyOverride: 'accept-language',
},
});

// Verify that headers are properly overridden despite original being read-only
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600, must-revalidate'
);
expect(res.headers.get('vary')).toBe('accept-language');
expect(res.headers.get('x-cache-status')).toBe(MISS);
expect(await res.text()).toBe('test content');
});

it('should preserve all Response properties when creating new Response with modified headers', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'test response body with special characters: 测试内容';
const originalBody =
'test response body with special characters: 测试内容';
const originalResponse = new Response(originalBody, {
status: 201,
statusText: 'Created',
Expand DownExpand Up@@ -283,9 +235,13 @@ describe('HTTP Header Override Tests', () => {
expect(res.status).toBe(201);
expect(res.statusText).toBe('Created');
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8');
expect(res.headers.get('content-length')).toBe(originalBody.length.toString());
expect(res.headers.get('content-length')).toBe(
originalBody.length.toString()
);
expect(res.headers.get('x-custom-header')).toBe('custom-value');
expect(res.headers.get('cache-control')).toBe('max-age=300, s-maxage=600');
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600'
);
expect(res.headers.get('vary')).toBe('user-agent');
expect(res.headers.get('x-cache-status')).toBe(MISS);
expect(await res.text()).toBe(originalBody);
Expand All@@ -301,7 +257,7 @@ describe('HTTP Header Override Tests', () => {
headers: {
'content-type': 'application/json',
'cache-control': 'max-age=300, public',
'etag': '"abc123"',
etag: '"abc123"',
'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT',
},
});
Expand DownExpand Up@@ -333,7 +289,9 @@ describe('HTTP Header Override Tests', () => {
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/json');
expect(res.headers.get('etag')).toBe('"abc123"');
expect(res.headers.get('last-modified')).toBe('Wed, 21 Oct 2015 07:28:00 GMT');
expect(res.headers.get('last-modified')).toBe(
'Wed, 21 Oct 2015 07:28:00 GMT'
);
expect(res.headers.get('cache-control')).toBe(
'max-age=300, public, s-maxage=600, must-revalidate'
);
Expand All@@ -344,6 +302,231 @@ describe('HTTP Header Override Tests', () => {
expect(await res.text()).toBe('multi-header test');
});
});

describe('createInterceptor Header Override Bug Fixes', () => {
it('should handle responses correctly when no header overrides are configured', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'no override test';

let interceptorCalled = false;
let originalResponse: Response;
const fetch = createSharedCacheFetch(cache, {
async fetch() {
originalResponse = new Response(originalBody, {
status: 200,
headers: { 'content-type': 'text/plain' },
});

// Mock the response to detect if interceptor creates new object
const originalClone = originalResponse.clone.bind(originalResponse);
originalResponse.clone = () => {
interceptorCalled = true;
return originalClone();
};

return originalResponse;
},
});

const res = await fetch(TEST_URL);

// Interceptor should not have unnecessarily cloned when no overrides
expect(interceptorCalled).toBe(false);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.has('x-cache-status')).toBe(true); // Cache status should be added
expect(await res.text()).toBe(originalBody);
});

it('should apply header overrides correctly', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'override test';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return new Response(originalBody, {
status: 200,
headers: { 'content-type': 'text/plain' },
});
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=300',
},
});

expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.get('cache-control')).toBe('s-maxage=300');
expect(res.headers.has('x-cache-status')).toBe(true);
expect(await res.text()).toBe(originalBody);
});

it('should handle readonly headers without throwing errors', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'readonly interceptor test';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
const response = new Response(originalBody, {
status: 200,
headers: { 'content-type': 'application/json' },
});

// Make headers readonly by mocking set method to throw
response.headers.set = () => {
throw new Error('Cannot modify readonly headers');
};

return response;
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=600',
varyOverride: 'accept-language',
},
});

// Should successfully handle readonly headers
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/json');
expect(res.headers.get('cache-control')).toBe('s-maxage=600');
expect(res.headers.get('vary')).toBe('accept-language');
expect(await res.text()).toBe(originalBody);
});

it('should not modify response on non-ok status', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);

let headerModificationAttempted = false;
const fetch = createSharedCacheFetch(cache, {
async fetch() {
const originalErrorResponse = new Response('Not Found', {
status: 404,
headers: { 'content-type': 'text/plain' },
});

// Track if headers.set is called (which would indicate header modification)
const originalSet = originalErrorResponse.headers.set.bind(
originalErrorResponse.headers
);
originalErrorResponse.headers.set = (name, value) => {
if (name !== 'x-cache-status') {
// setCacheStatus might still be called
headerModificationAttempted = true;
}
return originalSet(name, value);
};

return originalErrorResponse;
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=300',
varyOverride: 'accept',
},
});

// Headers should not be modified by interceptor for non-ok responses
expect(headerModificationAttempted).toBe(false);
expect(res.status).toBe(404);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.has('cache-control')).toBe(false);
expect(res.headers.has('vary')).toBe(false);
});

it('should preserve all response properties when handling header overrides', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const complexBody = 'complex response: 测试内容 with special chars';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return new Response(complexBody, {
status: 201,
statusText: 'Created',
headers: {
'content-type': 'text/plain; charset=utf-8',
'content-length': complexBody.length.toString(),
etag: '"interceptor-test"',
'last-modified': 'Thu, 01 Dec 2022 12:00:00 GMT',
'x-original': 'true',
},
});
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 'max-age=300, s-maxage=600',
varyOverride: 'accept-encoding, user-agent',
},
});

// Verify all original properties are preserved
expect(res.status).toBe(201);
expect(res.statusText).toBe('Created');
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8');
expect(res.headers.get('content-length')).toBe(
complexBody.length.toString()
);
expect(res.headers.get('etag')).toBe('"interceptor-test"');
expect(res.headers.get('last-modified')).toBe(
'Thu, 01 Dec 2022 12:00:00 GMT'
);
expect(res.headers.get('x-original')).toBe('true');

// Verify overrides are applied
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600'
);
expect(res.headers.get('vary')).toBe('accept-encoding, user-agent');

expect(await res.text()).toBe(complexBody);
});

it('should correctly handle multiple requests with different override configurations', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
let callCount = 0;

const fetch = createSharedCacheFetch(cache, {
async fetch() {
callCount++;
return new Response(`call ${callCount}`, {
status: 200,
headers: { 'content-type': 'text/plain' },
});
},
});

// Test multiple calls with and without overrides
const noOverride = await fetch(`${TEST_URL}?1`);
const withOverride = await fetch(`${TEST_URL}?2`, {
sharedCache: { cacheControlOverride: 's-maxage=300' },
});

expect(callCount).toBe(2);
expect(await noOverride.clone().text()).toBe('call 1');
expect(await withOverride.clone().text()).toBe('call 2');

// Both should have cache status
expect(noOverride.headers.has('x-cache-status')).toBe(true);
expect(withOverride.headers.has('x-cache-status')).toBe(true);

// Only the one with override should have cache-control
expect(noOverride.headers.has('cache-control')).toBe(false);
expect(withOverride.headers.get('cache-control')).toBe('s-maxage=300');
});
});
});

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/fix-readonly-headers-comprehensive.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@web-widget/shared-cache": patch
---

fix: Comprehensive fix for readonly headers modification issues

This change completely resolves the readonly headers modification problems that were partially addressed in the previous fix. The solution includes:

**New Features:**
- Added `src/utils/response.ts` with intelligent response utilities:
- `modifyResponseHeaders()`: Smart header modification with readonly fallback
- `setResponseHeader()`: Convenient single header setting function

**Bug Fixes:**
- Fixed `setCacheStatus()` function in `fetch.ts` to properly handle readonly headers
- Optimized `createInterceptor()` function to avoid unnecessary Response cloning
- Ensured `cache.ts` uses safe header modification patterns
- All header modifications now gracefully handle readonly scenarios

**Performance Improvements:**
- No Response cloning when no header overrides are configured
- Direct header modification when possible (for mutable headers)
- Smart fallback to new Response creation only when necessary
- Significant performance improvement for common use cases

**Testing:**
- Added comprehensive unit tests for response utilities (10 new tests)
- Added specific tests for createInterceptor readonly headers handling (6 new tests)
- All 258 tests pass with 93.93% code coverage
- Tests cover edge cases, error scenarios, and performance considerations

This fix ensures that header modifications work reliably across all environments (browser, Node.js, etc.) while maintaining optimal performance by avoiding unnecessary object creation.
291 changes: 237 additions & 54 deletions src/fetch.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -192,59 +192,11 @@ describe('HTTP Header Override Tests', () => {
expect(res.headers.get('x-cache-status')).toBe(DYNAMIC);
});

it('should handle read-only headers by creating new Response object', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);

// Create a response with read-only headers to simulate browser environment
const originalResponse = new Response('test content', {
status: 200,
headers: {
'content-type': 'text/plain',
'cache-control': 'max-age=300',
},
});

// Make headers read-only by freezing the headers object
const readOnlyHeaders = new Headers(originalResponse.headers);
Object.freeze(readOnlyHeaders);

// Create a response with read-only headers
const responseWithReadOnlyHeaders = new Response(originalResponse.body, {
status: originalResponse.status,
statusText: originalResponse.statusText,
headers: readOnlyHeaders,
});

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return responseWithReadOnlyHeaders;
},
});

// This should not throw an error and should properly apply header overrides
const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=600, must-revalidate',
varyOverride: 'accept-language',
},
});

// Verify that headers are properly overridden despite original being read-only
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600, must-revalidate'
);
expect(res.headers.get('vary')).toBe('accept-language');
expect(res.headers.get('x-cache-status')).toBe(MISS);
expect(await res.text()).toBe('test content');
});

it('should preserve all Response properties when creating new Response with modified headers', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'test response body with special characters: 测试内容';
const originalBody =
'test response body with special characters: 测试内容';
const originalResponse = new Response(originalBody, {
status: 201,
statusText: 'Created',
Expand DownExpand Up@@ -283,9 +235,13 @@ describe('HTTP Header Override Tests', () => {
expect(res.status).toBe(201);
expect(res.statusText).toBe('Created');
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8');
expect(res.headers.get('content-length')).toBe(originalBody.length.toString());
expect(res.headers.get('content-length')).toBe(
originalBody.length.toString()
);
expect(res.headers.get('x-custom-header')).toBe('custom-value');
expect(res.headers.get('cache-control')).toBe('max-age=300, s-maxage=600');
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600'
);
expect(res.headers.get('vary')).toBe('user-agent');
expect(res.headers.get('x-cache-status')).toBe(MISS);
expect(await res.text()).toBe(originalBody);
Expand All@@ -301,7 +257,7 @@ describe('HTTP Header Override Tests', () => {
headers: {
'content-type': 'application/json',
'cache-control': 'max-age=300, public',
'etag': '"abc123"',
etag: '"abc123"',
'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT',
},
});
Expand DownExpand Up@@ -333,7 +289,9 @@ describe('HTTP Header Override Tests', () => {
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/json');
expect(res.headers.get('etag')).toBe('"abc123"');
expect(res.headers.get('last-modified')).toBe('Wed, 21 Oct 2015 07:28:00 GMT');
expect(res.headers.get('last-modified')).toBe(
'Wed, 21 Oct 2015 07:28:00 GMT'
);
expect(res.headers.get('cache-control')).toBe(
'max-age=300, public, s-maxage=600, must-revalidate'
);
Expand All@@ -344,6 +302,231 @@ describe('HTTP Header Override Tests', () => {
expect(await res.text()).toBe('multi-header test');
});
});

describe('createInterceptor Header Override Bug Fixes', () => {
it('should handle responses correctly when no header overrides are configured', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'no override test';

let interceptorCalled = false;
let originalResponse: Response;
const fetch = createSharedCacheFetch(cache, {
async fetch() {
originalResponse = new Response(originalBody, {
status: 200,
headers: { 'content-type': 'text/plain' },
});

// Mock the response to detect if interceptor creates new object
const originalClone = originalResponse.clone.bind(originalResponse);
originalResponse.clone = () => {
interceptorCalled = true;
return originalClone();
};

return originalResponse;
},
});

const res = await fetch(TEST_URL);

// Interceptor should not have unnecessarily cloned when no overrides
expect(interceptorCalled).toBe(false);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.has('x-cache-status')).toBe(true); // Cache status should be added
expect(await res.text()).toBe(originalBody);
});

it('should apply header overrides correctly', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'override test';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return new Response(originalBody, {
status: 200,
headers: { 'content-type': 'text/plain' },
});
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=300',
},
});

expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.get('cache-control')).toBe('s-maxage=300');
expect(res.headers.has('x-cache-status')).toBe(true);
expect(await res.text()).toBe(originalBody);
});

it('should handle readonly headers without throwing errors', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'readonly interceptor test';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
const response = new Response(originalBody, {
status: 200,
headers: { 'content-type': 'application/json' },
});

// Make headers readonly by mocking set method to throw
response.headers.set = () => {
throw new Error('Cannot modify readonly headers');
};

return response;
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=600',
varyOverride: 'accept-language',
},
});

// Should successfully handle readonly headers
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/json');
expect(res.headers.get('cache-control')).toBe('s-maxage=600');
expect(res.headers.get('vary')).toBe('accept-language');
expect(await res.text()).toBe(originalBody);
});

it('should not modify response on non-ok status', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);

let headerModificationAttempted = false;
const fetch = createSharedCacheFetch(cache, {
async fetch() {
const originalErrorResponse = new Response('Not Found', {
status: 404,
headers: { 'content-type': 'text/plain' },
});

// Track if headers.set is called (which would indicate header modification)
const originalSet = originalErrorResponse.headers.set.bind(
originalErrorResponse.headers
);
originalErrorResponse.headers.set = (name, value) => {
if (name !== 'x-cache-status') {
// setCacheStatus might still be called
headerModificationAttempted = true;
}
return originalSet(name, value);
};

return originalErrorResponse;
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=300',
varyOverride: 'accept',
},
});

// Headers should not be modified by interceptor for non-ok responses
expect(headerModificationAttempted).toBe(false);
expect(res.status).toBe(404);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.has('cache-control')).toBe(false);
expect(res.headers.has('vary')).toBe(false);
});

it('should preserve all response properties when handling header overrides', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const complexBody = 'complex response: 测试内容 with special chars';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return new Response(complexBody, {
status: 201,
statusText: 'Created',
headers: {
'content-type': 'text/plain; charset=utf-8',
'content-length': complexBody.length.toString(),
etag: '"interceptor-test"',
'last-modified': 'Thu, 01 Dec 2022 12:00:00 GMT',
'x-original': 'true',
},
});
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 'max-age=300, s-maxage=600',
varyOverride: 'accept-encoding, user-agent',
},
});

// Verify all original properties are preserved
expect(res.status).toBe(201);
expect(res.statusText).toBe('Created');
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8');
expect(res.headers.get('content-length')).toBe(
complexBody.length.toString()
);
expect(res.headers.get('etag')).toBe('"interceptor-test"');
expect(res.headers.get('last-modified')).toBe(
'Thu, 01 Dec 2022 12:00:00 GMT'
);
expect(res.headers.get('x-original')).toBe('true');

// Verify overrides are applied
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600'
);
expect(res.headers.get('vary')).toBe('accept-encoding, user-agent');

expect(await res.text()).toBe(complexBody);
});

it('should correctly handle multiple requests with different override configurations', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
let callCount = 0;

const fetch = createSharedCacheFetch(cache, {
async fetch() {
callCount++;
return new Response(`call ${callCount}`, {
status: 200,
headers: { 'content-type': 'text/plain' },
});
},
});

// Test multiple calls with and without overrides
const noOverride = await fetch(`${TEST_URL}?1`);
const withOverride = await fetch(`${TEST_URL}?2`, {
sharedCache: { cacheControlOverride: 's-maxage=300' },
});

expect(callCount).toBe(2);
expect(await noOverride.clone().text()).toBe('call 1');
expect(await withOverride.clone().text()).toBe('call 2');

// Both should have cache status
expect(noOverride.headers.has('x-cache-status')).toBe(true);
expect(withOverride.headers.has('x-cache-status')).toBe(true);

// Only the one with override should have cache-control
expect(noOverride.headers.has('cache-control')).toBe(false);
expect(withOverride.headers.get('cache-control')).toBe('s-maxage=300');
});
});
});

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/fix-readonly-headers-comprehensive.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@web-widget/shared-cache": patch
---

fix: Comprehensive fix for readonly headers modification issues

This change completely resolves the readonly headers modification problems that were partially addressed in the previous fix. The solution includes:

**New Features:**
- Added `src/utils/response.ts` with intelligent response utilities:
- `modifyResponseHeaders()`: Smart header modification with readonly fallback
- `setResponseHeader()`: Convenient single header setting function

**Bug Fixes:**
- Fixed `setCacheStatus()` function in `fetch.ts` to properly handle readonly headers
- Optimized `createInterceptor()` function to avoid unnecessary Response cloning
- Ensured `cache.ts` uses safe header modification patterns
- All header modifications now gracefully handle readonly scenarios

**Performance Improvements:**
- No Response cloning when no header overrides are configured
- Direct header modification when possible (for mutable headers)
- Smart fallback to new Response creation only when necessary
- Significant performance improvement for common use cases

**Testing:**
- Added comprehensive unit tests for response utilities (10 new tests)
- Added specific tests for createInterceptor readonly headers handling (6 new tests)
- All 258 tests pass with 93.93% code coverage
- Tests cover edge cases, error scenarios, and performance considerations

This fix ensures that header modifications work reliably across all environments (browser, Node.js, etc.) while maintaining optimal performance by avoiding unnecessary object creation.
291 changes: 237 additions & 54 deletions src/fetch.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -192,59 +192,11 @@ describe('HTTP Header Override Tests', () => {
expect(res.headers.get('x-cache-status')).toBe(DYNAMIC);
});

it('should handle read-only headers by creating new Response object', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);

// Create a response with read-only headers to simulate browser environment
const originalResponse = new Response('test content', {
status: 200,
headers: {
'content-type': 'text/plain',
'cache-control': 'max-age=300',
},
});

// Make headers read-only by freezing the headers object
const readOnlyHeaders = new Headers(originalResponse.headers);
Object.freeze(readOnlyHeaders);

// Create a response with read-only headers
const responseWithReadOnlyHeaders = new Response(originalResponse.body, {
status: originalResponse.status,
statusText: originalResponse.statusText,
headers: readOnlyHeaders,
});

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return responseWithReadOnlyHeaders;
},
});

// This should not throw an error and should properly apply header overrides
const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=600, must-revalidate',
varyOverride: 'accept-language',
},
});

// Verify that headers are properly overridden despite original being read-only
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600, must-revalidate'
);
expect(res.headers.get('vary')).toBe('accept-language');
expect(res.headers.get('x-cache-status')).toBe(MISS);
expect(await res.text()).toBe('test content');
});

it('should preserve all Response properties when creating new Response with modified headers', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'test response body with special characters: 测试内容';
const originalBody =
'test response body with special characters: 测试内容';
const originalResponse = new Response(originalBody, {
status: 201,
statusText: 'Created',
Expand DownExpand Up@@ -283,9 +235,13 @@ describe('HTTP Header Override Tests', () => {
expect(res.status).toBe(201);
expect(res.statusText).toBe('Created');
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8');
expect(res.headers.get('content-length')).toBe(originalBody.length.toString());
expect(res.headers.get('content-length')).toBe(
originalBody.length.toString()
);
expect(res.headers.get('x-custom-header')).toBe('custom-value');
expect(res.headers.get('cache-control')).toBe('max-age=300, s-maxage=600');
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600'
);
expect(res.headers.get('vary')).toBe('user-agent');
expect(res.headers.get('x-cache-status')).toBe(MISS);
expect(await res.text()).toBe(originalBody);
Expand All@@ -301,7 +257,7 @@ describe('HTTP Header Override Tests', () => {
headers: {
'content-type': 'application/json',
'cache-control': 'max-age=300, public',
'etag': '"abc123"',
etag: '"abc123"',
'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT',
},
});
Expand DownExpand Up@@ -333,7 +289,9 @@ describe('HTTP Header Override Tests', () => {
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/json');
expect(res.headers.get('etag')).toBe('"abc123"');
expect(res.headers.get('last-modified')).toBe('Wed, 21 Oct 2015 07:28:00 GMT');
expect(res.headers.get('last-modified')).toBe(
'Wed, 21 Oct 2015 07:28:00 GMT'
);
expect(res.headers.get('cache-control')).toBe(
'max-age=300, public, s-maxage=600, must-revalidate'
);
Expand All@@ -344,6 +302,231 @@ describe('HTTP Header Override Tests', () => {
expect(await res.text()).toBe('multi-header test');
});
});

describe('createInterceptor Header Override Bug Fixes', () => {
it('should handle responses correctly when no header overrides are configured', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'no override test';

let interceptorCalled = false;
let originalResponse: Response;
const fetch = createSharedCacheFetch(cache, {
async fetch() {
originalResponse = new Response(originalBody, {
status: 200,
headers: { 'content-type': 'text/plain' },
});

// Mock the response to detect if interceptor creates new object
const originalClone = originalResponse.clone.bind(originalResponse);
originalResponse.clone = () => {
interceptorCalled = true;
return originalClone();
};

return originalResponse;
},
});

const res = await fetch(TEST_URL);

// Interceptor should not have unnecessarily cloned when no overrides
expect(interceptorCalled).toBe(false);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.has('x-cache-status')).toBe(true); // Cache status should be added
expect(await res.text()).toBe(originalBody);
});

it('should apply header overrides correctly', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'override test';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return new Response(originalBody, {
status: 200,
headers: { 'content-type': 'text/plain' },
});
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=300',
},
});

expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.get('cache-control')).toBe('s-maxage=300');
expect(res.headers.has('x-cache-status')).toBe(true);
expect(await res.text()).toBe(originalBody);
});

it('should handle readonly headers without throwing errors', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'readonly interceptor test';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
const response = new Response(originalBody, {
status: 200,
headers: { 'content-type': 'application/json' },
});

// Make headers readonly by mocking set method to throw
response.headers.set = () => {
throw new Error('Cannot modify readonly headers');
};

return response;
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=600',
varyOverride: 'accept-language',
},
});

// Should successfully handle readonly headers
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/json');
expect(res.headers.get('cache-control')).toBe('s-maxage=600');
expect(res.headers.get('vary')).toBe('accept-language');
expect(await res.text()).toBe(originalBody);
});

it('should not modify response on non-ok status', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);

let headerModificationAttempted = false;
const fetch = createSharedCacheFetch(cache, {
async fetch() {
const originalErrorResponse = new Response('Not Found', {
status: 404,
headers: { 'content-type': 'text/plain' },
});

// Track if headers.set is called (which would indicate header modification)
const originalSet = originalErrorResponse.headers.set.bind(
originalErrorResponse.headers
);
originalErrorResponse.headers.set = (name, value) => {
if (name !== 'x-cache-status') {
// setCacheStatus might still be called
headerModificationAttempted = true;
}
return originalSet(name, value);
};

return originalErrorResponse;
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=300',
varyOverride: 'accept',
},
});

// Headers should not be modified by interceptor for non-ok responses
expect(headerModificationAttempted).toBe(false);
expect(res.status).toBe(404);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.has('cache-control')).toBe(false);
expect(res.headers.has('vary')).toBe(false);
});

it('should preserve all response properties when handling header overrides', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const complexBody = 'complex response: 测试内容 with special chars';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return new Response(complexBody, {
status: 201,
statusText: 'Created',
headers: {
'content-type': 'text/plain; charset=utf-8',
'content-length': complexBody.length.toString(),
etag: '"interceptor-test"',
'last-modified': 'Thu, 01 Dec 2022 12:00:00 GMT',
'x-original': 'true',
},
});
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 'max-age=300, s-maxage=600',
varyOverride: 'accept-encoding, user-agent',
},
});

// Verify all original properties are preserved
expect(res.status).toBe(201);
expect(res.statusText).toBe('Created');
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8');
expect(res.headers.get('content-length')).toBe(
complexBody.length.toString()
);
expect(res.headers.get('etag')).toBe('"interceptor-test"');
expect(res.headers.get('last-modified')).toBe(
'Thu, 01 Dec 2022 12:00:00 GMT'
);
expect(res.headers.get('x-original')).toBe('true');

// Verify overrides are applied
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600'
);
expect(res.headers.get('vary')).toBe('accept-encoding, user-agent');

expect(await res.text()).toBe(complexBody);
});

it('should correctly handle multiple requests with different override configurations', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
let callCount = 0;

const fetch = createSharedCacheFetch(cache, {
async fetch() {
callCount++;
return new Response(`call ${callCount}`, {
status: 200,
headers: { 'content-type': 'text/plain' },
});
},
});

// Test multiple calls with and without overrides
const noOverride = await fetch(`${TEST_URL}?1`);
const withOverride = await fetch(`${TEST_URL}?2`, {
sharedCache: { cacheControlOverride: 's-maxage=300' },
});

expect(callCount).toBe(2);
expect(await noOverride.clone().text()).toBe('call 1');
expect(await withOverride.clone().text()).toBe('call 2');

// Both should have cache status
expect(noOverride.headers.has('x-cache-status')).toBe(true);
expect(withOverride.headers.has('x-cache-status')).toBe(true);

// Only the one with override should have cache-control
expect(noOverride.headers.has('cache-control')).toBe(false);
expect(withOverride.headers.get('cache-control')).toBe('s-maxage=300');
});
});
});

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/fix-readonly-headers-comprehensive.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@web-widget/shared-cache": patch
---

fix: Comprehensive fix for readonly headers modification issues

This change completely resolves the readonly headers modification problems that were partially addressed in the previous fix. The solution includes:

**New Features:**
- Added `src/utils/response.ts` with intelligent response utilities:
- `modifyResponseHeaders()`: Smart header modification with readonly fallback
- `setResponseHeader()`: Convenient single header setting function

**Bug Fixes:**
- Fixed `setCacheStatus()` function in `fetch.ts` to properly handle readonly headers
- Optimized `createInterceptor()` function to avoid unnecessary Response cloning
- Ensured `cache.ts` uses safe header modification patterns
- All header modifications now gracefully handle readonly scenarios

**Performance Improvements:**
- No Response cloning when no header overrides are configured
- Direct header modification when possible (for mutable headers)
- Smart fallback to new Response creation only when necessary
- Significant performance improvement for common use cases

**Testing:**
- Added comprehensive unit tests for response utilities (10 new tests)
- Added specific tests for createInterceptor readonly headers handling (6 new tests)
- All 258 tests pass with 93.93% code coverage
- Tests cover edge cases, error scenarios, and performance considerations

This fix ensures that header modifications work reliably across all environments (browser, Node.js, etc.) while maintaining optimal performance by avoiding unnecessary object creation.
291 changes: 237 additions & 54 deletions src/fetch.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -192,59 +192,11 @@ describe('HTTP Header Override Tests', () => {
expect(res.headers.get('x-cache-status')).toBe(DYNAMIC);
});

it('should handle read-only headers by creating new Response object', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);

// Create a response with read-only headers to simulate browser environment
const originalResponse = new Response('test content', {
status: 200,
headers: {
'content-type': 'text/plain',
'cache-control': 'max-age=300',
},
});

// Make headers read-only by freezing the headers object
const readOnlyHeaders = new Headers(originalResponse.headers);
Object.freeze(readOnlyHeaders);

// Create a response with read-only headers
const responseWithReadOnlyHeaders = new Response(originalResponse.body, {
status: originalResponse.status,
statusText: originalResponse.statusText,
headers: readOnlyHeaders,
});

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return responseWithReadOnlyHeaders;
},
});

// This should not throw an error and should properly apply header overrides
const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=600, must-revalidate',
varyOverride: 'accept-language',
},
});

// Verify that headers are properly overridden despite original being read-only
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600, must-revalidate'
);
expect(res.headers.get('vary')).toBe('accept-language');
expect(res.headers.get('x-cache-status')).toBe(MISS);
expect(await res.text()).toBe('test content');
});

it('should preserve all Response properties when creating new Response with modified headers', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'test response body with special characters: 测试内容';
const originalBody =
'test response body with special characters: 测试内容';
const originalResponse = new Response(originalBody, {
status: 201,
statusText: 'Created',
Expand DownExpand Up@@ -283,9 +235,13 @@ describe('HTTP Header Override Tests', () => {
expect(res.status).toBe(201);
expect(res.statusText).toBe('Created');
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8');
expect(res.headers.get('content-length')).toBe(originalBody.length.toString());
expect(res.headers.get('content-length')).toBe(
originalBody.length.toString()
);
expect(res.headers.get('x-custom-header')).toBe('custom-value');
expect(res.headers.get('cache-control')).toBe('max-age=300, s-maxage=600');
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600'
);
expect(res.headers.get('vary')).toBe('user-agent');
expect(res.headers.get('x-cache-status')).toBe(MISS);
expect(await res.text()).toBe(originalBody);
Expand All@@ -301,7 +257,7 @@ describe('HTTP Header Override Tests', () => {
headers: {
'content-type': 'application/json',
'cache-control': 'max-age=300, public',
'etag': '"abc123"',
etag: '"abc123"',
'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT',
},
});
Expand DownExpand Up@@ -333,7 +289,9 @@ describe('HTTP Header Override Tests', () => {
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/json');
expect(res.headers.get('etag')).toBe('"abc123"');
expect(res.headers.get('last-modified')).toBe('Wed, 21 Oct 2015 07:28:00 GMT');
expect(res.headers.get('last-modified')).toBe(
'Wed, 21 Oct 2015 07:28:00 GMT'
);
expect(res.headers.get('cache-control')).toBe(
'max-age=300, public, s-maxage=600, must-revalidate'
);
Expand All@@ -344,6 +302,231 @@ describe('HTTP Header Override Tests', () => {
expect(await res.text()).toBe('multi-header test');
});
});

describe('createInterceptor Header Override Bug Fixes', () => {
it('should handle responses correctly when no header overrides are configured', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'no override test';

let interceptorCalled = false;
let originalResponse: Response;
const fetch = createSharedCacheFetch(cache, {
async fetch() {
originalResponse = new Response(originalBody, {
status: 200,
headers: { 'content-type': 'text/plain' },
});

// Mock the response to detect if interceptor creates new object
const originalClone = originalResponse.clone.bind(originalResponse);
originalResponse.clone = () => {
interceptorCalled = true;
return originalClone();
};

return originalResponse;
},
});

const res = await fetch(TEST_URL);

// Interceptor should not have unnecessarily cloned when no overrides
expect(interceptorCalled).toBe(false);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.has('x-cache-status')).toBe(true); // Cache status should be added
expect(await res.text()).toBe(originalBody);
});

it('should apply header overrides correctly', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'override test';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return new Response(originalBody, {
status: 200,
headers: { 'content-type': 'text/plain' },
});
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=300',
},
});

expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.get('cache-control')).toBe('s-maxage=300');
expect(res.headers.has('x-cache-status')).toBe(true);
expect(await res.text()).toBe(originalBody);
});

it('should handle readonly headers without throwing errors', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'readonly interceptor test';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
const response = new Response(originalBody, {
status: 200,
headers: { 'content-type': 'application/json' },
});

// Make headers readonly by mocking set method to throw
response.headers.set = () => {
throw new Error('Cannot modify readonly headers');
};

return response;
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=600',
varyOverride: 'accept-language',
},
});

// Should successfully handle readonly headers
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/json');
expect(res.headers.get('cache-control')).toBe('s-maxage=600');
expect(res.headers.get('vary')).toBe('accept-language');
expect(await res.text()).toBe(originalBody);
});

it('should not modify response on non-ok status', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);

let headerModificationAttempted = false;
const fetch = createSharedCacheFetch(cache, {
async fetch() {
const originalErrorResponse = new Response('Not Found', {
status: 404,
headers: { 'content-type': 'text/plain' },
});

// Track if headers.set is called (which would indicate header modification)
const originalSet = originalErrorResponse.headers.set.bind(
originalErrorResponse.headers
);
originalErrorResponse.headers.set = (name, value) => {
if (name !== 'x-cache-status') {
// setCacheStatus might still be called
headerModificationAttempted = true;
}
return originalSet(name, value);
};

return originalErrorResponse;
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=300',
varyOverride: 'accept',
},
});

// Headers should not be modified by interceptor for non-ok responses
expect(headerModificationAttempted).toBe(false);
expect(res.status).toBe(404);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.has('cache-control')).toBe(false);
expect(res.headers.has('vary')).toBe(false);
});

it('should preserve all response properties when handling header overrides', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const complexBody = 'complex response: 测试内容 with special chars';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return new Response(complexBody, {
status: 201,
statusText: 'Created',
headers: {
'content-type': 'text/plain; charset=utf-8',
'content-length': complexBody.length.toString(),
etag: '"interceptor-test"',
'last-modified': 'Thu, 01 Dec 2022 12:00:00 GMT',
'x-original': 'true',
},
});
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 'max-age=300, s-maxage=600',
varyOverride: 'accept-encoding, user-agent',
},
});

// Verify all original properties are preserved
expect(res.status).toBe(201);
expect(res.statusText).toBe('Created');
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8');
expect(res.headers.get('content-length')).toBe(
complexBody.length.toString()
);
expect(res.headers.get('etag')).toBe('"interceptor-test"');
expect(res.headers.get('last-modified')).toBe(
'Thu, 01 Dec 2022 12:00:00 GMT'
);
expect(res.headers.get('x-original')).toBe('true');

// Verify overrides are applied
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600'
);
expect(res.headers.get('vary')).toBe('accept-encoding, user-agent');

expect(await res.text()).toBe(complexBody);
});

it('should correctly handle multiple requests with different override configurations', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
let callCount = 0;

const fetch = createSharedCacheFetch(cache, {
async fetch() {
callCount++;
return new Response(`call ${callCount}`, {
status: 200,
headers: { 'content-type': 'text/plain' },
});
},
});

// Test multiple calls with and without overrides
const noOverride = await fetch(`${TEST_URL}?1`);
const withOverride = await fetch(`${TEST_URL}?2`, {
sharedCache: { cacheControlOverride: 's-maxage=300' },
});

expect(callCount).toBe(2);
expect(await noOverride.clone().text()).toBe('call 1');
expect(await withOverride.clone().text()).toBe('call 2');

// Both should have cache status
expect(noOverride.headers.has('x-cache-status')).toBe(true);
expect(withOverride.headers.has('x-cache-status')).toBe(true);

// Only the one with override should have cache-control
expect(noOverride.headers.has('cache-control')).toBe(false);
expect(withOverride.headers.get('cache-control')).toBe('s-maxage=300');
});
});
});

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/fix-readonly-headers-comprehensive.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@web-widget/shared-cache": patch
---

fix: Comprehensive fix for readonly headers modification issues

This change completely resolves the readonly headers modification problems that were partially addressed in the previous fix. The solution includes:

**New Features:**
- Added `src/utils/response.ts` with intelligent response utilities:
- `modifyResponseHeaders()`: Smart header modification with readonly fallback
- `setResponseHeader()`: Convenient single header setting function

**Bug Fixes:**
- Fixed `setCacheStatus()` function in `fetch.ts` to properly handle readonly headers
- Optimized `createInterceptor()` function to avoid unnecessary Response cloning
- Ensured `cache.ts` uses safe header modification patterns
- All header modifications now gracefully handle readonly scenarios

**Performance Improvements:**
- No Response cloning when no header overrides are configured
- Direct header modification when possible (for mutable headers)
- Smart fallback to new Response creation only when necessary
- Significant performance improvement for common use cases

**Testing:**
- Added comprehensive unit tests for response utilities (10 new tests)
- Added specific tests for createInterceptor readonly headers handling (6 new tests)
- All 258 tests pass with 93.93% code coverage
- Tests cover edge cases, error scenarios, and performance considerations

This fix ensures that header modifications work reliably across all environments (browser, Node.js, etc.) while maintaining optimal performance by avoiding unnecessary object creation.
291 changes: 237 additions & 54 deletions src/fetch.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -192,59 +192,11 @@ describe('HTTP Header Override Tests', () => {
expect(res.headers.get('x-cache-status')).toBe(DYNAMIC);
});

it('should handle read-only headers by creating new Response object', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);

// Create a response with read-only headers to simulate browser environment
const originalResponse = new Response('test content', {
status: 200,
headers: {
'content-type': 'text/plain',
'cache-control': 'max-age=300',
},
});

// Make headers read-only by freezing the headers object
const readOnlyHeaders = new Headers(originalResponse.headers);
Object.freeze(readOnlyHeaders);

// Create a response with read-only headers
const responseWithReadOnlyHeaders = new Response(originalResponse.body, {
status: originalResponse.status,
statusText: originalResponse.statusText,
headers: readOnlyHeaders,
});

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return responseWithReadOnlyHeaders;
},
});

// This should not throw an error and should properly apply header overrides
const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=600, must-revalidate',
varyOverride: 'accept-language',
},
});

// Verify that headers are properly overridden despite original being read-only
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600, must-revalidate'
);
expect(res.headers.get('vary')).toBe('accept-language');
expect(res.headers.get('x-cache-status')).toBe(MISS);
expect(await res.text()).toBe('test content');
});

it('should preserve all Response properties when creating new Response with modified headers', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'test response body with special characters: 测试内容';
const originalBody =
'test response body with special characters: 测试内容';
const originalResponse = new Response(originalBody, {
status: 201,
statusText: 'Created',
Expand DownExpand Up@@ -283,9 +235,13 @@ describe('HTTP Header Override Tests', () => {
expect(res.status).toBe(201);
expect(res.statusText).toBe('Created');
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8');
expect(res.headers.get('content-length')).toBe(originalBody.length.toString());
expect(res.headers.get('content-length')).toBe(
originalBody.length.toString()
);
expect(res.headers.get('x-custom-header')).toBe('custom-value');
expect(res.headers.get('cache-control')).toBe('max-age=300, s-maxage=600');
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600'
);
expect(res.headers.get('vary')).toBe('user-agent');
expect(res.headers.get('x-cache-status')).toBe(MISS);
expect(await res.text()).toBe(originalBody);
Expand All@@ -301,7 +257,7 @@ describe('HTTP Header Override Tests', () => {
headers: {
'content-type': 'application/json',
'cache-control': 'max-age=300, public',
'etag': '"abc123"',
etag: '"abc123"',
'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT',
},
});
Expand DownExpand Up@@ -333,7 +289,9 @@ describe('HTTP Header Override Tests', () => {
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/json');
expect(res.headers.get('etag')).toBe('"abc123"');
expect(res.headers.get('last-modified')).toBe('Wed, 21 Oct 2015 07:28:00 GMT');
expect(res.headers.get('last-modified')).toBe(
'Wed, 21 Oct 2015 07:28:00 GMT'
);
expect(res.headers.get('cache-control')).toBe(
'max-age=300, public, s-maxage=600, must-revalidate'
);
Expand All@@ -344,6 +302,231 @@ describe('HTTP Header Override Tests', () => {
expect(await res.text()).toBe('multi-header test');
});
});

describe('createInterceptor Header Override Bug Fixes', () => {
it('should handle responses correctly when no header overrides are configured', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'no override test';

let interceptorCalled = false;
let originalResponse: Response;
const fetch = createSharedCacheFetch(cache, {
async fetch() {
originalResponse = new Response(originalBody, {
status: 200,
headers: { 'content-type': 'text/plain' },
});

// Mock the response to detect if interceptor creates new object
const originalClone = originalResponse.clone.bind(originalResponse);
originalResponse.clone = () => {
interceptorCalled = true;
return originalClone();
};

return originalResponse;
},
});

const res = await fetch(TEST_URL);

// Interceptor should not have unnecessarily cloned when no overrides
expect(interceptorCalled).toBe(false);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.has('x-cache-status')).toBe(true); // Cache status should be added
expect(await res.text()).toBe(originalBody);
});

it('should apply header overrides correctly', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'override test';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return new Response(originalBody, {
status: 200,
headers: { 'content-type': 'text/plain' },
});
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=300',
},
});

expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.get('cache-control')).toBe('s-maxage=300');
expect(res.headers.has('x-cache-status')).toBe(true);
expect(await res.text()).toBe(originalBody);
});

it('should handle readonly headers without throwing errors', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'readonly interceptor test';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
const response = new Response(originalBody, {
status: 200,
headers: { 'content-type': 'application/json' },
});

// Make headers readonly by mocking set method to throw
response.headers.set = () => {
throw new Error('Cannot modify readonly headers');
};

return response;
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=600',
varyOverride: 'accept-language',
},
});

// Should successfully handle readonly headers
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/json');
expect(res.headers.get('cache-control')).toBe('s-maxage=600');
expect(res.headers.get('vary')).toBe('accept-language');
expect(await res.text()).toBe(originalBody);
});

it('should not modify response on non-ok status', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);

let headerModificationAttempted = false;
const fetch = createSharedCacheFetch(cache, {
async fetch() {
const originalErrorResponse = new Response('Not Found', {
status: 404,
headers: { 'content-type': 'text/plain' },
});

// Track if headers.set is called (which would indicate header modification)
const originalSet = originalErrorResponse.headers.set.bind(
originalErrorResponse.headers
);
originalErrorResponse.headers.set = (name, value) => {
if (name !== 'x-cache-status') {
// setCacheStatus might still be called
headerModificationAttempted = true;
}
return originalSet(name, value);
};

return originalErrorResponse;
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=300',
varyOverride: 'accept',
},
});

// Headers should not be modified by interceptor for non-ok responses
expect(headerModificationAttempted).toBe(false);
expect(res.status).toBe(404);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.has('cache-control')).toBe(false);
expect(res.headers.has('vary')).toBe(false);
});

it('should preserve all response properties when handling header overrides', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const complexBody = 'complex response: 测试内容 with special chars';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return new Response(complexBody, {
status: 201,
statusText: 'Created',
headers: {
'content-type': 'text/plain; charset=utf-8',
'content-length': complexBody.length.toString(),
etag: '"interceptor-test"',
'last-modified': 'Thu, 01 Dec 2022 12:00:00 GMT',
'x-original': 'true',
},
});
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 'max-age=300, s-maxage=600',
varyOverride: 'accept-encoding, user-agent',
},
});

// Verify all original properties are preserved
expect(res.status).toBe(201);
expect(res.statusText).toBe('Created');
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8');
expect(res.headers.get('content-length')).toBe(
complexBody.length.toString()
);
expect(res.headers.get('etag')).toBe('"interceptor-test"');
expect(res.headers.get('last-modified')).toBe(
'Thu, 01 Dec 2022 12:00:00 GMT'
);
expect(res.headers.get('x-original')).toBe('true');

// Verify overrides are applied
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600'
);
expect(res.headers.get('vary')).toBe('accept-encoding, user-agent');

expect(await res.text()).toBe(complexBody);
});

it('should correctly handle multiple requests with different override configurations', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
let callCount = 0;

const fetch = createSharedCacheFetch(cache, {
async fetch() {
callCount++;
return new Response(`call ${callCount}`, {
status: 200,
headers: { 'content-type': 'text/plain' },
});
},
});

// Test multiple calls with and without overrides
const noOverride = await fetch(`${TEST_URL}?1`);
const withOverride = await fetch(`${TEST_URL}?2`, {
sharedCache: { cacheControlOverride: 's-maxage=300' },
});

expect(callCount).toBe(2);
expect(await noOverride.clone().text()).toBe('call 1');
expect(await withOverride.clone().text()).toBe('call 2');

// Both should have cache status
expect(noOverride.headers.has('x-cache-status')).toBe(true);
expect(withOverride.headers.has('x-cache-status')).toBe(true);

// Only the one with override should have cache-control
expect(noOverride.headers.has('cache-control')).toBe(false);
expect(withOverride.headers.get('cache-control')).toBe('s-maxage=300');
});
});
});

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/fix-readonly-headers-comprehensive.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@web-widget/shared-cache": patch
---

fix: Comprehensive fix for readonly headers modification issues

This change completely resolves the readonly headers modification problems that were partially addressed in the previous fix. The solution includes:

**New Features:**
- Added `src/utils/response.ts` with intelligent response utilities:
- `modifyResponseHeaders()`: Smart header modification with readonly fallback
- `setResponseHeader()`: Convenient single header setting function

**Bug Fixes:**
- Fixed `setCacheStatus()` function in `fetch.ts` to properly handle readonly headers
- Optimized `createInterceptor()` function to avoid unnecessary Response cloning
- Ensured `cache.ts` uses safe header modification patterns
- All header modifications now gracefully handle readonly scenarios

**Performance Improvements:**
- No Response cloning when no header overrides are configured
- Direct header modification when possible (for mutable headers)
- Smart fallback to new Response creation only when necessary
- Significant performance improvement for common use cases

**Testing:**
- Added comprehensive unit tests for response utilities (10 new tests)
- Added specific tests for createInterceptor readonly headers handling (6 new tests)
- All 258 tests pass with 93.93% code coverage
- Tests cover edge cases, error scenarios, and performance considerations

This fix ensures that header modifications work reliably across all environments (browser, Node.js, etc.) while maintaining optimal performance by avoiding unnecessary object creation.
291 changes: 237 additions & 54 deletions src/fetch.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -192,59 +192,11 @@ describe('HTTP Header Override Tests', () => {
expect(res.headers.get('x-cache-status')).toBe(DYNAMIC);
});

it('should handle read-only headers by creating new Response object', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);

// Create a response with read-only headers to simulate browser environment
const originalResponse = new Response('test content', {
status: 200,
headers: {
'content-type': 'text/plain',
'cache-control': 'max-age=300',
},
});

// Make headers read-only by freezing the headers object
const readOnlyHeaders = new Headers(originalResponse.headers);
Object.freeze(readOnlyHeaders);

// Create a response with read-only headers
const responseWithReadOnlyHeaders = new Response(originalResponse.body, {
status: originalResponse.status,
statusText: originalResponse.statusText,
headers: readOnlyHeaders,
});

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return responseWithReadOnlyHeaders;
},
});

// This should not throw an error and should properly apply header overrides
const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=600, must-revalidate',
varyOverride: 'accept-language',
},
});

// Verify that headers are properly overridden despite original being read-only
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600, must-revalidate'
);
expect(res.headers.get('vary')).toBe('accept-language');
expect(res.headers.get('x-cache-status')).toBe(MISS);
expect(await res.text()).toBe('test content');
});

it('should preserve all Response properties when creating new Response with modified headers', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'test response body with special characters: 测试内容';
const originalBody =
'test response body with special characters: 测试内容';
const originalResponse = new Response(originalBody, {
status: 201,
statusText: 'Created',
Expand DownExpand Up@@ -283,9 +235,13 @@ describe('HTTP Header Override Tests', () => {
expect(res.status).toBe(201);
expect(res.statusText).toBe('Created');
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8');
expect(res.headers.get('content-length')).toBe(originalBody.length.toString());
expect(res.headers.get('content-length')).toBe(
originalBody.length.toString()
);
expect(res.headers.get('x-custom-header')).toBe('custom-value');
expect(res.headers.get('cache-control')).toBe('max-age=300, s-maxage=600');
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600'
);
expect(res.headers.get('vary')).toBe('user-agent');
expect(res.headers.get('x-cache-status')).toBe(MISS);
expect(await res.text()).toBe(originalBody);
Expand All@@ -301,7 +257,7 @@ describe('HTTP Header Override Tests', () => {
headers: {
'content-type': 'application/json',
'cache-control': 'max-age=300, public',
'etag': '"abc123"',
etag: '"abc123"',
'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT',
},
});
Expand DownExpand Up@@ -333,7 +289,9 @@ describe('HTTP Header Override Tests', () => {
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/json');
expect(res.headers.get('etag')).toBe('"abc123"');
expect(res.headers.get('last-modified')).toBe('Wed, 21 Oct 2015 07:28:00 GMT');
expect(res.headers.get('last-modified')).toBe(
'Wed, 21 Oct 2015 07:28:00 GMT'
);
expect(res.headers.get('cache-control')).toBe(
'max-age=300, public, s-maxage=600, must-revalidate'
);
Expand All@@ -344,6 +302,231 @@ describe('HTTP Header Override Tests', () => {
expect(await res.text()).toBe('multi-header test');
});
});

describe('createInterceptor Header Override Bug Fixes', () => {
it('should handle responses correctly when no header overrides are configured', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'no override test';

let interceptorCalled = false;
let originalResponse: Response;
const fetch = createSharedCacheFetch(cache, {
async fetch() {
originalResponse = new Response(originalBody, {
status: 200,
headers: { 'content-type': 'text/plain' },
});

// Mock the response to detect if interceptor creates new object
const originalClone = originalResponse.clone.bind(originalResponse);
originalResponse.clone = () => {
interceptorCalled = true;
return originalClone();
};

return originalResponse;
},
});

const res = await fetch(TEST_URL);

// Interceptor should not have unnecessarily cloned when no overrides
expect(interceptorCalled).toBe(false);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.has('x-cache-status')).toBe(true); // Cache status should be added
expect(await res.text()).toBe(originalBody);
});

it('should apply header overrides correctly', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'override test';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return new Response(originalBody, {
status: 200,
headers: { 'content-type': 'text/plain' },
});
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=300',
},
});

expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.get('cache-control')).toBe('s-maxage=300');
expect(res.headers.has('x-cache-status')).toBe(true);
expect(await res.text()).toBe(originalBody);
});

it('should handle readonly headers without throwing errors', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'readonly interceptor test';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
const response = new Response(originalBody, {
status: 200,
headers: { 'content-type': 'application/json' },
});

// Make headers readonly by mocking set method to throw
response.headers.set = () => {
throw new Error('Cannot modify readonly headers');
};

return response;
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=600',
varyOverride: 'accept-language',
},
});

// Should successfully handle readonly headers
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/json');
expect(res.headers.get('cache-control')).toBe('s-maxage=600');
expect(res.headers.get('vary')).toBe('accept-language');
expect(await res.text()).toBe(originalBody);
});

it('should not modify response on non-ok status', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);

let headerModificationAttempted = false;
const fetch = createSharedCacheFetch(cache, {
async fetch() {
const originalErrorResponse = new Response('Not Found', {
status: 404,
headers: { 'content-type': 'text/plain' },
});

// Track if headers.set is called (which would indicate header modification)
const originalSet = originalErrorResponse.headers.set.bind(
originalErrorResponse.headers
);
originalErrorResponse.headers.set = (name, value) => {
if (name !== 'x-cache-status') {
// setCacheStatus might still be called
headerModificationAttempted = true;
}
return originalSet(name, value);
};

return originalErrorResponse;
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=300',
varyOverride: 'accept',
},
});

// Headers should not be modified by interceptor for non-ok responses
expect(headerModificationAttempted).toBe(false);
expect(res.status).toBe(404);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.has('cache-control')).toBe(false);
expect(res.headers.has('vary')).toBe(false);
});

it('should preserve all response properties when handling header overrides', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const complexBody = 'complex response: 测试内容 with special chars';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return new Response(complexBody, {
status: 201,
statusText: 'Created',
headers: {
'content-type': 'text/plain; charset=utf-8',
'content-length': complexBody.length.toString(),
etag: '"interceptor-test"',
'last-modified': 'Thu, 01 Dec 2022 12:00:00 GMT',
'x-original': 'true',
},
});
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 'max-age=300, s-maxage=600',
varyOverride: 'accept-encoding, user-agent',
},
});

// Verify all original properties are preserved
expect(res.status).toBe(201);
expect(res.statusText).toBe('Created');
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8');
expect(res.headers.get('content-length')).toBe(
complexBody.length.toString()
);
expect(res.headers.get('etag')).toBe('"interceptor-test"');
expect(res.headers.get('last-modified')).toBe(
'Thu, 01 Dec 2022 12:00:00 GMT'
);
expect(res.headers.get('x-original')).toBe('true');

// Verify overrides are applied
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600'
);
expect(res.headers.get('vary')).toBe('accept-encoding, user-agent');

expect(await res.text()).toBe(complexBody);
});

it('should correctly handle multiple requests with different override configurations', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
let callCount = 0;

const fetch = createSharedCacheFetch(cache, {
async fetch() {
callCount++;
return new Response(`call ${callCount}`, {
status: 200,
headers: { 'content-type': 'text/plain' },
});
},
});

// Test multiple calls with and without overrides
const noOverride = await fetch(`${TEST_URL}?1`);
const withOverride = await fetch(`${TEST_URL}?2`, {
sharedCache: { cacheControlOverride: 's-maxage=300' },
});

expect(callCount).toBe(2);
expect(await noOverride.clone().text()).toBe('call 1');
expect(await withOverride.clone().text()).toBe('call 2');

// Both should have cache status
expect(noOverride.headers.has('x-cache-status')).toBe(true);
expect(withOverride.headers.has('x-cache-status')).toBe(true);

// Only the one with override should have cache-control
expect(noOverride.headers.has('cache-control')).toBe(false);
expect(withOverride.headers.get('cache-control')).toBe('s-maxage=300');
});
});
});

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/fix-readonly-headers-comprehensive.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@web-widget/shared-cache": patch
---

fix: Comprehensive fix for readonly headers modification issues

This change completely resolves the readonly headers modification problems that were partially addressed in the previous fix. The solution includes:

**New Features:**
- Added `src/utils/response.ts` with intelligent response utilities:
- `modifyResponseHeaders()`: Smart header modification with readonly fallback
- `setResponseHeader()`: Convenient single header setting function

**Bug Fixes:**
- Fixed `setCacheStatus()` function in `fetch.ts` to properly handle readonly headers
- Optimized `createInterceptor()` function to avoid unnecessary Response cloning
- Ensured `cache.ts` uses safe header modification patterns
- All header modifications now gracefully handle readonly scenarios

**Performance Improvements:**
- No Response cloning when no header overrides are configured
- Direct header modification when possible (for mutable headers)
- Smart fallback to new Response creation only when necessary
- Significant performance improvement for common use cases

**Testing:**
- Added comprehensive unit tests for response utilities (10 new tests)
- Added specific tests for createInterceptor readonly headers handling (6 new tests)
- All 258 tests pass with 93.93% code coverage
- Tests cover edge cases, error scenarios, and performance considerations

This fix ensures that header modifications work reliably across all environments (browser, Node.js, etc.) while maintaining optimal performance by avoiding unnecessary object creation.
291 changes: 237 additions & 54 deletions src/fetch.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -192,59 +192,11 @@ describe('HTTP Header Override Tests', () => {
expect(res.headers.get('x-cache-status')).toBe(DYNAMIC);
});

it('should handle read-only headers by creating new Response object', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);

// Create a response with read-only headers to simulate browser environment
const originalResponse = new Response('test content', {
status: 200,
headers: {
'content-type': 'text/plain',
'cache-control': 'max-age=300',
},
});

// Make headers read-only by freezing the headers object
const readOnlyHeaders = new Headers(originalResponse.headers);
Object.freeze(readOnlyHeaders);

// Create a response with read-only headers
const responseWithReadOnlyHeaders = new Response(originalResponse.body, {
status: originalResponse.status,
statusText: originalResponse.statusText,
headers: readOnlyHeaders,
});

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return responseWithReadOnlyHeaders;
},
});

// This should not throw an error and should properly apply header overrides
const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=600, must-revalidate',
varyOverride: 'accept-language',
},
});

// Verify that headers are properly overridden despite original being read-only
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600, must-revalidate'
);
expect(res.headers.get('vary')).toBe('accept-language');
expect(res.headers.get('x-cache-status')).toBe(MISS);
expect(await res.text()).toBe('test content');
});

it('should preserve all Response properties when creating new Response with modified headers', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'test response body with special characters: 测试内容';
const originalBody =
'test response body with special characters: 测试内容';
const originalResponse = new Response(originalBody, {
status: 201,
statusText: 'Created',
Expand DownExpand Up@@ -283,9 +235,13 @@ describe('HTTP Header Override Tests', () => {
expect(res.status).toBe(201);
expect(res.statusText).toBe('Created');
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8');
expect(res.headers.get('content-length')).toBe(originalBody.length.toString());
expect(res.headers.get('content-length')).toBe(
originalBody.length.toString()
);
expect(res.headers.get('x-custom-header')).toBe('custom-value');
expect(res.headers.get('cache-control')).toBe('max-age=300, s-maxage=600');
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600'
);
expect(res.headers.get('vary')).toBe('user-agent');
expect(res.headers.get('x-cache-status')).toBe(MISS);
expect(await res.text()).toBe(originalBody);
Expand All@@ -301,7 +257,7 @@ describe('HTTP Header Override Tests', () => {
headers: {
'content-type': 'application/json',
'cache-control': 'max-age=300, public',
'etag': '"abc123"',
etag: '"abc123"',
'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT',
},
});
Expand DownExpand Up@@ -333,7 +289,9 @@ describe('HTTP Header Override Tests', () => {
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/json');
expect(res.headers.get('etag')).toBe('"abc123"');
expect(res.headers.get('last-modified')).toBe('Wed, 21 Oct 2015 07:28:00 GMT');
expect(res.headers.get('last-modified')).toBe(
'Wed, 21 Oct 2015 07:28:00 GMT'
);
expect(res.headers.get('cache-control')).toBe(
'max-age=300, public, s-maxage=600, must-revalidate'
);
Expand All@@ -344,6 +302,231 @@ describe('HTTP Header Override Tests', () => {
expect(await res.text()).toBe('multi-header test');
});
});

describe('createInterceptor Header Override Bug Fixes', () => {
it('should handle responses correctly when no header overrides are configured', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'no override test';

let interceptorCalled = false;
let originalResponse: Response;
const fetch = createSharedCacheFetch(cache, {
async fetch() {
originalResponse = new Response(originalBody, {
status: 200,
headers: { 'content-type': 'text/plain' },
});

// Mock the response to detect if interceptor creates new object
const originalClone = originalResponse.clone.bind(originalResponse);
originalResponse.clone = () => {
interceptorCalled = true;
return originalClone();
};

return originalResponse;
},
});

const res = await fetch(TEST_URL);

// Interceptor should not have unnecessarily cloned when no overrides
expect(interceptorCalled).toBe(false);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.has('x-cache-status')).toBe(true); // Cache status should be added
expect(await res.text()).toBe(originalBody);
});

it('should apply header overrides correctly', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'override test';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return new Response(originalBody, {
status: 200,
headers: { 'content-type': 'text/plain' },
});
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=300',
},
});

expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.get('cache-control')).toBe('s-maxage=300');
expect(res.headers.has('x-cache-status')).toBe(true);
expect(await res.text()).toBe(originalBody);
});

it('should handle readonly headers without throwing errors', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'readonly interceptor test';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
const response = new Response(originalBody, {
status: 200,
headers: { 'content-type': 'application/json' },
});

// Make headers readonly by mocking set method to throw
response.headers.set = () => {
throw new Error('Cannot modify readonly headers');
};

return response;
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=600',
varyOverride: 'accept-language',
},
});

// Should successfully handle readonly headers
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/json');
expect(res.headers.get('cache-control')).toBe('s-maxage=600');
expect(res.headers.get('vary')).toBe('accept-language');
expect(await res.text()).toBe(originalBody);
});

it('should not modify response on non-ok status', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);

let headerModificationAttempted = false;
const fetch = createSharedCacheFetch(cache, {
async fetch() {
const originalErrorResponse = new Response('Not Found', {
status: 404,
headers: { 'content-type': 'text/plain' },
});

// Track if headers.set is called (which would indicate header modification)
const originalSet = originalErrorResponse.headers.set.bind(
originalErrorResponse.headers
);
originalErrorResponse.headers.set = (name, value) => {
if (name !== 'x-cache-status') {
// setCacheStatus might still be called
headerModificationAttempted = true;
}
return originalSet(name, value);
};

return originalErrorResponse;
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=300',
varyOverride: 'accept',
},
});

// Headers should not be modified by interceptor for non-ok responses
expect(headerModificationAttempted).toBe(false);
expect(res.status).toBe(404);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.has('cache-control')).toBe(false);
expect(res.headers.has('vary')).toBe(false);
});

it('should preserve all response properties when handling header overrides', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const complexBody = 'complex response: 测试内容 with special chars';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return new Response(complexBody, {
status: 201,
statusText: 'Created',
headers: {
'content-type': 'text/plain; charset=utf-8',
'content-length': complexBody.length.toString(),
etag: '"interceptor-test"',
'last-modified': 'Thu, 01 Dec 2022 12:00:00 GMT',
'x-original': 'true',
},
});
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 'max-age=300, s-maxage=600',
varyOverride: 'accept-encoding, user-agent',
},
});

// Verify all original properties are preserved
expect(res.status).toBe(201);
expect(res.statusText).toBe('Created');
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8');
expect(res.headers.get('content-length')).toBe(
complexBody.length.toString()
);
expect(res.headers.get('etag')).toBe('"interceptor-test"');
expect(res.headers.get('last-modified')).toBe(
'Thu, 01 Dec 2022 12:00:00 GMT'
);
expect(res.headers.get('x-original')).toBe('true');

// Verify overrides are applied
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600'
);
expect(res.headers.get('vary')).toBe('accept-encoding, user-agent');

expect(await res.text()).toBe(complexBody);
});

it('should correctly handle multiple requests with different override configurations', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
let callCount = 0;

const fetch = createSharedCacheFetch(cache, {
async fetch() {
callCount++;
return new Response(`call ${callCount}`, {
status: 200,
headers: { 'content-type': 'text/plain' },
});
},
});

// Test multiple calls with and without overrides
const noOverride = await fetch(`${TEST_URL}?1`);
const withOverride = await fetch(`${TEST_URL}?2`, {
sharedCache: { cacheControlOverride: 's-maxage=300' },
});

expect(callCount).toBe(2);
expect(await noOverride.clone().text()).toBe('call 1');
expect(await withOverride.clone().text()).toBe('call 2');

// Both should have cache status
expect(noOverride.headers.has('x-cache-status')).toBe(true);
expect(withOverride.headers.has('x-cache-status')).toBe(true);

// Only the one with override should have cache-control
expect(noOverride.headers.has('cache-control')).toBe(false);
expect(withOverride.headers.get('cache-control')).toBe('s-maxage=300');
});
});
});

/**
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/fix-readonly-headers-comprehensive.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@web-widget/shared-cache": patch
---

fix: Comprehensive fix for readonly headers modification issues

This change completely resolves the readonly headers modification problems that were partially addressed in the previous fix. The solution includes:

**New Features:**
- Added `src/utils/response.ts` with intelligent response utilities:
- `modifyResponseHeaders()`: Smart header modification with readonly fallback
- `setResponseHeader()`: Convenient single header setting function

**Bug Fixes:**
- Fixed `setCacheStatus()` function in `fetch.ts` to properly handle readonly headers
- Optimized `createInterceptor()` function to avoid unnecessary Response cloning
- Ensured `cache.ts` uses safe header modification patterns
- All header modifications now gracefully handle readonly scenarios

**Performance Improvements:**
- No Response cloning when no header overrides are configured
- Direct header modification when possible (for mutable headers)
- Smart fallback to new Response creation only when necessary
- Significant performance improvement for common use cases

**Testing:**
- Added comprehensive unit tests for response utilities (10 new tests)
- Added specific tests for createInterceptor readonly headers handling (6 new tests)
- All 258 tests pass with 93.93% code coverage
- Tests cover edge cases, error scenarios, and performance considerations

This fix ensures that header modifications work reliably across all environments (browser, Node.js, etc.) while maintaining optimal performance by avoiding unnecessary object creation.
291 changes: 237 additions & 54 deletions src/fetch.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -192,59 +192,11 @@ describe('HTTP Header Override Tests', () => {
expect(res.headers.get('x-cache-status')).toBe(DYNAMIC);
});

it('should handle read-only headers by creating new Response object', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);

// Create a response with read-only headers to simulate browser environment
const originalResponse = new Response('test content', {
status: 200,
headers: {
'content-type': 'text/plain',
'cache-control': 'max-age=300',
},
});

// Make headers read-only by freezing the headers object
const readOnlyHeaders = new Headers(originalResponse.headers);
Object.freeze(readOnlyHeaders);

// Create a response with read-only headers
const responseWithReadOnlyHeaders = new Response(originalResponse.body, {
status: originalResponse.status,
statusText: originalResponse.statusText,
headers: readOnlyHeaders,
});

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return responseWithReadOnlyHeaders;
},
});

// This should not throw an error and should properly apply header overrides
const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=600, must-revalidate',
varyOverride: 'accept-language',
},
});

// Verify that headers are properly overridden despite original being read-only
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600, must-revalidate'
);
expect(res.headers.get('vary')).toBe('accept-language');
expect(res.headers.get('x-cache-status')).toBe(MISS);
expect(await res.text()).toBe('test content');
});

it('should preserve all Response properties when creating new Response with modified headers', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'test response body with special characters: 测试内容';
const originalBody =
'test response body with special characters: 测试内容';
const originalResponse = new Response(originalBody, {
status: 201,
statusText: 'Created',
Expand DownExpand Up@@ -283,9 +235,13 @@ describe('HTTP Header Override Tests', () => {
expect(res.status).toBe(201);
expect(res.statusText).toBe('Created');
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8');
expect(res.headers.get('content-length')).toBe(originalBody.length.toString());
expect(res.headers.get('content-length')).toBe(
originalBody.length.toString()
);
expect(res.headers.get('x-custom-header')).toBe('custom-value');
expect(res.headers.get('cache-control')).toBe('max-age=300, s-maxage=600');
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600'
);
expect(res.headers.get('vary')).toBe('user-agent');
expect(res.headers.get('x-cache-status')).toBe(MISS);
expect(await res.text()).toBe(originalBody);
Expand All@@ -301,7 +257,7 @@ describe('HTTP Header Override Tests', () => {
headers: {
'content-type': 'application/json',
'cache-control': 'max-age=300, public',
'etag': '"abc123"',
etag: '"abc123"',
'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT',
},
});
Expand DownExpand Up@@ -333,7 +289,9 @@ describe('HTTP Header Override Tests', () => {
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/json');
expect(res.headers.get('etag')).toBe('"abc123"');
expect(res.headers.get('last-modified')).toBe('Wed, 21 Oct 2015 07:28:00 GMT');
expect(res.headers.get('last-modified')).toBe(
'Wed, 21 Oct 2015 07:28:00 GMT'
);
expect(res.headers.get('cache-control')).toBe(
'max-age=300, public, s-maxage=600, must-revalidate'
);
Expand All@@ -344,6 +302,231 @@ describe('HTTP Header Override Tests', () => {
expect(await res.text()).toBe('multi-header test');
});
});

describe('createInterceptor Header Override Bug Fixes', () => {
it('should handle responses correctly when no header overrides are configured', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'no override test';

let interceptorCalled = false;
let originalResponse: Response;
const fetch = createSharedCacheFetch(cache, {
async fetch() {
originalResponse = new Response(originalBody, {
status: 200,
headers: { 'content-type': 'text/plain' },
});

// Mock the response to detect if interceptor creates new object
const originalClone = originalResponse.clone.bind(originalResponse);
originalResponse.clone = () => {
interceptorCalled = true;
return originalClone();
};

return originalResponse;
},
});

const res = await fetch(TEST_URL);

// Interceptor should not have unnecessarily cloned when no overrides
expect(interceptorCalled).toBe(false);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.has('x-cache-status')).toBe(true); // Cache status should be added
expect(await res.text()).toBe(originalBody);
});

it('should apply header overrides correctly', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'override test';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return new Response(originalBody, {
status: 200,
headers: { 'content-type': 'text/plain' },
});
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=300',
},
});

expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.get('cache-control')).toBe('s-maxage=300');
expect(res.headers.has('x-cache-status')).toBe(true);
expect(await res.text()).toBe(originalBody);
});

it('should handle readonly headers without throwing errors', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const originalBody = 'readonly interceptor test';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
const response = new Response(originalBody, {
status: 200,
headers: { 'content-type': 'application/json' },
});

// Make headers readonly by mocking set method to throw
response.headers.set = () => {
throw new Error('Cannot modify readonly headers');
};

return response;
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=600',
varyOverride: 'accept-language',
},
});

// Should successfully handle readonly headers
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/json');
expect(res.headers.get('cache-control')).toBe('s-maxage=600');
expect(res.headers.get('vary')).toBe('accept-language');
expect(await res.text()).toBe(originalBody);
});

it('should not modify response on non-ok status', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);

let headerModificationAttempted = false;
const fetch = createSharedCacheFetch(cache, {
async fetch() {
const originalErrorResponse = new Response('Not Found', {
status: 404,
headers: { 'content-type': 'text/plain' },
});

// Track if headers.set is called (which would indicate header modification)
const originalSet = originalErrorResponse.headers.set.bind(
originalErrorResponse.headers
);
originalErrorResponse.headers.set = (name, value) => {
if (name !== 'x-cache-status') {
// setCacheStatus might still be called
headerModificationAttempted = true;
}
return originalSet(name, value);
};

return originalErrorResponse;
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 's-maxage=300',
varyOverride: 'accept',
},
});

// Headers should not be modified by interceptor for non-ok responses
expect(headerModificationAttempted).toBe(false);
expect(res.status).toBe(404);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.has('cache-control')).toBe(false);
expect(res.headers.has('vary')).toBe(false);
});

it('should preserve all response properties when handling header overrides', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const complexBody = 'complex response: 测试内容 with special chars';

const fetch = createSharedCacheFetch(cache, {
async fetch() {
return new Response(complexBody, {
status: 201,
statusText: 'Created',
headers: {
'content-type': 'text/plain; charset=utf-8',
'content-length': complexBody.length.toString(),
etag: '"interceptor-test"',
'last-modified': 'Thu, 01 Dec 2022 12:00:00 GMT',
'x-original': 'true',
},
});
},
});

const res = await fetch(TEST_URL, {
sharedCache: {
cacheControlOverride: 'max-age=300, s-maxage=600',
varyOverride: 'accept-encoding, user-agent',
},
});

// Verify all original properties are preserved
expect(res.status).toBe(201);
expect(res.statusText).toBe('Created');
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8');
expect(res.headers.get('content-length')).toBe(
complexBody.length.toString()
);
expect(res.headers.get('etag')).toBe('"interceptor-test"');
expect(res.headers.get('last-modified')).toBe(
'Thu, 01 Dec 2022 12:00:00 GMT'
);
expect(res.headers.get('x-original')).toBe('true');

// Verify overrides are applied
expect(res.headers.get('cache-control')).toBe(
'max-age=300, s-maxage=600'
);
expect(res.headers.get('vary')).toBe('accept-encoding, user-agent');

expect(await res.text()).toBe(complexBody);
});

it('should correctly handle multiple requests with different override configurations', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
let callCount = 0;

const fetch = createSharedCacheFetch(cache, {
async fetch() {
callCount++;
return new Response(`call ${callCount}`, {
status: 200,
headers: { 'content-type': 'text/plain' },
});
},
});

// Test multiple calls with and without overrides
const noOverride = await fetch(`${TEST_URL}?1`);
const withOverride = await fetch(`${TEST_URL}?2`, {
sharedCache: { cacheControlOverride: 's-maxage=300' },
});

expect(callCount).toBe(2);
expect(await noOverride.clone().text()).toBe('call 1');
expect(await withOverride.clone().text()).toBe('call 2');

// Both should have cache status
expect(noOverride.headers.has('x-cache-status')).toBe(true);
expect(withOverride.headers.has('x-cache-status')).toBe(true);

// Only the one with override should have cache-control
expect(noOverride.headers.has('cache-control')).toBe(false);
expect(withOverride.headers.get('cache-control')).toBe('s-maxage=300');
});
});
});

/**
Expand Down
Loading