fluxion-ts 0.0.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/.oxlintrc.json +64 -0
- package/.prettierrc +6 -0
- package/AGENTS.md +3 -0
- package/Dockerfile +13 -0
- package/LICENSE +21 -0
- package/README.md +11 -0
- package/document/index.html +16 -0
- package/document/src/main.tsx +8 -0
- package/document/src/styles.css +321 -0
- package/document/src/view/App.tsx +304 -0
- package/document/src/view/CodeBlock.tsx +11 -0
- package/document/src/view/Section.tsx +18 -0
- package/document/vite.config.ts +23 -0
- package/draft/vibe.md +50 -0
- package/package.json +66 -0
- package/rollup.config.mjs +102 -0
- package/scripts/build-image.ts +13 -0
- package/scripts/bump-version.ts +12 -0
- package/scripts/configs.ts +79 -0
- package/scripts/lines.ts +54 -0
- package/scripts/publish.ts +6 -0
- package/src/common/consts.ts +30 -0
- package/src/common/dtm.ts +10 -0
- package/src/common/logger.ts +145 -0
- package/src/core/meta-api.ts +48 -0
- package/src/core/server.ts +447 -0
- package/src/core/types.d.ts +6 -0
- package/src/core/utils/headers.ts +34 -0
- package/src/core/utils/request.ts +145 -0
- package/src/core/utils/send-json.ts +21 -0
- package/src/index.ts +11 -0
- package/src/workers/file-runtime.ts +1071 -0
- package/src/workers/handler-worker-pool.ts +754 -0
- package/src/workers/handler-worker.ts +1029 -0
- package/src/workers/options.ts +77 -0
- package/src/workers/protocol.d.ts +186 -0
- package/tests/core/dynamic-directory.test.ts +48 -0
- package/tests/core/file-runtime.test.ts +347 -0
- package/tests/core/server-options.test.ts +204 -0
- package/tests/e2e/fluxion-server.e2e-spec.ts +225 -0
- package/tests/helpers/test-utils.ts +81 -0
- package/tsconfig.json +22 -0
- package/vitest.config.ts +24 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { performance } from 'node:perf_hooks';
|
|
6
|
+
|
|
7
|
+
import { HandlerResult, HttpCode } from '@/common/consts.js';
|
|
8
|
+
import { createLogger, getErrorMessage, type LoggerOption } from '@/common/logger.js';
|
|
9
|
+
import { createFileRuntime } from '@/workers/file-runtime.js';
|
|
10
|
+
import type { ExecutorOptions, WorkerStrategy } from '@/workers/options.js';
|
|
11
|
+
import type { protocol } from '@/workers/protocol.js';
|
|
12
|
+
|
|
13
|
+
import { createMetaApi } from './meta-api.js';
|
|
14
|
+
|
|
15
|
+
import type { NormalizedRequest } from './types.js';
|
|
16
|
+
import { safeSendJson } from './utils/send-json.js';
|
|
17
|
+
import { getRealIp } from './utils/headers.js';
|
|
18
|
+
import { createBodyPreviewCapture, parseQuery, toURL } from './utils/request.js';
|
|
19
|
+
|
|
20
|
+
const nodeRequire = createRequire(import.meta.url);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Database config item accepted by server options.
|
|
24
|
+
*/
|
|
25
|
+
export interface FluxionDatabaseConfig {
|
|
26
|
+
/**
|
|
27
|
+
* Stable database name.
|
|
28
|
+
*/
|
|
29
|
+
name: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* User-provided database config input.
|
|
34
|
+
*/
|
|
35
|
+
export type FluxionDatabaseInput = string | FluxionDatabaseConfig;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Raw database config item loaded from private config file.
|
|
39
|
+
*/
|
|
40
|
+
export type FluxionDatabaseRuntimeConfigInput =
|
|
41
|
+
| protocol.DbDriver
|
|
42
|
+
| (Record<string, unknown> & {
|
|
43
|
+
driver?: string;
|
|
44
|
+
type?: string;
|
|
45
|
+
contextKey?: string;
|
|
46
|
+
options?: Record<string, unknown>;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export interface FluxionOptions {
|
|
50
|
+
/**
|
|
51
|
+
* The directory where dynamic files (e.g. uploaded files) will be stored. It will be created if it doesn't exist.
|
|
52
|
+
* It is recommended to use an empty directory that is not used for any other purpose, to avoid potential conflicts or security issues.
|
|
53
|
+
*/
|
|
54
|
+
dir: string;
|
|
55
|
+
|
|
56
|
+
host: string;
|
|
57
|
+
|
|
58
|
+
port: number;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Declared database names used by worker strategy routing.
|
|
62
|
+
*/
|
|
63
|
+
databases?: FluxionDatabaseInput[];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Optional path to private db config file.
|
|
67
|
+
* Defaults to `./.fluxion-private/db.config.cjs`.
|
|
68
|
+
*/
|
|
69
|
+
dbConfigPath?: string;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Worker routing strategy.
|
|
73
|
+
*/
|
|
74
|
+
workerStrategy?: WorkerStrategy;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Base worker runtime option overrides.
|
|
78
|
+
*/
|
|
79
|
+
workerOptions?: Partial<ExecutorOptions>;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Maximum request body bytes accepted by dynamic handlers.
|
|
83
|
+
* Requests larger than this limit will return 413.
|
|
84
|
+
*/
|
|
85
|
+
maxRequestBytes?: number;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Logger output mode or custom logger sink.
|
|
89
|
+
* Defaults to `one-line`.
|
|
90
|
+
*/
|
|
91
|
+
logger?: LoggerOption;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Runtime guard for plain object values.
|
|
96
|
+
*/
|
|
97
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
98
|
+
return typeof value === 'object' && value !== null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Normalizes db driver aliases to runtime-supported values.
|
|
103
|
+
*/
|
|
104
|
+
function normalizeDbDriver(input: string, source: string, dbName: string): protocol.DbDriver {
|
|
105
|
+
const normalized = input.trim().toLowerCase();
|
|
106
|
+
|
|
107
|
+
if (normalized === 'pg' || normalized === 'postgres' || normalized === 'postgresql') {
|
|
108
|
+
return 'pg';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (normalized === 'mysql2' || normalized === 'mysql') {
|
|
112
|
+
return 'mysql2';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
throw new Error(`Unsupported db driver "${input}" for "${dbName}" in ${source}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Normalizes one raw db config item.
|
|
120
|
+
*/
|
|
121
|
+
function normalizeDatabaseRuntimeConfigItem(
|
|
122
|
+
dbName: string,
|
|
123
|
+
input: FluxionDatabaseRuntimeConfigInput,
|
|
124
|
+
source: string,
|
|
125
|
+
): protocol.WorkerDbConnectionConfig {
|
|
126
|
+
if (typeof input === 'string') {
|
|
127
|
+
return {
|
|
128
|
+
driver: normalizeDbDriver(input, source, dbName),
|
|
129
|
+
options: {},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const rawDriver =
|
|
134
|
+
typeof input.driver === 'string'
|
|
135
|
+
? input.driver
|
|
136
|
+
: typeof input.type === 'string'
|
|
137
|
+
? input.type
|
|
138
|
+
: undefined;
|
|
139
|
+
|
|
140
|
+
if (rawDriver === undefined) {
|
|
141
|
+
throw new Error(`Missing db driver for "${dbName}" in ${source}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const rawContextKey = typeof input.contextKey === 'string' ? input.contextKey.trim() : undefined;
|
|
145
|
+
const contextKey = rawContextKey === undefined || rawContextKey.length === 0 ? undefined : rawContextKey;
|
|
146
|
+
|
|
147
|
+
const options: Record<string, unknown> = {};
|
|
148
|
+
|
|
149
|
+
if (input.options !== undefined) {
|
|
150
|
+
if (!isRecord(input.options)) {
|
|
151
|
+
throw new Error(`Invalid db options for "${dbName}" in ${source}: options must be an object`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const optionKeys = Object.keys(input.options);
|
|
155
|
+
for (let i = 0; i < optionKeys.length; i++) {
|
|
156
|
+
const key = optionKeys[i];
|
|
157
|
+
const value = input.options[key];
|
|
158
|
+
if (value !== undefined) {
|
|
159
|
+
options[key] = value;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
const keys = Object.keys(input);
|
|
164
|
+
for (let i = 0; i < keys.length; i++) {
|
|
165
|
+
const key = keys[i];
|
|
166
|
+
if (key === 'driver' || key === 'type' || key === 'options') {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const value = input[key];
|
|
171
|
+
if (value !== undefined) {
|
|
172
|
+
options[key] = value;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
driver: normalizeDbDriver(rawDriver, source, dbName),
|
|
179
|
+
contextKey,
|
|
180
|
+
options,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Normalizes private db config file exports.
|
|
186
|
+
*/
|
|
187
|
+
function normalizeDatabaseConfigMap(input: unknown, source: string): protocol.WorkerDbConfigMap {
|
|
188
|
+
if (input === undefined || input === null) {
|
|
189
|
+
return {};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!isRecord(input)) {
|
|
193
|
+
throw new Error(`Invalid db config file "${source}": expected object export`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const normalized: protocol.WorkerDbConfigMap = {};
|
|
197
|
+
const rawNames = Object.keys(input);
|
|
198
|
+
|
|
199
|
+
for (let i = 0; i < rawNames.length; i++) {
|
|
200
|
+
const rawName = rawNames[i];
|
|
201
|
+
const name = rawName.trim();
|
|
202
|
+
|
|
203
|
+
if (name.length === 0) {
|
|
204
|
+
throw new Error(`Invalid db config in "${source}": empty database name`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const rawConfig = input[rawName];
|
|
208
|
+
if (typeof rawConfig !== 'string' && !isRecord(rawConfig)) {
|
|
209
|
+
throw new Error(`Invalid db config for "${name}" in "${source}"`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
normalized[name] = normalizeDatabaseRuntimeConfigItem(
|
|
213
|
+
name,
|
|
214
|
+
rawConfig as FluxionDatabaseRuntimeConfigInput,
|
|
215
|
+
source,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return normalized;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Resolves private db config path.
|
|
224
|
+
*/
|
|
225
|
+
function resolveDbConfigPath(input: string | undefined): string {
|
|
226
|
+
return path.resolve(input ?? path.join('.fluxion-private', 'db.config.cjs'));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Loads db config module from private file.
|
|
231
|
+
*/
|
|
232
|
+
function loadDatabaseConfigMapFromFile(filePath: string): protocol.WorkerDbConfigMap {
|
|
233
|
+
if (!fs.existsSync(filePath)) {
|
|
234
|
+
return {};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const requiredModule = nodeRequire(filePath) as unknown;
|
|
238
|
+
const exported = isRecord(requiredModule) && 'default' in requiredModule ? requiredModule.default : requiredModule;
|
|
239
|
+
return normalizeDatabaseConfigMap(exported, filePath);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Normalizes database names with private config fallback.
|
|
244
|
+
*/
|
|
245
|
+
function normalizeDatabaseNames(
|
|
246
|
+
databases: FluxionDatabaseInput[] | undefined,
|
|
247
|
+
fallbackNames: readonly string[],
|
|
248
|
+
): string[] {
|
|
249
|
+
if (databases === undefined || databases.length === 0) {
|
|
250
|
+
const names = [...fallbackNames];
|
|
251
|
+
names.sort((left, right) => left.localeCompare(right));
|
|
252
|
+
return names;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const names: string[] = [];
|
|
256
|
+
const seen = new Set<string>();
|
|
257
|
+
|
|
258
|
+
for (let i = 0; i < databases.length; i++) {
|
|
259
|
+
const item = databases[i];
|
|
260
|
+
const rawName = typeof item === 'string' ? item : item.name;
|
|
261
|
+
const name = rawName.trim();
|
|
262
|
+
|
|
263
|
+
if (name.length === 0) {
|
|
264
|
+
throw new Error(`Invalid databases[${i}]: empty name`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (seen.has(name)) {
|
|
268
|
+
throw new Error(`Duplicate database name: ${name}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
seen.add(name);
|
|
272
|
+
names.push(name);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return names;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Selects declared db configs for worker bootstrap.
|
|
280
|
+
*/
|
|
281
|
+
function selectDeclaredDatabaseConfigMap(
|
|
282
|
+
databaseNames: readonly string[],
|
|
283
|
+
databaseConfigMap: protocol.WorkerDbConfigMap,
|
|
284
|
+
): protocol.WorkerDbConfigMap {
|
|
285
|
+
const selected: protocol.WorkerDbConfigMap = {};
|
|
286
|
+
|
|
287
|
+
for (let i = 0; i < databaseNames.length; i++) {
|
|
288
|
+
const name = databaseNames[i];
|
|
289
|
+
const config = databaseConfigMap[name];
|
|
290
|
+
if (config !== undefined) {
|
|
291
|
+
selected[name] = config;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return selected;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function fluxion(options: FluxionOptions): http.Server {
|
|
299
|
+
const dir = path.resolve(options.dir);
|
|
300
|
+
const dbConfigPath = resolveDbConfigPath(options.dbConfigPath);
|
|
301
|
+
const loadedDatabaseConfigMap = loadDatabaseConfigMapFromFile(dbConfigPath);
|
|
302
|
+
const databaseNames = normalizeDatabaseNames(options.databases, Object.keys(loadedDatabaseConfigMap));
|
|
303
|
+
const databaseConfigMap = selectDeclaredDatabaseConfigMap(databaseNames, loadedDatabaseConfigMap);
|
|
304
|
+
const logger = createLogger(options.logger);
|
|
305
|
+
if (!fs.existsSync(dir)) {
|
|
306
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
307
|
+
logger.write('INFO', 'DynamicDirectoryCreated', { directory: dir });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const fileRuntime = createFileRuntime(dir, {
|
|
311
|
+
databaseNames,
|
|
312
|
+
databaseConfigMap,
|
|
313
|
+
workerStrategy: options.workerStrategy,
|
|
314
|
+
workerOptions: options.workerOptions,
|
|
315
|
+
maxRequestBytes: options.maxRequestBytes,
|
|
316
|
+
logger,
|
|
317
|
+
});
|
|
318
|
+
const metaApi = createMetaApi({
|
|
319
|
+
dir,
|
|
320
|
+
getRouteSnapshot: fileRuntime.getRouteSnapshot,
|
|
321
|
+
getWorkerSnapshot: fileRuntime.getWorkerSnapshot,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
void fileRuntime
|
|
325
|
+
.getRouteSnapshot()
|
|
326
|
+
.then((snapshot) => {
|
|
327
|
+
const handlerCount = snapshot.handlers.length;
|
|
328
|
+
const staticFileCount = snapshot.staticFiles.length;
|
|
329
|
+
|
|
330
|
+
logger.write('INFO', 'DynamicDirectoryLoaded', {
|
|
331
|
+
dir,
|
|
332
|
+
handlerCount,
|
|
333
|
+
staticFileCount,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
if (handlerCount === 0) {
|
|
337
|
+
logger.write('INFO', 'DynamicHandlersLoaded', { count: 0 });
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (let i = 0; i < snapshot.handlers.length; i++) {
|
|
342
|
+
const handler = snapshot.handlers[i];
|
|
343
|
+
logger.write('INFO', 'HandlerLoaded', {
|
|
344
|
+
route: handler.route,
|
|
345
|
+
file: handler.file,
|
|
346
|
+
version: handler.version,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
.catch((error) => {
|
|
351
|
+
logger.write('ERROR', 'DynamicDirectoryLoadFailed', {
|
|
352
|
+
dir,
|
|
353
|
+
error: getErrorMessage(error),
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const server = http.createServer((req, res) => {
|
|
358
|
+
const method = req.method ?? 'GET';
|
|
359
|
+
const ip = getRealIp(req);
|
|
360
|
+
const url = toURL(req.url);
|
|
361
|
+
if (url === undefined) {
|
|
362
|
+
safeSendJson(res, { message: 'Bad Request: req.url is undefined' }, HttpCode.BadRequest);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const normalized: NormalizedRequest = {
|
|
367
|
+
method,
|
|
368
|
+
ip,
|
|
369
|
+
url,
|
|
370
|
+
query: parseQuery(url.searchParams),
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const bodyCapture = createBodyPreviewCapture(req);
|
|
374
|
+
|
|
375
|
+
logger.write('INFO', 'RequestReceived', { method, ip, path: url.pathname });
|
|
376
|
+
|
|
377
|
+
const start = performance.now();
|
|
378
|
+
res.once('finish', () => {
|
|
379
|
+
const fields: Record<string, unknown> = {
|
|
380
|
+
method,
|
|
381
|
+
ip,
|
|
382
|
+
path: url.pathname,
|
|
383
|
+
status: res.statusCode,
|
|
384
|
+
duration: (performance.now() - start).toFixed(4),
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
if (Object.keys(normalized.query).length > 0) {
|
|
388
|
+
fields.query = normalized.query;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const bodyPreview = bodyCapture.getPreview();
|
|
392
|
+
if (bodyPreview.exists) {
|
|
393
|
+
fields.body = bodyPreview.value;
|
|
394
|
+
fields.bodyBytes = bodyPreview.bytes;
|
|
395
|
+
fields.bodyTruncated = bodyPreview.truncated;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
logger.write('INFO', 'RequestCompleted', fields);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
void metaApi
|
|
402
|
+
.handleRequest(req, res, normalized)
|
|
403
|
+
.then(async (metaHandled) => {
|
|
404
|
+
if (metaHandled) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const result = await fileRuntime.handleRequest(req, res, normalized);
|
|
409
|
+
if (result === HandlerResult.NotFound) {
|
|
410
|
+
safeSendJson(res, { message: 'Route not found', method, url }, HttpCode.NotFound);
|
|
411
|
+
}
|
|
412
|
+
})
|
|
413
|
+
.catch((error) => {
|
|
414
|
+
logger.write('ERROR', 'RequestFailed', { method, ip, path: url.pathname, error: getErrorMessage(error) });
|
|
415
|
+
|
|
416
|
+
if ((error as NodeJS.ErrnoException).code === 'REQUEST_BODY_TOO_LARGE') {
|
|
417
|
+
safeSendJson(res, { message: getErrorMessage(error) }, HttpCode.PayloadTooLarge);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
safeSendJson(res, { message: 'Internal Server Error' }, HttpCode.InternalServerError);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
server.on('close', () => {
|
|
426
|
+
void fileRuntime.close();
|
|
427
|
+
logger.write('INFO', 'ServerClosed', {
|
|
428
|
+
host: options.host,
|
|
429
|
+
port: options.port,
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
server.listen(options.port, options.host, () => {
|
|
434
|
+
logger.write('INFO', 'ServerStarted', {
|
|
435
|
+
host: options.host,
|
|
436
|
+
port: options.port,
|
|
437
|
+
});
|
|
438
|
+
logger.write('INFO', 'DynamicDirectory', { directory: dir });
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
server.on('error', (error) => {
|
|
442
|
+
logger.write('ERROR', 'ServerError', {
|
|
443
|
+
error: getErrorMessage(error),
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
return server;
|
|
447
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
|
|
3
|
+
export function getRealIp(req: IncomingMessage): string {
|
|
4
|
+
const forwardedFor = req.headersDistinct['x-forwarded-for'];
|
|
5
|
+
if (forwardedFor) {
|
|
6
|
+
const firstForwarded = forwardedFor[0]?.split(',')[0]?.trim();
|
|
7
|
+
if (firstForwarded && firstForwarded.length > 0) {
|
|
8
|
+
return firstForwarded;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const realIp = req.headersDistinct['x-real-ip']?.[0].trim();
|
|
13
|
+
if (realIp !== undefined) {
|
|
14
|
+
return realIp;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return req.socket.remoteAddress ?? 'unknown';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isTextualContentType(contentType: string | undefined): boolean {
|
|
21
|
+
if (contentType === undefined) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const normalized = contentType.toLowerCase();
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
normalized.startsWith('text/') ||
|
|
29
|
+
normalized.includes('json') ||
|
|
30
|
+
normalized.includes('xml') ||
|
|
31
|
+
normalized.includes('x-www-form-urlencoded') ||
|
|
32
|
+
normalized.includes('javascript')
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type http from 'node:http';
|
|
2
|
+
|
|
3
|
+
import { DUMMY_BASE_URL } from '@/common/consts.js';
|
|
4
|
+
import { isTextualContentType } from './headers.js';
|
|
5
|
+
|
|
6
|
+
interface ParsedRequestTarget {
|
|
7
|
+
path: string;
|
|
8
|
+
query: Record<string, string | string[]>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface BodyPreview {
|
|
12
|
+
exists: boolean;
|
|
13
|
+
value?: string;
|
|
14
|
+
bytes: number;
|
|
15
|
+
truncated: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function toURL(rawUrl: string | undefined): URL | undefined {
|
|
19
|
+
if (rawUrl === undefined) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
return new URL(rawUrl, DUMMY_BASE_URL);
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseQuery(searchParams: URLSearchParams): Record<string, string | string[]> {
|
|
31
|
+
const query: Record<string, string | string[]> = {};
|
|
32
|
+
|
|
33
|
+
for (const [key, value] of searchParams.entries()) {
|
|
34
|
+
const existing = query[key];
|
|
35
|
+
|
|
36
|
+
if (existing === undefined) {
|
|
37
|
+
query[key] = value;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (Array.isArray(existing)) {
|
|
42
|
+
existing.push(value);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
query[key] = [existing, value];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return query;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createBodyPreviewCapture(
|
|
53
|
+
req: http.IncomingMessage,
|
|
54
|
+
maxBytes = 8192,
|
|
55
|
+
): { getPreview: () => BodyPreview } {
|
|
56
|
+
const originalPush = req.push;
|
|
57
|
+
const chunks: Buffer[] = [];
|
|
58
|
+
let previewBytes = 0;
|
|
59
|
+
let totalBytes = 0;
|
|
60
|
+
let truncated = false;
|
|
61
|
+
let restored = false;
|
|
62
|
+
|
|
63
|
+
const restorePush = (): void => {
|
|
64
|
+
if (restored) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
req.push = originalPush;
|
|
69
|
+
restored = true;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
req.push = ((chunk: unknown, encoding?: BufferEncoding): boolean => {
|
|
73
|
+
if (chunk !== null && chunk !== undefined) {
|
|
74
|
+
let bufferChunk: Buffer;
|
|
75
|
+
|
|
76
|
+
if (Buffer.isBuffer(chunk)) {
|
|
77
|
+
bufferChunk = chunk;
|
|
78
|
+
} else if (typeof chunk === 'string') {
|
|
79
|
+
bufferChunk = Buffer.from(chunk, encoding);
|
|
80
|
+
} else if (ArrayBuffer.isView(chunk)) {
|
|
81
|
+
bufferChunk = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
82
|
+
} else {
|
|
83
|
+
bufferChunk = Buffer.from(String(chunk));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
totalBytes += bufferChunk.length;
|
|
87
|
+
|
|
88
|
+
if (previewBytes < maxBytes) {
|
|
89
|
+
const remaining = maxBytes - previewBytes;
|
|
90
|
+
const nextSlice = bufferChunk.subarray(0, remaining);
|
|
91
|
+
chunks.push(nextSlice);
|
|
92
|
+
previewBytes += nextSlice.length;
|
|
93
|
+
|
|
94
|
+
if (bufferChunk.length > remaining) {
|
|
95
|
+
truncated = true;
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
truncated = true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return originalPush.call(req, chunk as never, encoding);
|
|
103
|
+
}) as typeof req.push;
|
|
104
|
+
|
|
105
|
+
req.once('end', restorePush);
|
|
106
|
+
req.once('close', restorePush);
|
|
107
|
+
|
|
108
|
+
const getPreview = (): BodyPreview => {
|
|
109
|
+
const contentLength = req.headers['content-length'];
|
|
110
|
+
const declaredBytes = contentLength ? Number.parseInt(contentLength, 10) : NaN;
|
|
111
|
+
const hasDeclaredBody = Number.isFinite(declaredBytes) && declaredBytes > 0;
|
|
112
|
+
const hasCapturedBody = totalBytes > 0;
|
|
113
|
+
const hasBody = hasDeclaredBody || hasCapturedBody;
|
|
114
|
+
const effectiveBytes = hasCapturedBody ? totalBytes : hasDeclaredBody ? declaredBytes : 0;
|
|
115
|
+
|
|
116
|
+
if (!hasBody) {
|
|
117
|
+
return {
|
|
118
|
+
exists: false,
|
|
119
|
+
bytes: 0,
|
|
120
|
+
truncated: false,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const contentType = req.headers['content-type'];
|
|
125
|
+
const bodyBuffer = Buffer.concat(chunks);
|
|
126
|
+
|
|
127
|
+
if (isTextualContentType(contentType)) {
|
|
128
|
+
return {
|
|
129
|
+
exists: true,
|
|
130
|
+
value: bodyBuffer.toString('utf8'),
|
|
131
|
+
bytes: effectiveBytes,
|
|
132
|
+
truncated: truncated || (hasDeclaredBody && declaredBytes > bodyBuffer.length),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
exists: true,
|
|
138
|
+
value: `<binary body: ${effectiveBytes} bytes>`,
|
|
139
|
+
bytes: effectiveBytes,
|
|
140
|
+
truncated: truncated || (hasDeclaredBody && declaredBytes > bodyBuffer.length),
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return { getPreview };
|
|
145
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ServerResponse } from 'node:http';
|
|
2
|
+
import { HttpCode } from '@/common/consts.js';
|
|
3
|
+
|
|
4
|
+
export function sendJson(res: ServerResponse, payload: unknown, statusCode: HttpCode = HttpCode.Ok): void {
|
|
5
|
+
res.statusCode = statusCode;
|
|
6
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
7
|
+
res.end(JSON.stringify(payload));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function safeSendJson(res: ServerResponse, payload: unknown, statusCode: HttpCode = HttpCode.Ok): void {
|
|
11
|
+
if (res.writableEnded) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (res.headersSent) {
|
|
16
|
+
res.end();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
sendJson(res, payload, statusCode);
|
|
21
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { fluxion, type FluxionOptions } from './core/server.js';
|
|
2
|
+
|
|
3
|
+
export { fluxion, type FluxionOptions };
|
|
4
|
+
|
|
5
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
6
|
+
fluxion({
|
|
7
|
+
dir: process.env.DYNAMIC_DIRECTORY ?? 'dynamicDirectory',
|
|
8
|
+
host: process.env.HOST ?? 'localhost',
|
|
9
|
+
port: process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000,
|
|
10
|
+
});
|
|
11
|
+
}
|