@bleedingdev/modern-js-runtime-extensions 3.9.0-ultramodern.9 → 3.9.3-ultramodern.2

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.
Files changed (124) hide show
  1. package/dist/cjs/{reactContext.js → localisedUrls/config.js} +14 -10
  2. package/dist/cjs/localisedUrls/index.js +95 -0
  3. package/dist/cjs/localisedUrls/normalise.js +87 -0
  4. package/dist/cjs/localisedUrls/pathname.js +68 -0
  5. package/dist/cjs/localisedUrls/patterns.js +214 -0
  6. package/dist/cjs/localisedUrls/redirect.js +104 -0
  7. package/dist/cjs/localisedUrls/resolve.js +78 -0
  8. package/dist/cjs/localisedUrls/routeIdentity.js +92 -0
  9. package/dist/cjs/localisedUrls/routes.js +217 -0
  10. package/dist/cjs/localisedUrls/types.js +18 -0
  11. package/dist/cjs/rendererHead.js +18 -5
  12. package/dist/cjs/request-policy/policyCore.js +1 -1
  13. package/dist/cjs/request-policy/transport.js +2 -2
  14. package/dist/cjs/routerState.js +85 -77
  15. package/dist/cjs/routerStatePlugin.js +63 -9
  16. package/dist/cjs/server-config/serverSource.js +18 -0
  17. package/dist/esm/localisedUrls/config.mjs +11 -0
  18. package/dist/esm/localisedUrls/index.mjs +8 -0
  19. package/dist/esm/localisedUrls/normalise.mjs +37 -0
  20. package/dist/esm/localisedUrls/pathname.mjs +24 -0
  21. package/dist/esm/localisedUrls/patterns.mjs +152 -0
  22. package/dist/esm/localisedUrls/redirect.mjs +57 -0
  23. package/dist/esm/localisedUrls/resolve.mjs +37 -0
  24. package/dist/esm/localisedUrls/routeIdentity.mjs +54 -0
  25. package/dist/esm/localisedUrls/routes.mjs +176 -0
  26. package/dist/esm/localisedUrls/types.mjs +0 -0
  27. package/dist/esm/rendererHead.mjs +14 -5
  28. package/dist/esm/request-policy/policyCore.mjs +1 -1
  29. package/dist/esm/request-policy/transport.mjs +2 -2
  30. package/dist/esm/routerState.mjs +81 -73
  31. package/dist/esm/routerStatePlugin.mjs +62 -11
  32. package/dist/esm/server-config/serverSource.mjs +0 -0
  33. package/dist/esm-node/localisedUrls/config.mjs +12 -0
  34. package/dist/esm-node/localisedUrls/index.mjs +9 -0
  35. package/dist/esm-node/localisedUrls/normalise.mjs +38 -0
  36. package/dist/esm-node/localisedUrls/pathname.mjs +25 -0
  37. package/dist/esm-node/localisedUrls/patterns.mjs +153 -0
  38. package/dist/esm-node/localisedUrls/redirect.mjs +58 -0
  39. package/dist/esm-node/localisedUrls/resolve.mjs +38 -0
  40. package/dist/esm-node/localisedUrls/routeIdentity.mjs +55 -0
  41. package/dist/esm-node/localisedUrls/routes.mjs +177 -0
  42. package/dist/esm-node/localisedUrls/types.mjs +1 -0
  43. package/dist/esm-node/rendererHead.mjs +14 -5
  44. package/dist/esm-node/request-policy/policyCore.mjs +1 -1
  45. package/dist/esm-node/request-policy/transport.mjs +2 -2
  46. package/dist/esm-node/routerState.mjs +81 -73
  47. package/dist/esm-node/routerStatePlugin.mjs +62 -11
  48. package/dist/esm-node/server-config/serverSource.mjs +1 -0
  49. package/dist/types/helmetCompat.d.ts +1 -1
  50. package/dist/types/localisedUrls/config.d.ts +9 -0
  51. package/dist/types/localisedUrls/index.d.ts +10 -0
  52. package/dist/types/localisedUrls/normalise.d.ts +10 -0
  53. package/dist/types/localisedUrls/pathname.d.ts +4 -0
  54. package/dist/types/localisedUrls/patterns.d.ts +11 -0
  55. package/dist/types/localisedUrls/redirect.d.ts +19 -0
  56. package/dist/types/localisedUrls/resolve.d.ts +7 -0
  57. package/dist/types/localisedUrls/routeIdentity.d.ts +22 -0
  58. package/dist/types/localisedUrls/routes.d.ts +3 -0
  59. package/dist/types/localisedUrls/types.d.ts +14 -0
  60. package/dist/types/rendererHead.d.ts +5 -3
  61. package/dist/types/request-policy/types.d.ts +1 -1
  62. package/dist/types/routeHydration.d.ts +2 -2
  63. package/dist/types/routerProvider.d.ts +1 -3
  64. package/dist/types/routerState.d.ts +11 -6
  65. package/dist/types/routerStatePlugin.d.ts +12 -0
  66. package/dist/types/routerStateTypes.d.ts +47 -17
  67. package/dist/types/server-config/index.d.ts +1 -0
  68. package/dist/types/server-config/serverSource.d.ts +16 -0
  69. package/package.json +42 -44
  70. package/src/helmetCompat.ts +1 -1
  71. package/src/localisedUrls/config.ts +18 -0
  72. package/src/localisedUrls/index.ts +25 -0
  73. package/src/localisedUrls/normalise.ts +62 -0
  74. package/src/localisedUrls/pathname.ts +60 -0
  75. package/src/localisedUrls/patterns.ts +283 -0
  76. package/src/localisedUrls/redirect.ts +110 -0
  77. package/src/localisedUrls/resolve.ts +106 -0
  78. package/src/localisedUrls/routeIdentity.ts +83 -0
  79. package/src/localisedUrls/routes.ts +451 -0
  80. package/src/localisedUrls/types.ts +18 -0
  81. package/src/rendererHead.ts +25 -8
  82. package/src/request-policy/policyCore.ts +1 -1
  83. package/src/request-policy/transport.ts +2 -2
  84. package/src/request-policy/types.ts +1 -1
  85. package/src/routeHydration.ts +2 -2
  86. package/src/routerProvider.ts +1 -1
  87. package/src/routerState.ts +127 -115
  88. package/src/routerStatePlugin.ts +106 -10
  89. package/src/routerStateTypes.ts +47 -17
  90. package/src/server-config/index.ts +1 -0
  91. package/src/server-config/serverSource.ts +23 -0
  92. package/dist/cjs/buildIdentity.js +0 -73
  93. package/dist/esm/buildIdentity.mjs +0 -35
  94. package/dist/esm/reactContext.mjs +0 -7
  95. package/dist/esm-node/buildIdentity.mjs +0 -36
  96. package/dist/esm-node/reactContext.mjs +0 -8
  97. package/dist/types/buildIdentity.d.ts +0 -37
  98. package/dist/types/reactContext.d.ts +0 -6
  99. package/rslib.config.mts +0 -19
  100. package/rstest.config.mts +0 -38
  101. package/src/buildIdentity.ts +0 -61
  102. package/src/reactContext.ts +0 -16
  103. package/tests/buildIdentity.test.ts +0 -51
  104. package/tests/contextExtensions.test.ts +0 -27
  105. package/tests/headRuntime.test.tsx +0 -229
  106. package/tests/rendererHead.test.ts +0 -187
  107. package/tests/request-policy/browser-policy.test.ts +0 -108
  108. package/tests/request-policy/client-isolation.test.ts +0 -109
  109. package/tests/request-policy/node-policy.test.ts +0 -447
  110. package/tests/request-policy/policyCore.test.ts +0 -129
  111. package/tests/request-policy/requestContext.test.ts +0 -66
  112. package/tests/request-policy/requestFactory.outbound-contract.test.ts +0 -290
  113. package/tests/request-policy/traceparent.test.ts +0 -52
  114. package/tests/request-policy/transport-retry.test.ts +0 -367
  115. package/tests/routeHydration.test.ts +0 -180
  116. package/tests/routerPrefetchPolicy.test.ts +0 -102
  117. package/tests/routerProvider.test.ts +0 -29
  118. package/tests/routerState.test.ts +0 -78
  119. package/tests/routerStatePlugin.test.ts +0 -63
  120. package/tests/runtimeContextProjection.test.ts +0 -83
  121. package/tests/safeFailure.test.ts +0 -136
  122. package/tests/shellStream.test.ts +0 -122
  123. package/tests/workspaceEvents.test.ts +0 -221
  124. package/tsconfig.json +0 -10
