express-file-cluster 0.1.4 → 0.1.6

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.
@@ -1,196 +0,0 @@
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 DELETED
@@ -1,191 +0,0 @@
1
- import http from 'node:http';
2
- import express from 'express';
3
- import cors from 'cors';
4
- import cookieParser from 'cookie-parser';
5
- import cluster from 'node:cluster';
6
- import os from 'node:os';
7
- import dotenv from 'dotenv';
8
- import type { EFCConfig } from './types.js';
9
- import { scanDir } from './router/scan.js';
10
- import { mountRoutes } from './router/mount.js';
11
- import { runMaster } from './cluster/index.js';
12
- import { configureAuth } from './auth/index.js';
13
- import { HttpError } from './errors.js';
14
- import { connectMongo } from './db/mongo.js';
15
- import { setDbClient } from './db/index.js';
16
- import { scanTasks } from './tasks/scanner.js';
17
- import { initBullMQ } from './tasks/bullmq-backend.js';
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> {
32
- const {
33
- port: _port,
34
- workers,
35
- apiDir,
36
- globalMiddlewares = [],
37
- onWorkerReady,
38
- onWorkerCrash,
39
- onError,
40
- } = config;
41
-
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';
52
-
53
- if (clusterEnabled && cluster.isPrimary) {
54
- runMaster({
55
- workers: workers ?? os.cpus().length,
56
- ...(onWorkerReady !== undefined && { onWorkerReady }),
57
- ...(onWorkerCrash !== undefined && { onWorkerCrash }),
58
- });
59
- return;
60
- }
61
-
62
- const app = express();
63
-
64
- const corsOption = config.cors ?? true;
65
- if (corsOption !== false) {
66
- const envOrigins = process.env['CORS_ORIGINS'];
67
- let origin: string | string[] | boolean;
68
- if (envOrigins) {
69
- const list = envOrigins.split(',').map((o) => o.trim()).filter(Boolean);
70
- origin = list; // always array so cors validates against the request Origin
71
- } else if (typeof corsOption === 'object' && corsOption.origin !== undefined) {
72
- origin = corsOption.origin;
73
- } else {
74
- origin = true;
75
- }
76
- const corsOpts = typeof corsOption === 'object' ? { ...corsOption, origin } : { origin };
77
- app.use(cors(corsOpts));
78
- }
79
-
80
- app.use(express.json());
81
- app.use(express.urlencoded({ extended: true }));
82
- app.use(cookieParser());
83
-
84
- for (const mw of globalMiddlewares) {
85
- app.use(mw);
86
- }
87
-
88
- // Pre-Flight step 1: Connect database
89
- if (database === 'mongodb' && databaseUrl) {
90
- const conn = await connectMongo(databaseUrl);
91
- setDbClient(conn as unknown as Record<string, unknown>);
92
- }
93
-
94
- // Pre-Flight step 2: Configure auth
95
- if (jwtSecret) {
96
- const cookieDomain = process.env['COOKIE_DOMAIN'];
97
- configureAuth({
98
- secret: jwtSecret,
99
- strategy: authStrategy,
100
- expiresIn: process.env['JWT_EXPIRES_IN'] ?? '7d',
101
- ...(cookieDomain !== undefined && { cookieDomain }),
102
- });
103
- }
104
-
105
- // Pre-Flight step 3: Scan and register tasks
106
- if (config.tasksDir) {
107
- await scanTasks(config.tasksDir);
108
- }
109
-
110
- // Pre-Flight step 4: Start task queue backend
111
- if (config.tasks) {
112
- if (config.tasks.backend === 'bullmq') {
113
- await initBullMQ({
114
- redisUrl: config.tasks.redisUrl ?? 'redis://localhost:6379',
115
- concurrency: config.tasks.concurrency ?? 5,
116
- });
117
- }
118
- }
119
-
120
- // Pre-Flight step 5: Scan routes and mount
121
- const routes = scanDir(apiDir);
122
- await mountRoutes(app, routes);
123
-
124
- if (onError) {
125
- app.use(onError);
126
- } else {
127
- app.use(
128
- (
129
- err: unknown,
130
- _req: express.Request,
131
- res: express.Response,
132
- _next: express.NextFunction,
133
- ) => {
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 });
138
- } else {
139
- console.error('[EFC] Unhandled error:', err);
140
- res.status(500).json({ error: 'Internal Server Error', statusCode: 500 });
141
- }
142
- },
143
- );
144
- }
145
-
146
- return new Promise<http.Server>((resolve, reject) => {
147
- const server = app.listen(port);
148
-
149
- server.once('listening', () => {
150
- const wid = (cluster.worker as { id: number } | undefined)?.id ?? 'primary';
151
- const addr = server.address() as { port: number } | null;
152
- console.log(`[EFC] Worker ${wid} listening on :${addr?.port ?? port}`);
153
- resolve(server);
154
- });
155
-
156
- server.once('error', reject);
157
- });
158
- }
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
-
177
- export { HttpError } from './errors.js';
178
- export { compose } from './compose.js';
179
- export { db, setDbClient, getDbClient, defineModel } from './db/index.js';
180
- export { scanDir } from './router/scan.js';
181
- export type {
182
- EFCConfig,
183
- CorsConfig,
184
- RouteEntry,
185
- TaskOptions,
186
- TaskDefinition,
187
- DatabaseEngine,
188
- AuthStrategy,
189
- ModelSchema,
190
- ModelCRUD,
191
- } from './types.js';
@@ -1,75 +0,0 @@
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
- });
@@ -1,49 +0,0 @@
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 };
@@ -1,23 +0,0 @@
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
- });
@@ -1,55 +0,0 @@
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 };
@@ -1,76 +0,0 @@
1
- import type { Job } from 'bullmq';
2
- import { taskRegistry, setEnqueueImpl } from './index.js';
3
- import { runInThread } from './thread-runner.js';
4
-
5
- interface BullMQOpts {
6
- redisUrl: string;
7
- concurrency: number;
8
- }
9
-
10
- export async function initBullMQ(opts: BullMQOpts): Promise<void> {
11
- let bullmq: typeof import('bullmq');
12
- try {
13
- bullmq = await import('bullmq');
14
- } catch {
15
- throw new Error('[EFC] bullmq is not installed. Run: npm install bullmq');
16
- }
17
-
18
- const connection = parseRedisUrl(opts.redisUrl);
19
- const queue = new bullmq.Queue('efc', { connection });
20
-
21
- const worker = new bullmq.Worker(
22
- 'efc',
23
- async (job: Job) => {
24
- const def = taskRegistry.get(job.name);
25
- if (!def) {
26
- throw new Error(`[EFC] No task registered for name: ${job.name}`);
27
- }
28
-
29
- if (def.options.thread && def.filePath) {
30
- await runInThread(def.filePath, job.data);
31
- } else {
32
- await def.handler(job.data);
33
- }
34
- },
35
- { connection, concurrency: opts.concurrency },
36
- );
37
-
38
- worker.on('failed', (job: Job | undefined, err: Error) => {
39
- console.error(`[EFC] Task ${job?.name ?? 'unknown'} failed:`, err.message);
40
- });
41
-
42
- worker.on('completed', (job: Job) => {
43
- console.log(`[EFC] Task ${job.name} completed (id=${job.id})`);
44
- });
45
-
46
- setEnqueueImpl(async (name, payload) => {
47
- const def = taskRegistry.get(name);
48
- if (!def) {
49
- throw new Error(`[EFC] Cannot enqueue unknown task: "${name}". Is the task file in tasksDir?`);
50
- }
51
-
52
- await queue.add(name, payload, {
53
- attempts: def.options.retries ?? 3,
54
- backoff: {
55
- type: def.options.backoff ?? 'exponential',
56
- delay: 1000,
57
- },
58
- });
59
- });
60
-
61
- console.log('[EFC] BullMQ backend initialised');
62
- }
63
-
64
- function parseRedisUrl(url: string): { host: string; port: number; password?: string } {
65
- try {
66
- const u = new URL(url);
67
- const result: { host: string; port: number; password?: string } = {
68
- host: u.hostname || 'localhost',
69
- port: parseInt(u.port || '6379', 10),
70
- };
71
- if (u.password) result.password = u.password;
72
- return result;
73
- } catch {
74
- return { host: 'localhost', port: 6379 };
75
- }
76
- }