@bleedingdev/modern-js-runtime-extensions 3.9.0-ultramodern.4 → 3.9.0-ultramodern.6

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 (155) hide show
  1. package/dist/cjs/contextExtensions.js +74 -0
  2. package/dist/cjs/headRuntime.js +91 -0
  3. package/dist/cjs/helmetContext.js +54 -0
  4. package/dist/cjs/index.js +18 -4
  5. package/dist/cjs/prefetchPolicy.js +52 -0
  6. package/dist/cjs/request-policy/browser.js +139 -0
  7. package/dist/cjs/request-policy/client.js +234 -0
  8. package/dist/cjs/request-policy/node.js +149 -0
  9. package/dist/cjs/request-policy/policyCore.js +266 -0
  10. package/dist/cjs/request-policy/requestContext.js +137 -0
  11. package/dist/cjs/request-policy/traceparent.js +56 -0
  12. package/dist/cjs/request-policy/transport.js +234 -0
  13. package/dist/cjs/request-policy/types.js +57 -0
  14. package/dist/cjs/routeHydration.js +127 -0
  15. package/dist/cjs/routerPrefetchPolicy.js +153 -0
  16. package/dist/cjs/routerProvider.js +105 -0
  17. package/dist/cjs/routerState.js +167 -0
  18. package/dist/cjs/routerStatePlugin.js +86 -0
  19. package/dist/cjs/routerStateTypes.js +18 -0
  20. package/dist/cjs/runtimeContextProjection.js +112 -0
  21. package/dist/cjs/server-config/bffRuntime.js +18 -0
  22. package/dist/cjs/server-config/index.js +18 -0
  23. package/dist/cjs/server-config/serverTelemetry.js +18 -0
  24. package/dist/cjs/shellStream.js +34 -2
  25. package/dist/cjs/workspaceEvents.js +159 -0
  26. package/dist/esm/contextExtensions.mjs +33 -0
  27. package/dist/esm/headRuntime.mjs +43 -0
  28. package/dist/esm/helmetContext.mjs +13 -0
  29. package/dist/esm/index.mjs +2 -0
  30. package/dist/esm/prefetchPolicy.mjs +11 -0
  31. package/dist/esm/request-policy/browser.mjs +11 -0
  32. package/dist/esm/request-policy/client.mjs +195 -0
  33. package/dist/esm/request-policy/policyCore.mjs +171 -0
  34. package/dist/esm/request-policy/requestContext.mjs +75 -0
  35. package/dist/esm/request-policy/traceparent.mjs +18 -0
  36. package/dist/esm/request-policy/transport.mjs +196 -0
  37. package/dist/esm/request-policy/types.mjs +10 -0
  38. package/dist/esm/routeHydration.mjs +77 -0
  39. package/dist/esm/routerPrefetchPolicy.mjs +115 -0
  40. package/dist/esm/routerProvider.mjs +58 -0
  41. package/dist/esm/routerState.mjs +105 -0
  42. package/dist/esm/routerStatePlugin.mjs +48 -0
  43. package/dist/esm/routerStateTypes.mjs +0 -0
  44. package/dist/esm/runtimeContextProjection.mjs +74 -0
  45. package/dist/esm/server-config/bffRuntime.mjs +0 -0
  46. package/dist/esm/server-config/index.mjs +0 -0
  47. package/dist/esm/server-config/serverTelemetry.mjs +0 -0
  48. package/dist/esm/shellStream.mjs +34 -2
  49. package/dist/esm/workspaceEvents.mjs +66 -0
  50. package/dist/esm-node/contextExtensions.mjs +34 -0
  51. package/dist/esm-node/headRuntime.mjs +44 -0
  52. package/dist/esm-node/helmetContext.mjs +14 -0
  53. package/dist/esm-node/index.mjs +2 -0
  54. package/dist/esm-node/prefetchPolicy.mjs +12 -0
  55. package/dist/esm-node/request-policy/browser.mjs +12 -0
  56. package/dist/esm-node/request-policy/client.mjs +196 -0
  57. package/dist/esm-node/request-policy/node.mjs +22 -0
  58. package/dist/esm-node/request-policy/policyCore.mjs +172 -0
  59. package/dist/esm-node/request-policy/requestContext.mjs +76 -0
  60. package/dist/esm-node/request-policy/traceparent.mjs +19 -0
  61. package/dist/esm-node/request-policy/transport.mjs +197 -0
  62. package/dist/esm-node/request-policy/types.mjs +11 -0
  63. package/dist/esm-node/routeHydration.mjs +78 -0
  64. package/dist/esm-node/routerPrefetchPolicy.mjs +116 -0
  65. package/dist/esm-node/routerProvider.mjs +59 -0
  66. package/dist/esm-node/routerState.mjs +106 -0
  67. package/dist/esm-node/routerStatePlugin.mjs +49 -0
  68. package/dist/esm-node/routerStateTypes.mjs +1 -0
  69. package/dist/esm-node/runtimeContextProjection.mjs +75 -0
  70. package/dist/esm-node/server-config/bffRuntime.mjs +1 -0
  71. package/dist/esm-node/server-config/index.mjs +1 -0
  72. package/dist/esm-node/server-config/serverTelemetry.mjs +1 -0
  73. package/dist/esm-node/shellStream.mjs +34 -2
  74. package/dist/esm-node/workspaceEvents.mjs +67 -0
  75. package/dist/types/contextExtensions.d.ts +34 -0
  76. package/dist/types/headRuntime.d.ts +12 -0
  77. package/dist/types/helmetContext.d.ts +10 -0
  78. package/dist/types/index.d.ts +2 -0
  79. package/dist/types/prefetchPolicy.d.ts +4 -0
  80. package/dist/types/request-policy/browser.d.ts +10 -0
  81. package/dist/types/request-policy/client.d.ts +14 -0
  82. package/dist/types/request-policy/node.d.ts +10 -0
  83. package/dist/types/request-policy/policyCore.d.ts +107 -0
  84. package/dist/types/request-policy/requestContext.d.ts +22 -0
  85. package/dist/types/request-policy/traceparent.d.ts +10 -0
  86. package/dist/types/request-policy/transport.d.ts +12 -0
  87. package/dist/types/request-policy/types.d.ts +144 -0
  88. package/dist/types/routeHydration.d.ts +24 -0
  89. package/dist/types/routerPrefetchPolicy.d.ts +28 -0
  90. package/dist/types/routerProvider.d.ts +55 -0
  91. package/dist/types/routerState.d.ts +12 -0
  92. package/dist/types/routerStatePlugin.d.ts +19 -0
  93. package/dist/types/routerStateTypes.d.ts +40 -0
  94. package/dist/types/runtimeContextProjection.d.ts +30 -0
  95. package/dist/types/server-config/bffRuntime.d.ts +122 -0
  96. package/dist/types/server-config/index.d.ts +2 -0
  97. package/dist/types/server-config/serverTelemetry.d.ts +322 -0
  98. package/dist/types/workspaceEvents.d.ts +62 -0
  99. package/package.json +198 -13
  100. package/rslib.config.mts +17 -2
  101. package/rstest.config.mts +33 -2
  102. package/src/contextExtensions.ts +81 -0
  103. package/src/headRuntime.tsx +77 -0
  104. package/src/helmetContext.ts +31 -0
  105. package/src/index.ts +2 -0
  106. package/src/prefetchPolicy.ts +33 -0
  107. package/src/request-policy/browser.ts +22 -0
  108. package/src/request-policy/client.ts +404 -0
  109. package/src/request-policy/node.ts +41 -0
  110. package/src/request-policy/policyCore.ts +424 -0
  111. package/src/request-policy/requestContext.ts +149 -0
  112. package/src/request-policy/traceparent.ts +43 -0
  113. package/src/request-policy/transport.ts +349 -0
  114. package/src/request-policy/types.ts +229 -0
  115. package/src/routeHydration.ts +208 -0
  116. package/src/routerPrefetchPolicy.ts +195 -0
  117. package/src/routerProvider.ts +160 -0
  118. package/src/routerState.ts +208 -0
  119. package/src/routerStatePlugin.ts +86 -0
  120. package/src/routerStateTypes.ts +46 -0
  121. package/src/runtimeContextProjection.ts +105 -0
  122. package/src/server-config/bffRuntime.ts +136 -0
  123. package/src/server-config/index.ts +2 -0
  124. package/src/server-config/serverTelemetry.ts +337 -0
  125. package/src/shellStream.ts +55 -25
  126. package/src/workspaceEvents.ts +324 -0
  127. package/tests/buildIdentity.test.ts +2 -4
  128. package/tests/contextExtensions.test.ts +71 -0
  129. package/tests/headRuntime.test.tsx +369 -0
  130. package/tests/nativeSafeFailureCompatibility.test.ts +117 -0
  131. package/tests/prefetchPolicy.test.ts +51 -0
  132. package/tests/rendererHead.test.ts +1 -72
  133. package/tests/request-policy/browser-policy.test.ts +608 -0
  134. package/tests/request-policy/client-isolation.test.ts +149 -0
  135. package/tests/request-policy/identityBinding.test.ts +75 -0
  136. package/tests/request-policy/node-policy.test.ts +741 -0
  137. package/tests/request-policy/policyCore.test.ts +250 -0
  138. package/tests/request-policy/requestContext.test.ts +80 -0
  139. package/tests/request-policy/requestFactory.outbound-contract.test.ts +691 -0
  140. package/tests/request-policy/traceparent.test.ts +55 -0
  141. package/tests/request-policy/transport-retry.test.ts +370 -0
  142. package/tests/routeHydration.test.ts +180 -0
  143. package/tests/routerPrefetchPolicy.test.ts +102 -0
  144. package/tests/routerProvider.test.ts +29 -0
  145. package/tests/routerState.test.ts +157 -0
  146. package/tests/routerStatePlugin.test.ts +108 -0
  147. package/tests/runtimeContextProjection.test.ts +135 -0
  148. package/tests/shellStream.test.ts +37 -0
  149. package/tests/workspaceEvents.test.ts +391 -0
  150. package/dist/cjs/rscHtmlStream.js +0 -128
  151. package/dist/esm/rscHtmlStream.mjs +0 -86
  152. package/dist/esm-node/rscHtmlStream.mjs +0 -87
  153. package/dist/types/rscHtmlStream.d.ts +0 -4
  154. package/src/rscHtmlStream.ts +0 -142
  155. package/tests/rscHtmlStream.test.ts +0 -153
