@dtdyq/restbase 2.0.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 +18 -23
- package/bin/restbase.ts +309 -191
- package/client/README.md +35 -13
- package/client/package.json +1 -1
- package/client/restbase-client.ts +44 -11
- package/package.json +3 -1
- package/src/auth.ts +11 -6
- package/src/crud.ts +20 -11
- package/src/logger.ts +4 -1
- package/src/query.ts +5 -2
- package/src/server.ts +23 -3
- package/src/types.ts +3 -1
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ bun install -g @dtdyq/restbase
|
|
|
24
24
|
安装后在任意目录使用 `restbase` 命令。所有配置通过 `.env` 文件读取:
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
|
-
#
|
|
27
|
+
# 交互式配置 .env(已存在则用当前值作为默认)
|
|
28
28
|
restbase env
|
|
29
29
|
|
|
30
30
|
# 前台启动(读取当前目录 .env)
|
|
@@ -36,11 +36,11 @@ restbase start
|
|
|
36
36
|
# 查看运行中的实例(含健康状态)
|
|
37
37
|
restbase status
|
|
38
38
|
|
|
39
|
-
#
|
|
40
|
-
restbase log <pid>
|
|
39
|
+
# 查看某个实例的实时日志(支持 PID 或 SVR_NAME)
|
|
40
|
+
restbase log <pid|name>
|
|
41
41
|
|
|
42
|
-
#
|
|
43
|
-
restbase stop <pid>
|
|
42
|
+
# 停止(支持 PID、SVR_NAME 或 all)
|
|
43
|
+
restbase stop <pid|name> # 停止指定实例
|
|
44
44
|
restbase stop all # 停止所有实例
|
|
45
45
|
```
|
|
46
46
|
|
|
@@ -60,9 +60,9 @@ bun run src/server.ts
|
|
|
60
60
|
### 验证
|
|
61
61
|
|
|
62
62
|
```bash
|
|
63
|
-
# 健康检查(返回实例状态 +
|
|
63
|
+
# 健康检查(返回实例状态 + 资源占用 + cwd + logFile)
|
|
64
64
|
curl http://localhost:3333/api/health
|
|
65
|
-
# → { "code": "OK", "data": { "status": "healthy", "port": 3333, "pid": 12345, "uptime": 60, "memory": {...}, "cpu": {...} } }
|
|
65
|
+
# → { "code": "OK", "data": { "status": "healthy", "port": 3333, "pid": 12345, "cwd": "/path/to/dir", "uptime": 60, "memory": {...}, "cpu": {...} } }
|
|
66
66
|
|
|
67
67
|
# Basic Auth 查询
|
|
68
68
|
curl -u admin:admin http://localhost:3333/api/data/products
|
|
@@ -82,6 +82,10 @@ import RestBase, { gt } from "@dtdyq/restbase-client";
|
|
|
82
82
|
const rb = new RestBase(); // 同源部署不传参
|
|
83
83
|
await rb.auth.login("admin", "admin");
|
|
84
84
|
const data = await rb.table("products").query().where(gt("price", 100)).data();
|
|
85
|
+
|
|
86
|
+
// 多节点负载均衡(所有节点连同一 DB)
|
|
87
|
+
const rb2 = new RestBase(["http://node1:3333", "http://node2:3333"]);
|
|
88
|
+
await rb2.auth.login("admin", "admin"); // 一次登录,token 共享
|
|
85
89
|
```
|
|
86
90
|
|
|
87
91
|
详见 [client/README.md](client/README.md)。
|
|
@@ -103,6 +107,7 @@ bun run build # 编译为当前平台独立二进制
|
|
|
103
107
|
| `SVR_PORT` | 服务端口 | `3333` |
|
|
104
108
|
| `SVR_STATIC` | 静态文件目录(前端托管) | 空 |
|
|
105
109
|
| `SVR_API_LIMIT` | API 限流(每秒每接口请求数) | `100` |
|
|
110
|
+
| `SVR_CORS_ORIGIN` | CORS 允许的来源(逗号分隔,`*`=全部) | `*` |
|
|
106
111
|
| `DB_URL` | 数据库连接串 | `sqlite://:memory:` |
|
|
107
112
|
| `DB_AUTH_TABLE` | 用户表名 | `users` |
|
|
108
113
|
| `DB_AUTH_FIELD` | owner 字段名 | `owner` |
|
|
@@ -139,28 +144,18 @@ restbase <command> [arguments]
|
|
|
139
144
|
Commands:
|
|
140
145
|
run 前台启动服务(读取当前目录 .env)
|
|
141
146
|
start 后台启动(daemon 模式)
|
|
142
|
-
stop <pid|all>
|
|
143
|
-
status
|
|
144
|
-
log <pid>
|
|
145
|
-
env
|
|
147
|
+
stop <pid|name|all> 停止后台实例(支持 PID、SVR_NAME 或 all)
|
|
148
|
+
status 查看运行中的实例(含健康检查)
|
|
149
|
+
log <pid|name> 实时查看实例日志(支持 PID 或 SVR_NAME)
|
|
150
|
+
env 交互式 .env 配置(创建或重新配置)
|
|
146
151
|
version 显示版本号
|
|
147
152
|
help 显示帮助
|
|
148
153
|
```
|
|
149
154
|
|
|
150
155
|
所有配置通过 `.env` 文件管理,可在 `.env` 中设置 `SVR_NAME` 为实例命名。
|
|
151
156
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
```
|
|
155
|
-
~/.restbase/
|
|
156
|
-
├── instances/ # 实例元数据(name/port/pid/logPath)
|
|
157
|
-
│ ├── 12345.json
|
|
158
|
-
│ └── 23456.json
|
|
159
|
-
└── logs/ # daemon 默认日志目录
|
|
160
|
-
├── 3333.log
|
|
161
|
-
├── 3333.out
|
|
162
|
-
└── 8080.log
|
|
163
|
-
```
|
|
157
|
+
实例管理完全无状态:通过 `ps` 自动发现运行中的实例,通过 `/api/health` 获取实例详情。
|
|
158
|
+
daemon 模式默认日志存放在 `~/.restbase/logs/`。
|
|
164
159
|
|
|
165
160
|
## 文件结构
|
|
166
161
|
|
package/bin/restbase.ts
CHANGED
|
@@ -1,91 +1,74 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* bin/restbase.ts — CLI entry point
|
|
3
|
+
* bin/restbase.ts — CLI entry point (stateless management)
|
|
4
4
|
*
|
|
5
5
|
* Commands:
|
|
6
6
|
* restbase run Start server in foreground (reads .env)
|
|
7
7
|
* restbase start Start server in background (daemon)
|
|
8
|
-
* restbase stop <pid|all>
|
|
9
|
-
* restbase status
|
|
10
|
-
* restbase log <pid>
|
|
8
|
+
* restbase stop <pid|name|all> Stop instance(s) by PID, name or all
|
|
9
|
+
* restbase status Show running instances (table + health check)
|
|
10
|
+
* restbase log <pid|name> Tail log of a background instance
|
|
11
11
|
* restbase env Generate .env template in current directory
|
|
12
12
|
* restbase version Show version
|
|
13
13
|
* restbase help Show help
|
|
14
14
|
*
|
|
15
15
|
* All configuration is read from .env in the current working directory.
|
|
16
|
-
*
|
|
16
|
+
*
|
|
17
|
+
* Instance discovery is stateless:
|
|
18
|
+
* - `restbase start` spawns the child with a `--port=N` marker arg
|
|
19
|
+
* - Management commands use `ps` to find processes matching `restbase.*--port=`
|
|
20
|
+
* - Detailed info (name, logFile, cwd, uptime…) is fetched via /api/health
|
|
17
21
|
*/
|
|
18
22
|
|
|
19
|
-
import {spawn} from "child_process";
|
|
23
|
+
import {spawn, execSync} from "child_process";
|
|
20
24
|
import {
|
|
25
|
+
closeSync,
|
|
21
26
|
existsSync,
|
|
22
27
|
mkdirSync,
|
|
23
|
-
readFileSync,
|
|
24
|
-
unlinkSync,
|
|
25
|
-
readdirSync,
|
|
26
28
|
openSync,
|
|
27
29
|
writeFileSync,
|
|
28
30
|
} from "fs";
|
|
29
31
|
import {resolve, join} from "path";
|
|
32
|
+
import {readFileSync} from "fs";
|
|
30
33
|
import {homedir} from "os";
|
|
34
|
+
import * as p from "@clack/prompts";
|
|
31
35
|
|
|
32
36
|
/* ═══════════ Global paths ═══════════ */
|
|
33
37
|
|
|
34
38
|
const RESTBASE_HOME = resolve(homedir(), ".restbase");
|
|
35
|
-
const INSTANCES_DIR = join(RESTBASE_HOME, "instances");
|
|
36
39
|
const LOGS_DIR = join(RESTBASE_HOME, "logs");
|
|
37
40
|
|
|
38
|
-
function
|
|
39
|
-
mkdirSync(INSTANCES_DIR, {recursive: true});
|
|
41
|
+
function ensureLogDir() {
|
|
40
42
|
mkdirSync(LOGS_DIR, {recursive: true});
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
/* ═══════════
|
|
45
|
+
/* ═══════════ Process discovery via ps ═══════════ */
|
|
44
46
|
|
|
45
|
-
interface
|
|
47
|
+
interface PsInstance {
|
|
46
48
|
pid: number;
|
|
47
|
-
name: string;
|
|
48
49
|
port: number;
|
|
49
|
-
logPath: string;
|
|
50
|
-
cwd: string;
|
|
51
|
-
startedAt: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function instanceFile(pid: number) {
|
|
55
|
-
return join(INSTANCES_DIR, `${pid}.json`);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function saveInstance(meta: InstanceMeta) {
|
|
59
|
-
writeFileSync(instanceFile(meta.pid), JSON.stringify(meta, null, 2));
|
|
60
50
|
}
|
|
61
51
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Scan running processes for `restbase.*--port=N` and extract PID + port.
|
|
54
|
+
* Works on macOS and Linux.
|
|
55
|
+
*/
|
|
56
|
+
function discoverInstances(): PsInstance[] {
|
|
65
57
|
try {
|
|
66
|
-
|
|
58
|
+
const out = execSync("ps -eo pid,args", {encoding: "utf-8"});
|
|
59
|
+
const results: PsInstance[] = [];
|
|
60
|
+
for (const line of out.split("\n")) {
|
|
61
|
+
const match = line.match(/^\s*(\d+)\s+.*restbase.*--port=(\d+)/);
|
|
62
|
+
if (match) {
|
|
63
|
+
results.push({pid: Number(match[1]), port: Number(match[2])});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return results;
|
|
67
67
|
} catch {
|
|
68
|
-
return
|
|
68
|
+
return [];
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
function loadAllInstances(): InstanceMeta[] {
|
|
73
|
-
ensureDirs();
|
|
74
|
-
const files = readdirSync(INSTANCES_DIR).filter((f) => f.endsWith(".json"));
|
|
75
|
-
const result: InstanceMeta[] = [];
|
|
76
|
-
for (const f of files) {
|
|
77
|
-
try {
|
|
78
|
-
result.push(JSON.parse(readFileSync(join(INSTANCES_DIR, f), "utf-8")));
|
|
79
|
-
} catch { /* corrupted, skip */ }
|
|
80
|
-
}
|
|
81
|
-
return result;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function removeInstance(pid: number) {
|
|
85
|
-
const f = instanceFile(pid);
|
|
86
|
-
if (existsSync(f)) unlinkSync(f);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
72
|
/* ═══════════ Process helpers ═══════════ */
|
|
90
73
|
|
|
91
74
|
function isAlive(pid: number): boolean {
|
|
@@ -102,6 +85,7 @@ interface HealthInfo {
|
|
|
102
85
|
name?: string;
|
|
103
86
|
port?: number;
|
|
104
87
|
pid?: number;
|
|
88
|
+
cwd?: string;
|
|
105
89
|
logFile?: string;
|
|
106
90
|
startedAt?: string;
|
|
107
91
|
uptime?: number;
|
|
@@ -141,31 +125,25 @@ async function cmdRun() {
|
|
|
141
125
|
|
|
142
126
|
/** start — daemon mode */
|
|
143
127
|
async function cmdStart() {
|
|
144
|
-
|
|
128
|
+
ensureLogDir();
|
|
145
129
|
|
|
146
130
|
const port = Number(process.env.SVR_PORT) || 3333;
|
|
147
|
-
const name = process.env.SVR_NAME || "";
|
|
148
131
|
|
|
149
132
|
// Reject if this port is already running
|
|
150
|
-
for (const inst of
|
|
151
|
-
if (inst.port === port
|
|
133
|
+
for (const inst of discoverInstances()) {
|
|
134
|
+
if (inst.port === port) {
|
|
152
135
|
console.log(`RestBase already running on port ${port} (PID: ${inst.pid})`);
|
|
153
136
|
return;
|
|
154
137
|
}
|
|
155
138
|
}
|
|
156
139
|
|
|
157
|
-
//
|
|
158
|
-
const logPath = process.env.LOG_FILE
|
|
159
|
-
? resolve(process.cwd(), process.env.LOG_FILE)
|
|
160
|
-
: join(LOGS_DIR, `${port}.log`);
|
|
161
|
-
|
|
162
|
-
// Build child env: silence console, ensure LOG_FILE
|
|
140
|
+
// Build child env: silence console, ensure LOG_FILE for daemon
|
|
163
141
|
const env: Record<string, string> = {
|
|
164
142
|
...(process.env as Record<string, string>),
|
|
165
143
|
LOG_CONSOLE: "false",
|
|
166
144
|
};
|
|
167
145
|
if (!process.env.LOG_FILE) {
|
|
168
|
-
env.LOG_FILE =
|
|
146
|
+
env.LOG_FILE = join(LOGS_DIR, `${port}.log`);
|
|
169
147
|
}
|
|
170
148
|
|
|
171
149
|
// stdout/stderr → separate crash log (safety net for non-pino output)
|
|
@@ -174,7 +152,7 @@ async function cmdStart() {
|
|
|
174
152
|
const err = openSync(stdoutPath, "a");
|
|
175
153
|
|
|
176
154
|
const self = getSelfCommand();
|
|
177
|
-
const child = spawn(self[0]!, [...self.slice(1), "run"], {
|
|
155
|
+
const child = spawn(self[0]!, [...self.slice(1), "run", `--port=${port}`], {
|
|
178
156
|
detached: true,
|
|
179
157
|
stdio: ["ignore", out, err],
|
|
180
158
|
env,
|
|
@@ -182,52 +160,65 @@ async function cmdStart() {
|
|
|
182
160
|
});
|
|
183
161
|
child.unref();
|
|
184
162
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
name,
|
|
189
|
-
port,
|
|
190
|
-
logPath,
|
|
191
|
-
cwd: process.cwd(),
|
|
192
|
-
startedAt: new Date().toISOString(),
|
|
193
|
-
});
|
|
163
|
+
/* 父进程不再需要这些 fd,关闭防止泄漏 */
|
|
164
|
+
closeSync(out);
|
|
165
|
+
closeSync(err);
|
|
194
166
|
|
|
195
167
|
console.log(`RestBase started (PID: ${child.pid}, port: ${port})`);
|
|
196
168
|
}
|
|
197
169
|
|
|
198
|
-
/**
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
170
|
+
/**
|
|
171
|
+
* Resolve a target identifier to matching instances.
|
|
172
|
+
* Supports: "all", numeric PID, or SVR_NAME (fetched from health endpoint).
|
|
173
|
+
*/
|
|
174
|
+
async function resolveInstances(target: string): Promise<{ pid: number; port: number; name?: string }[]> {
|
|
175
|
+
const all = discoverInstances();
|
|
176
|
+
if (all.length === 0) return [];
|
|
177
|
+
if (target === "all") return all;
|
|
178
|
+
|
|
179
|
+
// Try numeric PID first
|
|
180
|
+
const num = parseInt(target, 10);
|
|
181
|
+
if (!isNaN(num)) {
|
|
182
|
+
const byPid = all.find((i) => i.pid === num);
|
|
183
|
+
if (byPid) return [byPid];
|
|
184
|
+
// Maybe it's a port number?
|
|
185
|
+
const byPort = all.find((i) => i.port === num);
|
|
186
|
+
if (byPort) return [byPort];
|
|
187
|
+
return [];
|
|
208
188
|
}
|
|
209
189
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
190
|
+
// Otherwise treat as SVR_NAME — need to fetch health for each to match
|
|
191
|
+
const matched: { pid: number; port: number; name?: string }[] = [];
|
|
192
|
+
for (const inst of all) {
|
|
193
|
+
const health = await fetchHealth(inst.port);
|
|
194
|
+
if (health?.name === target) {
|
|
195
|
+
matched.push({...inst, name: health.name});
|
|
196
|
+
}
|
|
214
197
|
}
|
|
215
|
-
|
|
198
|
+
return matched;
|
|
216
199
|
}
|
|
217
200
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
201
|
+
/** stop — by PID, name, or "all" */
|
|
202
|
+
async function cmdStop(target: string) {
|
|
203
|
+
const instances = await resolveInstances(target);
|
|
204
|
+
if (instances.length === 0) {
|
|
205
|
+
console.log(target === "all" ? "No RestBase instances found" : `No instance found matching "${target}"`);
|
|
222
206
|
return;
|
|
223
207
|
}
|
|
208
|
+
for (const inst of instances) stopOne(inst.pid, inst.port, inst.name);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function stopOne(pid: number, port?: number, name?: string) {
|
|
212
|
+
const label = [name, port ? `port ${port}` : ""].filter(Boolean).join(", ");
|
|
224
213
|
try {
|
|
225
|
-
if (isAlive(pid))
|
|
226
|
-
|
|
227
|
-
|
|
214
|
+
if (isAlive(pid)) {
|
|
215
|
+
process.kill(pid, "SIGTERM");
|
|
216
|
+
console.log(`Stopped PID ${pid}${label ? ` (${label})` : ""}`);
|
|
217
|
+
} else {
|
|
218
|
+
console.log(`Process ${pid} is not running`);
|
|
219
|
+
}
|
|
228
220
|
} catch {
|
|
229
|
-
|
|
230
|
-
console.log(`Process ${pid} already gone, cleaned up`);
|
|
221
|
+
console.log(`Failed to stop PID ${pid}`);
|
|
231
222
|
}
|
|
232
223
|
}
|
|
233
224
|
|
|
@@ -271,7 +262,7 @@ function fmtTime(iso: string): string {
|
|
|
271
262
|
|
|
272
263
|
/** status — table with health check */
|
|
273
264
|
async function cmdStatus() {
|
|
274
|
-
const instances =
|
|
265
|
+
const instances = discoverInstances();
|
|
275
266
|
if (instances.length === 0) {
|
|
276
267
|
console.log("No RestBase instances found");
|
|
277
268
|
return;
|
|
@@ -294,24 +285,18 @@ async function cmdStatus() {
|
|
|
294
285
|
console.log("─".repeat(nW + pW + sW + ptW + stW + uW + mW + cW + 30));
|
|
295
286
|
|
|
296
287
|
for (const inst of instances) {
|
|
297
|
-
// Dead process → clean up silently
|
|
298
|
-
if (!isAlive(inst.pid)) {
|
|
299
|
-
removeInstance(inst.pid);
|
|
300
|
-
continue;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
288
|
// Fetch live info from health endpoint
|
|
304
289
|
const health = await fetchHealth(inst.port);
|
|
305
290
|
|
|
306
|
-
const name = health?.name ||
|
|
291
|
+
const name = health?.name || "-";
|
|
307
292
|
const state = health?.status ?? "unreachable";
|
|
308
|
-
const started = health?.startedAt ? fmtTime(health.startedAt) :
|
|
293
|
+
const started = health?.startedAt ? fmtTime(health.startedAt) : "-";
|
|
309
294
|
const uptime = health?.uptime !== undefined ? fmtUptime(health.uptime) : "-";
|
|
310
295
|
const mem = health?.memory ? fmtBytes(health.memory.rss) : "-";
|
|
311
296
|
const cpu = (health?.cpu && health?.uptime)
|
|
312
297
|
? fmtCpu(health.cpu.user, health.cpu.system, health.uptime)
|
|
313
298
|
: "-";
|
|
314
|
-
const logPath = health?.logFile ||
|
|
299
|
+
const logPath = health?.logFile || "-";
|
|
315
300
|
|
|
316
301
|
console.log(
|
|
317
302
|
name.padEnd(nW) +
|
|
@@ -327,55 +312,36 @@ async function cmdStatus() {
|
|
|
327
312
|
}
|
|
328
313
|
}
|
|
329
314
|
|
|
330
|
-
/**
|
|
331
|
-
* Find the latest log file matching the base path.
|
|
332
|
-
* pino-roll creates files like: app.2026-02-11.1.log (date + sequence inserted before extension)
|
|
333
|
-
* So for base "log/app.log", we look for "app*.log" in "log/".
|
|
334
|
-
*/
|
|
335
|
-
function findLatestLog(basePath: string): string | null {
|
|
336
|
-
// If exact file exists, use it
|
|
337
|
-
if (existsSync(basePath)) return basePath;
|
|
338
|
-
|
|
339
|
-
const dir = resolve(basePath, "..");
|
|
340
|
-
if (!existsSync(dir)) return null;
|
|
341
|
-
|
|
342
|
-
const base = basePath.split("/").pop()!; // "app.log"
|
|
343
|
-
const dot = base.lastIndexOf(".");
|
|
344
|
-
const stem = dot > 0 ? base.slice(0, dot) : base; // "app"
|
|
345
|
-
const ext = dot > 0 ? base.slice(dot) : ""; // ".log"
|
|
346
|
-
|
|
347
|
-
// Find all matching files, sort by mtime descending
|
|
348
|
-
const files = readdirSync(dir)
|
|
349
|
-
.filter((f) => f.startsWith(stem) && f.endsWith(ext) && f !== base)
|
|
350
|
-
.map((f) => ({name: f, mtime: Bun.file(join(dir, f)).lastModified}))
|
|
351
|
-
.sort((a, b) => b.mtime - a.mtime);
|
|
352
|
-
|
|
353
|
-
return files.length > 0 ? join(dir, files[0]!.name) : null;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/** log — tail -f the log file of an instance */
|
|
315
|
+
/** log — tail -f the log file of an instance (by PID or name) */
|
|
357
316
|
async function cmdLog(target: string) {
|
|
358
|
-
const
|
|
359
|
-
if (
|
|
360
|
-
console.error(`
|
|
317
|
+
const instances = await resolveInstances(target);
|
|
318
|
+
if (instances.length === 0) {
|
|
319
|
+
console.error(`No running RestBase instance found matching "${target}"`);
|
|
320
|
+
process.exit(1);
|
|
321
|
+
}
|
|
322
|
+
if (instances.length > 1) {
|
|
323
|
+
console.error(`Multiple instances match "${target}". Use PID to be specific:`);
|
|
324
|
+
for (const i of instances) console.error(` PID ${i.pid} (port ${i.port})`);
|
|
361
325
|
process.exit(1);
|
|
362
326
|
}
|
|
363
327
|
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
328
|
+
const inst = instances[0]!;
|
|
329
|
+
|
|
330
|
+
// Fetch log file path from health endpoint (points to current.log symlink)
|
|
331
|
+
const health = await fetchHealth(inst.port);
|
|
332
|
+
if (!health?.logFile) {
|
|
333
|
+
console.error(`Cannot determine log file for PID ${inst.pid} (health endpoint unreachable or logFile not configured)`);
|
|
367
334
|
process.exit(1);
|
|
368
335
|
}
|
|
369
336
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
console.error(`Log file not found for base path: ${meta.logPath}`);
|
|
337
|
+
if (!existsSync(health.logFile)) {
|
|
338
|
+
console.error(`Log file not found: ${health.logFile}`);
|
|
373
339
|
process.exit(1);
|
|
374
340
|
}
|
|
375
341
|
|
|
376
|
-
console.log(`Tailing ${logFile} (Ctrl+C to stop)\n`);
|
|
342
|
+
console.log(`Tailing ${health.logFile} (Ctrl+C to stop)\n`);
|
|
377
343
|
|
|
378
|
-
const tail = spawn("tail", ["-f", "-n", "100", logFile], {
|
|
344
|
+
const tail = spawn("tail", ["-f", "-n", "100", health.logFile], {
|
|
379
345
|
stdio: "inherit",
|
|
380
346
|
});
|
|
381
347
|
|
|
@@ -387,54 +353,202 @@ async function cmdLog(target: string) {
|
|
|
387
353
|
await new Promise<void>((resolve) => tail.on("close", () => resolve()));
|
|
388
354
|
}
|
|
389
355
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
356
|
+
/* ═══════════ Interactive env configuration ═══════════ */
|
|
357
|
+
|
|
358
|
+
/** Parse an existing .env file into a key→value map */
|
|
359
|
+
function parseEnvFile(path: string): Record<string, string> {
|
|
360
|
+
const map: Record<string, string> = {};
|
|
361
|
+
if (!existsSync(path)) return map;
|
|
362
|
+
for (const line of readFileSync(path, "utf-8").split("\n")) {
|
|
363
|
+
const trimmed = line.trim();
|
|
364
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
365
|
+
const eq = trimmed.indexOf("=");
|
|
366
|
+
if (eq < 0) continue;
|
|
367
|
+
const key = trimmed.slice(0, eq).trim();
|
|
368
|
+
let val = trimmed.slice(eq + 1).trim();
|
|
369
|
+
// strip surrounding quotes
|
|
370
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
371
|
+
val = val.slice(1, -1);
|
|
372
|
+
}
|
|
373
|
+
map[key] = val;
|
|
397
374
|
}
|
|
375
|
+
return map;
|
|
376
|
+
}
|
|
398
377
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
378
|
+
/** Interactive env — prompt user for each config parameter */
|
|
379
|
+
async function cmdEnv() {
|
|
380
|
+
const nodeEnv = process.env.NODE_ENV;
|
|
381
|
+
const envFile = nodeEnv ? `.env.${nodeEnv}` : ".env";
|
|
382
|
+
const envPath = resolve(process.cwd(), envFile);
|
|
383
|
+
const existing = parseEnvFile(envPath);
|
|
384
|
+
const isUpdate = Object.keys(existing).length > 0;
|
|
385
|
+
|
|
386
|
+
p.intro(isUpdate ? `Reconfigure ${envFile} (current values as defaults)` : `Initialize ${envFile} configuration`);
|
|
387
|
+
|
|
388
|
+
/* helper: get default — existing value > built-in default */
|
|
389
|
+
const d = (key: string, fallback: string) => existing[key] ?? fallback;
|
|
390
|
+
|
|
391
|
+
/* helper: wrap clack text — returns string (empty if user skipped) */
|
|
392
|
+
const ask = async (opts: { message: string; key: string; fallback: string; placeholder?: string }) => {
|
|
393
|
+
const result = await p.text({
|
|
394
|
+
message: opts.message,
|
|
395
|
+
initialValue: d(opts.key, opts.fallback),
|
|
396
|
+
placeholder: opts.placeholder,
|
|
397
|
+
});
|
|
398
|
+
if (p.isCancel(result)) { p.cancel("Cancelled"); process.exit(0); }
|
|
399
|
+
return (result as string).trim();
|
|
400
|
+
};
|
|
406
401
|
|
|
407
|
-
|
|
402
|
+
/* helper: select */
|
|
403
|
+
const choose = async (opts: { message: string; key: string; fallback: string; options: { value: string; label: string }[] }) => {
|
|
404
|
+
const result = await p.select({
|
|
405
|
+
message: opts.message,
|
|
406
|
+
initialValue: d(opts.key, opts.fallback),
|
|
407
|
+
options: opts.options,
|
|
408
|
+
});
|
|
409
|
+
if (p.isCancel(result)) { p.cancel("Cancelled"); process.exit(0); }
|
|
410
|
+
return result as string;
|
|
411
|
+
};
|
|
408
412
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
# SVR_STATIC= # Static file directory for SPA hosting
|
|
412
|
-
# SVR_API_LIMIT=100 # Rate limit: max requests per second per API
|
|
413
|
+
// ── Server ──
|
|
414
|
+
p.log.step("Server");
|
|
413
415
|
|
|
414
|
-
|
|
416
|
+
const SVR_NAME = await ask({
|
|
417
|
+
message: "Instance name (shown in 'restbase status')",
|
|
418
|
+
key: "SVR_NAME", fallback: "", placeholder: "optional",
|
|
419
|
+
});
|
|
420
|
+
const SVR_PORT = await ask({
|
|
421
|
+
message: "Server port",
|
|
422
|
+
key: "SVR_PORT", fallback: "3333",
|
|
423
|
+
});
|
|
424
|
+
const SVR_STATIC = await ask({
|
|
425
|
+
message: "Static file directory for SPA hosting",
|
|
426
|
+
key: "SVR_STATIC", fallback: "", placeholder: "optional, e.g. dist",
|
|
427
|
+
});
|
|
428
|
+
const SVR_API_LIMIT = await ask({
|
|
429
|
+
message: "Rate limit (max requests per second per API, 0 = off)",
|
|
430
|
+
key: "SVR_API_LIMIT", fallback: "100",
|
|
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
|
+
});
|
|
415
436
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
# DB_AUTH_FIELD=owner # Owner field name for tenant isolation
|
|
419
|
-
# DB_AUTH_FIELD_NULL_OPEN=false # Treat owner=NULL rows as public data
|
|
420
|
-
# DB_INIT_SQL= # SQL file to run on startup
|
|
437
|
+
// ── Database ──
|
|
438
|
+
p.log.step("Database");
|
|
421
439
|
|
|
422
|
-
|
|
440
|
+
const DB_URL = await ask({
|
|
441
|
+
message: "Database URL (sqlite://<path> or mysql://user:pass@host/db)",
|
|
442
|
+
key: "DB_URL", fallback: "sqlite://:memory:",
|
|
443
|
+
});
|
|
444
|
+
const DB_AUTH_TABLE = await ask({
|
|
445
|
+
message: "Auth table name",
|
|
446
|
+
key: "DB_AUTH_TABLE", fallback: "users",
|
|
447
|
+
});
|
|
448
|
+
const DB_AUTH_FIELD = await ask({
|
|
449
|
+
message: "Owner field name (tenant isolation)",
|
|
450
|
+
key: "DB_AUTH_FIELD", fallback: "owner",
|
|
451
|
+
});
|
|
452
|
+
const DB_AUTH_FIELD_NULL_OPEN = await choose({
|
|
453
|
+
message: "Treat owner=NULL rows as public data?",
|
|
454
|
+
key: "DB_AUTH_FIELD_NULL_OPEN", fallback: "false",
|
|
455
|
+
options: [
|
|
456
|
+
{value: "false", label: "false — NULL rows are hidden"},
|
|
457
|
+
{value: "true", label: "true — NULL rows are visible to everyone"},
|
|
458
|
+
],
|
|
459
|
+
});
|
|
460
|
+
const DB_INIT_SQL = await ask({
|
|
461
|
+
message: "SQL file to run on startup",
|
|
462
|
+
key: "DB_INIT_SQL", fallback: "", placeholder: "optional, e.g. init.sql",
|
|
463
|
+
});
|
|
423
464
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
# AUTH_BASIC_OPEN=true # Enable Basic Auth
|
|
465
|
+
// ── Auth ──
|
|
466
|
+
p.log.step("Auth");
|
|
427
467
|
|
|
428
|
-
|
|
468
|
+
const AUTH_JWT_SECRET = await ask({
|
|
469
|
+
message: "JWT secret (⚠ change in production!)",
|
|
470
|
+
key: "AUTH_JWT_SECRET", fallback: "restbase",
|
|
471
|
+
});
|
|
472
|
+
const AUTH_JWT_EXP = await ask({
|
|
473
|
+
message: "JWT expiry in seconds (default: 12 hours)",
|
|
474
|
+
key: "AUTH_JWT_EXP", fallback: "43200",
|
|
475
|
+
});
|
|
476
|
+
const AUTH_BASIC_OPEN = await choose({
|
|
477
|
+
message: "Enable Basic Auth?",
|
|
478
|
+
key: "AUTH_BASIC_OPEN", fallback: "true",
|
|
479
|
+
options: [
|
|
480
|
+
{value: "true", label: "true — Basic Auth enabled"},
|
|
481
|
+
{value: "false", label: "false — JWT only"},
|
|
482
|
+
],
|
|
483
|
+
});
|
|
429
484
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
485
|
+
// ── Logging ──
|
|
486
|
+
p.log.step("Logging");
|
|
487
|
+
|
|
488
|
+
const LOG_LEVEL = await choose({
|
|
489
|
+
message: "Log level",
|
|
490
|
+
key: "LOG_LEVEL", fallback: "INFO",
|
|
491
|
+
options: [
|
|
492
|
+
{value: "ERROR", label: "ERROR — errors only"},
|
|
493
|
+
{value: "INFO", label: "INFO — requests + errors"},
|
|
494
|
+
{value: "DEBUG", label: "DEBUG — verbose (headers, body, SQL)"},
|
|
495
|
+
],
|
|
496
|
+
});
|
|
497
|
+
const LOG_CONSOLE = await choose({
|
|
498
|
+
message: "Console output? (auto-disabled in daemon mode)",
|
|
499
|
+
key: "LOG_CONSOLE", fallback: "true",
|
|
500
|
+
options: [
|
|
501
|
+
{value: "true", label: "true — print to console"},
|
|
502
|
+
{value: "false", label: "false — silent"},
|
|
503
|
+
],
|
|
504
|
+
});
|
|
505
|
+
const LOG_FILE = await ask({
|
|
506
|
+
message: "Log file path (auto-configured in daemon mode)",
|
|
507
|
+
key: "LOG_FILE", fallback: "", placeholder: "optional, e.g. log/app.log",
|
|
508
|
+
});
|
|
509
|
+
const LOG_RETAIN_DAYS = await ask({
|
|
510
|
+
message: "Log file retention days",
|
|
511
|
+
key: "LOG_RETAIN_DAYS", fallback: "7",
|
|
512
|
+
});
|
|
435
513
|
|
|
436
|
-
|
|
437
|
-
|
|
514
|
+
// ── Build .env content ──
|
|
515
|
+
const line = (key: string, val: string, comment: string) =>
|
|
516
|
+
val ? `${key}=${val}${comment ? ` # ${comment}` : ""}` : `# ${key}=${comment ? ` # ${comment}` : ""}`;
|
|
517
|
+
|
|
518
|
+
const lines = [
|
|
519
|
+
"# ═══════════════════════════════════════════════════════════",
|
|
520
|
+
"# RestBase Configuration",
|
|
521
|
+
"# ═══════════════════════════════════════════════════════════",
|
|
522
|
+
"",
|
|
523
|
+
"# ── Server ────────────────────────────────────────────────",
|
|
524
|
+
line("SVR_NAME", SVR_NAME, "Instance name"),
|
|
525
|
+
line("SVR_PORT", SVR_PORT, "Server port"),
|
|
526
|
+
line("SVR_STATIC", SVR_STATIC, "Static file directory"),
|
|
527
|
+
line("SVR_API_LIMIT", SVR_API_LIMIT, "Rate limit per API"),
|
|
528
|
+
line("SVR_CORS_ORIGIN", SVR_CORS_ORIGIN, "CORS origins (* = all)"),
|
|
529
|
+
"",
|
|
530
|
+
"# ── Database ──────────────────────────────────────────────",
|
|
531
|
+
line("DB_URL", DB_URL, ""),
|
|
532
|
+
line("DB_AUTH_TABLE", DB_AUTH_TABLE, "Auth table name"),
|
|
533
|
+
line("DB_AUTH_FIELD", DB_AUTH_FIELD, "Owner field"),
|
|
534
|
+
line("DB_AUTH_FIELD_NULL_OPEN", DB_AUTH_FIELD_NULL_OPEN, ""),
|
|
535
|
+
line("DB_INIT_SQL", DB_INIT_SQL, "SQL file on startup"),
|
|
536
|
+
"",
|
|
537
|
+
"# ── Auth ──────────────────────────────────────────────────",
|
|
538
|
+
line("AUTH_JWT_SECRET", AUTH_JWT_SECRET, "⚠ change in production"),
|
|
539
|
+
line("AUTH_JWT_EXP", AUTH_JWT_EXP, "seconds"),
|
|
540
|
+
line("AUTH_BASIC_OPEN", AUTH_BASIC_OPEN, ""),
|
|
541
|
+
"",
|
|
542
|
+
"# ── Logging ───────────────────────────────────────────────",
|
|
543
|
+
line("LOG_LEVEL", LOG_LEVEL, "ERROR / INFO / DEBUG"),
|
|
544
|
+
line("LOG_CONSOLE", LOG_CONSOLE, ""),
|
|
545
|
+
line("LOG_FILE", LOG_FILE, "auto-configured in daemon mode"),
|
|
546
|
+
line("LOG_RETAIN_DAYS", LOG_RETAIN_DAYS, "days"),
|
|
547
|
+
"",
|
|
548
|
+
];
|
|
549
|
+
|
|
550
|
+
writeFileSync(envPath, lines.join("\n"));
|
|
551
|
+
p.outro(`Saved to ${envPath}`);
|
|
438
552
|
}
|
|
439
553
|
|
|
440
554
|
/** version */
|
|
@@ -454,10 +568,10 @@ Usage:
|
|
|
454
568
|
Commands:
|
|
455
569
|
run Start server in foreground (reads .env)
|
|
456
570
|
start Start server in background (daemon mode)
|
|
457
|
-
stop <pid|all> Stop
|
|
458
|
-
status
|
|
459
|
-
log <pid> Tail the log of
|
|
460
|
-
env
|
|
571
|
+
stop <pid|name|all> Stop instance(s) by PID, SVR_NAME, or all
|
|
572
|
+
status Show all running background instances
|
|
573
|
+
log <pid|name> Tail the log of an instance by PID or SVR_NAME
|
|
574
|
+
env Interactive .env configuration (create or reconfigure)
|
|
461
575
|
version Show version
|
|
462
576
|
help Show this help
|
|
463
577
|
|
|
@@ -465,16 +579,20 @@ Examples:
|
|
|
465
579
|
restbase run Start in foreground
|
|
466
580
|
restbase start Start in background (daemon)
|
|
467
581
|
restbase status List running instances with health status
|
|
468
|
-
restbase log 12345 Tail log
|
|
469
|
-
restbase
|
|
582
|
+
restbase log 12345 Tail log by PID
|
|
583
|
+
restbase log my-api Tail log by SVR_NAME
|
|
584
|
+
restbase stop 12345 Stop instance by PID
|
|
585
|
+
restbase stop my-api Stop instance by SVR_NAME
|
|
470
586
|
restbase stop all Stop all background instances
|
|
471
|
-
restbase env
|
|
587
|
+
restbase env Interactive .env setup (reconfigure if exists)
|
|
472
588
|
|
|
473
589
|
Configuration:
|
|
474
590
|
All settings are read from .env in the current working directory.
|
|
475
|
-
Run 'restbase env'
|
|
591
|
+
Run 'restbase env' for interactive configuration (create or update .env).
|
|
476
592
|
|
|
477
|
-
Instance
|
|
593
|
+
Instance discovery:
|
|
594
|
+
Background instances are discovered via 'ps' — no PID files needed.
|
|
595
|
+
Instance details (name, log path, uptime…) are fetched from /api/health.
|
|
478
596
|
`);
|
|
479
597
|
}
|
|
480
598
|
|
|
@@ -495,10 +613,10 @@ switch (command) {
|
|
|
495
613
|
|
|
496
614
|
case "stop":
|
|
497
615
|
if (!arg1) {
|
|
498
|
-
console.error("Usage: restbase stop <pid|all>");
|
|
616
|
+
console.error("Usage: restbase stop <pid|name|all>");
|
|
499
617
|
process.exit(1);
|
|
500
618
|
}
|
|
501
|
-
cmdStop(arg1);
|
|
619
|
+
await cmdStop(arg1);
|
|
502
620
|
process.exit(0);
|
|
503
621
|
break;
|
|
504
622
|
|
|
@@ -509,14 +627,14 @@ switch (command) {
|
|
|
509
627
|
|
|
510
628
|
case "log":
|
|
511
629
|
if (!arg1) {
|
|
512
|
-
console.error("Usage: restbase log <pid>");
|
|
630
|
+
console.error("Usage: restbase log <pid|name>");
|
|
513
631
|
process.exit(1);
|
|
514
632
|
}
|
|
515
633
|
await cmdLog(arg1);
|
|
516
634
|
break;
|
|
517
635
|
|
|
518
636
|
case "env":
|
|
519
|
-
cmdEnv();
|
|
637
|
+
await cmdEnv();
|
|
520
638
|
process.exit(0);
|
|
521
639
|
break;
|
|
522
640
|
|
package/client/README.md
CHANGED
|
@@ -51,7 +51,7 @@ import RestBase, {
|
|
|
51
51
|
```ts
|
|
52
52
|
import RestBase, { eq, gt, or, agg, sel } from "@dtdyq/restbase-client";
|
|
53
53
|
|
|
54
|
-
//
|
|
54
|
+
// 同源部署 — 不需要传 endpoint
|
|
55
55
|
const rb = new RestBase();
|
|
56
56
|
|
|
57
57
|
// 或指定后端地址
|
|
@@ -67,6 +67,16 @@ const list = await products.query()
|
|
|
67
67
|
.orderDesc("price")
|
|
68
68
|
.page(1, 20)
|
|
69
69
|
.data();
|
|
70
|
+
|
|
71
|
+
// ── 多节点负载均衡 ──
|
|
72
|
+
// 所有节点连同一个数据库,请求随机分发
|
|
73
|
+
const rb2 = new RestBase([
|
|
74
|
+
"http://node1:3333",
|
|
75
|
+
"http://node2:3333",
|
|
76
|
+
"http://node3:3333",
|
|
77
|
+
]);
|
|
78
|
+
await rb2.auth.login("admin", "admin"); // 登录一次,token 共享
|
|
79
|
+
const list2 = await rb2.table("products").query().data(); // 随机打到某个节点
|
|
70
80
|
```
|
|
71
81
|
|
|
72
82
|
---
|
|
@@ -74,20 +84,32 @@ const list = await products.query()
|
|
|
74
84
|
## RestBase — 主入口
|
|
75
85
|
|
|
76
86
|
```ts
|
|
77
|
-
|
|
78
|
-
const rb = new RestBase(
|
|
87
|
+
// 同源部署(Vite proxy / 静态托管)
|
|
88
|
+
const rb = new RestBase();
|
|
89
|
+
|
|
90
|
+
// 单个 endpoint
|
|
91
|
+
const rb = new RestBase("http://localhost:3333");
|
|
92
|
+
|
|
93
|
+
// 多个 endpoint — 负载均衡(每次请求随机选一个节点)
|
|
94
|
+
const rb = new RestBase([
|
|
95
|
+
"http://localhost:3333",
|
|
96
|
+
"http://localhost:8080",
|
|
97
|
+
"http://localhost:9090",
|
|
98
|
+
]);
|
|
79
99
|
```
|
|
80
100
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
|
84
|
-
|
|
85
|
-
| `rb.
|
|
86
|
-
| `rb.
|
|
87
|
-
| `rb.
|
|
88
|
-
| `rb.
|
|
89
|
-
| `rb.
|
|
90
|
-
| `rb.
|
|
101
|
+
> 多 endpoint 模式要求所有服务端实例连接同一个数据库。客户端共享同一套 auth 状态(token / Basic Auth),每次请求随机分发到不同节点,分散单节点压力。
|
|
102
|
+
|
|
103
|
+
| 方法 | 返回类型 | 说明 |
|
|
104
|
+
|:----------------------|:------------------------------------------|:------------------------------------|
|
|
105
|
+
| `rb.auth` | `AuthClient` | 鉴权客户端 |
|
|
106
|
+
| `rb.table<T>(name)` | `TableClient<T>` | 获取表操作客户端 |
|
|
107
|
+
| `rb.health()` | `Promise<ApiResponse>` | 健康检查(返回 name/port/pid/uptime/mem/cpu) |
|
|
108
|
+
| `rb.tables()` | `Promise<ApiResponse<TableMeta[]>>` | 获取所有表元数据(不含 users) |
|
|
109
|
+
| `rb.tableMeta(name)` | `Promise<ApiResponse<TableMeta \| null>>` | 获取单表元数据 |
|
|
110
|
+
| `rb.syncMeta()` | `Promise<ApiResponse<TableMeta[]>>` | 运行时同步 DB 表结构 |
|
|
111
|
+
| `rb.setHeader(k, v)` | `this` | 设置自定义请求头 |
|
|
112
|
+
| `rb.setRequestId(id)` | `this` | 设置请求追踪 ID(`X-Request-Id`) |
|
|
91
113
|
|
|
92
114
|
**TableMeta 结构:**
|
|
93
115
|
|
package/client/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dtdyq/restbase-client",
|
|
3
|
-
"version": "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",
|
|
@@ -451,14 +451,20 @@ export class AuthClient {
|
|
|
451
451
|
══════════════════════════════════════════════════════════════ */
|
|
452
452
|
|
|
453
453
|
class HttpClient {
|
|
454
|
-
private
|
|
454
|
+
private _urls: string[];
|
|
455
455
|
private _token: string | null = null;
|
|
456
456
|
private _basicAuth: string | null = null;
|
|
457
457
|
private _requestId?: string;
|
|
458
458
|
private _headers: Record<string, string> = {};
|
|
459
459
|
|
|
460
|
-
constructor(
|
|
461
|
-
|
|
460
|
+
constructor(urls: string | string[]) {
|
|
461
|
+
const list = Array.isArray(urls) ? urls : [urls];
|
|
462
|
+
this._urls = list.map((u) => u.replace(/\/+$/, ""));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Pick a random base URL (load balancing) */
|
|
466
|
+
private _pickUrl(): string {
|
|
467
|
+
return this._urls[Math.floor(Math.random() * this._urls.length)]!;
|
|
462
468
|
}
|
|
463
469
|
|
|
464
470
|
setToken(token: string | null): void {
|
|
@@ -471,7 +477,11 @@ class HttpClient {
|
|
|
471
477
|
}
|
|
472
478
|
|
|
473
479
|
setBasicAuth(username: string, password: string): void {
|
|
474
|
-
|
|
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);
|
|
475
485
|
this._token = null;
|
|
476
486
|
}
|
|
477
487
|
|
|
@@ -511,7 +521,7 @@ class HttpClient {
|
|
|
511
521
|
}
|
|
512
522
|
|
|
513
523
|
private async _fetch<T>(method: string, path: string, params?: Record<string, string>, body?: unknown): Promise<ApiResponse<T>> {
|
|
514
|
-
let url = `${this.
|
|
524
|
+
let url = `${this._pickUrl()}${path}`;
|
|
515
525
|
if (params && Object.keys(params).length > 0) {
|
|
516
526
|
url += `?${new URLSearchParams(params).toString()}`;
|
|
517
527
|
}
|
|
@@ -526,12 +536,35 @@ class HttpClient {
|
|
|
526
536
|
RestBase — 主入口
|
|
527
537
|
══════════════════════════════════════════════════════════════ */
|
|
528
538
|
|
|
539
|
+
/**
|
|
540
|
+
* RestBase — 前端客户端主入口
|
|
541
|
+
*
|
|
542
|
+
* 支持三种构造方式:
|
|
543
|
+
*
|
|
544
|
+
* ```ts
|
|
545
|
+
* // 1. 同源部署(无参数)
|
|
546
|
+
* const rb = new RestBase();
|
|
547
|
+
*
|
|
548
|
+
* // 2. 单个 endpoint
|
|
549
|
+
* const rb = new RestBase("http://localhost:3333");
|
|
550
|
+
*
|
|
551
|
+
* // 3. 多个 endpoint(负载均衡,每次请求随机选一个)
|
|
552
|
+
* const rb = new RestBase([
|
|
553
|
+
* "http://localhost:3333",
|
|
554
|
+
* "http://localhost:8080",
|
|
555
|
+
* "http://localhost:9090",
|
|
556
|
+
* ]);
|
|
557
|
+
* ```
|
|
558
|
+
*
|
|
559
|
+
* 多 endpoint 模式下,所有服务端实例应连接同一个数据库。
|
|
560
|
+
* 客户端共享同一套 auth 状态,每次请求随机分发到不同节点。
|
|
561
|
+
*/
|
|
529
562
|
export class RestBase {
|
|
530
563
|
readonly auth: AuthClient;
|
|
531
564
|
private _http: HttpClient;
|
|
532
565
|
|
|
533
|
-
constructor(endpoint
|
|
534
|
-
this._http = new HttpClient(endpoint);
|
|
566
|
+
constructor(endpoint?: string | string[]) {
|
|
567
|
+
this._http = new HttpClient(endpoint ?? "");
|
|
535
568
|
this.auth = new AuthClient(this._http);
|
|
536
569
|
}
|
|
537
570
|
|
|
@@ -541,8 +574,8 @@ export class RestBase {
|
|
|
541
574
|
}
|
|
542
575
|
|
|
543
576
|
/** 健康检查 */
|
|
544
|
-
async health(): Promise<ApiResponse
|
|
545
|
-
return this._http.get
|
|
577
|
+
async health(): Promise<ApiResponse> {
|
|
578
|
+
return this._http.get("/api/health");
|
|
546
579
|
}
|
|
547
580
|
|
|
548
581
|
/** 获取所有表元数据(不含 users 表) */
|
|
@@ -560,13 +593,13 @@ export class RestBase {
|
|
|
560
593
|
return this._http.get<TableMeta[]>("/api/meta/sync");
|
|
561
594
|
}
|
|
562
595
|
|
|
563
|
-
/**
|
|
596
|
+
/** 设置自定义请求头(当前 endpoint) */
|
|
564
597
|
setHeader(key: string, value: string): this {
|
|
565
598
|
this._http.setHeader(key, value);
|
|
566
599
|
return this;
|
|
567
600
|
}
|
|
568
601
|
|
|
569
|
-
/** 设置请求追踪 ID */
|
|
602
|
+
/** 设置请求追踪 ID(当前 endpoint) */
|
|
570
603
|
setRequestId(id: string): this {
|
|
571
604
|
this._http.setRequestId(id);
|
|
572
605
|
return this;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dtdyq/restbase",
|
|
3
|
-
"version": "2.
|
|
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",
|
|
@@ -18,10 +18,12 @@
|
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
20
|
"dev": "bun run --watch src/server.ts",
|
|
21
|
+
"start": "bun run src/server.ts",
|
|
21
22
|
"test": "bun test src/rest.test.ts",
|
|
22
23
|
"build": "bun build --compile --minify --sourcemap ./bin/restbase.ts --outfile restbase"
|
|
23
24
|
},
|
|
24
25
|
"dependencies": {
|
|
26
|
+
"@clack/prompts": "^1.0.0",
|
|
25
27
|
"@hono/zod-validator": "^0.4.0",
|
|
26
28
|
"hono": "^4.6.0",
|
|
27
29
|
"pino": "^10.3.1",
|
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/logger.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* - 日志保留天数(LOG_RETAIN_DAYS,默认 7)
|
|
8
8
|
* - 日志等级(LOG_LEVEL)
|
|
9
9
|
*
|
|
10
|
+
* pino-roll 启用 symlink,当前日志始终通过 current.log 软链接访问。
|
|
11
|
+
*
|
|
10
12
|
* 导出 log 实例,全局使用 log.info / log.debug / log.error / log.fatal
|
|
11
13
|
*/
|
|
12
14
|
import pino from "pino";
|
|
@@ -57,7 +59,7 @@ if (cfg.logConsole) {
|
|
|
57
59
|
});
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
/* 文件(pino-roll 滚动写入 NDJSON
|
|
62
|
+
/* 文件(pino-roll 滚动写入 NDJSON,symlink 指向当前文件) */
|
|
61
63
|
if (cfg.logFile) {
|
|
62
64
|
const {default: buildRollStream} = await import("pino-roll");
|
|
63
65
|
const rollStream = await buildRollStream({
|
|
@@ -67,6 +69,7 @@ if (cfg.logFile) {
|
|
|
67
69
|
dateFormat: "yyyy-MM-dd",
|
|
68
70
|
limit: {count: cfg.logRetainDays},
|
|
69
71
|
mkdir: true,
|
|
72
|
+
symlink: true,
|
|
70
73
|
});
|
|
71
74
|
|
|
72
75
|
streams.push({
|
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
|
@@ -21,6 +21,7 @@ import {Hono} from "hono";
|
|
|
21
21
|
import {requestId} from "hono/request-id";
|
|
22
22
|
import {cors} from "hono/cors";
|
|
23
23
|
import {serveStatic} from "hono/bun";
|
|
24
|
+
import {resolve, dirname} from "path";
|
|
24
25
|
import {type ApiRes, type AppEnv, AppError, cfg, ok, reqStore} from "./types.ts";
|
|
25
26
|
import {log} from "./logger.ts";
|
|
26
27
|
import {getTableMetaByName, getTablesMeta, initDb, syncTablesMeta} from "./db.ts";
|
|
@@ -50,7 +51,7 @@ const app = new Hono<AppEnv>();
|
|
|
50
51
|
/* ═══════════ 3. CORS 中间件 ═══════════ */
|
|
51
52
|
|
|
52
53
|
app.use("*", cors({
|
|
53
|
-
origin: "*",
|
|
54
|
+
origin: cfg.corsOrigin === "*" ? "*" : cfg.corsOrigin.split(",").map((s) => s.trim()),
|
|
54
55
|
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
55
56
|
allowHeaders: ["Content-Type", "Authorization", "X-Request-Id"],
|
|
56
57
|
exposeHeaders: ["X-Request-Id"],
|
|
@@ -68,8 +69,26 @@ if (cfg.apiLimit > 0) {
|
|
|
68
69
|
/** API 路径 → { tokens: 剩余令牌, lastRefill: 上次填充时间戳 } */
|
|
69
70
|
const buckets = new Map<string, { tokens: number; lastRefill: number }>();
|
|
70
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
|
+
|
|
71
90
|
app.use("/api/*", async (c, next) => {
|
|
72
|
-
const key = `${c.req.method} ${c.req.path}`;
|
|
91
|
+
const key = `${c.req.method} ${normalizePath(c.req.path)}`;
|
|
73
92
|
const now = Date.now();
|
|
74
93
|
let bucket = buckets.get(key);
|
|
75
94
|
|
|
@@ -173,7 +192,8 @@ app.get("/api/health", (c) => {
|
|
|
173
192
|
name: cfg.name || undefined,
|
|
174
193
|
port: cfg.port,
|
|
175
194
|
pid: process.pid,
|
|
176
|
-
|
|
195
|
+
cwd: process.cwd(),
|
|
196
|
+
logFile: cfg.logFile ? resolve(dirname(cfg.logFile), "current.log") : undefined,
|
|
177
197
|
startedAt,
|
|
178
198
|
uptime: uptimeSec,
|
|
179
199
|
memory: {
|
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) */
|