@nexa-stack/framework 1.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/.env.example +46 -0
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/bin/nexa.mjs +41 -0
- package/bin/nexa.ts +334 -0
- package/docs/AI.md +69 -0
- package/docs/ARCHITECTURE.md +74 -0
- package/docs/EXAMPLES.md +114 -0
- package/docs/FRAMEWORK.md +226 -0
- package/docs/LANGUAGE.md +39 -0
- package/docs/README.md +7 -0
- package/docs/READY.md +51 -0
- package/docs/REFERENCE.md +255 -0
- package/docs/START.md +98 -0
- package/docs/advanced.md +97 -0
- package/docs/authentication.md +54 -0
- package/docs/cli.md +15 -0
- package/docs/compare.md +51 -0
- package/docs/configuration.md +65 -0
- package/docs/database.md +396 -0
- package/docs/installation.md +57 -0
- package/docs/localization.md +47 -0
- package/docs/resources.md +75 -0
- package/docs/routing.md +59 -0
- package/docs/seeding.md +33 -0
- package/docs/services.md +146 -0
- package/package.json +77 -0
- package/packages/auth/src/auth.test.ts +23 -0
- package/packages/auth/src/auth.ts +287 -0
- package/packages/auth/src/index.ts +17 -0
- package/packages/cache/src/index.ts +203 -0
- package/packages/client/src/index.ts +49 -0
- package/packages/core/src/app.ts +97 -0
- package/packages/core/src/config.ts +55 -0
- package/packages/core/src/dev.ts +104 -0
- package/packages/core/src/fields.test.ts +81 -0
- package/packages/core/src/fields.ts +309 -0
- package/packages/core/src/index.ts +60 -0
- package/packages/core/src/lang.ts +79 -0
- package/packages/core/src/loader.ts +42 -0
- package/packages/core/src/migrate.ts +91 -0
- package/packages/core/src/policy.ts +36 -0
- package/packages/core/src/registry.ts +17 -0
- package/packages/core/src/reload.ts +92 -0
- package/packages/core/src/resource.test.ts +32 -0
- package/packages/core/src/resource.ts +87 -0
- package/packages/core/src/routes.ts +22 -0
- package/packages/core/src/runtime.ts +11 -0
- package/packages/database/src/builder.ts +266 -0
- package/packages/database/src/database.ts +252 -0
- package/packages/database/src/dialect.ts +186 -0
- package/packages/database/src/index.ts +6 -0
- package/packages/database/src/mysql.ts +114 -0
- package/packages/database/src/postgres.ts +117 -0
- package/packages/database/src/query.ts +115 -0
- package/packages/database/src/sqlite.ts +216 -0
- package/packages/database/src/types.ts +104 -0
- package/packages/events/src/index.ts +17 -0
- package/packages/export/src/index.ts +36 -0
- package/packages/log/src/index.ts +38 -0
- package/packages/mail/src/index.ts +130 -0
- package/packages/notifications/src/index.ts +84 -0
- package/packages/plugins/src/index.ts +39 -0
- package/packages/queue/src/index.ts +185 -0
- package/packages/queue/src/jobs.ts +9 -0
- package/packages/schedule/src/index.ts +64 -0
- package/packages/server/src/index.ts +1 -0
- package/packages/server/src/middleware.ts +143 -0
- package/packages/server/src/query.ts +40 -0
- package/packages/server/src/router.ts +813 -0
- package/packages/sms/src/index.ts +33 -0
- package/packages/storage/src/upload.ts +36 -0
- package/packages/testing/src/index.ts +67 -0
- package/packages/validation/src/index.ts +1 -0
- package/packages/validation/src/validate.test.ts +35 -0
- package/packages/validation/src/validate.ts +112 -0
- package/public/admin.html +369 -0
- package/public/compare.html +66 -0
- package/public/dev-bar.js +213 -0
- package/public/docs.html +315 -0
- package/public/index.html +66 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Database } from "../../database/src/database.js";
|
|
2
|
+
import { notificationsTableSql } from "../../database/src/dialect.js";
|
|
3
|
+
import { mail } from "../../mail/src/index.js";
|
|
4
|
+
import { log } from "../../log/src/index.js";
|
|
5
|
+
|
|
6
|
+
export interface Notifiable {
|
|
7
|
+
id: number;
|
|
8
|
+
email?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface NotificationPayload {
|
|
12
|
+
type?: string;
|
|
13
|
+
title: string;
|
|
14
|
+
body?: string;
|
|
15
|
+
data?: Record<string, unknown>;
|
|
16
|
+
/** channels: database (default) + mail */
|
|
17
|
+
via?: ("database" | "mail")[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let dbRef: Database | null = null;
|
|
21
|
+
|
|
22
|
+
export async function setupNotifications(db: Database): Promise<void> {
|
|
23
|
+
dbRef = db;
|
|
24
|
+
await db.run(notificationsTableSql(db.dialect));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function notify(
|
|
28
|
+
user: Notifiable | number,
|
|
29
|
+
payload: NotificationPayload
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
if (!dbRef) throw new Error("Call setupNotifications() first");
|
|
32
|
+
|
|
33
|
+
const userId = typeof user === "number" ? user : user.id;
|
|
34
|
+
const email = typeof user === "number" ? undefined : user.email;
|
|
35
|
+
const via = payload.via ?? ["database"];
|
|
36
|
+
const type = payload.type ?? "app";
|
|
37
|
+
|
|
38
|
+
if (via.includes("database")) {
|
|
39
|
+
await dbRef.insert("notifications", {
|
|
40
|
+
user_id: userId,
|
|
41
|
+
type,
|
|
42
|
+
title: payload.title,
|
|
43
|
+
body: payload.body ?? "",
|
|
44
|
+
data: JSON.stringify(payload.data ?? {}),
|
|
45
|
+
read_at: null,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (via.includes("mail") && email) {
|
|
50
|
+
await mail(email, payload.title, payload.body ?? payload.title);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
log.info(`Notify user#${userId}: ${payload.title}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function unreadNotifications(userId: number): Promise<Record<string, unknown>[]> {
|
|
57
|
+
if (!dbRef) return [];
|
|
58
|
+
return dbRef.query(
|
|
59
|
+
`SELECT * FROM notifications WHERE user_id = ? AND read_at IS NULL ORDER BY id DESC LIMIT 50`,
|
|
60
|
+
[userId]
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function markNotificationRead(id: number, userId: number): Promise<boolean> {
|
|
65
|
+
if (!dbRef) return false;
|
|
66
|
+
const row = await dbRef.getOne(
|
|
67
|
+
`SELECT id FROM notifications WHERE id = ? AND user_id = ?`,
|
|
68
|
+
[id, userId]
|
|
69
|
+
);
|
|
70
|
+
if (!row) return false;
|
|
71
|
+
await dbRef.run(`UPDATE notifications SET read_at = ? WHERE id = ?`, [
|
|
72
|
+
new Date().toISOString(),
|
|
73
|
+
id,
|
|
74
|
+
]);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function markAllRead(userId: number): Promise<void> {
|
|
79
|
+
if (!dbRef) return;
|
|
80
|
+
await dbRef.run(
|
|
81
|
+
`UPDATE notifications SET read_at = ? WHERE user_id = ? AND read_at IS NULL`,
|
|
82
|
+
[new Date().toISOString(), userId]
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
type Hook = (ctx: Record<string, unknown>) => void | Promise<void>;
|
|
2
|
+
|
|
3
|
+
export interface Plugin {
|
|
4
|
+
name: string;
|
|
5
|
+
boot?: () => void | Promise<void>;
|
|
6
|
+
hooks?: Record<string, Hook>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const plugins: Plugin[] = [];
|
|
10
|
+
const hooks = new Map<string, Hook[]>();
|
|
11
|
+
|
|
12
|
+
export function plugin(p: Plugin): void {
|
|
13
|
+
plugins.push(p);
|
|
14
|
+
if (p.hooks) {
|
|
15
|
+
for (const [name, fn] of Object.entries(p.hooks)) {
|
|
16
|
+
if (!hooks.has(name)) hooks.set(name, []);
|
|
17
|
+
hooks.get(name)!.push(fn);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function bootPlugins(): Promise<void> {
|
|
23
|
+
for (const p of plugins) {
|
|
24
|
+
if (p.boot) await p.boot();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function hook(name: string, ctx: Record<string, unknown> = {}): Promise<void> {
|
|
29
|
+
for (const fn of hooks.get(name) ?? []) await fn(ctx);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function listPlugins(): string[] {
|
|
33
|
+
return plugins.map((p) => p.name);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function clearPlugins(): void {
|
|
37
|
+
plugins.length = 0;
|
|
38
|
+
hooks.clear();
|
|
39
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { Database } from "../../database/src/database.js";
|
|
2
|
+
import { emit } from "../../events/src/index.js";
|
|
3
|
+
import { jobsTableSql } from "../../database/src/dialect.js";
|
|
4
|
+
import { randomUUID } from "crypto";
|
|
5
|
+
|
|
6
|
+
export interface JobRow {
|
|
7
|
+
id: number;
|
|
8
|
+
name: string;
|
|
9
|
+
payload: string;
|
|
10
|
+
status: string;
|
|
11
|
+
attempts?: number;
|
|
12
|
+
max_tries?: number;
|
|
13
|
+
available_at?: string | null;
|
|
14
|
+
locked_by?: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const workerId = process.env.NEXA_WORKER_ID || `w-${randomUUID().slice(0, 8)}`;
|
|
18
|
+
|
|
19
|
+
export async function setupQueue(db: Database): Promise<void> {
|
|
20
|
+
await db.run(jobsTableSql(db.dialect));
|
|
21
|
+
await ensureJobColumns(db);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function ensureJobColumns(db: Database): Promise<void> {
|
|
25
|
+
const cols = await db.listColumns("_nexa_jobs");
|
|
26
|
+
const have = new Set(cols.map((c) => c.toLowerCase()));
|
|
27
|
+
const add = async (name: string, sqlType: string) => {
|
|
28
|
+
if (have.has(name)) return;
|
|
29
|
+
try {
|
|
30
|
+
await db.run(`ALTER TABLE _nexa_jobs ADD COLUMN ${name} ${sqlType}`);
|
|
31
|
+
have.add(name);
|
|
32
|
+
} catch {
|
|
33
|
+
/* exists */
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const intType = "INTEGER";
|
|
37
|
+
const textType = db.dialect === "postgres" ? "TIMESTAMPTZ" : "TEXT";
|
|
38
|
+
await add("attempts", `${intType} DEFAULT 0`);
|
|
39
|
+
await add("max_tries", `${intType} DEFAULT 3`);
|
|
40
|
+
await add("available_at", textType);
|
|
41
|
+
await add("locked_by", "TEXT");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function dispatch(
|
|
45
|
+
db: Database,
|
|
46
|
+
name: string,
|
|
47
|
+
payload: unknown,
|
|
48
|
+
opts?: { maxTries?: number; delayMs?: number }
|
|
49
|
+
): Promise<number> {
|
|
50
|
+
const available =
|
|
51
|
+
opts?.delayMs && opts.delayMs > 0
|
|
52
|
+
? new Date(Date.now() + opts.delayMs).toISOString()
|
|
53
|
+
: new Date().toISOString();
|
|
54
|
+
const row = await db.insert("_nexa_jobs", {
|
|
55
|
+
name,
|
|
56
|
+
payload: JSON.stringify(payload),
|
|
57
|
+
status: "pending",
|
|
58
|
+
attempts: 0,
|
|
59
|
+
max_tries: opts?.maxTries ?? 3,
|
|
60
|
+
available_at: available,
|
|
61
|
+
locked_by: null,
|
|
62
|
+
});
|
|
63
|
+
return Number(row.id);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { registerBuiltInJobs } from "./jobs.js";
|
|
67
|
+
|
|
68
|
+
function backoffMs(attempts: number): number {
|
|
69
|
+
return Math.min(60_000, 1000 * 2 ** Math.max(0, attempts - 1));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Atomically claim pending jobs — safe for multiple workers */
|
|
73
|
+
async function claimJobs(db: Database, limit: number): Promise<JobRow[]> {
|
|
74
|
+
const now = new Date().toISOString();
|
|
75
|
+
|
|
76
|
+
if (db.dialect === "postgres") {
|
|
77
|
+
try {
|
|
78
|
+
return (await db.query(
|
|
79
|
+
`
|
|
80
|
+
UPDATE _nexa_jobs SET status = 'processing', locked_by = ?
|
|
81
|
+
WHERE id IN (
|
|
82
|
+
SELECT id FROM _nexa_jobs
|
|
83
|
+
WHERE status = 'pending'
|
|
84
|
+
AND (available_at IS NULL OR available_at <= ?)
|
|
85
|
+
ORDER BY id ASC
|
|
86
|
+
LIMIT ?
|
|
87
|
+
FOR UPDATE SKIP LOCKED
|
|
88
|
+
)
|
|
89
|
+
RETURNING *
|
|
90
|
+
`,
|
|
91
|
+
[workerId, now, limit]
|
|
92
|
+
)) as JobRow[];
|
|
93
|
+
} catch {
|
|
94
|
+
/* fall through to portable claim */
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const candidates = (await db.query(
|
|
99
|
+
`SELECT id FROM _nexa_jobs
|
|
100
|
+
WHERE status = 'pending'
|
|
101
|
+
AND (available_at IS NULL OR available_at <= ?)
|
|
102
|
+
ORDER BY id ASC LIMIT ?`,
|
|
103
|
+
[now, limit]
|
|
104
|
+
)) as { id: number }[];
|
|
105
|
+
|
|
106
|
+
const claimed: JobRow[] = [];
|
|
107
|
+
for (const c of candidates) {
|
|
108
|
+
await db.run(
|
|
109
|
+
`UPDATE _nexa_jobs SET status = 'processing', locked_by = ?
|
|
110
|
+
WHERE id = ? AND status = 'pending'`,
|
|
111
|
+
[workerId, c.id]
|
|
112
|
+
);
|
|
113
|
+
const row = (await db.getOne(
|
|
114
|
+
`SELECT * FROM _nexa_jobs WHERE id = ? AND status = 'processing' AND locked_by = ?`,
|
|
115
|
+
[c.id, workerId]
|
|
116
|
+
)) as JobRow | null;
|
|
117
|
+
if (row) claimed.push(row);
|
|
118
|
+
}
|
|
119
|
+
return claimed;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function runOne(db: Database, job: JobRow): Promise<boolean> {
|
|
123
|
+
const attempts = Number(job.attempts ?? 0) + 1;
|
|
124
|
+
const maxTries = Number(job.max_tries ?? 3);
|
|
125
|
+
try {
|
|
126
|
+
const payload = JSON.parse(job.payload);
|
|
127
|
+
await emit(job.name, payload);
|
|
128
|
+
await db.run(
|
|
129
|
+
`UPDATE _nexa_jobs SET status = 'done', attempts = ?, locked_by = NULL WHERE id = ?`,
|
|
130
|
+
[attempts, job.id]
|
|
131
|
+
);
|
|
132
|
+
return true;
|
|
133
|
+
} catch {
|
|
134
|
+
if (attempts < maxTries) {
|
|
135
|
+
const next = new Date(Date.now() + backoffMs(attempts)).toISOString();
|
|
136
|
+
await db.run(
|
|
137
|
+
`UPDATE _nexa_jobs SET status = 'pending', attempts = ?, available_at = ?, locked_by = NULL WHERE id = ?`,
|
|
138
|
+
[attempts, next, job.id]
|
|
139
|
+
);
|
|
140
|
+
} else {
|
|
141
|
+
await db.run(
|
|
142
|
+
`UPDATE _nexa_jobs SET status = 'failed', attempts = ?, locked_by = NULL WHERE id = ?`,
|
|
143
|
+
[attempts, job.id]
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function processJobs(
|
|
151
|
+
db: Database,
|
|
152
|
+
limit = 10,
|
|
153
|
+
concurrency = Number(process.env.QUEUE_CONCURRENCY || 1)
|
|
154
|
+
): Promise<number> {
|
|
155
|
+
const jobs = await claimJobs(db, limit);
|
|
156
|
+
if (jobs.length === 0) return 0;
|
|
157
|
+
|
|
158
|
+
const conc = Math.max(1, Math.min(concurrency, jobs.length));
|
|
159
|
+
let done = 0;
|
|
160
|
+
|
|
161
|
+
for (let i = 0; i < jobs.length; i += conc) {
|
|
162
|
+
const batch = jobs.slice(i, i + conc);
|
|
163
|
+
const results = await Promise.all(batch.map((job) => runOne(db, job)));
|
|
164
|
+
done += results.filter(Boolean).length;
|
|
165
|
+
}
|
|
166
|
+
return done;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Long-running worker loop for multi-instance deploys */
|
|
170
|
+
export async function workQueue(
|
|
171
|
+
db: Database,
|
|
172
|
+
opts: { limit?: number; concurrency?: number; idleMs?: number; once?: boolean } = {}
|
|
173
|
+
): Promise<void> {
|
|
174
|
+
const limit = opts.limit ?? 10;
|
|
175
|
+
const concurrency = opts.concurrency ?? Number(process.env.QUEUE_CONCURRENCY || 2);
|
|
176
|
+
const idleMs = opts.idleMs ?? 1000;
|
|
177
|
+
for (;;) {
|
|
178
|
+
const n = await processJobs(db, limit, concurrency);
|
|
179
|
+
if (opts.once) return;
|
|
180
|
+
if (n === 0) {
|
|
181
|
+
const { sleep } = await import("../../core/src/runtime.js");
|
|
182
|
+
await sleep(idleMs);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { on } from "../../events/src/index.js";
|
|
2
|
+
import { sendMail, type MailMessage } from "../../mail/src/index.js";
|
|
3
|
+
|
|
4
|
+
/** Register built-in queue job handlers (mail, etc.) */
|
|
5
|
+
export function registerBuiltInJobs(): void {
|
|
6
|
+
on("mail.send", async (payload) => {
|
|
7
|
+
await sendMail(payload as MailMessage);
|
|
8
|
+
});
|
|
9
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
type TaskFn = () => void | Promise<void>;
|
|
2
|
+
|
|
3
|
+
export interface ScheduledTask {
|
|
4
|
+
name: string;
|
|
5
|
+
intervalMs: number;
|
|
6
|
+
fn: TaskFn;
|
|
7
|
+
lastRun?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const tasks: ScheduledTask[] = [];
|
|
11
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
12
|
+
|
|
13
|
+
function add(name: string, intervalMs: number, fn: TaskFn): void {
|
|
14
|
+
tasks.push({ name, intervalMs, fn });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const schedule = {
|
|
18
|
+
everyMinute(fn: TaskFn, name = "everyMinute"): void {
|
|
19
|
+
add(name, 60_000, fn);
|
|
20
|
+
},
|
|
21
|
+
everyFiveMinutes(fn: TaskFn, name = "everyFiveMinutes"): void {
|
|
22
|
+
add(name, 5 * 60_000, fn);
|
|
23
|
+
},
|
|
24
|
+
everyHour(fn: TaskFn, name = "everyHour"): void {
|
|
25
|
+
add(name, 60 * 60_000, fn);
|
|
26
|
+
},
|
|
27
|
+
daily(fn: TaskFn, name = "daily"): void {
|
|
28
|
+
add(name, 24 * 60 * 60_000, fn);
|
|
29
|
+
},
|
|
30
|
+
every(seconds: number, fn: TaskFn, name?: string): void {
|
|
31
|
+
add(name ?? `every_${seconds}s`, seconds * 1000, fn);
|
|
32
|
+
},
|
|
33
|
+
list(): ScheduledTask[] {
|
|
34
|
+
return [...tasks];
|
|
35
|
+
},
|
|
36
|
+
clear(): void {
|
|
37
|
+
tasks.length = 0;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export async function runDueTasks(now = Date.now()): Promise<number> {
|
|
42
|
+
let ran = 0;
|
|
43
|
+
for (const task of tasks) {
|
|
44
|
+
if (task.lastRun && now - task.lastRun < task.intervalMs) continue;
|
|
45
|
+
await task.fn();
|
|
46
|
+
task.lastRun = now;
|
|
47
|
+
ran++;
|
|
48
|
+
}
|
|
49
|
+
return ran;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Keep process alive and run tasks on interval (for `nexa schedule:work`) */
|
|
53
|
+
export function startScheduler(tickMs = 30_000): void {
|
|
54
|
+
if (timer) return;
|
|
55
|
+
timer = setInterval(() => {
|
|
56
|
+
runDueTasks().catch((err) => console.error("schedule error", err));
|
|
57
|
+
}, tickMs);
|
|
58
|
+
console.log(`Scheduler running (${tasks.length} task(s), tick ${tickMs}ms)`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function stopScheduler(): void {
|
|
62
|
+
if (timer) clearInterval(timer);
|
|
63
|
+
timer = null;
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Router, createServer } from "./router.js";
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { AuthUser } from "../../auth/src/auth.js";
|
|
2
|
+
import { config } from "../../core/src/config.js";
|
|
3
|
+
|
|
4
|
+
export type Middleware = (
|
|
5
|
+
req: Request,
|
|
6
|
+
user: AuthUser | null,
|
|
7
|
+
next: () => Promise<Response>
|
|
8
|
+
) => Promise<Response> | Response;
|
|
9
|
+
|
|
10
|
+
const stack: Middleware[] = [];
|
|
11
|
+
|
|
12
|
+
export function use(mw: Middleware): void {
|
|
13
|
+
stack.push(mw);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function clearMiddleware(): void {
|
|
17
|
+
stack.length = 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getMiddleware(): Middleware[] {
|
|
21
|
+
return [...stack];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function runMiddleware(
|
|
25
|
+
req: Request,
|
|
26
|
+
user: AuthUser | null,
|
|
27
|
+
final: () => Promise<Response>
|
|
28
|
+
): Promise<Response> {
|
|
29
|
+
let i = 0;
|
|
30
|
+
const dispatch = async (): Promise<Response> => {
|
|
31
|
+
if (i >= stack.length) return final();
|
|
32
|
+
const mw = stack[i++];
|
|
33
|
+
return mw(req, user, dispatch);
|
|
34
|
+
};
|
|
35
|
+
return dispatch();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function clientKey(req: Request): string {
|
|
39
|
+
const ip =
|
|
40
|
+
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
41
|
+
req.headers.get("x-real-ip") ||
|
|
42
|
+
"local";
|
|
43
|
+
return `${ip}:${new URL(req.url).pathname}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** In-memory rate limit (dev / single process) */
|
|
47
|
+
function memoryRateLimit(limit: number, windowMs: number): Middleware {
|
|
48
|
+
const hits = new Map<string, { count: number; reset: number }>();
|
|
49
|
+
return (req, _user, next) => {
|
|
50
|
+
const key = clientKey(req);
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
let entry = hits.get(key);
|
|
53
|
+
if (!entry || now > entry.reset) {
|
|
54
|
+
entry = { count: 0, reset: now + windowMs };
|
|
55
|
+
hits.set(key, entry);
|
|
56
|
+
}
|
|
57
|
+
entry.count++;
|
|
58
|
+
if (entry.count > limit) {
|
|
59
|
+
return Response.json(
|
|
60
|
+
{ error: "Too many requests" },
|
|
61
|
+
{
|
|
62
|
+
status: 429,
|
|
63
|
+
headers: { "Retry-After": String(Math.ceil((entry.reset - now) / 1000)) },
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return next();
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Redis atomic INCR rate limit — multi-instance safe */
|
|
72
|
+
function redisRateLimit(limit: number, windowMs: number): Middleware {
|
|
73
|
+
return async (req, _user, next) => {
|
|
74
|
+
try {
|
|
75
|
+
const { cache } = await import("../../cache/src/index.js");
|
|
76
|
+
const key = `rl:${clientKey(req)}`;
|
|
77
|
+
const windowSec = Math.max(1, Math.ceil(windowMs / 1000));
|
|
78
|
+
const current = await cache.incr(key, windowSec);
|
|
79
|
+
if (current > limit) {
|
|
80
|
+
return Response.json(
|
|
81
|
+
{ error: "Too many requests" },
|
|
82
|
+
{ status: 429, headers: { "Retry-After": String(windowSec) } }
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return next();
|
|
86
|
+
} catch {
|
|
87
|
+
return memoryRateLimit(limit, windowMs)(req, _user, next);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Rate limit: N requests per windowMs per IP+path.
|
|
94
|
+
* Uses Redis when RATE_LIMIT_DRIVER=redis or CACHE_DRIVER=redis.
|
|
95
|
+
*/
|
|
96
|
+
export function rateLimit(limit = 60, windowMs = 60_000): Middleware {
|
|
97
|
+
const driver = (config("RATE_LIMIT_DRIVER") || config("CACHE_DRIVER") || "memory").toLowerCase();
|
|
98
|
+
if (driver === "redis") return redisRateLimit(limit, windowMs);
|
|
99
|
+
return memoryRateLimit(limit, windowMs);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const authHits = new Map<string, { count: number; reset: number }>();
|
|
103
|
+
|
|
104
|
+
/** Returns 429 when an auth POST endpoint is over limit, else null. */
|
|
105
|
+
export function checkAuthRateLimit(req: Request): Response | null {
|
|
106
|
+
const limit = Number(config("AUTH_RATE_LIMIT") || 10);
|
|
107
|
+
if (limit <= 0) return null;
|
|
108
|
+
const path = new URL(req.url).pathname;
|
|
109
|
+
if (!path.startsWith("/api/auth/") || req.method !== "POST") return null;
|
|
110
|
+
|
|
111
|
+
const key = clientKey(req);
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
let entry = authHits.get(key);
|
|
114
|
+
if (!entry || now > entry.reset) {
|
|
115
|
+
entry = { count: 0, reset: now + 60_000 };
|
|
116
|
+
authHits.set(key, entry);
|
|
117
|
+
}
|
|
118
|
+
entry.count++;
|
|
119
|
+
if (entry.count > limit) {
|
|
120
|
+
return Response.json(
|
|
121
|
+
{ error: "Too many requests" },
|
|
122
|
+
{
|
|
123
|
+
status: 429,
|
|
124
|
+
headers: { "Retry-After": String(Math.ceil((entry.reset - now) / 1000)) },
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function cors(origins = "*"): Middleware {
|
|
132
|
+
return async (req, _user, next) => {
|
|
133
|
+
const res = await next();
|
|
134
|
+
const headers = new Headers(res.headers);
|
|
135
|
+
headers.set("Access-Control-Allow-Origin", origins);
|
|
136
|
+
headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
137
|
+
headers.set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
|
|
138
|
+
if (req.method === "OPTIONS") {
|
|
139
|
+
return new Response(null, { status: 204, headers });
|
|
140
|
+
}
|
|
141
|
+
return new Response(res.body, { status: res.status, headers });
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ResourceDefinition } from "../../core/src/resource.js";
|
|
2
|
+
import { fieldColumn } from "../../core/src/fields.js";
|
|
3
|
+
import type { QueryOpts } from "../../database/src/query.js";
|
|
4
|
+
|
|
5
|
+
const IDENT_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
6
|
+
|
|
7
|
+
export function parseListQuery(url: URL, resource: ResourceDefinition): QueryOpts {
|
|
8
|
+
const page = Math.max(1, Number(url.searchParams.get("page") ?? 1));
|
|
9
|
+
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get("limit") ?? 20)));
|
|
10
|
+
const offset = (page - 1) * limit;
|
|
11
|
+
const search = url.searchParams.get("search") ?? undefined;
|
|
12
|
+
const order = url.searchParams.get("order") === "asc" ? "asc" : "desc";
|
|
13
|
+
|
|
14
|
+
const allowedCols = new Set<string>(["id", "created_at", "updated_at"]);
|
|
15
|
+
if (resource.softDelete) allowedCols.add("deleted_at");
|
|
16
|
+
|
|
17
|
+
const filters: Record<string, string> = {};
|
|
18
|
+
for (const [name, field] of Object.entries(resource.fields)) {
|
|
19
|
+
const col = fieldColumn(name, field);
|
|
20
|
+
if (!col || !IDENT_RE.test(col)) continue;
|
|
21
|
+
allowedCols.add(col);
|
|
22
|
+
const val = url.searchParams.get(name) ?? url.searchParams.get(col);
|
|
23
|
+
if (val) filters[col] = val;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let sort = url.searchParams.get("sort") ?? undefined;
|
|
27
|
+
if (sort && (!IDENT_RE.test(sort) || !allowedCols.has(sort))) {
|
|
28
|
+
sort = undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let softDelete: QueryOpts["softDelete"];
|
|
32
|
+
if (resource.softDelete) {
|
|
33
|
+
const trashed = url.searchParams.get("trashed");
|
|
34
|
+
if (trashed === "only") softDelete = "only";
|
|
35
|
+
else if (trashed === "with") softDelete = "with";
|
|
36
|
+
else softDelete = "exclude";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { limit, offset, search, sort, order, filters, softDelete };
|
|
40
|
+
}
|