@bhooai/nexus-core 0.1.6 → 2.0.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/package.json +3 -2
- package/src/app/ErrorHandler.ts +65 -0
- package/src/app/FormRequest.ts +43 -0
- package/src/app/Job.ts +98 -0
- package/src/app/Mailable.ts +114 -0
- package/src/app/Resource.ts +37 -0
- package/src/app/Seeder.ts +17 -0
- package/src/app/ServiceProvider.ts +16 -0
- package/src/app/Storage.ts +366 -0
- package/src/app/createNexusApp.ts +325 -0
- package/src/app/defineRoutes.ts +48 -0
- package/src/app/discover.ts +144 -0
- package/src/app/errorPages/_base.html +121 -0
- package/src/app/errorPages.ts +117 -0
- package/src/app/events.ts +81 -0
- package/src/app/index.ts +14 -0
- package/src/app/maintenance.ts +80 -0
- package/src/app/policies.ts +53 -0
- package/src/config/defaults.ts +0 -20
- package/src/config/schema.ts +1 -27
- package/src/config/types.ts +3 -86
- package/src/index.ts +2 -1
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage — disk abstraction. Drivers: local + s3 (the latter via @aws-sdk
|
|
3
|
+
* when installed). Apps call `storage.disk(name)`; route upload specs use
|
|
4
|
+
* the same facade.
|
|
5
|
+
*
|
|
6
|
+
* await storage.disk('uploads').put('avatars', file);
|
|
7
|
+
* const url = await storage.disk('private').signedUrl('invoices/1.pdf', { ttl: 300 });
|
|
8
|
+
*/
|
|
9
|
+
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
|
10
|
+
import { mkdir, readFile, stat as fsStat, unlink, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { createReadStream } from 'node:fs';
|
|
12
|
+
import { dirname, extname, join, resolve } from 'node:path';
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import type { Readable } from 'node:stream';
|
|
15
|
+
|
|
16
|
+
export interface StoredFile {
|
|
17
|
+
id: string;
|
|
18
|
+
path: string;
|
|
19
|
+
url: string;
|
|
20
|
+
disk: string;
|
|
21
|
+
size: number;
|
|
22
|
+
mime: string;
|
|
23
|
+
originalName: string;
|
|
24
|
+
sha256: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SignedUrlOptions {
|
|
28
|
+
/** Seconds the URL stays valid. */
|
|
29
|
+
ttl?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Disk {
|
|
33
|
+
name: string;
|
|
34
|
+
put(dir: string, source: Buffer | Readable | { buffer: Buffer; originalName?: string; mime?: string }): Promise<StoredFile>;
|
|
35
|
+
get(path: string): Promise<Buffer>;
|
|
36
|
+
stream(path: string): Readable;
|
|
37
|
+
delete(path: string): Promise<void>;
|
|
38
|
+
exists(path: string): Promise<boolean>;
|
|
39
|
+
url(path: string): string;
|
|
40
|
+
signedUrl(path: string, opts?: SignedUrlOptions): Promise<string>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DiskConfig {
|
|
44
|
+
driver: 'local' | 's3';
|
|
45
|
+
/** local: filesystem root. */
|
|
46
|
+
root?: string;
|
|
47
|
+
/** local: public serve path prefix (e.g. /uploads). */
|
|
48
|
+
servePath?: string;
|
|
49
|
+
/** s3: bucket. */
|
|
50
|
+
bucket?: string;
|
|
51
|
+
/** s3: region. */
|
|
52
|
+
region?: string;
|
|
53
|
+
/** s3: optional endpoint (MinIO/R2/B2). */
|
|
54
|
+
endpoint?: string;
|
|
55
|
+
/** s3: optional public CDN base URL. */
|
|
56
|
+
cdnUrl?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface StorageConfig {
|
|
60
|
+
disks: Record<string, DiskConfig>;
|
|
61
|
+
default?: string;
|
|
62
|
+
/** HMAC secret used to sign local private URLs. Falls back to auth jwt secret. */
|
|
63
|
+
signingSecret?: string;
|
|
64
|
+
imageTransform?: {
|
|
65
|
+
enabled: boolean;
|
|
66
|
+
formats: string[];
|
|
67
|
+
sizes: Record<string, number>;
|
|
68
|
+
stripMetadata?: boolean;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Local driver
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
class LocalDisk implements Disk {
|
|
77
|
+
constructor(readonly name: string, private cfg: DiskConfig, private signingSecret: string) {}
|
|
78
|
+
|
|
79
|
+
private resolveRoot(): string {
|
|
80
|
+
if (!this.cfg.root) throw new Error(`disk "${this.name}" missing root`);
|
|
81
|
+
return resolve(this.cfg.root);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private abs(p: string): string {
|
|
85
|
+
return join(this.resolveRoot(), p);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async put(
|
|
89
|
+
dir: string,
|
|
90
|
+
source: Buffer | Readable | { buffer: Buffer; originalName?: string; mime?: string },
|
|
91
|
+
): Promise<StoredFile> {
|
|
92
|
+
const buffer = await sourceToBuffer(source);
|
|
93
|
+
const meta = sourceAsMeta(source);
|
|
94
|
+
const originalName = meta.originalName ?? 'file';
|
|
95
|
+
const ext = extname(originalName) || '';
|
|
96
|
+
const id = randomUUID();
|
|
97
|
+
const date = new Date();
|
|
98
|
+
const shard = `${date.getUTCFullYear()}/${String(date.getUTCMonth() + 1).padStart(2, '0')}`;
|
|
99
|
+
const rel = join(dir, shard, `${id}${ext}`).replace(/\\/g, '/');
|
|
100
|
+
const abs = this.abs(rel);
|
|
101
|
+
|
|
102
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
103
|
+
await writeFile(abs, buffer);
|
|
104
|
+
|
|
105
|
+
const sha256 = createHash('sha256').update(buffer).digest('hex');
|
|
106
|
+
return {
|
|
107
|
+
id,
|
|
108
|
+
path: rel,
|
|
109
|
+
url: this.url(rel),
|
|
110
|
+
disk: this.name,
|
|
111
|
+
size: buffer.length,
|
|
112
|
+
mime: meta.mime ?? 'application/octet-stream',
|
|
113
|
+
originalName,
|
|
114
|
+
sha256,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async get(path: string): Promise<Buffer> {
|
|
119
|
+
return readFile(this.abs(path));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
stream(path: string): Readable {
|
|
123
|
+
return createReadStream(this.abs(path));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async delete(path: string): Promise<void> {
|
|
127
|
+
try {
|
|
128
|
+
await unlink(this.abs(path));
|
|
129
|
+
} catch {
|
|
130
|
+
/* swallow — deletes are idempotent */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async exists(path: string): Promise<boolean> {
|
|
135
|
+
try {
|
|
136
|
+
await fsStat(this.abs(path));
|
|
137
|
+
return true;
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
url(path: string): string {
|
|
144
|
+
const base = this.cfg.servePath ?? `/${this.name}`;
|
|
145
|
+
return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async signedUrl(path: string, opts: SignedUrlOptions = {}): Promise<string> {
|
|
149
|
+
const ttl = opts.ttl ?? 300;
|
|
150
|
+
const expires = Math.floor(Date.now() / 1000) + ttl;
|
|
151
|
+
const toSign = `${path}:${expires}`;
|
|
152
|
+
const sig = createHmac('sha256', this.signingSecret).update(toSign).digest('base64url');
|
|
153
|
+
const base = `/files/${encodeURIComponent(path)}`;
|
|
154
|
+
return `${base}?expires=${expires}&sig=${sig}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
verifySignedUrl(path: string, expires: number, sig: string): boolean {
|
|
158
|
+
if (Math.floor(Date.now() / 1000) > expires) return false;
|
|
159
|
+
const expected = createHmac('sha256', this.signingSecret).update(`${path}:${expires}`).digest('base64url');
|
|
160
|
+
return expected === sig;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// S3 driver (dynamically loaded if @aws-sdk/client-s3 installed)
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
class S3Disk implements Disk {
|
|
169
|
+
private client: any;
|
|
170
|
+
private ready = false;
|
|
171
|
+
|
|
172
|
+
constructor(readonly name: string, private cfg: DiskConfig) {}
|
|
173
|
+
|
|
174
|
+
private async ensure(): Promise<void> {
|
|
175
|
+
if (this.ready) return;
|
|
176
|
+
const mod = await import('@aws-sdk/client-s3' as string).catch(() => null);
|
|
177
|
+
if (!mod) throw new Error('S3 driver requires @aws-sdk/client-s3 — install it as an optional dep');
|
|
178
|
+
const { S3Client } = mod as any;
|
|
179
|
+
this.client = new S3Client({
|
|
180
|
+
region: this.cfg.region,
|
|
181
|
+
...(this.cfg.endpoint ? { endpoint: this.cfg.endpoint, forcePathStyle: true } : {}),
|
|
182
|
+
});
|
|
183
|
+
this.ready = true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async put(dir: string, source: Buffer | Readable | { buffer: Buffer; originalName?: string; mime?: string }): Promise<StoredFile> {
|
|
187
|
+
await this.ensure();
|
|
188
|
+
const { PutObjectCommand } = (await import('@aws-sdk/client-s3' as string)) as any;
|
|
189
|
+
const buffer = await sourceToBuffer(source);
|
|
190
|
+
const meta = sourceAsMeta(source);
|
|
191
|
+
const originalName = meta.originalName ?? 'file';
|
|
192
|
+
const ext = extname(originalName) || '';
|
|
193
|
+
const id = randomUUID();
|
|
194
|
+
const date = new Date();
|
|
195
|
+
const shard = `${date.getUTCFullYear()}/${String(date.getUTCMonth() + 1).padStart(2, '0')}`;
|
|
196
|
+
const rel = `${dir}/${shard}/${id}${ext}`;
|
|
197
|
+
await this.client.send(new PutObjectCommand({
|
|
198
|
+
Bucket: this.cfg.bucket,
|
|
199
|
+
Key: rel,
|
|
200
|
+
Body: buffer,
|
|
201
|
+
ContentType: meta.mime ?? 'application/octet-stream',
|
|
202
|
+
}));
|
|
203
|
+
const sha256 = createHash('sha256').update(buffer).digest('hex');
|
|
204
|
+
return {
|
|
205
|
+
id,
|
|
206
|
+
path: rel,
|
|
207
|
+
url: this.url(rel),
|
|
208
|
+
disk: this.name,
|
|
209
|
+
size: buffer.length,
|
|
210
|
+
mime: meta.mime ?? 'application/octet-stream',
|
|
211
|
+
originalName,
|
|
212
|
+
sha256,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async get(path: string): Promise<Buffer> {
|
|
217
|
+
await this.ensure();
|
|
218
|
+
const { GetObjectCommand } = (await import('@aws-sdk/client-s3' as string)) as any;
|
|
219
|
+
const out = await this.client.send(new GetObjectCommand({ Bucket: this.cfg.bucket, Key: path }));
|
|
220
|
+
const chunks: Buffer[] = [];
|
|
221
|
+
for await (const chunk of out.Body as AsyncIterable<Buffer>) chunks.push(chunk);
|
|
222
|
+
return Buffer.concat(chunks);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
stream(_path: string): Readable {
|
|
226
|
+
throw new Error('S3 stream() not yet implemented — use get()');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async delete(path: string): Promise<void> {
|
|
230
|
+
await this.ensure();
|
|
231
|
+
const { DeleteObjectCommand } = (await import('@aws-sdk/client-s3' as string)) as any;
|
|
232
|
+
await this.client.send(new DeleteObjectCommand({ Bucket: this.cfg.bucket, Key: path }));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async exists(path: string): Promise<boolean> {
|
|
236
|
+
await this.ensure();
|
|
237
|
+
const { HeadObjectCommand } = (await import('@aws-sdk/client-s3' as string)) as any;
|
|
238
|
+
try {
|
|
239
|
+
await this.client.send(new HeadObjectCommand({ Bucket: this.cfg.bucket, Key: path }));
|
|
240
|
+
return true;
|
|
241
|
+
} catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
url(path: string): string {
|
|
247
|
+
if (this.cfg.cdnUrl) return `${this.cfg.cdnUrl.replace(/\/+$/, '')}/${path}`;
|
|
248
|
+
if (this.cfg.endpoint) return `${this.cfg.endpoint.replace(/\/+$/, '')}/${this.cfg.bucket}/${path}`;
|
|
249
|
+
return `https://${this.cfg.bucket}.s3.${this.cfg.region}.amazonaws.com/${path}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async signedUrl(path: string, opts: SignedUrlOptions = {}): Promise<string> {
|
|
253
|
+
await this.ensure();
|
|
254
|
+
const presigner = await import('@aws-sdk/s3-request-presigner' as string).catch(() => null);
|
|
255
|
+
if (!presigner) throw new Error('S3 presigned URLs require @aws-sdk/s3-request-presigner');
|
|
256
|
+
const { getSignedUrl } = presigner as any;
|
|
257
|
+
const { GetObjectCommand } = (await import('@aws-sdk/client-s3' as string)) as any;
|
|
258
|
+
return await getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.cfg.bucket, Key: path }), {
|
|
259
|
+
expiresIn: opts.ttl ?? 300,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Helpers
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
async function sourceToBuffer(
|
|
269
|
+
source: Buffer | Readable | { buffer: Buffer; originalName?: string; mime?: string },
|
|
270
|
+
): Promise<Buffer> {
|
|
271
|
+
if (Buffer.isBuffer(source)) return source;
|
|
272
|
+
if (typeof source === 'object' && 'buffer' in source) return source.buffer;
|
|
273
|
+
const chunks: Buffer[] = [];
|
|
274
|
+
for await (const chunk of source as AsyncIterable<Buffer>) chunks.push(chunk);
|
|
275
|
+
return Buffer.concat(chunks);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function sourceAsMeta(
|
|
279
|
+
source: Buffer | Readable | { buffer: Buffer; originalName?: string; mime?: string },
|
|
280
|
+
): { originalName?: string; mime?: string } {
|
|
281
|
+
if (typeof source === 'object' && !Buffer.isBuffer(source) && 'buffer' in source) {
|
|
282
|
+
const out: { originalName?: string; mime?: string } = {};
|
|
283
|
+
if (source.originalName) out.originalName = source.originalName;
|
|
284
|
+
if (source.mime) out.mime = source.mime;
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
return {};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Facade
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
class StorageFacade {
|
|
295
|
+
private disks = new Map<string, Disk>();
|
|
296
|
+
private cfg: StorageConfig | null = null;
|
|
297
|
+
private signingSecret: string = '';
|
|
298
|
+
|
|
299
|
+
configure(cfg: StorageConfig, signingSecret: string): void {
|
|
300
|
+
this.cfg = cfg;
|
|
301
|
+
this.signingSecret = signingSecret || cfg.signingSecret || 'nexus-insecure';
|
|
302
|
+
for (const [name, diskCfg] of Object.entries(cfg.disks ?? {})) {
|
|
303
|
+
if (diskCfg.driver === 'local') {
|
|
304
|
+
this.disks.set(name, new LocalDisk(name, diskCfg, this.signingSecret));
|
|
305
|
+
} else if (diskCfg.driver === 's3') {
|
|
306
|
+
this.disks.set(name, new S3Disk(name, diskCfg));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
disk(name?: string): Disk {
|
|
312
|
+
const key = name ?? this.cfg?.default ?? Object.keys(this.cfg?.disks ?? {})[0];
|
|
313
|
+
if (!key) throw new Error('Storage has no disks configured');
|
|
314
|
+
const d = this.disks.get(key);
|
|
315
|
+
if (!d) throw new Error(`Storage disk "${key}" not configured`);
|
|
316
|
+
return d;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
local(name: string): LocalDisk | null {
|
|
320
|
+
const d = this.disks.get(name);
|
|
321
|
+
return d instanceof LocalDisk ? d : null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export const storage = new StorageFacade();
|
|
326
|
+
|
|
327
|
+
/** Auto-configure from env — used by createNexusApp() boot. */
|
|
328
|
+
export function configureStorageFromEnv(root: string, signingSecret: string): void {
|
|
329
|
+
const cfg: StorageConfig = {
|
|
330
|
+
default: 'uploads',
|
|
331
|
+
disks: {
|
|
332
|
+
uploads: {
|
|
333
|
+
driver: 'local',
|
|
334
|
+
root: join(root, 'storage', 'uploads'),
|
|
335
|
+
servePath: '/uploads',
|
|
336
|
+
},
|
|
337
|
+
private: {
|
|
338
|
+
driver: 'local',
|
|
339
|
+
root: join(root, 'storage', 'private'),
|
|
340
|
+
},
|
|
341
|
+
...(process.env.NEXUS_STORAGE_S3_BUCKET
|
|
342
|
+
? {
|
|
343
|
+
s3media: {
|
|
344
|
+
driver: 's3' as const,
|
|
345
|
+
bucket: process.env.NEXUS_STORAGE_S3_BUCKET,
|
|
346
|
+
region: process.env.NEXUS_STORAGE_S3_REGION,
|
|
347
|
+
...(process.env.NEXUS_STORAGE_S3_ENDPOINT ? { endpoint: process.env.NEXUS_STORAGE_S3_ENDPOINT } : {}),
|
|
348
|
+
...(process.env.NEXUS_STORAGE_S3_CDN ? { cdnUrl: process.env.NEXUS_STORAGE_S3_CDN } : {}),
|
|
349
|
+
},
|
|
350
|
+
}
|
|
351
|
+
: {}),
|
|
352
|
+
},
|
|
353
|
+
signingSecret,
|
|
354
|
+
};
|
|
355
|
+
storage.configure(cfg, signingSecret);
|
|
356
|
+
|
|
357
|
+
// Ensure directories exist
|
|
358
|
+
for (const d of Object.values(cfg.disks ?? {})) {
|
|
359
|
+
if (d.driver === 'local' && d.root) {
|
|
360
|
+
const abs = resolve(d.root);
|
|
361
|
+
if (!existsSync(abs)) {
|
|
362
|
+
void mkdir(abs, { recursive: true }).catch(() => {});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createNexusApp — opinionated bootstrap that wires a Nexus backend from
|
|
3
|
+
* conventional folders under src/.
|
|
4
|
+
*
|
|
5
|
+
* Discovers routes/, graphql/, ws/, mail/, errors/, events/, listeners/,
|
|
6
|
+
* jobs/, policies/, providers/, database/seeds+migrations, plugins/, and
|
|
7
|
+
* config/. Replaces manual wiring of Routers, GraphQL gateways, Realtime
|
|
8
|
+
* servers, etc.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* import { createNexusApp } from '@bhooai/nexus-core';
|
|
12
|
+
* const app = await createNexusApp({ name: 'backend-main' });
|
|
13
|
+
* await app.listen();
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { resolve, dirname } from 'node:path';
|
|
17
|
+
import { readFile } from 'node:fs/promises';
|
|
18
|
+
import { loadConfigAuto, type NexusConfig } from '../config/index.js';
|
|
19
|
+
import { Container } from '../di/Container.js';
|
|
20
|
+
import { Router } from '../http/Router.js';
|
|
21
|
+
import { NexusServer } from '../http/Server.js';
|
|
22
|
+
import type { Handler, Middleware } from '../http/context.js';
|
|
23
|
+
import { discoverBackend, importDefault, type DiscoveryResult } from './discover.js';
|
|
24
|
+
import type { RoutesFile, RouteDef } from './defineRoutes.js';
|
|
25
|
+
import { DefaultErrorHandler, ErrorHandler } from './ErrorHandler.js';
|
|
26
|
+
import { ErrorPages } from './errorPages.js';
|
|
27
|
+
import { eventBus, type EventBus, type Listener } from './events.js';
|
|
28
|
+
import { configureQueue, InMemoryQueueAdapter, type JobQueueAdapter } from './Job.js';
|
|
29
|
+
import { configureMailDriver, configureMailRenderer, logMailDriver } from './Mailable.js';
|
|
30
|
+
import { policyRegistry } from './policies.js';
|
|
31
|
+
import { configureStorageFromEnv } from './Storage.js';
|
|
32
|
+
import { maintenanceMiddleware } from './maintenance.js';
|
|
33
|
+
|
|
34
|
+
export interface CreateNexusAppOptions {
|
|
35
|
+
/** Identifier for this backend, used in admin, telemetry, logs. */
|
|
36
|
+
name?: string;
|
|
37
|
+
/** Path to the backend src/ folder. Defaults to `<cwd>/src`. */
|
|
38
|
+
srcRoot?: string;
|
|
39
|
+
/** Project root (where .nexus-down and storage/ live). Defaults to srcRoot/.. */
|
|
40
|
+
projectRoot?: string;
|
|
41
|
+
/** Override the auto-discovered config (for tests). */
|
|
42
|
+
config?: NexusConfig;
|
|
43
|
+
/** Additional global middleware applied after built-ins. */
|
|
44
|
+
middleware?: Middleware[];
|
|
45
|
+
/** Manual route registrations in addition to routes/ discovery. */
|
|
46
|
+
additionalRoutes?: RouteDef[];
|
|
47
|
+
/** Custom queue adapter (default: InMemoryQueueAdapter). */
|
|
48
|
+
queueAdapter?: JobQueueAdapter;
|
|
49
|
+
/** Custom error handler (default: DefaultErrorHandler). */
|
|
50
|
+
errorHandler?: ErrorHandler;
|
|
51
|
+
/** Hooks for extending boot. */
|
|
52
|
+
beforeStart?: (app: NexusApp) => Promise<void> | void;
|
|
53
|
+
afterStart?: (app: NexusApp) => Promise<void> | void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface NexusApp {
|
|
57
|
+
/** Backend identifier (from options.name). */
|
|
58
|
+
name: string;
|
|
59
|
+
/** Loaded config. */
|
|
60
|
+
config: NexusConfig;
|
|
61
|
+
/** DI container. */
|
|
62
|
+
container: Container;
|
|
63
|
+
/** HTTP router (mounted under /). */
|
|
64
|
+
router: Router;
|
|
65
|
+
/** HTTP server. */
|
|
66
|
+
server: NexusServer;
|
|
67
|
+
/** Discovery manifest — what was found under src/. */
|
|
68
|
+
discovery: DiscoveryResult;
|
|
69
|
+
/** Domain event bus for this app. */
|
|
70
|
+
events: EventBus;
|
|
71
|
+
/** Register an additional middleware after built-ins. */
|
|
72
|
+
use(mw: Middleware): this;
|
|
73
|
+
/** Register an additional route at runtime. */
|
|
74
|
+
route(def: RouteDef): this;
|
|
75
|
+
/** Start listening. Resolves when the server is bound. */
|
|
76
|
+
listen(): Promise<void>;
|
|
77
|
+
/** Graceful shutdown. */
|
|
78
|
+
close(): Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<NexusApp> {
|
|
82
|
+
const name = opts.name ?? 'backend';
|
|
83
|
+
const srcRoot = resolve(opts.srcRoot ?? resolve(process.cwd(), 'src'));
|
|
84
|
+
const projectRoot = resolve(opts.projectRoot ?? dirname(srcRoot));
|
|
85
|
+
let config = opts.config ?? (await loadConfigAuto({ root: projectRoot }));
|
|
86
|
+
|
|
87
|
+
// ------------------------------------------------------------------
|
|
88
|
+
// Per-app ports: NEXUS_PORT env var (set by `nexus dev` per service) wins.
|
|
89
|
+
// Otherwise, config port is used. For multi-app backends this is the only
|
|
90
|
+
// way they don't all collide on 4000.
|
|
91
|
+
// ------------------------------------------------------------------
|
|
92
|
+
const envPort = process.env.NEXUS_PORT ? parseInt(process.env.NEXUS_PORT, 10) : null;
|
|
93
|
+
if (envPort && config.server) {
|
|
94
|
+
config = Object.freeze({ ...config, server: { ...config.server, port: envPort } }) as NexusConfig;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const container = new Container();
|
|
98
|
+
const router = new Router();
|
|
99
|
+
|
|
100
|
+
// ------------------------------------------------------------------
|
|
101
|
+
// Discover convention folders
|
|
102
|
+
// ------------------------------------------------------------------
|
|
103
|
+
const discovery = await discoverBackend(srcRoot);
|
|
104
|
+
|
|
105
|
+
// ------------------------------------------------------------------
|
|
106
|
+
// Storage facade + queue + mail — always configured, even if minimal
|
|
107
|
+
// ------------------------------------------------------------------
|
|
108
|
+
configureStorageFromEnv(projectRoot, config.auth?.jwt?.secret ?? 'nexus-insecure');
|
|
109
|
+
configureQueue(opts.queueAdapter ?? new InMemoryQueueAdapter());
|
|
110
|
+
configureMailDriver(logMailDriver);
|
|
111
|
+
configureMailRenderer(async (templateName, data) => renderMailTemplate(projectRoot, templateName, data));
|
|
112
|
+
|
|
113
|
+
// ------------------------------------------------------------------
|
|
114
|
+
// Error pages + handler
|
|
115
|
+
// ------------------------------------------------------------------
|
|
116
|
+
const errorPages = new ErrorPages(projectRoot);
|
|
117
|
+
let errorHandler: ErrorHandler = opts.errorHandler ?? new DefaultErrorHandler();
|
|
118
|
+
if (discovery.errorHandler) {
|
|
119
|
+
try {
|
|
120
|
+
const custom = await importDefault<new () => ErrorHandler>(discovery.errorHandler);
|
|
121
|
+
if (typeof custom === 'function') errorHandler = new custom();
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.warn(`[${name}] failed to load errors/Handler.ts — falling back to DefaultErrorHandler:`, err);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ------------------------------------------------------------------
|
|
128
|
+
// Events + listeners auto-wire (convention: On<Foo> listens for <Foo>)
|
|
129
|
+
// ------------------------------------------------------------------
|
|
130
|
+
const events: EventBus = eventBus;
|
|
131
|
+
const eventsByName = new Map<string, unknown>();
|
|
132
|
+
for (const file of discovery.events) {
|
|
133
|
+
try {
|
|
134
|
+
const Evt = await importDefault(file);
|
|
135
|
+
eventsByName.set((Evt as { name?: string })?.name ?? file.name, Evt);
|
|
136
|
+
} catch { /* ignore bad event file */ }
|
|
137
|
+
}
|
|
138
|
+
for (const file of discovery.listeners) {
|
|
139
|
+
try {
|
|
140
|
+
const Lst = await importDefault<new () => Listener>(file);
|
|
141
|
+
if (typeof Lst !== 'function') continue;
|
|
142
|
+
// Convention: On<Foo> listens for <Foo>
|
|
143
|
+
const short = file.name.replace(/^On/, '');
|
|
144
|
+
const Evt = eventsByName.get(short);
|
|
145
|
+
if (Evt && typeof Evt === 'function') {
|
|
146
|
+
events.register(Lst, Evt as never);
|
|
147
|
+
}
|
|
148
|
+
} catch { /* ignore */ }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ------------------------------------------------------------------
|
|
152
|
+
// Policies (per-resource registry)
|
|
153
|
+
// ------------------------------------------------------------------
|
|
154
|
+
for (const file of discovery.policies) {
|
|
155
|
+
try {
|
|
156
|
+
const Pol = await importDefault<new () => Record<string, unknown>>(file);
|
|
157
|
+
if (typeof Pol === 'function') {
|
|
158
|
+
// Without a ResourceCtor hint in the discovery file we can't fully
|
|
159
|
+
// auto-bind; users typically register manually inside the policy class.
|
|
160
|
+
// Skip auto-wire — explicit registration wins.
|
|
161
|
+
void Pol;
|
|
162
|
+
}
|
|
163
|
+
} catch { /* ignore */ }
|
|
164
|
+
}
|
|
165
|
+
void policyRegistry;
|
|
166
|
+
|
|
167
|
+
// ------------------------------------------------------------------
|
|
168
|
+
// Providers: register phase
|
|
169
|
+
// ------------------------------------------------------------------
|
|
170
|
+
const providerInstances: Array<{ register?: (c: Container) => void; boot?: (c: Container) => Promise<void> | void }> = [];
|
|
171
|
+
for (const file of discovery.providers) {
|
|
172
|
+
try {
|
|
173
|
+
const ProviderCtor = await importDefault<new () => { register?: (c: Container) => void; boot?: (c: Container) => Promise<void> | void }>(file);
|
|
174
|
+
if (typeof ProviderCtor !== 'function') continue;
|
|
175
|
+
const inst = new ProviderCtor();
|
|
176
|
+
providerInstances.push(inst);
|
|
177
|
+
if (typeof inst.register === 'function') inst.register(container);
|
|
178
|
+
} catch { /* ignore */ }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ------------------------------------------------------------------
|
|
182
|
+
// Mount discovered routes
|
|
183
|
+
// ------------------------------------------------------------------
|
|
184
|
+
for (const file of discovery.routes) {
|
|
185
|
+
try {
|
|
186
|
+
const mod: any = await importDefault(file);
|
|
187
|
+
const routesFile: RoutesFile | undefined = mod && typeof mod === 'object' && 'routes' in mod ? mod : undefined;
|
|
188
|
+
if (!routesFile) continue;
|
|
189
|
+
const prefix = (routesFile.prefix ?? (mod as any)?.prefix ?? '') as string;
|
|
190
|
+
for (const def of routesFile.routes) {
|
|
191
|
+
registerRoute(router, def, prefix);
|
|
192
|
+
}
|
|
193
|
+
} catch (err) {
|
|
194
|
+
console.warn(`[${name}] failed to mount routes from ${file.path}:`, err);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const def of opts.additionalRoutes ?? []) registerRoute(router, def, '');
|
|
199
|
+
|
|
200
|
+
// ------------------------------------------------------------------
|
|
201
|
+
// Built-in: /health + /admin/_registry
|
|
202
|
+
// ------------------------------------------------------------------
|
|
203
|
+
router.get('/health', async (ctx) => {
|
|
204
|
+
ctx.json({ ok: true, name, time: new Date().toISOString() });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Apps registry — used by the admin SPA's Apps tab. Reads the CLI's
|
|
208
|
+
// .nexus-ports.json (when present) and reports per-app info. Apps that
|
|
209
|
+
// aren't running show healthy: false.
|
|
210
|
+
router.get('/admin/_registry', async (ctx) => {
|
|
211
|
+
const apps: Array<{ name: string; port: number; source: 'registry' }> = [];
|
|
212
|
+
try {
|
|
213
|
+
const regPath = resolve(projectRoot, '.nexus-ports.json');
|
|
214
|
+
if (existsSync(regPath)) {
|
|
215
|
+
const raw = await readFile(regPath, 'utf-8');
|
|
216
|
+
const reg = JSON.parse(raw) as Record<string, number>;
|
|
217
|
+
for (const [appName, port] of Object.entries(reg)) {
|
|
218
|
+
apps.push({ name: appName, port, source: 'registry' });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} catch (err) {
|
|
222
|
+
void err; // fall through with empty list
|
|
223
|
+
}
|
|
224
|
+
// Always include the current app if not listed
|
|
225
|
+
if (!apps.some((a) => a.name === name)) {
|
|
226
|
+
apps.unshift({ name, port: config.server.port, source: 'registry' });
|
|
227
|
+
}
|
|
228
|
+
ctx.json({ apps });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ------------------------------------------------------------------
|
|
232
|
+
// HTTP server with error handler + maintenance middleware wired in
|
|
233
|
+
// ------------------------------------------------------------------
|
|
234
|
+
const server = new NexusServer({
|
|
235
|
+
router,
|
|
236
|
+
bodyLimit: config.server.bodyLimit,
|
|
237
|
+
trustProxy: config.server.trustProxy,
|
|
238
|
+
...(config.server.https && config.server.certFile && config.server.keyFile
|
|
239
|
+
? { certFile: config.server.certFile, keyFile: config.server.keyFile }
|
|
240
|
+
: {}),
|
|
241
|
+
onError: (err, ctx) => errorHandler.toApiError(err),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Maintenance mode (checked first)
|
|
245
|
+
server.use(maintenanceMiddleware({ projectRoot }));
|
|
246
|
+
|
|
247
|
+
// User-supplied global middleware
|
|
248
|
+
for (const mw of opts.middleware ?? []) server.use(mw);
|
|
249
|
+
|
|
250
|
+
// ------------------------------------------------------------------
|
|
251
|
+
// Providers: boot phase
|
|
252
|
+
// ------------------------------------------------------------------
|
|
253
|
+
for (const inst of providerInstances) {
|
|
254
|
+
if (typeof inst.boot === 'function') await inst.boot(container);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const app: NexusApp = {
|
|
258
|
+
name,
|
|
259
|
+
config,
|
|
260
|
+
container,
|
|
261
|
+
router,
|
|
262
|
+
server,
|
|
263
|
+
discovery,
|
|
264
|
+
events,
|
|
265
|
+
use(mw) { server.use(mw); return this; },
|
|
266
|
+
route(def) { registerRoute(router, def, ''); return this; },
|
|
267
|
+
async listen() {
|
|
268
|
+
if (opts.beforeStart) await opts.beforeStart(app);
|
|
269
|
+
await server.listen(config.server.port, config.server.host);
|
|
270
|
+
if (opts.afterStart) await opts.afterStart(app);
|
|
271
|
+
},
|
|
272
|
+
async close() {
|
|
273
|
+
await server.close();
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
return app;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function renderMailTemplate(
|
|
281
|
+
projectRoot: string,
|
|
282
|
+
templateName: string,
|
|
283
|
+
data: Record<string, unknown>,
|
|
284
|
+
): Promise<{ html: string; text?: string }> {
|
|
285
|
+
const candidates = [
|
|
286
|
+
resolve(projectRoot, 'src', 'mail', 'templates', `${templateName}.ejs`),
|
|
287
|
+
resolve(projectRoot, 'src', 'mail', 'templates', `${templateName}.html`),
|
|
288
|
+
resolve(projectRoot, 'mail', 'templates', `${templateName}.ejs`),
|
|
289
|
+
];
|
|
290
|
+
for (const cand of candidates) {
|
|
291
|
+
if (!existsSync(cand)) continue;
|
|
292
|
+
const raw = await readFile(cand, 'utf-8');
|
|
293
|
+
const html = substitute(raw, data);
|
|
294
|
+
return { html, text: stripTags(html) };
|
|
295
|
+
}
|
|
296
|
+
return { html: `<p>${templateName}</p>`, text: templateName };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function substitute(template: string, vars: Record<string, unknown>): string {
|
|
300
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_m, k: string) => {
|
|
301
|
+
const v = vars[k];
|
|
302
|
+
return v === undefined || v === null ? '' : String(v);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function stripTags(html: string): string {
|
|
307
|
+
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function registerRoute(router: Router, def: RouteDef, prefix: string): void {
|
|
311
|
+
const full = normalizePath(prefix, def.path);
|
|
312
|
+
const mw: Middleware[] = [];
|
|
313
|
+
if (def.middleware) mw.push(...def.middleware);
|
|
314
|
+
// upload spec, rateLimit, etc. — wired in later phases
|
|
315
|
+
router.add(def.method, full, def.handler as Handler, mw);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function normalizePath(prefix: string, path: string): string {
|
|
319
|
+
const p = prefix.replace(/\/+$/, '');
|
|
320
|
+
const s = path.startsWith('/') ? path : `/${path}`;
|
|
321
|
+
return p ? `${p}${s}` : s;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export type { RoutesFile, RouteDef } from './defineRoutes.js';
|
|
325
|
+
export { defineRoutes } from './defineRoutes.js';
|