@muspellheim/shared 0.5.0 → 0.6.1

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.
@@ -1,19 +1,31 @@
1
1
  // Copyright (c) 2023-2024 Falko Schumann. All rights reserved. MIT license.
2
2
 
3
+ /**
4
+ * Provide the configuration of an application.
5
+ *
6
+ * The sources in order of priority:
7
+ *
8
+ * 1. JSON file
9
+ * 2. Environment variables
10
+ * 3. Command line arguments
11
+ *
12
+ * @module
13
+ */
14
+
3
15
  import fsPromises from 'node:fs/promises';
4
16
  import path from 'node:path';
5
17
  import process from 'node:process';
6
18
 
7
- import { deepMerge } from '../util.js';
19
+ import { deepCopy, deepMerge } from '../util.js';
8
20
 
9
21
  // TODO How to handle optional values? Cast to which type?
10
22
  // TODO Use JSON schema to validate the configuration?
11
23
 
12
24
  /**
13
- * Provide the configuration of an application.
25
+ * Reads the configuration from file and environment variables.
14
26
  *
15
27
  * The configuration is read from a JSON file `application.json` from the
16
- * working directory.
28
+ * working directory. Can be configured with `config.name`, `config.location`.
17
29
  *
18
30
  * Example:
19
31
  *
@@ -26,120 +38,111 @@ import { deepMerge } from '../util.js';
26
38
  *
27
39
  * ```javascript
28
40
  * const configuration = ConfigurationProperties.create({
29
- * defaults: {
41
+ * defaultProperties: {
30
42
  * port: 8080,
31
43
  * database: { host: 'localhost', port: 5432 },
32
44
  * },
33
45
  * });
34
46
  * const config = await configuration.get();
35
47
  * ```
48
+ *
49
+ * @template T
36
50
  */
