@tabbr/module-cli 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -129,6 +129,38 @@ for a module that has no backend code (then only the entities are published). Th
129
129
  key with the SDK's `injectModuleEntity()`, the backend with `tabbr.entities.<name>`. See `docs/module-entities.md` in the
130
130
  Tabbr repository for the field types, access rules and what the club gets; `tabbr-groups-module/` is a complete example.
131
131
 
132
+ ## Contracts
133
+
134
+ What a module uses of Tabbr or of another module is a **contract** - never its code. Declare the ones you use with a
135
+ version range, and the module API level that has contracts:
136
+
137
+ ```json
138
+ {
139
+ "engines": { "tabbr": "^1.3" },
140
+ "uses": { "sys.access": "^1" },
141
+ "optionalUses": { "tabbr.consent": "^1" }
142
+ }
143
+ ```
144
+
145
+ `publish` puts `uses` and `optionalUses` in the manifest (also for a module without backend code); Tabbr only installs
146
+ the module where the club has the contracts in `uses` (`optionalUses`: it works without them), and its server-side part
147
+ gets them as `tabbr.uses['sys.access']`. The frontend gets a contract with the SDK's `MODULE_CONTRACTS` (`await
148
+ inject(MODULE_CONTRACTS).get('sys.access')`, `null` when the club doesn't have it). Their types come from
149
+ `fetch-types --contracts` (below).
150
+
151
+ A module can **provide** a contract too (module API 1.3), named after its publisher:
152
+
153
+ ```json
154
+ {
155
+ "provides": { "acme.groups": { "version": "1.0.0", "types": "contracts/acme.groups.d.ts" } }
156
+ }
157
+ ```
158
+
159
+ The `.d.ts` stands on its own (no imports) and exports `interface Frontend` and/or `interface Backend`; `publish` puts
160
+ it in the build (`backend/contracts/acme.groups.d.ts`), where Tabbr serves it to the modules that use the contract. Your
161
+ module class offers the frontend part as `provides = { 'acme.groups': ... }`, your server-side part exports the backend
162
+ part as `export const provides = { 'acme.groups': { ... } }`. See `docs/module-contracts.md` in the Tabbr repository.
163
+
132
164
  ## Usage: publish
133
165
 
134
166
  ```
@@ -239,6 +271,16 @@ Fetched files reflect one specific club's live, possibly-custom field configurat
239
271
  custom entities, ...) and are regenerable on demand - add `src/types/tabbr/` (or wherever `--out`
240
272
  points) to your workspace's `.gitignore` rather than committing them.
241
273
 
274
+ ```
275
+ npx tabbr-module fetch-types --contracts [--cwd .] [--api-host https://api.tabbr.be]
276
+ ```
277
+
278
+ **With `--contracts`**: fetches `GET /api/contracts/{name}/types` for every contract in `uses` and `optionalUses` into
279
+ `contracts/<name>.d.ts`. Tabbr's own don't depend on a club; one a module provides comes from your club (`club` in
280
+ tabbr.config.json) when a module there provides it, else from the module store - **commit them**: the store builds your
281
+ module with them. Add `contracts/*.d.ts` to your tsconfig(s); it types `MODULE_CONTRACTS.get(name)` and
282
+ `tabbr.uses[name]`, and its own types can be imported from `@tabbr/contracts/<name>`.
283
+
242
284
  ## Prerequisite: Keycloak redirect URI
243
285
 
244
286
  Login opens your browser to Keycloak and catches the redirect on a temporary local HTTP server
@@ -7,7 +7,8 @@ const { getAccessToken } = require('../src/auth');
7
7
  const { buildAndZip } = require('../src/build');
8
8
  const { publish, publishToStore, submitSourceToStore, storeMeta } = require('../src/publish');
9
9
  const { packSource } = require('../src/source');
10
- const { fetchScriptTypes, fetchAllEntityTypes, VALID_KINDS } = require('../src/fetch-types');
10
+ const { fetchScriptTypes, fetchAllEntityTypes, fetchContractTypes, VALID_KINDS } = require('../src/fetch-types');
11
+ const { usesOf } = require('../src/backend-build');
11
12
  const { CONFIG_FILE, DEFAULT_API_HOST, readConfig, requireClub, runInit } = require('../src/config');
