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

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 +170 -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 +117 -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 +118 -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 +77 -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 +340 -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 +38 -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,55 @@
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
+ ])('parses a valid traceparent with flags %s', (flags, sampled) => {
11
+ expect(parseTraceparent(`00-${traceId}-${spanId}-${flags}`)).toEqual({
12
+ traceId,
13
+ spanId,
14
+ sampled,
15
+ });
16
+ });
17
+
18
+ test('lowercases mixed-case ids and trims surrounding whitespace', () => {
19
+ expect(
20
+ parseTraceparent(
21
+ ` 00-${traceId.toUpperCase()}-${spanId.toUpperCase()}-01 `,
22
+ ),
23
+ ).toEqual({
24
+ traceId,
25
+ spanId,
26
+ sampled: true,
27
+ });
28
+ });
29
+
30
+ test('reads the sampled bit from arbitrary flag values', () => {
31
+ expect(parseTraceparent(`00-${traceId}-${spanId}-03`)?.sampled).toBe(true);
32
+ expect(parseTraceparent(`00-${traceId}-${spanId}-02`)?.sampled).toBe(false);
33
+ });
34
+
35
+ test('rejects missing, empty, and malformed headers', () => {
36
+ expect(parseTraceparent(undefined)).toBeUndefined();
37
+ expect(parseTraceparent(null)).toBeUndefined();
38
+ expect(parseTraceparent('')).toBeUndefined();
39
+ expect(parseTraceparent('not-a-traceparent')).toBeUndefined();
40
+ expect(parseTraceparent(`00-${traceId}-${spanId}`)).toBeUndefined();
41
+ expect(parseTraceparent(`01-${traceId}-${spanId}-01`)).toBeUndefined();
42
+ expect(
43
+ parseTraceparent(`00-${traceId.slice(1)}-${spanId}-01`),
44
+ ).toBeUndefined();
45
+ });
46
+
47
+ test('rejects all-zero trace and span ids per the W3C spec', () => {
48
+ expect(
49
+ parseTraceparent(`00-${'0'.repeat(32)}-${spanId}-01`),
50
+ ).toBeUndefined();
51
+ expect(
52
+ parseTraceparent(`00-${traceId}-${'0'.repeat(16)}-01`),
53
+ ).toBeUndefined();
54
+ });
55
+ });
@@ -0,0 +1,370 @@
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
+ 'TimeoutError',
54
+ ])('retries %s up to the configured max then rejects', async errorName => {
55
+ rs.useFakeTimers();
56
+ const errors = [
57
+ createNamedError(errorName),
58
+ createNamedError(errorName),
59
+ createNamedError(errorName),
60
+ ];
61
+ let callCount = 0;
62
+ const fetcher = rs.fn(async () => {
63
+ const error = errors[callCount];
64
+ callCount += 1;
65
+ throw error;
66
+ });
67
+
68
+ const pending = executeRetryRequest({
69
+ fetcher,
70
+ retry: {
71
+ retries: 2,
72
+ baseDelayMs: 10,
73
+ maxDelayMs: 100,
74
+ jitterRatio: 0,
75
+ },
76
+ });
77
+ const observed = pending.catch(error => error);
78
+
79
+ try {
80
+ await flushMicrotasks();
81
+ expect(fetcher).toHaveBeenCalledTimes(1);
82
+
83
+ await rs.advanceTimersByTimeAsync(9);
84
+ expect(fetcher).toHaveBeenCalledTimes(1);
85
+ await rs.advanceTimersByTimeAsync(1);
86
+ await flushMicrotasks();
87
+ expect(fetcher).toHaveBeenCalledTimes(2);
88
+
89
+ await rs.advanceTimersByTimeAsync(19);
90
+ expect(fetcher).toHaveBeenCalledTimes(2);
91
+ await rs.advanceTimersByTimeAsync(1);
92
+ await flushMicrotasks();
93
+ expect(fetcher).toHaveBeenCalledTimes(3);
94
+ await expect(observed).resolves.toBe(errors[2]);
95
+ } finally {
96
+ await rs.advanceTimersByTimeAsync(1000);
97
+ await observed;
98
+ rs.useRealTimers();
99
+ }
100
+ });
101
+
102
+ test('should not retry caller-aborted requests', async () => {
103
+ rs.useFakeTimers();
104
+ const onDegraded = rs.fn();
105
+ const abortController = new AbortController();
106
+ abortController.abort();
107
+ const abortError = createNamedError('AbortError');
108
+ const fetcher = rs.fn(async (_url: string, init: RequestInit) => {
109
+ expect(init.signal).toBe(abortController.signal);
110
+ throw abortError;
111
+ });
112
+
113
+ const pending = executeRetryRequest({
114
+ fetcher,
115
+ init: {
116
+ method: 'GET',
117
+ signal: abortController.signal,
118
+ },
119
+ retry: {
120
+ retries: 2,
121
+ baseDelayMs: 1,
122
+ maxDelayMs: 1,
123
+ jitterRatio: 0,
124
+ },
125
+ onDegraded,
126
+ });
127
+ const observed = pending.catch(error => error);
128
+
129
+ try {
130
+ await flushMicrotasks();
131
+ expect(fetcher).toHaveBeenCalledTimes(1);
132
+ expect(onDegraded).not.toHaveBeenCalledWith(
133
+ expect.objectContaining({ reason: 'retry' }),
134
+ );
135
+ await expect(observed).resolves.toBe(abortError);
136
+ } finally {
137
+ await rs.advanceTimersByTimeAsync(5);
138
+ await observed;
139
+ rs.useRealTimers();
140
+ }
141
+ });
142
+
143
+ test('retries default retryable status codes after the backoff delay', async () => {
144
+ rs.useFakeTimers();
145
+
146
+ try {
147
+ for (const statusCode of retryableStatusCodes) {
148
+ const retryableError = createStatusError(statusCode);
149
+ const response = { ok: true, status: 200 };
150
+ let callCount = 0;
151
+ const fetcher = rs.fn(async () => {
152
+ callCount += 1;
153
+ if (callCount === 1) {
154
+ throw retryableError;
155
+ }
156
+ return response;
157
+ });
158
+
159
+ const pending = executeRetryRequest({
160
+ fetcher,
161
+ retry: {
162
+ retries: 1,
163
+ baseDelayMs: 5,
164
+ maxDelayMs: 5,
165
+ jitterRatio: 0,
166
+ },
167
+ });
168
+
169
+ await flushMicrotasks();
170
+ expect(fetcher).toHaveBeenCalledTimes(1);
171
+ await rs.advanceTimersByTimeAsync(4);
172
+ expect(fetcher).toHaveBeenCalledTimes(1);
173
+ await rs.advanceTimersByTimeAsync(1);
174
+ await expect(pending).resolves.toBe(response);
175
+ expect(fetcher).toHaveBeenCalledTimes(2);
176
+ }
177
+ } finally {
178
+ await rs.advanceTimersByTimeAsync(1000);
179
+ rs.useRealTimers();
180
+ }
181
+ });
182
+
183
+ test('does not retry non-retryable status codes', async () => {
184
+ for (const statusCode of nonRetryableStatusCodes) {
185
+ const error = createStatusError(statusCode);
186
+ const fetcher = rs.fn(async () => {
187
+ throw error;
188
+ });
189
+
190
+ await expect(
191
+ executeRetryRequest({
192
+ fetcher,
193
+ retry: {
194
+ retries: 2,
195
+ baseDelayMs: 1,
196
+ maxDelayMs: 1,
197
+ jitterRatio: 0,
198
+ },
199
+ }),
200
+ ).rejects.toBe(error);
201
+ expect(fetcher).toHaveBeenCalledTimes(1);
202
+ }
203
+ });
204
+
205
+ test('uses a custom shouldRetry predicate instead of default rules', async () => {
206
+ rs.useFakeTimers();
207
+ const retryableByPolicy = createStatusError(400);
208
+ const response = { ok: true, status: 200 };
209
+ const shouldRetry = rs.fn(() => true);
210
+ let callCount = 0;
211
+ const fetcher = rs.fn(async () => {
212
+ callCount += 1;
213
+ if (callCount === 1) {
214
+ throw retryableByPolicy;
215
+ }
216
+ return response;
217
+ });
218
+
219
+ const pending = executeRetryRequest({
220
+ fetcher,
221
+ method: 'POST',
222
+ init: { method: 'POST' },
223
+ retry: {
224
+ retries: 1,
225
+ baseDelayMs: 7,
226
+ maxDelayMs: 7,
227
+ jitterRatio: 0,
228
+ shouldRetry,
229
+ },
230
+ });
231
+
232
+ try {
233
+ await flushMicrotasks();
234
+ expect(fetcher).toHaveBeenCalledTimes(1);
235
+ expect(shouldRetry).toHaveBeenCalledWith(
236
+ expect.objectContaining({
237
+ attempt: 1,
238
+ error: retryableByPolicy,
239
+ maxAttempts: 2,
240
+ method: 'POST',
241
+ statusCode: 400,
242
+ }),
243
+ );
244
+
245
+ await rs.advanceTimersByTimeAsync(6);
246
+ expect(fetcher).toHaveBeenCalledTimes(1);
247
+ await rs.advanceTimersByTimeAsync(1);
248
+ await expect(pending).resolves.toBe(response);
249
+ expect(fetcher).toHaveBeenCalledTimes(2);
250
+ } finally {
251
+ await rs.advanceTimersByTimeAsync(1000);
252
+ rs.useRealTimers();
253
+ }
254
+ });
255
+
256
+ test('does not retry non-idempotent methods by default', async () => {
257
+ const error = createNamedError('FetchError');
258
+ const fetcher = rs.fn(async () => {
259
+ throw error;
260
+ });
261
+
262
+ await expect(
263
+ executeRetryRequest({
264
+ fetcher,
265
+ method: 'POST',
266
+ init: { method: 'POST' },
267
+ retry: {
268
+ retries: 2,
269
+ baseDelayMs: 1,
270
+ maxDelayMs: 1,
271
+ jitterRatio: 0,
272
+ },
273
+ }),
274
+ ).rejects.toBe(error);
275
+ expect(fetcher).toHaveBeenCalledTimes(1);
276
+ });
277
+
278
+ test('timeout aborts the in-flight request when a caller signal is present', async () => {
279
+ rs.useFakeTimers();
280
+ const callerController = new AbortController();
281
+ const onDegraded = rs.fn();
282
+ let inFlightSignal: AbortSignal | undefined;
283
+ const fetcher = rs.fn((_url: string, init: RequestInit) => {
284
+ inFlightSignal = init.signal || undefined;
285
+ return new Promise((_, reject) => {
286
+ init.signal?.addEventListener('abort', () => {
287
+ reject(createNamedError('AbortError'));
288
+ });
289
+ });
290
+ });
291
+
292
+ const pending = executeWithResilience({
293
+ requestId: 'transport-timeout-with-caller-signal',
294
+ target: 'browser',
295
+ method: 'GET',
296
+ url: requestUrl,
297
+ init: {
298
+ method: 'GET',
299
+ signal: callerController.signal,
300
+ },
301
+ fetcher,
302
+ transport: {
303
+ timeoutMs: 25,
304
+ onDegraded,
305
+ },
306
+ });
307
+ const observed = pending.catch(error => error);
308
+
309
+ try {
310
+ await flushMicrotasks();
311
+ expect(inFlightSignal?.aborted).toBe(false);
312
+ await rs.advanceTimersByTimeAsync(25);
313
+
314
+ await expect(observed).resolves.toMatchObject({ name: 'TimeoutError' });
315
+ expect(inFlightSignal?.aborted).toBe(true);
316
+ expect(callerController.signal.aborted).toBe(false);
317
+ expect(onDegraded).toHaveBeenCalledWith(
318
+ expect.objectContaining({ reason: 'timeout', timeoutMs: 25 }),
319
+ );
320
+ } finally {
321
+ await rs.advanceTimersByTimeAsync(1000);
322
+ await observed;
323
+ rs.useRealTimers();
324
+ }
325
+ });
326
+
327
+ test('caller abort still cancels a request that has a timeout', async () => {
328
+ rs.useFakeTimers();
329
+ const callerController = new AbortController();
330
+ const onDegraded = rs.fn();
331
+ let inFlightSignal: AbortSignal | undefined;
332
+ const abortError = createNamedError('AbortError');
333
+ const fetcher = rs.fn((_url: string, init: RequestInit) => {
334
+ inFlightSignal = init.signal || undefined;
335
+ return new Promise((_, reject) => {
336
+ init.signal?.addEventListener('abort', () => reject(abortError));
337
+ });
338
+ });
339
+
340
+ const pending = executeWithResilience({
341
+ requestId: 'transport-caller-abort-with-timeout',
342
+ target: 'browser',
343
+ method: 'GET',
344
+ url: requestUrl,
345
+ init: {
346
+ method: 'GET',
347
+ signal: callerController.signal,
348
+ },
349
+ fetcher,
350
+ transport: {
351
+ timeoutMs: 25,
352
+ onDegraded,
353
+ },
354
+ });
355
+ const observed = pending.catch(error => error);
356
+
357
+ try {
358
+ await flushMicrotasks();
359
+ callerController.abort();
360
+
361
+ await expect(observed).resolves.toBe(abortError);
362
+ expect(inFlightSignal?.aborted).toBe(true);
363
+ expect(onDegraded).not.toHaveBeenCalled();
364
+ } finally {
365
+ await rs.advanceTimersByTimeAsync(1000);
366
+ await observed;
367
+ rs.useRealTimers();
368
+ }
369
+ });
370
+ });
@@ -0,0 +1,180 @@
1
+ import {
2
+ createRouteHydrationScriptTags,
3
+ getMatchedRouteChunks,
4
+ injectBeforeHydrationEntryScript,
5
+ orderHydrationScriptChunks,
6
+ replaceChunkJsPlaceholder,
7
+ } from '../src/routeHydration';
8
+
9
+ const manifest = {
10
+ routeAssets: {
11
+ layout: { assets: ['/shared.js', '/layout.css'] },
12
+ page: { assets: ['/page.js', '/shared.js'] },
13
+ empty: {},
14
+ missing: undefined,
15
+ 'async-main': { assets: ['/shared.js', '/async-main.hash.js'] },
16
+ },
17
+ };
18
+
19
+ const tag = (src: string) => `<script src="${src}"></script>`;
20
+
21
+ describe('route hydration policy', () => {
22
+ it('maps matched assets in route order without deduplicating or filtering', () => {
23
+ const mapped = getMatchedRouteChunks(
24
+ manifest,
25
+ ['layout', 'unknown', 'empty', 'missing', 'page'],
26
+ asset => ({ url: asset, marker: 'preserved' }),
27
+ );
28
+ expect(mapped).toEqual(
29
+ ['/shared.js', '/layout.css', '/page.js', '/shared.js'].map(url => ({
30
+ url,
31
+ marker: 'preserved',
32
+ })),
33
+ );
34
+ });
35
+
36
+ it('returns no chunks for an explicit missing manifest or empty matches', () => {
37
+ const toChunk = rstest.fn((url: string) => ({ url }));
38
+ expect(getMatchedRouteChunks(undefined, ['page'], toChunk)).toEqual([]);
39
+ expect(getMatchedRouteChunks({}, ['page'], toChunk)).toEqual([]);
40
+ expect(getMatchedRouteChunks(manifest, [], toChunk)).toEqual([]);
41
+ expect(toChunk).not.toHaveBeenCalled();
42
+ expect(createRouteHydrationScriptTags(undefined, ['page'], 'main')).toBe(
43
+ '',
44
+ );
45
+ expect(createRouteHydrationScriptTags({}, ['page'], 'main')).toBe('');
46
+ });
47
+
48
+ it('keeps dependencies, collected chunks and route chunks before async entry', () => {
49
+ const dependency = { url: '/dependency.js', marker: 'dependency' };
50
+ const collected = { url: '/collected.js', marker: 'collected' };
51
+ const route = { url: '/route.js', marker: 'route' };
52
+ const entry = { url: '/async-main.hash.js', marker: 'entry' };
53
+ const asyncEntryChunks = [entry, dependency];
54
+ const result = orderHydrationScriptChunks({
55
+ asyncEntryChunks,
56
+ collectedChunks: [collected, { ...dependency, marker: 'duplicate' }],
57
+ matchedRouteChunks: [route, { url: '' }, {}, { ...collected }],
58
+ entryName: 'main',
59
+ });
60
+ expect(result).toEqual([dependency, collected, route, entry]);
61
+ expect(result[0]).toBe(dependency);
62
+ expect(result[1]).toBe(collected);
63
+ expect(asyncEntryChunks).toEqual([entry, dependency]);
64
+ });
65
+
66
+ it('classifies async entries using filename while retaining the JS URL rule', () => {
67
+ const entry = { url: '/opaque.js', filename: 'nested/async-main-hash.js' };
68
+ const queried = { url: '/async-main.js?hash=1' };
69
+ const otherEntry = { url: '/async-other.js' };
70
+ const asset = { url: '/async-main.css' };
71
+ const route = { url: '/route.js' };
72
+ expect(
73
+ orderHydrationScriptChunks({
74
+ asyncEntryChunks: [entry, queried, otherEntry, asset],
75
+ collectedChunks: [],
76
+ matchedRouteChunks: [route],
77
+ entryName: 'main',
78
+ }),
79
+ ).toEqual([queried, otherEntry, asset, route, entry]);
80
+ });
81
+
82
+ it('serializes unique JS route assets then async assets and matches exact src', () => {
83
+ expect(
84
+ createRouteHydrationScriptTags(manifest, ['layout', 'page'], 'main', {
85
+ template: `${tag('/page.js.map')}${tag('/shared.js')}`,
86
+ }),
87
+ ).toBe(`${tag('/page.js')} ${tag('/async-main.hash.js')}`);
88
+ expect(createRouteHydrationScriptTags(manifest, [], 'main')).toBe(
89
+ `${tag('/shared.js')} ${tag('/async-main.hash.js')}`,
90
+ );
91
+ });
92
+
93
+ it('escapes asset and nonce attributes and omits empty nonce', () => {
94
+ const unsafeManifest = {
95
+ routeAssets: {
96
+ page: { assets: ['/route" onload="alert(1)&x=<tag>.js'] },
97
+ },
98
+ };
99
+ expect(
100
+ createRouteHydrationScriptTags(unsafeManifest, ['page'], 'main', {
101
+ nonce: 'nonce"&<value>',
102
+ }),
103
+ ).toBe(
104
+ '<script src="/route&quot; onload=&quot;alert(1)&amp;x=&lt;tag&gt;.js" nonce="nonce&quot;&amp;&lt;value&gt;"></script>',
105
+ );
106
+ expect(
107
+ createRouteHydrationScriptTags(manifest, ['page'], 'other', {
108
+ nonce: '',
109
+ }),
110
+ ).toBe(`${tag('/page.js')} ${tag('/shared.js')}`);
111
+ });
112
+
113
+ it('prefers the main entry even when an async entry appears earlier', () => {
114
+ const asyncEntry = tag('/async-main.hash.js');
115
+ const entry =
116
+ "<script defer src='/nested/main-hash.js?build=1#hash'></script>";
117
+ const template = `${asyncEntry}${entry}`;
118
+ expect(injectBeforeHydrationEntryScript(template, 'ROUTE', 'main')).toBe(
119
+ `${asyncEntry}ROUTE${entry}`,
120
+ );
121
+ });
122
+
123
+ it('falls back to async entry and preserves templates with no entry', () => {
124
+ const asyncEntry = tag('/nested/async-main.js');
125
+ expect(injectBeforeHydrationEntryScript(asyncEntry, 'ROUTE', 'main')).toBe(
126
+ `ROUTE${asyncEntry}`,
127
+ );
128
+ expect(injectBeforeHydrationEntryScript(tag('/index.js'), 'ROUTE')).toBe(
129
+ `ROUTE${tag('/index.js')}`,
130
+ );
131
+ const other = tag('/other-main.js');
132
+ expect(injectBeforeHydrationEntryScript(other, 'ROUTE', 'main')).toBe(
133
+ other,
134
+ );
135
+ expect(injectBeforeHydrationEntryScript(asyncEntry, '', 'main')).toBe(
136
+ asyncEntry,
137
+ );
138
+ });
139
+
140
+ it('moves placeholder scripts before hydration and replaces literal text safely', () => {
141
+ const placeholder = '<!-- chunks -->';
142
+ const scripts = '<script src="/$&-$`-$\'.js"></script>';
143
+ const entry = tag('/main.js');
144
+ expect(
145
+ replaceChunkJsPlaceholder(
146
+ `${entry}${placeholder}`,
147
+ scripts,
148
+ 'main',
149
+ placeholder,
150
+ ),
151
+ ).toBe(`${scripts}${entry}`);
152
+ expect(
153
+ replaceChunkJsPlaceholder(
154
+ `before${placeholder}after`,
155
+ scripts,
156
+ 'main',
157
+ placeholder,
158
+ ),
159
+ ).toBe(`before${scripts}after`);
160
+ expect(replaceChunkJsPlaceholder(entry, scripts, 'main', placeholder)).toBe(
161
+ entry,
162
+ );
163
+ expect(
164
+ replaceChunkJsPlaceholder(
165
+ `${entry}${placeholder}`,
166
+ '',
167
+ 'main',
168
+ placeholder,
169
+ ),
170
+ ).toBe(entry);
171
+ expect(
172
+ replaceChunkJsPlaceholder(
173
+ `${placeholder}${placeholder}`,
174
+ '',
175
+ undefined,
176
+ placeholder,
177
+ ),
178
+ ).toBe(placeholder);
179
+ });
180
+ });
@@ -0,0 +1,102 @@
1
+ import { createRouterPrefetchPolicy } from '../src/routerPrefetchPolicy';
2
+
3
+ const settle = async () => {
4
+ for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
5
+ };
6
+
7
+ afterEach(() => rstest.useRealTimers());
8
+
9
+ test('implements render defaults and cancels delayed intent before dispatch', async () => {
10
+ rstest.useFakeTimers();
11
+ const policy = createRouterPrefetchPolicy();
12
+ const notify = rstest.fn();
13
+ const render = policy.observe({ element: null, notify });
14
+ expect(notify).toHaveBeenLastCalledWith({ code: true, data: true });
15
+ render.dispose();
16
+ const intent = policy.observe({
17
+ element: null,
18
+ prefetch: 'none',
19
+ preload: 'intent',
20
+ notify,
21
+ });
22
+ intent.onIntent();
23
+ intent.onCancel();
24
+ await rstest.advanceTimersByTimeAsync(100);
25
+ expect(notify).toHaveBeenLastCalledWith({ code: false, data: false });
26
+ intent.onIntent();
27
+ await rstest.advanceTimersByTimeAsync(100);
28
+ expect(notify).toHaveBeenLastCalledWith({ code: true, data: false });
29
+ intent.dispose();
30
+ });
31
+
32
+ test('limits concurrent loads, removes cancelled queued keys, and retries rejected loads', async () => {
33
+ const policy = createRouterPrefetchPolicy();
34
+ const context = {};
35
+ const loader = {};
36
+ const started: number[] = [];
37
+ const releases: (() => void)[] = [];
38
+ const schedule = (id: number) =>
39
+ policy.schedule({
40
+ runtimeContext: context,
41
+ chunkLoader: loader,
42
+ publicPath: '/',
43
+ key: String(id),
44
+ run: () => {
45
+ started.push(id);
46
+ return new Promise<void>(resolve => releases.push(resolve));
47
+ },
48
+ });
49
+ const cancellations = Array.from({ length: 6 }, (_, id) => schedule(id));
50
+ await settle();
51
+ expect(started).toEqual([0, 1, 2, 3]);
52
+ cancellations[4]();
53
+ releases[0]();
54
+ await settle();
55
+ expect(started).toEqual([0, 1, 2, 3, 5]);
56
+ schedule(4);
57
+ releases[1]();
58
+ await settle();
59
+ expect(started).toEqual([0, 1, 2, 3, 5, 4]);
60
+ for (const release of releases) release();
61
+ await settle();
62
+ const fail = rstest.fn(() => Promise.reject(new Error('retry')));
63
+ const options = {
64
+ runtimeContext: context,
65
+ chunkLoader: loader,
66
+ publicPath: '/',
67
+ key: 'failed',
68
+ run: fail,
69
+ };
70
+ policy.schedule(options);
71
+ await settle();
72
+ policy.schedule(options);
73
+ await settle();
74
+ expect(fail).toHaveBeenCalledTimes(2);
75
+ });
76
+
77
+ test('deduplicates only within matching context, loader, public path and live TTL', async () => {
78
+ let now = 0;
79
+ rstest.spyOn(performance, 'now').mockImplementation(() => now);
80
+ const policy = createRouterPrefetchPolicy();
81
+ const run = rstest.fn(() => Promise.resolve());
82
+ const options = {
83
+ runtimeContext: {},
84
+ chunkLoader: {},
85
+ publicPath: '/',
86
+ key: 'route',
87
+ run,
88
+ };
89
+ policy.schedule(options);
90
+ policy.schedule(options);
91
+ await settle();
92
+ expect(run).toHaveBeenCalledTimes(1);
93
+ policy.schedule({ ...options, runtimeContext: {} });
94
+ policy.schedule({ ...options, chunkLoader: {} });
95
+ policy.schedule({ ...options, publicPath: '/other/' });
96
+ await settle();
97
+ expect(run).toHaveBeenCalledTimes(4);
98
+ now = 30_001;
99
+ policy.schedule(options);
100
+ await settle();
101
+ expect(run).toHaveBeenCalledTimes(5);
102
+ });