@bleedingdev/modern-js-runtime-renderer-extensions 0.0.0 → 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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cjs/assetPolicy.js +89 -0
  3. package/dist/cjs/federatedCss.js +57 -0
  4. package/dist/cjs/node.js +53 -0
  5. package/dist/cjs/runtimePlugin.js +131 -0
  6. package/dist/cjs/templatePolicy.js +76 -0
  7. package/dist/esm/assetPolicy.mjs +48 -0
  8. package/dist/esm/federatedCss.mjs +19 -0
  9. package/dist/esm/runtimePlugin.mjs +78 -0
  10. package/dist/esm/templatePolicy.mjs +38 -0
  11. package/dist/esm-node/assetPolicy.mjs +49 -0
  12. package/dist/esm-node/federatedCss.mjs +20 -0
  13. package/dist/esm-node/node.mjs +11 -0
  14. package/dist/esm-node/runtimePlugin.mjs +79 -0
  15. package/dist/esm-node/templatePolicy.mjs +39 -0
  16. package/dist/types/assetPolicy.d.ts +19 -0
  17. package/dist/types/federatedCss.d.ts +6 -0
  18. package/dist/types/node.d.ts +3 -0
  19. package/dist/types/runtimePlugin.d.ts +7 -0
  20. package/dist/types/templatePolicy.d.ts +2 -0
  21. package/package.json +61 -7
  22. package/rslib.config.mts +19 -0
  23. package/rstest.config.mts +29 -0
  24. package/src/assetPolicy.ts +86 -0
  25. package/src/federatedCss.ts +43 -0
  26. package/src/node.ts +16 -0
  27. package/src/runtimePlugin.ts +119 -0
  28. package/src/templatePolicy.ts +69 -0
  29. package/tests/federatedCss.test.ts +80 -0
  30. package/tests/headRuntime.test.tsx +334 -0
  31. package/tests/nativeAssetPolicy.test.tsx +515 -0
  32. package/tests/nativeStreamHead.test.tsx +77 -0
  33. package/tests/nativeStringHead.test.tsx +128 -0
  34. package/tests/nativeTemplatePolicy.test.tsx +818 -0
  35. package/tests/runtimePlugin.test.tsx +37 -0
  36. package/tests/scriptOrder.security.test.ts +42 -0
  37. package/tsconfig.json +10 -0
  38. package/README.md +0 -3
