@bleedingdev/modern-js-runtime-renderer-extensions 0.0.0 → 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.
- package/LICENSE +21 -0
- package/dist/cjs/assetPolicy.js +89 -0
- package/dist/cjs/federatedCss.js +57 -0
- package/dist/cjs/node.js +53 -0
- package/dist/cjs/runtimePlugin.js +131 -0
- package/dist/cjs/templatePolicy.js +76 -0
- package/dist/esm/assetPolicy.mjs +48 -0
- package/dist/esm/federatedCss.mjs +19 -0
- package/dist/esm/runtimePlugin.mjs +78 -0
- package/dist/esm/templatePolicy.mjs +38 -0
- package/dist/esm-node/assetPolicy.mjs +49 -0
- package/dist/esm-node/federatedCss.mjs +20 -0
- package/dist/esm-node/node.mjs +11 -0
- package/dist/esm-node/runtimePlugin.mjs +79 -0
- package/dist/esm-node/templatePolicy.mjs +39 -0
- package/dist/types/assetPolicy.d.ts +19 -0
- package/dist/types/federatedCss.d.ts +6 -0
- package/dist/types/node.d.ts +3 -0
- package/dist/types/runtimePlugin.d.ts +7 -0
- package/dist/types/templatePolicy.d.ts +2 -0
- package/package.json +61 -7
- package/rslib.config.mts +19 -0
- package/rstest.config.mts +29 -0
- package/src/assetPolicy.ts +86 -0
- package/src/federatedCss.ts +43 -0
- package/src/node.ts +16 -0
- package/src/runtimePlugin.ts +119 -0
- package/src/templatePolicy.ts +69 -0
- package/tests/federatedCss.test.ts +80 -0
- package/tests/headRuntime.test.tsx +334 -0
- package/tests/nativeAssetPolicy.test.tsx +515 -0
- package/tests/nativeStreamHead.test.tsx +77 -0
- package/tests/nativeStringHead.test.tsx +128 -0
- package/tests/nativeTemplatePolicy.test.tsx +818 -0
- package/tests/runtimePlugin.test.tsx +37 -0
- package/tests/scriptOrder.security.test.ts +42 -0
- package/tsconfig.json +10 -0
- package/README.md +0 -3
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
import { getGlobalInternalRuntimeContext } from '../../plugin-runtime/src/core/context';
|
|
2
|
+
import { registerPlugin } from '../../plugin-runtime/src/core/plugin';
|
|
3
|
+
import {
|
|
4
|
+
createSSRRenderLifecycle,
|
|
5
|
+
replaceSSRTemplateChunk,
|
|
6
|
+
} from '../../plugin-runtime/src/core/server/shared';
|
|
7
|
+
import { rendererHeadPlugin } from '../src/node';
|
|
8
|
+
|
|
9
|
+
const lifecycleFor = (options: any, mode: 'string' | 'stream' = 'stream') => {
|
|
10
|
+
registerPlugin([rendererHeadPlugin()]);
|
|
11
|
+
const hooks = getGlobalInternalRuntimeContext().hooks;
|
|
12
|
+
const render = {
|
|
13
|
+
runtimeContext: options.runtimeContext,
|
|
14
|
+
request: options.request ?? new Request('http://localhost/'),
|
|
15
|
+
resource: options.resource ?? {
|
|
16
|
+
entryName: options.entryName,
|
|
17
|
+
routeManifest: options.routeManifest,
|
|
18
|
+
moduleFederationCssAssets: options.moduleFederationCssAssets,
|
|
19
|
+
},
|
|
20
|
+
config: options.config ?? {},
|
|
21
|
+
platform: 'node' as const,
|
|
22
|
+
mode,
|
|
23
|
+
isRsc: false,
|
|
24
|
+
};
|
|
25
|
+
return createSSRRenderLifecycle(
|
|
26
|
+
mode === 'string'
|
|
27
|
+
? (hooks.extendStringSSRCollectors
|
|
28
|
+
.call({ chunkSet: options.chunkSet, render })
|
|
29
|
+
.filter(Boolean) as any)
|
|
30
|
+
: (hooks.extendStreamSSR
|
|
31
|
+
.call({ ...render, terminalMarker: '<!--end-->' })
|
|
32
|
+
.filter(Boolean) as any),
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const buildShellAfterTemplate = (template: string, options: any) =>
|
|
37
|
+
nativeBuildShellAfterTemplate(template, {
|
|
38
|
+
...options,
|
|
39
|
+
lifecycle: lifecycleFor(options),
|
|
40
|
+
});
|
|
41
|
+
const getTemplates = (template: string, options: any) =>
|
|
42
|
+
nativeGetTemplates(template, {
|
|
43
|
+
...options,
|
|
44
|
+
lifecycle: lifecycleFor(options),
|
|
45
|
+
});
|
|
46
|
+
const createDataCollector = (options: any) => {
|
|
47
|
+
const lifecycle = lifecycleFor(options, 'string');
|
|
48
|
+
const collector = new SSRDataCollector({ ...options, lifecycle });
|
|
49
|
+
return {
|
|
50
|
+
effect() {
|
|
51
|
+
collector.effect();
|
|
52
|
+
options.chunkSet.ssrScripts = replaceSSRTemplateChunk(
|
|
53
|
+
{
|
|
54
|
+
name: 'data',
|
|
55
|
+
template: SSR_DATA_PLACEHOLDER,
|
|
56
|
+
placeholder: SSR_DATA_PLACEHOLDER,
|
|
57
|
+
content: options.chunkSet.ssrScripts,
|
|
58
|
+
},
|
|
59
|
+
lifecycle,
|
|
60
|
+
);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
import { applyRouterRuntimeState } from '@modern-js/runtime-extensions/router-state';
|
|
66
|
+
import { RenderLevel } from '../../plugin-runtime/src/core/constants';
|
|
67
|
+
import { SSR_DATA_PLACEHOLDER } from '../../plugin-runtime/src/core/server/constants';
|
|
68
|
+
import { buildShellAfterTemplate as nativeBuildShellAfterTemplate } from '../../plugin-runtime/src/core/server/stream/afterTemplate';
|
|
69
|
+
import { getTemplates as nativeGetTemplates } from '../../plugin-runtime/src/core/server/stream/template';
|
|
70
|
+
import { SSRDataCollector } from '../../plugin-runtime/src/core/server/string/ssrData';
|
|
71
|
+
|
|
72
|
+
const withRouterSnapshot = (
|
|
73
|
+
runtimeContext: Record<string, unknown>,
|
|
74
|
+
serverSnapshot: Record<string, unknown>,
|
|
75
|
+
) => {
|
|
76
|
+
applyRouterRuntimeState(runtimeContext as any, {
|
|
77
|
+
framework: 'react-router',
|
|
78
|
+
serverSnapshot,
|
|
79
|
+
});
|
|
80
|
+
return runtimeContext;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const scriptSrcs = (html: string) =>
|
|
84
|
+
Array.from(
|
|
85
|
+
html.matchAll(/<script\b[^>]*\bsrc=(?:"([^"]+)"|'([^']+)'|([^\s>]+))/g),
|
|
86
|
+
).map(match => match[1] ?? match[2] ?? match[3]);
|
|
87
|
+
|
|
88
|
+
describe('SSRDataCollector (stream parity)', () => {
|
|
89
|
+
it('should append router hydration script from the shared router snapshot', () => {
|
|
90
|
+
const chunkSet = {
|
|
91
|
+
renderLevel: RenderLevel.SERVER_RENDER,
|
|
92
|
+
ssrScripts: '',
|
|
93
|
+
jsChunk: '',
|
|
94
|
+
cssChunk: '',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const collector = createDataCollector({
|
|
98
|
+
runtimeContext: withRouterSnapshot(
|
|
99
|
+
{
|
|
100
|
+
initialData: {},
|
|
101
|
+
__i18nData__: {},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
hydrationScript: '<script>window.__HYDRATE__ = "router";</script>',
|
|
105
|
+
},
|
|
106
|
+
) as any,
|
|
107
|
+
request: new Request('http://localhost/'),
|
|
108
|
+
chunkSet,
|
|
109
|
+
ssrContext: {
|
|
110
|
+
request: {
|
|
111
|
+
params: {},
|
|
112
|
+
query: {},
|
|
113
|
+
pathname: '/',
|
|
114
|
+
host: 'localhost',
|
|
115
|
+
url: 'http://localhost/',
|
|
116
|
+
headers: {},
|
|
117
|
+
},
|
|
118
|
+
reporter: { sessionId: 'session-1' },
|
|
119
|
+
} as any,
|
|
120
|
+
ssrConfig: {} as any,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
collector.effect();
|
|
124
|
+
|
|
125
|
+
expect(chunkSet.ssrScripts).toContain('window.__HYDRATE__ = "router";');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should inject generic router hydration scripts into stream templates', async () => {
|
|
129
|
+
const html = await buildShellAfterTemplate(SSR_DATA_PLACEHOLDER, {
|
|
130
|
+
entryName: 'main',
|
|
131
|
+
renderLevel: RenderLevel.SERVER_RENDER,
|
|
132
|
+
request: new Request('http://localhost/'),
|
|
133
|
+
runtimeContext: withRouterSnapshot(
|
|
134
|
+
{
|
|
135
|
+
initialData: {},
|
|
136
|
+
__i18nData__: {},
|
|
137
|
+
routeManifest: {},
|
|
138
|
+
ssrContext: {
|
|
139
|
+
request: {
|
|
140
|
+
params: {},
|
|
141
|
+
query: {},
|
|
142
|
+
pathname: '/',
|
|
143
|
+
host: 'localhost',
|
|
144
|
+
url: 'http://localhost/',
|
|
145
|
+
headers: {},
|
|
146
|
+
},
|
|
147
|
+
reporter: { sessionId: 'session-1' },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
hydrationScripts: [
|
|
152
|
+
'<script>window.__STREAM_ROUTER_A__ = true;</script>',
|
|
153
|
+
'<script>window.__STREAM_ROUTER_B__ = true;</script>',
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
) as any,
|
|
157
|
+
ssrConfig: {} as any,
|
|
158
|
+
config: {} as any,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(html).toContain('window.__STREAM_ROUTER_A__ = true;');
|
|
162
|
+
expect(html).toContain('window.__STREAM_ROUTER_B__ = true;');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should inject matched route scripts before hydration', async () => {
|
|
166
|
+
const html = await buildShellAfterTemplate('<!--<?- chunksMap.js ?>-->', {
|
|
167
|
+
entryName: 'main',
|
|
168
|
+
renderLevel: RenderLevel.SERVER_RENDER,
|
|
169
|
+
request: new Request('http://localhost/products/shoe'),
|
|
170
|
+
runtimeContext: withRouterSnapshot(
|
|
171
|
+
{
|
|
172
|
+
routeManifest: {
|
|
173
|
+
routeAssets: {
|
|
174
|
+
layout: {
|
|
175
|
+
assets: ['/assets/layout.js'],
|
|
176
|
+
},
|
|
177
|
+
'products/$slug': {
|
|
178
|
+
assets: ['/assets/product.js', '/assets/product.css'],
|
|
179
|
+
},
|
|
180
|
+
'async-main': {
|
|
181
|
+
assets: ['/assets/main.js'],
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
initialData: {},
|
|
186
|
+
__i18nData__: {},
|
|
187
|
+
ssrContext: {
|
|
188
|
+
request: {
|
|
189
|
+
params: {},
|
|
190
|
+
query: {},
|
|
191
|
+
pathname: '/products/shoe',
|
|
192
|
+
host: 'localhost',
|
|
193
|
+
url: 'http://localhost/products/shoe',
|
|
194
|
+
headers: {},
|
|
195
|
+
},
|
|
196
|
+
reporter: { sessionId: 'session-1' },
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
matchedRouteIds: ['layout', 'products/$slug'],
|
|
201
|
+
},
|
|
202
|
+
) as any,
|
|
203
|
+
ssrConfig: {} as any,
|
|
204
|
+
config: {} as any,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(html).toContain('<script src="/assets/layout.js"></script>');
|
|
208
|
+
expect(html).toContain('<script src="/assets/product.js"></script>');
|
|
209
|
+
expect(html).toContain('<script src="/assets/main.js"></script>');
|
|
210
|
+
expect(html).not.toContain('/assets/product.css');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should move matched route scripts before the hydration entry script', async () => {
|
|
214
|
+
const html = await buildShellAfterTemplate(
|
|
215
|
+
'<script defer src="/static/js/index.js"></script><!--<?- chunksMap.js ?>-->',
|
|
216
|
+
{
|
|
217
|
+
entryName: 'index',
|
|
218
|
+
renderLevel: RenderLevel.SERVER_RENDER,
|
|
219
|
+
request: new Request('http://localhost/products/shoe'),
|
|
220
|
+
runtimeContext: withRouterSnapshot(
|
|
221
|
+
{
|
|
222
|
+
routeManifest: {
|
|
223
|
+
routeAssets: {
|
|
224
|
+
'products/$slug': {
|
|
225
|
+
assets: [
|
|
226
|
+
'/static/js/async/products/shared.js',
|
|
227
|
+
'/static/js/async/products/$slug.js',
|
|
228
|
+
],
|
|
229
|
+
},
|
|
230
|
+
'async-index': {
|
|
231
|
+
assets: ['/static/js/async/async-index.js'],
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
initialData: {},
|
|
236
|
+
__i18nData__: {},
|
|
237
|
+
ssrContext: {
|
|
238
|
+
request: {
|
|
239
|
+
params: {},
|
|
240
|
+
query: {},
|
|
241
|
+
pathname: '/products/shoe',
|
|
242
|
+
host: 'localhost',
|
|
243
|
+
url: 'http://localhost/products/shoe',
|
|
244
|
+
headers: {},
|
|
245
|
+
},
|
|
246
|
+
reporter: { sessionId: 'session-1' },
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
matchedRouteIds: ['products/$slug'],
|
|
251
|
+
},
|
|
252
|
+
) as any,
|
|
253
|
+
ssrConfig: {} as any,
|
|
254
|
+
config: {} as any,
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
expect(scriptSrcs(html)).toEqual([
|
|
259
|
+
'/static/js/async/products/shared.js',
|
|
260
|
+
'/static/js/async/products/$slug.js',
|
|
261
|
+
'/static/js/async/async-index.js',
|
|
262
|
+
'/static/js/index.js',
|
|
263
|
+
]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should place stream hydration bootstrap before an async head entry', async () => {
|
|
267
|
+
const { shellBefore, shellAfter } = await getTemplates(
|
|
268
|
+
[
|
|
269
|
+
'<html><head>',
|
|
270
|
+
'<script async src="/static/js/index.js"></script>',
|
|
271
|
+
'</head><body><div id="root">',
|
|
272
|
+
'<!--<?- html ?>-->',
|
|
273
|
+
'</div>',
|
|
274
|
+
'<!--<?- chunksMap.js ?>-->',
|
|
275
|
+
SSR_DATA_PLACEHOLDER,
|
|
276
|
+
'</body></html>',
|
|
277
|
+
].join(''),
|
|
278
|
+
{
|
|
279
|
+
entryName: 'index',
|
|
280
|
+
renderLevel: RenderLevel.SERVER_RENDER,
|
|
281
|
+
request: new Request('http://localhost/products/shoe'),
|
|
282
|
+
runtimeContext: withRouterSnapshot(
|
|
283
|
+
{
|
|
284
|
+
routeManifest: {
|
|
285
|
+
routeAssets: {
|
|
286
|
+
'products/$slug': {
|
|
287
|
+
assets: ['/static/js/async/products/$slug.js'],
|
|
288
|
+
},
|
|
289
|
+
'async-index': {
|
|
290
|
+
assets: ['/static/js/async/async-index.js'],
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
initialData: {},
|
|
295
|
+
__i18nData__: {},
|
|
296
|
+
ssrContext: {
|
|
297
|
+
request: {
|
|
298
|
+
params: {},
|
|
299
|
+
query: {},
|
|
300
|
+
pathname: '/products/shoe',
|
|
301
|
+
host: 'localhost',
|
|
302
|
+
url: 'http://localhost/products/shoe',
|
|
303
|
+
headers: {},
|
|
304
|
+
},
|
|
305
|
+
reporter: { sessionId: 'session-1' },
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
hydrationScripts: [
|
|
310
|
+
'<script>window.$_TSR = { router: "hydrated" };</script>',
|
|
311
|
+
],
|
|
312
|
+
matchedRouteIds: ['products/$slug'],
|
|
313
|
+
},
|
|
314
|
+
) as any,
|
|
315
|
+
ssrConfig: {} as any,
|
|
316
|
+
config: {} as any,
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const html = `${shellBefore}<main>server markup</main>${shellAfter}`;
|
|
321
|
+
const entryIndex = html.indexOf(
|
|
322
|
+
'<script async src="/static/js/index.js"></script>',
|
|
323
|
+
);
|
|
324
|
+
const routeIndex = html.indexOf(
|
|
325
|
+
'<script src="/static/js/async/products/$slug.js"></script>',
|
|
326
|
+
);
|
|
327
|
+
const ssrDataIndex = html.indexOf('window._SSR_DATA =');
|
|
328
|
+
const routerBootstrapIndex = html.indexOf('window.$_TSR =');
|
|
329
|
+
|
|
330
|
+
expect(routeIndex).toBeGreaterThan(-1);
|
|
331
|
+
expect(ssrDataIndex).toBeGreaterThan(routeIndex);
|
|
332
|
+
expect(routerBootstrapIndex).toBeGreaterThan(ssrDataIndex);
|
|
333
|
+
expect(entryIndex).toBeGreaterThan(routerBootstrapIndex);
|
|
334
|
+
expect(html).not.toContain('<!--<?- chunksMap.js ?>-->');
|
|
335
|
+
expect(html).not.toContain(SSR_DATA_PLACEHOLDER);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('should preserve JSON bootstrap and nonce before an async head entry', async () => {
|
|
339
|
+
const { shellBefore, shellAfter } = await getTemplates(
|
|
340
|
+
[
|
|
341
|
+
'<html><head>',
|
|
342
|
+
'<script async nonce="nonce-value" src="/static/js/index.js"></script>',
|
|
343
|
+
'</head><body><div id="root">',
|
|
344
|
+
'<!--<?- html ?>-->',
|
|
345
|
+
'</div>',
|
|
346
|
+
'<!--<?- chunksMap.js ?>-->',
|
|
347
|
+
SSR_DATA_PLACEHOLDER,
|
|
348
|
+
'</body></html>',
|
|
349
|
+
].join(''),
|
|
350
|
+
{
|
|
351
|
+
entryName: 'index',
|
|
352
|
+
renderLevel: RenderLevel.SERVER_RENDER,
|
|
353
|
+
request: new Request('http://localhost/products/shoe'),
|
|
354
|
+
runtimeContext: withRouterSnapshot(
|
|
355
|
+
{
|
|
356
|
+
routeManifest: {
|
|
357
|
+
routeAssets: {
|
|
358
|
+
'products/$slug': {
|
|
359
|
+
assets: ['/static/js/async/products/$slug.js'],
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
initialData: {},
|
|
364
|
+
__i18nData__: {},
|
|
365
|
+
ssrContext: {
|
|
366
|
+
request: {
|
|
367
|
+
params: {},
|
|
368
|
+
query: {},
|
|
369
|
+
pathname: '/products/shoe',
|
|
370
|
+
host: 'localhost',
|
|
371
|
+
url: 'http://localhost/products/shoe',
|
|
372
|
+
headers: {},
|
|
373
|
+
},
|
|
374
|
+
reporter: { sessionId: 'session-1' },
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
hydrationScripts: [
|
|
379
|
+
'<script nonce="nonce-value">window.$_TSR = { router: "hydrated" };</script>',
|
|
380
|
+
],
|
|
381
|
+
matchedRouteIds: ['products/$slug'],
|
|
382
|
+
},
|
|
383
|
+
) as any,
|
|
384
|
+
ssrConfig: {} as any,
|
|
385
|
+
config: {
|
|
386
|
+
nonce: 'nonce-value',
|
|
387
|
+
useJsonScript: true,
|
|
388
|
+
} as any,
|
|
389
|
+
},
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
const html = `${shellBefore}<main>server markup</main>${shellAfter}`;
|
|
393
|
+
const routeIndex = html.indexOf(
|
|
394
|
+
'<script src="/static/js/async/products/$slug.js" nonce="nonce-value"></script>',
|
|
395
|
+
);
|
|
396
|
+
const ssrDataIndex = html.indexOf(
|
|
397
|
+
'<script type="application/json" id="__MODERN_SSR_DATA__">',
|
|
398
|
+
);
|
|
399
|
+
const routerBootstrapIndex = html.indexOf('window.$_TSR =');
|
|
400
|
+
const entryIndex = html.indexOf(
|
|
401
|
+
'<script async nonce="nonce-value" src="/static/js/index.js"></script>',
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
expect(routeIndex).toBeGreaterThan(-1);
|
|
405
|
+
expect(ssrDataIndex).toBeGreaterThan(routeIndex);
|
|
406
|
+
expect(routerBootstrapIndex).toBeGreaterThan(ssrDataIndex);
|
|
407
|
+
expect(entryIndex).toBeGreaterThan(routerBootstrapIndex);
|
|
408
|
+
expect(html).not.toContain('window._SSR_DATA =');
|
|
409
|
+
expect(html).not.toContain('<!--<?- chunksMap.js ?>-->');
|
|
410
|
+
expect(html).not.toContain(SSR_DATA_PLACEHOLDER);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('should preserve a custom stream template that omits script markers', async () => {
|
|
414
|
+
const template = '<script async src="/static/js/index.js"></script>';
|
|
415
|
+
const html = await buildShellAfterTemplate(template, {
|
|
416
|
+
entryName: 'index',
|
|
417
|
+
renderLevel: RenderLevel.SERVER_RENDER,
|
|
418
|
+
request: new Request('http://localhost/products/shoe'),
|
|
419
|
+
runtimeContext: withRouterSnapshot(
|
|
420
|
+
{
|
|
421
|
+
initialData: {},
|
|
422
|
+
__i18nData__: {},
|
|
423
|
+
routeManifest: {
|
|
424
|
+
routeAssets: {
|
|
425
|
+
'products/$slug': {
|
|
426
|
+
assets: ['/static/js/async/products/$slug.js'],
|
|
427
|
+
},
|
|
428
|
+
'async-index': {
|
|
429
|
+
assets: ['/static/js/async/async-index.js'],
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
ssrContext: {
|
|
434
|
+
request: {
|
|
435
|
+
params: {},
|
|
436
|
+
query: {},
|
|
437
|
+
pathname: '/products/shoe',
|
|
438
|
+
host: 'localhost',
|
|
439
|
+
url: 'http://localhost/products/shoe',
|
|
440
|
+
headers: {
|
|
441
|
+
'x-request-id': 'request-1',
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
reporter: { sessionId: 'session-1' },
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
hydrationScripts: [
|
|
449
|
+
'<script>window.__OMITTED_BOOTSTRAP__ = true;</script>',
|
|
450
|
+
],
|
|
451
|
+
matchedRouteIds: ['products/$slug'],
|
|
452
|
+
},
|
|
453
|
+
) as any,
|
|
454
|
+
ssrConfig: {
|
|
455
|
+
unsafeHeaders: ['x-request-id'],
|
|
456
|
+
} as any,
|
|
457
|
+
config: {} as any,
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
expect(html).toBe(template);
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
import React from 'react';
|
|
465
|
+
import { renderString } from '../../plugin-runtime/src/core/server/string';
|
|
466
|
+
|
|
467
|
+
const TSR_BOOTSTRAP = '<script>window.$_TSR = { router: "hydrated" };</script>';
|
|
468
|
+
|
|
469
|
+
const createRuntimeContext = (options: { withRouterBootstrap: boolean }) => {
|
|
470
|
+
const runtimeContext = {
|
|
471
|
+
isBrowser: false,
|
|
472
|
+
requestContext: {},
|
|
473
|
+
context: {},
|
|
474
|
+
initialData: {},
|
|
475
|
+
__i18nData__: {},
|
|
476
|
+
ssrContext: {
|
|
477
|
+
request: {
|
|
478
|
+
params: {},
|
|
479
|
+
query: {},
|
|
480
|
+
pathname: '/',
|
|
481
|
+
host: 'localhost',
|
|
482
|
+
url: 'http://localhost/',
|
|
483
|
+
headers: {},
|
|
484
|
+
},
|
|
485
|
+
reporter: { sessionId: 'session-1' },
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
if (options.withRouterBootstrap) {
|
|
490
|
+
applyRouterRuntimeState(runtimeContext as any, {
|
|
491
|
+
framework: 'react-router',
|
|
492
|
+
serverSnapshot: {
|
|
493
|
+
hydrationScripts: [TSR_BOOTSTRAP],
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return runtimeContext;
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const render = async (
|
|
502
|
+
htmlTemplate: string,
|
|
503
|
+
options: { entryName?: string; withRouterBootstrap?: boolean } = {},
|
|
504
|
+
) => {
|
|
505
|
+
registerPlugin([rendererHeadPlugin()]);
|
|
506
|
+
|
|
507
|
+
return renderString(
|
|
508
|
+
new Request('http://localhost/'),
|
|
509
|
+
React.createElement('main', null, 'server markup'),
|
|
510
|
+
{
|
|
511
|
+
resource: {
|
|
512
|
+
entryName: options.entryName ?? 'index',
|
|
513
|
+
htmlTemplate,
|
|
514
|
+
routeManifest: {},
|
|
515
|
+
},
|
|
516
|
+
runtimeContext: createRuntimeContext({
|
|
517
|
+
withRouterBootstrap: options.withRouterBootstrap ?? true,
|
|
518
|
+
}),
|
|
519
|
+
config: {},
|
|
520
|
+
onError: () => {},
|
|
521
|
+
onTiming: () => {},
|
|
522
|
+
} as any,
|
|
523
|
+
);
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
describe('renderString template assembly (string-mode script ordering)', () => {
|
|
527
|
+
it('should emit the SSR data + router bootstrap before the entry script', async () => {
|
|
528
|
+
const html = await render(
|
|
529
|
+
[
|
|
530
|
+
'<html><head>',
|
|
531
|
+
'<script src="/static/js/index.js" async></script>',
|
|
532
|
+
'<!--<?- chunksMap.css ?>-->',
|
|
533
|
+
'</head><body><div id="root">',
|
|
534
|
+
'<!--<?- html ?>-->',
|
|
535
|
+
'</div>',
|
|
536
|
+
'<!--<?- chunksMap.js ?>-->',
|
|
537
|
+
SSR_DATA_PLACEHOLDER,
|
|
538
|
+
'</body></html>',
|
|
539
|
+
].join(''),
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
const entryIndex = html.indexOf(
|
|
543
|
+
'<script src="/static/js/index.js" async></script>',
|
|
544
|
+
);
|
|
545
|
+
const ssrDataIndex = html.indexOf('window._SSR_DATA =');
|
|
546
|
+
const routerBootstrapIndex = html.indexOf('window.$_TSR =');
|
|
547
|
+
|
|
548
|
+
expect(ssrDataIndex).toBeGreaterThan(-1);
|
|
549
|
+
expect(routerBootstrapIndex).toBeGreaterThan(ssrDataIndex);
|
|
550
|
+
expect(entryIndex).toBeGreaterThan(routerBootstrapIndex);
|
|
551
|
+
expect(html).not.toContain(SSR_DATA_PLACEHOLDER);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('should keep the async entry variant ordered after the bootstrap', async () => {
|
|
555
|
+
const html = await render(
|
|
556
|
+
[
|
|
557
|
+
'<html><head>',
|
|
558
|
+
'<script async src="/static/js/async-index.js"></script>',
|
|
559
|
+
'</head><body><div id="root">',
|
|
560
|
+
'<!--<?- html ?>-->',
|
|
561
|
+
'</div>',
|
|
562
|
+
SSR_DATA_PLACEHOLDER,
|
|
563
|
+
'</body></html>',
|
|
564
|
+
].join(''),
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
const entryIndex = html.indexOf(
|
|
568
|
+
'<script async src="/static/js/async-index.js"></script>',
|
|
569
|
+
);
|
|
570
|
+
const routerBootstrapIndex = html.indexOf('window.$_TSR =');
|
|
571
|
+
|
|
572
|
+
expect(routerBootstrapIndex).toBeGreaterThan(-1);
|
|
573
|
+
expect(entryIndex).toBeGreaterThan(routerBootstrapIndex);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it('should fall back to in-place replacement when no entry script tag exists', async () => {
|
|
577
|
+
const html = await render(
|
|
578
|
+
[
|
|
579
|
+
'<html><head></head><body><div id="root">',
|
|
580
|
+
'<!--<?- html ?>-->',
|
|
581
|
+
'</div>',
|
|
582
|
+
'<span id="marker"></span>',
|
|
583
|
+
SSR_DATA_PLACEHOLDER,
|
|
584
|
+
'</body></html>',
|
|
585
|
+
].join(''),
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
const markerIndex = html.indexOf('<span id="marker"></span>');
|
|
589
|
+
const ssrDataIndex = html.indexOf('window._SSR_DATA =');
|
|
590
|
+
|
|
591
|
+
expect(markerIndex).toBeGreaterThan(-1);
|
|
592
|
+
expect(ssrDataIndex).toBeGreaterThan(markerIndex);
|
|
593
|
+
expect(html).not.toContain(SSR_DATA_PLACEHOLDER);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it('should preserve a custom template that omits script markers', async () => {
|
|
597
|
+
const template =
|
|
598
|
+
'<html><head><script async src="/static/js/index.js"></script></head><body>custom shell</body></html>';
|
|
599
|
+
const runtimeContext = createRuntimeContext({ withRouterBootstrap: true });
|
|
600
|
+
runtimeContext.ssrContext.request.headers = {
|
|
601
|
+
'x-request-id': 'request-1',
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
const html = await renderString(
|
|
605
|
+
new Request('http://localhost/'),
|
|
606
|
+
React.createElement('main', null, 'server markup'),
|
|
607
|
+
{
|
|
608
|
+
resource: {
|
|
609
|
+
entryName: 'index',
|
|
610
|
+
htmlTemplate: template,
|
|
611
|
+
routeManifest: {},
|
|
612
|
+
},
|
|
613
|
+
runtimeContext,
|
|
614
|
+
config: {
|
|
615
|
+
ssr: {
|
|
616
|
+
unsafeHeaders: ['x-request-id'],
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
onError: () => {},
|
|
620
|
+
onTiming: () => {},
|
|
621
|
+
} as any,
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
expect(html).toBe(template);
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
import vm from 'node:vm';
|
|
629
|
+
|
|
630
|
+
const createScripts = (options?: {
|
|
631
|
+
useJsonScript?: boolean;
|
|
632
|
+
nonce?: string;
|
|
633
|
+
unsafeHeaders?: string[];
|
|
634
|
+
routerServerSnapshot?: {
|
|
635
|
+
routerData?: {
|
|
636
|
+
loaderData?: Record<string, unknown>;
|
|
637
|
+
errors?: Record<string, unknown>;
|
|
638
|
+
};
|
|
639
|
+
hydrationScript?: string;
|
|
640
|
+
hydrationScripts?: string[];
|
|
641
|
+
};
|
|
642
|
+
}) => {
|
|
643
|
+
const chunkSet = {
|
|
644
|
+
renderLevel: RenderLevel.SERVER_RENDER,
|
|
645
|
+
ssrScripts: '',
|
|
646
|
+
jsChunk: '',
|
|
647
|
+
cssChunk: '',
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
const runtimeContext = {
|
|
651
|
+
initialData: { name: 'modern.js' },
|
|
652
|
+
__i18nData__: {},
|
|
653
|
+
} as any;
|
|
654
|
+
if (options?.routerServerSnapshot) {
|
|
655
|
+
applyRouterRuntimeState(runtimeContext, {
|
|
656
|
+
framework: 'react-router',
|
|
657
|
+
serverSnapshot: options.routerServerSnapshot,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const collector = createDataCollector({
|
|
662
|
+
runtimeContext,
|
|
663
|
+
request: new Request('http://localhost/'),
|
|
664
|
+
chunkSet,
|
|
665
|
+
ssrContext: {
|
|
666
|
+
request: {
|
|
667
|
+
params: {},
|
|
668
|
+
query: {},
|
|
669
|
+
pathname: '/',
|
|
670
|
+
host: 'localhost',
|
|
671
|
+
url: 'http://localhost/',
|
|
672
|
+
headers: {
|
|
673
|
+
authorization: 'Bearer secret',
|
|
674
|
+
cookie: 'sid=abc',
|
|
675
|
+
'x-request-id': 'req-1',
|
|
676
|
+
'x-internal-secret': 'hidden',
|
|
677
|
+
},
|
|
678
|
+
},
|
|
679
|
+
reporter: { sessionId: 'session-1' },
|
|
680
|
+
} as any,
|
|
681
|
+
ssrConfig: {
|
|
682
|
+
unsafeHeaders: options?.unsafeHeaders,
|
|
683
|
+
} as any,
|
|
684
|
+
nonce: options?.nonce,
|
|
685
|
+
useJsonScript: options?.useJsonScript,
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
collector.effect();
|
|
689
|
+
return chunkSet.ssrScripts;
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
const parseScripts = (html: string) =>
|
|
693
|
+
[
|
|
694
|
+
...html.matchAll(
|
|
695
|
+
/<script(?<attributes>[^>]*)>(?<body>[\s\S]*?)<\/script>/gu,
|
|
696
|
+
),
|
|
697
|
+
].map(match => ({
|
|
698
|
+
attributes: Object.fromEntries(
|
|
699
|
+
[
|
|
700
|
+
...(match.groups?.attributes ?? '').matchAll(/([\w-]+)="([^"]*)"/gu),
|
|
701
|
+
].map(attribute => [attribute[1], attribute[2]]),
|
|
702
|
+
),
|
|
703
|
+
body: match.groups?.body ?? '',
|
|
704
|
+
}));
|
|
705
|
+
|
|
706
|
+
const executeScripts = (html: string) => {
|
|
707
|
+
const browser = {} as Record<string, any>;
|
|
708
|
+
const context = vm.createContext({ window: browser });
|
|
709
|
+
for (const script of parseScripts(html)) {
|
|
710
|
+
vm.runInContext(script.body, context);
|
|
711
|
+
}
|
|
712
|
+
return browser;
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
describe('SSR data script generation', () => {
|
|
716
|
+
it('should use router snapshot data and hydration script when present', () => {
|
|
717
|
+
const browser = executeScripts(
|
|
718
|
+
createScripts({
|
|
719
|
+
routerServerSnapshot: {
|
|
720
|
+
routerData: {
|
|
721
|
+
loaderData: { route: { ok: true } },
|
|
722
|
+
errors: {},
|
|
723
|
+
},
|
|
724
|
+
hydrationScript: '<script>window.__ROUTER_SSR__ = true;</script>',
|
|
725
|
+
},
|
|
726
|
+
}),
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
expect(browser.__ROUTER_SSR__).toBe(true);
|
|
730
|
+
expect(browser._ROUTER_DATA).toEqual({
|
|
731
|
+
errors: {},
|
|
732
|
+
loaderData: { route: { ok: true } },
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it('should serialize generic router hydration scripts when present', () => {
|
|
737
|
+
const browser = executeScripts(
|
|
738
|
+
createScripts({
|
|
739
|
+
routerServerSnapshot: {
|
|
740
|
+
hydrationScripts: [
|
|
741
|
+
'<script>window.__ROUTER_A__ = true;</script>',
|
|
742
|
+
'<script>window.__ROUTER_B__ = true;</script>',
|
|
743
|
+
],
|
|
744
|
+
},
|
|
745
|
+
}),
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
expect(browser.__ROUTER_A__).toBe(true);
|
|
749
|
+
expect(browser.__ROUTER_B__).toBe(true);
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
import {
|
|
754
|
+
injectBeforeHydrationEntryScript,
|
|
755
|
+
replaceChunkJsPlaceholder,
|
|
756
|
+
} from '@modern-js/runtime-extensions';
|
|
757
|
+
|
|
758
|
+
const matrixScriptSrcs = (html: string) =>
|
|
759
|
+
Array.from(html.matchAll(/<script\b[^>]*\bsrc=(["'])(.*?)\1[^>]*>/g)).map(
|
|
760
|
+
match => match[2],
|
|
761
|
+
);
|
|
762
|
+
|
|
763
|
+
describe('string SSR script ordering', () => {
|
|
764
|
+
it('injects hydration route scripts before the entry bootstrap', () => {
|
|
765
|
+
const html = injectBeforeHydrationEntryScript(
|
|
766
|
+
'<head><script defer src="/static/js/index.abc.js"></script></head>',
|
|
767
|
+
'<script defer src="/static/js/async/(lang)/page.js"></script>',
|
|
768
|
+
'index',
|
|
769
|
+
);
|
|
770
|
+
|
|
771
|
+
expect(matrixScriptSrcs(html)).toEqual([
|
|
772
|
+
'/static/js/async/(lang)/page.js',
|
|
773
|
+
'/static/js/index.abc.js',
|
|
774
|
+
]);
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it('falls back to the async entry when the bootstrap script is absent', () => {
|
|
778
|
+
const html = injectBeforeHydrationEntryScript(
|
|
779
|
+
'<body><script defer src="/static/js/async/async-index.js"></script></body>',
|
|
780
|
+
'<script defer src="/static/js/async/(lang)/page.js"></script>',
|
|
781
|
+
'index',
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
expect(matrixScriptSrcs(html)).toEqual([
|
|
785
|
+
'/static/js/async/(lang)/page.js',
|
|
786
|
+
'/static/js/async/async-index.js',
|
|
787
|
+
]);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it('preserves a template that omits the script marker', () => {
|
|
791
|
+
const template =
|
|
792
|
+
'<body><script defer src="/static/js/index.js"></script></body>';
|
|
793
|
+
|
|
794
|
+
expect(
|
|
795
|
+
replaceChunkJsPlaceholder(
|
|
796
|
+
template,
|
|
797
|
+
'<script src="/static/js/async/page.js"></script>',
|
|
798
|
+
undefined,
|
|
799
|
+
'<!--<?- chunksMap.js ?>-->',
|
|
800
|
+
),
|
|
801
|
+
).toBe(template);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it('orders scripts before the entry when the marker is present', () => {
|
|
805
|
+
const html = replaceChunkJsPlaceholder(
|
|
806
|
+
'<body><script defer src="/static/js/index.js"></script><!--<?- chunksMap.js ?>--></body>',
|
|
807
|
+
'<script src="/static/js/async/page.js"></script>',
|
|
808
|
+
undefined,
|
|
809
|
+
'<!--<?- chunksMap.js ?>-->',
|
|
810
|
+
);
|
|
811
|
+
|
|
812
|
+
expect(matrixScriptSrcs(html)).toEqual([
|
|
813
|
+
'/static/js/async/page.js',
|
|
814
|
+
'/static/js/index.js',
|
|
815
|
+
]);
|
|
816
|
+
expect(html).not.toContain('<!--<?- chunksMap.js ?>-->');
|
|
817
|
+
});
|
|
818
|
+
});
|