@mfeflow/contracts 0.2.0 → 0.4.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`. */
@@ -0,0 +1,31 @@
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;
package/dist/feature.d.ts CHANGED
@@ -159,6 +159,8 @@ export interface FeatureManifest {
159
159
  name: string;
160
160
  version: string;
161
161
  description?: string;
162
+ /** Words a registry's `index.json` lists for search. */
163
+ keywords?: string[];
162
164
  prompts?: FeaturePrompt[];
163
165
  requires?: string[];
164
166
  conflicts?: string[];
package/dist/index.d.ts CHANGED
@@ -3,7 +3,8 @@ export { ANALYTICS_EVENT, EVENT_NAME, EXPOSE, isMfeManifest, MFE_NAME, ROUTE, va
3
3
  export { assetsFromManifest, type RemoteAssets } from './manifest-assets.js';
4
4
  export { injectIntoHead, registryInjection, type InjectionOptions, type RouteNeed, } from './html-injection.js';
5
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';
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';
7
8
  export { COST_MODEL, judgeCost, round1, splitScripts, type CostLine, type CostModel, type CostSample, type CostVerdict, } from './cost-model.js';
8
9
  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
10
  export { DOCS_URL, SCHEMA_URLS, errorDocsUrl, type SchemaName } from './docs.js';
package/dist/index.js CHANGED
@@ -435,6 +435,99 @@ function isRegistryEntry(value) {
435
435
  function isRegistryIndex(value) {
436
436
  return 0 === validateRegistryIndex(value).length;
437
437
  }
438
+ const INDEX_VERSION = /^\d{1,9}\.\d{1,9}\.\d{1,9}(?:-[0-9A-Za-z.-]{1,64})?(?:\+[0-9A-Za-z.-]{1,64})?$/;
439
+ const REGISTRY_ITEM_NAME = /^[a-z][a-z0-9-]*$/;
440
+ const MAX_INDEX_ITEMS = 1000;
441
+ const MAX_DESCRIPTION = 300;
442
+ const MAX_KEYWORDS = 20;
443
+ const MAX_KEYWORD = 40;
444
+ const INVISIBLE = /[\p{Cc}\p{Cf}\p{Cn}\p{Co}\p{Cs}]/u;
445
+ const LOOPBACK_HOSTS = new Set([
446
+ '127.0.0.1',
447
+ 'localhost',
448
+ '[::1]'
449
+ ]);
450
+ function isLoopbackHttp(url) {
451
+ return 'http:' === url.protocol && LOOPBACK_HOSTS.has(url.hostname);
452
+ }
453
+ function isRegistryProtocol(url) {
454
+ return 'https:' === url.protocol || isLoopbackHttp(url);
455
+ }
456
+ const PLACEHOLDER = /\{([^{}]*)\}/g;
457
+ function registryTemplateProblem(template) {
458
+ if (!template.includes('{name}')) return 'must contain {name}';
459
+ const unknown = [
460
+ ...template.matchAll(PLACEHOLDER)
461
+ ].map((m)=>m[1] ?? '').find((name)=>'name' !== name && 'version' !== name);
462
+ if (void 0 !== unknown) return `has {${unknown}}; only {name} and {version} are placeholders`;
463
+ if (/[{}]/.test(template.replaceAll(PLACEHOLDER, ''))) return 'has an unmatched { or }';
464
+ const origin = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)/i.exec(template);
465
+ if (origin?.[1]?.includes('{') === true) return 'has a placeholder in the host; put it in the path';
466
+ const first = template.search(/\{(?:name|version)\}/);
467
+ if (null !== origin && template.lastIndexOf('/', first) < origin[0].length) return 'needs a path before the first placeholder, like https://host/{name}.json';
468
+ let url;
469
+ try {
470
+ url = new URL(template.replaceAll('{name}', 'x').replaceAll('{version}', '1'));
471
+ } catch {
472
+ return 'is not a URL';
473
+ }
474
+ 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';
475
+ if ('' !== url.username || '' !== url.password) return 'must not contain a user name or password';
476
+ }
477
+ function registryIndexUrl(template) {
478
+ const at = template.search(/\{(?:name|version)\}/);
479
+ const base = template.slice(0, template.lastIndexOf('/', at) + 1);
480
+ return new URL('index.json', base).href;
481
+ }
482
+ function validateFeatureRegistryIndex(value) {
483
+ const c = new Checker();
484
+ if (!isObj(value)) {
485
+ c.add('', 'must be an object');
486
+ return c.issues;
487
+ }
488
+ const items = value.items;
489
+ if (!Array.isArray(items)) {
490
+ c.add('items', 'is required and must be an array');
491
+ return c.issues;
492
+ }
493
+ if (items.length > MAX_INDEX_ITEMS) {
494
+ c.add('items', `has more than ${String(MAX_INDEX_ITEMS)} items`);
495
+ return c.issues;
496
+ }
497
+ const seen = new Set();
498
+ items.forEach((item, i)=>{
499
+ const at = `items[${String(i)}]`;
500
+ if (!isObj(item)) return void c.add(at, 'must be an object');
501
+ const name = c.string(item, 'name', at, {
502
+ pattern: REGISTRY_ITEM_NAME,
503
+ hint: 'must be a feature name without the namespace, like "analytics-segment"'
504
+ });
505
+ if (void 0 !== name) {
506
+ if (seen.has(name)) c.add(`${at}.name`, `"${name}" is listed twice`);
507
+ seen.add(name);
508
+ }
509
+ c.string(item, 'version', at, {
510
+ pattern: INDEX_VERSION,
511
+ hint: 'must be a version like 1.2.0'
512
+ });
513
+ const description = c.string(item, "description", at);
514
+ if (void 0 !== description) {
515
+ if (description.length > MAX_DESCRIPTION) c.add(`${at}.description`, `must be at most ${String(MAX_DESCRIPTION)} characters`);
516
+ else if (INVISIBLE.test(description)) c.add(`${at}.description`, 'must be plain text without control characters');
517
+ }
518
+ const keywords = c.stringArray(item, 'keywords', at, {
519
+ required: false
520
+ });
521
+ if (keywords.length > MAX_KEYWORDS) c.add(`${at}.keywords`, `must have at most ${String(MAX_KEYWORDS)} entries`);
522
+ keywords.forEach((keyword, k)=>{
523
+ 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`);
524
+ });
525
+ });
526
+ return c.issues;
527
+ }
528
+ function isFeatureRegistryIndex(value) {
529
+ return 0 === validateFeatureRegistryIndex(value).length;
530
+ }
438
531
  const project_SEGMENT = String.raw`(?!\.\.?(?:/|$))[^/\\\u0000]+`;