12
13
  const { DEFAULT_LOCAL_API_HOST, trustLocalCertificateOrRespawn } = require('../src/local');
13
14
  const DEFAULT_KIND = 'HOOK'; // matches the server's own @RequestParam default for /meta/script
@@ -30,6 +31,7 @@ function parseArgs(argv) {
30
31
  case '--entity': args.entity = next(); break;
31
32
  case '--kind': args.kind = next(); break;
32
33
  case '--out': args.out = next(); break;
34
+ case '--contracts': args.contracts = true; break;
33
35
  case '--store': args.store = true; break;
34
36
  case '--prebuilt': args.prebuilt = true; break;
35
37
  case '--changelog': args.changelog = next(); break;
@@ -102,6 +104,15 @@ Usage: tabbr-module build --out <module.zip> [--cwd <path>]
102
104
  Builds the module (frontend and/or backend part, as publish does) into a zip without
103
105
  publishing it - what the store's builder runs.
104
106
 
107
+ Usage: tabbr-module fetch-types --contracts [--cwd <path>] [--api-host <url>]
108
+
109
+ Fetches the contracts the module uses ("uses" and "optionalUses" in ${CONFIG_FILE}, e.g.
110
+ { "sys.access": "^1" }) into contracts/<name>.d.ts - commit them: the store builds your module with them.
111
+ Include contracts/ in your tsconfig(s); then MODULE_CONTRACTS.get('<name>') (frontend) and
112
+ tabbr.uses['<name>'] (backend) are typed, and the contract's own types can be imported from
113
+ '@tabbr/contracts/<name>'. Tabbr's own contracts need no --club; one a module provides comes from your club
114
+ (${CONFIG_FILE} "club") when a module there provides it, else from the module store.
115
+
105
116
  Usage: tabbr-module fetch-types [--club <CODE>] [--entity <name>] [options]
106
117
 
107
118
  With --entity: fetches the ambient .d.ts for that ONE entity's script sandbox - the same typings
@@ -251,7 +262,30 @@ ${dts}
251
262
  console.log(`Wrote ${outPath}`);
252
263
  }
253
264
 
