Skip to content

Commit e9f5b9e

Browse files
committed
feat(extensions): route manifest commands through the deferred registry
The manifest loader no longer writes injector records or reads exception text to detect conflicts; it hands each entry to registerDeferredCommand and reports the rejection it gets back. A command claimed by another extension names that extension, one the CLI provides says so without exposing internals, and re-loading an already loaded extension is silent rather than a conflict with itself. Entry values may now be an object carrying the module path under `path`, with unrecognised keys ignored, so the shape can grow without stranding manifests on released CLIs. Default commands are registered ahead of their siblings so JSON key order carries no meaning. The manifest key is what the command is dispatched as — routing happens before the module exists — so a definition whose own name disagrees runs under the key and warns naming both, and definitions register through the same helper as registerCommandDefinition.
1 parent 346c385 commit e9f5b9e

2 files changed

Lines changed: 465 additions & 64 deletions

File tree

lib/services/extensibility-service.ts

Lines changed: 116 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { createRegExp, regExpEscape } from "../common/helpers";
66
import { reportDeprecation } from "../common/deprecation";
77
import { INodePackageManager, INpmsSingleResultData } from "../declarations";
88
import {
9+
IDictionary,
910
IFileSystem,
1011
ISettingsService,
1112
IStringDictionary,
@@ -22,8 +23,9 @@ import { IInjector } from "../common/definitions/yok";
2223
import { CommandsDelimiters } from "../common/constants";
2324
import { inject } from "../common/di/inject";
2425
import { CommandRegistry } from "../common/contracts";
25-
import { isCommandDefinition } from "../common/define-command";
26-
import { createCommandFromDefinition } from "../common/services/command-definition-adapter";
26+
import type { DeferredCommandRejection } from "../common/contracts";
27+
import { DefinedCommand, isCommandDefinition } from "../common/define-command";
28+
import { registerDefinitionAs } from "../common/services/command-definition-adapter";
2729

2830
function isNonEmptyString(value: any): boolean {
2931
return typeof value === "string" && value.trim().length > 0;
@@ -33,6 +35,44 @@ function isCommandsMap(commands: any): boolean {
3335
return !!commands && typeof commands === "object" && !Array.isArray(commands);
3436
}
3537

38+
/**
39+
* A manifest entry is either the module path or an envelope carrying it under
40+
* `path`. Unknown envelope keys are ignored on purpose: a CLI released today
41+
* must keep loading manifests that grow new keys tomorrow.
42+
*/
43+
function getEntryModulePath(value: any): string {
44+
if (isNonEmptyString(value)) {
45+
return value;
46+
}
47+
48+
if (
49+
value &&
50+
typeof value === "object" &&
51+
!Array.isArray(value) &&
52+
isNonEmptyString(value.path)
53+
) {
54+
return value.path;
55+
}
56+
57+
return null;
58+
}
59+
60+
const isDefaultCommandName = (name: string): boolean =>
61+
name.indexOf(CommandsDelimiters.DefaultHierarchicalCommand) !== -1;
62+
63+
function describeRejection(rejection: DeferredCommandRejection): string {
64+
switch (rejection.reason) {
65+
case "invalid-name":
66+
return rejection.detail;
67+
case "claimed":
68+
return `it is already registered by extension ${rejection.owner}`;
69+
case "built-in":
70+
return "it is already provided by the CLI";
71+
case "subcommand-parent":
72+
return "it is already in use as the parent of its subcommands";
73+
}
74+
}
75+
3676
/**
3777
* Reads the names of the commands an extension contributes out of either shape
3878
* of `nativescript.commands` - the legacy array of names, or the map of name to
@@ -56,9 +96,6 @@ function getDeclaredCommandNames(
5696
export class ExtensibilityService implements IExtensibilityService {
5797
private customPathToExtensions: string = null;
5898

59-
/** Command name -> name of the extension whose manifest claimed it first. */
60-
private manifestCommandOwners: IStringDictionary = {};
61-
6299
private commandRegistry = inject(CommandRegistry);
63100

64101
private get pathToPackageJson(): string {
@@ -316,11 +353,11 @@ export class ExtensibilityService implements IExtensibilityService {
316353

317354
/**
318355
* Returns the `nativescript.commands` value of an extension only when it is a
319-
* map of command name to module path. Any other shape (the legacy array of
356+
* map of command name to module. Any other shape (the legacy array of
320357
* command names, a missing key, an unreadable package.json) yields null and
321358
* keeps the extension on the eager require path.
322359
*/
323-
private getDeclaredCommandsMap(extensionName: string): IStringDictionary {
360+
private getDeclaredCommandsMap(extensionName: string): IDictionary<any> {
324361
let commands: any;
325362

326363
try {
@@ -340,7 +377,7 @@ export class ExtensibilityService implements IExtensibilityService {
340377
}
341378

342379
/**
343-
* Registers each declared command as a deferred require of its own module, so
380+
* Registers each declared command as a deferred load of its own module, so
344381
* nothing from the extension is loaded until one of its commands is executed.
345382
* A module may either register itself on load (a legacy-style
346383
* `$injector.registerCommand(<name>, <class>)` at the top level) or export a
@@ -350,69 +387,93 @@ export class ExtensibilityService implements IExtensibilityService {
350387
private registerDeclaredCommands(
351388
extensionName: string,
352389
pathToExtension: string,
353-
commands: IStringDictionary,
390+
commands: IDictionary<any>,
354391
): void {
355-
for (const commandName of _.keys(commands)) {
356-
const modulePath = commands[commandName];
392+
// Manifest key order carries no meaning, so a parent's default command is
393+
// registered before its siblings rather than wherever the author put it.
394+
const commandNames = _.sortBy(_.keys(commands), (commandName) =>
395+
isDefaultCommandName(commandName) ? 0 : 1,
396+
);
397+
398+
for (const commandName of commandNames) {
399+
const modulePath = getEntryModulePath(commands[commandName]);
357400

358-
if (!isNonEmptyString(commandName) || !isNonEmptyString(modulePath)) {
401+
if (!isNonEmptyString(commandName) || !modulePath) {
359402
this.$logger.warn(
360403
`Extension ${extensionName} declares an invalid command in its nativescript.commands: '${commandName}': ${JSON.stringify(
361-
modulePath,
362-
)}. Both the command name and the path to its module must be non-empty strings. Skipping this command.`,
404+
commands[commandName],
405+
)}. The command name must be a non-empty string and its value either the path to its module or an object with a non-empty 'path'. Skipping this command.`,
363406
);
364407
continue;
365408
}
366409

367410
const absoluteModulePath = path.join(pathToExtension, modulePath);
368-
const parentName = commandName.split(
369-
CommandsDelimiters.HierarchicalCommand,
370-
)[0];
371-
const parentWasAbsent =
372-
parentName !== commandName &&
373-
!this.$injector.has(`commands.${parentName}`);
411+
const result = this.commandRegistry.registerDeferredCommand(commandName, {
412+
owner: extensionName,
413+
source: absoluteModulePath,
414+
load: () =>
415+
this.loadDeclaredCommand(
416+
extensionName,
417+
commandName,
418+
absoluteModulePath,
419+
),
420+
});
374421

375-
try {
376-
this.commandRegistry.requireCommand(commandName, absoluteModulePath);
377-
} catch (err) {
378-
const owner = this.manifestCommandOwners[commandName];
379-
const ownerInfo = owner
380-
? ` It is already registered by extension ${owner}.`
381-
: "";
422+
if (!result.registered) {
382423
this.$logger.warn(
383-
`Extension ${extensionName} is unable to register command ${commandName}.${ownerInfo} Error: ${err.message}`,
424+
`Extension ${extensionName} is unable to register command '${commandName}': ${describeRejection(
425+
result.rejection,
426+
)}.`,
384427
);
385-
continue;
386428
}
429+
}
430+
}
387431

388-
// requireCommand's own loader only require()s the module for its side
389-
// effects, which covers self-registering modules but not definition
390-
// exports. The override must also land on a parent record this entry
391-
// just created: dispatch resolves the parent BEFORE any child module
392-
// has loaded, and the parent dispatcher only comes into existence once
393-
// a child's registerCommand runs.
394-
const loader = () => {
395-
const exported = require(absoluteModulePath);
396-
const candidate = (exported && exported.default) ?? exported;
397-
if (isCommandDefinition(candidate)) {
398-
this.commandRegistry.registerCommand(commandName, () =>
399-
createCommandFromDefinition(<any>candidate),
400-
);
401-
}
402-
};
403-
this.$injector.register({
404-
provide: `commands.${commandName}`,
405-
useLazyRequire: loader,
406-
});
407-
if (parentWasAbsent) {
408-
this.$injector.register({
409-
provide: `commands.${parentName}`,
410-
useLazyRequire: loader,
411-
});
412-
}
432+
/**
433+
* Runs on the first resolution of one declared command. Definition modules
434+
* are registered here rather than by the module itself, which is what lets
435+
* the manifest key stay authoritative for routing.
436+
*/
437+
private loadDeclaredCommand(
438+
extensionName: string,
439+
commandName: string,
440+
absoluteModulePath: string,
441+
): void {
442+
const exported = require(absoluteModulePath);
443+
const candidate = (exported && exported.default) ?? exported;
444+
445+
if (!isCommandDefinition(candidate)) {
446+
return;
447+
}
448+
449+
this.warnOnDeclaredNameMismatch(
450+
extensionName,
451+
commandName,
452+
absoluteModulePath,
453+
candidate,
454+
);
455+
registerDefinitionAs(commandName, candidate, this.$injector);
456+
}
413457

414-
this.manifestCommandOwners[commandName] = extensionName;
458+
private warnOnDeclaredNameMismatch(
459+
extensionName: string,
460+
commandName: string,
461+
absoluteModulePath: string,
462+
definition: DefinedCommand<any>,
463+
): void {
464+
const declaredNames = Array.isArray(definition.name)
465+
? definition.name
466+
: [definition.name];
467+
468+
if (_.includes(declaredNames, commandName)) {
469+
return;
415470
}
471+
472+
this.$logger.warn(
473+
`Extension ${extensionName} declares command '${commandName}' in its package.json, but the definition in ${absoluteModulePath} names itself '${declaredNames.join(
474+
"', '",
475+
)}'. The command runs as '${commandName}' - the manifest decides how it is invoked.`,
476+
);
416477
}
417478

418479
private getPathToExtension(extensionName: string): string {

0 commit comments

Comments
 (0)