@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 CHANGED
@@ -1,6 +1,6 @@
1
- {
1
+ {
2
2
  "name": "@bhooai/nexus-core",
3
- "version": "0.1.6",
3
+ "version": "2.0.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -38,3 +38,4 @@
38
38
  "vitest": "^2.1.1"
39
39
  }
40
40
  }
41
+
@@ -0,0 +1,65 @@
1
+ /**
2
+ * ErrorHandler — global exception handler with content-negotiated rendering.
3
+ *
4
+ * Subclass + override `report` and/or `render`. Default behavior:
5
+ * - JSON for Accept: application/json or /api/* paths
6
+ * - HTML error pages otherwise, looked up from errors/pages/<status>.html
7
+ */
8
+ import type { RequestContext } from '../http/context.js';
9
+ import { NexusError, toNexusError } from '../errors.js';
10
+
11
+ export type ErrorPageLookup = (status: number) => Promise<string | null>;
12
+
13
+ export abstract class ErrorHandler {
14
+ /** log/submit the error (default: console). */
15
+ report(err: NexusError): void {
16
+ console.error(`[${err.code}]`, err.message, err.details ?? '');
17
+ }
18
+
19
+ /** Build a custom response body (any return type is serialized). */
20
+ toApiError(err: NexusError): unknown {
21
+ return {
22
+ error: {
23
+ code: err.code,
24
+ message: err.message,
25
+ ...(err.details ? { details: err.details } : {}),
26
+ },
27
+ };
28
+ }
29
+
30
+ /** HTML page lookup. */
31
+ protected async pageFor(lookup: ErrorPageLookup, status: number): Promise<string | null> {
32
+ return lookup(status);
33
+ }
34
+
35
+ /** Render a styled error page; subclass can override. */
36
+ protected renderPage(pageHtml: string, _err: NexusError, _ctx: RequestContext): string {
37
+ return pageHtml;
38
+ }
39
+
40
+ /** Main entry — invoked by the framework when an error escapes handlers. */
41
+ async render(err: unknown, ctx: RequestContext, lookup: ErrorPageLookup): Promise<void> {
42
+ const ne = toNexusError(err);
43
+ this.report(ne);
44
+
45
+ const accept = (ctx.headers.accept as string) ?? '';
46
+ const wantsJson = accept.includes('application/json') || ctx.path.startsWith('/api') || ctx.path.startsWith('/admin');
47
+
48
+ if (wantsJson) {
49
+ ctx.json(this.toApiError(ne), ne.statusCode);
50
+ return;
51
+ }
52
+
53
+ const html = await this.pageFor(lookup, ne.statusCode);
54
+ if (html) {
55
+ ctx.html(this.renderPage(html, ne, ctx), ne.statusCode);
56
+ return;
57
+ }
58
+
59
+ // Fallback: plain-text error.
60
+ ctx.text(`${ne.statusCode} ${ne.code}: ${ne.message}`, ne.statusCode);
61
+ }
62
+ }
63
+
64
+ /** Default framework-provided handler. Subclass + override hooks. */
65
+ export class DefaultErrorHandler extends ErrorHandler {}
@@ -0,0 +1,43 @@
1
+ /**
2
+ * FormRequest — Laravel-style validation + authorization wrapper for routes.
3
+ *
4
+ * Subclass + define `schema` (zod) + optional `authorize(ctx)`. Routes
5
+ * reference the class via `request: CreateUserRequest` in RouteDef; the
6
+ * framework parses the body and exposes validated data on `ctx.validated`.
7
+ *
8
+ * Manual usage:
9
+ * const data = await CreateUserRequest.parse(ctx);
10
+ */
11
+ import type { ZodTypeAny, infer as ZInfer } from 'zod';
12
+ import type { RequestContext } from '../http/context.js';
13
+ import { NexusError } from '../errors.js';
14
+
15
+ export abstract class FormRequest<TSchema extends ZodTypeAny = ZodTypeAny> {
16
+ /** Zod schema applied to ctx.body. */
17
+ abstract readonly schema: TSchema;
18
+
19
+ /** Override to gate the request by authorization. Return false to 403. */
20
+ authorize(_ctx: RequestContext): boolean | Promise<boolean> {
21
+ return true;
22
+ }
23
+
24
+ /** Parse + validate ctx.body against `schema`. Throws 422 on failure. */
25
+ async validate(ctx: RequestContext): Promise<ZInfer<TSchema>> {
26
+ const allowed = await this.authorize(ctx);
27
+ if (!allowed) throw new NexusError('Request not authorized', { code: 'FORBIDDEN', statusCode: 403 });
28
+ const result = this.schema.safeParse(ctx.body);
29
+ if (!result.success) {
30
+ throw new NexusError('Validation failed', { code: 'VALIDATION', statusCode: 422, details: { issues: result.error.issues } });
31
+ }
32
+ return result.data as ZInfer<TSchema>;
33
+ }
34
+ }
35
+
36
+ /** Convenience for routes: parse + validate + return data in one call. */
37
+ export async function validateBody<TSchema extends ZodTypeAny>(
38
+ RequestClass: new () => FormRequest<TSchema>,
39
+ ctx: RequestContext,
40
+ ): Promise<ZInfer<TSchema>> {
41
+ const req = new RequestClass();
42
+ return req.validate(ctx);
43
+ }
package/src/app/Job.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Job + queue abstraction. In-process by default; swap for Redis-backed
3
+ * adapter in production via configureQueue().
4
+ *
5
+ * Usage:
6
+ * class SendWelcomeJob extends Job {
7
+ * queue = 'mail';
8
+ * async handle(user: User) { await WelcomeMail.to(user.email).send(); }
9
+ * }
10
+ * await SendWelcomeJob.dispatch(user);
11
+ */
12
+
13
+ export interface JobPayload {
14
+ id: string;
15
+ queue: string;
16
+ runAt: number;
17
+ attempts: number;
18
+ args: unknown[];
19
+ }
20
+
21
+ export interface JobQueueAdapter {
22
+ enqueue(payload: JobPayload): Promise<void>;
23
+ dequeue(queue: string): Promise<JobPayload | null>;
24
+ /** Mark the job permanently failed. */
25
+ fail(payload: JobPayload, err: unknown): Promise<void>;
26
+ /** Requeue for retry with backoff. */
27
+ retry(payload: JobPayload, err: unknown): Promise<void>;
28
+ }
29
+
30
+ let activeAdapter: JobQueueAdapter | null = null;
31
+
32
+ /** Configure the queue adapter; called by createNexusApp(). */
33
+ export function configureQueue(adapter: JobQueueAdapter): void {
34
+ activeAdapter = adapter;
35
+ }
36
+
37
+ /** Backoff strategy: linear | exponential | fixed. */
38
+ export type BackoffStrategy = 'linear' | 'exponential' | 'fixed';
39
+
40
+ export abstract class Job<TArgs extends unknown[] = unknown[]> {
41
+ /** Queue name (default 'default'). */
42
+ queue: string = 'default';
43
+ /** Max retry attempts before marking failed. */
44
+ retries: number = 3;
45
+ /** Backoff strategy on retry. */
46
+ backoff: BackoffStrategy = 'exponential';
47
+
48
+ /** Handle the job. Throwing triggers retry/failure per `retries`. */
49
+ abstract handle(...args: TArgs): Promise<void>;
50
+
51
+ /** Enqueue this job. */
52
+ static async dispatch<T extends Job>(this: new (...args: never[]) => T, ...args: unknown[]): Promise<void> {
53
+ if (!activeAdapter) throw new Error('Queue adapter not configured — call configureQueue() during boot');
54
+ const instance = new this();
55
+ const payload: JobPayload = {
56
+ id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
57
+ queue: instance.queue,
58
+ runAt: Date.now(),
59
+ attempts: 0,
60
+ args: args,
61
+ };
62
+ await activeAdapter.enqueue(payload);
63
+ }
64
+ }
65
+
66
+ /** Simple in-memory queue adapter — usable for dev and tests, NOT production. */
67
+ export class InMemoryQueueAdapter implements JobQueueAdapter {
68
+ private queues = new Map<string, JobPayload[]>();
69
+ private dead: JobPayload[] = [];
70
+
71
+ async enqueue(payload: JobPayload): Promise<void> {
72
+ if (!this.queues.has(payload.queue)) this.queues.set(payload.queue, []);
73
+ this.queues.get(payload.queue)!.push(payload);
74
+ }
75
+
76
+ async dequeue(queue: string): Promise<JobPayload | null> {
77
+ const list = this.queues.get(queue);
78
+ if (!list || list.length === 0) return null;
79
+ const due = list.findIndex((p) => p.runAt <= Date.now());
80
+ if (due === -1) return null;
81
+ const [payload] = list.splice(due, 1);
82
+ return payload ?? null;
83
+ }
84
+
85
+ async fail(payload: JobPayload): Promise<void> {
86
+ this.dead.push(payload);
87
+ }
88
+
89
+ async retry(payload: JobPayload): Promise<void> {
90
+ const next: JobPayload = { ...payload, attempts: payload.attempts + 1, runAt: Date.now() + 1000 * (payload.attempts + 1) };
91
+ await this.enqueue(next);
92
+ }
93
+
94
+ /** Peek dead letters. */
95
+ deadLetter(): JobPayload[] {
96
+ return [...this.dead];
97
+ }
98
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Mailable — declarative email class. Template lives in
3
+ * `mail/templates/<name>.ejs`; mailable class lives in
4
+ * `mail/mailables/<name>Mail.ts`.
5
+ *
6
+ * class WelcomeMail extends Mailable {
7
+ * subject = 'Welcome';
8
+ * template = 'welcome';
9
+ * constructor(public user: User) { super(); }
10
+ * data() { return { name: this.user.name }; }
11
+ * }
12
+ *
13
+ * await WelcomeMail.to('a@b.com').send();
14
+ * await WelcomeMail.to('a@b.com').queue();
15
+ */
16
+
17
+ export interface MailContext {
18
+ to: string;
19
+ subject: string;
20
+ template: string;
21
+ data: Record<string, unknown>;
22
+ }
23
+
24
+ export interface MailDriver {
25
+ /** Send synchronously. Returns a provider id on success. */
26
+ send(payload: MailContext & { html: string; text?: string }): Promise<{ id: string }>;
27
+ }
28
+
29
+ let activeDriver: MailDriver | null = null;
30
+ let templateRenderer: ((name: string, data: Record<string, unknown>) => Promise<{ html: string; text?: string }>) | null = null;
31
+ let queueDispatcher: ((payload: { mailable: string; to: string; data: Record<string, unknown> }) => Promise<void>) | null = null;
32
+ const registry = new Map<string, new (...args: never[]) => Mailable>();
33
+
34
+ /** Configure the active mail driver. Called during boot. */
35
+ export function configureMailDriver(driver: MailDriver): void {
36
+ activeDriver = driver;
37
+ }
38
+
39
+ /** Configure the template renderer. Called during boot by createNexusApp(). */
40
+ export function configureMailRenderer(
41
+ renderer: (name: string, data: Record<string, unknown>) => Promise<{ html: string; text?: string }>,
42
+ ): void {
43
+ templateRenderer = renderer;
44
+ }
45
+
46
+ /** Wire mailable-queue dispatch (used by `.queue()`). */
47
+ export function configureMailQueue(
48
+ dispatch: (payload: { mailable: string; to: string; data: Record<string, unknown> }) => Promise<void>,
49
+ ): void {
50
+ queueDispatcher = dispatch;
51
+ }
52
+
53
+ /** Register a Mailable class under its `template` name (for queue deserialization). */
54
+ export function registerMailable(name: string, ctor: new (...args: never[]) => Mailable): void {
55
+ registry.set(name, ctor);
56
+ }
57
+
58
+ export abstract class Mailable {
59
+ /** Subject line. Override in subclass. */
60
+ abstract subject: string;
61
+ /** Template name (no extension) — file lives at mail/templates/<name>.ejs. */
62
+ abstract template: string;
63
+
64
+ protected toAddress: string | null = null;
65
+
66
+ /** Static entry point. */
67
+ static to<T extends Mailable>(this: new (...args: never[]) => T, address: string, ...args: unknown[]): T {
68
+ const ctor = this as unknown as new (...args: unknown[]) => T;
69
+ const inst = new ctor(...args);
70
+ inst.toAddress = address;
71
+ return inst;
72
+ }
73
+
74
+ /** Subclass hook: data passed to template. */
75
+ data(): Record<string, unknown> {
76
+ return {};
77
+ }
78
+
79
+ /** Send immediately via the active driver. */
80
+ async send(): Promise<{ id: string }> {
81
+ if (!activeDriver) throw new Error('Mail driver not configured');
82
+ if (!templateRenderer) throw new Error('Mail renderer not configured');
83
+ if (!this.toAddress) throw new Error('Mailable missing to-address — use Mailable.to(...)');
84
+ const rendered = await templateRenderer(this.template, this.data());
85
+ const name = (this as { constructor: { name: string } }).constructor.name;
86
+ registerMailable(name, this.constructor as new (...args: never[]) => Mailable);
87
+ return activeDriver.send({
88
+ to: this.toAddress,
89
+ subject: this.subject,
90
+ template: this.template,
91
+ data: this.data(),
92
+ html: rendered.html,
93
+ ...(rendered.text ? { text: rendered.text } : {}),
94
+ });
95
+ }
96
+
97
+ /** Enqueue via the configured queue dispatcher. */
98
+ async queue(): Promise<void> {
99
+ if (!queueDispatcher) throw new Error('Mail queue not configured');
100
+ if (!this.toAddress) throw new Error('Mailable missing to-address — use Mailable.to(...)');
101
+ const name = (this as { constructor: { name: string } }).constructor.name;
102
+ registerMailable(name, this.constructor as new (...args: never[]) => Mailable);
103
+ await queueDispatcher({ mailable: name, to: this.toAddress, data: this.data() });
104
+ }
105
+ }
106
+
107
+ /** Console logging driver (dev). */
108
+ export const logMailDriver: MailDriver = {
109
+ async send(payload) {
110
+ console.log(`[mail] → ${payload.to} :: ${payload.subject}`);
111
+ if (process.env.NEXUS_MAIL_VERBOSE) console.log(payload.html);
112
+ return { id: `log-${Date.now()}` };
113
+ },
114
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Resource — API response transformer. Wraps a domain object as a
3
+ * versioned JSON-friendly shape, with helpers for single + collection.
4
+ */
5
+
6
+ export abstract class Resource<T = unknown> {
7
+ constructor(protected value: T) {}
8
+
9
+ /** Convert underlying value → JSON-friendly object. Override in subclass. */
10
+ abstract toJSON(value: T, ctx?: unknown): unknown;
11
+
12
+ /** Include a field conditionally. */
13
+ protected when<TVal>(condition: boolean, v: TVal): TVal | undefined {
14
+ return condition ? v : undefined;
15
+ }
16
+
17
+ /** Include a field only when present. */
18
+ protected whenPresent<TVal>(v: TVal | null | undefined): TVal | undefined {
19
+ return v === null || v === undefined ? undefined : v;
20
+ }
21
+
22
+ /** Build the JSON payload. */
23
+ serialize(ctx?: unknown): unknown {
24
+ return this.toJSON(this.value, ctx);
25
+ }
26
+
27
+ /** Static convenience: serialize a single value. */
28
+ static make<T, R extends Resource<T>>(this: new (v: T) => R, value: T, ctx?: unknown): unknown {
29
+ const r = new this(value);
30
+ return r.serialize(ctx);
31
+ }
32
+
33
+ /** Static convenience: serialize a collection. */
34
+ static collection<T, R extends Resource<T>>(this: new (v: T) => R, values: T[], ctx?: unknown): unknown[] {
35
+ return values.map((v) => new this(v).serialize(ctx));
36
+ }
37
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Seeder — database seeding. Auto-discovered from database/seeds/.
3
+ *
4
+ * Run via `nexus db:seed` (CLI) or programmatically:
5
+ * const seeds = await loadSeeders('database/seeds');
6
+ * for (const S of seeds) await new S().run();
7
+ */
8
+
9
+ export abstract class Seeder {
10
+ /** Seed name (defaults to class name). */
11
+ get name(): string {
12
+ return (this as { constructor: { name: string } }).constructor.name;
13
+ }
14
+
15
+ /** Override — perform the seeding. */
16
+ abstract run(): Promise<void>;
17
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * ServiceProvider — Laravel-style registration + boot for DI.
3
+ *
4
+ * register(container) — wire singletons into the container
5
+ * boot(container) — async init after all providers registered
6
+ *
7
+ * Discovered automatically from `providers/*.ts` by `createNexusApp()`.
8
+ */
9
+ import type { Container } from '../di/Container.js';
10
+
11
+ export abstract class ServiceProvider {
12
+ /** Wire singletons. */
13
+ register(_container: Container): void {}
14
+ /** Post-registration init. */
15
+ async boot(_container: Container): Promise<void> {}
16
+ }