express-file-cluster 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/dist/cli/index.cjs +15 -13
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +15 -13
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +59 -25
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +58 -25
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
- package/src/cli/commands/start.ts +18 -15
- package/src/ignite.test.ts +196 -0
- package/src/index.ts +58 -18
|
@@ -24,14 +24,20 @@ export function startCommand(): Command {
|
|
|
24
24
|
return cmd;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
function
|
|
28
|
-
const env: NodeJS.ProcessEnv = { ...process.env, NODE_ENV: 'development' };
|
|
27
|
+
function parseDotenv(cwd: string): Record<string, string> {
|
|
29
28
|
const envFile = path.join(cwd, '.env');
|
|
30
|
-
if (fs.existsSync(envFile)) {
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
if (!fs.existsSync(envFile)) return {};
|
|
30
|
+
const vars: Record<string, string> = {};
|
|
31
|
+
for (const line of fs.readFileSync(envFile, 'utf8').split('\n')) {
|
|
32
|
+
const trimmed = line.trim();
|
|
33
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
34
|
+
const eq = trimmed.indexOf('=');
|
|
35
|
+
if (eq === -1) continue;
|
|
36
|
+
const key = trimmed.slice(0, eq).trim();
|
|
37
|
+
const raw = trimmed.slice(eq + 1).trim();
|
|
38
|
+
vars[key] = raw.replace(/^(['"])(.*)\1$/, '$2');
|
|
33
39
|
}
|
|
34
|
-
return
|
|
40
|
+
return vars;
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
function startDev(): void {
|
|
@@ -48,11 +54,10 @@ function startDev(): void {
|
|
|
48
54
|
const localTsx = path.join(cwd, 'node_modules', '.bin', 'tsx');
|
|
49
55
|
const tsx = fs.existsSync(localTsx) ? localTsx : 'tsx';
|
|
50
56
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
env: buildDevEnv(cwd),
|
|
54
|
-
});
|
|
57
|
+
// .env values are base; existing process.env takes precedence (same as dotenv default)
|
|
58
|
+
const env: NodeJS.ProcessEnv = { ...parseDotenv(cwd), ...process.env, NODE_ENV: 'development' };
|
|
55
59
|
|
|
60
|
+
const child = spawn(tsx, ['watch', entry], { stdio: 'inherit', env });
|
|
56
61
|
child.on('exit', (code) => process.exit(code ?? 0));
|
|
57
62
|
}
|
|
58
63
|
|
|
@@ -67,14 +72,12 @@ function startProd(): void {
|
|
|
67
72
|
|
|
68
73
|
console.log(chalk.cyan('[EFC] Starting production server…'));
|
|
69
74
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const child = spawn('node', nodeArgs, {
|
|
75
|
+
// Production env comes exclusively from process.env — no .env file loading.
|
|
76
|
+
// Platforms (Docker, Kubernetes, Railway, Heroku, etc.) inject vars directly.
|
|
77
|
+
const child = spawn('node', [distEntry], {
|
|
74
78
|
stdio: 'inherit',
|
|
75
79
|
env: { ...process.env, NODE_ENV: 'production' },
|
|
76
80
|
});
|
|
77
|
-
|
|
78
81
|
child.on('exit', (code) => process.exit(code ?? 0));
|
|
79
82
|
}
|
|
80
83
|
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import type { Server } from 'node:http';
|
|
7
|
+
import { ignite } from './index.js';
|
|
8
|
+
|
|
9
|
+
// helpers ----------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
function tmpDir() {
|
|
12
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'efc-ignite-'));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function writeRoute(dir: string, name: string, code: string) {
|
|
16
|
+
const file = path.join(dir, name);
|
|
17
|
+
fs.writeFileSync(file, code);
|
|
18
|
+
return file;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function closeServer(server: Server): Promise<void> {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
server.closeAllConnections(); // release keep-alive connections so close() fires promptly
|
|
24
|
+
server.close((e) => (e ? reject(e) : resolve()));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// -----------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
describe('ignite() — server integration', () => {
|
|
31
|
+
let apiDir: string;
|
|
32
|
+
let server: Server | undefined;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
apiDir = tmpDir();
|
|
36
|
+
// ensure no DATABASE_URL so connectMongo is never called
|
|
37
|
+
delete process.env['DATABASE_URL'];
|
|
38
|
+
delete process.env['CORS_ORIGINS'];
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
if (server) { await closeServer(server); server = undefined; }
|
|
43
|
+
fs.rmSync(apiDir, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// --- route serving ----------------------------------------------------
|
|
47
|
+
|
|
48
|
+
it('serves a GET route', async () => {
|
|
49
|
+
writeRoute(apiDir, 'health.js', `export const GET = async (_req, res) => res.json({ ok: true });`);
|
|
50
|
+
server = await ignite({ apiDir, cluster: false, port: 0 });
|
|
51
|
+
const res = await request(server).get('/health');
|
|
52
|
+
expect(res.status).toBe(200);
|
|
53
|
+
expect(res.body).toEqual({ ok: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('serves a POST route', async () => {
|
|
57
|
+
writeRoute(apiDir, 'echo.js', `export const POST = async (req, res) => res.status(201).json(req.body);`);
|
|
58
|
+
server = await ignite({ apiDir, cluster: false, port: 0 });
|
|
59
|
+
const res = await request(server).post('/echo').send({ msg: 'hello' });
|
|
60
|
+
expect(res.status).toBe(201);
|
|
61
|
+
expect(res.body).toEqual({ msg: 'hello' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('resolves dynamic route params', async () => {
|
|
65
|
+
fs.mkdirSync(path.join(apiDir, 'users'));
|
|
66
|
+
writeRoute(
|
|
67
|
+
path.join(apiDir, 'users'), '[id].js',
|
|
68
|
+
`export const GET = async (req, res) => res.json({ id: req.params.id });`,
|
|
69
|
+
);
|
|
70
|
+
server = await ignite({ apiDir, cluster: false, port: 0 });
|
|
71
|
+
const res = await request(server).get('/users/42');
|
|
72
|
+
expect(res.status).toBe(200);
|
|
73
|
+
expect(res.body).toEqual({ id: '42' });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns 405 for an unregistered method', async () => {
|
|
77
|
+
writeRoute(apiDir, 'items.js', `export const GET = async (_req, res) => res.json([]);`);
|
|
78
|
+
server = await ignite({ apiDir, cluster: false, port: 0 });
|
|
79
|
+
const res = await request(server).post('/items');
|
|
80
|
+
expect(res.status).toBe(405);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// --- body parsing -----------------------------------------------------
|
|
84
|
+
|
|
85
|
+
it('parses JSON bodies automatically', async () => {
|
|
86
|
+
writeRoute(apiDir, 'body.js', `export const POST = async (req, res) => res.json(req.body);`);
|
|
87
|
+
server = await ignite({ apiDir, cluster: false, port: 0 });
|
|
88
|
+
const res = await request(server)
|
|
89
|
+
.post('/body')
|
|
90
|
+
.set('Content-Type', 'application/json')
|
|
91
|
+
.send(JSON.stringify({ x: 1 }));
|
|
92
|
+
expect(res.body).toEqual({ x: 1 });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('parses URL-encoded bodies automatically', async () => {
|
|
96
|
+
writeRoute(apiDir, 'form.js', `export const POST = async (req, res) => res.json(req.body);`);
|
|
97
|
+
server = await ignite({ apiDir, cluster: false, port: 0 });
|
|
98
|
+
const res = await request(server)
|
|
99
|
+
.post('/form')
|
|
100
|
+
.set('Content-Type', 'application/x-www-form-urlencoded')
|
|
101
|
+
.send('name=efc&version=1');
|
|
102
|
+
expect(res.body).toEqual({ name: 'efc', version: '1' });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// --- CORS -------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
it('allows all origins by default', async () => {
|
|
108
|
+
writeRoute(apiDir, 'ping.js', `export const GET = async (_req, res) => res.json({});`);
|
|
109
|
+
server = await ignite({ apiDir, cluster: false, port: 0 });
|
|
110
|
+
const res = await request(server).get('/ping').set('Origin', 'http://unknown.example');
|
|
111
|
+
expect(res.headers['access-control-allow-origin']).toBe('http://unknown.example');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('restricts origins when CORS_ORIGINS is set', async () => {
|
|
115
|
+
process.env['CORS_ORIGINS'] = 'http://allowed.example';
|
|
116
|
+
writeRoute(apiDir, 'ping.js', `export const GET = async (_req, res) => res.json({});`);
|
|
117
|
+
server = await ignite({ apiDir, cluster: false, port: 0 });
|
|
118
|
+
|
|
119
|
+
const allowed = await request(server).get('/ping').set('Origin', 'http://allowed.example');
|
|
120
|
+
expect(allowed.headers['access-control-allow-origin']).toBe('http://allowed.example');
|
|
121
|
+
|
|
122
|
+
const blocked = await request(server).get('/ping').set('Origin', 'http://evil.example');
|
|
123
|
+
expect(blocked.headers['access-control-allow-origin']).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('supports multiple origins in CORS_ORIGINS', async () => {
|
|
127
|
+
process.env['CORS_ORIGINS'] = 'http://a.example, http://b.example';
|
|
128
|
+
writeRoute(apiDir, 'ping.js', `export const GET = async (_req, res) => res.json({});`);
|
|
129
|
+
server = await ignite({ apiDir, cluster: false, port: 0 });
|
|
130
|
+
|
|
131
|
+
const a = await request(server).get('/ping').set('Origin', 'http://a.example');
|
|
132
|
+
expect(a.headers['access-control-allow-origin']).toBe('http://a.example');
|
|
133
|
+
|
|
134
|
+
const b = await request(server).get('/ping').set('Origin', 'http://b.example');
|
|
135
|
+
expect(b.headers['access-control-allow-origin']).toBe('http://b.example');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('disables CORS when cors: false', async () => {
|
|
139
|
+
writeRoute(apiDir, 'ping.js', `export const GET = async (_req, res) => res.json({});`);
|
|
140
|
+
server = await ignite({ apiDir, cluster: false, cors: false, port: 0 });
|
|
141
|
+
const res = await request(server).get('/ping').set('Origin', 'http://anywhere.example');
|
|
142
|
+
expect(res.headers['access-control-allow-origin']).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// --- port & env auto-reading ------------------------------------------
|
|
146
|
+
|
|
147
|
+
it('reads PORT from env when port is not passed', async () => {
|
|
148
|
+
process.env['PORT'] = '0'; // 0 = OS assigns a free port
|
|
149
|
+
writeRoute(apiDir, 'hi.js', `export const GET = async (_req, res) => res.json({ hi: true });`);
|
|
150
|
+
server = await ignite({ apiDir, cluster: false, port: 0 });
|
|
151
|
+
const addr = server.address() as { port: number };
|
|
152
|
+
expect(addr.port).toBeGreaterThan(0);
|
|
153
|
+
delete process.env['PORT'];
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('falls back to port 3000 when PORT env is absent', async () => {
|
|
157
|
+
delete process.env['PORT'];
|
|
158
|
+
writeRoute(apiDir, 'hi.js', `export const GET = async (_req, res) => res.json({});`);
|
|
159
|
+
server = await ignite({ apiDir, cluster: false, port: 0 }); // 0 avoids binding 3000 in tests
|
|
160
|
+
const addr = server.address() as { port: number };
|
|
161
|
+
expect(addr.port).toBeGreaterThan(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('ignores NaN port and falls back to env PORT', async () => {
|
|
165
|
+
process.env['PORT'] = '0';
|
|
166
|
+
writeRoute(apiDir, 'hi.js', `export const GET = async (_req, res) => res.json({});`);
|
|
167
|
+
server = await ignite({ apiDir, cluster: false, port: Number(undefined) }); // NaN
|
|
168
|
+
const addr = server.address() as { port: number };
|
|
169
|
+
expect(addr.port).toBeGreaterThan(0);
|
|
170
|
+
delete process.env['PORT'];
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// --- error handling ---------------------------------------------------
|
|
174
|
+
|
|
175
|
+
it('returns 404 for unknown routes', async () => {
|
|
176
|
+
writeRoute(apiDir, 'exists.js', `export const GET = async (_req, res) => res.json({});`);
|
|
177
|
+
server = await ignite({ apiDir, cluster: false, port: 0 });
|
|
178
|
+
const res = await request(server).get('/does-not-exist');
|
|
179
|
+
expect(res.status).toBe(404);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('returns structured JSON for HttpError', async () => {
|
|
183
|
+
// Attach statusCode directly to avoid cross-module instanceof issues in tests
|
|
184
|
+
writeRoute(
|
|
185
|
+
apiDir, 'fail.js',
|
|
186
|
+
`export const GET = async (_req, _res, next) => {
|
|
187
|
+
const err = Object.assign(new Error('Invalid input'), { statusCode: 422 });
|
|
188
|
+
next(err);
|
|
189
|
+
};`,
|
|
190
|
+
);
|
|
191
|
+
server = await ignite({ apiDir, cluster: false, port: 0 });
|
|
192
|
+
const res = await request(server).get('/fail');
|
|
193
|
+
expect(res.status).toBe(422);
|
|
194
|
+
expect(res.body).toMatchObject({ error: 'Invalid input', statusCode: 422 });
|
|
195
|
+
});
|
|
196
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import '
|
|
1
|
+
import http from 'node:http';
|
|
2
2
|
import express from 'express';
|
|
3
3
|
import cors from 'cors';
|
|
4
4
|
import cookieParser from 'cookie-parser';
|
|
5
5
|
import cluster from 'node:cluster';
|
|
6
6
|
import os from 'node:os';
|
|
7
|
+
import dotenv from 'dotenv';
|
|
7
8
|
import type { EFCConfig } from './types.js';
|
|
8
9
|
import { scanDir } from './router/scan.js';
|
|
9
10
|
import { mountRoutes } from './router/mount.js';
|
|
@@ -15,24 +16,39 @@ import { setDbClient } from './db/index.js';
|
|
|
15
16
|
import { scanTasks } from './tasks/scanner.js';
|
|
16
17
|
import { initBullMQ } from './tasks/bullmq-backend.js';
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
// Load .env only outside production — production envs are injected by the platform
|
|
20
|
+
if (process.env['NODE_ENV'] !== 'production') {
|
|
21
|
+
dotenv.config();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function detectDatabase(url?: string): 'mongodb' | 'postgresql' | undefined {
|
|
25
|
+
if (!url) return undefined;
|
|
26
|
+
if (url.startsWith('mongodb')) return 'mongodb';
|
|
27
|
+
if (url.startsWith('postgres')) return 'postgresql';
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function ignite(config: EFCConfig): Promise<http.Server | undefined> {
|
|
19
32
|
const {
|
|
20
33
|
port: _port,
|
|
21
|
-
cluster: clusterEnabled = true,
|
|
22
34
|
workers,
|
|
23
35
|
apiDir,
|
|
24
|
-
jwtSecret,
|
|
25
|
-
authStrategy = 'http-only',
|
|
26
36
|
globalMiddlewares = [],
|
|
27
37
|
onWorkerReady,
|
|
28
38
|
onWorkerCrash,
|
|
29
39
|
onError,
|
|
30
40
|
} = config;
|
|
31
41
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
42
|
+
// All runtime values fall back to environment variables
|
|
43
|
+
const envPort = process.env['PORT'] != null ? Number(process.env['PORT']) : NaN;
|
|
44
|
+
const port = (_port != null && !Number.isNaN(_port)) ? _port
|
|
45
|
+
: !Number.isNaN(envPort) ? envPort
|
|
46
|
+
: 3000;
|
|
47
|
+
const databaseUrl = config.databaseUrl ?? process.env['DATABASE_URL'];
|
|
48
|
+
const database = config.database ?? detectDatabase(databaseUrl);
|
|
49
|
+
const jwtSecret = config.jwtSecret ?? process.env['JWT_SECRET'];
|
|
50
|
+
const authStrategy = config.authStrategy ?? 'http-only';
|
|
51
|
+
const clusterEnabled = config.cluster ?? process.env['NODE_ENV'] === 'production';
|
|
36
52
|
|
|
37
53
|
if (clusterEnabled && cluster.isPrimary) {
|
|
38
54
|
runMaster({
|
|
@@ -51,7 +67,7 @@ export async function ignite(config: EFCConfig): Promise<void> {
|
|
|
51
67
|
let origin: string | string[] | boolean;
|
|
52
68
|
if (envOrigins) {
|
|
53
69
|
const list = envOrigins.split(',').map((o) => o.trim()).filter(Boolean);
|
|
54
|
-
origin = list
|
|
70
|
+
origin = list; // always array so cors validates against the request Origin
|
|
55
71
|
} else if (typeof corsOption === 'object' && corsOption.origin !== undefined) {
|
|
56
72
|
origin = corsOption.origin;
|
|
57
73
|
} else {
|
|
@@ -70,8 +86,8 @@ export async function ignite(config: EFCConfig): Promise<void> {
|
|
|
70
86
|
}
|
|
71
87
|
|
|
72
88
|
// Pre-Flight step 1: Connect database
|
|
73
|
-
if (
|
|
74
|
-
const conn = await connectMongo(
|
|
89
|
+
if (database === 'mongodb' && databaseUrl) {
|
|
90
|
+
const conn = await connectMongo(databaseUrl);
|
|
75
91
|
setDbClient(conn as unknown as Record<string, unknown>);
|
|
76
92
|
}
|
|
77
93
|
|
|
@@ -115,8 +131,10 @@ export async function ignite(config: EFCConfig): Promise<void> {
|
|
|
115
131
|
res: express.Response,
|
|
116
132
|
_next: express.NextFunction,
|
|
117
133
|
) => {
|
|
118
|
-
|
|
119
|
-
|
|
134
|
+
const asHttp = err instanceof Error && 'statusCode' in err
|
|
135
|
+
? (err as HttpError) : null;
|
|
136
|
+
if (asHttp) {
|
|
137
|
+
res.status(asHttp.statusCode).json({ error: asHttp.message, statusCode: asHttp.statusCode });
|
|
120
138
|
} else {
|
|
121
139
|
console.error('[EFC] Unhandled error:', err);
|
|
122
140
|
res.status(500).json({ error: 'Internal Server Error', statusCode: 500 });
|
|
@@ -125,15 +143,37 @@ export async function ignite(config: EFCConfig): Promise<void> {
|
|
|
125
143
|
);
|
|
126
144
|
}
|
|
127
145
|
|
|
128
|
-
|
|
129
|
-
app.listen(port
|
|
146
|
+
return new Promise<http.Server>((resolve, reject) => {
|
|
147
|
+
const server = app.listen(port);
|
|
148
|
+
|
|
149
|
+
server.once('listening', () => {
|
|
130
150
|
const wid = (cluster.worker as { id: number } | undefined)?.id ?? 'primary';
|
|
131
|
-
|
|
132
|
-
|
|
151
|
+
const addr = server.address() as { port: number } | null;
|
|
152
|
+
console.log(`[EFC] Worker ${wid} listening on :${addr?.port ?? port}`);
|
|
153
|
+
resolve(server);
|
|
133
154
|
});
|
|
155
|
+
|
|
156
|
+
server.once('error', reject);
|
|
134
157
|
});
|
|
135
158
|
}
|
|
136
159
|
|
|
160
|
+
export function gracefulShutdown(server: http.Server, timeoutMs = 10_000): void {
|
|
161
|
+
const shutdown = (signal: string) => {
|
|
162
|
+
console.log(`[EFC] ${signal} received — closing server gracefully…`);
|
|
163
|
+
server.closeIdleConnections();
|
|
164
|
+
server.close(() => {
|
|
165
|
+
console.log('[EFC] Server closed');
|
|
166
|
+
process.exit(0);
|
|
167
|
+
});
|
|
168
|
+
setTimeout(() => {
|
|
169
|
+
console.error('[EFC] Forced exit after timeout');
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}, timeoutMs).unref();
|
|
172
|
+
};
|
|
173
|
+
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
|
174
|
+
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
175
|
+
}
|
|
176
|
+
|
|
137
177
|
export { HttpError } from './errors.js';
|
|
138
178
|
export { compose } from './compose.js';
|
|
139
179
|
export { db, setDbClient, getDbClient, defineModel } from './db/index.js';
|