@@ -2,7 +2,6 @@ import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse} from '../types/api';
2
2
3
3
import fs from 'node:fs' ;
4
4
import path from 'node:path' ;
5
- import { Context , runInNewContext } from 'node:vm' ;
6
5
7
6
import bind from 'bind-decorator' ;
8
7
import stringify from 'json-stable-stringify-without-jsonify' ;
@@ -16,9 +15,11 @@ import Extension from './extension';
16
15
const SUPPORTED_OPERATIONS = [ 'save' , 'remove' ] ;
17
16
18
17
export default abstract class ExternalJSExtension < M > extends Extension {
18
+ protected folderName : string ;
19
19
protected mqttTopic : string ;
20
20
protected requestRegex : RegExp ;
21
21
protected basePath : string ;
22
+ protected srcBasePath : string ;
22
23
23
24
constructor (
24
25
zigbee : Zigbee ,
@@ -34,9 +35,17 @@ export default abstract class ExternalJSExtension<M> extends Extension {
34
35
) {
35
36
super ( zigbee , mqtt , state , publishEntityState , eventBus , enableDisableExtension , restartCallback , addExtension ) ;
36
37
38
+ this . folderName = folderName ;
37
39
this . mqttTopic = mqttTopic ;
38
40
this . requestRegex = new RegExp ( `${ settings . get ( ) . mqtt . base_topic } /bridge/request/${ mqttTopic } /(save|remove)` ) ;
39
41
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
+ ) ;
40
49
}
41
50
42
51
override async start ( ) : Promise < void > {
@@ -46,25 +55,35 @@ export default abstract class ExternalJSExtension<M> extends Extension {
46
55
await this . publishExternalJS ( ) ;
47
56
}
48
57
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 } ) ;
52
69
}
53
70
54
- return path . join ( this . basePath , name ) ;
71
+ return path . join ( basePath , name ) ;
55
72
}
56
73
57
74
protected getFileCode ( name : string ) : string {
58
- return fs . readFileSync ( path . join ( this . basePath , name ) , 'utf8' ) ;
75
+ return fs . readFileSync ( this . getFilePath ( name ) , 'utf8' ) ;
59
76
}
60
77
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 ) ) {
63
82
return ;
64
83
}
65
84
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' ) ) {
68
87
yield { name : fileName , code : this . getFileCode ( fileName ) } ;
69
88
}
70
89
}
@@ -100,9 +119,9 @@ export default abstract class ExternalJSExtension<M> extends Extension {
100
119
}
101
120
}
102
121
103
- protected abstract removeJS ( name : string , module : M ) : Promise < void > ;
122
+ protected abstract removeJS ( name : string , mod : M ) : Promise < void > ;
104
123
105
- protected abstract loadJS ( name : string , module : M ) : Promise < void > ;
124
+ protected abstract loadJS ( name : string , mod : M , newName ?: string ) : Promise < void > ;
106
125
107
126
@bind private async remove (
108
127
message : Zigbee2MQTTAPI [ 'bridge/request/converter/remove' ] | Zigbee2MQTTAPI [ 'bridge/request/extension/remove' ] ,
@@ -112,18 +131,21 @@ export default abstract class ExternalJSExtension<M> extends Extension {
112
131
}
113
132
114
133
const { name} = message ;
134
+ const srcToBeRemoved = this . getFilePath ( name , false , true ) ;
115
135
const toBeRemoved = this . getFilePath ( name ) ;
116
136
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 ) ) ;
119
139
140
+ await this . removeJS ( name , mod . default ) ;
141
+ fs . rmSync ( srcToBeRemoved , { force : true } ) ;
120
142
fs . rmSync ( toBeRemoved , { force : true } ) ;
121
143
logger . info ( `${ name } (${ toBeRemoved } ) removed.` ) ;
122
144
await this . publishExternalJS ( ) ;
123
145
124
146
return utils . getResponse ( message , { } ) ;
125
147
} else {
126
- return utils . getResponse ( message , { } , `${ name } (${ toBeRemoved } ) doesn't exists` ) ;
148
+ return utils . getResponse ( message , { } , `${ name } (${ srcToBeRemoved } ) doesn't exists` ) ;
127
149
}
128
150
}
129
151
@@ -135,32 +157,76 @@ export default abstract class ExternalJSExtension<M> extends Extension {
135
157
}
136
158
137
159
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 ) ? j s $ / ) ;
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 ) ;
138
181
139
182
try {
140
- await this . loadJS ( name , this . loadModuleFromText ( code , name ) ) ;
183
+ fs . writeFileSync ( newSrcFilePath , code , 'utf8' ) ;
141
184
142
- const filePath = this . getFilePath ( name , true ) ;
185
+ const mod = await import ( this . getImportPath ( newSrcFilePath ) ) ;
143
186
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' ) ;
146
191
await this . publishExternalJS ( ) ;
147
192
148
193
return utils . getResponse ( message , { } ) ;
149
194
} 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 } ` ) ;
151
199
}
152
200
}
153
201
154
202
private async loadFiles ( ) : Promise < void > {
155
203
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
+ }
157
223
}
158
224
}
159
225
160
226
private async publishExternalJS ( ) : Promise < void > {
161
227
await this . mqtt . publish (
162
228
`bridge/${ this . mqttTopic } s` ,
163
- stringify ( Array . from ( this . getFiles ( ) ) ) ,
229
+ stringify ( Array . from ( this . getFiles ( true ) ) ) ,
164
230
{
165
231
retain : true ,
166
232
qos : 0 ,
@@ -170,22 +236,8 @@ export default abstract class ExternalJSExtension<M> extends Extension {
170
236
) ;
171
237
}
172
238
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 ( '\\' , '/' ) ;
190
242
}
191
243
}
0 commit comments