@adobe/spacecat-shared-tokowaka-client 1.5.2 → 1.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [@adobe/spacecat-shared-tokowaka-client-v1.5.4](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.5.3...@adobe/spacecat-shared-tokowaka-client-v1.5.4) (2026-01-21)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * edge preview api headers handling ([#1276](https://github.com/adobe/spacecat-shared/issues/1276)) ([e2a7ba8](https://github.com/adobe/spacecat-shared/commit/e2a7ba88df77ee7e369d39e623966b5760ac9d12))
7
+
8
+ # [@adobe/spacecat-shared-tokowaka-client-v1.5.3](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.5.2...@adobe/spacecat-shared-tokowaka-client-v1.5.3) (2026-01-21)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * update tokowaka config ([#1275](https://github.com/adobe/spacecat-shared/issues/1275)) ([06cedcd](https://github.com/adobe/spacecat-shared/commit/06cedcd3d6f5956d895f7dedb7579d2eefffe58e))
14
+
1
15
  # [@adobe/spacecat-shared-tokowaka-client-v1.5.2](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.5.1...@adobe/spacecat-shared-tokowaka-client-v1.5.2) (2026-01-21)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-tokowaka-client",
3
- "version": "1.5.2",
3
+ "version": "1.5.4",
4
4
  "description": "Tokowaka Client for SpaceCat - Edge optimization config management",
5
5
  "type": "module",
6
6
  "engines": {
package/src/index.js CHANGED
@@ -349,15 +349,21 @@ class TokowakaClient {
349
349
 
350
350
  // dont override api keys
351
351
  // if patches exist, they cannot reset to empty object
352
+ const hasForceFail = options.forceFail !== undefined
353
+ || existingMetaconfig.forceFail !== undefined;
354
+ const forceFail = options.forceFail
355
+ ?? existingMetaconfig.forceFail
356
+ ?? false;
357
+
352
358
  const metaconfig = {
353
359
  siteId,
354
360
  apiKeys: existingMetaconfig.apiKeys,
355
- tokowakaEnabled: options.tokowakaEnabled ?? true,
356
- enhancements: options.enhancements ?? true,
361
+ tokowakaEnabled: options.tokowakaEnabled ?? existingMetaconfig.tokowakaEnabled ?? true,
362
+ enhancements: options.enhancements ?? existingMetaconfig.enhancements ?? true,
357
363
  patches: isNonEmptyObject(options.patches)
358
364
  ? options.patches
359
365
  : (existingMetaconfig.patches ?? {}),
360
- ...(options.forceFail && { forceFail: true }),
366
+ ...(hasForceFail && { forceFail }),
361
367
  };
362
368
 
363
369
  const s3Path = await this.uploadMetaconfig(url, metaconfig);
@@ -24,8 +24,11 @@ function sleep(ms) {
24
24
  }
25
25
 
26
26
  /**
27
- * Makes an HTTP request with retry logic
28
- * Retries until max retries are exhausted or x-edge-optimize-cache header is present
27
+ * Makes an HTTP request with retry logic for both original and optimized HTML.
28
+ * Header validation logic (same for both):
29
+ * - No proxy AND no cache header: Return response immediately (success)
30
+ * - Proxy header present BUT no cache header: Retry until cache header found
31
+ * - Cache header present (regardless of proxy): Return response (success)
29
32
  * @param {string} url - URL to fetch
30
33
  * @param {Object} options - Fetch options
31
34
  * @param {number} maxRetries - Maximum number of retries
@@ -48,23 +51,35 @@ async function fetchWithRetry(url, options, maxRetries, retryDelayMs, log, fetch
48
51
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
49
52
  }
50
53
 
51
- // Check for x-edge-optimize-cache header - if present, stop retrying
54
+ // Check for edge optimize headers
52
55
  const cacheHeader = response.headers.get('x-edge-optimize-cache');
56
+ const proxyHeader = response.headers.get('x-edge-optimize-proxy');
57
+
58
+ log.debug(`Headers - cache: ${cacheHeader || 'none'}, proxy: ${proxyHeader || 'none'}`);
59
+
60
+ // Case 1: Cache header present (regardless of proxy) -> Success
53
61
  if (cacheHeader) {
54
62
  log.debug(`Cache header found (x-edge-optimize-cache: ${cacheHeader}), stopping retry logic`);
55
63
  return response;
56
64
  }
57
65
 
58
- // If no cache header and we haven't exhausted retries, continue
66
+ // Case 2: No cache header AND no proxy header -> Success (return immediately)
67
+ if (!proxyHeader) {
68
+ log.debug('No edge optimize headers found, proceeding as successful flow');
69
+ return response;
70
+ }
71
+
72
+ // Case 3: Proxy header present BUT no cache header -> Retry until cache found
73
+ log.debug('Proxy header present without cache header, will retry...');
74
+
75
+ // If we haven't exhausted retries, continue
59
76
  if (attempt < maxRetries + 1) {
60
- log.debug(`No cache header found on attempt ${attempt}, will retry...`);
61
- // Wait before retrying
62
77
  log.debug(`Waiting ${retryDelayMs}ms before retry...`);
63
78
  // eslint-disable-next-line no-await-in-loop
64
79
  await sleep(retryDelayMs);
65
80
  } else {
66
- // Last attempt without cache header - throw error
67
- log.error(`Max retries (${maxRetries}) exhausted without cache header`);
81
+ // Last attempt - throw error
82
+ log.error(`Max retries (${maxRetries}) exhausted. Proxy header present but cache header not found`);
68
83
  throw new Error(`Cache header (x-edge-optimize-cache) not found after ${maxRetries} retries`);
69
84
  }
70
85
  } catch (error) {
@@ -145,6 +160,7 @@ export async function fetchHtmlWithWarmup(
145
160
  'x-forwarded-host': forwardedHost,
146
161
  'x-edge-optimize-api-key': apiKey,
147
162
  'x-edge-optimize-url': urlPath,
163
+ 'Accept-Encoding': 'identity', // Disable compression to avoid content-length: 0 issue
148
164
  };
149
165
 
150
166
  if (isOptimized) {
@@ -598,8 +598,9 @@ describe('TokowakaClient', () => {
598
598
  expect(result).to.have.property('siteId', siteId);
599
599
  expect(result).to.have.property('apiKeys');
600
600
  expect(result.apiKeys).to.deep.equal(['existing-api-key-123']);
601
- expect(result).to.have.property('tokowakaEnabled', true);
602
- expect(result).to.have.property('enhancements', true);
601
+ // Should preserve existing metaconfig values when options not provided
602
+ expect(result).to.have.property('tokowakaEnabled', false);
603
+ expect(result).to.have.property('enhancements', false);
603
604
  expect(result.patches).to.deep.equal({ 'existing-patch': 'value' });
604
605
  expect(result).to.not.have.property('forceFail');
605
606
  });
@@ -611,7 +612,7 @@ describe('TokowakaClient', () => {
611
612
  const result = await client.updateMetaconfig(url, siteId, { tokowakaEnabled: false });
612
613
 
613
614
  expect(result).to.have.property('tokowakaEnabled', false);
614
- expect(result).to.have.property('enhancements', true);
615
+ expect(result).to.have.property('enhancements', false);
615
616
  expect(result.patches).to.deep.equal({ 'existing-patch': 'value' });
616
617
  expect(result).to.not.have.property('forceFail');
617
618
  });
@@ -623,8 +624,9 @@ describe('TokowakaClient', () => {
623
624
  const result = await client.updateMetaconfig(url, siteId, { tokowakaEnabled: true });
624
625
 
625
626
  expect(result).to.have.property('tokowakaEnabled', true);
626
- expect(result).to.have.property('enhancements', true);
627
+ expect(result).to.have.property('enhancements', false);
627
628
  expect(result.patches).to.deep.equal({ 'existing-patch': 'value' });
629
+ expect(result).to.not.have.property('forceFail');
628
630
  });
629
631
 
630
632
  it('should update metaconfig with enhancements set to false', async () => {
@@ -633,9 +635,10 @@ describe('TokowakaClient', () => {
633
635
 
634
636
  const result = await client.updateMetaconfig(url, siteId, { enhancements: false });
635
637
 
636
- expect(result).to.have.property('tokowakaEnabled', true);
638
+ expect(result).to.have.property('tokowakaEnabled', false);
637
639
  expect(result).to.have.property('enhancements', false);
638
640
  expect(result.patches).to.deep.equal({ 'existing-patch': 'value' });
641
+ expect(result).to.not.have.property('forceFail');
639
642
  });
640
643
 
641
644
  it('should update metaconfig with enhancements set to true explicitly', async () => {
@@ -644,9 +647,10 @@ describe('TokowakaClient', () => {
644
647
 
645
648
  const result = await client.updateMetaconfig(url, siteId, { enhancements: true });
646
649
 
647
- expect(result).to.have.property('tokowakaEnabled', true);
650
+ expect(result).to.have.property('tokowakaEnabled', false);
648
651
  expect(result).to.have.property('enhancements', true);
649
652
  expect(result.patches).to.deep.equal({ 'existing-patch': 'value' });
653
+ expect(result).to.not.have.property('forceFail');
650
654
  });
651
655
 
652
656
  it('should override patches when non-empty patches object is provided', async () => {
@@ -708,13 +712,13 @@ describe('TokowakaClient', () => {
708
712
  expect(result).to.have.property('forceFail', true);
709
713
  });
710
714
 
711
- it('should not include forceFail when set to false', async () => {
715
+ it('should include forceFail when set to false', async () => {
712
716
  const siteId = 'site-789';
713
717
  const url = 'https://example.com';
714
718
 
715
719
  const result = await client.updateMetaconfig(url, siteId, { forceFail: false });
716
720
 
717
- expect(result).to.not.have.property('forceFail');
721
+ expect(result).to.have.property('forceFail', false);
718
722
  });
719
723
 
720
724
  it('should not include forceFail when undefined', async () => {
@@ -726,6 +730,38 @@ describe('TokowakaClient', () => {
726
730
  expect(result).to.not.have.property('forceFail');
727
731
  });
728
732
 
733
+ it('should use forceFail as false when options.forceFail is null and existingMetaconfig has no forceFail', async () => {
734
+ const siteId = 'site-789';
735
+ const url = 'https://example.com';
736
+
737
+ const result = await client.updateMetaconfig(url, siteId, { forceFail: null });
738
+
739
+ expect(result).to.have.property('forceFail', false);
740
+ });
741
+
742
+ it('should preserve existingMetaconfig forceFail when options.forceFail is null', async () => {
743
+ const configWithForceFail = {
744
+ siteId: 'site-456',
745
+ apiKeys: ['existing-api-key-123'],
746
+ tokowakaEnabled: true,
747
+ enhancements: true,
748
+ patches: {},
749
+ forceFail: true,
750
+ };
751
+ s3Client.send.onFirstCall().resolves({
752
+ Body: {
753
+ transformToString: sinon.stub().resolves(JSON.stringify(configWithForceFail)),
754
+ },
755
+ });
756
+
757
+ const siteId = 'site-789';
758
+ const url = 'https://example.com';
759
+
760
+ const result = await client.updateMetaconfig(url, siteId, { forceFail: null });
761
+
762
+ expect(result).to.have.property('forceFail', true);
763
+ });
764
+
729
765
  it('should update metaconfig with multiple options', async () => {
730
766
  const siteId = 'site-789';
731
767
  const url = 'https://example.com';
@@ -855,6 +891,259 @@ describe('TokowakaClient', () => {
855
891
 
856
892
  expect(result.patches).to.deep.equal(singlePatch);
857
893
  });
894
+
895
+ it('should preserve existing patches when options.patches is null', async () => {
896
+ const siteId = 'site-789';
897
+ const url = 'https://example.com';
898
+
899
+ const result = await client.updateMetaconfig(url, siteId, { patches: null });
900
+
901
+ expect(result.patches).to.deep.equal({ 'existing-patch': 'value' });
902
+ });
903
+
904
+ it('should preserve tokowakaEnabled=true from existingMetaconfig when options not provided', async () => {
905
+ const configWithTokowakaEnabled = {
906
+ siteId: 'site-456',
907
+ apiKeys: ['existing-api-key-123'],
908
+ tokowakaEnabled: true,
909
+ enhancements: false,
910
+ patches: {},
911
+ };
912
+ s3Client.send.onFirstCall().resolves({
913
+ Body: {
914
+ transformToString: sinon.stub().resolves(JSON.stringify(configWithTokowakaEnabled)),
915
+ },
916
+ });
917
+
918
+ const siteId = 'site-789';
919
+ const url = 'https://example.com';
920
+
921
+ const result = await client.updateMetaconfig(url, siteId);
922
+
923
+ expect(result).to.have.property('tokowakaEnabled', true);
924
+ });
925
+
926
+ it('should preserve enhancements=true from existingMetaconfig when options not provided', async () => {
927
+ const configWithEnhancements = {
928
+ siteId: 'site-456',
929
+ apiKeys: ['existing-api-key-123'],
930
+ tokowakaEnabled: false,
931
+ enhancements: true,
932
+ patches: {},
933
+ };
934
+ s3Client.send.onFirstCall().resolves({
935
+ Body: {
936
+ transformToString: sinon.stub().resolves(JSON.stringify(configWithEnhancements)),
937
+ },
938
+ });
939
+
940
+ const siteId = 'site-789';
941
+ const url = 'https://example.com';
942
+
943
+ const result = await client.updateMetaconfig(url, siteId);
944
+
945
+ expect(result).to.have.property('enhancements', true);
946
+ });
947
+
948
+ it('should default tokowakaEnabled to true when not in existingMetaconfig or options', async () => {
949
+ const configWithoutTokowakaEnabled = {
950
+ siteId: 'site-456',
951
+ apiKeys: ['existing-api-key-123'],
952
+ enhancements: false,
953
+ patches: {},
954
+ };
955
+ s3Client.send.onFirstCall().resolves({
956
+ Body: {
957
+ transformToString: sinon.stub().resolves(JSON.stringify(configWithoutTokowakaEnabled)),
958
+ },
959
+ });
960
+
961
+ const siteId = 'site-789';
962
+ const url = 'https://example.com';
963
+
964
+ const result = await client.updateMetaconfig(url, siteId);
965
+
966
+ expect(result).to.have.property('tokowakaEnabled', true);
967
+ });
968
+
969
+ it('should default enhancements to true when not in existingMetaconfig or options', async () => {
970
+ const configWithoutEnhancements = {
971
+ siteId: 'site-456',
972
+ apiKeys: ['existing-api-key-123'],
973
+ tokowakaEnabled: false,
974
+ patches: {},
975
+ };
976
+ s3Client.send.onFirstCall().resolves({
977
+ Body: {
978
+ transformToString: sinon.stub().resolves(JSON.stringify(configWithoutEnhancements)),
979
+ },
980
+ });
981
+
982
+ const siteId = 'site-789';
983
+ const url = 'https://example.com';
984
+
985
+ const result = await client.updateMetaconfig(url, siteId);
986
+
987
+ expect(result).to.have.property('enhancements', true);
988
+ });
989
+
990
+ it('should preserve forceFail=true from existingMetaconfig when options not provided', async () => {
991
+ const configWithForceFail = {
992
+ siteId: 'site-456',
993
+ apiKeys: ['existing-api-key-123'],
994
+ tokowakaEnabled: true,
995
+ enhancements: true,
996
+ patches: {},
997
+ forceFail: true,
998
+ };
999
+ s3Client.send.onFirstCall().resolves({
1000
+ Body: {
1001
+ transformToString: sinon.stub().resolves(JSON.stringify(configWithForceFail)),
1002
+ },
1003
+ });
1004
+
1005
+ const siteId = 'site-789';
1006
+ const url = 'https://example.com';
1007
+
1008
+ const result = await client.updateMetaconfig(url, siteId);
1009
+
1010
+ expect(result).to.have.property('forceFail', true);
1011
+ });
1012
+
1013
+ it('should override existingMetaconfig forceFail when explicitly set to false in options', async () => {
1014
+ const configWithForceFail = {
1015
+ siteId: 'site-456',
1016
+ apiKeys: ['existing-api-key-123'],
1017
+ tokowakaEnabled: true,
1018
+ enhancements: true,
1019
+ patches: {},
1020
+ forceFail: true,
1021
+ };
1022
+ s3Client.send.onFirstCall().resolves({
1023
+ Body: {
1024
+ transformToString: sinon.stub().resolves(JSON.stringify(configWithForceFail)),
1025
+ },
1026
+ });
1027
+
1028
+ const siteId = 'site-789';
1029
+ const url = 'https://example.com';
1030
+
1031
+ const result = await client.updateMetaconfig(url, siteId, { forceFail: false });
1032
+
1033
+ expect(result).to.have.property('forceFail', false);
1034
+ });
1035
+
1036
+ it('should override existingMetaconfig forceFail when explicitly set to true in options', async () => {
1037
+ const configWithoutForceFail = {
1038
+ siteId: 'site-456',
1039
+ apiKeys: ['existing-api-key-123'],
1040
+ tokowakaEnabled: true,
1041
+ enhancements: true,
1042
+ patches: {},
1043
+ forceFail: false,
1044
+ };
1045
+ s3Client.send.onFirstCall().resolves({
1046
+ Body: {
1047
+ transformToString: sinon.stub().resolves(JSON.stringify(configWithoutForceFail)),
1048
+ },
1049
+ });
1050
+
1051
+ const siteId = 'site-789';
1052
+ const url = 'https://example.com';
1053
+
1054
+ const result = await client.updateMetaconfig(url, siteId, { forceFail: true });
1055
+
1056
+ expect(result).to.have.property('forceFail', true);
1057
+ });
1058
+
1059
+ it('should preserve forceFail=false from existingMetaconfig when options not provided', async () => {
1060
+ const configWithForceFail = {
1061
+ siteId: 'site-456',
1062
+ apiKeys: ['existing-api-key-123'],
1063
+ tokowakaEnabled: true,
1064
+ enhancements: true,
1065
+ patches: {},
1066
+ forceFail: false,
1067
+ };
1068
+ s3Client.send.onFirstCall().resolves({
1069
+ Body: {
1070
+ transformToString: sinon.stub().resolves(JSON.stringify(configWithForceFail)),
1071
+ },
1072
+ });
1073
+
1074
+ const siteId = 'site-789';
1075
+ const url = 'https://example.com';
1076
+
1077
+ const result = await client.updateMetaconfig(url, siteId);
1078
+
1079
+ expect(result).to.have.property('forceFail', false);
1080
+ });
1081
+
1082
+ it('should override existingMetaconfig tokowakaEnabled=false when explicitly set to true', async () => {
1083
+ const siteId = 'site-789';
1084
+ const url = 'https://example.com';
1085
+ // existingMetaconfig has tokowakaEnabled: false
1086
+
1087
+ const result = await client.updateMetaconfig(url, siteId, { tokowakaEnabled: true });
1088
+
1089
+ expect(result).to.have.property('tokowakaEnabled', true);
1090
+ });
1091
+
1092
+ it('should override existingMetaconfig enhancements=false when explicitly set to true', async () => {
1093
+ const siteId = 'site-789';
1094
+ const url = 'https://example.com';
1095
+ // existingMetaconfig has enhancements: false
1096
+
1097
+ const result = await client.updateMetaconfig(url, siteId, { enhancements: true });
1098
+
1099
+ expect(result).to.have.property('enhancements', true);
1100
+ });
1101
+
1102
+ it('should handle case where options.forceFail and existingMetaconfig.forceFail are both true', async () => {
1103
+ const configWithForceFail = {
1104
+ siteId: 'site-456',
1105
+ apiKeys: ['existing-api-key-123'],
1106
+ tokowakaEnabled: true,
1107
+ enhancements: true,
1108
+ patches: {},
1109
+ forceFail: true,
1110
+ };
1111
+ s3Client.send.onFirstCall().resolves({
1112
+ Body: {
1113
+ transformToString: sinon.stub().resolves(JSON.stringify(configWithForceFail)),
1114
+ },
1115
+ });
1116
+
1117
+ const siteId = 'site-789';
1118
+ const url = 'https://example.com';
1119
+
1120
+ const result = await client.updateMetaconfig(url, siteId, { forceFail: true });
1121
+
1122
+ expect(result).to.have.property('forceFail', true);
1123
+ });
1124
+
1125
+ it('should handle case where options.forceFail and existingMetaconfig.forceFail are both false', async () => {
1126
+ const configWithForceFail = {
1127
+ siteId: 'site-456',
1128
+ apiKeys: ['existing-api-key-123'],
1129
+ tokowakaEnabled: true,
1130
+ enhancements: true,
1131
+ patches: {},
1132
+ forceFail: false,
1133
+ };
1134
+ s3Client.send.onFirstCall().resolves({
1135
+ Body: {
1136
+ transformToString: sinon.stub().resolves(JSON.stringify(configWithForceFail)),
1137
+ },
1138
+ });
1139
+
1140
+ const siteId = 'site-789';
1141
+ const url = 'https://example.com';
1142
+
1143
+ const result = await client.updateMetaconfig(url, siteId);
1144
+
1145
+ expect(result).to.have.property('forceFail', false);
1146
+ });
858
1147
  });
859
1148
 
860
1149
  describe('uploadConfig', () => {
@@ -154,6 +154,150 @@ describe('HTML Utils', () => {
154
154
  expect(actualUrl).to.equal('https://edge.example.com/page?tokowakaPreview=true');
155
155
  });
156
156
 
157
+ it('should return immediately for optimized HTML when no headers present', async () => {
158
+ // Warmup succeeds
159
+ fetchStub.onCall(0).resolves({
160
+ ok: true,
161
+ status: 200,
162
+ statusText: 'OK',
163
+ headers: {
164
+ get: () => null,
165
+ },
166
+ text: async () => 'warmup',
167
+ });
168
+ // First actual call - no headers, should succeed
169
+ fetchStub.onCall(1).resolves({
170
+ ok: true,
171
+ status: 200,
172
+ statusText: 'OK',
173
+ headers: {
174
+ get: () => null,
175
+ },
176
+ text: async () => '<html>No headers</html>',
177
+ });
178
+
179
+ const html = await fetchHtmlWithWarmup(
180
+ 'https://example.com/page',
181
+ 'api-key',
182
+ 'host',
183
+ 'https://edge.example.com',
184
+ log,
185
+ true, // isOptimized
186
+ { warmupDelayMs: 0, maxRetries: 3, retryDelayMs: 0 },
187
+ );
188
+
189
+ expect(html).to.equal('<html>No headers</html>');
190
+ // Should succeed immediately (warmup + 1 attempt)
191
+ expect(fetchStub.callCount).to.equal(2);
192
+ });
193
+
194
+ it('should throw error for optimized HTML when proxy present but cache not found after retries', async () => {
195
+ // Warmup succeeds
196
+ fetchStub.onCall(0).resolves({
197
+ ok: true,
198
+ status: 200,
199
+ statusText: 'OK',
200
+ headers: {
201
+ get: () => null,
202
+ },
203
+ text: async () => 'warmup',
204
+ });
205
+ // All actual calls have proxy but no cache header
206
+ fetchStub.onCall(1).resolves({
207
+ ok: true,
208
+ status: 200,
209
+ statusText: 'OK',
210
+ headers: {
211
+ get: (name) => (name === 'x-edge-optimize-proxy' ? 'true' : null),
212
+ },
213
+ text: async () => '<html>Proxy only 1</html>',
214
+ });
215
+ fetchStub.onCall(2).resolves({
216
+ ok: true,
217
+ status: 200,
218
+ statusText: 'OK',
219
+ headers: {
220
+ get: (name) => (name === 'x-edge-optimize-proxy' ? 'true' : null),
221
+ },
222
+ text: async () => '<html>Proxy only 2</html>',
223
+ });
224
+ fetchStub.onCall(3).resolves({
225
+ ok: true,
226
+ status: 200,
227
+ statusText: 'OK',
228
+ headers: {
229
+ get: (name) => (name === 'x-edge-optimize-proxy' ? 'true' : null),
230
+ },
231
+ text: async () => '<html>Proxy only 3</html>',
232
+ });
233
+
234
+ try {
235
+ await fetchHtmlWithWarmup(
236
+ 'https://example.com/page',
237
+ 'api-key',
238
+ 'host',
239
+ 'https://edge.example.com',
240
+ log,
241
+ true, // isOptimized
242
+ { warmupDelayMs: 0, maxRetries: 2, retryDelayMs: 0 },
243
+ );
244
+ expect.fail('Should have thrown error');
245
+ } catch (error) {
246
+ expect(error.message).to.include('Failed to fetch optimized HTML');
247
+ expect(error.message).to.include('Cache header (x-edge-optimize-cache) not found after 2 retries');
248
+ }
249
+
250
+ // Should have tried 3 times (initial + 2 retries) plus warmup
251
+ expect(fetchStub.callCount).to.equal(4);
252
+ });
253
+
254
+ it('should retry for optimized HTML when proxy present until cache found', async () => {
255
+ // Warmup succeeds
256
+ fetchStub.onCall(0).resolves({
257
+ ok: true,
258
+ status: 200,
259
+ statusText: 'OK',
260
+ headers: {
261
+ get: () => null,
262
+ },
263
+ text: async () => 'warmup',
264
+ });
265
+ // First call has only proxy header - should retry
266
+ fetchStub.onCall(1).resolves({
267
+ ok: true,
268
+ status: 200,
269
+ statusText: 'OK',
270
+ headers: {
271
+ get: (name) => (name === 'x-edge-optimize-proxy' ? 'true' : null),
272
+ },
273
+ text: async () => '<html>Proxy only</html>',
274
+ });
275
+ // Second call has cache header (proxy might still be there) - should succeed
276
+ fetchStub.onCall(2).resolves({
277
+ ok: true,
278
+ status: 200,
279
+ statusText: 'OK',
280
+ headers: {
281
+ get: (name) => (name === 'x-edge-optimize-cache' ? 'HIT' : null),
282
+ },
283
+ text: async () => '<html>Cached HTML</html>',
284
+ });
285
+
286
+ const html = await fetchHtmlWithWarmup(
287
+ 'https://example.com/page',
288
+ 'api-key',
289
+ 'host',
290
+ 'https://edge.example.com',
291
+ log,
292
+ true, // isOptimized
293
+ { warmupDelayMs: 0, maxRetries: 3, retryDelayMs: 0 },
294
+ );
295
+
296
+ expect(html).to.equal('<html>Cached HTML</html>');
297
+ // Should retry when only proxy present (warmup + 2 attempts)
298
+ expect(fetchStub.callCount).to.equal(3);
299
+ });
300
+
157
301
  it('should throw error when HTTP response is not ok', async () => {
158
302
  // Warmup succeeds
159
303
  fetchStub.onCall(0).resolves({
@@ -285,7 +429,7 @@ describe('HTML Utils', () => {
285
429
  }
286
430
  });
287
431
 
288
- it('should stop retrying when x-edge-optimize-cache header is found', async () => {
432
+ it('should return immediately when no edge optimize headers are present', async () => {
289
433
  // Warmup succeeds
290
434
  fetchStub.onCall(0).resolves({
291
435
  ok: true,
@@ -296,7 +440,7 @@ describe('HTML Utils', () => {
296
440
  },
297
441
  text: async () => 'warmup',
298
442
  });
299
- // First actual call - no cache header
443
+ // First actual call - no headers, should succeed immediately
300
444
  fetchStub.onCall(1).resolves({
301
445
  ok: true,
302
446
  status: 200,
@@ -304,17 +448,58 @@ describe('HTML Utils', () => {
304
448
  headers: {
305
449
  get: () => null,
306
450
  },
307
- text: async () => '<html>No cache</html>',
451
+ text: async () => '<html>No headers</html>',
452
+ });
453
+
454
+ const html = await fetchHtmlWithWarmup(
455
+ 'https://example.com/page',
456
+ 'api-key',
457
+ 'host',
458
+ 'https://edge.example.com',
459
+ log,
460
+ false,
461
+ { warmupDelayMs: 0, maxRetries: 3, retryDelayMs: 0 },
462
+ );
463
+
464
+ expect(html).to.equal('<html>No headers</html>');
465
+ // Should succeed immediately without retry (warmup + 1 attempt)
466
+ expect(fetchStub.callCount).to.equal(2);
467
+ });
468
+
469
+ it('should retry when proxy header present without cache until cache is found', async () => {
470
+ // Warmup succeeds
471
+ fetchStub.onCall(0).resolves({
472
+ ok: true,
473
+ status: 200,
474
+ statusText: 'OK',
475
+ headers: {
476
+ get: () => null,
477
+ },
478
+ text: async () => 'warmup',
479
+ });
480
+ // First call has proxy header but no cache - should retry
481
+ fetchStub.onCall(1).resolves({
482
+ ok: true,
483
+ status: 200,
484
+ statusText: 'OK',
485
+ headers: {
486
+ get: (name) => (name === 'x-edge-optimize-proxy' ? 'true' : null),
487
+ },
488
+ text: async () => '<html>Proxy only</html>',
308
489
  });
309
- // Second actual call - cache header found
490
+ // Second call has both headers - should succeed
310
491
  fetchStub.onCall(2).resolves({
311
492
  ok: true,
312
493
  status: 200,
313
494
  statusText: 'OK',
314
495
  headers: {
315
- get: (name) => (name === 'x-edge-optimize-cache' ? 'HIT' : null),
496
+ get: (name) => {
497
+ if (name === 'x-edge-optimize-cache') return 'HIT';
498
+ if (name === 'x-edge-optimize-proxy') return 'true';
499
+ return null;
500
+ },
316
501
  },
317
- text: async () => '<html>Cached HTML</html>',
502
+ text: async () => '<html>Both headers</html>',
318
503
  });
319
504
 
320
505
  const html = await fetchHtmlWithWarmup(
@@ -327,12 +512,12 @@ describe('HTML Utils', () => {
327
512
  { warmupDelayMs: 0, maxRetries: 3, retryDelayMs: 0 },
328
513
  );
329
514
 
330
- expect(html).to.equal('<html>Cached HTML</html>');
331
- // Should stop after finding cache header (warmup + 2 attempts)
515
+ expect(html).to.equal('<html>Both headers</html>');
516
+ // Should retry when only proxy present (warmup + 2 attempts)
332
517
  expect(fetchStub.callCount).to.equal(3);
333
518
  });
334
519
 
335
- it('should throw error when cache header not found after max retries', async () => {
520
+ it('should throw error when proxy header present but cache not found after max retries', async () => {
336
521
  // Warmup succeeds
337
522
  fetchStub.onCall(0).resolves({
338
523
  ok: true,
@@ -343,33 +528,33 @@ describe('HTML Utils', () => {
343
528
  },
344
529
  text: async () => 'warmup',
345
530
  });
346
- // All actual calls succeed but no cache header
531
+ // All actual calls have proxy but no cache header
347
532
  fetchStub.onCall(1).resolves({
348
533
  ok: true,
349
534
  status: 200,
350
535
  statusText: 'OK',
351
536
  headers: {
352
- get: () => null,
537
+ get: (name) => (name === 'x-edge-optimize-proxy' ? 'true' : null),
353
538
  },
354
- text: async () => '<html>No cache 1</html>',
539
+ text: async () => '<html>Proxy only 1</html>',
355
540
  });
356
541
  fetchStub.onCall(2).resolves({
357
542
  ok: true,
358
543
  status: 200,
359
544
  statusText: 'OK',
360
545
  headers: {
361
- get: () => null,
546
+ get: (name) => (name === 'x-edge-optimize-proxy' ? 'true' : null),
362
547
  },
363
- text: async () => '<html>No cache 2</html>',
548
+ text: async () => '<html>Proxy only 2</html>',
364
549
  });
365
550
  fetchStub.onCall(3).resolves({
366
551
  ok: true,
367
552
  status: 200,
368
553
  statusText: 'OK',
369
554
  headers: {
370
- get: () => null,
555
+ get: (name) => (name === 'x-edge-optimize-proxy' ? 'true' : null),
371
556
  },
372
- text: async () => '<html>No cache 3</html>',
557
+ text: async () => '<html>Proxy only 3</html>',
373
558
  });
374
559
 
375
560
  try {
@@ -428,6 +613,84 @@ describe('HTML Utils', () => {
428
613
  // Should not retry if cache header found on first attempt
429
614
  expect(fetchStub.callCount).to.equal(2); // warmup + 1 actual
430
615
  });
616
+
617
+ it('should return immediately when cache header is present (with or without proxy)', async () => {
618
+ // Warmup succeeds
619
+ fetchStub.onCall(0).resolves({
620
+ ok: true,
621
+ status: 200,
622
+ statusText: 'OK',
623
+ headers: {
624
+ get: () => null,
625
+ },
626
+ text: async () => 'warmup',
627
+ });
628
+ // First actual call has cache header (proxy may or may not be present)
629
+ fetchStub.onCall(1).resolves({
630
+ ok: true,
631
+ status: 200,
632
+ statusText: 'OK',
633
+ headers: {
634
+ get: (name) => {
635
+ if (name === 'x-edge-optimize-cache') return 'HIT';
636
+ if (name === 'x-edge-optimize-proxy') return 'true';
637
+ return null;
638
+ },
639
+ },
640
+ text: async () => '<html>Cache header present</html>',
641
+ });
642
+
643
+ const html = await fetchHtmlWithWarmup(
644
+ 'https://example.com/page',
645
+ 'api-key',
646
+ 'host',
647
+ 'https://edge.example.com',
648
+ log,
649
+ false,
650
+ { warmupDelayMs: 0, maxRetries: 3, retryDelayMs: 0 },
651
+ );
652
+
653
+ expect(html).to.equal('<html>Cache header present</html>');
654
+ // Should succeed immediately when cache header present (warmup + 1 attempt)
655
+ expect(fetchStub.callCount).to.equal(2);
656
+ });
657
+
658
+ it('should succeed when only cache header is present (no proxy header)', async () => {
659
+ // Warmup succeeds
660
+ fetchStub.onCall(0).resolves({
661
+ ok: true,
662
+ status: 200,
663
+ statusText: 'OK',
664
+ headers: {
665
+ get: () => null,
666
+ },
667
+ text: async () => 'warmup',
668
+ });
669
+ // First actual call has only cache header
670
+ fetchStub.onCall(1).resolves({
671
+ ok: true,
672
+ status: 200,
673
+ statusText: 'OK',
674
+ headers: {
675
+ get: (name) => (name === 'x-edge-optimize-cache' ? 'HIT' : null),
676
+ },
677
+ text: async () => '<html>Cache only HTML</html>',
678
+ });
679
+
680
+ const html = await fetchHtmlWithWarmup(
681
+ 'https://example.com/page',
682
+ 'api-key',
683
+ 'host',
684
+ 'https://edge.example.com',
685
+ log,
686
+ false,
687
+ { warmupDelayMs: 0, maxRetries: 3, retryDelayMs: 0 },
688
+ );
689
+
690
+ expect(html).to.equal('<html>Cache only HTML</html>');
691
+ // Should succeed immediately with cache header only (warmup + 1 attempt)
692
+ expect(fetchStub.callCount).to.equal(2);
693
+ });
431
694
  });
432
695
 
433
696
  describe('calculateForwardedHost', () => {