@dtdyq/restbase 2.1.0 → 2.1.1
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/README.md +1 -0
- package/bin/restbase.ts +14 -2
- package/client/package.json +1 -1
- package/client/restbase-client.ts +5 -1
- package/package.json +1 -1
- package/src/auth.ts +11 -6
- package/src/crud.ts +20 -11
- package/src/query.ts +5 -2
- package/src/server.ts +20 -2
- package/src/types.ts +3 -1
package/README.md
CHANGED
|
@@ -107,6 +107,7 @@ bun run build # 编译为当前平台独立二进制
|
|
|
107
107
|
| `SVR_PORT` | 服务端口 | `3333` |
|
|
108
108
|
| `SVR_STATIC` | 静态文件目录(前端托管) | 空 |
|
|
109
109
|
| `SVR_API_LIMIT` | API 限流(每秒每接口请求数) | `100` |
|
|
110
|
+
| `SVR_CORS_ORIGIN` | CORS 允许的来源(逗号分隔,`*`=全部) | `*` |
|
|
110
111
|
| `DB_URL` | 数据库连接串 | `sqlite://:memory:` |
|
|
111
112
|
| `DB_AUTH_TABLE` | 用户表名 | `users` |
|
|
112
113
|
| `DB_AUTH_FIELD` | owner 字段名 | `owner` |
|
package/bin/restbase.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import {spawn, execSync} from "child_process";
|
|
24
24
|
import {
|
|
25
|
+
closeSync,
|
|
25
26
|
existsSync,
|
|
26
27
|
mkdirSync,
|
|
27
28
|
openSync,
|
|
@@ -159,6 +160,10 @@ async function cmdStart() {
|
|
|
159
160
|
});
|
|
160
161
|
child.unref();
|
|
161
162
|
|
|
163
|
+
/* 父进程不再需要这些 fd,关闭防止泄漏 */
|
|
164
|
+
closeSync(out);
|
|
165
|
+
closeSync(err);
|
|
166
|
+
|
|
162
167
|
console.log(`RestBase started (PID: ${child.pid}, port: ${port})`);
|
|
163
168
|
}
|
|
164
169
|
|
|
@@ -372,11 +377,13 @@ function parseEnvFile(path: string): Record<string, string> {
|
|
|
372
377
|
|
|
373
378
|
/** Interactive env — prompt user for each config parameter */
|
|
374
379
|
async function cmdEnv() {
|
|
375
|
-
const
|
|
380
|
+
const nodeEnv = process.env.NODE_ENV;
|
|
381
|
+
const envFile = nodeEnv ? `.env.${nodeEnv}` : ".env";
|
|
382
|
+
const envPath = resolve(process.cwd(), envFile);
|
|
376
383
|
const existing = parseEnvFile(envPath);
|
|
377
384
|
const isUpdate = Object.keys(existing).length > 0;
|
|
378
385
|
|
|
379
|
-
p.intro(isUpdate ?
|
|
386
|
+
p.intro(isUpdate ? `Reconfigure ${envFile} (current values as defaults)` : `Initialize ${envFile} configuration`);
|
|
380
387
|
|
|
381
388
|
/* helper: get default — existing value > built-in default */
|
|
382
389
|
const d = (key: string, fallback: string) => existing[key] ?? fallback;
|
|
@@ -422,6 +429,10 @@ async function cmdEnv() {
|
|
|
422
429
|
message: "Rate limit (max requests per second per API, 0 = off)",
|
|
423
430
|
key: "SVR_API_LIMIT", fallback: "100",
|
|
424
431
|
});
|
|
432
|
+
const SVR_CORS_ORIGIN = await ask({
|
|
433
|
+
message: "CORS allowed origins (comma-separated, * = all)",
|
|
434
|
+
key: "SVR_CORS_ORIGIN", fallback: "*", placeholder: "e.g. https://example.com",
|
|
435
|
+
});
|
|
425
436
|
|
|
426
437
|
// ── Database ──
|
|
427
438
|
p.log.step("Database");
|
|
@@ -514,6 +525,7 @@ async function cmdEnv() {
|
|
|
514
525
|
line("SVR_PORT", SVR_PORT, "Server port"),
|
|
515
526
|
line("SVR_STATIC", SVR_STATIC, "Static file directory"),
|
|
516
527
|
line("SVR_API_LIMIT", SVR_API_LIMIT, "Rate limit per API"),
|
|
528
|
+
line("SVR_CORS_ORIGIN", SVR_CORS_ORIGIN, "CORS origins (* = all)"),
|
|
517
529
|
"",
|
|
518
530
|
"# ── Database ──────────────────────────────────────────────",
|
|
519
531
|
line("DB_URL", DB_URL, ""),
|
package/client/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dtdyq/restbase-client",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "Type-safe, zero-dependency client for RestBase API — works in Browser / Node / Bun / Deno",
|
|
5
5
|
"keywords": ["restbase", "rest", "api", "client", "typescript", "query-builder"],
|
|
6
6
|
"license": "MIT",
|
|
@@ -477,7 +477,11 @@ class HttpClient {
|
|
|
477
477
|
}
|
|
478
478
|
|
|
479
479
|
setBasicAuth(username: string, password: string): void {
|
|
480
|
-
|
|
480
|
+
/* Unicode-safe base64: TextEncoder 处理非 ASCII 字符 */
|
|
481
|
+
const bytes = new TextEncoder().encode(`${username}:${password}`);
|
|
482
|
+
let binary = "";
|
|
483
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
484
|
+
this._basicAuth = btoa(binary);
|
|
481
485
|
this._token = null;
|
|
482
486
|
}
|
|
483
487
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dtdyq/restbase",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "Zero-code REST API server for SQLite / MySQL — CLI with daemon mode, health monitoring, built-in auth & tenant isolation",
|
|
5
5
|
"keywords": ["rest", "api", "crud", "sqlite", "mysql", "hono", "bun", "zero-code", "cli", "daemon"],
|
|
6
6
|
"license": "MIT",
|
package/src/auth.ts
CHANGED
|
@@ -51,7 +51,7 @@ export const authMiddleware = async (
|
|
|
51
51
|
const password = decoded.slice(sep + 1);
|
|
52
52
|
const user = await findUser(username);
|
|
53
53
|
if (user && user.password === password) {
|
|
54
|
-
c.set("userId", user.id
|
|
54
|
+
c.set("userId", user.id);
|
|
55
55
|
c.set("username", username);
|
|
56
56
|
return next();
|
|
57
57
|
}
|
|
@@ -66,14 +66,20 @@ export const authMiddleware = async (
|
|
|
66
66
|
|
|
67
67
|
/* ═══════════ 内部工具 ═══════════ */
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
interface AuthUser {
|
|
70
|
+
id: number;
|
|
71
|
+
username: string;
|
|
72
|
+
password: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function findUser(username: string): Promise<AuthUser | null> {
|
|
70
76
|
const rows = await run(
|
|
71
77
|
`SELECT id, username, password
|
|
72
78
|
FROM ${q(cfg.authTable)}
|
|
73
79
|
WHERE username = $1`,
|
|
74
80
|
[username],
|
|
75
81
|
);
|
|
76
|
-
return rows.length > 0 ? (rows[0] as
|
|
82
|
+
return rows.length > 0 ? (rows[0] as AuthUser) : null;
|
|
77
83
|
}
|
|
78
84
|
|
|
79
85
|
async function issueToken(uid: number, username: string): Promise<string> {
|
|
@@ -114,6 +120,7 @@ export function registerAuthRoutes(app: Hono<AppEnv>) {
|
|
|
114
120
|
[username, password],
|
|
115
121
|
);
|
|
116
122
|
const user = await findUser(username);
|
|
123
|
+
if (!user) throw new AppError("AUTH_ERROR", "Registration failed");
|
|
117
124
|
return c.json(ok(await issueToken(user.id, username)));
|
|
118
125
|
},
|
|
119
126
|
);
|
|
@@ -128,9 +135,7 @@ export function registerAuthRoutes(app: Hono<AppEnv>) {
|
|
|
128
135
|
[userId],
|
|
129
136
|
);
|
|
130
137
|
if (rows.length === 0) throw new AppError("AUTH_ERROR", "User not found");
|
|
131
|
-
const data =
|
|
132
|
-
delete data.id;
|
|
133
|
-
delete data.password;
|
|
138
|
+
const {id: _, password: _pw, ...data} = rows[0] as Record<string, unknown>;
|
|
134
139
|
return c.json(ok(data));
|
|
135
140
|
});
|
|
136
141
|
|
package/src/crud.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
zodHook, bodyQuerySchema, bodyDeleteSchema, bodyDataSchema,
|
|
24
24
|
type BodyQuery, type BodyWhereInput,
|
|
25
25
|
} from "./types.ts";
|
|
26
|
-
import {getTable, isAuthTable, run, type TblMeta} from "./db.ts";
|
|
26
|
+
import {db, getTable, isAuthTable, run, type TblMeta} from "./db.ts";
|
|
27
27
|
import {buildBodyDeleteSQL, buildBodyListSQL, buildDeleteSQL, buildListSQL} from "./query.ts";
|
|
28
28
|
|
|
29
29
|
/* ═══════════ 注册路由 ═══════════ */
|
|
@@ -60,8 +60,7 @@ export function registerCrudRoutes(app: Hono<AppEnv>) {
|
|
|
60
60
|
const body = c.req.valid("json")
|
|
61
61
|
|
|
62
62
|
const {sql, values} = buildBodyDeleteSQL(tbl, body, userId);
|
|
63
|
-
const ids = await
|
|
64
|
-
await run(sql, values);
|
|
63
|
+
const ids = await transactionalDelete(tbl, sql, values);
|
|
65
64
|
return c.json(ok({deleted: ids}));
|
|
66
65
|
});
|
|
67
66
|
|
|
@@ -176,8 +175,7 @@ export function registerCrudRoutes(app: Hono<AppEnv>) {
|
|
|
176
175
|
for (const [k, v] of url.searchParams.entries()) params[k] = v;
|
|
177
176
|
|
|
178
177
|
const {sql, values} = buildDeleteSQL(tbl, params, userId);
|
|
179
|
-
const ids = await
|
|
180
|
-
await run(sql, values);
|
|
178
|
+
const ids = await transactionalDelete(tbl, sql, values);
|
|
181
179
|
return c.json(ok({deleted: ids}));
|
|
182
180
|
});
|
|
183
181
|
|
|
@@ -208,20 +206,31 @@ export function registerCrudRoutes(app: Hono<AppEnv>) {
|
|
|
208
206
|
/* ═══════════ 内部工具 ═══════════ */
|
|
209
207
|
|
|
210
208
|
/**
|
|
211
|
-
*
|
|
209
|
+
* 在事务中先 SELECT 待删除的主键列表,再执行 DELETE,保证一致性。
|
|
212
210
|
* 将 DELETE SQL 改写为 SELECT pk FROM ... WHERE ...
|
|
213
211
|
*/
|
|
214
|
-
async function
|
|
212
|
+
async function transactionalDelete(
|
|
215
213
|
tbl: TblMeta, deleteSql: string, values: unknown[],
|
|
216
214
|
): Promise<unknown[]> {
|
|
217
|
-
if (!tbl.pk)
|
|
218
|
-
|
|
215
|
+
if (!tbl.pk) {
|
|
216
|
+
await run(deleteSql, values);
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
219
|
const selectSql = deleteSql.replace(
|
|
220
220
|
/^DELETE\s+FROM/i,
|
|
221
221
|
`SELECT ${q(tbl.pk)} FROM`,
|
|
222
222
|
);
|
|
223
|
-
|
|
224
|
-
|
|
223
|
+
await db.unsafe("BEGIN");
|
|
224
|
+
try {
|
|
225
|
+
const rows = await run(selectSql, values);
|
|
226
|
+
const ids = rows.map((r: any) => r[tbl.pk!]);
|
|
227
|
+
await run(deleteSql, values);
|
|
228
|
+
await db.unsafe("COMMIT");
|
|
229
|
+
return ids;
|
|
230
|
+
} catch (err) {
|
|
231
|
+
await db.unsafe("ROLLBACK");
|
|
232
|
+
throw err;
|
|
233
|
+
}
|
|
225
234
|
}
|
|
226
235
|
|
|
227
236
|
/** 去掉 owner 字段 */
|
package/src/query.ts
CHANGED
|
@@ -238,10 +238,13 @@ function buildIn(
|
|
|
238
238
|
|
|
239
239
|
/* BETWEEN: 12...20 */
|
|
240
240
|
if (inner.includes("...")) {
|
|
241
|
-
const
|
|
241
|
+
const parts = inner.split("...");
|
|
242
|
+
const lo = parts[0];
|
|
243
|
+
const hi = parts[parts.length - 1];
|
|
244
|
+
if (!lo || !hi) throw new AppError("QUERY_ERROR", "BETWEEN requires two values: lo...hi");
|
|
242
245
|
return {
|
|
243
246
|
sql: `${f} ${not}BETWEEN $${ctx.n++} AND $${ctx.n++}`,
|
|
244
|
-
values: [typed(lo
|
|
247
|
+
values: [typed(lo, col), typed(hi, col)],
|
|
245
248
|
};
|
|
246
249
|
}
|
|
247
250
|
|
package/src/server.ts
CHANGED
|
@@ -51,7 +51,7 @@ const app = new Hono<AppEnv>();
|
|
|
51
51
|
/* ═══════════ 3. CORS 中间件 ═══════════ */
|
|
52
52
|
|
|
53
53
|
app.use("*", cors({
|
|
54
|
-
origin: "*",
|
|
54
|
+
origin: cfg.corsOrigin === "*" ? "*" : cfg.corsOrigin.split(",").map((s) => s.trim()),
|
|
55
55
|
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
56
56
|
allowHeaders: ["Content-Type", "Authorization", "X-Request-Id"],
|
|
57
57
|
exposeHeaders: ["X-Request-Id"],
|
|
@@ -69,8 +69,26 @@ if (cfg.apiLimit > 0) {
|
|
|
69
69
|
/** API 路径 → { tokens: 剩余令牌, lastRefill: 上次填充时间戳 } */
|
|
70
70
|
const buckets = new Map<string, { tokens: number; lastRefill: number }>();
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* 归一化路径:只保留 /api/ 后前两段,截断更深的路径参数。
|
|
74
|
+
* 例如 /api/data/products/123 → /api/data/products
|
|
75
|
+
* 通用规则,新增接口无需修改。
|
|
76
|
+
*/
|
|
77
|
+
function normalizePath(path: string): string {
|
|
78
|
+
const segs = path.split("/"); // ["", "api", "data", "products", "123"]
|
|
79
|
+
return segs.length > 4 ? segs.slice(0, 4).join("/") : path;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 定期清理超过 60 秒未使用的 bucket,防止内存泄漏 */
|
|
83
|
+
setInterval(() => {
|
|
84
|
+
const cutoff = Date.now() - 60_000;
|
|
85
|
+
for (const [key, bucket] of buckets) {
|
|
86
|
+
if (bucket.lastRefill < cutoff) buckets.delete(key);
|
|
87
|
+
}
|
|
88
|
+
}, 60_000).unref();
|
|
89
|
+
|
|
72
90
|
app.use("/api/*", async (c, next) => {
|
|
73
|
-
const key = `${c.req.method} ${c.req.path}`;
|
|
91
|
+
const key = `${c.req.method} ${normalizePath(c.req.path)}`;
|
|
74
92
|
const now = Date.now();
|
|
75
93
|
let bucket = buckets.get(key);
|
|
76
94
|
|
package/src/types.ts
CHANGED
|
@@ -18,7 +18,9 @@ export const cfg = {
|
|
|
18
18
|
/** 静态文件目录(相对路径,为空则不托管) */
|
|
19
19
|
staticDir: Bun.env.SVR_STATIC ?? "",
|
|
20
20
|
/** API 限流:每秒每个 API 接口允许的最大请求数(0 = 不限流) */
|
|
21
|
-
apiLimit: Number(Bun.env.SVR_API_LIMIT)
|
|
21
|
+
apiLimit: Bun.env.SVR_API_LIMIT !== undefined ? Number(Bun.env.SVR_API_LIMIT) : 100,
|
|
22
|
+
/** CORS 允许的来源(默认 *,生产环境建议改为具体域名,逗号分隔多个) */
|
|
23
|
+
corsOrigin: Bun.env.SVR_CORS_ORIGIN ?? "*",
|
|
22
24
|
/** 数据库连接字符串 */
|
|
23
25
|
db: _db,
|
|
24
26
|
/** 是否 SQLite(非 mysql:// 则视为 SQLite) */
|