@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 +42 -0
- package/bin/tabbr-module.js +35 -1
- package/package.json +1 -1
- package/src/backend-build.js +77 -6
- package/src/build.js +3 -0
- package/src/fetch-types.js +19 -1
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
|
package/bin/tabbr-module.js
CHANGED
|
@@ -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
package/src/backend-build.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
});
|
package/src/fetch-types.js
CHANGED
|
@@ -46,4 +46,22 @@ async function fetchAllEntityTypes({ apiHost, club, accessToken }) {
|
|
|
46
46
|
return response.text();
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
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 };
|