geonix 1.30.2 → 1.31.0

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/src/Service.js CHANGED
@@ -1,5 +1,15 @@
1
1
  import { connection } from "./Connection.js";
2
- import { picoid, sleep, hash, getSecondsSinceMidnight, OverlayObject, GeonixVersion, getFirstItemFromAsyncIterable, getNetworkAddresses, deepMerge } from "./Util.js";
2
+ import {
3
+ picoid,
4
+ sleep,
5
+ hash,
6
+ getSecondsSinceMidnight,
7
+ OverlayObject,
8
+ GeonixVersion,
9
+ getFirstItemFromAsyncIterable,
10
+ getNetworkAddresses,
11
+ deepMerge,
12
+ } from "./Util.js";
3
13
  import { webserver } from "./WebServer.js";
4
14
  import { createConnection } from "net";
5
15
  import { EOL } from "os";
@@ -18,7 +28,7 @@ const getInactivityTimeout = () => parseInt(process.env.GX_INACTIVITY_TIMEOUT) |
18
28
 
19
29
  const protectedMethodNames = ["constructor", "onStart"];
20
30
  const endpointMatcher = /^((?<options>.+)\|)?(?<verb>WS|SUB|GET|POST|PATCH|PUT|DELETE|HEAD|OPTIONS|ALL)\s(?<url>.*)/;
21
- const isEndpointFilter = methodName => endpointMatcher.test(methodName);
31
+ const isEndpointFilter = (methodName) => endpointMatcher.test(methodName);
22
32
  const ERROR_BEGIN_DELIMITER = "-".repeat(10);
23
33
  const ERROR_END_DELIMITER = "-".repeat(40);
24
34
 
@@ -33,9 +43,9 @@ const defaultServiceOptions = {
33
43
  middleware: {
34
44
  json: true,
35
45
  raw: true,
36
- cookies: true
46
+ cookies: true,
37
47
  },
38
- fullBeacon: true
48
+ fullBeacon: true,
39
49
  };
40
50
 
41
51
  /**
@@ -43,7 +53,6 @@ const defaultServiceOptions = {
43
53
  * the service on the NATS bus, expose HTTP endpoints, and begin sending beacons to the registry.
44
54
  */
