@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +19 -21
  3. package/__tests__/remoteLoader.spec.ts +75 -79
  4. package/__tests__/resolvers.spec.ts +10 -49
  5. package/dist/main.js +1 -1
  6. package/dist/types/config/src/context.d.ts +3 -3
  7. package/dist/types/config/src/credentials.d.ts +0 -1
  8. package/dist/types/config/src/credentials.d.ts.map +1 -1
  9. package/dist/types/config/src/index.d.ts +2 -6
  10. package/dist/types/config/src/index.d.ts.map +1 -1
  11. package/dist/types/config/src/publish.d.ts +3 -3
  12. package/dist/types/config/src/publish.d.ts.map +1 -1
  13. package/dist/types/config/src/types.d.ts +9 -14
  14. package/dist/types/config/src/types.d.ts.map +1 -1
  15. package/dist/types/config/src/utils/index.d.ts +0 -3
  16. package/dist/types/config/src/utils/index.d.ts.map +1 -1
  17. package/dist/types/config/src/validators/index.d.ts +0 -2
  18. package/dist/types/config/src/validators/index.d.ts.map +1 -1
  19. package/dist/types/loader/src/remoteLoader.d.ts +2 -3
  20. package/dist/types/loader/src/remoteLoader.d.ts.map +1 -1
  21. package/dist/types/loader/src/resolvers.d.ts +4 -11
  22. package/dist/types/loader/src/resolvers.d.ts.map +1 -1
  23. package/dist/types/runtime/src/errors.d.ts +26 -0
  24. package/dist/types/runtime/src/errors.d.ts.map +1 -0
  25. package/dist/types/runtime/src/index.d.ts +4 -0
  26. package/dist/types/runtime/src/index.d.ts.map +1 -0
  27. package/dist/types/runtime/src/store.d.ts +26 -0
  28. package/dist/types/runtime/src/store.d.ts.map +1 -0
  29. package/dist/types/runtime/src/types.d.ts +10 -0
  30. package/dist/types/runtime/src/types.d.ts.map +1 -0
  31. package/package.json +7 -3
  32. package/src/remoteLoader.ts +11 -18
  33. package/src/resolvers.ts +7 -64
  34. package/webpack.config.js +5 -0
  35. package/dist/types/config/src/deregister.d.ts +0 -3
  36. package/dist/types/config/src/deregister.d.ts.map +0 -1
  37. package/dist/types/config/src/generate.env.d.ts +0 -3
  38. package/dist/types/config/src/generate.env.d.ts.map +0 -1
  39. package/dist/types/config/src/generate.global-config.d.ts +0 -10
  40. package/dist/types/config/src/generate.global-config.d.ts.map +0 -1
  41. package/dist/types/config/src/register.d.ts +0 -4
  42. package/dist/types/config/src/register.d.ts.map +0 -1
  43. package/dist/types/config/src/utils/isValidUrl.d.ts +0 -3
  44. package/dist/types/config/src/utils/isValidUrl.d.ts.map +0 -1
  45. package/dist/types/config/src/utils/loadJson.d.ts +0 -10
  46. package/dist/types/config/src/utils/loadJson.d.ts.map +0 -1
  47. package/dist/types/config/src/utils/merge.d.ts +0 -4
  48. package/dist/types/config/src/utils/merge.d.ts.map +0 -1
  49. package/dist/types/config/src/validators/AppshellGlobalConfigValidator.d.ts +0 -4
  50. package/dist/types/config/src/validators/AppshellGlobalConfigValidator.d.ts.map +0 -1
  51. package/dist/types/config/src/validators/AppshellManifestValidator.d.ts +0 -4
  52. 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 --save-dev
23
+ npm install @appshell/loader
24
24
  ```
25
25
 
26
26
  or
27
27
 
28
28
  ```console
29
- yarn add -D @appshell/loader
29
+ yarn add @appshell/loader
30
30
  ```
31
31
 
32
32
  or
33
33
 
34
34
  ```console