@@ -0,0 +1,741 @@
1
+ import {
2
+ CrossOriginEnvelopePolicyError,
3
+ configure,
4
+ createRequest,
5
+ IdentityBindingViolationError,
6
+ OperationContractViolationError,
7
+ ProducerClientNotInitializedError,
8
+ ProducerDomainNotConfiguredError,
9
+ } from '@modern-js/runtime-extensions/request-policy';
10
+ import { storage } from '@modern-js/runtime-utils/node';
11
+ import nock from 'nock';
12
+
13
+ describe('configure', () => {
14
+ const url = 'http://127.0.0.1:8080';
15
+ const path = '/api';
16
+ const method = 'GET';
17
+ const response = {
18
+ code: 200,
19
+ data: {
20
+ message: 'hello Modernjs',
21
+ },
22
+ };
23
+
24
+ const run = (
25
+ headers: Record<string, string>,
26
+ callback: () => Promise<void> | void,
27
+ ) =>
28
+ storage.run(
29
+ {
30
+ headers,
31
+ monitors: {} as any,
32
+ },
33
+ callback,
34
+ );
35
+
36
+ // beforeEach(() => {
37
+ // nock.disableNetConnect();
38
+ // });
39
+
40
+ // afterEach(() => {
41
+ // nock.cleanAll();
42
+ // });
43
+
44
+ test('should support inputParams for non-default requestId with configured domain', async () => {
45
+ const producer = 'producer-input-params';
46
+ const producerUrl = 'http://127.0.0.1:9091';
47
+
48
+ await run({}, async () => {
49
+ nock(producerUrl).post(path, 'modernjs').reply(200, response);
50
+
51
+ configure({
52
+ requestId: producer,
53
+ operationContract: {
54
+ enabled: false,
55
+ },
56
+ setDomain: () => producerUrl,
57
+ });
58
+
59
+ const request = createRequest(
60
+ path,
61
+ 'POST',
62
+ 8080,
63
+ 'inputParams',
64
+ undefined,
65
+ producer,
66
+ );
67
+ const data = await request('modernjs');
68
+ expect(data).toStrictEqual(response);
69
+ });
70
+ });
71
+
72
+ test('should throw for non-default requestId when producer client is not initialized', async () => {
73
+ const request = createRequest({
74
+ path,
75
+ method,
76
+ port: 8080,
77
+ requestId: 'producer-app',
78
+ });
79
+
80
+ expect(() => request()).toThrow(ProducerClientNotInitializedError);
81
+ });
82
+
83
+ test('should require setDomain when configuring non-default requestId', () => {
84
+ expect(() =>
85
+ configure({
86
+ requestId: 'producer-a',
87
+ }),
88
+ ).toThrow(ProducerDomainNotConfiguredError);
89
+ });
90
+
91
+ test('should isolate custom request by requestId', async () => {
92
+ const producerA = 'producer-a';
93
+ const producerB = 'producer-b';
94
+ const urlA = 'http://127.0.0.1:9081';
95
+ const urlB = 'http://127.0.0.1:9082';
96
+
97
+ nock(urlA).get(path).reply(200, response);
98
+ nock(urlB).get(path).reply(200, response);
99
+
100
+ const customRequestA = rs.fn((requestPath: any) => fetch(requestPath));
101
+ const customRequestB = rs.fn((requestPath: any) => fetch(requestPath));
102
+
103
+ configure({
104
+ request: customRequestA as unknown as typeof fetch,
105
+ requestId: producerA,
106
+ operationContract: {
107
+ enabled: false,
108
+ },
109
+ setDomain: () => urlA,
110
+ });
111
+ configure({
112
+ request: customRequestB as unknown as typeof fetch,
113
+ requestId: producerB,
114
+ operationContract: {
115
+ enabled: false,
116
+ },
117
+ setDomain: () => urlB,
118
+ });
119
+
120
+ const requestA = createRequest({
121
+ path,
122
+ method,
123
+ port: 8080,
124
+ requestId: producerA,
125
+ });
126
+ const requestB = createRequest({
127
+ path,
128
+ method,
129
+ port: 8080,
130
+ requestId: producerB,
131
+ });
132
+
133
+ const resA = await requestA();
134
+ const resB = await requestB();
135
+
136
+ expect(customRequestA).toHaveBeenCalledTimes(1);
137
+ expect(customRequestB).toHaveBeenCalledTimes(1);
138
+ expect(resA instanceof Response).toBe(true);
139
+ expect(resB instanceof Response).toBe(true);
140
+ });
141
+
142
+ test('should propagate allowed headers for non-default requestId', async () => {
143
+ const producer = 'producer-non-default';
144
+ const authKey = 'token-abc';
145
+ const producerUrl = 'http://127.0.0.1:9083';
146
+
147
+ await run(
148
+ {
149
+ authorization: authKey,
150
+ },
151
+ async () => {
152
+ nock(producerUrl, {
153
+ reqheaders: {
154
+ authorization: authKey,
155
+ },
156
+ })
157
+ .get(path)
158
+ .reply(200, response);
159
+
160
+ configure({
161
+ requestId: producer,
162
+ allowedHeaders: ['authorization'],
163
+ operationContract: {
164
+ enabled: false,
165
+ },
166
+ setDomain: () => producerUrl,
167
+ });
168
+
169
+ const request = createRequest(
170
+ path,
171
+ method,
172
+ 8080,
173
+ undefined,
174
+ undefined,
175
+ producer,
176
+ );
177
+ const data = await request();
178
+
179
+ expect(data).toStrictEqual(response);
180
+ },
181
+ );
182
+ });
183
+
184
+ test('should support secure resolveHeaders callback for non-default requestId', async () => {
185
+ const producer = 'producer-with-resolver';
186
+ const authKey = 'token-def';
187
+ const tenant = 'tenant-a';
188
+ const producerUrl = 'http://127.0.0.1:9084';
189
+
190
+ await run(
191
+ {
192
+ authorization: authKey,
193
+ 'x-tenant-id': tenant,
194
+ },
195
+ async () => {
196
+ nock(producerUrl, {
197
+ reqheaders: {
198
+ authorization: authKey,
199
+ 'x-tenant-id': 'tenant-masked',
200
+ },
201
+ })
202
+ .get(path)
203
+ .reply(200, response);
204
+
205
+ configure({
206
+ requestId: producer,
207
+ allowedHeaders: ['authorization', 'x-tenant-id'],
208
+ operationContract: {
209
+ enabled: false,
210
+ },
211
+ resolveHeaders: ({ incomingHeaders }) => ({
212
+ ...incomingHeaders,
213
+ 'x-tenant-id': 'tenant-masked',
214
+ 'x-injected': 'blocked',
215
+ }),
216
+ setDomain: () => producerUrl,
217
+ });
218
+
219
+ const request = createRequest(
220
+ path,
221
+ method,
222
+ 8080,
223
+ undefined,
224
+ undefined,
225
+ producer,
226
+ );
227
+ const data = await request();
228
+
229
+ expect(data).toStrictEqual(response);
230
+ },
231
+ );
232
+ });
233
+
234
+ test('should reject client-supplied tenant headers by default for non-default producer clients', async () => {
235
+ const producer = 'producer-identity-strip';
236
+ const producerUrl = 'http://127.0.0.1:9085';
237
+
238
+ await run({}, async () => {
239
+ nock(producerUrl, {
240
+ badheaders: ['x-tenant-id'],
241
+ })
242
+ .get(path)
243
+ .reply(200, response);
244
+
245
+ configure({
246
+ requestId: producer,
247
+ operationContract: {
248
+ enabled: false,
249
+ },
250
+ setDomain: () => producerUrl,
251
+ });
252
+
253
+ const request = createRequest(
254
+ path,
255
+ method,
256
+ 8080,
257
+ undefined,
258
+ undefined,
259
+ producer,
260
+ );
261
+
262
+ expect(() =>
263
+ request({
264
+ headers: {
265
+ 'x-tenant-id': 'tenant-client',
266
+ },
267
+ }),
268
+ ).toThrow(IdentityBindingViolationError);
269
+ });
270
+ });
271
+
272
+ test('should enforce server-derived tenant and subject context over client overrides', async () => {
273
+ const producer = 'producer-identity-derived';
274
+ const producerUrl = 'http://127.0.0.1:9086';
275
+
276
+ await run(
277
+ {
278
+ 'x-tenant-id': 'tenant-server',
279
+ 'x-subject-id': 'subject-server',
280
+ },
281
+ async () => {
282
+ nock(producerUrl, {
283
+ reqheaders: {
284
+ 'x-tenant-id': 'tenant-server',
285
+ 'x-subject-id': 'subject-server',
286
+ },
287
+ })
288
+ .get(path)
289
+ .reply(200, response);
290
+
291
+ configure({
292
+ requestId: producer,
293
+ operationContract: {
294
+ enabled: false,
295
+ },
296
+ identityBinding: {
297
+ strict: false,
298
+ },
299
+ setDomain: () => producerUrl,
300
+ });
301
+
302
+ const request = createRequest(
303
+ path,
304
+ method,
305
+ 8080,
306
+ undefined,
307
+ undefined,
308
+ producer,
309
+ );
310
+
311
+ const data = await request({
312
+ headers: {
313
+ 'x-tenant-id': 'tenant-client',
314
+ 'x-subject-id': 'subject-client',
315
+ },
316
+ });
317
+
318
+ expect(data).toStrictEqual(response);
319
+ },
320
+ );
321
+ });
322
+
323
+ test('should reject client identity override in strict identity binding mode', () => {
324
+ const producer = 'producer-identity-strict';
325
+
326
+ configure({
327
+ requestId: producer,
328
+ setDomain: () => 'http://127.0.0.1:9087',
329
+ operationContract: {
330
+ enabled: false,
331
+ },
332
+ identityBinding: {
333
+ strict: true,
334
+ },
335
+ });
336
+
337
+ const request = createRequest(
338
+ path,
339
+ method,
340
+ 8080,
341
+ undefined,
342
+ undefined,
343
+ producer,
344
+ );
345
+
346
+ expect(() =>
347
+ request({
348
+ headers: {
349
+ 'x-tenant-id': 'tenant-client',
350
+ },
351
+ }),
352
+ ).toThrow(IdentityBindingViolationError);
353
+ });
354
+
355
+ test('should require envelope and block cross-origin producer calls in production by default', async () => {
356
+ const producer = 'producer-envelope-default';
357
+ const previousEnv = process.env.NODE_ENV;
358
+ process.env.NODE_ENV = 'production';
359
+
360
+ await run(
361
+ {
362
+ origin: 'https://consumer.internal',
363
+ },
364
+ async () => {
365
+ try {
366
+ configure({
367
+ requestId: producer,
368
+ operationContract: {
369
+ enabled: false,
370
+ },
371
+ setDomain: () => 'https://producer.internal',
372
+ });
373
+
374
+ const request = createRequest(
375
+ path,
376
+ method,
377
+ 8080,
378
+ undefined,
379
+ undefined,
380
+ producer,
381
+ );
382
+
383
+ expect(() => request()).toThrow(CrossOriginEnvelopePolicyError);
384
+ } finally {
385
+ process.env.NODE_ENV = previousEnv;
386
+ }
387
+ },
388
+ );
389
+ });
390
+
391
+ test('should allow explicit cross-origin envelope policy and attach envelope header', async () => {
392
+ const producer = 'producer-envelope-policy';
393
+ const producerUrl = 'https://producer.internal';
394
+ const previousEnv = process.env.NODE_ENV;
395
+ process.env.NODE_ENV = 'production';
396
+
397
+ await run(
398
+ {
399
+ origin: 'https://consumer.internal',
400
+ },
401
+ async () => {
402
+ try {
403
+ nock(producerUrl).get(path).reply(200, response);
404
+ const customRequest = rs.fn((requestPath: any, init: any) =>
405
+ fetch(requestPath, init),
406
+ );
407
+
408
+ configure({
409
+ request: customRequest as unknown as typeof fetch,
410
+ requestId: producer,
411
+ operationContract: {
412
+ enabled: false,
413
+ },
414
+ setDomain: () => producerUrl,
415
+ allowCrossOriginEnvelope: ({
416
+ requestId,
417
+ sourceOrigin,
418
+ targetOrigin,
419
+ }) =>
420
+ requestId === producer &&
421
+ sourceOrigin === 'https://consumer.internal' &&
422
+ targetOrigin === producerUrl,
423
+ });
424
+
425
+ const request = createRequest(
426
+ path,
427
+ method,
428
+ 8080,
429
+ undefined,
430
+ undefined,
431
+ producer,
432
+ );
433
+ const res = await request();
434
+ const data = await res.json();
435
+
436
+ const headers = customRequest.mock.calls[0]?.[1]?.headers;
437
+ const envelope = JSON.parse(headers['x-modernjs-bff-envelope']);
438
+ expect(envelope.requestId).toBe(producer);
439
+ expect(envelope.target).toBe('server');
440
+ expect(data).toStrictEqual(response);
441
+ } finally {
442
+ process.env.NODE_ENV = previousEnv;
443
+ }
444
+ },
445
+ );
446
+ });
447
+
448
+ test('should attach operation context headers for non-default producer client', async () => {
449
+ const producer = 'crm.producer-a';
450
+ const producerUrl = 'http://127.0.0.1:18080';
451
+ const traceparent =
452
+ '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
453
+
454
+ await run({ traceparent }, async () => {
455
+ nock(producerUrl).get(path).reply(200, response);
456
+ const customRequest = rs.fn((requestPath: any, init: any) =>
457
+ fetch(requestPath, init),
458
+ );
459
+
460
+ configure({
461
+ request: customRequest as unknown as typeof fetch,
462
+ requestId: producer,
463
+ setDomain: () => producerUrl,
464
+ });
465
+
466
+ const request = createRequest(
467
+ path,
468
+ method,
469
+ 8080,
470
+ undefined,
471
+ undefined,
472
+ producer,
473
+ {
474
+ operationId: `GET:${path}`,
475
+ routePath: path,
476
+ method,
477
+ schemaHash: 'schema-test',
478
+ operationVersion: 1,
479
+ traceparent,
480
+ },
481
+ );
482
+ await request();
483
+
484
+ const headers = customRequest.mock.calls[0]?.[1]?.headers;
485
+ expect(headers['x-operation-id']).toBe(`${producer}:GET:${path}`);
486
+ expect(headers.traceparent).toBe(traceparent);
487
+ const operationContext = JSON.parse(
488
+ headers['x-modernjs-bff-operation-context'],
489
+ );
490
+ expect(operationContext.requestId).toBe(producer);
491
+ expect(operationContext.operationId).toBe(`${producer}:GET:${path}`);
492
+ expect(operationContext.traceparent).toBe(traceparent);
493
+ expect(operationContext.traceId).toBe('4bf92f3577b34da6a3ce929d0e0e4736');
494
+ expect(operationContext.spanId).toBe('00f067aa0ba902b7');
495
+ });
496
+ });
497
+
498
+ test('should reject requests missing schema/version operation contract metadata by default', () => {
499
+ const producer = 'producer-operation-contract-default';
500
+
501
+ configure({
502
+ requestId: producer,
503
+ setDomain: () => 'http://127.0.0.1:19080',
504
+ });
505
+
506
+ const request = createRequest(
507
+ path,
508
+ method,
509
+ 8080,
510
+ undefined,
511
+ undefined,
512
+ producer,
513
+ );
514
+
515
+ expect(() => request()).toThrow(OperationContractViolationError);
516
+ });
517
+
518
+ test('should enforce operation contract metadata for default requestId when strict-default mode is enabled', () => {
519
+ process.env.MODERN_BFF_STRICT_DEFAULT_REQUEST_ID = 'true';
520
+ try {
521
+ configure({
522
+ operationContract: {
523
+ enabled: true,
524
+ strict: true,
525
+ requireSchemaHash: true,
526
+ requireOperationVersion: true,
527
+ },
528
+ });
529
+
530
+ const request = createRequest(path, method, 8080);
531
+ expect(() => request()).toThrow(OperationContractViolationError);
532
+ } finally {
533
+ configure({
534
+ operationContract: {
535
+ enabled: false,
536
+ },
537
+ });
538
+ delete process.env.MODERN_BFF_STRICT_DEFAULT_REQUEST_ID;
539
+ }
540
+ });
541
+
542
+ test('should retry with backoff and emit degraded telemetry events', async () => {
543
+ rs.useFakeTimers();
544
+ const onDegraded = rs.fn();
545
+ let attempts = 0;
546
+
547
+ const customRequest = rs.fn(async () => {
548
+ attempts += 1;
549
+ if (attempts < 3) {
550
+ const error: any = new Error('temporary upstream error');
551
+ error.status = 503;
552
+ throw error;
553
+ }
554
+ return response;
555
+ });
556
+
557
+ try {
558
+ configure({
559
+ request: customRequest as unknown as typeof fetch,
560
+ transport: {
561
+ retry: {
562
+ retries: 2,
563
+ baseDelayMs: 20,
564
+ maxDelayMs: 20,
565
+ jitterRatio: 0,
566
+ },
567
+ onDegraded,
568
+ },
569
+ });
570
+
571
+ const request = createRequest(path, method, 8080);
572
+ const pending = request();
573
+
574
+ await Promise.resolve();
575
+ await rs.advanceTimersByTimeAsync(20);
576
+ await Promise.resolve();
577
+ await rs.advanceTimersByTimeAsync(20);
578
+ await Promise.resolve();
579
+
580
+ await expect(pending).resolves.toStrictEqual(response);
581
+ expect(customRequest).toHaveBeenCalledTimes(3);
582
+ expect(onDegraded).toHaveBeenCalledWith(
583
+ expect.objectContaining({
584
+ reason: 'retry',
585
+ attempt: 1,
586
+ maxAttempts: 3,
587
+ backoffMs: 20,
588
+ }),
589
+ );
590
+ expect(onDegraded).toHaveBeenCalledWith(
591
+ expect.objectContaining({
592
+ reason: 'retry',
593
+ attempt: 2,
594
+ maxAttempts: 3,
595
+ backoffMs: 20,
596
+ }),
597
+ );
598
+ } finally {
599
+ rs.useRealTimers();
600
+ }
601
+ });
602
+
603
+ test('should not retry non-idempotent requests by default', async () => {
604
+ rs.useFakeTimers();
605
+ const onDegraded = rs.fn();
606
+ const customRequest = rs.fn(async () => {
607
+ const error: any = new Error('post failed');
608
+ error.status = 503;
609
+ throw error;
610
+ });
611
+
612
+ try {
613
+ configure({
614
+ request: customRequest as unknown as typeof fetch,
615
+ transport: {
616
+ retry: {
617
+ retries: 2,
618
+ baseDelayMs: 1,
619
+ maxDelayMs: 1,
620
+ jitterRatio: 0,
621
+ },
622
+ onDegraded,
623
+ },
624
+ });
625
+
626
+ const request = createRequest(path, 'POST', 8080);
627
+ const pending = request({ body: 'payload' });
628
+ const failure = expect(pending).rejects.toThrow('post failed');
629
+
630
+ await Promise.resolve();
631
+ await rs.advanceTimersByTimeAsync(1);
632
+ await Promise.resolve();
633
+ await rs.advanceTimersByTimeAsync(1);
634
+ await failure;
635
+
636
+ expect(customRequest).toHaveBeenCalledTimes(1);
637
+ expect(onDegraded).not.toHaveBeenCalledWith(
638
+ expect.objectContaining({ reason: 'retry' }),
639
+ );
640
+ } finally {
641
+ rs.useRealTimers();
642
+ }
643
+ });
644
+
645
+ test('should emit retry_exhausted when retry budget is consumed', async () => {
646
+ rs.useFakeTimers();
647
+ const onDegraded = rs.fn();
648
+ const customRequest = rs.fn(async () => {
649
+ const error: any = new Error('upstream unavailable');
650
+ error.status = 503;
651
+ throw error;
652
+ });
653
+
654
+ try {
655
+ configure({
656
+ request: customRequest as unknown as typeof fetch,
657
+ transport: {
658
+ retry: {
659
+ retries: 1,
660
+ baseDelayMs: 10,
661
+ maxDelayMs: 10,
662
+ jitterRatio: 0,
663
+ },
664
+ onDegraded,
665
+ },
666
+ });
667
+
668
+ const request = createRequest(path, method, 8080);
669
+ const pending = request();
670
+ const failure = expect(pending).rejects.toThrow('upstream unavailable');
671
+
672
+ await Promise.resolve();
673
+ await rs.advanceTimersByTimeAsync(10);
674
+ await failure;
675
+
676
+ expect(customRequest).toHaveBeenCalledTimes(2);
677
+ expect(onDegraded).toHaveBeenCalledWith(
678
+ expect.objectContaining({
679
+ reason: 'retry',
680
+ attempt: 1,
681
+ maxAttempts: 2,
682
+ }),
683
+ );
684
+ expect(onDegraded).toHaveBeenCalledWith(
685
+ expect.objectContaining({
686
+ reason: 'retry_exhausted',
687
+ attempt: 2,
688
+ maxAttempts: 2,
689
+ }),
690
+ );
691
+ } finally {
692
+ rs.useRealTimers();
693
+ }
694
+ });
695
+
696
+ test('should abort timed out requests and emit timeout degraded event', async () => {
697
+ rs.useFakeTimers();
698
+ const onDegraded = rs.fn();
699
+
700
+ const customRequest = rs.fn((_requestPath: any, init?: any) => {
701
+ return new Promise((_, reject) => {
702
+ init?.signal?.addEventListener('abort', () => {
703
+ const error: any = new Error('aborted');
704
+ error.name = 'AbortError';
705
+ reject(error);
706
+ });
707
+ });
708
+ });
709
+
710
+ try {
711
+ configure({
712
+ request: customRequest as unknown as typeof fetch,
713
+ transport: {
714
+ timeoutMs: 50,
715
+ onDegraded,
716
+ },
717
+ });
718
+
719
+ const request = createRequest(path, method, 8080);
720
+ const pending = request();
721
+ const failure = expect(pending).rejects.toMatchObject({
722
+ name: 'TimeoutError',
723
+ });
724
+
725
+ await rs.advanceTimersByTimeAsync(50);
726
+ await failure;
727
+
728
+ expect(customRequest).toHaveBeenCalledTimes(1);
729
+ expect(onDegraded).toHaveBeenCalledWith(
730
+ expect.objectContaining({
731
+ reason: 'timeout',
732
+ timeoutMs: 50,
733
+ attempt: 1,
734
+ maxAttempts: 1,
735
+ }),
736
+ );
737
+ } finally {
738
+ rs.useRealTimers();
739
+ }
740
+ });
741
+ });