45
55
  export class Service {
46
-
47
56
  /**
48
57
  * Creates a new instance of the subclass and starts it on the NATS bus.
49
58
  *
@@ -52,10 +61,10 @@ export class Service {
52
61
  */
53
62
  static start(options = {}) {
54
63
  const instance = new this();
55
- instance.#start(options).catch(e => logger.error("gx.service.start:", e));
64
+ instance.#start(options).catch((e) => logger.error("gx.service.start:", e));
56
65
  }
57
66
 
58
- // ---------------------------------------------------------------------------------------------
67
+ // ---------------------------------------------------------------------------------------------
59
68
 
60
69
  #isActive = false;
61
70
  #me = {};
@@ -78,7 +87,10 @@ export class Service {
78
87
  const lineMap = new Map();
79
88
  for (const name of fields) {
80
89
  const quoted = JSON.stringify(name);
81
- lineMap.set(name, serviceSource.findIndex(line => line.includes(quoted)));
90
+ lineMap.set(
91
+ name,
92
+ serviceSource.findIndex((line) => line.includes(quoted)),
93
+ );
82
94
  }
83
95
  fields.sort((a, b) => lineMap.get(a) - lineMap.get(b));
84
96
 
@@ -90,15 +102,21 @@ export class Service {
90
102
  // name
91
103
  n: options.name ?? this.constructor.name,
92
104
  // version
93
- v: process.env.GX_VERSION || process.env.VERSION || process.env.version || options?.version || this.version || `999.999.${getSecondsSinceMidnight()}`,
105
+ v:
106
+ process.env.GX_VERSION ||
107
+ process.env.VERSION ||
108
+ process.env.version ||
109
+ options?.version ||
110
+ this.version ||
111
+ `999.999.${getSecondsSinceMidnight()}`,
94
112
  // methods
95
113
  m: fields
96
- .filter(methodName => !protectedMethodNames.includes(methodName))
97
- .filter(methodName => !methodName.startsWith("$")),
114
+ .filter((methodName) => !protectedMethodNames.includes(methodName))
115
+ .filter((methodName) => !methodName.startsWith("$")),
98
116
  // geonix version
99
117
  gx: GeonixVersion,
100
118
  // IP addresses
101
- a: getNetworkAddresses().map(address => `${address}:${webserver.getPort()}`)
119
+ a: getNetworkAddresses().map((address) => `${address}:${webserver.getPort()}`),
102
120
  };
103
121
 
104
122
  // check if method takes context as first argument
@@ -106,21 +124,20 @@ export class Service {
106
124
  const method = this[methodName];
107
125
  this.#methodTakesContext.set(
108
126
  method,
109
- method.toString()?.match(/\((?<args>.*)\)/)?.groups?.args.startsWith("$")
127
+ method
128
+ .toString()
129
+ ?.match(/\((?<args>.*)\)/)
130
+ ?.groups?.args.startsWith("$"),
110
131
  );
111
132
  }
112
133
 
113
- this.#beacon()
114
- .catch(e => logger.error("gx.beacon:", e));
134
+ this.#beacon().catch((e) => logger.error("gx.beacon:", e));
115
135
 
116
- this.#callListener(`${this.#me.n}@${this.#me.v}`)
117
- .catch(e => logger.error("gx.queueListener:", e));
136
+ this.#callListener(`${this.#me.n}@${this.#me.v}`).catch((e) => logger.error("gx.queueListener:", e));
118
137
 
119
- this.#callListener(this.#me.i)
120
- .catch(e => logger.error("gx.directListener:", e));
138
+ this.#callListener(this.#me.i).catch((e) => logger.error("gx.directListener:", e));
121
139
 
122
- this.#webserver()
123
- .catch(e => logger.error("gx.webserver:", e));
140
+ this.#webserver().catch((e) => logger.error("gx.webserver:", e));
124
141
 
125
142
  logger.info("gx.service.start", this.#me.n, this.#me.v);
126
143
 
@@ -141,7 +158,7 @@ export class Service {
141
158
  async #beacon() {
142
159
  while (this.#isActive) {
143
160
  const payload = this.#options.fullBeacon ? this.#me : { i: this.#me.i };
144
- connection.publish("gx2.beacon", payload).catch(e => logger.warn("beacon.publish:", e));
161
+ connection.publish("gx2.beacon", payload).catch((e) => logger.warn("beacon.publish:", e));
145
162
  await sleep(BEACON_INTERVAL);
146
163
  }
147
164
  }
@@ -168,11 +185,10 @@ export class Service {
168
185
 
169
186
  /**
170
187
  * Register local endpoints with express instance
171
- * @returns
188
+ * @returns
172
189
  */
173
190
  async #webserver() {
174
- const endpoints = this.#me.m
175
- .filter(isEndpointFilter);
191
+ const endpoints = this.#me.m.filter(isEndpointFilter);
176
192
 
177
193
  const router = webserver.router();
178
194
 
@@ -181,7 +197,9 @@ export class Service {
181
197
  router.post(`/!!_gx/rpc/${hash(this.#me.i)}`, raw, async (req, res) => {
182
198
  const body = _payloadKey
183
199
  ? JSON.parse(decryptPayload(req.body))
184
- : (Buffer.isBuffer(req.body) ? JSON.parse(req.body.toString()) : req.body);
200
+ : Buffer.isBuffer(req.body)
201
+ ? JSON.parse(req.body.toString())
202
+ : req.body;
185
203
  await this.#onCall(body, (result) => {
186
204
  const payload = Buffer.from(JSON.stringify(result));
187
205
  if (_payloadKey) {
@@ -214,7 +232,7 @@ export class Service {
214
232
  let { verb, url: uri } = endpointMatcher.exec(endpoint)?.groups || {};
215
233
  verb = verb.toLowerCase();
216
234
 
217
- let handlers = (Array.isArray(this[endpoint]) ? this[endpoint] : [this[endpoint]]);
235
+ let handlers = Array.isArray(this[endpoint]) ? this[endpoint] : [this[endpoint]];
218
236
 
219
237
  const handlersBefore = this.#options?.handlers?.before ?? [];
220
238
  const handlersAfter = this.#options?.handlers?.after ?? [];
@@ -232,7 +250,14 @@ export class Service {
232
250
  // handlersBefore run as route-scoped middleware before the upgrade;
233
251
  // handlersAfter does not apply to WebSocket connections.
234
252
  if (handlersBefore.length > 0) {
235
- router.use(uri, ...handlersBefore.map(h => (...args) => h.apply(this, args)));
253
+ router.use(
254
+ uri,
255
+ ...handlersBefore.map(
256
+ (h) =>
257
+ (...args) =>
258
+ h.apply(this, args),
259
+ ),
260
+ );
236
261
  }
237
262
  router.ws(uri, this[endpoint].bind(this));
238
263
  break;
@@ -258,14 +283,14 @@ export class Service {
258
283
  handler(event.data);
259
284
  }
260
285
  };
261
- processor().catch(e => logger.error("$sub.processor:", e));
286
+ processor().catch((e) => logger.error("$sub.processor:", e));
262
287
  }
263
288
 
264
289
  /**
265
290
  * Handle individual call
266
- * @param {Object} call
267
- * @param {Function} respond
268
- * @returns
291
+ * @param {Object} call
292
+ * @param {Function} respond
293
+ * @returns
269
294
  */
270
295
  async #onCall(call, respond) {
271
296
  const { m: methodName, a: args, c: context, o: caller } = call;
@@ -302,7 +327,7 @@ export class Service {
302
327
 
303
328
  this.#connections.set(streamId, {
304
329
  client,
305
- sub: ingress
330
+ sub: ingress,
306
331
  });
307
332
 
308
333
  const cleanup = () => {
@@ -338,8 +363,11 @@ export class Service {
338
363
  cleanup();
339
364
  };
340
365
 
341
- incomingLoop().catch(e => { logger.error("$createConnection.incomingLoop:", e); cleanup(); });
342
- controlLoop().catch(e => logger.error("$createConnection.controlLoop:", e));
366
+ incomingLoop().catch((e) => {
367
+ logger.error("$createConnection.incomingLoop:", e);
368
+ cleanup();
369
+ });
370
+ controlLoop().catch((e) => logger.error("$createConnection.controlLoop:", e));
343
371
 
344
372
  return true;
345
373
  }
@@ -350,11 +378,11 @@ export class Service {
350
378
  node: {
351
379
  version: process.version,
352
380
  platform: process.platform,
353
- arch: process.arch
381
+ arch: process.arch,
354
382
  },
355
383
  mem: process.memoryUsage(),
356
384
  rss: process.memoryUsage.rss(),
357
- cpu: process.cpuUsage()
385
+ cpu: process.cpuUsage(),
358
386
  };
359
387
  }
360
388
 
@@ -369,5 +397,4 @@ export class Service {
369
397
  $stop() {
370
398
  this.#isActive = false;
371
399
  }
372
-
373
- }
400
+ }
package/src/Stream.js CHANGED
@@ -52,7 +52,7 @@ export function Stream(data) {
52
52
 
53
53
  const event = await Promise.race([
54
54
  getFirstItemFromAsyncIterable(control),
55
- new Promise(resolve => setTimeout(() => resolve(null), getStreamTimeout()))
55
+ new Promise((resolve) => setTimeout(() => resolve(null), getStreamTimeout())),
56
56
  ]);
57
57
 
58
58
  if (!event) {
@@ -68,20 +68,22 @@ export function Stream(data) {
68
68
  delete activeStreams[id];
69
69
 
70
70
  // kickstart the stream
71
- readable.on("data", chunk => connection.publishRaw(`gx2.stream.${id}.b`, chunk));
71
+ readable.on("data", (chunk) => connection.publishRaw(`gx2.stream.${id}.b`, chunk));
72
72
  readable.on("close", () => {
73
73
  connection.publishRaw(`gx2.stream.${id}.b`);
74
74
  });
75
75
  }
76
- })().catch(e => logger.error("stream.nats.handler:", e));
76
+ })().catch((e) => logger.error("stream.nats.handler:", e));
77
77
 
78
78
  const result = {
79
79
  $: "stream",
80
- id
80
+ id,
81
81
  };
82
82
 
83
83
  // get the port and addresses of the webserver
84
- const addresses = webserver.getPort() ? getNetworkAddresses().map(address => `${address}:${webserver.getPort()}`) : undefined;
84
+ const addresses = webserver.getPort()
85
+ ? getNetworkAddresses().map((address) => `${address}:${webserver.getPort()}`)
86
+ : undefined;
85
87
  if (addresses) {
86
88
  result.a = addresses;
87
89
  }
@@ -151,7 +153,11 @@ export async function getReadable(object) {
151
153
  }
152
154
  }
153
155
  };
