@tabbr/module-cli 0.4.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 +26 -10
- package/bin/tabbr-module.js +12 -8
- package/package.json +1 -1
- package/src/backend-build.js +62 -16
- package/src/build.js +3 -0
- package/src/fetch-types.js +5 -2
package/README.md
CHANGED
|
@@ -131,20 +131,35 @@ Tabbr repository for the field types, access rules and what the club gets; `tabb
|
|
|
131
131
|
|
|
132
132
|
## Contracts
|
|
133
133
|
|
|
134
|
-
What a module uses of Tabbr
|
|
135
|
-
|
|
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
136
|
|
|
137
137
|
```json
|
|
138
138
|
{
|
|
139
|
-
"engines": { "tabbr": "^1.
|
|
140
|
-
"uses": { "sys.access": "^1" }
|
|
139
|
+
"engines": { "tabbr": "^1.3" },
|
|
140
|
+
"uses": { "sys.access": "^1" },
|
|
141
|
+
"optionalUses": { "tabbr.consent": "^1" }
|
|
141
142
|
}
|
|
142
143
|
```
|
|
143
144
|
|
|
144
|
-
`publish` puts `uses` in the manifest (also for a module without backend code); Tabbr only installs
|
|
145
|
-
the club has
|
|
146
|
-
contract with the SDK's `MODULE_CONTRACTS` (`await
|
|
147
|
-
|
|
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.
|
|
148
163
|
|
|
149
164
|
## Usage: publish
|
|
150
165
|
|
|
@@ -260,8 +275,9 @@ points) to your workspace's `.gitignore` rather than committing them.
|
|
|
260
275
|
npx tabbr-module fetch-types --contracts [--cwd .] [--api-host https://api.tabbr.be]
|
|
261
276
|
```
|
|
262
277
|
|
|
263
|
-
**With `--contracts`**: fetches `GET /api/contracts/{name}/types` for every contract in `uses` into
|
|
264
|
-
`contracts/<name>.d.ts`.
|
|
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
|
|
265
281
|
module with them. Add `contracts/*.d.ts` to your tsconfig(s); it types `MODULE_CONTRACTS.get(name)` and
|
|
266
282
|
`tabbr.uses[name]`, and its own types can be imported from `@tabbr/contracts/<name>`.
|
|
267
283
|
|
package/bin/tabbr-module.js
CHANGED
|
@@ -106,10 +106,12 @@ publishing it - what the store's builder runs.
|
|
|
106
106
|
|
|
107
107
|
Usage: tabbr-module fetch-types --contracts [--cwd <path>] [--api-host <url>]
|
|
108
108
|
|
|
109
|
-
Fetches the contracts the module uses ("uses" in ${CONFIG_FILE}, e.g.
|
|
110
|
-
contracts/<name>.d.ts - commit them: the store builds your module with them.
|
|
111
|
-
tsconfig(s); then MODULE_CONTRACTS.get('<name>') (frontend) and
|
|
112
|
-
and the contract's own types can be imported from
|
|
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.
|
|
113
115
|
|
|
114
116
|
Usage: tabbr-module fetch-types [--club <CODE>] [--entity <name>] [options]
|
|
115
117
|
|
|
@@ -260,9 +262,10 @@ ${dts}
|
|
|
260
262
|
console.log(`Wrote ${outPath}`);
|
|
261
263
|
}
|
|
262
264
|
|
|
263
|
-
/** One file per contract the module uses: contracts/<name>.d.ts. */
|
|
265
|
+
/** One file per contract the module uses (also optionally): contracts/<name>.d.ts. */
|
|
264
266
|
async function runFetchContractTypes(args) {
|
|
265
|
-
const
|
|
267
|
+
const uses = { ...usesOf(args, 'optionalUses'), ...usesOf(args) };
|
|
268
|
+
const names = Object.keys(uses);
|
|
266
269
|
if (names.length === 0) {
|
|
267
270
|
throw new Error(`No contracts to fetch: add "uses" to ${CONFIG_FILE}, e.g. "uses": { "sys.access": "^1" }.`);
|
|
268
271
|
}
|
|
@@ -270,10 +273,11 @@ async function runFetchContractTypes(args) {
|
|
|
270
273
|
const dir = path.join(args.cwd, 'contracts');
|
|
271
274
|
fs.mkdirSync(dir, { recursive: true });
|
|
272
275
|
for (const name of names) {
|
|
273
|
-
|
|
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 });
|
|
274
278
|
const outPath = path.join(dir, `${name}.d.ts`);
|
|
275
279
|
fs.writeFileSync(outPath, dts);
|
|
276
|
-
console.log(`Wrote ${outPath} (${
|
|
280
|
+
console.log(`Wrote ${outPath} (${uses[name]})`);
|
|
277
281
|
}
|
|
278
282
|
}
|
|
279
283
|
|
package/package.json
CHANGED
package/src/backend-build.js
CHANGED
|
@@ -46,27 +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
|
+
|
|
49
51
|
/**
|
|
50
|
-
* The contracts the module uses (`"uses"` in tabbr.config.json, e.g. `{ "sys.access": "^1" }`)
|
|
51
|
-
* ranges, checked just enough to fail here - Tabbr checks
|
|
52
|
-
* satisfy) when the module is published or installed.
|
|
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.
|
|
53
55
|
*/
|
|
54
|
-
function usesOf(config) {
|
|
55
|
-
const uses = config
|
|
56
|
+
function usesOf(config, key = 'uses') {
|
|
57
|
+
const uses = config[key] || {};
|
|
56
58
|
if (typeof uses !== 'object' || Array.isArray(uses)) {
|
|
57
|
-
throw new Error(
|
|
59
|
+
throw new Error(`"${key}" in tabbr.config.json must be an object: { "<contract>": "<version range>" }, e.g. { "sys.access": "^1" }.`);
|
|
58
60
|
}
|
|
59
61
|
for (const [name, range] of Object.entries(uses)) {
|
|
60
|
-
if (
|
|
61
|
-
throw new Error(`"
|
|
62
|
+
if (!CONTRACT_NAME.test(name)) {
|
|
63
|
+
throw new Error(`"${key}": "${name}" is not a contract name (e.g. "sys.access").`);
|
|
62
64
|
}
|
|
63
65
|
if (typeof range !== 'string' || !range.trim()) {
|
|
64
|
-
throw new Error(`"
|
|
66
|
+
throw new Error(`"${key}": "${name}" needs a version range, e.g. "^1".`);
|
|
65
67
|
}
|
|
66
68
|
}
|
|
67
69
|
return uses;
|
|
68
70
|
}
|
|
69
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
|
+
|
|
70
110
|
/**
|
|
71
111
|
* Bundles a module workspace's backend part (if configured) into a single, dependency-free
|
|
72
112
|
* `bundle.js`, using esbuild so module authors can write normal TypeScript (types, imports,
|
|
@@ -75,18 +115,21 @@ function usesOf(config) {
|
|
|
75
115
|
* `BackendModuleRuntimeService`).
|
|
76
116
|
*
|
|
77
117
|
* Also writes the manifest the server reads (`manifest.json`): the backend config plus the entities the module declares
|
|
78
|
-
* and the contracts it uses - which a module can do without any backend code, so then only the manifest
|
|
79
|
-
* (`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).
|
|
80
120
|
*
|
|
81
|
-
* Returns `null` when the workspace has no backend part, no entities and uses no contracts, so `build.js`
|
|
82
|
-
* it entirely for such 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.
|
|
83
123
|
*/
|
|
84
124
|
async function buildBackend(cwd, config) {
|
|
85
125
|
const backendConfig = config.backend;
|
|
86
126
|
const entities = entitiesOf(config);
|
|
87
127
|
const uses = usesOf(config);
|
|
128
|
+
const optionalUses = usesOf(config, 'optionalUses');
|
|
129
|
+
const provides = providesOf(cwd, config);
|
|
88
130
|
const bundleJs = backendConfig ? await bundle(cwd, backendConfig) : null;
|
|
89
|
-
if (bundleJs == null && entities.length === 0 && Object.keys(uses).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) {
|
|
90
133
|
return null;
|
|
91
134
|
}
|
|
92
135
|
const settings = backendConfig || {};
|
|
@@ -100,9 +143,12 @@ async function buildBackend(cwd, config) {
|
|
|
100
143
|
manageRoles: bundleJs != null && settings.manageRoles === true,
|
|
101
144
|
// the contracts it uses - for its frontend part as well, so the manifest is written for a frontend-only module too
|
|
102
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,
|
|
103
149
|
};
|
|
104
150
|
|
|
105
|
-
return { bundleJs, manifestJson: JSON.stringify(manifest, null, 2) };
|
|
151
|
+
return { bundleJs, manifestJson: JSON.stringify(manifest, null, 2), files: provides.files };
|
|
106
152
|
}
|
|
107
153
|
|
|
108
154
|
/** The backend entry bundled into one script, or null when there is no entry file (and none was configured explicitly). */
|
|
@@ -145,4 +191,4 @@ async function bundle(cwd, backendConfig) {
|
|
|
145
191
|
return result.outputFiles[0].text;
|
|
146
192
|
}
|
|
147
193
|
|
|
148
|
-
module.exports = { buildBackend, entitiesOf, usesOf, 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
|
@@ -51,10 +51,13 @@ async function fetchAllEntityTypes({ apiHost, club, accessToken }) {
|
|
|
51
51
|
* `@tabbr/contracts/<name>`, plus its entries in `TabbrUiContracts` (what `MODULE_CONTRACTS.get(name)` returns) and
|
|
52
52
|
* `TabbrContracts` (`tabbr.uses[name]` server-side). See docs/module-contracts.md (tabbr repo).
|
|
53
53
|
*/
|
|
54
|
-
async function fetchContractTypes({ apiHost, name, accessToken }) {
|
|
54
|
+
async function fetchContractTypes({ apiHost, name, accessToken, club }) {
|
|
55
55
|
const url = `${apiHost}/api/contracts/${encodeURIComponent(name)}/types`;
|
|
56
56
|
console.log(`Fetching the contract ${name} from ${url} ...`);
|
|
57
|
-
const
|
|
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 });
|
|
58
61
|
if (!response.ok) {
|
|
59
62
|
throw new Error(`Fetching the contract ${name} failed: ${response.status} ${await response.text()}`);
|
|
60
63
|
}
|