439
532
  const CHECKOUT_PATH = new RegExp(`^(?:(?![A-Za-z]:)(?:\\.\\.(?:/\\.\\.)*(?:/${project_SEGMENT})*|${project_SEGMENT}(?:/${project_SEGMENT})*)|(?:/|[A-Za-z]:/)${project_SEGMENT}(?:/${project_SEGMENT})*)$`);
440
533
  const FILE_HASH = /^sha256-[0-9a-f]{64}$/;
@@ -519,7 +612,11 @@ function validateMfeflowProject(value) {
519
612
  const at = `registries.${ns}`;
520
613
  if ('@mfeflow' === ns) c.add(at, '@mfeflow is reserved for first-party features');
521
614
  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}');
615
+ if ('string' != typeof url) c.add(at, 'must be a URL template like "https://example.com/{name}.json"');
616
+ else {
617
+ const problem = registryTemplateProblem(url);
618
+ if (void 0 !== problem) c.add(at, problem);
619
+ }
523
620
  }
524
621
  if ('local' in value) {
525
622
  const local = c.string(value, 'local', '');
@@ -789,6 +886,9 @@ function validateFeatureManifest(value) {
789
886
  c.string(value, "description", '', {
790
887
  required: false
791
888
  });
889
+ c.stringArray(value, 'keywords', '', {
890
+ required: false
891
+ });
792
892
  c.stringArray(value, 'requires', '', {
793
893
  required: false,
794
894
  pattern: FEATURE_NAME
@@ -868,7 +968,8 @@ const SCHEMA_URLS = {
868
968
  project: `${DOCS_URL}/schema/mfeflow.json`,
869
969
  feature: `${DOCS_URL}/schema/feature.json`,
870
970
  registryEntry: `${DOCS_URL}/schema/registry-entry.json`,
871
- registryIndex: `${DOCS_URL}/schema/registry-index.json`
971
+ registryIndex: `${DOCS_URL}/schema/registry-index.json`,
972
+ featureRegistryIndex: `${DOCS_URL}/schema/feature-registry-index.json`
872
973
  };
873
974
  function errorDocsUrl(code) {
874
975
  return `${DOCS_URL}/errors/${code}`;
@@ -884,6 +985,7 @@ const json_schema_strings = (pattern)=>({
884
985
  type: 'array',
885
986
  items: str(pattern)
886
987
  });
988
+ const REGISTRY_URL = /^(?:https:\/\/|http:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?:[:/?#])).*\{name\}/;
887
989
  const literal = {
888
990
  type: [
889
991
  'string',
@@ -1228,7 +1330,7 @@ const JSON_SCHEMAS = {
1228
1330
  },
1229
1331
  additionalProperties: {
1230
1332
  type: 'string',
1231
- pattern: '^https://.*\\{name\\}'
1333
+ pattern: REGISTRY_URL.source
1232
1334
  }
1233
1335
  },
1234
1336
  local: str(CHECKOUT_PATH),
@@ -1262,6 +1364,7 @@ const JSON_SCHEMAS = {
1262
1364
  name: str(FEATURE_NAME),
1263
1365
  version: str(),
1264
1366
  description: str(),
1367
+ keywords: json_schema_strings(),
1265
1368
  prompts: {
1266
1369
  type: 'array',
1267
1370
  items: json_schema_item({
@@ -1418,6 +1521,37 @@ const JSON_SCHEMAS = {
1418
1521
  required: [
1419
1522
  'remotes'
1420
1523
  ]
1524
+ }),
1525
+ featureRegistryIndex: root('featureRegistryIndex', 'feature registry index', {
1526
+ properties: {
1527
+ items: {
1528
+ type: 'array',
1529
+ maxItems: 1000,
1530
+ items: json_schema_item({
1531
+ name: str(REGISTRY_ITEM_NAME),
1532
+ version: str(),
1533
+ description: {
1534
+ type: 'string',
1535
+ maxLength: 300
1536
+ },
1537
+ keywords: {
1538
+ type: 'array',
1539
+ maxItems: 20,
1540
+ items: {
1541
+ type: 'string',
1542
+ maxLength: 40
1543
+ }
1544
+ }
1545
+ }, [
1546
+ 'name',
1547
+ 'version',
1548
+ "description"
1549
+ ])
1550
+ }
1551
+ },
1552
+ required: [
1553
+ 'items'
1554
+ ]
1421
1555
  })
1422
1556
  };
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 };
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 };
@@ -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
  */
package/dist/project.d.ts CHANGED
@@ -18,7 +18,7 @@ export interface MfeflowProject {
18
18
  }>;
19
19
  /** Hashes of copied files, so `upgrade` can tell when a file was edited. */
20
20
  files?: Record<string, string>;
21
- /** Third-party feature registries: `{ "@someone": "https://…/{name}.json" }`. `@mfeflow` is reserved. */
21
+ /** Third-party feature registries: `{ "@someone": "https://…/{name}.json" }`, https only (plain http for loopback hosts). `@mfeflow` is reserved. */
22
22
  registries?: Record<string, string>;
23
23
  /** Built mfeflow checkout, relative to the project root, that `@mfeflow/*` dependencies link to. Set by `--local` only. */
24
24
  local?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mfeflow/contracts",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",