express-file-cluster 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/index.cjs +98 -0
- package/dist/auth/index.cjs.map +1 -0
- package/dist/auth/index.d.cts +16 -0
- package/dist/auth/index.d.ts +16 -0
- package/dist/auth/index.js +59 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/cli/index.cjs +380 -0
- package/dist/cli/index.cjs.map +1 -0
- package/dist/cli/index.d.cts +1 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +357 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.cjs +541 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +23 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +498 -0
- package/dist/index.js.map +1 -0
- package/dist/tasks/index.cjs +70 -0
- package/dist/tasks/index.cjs.map +1 -0
- package/dist/tasks/index.d.cts +16 -0
- package/dist/tasks/index.d.ts +16 -0
- package/dist/tasks/index.js +41 -0
- package/dist/tasks/index.js.map +1 -0
- package/dist/types-DNdkDUPX.d.cts +70 -0
- package/dist/types-DNdkDUPX.d.ts +70 -0
- package/package.json +69 -0
- package/src/auth/index.ts +71 -0
- package/src/cli/commands/build.ts +43 -0
- package/src/cli/commands/diagnostics.ts +124 -0
- package/src/cli/commands/generate.ts +91 -0
- package/src/cli/commands/run.ts +28 -0
- package/src/cli/commands/start.ts +74 -0
- package/src/cli/index.ts +22 -0
- package/src/cluster/index.ts +31 -0
- package/src/compose.ts +26 -0
- package/src/db/index.ts +31 -0
- package/src/db/model.ts +95 -0
- package/src/db/mongo.ts +22 -0
- package/src/errors.ts +10 -0
- package/src/index.ts +127 -0
- package/src/router/mount.test.ts +75 -0
- package/src/router/mount.ts +49 -0
- package/src/router/scan.test.ts +23 -0
- package/src/router/scan.ts +55 -0
- package/src/tasks/bullmq-backend.ts +76 -0
- package/src/tasks/index.ts +51 -0
- package/src/tasks/scanner.test.ts +58 -0
- package/src/tasks/scanner.ts +30 -0
- package/src/tasks/thread-runner.ts +40 -0
- package/src/types.ts +68 -0
- package/tsconfig.json +9 -0
- package/tsup.config.ts +26 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
|
|
7
|
+
export function startCommand(): Command {
|
|
8
|
+
const cmd = new Command('start');
|
|
9
|
+
|
|
10
|
+
cmd
|
|
11
|
+
.argument('<mode>', 'dev | prod')
|
|
12
|
+
.description('Start the EFC server')
|
|
13
|
+
.action((mode: string) => {
|
|
14
|
+
if (mode === 'dev') {
|
|
15
|
+
startDev();
|
|
16
|
+
} else if (mode === 'prod') {
|
|
17
|
+
startProd();
|
|
18
|
+
} else {
|
|
19
|
+
console.error(chalk.red(`Unknown mode: ${mode}. Use 'dev' or 'prod'.`));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return cmd;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function startDev(): void {
|
|
28
|
+
const entry = resolveEntry();
|
|
29
|
+
if (!entry) {
|
|
30
|
+
console.error(chalk.red('[EFC] Could not find entry point. Expected src/index.ts or index.ts'));
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
console.log(chalk.cyan('[EFC] Starting development server…'));
|
|
35
|
+
console.log(chalk.dim(` Entry: ${entry}`));
|
|
36
|
+
|
|
37
|
+
const child = spawn(
|
|
38
|
+
'node',
|
|
39
|
+
['--import', 'tsx/esm', '--watch', entry],
|
|
40
|
+
{ stdio: 'inherit', env: { ...process.env, NODE_ENV: 'development' } },
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function startProd(): void {
|
|
47
|
+
const cwd = process.cwd();
|
|
48
|
+
const distEntry = path.join(cwd, 'dist', 'index.js');
|
|
49
|
+
|
|
50
|
+
if (!fs.existsSync(distEntry)) {
|
|
51
|
+
console.error(chalk.red('[EFC] dist/index.js not found. Run `efc build prod` first.'));
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log(chalk.cyan('[EFC] Starting production server…'));
|
|
56
|
+
|
|
57
|
+
const child = spawn('node', [distEntry], {
|
|
58
|
+
stdio: 'inherit',
|
|
59
|
+
env: { ...process.env, NODE_ENV: 'production' },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveEntry(): string | null {
|
|
66
|
+
const cwd = process.cwd();
|
|
67
|
+
const candidates = [
|
|
68
|
+
path.join(cwd, 'src', 'index.ts'),
|
|
69
|
+
path.join(cwd, 'index.ts'),
|
|
70
|
+
path.join(cwd, 'src', 'index.js'),
|
|
71
|
+
path.join(cwd, 'index.js'),
|
|
72
|
+
];
|
|
73
|
+
return candidates.find((f) => fs.existsSync(f)) ?? null;
|
|
74
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { startCommand } from './commands/start.js';
|
|
4
|
+
import { buildCommand } from './commands/build.js';
|
|
5
|
+
import { runCommand } from './commands/run.js';
|
|
6
|
+
import { generateCommand } from './commands/generate.js';
|
|
7
|
+
import { diagnosticsCommands } from './commands/diagnostics.js';
|
|
8
|
+
|
|
9
|
+
const program = new Command('efc')
|
|
10
|
+
.description('Express File Cluster CLI')
|
|
11
|
+
.version('0.1.0');
|
|
12
|
+
|
|
13
|
+
program.addCommand(startCommand());
|
|
14
|
+
program.addCommand(buildCommand());
|
|
15
|
+
program.addCommand(runCommand());
|
|
16
|
+
program.addCommand(generateCommand());
|
|
17
|
+
|
|
18
|
+
for (const cmd of diagnosticsCommands()) {
|
|
19
|
+
program.addCommand(cmd);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
program.parse(process.argv);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import cluster from 'node:cluster';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
|
|
4
|
+
interface ClusterOptions {
|
|
5
|
+
workers?: number;
|
|
6
|
+
onWorkerReady?: ((id: number) => void) | undefined;
|
|
7
|
+
onWorkerCrash?: ((id: number, code: number) => void) | undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function runMaster(options: ClusterOptions = {}): void {
|
|
11
|
+
const count = options.workers ?? os.cpus().length;
|
|
12
|
+
|
|
13
|
+
console.log(`[EFC] Primary ${process.pid} starting ${count} workers`);
|
|
14
|
+
|
|
15
|
+
for (let i = 0; i < count; i++) {
|
|
16
|
+
cluster.fork();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
cluster.on('online', (worker) => {
|
|
20
|
+
options.onWorkerReady?.(worker.id);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
cluster.on('exit', (worker, code, signal) => {
|
|
24
|
+
const exitCode = code ?? (signal ? -1 : 0);
|
|
25
|
+
console.warn(`[EFC] Worker ${worker.id} exited (code=${exitCode}), respawning…`);
|
|
26
|
+
options.onWorkerCrash?.(worker.id, exitCode);
|
|
27
|
+
cluster.fork();
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export { cluster };
|
package/src/compose.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { RequestHandler } from 'express';
|
|
2
|
+
|
|
3
|
+
export function compose(...handlers: RequestHandler[]): RequestHandler {
|
|
4
|
+
return (req, res, next) => {
|
|
5
|
+
let index = 0;
|
|
6
|
+
|
|
7
|
+
function dispatch(i: number): void {
|
|
8
|
+
if (i >= handlers.length) {
|
|
9
|
+
next();
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const handler = handlers[i];
|
|
13
|
+
if (!handler) {
|
|
14
|
+
next();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
Promise.resolve(handler(req, res, () => dispatch(i + 1))).catch(next);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
next(err);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
dispatch(index);
|
|
25
|
+
};
|
|
26
|
+
}
|
package/src/db/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Thread-local DB client populated during Pre-Flight.
|
|
2
|
+
// The Proxy ensures callers import `db` once and never null-check it —
|
|
3
|
+
// it throws with a helpful message if accessed before Pre-Flight completes.
|
|
4
|
+
|
|
5
|
+
type AnyClient = Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
let _client: AnyClient | null = null;
|
|
8
|
+
|
|
9
|
+
export function setDbClient(client: AnyClient): void {
|
|
10
|
+
_client = client;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getDbClient(): AnyClient {
|
|
14
|
+
if (!_client) {
|
|
15
|
+
throw new Error('[EFC] db not ready — accessed before Pre-Flight completed');
|
|
16
|
+
}
|
|
17
|
+
return _client;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const db: AnyClient = new Proxy({} as AnyClient, {
|
|
21
|
+
get(_target, prop: string) {
|
|
22
|
+
return getDbClient()[prop];
|
|
23
|
+
},
|
|
24
|
+
set(_target, prop: string, value: unknown) {
|
|
25
|
+
getDbClient()[prop] = value;
|
|
26
|
+
return true;
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export { connectMongo } from './mongo.js';
|
|
31
|
+
export { defineModel } from './model.js';
|
package/src/db/model.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { ModelSchema, ModelCRUD } from '../types.js';
|
|
2
|
+
|
|
3
|
+
type AnyRecord = Record<string, unknown> & { id: string };
|
|
4
|
+
|
|
5
|
+
function normalise(doc: Record<string, unknown>): AnyRecord {
|
|
6
|
+
const id = doc['_id'] ? String(doc['_id']) : '';
|
|
7
|
+
return { ...doc, id } as AnyRecord;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function getModel(name: string, schemaObj: Record<string, unknown>): Promise<import('mongoose').Model<Record<string, unknown>>> {
|
|
11
|
+
let mg: typeof import('mongoose');
|
|
12
|
+
try {
|
|
13
|
+
mg = await import('mongoose');
|
|
14
|
+
} catch {
|
|
15
|
+
throw new Error('[EFC] mongoose is not installed. Run: npm install mongoose');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (mg.models[name]) return mg.models[name] as import('mongoose').Model<Record<string, unknown>>;
|
|
19
|
+
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
|
+
const schema = new mg.Schema(schemaObj as any, { timestamps: true });
|
|
22
|
+
return mg.model<Record<string, unknown>>(name, schema);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildMongooseSchema(schema: ModelSchema): Record<string, unknown> {
|
|
26
|
+
const out: Record<string, unknown> = {};
|
|
27
|
+
for (const [key, def] of Object.entries(schema)) {
|
|
28
|
+
const entry: Record<string, unknown> = {};
|
|
29
|
+
switch (def.type) {
|
|
30
|
+
case 'string': entry['type'] = String; break;
|
|
31
|
+
case 'number': entry['type'] = Number; break;
|
|
32
|
+
case 'boolean': entry['type'] = Boolean; break;
|
|
33
|
+
case 'date': entry['type'] = Date; break;
|
|
34
|
+
case 'object': entry['type'] = Object; break;
|
|
35
|
+
case 'array': entry['type'] = Array; break;
|
|
36
|
+
}
|
|
37
|
+
if (def.required !== undefined) entry['required'] = def.required;
|
|
38
|
+
if (def.unique !== undefined) entry['unique'] = def.unique;
|
|
39
|
+
if (def.default !== undefined) entry['default'] = def.default;
|
|
40
|
+
out[key] = entry;
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function defineModel<T extends Record<string, unknown>>(
|
|
46
|
+
name: string,
|
|
47
|
+
schema: ModelSchema,
|
|
48
|
+
): ModelCRUD<T> {
|
|
49
|
+
const mongooseSchema = buildMongooseSchema(schema);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
async find(filter = {}) {
|
|
53
|
+
const M = await getModel(name, mongooseSchema);
|
|
54
|
+
const docs = await M.find(filter as Record<string, unknown>).lean();
|
|
55
|
+
return (docs as Record<string, unknown>[]).map(normalise) as (T & { id: string })[];
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async findById(id) {
|
|
59
|
+
const M = await getModel(name, mongooseSchema);
|
|
60
|
+
const doc = await M.findById(id).lean();
|
|
61
|
+
if (!doc) return null;
|
|
62
|
+
return normalise(doc as Record<string, unknown>) as T & { id: string };
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
async findOne(filter) {
|
|
66
|
+
const M = await getModel(name, mongooseSchema);
|
|
67
|
+
const doc = await M.findOne(filter as Record<string, unknown>).lean();
|
|
68
|
+
if (!doc) return null;
|
|
69
|
+
return normalise(doc as Record<string, unknown>) as T & { id: string };
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
async create(data) {
|
|
73
|
+
const M = await getModel(name, mongooseSchema);
|
|
74
|
+
const doc = await M.create(data);
|
|
75
|
+
return normalise(doc.toObject() as Record<string, unknown>) as T & { id: string };
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
async update(id, data) {
|
|
79
|
+
const M = await getModel(name, mongooseSchema);
|
|
80
|
+
const doc = await M.findByIdAndUpdate(id, data, { new: true }).lean();
|
|
81
|
+
if (!doc) return null;
|
|
82
|
+
return normalise(doc as Record<string, unknown>) as T & { id: string };
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
async delete(id) {
|
|
86
|
+
const M = await getModel(name, mongooseSchema);
|
|
87
|
+
await M.findByIdAndDelete(id);
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
async count(filter = {}) {
|
|
91
|
+
const M = await getModel(name, mongooseSchema);
|
|
92
|
+
return M.countDocuments(filter as Record<string, unknown>);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
package/src/db/mongo.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Wraps mongoose connection for Pre-Flight. Throws clearly if mongoose isn't installed.
|
|
2
|
+
|
|
3
|
+
let mongoose: typeof import('mongoose');
|
|
4
|
+
|
|
5
|
+
async function loadMongoose(): Promise<typeof import('mongoose')> {
|
|
6
|
+
if (mongoose) return mongoose;
|
|
7
|
+
try {
|
|
8
|
+
mongoose = await import('mongoose');
|
|
9
|
+
return mongoose;
|
|
10
|
+
} catch {
|
|
11
|
+
throw new Error(
|
|
12
|
+
'[EFC] mongoose is not installed. Run: npm install mongoose',
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function connectMongo(url: string): Promise<import('mongoose').Connection> {
|
|
18
|
+
const mg = await loadMongoose();
|
|
19
|
+
await mg.connect(url);
|
|
20
|
+
console.log('[EFC] MongoDB connected');
|
|
21
|
+
return mg.connection;
|
|
22
|
+
}
|
package/src/errors.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import cookieParser from 'cookie-parser';
|
|
3
|
+
import cluster from 'node:cluster';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import type { EFCConfig } from './types.js';
|
|
6
|
+
import { scanDir } from './router/scan.js';
|
|
7
|
+
import { mountRoutes } from './router/mount.js';
|
|
8
|
+
import { runMaster } from './cluster/index.js';
|
|
9
|
+
import { configureAuth } from './auth/index.js';
|
|
10
|
+
import { HttpError } from './errors.js';
|
|
11
|
+
import { connectMongo } from './db/mongo.js';
|
|
12
|
+
import { setDbClient } from './db/index.js';
|
|
13
|
+
import { scanTasks } from './tasks/scanner.js';
|
|
14
|
+
import { initBullMQ } from './tasks/bullmq-backend.js';
|
|
15
|
+
|
|
16
|
+
export async function ignite(config: EFCConfig): Promise<void> {
|
|
17
|
+
const {
|
|
18
|
+
port = 3000,
|
|
19
|
+
cluster: clusterEnabled = true,
|
|
20
|
+
workers,
|
|
21
|
+
apiDir,
|
|
22
|
+
jwtSecret,
|
|
23
|
+
authStrategy = 'http-only',
|
|
24
|
+
globalMiddlewares = [],
|
|
25
|
+
onWorkerReady,
|
|
26
|
+
onWorkerCrash,
|
|
27
|
+
onError,
|
|
28
|
+
} = config;
|
|
29
|
+
|
|
30
|
+
if (clusterEnabled && cluster.isPrimary) {
|
|
31
|
+
runMaster({
|
|
32
|
+
workers: workers ?? os.cpus().length,
|
|
33
|
+
...(onWorkerReady !== undefined && { onWorkerReady }),
|
|
34
|
+
...(onWorkerCrash !== undefined && { onWorkerCrash }),
|
|
35
|
+
});
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const app = express();
|
|
40
|
+
|
|
41
|
+
app.use(express.json());
|
|
42
|
+
app.use(express.urlencoded({ extended: true }));
|
|
43
|
+
app.use(cookieParser());
|
|
44
|
+
|
|
45
|
+
for (const mw of globalMiddlewares) {
|
|
46
|
+
app.use(mw);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Pre-Flight step 1: Connect database
|
|
50
|
+
if (config.database === 'mongodb' && config.databaseUrl) {
|
|
51
|
+
const conn = await connectMongo(config.databaseUrl);
|
|
52
|
+
setDbClient(conn as unknown as Record<string, unknown>);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Pre-Flight step 2: Configure auth
|
|
56
|
+
if (jwtSecret) {
|
|
57
|
+
const cookieDomain = process.env['COOKIE_DOMAIN'];
|
|
58
|
+
configureAuth({
|
|
59
|
+
secret: jwtSecret,
|
|
60
|
+
strategy: authStrategy,
|
|
61
|
+
expiresIn: process.env['JWT_EXPIRES_IN'] ?? '7d',
|
|
62
|
+
...(cookieDomain !== undefined && { cookieDomain }),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Pre-Flight step 3: Scan and register tasks
|
|
67
|
+
if (config.tasksDir) {
|
|
68
|
+
await scanTasks(config.tasksDir);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Pre-Flight step 4: Start task queue backend
|
|
72
|
+
if (config.tasks) {
|
|
73
|
+
if (config.tasks.backend === 'bullmq') {
|
|
74
|
+
await initBullMQ({
|
|
75
|
+
redisUrl: config.tasks.redisUrl ?? 'redis://localhost:6379',
|
|
76
|
+
concurrency: config.tasks.concurrency ?? 5,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Pre-Flight step 5: Scan routes and mount
|
|
82
|
+
const routes = scanDir(apiDir);
|
|
83
|
+
await mountRoutes(app, routes);
|
|
84
|
+
|
|
85
|
+
if (onError) {
|
|
86
|
+
app.use(onError);
|
|
87
|
+
} else {
|
|
88
|
+
app.use(
|
|
89
|
+
(
|
|
90
|
+
err: unknown,
|
|
91
|
+
_req: express.Request,
|
|
92
|
+
res: express.Response,
|
|
93
|
+
_next: express.NextFunction,
|
|
94
|
+
) => {
|
|
95
|
+
if (err instanceof HttpError) {
|
|
96
|
+
res.status(err.statusCode).json({ error: err.message, statusCode: err.statusCode });
|
|
97
|
+
} else {
|
|
98
|
+
console.error('[EFC] Unhandled error:', err);
|
|
99
|
+
res.status(500).json({ error: 'Internal Server Error', statusCode: 500 });
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await new Promise<void>((resolve) => {
|
|
106
|
+
app.listen(port, () => {
|
|
107
|
+
const wid = (cluster.worker as { id: number } | undefined)?.id ?? 'primary';
|
|
108
|
+
console.log(`[EFC] Worker ${wid} listening on :${port}`);
|
|
109
|
+
resolve();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export { HttpError } from './errors.js';
|
|
115
|
+
export { compose } from './compose.js';
|
|
116
|
+
export { db, setDbClient, getDbClient, defineModel } from './db/index.js';
|
|
117
|
+
export { scanDir } from './router/scan.js';
|
|
118
|
+
export type {
|
|
119
|
+
EFCConfig,
|
|
120
|
+
RouteEntry,
|
|
121
|
+
TaskOptions,
|
|
122
|
+
TaskDefinition,
|
|
123
|
+
DatabaseEngine,
|
|
124
|
+
AuthStrategy,
|
|
125
|
+
ModelSchema,
|
|
126
|
+
ModelCRUD,
|
|
127
|
+
} from './types.js';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { mountRoutes } from './mount.js';
|
|
7
|
+
import type { RouteEntry } from '../types.js';
|
|
8
|
+
|
|
9
|
+
function makeApp() {
|
|
10
|
+
return express();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('mountRoutes', () => {
|
|
14
|
+
let tmpDir: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'efc-mount-test-'));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('mounts a GET handler from a route module', async () => {
|
|
25
|
+
const file = path.join(tmpDir, 'health.js');
|
|
26
|
+
fs.writeFileSync(
|
|
27
|
+
file,
|
|
28
|
+
`export const GET = async (req, res) => res.json({ ok: true });`,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const app = makeApp();
|
|
32
|
+
const routes: RouteEntry[] = [{ urlPath: '/health', filePath: file, params: [] }];
|
|
33
|
+
|
|
34
|
+
await mountRoutes(app, routes);
|
|
35
|
+
|
|
36
|
+
const stack = app._router?.stack ?? [];
|
|
37
|
+
const hasRoute = stack.some(
|
|
38
|
+
(layer: { route?: { path: string; methods: Record<string, boolean> } }) =>
|
|
39
|
+
layer.route?.path === '/health' && layer.route?.methods?.get,
|
|
40
|
+
);
|
|
41
|
+
expect(hasRoute).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('mounts multiple methods from a single route file', async () => {
|
|
45
|
+
const file = path.join(tmpDir, 'users.js');
|
|
46
|
+
fs.writeFileSync(
|
|
47
|
+
file,
|
|
48
|
+
`export const GET = async (req, res) => res.json([]);
|
|
49
|
+
export const POST = async (req, res) => res.status(201).json({});`,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const app = makeApp();
|
|
53
|
+
const routes: RouteEntry[] = [{ urlPath: '/users', filePath: file, params: [] }];
|
|
54
|
+
|
|
55
|
+
await mountRoutes(app, routes);
|
|
56
|
+
|
|
57
|
+
// Express registers one layer per method; collect all /users layers
|
|
58
|
+
type Layer = { route?: { path: string; methods: Record<string, boolean> } };
|
|
59
|
+
const stack: Layer[] = app._router?.stack ?? [];
|
|
60
|
+
const layers = stack.filter((l) => l.route?.path === '/users');
|
|
61
|
+
const methods = layers.flatMap((l) => Object.keys(l.route?.methods ?? {}));
|
|
62
|
+
expect(methods).toContain('get');
|
|
63
|
+
expect(methods).toContain('post');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('skips non-function exports silently', async () => {
|
|
67
|
+
const file = path.join(tmpDir, 'noop.js');
|
|
68
|
+
fs.writeFileSync(file, `export const middlewares = [];`);
|
|
69
|
+
|
|
70
|
+
const app = makeApp();
|
|
71
|
+
const routes: RouteEntry[] = [{ urlPath: '/noop', filePath: file, params: [] }];
|
|
72
|
+
|
|
73
|
+
await expect(mountRoutes(app, routes)).resolves.toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Application, RequestHandler } from 'express';
|
|
2
|
+
import type { RouteEntry } from '../types.js';
|
|
3
|
+
|
|
4
|
+
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] as const;
|
|
5
|
+
type HttpMethod = (typeof HTTP_METHODS)[number];
|
|
6
|
+
|
|
7
|
+
function asyncWrap(handler: RequestHandler): RequestHandler {
|
|
8
|
+
return (req, res, next) => {
|
|
9
|
+
Promise.resolve(handler(req, res, next)).catch(next);
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function mountRoutes(app: Application, routes: RouteEntry[]): Promise<void> {
|
|
14
|
+
for (const route of routes) {
|
|
15
|
+
const mod = (await import(route.filePath)) as Record<string, unknown>;
|
|
16
|
+
const routeMiddlewares: RequestHandler[] = Array.isArray(mod['middlewares'])
|
|
17
|
+
? (mod['middlewares'] as RequestHandler[])
|
|
18
|
+
: [];
|
|
19
|
+
|
|
20
|
+
const implemented: HttpMethod[] = [];
|
|
21
|
+
const unimplemented: HttpMethod[] = [];
|
|
22
|
+
|
|
23
|
+
for (const method of HTTP_METHODS) {
|
|
24
|
+
if (typeof mod[method] === 'function') {
|
|
25
|
+
implemented.push(method);
|
|
26
|
+
app[method.toLowerCase() as Lowercase<HttpMethod>](
|
|
27
|
+
route.urlPath,
|
|
28
|
+
...routeMiddlewares,
|
|
29
|
+
asyncWrap(mod[method] as RequestHandler),
|
|
30
|
+
);
|
|
31
|
+
} else {
|
|
32
|
+
unimplemented.push(method);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (implemented.length > 0 && unimplemented.length > 0) {
|
|
37
|
+
const allowHeader = implemented.join(', ');
|
|
38
|
+
app.all(route.urlPath, (req, res, next) => {
|
|
39
|
+
if ((unimplemented as string[]).includes(req.method)) {
|
|
40
|
+
res.set('Allow', allowHeader).status(405).json({ error: 'Method Not Allowed' });
|
|
41
|
+
} else {
|
|
42
|
+
next();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export { asyncWrap };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { filePathToUrlPath } from './scan.js';
|
|
3
|
+
|
|
4
|
+
describe('filePathToUrlPath', () => {
|
|
5
|
+
it('maps static files to their path', () => {
|
|
6
|
+
expect(filePathToUrlPath('/health.ts')).toBe('/health');
|
|
7
|
+
expect(filePathToUrlPath('/users/profile.ts')).toBe('/users/profile');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('maps index files to parent path', () => {
|
|
11
|
+
expect(filePathToUrlPath('/index.ts')).toBe('/');
|
|
12
|
+
expect(filePathToUrlPath('/users/index.ts')).toBe('/users');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('maps dynamic segments', () => {
|
|
16
|
+
expect(filePathToUrlPath('/users/[id].ts')).toBe('/users/:id');
|
|
17
|
+
expect(filePathToUrlPath('/posts/[slug]/comments.ts')).toBe('/posts/:slug/comments');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('handles nested dynamic', () => {
|
|
21
|
+
expect(filePathToUrlPath('/a/[b]/c/[d].ts')).toBe('/a/:b/c/:d');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { RouteEntry } from '../types.js';
|
|
4
|
+
|
|
5
|
+
const ROUTE_FILE_RE = /\.(ts|js|mts|mjs|cts|cjs)$/;
|
|
6
|
+
const DYNAMIC_SEGMENT_RE = /\[([^\]]+)\]/g;
|
|
7
|
+
|
|
8
|
+
function filePathToUrlPath(relativePath: string): string {
|
|
9
|
+
let p = relativePath.replace(ROUTE_FILE_RE, '');
|
|
10
|
+
// index files map to parent path
|
|
11
|
+
p = p.replace(/\/index$/, '');
|
|
12
|
+
// [param] → :param
|
|
13
|
+
p = p.replace(DYNAMIC_SEGMENT_RE, ':$1');
|
|
14
|
+
return p === '' ? '/' : p.startsWith('/') ? p : `/${p}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function extractParams(relativePath: string): string[] {
|
|
18
|
+
const params: string[] = [];
|
|
19
|
+
let match: RegExpExecArray | null;
|
|
20
|
+
const re = new RegExp(DYNAMIC_SEGMENT_RE.source, 'g');
|
|
21
|
+
while ((match = re.exec(relativePath)) !== null) {
|
|
22
|
+
if (match[1]) params.push(match[1]);
|
|
23
|
+
}
|
|
24
|
+
return params;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function scanDir(dir: string, base: string = dir): RouteEntry[] {
|
|
28
|
+
if (!fs.existsSync(dir)) return [];
|
|
29
|
+
|
|
30
|
+
const entries: RouteEntry[] = [];
|
|
31
|
+
const items = fs.readdirSync(dir, { withFileTypes: true });
|
|
32
|
+
|
|
33
|
+
for (const item of items) {
|
|
34
|
+
const fullPath = path.join(dir, item.name);
|
|
35
|
+
if (item.isDirectory()) {
|
|
36
|
+
entries.push(...scanDir(fullPath, base));
|
|
37
|
+
} else if (ROUTE_FILE_RE.test(item.name)) {
|
|
38
|
+
const relative = '/' + path.relative(base, fullPath).replace(/\\/g, '/');
|
|
39
|
+
entries.push({
|
|
40
|
+
urlPath: filePathToUrlPath(relative),
|
|
41
|
+
filePath: fullPath,
|
|
42
|
+
params: extractParams(relative),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Sort: static routes before dynamic ones at each segment level
|
|
48
|
+
return entries.sort((a, b) => {
|
|
49
|
+
const aDynamic = a.urlPath.includes(':') ? 1 : 0;
|
|
50
|
+
const bDynamic = b.urlPath.includes(':') ? 1 : 0;
|
|
51
|
+
return aDynamic - bDynamic || a.urlPath.localeCompare(b.urlPath);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export { filePathToUrlPath };
|