@bleedingdev/modern-js-runtime-renderer-extensions 0.0.0 → 3.9.0-ultramodern.11
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 +143 -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 +87 -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 +88 -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 +13 -0
- package/dist/types/templatePolicy.d.ts +2 -0
- package/package.json +60 -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 +141 -0
- package/src/templatePolicy.ts +69 -0
- package/tests/federatedCss.test.ts +49 -0
- package/tests/headRuntime.test.tsx +163 -0
- package/tests/nativeAssetPolicy.test.tsx +377 -0
- package/tests/nativeStreamHead.test.tsx +77 -0
- package/tests/nativeStringHead.test.tsx +128 -0
- package/tests/nativeTemplatePolicy.test.tsx +366 -0
- package/tests/rootWrapOrder.test.tsx +70 -0
- package/tests/scriptOrder.security.test.ts +42 -0
- package/tsconfig.json +10 -0
- package/README.md +0 -3
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Collector,
|
|
3
|
+
RuntimePlugin,
|
|
4
|
+
SSRRenderLifecycle,
|
|
5
|
+
StreamSSRExtender,
|
|
6
|
+
} from '@modern-js/plugin/runtime';
|
|
7
|
+
import {
|
|
8
|
+
abortHeadRender,
|
|
9
|
+
beginHeadRender,
|
|
10
|
+
completeHeadRender,
|
|
11
|
+
createConservingWebShellStream,
|
|
12
|
+
publishHeadRender,
|
|
13
|
+
} from '@modern-js/runtime-extensions';
|
|
14
|
+
import { projectRuntimeContext } from '@modern-js/runtime-extensions/context-projection';
|
|
15
|
+
import { createHeadRuntime } from '@modern-js/runtime-extensions/head-runtime';
|
|
16
|
+
import { ensureHelmetContext } from '@modern-js/runtime-extensions/helmet-context';
|
|
17
|
+
import React from 'react';
|
|
18
|
+
import { createAssetPolicy } from './assetPolicy';
|
|
19
|
+
import { createTemplatePolicy } from './templatePolicy';
|
|
20
|
+
|
|
21
|
+
export type RendererHeadPluginOptions = {
|
|
22
|
+
processNodeStream?: (
|
|
23
|
+
source: NodeJS.ReadWriteStream,
|
|
24
|
+
context: object,
|
|
25
|
+
terminalMarker: string,
|
|
26
|
+
) => NodeJS.ReadWriteStream;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function createRendererHeadPlugin(
|
|
30
|
+
options: RendererHeadPluginOptions = {},
|
|
31
|
+
): RuntimePlugin<{}> {
|
|
32
|
+
return {
|
|
33
|
+
name: '@modern-js/runtime-renderer-extensions',
|
|
34
|
+
setup(api) {
|
|
35
|
+
const head = createHeadRuntime();
|
|
36
|
+
api.resolveComponent((component, { name }) =>
|
|
37
|
+
name === 'head.Helmet' ? head.Head : component,
|
|
38
|
+
);
|
|
39
|
+
api.transformRuntimeContext(projectRuntimeContext);
|
|
40
|
+
api.wrapRoot(Root => {
|
|
41
|
+
const HeadRoot = (props: React.ComponentProps<typeof Root>) =>
|
|
42
|
+
// A root is only defined once the plugin that owns it (the router,
|
|
43
|
+
// for a file-system routes entry) has run. This plugin must keep
|
|
44
|
+
// working when it is registered before that one: it then wraps the
|
|
45
|
+
// children the later plugin hands it instead of a missing root.
|
|
46
|
+
head.wrapClientRoot(
|
|
47
|
+
Root
|
|
48
|
+
? React.createElement(Root, props)
|
|
49
|
+
: ((props as { children?: React.ReactNode })?.children ?? null),
|
|
50
|
+
);
|
|
51
|
+
return HeadRoot;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const createState = (runtimeContext: object) => {
|
|
55
|
+
const helmetContext = ensureHelmetContext(runtimeContext);
|
|
56
|
+
const lifecycle: SSRRenderLifecycle = {
|
|
57
|
+
beforeReact() {
|
|
58
|
+
beginHeadRender(runtimeContext);
|
|
59
|
+
},
|
|
60
|
+
completedBody(html, { phase }) {
|
|
61
|
+
if (phase === 'complete') {
|
|
62
|
+
return completeHeadRender(runtimeContext, html);
|
|
63
|
+
}
|
|
64
|
+
publishHeadRender(runtimeContext);
|
|
65
|
+
return html;
|
|
66
|
+
},
|
|
67
|
+
getHeadData() {
|
|
68
|
+
return helmetContext.helmet ?? undefined;
|
|
69
|
+
},
|
|
70
|
+
onTerminal(terminal) {
|
|
71
|
+
if (terminal.status !== 'complete') abortHeadRender(runtimeContext);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
lifecycle,
|
|
76
|
+
wrap(root: React.ReactNode) {
|
|
77
|
+
return head.wrapServerRoot(root, { runtimeContext, helmetContext });
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
api.extendStringSSRCollectors(({ render }): Collector => {
|
|
83
|
+
const state = createState(render.runtimeContext);
|
|
84
|
+
return {
|
|
85
|
+
...state.lifecycle,
|
|
86
|
+
...createAssetPolicy(render),
|
|
87
|
+
...createTemplatePolicy(render),
|
|
88
|
+
collect: state.wrap,
|
|
89
|
+
effect() {},
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
api.extendStreamSSR((info): StreamSSRExtender => {
|
|
93
|
+
const { runtimeContext, terminalMarker } = info;
|
|
94
|
+
const processNodeStream = options.processNodeStream;
|
|
95
|
+
if (info.platform === 'node' && processNodeStream === undefined) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
'Node head rendering requires the Node runtime-renderer-extensions entry',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const state = createState(runtimeContext);
|
|
101
|
+
return {
|
|
102
|
+
...state.lifecycle,
|
|
103
|
+
...createAssetPolicy(info),
|
|
104
|
+
...createTemplatePolicy(info),
|
|
105
|
+
modifyRootElement: state.wrap,
|
|
106
|
+
streamPhase: 'body',
|
|
107
|
+
processStream:
|
|
108
|
+
processNodeStream === undefined
|
|
109
|
+
? undefined
|
|
110
|
+
: source =>
|
|
111
|
+
processNodeStream(source, runtimeContext, terminalMarker),
|
|
112
|
+
processReadableStream: source =>
|
|
113
|
+
createConservingWebShellStream(
|
|
114
|
+
source,
|
|
115
|
+
runtimeContext,
|
|
116
|
+
terminalMarker,
|
|
117
|
+
),
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The plugins whose root this one wraps. Declaring them keeps the head
|
|
126
|
+
* provider outside their trees no matter what order the CLI emitted the
|
|
127
|
+
* runtime descriptors in.
|
|
128
|
+
*/
|
|
129
|
+
export const RENDERER_HEAD_PRE_PLUGINS = [
|
|
130
|
+
'@modern-js/plugin-router',
|
|
131
|
+
'@modern-js/plugin-tanstack',
|
|
132
|
+
'@modern-js/plugin-state',
|
|
133
|
+
'@modern-js/plugin-i18n',
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
export const rendererHeadPlugin = (): RuntimePlugin<{}> => ({
|
|
137
|
+
...createRendererHeadPlugin(),
|
|
138
|
+
pre: RENDERER_HEAD_PRE_PLUGINS,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
export default rendererHeadPlugin;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SSRRenderInfo,
|
|
3
|
+
SSRRenderLifecycle,
|
|
4
|
+
} from '@modern-js/plugin/runtime';
|
|
5
|
+
import {
|
|
6
|
+
createRouteHydrationScriptTags,
|
|
7
|
+
replaceChunkJsPlaceholder,
|
|
8
|
+
} from '@modern-js/runtime-extensions';
|
|
9
|
+
import {
|
|
10
|
+
getRouterHydrationScripts,
|
|
11
|
+
getRouterMatchedRouteIds,
|
|
12
|
+
getRouterServerSnapshot,
|
|
13
|
+
} from '@modern-js/runtime-extensions/router-state';
|
|
14
|
+
import { getRendererResources } from './assetPolicy';
|
|
15
|
+
import { createFederatedCssLinks } from './federatedCss';
|
|
16
|
+
|
|
17
|
+
export function createTemplatePolicy(
|
|
18
|
+
render: SSRRenderInfo,
|
|
19
|
+
): Pick<SSRRenderLifecycle, 'transformTemplateChunk' | 'getRouterData'> {
|
|
20
|
+
const { resource, routeManifest, entryName } = getRendererResources(render);
|
|
21
|
+
const config = render.config as { nonce?: string } | undefined;
|
|
22
|
+
return {
|
|
23
|
+
getRouterData() {
|
|
24
|
+
return (
|
|
25
|
+
getRouterServerSnapshot(render.runtimeContext)?.routerData || undefined
|
|
26
|
+
);
|
|
27
|
+
},
|
|
28
|
+
transformTemplateChunk(chunk, formatting) {
|
|
29
|
+
if (chunk.name === 'styles') {
|
|
30
|
+
return {
|
|
31
|
+
...chunk,
|
|
32
|
+
content:
|
|
33
|
+
chunk.content +
|
|
34
|
+
createFederatedCssLinks(
|
|
35
|
+
resource?.moduleFederationCssAssets,
|
|
36
|
+
{
|
|
37
|
+
template: chunk.template,
|
|
38
|
+
attributes: chunk.attributes,
|
|
39
|
+
existingAssets: chunk.emittedAssets,
|
|
40
|
+
},
|
|
41
|
+
formatting,
|
|
42
|
+
),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
let content = chunk.content;
|
|
46
|
+
if (chunk.name === 'scripts' && render.mode === 'stream') {
|
|
47
|
+
content = createRouteHydrationScriptTags(
|
|
48
|
+
routeManifest,
|
|
49
|
+
getRouterMatchedRouteIds(render.runtimeContext) ?? [],
|
|
50
|
+
entryName,
|
|
51
|
+
{ nonce: config?.nonce, template: chunk.template },
|
|
52
|
+
);
|
|
53
|
+
} else if (chunk.name === 'data') {
|
|
54
|
+
const scripts = getRouterHydrationScripts(render.runtimeContext);
|
|
55
|
+
if (scripts.length > 0) content += `\n${scripts.join('\n')}`;
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
...chunk,
|
|
59
|
+
content: '',
|
|
60
|
+
template: replaceChunkJsPlaceholder(
|
|
61
|
+
chunk.template,
|
|
62
|
+
content,
|
|
63
|
+
entryName,
|
|
64
|
+
chunk.placeholder,
|
|
65
|
+
),
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {
|
|
2
|
+
attributesToString,
|
|
3
|
+
hasStylesheetLink,
|
|
4
|
+
} from '../../plugin-runtime/src/core/server/utils';
|
|
5
|
+
import { createFederatedCssLinks as createLinks } from '../src/federatedCss';
|
|
6
|
+
|
|
7
|
+
const createFederatedCssLinks = (
|
|
8
|
+
assets: Parameters<typeof createLinks>[0],
|
|
9
|
+
options: Parameters<typeof createLinks>[1],
|
|
10
|
+
) => createLinks(assets, options, { attributesToString, hasStylesheetLink });
|
|
11
|
+
|
|
12
|
+
describe('createFederatedCssLinks', () => {
|
|
13
|
+
it('does not dedupe a stylesheet against a longer href substring', () => {
|
|
14
|
+
const html = createFederatedCssLinks(['/remote/theme.css'], {
|
|
15
|
+
template: '<link href="/remote/theme.css?v=next" rel="stylesheet" />',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
expect(html).toBe('<link href="/remote/theme.css" rel="stylesheet" />');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('escapes federated css asset urls before interpolating href attributes', () => {
|
|
22
|
+
const html = createFederatedCssLinks(
|
|
23
|
+
['/remote/theme" onload="alert(1)&x=<tag>.css'],
|
|
24
|
+
{
|
|
25
|
+
template: '',
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
expect(html).toBe(
|
|
30
|
+
'<link href="/remote/theme" onload="alert(1)&x=<tag>.css" rel="stylesheet" />',
|
|
31
|
+
);
|
|
32
|
+
expect(html).not.toContain('onload="alert(1)');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('escapes extra attributes and prevents duplicate renderer-owned attributes', () => {
|
|
36
|
+
const html = createFederatedCssLinks(['/remote/theme.css'], {
|
|
37
|
+
template: '',
|
|
38
|
+
attributes: {
|
|
39
|
+
nonce: 'nonce"&<value>',
|
|
40
|
+
href: 'https://attacker.example/override.css',
|
|
41
|
+
rel: 'preload',
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(html).toBe(
|
|
46
|
+
'<link nonce="nonce"&<value>" href="/remote/theme.css" rel="stylesheet" />',
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { getHelmetContext } from '@modern-js/runtime-extensions/helmet-context';
|
|
2
|
+
import React, { useContext } from 'react';
|
|
3
|
+
import { renderToReadableStream, renderToString } from 'react-dom/server';
|
|
4
|
+
import {
|
|
5
|
+
getGlobalEnableRsc,
|
|
6
|
+
getGlobalInternalRuntimeContext,
|
|
7
|
+
getInitialContext,
|
|
8
|
+
InternalRuntimeContext,
|
|
9
|
+
setGlobalContext,
|
|
10
|
+
setGlobalInternalRuntimeContext,
|
|
11
|
+
} from '../../plugin-runtime/src/core/context';
|
|
12
|
+
import { registerPlugin } from '../../plugin-runtime/src/core/plugin';
|
|
13
|
+
import {
|
|
14
|
+
wrapRuntimeContextProvider as nativeWrapRuntimeContextProvider,
|
|
15
|
+
wrapRuntimeComponentResolver,
|
|
16
|
+
} from '../../plugin-runtime/src/core/react/wrapper';
|
|
17
|
+
import { Helmet } from '../../plugin-runtime/src/exports/head';
|
|
18
|
+
import rendererHeadPlugin from '../src/runtimePlugin';
|
|
19
|
+
|
|
20
|
+
const previousRuntime = getGlobalInternalRuntimeContext();
|
|
21
|
+
const previousRsc = getGlobalEnableRsc();
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
setGlobalContext({ enableRsc: false });
|
|
24
|
+
registerPlugin([rendererHeadPlugin()]);
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
setGlobalInternalRuntimeContext(previousRuntime);
|
|
28
|
+
setGlobalContext({ enableRsc: previousRsc });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Exercise the unchanged native provider/head entry points through registered
|
|
32
|
+
// collector providers. Transaction/lifecycle rendering is covered separately.
|
|
33
|
+
function wrapRuntimeContextProvider(
|
|
34
|
+
app: React.ReactElement,
|
|
35
|
+
context: ReturnType<typeof getInitialContext>,
|
|
36
|
+
) {
|
|
37
|
+
const hooks = getGlobalInternalRuntimeContext().hooks;
|
|
38
|
+
const App = hooks.wrapRoot.call(() => app);
|
|
39
|
+
let root = nativeWrapRuntimeContextProvider(<App />, context);
|
|
40
|
+
if (getGlobalEnableRsc() && !context.isBrowser) {
|
|
41
|
+
root = wrapRuntimeComponentResolver(root);
|
|
42
|
+
}
|
|
43
|
+
const collectors = hooks.extendStringSSRCollectors.call({
|
|
44
|
+
chunkSet: { renderLevel: 0, ssrScripts: '', jsChunk: '', cssChunk: '' },
|
|
45
|
+
render: {
|
|
46
|
+
runtimeContext: context,
|
|
47
|
+
request: new Request('https://example.test/tractors'),
|
|
48
|
+
platform: 'node',
|
|
49
|
+
mode: 'string',
|
|
50
|
+
isRsc: getGlobalEnableRsc() === true,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
for (const collector of collectors) root = collector.collect?.(root) ?? root;
|
|
54
|
+
return root;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('registered native runtime/head compatibility', () => {
|
|
58
|
+
it('keeps overlapping requests on their original head contexts after projection', async () => {
|
|
59
|
+
const projectedContexts: object[] = [];
|
|
60
|
+
getGlobalInternalRuntimeContext().pluginAPI?.transformRuntimeContext(
|
|
61
|
+
(projection: any) => {
|
|
62
|
+
const internalContext = { ...projection.internalContext };
|
|
63
|
+
delete internalContext[
|
|
64
|
+
Symbol.for('@modern-js/runtime:context-extensions')
|
|
65
|
+
];
|
|
66
|
+
projectedContexts.push(internalContext);
|
|
67
|
+
return { ...projection, internalContext };
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
const SuspendedHead = ({
|
|
71
|
+
ready,
|
|
72
|
+
title,
|
|
73
|
+
}: {
|
|
74
|
+
ready: Promise<void>;
|
|
75
|
+
title: string;
|
|
76
|
+
}) => {
|
|
77
|
+
React.use(ready);
|
|
78
|
+
return (
|
|
79
|
+
<Helmet>
|
|
80
|
+
<title>{title}</title>
|
|
81
|
+
</Helmet>
|
|
82
|
+
);
|
|
83
|
+
};
|
|
84
|
+
const requests = ['first request', 'second request'].map(title => {
|
|
85
|
+
let release = () => {};
|
|
86
|
+
const ready = new Promise<void>(resolve => {
|
|
87
|
+
release = resolve;
|
|
88
|
+
});
|
|
89
|
+
const context = getInitialContext(false);
|
|
90
|
+
return { title, ready, release, context };
|
|
91
|
+
});
|
|
92
|
+
const streams = await Promise.all(
|
|
93
|
+
requests.map(({ title, ready, context }) =>
|
|
94
|
+
renderToReadableStream(
|
|
95
|
+
wrapRuntimeContextProvider(
|
|
96
|
+
<React.Suspense fallback="pending">
|
|
97
|
+
<SuspendedHead ready={ready} title={title} />
|
|
98
|
+
</React.Suspense>,
|
|
99
|
+
context,
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
requests[1].release();
|
|
105
|
+
requests[0].release();
|
|
106
|
+
await Promise.all(
|
|
107
|
+
streams.map(async stream => {
|
|
108
|
+
await stream.allReady;
|
|
109
|
+
return new Response(stream).text();
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
for (const { context, title } of requests) {
|
|
114
|
+
expect(getHelmetContext(context)?.helmet?.title.toString()).toBe(
|
|
115
|
+
`<title data-rh="true">${title}</title>`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
expect(projectedContexts).toHaveLength(2);
|
|
119
|
+
for (const context of projectedContexts) {
|
|
120
|
+
expect(getHelmetContext(context)).toBeUndefined();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('keeps response functions outside the RSC internal context', () => {
|
|
125
|
+
let internalValue: ReturnType<typeof getInitialContext> | undefined;
|
|
126
|
+
|
|
127
|
+
const Probe = () => {
|
|
128
|
+
internalValue = useContext(InternalRuntimeContext);
|
|
129
|
+
return null;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const response = {
|
|
133
|
+
setHeader: rstest.fn(),
|
|
134
|
+
status: rstest.fn(),
|
|
135
|
+
locals: { tenant: 'tractor-store' },
|
|
136
|
+
};
|
|
137
|
+
const context = getInitialContext(false);
|
|
138
|
+
context.ssrContext = {
|
|
139
|
+
request: {
|
|
140
|
+
params: { category: 'compact' },
|
|
141
|
+
pathname: '/tractors',
|
|
142
|
+
query: { sort: 'price' },
|
|
143
|
+
headers: { accept: 'text/html' },
|
|
144
|
+
host: 'example.test',
|
|
145
|
+
url: 'https://example.test/tractors?sort=price',
|
|
146
|
+
},
|
|
147
|
+
response,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
setGlobalContext({ enableRsc: true });
|
|
151
|
+
try {
|
|
152
|
+
renderToString(wrapRuntimeContextProvider(<Probe />, context));
|
|
153
|
+
} finally {
|
|
154
|
+
setGlobalContext({ enableRsc: false });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
expect(
|
|
158
|
+
Object.values(internalValue?.requestContext.response ?? {}).some(
|
|
159
|
+
value => typeof value === 'function',
|
|
160
|
+
),
|
|
161
|
+
).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
});
|