154
- dataHandler().catch(e => { logger.error("stream.dataHandler:", e); subscription.unsubscribe(); readable.destroy(); });
156
+ dataHandler().catch((e) => {
157
+ logger.error("stream.dataHandler:", e);
158
+ subscription.unsubscribe();
159
+ readable.destroy();
160
+ });
155
161
 
156
162
  // kickstart remote stream with a blank message
157
163
  await connection.publishRaw(`gx2.stream.${object.id}.a`);
package/src/Util.js CHANGED
@@ -14,22 +14,22 @@ import { tmpdir } from "os";
14
14
 
15
15
  /**
16
16
  * Wait for {delay} ms
17
- * @param {number} delay
18
- * @returns
17
+ * @param {number} delay
18
+ * @returns
19
19
  */
20
- export const sleep = delay => new Promise(resolve => setTimeout(resolve, delay));
20
+ export const sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay));
21
21
 
22
22
  /**
23
23
  * Wait for next tick
24
- *
25
- * @returns
24
+ *
25
+ * @returns
26
26
  */
27
- export const yieldToEventLoop = () => new Promise(resolve => setImmediate(resolve));
27
+ export const yieldToEventLoop = () => new Promise((resolve) => setImmediate(resolve));
28
28
 
29
29
  /**
30
30
  * Parse nats:// URL
31
- * @param {string} url
32
- * @returns
31
+ * @param {string} url
32
+ * @returns
33
33
  */
