@mfeflow/contracts 0.3.0 → 0.5.0
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/dist/docs.d.ts +1 -0
- package/dist/entry.d.ts +45 -0
- package/dist/entry.js +139 -2
- package/dist/feature-registry.d.ts +46 -0
- package/dist/feature.d.ts +2 -0
- package/dist/html-injection.d.ts +2 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.js +449 -110
- package/dist/json-schema.d.ts +1 -1
- package/dist/mfe-manifest.d.ts +2 -2
- package/dist/project.d.ts +19 -1
- package/dist/registry.d.ts +4 -23
- package/dist/remote-budget.d.ts +21 -0
- package/dist/remote-dist.d.ts +12 -0
- package/dist/routes.d.ts +6 -0
- package/dist/validate.d.ts +8 -1
- package/package.json +1 -1
package/dist/docs.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export declare const SCHEMA_URLS: {
|
|
|
7
7
|
readonly feature: "https://mfeflow.github.io/mfeflow/schema/feature.json";
|
|
8
8
|
readonly registryEntry: "https://mfeflow.github.io/mfeflow/schema/registry-entry.json";
|
|
9
9
|
readonly registryIndex: "https://mfeflow.github.io/mfeflow/schema/registry-index.json";
|
|
10
|
+
readonly featureRegistryIndex: "https://mfeflow.github.io/mfeflow/schema/feature-registry-index.json";
|
|
10
11
|
};
|
|
11
12
|
export type SchemaName = keyof typeof SCHEMA_URLS;
|
|
12
13
|
/** The docs page for an error code such as `E102`. */
|
package/dist/entry.d.ts
CHANGED
|
@@ -5,6 +5,51 @@ export declare const ENTRY_TYPES: readonly string[];
|
|
|
5
5
|
* also hold `-`, as the `globalName` in `mf-manifest.json` does for a remote like `my-app`.
|
|
6
6
|
*/
|
|
7
7
|
export declare const GLOBAL_NAME: RegExp;
|
|
8
|
+
/** A Module Federation name that is also safe as a plain object key. */
|
|
9
|
+
export declare const isRemoteName: (value: string) => boolean;
|
|
10
|
+
export declare const ROUTE: RegExp;
|
|
11
|
+
export declare const EXPOSE: RegExp;
|
|
12
|
+
/** One remote of the registry index, the shape the shell, the edge, the CLI and the dev registry share. */
|
|
13
|
+
export interface RegistryIndexRemote {
|
|
14
|
+
/** Module Federation name. */
|
|
15
|
+
name: string;
|
|
16
|
+
/** `mf-manifest.json` URL. */
|
|
17
|
+
entry: string;
|
|
18
|
+
/** Entry script URL; shells register it in place of `entry` when present. */
|
|
19
|
+
remoteEntry?: string;
|
|
20
|
+
/** How `remoteEntry` loads (`module` for ESM). */
|
|
21
|
+
type?: string;
|
|
22
|
+
/** The global `remoteEntry` defines when it differs from `name`. */
|
|
23
|
+
entryGlobalName?: string;
|
|
24
|
+
/** Per expose (`./App`), the files to preload. Not inlined into the shell's HTML. */
|
|
25
|
+
exposes?: Record<string, string[]>;
|
|
26
|
+
version?: string;
|
|
27
|
+
routes?: string[];
|
|
28
|
+
owner?: string;
|
|
29
|
+
/** The version served before `version`; what a rollback returns to. */
|
|
30
|
+
previous?: string;
|
|
31
|
+
/** Older versions served, newest first and bounded. */
|
|
32
|
+
history?: string[];
|
|
33
|
+
/** Set after a rollback: the version that was served and was moved away from. Never a rollback target. */
|
|
34
|
+
rolledBackFrom?: string;
|
|
35
|
+
}
|
|
36
|
+
/** What the shell fetches at boot. */
|
|
37
|
+
export interface RegistryIndex {
|
|
38
|
+
shell?: string;
|
|
39
|
+
env?: string;
|
|
40
|
+
generatedAt?: string;
|
|
41
|
+
remotes: RegistryIndexRemote[];
|
|
42
|
+
}
|
|
43
|
+
export interface RegistryIssue {
|
|
44
|
+
/** Relative to the value checked, e.g. `type` or `exposes./App[0]`. */
|
|
45
|
+
path: string;
|
|
46
|
+
message: string;
|
|
47
|
+
}
|
|
48
|
+
/** Problems with the entry script fields a registry entry and an index remote share. */
|
|
49
|
+
export declare function remoteFileIssues(obj: Record<string, unknown>): RegistryIssue[];
|
|
50
|
+
/** Every problem with one registry index remote; empty when it is valid. Browser-safe: the shell runs it. */
|
|
51
|
+
export declare function registryRemoteIssues(value: unknown): RegistryIssue[];
|
|
52
|
+
export declare function isRegistryIndexRemote(value: unknown): value is RegistryIndexRemote;
|
|
8
53
|
/** The registry remote fields the shell and the edge read; everything else is dropped. */
|
|
9
54
|
export declare const REGISTRY_REMOTE_FIELDS: readonly string[];
|
|
10
55
|
/** A copy of `remote` with only the whitelisted fields that are set. */
|
package/dist/entry.js
CHANGED
|
@@ -20,19 +20,156 @@ const ENTRY_TYPES = [
|
|
|
20
20
|
'system'
|
|
21
21
|
];
|
|
22
22
|
const GLOBAL_NAME = /^[A-Za-z_$][\w$-]*$/;
|
|
23
|
+
const RESERVED_NAMES = new Set([
|
|
24
|
+
'__proto__',
|
|
25
|
+
'constructor',
|
|
26
|
+
'prototype'
|
|
27
|
+
]);
|
|
28
|
+
const isRemoteName = (value)=>GLOBAL_NAME.test(value) && !RESERVED_NAMES.has(value);
|
|
29
|
+
const ROUTE = /^\/[A-Za-z0-9/_:*.-]*$/;
|
|
30
|
+
const EXPOSE = /^\.\/[A-Za-z0-9_/-]+$/;
|
|
31
|
+
function isRecord(value) {
|
|
32
|
+
return 'object' == typeof value && null !== value && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
function isHttpUrl(value) {
|
|
35
|
+
try {
|
|
36
|
+
const { protocol } = new URL(value);
|
|
37
|
+
return 'https:' === protocol || 'http:' === protocol;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function stringField(obj, key, out, check, required = false) {
|
|
43
|
+
const value = obj[key];
|
|
44
|
+
if (void 0 === value) {
|
|
45
|
+
if (required) out.push({
|
|
46
|
+
path: key,
|
|
47
|
+
message: 'is required'
|
|
48
|
+
});
|
|
49
|
+
} else if ('string' != typeof value) out.push({
|
|
50
|
+
path: key,
|
|
51
|
+
message: 'must be a string'
|
|
52
|
+
});
|
|
53
|
+
else if (check && !check.test(value)) out.push({
|
|
54
|
+
path: key,
|
|
55
|
+
message: check.hint
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const URL_CHECK = {
|
|
59
|
+
test: isHttpUrl,
|
|
60
|
+
hint: 'must be an http(s) URL'
|
|
61
|
+
};
|
|
62
|
+
function remoteFileIssues(obj) {
|
|
63
|
+
const out = [];
|
|
64
|
+
stringField(obj, 'remoteEntry', out, URL_CHECK);
|
|
65
|
+
stringField(obj, 'type', out, {
|
|
66
|
+
test: (value)=>ENTRY_TYPES.includes(value),
|
|
67
|
+
hint: `must be a Module Federation entry type: ${ENTRY_TYPES.join(', ')}`
|
|
68
|
+
});
|
|
69
|
+
stringField(obj, 'entryGlobalName', out, {
|
|
70
|
+
test: (value)=>GLOBAL_NAME.test(value),
|
|
71
|
+
hint: 'must be a JavaScript global name (letters, digits, _, $, -; not starting with a digit)'
|
|
72
|
+
});
|
|
73
|
+
const exposes = obj.exposes;
|
|
74
|
+
if (void 0 === exposes) return out;
|
|
75
|
+
if (!isRecord(exposes)) return [
|
|
76
|
+
...out,
|
|
77
|
+
{
|
|
78
|
+
path: 'exposes',
|
|
79
|
+
message: 'must be an object'
|
|
80
|
+
}
|
|
81
|
+
];
|
|
82
|
+
for (const [expose, files] of Object.entries(exposes)){
|
|
83
|
+
const at = `exposes.${expose}`;
|
|
84
|
+
if (!EXPOSE.test(expose)) out.push({
|
|
85
|
+
path: at,
|
|
86
|
+
message: 'must look like "./App"'
|
|
87
|
+
});
|
|
88
|
+
if (!Array.isArray(files)) {
|
|
89
|
+
out.push({
|
|
90
|
+
path: at,
|
|
91
|
+
message: 'must be an array of URLs'
|
|
92
|
+
});
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
files.forEach((file, i)=>{
|
|
96
|
+
if ('string' != typeof file || !isHttpUrl(file)) out.push({
|
|
97
|
+
path: `${at}[${String(i)}]`,
|
|
98
|
+
message: 'must be an http(s) URL'
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
function registryRemoteIssues(value) {
|
|
105
|
+
if (!isRecord(value)) return [
|
|
106
|
+
{
|
|
107
|
+
path: '',
|
|
108
|
+
message: 'must be an object'
|
|
109
|
+
}
|
|
110
|
+
];
|
|
111
|
+
const out = [];
|
|
112
|
+
stringField(value, 'name', out, {
|
|
113
|
+
test: isRemoteName,
|
|
114
|
+
hint: 'must be a Module Federation name'
|
|
115
|
+
}, true);
|
|
116
|
+
stringField(value, 'entry', out, URL_CHECK, true);
|
|
117
|
+
stringField(value, 'version', out);
|
|
118
|
+
stringField(value, 'owner', out);
|
|
119
|
+
stringField(value, 'previous', out);
|
|
120
|
+
stringField(value, 'rolledBackFrom', out);
|
|
121
|
+
const history = value.history;
|
|
122
|
+
if (void 0 !== history) if (Array.isArray(history)) history.forEach((version, i)=>{
|
|
123
|
+
if ('string' != typeof version) out.push({
|
|
124
|
+
path: `history[${String(i)}]`,
|
|
125
|
+
message: 'must be a string'
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
else out.push({
|
|
129
|
+
path: 'history',
|
|
130
|
+
message: 'must be an array of strings'
|
|
131
|
+
});
|
|
132
|
+
const routes = value.routes;
|
|
133
|
+
if (void 0 !== routes) if (Array.isArray(routes)) routes.forEach((route, i)=>{
|
|
134
|
+
const at = `routes[${String(i)}]`;
|
|
135
|
+
if ('string' != typeof route) out.push({
|
|
136
|
+
path: at,
|
|
137
|
+
message: 'must be a string'
|
|
138
|
+
});
|
|
139
|
+
else if (!ROUTE.test(route)) out.push({
|
|
140
|
+
path: at,
|
|
141
|
+
message: 'must start with "/"'
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
else out.push({
|
|
145
|
+
path: 'routes',
|
|
146
|
+
message: 'must be an array of strings'
|
|
147
|
+
});
|
|
148
|
+
return [
|
|
149
|
+
...out,
|
|
150
|
+
...remoteFileIssues(value)
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
function isRegistryIndexRemote(value) {
|
|
154
|
+
return 0 === registryRemoteIssues(value).length;
|
|
155
|
+
}
|
|
23
156
|
const REGISTRY_REMOTE_FIELDS = [
|
|
24
157
|
'name',
|
|
25
158
|
'entry',
|
|
26
159
|
'remoteEntry',
|
|
27
160
|
'type',
|
|
28
161
|
'entryGlobalName',
|
|
162
|
+
'exposes',
|
|
29
163
|
'version',
|
|
30
164
|
'owner',
|
|
31
|
-
'routes'
|
|
165
|
+
'routes',
|
|
166
|
+
'previous',
|
|
167
|
+
'history',
|
|
168
|
+
'rolledBackFrom'
|
|
32
169
|
];
|
|
33
170
|
function pickRegistryFields(remote) {
|
|
34
171
|
const out = {};
|
|
35
172
|
for (const [key, value] of Object.entries(remote))if (void 0 !== value && REGISTRY_REMOTE_FIELDS.includes(key)) out[key] = value;
|
|
36
173
|
return out;
|
|
37
174
|
}
|
|
38
|
-
export { ENTRY_TYPES, GLOBAL_NAME, REGISTRY_REMOTE_FIELDS, pickRegistryFields };
|
|
175
|
+
export { ENTRY_TYPES, EXPOSE, GLOBAL_NAME, REGISTRY_REMOTE_FIELDS, ROUTE, isRegistryIndexRemote, isRemoteName, pickRegistryFields, registryRemoteIssues, remoteFileIssues };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type Issue } from './validate.js';
|
|
2
|
+
/** One feature a third-party registry offers. `name` has no namespace: the registry's own namespace goes in front. */
|
|
3
|
+
export interface FeatureRegistryItem {
|
|
4
|
+
name: string;
|
|
5
|
+
version: string;
|
|
6
|
+
description: string;
|
|
7
|
+
keywords?: string[];
|
|
8
|
+
}
|
|
9
|
+
/** `<base>/index.json` of a third-party feature registry. */
|
|
10
|
+
export interface FeatureRegistryIndex {
|
|
11
|
+
$schema?: string;
|
|
12
|
+
items: FeatureRegistryItem[];
|
|
13
|
+
}
|
|
14
|
+
export declare const REGISTRY_ITEM_NAME: RegExp;
|
|
15
|
+
export declare const MAX_INDEX_ITEMS = 1000;
|
|
16
|
+
export declare const MAX_DESCRIPTION = 300;
|
|
17
|
+
export declare const MAX_KEYWORDS = 20;
|
|
18
|
+
export declare const MAX_KEYWORD = 40;
|
|
19
|
+
/** Plain http is allowed for the machine you are on, so a registry can be tried before it is hosted. */
|
|
20
|
+
export declare function isLoopbackHttp(url: URL): boolean;
|
|
21
|
+
/** True when `url` is https, or http on a loopback host. */
|
|
22
|
+
export declare function isRegistryProtocol(url: URL): boolean;
|
|
23
|
+
/** Why `template` cannot be a registry URL, or undefined when it can. */
|
|
24
|
+
export declare function registryTemplateProblem(template: string): string | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Where a registry's `index.json` is: in the folder the template starts in, before the first
|
|
27
|
+
* segment that has a placeholder. `https://x.dev/r/{name}.json` has its index at `https://x.dev/r/index.json`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function registryIndexUrl(template: string): string;
|
|
30
|
+
export declare function validateFeatureRegistryIndex(value: unknown): Issue[];
|
|
31
|
+
export declare function isFeatureRegistryIndex(value: unknown): value is FeatureRegistryIndex;
|
|
32
|
+
/**
|
|
33
|
+
* True when `hostname` (as `URL.hostname` spells it) is the machine itself or a private network:
|
|
34
|
+
* localhost, 127/8, ::1, 10/8, 172.16/12, 192.168/16, 169.254/16, 100.64/10, 192.0.0/24, 0/8, fc00::/7,
|
|
35
|
+
* fe80::/10, fec0::/10, IPv4-compatible, IPv4-mapped, NAT64 and 6to4 IPv6 forms that hold one of
|
|
36
|
+
* those, and names ending .localhost, .internal or .local.
|
|
37
|
+
* Only the literal host is looked at; a public name that resolves to a private address is not caught.
|
|
38
|
+
*/
|
|
39
|
+
export declare function isPrivateHost(hostname: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* `url` with the values of token-like query parameters (token, key, sig, auth, password, secret,
|
|
42
|
+
* X-Amz-*) and any user name or password replaced by `***`. Works on templates too, so `{name}` stays.
|
|
43
|
+
*/
|
|
44
|
+
export declare function redactUrl(url: string): string;
|
|
45
|
+
/** `text` with every http(s) URL in it passed through `redactUrl`, for messages that quote one. */
|
|
46
|
+
export declare function redactUrlsIn(text: string): string;
|
package/dist/feature.d.ts
CHANGED
|
@@ -159,6 +159,8 @@ export interface FeatureManifest {
|
|
|
159
159
|
name: string;
|
|
160
160
|
version: string;
|
|
161
161
|
description?: string;
|
|
162
|
+
/** Words a registry's `index.json` lists for search. */
|
|
163
|
+
keywords?: string[];
|
|
162
164
|
prompts?: FeaturePrompt[];
|
|
163
165
|
requires?: string[];
|
|
164
166
|
conflicts?: string[];
|
package/dist/html-injection.d.ts
CHANGED
|
@@ -18,7 +18,8 @@ export interface InjectionOptions {
|
|
|
18
18
|
* index) and preload hints for the files each remote the route renders needs.
|
|
19
19
|
* Everything the runtime reads from the fetched index is inlined, so both
|
|
20
20
|
* paths register the same remotes; only the per-expose `exposes` file lists
|
|
21
|
-
*
|
|
21
|
+
* and the rollback fields (`previous`, `history`, `rolledBackFrom`) stay out
|
|
22
|
+
* of the page.
|
|
22
23
|
*
|
|
23
24
|
* Hints match how the browser will request each file, or it downloads twice:
|
|
24
25
|
* classic scripts and styles are `rel="preload"` without `crossorigin`; the
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
export { isSafeRelativePath, type Issue } from './validate.js';
|
|
1
|
+
export { isPortableRelativePath, isSafeRelativePath, type Issue } from './validate.js';
|
|
2
2
|
export { ANALYTICS_EVENT, EVENT_NAME, EXPOSE, isMfeManifest, MFE_NAME, ROUTE, validateMfeManifest, type MfeManifest, } from './mfe-manifest.js';
|
|
3
|
+
export { routesOverlap } from './routes.js';
|
|
3
4
|
export { assetsFromManifest, type RemoteAssets } from './manifest-assets.js';
|
|
5
|
+
export { remoteDistFiles, type RemoteDistFiles } from './remote-dist.js';
|
|
6
|
+
export { judgeRemoteInitial, primaryExpose, type RemoteInitialVerdict } from './remote-budget.js';
|
|
4
7
|
export { injectIntoHead, registryInjection, type InjectionOptions, type RouteNeed, } from './html-injection.js';
|
|
5
|
-
export { isRegistryEntry, isRegistryIndex, validateRegistryEntry, validateRegistryIndex, type RegistryEntry, type RegistryIndex, type RegistryIndexRemote, } from './registry.js';
|
|
6
|
-
export {
|
|
8
|
+
export { isRegistryEntry, isRegistryIndex, isRegistryIndexRemote, registryRemoteIssues, ROUTE_OVERLAP, validateRegistryEntry, validateRegistryIndex, type RegistryEntry, type RegistryIndex, type RegistryIndexRemote, } from './registry.js';
|
|
9
|
+
export { isFeatureRegistryIndex, isLoopbackHttp, isPrivateHost, isRegistryProtocol, redactUrl, redactUrlsIn, registryIndexUrl, registryTemplateProblem, validateFeatureRegistryIndex, MAX_DESCRIPTION, MAX_INDEX_ITEMS, REGISTRY_ITEM_NAME, type FeatureRegistryIndex, type FeatureRegistryItem, } from './feature-registry.js';
|
|
10
|
+
export { FILE_HASH, PROJECT_REPOS, REGISTRY_HOST, REGISTRY_NAMESPACE, isMfeflowProject, validateMfeflowProject, type MfeflowProject, type ProjectBudget, type ProjectRegistry, } from './project.js';
|
|
7
11
|
export { COST_MODEL, judgeCost, round1, splitScripts, type CostLine, type CostModel, type CostSample, type CostVerdict, } from './cost-model.js';
|
|
8
12
|
export { FEATURE_CONDITION, FEATURE_NAME, isFeatureManifest, TRANSFORM_OPS, validateFeatureManifest, type AddBootStepOp, type AddEnvKeyOp, type AddImportOp, type AddRouteOp, type AddSharedOp, type AddWorkflowStepOp, type EnvValidator, type FeatureCondition, type FeatureDependency, type FeatureFile, type FeatureManifest, type FeatureMigration, type FeaturePrompt, type FeatureScript, type FeatureTarget, type Literal, type PromptValue, type TransformOp, type TransformOpName, type WorkflowStep, type WrapProviderOp, } from './feature.js';
|
|
9
13
|
export { DOCS_URL, SCHEMA_URLS, errorDocsUrl, type SchemaName } from './docs.js';
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { registryRemoteIssues, GLOBAL_NAME, remoteFileIssues, ENTRY_TYPES, pickRegistryFields, EXPOSE, isRemoteName, ROUTE } from "./entry.js";
|
|
2
2
|
function isObj(value) {
|
|
3
3
|
return 'object' == typeof value && null !== value && !Array.isArray(value);
|
|
4
4
|
}
|
|
5
5
|
class Checker {
|
|
6
6
|
issues = [];
|
|
7
|
-
add(path, message) {
|
|
8
|
-
this.issues.push({
|
|
7
|
+
add(path, message, code) {
|
|
8
|
+
this.issues.push(void 0 === code ? {
|
|
9
9
|
path,
|
|
10
10
|
message
|
|
11
|
+
} : {
|
|
12
|
+
path,
|
|
13
|
+
message,
|
|
14
|
+
code
|
|
11
15
|
});
|
|
12
16
|
}
|
|
13
17
|
string(obj, key, path, opts = {}) {
|
|
@@ -78,9 +82,12 @@ function isSafeRelativePath(value) {
|
|
|
78
82
|
if (value.startsWith('/') || /^[A-Za-z]:/.test(value)) return false;
|
|
79
83
|
return value.split('/').every((part)=>'' !== part && '.' !== part && '..' !== part);
|
|
80
84
|
}
|
|
85
|
+
const DEVICE_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
|
|
86
|
+
function isPortableRelativePath(value) {
|
|
87
|
+
if (!isSafeRelativePath(value)) return false;
|
|
88
|
+
return value.split('/').every((part)=>!part.includes(':') && !DEVICE_NAME.test(part) && !/[. ]$/.test(part));
|
|
89
|
+
}
|
|
81
90
|
const MFE_NAME = /^[a-z][a-z0-9]*(?:_[a-z][a-z0-9]*)*$/;
|
|
82
|
-
const ROUTE = /^\/[A-Za-z0-9/_:*.-]*$/;
|
|
83
|
-
const EXPOSE = /^\.\/[A-Za-z0-9_/-]+$/;
|
|
84
91
|
const EVENT_NAME = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+\.v[1-9][0-9]*$/;
|
|
85
92
|
const ANALYTICS_EVENT = /^[a-z][a-z0-9]*(_[a-z0-9]+)+$/;
|
|
86
93
|
function validateMfeManifest(value) {
|
|
@@ -146,6 +153,32 @@ function validateMfeManifest(value) {
|
|
|
146
153
|
function isMfeManifest(value) {
|
|
147
154
|
return 0 === validateMfeManifest(value).length;
|
|
148
155
|
}
|
|
156
|
+
function segments(route) {
|
|
157
|
+
return route.toLowerCase().split('/').filter(Boolean);
|
|
158
|
+
}
|
|
159
|
+
const isParam = (segment)=>segment.startsWith(':');
|
|
160
|
+
const isWild = (segment)=>segment.endsWith('*');
|
|
161
|
+
const prefixOf = (segment)=>isParam(segment) ? '' : segment.slice(0, -1);
|
|
162
|
+
const tailAt = (list, i)=>i === list.length - 1 && isWild(list[i] ?? '');
|
|
163
|
+
function segmentsMeet(x, y) {
|
|
164
|
+
if (isWild(x) && isWild(y)) return x.startsWith(prefixOf(y)) || y.startsWith(prefixOf(x));
|
|
165
|
+
if (isWild(x)) return isParam(y) || y.startsWith(prefixOf(x));
|
|
166
|
+
if (isWild(y)) return isParam(x) || x.startsWith(prefixOf(y));
|
|
167
|
+
return x === y || isParam(x) || isParam(y);
|
|
168
|
+
}
|
|
169
|
+
function routesOverlap(a, b) {
|
|
170
|
+
const left = segments(a);
|
|
171
|
+
const right = segments(b);
|
|
172
|
+
for(let i = 0; i < Math.max(left.length, right.length); i += 1){
|
|
173
|
+
const x = left[i];
|
|
174
|
+
const y = right[i];
|
|
175
|
+
if (void 0 === x) return tailAt(right, i) && '' === prefixOf(y ?? '');
|
|
176
|
+
if (void 0 === y) return tailAt(left, i) && '' === prefixOf(x);
|
|
177
|
+
if (!segmentsMeet(x, y)) return false;
|
|
178
|
+
if (tailAt(left, i) || tailAt(right, i)) break;
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
149
182
|
function fail(why) {
|
|
150
183
|
throw new Error(`mf-manifest.json: ${why}`);
|
|
151
184
|
}
|
|
@@ -226,6 +259,107 @@ function assetsFromManifest(manifest, manifestUrl, options = {}) {
|
|
|
226
259
|
}
|
|
227
260
|
};
|
|
228
261
|
}
|
|
262
|
+
const DIST_ORIGIN = 'https://dist.invalid';
|
|
263
|
+
const SCRIPT = /\.[cm]?js$/;
|
|
264
|
+
function distPath(url) {
|
|
265
|
+
try {
|
|
266
|
+
return decodeURIComponent(new URL(url).pathname).replace(/^\//, '');
|
|
267
|
+
} catch {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function remoteDistFiles(manifest) {
|
|
272
|
+
if (!isObj(manifest) || !isObj(manifest.metaData) || !isObj(manifest.metaData.remoteEntry)) return;
|
|
273
|
+
const local = {
|
|
274
|
+
...manifest,
|
|
275
|
+
metaData: {
|
|
276
|
+
...manifest.metaData,
|
|
277
|
+
publicPath: 'auto',
|
|
278
|
+
getPublicPath: void 0,
|
|
279
|
+
globalName: void 0,
|
|
280
|
+
remoteEntry: {
|
|
281
|
+
...manifest.metaData.remoteEntry,
|
|
282
|
+
type: void 0
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
let assets;
|
|
287
|
+
try {
|
|
288
|
+
assets = assetsFromManifest(local, `${DIST_ORIGIN}/mf-manifest.json`);
|
|
289
|
+
} catch {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const entry = void 0 === assets.remoteEntry ? void 0 : distPath(assets.remoteEntry);
|
|
293
|
+
if (void 0 === entry) return;
|
|
294
|
+
const exposes = {};
|
|
295
|
+
for (const [name, urls] of Object.entries(assets.exposes))exposes[name] = urls.flatMap((url)=>distPath(url) ?? []).filter((f)=>SCRIPT.test(f));
|
|
296
|
+
return {
|
|
297
|
+
entry,
|
|
298
|
+
exposes
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const COST_MODEL = {
|
|
302
|
+
lcpDeltaMs: 300,
|
|
303
|
+
shellKb: 32,
|
|
304
|
+
remoteKb: 10
|
|
305
|
+
};
|
|
306
|
+
const KB = 1024;
|
|
307
|
+
const round1 = (value)=>Math.round(10 * value) / 10;
|
|
308
|
+
const line = (actual, budget)=>({
|
|
309
|
+
budget,
|
|
310
|
+
actual,
|
|
311
|
+
pass: actual <= budget
|
|
312
|
+
});
|
|
313
|
+
function judgeCost(sample, model = COST_MODEL) {
|
|
314
|
+
const lcp = line(Math.round(sample.lcpDeltaMs), model.lcpDeltaMs);
|
|
315
|
+
const shell = line(round1((sample.shellBytes - sample.monolithBytes) / KB), model.shellKb);
|
|
316
|
+
const remotes = Object.entries(sample.remoteBytes).toSorted(([a], [b])=>a < b ? -1 : a > b ? 1 : 0).map(([name, bytes])=>Object.assign(line(round1(bytes / KB), model.remoteKb), {
|
|
317
|
+
name
|
|
318
|
+
}));
|
|
319
|
+
return {
|
|
320
|
+
pass: lcp.pass && shell.pass && remotes.every((r)=>r.pass),
|
|
321
|
+
lcp,
|
|
322
|
+
shell,
|
|
323
|
+
remotes
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
const REMOTE_PATH = /^\/mfe\/([^/]+)\//;
|
|
327
|
+
function splitScripts(requests) {
|
|
328
|
+
let shellBytes = 0;
|
|
329
|
+
const remoteBytes = {};
|
|
330
|
+
for (const request of requests){
|
|
331
|
+
if ('Script' !== request.type) continue;
|
|
332
|
+
const remote = REMOTE_PATH.exec(request.path)?.[1];
|
|
333
|
+
if (void 0 === remote) shellBytes += request.bytes;
|
|
334
|
+
else remoteBytes[remote] = (remoteBytes[remote] ?? 0) + request.bytes;
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
shellBytes,
|
|
338
|
+
remoteBytes
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
const remote_budget_KB = 1024;
|
|
342
|
+
function primaryExpose(names) {
|
|
343
|
+
return names.includes('./App') ? './App' : names[0];
|
|
344
|
+
}
|
|
345
|
+
function judgeRemoteInitial(bytes, budgetKb, model = COST_MODEL) {
|
|
346
|
+
const totalKb = round1((bytes.entryBytes + bytes.exposeBytes) / remote_budget_KB);
|
|
347
|
+
if (void 0 !== budgetKb) return {
|
|
348
|
+
kb: totalKb,
|
|
349
|
+
totalKb,
|
|
350
|
+
limitKb: budgetKb,
|
|
351
|
+
pass: totalKb <= budgetKb,
|
|
352
|
+
source: 'mfe.json'
|
|
353
|
+
};
|
|
354
|
+
const kb = round1(bytes.entryBytes / remote_budget_KB);
|
|
355
|
+
return {
|
|
356
|
+
kb,
|
|
357
|
+
totalKb,
|
|
358
|
+
limitKb: model.remoteKb,
|
|
359
|
+
pass: kb <= model.remoteKb,
|
|
360
|
+
source: 'cost-model'
|
|
361
|
+
};
|
|
362
|
+
}
|
|
229
363
|
const SCRIPT_UNSAFE = new RegExp('[<\\u2028\\u2029]', 'g');
|
|
230
364
|
function jsonForScript(value) {
|
|
231
365
|
return JSON.stringify(value).replace(SCRIPT_UNSAFE, (c)=>`\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
@@ -237,9 +371,15 @@ const ESM_TYPES = new Set([
|
|
|
237
371
|
'module',
|
|
238
372
|
'esm'
|
|
239
373
|
]);
|
|
374
|
+
const NOT_INLINED = new Set([
|
|
375
|
+
'exposes',
|
|
376
|
+
'previous',
|
|
377
|
+
'history',
|
|
378
|
+
'rolledBackFrom'
|
|
379
|
+
]);
|
|
240
380
|
function registryInjection(index, needs, options = {}) {
|
|
241
381
|
const inline = {
|
|
242
|
-
remotes: index.remotes.map((remote)=>pickRegistryFields(remote))
|
|
382
|
+
remotes: index.remotes.map((remote)=>Object.fromEntries(Object.entries(pickRegistryFields(remote)).filter(([key])=>!NOT_INLINED.has(key))))
|
|
243
383
|
};
|
|
244
384
|
let html = `<script>window.__MFE_REGISTRY__=${jsonForScript(inline)}</script>`;
|
|
245
385
|
const seen = new Set();
|
|
@@ -307,36 +447,13 @@ function injectIntoHead(html, snippet) {
|
|
|
307
447
|
const end = -1 === charset ? open : charset;
|
|
308
448
|
return html.slice(0, end) + snippet + html.slice(end);
|
|
309
449
|
}
|
|
310
|
-
const
|
|
450
|
+
const ROUTE_OVERLAP = 'route-overlap';
|
|
311
451
|
const VERSION = /^[0-9A-Za-z.+-]+$/;
|
|
312
|
-
function
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
c.string(obj, 'type', at, {
|
|
318
|
-
required: false,
|
|
319
|
-
pattern: ENTRY_TYPE,
|
|
320
|
-
hint: `must be a Module Federation entry type: ${ENTRY_TYPES.join(', ')}`
|
|
321
|
-
});
|
|
322
|
-
c.string(obj, 'entryGlobalName', at, {
|
|
323
|
-
required: false,
|
|
324
|
-
pattern: GLOBAL_NAME,
|
|
325
|
-
hint: 'must be a JavaScript global name (letters, digits, _, $, -; not starting with a digit)'
|
|
326
|
-
});
|
|
327
|
-
const exposes = c.object(obj, 'exposes', at, false);
|
|
328
|
-
if (!exposes) return;
|
|
329
|
-
const base = at ? `${at}.exposes` : 'exposes';
|
|
330
|
-
for (const [expose, files] of Object.entries(exposes)){
|
|
331
|
-
if (!EXPOSE.test(expose)) c.add(`${base}.${expose}`, 'must look like "./App"');
|
|
332
|
-
if (!Array.isArray(files)) {
|
|
333
|
-
c.add(`${base}.${expose}`, 'must be an array of URLs');
|
|
334
|
-
continue;
|
|
335
|
-
}
|
|
336
|
-
files.forEach((file, i)=>{
|
|
337
|
-
if ('string' != typeof file || !isUrl(file)) c.add(`${base}.${expose}[${String(i)}]`, 'must be an http(s) URL');
|
|
338
|
-
});
|
|
339
|
-
}
|
|
452
|
+
function addIssues(c, issues, at) {
|
|
453
|
+
for (const issue of issues)c.add([
|
|
454
|
+
at,
|
|
455
|
+
issue.path
|
|
456
|
+
].filter(Boolean).join('.'), issue.message);
|
|
340
457
|
}
|
|
341
458
|
function validateRegistryEntry(value) {
|
|
342
459
|
const c = new Checker();
|
|
@@ -350,7 +467,7 @@ function validateRegistryEntry(value) {
|
|
|
350
467
|
});
|
|
351
468
|
const url = c.string(value, 'manifestUrl', '');
|
|
352
469
|
if (void 0 !== url && !isUrl(url)) c.add('manifestUrl', 'must be an http(s) URL');
|
|
353
|
-
|
|
470
|
+
addIssues(c, remoteFileIssues(value), '');
|
|
354
471
|
c.string(value, 'previous', '', {
|
|
355
472
|
required: false,
|
|
356
473
|
pattern: VERSION,
|
|
@@ -395,37 +512,21 @@ function validateRegistryIndex(value) {
|
|
|
395
512
|
return c.issues;
|
|
396
513
|
}
|
|
397
514
|
const names = new Set();
|
|
398
|
-
const claimed =
|
|
515
|
+
const claimed = [];
|
|
399
516
|
remotes.forEach((remote, i)=>{
|
|
400
517
|
const at = `remotes[${String(i)}]`;
|
|
401
518
|
if (!isObj(remote)) return void c.add(at, 'must be an object');
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
c.string(remote, 'owner', at, {
|
|
413
|
-
required: false
|
|
414
|
-
});
|
|
415
|
-
const routes = c.stringArray(remote, 'routes', at, {
|
|
416
|
-
required: false,
|
|
417
|
-
pattern: ROUTE,
|
|
418
|
-
hint: 'must start with "/"'
|
|
519
|
+
addIssues(c, registryRemoteIssues(remote), at);
|
|
520
|
+
const { name } = remote;
|
|
521
|
+
if ('string' != typeof name || !isRemoteName(name)) return;
|
|
522
|
+
if (names.has(name)) c.add(`${at}.name`, `duplicate remote "${name}"`);
|
|
523
|
+
names.add(name);
|
|
524
|
+
const routes = Array.isArray(remote.routes) ? remote.routes.filter((r)=>'string' == typeof r && ROUTE.test(r)) : [];
|
|
525
|
+
for (const route of routes)for (const other of claimed)if (other.remote !== name && routesOverlap(route, other.route)) c.add(`${at}.routes`, `route "${route}" of "${name}" overlaps route "${other.route}" of "${other.remote}"`, ROUTE_OVERLAP);
|
|
526
|
+
for (const route of routes)claimed.push({
|
|
527
|
+
remote: name,
|
|
528
|
+
route
|
|
419
529
|
});
|
|
420
|
-
if (void 0 !== name) {
|
|
421
|
-
if (names.has(name)) c.add(`${at}.name`, `duplicate remote "${name}"`);
|
|
422
|
-
names.add(name);
|
|
423
|
-
for (const route of routes){
|
|
424
|
-
const owner = claimed.get(route);
|
|
425
|
-
if (void 0 !== owner && owner !== name) c.add(`${at}.routes`, `route "${route}" is already claimed by "${owner}"`);
|
|
426
|
-
claimed.set(route, name);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
530
|
});
|
|
430
531
|
return c.issues;
|
|
431
532
|
}
|
|
@@ -435,6 +536,183 @@ function isRegistryEntry(value) {
|
|
|
435
536
|
function isRegistryIndex(value) {
|
|
436
537
|
return 0 === validateRegistryIndex(value).length;
|
|
437
538
|
}
|
|
539
|
+
const INDEX_VERSION = /^\d{1,9}\.\d{1,9}\.\d{1,9}(?:-[0-9A-Za-z.-]{1,64})?(?:\+[0-9A-Za-z.-]{1,64})?$/;
|
|
540
|
+
const REGISTRY_ITEM_NAME = /^[a-z][a-z0-9-]*$/;
|
|
541
|
+
const MAX_INDEX_ITEMS = 1000;
|
|
542
|
+
const MAX_DESCRIPTION = 300;
|
|
543
|
+
const MAX_KEYWORDS = 20;
|
|
544
|
+
const MAX_KEYWORD = 40;
|
|
545
|
+
const INVISIBLE = /[\p{Cc}\p{Cf}\p{Cn}\p{Co}\p{Cs}]/u;
|
|
546
|
+
const LOOPBACK_HOSTS = new Set([
|
|
547
|
+
'127.0.0.1',
|
|
548
|
+
'localhost',
|
|
549
|
+
'[::1]'
|
|
550
|
+
]);
|
|
551
|
+
function isLoopbackHttp(url) {
|
|
552
|
+
return 'http:' === url.protocol && LOOPBACK_HOSTS.has(url.hostname);
|
|
553
|
+
}
|
|
554
|
+
function isRegistryProtocol(url) {
|
|
555
|
+
return 'https:' === url.protocol || isLoopbackHttp(url);
|
|
556
|
+
}
|
|
557
|
+
const PLACEHOLDER = /\{([^{}]*)\}/g;
|
|
558
|
+
function registryTemplateProblem(template) {
|
|
559
|
+
if (!template.includes('{name}')) return 'must contain {name}';
|
|
560
|
+
const unknown = [
|
|
561
|
+
...template.matchAll(PLACEHOLDER)
|
|
562
|
+
].map((m)=>m[1] ?? '').find((name)=>'name' !== name && 'version' !== name);
|
|
563
|
+
if (void 0 !== unknown) return `has {${unknown}}; only {name} and {version} are placeholders`;
|
|
564
|
+
if (/[{}]/.test(template.replaceAll(PLACEHOLDER, ''))) return 'has an unmatched { or }';
|
|
565
|
+
const origin = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)/i.exec(template);
|
|
566
|
+
if (origin?.[1]?.includes('{') === true) return 'has a placeholder in the host; put it in the path';
|
|
567
|
+
const first = template.search(/\{(?:name|version)\}/);
|
|
568
|
+
if (null !== origin && template.lastIndexOf('/', first) < origin[0].length) return 'needs a path before the first placeholder, like https://host/{name}.json';
|
|
569
|
+
let url;
|
|
570
|
+
try {
|
|
571
|
+
url = new URL(template.replaceAll('{name}', 'x').replaceAll('{version}', '1'));
|
|
572
|
+
} catch {
|
|
573
|
+
return 'is not a URL';
|
|
574
|
+
}
|
|
575
|
+
if (!isRegistryProtocol(url)) return 'http:' === url.protocol ? 'must be https (http is allowed only for 127.0.0.1, localhost and [::1])' : 'must be an https URL';
|
|
576
|
+
if ('' !== url.username || '' !== url.password) return 'must not contain a user name or password';
|
|
577
|
+
}
|
|
578
|
+
function registryIndexUrl(template) {
|
|
579
|
+
const at = template.search(/\{(?:name|version)\}/);
|
|
580
|
+
const base = template.slice(0, template.lastIndexOf('/', at) + 1);
|
|
581
|
+
return new URL('index.json', base).href;
|
|
582
|
+
}
|
|
583
|
+
function validateFeatureRegistryIndex(value) {
|
|
584
|
+
const c = new Checker();
|
|
585
|
+
if (!isObj(value)) {
|
|
586
|
+
c.add('', 'must be an object');
|
|
587
|
+
return c.issues;
|
|
588
|
+
}
|
|
589
|
+
const items = value.items;
|
|
590
|
+
if (!Array.isArray(items)) {
|
|
591
|
+
c.add('items', 'is required and must be an array');
|
|
592
|
+
return c.issues;
|
|
593
|
+
}
|
|
594
|
+
if (items.length > MAX_INDEX_ITEMS) {
|
|
595
|
+
c.add('items', `has more than ${String(MAX_INDEX_ITEMS)} items`);
|
|
596
|
+
return c.issues;
|
|
597
|
+
}
|
|
598
|
+
const seen = new Set();
|
|
599
|
+
items.forEach((item, i)=>{
|
|
600
|
+
const at = `items[${String(i)}]`;
|
|
601
|
+
if (!isObj(item)) return void c.add(at, 'must be an object');
|
|
602
|
+
const name = c.string(item, 'name', at, {
|
|
603
|
+
pattern: REGISTRY_ITEM_NAME,
|
|
604
|
+
hint: 'must be a feature name without the namespace, like "analytics-segment"'
|
|
605
|
+
});
|
|
606
|
+
if (void 0 !== name) {
|
|
607
|
+
if (seen.has(name)) c.add(`${at}.name`, `"${name}" is listed twice`);
|
|
608
|
+
seen.add(name);
|
|
609
|
+
}
|
|
610
|
+
c.string(item, 'version', at, {
|
|
611
|
+
pattern: INDEX_VERSION,
|
|
612
|
+
hint: 'must be a version like 1.2.0'
|
|
613
|
+
});
|
|
614
|
+
const description = c.string(item, "description", at);
|
|
615
|
+
if (void 0 !== description) {
|
|
616
|
+
if (description.length > MAX_DESCRIPTION) c.add(`${at}.description`, `must be at most ${String(MAX_DESCRIPTION)} characters`);
|
|
617
|
+
else if (INVISIBLE.test(description)) c.add(`${at}.description`, 'must be plain text without control characters');
|
|
618
|
+
}
|
|
619
|
+
const keywords = c.stringArray(item, 'keywords', at, {
|
|
620
|
+
required: false
|
|
621
|
+
});
|
|
622
|
+
if (keywords.length > MAX_KEYWORDS) c.add(`${at}.keywords`, `must have at most ${String(MAX_KEYWORDS)} entries`);
|
|
623
|
+
keywords.forEach((keyword, k)=>{
|
|
624
|
+
if (keyword.length > MAX_KEYWORD || INVISIBLE.test(keyword)) c.add(`${at}.keywords[${String(k)}]`, `must be plain text of at most ${String(MAX_KEYWORD)} characters`);
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
return c.issues;
|
|
628
|
+
}
|
|
629
|
+
function isFeatureRegistryIndex(value) {
|
|
630
|
+
return 0 === validateFeatureRegistryIndex(value).length;
|
|
631
|
+
}
|
|
632
|
+
function ipv4Octets(host) {
|
|
633
|
+
const parts = host.split('.');
|
|
634
|
+
if (4 !== parts.length) return;
|
|
635
|
+
const octets = parts.map((part)=>/^\d{1,3}$/.test(part) ? Number(part) : 0 / 0);
|
|
636
|
+
return octets.every((n)=>n <= 255) ? octets : void 0;
|
|
637
|
+
}
|
|
638
|
+
function isPrivateIpv4([a = 0, b = 0, c = 0]) {
|
|
639
|
+
return 0 === a || 10 === a || 127 === a || 100 === a && b >= 64 && b <= 127 || 169 === a && 254 === b || 172 === a && b >= 16 && b <= 31 || 192 === a && 168 === b || 192 === a && 0 === b && 0 === c;
|
|
640
|
+
}
|
|
641
|
+
const side = (part)=>'' === part ? [] : part.split(':');
|
|
642
|
+
function ipv6Groups(host) {
|
|
643
|
+
const [head = '', tail, ...rest] = host.split('::');
|
|
644
|
+
if (rest.length > 0) return;
|
|
645
|
+
const headGroups = side(head);
|
|
646
|
+
const groups = [
|
|
647
|
+
...headGroups,
|
|
648
|
+
...void 0 === tail ? [] : side(tail)
|
|
649
|
+
];
|
|
650
|
+
if (groups.some((g)=>!/^[0-9a-f]{1,4}$/.test(g))) return;
|
|
651
|
+
const numbers = groups.map((g)=>Number.parseInt(g, 16));
|
|
652
|
+
if (void 0 === tail) return 8 === numbers.length ? numbers : void 0;
|
|
653
|
+
const missing = 8 - numbers.length;
|
|
654
|
+
if (missing < 1) return;
|
|
655
|
+
return [
|
|
656
|
+
...numbers.slice(0, headGroups.length),
|
|
657
|
+
...Array.from({
|
|
658
|
+
length: missing
|
|
659
|
+
}, ()=>0),
|
|
660
|
+
...numbers.slice(headGroups.length)
|
|
661
|
+
];
|
|
662
|
+
}
|
|
663
|
+
const embedded = (hi = 0, lo = 0)=>[
|
|
664
|
+
hi >> 8,
|
|
665
|
+
0xff & hi,
|
|
666
|
+
lo >> 8,
|
|
667
|
+
0xff & lo
|
|
668
|
+
];
|
|
669
|
+
function isPrivateIpv6(host) {
|
|
670
|
+
const groups = ipv6Groups(host);
|
|
671
|
+
if (void 0 === groups) return false;
|
|
672
|
+
const [g0 = 0, g1 = 0] = groups;
|
|
673
|
+
const zeros = (from, to)=>groups.slice(from, to).every((g)=>0 === g);
|
|
674
|
+
if (zeros(0, 6)) return isPrivateIpv4(embedded(groups[6], groups[7]));
|
|
675
|
+
if ((0xfe00 & g0) === 0xfc00 || (0xffc0 & g0) === 0xfe80 || (0xffc0 & g0) === 0xfec0) return true;
|
|
676
|
+
const mapped = zeros(0, 5) && 0xffff === groups[5];
|
|
677
|
+
const nat64 = 0x64 === g0 && 0xff9b === g1 && zeros(2, 6);
|
|
678
|
+
if (mapped || nat64) return isPrivateIpv4(embedded(groups[6], groups[7]));
|
|
679
|
+
return 0x2002 === g0 && isPrivateIpv4(embedded(groups[1], groups[2]));
|
|
680
|
+
}
|
|
681
|
+
function isPrivateHost(hostname) {
|
|
682
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
|
683
|
+
if ('localhost' === host || /\.(?:localhost|internal|local)$/.test(host)) return true;
|
|
684
|
+
if (host.includes(':')) return isPrivateIpv6(host);
|
|
685
|
+
const octets = ipv4Octets(host);
|
|
686
|
+
return void 0 !== octets && isPrivateIpv4(octets);
|
|
687
|
+
}
|
|
688
|
+
const SECRET_PARAM = /token|key|sig|auth|passw|secret|credential|x-amz-|(?:^|[_-])(?:jwt|bearer|session|pat|access|code)(?:$|[_-])/i;
|
|
689
|
+
const URL_PARTS = /^([a-z][a-z0-9+.-]*:\/\/)([^/?#@]*@)?([^?#]*)(\?[^#]*)?(#.*)?$/i;
|
|
690
|
+
function safeDecode(value) {
|
|
691
|
+
try {
|
|
692
|
+
return decodeURIComponent(value);
|
|
693
|
+
} catch {
|
|
694
|
+
return value;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
function redactUrl(url) {
|
|
698
|
+
const parts = URL_PARTS.exec(url);
|
|
699
|
+
if (null === parts) return url;
|
|
700
|
+
const [, scheme = '', userinfo, rest = '', query, hash] = parts;
|
|
701
|
+
const search = void 0 === query ? '' : `?${hideSecrets(query.slice(1))}`;
|
|
702
|
+
const fragment = void 0 === hash ? '' : `#${hideSecrets(hash.slice(1))}`;
|
|
703
|
+
return `${scheme}${void 0 === userinfo ? '' : '***@'}${rest}${search}${fragment}`;
|
|
704
|
+
}
|
|
705
|
+
function hideSecrets(params) {
|
|
706
|
+
return params.split(/([&;])/).map((part)=>{
|
|
707
|
+
const eq = part.indexOf('=');
|
|
708
|
+
if (eq <= 0) return part;
|
|
709
|
+
const key = part.slice(0, eq);
|
|
710
|
+
return SECRET_PARAM.test(safeDecode(key)) ? `${key}=***` : part;
|
|
711
|
+
}).join('');
|
|
712
|
+
}
|
|
713
|
+
function redactUrlsIn(text) {
|
|
714
|
+
return text.replaceAll(/https?:\/\/[^\s"'<>]+/gi, (found)=>redactUrl(found));
|
|
715
|
+
}
|
|
438
716
|
const project_SEGMENT = String.raw`(?!\.\.?(?:/|$))[^/\\\u0000]+`;
|
|
439
717
|
const CHECKOUT_PATH = new RegExp(`^(?:(?![A-Za-z]:)(?:\\.\\.(?:/\\.\\.)*(?:/${project_SEGMENT})*|${project_SEGMENT}(?:/${project_SEGMENT})*)|(?:/|[A-Za-z]:/)${project_SEGMENT}(?:/${project_SEGMENT})*)$`);
|
|
440
718
|
const FILE_HASH = /^sha256-[0-9a-f]{64}$/;
|
|
@@ -452,7 +730,14 @@ function isCheckoutPath(value) {
|
|
|
452
730
|
const rest = -1 === climbing ? [] : parts.slice(climbing);
|
|
453
731
|
return rest.every((part)=>'' !== part && '.' !== part && '..' !== part);
|
|
454
732
|
}
|
|
733
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
734
|
+
const REGISTRY_HOST = /^(?:file:\S.*|s3:\/\/[a-z0-9][a-z0-9.-]{1,61}[a-z0-9](?:\/\S*)?)$/;
|
|
455
735
|
const SCOPE = /^@[a-z0-9][a-z0-9-._]*$/;
|
|
736
|
+
const PROJECT_REPOS = [
|
|
737
|
+
'platform',
|
|
738
|
+
'shell',
|
|
739
|
+
'remote'
|
|
740
|
+
];
|
|
456
741
|
const FEATURE = /^(@[a-z0-9-]+\/)?[a-z][a-z0-9-]*$/;
|
|
457
742
|
function validateMfeflowProject(value) {
|
|
458
743
|
const c = new Checker();
|
|
@@ -461,6 +746,18 @@ function validateMfeflowProject(value) {
|
|
|
461
746
|
return c.issues;
|
|
462
747
|
}
|
|
463
748
|
if (1 !== value.version) c.add('version', 'must be 1');
|
|
749
|
+
if ('cli' in value) c.string(value, 'cli', '', {
|
|
750
|
+
required: false,
|
|
751
|
+
pattern: SEMVER,
|
|
752
|
+
hint: 'must be a version like "0.5.0"'
|
|
753
|
+
});
|
|
754
|
+
let repo;
|
|
755
|
+
if ('repo' in value) {
|
|
756
|
+
repo = c.string(value, 'repo', '', {
|
|
757
|
+
required: false
|
|
758
|
+
});
|
|
759
|
+
if (void 0 !== repo && !PROJECT_REPOS.some((name)=>name === repo)) c.add('repo', 'must be platform, shell or remote');
|
|
760
|
+
}
|
|
464
761
|
const topology = c.string(value, 'topology', '');
|
|
465
762
|
if (void 0 !== topology && ![
|
|
466
763
|
'monorepo',
|
|
@@ -497,7 +794,7 @@ function validateMfeflowProject(value) {
|
|
|
497
794
|
});
|
|
498
795
|
if (void 0 !== port && !Number.isInteger(port)) c.add(`${at}.port`, 'must be a whole number');
|
|
499
796
|
}
|
|
500
|
-
if (0 === shells) c.add('apps', 'needs at least one shell');
|
|
797
|
+
if (0 === shells && 'remote' !== repo) c.add('apps', 'needs at least one shell');
|
|
501
798
|
}
|
|
502
799
|
const features = c.object(value, 'features', '');
|
|
503
800
|
if (features) for (const [name, feature] of Object.entries(features)){
|
|
@@ -506,6 +803,10 @@ function validateMfeflowProject(value) {
|
|
|
506
803
|
if (isObj(feature)) {
|
|
507
804
|
c.string(feature, 'version', at);
|
|
508
805
|
c.object(feature, 'options', at, false);
|
|
806
|
+
if ('integrity' in feature) {
|
|
807
|
+
const integrity = c.string(feature, 'integrity', at);
|
|
808
|
+
if (void 0 !== integrity && !FILE_HASH.test(integrity)) c.add(`${at}.integrity`, 'must be a hash like "sha256-<64 hex characters>"');
|
|
809
|
+
}
|
|
509
810
|
} else c.add(at, 'must be an object');
|
|
510
811
|
}
|
|
511
812
|
const files = c.object(value, 'files', '', false);
|
|
@@ -519,7 +820,11 @@ function validateMfeflowProject(value) {
|
|
|
519
820
|
const at = `registries.${ns}`;
|
|
520
821
|
if ('@mfeflow' === ns) c.add(at, '@mfeflow is reserved for first-party features');
|
|
521
822
|
else if (!REGISTRY_NAMESPACE.test(ns)) c.add(at, 'namespaces look like "@someone"');
|
|
522
|
-
if ('string' != typeof url
|
|
823
|
+
if ('string' != typeof url) c.add(at, 'must be a URL template like "https://example.com/{name}.json"');
|
|
824
|
+
else {
|
|
825
|
+
const problem = registryTemplateProblem(url);
|
|
826
|
+
if (void 0 !== problem) c.add(at, problem);
|
|
827
|
+
}
|
|
523
828
|
}
|
|
524
829
|
if ('local' in value) {
|
|
525
830
|
const local = c.string(value, 'local', '');
|
|
@@ -535,51 +840,27 @@ function validateMfeflowProject(value) {
|
|
|
535
840
|
required: false,
|
|
536
841
|
min: 1
|
|
537
842
|
});
|
|
843
|
+
const registry = c.object(value, 'registry', '', false);
|
|
844
|
+
if (registry) {
|
|
845
|
+
c.string(registry, 'host', 'registry', {
|
|
846
|
+
required: false,
|
|
847
|
+
pattern: REGISTRY_HOST,
|
|
848
|
+
hint: 'must be file:<dir> or s3://<bucket>[/prefix], with no credentials'
|
|
849
|
+
});
|
|
850
|
+
const baseUrl = c.string(registry, 'baseUrl', 'registry', {
|
|
851
|
+
required: false
|
|
852
|
+
});
|
|
853
|
+
if (void 0 !== baseUrl && !(isUrl(baseUrl) && validBaseUrl(baseUrl))) c.add('registry.baseUrl', 'must be an https URL (http only on localhost) without credentials, a query, a fragment or a trailing slash');
|
|
854
|
+
}
|
|
538
855
|
return c.issues;
|
|
539
856
|
}
|
|
857
|
+
function validBaseUrl(value) {
|
|
858
|
+
const url = new URL(value);
|
|
859
|
+
return isRegistryProtocol(url) && '' === url.username && '' === url.password && !/[?#]/.test(value) && !value.endsWith('/');
|
|
860
|
+
}
|
|
540
861
|
function isMfeflowProject(value) {
|
|
541
862
|
return 0 === validateMfeflowProject(value).length;
|
|
542
863
|
}
|
|
543
|
-
const COST_MODEL = {
|
|
544
|
-
lcpDeltaMs: 300,
|
|
545
|
-
shellKb: 32,
|
|
546
|
-
remoteKb: 10
|
|
547
|
-
};
|
|
548
|
-
const KB = 1024;
|
|
549
|
-
const round1 = (value)=>Math.round(10 * value) / 10;
|
|
550
|
-
const line = (actual, budget)=>({
|
|
551
|
-
budget,
|
|
552
|
-
actual,
|
|
553
|
-
pass: actual <= budget
|
|
554
|
-
});
|
|
555
|
-
function judgeCost(sample, model = COST_MODEL) {
|
|
556
|
-
const lcp = line(Math.round(sample.lcpDeltaMs), model.lcpDeltaMs);
|
|
557
|
-
const shell = line(round1((sample.shellBytes - sample.monolithBytes) / KB), model.shellKb);
|
|
558
|
-
const remotes = Object.entries(sample.remoteBytes).toSorted(([a], [b])=>a < b ? -1 : a > b ? 1 : 0).map(([name, bytes])=>Object.assign(line(round1(bytes / KB), model.remoteKb), {
|
|
559
|
-
name
|
|
560
|
-
}));
|
|
561
|
-
return {
|
|
562
|
-
pass: lcp.pass && shell.pass && remotes.every((r)=>r.pass),
|
|
563
|
-
lcp,
|
|
564
|
-
shell,
|
|
565
|
-
remotes
|
|
566
|
-
};
|
|
567
|
-
}
|
|
568
|
-
const REMOTE_PATH = /^\/mfe\/([^/]+)\//;
|
|
569
|
-
function splitScripts(requests) {
|
|
570
|
-
let shellBytes = 0;
|
|
571
|
-
const remoteBytes = {};
|
|
572
|
-
for (const request of requests){
|
|
573
|
-
if ('Script' !== request.type) continue;
|
|
574
|
-
const remote = REMOTE_PATH.exec(request.path)?.[1];
|
|
575
|
-
if (void 0 === remote) shellBytes += request.bytes;
|
|
576
|
-
else remoteBytes[remote] = (remoteBytes[remote] ?? 0) + request.bytes;
|
|
577
|
-
}
|
|
578
|
-
return {
|
|
579
|
-
shellBytes,
|
|
580
|
-
remoteBytes
|
|
581
|
-
};
|
|
582
|
-
}
|
|
583
864
|
const TRANSFORM_OPS = [
|
|
584
865
|
'add-import',
|
|
585
866
|
'wrap-provider',
|
|
@@ -789,6 +1070,9 @@ function validateFeatureManifest(value) {
|
|
|
789
1070
|
c.string(value, "description", '', {
|
|
790
1071
|
required: false
|
|
791
1072
|
});
|
|
1073
|
+
c.stringArray(value, 'keywords', '', {
|
|
1074
|
+
required: false
|
|
1075
|
+
});
|
|
792
1076
|
c.stringArray(value, 'requires', '', {
|
|
793
1077
|
required: false,
|
|
794
1078
|
pattern: FEATURE_NAME
|
|
@@ -868,7 +1152,8 @@ const SCHEMA_URLS = {
|
|
|
868
1152
|
project: `${DOCS_URL}/schema/mfeflow.json`,
|
|
869
1153
|
feature: `${DOCS_URL}/schema/feature.json`,
|
|
870
1154
|
registryEntry: `${DOCS_URL}/schema/registry-entry.json`,
|
|
871
|
-
registryIndex: `${DOCS_URL}/schema/registry-index.json
|
|
1155
|
+
registryIndex: `${DOCS_URL}/schema/registry-index.json`,
|
|
1156
|
+
featureRegistryIndex: `${DOCS_URL}/schema/feature-registry-index.json`
|
|
872
1157
|
};
|
|
873
1158
|
function errorDocsUrl(code) {
|
|
874
1159
|
return `${DOCS_URL}/errors/${code}`;
|
|
@@ -884,6 +1169,7 @@ const json_schema_strings = (pattern)=>({
|
|
|
884
1169
|
type: 'array',
|
|
885
1170
|
items: str(pattern)
|
|
886
1171
|
});
|
|
1172
|
+
const REGISTRY_URL = /^(?:https:\/\/|http:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?:[:/?#])).*\{name\}/;
|
|
887
1173
|
const literal = {
|
|
888
1174
|
type: [
|
|
889
1175
|
'string',
|
|
@@ -1164,6 +1450,12 @@ const JSON_SCHEMAS = {
|
|
|
1164
1450
|
version: {
|
|
1165
1451
|
const: 1
|
|
1166
1452
|
},
|
|
1453
|
+
cli: str(SEMVER),
|
|
1454
|
+
repo: {
|
|
1455
|
+
enum: [
|
|
1456
|
+
...PROJECT_REPOS
|
|
1457
|
+
]
|
|
1458
|
+
},
|
|
1167
1459
|
topology: {
|
|
1168
1460
|
enum: [
|
|
1169
1461
|
'monorepo',
|
|
@@ -1207,7 +1499,8 @@ const JSON_SCHEMAS = {
|
|
|
1207
1499
|
version: str(),
|
|
1208
1500
|
options: {
|
|
1209
1501
|
type: 'object'
|
|
1210
|
-
}
|
|
1502
|
+
},
|
|
1503
|
+
integrity: str(FILE_HASH)
|
|
1211
1504
|
}, [
|
|
1212
1505
|
'version'
|
|
1213
1506
|
])
|
|
@@ -1228,7 +1521,7 @@ const JSON_SCHEMAS = {
|
|
|
1228
1521
|
},
|
|
1229
1522
|
additionalProperties: {
|
|
1230
1523
|
type: 'string',
|
|
1231
|
-
pattern:
|
|
1524
|
+
pattern: REGISTRY_URL.source
|
|
1232
1525
|
}
|
|
1233
1526
|
},
|
|
1234
1527
|
local: str(CHECKOUT_PATH),
|
|
@@ -1246,6 +1539,16 @@ const JSON_SCHEMAS = {
|
|
|
1246
1539
|
minimum: 1
|
|
1247
1540
|
}
|
|
1248
1541
|
]))
|
|
1542
|
+
},
|
|
1543
|
+
registry: {
|
|
1544
|
+
type: 'object',
|
|
1545
|
+
properties: {
|
|
1546
|
+
host: str(REGISTRY_HOST),
|
|
1547
|
+
baseUrl: {
|
|
1548
|
+
type: 'string',
|
|
1549
|
+
pattern: String.raw`^(?:https://[^@\s?#]*[^/@\s?#]|http://(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?(?:/[^@\s?#]*[^/@\s?#])?)$`
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1249
1552
|
}
|
|
1250
1553
|
},
|
|
1251
1554
|
required: [
|
|
@@ -1262,6 +1565,7 @@ const JSON_SCHEMAS = {
|
|
|
1262
1565
|
name: str(FEATURE_NAME),
|
|
1263
1566
|
version: str(),
|
|
1264
1567
|
description: str(),
|
|
1568
|
+
keywords: json_schema_strings(),
|
|
1265
1569
|
prompts: {
|
|
1266
1570
|
type: 'array',
|
|
1267
1571
|
items: json_schema_item({
|
|
@@ -1408,7 +1712,10 @@ const JSON_SCHEMAS = {
|
|
|
1408
1712
|
...remoteFiles,
|
|
1409
1713
|
version: str(),
|
|
1410
1714
|
routes: json_schema_strings(ROUTE),
|
|
1411
|
-
owner: str()
|
|
1715
|
+
owner: str(),
|
|
1716
|
+
previous: str(),
|
|
1717
|
+
history: json_schema_strings(),
|
|
1718
|
+
rolledBackFrom: str()
|
|
1412
1719
|
}, [
|
|
1413
1720
|
'name',
|
|
1414
1721
|
'entry'
|
|
@@ -1418,6 +1725,38 @@ const JSON_SCHEMAS = {
|
|
|
1418
1725
|
required: [
|
|
1419
1726
|
'remotes'
|
|
1420
1727
|
]
|
|
1728
|
+
}),
|
|
1729
|
+
featureRegistryIndex: root('featureRegistryIndex', 'feature registry index', {
|
|
1730
|
+
properties: {
|
|
1731
|
+
items: {
|
|
1732
|
+
type: 'array',
|
|
1733
|
+
maxItems: 1000,
|
|
1734
|
+
items: json_schema_item({
|
|
1735
|
+
name: str(REGISTRY_ITEM_NAME),
|
|
1736
|
+
version: str(),
|
|
1737
|
+
description: {
|
|
1738
|
+
type: 'string',
|
|
1739
|
+
maxLength: 300
|
|
1740
|
+
},
|
|
1741
|
+
keywords: {
|
|
1742
|
+
type: 'array',
|
|
1743
|
+
maxItems: 20,
|
|
1744
|
+
items: {
|
|
1745
|
+
type: 'string',
|
|
1746
|
+
maxLength: 40
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
}, [
|
|
1750
|
+
'name',
|
|
1751
|
+
'version',
|
|
1752
|
+
"description"
|
|
1753
|
+
])
|
|
1754
|
+
}
|
|
1755
|
+
},
|
|
1756
|
+
required: [
|
|
1757
|
+
'items'
|
|
1758
|
+
]
|
|
1421
1759
|
})
|
|
1422
1760
|
};
|
|
1423
|
-
export {
|
|
1761
|
+
export { EXPOSE, ROUTE, isRegistryIndexRemote, registryRemoteIssues } from "./entry.js";
|
|
1762
|
+
export { ANALYTICS_EVENT, COST_MODEL, DOCS_URL, EVENT_NAME, FEATURE_CONDITION, FEATURE_NAME, FILE_HASH, JSON_SCHEMAS, MAX_DESCRIPTION, MAX_INDEX_ITEMS, MFE_NAME, PROJECT_REPOS, REGISTRY_HOST, REGISTRY_ITEM_NAME, REGISTRY_NAMESPACE, ROUTE_OVERLAP, SCHEMA_URLS, TRANSFORM_OPS, assetsFromManifest, errorDocsUrl, injectIntoHead, isFeatureManifest, isFeatureRegistryIndex, isLoopbackHttp, isMfeManifest, isMfeflowProject, isPortableRelativePath, isPrivateHost, isRegistryEntry, isRegistryIndex, isRegistryProtocol, isSafeRelativePath, judgeCost, judgeRemoteInitial, primaryExpose, redactUrl, redactUrlsIn, registryIndexUrl, registryInjection, registryTemplateProblem, remoteDistFiles, round1, routesOverlap, splitScripts, validateFeatureManifest, validateFeatureRegistryIndex, validateMfeManifest, validateMfeflowProject, validateRegistryEntry, validateRegistryIndex };
|
package/dist/json-schema.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type SchemaName } from './docs.js';
|
|
2
2
|
type Json = Record<string, unknown>;
|
|
3
3
|
/**
|
|
4
|
-
* Editor schemas for the
|
|
4
|
+
* Editor schemas for the six JSON documents mfeflow reads. They mirror the validators in this package; the
|
|
5
5
|
* validators stay the authority (they also check things a schema cannot, like duplicate routes), so a document
|
|
6
6
|
* can pass here and still fail `mfeflow validate`.
|
|
7
7
|
*/
|
package/dist/mfe-manifest.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { EXPOSE, ROUTE } from './entry.js';
|
|
1
2
|
import { type Issue } from './validate.js';
|
|
3
|
+
export { EXPOSE, ROUTE };
|
|
2
4
|
/** `mfe.json`: who owns a micro-frontend and what it promises. */
|
|
3
5
|
export interface MfeManifest {
|
|
4
6
|
$schema?: string;
|
|
@@ -32,8 +34,6 @@ export interface MfeManifest {
|
|
|
32
34
|
* cannot become the same component or global name.
|
|
33
35
|
*/
|
|
34
36
|
export declare const MFE_NAME: RegExp;
|
|
35
|
-
export declare const ROUTE: RegExp;
|
|
36
|
-
export declare const EXPOSE: RegExp;
|
|
37
37
|
/** `<domain>.<event>.v<major>`, e.g. `cart.item-added.v1`. The same pattern `@mfeflow/event-bus` checks names against. */
|
|
38
38
|
export declare const EVENT_NAME: RegExp;
|
|
39
39
|
/** `<domain>_<object>_<action>`, e.g. `checkout_order_completed`. */
|
package/dist/project.d.ts
CHANGED
|
@@ -3,6 +3,10 @@ import { type Issue } from './validate.js';
|
|
|
3
3
|
export interface MfeflowProject {
|
|
4
4
|
$schema?: string;
|
|
5
5
|
version: 1;
|
|
6
|
+
/** Version of the CLI that last wrote this file. */
|
|
7
|
+
cli?: string;
|
|
8
|
+
/** Which repo this is in a multi-repo setup. Absent means a platform monorepo. */
|
|
9
|
+
repo?: 'platform' | 'shell' | 'remote';
|
|
6
10
|
topology: 'monorepo' | 'polyrepo' | 'hybrid';
|
|
7
11
|
packageManager: 'pnpm' | 'npm';
|
|
8
12
|
/** npm scope for the project's own packages, e.g. `@shopco`. */
|
|
@@ -15,15 +19,25 @@ export interface MfeflowProject {
|
|
|
15
19
|
features: Record<string, {
|
|
16
20
|
version: string;
|
|
17
21
|
options?: Record<string, unknown>;
|
|
22
|
+
/** `sha256-<hex>` of the `feature.json` a registry served when the feature was added. Third-party features only. */
|
|
23
|
+
integrity?: string;
|
|
18
24
|
}>;
|
|
19
25
|
/** Hashes of copied files, so `upgrade` can tell when a file was edited. */
|
|
20
26
|
files?: Record<string, string>;
|
|
21
|
-
/** Third-party feature registries: `{ "@someone": "https://…/{name}.json" }
|
|
27
|
+
/** Third-party feature registries: `{ "@someone": "https://…/{name}.json" }`, https only (plain http for loopback hosts). `@mfeflow` is reserved. */
|
|
22
28
|
registries?: Record<string, string>;
|
|
23
29
|
/** Built mfeflow checkout, relative to the project root, that `@mfeflow/*` dependencies link to. Set by `--local` only. */
|
|
24
30
|
local?: string;
|
|
25
31
|
/** Overrides of the cost-model budget `mfeflow budget` and the perf job check. */
|
|
26
32
|
budget?: ProjectBudget;
|
|
33
|
+
/** Where `mfeflow deploy`, `promote`, `rollback` and `status` publish by default. */
|
|
34
|
+
registry?: ProjectRegistry;
|
|
35
|
+
}
|
|
36
|
+
export interface ProjectRegistry {
|
|
37
|
+
/** `file:<dir>` for a folder, or `s3://<bucket>[/prefix]`. Credentials never go here; they come from the environment. */
|
|
38
|
+
host?: string;
|
|
39
|
+
/** Where the host's files are served from (`https://cdn.example.com`), without a trailing slash. */
|
|
40
|
+
baseUrl?: string;
|
|
27
41
|
}
|
|
28
42
|
export interface ProjectBudget {
|
|
29
43
|
/** JS the shell may add over a one-app build, in KB. Default 32. */
|
|
@@ -41,6 +55,10 @@ export declare const FILE_HASH: RegExp;
|
|
|
41
55
|
export declare const REGISTRY_NAMESPACE: RegExp;
|
|
42
56
|
/** A relative path with forward slashes whose only `..` segments come first, so it may leave the project. */
|
|
43
57
|
export declare function isCheckoutPath(value: string): boolean;
|
|
58
|
+
export declare const SEMVER: RegExp;
|
|
59
|
+
/** `file:<dir>` or `s3://<bucket>[/prefix]`. No credentials: `@` and `:` cannot appear in a bucket name. */
|
|
60
|
+
export declare const REGISTRY_HOST: RegExp;
|
|
44
61
|
export declare const SCOPE: RegExp;
|
|
62
|
+
export declare const PROJECT_REPOS: readonly ['platform', 'shell', 'remote'];
|
|
45
63
|
export declare function validateMfeflowProject(value: unknown): Issue[];
|
|
46
64
|
export declare function isMfeflowProject(value: unknown): value is MfeflowProject;
|
package/dist/registry.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { isRegistryIndexRemote, registryRemoteIssues, type RegistryIndex, type RegistryIndexRemote } from './entry.js';
|
|
1
2
|
import { type Issue } from './validate.js';
|
|
3
|
+
export { isRegistryIndexRemote, registryRemoteIssues, type RegistryIndex, type RegistryIndexRemote, };
|
|
2
4
|
/** `registry/envs/<env>/<name>.json`: what one remote serves in one environment. */
|
|
3
5
|
export interface RegistryEntry {
|
|
4
6
|
version: string;
|
|
@@ -19,29 +21,8 @@ export interface RegistryEntry {
|
|
|
19
21
|
percent: number;
|
|
20
22
|
};
|
|
21
23
|
}
|
|
22
|
-
/**
|
|
23
|
-
export
|
|
24
|
-
shell?: string;
|
|
25
|
-
env?: string;
|
|
26
|
-
generatedAt?: string;
|
|
27
|
-
remotes: RegistryIndexRemote[];
|
|
28
|
-
}
|
|
29
|
-
export interface RegistryIndexRemote {
|
|
30
|
-
name: string;
|
|
31
|
-
/** `mf-manifest.json` URL. */
|
|
32
|
-
entry: string;
|
|
33
|
-
/** Entry script URL; shells register it in place of `entry` when present. */
|
|
34
|
-
remoteEntry?: string;
|
|
35
|
-
/** How `remoteEntry` loads (`module` for ESM). */
|
|
36
|
-
type?: string;
|
|
37
|
-
/** The global `remoteEntry` defines when it differs from `name`. */
|
|
38
|
-
entryGlobalName?: string;
|
|
39
|
-
/** Per expose, the files to preload. Not inlined into the shell's HTML. */
|
|
40
|
-
exposes?: Record<string, string[]>;
|
|
41
|
-
version?: string;
|
|
42
|
-
routes?: string[];
|
|
43
|
-
owner?: string;
|
|
44
|
-
}
|
|
24
|
+
/** The `code` of the issues `validateRegistryIndex` reports for overlapping routes. */
|
|
25
|
+
export declare const ROUTE_OVERLAP = "route-overlap";
|
|
45
26
|
export declare const VERSION: RegExp;
|
|
46
27
|
export declare function validateRegistryEntry(value: unknown): Issue[];
|
|
47
28
|
export declare function validateRegistryIndex(value: unknown): Issue[];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type CostModel } from './cost-model.js';
|
|
2
|
+
/** The expose a remote's route loads first: `./App`, else the first one. */
|
|
3
|
+
export declare function primaryExpose(names: readonly string[]): string | undefined;
|
|
4
|
+
export interface RemoteInitialVerdict {
|
|
5
|
+
/** Gzip KB that were judged, one decimal: the entry plus the primary expose, or the entry alone without an `mfe.json` budget. */
|
|
6
|
+
kb: number;
|
|
7
|
+
/** Gzip KB of the entry plus the primary expose, whichever was judged. */
|
|
8
|
+
totalKb: number;
|
|
9
|
+
limitKb: number;
|
|
10
|
+
pass: boolean;
|
|
11
|
+
/** Where the limit came from: the remote's `mfe.json` budget or the cost model. */
|
|
12
|
+
source: 'mfe.json' | 'cost-model';
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Judges the JS a remote loads first. With an `mfe.json` `budgets.initialJsKb` the entry plus the
|
|
16
|
+
* primary expose must fit it; without one the entry alone must fit the cost model's `remoteKb`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function judgeRemoteInitial(bytes: {
|
|
19
|
+
entryBytes: number;
|
|
20
|
+
exposeBytes: number;
|
|
21
|
+
}, budgetKb: number | undefined, model?: CostModel): RemoteInitialVerdict;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface RemoteDistFiles {
|
|
2
|
+
/** The entry script, as a path inside the build folder. */
|
|
3
|
+
entry: string;
|
|
4
|
+
/** expose (`./App`) → the JS files it loads before it renders, as paths inside the build folder. */
|
|
5
|
+
exposes: Record<string, string[]>;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The files a remote's build folder holds for its entry and each expose's synchronous JS, read from
|
|
9
|
+
* its `mf-manifest.json`. Paths resolve against the folder whatever `publicPath` says about where the
|
|
10
|
+
* files are served. Undefined when the manifest has no usable entry.
|
|
11
|
+
*/
|
|
12
|
+
export declare function remoteDistFiles(manifest: unknown): RemoteDistFiles | undefined;
|
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True when some URL path matches both routes. Routes compare case-insensitively and ignore a
|
|
3
|
+
* trailing slash. A `*` on the last segment (`/checkout/*`, `/checkout*`, `/:rest*`) matches any
|
|
4
|
+
* tail, including none; anywhere else it stays inside one segment. `:param` matches one segment.
|
|
5
|
+
*/
|
|
6
|
+
export declare function routesOverlap(a: string, b: string): boolean;
|
package/dist/validate.d.ts
CHANGED
|
@@ -2,12 +2,14 @@ export interface Issue {
|
|
|
2
2
|
/** Where, e.g. `routes[0]` or `rollout.percent`. Empty for the root. */
|
|
3
3
|
path: string;
|
|
4
4
|
message: string;
|
|
5
|
+
/** Set on issues a caller may want to tell apart, e.g. `route-overlap`. */
|
|
6
|
+
code?: string;
|
|
5
7
|
}
|
|
6
8
|
export type Obj = Record<string, unknown>;
|
|
7
9
|
export declare function isObj(value: unknown): value is Obj;
|
|
8
10
|
export declare class Checker {
|
|
9
11
|
readonly issues: Issue[];
|
|
10
|
-
add(path: string, message: string): void;
|
|
12
|
+
add(path: string, message: string, code?: string): void;
|
|
11
13
|
/** Reports a missing or wrong-typed string and returns it when valid. */
|
|
12
14
|
string(obj: Obj, key: string, path: string, opts?: {
|
|
13
15
|
required?: boolean;
|
|
@@ -30,3 +32,8 @@ export declare function isUrl(value: string): boolean;
|
|
|
30
32
|
/** The regex form of `isSafeRelativePath`, for JSON Schema. */
|
|
31
33
|
export declare const SAFE_RELATIVE_PATH: RegExp;
|
|
32
34
|
export declare function isSafeRelativePath(value: string): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* `isSafeRelativePath` plus what a file on any OS can be named: no `:` (drive letters, NTFS
|
|
37
|
+
* streams), no Windows device names (`CON`, `nul.txt`, `COM1`) and no segment ending in a dot or space.
|
|
38
|
+
*/
|
|
39
|
+
export declare function isPortableRelativePath(value: string): boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mfeflow/contracts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Types, validators and JSON Schemas for mfeflow: mfe.json ownership manifests, mfeflow.json project state, and the remote registry (entries and index).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"micro-frontend",
|