From 84a4c827a1ac280d650816c6e641a7998eba6a4f Mon Sep 17 00:00:00 2001 From: Sam Brown Date: Fri, 21 Jul 2023 13:22:13 +0100 Subject: [PATCH] RemoteView: support a wider range of potential asset URLs (#2439) --- .changeset/nasty-pans-cough.md | 5 + docs/components/remote-view.md | 82 ++++++ ...efault-remote-view-error-fallback.test.tsx | 2 +- .../src/__tests__/get-urls.test.tsx | 238 ++++++++++++++++++ .../src/__tests__/remote-view.test.tsx | 4 +- .../default-remote-view-error-fallback.tsx | 2 +- .../components/remote-view-error-boundary.tsx | 2 +- .../src/components/remote-view-provider.tsx | 32 ++- .../remote-view/src/hooks/useRemoteView.tsx | 2 +- packages/remote-view/src/index.ts | 2 +- packages/remote-view/src/types.ts | 2 +- ...callyImport.tsx => dynamically-import.tsx} | 6 +- packages/remote-view/src/utils/get-urls.ts | 78 ++++++ ...emoteViewError.ts => remote-view-error.ts} | 0 14 files changed, 439 insertions(+), 18 deletions(-) create mode 100644 .changeset/nasty-pans-cough.md create mode 100644 packages/remote-view/src/__tests__/get-urls.test.tsx rename packages/remote-view/src/utils/{dynamicallyImport.tsx => dynamically-import.tsx} (55%) create mode 100644 packages/remote-view/src/utils/get-urls.ts rename packages/remote-view/src/utils/{remoteViewError.ts => remote-view-error.ts} (100%) diff --git a/.changeset/nasty-pans-cough.md b/.changeset/nasty-pans-cough.md new file mode 100644 index 000000000..04939acd0 --- /dev/null +++ b/.changeset/nasty-pans-cough.md @@ -0,0 +1,5 @@ +--- +'@modular-scripts/remote-view': minor +--- + +Add support for a wider range of asset URLs diff --git a/docs/components/remote-view.md b/docs/components/remote-view.md index 6b62dfe94..2d6ef8406 100644 --- a/docs/components/remote-view.md +++ b/docs/components/remote-view.md @@ -105,6 +105,88 @@ whereas Modular [Apps](https://modular.js.org/package-types/app/) are loaded into an iframe. For more information on Modular types, check out the [Package Types breakdown](https://modular.js.org/package-types/). +## Providing ESM View URLs + +`` expects an array of URLs which are expected to point at +CDN-hosted ESM Views. + +URLs should either be absolute URLs or relative paths from `/` and point at the +root of your CDN-hosted ESM Views. + +### Supported ESM View URLs + +```javascript +// Absolute URLs, with optional trailing / +'https://localhost:3030/my-card-view', +'https://localhost:3030/my-card-view/', +// HTTP also allowed +'http://localhost:3030/my-card-view', +'http://localhost:3030/my-card-view/', +// Absolute URLs with deep paths +'https://cdn.example.com/subpath/foo/my-card-view', +'https://cdn.example.com/subpath/foo/my-card-view/', +// Root-relative URLs +'/my-card-view', +'/my-card-view/', +// Root-relative URLs with deep paths +'/subpath/foo/my-card-view', +'/subpath/foo/my-card-view/', +``` + +### Unsupported ESM View URLs + +```javascript +// Plain / +'/', +// Relative path from current location +'./relpath/my-card-view', +'./relpath/my-card-view/', +// No protocol, but no leading / +'foo/my-card-view', +'foo/my-card-view/', +// Unsupported protocols +'file:///Users/foo/subpath/my-card-view', +'file:///Users/foo/subpath/my-card-view/', +``` + +The expected method of composing an application that uses ESM Views with +RemoteView is that each ESM View and it's static assets (namely `module` and +`style` paths in `package.json`) all exist under the relevant ESM View's root +URL. For example, let's say you have a Card ESM View: + +- Root path of the ESM View: `https://cdn.example.com/my-card-view/` +- Path to the Card's `package.json`: + `https://cdn.example.com/my-card-view/package.json` +- Path to the Card's ES module: + `https://cdn.example.com/my-card-view/static/card.js` +- Path to the Card's CSS: `https://cdn.example.com/my-card-view/static/card.css` + +Where the Card's `package.json` contains: + +```json +{ + "module": "./static/card.js", + "style": "./static/card.css" +} +``` + +However, it is also possible to supply absolute URLs for `module` and `style`. +This might be useful if you are consuming view assets from a different origin +than the host application. + +Supported `module` and `style` values: + +- `./` prefix: `./static/js/foo.js`, `./static/css/foo.css` +- `/` prefix: `/static/js/foo.js`, `/static/css/foo.css` +- unprefixed: `static/js/foo.js`, `static/css/foo.css` +- absolute: `https://cdn.example/js/foo.js`, + `https://cdn.example.com/css/foo.css` + +A value such as `/../static/js/foo.js` is **not supported**. + +By default, Modular ESM Views automatically generate RemoteView-compatible +values. + ## Fall back to iframes It is also possible to load [ESM Views](https://modular.js.org/esm-views) (in diff --git a/packages/remote-view/src/__tests__/default-remote-view-error-fallback.test.tsx b/packages/remote-view/src/__tests__/default-remote-view-error-fallback.test.tsx index 1b52ff308..fcc82aad9 100644 --- a/packages/remote-view/src/__tests__/default-remote-view-error-fallback.test.tsx +++ b/packages/remote-view/src/__tests__/default-remote-view-error-fallback.test.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import { DefaultRemoteViewErrorFallback } from '../components/default-remote-view-error-fallback'; -import { RemoteViewError } from '../utils/remoteViewError'; +import { RemoteViewError } from '../utils/remote-view-error'; const mockRemoteViewError = new RemoteViewError( 'Some example error', diff --git a/packages/remote-view/src/__tests__/get-urls.test.tsx b/packages/remote-view/src/__tests__/get-urls.test.tsx new file mode 100644 index 000000000..1b0a01bf2 --- /dev/null +++ b/packages/remote-view/src/__tests__/get-urls.test.tsx @@ -0,0 +1,238 @@ +import { + esmViewUrlIsValid, + getRemoteAssetUrl, + getRemotePackageJsonUrl, +} from '../utils/get-urls'; + +const VALID_INPUTS = [ + // Absolute URLs, with optional trailing / + 'https://localhost:3030/my-card-view', + 'https://localhost:3030/my-card-view/', + // HTTP also allowed + 'http://localhost:3030/my-card-view', + 'http://localhost:3030/my-card-view/', + // Absolute URLs with deep paths + 'https://cdn.example.com/subpath/foo/my-card-view', + 'https://cdn.example.com/subpath/foo/my-card-view/', + // Root-relative URLs + '/my-card-view', + '/my-card-view/', + // Root-relative URLs with deep paths + '/subpath/foo/my-card-view', + '/subpath/foo/my-card-view/', +]; + +const INVALID_INPUTS = [ + // Plain / + '/', + // Relative path from current location + './relpath/my-card-view', + './relpath/my-card-view/', + // No protocol, but no leading / + 'foo/my-card-view', + 'foo/my-card-view/', + // Unsupported protocol + 'file:///Users/foo/subpath/my-card-view', + 'file:///Users/foo/subpath/my-card-view/', +]; + +describe('getUrls', () => { + describe('should validate URLs supplied from user input', () => { + describe('URLs considered valid', () => { + VALID_INPUTS.forEach((remoteViewUrl) => { + it(`allows ${remoteViewUrl}`, () => { + expect(esmViewUrlIsValid(remoteViewUrl)).toBe(true); + }); + }); + }); + + describe('URLs considered invalid', () => { + INVALID_INPUTS.forEach((remoteViewUrl) => { + it(`prohibits ${remoteViewUrl}`, () => { + expect(esmViewUrlIsValid(remoteViewUrl)).toBe(false); + }); + }); + }); + }); + + describe('should correctly point to package.json', () => { + const EXPECTED_OUTCOMES = [ + 'https://localhost:3030/my-card-view/package.json', + 'https://localhost:3030/my-card-view/package.json', + 'http://localhost:3030/my-card-view/package.json', + 'http://localhost:3030/my-card-view/package.json', + 'https://cdn.example.com/subpath/foo/my-card-view/package.json', + 'https://cdn.example.com/subpath/foo/my-card-view/package.json', + '/my-card-view/package.json', + '/my-card-view/package.json', + '/subpath/foo/my-card-view/package.json', + '/subpath/foo/my-card-view/package.json', + ]; + + VALID_INPUTS.forEach((validBaseUrl, index) => { + it(`given the valid ESM View URL of "${validBaseUrl}", correctly points to package.json`, () => { + expect(getRemotePackageJsonUrl(validBaseUrl)).toBe( + EXPECTED_OUTCOMES[index], + ); + }); + }); + }); + + const SUPPORTED_ASSET_PATHS = { + PRECEDING_SLASH: '/static/foo/bar.js', + PRECEDING_DOT_SLASH: './static/foo/bar.js', + ABSOLUTE_URL: 'https://cdn.example.com/foo/bar/module.js', + NO_PREFIX: 'foo/bar/module.js', + }; + + const EXPECTED_ASSET_OUTPUTS: Record> = { + 'https://localhost:3030/my-card-view': { + [SUPPORTED_ASSET_PATHS.PRECEDING_SLASH]: + 'https://localhost:3030/my-card-view/static/foo/bar.js', + [SUPPORTED_ASSET_PATHS.PRECEDING_DOT_SLASH]: + 'https://localhost:3030/my-card-view/static/foo/bar.js', + [SUPPORTED_ASSET_PATHS.ABSOLUTE_URL]: + 'https://cdn.example.com/foo/bar/module.js', + [SUPPORTED_ASSET_PATHS.NO_PREFIX]: + 'https://localhost:3030/my-card-view/foo/bar/module.js', + }, + 'https://localhost:3030/my-card-view/': { + [SUPPORTED_ASSET_PATHS.PRECEDING_SLASH]: + 'https://localhost:3030/my-card-view/static/foo/bar.js', + [SUPPORTED_ASSET_PATHS.PRECEDING_DOT_SLASH]: + 'https://localhost:3030/my-card-view/static/foo/bar.js', + [SUPPORTED_ASSET_PATHS.ABSOLUTE_URL]: + 'https://cdn.example.com/foo/bar/module.js', + [SUPPORTED_ASSET_PATHS.NO_PREFIX]: + 'https://localhost:3030/my-card-view/foo/bar/module.js', + }, + 'http://localhost:3030/my-card-view': { + [SUPPORTED_ASSET_PATHS.PRECEDING_SLASH]: + 'http://localhost:3030/my-card-view/static/foo/bar.js', + [SUPPORTED_ASSET_PATHS.PRECEDING_DOT_SLASH]: + 'http://localhost:3030/my-card-view/static/foo/bar.js', + [SUPPORTED_ASSET_PATHS.ABSOLUTE_URL]: + 'https://cdn.example.com/foo/bar/module.js', + [SUPPORTED_ASSET_PATHS.NO_PREFIX]: + 'http://localhost:3030/my-card-view/foo/bar/module.js', + }, + 'http://localhost:3030/my-card-view/': { + [SUPPORTED_ASSET_PATHS.PRECEDING_SLASH]: + 'http://localhost:3030/my-card-view/static/foo/bar.js', + [SUPPORTED_ASSET_PATHS.PRECEDING_DOT_SLASH]: + 'http://localhost:3030/my-card-view/static/foo/bar.js', + [SUPPORTED_ASSET_PATHS.ABSOLUTE_URL]: + 'https://cdn.example.com/foo/bar/module.js', + [SUPPORTED_ASSET_PATHS.NO_PREFIX]: + 'http://localhost:3030/my-card-view/foo/bar/module.js', + }, + 'https://cdn.example.com/subpath/foo/my-card-view': { + [SUPPORTED_ASSET_PATHS.PRECEDING_SLASH]: + 'https://cdn.example.com/subpath/foo/my-card-view/static/foo/bar.js', + [SUPPORTED_ASSET_PATHS.PRECEDING_DOT_SLASH]: + 'https://cdn.example.com/subpath/foo/my-card-view/static/foo/bar.js', + [SUPPORTED_ASSET_PATHS.ABSOLUTE_URL]: + 'https://cdn.example.com/foo/bar/module.js', + [SUPPORTED_ASSET_PATHS.NO_PREFIX]: + 'https://cdn.example.com/subpath/foo/my-card-view/foo/bar/module.js', + }, + 'https://cdn.example.com/subpath/foo/my-card-view/': { + [SUPPORTED_ASSET_PATHS.PRECEDING_SLASH]: + 'https://cdn.example.com/subpath/foo/my-card-view/static/foo/bar.js', + [SUPPORTED_ASSET_PATHS.PRECEDING_DOT_SLASH]: + 'https://cdn.example.com/subpath/foo/my-card-view/static/foo/bar.js', + [SUPPORTED_ASSET_PATHS.ABSOLUTE_URL]: + 'https://cdn.example.com/foo/bar/module.js', + [SUPPORTED_ASSET_PATHS.NO_PREFIX]: + 'https://cdn.example.com/subpath/foo/my-card-view/foo/bar/module.js', + }, + '/my-card-view': { + [SUPPORTED_ASSET_PATHS.PRECEDING_SLASH]: + '/my-card-view/static/foo/bar.js', + [SUPPORTED_ASSET_PATHS.PRECEDING_DOT_SLASH]: + '/my-card-view/static/foo/bar.js', + [SUPPORTED_ASSET_PATHS.ABSOLUTE_URL]: + 'https://cdn.example.com/foo/bar/module.js', + [SUPPORTED_ASSET_PATHS.NO_PREFIX]: '/my-card-view/foo/bar/module.js', + }, + '/my-card-view/': { + [SUPPORTED_ASSET_PATHS.PRECEDING_SLASH]: + '/my-card-view/static/foo/bar.js', + [SUPPORTED_ASSET_PATHS.PRECEDING_DOT_SLASH]: + '/my-card-view/static/foo/bar.js', + [SUPPORTED_ASSET_PATHS.ABSOLUTE_URL]: + 'https://cdn.example.com/foo/bar/module.js', + [SUPPORTED_ASSET_PATHS.NO_PREFIX]: '/my-card-view/foo/bar/module.js', + }, + '/subpath/foo/my-card-view': { + [SUPPORTED_ASSET_PATHS.PRECEDING_SLASH]: + '/subpath/foo/my-card-view/static/foo/bar.js', + [SUPPORTED_ASSET_PATHS.PRECEDING_DOT_SLASH]: + '/subpath/foo/my-card-view/static/foo/bar.js', + [SUPPORTED_ASSET_PATHS.ABSOLUTE_URL]: + 'https://cdn.example.com/foo/bar/module.js', + [SUPPORTED_ASSET_PATHS.NO_PREFIX]: + '/subpath/foo/my-card-view/foo/bar/module.js', + }, + '/subpath/foo/my-card-view/': { + [SUPPORTED_ASSET_PATHS.PRECEDING_SLASH]: + '/subpath/foo/my-card-view/static/foo/bar.js', + [SUPPORTED_ASSET_PATHS.PRECEDING_DOT_SLASH]: + '/subpath/foo/my-card-view/static/foo/bar.js', + [SUPPORTED_ASSET_PATHS.ABSOLUTE_URL]: + 'https://cdn.example.com/foo/bar/module.js', + [SUPPORTED_ASSET_PATHS.NO_PREFIX]: + '/subpath/foo/my-card-view/foo/bar/module.js', + }, + }; + + describe('should build static asset URLs corectly', () => { + VALID_INPUTS.forEach((validBaseUrl) => { + describe(`given an ESM View URL of ${validBaseUrl}`, () => { + const expectedA = + EXPECTED_ASSET_OUTPUTS[validBaseUrl][ + SUPPORTED_ASSET_PATHS.PRECEDING_SLASH + ]; + it(`given the assetPath of "${SUPPORTED_ASSET_PATHS.PRECEDING_SLASH}", produces "${expectedA}"`, () => { + expect( + getRemoteAssetUrl( + validBaseUrl, + SUPPORTED_ASSET_PATHS.PRECEDING_SLASH, + ), + ).toBe(expectedA); + }); + + const expectedB = + EXPECTED_ASSET_OUTPUTS[validBaseUrl][ + SUPPORTED_ASSET_PATHS.PRECEDING_DOT_SLASH + ]; + it(`given the assetPath of "${SUPPORTED_ASSET_PATHS.PRECEDING_DOT_SLASH}", produces "${expectedB}"`, () => { + expect( + getRemoteAssetUrl( + validBaseUrl, + SUPPORTED_ASSET_PATHS.PRECEDING_DOT_SLASH, + ), + ).toBe(expectedB); + }); + + const expectedC = + EXPECTED_ASSET_OUTPUTS[validBaseUrl][ + SUPPORTED_ASSET_PATHS.ABSOLUTE_URL + ]; + it(`given the assetPath of "${SUPPORTED_ASSET_PATHS.ABSOLUTE_URL}", produces "${expectedC}"`, () => { + expect( + getRemoteAssetUrl(validBaseUrl, SUPPORTED_ASSET_PATHS.ABSOLUTE_URL), + ).toBe(expectedC); + }); + + const expectedD = + EXPECTED_ASSET_OUTPUTS[validBaseUrl][SUPPORTED_ASSET_PATHS.NO_PREFIX]; + it(`given the assetPath of "${SUPPORTED_ASSET_PATHS.NO_PREFIX}", produces "${expectedD}"`, () => { + expect( + getRemoteAssetUrl(validBaseUrl, SUPPORTED_ASSET_PATHS.NO_PREFIX), + ).toBe(expectedD); + }); + }); + }); + }); +}); diff --git a/packages/remote-view/src/__tests__/remote-view.test.tsx b/packages/remote-view/src/__tests__/remote-view.test.tsx index 984062686..9d43fbaed 100644 --- a/packages/remote-view/src/__tests__/remote-view.test.tsx +++ b/packages/remote-view/src/__tests__/remote-view.test.tsx @@ -14,7 +14,7 @@ import { RemoteViewErrorBoundary, RemoteViewProvider, } from '../components'; -import { RemoteViewError } from '../utils/remoteViewError'; +import { RemoteViewError } from '../utils/remote-view-error'; function FakeComponentA() { return
Faked dynamically imported module A
; @@ -59,7 +59,7 @@ const badManifestB = { }, }; -jest.mock('../utils/dynamicallyImport', () => { +jest.mock('../utils/dynamically-import', () => { let n = 0; return { dynamicallyImport: async () => { diff --git a/packages/remote-view/src/components/default-remote-view-error-fallback.tsx b/packages/remote-view/src/components/default-remote-view-error-fallback.tsx index 384e41fd9..a08b770f5 100644 --- a/packages/remote-view/src/components/default-remote-view-error-fallback.tsx +++ b/packages/remote-view/src/components/default-remote-view-error-fallback.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { RemoteViewError } from '../utils/remoteViewError'; +import { RemoteViewError } from '../utils/remote-view-error'; export function DefaultRemoteViewErrorFallback({ error, diff --git a/packages/remote-view/src/components/remote-view-error-boundary.tsx b/packages/remote-view/src/components/remote-view-error-boundary.tsx index 21f22bcc3..8570ac744 100644 --- a/packages/remote-view/src/components/remote-view-error-boundary.tsx +++ b/packages/remote-view/src/components/remote-view-error-boundary.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { RemoteViewError } from '../utils/remoteViewError'; +import { RemoteViewError } from '../utils/remote-view-error'; import { DefaultRemoteViewErrorFallback } from './default-remote-view-error-fallback'; import { DefaultUnknownErrorFallback } from './default-unknown-error-fallback'; diff --git a/packages/remote-view/src/components/remote-view-provider.tsx b/packages/remote-view/src/components/remote-view-provider.tsx index 1f1993e41..613eb50df 100644 --- a/packages/remote-view/src/components/remote-view-provider.tsx +++ b/packages/remote-view/src/components/remote-view-provider.tsx @@ -1,8 +1,13 @@ import React, { useEffect, useMemo, useState } from 'react'; import { ErrorContext, ViewsContext } from '../context'; -import { RemoteViewError } from '../utils/remoteViewError'; -import { dynamicallyImport } from '../utils/dynamicallyImport'; +import { RemoteViewError } from '../utils/remote-view-error'; +import { dynamicallyImport } from '../utils/dynamically-import'; import { loading } from '../utils/symbol'; +import { + esmViewUrlIsValid, + getRemoteAssetUrl, + getRemotePackageJsonUrl, +} from '../utils/get-urls'; import type { ManifestCheck, RemoteViewErrorsContext, @@ -22,7 +27,8 @@ async function loadRemoteView( ): Promise { let manifest: MicrofrontendManifest | undefined; try { - const response = await fetch(`${baseUrl}/package.json`); + const packageJsonUrl = getRemotePackageJsonUrl(baseUrl); + const response = await fetch(packageJsonUrl); manifest = (await response.json()) as MicrofrontendManifest; } catch (e) { throw new RemoteViewError( @@ -50,7 +56,8 @@ async function loadRemoteView( (loadWithIframeFallback && loadWithIframeFallback(manifest)) ) { const iframeTitle = manifest.name; - return () =>