fluxion-ts 0.1.2 → 0.1.4
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/README.md +1 -1
- package/dist/index.mjs +1571 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +4 -4
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1571 @@
|
|
|
1
|
+
import fs, { existsSync } from 'node:fs';
|
|
2
|
+
import cluster from 'node:cluster';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
const ANSI_BACKGROUND_OFFSET = 10;
|
|
8
|
+
|
|
9
|
+
const wrapAnsi16 = (offset = 0) => code => `\u001B[${code + offset}m`;
|
|
10
|
+
|
|
11
|
+
const wrapAnsi256 = (offset = 0) => code => `\u001B[${38 + offset};5;${code}m`;
|
|
12
|
+
|
|
13
|
+
const wrapAnsi16m = (offset = 0) => (red, green, blue) => `\u001B[${38 + offset};2;${red};${green};${blue}m`;
|
|
14
|
+
|
|
15
|
+
const styles$1 = {
|
|
16
|
+
modifier: {
|
|
17
|
+
reset: [0, 0],
|
|
18
|
+
// 21 isn't widely supported and 22 does the same thing
|
|
19
|
+
bold: [1, 22],
|
|
20
|
+
dim: [2, 22],
|
|
21
|
+
italic: [3, 23],
|
|
22
|
+
underline: [4, 24],
|
|
23
|
+
overline: [53, 55],
|
|
24
|
+
inverse: [7, 27],
|
|
25
|
+
hidden: [8, 28],
|
|
26
|
+
strikethrough: [9, 29],
|
|
27
|
+
},
|
|
28
|
+
color: {
|
|
29
|
+
black: [30, 39],
|
|
30
|
+
red: [31, 39],
|
|
31
|
+
green: [32, 39],
|
|
32
|
+
yellow: [33, 39],
|
|
33
|
+
blue: [34, 39],
|
|
34
|
+
magenta: [35, 39],
|
|
35
|
+
cyan: [36, 39],
|
|
36
|
+
white: [37, 39],
|
|
37
|
+
|
|
38
|
+
// Bright color
|
|
39
|
+
blackBright: [90, 39],
|
|
40
|
+
gray: [90, 39], // Alias of `blackBright`
|
|
41
|
+
grey: [90, 39], // Alias of `blackBright`
|
|
42
|
+
redBright: [91, 39],
|
|
43
|
+
greenBright: [92, 39],
|
|
44
|
+
yellowBright: [93, 39],
|
|
45
|
+
blueBright: [94, 39],
|
|
46
|
+
magentaBright: [95, 39],
|
|
47
|
+
cyanBright: [96, 39],
|
|
48
|
+
whiteBright: [97, 39],
|
|
49
|
+
},
|
|
50
|
+
bgColor: {
|
|
51
|
+
bgBlack: [40, 49],
|
|
52
|
+
bgRed: [41, 49],
|
|
53
|
+
bgGreen: [42, 49],
|
|
54
|
+
bgYellow: [43, 49],
|
|
55
|
+
bgBlue: [44, 49],
|
|
56
|
+
bgMagenta: [45, 49],
|
|
57
|
+
bgCyan: [46, 49],
|
|
58
|
+
bgWhite: [47, 49],
|
|
59
|
+
|
|
60
|
+
// Bright color
|
|
61
|
+
bgBlackBright: [100, 49],
|
|
62
|
+
bgGray: [100, 49], // Alias of `bgBlackBright`
|
|
63
|
+
bgGrey: [100, 49], // Alias of `bgBlackBright`
|
|
64
|
+
bgRedBright: [101, 49],
|
|
65
|
+
bgGreenBright: [102, 49],
|
|
66
|
+
bgYellowBright: [103, 49],
|
|
67
|
+
bgBlueBright: [104, 49],
|
|
68
|
+
bgMagentaBright: [105, 49],
|
|
69
|
+
bgCyanBright: [106, 49],
|
|
70
|
+
bgWhiteBright: [107, 49],
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
Object.keys(styles$1.modifier);
|
|
75
|
+
const foregroundColorNames = Object.keys(styles$1.color);
|
|
76
|
+
const backgroundColorNames = Object.keys(styles$1.bgColor);
|
|
77
|
+
[...foregroundColorNames, ...backgroundColorNames];
|
|
78
|
+
|
|
79
|
+
function assembleStyles() {
|
|
80
|
+
const codes = new Map();
|
|
81
|
+
|
|
82
|
+
for (const [groupName, group] of Object.entries(styles$1)) {
|
|
83
|
+
for (const [styleName, style] of Object.entries(group)) {
|
|
84
|
+
styles$1[styleName] = {
|
|
85
|
+
open: `\u001B[${style[0]}m`,
|
|
86
|
+
close: `\u001B[${style[1]}m`,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
group[styleName] = styles$1[styleName];
|
|
90
|
+
|
|
91
|
+
codes.set(style[0], style[1]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
Object.defineProperty(styles$1, groupName, {
|
|
95
|
+
value: group,
|
|
96
|
+
enumerable: false,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
Object.defineProperty(styles$1, 'codes', {
|
|
101
|
+
value: codes,
|
|
102
|
+
enumerable: false,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
styles$1.color.close = '\u001B[39m';
|
|
106
|
+
styles$1.bgColor.close = '\u001B[49m';
|
|
107
|
+
|
|
108
|
+
styles$1.color.ansi = wrapAnsi16();
|
|
109
|
+
styles$1.color.ansi256 = wrapAnsi256();
|
|
110
|
+
styles$1.color.ansi16m = wrapAnsi16m();
|
|
111
|
+
styles$1.bgColor.ansi = wrapAnsi16(ANSI_BACKGROUND_OFFSET);
|
|
112
|
+
styles$1.bgColor.ansi256 = wrapAnsi256(ANSI_BACKGROUND_OFFSET);
|
|
113
|
+
styles$1.bgColor.ansi16m = wrapAnsi16m(ANSI_BACKGROUND_OFFSET);
|
|
114
|
+
|
|
115
|
+
// From https://github.com/Qix-/color-convert/blob/3f0e0d4e92e235796ccb17f6e85c72094a651f49/conversions.js
|
|
116
|
+
Object.defineProperties(styles$1, {
|
|
117
|
+
rgbToAnsi256: {
|
|
118
|
+
value(red, green, blue) {
|
|
119
|
+
// We use the extended greyscale palette here, with the exception of
|
|
120
|
+
// black and white. normal palette only has 4 greyscale shades.
|
|
121
|
+
if (red === green && green === blue) {
|
|
122
|
+
if (red < 8) {
|
|
123
|
+
return 16;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (red > 248) {
|
|
127
|
+
return 231;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return Math.round(((red - 8) / 247) * 24) + 232;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return 16
|
|
134
|
+
+ (36 * Math.round(red / 255 * 5))
|
|
135
|
+
+ (6 * Math.round(green / 255 * 5))
|
|
136
|
+
+ Math.round(blue / 255 * 5);
|
|
137
|
+
},
|
|
138
|
+
enumerable: false,
|
|
139
|
+
},
|
|
140
|
+
hexToRgb: {
|
|
141
|
+
value(hex) {
|
|
142
|
+
const matches = /[a-f\d]{6}|[a-f\d]{3}/i.exec(hex.toString(16));
|
|
143
|
+
if (!matches) {
|
|
144
|
+
return [0, 0, 0];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let [colorString] = matches;
|
|
148
|
+
|
|
149
|
+
if (colorString.length === 3) {
|
|
150
|
+
colorString = [...colorString].map(character => character + character).join('');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const integer = Number.parseInt(colorString, 16);
|
|
154
|
+
|
|
155
|
+
return [
|
|
156
|
+
/* eslint-disable no-bitwise */
|
|
157
|
+
(integer >> 16) & 0xFF,
|
|
158
|
+
(integer >> 8) & 0xFF,
|
|
159
|
+
integer & 0xFF,
|
|
160
|
+
/* eslint-enable no-bitwise */
|
|
161
|
+
];
|
|
162
|
+
},
|
|
163
|
+
enumerable: false,
|
|
164
|
+
},
|
|
165
|
+
hexToAnsi256: {
|
|
166
|
+
value: hex => styles$1.rgbToAnsi256(...styles$1.hexToRgb(hex)),
|
|
167
|
+
enumerable: false,
|
|
168
|
+
},
|
|
169
|
+
ansi256ToAnsi: {
|
|
170
|
+
value(code) {
|
|
171
|
+
if (code < 8) {
|
|
172
|
+
return 30 + code;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (code < 16) {
|
|
176
|
+
return 90 + (code - 8);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let red;
|
|
180
|
+
let green;
|
|
181
|
+
let blue;
|
|
182
|
+
|
|
183
|
+
if (code >= 232) {
|
|
184
|
+
red = (((code - 232) * 10) + 8) / 255;
|
|
185
|
+
green = red;
|
|
186
|
+
blue = red;
|
|
187
|
+
} else {
|
|
188
|
+
code -= 16;
|
|
189
|
+
|
|
190
|
+
const remainder = code % 36;
|
|
191
|
+
|
|
192
|
+
red = Math.floor(code / 36) / 5;
|
|
193
|
+
green = Math.floor(remainder / 6) / 5;
|
|
194
|
+
blue = (remainder % 6) / 5;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const value = Math.max(red, green, blue) * 2;
|
|
198
|
+
|
|
199
|
+
if (value === 0) {
|
|
200
|
+
return 30;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// eslint-disable-next-line no-bitwise
|
|
204
|
+
let result = 30 + ((Math.round(blue) << 2) | (Math.round(green) << 1) | Math.round(red));
|
|
205
|
+
|
|
206
|
+
if (value === 2) {
|
|
207
|
+
result += 60;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return result;
|
|
211
|
+
},
|
|
212
|
+
enumerable: false,
|
|
213
|
+
},
|
|
214
|
+
rgbToAnsi: {
|
|
215
|
+
value: (red, green, blue) => styles$1.ansi256ToAnsi(styles$1.rgbToAnsi256(red, green, blue)),
|
|
216
|
+
enumerable: false,
|
|
217
|
+
},
|
|
218
|
+
hexToAnsi: {
|
|
219
|
+
value: hex => styles$1.ansi256ToAnsi(styles$1.hexToAnsi256(hex)),
|
|
220
|
+
enumerable: false,
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return styles$1;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const ansiStyles = assembleStyles();
|
|
228
|
+
|
|
229
|
+
/* eslint-env browser */
|
|
230
|
+
|
|
231
|
+
const level = (() => {
|
|
232
|
+
if (!('navigator' in globalThis)) {
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (globalThis.navigator.userAgentData) {
|
|
237
|
+
const brand = navigator.userAgentData.brands.find(({brand}) => brand === 'Chromium');
|
|
238
|
+
if (brand && brand.version > 93) {
|
|
239
|
+
return 3;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (/\b(Chrome|Chromium)\//.test(globalThis.navigator.userAgent)) {
|
|
244
|
+
return 1;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return 0;
|
|
248
|
+
})();
|
|
249
|
+
|
|
250
|
+
const colorSupport = level !== 0 && {
|
|
251
|
+
level};
|
|
252
|
+
|
|
253
|
+
const supportsColor = {
|
|
254
|
+
stdout: colorSupport,
|
|
255
|
+
stderr: colorSupport,
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// TODO: When targeting Node.js 16, use `String.prototype.replaceAll`.
|
|
259
|
+
function stringReplaceAll(string, substring, replacer) {
|
|
260
|
+
let index = string.indexOf(substring);
|
|
261
|
+
if (index === -1) {
|
|
262
|
+
return string;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const substringLength = substring.length;
|
|
266
|
+
let endIndex = 0;
|
|
267
|
+
let returnValue = '';
|
|
268
|
+
do {
|
|
269
|
+
returnValue += string.slice(endIndex, index) + substring + replacer;
|
|
270
|
+
endIndex = index + substringLength;
|
|
271
|
+
index = string.indexOf(substring, endIndex);
|
|
272
|
+
} while (index !== -1);
|
|
273
|
+
|
|
274
|
+
returnValue += string.slice(endIndex);
|
|
275
|
+
return returnValue;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function stringEncaseCRLFWithFirstIndex(string, prefix, postfix, index) {
|
|
279
|
+
let endIndex = 0;
|
|
280
|
+
let returnValue = '';
|
|
281
|
+
do {
|
|
282
|
+
const gotCR = string[index - 1] === '\r';
|
|
283
|
+
returnValue += string.slice(endIndex, (gotCR ? index - 1 : index)) + prefix + (gotCR ? '\r\n' : '\n') + postfix;
|
|
284
|
+
endIndex = index + 1;
|
|
285
|
+
index = string.indexOf('\n', endIndex);
|
|
286
|
+
} while (index !== -1);
|
|
287
|
+
|
|
288
|
+
returnValue += string.slice(endIndex);
|
|
289
|
+
return returnValue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const {stdout: stdoutColor, stderr: stderrColor} = supportsColor;
|
|
293
|
+
|
|
294
|
+
const GENERATOR = Symbol('GENERATOR');
|
|
295
|
+
const STYLER = Symbol('STYLER');
|
|
296
|
+
const IS_EMPTY = Symbol('IS_EMPTY');
|
|
297
|
+
|
|
298
|
+
// `supportsColor.level` → `ansiStyles.color[name]` mapping
|
|
299
|
+
const levelMapping = [
|
|
300
|
+
'ansi',
|
|
301
|
+
'ansi',
|
|
302
|
+
'ansi256',
|
|
303
|
+
'ansi16m',
|
|
304
|
+
];
|
|
305
|
+
|
|
306
|
+
const styles = Object.create(null);
|
|
307
|
+
|
|
308
|
+
const applyOptions = (object, options = {}) => {
|
|
309
|
+
if (options.level && !(Number.isInteger(options.level) && options.level >= 0 && options.level <= 3)) {
|
|
310
|
+
throw new Error('The `level` option should be an integer from 0 to 3');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Detect level if not set manually
|
|
314
|
+
const colorLevel = stdoutColor ? stdoutColor.level : 0;
|
|
315
|
+
object.level = options.level === undefined ? colorLevel : options.level;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const chalkFactory = options => {
|
|
319
|
+
const chalk = (...strings) => strings.join(' ');
|
|
320
|
+
applyOptions(chalk, options);
|
|
321
|
+
|
|
322
|
+
Object.setPrototypeOf(chalk, createChalk.prototype);
|
|
323
|
+
|
|
324
|
+
return chalk;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
function createChalk(options) {
|
|
328
|
+
return chalkFactory(options);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
Object.setPrototypeOf(createChalk.prototype, Function.prototype);
|
|
332
|
+
|
|
333
|
+
for (const [styleName, style] of Object.entries(ansiStyles)) {
|
|
334
|
+
styles[styleName] = {
|
|
335
|
+
get() {
|
|
336
|
+
const builder = createBuilder(this, createStyler(style.open, style.close, this[STYLER]), this[IS_EMPTY]);
|
|
337
|
+
Object.defineProperty(this, styleName, {value: builder});
|
|
338
|
+
return builder;
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
styles.visible = {
|
|
344
|
+
get() {
|
|
345
|
+
const builder = createBuilder(this, this[STYLER], true);
|
|
346
|
+
Object.defineProperty(this, 'visible', {value: builder});
|
|
347
|
+
return builder;
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const getModelAnsi = (model, level, type, ...arguments_) => {
|
|
352
|
+
if (model === 'rgb') {
|
|
353
|
+
if (level === 'ansi16m') {
|
|
354
|
+
return ansiStyles[type].ansi16m(...arguments_);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (level === 'ansi256') {
|
|
358
|
+
return ansiStyles[type].ansi256(ansiStyles.rgbToAnsi256(...arguments_));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return ansiStyles[type].ansi(ansiStyles.rgbToAnsi(...arguments_));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (model === 'hex') {
|
|
365
|
+
return getModelAnsi('rgb', level, type, ...ansiStyles.hexToRgb(...arguments_));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return ansiStyles[type][model](...arguments_);
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const usedModels = ['rgb', 'hex', 'ansi256'];
|
|
372
|
+
|
|
373
|
+
for (const model of usedModels) {
|
|
374
|
+
styles[model] = {
|
|
375
|
+
get() {
|
|
376
|
+
const {level} = this;
|
|
377
|
+
return function (...arguments_) {
|
|
378
|
+
const styler = createStyler(getModelAnsi(model, levelMapping[level], 'color', ...arguments_), ansiStyles.color.close, this[STYLER]);
|
|
379
|
+
return createBuilder(this, styler, this[IS_EMPTY]);
|
|
380
|
+
};
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const bgModel = 'bg' + model[0].toUpperCase() + model.slice(1);
|
|
385
|
+
styles[bgModel] = {
|
|
386
|
+
get() {
|
|
387
|
+
const {level} = this;
|
|
388
|
+
return function (...arguments_) {
|
|
389
|
+
const styler = createStyler(getModelAnsi(model, levelMapping[level], 'bgColor', ...arguments_), ansiStyles.bgColor.close, this[STYLER]);
|
|
390
|
+
return createBuilder(this, styler, this[IS_EMPTY]);
|
|
391
|
+
};
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const proto = Object.defineProperties(() => {}, {
|
|
397
|
+
...styles,
|
|
398
|
+
level: {
|
|
399
|
+
enumerable: true,
|
|
400
|
+
get() {
|
|
401
|
+
return this[GENERATOR].level;
|
|
402
|
+
},
|
|
403
|
+
set(level) {
|
|
404
|
+
this[GENERATOR].level = level;
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const createStyler = (open, close, parent) => {
|
|
410
|
+
let openAll;
|
|
411
|
+
let closeAll;
|
|
412
|
+
if (parent === undefined) {
|
|
413
|
+
openAll = open;
|
|
414
|
+
closeAll = close;
|
|
415
|
+
} else {
|
|
416
|
+
openAll = parent.openAll + open;
|
|
417
|
+
closeAll = close + parent.closeAll;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
open,
|
|
422
|
+
close,
|
|
423
|
+
openAll,
|
|
424
|
+
closeAll,
|
|
425
|
+
parent,
|
|
426
|
+
};
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const createBuilder = (self, _styler, _isEmpty) => {
|
|
430
|
+
// Single argument is hot path, implicit coercion is faster than anything
|
|
431
|
+
// eslint-disable-next-line no-implicit-coercion
|
|
432
|
+
const builder = (...arguments_) => applyStyle(builder, (arguments_.length === 1) ? ('' + arguments_[0]) : arguments_.join(' '));
|
|
433
|
+
|
|
434
|
+
// We alter the prototype because we must return a function, but there is
|
|
435
|
+
// no way to create a function with a different prototype
|
|
436
|
+
Object.setPrototypeOf(builder, proto);
|
|
437
|
+
|
|
438
|
+
builder[GENERATOR] = self;
|
|
439
|
+
builder[STYLER] = _styler;
|
|
440
|
+
builder[IS_EMPTY] = _isEmpty;
|
|
441
|
+
|
|
442
|
+
return builder;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const applyStyle = (self, string) => {
|
|
446
|
+
if (self.level <= 0 || !string) {
|
|
447
|
+
return self[IS_EMPTY] ? '' : string;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
let styler = self[STYLER];
|
|
451
|
+
|
|
452
|
+
if (styler === undefined) {
|
|
453
|
+
return string;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const {openAll, closeAll} = styler;
|
|
457
|
+
if (string.includes('\u001B')) {
|
|
458
|
+
while (styler !== undefined) {
|
|
459
|
+
// Replace any instances already present with a re-opening code
|
|
460
|
+
// otherwise only the part of the string until said closing code
|
|
461
|
+
// will be colored, and the rest will simply be 'plain'.
|
|
462
|
+
string = stringReplaceAll(string, styler.close, styler.open);
|
|
463
|
+
|
|
464
|
+
styler = styler.parent;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// We can move both next actions out of loop, because remaining actions in loop won't have
|
|
469
|
+
// any/visible effect on parts we add here. Close the styling before a linebreak and reopen
|
|
470
|
+
// after next line to fix a bleed issue on macOS: https://github.com/chalk/chalk/pull/92
|
|
471
|
+
const lfIndex = string.indexOf('\n');
|
|
472
|
+
if (lfIndex !== -1) {
|
|
473
|
+
string = stringEncaseCRLFWithFirstIndex(string, closeAll, openAll, lfIndex);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return openAll + string + closeAll;
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
Object.defineProperties(createChalk.prototype, styles);
|
|
480
|
+
|
|
481
|
+
const chalk = createChalk();
|
|
482
|
+
createChalk({level: stderrColor ? stderrColor.level : 0});
|
|
483
|
+
|
|
484
|
+
function dtm(dt = new Date()) {
|
|
485
|
+
const y = dt.getFullYear();
|
|
486
|
+
const m = String(dt.getMonth() + 1).padStart(2, '0');
|
|
487
|
+
const d = String(dt.getDate()).padStart(2, '0');
|
|
488
|
+
const hh = String(dt.getHours()).padStart(2, '0');
|
|
489
|
+
const mm = String(dt.getMinutes()).padStart(2, '0');
|
|
490
|
+
const ss = String(dt.getSeconds()).padStart(2, '0');
|
|
491
|
+
const ms = String(dt.getMilliseconds()).padStart(3, '0');
|
|
492
|
+
return `${y}.${m}.${d} ${hh}:${mm}:${ss}.${ms}`;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const $stringify = JSON.stringify;
|
|
496
|
+
const $keys = Object.keys;
|
|
497
|
+
|
|
498
|
+
function loadFunction(injectionConfig) {
|
|
499
|
+
const m = require(injectionConfig.modulePath);
|
|
500
|
+
if (typeof m === 'function') {
|
|
501
|
+
return m;
|
|
502
|
+
}
|
|
503
|
+
else if (typeof m.default === 'function') {
|
|
504
|
+
return m.default;
|
|
505
|
+
}
|
|
506
|
+
else if (typeof m.handler === 'function') {
|
|
507
|
+
return m.handler;
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
throw new Error(`[FluxionTs error] Invalid handler module '${injectionConfig.modulePath}', make sure it has a default export or named export called "handler" which is a function`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const safeStringify = (value) => {
|
|
515
|
+
try {
|
|
516
|
+
return $stringify(value);
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
return '[unserializable]';
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
const ColoredLevels = {
|
|
523
|
+
INFO: chalk.hex('#0386e3')('INFO'),
|
|
524
|
+
WARN: chalk.hex('#fb923c')('WARN'),
|
|
525
|
+
ERROR: chalk.hex('#ef4444')('ERROR'),
|
|
526
|
+
SUCC: chalk.hex('#22c55e')('SUCC'),
|
|
527
|
+
DEBUG: chalk.hex('#d327e0')('DEBUG'),
|
|
528
|
+
VERBOSE: chalk.hex('#36ffeb')('SUCC'),
|
|
529
|
+
};
|
|
530
|
+
const TimestampColor = chalk.hex('#166534');
|
|
531
|
+
const oneLineLogger = (entry) => {
|
|
532
|
+
const { level: rawLevel, timestamp: rawTimestamp, event: rawEvent, message: rawMessage, ...fields } = entry;
|
|
533
|
+
const timestamp = TimestampColor(`[${rawTimestamp}]`);
|
|
534
|
+
const level = ColoredLevels[rawLevel] ?? rawLevel;
|
|
535
|
+
const body = rawMessage ?? rawEvent;
|
|
536
|
+
const fieldsText = $keys(fields).length > 0 ? ` ${chalk.dim(safeStringify(fields))}` : '';
|
|
537
|
+
console.log(`${timestamp} ${level} ${body}${fieldsText}`);
|
|
538
|
+
};
|
|
539
|
+
/**
|
|
540
|
+
* & Logger Options here is checked by normalizeOptions function.
|
|
541
|
+
*/
|
|
542
|
+
function resolveLoggerSink(cx) {
|
|
543
|
+
const loggerOption = cx.options.logger;
|
|
544
|
+
if (loggerOption === undefined || loggerOption === 'one-line') {
|
|
545
|
+
return oneLineLogger;
|
|
546
|
+
}
|
|
547
|
+
if (loggerOption === 'json-line') {
|
|
548
|
+
return (entry) => console.log(safeStringify(entry));
|
|
549
|
+
}
|
|
550
|
+
return loadFunction(loggerOption);
|
|
551
|
+
}
|
|
552
|
+
function createLogger(cx) {
|
|
553
|
+
const sink = resolveLoggerSink(cx);
|
|
554
|
+
const logger = {
|
|
555
|
+
write(level, event, fields = {}) {
|
|
556
|
+
const entry = {
|
|
557
|
+
...fields,
|
|
558
|
+
timestamp: dtm(),
|
|
559
|
+
level,
|
|
560
|
+
event,
|
|
561
|
+
};
|
|
562
|
+
try {
|
|
563
|
+
sink(entry);
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
// Ignore logger sink failures to avoid breaking request handling.
|
|
567
|
+
}
|
|
568
|
+
},
|
|
569
|
+
info(event, fields) {
|
|
570
|
+
this.write('INFO', event, fields);
|
|
571
|
+
},
|
|
572
|
+
warn(event, fields) {
|
|
573
|
+
this.write('WARN', event, fields);
|
|
574
|
+
},
|
|
575
|
+
error(event, fields) {
|
|
576
|
+
this.write('ERROR', event, fields);
|
|
577
|
+
},
|
|
578
|
+
succ(event, fields) {
|
|
579
|
+
this.write('SUCC', event, fields);
|
|
580
|
+
},
|
|
581
|
+
debug(event, fields) {
|
|
582
|
+
this.write('DEBUG', event, fields);
|
|
583
|
+
},
|
|
584
|
+
verbose(event, fields) {
|
|
585
|
+
this.write('VERBOSE', event, fields);
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
return logger;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* ! Error.isError needs Node.js 24
|
|
592
|
+
*/
|
|
593
|
+
const getErrorMessage = (error) => typeof error === 'object' && error !== null ? error.message : String(error);
|
|
594
|
+
|
|
595
|
+
var whether;
|
|
596
|
+
(function (whether) {
|
|
597
|
+
whether.isObject = (value) => {
|
|
598
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
599
|
+
};
|
|
600
|
+
})(whether || (whether = {}));
|
|
601
|
+
var expect;
|
|
602
|
+
(function (expect) {
|
|
603
|
+
function isObject(o, message) {
|
|
604
|
+
if (typeof o !== 'object' || o === null) {
|
|
605
|
+
$throw(message);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
expect.isObject = isObject;
|
|
609
|
+
function isString(s, message) {
|
|
610
|
+
if (typeof s !== 'string') {
|
|
611
|
+
$throw(message);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
expect.isString = isString;
|
|
615
|
+
function isNumber(n, message) {
|
|
616
|
+
if (typeof n !== 'number') {
|
|
617
|
+
$throw(message);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
expect.isNumber = isNumber;
|
|
621
|
+
function isPositiveInteger(n, message) {
|
|
622
|
+
if (typeof n !== 'number' || n <= 0 || !Number.isSafeInteger(n)) {
|
|
623
|
+
$throw(message);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
expect.isPositiveInteger = isPositiveInteger;
|
|
627
|
+
function isObjectArray(arr, message) {
|
|
628
|
+
if (!Array.isArray(arr) || arr.some((item) => typeof item !== 'object' || item === null)) {
|
|
629
|
+
$throw(message);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
expect.isObjectArray = isObjectArray;
|
|
633
|
+
})(expect || (expect = {}));
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Resolves runtime options with framework defaults.
|
|
637
|
+
*/
|
|
638
|
+
function resolveWorkerOptions(options) {
|
|
639
|
+
return {
|
|
640
|
+
maxWorkerCount: options.maxWorkerCount ?? 4,
|
|
641
|
+
requestTimeoutMs: options.requestTimeoutMs ?? 3000,
|
|
642
|
+
maxInflight: options.maxInflight ?? 64,
|
|
643
|
+
memorySoftLimitMb: options.memorySoftLimitMb ?? 96,
|
|
644
|
+
memoryHardLimitMb: options.memoryHardLimitMb ?? 128,
|
|
645
|
+
memorySampleIntervalMs: options.memorySampleIntervalMs ?? 5000,
|
|
646
|
+
maxOldGenerationSizeMb: options.maxOldGenerationSizeMb ?? 128,
|
|
647
|
+
maxYoungGenerationSizeMb: options.maxYoungGenerationSizeMb ?? 32,
|
|
648
|
+
stackSizeMb: options.stackSizeMb ?? 4,
|
|
649
|
+
maxResponseBytes: options.maxResponseBytes ?? 2 * 1024 * 1024,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
function expectLoggerOption(o) {
|
|
653
|
+
if (o === 'one-line' || o === 'json-line') {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (typeof o === 'object' && o !== null && typeof o.modulePath === 'string' && typeof o.name === 'string') {
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
throw new Error(`[FluxionTs error] Invalid logger option, must be 'one-line', 'json-line' or { modulePath: string; name: string; }`);
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Normalize options and create necessary resources like the dynamic directory and logger.
|
|
663
|
+
*/
|
|
664
|
+
function normalizeOptions(options) {
|
|
665
|
+
expect.isObject(options, 'FluxionOptions must be an object');
|
|
666
|
+
let { dir, host, port, metaPort, injections = [], moduleDir = process.cwd(), workerOptions = {}, maxRequestBytes = 8000000, reloadDelay = 300, apiExts = ['.ts'], routerExclude = [], } = options;
|
|
667
|
+
const logger = options.logger ?? 'one-line';
|
|
668
|
+
expectLoggerOption(logger);
|
|
669
|
+
expect.isString(dir, 'FluxionOptions.dir must be a string');
|
|
670
|
+
expect.isString(moduleDir, 'FluxionOptions.moduleDir must be a string');
|
|
671
|
+
expect.isString(host, 'FluxionOptions.host must be a string');
|
|
672
|
+
expect.isPositiveInteger(reloadDelay, 'FluxionOptions.reloadDelay must be a positive integer');
|
|
673
|
+
if (reloadDelay < 50) {
|
|
674
|
+
throw new Error('[FluxionTs error] FluxionOptions.reloadDelay must be greater than or equal to 50');
|
|
675
|
+
}
|
|
676
|
+
expect.isPositiveInteger(port, 'FluxionOptions.port must be a positive integer');
|
|
677
|
+
if (port > 65535) {
|
|
678
|
+
throw new Error('[FluxionTs error] FluxionOptions.port must be less than or equal to 65535');
|
|
679
|
+
}
|
|
680
|
+
metaPort ?? (metaPort = port + 1);
|
|
681
|
+
expect.isPositiveInteger(metaPort, 'FluxionOptions.metaPort must be a positive integer');
|
|
682
|
+
if (metaPort > 65535) {
|
|
683
|
+
throw new Error('[FluxionTs error] FluxionOptions.metaPort must be less than or equal to 65535');
|
|
684
|
+
}
|
|
685
|
+
if (metaPort === port) {
|
|
686
|
+
throw new Error('[FluxionTs error] FluxionOptions.metaPort must be different from FluxionOptions.port');
|
|
687
|
+
}
|
|
688
|
+
expect.isObjectArray(injections, 'FluxionOptions.injections must be an array of objects');
|
|
689
|
+
expect.isObject(workerOptions, 'FluxionOptions.workerOptions must be an object');
|
|
690
|
+
expect.isPositiveInteger(maxRequestBytes, 'FluxionOptions.maxRequestBytes must be a positive integer');
|
|
691
|
+
if (!existsSync(dir)) {
|
|
692
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
693
|
+
}
|
|
694
|
+
return {
|
|
695
|
+
dir,
|
|
696
|
+
host,
|
|
697
|
+
port,
|
|
698
|
+
reloadDelay,
|
|
699
|
+
metaPort,
|
|
700
|
+
injections,
|
|
701
|
+
moduleDir,
|
|
702
|
+
workerOptions: resolveWorkerOptions(workerOptions),
|
|
703
|
+
maxRequestBytes,
|
|
704
|
+
logger,
|
|
705
|
+
apiExts,
|
|
706
|
+
routerExclude,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const INJECTION_KEY = Symbol.for('fluxion.injection');
|
|
711
|
+
const isPrimaryMessage = (v) => [100 /* PrimaryAction.Ping */].includes(v?.type);
|
|
712
|
+
const isWorkerMessage = (v) => [202 /* WorkerAction.Pong */, 200 /* WorkerAction.Created */, 201 /* WorkerAction.Ready */, 203 /* WorkerAction.Stats */].includes(v?.type);
|
|
713
|
+
|
|
714
|
+
const sendToPrimary = (message) => process.send?.(message);
|
|
715
|
+
const sendToWorker = (worker, message) => worker.send(message);
|
|
716
|
+
|
|
717
|
+
const DUMMY_BASE_URL = 'http://fluxion.local';
|
|
718
|
+
const META_PREFIX = '/_fluxion';
|
|
719
|
+
const STATIC_CONTENT_TYPES = {
|
|
720
|
+
'.css': 'text/css; charset=utf-8',
|
|
721
|
+
'.html': 'text/html; charset=utf-8',
|
|
722
|
+
'.ico': 'image/x-icon',
|
|
723
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
724
|
+
'.json': 'application/json; charset=utf-8',
|
|
725
|
+
'.map': 'application/json; charset=utf-8',
|
|
726
|
+
'.png': 'image/png',
|
|
727
|
+
'.jpg': 'image/jpeg',
|
|
728
|
+
'.jpeg': 'image/jpeg',
|
|
729
|
+
'.svg': 'image/svg+xml',
|
|
730
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
731
|
+
'.webp': 'image/webp',
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
function sendJson(res, payload, statusCode = 200 /* HttpCode.Ok */) {
|
|
735
|
+
res.statusCode = statusCode;
|
|
736
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
737
|
+
res.end(JSON.stringify(payload));
|
|
738
|
+
}
|
|
739
|
+
function safeSendJson(res, payload, statusCode = 200 /* HttpCode.Ok */) {
|
|
740
|
+
if (res.writableEnded) {
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
if (res.headersSent) {
|
|
744
|
+
res.end();
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
sendJson(res, payload, statusCode);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function createPrimaryMetaApiServer(cx, getWorkersSnapshot) {
|
|
751
|
+
const server = http.createServer((req, res) => {
|
|
752
|
+
const method = req.method ?? 'GET';
|
|
753
|
+
let pathname = '/';
|
|
754
|
+
try {
|
|
755
|
+
pathname = new URL(req.url ?? '/', 'http://fluxion.local').pathname;
|
|
756
|
+
}
|
|
757
|
+
catch {
|
|
758
|
+
sendJson(res, { message: 'Bad Request: invalid url' }, 400 /* HttpCode.BadRequest */);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (method === 'GET' && pathname === META_PREFIX + '/healthz') {
|
|
762
|
+
sendJson(res, {
|
|
763
|
+
ok: true,
|
|
764
|
+
role: 'primary',
|
|
765
|
+
pid: process.pid,
|
|
766
|
+
now: Date.now(),
|
|
767
|
+
uptimeSeconds: Number(process.uptime().toFixed(3)),
|
|
768
|
+
});
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (method === 'GET' && pathname === META_PREFIX + '/workers') {
|
|
772
|
+
sendJson(res, {
|
|
773
|
+
ok: true,
|
|
774
|
+
now: Date.now(),
|
|
775
|
+
workers: getWorkersSnapshot(),
|
|
776
|
+
});
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
sendJson(res, { message: 'Not Found' }, 404 /* HttpCode.NotFound */);
|
|
780
|
+
});
|
|
781
|
+
server.on('listening', () => {
|
|
782
|
+
cx.logger.info('MetaApiStarted', {
|
|
783
|
+
pid: process.pid,
|
|
784
|
+
host: cx.options.host,
|
|
785
|
+
port: cx.options.metaPort,
|
|
786
|
+
prefix: META_PREFIX,
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
server.on('error', (error) => {
|
|
790
|
+
cx.logger.error('MetaApiError', {
|
|
791
|
+
host: cx.options.host,
|
|
792
|
+
port: cx.options.metaPort,
|
|
793
|
+
error: getErrorMessage(error),
|
|
794
|
+
});
|
|
795
|
+
process.exit(1);
|
|
796
|
+
});
|
|
797
|
+
server.listen(cx.options.metaPort, cx.options.host);
|
|
798
|
+
return server;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const bytesToMb = (bytes) => Number((bytes / 1024 / 1024).toFixed(2));
|
|
802
|
+
function initPrimary(cx) {
|
|
803
|
+
if (!cluster.isPrimary) {
|
|
804
|
+
throw new Error('[FluxionTs error] createPrimary should only be called in primary process');
|
|
805
|
+
}
|
|
806
|
+
const { workerOptions } = cx.options;
|
|
807
|
+
const cpuCount = Math.max(1, os.cpus().length);
|
|
808
|
+
const workerCount = Math.max(1, Math.min(workerOptions.maxWorkerCount ?? Math.min(2, cpuCount), cpuCount));
|
|
809
|
+
cx.logger.info('PrimaryStarted', {
|
|
810
|
+
pid: process.pid,
|
|
811
|
+
workers: workerCount,
|
|
812
|
+
host: cx.options.host,
|
|
813
|
+
port: cx.options.port,
|
|
814
|
+
metaPort: cx.options.metaPort,
|
|
815
|
+
});
|
|
816
|
+
const workers = new Map();
|
|
817
|
+
const getWorkersSnapshot = () => {
|
|
818
|
+
return {
|
|
819
|
+
primaryPid: process.pid,
|
|
820
|
+
host: cx.options.host,
|
|
821
|
+
port: cx.options.port,
|
|
822
|
+
metaPort: cx.options.metaPort,
|
|
823
|
+
uptimeSeconds: Number(process.uptime().toFixed(3)),
|
|
824
|
+
workers: Array.from(workers.entries()).map(([workerId, info]) => {
|
|
825
|
+
const { instance } = info;
|
|
826
|
+
const stats = info.lastStats;
|
|
827
|
+
return {
|
|
828
|
+
workerId,
|
|
829
|
+
pid: info.pid ?? instance.process.pid ?? null,
|
|
830
|
+
state: info.state,
|
|
831
|
+
createdAt: info.createdAt,
|
|
832
|
+
readyAt: info.readyAt ?? null,
|
|
833
|
+
connected: instance.isConnected(),
|
|
834
|
+
dead: instance.isDead(),
|
|
835
|
+
exitedAfterDisconnect: instance.exitedAfterDisconnect,
|
|
836
|
+
lastPongAt: info.lastPongAt ?? null,
|
|
837
|
+
lastRttMs: info.lastRttMs ?? null,
|
|
838
|
+
stats: stats === undefined
|
|
839
|
+
? null
|
|
840
|
+
: {
|
|
841
|
+
at: stats.at,
|
|
842
|
+
uptimeSeconds: stats.uptimeSeconds,
|
|
843
|
+
cpu: stats.cpu,
|
|
844
|
+
memory: {
|
|
845
|
+
...stats.memory,
|
|
846
|
+
rssMb: bytesToMb(stats.memory.rss),
|
|
847
|
+
heapTotalMb: bytesToMb(stats.memory.heapTotal),
|
|
848
|
+
heapUsedMb: bytesToMb(stats.memory.heapUsed),
|
|
849
|
+
externalMb: bytesToMb(stats.memory.external),
|
|
850
|
+
arrayBuffersMb: bytesToMb(stats.memory.arrayBuffers),
|
|
851
|
+
},
|
|
852
|
+
},
|
|
853
|
+
};
|
|
854
|
+
}),
|
|
855
|
+
};
|
|
856
|
+
};
|
|
857
|
+
createPrimaryMetaApiServer(cx, getWorkersSnapshot);
|
|
858
|
+
const attachWorker = (worker) => {
|
|
859
|
+
const workerInfo = {
|
|
860
|
+
state: 'creating',
|
|
861
|
+
pid: worker.process.pid,
|
|
862
|
+
createdAt: Date.now(),
|
|
863
|
+
instance: worker,
|
|
864
|
+
};
|
|
865
|
+
workers.set(worker.id, workerInfo);
|
|
866
|
+
worker.on('message', (raw) => {
|
|
867
|
+
if (!isWorkerMessage(raw)) {
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
if (raw.type === 202 /* WorkerAction.Pong */) {
|
|
871
|
+
const rtt = Date.now() - raw.sentAt;
|
|
872
|
+
workerInfo.pid = raw.pid;
|
|
873
|
+
workerInfo.lastPongAt = Date.now();
|
|
874
|
+
workerInfo.lastRttMs = rtt;
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
if (raw.type === 201 /* WorkerAction.Ready */) {
|
|
878
|
+
workerInfo.state = 'ready';
|
|
879
|
+
workerInfo.pid = raw.pid;
|
|
880
|
+
workerInfo.readyAt = Date.now();
|
|
881
|
+
cx.logger.info('WorkerReady', { workerId: worker.id, pid: raw.pid });
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (raw.type === 200 /* WorkerAction.Created */) {
|
|
885
|
+
workerInfo.state = 'created';
|
|
886
|
+
workerInfo.pid = raw.pid;
|
|
887
|
+
cx.logger.info('WorkerCreated', { workerId: worker.id, pid: raw.pid });
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (raw.type === 203 /* WorkerAction.Stats */) {
|
|
891
|
+
workerInfo.pid = raw.pid;
|
|
892
|
+
workerInfo.lastStats = raw.stats;
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
worker.on('exit', (code, signal) => {
|
|
896
|
+
workers.delete(worker.id);
|
|
897
|
+
cx.logger.warn('WorkerExited', {
|
|
898
|
+
workerId: worker.id,
|
|
899
|
+
pid: worker.process.pid ?? 'unknown',
|
|
900
|
+
code,
|
|
901
|
+
signal: signal ?? 'none',
|
|
902
|
+
});
|
|
903
|
+
});
|
|
904
|
+
};
|
|
905
|
+
for (let i = 0; i < workerCount; i++) {
|
|
906
|
+
attachWorker(cluster.fork({ WORKER_ID: String(i + 1) }));
|
|
907
|
+
}
|
|
908
|
+
const pingTimer = setInterval(() => {
|
|
909
|
+
const sentAt = Date.now();
|
|
910
|
+
for (const info of workers.values()) {
|
|
911
|
+
if (!info.instance.isConnected()) {
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
try {
|
|
915
|
+
sendToWorker(info.instance, { type: 100 /* PrimaryAction.Ping */, sentAt });
|
|
916
|
+
}
|
|
917
|
+
catch {
|
|
918
|
+
// Ignore transient IPC errors; worker lifecycle events will reconcile state.
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}, 5000);
|
|
922
|
+
pingTimer.unref();
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* For low version of Node.js that does not support `Promise.try`, we can implement it ourselves.
|
|
927
|
+
*
|
|
928
|
+
* Only for async functions.
|
|
929
|
+
*/
|
|
930
|
+
function PromiseTry(fn, ...args) {
|
|
931
|
+
return new Promise((resolve, reject) => {
|
|
932
|
+
// in case `fn` throws synchronously, we catch it and reject the promise
|
|
933
|
+
try {
|
|
934
|
+
fn(...args)
|
|
935
|
+
.then(resolve)
|
|
936
|
+
.catch(reject);
|
|
937
|
+
}
|
|
938
|
+
catch (error) {
|
|
939
|
+
reject(error);
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function getRealIp(req) {
|
|
945
|
+
const forwardedFor = req.headersDistinct['x-forwarded-for'];
|
|
946
|
+
if (forwardedFor) {
|
|
947
|
+
const firstForwarded = forwardedFor[0]?.split(',')[0]?.trim();
|
|
948
|
+
if (firstForwarded && firstForwarded.length > 0) {
|
|
949
|
+
return firstForwarded;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
const realIp = req.headersDistinct['x-real-ip']?.[0].trim();
|
|
953
|
+
if (realIp !== undefined) {
|
|
954
|
+
return realIp;
|
|
955
|
+
}
|
|
956
|
+
return req.socket.remoteAddress ?? 'unknown';
|
|
957
|
+
}
|
|
958
|
+
function isTextualContentType(contentType) {
|
|
959
|
+
if (contentType === undefined) {
|
|
960
|
+
return false;
|
|
961
|
+
}
|
|
962
|
+
const normalized = contentType.toLowerCase();
|
|
963
|
+
return (normalized.startsWith('text/') ||
|
|
964
|
+
normalized.includes('json') ||
|
|
965
|
+
normalized.includes('xml') ||
|
|
966
|
+
normalized.includes('x-www-form-urlencoded') ||
|
|
967
|
+
normalized.includes('javascript'));
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function toURL(rawUrl) {
|
|
971
|
+
if (rawUrl === undefined) {
|
|
972
|
+
return undefined;
|
|
973
|
+
}
|
|
974
|
+
try {
|
|
975
|
+
return new URL(rawUrl, DUMMY_BASE_URL);
|
|
976
|
+
}
|
|
977
|
+
catch {
|
|
978
|
+
return undefined;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function parseQuery(searchParams) {
|
|
983
|
+
const query = {};
|
|
984
|
+
for (const [key, value] of searchParams.entries()) {
|
|
985
|
+
const existing = query[key];
|
|
986
|
+
if (existing === undefined) {
|
|
987
|
+
query[key] = value;
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
if (Array.isArray(existing)) {
|
|
991
|
+
existing.push(value);
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
query[key] = [existing, value];
|
|
995
|
+
}
|
|
996
|
+
return query;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function createRequestBodyTooLargeError(receivedBytes, maxBytes) {
|
|
1000
|
+
const sizeError = new Error(`request body too large: ${receivedBytes.toString()} bytes exceeds ${maxBytes.toString()} bytes`);
|
|
1001
|
+
sizeError.code = 'REQUEST_BODY_TOO_LARGE';
|
|
1002
|
+
return sizeError;
|
|
1003
|
+
}
|
|
1004
|
+
function getHeaderValue(headerValue) {
|
|
1005
|
+
return Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
|
1006
|
+
}
|
|
1007
|
+
function createEmptyPreview() {
|
|
1008
|
+
return {
|
|
1009
|
+
exists: false,
|
|
1010
|
+
bytes: 0,
|
|
1011
|
+
truncated: false,
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
function createPreview(previewBuffer, totalBytes, contentType, truncated) {
|
|
1015
|
+
if (totalBytes === 0) {
|
|
1016
|
+
return createEmptyPreview();
|
|
1017
|
+
}
|
|
1018
|
+
if (isTextualContentType(contentType)) {
|
|
1019
|
+
return {
|
|
1020
|
+
exists: true,
|
|
1021
|
+
value: previewBuffer.toString('utf8'),
|
|
1022
|
+
bytes: totalBytes,
|
|
1023
|
+
truncated,
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
return {
|
|
1027
|
+
exists: true,
|
|
1028
|
+
value: `<binary body: ${totalBytes} bytes>`,
|
|
1029
|
+
bytes: totalBytes,
|
|
1030
|
+
truncated,
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
async function readRequestBodyWithPreview(req, method, maxBytes, previewMaxBytes = 8192) {
|
|
1034
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
1035
|
+
return {
|
|
1036
|
+
rawBody: undefined,
|
|
1037
|
+
preview: createEmptyPreview(),
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
if (req.readableEnded) {
|
|
1041
|
+
return {
|
|
1042
|
+
rawBody: undefined,
|
|
1043
|
+
preview: createEmptyPreview(),
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
const contentLengthRaw = getHeaderValue(req.headers['content-length']);
|
|
1047
|
+
const declaredBytes = contentLengthRaw !== undefined ? Number.parseInt(contentLengthRaw, 10) : NaN;
|
|
1048
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
|
|
1049
|
+
throw createRequestBodyTooLargeError(declaredBytes, maxBytes);
|
|
1050
|
+
}
|
|
1051
|
+
return new Promise((resolve, reject) => {
|
|
1052
|
+
const rawBodyChunks = [];
|
|
1053
|
+
const previewChunks = [];
|
|
1054
|
+
let totalBytes = 0;
|
|
1055
|
+
let previewBytes = 0;
|
|
1056
|
+
let previewTruncated = false;
|
|
1057
|
+
let settled = false;
|
|
1058
|
+
const cleanup = () => {
|
|
1059
|
+
req.off('data', onData);
|
|
1060
|
+
req.off('end', onEnd);
|
|
1061
|
+
req.off('error', onError);
|
|
1062
|
+
req.off('aborted', onAborted);
|
|
1063
|
+
};
|
|
1064
|
+
const settle = (action) => {
|
|
1065
|
+
if (settled) {
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
settled = true;
|
|
1069
|
+
action();
|
|
1070
|
+
};
|
|
1071
|
+
const onData = (chunk) => {
|
|
1072
|
+
const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1073
|
+
totalBytes += bufferChunk.byteLength;
|
|
1074
|
+
if (totalBytes > maxBytes) {
|
|
1075
|
+
cleanup();
|
|
1076
|
+
req.resume();
|
|
1077
|
+
settle(() => {
|
|
1078
|
+
reject(createRequestBodyTooLargeError(totalBytes, maxBytes));
|
|
1079
|
+
});
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
rawBodyChunks.push(bufferChunk);
|
|
1083
|
+
if (previewBytes < previewMaxBytes) {
|
|
1084
|
+
const remaining = previewMaxBytes - previewBytes;
|
|
1085
|
+
const nextSlice = bufferChunk.subarray(0, remaining);
|
|
1086
|
+
previewChunks.push(nextSlice);
|
|
1087
|
+
previewBytes += nextSlice.length;
|
|
1088
|
+
if (nextSlice.length < bufferChunk.length) {
|
|
1089
|
+
previewTruncated = true;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
else {
|
|
1093
|
+
previewTruncated = true;
|
|
1094
|
+
}
|
|
1095
|
+
};
|
|
1096
|
+
const onEnd = () => {
|
|
1097
|
+
cleanup();
|
|
1098
|
+
settle(() => {
|
|
1099
|
+
const rawBody = rawBodyChunks.length > 0 ? Buffer.concat(rawBodyChunks) : undefined;
|
|
1100
|
+
const previewBuffer = previewChunks.length > 0 ? Buffer.concat(previewChunks) : Buffer.alloc(0);
|
|
1101
|
+
resolve({
|
|
1102
|
+
rawBody,
|
|
1103
|
+
preview: createPreview(previewBuffer, rawBody?.byteLength ?? 0, getHeaderValue(req.headers['content-type']), previewTruncated),
|
|
1104
|
+
});
|
|
1105
|
+
});
|
|
1106
|
+
};
|
|
1107
|
+
const onError = (error) => {
|
|
1108
|
+
cleanup();
|
|
1109
|
+
settle(() => {
|
|
1110
|
+
reject(error);
|
|
1111
|
+
});
|
|
1112
|
+
};
|
|
1113
|
+
const onAborted = () => {
|
|
1114
|
+
cleanup();
|
|
1115
|
+
settle(() => {
|
|
1116
|
+
reject(new Error('request aborted while reading body'));
|
|
1117
|
+
});
|
|
1118
|
+
};
|
|
1119
|
+
req.on('data', onData);
|
|
1120
|
+
req.once('end', onEnd);
|
|
1121
|
+
req.once('error', onError);
|
|
1122
|
+
req.once('aborted', onAborted);
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
async function parseBody(req, method, maxBytes) {
|
|
1126
|
+
const { rawBody, preview } = await readRequestBodyWithPreview(req, method, maxBytes);
|
|
1127
|
+
if (rawBody === undefined || rawBody.byteLength === 0) {
|
|
1128
|
+
return {
|
|
1129
|
+
body: {},
|
|
1130
|
+
preview,
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
const contentType = getHeaderValue(req.headers['content-type'])?.toLowerCase() ?? '';
|
|
1134
|
+
if (contentType.includes('json')) {
|
|
1135
|
+
const textBody = rawBody.toString('utf8').trim();
|
|
1136
|
+
if (textBody.length === 0) {
|
|
1137
|
+
return {
|
|
1138
|
+
body: {},
|
|
1139
|
+
preview,
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
try {
|
|
1143
|
+
const parsed = JSON.parse(textBody);
|
|
1144
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
1145
|
+
return {
|
|
1146
|
+
body: parsed,
|
|
1147
|
+
preview,
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
return {
|
|
1151
|
+
body: { value: parsed },
|
|
1152
|
+
preview,
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
catch {
|
|
1156
|
+
return {
|
|
1157
|
+
body: { raw: textBody },
|
|
1158
|
+
preview,
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
if (contentType.includes('x-www-form-urlencoded')) {
|
|
1163
|
+
return {
|
|
1164
|
+
body: parseQuery(new URLSearchParams(rawBody.toString('utf8'))),
|
|
1165
|
+
preview,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
if (isTextualContentType(contentType)) {
|
|
1169
|
+
return {
|
|
1170
|
+
body: { raw: rawBody.toString('utf8') },
|
|
1171
|
+
preview,
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
return {
|
|
1175
|
+
body: {},
|
|
1176
|
+
preview,
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Parse Cookie header string into an object
|
|
1182
|
+
*/
|
|
1183
|
+
function parseCookie(cookieHeader) {
|
|
1184
|
+
if (!cookieHeader) {
|
|
1185
|
+
return {};
|
|
1186
|
+
}
|
|
1187
|
+
const cookies = {};
|
|
1188
|
+
const pairs = cookieHeader.split(';');
|
|
1189
|
+
for (const pair of pairs) {
|
|
1190
|
+
const [key, ...valueParts] = pair.split('=');
|
|
1191
|
+
if (!key)
|
|
1192
|
+
continue;
|
|
1193
|
+
const trimmedKey = key.trim();
|
|
1194
|
+
const value = valueParts.join('=').trim();
|
|
1195
|
+
cookies[trimmedKey] = decodeURIComponent(value);
|
|
1196
|
+
}
|
|
1197
|
+
return cookies;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function createWorkerServer(cx) {
|
|
1201
|
+
const server = http.createServer(async (req, res) => {
|
|
1202
|
+
const method = req.method ?? 'GET';
|
|
1203
|
+
const ip = getRealIp(req);
|
|
1204
|
+
const url = toURL(req.url);
|
|
1205
|
+
if (url === undefined) {
|
|
1206
|
+
safeSendJson(res, { message: 'Bad Request: req.url is undefined' }, 400 /* HttpCode.BadRequest */);
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
const normalized = {
|
|
1210
|
+
method,
|
|
1211
|
+
ip,
|
|
1212
|
+
url,
|
|
1213
|
+
query: parseQuery(url.searchParams),
|
|
1214
|
+
body: {},
|
|
1215
|
+
headers: req.headers,
|
|
1216
|
+
cookie: parseCookie(req.headers.cookie),
|
|
1217
|
+
};
|
|
1218
|
+
let bodyPreview = {
|
|
1219
|
+
exists: false,
|
|
1220
|
+
bytes: 0,
|
|
1221
|
+
truncated: false,
|
|
1222
|
+
};
|
|
1223
|
+
cx.logger.info('Req', { method, ip, path: url.pathname });
|
|
1224
|
+
const start = performance.now();
|
|
1225
|
+
res.once('finish', () => {
|
|
1226
|
+
const fields = {
|
|
1227
|
+
workerId: process.env.WORKER_ID ?? '[primary]',
|
|
1228
|
+
method,
|
|
1229
|
+
ip,
|
|
1230
|
+
path: url.pathname,
|
|
1231
|
+
status: res.statusCode,
|
|
1232
|
+
duration: (performance.now() - start).toFixed(4),
|
|
1233
|
+
};
|
|
1234
|
+
if ($keys(normalized.query).length > 0) {
|
|
1235
|
+
fields.query = normalized.query;
|
|
1236
|
+
}
|
|
1237
|
+
if (bodyPreview.exists) {
|
|
1238
|
+
fields.body = bodyPreview.value;
|
|
1239
|
+
fields.bodyBytes = bodyPreview.bytes;
|
|
1240
|
+
fields.bodyTruncated = bodyPreview.truncated;
|
|
1241
|
+
}
|
|
1242
|
+
cx.logger.info('Res', fields);
|
|
1243
|
+
});
|
|
1244
|
+
// * Start request handling
|
|
1245
|
+
try {
|
|
1246
|
+
if (normalized.url.pathname.startsWith(META_PREFIX + '/')) {
|
|
1247
|
+
safeSendJson(res, { message: `Meta APIs are available on port ${cx.options.metaPort}` }, 404 /* HttpCode.NotFound */);
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
const parsed = await parseBody(req, normalized.method, cx.options.maxRequestBytes);
|
|
1251
|
+
normalized.body = parsed.body;
|
|
1252
|
+
bodyPreview = parsed.preview;
|
|
1253
|
+
const handler = await cx.router.getHandler(url);
|
|
1254
|
+
if (!handler) {
|
|
1255
|
+
safeSendJson(res, { message: 'Not Found' }, 404 /* HttpCode.NotFound */);
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
const result = await PromiseTry(handler, normalized, req, res);
|
|
1259
|
+
if (result !== cx.router.StaticHandled) {
|
|
1260
|
+
safeSendJson(res, result);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
catch (error) {
|
|
1264
|
+
cx.logger.error('RequestFailed', {
|
|
1265
|
+
method: normalized.method,
|
|
1266
|
+
ip: normalized.ip,
|
|
1267
|
+
path: normalized.url.pathname,
|
|
1268
|
+
error: getErrorMessage(error),
|
|
1269
|
+
});
|
|
1270
|
+
if (error.code === 'REQUEST_BODY_TOO_LARGE') {
|
|
1271
|
+
safeSendJson(res, { message: getErrorMessage(error) }, 413 /* HttpCode.PayloadTooLarge */);
|
|
1272
|
+
}
|
|
1273
|
+
else {
|
|
1274
|
+
safeSendJson(res, { message: 'Internal Server Error' }, 500 /* HttpCode.InternalServerError */);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
server.on('close', () => {
|
|
1279
|
+
cx.logger.info('ServerClosed', {
|
|
1280
|
+
host: cx.options.host,
|
|
1281
|
+
port: cx.options.port,
|
|
1282
|
+
});
|
|
1283
|
+
});
|
|
1284
|
+
server.listen(cx.options.port, cx.options.host, () => {
|
|
1285
|
+
cx.logger.info('ServerStarted', {
|
|
1286
|
+
pid: process.pid,
|
|
1287
|
+
host: cx.options.host,
|
|
1288
|
+
port: cx.options.port,
|
|
1289
|
+
});
|
|
1290
|
+
cx.logger.info('DynamicDirectory', { directory: cx.options.dir });
|
|
1291
|
+
});
|
|
1292
|
+
server.on('error', (error) => {
|
|
1293
|
+
cx.logger.error('ServerError', {
|
|
1294
|
+
error: getErrorMessage(error),
|
|
1295
|
+
});
|
|
1296
|
+
});
|
|
1297
|
+
return server;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
const inject = async (cx) => {
|
|
1301
|
+
const o = {};
|
|
1302
|
+
Reflect.set(globalThis, INJECTION_KEY, o);
|
|
1303
|
+
for (let i = 0; i < cx.options.injections.length; i++) {
|
|
1304
|
+
const injection = cx.options.injections[i];
|
|
1305
|
+
const factory = loadFunction(injection);
|
|
1306
|
+
const instance = await PromiseTry(factory);
|
|
1307
|
+
o[injection.name] = instance;
|
|
1308
|
+
}
|
|
1309
|
+
cx.logger.info(`[worker ${process.pid}] injections loaded`, Object.keys(o));
|
|
1310
|
+
};
|
|
1311
|
+
const startStatsReporter = () => {
|
|
1312
|
+
let previousCpuUsage = process.cpuUsage();
|
|
1313
|
+
let previousAt = Date.now();
|
|
1314
|
+
const interval = setInterval(() => {
|
|
1315
|
+
const now = Date.now();
|
|
1316
|
+
const elapsedMicros = Math.max(1, (now - previousAt) * 1000);
|
|
1317
|
+
const cpuDelta = process.cpuUsage(previousCpuUsage);
|
|
1318
|
+
const cpuPercent = Number((((cpuDelta.user + cpuDelta.system) / elapsedMicros) * 100).toFixed(2));
|
|
1319
|
+
previousCpuUsage = process.cpuUsage();
|
|
1320
|
+
previousAt = now;
|
|
1321
|
+
const memoryUsage = process.memoryUsage();
|
|
1322
|
+
sendToPrimary({
|
|
1323
|
+
type: 203 /* WorkerAction.Stats */,
|
|
1324
|
+
pid: process.pid,
|
|
1325
|
+
stats: {
|
|
1326
|
+
at: now,
|
|
1327
|
+
pid: process.pid,
|
|
1328
|
+
uptimeSeconds: Number(process.uptime().toFixed(3)),
|
|
1329
|
+
cpu: {
|
|
1330
|
+
userMicros: cpuDelta.user,
|
|
1331
|
+
systemMicros: cpuDelta.system,
|
|
1332
|
+
percent: cpuPercent,
|
|
1333
|
+
},
|
|
1334
|
+
memory: {
|
|
1335
|
+
rss: memoryUsage.rss,
|
|
1336
|
+
heapTotal: memoryUsage.heapTotal,
|
|
1337
|
+
heapUsed: memoryUsage.heapUsed,
|
|
1338
|
+
external: memoryUsage.external,
|
|
1339
|
+
arrayBuffers: memoryUsage.arrayBuffers,
|
|
1340
|
+
},
|
|
1341
|
+
},
|
|
1342
|
+
});
|
|
1343
|
+
}, 2000);
|
|
1344
|
+
interval.unref();
|
|
1345
|
+
};
|
|
1346
|
+
function initWorker(cx) {
|
|
1347
|
+
if (cluster.isPrimary) {
|
|
1348
|
+
throw new Error('[FluxionTs error] createWorker should only be called in worker process');
|
|
1349
|
+
}
|
|
1350
|
+
process.on('message', (raw) => {
|
|
1351
|
+
if (!isPrimaryMessage(raw)) {
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
if (raw.type === 100 /* PrimaryAction.Ping */) {
|
|
1355
|
+
sendToPrimary({ type: 202 /* WorkerAction.Pong */, pid: process.pid, sentAt: raw.sentAt, receivedAt: Date.now() });
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
});
|
|
1359
|
+
sendToPrimary({ type: 200 /* WorkerAction.Created */, pid: process.pid });
|
|
1360
|
+
startStatsReporter();
|
|
1361
|
+
PromiseTry(async () => {
|
|
1362
|
+
await inject(cx);
|
|
1363
|
+
createWorkerServer(cx);
|
|
1364
|
+
sendToPrimary({ type: 201 /* WorkerAction.Ready */, pid: process.pid });
|
|
1365
|
+
}).catch((error) => {
|
|
1366
|
+
cx.logger.error('WorkerBootstrapFailed', {
|
|
1367
|
+
pid: process.pid,
|
|
1368
|
+
error: getErrorMessage(error),
|
|
1369
|
+
});
|
|
1370
|
+
process.exit(1);
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
class FluxionWatcher {
|
|
1375
|
+
constructor(cx) {
|
|
1376
|
+
this.timer = null;
|
|
1377
|
+
this.watcher = null;
|
|
1378
|
+
this.filesChanged = new Set();
|
|
1379
|
+
this.cx = cx;
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Recursively register all files in the options directory.
|
|
1383
|
+
*/
|
|
1384
|
+
init() {
|
|
1385
|
+
const dirPath = path.join(process.cwd(), this.cx.options.dir);
|
|
1386
|
+
const registerRecursive = (dir, relativePath) => {
|
|
1387
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1388
|
+
for (const entry of entries) {
|
|
1389
|
+
const entryPath = path.join(dir, entry.name);
|
|
1390
|
+
const entryRelativePath = path.join(relativePath, entry.name);
|
|
1391
|
+
if (entry.isDirectory()) {
|
|
1392
|
+
registerRecursive(entryPath, entryRelativePath);
|
|
1393
|
+
}
|
|
1394
|
+
else if (entry.isFile()) {
|
|
1395
|
+
try {
|
|
1396
|
+
this.cx.router.register(entryRelativePath);
|
|
1397
|
+
}
|
|
1398
|
+
catch (err) {
|
|
1399
|
+
this.cx.logger.error(`Error registering [${entryRelativePath}]: ${err.message}`);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
};
|
|
1404
|
+
if (fs.existsSync(dirPath)) {
|
|
1405
|
+
registerRecursive(dirPath, '');
|
|
1406
|
+
this.cx.logger.info(`Initial registration complete for directory: ${this.cx.options.dir}`);
|
|
1407
|
+
}
|
|
1408
|
+
else {
|
|
1409
|
+
this.cx.logger.warn(`Directory does not exist: ${this.cx.options.dir}`);
|
|
1410
|
+
}
|
|
1411
|
+
return this;
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Since all actions are mapped to `rename` and `change` (WatchEventType).
|
|
1415
|
+
*
|
|
1416
|
+
* We could only record every file and reload them all.
|
|
1417
|
+
*/
|
|
1418
|
+
start() {
|
|
1419
|
+
this.init();
|
|
1420
|
+
this.watcher = fs
|
|
1421
|
+
.watch(this.cx.options.dir, { recursive: true }, (_eventType, filename) => {
|
|
1422
|
+
if (!filename) {
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
this.filesChanged.add(filename);
|
|
1426
|
+
if (!this.timer) {
|
|
1427
|
+
this.timer = setTimeout(() => {
|
|
1428
|
+
this.filesChanged.forEach((p, _, s) => {
|
|
1429
|
+
try {
|
|
1430
|
+
this.cx.router.register(p);
|
|
1431
|
+
}
|
|
1432
|
+
catch (err) {
|
|
1433
|
+
this.cx.logger.error(`Error refreshing handlers: ${err.message}`);
|
|
1434
|
+
}
|
|
1435
|
+
finally {
|
|
1436
|
+
s.delete(p);
|
|
1437
|
+
}
|
|
1438
|
+
});
|
|
1439
|
+
this.timer = null;
|
|
1440
|
+
}, this.cx.options.reloadDelay);
|
|
1441
|
+
}
|
|
1442
|
+
})
|
|
1443
|
+
.on('error', (err) => {
|
|
1444
|
+
this.cx.logger.error(`Watcher error: ${err.message}`);
|
|
1445
|
+
this.cx.logger.error(`Restarting watcher...`);
|
|
1446
|
+
this.stop().start();
|
|
1447
|
+
});
|
|
1448
|
+
this.cx.logger.info(`Watcher started on directory: ${this.cx.options.dir}`);
|
|
1449
|
+
return this;
|
|
1450
|
+
}
|
|
1451
|
+
stop() {
|
|
1452
|
+
if (this.watcher) {
|
|
1453
|
+
this.watcher.close();
|
|
1454
|
+
this.watcher = null;
|
|
1455
|
+
}
|
|
1456
|
+
if (this.timer) {
|
|
1457
|
+
clearTimeout(this.timer);
|
|
1458
|
+
this.timer = null;
|
|
1459
|
+
}
|
|
1460
|
+
this.filesChanged.clear();
|
|
1461
|
+
return this;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
class FluxionRouter {
|
|
1466
|
+
constructor(cx) {
|
|
1467
|
+
/**
|
|
1468
|
+
* This means the request has been handled by static resource handler, and no more response should be sent.
|
|
1469
|
+
*/
|
|
1470
|
+
this.StaticHandled = Symbol.for('fluxion.router.StaticHandled');
|
|
1471
|
+
this.handlers = new Map();
|
|
1472
|
+
this.cx = cx;
|
|
1473
|
+
}
|
|
1474
|
+
makeStaticResource(filepath) {
|
|
1475
|
+
const fullPath = path.join(this.cx.options.dir, filepath);
|
|
1476
|
+
return async (normalized, _req, res) => {
|
|
1477
|
+
if (normalized.method !== 'GET' && normalized.method !== 'HEAD') {
|
|
1478
|
+
res.statusCode = 405;
|
|
1479
|
+
res.setHeader('Allow', 'GET, HEAD');
|
|
1480
|
+
res.end();
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
if (!fs.existsSync(fullPath)) {
|
|
1484
|
+
res.statusCode = 404;
|
|
1485
|
+
res.end('Not Found');
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
const stat = fs.statSync(fullPath);
|
|
1489
|
+
if (!stat.isFile()) {
|
|
1490
|
+
res.statusCode = 404;
|
|
1491
|
+
res.end('Not Found');
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
const extension = path.extname(filepath).toLowerCase();
|
|
1495
|
+
const contentType = STATIC_CONTENT_TYPES[extension] ?? 'application/octet-stream';
|
|
1496
|
+
res.statusCode = 200;
|
|
1497
|
+
res.setHeader('Content-Type', contentType);
|
|
1498
|
+
res.setHeader('Content-Length', String(stat.size));
|
|
1499
|
+
if (normalized.method === 'HEAD') {
|
|
1500
|
+
res.end();
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
return new Promise((resolve, reject) => {
|
|
1504
|
+
const stream = fs.createReadStream(fullPath);
|
|
1505
|
+
stream.on('error', reject);
|
|
1506
|
+
stream.on('end', () => resolve(this.StaticHandled));
|
|
1507
|
+
stream.pipe(res);
|
|
1508
|
+
});
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* 1. Check if the path exists, if not, delete the handler;
|
|
1513
|
+
* 2. If the file extension matches `routerExclude`, delete it and return early;
|
|
1514
|
+
* 3. If the file extension matches `apiExts`, register it as an API, otherwise register as static resource;
|
|
1515
|
+
* @param filepath
|
|
1516
|
+
*/
|
|
1517
|
+
register(filepath) {
|
|
1518
|
+
const fullpath = path.join(process.cwd(), this.cx.options.dir, filepath);
|
|
1519
|
+
if (!fs.existsSync(fullpath)) {
|
|
1520
|
+
this.handlers.delete(filepath);
|
|
1521
|
+
this.cx.logger.info(`[${filepath}] deleted`);
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
delete require.cache[fullpath];
|
|
1525
|
+
const extension = path.extname(filepath).toLowerCase();
|
|
1526
|
+
// Check if this file should be excluded from registration
|
|
1527
|
+
if (this.cx.options.routerExclude.some((ext) => extension === ext)) {
|
|
1528
|
+
this.handlers.delete(filepath);
|
|
1529
|
+
this.cx.logger.info(`[${filepath}] excluded`);
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
// register as api
|
|
1533
|
+
// ! Files with extensions matching `apiExts` are considered as API handlers.
|
|
1534
|
+
if (this.cx.options.apiExts.some((ext) => extension === ext)) {
|
|
1535
|
+
const handler = loadFunction({ modulePath: fullpath });
|
|
1536
|
+
this.handlers.set(filepath, handler);
|
|
1537
|
+
this.cx.logger.info(`[${filepath}] handler registered`);
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
// register as static resource
|
|
1541
|
+
this.handlers.set(filepath, this.makeStaticResource(filepath));
|
|
1542
|
+
this.cx.logger.info(`[${filepath}] static resource registered`);
|
|
1543
|
+
}
|
|
1544
|
+
getHandler(url) {
|
|
1545
|
+
const relativePath = url.pathname.replace(/^[\/]+/, '').replace(/[\/]+$/, '');
|
|
1546
|
+
return this.handlers.get(relativePath);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
async function fluxion(options) {
|
|
1551
|
+
const context = {
|
|
1552
|
+
options: normalizeOptions(options),
|
|
1553
|
+
};
|
|
1554
|
+
context.logger = createLogger(context);
|
|
1555
|
+
context.router = new FluxionRouter(context);
|
|
1556
|
+
if (cluster.isPrimary) {
|
|
1557
|
+
initPrimary(context);
|
|
1558
|
+
}
|
|
1559
|
+
else {
|
|
1560
|
+
// Only worker creates the watcher
|
|
1561
|
+
context.watcher = new FluxionWatcher(context).start();
|
|
1562
|
+
initWorker(context);
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function defineFluxionHandler(handler) {
|
|
1567
|
+
return handler;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
export { defineFluxionHandler, fluxion };
|
|
1571
|
+
//# sourceMappingURL=index.mjs.map
|