@rmc-toolkit/core 0.1.0 → 0.2.3
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/README.md +64 -0
- package/dist/externals.d.ts.map +1 -1
- package/dist/externals.js +3 -2
- package/dist/host-observable.d.ts +23 -0
- package/dist/host-observable.d.ts.map +1 -0
- package/dist/host-observable.js +37 -0
- package/dist/host-observable.test.d.ts +2 -0
- package/dist/host-observable.test.d.ts.map +1 -0
- package/dist/host-observable.test.js +177 -0
- package/dist/host.d.ts +1 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +4 -0
- package/dist/host.test.js +62 -0
- package/dist/import-map.d.ts +8 -0
- package/dist/import-map.d.ts.map +1 -1
- package/dist/import-map.js +85 -12
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.test.js +229 -21
- package/dist/manifest.d.ts +7 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +23 -0
- package/dist/types.d.ts +17 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +35 -0
- package/package.json +6 -1
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# @rmc-toolkit/core
|
|
2
|
+
|
|
3
|
+
Framework-agnostic core of [Runtime Module Composition](https://runtime-module-composition.dev): manifest-driven import map generation, route resolution, dynamic module loading, and a runtime host lifecycle that the Vite/React/Vue adapters build on. Has no dependency on any bundler or UI framework.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @rmc-toolkit/core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick example
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// runtime-composition.manifest.ts
|
|
15
|
+
import { defineManifest } from "@rmc-toolkit/core";
|
|
16
|
+
|
|
17
|
+
export const manifest = defineManifest({
|
|
18
|
+
namespace: "@acme",
|
|
19
|
+
assetsOrigin: "https://assets.example.com",
|
|
20
|
+
externalDepsOrigin: "https://esm.sh",
|
|
21
|
+
externalDeps: [
|
|
22
|
+
// The shared React singleton every other entry's peerDeps pins to by
|
|
23
|
+
// name. Has no peer deps of its own, so it opts out of defaultPeerDeps
|
|
24
|
+
// rather than self-referencing itself.
|
|
25
|
+
{ name: "react", version: "19.2.7", peerDeps: false },
|
|
26
|
+
// Needs the same React instance — matches defaultPeerDeps below, so no
|
|
27
|
+
// peerDeps field needed here.
|
|
28
|
+
{ name: "react-dom/client", version: "19.2.7" },
|
|
29
|
+
{
|
|
30
|
+
name: "@radix-ui/themes",
|
|
31
|
+
version: "3.3.0",
|
|
32
|
+
// Needs both react and react-dom, which differs from defaultPeerDeps
|
|
33
|
+
// — their versions are looked up from the entries above at
|
|
34
|
+
// generation time, never hand-typed here.
|
|
35
|
+
peerDeps: ["react", "react-dom"],
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
// Applied automatically to every externalDeps entry that doesn't set its
|
|
39
|
+
// own peerDeps (react-dom/client, above).
|
|
40
|
+
defaultPeerDeps: ["react"],
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`createRuntimeHost` resolves a path to a slice's module specifier via the manifest, imports it, and mounts/unmounts it — with built-in error recovery and protection against rapid-navigation races. It only reacts to a path string; wire it to whatever produces navigation events (a `popstate` listener, as above, or a router's own navigation callback).
|
|
45
|
+
|
|
46
|
+
If you're building a React or Vue host, use [`@rmc-toolkit/react`](https://www.npmjs.com/package/@rmc-toolkit/react) or [`@rmc-toolkit/vue`](https://www.npmjs.com/package/@rmc-toolkit/vue) instead — both wrap this same lifecycle in a framework-idiomatic hook/composable. Use this package directly for a host with no framework, or one without a dedicated adapter yet.
|
|
47
|
+
|
|
48
|
+
## What's in here
|
|
49
|
+
|
|
50
|
+
- `defineManifest`, `validateManifest` — declare and lint the manifest that drives everything else.
|
|
51
|
+
- `createImportMap`, `createImportMapBootstrapScript`, `resolveImportMapSpecifier` — generate the browser import map from a manifest.
|
|
52
|
+
- `resolveRoute` — resolve a URL path to a slice's module specifier.
|
|
53
|
+
- `createExternalMatcher`, `listExternalSpecifiers` — determine which specifiers a bundler should leave external (used by `@rmc-toolkit/vite`).
|
|
54
|
+
- `importModule`, `unwrapDefault` — the dynamic-import primitive slices/hosts load through.
|
|
55
|
+
- `createRuntimeHost`, `createRuntimeHostObservable`, `notifyInternalNavigation` — the resolve/import/mount/unmount lifecycle, plain and as a subscribable observable.
|
|
56
|
+
|
|
57
|
+
Full signatures and behavior for every export: [API Reference](https://runtime-module-composition.dev/api-reference/#core-rmc-toolkitcore).
|
|
58
|
+
|
|
59
|
+
## Documentation
|
|
60
|
+
|
|
61
|
+
- [Getting Started](https://runtime-module-composition.dev/getting-started/) — install and wire up a host + slice end to end
|
|
62
|
+
- [API Reference](https://runtime-module-composition.dev/api-reference/#core-rmc-toolkitcore)
|
|
63
|
+
- [Technical Implementation](https://runtime-module-composition.dev/technical-implementation/) — the architecture and failure modes behind the pattern
|
|
64
|
+
- [Multi-Framework Demo](https://runtime-module-composition.dev/demo/) — a full, runnable reference implementation
|
package/dist/externals.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"externals.d.ts","sourceRoot":"","sources":["../src/externals.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,0BAA0B,EAE3B,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"externals.d.ts","sourceRoot":"","sources":["../src/externals.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,0BAA0B,EAE3B,MAAM,YAAY,CAAC;AAMpB,eAAO,MAAM,sBAAsB,GACjC,UAAU,0BAA0B,KACnC,MAAM,EAiBR,CAAC;AAEF,eAAO,MAAM,qBAAqB,GAChC,UAAU,0BAA0B,KACnC,CAAC,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAW9B,CAAC"}
|
package/dist/externals.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { trimTrailingSlash } from "./manifest.js";
|
|
1
2
|
const isSharedExternal = (config) => typeof config === "string" ? true : config.external !== false;
|
|
2
3
|
export const listExternalSpecifiers = (manifest) => {
|
|
3
4
|
const prefixes = [
|
|
4
|
-
`${manifest.namespace}/`,
|
|
5
|
+
`${trimTrailingSlash(manifest.namespace)}/`,
|
|
5
6
|
manifest.externalDepsOrigin
|
|
6
7
|
? (manifest.externalDepsPrefix ?? "@esm.sh/")
|
|
7
8
|
: null,
|
|
@@ -16,7 +17,7 @@ export const listExternalSpecifiers = (manifest) => {
|
|
|
16
17
|
};
|
|
17
18
|
export const createExternalMatcher = (manifest) => {
|
|
18
19
|
const exactSpecifiers = new Set(listExternalSpecifiers(manifest));
|
|
19
|
-
const namespacePrefix = `${manifest.namespace}/`;
|
|
20
|
+
const namespacePrefix = `${trimTrailingSlash(manifest.namespace)}/`;
|
|
20
21
|
const externalDepsPrefix = manifest.externalDepsOrigin
|
|
21
22
|
? (manifest.externalDepsPrefix ?? "@esm.sh/")
|
|
22
23
|
: null;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RuntimeHostOptions } from "./host.js";
|
|
2
|
+
export type RuntimeHostStatus = {
|
|
3
|
+
type: "idle";
|
|
4
|
+
} | {
|
|
5
|
+
type: "loading";
|
|
6
|
+
path: string;
|
|
7
|
+
} | {
|
|
8
|
+
type: "ready";
|
|
9
|
+
path: string;
|
|
10
|
+
} | {
|
|
11
|
+
type: "error";
|
|
12
|
+
path: string;
|
|
13
|
+
error: unknown;
|
|
14
|
+
};
|
|
15
|
+
export type RuntimeHostObservableOptions = Pick<RuntimeHostOptions, "manifest" | "target" | "importer">;
|
|
16
|
+
export type RuntimeHostObservable = {
|
|
17
|
+
next(path: string): void;
|
|
18
|
+
subscribe(observer: (status: RuntimeHostStatus) => void): () => void;
|
|
19
|
+
getSnapshot(): RuntimeHostStatus;
|
|
20
|
+
destroy(): Promise<void>;
|
|
21
|
+
};
|
|
22
|
+
export declare const createRuntimeHostObservable: (options: RuntimeHostObservableOptions) => RuntimeHostObservable;
|
|
23
|
+
//# sourceMappingURL=host-observable.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"host-observable.d.ts","sourceRoot":"","sources":["../src/host-observable.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAEpD,MAAM,MAAM,iBAAiB,GACzB;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAChB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACjC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC/B;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAAC;AAEpD,MAAM,MAAM,4BAA4B,GAAG,IAAI,CAC7C,kBAAkB,EAClB,UAAU,GAAG,QAAQ,GAAG,UAAU,CACnC,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,iBAAiB,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IACrE,WAAW,IAAI,iBAAiB,CAAC;IACjC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B,CAAC;AAEF,eAAO,MAAM,2BAA2B,GACtC,SAAS,4BAA4B,KACpC,qBAsCF,CAAC"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createRuntimeHost } from "./host.js";
|
|
2
|
+
export const createRuntimeHostObservable = (options) => {
|
|
3
|
+
let currentStatus = { type: "idle" };
|
|
4
|
+
const observers = new Set();
|
|
5
|
+
const setStatus = (status) => {
|
|
6
|
+
currentStatus = status;
|
|
7
|
+
for (const observer of observers) {
|
|
8
|
+
observer(status);
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
const host = createRuntimeHost({
|
|
12
|
+
...options,
|
|
13
|
+
onLoading: (path) => setStatus({ type: "loading", path }),
|
|
14
|
+
onReady: (path) => setStatus({ type: "ready", path }),
|
|
15
|
+
onError: (error, path) => setStatus({ type: "error", path, error }),
|
|
16
|
+
});
|
|
17
|
+
return {
|
|
18
|
+
next(path) {
|
|
19
|
+
// Fire-and-forget is safe here: resolveAndMount() never throws or
|
|
20
|
+
// rejects — it catches everything internally and reports failures via
|
|
21
|
+
// onError, which setStatus() above turns into an "error" status.
|
|
22
|
+
void host.resolveAndMount(path);
|
|
23
|
+
},
|
|
24
|
+
subscribe(observer) {
|
|
25
|
+
observers.add(observer);
|
|
26
|
+
return () => {
|
|
27
|
+
observers.delete(observer);
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
getSnapshot() {
|
|
31
|
+
return currentStatus;
|
|
32
|
+
},
|
|
33
|
+
destroy() {
|
|
34
|
+
return host.destroy();
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"host-observable.test.d.ts","sourceRoot":"","sources":["../src/host-observable.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, test, vi } from "vitest";
|
|
3
|
+
import { defineManifest } from "./manifest.js";
|
|
4
|
+
import { createRuntimeHostObservable, } from "./host-observable.js";
|
|
5
|
+
const manifest = defineManifest({
|
|
6
|
+
namespace: "@acme",
|
|
7
|
+
assetsOrigin: "https://assets.example.com",
|
|
8
|
+
sliceOverrides: {
|
|
9
|
+
search: { route: "/search/*", specifier: "@acme/search/index.mjs", entry: "index.mjs" },
|
|
10
|
+
cart: { route: "/cart/*", specifier: "@acme/cart/index.mjs", entry: "index.mjs" },
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
const createMockModule = () => ({
|
|
14
|
+
mount: vi.fn(async () => { }),
|
|
15
|
+
unmount: vi.fn(async () => { }),
|
|
16
|
+
});
|
|
17
|
+
describe("createRuntimeHostObservable", () => {
|
|
18
|
+
test("starts with an idle status", () => {
|
|
19
|
+
const target = document.createElement("div");
|
|
20
|
+
const observable = createRuntimeHostObservable({ manifest, target });
|
|
21
|
+
expect(observable.getSnapshot()).toEqual({ type: "idle" });
|
|
22
|
+
});
|
|
23
|
+
test("transitions loading -> ready on a successful next() call", async () => {
|
|
24
|
+
const searchModule = createMockModule();
|
|
25
|
+
const importer = vi.fn(async () => ({ default: searchModule }));
|
|
26
|
+
const target = document.createElement("div");
|
|
27
|
+
const statuses = [];
|
|
28
|
+
const observable = createRuntimeHostObservable({ manifest, target, importer });
|
|
29
|
+
observable.subscribe((status) => statuses.push(status));
|
|
30
|
+
observable.next("/search");
|
|
31
|
+
await vi.waitFor(() => {
|
|
32
|
+
expect(observable.getSnapshot()).toEqual({ type: "ready", path: "/search" });
|
|
33
|
+
});
|
|
34
|
+
expect(statuses).toEqual([
|
|
35
|
+
{ type: "loading", path: "/search" },
|
|
36
|
+
{ type: "ready", path: "/search" },
|
|
37
|
+
]);
|
|
38
|
+
expect(searchModule.mount).toHaveBeenCalledTimes(1);
|
|
39
|
+
});
|
|
40
|
+
test("transitions to error when no route matches", async () => {
|
|
41
|
+
const target = document.createElement("div");
|
|
42
|
+
const importer = vi.fn();
|
|
43
|
+
const observable = createRuntimeHostObservable({ manifest, target, importer });
|
|
44
|
+
observable.next("/");
|
|
45
|
+
await vi.waitFor(() => {
|
|
46
|
+
expect(observable.getSnapshot().type).toBe("error");
|
|
47
|
+
});
|
|
48
|
+
const status = observable.getSnapshot();
|
|
49
|
+
if (status.type !== "error")
|
|
50
|
+
throw new Error("expected error status");
|
|
51
|
+
expect(status.path).toBe("/");
|
|
52
|
+
expect(status.error).toBeInstanceOf(Error);
|
|
53
|
+
});
|
|
54
|
+
test("transitions to error when the importer rejects", async () => {
|
|
55
|
+
const target = document.createElement("div");
|
|
56
|
+
const importer = vi.fn(async () => {
|
|
57
|
+
throw new Error("network failure");
|
|
58
|
+
});
|
|
59
|
+
const observable = createRuntimeHostObservable({ manifest, target, importer });
|
|
60
|
+
observable.next("/search");
|
|
61
|
+
await vi.waitFor(() => {
|
|
62
|
+
expect(observable.getSnapshot().type).toBe("error");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
test("notifies multiple independent subscribers identically", async () => {
|
|
66
|
+
const searchModule = createMockModule();
|
|
67
|
+
const importer = vi.fn(async () => ({ default: searchModule }));
|
|
68
|
+
const target = document.createElement("div");
|
|
69
|
+
const first = [];
|
|
70
|
+
const second = [];
|
|
71
|
+
const observable = createRuntimeHostObservable({ manifest, target, importer });
|
|
72
|
+
observable.subscribe((status) => first.push(status));
|
|
73
|
+
observable.subscribe((status) => second.push(status));
|
|
74
|
+
observable.next("/search");
|
|
75
|
+
await vi.waitFor(() => {
|
|
76
|
+
expect(observable.getSnapshot()).toEqual({ type: "ready", path: "/search" });
|
|
77
|
+
});
|
|
78
|
+
expect(first).toEqual(second);
|
|
79
|
+
});
|
|
80
|
+
test("unsubscribe stops further notifications", async () => {
|
|
81
|
+
const searchModule = createMockModule();
|
|
82
|
+
const cartModule = createMockModule();
|
|
83
|
+
const importer = vi.fn(async (specifier) => ({
|
|
84
|
+
default: specifier === "@acme/search/index.mjs" ? searchModule : cartModule,
|
|
85
|
+
}));
|
|
86
|
+
const target = document.createElement("div");
|
|
87
|
+
const statuses = [];
|
|
88
|
+
const observable = createRuntimeHostObservable({ manifest, target, importer });
|
|
89
|
+
const unsubscribe = observable.subscribe((status) => statuses.push(status));
|
|
90
|
+
observable.next("/search");
|
|
91
|
+
await vi.waitFor(() => {
|
|
92
|
+
expect(observable.getSnapshot()).toEqual({ type: "ready", path: "/search" });
|
|
93
|
+
});
|
|
94
|
+
unsubscribe();
|
|
95
|
+
statuses.length = 0;
|
|
96
|
+
observable.next("/cart");
|
|
97
|
+
await vi.waitFor(() => {
|
|
98
|
+
expect(observable.getSnapshot()).toEqual({ type: "ready", path: "/cart" });
|
|
99
|
+
});
|
|
100
|
+
expect(statuses).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
test("getSnapshot returns the same object reference until the next transition", async () => {
|
|
103
|
+
const searchModule = createMockModule();
|
|
104
|
+
const importer = vi.fn(async () => ({ default: searchModule }));
|
|
105
|
+
const target = document.createElement("div");
|
|
106
|
+
const observable = createRuntimeHostObservable({ manifest, target, importer });
|
|
107
|
+
observable.next("/search");
|
|
108
|
+
await vi.waitFor(() => {
|
|
109
|
+
expect(observable.getSnapshot()).toEqual({ type: "ready", path: "/search" });
|
|
110
|
+
});
|
|
111
|
+
const first = observable.getSnapshot();
|
|
112
|
+
const second = observable.getSnapshot();
|
|
113
|
+
expect(first).toBe(second);
|
|
114
|
+
});
|
|
115
|
+
test("navigating to the same specifier again does not change status", async () => {
|
|
116
|
+
const searchModule = createMockModule();
|
|
117
|
+
const importer = vi.fn(async () => ({ default: searchModule }));
|
|
118
|
+
const target = document.createElement("div");
|
|
119
|
+
const statuses = [];
|
|
120
|
+
const observable = createRuntimeHostObservable({ manifest, target, importer });
|
|
121
|
+
observable.next("/search");
|
|
122
|
+
await vi.waitFor(() => {
|
|
123
|
+
expect(observable.getSnapshot()).toEqual({ type: "ready", path: "/search" });
|
|
124
|
+
});
|
|
125
|
+
observable.subscribe((status) => statuses.push(status));
|
|
126
|
+
observable.next("/search/results");
|
|
127
|
+
expect(statuses).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
test("destroy() unmounts the current module", async () => {
|
|
130
|
+
const searchModule = createMockModule();
|
|
131
|
+
const importer = vi.fn(async () => ({ default: searchModule }));
|
|
132
|
+
const target = document.createElement("div");
|
|
133
|
+
const observable = createRuntimeHostObservable({ manifest, target, importer });
|
|
134
|
+
observable.next("/search");
|
|
135
|
+
await vi.waitFor(() => {
|
|
136
|
+
expect(observable.getSnapshot()).toEqual({ type: "ready", path: "/search" });
|
|
137
|
+
});
|
|
138
|
+
await observable.destroy();
|
|
139
|
+
expect(searchModule.unmount).toHaveBeenCalledTimes(1);
|
|
140
|
+
});
|
|
141
|
+
test("does not broadcast status from a stale call that loses the race", async () => {
|
|
142
|
+
const searchModule = createMockModule();
|
|
143
|
+
const cartModule = createMockModule();
|
|
144
|
+
const target = document.createElement("div");
|
|
145
|
+
const statuses = [];
|
|
146
|
+
let resolveSearchImport;
|
|
147
|
+
const searchImportPromise = new Promise((resolve) => {
|
|
148
|
+
resolveSearchImport = resolve;
|
|
149
|
+
});
|
|
150
|
+
const importer = vi.fn(async (specifier) => {
|
|
151
|
+
if (specifier === "@acme/search/index.mjs") {
|
|
152
|
+
return searchImportPromise;
|
|
153
|
+
}
|
|
154
|
+
return { default: cartModule };
|
|
155
|
+
});
|
|
156
|
+
const observable = createRuntimeHostObservable({ manifest, target, importer });
|
|
157
|
+
observable.subscribe((status) => statuses.push(status));
|
|
158
|
+
observable.next("/search");
|
|
159
|
+
observable.next("/cart");
|
|
160
|
+
await vi.waitFor(() => {
|
|
161
|
+
expect(observable.getSnapshot()).toEqual({ type: "ready", path: "/cart" });
|
|
162
|
+
});
|
|
163
|
+
resolveSearchImport({ default: searchModule });
|
|
164
|
+
// Give the resumed stale call a chance to (incorrectly) broadcast, if it were going to.
|
|
165
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
166
|
+
expect(observable.getSnapshot()).toEqual({ type: "ready", path: "/cart" });
|
|
167
|
+
// "/search" legitimately gets its own "loading" status (it was still the
|
|
168
|
+
// latest call at that synchronous instant, before "/cart" pre-empted it),
|
|
169
|
+
// but it must never reach "ready" or "error" once it has lost the race —
|
|
170
|
+
// only "/cart"'s own loading -> ready pair should follow.
|
|
171
|
+
expect(statuses).toEqual([
|
|
172
|
+
{ type: "loading", path: "/search" },
|
|
173
|
+
{ type: "loading", path: "/cart" },
|
|
174
|
+
{ type: "ready", path: "/cart" },
|
|
175
|
+
]);
|
|
176
|
+
});
|
|
177
|
+
});
|
package/dist/host.d.ts
CHANGED
package/dist/host.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"host.d.ts","sourceRoot":"","sources":["../src/host.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,eAAe,EACf,0BAA0B,EAE3B,MAAM,YAAY,CAAC;AAEpB,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,EAAE,0BAA0B,CAAC;IACrC,MAAM,EAAE,OAAO,CAAC;IAChB,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjD,QAAQ,CAAC,EAAE,eAAe,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B,CAAC;AAEF,eAAO,MAAM,iBAAiB,GAAI,SAAS,kBAAkB,KAAG,
|
|
1
|
+
{"version":3,"file":"host.d.ts","sourceRoot":"","sources":["../src/host.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,eAAe,EACf,0BAA0B,EAE3B,MAAM,YAAY,CAAC;AAEpB,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,EAAE,0BAA0B,CAAC;IACrC,MAAM,EAAE,OAAO,CAAC;IAChB,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjD,QAAQ,CAAC,EAAE,eAAe,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B,CAAC;AAEF,eAAO,MAAM,iBAAiB,GAAI,SAAS,kBAAkB,KAAG,WAgH/D,CAAC;AAEF,eAAO,MAAM,wBAAwB,GAAI,MAAM,MAAM,KAAG,IAGvD,CAAC"}
|
package/dist/host.js
CHANGED
|
@@ -3,6 +3,7 @@ import { resolveRoute } from "./routes.js";
|
|
|
3
3
|
export const createRuntimeHost = (options) => {
|
|
4
4
|
const { manifest, target, importer } = options;
|
|
5
5
|
const onLoading = options.onLoading ?? (() => { });
|
|
6
|
+
const onReady = options.onReady ?? (() => { });
|
|
6
7
|
// Reuse one generic message for both "no route matched" and "import/mount
|
|
7
8
|
// failed" — the default UI's job is "something's wrong", not a diagnostic
|
|
8
9
|
// surface; pass a custom onError to differentiate them.
|
|
@@ -72,6 +73,9 @@ export const createRuntimeHost = (options) => {
|
|
|
72
73
|
// (the earlier, easily-hit case of an older call clobbering a newer
|
|
73
74
|
// call's already-*settled* mount).
|
|
74
75
|
await runtimeModule.mount(target, { route: match, manifest });
|
|
76
|
+
if (token === latestToken) {
|
|
77
|
+
onReady(path);
|
|
78
|
+
}
|
|
75
79
|
}
|
|
76
80
|
catch (error) {
|
|
77
81
|
if (token === latestToken) {
|
package/dist/host.test.js
CHANGED
|
@@ -125,6 +125,68 @@ describe("createRuntimeHost", () => {
|
|
|
125
125
|
expect(onLoading).toHaveBeenCalledTimes(1);
|
|
126
126
|
expect(onLoading).toHaveBeenCalledWith("/search");
|
|
127
127
|
});
|
|
128
|
+
test("calls onReady with the path after a successful mount", async () => {
|
|
129
|
+
const onReady = vi.fn();
|
|
130
|
+
const searchModule = createMockModule();
|
|
131
|
+
const importer = vi.fn(async () => ({ default: searchModule }));
|
|
132
|
+
const target = document.createElement("div");
|
|
133
|
+
const host = createRuntimeHost({ manifest, target, importer, onReady });
|
|
134
|
+
await host.resolveAndMount("/search");
|
|
135
|
+
expect(onReady).toHaveBeenCalledTimes(1);
|
|
136
|
+
expect(onReady).toHaveBeenCalledWith("/search");
|
|
137
|
+
});
|
|
138
|
+
test("does not call onReady for a stale call that loses the race", async () => {
|
|
139
|
+
const onReady = vi.fn();
|
|
140
|
+
const searchModule = createMockModule();
|
|
141
|
+
const cartModule = createMockModule();
|
|
142
|
+
const target = document.createElement("div");
|
|
143
|
+
let resolveSearchImport;
|
|
144
|
+
const searchImportPromise = new Promise((resolve) => {
|
|
145
|
+
resolveSearchImport = resolve;
|
|
146
|
+
});
|
|
147
|
+
const importer = vi.fn(async (specifier) => {
|
|
148
|
+
if (specifier === "@acme/search/index.mjs") {
|
|
149
|
+
return searchImportPromise;
|
|
150
|
+
}
|
|
151
|
+
return { default: cartModule };
|
|
152
|
+
});
|
|
153
|
+
const host = createRuntimeHost({ manifest, target, importer, onReady });
|
|
154
|
+
const firstCall = host.resolveAndMount("/search");
|
|
155
|
+
const secondCall = host.resolveAndMount("/cart");
|
|
156
|
+
await secondCall;
|
|
157
|
+
resolveSearchImport({ default: searchModule });
|
|
158
|
+
await firstCall;
|
|
159
|
+
expect(onReady).toHaveBeenCalledTimes(1);
|
|
160
|
+
expect(onReady).toHaveBeenCalledWith("/cart");
|
|
161
|
+
});
|
|
162
|
+
test("does not call onReady for a call whose mount() finishes after a newer call has already taken over", async () => {
|
|
163
|
+
const onReady = vi.fn();
|
|
164
|
+
const target = document.createElement("div");
|
|
165
|
+
let resolveSearchMount;
|
|
166
|
+
const searchMountPromise = new Promise((resolve) => {
|
|
167
|
+
resolveSearchMount = resolve;
|
|
168
|
+
});
|
|
169
|
+
const searchModule = {
|
|
170
|
+
mount: vi.fn(() => searchMountPromise),
|
|
171
|
+
unmount: vi.fn(async () => { }),
|
|
172
|
+
};
|
|
173
|
+
const cartModule = createMockModule();
|
|
174
|
+
const importer = vi.fn(async (specifier) => ({
|
|
175
|
+
default: specifier === "@acme/search/index.mjs" ? searchModule : cartModule,
|
|
176
|
+
}));
|
|
177
|
+
const host = createRuntimeHost({ manifest, target, importer, onReady });
|
|
178
|
+
const firstCall = host.resolveAndMount("/search");
|
|
179
|
+
// Let the first call's import resolve and its mount() begin (and hang).
|
|
180
|
+
await vi.waitFor(() => {
|
|
181
|
+
expect(searchModule.mount).toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
const secondCall = host.resolveAndMount("/cart");
|
|
184
|
+
await secondCall;
|
|
185
|
+
resolveSearchMount();
|
|
186
|
+
await firstCall;
|
|
187
|
+
expect(onReady).toHaveBeenCalledTimes(1);
|
|
188
|
+
expect(onReady).toHaveBeenCalledWith("/cart");
|
|
189
|
+
});
|
|
128
190
|
test("calls onError (not the 'no slice matches' message) when unmounting during a no-match navigation itself throws", async () => {
|
|
129
191
|
const onError = vi.fn();
|
|
130
192
|
const target = document.createElement("div");
|
package/dist/import-map.d.ts
CHANGED
|
@@ -5,4 +5,12 @@ export type CreateImportMapOptions = {
|
|
|
5
5
|
};
|
|
6
6
|
export declare const createImportMap: (manifest: RuntimeCompositionManifest, options?: CreateImportMapOptions) => ImportMap;
|
|
7
7
|
export declare const createImportMapBootstrapScript: (manifest: RuntimeCompositionManifest, options?: CreateImportMapOptions) => string;
|
|
8
|
+
/**
|
|
9
|
+
* Resolves a specifier against an already-generated ImportMap the same way
|
|
10
|
+
* a browser resolves import maps: an exact key wins; otherwise the longest
|
|
11
|
+
* key ending in "/" that the specifier starts with (a prefix mapping),
|
|
12
|
+
* with the remainder appended to that prefix's target. Returns undefined
|
|
13
|
+
* if nothing matches.
|
|
14
|
+
*/
|
|
15
|
+
export declare const resolveImportMapSpecifier: (importMap: ImportMap, specifier: string) => string | undefined;
|
|
8
16
|
//# sourceMappingURL=import-map.d.ts.map
|
package/dist/import-map.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"import-map.d.ts","sourceRoot":"","sources":["../src/import-map.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"import-map.d.ts","sourceRoot":"","sources":["../src/import-map.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,SAAS,EACT,0BAA0B,EAC1B,kBAAkB,EAGnB,MAAM,YAAY,CAAC;AA4BpB,MAAM,MAAM,sBAAsB,GAAG;IACnC,WAAW,CAAC,EAAE,kBAAkB,CAAC;IACjC,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAsFF,eAAO,MAAM,eAAe,GAC1B,UAAU,0BAA0B,EACpC,UAAS,sBAA2B,KACnC,SAgDF,CAAC;AAIF,eAAO,MAAM,8BAA8B,GACzC,UAAU,0BAA0B,EACpC,UAAS,sBAA2B,KACnC,MAwCF,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,GACpC,WAAW,SAAS,EACpB,WAAW,MAAM,KAChB,MAAM,GAAG,SA4BX,CAAC"}
|
package/dist/import-map.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { joinUrl } from "./manifest.js";
|
|
1
|
+
import { joinUrl, splitPackageSpecifier } from "./manifest.js";
|
|
2
2
|
const resolveSharedDependencyUrl = (config, environment) => {
|
|
3
3
|
if (typeof config === "string") {
|
|
4
4
|
return config;
|
|
@@ -13,9 +13,6 @@ const resolveSliceUrl = (manifest, slice, environment) => {
|
|
|
13
13
|
return joinUrl(manifest.assetsOrigin, entry);
|
|
14
14
|
};
|
|
15
15
|
const ensurePrefix = (value) => value.endsWith("/") ? value : `${value}/`;
|
|
16
|
-
const buildDepsQuery = (peerDeps) => Object.entries(peerDeps)
|
|
17
|
-
.map(([name, version]) => `${name}@${version}`)
|
|
18
|
-
.join(",");
|
|
19
16
|
const applyDevFlag = (value, externalDepsOrigin) => {
|
|
20
17
|
if (!value.startsWith(externalDepsOrigin)) {
|
|
21
18
|
return value;
|
|
@@ -25,6 +22,51 @@ const applyDevFlag = (value, externalDepsOrigin) => {
|
|
|
25
22
|
const resolveAssetsOrigin = (manifest, environment) => manifest.environments?.[environment]?.assetsOrigin ?? manifest.assetsOrigin;
|
|
26
23
|
const resolveExternalDepsOrigin = (manifest, environment) => manifest.environments?.[environment]?.externalDepsOrigin ??
|
|
27
24
|
manifest.externalDepsOrigin;
|
|
25
|
+
/**
|
|
26
|
+
* One basePackage -> version lookup built from every externalDeps entry.
|
|
27
|
+
* If two entries share a basePackage but declare different versions, the
|
|
28
|
+
* FIRST-declared one wins here (used only for resolving other entries'
|
|
29
|
+
* peerDeps references) — each entry's own URL is still built from its own
|
|
30
|
+
* declared version regardless of what wins this lookup, so a genuine
|
|
31
|
+
* conflict stays visible in the generated map rather than being silently
|
|
32
|
+
* normalized away. See validateManifest for the diagnostic that surfaces
|
|
33
|
+
* this class of mistake.
|
|
34
|
+
*/
|
|
35
|
+
const buildVersionIndex = (entries) => {
|
|
36
|
+
const index = new Map();
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
const { basePackage } = splitPackageSpecifier(entry.name);
|
|
39
|
+
if (!index.has(basePackage)) {
|
|
40
|
+
index.set(basePackage, { version: entry.version, entryName: entry.name });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return index;
|
|
44
|
+
};
|
|
45
|
+
/** Inserts `@version` right after the base package, preserving any subpath:
|
|
46
|
+
* "react-dom/client" + "19.2.4" -> ".../react-dom@19.2.4/client". */
|
|
47
|
+
const buildVersionedUrl = (origin, name, version) => {
|
|
48
|
+
const { basePackage, subpath } = splitPackageSpecifier(name);
|
|
49
|
+
const versionedPath = subpath
|
|
50
|
+
? `${basePackage}@${version}/${subpath}`
|
|
51
|
+
: `${basePackage}@${version}`;
|
|
52
|
+
return joinUrl(origin, versionedPath);
|
|
53
|
+
};
|
|
54
|
+
const resolvePeerNames = (entry, defaultPeerDeps) => {
|
|
55
|
+
if (entry.peerDeps === false) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
return entry.peerDeps ?? defaultPeerDeps ?? [];
|
|
59
|
+
};
|
|
60
|
+
/** A peer name with no matching externalDeps entry is silently omitted —
|
|
61
|
+
* createImportMap never throws; validateManifest is where that mistake
|
|
62
|
+
* gets surfaced, at warning level. */
|
|
63
|
+
const buildDepsQuery = (peerNames, versionIndex) => peerNames
|
|
64
|
+
.map((peerName) => {
|
|
65
|
+
const resolved = versionIndex.get(peerName);
|
|
66
|
+
return resolved ? `${peerName}@${resolved.version}` : null;
|
|
67
|
+
})
|
|
68
|
+
.filter((value) => value !== null)
|
|
69
|
+
.join(",");
|
|
28
70
|
export const createImportMap = (manifest, options = {}) => {
|
|
29
71
|
const environment = options.environment ?? "production";
|
|
30
72
|
const imports = {};
|
|
@@ -34,14 +76,14 @@ export const createImportMap = (manifest, options = {}) => {
|
|
|
34
76
|
const externalDepsPrefix = ensurePrefix(manifest.externalDepsPrefix ?? "@esm.sh/");
|
|
35
77
|
if (externalDepsOrigin) {
|
|
36
78
|
imports[externalDepsPrefix] = ensurePrefix(externalDepsOrigin);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const specifier = `${externalDepsPrefix}${name}`;
|
|
41
|
-
const baseUrl =
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
79
|
+
const externalDeps = manifest.externalDeps ?? [];
|
|
80
|
+
const versionIndex = buildVersionIndex(externalDeps);
|
|
81
|
+
for (const entry of externalDeps) {
|
|
82
|
+
const specifier = `${externalDepsPrefix}${entry.name}`;
|
|
83
|
+
const baseUrl = buildVersionedUrl(externalDepsOrigin, entry.name, entry.version);
|
|
84
|
+
const peerNames = resolvePeerNames(entry, manifest.defaultPeerDeps);
|
|
85
|
+
const depsQuery = buildDepsQuery(peerNames, versionIndex);
|
|
86
|
+
imports[specifier] = depsQuery ? `${baseUrl}?deps=${depsQuery}` : baseUrl;
|
|
45
87
|
}
|
|
46
88
|
}
|
|
47
89
|
for (const [specifier, config] of Object.entries(manifest.exactImports ?? {})) {
|
|
@@ -104,3 +146,34 @@ export const createImportMapBootstrapScript = (manifest, options = {}) => {
|
|
|
104
146
|
})();
|
|
105
147
|
`;
|
|
106
148
|
};
|
|
149
|
+
/**
|
|
150
|
+
* Resolves a specifier against an already-generated ImportMap the same way
|
|
151
|
+
* a browser resolves import maps: an exact key wins; otherwise the longest
|
|
152
|
+
* key ending in "/" that the specifier starts with (a prefix mapping),
|
|
153
|
+
* with the remainder appended to that prefix's target. Returns undefined
|
|
154
|
+
* if nothing matches.
|
|
155
|
+
*/
|
|
156
|
+
export const resolveImportMapSpecifier = (importMap, specifier) => {
|
|
157
|
+
if (specifier in importMap.imports) {
|
|
158
|
+
return importMap.imports[specifier];
|
|
159
|
+
}
|
|
160
|
+
let bestPrefix;
|
|
161
|
+
for (const key of Object.keys(importMap.imports)) {
|
|
162
|
+
if (key.endsWith("/") &&
|
|
163
|
+
specifier.startsWith(key) &&
|
|
164
|
+
(bestPrefix === undefined || key.length > bestPrefix.length)) {
|
|
165
|
+
bestPrefix = key;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (bestPrefix === undefined) {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
// bestPrefix is always a real key of importMap.imports (it's derived from
|
|
172
|
+
// Object.keys(importMap.imports) above), so this lookup can't actually be
|
|
173
|
+
// undefined — but note this is a hand-proven invariant, not something the
|
|
174
|
+
// type checker enforces here: TypeScript's `+` operator doesn't propagate
|
|
175
|
+
// noUncheckedIndexedAccess's `| undefined` the way a direct type
|
|
176
|
+
// annotation would. If bestPrefix's derivation ever changes, re-verify
|
|
177
|
+
// this invariant still holds.
|
|
178
|
+
return importMap.imports[bestPrefix] + specifier.slice(bestPrefix.length);
|
|
179
|
+
};
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,WAAW,CAAC;AAC1B,cAAc,iBAAiB,CAAC;AAChC,cAAc,aAAa,CAAC;AAC5B,cAAc,eAAe,CAAC;AAC9B,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,sBAAsB,CAAC;AACrC,cAAc,WAAW,CAAC;AAC1B,cAAc,iBAAiB,CAAC;AAChC,cAAc,aAAa,CAAC;AAC5B,cAAc,eAAe,CAAC;AAC9B,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC"}
|
package/dist/index.js
CHANGED
package/dist/index.test.js
CHANGED
|
@@ -1,12 +1,36 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { describe, expect, test, vi } from "vitest";
|
|
3
|
-
import { createExternalMatcher, createImportMap, createImportMapBootstrapScript, createRuntimeHost, defineManifest, notifyInternalNavigation, resolveRoute, validateManifest, } from "./index.js";
|
|
3
|
+
import { createExternalMatcher, createImportMap, createImportMapBootstrapScript, createRuntimeHost, createRuntimeHostObservable, defineManifest, notifyInternalNavigation, resolveImportMapSpecifier, resolveRoute, splitPackageSpecifier, validateManifest, } from "./index.js";
|
|
4
4
|
const manifest = defineManifest({
|
|
5
5
|
namespace: "@acme",
|
|
6
6
|
assetsOrigin: "https://assets.example.com",
|
|
7
7
|
externalDepsOrigin: "https://esm.sh",
|
|
8
8
|
});
|
|
9
9
|
describe("runtime composition core", () => {
|
|
10
|
+
test("splitPackageSpecifier splits a bare package name with no subpath", () => {
|
|
11
|
+
expect(splitPackageSpecifier("react")).toEqual({
|
|
12
|
+
basePackage: "react",
|
|
13
|
+
subpath: null,
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
test("splitPackageSpecifier splits a package name with a subpath", () => {
|
|
17
|
+
expect(splitPackageSpecifier("react-dom/client")).toEqual({
|
|
18
|
+
basePackage: "react-dom",
|
|
19
|
+
subpath: "client",
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
test("splitPackageSpecifier splits a scoped package name with no subpath", () => {
|
|
23
|
+
expect(splitPackageSpecifier("@radix-ui/themes")).toEqual({
|
|
24
|
+
basePackage: "@radix-ui/themes",
|
|
25
|
+
subpath: null,
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
test("splitPackageSpecifier splits a scoped package name with a multi-segment subpath", () => {
|
|
29
|
+
expect(splitPackageSpecifier("@radix-ui/themes/some/path")).toEqual({
|
|
30
|
+
basePackage: "@radix-ui/themes",
|
|
31
|
+
subpath: "some/path",
|
|
32
|
+
});
|
|
33
|
+
});
|
|
10
34
|
test("creates an import map from namespace and external dependency origins", () => {
|
|
11
35
|
expect(createImportMap(manifest)).toEqual({
|
|
12
36
|
imports: {
|
|
@@ -49,29 +73,37 @@ describe("runtime composition core", () => {
|
|
|
49
73
|
},
|
|
50
74
|
});
|
|
51
75
|
});
|
|
52
|
-
test("generates externalDeps entries with defaultPeerDeps
|
|
76
|
+
test("generates externalDeps entries with defaultPeerDeps resolved from a sibling entry's version", () => {
|
|
53
77
|
expect(createImportMap({
|
|
54
78
|
...manifest,
|
|
55
|
-
externalDeps: [
|
|
56
|
-
|
|
79
|
+
externalDeps: [
|
|
80
|
+
{ name: "react", version: "19.2.4", peerDeps: false },
|
|
81
|
+
{ name: "zustand", version: "4.5.0" },
|
|
82
|
+
],
|
|
83
|
+
defaultPeerDeps: ["react"],
|
|
57
84
|
})).toEqual({
|
|
58
85
|
imports: {
|
|
59
86
|
"@acme/": "https://assets.example.com/",
|
|
60
87
|
"@esm.sh/": "https://esm.sh/",
|
|
61
|
-
"@esm.sh/
|
|
88
|
+
"@esm.sh/react": "https://esm.sh/react@19.2.4",
|
|
89
|
+
"@esm.sh/zustand": "https://esm.sh/zustand@4.5.0?deps=react@19.2.4",
|
|
62
90
|
},
|
|
63
91
|
});
|
|
64
92
|
});
|
|
65
93
|
test("supports externalDeps entries that opt out of peerDeps", () => {
|
|
66
94
|
expect(createImportMap({
|
|
67
95
|
...manifest,
|
|
68
|
-
externalDeps: [
|
|
69
|
-
|
|
96
|
+
externalDeps: [
|
|
97
|
+
{ name: "react", version: "19.2.4", peerDeps: false },
|
|
98
|
+
{ name: "date-fns", version: "3.6.0", peerDeps: false },
|
|
99
|
+
],
|
|
100
|
+
defaultPeerDeps: ["react"],
|
|
70
101
|
})).toEqual({
|
|
71
102
|
imports: {
|
|
72
103
|
"@acme/": "https://assets.example.com/",
|
|
73
104
|
"@esm.sh/": "https://esm.sh/",
|
|
74
|
-
"@esm.sh/
|
|
105
|
+
"@esm.sh/react": "https://esm.sh/react@19.2.4",
|
|
106
|
+
"@esm.sh/date-fns": "https://esm.sh/date-fns@3.6.0",
|
|
75
107
|
},
|
|
76
108
|
});
|
|
77
109
|
});
|
|
@@ -79,45 +111,155 @@ describe("runtime composition core", () => {
|
|
|
79
111
|
expect(createImportMap({
|
|
80
112
|
...manifest,
|
|
81
113
|
externalDeps: [
|
|
114
|
+
{ name: "react", version: "19.2.4", peerDeps: false },
|
|
115
|
+
{ name: "react-dom", version: "19.2.4", peerDeps: false },
|
|
82
116
|
{
|
|
83
117
|
name: "@radix-ui/themes",
|
|
84
|
-
|
|
118
|
+
version: "3.0.0",
|
|
119
|
+
peerDeps: ["react", "react-dom"],
|
|
85
120
|
},
|
|
86
121
|
],
|
|
87
|
-
defaultPeerDeps:
|
|
122
|
+
defaultPeerDeps: ["react"],
|
|
88
123
|
})).toEqual({
|
|
89
124
|
imports: {
|
|
90
125
|
"@acme/": "https://assets.example.com/",
|
|
91
126
|
"@esm.sh/": "https://esm.sh/",
|
|
92
|
-
"@esm.sh
|
|
127
|
+
"@esm.sh/react": "https://esm.sh/react@19.2.4",
|
|
128
|
+
"@esm.sh/react-dom": "https://esm.sh/react-dom@19.2.4",
|
|
129
|
+
"@esm.sh/@radix-ui/themes": "https://esm.sh/@radix-ui/themes@3.0.0?deps=react@19.2.4,react-dom@19.2.4",
|
|
93
130
|
},
|
|
94
131
|
});
|
|
95
132
|
});
|
|
96
133
|
test("devDeps appends ?dev to exact external entries but not the catch-all prefix", () => {
|
|
97
134
|
expect(createImportMap({
|
|
98
135
|
...manifest,
|
|
99
|
-
externalDeps: [
|
|
100
|
-
|
|
136
|
+
externalDeps: [
|
|
137
|
+
{ name: "react", version: "19.2.4", peerDeps: false },
|
|
138
|
+
{ name: "zustand", version: "4.5.0" },
|
|
139
|
+
],
|
|
140
|
+
defaultPeerDeps: ["react"],
|
|
101
141
|
}, { devDeps: true })).toEqual({
|
|
102
142
|
imports: {
|
|
103
143
|
"@acme/": "https://assets.example.com/",
|
|
104
144
|
"@esm.sh/": "https://esm.sh/",
|
|
105
|
-
"@esm.sh/
|
|
145
|
+
"@esm.sh/react": "https://esm.sh/react@19.2.4?dev",
|
|
146
|
+
"@esm.sh/zustand": "https://esm.sh/zustand@4.5.0?deps=react@19.2.4&dev",
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
test("externalDeps entries resolve with no ?deps= query when defaultPeerDeps is unset", () => {
|
|
151
|
+
expect(createImportMap({
|
|
152
|
+
...manifest,
|
|
153
|
+
externalDeps: [{ name: "zustand", version: "4.5.0" }],
|
|
154
|
+
})).toEqual({
|
|
155
|
+
imports: {
|
|
156
|
+
"@acme/": "https://assets.example.com/",
|
|
157
|
+
"@esm.sh/": "https://esm.sh/",
|
|
158
|
+
"@esm.sh/zustand": "https://esm.sh/zustand@4.5.0",
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
test("externalDeps entry's specifier key excludes the version while the URL includes it", () => {
|
|
163
|
+
expect(createImportMap({
|
|
164
|
+
...manifest,
|
|
165
|
+
externalDeps: [{ name: "react", version: "19.2.4" }],
|
|
166
|
+
})).toEqual({
|
|
167
|
+
imports: {
|
|
168
|
+
"@acme/": "https://assets.example.com/",
|
|
169
|
+
"@esm.sh/": "https://esm.sh/",
|
|
170
|
+
"@esm.sh/react": "https://esm.sh/react@19.2.4",
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
test("a subpath externalDeps entry inserts the version after the base package, before the subpath", () => {
|
|
175
|
+
expect(createImportMap({
|
|
176
|
+
...manifest,
|
|
177
|
+
externalDeps: [{ name: "react-dom/client", version: "19.2.4" }],
|
|
178
|
+
})).toEqual({
|
|
179
|
+
imports: {
|
|
180
|
+
"@acme/": "https://assets.example.com/",
|
|
181
|
+
"@esm.sh/": "https://esm.sh/",
|
|
182
|
+
"@esm.sh/react-dom/client": "https://esm.sh/react-dom@19.2.4/client",
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
test("an unresolvable peerDeps name is silently omitted rather than throwing", () => {
|
|
187
|
+
expect(createImportMap({
|
|
188
|
+
...manifest,
|
|
189
|
+
externalDeps: [
|
|
190
|
+
{ name: "react", version: "19.2.4", peerDeps: false },
|
|
191
|
+
{
|
|
192
|
+
name: "@radix-ui/themes",
|
|
193
|
+
version: "3.0.0",
|
|
194
|
+
peerDeps: ["react", "svelte"],
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
})).toEqual({
|
|
198
|
+
imports: {
|
|
199
|
+
"@acme/": "https://assets.example.com/",
|
|
200
|
+
"@esm.sh/": "https://esm.sh/",
|
|
201
|
+
"@esm.sh/react": "https://esm.sh/react@19.2.4",
|
|
202
|
+
// "svelte" has no externalDeps entry, so it's silently dropped from
|
|
203
|
+
// the query instead of throwing — only "react" resolves.
|
|
204
|
+
"@esm.sh/@radix-ui/themes": "https://esm.sh/@radix-ui/themes@3.0.0?deps=react@19.2.4",
|
|
106
205
|
},
|
|
107
206
|
});
|
|
108
207
|
});
|
|
109
|
-
test("
|
|
208
|
+
test("entries sharing a base package with different versions each keep their own URL, and peer lookups use the first-declared version", () => {
|
|
110
209
|
expect(createImportMap({
|
|
111
210
|
...manifest,
|
|
112
|
-
externalDeps: [
|
|
211
|
+
externalDeps: [
|
|
212
|
+
{ name: "react-dom", version: "19.2.4", peerDeps: false },
|
|
213
|
+
{ name: "react-dom/client", version: "19.2.5", peerDeps: false },
|
|
214
|
+
{ name: "react", version: "19.2.4", peerDeps: false },
|
|
215
|
+
{
|
|
216
|
+
name: "@radix-ui/themes",
|
|
217
|
+
version: "3.0.0",
|
|
218
|
+
peerDeps: ["react-dom"],
|
|
219
|
+
},
|
|
220
|
+
],
|
|
113
221
|
})).toEqual({
|
|
114
222
|
imports: {
|
|
115
223
|
"@acme/": "https://assets.example.com/",
|
|
116
224
|
"@esm.sh/": "https://esm.sh/",
|
|
117
|
-
|
|
225
|
+
// Each entry's own URL always uses its own declared version — the
|
|
226
|
+
// conflict (19.2.4 vs 19.2.5) stays visible here, it's never
|
|
227
|
+
// silently normalized to one value.
|
|
228
|
+
"@esm.sh/react-dom": "https://esm.sh/react-dom@19.2.4",
|
|
229
|
+
"@esm.sh/react-dom/client": "https://esm.sh/react-dom@19.2.5/client",
|
|
230
|
+
"@esm.sh/react": "https://esm.sh/react@19.2.4",
|
|
231
|
+
// Peer lookup for "react-dom" uses the FIRST-declared version
|
|
232
|
+
// (19.2.4, from the "react-dom" entry, not "react-dom/client").
|
|
233
|
+
"@esm.sh/@radix-ui/themes": "https://esm.sh/@radix-ui/themes@3.0.0?deps=react-dom@19.2.4",
|
|
118
234
|
},
|
|
119
235
|
});
|
|
120
236
|
});
|
|
237
|
+
test("resolveImportMapSpecifier prefers an exact match over a shorter matching prefix", () => {
|
|
238
|
+
const importMap = createImportMap({
|
|
239
|
+
...manifest,
|
|
240
|
+
externalDeps: [{ name: "react", version: "19.2.4" }],
|
|
241
|
+
});
|
|
242
|
+
expect(resolveImportMapSpecifier(importMap, "@esm.sh/react")).toBe("https://esm.sh/react@19.2.4");
|
|
243
|
+
});
|
|
244
|
+
test("resolveImportMapSpecifier resolves via the longest matching prefix when no exact key exists", () => {
|
|
245
|
+
const importMap = createImportMap(manifest);
|
|
246
|
+
expect(resolveImportMapSpecifier(importMap, "@acme/search/index.mjs")).toBe("https://assets.example.com/search/index.mjs");
|
|
247
|
+
});
|
|
248
|
+
test("resolveImportMapSpecifier prefers the longest of two overlapping prefix keys", () => {
|
|
249
|
+
const importMap = createImportMap({
|
|
250
|
+
...manifest,
|
|
251
|
+
environments: {
|
|
252
|
+
development: {
|
|
253
|
+
sliceOrigins: { search: "http://localhost:5174" },
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
}, { environment: "development" });
|
|
257
|
+
expect(resolveImportMapSpecifier(importMap, "@acme/search/index.mjs")).toBe("http://localhost:5174/index.mjs");
|
|
258
|
+
});
|
|
259
|
+
test("resolveImportMapSpecifier returns undefined when nothing matches", () => {
|
|
260
|
+
const importMap = createImportMap(manifest);
|
|
261
|
+
expect(resolveImportMapSpecifier(importMap, "lodash")).toBeUndefined();
|
|
262
|
+
});
|
|
121
263
|
test("resolves routes by convention without explicit slice config", () => {
|
|
122
264
|
expect(resolveRoute(manifest, "/search/routes")?.specifier).toBe("@acme/search/index.mjs");
|
|
123
265
|
});
|
|
@@ -174,6 +316,61 @@ describe("runtime composition core", () => {
|
|
|
174
316
|
test("validates the base manifest without errors", () => {
|
|
175
317
|
expect(validateManifest(manifest).filter((item) => item.level === "error")).toHaveLength(0);
|
|
176
318
|
});
|
|
319
|
+
test("validateManifest warns when externalDeps entries share a base package but declare different versions", () => {
|
|
320
|
+
const diagnostics = validateManifest({
|
|
321
|
+
...manifest,
|
|
322
|
+
externalDeps: [
|
|
323
|
+
{ name: "react-dom", version: "19.2.4" },
|
|
324
|
+
{ name: "react-dom/client", version: "19.2.5" },
|
|
325
|
+
],
|
|
326
|
+
});
|
|
327
|
+
expect(diagnostics.some((d) => d.level === "warning" && d.code === "external-deps-version-conflict")).toBe(true);
|
|
328
|
+
});
|
|
329
|
+
test("validateManifest emits exactly one warning for a group of 3+ conflicting entries, not one per pair", () => {
|
|
330
|
+
const diagnostics = validateManifest({
|
|
331
|
+
...manifest,
|
|
332
|
+
externalDeps: [
|
|
333
|
+
{ name: "react-dom", version: "19.2.4" },
|
|
334
|
+
{ name: "react-dom/client", version: "19.2.5" },
|
|
335
|
+
{ name: "react-dom/server", version: "19.2.6" },
|
|
336
|
+
],
|
|
337
|
+
});
|
|
338
|
+
expect(diagnostics.filter((d) => d.code === "external-deps-version-conflict")).toHaveLength(1);
|
|
339
|
+
});
|
|
340
|
+
test("validateManifest does not warn when externalDeps entries sharing a base package agree on version", () => {
|
|
341
|
+
const diagnostics = validateManifest({
|
|
342
|
+
...manifest,
|
|
343
|
+
externalDeps: [
|
|
344
|
+
{ name: "react-dom", version: "19.2.4" },
|
|
345
|
+
{ name: "react-dom/client", version: "19.2.4" },
|
|
346
|
+
],
|
|
347
|
+
});
|
|
348
|
+
expect(diagnostics.some((d) => d.code === "external-deps-version-conflict")).toBe(false);
|
|
349
|
+
});
|
|
350
|
+
test("validateManifest warns when a peerDeps name has no matching externalDeps entry", () => {
|
|
351
|
+
const diagnostics = validateManifest({
|
|
352
|
+
...manifest,
|
|
353
|
+
externalDeps: [
|
|
354
|
+
{
|
|
355
|
+
name: "@radix-ui/themes",
|
|
356
|
+
version: "3.0.0",
|
|
357
|
+
peerDeps: ["react", "svelte"],
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
});
|
|
361
|
+
expect(diagnostics.some((d) => d.level === "warning" && d.code === "external-deps-unresolvable-peer")).toBe(true);
|
|
362
|
+
});
|
|
363
|
+
test("validateManifest does not warn when every peerDeps name resolves, including via defaultPeerDeps", () => {
|
|
364
|
+
const diagnostics = validateManifest({
|
|
365
|
+
...manifest,
|
|
366
|
+
externalDeps: [
|
|
367
|
+
{ name: "react", version: "19.2.4", peerDeps: false },
|
|
368
|
+
{ name: "@radix-ui/themes", version: "3.0.0" },
|
|
369
|
+
],
|
|
370
|
+
defaultPeerDeps: ["react"],
|
|
371
|
+
});
|
|
372
|
+
expect(diagnostics.some((d) => d.code === "external-deps-unresolvable-peer")).toBe(false);
|
|
373
|
+
});
|
|
177
374
|
test("createImportMapBootstrapScript generates valid, parseable JavaScript", () => {
|
|
178
375
|
const script = createImportMapBootstrapScript(manifest);
|
|
179
376
|
expect(() => new Function(script)).not.toThrow();
|
|
@@ -198,11 +395,14 @@ describe("runtime composition core", () => {
|
|
|
198
395
|
expect(script).toContain('script.type = "importmap"');
|
|
199
396
|
});
|
|
200
397
|
test("createImportMapBootstrapScript's embedded dev-flag logic matches createImportMap's own devDeps behavior", () => {
|
|
201
|
-
const manifestWithExternalDeps = {
|
|
398
|
+
const manifestWithExternalDeps = defineManifest({
|
|
202
399
|
...manifest,
|
|
203
|
-
externalDeps: [
|
|
204
|
-
|
|
205
|
-
|
|
400
|
+
externalDeps: [
|
|
401
|
+
{ name: "react", version: "19.2.4", peerDeps: false },
|
|
402
|
+
{ name: "zustand", version: "4.5.0" },
|
|
403
|
+
],
|
|
404
|
+
defaultPeerDeps: ["react"],
|
|
405
|
+
});
|
|
206
406
|
const expectedWithDevDeps = createImportMap(manifestWithExternalDeps, {
|
|
207
407
|
devDeps: true,
|
|
208
408
|
}).imports;
|
|
@@ -237,4 +437,12 @@ describe("runtime composition core", () => {
|
|
|
237
437
|
expect(mountSpy).toHaveBeenCalledTimes(1);
|
|
238
438
|
expect(typeof notifyInternalNavigation).toBe("function");
|
|
239
439
|
});
|
|
440
|
+
test("re-exports createRuntimeHostObservable from the public barrel", () => {
|
|
441
|
+
const target = document.createElement("div");
|
|
442
|
+
const observable = createRuntimeHostObservable({
|
|
443
|
+
manifest: { namespace: "@acme", assetsOrigin: "https://assets.example.com" },
|
|
444
|
+
target,
|
|
445
|
+
});
|
|
446
|
+
expect(observable.getSnapshot()).toEqual({ type: "idle" });
|
|
447
|
+
});
|
|
240
448
|
});
|
package/dist/manifest.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import type { RuntimeCompositionManifest } from "./types.js";
|
|
1
|
+
import type { PackageSpecifier, RuntimeCompositionManifest } from "./types.js";
|
|
2
2
|
export declare const defineManifest: <TManifest extends RuntimeCompositionManifest>(manifest: TManifest) => TManifest;
|
|
3
3
|
export declare const trimTrailingSlash: (value: string) => string;
|
|
4
4
|
export declare const trimLeadingSlash: (value: string) => string;
|
|
5
5
|
export declare const joinUrl: (origin: string, path: string) => string;
|
|
6
|
+
/**
|
|
7
|
+
* Splits a package specifier into its base package (handling scoped
|
|
8
|
+
* packages, e.g. "@radix-ui/themes") and an optional subpath (e.g.
|
|
9
|
+
* "react-dom/client" -> basePackage "react-dom", subpath "client").
|
|
10
|
+
*/
|
|
11
|
+
export declare const splitPackageSpecifier: (name: string) => PackageSpecifier;
|
|
6
12
|
//# sourceMappingURL=manifest.d.ts.map
|
package/dist/manifest.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,0BAA0B,EAAE,MAAM,YAAY,CAAC;AAE/E,eAAO,MAAM,cAAc,GAAI,SAAS,SAAS,0BAA0B,EACzE,UAAU,SAAS,KAClB,SAAqB,CAAC;AAEzB,eAAO,MAAM,iBAAiB,GAAI,OAAO,MAAM,KAAG,MACA,CAAC;AAEnD,eAAO,MAAM,gBAAgB,GAAI,OAAO,MAAM,KAAG,MACD,CAAC;AAEjD,eAAO,MAAM,OAAO,GAAI,QAAQ,MAAM,EAAE,MAAM,MAAM,KAAG,MACG,CAAC;AAE3D;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,GAAI,MAAM,MAAM,KAAG,gBAmBpD,CAAC"}
|
package/dist/manifest.js
CHANGED
|
@@ -2,3 +2,26 @@ export const defineManifest = (manifest) => manifest;
|
|
|
2
2
|
export const trimTrailingSlash = (value) => value.endsWith("/") ? value.slice(0, -1) : value;
|
|
3
3
|
export const trimLeadingSlash = (value) => value.startsWith("/") ? value.slice(1) : value;
|
|
4
4
|
export const joinUrl = (origin, path) => `${trimTrailingSlash(origin)}/${trimLeadingSlash(path)}`;
|
|
5
|
+
/**
|
|
6
|
+
* Splits a package specifier into its base package (handling scoped
|
|
7
|
+
* packages, e.g. "@radix-ui/themes") and an optional subpath (e.g.
|
|
8
|
+
* "react-dom/client" -> basePackage "react-dom", subpath "client").
|
|
9
|
+
*/
|
|
10
|
+
export const splitPackageSpecifier = (name) => {
|
|
11
|
+
const segments = name.split("/");
|
|
12
|
+
if (name.startsWith("@")) {
|
|
13
|
+
const scope = segments[0] ?? "";
|
|
14
|
+
const packageName = segments[1] ?? "";
|
|
15
|
+
const rest = segments.slice(2);
|
|
16
|
+
return {
|
|
17
|
+
basePackage: `${scope}/${packageName}`,
|
|
18
|
+
subpath: rest.length > 0 ? rest.join("/") : null,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const packageName = segments[0] ?? name;
|
|
22
|
+
const rest = segments.slice(1);
|
|
23
|
+
return {
|
|
24
|
+
basePackage: packageName,
|
|
25
|
+
subpath: rest.length > 0 ? rest.join("/") : null,
|
|
26
|
+
};
|
|
27
|
+
};
|
package/dist/types.d.ts
CHANGED
|
@@ -25,9 +25,21 @@ export type EnvironmentConfig = {
|
|
|
25
25
|
externalDepsOrigin?: string;
|
|
26
26
|
sliceOrigins?: Record<string, string>;
|
|
27
27
|
};
|
|
28
|
-
export type
|
|
28
|
+
export type PackageSpecifier = {
|
|
29
|
+
basePackage: string;
|
|
30
|
+
subpath: string | null;
|
|
31
|
+
};
|
|
32
|
+
export type ExternalDepEntry = {
|
|
33
|
+
/** Bare package name, or a subpath import, e.g. "react" or "react-dom/client". */
|
|
29
34
|
name: string;
|
|
30
|
-
|
|
35
|
+
/** Always required — this is what prevents a bare entry from resolving to
|
|
36
|
+
* an unpinned "latest" CDN build. */
|
|
37
|
+
version: string;
|
|
38
|
+
/** Peer package NAMES (not versions) to pin via esm.sh's `?deps=` query.
|
|
39
|
+
* Versions are looked up from those names' own externalDeps entries at
|
|
40
|
+
* generation time — never hand-typed here. `false` opts this entry out
|
|
41
|
+
* of `defaultPeerDeps`. */
|
|
42
|
+
peerDeps?: string[] | false;
|
|
31
43
|
};
|
|
32
44
|
export type RuntimeCompositionManifest = {
|
|
33
45
|
namespace: string;
|
|
@@ -40,7 +52,9 @@ export type RuntimeCompositionManifest = {
|
|
|
40
52
|
sliceOverrides?: Record<string, SliceConfig>;
|
|
41
53
|
routeOverrides?: Record<string, RouteOverrideConfig>;
|
|
42
54
|
externalDeps?: ExternalDepEntry[];
|
|
43
|
-
|
|
55
|
+
/** Peer package NAMES applied automatically to every externalDeps entry
|
|
56
|
+
* that doesn't set its own `peerDeps`. */
|
|
57
|
+
defaultPeerDeps?: string[];
|
|
44
58
|
};
|
|
45
59
|
export type RuntimeRouteMatch = {
|
|
46
60
|
sliceName: string;
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,SAAS,GAAG;IACtB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACjD,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG,aAAa,GAAG,SAAS,GAAG,YAAY,CAAC;AAE1E,MAAM,MAAM,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,kBAAkB,EAAE,MAAM,CAAC,CAAC,CAAC;AAE5E,MAAM,MAAM,sBAAsB,GAC9B,MAAM,GACN;IACE,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,iBAAiB,CAAC;IACjC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEN,MAAM,MAAM,WAAW,GAAG;IACxB,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,iBAAiB,CAAC;IACjC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAC3B,MAAM,GACN;IACE,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;CAC3B,CAAC;AAEN,MAAM,MAAM,iBAAiB,GAAG;IAC9B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACvC,CAAC;AAEF,MAAM,MAAM,gBAAgB,
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,SAAS,GAAG;IACtB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACjD,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG,aAAa,GAAG,SAAS,GAAG,YAAY,CAAC;AAE1E,MAAM,MAAM,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,kBAAkB,EAAE,MAAM,CAAC,CAAC,CAAC;AAE5E,MAAM,MAAM,sBAAsB,GAC9B,MAAM,GACN;IACE,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,iBAAiB,CAAC;IACjC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEN,MAAM,MAAM,WAAW,GAAG;IACxB,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,iBAAiB,CAAC;IACjC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAC3B,MAAM,GACN;IACE,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;CAC3B,CAAC;AAEN,MAAM,MAAM,iBAAiB,GAAG;IAC9B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACvC,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,kFAAkF;IAClF,IAAI,EAAE,MAAM,CAAC;IACb;0CACsC;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB;;;gCAG4B;IAC5B,QAAQ,CAAC,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC;CAC7B,CAAC;AAEF,MAAM,MAAM,0BAA0B,GAAG;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,kBAAkB,EAAE,iBAAiB,CAAC,CAAC,CAAC;IACtE,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,sBAAsB,CAAC,CAAC;IACtD,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IAC7C,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC;IACrD,YAAY,CAAC,EAAE,gBAAgB,EAAE,CAAC;IAClC;+CAC2C;IAC3C,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAChC,CAAC;AAEF,MAAM,MAAM,4BAA4B,GAAG;IACzC,KAAK,EAAE,OAAO,GAAG,SAAS,CAAC;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,KAAK,CAAC,EAAE,iBAAiB,CAAC;IAC1B,QAAQ,CAAC,EAAE,0BAA0B,CAAC;IACtC,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,CAAC,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,oBAAoB,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7E,OAAO,CAAC,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAClC,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC"}
|
package/dist/validation.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,4BAA4B,EAC5B,0BAA0B,EAC3B,MAAM,YAAY,CAAC;AAGpB,eAAO,MAAM,gBAAgB,GAC3B,UAAU,0BAA0B,KACnC,4BAA4B,EAyH9B,CAAC"}
|
package/dist/validation.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { splitPackageSpecifier } from "./manifest.js";
|
|
1
2
|
export const validateManifest = (manifest) => {
|
|
2
3
|
const diagnostics = [];
|
|
3
4
|
if (!manifest.namespace.startsWith("@")) {
|
|
@@ -63,5 +64,39 @@ export const validateManifest = (manifest) => {
|
|
|
63
64
|
});
|
|
64
65
|
}
|
|
65
66
|
}
|
|
67
|
+
const externalDeps = manifest.externalDeps ?? [];
|
|
68
|
+
const entriesByBasePackage = new Map();
|
|
69
|
+
for (const entry of externalDeps) {
|
|
70
|
+
const { basePackage } = splitPackageSpecifier(entry.name);
|
|
71
|
+
const group = entriesByBasePackage.get(basePackage) ?? [];
|
|
72
|
+
group.push(entry);
|
|
73
|
+
entriesByBasePackage.set(basePackage, group);
|
|
74
|
+
}
|
|
75
|
+
for (const [basePackage, group] of entriesByBasePackage) {
|
|
76
|
+
const distinctVersions = new Set(group.map((entry) => entry.version));
|
|
77
|
+
if (distinctVersions.size > 1) {
|
|
78
|
+
diagnostics.push({
|
|
79
|
+
level: "warning",
|
|
80
|
+
code: "external-deps-version-conflict",
|
|
81
|
+
message: `externalDeps entries ${group
|
|
82
|
+
.map((entry) => `"${entry.name}"@${entry.version}`)
|
|
83
|
+
.join(", ")} all resolve to package "${basePackage}" but declare different versions.`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const entry of externalDeps) {
|
|
88
|
+
const peerNames = entry.peerDeps === false
|
|
89
|
+
? []
|
|
90
|
+
: (entry.peerDeps ?? manifest.defaultPeerDeps ?? []);
|
|
91
|
+
for (const peerName of peerNames) {
|
|
92
|
+
if (!entriesByBasePackage.has(peerName)) {
|
|
93
|
+
diagnostics.push({
|
|
94
|
+
level: "warning",
|
|
95
|
+
code: "external-deps-unresolvable-peer",
|
|
96
|
+
message: `externalDeps entry "${entry.name}" lists peer dependency "${peerName}", but no externalDeps entry declares a version for "${peerName}".`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
66
101
|
return diagnostics;
|
|
67
102
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmc-toolkit/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/runtime-module-composition/rmc-toolkit",
|
|
8
|
+
"directory": "packages/core"
|
|
9
|
+
},
|
|
5
10
|
"main": "./dist/index.js",
|
|
6
11
|
"types": "./dist/index.d.ts",
|
|
7
12
|
"exports": {
|