37
51
  export class ConfigurationProperties {
38
52
  /**
39
53
  * Creates an instance of the application configuration.
40
54
  *
55
+ * @template T
41
56
  * @param {object} options The configuration options.
42
- * @param {object} [options.defaults={}] The default configuration.
57
+ * @param {T} [options.defaultProperties=null] The default configuration.
43
58
  * @param {string} [options.prefix=""] The prefix of the properties to get.
44
- * @param {string} [options.name='application.json'] The name of the
45
- * configuration file.
46
- * @param {string[]} [options.location=['.', 'config']] The locations where to
47
- * search for the configuration file.
48
- * @return {ConfigurationProperties} The new instance.
59
+ * @return {ConfigurationProperties<T>} The new instance.
49
60
  */
50
- static create({
51
- defaults = {},
52
- prefix = '',
53
- name = 'application.json',
54
- location = ['.', 'config'],
55
- } = {}) {
56
- return new ConfigurationProperties(
57
- defaults,
58
- prefix,
59
- name,
60
- location,
61
- fsPromises,
62
- );
61
+ static create({ defaultProperties = null, prefix = '' } = {}) {
62
+ return new ConfigurationProperties(defaultProperties, prefix, fsPromises);
63
63
  }
64
64
 
65
65
  /**
66
66
  * Creates a nullable of the application configuration.
67
67
  *
68
+ * @template T
68
69
  * @param {object} options The configuration options.
69
- * @param {object} [options.defaults={}] The default configuration.
70
+ * @param {T} [options.defaultProperties=null] The default configuration.
70
71
  * @param {string} [options.prefix=""] The prefix of the properties to get.
71
- * @param {string} [options.name='application.json'] The name of the
72
- * configuration file.
73
- * @param {string[]} [options.location=['.', 'config']] The locations where to
74
- * search for the configuration file.
75
72
  * @param {object} [options.files={}] The files and file content that are
76
73
  * available.
74
+ * @return {ConfigurationProperties<T>} The new instance.
77
75
  */
78
76
  static createNull({
79
- defaults = {},
77
+ defaultProperties = null,
80
78
  prefix = '',
81
- name = 'application.json',
82
- location = ['.', 'config'],
83
79
  files = {},
84
80
  } = {}) {
85
81
  return new ConfigurationProperties(
86
- defaults,
82
+ defaultProperties,
87
83
  prefix,
88
- name,
89
- location,
90
84
  new FsStub(files),
91
85
  );
92
86
  }
93
87
 
94
- #defaults;
88
+ /** @type {T} */
89
+ #defaultProperties;
90
+
91
+ /** @type {string} */
95
92
  #prefix;
96
- #name;
97
- #locations;
93
+
94
+ /** @type {fsPromises} */
98
95
  #fs;
99
96
 
97
+ /** @type {T} */
98
+ #cached;
99
+
100
100
  /**
101
101
  * The constructor is for internal use. Use the factory methods instead.
102
102
  *
103
+ * @param {T} defaultProperties
104
+ * @param {string} prefix
105
+ * @param {fsPromises} fs
103
106
  * @see ConfigurationProperties.create
104
107
  * @see ConfigurationProperties.createNull
105
108
  */
106
- constructor(
107
- /** @type {object} */ defaults,
108
- /** @type {string} */ prefix,
109
- /** @type {string} */ name,
110
- /** @type {string[]} */ locations,
111
- /** @type {fsPromises} */ fs,
112
- ) {
113
- this.#defaults = defaults;
109
+ constructor(defaultProperties, prefix, fs) {
110
+ this.#defaultProperties = defaultProperties;
114
111
  this.#prefix = prefix;
115
- this.#name = name;
116
- this.#locations = locations;
117
112
  this.#fs = fs;
118
113
  }
119
114
 
120
115
  /**
121
116
  * Loads the configuration from the file.
122
117
  *
123
- * @return {Promise<object>} The configuration object.
118
+ * @return {Promise<T>} The configuration object.
124
119
  */
125
120
  async get() {
121
+ if (this.#cached !== undefined) {
122
+ return this.#cached;
123
+ }
124
+
125
+ // TODO Interpret placeholders like ${foo.bar}
126
+ // TODO Interpret placeholders with default value like ${foo.bar:default}
127
+
126
128
  let config = await this.#loadFile();
127
- // FIXME copy defaults before merging
128
- config = deepMerge(this.#defaults, config);
129
- this.#applyEnvironmentVariables(config);
130
- // TODO apply command line arguments
131
- return this.#getSubset(config, this.#prefix);
129
+ config = deepMerge(deepCopy(this.#defaultProperties), config);
130
+ // TODO apply environment variable APPLICATION_JSON as JSON string
131
+ this.#applyVariables(config, getEnv);
132
+ this.#applyVariables(config, getArg);
133
+ this.#cached = this.#getSubset(config, this.#prefix);
134
+ return this.#cached;
132
135
  }
133
136
 
134
137
  async #loadFile() {
135
- let config = {};
136
- for (const location of this.#locations) {
138
+ const { name, location } = this.#getConfig();
139
+ for (const l of location) {
137
140
  try {
138
- const filePath = path.join(location, this.#name);
141
+ const filePath = path.join(l, name);
139
142
  const content = await this.#fs.readFile(filePath, 'utf-8');
140
- config = JSON.parse(content);
141
- break;
143
+ return JSON.parse(content);
142
144
  } catch (err) {
145
+ // @ts-ignore NodeJS error code
143
146
  if (err.code === 'ENOENT') {
144
147
  // ignore file not found
145
148
  continue;
@@ -148,54 +151,68 @@ export class ConfigurationProperties {
148
151
  throw err;
149
152
  }
150
153
  }
151
- return config;
152
154
  }
153
155
 
154
- #applyEnvironmentVariables(config, path) {
156
+ #getConfig() {
157
+ const defaultConfig = {
158
+ config: {
159
+ name: 'application.json',
160
+ location: ['.', 'config'],
161
+ },
162
+ };
163
+ this.#applyVariables(defaultConfig, getEnv);
164
+ this.#applyVariables(defaultConfig, getArg);
165
+ const {
166
+ config: { name, location },
167
+ } = defaultConfig;
168
+ return { name, location };
169
+ }
170
+
171
+ #applyVariables(config, getValue, path) {
155
172
  // handle object
156
173
  // handle array
157
174
  // handle string
158
175
  // handle number
159
176
  // handle boolean (true, false)
160
- // handle null (empty env var set the value to null)
161
- // if env var is undefined, keep the default value
177
+ // handle null (empty string set the value to null)
178
+ // if value is undefined, keep the default value
162
179
  for (const key in config) {
163
180
  if (typeof config[key] === 'boolean') {
164
- const value = this.#getEnv(key, path);
181
+ const value = getValue(key, path);
165
182
  if (value === null) {
166
183
  config[key] = null;
167
184
  } else if (value) {
168
185
  config[key] = value.toLowerCase() === 'true';
169
186
  }
170
187
  } else if (typeof config[key] === 'number') {
171
- const value = this.#getEnv(key, path);
188
+ const value = getValue(key, path);
172
189
  if (value === null) {
173
190
  config[key] = null;
174
191
  } else if (value) {
175
192
  config[key] = Number(value);
176
193
  }
177
194
  } else if (typeof config[key] === 'string') {
178
- const value = this.#getEnv(key, path);
195
+ const value = getValue(key, path);
179
196
  if (value === null) {
180
197
  config[key] = null;
181
198
  } else if (value) {
182
199
  config[key] = String(value);
183
200
  }
184
201
  } else if (config[key] === null) {
185
- const value = this.#getEnv(key, path);
202
+ const value = getValue(key, path);
186
203
  if (value === null) {
187
204
  config[key] = null;
188
205
  } else if (value) {
189
206
  config[key] = value;
190
207
  }
191
208
  } else if (typeof config[key] === 'object') {
192
- const value = this.#getEnv(key, path);
209
+ const value = getValue(key, path);
193
210
  if (value === null) {
194
211
  config[key] = null;
195
212
  } else if (Array.isArray(config[key]) && value) {
196
213
  config[key] = value.split(',');
197
214
  } else {
198
- this.#applyEnvironmentVariables(config[key], key);
215
+ this.#applyVariables(config[key], getValue, key);
199
216
  }
200
217
  } else {
201
218
  throw new Error(`Unsupported type: ${typeof config[key]}`);
@@ -203,19 +220,6 @@ export class ConfigurationProperties {
203
220
  }
204
221
  }
205
222
 
206
- #getEnv(key, path = '') {
207
- let envKey = key;
208
- if (path) {
209
- envKey = `${path}_${envKey}`;
210
- }
211
- envKey = envKey.toUpperCase();
212
- const value = process.env[envKey];
213
- if (value === '') {
214
- return null;
215
- }
216
- return value;
217
- }
218
-
219
223
  #getSubset(config, prefix) {
220
224
  if (prefix === '') {
221
225
  return config;
@@ -230,9 +234,45 @@ export class ConfigurationProperties {
230
234
  }
231
235
  }
232
236
 
237
+ function getEnv(key, path = '') {
238
+ if (path) {
239
+ key = `${path}_${key}`;
240
+ }
241
+ key = key.toUpperCase();
242
+ const value = process.env[key];
243
+ if (value === '') {
244
+ return null;
245
+ }
246
+ return value;
247
+ }
248
+
249
+ function getArg(key, path = '') {
250
+ if (path) {
251
+ key = `${path}.${key}`;
252
+ }
253
+ key = `--${key}=`;
254
+ const keyValue = process.argv.find((arg) => arg.startsWith(key));
255
+ if (keyValue == null) {
256
+ return;
257
+ }
258
+
259
+ const [, value] = keyValue.split('=');
260
+ if (value === '') {
261
+ return null;
262
+ }
263
+ return value;
264
+ }
265
+
266
+ /**
267
+ * @ignore
268
+ */
233
269
  class FsStub {
270
+ /** @type {Record<string, string>} */
234
271
  #files;
235
272
 
273
+ /**
274
+ * @param {Record<string, string>} files
275
+ */
236
276
  constructor(files) {
237
277
  this.#files = files;
238
278
  }
@@ -240,15 +280,12 @@ class FsStub {
240
280
  readFile(path) {
241
281
  const fileContent = this.#files[path];
242
282
  if (fileContent == null) {
243
- const err = new Error(`File not found: ${path}`);
283
+ const err = new Error(`No such file or directory`);
284
+ // @ts-ignore NodeJS error code
244
285
  err.code = 'ENOENT';
245
286
  throw err;
246
287
  }
247
288
 
248
- if (typeof fileContent === 'string') {
249
- return fileContent;
250
- }
251
-
252
289
  return JSON.stringify(fileContent);
253
290
  }
254
291
  }
@@ -1,14 +1,16 @@
1
1
  // Copyright (c) 2023-2024 Falko Schumann. All rights reserved. MIT license.
2
2
 
3
3
  /**
4
- * @import * as express from 'express'
4
+ * @import express from 'express'
5
5
  */
6
6
 
7
+ // TODO Remove dependency to express
8
+
7
9
  export function runSafe(/** @type {express.RequestHandler} */ handler) {
8
10
  // TODO runSafe is obsolete with with Express 5
9
11
  return async (request, response, next) => {
10
12
  try {
11
- await handler(request, response);
13
+ await handler(request, response, next);
12
14
  } catch (error) {
13
15
  next(error);
14
16
  }
package/lib/node/index.js CHANGED
@@ -1,10 +1,9 @@
1
1
  // Copyright (c) 2023-2024 Falko Schumann. All rights reserved. MIT license.
2
2
 
3
- export * from '../index.js';
4
-
5
3
  export * from './actuator-controller.js';
6
4
  export * from './configuration-properties.js';
7
5
  export * from './handler.js';
8
6
  export * from './logging.js';
9
7
  export * from './long-polling.js';
10
8
  export * from './sse-emitter.js';
9
+ export * from './static-files-controller.js';
@@ -46,9 +46,12 @@ export class FileHandler extends Handler {
46
46
  await fsPromises.rm(this.#filename);
47
47
  }
48
48
  } catch (error) {
49
- // ignore error if file does not exist
50
- if (error.code !== 'ENOENT') {
49
+ // @ts-ignore NodeJS error code
50
+ if (error.code === 'ENOENT') {
51
+ // ignore error if file does not exist
51
52
  console.error(error);
53
+ } else {
54
+ throw error;
52
55
  }
53
56
  }
54
57
  }
@@ -6,6 +6,8 @@
6
6
 
7
7
  import * as handler from './handler.js';
8
8
 
9
+ // TODO Remove dependency to express
10
+
9
11
  export class LongPolling {
10
12
  #version = 0;
11
13
  #waiting = [];
@@ -0,0 +1,15 @@
1
+ // Copyright (c) 2024 Falko Schumann. All rights reserved. MIT license.
2
+
3
+ import path from 'node:path';
4
+ import express from 'express';
5
+
6
+ export class StaticFilesController {
7
+ /**
8
+ * @param {express.Express} app
9
+ * @param {string} [directory="./public"]
10
+ * @param {string} [route="/"]
11
+ */
12
+ constructor(app, directory = './public', route = '/') {
13
+ app.use(route, express.static(path.join(directory)));
14
+ }
15
+ }
package/lib/sse-client.js CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  import { MessageClient } from './message-client.js';
4
4
 
5
+ /**
6
+ * @ignore @typedef {typeof EventSource} EventSourceConstructor
7
+ */
8
+
5
9
  /**
6
10
  * A client for the server-sent events protocol.
7
11
  *
@@ -32,29 +36,35 @@ export class SseClient extends MessageClient {
32
36
  /**
33
37
  * The constructor is for internal use. Use the factory methods instead.
34
38
  *
39
+ * @param {EventSourceConstructor} eventSourceConstructor
35
40
  * @see SseClient.create
36
41
  * @see SseClient.createNull
37
42
  */
38
- constructor(/** @type {function(new:EventSource)} */ eventSourceConstructor) {
43
+ constructor(eventSourceConstructor) {
39
44
  super();
40
45
  this.#eventSourceConstructor = eventSourceConstructor;
41
46
  }
42
47
 
48
+ /**
49
+ * @override
50
+ */
43
51
  get isConnected() {
44
52
  return this.#eventSource?.readyState === this.#eventSourceConstructor.OPEN;
45
53
  }
46
54
 
55
+ /**
56
+ * @override
57
+ */
47
58
  get url() {
48
59
  return this.#eventSource?.url;
49
60
  }
50
61
 
51
62
  /**
52
63
  * Connects to the server.
53
- *
54
64
  * @param {URL | string} url The server URL to connect to.
55
- * @param {string} [eventName=message] The optional event type to listen to.
65
+ * @param {string} [eventName] The optional event type to listen to.
66
+ * @override
56
67
  */
57
-
58
68
  async connect(url, eventName = 'message') {
59
69
  await new Promise((resolve, reject) => {
60
70
  if (this.isConnected) {
@@ -68,13 +78,11 @@ export class SseClient extends MessageClient {
68
78
  this.#handleOpen(e);
69
79
  resolve();
70
80
  });
71
- this.#eventSource.addEventListener(
72
- eventName,
73
- (e) => this.#handleMessage(e),
81
+ this.#eventSource.addEventListener(eventName, (e) =>
82
+ this.#handleMessage(e),
74
83
  );
75
- this.#eventSource.addEventListener(
76
- 'error',
77
- (e) => this.#handleError(e),
84
+ this.#eventSource.addEventListener('error', (e) =>
85
+ this.#handleError(e),
78
86
  );
79
87
  } catch (error) {
80
88
  reject(error);
@@ -82,6 +90,9 @@ export class SseClient extends MessageClient {
82
90
  });
83
91
  }
84
92
 
93
+ /**
94
+ * @override
95
+ */
85
96
  async close() {
86
97
  await new Promise((resolve, reject) => {
87
98
  if (!this.isConnected) {
package/lib/time.js CHANGED
@@ -25,12 +25,12 @@ export class Clock {
25
25
  /**
26
26
  * Creates a clock using a fixed date.
27
27
  *
28
- * @param {Date} [fixed='2024-02-21T19:16:00Z'] The fixed date of the clock.
28
+ * @param {Date} [date='2024-02-21T19:16:00Z'] The fixed date of the clock.
29
29
  * @return {Clock} A clock that returns alaways a fixed date.
30
30
  * @see Clock#add
31
31
  */
32
- static fixed(fixedDate = new Date('2024-02-21T19:16:00Z')) {
33
- return new Clock(fixedDate);
32
+ static fixed(date = new Date('2024-02-21T19:16:00Z')) {
33
+ return new Clock(date);
34
34
  }
35
35
 
36
36
  #date;
@@ -241,10 +241,11 @@ export class Duration {
241
241
  * @readonly
242
242
  */
243
243
  get secondsPart() {
244
- const value = (this.millis -
245
- this.daysPart * 86400000 -
246
- this.hoursPart * 3600000 -
247
- this.minutesPart * 60000) /
244
+ const value =
245
+ (this.millis -
246
+ this.daysPart * 86400000 -
247
+ this.hoursPart * 3600000 -
248
+ this.minutesPart * 60000) /
248
249
  1000;
249
250
  return this.isNegative() ? Math.ceil(value) : Math.floor(value);
250
251
  }
@@ -256,7 +257,8 @@ export class Duration {
256
257
  * @readonly
257
258
  */
258
259
  get millisPart() {
259
- const value = this.millis -
260
+ const value =
261
+ this.millis -
260
262
  this.daysPart * 86400000 -
261
263
  this.hoursPart * 3600000 -
262
264
  this.minutesPart * 60000 -
@@ -267,7 +269,7 @@ export class Duration {
267
269
  /**
268
270
  * Checks if the duration is zero.
269
271
  *
270
- * @type {boolean}
272
+ * @return {boolean}
271
273
  */
272
274
  isZero() {
273
275
  return this.millis === 0;
@@ -276,7 +278,7 @@ export class Duration {
276
278
  /**
277
279
  * Checks if the duration is negative.
278
280
  *
279
- * @type {boolean}
281
+ * @return {boolean}
280
282
  */
281
283
  isNegative() {
282
284
  return this.millis < 0;
@@ -285,7 +287,7 @@ export class Duration {
285
287
  /**
286
288
  * Checks if the duration is positive.
287
289
  *
288
- * @type {boolean}
290
+ * @return {boolean}
289
291
  */
290
292
  isPositive() {
291
293
  return this.millis > 0;