Skip to content

Commit badf107

Browse files
authored
feat: Use dynamic import for external JS (#26522)
1 parent f872a51 commit badf107

26 files changed

+844
-278
lines changed

lib/extension/externalConverters.ts

+13-10
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import type * as zhc from 'zigbee-herdsman-converters';
1+
import type {ExternalDefinitionWithExtend} from 'zigbee-herdsman-converters';
22

33
import {addExternalDefinition, removeExternalDefinitions} from 'zigbee-herdsman-converters';
44

55
import logger from '../util/logger';
66
import ExternalJSExtension from './externalJS';
77

8-
type ModuleExports = zhc.ExternalDefinitionWithExtend | zhc.ExternalDefinitionWithExtend[];
8+
type TModule = ExternalDefinitionWithExtend | ExternalDefinitionWithExtend[];
99

10-
export default class ExternalConverters extends ExternalJSExtension<ModuleExports> {
10+
export default class ExternalConverters extends ExternalJSExtension<TModule> {
1111
constructor(
1212
zigbee: Zigbee,
1313
mqtt: MQTT,
@@ -33,29 +33,32 @@ export default class ExternalConverters extends ExternalJSExtension<ModuleExport
3333
}
3434

3535
// eslint-disable-next-line @typescript-eslint/no-unused-vars
36-
protected async removeJS(name: string, module: ModuleExports): Promise<void> {
36+
protected async removeJS(name: string, mod: TModule): Promise<void> {
3737
removeExternalDefinitions(name);
3838

3939
await this.zigbee.resolveDevicesDefinitions(true);
4040
}
4141

42-
protected async loadJS(name: string, module: ModuleExports): Promise<void> {
42+
protected async loadJS(name: string, mod: TModule, newName?: string): Promise<void> {
4343
try {
4444
removeExternalDefinitions(name);
4545

46-
const definitions = Array.isArray(module) ? module : [module];
46+
const definitions = Array.isArray(mod) ? mod : [mod];
47+
4748
for (const definition of definitions) {
48-
definition.externalConverterName = name;
49+
definition.externalConverterName = newName ?? name;
4950

5051
addExternalDefinition(definition);
51-
logger.info(`Loaded external converter '${name}'.`);
52+
logger.info(`Loaded external converter '${newName ?? name}'.`);
5253
}
5354

5455
await this.zigbee.resolveDevicesDefinitions(true);
5556
} catch (error) {
56-
logger.error(`Failed to load external converter '${name}'`);
57-
logger.error(`Check the code for syntax error and make sure it is up to date with the current Zigbee2MQTT version.`);
5857
logger.error(
58+
/* v8 ignore next */
59+
`Failed to load external converter '${newName ?? name}'. Check the code for syntax error and make sure it is up to date with the current Zigbee2MQTT version.`,
60+
);
61+
logger.warning(
5962
`External converters are not meant for long term usage, but for local testing after which a pull request should be created to add out-of-the-box support for the device`,
6063
);
6164

lib/extension/externalExtensions.ts

+34-23
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import logger from '../util/logger';
44
import * as settings from '../util/settings';
55
import ExternalJSExtension from './externalJS';
66

7-
type ModuleExports = typeof Extension;
7+
type TModule = new (...args: ConstructorParameters<typeof Extension>) => Extension;
88

9-
export default class ExternalExtensions extends ExternalJSExtension<ModuleExports> {
9+
export default class ExternalExtensions extends ExternalJSExtension<TModule> {
1010
constructor(
1111
zigbee: Zigbee,
1212
mqtt: MQTT,
@@ -31,29 +31,40 @@ export default class ExternalExtensions extends ExternalJSExtension<ModuleExport
3131
);
3232
}
3333

34-
protected async removeJS(name: string, module: ModuleExports): Promise<void> {
35-
await this.enableDisableExtension(false, module.name);
34+
protected async removeJS(name: string, mod: TModule): Promise<void> {
35+
await this.enableDisableExtension(false, mod.name);
3636
}
3737

38-
protected async loadJS(name: string, module: ModuleExports): Promise<void> {
39-
// stop if already started
40-
await this.enableDisableExtension(false, module.name);
41-
await this.addExtension(
42-
// @ts-expect-error `module` is the interface, not the actual passed class
43-
new module(
44-
this.zigbee,
45-
this.mqtt,
46-
this.state,
47-
this.publishEntityState,
48-
this.eventBus,
49-
this.enableDisableExtension,
50-
this.restartCallback,
51-
this.addExtension,
52-
settings,
53-
logger,
54-
),
55-
);
38+
protected async loadJS(name: string, mod: TModule, newName?: string): Promise<void> {
39+
try {
40+
// stop if already started
41+
await this.enableDisableExtension(false, mod.name);
42+
await this.addExtension(
43+
new mod(
44+
this.zigbee,
45+
this.mqtt,
46+
this.state,
47+
this.publishEntityState,
48+
this.eventBus,
49+
this.enableDisableExtension,
50+
this.restartCallback,
51+
this.addExtension,
52+
// @ts-expect-error additional params that don't fit the internal `Extension` type
53+
settings,
54+
logger,
55+
),
56+
);
57+
58+
/* v8 ignore start */
59+
logger.info(`Loaded external extension '${newName ?? name}'.`);
60+
} catch (error) {
61+
logger.error(
62+
/* v8 ignore next */
63+
`Failed to load external extension '${newName ?? name}'. Check the code for syntax error and make sure it is up to date with the current Zigbee2MQTT version.`,
64+
);
5665

57-
logger.info(`Loaded external extension '${name}'.`);
66+
throw error;
67+
}
68+
/* v8 ignore stop */
5869
}
5970
}

lib/extension/externalJS.ts

+91-39
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse} from '../types/api';
22

33
import fs from 'node:fs';
44
import path from 'node:path';
5-
import {Context, runInNewContext} from 'node:vm';
65

76
import bind from 'bind-decorator';
87
import stringify from 'json-stable-stringify-without-jsonify';
@@ -16,9 +15,11 @@ import Extension from './extension';
1615
const SUPPORTED_OPERATIONS = ['save', 'remove'];
1716

1817
export default abstract class ExternalJSExtension<M> extends Extension {
18+
protected folderName: string;
1919
protected mqttTopic: string;
2020
protected requestRegex: RegExp;
2121
protected basePath: string;
22+
protected srcBasePath: string;
2223

2324
constructor(
2425
zigbee: Zigbee,
@@ -34,9 +35,17 @@ export default abstract class ExternalJSExtension<M> extends Extension {
3435
) {
3536
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
3637

38+
this.folderName = folderName;
3739
this.mqttTopic = mqttTopic;
3840
this.requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/${mqttTopic}/(save|remove)`);
3941
this.basePath = data.joinPath(folderName);
42+
// 1-up from this file
43+
this.srcBasePath = path.join(
44+
__dirname,
45+
'..',
46+
// prevent race in vitest with files being manipulated from same location
47+
process.env.VITEST_WORKER_ID ? /* v8 ignore next */ `${folderName}_${Math.floor(Math.random() * 10000)}` : folderName,
48+
);
4049
}
4150

4251
override async start(): Promise<void> {
@@ -46,25 +55,35 @@ export default abstract class ExternalJSExtension<M> extends Extension {
4655
await this.publishExternalJS();
4756
}
4857

49-
private getFilePath(name: string, mkBasePath: boolean = false): string {
50-
if (mkBasePath && !fs.existsSync(this.basePath)) {
51-
fs.mkdirSync(this.basePath, {recursive: true});
58+
override async stop(): Promise<void> {
59+
// remove src base path on stop to ensure always back to default
60+
fs.rmSync(this.srcBasePath, {force: true, recursive: true});
61+
await super.stop();
62+
}
63+
64+
private getFilePath(name: string, mkBasePath = false, inSource = false): string {
65+
const basePath = inSource ? this.srcBasePath : this.basePath;
66+
67+
if (mkBasePath && !fs.existsSync(basePath)) {
68+
fs.mkdirSync(basePath, {recursive: true});
5269
}
5370

54-
return path.join(this.basePath, name);
71+
return path.join(basePath, name);
5572
}
5673

5774
protected getFileCode(name: string): string {
58-
return fs.readFileSync(path.join(this.basePath, name), 'utf8');
75+
return fs.readFileSync(this.getFilePath(name), 'utf8');
5976
}
6077

61-
protected *getFiles(): Generator<{name: string; code: string}> {
62-
if (!fs.existsSync(this.basePath)) {
78+
protected *getFiles(inSource = false): Generator<{name: string; code: string}> {
79+
const basePath = inSource ? this.srcBasePath : this.basePath;
80+
81+
if (!fs.existsSync(basePath)) {
6382
return;
6483
}
6584

66-
for (const fileName of fs.readdirSync(this.basePath)) {
67-
if (fileName.endsWith('.js')) {
85+
for (const fileName of fs.readdirSync(basePath)) {
86+
if (fileName.endsWith('.js') || fileName.endsWith('.cjs') || fileName.endsWith('.mjs')) {
6887
yield {name: fileName, code: this.getFileCode(fileName)};
6988
}
7089
}
@@ -100,9 +119,9 @@ export default abstract class ExternalJSExtension<M> extends Extension {
100119
}
101120
}
102121

103-
protected abstract removeJS(name: string, module: M): Promise<void>;
122+
protected abstract removeJS(name: string, mod: M): Promise<void>;
104123

105-
protected abstract loadJS(name: string, module: M): Promise<void>;
124+
protected abstract loadJS(name: string, mod: M, newName?: string): Promise<void>;
106125

107126
@bind private async remove(
108127
message: Zigbee2MQTTAPI['bridge/request/converter/remove'] | Zigbee2MQTTAPI['bridge/request/extension/remove'],
@@ -112,18 +131,21 @@ export default abstract class ExternalJSExtension<M> extends Extension {
112131
}
113132

114133
const {name} = message;
134+
const srcToBeRemoved = this.getFilePath(name, false, true);
115135
const toBeRemoved = this.getFilePath(name);
116136

117-
if (fs.existsSync(toBeRemoved)) {
118-
await this.removeJS(name, this.loadModuleFromText(this.getFileCode(name), name));
137+
if (fs.existsSync(srcToBeRemoved)) {
138+
const mod = await import(this.getImportPath(srcToBeRemoved));
119139

140+
await this.removeJS(name, mod.default);
141+
fs.rmSync(srcToBeRemoved, {force: true});
120142
fs.rmSync(toBeRemoved, {force: true});
121143
logger.info(`${name} (${toBeRemoved}) removed.`);
122144
await this.publishExternalJS();
123145

124146
return utils.getResponse(message, {});
125147
} else {
126-
return utils.getResponse(message, {}, `${name} (${toBeRemoved}) doesn't exists`);
148+
return utils.getResponse(message, {}, `${name} (${srcToBeRemoved}) doesn't exists`);
127149
}
128150
}
129151

@@ -135,32 +157,76 @@ export default abstract class ExternalJSExtension<M> extends Extension {
135157
}
136158

137159
const {name, code} = message;
160+
const srcFilePath = this.getFilePath(name, true, true);
161+
let newName = name;
162+
163+
if (fs.existsSync(srcFilePath)) {
164+
// if file already exist, version it to bypass node module caching
165+
const versionMatch = name.match(/\.(\d+)\.(c|m)?js$/);
166+
167+
if (versionMatch) {
168+
const version = parseInt(versionMatch[1], 10);
169+
newName = name.replace(`.${version}.`, `.${version + 1}.`);
170+
} else {
171+
const ext = path.extname(name);
172+
newName = name.replace(ext, `.1${ext}`);
173+
}
174+
175+
// remove previous version
176+
fs.rmSync(srcFilePath, {force: true});
177+
fs.rmSync(this.getFilePath(name, true, false), {force: true});
178+
}
179+
180+
const newSrcFilePath = this.getFilePath(newName, false /* already created above if needed */, true);
138181

139182
try {
140-
await this.loadJS(name, this.loadModuleFromText(code, name));
183+
fs.writeFileSync(newSrcFilePath, code, 'utf8');
141184

142-
const filePath = this.getFilePath(name, true);
185+
const mod = await import(this.getImportPath(newSrcFilePath));
143186

144-
fs.writeFileSync(filePath, code, 'utf8');
145-
logger.info(`${name} loaded. Contents written to '${filePath}'.`);
187+
await this.loadJS(name, mod.default, newName);
188+
logger.info(`${newName} loaded. Contents written to '${newSrcFilePath}'.`);
189+
// keep original in data folder synced
190+
fs.writeFileSync(this.getFilePath(newName, true, false), code, 'utf8');
146191
await this.publishExternalJS();
147192

148193
return utils.getResponse(message, {});
149194
} catch (error) {
150-
return utils.getResponse(message, {}, `${name} contains invalid code: ${(error as Error).message}`);
195+
fs.rmSync(newSrcFilePath, {force: true});
196+
// NOTE: original in data folder doesn't get written if invalid
197+
198+
return utils.getResponse(message, {}, `${newName} contains invalid code: ${(error as Error).message}`);
151199
}
152200
}
153201

154202
private async loadFiles(): Promise<void> {
155203
for (const extension of this.getFiles()) {
156-
await this.loadJS(extension.name, this.loadModuleFromText(extension.code, extension.name));
204+
const srcFilePath = this.getFilePath(extension.name, true, true);
205+
const filePath = this.getFilePath(extension.name);
206+
207+
try {
208+
fs.copyFileSync(filePath, srcFilePath);
209+
210+
const mod = await import(this.getImportPath(srcFilePath));
211+
212+
await this.loadJS(extension.name, mod.default);
213+
} catch (error) {
214+
// change ext so Z2M doesn't try to load it again and again
215+
fs.renameSync(filePath, `${filePath}.invalid`);
216+
fs.rmSync(srcFilePath, {force: true});
217+
218+
logger.error(
219+
`Invalid external ${this.mqttTopic} '${extension.name}' was ignored and renamed to prevent interference with Zigbee2MQTT.`,
220+
);
221+
logger.debug((error as Error).stack!);
222+
}
157223
}
158224
}
159225

160226
private async publishExternalJS(): Promise<void> {
161227
await this.mqtt.publish(
162228
`bridge/${this.mqttTopic}s`,
163-
stringify(Array.from(this.getFiles())),
229+
stringify(Array.from(this.getFiles(true))),
164230
{
165231
retain: true,
166232
qos: 0,
@@ -170,22 +236,8 @@ export default abstract class ExternalJSExtension<M> extends Extension {
170236
);
171237
}
172238

173-
private loadModuleFromText(moduleCode: string, name: string): M {
174-
const moduleFakePath = path.join(__dirname, '..', '..', 'data', 'extension', name);
175-
const sandbox: Context = {
176-
require: require,
177-
module: {},
178-
console,
179-
setTimeout,
180-
clearTimeout,
181-
setInterval,
182-
clearInterval,
183-
setImmediate,
184-
clearImmediate,
185-
};
186-
187-
runInNewContext(moduleCode, sandbox, moduleFakePath);
188-
189-
return sandbox.module.exports;
239+
private getImportPath(filePath: string): string {
240+
// prevent issues on Windows
241+
return path.relative(__dirname, filePath).replaceAll('\\', '/');
190242
}
191243
}

test/assets/external_converters/mock-external-converter.js test/assets/external_converters/cjs/mock-external-converter.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
const {posix} = require('node:path');
2+
13
const mockDevice = {
24
mock: true,
35
zigbeeModel: ['external_converter_device'],
46
vendor: 'external',
57
model: 'external_converter_device',
6-
description: 'external',
8+
description: posix.join('external', 'converter'),
79
fromZigbee: [],
810
toZigbee: [],
911
exposes: [],
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export default [
2+
{
3+
mock: 1,
4+
model: 'external_converters_device_1',
5+
zigbeeModel: ['external_converter_device_1'],
6+
vendor: 'external_1',
7+
description: 'external_1',
8+
fromZigbee: [],
9+
toZigbee: [],
10+
exposes: [],
11+
},
12+
{
13+
mock: 2,
14+
model: 'external_converters_device_2',
15+
zigbeeModel: ['external_converter_device_2'],
16+
vendor: 'external_2',
17+
description: 'external_2',
18+
fromZigbee: [],
19+
toZigbee: [],
20+
exposes: [],
21+
},
22+
];

0 commit comments

Comments
 (0)