@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 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[];
@@ -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
- * stay out of the page.
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 { FILE_HASH, isMfeflowProject, validateMfeflowProject, type MfeflowProject, type ProjectBudget, } from './project.js';
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 { ENTRY_TYPES, GLOBAL_NAME, pickRegistryFields } from "./entry.js";
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 ENTRY_TYPE = new RegExp(`^(?:${ENTRY_TYPES.join('|')})$`);
450
+ const ROUTE_OVERLAP = 'route-overlap';
311
451
  const VERSION = /^[0-9A-Za-z.+-]+$/;
312
- function checkRemoteFiles(c, obj, at) {
313
- const entry = c.string(obj, 'remoteEntry', at, {
314
- required: false
315
- });
316
- if (void 0 !== entry && !isUrl(entry)) c.add(at ? `${at}.remoteEntry` : 'remoteEntry', 'must be an http(s) URL');
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
- checkRemoteFiles(c, value, '');
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 = new Map();
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
- const name = c.string(remote, 'name', at, {
403
- pattern: MFE_NAME,
404
- hint: 'must be a Module Federation name'
405
- });
406
- const entry = c.string(remote, 'entry', at);
407
- if (void 0 !== entry && !isUrl(entry)) c.add(`${at}.entry`, 'must be an http(s) URL');
408
- checkRemoteFiles(c, remote, at);
409
- c.string(remote, 'version', at, {
410
- required: false
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 || !url.startsWith('https://') || !url.includes('{name}')) c.add(at, 'must be an https URL containing {name}');
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: '^https://.*\\{name\\}'
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 { ANALYTICS_EVENT, COST_MODEL, DOCS_URL, EVENT_NAME, EXPOSE, FEATURE_CONDITION, FEATURE_NAME, FILE_HASH, JSON_SCHEMAS, MFE_NAME, ROUTE, SCHEMA_URLS, TRANSFORM_OPS, assetsFromManifest, errorDocsUrl, injectIntoHead, isFeatureManifest, isMfeManifest, isMfeflowProject, isRegistryEntry, isRegistryIndex, isSafeRelativePath, judgeCost, registryInjection, round1, splitScripts, validateFeatureManifest, validateMfeManifest, validateMfeflowProject, validateRegistryEntry, validateRegistryIndex };
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 };
@@ -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 five JSON documents mfeflow reads. They mirror the validators in this package; 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
  */
@@ -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" }`. `@mfeflow` is reserved. */
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;
@@ -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
- /** What the shell fetches at boot. */
23
- export interface RegistryIndex {
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;
@@ -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;
@@ -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.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",