265
+ /** One file per contract the module uses (also optionally): contracts/<name>.d.ts. */
266
+ async function runFetchContractTypes(args) {
267
+ const uses = { ...usesOf(args, 'optionalUses'), ...usesOf(args) };
268
+ const names = Object.keys(uses);
269
+ if (names.length === 0) {
270
+ throw new Error(`No contracts to fetch: add "uses" to ${CONFIG_FILE}, e.g. "uses": { "sys.access": "^1" }.`);
271
+ }
272
+ const accessToken = await getAccessToken();
273
+ const dir = path.join(args.cwd, 'contracts');
274
+ fs.mkdirSync(dir, { recursive: true });
275
+ for (const name of names) {
276
+ // with a club: a contract one of its modules provides comes from there (else from the store)
277
+ const dts = await fetchContractTypes({ apiHost: args.apiHost, name, accessToken, club: args.club });
278
+ const outPath = path.join(dir, `${name}.d.ts`);
279
+ fs.writeFileSync(outPath, dts);
280
+ console.log(`Wrote ${outPath} (${uses[name]})`);
281
+ }
282
+ }
283
+
254
284
  async function runFetchTypes(args) {
285
+ if (args.contracts) {
286
+ await runFetchContractTypes(args);
287
+ return;
288
+ }
255
289
  args.club = requireClub(args.club);
256
290
  if (args.kind !== DEFAULT_KIND && !args.entity) {
257
291
  console.error('--kind only applies to a single --entity fetch (the combined file has no script sandbox, so no kind).\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tabbr/module-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Set up, build and publish a Native Federation club module to a tabbr club.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "SystemWorks BV",
@@ -46,6 +46,67 @@ function entitiesOf(config) {
46
46
  return entities;
47
47
  }
48
48
 
49
+ const CONTRACT_NAME = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
50
+
51
+ /**
52
+ * The contracts the module uses (`"uses"` in tabbr.config.json, e.g. `{ "sys.access": "^1" }`), or uses when the club
53
+ * has them (`key` `"optionalUses"`): their names and version ranges, checked just enough to fail here - Tabbr checks
54
+ * them again (a contract it doesn't have, a range it can't satisfy) when the module is published or installed.
55
+ */
56
+ function usesOf(config, key = 'uses') {
57
+ const uses = config[key] || {};
58
+ if (typeof uses !== 'object' || Array.isArray(uses)) {
59
+ throw new Error(`"${key}" in tabbr.config.json must be an object: { "<contract>": "<version range>" }, e.g. { "sys.access": "^1" }.`);
60
+ }
61
+ for (const [name, range] of Object.entries(uses)) {
62
+ if (!CONTRACT_NAME.test(name)) {
63
+ throw new Error(`"${key}": "${name}" is not a contract name (e.g. "sys.access").`);
64
+ }
65
+ if (typeof range !== 'string' || !range.trim()) {
66
+ throw new Error(`"${key}": "${name}" needs a version range, e.g. "^1".`);
67
+ }
68
+ }
69
+ return uses;
70
+ }
71
+
72
+ /**
73
+ * The contracts the module provides itself (`"provides"` in tabbr.config.json, e.g.
74
+ * `{ "acme.groups": { "version": "1.0.0", "types": "contracts/acme.groups.d.ts" } }`): for the manifest their names and
75
+ * versions, and their TypeScript - which goes into the build as `backend/contracts/<name>.d.ts`, where Tabbr serves it
76
+ * to the modules that use the contract. The file stands on its own (no imports) and exports `interface Frontend`
77
+ * and/or `interface Backend`. Tabbr checks the rest (the name is the publisher's, no other module has it).
78
+ */
79
+ function providesOf(cwd, config) {
80
+ const provides = config.provides || {};
81
+ if (typeof provides !== 'object' || Array.isArray(provides)) {
82
+ throw new Error('"provides" in tabbr.config.json must be an object: { "<contract>": { "version": "1.0.0", "types": "contracts/<contract>.d.ts" } }.');
83
+ }
84
+ const versions = {};
85
+ const files = {};
86
+ for (const [name, contract] of Object.entries(provides)) {
87
+ if (!CONTRACT_NAME.test(name) || name.startsWith('sys.')) {
88
+ throw new Error(`"provides": "${name}" is not a contract name a module can provide (e.g. "acme.groups"; "sys." is Tabbr's).`);
89
+ }
90
+ if (!contract || !/^\d+\.\d+\.\d+$/.test(contract.version || '')) {
91
+ throw new Error(`"provides": "${name}" needs a version, e.g. { "version": "1.0.0" }.`);
92
+ }
93
+ const typesPath = path.join(cwd, contract.types || `contracts/${name}.d.ts`);
94
+ if (!fs.existsSync(typesPath)) {
95
+ throw new Error(`"provides": the TypeScript of "${name}" is missing: ${typesPath}.`);
96
+ }
97
+ const source = fs.readFileSync(typesPath, 'utf8');
98
+ if (!/\bexport\s+interface\s+(Frontend|Backend)\b/.test(source)) {
99
+ throw new Error(`"provides": ${typesPath} exports neither interface Frontend nor interface Backend.`);
100
+ }
101
+ if (/^\s*import\b/m.test(source)) {
102
+ throw new Error(`"provides": ${typesPath} may not import anything - a contract stands on its own.`);
103
+ }
104
+ versions[name] = contract.version;
105
+ files[`contracts/${name}.d.ts`] = source;
106
+ }
107
+ return { versions, files };
108
+ }
109
+
49
110
  /**
50
111
  * Bundles a module workspace's backend part (if configured) into a single, dependency-free
51
112
  * `bundle.js`, using esbuild so module authors can write normal TypeScript (types, imports,
@@ -54,16 +115,21 @@ function entitiesOf(config) {
54
115
  * `BackendModuleRuntimeService`).
55
116
  *
56
117
  * Also writes the manifest the server reads (`manifest.json`): the backend config plus the entities the module declares
57
- * - which a module can do without any backend code, so then only the manifest is produced (`bundleJs` null).
118
+ * and the contracts it uses and provides - which a module can do without any backend code, so then only the manifest
119
+ * is produced (`bundleJs` null). `files`: more files for the build's `backend/` (the TypeScript of what it provides).
58
120
  *
59
- * Returns `null` when the workspace has neither a backend part nor entities, so `build.js` can skip
60
- * it entirely for a frontend-only module.
121
+ * Returns `null` when the workspace has no backend part, no entities and uses or provides no contracts, so `build.js`
122
+ * can skip it entirely for such a frontend-only module.
61
123
  */
62
124
  async function buildBackend(cwd, config) {
63
125
  const backendConfig = config.backend;
64
126
  const entities = entitiesOf(config);
127
+ const uses = usesOf(config);
128
+ const optionalUses = usesOf(config, 'optionalUses');
129
+ const provides = providesOf(cwd, config);
65
130
  const bundleJs = backendConfig ? await bundle(cwd, backendConfig) : null;
66
- if (bundleJs == null && entities.length === 0) {
131
+ if (bundleJs == null && entities.length === 0 && Object.keys(uses).length === 0 && Object.keys(optionalUses).length === 0
132
+ && Object.keys(provides.versions).length === 0) {
67
133
  return null;
68
134
  }
69
135
  const settings = backendConfig || {};
@@ -75,9 +141,14 @@ async function buildBackend(cwd, config) {
75
141
  entities,
76
142
  // the module gives persons roles (tabbr.access.setManagedRoles) - shown to the club when it installs the module
77
143
  manageRoles: bundleJs != null && settings.manageRoles === true,
144
+ // the contracts it uses - for its frontend part as well, so the manifest is written for a frontend-only module too
145
+ uses,
146
+ optionalUses,
147
+ // the contracts it provides, with their version; their TypeScript goes along as backend/contracts/<name>.d.ts
148
+ provides: provides.versions,
78
149
  };
79
150
 
80
- return { bundleJs, manifestJson: JSON.stringify(manifest, null, 2) };
151
+ return { bundleJs, manifestJson: JSON.stringify(manifest, null, 2), files: provides.files };
81
152
  }
82
153
 
83
154
  /** The backend entry bundled into one script, or null when there is no entry file (and none was configured explicitly). */
@@ -120,4 +191,4 @@ async function bundle(cwd, backendConfig) {
120
191
  return result.outputFiles[0].text;
121
192
  }
122
193
 
123
- module.exports = { buildBackend, entitiesOf, DEFAULT_ENTRY };
194
+ module.exports = { buildBackend, entitiesOf, usesOf, providesOf, DEFAULT_ENTRY };
package/src/build.js CHANGED
@@ -82,6 +82,9 @@ function zipModule(frontendDir, backend) {
82
82
  archive.append(backend.bundleJs, { name: 'backend/bundle.js' });
83
83
  }
84
84
  archive.append(backend.manifestJson, { name: 'backend/manifest.json' });
85
+ for (const [name, content] of Object.entries(backend.files || {})) {
86
+ archive.append(content, { name: `backend/${name}` });
87
+ }
85
88
  }
86
89
  archive.finalize();
87
90
  });
@@ -46,4 +46,22 @@ async function fetchAllEntityTypes({ apiHost, club, accessToken }) {
46
46
  return response.text();
47
47
  }
48
48
 
49
- module.exports = { fetchScriptTypes, fetchAllEntityTypes, VALID_KINDS };
49
+ /**
50
+ * A contract a module uses (`"uses"` in tabbr.config.json), as Tabbr serves it: its types as the module
51
+ * `@tabbr/contracts/<name>`, plus its entries in `TabbrUiContracts` (what `MODULE_CONTRACTS.get(name)` returns) and
52
+ * `TabbrContracts` (`tabbr.uses[name]` server-side). See docs/module-contracts.md (tabbr repo).
53
+ */
54
+ async function fetchContractTypes({ apiHost, name, accessToken, club }) {
55
+ const url = `${apiHost}/api/contracts/${encodeURIComponent(name)}/types`;
56
+ console.log(`Fetching the contract ${name} from ${url} ...`);
57
+ const headers = { Authorization: `Bearer ${accessToken}` };
58
+ // a contract a module provides: the club's version of it, when the club has that module
59
+ if (club) headers['X-club'] = club.toUpperCase();
60
+ const response = await fetch(url, { headers });
61
+ if (!response.ok) {
62
+ throw new Error(`Fetching the contract ${name} failed: ${response.status} ${await response.text()}`);
63
+ }
64
+ return response.text();
65
+ }
66
+
67
+ module.exports = { fetchScriptTypes, fetchAllEntityTypes, fetchContractTypes, VALID_KINDS };