35
- pnpm add -D @appshell/loader
35
+ pnpm add @appshell/loader
36
36
  ```
37
37
 
38
- The default export from this package is the loader function. It is given the global appshell configuration, and returns an async function that can be called to dynamically load Appshell components.
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(config);
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 global appshell configuration come from?**
52
+ **Where does the composition come from?**
51
53
 
52
- > See [@appshell/cli](https://www.npmjs.com/package/@appshell/cli)
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 | Source | When it answers |
63
- | ------------------------ | ----------------------------------------------------- | -------------------------------------------------------------------------- |
64
- | `inlineResolver` | `window.__appshell_config__` | The registry served the page and inlined the composition. No network call. |
65
- | `registryResolver` | `GET /v1/environments/:id/remotes/:key` | The remote was activated after this page was served. |
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
- Environment variables reach `window.__appshell_env__<scope>` already merged with the
69
- environment's overrides when a composition is present, because the registry does that
70
- merge server side. The legacy resolver still merges in the browser, since in that mode
71
- no server has.
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(config, { resolver: myResolver });
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 { AppshellGlobalConfig, AppshellManifest } from '@appshell/config';
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 manifest: AppshellManifest = {
13
- remotes: {
14
- 'TestModule/TestComponent': {
15
- id: 'test-component',
16
- scope: 'TestModule',
17
- module: './TestComponent',
18
- manifestUrl: 'http://test.com/appshell.manifest.json',
19
- remoteEntryUrl: 'http://test.com/remoteEntry.js',
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
- overrides: {
36
- environment: {
37
- TestModule: {
38
- ENV_VAR_A: 'New value for A',
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.mockIf('http://test.com/appshell.manifest.json', () =>
46
- Promise.resolve({ ok: true, body: JSON.stringify(manifest) }),
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 Appshell component if it is found in the registry', async () => {
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(config);
57
- const [ActualComponent, actualManifest] = await loadRemote('TestModule/TestComponent');
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(actualManifest).toEqual(manifest);
49
+ expect(actualRemote).toEqual(remote);
61
50
  });
62
51
 
63
- it('should apply overridden environment values', async () => {
64
- const ExpectedComponent = () => 'test component';
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
- const loadRemote = remoteLoader(config);
55
+ await remoteLoader({ composition })(KEY);
70
56
 
71
- await loadRemote('TestModule/TestComponent');
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
- // eslint-disable-next-line no-underscore-dangle
74
- expect(window.__appshell_env__TestModule).toMatchObject({ ENV_VAR_A: 'New value for A' });
68
+ await remoteLoader({ composition })(KEY);
69
+
70
+ expect(order).toEqual(['vars delivered']);
75
71
  });
76
72
 
77
- it('should throw if remote key is not found in the registry', async () => {
78
- const ExpectedComponent = () => 'test component';
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
- const loadRemote = remoteLoader(config);
76
+ await remoteLoader({ composition })(KEY);
82
77
 
83
- await expect(loadRemote('TestModule/DoesNotExist')).rejects.toThrow(
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 throw if load Appshell component fails', async () => {
89
- jest.spyOn(loadAppshellComponent, 'default').mockRejectedValue(new Error('failed'));
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 loadRemote = remoteLoader(config);
86
+ const [, actualRemote] = await remoteLoader({ composition })('LateModule/Late');
92
87
 
93
- await expect(loadRemote('TestModule/TestComponent')).rejects.toThrow(
94
- /Failed to load component/i,
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 load from an inlined composition without fetching a manifest', async () => {
99
- const ExpectedComponent = () => 'test component';
100
- jest.spyOn(loadAppshellComponent, 'default').mockResolvedValue(ExpectedComponent);
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
- const loadRemote = remoteLoader(config, {
104
- composition: {
105
- environmentId: 'acme/dev',
106
- revision: 1,
107
- index: config.index,
108
- remotes: manifest.remotes,
109
- environment: { TestModule: { ENV_VAR_A: 'Composed value for A' } },
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
- expect(ActualComponent).toEqual(ExpectedComponent);
116
- expect(actualManifest?.modules).toEqual({});
117
- // eslint-disable-next-line no-underscore-dangle
118
- expect(window.__appshell_env__TestModule).toEqual({ ENV_VAR_A: 'Composed value for A' });
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, AppshellGlobalConfig, AppshellManifest } from '@appshell/config';
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
- environment: { TestModule: { ENV_VAR_A: 'Original value for A' } },
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
- environmentId: 'acme/dev',
27
+ applicationId: 'acme/dev',
35
28
  revision: 7,
36
- index: config.index,
29
+ root: KEY,
30
+ rootProps: {},
31
+ index: { [KEY]: MANIFEST_URL },
37
32
  remotes: manifest.remotes,
38
- environment: manifest.environment,
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?.environment).toEqual({ ENV_VAR_A: 'Original value for A' });
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/environments/acme/dev/remotes/LateModule/Late',
63
+ 'http://registry/v1/applications/acme/dev/remotes/LateModule/Late',
103
64
  expect.anything(),
104
65
  );
105
66
  expect(resolution?.remote).toEqual(added);