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.
Files changed (43) hide show
  1. package/.oxlintrc.json +64 -0
  2. package/.prettierrc +6 -0
  3. package/AGENTS.md +3 -0
  4. package/Dockerfile +13 -0
  5. package/LICENSE +21 -0
  6. package/README.md +11 -0
  7. package/document/index.html +16 -0
  8. package/document/src/main.tsx +8 -0
  9. package/document/src/styles.css +321 -0
  10. package/document/src/view/App.tsx +304 -0
  11. package/document/src/view/CodeBlock.tsx +11 -0
  12. package/document/src/view/Section.tsx +18 -0
  13. package/document/vite.config.ts +23 -0
  14. package/draft/vibe.md +50 -0
  15. package/package.json +66 -0
  16. package/rollup.config.mjs +102 -0
  17. package/scripts/build-image.ts +13 -0
  18. package/scripts/bump-version.ts +12 -0
  19. package/scripts/configs.ts +79 -0
  20. package/scripts/lines.ts +54 -0
  21. package/scripts/publish.ts +6 -0
  22. package/src/common/consts.ts +30 -0
  23. package/src/common/dtm.ts +10 -0
  24. package/src/common/logger.ts +145 -0
  25. package/src/core/meta-api.ts +48 -0
  26. package/src/core/server.ts +447 -0
  27. package/src/core/types.d.ts +6 -0
  28. package/src/core/utils/headers.ts +34 -0
  29. package/src/core/utils/request.ts +145 -0
  30. package/src/core/utils/send-json.ts +21 -0
  31. package/src/index.ts +11 -0
  32. package/src/workers/file-runtime.ts +1071 -0
  33. package/src/workers/handler-worker-pool.ts +754 -0
  34. package/src/workers/handler-worker.ts +1029 -0
  35. package/src/workers/options.ts +77 -0
  36. package/src/workers/protocol.d.ts +186 -0
  37. package/tests/core/dynamic-directory.test.ts +48 -0
  38. package/tests/core/file-runtime.test.ts +347 -0
  39. package/tests/core/server-options.test.ts +204 -0
  40. package/tests/e2e/fluxion-server.e2e-spec.ts +225 -0
  41. package/tests/helpers/test-utils.ts +81 -0
  42. package/tsconfig.json +22 -0
  43. package/vitest.config.ts +24 -0
