Skip to content

Commit ba48481

Browse files
committed
feat(extensions): lazy per-command loading via a nativescript.commands map
An extension whose package.json declares nativescript.commands as a map of command name to module path is no longer require()d at startup. Each entry is registered with injector.requireCommand against the module's absolute path, so a command's implementation loads only when that command is first resolved, and the CLI stops paying every installed extension's load cost on every invocation. Entries are validated: a command name or module path that is not a non-empty string is warned about and skipped, and a name already claimed by another extension is reported as a warning naming both extensions rather than propagating the injector's "require'd twice" failure. The legacy array shape (and a missing commands key) keeps today's behavior verbatim - eager require of the extension main plus the extensions.require-time-registration deprecation report. Both shapes now feed IExtensionData.commands and the npm install suggestion for unknown commands.
1 parent 74caa8f commit ba48481

4 files changed

Lines changed: 780 additions & 10 deletions

File tree

extensions.md

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
Writing a CLI Extension
2+
=======================
3+
4+
An extension adds new commands to the NativeScript CLI. Extensions are ordinary
5+
npm packages: they are published to npm, installed per user rather than per
6+
project, and are available from every project on the machine.
7+
8+
```bash
9+
ns extension install <package-name>
10+
ns extension uninstall <package-name>
11+
```
12+
13+
Installed extensions live in the CLI profile directory, under
14+
`extensions/node_modules/<package-name>`, and every CLI invocation consults each
15+
of them. That makes the manifest below the most important file in an extension:
16+
it is what the CLI reads on startup, and it decides whether your code is loaded
17+
eagerly or only when one of your commands is actually executed.
18+
19+
## Making a package discoverable
20+
21+
Add the `nativescript:extension` keyword to `package.json`. The CLI searches npm
22+
for that keyword when it needs to suggest an extension for an unknown command
23+
(see [Suggesting an extension](#suggesting-an-extension-for-an-unknown-command)).
24+
25+
```json
26+
{
27+
"name": "nativescript-hello",
28+
"version": "1.0.0",
29+
"keywords": ["nativescript:extension"]
30+
}
31+
```
32+
33+
## Declaring commands
34+
35+
Commands are declared in the `commands` key of the `nativescript` key of the
36+
extension's `package.json`. Two shapes are accepted.
37+
38+
### A map of command name to module (recommended)
39+
40+
```json
41+
{
42+
"nativescript": {
43+
"commands": {
44+
"hello|world": "./dist/commands/hello-world.js",
45+
"hello|*default": "./dist/commands/hello.js"
46+
}
47+
}
48+
}
49+
```
50+
51+
Each key is a command name; each value is a path to the module implementing it,
52+
resolved relative to the extension's root directory.
53+
54+
Declaring commands this way is strongly preferred:
55+
56+
* **Per-command lazy loading.** Nothing in the extension is loaded when the CLI
57+
starts. A command's module is required the first time that command is
58+
resolved, so `ns build android` never pays the cost of loading an unrelated
59+
extension. With a large or dependency-heavy extension installed, that is the
60+
difference between a noticeable startup delay on every command and none.
61+
* **Early, named conflict detection.** Two extensions claiming the same command
62+
name is reported as a warning that names both extensions and the contested
63+
command, and the extension that claimed it first keeps working. Under the
64+
legacy shape the same collision surfaces as an opaque
65+
`module '...' require'd twice.` failure from whichever extension happened to
66+
load second.
67+
* **The CLI knows what you contribute without running you.** The declared
68+
command names are what the install suggestion for an unknown command matches
69+
against, and they are available to the CLI as metadata about the installed
70+
extension.
71+
72+
Malformed entries are skipped rather than fatal: an entry whose command name or
73+
module path is not a non-empty string is reported as a warning naming the
74+
extension and the offending entry, and the extension's remaining commands are
75+
still registered.
76+
77+
### An array of command names (legacy)
78+
79+
```json
80+
{
81+
"nativescript": {
82+
"commands": ["hello|world", "hello|*default"]
83+
}
84+
}
85+
```
86+
87+
The array is a discovery aid only — it lists the names the CLI may suggest your
88+
extension for, but it says nothing about where the implementations live. An
89+
extension declaring commands this way (or declaring no commands at all) is
90+
loaded the old way: the CLI `require()`s the package's main entry on **every**
91+
invocation and expects the module's top-level code to register everything.
92+
93+
```js
94+
// index.js of a legacy extension
95+
const path = require("path");
96+
97+
global.$injector.requireCommand(
98+
"hello|world",
99+
path.join(__dirname, "commands", "hello-world"),
100+
);
101+
```
102+
103+
This path remains supported, but it is tracked for eventual deprecation. Run any
104+
command with `--log trace` to see which installed extensions still rely on it,
105+
or set `NS_DEPRECATIONS=warn` to have those reports printed as warnings.
106+
107+
## Writing a command module
108+
109+
A command module must register itself when it is loaded, by calling
110+
`$injector.registerCommand` at the top level with the same name it is declared
111+
under in the manifest. The CLI resolves the command through the injector right
112+
after loading the module, so registration has to happen as a side effect of the
113+
`require`.
114+
115+
```js
116+
// dist/commands/hello-world.js
117+
class HelloWorldCommand {
118+
constructor($logger) {
119+
this.$logger = $logger;
120+
this.allowedParameters = [];
121+
}
122+
123+
async execute(args) {
124+
this.$logger.info(`Hello, ${args[0] || "world"}!`);
125+
}
126+
}
127+
128+
global.$injector.registerCommand("hello|world", HelloWorldCommand);
129+
```
130+
131+
Constructor parameters are injected by name: a parameter called `$logger`
132+
receives the CLI's logger, `$fs` its file system service, and so on. A command
133+
class must expose `allowedParameters` and an `execute(args)` method.
134+
135+
## Command names
136+
137+
Command names use `|` to express hierarchy, so `"hello|world"` is invoked as
138+
`ns hello world`. Prefixing the last segment with `*` marks a default
139+
subcommand: `"hello|*default"` runs both for `ns hello default` and for a bare
140+
`ns hello`.
141+
142+
When an extension contributes several commands under the same parent, declare
143+
the default command before its siblings — the CLI creates the parent dispatcher
144+
from the first entry it sees, and a default command registered after that parent
145+
already exists is rejected.
146+
147+
## Suggesting an extension for an unknown command
148+
149+
When a user types a command the CLI does not know, it searches npm for packages
150+
carrying the `nativescript:extension` keyword, reads the `nativescript.commands`
151+
key of each candidate's published `package.json`, and matches it against the
152+
words the user typed — longest match first, so `ns valid command with args`
153+
matches a declared `valid|command|with` before `valid|command`. A declared
154+
default command also matches its short form: an extension declaring
155+
`hello|*default` is suggested for a bare `ns hello`.
156+
157+
Both manifest shapes participate in this matching. If a match is found, the CLI
158+
tells the user which extension provides the command and how to install it:
159+
160+
```
161+
The command hello world is registered in extension nativescript-hello.
162+
You can install it by executing 'ns extension install nativescript-hello'
163+
```
164+
165+
## Documentation
166+
167+
Point the `docs` key of the `nativescript` key at a directory of `.md` files to
168+
have the CLI's help system pick up the help for your commands.
169+
170+
```json
171+
{
172+
"nativescript": {
173+
"docs": "./docs",
174+
"commands": {
175+
"hello|world": "./dist/commands/hello-world.js"
176+
}
177+
}
178+
}
179+
```

lib/common/definitions/extensibility.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ interface IExtensionData extends IExtensionName {
3030
* Full path to the directory of the installed extension.
3131
*/
3232
pathToExtension: string;
33+
34+
/**
35+
* Names of the commands the extension contributes, as declared in the commands key of the nativescript key of its package.json.
36+
* The key may be a map of command name to the module implementing it, in which case these are its keys, or the legacy array of command names, in which case these are its entries.
37+
* The property is not set when the extension declares no commands.
38+
*/
39+
commands?: string[];
3340
}
3441

3542
/**

lib/services/extensibility-service.ts

Lines changed: 134 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,40 @@ import {
1919
} from "../common/definitions/extensibility";
2020
import { injector } from "../common/yok";
2121

22+
function isNonEmptyString(value: any): boolean {
23+
return typeof value === "string" && value.trim().length > 0;
24+
}
25+
26+
function isCommandsMap(commands: any): boolean {
27+
return !!commands && typeof commands === "object" && !Array.isArray(commands);
28+
}
29+
30+
/**
31+
* Reads the names of the commands an extension contributes out of either shape
32+
* of `nativescript.commands` - the legacy array of names, or the map of name to
33+
* module path.
34+
*/
35+
function getDeclaredCommandNames(
36+
commands: any,
37+
opts?: { copy: boolean },
38+
): string[] {
39+
if (Array.isArray(commands)) {
40+
return opts && opts.copy ? commands.slice() : commands;
41+
}
42+
43+
if (isCommandsMap(commands)) {
44+
return _.keys(commands);
45+
}
46+
47+
return null;
48+
}
49+
2250
export class ExtensibilityService implements IExtensibilityService {
2351
private customPathToExtensions: string = null;
2452

53+
/** Command name -> name of the extension whose manifest claimed it first. */
54+
private manifestCommandOwners: IStringDictionary = {};
55+
2556
private get pathToPackageJson(): string {
2657
return path.join(this.pathToExtensions, constants.PACKAGE_JSON_FILE_NAME);
2758
}
@@ -132,25 +163,47 @@ export class ExtensibilityService implements IExtensibilityService {
132163
packageJsonData.nativescript &&
133164
packageJsonData.nativescript.docs &&
134165
path.join(pathToExtension, packageJsonData.nativescript.docs);
135-
return {
166+
const result: IExtensionData = {
136167
extensionName: packageJsonData.name,
137168
version: packageJsonData.version,
138169
docs,
139170
pathToExtension,
140171
};
172+
173+
const commands = getDeclaredCommandNames(
174+
packageJsonData &&
175+
packageJsonData.nativescript &&
176+
packageJsonData.nativescript.commands,
177+
);
178+
if (commands) {
179+
result.commands = commands;
180+
}
181+
182+
return result;
141183
}
142184

143185
public async loadExtension(extensionName: string): Promise<IExtensionData> {
144186
try {
145187
await this.assertExtensionIsInstalled(extensionName);
146188

147189
const pathToExtension = this.getPathToExtension(extensionName);
148-
reportDeprecation({
149-
api: "extensions.require-time-registration",
150-
detail: extensionName,
151-
logger: this.$logger,
152-
});
153-
this.$requireService.require(pathToExtension);
190+
const commandsMap = this.getDeclaredCommandsMap(extensionName);
191+
192+
if (commandsMap) {
193+
this.registerDeclaredCommands(
194+
extensionName,
195+
pathToExtension,
196+
commandsMap,
197+
);
198+
} else {
199+
reportDeprecation({
200+
api: "extensions.require-time-registration",
201+
detail: extensionName,
202+
logger: this.$logger,
203+
});
204+
this.$requireService.require(pathToExtension);
205+
}
206+
154207
return this.getInstalledExtensionData(extensionName);
155208
} catch (error) {
156209
this.$logger.warn(
@@ -200,10 +253,13 @@ export class ExtensibilityService implements IExtensibilityService {
200253
await this.$packageManager.getRegistryPackageData(extensionName);
201254
const latestPackageData =
202255
registryData.versions[registryData["dist-tags"].latest];
203-
const commands: string[] =
256+
const commands = getDeclaredCommandNames(
204257
latestPackageData &&
205-
latestPackageData.nativescript &&
206-
latestPackageData.nativescript.commands;
258+
latestPackageData.nativescript &&
259+
latestPackageData.nativescript.commands,
260+
// The |* synthesis below pushes into this array.
261+
{ copy: true },
262+
);
207263
if (commands && commands.length) {
208264
// For each default command we need to add its short syntax in the array of commands.
209265
// For example in case there's a default command called devices list, the commands array will contain devices|*list.
@@ -249,6 +305,74 @@ export class ExtensibilityService implements IExtensibilityService {
249305
return null;
250306
}
251307

308+
/**
309+
* Returns the `nativescript.commands` value of an extension only when it is a
310+
* map of command name to module path. Any other shape (the legacy array of
311+
* command names, a missing key, an unreadable package.json) yields null and
312+
* keeps the extension on the eager require path.
313+
*/
314+
private getDeclaredCommandsMap(extensionName: string): IStringDictionary {
315+
let commands: any;
316+
317+
try {
318+
const packageJsonData = this.getExtensionPackageJsonData(extensionName);
319+
commands =
320+
packageJsonData &&
321+
packageJsonData.nativescript &&
322+
packageJsonData.nativescript.commands;
323+
} catch (err) {
324+
this.$logger.trace(
325+
`Unable to read the package.json of extension ${extensionName}. Error is: ${err}`,
326+
);
327+
return null;
328+
}
329+
330+
return isCommandsMap(commands) ? commands : null;
331+
}
332+
333+
/**
334+
* Registers each declared command as a deferred require of its own module, so
335+
* nothing from the extension is loaded until one of its commands is executed.
336+
* Each module is expected to register itself on load, by calling
337+
* `$injector.registerCommand(<name>, <class>)` at the top level.
338+
*/
339+
private registerDeclaredCommands(
340+
extensionName: string,
341+
pathToExtension: string,
342+
commands: IStringDictionary,
343+
): void {
344+
for (const commandName of _.keys(commands)) {
345+
const modulePath = commands[commandName];
346+
347+
if (!isNonEmptyString(commandName) || !isNonEmptyString(modulePath)) {
348+
this.$logger.warn(
349+
`Extension ${extensionName} declares an invalid command in its nativescript.commands: '${commandName}': ${JSON.stringify(
350+
modulePath,
351+
)}. Both the command name and the path to its module must be non-empty strings. Skipping this command.`,
352+
);
353+
continue;
354+
}
355+
356+
try {
357+
injector.requireCommand(
358+
commandName,
359+
path.join(pathToExtension, modulePath),
360+
);
361+
} catch (err) {
362+
const owner = this.manifestCommandOwners[commandName];
363+
const ownerInfo = owner
364+
? ` It is already registered by extension ${owner}.`
365+
: "";
366+
this.$logger.warn(
367+
`Extension ${extensionName} is unable to register command ${commandName}.${ownerInfo} Error: ${err.message}`,
368+
);
369+
continue;
370+
}
371+
372+
this.manifestCommandOwners[commandName] = extensionName;
373+
}
374+
}
375+
252376
private getPathToExtension(extensionName: string): string {
253377
return path.join(
254378
this.pathToExtensions,

0 commit comments

Comments
 (0)