@mfeflow/contracts 0.4.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/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 };
@@ -29,3 +29,18 @@ export declare function registryTemplateProblem(template: string): string | unde
29
29
  export declare function registryIndexUrl(template: string): string;
30
30
  export declare function validateFeatureRegistryIndex(value: unknown): Issue[];
31
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;
@@ -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,10 +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 { isFeatureRegistryIndex, isLoopbackHttp, isRegistryProtocol, registryIndexUrl, registryTemplateProblem, validateFeatureRegistryIndex, MAX_DESCRIPTION, MAX_INDEX_ITEMS, REGISTRY_ITEM_NAME, type FeatureRegistryIndex, type FeatureRegistryItem, } from './feature-registry.js';
7
- export { FILE_HASH, REGISTRY_NAMESPACE, 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';
8
11
  export { COST_MODEL, judgeCost, round1, splitScripts, type CostLine, type CostModel, type CostSample, type CostVerdict, } from './cost-model.js';
9
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';
10
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
  }
@@ -528,6 +629,90 @@ function validateFeatureRegistryIndex(value) {
528
629
  function isFeatureRegistryIndex(value) {
529
630
  return 0 === validateFeatureRegistryIndex(value).length;
530
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
+ }
531
716
  const project_SEGMENT = String.raw`(?!\.\.?(?:/|$))[^/\\\u0000]+`;
532
717
  const CHECKOUT_PATH = new RegExp(`^(?:(?![A-Za-z]:)(?:\\.\\.(?:/\\.\\.)*(?:/${project_SEGMENT})*|${project_SEGMENT}(?:/${project_SEGMENT})*)|(?:/|[A-Za-z]:/)${project_SEGMENT}(?:/${project_SEGMENT})*)$`);
533
718
  const FILE_HASH = /^sha256-[0-9a-f]{64}$/;
@@ -545,7 +730,14 @@ function isCheckoutPath(value) {
545
730
  const rest = -1 === climbing ? [] : parts.slice(climbing);
546
731
  return rest.every((part)=>'' !== part && '.' !== part && '..' !== part);
547
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*)?)$/;
548
735
  const SCOPE = /^@[a-z0-9][a-z0-9-._]*$/;
736
+ const PROJECT_REPOS = [
737
+ 'platform',
738
+ 'shell',
739
+ 'remote'
740
+ ];
549
741
  const FEATURE = /^(@[a-z0-9-]+\/)?[a-z][a-z0-9-]*$/;
550
742
  function validateMfeflowProject(value) {
551
743
  const c = new Checker();
@@ -554,6 +746,18 @@ function validateMfeflowProject(value) {
554
746
  return c.issues;
555
747
  }
556
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
+ }
557
761
  const topology = c.string(value, 'topology', '');
558
762
  if (void 0 !== topology && ![
559
763
  'monorepo',
@@ -590,7 +794,7 @@ function validateMfeflowProject(value) {
590
794
  });
591
795
  if (void 0 !== port && !Number.isInteger(port)) c.add(`${at}.port`, 'must be a whole number');
592
796
  }
593
- 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');
594
798
  }
595
799
  const features = c.object(value, 'features', '');
596
800
  if (features) for (const [name, feature] of Object.entries(features)){
@@ -599,6 +803,10 @@ function validateMfeflowProject(value) {
599
803
  if (isObj(feature)) {
600
804
  c.string(feature, 'version', at);
601
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
+ }
602
810
  } else c.add(at, 'must be an object');
603
811
  }
604
812
  const files = c.object(value, 'files', '', false);
@@ -632,51 +840,27 @@ function validateMfeflowProject(value) {
632
840
  required: false,
633
841
  min: 1
634
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
+ }
635
855
  return c.issues;
636
856
  }
