@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,691 @@
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 IdentityBindingOptions,
9
+ type OperationContext,
10
+ type OperationContractOptions,
11
+ type TransportTarget,
12
+ } from '../../src/request-policy/types';
13
+
14
+ const ACCEPT_HEADER = 'application/json,*/*;q=0.8';
15
+ const REQUEST_PATH = '/api/widgets';
16
+ const PORT = 8080;
17
+ const SERVER_URL = `http://127.0.0.1:${PORT}${REQUEST_PATH}`;
18
+ const PRODUCER_URL = `https://producer.example${REQUEST_PATH}`;
19
+ const CONSUMER_ORIGIN = 'https://consumer.example';
20
+ const INCOMING_TRACEPARENT =
21
+ '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
22
+ const OPERATION_TRACEPARENT =
23
+ '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01';
24
+
25
+ type HeaderMap = Record<string, any>;
26
+ type Method = 'GET' | 'POST';
27
+
28
+ type OutboundScenario = {
29
+ name: string;
30
+ target: TransportTarget;
31
+ method: Method;
32
+ requestId?: string;
33
+ domain?: string;
34
+ incomingHeaders?: HeaderMap;
35
+ payload: {
36
+ body?: string;
37
+ headers?: HeaderMap;
38
+ };
39
+ identityBinding?: IdentityBindingOptions;
40
+ requireEnvelope?: boolean;
41
+ allowCrossOriginEnvelope?: boolean;
42
+ operationContract?: OperationContractOptions;
43
+ operationContext?: OperationContext;
44
+ expectedUrl: string;
45
+ expectedBody?: string;
46
+ expectedStaticHeaders: HeaderMap;
47
+ assertDynamicHeaders?: (headers: HeaderMap) => void;
48
+ };
49
+
50
+ const restoreStrictDefaultRequestId = (value: string | undefined) => {
51
+ if (typeof value === 'undefined') {
52
+ delete process.env.MODERN_BFF_STRICT_DEFAULT_REQUEST_ID;
53
+ return;
54
+ }
55
+ process.env.MODERN_BFF_STRICT_DEFAULT_REQUEST_ID = value;
56
+ };
57
+
58
+ const createHarness = (
59
+ target: TransportTarget,
60
+ incomingHeaders: HeaderMap = {},
61
+ ) => {
62
+ const request = rs.fn(
63
+ (_requestPath: RequestInfo | URL, _init?: RequestInit) =>
64
+ Promise.resolve(new Response(JSON.stringify({ ok: true }))),
65
+ );
66
+
67
+ rs.spyOn(storage, 'useContext').mockReturnValue({ headers: incomingHeaders });
68
+ const requestFactory =
69
+ target === 'server' ? createServerClient() : createBrowserClient();
70
+
71
+ return {
72
+ request,
73
+ requestFactory,
74
+ };
75
+ };
76
+
77
+ const assertRequestInit = (scenario: OutboundScenario, init: RequestInit) => {
78
+ const headers = init.headers as HeaderMap;
79
+
80
+ expect(init.method).toBe(scenario.method);
81
+ expect(init.body).toBe(scenario.expectedBody);
82
+ for (const [key, value] of Object.entries(scenario.expectedStaticHeaders)) {
83
+ expect(headers[key]).toBe(value);
84
+ }
85
+ expect(
86
+ Object.keys(headers).filter(key => key.toLowerCase() === 'accept'),
87
+ ).toEqual(['accept']);
88
+
89
+ scenario.assertDynamicHeaders?.(headers);
90
+ };
91
+
92
+ const outboundScenarios: OutboundScenario[] = [
93
+ {
94
+ name: 'browser GET strips caller body and canonicalizes accept',
95
+ target: 'browser',
96
+ method: 'GET',
97
+ payload: {
98
+ body: 'drop-me',
99
+ headers: {
100
+ Accept: 'application/problem+json',
101
+ },
102
+ },
103
+ expectedUrl: REQUEST_PATH,
104
+ expectedStaticHeaders: {
105
+ 'Content-Type': 'text/plain',
106
+ accept: ACCEPT_HEADER,
107
+ },
108
+ },
109
+ {
110
+ name: 'browser POST keeps caller body without propagating incoming traceparent',
111
+ target: 'browser',
112
+ method: 'POST',
113
+ incomingHeaders: {
114
+ traceparent: INCOMING_TRACEPARENT,
115
+ },
116
+ payload: {
117
+ body: 'keep-me',
118
+ headers: {
119
+ Accept: 'application/problem+json',
120
+ },
121
+ },
122
+ expectedUrl: REQUEST_PATH,
123
+ expectedBody: 'keep-me',
124
+ expectedStaticHeaders: {
125
+ 'Content-Type': 'text/plain',
126
+ accept: ACCEPT_HEADER,
127
+ },
128
+ },
129
+ {
130
+ name: 'server GET strips caller body and propagates incoming traceparent',
131
+ target: 'server',
132
+ method: 'GET',
133
+ incomingHeaders: {
134
+ traceparent: INCOMING_TRACEPARENT,
135
+ },
136
+ payload: {
137
+ body: 'drop-me',
138
+ headers: {
139
+ Accept: 'application/problem+json',
140
+ },
141
+ },
142
+ expectedUrl: SERVER_URL,
143
+ expectedStaticHeaders: {
144
+ 'Content-Type': 'text/plain',
145
+ accept: ACCEPT_HEADER,
146
+ traceparent: INCOMING_TRACEPARENT,
147
+ },
148
+ },
149
+ {
150
+ name: 'server POST keeps caller body and propagates incoming traceparent',
151
+ target: 'server',
152
+ method: 'POST',
153
+ incomingHeaders: {
154
+ traceparent: INCOMING_TRACEPARENT,
155
+ },
156
+ payload: {
157
+ body: 'keep-me',
158
+ headers: {
159
+ Accept: 'application/problem+json',
160
+ },
161
+ },
162
+ expectedUrl: SERVER_URL,
163
+ expectedBody: 'keep-me',
164
+ expectedStaticHeaders: {
165
+ 'Content-Type': 'text/plain',
166
+ accept: ACCEPT_HEADER,
167
+ traceparent: INCOMING_TRACEPARENT,
168
+ },
169
+ },
170
+ {
171
+ name: 'protected identity binding replaces caller case variant',
172
+ target: 'server',
173
+ method: 'POST',
174
+ payload: {
175
+ body: 'keep-me',
176
+ headers: {
177
+ Accept: 'application/problem+json',
178
+ 'X-Tenant-Id': 'caller-tenant',
179
+ },
180
+ },
181
+ identityBinding: {
182
+ enabled: true,
183
+ strict: false,
184
+ protectedHeaders: ['x-tenant-id'],
185
+ deriveHeaders: () => ({
186
+ 'x-tenant-id': 'bound-tenant',
187
+ }),
188
+ },
189
+ expectedUrl: SERVER_URL,
190
+ expectedBody: 'keep-me',
191
+ expectedStaticHeaders: {
192
+ 'Content-Type': 'text/plain',
193
+ accept: ACCEPT_HEADER,
194
+ 'x-tenant-id': 'bound-tenant',
195
+ },
196
+ },
197
+ {
198
+ name: 'configured secured request emits envelope and operation context',
199
+ target: 'server',
200
+ method: 'POST',
201
+ requestId: 'producer-checkout',
202
+ domain: 'https://producer.example',
203
+ incomingHeaders: {
204
+ origin: CONSUMER_ORIGIN,
205
+ },
206
+ payload: {
207
+ body: 'keep-me',
208
+ headers: {
209
+ Accept: 'application/problem+json',
210
+ },
211
+ },
212
+ requireEnvelope: true,
213
+ allowCrossOriginEnvelope: true,
214
+ operationContract: {
215
+ enabled: true,
216
+ requireSchemaHash: true,
217
+ requireOperationVersion: true,
218
+ },
219
+ operationContext: {
220
+ operationId: 'create-widget',
221
+ routePath: REQUEST_PATH,
222
+ method: 'POST',
223
+ schemaHash: 'sha256:create-widget',
224
+ operationVersion: 3,
225
+ traceparent: OPERATION_TRACEPARENT,
226
+ },
227
+ expectedUrl: PRODUCER_URL,
228
+ expectedBody: 'keep-me',
229
+ expectedStaticHeaders: {
230
+ 'Content-Type': 'text/plain',
231
+ accept: ACCEPT_HEADER,
232
+ traceparent: OPERATION_TRACEPARENT,
233
+ [BFF_OPERATION_CONTEXT_HEADER]: 'producer-checkout:create-widget',
234
+ },
235
+ assertDynamicHeaders: headers => {
236
+ const envelope = JSON.parse(headers[BFF_ENVELOPE_HEADER]);
237
+ expect(envelope).toMatchObject({
238
+ requestId: 'producer-checkout',
239
+ target: 'server',
240
+ sourceOrigin: CONSUMER_ORIGIN,
241
+ targetOrigin: 'https://producer.example',
242
+ traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
243
+ spanId: 'bbbbbbbbbbbbbbbb',
244
+ });
245
+ expect(typeof envelope.timestamp).toBe('number');
246
+
247
+ const operationContext = JSON.parse(
248
+ headers[BFF_OPERATION_CONTEXT_DETAIL_HEADER],
249
+ );
250
+ expect(operationContext).toMatchObject({
251
+ requestId: 'producer-checkout',
252
+ operationId: 'producer-checkout:create-widget',
253
+ routePath: REQUEST_PATH,
254
+ method: 'POST',
255
+ schemaHash: 'sha256:create-widget',
256
+ operationVersion: 3,
257
+ traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
258
+ spanId: 'bbbbbbbbbbbbbbbb',
259
+ });
260
+ },
261
+ },
262
+ ];
263
+
264
+ describe('requestFactory outbound request contract', () => {
265
+ for (const scenario of outboundScenarios) {
266
+ test(scenario.name, async () => {
267
+ const strictDefaultRequestId =
268
+ process.env.MODERN_BFF_STRICT_DEFAULT_REQUEST_ID;
269
+ delete process.env.MODERN_BFF_STRICT_DEFAULT_REQUEST_ID;
270
+
271
+ try {
272
+ const { request, requestFactory } = createHarness(
273
+ scenario.target,
274
+ scenario.incomingHeaders,
275
+ );
276
+ const requestId = scenario.requestId || 'default';
277
+
278
+ requestFactory.configure({
279
+ request: request as unknown as typeof fetch,
280
+ requestId,
281
+ ...(requestId !== 'default'
282
+ ? { setDomain: () => scenario.domain || '' }
283
+ : {}),
284
+ ...(scenario.identityBinding
285
+ ? { identityBinding: scenario.identityBinding }
286
+ : {}),
287
+ ...(typeof scenario.requireEnvelope === 'boolean'
288
+ ? { requireEnvelope: scenario.requireEnvelope }
289
+ : {}),
290
+ ...(typeof scenario.allowCrossOriginEnvelope === 'boolean'
291
+ ? { allowCrossOriginEnvelope: scenario.allowCrossOriginEnvelope }
292
+ : {}),
293
+ ...(scenario.operationContract
294
+ ? { operationContract: scenario.operationContract }
295
+ : {}),
296
+ });
297
+
298
+ const send = requestFactory.createRequest({
299
+ path: REQUEST_PATH,
300
+ method: scenario.method,
301
+ port: PORT,
302
+ requestId,
303
+ operationContext: scenario.operationContext,
304
+ });
305
+
306
+ await send(scenario.payload);
307
+
308
+ expect(request).toHaveBeenCalledTimes(1);
309
+ const [url, init] = request.mock.calls[0];
310
+ expect(String(url)).toBe(scenario.expectedUrl);
311
+ assertRequestInit(scenario, init as RequestInit);
312
+ } finally {
313
+ restoreStrictDefaultRequestId(strictDefaultRequestId);
314
+ }
315
+ });
316
+ }
317
+
318
+ test('inputParams forwards only allowlisted and server-derived identity headers', async () => {
319
+ const requestId = 'producer-input-params-identity';
320
+ const { request, requestFactory } = createHarness('server', {
321
+ 'x-tenant-id': 'tenant-server',
322
+ 'x-subject-id': 'subject-server',
323
+ 'x-forwarded-feature': 'feature-server',
324
+ 'x-private-incoming': 'must-not-forward',
325
+ });
326
+
327
+ requestFactory.configure({
328
+ request: request as unknown as typeof fetch,
329
+ requestId,
330
+ allowedHeaders: ['x-forwarded-feature'],
331
+ operationContract: { enabled: false },
332
+ requireEnvelope: false,
333
+ setDomain: () => 'https://producer.example',
334
+ });
335
+
336
+ const send = requestFactory.createRequest({
337
+ path: REQUEST_PATH,
338
+ method: 'POST',
339
+ port: PORT,
340
+ httpMethodDecider: 'inputParams',
341
+ requestId,
342
+ });
343
+
344
+ await send('widget');
345
+
346
+ expect(request).toHaveBeenCalledTimes(1);
347
+ const headers = request.mock.calls[0][1]?.headers as HeaderMap;
348
+ expect(headers).toMatchObject({
349
+ 'x-tenant-id': 'tenant-server',
350
+ 'x-subject-id': 'subject-server',
351
+ 'x-forwarded-feature': 'feature-server',
352
+ });
353
+ expect(headers['x-private-incoming']).toBeUndefined();
354
+ });
355
+
356
+ test('uploader forwards policy, allowlisted, and server-derived headers', async () => {
357
+ const requestId = 'producer-uploader-identity';
358
+ const { request, requestFactory } = createHarness('server', {
359
+ origin: CONSUMER_ORIGIN,
360
+ 'x-tenant-id': 'tenant-server',
361
+ 'x-subject-id': 'subject-server',
362
+ 'x-forwarded-feature': 'feature-server',
363
+ 'x-private-incoming': 'must-not-forward',
364
+ });
365
+
366
+ requestFactory.configure({
367
+ request: request as unknown as typeof fetch,
368
+ requestId,
369
+ allowedHeaders: ['x-forwarded-feature'],
370
+ operationContract: {
371
+ enabled: true,
372
+ requireSchemaHash: true,
373
+ requireOperationVersion: true,
374
+ },
375
+ requireEnvelope: true,
376
+ allowCrossOriginEnvelope: true,
377
+ setDomain: () => 'https://producer.example',
378
+ });
379
+
380
+ const upload = requestFactory.createUploader({
381
+ path: REQUEST_PATH,
382
+ requestId,
383
+ operationContext: {
384
+ operationId: 'upload-widget',
385
+ routePath: REQUEST_PATH,
386
+ method: 'POST',
387
+ schemaHash: 'sha256:upload-widget',
388
+ operationVersion: 1,
389
+ traceparent: OPERATION_TRACEPARENT,
390
+ },
391
+ });
392
+
393
+ await upload({
394
+ files: {
395
+ file: new File(['widget'], 'widget.txt', { type: 'text/plain' }),
396
+ },
397
+ });
398
+
399
+ expect(request).toHaveBeenCalledTimes(1);
400
+ const [, init] = request.mock.calls[0];
401
+ expect(init.method).toBe('POST');
402
+ expect(init.body).toBeInstanceOf(FormData);
403
+ const form = init.body as FormData;
404
+ const uploaded = form.get('file');
405
+ expect(uploaded).toBeInstanceOf(File);
406
+ expect(uploaded).toMatchObject({
407
+ name: 'widget.txt',
408
+ type: 'text/plain',
409
+ });
410
+ expect(await (uploaded as File).text()).toBe('widget');
411
+ const headers = request.mock.calls[0][1]?.headers as HeaderMap;
412
+ expect(headers).toMatchObject({
413
+ 'x-tenant-id': 'tenant-server',
414
+ 'x-subject-id': 'subject-server',
415
+ 'x-forwarded-feature': 'feature-server',
416
+ });
417
+ expect(headers['x-private-incoming']).toBeUndefined();
418
+ const envelope = JSON.parse(headers[BFF_ENVELOPE_HEADER]);
419
+ expect(envelope).toMatchObject({
420
+ requestId,
421
+ target: 'server',
422
+ sourceOrigin: CONSUMER_ORIGIN,
423
+ targetOrigin: 'https://producer.example',
424
+ });
425
+ expect(headers[BFF_OPERATION_CONTEXT_HEADER]).toBe(
426
+ `${requestId}:upload-widget`,
427
+ );
428
+ const operationContext = JSON.parse(
429
+ headers[BFF_OPERATION_CONTEXT_DETAIL_HEADER],
430
+ );
431
+ expect(operationContext).toMatchObject({
432
+ requestId,
433
+ operationId: `${requestId}:upload-widget`,
434
+ routePath: REQUEST_PATH,
435
+ method: 'POST',
436
+ schemaHash: 'sha256:upload-widget',
437
+ operationVersion: 1,
438
+ traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
439
+ spanId: 'bbbbbbbbbbbbbbbb',
440
+ });
441
+ });
442
+
443
+ test('forwards allowlisted and resolved headers without casing assumptions', async () => {
444
+ const resolveHeaders = rs.fn(() => ({
445
+ AUTHORIZATION: 'Bearer resolved',
446
+ 'x-injected': 'must-not-forward',
447
+ }));
448
+ const { request, requestFactory } = createHarness('server', {
449
+ Authorization: 'Bearer incoming',
450
+ });
451
+
452
+ requestFactory.configure({
453
+ request: request as unknown as typeof fetch,
454
+ allowedHeaders: ['authorization'],
455
+ resolveHeaders,
456
+ });
457
+
458
+ const send = requestFactory.createRequest({
459
+ path: REQUEST_PATH,
460
+ method: 'GET',
461
+ port: PORT,
462
+ });
463
+ await send({
464
+ headers: { Authorization: 'Bearer caller' },
465
+ });
466
+
467
+ expect(resolveHeaders).toHaveBeenCalledWith(
468
+ expect.objectContaining({
469
+ incomingHeaders: { authorization: 'Bearer incoming' },
470
+ }),
471
+ );
472
+ const headers = request.mock.calls[0][1]?.headers as HeaderMap;
473
+ expect(headers).toMatchObject({
474
+ authorization: 'Bearer resolved',
475
+ });
476
+ expect(headers['x-injected']).toBeUndefined();
477
+ expect(
478
+ Object.keys(headers).filter(key => key.toLowerCase() === 'authorization'),
479
+ ).toEqual(['authorization']);
480
+ });
481
+
482
+ test('reconfiguration removes omitted header and envelope policy', async () => {
483
+ const resolveHeaders = rs.fn(() => ({
484
+ authorization: 'Bearer stale',
485
+ }));
486
+ const { request, requestFactory } = createHarness('server', {
487
+ authorization: 'Bearer incoming',
488
+ });
489
+
490
+ requestFactory.configure({
491
+ request: request as unknown as typeof fetch,
492
+ allowedHeaders: ['authorization'],
493
+ resolveHeaders,
494
+ requireEnvelope: true,
495
+ identityBinding: {
496
+ enabled: true,
497
+ strict: false,
498
+ protectedHeaders: ['x-user-id'],
499
+ deriveHeaders: () => ({ 'x-user-id': 'stale-user' }),
500
+ },
501
+ });
502
+ requestFactory.configure({
503
+ request: request as unknown as typeof fetch,
504
+ });
505
+
506
+ const send = requestFactory.createRequest({
507
+ path: REQUEST_PATH,
508
+ method: 'GET',
509
+ port: PORT,
510
+ });
511
+ await send();
512
+
513
+ expect(resolveHeaders).not.toHaveBeenCalled();
514
+ expect(request.mock.calls[0][1]?.headers).toEqual({
515
+ accept: ACCEPT_HEADER,
516
+ });
517
+ });
518
+
519
+ test('reconfiguration removes omitted retry policy', async () => {
520
+ const failure = Object.assign(new Error('unavailable'), { status: 503 });
521
+ const request = rs.fn(async () => {
522
+ throw failure;
523
+ });
524
+ const { requestFactory } = createHarness('server');
525
+
526
+ requestFactory.configure({
527
+ request: request as unknown as typeof fetch,
528
+ transport: {
529
+ retry: {
530
+ retries: 1,
531
+ baseDelayMs: 1,
532
+ maxDelayMs: 1,
533
+ jitterRatio: 0,
534
+ },
535
+ },
536
+ });
537
+ requestFactory.configure({
538
+ request: request as unknown as typeof fetch,
539
+ });
540
+
541
+ const send = requestFactory.createRequest({
542
+ path: REQUEST_PATH,
543
+ method: 'GET',
544
+ port: PORT,
545
+ });
546
+
547
+ await expect(send()).rejects.toBe(failure);
548
+ expect(request).toHaveBeenCalledTimes(1);
549
+ });
550
+
551
+ test('reconfiguration restores omitted secured-producer defaults', () => {
552
+ const requestId = 'producer-policy-reset';
553
+ const { request, requestFactory } = createHarness('server');
554
+
555
+ requestFactory.configure({
556
+ request: request as unknown as typeof fetch,
557
+ requestId,
558
+ requireEnvelope: false,
559
+ identityBinding: { enabled: false },
560
+ operationContract: { enabled: false },
561
+ setDomain: () => 'https://producer.example',
562
+ });
563
+ requestFactory.configure({
564
+ request: request as unknown as typeof fetch,
565
+ requestId,
566
+ });
567
+
568
+ const send = requestFactory.createRequest({
569
+ path: REQUEST_PATH,
570
+ method: 'GET',
571
+ port: PORT,
572
+ requestId,
573
+ });
574
+
575
+ expect(() => send()).toThrow('missing_schema_hash');
576
+ });
577
+
578
+ test('uploader uses an explicitly configured POST retry policy', async () => {
579
+ rs.useFakeTimers();
580
+ const retryableError = Object.assign(new Error('upload unavailable'), {
581
+ status: 503,
582
+ });
583
+ const response = new Response('{}', { status: 200 });
584
+ const onDegraded = rs.fn();
585
+ let attempts = 0;
586
+ const request = rs.fn(async () => {
587
+ attempts += 1;
588
+ if (attempts === 1) {
589
+ throw retryableError;
590
+ }
591
+ return response;
592
+ });
593
+ const { requestFactory } = createHarness('server');
594
+
595
+ requestFactory.configure({
596
+ request: request as unknown as typeof fetch,
597
+ transport: {
598
+ retry: {
599
+ retries: 1,
600
+ baseDelayMs: 5,
601
+ maxDelayMs: 5,
602
+ jitterRatio: 0,
603
+ shouldRetry: ({ method }) => method === 'POST',
604
+ },
605
+ onDegraded,
606
+ },
607
+ });
608
+ const upload = requestFactory.createUploader({ path: REQUEST_PATH });
609
+ const pending = upload({
610
+ files: { file: new File(['widget'], 'widget.txt') },
611
+ });
612
+ const observed = pending.catch(error => error);
613
+
614
+ try {
615
+ await Promise.resolve();
616
+ expect(request).toHaveBeenCalledTimes(1);
617
+ await rs.advanceTimersByTimeAsync(5);
618
+
619
+ await expect(pending).resolves.toBe(response);
620
+ expect(request).toHaveBeenCalledTimes(2);
621
+ expect(onDegraded).toHaveBeenCalledWith(
622
+ expect.objectContaining({
623
+ reason: 'retry',
624
+ method: 'POST',
625
+ attempt: 1,
626
+ maxAttempts: 2,
627
+ }),
628
+ );
629
+ } finally {
630
+ await rs.advanceTimersByTimeAsync(1000);
631
+ await observed;
632
+ rs.useRealTimers();
633
+ }
634
+ });
635
+
636
+ test('uploader timeout aborts transport and emits degraded telemetry', async () => {
637
+ rs.useFakeTimers();
638
+ const onDegraded = rs.fn();
639
+ const cleanupError = new Error('test cleanup');
640
+ let rejectRequest: ((error: Error) => void) | undefined;
641
+ let inFlightSignal: AbortSignal | undefined;
642
+ const request = rs.fn((_url: RequestInfo | URL, init?: RequestInit) => {
643
+ inFlightSignal = init?.signal || undefined;
644
+ return new Promise((_, reject) => {
645
+ rejectRequest = reject;
646
+ init?.signal?.addEventListener('abort', () => {
647
+ const error = new Error('aborted');
648
+ error.name = 'AbortError';
649
+ reject(error);
650
+ });
651
+ });
652
+ });
653
+ const { requestFactory } = createHarness('server');
654
+
655
+ requestFactory.configure({
656
+ request: request as unknown as typeof fetch,
657
+ transport: {
658
+ timeoutMs: 20,
659
+ onDegraded,
660
+ },
661
+ });
662
+ const upload = requestFactory.createUploader({ path: REQUEST_PATH });
663
+ const pending = upload({
664
+ files: { file: new File(['widget'], 'widget.txt') },
665
+ });
666
+ const observed = pending.catch(error => error);
667
+
668
+ try {
669
+ await Promise.resolve();
670
+ expect(inFlightSignal).toBeDefined();
671
+ await rs.advanceTimersByTimeAsync(20);
672
+
673
+ await expect(observed).resolves.toMatchObject({ name: 'TimeoutError' });
674
+ expect(inFlightSignal?.aborted).toBe(true);
675
+ expect(onDegraded).toHaveBeenCalledWith(
676
+ expect.objectContaining({
677
+ reason: 'timeout',
678
+ method: 'POST',
679
+ timeoutMs: 20,
680
+ }),
681
+ );
682
+ } finally {
683
+ if (!inFlightSignal?.aborted) {
684
+ rejectRequest?.(cleanupError);
685
+ }
686
+ await observed;
687
+ await rs.advanceTimersByTimeAsync(1000);
688
+ rs.useRealTimers();
689
+ }
690
+ });
691
+ });