@@ -1,290 +0,0 @@
1
- import { createClient as createBrowserClient } from '@modern-js/runtime-extensions/request-policy/client';
2
- import { createClient as createServerClient } from '@modern-js/runtime-extensions/request-policy/server';
3
- import { storage } from '@modern-js/runtime-utils/node';
4
- import {
5
- BFF_ENVELOPE_HEADER,
6
- BFF_OPERATION_CONTEXT_DETAIL_HEADER,
7
- BFF_OPERATION_CONTEXT_HEADER,
8
- type TransportTarget,
9
- } from '../../src/request-policy/types';
10
-
11
- const ACCEPT_HEADER = 'application/json,*/*;q=0.8';
12
- const REQUEST_PATH = '/api/widgets';
13
- const PORT = 8080;
14
- const SERVER_URL = `http://127.0.0.1:${PORT}${REQUEST_PATH}`;
15
- const PRODUCER_URL = `https://producer.example${REQUEST_PATH}`;
16
- const CONSUMER_ORIGIN = 'https://consumer.example';
17
- const INCOMING_TRACEPARENT =
18
- '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
19
- const OPERATION_TRACEPARENT =
20
- '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01';
21
-
22
- type HeaderMap = Record<string, any>;
23
-
24
- const createHarness = (
25
- target: TransportTarget,
26
- incomingHeaders: HeaderMap = {},
27
- ) => {
28
- const request = rs.fn(
29
- (_requestPath: RequestInfo | URL, _init?: RequestInit) =>
30
- Promise.resolve(new Response(JSON.stringify({ ok: true }))),
31
- );
32
-
33
- rs.spyOn(storage, 'useContext').mockReturnValue({ headers: incomingHeaders });
34
- const requestFactory =
35
- target === 'server' ? createServerClient() : createBrowserClient();
36
-
37
- return { request, requestFactory };
38
- };
39
-
40
- describe('requestFactory outbound request contract', () => {
41
- test('browser GET drops the caller body, canonicalizes accept and withholds the incoming traceparent', async () => {
42
- const { request, requestFactory } = createHarness('browser', {
43
- traceparent: INCOMING_TRACEPARENT,
44
- });
45
- requestFactory.configure({ request: request as unknown as typeof fetch });
46
-
47
- const send = requestFactory.createRequest({
48
- path: REQUEST_PATH,
49
- method: 'GET',
50
- port: PORT,
51
- });
52
- await send({
53
- body: 'drop-me',
54
- headers: { Accept: 'application/problem+json' },
55
- });
56
-
57
- const [url, init] = request.mock.calls[0];
58
- expect(String(url)).toBe(REQUEST_PATH);
59
- expect(init.method).toBe('GET');
60
- expect(init.body).toBeUndefined();
61
- const headers = init.headers as HeaderMap;
62
- expect(headers.accept).toBe(ACCEPT_HEADER);
63
- expect(
64
- Object.keys(headers).filter(key => key.toLowerCase() === 'accept'),
65
- ).toEqual(['accept']);
66
- expect(headers.traceparent).toBeUndefined();
67
- });
68
-
69
- test('server POST keeps the caller body and propagates the incoming traceparent', async () => {
70
- const { request, requestFactory } = createHarness('server', {
71
- traceparent: INCOMING_TRACEPARENT,
72
- });
73
- requestFactory.configure({ request: request as unknown as typeof fetch });
74
-
75
- const send = requestFactory.createRequest({
76
- path: REQUEST_PATH,
77
- method: 'POST',
78
- port: PORT,
79
- });
80
- await send({ body: 'keep-me' });
81
-
82
- const [url, init] = request.mock.calls[0];
83
- expect(String(url)).toBe(SERVER_URL);
84
- expect(init.method).toBe('POST');
85
- expect(init.body).toBe('keep-me');
86
- expect((init.headers as HeaderMap).traceparent).toBe(INCOMING_TRACEPARENT);
87
- });
88
-
89
- test('secured producer request emits the cross-origin envelope and operation context', async () => {
90
- const requestId = 'producer-checkout';
91
- const { request, requestFactory } = createHarness('server', {
92
- origin: CONSUMER_ORIGIN,
93
- });
94
-
95
- requestFactory.configure({
96
- request: request as unknown as typeof fetch,
97
- requestId,
98
- setDomain: () => 'https://producer.example',
99
- requireEnvelope: true,
100
- allowCrossOriginEnvelope: true,
101
- operationContract: {
102
- enabled: true,
103
- requireSchemaHash: true,
104
- requireOperationVersion: true,
105
- },
106
- });
107
-
108
- const send = requestFactory.createRequest({
109
- path: REQUEST_PATH,
110
- method: 'POST',
111
- port: PORT,
112
- requestId,
113
- operationContext: {
114
- operationId: 'create-widget',
115
- routePath: REQUEST_PATH,
116
- method: 'POST',
117
- schemaHash: 'sha256:create-widget',
118
- operationVersion: 3,
119
- traceparent: OPERATION_TRACEPARENT,
120
- },
121
- });
122
- await send({ body: 'keep-me' });
123
-
124
- const [url, init] = request.mock.calls[0];
125
- expect(String(url)).toBe(PRODUCER_URL);
126
- const headers = init.headers as HeaderMap;
127
- expect(headers.traceparent).toBe(OPERATION_TRACEPARENT);
128
- expect(headers[BFF_OPERATION_CONTEXT_HEADER]).toBe(
129
- 'producer-checkout:create-widget',
130
- );
131
- expect(JSON.parse(headers[BFF_ENVELOPE_HEADER])).toMatchObject({
132
- requestId,
133
- target: 'server',
134
- sourceOrigin: CONSUMER_ORIGIN,
135
- targetOrigin: 'https://producer.example',
136
- traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
137
- spanId: 'bbbbbbbbbbbbbbbb',
138
- });
139
- expect(
140
- JSON.parse(headers[BFF_OPERATION_CONTEXT_DETAIL_HEADER]),
141
- ).toMatchObject({
142
- operationId: 'producer-checkout:create-widget',
143
- schemaHash: 'sha256:create-widget',
144
- operationVersion: 3,
145
- });
146
- });
147
-
148
- test('forwards only allowlisted and server-derived identity headers', async () => {
149
- const requestId = 'producer-input-params-identity';
150
- const { request, requestFactory } = createHarness('server', {
151
- 'x-tenant-id': 'tenant-server',
152
- 'x-subject-id': 'subject-server',
153
- 'x-forwarded-feature': 'feature-server',
154
- 'x-private-incoming': 'must-not-forward',
155
- });
156
-
157
- requestFactory.configure({
158
- request: request as unknown as typeof fetch,
159
- requestId,
160
- allowedHeaders: ['x-forwarded-feature'],
161
- operationContract: { enabled: false },
162
- requireEnvelope: false,
163
- setDomain: () => 'https://producer.example',
164
- });
165
-
166
- const send = requestFactory.createRequest({
167
- path: REQUEST_PATH,
168
- method: 'POST',
169
- port: PORT,
170
- httpMethodDecider: 'inputParams',
171
- requestId,
172
- });
173
- await send('widget');
174
-
175
- const headers = request.mock.calls[0][1]?.headers as HeaderMap;
176
- expect(headers).toMatchObject({
177
- 'x-tenant-id': 'tenant-server',
178
- 'x-subject-id': 'subject-server',
179
- 'x-forwarded-feature': 'feature-server',
180
- });
181
- expect(headers['x-private-incoming']).toBeUndefined();
182
- });
183
-
184
- test('uploader sends the file as form data under the same header policy', async () => {
185
- const requestId = 'producer-uploader-identity';
186
- const { request, requestFactory } = createHarness('server', {
187
- origin: CONSUMER_ORIGIN,
188
- 'x-tenant-id': 'tenant-server',
189
- 'x-private-incoming': 'must-not-forward',
190
- });
191
-
192
- requestFactory.configure({
193
- request: request as unknown as typeof fetch,
194
- requestId,
195
- operationContract: { enabled: false },
196
- requireEnvelope: false,
197
- setDomain: () => 'https://producer.example',
198
- });
199
-
200
- const upload = requestFactory.createUploader({
201
- path: REQUEST_PATH,
202
- requestId,
203
- });
204
- await upload({
205
- files: {
206
- file: new File(['widget'], 'widget.txt', { type: 'text/plain' }),
207
- },
208
- });
209
-
210
- const [, init] = request.mock.calls[0];
211
- expect(init.method).toBe('POST');
212
- expect(init.body).toBeInstanceOf(FormData);
213
- const uploaded = (init.body as FormData).get('file') as File;
214
- expect(uploaded.name).toBe('widget.txt');
215
- expect(await uploaded.text()).toBe('widget');
216
- const headers = init.headers as HeaderMap;
217
- expect(headers['x-tenant-id']).toBe('tenant-server');
218
- expect(headers['x-private-incoming']).toBeUndefined();
219
- });
220
-
221
- test('resolved headers replace caller and incoming values without injecting unlisted ones', async () => {
222
- const resolveHeaders = rs.fn(() => ({
223
- AUTHORIZATION: 'Bearer resolved',
224
- 'x-injected': 'must-not-forward',
225
- }));
226
- const { request, requestFactory } = createHarness('server', {
227
- Authorization: 'Bearer incoming',
228
- });
229
-
230
- requestFactory.configure({
231
- request: request as unknown as typeof fetch,
232
- allowedHeaders: ['authorization'],
233
- resolveHeaders,
234
- });
235
-
236
- const send = requestFactory.createRequest({
237
- path: REQUEST_PATH,
238
- method: 'GET',
239
- port: PORT,
240
- });
241
- await send({ headers: { Authorization: 'Bearer caller' } });
242
-
243
- expect(resolveHeaders).toHaveBeenCalledWith(
244
- expect.objectContaining({
245
- incomingHeaders: { authorization: 'Bearer incoming' },
246
- }),
247
- );
248
- const headers = request.mock.calls[0][1]?.headers as HeaderMap;
249
- expect(headers.authorization).toBe('Bearer resolved');
250
- expect(headers['x-injected']).toBeUndefined();
251
- expect(
252
- Object.keys(headers).filter(key => key.toLowerCase() === 'authorization'),
253
- ).toEqual(['authorization']);
254
- });
255
-
256
- test('reconfiguration drops an omitted header and envelope policy', async () => {
257
- const resolveHeaders = rs.fn(() => ({ authorization: 'Bearer stale' }));
258
- const { request, requestFactory } = createHarness('server', {
259
- authorization: 'Bearer incoming',
260
- });
261
-
262
- requestFactory.configure({
263
- request: request as unknown as typeof fetch,
264
- allowedHeaders: ['authorization'],
265
- resolveHeaders,
266
- requireEnvelope: true,
267
- identityBinding: {
268
- enabled: true,
269
- strict: false,
270
- protectedHeaders: ['x-user-id'],
271
- deriveHeaders: () => ({ 'x-user-id': 'stale-user' }),
272
- },
273
- });
274
- requestFactory.configure({
275
- request: request as unknown as typeof fetch,
276
- });
277
-
278
- const send = requestFactory.createRequest({
279
- path: REQUEST_PATH,
280
- method: 'GET',
281
- port: PORT,
282
- });
283
- await send();
284
-
285
- expect(resolveHeaders).not.toHaveBeenCalled();
286
- expect(request.mock.calls[0][1]?.headers).toEqual({
287
- accept: ACCEPT_HEADER,
288
- });
289
- });
290
- });
@@ -1,52 +0,0 @@
1
- import { parseTraceparent } from '@modern-js/runtime-extensions/request-context';
2
-
3
- describe('parseTraceparent', () => {
4
- const traceId = '4bf92f3577b34da6a3ce929d0e0e4736';
5
- const spanId = '00f067aa0ba902b7';
6
-
7
- test.each([
8
- ['01', true],
9
- ['00', false],
10
- ['03', true],
11
- ['02', false],
12
- ])('parses a valid traceparent with flags %s', (flags, sampled) => {
13
- expect(parseTraceparent(`00-${traceId}-${spanId}-${flags}`)).toEqual({
14
- traceId,
15
- spanId,
16
- sampled,
17
- });
18
- });
19
-
20
- test('lowercases mixed-case ids and trims surrounding whitespace', () => {
21
- expect(
22
- parseTraceparent(
23
- ` 00-${traceId.toUpperCase()}-${spanId.toUpperCase()}-01 `,
24
- ),
25
- ).toEqual({
26
- traceId,
27
- spanId,
28
- sampled: true,
29
- });
30
- });
31
-
32
- test('rejects missing, empty, and malformed headers', () => {
33
- expect(parseTraceparent(undefined)).toBeUndefined();
34
- expect(parseTraceparent(null)).toBeUndefined();
35
- expect(parseTraceparent('')).toBeUndefined();
36
- expect(parseTraceparent('not-a-traceparent')).toBeUndefined();
37
- expect(parseTraceparent(`00-${traceId}-${spanId}`)).toBeUndefined();
38
- expect(parseTraceparent(`01-${traceId}-${spanId}-01`)).toBeUndefined();
39
- expect(
40
- parseTraceparent(`00-${traceId.slice(1)}-${spanId}-01`),
41
- ).toBeUndefined();
42
- });
43
-
44
- test('rejects all-zero trace and span ids per the W3C spec', () => {
45
- expect(
46
- parseTraceparent(`00-${'0'.repeat(32)}-${spanId}-01`),
47
- ).toBeUndefined();
48
- expect(
49
- parseTraceparent(`00-${traceId}-${'0'.repeat(16)}-01`),
50
- ).toBeUndefined();
51
- });
52
- });
@@ -1,367 +0,0 @@
1
- import { executeWithResilience } from '../../src/request-policy/transport';
2
-
3
- const requestUrl = 'https://modern.test/api';
4
- const retryableStatusCodes = [408, 425, 429, 500, 502, 503, 504];
5
- const nonRetryableStatusCodes = [400, 401, 404, 418, 501];
6
-
7
- const flushMicrotasks = async () => {
8
- await Promise.resolve();
9
- await Promise.resolve();
10
- };
11
-
12
- const createNamedError = (name: string) => {
13
- const error = new Error(name);
14
- (error as any).name = name;
15
- return error;
16
- };
17
-
18
- const createStatusError = (status: number) => {
19
- const error = new Error(`status ${status}`);
20
- (error as any).status = status;
21
- return error;
22
- };
23
-
24
- const executeRetryRequest = ({
25
- fetcher,
26
- method = 'GET',
27
- init = { method },
28
- retry,
29
- onDegraded,
30
- }: {
31
- fetcher: (...args: any[]) => Promise<any>;
32
- method?: string;
33
- init?: Record<string, any>;
34
- retry?: Record<string, any>;
35
- onDegraded?: (event: any) => void;
36
- }) =>
37
- executeWithResilience({
38
- requestId: 'transport-retry-test',
39
- target: 'browser',
40
- method,
41
- url: requestUrl,
42
- init,
43
- fetcher,
44
- transport: {
45
- retry,
46
- onDegraded,
47
- },
48
- });
49
-
50
- describe('transport retry behavior', () => {
51
- test.each([
52
- 'FetchError',
53
- ])('retries %s up to the configured max then rejects', async errorName => {
54
- rs.useFakeTimers();
55
- const errors = [
56
- createNamedError(errorName),
57
- createNamedError(errorName),
58
- createNamedError(errorName),
59
- ];
60
- let callCount = 0;
61
- const fetcher = rs.fn(async () => {
62
- const error = errors[callCount];
63
- callCount += 1;
64
- throw error;
65
- });
66
-
67
- const pending = executeRetryRequest({
68
- fetcher,
69
- retry: {
70
- retries: 2,
71
- baseDelayMs: 10,
72
- maxDelayMs: 100,
73
- jitterRatio: 0,
74
- },
75
- });
76
- const observed = pending.catch(error => error);
77
-
78
- try {
79
- await flushMicrotasks();
80
- expect(fetcher).toHaveBeenCalledTimes(1);
81
-
82
- await rs.advanceTimersByTimeAsync(9);
83
- expect(fetcher).toHaveBeenCalledTimes(1);
84
- await rs.advanceTimersByTimeAsync(1);
85
- await flushMicrotasks();
86
- expect(fetcher).toHaveBeenCalledTimes(2);
87
-
88
- await rs.advanceTimersByTimeAsync(19);
89
- expect(fetcher).toHaveBeenCalledTimes(2);
90
- await rs.advanceTimersByTimeAsync(1);
91
- await flushMicrotasks();
92
- expect(fetcher).toHaveBeenCalledTimes(3);
93
- await expect(observed).resolves.toBe(errors[2]);
94
- } finally {
95
- await rs.advanceTimersByTimeAsync(1000);
96
- await observed;
97
- rs.useRealTimers();
98
- }
99
- });
100
-
101
- test('should not retry caller-aborted requests', async () => {
102
- rs.useFakeTimers();
103
- const onDegraded = rs.fn();
104
- const abortController = new AbortController();
105
- abortController.abort();
106
- const abortError = createNamedError('AbortError');
107
- const fetcher = rs.fn(async (_url: string, init: RequestInit) => {
108
- expect(init.signal).toBe(abortController.signal);
109
- throw abortError;
110
- });
111
-
112
- const pending = executeRetryRequest({
113
- fetcher,
114
- init: {
115
- method: 'GET',
116
- signal: abortController.signal,
117
- },
118
- retry: {
119
- retries: 2,
120
- baseDelayMs: 1,
121
- maxDelayMs: 1,
122
- jitterRatio: 0,
123
- },
124
- onDegraded,
125
- });
126
- const observed = pending.catch(error => error);
127
-
128
- try {
129
- await flushMicrotasks();
130
- expect(fetcher).toHaveBeenCalledTimes(1);
131
- expect(onDegraded).not.toHaveBeenCalledWith(
132
- expect.objectContaining({ reason: 'retry' }),
133
- );
134
- await expect(observed).resolves.toBe(abortError);
135
- } finally {
136
- await rs.advanceTimersByTimeAsync(5);
137
- await observed;
138
- rs.useRealTimers();
139
- }
140
- });
141
-
142
- test('retries default retryable status codes after the backoff delay', async () => {
143
- rs.useFakeTimers();
144
-
145
- try {
146
- const statusCode = retryableStatusCodes[0];
147
- const retryableError = createStatusError(statusCode);
148
- const response = { ok: true, status: 200 };
149
- let callCount = 0;
150
- const fetcher = rs.fn(async () => {
151
- callCount += 1;
152
- if (callCount === 1) {
153
- throw retryableError;
154
- }
155
- return response;
156
- });
157
-
158
- const pending = executeRetryRequest({
159
- fetcher,
160
- retry: {
161
- retries: 1,
162
- baseDelayMs: 5,
163
- maxDelayMs: 5,
164
- jitterRatio: 0,
165
- },
166
- });
167
-
168
- await flushMicrotasks();
169
- expect(fetcher).toHaveBeenCalledTimes(1);
170
- await rs.advanceTimersByTimeAsync(4);
171
- expect(fetcher).toHaveBeenCalledTimes(1);
172
- await rs.advanceTimersByTimeAsync(1);
173
- await expect(pending).resolves.toBe(response);
174
- expect(fetcher).toHaveBeenCalledTimes(2);
175
- } finally {
176
- await rs.advanceTimersByTimeAsync(1000);
177
- rs.useRealTimers();
178
- }
179
- });
180
-
181
- test('does not retry non-retryable status codes', async () => {
182
- const statusCode = nonRetryableStatusCodes[0];
183
- const error = createStatusError(statusCode);
184
- const fetcher = rs.fn(async () => {
185
- throw error;
186
- });
187
-
188
- await expect(
189
- executeRetryRequest({
190
- fetcher,
191
- retry: {
192
- retries: 2,
193
- baseDelayMs: 1,
194
- maxDelayMs: 1,
195
- jitterRatio: 0,
196
- },
197
- }),
198
- ).rejects.toBe(error);
199
- expect(fetcher).toHaveBeenCalledTimes(1);
200
- });
201
-
202
- test('uses a custom shouldRetry predicate instead of default rules', async () => {
203
- rs.useFakeTimers();
204
- const retryableByPolicy = createStatusError(400);
205
- const response = { ok: true, status: 200 };
206
- const shouldRetry = rs.fn(() => true);
207
- let callCount = 0;
208
- const fetcher = rs.fn(async () => {
209
- callCount += 1;
210
- if (callCount === 1) {
211
- throw retryableByPolicy;
212
- }
213
- return response;
214
- });
215
-
216
- const pending = executeRetryRequest({
217
- fetcher,
218
- method: 'POST',
219
- init: { method: 'POST' },
220
- retry: {
221
- retries: 1,
222
- baseDelayMs: 7,
223
- maxDelayMs: 7,
224
- jitterRatio: 0,
225
- shouldRetry,
226
- },
227
- });
228
-
229
- try {
230
- await flushMicrotasks();
231
- expect(fetcher).toHaveBeenCalledTimes(1);
232
- expect(shouldRetry).toHaveBeenCalledWith(
233
- expect.objectContaining({
234
- attempt: 1,
235
- error: retryableByPolicy,
236
- maxAttempts: 2,
237
- method: 'POST',
238
- statusCode: 400,
239
- }),
240
- );
241
-
242
- await rs.advanceTimersByTimeAsync(6);
243
- expect(fetcher).toHaveBeenCalledTimes(1);
244
- await rs.advanceTimersByTimeAsync(1);
245
- await expect(pending).resolves.toBe(response);
246
- expect(fetcher).toHaveBeenCalledTimes(2);
247
- } finally {
248
- await rs.advanceTimersByTimeAsync(1000);
249
- rs.useRealTimers();
250
- }
251
- });
252
-
253
- test('does not retry non-idempotent methods by default', async () => {
254
- const error = createNamedError('FetchError');
255
- const fetcher = rs.fn(async () => {
256
- throw error;
257
- });
258
-
259
- await expect(
260
- executeRetryRequest({
261
- fetcher,
262
- method: 'POST',
263
- init: { method: 'POST' },
264
- retry: {
265
- retries: 2,
266
- baseDelayMs: 1,
267
- maxDelayMs: 1,
268
- jitterRatio: 0,
269
- },
270
- }),
271
- ).rejects.toBe(error);
272
- expect(fetcher).toHaveBeenCalledTimes(1);
273
- });
274
-
275
- test('timeout aborts the in-flight request when a caller signal is present', async () => {
276
- rs.useFakeTimers();
277
- const callerController = new AbortController();
278
- const onDegraded = rs.fn();
279
- let inFlightSignal: AbortSignal | undefined;
280
- const fetcher = rs.fn((_url: string, init: RequestInit) => {
281
- inFlightSignal = init.signal || undefined;
282
- return new Promise((_, reject) => {
283
- init.signal?.addEventListener('abort', () => {
284
- reject(createNamedError('AbortError'));
285
- });
286
- });
287
- });
288
-
289
- const pending = executeWithResilience({
290
- requestId: 'transport-timeout-with-caller-signal',
291
- target: 'browser',
292
- method: 'GET',
293
- url: requestUrl,
294
- init: {
295
- method: 'GET',
296
- signal: callerController.signal,
297
- },
298
- fetcher,
299
- transport: {
300
- timeoutMs: 25,
301
- onDegraded,
302
- },
303
- });
304
- const observed = pending.catch(error => error);
305
-
306
- try {
307
- await flushMicrotasks();
308
- expect(inFlightSignal?.aborted).toBe(false);
309
- await rs.advanceTimersByTimeAsync(25);
310
-
311
- await expect(observed).resolves.toMatchObject({ name: 'TimeoutError' });
312
- expect(inFlightSignal?.aborted).toBe(true);
313
- expect(callerController.signal.aborted).toBe(false);
314
- expect(onDegraded).toHaveBeenCalledWith(
315
- expect.objectContaining({ reason: 'timeout', timeoutMs: 25 }),
316
- );
317
- } finally {
318
- await rs.advanceTimersByTimeAsync(1000);
319
- await observed;
320
- rs.useRealTimers();
321
- }
322
- });
323
-
324
- test('caller abort still cancels a request that has a timeout', async () => {
325
- rs.useFakeTimers();
326
- const callerController = new AbortController();
327
- const onDegraded = rs.fn();
328
- let inFlightSignal: AbortSignal | undefined;
329
- const abortError = createNamedError('AbortError');
330
- const fetcher = rs.fn((_url: string, init: RequestInit) => {
331
- inFlightSignal = init.signal || undefined;
332
- return new Promise((_, reject) => {
333
- init.signal?.addEventListener('abort', () => reject(abortError));
334
- });
335
- });
336
-
337
- const pending = executeWithResilience({
338
- requestId: 'transport-caller-abort-with-timeout',
339
- target: 'browser',
340
- method: 'GET',
341
- url: requestUrl,
342
- init: {
343
- method: 'GET',
344
- signal: callerController.signal,
345
- },
346
- fetcher,
347
- transport: {
348
- timeoutMs: 25,
349
- onDegraded,
350
- },
351
- });
352
- const observed = pending.catch(error => error);
353
-
354
- try {
355
- await flushMicrotasks();
356
- callerController.abort();
357
-
358
- await expect(observed).resolves.toBe(abortError);
359
- expect(inFlightSignal?.aborted).toBe(true);
360
- expect(onDegraded).not.toHaveBeenCalled();
361
- } finally {
362
- await rs.advanceTimersByTimeAsync(1000);
363
- await observed;
364
- rs.useRealTimers();
365
- }
366
- });
367
- });