@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 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 envPath = resolve(process.cwd(), ".env");
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 ? "Reconfigure .env (current values as defaults)" : "Initialize .env configuration");
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, ""),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtdyq/restbase-client",
3
- "version": "2.1.0",
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
- this._basicAuth = btoa(`${username}:${password}`);
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.0",
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 as number);
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
- async function findUser(username: string) {
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 any) : null;
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 = {...(rows[0] as any)};
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 collectDeletedIds(tbl, sql, values);
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 collectDeletedIds(tbl, sql, values);
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
- * 在执行 DELETE 之前,先用相同 WHERE 条件 SELECT 出即将被删的主键列表。
209
+ * 在事务中先 SELECT 待删除的主键列表,再执行 DELETE,保证一致性。
212
210
  * 将 DELETE SQL 改写为 SELECT pk FROM ... WHERE ...
213
211
  */
214
- async function collectDeletedIds(
212
+ async function transactionalDelete(
215
213
  tbl: TblMeta, deleteSql: string, values: unknown[],
216
214
  ): Promise<unknown[]> {
217
- if (!tbl.pk) return [];
218
- /* "DELETE ... FROM ..." 替换为 "SELECT pk FROM ..."(SQL 模板可能含换行) */
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
- const rows = await run(selectSql, values);
224
- return rows.map((r: any) => r[tbl.pk!]);
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 [lo, hi] = inner.split("...");
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!, col), typed(hi!, col)],
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) || 100,
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) */