@@ -0,0 +1,1071 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import type http from 'node:http';
4
+
5
+ import { HandlerResult, STATIC_CONTENT_TYPES } from '@/common/consts.js';
6
+ import { createLogger, type FluxionLogger } from '@/common/logger.js';
7
+ import type { NormalizedRequest } from '@/core/types.js';
8
+ import { parseQuery, toURL } from '@/core/utils/request.js';
9
+
10
+ import { createHandlerWorkerPool } from './handler-worker-pool.js';
11
+ import type { HandlerWorkerPool, HandlerWorkerSnapshot } from './handler-worker-pool.js';
12
+ import type { ExecutorOptions, WorkerStrategy } from './options.js';
13
+ import type { protocol } from './protocol.js';
14
+
15
+ /**
16
+ * Parsed and validated request path.
17
+ */
18
+ interface ParsedPath {
19
+ /**
20
+ * Original pathname.
21
+ */
22
+ pathname: string;
23
+ /**
24
+ * Safe decoded path segments.
25
+ */
26
+ segments: string[];
27
+ }
28
+
29
+ /**
30
+ * Resolved dynamic handler file.
31
+ */
32
+ interface ResolvedHandlerFile {
33
+ /**
34
+ * Absolute handler path.
35
+ */
36
+ filePath: string;
37
+ /**
38
+ * Current handler version token.
39
+ */
40
+ version: string;
41
+ }
42
+
43
+ /**
44
+ * Common route snapshot fields.
45
+ */
46
+ interface RouteEntryBase {
47
+ /**
48
+ * Relative file path.
49
+ */
50
+ file: string;
51
+ /**
52
+ * Version token derived from file metadata.
53
+ */
54
+ version: string;
55
+ }
56
+
57
+ /**
58
+ * Dynamic route snapshot row.
59
+ */
60
+ export interface HandlerRouteEntry extends RouteEntryBase {
61
+ /**
62
+ * Public route path.
63
+ */
64
+ route: string;
65
+ }
66
+
67
+ /**
68
+ * Static route snapshot row.
69
+ */
70
+ export interface StaticRouteEntry extends RouteEntryBase {
71
+ /**
72
+ * Public route path.
73
+ */
74
+ route: string;
75
+ }
76
+
77
+ /**
78
+ * Full route snapshot used by meta api.
79
+ */
80
+ export interface FileRouteSnapshot {
81
+ /**
82
+ * Dynamic handler routes.
83
+ */
84
+ handlers: HandlerRouteEntry[];
85
+ /**
86
+ * Static file routes.
87
+ */
88
+ staticFiles: StaticRouteEntry[];
89
+ }
90
+
91
+ /**
92
+ * Worker runtime snapshot used by meta api.
93
+ */
94
+ export interface FileWorkerSnapshot {
95
+ /**
96
+ * Dynamic directory absolute path.
97
+ */
98
+ dir: string;
99
+ /**
100
+ * Worker supervisor snapshots.
101
+ */
102
+ workers: HandlerWorkerSnapshot[];
103
+ }
104
+
105
+ /**
106
+ * Optional file runtime configuration.
107
+ */
108
+ export interface FileRuntimeOptions {
109
+ /**
110
+ * Declared database names for worker strategy routing.
111
+ */
112
+ databaseNames?: string[];
113
+ /**
114
+ * Normalized db config map injected into worker pools.
115
+ */
116
+ databaseConfigMap?: protocol.WorkerDbConfigMap;
117
+ /**
118
+ * Worker strategy selector.
119
+ */
120
+ workerStrategy?: WorkerStrategy;
121
+ /**
122
+ * Base runtime option overrides applied to worker pools.
123
+ */
124
+ workerOptions?: Partial<ExecutorOptions>;
125
+ /**
126
+ * Maximum request body bytes accepted by dynamic handlers.
127
+ */
128
+ maxRequestBytes?: number;
129
+ /**
130
+ * Runtime logger implementation.
131
+ */
132
+ logger?: FluxionLogger;
133
+ }
134
+
135
+ /**
136
+ * File runtime public contract.
137
+ */
138
+ export interface FileRuntime {
139
+ /**
140
+ * Clears runtime caches.
141
+ */
142
+ clearCache(): void;
143
+ /**
144
+ * Closes runtime resources.
145
+ */
146
+ close(): Promise<void>;
147
+ /**
148
+ * Builds route snapshot from filesystem.
149
+ */
150
+ getRouteSnapshot(): Promise<FileRouteSnapshot>;
151
+ /**
152
+ * Returns worker diagnostics snapshot.
153
+ */
154
+ getWorkerSnapshot(): FileWorkerSnapshot;
155
+ /**
156
+ * Handles request by dynamic handler or static file fallback.
157
+ */
158
+ handleRequest(
159
+ req: http.IncomingMessage,
160
+ res: http.ServerResponse,
161
+ normalized?: NormalizedRequest,
162
+ ): Promise<HandlerResult>;
163
+ }
164
+
165
+ /**
166
+ * Worker definition before the pool is created.
167
+ */
168
+ interface ResolvedWorkerDefinition {
169
+ /**
170
+ * Stable worker id.
171
+ */
172
+ id: string;
173
+ /**
174
+ * Database names this worker can access.
175
+ */
176
+ dbSet: string[];
177
+ /**
178
+ * Marks auto-added all-db worker.
179
+ */
180
+ isFallbackAllDb: boolean;
181
+ /**
182
+ * Optional runtime overrides for this worker.
183
+ */
184
+ overrides?: Partial<ExecutorOptions>;
185
+ }
186
+
187
+ /**
188
+ * Runtime worker binding used for routing.
189
+ */
190
+ interface WorkerBinding {
191
+ /**
192
+ * Stable worker id.
193
+ */
194
+ id: string;
195
+ /**
196
+ * Database names this worker can access.
197
+ */
198
+ dbSet: string[];
199
+ /**
200
+ * Marks auto-added all-db worker.
201
+ */
202
+ isFallbackAllDb: boolean;
203
+ /**
204
+ * Worker pool handle.
205
+ */
206
+ pool: HandlerWorkerPool;
207
+ }
208
+
209
+ /**
210
+ * Returns static content-type by file extension.
211
+ */
212
+ function getContentType(filePath: string): string {
213
+ const extension = path.extname(filePath).toLowerCase();
214
+ return STATIC_CONTENT_TYPES[extension] ?? 'application/octet-stream';
215
+ }
216
+
217
+ /**
218
+ * Creates a typed request-body-too-large runtime error.
219
+ */
220
+ function createRequestBodyTooLargeError(receivedBytes: number, maxBytes: number): Error {
221
+ const sizeError = new Error(`request body too large: ${receivedBytes} bytes exceeds ${maxBytes} bytes`);
222
+ (sizeError as NodeJS.ErrnoException).code = 'REQUEST_BODY_TOO_LARGE';
223
+ return sizeError;
224
+ }
225
+
226
+ /**
227
+ * Validates max request body option.
228
+ */
229
+ function resolveMaxRequestBytes(value: number | undefined): number | undefined {
230
+ if (value === undefined) {
231
+ return undefined;
232
+ }
233
+
234
+ if (!Number.isFinite(value)) {
235
+ throw new Error('Invalid maxRequestBytes: must be a finite number');
236
+ }
237
+
238
+ const normalized = Math.floor(value);
239
+ if (normalized <= 0) {
240
+ throw new Error('Invalid maxRequestBytes: must be greater than 0');
241
+ }
242
+
243
+ return normalized;
244
+ }
245
+
246
+ /**
247
+ * Normalizes file separators to `/` for route output.
248
+ */
249
+ function normalizeRelativePath(relativePath: string): string {
250
+ return relativePath.split(path.sep).join('/');
251
+ }
252
+
253
+ /**
254
+ * Normalizes database name arrays into deduped sorted values.
255
+ */
256
+ function normalizeDbSet(input: readonly string[] | undefined): string[] {
257
+ if (input === undefined || input.length === 0) {
258
+ return [];
259
+ }
260
+
261
+ const seen = new Set<string>();
262
+ const normalized: string[] = [];
263
+
264
+ for (let i = 0; i < input.length; i++) {
265
+ const raw = input[i];
266
+ const name = raw.trim();
267
+
268
+ if (name.length === 0 || seen.has(name)) {
269
+ continue;
270
+ }
271
+
272
+ seen.add(name);
273
+ normalized.push(name);
274
+ }
275
+
276
+ normalized.sort((left, right) => left.localeCompare(right));
277
+ return normalized;
278
+ }
279
+
280
+ /**
281
+ * Determines whether two normalized db sets are equal.
282
+ */
283
+ function isSameDbSet(left: readonly string[], right: readonly string[]): boolean {
284
+ if (left.length !== right.length) {
285
+ return false;
286
+ }
287
+
288
+ for (let i = 0; i < left.length; i++) {
289
+ if (left[i] !== right[i]) {
290
+ return false;
291
+ }
292
+ }
293
+
294
+ return true;
295
+ }
296
+
297
+ /**
298
+ * Extracts runtime overrides from a custom worker item.
299
+ */
300
+ function extractWorkerOverrides(
301
+ baseOverrides: Partial<ExecutorOptions> | undefined,
302
+ customItem: { id: string; db: string[] } & Partial<ExecutorOptions>,
303
+ ): Partial<ExecutorOptions> {
304
+ const { id: _id, db: _db, ...customOverrides } = customItem;
305
+ return {
306
+ ...baseOverrides,
307
+ ...customOverrides,
308
+ };
309
+ }
310
+
311
+ /**
312
+ * ! Resolves worker strategy into concrete worker definitions.
313
+ */
314
+ function resolveWorkerDefinitions(
315
+ databaseNames: readonly string[],
316
+ workerStrategy: WorkerStrategy | undefined,
317
+ baseOverrides: Partial<ExecutorOptions> | undefined,
318
+ ): ResolvedWorkerDefinition[] {
319
+ const allDbSet = normalizeDbSet(databaseNames);
320
+
321
+ if (workerStrategy === undefined || workerStrategy === 'all') {
322
+ return [
323
+ {
324
+ id: 'fluxion-worker-all',
325
+ dbSet: allDbSet,
326
+ isFallbackAllDb: false,
327
+ overrides: baseOverrides,
328
+ },
329
+ ];
330
+ }
331
+
332
+ const declaredDbSet = new Set(allDbSet);
333
+ const definitions: ResolvedWorkerDefinition[] = [];
334
+ const idSet = new Set<string>();
335
+
336
+ for (let i = 0; i < workerStrategy.length; i++) {
337
+ const item = workerStrategy[i];
338
+ const id = item.id.trim();
339
+
340
+ if (id.length === 0) {
341
+ throw new Error(`Invalid workerStrategy item at index ${i}: id is empty`);
342
+ }
343
+
344
+ if (idSet.has(id)) {
345
+ throw new Error(`Duplicate workerStrategy id: ${id}`);
346
+ }
347
+
348
+ const dbSet = normalizeDbSet(item.db);
349
+ const unknownDb = dbSet.find((name) => !declaredDbSet.has(name));
350
+ if (unknownDb !== undefined) {
351
+ throw new Error(`Unknown database in workerStrategy (${id}): ${unknownDb}`);
352
+ }
353
+
354
+ definitions.push({
355
+ id,
356
+ dbSet,
357
+ isFallbackAllDb: false,
358
+ overrides: extractWorkerOverrides(baseOverrides, item),
359
+ });
360
+ idSet.add(id);
361
+ }
362
+
363
+ const hasAllDbWorker = definitions.some((item) => isSameDbSet(item.dbSet, allDbSet));
364
+ if (hasAllDbWorker) {
365
+ return definitions;
366
+ }
367
+
368
+ let fallbackId = 'fluxion-worker-all';
369
+ let suffix = 1;
370
+ while (idSet.has(fallbackId)) {
371
+ fallbackId = `fluxion-worker-all-${suffix}`;
372
+ suffix += 1;
373
+ }
374
+
375
+ definitions.push({
376
+ id: fallbackId,
377
+ dbSet: allDbSet,
378
+ isFallbackAllDb: true,
379
+ overrides: baseOverrides,
380
+ });
381
+
382
+ return definitions;
383
+ }
384
+
385
+ /**
386
+ * Picks the best execution worker by current load and stable id tie-break.
387
+ */
388
+ function selectExecutionWorker(workers: readonly WorkerBinding[]): WorkerBinding {
389
+ if (workers.length === 0) {
390
+ throw new Error('No worker can handle request');
391
+ }
392
+
393
+ let best = workers[0];
394
+ let bestInflight = best.pool.getSnapshot().inflight;
395
+
396
+ for (let i = 1; i < workers.length; i++) {
397
+ const candidate = workers[i];
398
+ const candidateInflight = candidate.pool.getSnapshot().inflight;
399
+
400
+ if (candidateInflight < bestInflight) {
401
+ best = candidate;
402
+ bestInflight = candidateInflight;
403
+ continue;
404
+ }
405
+
406
+ if (candidateInflight === bestInflight && candidate.id.localeCompare(best.id) < 0) {
407
+ best = candidate;
408
+ bestInflight = candidateInflight;
409
+ }
410
+ }
411
+
412
+ return best;
413
+ }
414
+
415
+ /**
416
+ * Verifies target path is still under root directory.
417
+ * ! Prevents directory traversal when resolving dynamic files.
418
+ */
419
+ function isUnderDirectory(targetPath: string, rootDirectory: string): boolean {
420
+ const relativePath = path.relative(rootDirectory, targetPath);
421
+ return !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
422
+ }
423
+
424
+ /**
425
+ * Private segments are not routable.
426
+ */
427
+ function isIgnoredSegment(segment: string): boolean {
428
+ return segment.startsWith('_');
429
+ }
430
+
431
+ /**
432
+ * Parses and validates pathname into safe segments.
433
+ */
434
+ function parseRequestPath(url: URL): ParsedPath | undefined {
435
+ const pathname = url.pathname;
436
+ const rawSegments = pathname.split('/').filter(Boolean);
437
+ const segments: string[] = [];
438
+
439
+ for (const rawSegment of rawSegments) {
440
+ let segment: string;
441
+
442
+ try {
443
+ segment = decodeURIComponent(rawSegment);
444
+ } catch {
445
+ return undefined;
446
+ }
447
+
448
+ if (
449
+ segment.length === 0 ||
450
+ segment === '.' ||
451
+ segment === '..' ||
452
+ segment.includes('/') ||
453
+ segment.includes('\\') ||
454
+ isIgnoredSegment(segment)
455
+ ) {
456
+ return undefined;
457
+ }
458
+
459
+ segments.push(segment);
460
+ }
461
+
462
+ return { pathname, segments };
463
+ }
464
+
465
+ /**
466
+ * Generates file version token from mtime and size.
467
+ */
468
+ async function getFileVersion(filePath: string): Promise<string | undefined> {
469
+ try {
470
+ const stat = await fs.promises.stat(filePath);
471
+
472
+ if (!stat.isFile()) {
473
+ return undefined;
474
+ }
475
+
476
+ return `${stat.mtimeMs}:${stat.size}`;
477
+ } catch (error) {
478
+ const code = (error as NodeJS.ErrnoException).code;
479
+
480
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
481
+ return undefined;
482
+ }
483
+
484
+ throw error;
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Converts relative path into public route.
490
+ */
491
+ function toPublicRoute(relativePath: string): string {
492
+ if (relativePath.length === 0) {
493
+ return '/';
494
+ }
495
+
496
+ return `/${normalizeRelativePath(relativePath)}`;
497
+ }
498
+
499
+ /**
500
+ * Maps handler file path to route path.
501
+ */
502
+ function getRouteFromHandlerFile(relativePath: string): string {
503
+ const normalizedRelativePath = normalizeRelativePath(relativePath);
504
+
505
+ if (normalizedRelativePath === 'index.mjs') {
506
+ return '/';
507
+ }
508
+
509
+ if (normalizedRelativePath.endsWith('/index.mjs')) {
510
+ const routePath = normalizedRelativePath.slice(0, -'/index.mjs'.length);
511
+ return toPublicRoute(routePath);
512
+ }
513
+
514
+ if (normalizedRelativePath.endsWith('.mjs')) {
515
+ const routePath = normalizedRelativePath.slice(0, -'.mjs'.length);
516
+ return toPublicRoute(routePath);
517
+ }
518
+
519
+ return toPublicRoute(normalizedRelativePath);
520
+ }
521
+
522
+ /**
523
+ * Builds ordered handler candidates for a route.
524
+ */
525
+ function buildHandlerCandidates(dynamicDirectory: string, segments: readonly string[]): string[] {
526
+ if (segments.length === 0) {
527
+ return [path.resolve(dynamicDirectory, 'index.mjs')];
528
+ }
529
+
530
+ const routePath = path.resolve(dynamicDirectory, ...segments);
531
+
532
+ return [path.resolve(routePath, 'index.mjs'), `${routePath}.mjs`];
533
+ }
534
+
535
+ /**
536
+ * Streams static file to response.
537
+ */
538
+ async function streamStaticFile(
539
+ filePath: string,
540
+ stat: fs.Stats,
541
+ method: string | undefined,
542
+ res: http.ServerResponse,
543
+ ): Promise<void> {
544
+ res.statusCode = 200;
545
+ res.setHeader('Content-Type', getContentType(filePath));
546
+ res.setHeader('Content-Length', String(stat.size));
547
+
548
+ if (method === 'HEAD') {
549
+ res.end();
550
+ return;
551
+ }
552
+
553
+ await new Promise<void>((resolve, reject) => {
554
+ const stream = fs.createReadStream(filePath);
555
+
556
+ stream.on('error', reject);
557
+ stream.on('end', resolve);
558
+ stream.pipe(res);
559
+ });
560
+ }
561
+
562
+ /**
563
+ * Normalizes request data when caller didn't pre-normalize.
564
+ */
565
+ function normalizeRequest(req: http.IncomingMessage, normalized?: NormalizedRequest): NormalizedRequest | undefined {
566
+ if (normalized !== undefined) {
567
+ return normalized;
568
+ }
569
+
570
+ const url = toURL(req.url);
571
+ if (url === undefined) {
572
+ return undefined;
573
+ }
574
+
575
+ const socket = req.socket as { remoteAddress?: string | undefined } | undefined;
576
+
577
+ return {
578
+ method: req.method ?? 'GET',
579
+ ip: socket?.remoteAddress ?? 'unknown',
580
+ url,
581
+ query: parseQuery(url.searchParams),
582
+ };
583
+ }
584
+
585
+ /**
586
+ * Serializes IncomingHttpHeaders for worker protocol.
587
+ */
588
+ function normalizeHeaders(headers: http.IncomingHttpHeaders): protocol.Headers {
589
+ const serializedHeaders: protocol.Headers = {};
590
+
591
+ const headerKeys = Object.keys(headers);
592
+ for (let i = 0; i < headerKeys.length; i++) {
593
+ const key = headerKeys[i];
594
+ const value = headers[key];
595
+
596
+ if (value === undefined) {
597
+ continue;
598
+ }
599
+
600
+ if (Array.isArray(value)) {
601
+ serializedHeaders[key] = value;
602
+ continue;
603
+ }
604
+
605
+ serializedHeaders[key] = value;
606
+ }
607
+
608
+ return serializedHeaders;
609
+ }
610
+
611
+ /**
612
+ * Reads request body once before worker execution.
613
+ * ! Body stream is consumable; do not read it elsewhere first.
614
+ */
615
+ async function readRequestBody(
616
+ req: http.IncomingMessage,
617
+ method: string,
618
+ maxBytes: number | undefined,
619
+ ): Promise<Uint8Array | undefined> {
620
+ if (method === 'GET' || method === 'HEAD') {
621
+ return undefined;
622
+ }
623
+
624
+ if (req.readableEnded) {
625
+ return undefined;
626
+ }
627
+
628
+ if (maxBytes !== undefined) {
629
+ const contentLengthHeader = req.headers['content-length'];
630
+ if (contentLengthHeader !== undefined) {
631
+ const declaredBytes = Number.parseInt(contentLengthHeader, 10);
632
+ if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
633
+ throw createRequestBodyTooLargeError(declaredBytes, maxBytes);
634
+ }
635
+ }
636
+ }
637
+
638
+ return new Promise<Uint8Array | undefined>((resolve, reject) => {
639
+ const chunks: Buffer[] = [];
640
+ let totalBytes = 0;
641
+ let settled = false;
642
+
643
+ const cleanup = (): void => {
644
+ req.off('data', onData);
645
+ req.off('end', onEnd);
646
+ req.off('error', onError);
647
+ req.off('aborted', onAborted);
648
+ };
649
+
650
+ const settle = (action: () => void): void => {
651
+ if (settled) {
652
+ return;
653
+ }
654
+
655
+ settled = true;
656
+ action();
657
+ };
658
+
659
+ const onData = (chunk: Buffer | string): void => {
660
+ const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
661
+ totalBytes += bufferChunk.byteLength;
662
+
663
+ if (maxBytes !== undefined && totalBytes > maxBytes) {
664
+ cleanup();
665
+ req.resume();
666
+ settle(() => {
667
+ reject(createRequestBodyTooLargeError(totalBytes, maxBytes));
668
+ });
669
+ return;
670
+ }
671
+
672
+ chunks.push(bufferChunk);
673
+ };
674
+
675
+ const onEnd = (): void => {
676
+ cleanup();
677
+
678
+ if (chunks.length === 0) {
679
+ settle(() => {
680
+ resolve(undefined);
681
+ });
682
+ return;
683
+ }
684
+
685
+ settle(() => {
686
+ resolve(Buffer.concat(chunks));
687
+ });
688
+ };
689
+
690
+ const onError = (error: Error): void => {
691
+ cleanup();
692
+ settle(() => {
693
+ reject(error);
694
+ });
695
+ };
696
+
697
+ const onAborted = (): void => {
698
+ cleanup();
699
+ settle(() => {
700
+ reject(new Error('request aborted while reading body'));
701
+ });
702
+ };
703
+
704
+ req.on('data', onData);
705
+ req.once('end', onEnd);
706
+ req.once('error', onError);
707
+ req.once('aborted', onAborted);
708
+ });
709
+ }
710
+
711
+ /**
712
+ * Applies serialized worker response back onto ServerResponse.
713
+ */
714
+ function applyWorkerResponse(res: http.ServerResponse, response: protocol.SerializedResponse): void {
715
+ res.statusCode = response.statusCode;
716
+
717
+ const headerKeys = Object.keys(response.headers);
718
+ for (let i = 0; i < headerKeys.length; i++) {
719
+ const key = headerKeys[i];
720
+ res.setHeader(key, response.headers[key]);
721
+ }
722
+
723
+ if (response.body === undefined || response.body.byteLength === 0) {
724
+ res.end();
725
+ return;
726
+ }
727
+
728
+ const body = Buffer.from(response.body.buffer, response.body.byteOffset, response.body.byteLength);
729
+ res.end(body);
730
+ }
731
+
732
+ /**
733
+ * @param dir Dynamic directory set in `FluxionOptions`
734
+ */
735
+ export function createFileRuntime(dir: string, options: FileRuntimeOptions = {}): FileRuntime {
736
+ /**
737
+ * Runtime logger.
738
+ */
739
+ const logger = options.logger ?? createLogger('one-line');
740
+
741
+ /**
742
+ * Main-thread view of loaded handler versions.
743
+ */
744
+ const handlerVersions = new Map<string, string>();
745
+
746
+ /**
747
+ * Database names declared by user options.
748
+ */
749
+ const databaseNames = normalizeDbSet(options.databaseNames);
750
+
751
+ /**
752
+ * Maximum request body bytes accepted by dynamic handlers.
753
+ */
754
+ const maxRequestBytes = resolveMaxRequestBytes(options.maxRequestBytes);
755
+
756
+ /**
757
+ * Static worker definitions resolved from strategy.
758
+ */
759
+ const workerDefinitions = resolveWorkerDefinitions(databaseNames, options.workerStrategy, options.workerOptions);
760
+
761
+ /**
762
+ * Worker pool bindings used for request routing.
763
+ */
764
+ const workerBindings: WorkerBinding[] = workerDefinitions.map((definition) => ({
765
+ id: definition.id,
766
+ dbSet: [...definition.dbSet],
767
+ isFallbackAllDb: definition.isFallbackAllDb,
768
+ pool: createHandlerWorkerPool({
769
+ meta: {
770
+ id: definition.id,
771
+ dbSet: definition.dbSet,
772
+ isFallbackAllDb: definition.isFallbackAllDb,
773
+ },
774
+ overrides: definition.overrides,
775
+ logger,
776
+ }),
777
+ }));
778
+
779
+ if (workerBindings.length === 0) {
780
+ throw new Error('No worker pools were created for runtime');
781
+ }
782
+
783
+ /**
784
+ * Writes load/reload logs for handlers.
785
+ */
786
+ const logHandlerLoad = (filePath: string, version: string, previousVersion?: string): void => {
787
+ const relativeFilePath = normalizeRelativePath(path.relative(dir, filePath));
788
+ const route = getRouteFromHandlerFile(relativeFilePath);
789
+
790
+ if (previousVersion === undefined) {
791
+ logger.write('INFO', 'HandlerLoaded', {
792
+ route,
793
+ file: relativeFilePath,
794
+ version,
795
+ });
796
+ return;
797
+ }
798
+
799
+ logger.write('INFO', 'HandlerReloaded', {
800
+ route,
801
+ file: relativeFilePath,
802
+ previousVersion,
803
+ version,
804
+ });
805
+ };
806
+
807
+ /**
808
+ * Selects target worker for handler execution.
809
+ */
810
+ const resolveExecutionWorker = async (_filePath: string, _version: string): Promise<WorkerBinding> => {
811
+ if (workerBindings.length === 1) {
812
+ return workerBindings[0];
813
+ }
814
+
815
+ return selectExecutionWorker(workerBindings);
816
+ };
817
+
818
+ /**
819
+ * Resolves the best matching handler for a route.
820
+ */
821
+ const resolveHandlerFile = async (segments: readonly string[]): Promise<ResolvedHandlerFile | undefined> => {
822
+ const candidates = buildHandlerCandidates(dir, segments);
823
+
824
+ for (const filePath of candidates) {
825
+ if (!isUnderDirectory(filePath, dir)) {
826
+ continue;
827
+ }
828
+
829
+ const version = await getFileVersion(filePath);
830
+
831
+ if (version !== undefined) {
832
+ return { filePath, version };
833
+ }
834
+ }
835
+
836
+ return undefined;
837
+ };
838
+
839
+ /**
840
+ * Executes matched handler inside worker.
841
+ */
842
+ const tryHandleHandler = async (
843
+ parsedPath: ParsedPath,
844
+ req: http.IncomingMessage,
845
+ res: http.ServerResponse,
846
+ normalized: NormalizedRequest,
847
+ ): Promise<HandlerResult> => {
848
+ if (parsedPath.pathname.endsWith('.mjs')) {
849
+ return HandlerResult.NotFound;
850
+ }
851
+
852
+ const resolved = await resolveHandlerFile(parsedPath.segments);
853
+
854
+ if (resolved === undefined) {
855
+ return HandlerResult.NotFound;
856
+ }
857
+
858
+ const worker = await resolveExecutionWorker(resolved.filePath, resolved.version);
859
+
860
+ const executeResult = await worker.pool.execute({
861
+ filePath: resolved.filePath,
862
+ version: resolved.version,
863
+ method: normalized.method,
864
+ url: req.url ?? `${normalized.url.pathname}${normalized.url.search}`,
865
+ headers: normalizeHeaders(req.headers),
866
+ body: await readRequestBody(req, normalized.method, maxRequestBytes),
867
+ ip: normalized.ip,
868
+ });
869
+
870
+ applyWorkerResponse(res, executeResult.response);
871
+
872
+ const previousVersion = handlerVersions.get(resolved.filePath);
873
+ if (previousVersion !== resolved.version) {
874
+ handlerVersions.set(resolved.filePath, resolved.version);
875
+ logHandlerLoad(resolved.filePath, resolved.version, previousVersion);
876
+ }
877
+
878
+ return HandlerResult.Handled;
879
+ };
880
+
881
+ /**
882
+ * Serves static files when no dynamic handler is matched.
883
+ */
884
+ const tryHandleStatic = async (
885
+ parsedPath: ParsedPath,
886
+ _req: http.IncomingMessage,
887
+ res: http.ServerResponse,
888
+ normalized: NormalizedRequest,
889
+ ): Promise<HandlerResult> => {
890
+ const method = normalized.method;
891
+
892
+ if (method !== 'GET' && method !== 'HEAD') {
893
+ return HandlerResult.NotFound;
894
+ }
895
+
896
+ if (parsedPath.segments.length === 0) {
897
+ return HandlerResult.NotFound;
898
+ }
899
+
900
+ const filePath = path.resolve(dir, ...parsedPath.segments);
901
+
902
+ if (!isUnderDirectory(filePath, dir)) {
903
+ return HandlerResult.NotFound;
904
+ }
905
+
906
+ if (path.extname(filePath).toLowerCase() === '.mjs') {
907
+ return HandlerResult.NotFound;
908
+ }
909
+
910
+ try {
911
+ const stat = await fs.promises.stat(filePath);
912
+
913
+ if (!stat.isFile()) {
914
+ return HandlerResult.NotFound;
915
+ }
916
+
917
+ await streamStaticFile(filePath, stat, method, res);
918
+ return HandlerResult.Handled;
919
+ } catch (error) {
920
+ const code = (error as NodeJS.ErrnoException).code;
921
+
922
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
923
+ return HandlerResult.NotFound;
924
+ }
925
+
926
+ throw error;
927
+ }
928
+ };
929
+
930
+ /**
931
+ * Scans dynamic directory and builds route snapshot.
932
+ */
933
+ const getRouteSnapshot = async (): Promise<FileRouteSnapshot> => {
934
+ const handlerByRoute = new Map<string, { entry: HandlerRouteEntry; priority: number }>();
935
+ const staticFiles: StaticRouteEntry[] = [];
936
+
937
+ const readEntries = async (directory: string): Promise<fs.Dirent[]> => {
938
+ try {
939
+ return await fs.promises.readdir(directory, { withFileTypes: true });
940
+ } catch (error) {
941
+ const code = (error as NodeJS.ErrnoException).code;
942
+
943
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
944
+ return [];
945
+ }
946
+
947
+ throw error;
948
+ }
949
+ };
950
+
951
+ const walk = async (directory: string, relativeDirectory: string): Promise<void> => {
952
+ const entries = await readEntries(directory);
953
+
954
+ for (let i = 0; i < entries.length; i++) {
955
+ const entry = entries[i];
956
+ if (entry.isDirectory()) {
957
+ if (isIgnoredSegment(entry.name)) {
958
+ continue;
959
+ }
960
+
961
+ const childDirectory = path.join(directory, entry.name);
962
+ const childRelativeDirectory = path.join(relativeDirectory, entry.name);
963
+ await walk(childDirectory, childRelativeDirectory);
964
+ continue;
965
+ }
966
+
967
+ if (!entry.isFile()) {
968
+ continue;
969
+ }
970
+
971
+ const absolutePath = path.join(directory, entry.name);
972
+ const relativePath = path.join(relativeDirectory, entry.name);
973
+ const version = await getFileVersion(absolutePath);
974
+
975
+ if (version === undefined) {
976
+ continue;
977
+ }
978
+
979
+ if (entry.name.endsWith('.mjs')) {
980
+ const route = getRouteFromHandlerFile(relativePath);
981
+ const entryItem: HandlerRouteEntry = {
982
+ route,
983
+ file: normalizeRelativePath(relativePath),
984
+ version,
985
+ };
986
+ const priority = entry.name === 'index.mjs' ? 0 : 1;
987
+ const existing = handlerByRoute.get(route);
988
+
989
+ if (existing === undefined || priority < existing.priority) {
990
+ handlerByRoute.set(route, { entry: entryItem, priority });
991
+ }
992
+
993
+ continue;
994
+ }
995
+
996
+ staticFiles.push({
997
+ route: toPublicRoute(relativePath),
998
+ file: normalizeRelativePath(relativePath),
999
+ version,
1000
+ });
1001
+ }
1002
+ };
1003
+
1004
+ await walk(dir, '');
1005
+
1006
+ const handlers = Array.from(handlerByRoute.values())
1007
+ .map((item) => item.entry)
1008
+ .sort((left, right) => left.route.localeCompare(right.route));
1009
+
1010
+ staticFiles.sort((left, right) => left.route.localeCompare(right.route));
1011
+
1012
+ return {
1013
+ handlers,
1014
+ staticFiles,
1015
+ };
1016
+ };
1017
+
1018
+ return {
1019
+ /**
1020
+ * Clears version cache and asks all worker pools to rotate.
1021
+ */
1022
+ clearCache() {
1023
+ handlerVersions.clear();
1024
+
1025
+ for (let i = 0; i < workerBindings.length; i++) {
1026
+ void workerBindings[i].pool.clearCache();
1027
+ }
1028
+ },
1029
+ /**
1030
+ * Closes worker pools.
1031
+ */
1032
+ async close() {
1033
+ await Promise.all(workerBindings.map((worker) => worker.pool.close()));
1034
+ },
1035
+ /**
1036
+ * Returns worker diagnostics for meta api.
1037
+ */
1038
+ getWorkerSnapshot() {
1039
+ return {
1040
+ dir,
1041
+ workers: workerBindings.map((worker) => worker.pool.getSnapshot()),
1042
+ };
1043
+ },
1044
+ getRouteSnapshot,
1045
+ /**
1046
+ * Runtime entrypoint for request handling.
1047
+ */
1048
+ async handleRequest(
1049
+ req: http.IncomingMessage,
1050
+ res: http.ServerResponse,
1051
+ normalized?: NormalizedRequest,
1052
+ ): Promise<HandlerResult> {
1053
+ const resolvedNormalized = normalizeRequest(req, normalized);
1054
+ if (resolvedNormalized === undefined) {
1055
+ return HandlerResult.NotFound;
1056
+ }
1057
+
1058
+ const parsedPath = parseRequestPath(resolvedNormalized.url);
1059
+ if (parsedPath === undefined) {
1060
+ return HandlerResult.NotFound;
1061
+ }
1062
+
1063
+ const handlerResult = await tryHandleHandler(parsedPath, req, res, resolvedNormalized);
1064
+ if (handlerResult === HandlerResult.Handled) {
1065
+ return HandlerResult.Handled;
1066
+ }
1067
+
1068
+ return tryHandleStatic(parsedPath, req, res, resolvedNormalized);
1069
+ },
1070
+ };
1071
+ }