Skip to content

Commit

Permalink
RemoteView: support a wider range of potential asset URLs (#2439)
Browse files Browse the repository at this point in the history
  • Loading branch information
sgb-io authored Jul 21, 2023
1 parent 666031d commit 84a4c82
Show file tree
Hide file tree
Showing 14 changed files with 439 additions and 18 deletions.
5 changes: 5 additions & 0 deletions .changeset/nasty-pans-cough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modular-scripts/remote-view': minor
---

Add support for a wider range of asset URLs
82 changes: 82 additions & 0 deletions docs/components/remote-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

`<RemoteViewProvider >` 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
238 changes: 238 additions & 0 deletions packages/remote-view/src/__tests__/get-urls.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, string>> = {
'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);
});
});
});
});
});
4 changes: 2 additions & 2 deletions packages/remote-view/src/__tests__/remote-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
RemoteViewErrorBoundary,
RemoteViewProvider,
} from '../components';
import { RemoteViewError } from '../utils/remoteViewError';
import { RemoteViewError } from '../utils/remote-view-error';

function FakeComponentA() {
return <div>Faked dynamically imported module A</div>;
Expand Down Expand Up @@ -59,7 +59,7 @@ const badManifestB = {
},
};

jest.mock('../utils/dynamicallyImport', () => {
jest.mock('../utils/dynamically-import', () => {
let n = 0;
return {
dynamicallyImport: async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { RemoteViewError } from '../utils/remoteViewError';
import { RemoteViewError } from '../utils/remote-view-error';

export function DefaultRemoteViewErrorFallback({
error,
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
Loading

0 comments on commit 84a4c82

Please sign in to comment.