@muspellheim/shared 0.4.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/LICENSE.txt +21 -0
- package/README.md +32 -0
- package/deno.json +12 -0
- package/dist/index.cjs +4455 -0
- package/lib/assert.js +15 -0
- package/lib/browser/components.js +165 -0
- package/lib/browser/index.js +5 -0
- package/lib/color.js +139 -0
- package/lib/configurable-responses.js +69 -0
- package/lib/feature-toggle.js +9 -0
- package/lib/health.js +514 -0
- package/lib/index.js +22 -0
- package/lib/lang.js +102 -0
- package/lib/logging.js +598 -0
- package/lib/long-polling-client.js +175 -0
- package/lib/message-client.js +74 -0
- package/lib/metrics.js +120 -0
- package/lib/node/actuator-controller.js +103 -0
- package/lib/node/configuration-properties.js +254 -0
- package/lib/node/handler.js +23 -0
- package/lib/node/index.js +10 -0
- package/lib/node/logging.js +57 -0
- package/lib/node/long-polling.js +81 -0
- package/lib/node/sse-emitter.js +104 -0
- package/lib/output-tracker.js +89 -0
- package/lib/service-locator.js +44 -0
- package/lib/sse-client.js +152 -0
- package/lib/stop-watch.js +54 -0
- package/lib/store.js +129 -0
- package/lib/time.js +443 -0
- package/lib/util.js +361 -0
- package/lib/validation.js +299 -0
- package/lib/vector.js +194 -0
- package/lib/web-socket-client.js +245 -0
- package/package.json +28 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// Copyright (c) 2023-2024 Falko Schumann. All rights reserved. MIT license.
|
|
2
|
+
|
|
3
|
+
import fsPromises from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
|
|
7
|
+
import { deepMerge } from '../util.js';
|
|
8
|
+
|
|
9
|
+
// TODO How to handle optional values? Cast to which type?
|
|
10
|
+
// TODO Use JSON schema to validate the configuration?
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Provide the configuration of an application.
|
|
14
|
+
*
|
|
15
|
+
* The configuration is read from a JSON file `application.json` from the
|
|
16
|
+
* working directory.
|
|
17
|
+
*
|
|
18
|
+
* Example:
|
|
19
|
+
*
|
|
20
|
+
* ```javascript
|
|
21
|
+
* const configuration = ConfigurationProperties.create();
|
|
22
|
+
* const config = await configuration.get();
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* With default values:
|
|
26
|
+
*
|
|
27
|
+
* ```javascript
|
|
28
|
+
* const configuration = ConfigurationProperties.create({
|
|
29
|
+
* defaults: {
|
|
30
|
+
* port: 8080,
|
|
31
|
+
* database: { host: 'localhost', port: 5432 },
|
|
32
|
+
* },
|
|
33
|
+
* });
|
|
34
|
+
* const config = await configuration.get();
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export class ConfigurationProperties {
|
|
38
|
+
/**
|
|
39
|
+
* Creates an instance of the application configuration.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} options The configuration options.
|
|
42
|
+
* @param {object} [options.defaults={}] The default configuration.
|
|
43
|
+
* @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.
|
|
49
|
+
*/
|
|
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
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Creates a nullable of the application configuration.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} options The configuration options.
|
|
69
|
+
* @param {object} [options.defaults={}] The default configuration.
|
|
70
|
+
* @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
|
+
* @param {object} [options.files={}] The files and file content that are
|
|
76
|
+
* available.
|
|
77
|
+
*/
|
|
78
|
+
static createNull({
|
|
79
|
+
defaults = {},
|
|
80
|
+
prefix = '',
|
|
81
|
+
name = 'application.json',
|
|
82
|
+
location = ['.', 'config'],
|
|
83
|
+
files = {},
|
|
84
|
+
} = {}) {
|
|
85
|
+
return new ConfigurationProperties(
|
|
86
|
+
defaults,
|
|
87
|
+
prefix,
|
|
88
|
+
name,
|
|
89
|
+
location,
|
|
90
|
+
new FsStub(files),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#defaults;
|
|
95
|
+
#prefix;
|
|
96
|
+
#name;
|
|
97
|
+
#locations;
|
|
98
|
+
#fs;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The constructor is for internal use. Use the factory methods instead.
|
|
102
|
+
*
|
|
103
|
+
* @see ConfigurationProperties.create
|
|
104
|
+
* @see ConfigurationProperties.createNull
|
|
105
|
+
*/
|
|
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;
|
|
114
|
+
this.#prefix = prefix;
|
|
115
|
+
this.#name = name;
|
|
116
|
+
this.#locations = locations;
|
|
117
|
+
this.#fs = fs;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Loads the configuration from the file.
|
|
122
|
+
*
|
|
123
|
+
* @return {Promise<object>} The configuration object.
|
|
124
|
+
*/
|
|
125
|
+
async get() {
|
|
126
|
+
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);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async #loadFile() {
|
|
135
|
+
let config = {};
|
|
136
|
+
for (const location of this.#locations) {
|
|
137
|
+
try {
|
|
138
|
+
const filePath = path.join(location, this.#name);
|
|
139
|
+
const content = await this.#fs.readFile(filePath, 'utf-8');
|
|
140
|
+
config = JSON.parse(content);
|
|
141
|
+
break;
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (err.code === 'ENOENT') {
|
|
144
|
+
// ignore file not found
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return config;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#applyEnvironmentVariables(config, path) {
|
|
155
|
+
// handle object
|
|
156
|
+
// handle array
|
|
157
|
+
// handle string
|
|
158
|
+
// handle number
|
|
159
|
+
// 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
|
|
162
|
+
for (const key in config) {
|
|
163
|
+
if (typeof config[key] === 'boolean') {
|
|
164
|
+
const value = this.#getEnv(key, path);
|
|
165
|
+
if (value === null) {
|
|
166
|
+
config[key] = null;
|
|
167
|
+
} else if (value) {
|
|
168
|
+
config[key] = value.toLowerCase() === 'true';
|
|
169
|
+
}
|
|
170
|
+
} else if (typeof config[key] === 'number') {
|
|
171
|
+
const value = this.#getEnv(key, path);
|
|
172
|
+
if (value === null) {
|
|
173
|
+
config[key] = null;
|
|
174
|
+
} else if (value) {
|
|
175
|
+
config[key] = Number(value);
|
|
176
|
+
}
|
|
177
|
+
} else if (typeof config[key] === 'string') {
|
|
178
|
+
const value = this.#getEnv(key, path);
|
|
179
|
+
if (value === null) {
|
|
180
|
+
config[key] = null;
|
|
181
|
+
} else if (value) {
|
|
182
|
+
config[key] = String(value);
|
|
183
|
+
}
|
|
184
|
+
} else if (config[key] === null) {
|
|
185
|
+
const value = this.#getEnv(key, path);
|
|
186
|
+
if (value === null) {
|
|
187
|
+
config[key] = null;
|
|
188
|
+
} else if (value) {
|
|
189
|
+
config[key] = value;
|
|
190
|
+
}
|
|
191
|
+
} else if (typeof config[key] === 'object') {
|
|
192
|
+
const value = this.#getEnv(key, path);
|
|
193
|
+
if (value === null) {
|
|
194
|
+
config[key] = null;
|
|
195
|
+
} else if (Array.isArray(config[key]) && value) {
|
|
196
|
+
config[key] = value.split(',');
|
|
197
|
+
} else {
|
|
198
|
+
this.#applyEnvironmentVariables(config[key], key);
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
throw new Error(`Unsupported type: ${typeof config[key]}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
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
|
+
#getSubset(config, prefix) {
|
|
220
|
+
if (prefix === '') {
|
|
221
|
+
return config;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const [key, ...rest] = prefix.split('.');
|
|
225
|
+
if (rest.length === 0) {
|
|
226
|
+
return config[key];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return this.#getSubset(config[key], rest.join('.'));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
class FsStub {
|
|
234
|
+
#files;
|
|
235
|
+
|
|
236
|
+
constructor(files) {
|
|
237
|
+
this.#files = files;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
readFile(path) {
|
|
241
|
+
const fileContent = this.#files[path];
|
|
242
|
+
if (fileContent == null) {
|
|
243
|
+
const err = new Error(`File not found: ${path}`);
|
|
244
|
+
err.code = 'ENOENT';
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (typeof fileContent === 'string') {
|
|
249
|
+
return fileContent;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return JSON.stringify(fileContent);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Copyright (c) 2023-2024 Falko Schumann. All rights reserved. MIT license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @import * as express from 'express'
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export function runSafe(/** @type {express.RequestHandler} */ handler) {
|
|
8
|
+
// TODO runSafe is obsolete with with Express 5
|
|
9
|
+
return async (request, response, next) => {
|
|
10
|
+
try {
|
|
11
|
+
await handler(request, response);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
next(error);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function reply(
|
|
19
|
+
/** @type {express.Response} */ response,
|
|
20
|
+
{ status = 200, headers = { 'Content-Type': 'text/plain' }, body = '' } = {},
|
|
21
|
+
) {
|
|
22
|
+
response.status(status).header(headers).send(body);
|
|
23
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Copyright (c) 2023-2024 Falko Schumann. All rights reserved. MIT license.
|
|
2
|
+
|
|
3
|
+
export * from '../index.js';
|
|
4
|
+
|
|
5
|
+
export * from './actuator-controller.js';
|
|
6
|
+
export * from './configuration-properties.js';
|
|
7
|
+
export * from './handler.js';
|
|
8
|
+
export * from './logging.js';
|
|
9
|
+
export * from './long-polling.js';
|
|
10
|
+
export * from './sse-emitter.js';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Copyright (c) 2023-2024 Falko Schumann. All rights reserved. MIT license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @import { LogRecord } from '../logging.js';
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fsPromises from 'node:fs/promises';
|
|
8
|
+
|
|
9
|
+
import { Handler } from '../logging.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A `Handler` that writes log messages to a file.
|
|
13
|
+
*
|
|
14
|
+
* @extends {Handler}
|
|
15
|
+
*/
|
|
16
|
+
export class FileHandler extends Handler {
|
|
17
|
+
#filename;
|
|
18
|
+
#limit;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Initialize a new `FileHandler`.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} filename The name of the file to write log messages to.
|
|
24
|
+
* @param {number} [limit=0] The maximum size of the file in bytes before it
|
|
25
|
+
* is rotated.
|
|
26
|
+
*/
|
|
27
|
+
constructor(filename, limit = 0) {
|
|
28
|
+
super();
|
|
29
|
+
this.#filename = filename;
|
|
30
|
+
this.#limit = limit < 0 ? 0 : limit;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @override */
|
|
34
|
+
async publish(/** @type {LogRecord} */ record) {
|
|
35
|
+
if (!this.isLoggable(record.level)) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const message = this.formatter.format(record);
|
|
40
|
+
if (this.#limit > 0) {
|
|
41
|
+
try {
|
|
42
|
+
const stats = await fsPromises.stat(this.#filename);
|
|
43
|
+
const fileSize = stats.size;
|
|
44
|
+
const newSize = fileSize + message.length;
|
|
45
|
+
if (newSize > this.#limit) {
|
|
46
|
+
await fsPromises.rm(this.#filename);
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
// ignore error if file does not exist
|
|
50
|
+
if (error.code !== 'ENOENT') {
|
|
51
|
+
console.error(error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
await fsPromises.appendFile(this.#filename, message + '\n');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Copyright (c) 2023-2024 Falko Schumann. All rights reserved. MIT license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @import * as express from 'express'
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as handler from './handler.js';
|
|
8
|
+
|
|
9
|
+
export class LongPolling {
|
|
10
|
+
#version = 0;
|
|
11
|
+
#waiting = [];
|
|
12
|
+
#getData;
|
|
13
|
+
|
|
14
|
+
constructor(/** @type {function(): Promise<*>} */ getData) {
|
|
15
|
+
this.#getData = getData;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async poll(
|
|
19
|
+
/** @type {express.Request} */ request,
|
|
20
|
+
/** @type {express.Response} */ response,
|
|
21
|
+
) {
|
|
22
|
+
if (this.#isCurrentVersion(request)) {
|
|
23
|
+
const responseData = await this.#tryLongPolling(request);
|
|
24
|
+
handler.reply(response, responseData);
|
|
25
|
+
} else {
|
|
26
|
+
const responseData = await this.#getResponse();
|
|
27
|
+
handler.reply(response, responseData);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async send() {
|
|
32
|
+
this.#version++;
|
|
33
|
+
const response = await this.#getResponse();
|
|
34
|
+
this.#waiting.forEach((resolve) => resolve(response));
|
|
35
|
+
this.#waiting = [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#isCurrentVersion(/** @type {express.Request} */ request) {
|
|
39
|
+
const tag = /"(.*)"/.exec(request.get('If-None-Match'));
|
|
40
|
+
return tag && tag[1] === String(this.#version);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#tryLongPolling(/** @type {express.Request} */ request) {
|
|
44
|
+
const time = this.#getPollingTime(request);
|
|
45
|
+
if (time == null) {
|
|
46
|
+
return { status: 304 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return this.#waitForChange(time);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#getPollingTime(/** @type {express.Request} */ request) {
|
|
53
|
+
const wait = /\bwait=(\d+)/.exec(request.get('Prefer'));
|
|
54
|
+
return wait != null ? Number(wait[1]) : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#waitForChange(/** @type {number} */ time) {
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
this.#waiting.push(resolve);
|
|
60
|
+
setTimeout(() => {
|
|
61
|
+
if (this.#waiting.includes(resolve)) {
|
|
62
|
+
this.#waiting = this.#waiting.filter((r) => r !== resolve);
|
|
63
|
+
resolve({ status: 304 });
|
|
64
|
+
}
|
|
65
|
+
}, time * 1000);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async #getResponse() {
|
|
70
|
+
const data = await this.#getData();
|
|
71
|
+
const body = JSON.stringify(data);
|
|
72
|
+
return {
|
|
73
|
+
headers: {
|
|
74
|
+
'Content-Type': 'application/json',
|
|
75
|
+
ETag: `"${this.#version}"`,
|
|
76
|
+
'Cache-Control': 'no-store',
|
|
77
|
+
},
|
|
78
|
+
body,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Copyright (c) 2023-2024 Falko Schumann. All rights reserved. MIT license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @import http from 'node:http'
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* An object for sending
|
|
9
|
+
* [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events).
|
|
10
|
+
*/
|
|
11
|
+
export class SseEmitter {
|
|
12
|
+
/** @type {?number} */ #timeout;
|
|
13
|
+
/** @type {http.ServerResponse|undefined} */ #response;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Creates a new SSE emitter with an optional timeout.
|
|
17
|
+
*
|
|
18
|
+
* @param {number} [timeout] The timeout in milliseconds after which the
|
|
19
|
+
* connection will be closed.
|
|
20
|
+
*/
|
|
21
|
+
constructor(timeout) {
|
|
22
|
+
this.#timeout = timeout;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The timeout in milliseconds after which the connection will be closed or
|
|
27
|
+
* undefined if no timeout is set.
|
|
28
|
+
*
|
|
29
|
+
* @type {number|undefined}
|
|
30
|
+
*/
|
|
31
|
+
get timeout() {
|
|
32
|
+
return this.#timeout;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Sets and extends the response object for sending Server-Sent Events.
|
|
37
|
+
*
|
|
38
|
+
* @param {http.ServerResponse} outputMessage The response object to use.
|
|
39
|
+
*/
|
|
40
|
+
extendResponse(outputMessage) {
|
|
41
|
+
// TODO check HTTP version, is it HTTP/2 when using EventSource?
|
|
42
|
+
outputMessage.statusCode = 200;
|
|
43
|
+
this.#response = outputMessage
|
|
44
|
+
.setHeader('Content-Type', 'text/event-stream')
|
|
45
|
+
.setHeader('Cache-Control', 'no-cache')
|
|
46
|
+
.setHeader('Keep-Alive', 'timeout=60')
|
|
47
|
+
.setHeader('Connection', 'keep-alive');
|
|
48
|
+
|
|
49
|
+
if (this.timeout != null) {
|
|
50
|
+
const timeoutId = setTimeout(() => this.#close(), this.timeout);
|
|
51
|
+
this.#response.addListener('close', () => clearTimeout(timeoutId));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Sends a SSE event.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} event The event to send.
|
|
59
|
+
* @param {string} [event.id] Add a SSE "id" line.
|
|
60
|
+
* @param {string} [event.name] Add a SSE "event" line.
|
|
61
|
+
* @param {number} [event.reconnectTime] Add a SSE "retry" line.
|
|
62
|
+
* @param {string} [event.comment] Add a SSE "comment" line.
|
|
63
|
+
* @param {string|object} [event.data] Add a SSE "data" line.
|
|
64
|
+
*/
|
|
65
|
+
send({ id, name, reconnectTime, comment, data } = {}) {
|
|
66
|
+
if (comment != null) {
|
|
67
|
+
this.#response.write(`: ${comment}\n`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (name != null) {
|
|
71
|
+
this.#response.write(`event: ${name}\n`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (data != null) {
|
|
75
|
+
if (typeof data === 'object') {
|
|
76
|
+
data = JSON.stringify(data);
|
|
77
|
+
} else {
|
|
78
|
+
data = String(data).replaceAll('\n', '\ndata: ');
|
|
79
|
+
}
|
|
80
|
+
this.#response.write(`data: ${data}\n`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (id != null) {
|
|
84
|
+
this.#response.write(`id: ${id}\n`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (reconnectTime != null) {
|
|
88
|
+
this.#response.write(`retry: ${reconnectTime}\n`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
this.#response.write('\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Simulates a timeout.
|
|
96
|
+
*/
|
|
97
|
+
simulateTimeout() {
|
|
98
|
+
this.#close();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#close() {
|
|
102
|
+
this.#response.end();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Copyright (c) 2023-2024 Falko Schumann. All rights reserved. MIT license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tracks output events.
|
|
5
|
+
*
|
|
6
|
+
* This is one of the nullability patterns from James Shore's article on
|
|
7
|
+
* [testing without mocks](https://www.jamesshore.com/v2/projects/nullables/testing-without-mocks#output-tracking).
|
|
8
|
+
*
|
|
9
|
+
* Example implementation of an event store:
|
|
10
|
+
*
|
|
11
|
+
* ```javascript
|
|
12
|
+
* async record(event) {
|
|
13
|
+
* // ...
|
|
14
|
+
* this.dispatchEvent(new CustomEvent(EVENT_RECORDED_EVENT, { detail: event }));
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* trackEventsRecorded() {
|
|
18
|
+
* return new OutputTracker(this, EVENT_RECORDED_EVENT);
|
|
19
|
+
* }
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Example usage:
|
|
23
|
+
*
|
|
24
|
+
* ```javascript
|
|
25
|
+
* const eventsRecorded = eventStore.trackEventsRecorded();
|
|
26
|
+
* // ...
|
|
27
|
+
* const data = eventsRecorded.data(); // [event1, event2, ...]
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export class OutputTracker {
|
|
31
|
+
/**
|
|
32
|
+
* Creates a tracker for a specific event of an event target.
|
|
33
|
+
*
|
|
34
|
+
* @param {EventTarget} eventTarget The event target to track.
|
|
35
|
+
* @param {string} event The event name to track.
|
|
36
|
+
*/
|
|
37
|
+
static create(eventTarget, event) {
|
|
38
|
+
return new OutputTracker(eventTarget, event);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#eventTarget;
|
|
42
|
+
#event;
|
|
43
|
+
#tracker;
|
|
44
|
+
#data = [];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Creates a tracker for a specific event of an event target.
|
|
48
|
+
*
|
|
49
|
+
* @param {EventTarget} eventTarget The event target to track.
|
|
50
|
+
* @param {string} event The event name to track.
|
|
51
|
+
*/
|
|
52
|
+
constructor(
|
|
53
|
+
/** @type {EventTarget} */ eventTarget,
|
|
54
|
+
/** @type {string} */ event,
|
|
55
|
+
) {
|
|
56
|
+
this.#eventTarget = eventTarget;
|
|
57
|
+
this.#event = event;
|
|
58
|
+
this.#tracker = (event) => this.#data.push(event.detail);
|
|
59
|
+
|
|
60
|
+
this.#eventTarget.addEventListener(this.#event, this.#tracker);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Returns the tracked data.
|
|
65
|
+
*
|
|
66
|
+
* @return {Array} The tracked data.
|
|
67
|
+
*/
|
|
68
|
+
get data() {
|
|
69
|
+
return this.#data;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Clears the tracked data and returns the cleared data.
|
|
74
|
+
*
|
|
75
|
+
* @return {Array} The cleared data.
|
|
76
|
+
*/
|
|
77
|
+
clear() {
|
|
78
|
+
const result = [...this.#data];
|
|
79
|
+
this.#data.length = 0;
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Stops tracking.
|
|
85
|
+
*/
|
|
86
|
+
stop() {
|
|
87
|
+
this.#eventTarget.removeEventListener(this.#event, this.#tracker);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Copyright (c) 2023-2024 Falko Schumann. All rights reserved. MIT license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A central place to register and resolve services.
|
|
5
|
+
*/
|
|
6
|
+
export class ServiceLocator {
|
|
7
|
+
static #instance = new ServiceLocator();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Gets the default service locator.
|
|
11
|
+
*
|
|
12
|
+
* @return {ServiceLocator} The default service locator.
|
|
13
|
+
*/
|
|
14
|
+
static getDefault() {
|
|
15
|
+
return ServiceLocator.#instance;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
#services = new Map();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Registers a service with name.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} name The name of the service.
|
|
24
|
+
* @param {object|Function} service The service object or constructor.
|
|
25
|
+
*/
|
|
26
|
+
register(name, service) {
|
|
27
|
+
this.#services.set(name, service);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolves a service by name.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} name The name of the service.
|
|
34
|
+
* @return {object} The service object.
|
|
35
|
+
*/
|
|
36
|
+
resolve(name) {
|
|
37
|
+
const service = this.#services.get(name);
|
|
38
|
+
if (service == null) {
|
|
39
|
+
throw new Error(`Service not found: ${name}.`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return typeof service === 'function' ? service() : service;
|
|
43
|
+
}
|
|
44
|
+
}
|