@tabbr/module-cli 0.3.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/README.md CHANGED
@@ -129,6 +129,23 @@ 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, later, of another module) is a **contract** - never its code. Declare the ones you use
135
+ with a version range, and the module API level that has contracts:
136
+
137
+ ```json
138
+ {
139
+ "engines": { "tabbr": "^1.1" },
140
+ "uses": { "sys.access": "^1" }
141
+ }
142
+ ```
143
+
144
+ `publish` puts `uses` in the manifest (also for a module without backend code); Tabbr only installs the module where
145
+ the club has those contracts, and its server-side part gets them as `tabbr.uses['sys.access']`. The frontend gets a
146
+ contract with the SDK's `MODULE_CONTRACTS` (`await inject(MODULE_CONTRACTS).get('sys.access')`). Their types come from
147
+ `fetch-types --contracts` (below). See `docs/module-contracts.md` in the Tabbr repository.
148
+
132
149
  ## Usage: publish
133
150
 
134
151
  ```
@@ -239,6 +256,15 @@ Fetched files reflect one specific club's live, possibly-custom field configurat
239
256
  custom entities, ...) and are regenerable on demand - add `src/types/tabbr/` (or wherever `--out`
240
257
  points) to your workspace's `.gitignore` rather than committing them.
241
258
 
259
+ ```
260
+ npx tabbr-module fetch-types --contracts [--cwd .] [--api-host https://api.tabbr.be]
261
+ ```
262
+
263
+ **With `--contracts`**: fetches `GET /api/contracts/{name}/types` for every contract in `uses` into
264
+ `contracts/<name>.d.ts`. Unlike the entity types these don't depend on a club - **commit them**: the store builds your
265
+ module with them. Add `contracts/*.d.ts` to your tsconfig(s); it types `MODULE_CONTRACTS.get(name)` and
266
+ `tabbr.uses[name]`, and its own types can be imported from `@tabbr/contracts/<name>`.
267
+
242
268
  ## Prerequisite: Keycloak redirect URI
243
269
 
244
270
  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,13 @@ 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" in ${CONFIG_FILE}, e.g. { "sys.access": "^1" }) into
110
+ contracts/<name>.d.ts - commit them: the store builds your module with them. Include contracts/ in your
111
+ tsconfig(s); then MODULE_CONTRACTS.get('<name>') (frontend) and tabbr.uses['<name>'] (backend) are typed,
112
+ and the contract's own types can be imported from '@tabbr/contracts/<name>'. No --club needed.
113
+
105
114
  Usage: tabbr-module fetch-types [--club <CODE>] [--entity <name>] [options]
106
115
 
107
116
  With --entity: fetches the ambient .d.ts for that ONE entity's script sandbox - the same typings
@@ -251,7 +260,28 @@ ${dts}
251
260
  console.log(`Wrote ${outPath}`);
252
261
  }
253
262
 
263
+ /** One file per contract the module uses: contracts/<name>.d.ts. */
264
+ async function runFetchContractTypes(args) {
265
+ const names = Object.keys(usesOf(args));
266
+ if (names.length === 0) {
267
+ throw new Error(`No contracts to fetch: add "uses" to ${CONFIG_FILE}, e.g. "uses": { "sys.access": "^1" }.`);
268
+ }
269
+ const accessToken = await getAccessToken();
270
+ const dir = path.join(args.cwd, 'contracts');
271
+ fs.mkdirSync(dir, { recursive: true });
272
+ for (const name of names) {
273
+ const dts = await fetchContractTypes({ apiHost: args.apiHost, name, accessToken });
274
+ const outPath = path.join(dir, `${name}.d.ts`);
275
+ fs.writeFileSync(outPath, dts);
276
+ console.log(`Wrote ${outPath} (${args.uses[name]})`);
277
+ }
278
+ }
279
+
254
280
  async function runFetchTypes(args) {
281
+ if (args.contracts) {
282
+ await runFetchContractTypes(args);
283
+ return;
284
+ }
255
285
  args.club = requireClub(args.club);
256
286
  if (args.kind !== DEFAULT_KIND && !args.entity) {
257
287
  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.0",
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,27 @@ function entitiesOf(config) {
46
46
  return entities;
47
47
  }
48
48
 
49
+ /**
50
+ * The contracts the module uses (`"uses"` in tabbr.config.json, e.g. `{ "sys.access": "^1" }`): their names and version
51
+ * ranges, checked just enough to fail here - Tabbr checks them again (a contract it doesn't have, a range it can't
52
+ * satisfy) when the module is published or installed.
53
+ */
54
+ function usesOf(config) {
55
+ const uses = config.uses || {};
56
+ if (typeof uses !== 'object' || Array.isArray(uses)) {
57
+ throw new Error('"uses" in tabbr.config.json must be an object: { "<contract>": "<version range>" }, e.g. { "sys.access": "^1" }.');
58
+ }
59
+ for (const [name, range] of Object.entries(uses)) {
60
+ if (!/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/.test(name)) {
61
+ throw new Error(`"uses": "${name}" is not a contract name (e.g. "sys.access").`);
62
+ }
63
+ if (typeof range !== 'string' || !range.trim()) {
64
+ throw new Error(`"uses": "${name}" needs a version range, e.g. "^1".`);
65
+ }
66
+ }
67
+ return uses;
68
+ }
69
+
49
70
  /**
50
71
  * Bundles a module workspace's backend part (if configured) into a single, dependency-free
51
72
  * `bundle.js`, using esbuild so module authors can write normal TypeScript (types, imports,
@@ -54,16 +75,18 @@ function entitiesOf(config) {
54
75
  * `BackendModuleRuntimeService`).
55
76
  *
56
77
  * 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).
78
+ * and the contracts it uses - which a module can do without any backend code, so then only the manifest is produced
79
+ * (`bundleJs` null).
58
80
  *
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.
81
+ * Returns `null` when the workspace has no backend part, no entities and uses no contracts, so `build.js` can skip
82
+ * it entirely for such a frontend-only module.
61
83
  */
62
84
  async function buildBackend(cwd, config) {
63
85
  const backendConfig = config.backend;
64
86
  const entities = entitiesOf(config);
87
+ const uses = usesOf(config);
65
88
  const bundleJs = backendConfig ? await bundle(cwd, backendConfig) : null;
66
- if (bundleJs == null && entities.length === 0) {
89
+ if (bundleJs == null && entities.length === 0 && Object.keys(uses).length === 0) {
67
90
  return null;
68
91
  }
69
92
  const settings = backendConfig || {};
@@ -75,6 +98,8 @@ async function buildBackend(cwd, config) {
75
98
  entities,
76
99
  // the module gives persons roles (tabbr.access.setManagedRoles) - shown to the club when it installs the module
77
100
  manageRoles: bundleJs != null && settings.manageRoles === true,
101
+ // the contracts it uses - for its frontend part as well, so the manifest is written for a frontend-only module too
102
+ uses,
78
103
  };
79
104
 
80
105
  return { bundleJs, manifestJson: JSON.stringify(manifest, null, 2) };
@@ -120,4 +145,4 @@ async function bundle(cwd, backendConfig) {
120
145
  return result.outputFiles[0].text;
121
146
  }
122
147
 
123
- module.exports = { buildBackend, entitiesOf, DEFAULT_ENTRY };
148
+ module.exports = { buildBackend, entitiesOf, usesOf, DEFAULT_ENTRY };
@@ -46,4 +46,19 @@ 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 }) {
55
+ const url = `${apiHost}/api/contracts/${encodeURIComponent(name)}/types`;
56
+ console.log(`Fetching the contract ${name} from ${url} ...`);
57
+ const response = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } });
58
+ if (!response.ok) {
59
+ throw new Error(`Fetching the contract ${name} failed: ${response.status} ${await response.text()}`);
60
+ }
61
+ return response.text();
62
+ }
63
+
64
+ module.exports = { fetchScriptTypes, fetchAllEntityTypes, fetchContractTypes, VALID_KINDS };