857
+ function validBaseUrl(value) {
858
+ const url = new URL(value);
859
+ return isRegistryProtocol(url) && '' === url.username && '' === url.password && !/[?#]/.test(value) && !value.endsWith('/');
860
+ }
637
861
  function isMfeflowProject(value) {
638
862
  return 0 === validateMfeflowProject(value).length;
639
863
  }
640
- const COST_MODEL = {
641
- lcpDeltaMs: 300,
642
- shellKb: 32,
643
- remoteKb: 10
644
- };
645
- const KB = 1024;
646
- const round1 = (value)=>Math.round(10 * value) / 10;
647
- const line = (actual, budget)=>({
648
- budget,
649
- actual,
650
- pass: actual <= budget
651
- });
652
- function judgeCost(sample, model = COST_MODEL) {
653
- const lcp = line(Math.round(sample.lcpDeltaMs), model.lcpDeltaMs);
654
- const shell = line(round1((sample.shellBytes - sample.monolithBytes) / KB), model.shellKb);
655
- 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), {
656
- name
657
- }));
658
- return {
659
- pass: lcp.pass && shell.pass && remotes.every((r)=>r.pass),
660
- lcp,
661
- shell,
662
- remotes
663
- };
664
- }
665
- const REMOTE_PATH = /^\/mfe\/([^/]+)\//;
666
- function splitScripts(requests) {
667
- let shellBytes = 0;
668
- const remoteBytes = {};
669
- for (const request of requests){
670
- if ('Script' !== request.type) continue;
671
- const remote = REMOTE_PATH.exec(request.path)?.[1];
672
- if (void 0 === remote) shellBytes += request.bytes;
673
- else remoteBytes[remote] = (remoteBytes[remote] ?? 0) + request.bytes;
674
- }
675
- return {
676
- shellBytes,
677
- remoteBytes
678
- };
679
- }
680
864
  const TRANSFORM_OPS = [
681
865
  'add-import',
682
866
  'wrap-provider',
@@ -1266,6 +1450,12 @@ const JSON_SCHEMAS = {
1266
1450
  version: {
1267
1451
  const: 1
1268
1452
  },
1453
+ cli: str(SEMVER),
1454
+ repo: {
1455
+ enum: [
1456
+ ...PROJECT_REPOS
1457
+ ]
1458
+ },
1269
1459
  topology: {
1270
1460
  enum: [
1271
1461
  'monorepo',
@@ -1309,7 +1499,8 @@ const JSON_SCHEMAS = {
1309
1499
  version: str(),
1310
1500
  options: {
1311
1501
  type: 'object'
1312
- }
1502
+ },
1503
+ integrity: str(FILE_HASH)
1313
1504
  }, [
1314
1505
  'version'
1315
1506
  ])
@@ -1348,6 +1539,16 @@ const JSON_SCHEMAS = {
1348
1539
  minimum: 1
1349
1540
  }
1350
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
+ }
1351
1552
  }
1352
1553
  },
1353
1554
  required: [
@@ -1511,7 +1712,10 @@ const JSON_SCHEMAS = {
1511
1712
  ...remoteFiles,
1512
1713
  version: str(),
1513
1714
  routes: json_schema_strings(ROUTE),
1514
- owner: str()
1715
+ owner: str(),
1716
+ previous: str(),
1717
+ history: json_schema_strings(),
1718
+ rolledBackFrom: str()
1515
1719
  }, [
1516
1720
  'name',
1517
1721
  'entry'
@@ -1554,4 +1758,5 @@ const JSON_SCHEMAS = {
1554
1758
  ]
1555
1759
  })
1556
1760
  };
1557
- export { ANALYTICS_EVENT, COST_MODEL, DOCS_URL, EVENT_NAME, EXPOSE, FEATURE_CONDITION, FEATURE_NAME, FILE_HASH, JSON_SCHEMAS, MAX_DESCRIPTION, MAX_INDEX_ITEMS, MFE_NAME, REGISTRY_ITEM_NAME, REGISTRY_NAMESPACE, ROUTE, SCHEMA_URLS, TRANSFORM_OPS, assetsFromManifest, errorDocsUrl, injectIntoHead, isFeatureManifest, isFeatureRegistryIndex, isLoopbackHttp, isMfeManifest, isMfeflowProject, isRegistryEntry, isRegistryIndex, isRegistryProtocol, isSafeRelativePath, judgeCost, registryIndexUrl, registryInjection, registryTemplateProblem, round1, splitScripts, validateFeatureManifest, validateFeatureRegistryIndex, 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,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,6 +19,8 @@ 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>;
@@ -24,6 +30,14 @@ export interface MfeflowProject {
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.4.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",