@@ -0,0 +1,80 @@
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&quot; onload=&quot;alert(1)&amp;x=&lt;tag&gt;.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&quot;&amp;&lt;value&gt;" href="/remote/theme.css" rel="stylesheet" />',
47
+ );
48
+ expect(html.match(/\bhref=/g)).toHaveLength(1);
49
+ expect(html.match(/\brel=/g)).toHaveLength(1);
50
+ });
51
+ });
52
+
53
+ describe('SSR federated CSS helper matrix', () => {
54
+ it('escapes federated CSS hrefs and dedupes existing, repeated, and template-present assets', () => {
55
+ expect(
56
+ createFederatedCssLinks(
57
+ [
58
+ '/remote/base.css',
59
+ '/remote/base.css',
60
+ '/remote/theme" onload="alert(1)&x=<tag>.css',
61
+ '/remote/template".css',
62
+ '/remote/existing.css',
63
+ '',
64
+ ],
65
+ {
66
+ template:
67
+ '<link href="/remote/template&quot;.css" rel="stylesheet" />',
68
+ attributes: {
69
+ 'data-mf': 'checkout',
70
+ nonce: 'nonce-2',
71
+ skipped: undefined,
72
+ },
73
+ existingAssets: ['/remote/existing.css'],
74
+ },
75
+ ),
76
+ ).toBe(
77
+ '<link data-mf="checkout" nonce="nonce-2" href="/remote/base.css" rel="stylesheet" /><link data-mf="checkout" nonce="nonce-2" href="/remote/theme&quot; onload=&quot;alert(1)&amp;x=&lt;tag&gt;.css" rel="stylesheet" />',
78
+ );
79
+ });
80
+ });
@@ -0,0 +1,334 @@
1
+ import { getHelmetContext } from '@modern-js/runtime-extensions/helmet-context';
2
+ import {
3
+ applyRouterRuntimeState,
4
+ getRouterRuntimeState,
5
+ getRouterServerSnapshot,
6
+ } from '@modern-js/runtime-extensions/router-state';
7
+ import React, { useContext } from 'react';
8
+ import { renderToReadableStream, renderToString } from 'react-dom/server';
9
+ import {
10
+ getGlobalEnableRsc,
11
+ getGlobalInternalRuntimeContext,
12
+ getInitialContext,
13
+ InternalRuntimeContext,
14
+ RuntimeContext,
15
+ setGlobalContext,
16
+ setGlobalInternalRuntimeContext,
17
+ } from '../../plugin-runtime/src/core/context';
18
+ import { registerPlugin } from '../../plugin-runtime/src/core/plugin';
19
+ import {
20
+ wrapRuntimeContextProvider as nativeWrapRuntimeContextProvider,
21
+ wrapRuntimeComponentResolver,
22
+ } from '../../plugin-runtime/src/core/react/wrapper';
23
+ import { Helmet } from '../../plugin-runtime/src/exports/head';
24
+ import rendererHeadPlugin from '../src/runtimePlugin';
25
+
26
+ const previousRuntime = getGlobalInternalRuntimeContext();
27
+ const previousRsc = getGlobalEnableRsc();
28
+ beforeEach(() => {
29
+ setGlobalContext({ enableRsc: false });
30
+ registerPlugin([rendererHeadPlugin()]);
31
+ });
32
+ afterEach(() => {
33
+ setGlobalInternalRuntimeContext(previousRuntime);
34
+ setGlobalContext({ enableRsc: previousRsc });
35
+ });
36
+
37
+ // Exercise the unchanged native provider/head entry points through registered
38
+ // collector providers. Transaction/lifecycle rendering is covered separately.
39
+ function wrapRuntimeContextProvider(
40
+ app: React.ReactElement,
41
+ context: ReturnType<typeof getInitialContext>,
42
+ ) {
43
+ const hooks = getGlobalInternalRuntimeContext().hooks;
44
+ const App = hooks.wrapRoot.call(() => app);
45
+ let root = nativeWrapRuntimeContextProvider(<App />, context);
46
+ if (getGlobalEnableRsc() && !context.isBrowser) {
47
+ root = wrapRuntimeComponentResolver(root);
48
+ }
49
+ const collectors = hooks.extendStringSSRCollectors.call({
50
+ chunkSet: { renderLevel: 0, ssrScripts: '', jsChunk: '', cssChunk: '' },
51
+ render: {
52
+ runtimeContext: context,
53
+ request: new Request('https://example.test/tractors'),
54
+ platform: 'node',
55
+ mode: 'string',
56
+ isRsc: getGlobalEnableRsc() === true,
57
+ },
58
+ });
59
+ for (const collector of collectors) root = collector.collect?.(root) ?? root;
60
+ return root;
61
+ }
62
+
63
+ describe('registered native runtime/head compatibility', () => {
64
+ it('keeps overlapping requests on their original head contexts after projection', async () => {
65
+ const projectedContexts: object[] = [];
66
+ getGlobalInternalRuntimeContext().pluginAPI?.transformRuntimeContext(
67
+ (projection: any) => {
68
+ const internalContext = { ...projection.internalContext };
69
+ delete internalContext[
70
+ Symbol.for('@modern-js/runtime:context-extensions')
71
+ ];
72
+ projectedContexts.push(internalContext);
73
+ return { ...projection, internalContext };
74
+ },
75
+ );
76
+ const SuspendedHead = ({
77
+ ready,
78
+ title,
79
+ }: {
80
+ ready: Promise<void>;
81
+ title: string;
82
+ }) => {
83
+ React.use(ready);
84
+ return (
85
+ <Helmet>
86
+ <title>{title}</title>
87
+ </Helmet>
88
+ );
89
+ };
90
+ const requests = ['first request', 'second request'].map(title => {
91
+ let release = () => {};
92
+ const ready = new Promise<void>(resolve => {
93
+ release = resolve;
94
+ });
95
+ const context = getInitialContext(false);
96
+ return { title, ready, release, context };
97
+ });
98
+ const streams = await Promise.all(
99
+ requests.map(({ title, ready, context }) =>
100
+ renderToReadableStream(
101
+ wrapRuntimeContextProvider(
102
+ <React.Suspense fallback="pending">
103
+ <SuspendedHead ready={ready} title={title} />
104
+ </React.Suspense>,
105
+ context,
106
+ ),
107
+ ),
108
+ ),
109
+ );
110
+ requests[1].release();
111
+ requests[0].release();
112
+ await Promise.all(
113
+ streams.map(async stream => {
114
+ await stream.allReady;
115
+ return new Response(stream).text();
116
+ }),
117
+ );
118
+
119
+ for (const { context, title } of requests) {
120
+ expect(getHelmetContext(context)?.helmet?.title.toString()).toBe(
121
+ `<title data-rh="true">${title}</title>`,
122
+ );
123
+ }
124
+ expect(projectedContexts).toHaveLength(2);
125
+ for (const context of projectedContexts) {
126
+ expect(getHelmetContext(context)).toBeUndefined();
127
+ }
128
+ });
129
+
130
+ it('should keep router runtime state out of public context enumeration', () => {
131
+ let runtimeValue: Record<string, unknown> | undefined;
132
+ let internalValue: Record<string, unknown> | undefined;
133
+
134
+ const Probe = () => {
135
+ runtimeValue = useContext(RuntimeContext) as Record<string, unknown>;
136
+ internalValue = useContext(InternalRuntimeContext) as Record<
137
+ string,
138
+ unknown
139
+ >;
140
+ return null;
141
+ };
142
+
143
+ const context = getInitialContext(false);
144
+ applyRouterRuntimeState(context, {
145
+ framework: 'custom-router',
146
+ instance: { kind: 'internal-router' },
147
+ serverSnapshot: {
148
+ matchedRouteIds: ['route-a'],
149
+ },
150
+ });
151
+
152
+ renderToString(
153
+ wrapRuntimeContextProvider(
154
+ <Probe />,
155
+ context as Record<string, unknown> as any,
156
+ ),
157
+ );
158
+
159
+ expect(getRouterServerSnapshot(internalValue as object)).toMatchObject({
160
+ matchedRouteIds: ['route-a'],
161
+ });
162
+ expect(getRouterRuntimeState(internalValue as object)?.framework).toBe(
163
+ 'custom-router',
164
+ );
165
+ expect(getRouterRuntimeState(internalValue as object)?.instance).toEqual({
166
+ kind: 'internal-router',
167
+ });
168
+
169
+ // None of the router state may leak into string-key enumeration of
170
+ // either context value — each forbidden key is asserted individually so
171
+ // a partial leak fails too.
172
+ const forbiddenKeys = [
173
+ 'routerFramework',
174
+ 'routerInstance',
175
+ 'routerRuntime',
176
+ 'routerServerSnapshot',
177
+ 'routerHydrationScript',
178
+ 'routerMatchedRouteIds',
179
+ '_helmetContext',
180
+ ];
181
+ for (const value of [runtimeValue, internalValue]) {
182
+ const keys = Object.keys(value as object);
183
+ for (const forbidden of forbiddenKeys) {
184
+ expect(keys).not.toContain(forbidden);
185
+ }
186
+ }
187
+ expect(runtimeValue?.routerFramework).toBeUndefined();
188
+ expect(runtimeValue?.routerInstance).toBeUndefined();
189
+ expect(runtimeValue?.routerServerSnapshot).toBeUndefined();
190
+
191
+ // The symbol-keyed extension slot must not be reachable from the PUBLIC
192
+ // context object either: spreads copy enumerable symbol properties, so
193
+ // the wrapper has to strip the slot from the public copy.
194
+ const extensionsSlot = Symbol.for('@modern-js/runtime:context-extensions');
195
+ expect(Object.getOwnPropertySymbols(runtimeValue as object)).not.toContain(
196
+ extensionsSlot,
197
+ );
198
+ expect(getRouterRuntimeState(runtimeValue as object)).toBeUndefined();
199
+ expect(getRouterServerSnapshot(runtimeValue as object)).toBeUndefined();
200
+
201
+ // ...while the internal context keeps carrying it.
202
+ expect(Object.getOwnPropertySymbols(internalValue as object)).toContain(
203
+ extensionsSlot,
204
+ );
205
+ });
206
+
207
+ it('should collect head tags in an isolated request context', () => {
208
+ const context = getInitialContext(false);
209
+
210
+ renderToString(
211
+ wrapRuntimeContextProvider(
212
+ <Helmet htmlAttributes={{ lang: 'cs' }}>
213
+ <title>Modern SSR</title>
214
+ </Helmet>,
215
+ context as Record<string, unknown> as any,
216
+ ),
217
+ );
218
+
219
+ expect(getHelmetContext(context)?.helmet?.htmlAttributes.toString()).toBe(
220
+ 'lang="cs"',
221
+ );
222
+ expect(getHelmetContext(context)?.helmet?.title.toString()).toBe(
223
+ '<title data-rh="true">Modern SSR</title>',
224
+ );
225
+ });
226
+
227
+ it('keeps response functions outside the RSC internal context', () => {
228
+ let internalValue: ReturnType<typeof getInitialContext> | undefined;
229
+
230
+ const Probe = () => {
231
+ internalValue = useContext(InternalRuntimeContext);
232
+ return null;
233
+ };
234
+
235
+ const response = {
236
+ setHeader: rstest.fn(),
237
+ status: rstest.fn(),
238
+ locals: { tenant: 'tractor-store' },
239
+ };
240
+ const context = getInitialContext(false);
241
+ context.ssrContext = {
242
+ request: {
243
+ params: { category: 'compact' },
244
+ pathname: '/tractors',
245
+ query: { sort: 'price' },
246
+ headers: { accept: 'text/html' },
247
+ host: 'example.test',
248
+ url: 'https://example.test/tractors?sort=price',
249
+ },
250
+ response,
251
+ };
252
+
253
+ setGlobalContext({ enableRsc: true });
254
+ try {
255
+ renderToString(wrapRuntimeContextProvider(<Probe />, context));
256
+ } finally {
257
+ setGlobalContext({ enableRsc: false });
258
+ }
259
+
260
+ expect(internalValue?.ssrContext).toBeUndefined();
261
+ expect(internalValue?.requestContext.request).toEqual({
262
+ params: { category: 'compact' },
263
+ pathname: '/tractors',
264
+ query: { sort: 'price' },
265
+ headers: { accept: 'text/html' },
266
+ host: 'example.test',
267
+ url: 'https://example.test/tractors?sort=price',
268
+ userAgent: undefined,
269
+ cookie: undefined,
270
+ referer: undefined,
271
+ });
272
+ expect(internalValue?.requestContext.response).toEqual({
273
+ locals: response.locals,
274
+ });
275
+ expect(
276
+ Object.values(internalValue?.requestContext.response ?? {}).some(
277
+ value => typeof value === 'function',
278
+ ),
279
+ ).toBe(false);
280
+ });
281
+
282
+ it('renders an RSC context without an SSR context', () => {
283
+ let internalValue: ReturnType<typeof getInitialContext> | undefined;
284
+
285
+ const Probe = () => {
286
+ internalValue = useContext(InternalRuntimeContext);
287
+ return <main>RSC request context</main>;
288
+ };
289
+
290
+ setGlobalContext({ enableRsc: true });
291
+ try {
292
+ const html = renderToString(
293
+ wrapRuntimeContextProvider(<Probe />, getInitialContext(false)),
294
+ );
295
+
296
+ expect(html).toContain('RSC request context');
297
+ expect(internalValue?.requestContext).toEqual({
298
+ request: {
299
+ params: {},
300
+ pathname: '',
301
+ query: {},
302
+ headers: {},
303
+ host: '',
304
+ url: '',
305
+ },
306
+ response: { locals: {} },
307
+ });
308
+ expect(internalValue?.ssrContext).toBeUndefined();
309
+ } finally {
310
+ setGlobalContext({ enableRsc: false });
311
+ }
312
+ });
313
+
314
+ it('serializes React head prop names to valid HTML attributes', () => {
315
+ const context = getInitialContext(false);
316
+
317
+ renderToString(
318
+ wrapRuntimeContextProvider(
319
+ <Helmet>
320
+ <link href="/cs" hrefLang="cs" rel="alternate" />
321
+ <meta charSet="utf-8" />
322
+ </Helmet>,
323
+ context as Record<string, unknown> as any,
324
+ ),
325
+ );
326
+
327
+ expect(getHelmetContext(context)?.helmet?.link.toString()).toContain(
328
+ '<link data-rh="true" href="/cs" hreflang="cs" rel="alternate">',
329
+ );
330
+ expect(getHelmetContext(context)?.helmet?.meta.toString()).toContain(
331
+ '<meta data-rh="true" charset="utf-8">',
332
+ );
333
+ });
334
+ });