34
34
  export function parseURL(url) {
35
35
  const parsed = new URL(url);
@@ -38,12 +38,12 @@ export function parseURL(url) {
38
38
  servers: `${parsed.hostname}:${parsed.port || 4222}`,
39
39
  user: parsed.password ? parsed.username : "",
40
40
  pass: parsed.password,
41
- token: parsed.username && !parsed.password ? parsed.username : undefined
41
+ token: parsed.username && !parsed.password ? parsed.username : undefined,
42
42
  };
43
43
 
44
44
  return {
45
45
  ...basic,
46
- ...Object.fromEntries(parsed.searchParams)
46
+ ...Object.fromEntries(parsed.searchParams),
47
47
  };
48
48
  }
49
49
 
@@ -51,7 +51,9 @@ const BASE62 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
51
51
  const LOG256_LOG62 = Math.log(256) / Math.log(62); // ≈ 1.3437
52
52
 
53
53
  export function encodeBase62(buffer) {
54
- if (buffer.length === 0) { return ""; }
54
+ if (buffer.length === 0) {
55
+ return "";
56
+ }
55
57
  const len = Math.ceil(buffer.length * LOG256_LOG62);
56
58
  let n = BigInt("0x" + buffer.toString("hex"));
57
59
  const chars = new Array(len);
@@ -73,17 +75,17 @@ export const picoid = (size = 16) => encodeBase62(randomBytes(size));
73
75
 
74
76
  /**
75
77
  * Get SHA256 hash of a string or a buffer
76
- * @param {string|Buffer} data
77
- * @returns
78
+ * @param {string|Buffer} data
79
+ * @returns
78
80
  */
79
81
  export const hash = (data) => createHash("sha256").update(data).digest("hex");
80
82
 
81
83
  /**
82
84
  * Create TCP or HTTP server at specified port
83
- * @param {number} port
84
- * @param {Object} pkg
85
- * @param {Function} handler
86
- * @returns
85
+ * @param {number} port
86
+ * @param {Object} pkg
87
+ * @param {Function} handler
88
+ * @returns
87
89
  */
88
90
  export const createServerAtPort = (port, pkg, handler) =>
89
91
  new Promise((resolve) => {
@@ -131,7 +133,7 @@ export const proxyHttp = (target, req, res) =>
131
133
  const remoteTarget = `${target}${req.originalUrl}`;
132
134
  const options = {
133
135
  method: req.method,
134
- headers: req.headers
136
+ headers: req.headers,
135
137
  };
136
138
 
137
139
  const protocol = req.protocol === "https" ? https : http;
@@ -151,11 +153,12 @@ export const proxyHttp = (target, req, res) =>
151
153
 
152
154
  /**
153
155
  * Create a object proxy that overlays overlay object
154
- * @param {*} object
155
- * @param {*} overlay
156
- * @returns
156
+ * @param {*} object
157
+ * @param {*} overlay
158
+ * @returns
157
159
  */
158
- export const OverlayObject = (object, overlay) => new Proxy(object, { get: (t, p) => overlay[p] !== undefined ? overlay[p] : t[p] });
160
+ export const OverlayObject = (object, overlay) =>
161
+ new Proxy(object, { get: (t, p) => (overlay[p] !== undefined ? overlay[p] : t[p]) });
159
162
 
160
163
  /**
161
164
  * The version string of the currently installed Geonix package, read from `package.json` at
@@ -173,24 +176,25 @@ export const GeonixVersion = (() => {
173
176
 
174
177
  /**
175
178
  * Chunk a stream into smaller chunks
176
- *
177
- * @param {*} chunkSize
178
- * @returns
179
+ *
180
+ * @param {*} chunkSize
181
+ * @returns
179
182
  */
180
- export const StreamChunker = (chunkSize = 65536) => new Transform({
181
- transform(chunk, _encoding, done) {
182
- let offset = 0;
183
- while (offset < chunk.length) {
184
- const sliceSize = Math.min(chunkSize, chunk.length - offset);
185
- this.push(chunk.slice(offset, offset + sliceSize));
186
- offset += sliceSize;
187
- }
188
- done();
189
- },
190
- flush(done) {
191
- done();
192
- }
193
- });
183
+ export const StreamChunker = (chunkSize = 65536) =>
184
+ new Transform({
185
+ transform(chunk, _encoding, done) {
186
+ let offset = 0;
187
+ while (offset < chunk.length) {
188
+ const sliceSize = Math.min(chunkSize, chunk.length - offset);
189
+ this.push(chunk.slice(offset, offset + sliceSize));
190
+ offset += sliceSize;
191
+ }
192
+ done();
193
+ },
194
+ flush(done) {
195
+ done();
196
+ },
197
+ });
194
198
 
195
199
  export async function getFirstItemFromAsyncIterable(asyncIterable) {
196
200
  const iterator = asyncIterable[Symbol.asyncIterator]();
@@ -199,24 +203,31 @@ export async function getFirstItemFromAsyncIterable(asyncIterable) {
199
203
  }
200
204
 
201
205
  export function getNetworkAddresses() {
202
- const list = [];
203
- const interfaces = networkInterfaces();
204
-
205
- for (let interfaceAddresses of Object.values(interfaces)) {
206
- for (let addressObject of interfaceAddresses) {
207
- if (addressObject.family === "IPv4") {
208
- list.push(addressObject.address);
209
- }
210
-
211
- if (addressObject.family === "IPv6") {
212
- list.push(`[${addressObject.address}]`);
213
- }
206
+ // Loopback entries are seeded first so same-host callers prefer them; the OS-reported
207
+ // duplicates are filtered out below to keep the prepended ones canonical.
208
+ const list = ["127.0.0.1", "[::1]"];
209
+ const interfaces = networkInterfaces() ?? {};
210
+
211
+ for (const interfaceAddresses of Object.values(interfaces)) {
212
+ if (!interfaceAddresses) { continue; }
213
+ for (const addressObject of interfaceAddresses) {
214
+ const addr = addressObject.family === "IPv4"
215
+ ? addressObject.address
216
+ : addressObject.family === "IPv6"
217
+ ? `[${addressObject.address}]`
218
+ : null;
219
+ if (!addr) { continue; }
220
+ if (addr === "127.0.0.1" || addr === "[::1]") { continue; }
221
+ list.push(addr);
214
222
  }
215
223
  }
216
224
 
217
225
  return list;
218
226
  }
219
227
 
228
+ export const isLoopbackAddress = (addressWithPort) =>
229
+ addressWithPort.startsWith("127.0.0.1:") || addressWithPort.startsWith("[::1]:");
230
+
220
231
  export function isIterable(obj) {
221
232
  return obj && (typeof obj[Symbol.iterator] === "function" || typeof obj[Symbol.asyncIterator] === "function");
222
233
  }
@@ -246,7 +257,7 @@ export async function parseMultipart(req, _options) {
246
257
  const END_OF_HEADERS = Buffer.from("\r\n\r\n");
247
258
  const options = {
248
259
  useMemory: false,
249
- ..._options
260
+ ..._options,
250
261
  };
251
262
  const parts = [];
252
263
  let stream = req;
@@ -308,7 +319,7 @@ export async function parseMultipart(req, _options) {
308
319
  headers: {},
309
320
  bodyFile: options.useMemory ? undefined : bodyFile,
310
321
  body: options.useMemory ? [] : createWriteStream(bodyFile, { flags: "wx" }),
311
- size: 0
322
+ size: 0,
312
323
  };
313
324
  parts.push(activePart);
314
325
  };
@@ -341,7 +352,9 @@ export async function parseMultipart(req, _options) {
341
352
  break;
342
353
  }
343
354
 
344
- const isLastBoundary = combined[boundaryIndex + boundary.length] === 45 && combined[boundaryIndex + boundary.length + 1] === 45;
355
+ const isLastBoundary =
356
+ combined[boundaryIndex + boundary.length] === 45 &&
357
+ combined[boundaryIndex + boundary.length + 1] === 45;
345
358
 
346
359
  if (boundaryIndex > 0) {
347
360
  write(combined.subarray(0, boundaryIndex));
@@ -360,7 +373,8 @@ export async function parseMultipart(req, _options) {
360
373
  }
361
374
 
362
375
  activePart.headers = combined
363
- .subarray(boundaryIndex + boundary.length + 2, endOfHeaders).toString()
376
+ .subarray(boundaryIndex + boundary.length + 2, endOfHeaders)
377
+ .toString()
364
378
  .split("\r\n")
365
379
  .reduce((acc, val) => {
366
380
  const [header, value] = val.split(": ");
@@ -404,7 +418,7 @@ export async function parseMultipart(req, _options) {
404
418
  try {
405
419
  await unlink(part.bodyFile);
406
420
  } catch {
407
- // ignore errors
421
+ // ignore errors
408
422
  }
409
423
  });
410
424
  }
package/src/WebServer.js CHANGED
@@ -50,7 +50,6 @@ export const ServeStatic = (root, options = {}) => {
50
50
  };
51
51
 
52
52
  class WebServer {
53
-
54
53
  #app = express();
55
54
  #server;
56
55
  #port;
@@ -60,7 +59,9 @@ class WebServer {
60
59
  #routers = [];
61
60
 
62
61
  async start() {
63
- if (this.#started) { return; }
62
+ if (this.#started) {
63
+ return;
64
+ }
64
65
 
65
66
  this.#started = true;
66
67
 
@@ -116,15 +117,14 @@ class WebServer {
116
117
  router = await new Promise((resolve, reject) => {
117
118
  currentResolve = resolve;
118
119
 
119
- router(req, res,
120
- (error, _req, _res, _next) => {
121
- if (error) {
122
- return reject(error);
123
- }
120
+ router(req, res, (error, _req, _res, _next) => {
121
+ if (error) {
122
+ return reject(error);
123
+ }
124
124
 
125
- // go to next router
126
- resolve(routers.shift());
127
- });
125
+ // go to next router
126
+ resolve(routers.shift());
127
+ });
128
128
  });
129
129
  }
130
130
 
@@ -138,7 +138,7 @@ class WebServer {
138
138
  this.#app.all("*", (req, res) => {
139
139
  res.status(404).send({
140
140
  error: 404,
141
- source: "ws"
141
+ source: "ws",
142
142
  });
143
143
  });
144
144
 
@@ -175,7 +175,6 @@ class WebServer {
175
175
  logger.info("gx.webserver.stop");
176
176
  }
177
177
  }
178
-
179
178
  }
180
179
 
181
180
  export const webserver = new WebServer();
@@ -1,11 +0,0 @@
1
- {
2
- "editor.codeActionsOnSave": {
3
- "source.fixAll.eslint": "explicit"
4
- },
5
- "eslint.validate": [
6
- "javascript"
7
- ],
8
- "editor.tabSize": 4,
9
- "editor.detectIndentation": false,
10
- "editor.formatOnSave": true
11
- }