@appshell/loader 1.0.0-alpha.14 → 1.0.0-alpha.16
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/CHANGELOG.md +7 -0
- package/README.md +19 -21
- package/__tests__/remoteLoader.spec.ts +75 -79
- package/__tests__/resolvers.spec.ts +10 -49
- package/dist/main.js +1 -1
- package/dist/types/config/src/context.d.ts +3 -3
- package/dist/types/config/src/credentials.d.ts +0 -1
- package/dist/types/config/src/credentials.d.ts.map +1 -1
- package/dist/types/config/src/index.d.ts +2 -6
- package/dist/types/config/src/index.d.ts.map +1 -1
- package/dist/types/config/src/publish.d.ts +3 -3
- package/dist/types/config/src/publish.d.ts.map +1 -1
- package/dist/types/config/src/types.d.ts +9 -14
- package/dist/types/config/src/types.d.ts.map +1 -1
- package/dist/types/config/src/utils/index.d.ts +0 -3
- package/dist/types/config/src/utils/index.d.ts.map +1 -1
- package/dist/types/config/src/validators/index.d.ts +0 -2
- package/dist/types/config/src/validators/index.d.ts.map +1 -1
- package/dist/types/loader/src/remoteLoader.d.ts +2 -3
- package/dist/types/loader/src/remoteLoader.d.ts.map +1 -1
- package/dist/types/loader/src/resolvers.d.ts +4 -11
- package/dist/types/loader/src/resolvers.d.ts.map +1 -1
- package/dist/types/runtime/src/errors.d.ts +26 -0
- package/dist/types/runtime/src/errors.d.ts.map +1 -0
- package/dist/types/runtime/src/index.d.ts +4 -0
- package/dist/types/runtime/src/index.d.ts.map +1 -0
- package/dist/types/runtime/src/store.d.ts +26 -0
- package/dist/types/runtime/src/store.d.ts.map +1 -0
- package/dist/types/runtime/src/types.d.ts +10 -0
- package/dist/types/runtime/src/types.d.ts.map +1 -0
- package/package.json +7 -3
- package/src/remoteLoader.ts +11 -18
- package/src/resolvers.ts +7 -64
- package/webpack.config.js +5 -0
- package/dist/types/config/src/deregister.d.ts +0 -3
- package/dist/types/config/src/deregister.d.ts.map +0 -1
- package/dist/types/config/src/generate.env.d.ts +0 -3
- package/dist/types/config/src/generate.env.d.ts.map +0 -1
- package/dist/types/config/src/generate.global-config.d.ts +0 -10
- package/dist/types/config/src/generate.global-config.d.ts.map +0 -1
- package/dist/types/config/src/register.d.ts +0 -4
- package/dist/types/config/src/register.d.ts.map +0 -1
- package/dist/types/config/src/utils/isValidUrl.d.ts +0 -3
- package/dist/types/config/src/utils/isValidUrl.d.ts.map +0 -1
- package/dist/types/config/src/utils/loadJson.d.ts +0 -10
- package/dist/types/config/src/utils/loadJson.d.ts.map +0 -1
- package/dist/types/config/src/utils/merge.d.ts +0 -4
- package/dist/types/config/src/utils/merge.d.ts.map +0 -1
- package/dist/types/config/src/validators/AppshellGlobalConfigValidator.d.ts +0 -4
- package/dist/types/config/src/validators/AppshellGlobalConfigValidator.d.ts.map +0 -1
- package/dist/types/config/src/validators/AppshellManifestValidator.d.ts +0 -4
- package/dist/types/config/src/validators/AppshellManifestValidator.d.ts.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# [1.0.0-alpha.16](https://github.com/appshellhq/appshell/compare/v1.0.0-alpha.14...v1.0.0-alpha.16) (2026-08-27)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
- deliver package vars through a shared runtime, not a global ([9473f62](https://github.com/appshellhq/appshell/commit/9473f6204b75885cd55b12a9ec71a44a74d6aa41))
|
|
11
|
+
- rename core vocabulary to Package / Application / Remote ([418b7e0](https://github.com/appshellhq/appshell/commit/418b7e010b6caa1af661f7fd9c05e751e69315a9))
|
|
12
|
+
|
|
6
13
|
# [1.0.0-alpha.14](https://github.com/appshell-org/appshell/compare/v1.0.0-alpha.13...v1.0.0-alpha.14) (2026-08-23)
|
|
7
14
|
|
|
8
15
|
**Note:** Version bump only for package @appshell/loader
|
package/README.md
CHANGED
|
@@ -20,60 +20,58 @@ Working examples can be found [here](https://github.com/navaris/appshell/tree/ma
|
|
|
20
20
|
To begin, you'll need to install `@appshell/loader`:
|
|
21
21
|
|
|
22
22
|
```console
|
|
23
|
-
npm install @appshell/loader
|
|
23
|
+
npm install @appshell/loader
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
or
|
|
27
27
|
|
|
28
28
|
```console
|
|
29
|
-
yarn add
|
|
29
|
+
yarn add @appshell/loader
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
or
|
|
33
33
|
|
|
34
34
|
```console
|
|
35
|
-
pnpm add
|
|
35
|
+
pnpm add @appshell/loader
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
The default export
|
|
38
|
+
The default export is the loader factory. It returns an async function that resolves a remote
|
|
39
|
+
key and loads the component behind it. It needs no configuration — the composition is read
|
|
40
|
+
from the document the registry served.
|
|
39
41
|
|
|
40
42
|
```ts
|
|
41
43
|
import componentLoader from '@appshell/loader';
|
|
42
44
|
|
|
43
|
-
const load = componentLoader(
|
|
45
|
+
const load = componentLoader();
|
|
44
46
|
|
|
45
47
|
const Component = load<MyComponent>('MyModule/MyComponent');
|
|
46
48
|
|
|
47
49
|
render(<Component />);
|
|
48
50
|
```
|
|
49
51
|
|
|
50
|
-
**Where does the
|
|
52
|
+
**Where does the composition come from?**
|
|
51
53
|
|
|
52
|
-
>
|
|
53
|
-
|
|
54
|
-
```bash
|
|
55
|
-
appshell generate global-config --registry appshell_registry_path
|
|
56
|
-
```
|
|
54
|
+
> The registry inlines it into the document it serves, as `window.__appshell_config__`.
|
|
57
55
|
|
|
58
56
|
## How a remote is resolved
|
|
59
57
|
|
|
60
58
|
The loader walks a chain of resolvers and uses the first one that answers:
|
|
61
59
|
|
|
62
|
-
| Resolver
|
|
63
|
-
|
|
|
64
|
-
| `inlineResolver`
|
|
65
|
-
| `registryResolver`
|
|
66
|
-
| `legacyManifestResolver` | `index[key]` → the app's own `appshell.manifest.json` | Pre-registry hosts. |
|
|
60
|
+
| Resolver | Source | When it answers |
|
|
61
|
+
| ------------------ | ----------------------------------------- | -------------------------------------------------------------------------- |
|
|
62
|
+
| `inlineResolver` | `window.__appshell_config__` | The registry served the page and inlined the composition. No network call. |
|
|
63
|
+
| `registryResolver` | `GET /v1/applications/:id/remotes/:key` | The remote was activated after this page was served. |
|
|
67
64
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
65
|
+
A package's vars are delivered into the `@appshell/runtime` store immediately before
|
|
66
|
+
its remote is loaded, so they are in place before the package's modules evaluate. They
|
|
67
|
+
arrive already merged with the application's overrides, because the registry does that
|
|
68
|
+
merge server side — the browser applies nothing. The package reads them with `getVars()`
|
|
69
|
+
from [`@appshell/vars`](../vars/README.md).
|
|
72
70
|
|
|
73
71
|
You can bypass the chain entirely, which is mainly useful for tests and embedders:
|
|
74
72
|
|
|
75
73
|
```ts
|
|
76
|
-
const load = componentLoader(
|
|
74
|
+
const load = componentLoader({ resolver: myResolver });
|
|
77
75
|
```
|
|
78
76
|
|
|
79
77
|
**Do you have any framework specific loaders?**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** @jest-environment jsdom */
|
|
2
|
-
import {
|
|
2
|
+
import { AppshellComposition, AppshellRemote } from '@appshell/config';
|
|
3
|
+
import { hasVars, readVars, resetVars } from '@appshell/runtime';
|
|
3
4
|
import fetch, { enableFetchMocks } from 'jest-fetch-mock';
|
|
4
5
|
import * as loadAppshellComponent from '../src/loadAppshellComponent';
|
|
5
6
|
import remoteLoader from '../src/remoteLoader';
|
|
@@ -9,112 +10,107 @@ enableFetchMocks();
|
|
|
9
10
|
jest.mock('../src/loadAppshellComponent');
|
|
10
11
|
|
|
11
12
|
describe('remoteLoader', () => {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
metadata: {},
|
|
21
|
-
},
|
|
22
|
-
},
|
|
23
|
-
modules: {},
|
|
24
|
-
environment: {
|
|
25
|
-
TestModule: {
|
|
26
|
-
ENV_VAR_A: 'Original value for A',
|
|
27
|
-
},
|
|
28
|
-
},
|
|
29
|
-
};
|
|
30
|
-
const config: AppshellGlobalConfig = {
|
|
31
|
-
index: {
|
|
32
|
-
'TestModule/TestComponent': 'http://test.com/appshell.manifest.json',
|
|
33
|
-
},
|
|
13
|
+
const KEY = 'TestModule/TestComponent';
|
|
14
|
+
|
|
15
|
+
const remote: AppshellRemote = {
|
|
16
|
+
id: 'test-component',
|
|
17
|
+
scope: 'TestModule',
|
|
18
|
+
module: './TestComponent',
|
|
19
|
+
manifestUrl: 'http://test.com/appshell.manifest.json',
|
|
20
|
+
remoteEntryUrl: 'http://test.com/remoteEntry.js',
|
|
34
21
|
metadata: {},
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const composition: AppshellComposition = {
|
|
25
|
+
applicationId: 'acme/dev',
|
|
26
|
+
revision: 1,
|
|
27
|
+
root: KEY,
|
|
28
|
+
rootProps: {},
|
|
29
|
+
index: { [KEY]: remote.manifestUrl },
|
|
30
|
+
remotes: { [KEY]: remote },
|
|
31
|
+
vars: { TestModule: { ENV_VAR_A: 'Composed value for A' } },
|
|
42
32
|
};
|
|
43
33
|
|
|
44
34
|
beforeEach(() => {
|
|
45
|
-
fetch.
|
|
46
|
-
|
|
47
|
-
);
|
|
35
|
+
fetch.resetMocks();
|
|
36
|
+
// The store is a singleton for the life of the page; each test gets a fresh one.
|
|
37
|
+
resetVars();
|
|
48
38
|
});
|
|
49
39
|
|
|
50
|
-
it('should return the
|
|
40
|
+
it('should return the remote from the inlined composition without fetching', async () => {
|
|
51
41
|
const ExpectedComponent = () => 'test component';
|
|
52
|
-
jest
|
|
53
|
-
.spyOn(loadAppshellComponent, 'default')
|
|
54
|
-
.mockResolvedValue(Promise.resolve(ExpectedComponent));
|
|
42
|
+
jest.spyOn(loadAppshellComponent, 'default').mockResolvedValue(ExpectedComponent);
|
|
55
43
|
|
|
56
|
-
const loadRemote = remoteLoader(
|
|
57
|
-
const [ActualComponent,
|
|
44
|
+
const loadRemote = remoteLoader({ composition });
|
|
45
|
+
const [ActualComponent, actualRemote] = await loadRemote(KEY);
|
|
58
46
|
|
|
47
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
59
48
|
expect(ActualComponent).toEqual(ExpectedComponent);
|
|
60
|
-
expect(
|
|
49
|
+
expect(actualRemote).toEqual(remote);
|
|
61
50
|
});
|
|
62
51
|
|
|
63
|
-
it('should
|
|
64
|
-
|
|
65
|
-
jest
|
|
66
|
-
.spyOn(loadAppshellComponent, 'default')
|
|
67
|
-
.mockResolvedValue(Promise.resolve(ExpectedComponent));
|
|
52
|
+
it('should hand the composed vars to the remote', async () => {
|
|
53
|
+
jest.spyOn(loadAppshellComponent, 'default').mockResolvedValue(() => 'test component');
|
|
68
54
|
|
|
69
|
-
|
|
55
|
+
await remoteLoader({ composition })(KEY);
|
|
70
56
|
|
|
71
|
-
|
|
57
|
+
expect(readVars('TestModule')).toEqual({ ENV_VAR_A: 'Composed value for A' });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should deliver the vars before the remote is given a chance to evaluate', async () => {
|
|
61
|
+
const order: string[] = [];
|
|
62
|
+
jest.spyOn(loadAppshellComponent, 'default').mockImplementation(async () => {
|
|
63
|
+
order.push(hasVars('TestModule') ? 'vars delivered' : 'vars missing');
|
|
64
|
+
|
|
65
|
+
return () => 'test component';
|
|
66
|
+
});
|
|
72
67
|
|
|
73
|
-
|
|
74
|
-
|
|
68
|
+
await remoteLoader({ composition })(KEY);
|
|
69
|
+
|
|
70
|
+
expect(order).toEqual(['vars delivered']);
|
|
75
71
|
});
|
|
76
72
|
|
|
77
|
-
it('should
|
|
78
|
-
|
|
79
|
-
jest.spyOn(loadAppshellComponent, 'default').mockResolvedValue(ExpectedComponent);
|
|
73
|
+
it('should leave nothing on the window', async () => {
|
|
74
|
+
jest.spyOn(loadAppshellComponent, 'default').mockResolvedValue(() => 'test component');
|
|
80
75
|
|
|
81
|
-
|
|
76
|
+
await remoteLoader({ composition })(KEY);
|
|
82
77
|
|
|
83
|
-
|
|
84
|
-
/Remote resource not found in registry/i,
|
|
85
|
-
);
|
|
78
|
+
expect(Object.keys(window).filter((key) => key.startsWith('__appshell_vars__'))).toEqual([]);
|
|
86
79
|
});
|
|
87
80
|
|
|
88
|
-
it('should
|
|
89
|
-
|
|
81
|
+
it('should fall back to the registry for a remote activated after the page was served', async () => {
|
|
82
|
+
const added: AppshellRemote = { ...remote, scope: 'LateModule', module: './Late' };
|
|
83
|
+
jest.spyOn(loadAppshellComponent, 'default').mockResolvedValue(() => 'late component');
|
|
84
|
+
fetch.mockResponseOnce(JSON.stringify(added));
|
|
90
85
|
|
|
91
|
-
const
|
|
86
|
+
const [, actualRemote] = await remoteLoader({ composition })('LateModule/Late');
|
|
92
87
|
|
|
93
|
-
|
|
94
|
-
/
|
|
88
|
+
expect(fetch).toHaveBeenCalledWith(
|
|
89
|
+
'/v1/applications/acme/dev/remotes/LateModule/Late',
|
|
90
|
+
expect.anything(),
|
|
95
91
|
);
|
|
92
|
+
expect(actualRemote).toEqual(added);
|
|
96
93
|
});
|
|
97
94
|
|
|
98
|
-
it('should
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
fetch.resetMocks();
|
|
95
|
+
it('should throw when neither the composition nor the registry has the remote', async () => {
|
|
96
|
+
jest.spyOn(loadAppshellComponent, 'default').mockResolvedValue(() => 'test component');
|
|
97
|
+
fetch.mockResponseOnce('', { status: 404 });
|
|
102
98
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
},
|
|
111
|
-
});
|
|
112
|
-
const [ActualComponent, actualManifest] = await loadRemote('TestModule/TestComponent');
|
|
99
|
+
await expect(remoteLoader({ composition })('TestModule/DoesNotExist')).rejects.toThrow(
|
|
100
|
+
/Remote resource not found in registry/i,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should throw when there is no composition at all', async () => {
|
|
105
|
+
jest.spyOn(loadAppshellComponent, 'default').mockResolvedValue(() => 'test component');
|
|
113
106
|
|
|
107
|
+
await expect(remoteLoader()(KEY)).rejects.toThrow(/Remote resource not found in registry/i);
|
|
114
108
|
expect(fetch).not.toHaveBeenCalled();
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should throw if loading the Appshell component fails', async () => {
|
|
112
|
+
jest.spyOn(loadAppshellComponent, 'default').mockRejectedValue(new Error('failed'));
|
|
113
|
+
|
|
114
|
+
await expect(remoteLoader({ composition })(KEY)).rejects.toThrow(/Failed to load component/i);
|
|
119
115
|
});
|
|
120
116
|
});
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
/** @jest-environment jsdom */
|
|
2
|
-
import { AppshellComposition,
|
|
2
|
+
import { AppshellComposition, AppshellManifest } from '@appshell/config';
|
|
3
3
|
import fetch, { enableFetchMocks } from 'jest-fetch-mock';
|
|
4
|
-
import {
|
|
5
|
-
chainResolvers,
|
|
6
|
-
inlineResolver,
|
|
7
|
-
legacyManifestResolver,
|
|
8
|
-
registryResolver,
|
|
9
|
-
} from '../src/resolvers';
|
|
4
|
+
import { chainResolvers, inlineResolver, registryResolver } from '../src/resolvers';
|
|
10
5
|
|
|
11
6
|
enableFetchMocks();
|
|
12
7
|
|
|
@@ -25,17 +20,17 @@ const manifest: AppshellManifest = {
|
|
|
25
20
|
},
|
|
26
21
|
},
|
|
27
22
|
modules: { TestModule: { name: 'TestModule' } },
|
|
28
|
-
|
|
23
|
+
vars: { TestModule: { ENV_VAR_A: 'Original value for A' } },
|
|
29
24
|
};
|
|
30
25
|
|
|
31
|
-
const config: AppshellGlobalConfig = { index: { [KEY]: MANIFEST_URL } };
|
|
32
|
-
|
|
33
26
|
const composition: AppshellComposition = {
|
|
34
|
-
|
|
27
|
+
applicationId: 'acme/dev',
|
|
35
28
|
revision: 7,
|
|
36
|
-
|
|
29
|
+
root: KEY,
|
|
30
|
+
rootProps: {},
|
|
31
|
+
index: { [KEY]: MANIFEST_URL },
|
|
37
32
|
remotes: manifest.remotes,
|
|
38
|
-
|
|
33
|
+
vars: manifest.vars,
|
|
39
34
|
};
|
|
40
35
|
|
|
41
36
|
beforeEach(() => {
|
|
@@ -48,47 +43,13 @@ describe('inlineResolver', () => {
|
|
|
48
43
|
|
|
49
44
|
expect(fetch).not.toHaveBeenCalled();
|
|
50
45
|
expect(resolution?.remote).toEqual(manifest.remotes[KEY]);
|
|
51
|
-
expect(resolution?.
|
|
46
|
+
expect(resolution?.vars).toEqual({ ENV_VAR_A: 'Original value for A' });
|
|
52
47
|
});
|
|
53
48
|
|
|
54
49
|
it('yields to the next resolver when the key is absent', async () => {
|
|
55
50
|
await expect(inlineResolver(composition)('Other/Key')).resolves.toBeUndefined();
|
|
56
51
|
await expect(inlineResolver(undefined)(KEY)).resolves.toBeUndefined();
|
|
57
52
|
});
|
|
58
|
-
|
|
59
|
-
// Locks the claim that the synthesized manifest is a drop-in for `ManifestProvider`.
|
|
60
|
-
it('synthesizes a manifest matching the legacy one apart from modules', async () => {
|
|
61
|
-
fetch.mockResponseOnce(JSON.stringify(manifest));
|
|
62
|
-
|
|
63
|
-
const legacy = await legacyManifestResolver(config)(KEY);
|
|
64
|
-
const inline = await inlineResolver(composition)(KEY);
|
|
65
|
-
|
|
66
|
-
expect(inline?.manifest).toEqual({ ...legacy?.manifest, modules: {} });
|
|
67
|
-
expect(inline?.environment).toEqual(legacy?.environment);
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
describe('legacyManifestResolver', () => {
|
|
72
|
-
it('merges environment overrides client side, because no server did', async () => {
|
|
73
|
-
fetch.mockResponseOnce(JSON.stringify(manifest));
|
|
74
|
-
|
|
75
|
-
const resolution = await legacyManifestResolver({
|
|
76
|
-
...config,
|
|
77
|
-
overrides: { environment: { TestModule: { ENV_VAR_A: 'New value for A' } } },
|
|
78
|
-
})(KEY);
|
|
79
|
-
|
|
80
|
-
expect(resolution?.environment).toEqual({ ENV_VAR_A: 'New value for A' });
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it('fetches each manifest only once', async () => {
|
|
84
|
-
fetch.mockResponse(JSON.stringify(manifest));
|
|
85
|
-
const resolve = legacyManifestResolver(config);
|
|
86
|
-
|
|
87
|
-
await resolve(KEY);
|
|
88
|
-
await resolve(KEY);
|
|
89
|
-
|
|
90
|
-
expect(fetch).toHaveBeenCalledTimes(1);
|
|
91
|
-
});
|
|
92
53
|
});
|
|
93
54
|
|
|
94
55
|
describe('registryResolver', () => {
|
|
@@ -99,7 +60,7 @@ describe('registryResolver', () => {
|
|
|
99
60
|
const resolution = await registryResolver(composition, 'http://registry')('LateModule/Late');
|
|
100
61
|
|
|
101
62
|
expect(fetch).toHaveBeenCalledWith(
|
|
102
|
-
'http://registry/v1/
|
|
63
|
+
'http://registry/v1/applications/acme/dev/remotes/LateModule/Late',
|
|
103
64
|
expect.anything(),
|
|
104
65
|
);
|
|
105
66
|
expect(resolution?.remote).toEqual(added);
|